这篇日志真不好写,已经重写了两遍,每次都是写到一大半时,发现自己对于问题本质的理解不对,于是删了重写,内容也完全不同。希望这次能把它写完。
日志的起因是我们项目里需要写一个测试工具,我写的代码受到了同事的质疑,他认为我的方案要写太多的类,而且宁愿用if...else
来做。
简单的说,这个测试工具,需要对不同Module的不同检查点进行检查。可以假设我们有下面一张表:
||---------------||---------------||---------------||------------------||
|| Module Name || Dir Checker || DB Checker || Storage Checker ||
||---------------||---------------||---------------||------------------||
|| ModuleA || Y || OK || /aaa.xml ||
||---------------||---------------||---------------||------------------||
|| ModuleB || Y || OK || /bbb.xml ||
||---------------||---------------||---------------||------------------||
|| ModuleC || N || FAILED || /ccc.xml ||
||---------------||---------------||---------------||------------------||
我们的测试工具,需要读取这样的一个表,根据其内容对相应的Module和检查点进行检查。
## 我的方案
我的方案是将每个Module的每个检查点,都作为一个独立的类。这样当以后有新的Module或检查点出现时,可以不用修改已有任何代码,只需要创建新类。
对于上表中的ModuleA,定义了三个类:
class ModuleADirChecker {
void check(String shouldInOrNot);
}
class ModuleADbChecker {
void check(String expectedStatus);
}
class ModuleAStorageChecker {
void check(String remotePath);
}
对于ModuleB和C,也将会各有三个类,这样就会有9个类。
如何将每个类与module/checkpoint匹配起来呢?最简单的方式是通过一堆`if...else...`来判断:
public void getChecker(String module, String checkPoint) {
if(module.equals("ModuleA")) {
if(checkPoint.equals("Dir Checker")) {
return new ModuleADirChecker();
} else if(checkPoint.equals("DB Checker")) {
return new ModuleADbChecker();
} else if(checkPoint.equals("Storage Checker")) {
return new ModuleAStorageChecker();
}
} else if(module.equals("ModuleB")) {
...
} else if(module.equals("ModuleC")) {
...
}
}
我想到用注解,可以会让代码更加独立一些,于是写了一个`CheckerAnno`注解,这么用:
@CheckerAnno(module="ModuleA", checkPoint="Dir Checker")
class ModuleADirChecker {
void check(String shouldInOrNot);
}
每个checker上,都放一个`CheckerAnno`,指示它匹配的是哪个module和checkPoint。在程序运行时,将会扫描代码,根据`CheckerAnno`中的数据,自动把各checker与excel表中的单元格匹配起来,生成实例,传入数据进行检查。这样我们就不需要写上面那个长长的`getChcker`方法了,并且以后添加新的Module或者Column时,也不需要修改**任何**已有代码。
## 同事的方案
同事觉得我的方案不好,因为他觉得我的类太多了。而且他发现,某些检查点虽然要检查多个Module,但它们的逻辑非常相似。比如`Dir Checker`仅仅需要不同的Module提供一个不同的值,实际的检查逻辑是一样的。
他给出的方案是:每个检查点一个类,在类里通过`if...else`来针对不同的Module做不同的检查。比如对于`DirChecker`,他是这么做的:
class DirChecker {
public void check(String moduleName, String shouldInOrNot) {
File dir = getDir(moduleName);
checkDir(dir, shouldInOrNot);
}
private File getDir(String moduleName) {
if(moduleName.equal("ModuleA") {
return localTempDirOfModuleA();
} else if(moduleName.equals("ModuleB")) {
return localTempDirOfModuleB();
} else if(moduleName.equals("ModuleC")) {
return localTempDirOfModuleC();
} else {
throw new IllegaArgumentException("Unknown module:" + moduleName);
}
}
}
对于其它的每一个检查点,也都需要定义一些这样的类,每个类中通过`if...else`来决定哪个Module进行哪种检查。
我觉得他这种方式的关键,不在于是否使用了`if...else`,因为他也可以使用注解来实现,跟我那种一样:
@CheckPoint("Dir Checker")
class DirChecker {
@CheckModule("ModuleA")
public void check1(String shouldInOrNot) {
...
}
@CheckModule("ModuleB")
public void check2(String shouldInOrNot) {
...
}
@CheckModule("ModuleC")
public void check3(String shouldInOrNot) {
...
}
}
然后可使用相同的方式扫描注解,将每个check方法与相应的module和checkpoint匹配起来。虽然每当增加一个新Module时,他需要修改已有的类,添加一个新的方法并加上相应的注解,但由于这样的方法相当于一个入口,不会修改已有的逻辑,所以也是可以接受的。
## 第三种方案
可以很自然的想到第三种方式:以行为类。即为每个Module定义一个类,里面提供多个方法分别对应每个检查点。如果用注解的话,大约是这样的:
@CheckerModule("ModuleA")
class ModuleA {
@CheckPoint("Dir Checker")
public void check1(String shouldInOrNot) {}
@CheckPoint("Db Checker")
public void check2(String expectedStatus) {}
@CheckPoint("Storage Checker")
public void check3(String remotePath) {}
}
所以问题的关键就在于粒度:对于这个例子,哪种粒度更好?行?列?还是单元格?
如果说这三种方案都是差不多的,随便选一个就行,那就省事了,看自己的喜好即可。否则的话,我应该以什么为考虑的重点,来决定采用哪一种粒度呢?
有同学说看不懂我在问什么。
的确我也感觉不知道自己在问什么,因为这个问题产生于真实的项目,很多影响我思考的因素也许在本文外,但我还没有意识到。从一个复杂项目中,把一个问题以最简方式完整的剥离出来,本身就是一件很难的事吧。如果真的能把所有有关联的因素抽出来了,所有没有关联的因素都留下了,也许答案自然就出来了,不需要再问了。
看来这个问题只能等我自己解决了。
你的同事对,你错。
你用的技巧,耦合更低,如果基类要提供给第三方,让第三方自己实现派生类,那就很好。
但这个例子是你们的内部测试代码,属于一个模块内部的实现,当然应该遵循“高内聚”而不是“低耦合”原则。那么你同事代码短,耦合重,都是优点。
当然,如果你们部门决定把测试时常用模式提成一个框架,让所有测试代码遵守这个框架的限制。那么作为框架的设计,也许可以重新考虑你用的基类方式。
@杨博,谢谢你高质量的回复。你提到的几点,我想了一下,现在这个测试工具的确已经成了一个测试框架,由测试组的成员使用,不过仅仅是当前项目的测试组内部使用。他们在未来所写的所有测试代码,都要按同样的规则来写。
另外我刚才又回想了一下,发现当时我这么设计还有一个原因:
虽然会以excel表格的形式来定义一些测试,但同时还需要支持纯手写java代码的方式,来实现一些特殊的测试用例。
这些测试用例通常只会对应某一个module,这样我就可以方便的直接实例化某个特定的类,如new ModuleADbChecker().check(…),非常干净。如果用同事的方法,就会变成new DbChecker().check(“ModuleA”,…),感觉调用了一个很大的方法,实际上只用到了其中很小的一块代码。
这样分析的话,我认为我的粒度还是最合适的。多谢你的意见!
类多类少不重要,是否采用switch/case不重要;代码朴素很重要。你怎么也犯java系的通病了呢,”未来扩展”、”代码优美”,这些事在真正发生之前根本一点都不重要,一点都不应该考虑。面向对象,尤其是java系的面向对象,就是纸老虎,还不如手写utils静态方法接地气。
你的方式合理。那个StorageService就是一个util
@liusong1111,哈哈,在现在的公司的大环境下,朴素不起来啊。这些天正在看设计模式、重构这些书,不自觉就想在工作里练练手。
另外还有两个原因:
一是这个项目的客户要对每一个完成的功能点签字,一旦上线再想修改就非常麻烦,所以只能趁上线前多想一点。
二是我们来之前的代码,一方面到处是完全没用上的扩展点,另一方面是朴素的连单元测试都没法加的代码,已经让我们加了两个月的班。
所以我们正在吸取教训,让现在写的代码方便测试,耦合度低一点儿。
我也觉得你同事的方法或许比剩下两种方法更加能够抽出共性来。
例子太小,怎么做都足够灵活。
凡是能够用继承做的地方,用组合做其实都很容易。
但在这个地方明显是 is-a 的关系嘛。
这么说吧,如果以后 StorageService 提供了更丰富的功能的话,那么 UserStorageService 用不用跟进去实现?
如果总是要跟进的话,那么就应该是继承的;如果不必跟进,只是简单利用 StorageService 的 save 和 get 接口的话,那么就组合。
在这个地方来说,难道 UserStorageService 不是应该一直跟进 StorageService 的新功能的吗?
回答@AlsoTang的问题,在我们实际的使用中,发现是把UserStorageService当作一个窄化的接口来用的,即只会使用它里面定义的方法,而不会用到父类里的。所以我现在更肯定它用组合好一些,也许命名方面应该改改。
我觉得不用非得定死按行还是按列分粒度,我觉得对于一个checker,如果逻辑相似(比如你说的DirChecker)就写一个类里,逻辑差距比较大就按module分类