让开发自动化:除掉构建脚本中的气味

当描述代码之类的东西时,我不喜欢 “气味(smell)”这个词。因为用拟人的手法来谈论比特和字节往往令人觉得很怪异。并不是说“气味”这个词不能准确地反映出某种表明代码可能有错误的症状,只是我觉得这样听起来很滑稽。然而,我依然选择再次用这种令人厌烦的方式来描述软件构建,坦白说,这是因为这些年我见过的很多构建脚本都散发着难闻的气味。

  在创建构建脚本时,即使是伟大的程序员也常常会遇到困难。就好像最近才学会如何编写程序性 代码似的 —— 他们还会编写庞大的单块构建文件、通过复制-粘贴编写代码、对属性进行硬编码等等。我总是很想知道为什么会这样。也许是因为构建脚本没有被编译成客户最终会使用的东西?然而我们都知道,要创建客户最终使用的代码,构建脚本是中心,如果那些脚本败絮其中,那么要想有效地创建 软件,就需要克服重重挑战。

  幸运的是,您可以轻松地在构建(不管是 Ant、Maven 还是定制的)之上部署一些实践,它们虽然可以帮助您创建一致的、可重复的、可维护的构建,但其过程会很长。学习如何创建更好的构建脚本的一种有效的方法是搞清楚哪些事情不要 去做,理解其中的道理,然后看看做事的正确 方法。在本文中,我将详细论述您应该避免的 9 种最常见的构建中的气味,为什么应该避免它们,以及如何修复它们:

  惟 IDE 的构建
复制-粘贴式的编写脚本方法
冗长的目标
庞大的构建文件
没有清理干净
硬编码的值
测试失败还能构建成功
魔力机
格式的缺失

  这里无意给出完整的列表,不过这份列表的确代表了近年来我读过的和写过的构建脚本中,我遇到的较为常见的一些气味。有些工具,例如 Maven,是为处理与构建有关的很多管道而设计的,它们可以帮助减轻部分气味。但是无论使用什么工具,还是有很多问题会发生。

  避免惟 IDE 的构建

  惟 IDE(IDE-only)的构建是指只能通过开发人员的 IDE 执行的构建,不幸的是,这似乎在构建中很常见。惟 IDE 的构建的问题是,它助长了 “在我的计算机上能运行”问题,即软件在开发人员的环境中可以运行,但是在任何其他人的环境中就不能运行。而且,由于惟 IDE 构建自动化程度不是很高,因而为集成到持续集成(Continuous Integration)环境带来极大的挑战。实际上,没有人为的干预,惟 IDE 常常无法自动化。

  我们要清楚:使用 IDE 来执行构建并没有错,但是 IDE 不应该成为能构建软件的惟一环境。特别是,一个完全用脚本编写的构建,可以使开发团队能够使用多种 IDE,因为只存在从 IDE 到构建的依赖性,而不存在相反方向的依赖性,如图 1 所示:

图 1. IDE 与构建的依赖关系

  惟 IDE 的构建有碍自动化,清除的惟一方法就是创建可编写脚本的构建。有足够的文档和太多的书籍可以为您提供指导(见 参考资料),而像 Maven 之类的项目也为从头开始定义构建提供了极大的方便。不管采用何种方法,都是选择一种构建平台,然后尽快地让项目成为可编写脚本的。

  复制-粘贴就像廉价的香水

  复制代码是软件项目当中一个常见的问题。实际上,甚至很多流行的开放源码项目都存在 20% 到 30% 的复制代码。代码复制令软件程序更难于维护,同理,构建脚本中的复制代码也存在这样的问题。例如,想象一下,假设您需要通过 Ant 的 fileset 类型引用特定的文件,如清单 1 所示:

  清单 1. 复制-粘贴 Ant 脚本

<fileset dir="./brewery/src" >
<include name="**/*.java"/>
<exclude name="**/*.groovy"/>
</fileset>

  如果需要在其他地方引用这组文件,例如为了编译、检查或生成文档,那么最终您可能会在多个地方使用相同的 fileset。如果在将来某个时候,您需要对那个 fileset 做出修改(比如说排除 .groovy 文件),那么最终可能需要在多个地方做更改。显然,这不是可维护的解决方案。然而,要除掉这股气味其实很简单。

  如清单 2 所示,通过 Ant 的 patternset 类型可以引用一个逻辑名称,以表示所需要的文件。那么,当需要向 fileset 添加(或排除)文件时,只需更改一次。

  清单 2. 复制-粘贴 Ant 脚本

