跳到主要内容

快速从零编写服务器示例

介绍

通过本节的学习,你可以快速的搭建一个完整的游戏服务器。学习内容包括:

  1. 逻辑服的编写
  2. Action 的编写
  3. 模拟客户端的请求
  4. 将项目打成 jar 包运行

启动展示

框架在内存占用、启动速度、打包等方面也是优秀的。

  • 内存方面:内存占用小。
  • 打包方面:打 jar 包后大约 15MB
  • 启动速度方面:应用通常会在 0.x 秒内完成启动。

start

Example Source Code

see https://github.com/iohao/ionet-examples

path : ionet-quick-demo

  • DemoApplication.java,服务器启动类
  • DemoClient.java,模拟客户端启动类

打 jar 包运行

执行下面的命令后,会在当前项目的 target 目录下生成一个 ionet-quick-demo.jar 文件, 这个 jar 文件可以单独启动。 打包后的 jar 包大小大约 15MB。

mvn clean package

启动 jar 文件

在示例项目中执行下面的命令启动 jar 文件,通常会在 0.x 秒将服务器启动完成。

java \
--add-opens java.base/jdk.internal.misc=ALL-UNNAMED \
--enable-native-access=ALL-UNNAMED \
-jar target/ionet-quick-demo.jar
提示

下载示例源码后,跟着当前文档来学习即可。

设置 pom.xml

创建新的项目后,在 pom.xml 中添加如下内容。当前示例已经添加好了,开发者运行即可。

version

pom.xml
<properties>
<java.version>25</java.version>
<!-- see https://central.sonatype.com/artifact/com.iohao.net/run-one -->
<ionet.version>xx.xx</ionet.version>
</properties>

<dependencies>
<dependency>
<groupId>com.iohao.net</groupId>
<artifactId>run-one</artifactId>
<version>${ionet.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 + "'}";
}
}

逻辑服

逻辑服需要实现 LogicServer 接口 , 有 2 个方法需要实现

  1. settingBarSkeletonBuilder,设置当前逻辑服的业务框架
  2. settingServerBuilder,设置当前逻辑服的信息。

  • code 5,扫描 action 类所在包。 内部会扫描当前类路径和子包路径下的所有类,无论有多少个 action 类,只需要配置任意一个类就行。
  • code 7,添加控制台输出插件。
  • code 12,设置逻辑服名称。
public class DemoLogicServer implements LogicServer {
@Override
public void settingBarSkeletonBuilder(BarSkeletonBuilder builder) {
// Scan the package where the action classes are located
builder.scanActionPackage(DemoAction.class);
// Add console output plugin
builder.addInOut(new DebugInOut());
}

@Override
public void settingServerBuilder(ServerBuilder builder) {
builder.setName("DemoLogicServer");
}
}

好了,逻辑服已经编写完成了,接下来写一个业务处理类。在实际当中,我们大部分时间都是在写业务逻辑。

错误码,异常码

断言 + 异常机制 = 清晰简洁的代码

业务框架支持异常机制,有了异常机制可以使得业务代码更加的清晰。 也正是有了异常机制,才能做到零学习成本(普通的 java 方法成为一个业务动作 action )。

如果有业务上的异常,请直接抛出去,不需要开发者做过多的处理。 业务框架会知道如何处理这个业务异常,这些抛出去的业务异常总是能给到请求端的。

我们通常会使用枚举类来管理错误码, 枚举类需要实现框架提供的 ErrorInformation 接口。

public enum ErrorCode implements ErrorInformation {
nameChecked(100, "The name must be Jackson");

final int code;
final String message;

ErrorCode(int code, String message) {
this.code = code;
this.message = message;
}

@Override
public String getMessage() {
return this.message;
}

@Override
public int getCode() {
return this.code;
}
}

编写 Action

