最近在折腾 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 和版本查询拆成 formatserveversion 三个业务子命令。

项目的 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 和版本查询拆成 formatserveversion 三个子命令。

本文以 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 切换模式 formatserveversion 子命令
引号与转义 先保护,再恢复 Lexer 按状态读取并保留原文
${...} CLI 预处理,WebUI 无保护 核心解析器直接识别
嵌入 Lua 和普通文本一起处理花括号 RawBlock 单独扫描
文件遍历 字符串前缀判断目录边界 os.OpenRoot 限制根目录访问

核心重构提交中,旧版的 beautifier.jsgoja 被删除,新的解析与输出逻辑放进了 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.Parsenginx.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
}

旧版主要依靠行尾和花括号调整缩进。新版本则明确知道 serverlocationBlocklistenproxy_passDirective,两个注释分别附着在开括号和闭括号位置。

解析结构不完整时,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 输出当前版本

缩进字符也增加了 spacetab 两个可读值:

# 四个空格
./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.OpenRootfs.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.0v2.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

旧版常见的 -helpv2.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