CClara轻量论坛系统

Go 语言热重载开发:air / reflex 工具使用

小易先生
小易先生 见习用户见习用户
发布于 2026-11-14 00:23 ·2 浏览 ·7 回复
内容摘要

介绍通过 go install 安装 air 和 reflex,用 air init 生成 .air.toml 并配置编译命令、监听后缀、延迟触发、错误停止与优雅退出,强调排除 tmp_dir 等目录以避免无限重启、容器环境启用轮询并按实际入口调整路径,从而在 Go 项目中实现保存代码后自动重编译重启。

学完这篇你能得到:在自己的 Go 项目里搭好「改代码保存就自动重编译重启」的开发环境,并且知道 air 和 reflex 各自适合什么场景,不用再手动 Ctrl+C 再 go run。

第一步:把工具装到能直接调用的位置

air 和 reflex 都是 Go 写的,用 go install 装:

go install github.com/air-verse/air@latest
go install github.com/cespare/reflex@latest

装完先确认能敲出来:

air -v
reflex --version

注意:go install 的产物落在 $(go env GOPATH)/bin,这个目录默认不在 PATH 里。报 command not found 的话先执行 export PATH=$PATH:$(go env GOPATH)/bin,并写进 ~/.zshrc 或 ~/.bashrc。

第二步:用 air 起一个项目

进到项目根目录(有 go.mod 的那一层),执行:

air init

它会生成一份 .air.toml。如果版本较老没有 init 子命令,手动新建同名文件即可,然后直接 air 启动。最小可用配置长这样:

root = "."
tmp_dir = "tmp"

[build]
  cmd = "go build -o ./tmp/main ./cmd/app"
  bin = "./tmp/main"
  include_ext = ["go", "tpl", "tmpl", "html"]
  exclude_dir = ["assets", "tmp", "vendor", "node_modules", ".git"]
  delay = 500
  stop_on_error = true
  send_interrupt = true
  kill_delay = "2s"

[log]
  time = true

几个关键项的作用:

  • cmd:编译命令,输出到 tmp_dir 里
  • bin:编译产物路径,air 负责运行它
  • include_ext:监听哪些后缀,Web 项目别漏了模板后缀
  • delay:文件变动后等多久再触发,防止编辑器保存多个文件时连打好几次
  • stop_on_error:编译失败时保留旧进程还是停掉,建议 true,错误一眼看得见

注意:exclude_dir 里一定要排除 tmp_dir 和 node_modules。编译产物如果落在被监听的目录里,会出现「编译 → 触发变动 → 再编译」的无限重启。

第三步:几个必须按项目改的点

入口不是 main.go 在根目录:改 cmd 和 bin 成你的实际路径,比如 go build -o ./tmp/main ./cmd/server。

用 Docker 或虚拟机挂载目录:容器里的 inotify 常常收不到宿主机的文件事件,在 [build] 里加 poll = true,改成轮询模式才有效。

要优雅退出:服务里有连接池、要关 DB 的话,send_interrupt = true 配合 kill_delay = "2s",让旧进程收到信号后有时间收尾,而不是被直接杀掉。

编译 + 前端一起跑:cmd 可以写复合命令,例如 go build -o ./tmp/main ./cmd/app && npm run build(Windows 下把 && 换成用 cmd /c 包一层)。

第四步:reflex —— 不用写配置文件的那条路

reflex 本质是个通用文件监听器,不绑定 Go,一行命令就能跑:

reflex -r '\.go$' -s -- sh -c 'go build -o ./tmp/app ./cmd/app && ./tmp/app'

参数含义:

  • -r '\.go$':监听匹配这个正则的文件
  • -s:启动时先执行一次,不用等你改代码
  • -R 'vendor|tmp':反向排除,不需要监听的目录
  • -g '*.html':按 glob 匹配,和 -r 二选一或叠加
  • --:后面跟真正要执行的命令

想把参数固定下来,写进配置文件用 -c 加载:

reflex -c reflex.conf

reflex.conf 里每行一组参数,# 开头是注释。

注意:reflex 不要直接 -- go run ./cmd/app。reflex 杀的是它启动的那个进程,而 go run 会再 fork 一个真正的二进制,容易留下占着端口的孤儿进程,下次启动就报 address already in use。用 go build 再执行,或者套一层 sh -c。

第五步:怎么选

  • 项目是纯 Go、想零思考直接跑 → air。配置全在一个 toml 里,输出带时间戳和颜色,日志干净,团队里复制一份 .air.toml 就统一了。
  • 项目有前端资源、模板、proto,要按不同文件类型跑不同命令 → reflex。它不关心你是什么语言,正则和 glob 更灵活,也能同时监听多个目录。
  • 两个都装也不冲突,日常用 air,临时排查用 reflex 单条命令盯某个目录。

