feat(server): add Schema validation system and binary encoding optimization (#421)
* feat(server): add distributed room support - Add DistributedRoomManager for multi-server room management - Add MemoryAdapter for testing and standalone mode - Add RedisAdapter for production multi-server deployments - Add LoadBalancedRouter with 5 load balancing strategies - Add distributed config option to createServer - Add $redirect message for cross-server player redirection - Add failover mechanism for automatic room recovery - Add room:migrated and server:draining event types - Update documentation (zh/en) * feat(server): add Schema validation system and binary encoding optimization ## Schema Validation System - Add lightweight schema validation system (s.object, s.string, s.number, etc.) - Support auto type inference with Infer<> generic - Integrate schema validation into API/message handlers - Add defineApiWithSchema and defineMsgWithSchema helpers ## Binary Encoding Optimization - Add native WebSocket binary frame support via sendBinary() - Add PacketType.Binary for efficient binary data transmission - Optimize ECSRoom.broadcastBinary() to use native binary ## Architecture Improvements - Extract BaseValidator to separate file to eliminate code duplication - Add ECSRoom export to main index.ts for better discoverability - Add Core.worldManager initialization check in ECSRoom constructor - Remove deprecated validate field from ApiDefinition (use schema instead) ## Documentation - Add Schema validation documentation in Chinese and English * fix(rpc): resolve ESLint warnings with proper types - Replace `any` with proper WebSocket type in connection.ts - Add IncomingMessage type for request handling in index.ts - Use Record<string, Handler> pattern instead of `any` casting - Replace `any` with `unknown` in ProtocolDef and type inference
This commit is contained in:
441
docs/src/content/docs/en/modules/network/distributed.md
Normal file
441
docs/src/content/docs/en/modules/network/distributed.md
Normal file
@@ -0,0 +1,441 @@
|
||||
---
|
||||
title: "Distributed Rooms"
|
||||
description: "Multi-server room management with DistributedRoomManager"
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Distributed room support allows multiple server instances to share a room registry, enabling cross-server player routing and failover.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Server A Server B Server C │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
│ │ Room 1 │ │ Room 3 │ │ Room 5 │ │
|
||||
│ │ Room 2 │ │ Room 4 │ │ Room 6 │ │
|
||||
│ └────┬────┘ └────┬────┘ └────┬────┘ │
|
||||
│ │ │ │ │
|
||||
│ └─────────────────┼─────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────▼──────────┐ │
|
||||
│ │ IDistributedAdapter │ │
|
||||
│ │ (Redis / Memory) │ │
|
||||
│ └─────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Single Server Mode (Testing)
|
||||
|
||||
```typescript
|
||||
import {
|
||||
DistributedRoomManager,
|
||||
MemoryAdapter,
|
||||
Room
|
||||
} from '@esengine/server';
|
||||
|
||||
// Define room type
|
||||
class GameRoom extends Room {
|
||||
maxPlayers = 4;
|
||||
}
|
||||
|
||||
// Create adapter and manager
|
||||
const adapter = new MemoryAdapter();
|
||||
const manager = new DistributedRoomManager(adapter, {
|
||||
serverId: 'server-1',
|
||||
serverAddress: 'localhost',
|
||||
serverPort: 3000
|
||||
}, (conn, type, data) => conn.send(JSON.stringify({ type, data })));
|
||||
|
||||
// Register room type
|
||||
manager.define('game', GameRoom);
|
||||
|
||||
// Start manager
|
||||
await manager.start();
|
||||
|
||||
// Distributed join/create room
|
||||
const result = await manager.joinOrCreateDistributed('game', 'player-1', conn);
|
||||
if ('redirect' in result) {
|
||||
// Player should connect to another server
|
||||
console.log(`Redirect to: ${result.redirect}`);
|
||||
} else {
|
||||
// Player joined local room
|
||||
const { room, player } = result;
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
await manager.stop(true);
|
||||
```
|
||||
|
||||
### Multi-Server Mode (Production)
|
||||
|
||||
```typescript
|
||||
import Redis from 'ioredis';
|
||||
import { DistributedRoomManager, RedisAdapter } from '@esengine/server';
|
||||
|
||||
const adapter = new RedisAdapter({
|
||||
factory: () => new Redis({
|
||||
host: 'redis.example.com',
|
||||
port: 6379
|
||||
}),
|
||||
prefix: 'game:',
|
||||
serverTtl: 30,
|
||||
snapshotTtl: 86400
|
||||
});
|
||||
|
||||
const manager = new DistributedRoomManager(adapter, {
|
||||
serverId: process.env.SERVER_ID,
|
||||
serverAddress: process.env.PUBLIC_IP,
|
||||
serverPort: 3000,
|
||||
heartbeatInterval: 5000,
|
||||
snapshotInterval: 30000,
|
||||
enableFailover: true,
|
||||
capacity: 100
|
||||
}, sendFn);
|
||||
```
|
||||
|
||||
## DistributedRoomManager
|
||||
|
||||
### Configuration Options
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `serverId` | `string` | required | Unique server identifier |
|
||||
| `serverAddress` | `string` | required | Public address for client connections |
|
||||
| `serverPort` | `number` | required | Server port |
|
||||
| `heartbeatInterval` | `number` | `5000` | Heartbeat interval (ms) |
|
||||
| `snapshotInterval` | `number` | `30000` | State snapshot interval, 0 to disable |
|
||||
| `migrationTimeout` | `number` | `10000` | Room migration timeout |
|
||||
| `enableFailover` | `boolean` | `true` | Enable automatic failover |
|
||||
| `capacity` | `number` | `100` | Max rooms on this server |
|
||||
|
||||
### Lifecycle Methods
|
||||
|
||||
#### start()
|
||||
|
||||
Start the distributed room manager. Connects to adapter, registers server, starts heartbeat.
|
||||
|
||||
```typescript
|
||||
await manager.start();
|
||||
```
|
||||
|
||||
#### stop(graceful?)
|
||||
|
||||
Stop the manager. If `graceful=true`, marks server as draining and saves all room snapshots.
|
||||
|
||||
```typescript
|
||||
await manager.stop(true);
|
||||
```
|
||||
|
||||
### Routing Methods
|
||||
|
||||
#### joinOrCreateDistributed()
|
||||
|
||||
Join or create a room with distributed awareness. Returns `{ room, player }` for local rooms or `{ redirect: string }` for remote rooms.
|
||||
|
||||
```typescript
|
||||
const result = await manager.joinOrCreateDistributed('game', 'player-1', conn);
|
||||
|
||||
if ('redirect' in result) {
|
||||
// Client should redirect to another server
|
||||
res.json({ redirect: result.redirect });
|
||||
} else {
|
||||
// Player joined local room
|
||||
const { room, player } = result;
|
||||
}
|
||||
```
|
||||
|
||||
#### route()
|
||||
|
||||
Route a player to the appropriate room/server.
|
||||
|
||||
```typescript
|
||||
const result = await manager.route({
|
||||
roomType: 'game',
|
||||
playerId: 'p1'
|
||||
});
|
||||
|
||||
switch (result.type) {
|
||||
case 'local': // Room is on this server
|
||||
break;
|
||||
case 'redirect': // Room is on another server
|
||||
// result.serverAddress contains target server
|
||||
break;
|
||||
case 'create': // No room exists, need to create
|
||||
break;
|
||||
case 'unavailable': // Cannot find or create room
|
||||
// result.reason contains error message
|
||||
break;
|
||||
}
|
||||
```
|
||||
|
||||
### State Management
|
||||
|
||||
#### saveSnapshot()
|
||||
|
||||
Manually save a room's state snapshot.
|
||||
|
||||
```typescript
|
||||
await manager.saveSnapshot(roomId);
|
||||
```
|
||||
|
||||
#### restoreFromSnapshot()
|
||||
|
||||
Restore a room from its saved snapshot.
|
||||
|
||||
```typescript
|
||||
const success = await manager.restoreFromSnapshot(roomId);
|
||||
```
|
||||
|
||||
### Query Methods
|
||||
|
||||
#### getServers()
|
||||
|
||||
Get all online servers.
|
||||
|
||||
```typescript
|
||||
const servers = await manager.getServers();
|
||||
```
|
||||
|
||||
#### queryDistributedRooms()
|
||||
|
||||
Query rooms across all servers.
|
||||
|
||||
```typescript
|
||||
const rooms = await manager.queryDistributedRooms({
|
||||
roomType: 'game',
|
||||
hasSpace: true,
|
||||
notLocked: true
|
||||
});
|
||||
```
|
||||
|
||||
## IDistributedAdapter
|
||||
|
||||
Interface for distributed backends. Implement this to add support for Redis, message queues, etc.
|
||||
|
||||
### Built-in Adapters
|
||||
|
||||
#### MemoryAdapter
|
||||
|
||||
In-memory implementation for testing and single-server mode.
|
||||
|
||||
```typescript
|
||||
const adapter = new MemoryAdapter({
|
||||
serverTtl: 15000, // Server offline after no heartbeat (ms)
|
||||
enableTtlCheck: true, // Enable automatic TTL checking
|
||||
ttlCheckInterval: 5000 // TTL check interval (ms)
|
||||
});
|
||||
```
|
||||
|
||||
#### RedisAdapter
|
||||
|
||||
Redis-based implementation for production multi-server deployments.
|
||||
|
||||
```typescript
|
||||
import Redis from 'ioredis';
|
||||
import { RedisAdapter } from '@esengine/server';
|
||||
|
||||
const adapter = new RedisAdapter({
|
||||
factory: () => new Redis('redis://localhost:6379'),
|
||||
prefix: 'game:', // Key prefix (default: 'dist:')
|
||||
serverTtl: 30, // Server TTL in seconds (default: 30)
|
||||
roomTtl: 0, // Room TTL, 0 = never expire (default: 0)
|
||||
snapshotTtl: 86400, // Snapshot TTL in seconds (default: 24h)
|
||||
channel: 'game:events' // Pub/Sub channel (default: 'distributed:events')
|
||||
});
|
||||
```
|
||||
|
||||
**RedisAdapter Configuration:**
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `factory` | `() => RedisClient` | required | Redis client factory (lazy connection) |
|
||||
| `prefix` | `string` | `'dist:'` | Key prefix for all Redis keys |
|
||||
| `serverTtl` | `number` | `30` | Server TTL in seconds |
|
||||
| `roomTtl` | `number` | `0` | Room TTL in seconds, 0 = no expiry |
|
||||
| `snapshotTtl` | `number` | `86400` | Snapshot TTL in seconds |
|
||||
| `channel` | `string` | `'distributed:events'` | Pub/Sub channel name |
|
||||
|
||||
**Features:**
|
||||
- Server registry with automatic heartbeat TTL
|
||||
- Room registry with cross-server lookup
|
||||
- State snapshots with configurable TTL
|
||||
- Pub/Sub for cross-server events
|
||||
- Distributed locks using Redis SET NX
|
||||
|
||||
### Custom Adapters
|
||||
|
||||
```typescript
|
||||
import type { IDistributedAdapter } from '@esengine/server';
|
||||
|
||||
class MyAdapter implements IDistributedAdapter {
|
||||
// Lifecycle
|
||||
async connect(): Promise<void> { }
|
||||
async disconnect(): Promise<void> { }
|
||||
isConnected(): boolean { return true; }
|
||||
|
||||
// Server Registry
|
||||
async registerServer(server: ServerRegistration): Promise<void> { }
|
||||
async unregisterServer(serverId: string): Promise<void> { }
|
||||
async heartbeat(serverId: string): Promise<void> { }
|
||||
async getServers(): Promise<ServerRegistration[]> { return []; }
|
||||
|
||||
// Room Registry
|
||||
async registerRoom(room: RoomRegistration): Promise<void> { }
|
||||
async unregisterRoom(roomId: string): Promise<void> { }
|
||||
async queryRooms(query: RoomQuery): Promise<RoomRegistration[]> { return []; }
|
||||
async findAvailableRoom(roomType: string): Promise<RoomRegistration | null> { return null; }
|
||||
|
||||
// State Snapshots
|
||||
async saveSnapshot(snapshot: RoomSnapshot): Promise<void> { }
|
||||
async loadSnapshot(roomId: string): Promise<RoomSnapshot | null> { return null; }
|
||||
|
||||
// Pub/Sub
|
||||
async publish(event: DistributedEvent): Promise<void> { }
|
||||
async subscribe(pattern: string, handler: Function): Promise<() => void> { return () => {}; }
|
||||
|
||||
// Distributed Locks
|
||||
async acquireLock(key: string, ttlMs: number): Promise<boolean> { return true; }
|
||||
async releaseLock(key: string): Promise<void> { }
|
||||
}
|
||||
```
|
||||
|
||||
## Player Routing Flow
|
||||
|
||||
```
|
||||
Client Server A Server B
|
||||
│ │ │
|
||||
│─── joinOrCreate ────────►│ │
|
||||
│ │ │
|
||||
│ │── findAvailableRoom() ───►│
|
||||
│ │◄──── room on Server B ────│
|
||||
│ │ │
|
||||
│◄─── redirect: B:3001 ────│ │
|
||||
│ │ │
|
||||
│───────────────── connect to Server B ───────────────►│
|
||||
│ │ │
|
||||
│◄─────────────────────────────── joined ─────────────│
|
||||
```
|
||||
|
||||
## Event Types
|
||||
|
||||
The distributed system publishes these events:
|
||||
|
||||
| Event | Description |
|
||||
|-------|-------------|
|
||||
| `server:online` | Server came online |
|
||||
| `server:offline` | Server went offline |
|
||||
| `server:draining` | Server is draining |
|
||||
| `room:created` | Room was created |
|
||||
| `room:disposed` | Room was disposed |
|
||||
| `room:updated` | Room info updated |
|
||||
| `room:message` | Cross-server room message |
|
||||
| `room:migrated` | Room migrated to another server |
|
||||
| `player:joined` | Player joined room |
|
||||
| `player:left` | Player left room |
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use Unique Server IDs** - Use hostname, container ID, or UUID
|
||||
|
||||
2. **Configure Proper Heartbeat** - Balance between freshness and network overhead
|
||||
|
||||
3. **Enable Snapshots for Stateful Rooms** - Ensure room state survives server restarts
|
||||
|
||||
4. **Handle Redirects Gracefully** - Client should reconnect to target server
|
||||
```typescript
|
||||
// Client handling redirect
|
||||
if (response.redirect) {
|
||||
await client.disconnect();
|
||||
await client.connect(response.redirect);
|
||||
await client.joinRoom(roomId);
|
||||
}
|
||||
```
|
||||
|
||||
5. **Use Distributed Locks** - Prevent race conditions in joinOrCreate
|
||||
|
||||
## Using createServer Integration
|
||||
|
||||
The simplest way to use distributed rooms is through `createServer`'s `distributed` config:
|
||||
|
||||
```typescript
|
||||
import { createServer } from '@esengine/server';
|
||||
import { RedisAdapter, Room } from '@esengine/server';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
class GameRoom extends Room {
|
||||
maxPlayers = 4;
|
||||
}
|
||||
|
||||
const server = await createServer({
|
||||
port: 3000,
|
||||
distributed: {
|
||||
enabled: true,
|
||||
adapter: new RedisAdapter({ factory: () => new Redis() }),
|
||||
serverId: 'server-1',
|
||||
serverAddress: 'ws://192.168.1.100',
|
||||
serverPort: 3000,
|
||||
enableFailover: true,
|
||||
capacity: 100
|
||||
}
|
||||
});
|
||||
|
||||
server.define('game', GameRoom);
|
||||
await server.start();
|
||||
```
|
||||
|
||||
When clients call the `JoinRoom` API, the server will automatically:
|
||||
1. Find available rooms (local or remote)
|
||||
2. If room is on another server, send `$redirect` message to client
|
||||
3. Client receives redirect and connects to target server
|
||||
|
||||
## Load Balancing
|
||||
|
||||
Use `LoadBalancedRouter` for server selection:
|
||||
|
||||
```typescript
|
||||
import { LoadBalancedRouter, createLoadBalancedRouter } from '@esengine/server';
|
||||
|
||||
// Using factory function
|
||||
const router = createLoadBalancedRouter('least-players');
|
||||
|
||||
// Or create directly
|
||||
const router = new LoadBalancedRouter({
|
||||
strategy: 'least-rooms', // Select server with fewest rooms
|
||||
preferLocal: true // Prefer local server
|
||||
});
|
||||
|
||||
// Available strategies
|
||||
// - 'round-robin': Round robin selection
|
||||
// - 'least-rooms': Fewest rooms
|
||||
// - 'least-players': Fewest players
|
||||
// - 'random': Random selection
|
||||
// - 'weighted': Weighted by capacity usage
|
||||
```
|
||||
|
||||
## Failover
|
||||
|
||||
When a server goes offline with `enableFailover` enabled, the system will automatically:
|
||||
|
||||
1. Detect server offline (via heartbeat timeout)
|
||||
2. Query all rooms on that server
|
||||
3. Use distributed lock to prevent multiple servers recovering same room
|
||||
4. Restore room state from snapshot
|
||||
5. Publish `room:migrated` event to notify other servers
|
||||
|
||||
```typescript
|
||||
// Ensure periodic snapshots
|
||||
const manager = new DistributedRoomManager(adapter, {
|
||||
serverId: 'server-1',
|
||||
serverAddress: 'localhost',
|
||||
serverPort: 3000,
|
||||
snapshotInterval: 30000, // Save snapshot every 30 seconds
|
||||
enableFailover: true // Enable failover
|
||||
}, sendFn);
|
||||
```
|
||||
|
||||
## Future Releases
|
||||
|
||||
- Redis Cluster support
|
||||
- More load balancing strategies (geo-location, latency-aware)
|
||||
@@ -147,6 +147,7 @@ service.on('chat', (data) => {
|
||||
|
||||
- [Client Usage](/en/modules/network/client/) - NetworkPlugin, components and systems
|
||||
- [Server Side](/en/modules/network/server/) - GameServer and Room management
|
||||
- [Distributed Rooms](/en/modules/network/distributed/) - Multi-server room management and player routing
|
||||
- [State Sync](/en/modules/network/sync/) - Interpolation and snapshot buffering
|
||||
- [Client Prediction](/en/modules/network/prediction/) - Input prediction and server reconciliation
|
||||
- [Area of Interest (AOI)](/en/modules/network/aoi/) - View filtering and bandwidth optimization
|
||||
|
||||
@@ -266,6 +266,122 @@ class GameRoom extends Room {
|
||||
}
|
||||
```
|
||||
|
||||
## Schema Validation
|
||||
|
||||
Use the built-in Schema validation system for runtime type validation:
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```typescript
|
||||
import { s, defineApiWithSchema } from '@esengine/server'
|
||||
|
||||
// Define schema
|
||||
const MoveSchema = s.object({
|
||||
x: s.number(),
|
||||
y: s.number(),
|
||||
speed: s.number().optional()
|
||||
})
|
||||
|
||||
// Auto type inference
|
||||
type Move = s.infer<typeof MoveSchema> // { x: number; y: number; speed?: number }
|
||||
|
||||
// Use schema to define API (auto validation)
|
||||
export default defineApiWithSchema(MoveSchema, {
|
||||
handler(req, ctx) {
|
||||
// req is validated, type-safe
|
||||
console.log(req.x, req.y)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Validator Types
|
||||
|
||||
| Type | Example | Description |
|
||||
|------|---------|-------------|
|
||||
| `s.string()` | `s.string().min(1).max(50)` | String with length constraints |
|
||||
| `s.number()` | `s.number().min(0).int()` | Number with range and integer constraints |
|
||||
| `s.boolean()` | `s.boolean()` | Boolean |
|
||||
| `s.literal()` | `s.literal('admin')` | Literal type |
|
||||
| `s.object()` | `s.object({ name: s.string() })` | Object |
|
||||
| `s.array()` | `s.array(s.number())` | Array |
|
||||
| `s.enum()` | `s.enum(['a', 'b'] as const)` | Enum |
|
||||
| `s.union()` | `s.union([s.string(), s.number()])` | Union type |
|
||||
| `s.record()` | `s.record(s.any())` | Record type |
|
||||
|
||||
### Modifiers
|
||||
|
||||
```typescript
|
||||
// Optional field
|
||||
s.string().optional()
|
||||
|
||||
// Default value
|
||||
s.number().default(0)
|
||||
|
||||
// Nullable
|
||||
s.string().nullable()
|
||||
|
||||
// String validation
|
||||
s.string().min(1).max(100).email().url().regex(/^[a-z]+$/)
|
||||
|
||||
// Number validation
|
||||
s.number().min(0).max(100).int().positive()
|
||||
|
||||
// Array validation
|
||||
s.array(s.string()).min(1).max(10).nonempty()
|
||||
|
||||
// Object validation
|
||||
s.object({ ... }).strict() // No extra fields allowed
|
||||
s.object({ ... }).partial() // All fields optional
|
||||
s.object({ ... }).pick('name', 'age') // Pick fields
|
||||
s.object({ ... }).omit('password') // Omit fields
|
||||
```
|
||||
|
||||
### Message Validation
|
||||
|
||||
```typescript
|
||||
import { s, defineMsgWithSchema } from '@esengine/server'
|
||||
|
||||
const InputSchema = s.object({
|
||||
keys: s.array(s.string()),
|
||||
timestamp: s.number()
|
||||
})
|
||||
|
||||
export default defineMsgWithSchema(InputSchema, {
|
||||
handler(msg, ctx) {
|
||||
// msg is validated
|
||||
console.log(msg.keys, msg.timestamp)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Manual Validation
|
||||
|
||||
```typescript
|
||||
import { s, parse, safeParse, createGuard } from '@esengine/server'
|
||||
|
||||
const UserSchema = s.object({
|
||||
name: s.string(),
|
||||
age: s.number().int().min(0)
|
||||
})
|
||||
|
||||
// Throws on error
|
||||
const user = parse(UserSchema, data)
|
||||
|
||||
// Returns result object
|
||||
const result = safeParse(UserSchema, data)
|
||||
if (result.success) {
|
||||
console.log(result.data)
|
||||
} else {
|
||||
console.error(result.error)
|
||||
}
|
||||
|
||||
// Type guard
|
||||
const isUser = createGuard(UserSchema)
|
||||
if (isUser(data)) {
|
||||
// data is User type
|
||||
}
|
||||
```
|
||||
|
||||
## Protocol Definition
|
||||
|
||||
Define shared types in `src/shared/protocol.ts`:
|
||||
|
||||
441
docs/src/content/docs/modules/network/distributed.md
Normal file
441
docs/src/content/docs/modules/network/distributed.md
Normal file
@@ -0,0 +1,441 @@
|
||||
---
|
||||
title: "分布式房间"
|
||||
description: "使用 DistributedRoomManager 实现多服务器房间管理"
|
||||
---
|
||||
|
||||
## 概述
|
||||
|
||||
分布式房间支持允许多个服务器实例共享房间注册表,实现跨服务器玩家路由和故障转移。
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Server A Server B Server C │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
│ │ Room 1 │ │ Room 3 │ │ Room 5 │ │
|
||||
│ │ Room 2 │ │ Room 4 │ │ Room 6 │ │
|
||||
│ └────┬────┘ └────┬────┘ └────┬────┘ │
|
||||
│ │ │ │ │
|
||||
│ └─────────────────┼─────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────▼──────────┐ │
|
||||
│ │ IDistributedAdapter │ │
|
||||
│ │ (Redis / Memory) │ │
|
||||
│ └─────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 单机模式(测试用)
|
||||
|
||||
```typescript
|
||||
import {
|
||||
DistributedRoomManager,
|
||||
MemoryAdapter,
|
||||
Room
|
||||
} from '@esengine/server';
|
||||
|
||||
// 定义房间类型
|
||||
class GameRoom extends Room {
|
||||
maxPlayers = 4;
|
||||
}
|
||||
|
||||
// 创建适配器和管理器
|
||||
const adapter = new MemoryAdapter();
|
||||
const manager = new DistributedRoomManager(adapter, {
|
||||
serverId: 'server-1',
|
||||
serverAddress: 'localhost',
|
||||
serverPort: 3000
|
||||
}, (conn, type, data) => conn.send(JSON.stringify({ type, data })));
|
||||
|
||||
// 注册房间类型
|
||||
manager.define('game', GameRoom);
|
||||
|
||||
// 启动管理器
|
||||
await manager.start();
|
||||
|
||||
// 分布式加入/创建房间
|
||||
const result = await manager.joinOrCreateDistributed('game', 'player-1', conn);
|
||||
if ('redirect' in result) {
|
||||
// 玩家应连接到其他服务器
|
||||
console.log(`重定向到: ${result.redirect}`);
|
||||
} else {
|
||||
// 玩家加入本地房间
|
||||
const { room, player } = result;
|
||||
}
|
||||
|
||||
// 优雅关闭
|
||||
await manager.stop(true);
|
||||
```
|
||||
|
||||
### 多服务器模式(生产用)
|
||||
|
||||
```typescript
|
||||
import Redis from 'ioredis';
|
||||
import { DistributedRoomManager, RedisAdapter } from '@esengine/server';
|
||||
|
||||
const adapter = new RedisAdapter({
|
||||
factory: () => new Redis({
|
||||
host: 'redis.example.com',
|
||||
port: 6379
|
||||
}),
|
||||
prefix: 'game:',
|
||||
serverTtl: 30,
|
||||
snapshotTtl: 86400
|
||||
});
|
||||
|
||||
const manager = new DistributedRoomManager(adapter, {
|
||||
serverId: process.env.SERVER_ID,
|
||||
serverAddress: process.env.PUBLIC_IP,
|
||||
serverPort: 3000,
|
||||
heartbeatInterval: 5000,
|
||||
snapshotInterval: 30000,
|
||||
enableFailover: true,
|
||||
capacity: 100
|
||||
}, sendFn);
|
||||
```
|
||||
|
||||
## DistributedRoomManager
|
||||
|
||||
### 配置选项
|
||||
|
||||
| 属性 | 类型 | 默认值 | 描述 |
|
||||
|------|------|--------|------|
|
||||
| `serverId` | `string` | 必填 | 服务器唯一标识 |
|
||||
| `serverAddress` | `string` | 必填 | 客户端连接的公开地址 |
|
||||
| `serverPort` | `number` | 必填 | 服务器端口 |
|
||||
| `heartbeatInterval` | `number` | `5000` | 心跳间隔(毫秒) |
|
||||
| `snapshotInterval` | `number` | `30000` | 状态快照间隔,0 禁用 |
|
||||
| `migrationTimeout` | `number` | `10000` | 房间迁移超时 |
|
||||
| `enableFailover` | `boolean` | `true` | 启用自动故障转移 |
|
||||
| `capacity` | `number` | `100` | 本服务器最大房间数 |
|
||||
|
||||
### 生命周期方法
|
||||
|
||||
#### start()
|
||||
|
||||
启动分布式房间管理器。连接适配器、注册服务器、启动心跳。
|
||||
|
||||
```typescript
|
||||
await manager.start();
|
||||
```
|
||||
|
||||
#### stop(graceful?)
|
||||
|
||||
停止管理器。如果 `graceful=true`,将服务器标记为 draining 并保存所有房间快照。
|
||||
|
||||
```typescript
|
||||
await manager.stop(true);
|
||||
```
|
||||
|
||||
### 路由方法
|
||||
|
||||
#### joinOrCreateDistributed()
|
||||
|
||||
分布式感知的加入或创建房间。返回本地房间的 `{ room, player }` 或远程房间的 `{ redirect: string }`。
|
||||
|
||||
```typescript
|
||||
const result = await manager.joinOrCreateDistributed('game', 'player-1', conn);
|
||||
|
||||
if ('redirect' in result) {
|
||||
// 客户端应重定向到其他服务器
|
||||
res.json({ redirect: result.redirect });
|
||||
} else {
|
||||
// 玩家加入了本地房间
|
||||
const { room, player } = result;
|
||||
}
|
||||
```
|
||||
|
||||
#### route()
|
||||
|
||||
将玩家路由到合适的房间/服务器。
|
||||
|
||||
```typescript
|
||||
const result = await manager.route({
|
||||
roomType: 'game',
|
||||
playerId: 'p1'
|
||||
});
|
||||
|
||||
switch (result.type) {
|
||||
case 'local': // 房间在本服务器
|
||||
break;
|
||||
case 'redirect': // 房间在其他服务器
|
||||
// result.serverAddress 包含目标服务器地址
|
||||
break;
|
||||
case 'create': // 没有可用房间,需要创建
|
||||
break;
|
||||
case 'unavailable': // 无法找到或创建房间
|
||||
// result.reason 包含错误信息
|
||||
break;
|
||||
}
|
||||
```
|
||||
|
||||
### 状态管理
|
||||
|
||||
#### saveSnapshot()
|
||||
|
||||
手动保存房间状态快照。
|
||||
|
||||
```typescript
|
||||
await manager.saveSnapshot(roomId);
|
||||
```
|
||||
|
||||
#### restoreFromSnapshot()
|
||||
|
||||
从保存的快照恢复房间。
|
||||
|
||||
```typescript
|
||||
const success = await manager.restoreFromSnapshot(roomId);
|
||||
```
|
||||
|
||||
### 查询方法
|
||||
|
||||
#### getServers()
|
||||
|
||||
获取所有在线服务器。
|
||||
|
||||
```typescript
|
||||
const servers = await manager.getServers();
|
||||
```
|
||||
|
||||
#### queryDistributedRooms()
|
||||
|
||||
查询所有服务器上的房间。
|
||||
|
||||
```typescript
|
||||
const rooms = await manager.queryDistributedRooms({
|
||||
roomType: 'game',
|
||||
hasSpace: true,
|
||||
notLocked: true
|
||||
});
|
||||
```
|
||||
|
||||
## IDistributedAdapter
|
||||
|
||||
分布式后端的接口。实现此接口以支持 Redis、消息队列等。
|
||||
|
||||
### 内置适配器
|
||||
|
||||
#### MemoryAdapter
|
||||
|
||||
用于测试和单机模式的内存实现。
|
||||
|
||||
```typescript
|
||||
const adapter = new MemoryAdapter({
|
||||
serverTtl: 15000, // 无心跳后服务器离线时间(毫秒)
|
||||
enableTtlCheck: true, // 启用自动 TTL 检查
|
||||
ttlCheckInterval: 5000 // TTL 检查间隔(毫秒)
|
||||
});
|
||||
```
|
||||
|
||||
#### RedisAdapter
|
||||
|
||||
用于生产环境多服务器部署的 Redis 实现。
|
||||
|
||||
```typescript
|
||||
import Redis from 'ioredis';
|
||||
import { RedisAdapter } from '@esengine/server';
|
||||
|
||||
const adapter = new RedisAdapter({
|
||||
factory: () => new Redis('redis://localhost:6379'),
|
||||
prefix: 'game:', // 键前缀(默认: 'dist:')
|
||||
serverTtl: 30, // 服务器 TTL(秒,默认: 30)
|
||||
roomTtl: 0, // 房间 TTL,0 = 永不过期(默认: 0)
|
||||
snapshotTtl: 86400, // 快照 TTL(秒,默认: 24 小时)
|
||||
channel: 'game:events' // Pub/Sub 频道(默认: 'distributed:events')
|
||||
});
|
||||
```
|
||||
|
||||
**RedisAdapter 配置:**
|
||||
|
||||
| 属性 | 类型 | 默认值 | 描述 |
|
||||
|------|------|--------|------|
|
||||
| `factory` | `() => RedisClient` | 必填 | Redis 客户端工厂(惰性连接) |
|
||||
| `prefix` | `string` | `'dist:'` | 所有 Redis 键的前缀 |
|
||||
| `serverTtl` | `number` | `30` | 服务器 TTL(秒) |
|
||||
| `roomTtl` | `number` | `0` | 房间 TTL(秒),0 = 不过期 |
|
||||
| `snapshotTtl` | `number` | `86400` | 快照 TTL(秒) |
|
||||
| `channel` | `string` | `'distributed:events'` | Pub/Sub 频道名 |
|
||||
|
||||
**功能特性:**
|
||||
- 带自动心跳 TTL 的服务器注册
|
||||
- 跨服务器查找的房间注册
|
||||
- 可配置 TTL 的状态快照
|
||||
- 跨服务器事件的 Pub/Sub
|
||||
- 使用 Redis SET NX 的分布式锁
|
||||
|
||||
### 自定义适配器
|
||||
|
||||
```typescript
|
||||
import type { IDistributedAdapter } from '@esengine/server';
|
||||
|
||||
class MyAdapter implements IDistributedAdapter {
|
||||
// 生命周期
|
||||
async connect(): Promise<void> { }
|
||||
async disconnect(): Promise<void> { }
|
||||
isConnected(): boolean { return true; }
|
||||
|
||||
// 服务器注册
|
||||
async registerServer(server: ServerRegistration): Promise<void> { }
|
||||
async unregisterServer(serverId: string): Promise<void> { }
|
||||
async heartbeat(serverId: string): Promise<void> { }
|
||||
async getServers(): Promise<ServerRegistration[]> { return []; }
|
||||
|
||||
// 房间注册
|
||||
async registerRoom(room: RoomRegistration): Promise<void> { }
|
||||
async unregisterRoom(roomId: string): Promise<void> { }
|
||||
async queryRooms(query: RoomQuery): Promise<RoomRegistration[]> { return []; }
|
||||
async findAvailableRoom(roomType: string): Promise<RoomRegistration | null> { return null; }
|
||||
|
||||
// 状态快照
|
||||
async saveSnapshot(snapshot: RoomSnapshot): Promise<void> { }
|
||||
async loadSnapshot(roomId: string): Promise<RoomSnapshot | null> { return null; }
|
||||
|
||||
// 发布/订阅
|
||||
async publish(event: DistributedEvent): Promise<void> { }
|
||||
async subscribe(pattern: string, handler: Function): Promise<() => void> { return () => {}; }
|
||||
|
||||
// 分布式锁
|
||||
async acquireLock(key: string, ttlMs: number): Promise<boolean> { return true; }
|
||||
async releaseLock(key: string): Promise<void> { }
|
||||
}
|
||||
```
|
||||
|
||||
## 玩家路由流程
|
||||
|
||||
```
|
||||
客户端 服务器 A 服务器 B
|
||||
│ │ │
|
||||
│─── joinOrCreate ────────►│ │
|
||||
│ │ │
|
||||
│ │── findAvailableRoom() ───►│
|
||||
│ │◄──── 服务器 B 上有房间 ────│
|
||||
│ │ │
|
||||
│◄─── redirect: B:3001 ────│ │
|
||||
│ │ │
|
||||
│───────────────── 连接到服务器 B ────────────────────►│
|
||||
│ │ │
|
||||
│◄─────────────────────────────── 已加入 ─────────────│
|
||||
```
|
||||
|
||||
## 事件类型
|
||||
|
||||
分布式系统发布以下事件:
|
||||
|
||||
| 事件 | 描述 |
|
||||
|------|------|
|
||||
| `server:online` | 服务器上线 |
|
||||
| `server:offline` | 服务器离线 |
|
||||
| `server:draining` | 服务器正在排空 |
|
||||
| `room:created` | 房间已创建 |
|
||||
| `room:disposed` | 房间已销毁 |
|
||||
| `room:updated` | 房间信息已更新 |
|
||||
| `room:message` | 跨服务器房间消息 |
|
||||
| `room:migrated` | 房间已迁移到其他服务器 |
|
||||
| `player:joined` | 玩家加入房间 |
|
||||
| `player:left` | 玩家离开房间 |
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **使用唯一服务器 ID** - 使用主机名、容器 ID 或 UUID
|
||||
|
||||
2. **配置合适的心跳** - 在新鲜度和网络开销之间平衡
|
||||
|
||||
3. **为有状态房间启用快照** - 确保房间状态在服务器重启后存活
|
||||
|
||||
4. **优雅处理重定向** - 客户端应重新连接到目标服务器
|
||||
```typescript
|
||||
// 客户端处理重定向
|
||||
if (response.redirect) {
|
||||
await client.disconnect();
|
||||
await client.connect(response.redirect);
|
||||
await client.joinRoom(roomId);
|
||||
}
|
||||
```
|
||||
|
||||
5. **使用分布式锁** - 防止 joinOrCreate 中的竞态条件
|
||||
|
||||
## 使用 createServer 集成
|
||||
|
||||
最简单的使用方式是通过 `createServer` 的 `distributed` 配置:
|
||||
|
||||
```typescript
|
||||
import { createServer } from '@esengine/server';
|
||||
import { RedisAdapter, Room } from '@esengine/server';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
class GameRoom extends Room {
|
||||
maxPlayers = 4;
|
||||
}
|
||||
|
||||
const server = await createServer({
|
||||
port: 3000,
|
||||
distributed: {
|
||||
enabled: true,
|
||||
adapter: new RedisAdapter({ factory: () => new Redis() }),
|
||||
serverId: 'server-1',
|
||||
serverAddress: 'ws://192.168.1.100',
|
||||
serverPort: 3000,
|
||||
enableFailover: true,
|
||||
capacity: 100
|
||||
}
|
||||
});
|
||||
|
||||
server.define('game', GameRoom);
|
||||
await server.start();
|
||||
```
|
||||
|
||||
当客户端调用 `JoinRoom` API 时,服务器会自动:
|
||||
1. 查找可用房间(本地或远程)
|
||||
2. 如果房间在其他服务器,发送 `$redirect` 消息给客户端
|
||||
3. 客户端收到重定向消息后连接到目标服务器
|
||||
|
||||
## 负载均衡
|
||||
|
||||
使用 `LoadBalancedRouter` 进行服务器选择:
|
||||
|
||||
```typescript
|
||||
import { LoadBalancedRouter, createLoadBalancedRouter } from '@esengine/server';
|
||||
|
||||
// 使用工厂函数
|
||||
const router = createLoadBalancedRouter('least-players');
|
||||
|
||||
// 或直接创建
|
||||
const router = new LoadBalancedRouter({
|
||||
strategy: 'least-rooms', // 选择房间数最少的服务器
|
||||
preferLocal: true // 优先选择本地服务器
|
||||
});
|
||||
|
||||
// 可用策略
|
||||
// - 'round-robin': 轮询
|
||||
// - 'least-rooms': 最少房间数
|
||||
// - 'least-players': 最少玩家数
|
||||
// - 'random': 随机选择
|
||||
// - 'weighted': 权重(基于容量使用率)
|
||||
```
|
||||
|
||||
## 故障转移
|
||||
|
||||
当服务器离线时,启用 `enableFailover` 后系统会自动:
|
||||
|
||||
1. 检测到服务器离线(通过心跳超时)
|
||||
2. 查询该服务器上的所有房间
|
||||
3. 使用分布式锁防止多服务器同时恢复
|
||||
4. 从快照恢复房间状态
|
||||
5. 发布 `room:migrated` 事件通知其他服务器
|
||||
|
||||
```typescript
|
||||
// 确保定期保存快照
|
||||
const manager = new DistributedRoomManager(adapter, {
|
||||
serverId: 'server-1',
|
||||
serverAddress: 'localhost',
|
||||
serverPort: 3000,
|
||||
snapshotInterval: 30000, // 每 30 秒保存快照
|
||||
enableFailover: true // 启用故障转移
|
||||
}, sendFn);
|
||||
```
|
||||
|
||||
## 后续版本
|
||||
|
||||
- Redis Cluster 支持
|
||||
- 更多负载均衡策略(地理位置、延迟感知)
|
||||
@@ -147,6 +147,7 @@ service.on('chat', (data) => {
|
||||
|
||||
- [客户端使用](/modules/network/client/) - NetworkPlugin、组件和系统
|
||||
- [服务器端](/modules/network/server/) - GameServer 和 Room 管理
|
||||
- [分布式房间](/modules/network/distributed/) - 多服务器房间管理和玩家路由
|
||||
- [状态同步](/modules/network/sync/) - 插值和快照缓冲
|
||||
- [客户端预测](/modules/network/prediction/) - 输入预测和服务器校正
|
||||
- [兴趣区域 (AOI)](/modules/network/aoi/) - 视野过滤和带宽优化
|
||||
|
||||
@@ -280,6 +280,122 @@ class GameRoom extends Room {
|
||||
}
|
||||
```
|
||||
|
||||
## Schema 验证
|
||||
|
||||
使用内置的 Schema 验证系统进行运行时类型验证:
|
||||
|
||||
### 基础用法
|
||||
|
||||
```typescript
|
||||
import { s, defineApiWithSchema } from '@esengine/server'
|
||||
|
||||
// 定义 Schema
|
||||
const MoveSchema = s.object({
|
||||
x: s.number(),
|
||||
y: s.number(),
|
||||
speed: s.number().optional()
|
||||
})
|
||||
|
||||
// 类型自动推断
|
||||
type Move = s.infer<typeof MoveSchema> // { x: number; y: number; speed?: number }
|
||||
|
||||
// 使用 Schema 定义 API(自动验证)
|
||||
export default defineApiWithSchema(MoveSchema, {
|
||||
handler(req, ctx) {
|
||||
// req 已验证,类型安全
|
||||
console.log(req.x, req.y)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 验证器类型
|
||||
|
||||
| 类型 | 示例 | 描述 |
|
||||
|------|------|------|
|
||||
| `s.string()` | `s.string().min(1).max(50)` | 字符串,支持长度限制 |
|
||||
| `s.number()` | `s.number().min(0).int()` | 数字,支持范围和整数限制 |
|
||||
| `s.boolean()` | `s.boolean()` | 布尔值 |
|
||||
| `s.literal()` | `s.literal('admin')` | 字面量类型 |
|
||||
| `s.object()` | `s.object({ name: s.string() })` | 对象 |
|
||||
| `s.array()` | `s.array(s.number())` | 数组 |
|
||||
| `s.enum()` | `s.enum(['a', 'b'] as const)` | 枚举 |
|
||||
| `s.union()` | `s.union([s.string(), s.number()])` | 联合类型 |
|
||||
| `s.record()` | `s.record(s.any())` | 记录类型 |
|
||||
|
||||
### 修饰符
|
||||
|
||||
```typescript
|
||||
// 可选字段
|
||||
s.string().optional()
|
||||
|
||||
// 默认值
|
||||
s.number().default(0)
|
||||
|
||||
// 可为 null
|
||||
s.string().nullable()
|
||||
|
||||
// 字符串验证
|
||||
s.string().min(1).max(100).email().url().regex(/^[a-z]+$/)
|
||||
|
||||
// 数字验证
|
||||
s.number().min(0).max(100).int().positive()
|
||||
|
||||
// 数组验证
|
||||
s.array(s.string()).min(1).max(10).nonempty()
|
||||
|
||||
// 对象验证
|
||||
s.object({ ... }).strict() // 不允许额外字段
|
||||
s.object({ ... }).partial() // 所有字段可选
|
||||
s.object({ ... }).pick('name', 'age') // 选择字段
|
||||
s.object({ ... }).omit('password') // 排除字段
|
||||
```
|
||||
|
||||
### 消息验证
|
||||
|
||||
```typescript
|
||||
import { s, defineMsgWithSchema } from '@esengine/server'
|
||||
|
||||
const InputSchema = s.object({
|
||||
keys: s.array(s.string()),
|
||||
timestamp: s.number()
|
||||
})
|
||||
|
||||
export default defineMsgWithSchema(InputSchema, {
|
||||
handler(msg, ctx) {
|
||||
// msg 已验证
|
||||
console.log(msg.keys, msg.timestamp)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 手动验证
|
||||
|
||||
```typescript
|
||||
import { s, parse, safeParse, createGuard } from '@esengine/server'
|
||||
|
||||
const UserSchema = s.object({
|
||||
name: s.string(),
|
||||
age: s.number().int().min(0)
|
||||
})
|
||||
|
||||
// 抛出错误
|
||||
const user = parse(UserSchema, data)
|
||||
|
||||
// 返回结果对象
|
||||
const result = safeParse(UserSchema, data)
|
||||
if (result.success) {
|
||||
console.log(result.data)
|
||||
} else {
|
||||
console.error(result.error)
|
||||
}
|
||||
|
||||
// 类型守卫
|
||||
const isUser = createGuard(UserSchema)
|
||||
if (isUser(data)) {
|
||||
// data 是 User 类型
|
||||
}
|
||||
```
|
||||
|
||||
## 协议定义
|
||||
|
||||
在 `src/shared/protocol.ts` 中定义客户端和服务端共享的类型:
|
||||
|
||||
Reference in New Issue
Block a user