代码初始化

This commit is contained in:
2026-05-07 15:54:43 +08:00
parent a078ca46e5
commit 3522f1011f
274 changed files with 15704 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
logs/
+8
View File
@@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<annotationProcessing>
<profile default="true" name="Default" enabled="true" />
<profile name="Maven default annotation processors profile" enabled="true">
<sourceOutputDir name="target/generated-sources/annotations" />
<sourceTestOutputDir name="target/generated-test-sources/test-annotations" />
<outputRelativeToContentRoot value="true" />
<module name="CrossModule" />
<module name="ProtoBufModule" />
<module name="ServerModule" />
<module name="BaseModule" />
</profile>
</annotationProcessing>
</component>
<component name="JavacSettings">
<option name="ADDITIONAL_OPTIONS_OVERRIDE">
<module name="BaseModule" options="-parameters" />
<module name="CrossModule" options="-parameters" />
<module name="ProtoBufModule" options="-parameters" />
<module name="ServerModule" options="-parameters" />
<module name="comm-framework" options="" />
</option>
</component>
</project>
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="test@43.155.238.153" uuid="fb532063-34ed-447d-94fa-f31be2dd310a">
<driver-ref>mongo</driver-ref>
<synchronize>true</synchronize>
<imported>true</imported>
<jdbc-driver>com.dbschema.MongoJdbcDriver</jdbc-driver>
<jdbc-url>mongodb://43.155.238.153:27017/test</jdbc-url>
<jdbc-additional-properties>
<property name="com.intellij.clouds.kubernetes.db.host.port" />
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
<property name="com.intellij.clouds.kubernetes.db.container.port" />
</jdbc-additional-properties>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding">
<file url="file://$PROJECT_DIR$/BaseModule/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/CrossModule/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/ProtoBufModule/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/ServerModule/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/src/main/resources" charset="UTF-8" />
</component>
</project>
+35
View File
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RemoteRepositoriesConfiguration">
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Maven Central" />
<option name="url" value="https://repo.huaweicloud.com/repository/maven/" />
</remote-repository>
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Central Repository" />
<option name="url" value="https://repo.maven.apache.org/maven2" />
</remote-repository>
<remote-repository>
<option name="id" value="spring-milestones" />
<option name="name" value="Spring Milestones" />
<option name="url" value="https://repo.spring.io/milestone" />
</remote-repository>
<remote-repository>
<option name="id" value="aliyun-public" />
<option name="name" value="aliyun-public" />
<option name="url" value="https://maven.aliyun.com/repository/public" />
</remote-repository>
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Maven Central repository" />
<option name="url" value="https://repo1.maven.org/maven2" />
</remote-repository>
<remote-repository>
<option name="id" value="jboss.community" />
<option name="name" value="JBoss Community repository" />
<option name="url" value="https://repository.jboss.org/nexus/content/repositories/public/" />
</remote-repository>
</component>
</project>
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="MavenProjectsManager">
<option name="originalFiles">
<list>
<option value="$PROJECT_DIR$/pom.xml" />
</list>
</option>
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="ms-17" project-jdk-type="JavaSDK" />
</project>
+124
View File
@@ -0,0 +1,124 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Palette2">
<group name="Swing">
<item class="com.intellij.uiDesigner.HSpacer" tooltip-text="Horizontal Spacer" icon="/com/intellij/uiDesigner/icons/hspacer.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="1" hsize-policy="6" anchor="0" fill="1" />
</item>
<item class="com.intellij.uiDesigner.VSpacer" tooltip-text="Vertical Spacer" icon="/com/intellij/uiDesigner/icons/vspacer.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="6" hsize-policy="1" anchor="0" fill="2" />
</item>
<item class="javax.swing.JPanel" icon="/com/intellij/uiDesigner/icons/panel.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="3" hsize-policy="3" anchor="0" fill="3" />
</item>
<item class="javax.swing.JScrollPane" icon="/com/intellij/uiDesigner/icons/scrollPane.svg" removable="false" auto-create-binding="false" can-attach-label="true">
<default-constraints vsize-policy="7" hsize-policy="7" anchor="0" fill="3" />
</item>
<item class="javax.swing.JButton" icon="/com/intellij/uiDesigner/icons/button.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="3" anchor="0" fill="1" />
<initial-values>
<property name="text" value="Button" />
</initial-values>
</item>
<item class="javax.swing.JRadioButton" icon="/com/intellij/uiDesigner/icons/radioButton.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="3" anchor="8" fill="0" />
<initial-values>
<property name="text" value="RadioButton" />
</initial-values>
</item>
<item class="javax.swing.JCheckBox" icon="/com/intellij/uiDesigner/icons/checkBox.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="3" anchor="8" fill="0" />
<initial-values>
<property name="text" value="CheckBox" />
</initial-values>
</item>
<item class="javax.swing.JLabel" icon="/com/intellij/uiDesigner/icons/label.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="0" anchor="8" fill="0" />
<initial-values>
<property name="text" value="Label" />
</initial-values>
</item>
<item class="javax.swing.JTextField" icon="/com/intellij/uiDesigner/icons/textField.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1">
<preferred-size width="150" height="-1" />
</default-constraints>
</item>
<item class="javax.swing.JPasswordField" icon="/com/intellij/uiDesigner/icons/passwordField.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1">
<preferred-size width="150" height="-1" />
</default-constraints>
</item>
<item class="javax.swing.JFormattedTextField" icon="/com/intellij/uiDesigner/icons/formattedTextField.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1">
<preferred-size width="150" height="-1" />
</default-constraints>
</item>
<item class="javax.swing.JTextArea" icon="/com/intellij/uiDesigner/icons/textArea.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
<preferred-size width="150" height="50" />
</default-constraints>
</item>
<item class="javax.swing.JTextPane" icon="/com/intellij/uiDesigner/icons/textPane.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
<preferred-size width="150" height="50" />
</default-constraints>
</item>
<item class="javax.swing.JEditorPane" icon="/com/intellij/uiDesigner/icons/editorPane.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
<preferred-size width="150" height="50" />
</default-constraints>
</item>
<item class="javax.swing.JComboBox" icon="/com/intellij/uiDesigner/icons/comboBox.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="0" hsize-policy="2" anchor="8" fill="1" />
</item>
<item class="javax.swing.JTable" icon="/com/intellij/uiDesigner/icons/table.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
<preferred-size width="150" height="50" />
</default-constraints>
</item>
<item class="javax.swing.JList" icon="/com/intellij/uiDesigner/icons/list.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="6" hsize-policy="2" anchor="0" fill="3">
<preferred-size width="150" height="50" />
</default-constraints>
</item>
<item class="javax.swing.JTree" icon="/com/intellij/uiDesigner/icons/tree.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
<preferred-size width="150" height="50" />
</default-constraints>
</item>
<item class="javax.swing.JTabbedPane" icon="/com/intellij/uiDesigner/icons/tabbedPane.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="3" hsize-policy="3" anchor="0" fill="3">
<preferred-size width="200" height="200" />
</default-constraints>
</item>
<item class="javax.swing.JSplitPane" icon="/com/intellij/uiDesigner/icons/splitPane.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="3" hsize-policy="3" anchor="0" fill="3">
<preferred-size width="200" height="200" />
</default-constraints>
</item>
<item class="javax.swing.JSpinner" icon="/com/intellij/uiDesigner/icons/spinner.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1" />
</item>
<item class="javax.swing.JSlider" icon="/com/intellij/uiDesigner/icons/slider.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1" />
</item>
<item class="javax.swing.JSeparator" icon="/com/intellij/uiDesigner/icons/separator.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3" />
</item>
<item class="javax.swing.JProgressBar" icon="/com/intellij/uiDesigner/icons/progressbar.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="0" fill="1" />
</item>
<item class="javax.swing.JToolBar" icon="/com/intellij/uiDesigner/icons/toolbar.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="0" fill="1">
<preferred-size width="-1" height="20" />
</default-constraints>
</item>
<item class="javax.swing.JToolBar$Separator" icon="/com/intellij/uiDesigner/icons/toolbarSeparator.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="0" anchor="0" fill="1" />
</item>
<item class="javax.swing.JScrollBar" icon="/com/intellij/uiDesigner/icons/scrollbar.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="6" hsize-policy="0" anchor="0" fill="2" />
</item>
</group>
</component>
</project>
+107
View File
@@ -0,0 +1,107 @@
# BaseModule
**公共运行时库**:被 `ServerModule``CrossModule` 依赖。包含 **Netty TCP 帧编解码**、**CmdRpc 分发管线**、**Proto 元数据注册表**、**Spring 命令扫描** 以及与跨服调用相关的 **`CrossRpcInvoker` 接口**。
本模块 **不是** 可执行 Spring Boot 应用;随区服或跨服进程一起加载。
## 网络分层(逻辑)
```mermaid
flowchart TB
subgraph TCP["TCP 字节流"]
F["一帧: 4B length + body"]
end
subgraph Net["BaseModule / net & frame"]
D["LengthPrefixedProtobuf 解码"]
H["CmdRpcTcpFrameHandler"]
end
subgraph Cmd["BaseModule / cmd"]
CD["CmdRpcChannelDispatch"]
PR["ProtoRegistry → ProtoInfo"]
PC["ProtoCommandReflect 解析 CTS / newBuilder STC"]
DP["CmdRpcDispatch → GameCommand"]
end
F --> D --> H --> CD
CD --> PR
CD --> PC
CD --> DP
```
## 1. 帧格式:`LengthPrefixedProtobuf`
- **编码**`LengthPrefixedProtobuf.encodeMessage(alloc, CmdRpcEnvelope)` — 先写 **大端 int32 长度**,再写 `CmdRpcEnvelope.toByteArray()`
- **解码**`newTcpFrameDecoder()` 返回 Netty 解码器,攒满一帧后向 pipeline 下游抛出 **一条 ByteBuf(仅 body**
这样 **区服客户端 TCP****跨服 TCP****区服连跨服的 Outbound** 三者 **帧格式一致**,仅 IP/端口与 `CommandSource` 不同。
## 2. 入站处理:`CmdRpcTcpFrameHandler`
- 读取一帧 `byte[]`,调用 **`CmdRpcChannelDispatch.dispatch(...)`**。
- 将返回的 **`CmdRpcEnvelope`** 再次 **长度前缀编码** 写回对端。
- `linkSource` 由构造时注入:`Client_TCP`(区服接客户端)或 `CROSS_TCP`(跨服接入站)。
**注意**:默认在 **Netty IO 线程** 上执行命令;若 `roundTrip` 同步调跨服会阻塞该连接上的 IO,Demo 场景可接受,生产需评估线程模型。
## 3. 分发核心:`CmdRpcChannelDispatch`
步骤简述:
1. `CmdRpcEnvelope.parseFrom(raw)`
2. `protoRegistry.resolve(linkSource, serviceId, methodId)` → 可选的 **`ProtoInfo`**。
3. 若存在 `ProtoInfo`**解析 payload 为 CTS**、**创建 STC 的 `Builder`**(网络层职责,命令内只填字段)。
4. 构造 **`CommandContext`**(含 `ChannelHandlerContext``CrossRpcInvoker`、解码结果等)。
5. **`CmdRpcDispatch.dispatchEnvelope(in, lookup, cctx)`** → 执行具体 **`GameCommand`**。
```mermaid
flowchart LR
IN[CmdRpcEnvelope in]
PI{ProtoInfo 存在?}
DEC[解码 CTS + STC Builder]
RAW[decoded=null 由命令自处理]
EXE[GameCommand.execute]
OUT[CmdRpcEnvelope out]
IN --> PI
PI -->|是| DEC --> EXE
PI -->|否| RAW --> EXE
EXE --> OUT
```
## 4. 命令模型
| 类型 | 说明 |
|------|------|
| `GameCommand` | `execute(CommandContext, CmdRpcEnvelope)`,完全自控 payload。 |
| `AbstractTypedPbCommand<C, B>` | 依赖 `ProtoInfo``run(ctx, cts, stcBuilder)`;回包信封 **复制** `service/method/cross_ret/link_seq`。 |
### `CommandSource` 与注册
- `@Cmd(source, serviceId, methodId)` 标注在命令类上。
- **`SpringGameCommandRegistry`**:按 Spring 容器里 `GameCommand` Bean 扫描,**只收录** `source` 属于当前进程配置集合的命令(区服只收 `Client_TCP`,跨服只收 `CROSS_TCP`)。
## 5. `ProtoRegistry` 与自动注册
- **`ProtoRegistry`**`register(ProtoInfo)` / `resolve(source, serviceId, methodId)`
- **`ProtoRegistryAutoConfigurer`**`SmartInitializingSingleton`):扫描带 `@Cmd``GameCommand`,若继承 **`AbstractTypedPbCommand`**,则从泛型推断 **CTS / STC 类型** 并注册 **`ProtoInfo`**。
- **未** 继承强类型基类的命令(如路径 3 中继)**不注册** `ProtoInfo`,由命令自行处理 payload。
## 6. `CrossRpcInvoker`
- 接口方法:`CmdRpcEnvelope roundTrip(CmdRpcEnvelope request)`
- **ServerModule** 中由 **`CrossTcpClient`** 实现:维护至跨服的 **长连接**`link_seq` 多路复用、注册与心跳。
区服业务命令(路径 2/3)通过 **`CommandContext.crossRpc()`** 拿到该实例,向跨服发 **`CrossForward*`** 对应的信封。
## 7. 依赖说明
- **Spring Context**`@Component` 等)、**Netty**、**protobuf-java**、**slf4j-api**、**Lombok**(可选)。
- 生成的 proto Java 类来自 **ProtoBufModule**
## 包结构(主要)
```text
com.huangzj.base
├── cmd # 分发、上下文、注册表、Proto 反射、CrossRpcInvoker
├── frame # 长度前缀、Buf 工具
├── net # CmdRpcTcpFrameHandler
└── spring # SpringGameCommandRegistry、其它 Spring 辅助
```
+51
View File
@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>huangzj</groupId>
<artifactId>comm-framework</artifactId>
<version>0.0.1-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>BaseModule</artifactId>
<packaging>jar</packaging>
<name>BaseModule</name>
<dependencies>
<dependency>
<groupId>huangzj</groupId>
<artifactId>ProtoBufModule</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-buffer</artifactId>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-codec</artifactId>
</dependency>
<dependency>
<groupId>com.google.protobuf</groupId>
<artifactId>protobuf-java</artifactId>
<version>${protobuf.version}</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,37 @@
package com.huangzj.base.cmd;
import com.huangzj.cmd.gen.CmdRpcEnvelope;
import com.google.protobuf.Message;
import org.springframework.util.ClassUtils;
/**
* 强类型 PB 命令:CTS/STC 由 {@link ProtoRegistryAutoConfigurer} 根据本类泛型与 {@link Cmd} 自动注册为
* {@link ProtoInfo};网络层解析请求并创建 {@link CommandContext#responseBuilder()},子类只实现 {@link #run}。
*/
public abstract class AbstractTypedPbCommand<C extends Message, B extends Message.Builder> implements GameCommand {
@Override
public CmdRpcEnvelope execute(CommandContext ctx, CmdRpcEnvelope request) throws Exception {
if (ctx.protoInfo() == null || ctx.decodedRequest() == null || ctx.responseBuilder() == null) {
throw new IllegalStateException(
"缺少 ProtoInfo 或网络层未解码: "
+ ClassUtils.getUserClass(getClass()).getName()
+ ",请确认本类带 @Cmd 且继承 AbstractTypedPbCommand<CTS, STC.Builder> 以自动注册 ProtoInfo");
}
@SuppressWarnings("unchecked")
C cts = (C) ctx.decodedRequest();
@SuppressWarnings("unchecked")
B stcBuilder = (B) ctx.responseBuilder();
run(ctx, cts, stcBuilder);
// CmdRpcEnvelope头原样带回:保证请求方(含区服↔跨服 link_seq)能把响应与请求对齐
return CmdRpcEnvelope.newBuilder()
.setServiceId(request.getServiceId())
.setMethodId(request.getMethodId())
.setCrossRet(request.getCrossRet())
.setLinkSeq(request.getLinkSeq())
.setPayload(stcBuilder.build().toByteString())
.build();
}
protected abstract void run(CommandContext ctx, C cts, B stcBuilder) throws Exception;
}
@@ -0,0 +1,25 @@
package com.huangzj.base.cmd;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 标在具体的 {@link GameCommand} 实现上,Spring 扫 Bean 时按来源 + service/method 归组。
* {@code serviceId} / {@code methodId} 请使用 {@link com.huangzj.cmd.gen.PbService} 与各 {@code ServiceN_Method}
* 生成的 {@code *_VALUE} 常量,勿写裸数字。
* CTS/STC 在 {@link ProtoRegistry} 中通过 {@link ProtoInfo} 注册,须与本注解一致。
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Cmd {
CommandSource source();
/** 对应 {@link com.huangzj.cmd.gen.PbService} 的 wire 值,如 {@code PbService.Service_1_VALUE}。 */
int serviceId();
/** 对应具体 {@code ServiceN_Method} 的 wire 值,如 {@code Service1_Method.Method1_VALUE}。 */
int methodId();
}
@@ -0,0 +1,54 @@
package com.huangzj.base.cmd;
import com.huangzj.cmd.gen.CmdRpcEnvelope;
import com.google.protobuf.Message;
import io.netty.channel.ChannelHandlerContext;
import lombok.extern.slf4j.Slf4j;
/**
* Netty 入站:先解析CmdRpcEnvelope,再按 {@link ProtoRegistry} 取 {@link ProtoInfo} 做 CTS 解码与 STC Builder 创建(网络层职责),
* 最后交给 {@link CmdRpcDispatch}。
*/
@Slf4j
public final class CmdRpcChannelDispatch {
private CmdRpcChannelDispatch() {
}
/**
* @param linkSource 当前链路(客户端 TCP / 跨服 TCP),与注册表 {@link ProtoInfo#source()} 一致
* @param protoRegistry PB 元数据表,在应用启动时完成注册
*/
public static CmdRpcEnvelope dispatch(
byte[] raw,
GameCommandLookup lookup,
ChannelHandlerContext ctx,
CrossRpcInvoker crossRpc,
CommandSource linkSource,
ProtoRegistry protoRegistry)
throws Exception {
CmdRpcEnvelope in = CmdRpcEnvelope.parseFrom(raw);
// 同一套 service/method,在 Client_TCP 与 CROSS_TCP 可对应不同 ProtoInfo(来源隔离)
ProtoInfo protoInfo = protoRegistry.resolve(linkSource, in.getServiceId(), in.getMethodId());
if (log.isDebugEnabled()) {
log.debug(
"CmdRpc inbound source={} remote={} service={} method={} protoInfo={}",
linkSource,
ctx.channel().remoteAddress(),
in.getServiceId(),
in.getMethodId(),
protoInfo != null);
}
Message decoded = null;
Message.Builder responseBuilder = null;
if (protoInfo != null) {
// 强类型命令:网络层负责 bytes→CTS、预创建 STC Builder,命令里只填字段
decoded = ProtoCommandReflect.parser(protoInfo.ctsClass()).parseFrom(in.getPayload());
responseBuilder = ProtoCommandReflect.builderSupplier(protoInfo.stcClass()).get();
}
// protoInfo==null 时(如纯中继)由具体 GameCommand 自行解析 payload / 组包
CommandContext cctx =
new CommandContext(ctx, crossRpc, in.getCrossRet(), protoInfo, decoded, responseBuilder);
return CmdRpcDispatch.dispatchEnvelope(in, lookup, cctx);
}
}
@@ -0,0 +1,30 @@
package com.huangzj.base.cmd;
import com.huangzj.cmd.gen.CmdRpcEnvelope;
/**
* 解析CmdRpcEnvelope、查表、执行,三步并一行,Netty handler 里少写点重复代码。
*/
public final class CmdRpcDispatch {
private CmdRpcDispatch() {
}
/**
* 从原始字节起跳,适合 TCP 一帧读完再分发。
*/
public static CmdRpcEnvelope dispatch(byte[] raw, GameCommandLookup lookup, CommandContext ctx) throws Exception {
CmdRpcEnvelope in = CmdRpcEnvelope.parseFrom(raw);
return dispatchEnvelope(in, lookup, ctx);
}
/**
* 已经 parse 过CmdRpcEnvelope时用,避免重复解析;上下文里的 cross_ret 必须在这之前就算好。
*/
public static CmdRpcEnvelope dispatchEnvelope(CmdRpcEnvelope in, GameCommandLookup lookup, CommandContext ctx) throws Exception {
// lookup 按「来源已过滤后的表」解析,故同一 service/method 在区服/跨服可绑定不同实现类
GameCommand cmd = lookup.resolve(in.getServiceId(), in.getMethodId());
// 异常若直接抛出,由上层 Netty handler 记录并关连接;此处不吞异常
return cmd.execute(ctx, in);
}
}
@@ -0,0 +1,52 @@
package com.huangzj.base.cmd;
import com.google.protobuf.Message;
import io.netty.channel.ChannelHandlerContext;
import lombok.Getter;
import lombok.experimental.Accessors;
/**
* 命令执行上下文:Netty、跨服调用器、CmdRpcEnvelope cross_ret,以及网络层根据 {@link ProtoInfo} 解析好的请求/响应 Builder。
*/
@Getter
@Accessors(fluent = true)
public class CommandContext {
private final ChannelHandlerContext channelHandlerContext;
private final CrossRpcInvoker crossRpc;
private final int crossReturnCode;
/**
* 本帧在 {@link ProtoRegistry} 中对应的元数据;未注册强类型协议时为 null。
*/
private final ProtoInfo protoInfo;
/**
* 网络层已按 {@link ProtoInfo#ctsClass()} 解析出的请求体;无 ProtoInfo 时为 null。
*/
private final Message decodedRequest;
/**
* 网络层按 {@link ProtoInfo#stcClass()} 创建的 STC Builder,业务填充后组包;无 ProtoInfo 时为 null。
*/
private final Message.Builder responseBuilder;
public CommandContext(ChannelHandlerContext channelHandlerContext, CrossRpcInvoker crossRpcInvoker) {
this(channelHandlerContext, crossRpcInvoker, 0, null, null, null);
}
public CommandContext(ChannelHandlerContext channelHandlerContext, CrossRpcInvoker crossRpcInvoker, int crossReturnCode) {
this(channelHandlerContext, crossRpcInvoker, crossReturnCode, null, null, null);
}
/**
* @param protoInfo 可为 null
* @param decodedRequest 与 protoInfo 成对;有 protoInfo 时通常非 null
* @param responseBuilder 与 protoInfo 成对;有 protoInfo 时通常非 null
*/
public CommandContext(ChannelHandlerContext channelHandlerContext, CrossRpcInvoker crossRpcInvoker, int crossReturnCode, ProtoInfo protoInfo, Message decodedRequest, Message.Builder responseBuilder) {
this.channelHandlerContext = channelHandlerContext;
this.crossRpc = crossRpcInvoker;
this.crossReturnCode = crossReturnCode;
this.protoInfo = protoInfo;
this.decodedRequest = decodedRequest;
this.responseBuilder = responseBuilder;
}
}
@@ -0,0 +1,28 @@
package com.huangzj.base.cmd;
import lombok.Getter;
/**
* 命令从哪儿来:区服面向客户端的 TCP、或跨服进程上的 TCP。
* {@code fromType} 若要与外部协议数字对齐,可直接使用。
*/
@Getter
public enum CommandSource {
/** 区服侧面向客户端的那条 Cmd/会话链路(名字历史遗留,别和 OSI 的 TCP 较真)。 */
Client_TCP(1, "CLIENT_TCP"),
/** 跨服机器上监听的 CmdRpc TCP。 */
CROSS_TCP(2, "CROSS_TCP");
/** 若要和外部协议数字对齐,用这个。 */
private final int fromType;
/** 给人看的短名字,日志里用着方便。 */
private final String fromTypeName;
CommandSource(int fromType, String fromTypeName) {
this.fromType = fromType;
this.fromTypeName = fromTypeName;
}
}
@@ -0,0 +1,14 @@
package com.huangzj.base.cmd;
import com.huangzj.cmd.gen.CmdRpcEnvelope;
/**
* 区服侧想跟跨服聊一句时用的抽象,默认实现是 TCP 同步 roundTrip。
*/
public interface CrossRpcInvoker {
/**
* 发一帧过去、等一帧回来;实现里一般是 TCP 同步写读,超时或断线要抛异常。
*/
CmdRpcEnvelope roundTrip(CmdRpcEnvelope request) throws Exception;
}
@@ -0,0 +1,15 @@
package com.huangzj.base.cmd;
import com.huangzj.cmd.gen.CmdRpcEnvelope;
/**
* 一条 CmdRpc 业务命令:入参出参都是CmdRpcEnvelope,具体 PB 在实现里自己拆。
*/
public interface GameCommand {
/**
* 处理一帧 RPCrequest 里带好 service/method/payload,返回完整CmdRpcEnvelope(含 payload)。
* 具体异常策略由上层 {@code CmdRpcDispatch} 决定是否包进CmdRpcEnvelope或断开连接。
*/
CmdRpcEnvelope execute(CommandContext ctx, CmdRpcEnvelope request) throws Exception;
}
@@ -0,0 +1,9 @@
package com.huangzj.base.cmd;
/**
* 根据 serviceId + methodId 捞出一条命令,找不到就抛 IllegalArgumentException。
*/
public interface GameCommandLookup {
GameCommand resolve(int serviceId, int methodId);
}
@@ -0,0 +1,60 @@
package com.huangzj.base.cmd;
import com.google.protobuf.Message;
import com.google.protobuf.Parser;
import java.lang.reflect.Method;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Supplier;
/**
* 按 protobuf 生成类做 CTS 解析 / STC Builder 创建(类比原工程 {@code ProtoMgr.decode} / {@code getStcProtoMessageByCtsNo})。
*/
final class ProtoCommandReflect {
private static final ConcurrentHashMap<Class<?>, Parser<?>> PARSERS = new ConcurrentHashMap<>();
private static final ConcurrentHashMap<Class<?>, Supplier<? extends Message.Builder>> BUILDER_FACTORIES =
new ConcurrentHashMap<>();
private ProtoCommandReflect() {
}
@SuppressWarnings("unchecked")
static <C extends Message> Parser<C> parser(Class<? extends Message> ctsClass) {
return (Parser<C>) PARSERS.computeIfAbsent(ctsClass, ProtoCommandReflect::loadParser);
}
@SuppressWarnings("unchecked")
static <B extends Message.Builder> Supplier<B> builderSupplier(Class<? extends Message> stcClass) {
return (Supplier<B>) BUILDER_FACTORIES.computeIfAbsent(stcClass, ProtoCommandReflect::loadBuilderFactory);
}
private static Parser<?> loadParser(Class<?> msgClass) {
try {
Method m = msgClass.getMethod("parser");
return (Parser<?>) m.invoke(null);
} catch (ReflectiveOperationException e) {
throw new IllegalStateException(
"CTS 须为 protobuf 生成类且含 public static Parser parser(): " + msgClass.getName(), e);
}
}
private static Supplier<? extends Message.Builder> loadBuilderFactory(Class<?> msgClass) {
try {
Method m = msgClass.getMethod("newBuilder");
Object probe = m.invoke(null);
if (!(probe instanceof Message.Builder)) {
throw new IllegalStateException("newBuilder 未返回 Message.Builder: " + msgClass.getName());
}
} catch (ReflectiveOperationException e) {
throw new IllegalStateException(
"STC 须为 protobuf 生成类且含 public static Builder newBuilder(): " + msgClass.getName(), e);
}
return () -> {
try {
return (Message.Builder) msgClass.getMethod("newBuilder").invoke(null);
} catch (ReflectiveOperationException e) {
throw new IllegalStateException(e);
}
};
}
}
@@ -0,0 +1,14 @@
package com.huangzj.base.cmd;
import com.google.protobuf.Message;
/**
* PB 接口注册时的元数据:与 {@link CommandSource} + service/method 绑定,供网络层取 CTS/STC 类型做解析。
* (对齐原工程 {@code ProtoInfo + ProtoMgr.getProtoInfoByCode} 思路。)
*/
public record ProtoInfo(
CommandSource source,
int serviceId,
int methodId,
Class<? extends Message> ctsClass,
Class<? extends Message> stcClass) {}
@@ -0,0 +1,33 @@
package com.huangzj.base.cmd;
import org.springframework.stereotype.Component;
import java.util.concurrent.ConcurrentHashMap;
/**
* 全局 PB 命令表:按「链路 + service + method」注册 {@link ProtoInfo},网络层只查这里拿类型。
*/
@Component
public class ProtoRegistry {
private final ConcurrentHashMap<String, ProtoInfo> byKey = new ConcurrentHashMap<>();
public void register(ProtoInfo info) {
String k = key(info.source(), info.serviceId(), info.methodId());
ProtoInfo prev = byKey.putIfAbsent(k, info);
if (prev != null) {
throw new IllegalStateException("ProtoInfo 重复注册: " + k + " 已有 " + prev);
}
}
/**
* 未注册时返回 null(例如纯透传、自定义解析的命令)。
*/
public ProtoInfo resolve(CommandSource source, int serviceId, int methodId) {
return byKey.get(key(source, serviceId, methodId));
}
private static String key(CommandSource source, int serviceId, int methodId) {
return source.name() + ":" + serviceId + ":" + methodId;
}
}
@@ -0,0 +1,56 @@
package com.huangzj.base.cmd;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.SmartInitializingSingleton;
import org.springframework.context.ApplicationContext;
import org.springframework.stereotype.Component;
import org.springframework.util.ClassUtils;
/**
* 启动时扫描所有 {@link GameCommand} Bean:带 {@link Cmd} 且继承 {@link AbstractTypedPbCommand} 的,
* 根据注解中的 source/service/method 与泛型上的 CTS/STC 自动 {@link ProtoRegistry#register}。
* 未继承强类型基类(如纯透传)的命令不注册,由网络层按无 {@link ProtoInfo} 处理。
*/
@Slf4j
@Component
public class ProtoRegistryAutoConfigurer implements SmartInitializingSingleton {
private final ApplicationContext applicationContext;
private final ProtoRegistry protoRegistry;
public ProtoRegistryAutoConfigurer(ApplicationContext applicationContext, ProtoRegistry protoRegistry) {
this.applicationContext = applicationContext;
this.protoRegistry = protoRegistry;
}
@Override
public void afterSingletonsInstantiated() {
Map<String, GameCommand> beans = applicationContext.getBeansOfType(GameCommand.class);
AtomicInteger n = new AtomicInteger();
for (GameCommand bean : beans.values()) {
Class<?> userClass = ClassUtils.getUserClass(bean.getClass());
Cmd cmd = userClass.getAnnotation(Cmd.class);
if (cmd == null) {
continue;
}
// 仅 AbstractTypedPbCommand 子类可解析泛型;GameCommand 裸实现(如路径3中继)不注册 ProtoInfo
TypedPbCommandReflect.resolveCtsStc(userClass)
.ifPresent(pair -> {
ProtoInfo info =
new ProtoInfo(cmd.source(), cmd.serviceId(), cmd.methodId(), pair.cts(), pair.stc());
protoRegistry.register(info);
n.incrementAndGet();
log.info(
"ProtoInfo registered: source={} service={} method={} cts={} stc={}",
cmd.source(),
cmd.serviceId(),
cmd.methodId(),
pair.cts().getSimpleName(),
pair.stc().getSimpleName());
});
}
log.info("ProtoRegistry auto-registration done, {} ProtoInfo bean(s)", n.get());
}
}
@@ -0,0 +1,64 @@
package com.huangzj.base.cmd;
import com.google.protobuf.Message;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import org.springframework.util.ClassUtils;
/**
* 从继承 {@link AbstractTypedPbCommand} 的具体命令类上解析 CTS / STC 消息类型(第二泛参为 {@code Xxx.Builder} 时,STC 取 {@code Xxx})。
*/
final class TypedPbCommandReflect {
private TypedPbCommandReflect() {}
record CtsStc(Class<? extends Message> cts, Class<? extends Message> stc) {}
static java.util.Optional<CtsStc> resolveCtsStc(Class<?> commandClass) {
Class<?> user = ClassUtils.getUserClass(commandClass);
ParameterizedType pt = findAbstractTypedSuper(user);
if (pt == null) {
// 非 AbstractTypedPbCommand 子类,或泛型在运行期被擦除到无法解析
return java.util.Optional.empty();
}
Type[] args = pt.getActualTypeArguments();
if (args.length < 2) {
return java.util.Optional.empty();
}
Class<?> ctsClass = rawClass(args[0]);
Class<?> builderOrSecond = rawClass(args[1]);
if (ctsClass == null
|| builderOrSecond == null
|| !Message.class.isAssignableFrom(ctsClass)) {
return java.util.Optional.empty();
}
// Java PB 惯例:Message.Builder 为 Message 的静态内部类,外层即 STC 类型
Class<?> stcClass = builderOrSecond.getDeclaringClass();
if (stcClass == null || !Message.class.isAssignableFrom(stcClass)) {
return java.util.Optional.empty();
}
return java.util.Optional.of(
new CtsStc(ctsClass.asSubclass(Message.class), stcClass.asSubclass(Message.class)));
}
private static ParameterizedType findAbstractTypedSuper(Class<?> c) {
while (c != null && c != Object.class) {
Type t = c.getGenericSuperclass();
if (t instanceof ParameterizedType p && p.getRawType() == AbstractTypedPbCommand.class) {
return p;
}
c = c.getSuperclass();
}
return null;
}
private static Class<?> rawClass(Type t) {
if (t instanceof Class<?> cl) {
return cl;
}
if (t instanceof ParameterizedType p && p.getRawType() instanceof Class<?> cl) {
return cl;
}
return null;
}
}
@@ -0,0 +1,32 @@
package com.huangzj.base.frame;
import com.google.protobuf.MessageLite;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufAllocator;
import io.netty.handler.codec.LengthFieldBasedFrameDecoder;
/**
* TCP 上 4 字节长度 + protobuf body 的通用写法,跨服和区服客户端共用。
*/
public final class LengthPrefixedProtobuf {
/** 单帧上限,防止恶意大包把内存打爆。 */
public static final int MAX_FRAME_LENGTH = 65536;
private LengthPrefixedProtobuf() {
}
/** 长度字段在开头 4 字节,剥离长度头后剩下纯 PB。 */
public static LengthFieldBasedFrameDecoder newTcpFrameDecoder() {
return new LengthFieldBasedFrameDecoder(MAX_FRAME_LENGTH, 0, 4, 0, 4);
}
/** 先发 4 字节小端长度,再发 body,和上面的 decoder 成对使用。 */
public static ByteBuf encodeMessage(ByteBufAllocator alloc, MessageLite message) {
byte[] body = message.toByteArray();
ByteBuf buf = alloc.buffer(4 + body.length);
buf.writeInt(body.length);
buf.writeBytes(body);
return buf;
}
}
@@ -0,0 +1,20 @@
package com.huangzj.base.frame;
import io.netty.buffer.ByteBuf;
/**
* 把 ByteBuf 里当前可读区间一次性拷成 byte[],handler 里少写两行样板代码。
*/
public final class NettyBufUtil {
private NettyBufUtil() {
}
/** 读指针会跟着动,注意别重复读同一段。 */
public static byte[] readReadableBytes(ByteBuf buf) {
int n = buf.readableBytes();
byte[] raw = new byte[n];
buf.readBytes(raw);
return raw;
}
}
@@ -0,0 +1,16 @@
package com.huangzj.base.module;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* 挂在某个宿主(一般是玩家或会话)上的模块基类,手里攥着宿主引用。
*
* @param <P> 宿主类型,demo 里是 {@link com.huangzj.server.module.DemoPlayerHost}
*/
@Getter
@RequiredArgsConstructor
public abstract class AbsHostModule<P> {
protected final P player;
}
@@ -0,0 +1,121 @@
package com.huangzj.base.module;
import lombok.Getter;
import lombok.Setter;
/**
* 玩家侧逻辑模块的骨架:tick、存档、登录回调都先空着,子类按需填。
* 和修仙那套 GeneralModule 思路类似,这里做了精简,只保留 demo 要用的钩子。
*/
@Getter
@Setter
public abstract class GeneralModule<P> extends AbsHostModule<P> {
/** tick / 存盘时共用的锁,避免和业务线程打架(demo 里只用在 runTick)。 */
protected final Object saveLockObj = new Object();
/** 是否有脏数据要同步,具体怎么用看业务。 */
protected boolean needSync;
/** 是否已经进入过登录后流程,占位字段。 */
protected boolean enterAfterLogin;
/** 为 true 时才会被 {@link com.huangzj.base.spring.module.PlayerModuleRegistry#runTickAll(long)} 扫到。 */
private boolean enableTick;
protected GeneralModule(P player) {
super(player);
}
/** 标记需要同步,一般配合 syncChange 使用。 */
public void setNeedSync() {
this.needSync = true;
}
/** 同步做完了就清掉标记。 */
public void finishSync() {
this.needSync = false;
}
/** 当前模块归属的玩家 id,子类必须给出。 */
public abstract long getPlayerId();
/** 拉档,默认 true 表示不用拦。 */
public boolean loadData() {
return true;
}
/** 拉档后的二次处理,默认放行。 */
public boolean afterLoadData() {
return true;
}
/** 存盘,子类可写 Mongo/Redis;默认啥也不干。 */
public boolean saveData() {
return true;
}
/** 把内存改动推到客户端或其它服,force 是否强推由子类解释。 */
public void syncChange(boolean force) {
}
/** 模块类型,默认就是具体类。 */
public Class<?> getModuleClass() {
return getClass();
}
/** 登录完成要下发的消息,占位。 */
public void loginSendMsg(boolean isReconnect) {
}
/** 登录后逻辑,占位。 */
public void afterLogin(boolean isReconnect) {
}
/** 下线清理,占位。 */
public void offline() {
}
/** 处理游戏内推送/内部消息,占位。 */
public void handleGameMsg(Object msg) {
}
/** 红点刷新,占位。 */
public void refreshRedPoint() {
}
/** 跨天/重置类逻辑,占位。 */
public void dailyResetData() {
}
/** 打开定时 tick,注册表只会调度 isEnableTick 为 true 的模块。 */
public void enableTick() {
this.enableTick = true;
}
/** 若要做精准调度可覆写;默认 0 表示「不承诺下次时间点」。 */
public long calcNextTickAt() {
return 0L;
}
/**
* 外层统一包一层锁和异常,真正的周期逻辑写在 {@link #tick(long)}。
*/
public void runTick(long runAt) {
try {
synchronized (saveLockObj) {
tick(runAt);
}
} catch (Exception e) {
onTickError(e);
}
}
/** 每个调度周期进来一次,子类写业务。 */
protected void tick(long runAt) {
}
/** tick 抛错时的钩子,默认吞掉,需要打日志或告警就覆写。 */
protected void onTickError(Exception e) {
}
}
@@ -0,0 +1,15 @@
package com.huangzj.base.module;
import java.util.Collections;
import java.util.Map;
/**
* 模块要是愿意暴露点计数器之类的,就实现这个;注册表拼 /module/info 时会 merge 进来。
*/
public interface ModuleMetricsProvider {
/** 默认啥指标也没有,子类按需 override。 */
default Map<String, Object> metrics() {
return Collections.emptyMap();
}
}
@@ -0,0 +1,17 @@
package com.huangzj.base.module;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 标在 {@link GeneralModule} 上,数字越小越先跑 tick(只是 demo 约定,你可以按习惯改)。
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface PlayerModule {
/** 排序用,默认 0;相同顺序时不保证谁先谁后。 */
int order() default 0;
}
@@ -0,0 +1,48 @@
package com.huangzj.base.net;
import com.huangzj.base.cmd.CmdRpcChannelDispatch;
import com.huangzj.base.cmd.CommandSource;
import com.huangzj.base.cmd.CrossRpcInvoker;
import com.huangzj.base.cmd.GameCommandLookup;
import com.huangzj.base.cmd.ProtoRegistry;
import com.huangzj.base.frame.LengthPrefixedProtobuf;
import com.huangzj.base.frame.NettyBufUtil;
import com.huangzj.cmd.gen.CmdRpcEnvelope;
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
/**
* 长度前缀剥掉后的一帧 = 整包 {@link CmdRpcEnvelope}:查 {@link ProtoRegistry} 解码并分发。
*/
@Slf4j
@RequiredArgsConstructor
public class CmdRpcTcpFrameHandler extends SimpleChannelInboundHandler<ByteBuf> {
private final GameCommandLookup lookup;
private final CrossRpcInvoker crossRpc;
private final CommandSource linkSource;
private final ProtoRegistry protoRegistry;
@Override
protected void channelRead0(ChannelHandlerContext ctx, ByteBuf msg) throws Exception {
byte[] raw = NettyBufUtil.readReadableBytes(msg);
// 解码 + ProtoRegistry + GameCommand 执行在同一线程(默认 IO 线程),业务应避免长时间阻塞
CmdRpcEnvelope out =
CmdRpcChannelDispatch.dispatch(raw, lookup, ctx, crossRpc, linkSource, protoRegistry);
ctx.writeAndFlush(LengthPrefixedProtobuf.encodeMessage(ctx.alloc(), out));
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
log.error(
"CmdRpc TCP handler error source={} remote={}",
linkSource,
ctx.channel().remoteAddress(),
cause);
// 半包解码器之上若已错乱,继续读无意义,直接断链让客户端重连
ctx.close();
}
}
@@ -0,0 +1,55 @@
package com.huangzj.base.spring;
import com.huangzj.base.cmd.Cmd;
import com.huangzj.base.cmd.CommandSource;
import com.huangzj.base.cmd.GameCommand;
import com.huangzj.base.cmd.GameCommandLookup;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationContext;
import org.springframework.util.ClassUtils;
/**
* 启动时把 Spring 容器里的 {@link GameCommand} 扫一遍,按 {@link Cmd} 过滤来源。
* 当前 key 为 {@link Cmd#serviceId()} + {@link Cmd#methodId()}(与 {@link com.huangzj.cmd.gen.CmdRpcEnvelope} 一致)。
*/
@Slf4j
public class SpringGameCommandRegistry implements GameCommandLookup {
private final Map<String, GameCommand> commands = new HashMap<>();
/** 只收某一种来源的命令。 */
public SpringGameCommandRegistry(ApplicationContext applicationContext, CommandSource source) {
this(applicationContext, EnumSet.of(source));
}
/** 区服若收多种来源,用 Set 传多个 {@link CommandSource}。 */
public SpringGameCommandRegistry(ApplicationContext applicationContext, Set<CommandSource> sources) {
Map<String, GameCommand> beans = applicationContext.getBeansOfType(GameCommand.class);
for (GameCommand cmd : beans.values()) {
Class<?> userClass = ClassUtils.getUserClass(cmd);
Cmd meta = userClass.getAnnotation(Cmd.class);
// 同一 Bean 可能带 @Cmd(source=Client_TCP),在跨服进程扫描时会被 sources 过滤掉
if (meta != null && sources.contains(meta.source())) {
commands.put(key(meta.serviceId(), meta.methodId()), cmd);
}
}
log.info("GameCommand registry sources={} size={} keys={}", sources, commands.size(), commands.keySet());
}
private static String key(int serviceId, int methodId) {
return ":" + serviceId + ":" + methodId;
}
@Override
public GameCommand resolve(int serviceId, int methodId) {
GameCommand cmd = commands.get(key(serviceId, methodId));
if (cmd == null) {
throw new IllegalArgumentException("unknown " + serviceId + "/" + methodId);
}
return cmd;
}
}
@@ -0,0 +1,85 @@
package com.huangzj.base.spring.module;
import com.huangzj.base.module.GeneralModule;
import com.huangzj.base.module.ModuleMetricsProvider;
import com.huangzj.base.module.PlayerModule;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.beans.BeansException;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Component;
/**
* 把容器里所有 {@link GeneralModule} 捞出来排个序,统一 tick、save,顺便给 HTTP 一个快照。
*/
@Component
public class PlayerModuleRegistry implements ApplicationContextAware {
/** 排好序的模块列表,只在容器刷新时重建。 */
private List<GeneralModule<?>> modules = List.of();
/**
* Spring 回调:收集所有 GeneralModule Bean,按 {@link PlayerModule#order()} 从小到大排。
*/
@Override
@SuppressWarnings({"rawtypes", "unchecked"})
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
Map<String, GeneralModule> raw = applicationContext.getBeansOfType(GeneralModule.class);
List<GeneralModule<?>> list = new ArrayList<>();
for (GeneralModule m : raw.values()) {
list.add(m);
}
list.sort(Comparator.comparingInt(this::orderOf));
modules = List.copyOf(list);
}
/** 没标注解就当 0,和别的模块挤在一起。 */
private int orderOf(GeneralModule<?> module) {
PlayerModule ann = module.getClass().getAnnotation(PlayerModule.class);
return ann == null ? 0 : ann.order();
}
/** 当前快照,一般给调试或管理接口。 */
public List<GeneralModule<?>> all() {
return modules;
}
/** 对所有「开了 tick」的模块跑一遍。 */
public void runTickAll(long runAt) {
for (GeneralModule<?> module : modules) {
if (module.isEnableTick()) {
module.runTick(runAt);
}
}
}
/** 依次存盘,谁失败谁自己处理,这里不中断链路。 */
public void saveAll() {
for (GeneralModule<?> module : modules) {
module.saveData();
}
}
/**
* 拼一份给人看的列表:类名、顺序、是否 tick,外加实现了 {@link ModuleMetricsProvider} 的指标。
*/
public List<Map<String, Object>> describeModules() {
List<Map<String, Object>> rows = new ArrayList<>();
for (GeneralModule<?> module : modules) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("class", module.getClass().getSimpleName());
PlayerModule ann = module.getClass().getAnnotation(PlayerModule.class);
row.put("order", ann == null ? 0 : ann.order());
row.put("enableTick", module.isEnableTick());
if (module instanceof ModuleMetricsProvider provider) {
row.putAll(provider.metrics());
}
rows.add(row);
}
return rows;
}
}
@@ -0,0 +1,17 @@
com\huangzj\base\cmd\CmdRpcDispatch.class
com\huangzj\base\cmd\GameCommand.class
com\huangzj\base\module\PlayerModule.class
com\huangzj\base\module\ModuleMetricsProvider.class
com\huangzj\base\cmd\GameCommandLookup.class
com\huangzj\base\cmd\CrossRpcInvoker.class
com\huangzj\base\spring\SpringGameCommandRegistry.class
com\huangzj\base\frame\NettyBufUtil.class
com\huangzj\base\module\AbsHostModule.class
com\huangzj\base\cmd\CommandContext.class
com\huangzj\base\module\GeneralModule.class
com\huangzj\base\spring\module\PlayerModuleRegistry.class
com\huangzj\base\frame\LengthPrefixedProtobuf.class
com\huangzj\base\cmd\Cmd.class
com\huangzj\base\cmd\AbstractTypedPbCommand.class
com\huangzj\base\cmd\CommandSource.class
com\huangzj\base\cmd\AbstractCrossToGamePbCommand.class
@@ -0,0 +1,17 @@
F:\coding\comm-framework\BaseModule\src\main\java\com\huangzj\base\cmd\AbstractTypedPbCommand.java
F:\coding\comm-framework\BaseModule\src\main\java\com\huangzj\base\frame\LengthPrefixedProtobuf.java
F:\coding\comm-framework\BaseModule\src\main\java\com\huangzj\base\cmd\CommandSource.java
F:\coding\comm-framework\BaseModule\src\main\java\com\huangzj\base\module\GeneralModule.java
F:\coding\comm-framework\BaseModule\src\main\java\com\huangzj\base\module\AbsHostModule.java
F:\coding\comm-framework\BaseModule\src\main\java\com\huangzj\base\cmd\CommandContext.java
F:\coding\comm-framework\BaseModule\src\main\java\com\huangzj\base\cmd\CmdRpcDispatch.java
F:\coding\comm-framework\BaseModule\src\main\java\com\huangzj\base\cmd\Cmd.java
F:\coding\comm-framework\BaseModule\src\main\java\com\huangzj\base\spring\module\PlayerModuleRegistry.java
F:\coding\comm-framework\BaseModule\src\main\java\com\huangzj\base\cmd\AbstractCrossToGamePbCommand.java
F:\coding\comm-framework\BaseModule\src\main\java\com\huangzj\base\module\ModuleMetricsProvider.java
F:\coding\comm-framework\BaseModule\src\main\java\com\huangzj\base\cmd\CrossRpcInvoker.java
F:\coding\comm-framework\BaseModule\src\main\java\com\huangzj\base\cmd\GameCommandLookup.java
F:\coding\comm-framework\BaseModule\src\main\java\com\huangzj\base\spring\SpringGameCommandRegistry.java
F:\coding\comm-framework\BaseModule\src\main\java\com\huangzj\base\module\PlayerModule.java
F:\coding\comm-framework\BaseModule\src\main\java\com\huangzj\base\frame\NettyBufUtil.java
F:\coding\comm-framework\BaseModule\src\main\java\com\huangzj\base\cmd\GameCommand.java
+101
View File
@@ -0,0 +1,101 @@
# CrossModule
**跨服独立进程**:仅启动 **一个 CmdRpc TCP 服务**(默认 **9200**),处理来源为 **`CommandSource.CROSS_TCP`** 的命令。
区服通过 **`CrossTcpClient`**(在 ServerModule)作为 **TCP 客户端** 连到本进程。
## 进程职责
| 职责 | 说明 |
|------|------|
| 监听 TCP | `CrossTcpServer``ApplicationReadyEvent``bind(cross.tcp.port)` |
| Pipeline | `CrossTcpChannelInitializer``LengthPrefixedProtobuf` 解码 + `CmdRpcTcpFrameHandler` |
| 命令路由 | `CrossGameCommandRegistry` 只注册 `@Cmd(source=CROSS_TCP)` 的 Bean |
| 链路安全(可选) | `CrossLinkAcceptProperties``cross.link.expected-token` 非空时校验注册 token |
## 网络拓扑
```mermaid
flowchart LR
subgraph Zone["ServerModule"]
CC[CrossTcpClient]
end
subgraph Cross["CrossModule :9200"]
XT[CrossTcpServer]
PL[pipeline]
REG[CrossGameCommandRegistry]
end
CC <-->|单 TCP 长连接 长度前缀+CmdRpcEnvelope| XT
XT --> PL --> REG
```
## 入站 Pipeline(与 BaseModule 一致)
```text
Socket
→ LengthFieldBasedFrameDecoder(由 LengthPrefixedProtobuf 配置)
→ CmdRpcTcpFrameHandler
→ CmdRpcChannelDispatchlinkSource = CROSS_TCP
→ GameCommand.execute
→ 编码响应帧写回
```
跨服侧 **`CrossRpcInvoker``null`**:跨服命令里 **不应** 再同步 `roundTrip` 回调区服(Demo 未实现跨服主动调区服)。
## 已实现的 `CROSS_TCP` 命令
### 业务:Service_2 / Method2
- **`CrossForwardCommand`**`AbstractTypedPbCommand`
- 入参:`CrossForwardCts`
- 出参:`CrossForwardStc`
- Demo 逻辑:给 `trace` 加前缀并写入 `cross_time_ms`
### 链路:Service_4(注册 / 心跳)
| method | 命令类 | 作用 |
|--------|--------|------|
| Register | `CrossLinkRegisterCommand` | 校验 `server_id`、可选 `expected-token` |
| Heartbeat | `CrossLinkHeartbeatCommand` | 回显 `client_time_ms`,写入 `cross_time_ms` |
```mermaid
sequenceDiagram
participant Z as 区服 CrossTcpClient
participant X as CrossModule
Z->>X: Service_4 Register + link_seq + CrossLinkRegisterCts
X-->>Z: 同 link_seq + CrossLinkRegisterStc(ok/message)
loop 每 heartbeat-interval-ms
Z->>X: Service_4 Heartbeat + link_seq
X-->>Z: CrossLinkHeartbeatStc
end
Z->>X: Service_2 Method2 + link_seq + CrossForwardCts
X-->>Z: CrossForwardStc
```
## 配置(`src/main/resources/application.yml`
```yaml
cross:
tcp:
port: 9200 # Netty 监听端口
link:
expected-token: "" # 非空则须与区服 cross.token 一致
app:
logging:
dir: logs/CrossModule
```
## 启动
主类:`com.huangzj.cross.CrossModuleApplication`
建议 **先于区服** 启动,否则区服 `CrossTcpClient` 预热与心跳会失败(日志告警),待跨服起来后下次业务或心跳会重连。
## 日志
`logback-spring.xml`:控制台 + 按级别分文件(`info.log` / `error.log`),目录由 `app.logging.dir` 指定。
## 扩展跨服 RPC
1.**ProtoBufModule** 增加 `service/method` 与消息体。
2. 在本模块新增 `@Cmd(source=CROSS_TCP, ...)``GameCommand``AbstractTypedPbCommand`
3. 若需强类型自动解码,使用 **`AbstractTypedPbCommand`**;启动时 **`ProtoRegistryAutoConfigurer`** 会为跨服进程注册对应 `ProtoInfo`
+55
View File
@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>huangzj</groupId>
<artifactId>comm-framework</artifactId>
<version>0.0.1-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>CrossModule</artifactId>
<packaging>jar</packaging>
<name>CrossModule</name>
<dependencies>
<dependency>
<groupId>huangzj</groupId>
<artifactId>ProtoBufModule</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>huangzj</groupId>
<artifactId>BaseModule</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<mainClass>com.huangzj.cross.CrossModuleApplication</mainClass>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,18 @@
package com.huangzj.cross;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
/**
* 跨服独立进程:只起 TCP + 命令注册,记得先让它跑起来区服才能 proxy。
*/
@SpringBootApplication(scanBasePackages = "com.huangzj")
@ConfigurationPropertiesScan(basePackages = "com.huangzj")
public class CrossModuleApplication {
/** 轻量进程,一般先于区服启动。 */
public static void main(String[] args) {
SpringApplication.run(CrossModuleApplication.class, args);
}
}
@@ -0,0 +1,26 @@
package com.huangzj.cross.cmd;
import com.huangzj.base.cmd.AbstractTypedPbCommand;
import com.huangzj.base.cmd.Cmd;
import com.huangzj.base.cmd.CommandContext;
import com.huangzj.base.cmd.CommandSource;
import com.huangzj.cmd.gen.CrossForwardCts;
import com.huangzj.cmd.gen.CrossForwardStc;
import com.huangzj.cmd.gen.PbService;
import com.huangzj.cmd.gen.Service2_Method;
import org.springframework.stereotype.Component;
/** 跨服示例:{@link PbService#Service_2} + {@link Service2_Method#Method2}。 */
@Component
@Cmd(
source = CommandSource.CROSS_TCP,
serviceId = PbService.Service_2_VALUE,
methodId = Service2_Method.Method2_VALUE)
public class CrossForwardCommand extends AbstractTypedPbCommand<CrossForwardCts, CrossForwardStc.Builder> {
@Override
protected void run(CommandContext ctx, CrossForwardCts cts, CrossForwardStc.Builder stcBuilder) {
// Demo:给 trace 加前缀便于区分「跨服加工」;生产可在此落库、转发其它服等
stcBuilder.setTrace("cross:" + cts.getTrace()).setCrossTimeMs(System.currentTimeMillis());
}
}
@@ -0,0 +1,18 @@
package com.huangzj.cross.cmd;
import com.huangzj.base.cmd.CommandSource;
import com.huangzj.base.spring.SpringGameCommandRegistry;
import org.springframework.context.ApplicationContext;
import org.springframework.stereotype.Component;
/**
* 跨服进程只认 {@link CommandSource#CROSS_TCP} 的命令,别的来源不会进这张表。
*/
@Component
public class CrossGameCommandRegistry extends SpringGameCommandRegistry {
/** 启动时扫 Bean,只收集带 {@code @Cmd(source=CROSS_TCP)} 的实现。 */
public CrossGameCommandRegistry(ApplicationContext applicationContext) {
super(applicationContext, CommandSource.CROSS_TCP);
}
}
@@ -0,0 +1,26 @@
package com.huangzj.cross.cmd;
import com.huangzj.base.cmd.AbstractTypedPbCommand;
import com.huangzj.base.cmd.Cmd;
import com.huangzj.base.cmd.CommandContext;
import com.huangzj.base.cmd.CommandSource;
import com.huangzj.cmd.gen.CrossLinkHeartbeatCts;
import com.huangzj.cmd.gen.CrossLinkHeartbeatStc;
import com.huangzj.cmd.gen.PbService;
import com.huangzj.cmd.gen.Service4_Method;
import org.springframework.stereotype.Component;
/** 区服 ↔ 跨服长连接心跳(仅 {@link CommandSource#CROSS_TCP})。 */
@Component
@Cmd(
source = CommandSource.CROSS_TCP,
serviceId = PbService.Service_4_VALUE,
methodId = Service4_Method.Heartbeat_VALUE)
public class CrossLinkHeartbeatCommand extends AbstractTypedPbCommand<CrossLinkHeartbeatCts, CrossLinkHeartbeatStc.Builder> {
@Override
protected void run(CommandContext ctx, CrossLinkHeartbeatCts cts, CrossLinkHeartbeatStc.Builder stcBuilder) {
// 原样回显客户端时间,便于区服侧算 RTT;cross_time_ms 为跨服处理时刻
stcBuilder.setClientTimeMs(cts.getClientTimeMs()).setCrossTimeMs(System.currentTimeMillis());
}
}
@@ -0,0 +1,44 @@
package com.huangzj.cross.cmd;
import com.huangzj.base.cmd.AbstractTypedPbCommand;
import com.huangzj.base.cmd.Cmd;
import com.huangzj.base.cmd.CommandContext;
import com.huangzj.base.cmd.CommandSource;
import com.huangzj.cmd.gen.CrossLinkRegisterCts;
import com.huangzj.cmd.gen.CrossLinkRegisterStc;
import com.huangzj.cmd.gen.PbService;
import com.huangzj.cmd.gen.Service4_Method;
import com.huangzj.cross.config.CrossLinkAcceptProperties;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
/**
* 区服 TCP 连上跨服后的注册握手(仅 {@link CommandSource#CROSS_TCP})。
*/
@Component
@RequiredArgsConstructor
@Cmd(
source = CommandSource.CROSS_TCP,
serviceId = PbService.Service_4_VALUE,
methodId = Service4_Method.Register_VALUE)
public class CrossLinkRegisterCommand extends AbstractTypedPbCommand<CrossLinkRegisterCts, CrossLinkRegisterStc.Builder> {
private final CrossLinkAcceptProperties acceptProperties;
@Override
protected void run(CommandContext ctx, CrossLinkRegisterCts cts, CrossLinkRegisterStc.Builder stcBuilder) {
String expected = acceptProperties.getExpectedToken();
// 跨服未配置 expected-token 时不校验,方便本地多区服联调;生产环境建议两边配置一致
if (StringUtils.hasText(expected) && !expected.equals(cts.getToken())) {
stcBuilder.setOk(false).setMessage("token mismatch");
return;
}
// server_id 用于日志、路由扩展;空则拒绝,避免匿名连接占坑
if (!StringUtils.hasText(cts.getServerId())) {
stcBuilder.setOk(false).setMessage("server_id required");
return;
}
stcBuilder.setOk(true).setMessage("ok " + cts.getServerId());
}
}
@@ -0,0 +1,17 @@
package com.huangzj.cross.config;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* 跨服侧:区服注册时可选的 token 校验(为空则不校验)。
*/
@Getter
@Setter
@ConfigurationProperties(prefix = "cross.link")
public class CrossLinkAcceptProperties {
/** 非空时须与区服 {@code cross.token} 一致才允许注册。 */
private String expectedToken = "";
}
@@ -0,0 +1,17 @@
package com.huangzj.cross.config;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* 跨服进程自己监听的 TCP 端口,长度前缀 + Protobuf 那一套。
*/
@Getter
@Setter
@ConfigurationProperties(prefix = "cross.tcp")
public class CrossTcpProperties {
/** Netty 监听端口,长度前缀 + protobuf 帧。 */
private int port;
}
@@ -0,0 +1,29 @@
package com.huangzj.cross.net;
import com.huangzj.base.cmd.CommandSource;
import com.huangzj.base.cmd.ProtoRegistry;
import com.huangzj.base.frame.LengthPrefixedProtobuf;
import com.huangzj.base.net.CmdRpcTcpFrameHandler;
import com.huangzj.cross.cmd.CrossGameCommandRegistry;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.socket.SocketChannel;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
/** 跨服 TCPpipeline 上挂帧解码 + CmdRpc 业务 handler。 */
@Component
@RequiredArgsConstructor
public class CrossTcpChannelInitializer extends ChannelInitializer<SocketChannel> {
private final CrossGameCommandRegistry crossGameCommandRegistry;
private final ProtoRegistry protoRegistry;
@Override
protected void initChannel(SocketChannel ch) {
ch.pipeline().addLast(LengthPrefixedProtobuf.newTcpFrameDecoder());
ch.pipeline()
.addLast(
new CmdRpcTcpFrameHandler(
crossGameCommandRegistry, null, CommandSource.CROSS_TCP, protoRegistry));
}
}
@@ -0,0 +1,95 @@
package com.huangzj.cross.net;
import com.huangzj.cross.config.CrossTcpProperties;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.Channel;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import jakarta.annotation.PreDestroy;
import java.net.BindException;
import java.util.concurrent.TimeUnit;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
/**
* 跨服侧的 TCP 入口:Spring 起来之后绑端口,应用关掉时把线程组也收干净。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class CrossTcpServer {
private final CrossTcpProperties properties;
private final CrossTcpChannelInitializer crossTcpChannelInitializer;
private EventLoopGroup bossGroup;
private EventLoopGroup workerGroup;
private Channel serverChannel;
/** 等 Spring 容器就绪再绑端口,避免 Bean 还没齐就开始接连接。 */
@EventListener(ApplicationReadyEvent.class)
public void start() throws InterruptedException {
bossGroup = new NioEventLoopGroup(1);
workerGroup = new NioEventLoopGroup();
try {
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.childHandler(crossTcpChannelInitializer);
serverChannel = b.bind(properties.getPort()).sync().channel();
log.info("Cross TCP {}", properties.getPort());
} catch (Exception e) {
if (findBindException(e) != null) {
shutdownGroupsOnly();
int port = properties.getPort();
throw new IllegalStateException(
"跨服 TCP 端口 "
+ port
+ " 已被占用(Address already in use)。请关闭占用该端口的进程,或在 application.yml 里修改 cross.tcp.port。"
+ " Windows 可查:netstat -ano | findstr :" + port,
e);
}
shutdownGroupsOnly();
throw e;
}
}
private static BindException findBindException(Throwable t) {
while (t != null) {
if (t instanceof BindException) {
return (BindException) t;
}
t = t.getCause();
}
return null;
}
/** 绑定失败时尚未 assign serverChannel,只收掉线程组,避免泄漏。 */
private void shutdownGroupsOnly() {
if (bossGroup != null) {
bossGroup.shutdownGracefully(0, 1, TimeUnit.SECONDS).syncUninterruptibly();
bossGroup = null;
}
if (workerGroup != null) {
workerGroup.shutdownGracefully(0, 1, TimeUnit.SECONDS).syncUninterruptibly();
workerGroup = null;
}
}
/** 进程退出时把 channel 和线程组都关掉,别留一堆 Netty 线程 hanging。 */
@PreDestroy
public void stop() throws InterruptedException {
if (serverChannel != null) {
serverChannel.close().syncUninterruptibly();
}
if (bossGroup != null) {
bossGroup.shutdownGracefully(0, 1, TimeUnit.SECONDS).syncUninterruptibly();
}
if (workerGroup != null) {
workerGroup.shutdownGracefully(0, 1, TimeUnit.SECONDS).syncUninterruptibly();
}
}
}
@@ -0,0 +1,10 @@
# 开发环境
cross:
tcp:
port: 9200
link:
expected-token: ""
app:
logging:
dir: logs/CrossModule
@@ -0,0 +1,10 @@
# 部署环境:监听端口、注册校验 token、日志目录建议走环境变量
cross:
tcp:
port: ${CROSS_TCP_PORT:9200}
link:
expected-token: ${CROSS_LINK_EXPECTED_TOKEN:}
app:
logging:
dir: ${APP_LOG_DIR:/var/log/comm-framework/cross}
@@ -0,0 +1,5 @@
spring:
application:
name: cross-module
profiles:
active: ${SPRING_PROFILES_ACTIVE:dev}
@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<!-- 目录由本模块 application.yml 的 app.logging.dir 指定,与 ServerModule 互不干扰 -->
<springProperty name="LOG_DIR" scope="context" source="app.logging.dir" defaultValue="logs/CrossModule"/>
<include resource="org/springframework/boot/logging/logback/defaults.xml"/>
<include resource="org/springframework/boot/logging/logback/console-appender.xml"/>
<!-- INFO、WARN:不含 ERROR,便于日常浏览 -->
<appender name="FILE_INFO" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_DIR}/info.log</file>
<encoder>
<pattern>${FILE_LOG_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>INFO</level>
</filter>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>ERROR</level>
<onMatch>DENY</onMatch>
<onMismatch>NEUTRAL</onMismatch>
</filter>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_DIR}/info.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
<maxFileSize>10MB</maxFileSize>
<maxHistory>14</maxHistory>
<totalSizeCap>512MB</totalSizeCap>
</rollingPolicy>
</appender>
<!-- ERROR 单独落盘,告警/排障只盯这个文件即可 -->
<appender name="FILE_ERROR" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_DIR}/error.log</file>
<encoder>
<pattern>${FILE_LOG_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>ERROR</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_DIR}/error.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
<maxFileSize>10MB</maxFileSize>
<maxHistory>30</maxHistory>
<totalSizeCap>512MB</totalSizeCap>
</rollingPolicy>
</appender>
<!-- DEBUG 仅进控制台(若把 root 调到 DEBUG);需要落盘时在 application.yml 里单独配 logger + appender -->
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="FILE_INFO"/>
<appender-ref ref="FILE_ERROR"/>
</root>
</configuration>
@@ -0,0 +1,10 @@
# 开发环境
cross:
tcp:
port: 9200
link:
expected-token: ""
app:
logging:
dir: logs/CrossModule
@@ -0,0 +1,10 @@
# 部署环境:监听端口、注册校验 token、日志目录建议走环境变量
cross:
tcp:
port: ${CROSS_TCP_PORT:9200}
link:
expected-token: ${CROSS_LINK_EXPECTED_TOKEN:}
app:
logging:
dir: ${APP_LOG_DIR:/var/log/comm-framework/cross}
@@ -0,0 +1,5 @@
spring:
application:
name: cross-module
profiles:
active: ${SPRING_PROFILES_ACTIVE:dev}
@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<!-- 目录由本模块 application.yml 的 app.logging.dir 指定,与 ServerModule 互不干扰 -->
<springProperty name="LOG_DIR" scope="context" source="app.logging.dir" defaultValue="logs/CrossModule"/>
<include resource="org/springframework/boot/logging/logback/defaults.xml"/>
<include resource="org/springframework/boot/logging/logback/console-appender.xml"/>
<!-- INFO、WARN:不含 ERROR,便于日常浏览 -->
<appender name="FILE_INFO" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_DIR}/info.log</file>
<encoder>
<pattern>${FILE_LOG_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>INFO</level>
</filter>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>ERROR</level>
<onMatch>DENY</onMatch>
<onMismatch>NEUTRAL</onMismatch>
</filter>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_DIR}/info.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
<maxFileSize>10MB</maxFileSize>
<maxHistory>14</maxHistory>
<totalSizeCap>512MB</totalSizeCap>
</rollingPolicy>
</appender>
<!-- ERROR 单独落盘,告警/排障只盯这个文件即可 -->
<appender name="FILE_ERROR" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_DIR}/error.log</file>
<encoder>
<pattern>${FILE_LOG_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>ERROR</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_DIR}/error.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
<maxFileSize>10MB</maxFileSize>
<maxHistory>30</maxHistory>
<totalSizeCap>512MB</totalSizeCap>
</rollingPolicy>
</appender>
<!-- DEBUG 仅进控制台(若把 root 调到 DEBUG);需要落盘时在 application.yml 里单独配 logger + appender -->
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="FILE_INFO"/>
<appender-ref ref="FILE_ERROR"/>
</root>
</configuration>
@@ -0,0 +1,7 @@
com\huangzj\cross\config\CrossTcpProperties.class
com\huangzj\cross\net\CrossTcpServer$1.class
com\huangzj\cross\cmd\CrossGameCommandRegistry.class
com\huangzj\cross\CrossModuleApplication.class
com\huangzj\cross\net\CrossTcpCommandHandler.class
com\huangzj\cross\net\CrossTcpServer.class
com\huangzj\cross\cmd\CrossForwardCommand.class
@@ -0,0 +1,6 @@
F:\coding\comm-framework\CrossModule\src\main\java\com\huangzj\cross\cmd\CrossGameCommandRegistry.java
F:\coding\comm-framework\CrossModule\src\main\java\com\huangzj\cross\config\CrossTcpProperties.java
F:\coding\comm-framework\CrossModule\src\main\java\com\huangzj\cross\CrossModuleApplication.java
F:\coding\comm-framework\CrossModule\src\main\java\com\huangzj\cross\net\CrossTcpServer.java
F:\coding\comm-framework\CrossModule\src\main\java\com\huangzj\cross\cmd\CrossForwardCommand.java
F:\coding\comm-framework\CrossModule\src\main\java\com\huangzj\cross\net\CrossTcpCommandHandler.java
+84
View File
@@ -0,0 +1,84 @@
# ProtoBufModule
本模块承载 **RPC 与业务消息的二进制契约**:所有 TCP 链路上传输的业务体都通过此处定义的 `protobuf` 生成 Java 类(包名 `com.huangzj.cmd.gen`)。
## 职责
- 维护 `src/main/protobuf` 下的 `.proto` 源文件。
- Maven `protobuf-maven-plugin``generate-sources` 阶段生成 Java 源码到 `target/generated-sources/protobuf/java`
- **其它模块**BaseModule、ServerModule、CrossModule)仅依赖本模块打出的 jar,不重复拷贝 `.proto`
## 目录结构(源文件)
```text
src/main/protobuf/
└── serverProto/
├── cmd_rpc.proto # CmdRpcEnvelope、各 CTS/STC、Method 枚举
└── pb_service.proto # PbServiceService_1 ~ Service_4
```
> 历史上独立的 `CrossProto` 已移除;跨服与区服共用 **`demo.cmd`** 包下的消息,通过 **service/method + CommandSource** 在 Java 侧区分语义。
## 网络层核心消息:`CmdRpcEnvelope`
```protobuf
message CmdRpcEnvelope {
uint32 service_id = 1;
uint32 method_id = 2;
bytes payload = 3; // 具体 CTS 的序列化字节
int32 cross_ret = 4; // 预留/透传
uint32 link_seq = 5; // 区服↔跨服长连接多路复用序号(客户端 TCP 可为 0)
}
```
### `link_seq` 的作用(与网络实现配合)
```mermaid
sequenceDiagram
participant Z as 区服 CrossTcpClient
participant X as 跨服 TCP
Note over Z,X: 单条 TCP 上可能同时存在多帧在途
Z->>X: 请求 A (link_seq=101)
Z->>X: 请求 B (link_seq=102)
X-->>Z: 响应 B (link_seq=102)
X-->>Z: 响应 A (link_seq=101)
```
- 区服在 **`CrossTcpClient`** 内为每次 `roundTrip` / 注册 / 心跳分配 **非 0**`link_seq`
- 跨服命令通过 **`AbstractTypedPbCommand`** 回包时 **原样带回** 请求信封中的 `link_seq`,以便客户端把响应与 `CompletableFuture` 对应。
## 服务与方法枚举
| PbService | 用途 |
|-----------|------|
| Service_1 | 路径 1 示例(Echo |
| Service_2 | 跨服业务 Method2`CrossForward*` |
| Service_3 | 路径 3 区服对客户端暴露的中转接口 |
| Service_4 | **链路控制**Register、Heartbeat(仅区服→跨服长连接上使用) |
`Service4_Method``Register``Heartbeat`,载荷分别为 `CrossLinkRegisterCts/Stc``CrossLinkHeartbeatCts/Stc`
## 业务消息(与路径对应)
- **路径 1**`CmdEchoCts` / `CmdEchoStc`
- **路径 2(客户端侧)**`CrossProxyCts` / `CrossProxyStc`(区服内会转为 `CrossForward*` 再发往跨服)。
- **路径 3**:客户端 payload 使用 **`CrossForwardCts`** 形态(与跨服 Service_2/Method2 一致),信封为 Service_3/Method3。
- **跨服 Service_2/Method2**`CrossForwardCts` / `CrossForwardStc`
## 生成代码
```bash
cd ProtoBufModule
mvn -q generate-sources
# 或使用 genpb.batWindows,会探测 Maven
```
生成物加入 `ProtoBufModule` jar,依赖方编译时自动可见。
## 扩展约定
新增 RPC 时建议:
1.`pb_service.proto` 增加 `PbService` 枚举值(若新服务号)。
2.`cmd_rpc.proto` 增加 `ServiceX_Method` 与 CTS/STC `message`
3. 执行 `generate-sources` 后,在 **BaseModule** 侧通过 `@Cmd` + `AbstractTypedPbCommand``GameCommand` 实现逻辑,并由 **`ProtoRegistryAutoConfigurer`** 自动注册 `ProtoInfo`(强类型命令)。
+21
View File
@@ -0,0 +1,21 @@
@echo off
setlocal
cd /d "%~dp0"
set "POM=%~dp0pom.xml"
set "MVN_CMD="
if defined MAVEN_HOME if exist "%MAVEN_HOME%\bin\mvn.cmd" set "MVN_CMD=%MAVEN_HOME%\bin\mvn.cmd"
if not defined MVN_CMD where mvn >nul 2>&1 && set "MVN_CMD=mvn"
if not defined MVN_CMD for %%I in (
"C:\Program Files\JetBrains\IntelliJ IDEA 2024.1.7\plugins\maven\lib\maven3\bin\mvn.cmd"
"C:\Program Files\JetBrains\IntelliJ IDEA 2023.3.8\plugins\maven\lib\maven3\bin\mvn.cmd"
) do if exist %%~I set "MVN_CMD=%%~I"
if not defined MVN_CMD (
echo Set MAVEN_HOME or add mvn to PATH.
exit /b 1
)
call "%MVN_CMD%" -q -f "%POM%" generate-sources
set "ERR=%ERRORLEVEL%"
if not "%ERR%"=="0" exit /b %ERR%
echo Java sources: %~dp0target\generated-sources\protobuf\java
endlocal
exit /b 0
+72
View File
@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>huangzj</groupId>
<artifactId>comm-framework</artifactId>
<version>0.0.1-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>ProtoBufModule</artifactId>
<packaging>jar</packaging>
<name>ProtoBufModule</name>
<dependencies>
<dependency>
<groupId>com.google.protobuf</groupId>
<artifactId>protobuf-java</artifactId>
<version>${protobuf.version}</version>
</dependency>
</dependencies>
<build>
<extensions>
<extension>
<groupId>kr.motd.maven</groupId>
<artifactId>os-maven-plugin</artifactId>
<version>1.7.1</version>
</extension>
</extensions>
<plugins>
<plugin>
<groupId>org.xolstice.maven.plugins</groupId>
<artifactId>protobuf-maven-plugin</artifactId>
<version>0.6.1</version>
<configuration>
<protoSourceRoot>${project.basedir}/src/main/protobuf</protoSourceRoot>
<protocArtifact>com.google.protobuf:protoc:${protobuf.version}:exe:${os.detected.classifier}</protocArtifact>
</configuration>
<executions>
<execution>
<goals>
<goal>compile</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>build-helper-maven-plugin</artifactId>
<version>3.5.0</version>
<executions>
<execution>
<id>add-sources</id>
<phase>generate-sources</phase>
<goals>
<goal>add-source</goal>
</goals>
<configuration>
<sources>
<source>${project.build.directory}/generated-sources/protobuf/java</source>
</sources>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,93 @@
syntax = "proto3";
package demo.cmd;
option java_package = "com.huangzj.cmd.gen";
option java_multiple_files = true;
enum Service1_Method{
Default1 = 0;
Method1 = 1;
}
enum Service2_Method{
Default2 = 0;
Method2 = 1;
}
enum Service3_Method{
Default3 = 0;
Method3 = 1;
}
/** 区服 ↔ 跨服链路控制(注册、心跳),不经客户端 TCP。 */
enum Service4_Method {
Default4 = 0;
Register = 1;
Heartbeat = 2;
}
service Service1Service {
rpc GetUser (CmdEchoCts) returns (CmdEchoStc);
}
message CmdRpcEnvelope {
uint32 service_id = 1;
uint32 method_id = 2;
bytes payload = 3;
int32 cross_ret = 4;
/** 区服↔跨服链路上对齐请求与响应;客户端 TCP 可保持默认 0。 */
uint32 link_seq = 5;
}
message CmdEchoCts {
string text = 1;
}
message CmdEchoStc {
string text = 1;
int64 server_time_ms = 2;
}
message CrossForwardCts {
string trace = 1;
}
message CrossForwardStc {
string trace = 1;
int64 cross_time_ms = 2;
}
message CrossProxyCts {
string trace = 1;
}
message CrossProxyStc {
string trace = 1;
int64 cross_time_ms = 2;
/** 路径2:区服在跨服返回后再追加的说明,证明「经区服二次处理」。 */
string server_note = 3;
}
message CrossLinkRegisterCts {
string server_id = 1;
string token = 2;
}
message CrossLinkRegisterStc {
bool ok = 1;
string message = 2;
}
message CrossLinkHeartbeatCts {
int64 client_time_ms = 1;
}
message CrossLinkHeartbeatStc {
int64 client_time_ms = 1;
int64 cross_time_ms = 2;
}
@@ -0,0 +1,14 @@
syntax = "proto3";
package demo.cmd;
option java_package = "com.huangzj.cmd.gen";
option java_multiple_files = true;
enum PbService {
Default = 0;
Service_1 = 1;
Service_2 = 2;
Service_3 = 3;
Service_4 = 4;
}
@@ -0,0 +1,17 @@
syntax = "proto3";
package demo.crosslink;
option java_package = "com.huangzj.cross.gen";
option java_multiple_files = true;
message CrossPing {
string trace = 1;
int32 nonce = 2;
}
message CrossPong {
string trace = 1;
int32 nonce = 2;
int64 cross_time_ms = 3;
}

Some files were not shown because too many files have changed in this diff Show More