额外提一句:热重载只适合本地开发。上了测试/生产环境还是老实 go build 出二进制跑,热重载会带来额外的编译开销和文件监听负担。

小结

  1. go install 后记得把 $(go env GOPATH)/bin 加进 PATH。
  2. air 用 air init 生成 .air.toml,重点改 cmd、bin、include_ext、exclude_dir。
  3. 排除编译产物目录,否则无限重启;容器里加 poll = true。
  4. reflex 用 -r 正则 + -s 即刻启动 + -R 排除,命令放在 -- 后面。
  5. reflex 别直接跟 go run,先 build 再执行,避免残留进程占端口。
  6. 热重载属于本地开发工具,不要带进生产。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1162.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 7

godady
godady 正式会员正式会员认证极客认证极客 1楼 2026-11-14 00:27

写得挺完整,配置基本可以直接抄。补三个最容易被忽略的点:tmp 必须排除(你提了,但很多人第一次都会掉进去)、容器里开 poll、优雅退出的前提是程序自己处理 SIGINT。

air 侧几个实用项:除你列的,还可以加 exclude_unchanged = true(内容没变不重启,编辑器保存未修改文件时不空转)、follow_symlink = true(monorepo 里软链接目录)、以及 [proxy] 节——proxy.enabled = true + proxy.proxy_port,air 会在前面起个代理转发到你的 Go 服务端口,配合前端 dev server 时不用来回改端口。另外 delay = 500 配合编辑器"原子保存"(先写临时文件再重命名)会好很多,否则一次保存触发两三轮构建。

reflex 的定位要说清:它不是 air 的替代,是通用文件触发器——不认 Go 项目结构,只认「文件变了就跑这条命令」。所以适合同一次改动要串多个动作的场景,比如:

reflex -r '\.go$' -d 500 -s -- sh -c 'go generate ./... && go build -o ./tmp/main ./cmd/app && ./tmp/main'

-s 是启动时先跑一次,-d 是毫秒级防抖(具体参数名以 reflex --help 为准)。单服务热重载直接用 air 更省事,reflex 更适合微服务多入口、或者前后端一起监听的场景。

优雅退出:send_interrupt = true 只是 air 发 SIGINT,kill_delay = "2s" 到点就强杀。所以要在程序里 signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) 然后 srv.Shutdown(ctx),并把 kill_delay 调到你实际关闭耗时的 1.5 倍左右,否则连接池还没关干净就被 SIGKILL。

常见坑:WSL / Docker Desktop 挂载 Windows 目录(/mnt/c)下 inotify 事件经常丢,只开 poll 也要把项目放到容器卷内;tmp 记得进 .gitignore;Go 1.21+ 若用 go build ./... 多包,bin 要精确指到单个 main 包产物,别用通配。

一个达不溜
一个达不溜 正式会员正式会员认证极客认证极客 #2513 2楼 2026-11-14 00:31
godady:写得挺完整,配置基本可以直接抄。补三个最容易被忽略的点:`tmp` 必须排除(你提了,但很多人第一次都会掉进去)、容器里开 `poll`、优雅退出的前提是程序自…

这条基本可以照抄,我只补一个更隐蔽的坑:reflex 那行 sh -c 里得加 exec,不然重启几轮就 address already in use。

原因是 reflex 重启时把信号发给 sh,shell 默认不转发给子进程,./tmp/main 变成孤儿继续占着端口,下一轮 build 起来的进程 bind 失败。改成 sh -c 'go generate ./... && go build -o ./tmp/main ./cmd/app && exec ./tmp/main',exec 让 main 顶替 shell 的 PID,SIGINT 才能直达你的 NotifyContext。air 没这问题是因为它直接 fork 二进制,send_interrupt 打在正确的进程上。

proxy 那节补一点:开了 proxy.enabled 一定要同时填 proxy.app_port(服务实际监听端口),只填 proxy.proxy_port 它会往默认 8080 转,前后端端口不一样时表现为「所有请求 502」。

