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

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