個案分析: 可測性對設計的影響

fcamel
fcamel的程式開發心得
7 min readNov 14, 2019

《Test-Driven Development 解決了什麼問題?或是製造更多問題?》提到:

相較於 test-driven development,我們該在介面設計時投入更多心力在可測性(testability),一旦程式易於測試,事前事後加測試,並沒有差別。

這篇舉實例討論,究竟有無考慮可測性,有多少差別?

問題描述

實作有多個連線的 server 程式,需要依整體和各別連線的流量對各別連線限流,避免某些連線流入大量資料影響其它連線。要怎麼設計軟體結構呢?

為方便討論,先假設已有 ConnectionManager 管理所有 Connection 物件,每個連線在自己的 Goroutine 用 blocking read 讀取資料。有 ConnectionManager 的好處是,方便實現 CloseAllConnections() 或踢掉重覆連線 (依身份認證的 ID 或是 IP)。

由誰管理限流?

第一個想法是本來已經有 ConnectionManager 了,由 ConnectionManager 記錄流量似乎沒增加什麼負擔。由於每個 Connection 各自在自己的 goroutine 呼叫 Read(),ConnectionManger 呼叫 Read() 後得知讀入的 byte 數,更新到流量表裡,然後決定是否要 Sleep 一段時間。

另一個想法是引入新物件 Throttler,讓 Connection 共有一個 Throttler,作類似的事。

乍看之下後者多加一個物件,效果和前者差不多,這樣用 ConnectionManager 較好?

從寫測試碼的角度看呢?

為了驗證限流的功能,記錄在 ConnectionManager 的作法需要準備 ConnectionManager 和數個 Connection 物件,由 Connection.Read() 回傳測試指定的資料。真的建連線寫資料又慢又麻煩。較好的作法是定義 Connection 介面,由 ConnectionImpl 實作。在測試時改用 ConnectionFake 方便填入測資。

另一方面,用 Throttler 實作時,寫個 Connection 的 decorator ThrottlingConnection,ConnectionManager 改成使用 ThrottlingConnection。ThrottlingConnetion 全部操作都呼叫 ConnectionImpl 的操作,只有在 Read() 時將記錄寫入 Throttler:

func (tc *ThrottlingConnection) Read(b []byte) (int, error) {
n, err := tc.conn.Read()
if err != nil {
return 0, err
}
tc.throttler.AddRecord(tc.conn.GetId(), n)
return n, nil
}

然後 Throttler.AddRecord() 發現超額就 Sleep 一段時間再返回。

測試時只需準備 Throttler,直接傳測資給 Throttler,不需設定 ConnectionFake 再由 ConnectionFake 傳給 Throttler。由此來看,比 ConnectoinManager 限流的作法簡單。

如何加快測試速度?

使用系統的 time.Sleep() 會拖慢測試時間。引入 Clock 介面避免此問題:

type Clock interface {
Now() time.Time
Sleep(d time.Duration)
}

生成 Throttler 時傳入 Clock (即 dependency injection),然後在測試時使用 ClockFake,這樣可以由測試碼透過 ClockFake 控制現在時間和確認 Throttler 有沒有使用 Sleep() 限流。

示意測試碼

實作 clockFake 協助測試:

type clockFake struct {
t time.Time
sleepingDuration time.Duration
}
func (c *clockFake) Now() time.Time { return c.t}func (c *clockFake) Sleep(d time.Duration) {
if d <= 0 {
return
}
c.sleepingDuration += d
c.t = c.t.Add(d)
}
func (c *clockFake) add(d time.Duration) {
c.t = c.t.Add(d)
}

模擬小流量不會被限流:

clk := &clockFake{}  
th := newThrottler(clk, 1e5) // 100 KB/s
for i := 0; i < 10; i++ {
th.pauseReadIfNeeded(id, 1000)
}
req.Equal(time.Duration(0), clk.sleepingDuration)

模擬剛好在流量上限不會被限流:

clk := &clockFake{}  
th := newThrottler(clk, 1e5) // 100 KB/s
for i := 0; i < 10; i++ {
clk.add(time.Second)
th.pauseReadIfNeeded(id, 1e5)
}
req.Equal(time.Duration(0), clk.sleepingDuration)

模擬被限流:

clk := &clockFake{}  
th := newThrottler(clk, 1e5) // 100 KB/s
for i := 0; i < 10; i++ {
th.pauseReadIfNeeded(id, 1e6) // Take ~10s
}

expected := 100 * time.Second
epsilon := expected / 10
req.True(expected - epsilon <= clk.sleepingDuration)
req.True(clk.sleepingDuration <= expected + epsilon)

有 fake clock 和 throttler 就能測,不需多作其它準備,方便模擬不同情境。可以想看看如果採用 ConnctionManager 限流的設計,測試碼會複雜多少?

額外討論

在思考可測性時理解 ConectoinManager 和 Throttler 兩者有不小的差異,讓我回頭思考如何更直覺地判斷要用 X 管全部物件,還是全部物件共用 X。發覺主要差異是兩者觸發事件的源頭不同。

上層物件觸發「停止服務」事件,所以由上層物件呼叫 ConnectionManager.CloseAllConnection() 完成這個需求,此時由 ConnectionManager 執行很合理。

限流是由「各別連線讀取資料」觸發,所以在 Connection 內部處理比較合理。由於需要共享整體流量變化,所以需要引入共通物件 Throttler。

結語

若沒有先考慮可測性就選了 ConnectionManager 限流的作法,之後補測試會比較辛苦。測試也是維護成本,日後需要改變限流的規則時,可能需要準備更複雜的測試,測試太複雜時又要重構測試。有了更多測試,愈來愈難擺脫在 ConnectionManager 內實作限流。

反之,一開始考慮可測性採用 Throttler,測試維護成本很低,日後改規則的成本也低。

--

--