作为软件开发人员最担心的就是变化,因为一旦变化,意味着自己的开发任务加重, 轻则修改代码,重则修改框架,如果不用做任何修改,则皆大欢喜,现实告诉我们,这是小概率事件,但比买彩票中大奖的概率还是大很多。于是各种讨论开始,开发人员开始讲述修改如何的大,进度如何紧张,架构师也在一旁不停的唠叨这个修改点的重要性,以及对整个系统带来的好处。
在业界曾经有一句很经典的话:“在软件开发领域中,唯一的不变就是变化” 。一旦变化,就有人遭殃,不是开发人员,就是设计师或架构师。无论谁遭殃,都不得不拥抱变化。
拥抱变化是极限编程(eXtreme Programming)里面一个非常重要的概念,代表了敏捷阵营对于变化的一种态度,那就是不拒绝,而且还主动求变。本文不想探讨敏捷方面的知识,如何去拥抱变化,而是想要探讨程序的可扩展性,如何在编码过程中,以最小的代价来应对程序未来的变化。
关于可扩展性, 其本身就是一个多方面的概念集合。有人说程序的可扩展性必须建立在对未来需求的准确把握上,也有人说程序的可扩展性必须建立在能够对需求变化快速响应上。不论孰是孰非,其最终目的都是要求,能在需求发生变化的时候以最小的代价去应付变化。
可以从两个纬度对可扩展性进行讨论,一是设计可扩展性,二是编码可扩展性,前者从宏观上考虑,后者从微观上考虑,当然编码也是一种设计活动。本文重点论述编码的可扩展性,对于设计可扩展性,是一个系统性工程,由于作者还没有达到那个高度和境界,所以不敢瞎写,本文基本上不做介绍。
《UNIX 编程艺术》一书中有一条关于扩展原则的描述:设计要着眼于未来,未来总比预想快。 关于设计可扩展性, 对于系统架构师或者系统工程师不仅仅要考虑在实现用户需求的基础上如何构建系统,还要考虑计算资源的可扩展、应用规模的可扩展,以及对技术换代的可扩展和性能等。
近期发生的干旱和水灾,每次都能找到人为的因素。本文开头提到的场景,如果进行代码回溯,也能找到一些人为的因素。如果当时的编码者在写代码时充分考虑了代码可扩展性,在一定条件下,可以达到用最小的代价去应对变化。如果当时只是为了完成任务,交差,后续的维护者可能面对的不是拥抱变化,而是拥抱痛苦!
场景一:在某嵌入式电信级设备整框分布式环境中,有NEMI板(管理板),SWF板(业务板),STU板(业务板)和LC板(业务板),每块板上都有CPU,运行着各自的程序。目前的架构仅仅对NEMI/SWF/STU板支持了HA(HighAvailable)功能,在SWF卡上运行的某个业务,需要关注SWF卡的主备倒换事件。 运行在SWF卡上的程序可以收到来自NEMI和SWF卡的主备倒换事件,于是进行了如下编码:
void processSwitchEvent(GenMsg * pMsg)
{
一些合法性判断语句
if (NEMI_SWITCH_EVENT == pMsg -> getSwitchEventGrp())
{
MSG_INFO(“Received NEMI Switch Event……”);
return ;
// process SWF Switch Event
业务处理代码
}
可能开发人员在进行if条件语句编码时,可能还考虑了另外一种写法:
void processSwitchEvent(GenMsg * pMsg)
{
一些合法性判断语句
if (SWF_SWITCH_EVENT == pMsg -> getSwitchEventGrp())
{
MSG_INFO(“Received SWF Switch Event……”);
业务处理代码
}
}
在最初的需求中,上下两种写法都是合适的,但是不是都合理呢?如果一旦需求发生变更,SWF卡上的另外一个业务需要关注STU板的倒换事件,那么STU板的倒换事件也会被广播到SWF卡上,最糟糕的是,这两个业务都订阅了倒换事件(通过消息里面的内容来判断是哪块板发生了倒换),那么上下两种写法的区别就体现出来了,一个能正确运行,而另外一个会把STU的倒换事件当作SWF的倒换事件。不难看出,下面一种写法更具有可扩展性,达到了以最小的代价去应对变化。正是这样小的修改,往往会被忽略,隐藏一个很深的bug,导致花大量的时间去定位。
对于上述的场景,大家编码时常会碰到,觉得这样写也合适,那样写也合适。虽然在一定条件下都很合适,但不一定都合理,那么此时就需要从其他方面加以考虑,如可扩展性,可维护性,可测试性等方面,从而确定哪种写法更合理。
场景二:假定存在如下一个消息类,最初类中只有一个成员变量,消息类的定义和实现如下:
class FsmFileTransferRequest : public GenMsgHdr
{
public :
FsmFileTransferRequest ( void )
{
memset ( & mFileTransferReq, 0 , sizeof (mFileTransferReq));
setMsgType (MTYPE_REQUEST);
setMsgTypeQual (MQUAL_FSM_FILE_TRANSFER_REQUEST);
setPayloadLen ( sizeof (mFileTransferReq));
}
// get/set operation
……
private :
SysPkg::FileTransferRequest mFileTransferReq;
};
对于该消息长度,基类提供了两种接口,一个接口是 setPayloadLen (),另外一个接口是 setMsgLen (),该接口是更高一级的封装,为所传入参数减去基类消息的长度,最终结果还是消息的净荷长度。也许有人会说,基类就不应该提供两套函数,让人迷惑,出错在所难免。
由于场景变化或者需求变更,需要在该类中添加其他的成员变量,维护者可能是这个系统中的另外一个模块的开发者(自己所负责的模块中,构造函数里都是用消息总长度函数,默认其他开发者跟他一样),添加了成员变量和实现后,忘记修改消息的净荷长度,编译并运行,结果与预想的大相径庭,于是开始不停的打断点调试,不断的在怀疑消息是不是丢了,或者没有用修改的代码进行编译,总之,一切该怀疑的都在脑海中闪现了一遍。
或者,意识到要修改消息净荷长度,于是修改成:
setPayloadLen ( sizeof (mFileTransferReq) + sizeof (mSuccessfulFlag));
如果只是一两个成员变量,还能忍受。需求一再变更,又增加了几个成员变量,继续修改,setPayloadLen()里面的代码会越来越长,只是代码写的难看而已。
如果类的实现者,在编写代码时,考虑一下可扩展性,采用消息的总长度函数,那么不论怎么添加成员变量,都不用修改消息长度,一劳永逸。如果确认这个消息不会被扩展,采用 setPayloadLen()也是合理的。
通过以上两个例子可以发现,如果在编码时,充分考虑了编码可扩展性,即使需求发生变更,有时也可以达到事半功倍的效果。关键问题是如何识别出这样的场景,这个只能靠经验了,没有捷径可走!