tumrs简要流程分析(一)—Gateway 启动流程

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-gatewayturms-server-common 两个模块。下文引用形如 类名:行号,完整路径见文末「关键文件索引」。


1. 总览:从 main() 到 TCP 端口就绪

sequenceDiagram participant Main as TurmsGatewayApplication participant Base as BaseTurmsApplication participant Spring as SpringApplication participant Listener as ApplicationEnvironmentEventListener participant CC as ClusterConfig participant Node as Node participant Asm as TcpUserSessionAssembler Main->>Base: main(args) / bootstrap() Base->>Base: static{} 设 TimeZone / maxDirectMemory=0 / web-type=none Base->>Base: validateEnv()(触发 JVM 校验) Base->>Spring: SpringApplication.run() Note over Spring: ① ApplicationEnvironmentPreparedEvent Spring->>Listener: onApplicationEvent Listener->>Listener: 读 @Application → Node.nodeType = GATEWAY Listener->>Listener: initNodeId / 配置日志 Note over Spring: ② refresh context(依赖 MongoCollectionInitializer) Spring->>CC: 创建 node() Bean CC->>Node: new Node(context, turmsContext, propertiesManager, addressMgr, healthCheck) Node->>Node: 构造 7 个 ClusterService + lazyInit CC->>CC: addShutdownHook(CLOSE_NODE, node::stop)(先注册再 start) CC->>Node: node.start()(sharedConfig → ... → id) Spring->>Asm: 构造 @Component TcpUserSessionAssembler Asm->>Asm: TcpServerFactory.create(..., bindConnectionWithSessionWrapper()) Asm->>Asm: bind().block() ------ 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 扫描 GATEWAYSERVER_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):

  1. validateEnv():通过触发 CollectionUtil / ClassUtil / StringUtil 的静态初始化来校验 JVM 兼容性,不兼容则抛 IncompatibleJvmException
  2. SpringApplication.run(applicationClass, args):进入 Spring Boot 启动流程。失败时刷新日志并 System.exit(1)(部分非守护线程会阻止 JVM 退出,故显式退出)。

2.3 nodeType 是如何在 Node 构造之前被设置的?

Node 在构造时会断言 nodeType != nullNode.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 注解拿到 nodeTypeApplicationEnvironmentEventListener.java:78),并赋值给 Node.nodeType 这个静态字段 。用 static 的原因有二(见 Node.java:67-75 注释):日志初始化需要 nodeType、且要避免 logger 与 Node 实例使用不同的 nodeId。


3. Node 的生命周期

3.1 Node 是什么

Nodeturms-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 装配

NodeClusterConfig 装配为 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 个构造参数就是它需要的全部外部依赖------ApplicationContextTurmsApplicationContext(turms 自定义上下文,提供构建信息与关闭钩子)、TurmsPropertiesManager(属性树 + 变更监听)、BaseServiceAddressManager(本节点对外地址)、HealthCheckManager(健康状态)。
  • 关闭钩子在 start() 之前注册ClusterConfig.java:53-56 注释):Node 可能启动失败抛异常,此时仍需执行关闭逻辑(如从集群注销成员),故先注册钩子再启动。
构造体做了什么

Node 构造函数(Node.java:98-185)按顺序:

  1. 断言 nodeType 已设置(第 104 行)------由前述监听器保证。
  2. 读取集群属性 :从 propertiesManager.getLocalProperties().getCluster() 取出 SharedConfigPropertiesNodePropertiesConnectionPropertiesDiscoveryPropertiesRpcProperties 等。
  3. 解析节点版本NodeVersion.parse(turmsContext.getBuildProperties().version())
  4. 初始化 nodeIdinitNodeId(nodeProperties.getId())Node.java:226)。若未配置则随机生成 8 位小写字母并告警(生产环境建议显式配置);校验长度与字符集(^[a-zA-Z_]\w*$)。
  5. 解析 zone / name:zone 为空则置空串(用作雪花 ID 的数据中心 ID);name 为空则用 nodeId。
  6. 逐一构造 7 个 ClusterService :注释明确写道「逐个传属性而不是传 Node 实例,是为了显式表达依赖关系」(Node.java:145-146)。
  7. 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,构造 DiscoveryServiceleaderEligible 实参为 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 需要 discoverylocalMemberIndex 作 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 之后做什么

connectionListenerUserSessionAssembler.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);
};
  1. createConnection :把 reactor-netty Connection 包成 turms 的 NetConnection(TCP 用 TcpConnection,WS 用 WebSocketConnection)。
  2. UserSessionWrapper :把「连接 + 远端地址 + 建立超时定时器 + session 建立回调」绑在一起(详见第 5 节)。此时 userSession 字段还是 null------要等客户端发来 CREATE_SESSION_REQUEST 且认证通过后,才由 SessionClientController 调用 sessionWrapper.setUserSession(...) 真正绑定 session,并触发回调把 notificationConsumer 设进 session。
  3. respondToRequests :订阅 in(即 in.receive()Flux<ByteBuf>),逐帧分发请求(详见 4.5)。
  4. 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 字节到业务处理):

