iT邦幫忙

2025 iThome 鐵人賽

DAY 28
0
Software Development

Go Clean Architecture API 開發全攻略系列 第 28

[Day 28] 當業務邏輯變得複雜時如何調整架構

  • 分享至 

  • xImage
  •  

到目前為止,我們的架構在處理 CRUD(建立、讀取、更新、刪除)類型的功能時表現出色。
但真實世界的業務遠比這複雜。如果需求變更為:「使用者註冊成功後,需要同時發送一封歡迎郵件、建立一個試用訂閱、並記錄一條審計日誌」,我們該怎麼辦?

如果將所有邏輯都塞進 Register 這個 UseCase 中,它會迅速變得臃腫、難以維護,且與郵件、計費、日誌等毫不相關的領域緊緊地耦合在一起。這就是「重構」的信號。

重構的信號:程式碼的「壞味道」

  • 臃腫的 UseCase:一個 Execute 方法有上百行程式碼,一個 UseCase 結構體注入了七八個不同的依賴。這違背了「單一職責原則」。
  • 跨領域的依賴UseCase 開始需要 import billingnotification 的套件。這表示不同業務領域之間的邊界開始變得模糊。
  • 過多的條件分支:一個方法裡充滿了 if-else,試圖處理太多不同的場景。

案例分析:假設我們有一個臃腫的 Register UseCase

// 重構前
func (u *UseCase) Execute(ctx context.Context, input Input) (*Output, *domain.GPError) {
    // 1. 檢查使用者是否存在 (userRepo)
    // 2. 建立使用者 (userRepo)
    // 3. 發送歡迎郵件 (notificationSvc) // 新增的依賴
    // 4. 建立試用訂閱 (billingSvc) // 又一個新依賴
    // 5. 記錄審計日誌 (auditSvc) // 再一個新依賴
    // 6. 回傳結果
    return nil
}

重構模式:引入「領域事件 (Domain Events)」

要解耦這些職責,一個極其強大的模式是領域事件。其核心思想是:

當一個領域中發生了重要的事(領域事件),系統的其他部分可以「訂閱」並「回應」這個事件,而無需事件的發布者知道它們的存在。

這是一種基於「發布-訂閱」模式的非同步解耦機制。

實作步驟

  1. 定義事件:定義一個 UserRegistered 事件,包含必要的資訊(如使用者 ID、Email)。
  2. 實作 Dispatcher:建立一個 dispatcher 介面,並提供一個實作,負責將事件傳遞給所有訂閱者。
  3. 建立訂閱者:為每個需要回應 UserRegistered 事件的功能(如發送郵件、建立訂閱、記錄日誌),建立一個獨立的 Listener。
  4. 註冊訂閱者:在應用程式啟動時,將這些 Listener 註冊到 Dispatcher。

定義事件

// internal/domain/event_payload.go
package domain

const (
	EventUserRegistered EventName = "UserRegistered"
)

type EventUserRegisteredPayload struct {
	UserID int
	Email  string
}

定義 Listener 介面

// internal/domain/event.go
type Listener interface {
	Handle(ctx context.Context, payload any) error
}

實作 Dispatcher

type Service struct {
	listeners map[domain.EventName][]domain.Listener
	logger    logger.Logger
}

func NewService(logger logger.Logger) *Service {
	return &Service{
		listeners: make(map[domain.EventName][]domain.Listener),
		logger:    logger,
	}
}

// dispatch 將事件傳遞給所有訂閱者
func (s *Service) dispatch(ctx context.Context, name domain.EventName, payload any) error{
	listeners, ok := s.listeners[name]
	if !ok {
		return fmt.Errorf("no listeners for event: %s", name)
	}

	for _, listener := range listeners {
		if err := listener.Handle(ctx, payload); err != nil {
      return err
		}
	}
	return nil
}

// RegisterListener 註冊事件監聽器
func (s *Service) RegisterListener(name domain.EventName, listener domain.Listener) {
	s.listeners[name] = append(s.listeners[name], listener)
}

