288 lines
6.7 KiB
Markdown
288 lines
6.7 KiB
Markdown
# Gin Router and Explicit HTTP Server Composition Design
|
||
|
||
日期:2026-07-02
|
||
|
||
## 目标
|
||
|
||
将 codespace 后端的原生 Go 1.22 `http.ServeMux` 路由替换为 Gin,同时继续显式构造 `http.Server`。Gin 只负责路由和 handler 上下文,网络层仍由 `http.Server` 控制。
|
||
|
||
本次改动的核心要求:
|
||
|
||
- 使用 Gin 注册 API 路由。
|
||
- 不使用 `router.Run()` 或 `engine.Run()`。
|
||
- 将 `*gin.Engine` 显式设置为 `http.Server.Handler`。
|
||
- 将服务器网络参数配置化,包括 timeout 和最大请求头大小。
|
||
- 保持现有 API 路径、HTTP method、响应 JSON 结构不变。
|
||
|
||
## 当前状态
|
||
|
||
当前代码:
|
||
|
||
- `cmd/server/main.go` 已显式构造 `http.Server`。
|
||
- `internal/api/router.go` 使用 `http.NewServeMux()`。
|
||
- handler 使用 `http.ResponseWriter`、`*http.Request`、`r.PathValue("id")` 和 `json.NewDecoder(r.Body)`。
|
||
- `pkg/config` 只配置 `server.addr`,timeout 仍硬编码在 `main.go`。
|
||
|
||
## 非目标
|
||
|
||
本次不做:
|
||
|
||
- 不引入认证、CORS、rate limit、request logger middleware。
|
||
- 不改变 workspace/file/process service 接口。
|
||
- 不改变 API 路径、HTTP method、响应结构。
|
||
- 不实现 WebSocket、watcher 推送、Git、LSP、AI Agent。
|
||
- 不把网络层控制权交给 Gin 的 `Run()`。
|
||
|
||
## 目标架构
|
||
|
||
```text
|
||
cmd/server/main.go
|
||
↓
|
||
config.Load()
|
||
↓
|
||
api.NewRouter(...)
|
||
↓
|
||
*gin.Engine
|
||
↓
|
||
http.Server{
|
||
Addr,
|
||
Handler: ginEngine,
|
||
ReadTimeout,
|
||
WriteTimeout,
|
||
IdleTimeout,
|
||
MaxHeaderBytes,
|
||
}
|
||
↓
|
||
ListenAndServe()
|
||
```
|
||
|
||
关键规则:
|
||
|
||
1. `internal/api.NewRouter` 返回 `*gin.Engine`。
|
||
2. `cmd/server/main.go` 显式创建 `http.Server`。
|
||
3. `http.Server.Handler` 设置为 Gin engine。
|
||
4. 代码中不得出现 `.Run(` 用于启动 Gin server。
|
||
5. `ReadTimeout`、`WriteTimeout`、`IdleTimeout`、`MaxHeaderBytes` 来自配置。
|
||
|
||
## 配置设计
|
||
|
||
`configs/config.yaml` 扩展为:
|
||
|
||
```yaml
|
||
server:
|
||
addr: ":8080"
|
||
readTimeout: "15s"
|
||
writeTimeout: "15s"
|
||
idleTimeout: "60s"
|
||
maxHeaderBytes: 1048576
|
||
workspace:
|
||
root: "./workspaces"
|
||
process:
|
||
opencodeCommand: "opencode"
|
||
```
|
||
|
||
`pkg/config.ServerConfig` 对外使用强类型:
|
||
|
||
```go
|
||
type ServerConfig struct {
|
||
Addr string
|
||
ReadTimeout time.Duration
|
||
WriteTimeout time.Duration
|
||
IdleTimeout time.Duration
|
||
MaxHeaderBytes int
|
||
}
|
||
```
|
||
|
||
YAML 加载不要依赖 `time.Duration` 的隐式解析。使用 raw config 字符串字段承接 YAML,然后通过 `time.ParseDuration` 显式解析。这样 `"15s"`、`"1m"`、`"500ms"` 都清晰可控。
|
||
|
||
环境变量增加:
|
||
|
||
- `CODESPACE_READ_TIMEOUT`
|
||
- `CODESPACE_WRITE_TIMEOUT`
|
||
- `CODESPACE_IDLE_TIMEOUT`
|
||
- `CODESPACE_MAX_HEADER_BYTES`
|
||
|
||
已有环境变量保持不变:
|
||
|
||
- `CODESPACE_ADDR`
|
||
- `CODESPACE_WORKSPACE_ROOT`
|
||
- `CODESPACE_OPENCODE_COMMAND`
|
||
|
||
默认值:
|
||
|
||
- `addr`: `:8080`
|
||
- `readTimeout`: `15s`
|
||
- `writeTimeout`: `15s`
|
||
- `idleTimeout`: `60s`
|
||
- `maxHeaderBytes`: `1048576`
|
||
|
||
## 路由设计
|
||
|
||
原路由保持不变:
|
||
|
||
```text
|
||
GET /healthz
|
||
POST /api/workspaces
|
||
GET /api/workspaces/:id
|
||
DELETE /api/workspaces/:id
|
||
GET /api/workspaces/:id/files
|
||
GET /api/workspaces/:id/files/read
|
||
PUT /api/workspaces/:id/files/write
|
||
POST /api/workspaces/:id/files/mkdir
|
||
DELETE /api/workspaces/:id/files
|
||
POST /api/workspaces/:id/files/rename
|
||
POST /api/workspaces/:id/process/start
|
||
POST /api/workspaces/:id/process/stop
|
||
POST /api/workspaces/:id/process/restart
|
||
GET /api/workspaces/:id/process/status
|
||
```
|
||
|
||
Gin 注册方式:
|
||
|
||
```go
|
||
r := gin.New()
|
||
r.GET("/healthz", healthHandler)
|
||
api := r.Group("/api")
|
||
api.POST("/workspaces", workspaceHandler.create)
|
||
api.GET("/workspaces/:id", workspaceHandler.get)
|
||
```
|
||
|
||
第一版使用 `gin.New()` 而不是 `gin.Default()`,避免隐式加入 logger middleware。只添加 `gin.Recovery()`,防止 panic 打爆进程。请求日志以后有真实需求再加。
|
||
|
||
## Handler 迁移
|
||
|
||
签名从:
|
||
|
||
```go
|
||
func (h *workspaceHandler) get(w http.ResponseWriter, r *http.Request)
|
||
```
|
||
|
||
改为:
|
||
|
||
```go
|
||
func (h *workspaceHandler) get(c *gin.Context)
|
||
```
|
||
|
||
路径参数:
|
||
|
||
```go
|
||
id := c.Param("id")
|
||
```
|
||
|
||
查询参数:
|
||
|
||
```go
|
||
path := c.Query("path")
|
||
```
|
||
|
||
JSON body:
|
||
|
||
```go
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
writeError(c, util.New(util.CodeBadRequest, "invalid json"))
|
||
return
|
||
}
|
||
```
|
||
|
||
响应:
|
||
|
||
```go
|
||
c.JSON(http.StatusCreated, model.WorkspaceResponse{ID: ws.ID})
|
||
c.Status(http.StatusNoContent)
|
||
```
|
||
|
||
## 错误处理
|
||
|
||
保持现有错误 JSON:
|
||
|
||
```json
|
||
{
|
||
"error": {
|
||
"code": "bad_request",
|
||
"message": "invalid json"
|
||
}
|
||
}
|
||
```
|
||
|
||
`internal/api/errors_helper.go` 改为接收 `*gin.Context`:
|
||
|
||
```go
|
||
func writeError(c *gin.Context, err error)
|
||
```
|
||
|
||
继续使用 `util.CodeOf(err)` 映射:
|
||
|
||
- `bad_request` -> 400
|
||
- `not_found` -> 404
|
||
- `conflict` -> 409
|
||
- default -> 500
|
||
|
||
可以复用 `pkg/response.ErrorBody` 和 `pkg/response.ErrorDetail`,避免重复定义响应结构。
|
||
|
||
## Server 组合
|
||
|
||
`cmd/server/main.go` 最终保持显式组合:
|
||
|
||
```go
|
||
router := api.NewRouter(workspaceSvc, fileSvc, processSvc)
|
||
server := &http.Server{
|
||
Addr: cfg.Server.Addr,
|
||
Handler: router,
|
||
ReadTimeout: cfg.Server.ReadTimeout,
|
||
WriteTimeout: cfg.Server.WriteTimeout,
|
||
IdleTimeout: cfg.Server.IdleTimeout,
|
||
MaxHeaderBytes: cfg.Server.MaxHeaderBytes,
|
||
}
|
||
```
|
||
|
||
启动仍然使用:
|
||
|
||
```go
|
||
server.ListenAndServe()
|
||
```
|
||
|
||
## 测试策略
|
||
|
||
现有 `httptest` 测试继续有效,因为 `*gin.Engine` 实现 `http.Handler`。
|
||
|
||
需要验证:
|
||
|
||
- `GET /healthz` 行为不变。
|
||
- workspace 创建、文件写读、process status 行为不变。
|
||
- Gin path param `:id` 正常传入 service。
|
||
- 配置默认值正确。
|
||
- YAML timeout 字符串能解析。
|
||
- 环境变量能覆盖 timeout 和 `maxHeaderBytes`。
|
||
- `go test ./...` 通过。
|
||
- `grep -R "\.Run(" cmd internal || true` 无输出。
|
||
- `go run ./cmd/server` 后 `/healthz` 返回 `{"status":"ok"}`。
|
||
|
||
## 依赖
|
||
|
||
新增唯一依赖:
|
||
|
||
```text
|
||
github.com/gin-gonic/gin
|
||
```
|
||
|
||
不新增其他框架或中间件依赖。
|
||
|
||
## 风险与处理
|
||
|
||
- 风险:Gin 默认模式可能输出 debug 信息。处理:启动时可设置 `gin.SetMode(gin.ReleaseMode)`,或保留 Gin 默认但不影响功能。第一版建议在 `NewRouter` 中设置 `gin.ReleaseMode`,减少噪音。
|
||
- 风险:`time.Duration` YAML 解析行为不符合预期。处理:使用 raw string + `time.ParseDuration`。
|
||
- 风险:错误响应格式变化。处理:复用现有响应结构并保留 HTTP 测试。
|
||
- 风险:误用 `router.Run()`。处理:增加 grep 验证。
|
||
|
||
## 完成标准
|
||
|
||
完成后必须满足:
|
||
|
||
- `internal/api` 使用 Gin 注册路由。
|
||
- `cmd/server/main.go` 使用 `http.Server{Handler: router}`。
|
||
- 不出现 Gin `Run()` 启动服务器。
|
||
- server 网络参数来自配置。
|
||
- API 行为和响应结构保持兼容。
|
||
- `go test ./...` 通过。
|
||
- server smoke test 通过。
|