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

進行後端程式開發時,常會使用到 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 第 13 期:Google 無須出售 Chrome

Chrome 的判決出來了,Google 不用分拆,只需要保障競爭者能跟它公平競爭。 這個判決有指標意義,所有人都知道 Google 長期利用 Chrome 數據改善它的搜尋引擎,讓其他廠商處於競爭劣勢。要解決這問題,最簡單方式是要求 Google 出售 Chrome,而法官在仔細評估後,給出相當審慎的判決。 我喜歡這種法律見解,具有實務與原則的平衡,法律條文不應該是照本宣科。 🗞️ 熱門新聞 Google Can Continue Paying for Firefox Search Deal, Judge Rules 以前很少注意 Chrome 的新聞,剛好最近判決出來了,看了一些。 最驚訝的是,Mozilla 有 85% 的年度收入是由 Google 給的,如果判決禁止 Google 出錢成為瀏覽器預設的搜尋引擎,將直接影響到 Mozilla

By Ken Chen

Weekly Issue 第 12 期:Bear 修改授權條款

通常開源專案需要面對長期維護的問題,而長期維護需要人力(開發者)物力(伺服器與基礎建設),個人開發者來說是個負擔。有些專案會有企業贊助,有些專案則是替用戶提供顧問與服務來收費維持。 這期選了 Bear 修改授權的新聞,也因為這則新聞,順道看了 Sentry 的授權模式。我們都希望擁有健康的開發生態,而授權條款很大程度左右了這點。 🗞️ 熱門新聞 Bear changes license to Elastic License Blog 平台工具 Bear 修改授權,原本是 MIT,現在改用 Elastic License。 看開發者的說法,原因是有人搭便車,fork 完直接部署成服務賣錢。開源不是免費勞工,這樣確實有點過分。Elastic License 的差別是不准以託管方式提供服務,算是補上這個洞。 相對 AGPL 來講,有時這種個人開發的小型專案,也不追求產業影響力,直接用 EL

By Ken Chen

Weekly Issue 第 11 期:AI 代理人插件可能存在資安風險

Preplexity 跟 Anthropic 等公司開始讓瀏覽器 AI 代理化,資安領域專家 Simon Willison 指出這可能會導致眾多資安漏洞出現。我建議兩邊的意見都可以看看,Anthropic 為了防堵問題,也下過不少功夫,看完後你會比較知道該如何使用 AI 代理。 另外這期特別喜歡 Mike Sun 談台灣的產品經理遇到的挑戰,我現在不太建議新人直接在台灣當產品經理,舞台太小,成長空間有限,會影響日後發展。如果真的對產品很有興趣,可以先到其他地方建立起正確的產品觀後,再回到台灣發展。 🗞️ 熱門新聞 Piloting Claude for Chrome Anthropic 最近推出 Chrome 用的 Claude 插件,但是依照說明文件:「當我們在自主模式中加入安全防護機制後,成功將 23.6%的攻擊成功率降低至 11.2%。」 儘管 Anthropic 特地專文說明它們的防護措施,

By Ken Chen

Weekly Issue 第 10 期:AI 機器人正造成網站負擔

隨著 LLM 變成日常的一部分,它們也在改變原有的網路生態。Fastly 的報告顯示,AI 機器人每分鐘可對網站發起高達 39K 次請求,日後造訪網站的,可能大多是機器人,而不是真人。 🗞️ 熱門新聞 Fastly warns AI bots can hit sites 39K times per minute 繼上次 Codeberg 的新聞後,Fastly 出報告指出 AI 機器人正造成網站營運負擔。 大多觀點延續幾個月來的趨勢:「網站負載增長主要並非來自人類訪客,而是代表聊天機器人公司運作的自動爬蟲與抓取程式。 」值得注意的是,AI Fetcher 的數量也在增加中,我猜這多少暗示了用戶搜尋資料的行為正在變化。 Meta 占了所有 AI 流量的 52% 🙄 ,相對下 Anthropic 只佔 3.76%

By Ken Chen