如何提高代码质量6

高质量代码的三要素

我们评价高质量代码有三要素:可读性、可维护性、可变更性。我们的代码要一个都不能少地达到了这三要素的要求才能算高质量的代码。

1.可读性强

一提到可读性似乎有一些老生常谈的味道,但令人沮丧的是,虽然大家一而再,再而三地强调可读性,但我们的代码在可读性方面依然做得非常糟糕。由于工作的需要,我常常需要去阅读他人的代码,维护他人设计的模块。每当我看到大段大段、密密麻麻的代码,而且还没有任何的注释时常常感慨不已,深深体会到了这项工作的重要。由于分工的需要,我们写的代码难免需要别人去阅读和维护的。而对于许多程序员来说,他们很少去阅读和维护别人的代码。正因为如此,他们很少关注代码的可读性,也对如何提高代码的可读性缺乏切身体会。有时即使为代码编写了注释,也常常是注释语言晦涩难懂形同天书,令阅读者反复斟酌依然不明其意。针对以上问题,我给大家以下建议:

1)不要编写大段的代码

如果你有阅读他人代码的经验,当你看到别人写的大段大段的代码,而且还不怎么带注释,你是怎样的感觉,是不是“嗡”地一声头大。各种各样的功能纠缠在一个方法中,各种变量来回调用,相信任何人多不会认为它是高质量的代码,但却频繁地出现在我们编写的程序了。如果现在你再回顾自己写过的代码,你会发现,稍微编写一个复杂的功能,几百行的代码就出去了。一些比较好的办法就是分段。将大段的代码经过整理,分为功能相对独立的一段又一段,并且在每段的前端编写一段注释。这样的编写,比前面那些杂乱无章的大段代码确实进步了不少,但它们在功能独立性、可复用性、可维护性方面依然不尽人意。从另一个比较专业的评价标准来说,它没有实现低耦合、高内聚。我给大家的建议是,将这些相对独立的段落另外封装成一个又一个的函数。

许多大师在自己的经典书籍中,都鼓励我们在编写代码的过程中应当养成不断重构的习惯。我们在编写代码的过程中常常要编写一些复杂的功能,起初是写在一个类的一个函数中。随着功能的逐渐展开,我们开始对复杂功能进行归纳整理,整理出了一个又一个的独立功能。这些独立功能有它与其它功能相互交流的输入输出数据。当我们分析到此处时,我们会非常自然地要将这些功能从原函数中分离出来,形成一个又一个独立的函数,供原函数调用。在编写这些函数时,我们应当仔细思考一下,为它们取一个释义名称,并为它们编写注释(后面还将详细讨论这个问题)。另一个需要思考的问题是,这些函数应当放到什么地方。这些函数可能放在原类中,也可能放到其它相应职责的类中,其遵循的原则应当是“职责驱动设计”(后面也将详细描述)。

下面是我编写的一个从XML文件中读取数据,将其生成工厂的一个类。这个类最主要的一段程序就是初始化工厂,该功能归纳起来就是三部分功能:用各种方式尝试读取文件、以DOM的方式解析XML数据流、生成工厂。而这些功能被我归纳整理后封装在一个不同的函数中,并且为其取了释义名称和编写了注释:

