Это многостраничный печатный вид этого раздела. Нажмите что бы печатать.

Вернуться к обычному просмотру страницы.

Изучение языка Go

Различные учебники и туториалс по языку Go

1 - Alex Edwards Let's Go часть 1

edwardsLetsGo учебный материал по написанию приложения на Go с авторизацией пользователя. Краткий конспект с основными мыслями (главы с 1 по 8)

Основы 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) 
}

Типы адресов в обработчике

  1. /snippet/view — фиксированный адрес
  2. /snippet/view/ — не фиксированный адрес и под ним обрабатывается поддерево
  3. Для корня проверка если указано что-то отличающееся от /
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
    }

Структура проекта

  1. Main оставить в одном файле
  2. 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)
    }

Шаблоны отдельно

Делаем составные шаблоны

  1. Базовый шаблон 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):

  1. func (*ServeMux) Handle – это метод структуры ServeMux.
  2. 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/").

🔹 Когда используется?

  1. Когда нужно вручную определить обработчик для запроса (например, для кастомной логики маршрутизации).
  2. В middleware, чтобы проверить, какой обработчик будет вызван для запроса.
  3. Для логирования или отладки (чтобы узнать, какой 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: /

🔹 Как работает внутри?

  1. Проверяет Host (если задан в ServeMux).
  2. Ищет точное совпадение пути (например, /users).
  3. Ищет самое длинное совпадение по префиксу (если путь зарегистрирован с /users/, то запрос /users/123 тоже сработает).
  4. Если ничего не найдено – возвращает 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. Она отвечает за:

  1. Поиск подходящего обработчика (на основе URL пути)
  2. Вызов этого обработчика (или возврат 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]

Пошаговый алгоритм:

  1. Проверяет Host (если в ServeMux есть пути с указанием хоста, например "example.com").
  2. Ищет точное совпадение пути (например, /users).
  3. Ищет самое длинное совпадение по префиксу (если путь зарегистрирован как /users/, то /users/123 тоже сработает).
  4. Если обработчик не найден — вызывает 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"))
}

🛠 Что происходит при запросе?

  1. Запрос GET /

    • ServeHTTP находит точное совпадение с / → вызывает homeHandler.
  2. Запрос GET /about

    • Совпадение с /about → вызывает aboutHandler.
  3. Запрос 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)
}

🔥 Ключевые особенности

  1. Иерархия путей:

    • /images/ совпадёт с /images/logo.png.
    • /images (без слеша) — только с точным совпадением.
  2. Host-specific пути:

    mux.Handle("example.com/", handler) // Сработает только для example.com
    
  3. Дефолтный обработчик 404:
    Можно заменить через mux.NotFound = customHandler.


🎯 Вывод

  • ServeHTTPядро роутинга в net/http.
  • Автоматически обрабатывает префиксы, хосты и 404.
  • Может быть переопределён для кастомной логики.

Что нужно сделать,чтобы получить Handle

  1. Создать структуру type home struct {} - или любой объект
  2. Создать функцию обработчик с вызовом ServeHTTP
func (h *home) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Write([]byte("This is my home page")) }
  1. Используем в коде
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()

Логирование

  1. Стандартный лог выводит в терминал
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

Логирование работы сервера

  1. Создать структуру для параметров сервера 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"
)

Для работы динамических шаблонов нужно:

  1. Создать шаблоны
  2. Выстроить иерархию внутри них (каждый блок и раздел лучше делать в отдельном шаблоне и соединять в один, например base)
  3. Создать массив строк с наименованием шаблонов и путей к ним
files := []string{ 
	"./ui/html/base.tmpl", 
	"./ui/html/partials/nav.tmpl", 
	"./ui/html/pages/view.tmpl", 
}
  1. Пропарсить шаблоны
ts, err := template.ParseFiles(files...) 
if err != nil {
	app.serverError(w, err) 
	return 
}
  1. Собрать 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 
}

