最近在折腾 CI,需要一个可靠的 Formatter,于是对三年前的工具做了一次升级。
写在前面
2023 年,我写过一篇《AI 加持的代码编写实战:快速实现 Nginx 配置格式化工具》。那篇文章记录了如何借助 GPT、代码补全工具和已有的开源实现,在很短的时间里完成 soulteary/nginx-formatter 这个能解决实际问题的小工具。
当时我调研过两条路线:一条基于字符串特征,见效快;另一条构建语法树,工作量更大,但边界更清楚。为了尽快整理 Nginx 社区里的示例配置,我选择了前者:把两百多行 beautifier.js 嵌入 Go,通过 goja 执行,再用 Go 补齐 CLI、WebUI、Docker 和跨平台发布。
因为这个选择,项目可以快速完成,但是它的上限也早早的被限制住了。
之后,有用户陆续提交了转义字符丢失、单引号 JSON 被拆坏、空字符串 return 被改写、不能处理单文件等问题。前三类问题暴露了字符串规则缺少语法上下文:旧版程序知道字符长什么样,却不知道字符此刻位于字符串、注释、变量、普通指令还是嵌套代码块中。
这次更新分成了三个连续的版本:v2.0.0 用原生 Go 的 Lexer、Parser、AST 和 Printer 替换格式化内核;v2.1.0 增加单文件入口;v2.2.0 再用 Cobra 重整命令行,将格式化、WebUI 和版本查询拆成 format、serve、version 三个业务子命令。
项目的 v1.1.1 发布于 2023 年 5 月 7 日,此后主分支没有代码提交,直到 2026 年 8 月 9 日的 v2 重构。这次重构分了三步。v2.0.0 用原生 Go 的 Lexer、Parser、AST 和 Printer 替换格式化内核;v2.1.0 增加单文件处理;v2.2.0 再把格式化、WebUI 和版本查询拆成 format、serve、version 三个子命令。
本文以 2026 年 8 月 9 日发布的 v2.2.0 为准。
为什么会有这个工具?
nginx-formatter 最初来自一个很具体的问题。
当时我在整理 Nginx 官方社区的 njs-examples 配置。仓库里的内容来自不同作者和不同年代,Tab、空格、缩进宽度和空行风格并不统一。编辑器里已有的格式化插件又不够可靠,某些配置经过处理后甚至会发生语义变化。
第一版的目标是尽快得到一个开箱即用、容易分发、可以批量整理配置的工具。以这个目标衡量,Go 外壳加内嵌 JavaScript 是一个合理的原型方案:复用已有算法,不要求用户安装 Node.js,一个二进制就能提供 CLI 和 WebUI。
但原型验证成功后,评价标准会发生变化。
一个格式化工具最大的风险,不是遇到不支持的输入后明确报错,而是在看起来成功的情况下改掉配置内容。对这类工具而言,“缩进更漂亮”只是表面功能,“处理好每一个语法 token”才是真正的产品底线。
v2 系列的重构,就是在保留原有使用体验的前提下,把这条底线向前推进。
为什么不能继续补正则?
先回顾一下 v1.1.1 的完整处理链路。
CLI 读取文件后,会先保护部分转义字符,规范化一部分 return 写法,把 ${...} 临时替换成占位符,再调用格式化器。格式化器每次新建一个 goja VM,将预处理后的配置拼进 JavaScript 模板字符串,然后运行按行、空白和花括号组织的一组规则。变量占位由 beautifier.js 恢复,转义占位最后由 CLI 恢复。
读取 .conf
→ 保护转义字符
→ FixReturn
→ 保护 ${...}
→ 创建 goja VM
→ 执行 beautifier.js
→ JavaScript 恢复变量占位
→ CLI 恢复转义占位
→ 写入文件
WebUI 看起来使用同一个 Formatter,实际上绕过了 CLI 的几层预处理,因此两种入口对复杂输入的行为也不完全一致。
这套方案最大的问题,是规则之间没有共享的语法上下文。
下面几个真实案例很有代表性:
| 输入特征 | 旧版现象 | 根因 |
|---|---|---|
Sogou\ web |
裸词中的反斜杠可能丢失 | 空格清理不知道这个空格已被转义 |
' { "code":200 }' |
单引号 JSON 的花括号被当作 Nginx 块 | 旧占位逻辑主要保护双引号内容 |
return 200 ""; |
空字符串可能被正则再次加引号 | FixReturn 猜测参数结构,而不是读取 token |
"${scheme}://`host`" |
Goja 报错或变量需要额外保护 | 配置要穿过 Go 字符串和 JS 模板字符串两层边界 |
*_by_lua_block |
Lua 中的花括号可能干扰 Nginx 缩进 | 普通花括号规则不知道这里已进入另一种语言 |
如果继续沿用旧架构,每增加一种引号、转义或嵌入语法,就要同时检查占位、清理、缩进、恢复以及 CLI/WebUI 两条链路。补丁数量会增加,状态组合增长得更快。
为了解决正则解决不了的“这个字符处于什么上下文”的答案,于是乎方案变了。
重构目标:先替换内核,再整理入口
v2.0.0 只替换格式化内核,CLI、WebUI 和发布流程尽量保持兼容;v2.1.0 加入单文件处理,v2.2.0 才调整命令入口。
| 维度 | v1.1.1 | v2.2.0 |
|---|---|---|
| 格式化核心 | beautifier.js + goja |
原生 Go Lexer、Parser、AST、Printer |
| 处理模型 | 按行、正则、花括号和占位符 | Token 边界和递归块结构 |
| 运行方式 | 每个文件创建 JavaScript VM | 普通 Go 函数调用 |
| CLI | 全局参数,通过 -web 切换模式 |
format、serve、version 子命令 |
| 引号与转义 | 先保护,再恢复 | Lexer 按状态读取并保留原文 |
${...} |
CLI 预处理,WebUI 无保护 | 核心解析器直接识别 |
| 嵌入 Lua | 和普通文本一起处理花括号 | RawBlock 单独扫描 |
| 文件遍历 | 字符串前缀判断目录边界 | os.OpenRoot 限制根目录访问 |
在核心重构提交中,旧版的 beautifier.js 和 goja 被删除,新的解析与输出逻辑放进了 internal/nginx。
新的格式化入口很薄:
func Formatter(s string, indent int, char string) (string, error) {
if s == "" {
return "", nil
}
cfg, err := nginx.Parse(s)
if err != nil {
return "", err
}
return nginx.Format(cfg, indent, char), nil
}
Formatter 只负责串起解析和输出,词法处理与布局规则分别放在 nginx.Parse 和 nginx.Format 中。
原生 Go 格式化器怎么工作
Lexer:先判断字符属于什么
Lexer 逐个 rune 扫描输入,生成下面几类 Token:
- 普通裸词
Ident; - 单引号或双引号字符串
String; - 分号、左花括号、右花括号;
#行注释;- 文件结束标记。
当 Lexer 进入单引号或双引号后,字符串内部的 {、}、; 和 # 都不再被当作 Nginx 语法;遇到反斜杠时,会把反斜杠和下一个字符一起保留。读取 Sogou\ web 这样的裸词时,Lexer 也会保留被转义的空格,不会把它拆成两个参数。
${...} 也不再需要替换成 [dollar] 一类占位符。Lexer 会在裸词中识别 ${,跟踪内部花括号深度,直到匹配对应的 }。边界测试还覆盖了 ${a${b}} 这类嵌套花括号输入,不过这不代表它是有效的 Nginx 变量写法。
Parser:把指令和块变成结构
Parser 使用递归下降方式消费 Token,将配置构造成轻量 AST。当前节点类型包括:
Directive:以分号结束的普通指令;Block:带有{ ... }的嵌套配置块;RawBlock:保存嵌入代码的原始块;Comment:独立注释;BlankLine:代表一个或多个连续空行,解析时会折叠为一个节点。
以一段常见配置为例:
server { # public entry
listen 80;
location /api {
proxy_pass http://backend;
} # api
}
旧版主要依靠行尾和花括号调整缩进。新版本则明确知道 server 和 location 是 Block,listen、proxy_pass 是 Directive,两个注释分别附着在开括号和闭括号位置。
解析结构不完整时,Parser 可以返回带行号的错误,例如多余的 }、缺失的 } 或没有闭合的 Lua 原始块。这比 JavaScript 执行异常或静默改写更容易定位。
这是一棵面向格式化的轻量 AST。它知道“这是一条指令”,但不知道 listen 可以出现在哪种上下文,也不会验证参数数量、模块依赖和文件路径。
对缺失分号做有限恢复
Issue #3 中的输入还有另一个特点:return 位于块末尾,结尾缺少分号。
location /api/ro {
default_type "application/json";
return ' { "code":200 }'
}
这段输入本身不是有效的 Nginx 配置。为了兼容 Issue #3,Parser 只在指令紧邻 } 或文件结尾时做有限恢复,由 Printer 补回分号:
location /api/ro {
default_type "application/json";
return ' { "code":200 }';
}
如果文件中间的一条指令缺少分号,下一行仍可能被当成前一条指令的参数;未闭合的引号目前也不会得到足够严格的诊断。
RawBlock:不把 Lua 当作 Nginx 继续解析
OpenResty 配置需要单独处理。
content_by_lua_block {
local data = { code = 200 }
ngx.say("}")
}
如果只计算花括号数量,Lua table 里的 {}、字符串里的 }、注释里的花括号都可能提前结束 Nginx 块。
新 Parser 会把名称以 _by_lua_block 结尾的块识别为 RawBlock。它不再按照 Nginx 指令解析块体,而是使用专门的扫描逻辑保存嵌入代码。在计算块深度时,扫描器会跳过以下内容中的花括号:
- 单引号和双引号字符串;
--行注释;[[...]]、[=[...]=]等 Lua 长字符串;- Lua 长注释。
对于 Lua 代码本身的嵌套 {},扫描器会正常增加、减少深度,直到找到与 Nginx 外层开括号对应的结束位置。
Printer 只统一这段代码的公共前导缩进。这里的“Raw”指语法上不解释,不代表字节完全不变:首尾空白行、公共缩进和行尾空格仍会被规范化。
Printer:让布局规则与解析规则分离
Printer 不再负责猜测结构,只根据 AST 输出缩进、分号、注释、空行和花括号。
它仍然保留了一些旧版布局习惯,例如空块输出为 { }、部分闭括号后增加空行,以减少大版本升级造成的不必要 diff。缩进字符和数量依旧可配置;v2.2.0 推荐通过 -c/--char 和 -n/--indent 表达。
解析和输出分离以后,可以直接验证一个格式化器很重要的性质:
format(format(input)) == format(input)
当前测试验证了带转义空格的正则表达式、块末尾缺失分号、开闭括号注释、Lua RawBlock 和 Lua 长字符串等场景的幂等性。
v2.2:用子命令区分格式化和 WebUI
前两个 v2 版本解决了格式化核心和文件输入,旧命令行却仍然保留着第一版的形态:所有参数都挂在同一个入口下,再由 -web 决定究竟是处理文件,还是启动服务。
./nginx-formatter -input=./conf.d -output=./dist
./nginx-formatter -web -port=8123
文件格式化和 WebUI 共用一组参数,-web 负责切换模式,-input 与 -port 也会出现在同一份帮助信息里。加入单文件处理后,这种入口更难继续扩展。
v2.2.0 在命令行重构中引入 Cobra,把格式化、WebUI 和版本查询拆开:
./nginx-formatter format -i ./conf.d -o ./dist
./nginx-formatter serve -p 8123
./nginx-formatter version
| 命令 | 用途 | 主要参数 |
|---|---|---|
format |
格式化目录或单个文件 | -i/--input、-o/--output、-n/--indent、-c/--char |
serve |
启动 WebUI 服务 | -p/--port、-n/--indent、-c/--char |
version |
输出当前版本 | 无 |
缩进字符也增加了 space 和 tab 两个可读值:
# 四个空格
./nginx-formatter format -n 4 -c space
# 一个 Tab
./nginx-formatter format -n 1 -c tab
为了避免直接打断已有脚本,根命令还保留了 -input、-output、-indent、-char、-web、-port 六个旧参数。它们只用于兼容,新脚本建议直接使用子命令。
几个历史问题的处理结果
| Issue | v2.2.0 的实际情况 |
|---|---|
| ^2:转义空格丢失 | 已修复 |
| ^3:单引号 JSON 被拆坏 | 已修复 |
| ^4:空字符串 \\\`return\\\` 被改写 | Issue 已关闭,问题仍可复现 |
| ^5:不能格式化单文件 | 已在 v2.1.0 中支持 |
Issue #2 中的 Sogou\ web 会被旧版改成 Sogou web,改变正则含义。新版在词法阶段保留完整转义序列,不再改写这类输入。
Issue #3 的单引号 JSON 会被旧版当成配置块。新版会完整保留单双引号、字符串中的花括号、# 和 ${...};移除 Goja 也一并删除了 JavaScript 模板字符串带来的转义问题。
当前只对 *_by_lua_block 做了专门处理,其他嵌入语言仍需要逐项支持。
目录访问不再依赖字符串前缀
旧版先用 filepath.Clean 清理路径,再通过字符串前缀判断文件是否位于输入目录。./nginx-source 这类相对路径可能因此被跳过,字符串比较也不能可靠限制目录边界。
文件访问重构改用 os.OpenRoot 和 fs.WalkDir,所有目录文件都通过根目录内的相对路径访问。这样既修复了相对路径问题,也会拒绝通过 .. 或符号链接越出指定目录。单文件模式处理明确文件路径,不经过这层目录访问逻辑。
性能、测试和交付
移除 Goja 后,Linux amd64 发布文件缩小了接近一半:
| 文件 | v1.1.1 | v2.2.0 |
|---|---|---|
tar.gz |
约 11.6 MB | 约 6.1 MB |
| 解压后二进制 | 约 22.9 MB | 约 11.2 MB |
我用两个官方二进制处理 1000 个相同配置文件,每版预热一次并运行 7 轮。在同一个共享 Linux 环境中,墙钟时间中位数从 2.12 秒降到 0.12 秒,约快 17 倍。这个结果不能当作通用 benchmark,但可以看出移除逐文件创建 Goja VM 后,批处理开销明显下降。在这批样本中,v2.1.0 与 v2.2.0 的输出逐字一致,CLI 重构没有改变这些配置的格式化结果。
新测试分别覆盖 Lexer、Parser、Printer、文件处理和 CLI,主要包括:
- 字符串、引号、转义和变量;
- Nginx 块结构、注释与 OpenResty Lua;
- 重复格式化的幂等性;
- 目录、单文件和旧 CLI 入口。
Go 1.26 下全量测试通过,internal/nginx 的语句覆盖率为 89.3%。Issue #2、#3 的复现配置以及 Issue #5 的单文件路径都已经进入回归测试;Issue #4 仍然缺少有效修复。
WebUI 从 Gin 切换到 Fiber,页面资源仍通过 go:embed 放进二进制,发布形式没有变化。移除 Goja 后依赖明显减少,v2.2.0 因 Cobra 增加了少量 CLI 依赖。GoReleaser 继续提供 macOS、Linux 二进制和多架构 Docker 镜像。
v2.2.0 还把源码构建镜像升级到 Go 1.26 / Alpine 3.21,解决了构建环境与 go.mod 不一致的问题。
使用 v2.2.0 版 Nginx Formatter
使用 Homebrew
在 macOS 或 Linux 上,可以使用项目的 Homebrew Tap。Formula 已经更新到 v2.2.0:
brew tap soulteary/tap
brew install soulteary/tap/nginx-formatter
升级和卸载分别是:
brew upgrade soulteary/tap/nginx-formatter
brew uninstall soulteary/tap/nginx-formatter
安装后,可以先确认版本:
nginx-formatter version
当前 Formula 的测试仍使用旧参数 -help,因此 brew test nginx-formatter 会失败;正常安装和 version 命令不受影响。
使用发布二进制
也可以从 v2.2.0 发布页面 下载对应平台和架构的文件。
格式化目录
新命令仍然可以递归处理目录中的小写 .conf 文件。为了便于审查,建议始终分开输入和输出:
nginx-formatter format \
-i ./nginx-source \
-o ./nginx-formatted \
-n 2 \
-c space
-i 与 --input、-o 与 --output 等价。-n 指定每级缩进数量,-c 指定缩进字符。使用 Tab 时不再需要向 Shell 传入真实制表符:
nginx-formatter format \
-i ./nginx-source \
-o ./nginx-formatted \
-n 1 \
-c tab
省略 -i 时,format 会处理当前目录。目录模式省略 -o 时,输出根目录始终是当前工作目录;即使 -i 指向其他目录,也不会自动写回输入目录。为了避免误覆盖,处理正式配置时建议同时写明输入和输出。
不带子命令的 ./nginx-formatter 仍会格式化当前目录,这是为旧脚本保留的兼容行为。新脚本应显式写出 format。
单文件格式化
当 -i 指向文件时,程序只处理该文件,而且不限制 .conf 后缀。
单文件模式下,-o 有三种语义:
- 省略:原地覆盖输入文件;
- 指向已存在的目录:写入该目录下的同名文件;
- 其他值:视作目标文件路径,并按需创建父目录。
# 原地覆盖,使用前务必保留备份
nginx-formatter format -i ./nginx.conf
# 写入已经存在的目录
mkdir -p ./dist
nginx-formatter format -i ./nginx.conf -o ./dist
# 写入明确的目标文件
nginx-formatter format \
-i ./nginx.conf \
-o ./dist/nginx.formatted.conf
如果希望 -o 表示目录,需要先创建该目录;不存在的路径会被当作目标文件。单文件写入不是原子替换,覆盖正式配置前仍应备份。
输入文件不能直接写成位置参数。当前版本会静默忽略 format nginx.conf 中的 nginx.conf,转而处理当前目录,因此必须使用 -i 或 --input:
# 正确
nginx-formatter format -i ./nginx.conf
# 不要这样写,位置参数目前不会生效
nginx-formatter format ./nginx.conf
查看帮助和版本
根命令和每个子命令都有独立帮助:
nginx-formatter --help
nginx-formatter format --help
nginx-formatter serve --help
nginx-formatter version
旧版常见的 -help 在 v2.2.0 中不再有效,应改用 -h 或 --help。
使用 Docker
拉取 v2 镜像:
docker pull soulteary/nginx-formatter:v2.2.0
如果要批量格式化,建议将输入挂成只读目录,并显式指定输出目录:
mkdir -p nginx-formatted
docker run --rm \
--user "$(id -u):$(id -g)" \
-v "$PWD/nginx-source:/input:ro" \
-v "$PWD/nginx-formatted:/output" \
soulteary/nginx-formatter:v2.2.0 \
format -i /input -o /output
目录模式下必须显式指定 -o /output。否则程序会尝试把结果写到镜像工作目录 /:默认用户运行时文件会随容器删除,指定普通用户时则可能直接写入失败。
使用 WebUI
本机临时处理一小段配置,可以启动 WebUI:
nginx-formatter serve -p 8080
程序实际监听 :8080,也就是所有网络接口。Docker 使用时,建议显式绑定回环地址:
docker run --rm \
-p 127.0.0.1:8080:8080 \
soulteary/nginx-formatter:v2.2.0 \
serve
当前 WebUI 没有认证和多用户隔离,不要直接暴露到公网。它还有一些输出回填问题,复杂配置目前优先使用 CLI,后文会详细说明。
最后
时间过的真快,三年一晃而过。
这篇文章就先写到这里吧。
—EOF