速率限制
AdonisJS 提供了一个官方包,用于在 Web 应用或 API 服务器中实现速率限制。该速率限制器提供 redis、mysql、postgresql、sqlite 和 memory 作为存储选项,并具备创建自定义存储提供者的能力。
@adonisjs/limiter 包构建在 node-rate-limiter-flexible 包之上,它提供了最快的速率限制 API 之一,并使用原子递增来避免竞态条件。
安装
使用以下命令安装并配置该包:
node ace add @adonisjs/limiter
-
使用检测到的包管理器安装
@adonisjs/limiter包。 -
在
adonisrc.ts文件中注册以下服务提供者。{providers: [// ...other providers() => import('@adonisjs/limiter/limiter_provider')]} -
创建
config/limiter.ts文件。 -
创建
start/limiter.ts文件。该文件用于定义 HTTP 节流中间件。 -
在
start/env.ts文件中定义以下环境变量及其校验。LIMITER_STORE=redis -
可选地,如果使用的是
database存储,则为rate_limits表创建数据库迁移。
配置
速率限制器的配置存储在 config/limiter.ts 文件中。
另请参阅:速率限制器配置桩(stub)
import env from '#start/env'
import { defineConfig, stores } from '@adonisjs/limiter'
const limiterConfig = defineConfig({
default: env.get('LIMITER_STORE'),
stores: {
redis: stores.redis({}),
database: stores.database({
tableName: 'rate_limits'
}),
memory: stores.memory({}),
},
})
export default limiterConfig
declare module '@adonisjs/limiter/types' {
export interface LimitersList extends InferLimiters<typeof limiterConfig> {}
}
-
default
-
用于应用速率限制的
default存储。该存储在同一配置文件的stores对象中定义。 -
stores
-
你计划在应用中使用的一组存储。我们建议始终配置
memory存储,它可以在测试期间使用。
环境变量
默认的速率限制器使用 LIMITER_STORE 环境变量定义,因此你可以在不同环境之间切换不同的存储。例如,在测试期间使用 memory 存储,在开发和生产环境中使用 redis 存储。
此外,必须对环境变量进行校验,以允许其中一个预配置的存储。校验使用 Env.schema.enum 规则定义在 start/env.ts 文件中。
{
LIMITER_STORE: Env.schema.enum(['redis', 'database', 'memory'] as const),
}
共享选项
以下是所有内置存储共享的选项列表。
-
keyPrefix
-
定义存储在数据库存储中的键的前缀。数据库存储会忽略
keyPrefix,因为可以使用不同的数据库表来隔离数据。 -
execEvenly
-
execEvenly选项会在节流请求时添加一个延迟,以便所有请求在给定的时间段结束时被耗尽。例如,如果你允许用户 每分钟 10 个请求,所有请求都会有人工延迟,因此第 10 个请求会在 1 分钟结束时完成。阅读
rate-limiter-flexible仓库上的 平滑流量峰值 文章以了解更多关于execEvenly选项的信息。 -
inMemoryBlockOnConsumed
-
定义在内存中阻塞该键之前应消耗的请求数。例如,你允许用户 每分钟 10 个请求,而他们在前 10 秒内就已经消耗了所有请求。
然而,他们继续向服务器发出请求,因此速率限制器必须先查询数据库,然后才能拒绝该请求。
为了减少数据库负载,你可以定义在消耗多少个请求之后,我们应该停止查询数据库并在内存中阻塞该键。
{duration: '1 minute',requests: 10,/*** After 12 requests, block the key within the* memory and stop consulting the database.*/inMemoryBlockOnConsumed: 12,} -
inMemoryBlockDuration
-
在内存中阻塞该键的持续时间。该选项会减少数据库负载,因为后端存储会先在内存中检查某个键是否被阻塞。
{inMemoryBlockDuration: '1 min'}
Redis 存储
redis 存储对 @adonisjs/redis 包有对等依赖(peer dependency);因此,在使用 redis 存储之前,你必须配置该包。
以下是 redis 存储接受的选项列表(连同共享选项)。
{
redis: stores.redis({
connectionName: 'main',
rejectIfRedisNotReady: false,
}),
}
-
connectionName
-
connectionName属性引用在config/redis.ts文件中定义的连接。我们建议使用一个单独的 redis 数据库用于速率限制器。 -
rejectIfRedisNotReady
-
当 Redis 连接的状态不是
ready时,拒绝速率限制请求。
数据库存储
database 存储对 @adonisjs/lucid 包有对等依赖(peer dependency),因此在使用数据库存储之前,你必须配置该包。
以下是数据库存储接受的选项列表(连同共享选项)。
只有 MySQL、PostgreSQL 和 SQLite 数据库可以与数据库存储一起使用。
{
database: stores.database({
connectionName: 'mysql',
dbName: 'my_app',
tableName: 'rate_limits',
schemaName: 'public',
clearExpiredByTimeout: false,
}),
}
-
connectionName
-
对
config/database.ts文件中定义的数据库连接引用。如果未定义,我们将使用默认的数据库连接。 -
dbName
-
用于执行 SQL 查询的数据库。我们尝试从
config/database.ts文件中定义的连接配置推断dbName的值。但是,如果使用连接字符串,则必须通过此属性提供数据库名称。 -
tableName
-
用于存储速率限制的数据库表。
-
schemaName
-
用于执行 SQL 查询的 schema(仅限 PostgreSQL)。
-
clearExpiredByTimeout
-
启用后,数据库存储将每 5 分钟清除一次过期的键。请注意,只会清除过期超过 1 小时的键。
限制 HTTP 请求
配置好速率限制器后,你可以使用 limiter.define 方法创建 HTTP 节流中间件。limiter 服务是一个使用 config/limiter.ts 文件中定义的配置创建的 LimiterManager 类的单例实例。
如果你打开 start/limiter.ts 文件,你会发现一个预定义的全局节流中间件,可以应用在单个路由或一组路由上。类似地,你可以在应用中根据需要创建任意数量的节流中间件。
在下面的示例中,全局节流中间件基于用户的 IP 地址允许用户 每分钟 10 个请求。
import limiter from '@adonisjs/limiter/services/main'
export const throttle = limiter.define('global', () => {
return limiter.allowRequests(10).every('1 minute')
})
你可以像下面这样将 throttle 中间件应用到路由上。
import router from '@adonisjs/core/services/router'
import { throttle } from '#start/limiter'
router
.get('/', () => {})
.use(throttle)
动态速率限制
让我们创建另一个中间件来保护 API 端点。这一次,我们将根据请求的认证状态应用动态速率限制。
export const apiThrottle = limiter.define('api', (ctx) => {
/**
* Allow logged-in users to make 100 requests by
* their user ID
*/
if (ctx.auth.user) {
return limiter
.allowRequests(100)
.every('1 minute')
.usingKey(`user_${ctx.auth.user.id}`)
}
/**
* Allow guest users to make 10 requests by ip address
*/
return limiter
.allowRequests(10)
.every('1 minute')
.usingKey(`ip_${ctx.request.ip()}`)
})
import { apiThrottle } from '#start/limiter'
router
.get('/api/repos/:id/stats', [RepoStatusController])
.use(apiThrottle)
切换后端存储
你可以使用 store 方法为节流中间件指定一个特定的后端存储。例如:
limiter
.allowRequests(10)
.every('1 minute')
.store('redis')
使用自定义键
默认情况下,请求按用户的 IP 地址进行速率限制。但是,你可以使用 usingKey 方法指定一个自定义键。
limiter
.allowRequests(10)
.every('1 minute')
.usingKey(`user_${ctx.auth.user.id}`)
封锁用户
如果用户即使在耗尽配额后仍继续发出请求,你可以使用 blockFor 方法将他们封锁一段指定的时长。该方法接受以秒为单位的时间段或时间表达式。
limiter
.allowRequests(10)
.every('1 minute')
/**
* Will be blocked for 30mins, if they send more than
* 10 requests under one minute
*/
.blockFor('30 mins')
处理 ThrottleException
当用户在指定时间范围内耗尽所有请求时,节流中间件会抛出 E_TOO_MANY_REQUESTS 异常。该异常将使用以下内容协商规则自动转换为 HTTP 响应。
-
带有
Accept=application/json头的 HTTP 请求将收到一个错误消息数组。每个数组元素都是一个带有 message 属性的对象。 -
带有
Accept=application/vnd.api+json头的 HTTP 请求将收到按照 JSON API 规范格式化的错误消息数组。 -
所有其他请求将收到一个纯文本响应消息。但是,你可以使用状态页面为速率限制器错误显示自定义错误页面。
你也可以在全局异常处理程序中自行处理该错误。
import { errors } from '@adonisjs/limiter'
import { HttpContext, ExceptionHandler } from '@adonisjs/core/http'
export default class HttpExceptionHandler extends ExceptionHandler {
protected debug = !app.inProduction
protected renderStatusPages = app.inProduction
async handle(error: unknown, ctx: HttpContext) {
if (error instanceof errors.E_TOO_MANY_REQUESTS) {
const message = error.getResponseMessage(ctx)
const headers = error.getDefaultHeaders()
Object.keys(headers).forEach((header) => {
ctx.response.header(header, headers[header])
})
return ctx.response.status(error.status).send(message)
}
return super.handle(error, ctx)
}
}
自定义错误消息
除了全局处理异常之外,你还可以使用 limitExceeded 钩子自定义错误消息、状态码和响应头。
import limiter from '@adonisjs/limiter/services/main'
export const throttle = limiter.define('global', () => {
return limiter
.allowRequests(10)
.every('1 minute')
.limitExceeded((error) => {
error
.setStatus(400)
.setMessage('Cannot process request. Try again later')
})
})
为错误消息使用翻译
如果你已经配置了 @adonisjs/i18n 包,你可以使用 errors.E_TOO_MANY_REQUESTS 键为错误消息定义翻译。例如:
{
"E_TOO_MANY_REQUESTS": "Trop de demandes"
}
最后,你可以使用 error.t 方法定义自定义翻译键。
limitExceeded((error) => {
error.t('errors.rate_limited', {
limit: error.response.limit,
remaining: error.response.remaining,
})
})
直接使用
除了限制 HTTP 请求之外,你还可以使用速率限制器在应用的其他部分应用速率限制。例如,如果在登录时多次提供无效凭据,则封锁某个用户。或者限制用户可以运行的并发任务数量。
创建速率限制器
在对某个动作应用速率限制之前,你必须使用 limiter.use 方法获取一个 Limiter 类的实例。该 use 方法接受后端存储的名称以及以下速率限制选项。
requests:在给定时间段内允许的请求数。duration:以秒为单位的时间段,或一个时间表达式字符串。block(可选):在耗尽所有请求后封锁该键的时长。inMemoryBlockOnConsumed(可选):参见共享选项inMemoryBlockDuration(可选):参见共享选项
import limiter from '@adonisjs/limiter/services/main'
const reportsLimiter = limiter.use('redis', {
requests: 1,
duration: '1 hour'
})
如果你想使用默认存储,请省略第一个参数。例如:
const reportsLimiter = limiter.use({
requests: 1,
duration: '1 hour'
})
对动作应用速率限制
一旦你创建了速率限制器实例,就可以使用 attempt 方法对某个动作应用速率限制。
该方法接受以下参数。
- 用于速率限制的唯一键。
- 在耗尽所有尝试之前要执行的回调函数。
attempt 方法返回回调函数的结果(如果它被执行)。否则,它返回 undefined。
const key = 'user_1_reports'
/**
* Attempt to run an action for the given key.
* The result will be the callback function return
* value or undefined if no callback was executed.
*/
const executed = reportsLimiter.attempt(key, async () => {
await generateReport()
return true
})
/**
* Notify users that they have exceeded the limit
*/
if (!executed) {
const availableIn = await reportsLimiter.availableIn(key)
return `Too many requests. Try after ${availableIn} seconds`
}
return 'Report generated'
防止过多的登录失败
直接使用的另一个例子是,当某个 IP 地址在登录表单上多次尝试无效时,禁止其再次尝试。
在下面的示例中,我们使用 limiter.penalize 方法,在用户提供无效凭据时消耗一个请求,并在耗尽所有尝试后将其封锁 20 分钟。
limiter.penalize 方法接受以下参数。
- 用于速率限制的唯一键。
- 要执行的回调函数。如果该函数抛出错误,将消耗一个请求。
penalize 方法返回回调函数的结果,或 ThrottleException 的实例。你可以使用该异常来查找距离下一次尝试剩余的时长。
import User from '#models/user'
import { HttpContext } from '@adonisjs/core/http'
import limiter from '@adonisjs/limiter/services/main'
export default class SessionController {
async store({ request, response, session }: HttpContext) {
const { email, password } = request.only(['email', 'passwords'])
/**
* Create a limiter
*/
const loginLimiter = limiter.use({
requests: 5,
duration: '1 min',
blockDuration: '20 mins'
})
/**
* Use IP address + email combination. This ensures if an
* attacker is misusing emails; we do not block actual
* users from logging in and only penalize the attacker
* IP address.
*/
const key = `login_${request.ip()}_${email}`
/**
* Wrap User.verifyCredentials inside the "penalize" method, so
* that we consume one request for every invalid credentials
* error
*/
const [error, user] = await loginLimiter.penalize(key, () => {
return User.verifyCredentials(email, password)
})
/**
* On ThrottleException, redirect the user back with a
* custom error message
*/
if (error) {
session.flashAll()
session.flashErrors({
E_TOO_MANY_REQUESTS: `Too many login requests. Try again after ${error.response.availableIn} seconds`
})
return response.redirect().back()
}
/**
* Otherwise, login the user
*/
}
}
手动消耗请求
除了 attempt 和 penalize 方法之外,你还可以直接与速率限制器交互,以检查剩余请求数并手动消耗它们。
在下面的示例中,我们使用 remaining 方法检查给定键是否已消耗所有请求。否则,使用 increment 方法消耗一个请求。
import limiter from '@adonisjs/limiter/services/main'
const requestsLimiter = limiter.use({
requests: 10,
duration: '1 minute'
})
if (await requestsLimiter.remaining('unique_key') > 0) {
await requestsLimiter.increment('unique_key')
await performAction()
} else {
return 'Too many requests'
}
在上面的示例中,remaining 和 increment 方法之间的调用可能会遇到竞态条件。因此,你可能需要改用 consume 方法。consume 方法会递增请求计数,并在所有请求都被消耗时抛出异常。
import { errors } from '@adonisjs/limiter'
try {
await requestsLimiter.consume('unique_key')
await performAction()
} catch (error) {
if (error instanceof errors.E_TOO_MANY_REQUESTS) {
return 'Too many requests'
}
}
封锁键
除了消耗请求之外,如果用户在耗尽所有尝试后继续发出请求,你可以将其键封锁更长时间。
封锁是由 consume、attempt 和 penalize 方法在创建了带有 blockDuration 选项的速率限制器实例时自动执行的。例如:
import limiter from '@adonisjs/limiter/services/main'
const requestsLimiter = limiter.use({
requests: 10,
duration: '1 minute',
blockDuration: '30 mins'
})
/**
* A user can make 10 requests in a minute. However, if
* they send the 11th request, we will block the key
* for 30 mins.
*/
await requestLimiter.consume('a_unique_key')
/**
* Same behavior as consume
*/
await requestLimiter.attempt('a_unique_key', () => {
})
/**
* Allow 10 failures and then block the key for 30 mins.
*/
await requestLimiter.penalize('a_unique_key', () => {
})
最后,你可以使用 block 方法将某个键封锁一段给定的时长。
const requestsLimiter = limiter.use({
requests: 10,
duration: '1 minute',
})
await requestsLimiter.block('a_unique_key', '30 mins')
重置尝试次数
你可以使用以下方法之一来减少请求数或从存储中删除整个键。
decrement 方法将请求数减 1,delete 方法删除该键。请注意,decrement 方法不是原子的,在并发过高时可能会将请求数设置为 -1。
import limiter from '@adonisjs/limiter/services/main'
const jobsLimiter = limiter.use({
requests: 2,
duration: '5 mins',
})
await jobsLimiter.attempt('unique_key', async () => {
await processJob()
/**
* Decrement the consumed requests after we are done
* processing the job. This will allow other workers
* to use the slot.
*/
await jobsLimiter.decrement('unique_key')
})
import limiter from '@adonisjs/limiter/services/main'
const requestsLimiter = limiter.use({
requests: 2,
duration: '5 mins',
})
await requestsLimiter.delete('unique_key')
测试
如果你使用单一(即默认)存储进行速率限制,你可能希望通过在 .env.test 文件中定义 LIMITER_STORE 环境变量,在测试期间切换到 memory 存储。
LIMITER_STORE=memory
你可以使用 limiter.clear 方法在测试之间清除速率限制存储。clear 方法接受一组存储名称,并清空数据库。
使用 Redis 时,建议为速率限制器使用一个单独的数据库。否则,clear 方法会清空整个数据库,这可能会影响应用的其他部分。
import limiter from '@adonisjs/limiter/services/main'
test.group('Reports', (group) => {
group.each.setup(() => {
return () => limiter.clear(['redis', 'memory'])
})
})
或者,你可以不带任何参数地调用 clear 方法,这样所有已配置的存储都会被清除。
test.group('Reports', (group) => {
group.each.setup(() => {
return () => limiter.clear()
})
})
创建自定义存储提供者
自定义存储提供者必须实现 LimiterStoreContract 接口,并定义以下属性/方法。
你可以将实现写在任意文件/文件夹中。创建自定义存储不需要服务提供者。
import string from '@adonisjs/core/helpers/string'
import { LimiterResponse } from '@adonisjs/limiter'
import {
LimiterStoreContract,
LimiterConsumptionOptions
} from '@adonisjs/limiter/types'
/**
* A custom set of options you want to accept.
*/
export type MongoDbLimiterConfig = {
client: MongoDBConnection
}
export class MongoDbLimiterStore implements LimiterStoreContract {
readonly name = 'mongodb'
declare readonly requests: number
declare readonly duration: number
declare readonly blockDuration: number
constructor(public config: MongoDbLimiterConfig & LimiterConsumptionOptions) {
this.request = this.config.requests
this.duration = string.seconds.parse(this.config.duration)
this.blockDuration = string.seconds.parse(this.config.blockDuration)
}
/**
* Consume one request for the given key. This method
* should throw an error when all requests have been
* already consumed.
*/
async consume(key: string | number): Promise<LimiterResponse> {
}
/**
* Consume one request for the given key, but do not throw an
* error when all requests have been consumed.
*/
async increment(key: string | number): Promise<LimiterResponse> {}
/**
* Reward one request to the given key. If possible, do not set
* the requests count to a negative value.
*/
async decrement(key: string | number): Promise<LimiterResponse> {}
/**
* Block a key for the specified duration.
*/
async block(
key: string | number,
duration: string | number
): Promise<LimiterResponse> {}
/**
* Set the number of consumed requests for a given key. The duration
* should be inferred from the config if no explicit duration
* is provided.
*/
async set(
key: string | number,
requests: number,
duration?: string | number
): Promise<LimiterResponse> {}
/**
* Delete the key from the storage
*/
async delete(key: string | number): Promise<boolean> {}
/**
* Flush all keys from the database
*/
async clear(): Promise<void> {}
/**
* Get a limiter response for a given key. Return `null` if the
* key does not exist.
*/
async get(key: string | number): Promise<LimiterResponse | null> {}
}
定义配置辅助函数
完成实现后,你必须创建一个配置辅助函数,以便在 config/limiter.ts 文件中使用该提供者。配置辅助函数应返回一个 LimiterManagerStoreFactory 函数。
你可以将以下函数写在 MongoDbLimiterStore 实现的同一个文件中。
import { LimiterManagerStoreFactory } from '@adonisjs/limiter/types'
/**
* Config helper to use the mongoDb store
* inside the config file
*/
export function mongoDbStore(config: MongoDbLimiterConfig) {
const storeFactory: LimiterManagerStoreFactory = (runtimeOptions) => {
return new MongoDbLimiterStore({
...config,
...runtimeOptions
})
}
}
使用配置辅助函数
完成后,你可以像下面这样使用 mongoDbStore 辅助函数。
import env from '#start/env'
import { mongoDbStore } from 'my-custom-package'
import { defineConfig } from '@adonisjs/limiter'
const limiterConfig = defineConfig({
default: env.get('LIMITER_STORE'),
stores: {
mongodb: mongoDbStore({
client: mongoDb // create mongoDb client
})
},
})
封装 rate-limiter-flexible 驱动
如果你打算封装 node-rate-limiter-flexible 包中的现有驱动,那么你可以使用 RateLimiterBridge 来实现。
让我们这次使用桥接器重新实现相同的 MongoDbLimiterStore。
import { RateLimiterBridge } from '@adonisjs/limiter'
import { RateLimiterMongo } from 'rate-limiter-flexible'
export class MongoDbLimiterStore extends RateLimiterBridge {
readonly name = 'mongodb'
constructor(public config: MongoDbLimiterConfig & LimiterConsumptionOptions) {
super(
new RateLimiterMongo({
storeClient: config.client,
points: config.requests,
duration: string.seconds.parse(config.duration),
blockDuration: string.seconds.parse(this.config.blockDuration)
// ... provide other options as well
})
)
}
/**
* Self-implement the clear method. Ideally, use the
* config.client to issue a delete query
*/
async clear() {}
}