Я старый фуллстек-разработчик и не знаю слов любви, но около полугода назад при очередной итерации сервера почувствовал себя утомленным путником, который узрел нежную красоту wr-обработчика нативного net/http
! Вот раньше всё было ужасно - а теперь красиво, приятно читать и интересно показать! За несколько месяцев я переделал свои сотни обработчиков на новый стиль - и всё еще доволен! Почистил авгиевы конюшни слоев логики - теперь там царит запах фиалок! Также у меня была возможность посмотреть как пилят http профессионалы бэкенда - далеко не как фуллстеки, о чем тоже непременно хочется рассказать!
Для ленивых читать - пора вернуть логику в обработчики! Но я расскажу подробно о той красоте, которая скрывается за этими многими восклицательными знаками, и о том, как её можно испортить. Структура такова:
сначала чем фуллстек отличается от нативного бэкенда,
потом пройдемся по API-стилю а-ля РЕСТ,
прочтем оду нативному http-модулю, расковыряем пару болячек фреймворков,
почитаем мои слова, почему wr-обработчик хорош сразу из коробки,
и посмотрим пример того, как превратить обработчик в школьный вид "задача-дано-решение-ответ".
Фуллстек vs нативный бэкенд
Статья про "клиент-сервер", потому я разделяю людей на 10 типа людей: которые знают двоичную систему счисления, и которые её не знают на тех, кто готовит только бэкенд, от тех, кто этот бэкенд еще и потребляет. Я более 6 лет программирую в два ide-окна: SPA-клиент vs Go-сервер, и имею своё мнение:
Golang - это просто! он сильно проще
React+TypeScript+<...>
. В бэкенде становится больно, когда пытаешься представить насколько дорого падает сервер - то есть к этому моменту у тебя уже многомиллионный бюджет, а на фронтенде у тебя проблемы уже, когда ты хочешь вывести "Hello, world!" через JS с добавкой CSS.Я настоятельно рекомендую попробовать
React+TypeScript+useSWR+<...>
. React позволяет писать фронт немного похоже на привычное гоферу - и даже не лезть в классы. TypeScript помогает гоферу выжить в js, а также поддерживать порядок API-интерфейсов настолько, что можно не загоняться отдельным сайтом api-документации (пока квалификация в команде близка по уровню). UseSWR - шикарная штука, которая позволяет удобно получать и обновлять состояние компонента.Следующая итерация - маршрутизация и роуты. Для каких-то бэкендеров может оказаться откровением, но фронтенд имеет свой роутер (react-router) и path-роуты - они отвечают за то, какие компоненты показывать при текущем url (то есть path != url). Это весьма отдельная тема, но важно, что роутер маппится и работает похоже на роутер в golang. Из этого следует, что единственный способ избежать шизофрении фуллстеку - соблюдать примерно зеркальную структуру обеих частей системы, и делать бизнес-фичи типа перекидывать эспандер из руки в руку!
Из вышесказанного выходит, что фуллстек-разработка для маленьких команд, где квалификация примерно похожая, и возможно это про RnD, где разрабатывается принципиальное решение, которое потом еще пилить и пилить. Из плюсов - говнокодить в свою песочницу моветон, потому нарастает приличная экспертиза в код-стайл.
Так как фронт тащить тяжелее, то пляшу от него. У меня сервер делает то, что надо сайту, а не наоборот!
Нативный го-бэкенд смотрит свысока на сайтостроение, потому что сайту запрещено стучаться в базу напрямую, а редкая ошибка сайта крашит опыт нескольких клиентов, а не весь бизнес - так фронтенд априори понижен в правах. При этом гармонично организовать вложение нескольких десятков бизнес-контекстов со сменой состояний - это вам не хухры-мухры! А для гофера эта вертикально ориентированная структура становится плоско горизонтальной!
Фуллстек против а-ля РЕСТ
У меня много чего есть, над чем можно подумать, поэтому "как упростить" я думаю часто и с удовольствием. Дорогие для сервера сортировку и фильтрацию я могу положить в клиенте. Сложные соединения данных остаются в базе данных, где им и место. Гоферу остается красиво валидировать входящие данные и сделать какие-то запросы в бд оптимально параллельными - здесь про http, конечно, ведь есть и другие интересные темы.
Выбор структуры API более важен сайтостроителю из-за сложного управления состоянием. Бэкенду обработчики в стройном горизонтальном ряду и без состояния - дешевое изменение, и пофиг, пока сервер не плюется ошибками в логи. Потому пусть фронтендер определит свой удобный API для логики своего приложения.
API "а-ля РЕСТ" хорош только для сайта типа википедии. В обычном бизнесе много ролей (например, три), несколько состояний сущности (например, три), и несколько операций (например, три). Это порождает на минуточку от 3 до 27 запросов в сервер. И когда это всё впихивают в
GET/POST/PUT/PATCH/DELETE /v1/entity/:id
- то только фейспалм и тихая скорбь!В продолжение добавлю, что по сути fetch-запросы - это удаленные вызовы процедур, то есть к http-запросам ДОЛЖНЫ ПРИМЕНЯТЬСЯ обычные правила - наименования функций должны отображать исполняемое действие и описывать возвращаемые данные. Причем вспомним правило "чем дальше использование от объявления, тем длиннее должно быть название" - куда уж дальше?!
Обновление состояния на фронте - это боль. Сейчас распространен подход "один источник состояния" - если я получаю данные из одного fetch, то и обновления получаю через него. А "быстрые" обновления заплатками после PATCH-метода мимо основного fetch-источника очень быстро надоедают. Удобно получать одну большую структуру для всей страницы, и при любом изменении не шить заплатки, а обновлять весь пакет. Это снимает кучу головняка - прямо холодный компресс на воспаленный лоб. И хотя сильно отличается от обычного подхода "один запрос = маленький кусочек данных", но работает! проверено! а все потому что тесная связь фронта и бэка порождает самодокументируемый код.
Сильные связи между частями системы имеют свои плюсы. Слабые связи означают, что сложность и связность переносится из кода в документацию проекта, - и не всем это дается! потому связи покрываются пылью только в голове разработчика - так рождается легаси! Сильная связь означает, что при изменении хочешь-нет, но придется поменять и связанную часть. Потому монолит рулит!
В продолжение - не то, чтобы я топлю только за монолит, но это необходимая стадия жизненного цикла проекта до распределенных сервисов и микросервисов. Эволюция не прыгает через ступеньки!
Рекомендую бэкендеру изучить работу роутера на стороне фронта (написать свой сайт). Как писал выше, работа роутеров схожа: роуты раскладываются в мапу + вызов функции. Но на фронтенде через обвес маршрутизатора реализуется очень много логики - может быть много уровней вложения, и узлы маршрутов имеют состояние. На бэкенде всё скучно и плоско, но знание react-routera хорошо обогатит понимание роутера на бэкенде и поможет "понять-и-простить" фронтендеров - они вынуждены реализовывать явную бизнес-логику через неявную логику контекстов.
Добавлю, что древовидная структура маршрутизатора должна всегда оставаться древовидной! Машина разложит роуты в мапу при компиляции. Но для дебага без мата не должно быть одинаковых префиксов пути в разных модулях: все пути
/v1/users/*
должны быть только в модулеusers
, и никаких роутов с таким префиксом в модуляхauth
,reports
и других.
Из зоопарка наблюдений:
GET /v1/users/:id
- это плохо! в списке запросов инструментов браузера в строке будет показан одинокий нечитабельный айдишник, а полный путь с методом отображается только по дополнительному клику в отдельной вкладке .GET /v1/users
- это плохо! префикс секции или обработчик? и непонятно, где искать роут и обработчик: в корневом маршрутизаторе или ниже уровнем.GET /v1/users/users
- это плохо! букваs
очень слабо отличает массив от одиночной структуры. В списке запросов гораздо приятнее обнаружить "userlist" или "list-of-users". Названия должны четко царапать глаз!минусом является большое количество переименований, чтобы наименование хорошо отображало действие/ответ.
Мой выбор:
Никаких идентификаторов
:id
в составе пути - ниже будет раскрыто подробнее."Одна страница = один набор данных" - обновление состояния через один источник.
"Разная выдача для разных полномочий = разные обработчики", иначе головняк.
"Одна кнопка = один обработчик" (или два действия "создать/обновить" для PUT-запроса). Если в один обработчик положить много вариантов действий, то количество кода не уменьшается, а просто выбор ветки действий прячется на уровень ниже - и появляется равноценная сложность на фронте. Поэтому хорошо разделять по наименованию обработчика - больше строк маршрутизатора, но нам доступны вложенные секции для управления сложностью.
Наименования http-роута максимально отображает действие/полномочия/результат - не надо экономить на буквах пути! Экономьте на длине jwt-токена, а название запроса пусть будет полным - напрямую влияет на использование/тестирование/дебаг в проекте.
Специфика моего проекта
В своём проекте я решал задачу местечкового самоуправления на базе реестра недвижимости и объединения сообществ в более крупные структуры. Сложность заключается не в количестве и глубине фич, а скорее в большом разнообразии типов субъекта запроса - часть действий доступна без регистрации, часть с суперполномочиями и основная масса с дополнительным разделением в 3 уровня: пользователь лично, с доверенностью от физлица и от другого сообщества (здесь еще пара вариантов!) плюс наличие атрибута (например, собственник/резидент) или статуса жизненного цикла сущности. Или коротенько:
невозможен RBAC (управление доступом по ролям) на уровне мидлвари, когда до обработчика дело не доходит вообще, так как нужны роль в конкретном документе и его статус жизненного цикла, который присутствует только в логике,
полноценный ABAC (управление доступом по атрибутам) в скриптах сразу вычеркиваем - пользователи системы неквалифицированные, и нет бюджета на специально обученных людей для тюнинга доступа,
но нужны и роли в сообществах, и проверка доступа со сложной логикой и
так я выбрал зашивать правила доступа жестко в код!
Поэтому имеем:
три ярких уровня обогащения субъекта запроса атрибутами, - на которых я пробовал построить маршрутизатор, но херня получается, поэтому
сейчас десяток+ нормально так связанных модулей бизнес-фич и
несколько сотен обработчиков, которые менялись лавинообразно при каждой новой итерации системы разделения доступа.
То есть я ОЧЕНЬ-ОЧЕНЬ ЧАСТО менял логику обработчиков, и очень редко мидлвари.
Нативный http - наше всё!
Я пробовал пару http-фреймворков на старте, но быстро отказался в сторону стандартного модуля net/http
. Потому что:
я встретил слова на Хабре в пользу нативного "вы просто не умеете его готовить!", и теперь я смело подписываюсь под этими словами;
фреймворки искажают, и вместо постепенного изучения хардкорного http вы будете погружаться в его особое видение другими людьми;
новая зависимость в проекте станет фильтром для новых людей в команде;
фреймворки делают магию! возникает новое пространство для изумительных ошибок. Например, мидлвари echo-сервера используют свой контекст, который не наследуется обработчиком. И потребуются дополнительные обвязки, чтобы пробросить расшифрованный в мидлваре токен в контекст обработчика.
если фреймворк делает магию из внутреннего вызова типа
use_cases.GetList(id string) ([]*User, error)
сразу в http-ответ, то у вас большие проблемы с разнообразием http-ответов, потому что последние требуют не только оригинальной логики, но и доступа к http.ResponseWriter.
На старте была претензия к нативному модулю (и она склоняла к фреймворку, но вылечилась хелпером) заключалась в том, что были некрасивые конструкции вида:
func GetUsers(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" {
// ...логика
}
}
// или
func GetUsers(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
// ...логика
default:
http.Error(w, "метод не поддерживается", 405)
}
}
И с какой-то версии мы может писать вместе с методом вида GET /users
, уживаясь с недостатком, что всё остальное становится "404 Not Found".
Великолепный func(w http.ResponseWriter, r *http.Request)
На первых порах из-за гофер-деформации представляется, что раз функция не возвращает ошибку - это неправильная функция и она делает неправильный мёд, и хочется немедленно привести ее в стандартный вид: из обработчика мы вызываем правильную функцию с результатом (*data, error)
. При этом уже возникает холивар на тему, где должна лежать "правильная функция" и кого она может вызывать - слои use_cases
, entity
или сразу store
.
Следствием такой "нормализации" вкупе с тем, что часть логики уходит в мидлвари (например, rbac-авторизация и парсинг входящих данных), является то, что слой обработчиков стремительно деградирует до одинокого вызова в следующий слой. А причиной, на мой взгляд, является непонимание особенного места http-слоя - это и есть бизнес-логика. Важный слой, где соединяются задача, проверка, решение и ответ.
И далее попытаюсь раскрыть тему. Для начала я опишу чем хорош http.Handler
:
У обработчика особенная сигнатура
(w,r)
, и он ничего не возвращает - И ЭТО ВОСТОРГ! Обработчикfunc(w http.ResponseWriter, r *http.Request) {}
НЕВОЗМОЖНО ПЕРЕИСПОЛЬЗОВАТЬ в другом слое и бессмысленно в своем - ни разу не помню одинаковых обработчиков.Обработчик - это "вещь в себе", а не приложение к слою
use_cases
. Совокупность обработчиков - это и есть http-сервер, который включает ВСЮ ЛОГИКУ обслуживания внешнего клиента. Писать логику в http-обработчиках - добро, потому что они решают оригинальные задачи и используют собственные абстракции.Обработчики - обособленный внешний слой, который может стучаться в нижележащие слои. Последнее не особо очевидно, когда всё приложение и является http-сервером - вызовы "один-к-одному". Но если в коде проекта кроме http уживаются асинхронные worker и cron, то особенная логика слоя обработчиков становится более очевидной.
В нативном обработчике не доступны мидлвари - они уровнем выше и могут накласть свои успехи только в контекст
r.Context()
. Очень важно, что мидлвари недоступны в обработчике! вся бизнес-логика должна быть явной, и бизнес-данные не должны передаваться через контекст!На входе имеем
r.URL.Query()
иr.Form()
со всеми нужными данными для выполнения задачи, остальное обработчик должен добыть сам - в его распоряжении все ресурсы сервера изr.Context()
.На выходе
w.Write()
примет всё что угодно, но в виде байтов. Так как http-протокол имеет разные версии и "всё что может пойти не так - пойдет не так!", то такая всеядность дает нам свободу самовыражения в хелперах.Обработчик не возвращает
error
- это важно! Это ФИЧА, а не баг! Замысел создателей в том, что обработчик НЕ ДОЛЖЕН ВЫЗЫВАТЬСЯ в какой-то сторонней функции, которая обязательно захочет проверку ошибки. Кто-то попробует возразить с позиции, что создатели модуля не умеют в http?! Это прямое указание на особое место в разработке. Пардонюсь, если все кроме меня это знают, но до меня это дошло извилистым путем и только полгода назад.УСПЕХ: внутри обособленного обработчика можно творить всякую дичь! Потому что обработчик уникален и не может быть переиспользован! и имеет собственную логику http-ответа, неравнозначную нижележащим слоям.
Как я готовлю http
Далее примеры кода с комментариями. Личные доработки представлены микро-хелперами обычно в несколько строк - вы легко напишете свои "самые правильные"! Представлен почти весь код нативного http-сервера - настолько он прост, и и примеры обработчиков с хелперами. Хелперы обкатаны на многих сотнях обработчиков, легко читаются и легко изменяются по F2 или через "замену строки" в среде разработки.
Пример запуска сервера с базовым контекстом (здесь нет личных хелперов):
func StartHTTP(cfg *system.MainCfg, mainApp models.Srv) error {
httpSrv := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Http.Port), // слушаем порт
// набор мидлварей + внутри вызывается root-маршрутизатор
Handler: Start(cfg.Http.Origin),
// базовый контекст сразу доступен мидлварям и обработчикам
BaseContext: func(net.Listener) context.Context {
// бизнес-логгер среди других ресурсов сервера
return context.WithValue(context.Background(), models.KeyHttpSrv, mainApp)
},
// ErrorLog: этот логгер только для http-сервиса
}
// region START
// `region + <ANY>` выводит метку на минимапе справа в ide
go func() {
err := httpSrv.ListenAndServeTLS(cfg.Http.Crt, cfg.Http.Key)
if !errors.Is(err, http.ErrServerClosed) {
slog.Error("Failed to start http", "error", err.Error())
os.Exit(3)
}
}()
slog.Info(fmt.Sprintf(`HTTP started on port %v`, cfg.Http.Port))
// region STOP
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
<-stop // Блокируемся до сигнала SIGINT или SIGTERM из системы
// graceful shutdown
err := httpSrv.Shutdown(context.Background())
if err != nil {
slog.Error("HTTP shutdown", "error", err.Error())
}
slog.Warn("HTTP gracefully stopped!")
}
Базовый набор мидлварей:
func Start(origin string) http.Handler {
// ахтунг! мидлвари запускаются в обратном порядке!
// root-маршрутизатор
start := MainRoutes()
// пишет в w.Write() из контекста: результат / http-ошибку / ошибка 418
start = middleware.Teapot418(start)
// если запрос исполняется дольше секунды, то выводим WARN
start = middleware.Timer1s(start)
// перехватываем панику
start = middleware.RecoveryPanic(start)
// отсекаем options-запросы клиента от деловых
start = middleware.OptionsCORS(origin, start)
return start
}
Root-маршрутизатор:
func MainRoutes() http.Handler {
mux := http.NewServeMux()
// закрывающий слэш важен, чтобы провалиться в секцию
mux.Handle("/auth/", http0auth.Routes())
// можно повесить мидлварю до вызова секции
mux.Handle("/paper/", guestOnly(http1paper.Routes()))
mux.Handle("/v1/", domain.Routes())
// я дополняю ответ префиксом, чтобы определить место 404-ошибки
mux.HandleFunc("/", delo.ErrRoute404("/*"))
// или мидлваря внутри секции
return withSubject(mux)
}
// мидлваря - субъект запроса из заголовка авторизации в контекст
func withSubject(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
subj, err := subject.GetSubject(r.Header)
if err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
r = r.WithContext(context.WithValue(r.Context(), models.KeyActor, subj))
next.ServeHTTP(w, r)
})
}
Маршрутизатор секции:
func Routes() http.Handler {
mux := http.NewServeMux()
// дополнительная секция маршрутизатора, слеш в конце важен!
mux.Handle("/v1/users/inbox/", inbox0api.InboxRoutes())
// вообще не использую параметры пути!
mux.HandleFunc("GET /v1/users/pass2user", getPass2User)
// разная выдача для разных полномочий = разные обработчики!
mux.HandleFunc("GET /v1/users/list-users", getListOfUsersNamed)
mux.HandleFunc("GET /v1/users/list-users-sudo", getListOfUsersNamedSudo)
// одна страница сайта = один комплект данных!
mux.HandleFunc("GET /v1/users/user-mgmt", getUserMgmt)
// одна кнопка на сайте = один обработчик на бэке!
mux.HandleFunc("PATCH /v1/users/user-patch", patchUser)
mux.HandleFunc("POST /v1/users/user-settings-email-add", EmailAdd)
mux.HandleFunc("POST /v1/users/user-settings-email-confirm", EmailConfirm)
mux.HandleFunc("DELETE /v1/users/self-remove", RemoveUserSelf)
// префикс для дебага
mux.HandleFunc("/", delo.ErrRoute404("/v1/users/*"))
return withUser(mux)
}
Далее несколько примеров обработчика, здесь мякотка статьи - о том как превратить короткий редирект в нормальную форму "задача-дано-решение-ответ", и появляются оригинальные хелперы. Обработчик теперь - это полный комплект логики с вызовом в слой данных store
, а дополнительные прокладки в use_cases / entity
отсутствуют от слова вообще.
Стартовый простенький пример. Напомню, обработчик нигде НЕ ПЕРЕИСПОЛЬЗУЕТСЯ, и потому плодим любые нужные обработчики с нужным набором полей, и сосредотачиваемся на их читабельности:
func getListOfUsersNamedSudo(w http.ResponseWriter, r *http.Request) {
const op = "d1.http0users.getListOfUsersNamedSudo"
// d - набор http-хелперов, в основном в несколько строчек
// srv - ресурсы сервера, шаблон singleton через контекст
// actor - субъект запроса (в следующих примерах)
// ctx - контекcт для проброса в логику и далее в store
d, srv, _, ctx := delo.Get(w, r)
if d.PermitSudoOnly() {
return
}
result, err := store.ListOfUserProfileByProxy(ctx, srv, models.ProxyNil)
if d.Err409Conflict(op, err) {
return
}
d.WriteJSON(result)
}
Здесь:
const op
- местоположение ошибки для дебага.delo.Get
из базовогоr.Context()
выдает всё нужное для моего обработчика.Хелперы помогают сократить рутинный код, для них важно хорошее наименование. Так я превращаю множественные низкоуровневые операции в "мною-читаемый-код" вида "задача-дано-решение-ответ".
Например,
d.Err409Conflict(op, err, ...args)
под капотом проверяет, что ошибка не nil, пишет в лог правильную запись, пишетhttp.Error(w, msg, 409)
и выдаетtrue
для выхода из обработчика. Этот сверхмассовый хелпер легко изменяется. И код, будучи трехстрочным в vscode, превращается в однострочник у тех, кому нравится компактная форма (goland).
В следующем примере описывается парсинг входящих данных и приводится пример сложного доступа по бизнес-правилам:
func PatchOrgMgmt(w http.ResponseWriter, r *http.Request) {
const op = "d2.http0org.PatchOrgMgmt"
d, srv, actor, ctx := delo.Get(w, r)
// универсальный парсинг входящих данных - независимо от метода!
var f struct {
Org models.OrgId `json:"org" validate:"required,uuid"`
Named *string `json:"named" validate:"omitempty,max=120"`
Manifest *string `json:"manifest" validate:"omitempty,max=250"`
}
// нам доступны все коды ошибок, потому для входящих данных 422!
if d.Err422EntityParse(&f) {
return
}
// запись организации используется в проверке доступа
data, err := store.Read(ctx, srv, f.Org)
if d.Err409Conflict(op, err) {
return
}
// проверка доступа: ok при любой nil-ошибке
// если все ошибки не nil, то пишем общую http-ошибку "unauthorized"
// и доступен текст ошибок для каких-то замыслов
if d.Permit401ErrNil(
checks.ErrMgmtOrganization(ctx, srv, actor, data),
actor.ErrSudo(),
) {
return
}
// или нативно работать только с bool-значениями
// ошибка 401 как бы намекает, что нужно поднять полномочия
if d.Permit401Bool(
checks.IsMgmtOrganization(ctx, srv, actor, data),
actor.IsSudo(),
) {
return
}
// на входе могут отсутствовать все значимые поля - и это ошибка!
upd := false
if f.Named != nil && *f.Named != data.Named {
data.Named = *f.Named
upd = true
}
if f.Manifest != nil && *f.Manifest != data.Manifest {
data.Manifest = *f.Manifest
upd = true
}
if !upd {
d.Write409Conflict(op, models.ErrNoChange)
return
}
// хотим сохраниться в транзакции
tx := srv.StartTX()
defer tx.Rollback()
// основная запись сообщества
err = store.UpdateTx(ctx, tx, data)
if d.Err409Conflict(op, err) {
return
}
// корневой документ называется как сообщество
err = docs.PatchTitleTx(ctx, tx, models.DocId(data.Org), data.Named, data.Manifest, time.Now())
if d.Err409Conflict(op, err) {
return
}
// коммит транзакции
err = tx.Commit()
if d.Err409Conflict(op, err) {
return
}
d.Write204() // клиент обновляется через другой запрос
}
О входящих данных
Вспомним, что в GET+DELETE мы передаем данные запроса через
r.URL
, а с POST+PUT+PATCH нам дополнительно кроме url доступенr.Form
aka body.Кому-то очень красиво извлекать идентификатор из пути вида
/users/:id
. Но это не мой выбор потому что:Вспомним, что заголовок может ДЛИННЫМ - в килобайтах!
Различия между
GET /users/:id
иGET /users/user?id=xxxx
не значимы для разбора, но во втором случае мы легко парсим разнородные данные:GET /users/act?id1=xxxx&id2=yyyy...
Также мы можем захотеть принять слайс идентификаторов в GET/DELETE. Мы хотим! большой слайс идентификаторов! Это уже
r.URL.Query
, и попробуйте описать это в видеGET /users/:id/x/y/z
.И всё резко плохо, когда мы делаем что-то с двумя родственными сущностями
/users/:id/add-contact/:user2
.То есть при любой чуть сложной логике мы неизбежно используем
r.URL.Query
- а зачем парсить аргумент пути, если можно брать сразу из query?!Вышесказанное особенно ярко проявляется с body-запросами - брать один идентификатор из пути, а остальное из body - это больно и писать, и читать!
Плюс я давно решил "одна кнопка = один обработчик", и сложить на фронте все данные в query / body - это простая задача без головняка!
Очевидное для меня решение - оставить только query для GET+DELETE и только body для всего остального.
Поэтому у меня унифицирован парсер/валидатор входящих данных:
Для GET+DELETE выбирает значение из
r.URL.Query
и проверяет тип черезreflect
, прежде чем положить в структуру.Для остального - обычный
json.Unmarshal()
в структуру.После заполнения структуры запускаем стандартный
validator/v10
. Если безуспешно, то имеется развернутая ошибка.С файлами в multipart-форме заметно сложнее, ниже есть пример.
Структура входящих данных в большинстве случаев уникальная, потому обычно описываю форму внутри обработчика. Частые формы типизирую в
models
.Я типизирую(!) идентификаторы "uuid в виде строки" - UserId, OrgId, DocId... - так невозможно использовать неправильный id вместо правильного. Иногда, как в примере выше, один id приводится в другой тип, а под капотом все равно строка, которая без сложностей уходит в postgresql!
Отдельно отмечу, что никогда не использую внутренние структуры-модели в виде входящих данных. То есть я не принимаю сущность целиком. У меня "кнопка=обработчик" + разделение доступа, и пользователю в конкретном обработчике могут быть доступны только пара полей как в примере. Никаких полных структур с прямым пробросом в бд!
Пример с файлом из multipart-формы, здесь еще немного лохмато с mime-типом, но работает:
func PutImage(w http.ResponseWriter, r *http.Request) {
const op = "d2.http0org.PutImage"
d, srv, actor, _ := delo.Get(w, r)
f := struct {
Org models.OrgId `validate:"required,uuid"`
Mime string `validate:"required,oneof=image/jpeg"`
Data []byte `validate:"required,min=100,max=100_000"`
}{
Org: models.OrgId(r.FormValue("org")),
}
file, head, err := r.FormFile("file")
if d.Err400BadRequest(op, err) {
return
}
defer file.Close()
f.Mime = head.Header.Get("Content-Type") // тип картинки
f.Data, err = utils.GetImageResize256(file, f.Mime) // картинка
if d.Err400BadRequest(op, err) {
return
}
// валидация входящих данных
if d.Err422EntityValidate(&f) {
return
}
// ...далее как обычно
}
Про разделение доступа и инсайт
Основная боль проекта - 3 уровня доступа с кучей атрибутов. Пробовал и через мидлварю, и в обработчике, и в бизнесе:
RBAC в мидлваре однозначно не для меня, потому что нужна проверка с бизнес-логикой;
можно долго жить, если проверка и в обработчике, и в бизнесе, но напрягает, что какие-то запросы данных дублируются;
проверка не может быть только в бизнесе, когда нужна особенная http-ошибка. А если бизнес-слой знает http-ошибку, то это путь по наклонной. Обычно речь про какие-то костыли через непрозрачную логику и контекст;
а разную http-ошибку очень хочется, чтобы смотреть самому себе в глаза в зеркале;
парсинг и валидация ушла в http-слой - получил 422-ошибку;
выделил проверку в бизнес-слое в отдельный модуль
/xxx/internal/checks
. Правила знают всё про свою сущность, и логику оказалось очень удобно вызывать в слое обработчиков - доступны 401/403, остались дублирующие запросы данных;и при этом оставались недоступные http-ошибки для бизнеса, а еще хочется отделить ошибки логики от ошибок сторонних sub-сервисов;
долго охреневаешь от наблюдения, когда
patch
из слоя обработчиков вызывает один и только одинpatch
из бизнес-слоя, который в свою очередь вызывает один и только один методpatch
из слоя данных, местами сдаешься и из обработчика делаешь запрос списка в слой данных;и как-то я в очередной истерике переношу всю логику в обработчик и... вот оно! РЕШЕНИЕ! Обработчик выглядит именно так, как должен выглядеть обработчик - ни добавить, ни убавить!
аккуратно рефачу несколько обработчиков посложнее. Вау! можно транзакции и запросы в разные модули без костылей - в душе тепло, свет и радость;
переделал все обработчики - полет нормальный, слой легко меняется и дебажится;
а в бизнес-слое остается действительно сложная логика. В обработчики переместились все CRUD'ы, заточенные под обслуживание сайта - слой уже не пустой, а играет весьма и весьма.
Тема закончилась, я выдохся, но еще покажу пример, где GET-запрос набирает большой пакет данных для страницы сайта не последовательно (что может быть больше 1+ сек), а в параллельных горутинах через пакет golang.org/x/sync/errgroup
:
// Возвращает пакет с запросами параллельно в горутинах
func getPass2RolesMgmt(w http.ResponseWriter, r *http.Request) {
const op = "d2.http3persons.getPass2RolesMgmt"
d, srv, actor, ctx := delo.Get(w, r)
var f models.FormOrg // массовая структура с одним id
if d.Err422EntityParse(&f) {
return
}
if d.Permit401Bool(
checks.IsMgmtOrganization(ctx, srv, actor, data),
actor.IsSudo(),
) {
return
}
// результат обработчика не используется повторно, не выношу наружу
res := &struct {
Roles []*mod.Role `json:"roles"`
Managers []*mod.RolesPerson `json:"managers"`
Data any `json:"data"`
}{}
// запросы в горутинах
g := errgroup.Group{}
var err error
// список ролей
g.Go(func() error {
res.Roles, err = store.RolesList(ctx, srv, f.Org)
return err
})
// список связей ролей с персонами
g.Go(func() error {
res.Managers, err = store.RolesPersonList(ctx, srv, f.Org)
return err
})
err = g.Wait()
if d.Err409Conflict(op, err) {
return
}
if len(Roles) == 0 {
d.WriteJSON(res)
return // выход по условию
}
// или можно запустить дополнительные запросы
g.Go(func() error {
res.Data, err = store.GetData(ctx, srv, f.Org)
return err
})
err = g.Wait()
if d.Err409Conflict(op, err) {
return
}
d.WriteJSON(res)
}
Внимание! Приведенный код с параллельным горутинами потенциально небезопасен. Если внутри случится паника, то она не будет перехвачена на уровне мидлвари - горутины изолированы. То есть это не массовый код, а точечная пилюлька на этапе оптимизации: сначала шлифануть ошибки, а потом в важных страницах оптимизировать по времени. На мой взгляд, простота применения (особенно в get-запросах) компенсирует небезопасность, но следует помнить, что пакет остается в экспериментальных, и вы тоже экспериментируете!
Выводы:
Http-обработчик решает оригинальные задачи, имеет собственные абстракции и не может быть переиспользован в других слоях. Совокупность обработчиков образует http-сервер, который представляет собой собственный вышележащий слой логики, а не плесень поверх бизнес-слоя.
Оригинальные сигнатуры нативного модуля
net/http
прямо и однозначно указывают на особенное место в разработке. Модуль делает http, а для сопутствующих задач легко адаптируется через собственные микро-хелперы.В статье приведены примеры, где стандартный обработчик превращается в школьный вид "задача-дано-решение-ответ", что по замыслу приведет к снижению сложности и удобству в жизненном цикле сервера.
При этом нижележащие слои логики освобождаются от массового кода CRUD-процессов, и решают действительно бэк-операции - получают второе дыхание.
Приведены аргументы в пользу построения API бэкенда удобным для использования на стороне фронтенда. И на примерах показано, как органично слой обработчиков подстраивается под задачи сайтостроения.
Статья написана под странное настроение (погода возращается!), пардоньте, если задел чьё-то чувство прекрасного. А может быть кто-то выложит свой "правильный обработчик" - похоливарим. И может быть мой опыт поможет кому-то написать идеальный обработчик... идеальный хотя бы на несколько недель!
З.Ы. за зарплату в рынке отрефачу http вашей компании! Стикер на холодильник @victor_kupriyanov
.