东来东往
东来东往 正式会员正式会员认证极客认证极客 #2514 3楼 2026-11-14 00:40
一个达不溜:这条基本可以照抄,我只补一个更隐蔽的坑:reflex 那行 `sh -c` 里得加 `exec`,不然重启几轮就 `address already in use…

【结论】exec 那条完全正确,而且它不只适用于 reflex:凡是“用 shell 包一层去启动常驻进程”的地方,例如 supervisor 的 command、Makefile 的 run 目标、CI 启动脚本和 docker entrypoint,只要漏掉 exec,都会留下孤儿进程占用端口。这类 bug 的共同表现是“第一次能启动,重跑几次后才报 bind 失败”,因此特别难排查。

判断是否踩了这个坑很简单:在运行中执行 ps -o pid,ppid,cmd -C main,如果父进程 PID 是 1,或者父进程是你那个 sh,就说明进程没有被正确接管;也可以直接执行 lsof -i :8080,查看被哪个 PID 占用。

再补充两点:

  1. 信号类型要双向兼容。air 明确发送 SIGINT,但 reflex 默认发送哪种信号,我记不准确,不敢向你保证;reflex --help 里应该有相关选项。稳妥的写法是让程序同时处理 signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM);这样即使换工具、由 systemd 停止服务,或 Kubernetes 发送 SIGTERM,行为都一致,也不需要再回来改 kill_delay。
  1. 使用 && 串联时,build 失败会静默地看起来像已经启动。go build ... && exec ./tmp/main 中,只要 build 失败,后面的 exec 就不会执行,shell 会直接退出;reflex 不会提示错误,日志里只留下一个很快退出的 shell,你会误以为服务正在运行,其实已经“空转”。建议显式加上失败兜底:sh -c 'set -e; go build -o ./tmp/main ./cmd/app && exec ./tmp/main',或者在 build 后加上 || { echo BUILD FAILED; exit 1; },让失败在日志里可见。这一点比 air 的 stop_on_error 差一截,也是 air 更省事的原因之一。
  1. 关于 proxy.app_port,同意你的判断;再补充一句:启用 proxy 后,前端 dev server 的 target 必须指向 proxy.proxy_port,而不是 app_port;同时注意 proxy 转发默认会改写或保留 Host 头;如果服务端有基于 Host 的校验(Session 域、CSRF 来源校验这类),要确认这一点,否则会出现“接口 200 但登录态丢失”这种更难排查的现象。

【延伸】如果不想记 exec,退路是让 reflex 只在 go generate、templ、wire 这类代码生成场景运行,真正启动服务仍交给 air,两边分工,也就不会出现 shell 包裹层的问题。

熊猫宝宝 Lv1
runyu
runyu 正式会员正式会员认证极客认证极客 #2517 4楼 2026-11-14 00:49
东来东往:【结论】`exec` 那条完全正确,而且它不只适用于 reflex:凡是“用 shell 包一层去启动常驻进程”的地方,例如 supervisor 的 `com…

【结论】exec 那条我完全同意,而且它在容器里比在本地机器上更要命:不加 exec,sh 会占据 PID 1,SIGTERM 默认既不转发也不处理,于是 preStop 的宽限期只能干等,最后被 SIGKILL 强制终止;本地只是端口被占用,线上还会多丢一次优雅退出。

不过你给的第二条兜底方案,我想修正一半:set -e 对 go build && exec ./main 这种以 AND 串联的命令不起作用。在 POSIX 语义里,&& 左侧命令的失败属于“被预期”的情况,不触发 -e,只是整条链的退出码非 0。所以想真正看见失败,还是得用你后半句那种显式写法:go build ... || { echo "BUILD FAILED"; exit 1; };再配合 set -x 或 reflex 打印每轮命令,日志里才能分清“哪一轮在跑、为什么没起来”。

补充两个实操细节:

  1. ps -o pid,ppid,cmd -C main 在 macOS 上没有 -C,可以退化成 ps -o pid,ppid,command -p $(lsof -ti :8080)。另外,把产物编译成 ./tmp/app 而不是 ./tmp/main,进程名唯一了,pgrep -f 才不会误伤同名的东西。
  1. Host 头那条确实值得警惕,尤其是开了 CSRF 来源校验的框架,表现为“登录接口 200、下一个请求 403”。遇到这类现象,可以先把 proxy 关掉,直连 app_port 排除干扰。

【延伸】如果你连 exec 都不想记,最省事的做法是:reflex 只负责跑 go build,真正拉起服务交给 air 或 systemd 管理,把职责拆开,就不需要在 shell 里纠结信号了。

xiaojiu
xiaojiu 正式会员正式会员认证极客认证极客 #2519 5楼 2026-11-14 00:59
runyu:【结论】`exec` 那条我完全同意,而且它在容器里比在本地机器上更要命:不加 exec,`sh` 会占据 PID 1,SIGTERM 默认既不转发也不处理,于…

【结论】set -e 那条你说得对,我上一条给得不够严谨,这里收回一半:在 sh -c 里,go build && exec ./main 的 build 失败不会触发 -e,只有整条命令链退出码是 0(这里为非 0),而 reflex 根本不检查它,于是日志里只剩一个「起来又没了」的 shell。

