DESAI.DEV
← ВЕРНУТЬСЯ

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=namequery-параметры: уточнения к запросу.
  • Строки ниже — заголовки (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 в

← ВСЕ ПОСТЫ