1. 概要

このチュートリアルでは、Bucket4jを使用してSpringRESTAPIのレート制限を行う方法を学習します。 APIのレート制限について説明し、Bucket4jについて学び、SpringアプリケーションでRESTAPIをレート制限するいくつかの方法を実行します。

2. APIレート制限

レート制限は、APIへのアクセスを制限する戦略です。 これは、クライアントが特定の時間枠内に実行できるAPI呼び出しの数を制限します。 これは、意図しないものと悪意のあるものの両方の乱用からAPIを保護するのに役立ちます。

レート制限は、多くの場合、IPアドレスを追跡することによって、またはAPIキーやアクセストークンなどのよりビジネス固有の方法でAPIに適用されます。 API開発者として、クライアントが制限に達したときに、いくつかの異なる方法で応答することを選択できます。

  • 残りの時間が経過するまでリクエストをキューに入れる
  • リクエストをすぐに許可しますが、このリクエストには追加料金がかかります
  • または、最も一般的には、リクエストを拒否します(HTTP 429 Too Many Requests)

3. Bucket4jレート制限ライブラリ

3.1. Bucket4jとは何ですか?

Bucket4jは、token-bucketアルゴリズムに基づくJavaレート制限ライブラリです。 Bucket4jは、スタンドアロンのJVMアプリケーションまたはクラスター環境のいずれかで使用できるスレッドセーフなライブラリです。 また、 JCache(JSR107)仕様を介したインメモリまたは分散キャッシュもサポートしています。

3.2. トークンバケットアルゴリズム

APIレート制限のコンテキストで、アルゴリズムを直感的に見てみましょう。

保持できるトークンの数として容量が定義されているバケットがあるとします。 コンシューマーがAPIエンドポイントにアクセスする場合は常に、バケットからトークンを取得する必要があります。 利用可能な場合はバケットからトークンを削除し、リクエストを受け入れます。 一方、バケットにトークンがない場合は、リクエストを拒否します。

リクエストはトークンを消費しているため、バケットの容量を超えないように、一定のレートでそれらを補充しています。

1分あたり100リクエストのレート制限があるAPIを考えてみましょう。 100の容量、1分あたり100トークンの補充率のバケットを作成できます。

70のリクエストを受け取った場合、これは1分間に利用可能なトークンよりも少なく、次の1分間の開始時にトークンを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の使用方法を説明する前に、いくつかのコアクラスと、それらがトークンバケットアルゴリズムの正式なモデルのさまざまな要素をどのように表すかについて簡単に説明しましょう。

Bucket インターフェースは、最大容量のトークンバケットを表します。 トークンを消費するためのtryConsumetryConsumeAndReturnRemainingなどのメソッドを提供します。 これらのメソッドは、リクエストが制限に準拠し、トークンが消費された場合、消費の結果をtrueとして返します。

Bandwidth クラスは、バケットの主要な構成要素であり、バケットの制限を定義します。 Bandwidth を使用して、バケットの容量と補充率を構成します。

Refill クラスは、トークンがバケットに追加される固定レートを定義するために使用されます。 レートは、特定の期間に追加されるトークンの数として構成できます。 たとえば、1秒あたり10バケット、5分あたり200トークンなどです。

BuckettryConsumeAndReturnRemainingメソッドは、ConsumptionProbeを返します。 ConsumptionProbe には、消費の結果とともに、残りのトークンなどのバケットのステータス、または要求されたトークンがバケットで再び使用可能になるまでの残り時間が含まれます。

4.3. 基本的な使用法

いくつかの基本的なレート制限パターンをテストしてみましょう。

1分あたり10リクエストのレート制限の場合、容量が10で、補充レートが1分あたり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トークンです。

次に、リフィルの動作を見てみましょう。

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

1分あたり10リクエストのレート制限があるとします。 同時に、最初の5秒間にすべてのトークンを使い果たすスパイクを避けたい場合があります。 Bucket4jを使用すると、同じバケットに複数の制限( Bandwidth )を設定できます。 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));

5. Bucket4jを使用したSpringAPIのレート制限

Bucket4jを使用して、SpringRESTAPIにレート制限を適用してみましょう。

5.1. Area Calculator API

シンプルですが非常に人気のあるエリア計算機RESTAPIを実装します。 現在、寸法が指定された長方形の面積を計算して返します。

@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では1分あたり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 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クライアントに対して次の計画があると仮定します。

  • 無料:APIクライアントごとに1時間あたり20リクエスト
  • 基本:APIクライアントごとに1時間あたり40リクエスト
  • Professional:APIクライアントごとに1時間あたり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キーごとにバケットのメモリ内ストアができました。 Controllerを変更して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-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. SpringMVCインターセプターの使用

ここまでは順調ですね! ここで、三角形の高さと底辺を指定して三角形の面積を計算して返す新しいAPIエンドポイントを追加する必要があるとします。

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

結局のところ、新しいエンドポイントもレート制限する必要があります。 以前のエンドポイントからレート制限コードをコピーして貼り付けるだけです。 または、 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/**");
    }
}

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. Bucket4j Spring Boot Starter

SpringアプリケーションでBucket4jを使用する別の方法を見てみましょう。 Bucket4j Spring Boot Starter は、Bucket4jの自動構成を提供し、SpringBootアプリケーションのプロパティまたは構成を介してAPIレート制限を実現するのに役立ちます。

Bucket4jスターターをアプリケーションに統合すると、アプリケーションコードなしで完全に宣言型のAPIレート制限の実装ができます

6.1. レート制限フィルター

この例では、レート制限を識別して適用するためのキーとして、リクエストヘッダーX-api-keyの値を使用しました。

Bucket4j Spring Boot Starterは、レート制限キーを定義するためのいくつかの事前定義された構成を提供します。

  • デフォルトのナイーブレート制限フィルター
  • IPアドレスでフィルタリング
  • 式ベースのフィルター

式ベースのフィルターは、 Spring Expression Language(SpEL)を使用します。 SpELは、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>

以前の実装では、メモリ内の Map を使用して、APIキー(コンシューマー)ごとにBucketを格納していました。 ここでは、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>

注:Bucket4jのキャッシュサポートに準拠するために、jcache依存関係も追加しました。

@EnableCachingアノテーションを任意の構成クラスに追加して、キャッシュ機能を有効にすることを忘れないでください。

6.3. アプリケーション構成

Bucket4jスターターライブラリを使用するようにアプリケーションを構成しましょう。 まず、構成カフェインキャッシュを使用して、APIキーとバケットをメモリに保存します。

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

次に、 configureBucket4jを作成しましょう。

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レート制限パラメーターを定義します

PricingPlanServiceRateLimitInterceptorを、順次評価されるレート制限構成のリストに置き換えました。

試してみましょう:

## 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. 結論

このチュートリアルでは、レート制限SpringAPIにBucket4jを使用するいくつかの異なるアプローチを見てきました。 詳細については、公式のドキュメントを確認してください。

いつものように、すべての例のソースコードは、GitHubから入手できます。