«1. Обзор

В этом руководстве мы узнаем, как использовать Bucket4j для ограничения скорости Spring REST API. Мы изучим ограничение скорости API, узнаем о Bucket4j и рассмотрим несколько способов ограничения скорости API REST в приложении Spring.

2. Ограничение скорости API

Ограничение скорости — это стратегия ограничения доступа к API. Он ограничивает количество вызовов API, которые клиент может сделать в течение определенного периода времени. Это помогает защитить API от чрезмерного использования, как непреднамеренного, так и злонамеренного.

Ограничения скорости часто применяются к API путем отслеживания IP-адреса или более специфичным для бизнеса способом, таким как ключи API или токены доступа. Как разработчики API, мы можем реагировать несколькими различными способами, когда клиент достигает предела:

    Постановка запроса в очередь до истечения оставшегося периода времени Разрешение запроса немедленно, но взимание дополнительной платы за этот запрос Или, чаще всего, отклонение запроса request (HTTP 429 Too Many Requests)

3. Библиотека ограничения скорости Bucket4j

3.1. Что такое Bucket4j?

Bucket4j — это Java-библиотека ограничения скорости, основанная на алгоритме token-bucket. Bucket4j — это потокобезопасная библиотека, которую можно использовать как в автономном приложении JVM, так и в кластерной среде. Он также поддерживает кэширование в памяти или распределенное кэширование с помощью спецификации JCache (JSR107).

3.2. Алгоритм Token Bucket

Давайте посмотрим на алгоритм интуитивно, в контексте ограничения скорости API.

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

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

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

Если мы получим 70 запросов, что меньше доступных токенов в данную минуту, мы добавим еще только 30 токенов в начале следующей минуты, чтобы довести корзину до предела. С другой стороны, если мы исчерпаем все жетоны за 40 секунд, мы будем ждать 20 секунд, чтобы пополнить ведро.

4. Начало работы с Bucket4j

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

Давайте начнем с добавления зависимости Bucket4j в наш pom.xml:

<dependency>
    <groupId>com.github.vladimir-bukhtoyarov</groupId>
    <artifactId>bucket4j-core</artifactId>
    <version>4.10.0</version>
</dependency>

4.2. Терминология

Прежде чем мы рассмотрим, как мы можем использовать Bucket4j, давайте кратко обсудим некоторые основные классы и то, как они представляют различные элементы в формальной модели алгоритма token-bucket.

Интерфейс Bucket представляет собой ведро токенов с максимальной емкостью. Он предоставляет такие методы, как tryConsume и tryConsumeAndReturnRemaining для использования токенов. Эти методы возвращают результат потребления как true, если запрос соответствует ограничениям и токен был использован.

Класс Bandwidth является ключевым строительным блоком корзины — он определяет пределы корзины. Мы используем Bandwidth для настройки емкости ведра и скорости пополнения.

Класс Refill используется для определения фиксированной скорости добавления токенов в корзину. Мы можем настроить скорость как количество токенов, которые будут добавлены за определенный период времени. Например, 10 ведер в секунду или 200 токенов за 5 минут и так далее.

Метод tryConsumeAndReturnRemaining в Bucket возвращает ConsumeProbe. ConsumptionProbe содержит, наряду с результатом потребления, состояние корзины, например оставшиеся токены или время, оставшееся до того, как запрошенные токены снова станут доступны в корзине.

4.3. Основное использование

Давайте проверим некоторые основные шаблоны ограничения скорости.

Для ограничения скорости 10 запросов в минуту мы создадим корзину емкостью 10 и скоростью пополнения 10 токенов в минуту:

Refill refill = Refill.intervally(10, Duration.ofMinutes(1));
Bandwidth limit = Bandwidth.classic(10, refill);
Bucket bucket = Bucket4j.builder()
    .addLimit(limit)
    .build();

for (int i = 1; i <= 10; i++) {
    assertTrue(bucket.tryConsume(1));
}
assertFalse(bucket.tryConsume(1));

«

«Refill.intervally пополняет корзину в начале временного окна — в данном случае 10 токенов в начале минуты.

