Автор оригинала: Priyank Srivastava.
1. Обзор
В этом уроке мы узнаем, как использовать Bucket4j для ограничения скорости API Spring REST . Мы рассмотрим ограничение скорости API, узнаем о Bucket4j и рассмотрим несколько способов ограничения скорости REST API в приложении Spring.
2. Ограничение скорости API
Ограничение скорости-это стратегия ограничения доступа к API . Он ограничивает количество вызовов API, которые клиент может выполнить в течение определенного периода времени. Это помогает защитить API от чрезмерного использования, как непреднамеренного, так и злонамеренного.
Ограничения скорости часто применяются к API путем отслеживания IP-адреса или более специфичным для бизнеса способом, таким как ключи API или токены доступа. Как разработчики API, мы можем реагировать несколькими различными способами, когда клиент достигает предела:
- Постановка запроса в очередь до истечения оставшегося периода времени
- Разрешение запроса немедленно, но взимание дополнительной платы за этот запрос
- Или, чаще всего, отклонение запроса (HTTP 429 Слишком много запросов)
3. Библиотека Ограничения Скорости Ковша
3.1. Что Такое Ведро?
Bucket 4j-это библиотека ограничения скорости Java, основанная на алгоритме token-bucket . Bucket4j-это потокобезопасная библиотека, которая может использоваться как в автономном приложении JVM, так и в кластерной среде. Он также поддерживает кэширование в памяти или распределенное кэширование через спецификацию JCache (JSR 107) .
3.2. Алгоритм Токен-ведро
Давайте рассмотрим алгоритм интуитивно, в контексте ограничения скорости API.
Предположим, что у нас есть ведро, емкость которого определяется как количество токенов, которые оно может содержать. Всякий раз, когда потребитель хочет получить доступ к конечной точке API, он должен получить токен из корзины . Мы удаляем токен из корзины, если он доступен, и принимаем запрос. С другой стороны, мы отклоняем запрос, если в корзине нет никаких токенов.
Поскольку запросы потребляют токены, мы также пополняем их с некоторой фиксированной скоростью , так что мы никогда не превышаем емкость корзины.
Давайте рассмотрим API, который имеет ограничение скорости в 100 запросов в минуту. Мы можем создать ведро вместимостью 100 и скоростью пополнения 100 токенов в минуту.
Если мы получим 70 запросов, что меньше, чем доступные токены за данную минуту, мы добавим еще только 30 токенов в начале следующей минуты, чтобы увеличить емкость корзины. С другой стороны, если мы исчерпаем все жетоны за 40 секунд, мы будем ждать 20 секунд, чтобы пополнить ведро.
4. Начало работы с ведром
4.1. Конфигурация Maven
Давайте начнем с добавления зависимости bucket 4 j в ваш pom.xml :
com.github.vladimir-bukhtoyarov bucket4j-core 4.10.0
4.2. Терминология
Прежде чем мы рассмотрим, как мы можем использовать Bucket4j, давайте кратко обсудим некоторые основные классы и то, как они представляют различные элементы в формальной модели алгоритма токен-ведро.
Интерфейс Bucket представляет собой ведро токенов с максимальной емкостью. Он предоставляет такие методы, как try Consume и try Consume И возвращает оставшиеся для потребления токенов. Эти методы возвращают результат потребления как true , если запрос соответствует ограничениям, и токен был израсходован.
Класс Bandwidth является ключевым строительным блоком корзины – он определяет пределы корзины. Мы используем Пропускную способность для настройки емкости ведра и скорости пополнения.
Класс Refill используется для определения фиксированной ставки, по которой токены добавляются в корзину. Мы можем настроить скорость как количество токенов, которые будут добавлены в данный период времени. Например, 10 ведер в секунду или 200 токенов за 5 минут и так далее.
Метод try Consume И Return Remaining в Bucket возвращает Зонд Потребления . Зонд потребления содержит, наряду с результатом потребления, состояние корзины, например, оставшиеся токены или время, оставшееся до тех пор, пока запрошенные токены снова не будут доступны в корзине.
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 позволяет нам установить несколько ограничений ( Пропускная способность ) для одного и того же ведра. Давайте добавим еще одно ограничение, которое позволяет только 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 с использованием Bucket4j
Давайте используем Bucket для применения ограничения скорости в API Spring REST.
5.1. API калькулятора площадей
Мы собираемся реализовать простой, но чрезвычайно популярный REST API калькулятора областей. В настоящее время он вычисляет и возвращает площадь прямоугольника с учетом его размеров:
@RestController class AreaCalculationController { @PostMapping(value = "/api/v1/area/rectangle") public ResponseEntityrectangle(@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 Слишком много запросов:
public ResponseEntityrectangle(@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
- Профессионал: 100 запросов в час на одного клиента API
Каждый клиент API получает уникальный ключ API, который он должен отправлять вместе с каждым запросом . Это поможет нам определить тарифный план, связанный с клиентом 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:
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 Mapcache = 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 ResponseEntityrectangle(@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 : оставшееся время, в секундах, до тех пор, пока ведро не будет снова заполнено
Мы можем вызвать Consumption Probe методы get Remaining Tokens и 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 ResponseEntitytriangle(@RequestBody TriangleDimensionsV1 dimensions) { return ResponseEntity.ok(new AreaV1("triangle", 0.5d * dimensions.getHeight() * dimensions.getBase())); }
Как оказалось, нам также нужно ограничить скорость нашей новой конечной точки. Мы можем просто скопировать и вставить код ограничения скорости из нашей предыдущей конечной точки. Или мы можем использовать Spring MVC HandlerInterceptor , чтобы отделить код ограничения скорости от бизнес-кода .
Давайте создадим перехватчик Ограничения скорости и реализуем код ограничения скорости в методе 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/**"); } }
Перехватчик Rate Limit перехватывает каждый запрос к конечным точкам 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. Стартер Загрузки Ковшовой Пружины
Давайте рассмотрим другой способ использования Bucket4j в весеннем приложении. Bucket4j Spring Boot Starter обеспечивает автоматическую настройку для Bucket 4 j, которая помогает нам достичь ограничения скорости API с помощью свойств или конфигурации приложения Spring Boot.
Как только мы интегрируем Bucket4j starter в ваше приложение, у нас будет полностью декларативная реализация ограничения скорости API, без какого-либо кода приложения .
6.1. Фильтры Ограничения скорости
В нашем примере мы использовали значение заголовка запроса X-api-key в качестве ключа для определения и применения ограничений скорости.
Стартер пружинной загрузки Bucket4j предоставляет несколько предопределенных конфигураций для определения нашего ключа ограничения скорости:
- наивный фильтр ограничения скорости, который используется по умолчанию
- фильтр по IP-адресу
- фильтры на основе выражений
Фильтры на основе выражений используют язык выражений Spring (SpEL) . SpEL предоставляет доступ к корневым объектам, таким как HttpServletRequest , которые можно использовать для построения выражений фильтра по IP-адресу ( getRemoteAddr () ), заголовкам запросов ( getHeader(‘X-api-key’) ) и так далее.
Библиотека также поддерживает пользовательские классы в выражениях фильтра, которые обсуждаются в документации .
6.2. Конфигурация Maven
Давайте начнем с добавления зависимости bucket4j-spring-boot-starter в ваш pom.xml :
com.giffing.bucket4j.spring.boot.starter bucket4j-spring-boot-starter 0.2.0
Мы использовали в памяти Map для хранения Bucket на ключ API (потребитель) в нашей предыдущей реализации. Здесь мы можем использовать абстракцию кэширования Spring для настройки хранилища в памяти, такого как Кофеин или Гуава .
Давайте добавим зависимости кэширования:
org.springframework.boot spring-boot-starter-cache javax.cache cache-api com.github.ben-manes.caffeine caffeine 2.8.2 com.github.ben-manes.caffeine jcache 2.8.2
Примечание: Мы также добавили зависимости cache , чтобы соответствовать поддержке кэширования Bucket 4 j.
Мы должны не забыть включить функцию кэширования, добавив аннотацию @EnableCaching к любому из классов конфигурации .
6.3. Конфигурация приложения
Давайте настроим наше приложение на использование стартовой библиотеки Bucket 4j. Во-первых, мы настроим Кэширование кофеина для хранения ключа API и ведра в памяти:
spring: cache: cache-names: - rate-limit-buckets caffeine: spec: maximumSize=100000,expireAfterAccess=3600s
Далее, давайте настроим Ведро:
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 (SpEL)
- bucket4j.filters.rate-limits.execute-condition – решает, следует ли выполнять ограничение скорости или нет, используя SpEL
- bucket4j.filters.rate-limits.bandwidths – определяет параметры ограничения скорости Bucket4j
Мы заменили PricingPlanService и перехватчик Ограничения скорости списком конфигураций ограничения скорости, которые оцениваются последовательно.
Давайте попробуем:
## 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 .