?

通過代碼分離模型編寫易維護易測試的代碼

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 代碼分離模型

代碼易維護性之所以存在問題,關鍵的一點是代碼不相關的部分混合在一起,也稱為相互依賴?,F有關于提高可維護性的核心理念也是解開依賴。只要把不相關的部分分離開來,這樣每一部分就可以單獨進行修改和測試,代碼的可維護性就會大幅提升。為了解決這一問題,本文提出隔離方法如下。

定義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 定義從細節中分離

在編寫代碼的過程中,我們需要創建大量對象,這些對象的直接創建造成了對象之間的強依賴,而這樣的強依賴又帶來了維護和測試的難題??聪旅嬉欢未a。

public String buildHtml() {

StringBuilder html=new StringBuilder();

html.append(" ");

html.append(" ");

html.append("

隨機數為:"

+new Random().nextInt()+"

");

html.append(" 91香蕉高清国产线观看免费-97夜夜澡人人爽人人喊a-99久久久无码国产精品9-国产亚洲日韩欧美综合 ");

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?,F在如果要進行單元測試的話,因為內部和外部代碼混合在一起,需要模擬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();

}

修改之后,原來的方法只依賴字符串類型變量,只包含業務邏輯和內部代碼,所有外部代碼都被分離到隔離方法中,隔離方法中只包含獲取基本類型數據和調用原來的方法這兩部分,沒有任何業務邏輯?,F在對業務邏輯的測試就變得異常簡單,外部代碼的改動也不會影響到業務邏輯。

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],我們需要對代碼進行進一步調整,以能夠讓我們方便地替換其中的步驟?,F在,雖然從代碼結構上每一個步驟和具體實現已經分離開來,但是它們還處于同一個類和文件之中,這樣就為滿足開閉原則帶來了難處。

接下來我們要做的是把具體的實現從現有的類和文件中獨立出來,形成可以變換的單獨的類。也就是實現從過程中分離的模式。使用面向接口編程的思想,為每一個步驟創建一個單獨的接口,每一步的每一種實現方法就是具體的一種實現,將其放入單獨的一個類文件中。這樣如果未來想要修改一個步驟,我們只需要改動其專門對應的一個文件即可,避免了對其他代碼帶來影響的可能。同時,如果想要替換一種實現方法,只需要創建一個新的類文件來實現對應的接口即可,對原有的邏輯除了替換新的實現之外沒有任何改動。另外,這樣單元測試也變成測試獨立的類,使得單元測試更方便和整潔。使用該模式修改之后的代碼如下:

//增強的粗細隔離方法

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();

}

}

}

修改之后,原來的類里面不再有任何具體的細節實現,只包含三個步驟,具體的實現轉移到不同的類文件中。這樣就完成了步驟和細節的徹底分離。閱讀代碼的時候只需要閱讀步驟即可,需要了解細節時到對應的實現類文件里查看?,F在如果想要替換其中一個步驟的實現,比如存儲步驟由文件存儲改為數據庫存儲,只需要新寫一個類來實現存儲接口,完成數據庫存儲功能,然后在隔離方法里面把為存儲創建的對象改為創建數據庫存儲對象即可。同樣,測試時我們可以使用一樣的方式來替換其中的一個或多個步驟,進而方便完成對任何一個的步驟的測試。這里還可以使用諸如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++語言中函數參數傳遞方式剖析