Далее давайте посмотрим на пополнение в действии.

Bandwidth limit = Bandwidth.classic(1, Refill.intervally(1, Duration.ofSeconds(2)));
Bucket bucket = Bucket4j.builder()
    .addLimit(limit)
    .build();
assertTrue(bucket.tryConsume(1));     // first request
Executors.newScheduledThreadPool(1)   // schedule another request for 2 seconds later
    .schedule(() -> assertTrue(bucket.tryConsume(1)), 2, TimeUnit.SECONDS); 

Мы установим скорость пополнения 1 токен в 2 секунды и закроем наши запросы, чтобы соблюдать ограничение скорости:

Bucket bucket = Bucket4j.builder()
    .addLimit(Bandwidth.classic(10, Refill.intervally(10, Duration.ofMinutes(1))))
    .addLimit(Bandwidth.classic(5, Refill.intervally(5, Duration.ofSeconds(20))))
    .build();

for (int i = 1; i <= 5; i++) {
    assertTrue(bucket.tryConsume(1));
}
assertFalse(bucket.tryConsume(1));

Предположим, у нас есть ограничение скорости 10 запросов в минуту. В то же время мы можем пожелать избежать всплесков, которые истощат все жетоны в течение первых 5 секунд. Bucket4j позволяет нам устанавливать несколько ограничений (пропускной способности) для одного и того же сегмента. Давайте добавим еще одно ограничение, допускающее только 5 запросов в 20-секундном временном окне:

5. Ограничение скорости в Spring API с помощью Bucket4j

Давайте используем Bucket4j для применения ограничения скорости в Spring REST API.

5.1. API калькулятора площади

@RestController
class AreaCalculationController {

    @PostMapping(value = "/api/v1/area/rectangle")
    public ResponseEntity<AreaV1> rectangle(@RequestBody RectangleDimensionsV1 dimensions) {
        return ResponseEntity.ok(new AreaV1("rectangle", dimensions.getLength() * dimensions.getWidth()));
    }
}

Мы собираемся реализовать простой, но очень популярный REST API калькулятора площади. В настоящее время он вычисляет и возвращает площадь прямоугольника с учетом его размеров:

$ curl -X POST http://localhost:9001/api/v1/area/rectangle \
    -H "Content-Type: application/json" \
    -d '{ "length": 10, "width": 12 }'

{ "shape":"rectangle","area":120.0 }

Давайте удостоверимся, что наш API запущен и работает:

5.2. Применение ограничения скорости

Теперь мы введем наивное ограничение скорости — API допускает 20 запросов в минуту. Другими словами, API отклоняет запрос, если он уже получил 20 запросов за временное окно в 1 минуту.

@RestController
class AreaCalculationController {

    private final Bucket bucket;

    public AreaCalculationController() {
        Bandwidth limit = Bandwidth.classic(20, Refill.greedy(20, Duration.ofMinutes(1)));
        this.bucket = Bucket4j.builder()
            .addLimit(limit)
            .build();
    }
    //..
}

Давайте изменим наш контроллер, чтобы создать корзину и добавить ограничение (пропускную способность):

public ResponseEntity<AreaV1> rectangle(@RequestBody RectangleDimensionsV1 dimensions) {
    if (bucket.tryConsume(1)) {
        return ResponseEntity.ok(new AreaV1("rectangle", dimensions.getLength() * dimensions.getWidth()));
    }

    return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).build();
}
# 21st request within 1 minute
$ curl -v -X POST http://localhost:9001/api/v1/area/rectangle \
    -H "Content-Type: application/json" \
    -d '{ "length": 10, "width": 12 }'

< HTTP/1.1 429

В этом API мы можем проверить, разрешен ли запрос, используя токен из корзины, используя метод tryConsume. Если мы достигли предела, мы можем отклонить запрос, ответив статусом HTTP 429 Too Many Requests:

5.3. Клиенты API и тарифный план

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

Тарифные планы помогают нам монетизировать наш API. Предположим, что у нас есть следующие планы для наших клиентов API:

