用 Fx 來替 Go 依賴注入吧

相信平常開發時,即使沒真的用到,也會聽別人提起「依賴注入」的概念。我們都知道依賴注入的目的是解耦模組間的依賴,但具體來說,依賴注入應該要怎麼進行呢?Go 對於依賴注入有什麼比較好的實踐呢?這篇就來談談 Go 相關的依賴注入話題。

常見的實踐方式

講到依賴注入,從 OOP 的觀點來看,可以回到 Martin Fowler 的 SOLID 原則,其中的 Dependency Inversion Principle 落實到編程中,就是依賴注入。James Grenning 曾經簡單扼要說明 DIP 原則的出發點

Martin tells us that high-level modules shouldn’t depend on low-level modules.

這裡,高層級的模組指的是商業邏輯的實現。依賴反轉原則之所以重要,在於它隔離了抽象與實作,高階層只定義抽象,所以可以快速完成。而低階再視高階需求來完成實作,讓抽換修改的彈性增加。重點會放在如何去定義抽象,而不是定義實作。

假設我們有個模組,稱為 app,負責商業邏輯;而有另一個模組,稱為 db,負責底層實現,那麼,依賴注入的用法會類似這樣

package main

import (
    "playground/internal/app"
    "playground/internal/db"
)

func main() {
    db := db.NewDatabase()
    app := app.NewApp(db)
    app.Run()
}

很單純吧,先後建立兩個實例,app 實例包含 db 實例,兩個實例間透過 interface 來溝通,在 main func 進行組合。組合完成後,就可以呼叫 app.Run 來執行程式。

具體來看 app 跟 db 的 New func 是

package app

import "playground/internal/entity"

type app struct {
    db entity.DatabaseInterface
}

func NewApp(db entity.DatabaseInterface) *app {
    return &app{db: db}
}

func (a *app) Run() {
    a.db.Save()
}

重點是 arg 需要使用 interface 來定義,同樣的,db 的 return 也要使用 interface

package db

import "playground/internal/entity"

type database struct{}

func NewDatabase() entity.DatabaseInterface {
    return &database{}
}

func (d *database) Save() {}

可以理解成,把實例從原本的模組搬出來,再搬進去 app 中,給 app 呼叫。在這過程中,app 不需要知道原本的樣子,只需要知道呼叫的方式。

框架的用途

雖然依賴注入的概念很單純,但當依賴的項目變多時,管理上會慢慢變得棘手。在剛剛的例子中,只有 app 依賴 db,假設現在 app 需要爬 10 不同的網站的資料,每個網站需要一套解析資料的邏輯呢?或者,依賴內又存在依賴關係,像是 A -> B -> C,成為 chain 的結構呢?如果依賴不僅存在於啟動,也要在結束程式時,套用 Graceful shutdown 的方式依照順序來結束呢?當你面對複雜的應用情境,就是框架出馬的時候了。

有些框架會內建依賴注入,像是 Angular

元件應該把諸如從伺服器獲取資料、驗證使用者輸入或直接往控制檯中寫日誌等工作委託給各種服務。透過把各種處理任務定義到可注入的服務類別中,你可以讓它被任何元件使用。 透過在不同的環境中注入同一種服務的不同提供者,你還可以讓你的應用更具適應性。

Angular 不會強迫你遵循這些原則。Angular 只會透過依賴注入來幫你更容易地將應用邏輯分解為服務,並讓這些服務可用於各個元件中。

這裡引入另一個重要的概念,SRP,依賴注入可以將 Component 中跟責任無關的輔助邏輯分離開來,讓元件專注於元件本身,從而提高可用性。這不是強制的,開發者仍然應該視自己的專案選擇技術實踐,框架提供的是「選擇」,而不是限制開發者的自由。

當然,作為一個標榜「simple」的語言,Golang 同樣沒有內建框架,但這並不意味著開發者需要自造輪子,常用的依賴注入框架,有 Google 的 wire (是的,即使是跟 Go 關係密切的 Google,也設計一套了框架來讓開發變得更有效率)跟 Uber 的 Fx。兩者的差別,在於 wire 是使用 Code Gen 的方式,而 Fx 則是使用 Reflection,在執行期動態判斷型別。

我覺得選擇哪套框架的差別不大,因為現在的公司用 Fx,這邊也主要介紹 Fx。

Fx 要怎麼用?