Java代码 复制代码 收藏代码
  1. /**
  2. * 初始化工厂。根据路径读取XML文件,将XML文件中的数据装载到工厂中
  3. * @param path XML的路径
  4. */ 
  5. public void initFactory(String path){ 
  6.     if(findOnlyOneFileByClassPath(path)){return;} 
  7.     if(findResourcesByUrl(path)){return;} 
  8.     if(findResourcesByFile(path)){return;} 
  9.     this.paths = new String[]{path}; 
  10.  
  11. /**
  12. * 初始化工厂。根据路径列表依次读取XML文件,将XML文件中的数据装载到工厂中
  13. * @param paths 路径列表
  14. */ 
  15. public void initFactory(String[] paths){ 
  16.     for(int i=0; i<paths.length; i++){ 
  17.         initFactory(paths[i]); 
  18.     } 
  19.     this.paths = paths; 
  20.  
  21. /**
  22. * 重新初始化工厂,初始化所需的参数,为上一次初始化工厂所用的参数。
  23. */ 
  24. public void reloadFactory(){ 
  25.     initFactory(this.paths); 
  26.  
  27. /**
  28. * 采用ClassLoader的方式试图查找一个文件,并调用<code>readXmlStream()</code>进行解析
  29. * @param path XML文件的路径
  30. * @return 是否成功
  31. */ 
  32. protected boolean findOnlyOneFileByClassPath(String path){ 
  33.     boolean success = false; 
  34.     try { 
  35.         Resource resource = new ClassPathResource(path, this.getClass()); 
  36.         resource.setFilter(this.getFilter()); 
  37.         InputStream is = resource.getInputStream(); 
  38.         if(is==null){return false;} 
  39.         readXmlStream(is); 
  40.         success = true; 
  41.     } catch (SAXException e) { 
  42.         log.debug("Error when findOnlyOneFileByClassPath:"+path,e); 
  43.     } catch (IOException e) { 
  44.         log.debug("Error when findOnlyOneFileByClassPath:"+path,e); 
  45.     } catch (ParserConfigurationException e) { 
  46.         log.debug("Error when findOnlyOneFileByClassPath:"+path,e); 
  47.     } 
  48.     return success; 
  49.  
  50. /**
  51. * 采用URL的方式试图查找一个目录中的所有XML文件,并调用<code>readXmlStream()</code>进行解析
  52. * @param path XML文件的路径
  53. * @return 是否成功
  54. */ 
  55. protected boolean findResourcesByUrl(String path){ 
  56.     boolean success = false; 
  57.     try { 
  58.         ResourcePath resourcePath = new PathMatchResource(path, this.getClass()); 
  59.         resourcePath.setFilter(this.getFilter()); 
  60.         Resource[] loaders = resourcePath.getResources(); 
  61.         for(int i=0; i<loaders.length; i++){ 
  62.             InputStream is = loaders[i].getInputStream(); 
  63.             if(is!=null){ 
  64.                 readXmlStream(is); 
  65.                 success = true; 
  66.             } 
  67.         } 
  68.     } catch (SAXException e) { 
  69.         log.debug("Error when findResourcesByUrl:"+path,e); 
  70.     } catch (IOException e) { 
  71.         log.debug("Error when findResourcesByUrl:"+path,e); 
  72.     } catch (ParserConfigurationException e) { 
  73.         log.debug("Error when findResourcesByUrl:"+path,e); 
  74.     } 
  75.     return success; 
  76.  
  77. /**
  78. * 用File的方式试图查找文件,并调用<code>readXmlStream()</code>解析
  79. * @param path XML文件的路径
  80. * @return 是否成功
  81. */ 
  82. protected boolean findResourcesByFile(String path){ 
  83.     boolean success = false; 
  84.     FileResource loader = new FileResource(new File(path)); 
  85.     loader.setFilter(this.getFilter()); 
  86.     try { 
  87.         Resource[] loaders = loader.getResources(); 
  88.         if(loaders==null){return false;} 
  89.         for(int i=0; i<loaders.length; i++){ 
  90.             InputStream is = loaders[i].getInputStream(); 
  91.             if(is!=null){ 
  92.                 readXmlStream(is); 
  93.                 success = true; 
  94.             } 
  95.         } 
  96.     } catch (IOException e) { 
  97.         log.debug("Error when findResourcesByFile:"+path,e); 
  98.     } catch (SAXException e) { 
  99.         log.debug("Error when findResourcesByFile:"+path,e); 
  100.     } catch (ParserConfigurationException e) { 
  101.         log.debug("Error when findResourcesByFile:"+path,e); 
  102.     } 
  103.     return success; 
  104.  
  105. /**
  106. * 读取并解析一个XML的文件输入流,以Element的形式获取XML的根,
  107. * 然后调用<code>buildFactory(Element)</code>构建工厂
  108. * @param inputStream 文件输入流
  109. * @throws SAXException
  110. * @throws IOException
  111. * @throws ParserConfigurationException
  112. */ 
  113. protected void readXmlStream(InputStream inputStream) throws SAXException, IOException, ParserConfigurationException{ 
  114.     if(inputStream==null){ 
  115.         throw new ParserConfigurationException("Cann't parse source because of InputStream is null!"); 
  116.     } 
  117.     DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); 
  118.        factory.setValidating(this.isValidating()); 
  119.        factory.setNamespaceAware(this.isNamespaceAware()); 
  120.        DocumentBuilder build = factory.newDocumentBuilder(); 
  121.        Document doc = build.parse(new InputSource(inputStream)); 
  122.        Element root = doc.getDocumentElement(); 
  123.        buildFactory(root); 
  124.  
  125. /**
  126. * 用从一个XML的文件中读取的数据构建工厂
  127. * @param root 从一个XML的文件中读取的数据的根
  128. */ 
  129. protected abstract void buildFactory(Element root); 
	/**	 * 初始化工厂。根据路径读取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, this.getClass());			resource.setFilter(this.getFilter());			InputStream is = resource.getInputStream();			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, this.getClass());			resourcePath.setFilter(this.getFilter());			Resource[] loaders = resourcePath.getResources();			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.setFilter(this.getFilter());		try {			Resource[] loaders = loader.getResources();			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 = DocumentBuilderFactory.newInstance();        factory.setValidating(this.isValidating());        factory.setNamespaceAware(this.isNamespaceAware());        DocumentBuilder build = factory.newDocumentBuilder();        Document doc = build.parse(new InputSource(inputStream));        Element root = doc.getDocumentElement();        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代码 复制代码 收藏代码
  1. /*
  2. * created on ${date}
  3. */ 
/* * created on ${date} */

“Types”代表的是我们新建的接口或类前的注释,我通常设定为:

Java代码 复制代码 收藏代码
  1. /**
  2. * @author ${user}
  3. */ 
/** *  * @author ${user} */

第一行为一个空行,是用于你写该类的注释。如果你采用“职责驱动设计”,这里首先应当描述的是该类的职责。如果需要,你可以写该类一些重要的方法及其用法、该类的属性及其中文含义等。

${user}代表的是你在windows中登陆的用户名。如果这个用户名不是你的名称,你可以直接写死为你自己的名称。

其它我通常都保持为默认值。通过以上设定,你在创建类或接口的时候,系统将自动为你编写好注释,然后你可以在这个基础上进行修改,大大提高注释编写的效率。

同时,如果你在代码中新增了一个函数时,通过Alt+Shift+J快捷键,可以按照模板快速添加注释。

在编写代码时如果你编写的是一个接口或抽象类,我还建议你在@author后面增加@see注释,将该接口或抽象类的所有实现类列出来,因为阅读者在阅读的时候,寻找接口或抽象类的实现类比较困难。

Java代码 复制代码 收藏代码
  1. /**
  2. * 抽象的单表数组查询实现类,仅用于单表查询
  3. * @author 范钢
  4. * @see com.htxx.support.query.DefaultArrayQuery
  5. * @see com.htxx.support.query.DwrQuery
  6. */ 
  7. public abstract class ArrayQuery implements ISingleQuery { 
  8. ... 
/** * 抽象的单表数组查询实现类,仅用于单表查询 * @author 范钢 * @see com.htxx.support.query.DefaultArrayQuery * @see com.htxx.support.query.DwrQuery */public abstract class ArrayQuery implements ISingleQuery {...

2.可维护性

软件的可维护性有几层意思,首先的意思就是能够适应软件在部署和使用中的各种情况。从这个角度上来说,它对我们的软件提出的要求就是不能将代码写死。

1)代码不能写死

我曾经见我的同事将系统要读取的一个日志文件指定在C盘的一个固定目录下,如果系统部署时没有这个目录以及这个文件就会出错。如果他将这个决定路径下的目录改为相对路径,或者通过一个属性文件可以修改,代码岂不就写活了。一般来说,我在设计中需要使用日志文件、属性文件、配置文件,通常都是以下几个方式:将文件放到与类相同的目录,使用ClassLoader.getResource()来读取;将文件放到classpath目录下,用File的相对路径来读取;使用web.xml或另一个属性文件来制定读取路径。

我也曾见另一家公司的软件要求,在部署的时候必须在C:/bea目录下,如果换成其它目录则不能正常运行。这样的设定常常为软件部署时带来许多的麻烦。如果服务器在该目录下已经没有多余空间,或者已经有其它软件,将是很挠头的事情。

2)预测可能发生的变化

除此之外,在设计的时候,如果将一些关键参数放到配置文件中,可以为软件部署和使用带来更多的灵活性。要做到这一点,要求我们在软件设计时,应当更多地有更多的意识,考虑到软件应用中可能发生的变化。比如,有一次我在设计财务软件的时候,考虑到一些单据在制作时的前置条件,在不同企业使用的时候,可能要求不一样,有些企业可能要求严格些而有些要求松散些。考虑到这种可能的变化,我将前置条件设计为可配置的,就可能方便部署人员在实际部署中进行灵活变化。然而这样的配置,必要的注释说明是非常必要的。

软件的可维护性的另一层意思就是软件的设计便于日后的变更。这一层意思与软件的可变更性是重合的。所有的软件设计理论的发展,都是从软件的可变更性这一要求逐渐展开的,它成为了软件设计理论的核心。

3.可变更性

前面我提到了,软件的变更性是所有软件理论的核心,那么什么是软件的可变更性呢?按照现在的软件理论,客户对软件的需求时时刻刻在发生着变化。当软件设计好以后,为应对客户需求的变更而进行的代码修改,其所需要付出的代价,就是软件设计的可变更性。由于软件合理地设计,修改所付出的代价越小,则软件的可变更性越好,即代码设计的质量越高。一种非常理想的状态是,无论客户需求怎样变化,软件只需进行适当地修改就能够适应。但这之所以称之为理想状态,因为客户需求变化是有大有小的。如果客户需求变化非常大,即使再好的设计也无法应付,甚至重新开发。然而,客户需求的适当变化,一个合理地设计可以使得变更代价最小化,延续我们设计的软件的生命力。

1)通过提高代码复用提高可维护性

我曾经遇到过这样一件事,我要维护的一个系统因为应用范围的扩大,它对机关级次的计算方式需要改变一种策略。如果这个项目统一采用一段公用方法来计算机关级次,这样一个修改实在太简单了,就是修改这个公用方法即可。但是,事实却不一样,对机关级次计算的代码遍布整个项目,甚至有些还写入到了那些复杂的SQL语句中。在这样一种情况下,这样一个需求的修改无异于需要遍历这个项目代码。这样一个实例显示了一个项目代码复用的重要,然而不幸的是,代码无法很好复用的情况遍布我们所有的项目。代码复用的道理十分简单,但要具体运作起来非常复杂,它除了需要很好的代码规划,还需要持续地代码重构。

对整个系统的整体分析与合理规划可以根本地保证代码复用。系统分析师通过用例模型、领域模型、分析模型的一步一步分析,最后通过正向工程,生成系统需要设计的各种类及其各自的属性和方法。采用这种方法,功能被合理地划分到这个类中,可以很好地保证代码复用。

采用以上方法虽然好,但技术难度较高,需要有高深的系统分析师,并不是所有项目都能普遍采用的,特别是时间比较紧张的项目。通过开发人员在设计过程中的重构,也许更加实用。当某个开发人员在开发一段代码时,发现该功能与前面已经开发功能相同,或者部分相同。这时,这个开发人员可以对前面已经开发的功能进行重构,将可以通用的代码提取出来,进行相应地改造,使其具有一定的通用性,便于各个地方可以使用。

一些比较成功的项目组会指定一个专门管理通用代码的人,负责收集和整理项目组中各个成员编写的,可以通用的代码。这个负责人同时也应当具有一定的代码编写功力,因为将专用代码提升为通用代码,或者以前使用该通用代码的某个功能,由于业务变更,而对这个通用代码的变更要求,都对这个负责人提出了很高的能力要求。

虽然后一种方式非常实用,但是它有些亡羊补牢的味道,不能从整体上对项目代码进行有效规划。正因为两种方法各有利弊,因此在项目中应当配合使用。

2)利用设计模式提高可变更性

对于初学者,软件设计理论常常感觉晦涩难懂。一个快速提高软件质量的捷径就是利用设计模式。这里说的设计模式,不仅仅指经典的32个模式,是一切前人总结的,我们可以利用的、更加广泛的设计模式。

a. if...else...

这个我也不知道叫什么名字,最早是哪位大师总结的,它出现在Larman的《UML与模式应用》,也出现在出现在Mardin的《敏捷软件开发》。它是这样描述的:当你发现你必须要设计这样的代码:“if...elseif...elseif...else...”时,你应当想到你的代码应当重构一下了。我们先看看这样的代码有怎样的特点。

Java代码 复制代码 收藏代码
  1. if(var.equals("A")){ 
  2.             doA(); 
  3.         }else if(var.equals("B")){ 
  4.             doB(); 
  5.         }else if(var.equals("C")){ 
  6.             doC(); 
  7.         }else{ 
  8.             doD(); 
  9.         } 
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代码 复制代码 收藏代码
  1. X x = factory.getBean(var); 
  2. x.doX(); 
X x = factory.getBean(var);x.doX();

这样就可以实现以上的功能了。我们看到这里有一个工厂,放着所有的A、B、C...并且与它们的key对应起来,并且写在配置文件中。如果出现新的选项时,通过修改配置文件就可以无限制的增加下去。

这个模式虽然有效提高了代码质量,但是不能滥用,并非只要出现if...else...就需要使用。由于它使用了工厂,一定程度上增加了代码复杂度,因此仅仅在选项较多,并且增加选项的可能性很大的情况下才可以使用。另外,要使用这个模式,继承我在附件中提供的抽象类XmlBuildFactoryFacade就可以快速建立一个工厂。如果你的项目放在spring或其它可配置框架中,也可以快速建立工厂。设计一个Map静态属性并使其V为这些A、B、C...这个工厂就建立起来了。

b.策略模式

也许你看过策略模式(strategy model)的相关资料但没有留下太多的印象。一个简单的例子可以让你快速理解它。如果一个员工系统中,员工被分为临时工和正式工并且在不同的地方相应的行为不一样。在设计它们的时候,你肯定设计一个抽象的员工类,并且设计两个继承类:临时工和正式工。这样,通过下塑类型,可以在不同的地方表现出临时工和正式工的各自行为。在另一个系统中,员工被分为了销售人员、技术人员、管理人员并且也在不同的地方相应的行为不一样。同样,我们在设计时也是设计一个抽象的员工类,并且设计数个继承类:销售人员、技术人员、管理人员。现在,我们要把这两个系统合并起来,也就是说,在新的系统中,员工既被分为临时工和正式工,又被分为了销售人员、技术人员、管理人员,这时候如何设计。如果我们还是使用以往的设计,我们将不得不设计很多继承类:销售临时工、销售正式工、技术临时工、技术正式工...如此的设计,在随着划分的类型,以及每种类型的选项的增多,呈笛卡尔增长。通过以上一个系统的设计,我们不得不发现,我们以往学习的关于继承的设计遇到了挑战。

解决继承出现的问题,有一个最好的办法,就是采用策略模式。在这个应用中,员工之所以要分为临时工和正式工,无非是因为它们的一些行为不一样,比如,发工资时的计算方式不同。如果我们在设计时不将员工类分为临时工类和正式工类,而仅仅只有员工类,只是在类中增加“工资发放策略”。当我们创建员工对象时,根据员工的类型,将“工资发放策略”设定为“临时工策略”或“正式工策略”,在计算工资时,只需要调用策略类中的“计算工资”方法,其行为的表现,也设计临时工类和正式工类是一样的。同样的设计可以放到销售人员策略、技术人员策略、管理人员策略中。一个通常的设计是,我们将某一个影响更大的、或者选项更少的属性设计成继承类,而将其它属性设计成策略类,就可以很好的解决以上问题。



使用策略模式,你同样把代码写活了,因为你可以无限制地增加策略。但是,使用策略模式你同样需要设计一个工厂——策略工厂。以上实例中,你需要设计一个发放工资策略工厂,并且在工厂中将“临时工”与“临时工策略”对应起来,将“正式工”与“正式工策略”对应起来。

c.适配器模式

我的笔记本是港货,它的插头与我们常用的插座不一样,所有我出差的时候我必须带一个适配器,才能使用不同地方的插座。这是一个对适配器模式最经典的描述。当我们设计的系统要与其它系统交互,或者我们设计的模块要与其它模块交互时,这种交互可能是调用一个接口,或者交换一段数据,接受方常常因发送方对协议的变更而频繁变更。这种变更,可能是接受方来源的变更,比如原来是A系统,现在变成B系统了;也可能是接受方自身的代码变更,如原来的接口现在增加了一个参数。由于发送方的变更常常导致接受方代码的不稳定,即频繁跟着修改,为接受方的维护带来困难。

遇到这样的问题,一个有经验的程序员马上想到的就是采用适配器模式。在设计时,我方的接口按照某个协议编写,并且保持固定不变。然后,在与真正对方接口时,在前段设计一个适配器类,一旦对方协议发生变更,我可以换个适配器,将新协议转换成原协议,问题就解决了。适配器模式应当包含一个接口和它的实现类。接口应当包含一个本系统要调用的方法,而它的实现类分别是与A系统接口的适配器、与B系统接口的适配器...



我曾经在一个项目中需要与另一个系统接口,起初那个系统通过一个数据集的方式为我提供数据,我写了一个接收数据集的适配器;后来改为用一个XML数据流的形式,我又写了一个接收XML的适配器。虽然为我提供数据的方式不同,但是经过适配器转换后,输出的数据是一样的。通过在spring中的配置,我可以灵活地切换到底是使用哪个适配器。

d.模板模式

32个经典模式中的模板模式,对开发者的代码规划能力提出了更高的要求,它要求开发者对自己开发的所有代码有一个相互联系和从中抽象的能力,从各个不同的模块和各个不同的功能中,抽象出其过程比较一致的通用流程,最终形成模板。譬如说,读取XML并形成工厂,是许多模块常常要使用的功能。它们虽然有各自的不同,但是总体流程都是一样的:读取XML文件、解析XML数据流、形成工厂。正因为有这样的特征,它们可以使用共同的模板,那么,什么是模板模式呢?

模板模式(Template Model)通常有一个抽象类。在这个抽象类中,通常有一个主函数,按照一定地顺序去调用其它函数。而其它函数往往是某这个连续过程中的各个步骤,如以上实例中的读取XML文件、解析XML数据流、形成工厂等步骤。由于这是一个抽象类,这些步骤函数可以是抽象函数。抽象类仅仅定义了整个过程的执行顺序,以及一些可以通用的步骤(如读取XML文件和解析XML数据流),而另一些比较个性的步骤,则由它的继承类自己去完成(如上例中的“形成工厂”,由于各个工厂各不一样,因此由各自的继承类自己去决定它的工厂是怎样形成的)。



各个继承类可以根据自己的需要,通过重载重新定义各个步骤函数。但是,模板模式要求不能重载主函数,因此正规的模板模式其主函数应当是final(虽然我们常常不这么写)。另外,模板模式还允许你定义的这个步骤中,有些步骤是可选步骤。对与可选步骤,我们通常称为“钩子(hood)”。它在编写时,在抽象类中并不是一个抽象函数,但却是一个什么都不写的空函数。继承类在编写时,如果需要这个步骤则重载这个函数,否则就什么也不写,进而在执行的时候也如同什么都没有执行。

通过以上对模板模式的描述可以发现,模板模式可以大大地提高我们的代码复用程度。

以上一些常用设计模式,都能使我们快速提高代码质量。还是那句话,设计模式不是什么高深的东西,恰恰相反,它是初学者快速提高的捷径。然而,如果说提高代码复用是提高代码质量的初阶,使用设计模式也只能是提高代码质量的中阶。那么,什么是高阶呢?我认为是那些分析设计理论,更具体地说,就是职责驱动设计和领域驱动设计。

3)职责驱动设计和领域驱动设计

前面我提到,当我们尝试写一些复杂功能的时候,我们把功能分解成一个个相对独立的函数。但是,应当将这些函数分配到哪个类中呢?也就是系统中的所有类都应当拥有哪些函数呢?或者说应当表现出哪些行为呢?答案就在这里:以职责为中心,根据职责分配行为。我们在分析系统时,首先是根据客户需求进行用例分析,然后根据用例绘制领域模式和分析模型,整个系统最主要的类就形成了。通过以上分析形成的类,往往和现实世界的对象是对应的。正因为如此,软件世界的这些类也具有了与现实世界的对象相对应的职责,以及在这些职责范围内的行为。

职责驱动设计(Responsibility Drive Design,RDD)是Craig Larman在他的经典著作《UML和模式应用》中提出的。职责驱动设计的核心思想,就是我们在对一个系统进行分析设计的时候,应当以职责为中心,根据职责分配行为。这种思想首先要求我们设计的所有软件世界的对象,应当与现实世界尽量保持一致,他称之为“低表示差异”。有了低表示差异,一方面提高了代码的可读性,另一方面,当业务发生变更的时候,也可以根据实际情况快速应对变更。

Craig Larman在提出职责驱动设计理论的同时,还提出了GRASP设计模式,来丰富这个理论。在GRASP设计模式中,我认为,低耦合、高内聚、信息专家模式最有用(以后再详细讲述)。

继Craig Larman提出的职责驱动设计数年之后,另一位大师提出了领域驱动设计。领域驱动设计(Domain Drive Design,DDD)是Eric Evans在他的同名著作《领域驱动设计》中提出的。在之前的设计理论中,领域模型是从用例模型到分析模型之间的一种中间模型,也就是从需求分析到软件开发之间的一种中间模型。这么一个中间模型,既不是需求阶段的重要产物,在开发阶段也不以它作为标准进行开发,仅仅是作为参考,甚至给人感觉有一些多余。但是,Evans在领域驱动设计中,将它放到了一个无比重要的位置。按照领域驱动设计的理论,在需求分析阶段,需求分析人员使用领域模型与客户进行沟通;在设计开发阶段,开发人员使用领域模型指导设计开发;在运行维护和二次开发阶段,维护和二次开发人员使用领域模型理解和熟悉系统,并指导他们进行维护和二次开发。总之,在整个软件开发的生命周期中,领域模型都成为了最核心的内容。

领域驱动设计继承了职责驱动设计。在领域驱动设计中强调的,依然是低表示差异,以及职责的分配。但是,如何做到低表示差异呢?如何完成职责分配呢?领域驱动设计给了我们完美的答案,那就是建立领域模型。领域驱动设计改变了我们的设计方式。在需求分析阶段,用例模型已不再是这个阶段的核心,而是建立领域模型。在开发和二次开发阶段,开发人员也不再是一埋头地猛扎进程序堆里开始编程,而是首先细致地进行领域模型分析。领域驱动设计强调持续精化,使领域模型不再是一旦完成分析就扔在一边不再理会的图纸,而是在不断理解业务的基础上不断修改和精化领域模型,进而驱动我们代码的精化。领域驱动设计强调的不再是一次软件开发过程中我们要做的工作,它看得更加长远,它强调的是一套软件在相当长一段时间内持续升级的过程中我们应当做的工作。我认为,领域驱动设计是提高代码质量的最高等级。当时,使用领域驱动设计进行软件开发是一场相当巨大的改革,它颠覆了我们过去的所有开发模式,我们必须脚踏实地地一步一步去实践和改变。

职责驱动设计

随着软件业的不断发展,随着软件需求的不断扩大,软件所管理的范围也在不断拓宽。过去一个软件仅仅管理一台电脑的一个小小的功能,而现在被扩展到了一个企业、一个行业、一个产业链。过去我们开发一套软件,只有少量的二次开发,当它使用到一定时候我们就抛弃掉重新又开发一套。现在,随着用户对软件依赖程度的不断加大,我们很难说抛弃一套软件重新开发了,更多的是在一套软件中持续改进,使这套软件的生命周期持续数年以及数个版本。正是因为软件业面临着如此巨大的压力,我们的代码质量,我们开发的软件拥有的可变更性和持续改进的能力,成为软件制胜的关键因素,令我们不能不反思。

代码质量评价的关键指标:低耦合,高内聚

耦合就是对某元素与其它元素之间的连接、感知和依赖的量度。耦合包括:

1.元素B是元素A的属性,或者元素A引用了元素B的实例(这包括元素A调用的某个方法,其参数中包含元素B)。

2.元素A调用了元素B的方法。

3.元素A直接或间接成为元素B的子类。

4.元素A是接口B的实现。

如果一个元素过于依赖其它元素,一旦它所依赖的元素不存在,或者发生变更,则该元素将不能再正常运行,或者不得不相应地进行变更。因此,耦合将大大影响代码的通用性和可变更性。

内聚,更为专业的说法叫功能内聚,是对软件系统中元素职责相关性和集中度的度量。如果元素具有高度相关的职责,除了这些职责内的任务,没有其它过多的工作,那么该元素就具有高内聚性,反之则为低内聚性。内聚就像一个专横的管理者,它只做自己职责范围内的事,而将其它与它相关的事情,分配给别人去做。

高质量的代码要求我们的代码保持低耦合、高内聚。但是,这个要求是如此的抽象与模糊,如何才能做到这些呢?软件大师们告诉我们了许多方法,其中之一就是Craig Larman的职责驱动设计。

职责驱动设计(Responsibility Drive Design,RDD)是Craig Larman在他的经典著作《UML和模式应用》中提出的。要理解职责驱动设计,我们首先要理解“低表示差异”。

低表示差异

我们开发的应用软件实际上是对现实世界的模拟,因此,软件世界与现实世界存在着必然的联系。当我们在进行需求分析的时候,需求分析员实际上是从客户那里在了解现实世界事物的规则、工作的流程。如果我们在软件分析和设计的过程中,将软件世界与现实世界紧密地联系到一起,我们的软件将更加本色地还原事物最本质的规律。这样的设计,就称之为“低表示差异”。



采用“低表示差异”进行软件设计,现实世界有什么事物,就映射为软件世界的各种对象(类);现实世界的事物拥有什么样的职责,在软件世界里的对象就拥有什么样的职责;在现实世界中的事物,因为它的职责而产生的行为,在软件世界中就反映为对象所拥有的函数。

低表示差异,使分析设计者对软件的分析和设计更加简单,思路更加清晰;使代码更加可读,阅读者更加易于理解;更重要的是,当需求发生变更,或者业务产生扩展时,设计者只需要遵循事物本来的面貌去思考和修改软件,使软件更加易于变更和扩展。

角色、职责、协作

理解了“低表示差异”,现在我们来看看我们应当如何运用职责驱动设计进行分析和设计。首先,我们通过与客户的沟通和对业务需求的了解,从中提取出现实世界中的关键事物以及相互之间的关系。这个过程我们通常通过建立领域模型来完成。领域模型建立起来以后,通过诸如Rational Rose这样的设计软件的正向工程,生成了我们在软件系统中最初始的软件类。这些软件类,由于每个都扮演着现实世界中的一个具体的角色,因而赋予了各自的职责。前面我已经提到,如果你的系统采用职责驱动设计的思想进行设计开发,作为一个好的习惯,你应当在每一个软件类的注释首行,清楚地描述该软件类的职责。

当我们完成了系统中软件类的制订,分配好了各自的职责,我们就应该开始根据软件需求,编写各个软件类的功能。在前面我给大家提出了一个建议,就是不要在一个函数中编写大段的代码。编写大段的代码,通常会降低代码的内聚度,因为这些代码中将包含不是该软件类应当完成的工作。作为一个有经验的开发人员,在编写一个功能时,首先应当对功能进行分解。一段稍微复杂的功能,通常都可以被分解成一个个相对独立的步骤。步骤与步骤之间存在着交互,那就是数据的输入输出。通过以上的分解,每一个步骤将形成一个独立的函数,并且使用一个可以表明这个步骤意图的释义函数名。接下来,我们应当考虑的,就是应当将这些函数交给谁。它们有可能交给原软件类,也有可能交给其它软件类,其分配的原则是什么呢?答案是否清楚,那就是职责。每个软件类代表现实世界的一个事物,或者说一个角色。在现实世界中这个任务应当由谁来完成,那么在软件世界中,这个函数就应当分配给相应的那个软件类。

通过以上步骤的分解,一个功能就分配给了多个软件类,相互协作地完成这个功能。这样的分析和设计,其代码一定是高内聚的和高可读性的。同时,当需求发生变更的时候,设计者通过对现实世界的理解,可以非常轻松地找到那个需要修改的软件类,而不会影响其它类,因而也就变得易维护、易变更和低耦合了。

说了这么多,举一个实例也许更能帮助理解。拿一个员工工资系统来说吧。当人力资源在发放一个月工资的时候,以及离职的员工肯定不能再发放工资了。在系统设计的期初,开发人员商量好,在员工信息中设定一个“离职标志”字段。编写工资发放的开发人员通过查询,将“离职标志”为false的员工查询出来,并为他们计算和发放工资。但是,随着这个系统的不断使用,编写员工管理的开发人员发现,“离职标志”字段已经不能满足客户的需求,因而将“离职标志”字段废弃,并增加了一个“离职时间”字段来管理离职的员工。然而,编写工资发放的开发人员并不知道这样的变更,依然使用着“离职标志”字段。显然,这样的结果就是,软件系统开始对离职员工发放工资了。仔细分析这个问题的原因,我们不难发现,确认员工是否离职,并不是“发放工资”软件类应当完成的工作,而应当是“员工管理”软件类应当完成的。如果将“获取非离职员工”的任务交给“员工管理”软件类,而“发放工资”软件类仅仅只是去调用,那么离职功能由“离职标志”字段改为了“离职时间”字段,其实就与“发放工资”软件类毫无关系。而作为“员工管理”的开发人员,一旦发生这样的变更,他当然知道去修改自己相应的“获取非离职员工”函数,这样就不会发生以上问题。通过这样一个实例,也许你能够理解“职责驱动设计”的精要与作用了吧。

职责分配与信息专家

通过以上对职责驱动设计的讲述,我们不难发现,职责驱动设计的精要就是职责分配。但是,在纷繁复杂的软件设计中,如何进行职责分配常常令我们迷惑。幸运的是,Larman大师清楚地认识到了这一点。在他的著作中,信息专家模式为我们提供了帮助。

信息专家模式(又称为专家模式)告诉我们,在分析设计中,应当将职责分配给软件系统中的这样一个软件类,它拥有实现这个职责所必须的信息。我们称这个软件类,叫“信息专家”。用更加简短的话说,就是将职责分配给信息专家。

为什么我们要将职责分配给信息专家呢?我们用上面的例子来说明吧。当“发放工资”软件类需要获取非离职员工时,“员工管理”软件类就是“获取非离职员工”任务的信息专家,因为它掌握着所有员工的信息。假设我们不将“获取非离职员工”的任务交给“员工管理”软件类,而是另一个软件类X,那么,为了获取员工信息,软件类X不得不访问“员工管理”软件类,从而使“发放工资”与X耦合,X又与“员工管理”耦合。这样的设计,不如直接将“获取非离职员工”的任务交给“员工管理”软件类,使得“发放工资”仅仅与“员工管理”耦合,从而有效地降低了系统的整体耦合度。

总之,采用“职责驱动设计”的思路,为我们提高软件开发质量、可读性、可维护性,以及保持软件的持续发展,提供了一个广阔的空间。

终于到了该说说领域驱动设计的时候了。我们在这场关于代码质量的讨论中,从代码可读性开始,讨论了代码复用性、设计模式,然后探讨了职责驱动设计。代码可读性是对代码质量最基本的要求,可惜我们仍有做得不够的(即使那些开发程序很多年的老程序员)。代码复用是提高代码质量的最初级阶段,但是在一个多人开发的项目团队中,围绕代码复用值得讨论的问题依然非常多,它依然是一个非常复杂的问题,甚至有时它不再仅仅是一个技术问题,而是一个管理问题。唉,提高代码质量的道理漫漫兮同志们要上下而求索。一个比较成功的保证代码质量的管理模式就是代码复查。让一些有经验的程序员定期去复查那些初级程序员的代码,指导他们的开发,被认为是成功的,但也代价巨大的。

然而,在这场关于代码质量的讨论中,我认为,最终的终极目标毫无疑问应当是“领域驱动设计”。领域驱动设计可以快速而根本地提高我们的代码质量,举一个最近发生的一件事情也许可以深刻地说明这一点。前不久,我将一个开发任务交给了我的一个手下。一周后,当我对他的代码进行复查的时候,我惊呆了。我甚至不能提出任何的建议来优化他的代码。随后,我花了半个小时的时间与他一起进行了一次领域模型分析,将他开发的这个模块用领域模型绘制了一个草图。随后的数日,他照着这个图纸重新进行了编码。当我再次复查他的代码时,我忍不住笑了。在短短的一周时间内能让一个人的代码质量判若两人,这不得不说是领域驱动设计带给我们的震撼。

但是,在领域驱动设计之前,我用大量篇幅讲解了职责驱动设计。职责驱动设计是领域驱动设计的理论基础,领域驱动设计是职责驱动设计的最佳实践。领域驱动设计要求我们以领域模型作为我们分析与开发的核心,为什么?因为我们的设计应当与现实世界保持“低表示差异”。领域驱动设计强调所有的领域对象应当以现实世界作为模板,为其定义和分配行为,为什么?因为我们的设计应当以职责为中心,按职责分配行为,分配行为的原则可以参照“信息专家”模式。领域驱动设计并不是横空出世的,而是在职责驱动设计的基础之上发展的。理解职责驱动设计可以促进我们对领域驱动设计的理解,然而非常遗憾的是,它却长期游离于我们的视线之外。

低表示差异与领域模型

我在前面的“职责驱动设计”部分已经讨论了“低表示差异”。用一句简短的话说,在我们的分析设计中,软件世界始终应当与现实世界保持“低表示差异”。如何保持低表示差异呢?答案就是领域模型分析。

领域驱动设计,其名称中,将“领域(Domain)”这个词放在了最显著的位置上,为什么呢?因为它的理论核心就是领域。在需求分析和设计阶段使用领域模型与客户进行软件需求的讨论。在这个阶段,领域模型是最重要的一个验收成果,没有完成领域模型分析,这个阶段就永远不算结束。在软件开发阶段采用领域模型作为核心设计图纸指导设计开发。领域模型怎样设计则我们的软件系统就怎样设计,软件系统中的最主要软件类都是源自领域模型中定义的领域对象。在运行维护及二次开发阶段,领域模型就如同房屋建筑中的设计图纸,它成为运行维护人员和二次开发人员熟悉和理解软件系统的核心线索。总之,在领域驱动设计中,领域模式成为最最核心的内容。所以我们应当首先理解什么是领域模型。

领域模型是对现实世界中某个业务领域的抽象。我们设计的软件不是对所有现实世界的模拟,而是对某个领域的模拟,譬如财务领域、税务领域、企业管理领域等等。这个领域我们称之为“业务领域”,而在这个领域里工作,并熟悉掌握这个领域中所有知识的人我们称之为“领域专家”。我们的分析和设计人员对业务领域的熟悉和理解的程度,往往决定我们的软件是否满足客户需求,也往往就决定了我们的软件是否成功。领域驱动设计理论要求我们在需求分析阶段必须非常深入地理解业务领域,采用的方式就是领域模型分析。同时,在这样一个过程中,应该有领域专家参与,甚至成为分析设计中的一个成员。

过去我们使用用例模型与领域专家交流,直到现在我们依然还在这样做。用例模型分析是我们分析设计的方法之一,但现在我们又有了一个新的强有力的工具,那就是领域模型分析。与用例模型比较,领域模型更加直观,可以更加立体地描述现实世界。如果说过去的需求设计文档是二维世界,用例模型只是二维半,领域模型才是真三维世界。领域模型是一大堆的类图,它描述的是业务领域中的各个事物,以及事物与事物之间的关系。

从业务领域中获取知识

说了这么多东西,现在让我们来点儿实在的东西吧:如何进行领域模型分析?建立领域模型需要从业务领域中获取素材。获取领域模型所需素材通常有两个途径:与领域专家的现场交流会中获得,和从用例模型的各个流程中提取名词或名词短语获得。我们将这些获取的素材经过加工,形成我们在领域模型中的一个又一个的类,这些类我们称之为概念类。现在的问题是,哪些应当成为领域模型中的概念类呢?如果我引用一堆定义和准则,并不能让你清楚明了,也许一个生动的比喻更能够让你理解深刻。需求分析有时候就像一部部动画剧,而那些枯燥乏味的概念,纷繁复杂的流程,在这些动画剧中似乎都突然活了,个个都有语言有性格。在这些动画剧中扮演的所有角色,就是我们需要的概念类。而他们做的所有动作,就是用例模型中的所有流程。

1)在业务讨论会中绘制领域模型

运用我曾经一篇文章中的实例来更加生动地描述这样一个过程吧:

在一个阳光明媚的下午,我们一个个西装革履、精神抖擞地来到了客户的办公现场。在一个明亮的会议室里,宽大深褐色的椭圆木桌旁已经聚集了十来个业务人员。看到我们进来,大家握手问候。相继就座后,互相介绍,往来寒暄,唠唠家常。共同的家乡,或熟或不怎么熟的某个人,都可能成为拉近彼此关系的理由。逐渐,一切开始进入正题。客户开始絮絮叨叨的描述自己的需求,而我们则在紧张的做着记录,时不时问一些问题,表明我们的立场,抒发我们的建议。在这样一个过程中,客户会描述他们的每一个业务,会讲解每个业务的流程,他们会讲出一些业务领域的专业词汇(尽管有些你当时还不太懂)。在这样一个过程中,作为需求分析员,你应当非常注意业务流程中的一些关键词汇,你应当(在当时或者过后)将它们提取出来,通过询问客户,弄清楚他们的定义,以及相互之间的关系。而这些词汇就是建立领域模型的开始。

这样的讨论会不可能是一次两次,而是数次。在这样的讨论会中,也许一支笔和一摞白纸会非常有用。在这样的讨论会中,你可以迅速将从客户那里理解的各种概念和知识,立即在白纸上画出一个又一个的草图。那些关键词汇被绘制成了一个个的概念类和它的属性(如果确实需要),用线条迅速标注出相互之间的关系。在你绘制的时候,客户会在不断地给你指正,或者说出了更多的业务知识。一张张的草图成为了你与客户交流的工具,也是最初始的领域模型。

这是一个财务软件的业务讨论会,一个业务人员正在跟我讲付款单是怎样制作成凭证的。“每张付款单都有一个商品明细,每个商品明细都有它的价格、数量和金额。”他指着一张付款单向我解释着。从这句话,我可以提出一些关键信息:付款单、商品明细、价格、数量和金额。付款单与商品明细是一对多关系,并且商品明细聚合在付款单中。每个商品明细都有价格、数量和金额,也就是说,价格、数量和金额是商品明细的属性,这都很清楚。紧接着,他下面的讲解就不是那么清楚容易了。“如果按照一张单据生成一张凭证,那么每张付款单生成一张凭证。单据中的每个明细在凭证中生成一条借方分录和一条贷方分录。将付款单中的付款科目作为借方科目,将付款单结算方式对应的结算方式科目作为贷方科目。现结的付款单在采购发 票中已制作凭证了,因此不再单独制作凭证。非预付的付款单不制作凭证,而是其执行付款核销以后,在核销单中制作凭证。”经过对以上语言的分析,我们可以绘制以下关系:一张凭证包含多个分录,是内聚关系。分录分为借方分录和贷方分录两种。一条商品明细对应一条借方分录和一条贷方分录。借方分录中包含“借方科目”属性,对应付款单中的付款科目;贷方分录中包含“贷方科目”属性,对应的是付款单中的一个什么科目。在这里,你可能对客户的某些描述不明白,因此要他做出解释。原来客户预先制订了一个规则,付款单中的结算方式分布对应了一个结算方式科目。OK,你在绘制的图形中,把结算方式科目作为关联类,将结算方式和贷方科目进行了一个关联。这样,“付款单生成凭证”这样一个场景的领域模型就绘制出来。

2)归纳和整理领域模型

在现场讨论会中,可能一些关键的概念被你忽略掉了。也可能一些关键性的关系被你忽略,或者在草图上并没有很好地表达,甚至存在谬误和矛盾。随着你事后的分析和整理,你从用例模型的流程描述中提取出了更多的概念。同时,随着你对问题的一步一步深入理解,你开始重构你起初的领域模型。在Evans的《领域驱动设计》中,他用大量的篇幅和实例描述和讲解了这样一个过程。另外一个重要的概念是,深入理解和重构领域模型不仅仅是在软件需求分析的阶段完成,它贯穿了整个软件开发的周期。按照迭代软件开发的思想,我们绝不能企图在需求分析阶段完成所有的分析(那是瀑布的思想)。随着我们对业务领域的深入理解,重构和精化领域模型贯穿整个“开发—维护—再开发”的过程中。而这也正符合了现代软件开发业的发展需求(我参与的项目已经经历了快5个年头,每年都在经历着新的开发)。

经过了这样的、有领域专家参与的、反复讨论与整理的过程,我们对业务领域理解将越来越深入,而我们设计的领域模型将越来越贴近现实世界中事物的本质。运用这样的领域模型图纸去开发我们的软件,毫无疑问我们已经成功了一半。(制作领域模型的更多细节见我的相关博客,我也会写更多的文章讨论)

运用领域模型开发软件

曾经有个笑话是这样说的:大师们站的高度都是非常高的,高到什么程度?他们都是生活在太空中的。追随大师是一个高风险的职业,为什么?一不小心就能让我们因缺氧而死掉。这个笑话非常深刻的道出了追随大师的关键,那就是怎样“着陆”,也就是如何“落地”。一个高深的理论,如果不能指导我们的实际工作,那么这个理论是没有价值的,领域驱动设计也是一样。下面我们来讨论一下如何运用领域模型指导我们的软件开发。

1)领域模型在我们的软件框架中扮演的是什么角色

