跳至主要內容

執行內容 (ExecutionContext) ​

Spine 會為每個請求或訊息建立執行內容,將輸入資訊與執行狀態傳給管線。攔截器透過 core.ExecutionContext 讀寫執行狀態,HTTP 控制器則透過語意型別與唯讀的 spine.Ctx,僅接收所需的值。

理解這項區分後,就能將攔截器儲存的值傳給控制器,並判斷 HTTP、事件消費者與 WebSocket 可使用的輸入。本文的介面與行為以 Spine v0.5.1 為準。

應用程式處理器範例是手動加入以 CLI 建立的專案的擴充程式碼。請保留產生的 BuildApplication,以及會檢查錯誤的 user.Register(app) 呼叫。core/ 介面是公開契約,應用程式不需要複製 internal/ 實作。

控制器與攔截器的存取範圍 ​

內容物件的用途並非將單一大型物件暴露給所有程式碼,而是區分控制管線的程式碼與商業邏輯所需的存取範圍。

使用位置接收的型別可使用的功能
HTTP 控制器context.Context傳遞取消與逾時資訊、發布領域事件
HTTP 控制器pkg/spine.Ctx查詢攔截器儲存的值
HTTP 控制器path.Int、query.Values、DTO 等綁定好的請求輸入
攔截器core.ExecutionContext查詢請求資訊、讀寫儲存區,以及執行前後的處理
HTTP 引數解析器core.HttpRequestContext解析 HTTP 主體、標頭、表單等輸入
消費者引數解析器core.ConsumerRequestContext解析事件名稱與負載
WebSocket 引數解析器core.WebSocketContext解析連線 ID、訊息型別與負載

本文中的 spine.Ctx 是 github.com/NARUBROWN/spine/pkg/spine 的型別,並非根應用程式套件 github.com/NARUBROWN/spine 的型別。如果在同一個檔案匯入這兩個套件,請用別名區分。

預設 HTTP 解析器不會將 core.ExecutionContext 或 core.HttpRequestContext 注入為控制器引數。在控制器中,請透過輸入型別與 httpx.Response[T] 回傳值表達所需行為,無須直接操作回應寫入器或主體綁定。

內容契約的關係 ​

ContextCarrier 與 EventBusCarrier 表示共用功能。HTTP 與消費者輸入契約會直接包含這兩個契約,而 WebSocket 訊息契約則擴充 ExecutionContext。

箭頭表示契約之間的包含關係。ControllerContext 與 spine.Ctx 並未嵌入 ExecutionContext,而是只公開 Get() 的獨立介面,讓使用者能查詢同一個儲存區。

實際的 HTTP 配接器同時實作 ExecutionContext 與 HttpRequestContext。消費者配接器也同時實作 ExecutionContext 與 ConsumerRequestContext。因此,解析器收到共用的內容物件後,可以確認是否支援所需的協定契約。

共用的基礎契約 ​

ContextCarrier ​

傳遞 Go 標準的 context.Context。用於將取消訊號與截止時間傳給服務、儲存庫與外部 API 呼叫。

go
// core/context.go
type ContextCarrier interface {
    Context() context.Context
}

HTTP 使用請求的 context.Context。如果控制器將 context.Context 宣告為引數,標準內容解析器就會傳入請求內容,並連結發布領域事件所需的 EventBus。

EventBusCarrier ​

存取屬於該次執行的 EventBus。

go
// core/context.go
type EventBusCarrier interface {
    EventBus() EventBus
}

core.EventBus 是收集與取出事件的最小契約。publish.DomainEvent 定義於 github.com/NARUBROWN/spine/pkg/event/publish。

go
// core/event_bus.go
type EventBus interface {
    Publish(events ...publish.DomainEvent)
    Drain() []publish.DomainEvent
}

控制器通常不會直接呼叫 EventBus(),而是使用收到的 context.Context 呼叫 publish.Event()。具體用法請參閱下方的 EventBus 與事件發布。

ExecutionContext:管線與攔截器的執行狀態 ​

core.ExecutionContext 是管線、路由器與攔截器共用的契約。

go
// core/context.go
type ExecutionContext interface {
    ContextCarrier
    EventBusCarrier

    Method() string
    Path() string
    Params() map[string]string
    Header(name string) string
    PathKeys() []string
    Queries() map[string][]string
    Set(key string, value any)
    Get(key string) (any, bool)
}

請求與路由資訊 ​

