自動生成重複代碼:使用 Go 的 Template

開發軟體時,常常會發現有些函式或方法很類似,例如對 Callback Function 來說,開發者都需要註冊回調函式,並在適當的時機,將資料交給回調函式處理,我們可以將這兩個動作,稱為 OnAction 跟 EmitAction。儘管繼承或組合能讓程式碼重複使用類似的組件,幫助開發者節省時間,但對於較複雜的情況,像是不同類(Class)的函式名稱也要不同時,仍需要仰賴開發者自行編寫。

試著想想,如果開發者僅僅寫設定檔(Config File),就有程式能根據設定檔來自動產生程式碼,不是很美好的事嗎?這是有的,在實務上,這類用於產生程式的程式被稱為 Code Generator,例如前面介紹過的 genny。Golang 有內建 generate 這個命令行工具,能幫助開發者將 Code Generator 跟編譯更密切結合在一起。

本文會用 Callback 的 Generator 當例子,講解如何開發並使用一套 Code Generator。需要 Clone 程式碼的,可以到這裡

Design a Config File

我們的目標是設計一套程式,可以依照 Config 來產生 Callback Function,Callback Function 能根據 Config,而有不同的名字跟引數型別。對像 Golang 這類強型別又沒有泛型的語言來說,這是很實用的功能。

先看專案結構

.
├── Makefile
├── cmd
│   ├── codegen
│   │   └── main.go
│   └── example
├── config
│   └── callback.json
└── go.mod

codegen 內的 main.go 是主要程式碼,也就是 Code Generator;而 config 用於放置需要的設定檔。

Config 應該長怎樣呢?我們希望它是一個陣列,這樣就能將每個元素對應到不同的 Callback,元素應該是個物件,包含 Name 跟 Arg 兩個不同的鍵,如果用 JSON 格式來表達,它會長

[
    {
        "EventName": "Click",
        "CallbackArg": "int"
    },
    {
        "EventName": "Move",
        "CallbackArg": "uint32"
    }
]

EventName 是 Callback 的名稱;CallbackArg 是 Callback 的引數型別。

來看主程式的部分,在 main.go 中讀進 config,並 Parse 成 Struct

package main

import (
    "encoding/json"
    "fmt"
    "io/ioutil"
    "os"
    "strings"
    "text/template"
)

type schema struct {
    EventName   string
    CallbackArg string
}

func main() {
    var schemas []schema
    data, err := ioutil.ReadFile("../../config/callback.json")
    if err != nil {
        panic(err)
    }
    err = json.Unmarshal(data, &schemas)
    if err != nil {
        panic(err)
    }
}

先建立個 Struct 來對應 config

type schema struct {
    EventName   string
    CallbackArg string
}

再來讀檔案

data, err := ioutil.ReadFile("../../config/callback.json")
if err != nil {
    panic(err)
}

接著反序列化 JSON,放進 array 中

var schemas []schema
err = json.Unmarshal(data, &schemas)
if err != nil {
    panic(err)
}

完成對 config 的讀取。

Design Template

接著來設計 template,可以把它想像成是個餅乾模具,只要將麵團塞進模具放入烤箱烘烤,等待出爐後就是熱騰騰的餅乾。如果要換口味,也只需要調整麵團的配方,不需要動到模具。對照到程式碼,它可以看成是一段重複的程式碼原型,某些段落可以塞進變數,模板引擎會根據模板來渲染,達到客制化效果。

讓人驚奇的是,Golang 有內建 template,這再次讓人感受到 Golang 在應用開發上的優勢。

在原本的專案下,加入 template

.
├── Makefile
├── cmd
│   ├── codegen
│   │   └── main.go
│   └── example
├── config
│   └── callback.json
├── go.mod
└── tmpl
    ├── callbackTemplate.tmpl
    ├── contextTemplate.tmpl
    └── main.tmpl

main.tmpl 用於產生主程式;contextTemplate.tmpl 用以產生一個 Struct,內有需要的 callback 函式;callbackTemplate.tmpl 用來產生 callback 的註冊及調用。

先來看要怎麼調用模板引擎,修改 main.go

t := template.Must(template.New("main.tmpl").ParseFiles("../../tmpl/main.tmpl"))
t = template.Must(t.ParseFiles("../../tmpl/callbackTemplate.tmpl"))
t = template.Must(t.ParseFiles("../../tmpl/contextTemplate.tmpl"))
err = t.Execute(os.Stdout, schemas)
if err != nil {
    fmt.Println(err)
}

使用 text/template 函式庫,在 ParseFiles 由檔案引入模板,然後用 Execute 來渲染模板,輸出的結果先導到 stdout,顯示於終端機畫面,如果開發者有需要,可以將它再重新導向到檔案中。

要注意 Execute 的第二個引數是 schemas ,這是由 config 中讀出的數據,也是要傳給模板的值。