首先第一个要解决的问题是,领域模型在我们的软件框架中,特别是时下最常见的Spring+Hibernate框架中扮演的是什么角色。我们不妨先看看Evans是怎样分层的。在书中,Evans将系统分为用户界面层(表示层)、应用层、领域层(模型层)和基础结构层。从他对各个层的表述我们不难看出,用户界面层(表示层)就是前端界面,应用层即是Service层,基础结构层即是DAO、工具类,以及其它的技术支持类。从这个角度来说,Evans在他的书中所说的领域层,在我们的框架中就应当是业务逻辑层(BUS),但事实并不是这样简单。在我们现在的框架中,数据与业务逻辑处理被分离了,举例说吧:

在一个员工信息管理系统中,领域模型可能包含了一个员工类,并且在该类中包含了那些诸如员工编号、姓名、性别、职务等属性。除此以外,一个员工类肯定也包含了诸如“新增员工”、“修改员工资料”之类的行为。领域模型如此,那么软件设计时会是怎样呢?



 

在设计一个员工信息管理系统时,它必然包含一个“员工BUS”的类,用于执行诸如“新增员工”、“修改员工资料”之类的行为。那么,那些员工的相关属性被放在哪里呢?它们并没有放在“员工BUS”类中,而是“员工”值对象中(注意:这里的值对象不是DDD中的那个值对象,而是ORM,或者说hibernate中的那个值对象)。领域模型的员工类,在软件系统中被分离为了“员工BUS”类和“员工”值对象类。

