在我刚开始写代码时,许多书籍不断教导我遵循正交性、DRY原则(Don’t Repeat Yourself)和SPOT原则(Single Point of Truth)等编程准则。
这些原则确实能够显著提高代码质量和可维护性,我也乐此不疲地在日常开发中应用这些原则。
然而,当我转向编写游戏服务器代码时,却发现自己常常难以严格执行这些原则。
在游戏服务器的开发中,各个模块通常独立存储它们所需的数据。
用关系型数据库的术语来说,每个模块可能会使用独立的table
。不过,游戏服务器的存储方式通常并不是按字段构建table
,而是将结构体(struct
)序列化为blob
格式后进行存储。
模块之间很少通过外键
进行约束,数据一致性和容错性完全依赖于代码的逻辑处理。
通常,模块内部负责将数据存储到数据库。如果多个模块共享一个table
,由于各模块落地时机问题,如果不妥善处理,会大大增加数据不一致的风险。
此外,加上强类型语言的约束以及各模块之间相似却微妙的逻辑差异,迫使开发者反复编写相似但不完全相同的代码。
这些年,我尝试了不同的方法来增加代码的复用。
- 抽象中间结构:当我们需要使用某段代码时,可以将
DB
结构转换成抽象的内存结构,处理完业务逻辑后再将内存结构转换回DB
结构进行存储。 - 复用设计好的
DB
结构:在设计模块数据结构时,复用一些通用的DB
结构,并直接调用相同的算法代码。
然而,这两种方式都有很大的局限性。
例如,方式1可能会转换1000
个数据,但最终只修改其中的1
个数据。不进行转换是不行的,因为修改一个元素时可能会读取其他数据。一个典型的例子就是抽卡。
方式2的复用性更差,举例说明:
struct common {
// 一些公共字段
}
struct entry_a {
struct common c;
// 其他字段
}
struct entry_b {
struct common c;
// 其他字段
}
struct module_a {
struct entry_a[] list;
}
struct module_b {
struct entry_b[] list;
}
由于强类型语言的限制,几乎不可能直接用相同的代码同时处理module_a.list
和module_b.list
。
现实中的情况更复杂,比如entry_?.other_fields
往往会以某种方式影响struct common[] list
的逻辑。
在我以前的游戏服务器开发中,我一直被数据库落地
给禁锢了。
我一直认为数据库落地的不一致
是无法解决的,因此也就不可能抽象出公共模块来存储并处理不同模块中相同的逻辑。
这就导致了,在进行代码设计时,无法自由的进行抽象。从而在方式1
和方式2
之间徘徊。
直到最近我终于找到了部分答案。
解决方案其实也很简单:模块不再负责数据的落地,而是通知落地框架
需要落地的数据。
落地框架
将在合适的时机(如每秒, 或每条协议之后)统一处理所有脏数据的落地。
如果在落地之前服务器崩溃,丢失的也仅仅是最后一秒的修改,相当于游戏回档到1秒前。
这对玩家几乎没有影响(充值问题可以通过掉单处理,充值服务器只需重新补发1秒前的订单即可)。
为强类型语言的游戏服务器设计这个落地框架
会比较麻烦,但收益是巨大的。
从此在进行数据库抽象时,我们不必再有数据库的心智负担。可以像普通应用程序那样进行自由的抽象。
“通常,模块内部负责将数据存储到数据库。如果多个模块共享一个table,由于各模块落地时机问题,如果不妥善处理,会大大增加数据不一致的风险。
此外,加上强类型语言的约束以及各模块之间相似却微妙的逻辑差异,迫使开发者反复编写相似但不完全相同的代码。” 大量cv就是这么出现的。。。加中间高速同步读写缓存层确实可以大大降低心智负担