重构我的sma11sCan项目 里面的Gin 框架:从”能跑”到”能用”
项目地址:github.com/shen060606/sma11sCan/tree/master
最近把自己写的一个端口扫描工具做了工程化改造。原来 Gin API 层所有的代码都塞在一个 main.go 里,没有中间件、没有统一的响应格式、没有优雅关闭,属于典型的”Demo 级别”代码。这篇文章记录一下改造的思路和具体做法。
改造前的样子
改造前只有一个 210 行的 cmd/sma11scan-api/main.go,里面塞了:
- 路由注册
- 三个 handler 函数(首页、扫描、历史查询)
- 扫描业务逻辑(CIDR 扫描、单 IP 扫描)
- 请求参数绑定和响应
所有东西混在一起,加一个新接口都不知道往哪塞。
核心问题就四个:
- 路由和 handler 魔术胶水粘在一起,代码组织没有章法
- 没有中间件,日志全靠
fmt.Println,panic 了前端拿到的是一段看不懂的纯文本
- 每个接口返回的 JSON 结构都不一样,前端要写一堆
if/else 来判断
r.Run(":8088") 一把梭,Ctrl+C 直接杀进程,数据库连接说断就断
改造后的结构
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67
| internal/api/ ├── router.go # 路由注册 ├── handler/ │ ├── scan.go # 扫描接口 │ └── query.go # 历史查询接口 ├── middleware/ │ ├── logger.go # 请求日志 │ └── recovery.go # panic 恢复 └── response/ └── response.go # 统一响应格式
````
`main.go` 从 210 行缩减到 50 行,只做三件事:初始化 DB → 注册路由 → 优雅启停。
---
## 一、路由分层
路由集中在一个文件,一眼看清有哪些接口。handler 函数独立到单独文件里,一个文件只干一件事。
```go // router.go func Setup() *gin.Engine { r := gin.New() r.Use(middleware.Logger(), middleware.Recovery()) r.LoadHTMLGlob("static/*")
r.GET("/", func(c *gin.Context) { c.HTML(200, "index.html", nil) })
api := r.Group("/api/v1") { api.POST("/scan", handler.CreateScan) api.GET("/scans", handler.QueryPage) api.GET("/scans/list", handler.ScanList) }
return r }
````
`gin.Group()` 做路由分组,以后加认证中间件只消在 `api` 分组上挂一行,不用一个一个接口加。
handler 里只做三件事:**参数绑定 → 调业务逻辑 → 统一响应**,不再碰 `c.JSON(200, gin.H{...})` 这种裸调用。
* * *
## 二、自定义中间件
你没看错,就两个中间件,但覆盖了 90% 的需求。
### Logger:请求日志带耗时
```go func Logger() gin.HandlerFunc { return func(c *gin.Context) { start := time.Now() path := c.Request.URL.Path
c.Next()
latency := time.Since(start) status := c.Writer.Status()
if status >= 500 { log.Printf("[WARN] %s %s %d %v", c.Request.Method, path, status, latency) } else { log.Printf("[INFO] %s %s %d %v", c.Request.Method, path, status, latency) } } }
|
效果:
1 2 3
| [INFO] POST /api/v1/scan 200 3.2s [WARN] POST /api/v1/scan 500 1.5s
|
比 fmt.Println("扫描完成") 强在哪里?有时间戳、有状态码、有耗时。线上排错时,扫一眼日志就知道哪个请求慢了、哪个挂了。
关键技巧:c.Next() 放中间。它之前是请求阶段,之后是响应阶段。这样 time.Since(start) 算出来的是 handler 真正的处理耗时。
Recovery:panic 了返回 JSON
Gin 内置的 Recovery 在 release 模式下返回的是纯文本 "Internal Server Error",前端拿到直接 JSON.parse 报错。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| func Recovery() gin.HandlerFunc { return func(c *gin.Context) { defer func() { if err := recover(); err != nil { log.Printf("[PANIC] %s %s panic: %v", c.Request.Method, c.Request.URL.Path, err) c.AbortWithStatusJSON(500, gin.H{ "code": 500, "message": "服务器内部错误", }) } }() c.Next() } }
|
永远返回 {"code":500, "message":"服务器内部错误"},前端统一处理。出问题的请求也打了日志,能翻到堆栈信息。
注意这里用 gin.New() 而不是 gin.Default(),因为 Default() 自动挂了内置的 Logger 和 Recovery,会跟我们自定义的冲突。
三、统一响应格式
改造前每个接口返回的结构都不同:
1 2 3 4 5 6 7 8 9
| {"ip": "1.2.3.4", "cdn": [], "ports": [...]}
{"error": "无法解析目标域名"}
[{id: 1, batch_id: 1, ...}]
|
前端要写三种判断逻辑,加一个新接口又要定一种新格式。改成这样:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| type Response struct { Code int `json:"code"` Message string `json:"message"` Data interface{} `json:"data,omitempty"` }
func OK(c *gin.Context, data interface{}) { c.JSON(200, Response{Code: 0, Message: "success", Data: data}) }
func Fail(c *gin.Context, httpStatus int, msg string) { c.JSON(httpStatus, Response{Code: httpStatus, Message: msg}) }
func BadRequest(c *gin.Context, msg string) { Fail(c, 400, msg) } func ServerError(c *gin.Context, msg string) { Fail(c, 500, msg) }
|
所有接口返回:
1 2 3 4 5 6
| {"code": 0, "message": "success", "data": {"ip": "1.2.3.4", "ports": [...]}}
{"code": 400, "message": "无法解析目标域名"}
|
前端永远 const resp = await fetch(...); if (resp.code === 0) { ... },一套逻辑吃所有接口。
handler 里用起来也干净:一行 response.OK(c, result) 替代了之前五六行的 c.JSON 裸写。
四、优雅关闭
你永远不知道线上服务什么时候会收到 SIGTERM。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| func main() { global.InitDB()
r := api.Setup()
srv := &http.Server{ Addr: ":8088", Handler: r, }
go func() { if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("Server error: %v", err) } }()
quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) <-quit
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel()
if err := srv.Shutdown(ctx); err != nil { log.Fatalf("Shutdown error: %v", err) }
sqlDB, _ := global.DB.DB() sqlDB.Close()
fmt.Println("Server exited") }
|
流程很简单:
ListenAndServe 放 goroutine 里跑,主 goroutine 等信号
- 收到 SIGINT(Ctrl+C)或 SIGTERM(kill)后,
srv.Shutdown() 停止接收新请求
context.WithTimeout 给正在处理的请求 10 秒完成,超时强制关闭
- 最后关闭数据库连接
有一个容易忽视的细节:ListenAndServe 在正常 Shutdown 后也会返回 http.ErrServerClosed,是一个 error。如果不加 && err != http.ErrServerClosed 判断,每次正常退出都会触发 log.Fatalf,后面的优雅关闭代码根本走不到。这个坑我踩过了。
总结
这次改造代码量很小,六个文件加起来不到 200 行,但对工程质量的提升是质的飞跃:
| 维度 |
改造前 |
改造后 |
| 路由 |
塞在 main.go 里 |
router + handler 独立文件 |
| 日志 |
fmt.Println |
中间件统一打印,带状态码和耗时 |
| panic |
Gin 内置纯文本返回 |
JSON 格式,前端不炸 |
| 响应 |
每个接口格式不同 |
统一 {code, message, data} |
| 关闭 |
r.Run 直接杀 |
10 秒缓冲 + DB 关闭 |
用不用 gin.Default()? 我建议小项目一律用 gin.New(),需要什么中间件自己挂。Default() 挂的内置 Logger 格式固定没法调,Recovery 返回文本不够友好。两个中间件自己写也就 30 行代码,换了完全可控的行为。
要不要上来就搞依赖注入、分层架构? 不用。我这次特意没加 Service 层、没搞依赖注入,handler 直接调 global.DB。对于一个小项目的 API 来说,过度设计比没设计更糟糕。等哪天 handler 里出现了重复的业务逻辑、或者要写单测了,再抽 Service 层不迟。
要不要加 CORS / Auth / RateLimit 中间件? CORS 前后端同域不需要,Auth 没有用户系统加了白加,RateLimit 交给前面 Nginx 做更合适。不是所有中间件都要往 Gin 里塞,各司其职才是好架构。
如果你也在给自己的小项目做工程化改造,希望这篇文章对你有帮助。完整的 Nginx 反向代理 + 限流配置也在项目仓库里,感兴趣可以去看。