重构我的sma11sCan项目 里面的Gin 框架:从”能跑”到”能用”

项目地址:github.com/shen060606/sma11sCan/tree/master

最近把自己写的一个端口扫描工具做了工程化改造。原来 Gin API 层所有的代码都塞在一个 main.go 里,没有中间件、没有统一的响应格式、没有优雅关闭,属于典型的”Demo 级别”代码。这篇文章记录一下改造的思路和具体做法。


改造前的样子

改造前只有一个 210 行的 cmd/sma11scan-api/main.go,里面塞了:

  • 路由注册
  • 三个 handler 函数(首页、扫描、历史查询)
  • 扫描业务逻辑(CIDR 扫描、单 IP 扫描)
  • 请求参数绑定和响应

所有东西混在一起,加一个新接口都不知道往哪塞。

核心问题就四个:

  1. 路由和 handler 魔术胶水粘在一起,代码组织没有章法
  2. 没有中间件,日志全靠 fmt.Println,panic 了前端拿到的是一段看不懂的纯文本
  3. 每个接口返回的 JSON 结构都不一样,前端要写一堆 if/else 来判断
  4. 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
// response.go
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

// 10 秒缓冲
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")
}

流程很简单:

  1. ListenAndServe 放 goroutine 里跑,主 goroutine 等信号
  2. 收到 SIGINT(Ctrl+C)或 SIGTERM(kill)后,srv.Shutdown() 停止接收新请求
  3. context.WithTimeout 给正在处理的请求 10 秒完成,超时强制关闭
  4. 最后关闭数据库连接

有一个容易忽视的细节: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 反向代理 + 限流配置也在项目仓库里,感兴趣可以去看。