Эта структура будет работать с базой данных и шаблонами

  1. . — весь контекст переданный в этот блок
  2. Передать структуру в шаблон: err = ts.ExecuteTemplate(w, "base", snippet)
  3. Использовать поля структуры в шаблоне {{.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}}
  1. Создать структуру
type templateData struct { 
	Snippet *models.Snippet 
}
  1. Выполнить все действия, описанные выше по парсингу шаблонов
  2. Создать экземпляр структуры данных
data := &templateData{ 
	Snippet: snippet, 
}
  1. Передать данные в генератор html кода
err = ts.ExecuteTemplate(w, "base", data) 
	if err != nil { 
		app.serverError(w, err) 
	}
  1. В шаблоне значения полей тогда будут вызываться с указанием полного пути поля: {{.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

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
}

Как избежать ошибок при рендеринге динамического кода

  1. Делать рендер кода
  2. Записывать результат в буфер
  3. Проверять на отсутствие ошибок
  4. делать выдачу в 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)
}

Динамические данные в шаблонах

  1. Создадим специальную структуру для передачи динамических данных в шаблон
type templateData struct {
    CurrentYear int
    Snippet     *models.Snippet
    Snippets    []*models.Snippet
}
  1. Создадим функцию helpers для возврата указателя на структуру с данными и расчетом динамических данных
func (app *application) newTemplateData(r *http.Request) *templateData {//получаем на вход указатель с данными запроса 
    return &templateData{ //и возвращаем указатель с данными ответа
        CurrentYear: time.Now().Year(),// по дороге посчитаем какой текущий год
    }
}
  1. Делаем вызов функции 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)
  1. Повторяем такие вызовы для всех обработчиков
  2. В шаблонах обрабатываем данные из структуры templateData как {{.Snippets}} или {{.Snippet}} и текущий год как {{.CurrentYear}}

Пользовательские функции в шаблонах

  1. Создаем функцию, которую планируем добавить в шаблон
func humanDate(t time.Time) string {
    return t.Format("02 Jan 2006 at 15:04")
}
  1. Создаем объект template.FuncMap и в нем объявляем свою функцию (например humanDate()). Фактически в переменной function создается карта: ключ - значение, где значение, это название функции, которое мы передаем в шаблон.
var functions = template.FuncMap{
    "humanDate": humanDate,
}
  1. Метод должен быть зарегистрирован до парсинга шаблона
    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
    }
  1. Используем функцию в шаблоне как и другие функции
<td>{{humanDate .Created}}</td>
или
<time>Created: {{.Created | humanDate}}</time>
или
<time>{{.Created | humanDate | printf "Created: %s"}}</time>

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)
    })
}

Создадим обработчик заголовков для всех страниц

Будем возвращать шаблоны 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
  1. Создадим функцию для создания заголовков
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)
    })
}
  1. Создадим функцию обработчик маршрутов
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)
}
  1. После обработки маршрута для каждой страницы будут создан заголовок

Функция логирования запросов

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) // Обработка ошибки
    }
}

Что происходит:

  1. Если вызвана panic("oops! something went wrong"), recover() вернёт строку "oops! something went wrong".
  2. fmt.Errorf() преобразует это значение в error.
  3. Ошибка передаётся в метод app.serverError() для логирования/отправки клиенту.

Зачем это нужно:

  • Чтобы избежать аварийного завершения программы при панике.
  • Логировать ошибки и отправлять клиенту понятный ответ (например, 500 Internal Server Error).

Важно:

  • recover() работает только внутри defer.
  • Всегда проверяйте, что recover() вернул не nil.

Заключительный вариант вызова middleware

    return app.recoverPanic(app.logRequest(secureHeaders(mux)))

Делается обработка для всех запускаемых горутин.

Автор рекомендует пакет justinas/alice

Для удобного и читабельного управления middleware можно использовать пакет justinas/alice

