Go能實現AOP嗎?

捉蟲大師發表於2022-04-19

hello~大家好,我是小樓,今天分享的話題是Go是否能實現AOP?

背景

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

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

image

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

Java是如何實現AOP的

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

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

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

image

在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

看這個專案的介紹:

image

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

image

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

image

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

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)

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

image

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

至於Go AST結構的介紹,也不是本文的重點,而且AST中的型別很多很多,我建議如果你想看的話直接Debug來看,對照原始碼比較清晰。

我們這裡就實現一個簡單的,在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())

輸出效果如下:

image

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

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

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

gofmt test.go

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

image

看到這裡你可能覺得太簡單了,我查了下資料,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

跑一下看看:

image

雖然又是硬編碼,但這不重要,又不是不能用~

image

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

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

image

最後

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

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

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

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

對了,本文的完整測試程式碼這裡可以看到:

https://github.com/lkxiaolou/all-in-one/tree/master/go-in-one/samples/tree

感謝大家,如果有點收穫,點個在看關注吧,我們下期再見。


搜尋關注微信公眾號"捉蟲大師",後端技術分享,架構設計、效能優化、原始碼閱讀、問題排查、踩坑實踐。

相關文章