Бесплатно: 20 запросов в час на клиент API Базовый: 40 запросов в час на клиент API Профессионал: 100 запросов в час на клиент API

enum PricingPlan {
    FREE {
        Bandwidth getLimit() {
            return Bandwidth.classic(20, Refill.intervally(20, Duration.ofHours(1)));
        }
    },
    BASIC {
        Bandwidth getLimit() {
            return Bandwidth.classic(40, Refill.intervally(40, Duration.ofHours(1)));
        }
    },
    PROFESSIONAL {
        Bandwidth getLimit() {
            return Bandwidth.classic(100, Refill.intervally(100, Duration.ofHours(1)));
        }
    };
    //..
}

Каждый клиент API получает уникальный ключ API, который они должны отправлять вместе с каждым запросом. Это поможет нам определить тарифный план, связанный с клиентом API.

enum PricingPlan {
    
    static PricingPlan resolvePlanFromApiKey(String apiKey) {
        if (apiKey == null || apiKey.isEmpty()) {
            return FREE;
        } else if (apiKey.startsWith("PX001-")) {
            return PROFESSIONAL;
        } else if (apiKey.startsWith("BX001-")) {
            return BASIC;
        }
        return FREE;
    }
    //..
}

Давайте определим ограничение скорости (Bandwidth) для каждого тарифного плана:

class PricingPlanService {

    private final Map<String, Bucket> cache = new ConcurrentHashMap<>();

    public Bucket resolveBucket(String apiKey) {
        return cache.computeIfAbsent(apiKey, this::newBucket);
    }

    private Bucket newBucket(String apiKey) {
        PricingPlan pricingPlan = PricingPlan.resolvePlanFromApiKey(apiKey);
        return Bucket4j.builder()
            .addLimit(pricingPlan.getLimit())
            .build();
    }
}

Далее добавим метод для разрешения тарифного плана из заданного ключа API:

@RestController
class AreaCalculationController {

    private PricingPlanService pricingPlanService;

    public ResponseEntity<AreaV1> rectangle(@RequestHeader(value = "X-api-key") String apiKey,
        @RequestBody RectangleDimensionsV1 dimensions) {

        Bucket bucket = pricingPlanService.resolveBucket(apiKey);
        ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);
        if (probe.isConsumed()) {
            return ResponseEntity.ok()
                .header("X-Rate-Limit-Remaining", Long.toString(probe.getRemainingTokens()))
                .body(new AreaV1("rectangle", dimensions.getLength() * dimensions.getWidth()));
        }
        
        long waitForRefill = probe.getNanosToWaitForRefill() / 1_000_000_000;
        return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
            .header("X-Rate-Limit-Retry-After-Seconds", String.valueOf(waitForRefill))
            .build();
    }
}

Далее нам нужно сохранить Bucket для каждого ключа API и получить Bucket для ограничения скорости:

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

    Давайте пройдемся по изменениям. Клиент API отправляет ключ API с заголовком запроса X-api-key. Мы используем PricingPlanService, чтобы получить ведро для этого ключа API и проверить, разрешен ли запрос, используя токен из ведра.

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

X-Rate-Limit-Remaining: количество токенов, оставшихся в текущее время. window X-Rate-Limit-Retry-After-Seconds: оставшееся время в секундах до заполнения корзины до следующего пополнения соответственно. Метод getNanosToWaitForRefill возвращает 0, если мы можем успешно использовать токен.

## successful request
$ curl -v -X POST http://localhost:9001/api/v1/area/rectangle \
    -H "Content-Type: application/json" -H "X-api-key:FX001-99999" \
    -d '{ "length": 10, "width": 12 }'

< HTTP/1.1 200
< X-Rate-Limit-Remaining: 11
{"shape":"rectangle","area":120.0}

## rejected request
$ curl -v -X POST http://localhost:9001/api/v1/area/rectangle \
    -H "Content-Type: application/json" -H "X-api-key:FX001-99999" \
    -d '{ "length": 10, "width": 12 }'

< HTTP/1.1 429
< X-Rate-Limit-Retry-After-Seconds: 583