方法在 HTTP 中的意義注意事項
Method()GET、POST 等請求方法消費者回傳 EVENT,WebSocket 回傳 WS
Path()/users/123 等實際請求路徑須與路由模式 /users/:id 區分
Header(name)指定的請求標頭沒有標頭時為空字串
Params()路由器擷取的路徑值全域 PreHandle 在 Spine 路由前執行
PathKeys()路由中路徑參數的宣告順序用於 path.* 型別的順序綁定
Queries()保留多個值的查詢映射同一個鍵可能有多個值

例如,/users/:userId/posts/:postId 路由收到 /users/123/posts/456?tag=go&tag=web 請求後,可以在路由完成後讀取下列資訊。

go
ctx.Method()   // "GET"
ctx.Path()     // "/users/123/posts/456"
ctx.Params()   // {"userId": "123", "postId": "456"}
ctx.PathKeys() // ["userId", "postId"]
ctx.Queries()  // {"tag": ["go", "web"]}

上述程式碼示範攔截器等程式碼可以讀取的值,不代表 HTTP 控制器會直接收到這個內容物件。

Set() 與 Get():在執行期間共用值 ​

攔截器可以透過 Set() 儲存驗證結果或請求追蹤資訊。控制器則透過唯讀檢視的 Get() 讀取相同的值。

go
// 인터셉터에서 저장
ctx.Set("auth.user", authInfo)

// 같은 실행에서 조회
value, ok := ctx.Get("auth.user")

Get() 會一併回傳值與是否存在的資訊。鍵存在不代表值就是預期的型別,因此請在使用該值的程式碼中一併確認型別。儲存區屬於單次請求或訊息執行,請勿當作全域快取使用。

和一般映射一樣,是否保證並行存取安全取決於實作。請勿假設可以在多個 goroutine 中,同時讀寫 HTTP 內容物件的儲存區。

ControllerContext 與 spine.Ctx:控制器的唯讀檢視 ​

core.ControllerContext 與 pkg/spine.Ctx 提供相同的方法集合。

go
// core/context.go
type ControllerContext interface {
    Get(key string) (any, bool)
}
go
// pkg/spine/types.go
type Ctx interface {
    Get(key string) (any, bool)
}

預設 HTTP 解析器會將執行內容包裝成這個檢視後傳入。控制器可以呼叫 Get(),但無法呼叫 Set()、Method() 或 EventBus()。即使透過要求額外方法的自訂介面擴充型別,也不會自動獲得支援。

讀取攔截器儲存的驗證資訊 ​

下列方法示範如何在驗證攔截器已儲存 auth.user 的應用程式中,為 UserController 加入方法。匯入 context、github.com/NARUBROWN/spine/pkg/spine、pkg/path 與 pkg/httpx。實際應用程式中,請依驗證原則檢查 authInfo 是否存在及其型別。

go
// src/user/user_controller.go
func (c *UserController) GetUser(ctx context.Context, cc spine.Ctx, userId path.Int) httpx.Response[any] {
    authInfo, _ := cc.Get("auth.user")
    return httpx.Response[any]{Body: authInfo}
}

即使宣告為 core.ControllerContext,在 HTTP 中也會收到相同的唯讀檢視。應用程式程式碼可以用 spine.Ctx 表達所需的存取範圍。驗證攔截器的撰寫與註冊方式請參閱攔截器教學。

事件消費者與 WebSocket 的預設管線未註冊 ControllerContextResolver。請勿直接在其他協定的處理器中,宣告 HTTP 可用的 spine.Ctx 引數。

HttpRequestContext:解析 HTTP 輸入 ​

core.HttpRequestContext 是 HTTP 解析器用來讀取個別參數、全部標頭、主體與 multipart 表單的契約。它不會嵌入 ExecutionContext,而是直接包含兩個基礎契約。

go
// core/context.go
type HttpRequestContext interface {
    ContextCarrier
    EventBusCarrier

    Param(name string) string
    Query(name string) string
    Header(name string) string

    Params() map[string]string
    Queries() map[string][]string
    Headers() map[string][]string

    Bind(out any) error
    MultipartForm() (*multipart.Form, error)
}

這裡的 multipart.Form 是 Go 標準 mime/multipart 的型別。

方法行為
Param(name)查詢該路徑參數的值
Query(name)查詢該查詢參數的第一個值,不存在時為空字串
Header(name)查詢請求標頭值
Params()查詢路徑參數映射
Queries()查詢保留多個值的查詢映射
Headers()查詢完整的請求標頭映射
Bind(out)將請求輸入綁定至傳入的 DTO
MultipartForm()查詢 multipart 表單與檔案資訊

HTTP 配接器的行為 ​

