自動生成重複代碼:使用 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 第 7 期:從 GitHub Spark 看 Prompt 工程

近期開始有人建議用 Context Engineering 來取代 Prompt Engineering,的確相較於 Prompt,Context 是更精確的用詞。前一期也提到,當 Duolingo 的 CEO 被問到 AI 是否只是模型套皮時,他也說模型一定有影響,但更多是關乎你的 Context。 那麼,業界現在是如何看待 Prompt 的呢?Github Spark 跟 V0 的例子或許能提供一些參考。 🗞️ 熱門新聞 Using GitHub Spark to reverse engineer GitHub Spark GitHub Spark 最近推出公開預覽,讓你可以用 prompt 直接開發應用。 作者用逆向工程,找出 Spark 的 system

By Ken Chen

Weekly Issue 第 6 期:Duolingo CEO 看 AI 與遊戲化

現在是 AI 時代,大家都在想怎麼讓自己的產品跟 AI 掛勾,但具體要怎麼做呢?背後的思考有哪些?Duolingo 給出他們自己的觀點。 例如,現在的產品是否只是 AI 套皮,你接收使用者的問題,套上自己的提詞後,拿去給 OpenAI,要它回答你?在現在百家爭鳴的情況下,選擇哪個模型會有差嗎?AI 能帶來新用戶與新營收嗎?等等。 另外本週也選了一篇少數派的文章,談 AI 對 RSS 的影響,對 RSS 未來方向有興趣的人不妨看看。 🗞️ 熱門新聞 Duolingo CEO Luis von Ahn wants you addicted to learning Duolingo CEO 專訪,相當紮實,推薦閱讀。 「對我們來說,

By Ken Chen

Weekly Issue 第 5 期:OpenAI 的企業文化

我一直都喜歡看科技公司的願景與文化,原因是,我想知道別人是如何看待自己的使命,又是用什麼方式打造它。願景通常在官網都會有,但想要知道文化,只能聽內部人講講了。 Palantir 前陣子因為它不同於矽谷的文化,而引起很多討論。受此影響,前 OpenAI 的員工在離職創辦公司後,也發文談論他所見到的 OpenAI。最讓我震撼的是,他們幾乎沒有資金困擾,想的都是如何打造出色的 AI 模型。 🗞️ 熱門新聞 Reflections on OpenAI 前員工談 OpenAI 的內部文化。 讀起來最大的感觸是,有些價值觀、觀點、實踐,只有在世界級的公司跟資源下,才有可能建立起來。讓每個團隊各自為政,看誰能端出最好的成果,這對新創(特別是沒拿創投)實在太奢侈了。 我相信這種經歷會變成是「可以帶著走的饗宴」,那種衝擊也是最寶貴的。 AI Open Source Productivity METR 前陣子發了一篇研究,說使用 AI

By Ken Chen

Weekly Issue 第 4 期:Canonical 的面試經驗

這星期看了比較多職涯相關的內容,最讓我驚訝的是 Canonical 的面試流程,當我分享這則新聞後,有更多朋友紛紛補充他們的面試經驗:需要經歷三個 Tier,每個 Tier 都有三關,而內容甚至還包括問人選「高中成績」與「大學生活」。 我很難想像一家做 Linux 發行版的公司,會如此草率對待人選,這讓我對他們家的產品有了很大的問號。 🗞️ 熱門新聞 My experience with Canonical's interview process 這是一篇 Canonical 的面試經歷(如果你不知道什麼是 Canonical,就是開發 Ubuntu 的公司)。 整個過程讓人非常驚訝,甚至還需要人選回答「高中成績」,而在面試中做筆記居然是扣分項。我看完後有股移除 Ubuntu 的衝動。真的太扯啦。 What happens when engineers work

By Ken Chen