最近在研究 DDD(Domain-Driven Design),在研究的過程中,忽然頓悟了一個之前苦惱許久的問題,雖然跟 DDD 沒有特別相關,但這種莫名奇妙的觸類旁通覺得很有趣,讓我想特別記錄下來。
問題主要是關於如何讓 Service 有能力決定何時該執行 Transaction(後面簡稱 TX),並且讓 Repository 的多個操作能夠在 Service 發起的 TX 下執行。
為了方便測試開發以及程式的可讀性,通常在 Repository 上的操作方法,一個方法只會做單一一件事,例如:
ABRepository
createA(dataA)
createB(dataB)
(原諒我偷懶,例子都是示意用的 pseudo code 🥰)
假設有一個資源集合叫 AB,他對應的 Repository 叫 ABRepository,我們可以為 AB 新增資源 A 與 B,分別對應於 Repository 上的 createA 與 createB 方法。
如果 A 與 B 總是分開建立,真的很棒。但如果有一天 A 與 B 必須在同個 TX 裡建立,該怎麼處理呢?
有幾種方法可以解決:
1. 在 Repository 上新增 createAB 方法
ABRepository
createAB(dataA, dataB)
createA(dataA)
createB(dataB)
這是最土炮的方式,對於每個需要 TX 的操作,讓他變成 Repository 上的 atomic operation,如 ABRepository 中的 createAB 方法所示,用起來會長這樣:
Service
createAB(dataA, dataB)
this.abRepository.createAB(dataA, dataB)
但是這樣做有三個缺點:
- 當一個 TX 裡需要做很多事時,這個 atomic operation 會變得很肥。
- 事務(TX)邏輯是 Service 的職責,若以 atomic operation 的方式處理,會讓事務邏輯都被封裝到了 Repository,Service 反而變得無所事事,使得 Service 的程式碼無法揭露重要的應用程式邏輯。
- 無法支援多 Repository 事務。
如果只是把簡單的複合操作(例如這裡的 createAB)封裝成 atomic operation 並不會造成太嚴重的後果,但是我還是會建議盡量避免這個方法,讓 Service 能夠揭露越多邏輯越好。
2. 在 Repository 上新增 begin 與 commit 方法
ABRepository
begin()
commit()
createA(dataA)
createB(dataB)
當 Repository 的 begin 方法被呼叫後,Repository 內部會初始化一個 TX,在遇到 commit 之前的所有操作,都會加進這個 TX。但這個方法會使得 Repository 變成有狀態的,如果 Repository 本身是 Singleton,就不能使用這個方法。
在 Service 中使用起來會像這樣:
Service
createAB(dataA, dataB)
this.abRepository.begin()
this.abRepository.createA(dataA)
this.abRepository.createB(dataB)
this.abRepository.commit()
對於單個 Repository 的情境,這樣的解法是可行的。如果是多個 Repository,就會有點彆扭:
Repository
begin()
commit()ARepository extends Repository
createA(dataA)BRepository extends Repository
createB(dataB)Service
createAB(dataA, dataB)
this.aRepository.begin()
this.bRepository.begin()
this.aRepository.createA(dataA)
this.bRepository.createB(dataB)
this.aRepository.commit()
this.bRepository.commit()
很顯然的,這兩個 Repository 會自掃門前雪,各自擁護自己的 TX,沒有機制可以互相分享。
3. 讓 Repository 的 begin 返回 TX
ABRepository
begin() TX
createA(dataA)
createB(dataB)
如果讓 Repository 的 begin 方法能夠返回 TX,就能讓大家都存取到同一個 TX,只是必須想個方法將 TX 傳給 Repository,最直覺的方式就是透過參數:
ABRepository
begin() TX
createA(tx, dataA)
createB(tx, dataB)
單個 Repository 使用起來會像這樣:
Service
createAB(dataA, dataB)
tx = this.abRepository.begin()
this.abRepository.createA(tx, dataA)
this.abRepository.createB(tx, dataB)
tx.commit()
要注意的是,這個方法必須在支援 TX 的方法上加入 TX 參數,該方法如果有時候不需使用到 TX,會造成一些使用上的麻煩。
或許可以在 Repository 上提供設置以及清除 TX 的方法:
ABRepository
setContext(tx)
removeContext()
begin() TX
createA(dataA)
createB(dataB)
使用起來會像這樣:
Service
createAB(dataA, dataB)
tx = this.abRepository.begin()
this.abRepository.setContext(tx)
this.abRepository.createA(dataA)
this.abRepository.createB(dataB)
tx.commit()
this.abRepository.removeContext()
要注意的是,這個方法會使得 Repository 變成有狀態的,如果 Repository 本身是 Singleton,就不能使用這個方法,只能選擇上面傳入 TX 參數的方式。
不管是傳入或是設置 TX 都可以有效地解決跨 Repository 的問題,以設置 TX 的方式為例:
Repository
setContext(tx)
removeContext()
begin() TXARepository extends Repository
createA(dataA)BRepository extends Repository
createB(dataB)Service
createAB(dataA, dataB)
tx = this.aRepository.begin()
// or tx = this.bRepository.begin()
this.aRepository.setContext(tx)
this.bRepository.setContext(tx)
this.aRepository.createA(dataA)
this.bRepository.createB(dataB)
tx.commit()
this.aRepository.removeContext()
this.bRepository.removeContext()
從上面這個例子可以發現一個令人困惑的地方:A 和 B Repository 都有能力可以發起 TX,所以該由誰來發起呢?其實誰來發起都可以,但讀起來和用起來不是特別舒服 😅
4. 引入獨立的 TXProvider
TXProvider
begin() TX
藉由引入一個獨立的 TXProvider,可以讓職責更明確,這邊只列出多個 Repository 的情境:
Repository
setContext(tx)
removeContext()ARepository extends Repository
createA(dataA)BRepository extends Repository
createB(dataB)Service
createAB(dataA, dataB)
tx = this.txProvider.begin()
this.aRepository.setContext(tx)
this.bRepository.setContext(tx)
this.aRepository.createA(dataA)
this.bRepository.createB(dataB)
tx.commit()
this.aRepository.removeContext()
this.bRepository.removeContext()
上述例子中的 Repository 與 TXProvider 都被視為介面,其對應的實例會以某種方式注入到 Service 裡面,實作細節因不同語言、情境而異,在這邊就不多著墨。