通过代码分离模型编写易维护易测试的代码

2021-10-15 12:48刘静静吕何新
计算机应用与软件 2021年10期
关键词:单元测试调用开发者

刘静静 吕何新

1(上海大学计算机工程与科学学院 上海 200444) 2(浙江树人大学信息科技学院 浙江 杭州 310015)

0 引 言

随着互联网时代的发展,应用软件也百花齐放。消费者对计算机应用软件的要求也越来越高。一款应用从创新的想法到投入市场的时间及其后续对已有功能的改进和新功能开发的周期时间也越来越短,这样才能让科技公司抓住商机,不断满足消费者越来越高的需求。在这样的挑战下,计算机软件的可维护性就凸显出越来越重要的地位,因为只有可维护性好的应用才能对各类改变做出快速响应并尽快投入市场。对于软件的可维护性,有两个非常重要的方面:是否容易修改和是否容易测试。测试尤其体现在是否容易做单元测试。为了写出易维护的代码,计算机领域很多专家做了大量的研究和探索,并取得了非常多的成果。Erich等[1]提出了非常著名的设计模式的概念,该理念至今仍然没有过时,在现在的软件开发中仍然频繁地被使用。为了让现有的代码变得更容易维护和测试,Martin[2]提出了重构的概念,这是设计模式之后的又一里程碑。重构现在仍然是开发者们经常提及和使用的技术和方法,国内在重构[3-5]和解依赖领域对静态代码依赖分析也做了大量研究[6]。Michael[7]对于修改现有的代码给出了比重构更加细致的方法,提出了多种有效修改已有代码的技术和方法。Kent等[8]推出了成熟版的极限编程,在测试驱动、持续集成、自动化测试等多个方面给出了一整套用来开发易维护应用软件的框架。对于每个类如何编写、类和类之间如何协作,Kent[9]又给出了在实现细节层面的实现模式,为写出坚实的代码又贡献了一份力量。在设计和实现大量理论之后,Martin[10]提出了整洁代码的理念和技术,并快速得到了大家的认可,高原等[11]在此基础上对代码坏味道的处理顺序给出了方案。Dustin等[12]在代码的可读性方面给出了各种实践和建议,代码的整洁和可读性也成为开发者密切关注的内容。

代码的可测性方面,主要的成果来自于Kent。Kent设计并实现了JUnit,开启了单元测试的纪元,后续在此基础上发展出了xUnit[13]系列。截至2019年9月8日,JUnit已迭代数十个版本,当前最新的版本是5.5.2。单元测试的出现大幅提升了代码的可维护性和质量。Kent[14]提出了测试驱动开发的理念,把单元测试的范畴从测试扩展到了开发和设计领域,为实现易维护易测试的代码又向前迈进了一步。

1 问题陈述

现在已有大量的理论和方法来支持代码的整洁和易维护性,但是目前开发者仍然面临代码不易修改和不易测试的难题。仔细分析上面的理论和技术后发现,已有理论对应单个方法的修改和测试的支持有所不足,而开发者编写和修改代码的时候,大部分情况下都是直接面临单个方法。对设计模式、重构等技能的掌握要非常熟练的时候,开发者才能轻松地将其应用到单个方法之上。同时,现有的理论和技术还存在第二个问题,学习掌握的难度较大。这样直接导致了一个现象,虽然这些技术非常知名和有用,但是现在对这些技能精通的开发却少之又少,进而严重影响了其在提高代码维护性方面的作用。

鉴于上面存在的两个问题,本文在大量研究已有理论和技术的基础上,提出了代码分离模型,其包括隔离方法和隔离层的概念,并在研究和分析大量代码实现场景之后,设计了代码分离的一系列模式。

隔离方法可以非常便捷地应用于单个方法层面,同时理解和掌握的难度也大幅降低。代码分离模式的提出可以帮助开发者提前识别出常见的应用场景,进而提高该技术使用的可能性。

2 代码分离模型

代码易维护性之所以存在问题,关键的一点是代码不相关的部分混合在一起,也称为相互依赖。现有关于提高可维护性的核心理念也是解开依赖。只要把不相关的部分分离开来,这样每一部分就可以单独进行修改和测试,代码的可维护性就会大幅提升。为了解决这一问题,本文提出隔离方法如下。

