Repository Wiki
isHarryh/Ark-Pets

进程间通信与 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):

  1. 选择裸 TCP + 行分隔 JSON,而非 HTTP/RPC 框架——通信只发生在 localhost,消息为短小的单行 JSON,BufferedReader.readLine() / PrintWriter.println() 即可完成帧定界,零依赖重量级协议栈。
  2. 候选端口数组而非固定端口——固定端口易被占用或冲突,PortUtils 提供探测 + 试绑定的两级协商(见下文)。
  3. 消息体显式携带字符编码(StringDTO 的 bytes + encoding)——规避跨平台默认字符集不一致导致的乱码。
  4. 每会话一线程(thread-per-session)——由共享的 ProcessPool 执行 SocketSession.run() 的阻塞读循环,模型简单且会话数(宠物数量)天然有限。

Architecture

Loading diagram...

上图展示了通信子系统的三个层次:

  • 协议层(core.concurrent):SocketData 定义线上格式;SocketSession 是所有会话的抽象基类;PortUtils 负责端口协商。
  • 服务端侧(启动器进程内):ArkHomeFX 启动时调用 SocketServer.startServer(hostTray) 绑定端口,随后每接受一个连接即创建 ServerSocketSession 并交给 ProcessPool;会话内的操作最终落到 HostTray 与 MemberTrayProxy。
  • 客户端侧(宠物进程内):ArkPets 通过 SocketClient 与服务端建立长连接,所有交互被封装为 SocketData 的收发。

值得注意的是,服务端会话与监听线程本身也运行在 ProcessPool 提供的线程上,通信子系统不自建线程池,从而与整个应用的并发模型保持一致。

主内容一:协议数据模型 SocketData

SocketData 是所有跨进程消息的统一信封,实现了 Serializable 并以 fastjson2 完成序列化。其完整定义如下:

java
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

字段语义:

字段类型说明
uuidjava.util.UUID发送方进程的稳定标识。服务端在首条消息到达时缓存该值(见 ServerSocketSession.receive),此后该会话的所有消息均绑定到同一成员托盘
operationOperation 枚举本条消息的操作码,见下表
msgStringDTO可选消息体,仅 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:

java
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,将字节与其字符集名称一起传输:

java
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)复用,客户端封装亦基于同一抽象:

java
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:

java
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

读循环的三种结局(对失效处理至关重要):

  1. readLine() 返回非 null → 正常路径,交给抽象方法 receive(request) 处理;
  2. readLine() 返回 null → 对端已按协议正常关闭输出流(进程退出、socket 半关闭),触发 onBroken() 后 close();
  3. 抛出 SocketException → 连接被重置(如宠物进程被强杀),同样走 onBroken() + close()。

send() 是唯一的写入口,PrintWriter 以 autoFlush 模式构造,因此每次 println 都立即刷出,服务端无需手动 flush:

java
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:

java
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 做幂等门闩。启动流程的完整控制流:

java
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():

java
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() 就是协议的操作分发表:

java
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 应答后即关。

两个生命周期钩子负责清理:

java
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 服务端":

java
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 试绑定找出空闲端口:

java
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:端到端时序

下述时序图描绘一次完整的生命周期:启动器冷启动 → 宠物进程接入并登录 → 托盘状态同步 → 宠物退出。

Loading diagram...

启动分支说明:当 getAvailablePort 抛出 ServerCollisionException,ArkHomeFX 记录错误日志并改为客户端行为——实例化 SocketClient 并发送 ACTIVATE_LAUNCHER,请求已运行的服务端唤起主窗口。这正是"重复启动 Ark-Pets 不再开第二个实例,而是把已有实例带到前台"这一产品行为的底层实现(见 ArkHomeFX.java)。

会话生命周期的状态视角:

Loading diagram...

Usage Examples

场景一:启动器装配服务端

来自桌面端入口 ArkHomeFX,展示启动、异常分流与关闭调用的真实用法:

java
SocketServer.getInstance().startServer(hostTray); hostTray.applyTrayIcon();
java
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}
java
SocketServer.getInstance().stopServer(); ProcessPool.getInstance().shutdown();

Sources:

场景二:客户端探测服务端端口

宠物进程/重复启动的进程都通过该方法发现服务端(含协议内验证):

java
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

场景三:服务端应答握手

java
1case HANDSHAKE_REQUEST -> { 2 this.send(SocketData.ofOperation(uuid, SocketData.Operation.HANDSHAKE_RESPONSE)); 3 close(); 4}

Source: SocketServer.java

Configuration Options

配置项类型默认值说明
Const.serverPortsint[]仓库常量(SocketServer 静态导入 serverPorts)服务端候选端口列表,服务端取首个空闲者绑定,客户端按序探测;两端共享同一份常量是协议兼容的前提
socket 读超时(仅探测用)int100 msPortUtils.getServerPort 中 setSoTimeout(100),防止探测挂起
PrintWriter autoFlushbooleantrueSocketSession.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 或抛 SocketExceptiononBroken() → removeMemberTray(uuid),实现"断线即注销"
端口被无关服务占用握手应答非 HANDSHAKE_RESPONSE(JSONException/超时)记 warn 日志并继续探测下一候选端口
端口无监听ConnectException静默跳过(正常探测路径)
恶意/损坏 JSONSocketData.of 抛 JSONExceptionreceive 内整体捕获,丢弃该消息,会话存活
候选端口全部占用getAvailablePort 循环结束抛 NoPortAvailableException,启动器记录错误、不应用托盘图标
重复启动启动器getAvailablePort 探测到已有服务端抛 ServerCollisionException,改发 ACTIVATE_LAUNCHER 唤起旧实例
监听线程池拒绝执行ProcessPool.execute(listener) 抛 RejectedExecutionException监听任务内静默捕获
会话在 LOGIN 前断开uuid == null 时 onBrokenremoveMemberTray(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 所采用的扩展方式。

说明:SocketClient、ProcessPool、HostTray/MemberTrayProxy 的内部实现属于兄弟页面主题,本页仅在通信链路边界处引用。