mm_rate_limit 0.0.0-stage → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -2
- package/index.js +25 -0
- package/lib/rate_limit.js +116 -0
- package/package.json +26 -5
- package/test.js +14 -0
- package/tests/rate_limit.test.js +79 -0
package/README.md
CHANGED
|
@@ -1,3 +1,31 @@
|
|
|
1
|
-
#
|
|
1
|
+
# mm_rate_limit 限流模块
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
按 key 维度(用户 id、IP、路由等)的令牌桶限流:桶容量 `capacity`,按 `interval` 毫秒匀速补充,支持突发流量与速率控制。
|
|
4
|
+
|
|
5
|
+
## 快速开始
|
|
6
|
+
|
|
7
|
+
```javascript
|
|
8
|
+
const { rateLimitAdmin } = require('mm_rate_limit');
|
|
9
|
+
|
|
10
|
+
const limiter = rateLimitAdmin('web', { capacity: 60, interval: 60000 }); // 每分钟 60 次
|
|
11
|
+
|
|
12
|
+
// 事件 before 阶段或中间件中检查
|
|
13
|
+
const rl = limiter.check(ctx.ip);
|
|
14
|
+
if (!rl.allowed) {
|
|
15
|
+
ctx.status = 429;
|
|
16
|
+
ctx.retry_after = rl.retry_after; // 建议等待毫秒数
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## API
|
|
22
|
+
|
|
23
|
+
| 方法 | 说明 |
|
|
24
|
+
| --- | --- |
|
|
25
|
+
| `check(key, cost?)` | 消费令牌,返回 `{ allowed, remaining, retry_after, limit }` |
|
|
26
|
+
| `reset(key)` | 删除某个 key 的桶(重置) |
|
|
27
|
+
| `clear()` | 清空全部桶 |
|
|
28
|
+
|
|
29
|
+
默认配置:`capacity: 60`、`interval: 60000`(每分钟 60 次)、`max_keys: 10000`(超限淘汰最久未用的桶)。
|
|
30
|
+
|
|
31
|
+
`rateLimitAdmin(scope, config)` 按作用域缓存实例,相同 `scope` 返回同一实例。
|
package/index.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const { RateLimit } = require('./lib/rate_limit.js');
|
|
2
|
+
const Admin = require('mm_corejs').Admin;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 限流管理 Admin 工厂
|
|
6
|
+
* 支持 os.js 调用:require('mm_rate_limit').admin.setServer(os) -> admin.get(scope, config)
|
|
7
|
+
*/
|
|
8
|
+
let admin = new Admin();
|
|
9
|
+
admin.setClass(RateLimit);
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 获取(或创建)指定作用域的限流实例
|
|
13
|
+
* @param {string} [scope] 作用域,缺省 'web'
|
|
14
|
+
* @param {Object} [config] 配置参数
|
|
15
|
+
* @returns {RateLimit} 限流实例
|
|
16
|
+
*/
|
|
17
|
+
function rateLimitAdmin(scope, config) {
|
|
18
|
+
return admin.get(scope || 'web', config);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
module.exports = {
|
|
22
|
+
RateLimit,
|
|
23
|
+
admin,
|
|
24
|
+
rateLimitAdmin
|
|
25
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { Base } = require('mm_mod');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 令牌桶限流类
|
|
7
|
+
*
|
|
8
|
+
* 以 key(如用户 id、IP、路由)为维度的令牌桶限流:
|
|
9
|
+
* 桶容量 capacity,按 interval 毫秒匀速补充 capacity 个令牌(速率 = capacity/interval)。
|
|
10
|
+
* 突发流量可一次性消费桶内存量令牌,长期速率不超过补充速率。
|
|
11
|
+
*/
|
|
12
|
+
class RateLimit extends Base {
|
|
13
|
+
/**
|
|
14
|
+
* 模块默认配置
|
|
15
|
+
*/
|
|
16
|
+
static config = {
|
|
17
|
+
name: 'rate_limit',
|
|
18
|
+
title: '令牌桶限流模块',
|
|
19
|
+
description: '按 key 维度的令牌桶限流,支持突发与匀速补充',
|
|
20
|
+
// 桶容量(突发上限)
|
|
21
|
+
capacity: 60,
|
|
22
|
+
// 补充周期(毫秒),每周期补充 capacity 个令牌
|
|
23
|
+
interval: 60000,
|
|
24
|
+
// 最大跟踪 key 数,超出后淘汰最久未使用的桶
|
|
25
|
+
max_keys: 10000
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 桶表:key -> { tokens, updated_at }
|
|
30
|
+
* @type {Map<string, Object>}
|
|
31
|
+
*/
|
|
32
|
+
#buckets = new Map();
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 计算当前可用令牌并消费
|
|
36
|
+
* @param {string} key 限流维度(用户 id、IP、路由等)
|
|
37
|
+
* @param {number} [cost] 本次消耗令牌数,默认 1
|
|
38
|
+
* @returns {Object} 结果 { allowed, remaining, retry_after, limit }
|
|
39
|
+
*/
|
|
40
|
+
check(key, cost) {
|
|
41
|
+
if (!key || typeof key !== 'string') {
|
|
42
|
+
throw new TypeError(`${this._label()} key must be a non-empty string`);
|
|
43
|
+
}
|
|
44
|
+
const want = cost === undefined ? 1 : cost;
|
|
45
|
+
if (typeof want !== 'number' || !Number.isFinite(want) || want <= 0) {
|
|
46
|
+
throw new TypeError(`${this._label()} cost must be a positive finite number`);
|
|
47
|
+
}
|
|
48
|
+
const capacity = this.config.capacity;
|
|
49
|
+
const interval = this.config.interval;
|
|
50
|
+
const now = Date.now();
|
|
51
|
+
|
|
52
|
+
let bucket = this.#buckets.get(key);
|
|
53
|
+
if (!bucket) {
|
|
54
|
+
// 淘汰最久未用的桶,防止内存膨胀
|
|
55
|
+
if (this.#buckets.size >= this.config.max_keys) {
|
|
56
|
+
let oldest_key = null;
|
|
57
|
+
let oldest = Infinity;
|
|
58
|
+
for (const [k, b] of this.#buckets) {
|
|
59
|
+
if (b.updated_at < oldest) {
|
|
60
|
+
oldest = b.updated_at;
|
|
61
|
+
oldest_key = k;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (oldest_key !== null) {
|
|
65
|
+
this.#buckets.delete(oldest_key);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
bucket = { tokens: capacity, updated_at: now };
|
|
69
|
+
this.#buckets.set(key, bucket);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 按时间流逝补充令牌(不足一个周期按比例补充)
|
|
73
|
+
const elapsed = Math.max(0, now - bucket.updated_at);
|
|
74
|
+
if (elapsed > 0 && bucket.tokens < capacity) {
|
|
75
|
+
const refill = (elapsed / interval) * capacity;
|
|
76
|
+
bucket.tokens = Math.min(capacity, bucket.tokens + refill);
|
|
77
|
+
}
|
|
78
|
+
bucket.updated_at = now;
|
|
79
|
+
|
|
80
|
+
const allowed = bucket.tokens >= want;
|
|
81
|
+
const result = {
|
|
82
|
+
allowed,
|
|
83
|
+
remaining: Math.floor(Math.max(0, bucket.tokens - (allowed ? want : 0))),
|
|
84
|
+
retry_after: 0,
|
|
85
|
+
limit: capacity
|
|
86
|
+
};
|
|
87
|
+
if (allowed) {
|
|
88
|
+
bucket.tokens -= want;
|
|
89
|
+
} else {
|
|
90
|
+
// 计算补足所需令牌的等待毫秒数(向上取整到秒)
|
|
91
|
+
const need = want - bucket.tokens;
|
|
92
|
+
result.retry_after = Math.ceil((need / capacity) * interval / 1000) * 1000;
|
|
93
|
+
}
|
|
94
|
+
return result;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 删除某个 key 的桶(如用户登出时重置)
|
|
99
|
+
* @param {string} key 限流维度
|
|
100
|
+
* @returns {boolean} 是否删除成功
|
|
101
|
+
*/
|
|
102
|
+
reset(key) {
|
|
103
|
+
return this.#buckets.delete(key);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 清空全部桶
|
|
108
|
+
* @returns {RateLimit} 当前实例
|
|
109
|
+
*/
|
|
110
|
+
clear() {
|
|
111
|
+
this.#buckets.clear();
|
|
112
|
+
return this;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = { RateLimit };
|
package/package.json
CHANGED
|
@@ -1,6 +1,27 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "mm_rate_limit",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "超级美眉限流模块:按 key 维度的令牌桶限流,支持突发与匀速补充",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "node test.js && node --test tests/rate_limit.test.js"
|
|
8
|
+
},
|
|
9
|
+
"keywords": [
|
|
10
|
+
"mm_mod",
|
|
11
|
+
"mm_middleware",
|
|
12
|
+
"rate_limit",
|
|
13
|
+
"token_bucket",
|
|
14
|
+
"throttle"
|
|
15
|
+
],
|
|
16
|
+
"layer": "middleware",
|
|
17
|
+
"group": "web",
|
|
18
|
+
"author": "邱文武",
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"mm_corejs": "^1.1.4",
|
|
22
|
+
"mm_mod": "^1.0.1"
|
|
23
|
+
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=18.0.0"
|
|
26
|
+
}
|
|
27
|
+
}
|
package/test.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const assert = require('assert');
|
|
3
|
+
const mod = require('./index.js');
|
|
4
|
+
|
|
5
|
+
assert.strictEqual(typeof mod.RateLimit, 'function', '应导出 RateLimit 类');
|
|
6
|
+
assert.strictEqual(typeof mod.rateLimitAdmin, 'function', '应导出 rateLimitAdmin 函数');
|
|
7
|
+
|
|
8
|
+
const rl = mod.rateLimitAdmin('smoke', { capacity: 2, interval: 60000 });
|
|
9
|
+
assert.ok(rl instanceof mod.RateLimit);
|
|
10
|
+
assert.strictEqual(rl.check('k').allowed, true);
|
|
11
|
+
assert.strictEqual(rl.check('k').allowed, true);
|
|
12
|
+
assert.strictEqual(rl.check('k').allowed, false, '容量耗尽后应拒绝');
|
|
13
|
+
|
|
14
|
+
console.log('mm_rate_limit 模块测试通过');
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* mm_rate_limit 单元测试
|
|
4
|
+
*/
|
|
5
|
+
const test = require('node:test');
|
|
6
|
+
const assert = require('assert');
|
|
7
|
+
|
|
8
|
+
const { RateLimit, rateLimitAdmin } = require('../index.js');
|
|
9
|
+
|
|
10
|
+
test('基本限流:容量内放行,耗尽拒绝', () => {
|
|
11
|
+
const rl = new RateLimit({ capacity: 3, interval: 60000 });
|
|
12
|
+
assert.strictEqual(rl.check('u1').allowed, true);
|
|
13
|
+
assert.strictEqual(rl.check('u1').allowed, true);
|
|
14
|
+
assert.strictEqual(rl.check('u1').allowed, true);
|
|
15
|
+
const denied = rl.check('u1');
|
|
16
|
+
assert.strictEqual(denied.allowed, false);
|
|
17
|
+
assert.ok(denied.retry_after > 0, '拒绝时应给出重试等待毫秒');
|
|
18
|
+
assert.strictEqual(rl.check('u1', 1).remaining, 0, 'remaining 不为负');
|
|
19
|
+
assert.strictEqual(denied.limit, 3);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('不同 key 相互隔离', () => {
|
|
23
|
+
const rl = new RateLimit({ capacity: 1, interval: 60000 });
|
|
24
|
+
assert.strictEqual(rl.check('a').allowed, true);
|
|
25
|
+
assert.strictEqual(rl.check('a').allowed, false);
|
|
26
|
+
assert.strictEqual(rl.check('b').allowed, true, '不同 key 独立计数');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('cost 批量消费', () => {
|
|
30
|
+
const rl = new RateLimit({ capacity: 10, interval: 60000 });
|
|
31
|
+
assert.strictEqual(rl.check('bulk', 4).allowed, true);
|
|
32
|
+
assert.strictEqual(rl.check('bulk', 4).allowed, true);
|
|
33
|
+
assert.strictEqual(rl.check('bulk', 4).allowed, false, '余量不足时应拒绝');
|
|
34
|
+
assert.throws(() => rl.check('bulk', 0), TypeError);
|
|
35
|
+
assert.throws(() => rl.check('bulk', -1), TypeError);
|
|
36
|
+
assert.throws(() => rl.check(''), TypeError);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('时间流逝自动补充令牌(按比例)', async () => {
|
|
40
|
+
const rl = new RateLimit({ capacity: 10, interval: 100 });
|
|
41
|
+
for (let i = 0; i < 10; i++) {
|
|
42
|
+
rl.check('t'); // 耗尽桶
|
|
43
|
+
}
|
|
44
|
+
assert.strictEqual(rl.check('t').allowed, false);
|
|
45
|
+
await new Promise((resolve) => setTimeout(resolve, 150)); // 流逝 150ms,应补充 15 个(截断为满桶 10)
|
|
46
|
+
assert.strictEqual(rl.check('t').allowed, true, '时间流逝后应补充令牌');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('reset / clear', () => {
|
|
50
|
+
const rl = new RateLimit({ capacity: 1, interval: 60000 });
|
|
51
|
+
rl.check('x');
|
|
52
|
+
assert.strictEqual(rl.reset('x'), true);
|
|
53
|
+
assert.strictEqual(rl.reset('x'), false);
|
|
54
|
+
assert.strictEqual(rl.check('x').allowed, true, 'reset 后重新放行');
|
|
55
|
+
rl.check('y');
|
|
56
|
+
rl.clear();
|
|
57
|
+
assert.strictEqual(rl.check('y').allowed, true, 'clear 后重新放行');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('max_keys 淘汰最久未用的桶', async () => {
|
|
61
|
+
const rl = new RateLimit({ capacity: 1, interval: 60000, max_keys: 2 });
|
|
62
|
+
const tick = () => new Promise((r) => setTimeout(r, 3));
|
|
63
|
+
rl.check('k1');
|
|
64
|
+
await tick();
|
|
65
|
+
rl.check('k2');
|
|
66
|
+
await tick();
|
|
67
|
+
rl.check('k1'); // k1 变为最近使用
|
|
68
|
+
await tick();
|
|
69
|
+
rl.check('k3'); // 超出 max_keys,应淘汰 k2
|
|
70
|
+
assert.strictEqual(rl.check('k1').allowed, false, 'k1 已消费,应拒绝');
|
|
71
|
+
assert.strictEqual(rl.check('k2').allowed, true, 'k2 桶被淘汰后重新满桶');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('工厂函数按作用域共享实例', () => {
|
|
75
|
+
const a = rateLimitAdmin('ut-rl', {});
|
|
76
|
+
const b = rateLimitAdmin('ut-rl');
|
|
77
|
+
assert.strictEqual(a, b);
|
|
78
|
+
assert.notStrictEqual(rateLimitAdmin('ut-rl-2'), a);
|
|
79
|
+
});
|