⾼质量代码有三要素:可读性、可维护性、可变更性
今天这堂培训课讲什么呢?我既不讲Spring,也不讲Hibernate,更不讲Ext,我不讲任何⼀个具体的技术。我们抛开任何具体的技术,来谈谈如何提⾼代码质量。如何提⾼代码质量,相信不仅是在座所有⼈苦恼的事情,也是所有软件项⽬苦恼的事情。如何提⾼代码质量呢,我认为我们⾸先要理解什么是⾼质量的代码。
  ⾼质量代码的三要素
  我们评价⾼质量代码有三要素:可读性、可维护性、可变更性。我们的代码要⼀个都不能少地达到了这三要素的要求才能算⾼质量的代码。
  1. 可读性强
  ⼀提到可读性似乎有⼀些⽼⽣常谈的味道,但令⼈沮丧的是,虽然⼤家⼀⽽再,再⽽三地强调可读性,但我们的代码在可读性⽅⾯依然做得⾮常糟糕。由于⼯作的需要,我常常需要去阅读他⼈的代码,维护他⼈设计的模块。每当我看到⼤段⼤段、密密⿇⿇的代码,⽽且还没有任何的注释时常常感慨不已,深深体会到了这项⼯作的重要。由于分⼯的需要,我们写的代码难免需要别⼈去阅读和维护的。⽽对于许多程序员来说,他们很少去阅读和维护别⼈的代码。正因为如此,他们很少关注代码的可读性,也对如何提⾼代
码的可读性缺乏切⾝体会。有时即使为代码编写了注释,也常常是注释语⾔晦涩难懂形同天书,令阅读者反复斟酌依然不明其意。针对以上问题,我给⼤家以下建议:
  1)不要编写⼤段的代码
  如果你有阅读他⼈代码的经验,当你看到别⼈写的⼤段⼤段的代码,⽽且还不怎么带注释,你是怎样的感觉,是不是“嗡”地⼀声头⼤。各种各样的功能纠缠在⼀个⽅法中,各种变量来回调⽤,相信任何⼈多不会认为它是⾼质量的代码,但却频繁地出现在我们编写的程序了。如果现在你再回顾⾃⼰写过的代码,你会发现,稍微编写⼀个复杂的功能,⼏百⾏的代码就出去了。⼀些⽐较好的办法就是分段。将⼤段的代码经过整理,分为功能相对独⽴的⼀段⼜⼀段,并且在每段的前端编写⼀段注释。这样的编写,⽐前⾯那些杂乱⽆章的⼤段代码确实进步了不少,但它们在功能独⽴性、可复⽤性、可维护性⽅⾯依然不尽⼈意。从另⼀个⽐较专业的评价标准来说,它没有实现低耦合、⾼内聚。我给⼤家的建议是,将这些相对独⽴的段落另外封装成⼀个⼜⼀个的函数。
  许多⼤师在⾃⼰的经典书籍中,都⿎励我们在编写代码的过程中应当养成不断重构的习惯。我们在编写代码的过程中常常要编写⼀些复杂的功能,起初是写在⼀个类的⼀个函数中。随着功能的逐渐展开,我们开始对复杂功能进⾏归纳整理,整理出了⼀个⼜⼀个的独⽴功能。这些独⽴功能有它与其它功能相互交流的输⼊输出数据。当我们分析到此处时,我们会⾮常⾃然地要将这些功能从原函数中分离出来,
形成⼀个⼜⼀个独⽴的函数,供原函数调⽤。在编写这些函数时,我们应当仔细思考⼀下,为它们取⼀个释义名称,并为它们编写注释(后⾯还将详细讨论这个问题)。另⼀个需要思考的问题是,这些函数应当放到什么地⽅。这些函数可能放在原类中,也可能放到其它相应职责的类中,其遵循的原则应当是“职责驱动设计”(后⾯也将详细描述)。
  下⾯是我编写的⼀个从XML⽂件中读取数据,将其⽣成⼯⼚的⼀个类。这个类最主要的⼀段程序就是初始化⼯⼚,该功能归纳起来就是三部分功能:⽤各种⽅式尝试读取⽂件、以DOM的⽅式解析XML数据流、⽣成⼯⼚。⽽这些功能被我归纳整理后封装在⼀个不同的函数中,并且为其取了释义名称和编写了注释:
  Java代码