@ActionController(DemoCmd.cmd)
public class DemoAction {
@ActionMethod(DemoCmd.here)
private HelloMessage here(HelloMessage helloMessage) {
HelloMessage newHelloMessage = new HelloMessage();
newHelloMessage.name = helloMessage.name + ", I'm here";
return newHelloMessage;
}

@ActionMethod(DemoCmd.jackson)
private HelloMessage jackson(HelloMessage helloMessage) {
// Example exception mechanism demonstration
// cn: 示例 异常机制演示
ErrorCode.nameChecked.assertTrue("Jackson".equals(helloMessage.name));

helloMessage.name = "Hello, " + helloMessage.name;
return helloMessage;
}

@ActionMethod(DemoCmd.list)
private List<HelloMessage> list() {
// Get a List data and return it to the request client
// cn: 得到一个 List 列表数据,并返回给请求端
return IntStream.range(1, 5).mapToObj(id -> {
HelloMessage helloMessage = new HelloMessage();
helloMessage.name = "data:" + id;
return helloMessage;
}).toList();
}

@ActionMethod(DemoCmd.triggerBroadcast)
private void triggerBroadcast() {
var communication = CommunicationKit.getCommunication();

// broadcast object
var message1 = new HelloMessage();
message1.name = "name-1";
communication.broadcastMulticast(DemoCmd.listenData, message1);

// broadcast list
var message2 = new HelloMessage();
message2.name = "name-2";
communication.broadcastMulticast(DemoCmd.listenList, List.of(message1, message2));
}
}

public interface DemoCmd {
int cmd = 1;

int here = 0;
int jackson = 1;
int list = 2;
int triggerBroadcast = 3;

AtomicInteger broadcastIndex = new AtomicInteger(20);
CmdInfo listenData = CmdInfo.of(cmd, broadcastIndex.getAndIncrement());
CmdInfo listenList = CmdInfo.of(cmd, broadcastIndex.getAndIncrement());
}

我们在类中定义了 4 个方法,这些业务方法也称为 action, 一个业务方法在业务框架中表示一个 action 即一个业务动作, 也就是说现在我们定义了 4 个 action 来处理我们的业务逻辑。

业务类和方法分别中指定了路由,这个路由表示这个 action 的唯一访问地址。 以路由方式访问,可以让我们的代码更好的模块化。

action 所做的事情分别是:

  • 1-0:返回单个的业务数据给请求端
  • 1-1:异常机制演示。当不符合断言时,业务框架会把错误码响应到请求端。
  • 1-2:返回 List 业务数据给请求端。
  • 1-3:触发广播,将业务数据广播给客户端(用户)。

在上面 action 中,我们的接收参数与返回值都是对象。 框架还支持使用基础类型做为参数和返回值,如 int、long、boolean 和 String 等, 框架称这些特性为业务参数的自动装箱、拆箱

启动服务器

对外服的端口是 10100,用于给真实用户连接的端口。

public class DemoApplication {
static void main() {
// Enable source documentation parsing to display code line numbers.
// cn: 开启源码文档解析,以显示代码行数。
CoreGlobalConfig.setting.parseDoc = true;
CoreGlobalConfig.setting.print = true;
// i18n: US or CHINA
Locale.setDefault(Locale.US);

// Create the ExternalServer builder and set the port for establishing connections with real players
// cn: 创建对外服构建器,并设置与真实玩家建立连接的端口
// port = 10100
int port = ExternalGlobalConfig.externalPort;
var externalServer = ExternalMapper.builder(port).build();

var aeron = new AeronLifecycleManager().getAeron();

new RunOne()
// aeron
.setAeron(aeron)
.enableCenterServer()
// externalServer. cn: 对外服
.setExternalServer(externalServer)
// logicServers. cn: 逻辑服
.setLogicServerList(List.of(new DemoLogicServer()))
.startup();
}
}
注意

启动时需要给 DemoApplication 添加 VM options

--add-opens java.base/jdk.internal.misc=ALL-UNNAMED
--enable-native-access=ALL-UNNAMED

see VM options - IDEA

接下来我们写一个模拟游戏前端在连接代码(模拟真实用户),来访问我们的游戏服务器。 在写模拟之前,我们先看一下我们游戏服务器的启动控制台。

控制台启动信息说明(重要)

quick_demo_zero_console

当服务器启动时,就可以看见我们刚才在 action 中定义的几个方法。 从启动信息中可以知道我们定义了多少个 action,这些 action 分别在什么地方。 这些信息非常的实用,如果没有这些信息,在稍大一点的项目中,你很难找到别人或自己写的业务方法。