正是因为这种数据与业务逻辑处理的分离,令一些人产生了误解,错将领域类对应成了Hibernate对象(希望他正在看这里)。没错,领域模型对应的是BUS层,但部分内容被分离到了值对象中。

记得数年前还有PO和VO的争论,但现在再也没有了。按照现在软件设计的思想,从UI一直到数据库,数据格式变得合成一体了。什么意思呢?页面上的表单是什么样子,提交到后台的值对象就是什么样子,最后持久化成数据库表就是什么样子。按照这样的设计思想,页面上表单中的控件ID、值对象中的属性、数据库表中的字段,都命名成了一致的名称。这样的设计大大简化了程序代码,但因为表单与值对象长得一个模样,也使得一些人误以为领域类对应的是UI。

2)运用领域模型开发的软件系统应当是这样的

不论怎样,我认为,运用领域模型开发软件,应当是以领域模型为中心,即以领域模型为蓝本进行开发,就如同建筑图纸与盖楼一样。领域模型中的某个概念类,在软件设计时应当映射成对应的BUS和值对象。同时,为了让开发人员更加专注地去思考那些领域问题,而不为其它技术细节所分析,也许以下方式不失为一个最佳实践之一:

a.       领域模型被映射到了软件框架的BUS层中。领域模型中的每个概念类,在BUS层中都有对应的XxxBus,并且包含了这个概念类中的所有行为(函数)。

