Go 入门笔记:6 个我踩过的坑,每个都来自我写的校园失物招领后端

目录
  1. 一、:= 和 var:短变量声明不是”少打几个字”
  2. 二、struct tag:一个字段并排挂三套标签
    1. 一个真实的 bug:空格能大摇大摆绕过 required
  3. 三、any 和类型断言:装进去容易,取出来要自己说清楚
  4. 四、函数是一等公民:中间件就是一个”返回函数的函数”
  5. 五、错误是值,不是异常
  6. 六、defer:拿到资源的那一行就写好怎么收尾
  7. 写在最后

仓库第一天的样子是——所有接口、所有数据库操作,全堆在 main.go 里。两周后它变成了 handler / service / repository 分层的样子。这篇文章不打算复述 Go 的语法大全(那种文章已经太多了),我只写自己真正卡住过的地方,每个点都配上项目里的真实代码。贴之前我把密码和密钥都换成了占位符。

一、:= 和 var:短变量声明不是”少打几个字”

:= 是声明并赋值,不是赋值。所以同一作用域里不能对同一个变量用两次:

1
2
a := 1
a := 2 // 编译错误:no new variables on left side of :=

真正会咬人的是它在新作用域里的行为。if、for、函数体都会开新作用域,里面的 := 会造一个同名的新变量,把外层的遮蔽掉——这个现象叫 variable shadowing:

1
2
3
4
5
6
7
8
var err error
if needCheck {
result, err := doSomething() // 这里的 err 是新变量,外层那个还是 nil
_ = result
}
if err != nil { // 永远进不来
// ...
}

外层 err 从头到尾都是 nil,所以后面那个判断形同虚设,错误被静默吞掉。这种 bug 编译器不报错、运行时也不崩,只是行为不对——最难查的一类。我的习惯是:**如果外层已经有 err 了,里面就写 var 或者用 =,别用 :=**。

二、struct tag:一个字段并排挂三套标签

1
2
3
4
5
6
type LostItem struct {
ID uint `gorm:"primaryKey" json:"id"`
Title string `json:"title" binding:"required,notblank"`
Location string `json:"location"`
Desc string `json:"desc"`
}

反引号里那一串可以并排挂好几套 tag,各归各的解析器管:

  • json:"title" → 序列化成 JSON 时这个字段叫什么
  • gorm:"primaryKey" → 建表时它是不是主键
  • binding:"required" → Gin 校验请求体时的规则

初学者最容易晕的是”这行到底是谁在管”。json:"-" 是”不出现在 JSON 里”,gorm:"-" 是”不存进数据库”,位置写错行为天差地别。

一个真实的 bug:空格能大摇大摆绕过 required

标题字段最初只写了 binding:"required"。队友测试时发现能提交一篇标题全是空格的帖子。

原因:required 判断的是”是不是零值”。字符串的零值是 "",而 " "(一个空格)长度是 1,在 required 眼里完全合法。后来加上 notblank 才好——它会先去掉两端的空白字符再判断有没有内容。

记住:required ≠ 非空字符串,它等于”不是零值”。 这个区别在字符串、切片、指针上都存在,语义各不相同。

三、any 和类型断言:装进去容易,取出来要自己说清楚

any 是 interface{} 的别名(Go 1.18 起),意思是”任意类型”。

1
2
3
4
5
type Response struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data any `json:"data"`
}

Data 用 any,成功时塞业务数据、失败时塞 nil,一个结构体就能应付所有接口的响应格式。

东西装进去是无痛的,取出来要类型断言:

1
2
3
4
5
6
7
8
9
v, exist := c.Get(key)
if !exist {
return 0
}
id, ok := v.(*model.TokenInfo)
if !ok {
return 0
}
return id.UserID

v.(*model.TokenInfo) 就是在说”我断定 v 里装的是 *model.TokenInfo“。两个细节:

  1. 单返回值形式 v.(T) 断言失败会直接 panic,双返回值 v, ok := x.(T) 才是安全的。我在中间件里一律用后者——鉴权代码 panic 等于整个服务挂掉。
  2. 断言的目标类型必须和塞进去的完全一致。我塞的是指针,写成 v.(model.TokenInfo)(少了星号)就永远不成立,而且不报错,只是默默走失败分支。

JWT 那边还有个更隐蔽的:

1
2
3
claims := token.Claims.(jwt.MapClaims)
rawUserID := claims["user_id"]
userID := uint(rawUserID.(float64))

claims["user_id"] 取出来是 any,实际类型是 float64——因为 JSON 里的数字统一解析成 float64。我明明签发的时候传的是 uint,取出来却是 float64,直接 .(uint) 会 panic。这个坑卡了我一阵子。

顺便说,上面这两行其实还有改进空间:token.Claims.(jwt.MapClaims) 没做 ok 检查,理论上能 panic。写文章的时候回看才发现的。

四、函数是一等公民:中间件就是一个”返回函数的函数”

这是我觉得 Go 最优雅的地方,也是 Gin 中间件的工作原理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func Auth() gin.HandlerFunc {
return func(c *gin.Context) {
header := c.GetHeader("Authorization")
if !strings.HasPrefix(header, "Bearer ") {
response.Fail(c, errcode.ErrUnauthorized)
c.Abort()
return
}
info, err := service.ParseToken(header[7:])
if err != nil {
response.Fail(c, errcode.ErrUnauthorized)
c.Abort()
return
}
c.Set(key, info)
c.Next()
}
}

