Дисклеймер

ВАЖНО: Это учебная статья для начинающих

Данный материал — первая часть цикла, где мы поэтапно изучаем net/http.

Код в этой статье сознательно упрощён для ясности изложения и тНЕ является production-ready.

Что опущено в этой части (будет в следующих):

  • Меры безопасности (CSRF, security headers, валидация)

  • Graceful shutdown и таймауты

  • Structured logging (slog)

  • Полноценная архитектура

Цель данной статьи: показать базовые механизмы net/http на практическом примере.

Не используйте этот код в production без доработок.

Введение

Веб-разработка – вечнозелёная тема в IT, как мы знаем, современные проекты требуют контроля ресурсов, высокой надежности, масштабируемости и простоты сопровождения.

Go – язык, на котором такие проекты разрабатывать довольно просто, все благодаря легкому синтаксису, высокой поддержке со стороны сообщества и постоянному контролю со стороны разработчиков. В цикле статей «Ботаем net/http на Go» мы разберемся с одним из наиболее весомых веб-фреймворков – net/http.

net/http – встроенный пакет Go для создания HTTP-серверов и клиентов, который среди других популярных решений выгодно выделяется:

  1. нулевыми зависимостями

  2. максимальной производительностью

  3. полным контролем над ресурсами и кодом

Единственный минус — больший объём кода по сравнению с высокоуровневыми веб-фреймворками. Однако он с лихвой перекрывается тем, что, пакет net/http является основой gin, chi, echo и почти всех других фреймворков, а как следствие, освоившись в нем, вы легко сможете перейти на любой другой из них. Увидите, это просто!

По ходу изучения net/http мы вместе разработаем ваш первый pet-проект на Go c использованием этого пакета – это поможет нам рассмотреть все функции на примерах и начать, что наиболее важно, практическую разработку. Не брезгуйте писать код со мной, после разбора теории я буду давать практические задания – выполняйте их и после этого знакомьтесь с моим решением. Это поможет вам побыстрее освоиться.

Задача

Представьте, что вам нужно передать коллеге, журналисту или другу информацию, но так, чтобы после получения она исчезла навсегда, не оставив никаких следов. В таком случае ни мессенджеры, ни email, ни облака вам не подойду. В реальном мире разведки и журналистики, в этих целях используется «мертвый почтовый ящик» (dead drop). В цифровом – различные сервисы наподобие Privnote.

Суть сервиса

Вы, как отправитель имеете возможность положить данные в почтовый ящик, от которого вам достанутся «ключи» (пароль) и уникальный «адрес» (ссылка). Просто передайте получателю ключ с адресом по разным каналам связи, например, по телефону назовите пароль, а ссылку отправьте в любом удобном мессенджере. Как только получатель заберет «посылку» (файлы или текст) из ячейки – она безвозвратно исчезнет. Прямого контакта между отправителем и получателем нет.

Это безопасно: перехватив пароль без ссылки – злоумышленник не узнает где его применить, получив адрес без пароля – злоумышленник не сможет открыть его.

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

Содержание

В первой статье цикла мы рассмотрим, как настроить проект, запустить базовый http-сервер, напишем наши первые middlewares, разберем темы шаблонов. К концу статьи у нас уже будет запущенный HTTP-сервер с главной страницей — фундамент будущего сервиса DeadDrop.

Я рассмотрю эти вопросы в следующих статьях по net/http, когда мы, разобравшись с основами, начнем углубляться в веб-разработку.

Основная часть

Создание модуля проекта

Первое, что необходимо сделать при создании любого Go-проекта, как вы уже должно быть знаете – инициализировать модуль вашего сервиса, делается это простой командой
go mod init <название_модуля>

Это необходимо, поскольку с версии Go 1.16+ работа вне модуля стала ограничена, модуль – корень вашего проекта, он необходим для корректной работы с зависимостями и инструментами разработки.

Давайте инициализируем модуль нашего проекта:

Создание модуля проекта
Создание модуля проекта

После создания нашего модуля, как видно на изображении, в директории нашего проекта должен появиться файл go.mod и go.sum.

Наконец, модуль deaddrop успешно создан и настало время запускать наш первый http-сервер.

Для понимания отмечу:

go.mod – фиксирует зависимости нашего проекта (например, какие сторонние библиотеки мы используем) и его модуль.

go.sum – фиксирует точные версии этих зависимостей.

Модули очень важны для CI/CD (непрерывная интеграция и развертывание) – пользуйтесь модулями, это удобно, ��ез них в дальнейшей работе у вас скорее всего возникнут такие проблемы:

  1. У одного участника вашей команды проект работает, у другого нет

  2. Сегодня модули широко используются в инструментах разработки, поэтому рано или поздно вы столкнетесь с тем, что они просто откажут в работе без go.mod

Создание и запуск первого http-сервера

Давайте для продолжения эффективного изучения net/http рассмотрим основные сущности и понятия этого фреймворка:

1) http.Server – основной объект, он отвечает за принятие соединений и обработку запросов. Сервер имеет следующую внутреннюю структуру:

server := &http.Server{

        Addr:    ":8080",

        Handler: handler,

    }

Addr – адрес и порт на котором сервер принимает входные соединения. Запись «:8080» означает – слушать все сетевые интерфейсы на порту 8080, это классика

Давайте теперь поподробнее рассмотрим что такое Handler

2) Handler – в net/http всё крутится вокруг этого интерфейса

type Handler interface {

    ServeHTTP(http.ResponseWriter, *http.Request)

}

