自定义协议编解码
概述
游戏客户端与服务器之间传递的是二进制消息,双方必须约定一套字节级协议:一段字节里,哪部分是操作码、哪部分是长度、哪部分是消息体。本文设计一套游戏服务器常用的自定义协议:消息头(Magic Number + Version + Serializer + Opcode + Length)+ 消息体(Protobuf),并给出完整的编解码实现与 Protobuf 集成方案。
一、为什么需要自定义协议
1.1 协议要解决的四件事
1. 边界:一条消息在哪结束(粘包拆包)
2. 路由:这条消息该交给谁处理(Opcode)
3. 序列化:消息体用什么格式编码(Serializer)
4. 安全:识别非法连接、兼容版本升级(Magic/Version)1.2 为什么不用 HTTP / 现成 RPC
| 方案 | 问题 |
|---|---|
| HTTP | 请求-响应模型,不适合服务器主动推送 |
| gRPC | 强绑定其服务定义,实时广播场景笨重 |
| JSON over TCP | 无边界、体积大、解析慢 |
| 纯自定义字节协议 | 灵活、可控、高效,游戏业主流 |
结论:
游戏协议"自定义消息头 + Protobuf 消息体"是工程共识
自定义头解决边界与路由,Protobuf 解决消息体序列化二、消息头设计
2.1 总体结构
| Magic(2B) | Version(1B) | Serializer(1B) | Opcode(2B) | Length(4B) | Body(N) |
|-----------|---|------------|-----|-------|----|
头固定 10 字节,Body 由 Length 决定| 字段 | 大小 | 含义 |
|---|---|---|
| Magic Number | 2 字节 | 魔数,校验是否为游戏消息 |
| Version | 1 字节 | 协议版本号 |
| Serializer | 1 字节 | 序列化方式编号 |
| Opcode | 2 字节 | 消息操作码(命令字) |
| Length | 4 字节 | 消息体长度(不含头) |
| Body | 不定 | 消息体,通常是 Protobuf 编码 |
2.2 各字段设计意图
Magic Number(魔数)
作用:
快速识别非法连接/错连端口的数据
随机数据第一个字段就对不上 → 直接断开
取值建议:
两个字节,如 0x4A 0x47(JG,Java Game)
不宜用过于简单的值(如 0x01 0x02)Version(版本号)
作用:
服务器支持多版本客户端
版本不兼容时给出提示而非静默解析错误
取值建议:
1 字节 0-255;大版本变更时递增
兼容策略:小版本向后兼容,不兼容则拒绝连接Serializer(序列化方式)
作用:
消息体可用不同序列化格式(可演进)
0 = Protobuf,1 = JSON,2 = MessagePack
取值建议:
前期固定 Protobuf(值 0)
保留字段以便未来切换/混用Opcode(操作码)
作用:
标识消息的"命令字",如登录、出牌、心跳
是消息分发路由的关键(见消息分发篇)
取值建议:
2 字节 0-65535,足够游戏使用
分段规划:1-100 系统消息,101+ 玩法消息Length(长度)
作用:
粘包拆包的切分依据(见粘包拆包篇)
配合 maxFrameLength 防超长攻击
取值建议:
4 字节(int),上限设 16KB 以内三、编解码实现
3.1 消息对象模型
java
public class GameMessage {
public static final int HEADER_LENGTH = 10;
private byte version;
private byte serializer;
private short opcode;
private byte[] body;
public GameMessage(short opcode, byte[] body) {
this.version = 1;
this.serializer = 0;
this.opcode = opcode;
this.body = body;
}
// getter / setter 略
}3.2 解码器:字节 → 消息对象
java
public class GameMessageDecoder extends ByteToMessageDecoder {
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
if (in.readableBytes() < GameMessage.HEADER_LENGTH) {
return; // 头都不足,等下一批数据
}
in.markReaderIndex();
short magic = in.readShort();
if (magic != MAGIC_NUMBER) {
ctx.close(); // 魔数不对,非游戏连接
return;
}
byte version = in.readByte();
byte serializer = in.readByte();
short opcode = in.readShort();
int length = in.readInt();
if (length < 0 || length > MAX_BODY_LENGTH) {
ctx.close(); // 长度非法,防攻击
return;
}
if (in.readableBytes() < length) {
in.resetReaderIndex(); // 体未到齐,回退等数据
return;
}
byte[] body = new byte[length];
in.readBytes(body);
out.add(new GameMessage(version, serializer, opcode, body));
}
}3.3 编码器:消息对象 → 字节
java
public class GameMessageEncoder extends MessageToByteEncoder<GameMessage> {
@Override
protected void encode(ChannelHandlerContext ctx, GameMessage msg, ByteBuf out) {
out.writeShort(MAGIC_NUMBER);
out.writeByte(msg.getVersion());
out.writeByte(msg.getSerializer());
out.writeShort(msg.getOpcode());
out.writeInt(msg.getBody().length);
out.writeBytes(msg.getBody());
}
}编解码注意点:
读写字段顺序必须与协议布局完全一致
解码校验顺序:魔数 → 长度 → 边界
编码时 Body 为空也要写 Length = 0(保持头固定)四、Protobuf 编解码集成
4.1 为什么选 Protobuf
| 序列化 | 体积 | 速度 | 跨语言 | 兼容性 |
|---|---|---|---|---|
| Protobuf | 小 | 快 | 好 | 强(字段号演进) |
| JSON | 大 | 慢 | 好 | 中 |
| MessagePack | 中 | 中 | 中 | 中 |
| FlatBuffers | 小 | 极快 | 好 | 强(零拷贝) |
选型结论:
小游戏客户端(微信小游戏 JS 也支持)
跨端(iOS / Android / 小游戏)
高效且向后兼容 → Protobuf 是默认首选
FlatBuffers 用于极致实时场景(帧同步)4.2 定义 proto 文件
protobuf
syntax = "proto3";
package game.msg;
message HeartbeatReq {
int64 clientTime = 1;
}
message LoginReq {
string token = 1;
int32 channel = 2;
}
message LoginResp {
int32 code = 1;
int64 playerId = 2;
string nickname = 3;
}proto 编写规范:
字段号一旦发布不可复用(兼容关键)
enum 第一值必须是 0
常用 proto 放一个 module,按玩法分包4.3 Maven 编译集成
xml
<plugin>
<groupId>org.xolstice.maven.plugins</groupId>
<artifactId>protobuf-maven-plugin</artifactId>
<version>0.6.1</version>
<configuration>
<protocArtifact>com.google.protobuf:protoc:3.21.12:exe:${os.detected.classifier}</protocArtifact>
<protoSourceRoot>src/main/proto</protoSourceRoot>
</configuration>
<executions>
<execution>
<goals>
<goal>compile</goal>
</goals>
</execution>
</executions>
</plugin>使用说明:
proto 文件放 src/main/proto 目录
编译生成 xxxOuterClass(proto 文件无 package 时)
mvn compile 自动生成 Java 代码4.4 解码器内反序列化
java
// 消息分发时按 Opcode 反序列化 body
byte[] body = msg.getBody();
if (msg.getOpcode() == OP_LOGIN) {
LoginReq req = LoginReq.parseFrom(body);
// 处理登录
}性能提示:
parseFrom 有一定开销,尽量在业务线程执行
高频消息(位置同步)考虑用 flatbuffers 或直接字节操作五、版本升级与兼容
5.1 协议演进策略
| 变更类型 | 处理方式 |
|---|---|
| 新增字段 | 新增字段号,旧客户端不感知(proto 天然支持) |
| 废弃字段 | 保留字段号,不再使用(不可复用) |
| 修改消息头布局 | 提升 Version,新旧兼容期内双协议并存 |
| 新增 Opcode | 直接新增,不影响已有消息 |
5.2 兼容检查清单
升级前检查:
头字段顺序是否变化(是 → Version 提升)
是否有字段号被复用(禁止)
Length 语义是否变化(同步更新编解码)
服务端是否同时兼容新旧版本六、安全加固
协议层安全要点:
魔数校验 → 拒绝非游戏流量
长度校验 → 拒绝超长/负数,防内存攻击
心跳超时 → 清理僵尸连接
登录前限流 → 防止恶意连接耗尽资源
后续加密:AES 加密 Body、HMAC 防篡改(见安全章节)七、小结
自定义协议的设计核心是**"头 + 体"模型**:固定 10 字节消息头承担魔数校验、版本管理、序列化标识、操作码路由、长度切分五重职责,消息体用 Protobuf 高效序列化并天然支持字段演进。编解码落地为 ByteToMessageDecoder 与 MessageToByteEncoder 两个 Handler,配合 protobuf-maven-plugin 自动生成 Java 代码。协议一旦发布,字段号与头布局就成为兼容性契约,后续演进靠 Version 与字段号规则管理,这是游戏服务器长期稳定的地基。