Разработка веб-серверов на Go

Обложка поста

Перевод статьи «Building Web Servers in Go»

Борис Рязанцев

Стандартная библиотека языка Go включает в себя множество полезных и функциональных компонентов «из коробки», которые позволяют легко разрабатывать серверные приложения. В статье мы изучим, как написать веб-сервер на Go. Начнем с базового «Hello World!» и закончим приложением, выполняющим следующие функции:

  • использует Let’s Encrypt для HTTPS;
  • выполняет маршрутизацию запросов к API;
  • реализует промежуточную обработку запросов (middleware);
  • раздаёт статические файлы;
  • корректно завершает свою работу.

Если вы хотите сразу увидеть готовый код, зайдите в репозиторий http-boilerplate на GitHub.

Hello World!

HTTP-сервер на Go пишется довольно быстро. Вот пример с одним обработчиком, возвращающим «Hello World!»:

package main

import (
	"fmt"
	"net/http"
)

func main() {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "Hello World!")
	})
	http.ListenAndServe(":80", nil)
}

Если вы запустите приложение и откроете в браузере страницу http://localhost, то увидите текст «Hello World!».

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

Как работает net/http

В прошлом примере использовался пакет net/http, который служит в Go основным средством для разработки HTTP-клиентов и серверов. Чтобы разобраться в коде, следует кратко объяснить три важные концепции: http.Handler, http.ServeMux и http.Server.

HTTP-обработчики

Обработчиком называется то, что принимает запрос и возвращает ответ. В Go обработчики реализуют интерфейс со следующей сигнатурой:

type Handler interface {
        ServeHTTP(ResponseWriter, *Request)
}

Наш первый пример использует вспомогательную функцию http.HandleFunc, которая оборачивает другую функцию, принимающую http.ResponseWriter и http.Request, в ServeHTTP.

Идея представить обработчики единым интерфейсом открывает много возможностей. Позже мы увидим, что промежуточная обработка запросов будет реализована обработчиком, чей метод ServeHTTP выполняет некоторый код, а после вызывает метод ServeHTTP другого обработчика.

Таким образом, обработчики формируют ответы на запросы. Но как понять, какой именно обработчик нужно использовать в данный момент?

Маршрутизация запросов

Для выбора обработчика запроса в Go используется HTTP-мультиплексор. В некоторых библиотеках он называется «muxer» или «router», но суть та же. Мультиплексор выбирает обработчик на основе анализа пути запроса.

Если вам нужна продвинутая поддержка маршрутизации, следует обратиться к сторонним библиотекам. Достойные альтернативы — gorilla/mux и go-chi/chi — позволяют простым образом реализовать промежуточную обработку, настроить wildcard-маршрутизацию и решить ряд других задач. Однако важнее то, что они совместимы со стандартными HTTP-обработчиками. Таким образом, сохранится простота кода и возможность его безболезненного изменения в будущем.

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

Обработка запросов

Наконец, нам необходимо что-то, способное слушать входящие соединения и перенаправлять каждый запрос соответствующему обработчику. Эту задачу можно возложить на http.Server.

Как мы увидим далее, сервер отвечает за всё, что связано с обработкой соединений. В частности, сюда относится работа по протоколу TLS. В нашем примере при вызове http.ListenAndServer используется стандартный HTTP-сервер.

Теперь перейдём к более сложным примерам.

Работа с Let’s Encrypt

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

Если у вас уже есть сертификат и закрытый ключ, просто используйте ListenAndServeTLS, указав корректные пути к файлам сертификата и ключа:

http.ListenAndServeTLS(":443", "cert.pem", "key.pem", nil)

Но можно сделать лучше.

Let’s Encrypt — это удостоверяющий центр, выдающий бесплатные сертификаты с возможностью их автоматического обновления. Для использования этого сервиса в Go-приложениях доступен пакет autocert.

Настроить Let’s Encrypt проще всего используя вспомогательный метод autocert.NewListener в связке с http.Serve. Вспомогательный метод получает и обновляет TLS-сертификаты, в то время как HTTP-сервер занимается обработкой запросов:

http.Serve(autocert.NewListener("example.com"), nil)

Если требуется более тонкая настройка, используйте менеджер autocert.Manager. Затем создайте сервер http.Server (до сих пор мы использовали реализацию по умолчанию) и добавьте менеджер в конфигурацию сервера TLSConfig:

m := &autocert.Manager{
Cache:      autocert.DirCache("golang-autocert"),
Prompt:     autocert.AcceptTOS,
HostPolicy: autocert.HostWhitelist("example.org", "www.example.org"),
}
server := &http.Server{
    Addr:      ":443",
    TLSConfig: m.TLSConfig(),
}
server.ListenAndServeTLS("", "")