Spine 的 HTTP 配接器使用 Echo。Param() 會先尋找 Spine 路由器儲存的值,找不到時才檢查 Echo 的路徑值。Params() 會回傳副本,以免變更原始路徑映射。

HTTP 的 Headers() 則會直接回傳請求的標頭映射。不要假設所有查詢結果都是副本,請僅用於讀取。

Bind() 與 MultipartForm() 會將輸入處理委派給 Echo。預設 HTTP DTO 解析器會建立結構指標,並傳給 Bind()。應用程式的控制器也請如下宣告指標。

go
func (c *UserController) CreateUser(req *CreateUserRequest) (httpx.Response[User], error)

這段程式碼是方法簽章範例,CreateUserRequest 與 User 是由應用程式定義的型別。表單 DTO 的欄位使用 form 標籤,上傳檔案則可以用 pkg/multipart.UploadedFiles 接收。

如果主體是無效的 JSON,或路徑值轉換成數字時失敗,就不會進入控制器。在 v0.5.1 中,這類綁定錯誤可能導致 500,因此不應假設所有輸入錯誤都會自動變成 400。錯誤轉成回應的順序請參閱執行管線。

ConsumerRequestContext:解析事件輸入 ​

core.ConsumerRequestContext 提供事件名稱與原始負載。

go
// core/context.go
type ConsumerRequestContext interface {
    ContextCarrier
    EventBusCarrier

    EventName() string
    Payload() []byte
}

消費者配接器同時實作這個契約與 ExecutionContext。由於不是 HTTP 請求,共用方法的行為如下。

方法在消費者中回傳的值
Method()EVENT
Path()事件名稱
EventName()事件名稱
Payload()收到訊息的原始位元組
Header(name)空字串
Params()nil
PathKeys()nil
Queries()nil

預設消費者解析器支援 context.Context、字串事件名稱、[]byte 負載與結構 DTO 值。DTO 會透過反序列化 JSON 負載建立。HTTP DTO 的指標規則不會直接套用到消費者。

如果缺少解析器所需的負載,或 JSON 反序列化失敗,就不會呼叫處理器。如何將這次執行失敗反映在訊息確認、重試與 dead-letter 處理上,由消費者執行階段與訊息傳輸設定負責。請勿直接套用 HTTP 錯誤回應表。

WebSocketContext:訊息與連線資訊 ​

core.WebSocketContext 在 ExecutionContext 上加入 WebSocket 訊息資訊。

go
// core/context.go
type WebSocketContext interface {
    ExecutionContext

    ConnID() string
    MessageType() int
    Payload() []byte
}

WebSocket 執行階段會為每則收到的訊息建立執行內容。Method() 回傳 WS,Path() 回傳連線請求的實際路徑。ConnID() 回傳連線 ID,MessageType() 回傳文字或二進位等訊框型別,Payload() 則回傳該訊息的位元組。

預設解析器支援 context.Context、pkg/ws.ConnectionID、[]byte 與結構 DTO 值。DTO 會將訊息負載以 JSON 反序列化。與 HTTP 不同,DTO 以結構值接收。

原始連線請求的資訊 ​

WebSocket 在處理訊息時,也可以查詢 HTTP upgrade 時收到的標頭、查詢參數與 Cookie。為此,另提供獨立的請求檢視。

go
// core/context.go
type WebSocketRequestContext interface {
    ContextCarrier

    Path() string
    Header(name string) string
    Headers() map[string][]string
    Query(name string) string
    Queries() map[string][]string
    Cookie(name string) (string, bool)
    Cookies() map[string]string
    RemoteAddr() string
    Host() string
    RequestURI() string
}
go
type WebSocketHandshakeContext interface {
    WebSocketRequestContext
}

type WebSocketMessageContext interface {
    WebSocketContext
    WebSocketRequestContext
}

預設 WebSocket 實作會複製並保存 upgrade 請求資訊。Headers()、Queries() 與 Cookies() 也會回傳副本,因此變更查詢結果不會改變已保存的請求資訊。這些值是連線當時的資訊,不代表每則訊息都會收到新的 HTTP 請求。

所需的值使用的契約
訊息負載與連線 IDcore.WebSocketContext
upgrade 前的標頭、查詢參數與 Cookie 檢查core.WebSocketHandshakeContext
處理訊息時一併查詢原始請求資訊core.WebSocketMessageContext

預設實作的 Header() 與 Queries() 會查詢這些請求資訊。值可能不存在,但不會因為是 WebSocket 就一律回傳空值。請勿假設自行撰寫的 WebSocketContext 實作也支援額外的請求檢視,應先確認型別。