b.       领域模型中的每个概念类都映射成了软件系统中的一个值对象。这个值对象包含了概念类的所有数据(即那些属性),以及各概念类之间的关系。但是一个值对象不一定完全对应一个数据库中的表(比如具有继承关系的值对象)。特别注意,《领域驱动设计》中提到的值对象与ORM中的值对象并不是一个概念,书中的实体也与这里的实体不完全是一个概念。

c.       软件系统中的UI尽量与值对象保持一致,即,页面上表单中的控件ID尽量与值对象中的属性保持对应,并通过诸如DWR的技术,将UI与BUS能够直接交互,简化过去繁杂的service层操作。

d.       使用BasicDao这样的通用代码来处理数据库持久化操作,将值对象直接扔给insert()、update()、delete()、load()函数,摒弃了过去为每个业务设计DAO的设计;采用hql配置文件的方式,将系统需要查询的语句全部放在配置文件中,然后使用统一的find()函数执行查询,满足各种各样的查询要求。

采用这样一个设计框架好处多多。首先它大大简化了软件开发的内容,过去繁杂的service层和DAO层统统被砍掉,仅仅保留下BUS层和UI层(当然必须有诸如DWR的强大框架和诸如BasicDao自开发的超轻量平台的支持)。我始终认为,每增加一段代码,就增加了一份程序出错的机会。因此我总是不遗余力地试图简化代码,甚至到了发指的地步。

