作者 | 李志信(冀鋒)
?AOP 與 IOC 的關系
AOP (面向切面編程)是一種編程設計思想,旨在通過攔截業務過程的切面,實現特定模塊化的能力,降低業務邏輯之間的耦合度。這一思路在眾多知名項目中都有實踐。例如 Spring 的切點 PointCut 、 gRPC的攔截器 Interceptor 、Dubbo 的過濾器 Filter。AOP 只是一種概念,這種概念被應用在不同的場景下,產生了不同的實現。
我們首先討論比較具體的 RPC 場景,以 gRPC 為例。
圖片摘自 grpc.io
針對一次 RPC 過程,gRPC 提供了可供用戶擴展的 Interceptor 接口,方便開發者寫入與業務相關的攔截邏輯。例如引入鑒權、服務發現、可觀測等能力,在 gRPC 生態中存在很多基于 Interceptor 的擴展實現,可參考 go-grpc-middleware[1]。這些擴展實現歸屬于 gRPC 生態,限定于 Client 和 Server 兩側的概念,限定于 RPC 場景。
我們將具象的場景抽象化,參考 Spring 的做法。
Spring 具備強大的依賴注入能力,在此基礎之上,提供了適配與業務對象方法的 AOP 能力,可以通過定義切點,將攔截器封裝在業務函數外部。這些 “切面”、“切點” 的概念,都是限定于 Spring 框架內,由其依賴注入(也就是 IOC)能力所管理。
我想表達的觀點是,AOP 的概念需要結合具體場景落地,必須受到來自所集成生態的約束。我認為單獨提 AOP 的概念,是不具備開發友好性和生產意義的,例如我可以按照面向過程編程的思路,寫一連串的函數調用,也可以說這是實現了 AOP,但其不具備可擴展性、可遷移性、更不具備通用性。這份約束是必要的,可強可弱,例如 Spring 生態的 AOP,較弱的約束具備較大的可擴展性,但實現起來相對復雜,發者需要學習其生態的眾多概念與 API,再若 Dubbo 、gRPC 生態的適配于 RPC 場景的 AOP,開發者只需要實現接口并以單一的 API 注入即可,其能力相對局限。
上述 “約束” 在實際開發場景可以具象為依賴注入,也就是 IOC。開發者需要使用的對象由生態所納管、封裝,無論是 Dubbo 的 Invoker、還是 Spring 的 Bean,IOC 過程為 AOP 的實踐提供了約束借口,提供了模型,提供了落地價值。
Go 生態與 AOP
AOP 概念與語言無關,雖然我贊成使用 AOP 的最佳實踐方案需要 Java 語言,但我不認為 AOP 是 Java 語言的專屬。在我所熟悉的 Go 生態中,依然有較多基于 AOP 思路的優秀項目,這些項目的共性,也如我上一節所闡述的,都是結合特定生態,解決特定業務場景問題,其中解決問題的廣度,取決于其 IOC 生態的約束力。IOC 是基石,AOP 是 IOC 生態的衍生物,一個不提供 AOP 的 IOC 生態可以做的很干凈很清爽,而一個提供 AOP 能力的 IOC 生態,可以做的很包容很強大。
上個月我開源了 IOC-golang [2]服務框架,專注于解決 Go 應用開發過程中的依賴注入問題。很多開發者把這個框架和 Google 開源的 wire [3]框架做比較,認為沒有 wire 清爽好用,這個問題的本質是兩個生態的設計初衷不同。wire 注重 IOC 而非 AOP,因此開發者可以通過學習一些簡單的概念和 API,使用腳手架和代碼生成能力,快速實現依賴注入,開發體驗很好。IOC-golang 注重基于 IOC 的 AOP 能力,并擁抱這一層的可擴展性,把 AOP 能力看作這一框架和其他 IOC 框架的差異點和價值點。
相比于解決具體問題的 SDK,我們可以把依賴注入框架的 IOC 能力看作“弱約束的IOC場景”,通過兩個框架差異點比較,拋出兩個核心的問題:
Go 生態在 “弱約束 IOC 的場景” 需不需要 AOP?
GO 生態在 “弱約束 IOC 的場景” 的 AOP 可以用來做什么?
我的觀點是:Go 生態一定是需要 AOP 的,即使在“弱約束 IOC 場景”,依然可以使用 AOP 來做一些業務無關的事情,比如增強應用的運維可觀測能力。由于語言特性,Go 生態的 AOP 不能和 Java 劃等號,Go 不支持注解,限制了開發者使用編寫業務語義 AOP 層的便利性,所以我認為 Go 的 AOP 并不適合處理業務邏輯,即使強行實現出來,也是反直覺的。我更接受把運維可觀測能力賦予 Go 生態的 AOP 層,而開發者對于 AOP 是無感知的。
例如,對于任何接口的實現結構,都可以使用 IOC-golang 框架封裝運維 AOP 層,從而讓一個應用程序的所有對象都具備可觀測能力。除此之外,我們也可以結合 RPC 場景、服務治理場景、故障注入場景,產生出更多 “運維” 領域的擴展思路。
IOC-golang 的 AOP 原理
使用 Go 語言實現方法代理的思路有二,分別為通過反射實現接口代理,和基于 Monkey 補丁的函數指針交換。后者不依賴接口,可以針對任何結構的方法封裝函數代理,需要侵入底層匯編代碼,關閉編譯優化,對于 CPU 架構有要求,并且在處理并發請求時會顯著削弱性能。
前者的生產意義較大,依賴接口,也是本節所討論的重點。
3.1 IOC-golang 的接口注入
在本框架開源的第一篇文章中有提到,IOC-golang 在依賴注入的過程具備兩個視角,結構提供者和結構使用者??蚣芙邮軄碜越Y構提供者定義的結構,并按照結構使用者的要求把結構提供出來。結構提供者只需關注結構本體,無需關注結構實現了哪些接口。而結構使用者需要關心結構的注入和使用方式:是注入至接口?注入至指針?是通過 API 獲???還是通過標簽注入獲???
- 通過標簽注入依賴對象
// +ioc:autowire=true
// +ioc:autowire:type=singleton
type App struct {
// 將實現注入至結構體指針
ServiceStruct *ServiceStruct `singleton:""`
// 將實現注入至接口
ServiceImpl Service `singleton:"main.ServiceImpl1"`
}
App 的 ServiceStruct 字段是具體結構的指針,字段本身已經可以定位期望被注入的結構,因此不需要在標簽中給定期望被注入的結構名。對于這種注入到結構體指針的字段,無法通過注入接口代理的方式提供 AOP 能力,只能通過上文提到的 monkey 補丁方案,這種方式不被推薦。
App 的 ServiceImpl 字段是一個名為 Service 的接口,期望注入的結構指針是 main.ServiceImpl。本質上是一個從結構到接口的斷言邏輯,雖然框架可以進行接口實現的校驗,但仍需要結構使用者保證注入的接口實現了該方法。對于這種注入到接口的方式,IOC-golang 框架自動為 main.ServiceImpl 結構創建代理,并將代理結構注入在 ServiceImpl 字段,因此這一接口字段具備了 AOP 能力。
因此,ioc 更建議開發者面向接口編程,而不是直接依賴具體結構,除了 AOP 能力之外,面向接口編程也會提高 go 代碼的可讀性、單元測試能力、模塊解耦合程度等。
- 通過 API 的方式獲取對象
IOC-golang 框架的開發者可以通過 API 的方式獲取結構指針,通過調用自動裝載模型(例如singleton)的 GetImpl 方法,可以獲取結構指針。
func GetServiceStructSingleton() (*ServiceStruct, error) {
i, err := singleton.GetImpl("main.ServiceStruct", nil)
if err != nil {
return nil, err
}
impl := i.(*ServiceStruct)
return impl, nil
}
使用 IOC-golang 框架的開發者更推薦通過API 的方式獲取接口對象,通過調用自動裝載模型(例如singleton)的 GetImplWithProxy 方法,可以獲取代理結構,該結構可被斷言為一個接口供使用。這個接口并非結構提供者手動創建,而是由 iocli 自動生成的“結構專屬接口”,在下文中將予以解釋。
func GetServiceStructIOCInterfaceSingleton() (ServiceStructIOCInterface, error) {
i, err := singleton.GetImplWithProxy("main.ServiceStruct", nil)
if err != nil {
return nil, err
}
impl := i.(ServiceStructIOCInterface)
return impl, nil
}
這兩種通過 API 獲取對象的方式可以由 iocli 工具自動生成,注意,這些代碼的作用都是方便開發者調用 API ,減少代碼量,而 ioc 自動裝載的邏輯內核并不是由工具生成的,這是與 wire 提供的依賴注入實現思路的不同點之一,也是很多開發者誤解的一點。
- IOC-golang 的結構專屬接口。
通過上面的介紹,我們知道 IOC-golang 框架推薦的 AOP 注入方式是強依賴接口的。但要求開發者為自己的全部結構,都手寫一個與之匹配的接口出來,這會耗費大量的時間。因此 iocli 工具可以自動生成結構專屬接口,減輕開發人員的代碼編寫量。
例如一個名為 ServiceImpl 的結構,其包含 GetHelloString 方法。
// +ioc:autowire=true
// +ioc:autowire:type=singleton
type ServiceImpl struct {
}
func (s *ServiceImpl) GetHelloString(name string) string {
return fmt.Sprintf("This is ServiceImpl1, hello %s", name)
}
當執行 iocli gen 命令后, 會在當前目錄生成一份代碼zz_generated.ioc.go 其中包含該結構的“專屬接口”:
type ServiceImplIOCInterface interface {
GetHelloString(name string) string
}
專屬接口的命名為 $(結構名)IOCInterface,專屬接口包含了結構的全部方法。專屬接口的作用有二:
- 減輕開發者工作量,方便直接通過 API 的方式 Get 到代理結構,方便直接作為字段注入。
- 結構專屬接口可以直接定位結構 ID,因此在注入專屬接口的時候,標簽無需顯式指定結構類型:
// +ioc:autowire=true
// +ioc:autowire:type=singleton
type App struct {
// 注入 ServiceImpl 結構專屬接口,無需在標簽中指定結構ID
ServiceOwnInterface ServiceImplIOCInterface `singleton:""`
}
因此,隨便找一個現有的 go 工程,其中使用結構指針的位置,我們推薦替換成結構專屬接口,框架默認注入代理;對于其中已經使用了接口的字段,我們推薦直接通過標簽注入結構,也是由框架默認注入代理。按照這種模式開發的工程,其全部對象都將具備運維能力。
3.2 代理的生成與注入
上一小節所提到的“注入至接口”的對象,都被被框架默認封裝了代理,具備運維能力,并提到了 iocli 會為所有結構產生“專屬接口”。在本節中,將解釋框架如何封裝代理層,如何注入至接口的。
- 代理結構的代碼生成與注冊
在前文提到生成的 zz.generated.ioc.go 代碼中包含結構專屬接口,同樣,其中也包含結構代理的定義。還是以上文中提到的 ServiceImpl 結構為例,它生成的代理結構如下:
type serviceImpl1_ struct {
GetHelloString_ func(name string) string
}
func (s *serviceImpl1_) GetHelloString(name string) string {
return s.GetHelloString_(name)
}
代理結構命名為小寫字母開頭的 $(結構名)_,其實現了“結構專屬接口” 的全部方法,并將所有方法調用代理至 $(方法名)_ 的方法字段,該方法字段會被框架以反射的方式實現。
與結構代碼一樣,代理結構也會在這個生成的文件中注冊到框架:
func init(){
normal.RegisterStructDescriptor(&autowire.StructDescriptor{
Factory: func() interface{} {
return &serviceImpl1_{} // 注冊代理結構
},
})
}
- 代理對象的注入
上述內容描述了代理結構的定義和注冊過程。當用戶期望獲取封裝了AOP層的代理對象,將首先加載真實對象,然后嘗試加載代理對象,最終通過反射實例化代理對象,注入接口,從而賦予接口運維能力。該過程可由下圖展示:
IOC-golang 基于 AOP 的應用
理解了上文中提到的實現思路,我們可以認為,使用 IOC-golang 框架開發的應用程序中,從框架注入、獲取的所有接口對象都是具備運維能力的。我們可以基于 AOP 的思路,擴展出我們期望的能力。我們提供了一個簡易的電商系統 demo shopping-system[4],展示了在分布式場景下 IOC-golang 基于 AOP 的可視化能力。感興趣的開發者可以參考 README,在自己的集群里運行這個系統,感受其運維能力底座。
4.1 方法、參數可觀測
- 查看應用接口和方法
% iocli list
github.com/alibaba/ioc-golang/extension/autowire/rpc/protocol/protocol_impl.IOCProtocol
[Invoke Export]
github.com/ioc-golang/shopping-system/internal/auth.Authenticator
[Check]
github.com/ioc-golang/shopping-system/pkg/service/festival/api.serviceIOCRPCClient
[ListCards ListCachedCards]
- 監聽調用參數
通過 iocli watch命令, 我們可以監聽鑒權接口的 Check 方法的調用:
iocli watch github.com/ioc-golang/shopping-system/internal/auth.Authenticator Check
- 發起針對入口的調用
curl -i -X GET 'localhost:8080/festival/listCards?user_id=1&num=10'
可查看到被監聽方法的調用參數和返回值,user id 為1。
% iocli watch github.com/ioc-golang/shopping-system/internal/auth.Authenticator Check
========== On Call ==========
github.com/ioc-golang/shopping-system/internal/auth.Authenticator.Check()
Param 1: (int64) 1
========== On Response ==========
github.com/ioc-golang/shopping-system/internal/auth.Authenticator.Check()
Response 1: (bool) true
4.2 全鏈路追蹤
基于 IOC-golang 的 AOP 層,可以提供用戶無感知、業務無侵入的分布式場景下全鏈路追蹤能力。即一個由本框架開發的系統,可以以任何一個接口方法為入口,采集到方法粒度的跨進程調用全鏈路。
基于 shopping-system 的全鏈路耗時信息,可以排查到名為 festival 進程的 gorm.First() 方法是系統的瓶頸。
這個能力的實現包括兩部分,分別是進程內的方法粒度鏈路追蹤,和進程之間的 RPC 調用鏈路追蹤。IOC 旨在打造開發者開箱即用的應用開發生態組件,這些內置的組件與框架提供的 RPC 能力都具備了運維能力。
- 基于 AOP 的進程內鏈路追蹤
IOC-golang 提供的鏈路追蹤能力的進程內實現,是基于 AOP 層做的,為了做到業務無感知,我們并沒有通過 context 上下文的方式去標識調用鏈路,而是通過 go routine id 進行標識。通過 go runtime 調用棧,來記錄當前調用相對入口函數的深度。
- 基于 IOC 原生 RPC 的進程間鏈路追蹤
IOC-golang 提供的原生 RPC 能力,無需定義 IDL文件,只需要為服務提供者標注 // +ioc:autowire:type=rpc ,即可生成相關注冊代碼和客戶端調用存根,啟動時暴露接口??蛻舳酥恍枰脒@一接口的客戶端存根,即可發起調用。這一原生 RPC 能力基于 json 序列化和 http 傳輸協議,方便承載鏈路追蹤 id。
展望
IOC-golang 開源至今已經突破 700 star,其熱度的增長超出了我的想象,也希望這個項目能帶來更大的開源價值與生產價值,歡迎越來越多的開發者參與到這個項目的討論和建設中。
參考鏈接:
[1]https://github.com/grpc-ecosystem/go-grpc-middleware
[2]https://github.com/alibaba/ioc-golang
[3]https://github.com/google/wire
[4]https://github.com/ioc-golang/shopping-system