前言
重構代碼時,我們常常糾結於這樣的問題:
- 需要進一步抽象嗎?會不會導致過度設計?
- 如果需要進一步抽象的話,如何進行抽象呢?有什麼通用的步驟或者法則嗎?
單元測試是我們常用的驗證代碼正確性的工具,但是如果只用來驗證正確性的話,那就是真是 “大炮打蚊子”--大材小用,它還可以幫助我們評判代碼的抽象程度與設計水平。本文還會提出一個以“可測試性”為目標,不斷迭代重構代碼的思路,利用這個思路,面對任何複雜的代碼,都能逐步推導出重構思路。
為了保證直觀,本文會以一個 『生產者消費者』 的代碼重構示例貫穿始終。
示例
重構 & 單測
在開始『生產者消費者』 的代碼重構示例前,先聊一聊重構。
程序員們重構一段代碼的動機是什麼?可能眾説紛紜:
- 代碼不夠簡潔?
- 不好維護?
- 不符合個人習慣?
- 過度設計,不好理解?
概括來説,就是降低代碼和架構的腐化速度,降低維護和升級的成本。在我看來,保證軟件質量和複雜度的唯一手段就是『持續重構』。
這裏又引出一個問題,什麼樣代碼/架構是好維護的?業界針對此問題有很多設計準則,比如開閉原則、單一職責原則、依賴倒置原則等等。但是今天想從另外一個角度來説,是不是可測性也可以做為衡量代碼好壞的標準。一般而言,可測試的代碼一般都是同時是簡潔和可維護的,但是簡潔可維護的代碼卻不一定是可測試的。
所以,從這個角度來説説重構是為了增強代碼的可測性,即面向單測重構。另一方面,一段沒有單測的代碼,你敢重構麼?這麼看來,單測和重構的關係密不可分。
接下來我們看一個簡單的例子,體會面向單測的重構。
public void producerConsumer() {
BlockingQueue<Integer> blockingQueue = new LinkedBlockingQueue<>();
Thread producerThread = new Thread(() -> {
for (int i = 0; i < 10; i++) {
blockingQueue.add(i + ThreadLocalRandom.current().nextInt(100));
}
});
Thread consumerThread = new Thread(() -> {
try {
while (true) {
Integer result = blockingQueue.take();
System.out.println(result);
}
} catch (InterruptedException ignore) {
}
});
producerThread.start();
consumerThread.start();
}
上面這段代碼主要可以劃分為3部分:
- 生產者:往阻塞隊列裏添加10個數據,具體邏輯:將 0-9 的每一個數字,分別加上 [0,100) 的隨機數
- 消費者:從阻塞隊列中獲取數字,並打印
- 主線程:啓動兩個線程,分別是生產者和消費者
這段代碼看上去還是挺簡潔的,但是,算得上一段好代碼嗎?嘗試下給這段代碼加上單元測試。僅僅運行一下這個代碼肯定是不夠的,因為我們無法確認生產消費邏輯是否正確執行。我也只能發出“完全不知道如何下手”的感嘆,這不是因為我們的單元測試編寫技巧不夠,而是因為代碼本身存在的問題:
- 違背單一職責原則:這一個函數同時做了 數據傳遞,處理數據,啓動線程三件事情。單元測試要兼顧這三個功能,就會很難寫。
-
這個代碼本身是不可重複的,不利於單元測試,不可重複體現在
- 需要測試的邏輯位於異步線程中,對於它什麼時候執行?什麼時候執行完?都是不可控的
- 邏輯中含有隨機數
- 消費者直接將數據輸出到標準輸出中,在不同環境中無法確定這裏的行為是什麼,有可能是輸出到了屏幕上,也可能是被重定向到了文件中
説到這裏,我們先停一停,討論一個點,『可測試意味着什麼』?因為前面説到,重構的目的是讓代碼可測試,這裏有必要重點討論下這個概念。
可測試意味着什麼?
首先我們要了解可測試意味着什麼,如果説一段代碼是可測試的,那麼它一定符合下面的條件:
- 可以在本地設計完備的測試用例,稱之為完全覆蓋的單元測試;
- 只要完全覆蓋的單元測試用例全部正確運行,那麼這一段邏輯肯定是沒有問題的;
再進一步,如果一個函數的返回值只和參數有關,只要參數確定,返回值就是唯一確定的,那麼這樣的函數一定能被完全覆蓋。這個好的特性叫引用透明。
但是現實中的代碼大多都不會有這麼好的性質,反而具有很多“壞的性質”,這些壞的性質也常被稱為副作用:
- 代碼中含有遠程調用,無法確定這次調用是否會成功;
- 含有隨機數生成邏輯,導致行為不確定;
- 執行結果和當前日期有關,比如只有工作日的早上,鬧鐘才會響起;
好在我們可以用一些技巧將這些副作用從核心邏輯中抽離出來。
“引用透明” 要求函數的出參由入參唯一確定,之前的例子容易讓人產生誤解,覺得出參和入參一定要是數據,讓我們把視野再打開一點,出入參可以是一個函數,它也可以是引用透明的。
普通的函數又可以稱作一階函數,而接收函數作為參數,或者返回一個函數的函數稱為高階函數,高階函數也可以是引用透明的。
對於高階函數 f(g) (g 是一個函數)來説,只要對於特定的函數 g,返回邏輯也是固定,它就是引用透明的了, 而不用在乎參數 g 或者返回的函數是否有副作用。利用這個特性,我們很容易將一個有副作用的函數轉換為一個引用透明的高階函數。
一個典型的擁有副作用的函數如下:
public int f() {
return ThreadLocalRandom.current().nextInt(100) + 1;
}
它生成了隨機數並且加 1,因為這個隨機數,導致它不可測試。但是我們將它轉換為一個可測試的高階函數,只要將隨機數生成邏輯作為一個參數傳入,並且返回一個函數即可:
public int g(Supplier<Integer> integerSupplier) {
return integerSupplier.get() + 1;
}
上面的 g 就是一個引用透明的函數,只要給 g 傳遞一個數字生成器,返回值一定是一個 “用數字生成器生成一個數字並且加1” 的邏輯,並且不存在分支條件和邊界情況,只需要一個用例即可覆蓋:
public void testG() {
Supplier<Integer> result = g(() -> 1);
assert result.get() == 2;
}
這裏我雖然使用了 Lambda 表達式簡化代碼,但是 “函數” 並不僅僅是指 Lambda 表達式,OOP 中的充血模型的對象,接口等等,只要其中含有邏輯,它們的傳遞和返回都可以看作 “函數”。
第一輪重構
我們本章回到開頭的生產者消費者的例子,用上一章學習到的知識對它進行重構。
那段代碼無法測試的第一個問題就是職責不清晰,它既做數據傳遞,又做數據處理。因此我們考慮將生產者消費者數據傳遞的代碼單獨抽取出來:
public <T> void producerConsumerInner(Consumer<Consumer<T>> producer,
Consumer<Supplier<T>> consumer) {
BlockingQueue<T> blockingQueue = new LinkedBlockingQueue<>();
new Thread(() -> producer.accept(blockingQueue::add)).start();
new Thread(() -> consumer.accept(() -> {
try {
return blockingQueue.take();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
})).start();
}
這一段代碼的職責就很清晰了,我們給這個方法編寫單元測試的目標也十分明確,即驗證數據能夠正確地從生產者傳遞到消費者。但是很快我們又遇到了之前提到的第二個問題,即異步線程不可控,會導致單測執行的不穩定,用上一章的方法,我們將執行器作為一個入參抽離出去:
public <T> void producerConsumerInner(Executor executor,
Consumer<Consumer<T>> producer,
Consumer<Supplier<T>> consumer) {
BlockingQueue<T> blockingQueue = new LinkedBlockingQueue<>();
executor.execute(() -> producer.accept(blockingQueue::add));
executor.execute(() -> consumer.accept(() -> {
try {
return blockingQueue.take();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}));
}
這時我們就為它寫一個穩定的單元測試了:
private void testProducerConsumerInner() {
producerConsumerInner(Runnable::run,
(Consumer<Consumer<Integer>>) producer -> {
producer.accept(1);
producer.accept(2);
},
consumer -> {
assert consumer.get() == 1;
assert consumer.get() == 2;
});
}
只要這個測試能夠通過,就能説明生產消費在邏輯上是沒有問題的。一個看起來比之前的分段函數複雜很多的邏輯,本質上卻只是它定義域上的一個恆等函數(因為只要一個用例就能覆蓋全部情況),是不是很驚訝。
如果不太喜歡上述的函數式編程風格,可以很容易地將其改造成 OOP 風格的抽象類
public abstract class ProducerConsumer<T> {
private final Executor executor;
private final BlockingQueue<T> blockingQueue;
public ProducerConsumer(Executor executor) {
this.executor = executor;
this.blockingQueue = new LinkedBlockingQueue<>();
}
public void start() {
executor.execute(this::produce);
executor.execute(this::consume);
}
abstract void produce();
abstract void consume();
protected void produceInner(T item) {
blockingQueue.add(item);
}
protected T consumeInner() {
try {
return blockingQueue.take();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
此時單元測試就會像是這個樣子:
private void testProducerConsumerAbCls() {
new ProducerConsumer<Integer>(Runnable::run) {
@Override
void produce() {
produceInner(1);
produceInner(2);
}
@Override
void consume() {
assert consumeInner() == 1;
assert consumeInner() == 2;
}
}.start();
}
主函數
public void producerConsumer() {
new ProducerConsumer<Integer>(Executors.newFixedThreadPool(2)) {
@Override
void produce() {
for (int i = 0; i < 10; i++) {
produceInner(i + ThreadLocalRandom.current().nextInt(100));
}
}
@Override
void consume() {
while (true) {
Integer result = consumeInner();
System.out.println(result);
}
}
}.start();
}
第二輪重構
在第一輪重構中,我們僅僅保障了數據傳遞邏輯是正確的,在第二輪重構中,我們還將進一步擴大可測試的範圍。
代碼中影響我們進一步擴大測試範圍因素還有兩個:
- 隨機數生成邏輯
- 打印邏輯
只要將這兩個邏輯像之前一樣抽出來即可:
public class NumberProducerConsumer extends ProducerConsumer<Integer> {
private final Supplier<Integer> numberGenerator;
private final Consumer<Integer> numberConsumer;
public NumberProducerConsumer(Executor executor,
Supplier<Integer> numberGenerator,
Consumer<Integer> numberConsumer) {
super(executor);
this.numberGenerator = numberGenerator;
this.numberConsumer = numberConsumer;
}
@Override
void produce() {
for (int i = 0; i < 10; i++) {
produceInner(i + numberGenerator.get());
}
}
@Override
void consume() {
while (true) {
Integer result = consumeInner();
numberConsumer.accept(result);
}
}
}
這次採用 OOP 和 函數式 混編的風格,也可以考慮將 numberGenerator 和 numberConsumer 兩個方法參數改成抽象方法,這樣就是更加純粹的 OOP。
它也只需要一個測試用例即可實現完全覆蓋:
private void testProducerConsumerInner2() {
AtomicInteger expectI = new AtomicInteger();
producerConsumerInner2(Runnable::run, () -> 0, i -> {
assert i == expectI.getAndIncrement();
});
assert expectI.get() == 10;
}
此時主函數變成:
public void producerConsumer() {
new NumberProducerConsumer(Executors.newFixedThreadPool(2),
() -> ThreadLocalRandom.current().nextInt(100),
System.out::println).start();
}
經過兩輪重構,我們將一個很隨意的麪條代碼重構成了很優雅的結構,除了更加可測試外,代碼也更加簡潔抽象,可複用,這些都是面向單測重構所帶來的附加好處。
你可能會注意到,即使經過了兩輪重構,我們依舊不會直接對主函數 producerConsumer 進行測試,而只是無限接近覆蓋裏面的全部邏輯,因為我認為它不在“測試的邊界”內,我更傾向於用集成測試去測試它,集成測試則不在本篇文章討論的範圍內。下一章則重點討論測試邊界的問題。
單元測試的邊界
邊界內的代碼都是單元測試可以有效覆蓋到的代碼,而邊界外的代碼則是沒有單元測試保障的。
上一章所描述的重構過程本質上就是一個在探索中不斷擴大測試邊界的過程。但是單元測試的邊界是不可能無限擴大的,因為實際的工程中必然有大量的不可測試部分,比如 RPC 調用,發消息,根據當前時間做計算等等,它們必然得在某個地方傳入測試邊界,而這一部分就是不可測試的。
理想的測試邊界應該是這樣的,系統中所有核心複雜的邏輯全部包含在了邊界內部,然後邊界外都是不包含邏輯的,非常簡單的代碼,比如就是一行接口調用。這樣任何對於系統的改動都可以在單元測試中就得到快速且充分的驗證,集成測試時只需要簡單測試下即可,如果出現問題,一定是對外部接口的理解有誤,而不是系統內部改錯了。
清晰的單元測試邊界劃分有利於構建更加穩定的系統核心代碼,因為我們在推進測試邊界的過程中會不斷地將副作用從核心代碼中剝離出去,最終會得到一個完整且可測試的核心,就如同下圖的對比一樣:
好代碼從來都不是一蹴而就的,都是先寫一個大概,然後逐漸迭代和重構的,從這個角度來説,重構別人的代碼和寫新代碼沒有很大的區別。
從上面的內容中,我們可以總結出一個簡單的重構工作流:
按照這個方法,就能夠逐步迭代出一套優雅且可測試的代碼,即使因為時間問題沒有迭代到理想的測試邊界,也會擁有一套大部分可測試的代碼,後人可以在前人用例的基礎上,繼續擴大測試邊界。
過度設計
再談一談過度設計的問題。
按照本文的方法是不可能出現過度設計的問題,過度設計一般發生在為了設計而設計,生搬硬套設計模式的場合,但是本文的所有設計都有一個明確的目的--提升代碼的“可測試性”,所有的技巧都是在過程中無意使用的,不存在生硬的問題。
而且過度設計會導致“可測試性”變差,過度設計的代碼常常是把自己的核心邏輯都給抽象掉了,導致單元測試無處可測。如果發現一段代碼“寫得很簡潔,很抽象,但是就是不好寫單元測試”,那麼大概率是被過度設計了。
和TDD的區別
本文到這裏都還沒有提及到 TDD,但是上文中闡述的內容肯定讓不少讀者想到了這個名詞,TDD 是 “測試驅動開發” 的簡寫,它強調在代碼編寫之前先寫用例,包括三個步驟:
- 紅燈:寫用例,運行,無法通過用例
- 綠燈:用最快最髒的代碼讓測試通過
- 重構:將代碼重構得更加優雅
在開發過程中不斷地重複這三個步驟。
但是會實踐中會發現,在繁忙的業務開發中想要先寫測試用例是很困難的,可能會有以下原因:
- 代碼結構尚未完全確定,出入口尚未明確,即使提前寫了單元測試,後面大概率也要修改
- 產品一句話需求,外加對系統不夠熟悉,用例很難在開發之前寫好
因此本文的工作流將順序做了一些調整,先寫代碼,然後再不斷地重構代碼適配單元測試,擴大系統的測試邊界。
不過從更廣義的 TDD 思想上來説,這篇文章的總體思路和 TDD 是差不多的,或者標題也可以改叫做 “TDD 實踐”。