版本 | 日期 | 備注 |
---|---|---|
1.0 | 2020.9.13 | 文章首發 |
1.1 | 2020.11.8 | 優化對于第一個方案的措辭 |
1.2 | 2021.1.17 | 優化小結部分 |
1.3 | 2021.2.3 | 修改標題從利用Clean Architecture寫好白盒測試 -> 遵循Clean Architecture寫好白盒測試
|
1.4 | 2021.5.21 | 修改標題從遵循Clean Architecture寫好白盒測試 -> 技巧:遵循Clean Architecture寫好白盒測試
|
前言
Clean Architecture
是Bob大叔在2012年提出的一個架構模型。其根據過去幾十年中的一系列架構提煉而成:
- Hexagonal Architecture:由 Alistair Cockburn 首先提出
- DCI:由 James Coplien 和Trygve Reenskaug 首先提出
- BCE:由 Ivar Jacobson 在他的 Obect Oriented Software Engineering: A Use-Case Driven Approach 一書中首先提出
根據這些架構設計出來的系統,往往具有以下特點:
- 獨立于框架:這些系統的架構并不依賴某個功能豐富的框架之中的某個函數??蚣芸梢员划敵晒ぞ邅硎褂?,但不需要讓系統來適應框架 。
- 可被測試這些系統的業務邏輯可以脫離 UI、 數據庫、Web 服務以及其他的外部元素來進行測試 。
- 獨立于UI:這些系統的UI變更起來很容易,不需要修改其他的系統部分。例如,我們可以在不修改業務邏輯的前提下將一個系統的UI由Web 界面替換成命令行界面 。
- 獨立于數據庫:我們可以輕易將這些系統使用的Oracle、SQL Server 替換成 Mongo、BigTable、CouchDB 之類的數據庫。因為業務邏輯與數據庫之間已經完成了解耦 。獨立于任何外部機構:這些系統的業務邏輯并不需要知道任何其他外部接口的存在 。
關于Clean Architecture
的介紹到此為止,有興趣的同學可以自行查閱google。
背景
最近寫了很多業務代碼,因為每個組件都是分布式部署的,導致手動測試時非常的痛苦,耗時耗力。于是筆者開始思考針對業務的自動化測試方案。
目前業務中一部分的代碼使用了Storm
這個框架,我們挑一個方便理解的用例,這里大概涉及三個組件:
- ReadSpout:從kafka、database讀取消息,并將其下發
- DispatcherBolt:讀取上游下發的消息,并根據一定的規則分發——比如自定義字段,將字段相同的數據放在一起并下發
- KafkaWriteBolt:讀取上游下發的消息,將關鍵字一樣的數據寫入kafka同一個分區
DispatcherBolt的代碼大致如下:
@Override
public void prepare(Map conf, TopologyContext context, OutputCollector collector) {
super.prepare(conf, context, collector);
try {
init();
} catch (Exception e) {
collector.reportError(e);
throw new RuntimeException(e);
}
}
@Override
public void execute(Tuple dataTuple) {
this.input = dataTuple;
try {
Object obj = dataTuple.getValueByField(EmitFields.MESSAGE);
String key = (String) dataTuple.getValueByField(EmitFields.GROUP_FIELD);
List<MessageEntry> messageEntries = (List<MessageEntry>) obj;
emitMessageEntry(key, messageEntries);
this.collector.ack(dataTuple);
} catch (Exception e) {
logger.info("Dispatcher Execute error: ", e);
this.collector.reportError(e);
this.collector.fail(dataTuple);
}
}
private void emitMessageEntry(String key, List<MessageEntry> messageEntries) throws Exception {
long lastPos = 0L, uniquePos = 0L, payloadSize = 0L;
UmsMessageBuilder builder = null;
String tableName = messageEntries.get(0).getEntryHeader().getTableName();
for (MessageEntry msgEntry : messageEntries) {
EntryHeader header = msgEntry.getEntryHeader();
header.setLastPosition(lastPos);
if (StringUtils.isEmpty(tableName) || (getExtractorConfig().getGroupType() == GroupType.SCHEMA && !StringUtils.equalsIgnoreCase(tableName, header.getTableName()))) {
emitBuilderMessage(builder, key);
builder = createUmsDataBuilder(msgEntry, destination, msgEntry.getBatchId(),
MediaType.DataSourceType.getTypeByName(getExtractorConfig().getNodeType()));
payloadSize = 0;
}
// DDL handle
if (msgEntry.isDdl()) {
emitBuilderMessage(builder, key);
executeDdlEvent(msgEntry);
emitDDLMessage(key, msgEntry);
builder = null;
continue;
}
if (builder != null && msgEntry.getEntryHeader().getHeader().getSourceType().equalsIgnoreCase(MediaType.DataSourceType.ORACLE.getName())) {
emitBoltMessage(key, builder.getMessage());
builder = createUmsDataBuilder(msgEntry, destination, msgEntry.getBatchId(),
MediaType.DataSourceType.getTypeByName(getExtractorConfig().getNodeType()));
payloadSize = 0;
}
// DML handle
if (builder == null) {
builder = createUmsDataBuilder(msgEntry, destination, msgEntry.getBatchId(),
MediaType.DataSourceType.getTypeByName(getExtractorConfig().getNodeType()));
payloadSize = 0;
}
for (CanalEntry.RowData rowData : msgEntry.getRowDataLst()) {
lastPos = Long.parseLong(header.getPosition()) + (++uniquePos);
if (header.isUpdate()) {
if (getExtractorConfig().getOutputBeforeUpdateFlg()) {
payloadSize += appendUpdateBefore2Builder(builder, header, rowData, EventType.BEFORE.getValue().toLowerCase());
}
if (ExtractorHelper.isPkUpdate(rowData.getAfterColumnsList())) {
payloadSize += appendUpdateBefore2Builder(builder, header, rowData, getEventTypeForUMS(CanalEntry.EventType.DELETE));
}
}
List<Object> payloads = new ArrayList<>();
payloadSize += appendRowData2Builder(payloads, builder, header, rowData);
builder.appendPayload(payloads.toArray());
}
}
}
emitBuilderMessage(builder, key);
}
注意,這里的兩個方法prepare
和execute
都是框架暴露出來的接口,用于初始化時獲得strom的上下文以及strom下發的對象。如果開發者使用不當,則會導致業務代碼和框架耦合。
方案1:Object Dependency Inject
這個方案在早期的時候做過嘗試,簡單的來說就是將中間那段emitMessageEntry
相關的代碼抽象成一個接口的方法,并在實現代碼中填入現在的邏輯,并通過spring這種IOC框架注入進來,類似于:
override fun prepare(topoConf: MutableMap<String, Any>, context: TopologyContext, collector: OutputCollector) {
super.prepare(topoConf, context, collector)
try {
init()
this.dispatcherServer = IOCUtil.getBean(DispatcherServer::class.java).init(collector)
} catch (e: Exception) {
collector.reportError(e)
throw RuntimeException(e)
}
}
override fun execute(input: Tuple) {
val obj = dataTuple.getValueByField(EmitFields.MESSAGE)
val key = dataTuple.getValueByField(EmitFields.GROUP_FIELD) as String
val messageEntries = obj as List<MessageEntry>
dispatcherService.dispatcherLogical(messageEntries,key)
}
這樣我們在單元測試里可以直接將dispatcherService
類注入進來,并自己實現一個OutputCollector
用于收集分發數據——通過配置spring框架來靈活的替換實現類。然后將mock的參數填入,并斷言結果是否符合我們的期待。
但由于storm會涉及到分發相關事宜(如序列化),這會讓業務代碼有點變扭:
- 將這個
dispatcherService
成員在Bolt里聲明為Transient
- 需要在初始化時初始化IOC容器
- 在初始化IOC容器后注入dispatcherService
可以看到,我們為了測試,竟然不得不修改業務代碼——加入無關緊要的邏輯,這顯然不是一個好的方案。
方案2:Mockito
Mockito實現的方案對業務沒有任何入侵性,直接寫測試代碼即可,寫出來的代碼類似于:
@RunWith(PowerMockRunner::class)
@PowerMockIgnore("javax.management.*")
class DispatcherBoltTest {
private lateinit var config: AbstractSinkConfig
private lateinit var outputCollector: OutputCollector
private lateinit var tuple: Tuple
@Before
fun atBefore() {
config = PowerMockito.mock(AbstractSinkConfig::class.java)
outputCollector = PowerMockito.mock(OutputCollector::class.java)
tuple = PowerMockito.mock(Tuple::class.java)
}
private fun init(dispatcherBoltImpl: DispatcherBoltImpl) {
reset(config)
reset(outputCollector)
reset(tuple)
dispatcherBoltImpl.prepare(mutableMapOf(), PowerMockito.mock(TopologyContext::class.java), outputCollector)
}
@Test
fun testSingleUms() {
//定義mock對象的一些行為
`when`(config.configProps).thenReturn(Properties())
//將需要測試的類實例化
val dispatcherBoltImpl = DispatcherBoltImpl(config)
init(dispatcherBoltImpl)
val umsMap = generateSingleUmsBo()
val boMap = getBoMap(intArrayOf(1))
//定義mock對象的一些行為
`when`(tuple.getValueByField(EmitFields.MESSAGE)).thenReturn(umsMap.messages)
`when`(tuple.getValueByField(EmitFields.GROUP_FIELD)).thenReturn(umsMap.dispatchKey)
`when`(tuple.getValueByField(EmitFields.EX_BO)).thenReturn(boMap)
dispatcherBoltImpl.handleDataTuple(tuple)
// 結果驗證
Mockito.verify(outputCollector, Mockito.times(1))
.emit(EmitFields.DATA_MSG, tuple, Values(umsMap.dispatchKey, umsMap.messages,
boMap,
EmitFields.EMIT_TO_BOLT))
}
}
邏輯很清晰易懂:先選擇需要mock的對象,并定義其被mock的行為,然后把數據填裝進去即可,最后根據結果校驗——本質上將業務和框架的行為一起測試了進去。
但如果把視野放高點看,有兩個潛在的問題需要考慮:
- 目前該類的業務邏輯比較簡單,所以我們需要關注的鏈路也較少——這體現在我們對于mock對象的mock行為編寫上。換句話說,該類越復雜,我們就需要編寫越多的mock代碼。
- 目前我們的業務和框架是緊耦合的,那么我們測試時需要將框架的行為一同考慮進去。同時也意味著框架行為變動時(如升級),測試用例需要大量變更。亦或是更換框架時,測試用例會變得幾乎不可用。這已經違反整潔架構的原則了——業務需要獨立于框架,而不是緊密耦合。
方案3:Clean Architecture
根據前面提到的,我們要做的第一件事就是剝離業務和框架的耦合。那么該如何剝離呢?我們直接拿出答案:
/**
* 剝離與任何流處理框架的耦合,僅關注UMS分發的服務
* */
interface DispatcherServer {
fun dispatcherMessageEntry(key: String, messageEntries: List<MessageEntry>, destination: String,
tableToDispatchColumn: HashMap<String, Set<String>>,
resultConsumer: (group: MutableMap<Int, UmsMessageBuilder>, key: String) -> Unit,
executeDdlEventBlock: (messageEntry: MessageEntry) -> Unit,
ddlMessageConsumer: (key: String, messageEntry: MessageEntry) -> Unit)
}
我們定義了三個函數型參數。利用這種方式,我們可以輕易的將業務和框架隔離開來。于是代碼調用起來就像這樣:
override fun execute(dataTuple: Tuple) {
input = dataTuple
try {
val obj = dataTuple.getValueByField(EmitFields.MESSAGE)
val key = dataTuple.getValueByField(EmitFields.GROUP_FIELD) as String
val messageEntries = obj as List<MessageEntry>
dispatcherServer.dispatcherMessageEntry(key, messageEntries, destination, tableToDispatchColumn,
dmlMessageConsumer = { builder, innerKey -> emitBuilderMessage(builder, innerKey) },
executeDdlEventBlock = { entry -> executeDdlEvent(entry) },
ddlMessageConsumer = { innerKey, msgEntry -> emitDDLMessage(innerKey, msgEntry) }
)
collector.ack(dataTuple)
} catch (e: Exception) {
logger.info("Dispatcher Execute error: ", e)
collector.reportError(e)
collector.fail(dataTuple)
}
}
emitBuilderMessage
、executeDdlEvent
、emitDDLMessage
只是DispatcherBolt中的一個私有方法,里面會將傳入的數據通過collector按照一定規則下發下去。這樣,我們就將框架相關的代碼放在了DispatcherBolt里。
而和框架無關的業務代碼,我們則可以將它放到DispatcherServer
的實現中去。
測試的代碼也可以專注在測試業務邏輯上:
@Test
fun testUpdateRecords() {
val originNamespace = "my_schema.my_table"
val mockData = listOf(getUpdate1Data())
val config = getMockConfig(extractorConfigJsonFile)
config.outputBeforeUpdateFlg = false
config.outputExtraValueFlg = false
config.payloadType = PayloadType.SIZE
config.maxPayloadSize = 10240
val dispatcherServer = DispatcherServerImpl(config)
val resultMap = mutableMapOf<Int, UmsMessageBuilder>()
dispatcherServer.dispatcherMessageEntry(originNamespace, mockData, "M26", hashMapOf(),
dmlMessageConsumer = { builder, innerKey ->
resultMap.putAll(builder)
Assert.assertEquals(innerKey, originNamespace)
},
executeDdlEventBlock = { throw RuntimeException("這堆數據中不應該出現DDL事件") },
ddlMessageConsumer = { _, _ -> throw RuntimeException("這堆數據中不應該出現DDL相關的結果") })
assertEquals(1, resultMap.keys.toSet().size, "當前數據中,應該被分為3組——根據主鍵分發原則,他們來自于不同的主鍵")
assertEquals(1, resultMap.size, "當前數據中,應該被分為3組——根據主鍵分發原則,他們來自于不同的主鍵")
val umsList = resultMap.values.map { it.message }
umsList.forEach {
Assert.assertEquals("m.M26.my_schema.my_table", it.schema.namespace)
Assert.assertEquals(1, it.payloads.size)
assertEquals(9, it.schema.fields.size, "5個擴展字段+4個schema字段應該為9")
Assert.assertEquals("inc", it.protocol.type)
Assert.assertEquals("2", it.protocol.version)
assertEquals(MediaType.DataSourceType.MYSQL, KafkaKeyUtils.getDataSourceType(it))
}
}
看完了效果,我們再來談談上面所用到技巧。其實這很像面向對象中的Strategy模式——定義一個算法接口,并將每一種算法都在這個接口下實現其邏輯,令同一個類型的算法能夠互換使用。這樣做的好處是算法的變化不影響使用方,也不受使用方的影響。而如果函數是一等公民的話,則會讓建立和操縱各種策略的工作變得十分簡單。
那么怎樣算是不簡單的呢?如果用java的話,我們得先定義一個專門的接口,聲明一個方法,在使用時用匿名內部實現將它傳入,但這其實沒什么必要,因為我們僅僅想傳一個函數進去,而不是對象。典型的代碼可以見:
ZStack源碼剖析之設計模式鑒賞——策略模式:https://segmentfault.com/a/1190000013460437
設計模式要做的事不外乎減少代碼冗余度,提高代碼復用性。而在函數式語言中,復用主要表現為通過參數來傳遞作為第一等語言成分的函數,各種函數式編程庫都頻繁地運用了這種手法。與面向對象語言相比(以類型為單位),函數式語言的重用發生于較粗的粒度級別上(以行為為單位),著眼于提取一些共通的運作機制,并參數化地調整其行為。
小結
在本文中,我和大家討論了一些典型的測試方法,最后我們使用策略模式較好的完成了測試代碼。而策略模式本身其實是inversion of control
的一種體現,關于IOC,我們可以用Hollywood Principle
來理解它——don't call us, we'll call you
:在最早版本時,我們的業務代碼在執行完后直接“找到”了框架的方法,至使耦合。在最后的版本里,我們的業務代碼暴露了策略接口,便于外部將邏輯靈活的注入進來,而不是緊耦在一起。