Хендлеры и избавление от глобальных переменных

6 minute read

Перевод статьи "Custom Handlers and Avoiding Globals in Go Web Applications"

Пакет net/http невероятно гибкий, благодаря повсеместному использованию интерфейса http.Handler. Построение приложения на основе интерфейсов позволяет вам проще расширять его, предоставлять различную реализацию и позволит поддерживать совместимость с другими пакетами в дикой природе. Так как дефолтная реализация довольна проста, мы разберемся как построить наш собственный тип хендлера(для избавления от повторяющихся обработок ошибок) и как его расширить для реализации некоторого "контекста" с пулом базы данных, списком шаблонов и всего такого, что позволит нам избавиться от глобальных переменных.

Создаем кастомный тип хендела

net/http предоставляет базовый тип HandlerFunc, по сути, это просто функция func(w http.ResponseWriter, r *http.Request). Это довольно простой для понимания и достаточно распространенный подход, который покрывает большинство простых юзкейсов. Но тут скрывается несколько неприятных проблем:

  • Мы не можем передать дополнительные параметры для http.HandlerFunc.
  • И нам необходимо выполнять однотипную проверку ошибок в каждом хендлере.

Если вы новичек в Go, то вам может показаться, что нет простого и быстрого решения этих проблем, которое еще и будет совместимо с другими HTTP пакетами. Но на самом деле это не так.

Мы создадим собственный тип хендлера, который реализует интерфейс http.Handler(читайте как реализующий метод ServeHTTP(http.ResponseWriter, *http.Request)) и будет совместим с чистым net/http HTTP миделварями(вроде nosurf) и роутерами/фреймворками(например gorilla/mux или Goji).

Для начала, давайте формализируем проблему:

func myHandler(w http.ResponseWriter, r *http.Request) {
    session, err := store.Get(r, "myapp")
    if err != nil {
        http.Error(w, http.StatusText(http.StatusInternalServerError), 
                        http.StatusInternalServerError)
        return 
        // Если забудете про return, то код в хендлере 
        // продолжит выполняться.
    }

    id := // Получаем id из get параметров, 
    // преобразуем с помощью strconv.Atoi, 
    // проверяем ошибки преобразования

    post := Post{ID: id}
    exists, err := db.GetPost(&post)
    if err != nil {
        http.Error(w, http.StatusText(http.StatusInternalServerError), 
                        http.StatusInternalServerError)
        return // Снова повторяем обработку ошибок
    }

    if !exists {
        http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
        return // ... и опять.
    }

    err = renderTemplate(w, "post.tmpl", post)
    if err != nil {
        // Ага, опять тоже самое...
    }
}

Проблема даже не столько в многословности, сколько в трудности отлова багов в таком коде. Если мы не выполним return после ошибочного запроса в базу или после проверки неправильного пароля, то выполнение кода продолжиться и это приведет к непредсказуемым результатам. В лучшем случае мы передадим в шаблон пустую структуру, что немного расстроит пользователя. В худшем - мы пометим запрос как HTTP 401 (неавторизированный), но при этом покажем любому пользователю все, что доступно только для залогиненного.

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

type appHandler func(http.ResponseWriter, *http.Request) (int, error)

// Наш appHandler соответствует http.Handler 
func (fn appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    if status, err := fn(w, r); err != nil {
        // Также, мы можем залогировать ошибку
        // Например: log.Printf("HTTP %d: %v", err)
        switch status {
        // Тут мы можем обрабатывать любые ошибки
        // в том числе и кастомные для определенный кодов.
        case http.StatusNotFound:
            notFound(w, r)
        case http.StatusInternalServerError:
            http.Error(w, http.StatusText(http.StatusInternalServerError), 
                            http.StatusInternalServerError)
        default:
        // Перехватываем остальные ошибки, для которых 
        // нет специальных обработчиков
            http.Error(w, http.StatusText(http.StatusInternalServerError), 
                            http.StatusInternalServerError)
        }
}

func myHandler(w http.ResponseWriter, r *http.Request) (int, error) {
    session, err := store.Get(r, "myapp")
    if err != nil {
        // Так намного лучше
        return http.StatusInternalServerError, err
    }

    post := Post{ID: id}
    exists, err := db.GetPost(&post)
    if err != nil {
        return http.StatusInternalServerError, err
    }

    // Мы можем сократить это. Так как renderTemplate возвращает `error`
    // наш метод ServeHTTP вернет HTTP 500, и не будет пытаться
    // отдать сломанный шаблон с кодом HTTP 200(реализацию renderTemplate
    // посмотрите ниже). Если все рендерится хорошо, то все 
    // пойдет по плану.
    return http.StatusOK, renderTemplate(w, "post.tmpl", data)
}

func main() {
    // Кастуем myHandler к appHandler
    http.Handle("/", appHandler(myHandler))
    http.ListenAndServe(":8000", nil)
}

Конечно, в этом нет ничего нового и фантастического. Andrew Gerrand описывал подобный прием в оф. блоге еще в 2011 году. Наша реализация лишь немного адаптированна для небольшого улучшения обработки ошибок. Я предпочитаю возвращать конкретный тип (int, err), так как я считаю это более идиоматическим. Но вы можете создать свой собственный тип ошибок(только давайте пока оставим этот код достаточно простым).

Расширяем наш кастомный хендлер

Давайте заранее определимся, что глобальные переменные - это зло воплоти. Вы не можете контролировать что и когда модифицирует их, очень проблематично отследить их текущее состояние, кроме того, глобальные переменные могут быть не защищенными он при конкурентном доступе. Конечно, они могут быть удобными при правильном использовании. И многие достойные проекты построены с использованием глобальных переменных(например вот и вот). Тип *sql.DB из пакета database/sql может безопасно использоваться глобально, так как он представляет собой пул и защищен мютексами, мапы (например мапы для шаблонов) можно читать(но не писать) конкурентно, хранилище для сессий также работает аналогично database/sql.

После прилива вдохновения, полученного от статьи @benbjohnson о структурировании Go приложения и дебатов с товарищем Gopher на редите(который использует аналогичный подход), я решил пристальнее присмотреться к моему коду(в котором было несколько глобальных объектов) и порефакторить его, добавив структуру для контекста, которая будет использоваться в моих хендлерах. В большинстве случаев, тип который я описал выше будет работать гладко, но вы можете отхватить проблемы, если ваш контекст будет использоваться за рамками хендлеров.

Вот список глобальных переменных, которое у меня были изначально:

var (
    decoder   *schema.Decoder
    bufpool   *bpool.Bufferpool
    templates map[string]*template.Template
    db        *sqlx.DB
    store     *redistore.RediStore
    mandrill  *gochimp.MandrillAPI
    twitter   *anaconda.TwitterApi
    log       *log.Logger
    conf      *config // конфиг приложения: хосты, порты и т.д.
)

И так, как же нам, используя наш кастомный хендлер, перенести все эти глобальные переменные в контекст, который мы можем передавать в наши хендлеры и использовать в рамках вызова ServeHTTP? Как нам получить доступ к мапе с шаблонами для отрисовки красивой ошибки с кастомным шаблоном? И как нам сделать все это совместимым с типом http.Handler?

package main

import (
    "fmt"
    "log"
    "net/http"

    "html/template"

    "github.com/gorilla/sessions"
    "github.com/jmoiron/sqlx"
    "github.com/zenazn/goji/graceful"
    "github.com/zenazn/goji/web"
)


// appContext содержит наш локальный контекст:
// доступ к базе, хранилище для сессий, список
// шаблонов и все остальное, что необходимо 
// внутри наших хендлеров. Мы создаем все эти
// инстансы в функции main и указываем ссылки на них.
type appContext struct {
    db        *sqlx.DB
    store     *sessions.CookieStore
    templates map[string]*template.Template
    decoder *schema.Decoder
    // ... и все остальные глобальные объекты.
}

// Мы превратили наш первоначальный appHandler 
// в структуру с двумя полями:
// - Функция с типом, аналогичным нашему типу хенделера(только она
 принимает еще и *appContext)
