iT邦幫忙

2022 iThome 鐵人賽

DAY 12
1
Software Development

軟體架構師的自我修養系列 第 12

[Day 12] 在程式碼中的時機耦合

  • 分享至 

  • xImage
  •  

我們總是在談論耦合,到底耦合是什麼?

大致上來說,有三種不同的元件耦合。

  1. 傳入(Afferent)耦合:任務A會被B、C、D呼叫。

  2. 傳出(Efferent)耦合:當任務A必須依靠呼叫B、C和D才能完成任務。

  3. 時機(Temporal)耦合:當任務A必須依靠呼叫B和C才能完成任務,且B要比C早執行。

這裡的元件耦合可以是程式碼層級、模組層級甚至服務層級。

這篇文章要帶你深入了解時機耦合實際上的長相,因為這是最常見卻也最容易被忽略的陷阱。首先我們用一個簡單的Nodejs程式碼作為範例。

function foo() {
    const rA = doA();
    const rB = doB(rA);
    return doC(rB);
}

從例子來看,我們發現這實在非常普通,幾乎我們所有的程式碼都長這個樣子。讓三件事循序發生這不是很普通嗎?

讓我們看一個更實際的例子。假設我們有一個電子商務網站,並且有一個結帳的功能purchase。那麼,讓我們從最簡單的樣子開始。

function purchase(cart) {
    let total = 0;
    for (let item of cart.items) {
        total += item.price;
    }
    return payByCreditCard(cart.user, total);
}

首先,將購物車內的所有品項價格加總,接著,呼叫金流服務來處理信用卡資訊。很簡單,對吧?

好,市場團隊想要推出一個折價券機制,讓消費者在花了超過1000塊後可以得到一張折價券。所以,我們修改一下結帳。

function purchase(cart) {
    let total = 0;
    for (let item of cart.items) {
        total += item.price;
    }
    let ok = payByCreditCard(cart.user, total);
    if (ok && total >= 1000) {
        ok = giveCoupon(cart.user, total);
    }
    return ok;
}

這個功能也很常見。緊接著,銷售團隊發現折價券是一個很不錯的促銷機制,所以他們提議讓消費滿5000的人可以得到一張抽獎券並抽獎。結帳功能持續成長。

function purchase(cart) {
    let total = 0;
    for (let item of cart.items) {
        total += item.price;
    }
    let ok = payByCreditCard(cart.user, total);
    if (ok && total >= 1000) {
        ok = giveCoupon(cart.user, total);
    }
    if (ok && total >= 5000) {
        ok = lottery(cart.user, total);
    }
    return ok;
}

這就是一個時機耦合。

無論是giveCouponlottery其實都依賴purchase,他們都必須在結帳完成後才能夠觸發。一但功能需求不斷增加,整個結帳的效能就會變得很糟,尤其是抽獎這類需要大量運算的功能,會讓效能雪上加霜。

透過領域事件來解耦時機

在上一節我們學到purchase應該專注在結帳,其餘的功能都是附加的,且不應該與結帳有著同樣生命週期。換句話說,即使giveCoupon失敗,也不應該影響purchaselottery

在領域驅動開發中有個做法稱為領域事件。當一個任務完成,就會發送一個事件讓關注的人採取對應的動作。順帶一提,這在設計模式中也被稱為Observer Pattern

在領域驅動開發中,這個通知包含了領域語言,因此被稱為領域事件。

因此,我們來用Nodejs的機制稍微改造purchase

const events = require('events');
const emitter = new events.EventEmitter();

emitter.on('purchased', function(user, total) {
    if (total >= 1000) {
        giveCoupon(cart.user, total);
    }
});
emitter.on('purchased', function(user, total) {
    if (total >= 5000) {
        lottery(cart.user, total);
    }
});

有了事件,我們可以完全將giveCouponlotterypurchase中解耦。就算有任何一個處理者失敗,也不會影響原來的結帳。

