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 —
решает, выполнять ограничение скорости или нет, используя SpELBucket4j.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 .