/**
* 初始化⼯⼚。根据路径读取XML⽂件,将XML⽂件中的数据装载到⼯⼚中
* @param path XML的路径
*/
public void initFactory(String path){
if(findOnlyOneFileByClassPath(path)){return;}
if(findResourcesByUrl(path)){return;}
if(findResourcesByFile(path)){return;}
this.paths = new String[]{path};
}
/**
* 初始化⼯⼚。根据路径列表依次读取XML⽂件,将XML⽂件中的数据装载到⼯⼚中
* @param paths 路径列表
*/
public void initFactory(String[] paths){
for(int i=0; i<paths.length; i++){
initFactory(paths[i]);
}
this.paths = paths;
}
/**
* 重新初始化⼯⼚,初始化所需的参数,为上⼀次初始化⼯⼚所⽤的参数。
*/
public void reloadFactory(){
initFactory(this.paths);
}
/**
* 采⽤ClassLoader的⽅式试图查⼀个⽂件,并调⽤<code>readXmlStream()</code>进⾏解析
* @param path XML⽂件的路径
* @return是否成功
*/
protected boolean findOnlyOneFileByClassPath(String path){
boolean success = false;
try {
Resource resource = new ClassPathResource(path, Class());
resource.Filter());
InputStream is = InputStream();
if(is==null){return false;}
readXmlStream(is);
success = true;
} catch (SAXException e) {
log.debug("Error when findOnlyOneFileByClassPath:"+path,e);
} catch (IOException e) {
log.debug("Error when findOnlyOneFileByClassPath:"+path,e);
} catch (ParserConfigurationException e) {
log.debug("Error when findOnlyOneFileByClassPath:"+path,e);
}
return success;
}
/
**
* 采⽤URL的⽅式试图查⼀个⽬录中的所有XML⽂件,并调⽤<code>readXmlStream()</code>进⾏解析怎么写代码做软件
* @param path XML⽂件的路径
* @return是否成功
*/
protected boolean findResourcesByUrl(String path){
boolean success = false;
try {
ResourcePath resourcePath = new PathMatchResource(path, Class());
resourcePath.Filter());
Resource[] loaders = Resources();
for(int i=0; i<loaders.length; i++){
InputStream is = loaders[i].getInputStream();
if(is!=null){
readXmlStream(is);
success = true;
}
}
} catch (SAXException e) {
log.debug("Error when findResourcesByUrl:"+path,e);
} catch (IOException e) {
log.debug("Error when findResourcesByUrl:"+path,e);
} catch (ParserConfigurationException e) {
log.debug("Error when findResourcesByUrl:"+path,e);
}
return success;
}
/**
* ⽤File的⽅式试图查⽂件,并调⽤<code>readXmlStream()</code>解析
* @param path XML⽂件的路径
* @return是否成功
*/
protected boolean findResourcesByFile(String path){
boolean success = false;
FileResource loader = new FileResource(new File(path));
loader.Filter());
try {
Resource[] loaders = Resources();
if(loaders==null){return false;}
for(int i=0; i<loaders.length; i++){
InputStream is = loaders[i].getInputStream();
if(is!=null){
readXmlStream(is);
success = true;
}
}
} catch (IOException e) {
log.debug("Error when findResourcesByFile:"+path,e);
} catch (SAXException e) {
log.debug("Error when findResourcesByFile:"+path,e);
} catch (ParserConfigurationException e) {
log.debug("Error when findResourcesByFile:"+path,e);
}
return success;
}
/
**
* 读取并解析⼀个XML的⽂件输⼊流,以Element的形式获取XML的根,
* 然后调⽤<code>buildFactory(Element)</code>构建⼯⼚
* @param inputStream ⽂件输⼊流
* @throws SAXException
* @throws IOException
* @throws ParserConfigurationException
*/
protected void readXmlStream(InputStream inputStream) throws SAXException, IOException, ParserConfigurationException{ if(inputStream==null){
throw new ParserConfigurationException("Cann't parse source because of InputStream is null!");
}
DocumentBuilderFactory factory = wInstance();
factory.setValidating(this.isValidating());
factory.setNamespaceAware(this.isNamespaceAware());
DocumentBuilder build = wDocumentBuilder();
Document doc = build.parse(new InputSource(inputStream));
Element root = DocumentElement();
buildFactory(root);
}
/**
* ⽤从⼀个XML的⽂件中读取的数据构建⼯⼚
* @param root 从⼀个XML的⽂件中读取的数据的根
*/
protected abstract void buildFactory(Element root);
  在编写代码的过程中,通常有两种不同的⽅式。⼀种是从下往上编写,也就是按照顺序,每分出去⼀个函数,都要将这个函数编写完,才回到主程序,继续往下编写。⽽⼀些更有经验的程序员会采⽤另外⼀种从上往下的编写⽅式。当他们在编写程序的时候,每个被分出去的程序,可以暂时只写⼀个空程序⽽不去具体实现功能。当主程序完成以后,再⼀个个实现它的所有⼦程序。采⽤这样的编写⽅式,可以使复杂程序有更好的规划,避免只见树⽊不见森林的弊病。
  有多少代码就算⼤段代码,每个⼈有⾃⼰的理解。我编写代码,每当达到15~20⾏的时候,我就开始考虑是否需要重构代码。同理,⼀个类也不应当有太多的函数,当函数达到⼀定程度的时候就应该考虑分为多个类了;⼀个包也不应当有太多的类······
  2)释义名称与注释
  我们在命名变量、函数、属性、类以及包的时候,应当仔细想想,使名称更加符合相应的功能。我们常常在说,设计⼀个系统时应当有⼀个或多个系统分析师对整个系统的包、类以及相关的函数和属性进⾏规划,但在通常的项⽬中这都⾮常难于做到。对它们的命名更多的还是程序员来完成。但是,在⼀个
