引言

上一篇我们概览了轻棋局的整体架构。这一篇深入后端核心: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 提供了三个核心能力:

  1. RoutingHandler — 路由分发
  2. BlockingHandler — 阻塞式处理(数据库操作)
  3. 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)
);

设计要点

  1. 主键用 varchar — 不用 AUTO_INCREMENT,而是用应用生成的 ID(如 UUID 或组合键)。原因:H2 的 GENERATED BY DEFAULT AS IDENTITY 在某些场景下不可靠。

  2. 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';
  1. 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>
  • 断线自动清理

小结

后端架构的核心设计原则:

  1. 简单直接 — 不用框架,直接写 Handler,代码量少但逻辑清晰
  2. 关注点分离 — Server/Store/Service/Engine 各司其职
  3. 渐进式复杂度 — 本地用 H2,生产切 PostgreSQL;内置引擎,可外接引擎
  4. 实时性 — WebSocket 处理对局更新,HTTP 处理 API 调用

上一篇:(一)项目总览与架构设计
下一篇:(三)AI 引擎设计与实现

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