Это многостраничный печатный вид этого раздела. Нажмите что бы печатать.
Изучение языка Go
1 - Alex Edwards Let's Go часть 1
Основы Web приложения
package main
import (
"log" //пакет для логирования
"net/http" //пакет для создания web приложений
)
func home(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello from Snippetbox"))
}
//основная функция, `home` которая получает на вход указатель кто слушает канал:
//w http.ResponseWriter чтобы выводить значения в web
//r *http.Request - структура, которую получаем на вход
func main() {
mux := http.NewServeMux()
//указатель на web сервер
mux.HandleFunc("/", home)
//обработчик запросов
log.Println("Starting server on :4000")
//логируем подключение
err := http.ListenAndServe(":4000", mux)
//слушаем порт
log.Fatal(err)
}
type ResponseWriter interface {
// Header возвращает карту заголовков, которая будет отправлена
// [ResponseWriter.WriteHeader].
Header() Header
// Write записывает данные в соединение как часть HTTP-ответа.
Write([]byte) (int, error)
// WriteHeader отправляет заголовок ответа HTTP с указанным
// кодом состояния.
WriteHeader(statusCode int)
}
type Request struct {
// Method задает метод HTTP (GET, POST, PUT и т.д.).
Method string
// Для клиентских запросов URL's Host указывает сервер, к которому нужно // подключиться.
URL *url.URL
// Версия протокола для входящих запросов сервера.
Proto string // «HTTP/1.0»
ProtoMajor int // 1
ProtoMinor int // 0
// Заголовок содержит поля заголовка запроса, которые либо получены
// сервером, либо должны быть отправлены клиентом.
//
// Host: example.com
// accept-encoding: gzip, deflate
// Accept-Language: en-us
// fOO: Bar
// foo: two
Request Header
// Body - это тело запроса.
Body io.ReadCloser
// GetBody определяет необязательную функцию для возврата новой копии Body.
GetBody func() (io.ReadCloser, error)
// ContentLength записывает длину связанного содержимого.
ContentLength int64
// TransferEncoding перечисляет кодировки передачи данных от крайних к внутренней.
TransferEncoding []string
Close bool
// Для серверных запросов поле Host указывает хост, на котором URL-адрес.
Host String
// Form содержит разобранные данные формы
Form url.Values
// PostForm содержит разобранные данные формы из параметров тела PATCH, POST
// или параметров тела PUT.
PostForm url.Values
// MultipartForm - это разобранная многочастная форма, включая загрузку файлов.
MultipartForm *multipart.Form
// Trailer определяет дополнительные заголовки, которые отправляются после запроса
// body.
Trailer Header
// RemoteAddr позволяет HTTP-серверам и другому программному обеспечению записывать сетевой адрес, отправивший запрос
RemoteAddr String
// RequestURI - это немодифицированный запрос-цель в
RequestURI String
// TLS позволяет HTTP-серверам и другому программному обеспечению записывать информацию о TLS-соединении
TLS *tls.ConnectionState
// Cancel - необязательный канал, закрытие которого указывает на то, что клиентский
// запрос следует считать отмененным.
Cancel <-chan struct{}
// Response - это ответ перенаправления, который вызвал этот запрос.
Response *Response
// Pattern - шаблон [ServeMux], который соответствует запросу.
Pattern string
// содержит отфильтрованные или неотфильтрованные поля
}
Расширение обработчиков
package main
import (
"log"
"net/http"
)
func home(w http.ResponseWriter, r *http.Request) { w
.Write([]byte("Hello from Snippetbox"))
} создаем функции обработчики каждого адреса
func snippetView(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Display a specific snippet..."))
} // для теста просто выводим что-нибудь
func snippetCreate(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Create a new snippet...")) }
func main() {
// Register the two new handler functions and corresponding URL patterns with // the servemux, in exactly the same way that we did before.
mux := http.NewServeMux()
mux.HandleFunc("/", home)
mux.HandleFunc("/snippet/view", snippetView)
mux.HandleFunc("/snippet/create", snippetCreate)
// обрабатываем каждый адрес из браузера и запускаем функцию обработчик
log.Println("Starting server on :4000")
err := http.ListenAndServe(":4000", mux)
log.Fatal(err)
}
Типы адресов в обработчике
/snippet/view
— фиксированный адрес/snippet/view/
— не фиксированный адрес и под ним обрабатывается поддерево- Для корня проверка если указано что-то отличающееся от
/
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
w.Write([]byte("Hello from Snippetbox"))
DefaultServeMux
var DefaultServeMux = NewServeMux()
это неявная переменная, которая хранит указатель на сервер и используется глобально. Поэтому сервера нужно создавать локально внутри main
У маршрутизатора Go могут быть проблемы с обработкой переменных
Но это по всей видимости в старых версиях. Новые могут больше и есть дополнительные модули, которые полноценно обрабатывают строку запроса
Отправка статуса
func snippetCreate(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" { // просто покажет каким методом отправили запрос
w.Header().Set("Allow", "POST")` //- эта настройка подскажет в ответе, какой метод разрешен
w.WriteHeader(405) // отправим код статуса в заголовке ответа
w.Write([]byte("Method Not Allowed")) // и просто напишем сообщение в случае неправильного кода
return
}
w.Write([]byte("Create a new snippet...")) //это сообщение успеха
}
curl -i -X POST http://localhost:4000/snippet/create // это для тестирования из командной строки
w.Header().Set("Allow", "POST")
- эта настройка подскажет в ответе, какой метод разрешен
w.Write([]byte("Method Not Allowed"))
у этой команды есть альтернатива более приемлемая: http.Error(w, "Method Not Allowed", 405)
и еще есть специальные константы с указанием статусов: http.StatusMethodNotAllowed
вместо 405
Статусы
const (
StatusContinue = 100 // RFC 9110, 15.2.1
StatusSwitchingProtocols = 101 // RFC 9110, 15.2.2
StatusProcessing = 102 // RFC 2518, 10.1
StatusEarlyHints = 103 // RFC 8297
StatusOK = 200 // RFC 9110, 15.3.1
StatusCreated = 201 // RFC 9110, 15.3.2
StatusAccepted = 202 // RFC 9110, 15.3.3
StatusNonAuthoritativeInfo = 203 // RFC 9110, 15.3.4
StatusNoContent = 204 // RFC 9110, 15.3.5
StatusResetContent = 205 // RFC 9110, 15.3.6
StatusPartialContent = 206 // RFC 9110, 15.3.7
StatusMultiStatus = 207 // RFC 4918, 11.1
StatusAlreadyReported = 208 // RFC 5842, 7.1
StatusIMUsed = 226 // RFC 3229, 10.4.1
StatusMultipleChoices = 300 // RFC 9110, 15.4.1
StatusMovedPermanently = 301 // RFC 9110, 15.4.2
StatusFound = 302 // RFC 9110, 15.4.3
StatusSeeOther = 303 // RFC 9110, 15.4.4
StatusNotModified = 304 // RFC 9110, 15.4.5
StatusUseProxy = 305 // RFC 9110, 15.4.6
StatusTemporaryRedirect = 307 // RFC 9110, 15.4.8
StatusPermanentRedirect = 308 // RFC 9110, 15.4.9
StatusBadRequest = 400 // RFC 9110, 15.5.1
StatusUnauthorized = 401 // RFC 9110, 15.5.2
StatusPaymentRequired = 402 // RFC 9110, 15.5.3
StatusForbidden = 403 // RFC 9110, 15.5.4
StatusNotFound = 404 // RFC 9110, 15.5.5
StatusMethodNotAllowed = 405 // RFC 9110, 15.5.6
StatusNotAcceptable = 406 // RFC 9110, 15.5.7
StatusProxyAuthRequired = 407 // RFC 9110, 15.5.8
StatusRequestTimeout = 408 // RFC 9110, 15.5.9
StatusConflict = 409 // RFC 9110, 15.5.10
StatusGone = 410 // RFC 9110, 15.5.11
StatusLengthRequired = 411 // RFC 9110, 15.5.12
StatusPreconditionFailed = 412 // RFC 9110, 15.5.13
StatusRequestEntityTooLarge = 413 // RFC 9110, 15.5.14
StatusRequestURITooLong = 414 // RFC 9110, 15.5.15
StatusUnsupportedMediaType = 415 // RFC 9110, 15.5.16
StatusRequestedRangeNotSatisfiable = 416 // RFC 9110, 15.5.17
StatusExpectationFailed = 417 // RFC 9110, 15.5.18
StatusTeapot = 418 // RFC 9110, 15.5.19 (Unused)
StatusMisdirectedRequest = 421 // RFC 9110, 15.5.20
StatusUnprocessableEntity = 422 // RFC 9110, 15.5.21
StatusLocked = 423 // RFC 4918, 11.3
StatusFailedDependency = 424 // RFC 4918, 11.4
StatusTooEarly = 425 // RFC 8470, 5.2.
StatusUpgradeRequired = 426 // RFC 9110, 15.5.22
StatusPreconditionRequired = 428 // RFC 6585, 3
StatusTooManyRequests = 429 // RFC 6585, 4
StatusRequestHeaderFieldsTooLarge = 431 // RFC 6585, 5
StatusUnavailableForLegalReasons = 451 // RFC 7725, 3
StatusInternalServerError = 500 // RFC 9110, 15.6.1
StatusNotImplemented = 501 // RFC 9110, 15.6.2
StatusBadGateway = 502 // RFC 9110, 15.6.3
StatusServiceUnavailable = 503 // RFC 9110, 15.6.4
StatusGatewayTimeout = 504 // RFC 9110, 15.6.5
StatusHTTPVersionNotSupported = 505 // RFC 9110, 15.6.6
StatusVariantAlsoNegotiates = 506 // RFC 2295, 8.1
StatusInsufficientStorage = 507 // RFC 4918, 11.5
StatusLoopDetected = 508 // RFC 5842, 7.2
StatusNotExtended = 510 // RFC 2774, 7
StatusNetworkAuthenticationRequired = 511 // RFC 6585, 6
)
Заголовок запроса
Автоматически устанавливаются три переменные запроса:
Date
и Content-Length
и Content-Type
.
Content-Length
определяется автоматически, но лучше установить явно:
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"name":"Alex"}`))
Методы управления заголовком
- w.Header().Set()
- w.Header().Add()
- w.Header().Del()
- w.Header().Get()
- w.Header().Values()
w.Header().Set("Cache-Control", "public, max-age=31536000")
w.Header().Add("Cache-Control", "public")
w.Header().Add("Cache-Control", "max-age=31536000")
w.Header().Del("Cache-Control")
w.Header().Get("Cache-Control")
w.Header().Values("Cache-Control")
Канонические имена в заголовке
Все имена в заголовке преобразуются в канонические: Первая буква с заглавной
Если нужно отменить преобразование:
w.Header()["X-XSS-Protection"] = []string{"1; mode=block"}
Подавление системных заголовков
w.Header()["Date"] = nil
потому-что DEL не удаляет их
Query
/snippet/view?id=1 // обработка строки запроса
id, err := strconv.Atoi(r.URL.Query().Get("id"))
if err != nil || id < 1 {
http.NotFound(w, r)
return
}
Структура проекта
- Main оставить в одном файле
- Handler вынести в отдельный файл с такой же структурой
internal
создадим директорию для зависимых пакетов для проекта
html шаблоны
ui/html/pages/home.tmpl
<!doctype html>
<html lang='en'>
<head>
<meta charset='utf-8'>
<title>Home - Snippetbox</title>
</head>
<body>
<header>
<h1><a href='/'>Snippetbox</a></h1>
</header>
<main>
<h2>Latest Snippets</h2>
<p>There's nothing to see here yet!</p>
</main>
<footer>Powered by <a href='https://golang.org/'>Go</a></footer>
</body>
</html>
html/template
- управляет шаблонами html из GO
ts, err := template.ParseFiles("./ui/html/pages/home.tmpl")
if err != nil {
log.Println(err.Error())
http.Error(w, "Internal Server Error", 500)
return
}
загружаем и парсим шаблон. Теперь он в переменной ts
err = ts.Execute(w, nil) //отправляет шаблон в поток w
if err != nil {
log.Println(err.Error())
http.Error(w, "Internal Server Error", 500)
}
Шаблоны отдельно
Делаем составные шаблоны
- Базовый шаблон base.tmpl
Задает основную структуру страницы:
{{define "base"}}
<!doctype html>
<html lang='en'>
<head>
<meta charset='utf-8'>
<title>{{template "title" .}} - Snippetbox</title>
</head>
<body>
<header>
<h1><a href='/'>Snippetbox</a></h1>
</header>
<main>
{{template "main" .}}
</main>
<footer>Powered by <a href='https://golang.org/'>Go</a></footer>
</body>
</html>
{{end}}
{{define "base"}}
- определяет основной блок, внутри вся структура
{{template "title" .}}
- заголовок указанный в переменной title, которую потом подставим
{{template "main" .}}
- название другого блока с именем main
чтобы вставить template
их нужно объявить как define
{{define "title"}}Home{{end}}
{{define "main"}}
<h2>Latest Snippets</h2>
<p>There's nothing to see here yet!</p>
{{end}}
Создание слайса с шаблонами
files := []string{
"./ui/html/base.tmpl",
"./ui/html/pages/home.tmpl",
}
что примечательного в этом коде: здесь нужно перечислить все шаблоны с их путями, которые будут собираться для вывода страницы
и немного поменялся парсер, теперь ему передадим не один шаблон, а слайсер с шаблонами
ts, err := template.ParseFiles(files...)
if err != nil {
log.Println(err.Error())
http.Error(w, "Internal Server Error", 500)
return
}
и поменяется метод для сборки всех шаблонов в одну страницу:
err = ts.ExecuteTemplate(w, "base", nil)
if err != nil {
log.Println(err.Error())
http.Error(w, "Internal Server Error", 500)
}
ts.ExecuteTemplate(w, "base", nil)
- указываем главный шаблон base
, в который будут собираться остальные
в итоге получили такой шаблон:
Итоговый шаблон
{{define "base"}}
<!doctype html>
<html lang='en'>
<head>
<meta charset='utf-8'>
<title>{{template "title" .}} - Snippetbox</title>
</head>
<body>
<header>
<h1><a href='/'>Snippetbox</a></h1>
</header>
<!-- Invoke the navigation template -->
{{template "nav" .}}
<main>
{{template "main" .}}
</main>
<footer>Powered by <a href='https://golang.org/'>Go</a></footer>
</body>
</html>
{{end}}
Block
Это похоже на template, только является необязательным
{{define "base"}}
<h1>An example template</h1>
{{block "sidebar" .}}
<p>My default sidebar content</p>
{{end}}
{{end}}
Еще есть функционал для встраивания файлов прямо в код с пакетом: embed
Статические файлы
http.FileServer
- встроенный обработчик статическийх файлов
fileServer := http.FileServer(http.Dir("./ui/static/"))
для подключения обработчика статических файлов необходимо:
mux.Handle("/static/", http.StripPrefix("/static", fileServer)) //http.StripPrefix позволяет правильно работать в адресной строке с именами файлов
Что можно положить в статику:
- CSS файлы
- JS файлы
Пример
{{define "base"}}
<!doctype html>
<html lang='en'>
<head>
<meta charset='utf-8'>
<title>{{template "title" .}} - Snippetbox</title>
<!-- Link to the CSS stylesheet and favicon -->
<link rel='stylesheet' href='/static/css/main.css'>
<link rel='shortcut icon' href='/static/img/favicon.ico' type='image/x-icon'>
<!-- Also link to some fonts hosted by Google -->
<link rel='stylesheet' href='https://fonts.googleapis.com/css?family=Ubuntu+Mono:400,700'>
</head>
<body>
<header>
<h1><a href='/'>Snippetbox</a></h1>
</header>
{{template "nav" .}}
<main>
{{template "main" .}}
</main>
<footer>Powered by <a href='https://golang.org/'>Go</a></footer>
<!-- And include the JavaScript file -->
<script src="/static/js/main.js" type="text/javascript"></script>
</body>
</html>
{{end}}
<script src="/static/js/main.js" type="text/javascript"></script>
- загрузит статический файл с JS
<link rel='stylesheet' href='/static/css/main.css'>
- загрузит CSS на страницу
Hundle что делает
Отступление с объяснением Hundle
В пакете net/http
языка Go есть два способа зарегистрировать обработчик (handler) для определённого пути (path):
func (*ServeMux) Handle
– это метод структурыServeMux
.func Handle
– это функция уровня пакета, которая использует дефолтныйServeMux
.
Разница между ними:
Критерий | (*ServeMux).Handle |
http.Handle |
---|---|---|
Тип | Метод (ServeMux.Handle ) |
Функция (http.Handle ) |
Принадлежность | Работает с конкретным ServeMux (роутером) |
Работает с дефолтным ServeMux |
Где используется | Когда нужно кастомное мультиплексирование | Для простых случаев (использует DefaultServeMux ) |
Пример использования | mux := http.NewServeMux(); mux.Handle("/", handler) |
http.Handle("/", handler) |
Примеры:
1. Использование (*ServeMux).Handle
(кастомный ServeMux
)
mux := http.NewServeMux() // создаём свой мультиплексор
mux.Handle("/path", myHandler) // регистрируем обработчик
http.ListenAndServe(":8080", mux) // запускаем сервер с этим мультиплексором
2. Использование http.Handle
(дефолтный ServeMux
)
http.Handle("/path", myHandler) // регистрируем обработчик в DefaultServeMux
http.ListenAndServe(":8080", nil) // nil означает использование DefaultServeMux
Важно:
- Если вы используете
http.Handle
, то обработчик регистрируется вDefaultServeMux
. - Если вы хотите кастомный роутер (например, для middleware, изоляции путей и т. д.), нужно использовать
NewServeMux()
и(*ServeMux).Handle
. - В продакшене часто избегают
DefaultServeMux
из-за возможных конфликтов (например, если несколько библиотек тоже регистрируют пути в нём).
Вывод:
(*ServeMux).Handle
– для кастомных роутеров.http.Handle
– для быстрого использования дефолтного роутера.
Hundler для чего нужен
Функция (*ServeMux).Handler
в пакете net/http
используется для поиска обработчика (http.Handler
) и шаблона пути (pattern
), который соответствует переданному HTTP-запросу (*http.Request
).
📌 Сигнатура функции:
func (mux *ServeMux) Handler(r *Request) (h Handler, pattern string)
r *Request
– входящий HTTP-запрос.- Возвращает:
h Handler
– обработчик, который должен обработать запрос.pattern string
– шаблон пути, который совпал с URL запроса (например,"/users/"
).
🔹 Когда используется?
- Когда нужно вручную определить обработчик для запроса (например, для кастомной логики маршрутизации).
- В middleware, чтобы проверить, какой обработчик будет вызван для запроса.
- Для логирования или отладки (чтобы узнать, какой
pattern
соответствует запросу).
🔹 Примеры использования
1️⃣ Пример 1: Получение обработчика для запроса
package main
import (
"fmt"
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "Hello, World!")
})
// Создаём тестовый запрос
req, _ := http.NewRequest("GET", "http://example.com/hello", nil)
// Получаем обработчик и шаблон пути
handler, pattern := mux.Handler(req)
fmt.Println("Handler:", handler != nil) // true
fmt.Println("Pattern:", pattern) // "/hello"
}
Вывод:
Handler: true
Pattern: /hello
2️⃣ Пример 2: Использование в middleware
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Получаем обработчик и шаблон до вызова next
handler, pattern := http.DefaultServeMux.Handler(r)
log.Printf("Request: %s %s → Handler: %T, Pattern: %s", r.Method, r.URL.Path, handler, pattern)
next.ServeHTTP(w, r)
})
}
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Home"))
})
http.ListenAndServe(":8080", loggingMiddleware(http.DefaultServeMux))
}
При запросе GET /
в логах появится:
Request: GET / → Handler: http.HandlerFunc, Pattern: /
🔹 Как работает внутри?
- Проверяет
Host
(если задан вServeMux
). - Ищет точное совпадение пути (например,
/users
). - Ищет самое длинное совпадение по префиксу (если путь зарегистрирован с
/users/
, то запрос/users/123
тоже сработает). - Если ничего не найдено – возвращает
NotFoundHandler
(который отвечает с404
).
🔹 Отличие от http.Handler()
(*ServeMux).Handler
– метод конкретногоServeMux
.http.Handler()
– работает сDefaultServeMux
.
Пример:
// С кастомным ServeMux
mux := http.NewServeMux()
handler, pattern := mux.Handler(req)
// С DefaultServeMux
handler, pattern := http.DefaultServeMux.Handler(req)
// Или просто:
handler, pattern := http.Handler(req) // работает с DefaultServeMux
🔹 Вывод
(*ServeMux).Handler
полезен, когда нужно динамически определить обработчик для запроса.- Используется в middleware, кастомных роутерах, логировании.
- Возвращает как сам обработчик, так и шаблон пути, что помогает в отладке.
ServeHTTP объяснение
Функция (*ServeMux).ServeHTTP
— это основной метод обработки HTTP-запросов в ServeMux
, который реализует интерфейс http.Handler
. Она отвечает за:
- Поиск подходящего обработчика (на основе URL пути)
- Вызов этого обработчика (или возврат
404 Not Found
)
🔍 Развёрнутое объяснение
📌 Сигнатура
func (mux *ServeMux) ServeHTTP(w http.ResponseWriter, r *http.Request)
w ResponseWriter
— интерфейс для записи HTTP-ответа.r *Request
— входящий HTTP-запрос.
🔧 Как работает? (Mermaid-схема)
flowchart TD A[Запрос поступает в ServeMux.ServeHTTP] --> B{Есть Host в URL?} B -->|Да| C[Ищем обработчик по host + path] B -->|Нет| D[Ищем обработчик только по path] C --> E{Найден обработчик?} D --> E E -->|Да| F[Вызываем handler.ServeHTTP(w, r)] E -->|Нет| G[Возвращаем 404 Not Found]
Пошаговый алгоритм:
- Проверяет
Host
(если вServeMux
есть пути с указанием хоста, например"example.com"
). - Ищет точное совпадение пути (например,
/users
). - Ищет самое длинное совпадение по префиксу (если путь зарегистрирован как
/users/
, то/users/123
тоже сработает). - Если обработчик не найден — вызывает
NotFoundHandler
(отправляет404
).
📂 Пример кода
package main
import (
"net/http"
)
func main() {
mux := http.NewServeMux()
// Регистрируем обработчики
mux.HandleFunc("/", homeHandler)
mux.HandleFunc("/about", aboutHandler)
// Запускаем сервер
http.ListenAndServe(":8080", mux)
}
func homeHandler(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Home Page"))
}
func aboutHandler(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("About Page"))
}
🛠 Что происходит при запросе?
-
Запрос
GET /
ServeHTTP
находит точное совпадение с/
→ вызываетhomeHandler
.
-
Запрос
GET /about
- Совпадение с
/about
→ вызываетaboutHandler
.
- Совпадение с
-
Запрос
GET /unknown
- Совпадений нет → возвращает
404
.
- Совпадений нет → возвращает
⚙️ Кастомизация ServeMux
Можно переопределить ServeHTTP
для своей логики (например, для middleware):
type CustomMux struct {
*http.ServeMux
}
func (m *CustomMux) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Логирование перед вызовом обработчика
log.Println("Request:", r.URL.Path)
m.ServeMux.ServeHTTP(w, r) // Вызов оригинального ServeHTTP
}
func main() {
mux := &CustomMux{http.NewServeMux()}
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Custom Mux!"))
})
http.ListenAndServe(":8080", mux)
}
🔥 Ключевые особенности
-
Иерархия путей:
/images/
совпадёт с/images/logo.png
./images
(без слеша) — только с точным совпадением.
-
Host-specific пути:
mux.Handle("example.com/", handler) // Сработает только для example.com
-
Дефолтный обработчик 404:
Можно заменить черезmux.NotFound = customHandler
.
🎯 Вывод
ServeHTTP
— ядро роутинга вnet/http
.- Автоматически обрабатывает префиксы, хосты и 404.
- Может быть переопределён для кастомной логики.
Что нужно сделать,чтобы получить Handle
- Создать структуру
type home struct {}
- или любой объект - Создать функцию обработчик с вызовом
ServeHTTP
func (h *home) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Write([]byte("This is my home page")) }
- Используем в коде
mux := http.NewServeMux()
mux.Handle("/", &home{})
ВСЁ!!!
А это синтетический сахар для обертывания обычных функций в Handle
mux := http.NewServeMux()
mux.HandleFunc("/", home) // home здесь обычная функция
http.ListenAndServe()
Это собственно сам сервер у которого тоже есть такой же метод ServeHTTP и который отправляет на него запросы по цепочке
Работаем с флагами командной строки
Пакет: flag
addr := flag.String("addr", ":4000", "HTTP network address") // прочитали адрес
flag.Parse()
...
log.Printf("Starting server on %s", *addr) //вывели на печать
err := http.ListenAndServe(*addr, mux) //слушаем порт, который получили из адресной строки
log.Fatal(err)
Флаги могут преобразовываться сразу в нужный тип:
- flag.String()
- flag.Int(),
- flag.Bool()
- flag.Float64().
help автоматически
для команды help автоматически сформируется все параметры вместе с подсказками
os.Getenv
Прочитает значение из переменной среды:
addr := os.Getenv("SNIPPETBOX_ADDR")
Структура флагов
type config struct {
addr string
staticDir string
}
создаем структуру для флагов и определяем переменные из структуры:
var cfg config
flag.StringVar(&cfg.addr, "addr", ":4000", "HTTP network address")
flag.StringVar(&cfg.staticDir, "static-dir", "./ui/static", "Path to static assets")
flag.Parse()
для этого используем функции:
- flag.StringVar(),
- flag.IntVar(),
- flag.BoolVar()
Логирование
- Стандартный лог выводит в терминал
log.Printf("Starting server on %s", *addr) // Information message
err := http.ListenAndServe(*addr, mux)
log.Fatal(err) // Error message
Продвинутый лог
infoLog := log.New(os.Stdout, "INFO\t", log.Ldate|log.Ltime) // создали макет лога для сообщений
errorLog := log.New(os.Stderr, "ERROR\t", log.Ldate|log.Ltime|log.Lshortfile) //макет лога для ошибок
... //вывод лога
infoLog.Printf("Starting server on %s", *addr)
err := http.ListenAndServe(*addr, mux)
errorLog.Fatal(err)
$ go run ./cmd/web
INFO 2022/01/29 16:00:50 Starting server on :4000
ERROR 2022/01/29 16:00:50 main.go:37: listen tcp :4000: bind: address already in use
exit status 1
log.Llongfile
, log.Lshortfile
— эти флаги определяют полноту выводимых данных
перенаправим вывод журнала в файлы
$ go run ./cmd/web >>/tmp/info.log 2>>/tmp/error.log
Логирование работы сервера
- Создать структуру для параметров сервера HTTP
infoLog := log.New(os.Stdout, "INFO\t", log.Ldate|log.Ltime)
errorLog := log.New(os.Stderr, "ERROR\t", log.Ldate|log.Ltime|log.Lshortfile)
srv := &http.Server{
Addr: *addr,
ErrorLog: errorLog,
Handler: mux,
}
// включаем сервер с обработчиком ошибок
err := srv.ListenAndServe()
Логирование в файл
f, err := os.OpenFile("/tmp/info.log", os.O_RDWR|os.O_CREATE, 0666)
if err != nil {
log.Fatal(err)
}
defer f.Close()
infoLog := log.New(f, "INFO\t", log.Ldate|log.Ltime)
Логирование во всех зависимостях приложения
Создать структуру приложения с его переменными
type application struct {
errorLog *log.Logger
infoLog *log.Logger
}
в main.go задать значения:
infoLog := log.New(os.Stdout, "INFO\t", log.Ldate|log.Ltime)
errorLog := log.New(os.Stderr, "ERROR\t", log.Ldate|log.Ltime|log.Lshortfile)
app := &application{
errorLog: errorLog,
infoLog: infoLog,
}
и использовать во всех зависимостях
так в обработчиках добавляем зависимость от структуры:
func (app *application) home(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
Вариант использования пакета config
func main() {
app := &config.Application{
ErrorLog: log.New(os.Stderr, "ERROR\t", log.Ldate|log.Ltime|log.Lshortfile)
}
mux.Handle("/", examplePackage.ExampleHandler(app))
}
func ExampleHandler(app *config.Application) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
...
ts, err := template.ParseFiles(files...)
if err != nil {
app.ErrorLog.Println(err.Error())
http.Error(w, "Internal Server Error", 500)
return
}
...
}
}
пакет позволяет применять конфигурацию для всех зависимостей
Динамические данные в шаблоне
Объявление внутреннего модуля в проекте
в проекте может быть несколько внутренних и не только внутренних модулей. В нашем случае внутренний модуль лежит в папке /internal/models
. Учитывая, что основной проект называется: snippetbox.alexedwards.net
, то полный путь к модулю будет: "snippetbox.alexedwards.net/internal/models"
package main
import (
"errors"
"fmt"
"html/template" // Uncomment import
"net/http"
"strconv"
"snippetbox.alexedwards.net/internal/models"
)
Для работы динамических шаблонов нужно:
- Создать шаблоны
- Выстроить иерархию внутри них (каждый блок и раздел лучше делать в отдельном шаблоне и соединять в один, например
base
) - Создать массив строк с наименованием шаблонов и путей к ним
files := []string{
"./ui/html/base.tmpl",
"./ui/html/partials/nav.tmpl",
"./ui/html/pages/view.tmpl",
}
- Пропарсить шаблоны
ts, err := template.ParseFiles(files...)
if err != nil {
app.serverError(w, err)
return
}
- Собрать html из шаблонов
err = ts.ExecuteTemplate(w, "base", snippet) //собираем в шаблоне base и передаем в него структуру данных snippet
if err != nil {
app.serverError(w, err)
}
Базовые понятия работы с шаблонами html/template
Для работы с сущностями создадим структуру в дополнительном модуле.
type Snippet struct {
ID int
Title string
Content string
Created time.Time
Expires time.Time
}
Эта структура будет работать с базой данных и шаблонами
.
— весь контекст переданный в этот блок- Передать структуру в шаблон:
err = ts.ExecuteTemplate(w, "base", snippet)
- Использовать поля структуры в шаблоне
{{.Title}}
и т.д.
{{define "title"}}Snippet #{{.ID}}{{end}}
{{define "main"}}
<div class='snippet'>
<div class='metadata'>
<strong>{{.Title}}</strong>
<span>#{{.ID}}</span>
</div>
<pre><code>{{.Content}}</code></pre>
<div class='metadata'>
<time>Created: {{.Created}}</time>
<time>Expires: {{.Expires}}</time>
</div>
</div>
{{end}}
Особенности пакета template
В структуру шаблонов можно передавать только одну переменную. Поэтому для передачи нескольких переменных их нужно собрать всех в одну структуру.- Создать структуру
type templateData struct {
Snippet *models.Snippet
}
- Выполнить все действия, описанные выше по парсингу шаблонов
- Создать экземпляр структуры данных
data := &templateData{
Snippet: snippet,
}
- Передать данные в генератор html кода
err = ts.ExecuteTemplate(w, "base", data)
if err != nil {
app.serverError(w, err)
}
- В шаблоне значения полей тогда будут вызываться с указанием полного пути поля:
{{.Snippet.ID}}
или{{.Snippet.Title}}
{{define "title"}}Snippet #{{.Snippet.ID}}{{end}}
{{define "main"}}
<div class='snippet'>
<div class='metadata'>
<strong>{{.Snippet.Title}}</strong>
<span>#{{.Snippet.ID}}</span>
</div>
<pre><code>{{.Snippet.Content}}</code></pre>
<div class='metadata'>
<time>Created: {{.Snippet.Created}}</time>
<time>Expires: {{.Snippet.Expires}}</time>
</div>
</div>
{{end}}
Вложенные структуры в шаблонах
Объявили шаблон
{{define "base"}}
...
{{end}}
Использование шаблона
В основном шаблоне можно указывать другие шаблоны, которые определены в других файлах. В других файлах может быть определено несколько шаблонов в каждом файле.
{{define "title"}}Home{{end}}
{{define "main"}}
<h2>Latest Snippets</h2>
<p>There's nothing to see here yet!</p>
{{end}}
Вставка встроенного шаблона в точку основного шаблона
{{template "title" .}}
{{define "base"}}
<!doctype html>
<html lang='en'>
<head>
<meta charset='utf-8'>
<title>{{template "title" .}} - Snippetbox</title>
</head>
<body>
<header>
<h1><a href='/'>Snippetbox</a></h1>
</header>
<main>
{{template "main" .}}
</main>
<footer>Powered by <a href='https://golang.org/'>Go</a></footer>
</body>
</html>
{{end}}
{{block}}…{{end}}
{{define "base"}}
<h1>An example template</h1>
{{block "sidebar" .}}
<p>My default sidebar content</p>
{{end}}
{{end}}
этот параметр можем использовать для передачи значений по умолчанию. Если нет в этом блоке данных, то он и не выведется, в отличии от {{template}}, который будет требовать обязательного вывода контента.
Вызов методов к полям
{{.Snippet.Created.Weekday}}
к нашему полю .Snippet.Created
вызовем метод Weekday
или {{.Snippet.Created.AddDate 0 6 0}}
{{if .Foo}} C1 {{else}} C2 {{end}}
{{with .Foo}} C1 {{else}} C2 {{end}}
{{define "title"}}Snippet #{{.Snippet.ID}}{{end}}
{{define "main"}}
{{with .Snippet}}
<div class='snippet'>
<div class='metadata'>
<strong>{{.Title}}</strong>
<span>#{{.ID}}</span>
</div>
<pre><code>{{.Content}}</code></pre>
<div class='metadata'>
<time>Created: {{.Created}}</time>
<time>Expires: {{.Expires}}</time>
</div>
</div>
{{end}}
{{end}}
собственно метод with
позволяет далее в этом блоке работать с полями указанной структуры без указания ее полного пути
{{range .Foo}} C1 {{else}} C2 {{end}}
range
пройдется по всем элементам .Foo
как цикл и выполнит все действия как делает with
с одним элементом
Операторы сравнения и логики
{{eq .Foo .Bar}}
{{ne .Foo .Bar}}
{{not .Foo}}
{{or .Foo .Bar}}
{{index .Foo i}}
возвращает текущий индекс массива
{{printf “%s-%s” .Foo .Bar}}
форматированная печать значений
{{len .Foo}}
{{$bar := len .Foo}}
— в этом варианте присвоит переменной длину .Foo
{{break}} {{continue}}
используется в if для разветвленной логики
Кэширование
Объяснение создания кэша
Подробный разбор map[string]*template.Template
1. Базовый анализ структуры
map[string]*template.Template
Это декларация типа в Go, представляющая:
- map - ассоциативный массив/словарь
- string - тип ключа (строка)
- *template.Template - тип значения (указатель на объект Template)
2. Компоненты:
- template.Template - тип из пакета
html/template
илиtext/template
, представляющий:- Разобранный шаблон
- Методы для выполнения шаблонов
- Ассоциированные именованные шаблоны
- Указатель (*) - используется для:
- Работы с одним экземпляром шаблона
- Избегания копирования больших структур
- Модификации шаблона после создания
3. Типичное использование:
templates := make(map[string]*template.Template)
// Добавление шаблона
templates["home"] = template.Must(template.ParseFiles("home.html"))
templates["about"] = template.Must(template.ParseFiles("about.html"))
// Использование
err := templates["home"].Execute(w, data)
4. Преимущества такого подхода:
-
Организация шаблонов:
- Группировка по функциональности (страницы, компоненты)
- Быстрый доступ по имени
-
Производительность:
- Парсинг шаблонов один раз при инициализации
- Кэширование готовых шаблонов
-
Гибкость:
- Динамический выбор шаблона
- Возможность горячей перезагрузки
5. Пример расширенного использования:
// Инициализация кэша шаблонов
var templateCache = map[string]*template.Template{
"home": parseTemplate("home.html", "layout.html"),
"about": parseTemplate("about.html", "layout.html"),
}
func parseTemplate(files ...string) *template.Template {
ts, err := template.ParseFiles(files...)
if err != nil {
log.Fatal(err)
}
return ts
}
// Обработчик HTTP
func homeHandler(w http.ResponseWriter, r *http.Request) {
data := struct{ Title string }{Title: "Главная"}
err := templateCache["home"].ExecuteTemplate(w, "layout", data)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
6. Особенности работы:
- Потокобезопасность: Шаблоны безопасны для конкурентного использования после создания
- Жизненный цикл: Обычно создаются при старте приложения
- Ошибки: Проверка должна быть при:
- Создании мапы
- Парсинге шаблонов
- Выполнении шаблонов
7. Альтернативные подходы:
-
sync.Map - для конкурентного доступа
var templates sync.Map templates.Store("home", template.Must(template.ParseFiles("home.html")))
-
Вложенные мапы - для сложных структур:
map[string]map[string]*template.Template
Такой подход широко используется в веб-фреймворках и приложениях для эффективного управления шаблонами.
создали функцию, которая будет кэшировать шаблоны в памяти map
package main
import (
"html/template" // New import
"path/filepath" // New import
"snippetbox.alexedwards.net/internal/models"
)
...
func newTemplateCache() (map[string]*template.Template, error) {
// Initialize a new map to act as the cache.
cache := map[string]*template.Template{}
// Use the filepath.Glob() function to get a slice of all filepaths that
// match the pattern "./ui/html/pages/*.tmpl". This will essentially gives
// us a slice of all the filepaths for our application 'page' templates
// like: [ui/html/pages/home.tmpl ui/html/pages/view.tmpl]
pages, err := filepath.Glob("./ui/html/pages/*.tmpl")
if err != nil {
return nil, err
}
// Loop through the page filepaths one-by-one.
for _, page := range pages {
// Extract the file name (like 'home.tmpl') from the full filepath
// and assign it to the name variable.
name := filepath.Base(page)
// Create a slice containing the filepaths for our base template, any
// partials and the page.
files := []string{
"./ui/html/base.tmpl",
"./ui/html/partials/nav.tmpl",
page,
}
// Parse the files into a template set.
ts, err := template.ParseFiles(files...)
if err != nil {
return nil, err
}
// Add the template set to the map, using the name of the page
// (like 'home.tmpl') as the key.
cache[name] = ts
}
// Return the map.
return cache, nil
}
в структуру приложения добавим поле
type application struct {
errorLog *log.Logger
infoLog *log.Logger
snippets *models.SnippetModel
templateCache map[string]*template.Template //это кэш
}
инициализация кэш
// Initialize a new template cache...
templateCache, err := newTemplateCache() //вызываем функцию и создаем кэш
if err != nil {
errorLog.Fatal(err)
}
создаем экземпляр приложения app с кэшем
app := &application{
errorLog: errorLog,
infoLog: infoLog,
snippets: &models.SnippetModel{DB: db},
templateCache: templateCache,
}
выдача кэшированных страниц
создадим функцию render
package main
...
func (app *application) render(w http.ResponseWriter, status int, page string, data *templateData) {
// Retrieve the appropriate template set from the cache based on the page
// name (like 'home.tmpl'). If no entry exists in the cache with the
// provided name, then create a new error and call the serverError() helper
// method that we made earlier and return.
ts, ok := app.templateCache[page]
if !ok {
err := fmt.Errorf("the template %s does not exist", page)
app.serverError(w, err)
return
}
// Write out the provided HTTP status code ('200 OK', '400 Bad Request'
// etc).
w.WriteHeader(status)
// Execute the template set and write the response body. Again, if there
// is any error we call the the serverError() helper.
err := ts.ExecuteTemplate(w, "base", data)
if err != nil {
app.serverError(w, err)
}
}
в эту функцию передаем название страницы и получаем готовый html код, только теперь уже из кэша
пример функции обращения к кэшу
func (app *application) home(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
app.notFound(w)
return
}
snippets, err := app.snippets.Latest()
if err != nil {
app.serverError(w, err)
return
}
// Use the new render helper.
app.render(w, http.StatusOK, "home.tmpl", &templateData{
Snippets: snippets,
})
}
полный код создания кэша
package main
...
func newTemplateCache() (map[string]*template.Template, error) {
cache := map[string]*template.Template{}
pages, err := filepath.Glob("./ui/html/pages/*.tmpl")
if err != nil {
return nil, err
}
for _, page := range pages {
name := filepath.Base(page)
// Parse the base template file into a template set.
ts, err := template.ParseFiles("./ui/html/base.tmpl")
if err != nil {
return nil, err
}
// Call ParseGlob() *on this template set* to add any partials.
ts, err = ts.ParseGlob("./ui/html/partials/*.tmpl")
if err != nil {
return nil, err
}
// Call ParseFiles() *on this template set* to add the page template.
ts, err = ts.ParseFiles(page)
if err != nil {
return nil, err
}
// Add the template set to the map as normal...
cache[name] = ts
}
return cache, nil
}
Объяснение парсинга шаблонов в кэш
Развернутое объяснение функции newTemplateCache()
Эта функция создает кэш шаблонов для веб-приложения, загружая и компилируя все шаблоны страниц вместе с базовым шаблоном и частичными шаблонами (partials). Рассмотрим ее построчно:
1. Объявление функции
func newTemplateCache() (map[string]*template.Template, error)
- Назначение: Создает и возвращает кэш шаблонов
- Возвращаемые значения:
map[string]*template.Template
- кэш шаблонов, где ключ - имя файла шаблонаerror
- ошибка, если что-то пошло не так
2. Инициализация кэша
cache := map[string]*template.Template{}
- Создается пустая мапа для хранения шаблонов
3. Поиск файлов страниц
pages, err := filepath.Glob("./ui/html/pages/*.tmpl")
- Использует
filepath.Glob
для поиска всех файлов с расширением.tmpl
в директории./ui/html/pages/
- Возвращает список путей к файлам или ошибку
4. Обработка ошибок поиска
if err != nil {
return nil, err
}
- Если при поиске файлов произошла ошибка, возвращает ее
5. Цикл по найденным страницам
for _, page := range pages {
- Перебирает все найденные файлы шаблонов страниц
6. Получение имени файла
name := filepath.Base(page)
- Извлекает базовое имя файла (без пути) с помощью
filepath.Base()
- Например, для “./ui/html/pages/home.tmpl” вернет “home.tmpl”
7. Парсинг базового шаблона
ts, err := template.ParseFiles("./ui/html/base.tmpl")
- Создает новый набор шаблонов (
*template.Template
), загружая базовый шаблон - Базовый шаблон обычно содержит общую структуру HTML (doctype, head, общие скрипты/стили)
8. Обработка ошибок парсинга
if err != nil {
return nil, err
}
- Если не удалось загрузить базовый шаблон, возвращает ошибку
9. Добавление частичных шаблонов
ts, err = ts.ParseGlob("./ui/html/partials/*.tmpl")
- Добавляет в набор шаблонов все файлы из директории partials
- Частичные шаблоны - это повторно используемые компоненты (header, footer и т.д.)
10. Обработка ошибок добавления partials
if err != nil {
return nil, err
}
- Возвращает ошибку, если не удалось загрузить partials
11. Добавление шаблона страницы
ts, err = ts.ParseFiles(page)
- Загружает конкретный шаблон страницы в тот же набор шаблонов
- Это позволяет странице использовать базовый шаблон и partials
12. Обработка ошибок добавления страницы
if err != nil {
return nil, err
}
- Возвращает ошибку, если не удалось загрузить шаблон страницы
13. Сохранение в кэш
cache[name] = ts
- Сохраняет готовый набор шаблонов в кэш под именем файла страницы
14. Возврат результата
return cache, nil
- После обработки всех страниц возвращает заполненный кэш и nil в качестве ошибки
Особенности работы:
-
Иерархия шаблонов:
- Базовый шаблон (base.tmpl)
- Частичные шаблоны (partials/*.tmpl)
- Шаблоны страниц (pages/*.tmpl)
-
Преимущества:
- Все шаблоны парсятся один раз при старте приложения
- Готовые к использованию шаблоны хранятся в памяти
- Быстрый доступ к шаблонам по имени
-
Использование в обработчиках:
// Где-то в обработчике: err := cache["home.tmpl"].ExecuteTemplate(w, "base", data)
-
Расширения:
- Можно добавить горячую перезагрузку в dev-режиме
- Можно кэшировать не только по имени файла, но и по логическому имени страницы
Эта реализация обеспечивает удобное управление шаблонами в веб-приложении, следуя принципам DRY (Don’t Repeat Yourself) и обеспечивая хорошую производительность.
Как избежать ошибок при рендеринге динамического кода
- Делать рендер кода
- Записывать результат в буфер
- Проверять на отсутствие ошибок
- делать выдачу в
http.ResponseWriter
.
Обновленный код обработчика с промежуточной записью результата в буфер
package main
import (
"bytes" // New import
"fmt"
"net/http"
"runtime/debug"
)
...
func (app *application) render(w http.ResponseWriter, status int, page string, data *templateData) {
ts, ok := app.templateCache[page]
if !ok {
err := fmt.Errorf("the template %s does not exist", page)
app.serverError(w, err)
return
}
// инициализация буфера
buf := new(bytes.Buffer)
// записываем результат рендеринга в буфер
err := ts.ExecuteTemplate(buf, "base", data)
if err != nil {
app.serverError(w, err)
return
}
// запишем статус в заголовок ответа
w.WriteHeader(status)
// возвращаем результат успешного рендеринга
buf.WriteTo(w)
}
Динамические данные в шаблонах
- Создадим специальную структуру для передачи динамических данных в шаблон
type templateData struct {
CurrentYear int
Snippet *models.Snippet
Snippets []*models.Snippet
}
- Создадим функцию helpers для возврата указателя на структуру с данными и расчетом динамических данных
func (app *application) newTemplateData(r *http.Request) *templateData {//получаем на вход указатель с данными запроса
return &templateData{ //и возвращаем указатель с данными ответа
CurrentYear: time.Now().Year(),// по дороге посчитаем какой текущий год
}
}
- Делаем вызов функции
render
и передаем ей на вход структуру с даннымиtemplateData
//загружаем из базы данные в переменную snippets
snippets, err := app.snippets.Latest()
if err != nil {
app.serverError(w, err)
return
}
data := app.newTemplateData(r) //создаем переменную типа templateData
data.Snippets = snippets //записываем в структуру переменной значения из базы данных
//вызываем функцию render и передаем ей структуру с изменяемыми данными
app.render(w, http.StatusOK, "home.tmpl", data)
- Повторяем такие вызовы для всех обработчиков
- В шаблонах обрабатываем данные из структуры
templateData
как {{.Snippets}} или {{.Snippet}} и текущий год как {{.CurrentYear}}
Пользовательские функции в шаблонах
- Создаем функцию, которую планируем добавить в шаблон
func humanDate(t time.Time) string {
return t.Format("02 Jan 2006 at 15:04")
}
- Создаем объект
template.FuncMap
и в нем объявляем свою функцию (напримерhumanDate()
). Фактически в переменной function создается карта: ключ - значение, где значение, это название функции, которое мы передаем в шаблон.
var functions = template.FuncMap{
"humanDate": humanDate,
}
- Метод должен быть зарегистрирован до парсинга шаблона
for _, page := range pages {
name := filepath.Base(page)
//перед парсером файла с шаблоном, передаем переменную functions с объявленными функциями
//функций может быть несколько
ts, err := template.New(name).Funcs(functions).ParseFiles("./ui/html/base.tmpl")
if err != nil {
return nil, err
}
ts, err = ts.ParseGlob("./ui/html/partials/*.tmpl")
if err != nil {
return nil, err
}
ts, err = ts.ParseFiles(page)
if err != nil {
return nil, err
}
cache[name] = ts
}
- Используем функцию в шаблоне как и другие функции
<td>{{humanDate .Created}}</td>
или
<time>Created: {{.Created | humanDate}}</time>
или
<time>{{.Created | humanDate | printf "Created: %s"}}</time>
Объяснение порядка добавления функций в шаблон
Подробный разбор команды ts, err := template.New(name).Funcs(functions).ParseFiles("./ui/html/base.tmpl")
Эта строка кода создает и настраивает новый шаблон в Go. Разберем ее по цепочке вызовов:
1. template.New(name)
- Назначение: Создает новый пустой шаблон
- Параметр:
name
(string) - уникальное имя шаблона
- Возвращает: Указатель на
*template.Template
- Особенности:
- Имя используется для вызова шаблона через
{{template "name"}}
- Если шаблон с таким именем уже существует, он будет заменен
- Имя используется для вызова шаблона через
2. .Funcs(functions)
- Назначение: Регистрирует пользовательские функции для использования в шаблоне
- Параметр:
functions
(типtemplate.FuncMap
) - мапа функций видаmap[string]interface{}
- Пример:
functions := template.FuncMap{ "uppercase": strings.ToUpper, "add": func(a, b int) int { return a + b }, }
- Важно:
- Должен вызываться до парсинга шаблона
- Функции должны возвращать 1 или 2 значения (второе - error)
3. .ParseFiles("./ui/html/base.tmpl")
- Назначение: Парсит указанные файлы как тело шаблона
- Параметр:
- Путь к файлу шаблона (может принимать несколько файлов)
- Особенности:
- Первый файл становится “ассоциированным” с этим шаблоном
- Может содержать определения других шаблонов через
{{define}}
Возвращаемые значения:
ts
(*template.Template) - созданный шаблонerr
(error) - ошибка, если что-то пошло не так
Полный пример использования:
funcs := template.FuncMap{
"formatDate": func(t time.Time) string { return t.Format("2006-01-02") },
}
ts, err := template.New("base").Funcs(funcs).ParseFiles("./ui/html/base.tmpl")
if err != nil {
log.Fatal(err)
}
// Использование в обработчике
err = ts.Execute(w, data)
Что происходит под капотом:
- Создается новый именованный шаблон
- Регистрируются пользовательские функции
- Читается и парсится файл шаблона
- Все определения шаблонов из файла добавляются в набор
Особенности безопасности при использовании html/template
:
- Автоматическое экранирование в зависимости от контекста
- Защита от XSS-атак
- Контекстно-зависимое экранирование для HTML, JS, CSS
Типичные ошибки:
- Регистрация функций после парсинга
- Использование несуществующих путей к файлам
- Попытка использовать недопустимые имена функций (например, с точками)
Эта цепочка вызовов является идиоматическим способом создания шаблонов в Go и часто используется при инициализации веб-приложений.
Middleware
Стандартный паттерн middlware
func myMiddleware(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
// TODO: Execute our middleware logic here...
next.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}
или использование анонимной функции
func myMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// TODO: Execute our middleware logic here...
next.ServeHTTP(w, r)
})
}
Объяснение middleware
Middleware в Go: кратко и понятно
Middleware — это промежуточный слой между HTTP-запросом и вашим обработчиком. Он позволяет выполнять код до или после основного обработчика (например, логирование, проверку авторизации, сжатие данных).
1. Глобальный Middleware (для всех запросов)
Если middleware добавлен до servemux
(роутера), то он будет выполняться для всех запросов.
Схема:
Запрос → Middleware → Servemux → Обработчик
Пример:
func logRequest(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Printf("Запрос: %s %s", r.Method, r.URL.Path)
next.ServeHTTP(w, r) // Передаем запрос дальше
})
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", homeHandler)
// Middleware применяется ко ВСЕМ запросам
app := logRequest(mux)
http.ListenAndServe(":8080", app)
}
Когда использовать:
✅ Логирование
✅ Сжатие ответов (gzip)
✅ Добавление заголовков CORS
2. Локальный Middleware (для конкретных маршрутов)
Если middleware добавлен после servemux
(оборачивает конкретный обработчик), то он работает только для определенных маршрутов.
Схема:
Запрос → Servemux → Middleware → Обработчик
Пример:
func checkAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !isAuthenticated(r) {
http.Error(w, "Доступ запрещен", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r) // Пропускаем авторизованных
})
}
func main() {
mux := http.NewServeMux()
mux.Handle("/admin", checkAuth(adminHandler)) // Только для /admin
mux.HandleFunc("/", homeHandler) // Без middleware
http.ListenAndServe(":8080", mux)
}
Когда использовать:
✅ Проверка авторизации
✅ Валидация данных
✅ Лимитирование запросов (rate limiting)
Вывод:
- До
servemux
→ Middleware на все запросы. - После
servemux
→ Middleware только для выбранных маршрутов.
Это дает гибкость в управлении логикой приложения без дублирования кода. 🚀
Создадим обработчик заголовков для всех страниц
Будем возвращать шаблоны html и к каждому будем записывать правильный заголовок в ответе
Content-Security-Policy: default-src 'self'; style-src 'self' fonts.googleapis.com; font-src fonts.gstatic.com
Referrer-Policy: origin-when-cross-origin
X-Content-Type-Options: nosniff
X-Frame-Options: deny
X-XSS-Protection: 0
Объяснение HEADERS
Краткое описание HTTP-заголовков безопасности
Эти заголовки защищают веб-приложение от распространённых атак (XSS, clickjacking, MIME-sniffing и др.).
1. Content-Security-Policy
(CSP)
Назначение: Ограничивает источники загрузки ресурсов (JS, CSS, шрифты, изображения).
Пример:
Content-Security-Policy: default-src 'self'; font-src fonts.gstatic.com; style-src fonts.googleapis.com 'self'
Эффект:
- Блокирует inline-JS (
unsafe-inline
) - Разрешает шрифты только с
fonts.gstatic.com
- Стили — с
fonts.googleapis.com
и своего домена
Другие варианты:
script-src 'self' https://trusted.cdn.com
— разрешает скрипты только с указанных источниковimg-src *
— разрешает изображения с любых доменов (небезопасно!)
2. Referrer-Policy
Назначение: Контролирует, какие данные отправляются в заголовке Referer
при переходе на другой сайт.
Пример:
Referrer-Policy: origin-when-cross-origin
Эффект:
- При переходе внутри сайта (
same-origin
) передаётся полный URL. - При переходе на другой домен — только источник (без пути и параметров).
Другие варианты:
no-referrer
— вообще не отправлятьReferer
strict-origin
— всегда отправлять только домен (без протокола и пути)
3. X-Content-Type-Options: nosniff
Назначение: Запрещает браузеру “угадывать” тип контента (MIME-sniffing).
Пример:
X-Content-Type-Options: nosniff
Эффект:
- Браузер строго следует указанному
Content-Type
. - Защищает от атак, когда вредоносный файл маскируется под изображение/JS.
4. X-Frame-Options: deny
Назначение: Блокирует встраивание страницы в <iframe>
(защита от clickjacking).
Пример:
X-Frame-Options: deny
Альтернативы:
sameorigin
— разрешает iframe только с текущего домена- Устарел в пользу CSP с
frame-ancestors 'none'
5. X-XSS-Protection: 0
Назначение: Отключает встроенный XSS-фильтр браузера (устаревший механизм).
Почему 0?
- Современные браузеры используют CSP для защиты от XSS.
- Старые версии
X-XSS-Protection
иногда сами вызывали уязвимости.
Другие важные заголовки безопасности
-
Strict-Transport-Security
(HSTS)- Принудительное использование HTTPS:
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
-
Permissions-Policy
- Отключает нежелательные API (камера, геолокация и др.):
Permissions-Policy: geolocation=(), microphone=()
-
Cross-Origin-Opener-Policy
(COOP)- Защищает от атак типа Spectre:
Cross-Origin-Opener-Policy: same-origin
-
Cross-Origin-Embedder-Policy
(COEP)- Блокирует загрузку кросс-доменных ресурсов без CORS:
Cross-Origin-Embedder-Policy: require-corp
Итог
Заголовок | Защита от | Современная альтернатива |
---|---|---|
CSP |
XSS, data injection | — |
Referrer-Policy |
Утечка URL | — |
X-Content-Type-Options |
MIME-sniffing | — |
X-Frame-Options |
Clickjacking | CSP: frame-ancestors 'none' |
X-XSS-Protection |
Устаревший XSS-фильтр | CSP |
Оптимальный набор:
Content-Security-Policy: default-src 'self'; script-src 'self' https://trusted.cdn.com; frame-ancestors 'none'
Strict-Transport-Security: max-age=63072000; includeSubDomains
Referrer-Policy: strict-origin-when-cross-origin
X-Content-Type-Options: nosniff
- Создадим функцию для создания заголовков
package main
import (
"net/http"
)
func secureHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Note: This is split across multiple lines for readability. You don't
// need to do this in your own code.
w.Header().Set("Content-Security-Policy",
"default-src 'self'; style-src 'self' fonts.googleapis.com; font-src fonts.gstatic.com")
w.Header().Set("Referrer-Policy", "origin-when-cross-origin")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "deny")
w.Header().Set("X-XSS-Protection", "0")
next.ServeHTTP(w, r)
})
}
- Создадим функцию обработчик маршрутов
package main
import "net/http"
// Update the signature for the routes() method so that it returns a
// http.Handler instead of *http.ServeMux.
func (app *application) routes() http.Handler {
mux := http.NewServeMux()
fileServer := http.FileServer(http.Dir("./ui/static/"))
mux.Handle("/static/", http.StripPrefix("/static", fileServer))
mux.HandleFunc("/", app.home)
mux.HandleFunc("/snippet/view", app.snippetView)
mux.HandleFunc("/snippet/create", app.snippetCreate)
// Pass the servemux as the 'next' parameter to the secureHeaders middleware.
// Because secureHeaders is just a function, and the function returns a
// http.Handler we don't need to do anything else.
return secureHeaders(mux)
}
- После обработки маршрута для каждой страницы будут создан заголовок
Функция логирования запросов
func (app *application) logRequest(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
app.infoLog.Printf("%s - %s %s %s", r.RemoteAddr, r.Proto, r.Method, r.URL.RequestURI())
next.ServeHTTP(w, r)
})
}
и для нашего случая выстроим цепочку middleware запросов
return app.logRequest(secureHeaders(mux))
полный код routes.go
package main
import "net/http"
func (app *application) routes() http.Handler {
mux := http.NewServeMux()
fileServer := http.FileServer(http.Dir("./ui/static/"))
mux.Handle("/static/", http.StripPrefix("/static", fileServer))
mux.HandleFunc("/", app.home)
mux.HandleFunc("/snippet/view", app.snippetView)
mux.HandleFunc("/snippet/create", app.snippetCreate)
// Wrap the existing chain with the logRequest middleware.
return app.logRequest(secureHeaders(mux))
}
Обработка паники в HTTP сервере
func (app *application) recoverPanic(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Create a deferred function (which will always be run in the event
// of a panic as Go unwinds the stack).
defer func() {
// Use the builtin recover function to check if there has been a
// panic or not. If there has...
if err := recover(); err != nil {
// Set a "Connection: close" header on the response.
w.Header().Set("Connection", "close")
// Call the app.serverError helper method to return a 500
// Internal Server response.
app.serverError(w, fmt.Errorf("%s", err))
}
}()
next.ServeHTTP(w, r)
})
}
Разбор HTTP-заголовка Connection: Close
и работы recover()
в Go
1. Заголовок Connection: Close
Назначение:
- Принудительно закрывает HTTP-соединение после отправки ответа.
- Сообщает клиенту, что сервер разорвёт соединение.
Особенности:
- В HTTP/1.x заголовок
Connection: Close
корректен и заставляет сервер закрыть соединение. - В HTTP/2 Go автоматически удаляет этот заголовок (чтобы не нарушать спецификацию) и вместо этого отправляет фрейм
GOAWAY
для graceful-закрытия.
Пример использования:
w.Header().Set("Connection", "Close") // Закрыть соединение после ответа
Когда применять:
- Для серверов с высокой нагрузкой, чтобы уменьшить число висящих соединений.
- В тестовых средах для принудительного сброса соединений.
2. Функция recover()
и обработка паник
Как работает recover()
:
- Возвращает значение, переданное в
panic()
(типany
). - Это значение может быть:
string
,error
, или любым другим типом.
justinas/alice Пример:
func handlePanic() {
if r := recover(); r != nil {
// Нормализация в error
err := fmt.Errorf("panic: %v", r)
app.serverError(err) // Обработка ошибки
}
}
Что происходит:
- Если вызвана
panic("oops! something went wrong")
,recover()
вернёт строку"oops! something went wrong"
. fmt.Errorf()
преобразует это значение вerror
.- Ошибка передаётся в метод
app.serverError()
для логирования/отправки клиенту.
Зачем это нужно:
- Чтобы избежать аварийного завершения программы при панике.
- Логировать ошибки и отправлять клиенту понятный ответ (например,
500 Internal Server Error
).
Важно:
recover()
работает только внутриdefer
.- Всегда проверяйте, что
recover()
вернул неnil
.
Заключительный вариант вызова middleware
return app.recoverPanic(app.logRequest(secureHeaders(mux)))
Делается обработка для всех запускаемых горутин.
Автор рекомендует пакет justinas/alice
Для удобного и читабельного управления middleware можно использовать пакет justinas/alice
Особенности:
- вместо
return myMiddleware1(myMiddleware2(myMiddleware3(myHandler)))
будем писатьreturn alice.New(myMiddleware1, myMiddleware2, myMiddleware3).Then(myHandler)
- есть методы Append и Then
myChain := alice.New(myMiddlewareOne, myMiddlewareTwo)
myOtherChain := myChain.Append(myMiddleware3)
return myOtherChain.Then(myHandler)
для нашего примера будет выглядеть:
standard := alice.New(app.recoverPanic, app.logRequest, secureHeaders)
// Return the 'standard' middleware chain followed by the servemux.
return standard.Then(mux)
Расширенные обработчики маршрутов
Автор предлагает вместо стандартной библиотеки Go использовать сторонних разработчиков:
- julienschmidt/httprouter,
- go-chi/chi,
- gorilla/mux.
сделав анализ современного развития этих пакетов можно прийти к выводу:
Сравнение пакетов
Вот сравнительный анализ трёх популярных HTTP-роутеров для Go (julienschmidt/httprouter, go-chi/chi, gorilla/mux) в сравнении со стандартным net/http
, а также рекомендации по их применению:
1. julienschmidt/httprouter
Отличия от net/http
:
✅ Высокая производительность – один из самых быстрых роутеров благодаря оптимизированному алгоритму сопоставления путей.
✅ Поддержка параметров в пути (например, /user/:id
) с синтаксисом :param
и *catch-all
.
✅ Четкое разделение методов HTTP (GET, POST и т. д.), что предотвращает коллизии.
❌ Нет встроенного middleware-стека (но можно подключить вручную).
❌ Менее гибкий, чем chi
и gorilla/mux
, из-за строгого подхода к роутингу.
Рекомендации:
✔ Используйте, если нужна максимальная скорость (например, в high-load API).
✔ Хорош для RESTful-сервисов с четкой структурой эндпоинтов.
✔ Не подходит, если нужна сложная логика маршрутизации или встроенная поддержка middleware.
2. go-chi/chi
Отличия от net/http
:
✅ Полная совместимость с net/http
– можно использовать стандартные http.Handler
и http.HandlerFunc
.
✅ Гибкость и простота – удобный синтаксис для группировки роутов и middleware.
✅ Встроенная поддержка middleware (например, CORS, логирование, аутентификация).
✅ Поддержка параметров в URL (/posts/{id}
) и regex-ограничений.
❌ Чуть медленнее, чем httprouter
, но разница незначительна в большинстве сценариев.
Рекомендации:
✔ Идеален для REST API и веб-приложений с middleware (аутентификация, логирование и т. д.).
✔ Подходит, если важна простота и совместимость со стандартной библиотекой.
✔ Лучший выбор для проектов, где баланс между скоростью и функциональностью критичен.
3. gorilla/mux
Отличия от net/http
:
✅ Богатые возможности маршрутизации – поддержка regex, условий на методы HTTP, query-параметров.
✅ Гибкость – можно задавать сложные правила для роутов (например, r.Host("example.com").Methods("GET")
).
✅ Поддержка middleware (через gorilla/handlers
или совместимость с http.Handler
).
❌ Медленнее, чем httprouter
и chi
.
❌ Сложнее в настройке из-за обилия возможностей.
❌ Проект в архиве (maintenance mode), но стабилен и используется в многих проектах.
Рекомендации:
✔ Хорош для legacy-проектов или сложных маршрутов с regex и условиями.
✔ Подходит, если нужен максимальный контроль над роутингом (например, поддомены, строгие условия).
✔ Лучше избегать в новых проектах (из-за статуса “архивный”), но старые проекты могут продолжать его использовать.
Сравнение со стандартным net/http
Роутер | Скорость | Гибкость | Middleware | Простота | Поддержка |
---|---|---|---|---|---|
net/http |
⚡️ Быстрый | ❌ Низкая | ❌ Нет | ✅ Простой | ✅ Активен |
httprouter |
⚡️⚡️ Очень быстрый | ❌ Жёсткий | ❌ Требует костылей | ✅ Простой | ✅ Активен |
chi |
⚡️ Быстрый | ✅ Высокая | ✅ Встроенный | ✅ Простой | ✅ Активен |
gorilla/mux |
🐢 Умеренный | ✅ Очень высокая | ✅ Через handlers |
❌ Сложный | ❌ Архивный |
Итоговые рекомендации:
- Для высоконагруженных API →
httprouter
. - Для баланса скорости и функциональности →
chi
. - Для сложных условий маршрутизации →
gorilla/mux
(но лучше мигрировать наchi
). - Для минимализма и обучения → стандартный
net/http
.
Если нужна максимальная скорость – httprouter
.
Если нужен удобный и современный роутер – chi
.
Если проект уже использует gorilla/mux
– можно оставить, но для новых проектов лучше chi
.
Чистые URL и маршрутизация на основе методов
Установка пакета julienschmidt/httprouter
,
go get github.com/julienschmidt/httprouter@v1
Синтаксис пакета:
package main
import (
"net/http"
"github.com/julienschmidt/httprouter" // New import
"github.com/justinas/alice"
)
func (app *application) routes() http.Handler {
// Initialize the router.
router := httprouter.New()
// Update the pattern for the route for the static files.
fileServer := http.FileServer(http.Dir("./ui/static/"))
router.Handler(http.MethodGet, "/static/*filepath", http.StripPrefix("/static", fileServer))
// And then create the routes using the appropriate methods, patterns and
// handlers.
router.HandlerFunc(http.MethodGet, "/", app.home)
router.HandlerFunc(http.MethodGet, "/snippet/view/:id", app.snippetView)
router.HandlerFunc(http.MethodGet, "/snippet/create", app.snippetCreate)
router.HandlerFunc(http.MethodPost, "/snippet/create", app.snippetCreatePost)
// Create the middleware chain as normal.
standard := alice.New(app.recoverPanic, app.logRequest, secureHeaders)
// Wrap the router with the middleware and return it as normal.
return standard.Then(router)
}
Особенности передачи параметров в пакете httprouter
//данные передаются в контексте типа Params
func (app *application) snippetView(w http.ResponseWriter, r *http.Request) {
params := httprouter.ParamsFromContext(r.Context()) //получаем данные запроса
id, err := strconv.Atoi(params.ByName("id")) //считываем по ключу id значение переданного id
if err != nil || id < 1 {
app.notFound(w)
return
}
//далее продолжаем обработку
поменяли обработку запроса в шаблоне
<td><a href='/snippet/view/{{.ID}}'>{{.Title}}</a></td>
Обработка ошибок
в пакете есть специальный метод app.notFound()
настройка в начале файла routes.go
func (app *application) routes() http.Handler {
router := httprouter.New()
router.NotFound = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
app.notFound(w)
})
позволит несуществующие пути обрабатывать ошибкой 404
Обработка форм
- Выполнить запрос GET к серверу для запроса формы
- Заполнение формы и отправка на сервер результата методом POST
- Обработка формы и валидация handler-ом
snippetCreatePost
- После проверки и добавление редирект на
/snippet/view/:id
для просмотра результата
Шаблон формы create.tmpl
{{define "title"}}Create a New Snippet{{end}}
{{define "main"}}
<form action='/snippet/create' method='POST'>
<div>
<label>Title:</label>
<input type='text' name='title'>
</div>
<div>
<label>Content:</label>
<textarea name='content'></textarea>
</div>
<div>
<label>Delete in:</label>
<input type='radio' name='expires' value='365' checked> One Year
<input type='radio' name='expires' value='7'> One Week
<input type='radio' name='expires' value='1'> One Day
</div>
<div>
<input type='submit' value='Publish snippet'>
</div>
</form>
{{end}}
Ссылка на вызов формы nav.html
в меню добавим пункт с вызовом формы
{{define "nav"}}
<nav>
<a href='/'>Home</a>
<!-- Add a link to the new form -->
<a href='/snippet/create'>Create snippet</a>
</nav>
{{end}}
Обработчик формы handler
func (app *application) snippetCreate(w http.ResponseWriter, r *http.Request) {
data := app.newTemplateData(r)
app.render(w, http.StatusOK, "create.tmpl", data)
}
Парсинг формы
r.ParseForm()
метод парсинга и проверки формы. Парсит форму и сохраняет значения в map r.ParseForm
Для получения значений из полей формы, используем r.PostForm.Get()
метод. Например: r.PostForm.Get("title")
Функция обработчик формы:
func (app *application) snippetCreatePost(w http.ResponseWriter, r *http.Request) {
//парсинг формы и проверка ошибок
err := r.ParseForm()
if err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
//получение значений title и content
title := r.PostForm.Get("title")
content := r.PostForm.Get("content")
//преобразование в целое число, так как все значения приходят в формате string
expires, err := strconv.Atoi(r.PostForm.Get("expires"))
if err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
//выполнение запроса к базе данных
id, err := app.snippets.Insert(title, content, expires)
if err != nil {
app.serverError(w, err)
return
}
http.Redirect(w, r, fmt.Sprintf("/snippet/view/%d", id), http.StatusSeeOther)
}
Для справки
r.PostForm
map популярен только для POST
, PATCH
и PUT
запросов. Но еще есть карта r.Form
которая сохраняет не только значение тела запроса но и из строки /snippet/create?foo=bar
Стандартные методы r.FormValue()
и r.PostFormValue()
автор не рекомендует использовать из-за сложностей в обработке ошибок.
Обработка множественных значений
r.PostForm.Get() метод Get не работает с множественными значениями и вернет только первый
например флажки чекбокса
В этом случае вам нужно будет работать непосредственно с картой r.PostForm. Основным типом карты r.PostForm является url.Values, который, в свою очередь, имеет базовый тип map[string][]string
. Таким образом, для полей с несколькими значениями вы можете пройтись по базовой карте, чтобы получить к ним доступ следующим образом:
for i, item := range r.PostForm["items"] {
fmt.Fprintf(w, "%d: Item %s\n", i, item)
}
Ограничение размера формы
Если вы не отправляете составные данные (т.е. ваша форма имеет атрибут enctype="multipart/form-data"
), то тела запросов POST
, PUT
и PATCH
ограничены 10 МБ. Если это значение будет превышено, то r.ParseForm()
вернет ошибку.
Если вы хотите изменить это ограничение, вы можете использовать http.MaxBytesReader()
функцию.
// Limit the request body size to 4096 bytes
r.Body = http.MaxBytesReader(w, r.Body, 4096)
err := r.ParseForm()
if err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
Кроме того, при достижении лимита MaxBytesReader устанавливает флаг на http.ResponseWriter
, который указывает серверу закрыть базовое TCP-соединение.
Валидация формы
// создадим переменную для валидации
fieldErrors := make(map[string]string)
// проверка, что title не пусто и не больше 100 знаков. Результат проблем записываем в нашу map с ключом именем поля
if strings.TrimSpace(title) == "" {
fieldErrors["title"] = "This field cannot be blank"
} else if utf8.RuneCountInString(title) > 100 {
fieldErrors["title"] = "This field cannot be more than 100 characters long"
}
// проверяем на пустоту content и записываем в map если есть проблемы
if strings.TrimSpace(content) == "" {
fieldErrors["content"] = "This field cannot be blank"
}
// проверяем что поле равно 1 7 или 365
if expires != 1 && expires != 7 && expires != 365 {
fieldErrors["expires"] = "This field must equal 1, 7 or 365"
}
// а теперь простая проверка всего map. Если map не пустой, значит есть ошибки и возвращаем ошибку. Точнее возвращаем весь map.
if len(fieldErrors) > 0 {
fmt.Fprint(w, fieldErrors)
return
}
Отображение ошибок на форме
- В обработчик шаблонов templates.go добавим поле Form
type templateData struct {
CurrentYear int
Snippet *models.Snippet
Snippets []*models.Snippet
Form any
}
поле Form необходимо для отправки результатов валидации формы
- В handlers.go добавим map с результатами валидации в структуру snippetCreateForm
type snippetCreateForm struct {
Title string
Content string
Expires int
FieldErrors map[string]string
}
- Заполним структуру значениями
form := snippetCreateForm{
Title: r.PostForm.Get("title"),
Content: r.PostForm.Get("content"),
Expires: expires,
FieldErrors: map[string]string{},
}
- Выполним валидацию
- Если есть ошибки то возвращаем ответ с ошибками
if len(form.FieldErrors) > 0 {
data := app.newTemplateData(r)
data.Form = form
app.render(w, http.StatusUnprocessableEntity, "create.tmpl", data)
return
}
т.е. вызываем эту же форму, только с заполненным map с ошибками, но если ошибок нет, то этот шаг не выполнится и пойдет обращение к базе данных
Изменяем шаблон для отображения ошибок
- Обновим create.tmpl шаблон формы
- Для повторного отображения введенных значений, используем значения поля Form
{{.Form.Title}}
и{{.Form.Content}}
- Для отображения ошибок валидации используем значения map:
{{.Form.FieldErrors.title}}
и другие - Шаблон стал теперь таким
{{define "title"}}Create a New Snippet{{end}}
{{define "main"}}
<form action='/snippet/create' method='POST'>
<div>
<label>Title:</label>
<!-- Use the `with` action to render the value of .Form.FieldErrors.title
if it is not empty. -->
{{with .Form.FieldErrors.title}}
<label class='error'>{{.}}</label>
{{end}}
<!-- Re-populate the title data by setting the `value` attribute. -->
<input type='text' name='title' value='{{.Form.Title}}'>
</div>
<div>
<label>Content:</label>
<!-- Likewise render the value of .Form.FieldErrors.content if it is not
empty. -->
{{with .Form.FieldErrors.content}}
<label class='error'>{{.}}</label>
{{end}}
<!-- Re-populate the content data as the inner HTML of the textarea. -->
<textarea name='content'>{{.Form.Content}}</textarea>
</div>
<div>
<label>Delete in:</label>
<!-- And render the value of .Form.FieldErrors.expires if it is not empty. -->
{{with .Form.FieldErrors.expires}}
<label class='error'>{{.}}</label>
{{end}}
<!-- Here we use the `if` action to check if the value of the re-populated
expires field equals 365. If it does, then we render the `checked`
attribute so that the radio input is re-selected. -->
<input type='radio' name='expires' value='365' {{if (eq .Form.Expires 365)}}checked{{end}}> One Year
<!-- And we do the same for the other possible values too... -->
<input type='radio' name='expires' value='7' {{if (eq .Form.Expires 7)}}checked{{end}}> One Week
<input type='radio' name='expires' value='1' {{if (eq .Form.Expires 1)}}checked{{end}}> One Day
</div>
<div>
<input type='submit' value='Publish snippet'>
</div>
</form>
{{end}}
- В обработчик handlers.go нужно обязательно добавить инициализацию Form для пустых значений, чтобы не было ошибки
func (app *application) snippetCreate(w http.ResponseWriter, r *http.Request) {
data := app.newTemplateData(r)
data.Form = snippetCreateForm{
Expires: 365,
}
app.render(w, http.StatusOK, "create.tmpl", data)
}
Создание хелперов валидации
Просто выносим всю валидацию из кода в отдельный модуль internal/validator
mkdir internal/validator
touch internal/validator/validator.go
создаем файл validator.go
File: internal/validator/validator.go
package validator
import (
"strings"
"unicode/utf8"
)
// Define a new Validator type which contains a map of validation errors for our
// form fields.
type Validator struct {
FieldErrors map[string]string
}
// Valid() returns true if the FieldErrors map doesn't contain any entries.
func (v *Validator) Valid() bool {
return len(v.FieldErrors) == 0
}
// AddFieldError() adds an error message to the FieldErrors map (so long as no
// entry already exists for the given key).
func (v *Validator) AddFieldError(key, message string) {
// Note: We need to initialize the map first, if it isn't already
// initialized.
if v.FieldErrors == nil {
v.FieldErrors = make(map[string]string)
}
if _, exists := v.FieldErrors[key]; !exists {
v.FieldErrors[key] = message
}
}
// CheckField() adds an error message to the FieldErrors map only if a
// validation check is not 'ok'.
func (v *Validator) CheckField(ok bool, key, message string) {
if !ok {
v.AddFieldError(key, message)
}
}
// NotBlank() returns true if a value is not an empty string.
func NotBlank(value string) bool {
return strings.TrimSpace(value) != ""
}
// MaxChars() returns true if a value contains no more than n characters.
func MaxChars(value string, n int) bool {
return utf8.RuneCountInString(value) <= n
}
// PermittedInt() returns true if a value is in a list of permitted integers.
func PermittedInt(value int, permittedValues ...int) bool {
for i := range permittedValues {
if value == permittedValues[i] {
return true
}
}
return false
}
Корректируем handlers.go
для использования валидатора внесем изменения
добавим импорт:
"snippetbox.alexedwards.net/internal/validator" // New import
- Убрали map и поставили validator.Validator
type snippetCreateForm struct {
Title string
Content string
Expires int
validator.Validator
}
основное преимущество validator.Validator в том, что он содержит все поля map и методы обработки
- При заполнении структуры Form, теперь не нужно заполонять map с ошибками
form := snippetCreateForm{
Title: r.PostForm.Get("title"),
Content: r.PostForm.Get("content"),
Expires: expires,
// Remove the FieldErrors assignment from here.
}
- Вызов проверок валидации теперь доступны прямо на переменной form
form.CheckField(validator.NotBlank(form.Title), "title", "This field cannot be blank")
form.CheckField(validator.MaxChars(form.Title, 100), "title", "This field cannot be more than 100 characters long")
form.CheckField(validator.NotBlank(form.Content), "content", "This field cannot be blank")
form.CheckField(validator.PermittedInt(form.Expires, 1, 7, 365), "expires", "This field must equal 1, 7 or 365")
- Проверяем валидность формы
if !form.Valid() {
data := app.newTemplateData(r)
data.Form = form
app.render(w, http.StatusUnprocessableEntity, "create.tmpl", data)
return
}
Автоматический парсинг форм
Автор предлагает использовать сторонний пакет go-playground/form
который по сути делает тоже самое, но более гибко и универсально
2 - Alex Edwards Let's Go часть 2
Stateful HTTP
Отслеживание состояний в HTTP
Будем выводить для начала сообщение по результатам добавления новой формы
Выбор менеджера сессий
Сравнение и анализ менеджеров сессий
1. Сравнение gorilla/sessions и alexedwards/scs
Характеристика | gorilla/sessions | alexedwards/scs |
---|---|---|
Тип хранилища | Cookie, файлы, Redis, Memcached, другие (через сторонние адаптеры) | Cookie, Redis, PostgreSQL, MySQL, SQLite, in-memory |
Безопасность | Поддержка подписанных кук, но требует ручной настройки | Встроенная защита от подделки (secure cookies), автоматическое обновление токенов |
Производительность | Зависит от бэкенда (Redis быстрее, чем куки) | Оптимизирован для работы с разными хранилищами, меньше накладных расходов |
Гибкость | Гибкий API, можно настраивать под разные сценарии | Более структурированный API, но менее гибкий |
Поддержка контекста | Нет встроенной поддержки context.Context |
Полная интеграция с context.Context |
Автоматическое продление | Нет (нужно реализовывать вручную) | Есть (автоматически обновляет сессии) |
Зависимости | Часть Gorilla Toolkit (можно использовать отдельно) | Независимая библиотека |
Активность разработки | Заморожен (Gorilla Toolkit больше не поддерживается) | Активно развивается |
Использование в продакшене | Широко использовался, но сейчас менее предпочтителен | Рекомендуется для новых проектов |
Вывод:
- Gorilla/sessions — устаревающий, но проверенный вариант. Подходит, если уже используется Gorilla Toolkit.
- SCS (alexedwards/scs) — современный, безопасный и более удобный для новых проектов.
2. Другие популярные менеджеры сессий в Go
a) Gin Sessions (для фреймворка Gin)
- Особенности: Интеграция с Gin, поддержка Redis, Cookie, Memcached.
- Плюсы: Простота использования в Gin-приложениях.
- Минусы: Зависит от Gin, не подходит для других фреймворков.
- Ссылка: github.com/gin-contrib/sessions
b) Beego Session
- Особенности: Встроен в фреймворк Beego, поддерживает файлы, Redis, Memcached, MySQL, PostgreSQL.
- Плюсы: Готовое решение для Beego.
- Минусы: Привязан к Beego.
- Ссылка: beego.me
c) Go-chi/session (от создателей Chi)
- Особенности: Минималистичный, работает с
context.Context
, поддерживает Redis и куки. - Плюсы: Легковесный, хорош для микрофреймворков.
- Минусы: Меньше возможностей, чем у SCS.
- Ссылка: github.com/go-chi/session
d) Iriris Sessions
- Особенности: Интеграция с фреймворком Iris, поддержка Redis, BoltDB, Badger, файлы.
- Плюсы: Высокая производительность, удобство в Iris.
- Минусы: Привязан к Iris.
- Ссылка: github.com/kataras/iris/sessions
3. Какой выбрать?
- Для новых проектов → alexedwards/scs (безопасность, контекст, активная разработка).
- Для Gin → gin-contrib/sessions.
- Для микрофреймворков (Chi, Echo) → go-chi/session или scs.
- Legacy-проекты на Gorilla → gorilla/sessions (но лучше мигрировать на scs).
Установка alexedwards/scs
go get github.com/alexedwards/scs/v2@v2
go get github.com/alexedwards/scs/mysqlstore
Создать таблицу в базе данных для хранения сессий
Поддерживает много различных баз данных о чем говорит в документации: https://github.com/alexedwards/scs
USE snippetbox;
CREATE TABLE sessions (
token CHAR(43) PRIMARY KEY,
data BLOB NOT NULL,
expiry TIMESTAMP(6) NOT NULL
);
CREATE INDEX sessions_expiry_idx ON sessions (expiry);
этот пример для нашей базы snippetbox
- Поле токена (tocken) будет содержать уникальный, случайно сгенерированный идентификатор для каждого сеанса.
- Поле данных (data) будет содержать фактические данные сеанса, которые вы хотите использовать совместно с HTTP-запросами. Они хранятся в виде двоичных данных типа BLOB (двоичный большой объект).
- Поле истечения срока действия (expiry) будет содержать время истечения срока действия сеанса.
Пакет scs автоматически удалит истекшие сеансы из таблицы сеансов, чтобы она не стала слишком большой.
Изменения в main.go
- Добавим импорты
"github.com/alexedwards/scs/mysqlstore" // New import
"github.com/alexedwards/scs/v2" // New import
- Добавим поле сессии в структуру приложения
type application struct {
errorLog *log.Logger
infoLog *log.Logger
snippets *models.SnippetModel
templateCache map[string]*template.Template
formDecoder *form.Decoder
sessionManager *scs.SessionManager
}
- Настройка менеджера сессий
sessionManager := scs.New() // Используем scs.New() для инициализации нового менеджера сессий.
sessionManager.Store = mysqlstore.New(db) //настраиваем его на использование нашей базы данных MySQL в качестве хранилища сессий
sessionManager.Lifetime = 12 * time.Hour //устанавливаем время жизни 12 часов
- Добавить менеджер сессий в структуру приложения
app := &application{
errorLog: errorLog,
infoLog: infoLog,
snippets: &models.SnippetModel{DB: db},
templateCache: templateCache,
formDecoder: formDecoder,
sessionManager: sessionManager,
}
Обернуть обработчики запросов
Чтобы сеансы работали, нам также необходимо обернуть маршруты наших приложений в промежуточное ПО, предоставляемое методом SessionManager.LoadAndSave(). Это промежуточное ПО автоматически загружает и сохраняет данные сеанса при каждом HTTP-запросе и ответе.
/static/*filepath - оборачивать не будем
Создадим ноувую цепочку для динамических данных сайта
Корректируем файл: routes.go
- Создаем переменную для учета сессий
dynamic := alice.New(app.sessionManager.LoadAndSave)
- Обертываем обработчики
router.Handler(http.MethodGet, "/", dynamic.ThenFunc(app.home))
router.Handler(http.MethodGet, "/snippet/view/:id", dynamic.ThenFunc(app.snippetView))
router.Handler(http.MethodGet, "/snippet/create", dynamic.ThenFunc(app.snippetCreate))
router.Handler(http.MethodPost, "/snippet/create", dynamic.ThenFunc(app.snippetCreatePost))
- Остальное оставляем без изменений
standard := alice.New(app.recoverPanic, app.logRequest, secureHeaders)
return standard.Then(router)
Если не используется пакет alice
синтаксис вызова будет другой
router := httprouter.New()
router.Handler(http.MethodGet, "/", app.sessionManager.LoadAndSave(http.HandlerFunc(app.home)))
router.Handler(http.MethodGet, "/snippet/view/:id", app.sessionManager.LoadAndSave(http.HandlerFunc(app.snippetView)))
// ... etc
Работа с данными сессии
Добавление сообщения об успешном добавлении сниппета
- Внесем изменения в файл handlers.go
Используйте метод Put() для добавления строкового значения (“Сниппет успешно создан!”) и соответствующего ключа (“flash”) к данным сессии.
func (app *application) snippetCreatePost(w http.ResponseWriter, r *http.Request) {
var form snippetCreateForm
...
app.sessionManager.Put(r.Context(), "flash", "Snippet successfully created!")
- Первый параметр, который мы передаем в app.sessionManager.Put(), — это текущий контекст запроса (это место, где менеджер сеансов временно хранит информацию, пока ваши обработчики работают с запросом.)
- Второй параметр (в нашем случае строка “flash”) является ключом для конкретного сообщения, которое мы добавляем в данные сессии. Впоследствии мы получим сообщение из данных сеанса также с помощью этого ключа.
Если для текущего пользователя нет существующего сеанса (или срок его сеанса истек), то новый пустой сеанс для него будет автоматически создан промежуточным программным обеспечением сеанса.
- Далее мы хотим, чтобы наш обработчик snippetView извлек флэш-сообщение (если оно существует в сессии для текущего пользователя) и передал его в HTML-шаблон для последующего отображения.
Поскольку мы хотим отобразить флэш-сообщение только один раз, на самом деле мы хотим извлечь и удалить сообщение из данных сеанса. Мы можем выполнить обе эти операции одновременно с помощью метода PopString().
func (app *application) snippetView(w http.ResponseWriter, r *http.Request) {
params := httprouter.ParamsFromContext(r.Context())
...
flash := app.sessionManager.PopString(r.Context(), "flash")
data := app.newTemplateData(r)
data.Snippet = snippet
// Pass the flash message to the template.
data.Flash = flash
app.render(w, http.StatusOK, "view.tmpl", data)
}
Информация
Информация: Если вы хотите получить значение только из данных сеанса (и оставить его там), вы можете использовать метод GetString(). Пакет scs также предоставляет методы для получения других распространенных типов данных, включая GetInt(), GetBool(), GetBytes() и GetTime().- Добавить поле Flash в структуру данных templateData (в файле templates.go)
type templateData struct {
CurrentYear int
Snippet *models.Snippet
Snippets []*models.Snippet
Form any
Flash string // Add a Flash field to the templateData struct.
}
- Внесем изменения в шаблон base.tmpl
{{define "base"}}
<!doctype html>
<html lang='en'>
<head>
<meta charset='utf-8'>
<title>{{template "title" .}} - Snippetbox</title>
<link rel='stylesheet' href='/static/css/main.css'>
<link rel='shortcut icon' href='/static/img/favicon.ico' type='image/x-icon'>
<link rel='stylesheet' href='https://fonts.googleapis.com/css?family=Ubuntu+Mono:400,700'>
</head>
<body>
<header>
<h1><a href='/'>Snippetbox</a></h1>
</header>
{{template "nav" .}}
<main>
<!-- Display the flash message if one exists -->
{{with .Flash}}
<div class='flash'>{{.}}</div>
{{end}}
{{template "main" .}}
</main>
<footer>
Powered by <a href='https://golang.org/'>Go</a> in {{.CurrentYear}}
</footer>
<script src="/static/js/main.js" type="text/javascript"></script>
</body>
</html>
{{end}}
Помните
{{with .Flash}} будет выполнен только в том случае, если значение .Flash не является пустой строкой. Таким образом, если в сеансе текущего пользователя нет ключа «flash», то в результате часть новой разметки просто не будет отображаться.- Модернизируем функцию newTemplateData в файле helpers.go для вывода других сообщений при совершении действий на сайте.
func (app *application) newTemplateData(r *http.Request) *templateData {
return &templateData{
CurrentYear: time.Now().Year(),
// Add the flash message to the template data, if one exists.
Flash: app.sessionManager.PopString(r.Context(), "flash"),
}
}
поэтому теперь можно убрать строки из файла handlers.go
flash := app.sessionManager.PopString(r.Context(), "flash") //убрать
...
data.Flash = flash //убрать
Безопасность данных
Создание самозаверяющего сертификата TLS
HTTPS — это, по сути, HTTP, отправляемый через соединение TLS (Transport Layer Security).
Поскольку данные отправляются через соединение TLS, они шифруются и подписываются, что помогает обеспечить их конфиденциальность и целостность во время передачи.
Прежде чем наш сервер сможет начать использовать HTTPS, нам необходимо сгенерировать сертификат TLS.
Удобно отметить, что пакет crypto/tls в стандартной библиотеке Go включает в себя инструмент generate_cert.go, который мы можем использовать для простого создания собственного самоподписанного сертификата.
Создание своего самозаписывающего сертификата
cd $HOME/code/snippetbox
mkdir tls
cd tls
Чтобы запустить инструмент generate_cert.go, вам нужно знать место на вашем компьютере, где установлен исходный код стандартной библиотеки Go.
Если вы используете Linux, macOS или FreeBSD и следуете официальным инструкциям по установке, то файл generate_cert.go должен находиться в папке /usr/local/go/src/crypto/tls
.
Запуск генерации сертификата
$ go run /usr/local/go/src/crypto/tls/generate_cert.go --rsa-bits=2048 --host=localhost
на manjaro оказался на /usr/lib/go/src/crypto/tls/generate_cert.go
для поиска можно воспользоваться командой:
find /usr -name 'generate_cert.go'
За кулисами инструмент generate_cert.go работает в два этапа:
- сначала он генерирует 2048-битную пару ключей RSA, которая является криптографически защищенным открытым ключом и закрытым ключом.
- Затем он сохраняет закрытый ключ в файле key.pem и генерирует самоподписанный сертификат TLS для хоста localhost, содержащий открытый ключ, который он хранит в файле cert.pem.
Как закрытый ключ, так и сертификат закодированы в PEM, что является стандартным форматом, используемым в большинстве реализаций TLS.
Запуск HTTPS сервера
- Заменить в файле main.go srv.ListenAndServe() на srv.ListenAndServeTLS()
err = srv.ListenAndServeTLS("./tls/cert.pem", "./tls/key.pem")
errorLog.Fatal(err)
- Запустить приложение
- Открыть сайт https://localhost:4000/ по протоколу https
Важно
Важно отметить, что пользователь, которого вы используете для запуска приложения Go, должен иметь разрешения на чтение файлов cert.pem и key.pem, в противном случае ListenAndServeTLS() вернет ошибку отказа в разрешении.Занесите каталог tls в .gitignore
нежелательно пушить сертификаты в репозиторий
Настройка параметров HTTPS
Go имеет хорошие настройки по умолчанию для своего HTTPS-сервера, но можно оптимизировать и настроить поведение сервера.
tls.CurveP256 and tls.X25519 - рекомендуется использовать эти протоколы
Добавим настройки в файл main.go
- Добавим импорт
import (
"crypto/tls" // New import
- Добавим tlsConfig
tlsConfig := &tls.Config{
CurvePreferences: []tls.CurveID{tls.X25519, tls.CurveP256},
}
- Добавим tls настройки в структуру srv
srv := &http.Server{
Addr: *addr,
ErrorLog: errorLog,
Handler: app.routes(),
TLSConfig: tlsConfig,
}
Настройка версий TLS
tlsConfig := &tls.Config{
MinVersion: tls.VersionTLS12,
MaxVersion: tls.VersionTLS12,
}
Ограничение наборов шифров Полный набор наборов шифров, поддерживаемых Go,
tlsConfig := &tls.Config{
CipherSuites: []uint16{
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
},
}
Тайм-ауты подключения
Давайте уделим немного времени, чтобы повысить отказоустойчивость нашего сервера, добавив некоторые настройки тайм-аута, например:
srv := &http.Server{
Addr: *addr,
ErrorLog: errorLog,
Handler: app.routes(),
TLSConfig: tlsConfig,
// Add Idle, Read and Write timeouts to the server.
IdleTimeout: time.Minute,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
}
добавляем в структуру srv необходимые поля для настройки таймаута
Все эти три тайм-аута — IdleTimeout, ReadTimeout и WriteTimeout — являются общесерверными настройками, которые действуют на базовое соединение и применяются ко всем запросам независимо от их обработчика или URL-адреса.
Параметр IdleTimeout
По умолчанию Go включает проверку активности для всех принятых подключений. Это помогает уменьшить задержку (особенно для подключений HTTPS), поскольку клиент может повторно использовать одно и то же соединение для нескольких запросов без необходимости повторять рукопожатие.
В нашем случае мы установили IdleTimeout на 1 минуту, что означает, что все keep-alive соединения будут автоматически закрыты после 1 минуты бездействия.
Параметр ReadTimeout
В нашем коде мы также установили параметр ReadTimeout равным 5 секундам. Это означает, что если заголовки или тело запроса все еще считываются через 5 секунд после первого принятия запроса, то Go закроет базовое соединение. Поскольку это “жесткое” замыкание соединения, пользователь не получит никакого ответа HTTP(S). Установка короткого периода ReadTimeout помогает снизить риск атак на медленных клиентов, таких как Slowloris, которые в противном случае могли бы держать соединение открытым на неопределенный срок, отправляя частичные, неполные HTTP(S) запросы.
Параметр WriteTimeout
Параметр WriteTimeout закроет базовое соединение, если наш сервер попытается записать данные в соединение через определенный период времени (в нашем коде это 10 секунд).
Но это ведет себя немного по-разному в зависимости от используемого протокола.
Для HTTP-соединений, если некоторые данные записываются в соединение более чем через 10 секунд после завершения чтения заголовка запроса, Go закроет базовое соединение, а не запишет данные.
Для соединений HTTPS, если некоторые данные записываются в соединение более чем через 10 секунд после первого принятия запроса, Go закроет базовое соединение вместо записи данных.
Это означает, что если вы используете HTTPS (как мы), имеет смысл установить значение WriteTimeout больше, чем ReadTimeout.
Важно помнить, что записи, выполненные обработчиком, буферизуются и записываются в соединение как единое целое при возврате обработчика. Таким образом, идея WriteTimeout обычно заключается не в том, чтобы предотвратить длительную работу обработчиков, а в том, чтобы предотвратить слишком долгую запись данных, возвращаемых обработчиком.
Настройка ReadHeaderTimeout
Функция http.Server также предоставляет параметр ReadHeaderTimeout, который мы не использовали в нашем приложении. Это работает аналогично ReadTimeout, за исключением того, что применяется только к чтению заголовков HTTP(S).
Это может быть полезно, если вы хотите применить ограничение на уровне сервера для чтения заголовков запросов, но хотите реализовать разные тайм-ауты на разных маршрутах, когда дело доходит до чтения тела запроса (возможно, с использованием http.TimeoutHandler() промежуточное ПО).
Для нашего веб-приложения Snippetbox у нас нет никаких действий, которые гарантируют тайм-ауты чтения для каждого маршрута — чтение заголовков и тел запросов для всех наших маршрутов должно быть комфортно завершено за 5 секунд, поэтому мы будем использовать ReadTimeout.
Настройка MaxHeaderBytes
http.Server также предоставляет поле MaxHeaderBytes, которое можно использовать для управления максимальным количеством байтов, которое сервер будет считывать при анализе заголовков запросов. По умолчанию Go разрешает максимальную длину заголовка 1 МБ.
Например, если вы хотите ограничить максимальную длину заголовка до 0,5 МБ, вы должны написать:
srv := &http.Server{
Addr: *addr,
MaxHeaderBytes: 524288,
...
}
Аутентификация пользователей
- Пользователь должен зарегистрироваться, посетив форму по адресу /user/signup и введя свое имя, адрес электронной почты и пароль. Мы сохраним эту информацию в новой таблице базы данных пользователей.
- Пользователь войдет в систему, посетив форму по адресу /user/login и введя свой адрес электронной почты и пароль.
- Затем мы проверим базу данных, чтобы увидеть, совпадают ли введенные ими адрес электронной почты и пароль с одним из пользователей в таблице пользователей. Если совпадение найдено, пользователь успешно прошел проверку подлинности, и мы добавляем соответствующее значение id для пользователя в данные его сеанса с помощью ключа “authenticatedUserID”.
- Когда мы получаем какие-либо последующие запросы, мы можем проверить данные сеанса пользователя на наличие значения “authenticatedUserID”. Если она существует, мы знаем, что пользователь уже успешно вошел в систему. Мы можем продолжать проверять это до тех пор, пока сессия не истечет, когда пользователю потребуется снова войти в систему. Если в сеансе отсутствует “authenticatedUserID”, мы знаем, что пользователь не вошел в систему.
Подготовительные мероприятия для создания инфраструктуры данных
Добавим новые маршруты для регистрации пользователей
HTTP Method | Route | Handler | Description |
---|---|---|---|
GET | /user/signup | userSignup | Display a HTML form for signing up a new user |
POST | /user/signup | userSignupPost | Create a new user |
GET | /user/login | userLogin | Display a HTML form for logging in a user |
POST | /user/login | userLoginPost | Authenticate and login the user |
POST | /user/logout | userLogoutPost | Logout the user |
Пропишем в файле routes.go данные маршруты:
router.Handler(http.MethodGet, "/user/signup", dynamic.ThenFunc(app.userSignup))
router.Handler(http.MethodPost, "/user/signup", dynamic.ThenFunc(app.userSignupPost))
router.Handler(http.MethodGet, "/user/login", dynamic.ThenFunc(app.userLogin))
router.Handler(http.MethodPost, "/user/login", dynamic.ThenFunc(app.userLoginPost))
router.Handler(http.MethodPost, "/user/logout", dynamic.ThenFunc(app.userLogoutPost))
Создание обработчиков для маршрутов handlers.go
func (app *application) userSignup(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Display a HTML form for signing up a new user...")
}
func (app *application) userSignupPost(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Create a new user...")
}
func (app *application) userLogin(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Display a HTML form for logging in a user...")
}
func (app *application) userLoginPost(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Authenticate and login the user...")
}
func (app *application) userLogoutPost(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Logout the user...")
}
Создать ссылки на экране для вызова функций nav.tmpl
{{define "nav"}}
<nav>
<div>
<a href='/'>Home</a>
<a href='/snippet/create'>Create snippet</a>
</div>
<div>
<a href='/user/signup'>Signup</a>
<a href='/user/login'>Login</a>
<form action='/user/logout' method='POST'>
<button>Logout</button>
</form>
</div>
</nav>
{{end}}
Создать таблицу для пользователей
USE snippetbox;
CREATE TABLE users (
id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL,
hashed_password CHAR(60) NOT NULL,
created DATETIME NOT NULL
);
ALTER TABLE users ADD CONSTRAINT users_uc_email UNIQUE (email);
Мы также добавили ограничение UNIQUE в столбец электронной почты и назвали его users_uc_email. Это ограничение гарантирует, что у нас не будет двух пользователей с одинаковым адресом электронной почты. Если мы попытаемся вставить запись в эту таблицу с дубликатом электронной почты, MySQL выдаст ошибку 1062: ER_DUP_ENTRY ошибку.
Создать аналогичные методы и структуры для работы с данными пользователей
Добавить необходимые типы ошибок для обработки пользователей в файл errors.go
package models
import (
"errors"
)
var (
ErrNoRecord = errors.New("models: no matching record found")
// Add a new ErrInvalidCredentials error. We'll use this later if a user
// tries to login with an incorrect email address or password.
ErrInvalidCredentials = errors.New("models: invalid credentials")
// Add a new ErrDuplicateEmail error. We'll use this later if a user
// tries to signup with an email address that's already in use.
ErrDuplicateEmail = errors.New("models: duplicate email")
)
Создаем отдельный файл для обработки пользователей users.go
cd $HOME/code/snippetbox
touch internal/models/users.go
package models
import (
"database/sql"
"time"
)
// Define a new User type. Notice how the field names and types align
// with the columns in the database "users" table?
type User struct {
ID int
Name string
Email string
HashedPassword []byte
Created time.Time
}
// Define a new UserModel type which wraps a database connection pool.
type UserModel struct {
DB *sql.DB
}
// We'll use the Insert method to add a new record to the "users" table.
func (m *UserModel) Insert(name, email, password string) error {
return nil
}
// We'll use the Authenticate method to verify whether a user exists with
// the provided email address and password. This will return the relevant
// user ID if they do.
func (m *UserModel) Authenticate(email, password string) (int, error) {
return 0, nil
}
// We'll use the Exists method to check if a user exists with a specific ID.
func (m *UserModel) Exists(id int) (bool, error) {
return false, nil
}
Добавить новое поле в структуру приложения main.go
добавим поле users
type application struct {
errorLog *log.Logger
infoLog *log.Logger
snippets *models.SnippetModel
users *models.UserModel
templateCache map[string]*template.Template
formDecoder *form.Decoder
sessionManager *scs.SessionManager
}
func main() {
...
// Initialize a models.UserModel instance and add it to the application
// dependencies.
app := &application{
errorLog: errorLog,
infoLog: infoLog,
snippets: &models.SnippetModel{DB: db},
users: &models.UserModel{DB: db},
templateCache: templateCache,
formDecoder: formDecoder,
sessionManager: sessionManager,
}
...
Регистрация пользователей
Создадим форму регистрации ui/html/pages/signup.tmpl
$ cd $HOME/code/snippetbox
$ touch ui/html/pages/signup.tmpl
{{define "title"}}Signup{{end}}
{{define "main"}}
<form action='/user/signup' method='POST' novalidate>
<div>
<label>Name:</label>
{{with .Form.FieldErrors.name}}
<label class='error'>{{.}}</label>
{{end}}
<input type='text' name='name' value='{{.Form.Name}}'>
</div>
<div>
<label>Email:</label>
{{with .Form.FieldErrors.email}}
<label class='error'>{{.}}</label>
{{end}}
<input type='email' name='email' value='{{.Form.Email}}'>
</div>
<div>
<label>Password:</label>
{{with .Form.FieldErrors.password}}
<label class='error'>{{.}}</label>
{{end}}
<input type='password' name='password'>
</div>
<div>
<input type='submit' value='Signup'>
</div>
</form>
{{end}}
Создадим новую структуру данных для регистрации пользователя
В файле handlers.go:
package main
...
// Create a new userSignupForm struct.
type userSignupForm struct {
Name string `form:"name"`
Email string `form:"email"`
Password string `form:"password"`
validator.Validator `form:"-"`
}
// Update the handler so it displays the signup page.
func (app *application) userSignup(w http.ResponseWriter, r *http.Request) {
data := app.newTemplateData(r)
data.Form = userSignupForm{}
app.render(w, http.StatusOK, "signup.tmpl", data)
}
...
Валидация формы регистрации
Добавим новые методы валидации в файле: internal/validator/validator.go
package validator
import (
"regexp" // New import
"strings"
"unicode/utf8"
)
// Use the regexp.MustCompile() function to parse a regular expression pattern
// for sanity checking the format of an email address. This returns a pointer to
// a 'compiled' regexp.Regexp type, or panics in the event of an error. Parsing
// this pattern once at startup and storing the compiled *regexp.Regexp in a
// variable is more performant than re-parsing the pattern each time we need it.
var EmailRX = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$")
...
// MinChars() returns true if a value contains at least n characters.
func MinChars(value string, n int) bool {
return utf8.RuneCountInString(value) >= n
}
// Matches() returns true if a value matches a provided compiled regular
// expression pattern.
func Matches(value string, rx *regexp.Regexp) bool {
return rx.MatchString(value)
}
Добавим обработчик формы в handlers.go
func (app *application) userSignupPost(w http.ResponseWriter, r *http.Request) {
// Declare an zero-valued instance of our userSignupForm struct.
var form userSignupForm
// Parse the form data into the userSignupForm struct.
err := app.decodePostForm(r, &form)
if err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
// Validate the form contents using our helper functions.
form.CheckField(validator.NotBlank(form.Name), "name", "This field cannot be blank")
form.CheckField(validator.NotBlank(form.Email), "email", "This field cannot be blank")
form.CheckField(validator.Matches(form.Email, validator.EmailRX), "email", "This field must be a valid email address")
form.CheckField(validator.NotBlank(form.Password), "password", "This field cannot be blank")
form.CheckField(validator.MinChars(form.Password, 8), "password", "This field must be at least 8 characters long")
// If there are any errors, redisplay the signup form along with a 422
// status code.
if !form.Valid() {
data := app.newTemplateData(r)
data.Form = form
app.render(w, http.StatusUnprocessableEntity, "signup.tmpl", data)
return
}
// Otherwise send the placeholder response (for now!).
fmt.Fprintln(w, "Create a new user...")
}
Краткое введение в bcrypt
- Хорошей практикой — является хранение одностороннего хеша пароля, полученного с помощью ресурсоемкой функции извлечения ключа, такой как Argon2, scrypt или bcrypt. Go имеет реализации всех 3 алгоритмов в пакете golang.org/x/crypto.
- Тем не менее, плюсом реализации bcrypt является то, что она включает в себя вспомогательные функции, специально разработанные для хеширования и проверки паролей, и именно их мы будем использовать здесь.
- Установка bcrypt
go get golang.org/x/crypto/bcrypt@latest
- Создать хэш пароля
hash, err := bcrypt.GenerateFromPassword([]byte("my plain text password"), 12)
- Эта функция вернет хеш длиной 60 символов, который выглядит примерно так:
$2a$12$NuTjWXm3KKntReFwyBVHyuf/to.HEwTy.eS206TNfkGfr6HzGJSWG
Второй параметр мы передаем в bcrypt.GenerateFromPassword() указывает стоимость, которая представлена целым числом от 4 до 31. 12 означает, что для генерации хэша пароля будет использовано 4096 (2^12) итераций bcrypt.
- Проверка пароля: функция
bcrypt.CompareHashAndPassword()
hash := []byte("$2a$12$NuTjWXm3KKntReFwyBVHyuf/to.HEwTy.eS206TNfkGfr6GzGJSWG")
err := bcrypt.CompareHashAndPassword(hash, []byte("my plain text password"))
Хранение информации о пользователе
Следующим этапом нашей сборки является обновление метода UserModel.Insert() таким образом, чтобы он создал новую запись в нашей таблице пользователей, содержащую проверенное имя, адрес электронной почты и хэшированный пароль.
- Для обработки ошибок в базе данных и сохранения хэша понадобятся библиотеки
import (
"database/sql"
"errors" // New import
"strings" // New import
"time"
"github.com/go-sql-driver/mysql" // New import
"golang.org/x/crypto/bcrypt" // New import
)
- Файл
internal/models/users.go
для обработки запросов к базе данных
type UserModel struct {
DB *sql.DB
}
func (m *UserModel) Insert(name, email, password string) error {
// Create a bcrypt hash of the plain-text password.
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), 12)
if err != nil {
return err
}
stmt := `INSERT INTO users (name, email, hashed_password, created)
VALUES(?, ?, ?, UTC_TIMESTAMP())`
// Use the Exec() method to insert the user details and hashed password
// into the users table.
_, err = m.DB.Exec(stmt, name, email, string(hashedPassword))
if err != nil {
// If this returns an error, we use the errors.As() function to check
// whether the error has the type *mysql.MySQLError. If it does, the
// error will be assigned to the mySQLError variable. We can then check
// whether or not the error relates to our users_uc_email key by
// checking if the error code equals 1062 and the contents of the error
// message string. If it does, we return an ErrDuplicateEmail error.
var mySQLError *mysql.MySQLError
if errors.As(err, &mySQLError) {
if mySQLError.Number == 1062 && strings.Contains(mySQLError.Message, "users_uc_email") {
return ErrDuplicateEmail
}
}
return err
}
return nil
}
- В основном приложении handlers.go отрабатывает запросы по маршрутам
func (app *application) userSignupPost(w http.ResponseWriter, r *http.Request) {
var form userSignupForm
...
err = app.users.Insert(form.Name, form.Email, form.Password)
if err != nil {
if errors.Is(err, models.ErrDuplicateEmail) {
form.AddFieldError("email", "Email address is already in use")
data := app.newTemplateData(r)
data.Form = form
app.render(w, http.StatusUnprocessableEntity, "signup.tmpl", data)
} else {
app.serverError(w, err)
}
return
}
...
Внимание
Автор не рекомендует использовать встроенные функции преобразования паролей в хэш в базах данных.User login
Для начала обновим internal/validator/validator.go
package validator
...
// Add a new NonFieldErrors []string field to the struct, which we will use to
// hold any validation errors which are not related to a specific form field.
type Validator struct {
NonFieldErrors []string
FieldErrors map[string]string
}
// Update the Valid() method to also check that the NonFieldErrors slice is
// empty.
func (v *Validator) Valid() bool {
return len(v.FieldErrors) == 0 && len(v.NonFieldErrors) == 0
}
// Create an AddNonFieldError() helper for adding error messages to the new
// NonFieldErrors slice.
func (v *Validator) AddNonFieldError(message string) {
v.NonFieldErrors = append(v.NonFieldErrors, message)
}
основная идея изменений, это добавить в основной валидатор проверку не только некорректных данных в форме, но и проверок в базе данных
Создадим шаблон для login (ui/html/pages/login.tmpl)
{{define "title"}}Login{{end}}
{{define "main"}}
<form action='/user/login' method='POST' novalidate>
<!-- Notice that here we are looping over the NonFieldErrors and displaying
them, if any exist -->
{{range .Form.NonFieldErrors}}
<div class='error'>{{.}}</div>
{{end}}
<div>
<label>Email:</label>
{{with .Form.FieldErrors.email}}
<label class='error'>{{.}}</label>
{{end}}
<input type='email' name='email' value='{{.Form.Email}}'>
</div>
<div>
<label>Password:</label>
{{with .Form.FieldErrors.password}}
<label class='error'>{{.}}</label>
{{end}}
<input type='password' name='password'>
</div>
<div>
<input type='submit' value='Login'>
</div>
</form>
{{end}}
Создадим структуру данных для login (handlers.go)
// Create a new userLoginForm struct.
type userLoginForm struct {
Email string `form:"email"`
Password string `form:"password"`
validator.Validator `form:"-"`
}
// Update the handler so it displays the login page.
func (app *application) userLogin(w http.ResponseWriter, r *http.Request) {
data := app.newTemplateData(r)
data.Form = userLoginForm{}
app.render(w, http.StatusOK, "login.tmpl", data)
}
Проверка пользователя
- Проверить есть такой email — если нет, то вернуть ошибку
- Сравнить хэш пароля — если не совпадает, вернуть ошибку
- Если все ОК — вернем ID пользователя
Внесем изменения в internal/models/users.go
func (m *UserModel) Authenticate(email, password string) (int, error) {
// Retrieve the id and hashed password associated with the given email. If
// no matching email exists we return the ErrInvalidCredentials error.
var id int
var hashedPassword []byte
stmt := "SELECT id, hashed_password FROM users WHERE email = ?"
err := m.DB.QueryRow(stmt, email).Scan(&id, &hashedPassword)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return 0, ErrInvalidCredentials
} else {
return 0, err
}
}
// Check whether the hashed password and plain-text password provided match.
// If they don't, we return the ErrInvalidCredentials error.
err = bcrypt.CompareHashAndPassword(hashedPassword, []byte(password))
if err != nil {
if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
return 0, ErrInvalidCredentials
} else {
return 0, err
}
}
// Otherwise, the password is correct. Return the user ID.
return id, nil
}
- После подтверждения пользователя создадим текущую сессию с пользователем. Внесем изменения в handlers.go
func (app *application) userLoginPost(w http.ResponseWriter, r *http.Request) {
// Decode the form data into the userLoginForm struct.
var form userLoginForm
err := app.decodePostForm(r, &form)
if err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
form.CheckField(validator.NotBlank(form.Email), "email", "This field cannot be blank")
form.CheckField(validator.Matches(form.Email, validator.EmailRX), "email", "This field must be a valid email address")
form.CheckField(validator.NotBlank(form.Password), "password", "This field cannot be blank")
if !form.Valid() {
data := app.newTemplateData(r)
data.Form = form
app.render(w, http.StatusUnprocessableEntity, "login.tmpl", data)
return
}
// Check whether the credentials are valid. If they're not, add a generic
// non-field error message and re-display the login page.
id, err := app.users.Authenticate(form.Email, form.Password)
if err != nil {
if errors.Is(err, models.ErrInvalidCredentials) {
form.AddNonFieldError("Email or password is incorrect")
data := app.newTemplateData(r)
data.Form = form
app.render(w, http.StatusUnprocessableEntity, "login.tmpl", data)
} else {
app.serverError(w, err)
}
return
}
//после каждого изменения состояния пользователя login/logout изменяем хэш текущей сессии
err = app.sessionManager.RenewToken(r.Context())
if err != nil {
app.serverError(w, err)
return
}
// Add the ID of the current user to the session, so that they are now
// 'logged in'.
app.sessionManager.Put(r.Context(), "authenticatedUserID", id)
// Redirect the user to the create snippet page.
http.Redirect(w, r, "/snippet/create", http.StatusSeeOther)
}
Примечание: Метод SessionManager.RenewToken(), который мы используем в приведенном выше коде, изменит идентификатор текущей сессии пользователя, но сохранит все данные, связанные с сессией. Рекомендуется делать это перед входом в систему, чтобы снизить риск атаки фиксации сеанса. Для получения дополнительной информации и информации по этому вопросу, пожалуйста, ознакомьтесь со шпаргалкой по управлению сеансами OWASP.
User logout
- Нужно удалить authenticatedUserID из сессии и все.
Обновим файл handlers.go
func (app *application) userLogoutPost(w http.ResponseWriter, r *http.Request) {
// обновим токен сессии
err := app.sessionManager.RenewToken(r.Context())
if err != nil {
app.serverError(w, err)
return
}
// удалим authenticatedUserID из сессии'logged out'.
app.sessionManager.Remove(r.Context(), "authenticatedUserID")
// Add a flash message to the session to confirm to the user that they've been
// logged out.
app.sessionManager.Put(r.Context(), "flash", "You've been logged out successfully!")
// Redirect the user to the application home page.
http.Redirect(w, r, "/", http.StatusSeeOther)
}
User authorization
В этом разделе включаются те полезности, которые доступны только аутентифицированным пользователям
- они могут создавать сниппеты
- в меню появляются новые пункты и убираются ненужные
Содержимое панели навигации меняется в зависимости от того, аутентифицирован ли пользователь (вошел в систему) или нет. В частности:
- аутентифицированные пользователи должны видеть ссылки на «Главную», «Создать сниппет» и «Выйти».
- Неаутентифицированные пользователи должны видеть ссылки на «Главную», «Регистрация» и «Вход».
Проверка аутентификации
Чтобы проверить аутентификацию пользователя, нужно проверить значение authenticatedUserID в сессии.
Чтобы не забыть, мы при аутентификации выполнили команду:
app.sessionManager.Put(r.Context(), "authenticatedUserID", id)
и эта информация записалась в базу данных в поле data
. С этим контекстом работает пакет SCS и выполняет все проверки через свои команды.
Добавим вспомогательную функцию isAuthenticated() для возврата статуса аутентификации (helpers.go):
func (app *application) isAuthenticated(r *http.Request) bool {
return app.sessionManager.Exists(r.Context(), "authenticatedUserID")
}
Добавим новое поле IsAuthenticated в нашу структуру templateData (templates.go):
type templateData struct {
CurrentYear int
Snippet *models.Snippet
Snippets []*models.Snippet
Form any
Flash string
IsAuthenticated bool // Add an IsAuthenticated field to the templateData struct.
}
Обновить newTemplateData() в helpers.go, чтобы эта информация автоматически добавлялась в структуру templateData каждый раз, когда мы отрисовываем шаблон.
func (app *application) newTemplateData(r *http.Request) *templateData {
return &templateData{
CurrentYear: time.Now().Year(),
Flash: app.sessionManager.PopString(r.Context(), "flash"),
// Add the authentication status to the template data.
IsAuthenticated: app.isAuthenticated(r),
}
}
Проверяем поле {{if .IsAuthenticated}} в шаблонах nav.tmpl
{{define "nav"}}
<nav>
<div>
<a href='/'>Home</a>
<!-- Toggle the link based on authentication status -->
{{if .IsAuthenticated}}
<a href='/snippet/create'>Create snippet</a>
{{end}}
</div>
<div>
<!-- Toggle the links based on authentication status -->
{{if .IsAuthenticated}}
<form action='/user/logout' method='POST'>
<button>Logout</button>
</form>
{{else}}
<a href='/user/signup'>Signup</a>
<a href='/user/login'>Login</a>
{{end}}
</div>
</nav>
{{end}}
Ограничение доступа к страницам
Ограничение доступа
В настоящее время мы скрываем навигационную ссылку «Создать сниппет» для всех пользователей, которые не вошли в систему. Но пользователь, не прошедший аутентификацию, все равно может создать новый сниппет, посетив страницу https://localhost:4000/snippet/create напрямую.
Давайте исправим это, так что если неаутентифицированный пользователь попытается посетить какие-либо маршруты с URL-адресом /snippet/create, он будет перенаправлен на /user/login.
Будем использовать middleware
Внесем изменения в middleware.go
func (app *application) requireAuthentication(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Если пользователь не аутентифицирован, перенаправьте его на страницу входа и
// вернитесь из цепочки промежуточного ПО, чтобы последующие обработчики в
// цепочке не выполнялись.
if !app.isAuthenticated(r) {
http.Redirect(w, r, "/user/login", http.StatusSeeOther)
return
}
//В противном случае установите заголовок "Cache-Control: no-store" так, чтобы страницы,
// требующие аутентификации, не сохранялись в кэше браузера пользователя (или
// другом промежуточном кэше).
w.Header().Add("Cache-Control", "no-store")
// And call the next handler in the chain.
next.ServeHTTP(w, r)
})
}
В нашем случае мы захотим защитить маршруты GET /snippet/create и POST /snippet/create.
И нет особого смысла выходить из системы пользователя, если он не вошел в систему, поэтому имеет смысл использовать его также и в маршруте POST /user/logout.
Чтобы помочь в этом, давайте разделим маршруты наших приложений на две «группы».
- Первая группа будет содержать наши «незащищенные» маршруты и использовать нашу существующую динамическую цепочку промежуточного программного обеспечения.
- Вторая группа будет содержать наши «защищенные» маршруты и будет использовать новую защищенную цепочку промежуточного ПО, состоящую из динамической цепочки промежуточного ПО и нашего нового промежуточного ПО requireAuthentication().
Корректируем файл routes.go
func (app *application) routes() http.Handler {
router := httprouter.New()
router.NotFound = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
app.notFound(w)
})
fileServer := http.FileServer(http.Dir("./ui/static/"))
router.Handler(http.MethodGet, "/static/*filepath", http.StripPrefix("/static", fileServer))
// Unprotected application routes using the "dynamic" middleware chain.
dynamic := alice.New(app.sessionManager.LoadAndSave)
router.Handler(http.MethodGet, "/", dynamic.ThenFunc(app.home))
router.Handler(http.MethodGet, "/snippet/view/:id", dynamic.ThenFunc(app.snippetView))
router.Handler(http.MethodGet, "/user/signup", dynamic.ThenFunc(app.userSignup))
router.Handler(http.MethodPost, "/user/signup", dynamic.ThenFunc(app.userSignupPost))
router.Handler(http.MethodGet, "/user/login", dynamic.ThenFunc(app.userLogin))
router.Handler(http.MethodPost, "/user/login", dynamic.ThenFunc(app.userLoginPost))
// Protected (authenticated-only) application routes, using a new "protected"
// middleware chain which includes the requireAuthentication middleware.
protected := dynamic.Append(app.requireAuthentication)
router.Handler(http.MethodGet, "/snippet/create", protected.ThenFunc(app.snippetCreate))
router.Handler(http.MethodPost, "/snippet/create", protected.ThenFunc(app.snippetCreatePost))
router.Handler(http.MethodPost, "/user/logout", protected.ThenFunc(app.userLogoutPost))
standard := alice.New(app.recoverPanic, app.logRequest, secureHeaders)
return standard.Then(router)
}
Без использования alice
Если вы не используете пакет justinas/alice — вы можете вручную обернуть обработчики следующим образом:
router.Handler(http.MethodPost, "/snippet/create", app.sessionManager.LoadAndSave(app.requireAuthentication(http.HandlerFunc(app.snippetCreate))))
CSRF protection
Что такое CSRF Protection
Что такое CSRF Protection?
CSRF (Cross-Site Request Forgery) — это тип атаки, при которой злоумышленник заставляет пользователя выполнить нежелательное действие на веб-сайте, на котором он аутентифицирован. Это может привести к несанкционированным действиям, таким как изменение паролей, отправка сообщений или выполнение финансовых транзакций.
Зачем нужна защита от CSRF?
Защита от CSRF необходима для предотвращения таких атак, чтобы обеспечить безопасность пользователей и защитить их данные. Без этой защиты злоумышленники могут использовать доверие пользователя к веб-приложению для выполнения вредоносных действий.
Автор рекомендует использовать пакет justinas/nosurf
для борьбы с CSRF
go get github.com/justinas/nosurf@v1
Добавление функции в middleware.go
import (
"fmt"
"net/http"
"github.com/justinas/nosurf" // New import
)
...
// Create a NoSurf middleware function which uses a customized CSRF cookie with
// the Secure, Path and HttpOnly attributes set.
func noSurf(next http.Handler) http.Handler {
csrfHandler := nosurf.New(next)
csrfHandler.SetBaseCookie(http.Cookie{
HttpOnly: true,
Path: "/",
Secure: true,
})
return csrfHandler
}
Защита формы logout
Одной из форм, которую нам необходимо защитить от CSRF-атак, является форма выхода из системы, которая включена в наш partial nav.tmpl и потенциально может появиться на любой странице нашего приложения. Таким образом, из-за этого нам необходимо использовать промежуточное ПО noSurf() на всех маршрутах нашего приложения (кроме /static/*filepath).
Исправим routes.go
dynamic := alice.New(app.sessionManager.LoadAndSave, noSurf)
остальные маршруты получают настройку автоматически
Добавление скрытых полей с токеном на каждую форму
Исправим templates.go
добавим поле с токеном в структуру templateData
package main
import (
"html/template"
"path/filepath"
"time"
"snippetbox.alexedwards.net/internal/models"
)
type templateData struct {
CurrentYear int
Snippet *models.Snippet
Snippets []*models.Snippet
Form any
Flash string
IsAuthenticated bool
CSRFToken string // Add a CSRFToken field.
}
Исправим helpers.go
внесем изменения в помощник для всех форм
func (app *application) newTemplateData(r *http.Request) *templateData {
return &templateData{
CurrentYear: time.Now().Year(),
Flash: app.sessionManager.PopString(r.Context(), "flash"),
IsAuthenticated: app.isAuthenticated(r),
CSRFToken: nosurf.Token(r), // Add the CSRF token.
}
}
Исправим все шаблоны и внесем в них скрытые поля
<input type='hidden' name='csrf_token' value='{{.CSRFToken}}'>
Using request context
Мы могли бы сделать эту проверку более надежной, запросив таблицу базы данных наших пользователей, чтобы убедиться, что значение “authenticatedUserID” является реальным, действительным значением (т.е. мы не удаляли учетную запись пользователя с момента его последнего входа в систему).
Поэтому сейчас будем изучать контекст запроса.
Как работает контекст запроса
Синтаксис контекста запроса
Основной код для добавления информации в контекст запроса выглядит следующим образом:
// Где r — это *http.Request...
ctx := r.Context()
ctx = context.WithValue(ctx, "isAuthenticated", true)
r = r.WithContext(ctx)
Давайте разберем это построчно.
- Сначала мы используем метод r.Context(), чтобы получить существующий контекст из запроса и присваиваем его переменной ctx.
- Затем мы используем метод context.WithValue(), чтобы создать новую копию существующего контекста, содержащую ключ “isAuthenticated” и значение true.
- Наконец, мы используем метод r.WithContext(), чтобы создать копию запроса с нашим новым контекстом.
Важно
Обратите внимание, что мы не обновляем контекст запроса напрямую. Мы создаем новую копию объекта http.Request с нашим новым контекстом.Также стоит отметить, что для ясности я сделал этот фрагмент кода немного более многословным, чем это необходимо. Обычно его пишут так:
ctx = context.WithValue(r.Context(), "isAuthenticated", true)
r = r.WithContext(ctx)
Итак, вот как вы добавляете данные в контекст запроса. Но как их снова извлечь?
Важно объяснить, что за кулисами значения контекста запроса хранятся с типом any
. Это означает, что после извлечения их из контекста вам нужно будет привести их к исходному типу перед использованием.
Чтобы извлечь значение, нам нужно использовать метод r.Context().Value(), вот так:
isAuthenticated, ok := r.Context().Value("isAuthenticated").(bool)
if !ok {
return errors.New("не удалось преобразовать значение в bool")
}
В этом коде мы пытаемся получить значение, связанное с ключом “isAuthenticated”, и одновременно проверяем, удалось ли нам привести его к типу bool. Если преобразование не удалось, мы возвращаем ошибку.
Избежание коллизий ключей
В приведенных выше примерах кода я использовал строку “isAuthenticated” в качестве ключа для хранения и извлечения данных из контекста запроса. Однако это не рекомендуется, поскольку существует риск, что другие сторонние пакеты, используемые вашим приложением, также захотят хранить данные с использованием ключа “isAuthenticated”, что приведет к коллизии имен.
Чтобы избежать этого, хорошей практикой является создание собственного пользовательского типа, который вы можете использовать для своих ключей контекста. Расширяя наш пример кода, гораздо лучше сделать что-то вроде этого:
// Объявляем пользовательский тип "contextKey" для ваших ключей контекста.
type contextKey string
// Создаем константу с типом contextKey, которую мы можем использовать.
const isAuthenticatedContextKey = contextKey("isAuthenticated")
...
// Устанавливаем значение в контексте запроса, используя нашу константу isAuthenticatedContextKey в качестве ключа.
ctx := r.Context()
ctx = context.WithValue(ctx, isAuthenticatedContextKey, true)
r = r.WithContext(ctx)
...
// Извлекаем значение из контекста запроса, используя нашу константу в качестве ключа.
isAuthenticated, ok := r.Context().Value(isAuthenticatedContextKey).(bool)
if !ok {
return errors.New("не удалось преобразовать значение в bool")
}
Таким образом, мы избегаем возможных коллизий ключей, используя пользовательский тип для ключей контекста.
Request context для authentication/authorization
Теперь, когда мы разобрали эти объяснения, давайте начнем использовать функциональность контекста запроса в нашем приложении.
Мы начнем с того, что вернемся к файлу internal/models/users.go
и обновим метод UserModel.Exists()
, чтобы он возвращал true, если пользователь с определенным ID существует в нашей таблице пользователей, и false в противном случае. Вот так:
Файл: internal/models/users.go
package models
...
func (m *UserModel) Exists(id int) (bool, error) {
var exists bool
stmt := "SELECT EXISTS(SELECT true FROM users WHERE id = ?)"
err := m.DB.QueryRow(stmt, id).Scan(&exists)
return exists, err
}
Затем давайте создадим новый файл cmd/web/context.go
. В этом файле мы определим пользовательский тип contextKey
и переменную isAuthenticatedContextKey
, чтобы у нас был уникальный ключ, который мы можем использовать для хранения и извлечения статуса аутентификации из контекста запроса (без риска коллизий имен).
package main
type contextKey string
const isAuthenticatedContextKey = contextKey("isAuthenticated")
Давайте создадим новый метод промежуточного ПО authenticate()
, который:
- Извлекает ID пользователя из данных сессии.
- Проверяет базу данных, чтобы узнать, соответствует ли ID действительному пользователю, используя метод
UserModel.Exists()
. - Обновляет контекст запроса, добавляя ключ
isAuthenticatedContextKey
со значением true.
Вот код:
Файл: cmd/web/middleware.go
package main
import (
"context" // Новый импорт
"fmt"
"net/http"
"github.com/justinas/nosurf"
)
...
func (app *application) authenticate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Извлекаем значение authenticatedUserID из сессии, используя метод
// GetInt(). Это вернет нулевое значение для int (0), если в сессии
// нет значения "authenticatedUserID" — в этом случае мы
// вызываем следующий обработчик в цепочке как обычно и прерываем обработку.
id := app.sessionManager.GetInt(r.Context(), "authenticatedUserID")
if id == 0 {
next.ServeHTTP(w, r)
return
}
// В противном случае мы проверяем, существует ли пользователь с этим ID
// в нашей базе данных.
exists, err := app.users.Exists(id)
if err != nil {
app.serverError(w, err)
return
}
// Если найден соответствующий пользователь, мы знаем, что запрос
// поступает от аутентифицированного пользователя, который существует
// в нашей базе данных. Мы создаем новую копию запроса (с
// значением isAuthenticatedContextKey равным true в контексте запроса)
// и присваиваем ее переменной r.
if exists {
ctx := context.WithValue(r.Context(), isAuthenticatedContextKey, true)
r = r.WithContext(ctx)
}
// Вызываем следующий обработчик в цепочке.
next.ServeHTTP(w, r)
})
}
Важно подчеркнуть следующее различие:
- Когда у нас нет действительного аутентифицированного пользователя, мы передаем оригинальный и неизмененный *http.Request следующему обработчику в цепочке.
- Когда у нас есть действительный аутентифицированный пользователь, мы создаем копию запроса с ключом
isAuthenticatedContextKey
и значением true, хранящимся в контексте запроса. Затем мы передаем эту копию *http.Request следующему обработчику в цепочке.
Хорошо, давайте обновим файл cmd/web/routes.go
, чтобы включить промежуточное ПО authenticate()
в нашу динамическую цепочку промежуточного ПО.
package main
...
func (app *application) routes() http.Handler {
router := httprouter.New()
router.NotFound = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
app.notFound(w)
})
fileServer := http.FileServer(http.Dir("./ui/static/"))
router.Handler(http.MethodGet, "/static/*filepath", http.StripPrefix("/static", fileServer))
// Add the authenticate() middleware to the chain.
dynamic := alice.New(app.sessionManager.LoadAndSave, noSurf, app.authenticate)
router.Handler(http.MethodGet, "/", dynamic.ThenFunc(app.home))
router.Handler(http.MethodGet, "/snippet/view/:id", dynamic.ThenFunc(app.snippetView))
router.Handler(http.MethodGet, "/user/signup", dynamic.ThenFunc(app.userSignup))
router.Handler(http.MethodPost, "/user/signup", dynamic.ThenFunc(app.userSignupPost))
router.Handler(http.MethodGet, "/user/login", dynamic.ThenFunc(app.userLogin))
router.Handler(http.MethodPost, "/user/login", dynamic.ThenFunc(app.userLoginPost))
protected := dynamic.Append(app.requireAuthentication)
router.Handler(http.MethodGet, "/snippet/create", protected.ThenFunc(app.snippetCreate))
router.Handler(http.MethodPost, "/snippet/create", protected.ThenFunc(app.snippetCreatePost))
router.Handler(http.MethodPost, "/user/logout", protected.ThenFunc(app.userLogoutPost))
standard := alice.New(app.recoverPanic, app.logRequest, secureHeaders)
return standard.Then(router)
}
Последнее, что нам нужно сделать, — это обновить наш вспомогательный метод isAuthenticated()
, чтобы вместо проверки данных сессии он теперь проверял контекст запроса, чтобы определить, аутентифицирован ли пользователь или нет.
Мы можем сделать это следующим образом:
Файл: cmd/web/helpers.go
package main
...
func (app *application) isAuthenticated(r *http.Request) bool {
isAuthenticated, ok := r.Context().Value(isAuthenticatedContextKey).(bool)
if !ok {
return false
}
return isAuthenticated
}
Важно отметить, что если в контексте запроса нет значения с ключом isAuthenticatedContextKey
или если подлежащие значение не является типом bool, то это приведение типов завершится неудачей. В этом случае мы принимаем «безопасный» запасной вариант и возвращаем false (т.е. предполагаем, что пользователь не аутентифицирован).
Если хотите, попробуйте снова запустить приложение. Оно должно корректно скомпилироваться, и если вы войдете как определенный пользователь и будете перемещаться по приложению, оно должно работать точно так же, как и раньше.
Дополнительная информация
Неправильное использование контекста запроса
Важно подчеркнуть, что контекст запроса следует использовать только для хранения информации, относящейся к времени жизни конкретного запроса. Документация Go для context.Context
предупреждает:
Используйте значения контекста только для данных, ограниченных запросом, которые передаются между процессами и API.
Это означает, что не следует использовать его для передачи зависимостей, которые существуют вне времени жизни запроса — таких как логгеры, кэши шаблонов и пул соединений с базой данных — в ваше промежуточное ПО и обработчики.
По причинам безопасности типов и ясности кода почти всегда лучше явно предоставлять эти зависимости вашим обработчикам, либо делая ваши обработчики методами структуры приложения (как мы делаем в этой книге), либо передавая их в замыкании.
3 - Alex Edwards Let's Go часть 3
Дополнительные возможности Go
В этом разделе книги мы поговорим о двух функциях Go, которые являются относительно новыми дополнениями к языку: встраивании файлов и обобщениях.
По сути:
- Встраивание файлов позволяет встраивать внешние файлы непосредственно в вашу программу на Go.
- Обобщения могут помочь сократить количество шаблонного кода, который вам нужно писать, при этом сохраняя безопасность типов на этапе компиляции.
Использование встроенных файлов
Одной из ключевых функций релиза Go 1.16 стал пакет embed
, который позволяет встраивать внешние файлы непосредственно в вашу программу на Go.
Эта функция действительно полезна, поскольку она позволяет создавать (а затем и распространять) программы на Go, которые полностью самодостаточны и содержат все необходимое для их работы в составе исполняемого бинарного файла.
Чтобы проиллюстрировать, как использовать пакет embed
, мы обновим наше приложение, чтобы встроить и использовать файлы из нашей существующей директории ui
(которая содержит наши статические файлы CSS/JavaScript/изображения и HTML-шаблоны).
Если вы хотите следовать за нами, сначала создайте новый файл ui/efs.go
:
package ui
import (
"embed"
)
//go:embed "html" "static"
var Files embed.FS
Ключевая строка здесь — //go:embed "html" "static"
.
Хотя это выглядит как комментарий, на самом деле это специальная директива. При компиляции приложения она указывает Go сохранить файлы из папок ui/html
и ui/static
в embedded-файловой системе embed.FS
, связанной с глобальной переменной Files
.
Вот несколько важных деталей, которые нужно учитывать:
-
Расположение директивы
Директиваgo:embed
должна находиться непосредственно перед переменной, в которую нужно встроить файлы. -
Формат директивы
Общий формат:go:embed <пути>
. Можно указывать несколько путей в одной директиве (как в примере выше). Пути должны быть относительными к файлу с исходным кодом, содержащему директиву. В нашем случаеgo:embed "static" "html"
встраивает папкиui/static
иui/html
. -
Область применения
Директиваgo:embed
работает только с глобальными переменными на уровне пакета, но не внутри функций или методов. При попытке использовать её внутри функции возникнет ошибка компиляции:"go:embed cannot apply to var inside func"
. -
Ограничения путей
- Пути не могут содержать
.
или..
. - Они не должны начинаться или заканчиваться на
/
. - Это ограничивает встраивание файлами, находящимися в той же директории (или её поддиректориях), что и исходный файл с директивой.
- Пути не могут содержать
-
Встраивание директорий
Если путь ведёт к папке, все её файлы встраиваются рекурсивно, кроме файлов, имена которых начинаются с.
или_
. Чтобы включить такие файлы, используйте префиксall:
, например://go:embed "all:static"
-
Разделитель путей
Всегда используйте прямой слеш (/
), даже в Windows. -
Корневая директория embedded-файловой системы
Она всегда соответствует расположению файла с директивойgo:embed
. В нашем примереFiles
содержит файловую системуembed.FS
, корнем которой является папкаui
.
Использование статических файлов
Давайте изменим наше приложение так, чтобы оно обслуживало наши статические файлы CSS, JavaScript и изображения из встроенной файловой системы, а не считывало их с диска во время выполнения.
Откройте файл cmd/web/routes.go
и обновите его следующим образом:
Файл: cmd/web/routes.go
package main
import (
"net/http"
"snippetbox.alexedwards.net/ui" // Новый импорт
"github.com/julienschmidt/httprouter"
"github.com/justinas/alice"
)
func (app *application) routes() http.Handler {
router := httprouter.New()
router.NotFound = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
app.notFound(w)
})
// Берем встроенную файловую систему ui.Files и преобразуем ее в тип http.FS, чтобы
// она соответствовала интерфейсу http.FileSystem. Затем передаем это в функцию
// http.FileServer() для создания обработчика файлового сервера.
fileServer := http.FileServer(http.FS(ui.Files))
// Наши статические файлы содержатся в папке "static" встроенной файловой системы ui.Files.
// Например, наш CSS-стилист находится по адресу "static/css/main.css". Это означает, что
// нам больше не нужно удалять префикс из URL запроса — любые запросы, начинающиеся с /static/,
// могут просто передаваться непосредственно файловому серверу, и соответствующий статический
// файл будет обслужен (если он существует).
router.Handler(http.MethodGet, "/static/*filepath", fileServer)
dynamic := alice.New(app.sessionManager.LoadAndSave, noSurf, app.authenticate)
router.Handler(http.MethodGet, "/", dynamic.ThenFunc(app.home))
router.Handler(http.MethodGet, "/snippet/view/:id", dynamic.ThenFunc(app.snippetView))
router.Handler(http.MethodGet, "/user/signup", dynamic.ThenFunc(app.userSignup))
router.Handler(http.MethodPost, "/user/signup", dynamic.ThenFunc(app.userSignupPost))
router.Handler(http.MethodGet, "/user/login", dynamic.ThenFunc(app.userLogin))
router.Handler(http.MethodPost, "/user/login", dynamic.ThenFunc(app.userLoginPost))
protected := dynamic.Append(app.requireAuthentication)
router.Handler(http.MethodGet, "/snippet/create", protected.ThenFunc(app.snippetCreate))
router.Handler(http.MethodPost, "/snippet/create", protected.ThenFunc(app.snippetCreatePost))
router.Handler(http.MethodPost, "/user/logout", protected.ThenFunc(app.userLogoutPost))
standard := alice.New(app.recoverPanic, app.logRequest, secureHeaders)
return standard.Then(router)
}
Если вы сохраните файлы и затем перезапустите приложение, вы должны увидеть, что все компилируется и работает корректно. Когда вы посетите https://localhost:4000
в вашем браузере, статические файлы должны обслуживаться из встроенной файловой системы, и все должно выглядеть нормально.
Встраивание HTML-шаблонов
Теперь давайте обновим файл cmd/web/templates.go
, чтобы наш кэш шаблонов использовал встроенные HTML-шаблоны из ui.Files
, а не те, что находятся на диске.
Чтобы помочь нам с этим, мы воспользуемся несколькими специальными функциями, которые Go 1.16 представил для работы с встроенными файловыми системами:
fs.Glob()
возвращает срез путей файлов, соответствующих шаблону glob. Это фактически то же самое, что и функцияfilepath.Glob()
, которую мы использовали ранее в книге, за исключением того, что она работает с встроенными файловыми системами.Template.ParseFS()
можно использовать для разбора HTML-шаблонов из встроенной файловой системы в набор шаблонов. Это фактически замена как для методовTemplate.ParseFiles()
, так иTemplate.ParseGlob()
, которые мы использовали ранее.Template.ParseFiles()
также является функцией с переменным числом аргументов, что позволяет вам разбирать несколько шаблонов за один вызовParseFiles()
.
Давайте применим это в нашем файле cmd/web/templates.go
:
Файл: cmd/web/templates.go
package main
import (
"html/template"
"io/fs" // Новый импорт
"path/filepath"
"time"
"snippetbox.alexedwards.net/internal/models"
"snippetbox.alexedwards.net/ui" // Новый импорт
)
...
func newTemplateCache() (map[string]*template.Template, error) {
cache := map[string]*template.Template{}
// Используем fs.Glob() для получения среза всех путей файлов в
// встроенной файловой системе ui.Files, которые соответствуют шаблону
// 'html/pages/*.tmpl'. Это фактически дает нам срез всех 'page' шаблонов
// для приложения, как и раньше.
pages, err := fs.Glob(ui.Files, "html/pages/*.tmpl")
if err != nil {
return nil, err
}
for _, page := range pages {
name := filepath.Base(page)
// Создаем срез, содержащий шаблоны, которые мы хотим разобрать.
patterns := []string{
"html/base.tmpl",
"html/partials/*.tmpl",
page,
}
// Используем ParseFS() вместо ParseFiles() для разбора файлов шаблонов
// из встроенной файловой системы ui.Files.
ts, err := template.New(name).Funcs(functions).ParseFS(ui.Files, patterns...)
if err != nil {
return nil, err
}
cache[name] = ts
}
return cache, nil
}
Теперь, когда это сделано, когда наше приложение будет собрано в бинарный файл, оно будет содержать все необходимые UI-файлы для работы.
Вы можете быстро попробовать это, собрав исполняемый бинарный файл в вашей директории /tmp
, скопировав сертификаты TLS и запустив бинарный файл. Вот так:
go build -o /tmp/web ./cmd/web/
cp -r ./tls /tmp/
cd /tmp/
./web
Использование обобщений
Go 1.18 — это первая версия языка, которая поддерживает обобщения, также известные под более техническим названием параметрический полиморфизм.
В общем, новая функциональность обобщений позволяет вам писать код, который работает с различными конкретными типами.
Например, в более ранних версиях Go, если вы хотели проверить, содержится ли определенное значение в срезе []string
и срезе []int
, вам нужно было бы написать две отдельные функции — одну для типа строк и другую для типа целых чисел. Это выглядело бы примерно так:
func containsString(v string, s []string) bool {
for i, vs := range s {
if v == vs {
return true
}
}
return false
}
func containsInt(v int, s []int) bool {
for i, vs := range s {
if v == vs {
return true
}
}
return false
}
Теперь, с обобщениями, возможно написать одну единственную функцию contains()
, которая будет работать для строк, целых чисел и всех других сопоставимых типов. Код выглядит так:
func contains[T comparable](v T, s []T) bool {
for i := range s {
if v == s[i] {
return true
}
}
return false
}
Когда использовать обобщения?
Вы можете рассмотреть возможность использования обобщений, когда:
- Вы часто пишете повторяющийся шаблонный код для различных типов данных. Примеры этого могут включать общие операции над срезами, картами или каналами — или вспомогательные функции для выполнения проверок валидации или тестовых утверждений для различных типов данных.
- Вы пишете код и часто обращаетесь к типу
any
(пустой интерфейс). Примером этого может быть создание структуры данных (например, очереди, кэша или связного списка), которая должна работать с различными типами.
В то же время, вероятно, вам не следует использовать обобщения:
- Если это делает ваш код труднее для понимания или менее ясным.
- Если все типы, с которыми вам нужно работать, имеют общий набор методов — в этом случае лучше определить и использовать обычный интерфейс.
- Просто потому, что вы можете. В первую очередь пишите простой не обобщенный код, и переходите к обобщенной версии только в том случае, если это действительно необходимо.
Использование обобщений в нашем приложении
В следующем разделе книги мы начнем писать тесты для нашего приложения, и в процессе этого мы сгенерируем много повторяющегося шаблонного кода. Мы воспользуемся функциональностью обобщений Go, чтобы помочь нам управлять этим и создать несколько универсальных вспомогательных функций для выполнения проверок тестов на различных типах данных.
Возможно, единственное, что действительно подходит для обобщений, — это функция PermittedInt()
в нашем файле internal/validator/validator.go
.
Давайте изменим ее на универсальную функцию PermittedValue()
, которую мы сможем использовать каждый раз, когда захотим проверить, находится ли предоставленное пользователем значение в наборе разрешенных значений — независимо от того, является ли предоставленное пользователем значение строкой, целым числом, float64
или любым другим сопоставимым типом.
Вот так: Вот технический перевод документации с соблюдением терминологии языка Go:
Файл: internal/validator/validator.go
package validator
// Замените функцию PermittedInt() на универсальную функцию PermittedValue().
// Эта функция возвращает true, если значение типа T равно одному из
// параметров variadic permittedValues.
func PermittedValue[T comparable](value T, permittedValues ...T) bool {
for i := range permittedValues {
if value == permittedValues[i] {
return true
}
}
return false
}
Затем мы можем обновить обработчик snippetCreatePost, чтобы использовать новую функцию PermittedValue() в проверках валидации, следующим образом:
Файл: cmd/web/handlers.go
package main
func (app *application) snippetCreatePost(w http.ResponseWriter, r *http.Request) {
var form snippetCreateForm
err := app.decodePostForm(r, &form)
if err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
form.CheckField(validator.NotBlank(form.Title), "title", "Это поле не может быть пустым")
form.CheckField(validator.MaxChars(form.Title, 100), "title", "Это поле не может содержать более 100 символов")
form.CheckField(validator.NotBlank(form.Content), "content", "Это поле не может быть пустым")
// Используйте универсальную функцию PermittedValue() вместо
// специфичной для типа функции PermittedInt().
form.CheckField(validator.PermittedValue(form.Expires, 1, 7, 365), "expires", "Это поле должно равняться 1, 7 или 365")
if !form.Valid() {
data := app.newTemplateData(r)
data.Form = form
app.render(w, http.StatusUnprocessableEntity, "create.tmpl", data)
return
}
id, err := app.snippets.Insert(form.Title, form.Content, form.Expires)
if err != nil {
app.serverError(w, err)
return
}
app.sessionManager.Put(r.Context(), "flash", "Сниппет успешно создан!")
http.Redirect(w, r, fmt.Sprintf("/snippet/view/%d", id), http.StatusSeeOther)
}
После внесения этих изменений вы должны убедиться, что ваше приложение компилируется корректно и продолжает функционировать так же, как и раньше.
4 - Alex Edwards Let's Go часть 4
Тестирование
И вот мы наконец-то подошли к теме тестирования.
Как и структурирование и организация кода приложения, нет единственного “правильного” способа структурировать и организовать тесты в Go. Но есть некоторые соглашения, шаблоны и лучшие практики, которые вы можете соблюдать.
В этом разделе мы добавим тесты для части кода нашего приложения с целью продемонстрировать общий синтаксис для создания тестов и проиллюстрировать некоторые шаблоны, которые можно повторно использовать в широком спектре приложений.
Вы узнаете:
- Как создавать и запускать табличные юнит-тесты и подтесты в Go.
- Как тестировать HTTP-обработчики и middleware.
- Как проводить сквозное тестирование маршрутов веб-приложения, middleware и обработчиков.
- Как создавать мок-объекты моделей базы данных и использовать их в юнит-тестах.
- Шаблон для тестирования отправки HTML-форм с CSRF-защитой.
- Как использовать тестовый экземпляр MySQL для интеграционного тестирования.
- Как легко рассчитывать и профилировать покрытие кода тестами.
Юнит-тестирование и подтесты
В этой главе мы создадим юнит-тест, чтобы убедиться, что наша функция humanDate()
(которую мы создали в главе о пользовательских функциях шаблонизации) выводит значения типа time.Time
в точно таком формате, как нам нужно.
Если вы не помните, функция humanDate()
выглядит так:
Файл: cmd/web/templates.go
package main
...
func humanDate(t time.Time) string {
return t.UTC().Format("02 Jan 2006 at 15:04")
}
...
Создание юнит-теста
Давайте сразу же создадим юнит-тест для этой функции.
В Go общепринятой практикой является создание тестов в файлах *_test.go, которые находятся непосредственно рядом с кодом, который вы тестируете. Итак, в этом случае первым делом мы создадим новый файл cmd/web/templates_test.go для хранения теста:
touch cmd/web/templates_test.go
Файл: cmd/web/templates_test.go
package main
import (
"testing"
"time"
)
func TestHumanDate(t *testing.T) {
// Initialize a new time.Time object and pass it to the humanDate function.
tm := time.Date(2022, 3, 17, 10, 15, 0, 0, time.UTC)
hd := humanDate(tm)
// Check that the output from the humanDate function is in the format we
// expect. If it isn't what we expect, use the t.Errorf() function to
// indicate that the test has failed and log the expected and actual
// values.
if hd != "17 Mar 2022 at 10:15" {
t.Errorf("got %q; want %q", hd, "17 Mar 2022 at 10:15")
}
}
Этот шаблон является базовым, который вы будете использовать почти для всех тестов, которые пишете на Go.
Важно запомнить:
-
Тест является обычным кодом на Go, который вызывает функцию humanDate() и проверяет, что результат соответствует ожидаемому.
-
Ваши юнит-тесты содержатся в обычной функции Go с сигнатурой
func(*testing.T)
. -
Чтобы быть действительным юнит-тестом, имя этой функции должно начинаться с слова Test. Обычно за ним следует имя функции, метода или типа, который вы тестируете, чтобы сделать очевидным, что тестируется.
-
Вы можете использовать функцию
t.Errorf()
, чтобы пометить тест как не прошедший и зарегистрировать описательное сообщение о сбое. Важно отметить, что вызовt.Errorf()
не останавливает выполнение вашего теста — после вызова Go продолжит выполнять любой оставшийся тестовый код как обычно.
Давайте попробуем это. Сохраните файл, затем используйте команду go test
, чтобы запустить все тесты в нашем пакете cmd/web:
$ go test ./cmd/web
ok snippetbox.igorra.net/cmd/web 0.002s
Итак, это хорошо. ok в этом выводе указывает, что все тесты в пакете (пока только наш тест TestHumanDate()) прошли без проблем.
Если вы хотите получить больше информации, вы можете увидеть, какие тесты выполняются, используя флаг -v
, чтобы получить подробный вывод:
$ go test -v ./cmd/web
=== RUN TestHumanDate
--- PASS: TestHumanDate (0.00s)
PASS
ok snippetbox.igorra.net/cmd/web 0.002s
Табличные тесты
Теперь давайте расширим нашу функцию TestHumanDate()
, чтобы охватить некоторые дополнительные тестовые случаи. В частности, мы обновим ее, чтобы также проверить, что:
-
Если входные данные для
humanDate()
являются нулевым временем, то она возвращает пустую строку “”. -
Вывод функции
humanDate()
всегда использует часовой пояс UTC.
В Go идиоматическим способом запуска нескольких тестовых случаев является использование табличных тестов.
По сути, идея табличных тестов заключается в создании «таблицы» тестовых случаев, содержащих входные данные и ожидаемые выходные данные, а затем в итерации по ним, запуская каждый тестовый случай в подтесте. Есть несколько способов настроить это, но распространенным подходом является определение тестовых случаев в срезе анонимных структур.
Я продемонстрирую: Файл: cmd/web/templates_test.go
package main
import (
"testing"
"time"
)
func TestHumanDate(t *testing.T) {
// Создаем срез анонимных структур, содержащих имя тестового случая,
// входные данные для нашей функции humanDate() (поле tm) и ожидаемый вывод
// (поле want).
tests := []struct {
name string
tm time.Time
want string
}{
{
name: "UTC",
tm: time.Date(2022, 3, 17, 10, 15, 0, 0, time.UTC),
want: "17 Mar 2022 at 10:15",
},
{
name: "Empty",
tm: time.Time{},
want: "",
},
{
name: "CET",
tm: time.Date(2022, 3, 17, 10, 15, 0, 0, time.FixedZone("CET", 1*60*60)),
want: "17 Mar 2022 at 09:15",
},
}
// Итерируем по тестовым случаям.
for _, tt := range tests {
// Используем функцию t.Run(), чтобы запустить подтест для каждого тестового случая.
// Первый параметр — имя теста (которое используется для идентификации подтеста в любом выводе журнала),
// а второй параметр — анонимная функция, содержащая фактический тест для каждого случая.
t.Run(tt.name, func(t *testing.T) {
hd := humanDate(tt.tm)
if hd != tt.want {
t.Errorf("got %q; want %q", hd, tt.want)
}
})
}
}
Примечание: В третьем тестовом случае мы используем CET (Центральноевропейское время) в качестве часового пояса, который на один час опережает UTC. Итак, мы хотим, чтобы вывод humanDate()
(в UTC) был “17 Mar 2022 at 09:15”, а не “17 Mar 2022 at 10:15”.
Хорошо, давайте запустим это и посмотрим, что произойдет:
$ go test -v ./cmd/web
=== RUN TestHumanDate
=== RUN TestHumanDate/UTC
=== RUN TestHumanDate/Empty
templates_test.go:44: got "01 Jan 0001 at 00:00"; want ""
=== RUN TestHumanDate/CET
templates_test.go:44: got "17 Mar 2022 at 10:15"; want "17 Mar 2022 at 09:15"
--- FAIL: TestHumanDate (0.00s)
--- PASS: TestHumanDate/UTC (0.00s)
--- FAIL: TestHumanDate/Empty (0.00s)
--- FAIL: TestHumanDate/CET (0.00s)
FAIL
FAIL snippetbox.alexedwards.net/cmd/web 0.003s
FAIL
Итак, здесь мы можем видеть индивидуальный вывод для каждого из наших подтестов. Как вы, возможно, догадались, наш первый тестовый случай прошел, но Empty и CET-тесты оба не прошли. Заметим, как для неудавшихся тестовых случаев мы получаем соответствующее сообщение об ошибке и имя файла и номер строки в выводе?
Давайте вернемся к нашей функции humanDate()
и обновим ее, чтобы исправить эти две проблемы:
Файл: cmd/web/templates.go
package main
...
func humanDate(t time.Time) string {
// Возвращаем пустую строку, если время имеет нулевое значение.
if t.IsZero() {
return ""
}
// Преобразуем время в UTC, прежде чем форматировать его.
return t.UTC().Format("02 Jan 2006 at 15:04")
}
...
И когда вы повторно запустите тесты,
$ go test -v ./cmd/web
=== RUN TestHumanDate
=== RUN TestHumanDate/UTC
=== RUN TestHumanDate/Empty
=== RUN TestHumanDate/CET
--- PASS: TestHumanDate (0.00s)
--- PASS: TestHumanDate/UTC (0.00s)
--- PASS: TestHumanDate/Empty (0.00s)
--- PASS: TestHumanDate/CET (0.00s)
PASS
ok snippetbox.alexedwards.net/cmd/web 0.003s
Вспомогательные функции для тестовых утверждений
Как я упоминал ранее в книге, в течение следующих нескольких глав мы будем писать много тестовых утверждений, которые являются вариацией этого шаблона:
if actualValue != expectedValue {
t.Errorf("got %v; want %v", actualValue, expectedValue)
}
Давайте быстро абстрагируем этот код в вспомогательную функцию.
Если вы следите за ходом событий, создайте новый пакет internal/assert
:
$ mkdir internal/assert
$ touch internal/assert/assert.go
И затем добавьте следующий код: Файл: internal/assert/assert.go
package assert
import (
"testing"
)
func Equal[T comparable](t *testing.T, actual, expected T) {
t.Helper()
if actual != expected {
t.Errorf("got: %v; want: %v", actual, expected)
}
}
Заметим, как мы настроили это так, что Equal()
является универсальной функцией? Это означает, что мы сможем использовать ее независимо от типа фактических и ожидаемых значений. Пока фактические и ожидаемые значения имеют один и тот же тип (например, они оба являются строковыми значениями или оба являются целыми числами), наш тестовый код должен компилироваться и работать нормально, когда мы вызываем Equal()
.
Примечание
Функция t.Helper()
, которую мы используем в приведенном выше коде, указывает Go-тест, что наша функция Equal()
является тестовым помощником. Это означает, что когда t.Errorf()
вызывается из нашей функции Equal()
, Go-тест будет сообщать имя файла и номер строки кода, который вызвал нашу функцию Equal()
, в выводе.
Без t.Helper()
вывод теста будет указывать на строку внутри функции Equal()
, где была вызвана t.Errorf()
, а не на строку в фактическом тестовом коде, где была вызвана Equal()
. Это может затруднить отладку и понимание, что именно пошло не так.
Используя t.Helper()
, мы можем получить более точную и полезную информацию об ошибках в наших тестах.
package main
import (
"testing"
"time"
"snippetbox.alexedwards.net/internal/assert" // New import
)
func TestHumanDate(t *testing.T) {
tests := []struct {
name string
tm time.Time
want string
}{
{
name: "UTC",
tm: time.Date(2022, 3, 17, 10, 15, 0, 0, time.UTC),
want: "17 Mar 2022 at 10:15",
},
{
name: "Empty",
tm: time.Time{},
want: "",
},
{
name: "CET",
tm: time.Date(2022, 3, 17, 10, 15, 0, 0, time.FixedZone("CET", 1*60*60)),
want: "17 Mar 2022 at 09:15",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hd := humanDate(tt.tm)
// Use the new assert.Equal() helper to compare the expected and
// actual values.
assert.Equal(t, hd, tt.want)
})
}
}
Дополнительная информация
Субтесты без таблицы тестовых случаев
Важно отметить, что вам не нужно использовать субтесты в сочетании с табличными тестами (как мы делали до сих пор в этой главе). Полностью допустимо выполнять субтесты, вызывая t.Run()
последовательно в ваших тестовых функциях, подобно этому:
func TestExample(t *testing.T) {
t.Run("Example sub-test 1", func(t *testing.T) {
// Выполняем один тест.
})
t.Run("Example sub-test 2", func(t *testing.T) {
// Выполняем другой тест.
})
t.Run("Example sub-test 3", func(t *testing.T) {
// И еще один...
})
}
Этот подход может быть полезен, когда у вас есть несколько независимых тестов, которые не связаны между собой таблицей тестовых случаев. Однако табличные тесты часто более удобны и кратки, когда у вас есть много тестов с похожими входными и выходными данными.
Тестирование HTTP-обработчиков и middleware
Давайте перейдем к конкретным техникам юнит-тестирования HTTP-обработчиков.
Все обработчики, которые мы написали для этого проекта, пока что довольно сложны для тестирования. Чтобы проще разобраться в теме, начнем с чего-то более простого.
Простой пример: обработчик ping
Откройте файл handlers.go и создайте новый обработчик ping, который:
- Возвращает статус-код 200 OK
- Отправляет тело ответа “OK”
Такой обработчик может использоваться для проверки работоспособности сервера (например, в мониторинге uptime).
package main
...
func ping(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("OK"))
}
В этой главе мы создадим юнит-тест TestPing, который проверит:
- Что обработчик ping возвращает HTTP-статус 200.
- Что тело ответа содержит строго “OK”.
Запись ответов
Для помощи в тестировании ваших обработчиков HTTP пакет net/http/httptest
в Go предоставляет набор полезных инструментов.
Одним из этих инструментов является тип httptest.ResponseRecorder
. По сути, это реализация http.ResponseWriter
, которая записывает код состояния ответа, заголовки и тело вместо того, чтобы фактически записывать их в соединение HTTP.
Таким образом, простым способом тестирования ваших обработчиков является создание нового объекта httptest.ResponseRecorder
, передача его в функцию обработчика, а затем проверка его после возврата обработчика.
Давайте попробуем сделать именно это, чтобы протестировать функцию обработчика ping
.
Сначала, следуя соглашениям Go, создайте новый файл handlers_test.go
,
$ touch cmd/web/handlers_test.go
Затем добавьте следующий код:
Файл: cmd/web/handlers_test.go
package main
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"testing"
"snippetbox.igorra.net/internal/assert"
)
func TestPing(t *testing.T) {
// Инициализируйте новый `httptest.ResponseRecorder`.
rr := httptest.NewRecorder()
// Инициализируйте новый фиктивный `http.Request`.
r, err := http.NewRequest(http.MethodGet, "/", nil)
if err != nil {
t.Fatal(err)
}
// Вызовите функцию обработчика `ping`, передав `httptest.ResponseRecorder` и `http.Request`.
ping(rr, r)
// Вызовите метод `Result()` для `http.ResponseRecorder`, чтобы получить `http.Response`, сгенерированный обработчиком `ping`.
rs := rr.Result()
// Проверьте, что код состояния, записанный обработчиком `ping`, был 200.
assert.Equal(t, rs.StatusCode, http.StatusOK)
// И мы можем проверить, что тело ответа, записанное обработчиком `ping`, равно "OK".
defer rs.Body.Close()
body, err := io.ReadAll(rs.Body)
if err != nil {
t.Fatal(err)
}
bytes.TrimSpace(body)
assert.Equal(t, string(body), "OK")
}
Примечание
В приведенном выше коде мы используем функцию t.Fatal()
в нескольких местах для обработки ситуаций, когда в нашем тестовом коде возникает неожиданная ошибка. При вызове t.Fatal()
пометит тест как неудачный, запишет ошибку и затем полностью остановит выполнение текущего теста (или подтеста).
Обычно следует вызывать t.Fatal()
в ситуациях, когда не имеет смысла продолжать текущий тест — например, при ошибке во время шага настройки или когда неожиданная ошибка от функции стандартной библиотеки Go означает, что вы не можете продолжить тест.
Сохраните файл, затем попробуйте запустить go test
снова с установленным флагом verbose. Вот так:
go test -v ./cmd/web
=== RUN TestPing
--- PASS: TestPing (0.00s)
=== RUN TestHumanDate
=== RUN TestHumanDate/UTC
=== RUN TestHumanDate/Empty
=== RUN TestHumanDate/CET
--- PASS: TestHumanDate (0.00s)
--- PASS: TestHumanDate/UTC (0.00s)
--- PASS: TestHumanDate/Empty (0.00s)
--- PASS: TestHumanDate/CET (0.00s)
PASS
ok snippetbox.igorra.net/cmd/web 0.002s
Тестирование middleware
Также возможно использовать тот же общий шаблон для модульного тестирования вашего middleware.
Мы продемонстрируем это, создав новый тест TestSecureHeaders
для middleware secureHeaders()
, который мы создали ранее в книге. В рамках этого теста мы хотим проверить, что:
Middleware `secureHeaders()` устанавливает все ожидаемые заголовки в ответе HTTP.
Middleware `secureHeaders()` корректно вызывает следующий обработчик в цепочке.
Сначала вам нужно создать файл cmd/web/middleware_test.go
, чтобы содержать тест:
$ touch cmd/web/middleware_test.go
Затем добавьте следующий код:
Файл: cmd/web/middleware_test.go
package main
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"testing"
"snippetbox.alexedwards.net/internal/assert"
)
func TestSecureHeaders(t *testing.T) {
// Инициализируйте новый `httptest.ResponseRecorder` и фиктивный `http.Request`.
rr := httptest.NewRecorder()
r, err := http.NewRequest(http.MethodGet, "/", nil)
if err != nil {
t.Fatal(err)
}
// Создайте фиктивный обработчик HTTP, который можно передать нашему middleware `secureHeaders`,
// который пишет код состояния 200 и тело ответа "OK".
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("OK"))
})
// Передайте фиктивный обработчик HTTP нашему middleware `secureHeaders`. Поскольку
// `secureHeaders` *возвращает* `http.Handler`, мы можем вызвать его метод `ServeHTTP()`,
// передав `http.ResponseRecorder` и фиктивный `http.Request`, чтобы выполнить его.
secureHeaders(next).ServeHTTP(rr, r)
// Вызовите метод `Result()` для `http.ResponseRecorder`, чтобы получить результаты теста.
rs := rr.Result()
// Проверьте, что middleware корректно установил заголовок `Content-Security-Policy` в ответе.
expectedValue := "default-src 'self'; style-src 'self' fonts.googleapis.com; font-src fonts.gstatic.com"
assert.Equal(t, rs.Header.Get("Content-Security-Policy"), expectedValue)
// Проверьте, что middleware корректно установил заголовок `Referrer-Policy` в ответе.
expectedValue = "origin-when-cross-origin"
assert.Equal(t, rs.Header.Get("Referrer-Policy"), expectedValue)
// Проверьте, что middleware корректно установил заголовок `X-Content-Type-Options` в ответе.
expectedValue = "nosniff"
assert.Equal(t, rs.Header.Get("X-Content-Type-Options"), expectedValue)
// Проверьте, что middleware корректно установил заголовок `X-Frame-Options` в ответе.
expectedValue = "deny"
assert.Equal(t, rs.Header.Get("X-Frame-Options"), expectedValue)
// Проверьте, что middleware корректно установил заголовок `X-XSS-Protection` в ответе.
expectedValue = "0"
assert.Equal(t, rs.Header.Get("X-XSS-Protection"), expectedValue)
// Проверьте, что middleware корректно вызвал следующий обработчик в цепочке,
// и код состояния ответа и тело ответа соответствуют ожиданиям.
assert.Equal(t, rs.StatusCode, http.StatusOK)
defer rs.Body.Close()
body, err := io.ReadAll(rs.Body)
if err != nil {
t.Fatal(err)
}
bytes.TrimSpace(body)
assert.Equal(t, string(body), "OK")
}
Если вы запустите тесты сейчас, вы должны увидеть, что тест TestSecureHeaders
проходит без каких-либо проблем.
go test -v ./cmd/web
=== RUN TestPing
--- PASS: TestPing (0.00s)
=== RUN TestSecureHeaders
--- PASS: TestSecureHeaders (0.00s)
=== RUN TestHumanDate
=== RUN TestHumanDate/UTC
=== RUN TestHumanDate/Empty
=== RUN TestHumanDate/CET
--- PASS: TestHumanDate (0.00s)
--- PASS: TestHumanDate/UTC (0.00s)
--- PASS: TestHumanDate/Empty (0.00s)
--- PASS: TestHumanDate/CET (0.00s)
PASS
ok snippetbox.igorra.net/cmd/web 0.002s
End-to-end testing (Сквозное тестирование)
В предыдущей главе мы обсудили общий шаблон для модульного тестирования ваших обработчиков HTTP в изоляции.
Но — в большинстве случаев — ваши обработчики HTTP не используются в изоляции. Итак, в этой главе мы объясним, как запустить сквозные тесты на вашем веб-приложении, которые охватывают маршрутизацию, middleware и обработчики.
Чтобы проиллюстрировать это, мы адаптируем нашу функцию TestPing
так, чтобы она запускала сквозной тест на нашем коде. В частности, мы хотим, чтобы тест гарантировал, что GET-запрос к /ping
нашему приложению вызывает функцию обработчика ping
и приводит к статусу 200 OK и телу ответа “OK”.
По сути, мы хотим проверить, что наше приложение имеет маршрут, подобный этому:
Метод | Шаблон | Обработчик | Действие |
---|---|---|---|
GET | /ping | ping | Вернуть ответ 200 OK |
Использование httptest.Server
Ключом к сквозному тестированию нашего приложения является функция httptest.NewTLSServer()
, которая создает экземпляр httptest.Server
, которому можно отправлять HTTPS-запросы.
С учетом этого, вернитесь к вашему файлу handlers_test.go
и обновите тест TestPing
так, чтобы он выглядел следующим образом:
Файл: cmd/web/handlers_test.go
package main
import (
"bytes"
"io"
"log" // Новый импорт
"net/http"
"net/http/httptest"
"testing"
"snippetbox.alexedwards.net/internal/assert"
)
func TestPing(t *testing.T) {
// Создайте новый экземпляр нашей структуры приложения. На данный момент это просто
// содержит пару фиктивных логгеров (которые игнорируют все, что пишется в них).
app := &application{
errorLog: log.New(io.Discard, "", 0),
infoLog: log.New(io.Discard, "", 0),
}
// Затем мы используем функцию `httptest.NewTLSServer()`, чтобы создать новый тестовый
// сервер, передав значение, возвращенное нашим методом `app.routes()`, в качестве
// обработчика для сервера. Это запускает HTTPS-сервер, который слушает на
// случайно выбранном порту вашей локальной машины в течение теста.
// Обратите внимание, что мы откладываем вызов `ts.Close()`, чтобы сервер был закрыт
// после завершения теста.
ts := httptest.NewTLSServer(app.routes())
defer ts.Close()
// Адрес сети, на котором тестовый сервер слушает, содержится в поле `ts.URL`.
// Мы можем использовать это вместе с методом `ts.Client().Get()`, чтобы отправить
// GET-запрос к `/ping` тестовому серверу. Это возвращает структуру `http.Response`,
// содержащую ответ.
rs, err := ts.Client().Get(ts.URL + "/ping")
if err != nil {
t.Fatal(err)
}
// Затем мы можем проверить значение кода состояния ответа и тела, используя
// тот же шаблон, что и раньше.
assert.Equal(t, rs.StatusCode, http.StatusOK)
defer rs.Body.Close()
body, err := io.ReadAll(rs.Body)
if err != nil {
t.Fatal(err)
}
bytes.TrimSpace(body)
assert.Equal(t, string(body), "OK")
}
Есть несколько вещей в этом коде, на которые стоит обратить внимание и обсудить.
- Когда мы вызываем
httptest.NewTLSServer()
, чтобы инициализировать тестовый сервер, нам нужно передатьhttp.Handler
в качестве параметра — и этот обработчик вызывается каждый раз, когда тестовый сервер получает HTTPS-запрос. В нашем случае мы передали возвращаемое значение из нашего методаapp.routes()
, что означает, что запрос к тестовому серверу будет использовать все наши реальные маршруты приложения, middleware и обработчики. - Это большое преимущество работы, которую мы проделали ранее в книге, чтобы изолировать всю маршрутизацию нашего приложения в методе
app.routes()
. - Если вы тестируете HTTP-сервер (а не HTTPS), вы должны использовать функцию
httptest.NewServer()
, чтобы создать тестовый сервер. - Метод
ts.Client()
возвращает клиент тестового сервера — который имеет типhttp.Client
— и мы всегда должны использовать этого клиента, чтобы отправлять запросы к тестовому серверу. Возможно настроить клиента, чтобы изменить его поведение, и мы объясним, как это сделать, в конце этой главы. - Вы можете задаться вопросом, почему мы установили поля
errorLog
иinfoLog
нашей структуры приложения, но не все остальные поля. Причина этого в том, что логгеры необходимы middlewarelogRequest
иrecoverPanic
, которые используются нашим приложением на каждом маршруте. Попытка запустить этот тест без установки этих двух зависимостей приведет к панике.
В любом случае, давайте попробуем новый тест:
go test -v ./cmd/web
=== RUN TestPing
handlers_test.go:42: got: 404; want: 200
handlers_test.go:51: got: Not Found
; want: OK
--- FAIL: TestPing (0.00s)
Из тестового вывода мы видим, что ответ на наш запрос GET /ping имеет код состояния 404, а не 200, как мы ожидали. И это потому, что мы еще не зарегистрировали маршрут GET /ping с помощью нашего маршрутизатора. Внесем правку в файл File: cmd/web/routes.go
router.HandlerFunc(http.MethodGet, "/ping", ping)
Итог:
go test -v ./cmd/web
=== RUN TestPing
--- PASS: TestPing (0.00s)
ok snippetbox.igorra.net/cmd/web 0.005s
Использование тестовых помощников
Наш тест TestPing
теперь работает хорошо. Но есть хорошая возможность выделить некоторые части этого кода в функции-помощники, которые мы можем повторно использовать, добавляя больше сквозных тестов к нашему проекту.
Нет жестких и быстрых правил о том, где размещать методы-помощники для тестов. Если помощник используется только в определенном файле *_test.go, то, вероятно, имеет смысл включить его встроенным в этот файл рядом с вашими тестами. С другой стороны, если вы собираетесь использовать помощник в тестах в нескольких пакетах, то вы можете поместить его в повторно используемый пакет, называемый internal/testutils
(или аналогичный), который может быть импортирован вашими тестовыми файлами.
В нашем случае помощники будут использоваться только в нашем пакете cmd/web
, и мы поместим их в новый файл cmd/web/testutils_test.go
.
Если вы следите за ходом, пожалуйста, создайте этот файл сейчас…
$ touch cmd/web/testutils_test.go
Затем добавьте следующий код:
Файл: cmd/web/testutils_test.go
package main
import (
"bytes"
"io"
"log"
"net/http"
"net/http/httptest"
"testing"
)
// Создайте помощник `newTestApplication`, который возвращает экземпляр нашей
// структуры приложения, содержащий фиктивные зависимости.
func newTestApplication(t *testing.T) *application {
return &application{
errorLog: log.New(io.Discard, "", 0),
infoLog: log.New(io.Discard, "", 0),
}
}
// Определите пользовательский тип `testServer`, который включает экземпляр `httptest.Server`.
type testServer struct {
*httptest.Server
}
// Создайте помощник `newTestServer`, который инициализирует и возвращает новый экземпляр
// нашего пользовательского типа `testServer`.
func newTestServer(t *testing.T, h http.Handler) *testServer {
ts := httptest.NewTLSServer(h)
return &testServer{ts}
}
// Реализуйте метод `get()` на нашем пользовательском типе `testServer`. Это делает GET-запрос
// к заданному пути URL, используя клиент тестового сервера, и возвращает код состояния,
// заголовки и тело ответа.
func (ts *testServer) get(t *testing.T, urlPath string) (int, http.Header, string) {
rs, err := ts.Client().Get(ts.URL + urlPath)
if err != nil {
t.Fatal(err)
}
defer rs.Body.Close()
body, err := io.ReadAll(rs.Body)
if err != nil {
t.Fatal(err)
}
bytes.TrimSpace(body)
return rs.StatusCode, rs.Header, string(body)
}
По сути, это просто обобщение кода, который мы уже написали в этой главе, чтобы запустить тестовый сервер и отправить GET-запрос к нему.
Давайте вернемся к нашему тесту TestPing
и используем эти новые помощники:
package main
import (
"net/http"
"testing"
"snippetbox.alexedwards.net/internal/assert"
)
func TestPing(t *testing.T) {
app := newTestApplication(t)
ts := newTestServer(t, app.routes())
defer ts.Close()
code, _, body := ts.get(t, "/ping")
assert.Equal(t, code, http.StatusOK)
assert.Equal(t, body, "OK")
}
все сработало
go test -v ./cmd/web
ok snippetbox.igorra.net/cmd/web 0.005s
Cookies and redirections
До сих пор в этой главе мы использовали настройки клиента тестового сервера по умолчанию. Но есть несколько изменений, которые я хотел бы внести, чтобы сделать его более подходящим для тестирования нашего веб-приложения. В частности:
- Мы хотим, чтобы клиент автоматически хранил любые куки, отправленные в ответе HTTPS, чтобы мы могли включить их (если это необходимо) в любые последующие запросы обратно к тестовому серверу. Это пригодится позже в книге, когда нам нужно будет поддерживать куки на нескольких запросах, чтобы протестировать наши меры противодействия CSRF.
- Мы не хотим, чтобы клиент автоматически следовал перенаправлениям. Вместо этого мы хотим, чтобы он возвращал первый ответ HTTPS, отправленный нашим сервером, чтобы мы могли протестировать ответ на этот конкретный запрос.
Изменим testutils_test.go
и обновим функцию newTestServer()
:
Файл: cmd/web/testutils_test.go
package main
import (
"bytes"
"io"
"log"
"net/http"
"net/http/cookiejar" // Новый импорт
"net/http/httptest"
"testing"
)
...
func newTestServer(t *testing.T, h http.Handler) *testServer {
// Инициализируйте тестовый сервер как обычно.
ts := httptest.NewTLSServer(h)
// Инициализируйте новый контейнер для куки.
jar, err := cookiejar.New(nil)
if err != nil {
t.Fatal(err)
}
// Добавьте контейнер для куки к клиенту тестового сервера. Любые куки ответа теперь
// будут храниться и отправляться с последующими запросами при использовании этого клиента.
ts.Client().Jar = jar
// Отключите следование за перенаправлениями для клиента тестового сервера, установив
// пользовательскую функцию `CheckRedirect`. Эта функция будет вызвана всякий раз, когда
// клиент получит ответ 3xx, и, всегда возвращая ошибку `http.ErrUseLastResponse`,
// она заставляет клиента немедленно вернуть полученный ответ.
ts.Client().CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}
return &testServer{ts}
}
...
Настройка тестирования
Управление запуском тестов
До сих пор в этой книге мы запускали тесты в определенном пакете (пакете cmd/web
) следующим образом:
$ go test ./cmd/web
Но также возможно запустить все тесты в вашем текущем проекте, используя шаблон ./...
. В нашем случае мы можем использовать его для запуска всех тестов в нашем проекте следующим образом:
$ go test ./...
ok snippetbox.alexedwards.net/cmd/web 0.007s
? snippetbox.alexedwards.net/internal/models [no test files]
? snippetbox.alexedwards.net/internal/validator [no test files]
? snippetbox.alexedwards.net/ui [no test files]
Или, идя в другом направлении, можно запустить только определенные тесты, используя флаг -run
. Это позволяет вам передать регулярное выражение — и только тесты с именем, совпадающим с регулярным выражением, будут запущены.
Например, мы могли бы запустить только тест TestPing
следующим образом:
$ go test -v -run="^TestPing$" ./cmd/web/
=== RUN TestPing
--- PASS: TestPing (0.00s)
PASS
ok snippetbox.alexedwards.net/cmd/web 0.008s
Вы также можете использовать флаг -run
, чтобы ограничить тестирование до определенных подтестов, используя формат {regexp}/{sub-test regexp}
. Например, чтобы запустить подтест UTC
нашего теста TestHumanDate
, мы можем сделать это:
$ go test -v -run="^TestHumanDate$/^UTC$" ./cmd/web
=== RUN TestHumanDate
=== RUN TestHumanDate/UTC
--- PASS: TestHumanDate (0.00s)
--- PASS: TestHumanDate/UTC (0.00s)
PASS
ok snippetbox.alexedwards.net/cmd/web 0.003s
Кэширование тестов
Если вы запустите один и тот же тест дважды — без внесения каких-либо изменений в пакет, который вы тестируете — то отображается кэшированная версия результата теста (указанная аннотацией (cached)
рядом с именем пакета).
$ go test ./cmd/web
ok snippetbox.alexedwards.net/cmd/web (cached)
В большинстве случаев кэширование результатов тестов действительно полезно (особенно для больших кодовых баз), поскольку оно помогает сократить общее время выполнения тестов. Но если вы хотите принудительно запустить свои тесты в полном объеме (и избежать кэша), вы можете использовать флаг -count=1
:
$ go test -count=1 ./cmd/web
Примечание
Флагcount
используется для указания go test
, сколько раз вы хотите выполнить каждый тест. Это не кэшируемый флаг, что означает, что каждый раз, когда вы его используете, go test
не будет ни читать, ни записывать результаты тестов в кэш. Таким образом, использование count=1
— это своего рода трюк, чтобы избежать кэша без влияния на выполнение тестов.
Кроме того, вы можете очистить кэшированные результаты для всех тестов с помощью команды go clean
:
$ go clean -testcache
Быстрое завершение
Когда мы используем функцию t.Errorf()
, чтобы пометить тест как неудачный, она не вызывает немедленное завершение go test
. Все остальные тесты (и подтесты) будут продолжать выполняться после сбоя.
Если вы предпочитаете завершить тесты сразу после первого сбоя, вы можете использовать флаг -failfast
:
$ go test -failfast ./cmd/web
Важно отметить, что флаг -failfast
останавливает тесты только в пакете, где произошел сбой. Если вы запускаете тесты в нескольких пакетах (например, используя go test ./...
), то тесты в других пакетах будут продолжать выполняться.
Параллельное тестирование
По умолчанию команда go test
выполняет все тесты последовательно, один за другим. Когда у вас небольшое количество тестов (как у нас) и время выполнения очень быстрое, это абсолютно нормально.
Но если у вас сотни или тысячи тестов, общее время выполнения может начать накапливаться до чего-то более существенного. И в этом сценарии вы можете сэкономить себе некоторое время, запустив тесты параллельно.
Вы можете указать, что тест можно запускать одновременно с другими тестами, вызвав функцию t.Parallel()
в начале теста. Например:
func TestPing(t *testing.T) {
t.Parallel()
...
}
Важно отметить, что:
-
Тесты, отмеченные с помощью
t.Parallel()
, будут запускаться параллельно с другими параллельными тестами и только с ними. -
По умолчанию максимальное количество тестов, которые будут запускаться одновременно, равно текущему значению
GOMAXPROCS
. Вы можете переопределить это, установив конкретное значение с помощью флага-parallel
. Например:$ go test -parallel 4 ./...
-
Не все тесты подходят для параллельного выполнения. Например, если у вас есть интеграционный тест, который требует, чтобы таблица базы данных находилась в определенном известном состоянии, то вы не хотели бы запускать его параллельно с другими тестами, которые изменяют ту же таблицу базы данных.
Включение детектора гонок
Команда go test
включает флаг -race
, который активирует детектор гонок Go при запуске тестов.
Если код, который вы тестируете, использует конкурентность, или вы запускаете тесты параллельно, включение этого может быть хорошей идеей, чтобы помочь выявить гонки, существующие в вашем приложении. Вы можете использовать его следующим образом:
$ go test -race ./cmd/web/
Важно отметить, что детектор гонок — это просто инструмент, который выявляет гонки данных, если и когда они обнаруживаются во время выполнения тестов. Он не проводит статический анализ вашего кода, и чистый запуск не гарантирует, что ваш код свободен от гонок.
Включение детектора гонок также увеличит общее время выполнения ваших тестов. Поэтому, если вы запускаете тесты очень часто в рамках рабочего процесса TDD, вы можете предпочесть использовать флаг -race
только во время выполнения тестов перед коммитом.
Мокирование зависимостей
Мокирование зависимостей
Мокирование зависимостей (или “mocking dependencies”) — это техника, используемая в тестировании программного обеспечения, которая позволяет создавать поддельные (мок) версии зависимостей, с которыми взаимодействует тестируемый компонент. Это делается для того, чтобы изолировать тестируемый код от реальных зависимостей, что позволяет:
-
Избежать побочных эффектов: Использование реальных зависимостей может привести к нежелательным изменениям состояния (например, запись в базу данных или отправка сетевых запросов). Моки позволяют избежать этих побочных эффектов.
-
Упростить тестирование: Моки могут быть настроены для возврата предопределенных значений, что упрощает тестирование различных сценариев, включая ошибки и исключительные ситуации.
-
Ускорить тесты: Тесты с моками обычно выполняются быстрее, так как они не зависят от внешних систем, таких как базы данных или API.
-
Улучшить контроль: Моки позволяют контролировать и проверять, как тестируемый код взаимодействует с зависимостями, что помогает выявить ошибки в логике.
В Go для мокирования зависимостей часто используются библиотеки, такие как gomock
или testify
, которые позволяют создавать моки и определять их поведение в тестах.
Новые тесты для нашего обработчика snippetView
и маршрута GET /snippet/view/:id
.
На протяжении всего этого проекта мы внедряли зависимости в наши обработчики через структуру application
, которая сейчас выглядит следующим образом:
type application struct {
errorLog *log.Logger
infoLog *log.Logger
snippets *models.SnippetModel
users *models.UserModel
templateCache map[string]*template.Template
formDecoder *form.Decoder
sessionManager *scs.SessionManager
}
При тестировании иногда имеет смысл мокировать эти зависимости вместо использования точно таких же, как в вашем производственном приложении.
Например, в предыдущей главе мы мокировали зависимости errorLog
и infoLog
логгерами, которые пишут сообщения в io.Discard
, вместо потоков os.Stdout
и os.Stderr
, как мы делаем в нашем производственном приложении:
func newTestApplication(t *testing.T) *application {
return &application{
errorLog: log.New(io.Discard, "", 0),
infoLog: log.New(io.Discard, "", 0),
}
}
Причина мокирования этих и записи в io.Discard
заключается в том, чтобы избежать засорения вывода наших тестов ненужными сообщениями журнала при запуске go test -v
(с включенным режимом verbose).
Примечание: В зависимости от вашего происхождения и опыта программирования, вы можете не считать эти логгеры моками. Вы можете назвать их фейками, заглушками или чем-то совсем другим. Но название не имеет значения — и разные люди называют их по-разному. Главное, что мы используем что-то, что exposes тот же интерфейс, что и производственный объект, для целей тестирования.
Две другие зависимости, которые имеет смысл мокировать, — это модели базы данных models.SnippetModel
и models.UserModel
. Создавая моки этих моделей, можно тестировать поведение наших обработчиков без необходимости настраивать весь тестовый экземпляр базы данных MySQL.
Мокирование моделей базы данных
Создайте новый пакет internal/models/mocks
, содержащий файлы snippets.go
и users.go
для хранения моков моделей базы данных, следующим образом:
$ mkdir internal/models/mocks
$ touch internal/models/mocks/snippets.go
$ touch internal/models/mocks/users.go
Давайте начнем с создания мока для нашей модели models.SnippetModel
. Для этого мы создадим простую структуру, которая реализует те же методы, что и наша производственная модель models.SnippetModel
, но методы будут возвращать фиксированные тестовые данные.
Файл: internal/models/mocks/snippets.go
package mocks
import (
"time"
"snippetbox.alexedwards.net/internal/models"
)
var mockSnippet = &models.Snippet{
ID: 1,
Title: "Старый тихий пруд",
Content: "Старый тихий пруд...",
Created: time.Now(),
Expires: time.Now(),
}
type SnippetModel struct{}
func (m *SnippetModel) Insert(title string, content string, expires int) (int, error) {
return 2, nil
}
func (m *SnippetModel) Get(id int) (*models.Snippet, error) {
switch id {
case 1:
return mockSnippet, nil
default:
return nil, models.ErrNoRecord
}
}
func (m *SnippetModel) Latest() ([]*models.Snippet, error) {
return []*models.Snippet{mockSnippet}, nil
}
Теперь сделаем то же самое для нашей модели models.UserModel
:
Файл: internal/models/mocks/users.go
package mocks
import (
"snippetbox.alexedwards.net/internal/models"
)
type UserModel struct{}
func (m *UserModel) Insert(name, email, password string) error {
switch email {
case "dupe@example.com":
return models.ErrDuplicateEmail
default:
return nil
}
}
func (m *UserModel) Authenticate(email, password string) (int, error) {
if email == "alice@example.com" && password == "pa$$word" {
return 1, nil
}
return 0, models.ErrInvalidCredentials
}
func (m *UserModel) Exists(id int) (bool, error) {
switch id {
case 1:
return true, nil
default:
return false, nil
}
}
Инициализация моков
На следующем этапе нашего проекта давайте вернемся к файлу testutils_test.go
и обновим функцию newTestApplication()
, чтобы она создавала структуру приложения со всеми необходимыми зависимостями для тестирования.
Файл: cmd/web/testutils_test.go
package main
import (
"bytes"
"io"
"log"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"testing"
"time" // Новый импорт
"snippetbox.alexedwards.net/internal/models/mocks" // Новый импорт
"github.com/alexedwards/scs/v2" // Новый импорт
"github.com/go-playground/form/v4" // Новый импорт
)
func newTestApplication(t *testing.T) *application {
// Создаем экземпляр кеша шаблонов.
templateCache, err := newTemplateCache()
if err != nil {
t.Fatal(err)
}
// И декодер форм.
formDecoder := form.NewDecoder()
// И экземпляр менеджера сессий. Обратите внимание, что мы используем те же настройки, что и
// в производственной среде, за исключением того, что мы *не* устанавливаем Store для менеджера сессий.
// Если Store не установлен, пакет SCS по умолчанию будет использовать временное
// хранилище в памяти, что идеально подходит для тестирования.
sessionManager := scs.New()
sessionManager.Lifetime = 12 * time.Hour
sessionManager.Cookie.Secure = true
return &application{
errorLog: log.New(io.Discard, "", 0),
infoLog: log.New(io.Discard, "", 0),
snippets: &mocks.SnippetModel{}, // Используем мок.
users: &mocks.UserModel{}, // Используем мок.
templateCache: templateCache,
formDecoder: formDecoder,
sessionManager: sessionManager,
}
}
…
Если вы попробуете запустить тесты сейчас, они не скомпилируются и выдадут следующие ошибки:
$ go test ./cmd/web
# snippetbox.alexedwards.net/cmd/web [snippetbox.alexedwards.net/cmd/web.test]
cmd/web/testutils_test.go:40:19: cannot use &mocks.SnippetModel{} (value of type *mocks.SnippetModel) as type *models.SnippetModel in struct literal
cmd/web/testutils_test.go:41:19: cannot use &mocks.UserModel{} (value of type *mocks.UserModel) as type *models.UserModel in struct literal
FAIL snippetbox.alexedwards.net/cmd/web [build failed]
FAIL
Это происходит потому, что наша структура приложения ожидает указатели на экземпляры models.SnippetModel
и models.UserModel
, но мы пытаемся использовать указатели на экземпляры mocks.SnippetModel
и mocks.UserModel
.
Идиоматическое решение этой проблемы — изменить нашу структуру приложения так, чтобы она использовала интерфейсы, которые удовлетворяются как нашими моками, так и производственными моделями базы данных.
Для этого давайте вернемся к файлу internal/models/snippets.go
и создадим новый тип интерфейса SnippetModelInterface
, который описывает методы, которые есть у нашей фактической структуры SnippetModel
.
Файл: internal/models/snippets.go
package models
import (
"database/sql"
"errors"
"time"
)
type SnippetModelInterface interface {
Insert(title string, content string, expires int) (int, error)
Get(id int) (*Snippet, error)
Latest() ([]*Snippet, error)
}
…
Теперь сделаем то же самое для нашей структуры UserModel
:
Файл: internal/models/users.go
package models
import (
"database/sql"
"errors"
"strings"
"time"
"github.com/go-sql-driver/mysql"
"golang.org/x/crypto/bcrypt"
)
type UserModelInterface interface {
Insert(name, email, password string) error
Authenticate(email, password string) (int, error)
Exists(id int) (bool, error)
}
…
Теперь, когда мы определили эти интерфейсы, давайте обновим нашу структуру приложения, чтобы она использовала их вместо конкретных типов SnippetModel
и UserModel
. Вот так:
Файл: cmd/web/main.go
package main
import (
"crypto/tls"
"database/sql"
"flag"
"html/template"
"log"
"net/http"
"os"
"time"
"snippetbox.alexedwards.net/internal/models"
"github.com/alexedwards/scs/mysqlstore"
"github.com/alexedwards/scs/v2"
"github.com/go-playground/form/v4"
_ "github.com/go-sql-driver/mysql"
)
type application struct {
errorLog *log.Logger
infoLog *log.Logger
snippets models.SnippetModelInterface // Используем наш новый интерфейс.
users models.UserModelInterface // Используем наш новый интерфейс.
templateCache map[string]*template.Template
formDecoder *form.Decoder
sessionManager *scs.SessionManager
}
…
Если вы снова попробуете запустить тесты, все должно работать корректно.
go test -v ./cmd/web
ok snippetbox.igorra.net/cmd/web 0.005s
Мы обновили структуру приложения так, что вместо того, чтобы поля snippets
и users
имели конкретные типы *models.SnippetModel
и *models.UserModel
, теперь они являются интерфейсами.
Пока объект имеет необходимые методы для удовлетворения интерфейса, мы можем использовать его в нашей структуре приложения. Как наши «реальные» модели базы данных (например, models.SnippetModel
), так и моковые модели базы данных (например, mocks.SnippetModel
) удовлетворяют этим интерфейсам, поэтому мы теперь можем использовать их взаимозаменяемо.
Тестирование обработчика snippetView
Перейдем к написанию сквозного теста для нашего обработчика snippetView
, который использует эти мокированные зависимости.
В рамках этого теста код в нашем обработчике snippetView
будет вызывать метод mock.SnippetModel.Get()
. Напоминаем, что этот мокированный метод модели возвращает models.ErrNoRecord
, если ID сниппета не равен 1 — в противном случае он вернет следующий мок-сниппет:
var mockSnippet = &models.Snippet{
ID: 1,
Title: "Старый тихий пруд",
Content: "Старый тихий пруд...",
Created: time.Now(),
Expires: time.Now(),
}
Таким образом, мы хотим протестировать следующее:
- Для запроса
GET /snippet/view/1
мы получаем ответ 200 OK с соответствующим мок-сниппетом, содержащимся в теле HTML-ответа. - Для всех остальных запросов к
GET /snippet/view/*
мы должны получить ответ 404 Not Found.
Для первой части теста мы хотим проверить, что тело ответа содержит определенное содержимое, а не является точно равным ему. Давайте быстро добавим новую функцию StringContains()
в наш пакет assert
, чтобы помочь с этим:
Файл: assert/assert.go
package assert
import (
"strings" // Новый импорт
"testing"
)
...
func StringContains(t *testing.T, actual, expectedSubstring string) {
t.Helper()
if !strings.Contains(actual, expectedSubstring) {
t.Errorf("got: %q; expected to contain: %q", actual, expectedSubstring)
}
}
Затем откройте файл cmd/web/handlers_test.go
и создайте новый тест TestSnippetView
, как показано ниже:
Файл: cmd/web/handlers_test.go
package main
...
func TestSnippetView(t *testing.T) {
// Создаем новый экземпляр нашей структуры приложения, который использует мокированные зависимости.
app := newTestApplication(t)
// Устанавливаем новый тестовый сервер для выполнения сквозных тестов.
ts := newTestServer(t, app.routes())
defer ts.Close()
// Настраиваем тесты с использованием таблицы для проверки ответов, отправляемых нашим приложением для различных URL.
tests := []struct {
name string
urlPath string
wantCode int
wantBody string
}{
{
name: "Valid ID",
urlPath: "/snippet/view/1",
wantCode: http.StatusOK,
wantBody: "Старый тихий пруд...",
},
{
name: "Non-existent ID",
urlPath: "/snippet/view/2",
wantCode: http.StatusNotFound,
},
{
name: "Negative ID",
urlPath: "/snippet/view/-1",
wantCode: http.StatusNotFound,
},
{
name: "Decimal ID",
urlPath: "/snippet/view/1.23",
wantCode: http.StatusNotFound,
},
{
name: "String ID",
urlPath: "/snippet/view/foo",
wantCode: http.StatusNotFound,
},
{
name: "Empty ID",
urlPath: "/snippet/view/",
wantCode: http.StatusNotFound,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
code, _, body := ts.get(t, tt.urlPath)
assert.Equal(t, code, tt.wantCode)
if tt.wantBody != "" {
assert.StringContains(t, body, tt.wantBody)
}
})
}
}
Когда вы снова запустите тесты, все должно пройти успешно, и вы увидите вывод, включая новые тесты TestSnippetView
, который будет выглядеть примерно так:
--- PASS: TestSnippetView (0.01s)
--- PASS: TestSnippetView/Valid_ID (0.00s)
--- PASS: TestSnippetView/Non-existent_ID (0.00s)
--- PASS: TestSnippetView/Negative_ID (0.00s)
--- PASS: TestSnippetView/Decimal_ID (0.00s)
--- PASS: TestSnippetView/String_ID (0.00s)
--- PASS: TestSnippetView/Empty_ID (0.00s)
Обратите внимание, как названия субтестов были канонизированы? Все пробелы в названии подтеста были заменены символом подчеркивания (и все непечатаемые символы также будут экранированы) в выходных данных теста.
Тестирование HTML-форм
В этой главе мы добавим сквозной тест для маршрута POST /user/signup
, который обрабатывается нашим обработчиком userSignupPost
.
Тестирование этого маршрута немного усложняется из-за проверки на наличие CSRF-токена, которую выполняет наше приложение. Любой запрос к POST /user/signup
всегда будет получать ответ 400 Bad Request, если запрос не содержит действительный CSRF-токен и куки. Чтобы обойти это, нам нужно эмулировать рабочий процесс реального пользователя в рамках нашего теста, следующим образом:
- Сделать запрос
GET /user/signup
. Это вернет ответ, который содержит CSRF-куку в заголовках ответа и CSRF-токен для страницы регистрации в теле ответа. - Извлечь CSRF-токен из HTML-тела ответа.
- Сделать запрос
POST /user/signup
, используя тот жеhttp.Client
, который мы использовали на шаге 1 (чтобы он автоматически передал CSRF-куку с POST-запросом) и включая CSRF-токен вместе с другими данными POST, которые мы хотим протестировать.
Давайте начнем с добавления новой вспомогательной функции в наш файл cmd/web/testutils_test.go
для извлечения CSRF-токена (если он существует) из HTML-тела ответа:
Файл: cmd/web/testutils_test.go
package main
import (
"bytes"
"html" // Новый импорт
"io"
"log"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"regexp" // Новый импорт
"testing"
"time"
"snippetbox.alexedwards.net/internal/models/mocks"
"github.com/alexedwards/scs/v2"
"github.com/go-playground/form/v4"
)
// Определяем регулярное выражение, которое захватывает значение CSRF-токена из
// HTML для нашей страницы регистрации пользователя.
var csrfTokenRX = regexp.MustCompile(`<input type='hidden' name='csrf_token' value='(.+)'>`)
func extractCSRFToken(t *testing.T, body string) string {
// Используем метод FindStringSubmatch для извлечения токена из HTML-тела.
// Обратите внимание, что это возвращает массив с полным совпадением в
// первой позиции и значениями любых захваченных данных в последующих
// позициях.
matches := csrfTokenRX.FindStringSubmatch(body)
if len(matches) < 2 {
t.Fatal("no csrf token found in body")
}
return html.UnescapeString(string(matches[1]))
}
Примечание
Вы можете задаться вопросом, почему мы используем функцию html.UnescapeString()
перед возвратом CSRF-токена.
Причина в том, что пакет html/template
в Go автоматически экранирует все динамически отображаемые данные, включая наш CSRF-токен. Поскольку CSRF-токен является строкой, закодированной в base64, он потенциально может содержать символ +
, который будет экранирован как +
. Поэтому после извлечения токена из HTML нам нужно пропустить его через html.UnescapeString()
, чтобы получить оригинальное значение токена.
Теперь, когда это сделано, давайте вернемся к файлу cmd/web/handlers_test.go
и создадим новый тест TestUserSignup
.
Для начала мы сделаем запрос GET /user/signup
, а затем извлечем и выведем CSRF-токен из тела HTML-ответа. Вот так:
Файл: cmd/web/handlers_test.go
package main
...
func TestUserSignup(t *testing.T) {
// Создаем структуру приложения, содержащую наши мокированные зависимости, и настраиваем
// тестовый сервер для выполнения сквозного теста.
app := newTestApplication(t)
ts := newTestServer(t, app.routes())
defer ts.Close()
// Выполняем запрос GET /user/signup и затем извлекаем CSRF-токен из
// тела ответа.
_, _, body := ts.get(t, "/user/signup")
csrfToken := extractCSRFToken(t, body)
// Логируем значение CSRF-токена в нашем тестовом выводе, используя функцию t.Logf().
// Функция t.Logf() работает так же, как fmt.Printf(), но записывает
// предоставленное сообщение в тестовый вывод.
t.Logf("CSRF token is: %q", csrfToken)
}
Важно, чтобы вы запускали тесты с флагом -v
(для включения подробного вывода), чтобы увидеть любой вывод от функции t.Logf()
.
Давайте сделаем это сейчас:
go test -v -run="TestUserSignup" ./cmd/web/
=== RUN TestUserSignup
handlers_test.go:96: CSRF token is: "+mBVJFv/BJOlinbDGyrjuB4noDRwCDctcf/gckiVWc2z2zo8mdY52+uuPiXNxqGnhZnFh7udQvsIehQx7/NO4Q=="
--- PASS: TestUserSignup (0.00s)
PASS
ok snippetbox.igorra.net/cmd/web 0.006s
Тестирование POST-запросов
Теперь давайте вернемся к файлу cmd/web/testutils_test.go
и создадим новый метод postForm()
для нашего типа testServer
, который мы можем использовать для отправки POST-запроса на наш тестовый сервер с конкретными данными формы в теле запроса.
Добавьте следующий код (который следует тому же общему паттерну, что и метод get()
, описанный ранее в книге):
Файл: cmd/web/testutils_test.go
package main
import (
"bytes"
"html"
"io"
"log"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url" // Новый импорт
"regexp"
"testing"
"time"
"snippetbox.alexedwards.net/internal/models/mocks"
"github.com/alexedwards/scs/v2"
"github.com/go-playground/form/v4"
)
...
// Создаем метод postForm для отправки POST-запросов на тестовый сервер.
// Последний параметр этого метода — это объект url.Values, который может
// содержать любые данные формы, которые вы хотите отправить в теле запроса.
func (ts *testServer) postForm(t *testing.T, urlPath string, form url.Values) (int, http.Header, string) {
rs, err := ts.Client().PostForm(ts.URL+urlPath, form)
if err != nil {
t.Fatal(err)
}
// Читаем тело ответа от тестового сервера.
defer rs.Body.Close()
body, err := io.ReadAll(rs.Body)
if err != nil {
t.Fatal(err)
}
bytes.TrimSpace(body)
// Возвращаем статус ответа, заголовки и тело.
return rs.StatusCode, rs.Header, string(body)
}
Теперь, наконец, мы готовы добавить несколько тестов с использованием таблицы для проверки поведения маршрута POST /user/signup
нашего приложения. В частности, мы хотим протестировать, что:
- Действительная регистрация приводит к ответу 303 See Other.
- Отправка формы без действительного CSRF-токена приводит к ответу 400 Bad Request.
- Неверная отправка формы приводит к ответу 422 Unprocessable Entity и повторному отображению формы регистрации. Это должно происходить, когда:
- Поля имени, электронной почты или пароля пустые.
- Электронная почта не имеет действительного формата.
- Пароль короче 8 символов.
- Адрес электронной почты уже используется.
Давайте обновим функцию TestUserSignup
, чтобы выполнить эти тесты следующим образом:
package main
import (
"net/http"
"net/url" // New import
"testing"
"snippetbox.alexedwards.net/internal/assert"
)
...
func TestUserSignup(t *testing.T) {
app := newTestApplication(t)
ts := newTestServer(t, app.routes())
defer ts.Close()
_, _, body := ts.get(t, "/user/signup")
validCSRFToken := extractCSRFToken(t, body)
const (
validName = "Bob"
validPassword = "validPa$$word"
validEmail = "bob@example.com"
formTag = "<form action='/user/signup' method='POST' novalidate>"
)
tests := []struct {
name string
userName string
userEmail string
userPassword string
csrfToken string
wantCode int
wantFormTag string
}{
{
name: "Valid submission",
userName: validName,
userEmail: validEmail,
userPassword: validPassword,
csrfToken: validCSRFToken,
wantCode: http.StatusSeeOther,
},
{
name: "Invalid CSRF Token",
userName: validName,
userEmail: validEmail,
userPassword: validPassword,
csrfToken: "wrongToken",
wantCode: http.StatusBadRequest,
},
{
name: "Empty name",
userName: "",
userEmail: validEmail,
userPassword: validPassword,
csrfToken: validCSRFToken,
wantCode: http.StatusUnprocessableEntity,
wantFormTag: formTag,
},
{
name: "Empty email",
userName: validName,
userEmail: "",
userPassword: validPassword,
csrfToken: validCSRFToken,
wantCode: http.StatusUnprocessableEntity,
wantFormTag: formTag,
},
{
name: "Empty password",
userName: validName,
userEmail: validEmail,
userPassword: "",
csrfToken: validCSRFToken,
wantCode: http.StatusUnprocessableEntity,
wantFormTag: formTag,
},
{
name: "Invalid email",
userName: validName,
userEmail: "bob@example.",
userPassword: validPassword,
csrfToken: validCSRFToken,
wantCode: http.StatusUnprocessableEntity,
wantFormTag: formTag,
},
{
name: "Short password",
userName: validName,
userEmail: validEmail,
userPassword: "pa$$",
csrfToken: validCSRFToken,
wantCode: http.StatusUnprocessableEntity,
wantFormTag: formTag,
},
{
name: "Duplicate email",
userName: validName,
userEmail: "dupe@example.com",
userPassword: validPassword,
csrfToken: validCSRFToken,
wantCode: http.StatusUnprocessableEntity,
wantFormTag: formTag,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
form := url.Values{}
form.Add("name", tt.userName)
form.Add("email", tt.userEmail)
form.Add("password", tt.userPassword)
form.Add("csrf_token", tt.csrfToken)
code, _, body := ts.postForm(t, "/user/signup", form)
assert.Equal(t, code, tt.wantCode)
if tt.wantFormTag != "" {
assert.StringContains(t, body, tt.wantFormTag)
}
})
}
}
5 - Alex Edwards Let's Go часть 5
Добавьте страницу «О программе» в приложение
Создайте обработчик about
File: cmd/web/handlers.go
package main
...
func (app *application) about(w http.ResponseWriter, r *http.Request) {
data := app.newTemplateData(r)
data.About = "Это приложение создано для того,чтобы тренироваться. <br />И я думаю, здесь потом написать markdown текст."
app.render(w, http.StatusOK, "about.tmpl", data)
}
Создайте маршрут GET /about
File: cmd/web/routes.go
router.Handler(http.MethodGet, "/about", dynamic.ThenFunc(app.about))
Создайте шаблон about .tmpl
File: ui/html/pages/about.tmpl
{{define "title"}}О приложении{{end}}
{{define "main"}}
{{with .About}}
<div class='snippet'>
<p>{{.}}</p>
</div>
{{end}}
{{end}}
Создать навигацию в nav.tmpl
<a href='/about'>About</a>
Создать новый флаг -debug
Создаем описание флага
Три точки правки для добавления флага:
type application struct {
debug bool // 1. Добавить в структуру приложения
errorLog *log.Logger
infoLog *log.Logger
snippets models.SnippetModelInterface
users models.UserModelInterface
templateCache map[string]*template.Template
formDecoder *form.Decoder
sessionManager *scs.SessionManager
}
...
debug := flag.Bool("debug", false, "Включить режим отладки, по умолчанию false") //2. добавить описание флага до Parse
...
app := &application{
debug: *debug, // Инициализация флага в экземпляре приложения
errorLog: errorLog,
infoLog: infoLog,
snippets: &models.SnippetModel{DB: db},
users: &models.UserModel{DB: db},
templateCache: templateCache,
formDecoder: formDecoder,
sessionManager: sessionManager,
}
Настройка cmd/web/helpers.go для serverError()
Добавим условие для проверки флага app.debug
.
func (app *application) serverError(w http.ResponseWriter, err error) {
trace := fmt.Sprintf("%s\n%s", err.Error(), debug.Stack())
app.errorLog.Output(2, trace)
if app.debug { //Добавил условие проверки флага
http.Error(w, trace, http.StatusInternalServerError)
return
}
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
Продолжаем тестирование. Тестирование обработчика snippetCreate
Создадим сквозной тест для маршрута GET /snippet/create
.
- Пользователи, не прошедшие аутентификацию, перенаправляются на форму входа.
- Пользователям, прошедшим проверку подлинности, отображается форма для создания нового сниппета.
Создадим заготовку для теста handlers_test.go
func TestSnippetCreate(t *testing.T) {
// Создаем новый экземпляр нашей структуры приложения, который использует мокированные зависимости.
app := newTestApplication(t)
// Устанавливаем новый тестовый сервер для выполнения сквозных тестов.
ts := newTestServer(t, app.routes())
defer ts.Close()
}
Создадим подтест с проверкой доступа неаутентифицированного пользователя
func TestSnippetCreate(t *testing.T) {
// Создаем новый экземпляр нашей структуры приложения, который использует мокированные зависимости.
app := newTestApplication(t)
// Устанавливаем новый тестовый сервер для выполнения сквозных тестов.
ts := newTestServer(t, app.routes())
defer ts.Close()
t.Run("Unauthenticated", func(t *testing.T) {//название теста и безымянная функция
code, headers, _ := ts.get(t, "/snippet/create") //запрос к серверу с моками по адресу проверки
//code и headers - запишем ответы от сервера кода и заголовка
assert.Equal(t, code, http.StatusSeeOther) //проверим код
assert.Equal(t, headers.Get("Location"), "/user/login") //проверим заголовок Location
//location показывает адрес текущей страницы
})
}
Страница личного кабинета
Добавить новую страницу «Ваша учетная запись» в приложение. Он должен быть сопоставлен с новым маршрутом GET /account/view
и отображать имя, адрес электронной почты и дату регистрации для текущего аутентифицированного пользователя
Создадим в файле internal/models/users.go функция UserModel.Get()
- Добавим метод Get в интерфейс UserModelInterface
type UserModelInterface interface {
Insert(name, email, password string) error
Authenticate(email, password string) (int, error)
Exists(id int) (bool, error)
Get(id int) (*User, error) //добавил метод Get
}
- Создадим метод Get
func (m *UserModel) Get(id int) (*User, error) {
var user User
stmt := `SELECT id, name, email, created FROM users WHERE id = ?`
err := m.DB.QueryRow(stmt, id).Scan(&user.ID, &user.Name, &user.Email, &user.Created)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNoRecord
} else {
return nil, err
}
}
return &user, err
}
routes.go
router.Handler(http.MethodGet, "/account/view", protected.ThenFunc(app.accountView))
handlers.go
Добавим функцию accountView
func (app *application) accountView(w http.ResponseWriter, r *http.Request) {
userID := app.sessionManager.GetInt(r.Context(), "authenticatedUserID")
user, err := app.users.Get(userID)
if err != nil {
if errors.Is(err, models.ErrNoRecord) {
http.Redirect(w, r, "/user/login", http.StatusSeeOther)
} else {
app.serverError(w, err)
}
return
}
fmt.Fprintf(w, "%+v", user)
}
templates.go
добавим поле User
type templateData struct {
CurrentYear int
Snippet *models.Snippet
Snippets []*models.Snippet
Form any
Flash string
IsAuthenticated bool
CSRFToken string
User *models.User //добавили User
}
ui/html/pages/account.tmpl
Создаем шаблон
{{define "title"}}Your Account{{end}}
{{define "main"}}
<h2>Your Account</h2>
{{with .User}}
<table>
<tr>
<th>Name</th>
<td>{{.Name}}</td>
</tr>
<tr>
<th>Email</th>
<td>{{.Email}}</td>
</tr>
<tr>
<th>Joined</th>
<td>{{humanDate .Created}}</td>
</tr>
</table>
{{end }}
{{end}}
handkers.go
func (app *application) accountView(w http.ResponseWriter, r *http.Request) {
userID := app.sessionManager.GetInt(r.Context(), "authenticatedUserID")
user, err := app.users.Get(userID)
if err != nil {
if errors.Is(err, models.ErrNoRecord) {
http.Redirect(w, r, "/user/login", http.StatusSeeOther)
} else {
app.serverError(w, err)
}
return
}
data := app.newTemplateData(r)
data.User = user
app.render(w, http.StatusOK, "account.tmpl", data)
}
Настроим меню приложения
{{define "nav"}}
<nav>
<div>
<a href='/'>Home</a>
<a href='/about'>About</a>
{{if .IsAuthenticated}}
<a href='/snippet/create'>Create snippet</a>
{{end}}
</div>
<div>
{{if .IsAuthenticated}}
<!-- Add the view account link for authenticated users -->
<a href='/account/view'>Account</a>
<form action='/user/logout' method='POST'>
<input type='hidden' name='csrf_token' value='{{.CSRFToken}}'>
<button>Logout</button>
</form>
{{else}}
<a href='/user/signup'>Signup</a>
<a href='/user/login'>Login</a>
{{end}}
</div>
</nav>
{{end}}
Перенаправление пользователя после входа в систему
Обновить middleware requireAuthentication()
чтобы перед тем, как неаутентифицированный пользователь будет перенаправлен на страницу входа, URL-путь, который он пытается посетить, был добавлен в данные его сеанса.
func (app *application) requireAuthentication(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !app.isAuthenticated(r) {
// Add the path that the user is trying to access to their session
// data.
app.sessionManager.Put(r.Context(), "redirectPathAfterLogin", r.URL.Path)
http.Redirect(w, r, "/user/login", http.StatusSeeOther)
return
}
w.Header().Add("Cache-Control", "no-store")
next.ServeHTTP(w, r)
})
}
Обновите обработчик userLogin
чтобы проверить путь URL-адреса пользователя в сеансе после успешного входа в систему. Если он существует, удалите его из данных сеанса и перенаправьте пользователя на этот URL-путь. В противном случае по умолчанию пользователь будет перенаправлен в /snippet/create.
path := app.sessionManager.PopString(r.Context(), "redirectPathAfterLogin")
if path != "" {
http.Redirect(w, r, path, http.StatusSeeOther)
return
}
http.Redirect(w, r, "/snippet/create", http.StatusSeeOther)
Восстановление пароля
Создадим 2 новых routes и handlers
GET /account/password/update — accountPasswordUpdate
POST /account/password/update — accountPasswordUpdatePost
handlers.go
func (app *application) accountPasswordUpdate(w http.ResponseWriter, r *http.Request) {
data := app.newTemplateData(r)
data.Form = userPasswordUpdate{}
app.render(w, http.StatusOK, "password.tmpl", data)
}
func (app *application) accountPasswordUpdatePost(w http.ResponseWriter, r *http.Request) {
var form userPasswordUpdate
err := app.decodePostForm(r, &form)
if err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
routes.go
router.Handler(http.MethodGet, "/account/password/update", protected.ThenFunc(app.accountPasswordUpdate))
router.Handler(http.MethodPost, "/account/password/update", protected.ThenFunc(app.accountPasswordUpdatePost))
ui/html/pages/password.tmpl
{{define "title"}}Change Password{{end}}
{{define "main"}}
<h2>Change Password</h2>
<form action='/account/password/update' method='POST' novalidate>
<input type='hidden' name='csrf_token' value='{{.CSRFToken}}'>
<div>
<label>Current password:</label>
{{with .Form.FieldErrors.currentPassword}}
<label class='error'>{{.}}</label>
{{end}}
<input type='password' name='currentPassword'>
</div>
<div>
<label>New password:</label>
{{with .Form.FieldErrors.newPassword}}
<label class='error'>{{.}}</label>
{{end}}
<input type='password' name='newPassword'>
</div>
<div>
<label>Confirm new password:</label>
{{with .Form.FieldErrors.newPasswordConfirmation}}
<label class='error'>{{.}}</label>
{{end}}
<input type='password' name='newPasswordConfirmation'>
</div>
<div>
<input type='submit' value='Change password'>
</div>
</form>
{{end}}
handlers.go
Обработчик Get
type accountPasswordUpdateForm struct {
CurrentPassword string `form:"currentPassword"`
NewPassword string `form:"newPassword"`
NewPasswordConfirmation string `form:"newPasswordConfirmation"`
validator.Validator `form:"-"`
}
func (app *application) accountPasswordUpdate(w http.ResponseWriter, r *http.Request) {
data := app.newTemplateData(r)
data.Form = accountPasswordUpdateForm{}
app.render(w, http.StatusOK, "password.tmpl", data)
}
обработчик Post
func (app *application) accountPasswordUpdatePost(w http.ResponseWriter, r *http.Request) {
var form accountPasswordUpdateForm
err := app.decodePostForm(r, &form)
if err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
form.CheckField(validator.NotBlank(form.CurrentPassword), "currentPassword", "This field cannot be blank")
form.CheckField(validator.NotBlank(form.NewPassword), "newPassword", "This field cannot be blank")
form.CheckField(validator.MinChars(form.NewPassword, 8), "newPassword", "This field must be at least 8 characters long")
form.CheckField(validator.NotBlank(form.NewPasswordConfirmation), "newPasswordConfirmation", "This field cannot be blank")
form.CheckField(form.NewPassword == form.NewPasswordConfirmation, "newPasswordConfirmation", "Passwords do not match")
if !form.Valid() {
data := app.newTemplateData(r)
data.Form = form
app.render(w, http.StatusUnprocessableEntity, "password.tmpl", data)
return
}
}
users.go
- добавим интерфейс PasswordUpdate
type UserModelInterface interface {
Insert(name, email, password string) error
Authenticate(email, password string) (int, error)
Exists(id int) (bool, error)
Get(id int) (*User, error)
PasswordUpdate(id int, currentPassword, newPassword string) error
}
- добавим метод PasswordUpdate
func (m *UserModel) PasswordUpdate(id int, currentPassword, newPassword string) error {
var currentHashedPassword []byte
stmt := "SELECT hashed_password FROM users WHERE id = ?"
err := m.DB.QueryRow(stmt, id).Scan(¤tHashedPassword)
if err != nil {
return err
}
err = bcrypt.CompareHashAndPassword(currentHashedPassword, []byte(currentPassword))
if err != nil {
if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
return ErrInvalidCredentials
} else {
return err
}
}
newHashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), 12)
if err != nil {
return err
}
stmt = "UPDATE users SET hashed_password = ? WHERE id = ?"
_, err = m.DB.Exec(stmt, string(newHashedPassword), id)
return err
}
handlers.go
окончательный вариантфункции accountPasswordUpdatePost
func (app *application) accountPasswordUpdatePost(w http.ResponseWriter, r *http.Request) {
var form accountPasswordUpdateForm
err := app.decodePostForm(r, &form)
if err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
form.CheckField(validator.NotBlank(form.CurrentPassword), "currentPassword", "This field cannot be blank")
form.CheckField(validator.NotBlank(form.NewPassword), "newPassword", "This field cannot be blank")
form.CheckField(validator.MinChars(form.NewPassword, 8), "newPassword", "This field must be at least 8 characters long")
form.CheckField(validator.NotBlank(form.NewPasswordConfirmation), "newPasswordConfirmation", "This field cannot be blank")
form.CheckField(form.NewPassword == form.NewPasswordConfirmation, "newPasswordConfirmation", "Passwords do not match")
if !form.Valid() {
data := app.newTemplateData(r)
data.Form = form
app.render(w, http.StatusUnprocessableEntity, "password.tmpl", data)
return
}
userID := app.sessionManager.GetInt(r.Context(), "authenticatedUserID")
err = app.users.PasswordUpdate(userID, form.CurrentPassword, form.NewPassword)
if err != nil {
if errors.Is(err, models.ErrInvalidCredentials) {
form.AddFieldError("currentPassword", "Current password is incorrect")
data := app.newTemplateData(r)
data.Form = form
app.render(w, http.StatusUnprocessableEntity, "password.tmpl", data)
} else if err != nil {
app.serverError(w, err)
}
return
}
app.sessionManager.Put(r.Context(), "flash", "Your password has been updated!")
http.Redirect(w, r, "/account/view", http.StatusSeeOther)
}
account.tmpl
поставим ссылку на изменение пароля
{{define "title"}}Your Account{{end}}
{{define "main"}}
<h2>Your Account</h2>
{{with .User}}
<table>
<tr>
<th>Name</th>
<td>{{.Name}}</td>
</tr>
<tr>
<th>Email</th>
<td>{{.Email}}</td>
</tr>
<tr>
<th>Joined</th>
<td>{{humanDate .Created}}</td>
</tr>
<tr>
<!-- Add a link to the change password form -->
<th>Password</th>
<td><a href="/account/password/update">Change password</a></td>
</tr>
</table>
{{end }}
{{end}}