IT加油站

JetBrains go-modern-guidelines:AI 辅助编写现代 Go 代码

25浏览 1天前 软件教程 MA122875

原文:https://dev.to/gde/go-in-practice-writing-modern-go-with-ai-testing-jetbrains-go-modern-guidelines-and-refactoring-151o(作者 @evanlin)

背景

在 AI 时代,大部分代码优化或编写任务都可以交给 AI。然而,由于模型训练数据的因素,太多写法已经过时。这导致代码无法利用最新 Go 版本的特性,实在可惜。

好在 JetBrains 发布了 go-modern-guidelines,一个很实用的插件。它能让你的 AI Agent 更聪明,教它如何使用最新语法来优化你的 Golang 代码。

*

go-modern-guidelines 是什么?

它要解决的问题:模型有知识截止日期,Go 没有

这个项目的定位非常明确:为 AI 代理提供当代 Go 编写规范,使其不会因知识截止而写过时的 Go 代码。

问题有两层。第一层容易理解:训练数据有截止日期。截止日期之后加入标准库的任何东西都不会被使用,因为模型没见过。项目自己的例子是 errors.AsType[T](Go 1.26);模型没见过,自然不会写。

第二层更微妙,项目称之为频率偏差:即使模型"知道"新写法,旧写法在训练数据中出现的频率也压倒性地高。互联网上十年的 Go 代码中,interface{} 出现的次数远多于 anysort.Slice 远多于 slices.SortFunc。模型做的是概率预测,多数票胜出的通常是旧写法。

在这次重构中确实可以看到第二点。原项目有这样一段代码:

// oauth2 库可能在刷新令牌过期、被撤销或其他无效情况时
// 返回包含 "invalid_grant" 的错误。
if err != nil {
    errorStr := err.Error()
    // 基础子字符串检查,以避免导入 "strings"
    for i := 0; i <= len(errorStr)-13; i++ {
        if errorStr[i:i+13] == "invalid_grant" {
            return true
        }
    }


手写的字符串搜索,注释还专门解释"为了避免导入 strings"。strings 就在标准库里,导入它的成本为零。这段代码真正需要的只是一行:strings.Contains(err.Error(), "invalid_grant")

工作原理:两个命令,一份随 Go 版本增长的列表

工具本身是一个 CLI,只有两个子命令:

list [--go-version <version> | --file-path <path>]
    Returns a list of guidelines supported by this Go version, sorted from newest to oldest.

explain <id>...
    Returns detailed explanations and before/after examples for specific guidelines.


list 的关键在于它会根据 Go 版本给出不同的答案。你可以直接传一个文件路径,它会查找 go.modgo.work,或者回退到本地 Go 工具链:

$ go-modern-guidelines list --file-path ~/Documents/linebot-file/main.go


项目 go.mod 指定了 go 1.24.0,所以返回了 45 条规范。改版本号,数量也会变:

| Go 版本 | 规范数量 |

| --- | --- |

| 1.21 | 32 |

| 1.22 | 37 |

| 1.23 | 41 |

| 1.24 | 45 |

| 1.25 | 46 |

| 1.26 | 48 |

| 1.27 | 54 |

这个设计是有意为之:它只建议你的项目版本实际能用的语法。 这对 AI 代理至关重要;否则它可能兴冲冲地建议 errors.AsType[T],而你的 CI 因为跑在 Go 1.24 上而失败。

看版本之间的差异,本质上是 Go 近期特性的浓缩列表:

$ diff <(list --go-version 1.21) <(list --go-version 1.22)
> range_over_int: Use for i := range n when iterating from 0 to n-1.
> loopvar_capture: Do not add redundant loop-variable copies before closures or
  taking addresses; Go 1.22 gives each iteration its own variables.
> cmp_or: Use cmp.Or to pick the first non-zero value from a fallback chain.
> reflect_type_for: Use reflect.TypeFor[T]() instead of reflect.TypeOf((*T)(nil)).Elem().
> http_servemux_patterns: Use method-aware ServeMux patterns and r.PathValue for
  path parameters.


list 提供一行摘要;准备好动手时用 explain 查看详细说明。输出如下:

$ go-modern-guidelines explain cmp_or

cmp_or:
  Since: Go 1.22

  Summary:
    Use cmp.Or to pick the first non-zero value from a fallback chain.

  Details:
    cmp.Or returns the first non-zero value from its arguments. It is concise
    for simple fallback chains, but remember that all arguments are evaluated
    before the call.

  Examples:

  Before:
    name := os.Getenv("NAME")
    if name == "" {
      name = "default"
    }

  After:
    name := cmp.Or(os.Getenv("NAME"), "default")


注意 Details 部分最后一句话:"all arguments are evaluated before the call." 这是 cmp.Or 真正的陷阱——如果你的回退源是一个昂贵的函数调用,写 cmp.Or(a(), b()) 会把两个函数都执行一遍。这种"可以用,但要知道代价"的提醒,比单纯告诉你改语法有用得多。

两层信息设计其实是为了节省上下文

这种 list/explain 的分层看起来像是界面设计,但实际上是为了 AI agent 的上下文窗口考虑。45 条准则,每条一行摘要,大约占用 1000 tokens;但如果每条都包含完整解释和前后对比示例,光塞进这个列表就要消耗数万 tokens。

所以工作流程是:先调用 list 扫描全部内容,确定哪些准则与当前代码相关,然后只对那些相关的调用 explain。在这次实践中,我实际上只 explain 了六条准则。

graph TD
    A[Prepare to modify Go code] --> B[list --file-path main.go]
    B --> C[Parse Go version from go.mod]
    C --> D[Return 45 guidelines available for that version<br/>one-line summary each]
    D --> E{Which ones are relevant to this code?}
    E -->|Pick candidates| F[explain cmp_or min_max ...]
    F --> G[Get detailed explanations and before/after]
    G --> H[Actually apply to code]
    E -->|None relevant| I[Write in original way]


skill 文档中有一条规则写得特别强调:不要将 `list` 的输出通过管道传给 `head`、`tail` 或 `grep`,否则可能会遗漏重要的准则。我在第一次尝试时就违反了这条规则,后面在"踩坑"部分会详细讨论。

安装

对于 Claude Code,只需两行命令:

/plugin marketplace add JetBrains/go-modern-guidelines
/plugin install modern-go-guidelines@goland-claude-marketplace


安装完成后,它会在 Go 相关任务中自动触发,也可以手动调用:/modern-go-guidelines:use-modern-go。Cursor、Junie 和 Codex 有各自的安装方式;其他 agent 可以使用 npx skills add JetBrains/go-modern-guidelines。该项目采用 Apache 2.0 许可证。

首次运行时,包装脚本会自动将 CLI 安装到本地缓存目录:

go-modern-guidelines: installing github.com/JetBrains/go-modern-guidelines@v0.1.1
  into /Users/xxx/.cache/go-modern-guidelines/v0.1.1


*

这个项目:一个膨胀到 1039 行的 main.go

先交代一下背景。linebot-file 的架构并不复杂:

graph LR
    A[LINE App] -->|Send file| B[LINE Platform]
    B -->|webhook| C[Cloud Run]
    C -->|Read token| D[(Firestore)]
    C -->|Upload/Query| E[Google Drive API]


用户通过 /connect_drive 授权,令牌存储在 Firestore 中,发送到聊天室的文件会自动上传到类似 LINE Bot Uploads/YYYY-MM/ 的文件夹结构中。功能是逐步添加的,所有东西都堆进了 main.go,其中 main() 函数本身就占了 564 行。

*

健康检查发现了什么

这一节与 go-modern-guidelines 没有直接关系——那个工具管的是"写法是否现代",而不是"逻辑是否正确"。但这些才是真正会坑到用户的问题,所以还是记录下来。

1. 能编译但永远不会执行的代码

这是最有意思的一个。原始的事件处理长这样:

switch e := event.(type) {
case webhook.MessageEvent:
    switch message := e.Message.(type) {
    case webhook.TextMessageContent:
        // ...
    case webhook.FileMessageContent:
        // ...
    case webhook.FollowEvent: // ← 注意这里的缩进层级
        if s, ok := e.Source.(*webhook.UserSource); ok {
            bot.LinkRichMenuIdToUser(s.UserId, richMenuConnect)
        }
    }
}


webhook.FollowEvent 被写在了内层 switch 里。内层 switch 判断的是 e.Message,类型为 MessageContentInterface——而关注事件永远不可能成为消息的内容。

为什么能编译通过?Go 确实会检查类型 switch;如果某个 case 的类型不可能实现该接口,编译器会报 impossible type switch case。问题出在 SDK 的接口定义上:

type MessageContentInterface interface {
    GetType() string
}


它只要求一个 GetType() string 方法。而 FollowEvent 恰好有这个方法(所有事件类型都有),所以在类型系统中,它"可以"是 MessageContentInterface。编译器放行了,但运行时永远不会匹配到。

实际后果:新用户添加 bot 为好友时,用于引导授权的 Rich Menu 从未被绑定。 这个功能可能已经坏了很久了,因为它不报错,只是默默什么都不做。

2. 群组消息导致 panic

userID := e.Source.(webhook.UserSource).UserId


未检查的类型断言出现了六次。只要 bot 被拉进群组且有人发图片,这一行就会 panic。

顺便说一下,同一个文件中还有几处用了 e.Source.(*webhook.GroupSource)(指针)。查看 SDK 的 UnmarshalSource,它返回的是而非指针,所以那些带 , ok 的断言结果始终为 false——同样是死代码。同一个文件里,两种截然不同的错误方式,方向还完全相反。

3. /recent_files 返回的是文件夹

// 上传时:文件放在 LINE Bot Uploads/YYYY-MM/
monthFolderID, _ := findOrCreateFolder(srv, "2026-08", mainFolderID)
srv.Files.Create(&drive.File{Parents: []string{monthFolderID}})

// 查询时:只在 LINE Bot Uploads 下查找
query := fmt.Sprintf("'%s' in parents and trashed=false", mainFolderID)


文件存储在按月划分的子文件夹中,但查询只查看了根文件夹。在 Google Drive 的数据模型中,文件夹也是一种文件类型,所以这个查询确实会返回结果——它返回的是 2026-082026-07 这些文件夹本身。

4. 用户输入直接拼接进 Drive 查询语句

query := fmt.Sprintf("... and name contains '%s'", searchQuery)


没有做转义处理。如果用户搜索 it's,那个单引号会破坏查询语法;进一步想,还可能注入额外的查询条件。修复方法是正确编写一个转义函数,注意转义顺序不能颠倒:

// 反斜杠必须先转义,否则为转义引号而添加的反斜杠
// 会在第二轮处理中被再次转义。
func escapeDriveQuery(s string) string {
    s = strings.ReplaceAll(s, `\`, `\\`)
    return strings.ReplaceAll(s, `'`, `\'`)
}


5. /quit 被当作搜索命令处理

} else if (len(message.Text) > 13 && message.Text[:13] == "/search_files") ||
          (len(message.Text) > 2 && message.Text[:2] == "/q") {
    commandPrefixLen := 0
    if ... {
        commandPrefixLen = 14 // "/search_files " 的长度
    } else if ... {
        commandPrefixLen = 3 // "/q " 的长度
    }
    searchQuery = message.Text[commandPrefixLen:]


手动字符串切片,而且硬编码了"后面必须跟一个空格"。如果用户输入 /quit,前两个字符是 /q,于是变成了搜索 it

*

go-modern-guidelines 实际改了什么

回到正题。list 给出的 45 条指南中,以下是在本次改动中实际应用的:

http_servemux_patterns:还移除了一段手动路径检查

原来的做法是让所有请求都进入同一个 handler,然后手动判断路径:

http.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
    // LINE Platform 必须以 POST 方式请求 webhook URL
    if req.URL.Path != "/" {
        http.NotFound(w, req)
        return
    }
    // ...
})