Вызовем API:

5.4. Использование Spring MVC Interceptor

@PostMapping(value = "/triangle")
public ResponseEntity<AreaV1> triangle(@RequestBody TriangleDimensionsV1 dimensions) {
    return ResponseEntity.ok(new AreaV1("triangle", 0.5d * dimensions.getHeight() * dimensions.getBase()));
}

Пока все хорошо! Предположим, теперь нам нужно добавить новую конечную точку API, которая вычисляет и возвращает площадь треугольника с учетом его высоты и основания:

Оказывается, нам также нужно ограничить скорость нашей новой конечной точки. Мы можем просто скопировать и вставить код ограничения скорости из нашей предыдущей конечной точки. Или мы можем использовать HandlerInterceptor Spring MVC, чтобы отделить код ограничения скорости от бизнес-кода.

public class RateLimitInterceptor implements HandlerInterceptor {

    private PricingPlanService pricingPlanService;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) 
      throws Exception {
        String apiKey = request.getHeader("X-api-key");
        if (apiKey == null || apiKey.isEmpty()) {
            response.sendError(HttpStatus.BAD_REQUEST.value(), "Missing Header: X-api-key");
            return false;
        }

        Bucket tokenBucket = pricingPlanService.resolveBucket(apiKey);
        ConsumptionProbe probe = tokenBucket.tryConsumeAndReturnRemaining(1);
        if (probe.isConsumed()) {
            response.addHeader("X-Rate-Limit-Remaining", String.valueOf(probe.getRemainingTokens()));
            return true;
        } else {
            long waitForRefill = probe.getNanosToWaitForRefill() / 1_000_000_000;
            response.addHeader("X-Rate-Limit-Retry-After-Seconds", String.valueOf(waitForRefill));
            response.sendError(HttpStatus.TOO_MANY_REQUESTS.value(),
              "You have exhausted your API Request Quota"); 
            return false;
        }
    }
}

Давайте создадим RateLimitInterceptor и реализуем код ограничения скорости в методе preHandle:

public class AppConfig implements WebMvcConfigurer {
    
    private RateLimitInterceptor interceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(interceptor)
            .addPathPatterns("/api/v1/area/**");
    }
}

«

«Наконец, мы должны добавить перехватчик в InterceptorRegistry:

## successful request
$ curl -v -X POST http://localhost:9001/api/v1/area/triangle \
    -H "Content-Type: application/json" -H "X-api-key:FX001-99999" \
    -d '{ "height": 15, "base": 8 }'

< HTTP/1.1 200
< X-Rate-Limit-Remaining: 9
{"shape":"triangle","area":60.0}

## rejected request
$ curl -v -X POST http://localhost:9001/api/v1/area/triangle \
    -H "Content-Type: application/json" -H "X-api-key:FX001-99999" \
    -d '{ "height": 15, "base": 8 }'

< HTTP/1.1 429
< X-Rate-Limit-Retry-After-Seconds: 299
{ "status": 429, "error": "Too Many Requests", "message": "You have exhausted your API Request Quota" }

RateLimitInterceptor перехватывает каждый запрос к нашим конечным точкам API расчета площади.

Давайте попробуем нашу новую конечную точку:

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

6. Bucket4j Spring Boot Starter

Давайте рассмотрим другой способ использования Bucket4j в приложении Spring. Bucket4j Spring Boot Starter обеспечивает автоматическую настройку для Bucket4j, которая помогает нам добиться ограничения скорости API с помощью свойств или конфигурации приложения Spring Boot.

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

6.1. Фильтры ограничения скорости

    В нашем примере мы использовали значение X-api-key заголовка запроса в качестве ключа для определения и применения ограничений скорости.

Bucket4j Spring Boot Starter предоставляет несколько предопределенных конфигураций для определения нашего ключа ограничения скорости:

простой фильтр ограничения скорости, который является фильтром по умолчанию с помощью фильтров на основе выражений IP-адреса

