代码初始化

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
+115
View File
@@ -0,0 +1,115 @@
# ServerModule
**区服进程**:同时提供
- **客户端游戏 TCP**`game.client-tcp.port`,默认示例 **9091**):`CommandSource.Client_TCP`
- **连跨服的出站 TCP 长连接**(`cross.host` / `cross.port`,示例 **9200**):由 **`CrossTcpClient`** 维护,`ProtoRegistry` 中对应逻辑仍按跨服协议,但 **连接发起在区服**
- **HTTP**`server.port`,默认 **8080**):Demo API、探针、静态探测页
## 总体数据流
```mermaid
flowchart TB
subgraph External["外部"]
GAME[游戏客户端 TCP]
BROWSER[浏览器 / HTTP 客户端]
end
subgraph Server["ServerModule"]
CTS[ClientTcpServer :9091]
HTTP[HTTP :8080]
REG[ServerCommandRegistry]
C2[TcpPath2 等命令]
CC[CrossTcpClient 长连接]
end
subgraph Cross["CrossModule :9200"]
XT[Cross TCP]
end
GAME --> CTS --> REG --> C2
BROWSER --> HTTP
C2 --> CC --> XT
```
## 1. 客户端 TCP(主入口)
- **`ClientTcpServer`**`ApplicationReadyEvent` 后绑定端口(`@Order` 较早,保证先于跨服预热)。
- **`ClientTcpChannelInitializer`**:解码器 + **`CmdRpcTcpFrameHandler`**`linkSource = Client_TCP`,注入 **`ServerCommandRegistry`** 与 **`CrossTcpClient`** 作为 **`CrossRpcInvoker`**)。
```mermaid
sequenceDiagram
participant C as 客户端
participant S as ClientTcpServer
participant R as ServerCommandRegistry
participant Cmd as GameCommand
participant X as CrossTcpClient
C->>S: 帧: CmdRpcEnvelope
S->>R: resolve(service, method)
R->>Cmd: execute
opt 路径2/3
Cmd->>X: roundTrip(跨服信封)
X-->>Cmd: 跨服响应
end
Cmd-->>S: 响应 CmdRpcEnvelope
S-->>C: 帧
```
### 三条路径(概念对照)
| 路径 | Client TCP service/method | 是否调跨服 | 说明 |
|------|---------------------------|------------|------|
| 1 | Service_1 / Method1 | 否 | Echo |
| 2 | Service_2 / Method2 | 是 | `CrossProxy*` → 转 `CrossForward*` → 跨服 → 回写 `CrossProxyStc`(含 `server_note` |
| 3 | Service_3 / Method3 | 是 | 信封对客户端为 S3payload 为 `CrossForwardCts`;区服只转发跨服 |
实现类:`TcpPath1LocalEchoCommand``TcpPath2CrossThenServerCommand``TcpPath3CrossRelayCommand`
## 2. 区服 → 跨服:`CrossTcpClient`
- **单连接长连**`ensureConnected()` 建链;断线后 `linkRegistered=false`,下次业务或心跳会重建。
- **注册**`Service_4` + `Register`,载荷 `CrossLinkRegisterCts``cross.server-id``cross.token`)。
- **心跳**`CrossTcpHeartbeatTask``cross.heartbeat-interval-ms`(默认 15000)调用 `sendHeartbeat()``Service_4` + `Heartbeat`)。
- **多路复用**:每次 `roundTrip` 分配 **`link_seq`**,在 `pendingBySeq` 中挂 `CompletableFuture`;入站 **`dispatchInbound`** 按 `link_seq` 完成 Future。
```mermaid
stateDiagram-v2
[*] --> Disconnected
Disconnected --> Connected: ensureConnected
Connected --> Registered: Service_4 Register OK
Registered --> Registered: roundTrip / Heartbeat
Registered --> Disconnected: 断链 / 心跳失败
```
- **预热**`CrossTcpClient.warmupCrossLink()` 监听 `ApplicationReadyEvent``@Order` 较低,在 **`ClientTcpServer` 绑定之后**),提前建链+注册。
配置示例见根目录 `README.md` 或本模块 `application.yml`
- `cross.host``cross.port`(须与 CrossModule `cross.tcp.port` 一致)
- `cross.server-id``cross.token``cross.heartbeat-interval-ms`
## 3. HTTP
- **`/api/ping`**`PingController`):探活。
- **`/api/config/client-tcp`**`ClientTcpConfigController`):返回 `game.client-tcp.port`
- **`/api/demo/*`**Mongo / Redis / 事件 / 模块 tick 等演示接口(见 `DemoApiController`)。
## 4. 其它 Demo(非网络核心)
- MongoDB / Redis / 事件 / 玩家模块 tick 等:见 `com.huangzj.server``web``mongo``redis``module` 等包。
## 5. 本机命令行压测三条 TCP 路径
`com.huangzj.server.demo.TcpPathsDemo`:与客户端相同帧格式,默认 `127.0.0.1:9091`
```bash
java -cp ... com.huangzj.server.demo.TcpPathsDemo [host] [port]
```
## 6. 日志
- `app.logging.dir`:默认 `logs/ServerModule`
- `logback-spring.xml``info.log`INFO+WARN,不含 ERROR)与 `error.log`(仅 ERROR
## 7. 启动顺序建议
1. 启动 **CrossModule**(端口 9200)。
2. 启动 **ServerModule**HTTP 8080,客户端 TCP 9091)。
3.`TcpPathsDemo` 或自建 TCP 客户端按路径 2 组帧,可验证经区服到跨服的全链路。
+63
View File
@@ -0,0 +1,63 @@
<?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>ServerModule</artifactId>
<packaging>jar</packaging>
<name>ServerModule</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-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</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.server.ServerModuleApplication</mainClass>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,26 @@
package com.huangzj.comm.crosslink;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* 区服连跨服 TCP:地址、注册身份、心跳间隔(长连接维护)。
*/
@Getter
@Setter
@ConfigurationProperties(prefix = "cross")
public class CrossLinkProperties {
private String host;
private int port;
/** 向跨服注册的区服实例 ID。 */
private String serverId = "zone-1";
/** 与跨服约定的令牌,跨服 {@code cross.link.expected-token} 非空时会校验。 */
private String token = "";
/** 心跳间隔(毫秒),仅在本进程已注册成功后发送。 */
private int heartbeatIntervalMs = 15000;
}
@@ -0,0 +1,52 @@
package com.huangzj.comm.crosslink;
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;
/**
* 区服进程内:跨服经长连接发回的入站帧处理。
*
* <p>跨服回包目的地是 <strong>区服</strong>(本进程),不是游戏客户端。客户端只连 {@link
* com.huangzj.server.net.ClientTcpServer};经跨服的逻辑由区服命令里 {@link CrossTcpClient#roundTrip} 等对跨服发包,
* 回包仍回到本 Handler,再按 {@link CmdRpcEnvelope#getLinkSeq()} 唤醒区服侧等待线程。
*
* <p>命名说明:相对「游戏客户端」而言这里是 <strong>Server(区服)</strong> 侧;TCP 上区服对跨服仍是主动建连方,故
* 建连类仍叫 {@link CrossTcpClient}。
*/
@Slf4j
@RequiredArgsConstructor
public class CrossServerInboundHandler extends SimpleChannelInboundHandler<ByteBuf> {
private final CrossTcpClient client;
/** 一帧一 CmdRpcEnvelope,按 link_seq 配对区服内 pending 的 roundTrip/注册/心跳。 */
@Override
protected void channelRead0(ChannelHandlerContext ctx, ByteBuf msg) throws Exception {
byte[] raw = NettyBufUtil.readReadableBytes(msg);
CmdRpcEnvelope env = CmdRpcEnvelope.parseFrom(raw);
client.dispatchInbound(env);
}
/**
* 对端正常 FIN:未完成的 roundTrip/注册/心跳必须以异常结束,否则 {@link
* java.util.concurrent.CompletableFuture#get} 会一直挂起。
*/
@Override
public void channelInactive(ChannelHandlerContext ctx) {
log.warn("Cross inbound channel inactive remote={}", ctx.channel().remoteAddress());
client.failAllPending(new java.io.IOException("cross inactive"));
}
/** 半包、PB 损坏、对端 RST 等:先失败所有 pending,再关 channel 防止重复读脏数据。 */
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
log.error("Cross inbound error remote={}", ctx.channel().remoteAddress(), cause);
client.failAllPending(cause);
ctx.close();
}
}
@@ -0,0 +1,266 @@
package com.huangzj.comm.crosslink;
import com.huangzj.base.cmd.CrossRpcInvoker;
import com.huangzj.base.frame.LengthPrefixedProtobuf;
import com.huangzj.cmd.gen.CmdRpcEnvelope;
import com.huangzj.cmd.gen.CrossLinkHeartbeatCts;
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 io.netty.bootstrap.Bootstrap;
import io.netty.channel.Channel;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;
import jakarta.annotation.PreDestroy;
import java.io.IOException;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
/**
* 区服 → 跨服:单条长连接、启动后注册、定时心跳;业务 {@link #roundTrip} 通过 {@link CmdRpcEnvelope#getLinkSeq()} 多路复用。
*
* <p>跨服回包进入 <strong>区服</strong> 进程,由 {@link CrossServerInboundHandler} 解析后 {@link #dispatchInbound};不直接写给游戏客户端。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class CrossTcpClient implements CrossRpcInvoker {
private final CrossLinkProperties properties;
private final EventLoopGroup group = new NioEventLoopGroup(1);
private final ConcurrentHashMap<Integer, CompletableFuture<CmdRpcEnvelope>> pendingBySeq = new ConcurrentHashMap<>();
private final AtomicInteger seqGen = new AtomicInteger(1);
private final Object connectLock = new Object();
private volatile Channel channel;
private volatile boolean linkRegistered;
/** 生成非 0 序号:0 在 proto3 里表示「未设置」,不能用作 link_seq 去匹配 pending。 */
private static int nonZeroSeq(AtomicInteger gen) {
int v = gen.incrementAndGet();
if (v == 0) {
v = gen.incrementAndGet();
}
return v;
}
private int nextSeq() {
return nonZeroSeq(seqGen);
}
/**
* 建链(幂等)。与注册共用同一把锁,避免「边连边注册」交错。 新 TCP 建立后必须重新走注册,故先把
* linkRegistered 置 false。
*/
private void ensureConnected() throws InterruptedException {
synchronized (connectLock) {
if (channel != null && channel.isActive()) {
return;
}
// 旧连接已不可用或首次连接:后续必须重新 Service_4 Register
linkRegistered = false;
log.info("Connecting cross TCP (long link) {}:{}", properties.getHost(), properties.getPort());
CrossServerInboundHandler inbound = new CrossServerInboundHandler(this);
Bootstrap b = new Bootstrap();
b.group(group)
.channel(NioSocketChannel.class)
.handler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
// 与跨服约定一致:4 字节大端长度 + CmdRpcEnvelope
ch.pipeline().addLast(LengthPrefixedProtobuf.newTcpFrameDecoder());
ch.pipeline().addLast(inbound);
}
});
ChannelFuture f = b.connect(properties.getHost(), properties.getPort()).sync();
channel = f.channel();
log.info("Cross TCP connected {}", channel.remoteAddress());
// 对端或本端关闭时:清 channel、作废注册态,并把仍在等的 Future 全部失败掉,防止永久阻塞
channel
.closeFuture()
.addListener(cf -> {
channel = null;
linkRegistered = false;
failAllPending(new IOException("cross TCP disconnected"));
log.warn("Cross TCP channel closed");
});
}
}
/**
* 双检锁:无锁快速路径(已注册)+ 与建链同锁的注册临界区,保证同一时刻只有一条注册在进行。 注册响应同样带
* link_seq,由 {@link #dispatchInbound} 完成 Futurefinally 再 remove 一次防止超时等异常泄漏 map 条目。
*/
private void ensureRegistered() throws Exception {
if (linkRegistered) {
return;
}
synchronized (connectLock) {
if (linkRegistered) {
return;
}
if (channel == null || !channel.isActive()) {
throw new IllegalStateException("cross channel not active");
}
CrossLinkRegisterCts cts =
CrossLinkRegisterCts.newBuilder()
.setServerId(properties.getServerId() == null ? "" : properties.getServerId())
.setToken(properties.getToken() == null ? "" : properties.getToken())
.build();
int id = nextSeq();
CompletableFuture<CmdRpcEnvelope> f = new CompletableFuture<>();
pendingBySeq.put(id, f);
try {
CmdRpcEnvelope env = CmdRpcEnvelope.newBuilder()
.setServiceId(PbService.Service_4_VALUE)
.setMethodId(Service4_Method.Register_VALUE)
.setPayload(cts.toByteString())
.setLinkSeq(id)
.build();
channel.writeAndFlush(LengthPrefixedProtobuf.encodeMessage(channel.alloc(), env));
// 注册允许稍长等待:跨服进程若刚启动,首次解析/建表可能略慢
CmdRpcEnvelope res = f.get(10, TimeUnit.SECONDS);
CrossLinkRegisterStc st = CrossLinkRegisterStc.parseFrom(res.getPayload());
if (!st.getOk()) {
linkRegistered = false;
throw new IllegalStateException("cross register rejected: " + st.getMessage());
}
linkRegistered = true;
log.info("Cross link registered ok serverId={}", properties.getServerId());
} finally {
pendingBySeq.remove(id, f);
}
}
}
/** Spring 就绪后主动建链并完成注册,缩短首包 RTT(在区服客户端 TCP 绑定之后执行)。 */
@Order(Ordered.LOWEST_PRECEDENCE)
@EventListener(ApplicationReadyEvent.class)
public void warmupCrossLink() {
try {
// 失败不阻止 Spring 启动:首条业务 roundTrip 或心跳会再次尝试建链+注册
ensureConnected();
ensureRegistered();
} catch (Exception e) {
log.warn("Cross link warmup failed (is CrossModule up?): {}", e.toString());
}
}
/**
* 发一帧等一帧;自动分配 {@link CmdRpcEnvelope.Builder#setLinkSeq(int)},与跨服回包对齐。
*/
@Override
public CmdRpcEnvelope roundTrip(CmdRpcEnvelope request) throws Exception {
// 业务调用前保证链路可用;link_seq 由本客户端统一分配,覆盖调用方传入值(避免冲突)
ensureConnected();
ensureRegistered();
int id = nextSeq();
CompletableFuture<CmdRpcEnvelope> f = new CompletableFuture<>();
pendingBySeq.put(id, f);
try {
CmdRpcEnvelope out = request.toBuilder().setLinkSeq(id).build();
Channel ch = channel;
if (ch == null || !ch.isActive()) {
throw new IOException("cross channel inactive");
}
ch.writeAndFlush(LengthPrefixedProtobuf.encodeMessage(ch.alloc(), out));
// 回包在 IO 线程里 complete,当前线程阻塞等待;超时由调用方感知
return f.get(5, TimeUnit.SECONDS);
} finally {
// 正常时 dispatchInbound 已按 seq remove;此处兜底防超时/异常导致 map 堆积
pendingBySeq.remove(id, f);
}
}
/**
* 供调度器定时调用:与业务 roundTrip 共用 pending 表与 link_seq 规则。 失败时主动关连接,迫使后续走
* ensureConnected 重建,避免半开连接一直占着注册态。
*/
public void sendHeartbeat() {
try {
ensureConnected();
ensureRegistered();
int id = nextSeq();
CompletableFuture<CmdRpcEnvelope> f = new CompletableFuture<>();
pendingBySeq.put(id, f);
try {
CrossLinkHeartbeatCts cts =
CrossLinkHeartbeatCts.newBuilder().setClientTimeMs(System.currentTimeMillis()).build();
CmdRpcEnvelope env = CmdRpcEnvelope.newBuilder()
.setServiceId(PbService.Service_4_VALUE)
.setMethodId(Service4_Method.Heartbeat_VALUE)
.setPayload(cts.toByteString())
.setLinkSeq(id)
.build();
Channel ch = channel;
if (ch == null || !ch.isActive()) {
return;
}
ch.writeAndFlush(LengthPrefixedProtobuf.encodeMessage(ch.alloc(), env));
// 心跳要快失败:超时说明对端卡死或网络异常,交给 catch 关连接
f.get(3, TimeUnit.SECONDS);
if (log.isDebugEnabled()) {
log.debug("Cross heartbeat ok");
}
} finally {
pendingBySeq.remove(id, f);
}
} catch (Exception e) {
log.warn("Cross heartbeat failed: {}", e.toString());
linkRegistered = false;
Channel ch = channel;
if (ch != null) {
ch.close();
}
}
}
/** Netty IO 线程调用:按 link_seq 唤醒对应阻塞中的 roundTrip/注册/心跳。 */
public void dispatchInbound(CmdRpcEnvelope env) {
int seq = env.getLinkSeq();
if (seq == 0) {
// 区服↔跨服约定必须带 seq;0 无法配对,多半是异常包或旧版本
log.warn("Cross inbound ignored: link_seq=0 service={} method={}", env.getServiceId(), env.getMethodId());
return;
}
CompletableFuture<CmdRpcEnvelope> cf = pendingBySeq.remove(seq);
if (cf != null) {
cf.complete(env);
} else {
// 重复包、乱序或已超时被 finally 清理后迟到到达
log.warn("Cross inbound no pending for link_seq={}", seq);
}
}
/** 连接断开或 pipeline 异常:让所有等待方立即收到异常,避免业务线程永久卡在 get()。 */
public void failAllPending(Throwable t) {
pendingBySeq.values().forEach(f -> f.completeExceptionally(t));
pendingBySeq.clear();
}
@PreDestroy
public void stop() {
Channel ch = channel;
if (ch != null) {
ch.close();
}
// 与 Netty 惯例一致:尽快结束线程组,不拉长停机时间
group.shutdownGracefully(0, 1, TimeUnit.SECONDS).syncUninterruptibly();
}
}
@@ -0,0 +1,22 @@
package com.huangzj.comm.crosslink;
import lombok.RequiredArgsConstructor;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/** 区服侧跨服长连接心跳(间隔见 {@code cross.heartbeat-interval-ms})。 */
@Component
@RequiredArgsConstructor
public class CrossTcpHeartbeatTask {
private final CrossTcpClient crossTcpClient;
/**
* fixedDelay:上一次执行结束后再隔 interval 执行,避免心跳堆积把 IO 线程打满。 与业务 roundTrip
* 并发时靠 link_seq 区分,无需额外串行化。
*/
@Scheduled(fixedDelayString = "${cross.heartbeat-interval-ms:15000}")
public void heartbeat() {
crossTcpClient.sendHeartbeat();
}
}
@@ -0,0 +1,20 @@
package com.huangzj.server;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* 区服 + 各种 demoHTTP、两条 WebSocket、Mongo/Redis、定时模块 tick 都在这儿。
*/
@SpringBootApplication(scanBasePackages = "com.huangzj")
@ConfigurationPropertiesScan(basePackages = "com.huangzj")
@EnableScheduling
public class ServerModuleApplication {
/** 入口:扫整个 com.huangzj,配置类、Netty、调度器一并起来。 */
public static void main(String[] args) {
SpringApplication.run(ServerModuleApplication.class, args);
}
}
@@ -0,0 +1,16 @@
package com.huangzj.server.cmd;
import com.huangzj.base.cmd.CommandSource;
import com.huangzj.base.spring.SpringGameCommandRegistry;
import java.util.EnumSet;
import org.springframework.context.ApplicationContext;
import org.springframework.stereotype.Component;
/** 区服:仅处理客户端 TCP 上来的 {@link CommandSource#Client_TCP} 命令(三条路径都在此注册)。 */
@Component
public class ServerCommandRegistry extends SpringGameCommandRegistry {
public ServerCommandRegistry(ApplicationContext applicationContext) {
super(applicationContext, EnumSet.of(CommandSource.Client_TCP));
}
}
@@ -0,0 +1,24 @@
package com.huangzj.server.cmd.cmds;
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.CmdEchoCts;
import com.huangzj.cmd.gen.CmdEchoStc;
import com.huangzj.cmd.gen.PbService;
import com.huangzj.cmd.gen.Service1_Method;
import org.springframework.stereotype.Component;
/**
* 路径1:客户端 → 区服,区服本地处理完直接回包(不经跨服)。
*/
@Component
@Cmd(source = CommandSource.Client_TCP, serviceId = PbService.Service_1_VALUE, methodId = Service1_Method.Method1_VALUE)
public class TcpPath1LocalEchoCommand extends AbstractTypedPbCommand<CmdEchoCts, CmdEchoStc.Builder> {
@Override
protected void run(CommandContext ctx, CmdEchoCts cts, CmdEchoStc.Builder stcBuilder) {
stcBuilder.setText(cts.getText()).setServerTimeMs(System.currentTimeMillis());
}
}
@@ -0,0 +1,50 @@
package com.huangzj.server.cmd.cmds;
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.base.cmd.CrossRpcInvoker;
import com.huangzj.cmd.gen.CmdRpcEnvelope;
import com.huangzj.cmd.gen.CrossForwardCts;
import com.huangzj.cmd.gen.CrossForwardStc;
import com.huangzj.cmd.gen.CrossProxyCts;
import com.huangzj.cmd.gen.CrossProxyStc;
import com.huangzj.cmd.gen.PbService;
import com.huangzj.cmd.gen.Service2_Method;
import org.springframework.stereotype.Component;
/**
* 路径2:客户端 → 区服 → 跨服处理 → 回到区服再加工 → 回客户端。
* 跨服侧为 {@link PbService#Service_2} + {@link Service2_Method#Method2}。
*/
@Component
@Cmd(
source = CommandSource.Client_TCP,
serviceId = PbService.Service_2_VALUE,
methodId = Service2_Method.Method2_VALUE)
public class TcpPath2CrossThenServerCommand extends AbstractTypedPbCommand<CrossProxyCts, CrossProxyStc.Builder> {
@Override
protected void run(CommandContext ctx, CrossProxyCts cts, CrossProxyStc.Builder stcBuilder) throws Exception {
CrossRpcInvoker inv = ctx.crossRpc();
if (inv == null) {
throw new IllegalStateException("cross unavailable");
}
// 客户端协议是 CrossProxy*;跨服只认 CrossForward*,这里在区服内做一次「外壳→内壳」转换
CrossForwardCts forward = CrossForwardCts.newBuilder().setTrace(cts.getTrace()).build();
CmdRpcEnvelope toCross = CmdRpcEnvelope.newBuilder()
.setServiceId(PbService.Service_2_VALUE)
.setMethodId(Service2_Method.Method2_VALUE)
.setPayload(forward.toByteString())
.build();
// CrossTcpClient 会为该CmdRpcEnvelope写入 link_seq 并同步等跨服回包
CmdRpcEnvelope back = inv.roundTrip(toCross);
CrossForwardStc cfs = CrossForwardStc.parseFrom(back.getPayload());
// 把跨服结果写回客户端协议的 STC,并追加区服侧字段证明二次处理过
stcBuilder
.setTrace(cfs.getTrace())
.setCrossTimeMs(cfs.getCrossTimeMs())
.setServerNote("path2: server after cross @" + System.currentTimeMillis());
}
}
@@ -0,0 +1,47 @@
package com.huangzj.server.cmd.cmds;
import com.huangzj.base.cmd.Cmd;
import com.huangzj.base.cmd.CommandContext;
import com.huangzj.base.cmd.CommandSource;
import com.huangzj.base.cmd.CrossRpcInvoker;
import com.huangzj.base.cmd.GameCommand;
import com.huangzj.cmd.gen.CmdRpcEnvelope;
import com.huangzj.cmd.gen.PbService;
import com.huangzj.cmd.gen.Service2_Method;
import com.huangzj.cmd.gen.Service3_Method;
import org.springframework.stereotype.Component;
/**
* 路径3:客户端 → 区服 → 跨服处理 → 回到区服只做透明中转。
* 请求体须与跨服 {@link PbService#Service_2} + {@link Service2_Method#Method2} 的 CTS 一致。
*/
@Component
@Cmd(
source = CommandSource.Client_TCP,
serviceId = PbService.Service_3_VALUE,
methodId = Service3_Method.Method3_VALUE)
public class TcpPath3CrossRelayCommand implements GameCommand {
@Override
public CmdRpcEnvelope execute(CommandContext ctx, CmdRpcEnvelope request) throws Exception {
CrossRpcInvoker inv = ctx.crossRpc();
if (inv == null) {
throw new IllegalStateException("cross unavailable");
}
// 路径3:客户端 payload 已是跨服 CTS 形态,区服只改CmdRpcEnvelope上的 service/method 指向跨服接口
CmdRpcEnvelope toCross = CmdRpcEnvelope.newBuilder()
.setServiceId(PbService.Service_2_VALUE)
.setMethodId(Service2_Method.Method2_VALUE)
.setPayload(request.getPayload())
.build();
CmdRpcEnvelope back = inv.roundTrip(toCross);
// 对客户端仍表现为 Service_3/Method3link_seq 沿用入站请求,跨服侧 seq 由 CrossTcpClient 单独维护
return CmdRpcEnvelope.newBuilder()
.setServiceId(request.getServiceId())
.setMethodId(request.getMethodId())
.setCrossRet(request.getCrossRet())
.setLinkSeq(request.getLinkSeq())
.setPayload(back.getPayload())
.build();
}
}
@@ -0,0 +1,43 @@
package com.huangzj.server.config;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* 和业务 demo 相关的配置,都挂在 app.* 下面,省得满天找字符串。
*/
@Getter
@ConfigurationProperties(prefix = "app")
public class AppProperties {
/** Mongo 集合名等。 */
private final Mongo mongo = new Mongo();
/** 定时刷盘间隔。 */
private final Save save = new Save();
/** 假玩家、模块 tick 间隔。 */
private final Module module = new Module();
@Getter
@Setter
public static class Mongo {
/** demo 文档往哪个 collection 塞。 */
private String collection;
}
@Getter
@Setter
public static class Save {
/** 多久触发一次批量/延迟保存(看 DemoMongoService 怎么用)。 */
private long intervalMs;
}
@Getter
@Setter
public static class Module {
/** 宿主 Bean 里的 playerId。 */
private long playerId;
/** 调度器多久叫醒一次模块 tick。 */
private long tickIntervalMs;
}
}
@@ -0,0 +1,14 @@
package com.huangzj.server.config;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
/** 客户端 → 区服的 CmdRpc TCP4 字节长度前缀 + CmdRpcEnvelope)。 */
@Getter
@Setter
@ConfigurationProperties(prefix = "game.client-tcp")
public class ClientTcpProperties {
private int port;
}
@@ -0,0 +1,96 @@
package com.huangzj.server.demo;
import com.huangzj.cmd.gen.CmdEchoCts;
import com.huangzj.cmd.gen.CmdEchoStc;
import com.huangzj.cmd.gen.CmdRpcEnvelope;
import com.huangzj.cmd.gen.CrossForwardCts;
import com.huangzj.cmd.gen.CrossForwardStc;
import com.huangzj.cmd.gen.CrossProxyCts;
import com.huangzj.cmd.gen.CrossProxyStc;
import com.huangzj.cmd.gen.PbService;
import com.huangzj.cmd.gen.Service1_Method;
import com.huangzj.cmd.gen.Service2_Method;
import com.huangzj.cmd.gen.Service3_Method;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.net.Socket;
/**
* 命令行压测三条客户端 TCP 路径:帧格式与区服 {@link com.huangzj.base.net.CmdRpcTcpFrameHandler} 一致。
*
* <p>运行:{@code java -cp ... com.huangzj.server.demo.TcpPathsDemo [host] [port]}
*/
public final class TcpPathsDemo {
private TcpPathsDemo() {}
public static void main(String[] args) throws Exception {
String host = args.length > 0 ? args[0] : "127.0.0.1";
int port = args.length > 1 ? Integer.parseInt(args[1]) : 9091;
try (Socket socket = new Socket(host, port)) {
socket.setSoTimeout(60_000);
System.out.println("connected " + host + ":" + port);
path1(socket);
path2(socket);
path3(socket);
}
}
private static void path1(Socket socket) throws Exception {
CmdRpcEnvelope req = CmdRpcEnvelope.newBuilder()
.setServiceId(PbService.Service_1_VALUE)
.setMethodId(Service1_Method.Method1_VALUE)
.setPayload(CmdEchoCts.newBuilder()
.setText("path1-echo")
.build()
.toByteString())
.build();
CmdRpcEnvelope resp = sendRecv(socket, req);
CmdEchoStc stc = CmdEchoStc.parseFrom(resp.getPayload());
System.out.println("[path1] echo=" + stc.getText() + " serverTime=" + stc.getServerTimeMs());
}
private static void path2(Socket socket) throws Exception {
CmdRpcEnvelope req = CmdRpcEnvelope.newBuilder()
.setServiceId(PbService.Service_2_VALUE)
.setMethodId(Service2_Method.Method2_VALUE)
.setPayload(CrossProxyCts.newBuilder()
.setTrace("path2-trace")
.build()
.toByteString())
.build();
CmdRpcEnvelope resp = sendRecv(socket, req);
CrossProxyStc stc = CrossProxyStc.parseFrom(resp.getPayload());
System.out.println("[path2] trace=" + stc.getTrace()
+ " crossTime=" + stc.getCrossTimeMs()
+ " note=" + stc.getServerNote());
}
private static void path3(Socket socket) throws Exception {
CmdRpcEnvelope req = CmdRpcEnvelope.newBuilder()
.setServiceId(PbService.Service_3_VALUE)
.setMethodId(Service3_Method.Method3_VALUE)
.setPayload(CrossForwardCts.newBuilder()
.setTrace("path3-trace")
.build()
.toByteString())
.build();
CmdRpcEnvelope resp = sendRecv(socket, req);
CrossForwardStc stc = CrossForwardStc.parseFrom(resp.getPayload());
System.out.println("[path3] trace=" + stc.getTrace() + " crossTime=" + stc.getCrossTimeMs());
}
private static CmdRpcEnvelope sendRecv(Socket socket, CmdRpcEnvelope env) throws Exception {
byte[] body = env.toByteArray();
DataOutputStream out = new DataOutputStream(socket.getOutputStream());
out.writeInt(body.length);
out.write(body);
out.flush();
DataInputStream in = new DataInputStream(socket.getInputStream());
int len = in.readInt();
byte[] resp = new byte[len];
in.readFully(resp);
return CmdRpcEnvelope.parseFrom(resp);
}
}
@@ -0,0 +1,22 @@
package com.huangzj.server.event;
import lombok.Getter;
import org.springframework.context.ApplicationEvent;
/**
* 随便造的一个领域事件,演示 Spring 事件总线怎么接。
*/
@Getter
public class AppDomainEvent extends ApplicationEvent {
/** 事件分类,监听方可以按 topic 过滤。 */
private final String topic;
/** 随便塞的字符串负载。 */
private final String body;
public AppDomainEvent(Object source, String topic, String body) {
super(source);
this.topic = topic;
this.body = body;
}
}
@@ -0,0 +1,35 @@
package com.huangzj.server.event;
import java.util.concurrent.atomic.AtomicInteger;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
/**
* 随便听听 {@link AppDomainEvent},记一下次数和最后一条,方便 HTTP 查状态。
*/
@Component
public class DemoEventListener {
private final AtomicInteger receiveCount = new AtomicInteger();
private volatile String lastTopic;
private volatile String lastBody;
@EventListener
public void onDomain(AppDomainEvent event) {
receiveCount.incrementAndGet();
lastTopic = event.getTopic();
lastBody = event.getBody();
}
public int getReceiveCount() {
return receiveCount.get();
}
public String getLastTopic() {
return lastTopic;
}
public String getLastBody() {
return lastBody;
}
}
@@ -0,0 +1,39 @@
package com.huangzj.server.module;
import com.huangzj.base.module.GeneralModule;
import com.huangzj.base.module.ModuleMetricsProvider;
import com.huangzj.base.module.PlayerModule;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.stereotype.Component;
@Component
@PlayerModule(order = 20)
public class DemoAuxModule extends GeneralModule<DemoPlayerHost> implements ModuleMetricsProvider {
private final AtomicLong tickCount = new AtomicLong();
public DemoAuxModule(DemoPlayerHost host) {
super(host);
enableTick();
}
/** 与 SamplePlayerModule 同一个宿主,playerId 一致。 */
@Override
public long getPlayerId() {
return getPlayer().getPlayerId();
}
@Override
protected void tick(long runAt) {
tickCount.incrementAndGet();
}
@Override
public Map<String, Object> metrics() {
Map<String, Object> m = new LinkedHashMap<>();
m.put("auxTickCount", tickCount.get());
return m;
}
}
@@ -0,0 +1,14 @@
package com.huangzj.server.module;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* Demo 里假装有个「玩家宿主」,模块都从这儿拿 playerId。
*/
@Getter
@RequiredArgsConstructor
public class DemoPlayerHost {
private final long playerId;
}
@@ -0,0 +1,14 @@
package com.huangzj.server.module;
import com.huangzj.server.config.AppProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class DemoPlayerHostBean {
@Bean
public DemoPlayerHost demoPlayerHost(AppProperties appProperties) {
return new DemoPlayerHost(appProperties.getModule().getPlayerId());
}
}
@@ -0,0 +1,24 @@
package com.huangzj.server.module;
import com.huangzj.base.spring.module.PlayerModuleRegistry;
import lombok.RequiredArgsConstructor;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* 按配置的节奏喊所有 GeneralModule 跑一遍 tick;手痒也可以走 HTTP 再触发一次。
*/
@Component
@RequiredArgsConstructor
public class ModuleTickScheduler {
private final PlayerModuleRegistry playerModuleRegistry;
/**
* fixedDelay:上一轮跑完再等间隔;占位符缺了就用 8000ms,免得本地没配启动失败。
*/
@Scheduled(fixedDelayString = "${app.module.tick-interval-ms:8000}")
public void scheduledTick() {
playerModuleRegistry.runTickAll(System.currentTimeMillis());
}
}
@@ -0,0 +1,54 @@
package com.huangzj.server.module;
import com.huangzj.base.module.GeneralModule;
import com.huangzj.base.module.ModuleMetricsProvider;
import com.huangzj.base.module.PlayerModule;
import com.huangzj.server.mongo.DemoMongoService;
import com.huangzj.server.mongo.DemoPayloadDoc;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.stereotype.Component;
/**
* Demo 主模块:每次 tick 打个计数,save 时往 Mongo 扔一条,方便你看链路是活的。
*/
@Component
@PlayerModule(order = 10)
public class SamplePlayerModule extends GeneralModule<DemoPlayerHost> implements ModuleMetricsProvider {
private final DemoMongoService mongoService;
private final AtomicLong tickCount = new AtomicLong();
public SamplePlayerModule(DemoPlayerHost host, DemoMongoService mongoService) {
super(host);
this.mongoService = mongoService;
enableTick();
}
@Override
public long getPlayerId() {
return getPlayer().getPlayerId();
}
@Override
protected void tick(long runAt) {
tickCount.incrementAndGet();
}
@Override
public boolean saveData() {
DemoPayloadDoc doc = new DemoPayloadDoc();
doc.setPlayerId(getPlayerId());
doc.setContent("tick-save-" + System.currentTimeMillis());
mongoService.saveImmediate(doc);
return true;
}
@Override
public Map<String, Object> metrics() {
Map<String, Object> m = new LinkedHashMap<>();
m.put("sampleTickCount", tickCount.get());
return m;
}
}
@@ -0,0 +1,54 @@
package com.huangzj.server.mongo;
import com.huangzj.server.config.AppProperties;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Sort;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
@Slf4j
@Service
public class DemoMongoService {
private final MongoTemplate mongoTemplate;
private final AppProperties appProperties;
private volatile DemoPayloadDoc scheduledDoc;
public DemoMongoService(MongoTemplate mongoTemplate, AppProperties appProperties) {
this.mongoTemplate = mongoTemplate;
this.appProperties = appProperties;
}
@Scheduled(fixedDelayString = "${app.save.interval-ms}")
public void flushScheduled() {
DemoPayloadDoc doc = scheduledDoc;
if (doc == null) {
return;
}
scheduledDoc = null;
try {
saveImmediate(doc);
log.info("scheduled mongo save playerId={}", doc.getPlayerId());
} catch (Exception e) {
log.error("scheduled save failed", e);
}
}
public void scheduleSave(DemoPayloadDoc doc) {
this.scheduledDoc = doc;
}
public DemoPayloadDoc saveImmediate(DemoPayloadDoc doc) {
return mongoTemplate.save(doc, appProperties.getMongo().getCollection());
}
public DemoPayloadDoc findLatestByPlayer(long playerId) {
Query q = new Query(Criteria.where("playerId").is(playerId))
.with(Sort.by(Sort.Direction.DESC, "_id"))
.limit(1);
return mongoTemplate.findOne(q, DemoPayloadDoc.class, appProperties.getMongo().getCollection());
}
}
@@ -0,0 +1,18 @@
package com.huangzj.server.mongo;
import lombok.Data;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
/**
* Demo 用的 Mongo 文档,字段很少,主要验证存取链路。
*/
@Data
@Document
public class DemoPayloadDoc {
@Id
private String id;
private long playerId;
private String content;
}
@@ -0,0 +1,35 @@
package com.huangzj.server.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.comm.crosslink.CrossTcpClient;
import com.huangzj.server.cmd.ServerCommandRegistry;
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 ClientTcpChannelInitializer extends ChannelInitializer<SocketChannel> {
private final ServerCommandRegistry serverCommandRegistry;
private final CrossTcpClient crossTcpClient;
private final ProtoRegistry protoRegistry;
@Override
protected void initChannel(SocketChannel ch) {
ch.pipeline().addLast(LengthPrefixedProtobuf.newTcpFrameDecoder());
ch.pipeline()
.addLast(new CmdRpcTcpFrameHandler(
serverCommandRegistry,
crossTcpClient,
CommandSource.Client_TCP,
protoRegistry));
}
}
@@ -0,0 +1,94 @@
package com.huangzj.server.net;
import com.huangzj.server.config.ClientTcpProperties;
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.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
/**
* 客户端 TCP 入口:与跨服侧帧格式一致,业务仅通过 {@code @Cmd}{@link com.huangzj.server.cmd.ServerCommandRegistry})。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ClientTcpServer {
private final ClientTcpProperties properties;
private final ClientTcpChannelInitializer clientTcpChannelInitializer;
private EventLoopGroup bossGroup;
private EventLoopGroup workerGroup;
private Channel serverChannel;
@Order(Ordered.HIGHEST_PRECEDENCE)
@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(clientTcpChannelInitializer);
serverChannel = b.bind(properties.getPort()).sync().channel();
log.info("Client TCP (game) listening on {}", properties.getPort());
} catch (Exception e) {
if (findBindException(e) != null) {
shutdownGroupsOnly();
int port = properties.getPort();
throw new IllegalStateException(
"区服客户端 TCP 端口 "
+ port
+ " 已被占用。请关闭占用进程或在 application.yml 修改 game.client-tcp.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;
}
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;
}
}
@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,609 @@
package com.huangzj.server.redis;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Range;
import org.springframework.data.redis.core.Cursor;
import org.springframework.data.redis.core.ScanOptions;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ZSetOperations;
import org.springframework.stereotype.Component;
/**
* 基于 {@link StringRedisTemplate} 的 Redis 常用数据结构封装,供业务注入使用。
*
* <p>约定:所有 value / member / field 均按 <strong>字符串</strong> 与 Redis 交互;若需存对象,请用 {@link
* #setJson}/{@link #getJson} 或 Hash 字段级 {@link #hashSetJson}/{@link #hashGetJson}Jackson 序列化)。
*
* <p>方法名前缀含义:{@code str} 字符串、{@code hash} 哈希表、{@code list} 列表、{@code set} 集合、{@code zSet}
* 有序集合;未命中 key 时返回值多为 {@code null},与 Spring Data Redis 行为一致。
*/
@Component
@RequiredArgsConstructor
public class RedisUtil {
private final StringRedisTemplate redis;
private final ObjectMapper objectMapper;
// region StringRedis String / Value
/** 写入字符串,无过期时间。 */
public void strSet(String key, String value) {
redis.opsForValue().set(key, value);
}
/** 写入字符串并设置存活时间。 */
public void strSet(String key, String value, Duration ttl) {
redis.opsForValue().set(key, value, ttl);
}
/** 仅当 key 不存在时写入;成功返回 true。 */
public boolean strSetIfAbsent(String key, String value) {
return Boolean.TRUE.equals(redis.opsForValue().setIfAbsent(key, value));
}
/** 仅当 key 不存在时写入并带 TTL;成功返回 true。 */
public boolean strSetIfAbsent(String key, String value, Duration ttl) {
return Boolean.TRUE.equals(redis.opsForValue().setIfAbsent(key, value, ttl));
}
/** 读取字符串;不存在返回 {@code null}。 */
public String strGet(String key) {
return redis.opsForValue().get(key);
}
/** 原子地读出旧值并写入新值。 */
public String strGetAndSet(String key, String value) {
return redis.opsForValue().getAndSet(key, value);
}
/** 将数字字符串自增 1;key 不存在时先按 0 再增。 */
public Long strIncrement(String key) {
return redis.opsForValue().increment(key);
}
/** 将数字字符串自增 {@code delta}。 */
public Long strIncrement(String key, long delta) {
return redis.opsForValue().increment(key, delta);
}
/** 将数字字符串自减 1。 */
public Long strDecrement(String key) {
return redis.opsForValue().decrement(key);
}
/** 将数字字符串自减 {@code delta}。 */
public Long strDecrement(String key, long delta) {
return redis.opsForValue().decrement(key, delta);
}
/** 设置位图中某一位;对应 Redis SETBIT。 */
public Boolean strSetBit(String key, long offset, boolean value) {
return redis.opsForValue().setBit(key, offset, value);
}
/** 读取位图中某一位;对应 Redis GETBIT。 */
public Boolean strGetBit(String key, long offset) {
return redis.opsForValue().getBit(key, offset);
}
/**
* 将对象序列化为 JSON 字符串后写入;序列化失败抛出 {@link IllegalStateException}。
*/
public void setJson(String key, Object value) {
try {
strSet(key, objectMapper.writeValueAsString(value));
} catch (JsonProcessingException e) {
throw new IllegalStateException("Redis JSON encode: " + key, e);
}
}
/**
* 将对象序列化为 JSON 字符串后写入并带 TTL。
*
* @throws IllegalStateException JSON 序列化失败
*/
public void setJson(String key, Object value, Duration ttl) {
try {
strSet(key, objectMapper.writeValueAsString(value), ttl);
} catch (JsonProcessingException e) {
throw new IllegalStateException("Redis JSON encode: " + key, e);
}
}
/**
* 读出 JSON 并反序列化为指定类型;key 不存在返回 {@code null}。
*
* @throws IllegalStateException 反序列化失败
*/
public <T> T getJson(String key, Class<T> type) {
String raw = strGet(key);
if (raw == null) {
return null;
}
try {
return objectMapper.readValue(raw, type);
} catch (JsonProcessingException e) {
throw new IllegalStateException("Redis JSON decode: " + key, e);
}
}
/**
* 读出 JSON 并反序列化(支持泛型,如 {@code List<String>});key 不存在返回 {@code null}。
*
* @throws IllegalStateException 反序列化失败
*/
public <T> T getJson(String key, TypeReference<T> typeRef) {
String raw = strGet(key);
if (raw == null) {
return null;
}
try {
return objectMapper.readValue(raw, typeRef);
} catch (JsonProcessingException e) {
throw new IllegalStateException("Redis JSON decode: " + key, e);
}
}
// endregion
// region Hash
/** 哈希表设置单个 field。 */
public void hashSet(String key, String field, String value) {
redis.opsForHash().put(key, field, value);
}
/** 哈希表批量写入;{@code map} 为空则不做任何事。 */
public void hashSetAll(String key, Map<String, String> map) {
if (map == null || map.isEmpty()) {
return;
}
redis.opsForHash().putAll(key, map);
}
/** 读取单个 field;不存在返回 {@code null}。 */
public String hashGet(String key, String field) {
Object v = redis.opsForHash().get(key, field);
return v == null ? null : String.valueOf(v);
}
/**
* 批量读 field,返回列表顺序与入参 {@code fields} 一致;某 field 不存在对应位置为 {@code null}。
*/
public List<String> hashMultiGet(String key, Collection<String> fields) {
if (fields == null || fields.isEmpty()) {
return List.of();
}
List<Object> raw = redis.opsForHash().multiGet(key, new ArrayList<>(fields));
List<String> out = new ArrayList<>(raw.size());
for (Object o : raw) {
out.add(o == null ? null : String.valueOf(o));
}
return out;
}
/** 返回哈希表全部 field-value;空表返回空 Map(非 {@code null})。 */
public Map<String, String> hashGetAll(String key) {
Map<Object, Object> entries = redis.opsForHash().entries(key);
if (entries.isEmpty()) {
return Collections.emptyMap();
}
Map<String, String> out = new LinkedHashMap<>(entries.size());
entries.forEach((k, v) -> out.put(String.valueOf(k), v == null ? null : String.valueOf(v)));
return out;
}
/** 删除一个或多个 field,返回实际删除个数。 */
public Long hashDelete(String key, Object... fields) {
if (fields == null || fields.length == 0) {
return 0L;
}
return redis.opsForHash().delete(key, fields);
}
/** field 是否存在。 */
public Boolean hashHasKey(String key, String field) {
return redis.opsForHash().hasKey(key, field);
}
/** 哈希表 field 个数。 */
public Long hashSize(String key) {
return redis.opsForHash().size(key);
}
/** 将 field 上数字字符串按 long 自增。 */
public Long hashIncrement(String key, String field, long delta) {
return redis.opsForHash().increment(key, field, delta);
}
/** 将 field 上数字按 double 自增(可存浮点计数)。 */
public Double hashIncrement(String key, String field, double delta) {
return redis.opsForHash().increment(key, field, delta);
}
/** 将对象 JSON 化后写入指定 field。 */
public void hashSetJson(String key, String field, Object value) {
try {
hashSet(key, field, objectMapper.writeValueAsString(value));
} catch (JsonProcessingException e) {
throw new IllegalStateException("Redis hash JSON encode: " + key + "#" + field, e);
}
}
/** 读出 field 的 JSON 并反序列化;field 不存在返回 {@code null}。 */
public <T> T hashGetJson(String key, String field, Class<T> type) {
String raw = hashGet(key, field);
if (raw == null) {
return null;
}
try {
return objectMapper.readValue(raw, type);
} catch (JsonProcessingException e) {
throw new IllegalStateException("Redis hash JSON decode: " + key + "#" + field, e);
}
}
// endregion
// region List
/** 从列表左侧压入一个元素,返回列表当前长度。 */
public Long listLeftPush(String key, String value) {
return redis.opsForList().leftPush(key, value);
}
/** 从左侧一次压入多个元素。 */
public Long listLeftPushAll(String key, String... values) {
if (values == null || values.length == 0) {
return 0L;
}
return redis.opsForList().leftPushAll(key, values);
}
/** 从列表右侧压入一个元素。 */
public Long listRightPush(String key, String value) {
return redis.opsForList().rightPush(key, value);
}
/** 从右侧一次压入多个元素。 */
public Long listRightPushAll(String key, String... values) {
if (values == null || values.length == 0) {
return 0L;
}
return redis.opsForList().rightPushAll(key, values);
}
/** 非阻塞:从左侧弹出一个元素。 */
public String listLeftPop(String key) {
return redis.opsForList().leftPop(key);
}
/** 非阻塞:从右侧弹出一个元素。 */
public String listRightPop(String key) {
return redis.opsForList().rightPop(key);
}
/**
* 阻塞版左侧弹出:在 {@code timeout} 内等待直到有元素或超时;超时返回 {@code null}。
*/
public String listLeftPop(String key, Duration timeout) {
return redis.opsForList().leftPop(key, timeout);
}
/**
* 阻塞版右侧弹出。
*/
public String listRightPop(String key, Duration timeout) {
return redis.opsForList().rightPop(key, timeout);
}
/**
* 按闭区间下标取子列表;{@code end} 可为 -1 表示到表尾。
*/
public List<String> listRange(String key, long start, long end) {
return redis.opsForList().range(key, start, end);
}
/** 列表长度。 */
public Long listSize(String key) {
return redis.opsForList().size(key);
}
/** 按下标取值;越界返回 {@code null}。 */
public String listIndex(String key, long index) {
return redis.opsForList().index(key, index);
}
/** 按下标覆盖元素。 */
public void listSet(String key, long index, String value) {
redis.opsForList().set(key, index, value);
}
/** 裁剪列表,只保留闭区间 {@code [start, end]} 内元素。 */
public void listTrim(String key, long start, long end) {
redis.opsForList().trim(key, start, end);
}
/**
* 删除与 {@code value} 相等的元素;{@code count > 0} 从左删 {@code count} 个,{@code count < 0} 从右删,{@code
* count = 0} 删除全部相等项。
*/
public Long listRemove(String key, long count, String value) {
return redis.opsForList().remove(key, count, value);
}
// endregion
// region Set
/** 向集合添加成员,返回新增个数(已存在的不计)。 */
public Long setAdd(String key, String... members) {
if (members == null || members.length == 0) {
return 0L;
}
return redis.opsForSet().add(key, members);
}
/** 从集合移除成员,返回实际移除个数。 */
public Long setRemove(String key, Object... members) {
if (members == null || members.length == 0) {
return 0L;
}
return redis.opsForSet().remove(key, members);
}
/** 返回集合全部成员;空集合返回空 Set(非 {@code null})。 */
public Set<String> setMembers(String key) {
Set<String> m = redis.opsForSet().members(key);
return m == null ? Collections.emptySet() : m;
}
/** 判断 member 是否在集合中。 */
public Boolean setIsMember(String key, String member) {
return redis.opsForSet().isMember(key, member);
}
/** 集合元素个数。 */
public Long setSize(String key) {
return redis.opsForSet().size(key);
}
/** 计算 {@code key} 与 {@code otherKey} 两个集合的交集。 */
public Set<String> setIntersect(String key, String otherKey) {
Set<String> s = redis.opsForSet().intersect(key, List.of(otherKey));
return s == null ? Collections.emptySet() : s;
}
/** 计算两个集合的并集。 */
public Set<String> setUnion(String key, String otherKey) {
Set<String> s = redis.opsForSet().union(key, List.of(otherKey));
return s == null ? Collections.emptySet() : s;
}
/** 计算两个集合的差集(存在于 key 且不存在于 otherKey)。 */
public Set<String> setDifference(String key, String otherKey) {
Set<String> s = redis.opsForSet().difference(key, List.of(otherKey));
return s == null ? Collections.emptySet() : s;
}
// endregion
// region Sorted setZSet
/** 添加成员及其分数;新增返回 true,仅更新分数可能返回 false(与 Redis 版本/实现有关)。 */
public Boolean zSetAdd(String key, String member, double score) {
return redis.opsForZSet().add(key, member, score);
}
/** 按成员删除,返回删除个数。 */
public Long zSetRemove(String key, Object... members) {
if (members == null || members.length == 0) {
return 0L;
}
return redis.opsForZSet().remove(key, members);
}
/** 将成员分数增加 {@code delta},返回新分数。 */
public Double zSetIncrementScore(String key, String member, double delta) {
return redis.opsForZSet().incrementScore(key, member, delta);
}
/** 有序集合成员个数(基数)。 */
public Long zSetCard(String key) {
return redis.opsForZSet().zCard(key);
}
/** 分数在闭区间 {@code [min, max]} 内的成员数量。 */
public Long zSetCount(String key, double min, double max) {
return redis.opsForZSet().count(key, min, max);
}
/** 查询成员分数;不存在返回 {@code null}。 */
public Double zSetScore(String key, String member) {
return redis.opsForZSet().score(key, member);
}
/** 按分数从低到高的排名(0 起);不存在返回 {@code null}。 */
public Long zSetRank(String key, String member) {
return redis.opsForZSet().rank(key, member);
}
/** 按分数从高到低的排名。 */
public Long zSetReverseRank(String key, String member) {
return redis.opsForZSet().reverseRank(key, member);
}
/** 按排名闭区间取成员(分数升序)。 */
public Set<String> zSetRange(String key, long start, long end) {
return redis.opsForZSet().range(key, start, end);
}
/** 按排名闭区间取成员(分数降序)。 */
public Set<String> zSetReverseRange(String key, long start, long end) {
return redis.opsForZSet().reverseRange(key, start, end);
}
/** 按分数闭区间 {@code [min, max]} 取成员。 */
public Set<String> zSetRangeByScore(String key, double min, double max) {
return redis.opsForZSet().rangeByScore(key, min, max);
}
/** 按排名区间取成员并带上分数。 */
public Set<ZSetOperations.TypedTuple<String>> zSetRangeWithScores(String key, long start, long end) {
Set<ZSetOperations.TypedTuple<String>> s = redis.opsForZSet().rangeWithScores(key, start, end);
return s == null ? Collections.emptySet() : s;
}
/** 按排名删除闭区间内的成员,返回删除个数。 */
public Long zSetRemoveRangeByRank(String key, long start, long end) {
return redis.opsForZSet().removeRange(key, start, end);
}
/** 按分数闭区间删除成员(含边界)。 */
public Long zSetRemoveRangeByScore(String key, double min, double max) {
return redis.opsForZSet().removeRangeByScore(key, min, max);
}
// endregion
// region HyperLogLog
/** 向 HyperLogLog 添加元素,返回内部认为新增了多少(近似 0/1)。 */
public Long hyperLogLogAdd(String key, String... elements) {
if (elements == null || elements.length == 0) {
return 0L;
}
return redis.opsForHyperLogLog().add(key, elements);
}
/** 估算基数(去重后大约多少个不同元素)。 */
public Long hyperLogLogSize(String key) {
return redis.opsForHyperLogLog().size(key);
}
/** 将多个 HyperLogLog 合并到 {@code destination}。 */
public void hyperLogLogUnion(String destination, String... sourceKeys) {
if (sourceKeys == null || sourceKeys.length == 0) {
return;
}
redis.opsForHyperLogLog().union(destination, sourceKeys);
}
// endregion
// region Key 与通用
/** 删除单个 key;不存在返回 false。 */
public Boolean delete(String key) {
return redis.delete(key);
}
/** 批量删除,返回成功删除的 key 数量。 */
public Long delete(Collection<String> keys) {
if (keys == null || keys.isEmpty()) {
return 0L;
}
return redis.delete(keys);
}
/** key 是否存在。 */
public Boolean hasKey(String key) {
return redis.hasKey(key);
}
/** 设置存活时间;key 不存在返回 false。 */
public Boolean expire(String key, Duration ttl) {
return redis.expire(key, ttl);
}
/** 去掉 TTL,使 key 持久存在(若支持)。 */
public Boolean persist(String key) {
return redis.persist(key);
}
/** 剩余过期时间(秒),-1 表示无过期,-2 表示 key 不存在。 */
public Long getExpire(String key) {
return redis.getExpire(key);
}
/** 剩余过期时间,单位由 {@code unit} 指定。 */
public Long getExpire(String key, TimeUnit unit) {
return redis.getExpire(key, unit);
}
/**
* 使用 SCAN 迭代匹配 {@code pattern} 的 key(非阻塞);{@code count} 为单次提示扫描条数,非精确上限。
*
* <p>适合生产环境替代 {@code KEYS}。
*/
public List<String> scanKeys(String pattern, long count) {
String p = Objects.requireNonNull(pattern, "pattern");
ScanOptions options = ScanOptions.scanOptions().match(p).count(count).build();
List<String> keys = new ArrayList<>();
try (Cursor<String> cursor = redis.scan(options)) {
while (cursor.hasNext()) {
keys.add(cursor.next());
}
}
return keys;
}
/**
* 将 {@link ZSetOperations.TypedTuple} 集合转为 {@code {member, score}} 的 Map 列表,便于直接 JSON 输出。
*/
public List<Map<String, Object>> zSetTuplesToRows(Set<ZSetOperations.TypedTuple<String>> tuples) {
if (tuples == null || tuples.isEmpty()) {
return List.of();
}
return tuples.stream()
.map(t -> {
Map<String, Object> row = new LinkedHashMap<>(2);
row.put("member", t.getValue());
row.put("score", t.getScore());
return row;
})
.collect(Collectors.toList());
}
/**
* 单次请求批量读取多个 key 的字符串值;结果顺序与 {@code keys} 一致,不存在的 key 对应位置为 {@code null}。
*/
public List<String> strMultiGet(List<String> keys) {
if (keys == null || keys.isEmpty()) {
return List.of();
}
return redis.opsForValue().multiGet(keys);
}
/** 批量写入多个 key-value,对应 Redis MSET。 */
public void strMultiSet(Map<String, String> map) {
if (map == null || map.isEmpty()) {
return;
}
redis.opsForValue().multiSet(map);
}
/**
* 返回底层 {@link StringRedisTemplate},用于执行 Lua、自定义 {@link org.springframework.data.redis.core.script.RedisScript}
* 等本类未封装的能力。
*/
public StringRedisTemplate template() {
return redis;
}
// endregion
}
@@ -0,0 +1,25 @@
package com.huangzj.server.web;
import com.huangzj.server.config.ClientTcpProperties;
import java.util.LinkedHashMap;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** 给页面或脚本展示「客户端连区服」的 TCP 端口(帧格式:4 字节大端长度 + CmdRpcEnvelope)。 */
@RestController
@RequestMapping("/api/config")
@RequiredArgsConstructor
public class ClientTcpConfigController {
private final ClientTcpProperties clientTcpProperties;
@GetMapping("/client-tcp")
public Map<String, Object> clientTcp() {
Map<String, Object> m = new LinkedHashMap<>();
m.put("port", clientTcpProperties.getPort());
return m;
}
}
@@ -0,0 +1,96 @@
package com.huangzj.server.web;
import com.huangzj.base.spring.module.PlayerModuleRegistry;
import com.huangzj.server.config.AppProperties;
import com.huangzj.server.event.AppDomainEvent;
import com.huangzj.server.event.DemoEventListener;
import com.huangzj.server.mongo.DemoMongoService;
import com.huangzj.server.mongo.DemoPayloadDoc;
import com.huangzj.server.redis.RedisUtil;
import java.util.LinkedHashMap;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/demo")
@RequiredArgsConstructor
public class DemoApiController {
private final DemoMongoService demoMongoService;
private final RedisUtil redisUtil;
private final ApplicationEventPublisher eventPublisher;
private final DemoEventListener demoEventListener;
private final PlayerModuleRegistry playerModuleRegistry;
private final AppProperties appProperties;
@PostMapping("/mongo/save-immediate")
public DemoPayloadDoc mongoSaveImmediate(@RequestBody DemoPayloadDoc doc) {
return demoMongoService.saveImmediate(doc);
}
@PostMapping("/mongo/save-scheduled")
public void mongoSaveScheduled(@RequestBody DemoPayloadDoc doc) {
demoMongoService.scheduleSave(doc);
}
@GetMapping("/mongo/latest")
public DemoPayloadDoc mongoLatest(@RequestParam long playerId) {
return demoMongoService.findLatestByPlayer(playerId);
}
@PostMapping("/redis/set")
public void redisSet(@RequestBody RedisKv body) {
redisUtil.strSet(body.key(), body.value());
}
@GetMapping("/redis/get")
public String redisGet(@RequestParam String key) {
return redisUtil.strGet(key);
}
/** 发一个 {@link AppDomainEvent}Listener 里会累加计数。 */
@PostMapping("/event/publish")
public void eventPublish(@RequestBody EventPubBody body) {
eventPublisher.publishEvent(new AppDomainEvent(this, body.topic(), body.body()));
}
@GetMapping("/event/last")
public Map<String, Object> eventLast() {
Map<String, Object> m = new LinkedHashMap<>();
m.put("count", demoEventListener.getReceiveCount());
m.put("lastTopic", demoEventListener.getLastTopic());
m.put("lastBody", demoEventListener.getLastBody());
return m;
}
@GetMapping("/module/info")
public Map<String, Object> moduleInfo() {
Map<String, Object> m = new LinkedHashMap<>();
m.put("playerId", appProperties.getModule().getPlayerId());
m.put("tickIntervalMs", appProperties.getModule().getTickIntervalMs());
m.put("modules", playerModuleRegistry.describeModules());
return m;
}
@PostMapping("/module/tick")
public String moduleTick() {
playerModuleRegistry.runTickAll(System.currentTimeMillis());
return "ok";
}
@PostMapping("/module/save")
public void moduleSave() {
playerModuleRegistry.saveAll();
}
public record RedisKv(String key, String value) {}
public record EventPubBody(String topic, String body) {}
}
@@ -0,0 +1,18 @@
package com.huangzj.server.web;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** 最小 HTTP 探活(原 {@code com.huangzj.comm.http} 合并至此)。 */
@RestController
@RequestMapping("/api")
public class PingController {
@GetMapping("/ping")
public PingPayload ping() {
return new PingPayload(true, System.currentTimeMillis());
}
public record PingPayload(boolean ok, long serverTimeMs) {}
}
@@ -0,0 +1,30 @@
# 开发环境:本机 Mongo / Redis / 跨服,日志落项目相对目录
server:
port: 8080
spring:
data:
mongodb:
uri: mongodb://
redis:
host:
port: 6379
password:
username:
game:
client-tcp:
port: 9091
cross:
host: 127.0.0.1
port: 9200
server-id: zone-1
token: ""
heartbeat-interval-ms: 15000
app:
logging:
dir: logs/ServerModule
mongo:
collection: test
@@ -0,0 +1,28 @@
# 部署环境(生产/预发):请通过环境变量注入连接信息与密钥,勿把真实密码写进仓库
server:
port: ${SERVER_HTTP_PORT:8080}
spring:
data:
mongodb:
uri: ${MONGODB_URI:mongodb://127.0.0.1:27017/demo_comm}
redis:
host: ${REDIS_HOST:127.0.0.1}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
username: ${REDIS_USERNAME:}
game:
client-tcp:
port: ${GAME_CLIENT_TCP_PORT:9091}
cross:
host: ${CROSS_HOST:127.0.0.1}
port: ${CROSS_TCP_PORT:9200}
server-id: ${ZONE_SERVER_ID:zone-1}
token: ${CROSS_LINK_TOKEN:}
heartbeat-interval-ms: ${CROSS_HEARTBEAT_INTERVAL_MS:15000}
app:
logging:
dir: ${APP_LOG_DIR:/var/log/comm-framework/server}
@@ -0,0 +1,17 @@
# 公共入口:按 spring.profiles.active 叠加 application-{profile}.yml
# 开发默认:未设置环境变量 SPRING_PROFILES_ACTIVE 时使用 dev
spring:
application:
name: server-module
profiles:
active: ${SPRING_PROFILES_ACTIVE:dev}
# 与运行环境无关的业务默认(可被各 profile 覆盖)
app:
mongo:
collection: demo_payload
save:
interval-ms: 15000
module:
player-id: 900001
tick-interval-ms: 8000
@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<!-- 目录由本模块 application.yml 的 app.logging.dir 指定,与 CrossModule 互不干扰 -->
<springProperty name="LOG_DIR" scope="context" source="app.logging.dir" defaultValue="logs/ServerModule"/>
<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,174 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>通信探测</title>
<style>
body { font-family: sans-serif; margin: 1rem; }
button { margin-right: 0.5rem; margin-bottom: 0.5rem; }
pre { background: #f4f4f4; padding: 0.75rem; white-space: pre-wrap; }
</style>
</head>
<body>
<p>业务 TCP4 字节大端长度 + <code>CmdRpcEnvelope</code>)端口:<code id="tcpPort"></code>
三条路径示例请运行 <code>com.huangzj.server.demo.TcpPathsDemo</code>(需先起 CrossModule 再起 ServerModule)。</p>
<button type="button" id="btnHttp">验证 HTTP</button>
<button type="button" id="btnMongoNow">Mongo 立即保存</button>
<button type="button" id="btnMongoLater">Mongo 定时保存</button>
<button type="button" id="btnMongoRead">Mongo 读最新</button>
<button type="button" id="btnRedisSet">Redis 写入</button>
<button type="button" id="btnRedisGet">Redis 读取</button>
<button type="button" id="btnEventPub">事件发布</button>
<button type="button" id="btnEventRead">事件状态</button>
<button type="button" id="btnModInfo">模块注册与定时配置</button>
<button type="button" id="btnModTick">手动 tick 全部 GeneralModule</button>
<button type="button" id="btnModSave">全部模块 saveData</button>
<pre id="log"></pre>
<script>
const logEl = document.getElementById('log');
let playerId = 900001;
function log(msg) {
logEl.textContent += msg + '\n';
}
async function refreshEndpoints() {
try {
const tcp = await fetch('/api/config/client-tcp');
const tj = await tcp.json();
document.getElementById('tcpPort').textContent = String(tj.port);
const mi = await fetch('/api/demo/module/info');
const mj = await mi.json();
playerId = mj.playerId;
} catch (e) {
log('加载配置失败 ' + e);
}
}
document.getElementById('btnHttp').onclick = async () => {
try {
const r = await fetch('/api/ping');
const j = await r.json();
log('[HTTP] ' + JSON.stringify(j));
} catch (e) {
log('[HTTP] 失败: ' + e);
}
};
document.getElementById('btnMongoNow').onclick = async () => {
try {
await refreshEndpoints();
const body = { playerId: playerId, content: 'immediate-' + Date.now() };
const r = await fetch('/api/demo/mongo/save-immediate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
log('[Mongo立即] ' + await r.text());
} catch (e) {
log('[Mongo立即] ' + e);
}
};
document.getElementById('btnMongoLater').onclick = async () => {
try {
await refreshEndpoints();
const body = { playerId: playerId, content: 'scheduled-' + Date.now() };
const r = await fetch('/api/demo/mongo/save-scheduled', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
log('[Mongo定时标记] ' + r.status);
} catch (e) {
log('[Mongo定时] ' + e);
}
};
document.getElementById('btnMongoRead').onclick = async () => {
try {
await refreshEndpoints();
const r = await fetch('/api/demo/mongo/latest?playerId=' + encodeURIComponent(playerId));
log('[Mongo读] ' + await r.text());
} catch (e) {
log('[Mongo读] ' + e);
}
};
document.getElementById('btnRedisSet').onclick = async () => {
try {
const r = await fetch('/api/demo/redis/set', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: 'demoKey', value: 'demoVal-' + Date.now() })
});
log('[Redis写] ' + r.status);
} catch (e) {
log('[Redis写] ' + e);
}
};
document.getElementById('btnRedisGet').onclick = async () => {
try {
const r = await fetch('/api/demo/redis/get?key=demoKey');
log('[Redis读] ' + await r.text());
} catch (e) {
log('[Redis读] ' + e);
}
};
document.getElementById('btnEventPub').onclick = async () => {
try {
const r = await fetch('/api/demo/event/publish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic: 'demo', body: 'hello-' + Date.now() })
});
log('[事件发布] ' + r.status);
} catch (e) {
log('[事件发布] ' + e);
}
};
document.getElementById('btnEventRead').onclick = async () => {
try {
const r = await fetch('/api/demo/event/last');
log('[事件状态] ' + await r.text());
} catch (e) {
log('[事件状态] ' + e);
}
};
document.getElementById('btnModInfo').onclick = async () => {
try {
const r = await fetch('/api/demo/module/info');
log('[模块] ' + await r.text());
} catch (e) {
log('[模块] ' + e);
}
};
document.getElementById('btnModTick').onclick = async () => {
try {
const r = await fetch('/api/demo/module/tick', { method: 'POST' });
log('[模块tick] ' + await r.text());
} catch (e) {
log('[模块tick] ' + e);
}
};
document.getElementById('btnModSave').onclick = async () => {
try {
const r = await fetch('/api/demo/module/save', { method: 'POST' });
log('[模块save] ' + r.status);
} catch (e) {
log('[模块save] ' + e);
}
};
refreshEndpoints();
</script>
</body>
</html>
@@ -0,0 +1,30 @@
# 开发环境:本机 Mongo / Redis / 跨服,日志落项目相对目录
server:
port: 8080
spring:
data:
mongodb:
uri: mongodb://huangzj:huangzj@43.155.238.153:27017/test?authSource=admin
redis:
host: 43.155.238.153
port: 6379
password: huangzj
username: default
game:
client-tcp:
port: 9091
cross:
host: 127.0.0.1
port: 9200
server-id: zone-1
token: ""
heartbeat-interval-ms: 15000
app:
logging:
dir: logs/ServerModule
mongo:
collection: test
@@ -0,0 +1,28 @@
# 部署环境(生产/预发):请通过环境变量注入连接信息与密钥,勿把真实密码写进仓库
server:
port: ${SERVER_HTTP_PORT:8080}
spring:
data:
mongodb:
uri: ${MONGODB_URI:mongodb://127.0.0.1:27017/demo_comm}
redis:
host: ${REDIS_HOST:127.0.0.1}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
username: ${REDIS_USERNAME:}
game:
client-tcp:
port: ${GAME_CLIENT_TCP_PORT:9091}
cross:
host: ${CROSS_HOST:127.0.0.1}
port: ${CROSS_TCP_PORT:9200}
server-id: ${ZONE_SERVER_ID:zone-1}
token: ${CROSS_LINK_TOKEN:}
heartbeat-interval-ms: ${CROSS_HEARTBEAT_INTERVAL_MS:15000}
app:
logging:
dir: ${APP_LOG_DIR:/var/log/comm-framework/server}
@@ -0,0 +1,17 @@
# 公共入口:按 spring.profiles.active 叠加 application-{profile}.yml
# 开发默认:未设置环境变量 SPRING_PROFILES_ACTIVE 时使用 dev
spring:
application:
name: server-module
profiles:
active: ${SPRING_PROFILES_ACTIVE:dev}
# 与运行环境无关的业务默认(可被各 profile 覆盖)
app:
mongo:
collection: demo_payload
save:
interval-ms: 15000
module:
player-id: 900001
tick-interval-ms: 8000
@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<!-- 目录由本模块 application.yml 的 app.logging.dir 指定,与 CrossModule 互不干扰 -->
<springProperty name="LOG_DIR" scope="context" source="app.logging.dir" defaultValue="logs/ServerModule"/>
<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,174 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>通信探测</title>
<style>
body { font-family: sans-serif; margin: 1rem; }
button { margin-right: 0.5rem; margin-bottom: 0.5rem; }
pre { background: #f4f4f4; padding: 0.75rem; white-space: pre-wrap; }
</style>
</head>
<body>
<p>业务 TCP4 字节大端长度 + <code>CmdRpcEnvelope</code>)端口:<code id="tcpPort"></code>
三条路径示例请运行 <code>com.huangzj.server.demo.TcpPathsDemo</code>(需先起 CrossModule 再起 ServerModule)。</p>
<button type="button" id="btnHttp">验证 HTTP</button>
<button type="button" id="btnMongoNow">Mongo 立即保存</button>
<button type="button" id="btnMongoLater">Mongo 定时保存</button>
<button type="button" id="btnMongoRead">Mongo 读最新</button>
<button type="button" id="btnRedisSet">Redis 写入</button>
<button type="button" id="btnRedisGet">Redis 读取</button>
<button type="button" id="btnEventPub">事件发布</button>
<button type="button" id="btnEventRead">事件状态</button>
<button type="button" id="btnModInfo">模块注册与定时配置</button>
<button type="button" id="btnModTick">手动 tick 全部 GeneralModule</button>
<button type="button" id="btnModSave">全部模块 saveData</button>
<pre id="log"></pre>
<script>
const logEl = document.getElementById('log');
let playerId = 900001;
function log(msg) {
logEl.textContent += msg + '\n';
}
async function refreshEndpoints() {
try {
const tcp = await fetch('/api/config/client-tcp');
const tj = await tcp.json();
document.getElementById('tcpPort').textContent = String(tj.port);
const mi = await fetch('/api/demo/module/info');
const mj = await mi.json();
playerId = mj.playerId;
} catch (e) {
log('加载配置失败 ' + e);
}
}
document.getElementById('btnHttp').onclick = async () => {
try {
const r = await fetch('/api/ping');
const j = await r.json();
log('[HTTP] ' + JSON.stringify(j));
} catch (e) {
log('[HTTP] 失败: ' + e);
}
};
document.getElementById('btnMongoNow').onclick = async () => {
try {
await refreshEndpoints();
const body = { playerId: playerId, content: 'immediate-' + Date.now() };
const r = await fetch('/api/demo/mongo/save-immediate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
log('[Mongo立即] ' + await r.text());
} catch (e) {
log('[Mongo立即] ' + e);
}
};
document.getElementById('btnMongoLater').onclick = async () => {
try {
await refreshEndpoints();
const body = { playerId: playerId, content: 'scheduled-' + Date.now() };
const r = await fetch('/api/demo/mongo/save-scheduled', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
log('[Mongo定时标记] ' + r.status);
} catch (e) {
log('[Mongo定时] ' + e);
}
};
document.getElementById('btnMongoRead').onclick = async () => {
try {
await refreshEndpoints();
const r = await fetch('/api/demo/mongo/latest?playerId=' + encodeURIComponent(playerId));
log('[Mongo读] ' + await r.text());
} catch (e) {
log('[Mongo读] ' + e);
}
};
document.getElementById('btnRedisSet').onclick = async () => {
try {
const r = await fetch('/api/demo/redis/set', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: 'demoKey', value: 'demoVal-' + Date.now() })
});
log('[Redis写] ' + r.status);
} catch (e) {
log('[Redis写] ' + e);
}
};
document.getElementById('btnRedisGet').onclick = async () => {
try {
const r = await fetch('/api/demo/redis/get?key=demoKey');
log('[Redis读] ' + await r.text());
} catch (e) {
log('[Redis读] ' + e);
}
};
document.getElementById('btnEventPub').onclick = async () => {
try {
const r = await fetch('/api/demo/event/publish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic: 'demo', body: 'hello-' + Date.now() })
});
log('[事件发布] ' + r.status);
} catch (e) {
log('[事件发布] ' + e);
}
};
document.getElementById('btnEventRead').onclick = async () => {
try {
const r = await fetch('/api/demo/event/last');
log('[事件状态] ' + await r.text());
} catch (e) {
log('[事件状态] ' + e);
}
};
document.getElementById('btnModInfo').onclick = async () => {
try {
const r = await fetch('/api/demo/module/info');
log('[模块] ' + await r.text());
} catch (e) {
log('[模块] ' + e);
}
};
document.getElementById('btnModTick').onclick = async () => {
try {
const r = await fetch('/api/demo/module/tick', { method: 'POST' });
log('[模块tick] ' + await r.text());
} catch (e) {
log('[模块tick] ' + e);
}
};
document.getElementById('btnModSave').onclick = async () => {
try {
const r = await fetch('/api/demo/module/save', { method: 'POST' });
log('[模块save] ' + r.status);
} catch (e) {
log('[模块save] ' + e);
}
};
refreshEndpoints();
</script>
</body>
</html>
@@ -0,0 +1,44 @@
com\huangzj\server\config\AppProperties$Mongo.class
com\huangzj\server\cmd\ZoneCommandRegistry.class
com\huangzj\server\mongo\DemoPayloadDoc.class
com\huangzj\server\module\SamplePlayerModule.class
com\huangzj\server\event\DemoEventListener.class
com\huangzj\server\web\DemoApiController$EventBody.class
com\huangzj\server\ServerModuleApplication.class
com\huangzj\server\web\DemoApiController$RedisKv.class
com\huangzj\comm\crosslink\CrossClientInboundHandler.class
com\huangzj\server\cmd\cmds\CrossProxyPbCommand.class
com\huangzj\comm\model\HttpPingPayload.class
com\huangzj\comm\codec\BinaryFrameCodec.class
com\huangzj\server\config\WsFrontendConfigController.class
com\huangzj\server\module\DemoAuxModule.class
com\huangzj\comm\ws\CommNettyWebSocketServer$1.class
com\huangzj\server\module\ModuleTickScheduler.class
com\huangzj\comm\ws\EchoBinaryCommandRouter.class
com\huangzj\server\web\DemoApiController.class
com\huangzj\comm\crosslink\CrossTcpClient.class
com\huangzj\server\config\AppProperties$Save.class
com\huangzj\server\module\DemoPlayerHostBean.class
com\huangzj\comm\ws\ProtobufWebSocketChannelHandler.class
com\huangzj\comm\ws\BinaryCommandRouter.class
com\huangzj\comm\crosslink\CrossTestController.class
com\huangzj\server\config\AppProperties$Module.class
com\huangzj\comm\model\CommandId.class
com\huangzj\server\config\CmdWebSocketProperties.class
com\huangzj\server\redis\DemoRedisService.class
com\huangzj\comm\model\DecodedBinaryEnvelope.class
com\huangzj\comm\ws\CommNettyWebSocketServer.class
com\huangzj\comm\config\CommWebSocketProperties.class
com\huangzj\server\module\DemoPlayerHost.class
com\huangzj\server\ws\cmd\CmdNettyWebSocketServer.class
com\huangzj\comm\http\CommHttpController.class
com\huangzj\server\ws\cmd\CmdNettyWebSocketServer$1.class
com\huangzj\comm\crosslink\CrossTcpClient$1.class
com\huangzj\comm\crosslink\CrossTestResponse.class
com\huangzj\server\event\AppDomainEvent.class
com\huangzj\server\config\AppProperties.class
com\huangzj\server\ws\cmd\CmdWebSocketHandler.class
com\huangzj\comm\crosslink\CrossLinkProperties.class
com\huangzj\server\cmd\cmds\EchoWsPbCommand.class
com\huangzj\server\cmd\cmds\CrossToGameEchoPbCommand.class
com\huangzj\server\mongo\DemoMongoService.class
@@ -0,0 +1,36 @@
F:\coding\comm-framework\ServerModule\src\main\java\com\huangzj\comm\model\DecodedBinaryEnvelope.java
F:\coding\comm-framework\ServerModule\src\main\java\com\huangzj\comm\model\CommandId.java
F:\coding\comm-framework\ServerModule\src\main\java\com\huangzj\server\module\DemoPlayerHostBean.java
F:\coding\comm-framework\ServerModule\src\main\java\com\huangzj\comm\codec\BinaryFrameCodec.java
F:\coding\comm-framework\ServerModule\src\main\java\com\huangzj\server\event\DemoEventListener.java
F:\coding\comm-framework\ServerModule\src\main\java\com\huangzj\server\module\ModuleTickScheduler.java
F:\coding\comm-framework\ServerModule\src\main\java\com\huangzj\server\mongo\DemoPayloadDoc.java
F:\coding\comm-framework\ServerModule\src\main\java\com\huangzj\comm\crosslink\CrossTestResponse.java
F:\coding\comm-framework\ServerModule\src\main\java\com\huangzj\comm\ws\EchoBinaryCommandRouter.java
F:\coding\comm-framework\ServerModule\src\main\java\com\huangzj\server\event\AppDomainEvent.java
F:\coding\comm-framework\ServerModule\src\main\java\com\huangzj\comm\config\CommWebSocketProperties.java
F:\coding\comm-framework\ServerModule\src\main\java\com\huangzj\comm\ws\BinaryCommandRouter.java
F:\coding\comm-framework\ServerModule\src\main\java\com\huangzj\server\cmd\cmds\EchoWsPbCommand.java
F:\coding\comm-framework\ServerModule\src\main\java\com\huangzj\server\config\AppProperties.java
F:\coding\comm-framework\ServerModule\src\main\java\com\huangzj\server\module\SamplePlayerModule.java
F:\coding\comm-framework\ServerModule\src\main\java\com\huangzj\server\module\DemoPlayerHost.java
F:\coding\comm-framework\ServerModule\src\main\java\com\huangzj\comm\crosslink\CrossClientInboundHandler.java
F:\coding\comm-framework\ServerModule\src\main\java\com\huangzj\comm\ws\ProtobufWebSocketChannelHandler.java
F:\coding\comm-framework\ServerModule\src\main\java\com\huangzj\server\redis\DemoRedisService.java
F:\coding\comm-framework\ServerModule\src\main\java\com\huangzj\server\ws\cmd\CmdWebSocketHandler.java
F:\coding\comm-framework\ServerModule\src\main\java\com\huangzj\server\mongo\DemoMongoService.java
F:\coding\comm-framework\ServerModule\src\main\java\com\huangzj\server\cmd\cmds\CrossToGameEchoPbCommand.java
F:\coding\comm-framework\ServerModule\src\main\java\com\huangzj\server\cmd\cmds\CrossProxyPbCommand.java
F:\coding\comm-framework\ServerModule\src\main\java\com\huangzj\comm\model\HttpPingPayload.java
F:\coding\comm-framework\ServerModule\src\main\java\com\huangzj\server\ServerModuleApplication.java
F:\coding\comm-framework\ServerModule\src\main\java\com\huangzj\comm\crosslink\CrossLinkProperties.java
F:\coding\comm-framework\ServerModule\src\main\java\com\huangzj\server\config\CmdWebSocketProperties.java
F:\coding\comm-framework\ServerModule\src\main\java\com\huangzj\server\web\DemoApiController.java
F:\coding\comm-framework\ServerModule\src\main\java\com\huangzj\server\config\WsFrontendConfigController.java
F:\coding\comm-framework\ServerModule\src\main\java\com\huangzj\server\cmd\ZoneCommandRegistry.java
F:\coding\comm-framework\ServerModule\src\main\java\com\huangzj\server\module\DemoAuxModule.java
F:\coding\comm-framework\ServerModule\src\main\java\com\huangzj\comm\ws\CommNettyWebSocketServer.java
F:\coding\comm-framework\ServerModule\src\main\java\com\huangzj\comm\crosslink\CrossTcpClient.java
F:\coding\comm-framework\ServerModule\src\main\java\com\huangzj\comm\crosslink\CrossTestController.java
F:\coding\comm-framework\ServerModule\src\main\java\com\huangzj\server\ws\cmd\CmdNettyWebSocketServer.java
F:\coding\comm-framework\ServerModule\src\main\java\com\huangzj\comm\http\CommHttpController.java