Java 的 Protobuf 序列化

之前 传奇类MMORPG 游戏都会去裸写二进制数据来交换, 但是需要客户端去定制处理包装二进制数据.

这种一般和客户端交换数据比较麻烦, 比如定义格式:

1
2
3
4
5
请求协议二进制: 
[int32] MSG_ID: 代表对应协议ID
[int32] SID: 代表服务器ID, 比如 '游戏1服', 滚服的时候 id 增长很快
[int64] UID: 代表数据库用户ID
[int32|byte[]] TOKEN: 代表登陆用户的TOKEN, 字符串由 int32(定长的长度) + byte[](顶长二进制) 合并

后续为了出于开发效率和性能大部分采用通用 Google Protobuf 来做数据协议交换:

因为其广泛跨语言和跨平台特性在游戏应用也很广, 这里主要是选择 Java 版本来引入和生成.

目前的 Protobuf 有以下版本:

  • proto 2: 很早期的版本, 基本上处于维护, 官方不推荐采用

  • proto 3: 常规维护版本, 追加比较新特性, 移除部分特性实现

  • proto editions: 热更新版本, 特性语法变动很频繁, 不推荐使用

日常使用推荐采用 Proto 3 版本, 后续 Java 采用 maven 来做项目管理

这里的 POM 文件配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<!-- 基础信息, 组织和包名这些可以放置在此 -->
<groupId>io.meteorcat.game</groupId>
<artifactId>pico</artifactId>
<version>1.0-SNAPSHOT</version>

<!-- 全局属性 -->
<properties>
<!-- 基础属性 -->
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>

<!-- protobuf 依赖 -->
<protobuf.platform.artifact-id>protobuf-bom</protobuf.platform.artifact-id>
<protobuf.platform.group-id>com.google.protobuf</protobuf.platform.group-id>
<protobuf.platform.version>4.32.0</protobuf.platform.version>

<!-- protobuf 插件 -->
<protobuf.plugin.group-id>org.xolstice.maven.plugins</protobuf.plugin.group-id>
<protobuf.plugin.version>0.6.1</protobuf.plugin.version>

<!-- protoc 编译器 -->
<protobuf.compiler.artifact-id>protoc</protobuf.compiler.artifact-id>
<protobuf.compiler.group-id>com.google.protobuf</protobuf.compiler.group-id>
<protobuf.compiler.version>${protobuf.platform.version}</protobuf.compiler.version>

<!-- protoc 插件判断平台相关插件 -->
<os.plugin.group-id>kr.motd.maven</os.plugin.group-id>
<os.plugin.version>1.6.2</os.plugin.version>


</properties>


<!-- 全局版本管理 -->
<dependencyManagement>
<dependencies>


<!-- protobuf 核心依赖 -->
<dependency>
<groupId>com.google.protobuf</groupId>
<artifactId>protobuf-java</artifactId>
<version>${protobuf.platform.version}</version>
</dependency>
</dependencies>
</dependencyManagement>

<!-- 第三方包管理 -->
<dependencies>
<!-- Protobuf 核心依赖 -->
<dependency>
<groupId>com.google.protobuf</groupId>
<artifactId>protobuf-java</artifactId>
</dependency>

</dependencies>


<!-- 扩展打包编译配置 -->
<build>

<!-- 扩展插件 -->
<extensions>
<!-- 检测提供给 Protobuf 提供系统平台编译插件, 也就是提供 ${os.detected.classifier} 这个平台变量 -->
<extension>
<groupId>${os.plugin.group-id}</groupId>
<artifactId>os-maven-plugin</artifactId>
<version>${os.plugin.version}</version>
</extension>
</extensions>

<plugins>

<!-- Protobuf 打包生成 Java 绑定插件 -->
<!-- https://www.xolstice.org/protobuf-maven-plugin/compile-mojo.html -->
<plugin>
<groupId>${protobuf.plugin.group-id}</groupId>
<artifactId>protobuf-maven-plugin</artifactId>
<version>${protobuf.plugin.version}</version>
<extensions>true</extensions>
<configuration>
<!-- Protobuf编译器版本 -->
<protocArtifact>
${protobuf.compiler.group-id}:${protobuf.compiler.artifact-id}:${protobuf.compiler.version}:exe:${os.detected.classifier}
</protocArtifact>