flowchart TD A[&#34;客户端 TCP 字节流&#34;] --> B[&#34;varintLengthBasedFrameDecoder<br/>拆出一帧 = 一个 ByteBuf&#34;] B --> C[&#34;TcpServerFactory.handle()<br/>in.receive() 返回 Flux&#34;] C --> D[&#34;UserSessionAssembler.respondToRequests()<br/>订阅 in(每连接一次)&#34;] D --> E[&#34;doOnNext: requestData.retain()<br/>(防 FluxReceive 提前释放)&#34;] E --> F[&#34;ClientRequestDispatcher.handleRequest(sessionWrapper, buffer)&#34;] F --> G{buffer 可读?} G -->|不可读| H[&#34;心跳:更新 session 心跳时间戳<br/>返回 EMPTY_BUFFER&#34;] G -->|可读| I[&#34;TurmsRequestParser.parseSimpleRequest<br/>解析 SimpleTurmsRequest&#34;] I --> J{requestType} J -->|CREATE_SESSION_REQUEST| K[&#34;SessionClientController<br/>handleCreateSessionRequest(网关本地)&#34;] J -->|DELETE_SESSION_REQUEST| L[&#34;SessionClientController<br/>handleDeleteSessionRequest(网关本地)&#34;] J -->|其它业务请求| M[&#34;ServiceRequestService<br/>→ RPC 转发 turms-service&#34;] K --> N[&#34;编码 TurmsNotification → ByteBuf&#34;] L --> N M --> N H --> O[&#34;netConnection.send(buffer.duplicate())<br/>回写客户端&#34;] N --> O

逐步对照源码:

  1. 拆帧varintLengthBasedFrameDecoder 把字节流按 varint 长度前缀拆成一个个 ByteBuf(一个 ByteBuf = 一个客户端请求)。

  2. in.receive() 入口TcpServerFactory.java:152):handlein.receive()Flux<ByteBuf> 传给 connectionListener.onAdded(...)

  3. 订阅UserSessionAssembler.respondToRequestsUserSessionAssembler.java:104):

    java 复制代码
    in.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()(因为 FluxReceivedoOnNext 返回后会 release 一次,业务侧还要异步处理,需保留),再交给 ClientRequestDispatcher.handleRequest。返回的响应 ByteBufduplicate() 后由 netConnection.send(...) 回写。

  4. 分发ClientRequestDispatcher.handleRequest0ClientRequestDispatcher.java:153):

    • 不可读 = 心跳if (!serviceRequestBuffer.isReadable())handleHeartbeatRequest,更新 session 心跳时间戳,返回 EMPTY_BUFFERClientRequestDispatcher.java:157324)。这是 turms 的心跳约定:空包即心跳。
    • 可读 = 业务请求TurmsRequestParser.parseSimpleRequest 解析为 SimpleTurmsRequest;做权限校验(session.hasPermission)、DELETE_SESSION 日志去重锁;进入 handleServiceRequestClientRequestDispatcher.java:270)。
  5. 路由handleServiceRequestswitchClientRequestDispatcher.java:305):

    java 复制代码
    return 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_REQUESTgateway 本地 处理(SessionClientController),不下发 service。
    • 其它请求包装成 ServiceRequest,由 ServiceRequestService 通过 RPC 转发给某个 turms-service 节点处理,响应再回传给客户端。
  6. 响应编码 :最终 TurmsNotificationProtoEncoder.getDirectByteBuffer(notification) 编码为堆外 ByteBufClientRequestDispatcher.java:258),回到第 3 步的 flatMap 被回写。

小结:新消息的「业务起点」就是 in.receive() 产出的 Flux<ByteBuf>;它在 respondToRequests 中被订阅,每一帧经 retain 后交给 ClientRequestDispatcher.handleRequest,按请求类型分流到「网关本地 session 处理」或「RPC 转发 service」。


5. Session 模型

turms 的 session 模型由三层抽象构成,解耦了「传输」「会话」「绑定」:

flowchart LR subgraph 传输层 NC[NetConnection<br/>抽象连接] TC[TcpConnection] WC[WebSocketConnection] end subgraph 会话层 US[UserSession<br/>每设备一个会话] end subgraph 绑定层 SW[UserSessionWrapper<br/>连接+会话绑定] end TC -.继承.-> NC WC -.继承.-> NC SW --> NC SW --> US US --> NC

5.1 NetConnection:传输抽象