Go 1.22 之后,ServeMux 模式支持方法和精确路径匹配:

mux := http.NewServeMux()
// "/{$}" 只匹配根路径,不匹配其下所有路径
mux.HandleFunc("POST /{$}", webhookHandler)
mux.HandleFunc("GET /oauth/callback", oauthCallbackHandler)
// 不能叫 /healthz,原因见 Pitfall 5
mux.HandleFunc("GET /health", healthHandler)


{$} 语法是关键:ServeMux 中的 "/" 是一个子树模式,会匹配其下所有路径,这就是原代码需要手动检查的原因。"/{$}" 只匹配根路径本身,所以不再需要检查。顺便还加了方法限制和健康检查端点。

健康检查端点的问题是后来才发现的,但那是部署之后的事,留到 Pitfall 5 再说。

cmp_or:三段回退逻辑变成三行

// 改之前
port := os.Getenv("PORT")
if port == "" {
    port = "5000"
}

// 改之后(默认值也改为 8080,与 Dockerfile EXPOSE 和 Cloud Run 惯例对齐)
port := cmp.Or(os.Getenv("PORT"), "8080")
richMenuConnect = cmp.Or(os.Getenv("RICH_MENU_CONNECT"), defaultRichMenuConnect)
richMenuMain = cmp.Or(os.Getenv("RICH_MENU_MAIN"), defaultRichMenuMain)