Любая сущность Go, реализующая метод ServeHTTP способна обрабатывать запросы. Понимание этого является ключевым фактором для успешного go разработчика, оно в последствии позволит нам писать кастомные обработчики. Не переживайте если пока что это кажется непонятным, в последствии мы закрепим с эту тему практикой.

Пока что хватит теории, давайте поближе к практике. Вся кажущаяся тяжесть разработки разбивается о действительную простоту запуска сервера, он запускается одной простой функцией:

http.ListenAndServe(":8080 ", nil)

На первый взгляд может показаться, что передача nil в http.ListenAndServe означает отсутствие обработчика, однако это не так.

В net/http существует важное соглашение:

если Handler == nil, используется http.DefaultServeMux. Фактически, предыдущая строка эквивалентна следующей:

http.ListenAndServe(":8080", http.DefaultServeMux)

DefaultServeMux — это глобальный маршрутизатор (multiplexer), который:

  • хранит соответствие URL-путей и обработчиков

  • выбирает нужный Handler для каждого запроса

  • возвращает 404, если подходящий обработчик не найден

Представьте, ваш проект начал расти, если вы пользовались DefaultServeMux он, как глобальная переменная пакета net/http будет виден из любого пакета вашего проекта, что снизит удобство его сопровождения. Вследствие этого – использование DefaultServeMux удобно только в случае учебных примеров, в последствии давайте использовать свой явно созданный маршрутизатор:

mux := http.NewServeMux()

Давайте же посмотрим, что будет если ее написать и запустить наш проект. Сделайте это и посмотрите на результат, перейдя по адресу localhost:8080

В нашем случае, если ознакомиться с реализацией ListenAndServe в go мы увидим, что метод может возвращать ошибку, а значит, целесообразна следующая реализация:

package main

import (
    "log"
    "net/http"
)

func runServer(addr string, handler http.Handler) error {
    return http.ListenAndServe(addr, handler)
}

func main() {
    mux := http.NewServeMux()
    err := runServer(":8080", mux)
    if err != nil {
        log.Fatal(err)
    }
}

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

Получаем ошибку: listen tcp :8080: bind: Only one usage of each socket address (protocol/network address/port) is normally permitted.

Следовательно, одна из основных причин возникновения ошибки – попытка запуска сервера на уже занятом сервере.
Должно быть, вы уже попробовали запустить сервер и при переходе на localhost:8080 увидели: 404 page not found.

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

Обработчики маршрутов

Обработка запросов в net/http выполняется при помощи уже знакомого нам интерфейса – Handler. И чтобы сервер перестал отдавать нам ошибку 404, нам стоит зарегистрировать хотя бы один такой обработчик в нашем маршрутизаторе ServeMux.

В net/http есть 2 подхода к регистрации обработчика:

1)     mux.Handle(<путь> string, handler http.Handler)

2)     mux.HandleFunc(<путь> string, handler func(w http.ResponseWriter, r *http.Request) – несложно догадаться, это удобная обертка для обычного http.Handler которая позволяет регистрировать обычные функции.

Перед тем как мы добавим наш первый обработчик в DeadDrop давайте для понимания ответим на вопрос, а что же такое http.ResponseWriter и http.Request

1)     http.ResponseWriter – интерфейс, который позволяет формировать HTTP-ответ клиенту.

Интерфейс имеет следующую сигнатуру:

 type ResponseWriter interface {

    Header() Header

    Write([]byte) (int, error)

    WriteHeader(statusCode int)

} 

Исходя из представленного интерфейса несложно разобраться с функциями «писателя»:

a)     Писать тело ответа через w.Write([]byte(“<текст>”))

b)     Устанавливать статус-код ответа через w.WriteHeader(<статус-код>)

c)     Устанавливать заголовки ответа с помощью метода w.Header.Set("Content-Type", "text/html")

2)     http.Request – структура, содержащая всю информацию, отправленную клиентом. Давайте посмотрим наиболее важные поля этой структуры:
Метод запроса: r.Method // GET, POST, PUT...

a)     Url запроса:  r.URL.Path     // /secret/abc

b)     Query параметры запроса: r.URL.Query()  // ?token=123

c)     Заголовки запроса: r.Header.Get("Authorization")

d)     Тело запроса: body, _ := io.ReadAll(r.Body)

Не забывайте после этого закрывать тело запроса: defer r.Body.Close()

Это лишь малая доля того, что полезного мы можем достать из запроса, с другими полями и функциями разберемся по ходу написания нашего проекта и изучения net/http

Что же, разобравшись с ResponseWriter и Request, настало время добавить зарегистрировать наш первый обработчик. Попробуйте сами создать HandleFunc, который при переходе по корневому адресу проекта «/» будет возвращать надпись «DeadDrop is Alive».

Вот действующий вариант такой функции:

// предыдущий код
func HomeHandler(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("DeadDrop is Alive"))
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/", HomeHandler) // регистрируем маршрут
    err := runServer(":8080", mux)
    if err != nil {
        log.Fatal(err)
    }
}
Запуск первого сервера
Запуск первого сервера

Теперь попробуйте перейти по адресу localhost:8080

Наконец-то, наш сервер запущен, и уже приветствует нас надписью «DeadDrop is Alive».

Попробуйте теперь перейти по адресу localhost:8080/hello: то же самое сообщение. Теперь по адресу localhost:8080/deaddrop/habr: то же самое сообщение. Возникает вопрос: а почему же так? Ошибка в коде?\

Мэтчинг маршрутов

Никакой ошибки! Дело в том, как же наш маршрутизатор ServeMux сопоставляет маршруты. В net/http работает правило самого длинного совпадения. Http.ServeMux использует очень простой и предсказуемый алгоритм:

Выбирается обработчик с самым длинным совпадающим префиксом пути
У нас, пока что, есть всего лишь один маршрут: «/» - он корень всех URL, а значит:

URL

Подходит под /

/

Да

/hello

Да

/deaddrop/habr

Да

А теперь давайте добавим и зарегистрируем еще один обработчик:

// Добавили новый handler
func HelloHandler(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("Hello world!"))
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/", HomeHandler)
    mux.HandleFunc("/hello/", HelloHandler) // добавили новый маршрут
Работа нового обработчика HelloHandler
Работа нового обработчика HelloHandler

Попробуйте перейти по адресу localhost:8080/hello и localhost:8080/hello/deaddrop

Окончательно разберемся, почему так получилось составив таблицу мэтчинга маршрутов:

URL

По�� какой префикс подходит

/

/

/deaddrop

/

/hello

/hello/

/hel/lo

/

/hello/deaddrop

/hello/

Вы можете подумать: не уж-то нельзя создать маршрут, который будет уникален и не будет объединен со всеми незарегистрированными дочерними маршрутами?

Конечно можно! Сделать это легко, просто уберите знак / после очередного зарегистрированного маршрута, попробуйте поменять в нашем коде:
mux.HandleFunc("/hello/", HelloHandler)
на:
mux.HandleFunc("/hello", HelloHandler) // поменяли «/hello/» на «/hello»
Составим аналогичную таблицу мэтчинга маршрутов:

URL

Под какой префикс подходит

/

/

/deaddrop

/

/hello

/hello

/hel/lo

/

/hello/deaddrop

/

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

Middleware

На этом этапе вы могли заметить, что консоль, в которой мы запускаем наш сервис, полностью пуста. Мы принимаем HTTP-запросы, обрабатываем их, возвращаем ответы — но никак об этом не узнаём, а ведь хорошо было бы видеть входящие запросы, понимать сколько времени они обрабатываются, ну и самое главное: сейчас, при любой ошибке наш сервер падает полностью, так быть не должно – в net/http для всех этих целей предназначены промежуточные обработчики middleware.

Middleware – обёртка обработчика, которая может выполняться до выполнения запроса, после него или даже вместе с ним.

Фактически это функция, принимающая http.Handler и возвращающая http.Handler. В большинстве случаев она имеет следующий вид.

func someMiddleware(next http.Handler) http.Handler{
  return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    // код ДО обработчика
    next.ServeHTTP(w, r)
    // код ПОСЛЕ обработчика
  })
}

Попробуйте написать middleware логирования запросов к нашему серверу, он должен писать в консоль сообщения вида «[INFO] <метод запроса> - <адрес запроса> - <время выполнения запроса>». После того как сделаете это мы проверим результат, и подключим его к нашему маршрутизатору.

Наш вариант реализации имеет следующий вид:

import "time"

func LoggingMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()

		next.ServeHTTP(w, r)

		log.Printf(
			"[INFO] %s - %s - %s",
			r.Method,
			r.URL.Path,
			time.Since(start),
		)
	})
}

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

Есть два способа:

  1. Подключение middleware к конкретному handler

  2. Подключение middleware к маршрутизатору целиком

Давайте рассмотрим как можно сделать это:

Чтобы подключить middleware к одному обработчику достаточно обернуть в него handler при регистрации маршрута, например так:

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/", HomeHandler)
    mux.Handle("/hello/", LoggingMiddleware(http.HandlerFunc(HelloHandler))) // добавили новый маршрут
}

Теперь перезапустим сервер и перейдем на localhost:8080, localhost:8080/hello и на localhost:8080/hello/habr. Посмотрите на результат в консоли:

Работа LoggingMiddleware в терминале приложения
Работа LoggingMiddleware в терминале приложения

Как видите, запросы по всем дочерним адресам «/hello/» успешно логируются, супер!

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

Именно поэтому в большинстве случаев middlewareподключается к целому маршрутизатору, сделать это даже проще чем к отдельному маршруту, взгляните

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/", HomeHandler)
    mux.HandleFunc("/hello/", HelloHandler)
    loggingMux := LoggingMiddleware(mux) // делаем новый, обернутый маршрутизатор
    err := runServer(":8080", loggingMux)
    if err != nil {
        log.Fatal(err)
    }
}

Смотрите на результат, мы успешно логируем запросы по всем адресам нашего маршрутизатора:

Отлично!

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

Попробуйте написать самостоятельно RecoveryMiddleware который будет не давать серверу упасть в случае паники, а также, пусть в консоли пишется какая именно ошибка создала панику, на сервере будет пусть отображается «Internal Error 500»

func RecoveryMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if err := recover(); err != nil {
                log.Printf("panic recovered: %v", err)
                http.Error(w, "Internal Server Error", http.StatusInternalServerError) // http.Status… - константы net/http, которые удобны к использованию и содержат внутри себя цифровые коды ошибок, в данном случае - 500
            }
        }()

        next.ServeHTTP(w, r)
    })
}

В данном случае важно вызывать recover в defer чтобы он выполнялся всегда после выполнения вложенного http.Handler.

Давайте попробуем искусственно создать панику для проверки нового middlewarek, создав новый маршрут /panic с соответствующим обработчиком:

mux.Handle("/panic", RecoveryMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        panic("panic error!")
    })))

Перейдем по адресу localhost:8080/panic и посмотрим в консоль:

Выход из паники
Выход из паники

Паника успешно восстановлена, результат, который мы видим в браузере:

Status 500 на сервере
Status 500 на сервере

Возникает логичный вопрос: «и что теперь, создавать еще один маршрутизатор поверх loggingMux?»

- Нет.

