feat(server): 添加可插拔速率限制系统 | add pluggable rate limiting system (#388)

* feat(server): 添加可插拔速率限制系统 | add pluggable rate limiting system

- 新增令牌桶策略 (TokenBucketStrategy) - 推荐用于一般场景
- 新增滑动窗口策略 (SlidingWindowStrategy) - 精确跟踪
- 新增固定窗口策略 (FixedWindowStrategy) - 简单高效
- 新增房间速率限制 mixin (withRateLimit)
- 新增速率限制装饰器 (@rateLimit, @noRateLimit)
- 新增按消息类型限流装饰器 (@rateLimitMessage, @noRateLimitMessage)
- 支持与认证系统组合使用
- 添加中英文文档
- 导出路径: @esengine/server/ratelimit

* docs: 更新 README 添加新模块 | update README with new modules

- 添加程序化生成 (procgen) 模块
- 添加 RPC 框架模块
- 添加游戏服务器 (server) 模块
- 添加事务系统 (transaction) 模块
- 添加世界流送 (world-streaming) 模块
- 更新网络模块描述
- 更新项目结构目录
This commit is contained in:
YHH
2025-12-29 17:12:54 +08:00
committed by GitHub
parent 764ce67742
commit afdeb00b4d
23 changed files with 3467 additions and 6 deletions

View File

@@ -0,0 +1,142 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { RateLimitContext } from '../context';
import { TokenBucketStrategy } from '../strategies/TokenBucket';
import { FixedWindowStrategy } from '../strategies/FixedWindow';
describe('RateLimitContext', () => {
let globalStrategy: TokenBucketStrategy;
let context: RateLimitContext;
beforeEach(() => {
globalStrategy = new TokenBucketStrategy({
rate: 10,
capacity: 20
});
context = new RateLimitContext('player-123', globalStrategy);
});
describe('check', () => {
it('should check without consuming', () => {
const result1 = context.check();
const result2 = context.check();
expect(result1.remaining).toBe(result2.remaining);
});
it('should use global strategy by default', () => {
const result = context.check();
expect(result.allowed).toBe(true);
expect(result.remaining).toBe(20);
});
});
describe('consume', () => {
it('should consume from global strategy', () => {
const result = context.consume();
expect(result.allowed).toBe(true);
expect(result.remaining).toBe(19);
});
it('should track consecutive limits', () => {
for (let i = 0; i < 25; i++) {
context.consume();
}
expect(context.consecutiveLimitCount).toBeGreaterThan(0);
});
it('should reset consecutive count on success', () => {
// Consume all 20 tokens plus some more to trigger rate limiting
for (let i = 0; i < 25; i++) {
context.consume();
}
// After consuming 25 tokens (20 capacity), 5 should be rate limited
expect(context.consecutiveLimitCount).toBeGreaterThan(0);
context.reset();
const result = context.consume();
expect(result.allowed).toBe(true);
expect(context.consecutiveLimitCount).toBe(0);
});
});
describe('reset', () => {
it('should reset global strategy', () => {
for (let i = 0; i < 15; i++) {
context.consume();
}
context.reset();
const status = context.check();
expect(status.remaining).toBe(20);
});
it('should reset specific message type', () => {
const msgStrategy = new FixedWindowStrategy({ rate: 5, capacity: 5 });
context.setMessageStrategy('Trade', msgStrategy);
for (let i = 0; i < 5; i++) {
context.consume('Trade');
}
context.reset('Trade');
const status = context.check('Trade');
expect(status.remaining).toBe(5);
});
});
describe('message strategies', () => {
it('should use message-specific strategy', () => {
const tradeStrategy = new FixedWindowStrategy({ rate: 1, capacity: 1 });
context.setMessageStrategy('Trade', tradeStrategy);
const result1 = context.consume('Trade');
expect(result1.allowed).toBe(true);
const result2 = context.consume('Trade');
expect(result2.allowed).toBe(false);
});
it('should fall back to global strategy for unknown types', () => {
const result = context.consume('UnknownType');
expect(result.allowed).toBe(true);
expect(result.remaining).toBe(19);
});
it('should check if message strategy exists', () => {
expect(context.hasMessageStrategy('Trade')).toBe(false);
const tradeStrategy = new FixedWindowStrategy({ rate: 1, capacity: 1 });
context.setMessageStrategy('Trade', tradeStrategy);
expect(context.hasMessageStrategy('Trade')).toBe(true);
});
it('should remove message strategy', () => {
const tradeStrategy = new FixedWindowStrategy({ rate: 1, capacity: 1 });
context.setMessageStrategy('Trade', tradeStrategy);
context.removeMessageStrategy('Trade');
expect(context.hasMessageStrategy('Trade')).toBe(false);
});
});
describe('resetConsecutiveCount', () => {
it('should reset consecutive limit count', () => {
for (let i = 0; i < 25; i++) {
context.consume();
}
expect(context.consecutiveLimitCount).toBeGreaterThan(0);
context.resetConsecutiveCount();
expect(context.consecutiveLimitCount).toBe(0);
});
});
});

View File

@@ -0,0 +1,185 @@
import { describe, it, expect, beforeEach } from 'vitest';
import {
rateLimit,
noRateLimit,
rateLimitMessage,
noRateLimitMessage,
getRateLimitMetadata,
RATE_LIMIT_METADATA_KEY
} from '../decorators/rateLimit';
describe('rateLimitMessage decorator', () => {
class TestClass {
@rateLimitMessage('Trade', { messagesPerSecond: 1, burstSize: 2 })
handleTrade() {
return 'trade';
}
@rateLimitMessage('Move', { cost: 2 })
handleMove() {
return 'move';
}
undecorated() {
return 'undecorated';
}
}
describe('metadata storage', () => {
it('should store rate limit metadata on target', () => {
const metadata = getRateLimitMetadata(TestClass.prototype, 'Trade');
expect(metadata).toBeDefined();
expect(metadata?.enabled).toBe(true);
});
it('should store config in metadata', () => {
const metadata = getRateLimitMetadata(TestClass.prototype, 'Trade');
expect(metadata?.config?.messagesPerSecond).toBe(1);
expect(metadata?.config?.burstSize).toBe(2);
});
it('should store cost in metadata', () => {
const metadata = getRateLimitMetadata(TestClass.prototype, 'Move');
expect(metadata?.config?.cost).toBe(2);
});
it('should return undefined for unregistered message types', () => {
const metadata = getRateLimitMetadata(TestClass.prototype, 'Unknown');
expect(metadata).toBeUndefined();
});
});
describe('method behavior', () => {
it('should not alter method behavior', () => {
const instance = new TestClass();
expect(instance.handleTrade()).toBe('trade');
expect(instance.handleMove()).toBe('move');
});
});
});
describe('noRateLimitMessage decorator', () => {
class TestClass {
@noRateLimitMessage('Heartbeat')
handleHeartbeat() {
return 'heartbeat';
}
@noRateLimitMessage('Ping')
handlePing() {
return 'ping';
}
}
describe('metadata storage', () => {
it('should mark message as exempt', () => {
const metadata = getRateLimitMetadata(TestClass.prototype, 'Heartbeat');
expect(metadata?.exempt).toBe(true);
expect(metadata?.enabled).toBe(false);
});
it('should store for multiple messages', () => {
const heartbeatMeta = getRateLimitMetadata(TestClass.prototype, 'Heartbeat');
const pingMeta = getRateLimitMetadata(TestClass.prototype, 'Ping');
expect(heartbeatMeta?.exempt).toBe(true);
expect(pingMeta?.exempt).toBe(true);
});
});
describe('method behavior', () => {
it('should not alter method behavior', () => {
const instance = new TestClass();
expect(instance.handleHeartbeat()).toBe('heartbeat');
expect(instance.handlePing()).toBe('ping');
});
});
});
describe('combined decorators', () => {
class CombinedTestClass {
@rateLimitMessage('SlowAction', { messagesPerSecond: 1 })
handleSlow() {
return 'slow';
}
@noRateLimitMessage('FastAction')
handleFast() {
return 'fast';
}
@rateLimitMessage('ExpensiveAction', { cost: 10 })
handleExpensive() {
return 'expensive';
}
}
it('should handle multiple different decorators', () => {
const slowMeta = getRateLimitMetadata(CombinedTestClass.prototype, 'SlowAction');
const fastMeta = getRateLimitMetadata(CombinedTestClass.prototype, 'FastAction');
const expensiveMeta = getRateLimitMetadata(CombinedTestClass.prototype, 'ExpensiveAction');
expect(slowMeta?.enabled).toBe(true);
expect(slowMeta?.config?.messagesPerSecond).toBe(1);
expect(fastMeta?.exempt).toBe(true);
expect(fastMeta?.enabled).toBe(false);
expect(expensiveMeta?.enabled).toBe(true);
expect(expensiveMeta?.config?.cost).toBe(10);
});
});
describe('RATE_LIMIT_METADATA_KEY', () => {
it('should be a symbol', () => {
expect(typeof RATE_LIMIT_METADATA_KEY).toBe('symbol');
});
it('should be used for metadata storage', () => {
class TestClass {
@rateLimitMessage('Test', {})
handleTest() {}
}
const metadataMap = (TestClass.prototype as any)[RATE_LIMIT_METADATA_KEY];
expect(metadataMap).toBeInstanceOf(Map);
});
});
describe('rateLimit decorator (auto-detect)', () => {
it('should be a decorator function', () => {
expect(typeof rateLimit).toBe('function');
expect(typeof rateLimit()).toBe('function');
});
it('should accept config', () => {
class TestClass {
@rateLimit({ messagesPerSecond: 5 })
someMethod() {
return 'test';
}
}
const instance = new TestClass();
expect(instance.someMethod()).toBe('test');
});
});
describe('noRateLimit decorator (auto-detect)', () => {
it('should be a decorator function', () => {
expect(typeof noRateLimit).toBe('function');
expect(typeof noRateLimit()).toBe('function');
});
it('should work as decorator', () => {
class TestClass {
@noRateLimit()
someMethod() {
return 'test';
}
}
const instance = new TestClass();
expect(instance.someMethod()).toBe('test');
});
});

View File

@@ -0,0 +1,157 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { Room } from '../../room/Room';
import { Player } from '../../room/Player';
import { withRateLimit, getPlayerRateLimitContext } from '../mixin/withRateLimit';
import { noRateLimitMessage, rateLimitMessage } from '../decorators/rateLimit';
import { onMessage } from '../../room/decorators';
describe('withRateLimit mixin', () => {
let RateLimitedRoom: ReturnType<typeof withRateLimit>;
beforeEach(() => {
RateLimitedRoom = withRateLimit(Room, {
messagesPerSecond: 10,
burstSize: 20
});
});
describe('basic functionality', () => {
it('should create a rate limited room class', () => {
expect(RateLimitedRoom).toBeDefined();
});
it('should have rateLimitStrategy property', () => {
class TestRoom extends RateLimitedRoom {
onCreate() {}
}
const room = new TestRoom();
expect(room.rateLimitStrategy).toBeDefined();
expect(room.rateLimitStrategy.name).toBe('token-bucket');
});
});
describe('strategy selection', () => {
it('should use token-bucket by default', () => {
class TestRoom extends withRateLimit(Room) {
onCreate() {}
}
const room = new TestRoom();
expect(room.rateLimitStrategy.name).toBe('token-bucket');
});
it('should use sliding-window when specified', () => {
class TestRoom extends withRateLimit(Room, { strategy: 'sliding-window' }) {
onCreate() {}
}
const room = new TestRoom();
expect(room.rateLimitStrategy.name).toBe('sliding-window');
});
it('should use fixed-window when specified', () => {
class TestRoom extends withRateLimit(Room, { strategy: 'fixed-window' }) {
onCreate() {}
}
const room = new TestRoom();
expect(room.rateLimitStrategy.name).toBe('fixed-window');
});
});
describe('configuration', () => {
it('should use default values', () => {
class TestRoom extends withRateLimit(Room) {
onCreate() {}
}
const room = new TestRoom();
expect(room.rateLimitStrategy).toBeDefined();
});
it('should accept custom messagesPerSecond', () => {
class TestRoom extends withRateLimit(Room, { messagesPerSecond: 5 }) {
onCreate() {}
}
const room = new TestRoom();
expect(room.rateLimitStrategy).toBeDefined();
});
it('should accept custom burstSize', () => {
class TestRoom extends withRateLimit(Room, { burstSize: 50 }) {
onCreate() {}
}
const room = new TestRoom();
expect(room.rateLimitStrategy).toBeDefined();
});
});
describe('dispose', () => {
it('should clean up on dispose', () => {
class TestRoom extends RateLimitedRoom {
onCreate() {}
}
const room = new TestRoom();
room._init({
id: 'test-room',
sendFn: vi.fn(),
broadcastFn: vi.fn(),
disposeFn: vi.fn()
});
expect(() => room.dispose()).not.toThrow();
});
});
});
describe('withRateLimit with auth', () => {
it('should be composable with other mixins', () => {
class TestRoom extends withRateLimit(Room, { messagesPerSecond: 10 }) {
onCreate() {}
}
const room = new TestRoom();
expect(room.rateLimitStrategy).toBeDefined();
});
});
describe('getPlayerRateLimitContext', () => {
it('should return null for player without context', () => {
const mockPlayer = {
id: 'player-1',
roomId: 'room-1',
data: {},
send: vi.fn(),
leave: vi.fn()
} as unknown as Player;
const context = getPlayerRateLimitContext(mockPlayer);
expect(context).toBeNull();
});
});
describe('decorator metadata', () => {
it('rateLimitMessage should set metadata', () => {
class TestRoom extends withRateLimit(Room) {
@rateLimitMessage('Trade', { messagesPerSecond: 1 })
@onMessage('Trade')
handleTrade() {}
}
expect(TestRoom).toBeDefined();
});
it('noRateLimitMessage should set exempt metadata', () => {
class TestRoom extends withRateLimit(Room) {
@noRateLimitMessage('Heartbeat')
@onMessage('Heartbeat')
handleHeartbeat() {}
}
expect(TestRoom).toBeDefined();
});
});

View File

@@ -0,0 +1,249 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { TokenBucketStrategy, createTokenBucketStrategy } from '../strategies/TokenBucket';
import { SlidingWindowStrategy, createSlidingWindowStrategy } from '../strategies/SlidingWindow';
import { FixedWindowStrategy, createFixedWindowStrategy } from '../strategies/FixedWindow';
describe('TokenBucketStrategy', () => {
let strategy: TokenBucketStrategy;
beforeEach(() => {
strategy = createTokenBucketStrategy({
rate: 10,
capacity: 20
});
});
describe('consume', () => {
it('should allow requests when tokens available', () => {
const result = strategy.consume('user-1');
expect(result.allowed).toBe(true);
expect(result.remaining).toBe(19);
});
it('should consume multiple tokens', () => {
const result = strategy.consume('user-1', 5);
expect(result.allowed).toBe(true);
expect(result.remaining).toBe(15);
});
it('should deny when not enough tokens', () => {
for (let i = 0; i < 20; i++) {
strategy.consume('user-1');
}
const result = strategy.consume('user-1');
expect(result.allowed).toBe(false);
expect(result.remaining).toBe(0);
expect(result.retryAfter).toBeGreaterThan(0);
});
it('should refill tokens over time', async () => {
for (let i = 0; i < 20; i++) {
strategy.consume('user-1');
}
await new Promise(resolve => setTimeout(resolve, 150));
const result = strategy.consume('user-1');
expect(result.allowed).toBe(true);
});
it('should handle different keys independently', () => {
for (let i = 0; i < 20; i++) {
strategy.consume('user-1');
}
const result1 = strategy.consume('user-1');
const result2 = strategy.consume('user-2');
expect(result1.allowed).toBe(false);
expect(result2.allowed).toBe(true);
});
});
describe('getStatus', () => {
it('should return full capacity for new key', () => {
const status = strategy.getStatus('new-user');
expect(status.remaining).toBe(20);
expect(status.allowed).toBe(true);
});
it('should not consume tokens', () => {
strategy.getStatus('user-1');
const status = strategy.getStatus('user-1');
expect(status.remaining).toBe(20);
});
});
describe('reset', () => {
it('should reset key to full capacity', () => {
for (let i = 0; i < 15; i++) {
strategy.consume('user-1');
}
strategy.reset('user-1');
const status = strategy.getStatus('user-1');
expect(status.remaining).toBe(20);
});
});
describe('cleanup', () => {
it('should clean up full buckets', async () => {
strategy.consume('user-1');
await new Promise(resolve => setTimeout(resolve, 100));
strategy.cleanup();
});
});
});
describe('SlidingWindowStrategy', () => {
let strategy: SlidingWindowStrategy;
beforeEach(() => {
strategy = createSlidingWindowStrategy({
rate: 10,
capacity: 10
});
});
describe('consume', () => {
it('should allow requests within capacity', () => {
const result = strategy.consume('user-1');
expect(result.allowed).toBe(true);
expect(result.remaining).toBe(9);
});
it('should deny when capacity exceeded', () => {
for (let i = 0; i < 10; i++) {
strategy.consume('user-1');
}
const result = strategy.consume('user-1');
expect(result.allowed).toBe(false);
expect(result.remaining).toBe(0);
});
it('should allow after window expires', async () => {
for (let i = 0; i < 10; i++) {
strategy.consume('user-1');
}
await new Promise(resolve => setTimeout(resolve, 1100));
const result = strategy.consume('user-1');
expect(result.allowed).toBe(true);
});
});
describe('getStatus', () => {
it('should return full capacity for new key', () => {
const status = strategy.getStatus('new-user');
expect(status.remaining).toBe(10);
expect(status.allowed).toBe(true);
});
});
describe('reset', () => {
it('should clear timestamps', () => {
for (let i = 0; i < 5; i++) {
strategy.consume('user-1');
}
strategy.reset('user-1');
const status = strategy.getStatus('user-1');
expect(status.remaining).toBe(10);
});
});
});
describe('FixedWindowStrategy', () => {
let strategy: FixedWindowStrategy;
beforeEach(() => {
strategy = createFixedWindowStrategy({
rate: 10,
capacity: 10
});
});
describe('consume', () => {
it('should allow requests within capacity', () => {
const result = strategy.consume('user-1');
expect(result.allowed).toBe(true);
expect(result.remaining).toBe(9);
});
it('should deny when capacity exceeded', () => {
for (let i = 0; i < 10; i++) {
strategy.consume('user-1');
}
const result = strategy.consume('user-1');
expect(result.allowed).toBe(false);
expect(result.retryAfter).toBeGreaterThanOrEqual(0);
});
it('should reset at window boundary', async () => {
for (let i = 0; i < 10; i++) {
strategy.consume('user-1');
}
await new Promise(resolve => setTimeout(resolve, 1100));
const result = strategy.consume('user-1');
expect(result.allowed).toBe(true);
expect(result.remaining).toBe(9);
});
});
describe('getStatus', () => {
it('should return full capacity for new key', () => {
const status = strategy.getStatus('new-user');
expect(status.remaining).toBe(10);
});
});
describe('reset', () => {
it('should reset count', () => {
for (let i = 0; i < 5; i++) {
strategy.consume('user-1');
}
strategy.reset('user-1');
const status = strategy.getStatus('user-1');
expect(status.remaining).toBe(10);
});
});
describe('cleanup', () => {
it('should clean up old windows', async () => {
strategy.consume('user-1');
await new Promise(resolve => setTimeout(resolve, 2100));
strategy.cleanup();
});
});
});
describe('Factory functions', () => {
it('createTokenBucketStrategy should create TokenBucketStrategy', () => {
const strategy = createTokenBucketStrategy({ rate: 5, capacity: 10 });
expect(strategy.name).toBe('token-bucket');
});
it('createSlidingWindowStrategy should create SlidingWindowStrategy', () => {
const strategy = createSlidingWindowStrategy({ rate: 5, capacity: 5 });
expect(strategy.name).toBe('sliding-window');
});
it('createFixedWindowStrategy should create FixedWindowStrategy', () => {
const strategy = createFixedWindowStrategy({ rate: 5, capacity: 5 });
expect(strategy.name).toBe('fixed-window');
});
});