Fx 是由 uber 開源的一套依賴注入框架,用來幫開發者管理上述提到的種種問題,GitHub Repo 的說明寫得很簡潔清晰

An application framework for Go that:

.Makes dependency injection easy.
.Eliminates the need for global state and func init().

首先讓依賴注入更簡單,其次降低 init() 的使用。我覺得第二點可以多講講,依照 Go 的設計,如果 pakcage 中有 init(),會在 process 運行時先被呼叫,順序則是依照 import 的順序。這意味著,當你的專案存在多重引用關係時,幾乎無法預期 init() 被呼叫的正確時間,從而沒辦法用正確的順序載入依賴。

在 uber 的 Coding Style Guide 也明確說明 init() 是個反模式

Avoid init()

Avoid init() where possible. When init() is unavoidable or desirable, code should attempt to:Be completely deterministic, regardless of program environment or invocation.Avoid depending on the ordering or side-effects of other init() functions. While init() ordering is well-known, code can change, and thus relationships between init() functions can make code brittle and error-prone.Avoid accessing or manipulating global or environment state, such as machine information, environment variables, working directory, program arguments/inputs, etc.Avoid I/O, including both filesystem, network, and system calls.

盡管有些狀況仍需要使用 init(),但使用依賴注入框架,可以幫助開發者避免用不正確的方式做事

Fx 用來管理依賴的方式跟容器有點像,首先用 New 產生 Fx 的 app 實例,告知需要的 New func,app 在執行前會呼叫 invoke 的 func,並載入相關的實例,完成依賴注入

package main

import (
    "playground/internal/app"
    "playground/internal/db"

    "go.uber.org/fx"
)

func main() {
    fxApp := fx.New(
        fx.Provide(
            db.NewDatabase,
            app.NewApp,
        ),
        fx.Invoke(app.Run),
    )
    fxApp.Run()
}

Provide 用來告知如何產生要被注入的的實例,由於 Fx 的設計是 Lazy Loading,僅僅只是在 Provide 聲明還不會被使用,需要透過 Invoke 喚起 func,明確表示需要該依賴,Provide 中的 func 才會被調用。

大致上,流程可以看成是 Run() -> Invoke() -> Provide() 的順序。

對應到原本的範例,我們要放入 Provide 的是 db 跟 app 的 New func,要放進 Invoke 是 app 的 Run func,當執行 Fx 的 Run 時則會顯示加載訊息

go run ./app

[Fx] PROVIDE    entity.DatabaseInterface <= playground/internal/db.NewDatabase()
[Fx] PROVIDE    *app.App <= playground/internal/app.NewApp()
[Fx] PROVIDE    fx.Lifecycle <= go.uber.org/fx.New.func1()
[Fx] PROVIDE    fx.Shutdowner <= go.uber.org/fx.(*App).shutdowner-fm()
[Fx] PROVIDE    fx.DotGraph <= go.uber.org/fx.(*App).dotGraph-fm()
[Fx] INVOKE             playground/internal/app.Run()
hello
[Fx] RUNNING

當然 Fx 還有一些進階功能,像是 Logger 的客製化,可以用來將 log 轉換成 ELK 可以接受的格式;解構賦值跟 Supply func,可以用來處理 config 的加載,這邊就有興趣的人自行研究啦。

結語

我以前都是手動處理,直到現在的工作,才發現還有依賴注入框架這東西。Fx 吸引我的點,大概是它還能管理生命週期,用來設計 Graceful shutdown 很好用。

但如果談到是否在專案中正式導入,我覺得是件值得思考的事。Fx 在開發上有幾個小問題:(1) 因為是在執行期才注入,會降低問題反應時間,有時忘記在 main 加入 New func,要等到 run 起來 panic 才知道;(2) 預設的 debug log 不是太友善,需要花點時間理解;(3) 如果使用到進階功能,像是 annotation,要再花時間理解相關用法。這幾項也跟團隊目前的技術能力息息相關。

對資深工程師來說,依賴注入或許是個簡單概念,使用框架時,能很容易映射使用方式與設計原理。但對不熟悉依賴注入的人來說,會需要花點工夫理解背後的思維。單純看程式碼,其實不容易看懂,例如 fx 背後是透過 Reflection 來實作依賴注入,即使在執行期 step into 執行,也不容易知道反射背後對到的是哪個 New func。