<patternset id="sources.pattern">
<include name="**/*.java"/>
<exclude name="**/*.groovy"/>
</patternset>
...
<fileset dir="./brewery/src">
<patternset refid="sources.pattern"/>
</fileset>

  对于精通面向对象编程的人来说,这种修复方法看上去很熟悉:既定的惯例不是在不同的类中一次又一次地定义相同的逻辑,而是将那个逻辑放在一个方法中,在不同地方都可以调用这个方法。于是,这个方法成为惟一的维护点,从而可以限制错误级联并可以鼓励重用。

  不要掺入冗长目标的气味

  Martin Fowler 在他撰写的 Refactoring 这本书中,对代码中存在冗长方法的气味这个问题做了精妙的描述 —— 过程越长,越难理解。实际上,冗长方法最终会担负太多的责任。当谈到构建时, 冗长目标这种构建气味是指更难于理解和维护的脚本。清单 3 就展示了一个相当冗长的目标:

  清单 3. 冗长目标

<target name="run-tests">
<mkdir dir="${classes.dir}"/>
<javac destdir="${classes.dir}" debug="true">
<src path="${src.dir}" />
<classpath refid="project.class.path"/>
</javac>
<javac destdir="${classes.dir}" debug="true">
<src path="${test.unit.dir}"/>
<classpath refid="test.class.path"/>
</javac>
<mkdir dir="${logs.junit.dir}" />
<junit fork="yes" haltonfailure="true" dir="${basedir}" printsummary="yes">
<classpath refid="test.class.path" />
<classpath refid="project.class.path"/>
<formatter type="plain" usefile="true" />
<formatter type="xml" usefile="true" />
<batchtest fork="yes" todir="${logs.junit.dir}">
<fileset dir="${test.unit.dir}">
<patternset refid="test.sources.pattern"/>
</fileset>
</batchtest>
</junit>  
<mkdir dir="${reports.junit.dir}" />
<junitreport todir="${reports.junit.dir}">
<fileset dir="${logs.junit.dir}">
<include name="TEST-*.xml" />
<include name="TEST-*.txt" />
</fileset>
<report format="frames" todir="${reports.junit.dir}" />
</junitreport>
</target>

 这个冗长的目标(相信我,我还见过冗长得多的目标)要执行四个不同的过程:编译源代码、编译测试、运行 JUnit 测试和创建一个 JUnitReport。要担负的责任已经够多了,更不用说将所有 XML 放在一个地方所增加的相关的复杂性。实际上,这个目标可以拆分成四个不同的、逻辑上的目标,如清单 4 所示:

  清单 4. 提取目标

<target name="compile-src">
<mkdir dir="${classes.dir}"/>
<javac destdir="${classes.dir}" debug="true">
<src path="${src.dir}" />
<classpath refid="project.class.path"/>
</javac>
</target>

<target name="compile-tests">
<mkdir dir="${classes.dir}"/>
<javac destdir="${classes.dir}" debug="true">
<src path="${test.unit.dir}"/>
<classpath refid="test.class.path"/>
</javac>
</target>
<target name="run-tests" depends="compile-src,compile-tests">
<mkdir dir="${logs.junit.dir}" />
<junit fork="yes" haltonfailure="true" dir="${basedir}" printsummary="yes">
<classpath refid="test.class.path" />
<classpath refid="project.class.path"/>
<formatter type="plain" usefile="true" />
<formatter type="xml" usefile="true" />
<batchtest fork="yes" todir="${logs.junit.dir}">
<fileset dir="${test.unit.dir}">
<patternset refid="test.sources.pattern"/>
</fileset>
</batchtest>
</junit>  
</target>
<target name="run-test-report" depends="compile-src,compile-tests,run-tests">
<mkdir dir="${reports.junit.dir}" />
<junitreport todir="${reports.junit.dir}">
<fileset dir="${logs.junit.dir}">
<include name="TEST-*.xml" />
<include name="TEST-*.txt" />
</fileset>
<report format="frames" todir="${reports.junit.dir}" />
</junitreport>
</target>

  可以看到,由于每个目标只担负一种责任,清单 4 中的代码理解起来要容易得多。根据用途分离目标,不但可以减少复杂性,还为在不同上下文中使用目标创造了条件,必要时还可以重用。

  庞大的构建文件也有一种很重的气味

  Fowler 还将 庞大的类也看作一种代码气味。就构建脚本而言,有这种类似气味的就是庞大的构建文件,它相当难以读懂。很难知道哪个目标是做什么的,目标的依赖关系是什么。这同样会给维护带来问题。而且,庞大的构建文件通常有相当多的剪切-粘贴的痕迹。

  为了缩小构建文件,可以从脚本中找出逻辑上相关的部分,将它们提取到更小的构建文件中,由主构建文件来执行这些较小的构建文件(例如,在 Ant 中,可以使用 ant 任务调用其他构建文件)。

  通常,我喜欢根据核心功能拆分构建脚本,确保它们可以作为独立脚本来执行(想想构建组件化)。例如,在我的 Ant 构建中,我喜欢定义四种类型的开发者测试:单元、组件、系统和功能。而且,我还喜欢运行四种类型的自动检查工具:编码标准、依赖性分析、代码覆盖范围和代码复杂度。我不是将这些测试和检查工具的执行放在一个庞大的构建脚本中(还加上编译、数据库集成和部署),而是将测试和检查工具的执行目标提取到两个不同的构建文件中,如图 2 所示:

