Упрощаем написание HTTP обработчиков на Golang

    При обработке входящего HTTP запроса требуется выполнить большое количество действий, таких как:


    • Логирование входящего HTTP запроса
    • Проверка на допустимость HTTP метода
    • Выполнение аутентификации (basic, MS AD, ...)
    • Проверка валидности token (при необходимости)
    • Считывание тела (body) входящего запроса
    • Считывание заголовка (header) входящего запроса
    • Собственно обработка запроса и формирование ответа
    • Установка HSTS Strict-Transport-Security
    • Установка Content-Type для исходящего ответа (response)
    • Логирование исходящего HTTP ответа
    • Запись заголовка (header) исходящего ответа
    • Запись тела исходящего ответа
    • Обработка и логирование ошибок
    • Обработка defer recovery для восстановления после возможной panic

    Большинство этих действий являются типовыми и не зависят от типа запроса и его обработки.
    Для продуктивной эксплуатации на каждое действие необходимо предусмотреть обработку ошибок и логирование.


    Повторять все это в каждом HTTP обработчике крайне неэффективно.


    Даже если вынести весь код в отдельные подфункции, все равно получается примерно по 80-100 строк кода на каждый HTTP обработчик без учета собственно обработки запроса и формирования ответа.


    Ниже описан используемый мной подход, по упрощению написание HTTP обработчиков на Golang без использования кодогенераторов и сторонних библиотек.


    Этот подход реализован в шаблоне backend сервера на Golang


    Этот подход был скомпонован из различных источников и рекомендаций в интернете.
    У любого разработчика, естественно, есть свои устоявшиеся приемы и наработки — буду рад, если кто-то поделится своими подходами.


    Подход к организации HTTP обработчиков


    На следующем рисунке показана упрощенная UML диаграмма выполнения HTTP обработчика на примере некоего EchoHandler.


    Идея, заложенная в предлагаемый подход, достаточно простая:


    • На верхнем уровне необходимо внедрить defer фукнцию для восстановления после паники. На UML диаграмме — это анонимная функция RecoverWrap.func1, показаная красным цветом.
    • Весь типовой код необходимо вынести в отдельный обработчик. Этот обработчик встраивается в наш HTTP handler. На UML диаграмме это функция Process — показана синим цветом.
    • Собственно код функциональной обработки запроса и формирования ответа вынесен в анонимную функцию в нашем HTTP handler. На UML диаграмме это функция EchoHandler.func1 — показана зеленым цветом.

    http_handler


    Пример кода


    Полный код приведен в репозитории.


    Ниже пример кода для HTTP обработчика EchoHandler, который "зеркалит" вход на выход.


    При регистрации обработчика в роутере, регистрируется не собственно обработчик EchoHandler, а анонимная функция обработки паники (она возвращается функцией RecoverWrap), которая уже вызывает наш обработчик EchoHandler.


    router.HandleFunc("/echo", service.RecoverWrap(http.HandlerFunc(service.EchoHandler))).Methods("GET")

    Текст функции RecoverWrap для регистрации анонимной функции обработки паники.
    После объявления defer func() запускается собственно наш обработчик EchoHandler.


    func (s *Service) RecoverWrap(handlerFunc http.HandlerFunc) http.HandlerFunc {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            // объявляем функцию восстановления после паники
            defer func() {
                var myerr error
                r := recover()
                if r != nil {
                    msg := "HTTP Handler recover from panic"
                    switch t := r.(type) {
                    case string:
                        myerr = myerror.New("8888", msg, t)
                    case error:
                        myerr = myerror.WithCause("8888", msg, t)
                    default:
                        myerr = myerror.New("8888", msg)
                    }
                    // расширенное логирование ошибки в контексте HTTP
                    s.processError(myerr, w, http.StatusInternalServerError, 0)
                }
            }()
    
            // вызываем обработчик
            if handlerFunc != nil {
                handlerFunc(w, r)
            }
        })
    }

    Собственно, код обработчика EchoHandler. В общем случае, он запускает функцию типовой обработки HTTP запроса Process и передается ей в качестве параметра анонимную функцию обработки.


    func (s *Service) EchoHandler(w http.ResponseWriter, r *http.Request) {
        // Запускаем универсальный обработчик HTTP запросов 
        _ = s.Process("POST", w, r, func(requestBuf []byte, reqID uint64) ([]byte, Header, int, error) {
            header := Header{} // заголовок ответа
    
            // Считаем параметры из заголовка входящего запроса и поместим их в заголовок ответа
            for key := range r.Header {
                header[key] = r.Header.Get(key)
            }
            // возвращаем буфер запроса в качестве ответа, заголовок ответа и статус 
            return requestBuf, header, http.StatusOK, nil
        })
    }

    Функция типовой обработки HTTP запроса Process. Входные параметры функции:


    • method string — HTTP метод обработчика используется для проверки входящего HTTP запроса
    • w http.ResponseWriter, r *http.Request — стандартные переменные для обработчика
    • fn func(requestBuf []byte, reqID uint64) ([]byte, Header, int, error) — собственно функция обработки, на вход она принимает буфер входящего запроса и уникальный номер HTTP request (для целей логирования), возвращает подготовленный буфер исходящего ответа, header исходящего ответа, HTTP статус и ошибку.

    func (s *Service) Process(method string, w http.ResponseWriter, r *http.Request, fn func(requestBuf []byte, reqID uint64) ([]byte, Header, int, error)) error {
        var myerr error
    
        // Получить уникальный номер HTTP запроса
        reqID := GetNextRequestID()
    
        // Логируем входящий HTTP запрос
        if s.logger != nil {
            _ = s.logger.LogHTTPInRequest(s.сtx, r, reqID) // При сбое HTTP логирования, делаем системное логирование, но работу не останавливаем
            mylog.PrintfDebugMsg("Logging HTTP in request: reqID", reqID)
        }
    
        // Проверим разрешенный метод
        mylog.PrintfDebugMsg("Check allowed HTTP method: reqID, request.Method, method", reqID, r.Method, method)
        if r.Method != method {
            myerr = myerror.New("8000", "HTTP method is not allowed: reqID, request.Method, method", reqID, r.Method, method)
            mylog.PrintfErrorInfo(myerr)
            return myerr
        }
    
        // Если включен режим аутентификации без использования JWT токена, то проверять пользователя и пароль каждый раз
        mylog.PrintfDebugMsg("Check authentication method: reqID, AuthType", reqID, s.cfg.AuthType)
        if (s.cfg.AuthType == "INTERNAL" || s.cfg.AuthType == "MSAD") && !s.cfg.UseJWT {
            mylog.PrintfDebugMsg("JWT is of. Need Authentication: reqID", reqID)
    
            // Считаем из заголовка HTTP Basic Authentication
            username, password, ok := r.BasicAuth()
            if !ok {
                myerr := myerror.New("8004", "Header 'Authorization' is not set")
                mylog.PrintfErrorInfo(myerr)
                return myerr
            }
            mylog.PrintfDebugMsg("Get Authorization header: username", username)
    
            // Выполняем аутентификацию
            if myerr = s.checkAuthentication(username, password); myerr != nil {
                mylog.PrintfErrorInfo(myerr)
                return myerr
            }
        }
    
        // Если используем JWT - проверим токен
        if s.cfg.UseJWT {
            mylog.PrintfDebugMsg("JWT is on. Check JSON web token: reqID", reqID)
    
            // Считаем token из requests cookies
            cookie, err := r.Cookie("token")
            if err != nil {
                myerr := myerror.WithCause("8005", "JWT token does not present in Cookie. You have to authorize first.", err)
                mylog.PrintfErrorInfo(myerr)
                return myerr
            }
    
            // Проверим JWT в token
            if myerr = myjwt.CheckJWTFromCookie(cookie, s.cfg.JwtKey); myerr != nil {
                mylog.PrintfErrorInfo(myerr)
                return myerr
            }
        }
    
        // Считаем тело запроса
        mylog.PrintfDebugMsg("Reading request body: reqID", reqID)
        requestBuf, err := ioutil.ReadAll(r.Body)
        if err != nil {
            myerr = myerror.WithCause("8001", "Failed to read HTTP body: reqID", err, reqID)
            mylog.PrintfErrorInfo(myerr)
            return myerr
        }
        mylog.PrintfDebugMsg("Read request body: reqID, len(body)", reqID, len(requestBuf))
    
        // вызываем обработчик
        mylog.PrintfDebugMsg("Calling external function handler: reqID, function", reqID, fn)
        responseBuf, header, status, myerr := fn(requestBuf, reqID)
        if myerr != nil {
            mylog.PrintfErrorInfo(myerr)
            return myerr
        }
    
        // use HSTS Strict-Transport-Security
        if s.cfg.UseHSTS {
            w.Header().Add("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
        }
    
        // Логируем ответ в файл
        if s.logger != nil {
            mylog.PrintfDebugMsg("Logging HTTP out response: reqID", reqID)
            _ = s.logger.LogHTTPOutResponse(s.сtx, header, responseBuf, status, reqID) // При сбое HTTP логирования, делаем системное логирование, но работу не останавливаем
        }
    
        // Записываем заголовок ответа
        mylog.PrintfDebugMsg("Set HTTP response headers: reqID", reqID)
        if header != nil {
            for key, h := range header {
                w.Header().Set(key, h)
            }
        }
    
        // Записываем HTTP статус ответа
        mylog.PrintfDebugMsg("Set HTTP response status: reqID, Status", reqID, http.StatusText(status))
        w.WriteHeader(status)
    
        // Записываем тело ответа
        if responseBuf != nil && len(responseBuf) > 0 {
            mylog.PrintfDebugMsg("Writing HTTP response body: reqID, len(body)", reqID, len(responseBuf))
            respWrittenLen, err := w.Write(responseBuf)
            if err != nil {
                myerr = myerror.WithCause("8002", "Failed to write HTTP repsonse: reqID", err)
                mylog.PrintfErrorInfo(myerr)
                return myerr
            }
            mylog.PrintfDebugMsg("Written HTTP response: reqID, len(body)", reqID, respWrittenLen)
        }
    
        return nil
    }

    Комментарии 11

      +5
      мда, честно, статья ни о чем, плюс еще и технически не очень грамотная, мягко говоря. Все вышеописанное называется очень просто — middleware и описано уже миллион раз за многие годы и гораздо в более правильном виде.
        0
        Спасибо за комментарий.
        Согласен с вами, статья получилась очень ограниченной. Она раскрывает только один маленький аспект проектирования middleware на Golang

        Полный код приведен в репозитории.
        +2

        Метод Process будет таким образом бесконечно прирастать if'ами и увеличивать таким образом цикломатическую сложность? Я, конечно, понимаю, что в реальных проектах всякое бывает, но зачем же это тащить как пример? К тому же ваш код слишком изобилует частностями из вашего проекта, что лишний раз показывает, что выбранный подход неуниверсален. Я бы рекомендовал вам подумать над универсализацией решения, оформления его в библиотеку на github и потом уже попробовать снова создать пост на хабре.


        P. S. И да, как верно выше отметили, попутно ознакомьтесь с концепцией middleware.

          0
          Полностью согласен. В реальном проекте Process значительно сложнее.

          Чтобы снизить цикломатическую сложность кода, все If сделаны без вложений с многочисленными точками выхода.
          В примере метод Process содержит то, что по минимуму необходимо. Сложно представить проект в котором бы не было аутентификации, токенов, или не было бы потребности в периодическом логировании HTTP трафика.

          Полный код приведен в репозитории.
            0

            С тестами беда.

          0
          В качестве детализации/реализации концепции middleware можно рассмотреть реверсный прокси, например, nginx, который решает большую (не всю без экзотики типа openresty, вроде бы) часть перечисленных задач с не очень доступной Вашей функции производительностью плюс ещё много всякого полезного умеет.
            0
            Вариант с реверсивным прокси для задач аутентификации, логирования HTTP трафика, поддержки токинов тестировался на Apache — отличное решение.
            Только заказчик не захотел поддерживать и сопровождать еще один сервер приложений.

            Поэтому и появилась эта статья — все что нужно можно легко сделать на Golang без использования дополнительных серверов приложений.
            0
            Уважаемый автор уже пробовал gin?
              0
              Gin пробовал для тестовых целей.
              Но вариант без сторонних библиотек показал выше производительность.
              Удалось получить до 15 000 [#/sec] с одного физического ядра.
              Для простых задач описанный вариант отлично подходит.
              Например, в таком варианте был реализован REST API к IBM MQ
                0
                Но вариант без сторонних библиотек показал выше производительность.
                Удалось получить до 15 000 [#/sec] с одного физического ядра.

                И что, 10k rps с ядра вас вас бы не устроил? какая у вас текущая нагрузка?
                  0

                  Если ваш вопрос "по текущей нагрузке", относится к REST API к IBM MQ, то там не нужно было более 1000 rps.
                  Там проблема с производительностью была в другом – каждое XML сообщение нужно было нормализовать (канонизация) по стандарту RFC 3076, завернуть его в транспортный пакет и посчитать для всего пакета HMAC с хэш-функцией ГОСТ-34.11.94.

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

            Самое читаемое