Рубрики
Без рубрики

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

Узнайте, как создавать пользовательские фильтры Spring Cloud Gateway.

Автор оригинала: Ger Roza.

1. Обзор

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

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

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

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

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

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

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

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

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


    
        
            org.springframework.cloud
            spring-cloud-dependencies
            Hoxton.SR4
            pom
            import
        
    

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


    org.springframework.cloud
    spring-cloud-starter-gateway

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

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

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

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

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

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

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

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

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

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

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

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

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

3.1. Написание глобальной Логики “Предварительного” Фильтра

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

Все, что нам нужно сделать, чтобы создать пользовательский глобальный фильтр, – это реализовать шлюз Spring Cloud Gateway Глобальный фильтр интерфейс и добавьте его в контекст в качестве компонента:

@Component
public class LoggingGlobalPreFilter implements GlobalFilter {

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

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

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

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

3.2. Написание глобальной Логики Фильтра “Post”

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

Например, мы можем определить наш фильтр “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}'], 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 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;
    }
}

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

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

4. Создание фильтров Шлюза

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

4.1. Определение фабрики фильтров шлюза

В целях осуществления Шлюзовой фильтр , нам придется реализовать Фабрика фильтров шлюза интерфейс. Spring Cloud Gateway также предоставляет абстрактный класс для упрощения процесса, AbstractGatewayFilter Factory класс:

@Component
public class LoggingGatewayFilterFactory extends 
  AbstractGatewayFilterFactory {

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

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

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

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

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

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

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

    // contructors, getters and setters...
}

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

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

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

@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. Регистрация фильтра шлюза со свойствами

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

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

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

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

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

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

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

4.3. Заказ фильтра шлюза

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

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

4.4. Программная регистрация фильтра шлюза

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

@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}")
              .filter(loggingFactory.apply(
              new Config("My Custom Message", true, true))))
            .uri("http://localhost:8081"))
      .build();
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Нам нужно будет получить новый экземпляр Server Web Exchange из исходного объекта exchange , изменив исходный экземпляр 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())
  .build()
  .toUri())

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

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

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

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

(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);
                });
        }));
}

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

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

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

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)
// ...

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

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

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

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

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

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

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