<!-- *.proto 源文件路径, 直接默认即可, 系统事件不需要其他共享 -->
<!-- <protoSourceRoot>${project.parent.basedir}/proto</protoSourceRoot>-->

<!-- 生成Java文件, 直接默认就行, IDEA会自动识别到这部分代码 -->
<!--<outputDirectory>${project.basedir}/src/main/java</outputDirectory>-->

<!-- 是否生成之前清空目录, 最好不要随便乱动 -->
<clearOutputDirectory>false</clearOutputDirectory>
</configuration>

<!-- 执行时机 -->
<executions>
<!-- 执行 mvn compile 的时候打包生成 Java 文件 -->
<execution>
<id>compile</id>
<phase>compile</phase>
<goals>
<goal>compile</goal>
</goals>
</execution>
</executions>
</plugin>


</plugins>
</build>
</project>

上面就是基础的依赖文件, 后续如果想扩充功能可以在上面代码之中追加, 然后就是创建项目之中的 proto 目录:

1
2
3
4
5
6
7
8
9
10
# 以下命令都是基于 Linux 的目录文件创建功能, 其他平台可以直接手动创建即可
# 假设目前已经在项目目录内部, 直接生成在源码文件生成
# 一般单项目都是处于 src/main/proto 之中, 可以打包插件的通过 protoSourceRoot 属性修改
mkdir src/main/proto

# 这里生成简单的登陆请求交换文件
# 需要注意: Google 官方文档推荐 proto 文件采用 '下划线' 命名法
# 但是在转化的时候名称大部分会默认转化为 '大驼峰' 命令法
touch src/main/proto/user.proto
# 之后就是准备开始编写 proto 协议文件

user.proto 文件内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 声明采用 proto v3 版本处理
syntax = "proto3";

// 针对 Java 生成放置于那个包目录
// 一般最好生成在 {自己包}.protobuf 之中, 最好避免这个目录在自己包存在
// 一定要避免同名包目录在自己业务存在, 否则可能会出现目录内部文件被覆盖的问题
option java_package = "io.meteorcat.game.protobuf";

// 官方推荐将对应消息全部拆分成不同的类对象, 否则会将单个文件所有的 message 全部合并成在单个类文件
option java_multiple_files = true;


// 注意: Protobuf 官方要求消息等(message/enum), 对外定义成 '大驼峰' 命名法
// 消息请求结构体, 这里我习惯采用 {功能名}Req 等效于 "{功能名} Request" 简称
message LoginReq{
int32 sid = 1; // 服务器ID
int64 uid = 2; // 用户ID, 注意有的语言并不支持 unsingle(无符号) 特性(uint32), 推荐统一直接采用有符号
string token = 3 ; // 登陆授权返回的 Token
}

// 注意: Protobuf 官方要求内部字段采用 '下划线' 命名法
// 消息响应结构体, 这里我习惯采用 {功能名}Res 等效于 "{功能名} Response" 简称
message LoginRes{
int32 status = 1; // 请求状态码, 默认 status = 0 代表成功, 其他可以让客户端确认错误码
int64 role_id = 2; // 用户在指定服务器下的角色ID, 单个账号可以在多个服务器都有角色, 采用角色ID做唯一
string nickname = 3; // 昵称
int32 level = 4; // 等级
int64 exp = 5; // 经验值
}

这里可以采用 IDEA 或者 maven 去触发 mvn compile 编译 Protobuf 的任务, 一般成功打印如下:

1
2
3
4
5
6
7
[INFO] Compiling 1 proto file(s) to /data/pico/target/generated-sources/protobuf/java
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 22.158 s
[INFO] Finished at: 2025-12-07T22:23:40+08:00
[INFO] ------------------------------------------------------------------------

这里的 /data/pico 就是我的项目目录, 执行完成就在 target/generated-sources/protobuf/java 生成绑定文件.