至於purchase只需要專心處理結帳即可。當結帳成功,就發送事件讓關注者知道。

function purchase(cart) {
    let total = 0;
    for (let item of cart.items) {
        total += item.price;
    }
    const ok = payByCreditCard(cart.user, total);
    if (ok) {
        emitter.emit('purchased', cart.user, total);
    }
    return ok;
}

如果未來有更多功能需求,也不需要改動purchase,只需要實作新的處理者就好。這樣的解耦作法實際上將程式碼等級的耦合和時機層級的耦合都解開了。

如何處理事件遺失?

錯誤終究是會發生的,我們必須面對它並正確處理它。

當我們透過事件解耦了折價券和抽獎,我們馬上會碰到一個問題:若是事件遺失了該怎麼辦?當結帳成功了,但卻沒收到折價券或抽獎,這對消費者來說絕對是個大問題。

換言之,我們如何保證送出的事件一定被成功執行?這就是為什麼訊息佇列存在的原因。

我們前幾天已經聊過訊息佇列了,有三種不同的抵達保證:

  • 至多一次
  • 至少一次
  • 僅有一次

大多數的訊息佇列都有至少一次的保證,也就是說,透過訊息佇列我們可以確保所有事件都被成功執行至少一次。這也保證了事件不會遺失。

因此,為了避免事件遺失,我們可以將emitter.emit修改為插入訊息佇列,例如RabbitMQ或Kafka。到這個階段,我們引入了系統層級的解耦,也就是訊息的生產者和訊息消費者分屬不同的執行單元。

如何處理發送失敗?

故事還沒完。我們已經可以保證送出的事件會被執行,那如果事件根本沒送出呢?

繼續以purchase為例,假設我們已經處理完payByCreditCard但送事件前系統就因為意外而中止了。那麼,即使有訊息佇列,我們依然會有不正確的結果。

為了避免這問題,我們可以使用事件朔源。在前些天的分散式交易和CQRS都有介紹事件朔源的概念。

在事件送出前,首先先將事件存在資料庫。當事件被正確處理,就將儲存的事件標記成已完成。重要的是,寫事件和處理結帳這兩件事必須存在在同一個交易操作。

如此一來,只要結帳成功就能保證事件至少會寫入資料庫。最後有一個定期的監控者來監視每個儲存的事件是否正確被執行。

結論

這次我們依然是一步步演化我們的系統,以此來了解當系統變龐大、複雜時該怎麼對應。

一開始,我們先從解耦程式碼和執行時機開始,透過領域事件做初步解耦。接著我們引入訊息佇列來進一步達成系統層級的解耦。

但就像我之前說的,系統演化是為了解決問題,但同時也會產生新問題。我們只能不斷選擇最適合的解法,並在複雜度、效能、生產力和其他種種因素中尋求妥協。

將執行單元一分為二馬上會碰到不一致的問題。要解決不一致有許多考量:

  • 不管是事件遺失或發送失敗都不在意,只尋求最簡單的架構,那EventEmitter就夠了。這個方案是最簡單的,且約略八成沒問題,但如果出現問題了呢?
  • 嘗試盡可能的做到可靠,所以引入訊息佇列,這樣可以確保99%沒問題。但還是有1%的風險,這樣的風險可以承受嗎?
  • 為了更加可靠,那麼實作事件朔源。但,事件朔源會帶來額外的複雜度也可能影響效能,這是可接受的嗎?

我總是說,系統設計中沒有完美的解法。

每個組織都有不同程度的風險承受能力,在眾多因素中我們試圖尋找我們最能接受的解法,但同時也要思考背後的風險和潛在問題。如此一來,每個人都能夠構建出一個有彈性(resilient)的系統。


上一篇
[Day 11] 事件驅動架構的設計模式(下)
下一篇
[Day 13] 用最小負擔實作事件驅動架構
系列文
軟體架構師的自我修養31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言