Анатомия шлюза API в Golang

Анатомия шлюза API в Golang

13 июня 2023 г.

Используете ли вы микросервисную архитектуру?

Если это так, есть вероятность, что в долгосрочной перспективе вам потребуется развернуть шлюз API. Шлюз API — это сетевое программное обеспечение, которое принимает запросы от клиентов и направляет их к вашим внутренним службам, передавая ответы обратно. Он находится между общедоступным Интернетом и вашей внутренней ИТ-инфраструктурой. На стороне, обращенной к Интернету, он представляет пользователям согласованный набор конечных точек. Внутри он не только перенаправляет запросы в соответствующие микрослужбы, но обычно также выполняет ряд вспомогательных задач, таких как проверка подлинности, ведение журнала, ограничение скорости, измерение, аудит и, возможно, преобразование полезной нагрузки.

Звучит как сложная часть программного обеспечения? Отнюдь не!

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

Я включаю фрагменты кода для строительных блоков реального шлюза API в Golang. Язык Go очень подходит для такого рода задач, поскольку он способен параллельно обрабатывать большое количество операций ввода-вывода.

Во-первых, давайте рассмотрим основные функции и архитектуру шлюза API. Шлюз API — это, по сути, веб-сервер, выполняющий обратное проксирование. Если в вашей системе работают микросервисы, вероятно, уже есть какой-то компонент, который выступает в качестве обратного прокси-сервера, например Nginx или HAProxy. Этот компонент может уже выполнять завершение TLS и балансировку нагрузки.

Среди распространенных вариантов использования шлюза API:

* аутентификация * авторизация * безопасность * запись и выставление счетов за использование API * ограничение скорости * Управление версиями API и абстракция API * синее/зеленое развертывание * Черный список IP-адресов или хостов * учет и мониторинг * Завершение TLS, кэширование и балансировка нагрузки

Веб-сервер

Давайте сначала рассмотрим функциональные возможности веб-сервиса. Ваш API-шлюз должен обрабатывать входящие запросы HTTP(s). Он либо находится за балансировщиком нагрузки, который завершает TLS, либо сам занимается балансировкой нагрузки и разгрузкой TLS. Пакет Golang net/http, являющийся частью стандартной библиотеки Go, поддерживает оба варианта использования. Мы рассмотрим первый случай с точки зрения сетевого администрирования, выгодно разделить эти две функции. Это означает, что нашему серверу нужно иметь дело только с незашифрованными полезными нагрузками. В следующем примере предположим, что мы хотим выполнить аутентификацию. Для этого мы реализуем три обработчика (предполагается Go 1.19):

`import ( "net/http" "log" "io/ioutil" )
http.HandleFunc("/login", func(res http.ResponseWriter, req *http.Request) {
if (req.method != "POST") {
http.NotFound(res, req)
return
}
params := req.URL.Query()
body, err := ioutil.ReadAll(req.Body)
if err != nil {
http.Error(res, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
// your actual login logic follows here using the data in "body" and "params"
token, err := myAuth.DoLogin(body, params)
if (err == nil) {
res.WriteHeader(http.StatusOK)
...
} else {
res.WriteHeader(http.StatusUnauthorized)
...
}
}
http.HandleFunc("/logout", func(res http.ResponseWriter, req *http.Request) {
if (req.method != "POST") {
http.NotFound(res, req)
return
}
if (!myAuth.authenticate(req.Header.Get("Authorization"))) {
res.WriteHeader(http.StatusUnauthorized)
...
return
}
// end session and remove any state related to user login account
}
http.HandleFunc("/", func(res http.ResponseWriter, req *http.Request) {
log.Printf("incoming request: %s %", req.Host, req.URL.String())
if (!myAuth.authenticate(req.Header.Get("Authorization"))) {
res.WriteHeader(http.StatusUnauthorized)
...
return
}
// todo: implement routing and forwarding
}`

Эти три обработчика запросов — все, что нужно. Два из них обрабатывают аутентификацию, а третий является обработчиком по умолчанию, который соответствует любому URL-адресу и перенаправляет запросы на серверную часть. HTTP-библиотека Go выполняет каждый обработчик запросов в собственной горутине, которая представляет собой легковесные потоки, управляемые Go. Это означает, что запросы обрабатываются одновременно и, возможно, параллельно, если программе разрешено использовать более одного ядра ЦП. Это также означает, что эта программа может обрабатывать множество одновременных запросов. Для этой задачи нам не нужна веб-инфраструктура, так как нам нужно реализовать только три простых обработчика запросов.

