Как стать автором
Обновить

Люблю я http, и вот как я его готовлю

Уровень сложностиСредний
Время на прочтение19 мин
Количество просмотров4.1K

Я старый фуллстек-разработчик и не знаю слов любви, но около полугода назад при очередной итерации сервера почувствовал себя утомленным путником, который узрел нежную красоту 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, конечно, ведь есть и другие интересные темы.

  1. Выбор структуры API более важен сайтостроителю из-за сложного управления состоянием. Бэкенду обработчики в стройном горизонтальном ряду и без состояния - дешевое изменение, и пофиг, пока сервер не плюется ошибками в логи. Потому пусть фронтендер определит свой удобный API для логики своего приложения.

  2. API "а-ля РЕСТ" хорош только для сайта типа википедии. В обычном бизнесе много ролей (например, три), несколько состояний сущности (например, три), и несколько операций (например, три). Это порождает на минуточку от 3 до 27 запросов в сервер. И когда это всё впихивают в GET/POST/PUT/PATCH/DELETE /v1/entity/:id - то только фейспалм и тихая скорбь!

  3. В продолжение добавлю, что по сути fetch-запросы - это удаленные вызовы процедур, то есть к http-запросам ДОЛЖНЫ ПРИМЕНЯТЬСЯ обычные правила - наименования функций должны отображать исполняемое действие и описывать возвращаемые данные. Причем вспомним правило "чем дальше использование от объявления, тем длиннее должно быть название" - куда уж дальше?!

  4. Обновление состояния на фронте - это боль. Сейчас распространен подход "один источник состояния" - если я получаю данные из одного fetch, то и обновления получаю через него. А "быстрые" обновления заплатками после PATCH-метода мимо основного fetch-источника очень быстро надоедают. Удобно получать одну большую структуру для всей страницы, и при любом изменении не шить заплатки, а обновлять весь пакет. Это снимает кучу головняка - прямо холодный компресс на воспаленный лоб. И хотя сильно отличается от обычного подхода "один запрос = маленький кусочек данных", но работает! проверено! а все потому что тесная связь фронта и бэка порождает самодокументируемый код.

  5. Сильные связи между частями системы имеют свои плюсы. Слабые связи означают, что сложность и связность переносится из кода в документацию проекта, - и не всем это дается! потому связи покрываются пылью только в голове разработчика - так рождается легаси! Сильная связь означает, что при изменении хочешь-нет, но придется поменять и связанную часть. Потому монолит рулит!

  6. В продолжение - не то, чтобы я топлю только за монолит, но это необходимая стадия жизненного цикла проекта до распределенных сервисов и микросервисов. Эволюция не прыгает через ступеньки!

  7. Рекомендую бэкендеру изучить работу роутера на стороне фронта (написать свой сайт). Как писал выше, работа роутеров схожа: роуты раскладываются в мапу + вызов функции. Но на фронтенде через обвес маршрутизатора реализуется очень много логики - может быть много уровней вложения, и узлы маршрутов имеют состояние. На бэкенде всё скучно и плоско, но знание react-routera хорошо обогатит понимание роутера на бэкенде и поможет "понять-и-простить" фронтендеров - они вынуждены реализовывать явную бизнес-логику через неявную логику контекстов.

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

  1. У обработчика особенная сигнатура (w,r), и он ничего не возвращает - И ЭТО ВОСТОРГ! Обработчик func(w http.ResponseWriter, r *http.Request) {} НЕВОЗМОЖНО ПЕРЕИСПОЛЬЗОВАТЬ в другом слое и бессмысленно в своем - ни разу не помню одинаковых обработчиков.

  2. Обработчик - это "вещь в себе", а не приложение к слою use_cases. Совокупность обработчиков - это и есть http-сервер, который включает ВСЮ ЛОГИКУ обслуживания внешнего клиента. Писать логику в http-обработчиках - добро, потому что они решают оригинальные задачи и используют собственные абстракции.

  3. Обработчики - обособленный внешний слой, который может стучаться в нижележащие слои. Последнее не особо очевидно, когда всё приложение и является http-сервером - вызовы "один-к-одному". Но если в коде проекта кроме http уживаются асинхронные worker и cron, то особенная логика слоя обработчиков становится более очевидной.

  4. В нативном обработчике не доступны мидлвари - они уровнем выше и могут накласть свои успехи только в контекст r.Context(). Очень важно, что мидлвари недоступны в обработчике! вся бизнес-логика должна быть явной, и бизнес-данные не должны передаваться через контекст!

  5. На входе имеем r.URL.Query() и r.Form() со всеми нужными данными для выполнения задачи, остальное обработчик должен добыть сам - в его распоряжении все ресурсы сервера из r.Context().

  6. На выходе w.Write() примет всё что угодно, но в виде байтов. Так как http-протокол имеет разные версии и "всё что может пойти не так - пойдет не так!", то такая всеядность дает нам свободу самовыражения в хелперах.

  7. Обработчик не возвращает error - это важно! Это ФИЧА, а не баг! Замысел создателей в том, что обработчик НЕ ДОЛЖЕН ВЫЗЫВАТЬСЯ в какой-то сторонней функции, которая обязательно захочет проверку ошибки. Кто-то попробует возразить с позиции, что создатели модуля не умеют в http?! Это прямое указание на особое место в разработке. Пардонюсь, если все кроме меня это знают, но до меня это дошло извилистым путем и только полгода назад.

  8. УСПЕХ: внутри обособленного обработчика можно творить всякую дичь! Потому что обработчик уникален и не может быть переиспользован! и имеет собственную логику 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() // клиент обновляется через другой запрос
}

