快速从零编写服务器示例
介绍
通过本节的学习,你可以快速的搭建一个完整的游戏服务器。学习内容包括:
- 游戏逻辑服的编写
- Action 的编写
- 模拟客户端的请求
- 将项目打成 jar 包运行
启动展示
ioGame 在内存占用、启动速度、打包等方面也是优秀的。
- 内存方面:内存占用小。
- 启动速度方面:应用通常会在 0.x 秒内完成启动。
- 打包方面:打 jar 包后大约 15MB。
Example Source Code
see https://github.com/iohao/ioGameSimpleOne
下载示例源码
git clone https://github.com/iohao/ioGameSimpleOne.git
运行步骤
- 服务器启动类 DemoApplication.java
- 模拟客户端启动类 DemoClient.java
打 jar 包运行
执行下面的命令后,会在当前项目的 target 目录下生成一个 simple-one-1.0-SNAPSHOT-jar-with-dependencies.jar 文件, 这个 jar 文件可以单独启动。 打包后的 jar 包大小大约 15MB。
mvn clean package
启动 jar 文件
进入 target 目录,执行下面的命令启动 jar 文件,通常会在 0.x 秒将服务器启动完成。
java -jar simple-one-1.0-SNAPSHOT-jar-with-dependencies.jar
下载示例源码后,跟着当前文档来学习即可。
设置 pom.xml
创建新的项目后,在 pom.xml 中添加如下内容
<properties>
<java.version>21</java.version>
<!-- see https://central.sonatype.com/artifact/com.iohao.game/run-one-netty -->
<ioGame.version>xx.xx</ioGame.version>
</properties>
<dependencies>
<dependency>
<groupId>com.iohao.game</groupId>
<artifactId>run-one-netty</artifactId>
<version>${ioGame.version}</version>
</dependency>
</dependencies>
定义业务数据协议
现在,我们定义数据协议,用于客户端与服务器的数据交互。 如果你只做过 web 开发而没有游戏服务器开发经验的,可以把这理解成 DTO、业务数据载体等,其主要目的是用于业务数据的传输。
由于原生的 PB 使用起来比较麻烦,这里我们使用 Jprotobuf。 这是对 PB 的简化使用,性能与原生的同等。
在普通的 java 类上添加 ProtobufClass 注解,就表示这是一个业务数据协议。
@ProtobufClass
public class HelloMessage {
public String name;
@Override
public String toString() {
return "HelloMessage{name='"+ name + "'}";
}
}
游戏逻辑服
游戏逻辑服需要继承 AbstractBrokerClientStartup , 有三个方法需要实现
- createBarSkeleton,当前游戏逻辑服使用的业务框架。
- createBrokerClientBuilder,当前游戏逻辑服信息。
- createBrokerAddress,游戏网关的连接地址。
代码说明
- code 4,业务框架构建器参数
- code 5,扫描 action 类所在包。 内部会扫描当前类路径和子包路径下的所有类,无论有多少个 action 类,只需要配置任意一个类就行。
- code 7,创建业务框架构建器
- code 8,添加控制台输出插件
- code 10,创建业务框架
- code 14~18,创建游戏逻辑服构建器,并设置逻辑服名称。
- code 21~25,设置连接到 Broker 的地址。
public class DemoLogicServer extends AbstractBrokerClientStartup {
@Override
public BarSkeleton createBarSkeleton() {
var config = new BarSkeletonBuilderParamConfig()
.scanActionPackage(DemoAction.class);
var builder = config.createBuilder();
builder.addInOut(new DebugInOut());
return builder.build();
}
@Override
public BrokerClientBuilder createBrokerClientBuilder() {
BrokerClientBuilder builder = BrokerClient.newBuilder();
builder.appName("DemoLogicServer");
return builder;
}
@Override
public BrokerAddress createBrokerAddress() {
String localIp = "127.0.0.1";
int brokerPort = IoGameGlobalConfig.brokerPort;
return new BrokerAddress(localIp, brokerPort);
}
}
好了,游戏逻辑服已经编写完成了,接下来写一个业务处理类。在实际当中,我们大部分时间都是在写业务逻辑。
错误码,异常码
业务框架支持异常机制,有了异常机制可以使得业务代码更加的清晰。 也正是有了异常机制,才能做到零学习成本(普通的 java 方法成为一个业务动作 action )。
如果有业务上的异常,请直接抛出去,不需要开发者做过多的处理。 业务框架会知道如何处理这个业务异常,这些抛出去的业务异常总是能给到游戏的请求端的。
游戏中,我们通常会使用枚举类来管理游戏中的错误码, 枚举类需要实现框架提供的 MsgExceptionInfo 接口。
public enum GameCode implements MsgExceptionInfo {
nameChecked(100, "The name must be Jackson");
final int code;
final String msg;
GameCode(int code, String msg) {
this.code = code;
this.msg = msg;
}
@Override
public String getMsg() {
return this.msg;
}
@Override
public int getCode() {
return this.code;
}
}
编写 Action
@ActionController(DemoCmd.cmd)
public class DemoAction {
@ActionMethod(DemoCmd.here)
public HelloMessage here(HelloMessage helloMessage) {
HelloMessage newHelloMessage = new HelloMessage();
newHelloMessage.name = helloMessage.name + ", I'm here";
return newHelloMessage;
}
@ActionMethod(DemoCmd.jackson)
public HelloMessage jackson(HelloMessage helloMessage) {
GameCode.nameChecked.assertTrue("Jackson".equals(helloMessage.name));
helloMessage.name = "Hello, " + helloMessage.name;
return helloMessage;
}
@ActionMethod(DemoCmd.list)
public List<HelloMessage> list() {
return IntStream.range(1, 5).mapToObj(id -> {
HelloMessage helloMessage = new HelloMessage();
helloMessage.name = "data:" + id;
return helloMessage;
}).toList();
}
}
public interface DemoCmd {
int cmd = 1;
int here = 0;
int jackson = 1;
int list = 2;
}
这三个业务方法也称为 action,一个业务方法在业务框架中表示一个 action 即一个业务动作, 也就是说现在我们定义了三个 action 来处理我们的业务逻辑。
业务类和方法分别中指定了路由,这个路由表示这个 action 的唯一访问地址。 以路由方式访问,可以让我们的代码更好的模块化。
action 所做的事情分别是:
- 1-0:返回单个的业务数据给请求端
- 1-1:异常机制演示。当不符合断言时,业务框架会把错误码响应到请求端。
- 1-2:返回 List 业务数据给请求端。
在上面 action 中,我们的接收参数与返回值都是对象。 框架还支持使用基础类型做为参数和返回值,如 int、long、boolean 和 String 等, 框架称这些特性为业务参数的自动装箱、拆箱。
启动游戏服务器
- code 3,游戏对外服端口
- code 4,游戏逻辑服
- code 5,启动
public class DemoApplication {
public static void main(String[] args) {
int port = 10100;
var demoLogicServer = new DemoLogicServer();
NettySimpleHelper.run(port, List.of(demoLogicServer));
}
}
可以看到,启动游戏服务器的代码是非常简单的,这样就写完成了。 在 DemoApplication 中,我们在一个进程中启动了游戏对外服、游戏网关、游戏逻辑服, 其中游戏对外服的端口是 10100,用于给真实用户连接的端口。
接下来我们写一个模拟游戏前端在连接代码(模拟真实用户),来访问我们的游戏服务器。 在写模拟之前,我们先看一下我们游戏服务器的启动控制台。
控制台启动信息说明(重要)
在游戏服务器启动时,可以看见我们刚才在游戏逻辑服 action 中定义的几个方法。 在启动信息中可以知道我们定义了多少个 action,这些 action 分别在什么地方。 这些信息非常的实用,如果没有这些信息,在稍大一点的项目中,你很难找到别人或自己写的业务方法。
在控制台中我们可以知道 action 对应的路由、所在类、业务方法、方法参数、返回值、所在代码行数等信息。 在开发工具中,点击控制台的 DemoAction.java:43 这条信息, 就可以跳转到对应的代码中(快速导航到对应的代码),这是一个极好的开发体验。
模拟客户端的请求
我们使用框架提供的压测&模拟客户端请求模块,来测试我们之前写的 action。
将模拟客户端请求模块添加到 pom.xml 中。
<dependency>
<groupId>com.iohao.game</groupId>
<artifactId>light-client</artifactId>
<version>${ioGame.version}</version>
</dependency>
编写模拟请求数据域
我们在 DemoRegion 中编配置了 3 个模拟请求数据,之后可以通过控制台来访问这些模拟请求。
public class DemoRegion extends AbstractInputCommandRegion {
static final Logger log = LoggerFactory.getLogger(DemoRegion.class);
@Override
public void initInputCommand() {
// Setting cmd. cn:设置主路由
inputCommandCreate.cmd = DemoCmd.cmd;
// ---------------- request 1-0 ----------------
ofCommand(DemoCmd.here).setTitle("here").setRequestData(() -> {
HelloMessage helloMessage = new HelloMessage();
helloMessage.name = "1";
return helloMessage;
}).callback(result -> {
HelloMessage value = result.getValue(HelloMessage.class);
log.info("{}", value);
});
// ---------------- request 1-1 ----------------
ofCommand(DemoCmd.jackson).setTitle("Jackson").setRequestData(() -> {
HelloMessage helloMessage = new HelloMessage();
helloMessage.name = "1";
return helloMessage;
}).callback(result -> {
// We won't get in here because an exception happened.
// The logic for action 1-1 requires the name to be Jackson.
// cn: 不会进入到这里,因为发生了异常。 1-1 action 的逻辑要求 name 必须是 Jackson。
HelloMessage value = result.getValue(HelloMessage.class);
log.info("{}", value);
});
// ---------------- request 1-2 ----------------
ofCommand(DemoCmd.list).setTitle("list").callback(result -> {
// We get list data because the server returns a List
// cn: 得到 list 数据,因为服务器返回的是 List
List<HelloMessage> list = result.listValue(HelloMessage.class);
log.info("{}", list);
});
}
}
编写模拟客户端
我们在 DemoClient 类中加载了我们刚才编写的模拟请求数据域(DemoRegion
),默认情况下 ClientRunOne 会连接游戏对外服端口 10100 上。
public class DemoClient {
public static void main(String[] args) {
// closeLog. cn:关闭模拟请求相关日志
ClientUserConfigs.closeLog();
// Startup Client. cn:启动模拟客户端
new ClientRunOne()
.setInputCommandRegions(List.of(new DemoRegion()))
.startup();
}
}
模拟客户端的控制台
我们在模拟客户端的控制台输入 “.” 时,会列出我们之前编写的模拟请求数据。
当我们在控制台中分别输入 1-0、1-1、1-2 模拟命令编号时,框架会将模拟请求发送到游戏服务器中。 当客户端收到游戏服务器响应的数据时,就会进入对应的回调中。如下图所示
我们是可以在控制台中多次调用模拟命令的,如果你有一个业务是需要多次测试的,那么这种方式是友好的。
模拟客户端与游戏服务器是通过 ExternalMessage 来交互的, ExternalMessage 是框架提供的游戏对外服的协议。
可以看到,我们在请求路由 1-1 时,游戏服务器响应了一个错误码。
模拟的客户端接收到的信息中 ExternalMessage.responseStatus 是 100, 这个 100 就是我们在业务中定义的错误码。 我们的 jackson 方法业务规定 name 的值如果不是 "Jackson",就会抛出异常。 在控制台中,我们可以看到对应的错误码和错误消息。
利用好业务框架提供的异常机制,可以使我们的业务代码更加的清晰、整洁。
业务框架会将 action 抛出的错误码放到 ResponseMessage.responseStatus 中,并给到对外服协议。 最终存放到 ExternalMessage.responseStatus 中。 关于异常机制的更多好处可以查看 异常机制 的使用建议。
游戏服务器的控制台信息(重要)
开发神器之一,当上面的客户端建立连接后,会给游戏服务器发送一条消息,服务器的控制台会打印如下信息。
┏━━━━━ Debug. [(DemoAction.java:43).here] ━━━━━ [cmd:1-0 65536] ━━━━━ [demoLogicServer] ━━━━━ [id:3977a25f-c8ad-4631-bff8-3ef07520bc1e]
┣ userId: 0
┣ 参数: helloMessage : HelloMessage{name='1'}
┣ 响应: HelloMessage{name='1, I'm here'}
┣ 耗时: 9 ms
┗━━━━━ [ioGame:21.27] ━━━━━ [线程:User-8-8] ━━━━━ [连接方式:WebSocket] ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
┏━━━━━ Error. [(DemoAction.java:56).jackson] ━━━ [cmd:1-1 65537] ━━━ [demoLogicServer] ━━━ [id:3977a25f-c8ad-4631-bff8-3ef07520bc1e]
┣ userId: 0
┣ 参数: helloMessage : HelloMessage{name='1'}
┣ 错误码: 100
┣ 错误信息: The name must be Jackson
┣ 耗时: 0 ms
┗━━━━━ [ioGame:21.27] ━━━━━ [线程:User-8-8] ━━━━━ [连接方式:WebSocket] ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
┏━━━━━ Debug. [(DemoAction.java:69).list] ━━━━━ [cmd:1-2 65538] ━━━━━ [demoLogicServer] ━━━━━ [id:3977a25f-c8ad-4631-bff8-3ef07520bc1e]
┣ userId: 0
┣ 参数: :
┣ 响应: [HelloMessage{name='data:1'}, HelloMessage{name='data:2'}, HelloMessage{name='data:3'}, HelloMessage{name='data:4'}]
┣ 耗时: 2 ms
┗━━━━━ [ioGame:21.27] ━━━━━ [线程:User-8-8] ━━━━━ [连接方式:WebSocket] ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
打印信息由 DebugInOut 插件提供,更详细的说明请阅读相关文档。
有了以上信息,游戏开发者可以很快的定位问题。如果没有可视化的信息,开发中会浪费很多时间在前后端的沟通上。问题包括:
- 是否传参问题 (游戏前端说传了)
- 是否响应问题(游戏后端说返回了)
- 业务执行时长问题 (游戏前端说没收到响应, 游戏后端说早就响应了)
其中代码导航可以让开发者快速的跳转到业务类对应代码中,在多人合作的项目中, 可以快速的知道业务经过了哪些方法的执行,使得我们可以快速的进行阅读或修改。
小结
我们用少量的代码就完成了游戏对外服、游戏网关、游戏逻辑服的编写,并模拟了客户端来访问刚才我们编写的游戏服务器。
还介绍了启动游戏服务器和访问业务方法(action)时控制台的信息,这些信息是开发过程中非常重要的,说是开发神器也不过份。
游戏框架使得开发者拥有极好的开发体验,且游戏服务器是稳定的、高性能的、高可扩展的、可简单上手的、分布式的。
还有一问
OK,似乎你们想问为什么只写了一个游戏逻辑服,但游戏对外服和游戏网关都没有写,就说完成了游戏对外服、游戏网关、游戏逻辑服的编写呢?
因为游戏对外服和游戏网关的编写比较简单,放在 NettySimpleHelper 里了。下面展示一下对外服和游戏网关的构建和启动相关代码。
游戏网关服
游戏网关的默认端口是 10200
public static void main(String[] args) {
BrokerServerBuilder brokerServerBuilder = BrokerServer.newBuilder();
brokerServerBuilder.build().startup();
}
游戏对外服
在上面的 DemoApplication.java 中,看见为什么需要一个游戏对外服端口,就是为这里准备的。
- code 3,创建游戏对外服构建器,并设置游戏对外服端口。
- code 4,连接方式 WebSocket,默认不填写也是这个值。
- code 5,与 Broker (游戏网关)的连接地址,默认不填写也是这个值。
- code 7,构建、启动
public static void main(String[] args) {
var builder = DefaultExternalServer
.newBuilder(10100)
.externalJoinEnum(ExternalJoinEnum.WEBSOCKET)
.brokerAddress(new BrokerAddress("127.0.0.1", IoGameGlobalConfig.brokerPort));
builder.build().startup();
}