原文: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{} 出现的次数远多于 any,sort.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.mod、go.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-08、2026-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。
any、errors_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-Z、2-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 -20 或 grep 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 的已知行为,Streamlit 和 n8n 都遇到过同样的问题。
原因与解决方案:把端点改成 /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.Or、min、slices.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-file。go-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)



