我们常常遇到这样的场景,在项目A中开发了很多功能,这时在项目B中遇到要开发和A中类似的功能,这时会怎么做?
很多人会不假思索的找到原来A项目中相关的功能代码,拷贝到B项目中,然后修改。
几乎人人都这么做过,这样做有些什么问题呢?
首先,由于代码常常混杂在一起,并不那么容易拷贝。特别是当代码不是自己写的,往往会一下拷贝了过多的无关内容到新项目中。
其次,需要把功能中的非通用部分(只适用于项目A的部分)修改掉,以与当前项目匹配,这对于复杂的功能,特别是通用逻辑与专用逻辑交错在一起的模块,也是挺费时的工作。
这种代码难以复用的问题屡见不鲜,我们于是希望能把这些功能做成开箱即用的的可复用模块。
所谓可复用模块,就是当你在别的项目中使用它时,不需要改它内部的任何东西,只需要按接口文档配置它的选项、实现它要求的接口,再调用它的接口就可以了。
对照这个标准,看看你写的东西有多少可以复用的呢?
那么是不是从一开始设计或编码的时候,就要以可复用的标准来设计函数或模块呢?
如果你认为这样才是优秀的设计和良好的习惯,那么你一定要小心别犯过度设计的毛病。
很多人读过设计模式,于是在项目中看到大量的factory/strategy/adapter等等。多数设计模式都是为了降低模块耦合,其实就是在设计可复用模块。
然而模块是不是需要复用,更多的是看经验,而不是去套用模式。滥用模式必然导致过度设计。
多数时候,一开始并不知道哪些东西需要复用,很多自以为是的拆分往往都被证明并不必要。
过度设计和设计不足都会导致代码难以理解,而且过度设计还会在一开始耗费更多的时间。
因此,不必试图从一开始就设计可复用模块,因为你常常并不知道哪些是真正需要被复用的。
运用重构思维,按需创建可复用模块
那么如何生成可复用的模块呢?
很简单,当你发现某个功能和已有的一个功能很像,却又不完全一样,这就给你制作可复用模块的信号了;如果这种情况再出现第三次第四次,显然这块功能就是有复用价值的。
这就是常见的lazy-loading机制的翻版,不在一开始设计可复用模块,而是在需要复用的时刻,通过重构代码来复用。
而到底是在出现第一次复用机会时就重构,还是第二次或第三次时才重构,就得取决于重构的代价与获得好处的权衡了。如果难度不太大,风险也可控,我们一般都是鼓励尽早重构的。
很多人天天做类似的编码,工作输出却还是线性的,为什么做第二次第三次仍然要花费差不多的精力?
古话说的好,磨刀不误砍柴功,我们需要的是立即停止重复,立即开始重构。
重构并不都是大刀阔斧的把项目大卸八块,更常见的是微重构,往往就是当你发现一块逻辑可以复用,就把它独立成了一个函数,然后函数多了,把它们分类放在一起,便于理解和修改,就形成了可复用模块的雏形。
优秀的程序员会如同有洁癖般憎恨重复,遇到重复就会忍不住做代码优化,这其实就是以重构思维,动态地渐近地演化的过程,而这一过程往往就会生产出很多有用的轮子。
如何面对重构的挑战
前面谈到复用的好处,以及鼓励通过重构而不是一开始庞大的设计来演化出可复用模块。作为代价,它不仅带来额外的开发时间,而且对软件质量的挑战是极大的,尤其是使用无须编译的弱类型的动态语言做开发。
简单的修改个函数名或加个参数,都需要人工仔细检查所有项目代码,即使这样也不牢靠。
只能靠测试用例来覆盖受影响的代码,所以,创建回归测试,覆盖主要业务逻辑,是敢于对项目进行重构的前提条件。
可复用模块的演化之路
说了这么多,下面举个例子吧,看看可复用模块是怎样演化出来的。
就以javascript来举例吧。假设一开始进入某H5应用,需要尝试自动登录到系统,面条式代码如下:
function initApp()
{
// 初始化环境,20行
// 尝试自动登录,50行
// 登录成功做些操作,10行
// 自动登录不成功跳转到登录页
}
这时并没有什么问题,运行很好。当写到登录页时,发现登录成功后也要做某些操作,和initApp里那些代码类似,为避免重复,这时就做个微重构,抽出一个共用函数handleLogin来:
function handleLogin(data)
{
// 登录成功做些操作,10行
}
function initApp()
{
...
handleLogin(data);
...
}
function login()
{
callSvr("login", function (data) {
handleLogin(data);
// ...
});
// ...
}
之后,又遇到一个逻辑,如果调用后台接口返回“未登录”错误时,可以先尝试一下自动登录,如果能登录成功就重新调用接口。
做这个逻辑时,想到“自动登录”代码在initApp里面有,那么也借机把它抽出来吧:
// ----- login相关函数 -------
function handleLogin(data)
{
// 登录成功做些操作,10行
}
function tryAutoLogin()
{
// 尝试自动登录,50行
}
// ---------------------------
function initApp()
{
...
tryAutoLogin();
handleLogin(data);
...
}
function callSvr(action)
{
$.ajax({
...
success: function (data) {
if (errorCode == E_NOLOGIN) {
if (tryAutoLogin() == OK) {
// 重新调用接口
}
else {
// 跳转登录页
}
}
}
});
}
像上面,我们把login相关函数放到一起,就已经初步形成了一个模块。这时,另一个项目也需要类似功能时,复制代码就变的容易了。
实际代码中这个模块会有很多函数以及相关变量,有些会被外部使用,有些只在模块内部使用,还有些会依赖外部的变量或函数。
使用者需要了解模块内大量的细节才能集成到新的项目中。
这为构建可复用模块提供了一个好机会。
为了隐藏复杂的模块内细节,我们把模块提供的公共接口,以及模块依赖的接口明确列举出来,形成文档,把其它内部实现隐藏起来。
这样,tryAutoLogin和handleLogin就成为模块的公共接口,会被外面调用;而模块需要外部提供一个callSvr函数,用于调用服务端接口,有了它模块才能工作。
我们用@module, @fn, @var这些标记直接在代码里通过注释来描述公共接口,便于使用者查看文档,也可以通过工具生成漂亮的文档。
/**
@module Login
(模块文档)
登录模块。使用示例:
$.extends(Login.options, {
callSvr: function (ac) {
$.ajax(...)
}
});
Login.tryAutoLogin();
Login.handleLogin(data);
*/
var Login = new LoginModule();
function LoginModule()
{
var self = this;
/**
@var Login.options = {callSvr(ac)}
*/
self.options = {
callSvr: function (ac) {
throw new Exception("callSvr is NOT implemented");
}
};
/**
@fn Login.handleLogin(data)
(公共函数文档)
*/
self.handleLogin = handleLogin;
function handleLogin(data)
{
// 登录成功做些操作,10行
}
/**
@fn Login.tryAutoLogin()
*/
self.tryAutoLogin = tryAutoLogin;
function tryAutoLogin()
{
// 尝试自动登录,50行
}
// 某内部函数,使用者无须关注,外部也无法调用
function privateFn()
{
}
}
上面就是一个典型的代码演化过程,通过不断遇到重复逻辑时重构代码,产生了模块,而当其它项目中又需要这块功能时,索性把这个模块独立出来,生成了一个有着良好封装和使用文档的可复用模块。
如果条件允许,把它贡献到开源社区是个不错的选择。
之后,如果遇到两个项目中处理不一样的情况,比如在项目B中handleLogin需要额外再做某操作,而项目A中不要此操作,常常在options中添加配置项就行了,比如
self.options = {
...,
doA: false
};
function handleLogin(data)
{
// 登录成功做些操作,10行
if (self.options.doA) {
doA();
}
}
随着options越来越多,模块被复用也越来越多,是不是会为创建了一个很有用的模块而感到高兴呢?
(注:作者为原SAP系统重构专家,“分布式访问和控制架构”的总架构师,开源快速应用开发框架“筋斗云”的主要作者)