项⽬开始的时候,应当对项⽬的命名出台⼀个规范。譬如,在我的项⽬中规定,新增记录⽤new或add开头,更新记录⽤edit或mod开头,删除⽤del开头,查询⽤find或query开头。使⽤最乱的就是get,因此我规定,get开头的函数仅仅⽤于获取类属性。
  注释是每个项⽬组都在不断强调的,可是依然有许多的代码没有任何的注释。为什么呢?因为每个项⽬在开发过程中往往时间都是⾮常紧的。在紧张的代码开发过程中,注释往往就渐渐地被忽略了。利⽤开发⼯具的代码编写模板也许可以解决这个问题。
  ⽤我们常⽤的MyEclipse为例,在菜单“window>>Preferences>>Java>>Code Style>>Code Templates>>Comments”中,可以简单的修改⼀下。
  “Files”代表的是我们每新建⼀个⽂件(可能是类也可能是接⼝)时编写的注释,我通常设定为:
  Java代码
/*
* created on ${date}
*/
  “Types”代表的是我们新建的接⼝或类前的注释,我通常设定为:
  Java代码
/**
*
* @author ${user}
*/
  第⼀⾏为⼀个空⾏,是⽤于你写该类的注释。如果你采⽤“职责驱动设计”,这⾥⾸先应当描述的是该类的职责。如果需要,你可以写该类⼀些重要的⽅法及其⽤法、该类的属性及其中⽂含义等。
  ${user}代表的是你在windows中登陆的⽤户名。如果这个⽤户名不是你的名称,你可以直接写死为你⾃⼰的名称。
  其它我通常都保持为默认值。通过以上设定,你在创建类或接⼝的时候,系统将⾃动为你编写好注释,然后你可以在这个基础上进⾏修
改,⼤⼤提⾼注释编写的效率。
  同时,如果你在代码中新增了⼀个函数时,通过Alt+Shift+J快捷键,可以按照模板快速添加注释。
  在编写代码时如果你编写的是⼀个接⼝或抽象类,我还建议你在@author后⾯增加@see注释,将该接⼝或抽象类的所有实现类列出来,因为阅读者在阅读的时候,寻接⼝或抽象类的实现类⽐较困难。
  Java代码
