蒼時弦也
蒼時弦也
資深軟體工程師
發表於
這篇文章是 Clean Architecture in TypeScript 系列的一部分,你可以透過 Leanpub 提前閱讀內容。

tsyringe 是微軟維護的一款輕量依賴注入(DI,Dependency Injection)容器,可以幫助我們很輕鬆的管理物件的依賴注入,在 Clean Architecture 的實踐中我們會將許多物件細分職責,此時有依賴注入的機制可以幫助我們在測試、開發上更加順手。

簡潔

在發現 tsyringe 前,我大多使用 InversifyJS 來處理依賴注入,然而 InversifyJS 的前置準備並不算容易,改用 tsyringe 後只需要很簡單的加上 @injectable() 就可以解決大多數的應用情境。

撰寫本文時 InversifyJS 的設計也變得相對簡單不少,如果需要更複雜的功能可以考慮使用 InversifyJS

舉例來說,我們有一個 KvConversationRepository 用來儲存使用者的對話,可以像下面這樣進行定義。

1@injectable()
2class KvConversationRepository {
3	constructor(@inject(KvStore) private readonly kv: KVNamespace) {}
4
5    // ...
6}

當我們需要使用時,則只需要如下的實作。

1const repository = container.resolve(KvConversationRepository)

這是一個非常常見的依賴注入實作方式,tsyringe 可以幫我們自動找到 KvStore 並且完成 KvConversationRepository 的實例化。

可測試性

使用依賴注入可以很好的提高專案的可測試性,尤其是現在大多數語言模型收費仍然不便宜,也沒有一個很好的方法可以控制輸出。

舉例來說,我們有一個會呼叫語言模型的情境。

1class Chat {
2  constructor(private readonly model: LlmModel) {}
3
4  public async execute(message: string): Promise<string> {
5    // ...
6	const res = await this.model.chat(message)
7	return res.text
8  }
9}

雖然我們設計了依賴注入機制,但會需要在每個測試都重新建立假的測試物件,使用依賴注入套件或者框架,就可以透過覆蓋原本註冊的物件來達到我們的目的。

1container.register(
2	LlmModel,
3	{ 
4		useFactory: (c) => { return new MockLlmModel() }
5	}
6)

透過這個技巧,我們就不用在每個測試案例重新產生 MockLLmModel 物件,而是自動的在需要時產生新的物件。

Serverless 友善

如果是在非 Serverless 環境,我們仍能透過像是加入 setup.vitest.ts 這類方式預先定義好依賴,但是 Serverless 每一次請求都是獨立的,這能很好的提高拓展性,卻也讓我們無法共用預先定義的依賴。

以 Cloudflare 為例子

1export default {
2	async fetch(request, env, ctx) {
3		// KVNamespace => env.KV
4		return new Response('Hello World!');
5	},
6
7};

如果我們要拿到 env.KV 這個 KVNamespace 的 Binding(綁定)就需要等到我們接受到請求,這樣就不能在 export default 前先將 Repository 這類跟資料庫相關的物件產生出來,必須每個請求都處理。

受限於這樣的特性,我們就很難再設定測試環境時完成定義,也沒辦法讓所有請求共用相同的實例,那麼在測試跟開發階段都會受到很大的限制。

然而,我們可以利用 tsyringe 的 useFactory 特性,動態的處理這個行為。

 1container.register(
 2	KvConversationRepository,
 3	{
 4		useFactory: (c) => {
 5			const kv = c.resolve(KVNamespace)
 6			return new KvConversationRepository(kv)
 7		}
 8	}
 9)
10
11export default {
12	async fetch(request, env, ctx) {
13		container.register(KVNamespace, { useValue: env.KV })
14
15		// ...
16
17		return routes.handle(request, env, ctx)
18	},
19
20};

依照上述的設計,KvConversationRepository 並不會一開始就被初始化,而是在被使用到時才呼叫,那麼就可以在 KVNamespace 還不存在之前被定義(@injectable() 也有類似的效果)

這樣我們就可以在開發跟測試中獲得更多設計上的彈性,而且每次都會產生新物件實例的特性也一定程度上可以避免副作用(Side Effect)的發生,即使我們在設計上就需要避免。

Cloudflare 在最近的更新已經允許用 import { env } from "cloudflare:workers"; 來直接取用 env 而不需要透過 fetch() 這類 Handler 來獲取。