在控制台中我们可以知道 action 对应的路由、所在类、业务方法、方法参数、返回值、所在代码行数等信息。 在开发工具中,点击控制台的 DemoAction.java:43 这条信息, 就可以跳转到对应的代码中(快速导航到对应的代码),这是一个极好的开发体验。

模拟客户端的请求

我们使用框架提供的压测&模拟客户端请求模块,来测试我们之前写的 action。 将模拟客户端请求模块添加到 pom.xml 中。

pom.xml
<dependency>
<groupId>com.iohao.net</groupId>
<artifactId>extension-client</artifactId>
<version>${ionet.version}</version>
</dependency>

编写模拟客户端

我们在 DemoRegion 中编写了 4 个模拟请求及两个广播监听,之后可以通过控制台来触发这些模拟请求。 在 DemoClient 类中加载了我们刚才编写的模拟请求数据域(DemoRegion),默认情况下 ClientRunOne 会连接到对外服的 10100 端口上。

public class DemoClient {
static void main() {
// US or CHINA
Locale.setDefault(Locale.CHINA);

// closeLog. cn: 关闭模拟请求相关日志
ClientUserConfigs.closeLog();
// Startup Client. cn: 启动模拟客户端
new ClientRunOne()
.setInputCommandRegions(List.of(new DemoRegion()))
.startup();
}

static class DemoRegion extends AbstractInputCommandRegion {
static final Logger log = LoggerFactory.getLogger(DemoRegion.class);

@Override
public void initInputCommand() {
// Setting cmd. cn:设置主路由
this.setCmd(DemoCmd.cmd, "TestDemo");

// ---------------- 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);
});

// ---------------- request 1-3 ----------------
ofCommand(DemoCmd.triggerBroadcast).setTitle("triggerBroadcast");

// ---------------- Broadcast Listener. cn: 广播监听 ----------------
ofListen(result -> {
HelloMessage value = result.getValue(HelloMessage.class);
log.info("{}", value);
}, DemoCmd.listenData, "listenData");

ofListen(result -> {
List<HelloMessage> helloMessageList = result.listValue(HelloMessage.class);
log.info("{}", helloMessageList);
}, DemoCmd.listenList, "listenList");
}
}
}

模拟客户端的控制台

我们在模拟客户端的控制台输入 “.” 时,会列出我们之前编写的模拟请求数据。

quick_demo_zero_client_console

当我们在控制台中分别输入 1-0、1-1、1-2、1-3 模拟命令编号时,框架会将模拟请求发送到服务器中。 当客户端收到服务器响应的数据时,就会进入对应的回调中。如下图所示

我们是可以在控制台中多次调用模拟命令的,如果你有一个业务是需要多次测试的,那么这种方式是友好的。

quick_demo_zero_client_console

模拟客户端与服务器是通过 ExternalMessage 来交互的, ExternalMessage 是框架提供的对外服协议

可以看到,我们在请求路由 1-1 时,服务器响应了一个错误码。

模拟的客户端接收到的信息中 ExternalMessage.responseStatus 是 100, 这个 100 就是我们在业务中定义的错误码。 我们的 jackson 方法业务规定 name 的值如果不是 "Jackson",就会抛出异常。 在控制台中,我们可以看到对应的错误码和错误消息。

利用好业务框架提供的异常机制,可以使我们的业务代码更加的清晰、整洁。

业务框架会将 action 抛出的错误码放到 ResponseMessage.responseStatus 中,并给到对外服协议。 最终存放到 ExternalMessage.responseStatus 中。 关于异常机制的更多好处可以查看 异常机制 的使用建议。

服务器的控制台信息(重要)

开发神器之一,当上面的客户端建立连接后,会给服务器发送一条消息,服务器的控制台会打印如下信息。

打印信息由 DebugInOut 插件提供,更详细的说明请阅读相关文档。

quick_demo_zero_console_debug

小结

我们用少量的代码就完成了对外服和逻辑服的编写,并模拟了客户端来访问刚才我们编写的服务器。 还介绍了启动服务器和访问业务方法(action)时控制台的信息,这些信息是开发过程中非常重要的,说是开发神器也不过分。

框架使得开发者拥有极好的开发体验,且服务器是稳定的、高性能的、高可扩展的、可简单上手的、分布式的。