这恰好符合 explain 提醒的使用条件:三个参数都是 os.Getenv 和常量,且全部求值没有副作用。

strings_cut_prefix_suffix:替换手动字符串切片

前面第五点的命令解析 message.Text[:13],被替换成了正经的解析函数:

func parseCommand(text string) (name, arg string, ok bool) {
    text = strings.TrimSpace(text)
    if !strings.HasPrefix(text, "/") {
        return "", "", false
    }

    name, arg, _ = strings.Cut(text, " ")
    switch name {
    case cmdConnect, cmdReconnect, cmdDisconnect, cmdRecent, cmdSearch, cmdSearchShort:
        return name, strings.TrimSpace(arg), true
    }
    return "", "", false
}


改用 strings.Cut 先切出完整命令名,再用 switch 做比较,从结构上消除了 /quit 的 bug——切出来的名字是 /quit,不在允许列表中,直接返回 false。

slices_sort_func + min:修复那个假排序

去重后的原始搜索结果是这样的:

// 去重并按创建时间排序(最新的在前)
uniqueFiles := make(map[string]*drive.File)
for _, file := range files {
    if _, exists := uniqueFiles[file.Id]; !exists {
        uniqueFiles[file.Id] = file
    }
}

result := make([]*drive.File, 0, len(uniqueFiles))
for _, file := range uniqueFiles {
    result = append(result, file)
}

if len(result) > 10 {
    result = result[:10]
}


注释写着"按创建时间排序(最新的在前)",但实际上根本没有排序操作——map 的迭代顺序是随机的,然后只是截取了前 10 条。所以用户拿到的是 10 条随机结果,而不是最新的 10 条。

// Drive 返回的 createdTime 是 RFC 3339 UTC 字符串;直接做字符串比较就是正确的时间顺序
func sortAndTrimFiles(files []*drive.File, limit int) []*drive.File {
    slices.SortStableFunc(files, func(a, b *drive.File) int {
        return cmp.Compare(b.CreatedTime, a.CreatedTime)
    })
    return files[:min(len(files), limit)]
}


min 内置函数(Go 1.21)在这里省掉了一个 if

anyerrors_is:小细节

map[string]interface{} 改成 map[string]any;这种一行代码的改动就不多说了。

crypto/rand.Text():一个需要先升级版本的建议

原来生成 OAuth state 的方式:

func generateState() string {
    b := make([]byte, 16)
    rand.Read(b) // 忽略错误
    return base64.URLEncoding.EncodeToString(b)
}


crypto/rand.Text() 是 Go 1.24 新增的,直接返回随机字符串,不会失败,输出是 base32(A-Z2-7),天然 URL 安全——非常适合用作 state 和 Firestore 文档 ID:

func generateState() string {
    return rand.Text()
}


但这个有一个前提条件,下面会讨论。

*

主要坑点与解决方案

坑点 1:技能文档明确说不要用 grep,我第一次偏偏反着来

技能文档写得很清楚:

不要通过 head、tail、grep、sed 或任何其他截断/过滤命令来管道处理输出。否则可能会遗漏重要的准则。

第一次调用时,我输入了:

$ go-modern-guidelines list --file-path main.go 2>&1 | tail -60


纯粹是怕长输出刷屏的条件反射。事后想想,这有两个层面的危险:第一,list 明确说明它是从新到旧排序的,所以 tail 拿到的恰好是最旧的那批;第二,这次能侥幸过关只是因为 go.mod 指定的是 1.23,总共 41 行,不到 60 行,所以 tail -60 把全部内容都打印出来了。

原因与解决方案:纯粹是运气。如果项目是 Go 1.27(54 条准则),tail -60 仍然不会截断;但如果我输入的是 head -20grep slices,就会整批整批地漏掉条目,而且没有任何提示说漏了东西。这种"输出被截断了但看起来很正常"的失败最难发现。老老实实读完整输出吧,也就 45 行。

坑点 2:你的 go.mod 可能不支持工具建议的语法

rand.Text() 出现在建议列表里,但当时项目的 go.mod 是:

module github.com/kkdai/linebot-file

// +heroku goVersion go1.21
go 1.23.0

toolchain go1.24.3


go 1.23.0 这一行决定了语言版本,和 toolchain 是不同的概念。工具根据它能解析的版本来给出建议,但要真正使用 rand.Text(),必须修改 go.mod

这不是无脑改一行的事,得保证整条链上的一致:toolchain 已经是 go1.24.3,Dockerfile 用的是 golang:1.24-alpine,都没问题。但 CI 有问题——.github/workflows/go.yml 硬编码了 go-version: '1.22',比 go.mod 要求的版本还旧;目前没出问题只是因为 Go 的自动 toolchain 下载机制。

原因与解决方案:把 go.mod 更新为 go 1.24.0,清掉那行过时的 // +heroku goVersion go1.21(这个项目早就跑在 Cloud Run 上了),把 CI 改成以 go.mod 为唯一事实来源:

- uses: actions/setup-go@v5
  with:
    # 以 go.mod 为唯一事实来源,避免 CI 与项目版本不一致
    go-version-file: go.mod


陷阱三:修改 go.mod 后,工具给出的建议变了

这是本次最有趣的发现。升级 go.mod 后,我在写测试之前重新运行了 list,发现列表顶部多了四条新内容:

testing_t_context: Use t.Context() when a test function needs a context tied to
                   the test lifetime.
json_omitzero: Use omitzero on JSON-tagged bool, numeric, struct, and time
                   fields whose zero value should be omitted...
testing_b_loop: Use b.Loop() for the main loop in benchmark functions.
strings_split_seq: Use strings or bytes SplitSeq and FieldsSeq helpers...


这四条正是 Go 1.24 中新增的内容。其中 testing_t_context 直接改变了我正在写的测试:

// 修改前
srv, err := drive.NewService(context.Background(),
    option.WithEndpoint(server.URL), option.WithoutAuthentication())

// 修改后 — context 绑定到测试生命周期,测试结束时自动取消
srv, err := drive.NewService(t.Context(),
    option.WithEndpoint(server.URL), option.WithoutAuthentication())


原因与解决方案:这个工具的输出会随项目状态变化,它不是一份静态文档。升级版本或切换项目,给出的建议就会不同。所以正确的用法不是在开始时跑一次就完事,而是在变更性质发生转变的节点重新运行——我的情况是,在"主程序写完、开始写测试"这个交界点重新跑了一次,恰好捕获到了 testing_t_context。如果我只在最开始检查过一次,就会错过它。