Маршрутизаторы можно в легкую обернуть сколько угодно раз. Воттак:

NewMux := RecoveryMiddleware(LoggingMiddleware(mux))

Хорошим тоном в нашем случае считается использование вспомогательной функции Chain, которая применит указанные middleware к хэндлерам.
Она выглядит так:

func Chain(handler http.Handler, middlewares ...func(http.Handler) http.Handler) http.Handler {
	for i := len(middlewares) - 1; i >= 0; i-- {
		handler = middlewares[i](handler)
	}
	return handler
}

Тогда применение middleware к нашему маршрутизатору будет иметь следующий вид:

NewMux := Chain(
        mux,
        RecoveryMiddleware, // выполнится вторым
        LoggingMiddleware, // выполнится первым
    )

Как вы думаете, критичен ли порядок промежуточных обработчиков в цепочке?

- В большинстве случаев – да, и особенно критичен для нашего нового RecoveryMiddleware. Я объясню почему, взгляните на схему:

Схема middlewares
Схема middlewares

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

Для полного понимания, рассмотрим еще один пример:

Chain(
	mux,
	A,
	B,
	C
)

Порядок выполнения, как вы уже догадались будет: A > B > C > mux.

Шаблоны

Сайт с текстом, конечно, работает, но давайте честно: сервис без интерфейса выглядит как утилита из 90-х. А мы всё-таки делаем production-ready DeadDrop, пусть пока и в pet-формате. Пользователю нужен браузер, кнопки, формы и аккуратная верстка.

В Go обработка шаблонов осуществляется пакетом http/template (не путать с text/template!)

  • http/template нужен для генерации HTML.

  • text/template нужен для генерации произвольного текста.

В отличии от text/template наш пакет умеет, что особенно важно, экранировать данные пользователя для безопасности. Это защищает от XSS-атак, когда злоумышленник пытается внедрит�� свой скрипт в код сайта.

Архитектура проекта

ВАЖНО:  в нашем случае довольно немного кода и такое «усложнение» архитектуры проекта не столь целесообразно, но оно необходимо новичкам для понимания того, как может выглядеть реальная архитектура серьезного проекта. Если вам интересно продолжать изучать техническую часть библиотеки net/http – просто пропустите её и ознакомьтесь попозже!

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

deaddrop/
├── cmd/
│   └── api/
│       └── main.go
│
├── internal/
│   ├── app/
│   │   └── app.go
│   │
│   ├── delivery/
│   │   └── http/
│   │       ├── handler/
│   │       │   └── home.go
│   │       └── router.go
│   │
│   └── middleware/
│       ├── logging.go
│       └── recovery.go
│
├── templates/
│   └── home.html
│
├── go.mod
└── README.md

Рассмотрим какой код где будет лежать

cmd/api/main.go – здесь будет лежать исключительно запуск проекта, никакой логики:

func main() {
    app.Run()
}

internal/app/app.go – инициализация сервера

func Run() {
    mux := http.NewServeMux()

    router.RegisterRoutes(mux)

    handler := Chain(
        mux,
        middleware.Recovery,
        middleware.Logging,
    )

    http.ListenAndServe(":8080", handler)
}

Здесь будем осуществлять:

1) сборка маршрутизатора ServeMux

2) подключение middleware

3) запуск сервера на определенном порту

internal/delivery – это http-слой, здесь должно лежать все то, что мы сделали в прошлой главе, рассмотрим более конкретные маршруты:
internal/delivery/router – здесь будем регистрировать маршруты

//router.go
func RegisterRoutes(mux *http.ServeMux) {
    mux.HandleFunc("/", handler.Home)
}

internal/delivery/handler – здесь реализуем обработчики

// handler/home.go
func Home(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("DeadDrop is Alive"))
}

Обратите внимание, на этом слое мы только работаем с http.Request и формируем ответ. Вся бизнес-логика, согласно принципам layered архитектуры, должна быть вынесена в отдельный слой

internal/delivery/middleware – сюда, как вы уже догадались положим наши промежуточные обработчики:

logging.go   // middleware логирование запросов

recovery.go  // middleware защита от panic

Наконец, новая для нас папка:

templates/ (они же шаблоны)

Сюда, как вы догадались, положим наши html-шаблоны. Давайте создадим первый файл в этой папке: home.html

Если у вас возникли сложности в создании архитектуры – просто ознакомьтесь с ней в репозитории DeadDrop на GitHub.

Пока что – не будем трогать в нашей архитектуре бизнес-логику, в последствии мы создадим под нее отдельные папки (слои).

Попробуйте ответить на вопрос: «Зачем нужна такая «сложная» архитектура?». Ответ прост, она со всех сторон облегчает дальнейшую разработку.

К примеру, сейчас наш сервис умещается в 50-70 строк кода, но production версии серьезных проектов могут занимать тысячи, представьте, насколько тяжело отлавливать баг в 10000-15000 строк кода. Есть и ряд других причин:

  1. удобство совместной разработки

  2. повышение читаемости

  3. удобство тестирования

  4. удобство масштабирования

Да и к тому же, layered архитектура – «золотой стандарт» в production, в последствии вы будете обязаны понять и усвоить ее принципы, чтобы продолжать успешную работу.

Продолжаем разбираться с шаблонами

Давайте начнем с базы, попробуем вынести наш «DeadDrop is Alive» в шаблон, для этого напишем реализуем файл home.html:

<!DOCTYPE html>
<html lang="ru">
<head>
    <meta charset="UTF-8">
    <title>DeadDrop</title>
</head>
<body>
    <h1>DeadDrop</h1>
    <p>{{ .Message }}</p>
</body>
</html>