图 2. 提取构建文件

  更小、更简洁的构建文件维护和理解起来要容易得多。实际上,这种模式对于代码而言同样适用。我们似乎在这里看到了模式的概念,不是吗?

 没有清理

  没有严格减少所有底层假设的构建无疑是一颗定时炸弹。例如,如果构建没有避免一些简单的假设,例如会去掉用陈旧的数据生成的二进制文件,那么前一次构建遗留下来的文件就会引起错误。或者,正是由于前一次构建留下的文件,构建竟然得以"成功",这种情况更糟糕。

  幸运的是,这个问题的解决办法很直观:只需删除任何之前的构建留下的所有目录和文件,就可以很容易地消除假设。这个简单的动作就可以减少假设,保证构建的成功或失败都是正确的。清单 5 演示了通过使用 delete Ant 任务删除之前的构建所使用的所有文件或目录,从而清理构建环境的一个例子:

  清单 5. 事先清理

<target name="clean">
<delete dir="${logs.dir}" quiet="true" failonerror="false"/>  
<delete dir="${build.dir}" quiet="true" failonerror="false"/>  
<delete dir="${reports.dir}" quiet="true" failonerror="false"/>  
<delete file="cobertura.ser" quiet="true" failonerror="false"/>  
</target>

  众所周知,旧的构建遗留下来的文件会导致很多不必要的麻烦。为了自己的方便,在运行一个构建之前,务必先删除构建所创建的任何工件。

  硬编码的臭味

  复制-粘贴式的编程有碍重用,将值进行硬编码又何尝不是呢。当构建脚本包含硬编码的值时,如果某个方面需要修改,那么就需要在多个地方修改那个值。更糟糕的是,很可能会忽略了某个地方而没有改那个值,从而引起与不匹配的值相关的错误,这种错误是很隐蔽的。而且,如果相信我的建议,选择使用多个构建脚本,那么硬编码的值将可能会成为构建维护中最终的挑战。在这一点上也请相信我!

  例如,在清单 6 中,run-simian 任务有很多硬编码的路径和值,即 _reports 目录:

  清单 6. 硬编码的值

<target name="run-simian">
<taskdef resource="simiantask.properties"
classpath="simian.classpath" classpathref="simian.classpath" />
<delete dir="./_reports" quiet="true" />
<mkdir dir="./_reports" />
<simian threshold="2" language="java"
ignoreCurlyBraces="true" ignoreIdentifierCase="true" ignoreStrings="true"
ignoreStringCase="true" ignoreNumbers="true" ignoreCharacters="true">
<fileset dir="${src.dir}"/>
<formatter type="xml" toFile="./_reports/simian-log.xml" />
</simian>
<xslt taskname="simian"
in="./_reports/simian-log.xml"
out="./_reports/Simian-Report.html"
style="./_config/simian.xsl" />
</target>

  如果硬编码 _reports 目录,那么当我决定将 Simian 报告放到另一个目录时,就会很麻烦。而且,如果其他工具在脚本的其他地方使用这个目录,那么很可能会有人输错目录名称,导致报告显示在不同的目录中。这时可以定义一个属性值,由这个属性值指向这个目录。然后,在整个脚本中都可以引用这个属性,这意味着当需要更改的时候,只需光顾一个地方,即属性的定义。清单 7 展示了重构之后的 run-simian 任务:

  清单 7. 使用属性

