TFS代码review
block标识
TFS每个block有一个唯一标识,目前的实现是一个uint32_t的整数id,每次新增一个block,就会为其分配新的id,具体实现方式是保存一个全局global_block_id的值,每次分配时就直接将这个值加1做为新的blockid;代码实现大致如下,每次需要分配时就调用generate函数。
class BlockIdFactory { public: uint32_t generate() { return ++global_block_id; } private: uint32_t global_block_id; // 全局的ID会持久化保存,每次启动时先加载 };
最近由于Erasure code项目的需要,我们将blockid升至64bit,因为在代码中使用blockid时都是直接用uint32_t,而且使用的地方特别多,差不多每个源代码文件里都会涉及到。升级blockid为uint64_t类型,意味着我们要将所有使用blockid的地方都修改掉,定义时由uint32_t修改为uint64_t,序列化时(存储在磁盘或通过网络传输)要改为uint64_t的序列化接口等等,总之要修改的地方非常多。
之所以一个简单的类型修改会导致很大的工作量,是因为我们没有很好的隐藏blockid的实现细节,一开始就规定了blockid是一个uint32_t类型的整数,而实际上blockid应该可以是任意类型,数值、字符串类型、或是一个更复杂的结构体。上面的代码通过generate函数(而不是直接在代码中使用new_block_id = ++global_block_id这样的语句)隐藏了blockid的生成细节,如果生成策略改了,只需要修改generate的实现即可;但代码由于代码暴露了blockid的类型细节,还是导致其不易扩展。
如果将blockid的类型抽象为一个BlockidType,在最初实现时,由于其是一个uint32_t的整数,使用typedef将该类型定义为uint32_t,大致的实现如下,所有使用blockid的地方都是用BlockidType,而不是uint32_t,这时,如果需要将blockid升级为64bit,或者是改成字符串类型,我们只需要在BlockIdFactory这个类内部进行修改,所有的修改细节都不会暴露到BlockIdFactory以外的地方,外面看到的仍然是BlockidType类型的blockid。
typedef uint32_t BlockidType; class BlockIdFactory { public: BlockidType generate() { return ++global_block_id; } private: uint32_t global_block_id; };
很多情况下,如果不能很直观的看出一个“对象”的类型,就应该将其定义为抽象数据类型,以方便扩展。比如数组的长度,一本书的页数,一个人的重量,这些能很直观的看出应该是数值类型,可直接根据实际情况使用uint32_t、uint64_t类型;而像本例里block标识,我们不能很直观的看出它究竟是什么,所以最初就应该将其设计为抽象类型。
消息序列化
TFS里所有需要在网络上传输的消息(客户端的请求消息、server的应答消息等),都会实现serialize/deserialize的接口,用于序列化/反序列化,每次增加一个新的消息,就要为这个消息写序列化接口,而这个基本上就是个机械工作,针对成员的类型,调用相应的序列化函数,代码大致如下。
class SomeMessage: public BaseMessage { public: void serialize(DataBuffer& output) { output.write_int32(foo); output.write_int64(bar); } void deserialze(DataBuffer& input) { input.read_int32(&foo); input.read_int64(&bar); } private: int32_t foo; int64_t bar; };
可以看出序列化/反序列化的工作相当“无聊”,基本上都是重复的工作,完全可以通过更好的设计避免掉。曾经见过有人把这些重复的工作用宏代替,使得增加消息的工作量非常小,但大量使用宏终归不是个好的选择,影响代码的可读性,同时也不方便定位问题。使用google protobuf可以方便的解决问题,其编解码的效率以及空间利用率都是非常高的。使用protobuf,你只需要关注消息内容本身,序列化/反序列化的工作它会帮你搞定,要添加新消息、修改现有的消息都非常简单。开源的利器很多,很多开源产品的实现比自己去写相同作用代码的质量都要高的,借助一些成熟的开源产品会使得你要到做的事情更简单,使得你能更好的关注事情本身。
代码复用
有些懒人为了少写消息,将多个消息进行复用,在不同的场景下各个成员代表不同的含义,于是就出现了如下的代码。
class GeneralMessage:public BaseMessage { public: void serialize(DataBuffer& output); void deserialze(DataBuffer& input); private: int32_t type; int64_t value1; int64_t value2; int64_t value3; int64_t value4; };
在实际应用时,每个请求对应一个type,在不同的type下,value1-value4代表不同的含义,比如在block在哪些server上时,value1代表blockid,而在查询server上有哪些block时,value1又代表serverid,服务器端收到消息后,根据type做不同的解释,4个成员以内的请求,基本上都可以复用这条消息,较少了很大部分的代码量,但极大的影响了代码的可读性,我至今都没有完全搞清楚,每种情况下各个value代表什么含义。其实,在上面思路的基础上,再稍加封装,就能既少写代码,又不影响代码的可读性,大致思路如下。
class GeneralMessage:public BaseMessage { public: void serialize(DataBuffer& output); void deserialze(DataBuffer& input); protected: int64_t value1; int64_t value2; int64_t value3; int64_t value4; }; // 编解码消息直接继承,不用实现 class SpecialMessage:public GeneralMessage { public: void set_block_id(const uint64_t block_id) { value1 = block_id; } uint64_t get_block_id() const { return value1; } void set_server_id(const uint64_t server_id) { value2 = server_id; } uint64_t get_server_id() const { return value2 } };
通过上面的封装,用户看到的是SpecialMessgae有两个重要的字段blockid,serverid,分别通过set/get来设置和获取,用户看不到实际这些数据存储在一个“命名不好”的变量里;阅读代码的人也能通过set/get接口,知道这是在设置哪些字段。封转可能带来一些额外的开销,但在绝大多数情况下,封装带来的益处会使得这些“开销”是可以忽略不计的,但也切勿“过度封装”。