Вы должно быть заметили, что наш html несколько отличается от привычных вам файлов – у нас имеется {{  .Message }}, что это? {{.Message}} – данные, которые мы передадим из Go.

Как загрузить шаблон в обработчик?

Чтобы наш обработчик отдавал и обрабатывал html нам необходимо спарсить (обработать его, проанализировать и структурировать к виду, воспринимаемому Go). Это делается функцией:

<название шаблона>,<error> = template.ParseFiles("<путь/к/шаблону.html>")

Для рендера (создания финального отображения) шаблона используется функция:

err := tmpl.Execute(w, data)

,где

err – потенциальная ошибка при рендере шаблона

w – объект http.ResponseWriter, вы же помните – именно он помогает писать нам ответы сервера, а сейчас ответом будет наш обработанный html-шаблон

data – передаваемые в шаблон данные, они классически представляют собой структуру с полями, требуемыми нашим шаблоном. В нашем случае – это поле Message – но оно может быть и нулевыми (zero value)

Попробуйте обработать шаблон, это делается в internal/delivery/handler/home.go

func HomeHandler(w http.ResponseWriter, r *http.Request) {
    tmpl, err := template.ParseFiles("../../templates/home.html")
    if err != nil {
        http.Error(w, "Template error", http.StatusInternalServerError)
        return
    }

    data := struct {
        Message string
    }{
        Message: "DeadDrop is Alive",
    }

    err = tmpl.Execute(w, data)
    if err != nil {
        http.Error(w, "Render error", http.StatusInternalServerError)
    }
}

Запускаем сервер, теперь используя команду:

go run cmd/api/main.go

Перейдем по адресу localhost:8080/

localhost с загруженным шаблоном
localhost с загруженным шаблоном

Пока что у нас есть некоторая недоработка, хоть браузер и смог автоматически определить, что мы передали в ответ html хорошим тоном считается явно объявлять это в заголовках ответа, добавим в наш HomeHandler строчку:

w.Header().Set("Content-Type", "text/html; charset=utf-8")

До момента tmpl.Execute.

Есть и другая проблема – оптимизационная: мы парсим шаблон при каждом запросе заново, а это:

1) Лишняя нагрузка на CPU

2) Замедление ответа

3) Плохая масштабируемость

Как же тогда делать правильно?

- Давайте парсить все наши шаблоны 1 раз – при запуске сервиса:

Давайте реализуем парсинг всех шаблонов разом:

Создадим новый файл /delivery/http/Template/templates.go в нем как раз объявим наш глобальный HomeTemplates

package http

import "html/template"

var HomeTemplate = template.Must(
    template.ParseFiles("templates/home.html"), //сделали путь не относительным, проект временно возвращает ошибку – добавление Makefile в следующей главе всё пофиксит
)

Тогда перепишем наш HomeHandler без рендеринга, он примет вид:

package handler

import (
    templates "deaddrop/internal/delivery/http/template" // такое объявление при импорте называется alias, оно нужно чтобы не возникало конфликтов в названиях пакетов
    "net/http"
)

func HomeHandler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/html; charset=utf-8")
    data := struct {
        Message string
    }{
        Message: "DeadDrop is Alive",
    }

    err := templates.HomeTemplate.Execute(w, data)
    if err != nil {
        http.Error(w, "Render error", http.StatusInternalServerError)
    }
}

Шаблон успешно создан, он обрабатывается и рендерится без лишних нагрузок на машину, теперь шаблоны будем рендерить так – 1 раз при запуске приложения.

Более того:

template.Must паникует, если случилась ошибка при рендере, а как вы можете догадаться это отлично сочетается с нашим RecoveryMiddleware

Makefile

Makefile – файл с инструкциями для специальной утилиты make – она автоматизирует процесс сборки и запуска проекта, как бы являясь удобной точкой входа в него. Сейчас у нас есть огромная проблема, процесс запуска проекта зависит от рабочей директории, это происходит из-за того, что выше мы использовали относительные пути. Если бы мы работали в команде с другими разработчиками появилось бы очень много условностей по типу:

«Чтобы запустить проект перейдите в директорию …» - весь этот процесс упрощается очень просто:

Перейдите в корень директории проекта и создайте новый файл c полным названием: Makefile (без расширения):

Давайте добавим в него следующее содержимое:

APP_NAME=deaddrop
CMD_DIR=cmd/api

.PHONY: run build clean

## Запуск сервера (гарантирует cwd = корень проекта)
run:
    cd $(CURDIR) && go run ./cmd/api

## Сборка бинарника
build:
    go build -o bin/$(APP_NAME) ./$(CMD_DIR)

Теперь проект запускается проще некуда. Находясь в корневой директории проекта напишите в терминале:

make run – чтобы запустить приложение (аналог go run .\cmd\api\main.go)

make build – чтобы скомпилировать проект в бинарник

Важно сказать:

В последствии мы перейдем от относительных путей, к embedFS (вы поймете, что это такое в следующих статьях), чтобы избежать таких неудобных зависимостей. Однако makefile является одной из must have практик на разработке любого уровня – можете поподробнее разобраться с написанием таких файлов посмотрев хороший видео-урок:

https://www.youtube.com/watch?v=TZj_51Z0AZU

или прочитав статью:

https://habr.com/ru/articles/155201/

Layout

Посмотрите на примеры оформления популярных сайтов:

Gmail:

Gmail head
Gmail head

Youtube:

Youtube head
Youtube head

Github:

Github head
Github head

Как видите, сверху у каждого из них есть «хэдер» - удобная панель навигации по сайту. Как вы знаете, в популярном сайте могут быть десятки маршрутов. Хэдер для каждого маршрута будет одинаковым. Как думаете, разработчики вставляют одинаковый код хэдера в каждый html-файл? Нет конечно! Для этого предназначены layout’ы