Программа предполагает, что конечная точка POST /login принимает учетные данные в теле запроса, поэтому мы считываем тело запроса в массив байтов. Дополнительные параметры могут быть переданы в строке запроса. Поскольку http.HandleFunc() не различает методы HTTP, мы должны сделать это самостоятельно. Обработчик возвращает статус 200 OK, если аутентификация прошла успешно, 401, если аутентификация не удалась, и 400, если текст запроса не может быть прочитан.

Фактическая логика аутентификации реализована в myAuth, который здесь не рассматривается. Учетные данные могут быть переданы серверной службе, такой как Okta, Auth0 или Keycloak, или вы можете найти учетные данные в базе данных. Токен, возвращаемый методом DoLogin, может быть токеном носителя, Веб-токен JSON или идентификатор сеанса для использования в последующих запросах либо в заголовке, либо в файле cookie. Опять же, конкретная реализация зависит от ваших требований безопасности. Его цель — аутентифицировать пользователя в запросах, которые следуют после входа в систему.

В приведенном выше примере мы используем заголовок «Авторизация» запроса для считывания токена и передачи его методу authenticate() myAuth, так что каждый запрос за исключением того, что запрос к конечной точке входа в систему аутентифицируется до его обработки. Аутентификация может быть реализована, например, путем поиска токена в ограниченном по времени кеше, в базе данных или путем отправки запроса в службу поставщика идентификаторов. Если части вашего API или вашего веб-сайта являются общедоступными, вы можете исключить определенные конечные точки из проверки подлинности.

Наконец, нам нужно запустить наш веб-сервер в основной процедуре go, вызвав функцию ListenAndServe() пакета HTTP. Второй аргумент этой функции — nil, что означает, что маршрутизация выполняется мультиплексором DefaultServeMux, который передает запросы нашим ранее определенным обработчикам. Если бы вместо этого мы хотели обрабатывать HTTPS-трафик, мы использовали бы метод ListenAndServeTLS() пакета HTTP.

