为什么需要序列化?
在服务器内部,大部分数据都是以对象的形式存储的,那么如果可以直接发一个对象发送给客户端或是其他进程来完成通信,既会非常直观方便,也不同意出现不一致等问题。
序列化和反序列化直观上就完成了对象->字节流以及字节流->对象的转化过程。
一般情况下,以下几种情况下,需要用到序列化
跨设备/跨进程,比如服务器和客户端之间的通信,服务器组内部的通信
服务器和存储进程的通信,比如把一个对象存储到redis或者mysql
服务器把数据向外以api接口形式展示给外部,比如通过webapi开放对象数据的查询
序列化方法需要解决的问题
性能(速度和空间),一般主流的算法速度正比于空间,所以不单独讨论两个指标。很多算法(比如protocolbuf)会对数据进行一定的压缩,或通过编码的方式存储来降低空间的开销。
序列化后的内容是否支持可见文本编码,文本存储的好处是,调试非常容易,特别是在开发期间,可以很方便的定位到问题。
是否支持版本升级(比如增加字段,重命名字段)。两端如果处于不同的版本,如果支持版本升级的算法,就具有一定的容错性。但这个功能的实现往往需要通过空间上的额外信息来支持。回过来提一下,一般我们并不需要强制限定通信双方的协议版本/对象版本完全一致(如果真需要这么做完全可以参考.net自带的序列化方法,基于强类型版本号的一致性比对)。
性能 | 文本编码 | 版本升级 | |
跨设备/跨进程 | 高 | 按需 | |
存储 | 低 | √ | |
api接口 | 低 | √ | √ |
这里的第三点api接口方式,一般较多用开源的方案(json),这么做比较有利于游戏开放商和运营平台间的协作。而前两种使用也可以选择开源的protocolbuf类库,下面我就简单讲一下如果要自己实现前两种方案有什么注意做的。
最大化熵的序列化方法
举例,我们需要序列化的对象定义如下
class TestWeapon
{
public int WeaponId;
public string WeaponName;
public float Power;
}
最简单的方式就是,依次序列化/反序列化三个成员变量。而采用BinaryWriter的编码方式可以让序列化结果的熵最大化(不考虑压缩等优化手段)。而这种做法,基本满足了上文关于跨设备/跨进程的需求(高性能,不支持版本升级)。所以服务器内部(一般服务器内部进程都是在一个序列化版本控制之下的),服务器和客户端同信(大部分游戏,服务器和客户端也可以保证是处于一个版本的)就可以采用这种序列化方式。
这时候,我们存储的结果类似于
313|ak47|95
如何支持版本升级
如果我们用上面这种序列化的方式,当在某个版本把对象序列化后存回db,一段时间后在需要把数据取出来,可能就会遇到无法反序列化的情况(可能在Power前新加了一个字段Level)。
所以我们需要一种方法,可以支持对象版本的升级。一个简单的思路是,我们存储时不仅仅存储数据,同时还对字段名进行存储,类似于key-valuie存储的方式。读数据的时候,可以先读取key,在把数据(value)保存到key所对应的存储位置上。
这时候,我们存储的结果类似于(当然实际上是二进制存储的)
WeaponId-313|WeaponName-ak47|Power-95
这就有一个很明显的缺点,字段名重命名了,序列化就会失败,或者说字段名很长的话,序列化性能就会比较差。
所以,我们希望可以优化字段名的存储效率
class TestWeapon
{
[StoreIndex(1)]
public int WeaponId;
[StoreIndex(2)]
public string WeaponName;
[StoreIndex(3)]
public float Power;
}
以此,我们引入了一个Attribute-StoreIndex。通过读取字段的特性值,我们最终存储的数据直接和Index挂钩,而不是字段名(当然就要求类内部的Index不重复)。这时候,我们的存储结果就变成了。
1-313|2-ak47|3-95
效率就非常接近于纯value的序列化了。