本文使用「署名 4.0 国际 (CC BY 4.0)」许可协议,欢迎转载、或重新修改使用,但需要注明来源。 [署名 4.0 国际 (CC BY 4.0)](https://creativecommons.org/licenses/by/4.0/deed.zh) 本文作者: 苏洋 创建时间: 2026年08月09日 统计字数: 10972字 阅读时间: 22分钟阅读 本文链接: https://soulteary.com/2026/08/09/three-years-later-i-rewrote-the-nginx-formatter.html ----- # 三年后,我重写了 Nginx Formatter 最近在折腾 CI,需要一个可靠的 Formatter,于是对三年前的工具做了一次升级。 ## 写在前面 2023 年,我写过一篇《[AI 加持的代码编写实战:快速实现 Nginx 配置格式化工具](https://soulteary.com/2023/05/20/code-writing-practice-supported-by-ai-quickly-implement-nginx-configuration-formatting-tool.html)》。那篇文章记录了如何借助 GPT、代码补全工具和已有的开源实现,在很短的时间里完成 [`soulteary/nginx-formatter`](https://github.com/soulteary/nginx-formatter) 这个能解决实际问题的小工具。 当时我调研过两条路线:一条基于字符串特征,见效快;另一条构建语法树,工作量更大,但边界更清楚。为了尽快整理 Nginx 社区里的示例配置,我选择了前者:把两百多行 `beautifier.js` 嵌入 Go,通过 `goja` 执行,再用 Go 补齐 CLI、WebUI、Docker 和跨平台发布。 因为这个选择,项目可以快速完成,但是它的上限也早早的被限制住了。 之后,有用户陆续提交了转义字符丢失、单引号 JSON 被拆坏、空字符串 `return` 被改写、不能处理单文件等问题。前三类问题暴露了字符串规则缺少语法上下文:旧版程序知道字符长什么样,却不知道字符此刻位于字符串、注释、变量、普通指令还是嵌套代码块中。 这次更新分成了三个连续的版本:[`v2.0.0`](https://github.com/soulteary/nginx-formatter/releases/tag/v2.0.0) 用原生 Go 的 Lexer、Parser、AST 和 Printer 替换格式化内核;[`v2.1.0`](https://github.com/soulteary/nginx-formatter/releases/tag/v2.1.0) 增加单文件入口;[`v2.2.0`](https://github.com/soulteary/nginx-formatter/releases/tag/v2.2.0) 再用 Cobra 重整命令行,将格式化、WebUI 和版本查询拆成 `format`、`serve`、`version` 三个业务子命令。 项目的 `v1.1.1` 发布于 2023 年 5 月 7 日,此后主分支没有代码提交,直到 2026 年 8 月 9 日的 v2 重构。这次重构分了三步。[`v2.0.0`](https://github.com/soulteary/nginx-formatter/releases/tag/v2.0.0) 用原生 Go 的 Lexer、Parser、AST 和 Printer 替换格式化内核;[`v2.1.0`](https://github.com/soulteary/nginx-formatter/releases/tag/v2.1.0) 增加单文件处理;[`v2.2.0`](https://github.com/soulteary/nginx-formatter/releases/tag/v2.2.0) 再把格式化、WebUI 和版本查询拆成 `format`、`serve`、`version` 三个子命令。 本文以 2026 年 8 月 9 日发布的 `v2.2.0` 为准。 ### 为什么会有这个工具? `nginx-formatter` 最初来自一个很具体的问题。 当时我在整理 Nginx 官方社区的 [`njs-examples`](https://github.com/nginx/njs-examples) 配置。仓库里的内容来自不同作者和不同年代,Tab、空格、缩进宽度和空行风格并不统一。编辑器里已有的格式化插件又不够可靠,某些配置经过处理后甚至会发生语义变化。 第一版的目标是尽快得到一个开箱即用、容易分发、可以批量整理配置的工具。以这个目标衡量,Go 外壳加内嵌 JavaScript 是一个合理的原型方案:复用已有算法,不要求用户安装 Node.js,一个二进制就能提供 CLI 和 WebUI。 但原型验证成功后,评价标准会发生变化。 一个格式化工具最大的风险,不是遇到不支持的输入后明确报错,而是在看起来成功的情况下改掉配置内容。对这类工具而言,“缩进更漂亮”只是表面功能,“处理好每一个语法 token”才是真正的产品底线。 v2 系列的重构,就是在保留原有使用体验的前提下,把这条底线向前推进。 ### 为什么不能继续补正则? 先回顾一下 `v1.1.1` 的完整处理链路。 CLI 读取文件后,会先保护部分转义字符,规范化一部分 `return` 写法,把 `${...}` 临时替换成占位符,再调用格式化器。格式化器每次新建一个 `goja` VM,将预处理后的配置拼进 JavaScript 模板字符串,然后运行按行、空白和花括号组织的一组规则。变量占位由 `beautifier.js` 恢复,转义占位最后由 CLI 恢复。 ```text 读取 .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` 限制根目录访问 | 在[核心重构提交](https://github.com/soulteary/nginx-formatter/commit/ef913a5a5d23e2bd7da523764bad13f0cee70f37)中,旧版的 `beautifier.js` 和 `goja` 被删除,新的解析与输出逻辑放进了 [`internal/nginx`](https://github.com/soulteary/nginx-formatter/tree/v2.0.0/internal/nginx)。 新的格式化入口很薄: ```go 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`:代表一个或多个连续空行,解析时会折叠为一个节点。 以一段常见配置为例: ```nginx 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` 位于块末尾,结尾缺少分号。 ```nginx location /api/ro { default_type "application/json"; return ' { "code":200 }' } ``` 这段输入本身不是有效的 Nginx 配置。为了兼容 Issue #3,Parser 只在指令紧邻 `}` 或文件结尾时做有限恢复,由 Printer 补回分号: ```nginx location /api/ro { default_type "application/json"; return ' { "code":200 }'; } ``` 如果文件中间的一条指令缺少分号,下一行仍可能被当成前一条指令的参数;未闭合的引号目前也不会得到足够严格的诊断。 ### RawBlock:不把 Lua 当作 Nginx 继续解析 OpenResty 配置需要单独处理。 ```nginx 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` 表达。 解析和输出分离以后,可以直接验证一个格式化器很重要的性质: ```text format(format(input)) == format(input) ``` 当前测试验证了带转义空格的正则表达式、块末尾缺失分号、开闭括号注释、Lua RawBlock 和 Lua 长字符串等场景的幂等性。 ## v2.2:用子命令区分格式化和 WebUI 前两个 v2 版本解决了格式化核心和文件输入,旧命令行却仍然保留着第一版的形态:所有参数都挂在同一个入口下,再由 `-web` 决定究竟是处理文件,还是启动服务。 ```bash ./nginx-formatter -input=./conf.d -output=./dist ./nginx-formatter -web -port=8123 ``` 文件格式化和 WebUI 共用一组参数,`-web` 负责切换模式,`-input` 与 `-port` 也会出现在同一份帮助信息里。加入单文件处理后,这种入口更难继续扩展。 `v2.2.0` 在[命令行重构](https://github.com/soulteary/nginx-formatter/commit/6d2a04afa1e08078c852687aae5a2394eeb228e3)中引入 Cobra,把格式化、WebUI 和版本查询拆开: ```bash ./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` 两个可读值: ```bash # 四个空格 ./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:转义空格丢失](https://github.com/soulteary/nginx-formatter/issues/2) | 已修复 | | [^3:单引号 JSON 被拆坏](https://github.com/soulteary/nginx-formatter/issues/3) | 已修复 | | [^4:空字符串 \\\\\\\`return\\\\\\\` 被改写](https://github.com/soulteary/nginx-formatter/issues/4) | Issue 已关闭,问题仍可复现 | | [^5:不能格式化单文件](https://github.com/soulteary/nginx-formatter/issues/5) | 已在 `v2.1.0` 中支持 | Issue #2 中的 `Sogou\ web` 会被旧版改成 `Sogou web`,改变正则含义。新版在词法阶段保留完整转义序列,不再改写这类输入。 Issue #3 的单引号 JSON 会被旧版当成配置块。新版会完整保留单双引号、字符串中的花括号、`#` 和 `${...}`;移除 Goja 也一并删除了 JavaScript 模板字符串带来的转义问题。 当前只对 `*_by_lua_block` 做了专门处理,其他嵌入语言仍需要逐项支持。 ### 目录访问不再依赖字符串前缀 旧版先用 `filepath.Clean` 清理路径,再通过字符串前缀判断文件是否位于输入目录。`./nginx-source` 这类相对路径可能因此被跳过,字符串比较也不能可靠限制目录边界。 [文件访问重构](https://github.com/soulteary/nginx-formatter/commit/020c1d8f40123f8e72b1c39e89a9dff83132d179)改用 [`os.OpenRoot`](https://pkg.go.dev/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](https://github.com/soulteary/nginx-formatter/commit/47f4c75bb4294ed48e1181c8efdb272430d4bf46),页面资源仍通过 `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](https://github.com/soulteary/homebrew-tap)。Formula 已经更新到 `v2.2.0`: ```bash brew tap soulteary/tap brew install soulteary/tap/nginx-formatter ``` 升级和卸载分别是: ```bash brew upgrade soulteary/tap/nginx-formatter brew uninstall soulteary/tap/nginx-formatter ``` 安装后,可以先确认版本: ```bash nginx-formatter version ``` 当前 Formula 的测试仍使用旧参数 `-help`,因此 `brew test nginx-formatter` 会失败;正常安装和 `version` 命令不受影响。 ### 使用发布二进制 也可以从 [`v2.2.0` 发布页面](https://github.com/soulteary/nginx-formatter/releases/tag/v2.2.0) 下载对应平台和架构的文件。 ### 格式化目录 新命令仍然可以递归处理目录中的小写 `.conf` 文件。为了便于审查,建议始终分开输入和输出: ```bash nginx-formatter format \ -i ./nginx-source \ -o ./nginx-formatted \ -n 2 \ -c space ``` `-i` 与 `--input`、`-o` 与 `--output` 等价。`-n` 指定每级缩进数量,`-c` 指定缩进字符。使用 Tab 时不再需要向 Shell 传入真实制表符: ```bash nginx-formatter format \ -i ./nginx-source \ -o ./nginx-formatted \ -n 1 \ -c tab ``` 省略 `-i` 时,`format` 会处理当前目录。目录模式省略 `-o` 时,输出根目录始终是当前工作目录;即使 `-i` 指向其他目录,也不会自动写回输入目录。为了避免误覆盖,处理正式配置时建议同时写明输入和输出。 不带子命令的 `./nginx-formatter` 仍会格式化当前目录,这是为旧脚本保留的兼容行为。新脚本应显式写出 `format`。 ### 单文件格式化 当 `-i` 指向文件时,程序只处理该文件,而且不限制 `.conf` 后缀。 单文件模式下,`-o` 有三种语义: - 省略:原地覆盖输入文件; - 指向已存在的目录:写入该目录下的同名文件; - 其他值:视作目标文件路径,并按需创建父目录。 ```bash # 原地覆盖,使用前务必保留备份 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`: ```bash # 正确 nginx-formatter format -i ./nginx.conf # 不要这样写,位置参数目前不会生效 nginx-formatter format ./nginx.conf ``` ### 查看帮助和版本 根命令和每个子命令都有独立帮助: ```bash nginx-formatter --help nginx-formatter format --help nginx-formatter serve --help nginx-formatter version ``` 旧版常见的 `-help` 在 `v2.2.0` 中不再有效,应改用 `-h` 或 `--help`。 ### 使用 Docker 拉取 v2 镜像: ```bash docker pull soulteary/nginx-formatter:v2.2.0 ``` 如果要批量格式化,建议将输入挂成只读目录,并显式指定输出目录: ```bash 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: ```bash nginx-formatter serve -p 8080 ``` 程序实际监听 `:8080`,也就是所有网络接口。Docker 使用时,建议显式绑定回环地址: ```bash docker run --rm \ -p 127.0.0.1:8080:8080 \ soulteary/nginx-formatter:v2.2.0 \ serve ``` 当前 WebUI 没有认证和多用户隔离,不要直接暴露到公网。它还有一些输出回填问题,复杂配置目前优先使用 CLI,后文会详细说明。 ## 最后 时间过的真快,三年一晃而过。 这篇文章就先写到这里吧。 —EOF