func main() { // Здесь находятся определения маршрутов... log.Println("Шлюз API прослушивает порт 8880") err := http.ListenAndServe(":8880"", nil) if err != nil { log.Panic(ошибка) }

Маршрутизация

Следующее, что нам нужно сделать, это направить входящие запросы на соответствующие серверные конечные точки. Мы могли бы рассмотреть возможность создания дополнительных обработчиков маршрутов для замены обработчика по умолчанию с использованием мультиплексора Go ServeMux. Однако это быстро повторяется. Поскольку ServeMux не поддерживает ни HTTP-методы, ни сопоставление хостов, ни шаблоны URL, это, вероятно, также становится утомительным. Один из вариантов — подключить другой сторонний мультиплексор, например gorilla/mux или httprouter в прослушиватель и позвольте ему выполнить маршрутизацию. Затем функции обработчика в определениях маршрута просто передают запрос вместе с проанализированными параметрами и целевым URL обратному прокси-серверу. Вот пример использования httprouter:

`import (     "github.com/julienschmidt/httprouter"     "log"     "net/http" )
func main() {
    router := httprouter.New()
    router.GET("/my/:name", function (res http.ResponseWriter, req http.Request, p httprouter.Params)) {
proxy("http://server/endpoint-1", res, req, p)
}
    router.POST("/another/route/path", function (res http.ResponseWriter, req *http.Request, p httprouter.Params)) {
proxy("http://server/endpoint-2", res, req, p)
}
...
    err := http.ListenAndServe(":8880", router)
if err != nil {
log.Panic(err)
}
}`

Этот подход ограничен функциональностью, которую предлагает соответствующий мультиплексор, например. httprouter в этом случае. Более гибкий подход — реализовать маршрутизацию самостоятельно. Для этого нам нужно разобрать интересующие нас части запроса. Это может быть URL-адрес, имя хоста, строка запроса, заголовки запроса или даже тело запроса. В следующем примере мы используем обработчик по умолчанию, чтобы сделать именно это. Мы передаем в функцию lookupTargetURL() три переменные: имя хоста (строка), URL-путь (строка) и параметры запроса (map[string][]string). Функция lookupTargetURL() сопоставляет эту информацию с таблицей маршрутизации, возможно, с использованием регулярных выражений, и возвращает URL-адрес целевой конечной точки и логический флаг, который сообщает нам, является ли конечная точка общедоступной или доступ к ней ограничен.< /p>

`import (     "log"     "net/http" )
func main() {
...
// default handler
http.HandleFunc("/", func(res http.ResponseWriter, req *http.Request) {
host := req.Host
path := req.URL.Path
qs := req.URL.Query()
log.Printf("incoming request: %s %", host, req.URL.String())
targetURL, isPublic := lookupTargetURL(host, path, qs)
if (targetURL == "") {
http.Error(res, "Not Found", 404)
return
}
if (!isPublic) {
if (!myAuth.authenticate(req.Header.Get("Authorization"))) {
http.Error(res, "Unauthorized", 401)
return
}
}
proxy(targetURL, res, req)
}
}`

Если шлюз API не находит соответствующий целевой маршрут, возвращенный целевой URL-адрес будет пустым. В этом случае возвращается код состояния 404. Если найден соответствующий маршрут с ограниченным доступом, шлюз API выполняет аутентификацию перед пересылкой запроса. Если это не удается, возвращается код состояния 401. Переадресация выполняется вызовом функции proxy(), подробно описанной ниже.

Реализация lookupTargetURL() зависит от ваших требований и здесь подробно не рассматривается. По сути, он соответствует аргументам против какой-то структуры данных. Это может быть карта шаблонов, карта структур, карта строк или что-то еще, что соответствует вашим потребностям. Последним и, возможно, самым интересным строительным блоком шлюза API является сама функция пересылки. Опять же, для этого мы используем функции стандартной библиотеки Go.

Пересылка

import (     "log"     "net/http" "net/url" "httputil" ) ... func proxy(строка targetURL, res http.ResponseWriter, req *http.Request) { target, err : = url.Parse(targetURL) if (err != nil) { http.Error(res, "Invalid URL", 500) return } proxy := httputil.NewSingleHostReverseProxy(target) proxy.Director = func(request *http.Request ) { request.URL.Scheme = target.Scheme request.URL.Host = target.Host request.URL.Path = target.Path } log.Printf("Переадресация запроса на %v", target) proxy.ServeHTTP(res, требование)

Мы используем функцию NewSingleHostReverseProxy() в пакете httputil для создания обратного прокси. Он принимает аргумент структуры URL и направляет запросы к схеме, хосту и (необязательно) базовому пути, указанному в этой структуре URL. Перед этим мы должны проанализировать строку targetURL, возвращаемую нашей функцией поиска маршрута, с помощью функции Parse() в пакете url. Обратите внимание, что обратный прокси-объект создается здесь на лету, по одному для каждого запроса. Если бы мы заранее знали целевой хост и схему, мы могли бы просто создать этот объект один раз в основной функции и таким образом сэкономить некоторые ресурсы памяти. Затем один и тот же объект будет повторно использоваться для каждого запроса путем вызова метода proxy.ServeHTTP(), который выполняет ретрансляцию.

Прежде чем мы вызовем эту функцию, мы должны выполнить перезапись URL-адреса, которая преобразует исходный URL-адрес запроса в URL-адрес, на который мы перенаправляем. Это достигается назначением функции proxy.Director, которая изменяет клон исходного запроса. В нашем случае мы присваиваем проанализированные строковые переменные схемы, хоста и пути новому запросу. Эти значения ранее были получены нашей функцией поиска маршрута и переданы в прокси-функцию в виде строки. Мы могли бы изменить и другие части входящего запроса, если бы захотели. Например, мы можем удалить конфиденциальную информацию из заголовков запросов или добавить новые заголовки запросов.

Точно так же можно изменить ответ таким же образом, назначив функцию proxy.ModifyResponse и выполнив мутации в объекте ответа. В большинстве случаев в этом нет необходимости, поскольку объект ответа обычно изменяется конечной точкой серверной службы. И последнее, но не менее важное: мы можем назначить функцию proxy.ErrorHandler для перехвата и обработки ошибок, которые могут возникнуть при попытке подключения к серверной службе. По умолчанию вызывающей стороне возвращается статус 502 Bad Gateway, если внутренняя служба недоступна.

Обзор

Шлюз API — это мощный инструмент в вашем наборе инструментов DevOps. Для создания базового шлюза API в Golang требуется всего несколько строк кода. Большая часть работы будет потрачена на реализацию вашей логики маршрутизации и любых вспомогательных функций, которые могут вам понадобиться.


Оригинал
PREVIOUS ARTICLE
NEXT ARTICLE