框架的目的是協助工程師,如果不知道需要什麼協助,用框架也幫不上忙,說不定還會綁手綁腳,我猜這樣聽起來有點無趣,但也或許這才是最實際的想法。技術管理有它的成本跟效益,如果能的話,當然還是建議依照正規方法來設計,不能的話,當專案規模小的時候,直接使用手動注入而不使用框架,也不失為一個折衷的方式。

Reference

Read more

OAuth 2.0 的身份認證:OpenID Connect

OAuth 2.0 的身份認證:OpenID Connect

OAuth 2 讓網路服務可以存取第三方的受保護資源,因此,有些開發者會進一步利用 OAuth 2 來進行使用者認證。但這中間存在著一些語義落差,因為 OAuth 2 當初設計目的是「授權」而不是「認證」,兩者關注的焦點會有些不同。OpenID Connect 是基於 OAuth 2 的一套身份認證協定,讓開發者可以在 OAuth 2 授權的基礎上,再加入標準的認證流程。在這篇文章中,我會說明授權跟認證的場景有何差異,並講解 OpenID Connect 如何滿足認證需求。 因為 OpenID Connect 是建構在 OAuth 2 的基礎上,我會假設這篇文章的讀者已經知道 OAuth 2 的組件與流程,如果你不熟悉,可以先閱讀另外兩篇文章 * OAuth 2.0:

By Ken Chen
更好的選擇?用 JWT 取代 Session 的風險

更好的選擇?用 JWT 取代 Session 的風險

因為 HTTP 是無狀態協定,為了保持使用者狀態,需要後端實作 Session 管理機制。在早期方式中,使用者狀態會跟 HTTP 的 Cookie 綁定,等到有需要的時候,例如驗證身份,就能使用 Cookie 內的資訊搭配後端 Session 來進行。但自從 JWT 出現後,使用者資訊可以編碼在 JWT 內,也開始有人用它來管理使用者身份。前些日子跟公司的資安團隊討論,發現 JWT 用來管理身份認證會有些風險。在這篇文章中,我會比較原本的 Session 管理跟 JWT 的差異,並說明可能的風險所在。 Session 管理 Session 是什麼意思?為什麼需要管理?我們可以從 HTTP 無狀態的特性聊起。所謂的無狀態,翻譯成白話,就是後面請求不會受前面請求的影響。想像現在有個朋友跟你借錢,

By Ken Chen

Goroutine 的併發治理:掌握生命週期

從併發的角度來看,Goroutine 跟 Thread 的概念很類似,都是將任務交給一個執行單元來處理。然而不同的是,Goroutine 將調度放在用戶態,因此更加輕量,也能避免多餘的 Context Switch。我們可以說,Go 的併發處理是由語言原生支援,有著更好的開發者體驗,但也因此更容易忘記底層仍存在著輕量成本,當這些成本積沙成塔,就會造成 Out of Memory。這篇文章會從 Goroutine 的生命週期切入,試著說明在併發的情境中,應該如何保持 Goroutine 的正常運作。 因為這篇講的內容會比較底層,如果對應用情境不熟的人,建議先看過同系列 * Goroutine 的併發治理:由錯誤處理談起 * Goroutine 的併發治理:值是怎麼傳遞? * Goroutine 的併發治理:管理 Worker Pool 再回來看這篇,應該會更容易理解。 Goroutine 的資源使用量 讓我們看個最簡單的例子,假設現在同時開

By Ken Chen

Goroutine 的併發治理:管理 Worker Pool

併發會需要多個 Goroutine 來同時執行任務,Goroutine 雖然輕量,也還是有配置成本,如果每次新的任務進來,都需要重新建立並配置 Goroutine,一方面不容易管理 Goroutine 的記憶體,一方面也會消耗 CPU 的運算效能。這時 Worker Pool 就登場了,我們可以在執行前,先將 Goroutine 配置好放到資源池中,要用時再調用閒置資源來處理,藉此資源回收重複利用。這篇文章會從 0 開始建立 Work Pool,試著丟進不同的場景需求,看看如何實現。 基本的 Worker Pool Worker Pool 的概念可以用這張圖來解釋 Job 會放在 Queue 中送給 Pool 內配置好的 Worker,Worker 處理完後再將結果送到另一個 Queue 內。因為這是很常見的併發模式,

By Ken Chen