訊息的處理架構:路由與中間層模式

進行後端程式開發時,常會使用到 Golang 的 http 標準庫。例如要對某個 URL 進行處理時,會用到

http.Handle("/foo", fooHandler)

可以想像,如果沒有 http 庫,就需要使用大量的 if else 來對 URL 判斷,大幅降低程式碼可讀性。http 庫對 URL 進行 function 註冊的設計模式,不僅可以使用在後端開發,也能應用於更普遍的情境。對於要對同類型資訊進行不同處理的例子,都可以使用這個設計模式。

本文會用 Golang 來開發一個類似 http middleware 的程式,處理自訂的訊息格式,藉由這過程,也順便走一次 http 庫的路由設計原理。

Build a Go Module

依照前篇提過的專案目錄結構,我們的使用情境是:(1)有個主程式負責主要邏輯;(2)有關路由跟 middleware 的內部邏輯,使用 package 來處理。因此專案目錄會是

project
├── cmd  # main applications for this project.
|   └── main
|      └── main.go
├── pkg  # code that's ok to use by external applications
|   └── route
|      └── route.go
├── scripts
|   └── build_win.bat
|── go.mod
└── README.md

go.mod 是由 go moudle 產生出來的文件,這是 Golang 在 1.11 後推出的套件管理工具,讓開發者可以不用依照 GOPATH 的結構來安排專案(原本使用 GOPATH 大約是因為 Google 用 Monorepo 來管理專案,而 Golang 沿襲著 Google 的風格)。

要將當前的專案目錄建立為 Module,只需要用 CLI 寫入

go mod init router

go module 會自動生成 go.mod,記錄模組與相依情況,這裡的 router 是模組名稱,如果這個模組會放到 github,可以用你的 github 網址作為模組名稱,更符合 Golang 的設計

module router

go 1.14

然後,因為專案目錄變得比較複雜了,也建立一個 batch file 來幫忙 build code