NetConnectionNetConnection.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):抽象,子类实现下发(TcpConnectionconnection.sendObject(buffer)WebSocketConnection 包成 BinaryWebSocketFrame)。
  • close(CloseReason):置 isConnected=false;若关闭状态码是 SWITCH 则置 isSwitchingToUdp=trueNetConnection.java:62)。
  • switchToUdp():等价于 close(SWITCH),触发降级。
  • tryNotifyClientToRecover():若连接已断、未在恢复中、且记录过 UDP 地址,则通过 UdpRequestDispatcher 发一个 OPEN_CONNECTION UDP 信号唤醒客户端重建 TCP/WS(NetConnection.java:79)------这是「从 UDP 升级回 TCP」的触发点(详见模块九)。

子类 TcpConnection / WebSocketConnection 仅实现 send / getAddress 与各自的关闭帧逻辑。

5.2 UserSession:会话

UserSessionUserSession.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(均可空)

两个设计要点:

  1. notificationConsumerByteBuf 而非 TurmsNotificationUserSession.java:72-82 注释给出四点理由):

    • 关闭 SSL 时可零拷贝转发,无需解析;
    • 同一个 ByteBuf 可发给多个客户端而不必复制;
    • 把业务逻辑与 turms-service 解耦(gateway 不需要理解通知内容);
    • (未来)UDP 连接的 ByteBuf 可能不是 TurmsNotification。 这个 consumer 在 UserSessionAssembler.bindConnectionWithSessionWrapper()onSessionEstablished 回调里被设置:duplicate() 后交给 netConnection.send()
  2. isSessionOpenisConnected 是两个概念UserSession.java:98-105 注释):session 可以在连接关闭后仍然 open------因为客户端可以继续用 UDP 心跳保活。这是 UDP 降级/升级能成立的前提。

关键方法:

  • close(CloseReason)UserSession.java:160):置 isSessionOpen=false 并关闭连接,返回是否曾经在线。session 一旦关闭不能重开,但连接可以断开重连
  • isOpen() vs isConnected():175:179):前者看 session 标志,后者看连接;open 但 disconnected 即 UDP 保活态。
  • supportsSwitchingToUdp():183):deviceType != BROWSER------浏览器永不降级到 UDP。
  • sendNotification(ByteBuf, TracingContext):192):直接调用 notificationConsumer.apply(...),把推送 ByteBuf 发往客户端。

5.3 UserSessionWrapper:绑定层

UserSessionWrapperUserSessionWrapper.java:43)从 access 层视角把「连接」与「session」绑在一起,并在两者间传递信息。字段:

  • addressInetSocketAddress):远端地址。
  • establishTimeoutTaskHashedWheelTimerTimeout):建立超时任务。若在 establishTimeoutMillis 内未完成 session 建立,以 LOGIN_TIMEOUT 关闭连接(UserSessionWrapper.java:105)。用一个共享的 HashedWheelTimerGATEWAY_SESSION_ESTABLISH_TIMEOUT_TIMER 线程)调度,适合大量短超时任务。
  • onSessionEstablishedConsumer<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 节点内存中 ,不会被序列化。本节点用 SessionServiceConcurrentHashMap<Long, UserSessionsManager> userIdToSessionsManager 做本地索引(一个用户可有多个设备类型各一个 session)。

而 session 的状态与位置 (用户在线否、在哪个节点、哪个设备)会镜像到 RedisUserStatusService),供集群任意节点查询「某用户是否在线、在哪个 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 状态镜像 / 并发登录冲突)。

相关推荐
ClouGence3 小时前
CloudDM:开源免费!一站式数据库访问、SQL审核、权限与脱敏管控平台
数据库·sql·开源
tedcloud1234 小时前
Kimi-K3 部署指南:大模型应用开发环境搭建实践
linux·运维·服务器·开源·音视频
小小龙学IT5 小时前
Taskflow:用一张“任务图“玩转现代 C++ 并行编程开源项
c++·开源·github
TunerT_TQ5 小时前
Valhalla 静态工程审阅 #023|Qwen3 源码证据驱动评测【大厂开源基础设施特辑】
开源·#agent工程化·#企业智能体·#多智能体系统·#qwen3·#阿里巴巴开源
dogstarhuang5 小时前
Kimi K3 本地部署实战:从 1.56TB 权重到推理服务的完整成本分析
java·人工智能·后端·ai·开源·接口·程序员创富
m4Rk_5 小时前
【论文阅读】Agent 记忆机制(31):MemAgent——通过强化学习让固定长度记忆处理百万级长文本
论文阅读·人工智能·学习·开源·github
DisonTangor17 小时前
【SeeDream开源平替】MiniMax H3 重磅开源:全模态视频生成新标杆,2K 画质 + 原生立体声,15 秒大片一键生成!
人工智能·ai作画·开源·aigc·音视频
冬奇Lab18 小时前
开源项目第177期:Apache Airflow — 用 Python 写出来的工作流调度器,数据工程师的标配工具
人工智能·开源·资讯