iT邦幫忙

2022 iThome 鐵人賽

1

筆者在最近幾個月陸續看了一些與設計模式、軟體重構有關的書籍:

補充:
設計重構這本書主要是在談軟體開發的技術債、介紹常見的軟體設計臭味與可能的解決辦法,雖然它介紹的臭味比較容易出現在物件導向的程式語言身上,不過我們還是可以吸收其中的精華並且應用到日常的實戰中。

進入正題

上面提到的書籍有些談論軟體臭味、有些談論如何在一開始就給出更好的設計或是使用更好的方法進行抽象,但這些文獻想要表達的重點不外乎就是:

  • 避免多餘、不必要的抽象
  • 避免不充分的抽象
  • 物件(模組)之間的依賴問題
  • 繼承關係出現混亂(不必要的繼承、繼承產生的循環)

而學習設計模式可以幫助我們一定程度上的避免寫出有問題的程式碼,不管是多執行緒程式的設計或是模組之間的依賴關係,我們幾乎都能以自己的情境去找到最合適的設計模式。

案例一:避免頻繁地使用 Lock

如果學過作業系統或是並行程式設計,你也許會知道當使用 mutex lock 時,其實會導致沒有取得 lock 資源的 process/thread 進入休眠,如果要開發一個高效能的網路程式,我們需要避免在程式會頻繁經過的 critical section 使用 lock,舉例來說:

package main

import (
	"fmt"
	"sync"
)

const (
	MSG_A = iota + 1
	MSG_B
	MSG_C
)

var MLock sync.Mutex

type Msg struct {
	Type int
}

func MsgDispatcher(msg *Msg) {
	switch msg.Type {
	case MSG_A:
		go HandleMsgA(msg)
	case MSG_B:
		go HandleMsgB(msg)
	case MSG_C:
		go HandleMsgC(msg)
	default:
		fmt.Println("Unknown msg type")
	}
}

func HandleMsgA(msg *Msg) {
	MLock.Lock()
	NewTransaction()
	fmt.Println("Handle MSG A")
	MLock.Unlock()
}

func HandleMsgB(msg *Msg) {
	MLock.Lock()
	NewTransaction()
	fmt.Println("Handle MSG B")
	MLock.Unlock()
}

func HandleMsgC(msg *Msg) {
	MLock.Lock()
	NewTransaction()
	fmt.Println("Handle MSG C")
	MLock.Unlock()
}

func NewTransaction() {
	// ...
}

上面的程式(不充分的)實作了一個 msg server,它包含了 msg dispatcher 以及不同類型訊息的 handler:

  • 為了能夠更快的應付並發的用戶請求,MsgDispatcher() 收到請求後會將訊息交給 goroutine 去處理。
  • 不同類別的訊息會在接收到該訊息時先幫它建立一個 transaction,用來記錄訊息發送與回覆的情況,但因為 server 使用了 goroutine 去處理並發的請求,所以在新增 Transaction 時就有可能會發生 race condition。
  • 為了避免 race condition,所有 Handler 都會使用 mutex lock 保護 critical section(這裡指 NewTransaction()

以上面的程式來看,如果有 1000 個客戶在一秒內對這個 server 發出服務請求,等於會有數百個 goroutine 在爭搶同一塊資源,這樣一來就會拖累系統效能。
套用設計模式可以讓我們提出幾個解決問題的想法:

1. 設計一個 worker pool 限制 goroutine 的數量

image

當 goroutine 的數量太多,可能會造成幾個問題:

  • 程式變的不穩定
  • 應用程式難以管理(當應用需要做 graceful shotdown 時,很難保證每個 goroutine 都要做出正確的處理)
  • 如果有 livelock 或是 deadlock 的問題,會非常難以除錯(搞不清楚是誰阻擋誰)

所以程式設計者會需要在開發並行程式時考量 goroutine 的數量,以 dispatch handler 的例子來說,我們可以在 event consumer 後方接著一個 worker pool,確保這些 pfcp 請求會被固定數量的 worker 逐一處理。

2. 將新增 transaction 的工作交給其他 goroutine 來執行

如果 pfcpServer context 只有一個 transaction table 需要維護,我們可以考慮把更新紀錄的工作交給一個 worker 處理,這麼一來直接移除 mutex lock 也不會有 race condition 的問題發生。

3. 結合多個設計模式

前面提到的兩個方法都可以解決部分的問題。有趣的是,在實作一個系統或是套件時,我們同樣可以結合多個設計模式處理我們的需求,像是前兩個例子彼此不衝突,我們就可以提出一個更完整個 solution:

  • message receiver & dispatcher 使用 producer & consumer model
  • dispatcher 派發工作時使用 worker pool
  • 紀錄 transaction 交給單一 worker

實際上在實作 pfcpServer 時可能會遇到更多問題,這時候我們就可以參考更多設計模式解決特定問題囉!

案例二:套件過於龐大,將它拆分後產生模組之間的循環依賴

當套件的函式或成員數量多於 50 個,程式碼的可讀性與可維護性將會下降,所以多數人會在這個時間將一個套件拆成多個子套件,舉例來說:
我們可以將 pfcp 拆成:

  • server:負責接收與傳送 pfcp 訊息,並將收到的訊息交給對應的 handler。
  • handler:處理 pfcp 請求與回應。
  • sender:發送各式的 pfcp 請求與回應。
  • message:定義訊息結構、如何解析 pfcp 訊息。
  • tx:負責紀錄收送訊息的紀錄(確保每個請求都有得到響應)。

當套件拆成多個子套件,我們必須小心的管理他們之間的依賴性,要盡量避免複雜的依賴關係圖產生。

解法:使用 interface 抽象多個 API

參考以下程式碼:

package main

import "fmt"

type Server interface {
	Handler
	Sender
}

type Handler interface {
	HandleMsg() error
}

type Sender interface {
	SendMsg() error
}

type pfcpServer struct {
}

var _ Server = (*pfcpServer)(nil)

func NewPfcpServer() *pfcpServer {
	return &pfcpServer{}
}

func (s *pfcpServer) HandleMsg() error {
	fmt.Println("Handling msg...")
	return nil
}

func (s *pfcpServer) SendMsg() error {
	fmt.Println("Sending msg...")
	return nil
}

func main() {
	server := NewPfcpServer()
	server.SendMsg()
	server.HandleMsg()
}

將各個子套件的功能設計為 API interface,再由 Server interface 統一管理這些 API。
對於任何子套件來說,他們都可以藉由 pfcpServer 物件存取到其他子套件的 API,這樣的做法有以下好處:

  • 所有套件只依賴 Server 存在的那個套件,不會有過於複雜的依賴關係
  • 由於所有方法都透過 interface 進行抽象,所以保持了可擴充性

總結

這篇文章除了是分享,更多的是紀錄自己學習設計模式背後精神的筆記。我想,不管是哪個設計模式或是哪種程式碼的臭味名詞都不需要死記硬背,重要的是在開發的過程中一邊發想哪個地方的設計可能會為日後的維護埋下地雷,或是哪些設計會造成程式出現很多難以發現的錯誤,學習設計模式與定期的重構就可以避免我們累積技術債造成的雪崩式效應。


上一篇
GitOps 與 ArgoCD
下一篇
完賽心得 & 軟體設計雜談
系列文
5G 核心網路與雲原生開發之亂彈阿翔36
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言