Наверняка вы сталкивались с ситуацией, когда на сервисах нужно реализовать какую-то однотипную техническую логику ( например, логирование пользовательских запросов, security и т.п.), каким-то образом модифицировать запрос и пробросить его дальше или просто сделать routing из одной точки, а не забивать url всех сервисов на frontend'е?
Поговорим про паттерн API Gateway, и как пример реализации возьмем Spring Cloud Gateway. В режиме Live Coding с его помощью реализуем типовые операции: routing, модификация запросов и ответов, а так же прикрутим к нему security.
- Разберемся что такое паттерн API Gateway:
- какие проблемы он решает;
- а для чего его использовать не надо.
- В двух словах про Spring Cloud Gateway и WebFlux.
- Посмотрим что Spring Cloud Gateway умеет и чем он нам будет полезен:
- Настраиваем проксирование запросов.
- Добавляем заголовки.
- Реализуем Rate Limiter для запросов пользователя.
- Логируем запрос и ответ.
- Добавляем retry и таймауты на запросы.
- Подключаем Spring Cloud Security для защиты наших endpoints.
Шлюз API находится между клиентами и службами, он выполняет функцию обратного прокси, передавая запросы от клиентов к сервисам. Также он может выполнять такие специализированные задачи, как аутентификация, SSL-termination и Rate Limiting.
Функции Gateway API можно сгруппировать в соответствии со следующими задачами:
- routing – используется в качестве обратного прокси-сервера для перенаправления запросов на одну или несколько сервисов с помощью маршрутизации L7. Шлюз предоставляет одну конечную точку для клиентов и позволяет разделить клиенты и сервисы;
- повторное выполнение запросов (retry), Circuit Breaker, timeouts;
- безопасность – аутентификация и авторизация, black/white list и т.п. (это очень важный пункт, т.к. на API Gateway решается вопрос безопасности, а за ним находится доверенна зона (DMZ));
- логирование запросов и ответов, мониторинг;
- кэширование ответов, gzip;
- агрегация и модификация запросов / ответов.
API Gateway в первую очередь утильный элемент системы, который не должен содержать в себе бизнес логики. Но это идет в противоречие с последним пунктом: "агрегация и модификация запросов / ответов", – потому что для любой манипуляции данными требуется знать из каких блоков они состоят и как их собирать. А значит на Gateway переносится часть бизнес логики, что делает его связанным с самим сервисом. Этого стоит избегать, но если есть необходимость в таком функционале, то использовать его только для "обогащения" ответа, а не изменения его структуры.
На базе этих правил Spring Cloud Gateway выполняет routing.
Writing Custom Route Predicate Factories
Основные правила:
spring:
cloud:
gateway:
routes:
- id: path-route
uri: http://dictionary:8080
predicates:
- Path=/dict/**
The Path Route Predicate Factory
spring:
cloud:
gateway:
routes:
- id: header-route
uri: http://dictionary:8080
predicates:
- Header=X-Target-Service, dict
The Header Route Predicate Factory
spring:
cloud:
gateway:
routes:
- id: method-route
uri: http://dictionary:8080
predicates:
- Method=GET,POST
The Method Route Predicate Factory
spring:
cloud:
gateway:
routes:
- id: query-route
uri: http://dictionary:8080
predicates:
- Query=service, dict
The Query Route Predicate Factory
Cross-Origin Resource Sharing (CORS) — механизм, использующий дополнительные HTTP-заголовки, чтобы дать возможность агенту пользователя получать разрешения на доступ к выбранным ресурсам с сервера на источнике (домене), отличном от того, что сайт использует в данный момент.
Источник идентифицируется следующей тройкой параметров: схема, полностью определенное имя хоста и порт.
Например, http://example.com
и https://example.com
имеют разные источники: первый использует схему http
, а второй
https
. Следовательно, 2 источника отличаются схемой и портом, тогда как хост один и тот же (example.com
).
Таким образом, если хотя бы один из трех элементов у двух ресурсов отличается, то источник ресурсов также считается
разным. В кросс-доменный запрос браузер автоматически добавляет заголовок Origin
, содержащий домен, с которого
осуществлён запрос. Сервер должен, со своей стороны, ответить специальными заголовками, разрешает ли он такой запрос к
себе. Если сервер разрешает кросс-доменный запрос с этого домена – он должен добавить к ответу заголовок
Access-Control-Allow-Origin
, содержащий домен запроса или звёздочку *
.
spring:
cloud:
gateway:
globalcors:
cors-configurations:
'[/**]':
allowedOrigins: "*"
allowedMethods:
- GET
- POST
- PATCH
- PUT
- DELETE
$ curl -X OPTIONS \
-H 'Access-Control-Request-Method: GET' \
-H "Origin: http://localhost" \
http://localhost:8000/dict/v1/lego-sets/ -v
* Connected to localhost (127.0.0.1) port 8000 (#0)
* Server auth using Basic with user 'ronin'
> OPTIONS /dict/v1/lego-sets/ HTTP/1.1
> Host: localhost:8000
> User-Agent: curl/7.79.1
> Accept: */*
> Access-Control-Request-Method: GET
> Origin: http://localhost
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< Vary: Origin
< Vary: Access-Control-Request-Method
< Vary: Access-Control-Request-Headers
< Access-Control-Allow-Origin: *
< Access-Control-Allow-Methods: GET,POST,PATCH,PUT,DELETE
< Cache-Control: no-cache, no-store, max-age=0, must-revalidate
< Pragma: no-cache
< Expires: 0
< X-Content-Type-Options: nosniff
< X-Frame-Options: DENY
< X-XSS-Protection: 1 ; mode=block
< Referrer-Policy: no-referrer
< content-length: 0
<
* Connection #0 to host localhost left intact
Writing Custom GatewayFilter Factories
Target URL: http://localhost:8080/api/v1/lego-sets
, Gateway URL: http://localhost:8080/dict/v1/lego-sets
.
public class WebConfiguration {
@Bean
public RouteLocator routers(RouteLocatorBuilder builder, RoutesProperties routes) {
return builder
.routes()
.route("dictionary", pathSpec -> pathSpec
.path("/dict/**") // http://localhost:8080/dict/**
.filters(filterSpec -> filterSpec
.stripPrefix(1) // /dict/v1/lego-sets -> /v1/lego-sets
.prefixPath("/api")) // /v1/lego-sets -> /api/v1/lego-sets
.uri("http://localhost:8080"))
.build();
}
}
public class WebConfiguration {
@Bean
public RouteLocator routers(RouteLocatorBuilder builder, RoutesProperties routes) {
return builder
.routes()
.route("dictionary", pathSpec -> pathSpec
.path("/dict/**")
.filters(filterSpec -> filterSpec
.addRequestHeader("X-Gateway-Timestamp", ISO_DATE_TIME.format(now()))
.uri(routes.getDictionary()))
.uri("http://localhost:8080"))
.build();
}
}
Rate Limiter – ограничение скорости обработки запросов, т.е. это искусственный барьер, который не дает клиенту выполнить больше определенного количества больше операций в единицу времени. Rate Limiter защищает систему от перегрузки, т.е. на целевой сервис попадет только такое количество запросов, которое не приведет к дефициту ресурсов (Resource Starvation).
Так же Rate Limiter может использоваться для предотвращения brute force атак и для контроля доступа к платным ресурсам.
Spring Cloud Gateway предоставляет фильтр RequestRateLimiterGatewayFilterFactory
для реализации Rate Limiter, но
оставляет за пользователем выбор ключа (KeyResolver
) и самого алгоритма (AbstractRateLimiter
).
Для простоты реализации считать, что Gateway запускаем в один instance или нам не требуется распространять информацию о состоянии buckets между нодами.
Для реализации возьмем алгоритм Token Bucket (Алгоритм маркерной корзины):
Bucket – емкость конечного размера, ассоциированная с пользователем (ip, location и т.п.), куда помещаются маркеры. Если количество маркеров больше заданного объема, то запрос отбрасывается (429 Too Many Requests), иначе в bucket добавляется token и запрос продолжает выполнение. Количество token в корзине возобновляется в течение времени.
@Configuration
public class WebConfiguration {
@Bean
public RouteLocator routers(RouteLocatorBuilder builder, RoutesProperties routes) {
return builder
.routes()
.route("dictionary", pathSpec -> pathSpec
.path("/dict/**")
.filters(filterSpec -> filterSpec
.requestRateLimiter(rateLimiterConfig -> rateLimiterConfig
.setRateLimiter(rateLimiter())
.setKeyResolver(keyResolver())))
.uri(routes.getDictionary()))
.build();
}
@Bean
public RateLimiter<InMemoryRateLimiter.Config> rateLimiter() {
return new InMemoryRateLimiter(1, 2, ofSeconds(10));
}
@Bean
public KeyResolver keyResolver() {
return exchange -> just(exchange.getRequest().getRemoteAddress().getAddress().getHostAddress());
}
}
System Design Basics: Rate Limiter
Если запрос завершился неуспешно (5xx Series), то выполняем повторный запрос. Spring Cloud Gateway позволяет настраивать политику повтора:
- retries – количество повторов;
- backoff – экспоненциальная задержка перед следующим повтором.
@Configuration
public class WebConfiguration {
@Bean
public RouteLocator routers(RouteLocatorBuilder builder, RoutesProperties routes) {
// @formatter:off
return builder
.routes()
.route("dictionary", pathSpec -> pathSpec
.path("/dict/**")
.filters(filterSpec -> filterSpec
.retry(retryConfig -> retryConfig
.setRetries(3) // 3 повтора
.setStatuses(HttpStatus.NOT_FOUND) // включить повтор при 404 Not Found
.setSeries(HttpStatus.Series.SERVER_ERROR) // выполнять повтор при 5xx ошибках
.setBackoff(ofSeconds(1), // firstBackoff – первый повтор через 1 секунду,
ofSeconds(10), // maxBackoff – не более 10 секунд
2, // factor – коэффициент задержки: backoff = firstBackoff * (factor ^ n), т.е.
false))) // basedOnPreviousValue – если true, то backoff = prevBackoff * factor
// если basedOnPreviousValue = true, то prevBackoff * factor
.uri(routes.getDictionary()))
.build();
// @formatter:on
}
}
Если мы знаем, что 99% запросов (99 line) выполняется за 200ms, не имеет смысла ждать окончания выполнения операции больше 400ms. Это называется Fail Fast – если операция выполняется слишком долго, то ее лучше прервать и повторить еще раз.
Для задания таймаутов в Java HTTP клиентах можно задать:
Connection Timeout
(установка соединения).Read Timeout
(таймаут чтения из InputStream после установки соединения).
Источник: Class URLConnection :: setReadTimeout.
@Configuration
public class WebConfiguration {
@Bean
@Autowired
public RestTemplate restTemplate(RestTemplateBuilder restTemplateBuilder) {
return restTemplateBuilder
.setConnectTimeout(Duration.ofSeconds(500))
.setReadTimeout(Duration.ofSeconds(500))
.build();
}
}
Если мы используем API Gateway, то мы можем задавать суммарный таймаут на всю операцию. В Spring Cloud Gateway можно задать общий таймаут и отдельный таймаут на каждую операцию:
@Configuration
public class WebConfiguration {
@Bean
public RouteLocator routers(RouteLocatorBuilder builder, RoutesProperties routes) {
return builder
.routes()
.route("dictionary", spec -> spec
.path("/dict/**")
.metadata(RouteMetadataUtils.RESPONSE_TIMEOUT_ATTR, 2000)
.uri(routes.getDictionary()))
.build();
}
}
Одна из основных задач API Gateway – авторизация, реализуем ее с помощью Spring Security
. Т.к. Spring Cloud Gateway
построен на WebFlux, для настройки правил security требуется создать SecurityWebFilterChain
(отличии от WebMVC, где мы
наследовались от WebSecurityConfigurerAdapter
и задавали конфигурацию HttpSecurity
).
@Configuration
class SecurityConfiguration {
@Bean
fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain =
http.authorizeExchange {
it.pathMatchers(OPTIONS).permitAll() // для CORS Preflight Request
it.pathMatchers(GET, "/manage/**").permitAll() // Actuator Endpoints
it.anyExchange().authenticated() // Все остальное с Basic Auth
}
.httpBasic { }
.build()
}
Можно посмотреть настройки проксирования и примененные правила:
$ curl http://localhost:8000/manage/gateway/routes | jq
По конкретному route:
$ curl http://localhost:8000/manage/gateway/routes/dictionary | jq
# запускает postgres и dictionary
$ docker compose up -d --wait
$ ./gradlew gateway:bootRun --args='--spring.profiles.active=local'
- Описать проблематику: какие сложности у нас будут без Gateway.