// DispatchUserRegistered 發布 UserRegistered 事件
// 這裏不是讓外部傳入 domain.EventUserRegisteredPayload,而是傳入必要的參數
// 這樣可以保證 event name 與 payload 的一致性
// 這裡可以根據需要同步與非同步 來決定是不是要回傳 error
func (s *Service) DispatchUserRegistered(ctx context.Context, userID int, email string) {
	go func() {
		err := s.dispatch(ctx, domain.EventUserRegistered, domain.EventUserRegisteredPayload{
			UserID: userID,
			Email:  email,
		})
		if err != nil {
			s.logger.With(ctx).Error(ctx, "failed to dispatch user registered event", err)
		}
	}()
}

建立訂閱者

type EmailService interface {
	SendEmail(email string, body any) error
}

type WelcomeEmail struct {
	emailService EmailService
}

func NewWelcomeEmail(emailService EmailService) *WelcomeEmail {
	return &WelcomeEmail{emailService: emailService}
}

// Handle 實作 domain.Listener 介面
func (w *WelcomeEmail) Handle(ctx context.Context, event any) error {
	userRegisteredEvent, ok := event.(domain.EventUserRegisteredPayload)
	if !ok {
		return fmt.Errorf("invalid event type")
	}

	// Send welcome email
	return w.emailService.SendEmail(userRegisteredEvent.Email, "Welcome!")
}

註冊訂閱者

// internal/application/service.go
dispatcherService := dispatcher.NewService(app.Logger)
dispatcherService.RegisterListener(domain.EventUserRegistered, listener.NewWelcomeEmail(emailService))

重構後的 Register UseCase

type dispatcher interface {
    DispatchUserRegistered(ctx context.Context, userID int, email string)
}

type UseCase struct {
  ...
  dispatcher dispatcher // 新增的依賴
}

func (u *UseCase) Execute(ctx context.Context, input Input) (*Output, *domain.GPError) {
    // 1. 檢查使用者是否存在 (userRepo)
    // 2. 建立使用者 (userRepo)

    // 發布領域事件
    u.dispatcher.DispatchUserRegistered(ctx, userID, input.email)

    // 6. 回傳結果
    return nil
}

我們可以看到,Register UseCase 現在只專注於「註冊使用者」這一件事。當使用者成功註冊後,它發布了一個 UserRegistered 事件。
其他需要回應這個事件的功能(如發送郵件、建立訂閱、記錄日誌)都被移到了獨立的 Listener 中,這些 Listener 完全不知道 Register UseCase 的存在。

另外一個好處是,當你提供使用者多種註冊方式的時候(例如 Email 註冊、Google OAuth 註冊),你只需要在這些不同的 UseCase 中發布相同的 UserRegistered 事件,而不需要重複實作發送郵件或建立訂閱的邏輯。

領域事件的好處

  • 高度解耦User 領域完全不知道 EmailBilling 領域的存在。
  • 極強的擴展性:如果未來需要新增一個「註冊成功後發送 Slack 通知」的功能,我們只需要新增一個 SlackNotificationListener 並註冊它即可,完全不需要修改任何 Register 的程式碼!

領域事件的缺點

  • 非同步處理:事件通常是非同步處理的,這意味著如果事件處理失敗,主流程可能已經完成,這需要額外的錯誤處理機制。
  • 調試困難:由於事件是非同步的,追蹤事件流可能變得困難,特別是在多個事件和訂閱者之間。
  • 潛在的性能問題:如果有大量的事件和訂閱者,可能會導致性能瓶頸,需要適當的優化和監控。

總結

當業務邏輯變得複雜時,使用領域事件是一種強大且靈活的解耦方式。它允許我們將不同的業務職責分離到獨立的模組中,提升系統的可維護性和擴展性。
然而,這種模式也帶來了一些挑戰,如非同步處理和調試困難,需要在設計時加以考慮。總的來說,領域事件是應對複雜業務邏輯的一個有效工具,值得在實際開發中嘗試和應用。

詳細的程式碼,請參考 Github 這一個 commit。


上一篇
[Day 27] 重複的程式碼,是一種原罪
下一篇
[Day 29] 程式碼品質守護者:使用 golangci-lint, gofumpt 統一團隊風格
系列文
Go Clean Architecture API 開發全攻略30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言