陷阱四:工具管的是语法,不是架构——而架构层面的陷阱更深

这是反面情况:go-modern-guidelines 不该、也不会对此发表意见。

最初,文件上传是在 webhook 处理器中同步完成的:从 LINE 下载视频再上传到 Drive 可能需要几十秒。LINE 期望在一定时间内收到响应;如果超时,它会重试,而重试会导致同一文件被重复上传

对此的标准建议几乎是条件反射式的:先返回 200,把剩下的活扔进 goroutine。我一开始也是这么想的,但写到一半想起了一件事——这个服务跑在 Cloud Run 上,默认只在请求处理期间分配 CPU。 一旦响应发出,那个 goroutine 就会被 CPU 限流,变成一个看起来在干活、实际上不知道什么时候才能跑完的黑洞。这比同步处理还糟糕;至少同步处理会老老实实地失败。

原因与解决方案:改用 webhook 的事件 ID 做去重,这样重试不会导致重复上传,同时保持同步处理:

// handledEvents 记录最近处理过的 webhook 事件 ID。LINE 会将认为失败的请求重发;
// 没有这个保护,重发会导致同一文件被再次上传。
type handledEvents struct {
    mu sync.Mutex
    seen map[string]time.Time
}

func (h *handledEvents) markHandled(id string) bool {
    if id == "" {
        return true // 没有事件 ID 就无法去重,当作新事件处理
    }
    // ... 清除过期记录,然后检查是否重复
}


另外加了一个兜底逻辑:"如果回复令牌过期,改用 push message 发送",这样用户在大文件上传完成后仍能收到通知。

这是一个折中方案;真正的解决方案是用 Cloud Tasks 或 Pub/Sub。我已经把它加进了项目路线图,并注明了原因"不能直接用 goroutine"——否则下一个接手的人(很可能就是三个月后的我)大概率会再踩同一个坑。

陷阱五:ServeMux 写得没问题,但 Cloud Run 不让你用

PR 合并后,我用 gcloud 检查了 Cloud Build,状态是 SUCCESS,新版本已就绪,所有流量都已切换过来。看起来大功告成。

戳一下各个端点:

GET / 405 ← 方法感知的 ServeMux 生效
POST / No signature 400 ← 签名验证生效
GET /nope 404 ← {$} 精确匹配生效
GET /healthz 404 ← ?


前三个都正确,但健康检查返回了 404。

一开始我以为是自己路由写错了,但打印响应内容后才发现不对劲——那是一个 Google 品牌的 HTML 错误页面(Error 404 (Not Found)!!1,带着 Google 机器人图片),不是 Go 的 404 page not found 纯文本。这意味着请求根本没到达我的程序。

查看 Cloud Run 请求日志证实了这一点:

15:44:54 GET 400 /oauth/callback
15:44:36 GET 404 /nope
15:44:36 POST 400 /
15:44:36 GET 405 /


我发了五个请求,但日志里只有四个。两个 /healthz 请求连记录都没有。

扫描各种常见健康检查路径后,范围大幅缩小:

/healthz 404 GFE(Google) ← 被拦截
/healthz/ 404 app(Go) ← 只差一个斜杠
/health 404 app(Go)
/readyz 404 app(Go)
/livez 404 app(Go)
/_ah/health 404 app(Go)
/status 404 app(Go)
/ping 404 app(Go)
/healthcheck 404 app(Go)


只有精确路径 /healthz 被 Google Frontend 拦截;哪怕多加一个斜杠,请求就能正常到达应用。我查了一下,发现这是 Cloud Run 的已知行为,Streamlitn8n 都遇到过同样的问题。

原因与解决方案:把端点改成 /health,一行修复。烦人的是,这个坑一点声响都没有——go vet 不吭声,测试不吭声,CI 全绿,构建成功,Cloud Run 显示 Ready,连请求日志都不留痕迹。唯一的发现方式就是实际去戳端点,然后注意到返回的 404 跟你程序返回的 404 长得不一样。

