面向切面編程?Go 能實現 AOP 嗎?

背景

寫 Java 的同學來寫 Go 就特別喜歡將兩者進行對比,經常看到技術羣裏討論,比如 Go 能不能實現 Java 那樣的 AOP 啊?Go 寫個事務好麻煩啊,有沒有 Spring 那樣的 @Transactional 註解啊?

遇到這樣的問題我通常會回覆:沒有、實現不了、再見。

直到看了《Go 語言底層原理剖析》這本書,我開始了一輪認真地探索。

Java 是如何實現 AOP 的

AOP 概念第一次是在若干年前學 Java 時看的一本書《Spring 實戰》中看到的,它指的是一種面向切面編程的思想。注意它只是一種思想,具體怎麼實現,你看着辦。

AOP 能在你代碼的前後織入代碼,這就能做很多有意思的事情了,比如統一的日誌打印、監控埋點,事務的開關,緩存等等。

可以分享一個我當年學習 AOP 時的筆記片段:

在 Java 中的實現方式可以是JDK動態代理字節碼增強技術

JDK 動態代理是在運行時動態地生成了一個代理類,JVM 通過加載這個代理類再實例化來實現 AOP 的能力。

字節碼增強技術可以多嘮叨兩句,當年學 Java 時第一章就說 Java 的特點是「一次編譯,到處運行」。

但當我們真正在工作中這個特性用處大嗎?好像並不大,生產中都使用了同一種服務器,只編譯了一次,也都只在這個系統運行。做到一次編譯,到處運行的技術底座是 JVM,JVM 可以加載字節碼並運行,這個字節碼是平臺無關的一種二進制中間碼。

似乎這個設定帶來了一些其他的好處。在 JVM 加載字節碼時,字節碼有一次被修改的機會,但這個字節碼的修改比較複雜,好在有現成的庫可用,如 ASM、Javassist 等。

至於像 ASM 這樣的庫是如何修改字節碼的,我還真就去問了 Alibaba Dragonwell 的一位朋友,他回答 ASM 是基於 Java 字節碼規範所做的「硬改」,但做了一些抽象,總體來說還是比較枯燥的。

由於這不是本文重點,所以只是提一下,如果想更詳細地瞭解可自行網上搜索。

Go 能否實現 AOP?

之前用「扁鵲三連」的方式回覆 Go 不能實現 AOP 的基礎其實就是我對 Java 實現 AOP 的思考,因爲 Go 沒有虛擬機一說,也沒有中間碼,直接源碼編譯爲可執行文件,可執行文件基本沒法修改,所以做不了。

但真就如此嗎?我搜索了一番。

運行時攔截

還真就在 Github 找到了一個能實現類似 AOP 功能的庫gohook(當然也有類似的其他庫):

https://github.com/brahma-adshonor/gohook

看這個項目的介紹:

運行時動態地 hook Go 的方法,也就是可以在方法前插入一些邏輯。它是怎麼做到的?

通過反射找到方法的地址(指針),然後插入一段代碼,執行完後再執行原方法。聽起來很牛 X,但它下面有個 Notes:

使用有一些限制,更重要的是沒有完全測試,不建議生產使用。這種不可靠的方式也就不嘗試了。

AST 修改源碼

這種方式就是我在看《Go 語言底層原理剖析》第一章看到的,其實我之前的文章也有寫過關於 AST 的,《Cobar 源碼分析之 AST》

AST 即抽象語法樹,可以認爲所有的高級編程語言源碼都可以抽象爲一種語法樹,即對代碼進行結構化的抽象,這種抽象可以讓我們更加簡單地分析甚至操作源碼。

Go 在編譯時大概分爲詞法與語法分析、類型檢查、通用 SSA 生成和最後的機器代碼生成這幾個階段。

其中詞法與語法分析之後,生成一個 AST 樹,在 Go 中我們能調用 Go 提供的 API 很輕易地生成 AST:

fset := token.NewFileSet()
// 這裏file就是一個AST對象
file, err := parser.ParseFile(fset, "aop.go", nil, parser.ParseComments)

比如這裏我的 aop.go 文件是這樣的:

package main

import "fmt"

func main() {
 fmt.Println(execute("roshi"))
}

func execute(name string) string {
 return name
}

想看生成的 AST 長什麼樣,可調用下面的方法:

ast.Print(fset, file)

由於篇幅太長,我截個圖感受下即可:

當然也有一些開源的可視化工具,但我覺得大可不必,想看的話 Debug 看下file的結構即可。

至於 Go AST 結構的介紹,也不是本文的重點,而且 AST 中的類型很多很多,敘述起來比較枯燥。

我們這裏就實現一個簡單的,在 execute 方法執行之前添加一條打印before的語句,接上述代碼:

const before = "fmt.Println(\"before\")"
...

exprInsert, err := parser.ParseExpr(before)
if err != nil {
 panic(err)
}

decls := make([]ast.Decl, 0, len(file.Decls))

for _, decl := range file.Decls {
 fd, ok := decl.(*ast.FuncDecl)
 if ok {
  if fd.Name.Name == "execute" {
   stats := make([]ast.Stmt, 0, len(fd.Body.List)+1)
   stats = append(stats, &ast.ExprStmt{
    X: exprInsert,
   })
   stats = append(stats, fd.Body.List...)
   fd.Body.List = stats
   decls = append(decls, fd)
   continue
  } else {
   decls = append(decls, decl)
  }
 } else {
  decls = append(decls, decl)
 }
}

file.Decls = decls

這裏 AST 就被我們修改了,雖然我們是寫死了針對 execute 方法,但總歸是邁出了第一步。

再把 AST 轉換爲源碼輸出,Go 也提供了 API:

var cfg printer.Config
var buf bytes.Buffer

cfg.Fprint(&buf, fset, file)

fmt.Printf(buf.String())

輸出效果如下:

看到這裏,我猜你應該有和我相同的想法,這玩意是不是可以用來格式化代碼?

沒錯,Go 自帶的格式化代碼工具 gofmt 的原理就是如此。

當我們寫完代碼時,可以執行 gofmt 對代碼進行格式化:

gofmt test.go

這相比於其他語言方便很多,終於有個官方的代碼格式了,甚至你可以在 IDEA 中安裝一個 file watchers 插件,監聽文件變更,當文件有變化時自動執行 gofmt 來格式化代碼。

看到這裏你可能覺得太簡單了,我查了下資料,AST 中還能拿到註釋,這就厲害了,我們可以把註釋當註解來玩,比如我加了 // before: 的註釋,自動把這個註釋後的代碼添加到方法之前去。

// before:fmt.Println("before...")
func executeComment(name string) string {
 return name
}

修改 AST 代碼如下,爲了篇幅,省略了打印代碼:

cmap := ast.NewCommentMap(fset, file, file.Comments)

for _, decl := range file.Decls {
 fd, ok := decl.(*ast.FuncDecl)
 if ok {
  if cs, ok := cmap[fd]; ok {
   for _, cg := range cs {
    for _, c := range cg.List {
     if strings.HasPrefix(c.Text, "// before:") {
      txt := strings.TrimPrefix(c.Text, "// before:")
      ei, err := parser.ParseExpr(txt)
      if err == nil {
       stats := make([]ast.Stmt, 0, len(fd.Body.List)+1)
       stats = append(stats, &ast.ExprStmt{
        X: ei,
       })
       stats = append(stats, fd.Body.List...)
       fd.Body.List = stats
       decls = append(decls, fd)
       continue
      }
     }
    }
   }
  } else {
   decls = append(decls, decl)
  }
 } else {
  decls = append(decls, decl)
 }
}

file.Decls = decls

跑一下看看:

雖然代碼很醜,但這不重要,又不是不能用~

但你發現,這樣實現 AOP 有個缺點,必須在編譯期對代碼進行一次重新生成,理論上來說,所有高級編程語言都可以這麼操作。

但這不是說毫無用處,比如這篇文章《每個 gopher 都需要了解的 Go AST》就給了我們一個實際的案例:

最後

寫到最後,我又在思考另一個問題,爲什麼 Go 的使用者沒有 AOP 的需求呢?反倒是寫 Java 的同學會想到 AOP。

我覺得可能還是 Go 太年輕了,Java 之所以要用 AOP,很大的原因是代碼已經堆積如山,沒法修改,歷史包袱沉重,最小代價實現需求是首選,所以會選擇 AOP 這種技術。

反觀 Go 還年輕,大多數項目屬於造輪子期間,需要 AOP 的地方早就在代碼中提前埋伏好了。我相信隨着發展,一定也會出現一個生產可用 Go AOP 框架。

至於現在問我,Go 能否實現 AOP,我還是回答:沒有、實現不了、再見。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/GS_3KyB-F5EeieuLYRRrNg