/**
* 抽象的单表数组查询实现类,仅⽤于单表查询
* @author范钢
* @see com.htxx.support.query.DefaultArrayQuery
* @see com.htxx.support.query.DwrQuery
*/
public abstract class ArrayQuery implements ISingleQuery {
...
  2. 可维护性
  软件的可维护性有⼏层意思,⾸先的意思就是能够适应软件在部署和使⽤中的各种情况。从这个⾓度上来说,它对我们的软件提出的要求就是不能将代码写死。
  1)代码不能写死
  我曾经见我的同事将系统要读取的⼀个⽇志⽂件指定在C盘的⼀个固定⽬录下,如果系统部署时没有这个⽬录以及这个⽂件就会出错。如果他将这个决定路径下的⽬录改为相对路径,或者通过⼀个属性⽂件
可以修改,代码岂不就写活了。⼀般来说,我在设计中需要使⽤⽇志⽂件、属性⽂件、配置⽂件,通常都是以下⼏个⽅式:将⽂件放到与类相同的⽬录,使⽤Resource()来读取;将⽂件放到classpath⽬录下,⽤File的相对路径来读取;使⽤l或另⼀个属性⽂件来制定读取路径。
  我也曾见另⼀家公司的软件要求,在部署的时候必须在C:/bea⽬录下,如果换成其它⽬录则不能正常运⾏。这样的设定常常为软件部署时带来许多的⿇烦。如果服务器在该⽬录下已经没有多余空间,或者已经有其它软件,将是很挠头的事情。
  2)预测可能发⽣的变化
  除此之外,在设计的时候,如果将⼀些关键参数放到配置⽂件中,可以为软件部署和使⽤带来更多的灵活性。要做到这⼀点,要求我们在软件设计时,应当有更多的意识,考虑到软件应⽤中可能发⽣的变化。⽐如,有⼀次我在设计财务软件的时候,考虑到⼀些单据在制作时的前置条件,在不同企业使⽤的时候,可能要求不⼀样,有些企业可能要求严格些⽽有些要求松散些。考虑到这种可能的变化,我将前置条件设计为可配置的,就可能⽅便部署⼈员在实际部署中进⾏灵活变化。然⽽这样的配置,必要的注释说明是⾮常必要的。
  软件可维护性的另⼀层意思就是软件的设计便于⽇后的变更。这⼀层意思与软件的可变更性是重合的。所有的软件设计理论的发展,都是从软件的可变更性这⼀要求逐渐展开的,它成为了软件设计理论
的核⼼。
  3. 可变更性
  前⾯我提到了,软件的变更性是所有软件理论的核⼼,那么什么是软件的可变更性呢?按照现在的软件理论,客户对软件的需求时时刻刻在发⽣着变化。当软件设计好以后,为应对客户需求的变更⽽进⾏的代码修改,其所需要付出的代价,就是软件设计的可变更性。由于软件合理的设计,修改所付出的代价越⼩,则软件的可变更性越好,即代码设计的质量越⾼。⼀种⾮常理想的状态是,⽆论客户需求怎样变化,软件只需进⾏适当的修改就能够适应。但这之所以称之为理想状态,因为客户需求变化是有⼤有⼩的。如果客户需求变化⾮常⼤,即使再好的设计也⽆法应付,甚⾄重新开发。然⽽,客户需求的适当变化,⼀个合理的设计可以使得变更代价最⼩化,延续我们设计的软件的⽣命⼒。
  1)通过提⾼代码复⽤提⾼可维护性
  我曾经遇到过这样⼀件事,我要维护的⼀个系统因为应⽤范围的扩⼤,它对机关级次的计算⽅式需要改变⼀种策略。如果这个项⽬统⼀采⽤⼀段公⽤⽅法来计算机关级次,这样⼀个修改实在太简单了,就是修改这个公⽤⽅法即可。但是,事实却不⼀样,对机关级次计算的代码遍布整个项⽬,甚⾄有些还写⼊到了那些复杂的SQL语句中。在这样⼀种情况下,这样⼀个需求的修改⽆异于需要遍历这个项⽬代码。这样⼀个实例显⽰了⼀个项⽬代码复⽤的重要,然⽽不幸的是,代码⽆法很好复⽤的情况遍布我们
