演化架构与紧急设计: 对设计进行重构
 

2009-08-17 作者:Neal Ford 来源:IBM

 
本文内容包括:
本系列 的早期文章讨论了单元测试如何引导您实现更好的设计。但是如果您已经具备大量的代码,那么如何从代码中发现隐藏的设计元素?上一期文章 讨论了为您的代码构建结构化目标。在本期文章中,作者 Neal Ford 进一步拓展了这些主题,介绍了可用于重构并实现紧急设计的各种技术。

在 “测试驱动设计,第 1 部分” 和 “测试驱动设计,第 2 部分” 中,我介绍了测试如何为新的项目实现更好的设计。在 “组合方法和 SLAP” 中,我讨论了两种关键模式 — 组合方法(composed method)和单一抽象层原理 — 为您的代码结构提供了整体目标。需要牢记这些模式。一旦拥有了一个现有软件项目,那么发现和利用设计元素的主要方法就是进行重构。在 Martin Fowler 的经典著作 Refactoring 中,他将重构定义为 “一种严格的技术,可以重新构造现有代码体,修改代码的内部结构,但是不会影响代码的外部行为”(见 参考资料)。重构是一种具有某种目的的结构转换。对任何项目来说,值得称赞的一点就是拥有可以轻松进行重构的代码库。在本文中,我将讨论如何使用重构技术来查找隐藏在代码库中的未得到充分设计的代码。

单元测试可以提供最重要的安全屏障,允许您按照自己的意愿重构代码库。如果您的项目的代码覆盖率达到了 100%,那么可以安全地重构代码。如果尚未达到这个程度的测试,那么草率地进行重构就会变得比较危险。本地化修改可以很容易地应用并且可以立即看到修改效果,但是副作用产生的破坏也会使您非常苦恼。软件会产生无法预料的耦合点,对代码的某一部分进行微小的修改会影响到整个代码库,造成数百行代码发生错误。要安全地修改代码并找出大量错误,需要进行广泛的单元测试。对于一个为期 2 年的 ThoughtWorks 项目,技术主管在运行该项目的前一天对代码进行了 53 处不同的重构。他在进行重构时信心满满,因为项目拥有广泛的代码覆盖率。

如何实现可以进行重大重构的代码库?一个办法就是不要编写任何代码,直到您将测试添加到整个项目中。当您提出这个建议后,您将被解雇,然后您可以去另一家重视单元测试的公司工作。这种方法可能不是很好。另一个好方法是让团队的其他成员认识到测试的价值并开始缓慢地围绕代码的最关键部分添加测试。做好规划并在近期内宣布一个日期:“从下周四启动,我们的代码覆盖率将不断增长”。每次编写新代码时,添加一个测试,每次修复一个 bug 时,编写一个测试。通过围绕最敏感的代码部分(新特性和容易出现 bug 的部分)逐步添加测试,那么测试就可以发挥最大的作用。

单元测试检验原子性行为。但是,如果您的代码库没有坚持组合方法的思想,该怎么办?换句话说,如果您的所有方法都具有几十或几百行代码,并且每个方法执行大量的任务,那么应该怎么做?您可以使用单元测试框架来围绕这些方法编写粗粒度功能测试,主要关注方法的输入和输出状态的转换。这种方法不如单元测试,因为不能对行为进行彻底的检验,但是总比不采取任何措施要好。对于代码中真正关键的部分,可能需要在进行重构之前添加一些功能测试作为一种安全保障。

重构机制非常简单,并且所有主要 IDE 目前都提供了出色的重构支持。比较困难的地方在于确定对哪些内容 进行重构。这就是本文其余部分要解决的问题。

与基础设施耦合

Java 世界的所有开发人员都使用框架来启动开发并提供最好的关键基础设施(不需要您编写的基础设施)。但是框架(包括商业的和开源的)所隐含的一个危险就是:它们总是试图让您与其进行紧密耦合,这使得发现代码中隐藏的设计变得更加困难。

框架和应用服务器都提供了 helper 类,诱使您实施一种更加简单的开发:如果您仅仅是导入和使用它们的某些类,那么完成特定的任务将变得非常容易。一个典型的例子就是 Struts,这是一种非常流行的开源 Web 框架。Struts 包括了一组 helper 类来帮助您处理常见问题。例如,如果允许您的域类扩展 Struts ActionForm 类,那么 Struts 将自动从请求中填充表单字段,处理验证和生命周期事件,并执行其他比较简单的行为。换而言之,Struts 提供了某种权衡:使用我们的类将使您的开发工作变得非常轻松。它鼓励您创建一种类似于图 1 所示的结构:

图 1. 使用 Struts ActionForm
Model 类扩展 ActionForm