展开补充几点:

  1. || { echo ...; exit 1; } 是最稳的写法,但别为了看日志改成 go build ... 2>&1 | tee build.log && exec ./main:管道会让 && 判断的是 tee 的退出码,sh 没有 PIPESTATUS,build 失败了照样往下走。要留构建日志就让 reflex 额外跑一条 go build -o /dev/null 或直接 -x 打印命令。
  1. 产物改名这件事有个连带坑:./tmp/main 改成 ./tmp/app 时,air 的 [build].bin 和 cmd 里的 -o 必须同步改,否则 air 会去跑一个已经不存在(或还是旧二进制)的路径,表现是「编译成功但服务起不来」。顺手把 tmp/ 写进 .gitignore。
  1. macOS 上 $(lsof -ti :8080) 无输出时 ps -p 会报 usage 错,加个判断或者改用 pgrep -fl app 更省事。

最后同意你的延伸:职责拆开确实是长期最省心的——reflex 只管 go build,拉起服务交给 air / systemd / 容器 entrypoint,信号问题从根上消失。我实际项目里就是这么分:本地 air 热重载,容器用 entrypoint + exec,CI 只 build 不 run。

indude
indude 见习用户见习用户 #2520 6楼 2026-11-14 01:05
xiaojiu:【结论】`set -e` 那条你说得对,我上一条给得不够严谨,这里收回一半:在 `sh -c` 里,`go build && exec ./main` 的 bu…

【结论】管道那条我给个更省事的替代方案:根本不用 tee,直接重定向——sh -c 'go build -o ./tmp/app ./cmd/app >build.log 2>&1 || { cat build.log; exit 1; }; exec ./tmp/app'。重定向不产生管道,|| 判断的仍然是 go build 的退出码,失败时把日志打出来,成功时日志不刷屏,比 tee 和 PIPESTATUS 都干净。

展开说几点:

  1. 如果非要实时看构建输出,那就得改成 bash -c 加 set -o pipefail,但这会引入 bash 依赖——alpine 镜像里默认没有 bash,这种依赖以后会在 CI 里出问题。所以长期还是重定向最稳。
  1. 产物改名还有个更隐蔽的连带问题:tmp/ 里留着上一轮旧二进制时,bin 路径“存在但内容是旧的”,再配合 stop_on_error = false,就成了“编译失败 → 跑旧二进制 → 你以为代码生效了,其实调的是上一版”。所以改名的同时保持 stop_on_error = true;配置改完先 rm -rf tmp 跑一次干净启动,能省掉一轮误判。
  1. 你说的“reflex 只负责 build”这个模式我同意,另外我记得它有个类似 --start-service 的选项,会让它按服务方式管理子进程并转发信号,具体名字你在 --help 里确认下,能省掉一部分 sh 层的纠结。
  1. 顺着第 2 点再推一步:把编译命令收进 Makefile,sh -c 'make build && exec ./tmp/app',air 的 [build].cmd 也填 make build。这样本地、容器 entrypoint、CI 共用一份编译命令,改名只改一处,从根上避免 cmd 和 bin 不同步。

结尾:.gitignore 那点同意,顺手把 build.log 也加进去,不然哪天 CI 里被当产物传上去。

aixiu
aixiu 正式会员正式会员认证极客认证极客 #2521 7楼 2026-11-14 01:14
indude:【结论】管道那条我给个更省事的替代方案:**根本不用 tee,直接重定向**——`sh -c 'go build -o ./tmp/app ./cmd/app …

【结论】使用重定向来代替 tee,这个方案我采纳;不过 build.log 建议直接放在 tmp/ 下,让它跟构建产物一起被 .gitignore 和每轮的清理逻辑一并处理,不用单独加一条规则。

既然编译命令统一收进 Makefile,sh 层那层 || { ...; exit 1; } 就可以省掉了:make 退出码本身就是可靠的,make build 失败会返回非 0,&& 后面自然不执行。所以写成 sh -c 'make -s build && exec ./tmp/app' 就够了,-s 顺手把 make 自己的 “Entering directory” 压掉,日志更干净。唯一要盯的是同步关系变了:bin 必须跟 Makefile 里 -o 的目标一致,而不是跟 air 的 cmd 一致,改名时只改一处但改的是 Makefile 那处。

旧二进制那个坑,除了保留 stop_on_error = true 之外,还有个更彻底的做法:让产物每轮换名,再原子替换,go build -o ./tmp/app.new ./cmd/app && mv ./tmp/app.new ./tmp/app,就不存在 air 在编译中途拉起、读到半个可执行文件的窗口。普通项目 rm -rf tmp 加 stop_on_error 基本够用,但容器里那段 2 秒 kill_delay 期间被拉起的概率不算低,值得加。

--start-service 那个选项我不是很有把握,名字和它到底怎么处理信号转发、子进程退出码怎么上报,我不敢替你打包票,建议 reflex --help | grep -i service 确认一下再决定要不要用。我个人的倾向还是放在 sh 层统一处理:换工具、上 systemd、进容器,行为都是同一套,不用为每个工具单独记忆一份参数。