Таким простым способом можно реализовать полную поддержку HTTPS с автоматическим обновлением сертификата.

Установка маршрутов

Маршрутизатор из стандартной библиотеки неплох, но недостаточно функционален. Как правило, в приложениях требуется более сложная логика маршрутизации. Например, настройка вложенных и wildcard-маршрутов или установка шаблонов и параметров путей.

В этом случае могут быть полезны пакеты gorilla/mux и go-chi/chi. Ниже описан пример настройки маршрутизации с помощью библиотеки chi.

Допустим, у нас есть файл api/v1/api.go, содержащий маршруты для нашего API:

// HelloResponse — это JSON-представление сообщения
type HelloResponse struct {
	Message string `json:"message"`
}

// HelloName возвращает персонализированное JSON-сообщение
func HelloName(w http.ResponseWriter, r *http.Request) {
	name := chi.URLParam(r, "name")
	response := HelloResponse{
		Message: fmt.Sprintf("Hello %s!", name),
	}
	jsonResponse(w, response, http.StatusOK)
}

// NewRouter возвращает HTTP-обработчик, который является точкой входа в API
func NewRouter() http.Handler {
	r := chi.NewRouter()
	r.Get("/{name}", HelloName)
	return r
}

В основном файле установим для маршрутов префикс api/v1:

// NewRouter возвращает новый HTTP-обработчик, который является точкой входа в приложение
func NewRouter() http.Handler {
	router := chi.NewRouter()
    router.Mount("/api/v1/", v1.NewRouter())
    return router
}
http.Serve(autocert.NewListener("example.com"), NewRouter())

Возможность организации маршрутов и применение продвинутых правил маршрутизации упрощает структуризацию и сопровождение крупных приложений.

Реализация middleware

Промежуточная обработка (middleware) представляет собой обёртывание одного HTTP-обработчика другим. Это позволяет реализовать аутентификацию, журналирование, сжатие и другую функциональность.

Данный шаблон программирования несложно понять благодаря простоте интерфейса http.Handler. Мы можем написать функцию, которая принимает один обработчик и оборачивает его в другой. Для примера рассмотрим обработчик с аутентификацией пользователей:

func RequireAuthentication(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if !isAuthenticated(r) {
            http.Redirect(w, r, "/login", http.StatusTemporaryRedirect)
            return
        }
        // Аутентификация прошла успешно, направляем запрос следующему обработчику
        next.ServeHTTP(w, r)
    })
}

Некоторые сторонние маршрутизаторы наподобие chi предоставляют полезные и готовые функции для промежуточной обработки.

Раздача статических файлов

В стандартную библиотеку Go встроены возможности для раздачи статического контента, такого как изображения, файлы JavaScript и CSS. Они доступны через функцию http.FileServer, которая возвращает обработчик, раздающий файлы из заданного каталога.

Простой пример с уже знакомым нам маршрутизатором:

func NewRouter() http.Handler {
    router := chi.NewRouter()
    r.Get("/{name}", HelloName)

	// Настройка раздачи статических файлов
	staticPath, _ := filepath.Abs("../../static/")
	fs := http.FileServer(http.Dir(staticPath))
    router.Handle("/*", fs)
    
    return r

Обратите внимание! Стандартный http.Dir просто выводит содержимое каталога, если в нём нет файла index.html. Так может быть раскрыта конфиденциальная информация. Если такое поведение нежелательно, используйте пакет unindexed.

Корректное завершение работы

Начиная с версии 1.8 в Go есть возможность корректно завершать работу HTTP-сервера вызовом метода Shutdown(). Мы запустим сервер в горутине и будем прослушивать канал для приёма сигнала прерывания (например комбинации CTRL+C). После получения сигнала выделим несколько секунд на корректное завершение работы сервера.

handler := server.NewRouter()
srv := &http.Server{
    Handler: handler,
}

go func() {
		srv.Serve(autocert.NewListener(domains...))
}()

// Ожидание сигнала
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt)
<-c

// Попытка корректного завершения
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
srv.Shutdown(ctx)

Заключение

Стандартная библиотека языка Go невероятно функциональна. В доказательство этого были рассмотрены её встроенные возможности и преимущества гибких интерфейсов для быстрой разработки надежных HTTP-серверов.

В качестве продолжения рекомендуем посмотреть статью на Cloudflare, чтобы узнать о предварительных шагах перед тем, как разворачивать HTTP-сервер на Go в Интернете.

Если вам интересны шаблоны, которые вы можете использовать в собственных приложениях, зайдите в проект http-boilerplate на GitHub. А чтобы увидеть описанные приемы программирования в реальных продуктах, посетите проект Gophish на GitHub.

Не смешно? А здесь смешно: @ithumor