黄色的方框包含了您的域类,但是 Struts 框架鼓励您扩展 ActionForm 获得有用的行为。然而,您现在必须将代码与 Struts 框架耦合。除了 Struts 应用程序外,您不能在其他任何位置使用域类。这还不利于域类的设计,因为这个实用类现在必须位于对象层次结构的顶层,不允许您使用继承来整合常见行为。

图 2 展示了一种更好的方法:

图 2. 改进后的设计,使用复合解除与 Struts 的耦合
使用复合解除与 Struts 的耦合

采用这种方法,您的域类对 Struts ActionForm 不存在任何依赖关系。相反,一个接口为您的域类和 ScheduleItemForm 类定义了语义,后者充当域类与框架之间的一个桥梁。ScheduleItemImplScheduleItemForm 都实现了这个接口,而 ScheduleItemForm 类通过复合(而不是继承)获得了对域类的引用。允许 Struts helper 维护对您的类的依赖关系,但是反过来并不成立:您不应该让类对框架存在依赖关系。现在,您可以在其他类型的应用程序中随意使用 ScheduleItem(比如 Swing 应用程序和服务层等)。

与基础设施建立耦合非常简单,并且对于许多应用程序来说也很普遍。当您导入了框架的优点时,框架可以极大地简化对服务的利用。您应当抵制这种诱惑。如果框架掩盖住了所有内容,那么就更加难以发现代码中的惯用模式(早期文章中定义为应用程序中出现的 little 模式)。

违反 DRY 原则

The Pragmatic Programmer 一书中,Andy Hunt 和 Dave Thomas 定义了 DRY 原则:不要自我复制(见 参考资料)。代码中有两处违背了 DRY 原则 — 复制和粘帖代码以及结构化复制,这将对设计产生影响。

复制和粘帖代码

代码复制使设计变得更加模糊,因为您无法找到惯用模式。在不同位置复制和粘帖代码会产生一些微小的差异,使您无法确定一个方法或多个方法的实际使用。当然,人们都知道复制和粘帖代码最终会害了自己,因为您不可避免地要修改行为,但是很难跟踪所有复制和粘帖了代码的位置。

如何找出代码库中隐藏的复制?有的 IDE 包括了复制检测器(比如 IntelliJ),或者提供了插件(比如 Eclipse)。还存在独立的工具,即包括开源的(比如 CPD,即 Copy/Paste Detector),也包括商业的(比如 Simian)(见 参考资料)。

CPD 项目是 PMD 源代码分析工具的一部分。它是一个基于 Swing 的应用程序,可以分析单独文件内或跨多个文件的标记的可配置数量。我需要一个典型的问题代码库来作为示例,因此选择了前面提到的 Struts 项目。在 Struts 2 代码上运行 CPD 将生成如图 3 所示的结果:

图 3. 在 Struts 2 上运行 CPD 生成的结果
在 Struts 2 上运行 CPD 生成的结果

CPD 在 Struts 代码库中找到了大量复制。其中许多复制主要关于向 Struts 添加 portlet 支持。事实上,大多数跨文件复制存在于 PortletXXX XXX (例如 PortletApplicationMapApplicationMap)之间。这表明 portlet 支持没有进行过良好设计。任何时候使用这些复制代码为现有框架添加额外的行为时,这都是主要的代码特征(code smell)。继承或复合都提供了一种更加干净的方法来扩展框架,如果这两种方法都不可行的话,那么问题就更加严重了。

这段代码中另一个常见的复制问题在 ApplicationMap.java 和 Sorter.java 文件中。ApplicationMap.java 包含一个共 27 行的复制代码块,如清单 1 所示:

清单 1. ApplicationMap.java 中的复制代码
 
				
entries.add(new Map.Entry() {
    public boolean equals(Object obj) {
        Map.Entry entry = (Map.Entry) obj;

        return ((key == null) ? 
            (entry.getKey() == null) : 
            key.equals(entry.getKey())) && ((value == null) ? 
                (entry.getValue() == null) : 
                value.equals(entry.getValue()));
    }

    public int hashCode() {
        return ((key == null) ? 
            0 : 
            key.hashCode()) ^ ((value == null) ? 
                0 : 
                value.hashCode());
    }

    public Object getKey() {
        return key;
    }

    public Object getValue() {
        return value;
    }

    public Object setValue(Object obj) {
        context.setAttribute(key.toString(), obj);

        return value;
    }
});

除了多次使用嵌套的三元运算符外(是判断任务安全性编码的良好指标,因为任何人都不能读取这些代码),这段复制代码的有趣之处并不在于代码本身。而是在于在出现复制的两个方法之前显示的先兆。第一处如清单 2 所示:

清单 2. 第一次出现复制代码时的先兆
 
				
while (enumeration.hasMoreElements()) {
    final String key = enumeration.nextElement().toString();
    final Object value = context.getAttribute(key);
    entries.add(new Map.Entry() {
    // remaining code elided, shown in Listing 1

清单 3 展示了出现第二次复制时的先兆:

清单 3. 出现第二次复制代码时的先兆
 
				
while (enumeration.hasMoreElements()) {
    final String key = enumeration.nextElement().toString();
    final Object value = context.getInitParameter(key);
    entries.add(new Map.Entry() {
    // remaining code elided, shown in Listing 1

在整个 while 循环中的惟一不同之处在于 清单 2 中对 context.getAttribute(key) 的调用和 清单 3 中对 context.getInitParameter(key) 的调用之间的差别。显然,这些可以实现参数化,允许复制代码销毁自己的方法。来自 Struts 的示例解释了免费的复制和粘帖代码,这些代码不仅毫无必要,并且容易修复。

实际上,这解释了利用并将条目添加到属性集中的方法在 Struts 代码库中是一种惯用模式。允许将几乎相同的代码放到多个位置将隐藏一个事实,即 Struts 需要一直执行这个操作,这将阻止将这些代码放到一个含义更明显的位置。要清理 Struts 代码库中多个类的设计,一种方法就是意识到这种惯用模式的存在并巩固这一行为。

结构化复制

另一种复制形式更加难以检测,因此危害也更大:结构化复制。使用有限的几种语言的开发人员(特别是那些只具备少量元编程支持的语言,比如 Java 和 C#)尤其容易出现这个问题。我的同事 Pat Farley 使用了一个短语就很好地总结了结构化复制:相同的空白,不同的值。就是说,您复制了几乎一模一样的代码(即空白位置也是相同的),但是变量使用不同的值。这种复制并不会出现在 CPD 之类的工具中,因为重复的基础设施的每个实例的值必须是惟一的。尽管如此,它仍然会损害您的代码。

下面举一个例子。我使用一个包含若干字段的简单 employee 类,如清单 4 所示:

清单 4. 一个简单 employee 类
 
				
public class Employee {
    private String name;
    private int salary;
    private int hireYear;

    public Employee(String name, int salary, int hireYear) {
        this.name = name;
        this.salary = salary;
        this.hireYear = hireYear;
    }

    public String getName() { return name; }
    public int getSalary() { return salary;}
    public int getHireYear() { return hireYear; }
}

对于这个简单类,我希望能够对类的任意字段进行排序。Java 语言中的一种机制可以通过创建实现 Comparator 接口的 comparator 类,改变排序次序。清单 5 展示了 namesalary 的 Comparator:

清单 5. namesalary 的 Comparator
 
				
public class EmployeeNameComparator implements Comparator<Employee> {
    public int compare(Employee emp1, Employee emp2) {
        return emp1.getName().compareTo(emp2.getName());
    }
}

public class EmployeeSalaryComparator implements Comparator<Employee> {
    public int compare(Employee emp1, Employee emp2) {
        return emp1.getSalary() - emp2.getSalary();                
    }
}

对于 Java 开发人员来说,这看上去非常自然。然而,考虑图 4 所示的代码视图,我在其中将两个 Comparator 重叠:

图 4. 重叠后的 comparator
Comparator 重叠

可以看到,相同的空白,不同的值 可以很好地形容这个情况。大部分代码是经过复制的;惟一不同的部分是返回的值。由于我以一种 “自然” 的方式使用了比较基础设施(即按语言设计者的意图使用),因此很难发现这种复制,但是它确实存在于代码中。也许对于区区三个属性来说不算太严重,但是如果增长到大量属性呢?您决定什么时候开始处理这种复制,您打算怎么对付它?

我准备使用反射(reflection)来创建一种通用的排序基础设施,其中不会涉及大量复制的样板代码。为此,我创建了一个类来为每个字段自动处理 comparator 的排序和创建。清单 6 展示了 EmployeeSorter 类:

清单 6. EmployeeSorter
 
				
public class EmployeeSorter {

    public void sort(List<DryEmployee> employees, String criteria) {
        Collections.sort(employees, getComparatorFor(criteria));
    }

    private Method getSelectionCriteriaMethod(String methodName) {
        Method m;
        methodName = "get" + methodName.substring(0, 1).toUpperCase() +
                methodName.substring(1);
        try {
            m = DryEmployee.class.getMethod(methodName);
        } catch (NoSuchMethodException e) {
            throw new RuntimeException(e.getMessage());
        }
        return m;
    }

    public Comparator<DryEmployee> getComparatorFor(final String field) {
        return new Comparator<DryEmployee>() {
            public int compare(DryEmployee o1, DryEmployee o2) {
                Object field1, field2;
                Method method = getSelectionCriteriaMethod(field);
                try {
                    field1 = method.invoke(o1);
                    field2 = method.invoke(o2);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                }
                return ((Comparable) field1).compareTo(field2);
            }
        };
    }
}

sort() 方法使用 Collecions.sort() 方法,传递雇员列表和一个生成的 comparator,调用此类的第三个方法。getComparatorFor() 方法充当一个工厂,根据传入的条件动态生成匿名 comparator 类。它通过 getSelectionCriteriaMethod() 使用反射从 employee 类检索相应的 get 方法,对进行比较的每个实例调用该方法,并返回结果。清单 7 中的单元测试展示了这个类对两个字段的实际应用:

清单 7. 测试泛型 comparator
 
				
public class TestEmployeeSorter {
    private EmployeeSorter _sorter;
    private ArrayList<DryEmployee> _list;
 
    @Before public void setup() {
        _sorter = new EmployeeSorter();
        _list = new ArrayList<DryEmployee>();
        _list.add(new DryEmployee("Homer", 20000, 1975));
        _list.add(new DryEmployee("Smithers", 150000, 1980));
        _list.add(new DryEmployee("Lenny", 100000, 1982));
    }

    @Test public void name_comparisons() {
        _sorter.sort(_list, "name");
        assertThat(_list.get(0).getName(), is("Homer"));
        assertThat(_list.get(1).getName(), is("Lenny"));
        assertThat(_list.get(2).getName(), is("Smithers"));
    }

    @Test public void salary_comparisons() {
        _sorter.sort(_list, "salary");
        assertThat(_list.get(0).getSalary(), is(20000));
        assertThat(_list.get(1).getSalary(), is(100000));
        assertThat(_list.get(2).getSalary(), is(150000));
    }
}

像上面这样使用反射代表了一种权衡:复杂性和简洁性。基于反射的版本最初比较难以理解,但是它提供了一些优点。首先,它自动为 Employee 类处理所有属性。准备好这些代码后,您可以安全地向 Employee 添加新属性,而不需要考虑创建 comparator 来对它们进行排序。其次,这种方法可以更加高效地处理大量属性。如果结构化复制不严重的话,那么也可以忍受。但是您必须要问问自己:当属性数量达到多少时,您必须使用反射来解决此问题?10 个、20 个还是 50 个?这个数字对于不同的开发人员和团队来说可能会发生变化。然而,如果试图寻找一种比较客观的衡量,为什么不衡量一下反射相对于单个 comparator 的复杂度呢?

在 “测试驱动设计,第 2 部分” 中,我引入了圈复杂度 指标,可以度量单一方法的相对复杂度。一种可以度量 Java 语言圈复杂度的非常不错的开源工具就是 JavaNCSS 工具(见 参考资料)。如果我对单个 comparator 类运行 JavaNCSS,它将返回 1,这并不奇怪:类中的单个方法只有一行代码而没有代码块。当我对整个 EmployeeSorter 类运行 JavaNCSS 时,所有方法的圈复杂度的总值为 8。这表示当属性数达到 9 时需要改用反射;这时结构复杂度超过了基于反射的方法的复杂度。

总之,每种解决方法都有利弊,这取决于您如何权衡。我已经习惯在 Java 语言和其他语言中使用反射,因此我将更加积极地推行这种方法,因为我不喜欢在软件中使用各种形式的重复。

结束语

在本期文章中,我首先讨论了使用重构作为手段来帮助理解和识别紧急设计。我介绍了与基础设施的耦合及其对设计的不利影响。文章主要介绍了几种不同形式的复制。重构与设计的交互是一个非常丰富的主题;下期文章将继续这个主题,讨论如何使用指标查找代码中最需要进行重构的部分,因此这些部分也最有可能包含等待发现的惯用模式。

参考资料

学习 获得产品和技术
  • PMD:下载 PMD(包括 CPD)。
  • Simian:Simian(Similarity Analyser)可以识别 Java、C#、C、C++、COBOL、Ruby、JSP、ASP、HTML、XML、Visual Basic 和 Groovy 源代码中的复制。
  • JavaNCSS:该命令行实用工具可以为 Java 编程语言度量两种标准的源代码指标。
讨论

火龙果软件/UML软件工程组织致力于提高您的软件工程实践能力,我们不断地吸取业界的宝贵经验,向您提供经过数百家企业验证的有效的工程技术实践经验,同时关注最新的理论进展,帮助您“领跑您所在行业的软件世界”。
资源网站: UML软件工程组织