gin.HandlerFunc 就是个函数类型,底层是 func(*gin.Context)。所以 Auth 这个函数”返回一个函数”完全合法——函数在 Go 里是普通值,能当参数传、能当返回值。

那为什么要绕这一圈?为了带参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func RequireRole(roles ...string) gin.HandlerFunc {
return func(c *gin.Context) {
info, ok := c.Get(key)
// ... 取 TokenInfo
for _, v := range roles { // roles 被"记住"了
if info.Role == v {
c.Next()
return
}
}
response.Fail(c, errcode.ErrForbidden)
c.Abort()
}
}

roles ...string 是变参,返回的匿名函数捕获了传进来的 roles——这就是闭包。于是路由可以写得像自然语言:

1
2
3
4
r.GET("/api/admin/ping",
middleware.Auth(),
middleware.RequireRole(model.RoleLostAdmin, model.RoleSysAdmin),
handler.Ping)

注意 middleware.Auth() 后面有括号:被调用后返回的那个函数才是真正的中间件。这个理解一旦通了,Gin 中间件就不再神秘。

还有两个必须记住的:

  • c.Abort() 是”后面别走了”,c.Next() 是”我处理完了,继续往下”。鉴权失败一定要 Abort,否则请求照样会打到业务 handler。
  • for _, v := range roles 里的第一个值是下标,用不上就用 _ 丢掉。Go 不允许声明了不用的变量。

五、错误是值,不是异常

Go 没有 try/catch。错误就是一个普通的返回值,error 是个内置接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var ErrUserExists = errors.New("用户已存在")

func RegisterUser(username, password string) (*model.User, error) {
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
user := &model.User{Username: username, Password: string(hashed), Role: model.RoleUser}

err = repository.CreateUser(user)
if err != nil {
if strings.Contains(err.Error(), "Duplicate entry") {
return nil, ErrUserExists
}
return nil, err
}
return user, nil
}

三个值得说的习惯:

1. if err != nil 之后立刻 return。 这个模式会重复很多遍,看着啰嗦;但换来的是错误路径一目了然——你不用去猜哪一行可能抛异常,只要看 err 有没有被检查。

2. 用 errors.New 定义”哨兵错误”,调用方用 errors.Is 判断。

1
2
3
4
5
6
7
user, err := repository.FindByUsername(username)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrInvalidCredentials
}
return nil, err
}

这里有个小心思:用户不存在和密码错误,返回的是同一个错误。这是故意的——如果分开返回,别人就能把登录接口当用户枚举器用,一个个试出哪些用户名已经注册。这叫用户枚举防护。

3. 在边界层做错误翻译。 strings.Contains(err.Error(), "Duplicate entry") 这行有点丑,它是去匹配 MySQL 的原始报错文本;但唯一约束在数据库层面,这是最直接的做法。重点在于别让数据库的报错原文漏给前端,翻译成 ErrUserExists 再说。

最后一个坑:**error 是接口,接口可能是 nil,但”装了 nil 指针的接口”不等于 nil。**

1
2
3
4
func f() error {
var p *MyErr = nil
return p // 返回值不是 nil!
}

函数返回 (*User, error) 这种组合时要特别小心,别在这种地方让调用方判断失误。

六、defer:拿到资源的那一行就写好怎么收尾

1
2
3
4
5
6
7
8
9
10
11
12
13
opened, err := fh.Open()
if err != nil {
return "", err
}
defer opened.Close()

f, err := os.Create(filepath.Join(uploadDir, photo))
if err != nil {
return "", err
}
defer f.Close()

_, err = io.Copy(f, opened)

defer 的语义是”函数返回之前执行”,不管是正常 return 还是中途 return。上面这段如果不用 defer,得在四个出错分支里各写一次 Close,漏一个就泄漏一个文件句柄。

两个小知识:

  • 多个 defer 是后进先出执行的(栈的顺序)。
  • defer 后面函数的参数在注册那一刻就求值了,不是执行时才求值。所以 defer f(x) 里的 x 是当时的 x。

分层的意义是”能单独改、能单独测”。 一开始我所有逻辑写在 handler 里,改一个校验规则要翻整个文件。拆完之后,换 token 实现只动 service,换数据库只动 repository,handler 一行不用改。

写在最后

回头看,真正卡住我的其实不是”Go 语法难”,而是这三类东西:

  • 看起来像别的语言、行为却不一样——:= 是声明会遮蔽、required 不等于非空、JSON 数字是 float64。
  • 需要重新建心智模型——错误是值、函数是值、闭包捕获。
  • 代码之外的工程习惯——别硬编码密钥、分层、拿到资源立刻 defer。

哦对,说到硬编码密钥。最开始我的数据库密码就直接写在 main.go 的字符串里。私有仓库看着没事,可一旦要发文章、截图、开源,密码就跟着飞出去了。JWT 密钥更危险——它泄露了,任何人都能伪造别人的身份登录。后来我抽了个 config 包,先把密钥挪了进去:

1
2
3
4
5
6
7
8
9
func getEnv(key string, fallback string) string {
value := os.Getenv(key)
if value == "" {
return fallback
}
return value
}

var JWTSecret = getEnv("JWT_SECRET", "")

默认值给空串,逼自己必须在环境变量里配。

另外,这篇文章贴出来之前我逐段检查过,确认没有真实密码和密钥漏出去。

如果你也在从零上手 Go 后端,我的建议是:实战大于语法喵

评论

用 GitHub 账号登录后发言。