<target name="run-simian">
<taskdef resource="simiantask.properties"
classpath="simian.classpath" classpathref="simian.classpath" />
<delete dir="${reports.simian.dir}" quiet="true" />
<mkdir dir="${reports.simian.dir}" />
<simian threshold="${simian.threshold}" language="${language.type}"
ignoreCurlyBraces="true" ignoreIdentifierCase="true" ignoreStrings="true"
ignoreStringCase="true" ignoreNumbers="true" ignoreCharacters="true">
<fileset dir="${src.dir}"/>
<formatter type="xml" toFile="${reports.simian.dir}/${simian.log.file}" />
</simian>
<xslt taskname="simian"
in="${reports.simian.dir}/${simian.log.file}"
out="${reports.simian.dir}/${simian.report.file}"
style="${config.dir}/${simian.xsl.file}" />
</target>

  硬编码的值不仅没有提高灵活性,反而拟制了灵活性。就像在源代码中很容易硬编码数据库连接 String 一样,在构建脚本中也应该避免将路径之类的东西硬编码。

  测试失败时,构建却能成功

  构建远远不止于单纯的源代码编译,它还可能包括自动化开发者测试的执行,如果想让软件一直正常运行,那么决不能允许构建中有任何失败的测试。别忘了,如果测试都得不到信任,那么还要测试干什么呢?

  清单 8 是这种构建气味的一个例子。注意 junit Ant 任务的 haltonfailure 属性被设置为 false(它的缺省值)。这意味着即使任何 JUnit 测试是失败的,构建也不会失败。

  清单 8. 气味:测试失败,构建却成功

<junit fork="yes" haltonfailure="false" dir="${basedir}" printsummary="yes">
<classpath refid="test.class.path" />
<classpath refid="project.class.path"/>
<formatter type="plain" usefile="true" />
<formatter type="xml" usefile="true" />
<batchtest fork="yes" todir="${logs.junit.dir}">
<fileset dir="${test.unit.dir}">
<patternset refid="test.sources.pattern"/>
</fileset>
</batchtest>
</junit>

  有两种方法防止构建中的这种气味。第一种方法是将 haltonfailure 属性设置为 true。这样就可以防止测试失败构建却成功的情况发生。

  对于这种方法,我惟一不喜欢的地方是,我想看看有多大百分比的测试遭到了失败,以便弄清楚失败的模式。因此第二种方法就是,每当有测试失败,就设置一个属性。然后,我对 Ant 进行配置,使得当执行了所有的测试之后,构建最终失败。这两种方法都行之有效。清单 9 演示了使用 tests.failed 属性的第二种方法:

  清单 9. 测试令构建失败

<junit dir="${basedir}" haltonfailure="false" printsummary="yes"
errorProperty="tests.failed" failureproperty="tests.failed">
<classpath>
<pathelement location="${classes.dir}" />
</classpath>
<batchtest fork="yes" todir="${logs.junit.dir}" unless="testcase">
<fileset dir="${src.dir}">
<include name="**/*Test*.java" />
</fileset>
</batchtest>
<formatter type="plain" usefile="true" />
<formatter type="xml" usefile="true" />
</junit>
<fail if="tests.failed" message="Test(s) failed." />

====================================分割线================================

最新内容请见作者的GitHub页:http://qaseven.github.io/

时间: 2024-09-12 17:47:58

让开发自动化:除掉构建脚本中的气味的相关文章

将AngularJS的单元测试和端到端测试集成到gradle构建脚本中

我目前工作的一个项目后端使用java和spring建立了一个restful service,前端使用AngularJS来渲染页面,提供用户接口.在前端的AngularJS项目中,我们使用Jasmine来写单元测试,使用AngularJS自带的Angular_scenario来写端到端测试.运行这些测试则使用的是Karma. 虽然使用Karma在命令行下可以很方便的运行所有的测试,但是我们想将这些集成到gradle的构建脚本中,从而将AngularJS的所有测试加入到CI的构建中.同时为了保证运行

让你提前认识软件开发(22):shell脚本中的文件操作