[@echo](http://twitter.com/echo) off

if not exist bin (
    mkdir bin
)

for /f "usebackq" %%i in (`dir /b /on /a:d .\cmd`) do (
    echo %%i
    go build -o ./bin/%%i.exe ./cmd/%%i
)

執行這個 batch file 後,會建立 bin 資料夾,並依照 cmd 下所有的資料夾名稱,個別建立執行檔。由於現在有 main.go,會生成對應的 ./bin/main.exe。

Design Router

在 pkg 下設計一個簡單的路由模組,負責將不同的訊息路由到對應的處理函式,路由器的設計可以長這樣

package route

// Message is message
type Message struct {
    Identification string
    Time           string
    Content        string
    Size           int
}

// Handler is handler
type Handler func(res, req *Message)

// Router is router
type Router struct {
    mux map[string]Handler
}

// NewRouter new a router
func NewRouter() *Router {
    return &Router{
    mux: make(map[string]Handler),
    }
}

// Add a route
func (r *Router) Add(route string, h Handler) {
    var mergedHandler = h
    r.mux[route] = mergedHandler
}

// Run router
func (r *Router) Run(res, req *Message) error {
    route := req.Identification
    handler, exists := r.mux[route]
    if exists {
    handler(res, req)
    }
    return nil
}

一段一段來看

首先設計一個訊息格式,視具體的應用需求,用來乘載要處理的資訊。這邊假設訊息格式中帶有辨識用的 Identification,如果是 http,可能是請求資源用的 URL。其他欄位可以是不同的 attribute,例如時間、內容、訊息大小等等。

// Message is message
type Message struct {
    Identification string
    Time           string
    Content        string
    Size           int
}

使用 type 的方式,定義訊息的處理函式。

// Handler is handler
type Handler func(res, req *Message)

定義路由器

// Router is router
type Router struct {
    mux map[string]Handler
}

路由器的具體實作是個 hashtable,用 Identification 當 key,處理函式當 value,當請求進來時,直接用 Identification 查出對應的處理函式進行處理。

// NewRouter new a router
func NewRouter() *Router {
    return &Router{
        mux: make(map[string]Handler),
    }
}

路由器的建構式,因為 Golang 不是完全的物件導向語言,因此沒有預設的建構式,使用上習慣用 NewType 的函式做為建構式。

// Add a route
func (r *Router) Add(route string, h Handler) {
    var mergedHandler = h
    r.mux[route] = mergedHandler
}

// Run router
func (r *Router) Run(res, req *Message) error {
    route := req.Identification
    handler, exists := r.mux[route]
    if exists {
        handler(res, req)
    }
    return nil
}

最後是路由器的關鍵 method。路由器有兩個 method,Add 用來將路由規則跟函式註冊到路由器中;Run 用來執行路由器。當路由器被執行時,它會去查自己的註冊表中有沒有對應的規則,如果有就執行。由於 Run 前會用 Add 將規則都寫進路由器中,message 進來就會進行對應的調用。

Use Router

來看看在 cmd 下應該如何使用設計好的路由器模組

package main

import (
    "fmt"
    "router/pkg/route"
)

func main() {
    router := route.NewRouter()
    router.Add("hello", helloHandler)
    var res route.Message
    req := route.Message{
        Identification: "hello",
        Content:        "Gopher",
    }
    router.Run(&res, &req)
    fmt.Println(res.Content)
}

func helloHandler(res, req *route.Message) {
    res.Content = req.Content
}

先由 pkg/route 引入使用的模組,建構新的路由器,對它進行註冊。假設可以由訊息的 Identification 來 Locate 訊息,則可以用

router.Add("hello", helloHandler)

來標明 Identification 是 hello 的訊息,希望用 helloHandler 來處理,而 helloHandler 的內容是

func helloHandler(res, req *route.Message) {
    res.Content = req.Content
}

它依照前面 route 模組 Handler 的定義,有兩個引數,分別表示 response 跟 request,helloHandler 會將 request 的 content 複製給 response。因此經過 helloHandler 後,出去的響應會有跟請求相同的內容。

建立一則訊息,Identification 是 hello,Content 是 Gopher,執行路由。

var res route.Message
req := route.Message{
    Identification: "hello",
    Content:        "Gopher",
}
router.Run(&res, &req)
fmt.Println(res.Content)

寫好程式先編譯,看看結果

.\scripts\build_win.bat
.\bin\main.exe

Gopher

Content 成功複製給 response 了。

Add Various Method

假設今天訊息類似 http method 一樣,有好幾種不同的處理方式,希望在進行註冊時,能明確各處理方式,免得出現 Get 寫成 Got 這類無意間拼錯的悲劇,可以怎麼做?

首先,來修改 route 模組,先修改訊息格式,加入 method

type Message struct {
    Identification string
    Method         string
    Time           string
    Content        string
    Size           int
}

將 Add 由 public 改為 private,免得誤用,並增加各 method 的 function

// add a route
func (r *Router) add(route string, h Handler) {
    var mergedHandler = h
    r.mux[route] = mergedHandler
}

// Get add a get method pattern
func (r *Router) Get(route string, h Handler) {
    r.add("get:"+route, h)
}

// Put add a put method pattern
func (r *Router) Put(route string, h Handler) {
    r.add("put:"+route, h)
}

// Post add a post method pattern
func (r *Router) Post(route string, h Handler) {
    r.add("post:"+route, h)
}

// Delete add a delete method pattern
func (r *Router) Delete(route string, h Handler) {
    r.add("delete:"+route, h)
}

Run 函式改為

func (r *Router) Run(res, req *Message) error {
    route := req.Method + ":" + req.Identification
    handler, exists := r.mux[route]
    if exists {
        handler(res, req)
    }
    return nil
}

概念上很單純,就是在 key 上新加個字段,用來分別 method。

使用上則變成

router.Get("hello", helloHandler)
var res route.Message
req := route.Message{
    Identification: "hello",
    Method:         "get",
    Content:        "Gopher",
}
router.Run(&res, &req)

註冊時直接用 Get 來註冊即可。

Add Middleware

如果現在有個情境,Identification foo 跟 bar 在進行核心的處理前,都需要某種共通的訊息處理,例如記錄運算時間,但 hello 不需要;或者是我們希望將前處理的邏輯跟核心處理邏輯分開,不要讓兩種不同邏輯混雜在一起,又要怎麼進行呢?這時就是 Middleware 派上用場的時刻了。

Middleware 顧名思義,是軟體的中間層,可以想像成是 Request 到核心邏輯,再到 Response 這段的中間夾層。這些中間層可以類似漢堡的結構,你想要加入什麼樣的食材,例如高麗菜、牛肉、酸黃瓜、起司等等,你就多加一層中間層,藉此達到分離與彈性的效果。

middleware 示意圖
middleware 示意圖

為了讓 route 模組具有中間層的功能,新增

// Middleware is public middleware
type middleware func(Handler) Handler

// Router is router
type Router struct {
    middlewareChain []middleware
    mux map[string]Handler
}

middleware 是中間層的 type,它是個函式,會吃進 Handler 進行包裝後,再吐出包裝後,具有同樣簽名的 Handler。因為 middleware 的存在,可以再執行被包裝的 Handler 前,先執行一些想要的前處理。

Router 的部分也加入 middlewareChain,讓這些 middleware 可以被註冊到路由器中,並進行嵌套式的包裝。

另外,也需要改寫 add,並新增一個 middleware 的註冊函式

// Use add middleware
func (r *Router) Use(m middleware) {
    r.middlewareChain = append(r.middlewareChain, m)
}

// add a route
func (r *Router) add(route string, h Handler) {
    var mergedHandler = h
    for i := len(r.middlewareChain) - 1; i >= 0; i-- {
    mergedHandler = r.middlewareChain[i](mergedHandler)
    }
    r.mux[route] = mergedHandler
}

Use 負責將 middleware 加入路由器中;add 中新增一個 for 迴圈,對 Handler 進行包裝。可以看到 for 迴圈會由最後一個加入的 middleware 開始,一層一層將 middleware 包覆到 Handler 上,等全部都包裝完後,放進 route 中。

最後修改 main 的使用方式,加入兩層 middleware

func main() {
    router := route.NewRouter()
    router.Use(cheeseMiddleware)
    router.Use(beefMiddleware)
    router.Get("hello", helloHandler)
    var res route.Message
    req := route.Message{
        Identification: "hello",
        Method:         "get",
        Content:        "Gopher",
    }
    router.Run(&res, &req)
    fmt.Println(res.Content)
}

func helloHandler(res, req *route.Message) {
    fmt.Println("This is core")
    res.Content += req.Content
}

func cheeseMiddleware(next route.Handler) route.Handler {
    return func(res, req *route.Message) {
        res.Content += "cheese "
        fmt.Println("This is cheese")
        next(res, req)
        fmt.Println("This is cheese")
    }
}

func beefMiddleware(next route.Handler) route.Handler {
    return func(res, req *route.Message) {
        res.Content += "beef "
        fmt.Println("This is beef")
        next(res, req)
        fmt.Println("This is beef")
    }
}

在進到核心處理邏輯前,先加入一層 cheese,再加入一層 beef,這兩層 middleware 都會印出訊息,並修改 response 的內容。

來看看輸出結果

.\bin\main.exe

This is cheese
This is beef
This is core
This is beef
This is cheese
cheese beef Gopher

夾心 Gopher 完成。

小結

http 庫跟 middleware 是現代 Web 開發中常使用的設計模式,跟基本的 if else 相比,route 的邏輯非常乾淨,只需要

router.Use(cheeseMiddleware)
router.Use(beefMiddleware)
router.Get("hello", helloHandler)

就能看出程式碼的意圖,無須關注條件判斷。同時,因為 middleware 跟 route 在設計時已經對介面進行定義,其他人在開發時能根據相同的 interface 來設計,擴充性也比 if else 來得要好。

這種以函式為主體,進行嵌套跟處理的模式,就是標準的函數式編程(Functional Programming),這也是 Golang 相對於傳統語言的一大特色。

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