注意: 编译完成如果发现 IDEA 没办法扫描到代码,
可以将 target/generated-sources/protobuf/java 作为 Sources(源代码) 加入扫描目录.

在 IDEA 目录菜单找到 target/generated-sources/protobuf/java 目录右键菜单 Mark Directory as 选择 Sources

之后编写个测试类确认生成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
package io.meteorcat.game;

import com.google.protobuf.InvalidProtocolBufferException;
import com.google.protobuf.MessageLite;
import io.meteorcat.game.protobuf.LoginReq;
import io.meteorcat.game.protobuf.LoginRes;

import java.nio.ByteBuffer;
import java.util.Arrays;
import java.util.Map;
import java.util.Objects;
import java.util.Scanner;
import java.util.random.RandomGenerator;

/**
* 测试 Protobuf 功能
*/
public class Main {
/**
* 系统入口方法
*
* @param args 参数
*/
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
LoginReq.Builder loginReqBuilder = LoginReq.newBuilder();

System.out.print("Enter Request Server ID: ");
loginReqBuilder.setSid(Integer.parseInt(scanner.nextLine()));

System.out.print("Enter Request User ID: ");
loginReqBuilder.setUid(Long.parseLong(scanner.nextLine()));

System.out.print("Enter Request Token: ");
loginReqBuilder.setToken(scanner.nextLine());

LoginReq loginReqMessage = loginReqBuilder.build();
byte[] loginReqBytes = loginReqMessage.toByteArray();
System.out.printf("Request Bytes: %s%n", Arrays.toString(loginReqBytes));


LoginRes loginRes = LoginRes
.newBuilder()
.setStatus(0)
.setRoleId(System.currentTimeMillis())
.setNickname("meteorcat")
.setLevel(RandomGenerator.getDefault().nextInt())
.setExp(999999)
.build();

byte[] loginResBytes = loginRes.toByteArray();
System.out.printf("Response Clazz: %s%n", loginRes.toString().replaceAll("\n", ", "));
System.out.printf("Response Bytes: %s%n", Arrays.toString(loginResBytes));


// 实际上如果是网络交换数据就需要对数据做编号
// 而数据编号直接在二进制表头写入即可, 比如 login request 协议定义为 10001
// 利用 ByteBuffer 申请数据最后合并成完成的消息协议
// 打包:协议号(4字节) + 消息长度(4字节) + 消息体
int totalLength = Integer.BYTES + Integer.BYTES + loginReqBytes.length;
ByteBuffer loginReqProtocol = ByteBuffer.allocate(totalLength);
loginReqProtocol.putInt(10001); // 写入协议ID
loginReqProtocol.putInt(loginReqBytes.length); // 写入协议长度
loginReqProtocol.put(loginReqBytes); // 写入协议内容


// 确认最后数据
byte[] loginReqProtocolBytes = loginReqProtocol.array();// Java 默认的数据是网络序列, 不需要管大端小端
System.out.printf("Login Protocol Bytes: %s%n", Arrays.toString(loginReqProtocolBytes));


// 后续默认 TCP 网络消息传递过来的时候, 优先提取首个 int32 值, 然后可以拿出 int32 去消息列表匹配获取解析器
// 这里模拟一个静态列表管理消息解析器
final Map<Integer, MessageLite> protocols = Map.of(
// 注入相关协议结构体, 基本上所有结构体都继承 MessageLite
10001, LoginReq.getDefaultInstance(),
10002, LoginRes.getDefaultInstance()
);

// 假设提取网络消息内容, 如果不存在该消息ID抛出运行异常
ByteBuffer netMessage = ByteBuffer.wrap(loginReqProtocolBytes);
int msgId = netMessage.getInt();// 首先提取到消息 ID
MessageLite messageLite = protocols.get(msgId);
if (Objects.isNull(messageLite)) {
throw new RuntimeException("Unknown Message ID: " + msgId);
}

// 校验剩余字节是否足够,避免数组越界
int msgLength = netMessage.getInt(); // 提取消息长度
if (netMessage.remaining() < msgLength) {
throw new RuntimeException("Unknown Body Length, remaining: " + netMessage.remaining() + ", length: " + msgLength);
}

// 创建读取的消息内容二进制数组
byte[] msgBody = new byte[msgLength];
netMessage.get(msgBody);
System.out.printf("Net Message - ID: %d, Length: %d, Body: %s%n", msgId, msgLength, Arrays.toString(msgBody));

// 如果存在该协议ID就尝试解析数据
try {
// 确认转化
MessageLite parsedMessage = messageLite.getParserForType().parseFrom(msgBody);
System.out.printf("Net Protobuf Struct: %s%n", parsedMessage.toString().replaceAll("\n", ", "));

// 这里默认是可以强制转换回原来格式
LoginReq parsedLoginReq = (LoginReq) parsedMessage;
System.out.printf("Net LoginReq Struct: %s%n", parsedLoginReq.toString().replaceAll("\n", ", "));

} catch (InvalidProtocolBufferException e) {
throw new RuntimeException("Failed By Protobuf Parse", e);
} catch (ClassCastException e) {
throw new RuntimeException("Message is Not LoginReq", e);
}
}
}

