轻棋局(二):后端核心架构
引言
上一篇我们概览了轻棋局的整体架构。这一篇深入后端核心:Undertow 服务器如何处理请求、WebSocket 如何实现实时对战、认证系统如何保护用户数据、房间系统如何管理多人对局。
1. Undertow 嵌入式服务器
为什么选 Undertow
轻棋局没有用 Spring Boot,而是直接使用 Undertow 2.3.18 作为嵌入式 HTTP 服务器。核心原因:简单、快速、够用。
<!-- pom.xml 中的依赖 -->
<dependency>
<groupId>io.undertow</groupId>
<artifactId>undertow-core</artifactId>
<version>2.3.18.Final</version>
</dependency>
Undertow 提供了三个核心能力:
- RoutingHandler — 路由分发
- BlockingHandler — 阻塞式处理(数据库操作)
- WebSocket — 实时通信
路由注册模式
OnlineSiteServer 在构造函数中注册所有路由:
RoutingHandler routes = Handlers.routing(false)
.get("/", this::handleIndex)
.get("/api/site/bootstrap", this::handleBootstrap)
.get("/api/auth/me", this::handleMe)
.get("/api/lobby/overview", this::handleLobby)
.get("/api/rooms/{roomId}", this::handleRoomById)
.get("/api/games/{gameId}", this::handleGameById)
.post("/api/auth/register", this::handleRegister)
.post("/api/auth/login", this::handleLogin)
.post("/api/rooms", this::handleCreateRoom)
.post("/api/games/{gameId}/move", this::handleMove)
// ... 30+ 路由
每个路由对应一个 handleXxx(HttpServerExchange exchange) 方法。Undertow 的 RoutingHandler 自动解析路径参数(如 {roomId}、{gameId}),通过 PathTemplateMatch 获取。
阻塞式处理
数据库操作必须在阻塞线程中执行,否则会阻塞 Undertow 的 IO 线程:
HttpHandler handler = Handlers.path(new BlockingHandler(routes))
.addExactPath("/ws", Handlers.websocket(...));
BlockingHandler 会将请求从 IO 线程池切换到 Worker 线程池,确保数据库查询不会阻塞其他请求。
Handler 编写模式
每个 Handler 遵循统一模式:
private void handleCreateRoom(HttpServerExchange exchange) {
// 1. 读取请求体
CreateRoomRequest req = readJson(exchange, CreateRoomRequest.class);
// 2. 检查认证
Optional<AuthUser> user = currentUser(exchange);
if (!user.isPresent()) {
sendError(exchange, 401, "未登录");
return;
}
// 3. 执行业务逻辑
RoomSnapshot room = roomHub.createRoom(user.get(), req);
// 4. 返回 JSON 响应
sendJson(exchange, room);
}
辅助方法:
readJson(exchange, Class<T>)— 解析请求体为 Java 对象sendJson(exchange, data)— 序列化为 JSON 并返回sendError(exchange, statusCode, message)— 返回错误currentUser(exchange)— 从 Cookie 获取当前用户
2. 认证系统
密码哈希
使用 BCrypt 算法存储密码:
public class PasswordHasher {
private static final int LOG_ROUNDS = 12;
public String hash(String plainPassword) {
return BCrypt.hashpw(plainPassword, BCrypt.gensalt(LOG_ROUNDS));
}
public boolean verify(String plainPassword, String hashedPassword) {
return BCrypt.checkpw(plainPassword, hashedPassword);
}
}
BCrypt 的优点:
- 自动加盐(salt),无需单独存储
- 计算成本可调(LOG_ROUNDS=12 约 250ms)
- 抗彩虹表攻击
会话管理
采用 Cookie + Token 方案:
// 登录成功后
String token = UUID.randomUUID().toString();
exchange.setCookie(new CookieImpl("session", token)
.setPath("/")
.setSameSiteMode("Lax")
.setMaxAge(7 * 24 * 3600)); // 7 天
// 存储会话
authSessions.create(new UserSession(token, userId, expiresAt));
认证流程:
用户登录 → 生成 Token → 设置 Cookie → 后续请求自动携带 Token → currentUser() 从 Cookie 解析
限流保护
对敏感接口实施限流:
private final RateLimiter authLimiter = new RateLimiter(8, 60_000); // 8次/分钟
private final RateLimiter createRoomLimiter = new RateLimiter(12, 60_000); // 12次/分钟
RateLimiter 使用令牌桶算法:
public class RateLimiter {
private final int maxTokens;
private final long refillIntervalMs;
private long tokens;
private long lastRefillTime;
public synchronized boolean tryAcquire() {
refill();
if (tokens > 0) {
tokens--;
return true;
}
return false;
}
}
WebSocket 认证
WebSocket 连接不会自动携带 Cookie,需要手动提取:
String header = exchange.getRequestHeaders().getFirst("Cookie");
// 从 Cookie header 中解析 session token
String token = parseCookieValue(header, "session");
Optional<AuthUser> user = token.isEmpty()
? Optional.empty()
: store.findUserByToken(token);
3. WebSocket 实时通信
架构
浏览器 A ──WebSocket──→ WsHub ──WebSocket──→ 浏览器 B
│
├── 房间频道
└── 全局频道
WsHub 实现
WsHub 管理所有 WebSocket 连接,按房间分组:
public class WsHub {
private final ConcurrentHashMap<String, Set<WebSocketChannel>> roomChannels
= new ConcurrentHashMap<>();
public void subscribe(String roomId, WebSocketChannel channel) {
roomChannels.computeIfAbsent(roomId, k -> ConcurrentHashMap.newKeySet())
.add(channel);
}
public void broadcast(String roomId, String message) {
Set<WebSocketChannel> channels = roomChannels.get(roomId);
if (channels != null) {
for (WebSocketChannel ch : channels) {
WebSockets.sendText(message, ch, null);
}
}
}
}
消息协议
所有消息使用 JSON 格式:
// 走棋通知
{
"type": "move",
"gameId": "game-123",
"moveIndex": 5,
"notation": "炮二平五",
"board": "...",
"currentTurn": "BLACK"
}
// 对局结束
{
"type": "game_over",
"gameId": "game-123",
"winner": "RED",
"reason": "checkmate"
}
// 计时更新
{
"type": "clock",
"gameId": "game-123",
"firstRemaining": 120,
"secondRemaining": 115
}
走棋流程
1. 浏览器 A 发送 POST /api/games/{gameId}/move
2. Server 验证走法合法性
3. Server 更新数据库
4. Server 通过 WsHub 广播到房间
5. 浏览器 B 收到 WebSocket 消息,更新棋盘
6. 如果轮到 AI,Server 计算 AI 走法
7. Server 再次广播 AI 的走法
4. 房间系统
房间生命周期
创建 → 等待加入 → 双方就绪 → 对局进行中 → 对局结束 → 归档
数据模型
public class RoomSnapshot {
String roomId;
String gameType; // XIANGQI, GOMOKU, GO
String status; // WAITING, READY, PLAYING, FINISHED
String visibility; // PUBLIC, PRIVATE
String inviteCode; // 邀请码(6位)
String creatorId;
String creatorUsername;
String joinerId;
String joinerUsername;
Instant createdAt;
}
邀请码机制
创建私有房间时生成唯一邀请码:
private String generateInviteCode() {
String chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // 排除易混淆字符
StringBuilder code = new StringBuilder();
for (int i = 0; i < 6; i++) {
code.append(chars.charAt(random.nextInt(chars.length())));
}
return code.toString();
}
加入者通过 /api/rooms/join-by-code 接口,输入邀请码即可加入对应房间。
5. 数据库设计
Schema 概览
-- 用户表
create table if not exists users (
id varchar(64) primary key,
username varchar(64) not null unique,
password_hash varchar(120) not null,
created_at timestamp not null
);
-- 会话表
create table if not exists auth_sessions (
token varchar(128) primary key,
user_id varchar(64) not null,
expires_at timestamp not null,
created_at timestamp not null
);
-- 对局表
create table if not exists games (
id varchar(64) primary key,
room_id varchar(64) not null,
game_type varchar(16) not null,
is_training boolean not null default false,
opponent_type varchar(32) not null default 'HUMAN',
ai_engine varchar(64),
difficulty varchar(16),
status varchar(16) not null,
first_user_id varchar(64) not null,
first_username varchar(64) not null,
first_side varchar(16) not null,
second_user_id varchar(64) not null,
second_username varchar(64) not null,
second_side varchar(16) not null,
current_turn varchar(16),
winner_side varchar(16),
result_text varchar(255),
board_json text not null,
move_count int not null default 0,
created_at timestamp not null,
started_at timestamp not null,
finished_at timestamp
);
-- 着法表
create table if not exists game_moves (
id varchar(96) primary key,
game_id varchar(64) not null,
move_index int not null,
actor_user_id varchar(64) not null,
side varchar(16) not null,
notation varchar(120),
payload_json text not null,
created_at timestamp not null
);
-- 学习进度表
create table if not exists learn_progress (
user_id varchar(64) not null,
content_type varchar(16) not null,
content_id varchar(96) not null,
completed_at timestamp not null,
updated_at timestamp not null,
primary key (user_id, content_type, content_id)
);
设计要点
-
主键用 varchar — 不用 AUTO_INCREMENT,而是用应用生成的 ID(如 UUID 或组合键)。原因:H2 的
GENERATED BY DEFAULT AS IDENTITY在某些场景下不可靠。 -
Schema 自动演化 — 用
ALTER TABLE ... ADD COLUMN IF NOT EXISTS实现向后兼容的 Schema 升级:
alter table games add column if not exists is_training boolean not null default false;
alter table games add column if not exists opponent_type varchar(32) not null default 'HUMAN';
- board_json 存储棋盘状态 — 每次走棋后,将整个棋盘序列化为 JSON 存入数据库。好处是恢复对局时不需要重放所有着法。
6. 数据存储层
OnlineStore
OnlineStore 是所有数据操作的统一入口:
public class OnlineStore {
private final UserRepository users;
private final AuthSessionRepository sessions;
public static OnlineStore createDefault() {
// 根据 XQ_DATABASE_URL 决定使用 H2 还是 PostgreSQL
String url = System.getenv("XQ_DATABASE_URL");
if (url != null && !url.isEmpty()) {
return new PostgresStore(url);
}
return new H2Store("xiangqi");
}
public void initSchema() {
// 执行 schema.sql 初始化表结构
}
}
连接池
使用 HikariCP 管理数据库连接:
HikariConfig config = new HikariConfig();
config.setJdbcUrl(url);
config.setMaximumPoolSize(10);
config.setMinimumIdle(2);
HikariDataSource ds = new HikariDataSource(config);
7. 完整请求生命周期
以「走棋」为例,完整请求流程:
1. 浏览器 POST /api/games/game-123/move
Body: {"fromRow": 9, "fromCol": 7, "toRow": 7, "toCol": 7}
2. BlockingHandler 将请求切换到 Worker 线程
3. OnlineSiteServer.handleMove() 执行:
a. readJson() 解析请求体
b. currentUser() 从 Cookie 获取用户
c. 从 OnlineStore 加载对局
d. 验证:是否轮到该用户?走法是否合法?
e. 执行走法,更新 Board
f. 检查胜负(将杀、困毙)
g. 保存到数据库
h. 通过 WsHub 广播 WebSocket 消息
i. 如果需要 AI 走法,调用引擎
j. sendJson() 返回结果
4. 浏览器收到响应,更新 UI
8. 性能考虑
限流
- 认证接口:8 次/分钟
- 创建房间:12 次/分钟
- 走棋接口:未限流(依赖对局状态验证)
数据库
- H2 本地文件模式:适合开发和小规模部署
- PostgreSQL:生产环境推荐,支持并发连接
- HikariCP:连接池大小 10,最小空闲 2
WebSocket
- 每个房间一个频道
- 消息广播使用
ConcurrentHashMap+Set<WebSocketChannel> - 断线自动清理
小结
后端架构的核心设计原则:
- 简单直接 — 不用框架,直接写 Handler,代码量少但逻辑清晰
- 关注点分离 — Server/Store/Service/Engine 各司其职
- 渐进式复杂度 — 本地用 H2,生产切 PostgreSQL;内置引擎,可外接引擎
- 实时性 — WebSocket 处理对局更新,HTTP 处理 API 调用
上一篇:(一)项目总览与架构设计
下一篇:(三)AI 引擎设计与实现
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐



所有评论(0)