进程间通信与 Socket 协议
Ark-Pets 采用 TCP Socket + JSON 行协议 实现跨进程通信(IPC):启动器(Launcher)进程内运行一个 SocketServer,每个桌面宠物进程作为客户端接入,通过统一的 SocketData 信封交换指令(登录、托盘操作、握手探测等)。本文完整剖析该通信子系统的协议模型、会话抽象、服务端生命周期、端口发现机制及其并发与失效处理。
Purpose and Scope
本页覆盖 Ark-Pets 进程间通信能力的全部端到端实现,位于 core/src/cn/harryh/arkpets/concurrent/ 包内:
- 协议数据模型
SocketData(操作枚举、UUID 标识、编码显式化的消息体) - 会话抽象
SocketSession(阻塞式读循环、发送、关闭与断连钩子) - 服务端
SocketServer及其内部类ServerSocketSession(单例、端口绑定、操作分发) - 端口发现与单实例碰撞检测
PortUtils(握手探测 + 绑定试探) - 启动器侧的装配与错误处理(
ArkHomeFX中的启动/停止调用点)
有意留给兄弟页面的内容:ProcessPool 线程池的调度细节、HostTray/MemberTrayProxy 托盘菜单的渲染与交互逻辑、SocketClient 客户端封装的内部实现,均属于各自独立主题,本页仅在通信链路上引用它们。
Overview
Ark-Pets 的产品形态是"一个启动器 + N 个独立宠物进程"。宠物进程各自拥有渲染循环与右键菜单需求,但系统托盘图标只应出现一个(由启动器持有)。因此需要一条跨进程通道完成:
| 需求 | 通信用途 |
|---|---|
| 单实例保证 | 启动器冷启动时探测是否已有服务端,避免出现多个托盘宿主 |
| 托盘聚合 | 每个宠物进程 LOGIN 后,其菜单项通过代理挂载到宿主托盘 |
| 状态同步 | 宠物的"保持动作/取消保持、透明模式、可切换场景、切换场景"实时反映到托盘菜单 |
| 唤起启动器 | 已有实例运行时,再次启动的进程请求服务端 ACTIVATE_LAUNCHER 显示主窗口 |
| 优雅退出 | 宠物进程退出前发送 LOGOUT,服务端摘除其托盘成员并关闭会话 |
关键设计决策(WHY):
- 选择裸 TCP + 行分隔 JSON,而非 HTTP/RPC 框架——通信只发生在
localhost,消息为短小的单行 JSON,BufferedReader.readLine()/PrintWriter.println()即可完成帧定界,零依赖重量级协议栈。 - 候选端口数组而非固定端口——固定端口易被占用或冲突,
PortUtils提供探测 + 试绑定的两级协商(见下文)。 - 消息体显式携带字符编码(
StringDTO的bytes + encoding)——规避跨平台默认字符集不一致导致的乱码。 - 每会话一线程(thread-per-session)——由共享的
ProcessPool执行SocketSession.run()的阻塞读循环,模型简单且会话数(宠物数量)天然有限。
Architecture
上图展示了通信子系统的三个层次:
- 协议层(
core.concurrent):SocketData定义线上格式;SocketSession是所有会话的抽象基类;PortUtils负责端口协商。 - 服务端侧(启动器进程内):
ArkHomeFX启动时调用SocketServer.startServer(hostTray)绑定端口,随后每接受一个连接即创建ServerSocketSession并交给ProcessPool;会话内的操作最终落到HostTray与MemberTrayProxy。 - 客户端侧(宠物进程内):
ArkPets通过SocketClient与服务端建立长连接,所有交互被封装为SocketData的收发。
值得注意的是,服务端会话与监听线程本身也运行在 ProcessPool 提供的线程上,通信子系统不自建线程池,从而与整个应用的并发模型保持一致。
主内容一:协议数据模型 SocketData
SocketData 是所有跨进程消息的统一信封,实现了 Serializable 并以 fastjson2 完成序列化。其完整定义如下:
1public class SocketData implements Serializable {
2 /** ArkPets cross-process-communication operations.
3 */
4 public enum Operation {
5 LOGIN,
6 LOGOUT,
7 KEEP_ACTION,
8 NO_KEEP_ACTION,
9 TRANSPARENT_MODE,
10 NO_TRANSPARENT_MODE,
11 CAN_CHANGE_STAGE,
12 CHANGE_STAGE,
13 HANDSHAKE_REQUEST,
14 HANDSHAKE_RESPONSE,
15 ACTIVATE_LAUNCHER
16 }
17
18 /** The UUID for identification. */
19 public UUID uuid;
20
21 /** The {@link Operation} of this request. */
22 public Operation operation;
23
24 /** The optional message string. */
25 public StringDTO msg;
26}Source: SocketData.java
字段语义:
| 字段 | 类型 | 说明 |
|---|---|---|
uuid | java.util.UUID | 发送方进程的稳定标识。服务端在首条消息到达时缓存该值(见 ServerSocketSession.receive),此后该会话的所有消息均绑定到同一成员托盘 |
operation | Operation 枚举 | 本条消息的操作码,见下表 |
msg | StringDTO | 可选消息体,仅 LOGIN(携带宠物名称)使用 |
11 种 Operation 全集:
| 操作码 | 方向 | 语义 |
|---|---|---|
LOGIN | 客户端 → 服务端 | 宠物进程上线,携带名称,服务端为其创建 MemberTrayProxy |
LOGOUT | 客户端 → 服务端 | 宠物进程退出前注销,服务端摘除托盘成员并关闭会话 |
KEEP_ACTION / NO_KEEP_ACTION | 客户端 → 服务端 | 启用/禁用"保持动作"菜单项状态 |
TRANSPARENT_MODE / NO_TRANSPARENT_MODE | 客户端 → 服务端 | 启用/禁用"透明模式"菜单项状态 |
CAN_CHANGE_STAGE / CHANGE_STAGE | 客户端 → 服务端 | 场景切换能力通知与切换触发 |
HANDSHAKE_REQUEST | 任意探测方 → 服务端 | 端口探测握手,用于发现服务端端口 |
HANDSHAKE_RESPONSE | 服务端 → 探测方 | 对握手的应答,证明该端口确为 Ark-Pets 服务端 |
ACTIVATE_LAUNCHER | 重复启动的进程 → 服务端 | 请求唤起已运行的启动器主窗口 |
序列化与反序列化:toString() 直接委托 JSONObject.toJSONString(this),解析则通过静态工厂 of(String)。两者构成协议的编解码边界——任何写入 socket 的对象都会先被序列化为一行 JSON:
1@Override
2public String toString() {
3 return JSONObject.toJSONString(this);
4}
5
6public static SocketData of(String jsonString) {
7 return JSONObject.parseObject(jsonString, SocketData.class);
8}
9
10public static SocketData ofLogin(UUID uuid, String name) {
11 return new SocketData(uuid, Operation.LOGIN, StringDTO.of(name));
12}
13
14public static SocketData ofOperation(UUID uuid, Operation operation) {
15 return new SocketData(uuid, operation, null);
16}Source: SocketData.java
设计意图:编码显式化的 StringDTO。消息体没有直接使用 String,而是私有内嵌 DTO,将字节与其字符集名称一起传输:
1private static class StringDTO {
2 public byte[] bytes;
3 public String encoding;
4
5 private StringDTO(byte[] bytes, String encoding) {
6 this.bytes = bytes;
7 this.encoding = encoding;
8 }
9
10 @Override
11 public String toString() {
12 return new String(bytes, Charset.forName(encoding));
13 }
14
15 public static StringDTO of(String string) {
16 return new StringDTO(string.getBytes(Charset.defaultCharset()), Charset.defaultCharset().name());
17 }
18}Source: SocketData.java
发送端按本机默认字符集编码并记录编码名,接收端按携带的编码名解码。这保证在不同默认字符集(如 GBK 与 UTF-8)的机器之间传输中文宠物名时不会产生乱码——一个典型的"自描述载荷"设计。
主内容二:会话抽象 SocketSession
SocketSession 是抽象基类,同时是 Runnable。它把"一条 TCP 连接上的读写循环"封装成可继承的模板,供服务端子类(ServerSocketSession)复用,客户端封装亦基于同一抽象:
1abstract public class SocketSession implements Runnable {
2 protected Socket target;
3 protected BufferedReader in;
4 protected PrintWriter out;
5 private boolean hasTarget = false;
6 private boolean hasRun = false;
7 private boolean hasClosed = false;
8
9 public final void setTarget(Socket target) {
10 try {
11 this.target = target;
12 in = new BufferedReader(new InputStreamReader(target.getInputStream()));
13 out = new PrintWriter(target.getOutputStream(), true);
14 hasTarget = true;
15 } catch (IOException e) {
16 throw new RuntimeException(e);
17 }
18 }
19}Source: SocketSession.java
三个 boolean 标志位构成简易的单次使用状态机:setTarget() 只能成功执行一次(成功后 hasTarget=true),run() 只能执行一次(hasRun 防重入),close() 只能执行一次(hasClosed 保证幂等关闭)。任何顺序颠倒都会抛出 IllegalStateException:
1@Override
2public final void run() {
3 if (hasRun)
4 throw new IllegalStateException("The session thread has run yet.");
5 if (!hasTarget)
6 throw new IllegalStateException("The target socket has not been set yet.");
7 hasRun = true;
8 try {
9 while (!target.isClosed()) {
10 try {
11 String request = in.readLine();
12 if (request == null) {
13 Logger.debug("SocketSession", "x- " + this);
14 this.onBroken();
15 this.close();
16 } else {
17 Logger.debug("SocketSession", "<- " + this + " " + request);
18 receive(request);
19 }
20 } catch (SocketException e) {
21 Logger.debug("SocketSession", "x- " + this + " (" + e.getMessage() + ")");
22 this.onBroken();
23 this.close();
24 }
25 }
26 } catch (IOException e) {
27 Logger.error("SocketSession", "An unexpected error occurred on " + this + ", details see below.", e);
28 }
29}Source: SocketSession.java
读循环的三种结局(对失效处理至关重要):
readLine()返回非 null → 正常路径,交给抽象方法receive(request)处理;readLine()返回 null → 对端已按协议正常关闭输出流(进程退出、socket 半关闭),触发onBroken()后close();- 抛出
SocketException→ 连接被重置(如宠物进程被强杀),同样走onBroken()+close()。
send() 是唯一的写入口,PrintWriter 以 autoFlush 模式构造,因此每次 println 都立即刷出,服务端无需手动 flush:
1public final void send(Object request) {
2 if (!hasTarget)
3 throw new IllegalStateException("The target socket has not been set yet.");
4 Logger.debug("SocketSession", "-> " + this + " " + request);
5 out.println(request);
6}Source: SocketSession.java
钩子方法(子类可覆写):receive(String) 为必须实现的请求处理器;onClosed() 在 close() 成功关闭 socket 与流之后回调;onBroken() 在连接异常断开时回调。基类实现为空,把语义留给子类定义。
主内容三:服务端 SocketServer 与操作分发
单例与生命周期
SocketServer 采用双重检查锁定(DCL)单例,字段 instance 声明为 volatile:
1public final class SocketServer {
2 private int port;
3 private ServerSocket serverSocket = null;
4 private final Set<SocketSession> sessionList = new CopyOnWriteArraySet<>();
5 private Thread listener;
6 private final AtomicBoolean running = new AtomicBoolean(false);
7 private static volatile SocketServer instance = null;
8
9 public static SocketServer getInstance() {
10 if (instance == null)
11 synchronized (SocketServer.class) {
12 if (instance == null)
13 instance = new SocketServer();
14 }
15 return instance;
16 }
17}Source: SocketServer.java
startServer 与 stopServer 均为 synchronized 方法并以 AtomicBoolean running 做幂等门闩。启动流程的完整控制流:
1public synchronized void startServer(HostTray hostTray)
2 throws PortUtils.NoPortAvailableException, PortUtils.ServerCollisionException {
3 if (running.get())
4 return;
5 Logger.info("SocketServer", "Request to start server");
6 this.port = PortUtils.getAvailablePort(serverPorts);
7 listener = new Thread(() -> {
8 try {
9 serverSocket = new ServerSocket(port);
10 Logger.info("SocketServer", "Server is running on port " + port);
11 while (!listener.isInterrupted()) {
12 Socket socket = serverSocket.accept();
13 SocketSession session = new ServerSocketSession(hostTray);
14 session.setTarget(socket);
15 sessionList.add(session);
16 ProcessPool.getInstance().execute(session);
17 Logger.info("SocketServer", "(+)" + session + " connected");
18 }
19 serverSocket.close();
20 Logger.info("SocketServer", "Server was stopped");
21 } catch (IOException e) {
22 Logger.error("SocketServer", "An unexpected error occurred while listening, details see below.", e);
23 } catch (RejectedExecutionException ignored) {
24 }
25 });
26 ProcessPool.getInstance().execute(listener);
27 running.set(true);
28}Source: SocketServer.java
逐步解析:① 先经 PortUtils.getAvailablePort(serverPorts) 从 Const.serverPorts 候选数组中确定端口(含碰撞检测);② 创建监听线程并立即提交到 ProcessPool;③ 监听线程内先 new ServerSocket(port) 完成绑定,再进入 accept() 循环——每个被接受的连接都会得到一个独立的 ServerSocketSession,经 setTarget 绑流后加入 sessionList,随即作为任务交给 ProcessPool 执行其阻塞读循环;④ 循环退出条件是监听线程被 interrupt(),随后关闭 ServerSocket。
停止路径则相反:中断监听线程令 accept() 抛出 SocketException 走出循环,然后对所有存活会话调用 close():
1public synchronized void stopServer() {
2 if (!running.get())
3 return;
4 Logger.info("SocketServer", "Request to stop server");
5 if (listener != null)
6 listener.interrupt();
7 sessionList.forEach(SocketSession::close);
8 running.set(false);
9}Source: SocketServer.java
toString() 提供了诊断友好的输出,形如 listen: 0.0.0.0:<port> 加逐行客户端地址列表,便于在日志中快速确认连接拓扑。
ServerSocketSession:操作分发核心
服务端会话子类持有 HostTray 引用、按连接懒创建的 MemberTrayProxy,以及从首条消息缓存的 uuid。receive() 就是协议的操作分发表:
1@Override
2public void receive(String request) {
3 try {
4 SocketData socketData = SocketData.of(request);
5 if (socketData == null || socketData.operation == null)
6 return;
7 if (uuid == null)
8 uuid = socketData.uuid;
9
10 switch (socketData.operation) {
11 case HANDSHAKE_REQUEST -> {
12 this.send(SocketData.ofOperation(uuid, SocketData.Operation.HANDSHAKE_RESPONSE));
13 close();
14 }
15 case ACTIVATE_LAUNCHER -> hostTray.showStage();
16 case LOGIN -> {
17 tray = new MemberTrayProxy(socketData, this, hostTray);
18 hostTray.addMemberTray(uuid, tray);
19 }
20 case LOGOUT -> {
21 hostTray.removeMemberTray(uuid);
22 tray.onExit();
23 close();
24 }
25 case KEEP_ACTION -> tray.onKeepAnimEn();
26 case NO_KEEP_ACTION -> tray.onKeepAnimDis();
27 case TRANSPARENT_MODE -> tray.onTransparentEn();
28 case NO_TRANSPARENT_MODE -> tray.onTransparentDis();
29 case CAN_CHANGE_STAGE -> tray.onCanChangeStage();
30 case CHANGE_STAGE -> tray.onChangeStage();
31 }
32 } catch (JSONException ignored) {
33 }
34}Source: SocketServer.java
几个值得注意的细节:
- 解析即校验:
SocketData.of失败或operation为 null 时静默丢弃,JSONException被整体捕获——坏消息不会击杀会话读循环。 - 握手是短连接:
HANDSHAKE_REQUEST应答后立即close(),探测方无需维持连接(见下文PortUtils)。 - uuid 惰性绑定:首条消息的 uuid 成为该连接的成员身份;托盘操作依赖该身份与
MemberTrayProxy一一对应。 - 两条"关闭型"操作:
LOGOUT在摘除托盘成员后主动关会话;HANDSHAKE_REQUEST应答后即关。
两个生命周期钩子负责清理:
1@Override
2protected void onClosed() {
3 Logger.info("SocketServer", "(-)" + this + " closed");
4 SocketServer.getInstance().sessionList.remove(this);
5}
6
7@Override
8protected void onBroken() {
9 Logger.info("SocketServer", "(x)" + this + " broken");
10 hostTray.removeMemberTray(uuid);
11}Source: SocketServer.java
关键失效语义:onBroken() 在宠物进程异常死亡(未发 LOGOUT)时也会把对应的成员托盘摘除——即"断线即注销",保证托盘不残留死进程的菜单项。日志中的 (+)/(-)/(x) 前缀分别对应连接建立、正常关闭、异常断开三种事件,便于运维排查。
主内容四:端口发现与单实例检测 PortUtils
PortUtils 解决"服务端绑定哪个端口、客户端连哪个端口"的协商问题,同时承担单实例碰撞检测职责。
客户端视角 —— getServerPort:遍历候选端口逐个尝试连接,并用协议内握手验证"这真的是 Ark-Pets 服务端":
1public static int getServerPort(int[] expectedPorts)
2 throws NoServerRunningException {
3 for (int serverPort : expectedPorts) {
4 try (Socket socket = new Socket("localhost", serverPort)) {
5 socket.setSoTimeout(100);
6 PrintWriter out = new PrintWriter(socket.getOutputStream(), true);
7 BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
8 out.println(SocketData.ofOperation(UUID.randomUUID(), SocketData.Operation.HANDSHAKE_REQUEST));
9 SocketData socketData = JSONObject.parseObject(in.readLine(), SocketData.class);
10 out.close();
11 in.close();
12 if (socketData.operation == SocketData.Operation.HANDSHAKE_RESPONSE)
13 return serverPort;
14 } catch (ConnectException ignored) {
15 } catch (JSONException ignored) {
16 Logger.warn("SocketServer", "Port " + serverPort + " responded with an invalid content");
17 } catch (IOException ignored) {
18 Logger.warn("SocketServer", "Port " + serverPort + " is inaccessible");
19 }
20 }
21 throw new NoServerRunningException();
22}Source: PortUtils.java
设计要点:探测 socket 设置了 100ms 读超时,防止候选端口被无关服务占用时长时间挂起;ConnectException(无监听)静默跳过属正常探测路径,而"端口有人但应答不是 Ark-Pets 协议"则记 warn 日志。只有收到 HANDSHAKE_RESPONSE 才认定命中。
服务端视角 —— getAvailablePort:先复用 getServerPort 判定是否已有服务端(有则抛 ServerCollisionException,实现单实例约束);否则用 DatagramSocket 试绑定找出空闲端口:
1public static int getAvailablePort(int[] expectedPorts)
2 throws NoPortAvailableException, ServerCollisionException {
3 try {
4 getServerPort(expectedPorts);
5 throw new ServerCollisionException();
6 } catch (NoServerRunningException ignored) {
7 }
8 for (int serverPort : expectedPorts) {
9 try (DatagramSocket ignored = new DatagramSocket(serverPort)) {
10 return serverPort;
11 } catch (SocketException ignored) {
12 }
13 }
14 throw new NoPortAvailableException();
15}Source: PortUtils.java
该类还定义了三个语义化异常(均继承 IllegalStateException):NoPortAvailableException(候选端口全忙)、ServerCollisionException(已有服务端)、NoServerRunningException(未发现服务端)。
Core Flow:端到端时序
下述时序图描绘一次完整的生命周期:启动器冷启动 → 宠物进程接入并登录 → 托盘状态同步 → 宠物退出。
启动分支说明:当 getAvailablePort 抛出 ServerCollisionException,ArkHomeFX 记录错误日志并改为客户端行为——实例化 SocketClient 并发送 ACTIVATE_LAUNCHER,请求已运行的服务端唤起主窗口。这正是"重复启动 Ark-Pets 不再开第二个实例,而是把已有实例带到前台"这一产品行为的底层实现(见 ArkHomeFX.java)。
会话生命周期的状态视角:
Usage Examples
场景一:启动器装配服务端
来自桌面端入口 ArkHomeFX,展示启动、异常分流与关闭调用的真实用法:
SocketServer.getInstance().startServer(hostTray);
hostTray.applyTrayIcon();1} catch (PortUtils.NoPortAvailableException ex) {
2 Logger.error("SocketServer", "No available port, thus server cannot be started");
3 // No HostTray icon will be applied when this situation happens.
4} catch (PortUtils.ServerCollisionException ex) {
5 Logger.error("SocketServer", "Server is already running");
6 SocketClient socketClient = new SocketClient();
7 // ...
8}SocketServer.getInstance().stopServer();
ProcessPool.getInstance().shutdown();Sources:
场景二:客户端探测服务端端口
宠物进程/重复启动的进程都通过该方法发现服务端(含协议内验证):
1socket.setSoTimeout(100);
2out.println(SocketData.ofOperation(UUID.randomUUID(), SocketData.Operation.HANDSHAKE_REQUEST));
3SocketData socketData = JSONObject.parseObject(in.readLine(), SocketData.class);
4if (socketData.operation == SocketData.Operation.HANDSHAKE_RESPONSE)
5 return serverPort;Source: PortUtils.java
场景三:服务端应答握手
1case HANDSHAKE_REQUEST -> {
2 this.send(SocketData.ofOperation(uuid, SocketData.Operation.HANDSHAKE_RESPONSE));
3 close();
4}Source: SocketServer.java
Configuration Options
| 配置项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
Const.serverPorts | int[] | 仓库常量(SocketServer 静态导入 serverPorts) | 服务端候选端口列表,服务端取首个空闲者绑定,客户端按序探测;两端共享同一份常量是协议兼容的前提 |
| socket 读超时(仅探测用) | int | 100 ms | PortUtils.getServerPort 中 setSoTimeout(100),防止探测挂起 |
| PrintWriter autoFlush | boolean | true | SocketSession.setTarget 构造 PrintWriter(out, true),每次 println 自动 flush |
API Reference
SocketServer.getInstance(): SocketServer
双重检查锁单例访问器;instance 为 volatile。构造函数私有。
SocketServer.startServer(hostTray: HostTray): void(synchronized)
选择端口、创建监听线程并提交 ProcessPool,置 running=true。
Parameters:
hostTray(HostTray):宿主托盘,将透传给每个ServerSocketSession用于操作分发。
Throws:
PortUtils.NoPortAvailableException:候选端口全部被占用。PortUtils.ServerCollisionException:检测到已有服务端运行(单实例约束)。
SocketServer.stopServer(): void(synchronized)
中断监听线程、关闭全部会话、置 running=false;幂等。
SocketSession.setTarget(target: Socket): void(final)
将 socket 包装为 BufferedReader/PrintWriter;此后 hasTarget=true。
Throws: RuntimeException(包装底层 IOException)。
SocketSession.run(): void(final,Runnable)
阻塞读循环:非 null 行交给 receive(String);null 或 SocketException 触发 onBroken() + close()。
Throws:
IllegalStateException:run已执行过(hasRun)或尚未setTarget(!hasTarget)。
SocketSession.send(request: Object): void(final)
out.println(request) 自动 flush 并记录 debug 日志。
Throws: IllegalStateException:未 setTarget。
SocketSession.receive(request: String): void(abstract)
子类实现的请求处理器,输入为一行 JSON 文本。
SocketSession.close(): void(final,幂等)
依次关闭 socket、in、out,成功后回调 onClosed()。
SocketData.of(jsonString: String): SocketData
fastjson2 反序列化;输入非法时由调用方捕获 JSONException。
SocketData.ofLogin(uuid: UUID, name: String): SocketData / SocketData.ofOperation(uuid: UUID, operation: Operation): SocketData
两个静态工厂:前者携带 StringDTO 消息体(LOGIN),后者无消息体。
PortUtils.getServerPort(expectedPorts: int[]): int
Returns: 首个通过 HANDSHAKE_REQUEST/HANDSHAKE_RESPONSE 验证的端口。
Throws: NoServerRunningException(所有候选端口均无有效服务端)。
PortUtils.getAvailablePort(expectedPorts: int[]): int
Returns: 首个可成功绑定 DatagramSocket 的端口。
Throws: ServerCollisionException(已有服务端)/ NoPortAvailableException(候选全忙)。
Failure Modes, Edge Cases & Concurrency
失效模式与边界情况
| 场景 | 检测点 | 处理方式 |
|---|---|---|
| 宠物进程正常退出 | 客户端发送 LOGOUT | 服务端摘除托盘成员后 close() 会话 |
| 宠物进程被强杀/崩溃 | readLine() 返回 null 或抛 SocketException | onBroken() → removeMemberTray(uuid),实现"断线即注销" |
| 端口被无关服务占用 | 握手应答非 HANDSHAKE_RESPONSE(JSONException/超时) | 记 warn 日志并继续探测下一候选端口 |
| 端口无监听 | ConnectException | 静默跳过(正常探测路径) |
| 恶意/损坏 JSON | SocketData.of 抛 JSONException | receive 内整体捕获,丢弃该消息,会话存活 |
| 候选端口全部占用 | getAvailablePort 循环结束 | 抛 NoPortAvailableException,启动器记录错误、不应用托盘图标 |
| 重复启动启动器 | getAvailablePort 探测到已有服务端 | 抛 ServerCollisionException,改发 ACTIVATE_LAUNCHER 唤起旧实例 |
| 监听线程池拒绝执行 | ProcessPool.execute(listener) 抛 RejectedExecutionException | 监听任务内静默捕获 |
会话在 LOGIN 前断开 | uuid == null 时 onBroken | removeMemberTray(null) 由 HostTray 侧处理 |
并发与一致性
- 单例安全:DCL +
volatile instance;startServer/stopServer均为synchronized,并以AtomicBoolean running做幂等门闩(避免重复启动/停止)。 - 会话集合:
sessionList使用CopyOnWriteArraySet——读多写少(遍历toString/关闭)场景下的无锁读,写(连接建立/onClosed移除)代价可接受。 - 线程模型:监听线程 + 每会话一个阻塞读循环,全部由共享
ProcessPool承载;SocketSession的三个boolean标志在单线程读循环内维护,天然无竞争。 - 顺序性:单连接上请求严格按行序处理;不同宠物间互不阻塞(各自的读循环独立)。
性能与运维要点
- 协议为短消息行式 JSON,无粘包/拆包问题(
readLine即帧边界);消息量与宠物数量成正比,量级小,无需零拷贝等优化。 - 探测路径的 100ms 超时是冷启动延迟的主要来源之一:最坏情况下
getServerPort要逐个等待每个候选端口的超时。 - 日志前缀速查:
SocketServer的(+)连接建立、(-)正常关闭、(x)异常断开;SocketSession的<-收、->发、x-断,配合Logger.debug可完整还原一次会话的全部流量。
Extension Points
- 新增操作码:在
SocketData.Operation枚举中追加值,并在ServerSocketSession.receive的 switch 中增加对应分支即可扩展协议;由于使用枚举与 fastjson2,向后兼容性由"未知枚举反序列化失败→消息被静默丢弃"间接保证。 - 自定义消息体:参照
StringDTO的模式,为msg引入新的 DTO(保持编码自描述)即可传输结构化数据。 - 自定义会话行为:继承
SocketSession并覆写receive/onClosed/onBroken,即ServerSocketSession所采用的扩展方式。
Related Links
- SocketServer.java — 服务端与操作分发
- SocketSession.java — 会话抽象基类
- SocketData.java — JSON 行协议信封
- PortUtils.java — 端口发现与单实例检测
- ArkHomeFX.java — 启动器装配点
说明:
SocketClient、ProcessPool、HostTray/MemberTrayProxy的内部实现属于兄弟页面主题,本页仅在通信链路边界处引用。