代码初始化
This commit is contained in:
@@ -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 | 是 | 信封对客户端为 S3,payload 为 `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 组帧,可验证经区服到跨服的全链路。
|
||||
@@ -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} 完成 Future;finally 再 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;
|
||||
|
||||
/**
|
||||
* 区服 + 各种 demo:HTTP、两条 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());
|
||||
}
|
||||
}
|
||||
+50
@@ -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/Method3;link_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 TCP(4 字节长度前缀 + 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;
|
||||
|
||||
/**
|
||||
* 区服客户端 TCP:pipeline 上挂帧解码 + 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 String(Redis 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 set(ZSet)
|
||||
|
||||
/** 添加成员及其分数;新增返回 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>业务 TCP(4 字节大端长度 + <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
|
||||
|
||||
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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>业务 TCP(4 字节大端长度 + <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>
|
||||
+44
@@ -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
|
||||
+36
@@ -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
|
||||
Reference in New Issue
Block a user