其次,系统的层次划分会非常清晰。UI层就是前端的一堆jsp、html和js,BUS就是一堆业务逻辑操作程序(不包含任何诸如hql的数据持久化代码),hql配置文件可以支持多配置文件,因此被分为了“员工管理”配置文件、“部门管理”配置文件、“薪金管理”配置文件。。。。。。

此外,我不得不说,世界终于变得清静了。因为这样一个框架,程序员从那么多羁绊中解脱出来了,他们终于可以全心全意地、以领域模型为中心、仔仔细细地开始考虑那些领域问题了。

在这样一个框架中,每个BUS都有它们自己的职责,这种职责被清清楚楚地标注在各自的注释中。从此,系统开始以职责为中心设计系统了。

3)运用领域模型开发的一个简短实例

也许一个实例是最说明问题的,让我们来举一个项目评审系统的例子吧。

在进行一次评审前首先要制定一个评审计划。在这份计划中,要详细定义此次评审的评审人、评审材料。显然,在领域模型中,评审计划是一个重要的概念,而评审人与评审材料是聚合在评审计划下的。随后是在评审过程中制作评审表。每个评审人都要对评审材料制作评审表。最后,评审组织者根据评审人的意见制作评审报告。

在这样一个需求下,我们应当怎样设计“制作评审表”的业务呢?在领域模型中,“制作评审表”应当是“评审表”的职责,也就是它所拥有的行为。因此,我们创建一个“评审表BUS”,并包含“制作评审表”的函数。随后,我们开始编写“制作评审表”的代码。在这里,我们首先要获取“评审者”和“评审材料”。由于这两部分是聚会在评审计划下的,毫无疑问我们应当调用“评审计划”获取“评审者”和“评审材料”(这里的“评审计划”即可以设计成“评审计划BUS”,也可以设计成“评审计划”配置文件)。然后,我们通过前端与用户交互,最终从前端获得用户填写的评审表,利用dwr直接形成“评审表”值对象,在“保存评审表”中调用通用DAO,持久化“评审表”。