所以修复时,我在代码里留了注释,也在 README 里写了一段说明:

// Not "/healthz": Cloud Run's frontend reserves that exact path and
// answers it with its own 404, so the request never reaches us.
mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) {


没有这行注释,下一个人看到 /health 就会觉得"这不合惯例,应该是 healthz"(很可能就是我自己),然后把它改回去。

陷阱六:我以为所有外部调用都加了 context,却漏掉了整整一条路径

/healthz 的时候,我又扫了一遍代码,发现了一个更尴尬的问题。

之前我非常满意的一个改动是"全程使用 context,所有外部调用都加了超时"。Firestore 有,Drive 也有。然后我 grep 了所有外部调用:

webhook.go:312 blob.GetMessageContent(messageID)
line.go:73 bot.ReplyMessage(...)
line.go:94 bot.PushMessage(...)
line.go:118 bot.LinkRichMenuIdToUser(...)


四个 LINE 调用,没有一个接受 context。我不得不深入 SDK 才找到原因:

c := &MessagingApiAPI{
    channelToken: channelToken,
    httpClient: http.DefaultClient, // ← Timeout 为零,意味着没有超时
}


而且 SDK 生成的方法签名不接受 context,所以我在外层 handler 里包装的超时对这些调用完全没有效果。如果 LINE 那边挂起,goroutine 就会无限期挂起。

原因与解决方案:问题不是我不知道要加超时,而是"我已经加了 context"的记忆覆盖了"这个 SDK 到底接不接受 context"的事实。改 Drive 和 Firestore 的时候,我一路顺畅地加 .Context(ctx)——顺畅到我没停下来想想还有哪些外部调用不是这个样子。

SDK 提供了注入点:

bot, err = messaging_api.NewMessagingApiAPI(accessToken,
    messaging_api.WithHTTPClient(&http.Client{Timeout: lineAPITimeout})) // 10 seconds

blob, err = messaging_api.NewMessagingApiBlobAPI(accessToken,
    messaging_api.WithBlobHTTPClient(&http.Client{Timeout: lineBlobTimeout})) // 5 minutes


我给 blob 那边设了 5 分钟,因为它需要下载用户发送的视频。

还有一个更隐蔽的陷阱。SDK 提供了一个看起来正是我想要的东西:

func (call *MessagingApiAPI) WithContext(ctx context.Context) *MessagingApiAPI {
    call.ctx = ctx
    return call
}


它直接覆盖一个共享结构的字段,然后返回同一个指针。我的 bot 是包级共享变量;如果多个请求同时进来,每个都调用 WithContext,这就是标准的数据竞争。名字听起来像函数式选项,行为却是突变。我也在代码里给这行留了注释,防止以后有人觉得它比 WithHTTPClient 更精确而改回去。

陷阱 7:不吭声的 Bug 需要测试主动去撞

同一轮还发现了另一个问题。uploadParents 函数负责列出所有 YYYY-MM 月份文件夹,原始写法如下:

r, err := srv.Files.List().Q(query).Fields("files(id)").Context(ctx).Do()


没有设置 PageSize。Drive API 默认每页返回 100 条,超出部分需要用 nextPageToken 再次请求。每个月生成一个文件夹,所以过了 100 个月——大约 8 年 4 个月——最早的文件夹就会从搜索范围和 /recent_files 中消失。不会报错,不会警告,只是结果变少了。

原因与解决方案:改用 Pages() 遍历所有分页。但我真正想说的是下一步。我对自己刚写的分页逻辑不太放心,于是写了一个 mock server,让它返回 nextPageToken,然后临时把实现改回只取第一页,看测试会不会失败:

--- FAIL: TestUploadParentsPagesThroughAllSubfolders
    uploadParents() = [root_id month_1 month_2], want [root_id month_1 month_2 month_3]


确认测试确实会失败后,再把实现改回来。这一步花了不到两分钟,但如果没有它,我只有一个"跑通了"的测试,却不知道它到底测了什么。静默截断这类 Bug 不会自己暴露;如果测试也是一片绿色的假象,你就什么都没有。

*

成果与收益

先看最直接的数字。重构前 main.go 有 1039 行,main() 有 564 行。拆分成六个文件后:

| 文件 | 行数 | 职责 |

| --- | --- | --- |

| main.go | 94 | 启动、环境变量检查、路由 |

| config.go | 74 | 常量与共享状态 |

| webhook.go | 344 | 事件分发、命令解析、命令处理 |

| line.go | 164 | LINE 消息组装 |

| drive.go | 183 | Drive 查询/上传 |

| auth.go | 264 | OAuth、令牌、撤销 |

main() 从 564 行降到了 62 行。有意思的是,主代码总行数几乎没有变化(1039 → 1123);真正显著增长的是测试:从 88 行增加到 468 行,测试数量从 1 个增加到 16 个,全部在 -race 下通过。

性能方面,搜索功能原来是"先找到根文件夹,再逐个检查每个月份子文件夹",即 1 + N 次 Drive API 调用;改成用 or 把所有父级拼进单条查询后,固定为 2 次调用。

`go-modern-guidelines` 的价值不在于教了我没见过的语法。 cmp.Orminslices.SortFunc 这些我大致都知道;问题在于编码时我不会主动想到它们——尤其是在修改一个已有文件时,周围的老语法会产生一种引力,让你自然而然地继续用同样的风格写下去。技能文档里有一句话说到了点子上:

如果某条指南适用,就遵循它,即使附近的代码或仓库惯例使用的是更旧的模式。

这句话正是在对抗前面提到的频率偏差,对人类同样适用。

它的边界也很清晰。 那五个真正会咬到用户的 Bug——switch 层级错误、类型断言 panic、查询返回了文件夹、查询未转义、/quit 误判——没有一个被 go-modern-guidelines 抓到;那不在它的防御范围内。它管的是"这段 Go 代码够不够现代",而不是"这段逻辑对不对"。把它当作 linter 的补充,而不是代码审查的替代品。

本文前半部分是在部署之前写的。 /healthz 那个陷阱是在文章写完、PR 合并之后,我去 gcloud 检查构建状态时才发现的。当时的情况是:45 条指南全部检查,所有适用的都已落实,17 个测试在 -race 下通过,三项 CI 检查全绿,Cloud Build SUCCESS,Cloud Run 显示 Ready 且 100% 流量已切换。在这一整排绿灯里,没有一个告诉你某个端点已经死了。

我在上一篇关于处理 Cloudflare 的文章中写过"构建成功不能等同于验证通过",我以为自己记住了,但这次又在同一个地方交了学费,只是层级不同——上次是构建成功但容器启动即崩溃;这次是容器跑得好好的,却被外层基础设施层吃掉了。工具管语法,测试管逻辑,CI 管这两者有没有退化,但它们都不管"把这个东西部署到那个特定环境后会发生什么"。那部分你得自己去戳。

记住,输出会随项目状态变化。 陷阱 3 中"升级 go.mod 后又多出四条建议"的现象,是这次最实际的体会。它不是一份查一次就完事的静态文档,而是一个根据项目当前状态回答问题的查询接口。当变更的性质发生变化时(从主程序转向测试、语言版本升级、项目切换),值得再跑一遍。

最后,本次改动在 PR #4 中,部署后新增的两个修复分别在 #5#6 中,完整代码仓库为 kkdai/linebot-filego-modern-guidelines 的源码位于 JetBrains/go-modern-guidelines,采用 Apache 2.0 许可证。

原文:https://dev.to/gde/go-in-practice-writing-modern-go-with-ai-testing-jetbrains-go-modern-guidelines-and-refactoring-151o(作者 @evanlin)

#go-modern-guidelines #Go语言 #AI编程 #JetBrains #代码重构