最终输出内容:

1
2
3
4
5
6
7
8
9
10
Enter Request Server ID: 10
Enter Request User ID: 10001
Enter Request Token: test-key
Request Bytes: [8, 10, 16, -111, 78, 26, 8, 116, 101, 115, 116, 45, 107, 101, 121]
Response Clazz: role_id: 1765122011763, nickname: "meteorcat", level: 1648233669, exp: 999999,
Response Bytes: [16, -13, -28, -31, -53, -81, 51, 26, 9, 109, 101, 116, 101, 111, 114, 99, 97, 116, 32, -59, -103, -8, -111, 6, 40, -65, -124, 61]
Login Protocol Bytes: [0, 0, 39, 17, 0, 0, 0, 15, 8, 10, 16, -111, 78, 26, 8, 116, 101, 115, 116, 45, 107, 101, 121]
Net Message - ID: 10001, Length: 15, Body: [8, 10, 16, -111, 78, 26, 8, 116, 101, 115, 116, 45, 107, 101, 121]
Net Protobuf Struct: sid: 10, uid: 10001, token: "test-key",
Net LoginReq Struct: sid: 10, uid: 10001, token: "test-key",

注意这里有个最大的问题, 那就是 Protobuf 消息只对序列化/反序列化数据负责, 而不对数据正确性负责!

以上面说的字节流数据做例子:

1
2
3
4
5
6
7
8
# 请求响应字节流每个 byte 位数据
Request Bytes: [8, 10, 16, -111, 78, 26, 8, 116, 101, 115, 116, 45, 107, 101, 121]
Response Bytes: [16, -13, -28, -31, -53, -81, 51, 26, 9, 109, 101, 116, 101, 111, 114, 99, 97, 116, 32, -59, -103, -8, -111, 6, 40, -65, -124, 61]

// 测试用 LoginRes 协议解析 LoginReq 数据
MessageLite errorMessageLite = protocols.get(10002);
errorMessageLite.getParserForType().parseFrom(msgBody);
请注意: 上面不会有任何报错, 而且数据会正确反序列化处理, 只是数据是错误的

Protobuf 消息解析以 弱类型兼容性优先:

  • 只校验二进制流的 “语法合法性”(如字段编号、数据类型的基础格式)

  • 不校验 “语义匹配性”(如是否属于目标消息类型)

  • 内部字段解析采用 “有数据则填充, 无数据则忽略取默认”

(int16 + int16)(int32) 对于 Protobuf 本身来说都是 int32 没有附加细节说怎么组成, 对程序来说就是 “数据”!

为什么要这么设计? 主要为了保障以下状况而兼容:

  • 当服务端升级消息并新增字段时, 老客户端解析新字节流, 会忽略新增字段从而不会崩溃

  • 当老服务端解析新客户端的字节流, 也会忽略不认识的字段从而正常运行