Написание веб-приложений
Введение
В этом руководстве рассматриваются следующие темы:
- Создание структуры данных с методами загрузки и сохранения
- Использование пакета
net/httpдля создания веб-приложений - Использование пакета
html/templateдля обработки HTML-шаблонов - Использование пакета
regexpдля валидации пользовательского ввода - Использование замыканий
Предполагаемые знания:
- Опыт программирования
- Понимание базовых веб-технологий (HTTP, HTML)
- Некоторые знания командной строки UNIX/DOS
Начало работы
На данный момент для запуска Go требуется машина с FreeBSD, Linux, macOS или Windows.
Мы будем использовать $ для обозначения командной строки.
Установите Go (см. Инструкции по установке).
Создайте новую директорию для этого руководства внутри вашего GOPATH и перейдите в неё:
$ mkdir gowiki $ cd gowiki
Создайте файл с именем wiki.go, откройте его в вашем любимом редакторе и
добавьте следующие строки:
package main import ( "fmt" "os" )
Мы импортируем пакеты fmt и os из стандартной библиотеки Go.
Позднее, по мере реализации дополнительной функциональности, мы добавим больше пакетов в эту
декларацию import.
Структуры данных
Начнем с определения структур данных. Вики состоит из серии взаимосвязанных страниц,
каждая из которых имеет заголовок и тело (содержание страницы).
Здесь мы определяем Page как структуру с двумя полями, представляющими
заголовок и тело.
type Page struct {
Title string
Body []byte
}
Тип []byte означает "срез типа byte".
(См. Срезы: использование и
внутреннее устройство для получения дополнительной информации о срезах.)
Элемент Body имеет тип []byte, а не string,
потому что именно такой тип ожидается библиотеками io, которые мы будем использовать,
как вы увидите ниже.
Структура Page описывает, как данные страниц будут храниться в памяти.
Но как насчет постоянного хранения? Мы можем решить эту задачу, создав
метод save для Page:
func (p *Page) save() error {
filename := p.Title + ".txt"
return os.WriteFile(filename, p.Body, 0600)
}
Сигнатура этого метода читается так: "Это метод с именем save, который
принимает в качестве получателя p, указатель на Page.
Он не принимает параметров и возвращает значение типа error."
Этот метод сохраняет Body Page в текстовый файл.
Для простоты мы будем использовать Title в качестве имени файла.
Метод save возвращает значение типа error, потому что
это тип возвращаемого значения функции WriteFile (стандартная функция библиотеки,
которая записывает срез байтов в файл). Метод save возвращает значение ошибки,
чтобы приложение могло обработать его в случае возникновения проблем при записи файла.
Если всё прошло успешно, Page.save() вернёт nil
(нулевое значение для указателей, интерфейсов и некоторых других типов).
Восьмеричный целочисленный литерал 0600, передаваемый в качестве третьего параметра
в WriteFile, указывает, что файл должен быть создан с правами чтения и записи
только для текущего пользователя. (См. man-страницу Unix open(2) для подробностей.)
Помимо сохранения страниц, нам также нужно будет загружать их:
func loadPage(title string) *Page {
filename := title + ".txt"
body, _ := os.ReadFile(filename)
return &Page{Title: title, Body: body}
}
Функция loadPage формирует имя файла из параметра title, читает содержимое файла в новую
переменную body и возвращает указатель на литерал Page, созданный с соответствующими
значениями title и body.
Функции могут возвращать несколько значений. Стандартная функция библиотеки os.ReadFile возвращает
[]byte и error. В функции loadPage ошибка пока не обрабатывается;
используется «пустой идентификатор», обозначенный символом подчеркивания (_), чтобы отбросить
возвращаемое значение ошибки (по сути, присвоить его ничему).
Но что произойдет, если ReadFile столкнется с ошибкой? Например, файл может не существовать. Не стоит
игнорировать такие ошибки. Давайте изменим функцию так, чтобы она возвращала *Page и error.
func loadPage(title string) (*Page, error) {
filename := title + ".txt"
body, err := os.ReadFile(filename)
if err != nil {
return nil, err
}
return &Page{Title: title, Body: body}, nil
}
Вызывающие функции теперь могут проверить второй параметр; если он равен nil, значит, страница была
успешно загружена. Если нет, то будет значение error, которое может быть обработано вызывающим кодом
(подробности см. в спецификации языка).
На этом этапе у нас есть простая структура данных и возможность сохранять и загружать данные из файла. Напишем
функцию main для тестирования написанного:
func main() {
p1 := &Page{Title: "TestPage", Body: []byte("This is a sample Page.")}
p1.save()
p2, _ := loadPage("TestPage")
fmt.Println(string(p2.Body))
}
После компиляции и выполнения этого кода будет создан файл с именем TestPage.txt, содержащий содержимое
p1. Затем файл будет прочитан в структуру p2, и его элемент Body будет
выведен на экран.
Скомпилировать и запустить программу можно следующим образом:
$ go build wiki.go $ ./wiki This is a sample Page.
(Если вы используете Windows, то для запуска программы необходимо ввести "wiki" без "./".)
Нажмите здесь, чтобы просмотреть написанный нами код до этого момента.
Знакомство с пакетом net/http (промежуточный раздел)
Вот полный рабочий пример простого веб-сервера:
<span class="comment">//go:build ignore</span>
package main
import (
"fmt"
"log"
"net/http"
)
func handler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hi there, I love %s!", r.URL.Path[1:])
}
func main() {
http.HandleFunc("/", handler)
log.Fatal(http.ListenAndServe(":8080", nil))
}
Функция main начинается с вызова http.HandleFunc, который указывает пакету
http обрабатывать все запросы к корневому URL ("/") с помощью функции handler.
Затем вызывается http.ListenAndServe, указывая, что сервер должен слушать порт 8080 на любом интерфейсе
(":8080"). (Не беспокойтесь о втором параметре, nil, на данный момент.)
Эта функция будет блокироваться до завершения работы программы.
ListenAndServe всегда возвращает ошибку, поскольку она возвращает значение только в случае
возникновения непредвиденной ошибки.
Чтобы записать эту ошибку в журнал, мы оборачиваем вызов функции в log.Fatal.
Функция handler имеет тип http.HandlerFunc.
Она принимает http.ResponseWriter и http.Request в качестве аргументов.
Значение http.ResponseWriter собирает ответ HTTP-сервера; записывая в него данные, мы отправляем их
клиенту.
http.Request — это структура данных, представляющая HTTP-запрос клиента.
r.URL.Path — это компонент пути URL-запроса. Завершающий [1:] означает:
«создать подсрез Path с первого символа до конца».
Это удаляет начальный символ "/" из имени пути.
Если запустить эту программу и открыть URL:
http://localhost:8080/monkeys
программа отобразит страницу со следующим содержимым:
Hi there, I love monkeys!
Использование net/http для обслуживания вики-страниц
Для использования пакета net/http он должен быть импортирован:
import ( "fmt" "os" "log" <b>"net/http"</b> )
Создадим обработчик viewHandler, который позволит пользователям
просматривать вики-страницу. Он будет обрабатывать URL, начинающиеся с "/view/".
func viewHandler(w http.ResponseWriter, r *http.Request) {
title := r.URL.Path[len("/view/"):]
p, _ := loadPage(title)
fmt.Fprintf(w, "<h1>%s</h1><div>%s</div>", p.Title, p.Body)
}
Снова обратите внимание на использование _ для игнорирования
возвращаемого значения error из функции loadPage. Это сделано здесь ради простоты
и обычно считается плохой практикой. Мы исправим это позже.
Сначала эта функция извлекает название страницы из r.URL.Path,
компонент пути запроса URL.
Компонент Path обрезается с помощью [len("/view/"):] для удаления
ведущего компонента "/view/" из пути запроса.
Это необходимо, потому что путь всегда будет начинаться с "/view/",
который не является частью названия страницы.
Затем функция загружает данные страницы, форматирует её с помощью простого
строкового HTML и записывает в w, который является http.ResponseWriter.
Чтобы использовать этот обработчик, перепишем функцию main, чтобы
инициализировать http, используя viewHandler для обработки
любых запросов по пути /view/.
func main() {
http.HandleFunc("/view/", viewHandler)
log.Fatal(http.ListenAndServe(":8080", nil))
}
Нажмите здесь, чтобы посмотреть написанный нами код.
Создадим некоторые данные страницы (в файле test.txt), скомпилируем код и
попробуем обслужить вики-страницу.
Откройте файл test.txt в редакторе и сохраните строку "Hello world" (без кавычек)
в нем.
$ go build wiki.go $ ./wiki
(Если вы используете Windows, необходимо вводить "wiki" без
"./" для запуска программы.)
При работе этого веб-сервера посещение http://localhost:8080/view/test
должно отобразить страницу с названием "test", содержащую слова "Hello world".
Редактирование страниц
Вики-страница не является вики-страницей без возможности редактирования. Создадим два новых
обработчика: один с именем editHandler для отображения формы редактирования страницы,
и другой с именем saveHandler для сохранения данных, введённых через форму.
Сначала добавим их в функцию main():
func main() {
http.HandleFunc("/view/", viewHandler)
http.HandleFunc("/edit/", editHandler)
http.HandleFunc("/save/", saveHandler)
log.Fatal(http.ListenAndServe(":8080", nil))
}
Функция editHandler загружает страницу
(или, если она не существует, создаёт пустую структуру Page),
и отображает HTML-форму.
func editHandler(w http.ResponseWriter, r *http.Request) {
title := r.URL.Path[len("/edit/"):]
p, err := loadPage(title)
if err != nil {
p = &Page{Title: title}
}
fmt.Fprintf(w, "<h1>Editing %s</h1>"+
"<form action=\"/save/%s\" method=\"POST\">"+
"<textarea name=\"body\">%s</textarea><br>"+
"<input type=\"submit\" value=\"Save\">"+
"</form>",
p.Title, p.Title, p.Body)
}
Эта функция будет работать нормально, но весь этот жёстко закодированный HTML выглядит некрасиво. Конечно, есть лучший способ.
Пакет html/template
Пакет html/template является частью стандартной библиотеки Go.
Мы можем использовать html/template для хранения HTML в отдельном файле,
что позволит изменять макет страницы редактирования без изменения
основного Go-кода.
Сначала необходимо добавить html/template в список импортов. Мы также больше не будем использовать
fmt, поэтому его нужно удалить.
import ( <b>"html/template"</b> "os" "net/http" )
Создадим файл шаблона, содержащий HTML-форму.
Откройте новый файл с именем edit.html и добавьте следующие строки:
<h1>Editing {{.Title}}</h1>
<form action="/save/{{.Title}}" method="POST">
<div><textarea name="body" rows="20" cols="80">{{printf "%s" .Body}}</textarea></div>
<div><input type="submit" value="Save"></div>
</form>
Измените editHandler так, чтобы он использовал шаблон вместо жестко закодированного HTML:
func editHandler(w http.ResponseWriter, r *http.Request) {
title := r.URL.Path[len("/edit/"):]
p, err := loadPage(title)
if err != nil {
p = &Page{Title: title}
}
t, _ := template.ParseFiles("edit.html")
t.Execute(w, p)
}
Функция template.ParseFiles прочитает содержимое файла edit.html и вернет *template.Template.
Метод t.Execute выполняет шаблон, записывая сгенерированный HTML в http.ResponseWriter.
Точечные идентификаторы .Title и .Body ссылаются на p.Title и
p.Body.
Директивы шаблона заключены в двойные фигурные скобки.
Инструкция printf "%s" .Body является вызовом функции, который выводит .Body как строку
вместо потока байтов, аналогично вызову fmt.Printf.
Пакет html/template помогает гарантировать, что только безопасный и корректно выглядящий HTML
генерируется действиями шаблона. Например, он автоматически экранирует любой знак больше (>),
заменяя его на >, чтобы убедиться, что данные пользователя не испортили HTML формы.
Поскольку мы теперь работаем с шаблонами, создадим шаблон для нашего viewHandler с именем view.html:
<h1>{{.Title}}</h1>
<p>[<a href="/edit/{{.Title}}">edit</a>]</p>
<div>{{printf "%s" .Body}}</div>
Измените viewHandler соответствующим образом:
func viewHandler(w http.ResponseWriter, r *http.Request) {
title := r.URL.Path[len("/view/"):]
p, _ := loadPage(title)
t, _ := template.ParseFiles("view.html")
t.Execute(w, p)
}
Обратите внимание, что мы использовали почти такой же код шаблонизации в обоих обработчиках. Удалим эту дублированную логику, переместив код шаблонизации в отдельную функцию:
func renderTemplate(w http.ResponseWriter, tmpl string, p *Page) {
t, _ := template.ParseFiles(tmpl + ".html")
t.Execute(w, p)
}
И измените обработчики так, чтобы они использовали эту функцию:
func viewHandler(w http.ResponseWriter, r *http.Request) {
title := r.URL.Path[len("/view/"):]
p, _ := loadPage(title)
renderTemplate(w, "view", p)
}
func editHandler(w http.ResponseWriter, r *http.Request) {
title := r.URL.Path[len("/edit/"):]
p, err := loadPage(title)
if err != nil {
p = &Page{Title: title}
}
renderTemplate(w, "edit", p)
}
Если закомментировать регистрацию нашего нереализованного обработчика сохранения в main, мы снова
сможем собрать и протестировать программу.
Нажмите здесь, чтобы просмотреть написанный нами код до этого момента.
Обработка несуществующих страниц
Что произойдет, если вы перейдете по адресу
/view/APageThatDoesntExist? Вы увидите страницу с HTML-кодом. Это происходит потому, что функция
игнорирует возвращаемое значение ошибки из loadPage и продолжает пытаться заполнить шаблон без данных.
Вместо этого, если запрашиваемая страница не существует, клиент должен быть перенаправлен на страницу
редактирования, чтобы можно было создать содержимое:
func viewHandler(w http.ResponseWriter, r *http.Request) {
title := r.URL.Path[len("/view/"):]
p, err := loadPage(title)
if err != nil {
http.Redirect(w, r, "/edit/"+title, http.StatusFound)
return
}
renderTemplate(w, "view", p)
}
Функция http.Redirect добавляет HTTP-статус код
http.StatusFound (302) и заголовок Location
в HTTP-ответ.
Сохранение страниц
Функция saveHandler будет обрабатывать отправку форм,
находящихся на страницах редактирования. После раскомментирования соответствующей строки в
main, реализуем обработчик:
func saveHandler(w http.ResponseWriter, r *http.Request) {
title := r.URL.Path[len("/save/"):]
body := r.FormValue("body")
p := &Page{Title: title, Body: []byte(body)}
p.save()
http.Redirect(w, r, "/view/"+title, http.StatusFound)
}
Заголовок страницы (указанный в URL) и единственное поле формы,
Body, сохраняются в новой структуре Page.
Затем вызывается метод save() для записи данных в файл,
и клиент перенаправляется на страницу /view/.
Значение, возвращаемое функцией FormValue, имеет тип string.
Перед тем как оно поместится в структуру Page, необходимо преобразовать его в []byte.
Для этого используется выражение []byte(body).
Обработка ошибок
В нашей программе есть несколько мест, где ошибки игнорируются. Это плохая практика, особенно потому, что при возникновении ошибки программа будет вести себя непредсказуемо. Более правильным решением будет обработка ошибок и возврат сообщения об ошибке пользователю. Таким образом, если что-то пойдет не так, сервер будет работать именно так, как задумано, и пользователь будет уведомлен об этом.
Сначала обработаем ошибки в функции renderTemplate:
func renderTemplate(w http.ResponseWriter, tmpl string, p *Page) {
t, err := template.ParseFiles(tmpl + ".html")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
err = t.Execute(w, p)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
Функция http.Error отправляет указанный HTTP-код ответа
(в данном случае "Internal Server Error") и сообщение об ошибке.
Уже само решение вынести это в отдельную функцию оправдано.
Теперь исправим saveHandler:
func saveHandler(w http.ResponseWriter, r *http.Request) {
title := r.URL.Path[len("/save/"):]
body := r.FormValue("body")
p := &Page{Title: title, Body: []byte(body)}
err := p.save()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/view/"+title, http.StatusFound)
}
Любые ошибки, возникшие во время вызова p.save(), будут переданы пользователю.
Кэширование шаблонов
В этом коде есть неэффективность: функция renderTemplate вызывает
ParseFiles при каждом рендеринге страницы.
Более эффективный подход — вызвать ParseFiles один раз при инициализации программы,
разбирая все шаблоны в один *Template.
Затем можно использовать метод
ExecuteTemplate
для рендеринга конкретного шаблона.
Сначала создадим глобальную переменную под названием templates и проинициализируем её вызовом
ParseFiles.
var templates = template.Must(template.ParseFiles("edit.html", "view.html"))
Функция template.Must является удобной обёрткой, которая вызывает панику при передаче
ненулевого значения error, и в противном случае возвращает
*Template без изменений. В данном случае паника уместна; если шаблоны не могут быть загружены,
единственным разумным действием будет завершить выполнение программы.
Функция ParseFiles принимает любое количество строковых аргументов,
определяющих файлы шаблонов, и разбирает эти файлы в шаблоны, именуя их согласно базовому имени файла.
Если мы добавим новые шаблоны в программу, их имена нужно будет добавить в аргументы вызова
ParseFiles.
Затем мы изменяем функцию renderTemplate, чтобы она вызывала метод
templates.ExecuteTemplate с именем соответствующего шаблона:
func renderTemplate(w http.ResponseWriter, tmpl string, p *Page) {
err := templates.ExecuteTemplate(w, tmpl+".html", p)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
Обратите внимание, что имя шаблона — это имя файла шаблона, поэтому мы должны
добавить ".html" к аргументу tmpl.
Проверка данных
Как можно было заметить, эта программа имеет серьезную уязвимость безопасности: пользователь может указать произвольный путь для чтения/записи на сервере. Чтобы устранить эту проблему, можно написать функцию для проверки заголовка с помощью регулярного выражения.
Сначала добавьте "regexp" в список import.
Затем мы можем создать глобальную переменную для хранения нашего регулярного выражения:
var validPath = regexp.MustCompile("^/(edit|save|view)/([a-zA-Z0-9]+)$")
Функция regexp.MustCompile разбирает и компилирует регулярное выражение,
и возвращает regexp.Regexp.
MustCompile отличается от Compile тем, что она вызывает
panic, если компиляция выражения не удалась, тогда как Compile
возвращает error в качестве второго параметра.
Теперь напишем функцию, которая использует выражение validPath
для проверки пути и извлечения заголовка страницы:
func getTitle(w http.ResponseWriter, r *http.Request) (string, error) {
m := validPath.FindStringSubmatch(r.URL.Path)
if m == nil {
http.NotFound(w, r)
return "", errors.New("invalid Page Title")
}
return m[2], nil <span class="comment">// The title is the second subexpression.</span>
}
Если заголовок действителен, он будет возвращен вместе со значением ошибки nil.
Если заголовок недействителен, функция запишет ошибку "404 Not Found" в HTTP-соединение и
вернет ошибку обработчику. Чтобы создать новую ошибку, необходимо импортировать пакет
errors.
Добавим вызов getTitle в каждый из обработчиков:
func viewHandler(w http.ResponseWriter, r *http.Request) {
title, err := getTitle(w, r)
if err != nil {
return
}
p, err := loadPage(title)
if err != nil {
http.Redirect(w, r, "/edit/"+title, http.StatusFound)
return
}
renderTemplate(w, "view", p)
}
func editHandler(w http.ResponseWriter, r *http.Request) {
title, err := getTitle(w, r)
if err != nil {
return
}
p, err := loadPage(title)
if err != nil {
p = &Page{Title: title}
}
renderTemplate(w, "edit", p)
}
func saveHandler(w http.ResponseWriter, r *http.Request) {
title, err := getTitle(w, r)
if err != nil {
return
}
body := r.FormValue("body")
p := &Page{Title: title, Body: []byte(body)}
err = p.save()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/view/"+title, http.StatusFound)
}
Введение в литералы функций и замыкания
Обработка условий ошибок в каждом обработчике приводит к повторению большого объема кода. А что если мы сможем обернуть каждый из обработчиков функцией, которая выполняет эту проверку и обработку ошибок? Литералы функций в Go предоставляют мощный способ абстрагирования функциональности, который может помочь нам здесь.
Сначала перепишем определения функций каждого из обработчиков, чтобы они принимали строку заголовка:
func viewHandler(w http.ResponseWriter, r *http.Request, title string) func editHandler(w http.ResponseWriter, r *http.Request, title string) func saveHandler(w http.ResponseWriter, r *http.Request, title string)
Теперь определим обёртку, которая принимает функцию указанного выше типа и возвращает
функцию типа http.HandlerFunc (подходит для передачи функции
http.HandleFunc):
func makeHandler(fn func (http.ResponseWriter, *http.Request, string)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Здесь мы извлечём название страницы из запроса,
// и вызовем предоставленный обработчик 'fn'
}
}
Возвращаемая функция называется замыканием (closure), потому что она захватывает значения, определённые вне её. В
данном случае переменная fn (единственный аргумент функции makeHandler) захвачена
замыканием. Переменная fn будет одной из наших функций сохранения, редактирования или просмотра.
Теперь мы можем взять код из функции getTitle и использовать его здесь
(с некоторыми небольшими изменениями):
func makeHandler(fn func(http.ResponseWriter, *http.Request, string)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
m := validPath.FindStringSubmatch(r.URL.Path)
if m == nil {
http.NotFound(w, r)
return
}
fn(w, r, m[2])
}
}
Замыкание, возвращаемое функцией makeHandler, является функцией, которая принимает
http.ResponseWriter и http.Request (иными словами, http.HandlerFunc).
Замыкание извлекает title из пути запроса и проверяет его с помощью регулярного выражения validPath.
Если title недействителен, ошибка будет записана в ResponseWriter с использованием функции
http.NotFound.
Если title действителен, будет вызвана захваченная функция обработчика fn с аргументами
ResponseWriter,
Request и title.
Теперь мы можем обернуть функции обработчиков с помощью makeHandler в функции main,
до их регистрации в пакете http:
func main() {
http.HandleFunc("/view/", makeHandler(viewHandler))
http.HandleFunc("/edit/", makeHandler(editHandler))
http.HandleFunc("/save/", makeHandler(saveHandler))
log.Fatal(http.ListenAndServe(":8080", nil))
}
Наконец мы удаляем вызовы getTitle из функций обработчиков,
делая их гораздо проще:
func viewHandler(w http.ResponseWriter, r *http.Request, title string) {
p, err := loadPage(title)
if err != nil {
http.Redirect(w, r, "/edit/"+title, http.StatusFound)
return
}
renderTemplate(w, "view", p)
}
func editHandler(w http.ResponseWriter, r *http.Request, title string) {
p, err := loadPage(title)
if err != nil {
p = &Page{Title: title}
}
renderTemplate(w, "edit", p)
}
func saveHandler(w http.ResponseWriter, r *http.Request, title string) {
body := r.FormValue("body")
p := &Page{Title: title, Body: []byte(body)}
err := p.save()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/view/"+title, http.StatusFound)
}
Попробуйте!
Нажмите здесь, чтобы просмотреть окончательный код.
Перекомпилируйте код и запустите приложение:
$ go build wiki.go $ ./wiki
Посещение адреса http://localhost:8080/view/ANewPage должно открыть форму редактирования страницы. После этого вы сможете ввести какой-либо текст, нажать 'Save' и быть перенаправленным на новосозданную страницу.
Дополнительные задачи
Вот несколько простых задач, которые вы можете решить самостоятельно:
- Храните шаблоны в каталоге
tmpl/, а данные страниц вdata/. - Добавьте обработчик, чтобы корневая страница веб-сайта перенаправляла на
/view/FrontPage. - Улучшите шаблоны страниц, сделав их корректным HTML и добавив некоторые правила CSS.
- Реализуйте связывание между страницами, преобразуя экземпляры
[PageName]в
<a href="https://go.dev/view/PageName">PageName</a>. (подсказка: можно использоватьregexp.ReplaceAllFuncдля этого)