Перейти к основному содержимому

Написание пользовательских фильтров Spring Cloud Gateway

· 12 мин. чтения

1. Обзор

В этом руководстве мы узнаем, как писать собственные фильтры Spring Cloud Gateway.

Мы представили эту структуру в нашем предыдущем посте «Изучение нового облачного шлюза Spring », где мы рассмотрели множество встроенных фильтров.

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

Во-первых, мы увидим, как мы можем создавать глобальные фильтры, которые будут влиять на каждый отдельный запрос, обрабатываемый шлюзом. Затем мы напишем фабрики фильтров шлюзов, которые можно применять к конкретным маршрутам и запросам.

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

2. Настройка проекта

Мы начнем с настройки базового приложения, которое будем использовать в качестве шлюза API.

2.1. Конфигурация Maven

При работе с библиотеками Spring Cloud всегда полезно настроить конфигурацию управления зависимостями для обработки зависимостей за нас:

<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>Hoxton.SR4</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>

Теперь мы можем добавить наши библиотеки Spring Cloud, не указывая фактическую версию, которую мы используем:

<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>

Последнюю версию Spring Cloud Release Train можно найти с помощью поисковой системы Maven Central. Конечно, мы всегда должны проверять, совместима ли версия с версией Spring Boot, которую мы используем в документации Spring Cloud .

2.2. Конфигурация шлюза API

Мы предположим, что есть второе приложение, работающее локально на порту 8081 , которое предоставляет ресурс (для простоты, просто String ) при нажатии /resource .

Имея это в виду, мы настроим наш шлюз для прокси-запросов к этой службе. В двух словах, когда мы отправляем запрос на шлюз с префиксом /service в пути URI, мы будем перенаправлять вызов на эту службу.

Итак, когда мы вызываем /service/resource в нашем шлюзе, мы должны получить ответ String .

Для этого мы настроим этот маршрут, используя свойства приложения :