Layout (макет, шаблон-оболочка) — это общая структура HTML, которая используется на всех страницах сайта.

В макете определяется каркас страницы

Внутри него вызывается шаблон контента с помощью {{ template "content" . }}

Каждая отдельная страница определяет content

То есть:

layout знает структуру

страницы знают что показывать внутри

Выглядит это так:

Создаем общий файл для каждой страницы deaddrop/templates/layout.html

templates/
├── layout.html
└── home.html
{{ define "layout" }} // объявляем, что этот файл макет
<!DOCTYPE html>
<html lang="ru">
<head>
    <meta charset="UTF-8">
    <title>{{ .Template }}</title>
</head>
<body>
    <header>{{ .Title }}</header>
    {{ template "content" . }} // здесь будет вставлено содержимое страницы, где мы определим content
</body>
</html>
{{ end }} // здесь будет конец нашего макета

Теперь наш home.html переделаем в content страницу:

{{ define "content" }}
<h1>Добро пожаловать!</h1>
<p>{{ .Message }}</p>
{{ end }}

Не забываем, что Go должен отрендерить наш новый файл: template.Must умеет парсить несколько файлов за раз, смотрите – это несложно:

var HomeTemplate = template.Must(
    template.ParseFiles(
        "templates/layout.html",
        "templates/home.html",
    ),
)

Макетов может быть много, как и блоков контента – соответственно нужно определить, каким способом соединить их друг с другом. Как всегда, в net/http сделать это просто, поменяем функцию рендера Execute на ее «расширенную версию» ExecuteTemplate – она принимает 3 аргумента

err := templates.HomeTemplate.ExecuteTemplate(w, "layout", data)

w и data нам уже давно знакомы, а content, как вы можете догадаться layout будет макетом, который мы загружаем

Запустим проект и перейдем на localhost:8080/

Layout примененный к шаблону
Layout примененный к шаблону

На этом моменте с шаблонами мы успешно разобрались, попробуйте самостоятельно добавить пару новых обработчиков с другими content-блоками, в layout добавьте футер.

Статика в net/http

Как статика была организована раньше - до embed FS:

Настало время серьёзно задуматься, каким будет интерфейс взаимодействия с нашим сервисом. Удобными инструментами создания прототипов интерфейсов является Figma и Tilda – давайте вместе накидаем прототип нашего интерфейса.

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

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

Реализуем все это и обновим наш home.html

{{ define "content" }}
<h1>Создать новую ячейку DeadDrop</h1>

<form action="/create" method="POST" enctype="multipart/form-data">

    <div>
        <label for="message">Сообщение:</label><br>
        <textarea id="message" name="message" rows="5" required></textarea>
    </div>

    <div>
        <label for="file">Прикрепить файл (опционально):</label><br>
        <input type="file" id="file" name="file">
    </div>

    <div>
        <label for="ttl">Время жизни ячейки (часов, опционально):</label><br>
        <input type="number" id="ttl" name="ttl" min="1" placeholder="Например, 24">
    </div>

    <div>
        <button type="submit">Создать ячейку</button>
    </div>
</form>
{{ end }}
Обновленный шаблон главной страницы
Обновленный шаблон главной страницы

В целом, страница обрела весь необходимый функционал, но без дизайна она всё ещё выглядит «по-древнему».

Правильным решением будет добавить css в наш документ, обновим layout.html:

{{ define "layout" }}
<!DOCTYPE html>
<html lang="ru">
<head>
    <meta charset="UTF-8">
    <title>{{ .Title }}</title>
    <link rel="stylesheet" href="/static/style.css">
</head>
<body>
    <header>DeadDrop</header>
    {{ template "content" . }}
</body>
</html>
{{ end }}

Файловый сервер и css

Для удобства и понимания следующей главы – настоятельно рекомендую скачать с GitHub репозитория проекта : https://github.com/Meedoeed/DeadDrop папку static - в ней лежат файлы animations.css и style.css. Вы, должно быть уже догадались, где она должна лежать, если придерживаться layered архитектуры: она не содержит бизнес-логики – значит разместим ее в delivery слой нашего проекта – теперь структура проекта должна выглядеть так:

Добавление директории статики
Добавление директории статики

Кроме того, достаньте с github обновленный layout.html и обновленный home.html – я добавил туда немного JavaScript – он необходим для визуальной части нашего сайта.

Если у вас не получается по каким-либо причинам скачать файлы с GitHub – это вовсе не проблема! Вы можете просо создать свои CSS файлы в папке static, а JS и вовсе совсем не обязателен! Главное не забудьте импортировать стили в вашем html, например так:

<link rel="stylesheet" href="/static/style.css">
<link rel="stylesheet" href="/static/animations.css">

Теперь попробуйте перейти на localhost:8080. Наверное, вы ждали красивый визуал с ходу, но не тут-то было – сайт остался таким же каким был в прошлой главе! Вы спросите: «Почему?!». Ответ кроется в механизме работы строчки <link rel="stylesheet" href="/static/style.css">, когда она рендерится нашим ExecuteTemplate, как вы видите в логах происходит обращение на localhost:8080/static/style.css – перейдите на этот адрес.

Результат ожидаем – для адреса, обработчик которого не зарегистрирован, у нас вышла ошибка 404 – такой страницы у нашего маршрутизатора просто нет! Так давайте ее зарегистрируем, для этого в net/http есть так называемый файловый сервер (fileserver)

