IOC-golang 的 AOP 原理與應用

阿里云云棲號 發佈 2022-07-05T15:16:56.205997+00:00

AOP 與 IOC 的關係AOP (面向切面編程)是一種編程設計思想,旨在通過攔截業務過程的切面,實現特定模塊化的能力,降低業務邏輯之間的耦合度。這一思路在眾多知名項目中都有實踐。


AOP 與 IOC 的關係

AOP (面向切面編程)是一種編程設計思想,旨在通過攔截業務過程的切面,實現特定模塊化的能力,降低業務邏輯之間的耦合度。這一思路在眾多知名項目中都有實踐。例如 Spring 的切點 PointCut 、 gRPC的攔截器 Interceptor 、Dubbo 的過濾器 Filter。AOP 只是一種概念,這種概念被應用在不同的場景下,產生了不同的實現。 我們首先討論比較具體的 RPC 場景,以 gRPC 為例。

針對一次 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 在依賴注入的過程具備兩個視角,結構提供者和結構使用者。框架接受來自結構提供者定義的結構,並按照結構使用者的要求把結構提供出來。結構提供者只需關注結構本體,無需關注結構實現了哪些接口。而結構使用者需要關心結構的注入和使用方式:是注入至接口?注入至指針?是通過 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,專屬接口包含了結構的全部方法。專屬接口的作用有二:

1、減輕開發者工作量,方便直接通過 API 的方式 Get 到代理結構,方便直接作為欄位注入。

2、結構專屬接口可以直接定位結構 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

作者 | 李志信(冀鋒)

原文連結:http://click.aliyun.com/m/1000347831/

本文為阿里雲原創內容,未經允許不得轉載。

關鍵字: