使用Bucket4j限制Spring API的訪問速率 - Baeldung

banq發表於2020-06-11

在本教程中,我們將學習如何使用  Bucket4j對Spring REST API進行速率限制。我們將探索API速率限制,瞭解Bucket4j,並透過一些在Spring應用程式中限制REST API速率的方法進行工作。
速率限制是一種限制對API的訪問的策略。它限制了客戶端可以在特定時間範圍內進行的API呼叫次數。這有助於防止API遭到無意​​和惡意的過度使用。
速率限制通常透過跟蹤IP地址或以更特定於業務的方式(例如API金鑰或訪問令牌)應用於API。作為API開發人員,我們可以選擇在客戶端達到最大限制時以幾種不同的方式做出響應:
  • 排隊請求,直到剩餘時間段已過
  • 立即允許該請求,但為此請求收取額外費用
  • 或者,最常見的是拒絕請求(HTTP 429太多請求)

Bucket4j是一個基於令牌桶演算法的Java速率限制庫。Bucket4j是一個執行緒安全的庫,可以在獨立的JVM應用程式或叢集環境中使用。它還透過JCache(JSR107)規範支援記憶體或分散式快取。

令牌桶演算法
讓我們在API速率限制的情況下直觀地檢視演算法。
假設我們有一個儲存桶,其容量定義為它可以容納的令牌數量。每當消費者想要訪問API端點時,它都必須從bucket中獲取令牌。我們會從儲存桶中刪除令牌(如果有)並接受請求。另一方面,如果儲存桶沒有任何令牌,我們將拒絕請求。
當請求正在消耗令牌時,我們還將以一定的固定速率對其進行補充,以使我們永遠不會超過儲存桶的容量。
讓我們考慮一個速率限制為每分鐘100個請求的API。我們可以建立一個容量為100的儲存桶,每分鐘的重新填充速率為100個令牌。
如果我們收到70個請求,少於給定分鐘內可用令牌的數量,則我們將在下一分鐘開始時僅新增30個令牌以使儲存桶達到最大容量。另一方面,如果我們在40秒內用完所有令牌,我們將等待20秒以重新填充儲存桶。

在介紹如何使用Bucket4j之前,讓我們簡要討論一些核心類,以及它們如何表示令牌桶演算法形式模型中的不同元素。

  • Bucket介面表示與最大容量令牌桶。它提供了諸如tryConsume和  tryConsumeAndReturnRemaining之類的方法來使用令牌。如果請求符合限制,並且令牌已使用,則這些方法將使用的結果返回為  true。
  • Bandwidth 類是儲存桶的關鍵構建塊:它定義了桶的極限。我們使用 “ 頻寬”來配置儲存桶的容量和填充速度。
  •  Refill 類用於定義在該令牌新增到桶中的固定速率。我們可以將速率配置為在給定時間段內要新增的令牌數量。例如,每秒10個儲存桶或每5分鐘200個令牌,依此類推。
  • Bucket中的tryConsumeAndReturnRemaining方法返回ConsumptionProbe。ConsumptionProbe包含消耗的結果以及儲存區的狀態,例如剩餘的令牌,或者直到請求的令牌再次在儲存區中可用之前的剩餘時間。


Bucket4j入門
首先,將bucket4j 依賴項新增到我們的pom.xml中:

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

讓我們測試一些基本的速率限制模式。
對於每分鐘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.interval在時間視窗的開始間隔地重新填充儲存桶,每分鐘開始時填充 10個令牌。
接下來,讓我們看看重新填充的動作。
我們將重新填充速率設定為每2秒1個令牌,並限制我們的請求以遵守速率限制:

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允許我們在同一個儲存桶上設定多個限制(頻寬)。讓我們新增另一個限制,該限制在20秒的時間視窗內僅允許5個請求:

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


使用Bucket4j對Spring API進行速率限制
有一個簡單但非常流行的面積計算器REST API。當前,它根據給定的尺寸計算並返回矩形的面積。現在,我們將引入一個樸素的速率限制-該API允許每分鐘20個請求。換句話說,如果API在1分鐘的時間視窗內已收到20個請求,則拒絕該請求。
建立一個儲存桶並新增限制(頻寬):

@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 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客戶端和定價計劃
現在我們有了一個可以限制API請求的簡單速率限制。接下來,讓我們為更多以業務為中心的價格限制引入定價計劃。
定價計劃可幫助我們透過API獲利。假設我們對API客戶端有以下計劃:

  • 免費:每個API客戶端每小時20個請求
  • 基本:每個API客戶端每小時40個請求
  • 專業:每個API客戶端每小時100個請求

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


接下來,我們需要為每個API金鑰儲存儲存桶,並檢索儲存桶以進行速率限制:

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客戶端傳送帶有X-api-key請求標頭的API金鑰。我們使用  PricingPlanService獲取此API金鑰的儲存桶,並透過使用儲存桶中的令牌來檢查是否允許該請求。
為了增強API的客戶端體驗,我們將使用以下附加響應標頭髮送有關速率限制的資訊:
  • X-Rate-Limit-Remaining:當前時間視窗中剩餘的令牌數量
  • X速率限制重試秒後:剩餘時間(以秒為單位),直到重新填充儲存桶為止

我們可以呼叫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


使用Spring MVC攔截器
假設我們現在必須新增一個新的API端點,我們還需要對新端點進行速率限制。我們可以簡單地從先前的端點複製並貼上速率限制程式碼。或者,我們可以使用Spring MVC的HandlerInterceptor將速率限制程式碼與業務程式碼分離。讓我們建立一個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/**");
    }
}


Bucket4j Spring Starter
讓我們看看在Spring應用程式中使用Bucket4j的另一種方法。Bucket4j Spring Boot Starter 提供Bucket4j自動配置,可以幫助我們實現API率透過Spring啟動應用程式效能或配置限制。
一旦將Bucket4j入門程式整合到我們的應用程式中,我們將獲得一個完全宣告性的API速率限制實現,而無需任何應用程式程式碼。
首先,將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>

我們在早期的實現中使用記憶體對映來儲存每個API金鑰(使用者)的儲存桶。在這裡,我們可以使用Spring的快取抽象來配置記憶體儲存,例如CaffeineGuava
讓我們新增快取依賴項:

<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的快取支援。
我們將配置 Caffeine快取以在記憶體中儲存API金鑰和儲存桶:

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


在我們的示例中,我們使用了請求標頭X-api-key的值作為標識和應用速率限制的鍵。
Bucket4j Spring Boot Starter提供了幾種預定義的配置來定義我們的速率限制金鑰:
  • 簡單的速率限制過濾器,這是預設設定
  • 按IP地址過濾
  • 基於表示式的過濾器

基於表示式的過濾器使用Spring Expression Language(SpEL)。SpEL提供對根物件的訪問,例如HttpServletRequest,可用於在IP地址(getRemoteAddr()),請求標頭(getHeader('X-api-key'))上構建過濾器表示式,等等。
該庫還在過濾器表示式中支援自定義類,在文件中對此進行了討論。

讓我們配置 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 – 從快取中獲取  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" }


所有示例的原始碼都可以在GitHub上獲得

相關文章