Go для веба: net/http, chi/v5, pgx, WebSocket
Подробное практическое руководство: от голого `net/http` до продакшен-сервиса с роутером chi, PostgreSQL через pgx и реалтаймом на WebSocket. С кодом, внутренностями и граблями.
Go для веба: net/http, chi/v5, pgx, WebSocket
> Подробное практическое руководство: от голого net/http до продакшен-сервиса с роутером chi, PostgreSQL через pgx и реалтаймом на WebSocket. С кодом, внутренностями и граблями.
---
Оглавление
1. [net/http: фундамент всего]
2. [chi/v5: идиоматичный роутер]
3. [pgx: PostgreSQL по-взрослому]
4. [WebSocket: реалтайм]
5. [Собираем всё вместе: скелет сервиса]
---
1. net/http
Почти весь веб-Go — включая chi, gin и прочие фреймворки — это тонкие обёртки над net/http. Кто понимает стандартную библиотеку, тому любой фреймворк понятен за вечер. Поэтому начинаем отсюда, с самого-самого нуля, и копаем глубоко.
1.1 Что происходит, когда «открывается сайт»
Прежде чем писать сервер, поймём, что он вообще делает. HTTP — это текстовый протокол «запрос-ответ» поверх TCP. Браузер (или мобильное приложение, или curl, или другой сервис) открывает соединение и присылает текст примерно такого вида:
GET /users/42?fields=name HTTP/1.1
Host: example.com
Accept: application/json
Разбор по частям:
GET— метод: что сделать. Основные:GET— получить,POST— создать/отправить,PUT/PATCH— обновить,DELETE— удалить./users/42— путь: с чем сделать.?fields=name— query-параметры: уточнения к запросу.- Строки ниже — заголовки (headers): метаданные, пары «ключ: значение».
- После пустой строки может идти тело (body) — например, JSON при POST.
Сервер отвечает в том же духе:
HTTP/1.1 200 OK
Content-Type: application/json
{"id": 42, "name": "Гоша"}
200 OK — статус-код. Запомните семейства: 2xx — успех; 3xx — редиректы; 4xx — ошибка клиента («сам дурак»: не туда пришёл, без прав, кривой JSON); 5xx — ошибка сервера («это мы сломались»). Частые коды: 200 OK, 201 Created, 204 No Content, 400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found, 500 Internal Server Error.
Вся работа веб-сервера — принять такой текст, понять, что просят, и собрать текст ответа. Всё дальнейшее в этой главе — удобные обёртки Go над этим простым обменом. Парсинг сырых байтов протокола net/http берёт на себя целиком; вам он отдаёт уже разобранные структуры.
1.2 Самый маленький сервер в мире
package main
import (
"fmt"
"net/http"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Привет, мир!")
})
fmt.Println("Слушаю http://localhost:8080")
http.ListenAndServe(":8080", nil)
}
Запустите (go run main.go), откройте в браузере http://localhost:8080 — поздравляю, вы веб-разработчик. Или проверьте из терминала:
curl -i http://localhost:8080
# HTTP/1.1 200 OK
# ...
# Привет, мир!
Теперь разберём каждую букву, потому что в этих шести строках спрятана половина всего net/http.
1.3 Хендлер: сердце всего
Хендлер (обработчик) — функция, обрабатывающая один HTTP-запрос. Её сигнатура священна, вы напишете её тысячи раз:
func(w http.ResponseWriter, r *http.Request)
r http.Request— входящий запрос, уже разобранный в структуру: метод, путь, заголовки, тело. Отсюда читаем.w http.ResponseWriter— исходящий ответ: интерфейс с методами для записи статуса, заголовков и тела. Сюда пишем.
Метафора: r — письмо, которое вам принесли; w — конверт с обратным адресом, в который вы кладёте ответ.
И сразу важнейший факт, который надо усвоить до всего остального: сервер запускает каждый запрос в отдельной горутине. Пришла тысяча запросов одновременно — ваш хендлер работает в тысяче горутин параллельно. Отсюда два следствия:
1. Сервер конкурентен «из коробки»: пишете простой синхронный код — никаких колбэков, промисов и event loop, — а конкурентность даётся бесплатно. 2. Любое общее состояние, которое трогают хендлеры (мапы, счётчики, кеши), обязано быть потокобезопасным — привет, мьютексы из гайда по конкурентности.
1.4 Handler и HandlerFunc: главная головоломка новичка
В net/http есть интерфейс — единственная абстракция обработчика во всём Go-вебе:
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
Хендлером считается любой тип с методом ServeHTTP. Но заводить структуру ради каждой ручки утомительно, поэтому в библиотеке лежит маленький шедевр дизайна:
// Тип-функция...
type HandlerFunc func(ResponseWriter, *Request)
// ...у которого объявлен МЕТОД. Он просто вызывает саму функцию.
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) { f(w, r) }
В Go методы можно вешать на любой именованный тип — в том числе на тип-функцию. Получается, что обычную функцию можно преобразованием типа превратить в интерфейс Handler:
func hello(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "hi") }
var h http.Handler = http.HandlerFunc(hello) // функция стала хендлером
Остановитесь и переварите это — оно того стоит. Все middleware, роутеры и фреймворки Go совместимы друг с другом именно потому, что говорят на языке этого интерфейса, а любая функция в него «заворачивается» одной строчкой. http.HandleFunc(pattern, f) из hello world — просто удобство, которое делает это преобразование за вас.
1.5 ServeMux: кто выбирает хендлер
Хендлеров в приложении много — кто решает, какой вызвать для какого пути? Мультиплексор (mux, роутер). В hello world мы передавали в ListenAndServe второй аргумент nil — это значит «глобальный DefaultServeMux», в который http.HandleFunc молча регистрирует ручки. Для игрушки сойдёт, для настоящего кода — нет: это глобальное состояние, и любой импортированный пакет может туда что-то подсадить (так люди случайно выставляли наружу /debug/pprof). Правильно — свой mux:
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", handleHome)
mux.HandleFunc("/about", handleAbout)
http.ListenAndServe(":8080", mux) // вместо nil — наш mux
}
Приглядитесь: mux сам является http.Handler! Его ServeHTTP смотрит на запрос и делегирует подходящему хендлеру. Хендлеры матрёшечны: сервер зовёт mux, mux зовёт вашу функцию. На этой матрёшечности дальше будет построено всё — от middleware до вложенных роутеров chi.
1.6 Роутинг Go 1.22+: методы и path-параметры
До Go 1.22 стандартный mux был спартанским (умел только префиксы путей) — из-за этого все и бежали в gorilla/chi/gin. С 1.22 он умеет главное:
mux := http.NewServeMux()
// Метод указывается прямо в паттерне:
mux.HandleFunc("GET /users", listUsers)
mux.HandleFunc("POST /users", createUser)
// Path-параметры — переменные в пути:
mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") // "42" для /users/42
fmt.Fprintf(w, "пользователь %s", id)
})
mux.HandleFunc("GET /files/{path...}", getFile) // wildcard-хвост из многих сегментов
mux.HandleFunc("GET /{$}", home) // строго "/" ("/" без {$} матчит ВСЁ)
Правила матчинга: побеждает наиболее специфичный паттерн (/users/admin бьёт /users/{id}); паттерн с методом специфичнее паттерна без; несовпадение метода → автоматический 405 Method Not Allowed; неоднозначно конфликтующие паттерны — паника при регистрации, а не сюрприз в рантайме.
Для небольших сервисов ServeMux 1.22+ закрывает 90% потребностей. Чего в нём нет — middleware-цепочек на группы роутов, вложенных роутеров, монтирования — за этим пойдём в chi (глава 2).
1.7 Читаем запрос: всё, что прислал клиент
func handler(w http.ResponseWriter, r *http.Request) {
// --- Метод и путь ---
_ = r.Method // "GET", "POST"...
_ = r.URL.Path // "/users/42"
// --- Query-параметры: /search?q=go&limit=10 ---
q := r.URL.Query().Get("q") // "go"; "" — если параметра нет
limit, err := strconv.Atoi(r.URL.Query().Get("limit"))
if err != nil || limit <= 0 {
limit = 20 // параметры — ВСЕГДА строки; конвертация и дефолты — ваша забота
}
// --- Заголовки (регистронезависимые) ---
_ = r.Header.Get("User-Agent")
_ = r.Header.Get("Authorization")
// --- Тело ---
// r.Body — это io.ReadCloser: ПОТОК, читается один раз, второй раз не перечитать.
body, _ := io.ReadAll(r.Body)
// Закрывать r.Body в хендлере не обязательно — сервер закроет сам.
// (В http.Client — наоборот, обязательно! См. 1.12.)
// --- HTML-форма ---
name := r.FormValue("name") // сам вызовет ParseForm
_ = q; _ = limit; _ = body; _ = name
}
1.8 Пишем ответ: порядок имеет значение
ResponseWriter — не буфер, который можно редактировать, а почти прямой канал в сокет. Отсюда железный порядок:
func handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") // 1. заголовки (копятся в мапе)
w.WriteHeader(http.StatusCreated) // 2. статус — тут заголовки уходят в сеть
w.Write([]byte(`{"ok":true}`)) // 3. тело
}
Нарушения порядка — классика первых багов:
w.Header().SetпослеWriteHeader— заголовок молча игнорируется: он уже уехал.WriteHeaderдважды (например,500после того как что-то записали) — в лог падаетhttp: superfluous response.WriteHeader call, клиент получает первый статус.- Не вызвали
WriteHeaderвовсе — первый жеw.Writeнеявно отправит200 OK. Поэтому для обычного успешного ответаWriteHeader(200)писать не нужно.
Отсюда правило хорошего хендлера: вычислили ответ полностью → отправили одним движением в конце. Используйте константы (http.StatusNotFound), а не магические числа.
Для ошибок есть шорткаты:
http.Error(w, "user not found", http.StatusNotFound) // статус + text/plain тело
http.NotFound(w, r)
И внимание: http.Error — обычная функция, она не прерывает хендлер! Забытый return после неё — и код продолжает выполняться, дописывая «успешный» ответ поверх ошибки. Это ошибка №1 всех новичков.
1.9 JSON: хлеб насущный API
type CreateUserReq struct {
Name string `json:"name"` // теги задают имена полей в JSON
Email string `json:"email"` // (без тега поле выйдет как "Name" — с большой буквы)
}
type User struct {
ID int64 `json:"id"`
Name string `json:"name"`
Email string `json:"email,omitempty"` // omitempty: пустое поле не выводить
}
func createUser(w http.ResponseWriter, r *http.Request) {
// Защита от гигантского тела: читаем максимум 1 МБ
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
var req CreateUserReq
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields() // неизвестное поле = ошибка, а не молчаливый пропуск
if err := dec.Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "невалидный JSON: "+err.Error())
return // ← не забываем!
}
// валидация — руками или библиотекой (go-playground/validator)
if req.Name == "" || !strings.Contains(req.Email, "@") {
writeError(w, http.StatusUnprocessableEntity, "name и email обязательны")
return
}
user := User{ID: 1, Name: req.Name, Email: req.Email}
writeJSON(w, http.StatusCreated, user)
}
// Хелперы, которые вы напишете в каждом сервисе:
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
log.Printf("writeJSON: %v", err) // писать в w уже поздно — статус ушёл
}
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
Приёмы, отличающие продакшен-код от туториала: MaxBytesReader (иначе клиент пришлёт 10 ГБ и уронит вас по памяти), DisallowUnknownFields (ловит опечатки в полях на клиенте), ранний return после каждой ошибки.
1.10 Сервер и таймауты: то, что все забывают
srv := &http.Server{
Addr: ":8080",
Handler: mux,
// Без этих таймаутов медленный/злонамеренный клиент держит
// соединение (и горутину, и файловый дескриптор) вечно — Slowloris-атака.
ReadHeaderTimeout: 5 * time.Second, // на чтение заголовков
ReadTimeout: 10 * time.Second, // на чтение всего запроса
WriteTimeout: 30 * time.Second, // на запись ответа
IdleTimeout: 120 * time.Second, // keep-alive простой
MaxHeaderBytes: 1 << 20, // 1 МБ на заголовки
}
log.Fatal(srv.ListenAndServe())
http.ListenAndServe(":8080", mux) без явного Server — это дефолты без единого таймаута. Для локальной игры ок, для продакшена — дыра. Всегда создавайте http.Server явно. Бонус: у явного srv есть метод Shutdown(ctx) — тот самый graceful shutdown из гайда по конкурентности.
1.11 Middleware: паттерн-луковица
Middleware — функция, оборачивающая хендлер и возвращающая новый хендлер: func(http.Handler) http.Handler. Между «принять запрос» и «вызвать бизнес-логику» она может делать что угодно: логировать, проверять авторизацию, ловить паники. Никакой магии — вот где выстреливает понимание HandlerFunc из 1.4:
// Логирование: метод, путь, статус, длительность.
func logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// ResponseWriter не отдаёт записанный статус — оборачиваем
lw := &loggingWriter{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(lw, r) // передаём управление внутрь луковицы
log.Printf("%s %s → %d за %s", r.Method, r.URL.Path, lw.status, time.Since(start))
})
}
type loggingWriter struct {
http.ResponseWriter // встраивание: все методы наследуются...
status int
}
func (lw *loggingWriter) WriteHeader(code int) { // ...а этот — перехватываем
lw.status = code
lw.ResponseWriter.WriteHeader(code)
}
// Восстановление после паники: одна кривая ручка не должна ронять процесс.
func recoverer(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
log.Printf("panic: %v\n%s", rec, debug.Stack())
http.Error(w, "internal error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
// Сборка: читается снизу вверх — запрос идёт recoverer → logging → mux
handler := recoverer(logging(mux))
Схема исполнения — луковица: код до next.ServeHTTP выполняется на пути запроса внутрь, код после — на пути ответа наружу:
запрос → [recoverer до] → [logging до] → хендлер → [logging после] → [recoverer после] → ответ
Через middleware делается всё сквозное: аутентификация, CORS, rate limiting, сжатие, trace ID. Данные из middleware в хендлер передаются через контекст запроса:
type userKey struct{} // непубличный тип ключа — никто снаружи не подменит
func auth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, err := parseToken(r.Header.Get("Authorization"))
if err != nil {
writeError(w, http.StatusUnauthorized, "невалидный токен")
return
}
ctx := context.WithValue(r.Context(), userKey{}, user)
next.ServeHTTP(w, r.WithContext(ctx)) // r иммутабелен — создаём копию с новым ctx
})
}
// в хендлере:
user, ok := r.Context().Value(userKey{}).(*User)
1.12 r.Context(): отмена от клиента
Контекст запроса отменяется автоматически, когда клиент разрывает соединение (закрыл вкладку, оборвалась сеть) или сервер начал Shutdown. Передавайте его во все нижележащие вызовы — БД, HTTP-клиенты, очереди:
func getReport(w http.ResponseWriter, r *http.Request) {
// клиент закрыл вкладку → ctx отменён → БД прервёт тяжёлый запрос,
// вместо того чтобы молотить его в пустоту
report, err := db.BuildHeavyReport(r.Context())
if err != nil {
if errors.Is(err, context.Canceled) {
return // клиента уже нет, отвечать некому
}
writeError(w, http.StatusInternalServerError, "не удалось построить отчёт")
return
}
writeJSON(w, http.StatusOK, report)
}
Не передали ctx в базу — и после ухода клиента сервер продолжит крутить тяжёлый SQL «в пустоту»; под нагрузкой это способ самоубийства сервиса. Это та самая механика распространения отмены из мира context — веб-сервер даёт её бесплатно, надо лишь не обрывать цепочку.
1.13 http.Client: как ходить наружу самим
Сервер — половина дела; вторая половина — быть клиентом чужих API:
// НИКОГДА: http.Get(url) — это DefaultClient БЕЗ ТАЙМАУТА.
// Зависший апстрим = зависшая навсегда горутина.
client := &http.Client{
Timeout: 10 * time.Second, // общий предохранитель на весь запрос
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10, // дефолт 2 — душит пул при работе с одним хостом!
IdleConnTimeout: 90 * time.Second,
},
}
func fetchJSON(ctx context.Context, url string, dst any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("запрос %s: %w", url, err)
}
defer resp.Body.Close() // ОБЯЗАТЕЛЬНО, иначе утечка соединений
if resp.StatusCode != http.StatusOK {
// err от Do здесь nil! 404 и 500 — это УСПЕШНО доставленные ответы;
// ошибку Do возвращает только на сетевых проблемах. Статус проверяем сами.
// Тело вычитываем и бросаем, чтобы соединение вернулось в keep-alive пул:
io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10))
return fmt.Errorf("%s: статус %d", url, resp.StatusCode)
}
return json.NewDecoder(resp.Body).Decode(dst)
}
Три железных правила клиента, нарушаемые в 90% туториалов: таймаут всегда (через client.Timeout и/или ctx); defer resp.Body.Close() всегда, даже если тело не нужно; err == nil не значит успех — проверяй статус. И клиент, как и сервер, потокобезопасен: создаётся один раз и переиспользуется — не плодите его на каждый запрос, убьёте пул соединений.
1.14 Тестирование: httptest
Стандартная библиотека делает тестирование HTTP тривиальным — сетевые сокеты не нужны:
func TestCreateUser(t *testing.T) {
body := strings.NewReader(`{"name":"Ann","email":"ann@example.com"}`)
req := httptest.NewRequest(http.MethodPost, "/users", body)
rec := httptest.NewRecorder() // фейковый ResponseWriter, пишущий в память
createUser(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("статус = %d, ждали 201; тело: %s", rec.Code, rec.Body)
}
var u User
if err := json.NewDecoder(rec.Body).Decode(&u); err != nil {
t.Fatalf("невалидный JSON в ответе: %v", err)
}
if u.Name != "Ann" {
t.Errorf("Name = %q, ждали Ann", u.Name)
}
}
// А для тестов клиентского кода — httptest.Server: настоящий сервер на случайном порту.
func TestFetchJSON(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"ok":true}`)
}))
defer ts.Close()
var out struct{ OK bool `json:"ok"` }
if err := fetchJSON(context.Background(), ts.URL, &out); err != nil {
t.Fatal(err)
}
}
httptest.NewRecorder тестирует хендлеры как чистые функции; httptest.NewServer поднимает реальный сервер для тестов клиентов. Это одна из причин, почему в Go так легко жить без моков HTTP.
---
2. chi/v5
2.1 Философия: 100% net/http
go get github.com/go-chi/chi/v5
chi выбирают за одно свойство: он не изобретает свои типы. Хендлер chi — это http.HandlerFunc. Middleware chi — это func(http.Handler) http.Handler. Никаких gin.Context и собственных экосистем: всё, что написано для net/http (включая главу 1 целиком), работает в chi без адаптеров, и наоборот. Плюс роутер на radix-дереве — быстрый матчинг без аллокаций, вложенные роутеры, группы и богатый набор готовых middleware.
Когда chi вместо голого ServeMux 1.22: когда нужны цепочки middleware на группы роутов, монтирование саб-роутеров, роут-параметры в middleware, версионирование API. То есть — почти в любом сервисе крупнее «трёх ручек».
2.2 Базовый сервис
package main
import (
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
func main() {
r := chi.NewRouter()
// Стандартный productionный набор — порядок имеет значение!
r.Use(middleware.RequestID) // X-Request-Id в контекст
r.Use(middleware.RealIP) // IP из X-Forwarded-For / X-Real-IP
r.Use(middleware.Logger) // лог запросов (в проде замените на slog-мидлварь)
r.Use(middleware.Recoverer) // паника → 500, стектрейс в лог
r.Use(middleware.Timeout(30 * time.Second)) // дедлайн в ctx запроса
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
})
r.Route("/api/v1", func(r chi.Router) {
r.Mount("/users", usersRouter())
})
srv := &http.Server{ // все правила из главы 1 в силе: таймауты, graceful shutdown
Addr: ":8080",
Handler: r,
ReadHeaderTimeout: 5 * time.Second,
}
srv.ListenAndServe()
}
r.Use регистрирует middleware до первого роута — chi паникует, если добавлять их после (это защита от неочевидного «половина роутов с мидлварью, половина без»).
2.3 URL-параметры и саб-роутеры
func usersRouter() chi.Router {
r := chi.NewRouter()
r.Get("/", listUsers) // GET /api/v1/users
r.Post("/", createUser) // POST /api/v1/users
// Вложенный роутер на конкретного пользователя
r.Route("/{userID}", func(r chi.Router) {
r.Use(userCtx) // middleware ТОЛЬКО для этого поддерева:
// достаёт пользователя из БД один раз
r.Get("/", getUser) // GET /api/v1/users/42
r.Put("/", updateUser)
r.Delete("/", deleteUser)
r.Get("/orders", listUserOrders)
})
return r
}
// userCtx: параметр пути → загрузка из БД → в контекст.
// Хендлеры ниже уже не думают ни про парсинг ID, ни про 404.
func userCtx(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "userID"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "userID должен быть числом")
return
}
user, err := store.GetUser(r.Context(), id)
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "пользователь не найден")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "ошибка БД")
return
}
ctx := context.WithValue(r.Context(), userKey{}, user)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func getUser(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value(userKey{}).(*User) // уже гарантированно есть
writeJSON(w, http.StatusOK, user)
}
Паттерн «ресурсный middleware» (userCtx) — визитная карточка chi: загрузка и проверка ресурса выносится из N хендлеров в одно место. Хендлеры становятся тремя строчками бизнес-логики.
Синтаксис параметров: {userID} — сегмент, {id:[0-9]+} — сегмент с regex-валидацией прямо в паттерне, /files/ — wildcard-хвост (достаётся через chi.URLParam(r, "")).
2.4 Группы: разные middleware без разных путей
Route создаёт под-путь, а Group — просто группу с общими middleware на том же уровне путей. Классика — публичные и защищённые ручки:
r.Route("/api/v1", func(r chi.Router) {
// публичное
r.Group(func(r chi.Router) {
r.Post("/login", login)
r.Post("/register", register)
})
// под JWT
r.Group(func(r chi.Router) {
r.Use(jwtAuth)
r.Get("/profile", getProfile)
r.Mount("/users", usersRouter())
})
// под JWT + только для админов
r.Group(func(r chi.Router) {
r.Use(jwtAuth, adminOnly)
r.Delete("/users/{userID}", forceDeleteUser)
})
})
2.5 Полезные штатные middleware
| Middleware | Что делает | |---|---| | middleware.RequestID | генерирует/пробрасывает ID запроса | | middleware.RealIP | настоящий IP клиента за прокси | | middleware.Recoverer | ловит панику → 500 | | middleware.Timeout(d) | ctx с таймаутом (хендлер обязан его уважать!) | | middleware.Compress(5) | gzip/deflate ответов | | middleware.Throttle(100) | не более N одновременных запросов (семафор!) | | middleware.Heartbeat("/ping") | лёгкий health-эндпоинт до всякой логики | | middleware.CleanPath, StripSlashes | нормализация путей | | middleware.BasicAuth | простая basic-auth защита |
Отдельно живут github.com/go-chi/cors и github.com/go-chi/httprate (rate limiting по IP/ключу) — тоже официальные.
Заметьте Throttle — это буквально семафор на буферизованном канале, надетый на HTTP. А Timeout лишь кладёт дедлайн в контекст: если хендлер не передаёт r.Context() в БД и клиенты, таймаут ничего не прервёт. Middleware не магия — это ваши же паттерны конкурентности, расставленные по луковице.
2.6 Кастомные 404/405 и обход роутов
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "нет такой ручки")
})
r.MethodNotAllowed(func(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusMethodNotAllowed, "метод не поддерживается")
})
// Пробежаться по всем зарегистрированным роутам (генерация доков, smoke-тесты):
chi.Walk(r, func(method, route string, h http.Handler, mw ...func(http.Handler) http.Handler) error {
fmt.Printf("%-7s %s\n", method, route)
return nil
})
2.7 Тестирование chi-роутера
Поскольку chi — это http.Handler, тестируется он ровно как в 1.8, только запрос гоняем через весь роутер (проверяя заодно матчинг и middleware):
func TestGetUser(t *testing.T) {
r := buildRouter(testStore) // ваша функция сборки роутера с зависимостями
req := httptest.NewRequest(http.MethodGet, "/api/v1/users/42", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("статус %d, тело %s", rec.Code, rec.Body)
}
}
Отсюда важный вывод об архитектуре: собирайте роутер в функции, принимающей зависимости (store, логгер, конфиг), а не в init/main с глобальными переменными — тогда тесты подсовывают фейки без плясок.
---
3. pgx
3.1 Почему pgx, а не database/sql
go get github.com/jackc/pgx/v5
pgx — де-факто стандартный драйвер PostgreSQL в Go. В отличие от универсального database/sql:
- нативный бинарный протокол Postgres — быстрее и без потерь типов;
- поддержка postgres-типов из коробки: массивы, jsonb, timestamptz, numeric, inet, интервалы;
pgxpool— правильный пул соединений;- батчи (несколько запросов за один round-trip),
COPY(массовая заливка),LISTEN/NOTIFY; - человеческие ошибки:
pgconn.PgErrorс кодом SQLSTATE, а не парсинг строк.
pgx умеет работать и через database/sql (pgx/v5/stdlib) — это нужно, если библиотека требует sql.DB. Но в своём коде используйте нативный интерфейс: он лучше во всём.
3.2 Пул соединений: pgxpool
pgx.Conn — одно соединение, не потокобезопасное. В сервисе, где хендлеры летят параллельно, нужен пул:
package main
import (
"context"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func newPool(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
cfg, err := pgxpool.ParseConfig(dsn)
// dsn: "postgres://user:pass@localhost:5432/mydb?sslmode=disable"
// или в стиле keyword: "host=... user=... dbname=..."
if err != nil {
return nil, err
}
cfg.MaxConns = 10 // потолок; по умолчанию max(4, NumCPU)
cfg.MinConns = 2 // держать тёплыми
cfg.MaxConnLifetime = time.Hour // ротация соединений
cfg.MaxConnIdleTime = 30 * time.Minute
cfg.HealthCheckPeriod = time.Minute
pool, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
return nil, err
}
if err := pool.Ping(ctx); err != nil { // проверяем сразу, а не на первом запросе
pool.Close()
return nil, err
}
return pool, nil
}
Пул потокобезопасен: создаётся один раз в main, передаётся в слои хранилища, закрывается pool.Close() при shutdown (после остановки HTTP-сервера — помните порядок «от входа к выходу»). Каждый Query/Exec сам берёт соединение из пула и возвращает его.
Про размер: MaxConns — это не «чем больше, тем лучше». У Postgres соединения дороги; десятки соединений на инстанс сервиса × десятки инстансов легко упираются в max_connections сервера. Считайте от общего бюджета.
3.3 Запросы: Exec, QueryRow, Query
type Store struct {
pool *pgxpool.Pool
}
// Exec — запросы без результата (INSERT/UPDATE/DELETE/DDL).
func (s *Store) DeactivateUser(ctx context.Context, id int64) error {
tag, err := s.pool.Exec(ctx,
`UPDATE users SET active = false WHERE id = $1`, id)
if err != nil {
return fmt.Errorf("deactivate user %d: %w", id, err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound // UPDATE «прошёл», но никого не задел — это часто бизнес-ошибка
}
return nil
}
// QueryRow — ровно одна строка.
func (s *Store) GetUser(ctx context.Context, id int64) (*User, error) {
var u User
err := s.pool.QueryRow(ctx,
`SELECT id, name, email, created_at FROM users WHERE id = $1`, id,
).Scan(&u.ID, &u.Name, &u.Email, &u.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound // доменная ошибка вместо драйверной
}
if err != nil {
return nil, fmt.Errorf("get user %d: %w", id, err)
}
return &u, nil
}
Заповеди:
- Только плейсхолдеры
$1, $2...для данных. Конкатенация строк = SQL-инъекция, без исключений. (Имена таблиц/колонок плейсхолдером передать нельзя — если они динамические, валидируйте по белому списку.) - ctx — в каждый вызов. Отмена запроса клиентом или таймаут реально прервут запрос на стороне Postgres.
pgx.ErrNoRows— не авария, а «не найдено»; конвертируйте в доменную ошибку на границе слоя хранилища, чтобы хендлеры не импортировали pgx.
3.4 Множество строк: Query и щедрый сахар CollectRows
Классический способ:
func (s *Store) ListActive(ctx context.Context) ([]User, error) {
rows, err := s.pool.Query(ctx,
`SELECT id, name, email, created_at FROM users WHERE active ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close() // обязательно: возвращает соединение в пул
var users []User
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Name, &u.Email, &u.CreatedAt); err != nil {
return nil, err
}
users = append(users, u)
}
return users, rows.Err() // ошибки итерации всплывают ЗДЕСЬ — не забывать!
}
rows.Err() после цикла обязателен: обрыв соединения посреди выборки не вернёт ошибку из Next() (он просто вернёт false), она ждёт вас в Err(). Забытая проверка = молча усечённые данные.
pgx v5 добавил дженерик-хелперы, убирающие всю эту шарманку:
// Строки → структуры по именам полей (сопоставление name → Name, created_at → CreatedAt)
users, err := pgx.CollectRows(rows, pgx.RowToStructByName[User])
// Ровно одна строка:
u, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[User])
// Одна колонка в срез:
ids, err := pgx.CollectRows(rows, pgx.RowTo[int64])
// В одну строку целиком:
users, err := pgx.CollectRows(
mustQuery(s.pool.Query(ctx, `SELECT ... FROM users`)),
pgx.RowToStructByName[User],
)
RowToStructByName требует совпадения набора колонок и полей (защита от рассинхрона запроса и структуры), RowToStructByNameLax разрешает лишние поля в структуре. Эти хелперы закрывают 90% болей, ради которых люди тащили ORM.
3.5 Транзакции
func (s *Store) TransferMoney(ctx context.Context, from, to int64, amount int64) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
// Страховка: если выйдем раньше Commit (ошибка, паника) — откат.
// Rollback после успешного Commit безвреден: вернёт ErrTxClosed, игнорируем.
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx,
`UPDATE accounts SET balance = balance - $1 WHERE id = $2 AND balance >= $1`,
amount, from); err != nil {
return fmt.Errorf("списание: %w", err)
}
if _, err := tx.Exec(ctx,
`UPDATE accounts SET balance = balance + $1 WHERE id = $2`,
amount, to); err != nil {
return fmt.Errorf("зачисление: %w", err)
}
return tx.Commit(ctx)
}
Паттерн defer tx.Rollback(ctx) сразу после Begin — обязательный рефлекс: любой ранний return безопасен, транзакция не повиснет. Всё внутри транзакции выполняется через tx, не через pool — иначе запрос уедет в другое соединение и окажется вне транзакции (тихий и страшный баг).
Для «выполни функцию в транзакции с авто-commit/rollback» есть хелпер:
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
// return nil → Commit; return err или паника → Rollback
...
return nil
})
3.6 Ошибки Postgres: pgconn.PgError
Уникальные индексы и констрейнты — это не «ошибка БД», это бизнес-события. Различаем их по SQLSTATE:
import "github.com/jackc/pgx/v5/pgconn"
func (s *Store) CreateUser(ctx context.Context, name, email string) (int64, error) {
var id int64
err := s.pool.QueryRow(ctx,
`INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id`,
name, email).Scan(&id)
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
switch pgErr.Code {
case "23505": // unique_violation
return 0, ErrEmailTaken
case "23503": // foreign_key_violation
return 0, ErrRelatedMissing
}
}
if err != nil {
return 0, fmt.Errorf("create user: %w", err)
}
return id, nil
}
Обратите внимание на RETURNING id — постгресовый способ получить сгенерированный ключ одним запросом, вместо двух round-trip'ов.
3.7 Batch: пачка запросов за один round-trip
Сетевая задержка × количество запросов — главный убийца производительности «болтливого» кода. Batch склеивает запросы:
func (s *Store) GetDashboard(ctx context.Context, userID int64) (*Dashboard, error) {
b := &pgx.Batch{}
b.Queue(`SELECT id, name, email, created_at FROM users WHERE id = $1`, userID)
b.Queue(`SELECT id, total, status FROM orders WHERE user_id = $1 ORDER BY id DESC LIMIT 10`, userID)
b.Queue(`SELECT count(*) FROM notifications WHERE user_id = $1 AND NOT read`, userID)
res := s.pool.SendBatch(ctx, b) // ОДИН поход в сеть
defer res.Close() // обязательно, в порядке очереди
var d Dashboard
var err error
if d.User, err = pgx.CollectOneRow(mustQ(res.Query()), pgx.RowToStructByName[User]); err != nil {
return nil, err
}
if d.Orders, err = pgx.CollectRows(mustQ(res.Query()), pgx.RowToStructByName[Order]); err != nil {
return nil, err
}
if err = res.QueryRow().Scan(&d.UnreadCount); err != nil {
return nil, err
}
return &d, nil
}
Результаты вычитываются строго в порядке постановки в очередь. Batch внутри транзакции (tx.SendBatch) — тоже работает.
3.8 CopyFrom: массовая вставка
Вставить 100 000 строк INSERT'ами — минуты; протоколом COPY — секунды:
rows := make([][]any, 0, len(events))
for _, e := range events {
rows = append(rows, []any{e.UserID, e.Type, e.Payload, e.At})
}
n, err := s.pool.CopyFrom(ctx,
pgx.Identifier{"events"}, // таблица
[]string{"user_id", "type", "payload", "at"}, // колонки
pgx.CopyFromRows(rows),
)
// n — сколько строк залито
Для стриминга без материализации среза есть pgx.CopyFromFunc / собственная реализация CopyFromSource.
3.9 LISTEN/NOTIFY: пуш-события из БД
Postgres умеет присылать события — удобно для инвалидации кешей и лёгкого pub/sub без брокера:
func listenEvents(ctx context.Context, pool *pgxpool.Pool) error {
conn, err := pool.Acquire(ctx) // выделенное соединение — LISTEN живёт на соединении
if err != nil {
return err
}
defer conn.Release()
if _, err := conn.Exec(ctx, `LISTEN user_events`); err != nil {
return err
}
for {
n, err := conn.Conn().WaitForNotification(ctx) // блокируется до события или отмены ctx
if err != nil {
return err // ctx отменён при shutdown — штатный выход
}
log.Printf("канал=%s payload=%s", n.Channel, n.Payload)
}
}
// Слать из SQL: NOTIFY user_events, '{"user_id":42}';
// или из триггера: PERFORM pg_notify('user_events', row_to_json(NEW)::text);
Эта штука идеально стыкуется с WebSocket из следующей главы: БД нотифицирует сервис, сервис рассылает клиентам.
---
4. WebSocket
4.1 Что это и когда нужно
HTTP — запрос-ответ: сервер молчит, пока его не спросят. WebSocket — постоянное двунаправленное соединение поверх TCP: любая сторона пишет в любой момент. Нужен для чатов, нотификаций, live-дашбордов, курсов/котировок, многопользовательских редакторов, игр.
Протокол начинается как HTTP: клиент шлёт GET с заголовками Upgrade: websocket, Sec-WebSocket-Key; сервер отвечает 101 Switching Protocols — и дальше по тому же TCP-соединению летают уже не HTTP-запросы, а фреймы: текстовые, бинарные и служебные (ping/pong/close).
В стандартной библиотеке Go веб-сокетов нет. Два основных пакета:
github.com/gorilla/websocket— ветеран, огромная база примеров, низкоуровневый контроль. Возьмём его за основу — на нём проще всего показать механику.github.com/coder/websocket(бывший nhooyr.io/websocket) — современный, компактный API с ctx во всех операциях, поддержка Wasm. Хороший выбор для нового кода; всё концептуальное из этой главы применимо и к нему.
go get github.com/gorilla/websocket
4.2 Upgrade: превращаем HTTP в WebSocket
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
// ЗАЩИТА ОТ CSWSH (cross-site WebSocket hijacking):
// браузер шлёт Origin, и мы обязаны проверить, что это НАШ сайт.
// Вернуть true не глядя = любой чужой сайт сможет открыть сокет
// от имени залогиненного пользователя (куки уедут автоматически!).
CheckOrigin: func(r *http.Request) bool {
return r.Header.Get("Origin") == "https://myapp.example.com"
},
}
func wsHandler(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
// Upgrade сам ответил клиенту ошибкой — просто логируем
log.Println("upgrade:", err)
return
}
defer conn.Close()
// эхо-сервер: читаем сообщение — отправляем обратно
for {
msgType, data, err := conn.ReadMessage()
if err != nil {
// сюда попадаем и при штатном закрытии клиентом
if websocket.IsUnexpectedCloseError(err,
websocket.CloseGoingAway, websocket.CloseNormalClosure) {
log.Println("read:", err)
}
return
}
if err := conn.WriteMessage(msgType, data); err != nil {
return
}
}
}
Аутентификация: браузерный WebSocket API не позволяет ставить произвольные заголовки, так что Authorization: Bearer ... не пройдёт. Варианты: кука сессии (едет автоматически — потому и критичен CheckOrigin), токен в query-параметре (попадает в логи — осторожно), или первый-же кадр после подключения с токеном внутри. Проверяйте auth до Upgrade — после 101 отвечать HTTP-статусами уже нельзя.
4.3 Золотое правило конкурентности WebSocket
Вот то, из-за чего падает большинство самописных реализаций:
> Соединение поддерживает максимум одного одновременного читателя и одного одновременного писателя. Две горутины, пишущие в один conn параллельно, — это перемешанные фреймы и паника/коррупция протокола.
А писать в соединение хотят многие: бизнес-логика шлёт сообщения, пингер шлёт ping, hub рассылает broadcast... Каноническое решение — вся запись идёт через один канал, который вычитывает единственная горутина-писатель (writePump). Никто не трогает conn напрямую. Знакомо? Это буквально «не общайтесь через разделяемую память — разделяйте память через общение».
4.4 Каноническая архитектура: Client + Hub
Схема из официального примера gorilla (chat), выдержавшая годы продакшена. Три сущности:
┌──────────────── Hub ────────────────┐
browser ⇄ readPump ─▶ inbound register/unregister │
browser ⇄ writePump ◀─ client.send ◀── broadcast ──────────┘
- readPump (горутина на клиента): единственный читатель conn; принятое отдаёт хабу.
- writePump (горутина на клиента): единственный писатель conn; берёт из канала
send, плюс шлёт ping по тикеру. - Hub (одна горутина на приложение): владеет множеством клиентов, обрабатывает register/unregister/broadcast. Поскольку с мапой клиентов работает только горутина хаба, мьютекс не нужен — синхронизация каналами.
package ws
import (
"log"
"time"
"github.com/gorilla/websocket"
)
const (
writeWait = 10 * time.Second // дедлайн на одну запись
pongWait = 60 * time.Second // ждём pong не дольше этого
pingPeriod = (pongWait * 9) / 10 // пингуем чаще, чем ждём (54s)
maxMsgSize = 4096 // лимит входящего сообщения
)
type Hub struct {
clients map[*Client]struct{}
register chan *Client
unregister chan *Client
broadcast chan []byte
}
func NewHub() *Hub {
return &Hub{
clients: make(map[*Client]struct{}),
register: make(chan *Client),
unregister: make(chan *Client),
broadcast: make(chan []byte, 256),
}
}
func (h *Hub) Run(ctx context.Context) {
for {
select {
case c := <-h.register:
h.clients[c] = struct{}{}
case c := <-h.unregister:
if _, ok := h.clients[c]; ok {
delete(h.clients, c)
close(c.send) // сигнал writePump'у клиента: закругляйся
}
case msg := <-h.broadcast:
for c := range h.clients {
select {
case c.send <- msg:
default:
// Буфер клиента полон = клиент слишком медленный.
// НЕ блокируем весь хаб из-за одного тормоза — отключаем его.
delete(h.clients, c)
close(c.send)
}
}
case <-ctx.Done(): // graceful shutdown
for c := range h.clients {
close(c.send)
}
return
}
}
}
type Client struct {
hub *Hub
conn *websocket.Conn
send chan []byte // очередь исходящих; писатель в conn — только writePump
}
func (c *Client) readPump() {
defer func() {
c.hub.unregister <- c
c.conn.Close()
}()
c.conn.SetReadLimit(maxMsgSize)
c.conn.SetReadDeadline(time.Now().Add(pongWait))
c.conn.SetPongHandler(func(string) error {
// живой клиент ответил на ping — продлеваем дедлайн чтения
c.conn.SetReadDeadline(time.Now().Add(pongWait))
return nil
})
for {
_, msg, err := c.conn.ReadMessage()
if err != nil {
return // любой разрыв → defer: unregister + close
}
c.hub.broadcast <- msg // в чате: входящее → всем
}
}
func (c *Client) writePump() {
ticker := time.NewTicker(pingPeriod)
defer func() {
ticker.Stop()
c.conn.Close() // закрытие conn разбудит readPump с ошибкой — симметричный демонтаж
}()
for {
select {
case msg, ok := <-c.send:
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
if !ok {
// хаб закрыл канал — вежливо прощаемся
c.conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
if err := c.conn.WriteMessage(websocket.TextMessage, msg); err != nil {
return
}
case <-ticker.C:
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}
// HTTP-хендлер, порождающий клиента:
func ServeWS(hub *Hub, w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
c := &Client{hub: hub, conn: conn, send: make(chan []byte, 64)}
hub.register <- c
go c.writePump()
go c.readPump()
// хендлер завершается — соединение живёт в двух горутинах клиента
}
4.5 Почему именно так: разбор решений
Ping/pong — это детектор смерти. TCP может «молча» умереть (клиент ушёл в метро, роутер перезагрузился) — без трафика вы узнаете об этом через десятки минут ОС-таймаутов, а горутины будут висеть. Схема: сервер шлёт ping каждые 54с; живой браузер отвечает pong автоматически; pong продлевает ReadDeadline на 60с. Нет понга — ReadMessage падает по дедлайну → демонтаж. Мёртвые соединения гарантированно убираются за ~минуту.
Сброс медленных клиентов (default в broadcast) — защита всей системы. Один клиент на GPRS, не успевающий читать, забьёт свой send-буфер. Блокироваться на нём — значит остановить рассылку всем. Политика «не влез в буфер — отключаем» жестока, но честна; альтернативы — копить в персональной очереди с лимитом или отправлять клиенту только последний снапшот (для дашбордов).
Демонтаж всегда симметричен. Умер readPump → unregister → хаб делает close(c.send) → writePump видит !ok → закрывает conn. Умер writePump → закрывает conn → readPump падает на чтении → unregister. Любая смерть с любой стороны сворачивает обе горутины и вычищает клиента из хаба. Проверяйте свою реализацию именно на это: «а что, если умрёт вот эта половина?»
Hub без мьютекса — сознательный выбор. Мапа клиентов конфайнится в одной горутине, все команды — через каналы. Можно и наоборот: мапа под sync.RWMutex, методы Register/Broadcast. Оба варианта валидны; канальный лучше масштабируется по сложности (легко добавить комнаты, приоритеты), мьютексный — короче для простых случаев.
4.6 Клиент на Go
WebSocket-клиенты пишут не только браузеры:
conn, resp, err := websocket.DefaultDialer.DialContext(ctx,
"wss://api.example.com/ws", http.Header{
"Authorization": []string{"Bearer " + token}, // из Go заголовки МОЖНО
})
if err != nil {
log.Fatal("dial:", err, resp)
}
defer conn.Close()
go func() { // читатель
for {
_, msg, err := conn.ReadMessage()
if err != nil {
return
}
log.Printf("← %s", msg)
}
}()
conn.WriteJSON(map[string]any{"type": "subscribe", "channel": "orders"})
WriteJSON/ReadJSON — удобные обёртки для JSON-сообщений (в проде обычно определяют типизированный конверт: {"type": "...", "payload": {...}} и switch по type).
4.7 Продакшен-заметки
- wss:// всегда. TLS обычно терминируется на балансировщике/ингрессе; проследите, чтобы прокси пробрасывал Upgrade (в nginx:
proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeoutбольше pingPeriod). - Горизонтальное масштабирование: клиенты одного «чата» окажутся на разных инстансах — хабам нужен общий шинный слой: Redis Pub/Sub, NATS или тот самый LISTEN/NOTIFY из 3.9.
- Reconnect — забота клиента: экспоненциальный backoff + повторная подписка + докачка пропущенного (храните события с ID).
- Метрики: число открытых соединений, длина send-буферов, частота сбросов медленных клиентов — три графика, по которым видно здоровье реалтайма.
---
5. Скелет сервиса
Финал: соберём все четыре главы в структуру реального сервиса — как это обычно раскладывают по файлам.
myservice/
├── cmd/server/main.go # сборка и запуск
├── internal/
│ ├── api/ # chi-роутер, хендлеры, middleware
│ │ ├── router.go
│ │ ├── users.go
│ │ └── ws.go
│ ├── store/ # pgx: всё общение с БД
│ │ └── store.go
│ └── ws/ # hub, client
│ └── hub.go
└── migrations/ # SQL-миграции (goose / golang-migrate / tern)
// cmd/server/main.go — дирижёр
func main() {
ctx, stop := signal.NotifyContext(context.Background(),
syscall.SIGINT, syscall.SIGTERM)
defer stop()
pool, err := store.NewPool(ctx, os.Getenv("DATABASE_URL")) // глава 3
if err != nil {
log.Fatal(err)
}
defer pool.Close()
st := store.New(pool)
hub := ws.NewHub() // глава 4
go hub.Run(ctx)
router := api.NewRouter(st, hub) // глава 2: роутер принимает зависимости
srv := &http.Server{ // глава 1: явный сервер с таймаутами
Addr: ":8080",
Handler: router,
ReadHeaderTimeout: 5 * time.Second,
}
go func() {
log.Println("listening :8080")
if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
}
}()
<-ctx.Done() // ждём сигнал
shCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shCtx); err != nil {
log.Println("shutdown:", err)
}
// порядок: HTTP остановлен → хаб получил ctx.Done() и закрыл клиентов →
// defer pool.Close() последним, когда запросов в БД уже нет
}
Зависимости текут в одну сторону: main → api → store, main → ws. store не знает про HTTP, api не знает про pgx (только про интерфейс store) — благодаря этому каждый слой тестируется отдельно: хендлеры с фейковым store через httptest, store — интеграционно на Postgres в