在这样的设计过程中,首先当然是设计领域模型了。在完成了领域模型的设计以后,应当是按照领域模型设计BUS和生成值对象(实际工作中可以先生成数据库再生成值对象)。随后开始编写BUS中的各个方法。在编写过程中,应当将某个方法合理地进行分解,根据职责去调用其它类中的方法(正如评审表去调用评审计划获取评审人和评审材料一样)。通过这样,功能被合理地分配到BUS的各个类中,保证了功能组织的高度内聚。

另一个开发中可能出现的问题这里不得不提。按照理想的领域驱动设计的流程,首先应当是需求分析人员分析和设计出领域模型,然后由开发人员照着领域模型设计开发。但是,由于各种各样的原因,实际情况可能并不总是这样。很多时候,开发人员可能没有得到领域模型而仅仅只有需求文档。这样的情况并不意味着开发人员可以摒弃领域模型而直接开始编码。在编码前,一个简短的领域模型分析和绘制领域草图,就是如同砍柴前的磨刀,是一个必不可少的步骤(这也是领域驱动设计与以往开发模式重要的不同点之一)。

领域模型维护与二次开发

前面,我分别讲述了分析人员运用领域模型分析和开发人员运用领域模型设计。在这两部分,我不断强调运用草图快速进行领域模型分析。开发过程总是忙碌而紧凑的,运用草图快速进行领域模型分析可以大大简化我们分析的过程,提高设计开发的效率。但是,这并不意味着我们可以随意处理这些分析草图。正如建筑设计图是建筑设施运行维护的重要资料,领域模型以及其它资料也是软件系统运行维护的重要资料。因此,我认为,这些分析设计草图应当妥善保管,并且在设计开发完成以后,应当专门进行归纳整理,为今后的运行维护和二次开发提供帮助。

另外,前面我提到,Evans的领域驱动设计,一个非常重要的思想就是持续地精化。Evans认为,我们对业务领域的认识是一个逐渐深刻的过程。随着认识的逐渐深刻,我们应该在一些合适的时机去重构我们的设计,甚至软件系统已经设计完成并交付使用以后。这当然要求我们拿出我们的勇气与魄力。在完成一次重构以后,相应的设计文档也应当同步更新。

当我们完成了以上这些领域模型的维护工作,一旦有新的开发工作,有新人参与项目的时候,快速熟悉系统并适应工作就应当是顺理成章的事情了。而我在《软件开发的轮回》中提到的那些痛苦的经历就将不再会出现。

也许以上的描述还不够直观,表述得还不够清晰。后面我会通过一个实例详细阐释这样的一个开发过程。

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值