Написание веб-приложений

Введение

В этом руководстве рассматриваются следующие темы:

Предполагаемые знания:

Начало работы

На данный момент для запуска 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 генерируется действиями шаблона. Например, он автоматически экранирует любой знак больше (>), заменяя его на &gt;, чтобы убедиться, что данные пользователя не испортили 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' и быть перенаправленным на новосозданную страницу.

Дополнительные задачи

Вот несколько простых задач, которые вы можете решить самостоятельно:

GoRu.dev Golang на русском

На сайте представлена адаптированная под русский язык документация языка программирования Golang