來看看模板的內容,main.tmpl 是

package main

{{ template "contextTemplate.tmpl" . }}
{{ template "callbackTemplate.tmpl" . }}

用 {{ action }} 框起來的是模板的 action,可以當成是模板語法,{{ template }} 意思是引入其他模板,在 main.tmpl 引入其他兩個模板; . 是調用程式傳入的參數。

main.tmpl 內的 contextTemplate.tmpl 是另一個子模板,用於產生 struct

// Context can be used to callback
type Context struct {
{{- range . }}
    {{ .EventName }}Callback func({{ .CallbackArg }})
{{- end }}
}

其中 range 是模板語言的迴圈,類似 Golang 的 for range; - 是省略 {{}} 前的空白,避免 action 干擾到模板。.EventName 跟 .CallbackArg 是傳入結構底下的欄位,也就是 config 中 EventName 跟 CallbackArg 的值。

經過 contextTemplate.tmpl 後,預期可以生成

// Context can be used to callback
type Context struct {
    ClickCallback func(int)
}

Click 跟 int 都是依照傳入參數而建立。

main.tmpl 內的另一個子模板 callbackTemplate.tmpl 用於產生 function

{{- range . }}
// On{{ .EventName }} register a callback function
func (c *Context) On{{ .EventName }}(callback func(arg {{ .CallbackArg }})) {
    c.{{ .EventName }}Callback = callback
}

// Emit{{ .EventName }} emit a callback event
func (c *Context) Emit{{ .EventName }}(arg {{ .CallbackArg }}) {
    c.{{ .EventName }}Callback(arg)
}
{{ end }}

經過 callbackTemplate.tmpl 後,可以生成

// OnClick register a callback function
func (c *Context) OnClick(callback func(arg int)) {
    c.clickCallback = callback
}

// EmitClick emit a callback event
func (c *Context) EmitClick(arg int) {
    c.clickCallback(arg)
}

如此一來,就完成模板的設計了。

Add Template Action

儘管 Golang 模板有內建很多 action,但還是可能找不到想要的功能,例如,在 Golang 語法中,Struct Field 的名字首字母如果是大寫,意謂該 Field 是 Public,但另一方面,開發者又會希望 Function Name 要 Follow Camal Method。對應到需求是,如果 EventName 有時能大寫,有時能小寫,那就兩全其美了。

這時就是自定義 action 派上用場的時機了,要加入自定義 action,可以回去修改 main.go,加入

funcLowerCase := template.FuncMap{"lower": strings.ToLower}
t := template.Must(template.New("main.tmpl").Funcs(funcLowerCase).ParseFiles("../../tmpl/main.tmpl"))

用 FuncMap 建立一個鍵值對,key 是 action 的名字,value 是 action 的執行內容,這邊使用 strings 下的 ToLower 幫忙做大小寫轉換。

建立後的 func 可以用 Funcs 帶進模板中。

再來修改模板 contextTemplate.tmpl

{{ .EventName | lower }}Callback func({{ .CallbackArg }})

| 是 pipeline,可以將前項的輸出跟後項的輸入用管道連接起來,放在這邊,意思是將 EventName 傳給 lower,而 lower 正是剛剛建立的模板 action。

於是渲染效果變成

// Context can be used to callback
type Context struct {
    clickCallback func(int)
}

處理好 scope 的問題了。

Try It

開發完成後,來看看如何使用。

一開始,先用 go run 來驗證程式是否正確

ken@DESKTOP-2R08VK6:~/git/medium-example-golang/codegen/cmd/codegen$ go run main.go

得到

package main

// Context can be used to callback
type Context struct {
        clickCallback func(int)
        moveCallback func(uint32)
}

// OnClick register a callback function
func (c *Context) OnClick(callback func(arg int)) {
        c.clickCallback = callback
}
...

內容正確!將輸出導出到 context.go 的檔案中

go run main.go > ./context.go

在專案中新增使用的程式

.
├── Makefile
├── cmd
│   ├── codegen
│   │   └── main.go
│   └── example
│       └── main.go
├── config
│   └── callback.json
├── go.mod
└── tmpl
    ├── callbackTemplate.tmpl
    ├── contextTemplate.tmpl
    └── main.tmpl

example/main.go 的內容是

package main

import "fmt"

func main() {
    printNum := func(num int) {
        fmt.Println(num)
    }
    context := &Context{}
    context.OnClick(printNum)
    context.EmitClick(5)
}

使用 Context,並將 Println 註冊為 OnClick 的 callback,使用 EmitClick 將資料發送給 callback,OnClick 收到後就會將資料印出。

在本例中,發送的資料是 5

接著在檔案中加入 go:generate,讓 go generate 可以執行註解內容,產生 context.go

//go:generate bash -c "go run ../codegen/main.go > ./context.go"

go generate ./...