// - Поле типа *appContext
type appHandler struct {
    *appContext
    h func(*appContext, http.ResponseWriter, *http.Request) (int, error)
}

// Наш метод ServeHTTP почти не изменился, за исключением, что 
// в нем теперь есть доступ к полю *appContext(шаблоны, логи и т.д.).
func (ah appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    // Мы можем передавать ah.appContext как параметр в наш хендлер.
    status, err := ah.h(ah.appContext, w, r)
    if err != nil {
        log.Printf("HTTP %d: %q", status, err)
        switch status {
        case http.StatusNotFound:
            http.NotFound(w, r)
            // И если нам необходимо отобразить пользователю
            // вменяемую страницу ошибки, то мы можем использовать
            // наш контекст, например:
            // err := ah.renderTemplate(w, "http_404.tmpl", nil)
        case http.StatusInternalServerError:
            http.Error(w, http.StatusText(status), status)
        default:
            http.Error(w, http.StatusText(status), status)
        }
    }
}

func main() {
    // В нашем примере указан 'nil', но в реальности
    // нам нужно заасайнить множество различных значений 
    // или использовать функцию конструктор 
    // (NewAppContext(conf config) *appContext) для инициализации
    // приложения, например, из конфигурационного файла.
    context := &appContext{db: nil, store: nil} // Для примера

    r := web.New()
    // Тут мы указываем инстанс нашего контекста и наш хендлер.
    r.Get("/", appHandler{context, IndexHandler})

    graceful.ListenAndServe(":8000", r)
}

func IndexHandler(a *appContext, 
                  w http.ResponseWriter,
                  r *http.Request) (int, error) {

    // В нашем хендлере теперь есть доступ к объектам нашего контекста.
    // Например, мы можем сделать запрос в базу: err := a.db.GetPosts()
    fmt.Fprintf(w, "IndexHandler: db is %q and store is %q", a.db, a.store)
    return 200, nil
}

Все осталось хорошо читаемым. Мы хорошо разобрались в системе типов и существующих интерфейсов, поэтому, если возникнет такая необходимость, мы также можем использовать обычные http.HandlerFunc. Также, наши хендлеры достаточно легко обернуть во что-то еще, принимающее http.Handler, поэтому мы можем легко использовать их в работе с Goji или gorilla/mux, нам не прийдется переписывать их полностью. Не забывайте про правильное использование и безопасное использование различных типов и применяйте пакет sync, если это необходимо.

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

Дополнения

  • Стоит прочитать замечательную статью от Justina про обработку ошибок в Go, особенно секцию про реализацию кастомного httpError.
  • Пишите миделваре для вашего Go приложения? Используйте функции типа func(http.Handler) http.Handler и вы получите универсальный способ создания различных миделварь. Единственное "исключение" в таком подходе - это проблема передачи состояний между хендлерами(например, CSRF токен), которая возникает когда вам нужно привязываться к контексту запроса(например, как это организовано в web.C для Goji или gorilla/context). Старайтесь избегать большого количества миделварей.
  • Вот вам пример, как можно отлавливать ошибки до рендеринга шаблона(в кратце - используйте буферный пул).
  • И конечно же финальные исходники последней реализации, которые вы можете комментировать.