Я старый фуллстек-разработчик и не знаю слов любви, но около полугода назад при очередной итерации сервера почувствовал себя утомленным путником, который узрел нежную красоту 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 бэкенда удобным для использования на стороне фронтенда. И на примерах показано, как органично слой обработчиков подстраивается под задачи сайтостроения.
Статья написана под странное настроение (погода возращается!), пардоньте, если задел чьё-то чувство прекрасного. А может быть кто-то выложит свой «правильный обработчик» — похоливарим. И может быть мой опыт поможет кому-то написать идеальный обработчик… идеальный хотя бы на несколько недель!
Блог не веду, потому приглашаю похоливарить здесь или оффлайн на мероприятиях: Яндекс Dev Day&Night 19 апреля и Avito GO 23 апреля в Москве.
З.Ы. за зарплату в рынке отрефачу http вашей компании! Стикер на холодильник @victor_kupriyanov
.
ссылка на оригинал статьи https://habr.com/ru/articles/901938/
Добавить комментарий