所有的项⽬。代码复⽤的道理⼗分简单,但要具体运作起来⾮常复杂,它除了需要很好的代码规划,还需要持续地代码重构。
  对整个系统的整体分析与合理规划可以根本地保证代码复⽤。系统分析师通过⽤例模型、领域模型、分析模型的⼀步⼀步分析,最后通过正向⼯程,⽣成系统需要设计的各种类及其各⾃的属性和⽅法。采⽤这种⽅法,功能被合理地划分到这个类中,可以很好地保证代码复⽤。
  采⽤以上⽅法虽然好,但技术难度较⾼,需要有⾼深的系统分析师,并不是所有项⽬都能普遍采⽤的,特别是时间⽐较紧张的项⽬。通过开发⼈员在设计过程中的重构,也许更加实⽤。当某个开发⼈员在开发⼀段代码时,发现该功能与前⾯已经开发功能相同,或者部分相同。这时,这个开发⼈员可以对前⾯已经开发的功能进⾏重构,将可以通⽤的代码提取出来,进⾏相应的改造,使其具有⼀定的通⽤性,便于各个地⽅可以使⽤。
  ⼀些⽐较成功的项⽬组会指定⼀个专门管理通⽤代码的⼈,负责收集和整理项⽬组中各个成员编写的、可以通⽤的代码。这个负责⼈同时也应当具有⼀定的代码编写功⼒,因为将专⽤代码提升为通⽤代码,或者以前使⽤该通⽤代码的某个功能,由于业务变更,⽽对这个通⽤代码的变更要求,都对这个负责⼈提出了很⾼的能⼒要求。
  虽然后⼀种⽅式⾮常实⽤,但是它有些亡⽺补牢的味道,不能从整体上对项⽬代码进⾏有效规划。正
因为两种⽅法各有利弊,因此在项
⽬中应当配合使⽤。
  2)利⽤设计模式提⾼可变更性
  对于初学者,软件设计理论常常感觉晦涩难懂。⼀个快速提⾼软件质量的捷径就是利⽤设计模式。这⾥说的设计模式,不仅仅指经典的32个模式,是⼀切前⼈总结的,我们可以利⽤的、更加⼴泛的设计模式。
  a. if...
  这个我也不知道叫什么名字,最早是哪位⼤师总结的,它出现在Larman的《UML与模式应⽤》,也出现在出现在Mardin的《敏捷软件开发》。它是这样描述的:当你发现你必须要设计这样的代码:“if...”时,你应当想到你的代码应当重构⼀下了。我们先看看这样的代码有怎样的特点。
  Java代码
if(var.equals("A")){ doA(); }
else if(var.equals("B")){ doB(); }
else if(var.equals("C")){ doC(); }
else{ doD(); }
  这样的代码很常见,也⾮常平常,我们⼤家都写过。但正是这样平常才隐藏着我们永远没有注意的问题。问题就在于,如果某⼀天这个选项不再仅仅是A、B、C,⽽是增加了新的选项,会怎样呢?你也许会说,那没有关系,我把代码改改就⾏。然⽽事实上并⾮如此,在⼤型软件研发与维护中有⼀个原则,每次的变更尽量不要去修改原有的代码。如果我们重构⼀下,能保证不修改原有代码,仅仅增加新的代码就能应付选项的增加,这就增加了这段代码的可维护性和可变更性,提⾼了代码质量。那么,我们应当如何去做呢?
  经过深⼊分析你会发现,这⾥存在⼀个对应关系,即A对应doA(),B对应doB()...如果将doA()、doB()、doC()...与原有代码解耦,问题就解决了。如何解耦呢?设计⼀个接⼝X以及它的实现A、B、C...每个类都包含⼀个⽅法doX(),并且将doA()的代码放到
