Дисклеймер
ВАЖНО: Это учебная статья для начинающих
Данный материал — первая часть цикла, где мы поэтапно изучаем 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-серверов и клиентов, который среди других популярных решений выгодно выделяется:
нулевыми зависимостями
максимальной производительностью
полным контролем над ресурсами и кодом
Единственный минус — больший объём кода по сравнению с высокоуровневыми веб-фреймворками. Однако он с лихвой перекрывается тем, что, пакет 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 (непрерывная интеграция и развертывание) – пользуйтесь модулями, это удобно, ��ез них в дальнейшей работе у вас скорее всего возникнут такие проблемы:
У одного участника вашей команды проект работает, у другого нет
Сегодня модули широко используются в инструментах разработки, поэтому рано или поздно вы столкнетесь с тем, что они просто откажут в работе без 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) // добавили новый маршрут

Попробуйте перейти по адресу 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),
)
})
}
Отлично, теперь давайте разбираться, как всё это дело к нашему коду подключать.
Есть два способа:
Подключение middleware к конкретному handler
Подключение 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. Посмотрите на результат в консоли:

Как видите, запросы по всем дочерним адресам «/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 и посмотрим в консоль:

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

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

Как можно увидеть 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 строк кода. Есть и ряд других причин:
удобство совместной разработки
повышение читаемости
удобство тестирования
удобство масштабирования
Да и к тому же, 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/

Пока что у нас есть некоторая недоработка, хоть браузер и смог автоматически определить, что мы передали в ответ 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:

Youtube:

Github:

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

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

Изменим наш файл 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