context.go 就被生出來了。

接著編譯並執行 example

ken@DESKTOP-2R08VK6:~/git/medium-example-golang/codegen$ ./bin/example 5

成功印出 5。

小結

Go 命令行工具的 generate 原本是為了方便整合外部工具,讓 Golang 編譯更順利,搭配 template 使用後,變成 Code Generator 的利器。

對於希望簡化程式碼,讓程式碼更具彈性,只要修改 config 即可完成擴充的人來說,Code Generator 會是個不錯的工具。實際上,以 Golang 目前沒有泛型的狀況來看,Code Generator 應該是一條開發捷徑;要注意的是,Code Generator 的用途是面向開發者,如果今天目的是要讓其他人容易使用,應該也要試試看用 Reflect 開發。

Reference

Read more

Weekly Issue 第 21 期:JetBrains 發表 2025 Go 生態系調查

最近在讀 Tony Fadell 的 "Build",作者曾經參與過 iPhone 的開發,各種經驗談讓人嘆為觀止,例如這段:「如果故事有某個部分銜接不上,那麼產品本身也會有某個地方行不通…這便是為什麼最後 iPhone 的表面是玻璃,而不是塑膠,以及為什麼 iPhone 沒有硬體鍵盤。」 好在哪呢?好在如果能掌握這個觀念,就能知道如何「閱讀」產品,看見一個產品,就像閱讀一則故事一樣,知道它的抑揚頓挫,知道它想表現的東西。我相信每個經歷過產品開發的人,看這本書都會很有感覺。   🗞️ 熱門新聞 The Go Ecosystem in 2025: Key Trends in Frameworks, Tools, and Developer Practices JetBrains 前陣子公布 Go 生態系的調查結果。

By Ken Chen

Weekly Issue 第 20 期:AI 泡沫的遺產

2000 年的 .com 泡沫雖然造成嚴重的經濟問題,但也給後續的網路世代留下豐富的遺產。我們現在使用的網路基礎建設,很多是因為泡沫的原因,才能一次性投資到位。而當下經歷的 AI 浪潮,在時間過去後,又會給我們留下什麼遺產呢? 🗞️ 熱門新聞 The Benefits of Bubbles 我看 Ben Thompson 的文章通常會有兩種感受,負面是他太囉唆了,把簡單的觀念講得太長(儘管容易懂),而正面是他的觀點一向很有創造性。 這篇也是,前陣子看到有篇談 AI 泡沫後,什麼都不會留下,因為 GPU 很快會隨著時間折舊掉。我持保留態度,我認為重點不僅是 GPU(正如我認為 .com 泡沫的重點不是 CPU),還有其他的東西,至於是什麼,我沒想到。 BT 認為是晶圓製造與電力,It's amazing,

By Ken Chen

Weekly Issue 第 19 期:Coursera 的預覽模式宣告 MOOC 終結

我有時會上課程網站買課,特別是國外的網站,有些課程內容品質高,而且還能無價體驗,我常常在想這在商業上怎麼行得通。Coursera 最近推出預覽功能,某方面來說,也是在宣告長期要往付費走。 網路最大的特點是開放,因為開放,我們看到不可思議的成長,也因為開放,我們有時會很惋惜理想的落幕。 🗞️ 熱門新聞 The Day MOOCs Truly Died: Coursera's Preview Mode Kills Free Learning 很有趣的一篇新聞:Coursera 的預覽模式給了 MOOC 最後一擊。 我對 Coursera 的商業模式不熟,看起來它之前是靠證書與服務營利。很難想像線上課程能用免費支撐這麼久,這幾乎是公益了,將內容鎖在付費牆後比較像可理解的商業行為。 讓我困惑的是,這些年 Coursera 是如何獲利?以及,當時投資人對它的想像是什麼? The PSF has withdrawn

By Ken Chen

Weekly Issue 第 18 期:OpenAI 發布 AI 瀏覽器 Atlas

OpenAI 最近發布 AI 瀏覽器,加上稍早的 Sora 2,在技術圈中引起一些討論。 我認為 OpenAI 嘗試將模型領域的優勢帶到應用面,但這也讓它顯得更像是一家營利公司,而非研究單位(雖然現在沒人會把 OpenAI 當成研究單位了)。 🗞️ 熱門新聞 Dane Stuckey (OpenAI CISO) on prompt injection risks for ChatGPT Atlas Simon Willison 聊了他對 OpenAI Altas 的看法,主要是資安方面。 幾個點:1) 提示詞注入問題依然存在,而且還沒有好解法;2) OpenAI 設計了登出模式與監視模式,讓使用者更容易意識到安全性。 在我看來第二點很重要,好設計應該要避免使用者犯錯,如果 AI 瀏覽器可以在登出狀態下執行,能避免掉很多麻煩的狀況,當然這意味著沒辦法自動購物。

By Ken Chen