Особенности:

  1. вместо return myMiddleware1(myMiddleware2(myMiddleware3(myHandler))) будем писать return alice.New(myMiddleware1, myMiddleware2, myMiddleware3).Then(myHandler)
  2. есть методы 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.

сделав анализ современного развития этих пакетов можно прийти к выводу:

Чистые 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

Обработка форм

  1. Выполнить запрос GET к серверу для запроса формы
  2. Заполнение формы и отправка на сервер результата методом POST
  3. Обработка формы и валидация handler-ом snippetCreatePost
  4. После проверки и добавление редирект на /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.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
    }

Отображение ошибок на форме

  1. В обработчик шаблонов templates.go добавим поле Form
type templateData struct {
    CurrentYear int
    Snippet     *models.Snippet
    Snippets    []*models.Snippet
    Form        any
}

поле Form необходимо для отправки результатов валидации формы

  1. В handlers.go добавим map с результатами валидации в структуру snippetCreateForm
type snippetCreateForm struct {
    Title       string
    Content     string
    Expires     int
    FieldErrors map[string]string
}
  1. Заполним структуру значениями
    form := snippetCreateForm{
        Title:       r.PostForm.Get("title"),
        Content:     r.PostForm.Get("content"),
        Expires:     expires,
        FieldErrors: map[string]string{},
    }
  1. Выполним валидацию
  2. Если есть ошибки то возвращаем ответ с ошибками
    if len(form.FieldErrors) > 0 {
        data := app.newTemplateData(r)
        data.Form = form
        app.render(w, http.StatusUnprocessableEntity, "create.tmpl", data)
        return
    }

т.е. вызываем эту же форму, только с заполненным map с ошибками, но если ошибок нет, то этот шаг не выполнится и пойдет обращение к базе данных

Изменяем шаблон для отображения ошибок

  1. Обновим create.tmpl шаблон формы
  2. Для повторного отображения введенных значений, используем значения поля Form {{.Form.Title}} и {{.Form.Content}}
  3. Для отображения ошибок валидации используем значения map: {{.Form.FieldErrors.title}} и другие
  4. Шаблон стал теперь таким
{{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}}
  1. В обработчик 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
  1. Убрали map и поставили validator.Validator
type snippetCreateForm struct {
    Title               string 
    Content             string 
    Expires             int    
    validator.Validator
}

основное преимущество validator.Validator в том, что он содержит все поля map и методы обработки

  1. При заполнении структуры Form, теперь не нужно заполонять map с ошибками
    form := snippetCreateForm{
        Title:   r.PostForm.Get("title"),
        Content: r.PostForm.Get("content"),
        Expires: expires,
        // Remove the FieldErrors assignment from here.
    }
  1. Вызов проверок валидации теперь доступны прямо на переменной 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")
  1. Проверяем валидность формы
    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

edwardsLetsGo учебный материал по написанию приложения на Go с авторизацией пользователя. Краткий конспект с основными мыслями (главы с 9 по 12)

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. Какой выбрать?

  1. Для новых проектовalexedwards/scs (безопасность, контекст, активная разработка).
  2. Для Gingin-contrib/sessions.
  3. Для микрофреймворков (Chi, Echo)go-chi/session или scs.
  4. Legacy-проекты на Gorillagorilla/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

  1. Добавим импорты
    "github.com/alexedwards/scs/mysqlstore" // New import
    "github.com/alexedwards/scs/v2"         // New import
  1. Добавим поле сессии в структуру приложения
type application struct {
    errorLog       *log.Logger
    infoLog        *log.Logger
    snippets       *models.SnippetModel
    templateCache  map[string]*template.Template
    formDecoder    *form.Decoder
    sessionManager *scs.SessionManager
}
  1. Настройка менеджера сессий
    sessionManager := scs.New() // Используем scs.New() для инициализации нового менеджера сессий. 
    sessionManager.Store = mysqlstore.New(db) //настраиваем его на использование нашей базы данных MySQL в качестве хранилища сессий 
    sessionManager.Lifetime = 12 * time.Hour //устанавливаем время жизни 12 часов 
  1. Добавить менеджер сессий в структуру приложения
    app := &application{
        errorLog:       errorLog,
        infoLog:        infoLog,
        snippets:       &models.SnippetModel{DB: db},
        templateCache:  templateCache,
        formDecoder:    formDecoder,
        sessionManager: sessionManager,
    }

