Turms 架构分析
本目录是 turms 项目的架构分析汇报材料,按依赖顺序逐模块生成。每个模块一个 Markdown 文件。源码地址 github.com/turms-im/tu...
模块清单与生成顺序
| # | 模块 | 文件 | 状态 |
|---|---|---|---|
| 1 | Gateway 启动流程 | 01-gateway-startup.md | ✅ |
| 2 | Session 会话结构与生命周期 | 02-session-lifecycle.md | ⏳ |
| 3 | MongoDB 表结构 | 03-mongodb-schema.md | ⏳ |
| 4 | Push 流程 | 04-push-flow.md | ⏳ |
| 5 | 群消息推送 | 05-group-message-push.md | ⏳ |
| 6 | Pull 流程 | 06-pull-flow.md | ⏳ |
| 7 | 消息可靠性保障 | 07-message-reliability.md | ⏳ |
| 8 | 降级到 UDP 协议 | 08-udp-downgrade.md | ⏳ |
| 9 | 从 UDP 升级到 TCP 协议 | 09-udp-upgrade.md | ⏳ |
排序依据
材料分两条依赖链,被引用的基础模块在前,综合/特化模块在后:
- 运行时链:启动流程 → session 抽象 →(push / pull / 传输切换都引用它)
- 数据链:MongoDB 表结构 ← 被 pull / 可靠性 / 群推送引用
其中 #7(消息可靠性)是对 #4/#5/#6 的综合性总结,#8/#9(UDP)是连接层优化、与消息投递语义正交,故置于末尾。
模块一:Gateway 启动流程
本模块聚焦三件事:(1)
Node的生命周期------如何创建、需要哪些依赖、做了什么;(2) TCP Server 的创建过程、新连接建立后的处理、与传统 Netty 的差异、新消息从in.receive()开始的执行路径;(3) session 模型。涉及代码主要位于
turms-gateway与turms-server-common两个模块。下文引用形如类名:行号,完整路径见文末「关键文件索引」。
1. 总览:从 main() 到 TCP 端口就绪
整条链路可以拆成三段:应用入口与环境准备 (第 2 节)、Node 生命周期 (第 3 节)、TCP Server 创建与连接处理(第 4 节)。
2. 应用入口与环境准备
2.1 入口类
TurmsGatewayApplication 是 gateway 的入口,继承 BaseTurmsApplication:
java
@Application(nodeType = NodeType.GATEWAY)
@SpringBootApplication(
scanBasePackages = {PackageConst.GATEWAY, PackageConst.SERVER_COMMON},
proxyBeanMethods = false)
public class TurmsGatewayApplication extends BaseTurmsApplication {
public static void main(String[] args) {
bootstrap(TurmsGatewayApplication.class, args);
}
}
两个关键点:
@Application(nodeType = NodeType.GATEWAY)是 turms 自定义注解,仅声明NodeType nodeType()(Application.java:32)。它本身不做任何事,由后文的监听器读取,用来在 Spring 上下文刷新之前就把Node.nodeType静态字段设为GATEWAY。@SpringBootApplication扫描GATEWAY与SERVER_COMMON两个包,proxyBeanMethods = false关闭 CGLIB 代理以减少启动开销。
2.2 BaseTurmsApplication 的 static 块与 bootstrap
BaseTurmsApplication 用一个静态初始化块硬编码若干 JVM/系统级属性,确保它们在任何 Spring 逻辑之前生效:
java
static {
TimeZone.setDefault(TimeZoneConst.ZONE); // 统一默认时区
System.setProperty("io.netty.maxDirectMemory", "0"); // 见下文
System.setProperty("spring.main.banner-mode", "off");
System.setProperty("spring.main.web-application-type", "none"); // 不启动 Web 容器
}
io.netty.maxDirectMemory=0 的含义:关闭 Netty 自带的直接内存上限计数器,从而可以通过 BufferPoolMXBean 直接观测直接内存占用------turms 大量使用堆外缓冲,这一项是可观测性的前提。
bootstrap() 做两件事(BaseTurmsApplication.java:52):
validateEnv():通过触发CollectionUtil/ClassUtil/StringUtil的静态初始化来校验 JVM 兼容性,不兼容则抛IncompatibleJvmException。SpringApplication.run(applicationClass, args):进入 Spring Boot 启动流程。失败时刷新日志并System.exit(1)(部分非守护线程会阻止 JVM 退出,故显式退出)。
2.3 nodeType 是如何在 Node 构造之前被设置的?
Node 在构造时会断言 nodeType != null(Node.java:104),否则抛异常。但 Node Bean 在上下文刷新时才创建,而日志等组件更早就需要 nodeType。turms 的做法是用一个监听 ApplicationEnvironmentPreparedEvent 的监听器------该事件在环境准备阶段触发,早于上下文刷新:
java
// ApplicationEnvironmentEventListener.java:60
public void onApplicationEvent(ApplicationEnvironmentPreparedEvent event) {
Class<?> mainApplicationClass = event.getSpringApplication().getMainApplicationClass();
String nodeId = Node.initNodeId(env.getProperty(TURMS_CLUSTER_NODE_ID, String.class));
NodeType nodeType = findNodeType(event, mainApplicationClass); // 读 @Application
Node.nodeType = nodeType; // 静态字段,全局可见
configureContextForLogging(env, nodeId, nodeType);
}
findNodeType 反射读取主类上的 @Application 注解拿到 nodeType(ApplicationEnvironmentEventListener.java:78),并赋值给 Node.nodeType 这个静态字段 。用 static 的原因有二(见 Node.java:67-75 注释):日志初始化需要 nodeType、且要避免 logger 与 Node 实例使用不同的 nodeId。
3. Node 的生命周期
3.1 Node 是什么
Node(turms-server-common/.../infra/cluster/node/Node.java:63)是每个服务节点的生命周期根 。它的类注释写道:本地节点的生命周期与本地 TCP/UDP/HTTP/WebSocket 服务器「大致相同」。它本身不处理业务,而是组合 一组 ClusterService,统一管理它们的 lazyInit → start → stop。
所有集群服务都实现 ClusterService 接口,三者生命周期方法为:
lazyInit(...):注入对其它服务的引用(不真正启动),用于在构造期建立相互引用、规避循环初始化。start():真正启动(绑定端口、连 MongoDB、开始心跳等)。stop(timeoutMillis):返回Mono<Void>,异步停止。
3.2 Node 的创建:依赖与构造过程
Bean 装配
Node 由 ClusterConfig 装配为 Spring Bean(ClusterConfig.java:38):
java
@Configuration
@DependsOn(IMongoCollectionInitializer.BEAN_NAME)
public class ClusterConfig {
@Bean
public Node node(ApplicationContext context,
TurmsApplicationContext turmsContext,
TurmsPropertiesManager propertiesManager,
BaseServiceAddressManager serviceAddressManager,
HealthCheckManager healthCheckManager) {
Node node = new Node(context, turmsContext, propertiesManager, serviceAddressManager, healthCheckManager);
turmsContext.addShutdownHook(JobShutdownOrder.CLOSE_NODE, node::stop); // 先注册
node.start(); // 再启动
return node;
}
}
要点:
@DependsOn(IMongoCollectionInitializer):确保 MongoDB 集合先初始化完成(建集合、建索引、分片),再创建 Node。集群服务(discovery、sharedConfig)依赖这些集合。- 构造参数即依赖 :
Node的 5 个构造参数就是它需要的全部外部依赖------ApplicationContext、TurmsApplicationContext(turms 自定义上下文,提供构建信息与关闭钩子)、TurmsPropertiesManager(属性树 + 变更监听)、BaseServiceAddressManager(本节点对外地址)、HealthCheckManager(健康状态)。 - 关闭钩子在
start()之前注册 (ClusterConfig.java:53-56注释):Node 可能启动失败抛异常,此时仍需执行关闭逻辑(如从集群注销成员),故先注册钩子再启动。
构造体做了什么
Node 构造函数(Node.java:98-185)按顺序:
- 断言 nodeType 已设置(第 104 行)------由前述监听器保证。
- 读取集群属性 :从
propertiesManager.getLocalProperties().getCluster()取出SharedConfigProperties、NodeProperties、ConnectionProperties、DiscoveryProperties、RpcProperties等。 - 解析节点版本 :
NodeVersion.parse(turmsContext.getBuildProperties().version())。 - 初始化 nodeId :
initNodeId(nodeProperties.getId())(Node.java:226)。若未配置则随机生成 8 位小写字母并告警(生产环境建议显式配置);校验长度与字符集(^[a-zA-Z_]\w*$)。 - 解析 zone / name:zone 为空则置空串(用作雪花 ID 的数据中心 ID);name 为空则用 nodeId。
- 逐一构造 7 个 ClusterService :注释明确写道「逐个传属性而不是传 Node 实例,是为了显式表达依赖关系」(
Node.java:145-146)。 - lazyInit :把全部 7 个服务塞进列表,对每个调用
service.lazyInit(codecService, connectionService, discoveryService, idService, rpcService, sharedConfigService),让它们彼此持有引用。
七个集群服务
| 服务 | 构造关键参数 | 职责 |
|---|---|---|
CodecService |
无 | 节点间 RPC 负载的编解码(自定义紧凑编码,非 protobuf) |
ConnectionService |
connectionProperties |
节点间 TCP RPC 服务器(接受其它节点的 RPC 连接) |
RpcService |
context, nodeType, rpcProperties |
节点间 request/response 与 fire-and-forget RPC |
SharedConfigService |
sharedConfigProperties.getMongo() |
集群共享配置(MongoDB member/leader/sharedClusterProperties 集合) |
DiscoveryService |
clusterId, nodeId, zone, name, nodeType, leaderEligible, priority, ... | 成员注册与 Leader 选举(基于 MongoDB + change stream) |
SharedPropertyService |
clusterId, nodeType, propertiesManager |
集群级属性的热更新推送(无需重启) |
IdService |
discoveryService |
雪花式集群感知 ID 生成(nextIncreasingId / nextLargeGapId) |
注意 gateway 节点 nodeType == GATEWAY,构造 DiscoveryService 时 leaderEligible 实参为 nodeType == NodeType.SERVICE && nodeProperties.isLeaderEligible()(Node.java:158)------gateway 不参与选主 ,只有 leaderEligible=true 的 SERVICE 节点才竞争 Leader。
lazyInit 的意义
服务之间存在相互引用(如 IdService 需要 DiscoveryService 的本地成员索引作 workerId;DiscoveryService 需要 ConnectionService 的端口上报地址)。若在各自构造函数里直接 new 对方会形成循环。turms 的做法:构造阶段只传「属性」,lazyInit 阶段统一注入「服务引用」,把依赖关系摊开在明面上。
3.3 start():启动顺序
java
public void start() {
sharedConfigService.start(); // 1. 连 MongoDB,准备共享配置
sharedPropertyService.start(); // 2. 拉取集群属性并注册变更监听
discoveryService.start(); // 3. 注册本节点成员、开始选主/心跳
codecService.start(); // 4. RPC 编解码就绪
connectionService.start(); // 5. 启动节点间 TCP RPC 服务器
rpcService.start(); // 6. RPC 调度就绪
idService.start(); // 7. 雪花 ID 生成器就绪(依赖 discovery 的成员索引)
}
顺序体现了依赖:discovery 需要 sharedConfig 的集合;idService 需要 discovery 的 localMemberIndex 作 workerId;connectionService 在 RPC 之前启动以便节点间可互连。
3.4 stop():停止顺序
java
public Mono<Void> stop(long timeoutMillis) {
List<ClusterService> services = List.of(
discoveryService, // 必须最先停:从共享配置注销本成员
sharedConfigService,
sharedPropertyService,
codecService,
rpcService,
idService,
connectionService); // 最后停连接
...
return Mono.zipDelayError(monos, Function.identity()).then();
}
要点:
- discovery 最先停 :注释说明它需要先在共享配置里注销本地成员信息,否则其它节点会以为它还在线(
Node.java:199-201)。 Mono.zipDelayError:任一服务停止出错不会中断其它服务的停止,尽可能清理干净。- 关闭钩子(
CLOSE_NODE)只是众多关闭任务之一,整体关闭顺序由JobShutdownOrder编排:先停止接收新连接、等待在途请求、关闭 session、关 TCP/UDP/WS 服务器,最后CLOSE_NODE,再关 Redis/Mongo。
4. TCP Server 的创建与连接处理
4.1 装配器:TcpUserSessionAssembler
TcpUserSessionAssembler(@Component,继承 UserSessionAssembler)是 TCP 服务器的装配入口。它在 Spring 上下文刷新时被构造,构造函数里完成服务器的创建(TcpUserSessionAssembler.java:58):
java
public TcpUserSessionAssembler(...) {
super(apiLoggingContext, clientRequestDispatcher, sessionService,
establishTimeoutMillis, closeTimeoutMillis); // 传给父类
GatewayProperties gatewayProperties = propertiesManager.getLocalProperties().getGateway();
TcpProperties tcpProperties = gatewayProperties.getTcp();
enabled = tcpProperties.isEnabled();
if (enabled) {
server = TcpServerFactory.create(
tcpProperties, blocklistService, serverStatusManager, sessionService,
bindConnectionWithSessionWrapper(), // ← 连接监听器
maxRequestSizeBytes);
host = server.host();
port = server.port();
applicationContext.addShutdownHook(JobShutdownOrder.CLOSE_GATEWAY_TCP_SERVER,
timeoutMillis -> { server.dispose(); return server.onDispose(); });
} else {
server = null; host = null; port = -1;
}
}
几个关键点:
bindConnectionWithSessionWrapper()是父类UserSessionAssembler提供的方法,返回一个ConnectionListener(函数式接口,onAdded(connection, remoteAddress, in, out, onClose))。它把「网络连接」与「session」绑定起来的逻辑写在这里,TCP / WebSocket 共用,只是createConnection()返回不同的NetConnection子类。- 仅当
gateway.tcp.enabled=true才创建服务器,否则置空。 - 注册
CLOSE_GATEWAY_TCP_SERVER关闭钩子(server.dispose())。 createConnection()覆写返回new TcpConnection((ChannelOperations<?,?>) connection, true, closeTimeout)。
4.2 TcpServerFactory.create:构建 reactor-netty TcpServer
TcpServerFactory.create()(TcpServerFactory.java:60)用 reactor-netty 而非裸 Netty 构建 TCP 服务器。核心结构:
java
TcpServer server = TcpServer.create()
.host(host).port(port)
.option(CONNECT_TIMEOUT_MILLIS, tcpProperties.getConnectTimeoutMillis())
.option(SO_REUSEADDR, true)
.option(SO_BACKLOG, tcpProperties.getBacklog())
.childOption(SO_REUSEADDR, true)
.childOption(SO_LINGER, 0)
.childOption(TCP_NODELAY, true)
.wiretap(...)
.runOn(LoopResourcesFactory.createForServer(ThreadNameConst.GATEWAY_TCP_PREFIX)) // 专属事件循环
.metrics(true, () -> new TurmsMicrometerChannelMetricsRecorder(...))
.doOnChannelInit((observer, channel, remoteAddress) -> { /* 建 pipeline */ })
.handle((in, out) -> { /* 连接级处理 */ });
// 可选 SSL
if (ssl.isEnabled()) { server = server.secure(...); }
return server.bind().block();
Socket 选项 (注释提到刻意不设 SO_SNDBUF/SO_RCVBUF,理由见阿里云那篇文章):
| 选项 | 值 | 说明 |
|---|---|---|
SO_REUSEADDR |
true | 地址复用,快速重启 |
SO_BACKLOG |
配置 | 全连接队列长度 |
SO_LINGER (child) |
0 | 关闭时直接 RST,不走 TIME_WAIT |
TCP_NODELAY (child) |
true | 关闭 Nagle,降低小包延迟(IM 场景关键) |
CONNECT_TIMEOUT_MILLIS |
配置 | 连接超时 |
专属事件循环 :runOn(...GATEWAY_TCP_PREFIX) 为 TCP 服务器分配独立的 LoopResources(独立线程组),与 UDP/WebSocket/节点间 RPC 的线程隔离,避免相互影响。
doOnChannelInit:构建 ChannelPipeline
doOnChannelInit 在每个新连接初始化 Channel 时 调用(在 channelActive 之前),用于装配 pipeline(TcpServerFactory.java:95):
java
.doOnChannelInit((connectionObserver, channel, remoteAddress) -> {
ChannelPipeline pipeline = channel.pipeline();
// 1. 最先加入:服务可用性拦截器(自定义 ChannelInboundHandler)
pipeline.addFirst("serviceAvailabilityHandler", serviceAvailabilityHandler);
// 2. 入站:varint 长度帧解码器(在 reactor-netty 的 ReactiveBridge 之前)
pipeline.addBefore(NettyPipeline.ReactiveBridge,
"varintLengthBasedFrameDecoder",
CodecFactory.getExtendedVarintLengthBasedFrameDecoder(maxFrameLength));
// 3. PROXY 协议处理(HAProxy,取真实客户端 IP)------ REQUIRED / OPTIONAL / DISABLED
if (REQUIRED == proxyProtocolMode) { HAProxyUtil.addProxyProtocolHandlers(...); }
else if (OPTIONAL == proxyProtocolMode) { HAProxyUtil.addProxyProtocolDetectorHandler(...); }
else { remoteAddressSink.tryEmitValue((InetSocketAddress) channel.remoteAddress()); }
// 4. 出站:varint 长度字段前置器
pipeline.addLast("varintLengthFieldPrepender", CodecFactory.getVarintLengthFieldPrepender());
// 5. 出站:protobuf 帧编码器(把 TurmsNotification 编码为 ByteBuf)
pipeline.addLast("protobufFrameEncoder", CodecFactory.getProtobufFrameEncoder());
})
serviceAvailabilityHandler 是 pipeline 中唯一 传统的 ChannelInboundHandler,放在最前面,用于在节点不健康或 IP 被拉黑时尽早拒绝连接 。remoteAddressSink 是一个 Sinks.One<InetSocketAddress>,用来把「真实客户端地址」(可能来自 PROXY 协议,也可能直接取 channel.remoteAddress())异步传递给后面的 handle。
handle:连接级处理
java
.handle((in, out) -> {
Connection connection = (Connection) in;
// 必须手动触发首次读:reactor-netty 在我们拿到 peer 地址前不会订阅入站流
connection.channel().config().setAutoRead(true);
return remoteAddressSink.asMono()
.flatMap(remoteAddress -> connectionListener.onAdded(
connection, remoteAddress,
in.receive(), // ← Flux<ByteBuf>,新消息入口
out,
connection.onDispose()));
})
in实际是 reactor-netty 的ChannelOperations(同时实现NettyInbound/NettyOutbound/Connection),故可强转为Connection。setAutoRead(true)必须在此处手动调用 (注释TcpServerFactory.java:137-145解释了三点):reactor-netty 在我们订阅入站流之前不会主动读;而我们订阅in.receive()又依赖先拿到 peer 地址(PROXY 协议解析是异步的)。setAutoRead(true)既置位也触发首次读。不能挪到doOnChannelInit,因为彼时 Channel 尚未就绪。- 拿到
remoteAddress后,调用connectionListener.onAdded(...),把in.receive()(Flux<ByteBuf>)传进去------这就是新消息的起点(详见 4.5)。
最后 server.bind().block() 同步绑定端口,失败抛 BindException。
4.3 新连接 added 之后做什么
connectionListener 即 UserSessionAssembler.bindConnectionWithSessionWrapper()(UserSessionAssembler.java:73)。onAdded 在连接建立后做四件事:
java
return (connection, remoteAddress, in, out, onClose) -> {
// ① 包装为 NetConnection(TCP 这里是 TcpConnection)
NetConnection netConnection = createConnection(connection, closeTimeout);
// ② 创建 UserSessionWrapper,并注册 onSessionEstablished 回调(设置通知消费者)
UserSessionWrapper sessionWrapper = new UserSessionWrapper(
netConnection, remoteAddress, establishTimeoutMillis,
userSession -> userSession.setNotificationConsumer((buf, ctx) -> {
buf = buf.duplicate(); // 独立 readerIndex,零拷贝
return netConnection.send(buf) // 发往客户端
.doOnError(t -> handleConnectionError(t, netConnection, userSession, ctx));
}));
// ③ 订阅入站流,分发请求
respondToRequests(connection, in, sessionWrapper);
// ④ 连接关闭时清理 session 信息
return tryRemoveSessionInfoOnConnectionClosed(onClose, sessionWrapper);
};
createConnection:把 reactor-nettyConnection包成 turms 的NetConnection(TCP 用TcpConnection,WS 用WebSocketConnection)。UserSessionWrapper:把「连接 + 远端地址 + 建立超时定时器 + session 建立回调」绑在一起(详见第 5 节)。此时userSession字段还是null------要等客户端发来CREATE_SESSION_REQUEST且认证通过后,才由SessionClientController调用sessionWrapper.setUserSession(...)真正绑定 session,并触发回调把notificationConsumer设进 session。respondToRequests:订阅in(即in.receive()的Flux<ByteBuf>),逐帧分发请求(详见 4.5)。tryRemoveSessionInfoOnConnectionClosed:连接关闭时,若 session 仍 open 且不是 正在切换到 UDP,则调用sessionService.closeLocalSession(..., UNKNOWN_ERROR)清理本地 session 与 Redis 状态;并用 CAS 锁保证DELETE_SESSION_REQUEST的日志只记一次。
UserSessionWrapper 还会启动一个 HashedWheelTimer 建立超时任务:若在 establishTimeoutMillis 内没有完成 session 建立,就以 LOGIN_TIMEOUT 关闭连接(UserSessionWrapper.java:105)。
4.4 与传统 Netty 的差异
如果你熟悉原生 Netty(ServerBootstrap + ChannelInitializer + SimpleChannelInboundHandler),reactor-netty 的差异主要体现在四点:
| 维度 | 传统 Netty | reactor-netty(turms 用法) |
|---|---|---|
| 业务入口 | 自定义 ChannelInboundHandler.channelRead0(ctx, msg),每条消息触发一次回调 |
.handle((in, out) -> Publisher<Void>),每连接只执行一次,返回的 Publisher 生命周期 = 连接生命周期 |
| 消息处理模型 | 事件回调,同步在 EventLoop 中执行 | 响应式流:in.receive() → Flux<ByteBuf>,用 doOnNext/flatMap 算子处理,自带背压(request(n)) |
| 首次读 | channelActive 后 Netty 默认 autoRead=true 会自动读 |
需手动 setAutoRead(true),因为 reactor-netty 在订阅入站流前不读,而订阅又依赖先拿到 peer 地址(PROXY 异步解析) |
| 与传统 Handler 共存 | 全部逻辑都是 Handler | pipeline 里仍可放传统 ChannelHandler(如 serviceAvailabilityHandler、帧编解码器)做早期拦截/编解码,业务逻辑则走响应式 handle |
换句话说:编解码仍在 Netty pipeline 里 (varintLengthBasedFrameDecoder 入站拆帧、varintLengthFieldPrepender+protobufFrameEncoder 出站封帧),但业务分发不再是 channelRead0 回调,而是 Flux<ByteBuf> 订阅 。pipeline 拆出一帧 ByteBuf,就作为 Flux 的一个 onNext 推给 respondToRequests。
出站侧 protobufFrameEncoder 设计得比较巧妙(TcpServerFactory.java:127-132 注释):高级操作会自己把对象编码成 ByteBuf 再下发,编码器对 ByteBuf 直接放行;而简单操作会直接把 TurmsNotification 实例往下传,由编码器统一编码------两种方式共存。
4.5 新消息从哪里开始执行(in.receive())
新消息的执行路径如下(从 TCP 字节到业务处理):
逐步对照源码:
-
拆帧 :
varintLengthBasedFrameDecoder把字节流按 varint 长度前缀拆成一个个ByteBuf(一个ByteBuf= 一个客户端请求)。 -
in.receive()入口 (TcpServerFactory.java:152):handle把in.receive()的Flux<ByteBuf>传给connectionListener.onAdded(...)。 -
订阅 (
UserSessionAssembler.respondToRequests,UserSessionAssembler.java:104):javain.doOnNext(requestData -> { if (connection.isDisposed()) return; UserSession userSession = sessionWrapper.getUserSession(); if (userSession != null && !userSession.isSessionOpen()) return; requestData.retain(); // 引用计数 +1(防 FluxReceive.drainReceiver 提前释放) TracingContext ctx = new TracingContext(); clientRequestDispatcher.handleRequest(sessionWrapper, requestData) .flatMap(buffer -> netConnection.send(buffer.duplicate())) // 回写 .contextWrite(c -> c.put(TracingContext.CTX_KEY_NAME, ctx)) .doFinally(signal -> ctx.clearThreadContext()) .subscribe(...); }).then().subscribe(...);每个
ByteBuf触发一次doOnNext:先retain()(因为FluxReceive在doOnNext返回后会release一次,业务侧还要异步处理,需保留),再交给ClientRequestDispatcher.handleRequest。返回的响应ByteBuf经duplicate()后由netConnection.send(...)回写。 -
分发 (
ClientRequestDispatcher.handleRequest0,ClientRequestDispatcher.java:153):- 不可读 = 心跳 :
if (!serviceRequestBuffer.isReadable())走handleHeartbeatRequest,更新 session 心跳时间戳,返回EMPTY_BUFFER(ClientRequestDispatcher.java:157、324)。这是 turms 的心跳约定:空包即心跳。 - 可读 = 业务请求 :
TurmsRequestParser.parseSimpleRequest解析为SimpleTurmsRequest;做权限校验(session.hasPermission)、DELETE_SESSION日志去重锁;进入handleServiceRequest(ClientRequestDispatcher.java:270)。
- 不可读 = 心跳 :
-
路由 (
handleServiceRequest的switch,ClientRequestDispatcher.java:305):javareturn switch (requestType) { case CREATE_SESSION_REQUEST -> sessionController.handleCreateSessionRequest(...); case DELETE_SESSION_REQUEST -> sessionController.handleDeleteSessionRequest(...); default -> { serviceRequestBuffer.retain(); yield handleServiceRequest(sessionWrapper, request, serviceRequestBuffer); } };CREATE_SESSION_REQUEST/DELETE_SESSION_REQUEST由 gateway 本地 处理(SessionClientController),不下发 service。- 其它请求包装成
ServiceRequest,由ServiceRequestService通过 RPC 转发给某个 turms-service 节点处理,响应再回传给客户端。
-
响应编码 :最终
TurmsNotification经ProtoEncoder.getDirectByteBuffer(notification)编码为堆外ByteBuf(ClientRequestDispatcher.java:258),回到第 3 步的flatMap被回写。
小结:新消息的「业务起点」就是
in.receive()产出的Flux<ByteBuf>;它在respondToRequests中被订阅,每一帧经retain后交给ClientRequestDispatcher.handleRequest,按请求类型分流到「网关本地 session 处理」或「RPC 转发 service」。
5. Session 模型
turms 的 session 模型由三层抽象构成,解耦了「传输」「会话」「绑定」:
5.1 NetConnection:传输抽象
NetConnection(NetConnection.java:40)是对底层连接的抽象,屏蔽 TCP / WebSocket 的差异。关键字段:
| 字段 | 类型 | 含义 |
|---|---|---|
udpAddress |
InetSocketAddress (nullable) |
客户端 UDP 地址(降级到 UDP 后记录,用于唤醒) |
isConnected |
volatile boolean | 当前连接是否可用 |
isSwitchingToUdp |
volatile boolean | 是否正在从 TCP/WS 切到 UDP |
isConnectionRecovering |
volatile boolean | 是否正在从 UDP 恢复回 TCP/WS |
关键方法:
send(ByteBuf):抽象,子类实现下发(TcpConnection调connection.sendObject(buffer);WebSocketConnection包成BinaryWebSocketFrame)。close(CloseReason):置isConnected=false;若关闭状态码是SWITCH则置isSwitchingToUdp=true(NetConnection.java:62)。switchToUdp():等价于close(SWITCH),触发降级。tryNotifyClientToRecover():若连接已断、未在恢复中、且记录过 UDP 地址,则通过UdpRequestDispatcher发一个OPEN_CONNECTIONUDP 信号唤醒客户端重建 TCP/WS(NetConnection.java:79)------这是「从 UDP 升级回 TCP」的触发点(详见模块九)。
子类 TcpConnection / WebSocketConnection 仅实现 send / getAddress 与各自的关闭帧逻辑。
5.2 UserSession:会话
UserSession(UserSession.java:51)是每个设备一个的会话对象。字段:
| 字段 | 含义 |
|---|---|
id |
随机正整数(RandomUtil.nextPositiveInt()),用作 UDP 的 session 标识与日志 |
version |
协议版本(当前必须为 1) |
permissions |
Set<TurmsRequest.KindCase>,本 session 允许发送的请求类型 |
userId / deviceType |
用户与设备类型 |
deviceDetails |
Map<String,String>,如 deviceToken/registrationId,主要用于推送与统计 |
loginDate / loginLocation |
登录时间与位置 |
notificationConsumer |
BiFunction<ByteBuf, TracingContext, Mono<Void>>------推送函数,见下 |
lastHeartbeatRequestTimestamp* |
心跳时间戳(毫秒/纳秒) |
lastRequestTimestamp* |
非心跳请求时间戳(用于判断空闲、触发 UDP 降级) |
isSessionOpen |
volatile,session 是否打开 |
isDeleteSessionLockAcquired |
CAS 字段,保证 DeleteSessionRequest 日志只记一次 |
connection / ip |
当前连接与客户端 IP(均可空) |
两个设计要点:
-
notificationConsumer用ByteBuf而非TurmsNotification(UserSession.java:72-82注释给出四点理由):- 关闭 SSL 时可零拷贝转发,无需解析;
- 同一个
ByteBuf可发给多个客户端而不必复制; - 把业务逻辑与 turms-service 解耦(gateway 不需要理解通知内容);
- (未来)UDP 连接的
ByteBuf可能不是TurmsNotification。 这个 consumer 在UserSessionAssembler.bindConnectionWithSessionWrapper()的onSessionEstablished回调里被设置:duplicate()后交给netConnection.send()。
-
isSessionOpen与isConnected是两个概念 (UserSession.java:98-105注释):session 可以在连接关闭后仍然 open------因为客户端可以继续用 UDP 心跳保活。这是 UDP 降级/升级能成立的前提。
关键方法:
close(CloseReason)(UserSession.java:160):置isSessionOpen=false并关闭连接,返回是否曾经在线。session 一旦关闭不能重开,但连接可以断开重连。isOpen()vsisConnected()(:175、:179):前者看 session 标志,后者看连接;open 但 disconnected 即 UDP 保活态。supportsSwitchingToUdp()(:183):deviceType != BROWSER------浏览器永不降级到 UDP。sendNotification(ByteBuf, TracingContext)(:192):直接调用notificationConsumer.apply(...),把推送ByteBuf发往客户端。
5.3 UserSessionWrapper:绑定层
UserSessionWrapper(UserSessionWrapper.java:43)从 access 层视角把「连接」与「session」绑在一起,并在两者间传递信息。字段:
address(InetSocketAddress):远端地址。establishTimeoutTask(HashedWheelTimer的Timeout):建立超时任务。若在establishTimeoutMillis内未完成 session 建立,以LOGIN_TIMEOUT关闭连接(UserSessionWrapper.java:105)。用一个共享的HashedWheelTimer(GATEWAY_SESSION_ESTABLISH_TIMEOUT_TIMER线程)调度,适合大量短超时任务。onSessionEstablished(Consumer<UserSession>):session 建立回调,设置notificationConsumer。connection/userSession(nullable):连接与(初始为空的)session。ip/ipStr:用ByteArrayWrapper包装的 IP(便于做 Map key),惰性计算。
核心方法 setUserSession(UserSession)(UserSessionWrapper.java:95):把 session 绑到 wrapper、把 connection + ip 绑到 session,再触发 onSessionEstablished 回调------这一步把「推送通道」接通。
5.4 与存储层的关系(预告模块二)
UserSession 对象(含活跃连接)只存在于拥有该连接的 gateway 节点内存中 ,不会被序列化。本节点用 SessionService 的 ConcurrentHashMap<Long, UserSessionsManager> userIdToSessionsManager 做本地索引(一个用户可有多个设备类型各一个 session)。
而 session 的状态与位置 (用户在线否、在哪个节点、哪个设备)会镜像到 Redis (UserStatusService),供集群任意节点查询「某用户是否在线、在哪个 gateway」。session 的创建(认证、并发登录冲突解决、Redis 注册)、心跳保活(HeartbeatManager)、销毁(先删 Redis 再关连接)等完整生命周期,将在模块二展开。
6. 关键文件索引
| 关注点 | 文件 |
|---|---|
| 入口 / 装配 | turms-gateway/src/main/java/im/turms/gateway/TurmsGatewayApplication.java |
| 基类 / bootstrap | turms-server-common/src/main/java/im/turms/server/common/BaseTurmsApplication.java |
| nodeType 静态设置 | turms-server-common/src/main/java/im/turms/server/common/infra/application/ApplicationEnvironmentEventListener.java |
@Application 注解 |
turms-server-common/src/main/java/im/turms/server/common/infra/application/Application.java |
| Node Bean 装配 | turms-server-common/src/main/java/im/turms/server/common/infra/cluster/ClusterConfig.java |
| Node 生命周期 | turms-server-common/src/main/java/im/turms/server/common/infra/cluster/node/Node.java |
| TCP 装配器 | turms-gateway/src/main/java/im/turms/gateway/access/client/tcp/TcpUserSessionAssembler.java |
| TCP 服务器构建 | turms-gateway/src/main/java/im/turms/gateway/access/client/tcp/TcpServerFactory.java |
| 连接↔session 绑定 | turms-gateway/src/main/java/im/turms/gateway/access/client/common/UserSessionAssembler.java |
| ConnectionListener | turms-gateway/src/main/java/im/turms/gateway/access/client/common/connection/ConnectionListener.java |
| 请求分发 | turms-gateway/src/main/java/im/turms/gateway/access/client/common/ClientRequestDispatcher.java |
| Session 模型 | turms-gateway/src/main/java/im/turms/gateway/access/client/common/UserSession.java |
| Wrapper | turms-gateway/src/main/java/im/turms/gateway/access/client/common/UserSessionWrapper.java |
| 连接抽象 | turms-gateway/src/main/java/im/turms/gateway/access/client/common/connection/NetConnection.java |
下一模块:02-session-lifecycle.md Session 会话的结构、存储与生命周期(创建认证 / 心跳保活 / 销毁 / Redis 状态镜像 / 并发登录冲突)。