О входящих данных

  1. Вспомним, что в GET+DELETE мы передаем данные запроса через r.URL, а с POST+PUT+PATCH нам дополнительно кроме url доступен r.Form aka body.

  2. Кому-то очень красиво извлекать идентификатор из пути вида /users/:id. Но это не мой выбор потому что:

    1. Вспомним, что заголовок может ДЛИННЫМ - в килобайтах!

    2. Различия между GET /users/:id и GET /users/user?id=xxxx не значимы для разбора, но во втором случае мы легко парсим разнородные данные: GET /users/act?id1=xxxx&id2=yyyy...

    3. Также мы можем захотеть принять слайс идентификаторов в GET/DELETE. Мы хотим! большой слайс идентификаторов! Это уже r.URL.Query, и попробуйте описать это в виде GET /users/:id/x/y/z.

    4. И всё резко плохо, когда мы делаем что-то с двумя родственными сущностями /users/:id/add-contact/:user2.

    5. То есть при любой чуть сложной логике мы неизбежно используем r.URL.Query - а зачем парсить аргумент пути, если можно брать сразу из query?!

    6. Вышесказанное особенно ярко проявляется с body-запросами - брать один идентификатор из пути, а остальное из body - это больно и писать, и читать!

    7. Плюс я давно решил "одна кнопка = один обработчик", и сложить на фронте все данные в query / body - это простая задача без головняка!

    8. Очевидное для меня решение - оставить только query для GET+DELETE и только body для всего остального.

  3. Поэтому у меня унифицирован парсер/валидатор входящих данных:

    1. Для GET+DELETE выбирает значение из r.URL.Query и проверяет тип через reflect, прежде чем положить в структуру.

    2. Для остального - обычный json.Unmarshal() в структуру.

    3. После заполнения структуры запускаем стандартный validator/v10. Если безуспешно, то имеется развернутая ошибка.

    4. С файлами в multipart-форме заметно сложнее, ниже есть пример.

  4. Структура входящих данных в большинстве случаев уникальная, потому обычно описываю форму внутри обработчика. Частые формы типизирую в models.

  5. Я типизирую(!) идентификаторы "uuid в виде строки" - UserId, OrgId, DocId... - так невозможно использовать неправильный id вместо правильного. Иногда, как в примере выше, один id приводится в другой тип, а под капотом все равно строка, которая без сложностей уходит в postgresql!

  6. Отдельно отмечу, что никогда не использую внутренние структуры-модели в виде входящих данных. То есть я не принимаю сущность целиком. У меня "кнопка=обработчик" + разделение доступа, и пользователю в конкретном обработчике могут быть доступны только пара полей как в примере. Никаких полных структур с прямым пробросом в бд!

Пример с файлом из 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(&amp;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-запросах) компенсирует небезопасность, но следует помнить, что пакет остается в экспериментальных, и вы тоже экспериментируете!

Выводы:

  1. Http-обработчик решает оригинальные задачи, имеет собственные абстракции и не может быть переиспользован в других слоях. Совокупность обработчиков образует http-сервер, который представляет собой собственный вышележащий слой логики, а не плесень поверх бизнес-слоя.

  2. Оригинальные сигнатуры нативного модуля net/httpпрямо и однозначно указывают на особенное место в разработке. Модуль делает http, а для сопутствующих задач легко адаптируется через собственные микро-хелперы.

  3. В статье приведены примеры, где стандартный обработчик превращается в школьный вид "задача-дано-решение-ответ", что по замыслу приведет к снижению сложности и удобству в жизненном цикле сервера.

  4. При этом нижележащие слои логики освобождаются от массового кода CRUD-процессов, и решают действительно бэк-операции - получают второе дыхание.

  5. Приведены аргументы в пользу построения API бэкенда удобным для использования на стороне фронтенда. И на примерах показано, как органично слой обработчиков подстраивается под задачи сайтостроения.

Статья написана под странное настроение (погода возращается!), пардоньте, если задел чьё-то чувство прекрасного. А может быть кто-то выложит свой "правильный обработчик" - похоливарим. И может быть мой опыт поможет кому-то написать идеальный обработчик... идеальный хотя бы на несколько недель!

З.Ы. за зарплату в рынке отрефачу http вашей компании! Стикер на холодильник @victor_kupriyanov.

Теги:
Хабы:
+18
Комментарии14

Публикации

Истории

Работа

Go разработчик
78 вакансий

Ближайшие события

19 марта – 28 апреля
Экспедиция «Рэйдикс»
Нижний НовгородЕкатеринбургНовосибирскВладивостокИжевскКазаньТюменьУфаИркутскЧелябинскСамараХабаровскКрасноярскОмск
24 апреля
VK Go Meetup 2025
Санкт-ПетербургОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань
14 мая
LinkMeetup
Москва
5 июня
Конференция TechRec AI&HR 2025
МоскваОнлайн
20 – 22 июня
Летняя айти-тусовка Summer Merge
Ульяновская область