Он будет хранить и отдавать файлы, предназначенные для функционирования нашего сервера по адресу localhost:8080/static/<имя_файла>

Fileserver в net/http создать легко – уже есть готовый handler прямо «из коробки»

Достаточно создать объект такого handler:

fileServer := http.FileServer(http.Dir(staticDir))

Здесь, staticDir – путь к нашей директории со статическими файлами

Помимо того, что он умеет читать файлы с диска и отдавать их по запросу – он автоматически подбирает правильные заголовки ответа (Content-Type, Last-Modified и т.д.).

Единственное, что по сути нужно для корректной работы fileserver – осознать, как сопоставляется путь в локальной директории проекта с путем веб-страницы:

Мы уже поняли по логам, что содержимое файла должно лежать по адресу localhost:8080/static/<имя_файла>

Рассмотрим локальн��й путь до static относительно корневой директории проекта:

Путь к директории статики
Путь к директории статики

Итак – путь к static, как мы видим такой: ./Deaddrop/internal/delivery/static

Теперь представим, что мы сделали так:

fileServer := http.FileServer(http.Dir(“Deaddrop/internal/delivery/static”))

И зарегистрируем его в наш маршрутизатор на адрес /static/ то сервер попытается его найти по адресу:

./Deaddrop/internal/delivery/static/static/

Для этого пользуемся методом http.StripPrefix пакета net/http, он уберет лишний префикс в пути fileserver

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

cwd, err := os.Getwd()// возвращает текущую рабочую директорию
if err != nil {
 panic(err)
}
staticDir := filepath.Join(cwd, "..", "..", "internal", "delivery", "static") // формируем путь методом Join из-за особенности формирования путей в разных Unix подобных системах и Windows. Мы в данном случае берем путь из cwd – с помощью «..» перемещаемся на две директории выше – в директорию Deaddrop, оттуда уходим в internal/delivery/static

fileServer := http.FileServer(http.Dir(staticDir)) // создаем handler файлового сервера
mux.Handle("/static/", http.StripPrefix("/static/", fileServer)) // регистрируем его в наш маршрутизатор по адресу /static исключая «лишний» /static/ с помощью StripPrefix

Пара важных замечаний:

Это важная глава для понимания работы со статикой но решение с относительными путями к ее директориям – плохое. Для этих целей сегодня используется Embed FS, который мы рассмотрим в следующей главе: не используйте относительные пути в реальных проектах

Не делитесь корневой папкой проекта! Тогда злоумышленники смогут получить все то, что вам, как разработчику – хотелось бы от них скрыть. Handler файлового сервера автоматически запрещает выходить за рамки предоставленной вами директории, он с ходу наделяет вас достаточным уровнем защиты.

По умолчанию – ваш браузер сам кэширует static файлы, но в последствии мы можем cache-control middleware в нашем проекте.

Этот подход в production почти не применяется, в основном static хранится в nginx и другие технологии. В прочем, для нашего проекта на данном этапе этого с лихвой хватает!

Что же – зайдем на главную страницу нашего DeadDrop, перейдя на localhost:8080

Стили импортированы
Стили импортированы

Если вы использовали подготовленные css файлы и html со встроенным мной JS-кодом – ваш DeadDrop – теперь выглядит так. Если вы использовали свои стили – они аналогично должны были примениться к html

Мы успешно научились использовать Fileserver net/http!

Embed FS - безопасная и удобная статика

Вы могли обратить внимание на архитектурное усложнение проекта: много папок с названием template и templates – это очень неудобно, запутаться легко. К тому же, как уже упоминалось в главе Makefile – в нашем проекте пути к файлам относительны, а значит зависят от точки входа в приложение.

Начиная с версии Go 1.16+ для поднятия файловых серверов активно используют Embed FS.

Embed FS – механизм встраивания файлов в бинарник приложения, как источник файлов. При этом http.FileServer в наших обработчиках останется, ведь это лишь способ отдачи этих файлов. Наш код изменится несущественно.

Создайте internal папку assets – это будет точкой доступа к нашей статике:

Переместите сюда из корня проекта папку templates и папку static. Директория проекта должна принять следующий вид:

Новая директория в проекте: internal/assets
Новая директория в проекте: internal/assets

Изменим наш файл templates.go добавив туда embed и разберемся с обновлениями:

package assets

import (
    "embed"
    "html/template"
    "io/fs"
)

//go:embed templates/*.html
var templateFiles embed.FS

//go:embed static/*.css
var staticFiles embed.FS

var HomeTemplate = template.Must(
    template.ParseFS(templateFiles, "templates/*.html"),
)

var StaticFS fs.FS

func init() {
    var err error
    StaticFS, err = fs.Sub(staticFiles, "static")
    if err != nil {
        panic(err)
    }
}

На примере кода рассмотрим, как работает embed fs:

Здесь:

//go:embed templates/*.html
var templateFiles embed.FS

//go:embed <путь> - директива, которая будет работать на этапе компиляции проекта, она как бы говорит – храни статические файлы этого пути в структуре embed.FS

В коде мы создаем отдельную переменную под статику и под шаблоны: обратите внимание на 2 вещи:

1) *.html – уникальная запись для передачи всех файлов директории указанного типа

2) Директории templates и static должны быть вложены в assets потому что embed рассчитывает пути относительно положения файла где используется этот пакет и не поддерживает дочерние пути вида ../../templates

var HomeTemplate = template.Must(
    template.ParseFS(templateFiles, "templates/*.html"),
)

Парсим шаблоны в глобальную переменную, делаем это практически как и раньше, рассмотрим особенность:

ParseFS в нашем случае будет искать все файлы внутри embed.FS – а не директории нашего проекта.