go
// WebSocket 메시지를 처리하는 인터셉터 안에서 조회하는 예
if requestCtx, ok := ctx.(core.WebSocketMessageContext); ok {
    origin := requestCtx.Header("Origin")
    _ = origin
}

這段程式碼不是控制器引數注入範例,而是從攔截器收到的 core.ExecutionContext 確認是否支援額外功能。

連線前檢查與訊息前置處理 ​

如果需要在連線前進行驗證或權限檢查,請實作選用的 core.WebSocketHandshakeInterceptor 契約。

go
type WebSocketHandshakeInterceptor interface {
    PreHandshake(ctx WebSocketHandshakeContext, meta HandlerMeta) error
}

如果註冊為套用至 WebSocket 的 core.Interceptor 也實作這個契約,執行階段會先保留連線名額,再於 HTTP upgrade 前呼叫 PreHandshake。等待驗證的連線也會計入連線上限。一般的 core.Interceptor.PreHandle 則會在連線後,每次處理訊息時呼叫。請區分必須拒絕連線本身的檢查與各訊息的前置處理。

各協定的輸入比較 ​

即使使用相同的執行管線,內容物件的意義與預設引數型別仍不同。

項目HTTP事件消費者WebSocket
執行單位一次 HTTP 請求一筆收到的事件一則收到的訊息
Method()HTTP 方法EVENTWS
Path()實際請求路徑事件名稱連線請求的實際路徑
Header()請求標頭空字串保存的 upgrade 請求標頭
Queries()請求查詢參數nil保存的 upgrade 請求查詢參數
主體輸入HTTP Bind()事件 Payload()訊息 Payload()
預設 DTO 型別結構指標結構值結構值
context.Context 引數支援支援支援
spine.Ctx 引數支援預設未註冊預設未註冊

協定特有的功能,請先透過對應介面確認型別再使用。即使有 ExecutionContext,也不代表能同時呼叫 HTTP 的 Bind() 與 WebSocket 的 ConnID()。

儲存區鍵與執行時點 ​

Spine 在內部儲存區使用下列鍵。應用程式請勿覆寫相同的鍵。應用程式的值請使用像 auth.user 這樣,能表明用途的獨立名稱。

鍵值的型別用途
spine.paramsmap[string]string路由器擷取的路徑參數
spine.pathKeys[]string路徑參數的宣告順序
spine.response_writercore.ResponseWriterHTTP 回應寫入與緩衝處理

spine.response_writer 會連結配接器提供的回應寫入器,管線則會將其包裝成在回應傳送前使用緩衝區的寫入器。請回傳 httpx.Response[T]、httpx.Redirect 或 httpx.Binary,不要在控制器取出這個鍵並直接寫入回應。

能從內容物件讀取哪些值,也取決於執行時點。全域 PreHandle 在路由前執行,路由 PreHandle 則在路由與引數綁定後執行。設計時,必須一併考慮儲存區應在何時具備該值,以及哪個階段會建立這個值。

EventBus 與事件發布 ​

控制器宣告 context.Context 時,標準內容解析器會連結該次執行的 EventBus 後傳入。控制器可以透過這個內容物件收集領域事件。

下列節錄中的 publish 匯入自 github.com/NARUBROWN/spine/pkg/event/publish。OrderCreated 是由應用程式定義,並實作 Name() string 與 OccurredAt() time.Time 的事件型別。

go
// src/user/user_controller.go
func (c *UserController) CreateOrder(ctx context.Context, orderId path.Int) httpx.Response[string] {
    // context.Context에 연결된 EventBus로 이벤트를 수집합니다.
    publish.Event(ctx, OrderCreated{
        OrderID: orderId.Value,
        At:      time.Now(),
    })

    return httpx.Response[string]{Body: "OK"}
}

單憑呼叫 publish.Event(),並不會完成向外部訊息代理的傳送。它會將事件收集到已連結的 EventBus;若已註冊事件派送掛鉤,則在成功回應的序列化與驗證完成後,取出事件並傳給派送器。註冊掛鉤需要設定事件發布器。

如果處理器回傳錯誤,或準備成功回應時失敗,就不會執行派送掛鉤。如果派送失敗,該錯誤會視為管線的執行錯誤。派送掛鉤會在 BeforeResponse 與實際的 HTTP 回應傳送之前執行,因此不能單憑事件已收集或已派送,就判定交易已提交或回應已傳送完成。

事件發布、交易收尾與回應寫入的順序,請參閱執行管線。