画面操作へのレート制限の追加
本体の標準機能ではありません
このページは本体を改修する場合の設計メモです。現行の仕組みはレート制限(API の日次上限と RateLimit.json)を参照してください。
前提にした現行実装(1.5.8.1)は次のとおりです。
- API とサーバースクリプトのレコード操作は、サイト単位・日次の
SiteModel.WithinApiLimits()で数える。画面からの作成・更新・削除は数えない。 RateLimit.jsonによるリクエスト単位の制限(ASP.NET Core のレートリミッター)があり、画面のレコード操作にはGeneralポリシーが掛かる。ただし有効にするにはライセンスのRateLimitオプションが要る。
そのため、改修を考える場面は次の 2 つです。
- 画面からの操作も、API と同じ「サイトごとの 1 日の件数」に含めたい(
WithinApiLimitsの流用)。 RateLimitオプションが無い環境で、リクエスト単位の制限を自前で入れたい(ミドルウェアの自前組み込み)。
RateLimit オプションがあるなら、2 は RateLimit.json の設定で足ります(1.5.8.1 の実装が、ここで検討する方式とほぼ同じ構成です)。
案 1:WithinApiLimits を画面操作にも掛ける
現状の分岐
画面の操作は Controllers/ItemsController から ItemModel.Create() などを呼び、API は Controllers/Api/ItemsController から ItemModel.CreateByApi() を呼びます。上限のチェックがあるのは後者だけです。
図を読み込み中…
流用できるもの
SiteModel.WithinApiLimits(context)(SiteModel.cs#L10081-L10112)。ItemModelはSetSite()の後ならSiteで参照できる。Error.Types.OverLimitApi.MessageJson(context, data)で、画面向けのResponseCollection形式のエラーを 1 行で返せる。既存の画面処理がError.Types.ItemsLimit.MessageJson(context)を返しているのと同じ形。Messages.ResponseOverLimitApi()も定義済み(Messages.cs#L4501)だが、1.5.8.1 では呼ばれていない。- メッセージ
OverLimitApiは多言語化済み(日本語は「ID: {0} のサイトで利用できるAPIの制限({1} 件/日)を超えました。」)。
改修イメージ
ItemModel.Create()・Update()・Delete()・BulkDelete() の SetSite() 直後にチェックを入れます。
public string Create(Context context)
{
SetSite(
context: context,
initSiteSettings: true);
if (!Site.WithinApiLimits(context: context))
{
return Error.Types.OverLimitApi.MessageJson(
context: context,
data: new[]
{
Site.SiteId.ToString(),
context.ContractSettings.ApiLimit().ToString()
});
}
switch (Site.ReferenceType)
{
// 既存の処理
}
}超過すると、画面にはエラーメッセージ(alert-error)が出ます。
注意点
| 観点 | 内容 |
|---|---|
| カウンターの共有 | Sites.ApiCount は API と共通。画面と API の両方で使うサイトは上限に早く達する。画面だけ別に数えたいなら Sites に別の列(例: FormCount・FormCountDate)と判定メソッドを足す必要があり、列の追加は CodeDefiner の定義変更になる |
| 並行処理 | WithinApiLimits() はロックなしの読み取り → 加算 → 書き込みなので、同時操作で上限をわずかに超える |
| 有効化 | LimitPerSite の既定は 0(無制限)。改修しても Api.json の LimitPerSite かテナントの ApiLimitPerSite を設定しない限り効かない |
| メッセージ | 文言が「API の制限」になる。画面用の文言にしたいなら App_Data/Displays/ に表示文字列を足す |
| 1 日単位 | 日次の件数制限なので、短時間の連打(数秒で数百件)を止める用途には向かない。その用途は案 2 |
案 2:ASP.NET Core のレートリミッターを自前で組み込む
.NET 7 以降は Microsoft.AspNetCore.RateLimiting と System.Threading.RateLimiting がフレームワークに含まれているので、NuGet の追加は要りません(1.5.8.1 は net10.0)。
WithinApiLimits との違い
| 項目 | WithinApiLimits | レートリミッター |
|---|---|---|
| 単位 | サイト | 任意(ユーザー・IP・API キー・テナントなど) |
| 時間枠 | 1 日固定 | 秒〜日で自由 |
| アルゴリズム | 固定の日次カウント | FixedWindow・SlidingWindow・TokenBucket・Concurrency |
| カウンター | DB(Sites) | プロセスのメモリ |
| 並行処理 | ロックなし | フレームワーク内で排他済み |
| 判定の場所 | モデルの中 | HTTP ミドルウェア(コントローラーの手前) |
| 複数台構成 | DB 共有なので台数に関係なく共通 | サーバーごとに別。実効上限は設定値 × 台数 |
構成
1.5.8.1 の RateLimit.json と同じく、次の 3 点で組み込みます。
Startup.ConfigureServicesでservices.AddRateLimiter()を呼び、名前付きポリシーを登録する。Startup.ConfigureのUseAuthentication()・UseAuthorization()の後にapp.UseRateLimiter()を置く(認証後に置くとユーザー単位のキーが使える。1.5.8.1 もこの位置。Startup.cs#L653-L664)。- コントローラーかアクションに
[EnableRateLimiting("ポリシー名")]を付ける。除外するアクションは[DisableRateLimiting]。ルート単位ならMapControllerRoute(...).RequireRateLimiting("ポリシー名")。
services.AddRateLimiter(options =>
{
options.AddPolicy("FormPost", httpContext =>
{
var key = httpContext.User?.Identity?.IsAuthenticated == true
? httpContext.User.Identity.Name
: httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
return RateLimitPartition.GetFixedWindowLimiter(
partitionKey: key,
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 100,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0
});
});
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
});キーの候補は次のとおりです。
| キー | 取得元 | 特徴 |
|---|---|---|
| IP アドレス | HttpContext.Connection.RemoteIpAddress | 未認証のリクエストにも使える。NAT やプロキシの後ろでは共有される |
| ユーザー | HttpContext.User.Identity.Name(ログイン ID) | 認証済みのみ |
| テナント | Context.TenantId | マルチテナント向き |
| サイト | ルートの id から解決 | WithinApiLimits と同じ粒度だが、ルートからの抽出が必要 |
上限値はハードコードせず、App_Data/Parameters/ に JSON を置いて Implem.ParameterAccessor/Parts にクラスを作り、Initializer.SetParameters() で読み込むのがプリザンターの流儀です。1.5.8.1 の RateLimit.json と Parts/RateLimit.cs がその実例なので、項目の設計はそれに合わせると移行しやすくなります。
拒否時の応答
画面の操作は Ajax で、応答は ResponseCollection 形式の JSON です。ミドルウェアが素の 429 を返すと、画面側は通常のエラー処理に落ちてメッセージが出ません。OnRejected で、X-Requested-With: XMLHttpRequest のリクエストには ResponseCollection 形式を返すと、既存のメッセージ表示に乗ります。1.5.8.1 の BodyRateLimitHelper.OnRejectedAsync() はこの切り替えをしています(BodyRateLimitHelper.cs#L311-L364)。
options.OnRejected = async (ctx, cancellationToken) =>
{
var response = ctx.HttpContext.Response;
response.ContentType = "application/json;charset=utf-8";
if (ctx.HttpContext.Request.Headers["X-Requested-With"] == "XMLHttpRequest")
{
var rc = new ResponseCollection();
rc.Message(
message: new Message(id: null, text: "リクエストが多すぎます。", css: "alert-error"),
target: "#Message");
await response.WriteAsync(rc.ToJson(), cancellationToken);
}
else
{
await response.WriteAsJsonAsync(
new { StatusCode = 429, Message = "Too many requests." },
cancellationToken);
}
};複数台構成
カウンターはプロセスのメモリにあるので、ロードバランサーの後ろに複数台並べるとサーバーごとに別々に数えます。台数に関係なく共通の上限にしたい場合は、PartitionedRateLimiter を自作して Redis などにカウンターを置きます。プリザンターは StackExchange.Redis を参照済みです(Implem.Pleasanter.csproj#L84)。
WithinApiLimits との併用
API にミドルウェアの制限を掛けると、API は「ミドルウェアの短期の制限」と「WithinApiLimits の日次の件数」の二重になります。両者は目的が違う(瞬間的な集中と 1 日の総量)ので併用して構いません。ミドルウェアを画面だけに掛けるなら、API コントローラーには付けないか [DisableRateLimiting] を付けます。