A.doX()中,将doB()的代码放到
B.doX()中...经过以上的重构,代码还是这些代码,效果却完全不⼀样了。我们只需要这样写:
  Java代码
X x = Bean(var); x.doX();
  这样就可以实现以上的功能了。我们看到这⾥有⼀个⼯⼚,放着所有的A、B、C...并且与它们的key对应起来,并且写在配置⽂件中。如果出现新的选项时,通过修改配置⽂件就可以⽆限制的增加下去。
  这个模式虽然有效提⾼了代码质量,但是不能滥⽤,并⾮只要出现if...就需要使⽤。由于它使⽤了⼯⼚,⼀定程度上增加了代码复杂度,因此仅仅在选项较多,并且增加选项的可能性很⼤的情况下才可以使⽤。另外,要使⽤这个模式,继承我在附件中提供的抽象类XmlBuildFactoryFacade就可以快速建⽴⼀个⼯⼚。如果你的项⽬放在spring或其它可配置框架中,也可以快速建⽴⼯⼚。设计⼀个Map静态属性并使其V为这些A、B、C...这个⼯⼚就建⽴起来了。
  b. 策略模式
  也许你看过策略模式(strategy model)的相关资料但没有留下太多的印象。⼀个简单的例⼦可以让你快速理解它。如果⼀个员⼯系统中,员⼯被分为临时⼯和正式⼯并且在不同的地⽅相应的⾏为不⼀样。在设计它们的时候,你肯定设计⼀个抽象的员⼯类,并且设计两个继承类:临时⼯和正式⼯。这样,通
过下溯类型,可以在不同的地⽅表现出临时⼯和正式⼯的各⾃⾏为。在另⼀个系统中,员⼯被分为了销售⼈员、技术⼈员、管理⼈员并且也在不同的地⽅相应的⾏为不⼀样。同样,我们在设计时也是设计⼀个抽象的员⼯类,并且设计数个继承类:销售⼈员、技术⼈员、管理⼈员。现在,我们要把这两个系统合并起来,也就是说,在新的系统中,员⼯既被分为临时⼯和正式⼯,⼜被分为了销售⼈员、技术⼈员、管理⼈员,这时候如何设计。如果我们还是使⽤以往的设计,我们将不得不设计很多继承类:销售临时⼯、销售正式⼯、技术临时⼯、技术正式⼯。。。如此的设计,在随着划分的类型,以及每种类型的选项的增多,呈笛卡尔增长。通过以上⼀个系统的设计,我们不得不发现,我们以往学习的关于继承的设计遇到了挑战。
  解决继承出现的问题,有⼀个最好的办法,就是采⽤策略模式。在这个应⽤中,员⼯之所以要分为临时⼯和正式⼯,⽆⾮是因为它们的⼀些⾏为不⼀样,⽐如,发⼯资时的计算⽅式不同。如果我们在设计时不将员⼯类分为临时⼯类和正式⼯类,⽽仅仅只有员⼯类,只是在类中增加“⼯资发放策略”。当我们创建员⼯对象时,根据员⼯的类型,将“⼯资发放策略”设定为“临时⼯策略”或“正式⼯策略”,在计算⼯资时,只需要调⽤策略类中的“计算⼯资”⽅法,其⾏为的表现,也设计临时⼯类和正式⼯类是⼀样的。同样的设计可以放到销售⼈员策略、技术⼈员策略、管理⼈员策略中。⼀个通常的设计是,我们将某⼀个影响更⼤的、或者选项更少的属性设计成继承类,⽽将其它属性设计成策略类,就可以很好的解决以上问题。
  使⽤策略模式,你同样把代码写活了,因为你可以⽆限制地增加策略。但是,使⽤策略模式你同样需要设计⼀个⼯⼚——策略⼯⼚。以上实例中,你需要设计⼀个发放⼯资策略⼯⼚,并且在⼯⼚中将“临时⼯”与“临时⼯策略”对应起来,将“正式⼯”与“正式⼯策略”对应起来。
  c. 适配器模式