spring:
cloud:
gateway:
routes:
- id: service_route
uri: http://localhost:8081
predicates:
- Path=/service/**
filters:
- RewritePath=/service(?<segment>/?.*), $\{segment}

Кроме того, чтобы иметь возможность правильно отслеживать процесс шлюза, мы также включим некоторые журналы:

logging:
level:
org.springframework.cloud.gateway: DEBUG
reactor.netty.http.client: DEBUG

3. Создание глобальных фильтров

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

В этом разделе мы начнем с написания простых глобальных фильтров. Это означает, что это повлияет на каждый запрос.

Во-первых, мы увидим, как мы можем выполнить логику перед отправкой прокси-запроса (также известный как «предварительный» фильтр).

3.1. Написание глобальной логики «предварительного» фильтра

Как мы уже говорили, на этом этапе мы создадим простые фильтры, так как основная цель здесь состоит только в том, чтобы увидеть, что фильтр действительно выполняется в нужный момент; достаточно просто зарегистрировать простое сообщение.

Все, что нам нужно сделать, чтобы создать собственный глобальный фильтр, — это реализовать интерфейс Spring Cloud Gateway GlobalFilter и добавить его в контекст как bean-компонент:

@Component
public class LoggingGlobalPreFilter implements GlobalFilter {

final Logger logger =
LoggerFactory.getLogger(LoggingGlobalPreFilter.class);

@Override
public Mono<Void> filter(
ServerWebExchange exchange,
GatewayFilterChain chain) {
logger.info("Global Pre Filter executed");
return chain.filter(exchange);
}
}

Мы можем легко увидеть, что здесь происходит; как только этот фильтр будет вызван, мы зарегистрируем сообщение и продолжим выполнение цепочки фильтров.

Давайте теперь определим фильтр «post», который может быть немного сложнее, если мы не знакомы с моделью реактивного программирования и API Spring Webflux .

3.2. Написание логики глобального фильтра «Post»

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

Например, мы можем определить наш фильтр «post» в классе конфигурации:

@Configuration
public class LoggingGlobalFiltersConfigurations {

final Logger logger =
LoggerFactory.getLogger(
LoggingGlobalFiltersConfigurations.class);

@Bean
public GlobalFilter postGlobalFilter() {
return (exchange, chain) -> {
return chain.filter(exchange)
.then(Mono.fromRunnable(() -> {
logger.info("Global Post Filter executed");
}));
};
}
}

Проще говоря, здесь мы запускаем новый экземпляр Mono после того, как цепочка завершила свое выполнение.

Давайте попробуем это сейчас, вызвав URL-адрес /service/resource в нашей службе шлюза и проверив консоль журнала:

DEBUG --- o.s.c.g.h.RoutePredicateHandlerMapping:
Route matched: service_route
DEBUG --- o.s.c.g.h.RoutePredicateHandlerMapping:
Mapping [Exchange: GET http://localhost/service/resource]
to Route{id='service_route', uri=http://localhost:8081, order=0, predicate=Paths: [/service/**],
match trailing slash: true, gatewayFilters=[[[RewritePath /service(?<segment>/?.*) = '${segment}'], order = 1]]}
INFO --- c.b.s.c.f.global.LoggingGlobalPreFilter:
Global Pre Filter executed
DEBUG --- r.netty.http.client.HttpClientConnect:
[id: 0x58f7e075, L:/127.0.0.1:57215 - R:localhost/127.0.0.1:8081]
Handler is being applied: {uri=http://localhost:8081/resource, method=GET}
DEBUG --- r.n.http.client.HttpClientOperations:
[id: 0x58f7e075, L:/127.0.0.1:57215 - R:localhost/127.0.0.1:8081]
Received response (auto-read:false) : [Content-Type=text/html;charset=UTF-8, Content-Length=16]
INFO --- c.f.g.LoggingGlobalFiltersConfigurations:
Global Post Filter executed
DEBUG --- r.n.http.client.HttpClientOperations:
[id: 0x58f7e075, L:/127.0.0.1:57215 - R:localhost/127.0.0.1:8081] Received last HTTP packet

Как мы видим, фильтры эффективно выполняются до и после того, как шлюз перенаправит запрос в сервис.

Естественно, мы можем объединить логику «до» и «после» в одном фильтре:

@Component
public class FirstPreLastPostGlobalFilter
implements GlobalFilter, Ordered {

final Logger logger =
LoggerFactory.getLogger(FirstPreLastPostGlobalFilter.class);

@Override
public Mono<Void> filter(ServerWebExchange exchange,
GatewayFilterChain chain) {
logger.info("First Pre Global Filter");
return chain.filter(exchange)
.then(Mono.fromRunnable(() -> {
logger.info("Last Post Global Filter");
}));
}

@Override
public int getOrder() {
return -1;
}
}

Обратите внимание, что мы также можем реализовать интерфейс Ordered , если нам небезразлично размещение фильтра в цепочке.

Из-за характера цепочки фильтров фильтр с более низким приоритетом (более низкий порядок в цепочке) выполнит свою «предварительную» логику на более ранней стадии, но его «последующая» реализация будет вызвана позже:

./1018c2485e3715d0af3f896cdf1878ab.png

4. Создание GatewayFilter s

Глобальные фильтры весьма полезны, но нам часто приходится выполнять точные настраиваемые операции фильтра шлюза, которые применяются только к некоторым маршрутам.

4.1. Определение GatewayFilterFactory

Чтобы реализовать GatewayFilter , нам нужно реализовать интерфейс GatewayFilterFactory . Spring Cloud Gateway также предоставляет абстрактный класс для упрощения процесса — класс AbstractGatewayFilterFactory :

@Component
public class LoggingGatewayFilterFactory extends
AbstractGatewayFilterFactory<LoggingGatewayFilterFactory.Config> {

final Logger logger =
LoggerFactory.getLogger(LoggingGatewayFilterFactory.class);

public LoggingGatewayFilterFactory() {
super(Config.class);
}

@Override
public GatewayFilter apply(Config config) {
// ...
}

public static class Config {
// ...
}
}

Здесь мы определили базовую структуру нашего GatewayFilterFactory . Мы будем использовать класс Config для настройки нашего фильтра при его инициализации.

В этом случае, например, мы можем определить три основных поля в нашей конфигурации:

public static class Config {
private String baseMessage;
private boolean preLogger;
private boolean postLogger;

// contructors, getters and setters...
}

Проще говоря, это следующие поля:

  1. пользовательское сообщение, которое будет включено в запись журнала
  2. флаг, указывающий, должен ли фильтр регистрироваться перед пересылкой запроса
  3. флаг, указывающий, должен ли фильтр регистрироваться после получения ответа от прокси-сервиса

И теперь мы можем использовать эти конфигурации для получения экземпляра GatewayFilter , который снова может быть представлен лямбда-функцией:

@Override
public GatewayFilter apply(Config config) {
return (exchange, chain) -> {
// Pre-processing
if (config.isPreLogger()) {
logger.info("Pre GatewayFilter logging: "
+ config.getBaseMessage());
}
return chain.filter(exchange)
.then(Mono.fromRunnable(() -> {
// Post-processing
if (config.isPostLogger()) {
logger.info("Post GatewayFilter logging: "
+ config.getBaseMessage());
}
}));
};
}

4.2. Регистрация GatewayFilter со свойствами

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

...
filters:
- RewritePath=/service(?<segment>/?.*), $\{segment}
- name: Logging
args:
baseMessage: My Custom Message
preLogger: true
postLogger: true

Нам просто нужно указать аргументы конфигурации. Важным моментом здесь является то, что нам нужен конструктор без аргументов и сеттеры, настроенные в нашем классе LoggingGatewayFilterFactory.Config , чтобы этот подход работал правильно.

Если мы хотим вместо этого настроить фильтр, используя компактную нотацию, мы можем сделать:

filters:
- RewritePath=/service(?<segment>/?.*), $\{segment}
- Logging=My Custom Message, true, true

Нам нужно немного подправить нашу фабрику. Короче говоря, мы должны переопределить метод ShortcutFieldOrder, чтобы указать порядок и количество аргументов, которые будет использовать свойство ярлыка :

@Override
public List<String> shortcutFieldOrder() {
return Arrays.asList("baseMessage",
"preLogger",
"postLogger");
}

4.3. Заказ GatewayFilter

Если мы хотим настроить положение фильтра в цепочке фильтров, мы можем получить экземпляр OrderedGatewayFilter из метода AbstractGatewayFilterFactory#apply вместо простого лямбда-выражения:

@Override
public GatewayFilter apply(Config config) {
return new OrderedGatewayFilter((exchange, chain) -> {
// ...
}, 1);
}

4.4. Программная регистрация GatewayFilter

Кроме того, мы также можем зарегистрировать наш фильтр программно. Давайте переопределим маршрут, который мы использовали, на этот раз настроив bean-компонент RouteLocator :

@Bean
public RouteLocator routes(
RouteLocatorBuilder builder,
LoggingGatewayFilterFactory loggingFactory) {
return builder.routes()
.route("service_route_java_config", r -> r.path("/service/**")
.filters(f ->
f.rewritePath("/service(?<segment>/?.*)", "$\\{segment}")
.filter(loggingFactory.apply(
new Config("My Custom Message", true, true))))
.uri("http://localhost:8081"))
.build();
}

5. Расширенные сценарии

До сих пор все, что мы делали, — это регистрировали сообщения на разных этапах процесса шлюза.

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

Далее мы увидим примеры этих различных сценариев.

5.1. Проверка и изменение запроса

Давайте представим гипотетический сценарий. Наш сервис раньше обслуживал свой контент на основе параметра запроса локали . Затем мы изменили API, чтобы вместо этого использовать заголовок Accept-Language , но некоторые клиенты по-прежнему используют параметр запроса.

Таким образом, мы хотим настроить шлюз для нормализации, следуя этой логике:

  1. если мы получим заголовок Accept-Language , мы хотим сохранить его
  2. в противном случае используйте значение параметра запроса локали
  3. если его тоже нет, используйте локаль по умолчанию
  4. наконец, мы хотим удалить параметр запроса локали

Примечание. Для простоты здесь мы сосредоточимся только на логике фильтра; чтобы взглянуть на всю реализацию, мы найдем ссылку на кодовую базу в конце учебника.

Давайте настроим наш фильтр шлюза как «предварительный» фильтр:

(exchange, chain) -> {
if (exchange.getRequest()
.getHeaders()
.getAcceptLanguage()
.isEmpty()) {
// populate the Accept-Language header...
}

// remove the query param...
return chain.filter(exchange);
};

Здесь мы заботимся о первом аспекте логики. Мы видим, что проверка объекта ServerHttpRequest действительно проста. На данный момент мы получили доступ только к его заголовкам, но, как мы увидим дальше, мы можем так же легко получить и другие атрибуты:

String queryParamLocale = exchange.getRequest()
.getQueryParams()
.getFirst("locale");

Locale requestLocale = Optional.ofNullable(queryParamLocale)
.map(l -> Locale.forLanguageTag(l))
.orElse(config.getDefaultLocale());

Теперь мы рассмотрели следующие два аспекта поведения. Но мы еще не изменили запрос. Для этого нам придется использовать возможность мутации .

При этом фреймворк будет создавать декоратор объекта, сохраняя исходный объект неизменным.

Изменить заголовки просто, потому что мы можем получить ссылку на объект карты HttpHeaders :

exchange.getRequest()
.mutate()
.headers(h -> h.setAcceptLanguageAsLocales(
Collections.singletonList(requestLocale)))

Но, с другой стороны, изменение URI — нетривиальная задача.

Нам нужно будет получить новый экземпляр ServerWebExchange из исходного объекта обмена , изменив исходный экземпляр ServerHttpRequest :

ServerWebExchange modifiedExchange = exchange.mutate()
// Here we'll modify the original request:
.request(originalRequest -> originalRequest)
.build();

return chain.filter(modifiedExchange);

Теперь пришло время обновить исходный URI запроса, удалив параметры запроса:

originalRequest -> originalRequest.uri(
UriComponentsBuilder.fromUri(exchange.getRequest()
.getURI())
.replaceQueryParams(new LinkedMultiValueMap<String, String>())
.build()
.toUri())

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

5.2. Изменение ответа

Продолжая тот же сценарий, мы теперь определим фильтр «post». Наш воображаемый сервис использовал для получения пользовательского заголовка, чтобы указать язык, который он в конечном итоге выбрал, вместо использования обычного заголовка Content-Language .

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

(exchange, chain) -> {
return chain.filter(exchange)
.then(Mono.fromRunnable(() -> {
ServerHttpResponse response = exchange.getResponse();

Optional.ofNullable(exchange.getRequest()
.getQueryParams()
.getFirst("locale"))
.ifPresent(qp -> {
String responseContentLanguage = response.getHeaders()
.getContentLanguage()
.getLanguage();

response.getHeaders()
.add("Bael-Custom-Language-Header", responseContentLanguage);
});
}));
}

Мы можем легко получить ссылку на объект ответа, и нам не нужно создавать его копию для его изменения, как в случае с запросом.

Это хороший пример важности порядка фильтров в цепочке; если мы настроим выполнение этого фильтра после того, который мы создали в предыдущем разделе, то объект обмена здесь будет содержать ссылку на ServerHttpRequest , который никогда не будет иметь никаких параметров запроса.

Даже не имеет значения, что это фактически срабатывает после выполнения всех «предварительных» фильтров, потому что у нас все еще есть ссылка на исходный запрос благодаря логике мутации .

5.3. Цепочка запросов к другим службам

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

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

В реактивной среде это означает объединение запросов во избежание блокировки асинхронного выполнения.

В нашем фильтре мы начнем с запроса к языковой службе:

(exchange, chain) -> {
return WebClient.create().get()
.uri(config.getLanguageEndpoint())
.exchange()
// ...
}

Обратите внимание, что мы возвращаем эту плавную операцию, потому что, как мы сказали, мы свяжем вывод вызова с нашим проксируемым запросом.

Следующим шагом будет извлечение языка — либо из тела ответа, либо из конфигурации, если ответ не был успешным — и его разбор:

// ...
.flatMap(response -> {
return (response.statusCode()
.is2xxSuccessful()) ? response.bodyToMono(String.class) : Mono.just(config.getDefaultLanguage());
}).map(LanguageRange::parse)
// ...

Наконец, мы установим значение LanguageRange в качестве заголовка запроса, как и раньше, и продолжим цепочку фильтров:

.map(range -> {
exchange.getRequest()
.mutate()
.headers(h -> h.setAcceptLanguage(range))
.build();

return exchange;
}).flatMap(chain::filter);

Все, теперь взаимодействие будет осуществляться в неблокирующем режиме.

6. Заключение

Теперь, когда мы научились писать собственные фильтры Spring Cloud Gateway и увидели, как манипулировать объектами запросов и ответов, мы готовы максимально использовать эту платформу.

Как всегда, все полные примеры можно найти на GitHub . Пожалуйста, помните, что для его тестирования нам нужно запустить интеграцию и живые тесты через Maven .