定义1数据隔离。把通过非基本类型获取数据的代码部分提取并隔离起来,将需要的数据从非基本类型获取后生成基本类型的数据,然后把原来方法对非基本类型的依赖转换为对基本类型数据的依赖,再使用该基本类型数据来调用原来的方法。

定义2行为隔离。把非业务行为的代码提取并隔离起来,将业务行为提取到一个单独的方法中,在隔离起来的非业务行为方法里调用新提取的方法,并建立非业务行为和业务行为的单一连接点。

在既没有非基本类型依赖也没有非业务行为的场景下,业务逻辑还存在一种常见的耦合和依赖,即流程与细节的紧密耦合。所以,第三种隔离方法为粗细隔离。粗的部分为流程和步骤,细的部分为详细的逻辑内容。

定义3粗细隔离。把业务逻辑的流程和步骤隔离出来,通过每一个步骤来调用其对应的具体细节逻辑,进而建立步骤和其对应的具体业务逻辑的连接。引入面向接口编程[15],可以更方便地修改或者替换每一个步骤,此时为增强版粗细隔离模式。

数据隔离和行为隔离方法的特点如下:

1) 只包含隔离出来的非基本类型或非业务行为。

2) 包含对原有方法的调用。

3) 没有逻辑。

4) 保留签名。

粗细隔离方法的特点如下:

1) 只包含逻辑的步骤轮廓。

2) 每一个步骤调用其对应的业务逻辑。

3) 没有细节。

4) 保留签名。

下文逐一讲解三种隔离方法的详细使用步骤。

数据隔离方法的使用步骤如下:

1) 找到需要隔离的非基本类型。

2) 将获取数据的调用提取到一个新方法,即为隔离方法。

3) 在隔离方法里从非基本类型中获取需要的基本类型的数据。

4) 根据需要为原来的方法添加参数,将获取数据的地方替换为参数引用。

5) 在隔离方法里调用添加过参数的原来的方法,将获取的基本数据类型作为参数传递过去。

行为隔离方法的使用步骤如下:

1) 找出需要隔离的非业务逻辑行为。

2) 将业务逻辑的行为放到一个新方法中。

3) 将非业务逻辑的行为保留在原来的方法,形成隔离方法。

4) 在隔离方法里调用新生成的只包含业务行为的方法。

粗细隔离方法的使用步骤如下:

1) 梳理业务逻辑的步骤。

2) 将代码整理并提取到每一个步骤对应的方法中。

3) 在隔离方法里调用每一个步骤对应的方法。

4) 如果为增强型粗细隔离方法,再为每个步骤创建一个接口及其现在对应的实现类。

假设方法1存在对方法2的调用,并且方法2中存在对非基本类型的依赖,对方法2使用隔离方法前后的对比如图1所示。

图1 使用隔离方法前后对比

如果代码中存在对一类非自己的API的大量依赖,比如数据存取框架Hibernate[16]。这样会有大量包含此类API的隔离方法,后续维护也会成为难题。针对这种情况,本文提出隔离层的概念和方案来解决这一问题。隔离层如图2所示。

图2 隔离层

定义4隔离层。把属于一类的隔离方法放到一起形成很薄的代码层,便于以后对外部类库的修改和替换,并且代码的改动只发生在隔离层之中,业务逻辑对应的代码不受任何影响。

当需要替换成另一类API时,只需要按照接口再实现一遍方法1到方法N,然后在隔离层的隔离方法里面替换为新的方法1到方法N,原来对隔离层的调用不需要做任何修改。通过隔离层,把外部的类库从自己的代码中分离出来,这样既实现了外部类库的方便替换,也实现了自己代码的测试便捷性。

在隔离方法和隔离层的基础上,借鉴设计模式的理念,把常见的分离场景再归为几种代码分离模式,就形成了本文的代码分离模型,如图3所示。

图3 代码分离模型

3 代码分离模式