Обернуть обработчики запросов

Чтобы сеансы работали, нам также необходимо обернуть маршруты наших приложений в промежуточное ПО, предоставляемое методом SessionManager.LoadAndSave(). Это промежуточное ПО автоматически загружает и сохраняет данные сеанса при каждом HTTP-запросе и ответе.

/static/*filepath - оборачивать не будем

Создадим ноувую цепочку для динамических данных сайта

Корректируем файл: routes.go

  1. Создаем переменную для учета сессий
    dynamic := alice.New(app.sessionManager.LoadAndSave)
  1. Обертываем обработчики
    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))
  1. Остальное оставляем без изменений
    standard := alice.New(app.recoverPanic, app.logRequest, secureHeaders)
    return standard.Then(router)

Работа с данными сессии

Добавление сообщения об успешном добавлении сниппета

  1. Внесем изменения в файл 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”) является ключом для конкретного сообщения, которое мы добавляем в данные сессии. Впоследствии мы получим сообщение из данных сеанса также с помощью этого ключа.

Если для текущего пользователя нет существующего сеанса (или срок его сеанса истек), то новый пустой сеанс для него будет автоматически создан промежуточным программным обеспечением сеанса.

  1. Далее мы хотим, чтобы наш обработчик 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)
}
  1. Добавить поле 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.
}
  1. Внесем изменения в шаблон 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}}
  1. Модернизируем функцию 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 работает в два этапа:

  1. сначала он генерирует 2048-битную пару ключей RSA, которая является криптографически защищенным открытым ключом и закрытым ключом.
  2. Затем он сохраняет закрытый ключ в файле key.pem и генерирует самоподписанный сертификат TLS для хоста localhost, содержащий открытый ключ, который он хранит в файле cert.pem.

Как закрытый ключ, так и сертификат закодированы в PEM, что является стандартным форматом, используемым в большинстве реализаций TLS.

Запуск HTTPS сервера

  1. Заменить в файле main.go srv.ListenAndServe() на srv.ListenAndServeTLS()
    err = srv.ListenAndServeTLS("./tls/cert.pem", "./tls/key.pem")
    errorLog.Fatal(err)
  1. Запустить приложение
  2. Открыть сайт https://localhost:4000/ по протоколу https

Занесите каталог tls в .gitignore

нежелательно пушить сертификаты в репозиторий

Настройка параметров HTTPS

Go имеет хорошие настройки по умолчанию для своего HTTPS-сервера, но можно оптимизировать и настроить поведение сервера.

tls.CurveP256 and tls.X25519 - рекомендуется использовать эти протоколы

Добавим настройки в файл main.go

  1. Добавим импорт
import (
    "crypto/tls" // New import
  1. Добавим tlsConfig
    tlsConfig := &tls.Config{
        CurvePreferences: []tls.CurveID{tls.X25519, tls.CurveP256},
    }
  1. Добавим 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,
    ...
}

Аутентификация пользователей

  1. Пользователь должен зарегистрироваться, посетив форму по адресу /user/signup и введя свое имя, адрес электронной почты и пароль. Мы сохраним эту информацию в новой таблице базы данных пользователей.
  2. Пользователь войдет в систему, посетив форму по адресу /user/login и введя свой адрес электронной почты и пароль.
  3. Затем мы проверим базу данных, чтобы увидеть, совпадают ли введенные ими адрес электронной почты и пароль с одним из пользователей в таблице пользователей. Если совпадение найдено, пользователь успешно прошел проверку подлинности, и мы добавляем соответствующее значение id для пользователя в данные его сеанса с помощью ключа “authenticatedUserID”.
  4. Когда мы получаем какие-либо последующие запросы, мы можем проверить данные сеанса пользователя на наличие значения “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

  1. Хорошей практикой — является хранение одностороннего хеша пароля, полученного с помощью ресурсоемкой функции извлечения ключа, такой как Argon2, scrypt или bcrypt. Go имеет реализации всех 3 алгоритмов в пакете golang.org/x/crypto.
  2. Тем не менее, плюсом реализации bcrypt является то, что она включает в себя вспомогательные функции, специально разработанные для хеширования и проверки паролей, и именно их мы будем использовать здесь.
  3. Установка bcrypt
go get golang.org/x/crypto/bcrypt@latest
  1. Создать хэш пароля
hash, err := bcrypt.GenerateFromPassword([]byte("my plain text password"), 12)
  1. Эта функция вернет хеш длиной 60 символов, который выглядит примерно так:
$2a$12$NuTjWXm3KKntReFwyBVHyuf/to.HEwTy.eS206TNfkGfr6HzGJSWG

Второй параметр мы передаем в bcrypt.GenerateFromPassword() указывает стоимость, которая представлена целым числом от 4 до 31. 12 означает, что для генерации хэша пароля будет использовано 4096 (2^12) итераций bcrypt.

  1. Проверка пароля: функция bcrypt.CompareHashAndPassword()
hash := []byte("$2a$12$NuTjWXm3KKntReFwyBVHyuf/to.HEwTy.eS206TNfkGfr6GzGJSWG") 
err := bcrypt.CompareHashAndPassword(hash, []byte("my plain text password"))

Хранение информации о пользователе

Следующим этапом нашей сборки является обновление метода UserModel.Insert() таким образом, чтобы он создал новую запись в нашей таблице пользователей, содержащую проверенное имя, адрес электронной почты и хэшированный пароль.

  1. Для обработки ошибок в базе данных и сохранения хэша понадобятся библиотеки
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
)
  1. Файл 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
}
  1. В основном приложении 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)
}

Проверка пользователя

  1. Проверить есть такой email — если нет, то вернуть ошибку
  2. Сравнить хэш пароля — если не совпадает, вернуть ошибку
  3. Если все ОК — вернем 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
}
  1. После подтверждения пользователя создадим текущую сессию с пользователем. Внесем изменения в 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

  1. Нужно удалить 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

В этом разделе включаются те полезности, которые доступны только аутентифицированным пользователям

  1. они могут создавать сниппеты
  2. в меню появляются новые пункты и убираются ненужные

Содержимое панели навигации меняется в зависимости от того, аутентифицирован ли пользователь (вошел в систему) или нет. В частности:

  • аутентифицированные пользователи должны видеть ссылки на «Главную», «Создать сниппет» и «Выйти».
  • Неаутентифицированные пользователи должны видеть ссылки на «Главную», «Регистрация» и «Вход».

Добавим вспомогательную функцию 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)

Давайте разберем это построчно.

  1. Сначала мы используем метод r.Context(), чтобы получить существующий контекст из запроса и присваиваем его переменной ctx.
  2. Затем мы используем метод context.WithValue(), чтобы создать новую копию существующего контекста, содержащую ключ “isAuthenticated” и значение true.
  3. Наконец, мы используем метод r.WithContext(), чтобы создать копию запроса с нашим новым контекстом.

Также стоит отметить, что для ясности я сделал этот фрагмент кода немного более многословным, чем это необходимо. Обычно его пишут так:

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(), который:

  1. Извлекает ID пользователя из данных сессии.
  2. Проверяет базу данных, чтобы узнать, соответствует ли ID действительному пользователю, используя метод UserModel.Exists().
  3. Обновляет контекст запроса, добавляя ключ 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

edwardsLetsGo учебный материал по написанию приложения на Go с авторизацией пользователя. Краткий конспект с основными мыслями (глава с 13)

Дополнительные возможности 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.

Вот несколько важных деталей, которые нужно учитывать:

  1. Расположение директивы
    Директива go:embed должна находиться непосредственно перед переменной, в которую нужно встроить файлы.

  2. Формат директивы
    Общий формат: go:embed <пути>. Можно указывать несколько путей в одной директиве (как в примере выше). Пути должны быть относительными к файлу с исходным кодом, содержащему директиву. В нашем случае go:embed "static" "html" встраивает папки ui/static и ui/html.

  3. Область применения
    Директива go:embed работает только с глобальными переменными на уровне пакета, но не внутри функций или методов. При попытке использовать её внутри функции возникнет ошибка компиляции: "go:embed cannot apply to var inside func".

  4. Ограничения путей

    • Пути не могут содержать . или ...
    • Они не должны начинаться или заканчиваться на /.
    • Это ограничивает встраивание файлами, находящимися в той же директории (или её поддиректориях), что и исходный файл с директивой.
  5. Встраивание директорий
    Если путь ведёт к папке, все её файлы встраиваются рекурсивно, кроме файлов, имена которых начинаются с . или _. Чтобы включить такие файлы, используйте префикс all:, например:

    //go:embed "all:static"
    
  6. Разделитель путей
    Всегда используйте прямой слеш (/), даже в Windows.

  7. Корневая директория 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

edwardsLetsGo учебный материал по написанию приложения на Go с авторизацией пользователя. Краткий конспект с основными мыслями (глава 14) Тестирование

Тестирование

И вот мы наконец-то подошли к теме тестирования.

Как и структурирование и организация кода приложения, нет единственного “правильного” способа структурировать и организовать тесты в 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().

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")
}

Сохраните файл, затем попробуйте запустить 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 нашей структуры приложения, но не все остальные поля. Причина этого в том, что логгеры необходимы middleware logRequest и 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

Кроме того, вы можете очистить кэшированные результаты для всех тестов с помощью команды 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”) — это техника, используемая в тестировании программного обеспечения, которая позволяет создавать поддельные (мок) версии зависимостей, с которыми взаимодействует тестируемый компонент. Это делается для того, чтобы изолировать тестируемый код от реальных зависимостей, что позволяет:

  1. Избежать побочных эффектов: Использование реальных зависимостей может привести к нежелательным изменениям состояния (например, запись в базу данных или отправка сетевых запросов). Моки позволяют избежать этих побочных эффектов.

  2. Упростить тестирование: Моки могут быть настроены для возврата предопределенных значений, что упрощает тестирование различных сценариев, включая ошибки и исключительные ситуации.

  3. Ускорить тесты: Тесты с моками обычно выполняются быстрее, так как они не зависят от внешних систем, таких как базы данных или API.

  4. Улучшить контроль: Моки позволяют контролировать и проверять, как тестируемый код взаимодействует с зависимостями, что помогает выявить ошибки в логике.

В 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-токен и куки. Чтобы обойти это, нам нужно эмулировать рабочий процесс реального пользователя в рамках нашего теста, следующим образом:

  1. Сделать запрос GET /user/signup. Это вернет ответ, который содержит CSRF-куку в заголовках ответа и CSRF-токен для страницы регистрации в теле ответа.
  2. Извлечь CSRF-токен из HTML-тела ответа.
  3. Сделать запрос 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]))
}

Теперь, когда это сделано, давайте вернемся к файлу 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

edwardsLetsGo учебный материал по написанию приложения на Go с авторизацией пользователя. Краткий конспект с основными мыслями (глава 17)

Добавьте страницу «О программе» в приложение

Создайте обработчик 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()

  1. Добавим метод 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
}
  1. Создадим метод 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

  1. добавим интерфейс 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
}
  1. добавим метод 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(&currentHashedPassword)
    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}}