第1部分 重新认识C语言 shell脚本中的文件操作   [文章摘要]         编写shell脚本时,经常会涉及到对文件的操作,比如从文件中读取一行数据.向文件追加一行数据等.完成文件读写操作的方法有很多,了解各种命令下文件操作的执行情况,有助于开发人员在不同使用场景下选择合适的命令.        本文以实际的shell脚本为例,介绍了对文件进行操作的不同方法,为相关开发工作提供了参考. [关键词]        shell  文件操作  读写  效率   一.概述         在

让你提前认识软件开发(30):数据库脚本中的空行与空格

第2部分 数据库SQL语言 数据库脚本中的空行与空格             在数据库脚本中,空行与空格起着"锦上添花"的作用.恰当地使用它们,可以提高代码的规范性及可阅读性,进而提升数据库的编程效率.   1. 空行         空行起着分隔脚本段落的作用,适当的空行可以使脚本的布局更加的清晰.空行的作用有以下几个:         (1) 用于分隔两个数据表的创建脚本         示例: create table tb_example1 (         [表内容实现代码

基于CruiseControl和Rational统一变更管理实现的软件开发中的自动化持续构建

基于CruiseControl和Rational统一变更管理实现的软件开发中的自动化持续构建 简介:本文介绍了持续构建工具 CruiseControl 和 IBM Rational 统一变更管理集成的解决方案.通 过本文中的解决方案,可以尽早的发现和规避代码中存在的风险,遵守统一的流程及时获取可发布的软件 ,确保敏捷开发的速度和质量. 统一变更管理系统中持续集成的必要性 使用 IBM Rational ClearCase 和 IBM Rational ClearQuest 实现的统一变更管理软件

【COCOS2DX(2.X)_LUA开发之三】在LUA中使用自定义精灵(LUA脚本与自创建类之间的访问)及LUA基础讲解

本站文章均为 李华明Himi 原创,转载务必在明显处注明:  转载自[黑米GameDev街区] 原文链接: http://www.himigame.com/lua-game/985.html 本篇做起来比较累,大家请参考最新篇[COCOS2DX-LUA 脚本开发之四]使用tolua++编译pkg,从而创建自定义类让Lua脚本使用 此篇可能会在最新的cocos2dx版本中出现如下问题: 1 2 LUA ERROR: ...24F82-1230-41FE-8A04-C445FB7D1BAB/mtet

centos6中AWS自动化运维脚本分享

背景介绍: 目前项目中使用了大量的AWS EC2 Instances作为服务器,在自动化运维方面,我们之前一直使用的是AWS CLI命令行工具,然后在Shell脚本中调用. 最近我想通过脚本实现一个"Clone"的功能,模拟Web Console上的"Launch More Like This"来创建Instance.但在Shell脚本中实现起来感觉不太舒服,于是就直接利用Python的boto库写了一个,在此分享给大家. 具体内容: 脚本地址:https://gi

提前认识软件开发(31) 数据库脚本中的begin与end

在数据库脚本中,begin与end是一对奇怪的单词.缺少它们,某些代码看起来会让人一头雾水:添加它们,代码的结构瞬间就清晰了. 确实,begin与end作为代码语句的开始和结束标志,可以让脚本程序的逻辑明确,易于阅读. begin与end主要用在以下地方: 1. if.else.else if.while等语句中 if.else.else if.while等语句要自占一行,执行语句不得紧跟其后,不论执行语句有多少都要加语句块标志begin-end. 脚本文件中的begin和end应独占一行并且位

提前认识软件开发(30) 数据库脚本中的空行与空格

在数据库脚本中,空行与空格起着"锦上添花"的作用.恰当地使用它们,可以提高代码的规范性及可阅读性,进而提升数据库的编程效率. 1. 空行 空行起着分隔脚本段落的作用,适当的空行可以使脚本的布局更加的清晰.空行的作用有以下几个: (1) 用于分隔两个数据表的创建脚本 示例: create table tb_example1 ( [表内容实现代码] ) go -- 空行 create table tb_example2 ( [表内容实现代码] ) go (2) 用于分割两个存储过程的创建脚

手把手教你使J2EE项目开发自动化

j2ee|项目     编者语:在这本书中,Pragmatic Project Automation, Mike Clark 为你提供了无所不有的方法来自动化你的软件项目:用Ant来一步式构建(one-step builds),用CruiseControl 按计划时间来持续构建(scheduling continuous builds),按一下按钮就可以发布软件,轻易地安装和布署应用,通过email,RSS,你的手机,甚至是熔岩灯(lava lamps)来监控构建和程序运行.方法包含示例使初学者