3.3 协议版本与多版本兼容
前两节把一条消息的字节格式钉死了。但线上永远有两种客户端:刚更新的新版,和还没更新(或不愿更新)的老版——你改了协议,老客户端不会瞬间消失,它们会继续按老格式发消息。这一节处理协议演进,核心是两个问题:客户端版本落后或超前时怎么办,以及同一段载荷怎么让老解码器和新解码器各自安全读出自己认识的部分。答案分两层:登录时先做一次显式的版本协商,把明显不兼容的客户端挡在门外;协商通过后,载荷层用「标签-长度-值」的字段格式,让新旧两端能互相忽略不认识的字段。
客户端落后或超前时怎么办
版本不匹配有两个方向。客户端落后(比服务端老)不认识新加的字段、可能缺少新命令;客户端超前(比服务端新)可能发出服务端还不认识的命令,或带上没定义的新字段。两个方向的处理原则一样:能忽略就忽略(未知字段跳过、未知命令回错误通知),落后太多就直接拒绝登录、提示升级。关键判断是:协议变更是「兼容」还是「破坏」——加一个可选字段是兼容的,改一个已有字段的含义、或删掉一个必填字段是破坏性的;破坏性变更必须靠版本协商挡住,兼容性变更靠字段格式自然吸收。
登录时的版本协商
aegis 在登录这一步做显式协商:客户端上报自己的主版本号,服务端检查它是否落在自己能服务的区间 [Min, Max] 内,然后把结果和区间一起回给客户端。之所以回带区间而不只回一个「是/否」,是因为客户端需要知道往哪个方向调整——Min 告诉它「至少要升到几」,Max 告诉它「最多能升到几」,它便能自助决定升级、降级还是弹窗。
| 客户端版本 | 服务端区间 | 结果 | 处理 |
|---|---|---|---|
| 0 | [1, 3] | 拒绝 | 太老,提示升级 |
| 1 / 2 / 3 | [1, 3] | 接受 | 进入游戏 |
| 4 | [1, 3] | 拒绝 | 太新,服务端需更新 |
字段级向后兼容:新增字段与未知字段
协商只处理「大版本」,日常小改动靠字段格式吸收。aegis 的载荷用最简单的 TLV:每个字段是 标签(1) | 长度(2, BE) | 值(N)。新增字段时老客户端不发它,新服务端解码时该字段保持零值(默认值);未知字段时新客户端多发它,老服务端解码时按 长度 整段跳过、偏移不受影响。
+------+--------+--------+------+--------+--------+------+--------+--------+
| tag1 | len1 | value1 | tag2 | len2 | value2 | tag3 | len3 | value3 |
| 1B | 2B BE | N1 B | 1B | 2B BE | N2 B | 1B | 2B BE | N3 B |
+------+--------+--------+------+--------+--------+------+--------+--------+
老解码器不认识 tag3,于是读它的 len3 直接跳过,绝不越界。这条规则的代价是每个字段多 3 字节的标签与长度开销,换来的是任意一端升级都不会把另一端读崩。标签一旦分配就永不改变含义,删除字段只保留空位——这是整个兼容体系的铁律。
可运行产物:协商与双向兼容
下面 internal/proto/version.go 是完整实现,层级 L1,命令 go test ./internal/proto/。它包含版本协商与字段级解码两部分:Negotiate 给出协商结果,FieldReader/FieldWriter 实现 TLV,DecodePlayerProfileV1 与 DecodePlayerProfileV2 是两个版本各自的解码器。
// file: internal/proto/version.go
// tier: L1
// verify: go test ./internal/proto/
package proto
import (
"encoding/binary"
"io"
)
// ProtocolVersion 是服务端当前实现的主协议版本。
const ProtocolVersion uint16 = 3
// VersionRange 是服务端能同时服务的主版本闭区间。
//
// 客户端版本只有落在 [Min, Max] 内才允许登录:太老解释不了新语义,太新会用到
// 服务端还不认识的字段。区间通常只往前放一个版本——老版本用一个发版周期就下线。
type VersionRange struct {
Min uint16
Max uint16
}
// ServerVersionRange 是 aegis 在《晨曦大陆》上线期对外声明的可服务区间。
var ServerVersionRange = VersionRange{Min: 1, Max: 3}
// LoginResp 是登录响应:无论接受与否都回带服务端可用版本区间。
type LoginResp struct {
ServerVersion uint16
MinVersion uint16
MaxVersion uint16
Accepted bool
Reason string
}
// Negotiate 根据客户端上报的主版本号给出协商结果。
func Negotiate(clientVersion uint16) LoginResp {
resp := LoginResp{
ServerVersion: ProtocolVersion,
MinVersion: ServerVersionRange.Min,
MaxVersion: ServerVersionRange.Max,
}
switch {
case clientVersion < ServerVersionRange.Min:
resp.Reason = "client too old, please update"
case clientVersion > ServerVersionRange.Max:
resp.Reason = "client too new, server update required"
default:
resp.Accepted = true
}
return resp
}
// 载荷字段标签。标签是稳定契约:一旦分配就永不改变含义,删除字段只保留空位。
const (
fieldName uint8 = 1
fieldLevel uint8 = 2
fieldGuild uint8 = 3 // v2 新增
fieldTitle uint8 = 4 // v2 新增
)
// FieldReader 按「标签 | 长度 | 值」逐字段读取载荷。
//
// 这是向后兼容的关键:解码器只关心认识的标签,遇到不认识的,Next 会把它的长度
// 读出来并整段跳过——新客户端多发的字段不会把老服务端读崩。
type FieldReader struct {
buf []byte
off int
}
// NewFieldReader 包装一段载荷。
func NewFieldReader(b []byte) *FieldReader { return &FieldReader{buf: b} }
// Next 返回下一个字段的标签与值。流正常结束返回 ok=false、err=nil;
// 中途截断返回 io.ErrUnexpectedEOF。
func (r *FieldReader) Next() (tag uint8, val []byte, ok bool, err error) {
if r.off >= len(r.buf) {
return 0, nil, false, nil
}
if r.off+3 > len(r.buf) {
return 0, nil, false, io.ErrUnexpectedEOF
}
tag = r.buf[r.off]
l := int(binary.BigEndian.Uint16(r.buf[r.off+1 : r.off+3]))
r.off += 3
if r.off+l > len(r.buf) {
return 0, nil, false, io.ErrUnexpectedEOF
}
val = r.buf[r.off : r.off+l]
r.off += l
return tag, val, true, nil
}
// FieldWriter 按同样的「标签 | 长度 | 值」格式拼载荷。
type FieldWriter struct{ buf []byte }
// NewFieldWriter 预分配 cap 字节。
func NewFieldWriter(cap int) *FieldWriter { return &FieldWriter{buf: make([]byte, 0, cap)} }
// PutBytes 写入一个字节串字段。
func (w *FieldWriter) PutBytes(tag uint8, v []byte) {
w.buf = append(w.buf, tag)
w.buf = binary.BigEndian.AppendUint16(w.buf, uint16(len(v)))
w.buf = append(w.buf, v...)
}
// PutString 写入一个字符串字段。
func (w *FieldWriter) PutString(tag uint8, v string) { w.PutBytes(tag, []byte(v)) }
// PutUint32 写入一个 32 位整数字段。
func (w *FieldWriter) PutUint32(tag uint8, v uint32) {
var tmp [4]byte
binary.BigEndian.PutUint32(tmp[:], v)
w.PutBytes(tag, tmp[:])
}
// Bytes 返回拼好的载荷。
func (w *FieldWriter) Bytes() []byte { return w.buf }
// PlayerProfile 是 v2 的完整语义。
type PlayerProfile struct {
Name string
Level uint32
Guild string // v2 新增:老客户端不发,默认空串
Title string // v2 新增
}
// DecodePlayerProfileV2 用 v2 的语义解码任意版本的载荷:认识的字段取值,不认识的
// 字段跳过,载荷里没出现的字段保持零值——于是老格式会被解成带默认值的新结构。
func DecodePlayerProfileV2(payload []byte) (PlayerProfile, error) {
var p PlayerProfile
r := NewFieldReader(payload)
for {
tag, val, ok, err := r.Next()
if err != nil {
return p, err
}
if !ok {
break
}
switch tag {
case fieldName:
p.Name = string(val)
case fieldLevel:
if len(val) != 4 {
return p, ErrShortFrame
}
p.Level = binary.BigEndian.Uint32(val)
case fieldGuild:
p.Guild = string(val)
case fieldTitle:
p.Title = string(val)
default: // 未知标签:Next 已消费,无需处理。
}
}
return p, nil
}
// DecodePlayerProfileV1 是「只懂 v1」的老解码器:只认识 name 与 level,
// 对 v2 新增的 guild/title 一律走 default 跳过。
func DecodePlayerProfileV1(payload []byte) (PlayerProfile, error) {
var p PlayerProfile
r := NewFieldReader(payload)
for {
tag, val, ok, err := r.Next()
if err != nil {
return p, err
}
if !ok {
break
}
switch tag {
case fieldName:
p.Name = string(val)
case fieldLevel:
if len(val) != 4 {
return p, ErrShortFrame
}
p.Level = binary.BigEndian.Uint32(val)
default: // 含 v2 的 guild/title,直接跳过。
}
}
return p, nil
}
测试覆盖两个方向:TestOldClientNewServer 让新服务端解老客户端发的老格式(新字段应填默认值),TestNewClientOldServer 让老解码器解新客户端发的新格式(未知字段应被安全跳过);另有一条 TestUnknownFieldSkipped 把未知标签插在已知字段中间,验证跳过时偏移不带歪。
// file: internal/proto/version_test.go
// tier: L1
// verify: go test ./internal/proto/
package proto
import (
"io"
"testing"
)
func TestNegotiate(t *testing.T) {
for _, c := range []struct {
client uint16
accepted bool
}{{0, false}, {1, true}, {2, true}, {3, true}, {4, false}} {
got := Negotiate(c.client)
if got.Accepted != c.accepted {
t.Fatalf("client=%d accepted=%v,期望 %v", c.client, got.Accepted, c.accepted)
}
// 无论接受与否都回带服务端区间与自身版本。
if got.ServerVersion != ProtocolVersion || got.MinVersion != ServerVersionRange.Min || got.MaxVersion != ServerVersionRange.Max {
t.Fatalf("client=%d 缺区间信息:%+v", c.client, got)
}
}
if Negotiate(0).Reason == "" || Negotiate(4).Reason == "" {
t.Fatal("拒绝登录必须给出原因")
}
}
func TestOldClientNewServer(t *testing.T) {
// 老客户端(v1)只发 name 与 level。
w := NewFieldWriter(64)
w.PutString(fieldName, "晨曦小骑士")
w.PutUint32(fieldLevel, 42)
got, err := DecodePlayerProfileV2(w.Bytes())
if err != nil {
t.Fatalf("新服务端解码老格式:%v", err)
}
if got.Name != "晨曦小骑士" || got.Level != 42 {
t.Fatalf("老格式语义解错:%+v", got)
}
if got.Guild != "" || got.Title != "" {
t.Fatalf("新字段应填默认值,得到 %+v", got)
}
}
func TestNewClientOldServer(t *testing.T) {
// 新客户端(v2)多发 guild 与 title。
w := NewFieldWriter(128)
w.PutString(fieldName, "晨曦小骑士")
w.PutUint32(fieldLevel, 42)
w.PutString(fieldGuild, "破晓")
w.PutString(fieldTitle, "屠龙者")
got, err := DecodePlayerProfileV1(w.Bytes())
if err != nil {
t.Fatalf("老解码器解码新格式:%v", err)
}
if got.Name != "晨曦小骑士" || got.Level != 42 {
t.Fatalf("老解码器应能读出认识的字段:%+v", got)
}
if got.Guild != "" || got.Title != "" {
t.Fatalf("老解码器不该解出未知字段:%+v", got)
}
}
func TestUnknownFieldSkipped(t *testing.T) {
// 未知标签 99 插在已知字段中间,验证跳过时偏移不带歪。
w := NewFieldWriter(128)
w.PutString(fieldName, "Nova")
w.PutUint32(99, 0xDEADBEEF)
w.PutUint32(fieldLevel, 7)
w.PutString(fieldName, "")
got, err := DecodePlayerProfileV2(w.Bytes())
if err != nil || got.Name != "" || got.Level != 7 {
t.Fatalf("未知标签跳过后偏移错位:%+v err=%v", got, err)
}
}
func TestFieldReaderTruncated(t *testing.T) {
// 声明长度 10 但只给 3 字节值。
bad := []byte{fieldName, 0x00, 0x0A, 'a', 'b', 'c'}
if _, _, _, err := NewFieldReader(bad).Next(); err != io.ErrUnexpectedEOF {
t.Fatalf("截断字段应返回 io.ErrUnexpectedEOF,得到 %v", err)
}
}
本机实跑:ok aegis/internal/proto,本节这 5 条全绿。TestOldClientNewServer 与 TestNewClientOldServer 是一对镜像:前者证明「老格式能被新语义读出正确结果」,后者证明「新格式里的新字段被老解码器安全跳过」。两条都过,才叫双向兼容。
本节小结
- 版本不匹配分落后与超前两个方向:破坏性变更靠登录协商挡住,兼容性变更靠字段格式吸收;载荷用
标签(1) | 长度(2) | 值(N)的 TLV,新增字段填默认值、未知字段整段跳过,标签一旦分配就永不改义,删字段只留空位。
下一章进入序列化:把这套字段格式落到更具体的二进制编解码上。
阅读导航:上一节:3.2 长度前缀、粘包与拆包 · 下一节:4.1 二进制编解码从零写 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。