消息序列化
序列化决定消息在网络上长什么样。Cherry 把它抽象成一个可替换的接口,框架的多个部分共用同一份实现。
接口
定义在 facade/serializer.go:
type ISerializer interface {
Marshal(v interface{}) ([]byte, error)
Unmarshal(data []byte, v interface{}) error
Name() string // 标识当前 codec,如 "json" / "protobuf"
}只有三个方法。集群、Actor 系统、网络解析器都通过它做编解码,所以换一个实现就整体换掉了。
两种内置实现
| JSON | Protobuf | |
|---|---|---|
| 构造 | cserializer.NewJSON() | cserializer.NewProtobuf() |
| 底层库 | json-iterator/go | google.golang.org/protobuf/proto |
Name() | json | protobuf |
| 可读性 | 好,便于调试 | 差,二进制 |
| 体积 / 速度 | 一般 | 优 |
import cserializer "github.com/cherry-game/cherry/net/serializer"
app.SetSerializer(cserializer.NewJSON())两者都有 []byte 直通
如果 Marshal 的入参已经是 []byte,两种实现都会原样返回,不再编码。这让上层可以自行控制某些消息的编码方式。
默认是 Protobuf
cherry.Configure(...) 创建的 Application 默认使用 protobuf:
serializer: cserializer.NewProtobuf()所以要用 JSON,必须显式调用 SetSerializer。
设置时机
必须在 app.Startup() 之前设置。
原因是握手包在启动时就预编码并缓存了。Startup() 里加载网络解析器时会构造握手响应,把 app.Serializer().Name() 写进 sys.serializer 字段:
{ "code": 200, "sys": { "heartbeat": 60, "dict": {}, "serializer": "json" } }启动之后再改虽然运行期的 Marshal / Unmarshal 会跟着变,但握手包里缓存的 codec 名已经发出去了,客户端会按错误的格式解析 body。
app := cherry.Configure(...)
app.SetSerializer(cserializer.NewJSON()) // 在 Startup 之前
app.Startup()两种格式的写法
JSON
业务 struct 加 json:"..." tag 即可,无需额外步骤:
type LoginRequest struct {
Nickname string `json:"nickname"`
}
type SyncMessage struct {
Name string `json:"name"`
Content string `json:"content"`
PacketTime int64 `json:"packetTime"`
}tag 里的名字就是客户端看到的名字。改这里等于改协议,客户端必须同步。
Protobuf
protobuf 的实现会对入参做 proto.Message 断言,断言失败直接返回 ProtobufWrongValueType。
所以 struct tag 方式不适用于 protobuf,必须写 .proto 并生成 pb.go:
syntax = "proto3";
package pb;
option go_package = "my-game/pb";
message LoginRequest {
int32 serverId = 1;
string token = 2;
map<int32, string> params = 5;
}生成的类型直接作为 Actor handler 的参数,框架会自动反序列化:
func (p *ActorAgent) login(session *cproto.Session, req *pb.LoginRequest) {
// req 已经反序列化好了
}生成命令:
protoc --go_out=. --go_opt=paths=source_relative protocol/*.proto不需要注册
protobuf 消息不用像 pomelo 原版那样在握手时下发 proto 定义,也不需要注册表。生成的 pb.go 直接参与编译,双方各自持有同一份定义即可。
序列化发生在哪里
理解调用点有助于排查「反序列化失败」这类问题。
| 环节 | 位置 |
|---|---|
| 客户端 body | 保持原始字节,不在这里反序列化 |
| Actor 调用 handler 前 | app.Serializer().Unmarshal(argBytes, argValue) |
| Actor 返回值 | app.Serializer().Marshal(retValue) |
| 发送响应 / 推送 | Response / Push / Kick 内部做 Marshal |
| 跨节点调用参数 | System.marshalArg → Marshal(arg) |
| 跨节点响应 | Unmarshal(rspData, reply) |
客户端消息是延迟反序列化的
从客户端收到的包,body 一路以原始 []byte 传到 Actor 的投递阶段(message.Args = msg.Data),直到真正要调用 handler 时才反序列化。所以「反序列化失败」的报错发生在 Actor 调用处,而不是收包处——排查时不要只盯着网络层。
跨节点消息用的是另一套
别混淆
节点之间传的 ClusterPacket 不使用 ISerializer,而是固定用 protobuf 序列化的 proto 消息。ISerializer 只管 ClusterPacket 内部的业务参数。
所以即使你把序列化器改成 JSON,节点间通信的报文格式也不变。
客户端必须对齐
服务端用哪种 codec,客户端就要用对应的 SDK:
| 服务端 | 客户端 |
|---|---|
NewJSON() | pomelo-client-json.js |
NewProtobuf() | pomelo-client-protobuf.js + 预编译的 pb.js |
客户端可以通过握手响应的 sys.serializer 字段得知服务端用的是哪种。
protobuf 客户端不是用 pomelo 原版的 proto 下发机制,而是直接用 protoc --js_out 生成的 pb.js:
var loginRequest = new proto.pb.LoginRequest();
loginRequest.setServerid(serverId);
loginRequest.setToken(token);
var loginData = loginRequest.serializeBinary(); // Uint8Array
pomelo.request("gate.user.login", loginData, function (data) {
var rsp = proto.pb.LoginResponse.deserializeBinary(data.body).toObject();
});细节见 客户端对接。
选型建议
| 场景 | 建议 |
|---|---|
| 网页调试、快速验证 | JSON,浏览器里能直接看到消息内容 |
| 正式环境 | Protobuf,省带宽、省 CPU |
| 内网低流量服务 | JSON 也可以,可维护性更好 |
可以混用吗
不可以。一个节点的序列化器是全局唯一的,同一节点上的所有连接共用一套 codec。要按连接区分需要自己扩展解析器。