作者 | 袁慎建
問題背景
數(shù)據(jù)分批器這個名字是我臨時起的一個名字,源于我輔導(dǎo)的客戶團(tuán)隊(duì)開發(fā)人員在當(dāng)時的核心系統(tǒng)中要解決的一個實(shí)際業(yè)務(wù)問題 —— Oracle的數(shù)據(jù)庫刪除每次只支持1000條。這個問題更確切的講是因?yàn)镺racle對下面這句SQL語句的支持約束:
問題就出在這個where id in ...上,后面?zhèn)魅氲募蠀?shù)ids最大支持1000條。而實(shí)際業(yè)務(wù)場景中存在大于1000條數(shù)據(jù),所以需要進(jìn)行分批處理。
針對這個問題,我暫時不去探究這個SQL機(jī)制本身的合理性[1]。本文我想借這個機(jī)會聊聊如何運(yùn)用TDD的方式去完成這個數(shù)據(jù)分批處理的設(shè)計(jì)和開發(fā)。
需求分解
基于這樣的問題,我的習(xí)慣是先對問題進(jìn)行分解,也就是TDD前要做的一個關(guān)鍵動作 —— Tasking。我快速做了個Tasking:
(1)非1000的整數(shù)倍,小于1000條
(2)1000的整數(shù)倍
(3)非1000的整數(shù)倍,大于1000條
基于Tasking結(jié)果,對上述需求場景進(jìn)行實(shí)例化,實(shí)例化過程中,邊界值是我要考慮的重點(diǎn):
(1)非1000的整數(shù)倍,小于1000條
- 0條
- 369條
(2)1000的整數(shù)倍
- 1000條
- 2000條
(3)非1000的整數(shù)倍,大于1000條
- 1369條
- 2222條
原有設(shè)計(jì)
上述代碼的for循環(huán)中做了兩件事,邊截取不同范圍的recordsId,邊調(diào)用了 CustomCustomerRiskScoreRepository 來做數(shù)據(jù)庫操作,分批邏輯和存儲夾雜在一起的過程式設(shè)計(jì)的好處是直接了當(dāng),當(dāng)然也讓 deleteByRecordIds() 的職責(zé)過多。
新的設(shè)計(jì)
基于我對軟件設(shè)計(jì)淺薄的理解,我認(rèn)為這個分批邏輯和Repository數(shù)據(jù)存儲邏輯分開會更優(yōu)雅,支持我的幾個主要理由是:
- 數(shù)據(jù)存儲邏輯更加純粹,它只用關(guān)心數(shù)據(jù)的CRUD。
- 重要:分批邏輯可以很方便地進(jìn)行自動化測試。
- 分批邏輯獨(dú)立出來,方便復(fù)用和維護(hù)。
基于以上的幾點(diǎn)理由,我在原來的過程式程序設(shè)計(jì)的基礎(chǔ)上引入一些OO的理念,進(jìn)行對象建模,比如抽象出一個數(shù)據(jù)分批器。我把對象建模過程看做在TDD之前的一些簡單且必要的設(shè)計(jì)。
TDD不太提倡在開始前不做任何設(shè)計(jì),恰恰它提倡做一些簡單且必要的程序接口設(shè)計(jì) —— 非教條主義
通過對象建模分析,我設(shè)計(jì)了兩個簡單的對象,一個是BatchDivider,另一個是Range,UML如下:
BatchDivider接收一個總數(shù),然后能夠返回一個包含了起止的范圍的集合,比如接收1369條,返回集合[Range(0, 1000), (1000, 1369)],起止信息我用Range對象來表示。
前期的設(shè)計(jì)就做到這,我沒有花太多時間去糾結(jié)細(xì)節(jié),因?yàn)楝F(xiàn)在的設(shè)計(jì)足夠讓我起步了。如果在后續(xù)發(fā)現(xiàn)了不完善的地方,交給后續(xù)的驅(qū)動和重構(gòu)。
編碼落地
至此,我已經(jīng)把需求進(jìn)行了分解和實(shí)例化,然后也做了簡單的程序設(shè)計(jì),跑步前的熱身完畢,接下來我就打算采用TDD的跑姿小跑起來。
之前的實(shí)例化我寫得比較簡單,由于我習(xí)慣用Given-When-Then的方式來描述,我把之前簡單的實(shí)例化再結(jié)合當(dāng)前的程序設(shè)計(jì)做了細(xì)化:
非1000的整數(shù)倍,小于1000條
(1)0條
- Given 待分批數(shù)據(jù)是0條
- When 分批處理
- Then 批次結(jié)果為空集合, []
(2)369條
- Given 待分批數(shù)據(jù)是369條
- When 分批處理
- Then 批次結(jié)果包含1個Range的集合,[(0, 369)]
1000的整數(shù)倍
(1)1000條
- Given 待分批數(shù)據(jù)是1000條
- When 分批處理
- Then 批次結(jié)果包含1個Range的集合,[(0, 1000)]
(2)2000條
- Given 待分批數(shù)據(jù)是2000條
- When 分批處理
- Then 批次結(jié)果包含2個Range的集合,[(0, 1000), (1000, 2000)]
非1000的整數(shù)倍,大于1000條
(1)1369條
- Given 待分批數(shù)據(jù)是1369條
- When 分批處理
- Then 批次結(jié)果包含2個Range的集合,[(0, 1000), (1000, 1369)]
(2)2222條
- Given 待分批數(shù)據(jù)是2222條
- When 分批處理
- Then 批次結(jié)果包含3個Range的集合,[(0, 1000), (1000, 2000), (2000, 2222)]
第1個測試
- Given 待分批數(shù)據(jù)是0條
- When 分批處理
- Then 批次結(jié)果為空集合, []
在編寫測試代碼時,有一種做法是從結(jié)果往前驅(qū)動,比如我會先寫assert,然后按照思維意圖一步一步往前倒逼我去命名變量,去給變量賦值。下面是我寫的第1個測試用例:
你現(xiàn)在看到的5 ~ 11行代碼,我的編寫順序是11 --> 5。這僅僅是我個人的習(xí)慣,因?yàn)槭艿揭越K為始和意圖導(dǎo)向編程觀念的影響,養(yǎng)成了這樣的習(xí)慣。Kent Beck在《測試驅(qū)動開發(fā)》也提到了這種方式。
按照反向驅(qū)動的方式寫出來的代碼,我發(fā)現(xiàn)測試代碼中的一些沒必要的臨時變量,但我先不著急去重構(gòu),我先聚焦讓這個測試通過,至少現(xiàn)在的代碼可讀性也非常好。很快,借助IDE的快捷鍵,我編寫了讓測試通過的實(shí)現(xiàn)代碼:
我使用了偽實(shí)現(xiàn)讓測試快速通過,緊接著借助Inline手法清除了測試代碼中沒必要的臨時變量totalItemSum和batchCount。
第2個測試
- Given 待分批數(shù)據(jù)是369條
- When 分批處理
- Then 批次結(jié)果包含1個Range的集合,[(0, 369)]
第二個測試起名為 given_item_count_below_1000 ,這里我沒有使用具體的實(shí)例化數(shù)據(jù)369,你或許會起名為 given_item_count_is_369。我個人習(xí)慣采用抽象場景來命名,因?yàn)槲矣X得能夠更直觀的提供業(yè)務(wù)場景的信息。
第二個測試,我直接給ranges添加了一個Range對象,并保留了上一個測試的判斷,快速地讓測試通過了:
第3個測試
- Given 待分批數(shù)據(jù)是1000條
- When 分批處理
- Then 批次結(jié)果包含1個Range的集合,[(0, 1000)]
這個測試運(yùn)行之后直接通過了,萬事大吉,運(yùn)氣還不錯。
第4個測試
- Given 待分批數(shù)據(jù)是2000條
- When 分批處理
- Then 批次結(jié)果包含2個Range的集合,[(0, 1000), (1000, 2000)]
為了通過這個測試,我編寫了如下的功能代碼,節(jié)省篇幅,我只摘取了核心的batchProcess方法:
很順利,我通過了第四個測試,我發(fā)現(xiàn)該方法中1000這個數(shù)字出現(xiàn)了好幾次,這是個魔法數(shù)字,而且重復(fù)出現(xiàn),在我看來這就是一只攜帶了兩種味道的惡魔,于是抽取一個常量ONE_BATCH_SIZE。
正當(dāng)我準(zhǔn)備寫下一個測試時,我運(yùn)行了之前的所有測試,發(fā)現(xiàn)第2個測試掛了 —— 我破壞了原來的功能。我回頭檢查了 batchProcess 方法的實(shí)現(xiàn),原來是 totalItemCount / ONE_BATCH_SIZE 這個運(yùn)算出了問題,當(dāng)totalItemCount = 369的時候,整除結(jié)果為0。
定位到問題,基于之前的開發(fā)經(jīng)驗(yàn),我嘗試了Math函數(shù)的幾個方法,最終找到了 Math.ceil(double) 方法:
?? 修復(fù)第2個測試之后,我心里給TDD點(diǎn)了個贊:TDD所構(gòu)建的測試安全網(wǎng)可以為重構(gòu)提供保護(hù),如果功能被破壞,測試很快就能發(fā)現(xiàn),這讓我去更有勇氣去重構(gòu)。
第5個測試
- Given 待分批數(shù)據(jù)是1369條
- When 分批處理
- Then 批次結(jié)果包含2個Range的集合,[(0, 1000), (1000, 1369)]
第6個測試
- Given 待分批數(shù)據(jù)是2222條
- When 分批處理
- Then 批次結(jié)果包含3個Range的集合,[(0, 1000), (1000, 2000), (2000, 2222)]
這個測試也直接通過了,到目前為止,所有的測試運(yùn)行結(jié)果都是綠色的。基于當(dāng)前的6個測試,我對當(dāng)前的程序非常有信心了,先前實(shí)例化的場景也都覆蓋了,所以我準(zhǔn)備停下來去倒杯水。
可規(guī)避的教條
教條:提前設(shè)計(jì)不可有
不是說TDD指Test-Driven Design,設(shè)計(jì)是由測試驅(qū)動出來的嗎,提前設(shè)計(jì)怎么交待呢?
TDD通常也會被解讀為:測試驅(qū)動設(shè)計(jì)。關(guān)于測試驅(qū)動設(shè)計(jì),我覺得一點(diǎn)點(diǎn)提前設(shè)計(jì)是有必要的,它給了我一個宏觀的方向,讓我能夠順利地開始。我的個人習(xí)慣是,在開始寫測試代碼前我會做一些簡單的紙面設(shè)計(jì),做一些簡單的對象建模,定義好一些對外的接口。在早期,我也不會寫紙面的設(shè)計(jì),而是直接開始寫測試代碼,實(shí)際上我在寫測試的時候腦海里是有設(shè)計(jì)的,只是我沒有顯性化出來。
一方面由于年齡增加,另一方面,面臨的問題很多時候沒這么簡單,顯性化出來可以減輕我大腦負(fù)載,并且還能帶來一些其他的好處。
另外,我發(fā)現(xiàn)Uncle Bob在做TDD的時候也會做一些簡單必要的提前設(shè)計(jì)。比如,他在演示TDD Kata 保齡球的時候就這么干過。
教條:新增測試要見紅
TDD循環(huán)圈紅-綠-重構(gòu),新增的測試必須要掛掉,上面第3、5、6,三個測試都直接綠了,你這TDD是在騙人的吧?
之前公司有Senior的同事就和我恨恨地討論過這點(diǎn),說實(shí)話我沒有什么理由去反駁這個觀點(diǎn),但我沒想明白的是為什么新增一個測試如果直接通過了就不是TDD了呢。測試直接通過了不是更好嘛?當(dāng)然前提是你的測試是對的。
后來我觀察了很多初學(xué)者在練習(xí)TDD時的做法,搞明白這種想法背后的擔(dān)心。我看有人寫了第一個測試,然后花很多時間去寫實(shí)現(xiàn),結(jié)果一股腦實(shí)現(xiàn)了很多場景的功能。講真,這樣做也未嘗不可,但從體會TDD的聚焦性和驅(qū)動性上來看,這個做法恐怕難以達(dá)到目的,這也是TDD門檻高的原因之一 —— 難以控制自己。但從實(shí)用性來看,一股腦寫完好幾個場景的功能實(shí)現(xiàn),然后補(bǔ)上后面幾個場景的測試,也并非不可。要知道咱們寫代碼的初衷是什么 —— 交付可用軟件,或美其名曰交付可用的高質(zhì)量軟件。
所以,為了在學(xué)習(xí)TDD時更好自控,就有了這樣的一個教條 —— 新增的測試一定得是掛的。但很多時候在編碼過程中,由于編程語言的便捷性,我們在快速、簡單地實(shí)現(xiàn)了某個邊界場景用例時難免會存在讓下一個測試直接通過的情況,比如我在寫第4個測試的時候,我直接使用了這個算法:
我沒有再花心思去想怎么搞個hard code參數(shù)是2000,再加一個if判斷。因?yàn)榛谇懊鎺讉€測試的知識和經(jīng)驗(yàn)積累,我已經(jīng)掌握了讓當(dāng)前測試通過的算法,而且不會比我hard code更花時間。要說快,我直接上這個會更快。寫完后我其實(shí)能預(yù)測到后面兩個測試會直接通過,但我還是會加上后面兩個測試,因?yàn)檫@不僅僅關(guān)乎TDD的姿勢,更關(guān)乎TDD本真的東西 —— 測試保護(hù)網(wǎng)。
另外,也可能是由于在拆分任務(wù)的時候太細(xì),使用了不同的數(shù)據(jù)來實(shí)例化了同一類場景,導(dǎo)致測試用例有交叉,又或者是拆分場景不合理產(chǎn)生了重復(fù),此時也是一個反饋調(diào)整任務(wù)列表的契機(jī)。
教條:通過測試唯快不破
TDD循環(huán)圈強(qiáng)調(diào)要以最快的速度、最簡單丑陋的方式讓測試快速通過,你這寫多了???
這個教條跟上一個是緊密相關(guān)的,在上一條后半部分也在解釋這個。有時候我明明寫一個功能完整的代碼比簡單、丑陋的代碼要更快,我就不會再去hard code,然后假裝慶祝自己是在做“真”TDD。Kent Beck在《測試驅(qū)動開發(fā)》一書的可運(yùn)行模式中提到,快速讓測試通過的三種方法:
- 偽實(shí)現(xiàn)
- 三角法
- 顯明實(shí)現(xiàn)
我理解這種觀點(diǎn)的支撐點(diǎn)在于對偽實(shí)現(xiàn)和三角法使用,它認(rèn)為設(shè)計(jì)應(yīng)該是通過大量實(shí)例化的測試用例驅(qū)動出來的。對于那些很復(fù)雜的業(yè)務(wù)場景,通過簡單的幾個用例確實(shí)沒法有效看清抽象模式,浮現(xiàn)不出良好的設(shè)計(jì),偽實(shí)現(xiàn)和三角法不失為一種好的驅(qū)動方式。但有時候,你對設(shè)計(jì)很清楚,實(shí)現(xiàn)很明顯,這個時候何不直接上呢?我在第2個測試的實(shí)現(xiàn)中采用了偽實(shí)現(xiàn),而在第4個中由于我破壞了之前的測試,我直接上了Math.ceil函數(shù),沒想到實(shí)現(xiàn)了最終的功能。
我提倡的“教條”
提倡:顯性化知識
這么簡單的業(yè)務(wù)需求,我腦袋瓜子完全夠用,有必要這么麻煩顯性地呈現(xiàn)出來嗎?
我認(rèn)為有必要寫出來,俗話說好記性不如爛筆頭。在整理呈現(xiàn)的過程中,我會投入更多的時間思考,思考有沒有準(zhǔn)確理解業(yè)務(wù)?思考如何能夠讓別人更容易理解?另外,它還可以作為一個顯性的計(jì)劃工具,幫助我評估我未來的工作。有了它,我跟團(tuán)隊(duì)成員溝通起來也更高效,并且在后續(xù)非單線程的工作模式中更容易聚焦重點(diǎn),更方便我去檢查任務(wù)進(jìn)展。在我看來,可視化的動作能讓Tasking發(fā)揮以下四個工具價(jià)值:
- 顯性的計(jì)劃工具
- 高效的溝通工具
- 便捷的檢查工具
- 進(jìn)化的思維工具
話說回來,寫出來并不會花太多時間吧,寫不出來就去做,有可能是因?yàn)檫€想不清楚,想不清楚的話就有危險(xiǎn)了。問題域越復(fù)雜,這里越值得花時間去想。
提倡:夯實(shí)基本功
總是拿玩具代碼來忽悠新人,在實(shí)際項(xiàng)目中遠(yuǎn)比這復(fù)雜的多,還是搞不定呀?
說到這個,我想起了我跟羽毛球的故事。我是2020年8月份的時候才正式接觸羽毛球,在這之前我平均一年打球不到5次,在這之后我最多的時候一周就超過了5次。以前,我無知地以為羽毛球沒什么復(fù)雜的,拿個拍子,場上跑跑,基本上人人都會,壓根用不著花錢找教練。后來場上數(shù)次被虐的“恥辱”讓我不得不去好好了解一下羽毛球。不學(xué)不知道,一學(xué)嚇一跳,簡單羅列一下:
- 身體基本功:體能、爆發(fā)力、耐力、反應(yīng)速度、彈跳
- 基礎(chǔ):發(fā)力、握拍、舉拍、步伐(6個方向,1~3步)
- 技巧:發(fā)球、接發(fā)球、吊球、勾對角、平抽、高遠(yuǎn)球、殺球、接殺球、封網(wǎng)、搓球
- 站位:男單、女單、男雙、混雙
上面列出來的還不齊全,并且基本功和技巧都涵蓋了正手和反手。羽毛球的技巧和戰(zhàn)術(shù)如此之多,每一個都夠練習(xí)一陣子,羽毛球的羽字:一分為二,學(xué)習(xí) + 練習(xí),這個字讓我感慨真不是隨意取的(為了幫助廣大球友快速成長,我整理了羽球知識庫,請?jiān)谖哪┇@取[2])。
我可以在這些技巧都不會或者都不嫻熟的情況下,跳過針對性的基本功練習(xí),直接上場叫囂要跟高手拉練,難免自取其辱還得不到有效提升。認(rèn)識到這點(diǎn)之后,唯有日練揮拍近千次來夯實(shí)基本功方可讓我有勇氣嘗試挑戰(zhàn)高手。
“練球不練功,打球一場空”。我認(rèn)為學(xué)習(xí)TDD也是一樣的道理,實(shí)際項(xiàng)目中確實(shí)業(yè)務(wù)和架構(gòu)復(fù)雜得多,但這不該成為你拒絕以簡單的案例入手去練習(xí)基本功的理由。再復(fù)雜的案例,元能力是類似的。
早在2018年底的,我給武漢某Offshore團(tuán)隊(duì)做了一場OOBootcamp,大家覺得ParkingLot不過癮,在訓(xùn)練營快結(jié)束的時候,我?guī)Т蠹姨接懥嗽谝粋€Java SpringBoot后端分層架構(gòu)中如何落地TDD。有了訓(xùn)練營的刻意練習(xí),團(tuán)隊(duì)很快在后端分層架構(gòu)中實(shí)踐落地了TDD。
所以,我個人認(rèn)為基本功是首要的,如果你還沒有嘗試過TDD,不妨先拋開懷疑,先去嘗試一下。
寫在最后
我接觸TDD有7年多時間了,至今未通透,仍在努力學(xué)習(xí)中。在實(shí)際項(xiàng)目落地TDD以及在TDD訓(xùn)練營中,我會堅(jiān)持一些原則,主要有以下三點(diǎn):
- Tasking優(yōu)先性
- 設(shè)計(jì)輕量性
- 知識明顯性
對業(yè)務(wù)需求進(jìn)行Tasking的過程能夠讓我重新梳理業(yè)務(wù),并且減少理解上的偏差和遺漏,避免在后續(xù)開發(fā)過程出現(xiàn)返工,造成更高的修改成本。即便你做不到測試先行,Tasking也值得去做好。
設(shè)計(jì)本身就是一種不可言說的知識,何為簡單且必要的設(shè)計(jì)?何為恰到好處的設(shè)計(jì)?不是看了幾本書、借用幾個大牛的三兩句話就能說清的,更多源自于日常實(shí)踐中的思考和總結(jié)。
針對Tasking的產(chǎn)出物,我會使用可視化和結(jié)構(gòu)化的方式顯性化管理起來,以便跟業(yè)務(wù)人員溝通確認(rèn),并更好地在團(tuán)隊(duì)內(nèi)共享。
三條中前兩條對個人底層勝任力提出了要求。Tasking做得質(zhì)量如何,比較依賴個人的分析性思維,設(shè)計(jì)做的好不好,考驗(yàn)的是一個人對設(shè)計(jì)的Sense。這些能力不會那么輕易通過短期的培訓(xùn)得到提升,但好在我們每個人都具備一些基礎(chǔ)。在這樣的基礎(chǔ)前提下,結(jié)合一些結(jié)構(gòu)化良好的顯性知識管理方式,堅(jiān)持跟時間賽跑吧。
補(bǔ)充說明
改進(jìn)
在這個數(shù)據(jù)分批器案例中,測試用例和設(shè)計(jì)上都有可以改進(jìn)的地方,比如:
- 測試用例使用jUnit 5的參數(shù)化測試,測試斷言的優(yōu)化;
- 消除「域冗余」:方法BatchDivider.batchProcess() --> BatchDivider.process();
- BatchDivider也可以做得更簡單,讓其成為一個靜態(tài)工廠方法,變身為工具類;
- BatchDivider還可以將分批處理的結(jié)果存起來,方便重復(fù)獲??;
- BatchDivider再或者后期需要支持動態(tài)分批,比如500一批,2000一批;
- 喜歡Java Stream API的小伙伴可以Stream來實(shí)現(xiàn)BatchDivider
- 等等其他未知改進(jìn);
改進(jìn)無止境,希望我想分享的點(diǎn)得到傳達(dá),更多的優(yōu)化留給文后有心的你。
手稿
過程我大概花了45分鐘的時間,從需求分解、前期設(shè)計(jì)、代碼庫的搭建和功能實(shí)現(xiàn),為了節(jié)省時間,我原先用的是手稿,文中是我后來另做了文字化。下圖是留作紀(jì)念的手稿:
? ?
原文鏈接:??一個非教條式的TDD例子 (qq.com)??