Далее мы объявляем переменную staticFS типа fs.FS – возникает вопрос:

Почему тогда не embed.FS? – fs.FS более широкий тип, embed.FS – его реализация, грубо говоря урезающий возможности оригинала. Как итог:

C embed.FS мы сможем обращаться только как с embed.FS, а c fs.FS можем обращаться и как с embed версией и как с «оригинальной».

Для статики в init() проводим инициализацию, это нужно для применения метода sub к staticFS:

StaticFS, err = fs.Sub(staticFiles, "static")

Применение этого метода решает важную проблему:

Без него мы могли бы обращаться к файлам по адресу: localhost:8080/static/static/… - это происходило бы потому что папка static вложена в assets и относительно директории файлового сервера embed.FS она лежит по адресу static/…

Методом Sub мы делаем подкаталог корневой директорией embed.FS

Теперь рассмотрим, как для работы теперь нужно поменять routes.go:

package http

import (
    "deaddrop/internal/assets"
    "deaddrop/internal/delivery/http/handler"
    "net/http"
)

func RegisterRoutes(mux *http.ServeMux) {
    mux.Handle("/static/", http.StripPrefix("/static/", handler.staticHandler)) // новый обработчик в папке handler
    mux.HandleFunc("/", handler.HomeHandler)
}

Можете заметить – лишний код и недопонимание с обилием папок templates исчезли:

Теперь нам достаточно создать обработчик для статики, где мы, как и раньше уберем лишний префикс «/static/» в пути с помощью StripPrefix().

Давайте рассмотрим, файл internal/delivery/http/handler/static.go

package handler

import (
    "deaddrop/internal/assets"
    "net/http"
)

var StaticHandler http.Handler = http.FileServer(
    http.FS(assets.StaticFS),
)

Здесь мы объявляем новый обработчик и инициализируем его с помощью уже знакомого метода конструктора http.FileServer() – он принимает http.FS -  в который мы передаем наши статики типа embed.FS.

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

Обратите внимание: embed FS – одна из лучших практик в современной backend разработке, старайтесь использовать именно этот подход для файлов вашего проекта.

RoadMap цикла "Ботаем net/http"

Часть 1 (эта) — Фундамент

✅ Основы net/http, middleware, шаблоны, статика 

✅ Запущен HTTP-сервер DeadDrop с интерфейсом

Часть 2 — Обработка данных (следующая)

▢ Обработка форм POST и загрузка файлов 

▢ In-memory хранилище секретов 

▢ Роутинг с параметрами (/secret/{id}

▢ DeadDrop: можно создавать и просматривать секреты

Часть 3 — Безопасность

▢ CSRF-токены, security headers 

▢ Валидация данных, хеширование паролей 

▢ Rate limiting, HTTPS 

▢ DeadDrop: защищенные формы, проверка паролей

Часть 4 — Production-готовность

▢ Graceful shutdown, structured logging 

▢ Конфигурация, health checks, метрики 

▢ DeadDrop: логирование, метрики, конфигурация

Часть 5 — Масштабирование и деплой*

▢ База данных (PostgreSQL/SQLite) 

▢ Кэширование, фоновые задачи 

▢ Docker, деплой, CI/CD 

▢ DeadDrop: персистентное хранилище, Docker, готовый к деплою

Текущий статус проекта DeadDrop:

- [x] Запущен HTTP-сервер

- [x] Главная страница с интерфейсом

- [x] Middleware (логирование, recovery)

- [  ] Обработка форм (Часть 2)

- [  ] Хранение данных (Часть 2)

- [  ] Безопасность (Часть 3)

- [  ] Production-фичи (Часть 4)

- [  ] База данных (Часть 5)

Следите за обновлениями в GitHub репозитории https://github.com/Meedoeed/DeadDrop!

Краткие итоги статьи

К концу первой статьи цикла «Ботаем net/http» мы получили http-сервис на чистом net/http, который способен принимать и обрабатывать запросы, он уже сейчас имеет хороший задел под production-версию.

Здесь мы вместе с вами на практике разобрали следующие темы:

· устройство http.Server и интерфейса http.Handler

· маршрутизацию и правило longest prefix в ServeMux

· написание и композицию middleware

· защиту сервера от panic

· работу с HTML-шаблонами и layout’ами

· подключение статических файлов через http.FileServer

· базовую архитектуру Go-проекта без фреймворков

· и другие темы.

Для нас особенно важно, что мы не использовали внешних зависимостей, благодаря этому сервис будет легко масштабировать и отлаживать. Ну и немаловажно: вы уже начали вникать в изнанку популярных фреймворков, уверяю – благодаря приобретенным знаниям вы сможете в них быстро освоиться и успешно вести современную разработку!

Нам предстоит ещё много работы, в следующей статье цикла мы уже разберем ряд следующих ключевых вопросов:

· Как обрабатывать формы и как работать с post-запросами?

· Как осуществляется безопасная загрузка файлов на сервер?

· Как валидировать пользовательские данные?

· Параллельно будем разбираться с другими важными вопросами web-разработки на net/http

Будьте уверены, скучно точно не будет!

Хотелось бы обратить внимание на то, что лучше всего учиться на практике – не брезгуйте писать код вместе со мной и задавать вопросы в комментариях, я всегда на связи и буду рад оказать помощь!

Также не забывайте следить за обновлениями в GitHub нашего совместного проекта: https://github.com/Meedoeed/DeadDrop!

До встречи в новой статье, где мы еще сильнее углубимся в net/http!

Полезные ссылки

pkg.go.dev/net/http

tour.golang.org

https://pkg.go.dev/html/template

https://go.dev/tour/