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

Ограничение скорости API Spring с использованием Bucket4j

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

1. Обзор

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

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

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

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

  • Очередь запроса до истечения оставшегося периода времени
  • Разрешить запрос немедленно, но взимать дополнительную плату за этот запрос
  • Или, чаще всего, отклонение запроса (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 токенов в начале минуты.

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

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

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

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

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

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

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

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

Мы собираемся реализовать простой, но чрезвычайно популярный REST 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()));
}
}

Давайте удостоверимся, что наш 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 }

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

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

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

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

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

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

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

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

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

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-ключу:

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;
}
//..
}

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

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. Давайте изменим наш контроллер , чтобы использовать PricingPlanService :

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

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

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

  • X-Rate-Limit-Remaining : количество токенов, оставшихся в текущем временном окне.
  • X-Rate-Limit-Retry-After-Seconds : оставшееся время в секундах до повторного заполнения ведра.

Мы можем вызвать методы ConsumptionProbe getRemainingTokens и getNanosToWaitForRefill, чтобы получить количество оставшихся токенов в ведре и время, оставшееся до следующего пополнения, соответственно. Метод getNanosToWaitForRefill возвращает 0, если мы можем успешно использовать токен.

Давайте вызовем API:

## 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

5.4. Использование перехватчика Spring MVC

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

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

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

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

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

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

public class AppConfig implements WebMvcConfigurer {

private RateLimitInterceptor interceptor;

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

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

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

## 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" }

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

6. Стартер Spring Boot Bucket4j

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

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

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

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

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

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

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

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

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

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

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

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

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

<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>

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

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

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

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

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

Далее настроим Bucket4j:

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

Мы заменили PricingPlanService и RateLimitInterceptor списком конфигураций ограничения скорости, которые оцениваются последовательно.

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

## 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 с ограничением скорости. Обязательно ознакомьтесь с официальной документацией , чтобы узнать больше.

Как обычно, исходный код всех примеров доступен на GitHub .