有了隔离方法和隔离层之后,将其使用到软件开发的过程中,本文发现主要的应用场景可以归纳为几类,在此基础上本文提出了代码分离模式。通过代码分离模式,开发者可以在常用的场景下更加熟练和准确地应用代码分离模型技术,进而提高编写出可维护易测试的代码的可能性。下面将详细描述每一个模式。在模式的描述中本文使用目前主流的开发语言Java[17]。

3.1 定义从细节中分离

在编写代码的过程中,我们需要创建大量对象,这些对象的直接创建造成了对象之间的强依赖,而这样的强依赖又带来了维护和测试的难题。看下面一段代码。

public String buildHtml() {

StringBuilder html=new StringBuilder();

html.append(" ");

html.append(" ");

html.append("

随机数为:"

+new Random().nextInt()+"

");

html.append(" ");

html.append("");

return html.toString();

}

这段代码存在对随机数Random的强依赖,要替换时需要碰触到业务逻辑对应的代码,要做单元测试时也发现因为随机数的原因不可测。这种场景下要应用定义从细节中分离的模式。使用隔离方法把Random的定义从业务逻辑中分离出来并放到隔离方法中,然后在隔离方法中调用原来的buildHtml方法,并把原来Random的依赖改为对一个整数的依赖放到方法的参数中,把隔离方法命名为buildHtml()以保持方法签名,进而不影响对该方法调用的地方。修改后的代码如下:

//数据隔离方法

public String buildHtml() {

return buildHtml(new Random().nextInt());

}

public String buildHtml(Integer nextInt) {

StringBuilder html=new StringBuilder();

html.append(" ");

html.append(" ");

html.append("

随机数为:"+nextInt+"

");

html.append(" ");

html.append("");

return html.toString();

}

现在,原来的buildHtml方法里面只对基本类型数据存在依赖,解除了对随机数的依赖,以后随机数类库的改动将不再影响到业务逻辑,同时现在的方法非常方便进行单元测试,只需要传入不同的整数即可完成单元测试。原来的依赖被隔离到隔离方法中,没有任何逻辑,后续的改动也方便在隔离方法中进行。

虽然Spring[18]的使用给对象管理带来了很大的便捷,但是在单个方法中难免还是存在创建局部变量的情况而引入强依赖。隔离方法配合Spring使用可以全方位地解决对象定义的依赖问题。另一方面,因为遗留代码等原因还存在大量的项目没有使用Spring,这时候为了把依赖隔离开来,隔离方法的使用就显得尤为重要了。

3.2 外部从内部中分离

我们先来区分一下内部代码和外部代码。开发者自己编写的代码为内部代码,编码过程中使用的JDK的类也是内部代码,其他需要的类库为外部代码。如果使用JDK的类库完成的代码,可维护性和可测试性都会相对较高,一旦引入外部代码,维护和测试的难度就会呈指数级上升,外部代码越多越难维护和测试。如果我们能够把外部代码从内部代码中分离出来,剩下的就是业务逻辑和内部代码,这样就解决了上述存在的难题。例如下面这段代码:

public void buildHtml(HttpServletRequest request,

HttpServletResponse response) {

PrintWriter out=response.getWriter();

try {

String requestURI=request.getRequestURI();

StringBuilder html=new StringBuilder();

html.append(" ");

html.append(" ");

html.append("

请求URI:"

+requestURI+"

");

html.append(" ");

html.append("");

out.println(html.toString());

} finally {

out.close();

}

}

其中,外部依赖的类库为Web编程常见的两个类HttpServletRequest和HttpServletResponse。现在如果要进行单元测试的话,因为内部和外部代码混合在一起,需要模拟request和response的行为,这就大幅提高了实现测试的难度。这类场景下要使用的模式为外部从内部分离模式。将request和response及其对应的方法调用提取到隔离方法中,在隔离方法里取得数据后再传给原来的方法,让原来的方法只包含业务逻辑和对基本类型数据的依赖。修改后的代码如下:

//数据隔离方法

public void buildHtml(HttpServletRequest request,

HttpServletResponse response) {

PrintWriter out=response.getWriter();

try {

String requestURI=request.getRequestURI();

out.println(buildHtml(requestURI));

} finally {

out.close();

}

}

protected String buildHtml(String requestURI) {

StringBuilder html=new StringBuilder();

html.append(" ");

html.append(" ");

html.append("

请求URI:"

+requestURI+"

");

html.append(" ");

html.append("")

return html.toString();

}

修改之后,原来的方法只依赖字符串类型变量,只包含业务逻辑和内部代码,所有外部代码都被分离到隔离方法中,隔离方法中只包含获取基本类型数据和调用原来的方法这两部分,没有任何业务逻辑。现在对业务逻辑的测试就变得异常简单,外部代码的改动也不会影响到业务逻辑。

3.3 步骤从细节中分离

梳理业务逻辑时,比较有效的方式是画出这个逻辑的流程图,图上的每一个步骤都会对应着一段业务逻辑。通过这个流程图,我们可以更容易地理解这个逻辑,后续其他人也可以通过流程图更快速地掌握该业务逻辑。所以我们会把步骤和每一步的细节分离开来提高我们理解逻辑的效率。再回到代码来看,开发者写代码时,一般都是把步骤和细节放在了一起,或者步骤是通过代码细节体现出来的,只有阅读完代码,才会理解该段代码的步骤。如果想要快速了解代码的逻辑,需要额外画出流程图或者时序图。如果写代码的时候把步骤从细节中分离出来,就可以通过看步骤来快速了解逻辑,这里的步骤就像流程图一样,当需要了解一个步骤的细节时,再深入到这一步骤的代码来看。例如下面这段代码:

public void storeOrderedFibonacciNumbers(

List numberList) throws IOException {

Collections.sort(numberList);

for (int i=2;i

numberList.set(i,numberList.get(i-1)+

numberList.get(i-2));

}

FileOutputStream storeFile=null;

try {

storeFile=

new FileOutputStream("resources/numbers.txt");

StringBuilder txtbuilder=new StringBuilder();

for(Integer number:numberList) {

txtbuilder.append(number.toString()+" ");

}

storeFile.write(txtbuilder.toString().getBytes());

storeFile.write(new Date().toString().getBytes());

} finally {

if (storeFile !=null) storeFile.close();

}

}

通过阅读代码可以看出,一共有三个步骤:排序、形成斐波那契形式的列表、存储到文件中。代码中没有显式的步骤,要通过阅读代码来梳理这些步骤。这只是一个简单的例子,现实中有很多逻辑比这个复杂得多,要仔细阅读很久才能理解代码的步骤,所以为了便于理解,这里没有选取复杂的例子。这里使用步骤从代码中分离的模式来对其进行修改,把梳理出的每一步放入一个单独的方法中,方法名作为步骤名,这样多个方法连起来就是逻辑的步骤了。修改后的代码如下:

//粗细隔离方法

public void storeOrderedFibonacciNumbers(

List list) throws IOException {

sort(list);

formFibonacciList(list);

storeToFile(list);

}

protected void sort(List list) {

Collections.sort(list);

}

protected void formFibonacciList(List list) {

for (int i=2;i

list.set(i,list.get(i-1)+list.get(i-2));

}

}

protected void storeToFile(List list)

throws FileNotFoundException, IOException {

FileOutputStream storeFile=null;

try {

storeFile=

new FileOutputStream("resources/numbers.txt");

StringBuilder txtbuilder=new StringBuilder();

for(Integer number:list) {

txtbuilder.append(number.toString()+" ");

}

storeFile.write(txtbuilder.toString().getBytes());

storeFile.write(new Date().toString().getBytes());

} finally {

if (storeFile !=null) storeFile.close();

}

}

修改之后,在原来的方法里可以清晰地看到逻辑的步骤,可以快速理解逻辑的内容,如果需要进一步详细地理解每一步的细节,再仔细阅读每一步对应的方法即可。这样在代码中,步骤和细节就分离开来,步骤就像流程图,每一步对应的方法就是具体的逻辑细节。后续改动时可以定位到更小的范围进行修改,降低了引入误操作造成问题的概率,同时每个步骤可以单独进行测试,也使得单元测试变得更加容易。

3.4 实现从过程中分离

实现了步骤从细节中分离之后,已经为代码的阅读、维护和测试带来了很大的便捷。不过,这时如果想要替换其中的一个或多个步骤,还是要改动很多代码。为了让代码更符合开闭原则[15],我们需要对代码进行进一步调整,以能够让我们方便地替换其中的步骤。现在,虽然从代码结构上每一个步骤和具体实现已经分离开来,但是它们还处于同一个类和文件之中,这样就为满足开闭原则带来了难处。

接下来我们要做的是把具体的实现从现有的类和文件中独立出来,形成可以变换的单独的类。也就是实现从过程中分离的模式。使用面向接口编程的思想,为每一个步骤创建一个单独的接口,每一步的每一种实现方法就是具体的一种实现,将其放入单独的一个类文件中。这样如果未来想要修改一个步骤,我们只需要改动其专门对应的一个文件即可,避免了对其他代码带来影响的可能。同时,如果想要替换一种实现方法,只需要创建一个新的类文件来实现对应的接口即可,对原有的逻辑除了替换新的实现之外没有任何改动。另外,这样单元测试也变成测试独立的类,使得单元测试更方便和整洁。使用该模式修改之后的代码如下:

//增强的粗细隔离方法

public void storeOrderedFibonacciNumbers(

List list) throws IOException {

NumberSorter sorter=new DefaultSorter();

FibonacciListBuilder fibonacciBuilder=

new DefaultFibonacciBuilder();

NumberStorage storage=new FileStorage();

storeOrderedFibonacciNumbers(list,sorter,

fibonacciBuilder,storage);

}

protected void storeOrderedFibonacciNumbers(

List list,

NumberSorter sorter,

FibonacciListBuilder builder,

NumberStorage storage) throws IOException {

sorter.sort(list);

builder.build(list);

storage.store(list);

}

public interface NumberSorter {

List sort(List list);

}

public class DefaultSorter implements NumberSorter {

@Override

public List sort(List list) {

Collections.sort(list);

return list;

}

}

public interface FibonacciListBuilder {

List build(List list);

}

public class DefaultFibonacciBuilder

implements FibonacciListBuilder {

@Override

public List build(List list) {

for (int i=2;i

list.set(i,list.get(i-1)+list.get(i-2));

}

return list;

}

}

public interface NumberStorage {

void store(List list) throws IOException;

}

public class FileStorage implements NumberStorage {

@Override

public void store(List list)

throws IOException {

FileOutputStream storeFile=null;

try {

storeFile=

new FileOutputStream("resources/numbers.txt");

StringBuilder txtbuilder=new StringBuilder();

for(Integer number:list) {

txtbuilder.append(number.toString()+" ");

}

storeFile.write(txtbuilder.toString()

.getBytes());

storeFile.write(new Date().toString()

.getBytes());

} finally {

if (storeFile !=null) storeFile.close();

}

}

}

修改之后,原来的类里面不再有任何具体的细节实现,只包含三个步骤,具体的实现转移到不同的类文件中。这样就完成了步骤和细节的彻底分离。阅读代码的时候只需要阅读步骤即可,需要了解细节时到对应的实现类文件里查看。现在如果想要替换其中一个步骤的实现,比如存储步骤由文件存储改为数据库存储,只需要新写一个类来实现存储接口,完成数据库存储功能,然后在隔离方法里面把为存储创建的对象改为创建数据库存储对象即可。同样,测试时我们可以使用一样的方式来替换其中的一个或多个步骤,进而方便完成对任何一个的步骤的测试。这里还可以使用诸如Mockito[19]这样的框架来完成单元测试,这样就不用额外再单独为测试创建实现类。替换数据库存储对应的代码如下:

//隔离方法

public void storeOrderedFibonacciNumbers(

List list) throws IOException {

NumberSorter sorter=new DefaultSorter();

FibonacciListBuilder fibonacciBuilder=

new DefaultFibonacciBuilder();

NumberStorage storage=new DatabaseStorage();

storeOrderedFibonacciNumbers(list,sorter,

fibonacciBuilder,storage);

}

public class DatabaseStorage implements NumberStorage {

@Override

public void store(List list)

throws IOException {

//把数字列表存到数据库之中

//此处省略

}

}

3.5 线程从逻辑中分离

为了提升系统的性能和避免用户等待,多线程已被频繁地使用于软件开发中。在带来益处的同时,也给应用代码的维护和测试带来新的挑战。在应用程序的代码中,线程相关的代码和业务逻辑的代码耦合在一起,为代码的修改增加了难度,也使得引入问题的概率大幅提升。在这样的方式下,既没法单独修改线程相关的代码,也无法单独修改业务逻辑相关的代码。同样,单元测试时必须考虑多线程的情况,使得单元测试的难度提升很多。例如下面这段代码:

public void executeBusinessLogicUnderOtherThread(

final String parameter) {

ExecutorService singleThreadExecutor=

Executors.newSingleThreadExecutor();

singleThreadExecutor.execute(new Runnable() {

@Override

public void run() {

System.out.println("子线程Id: "+

Thread.currentThread().getId());

System.out.println("业务逻辑:"+parameter);

}

});

}

线程相关的代码和逻辑相关的代码紧密地耦合在一起,线程的代码改动和逻辑的代码改动修改的是同一个方法。同时,测试这个方法的时候,每一个单元测试都必须要处理多线程的情况。这样的情景下,使用线程从逻辑中分离的模式来解决此处存在的难题。修改后的代码如下:

//用于接收参数和执行业务逻辑的接口

public interface LogicExecutor {

void execute();

T getParameter();

}

//行为隔离方法

public void executeBusinessLogicUnderOtherThread(

final String parameter) {

final LogicExecutor executor=

new LogicExecutor() {

@Override

public String getParameter() {

return parameter;

}

@Override

public void execute() {

businessLogic(getParameter());

}

};

executeBusinessLogicUnderOtherThread(executor);

}

public void executeBusinessLogicUnderOtherThread(

final LogicExecutor executor) {

ExecutorService singleThreadExecutor=

Executors.newSingleThreadExecutor();

singleThreadExecutor.execute(new Runnable() {

@Override

public void run() {

executor.execute();

}

});

singleThreadExecutor.shutdown();

}

public void businessLogic(final String parameter) {

System.out.println("子线程Id: "+

Thread.currentThread().getId());

System.out.println("业务逻辑:"+parameter);

}

为了提高普适性,本文创建一个接口用于接收业务逻辑需要的参数和执行业务逻辑,把业务逻辑相关的代码提取到一个单独的方法中,然后在实现了上面定义接口的类中调用逻辑对应的方法。这样就把线程这个非业务逻辑的行为和业务逻辑对应的行为隔离开来,既可以单独修改,又可以单独进行测试,对于多线程的测试,只需要模拟一个行为保证多行程工作正常即可。对于业务逻辑的行为,大部分测试用例都可以在单线程模式下完成,然后再做一些线程和逻辑的集成测试即可。

4 结 语

现有的提高代码可维护性和可测试性的技术方案,其理解和运用的难度,造成了目前开发者在软件开发的过程中,依然在编写存在大量依赖的代码,进而在许多技术存在的基础上,代码的维护和测试仍然是开发者面临的一大难题。为了解决这一难题,本文提出代码分离模型,通过隔离方法和隔离层,辅助开发简单、方便地实现低耦合并且高度可测的代码。代码分离模式的引入,让开发者在大部分场景下都可以快速并高质量地完成代码分离模型的运用。通过代码分离模式中的实例代码证明,代码分离模型这一创新对于提高代码的维护性和测试性简单高效,易于使用,能够大幅提高开发者的编程和测试水平,进而可以显著解决目前计算机软件面临的变化多和响应快的困难和挑战。

猜你喜欢
单元测试调用开发者
基于Android Broadcast的短信安全监听系统的设计和实现
“85后”高学历男性成为APP开发新生主力军
16%游戏开发者看好VR
一年级上册第五单元测试
一年级上册一、二单元测试
利用RFC技术实现SAP系统接口通信
第五单元测试卷
第六单元测试卷
C++语言中函数参数传递方式剖析