Фильтры на основе выражений используют Spring Язык выражений (SpEL). SpEL предоставляет доступ к корневым объектам, таким как HttpServletRequest, которые можно использовать для построения выражений фильтрации по IP-адресу (getRemoteAddr()), заголовкам запросов (getHeader(“X-api-key”)) и т. д.

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

<dependency>
    <groupId>com.giffing.bucket4j.spring.boot.starter</groupId>
    <artifactId>bucket4j-spring-boot-starter</artifactId>
    <version>0.2.0</version>
</dependency>

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

Давайте начнем с добавления зависимости Bucket4j-spring-boot-starter в наш pom.xml:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
    <groupId>javax.cache</groupId>
    <artifactId>cache-api</artifactId>
</dependency>
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
    <version>2.8.2</version>
</dependency>
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>jcache</artifactId>
    <version>2.8.2</version>
</dependency>

Мы использовали карту в памяти для хранения ключа Bucket для каждого API (потребителя) в нашем более ранняя реализация. Здесь мы можем использовать абстракцию кэширования Spring для настройки хранилища в памяти, такого как Caffeine или Guava.

Давайте добавим зависимости кэширования:

Примечание. Мы также добавили зависимости jcache, чтобы обеспечить поддержку кэширования Bucket4j.

Мы должны не забыть включить функцию кэширования, добавив аннотацию @EnableCaching к любому из классов конфигурации.

spring:
  cache:
    cache-names:
    - rate-limit-buckets
    caffeine:
      spec: maximumSize=100000,expireAfterAccess=3600s

6.3. Конфигурация приложения

bucket4j:
  enabled: true
  filters:
  - cache-name: rate-limit-buckets
    url: /api/v1/area.*
    strategy: first
    http-response-body: "{ \"status\": 429, \"error\": \"Too Many Requests\", \"message\": \"You have exhausted your API Request Quota\" }"
    rate-limits:
    - expression: "getHeader('X-api-key')"
      execute-condition: "getHeader('X-api-key').startsWith('PX001-')"
      bandwidths:
      - capacity: 100
        time: 1
        unit: hours
    - expression: "getHeader('X-api-key')"
      execute-condition: "getHeader('X-api-key').startsWith('BX001-')"
      bandwidths:
      - capacity: 40
        time: 1
        unit: hours
    - expression: "getHeader('X-api-key')"
      bandwidths:
      - capacity: 20
        time: 1
        unit: hours

Давайте настроим наше приложение для использования стартовой библиотеки Bucket4j. Во-первых, мы настроим кэширование Caffeine для хранения ключа API и Bucket в памяти:

    Затем давайте настроим Bucket4j:

Итак, что мы только что настроили?

Bucket4j.enabled=true — включает автоматическую настройку Bucket4j. Bucket4j.filters.cache-name — получает Bucket для ключа API из кеша. rate limit Bucket4j.filters.strategy=first — останавливается на первой совпадающей конфигурации ограничения скорости. Bucket4j.filters.rate-limits.expression — извлекает ключ с помощью языка выражений Spring (SpEL) Bucket4j.filters.rate-limits. execute-condition — решает, выполнять ли ограничение скорости или нет, используя SpEL. Bucket4j.filters.rate-limits.bandwidths — определяет параметры ограничения скорости Bucket4j. список конфигураций ограничения скорости, которые оцениваются последовательно.

## successful request
$ curl -v -X POST http://localhost:9000/api/v1/area/triangle \
    -H "Content-Type: application/json" -H "X-api-key:FX001-99999" \
    -d '{ "height": 20, "base": 7 }'

< HTTP/1.1 200
< X-Rate-Limit-Remaining: 7
{"shape":"triangle","area":70.0}

## rejected request
$ curl -v -X POST http://localhost:9000/api/v1/area/triangle \
    -H "Content-Type: application/json" -H "X-api-key:FX001-99999" \
    -d '{ "height": 7, "base": 20 }'

< HTTP/1.1 429
< X-Rate-Limit-Retry-After-Seconds: 212
{ "status": 429, "error": "Too Many Requests", "message": "You have exhausted your API Request Quota" }

Давайте попробуем:

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

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