Prometheus Prettifying PromQL:promql/parser 中表达式美化规则的源码级解读
【免费下载链接】prometheusThe Prometheus monitoring system and time series database.项目地址: https://gitcode.com/GitHub_Trending/pr/prometheus
PromQL 表达式一旦写复杂,往往长达数百字符挤在一行里,难以阅读和 diff。Prometheus 的promql/parser包内置了一个表达式美化器(prettifier),规则文档定义了它如何对解析后的 AST 节点做换行、缩进和归一化重写。本文以这份规则文档为主线,逐条拆解四条美化规则,并结合 prettier.go 的实现与 prettier_test.go 的测试用例,说明Prettify的调用方式、max_characters_per_line关键字的底层含义,以及节点深度(level)与缩进机制的工作原理,帮助你在二次开发 Prometheus 解析器或自研查询工具时直接复用这套能力。
核心概念:Prettify 与 max_characters_per_line
规则文档首先定义了一个关键字:
max_characters_per_line:美化后的单行表达式允许的最大字符数。
对应到源码,这是一个包级变量,默认值为 100:
var maxCharactersPerLine = 100见 prettier.go#L44。对外入口是Prettify函数,它把任意 AST 节点从根(level 0)开始递归格式化:
func Prettify(n Node) string { return n.Pretty(0) }见 prettier.go#L46-L48。它调用的是Node接口上的Pretty(level int)方法——每个节点类型(AggregateExpr、BinaryExpr、Call、ParenExpr等)各自实现一份。文件头部的注释(prettier.go#L21-L42)解释了整个算法思路:
- 解析后的 PromQL 是一棵嵌套的 AST,每个节点带一个
level(距根节点的深度),由父节点传递; - 格式化时每个节点只考虑两件事:父节点有没有为它换行(level 为 0 表示没换行,则不加缩进前缀;level > 0 则加
level个两空格缩进); - 当前节点是否需要被拆行:判据是其规范化长度
len(n.String())是否超过maxCharactersPerLine,超过则拆行并把 level+1 传给子节点,否则原样返回n.String()。
判定逻辑集中在needsSplit中:
// needsSplit normalizes the node and then checks if the node needs any split. func needsSplit(n Node) bool { if n == nil { return false } return len(n.String()) > maxCharactersPerLine }见 prettier.go#L176-L183。注意判断用的是>而非>=,即恰好等于上限时不拆行。缩进常量是两空格(prettier.go#L185-L189):
const indentString = " "规则一:超长节点才拆行,向量选择器与区间选择器是例外
规则原文:
- A node exceeding the
max_characters_per_linewill qualify for splitunless
- It is a
MatrixSelector- It is a
VectorSelector. Label sets in aVectorSelectorwill be in the same line as metric_name, separated by commas and a space
源码中,这两类节点的实现刻意不走needsSplit分支,而是统一走“加前缀缩进 + 原样输出”的路径:
func (e *MatrixSelector) Pretty(level int) string { return getCommonPrefixIndent(level, e) } func (e *VectorSelector) Pretty(level int) string { return getCommonPrefixIndent(level, e) }见 prettier.go#L142-L155。getCommonPrefixIndent只是indent(level) + current.String()。这个设计的实际效果是:即使node_filesystem_avail_bytes{job="node",fstype!=""}这样的选择器很长,它的指标名和标签集也始终保持在一行内、以,分隔,不会因为超长而被拆碎。这一点在测试用例中有直接体现,如 prettier_test.go#L466-L488 的混入(mixin)风格真实告警查询里,node_filesystem_avail_bytes{fstype!="",job="node"}完整保留在同一行。
另外规则附带了一条 Note:
Label groupings like
by,without,on,ignoringwill remain on the same line as their parent node
即分组/匹配标签子句与父节点(操作符)保持同行。以二元表达式为例,Pretty的拆行形式是“左操作数一行、操作符加 matching 串一行、右操作数一行”:
matching := e.getMatchingStr() return fmt.Sprintf("%s\n%s%s%s%s\n%s", e.LHS.Pretty(level+1), indent(level), e.Op, returnBool, matching, e.RHS.Pretty(level+1))见 prettier.go#L67-L80。on/ignoring/group_left/group_right等字符串由getMatchingStr()生成后紧跟在e.Op之后输出,因此始终与操作符同行,而不会单独换行。测试 prettier_test.go#L176-L183 展示了多组匹配子句的效果:
foo_1 + ignoring (foo) foo_2 + ignoring (job) group_left () foo_3 + on (instance) group_right () foo_4匹配子句串on (instance) group_right ()的生成逻辑在 printer.go#L148-L190 的getMatchingStr()中,它按顺序拼接on/ignoring (labels)、group_left/right (include)以及fill/fill_left/fill_right填充值子句。
规则二:嵌套节点只在超长时单独美化
规则原文:
- Nodes that are nested within another node will be prettified only if they exceed the
max_characters_per_line
这条规则对应的就是needsSplit的递归语义:一个节点只有自身的String()长度超限时才拆行,并把level+1传给子节点;否则它直接以单行字符串参与父节点的拼接,子树整体不被触碰。测试用例foo_1 + foo_2 + foo_3(limit=10)直观展示了这种“逐层独立判定”:
foo_1 + foo_2 + foo_3见 prettier_test.go#L152-L158。注意foo_1 + foo_2(长度 11)超限被拆,而更内层的短表达式则保持单行;二元运算的结合顺序决定了左结合树形,缩进层级也随之变化。TestExprPretty中还收录了来自 monitoring mixin 的复杂真实查询(prettier_test.go#L464-L513),是验证多层嵌套拆行行为的完整样本。
规则三:聚合表达式归一化为sum without (labels) (expression)形式
规则原文:
- Expressions like
sum(expression) without (label_matchers)will be modified tosum without(label_matchers) (expression)
这条规则的关键在于:归一化并不发生在 prettier 里,而是在更底层的String()输出上。AggregateExpr的字符串化始终把by (...)/without (...)前置到函数名之后:
func (node *AggregateExpr) writeAggOpStr(b *bytes.Buffer) { b.WriteString(node.Op.String()) switch { case node.Without: b.WriteString(" without (") writeLabels(b, node.Grouping) b.WriteString(") ") case len(node.Grouping) > 0: b.WriteString(" by (") writeLabels(b, node.Grouping) b.WriteString(") ") } }见 printer.go#L92-L104。由于needsSplit和所有不拆行路径都依赖n.String(),因此任何写法(sum(expr) by (job)、sum by (job) (expr)、sum(expr) without (job))只要进入格式化流程,输出都会被统一成sum by (job) (expr)这一规范形态,且标签之间用,分隔。测试用例直接验证了这一重写行为(limit=10 下):
输入:sum(task:errors:rate10s{job="s"}) without(job,foo) 输出:sum without (job, foo) ( task:errors:rate10s{job="s"} )见 prettier_test.go#L44-L49。超限时拆行后的形态由AggregateExpr.Pretty生成:ShortString()(即sum without (job, foo))+(\n+ 子表达式 +\n);若聚合操作符带参数(IsAggregatorWithParam(),如topk、quantile),参数会作为单独一行插在子表达式之前:
s += e.ShortString() s += "(\n" if e.Op.IsAggregatorWithParam() { s += fmt.Sprintf("%s,\n", e.Param.Pretty(level+1)) } s += fmt.Sprintf("%s\n%s)", e.Expr.Pretty(level+1), indent(level))见 prettier.go#L50-L65。对应效果(prettier_test.go#L62-L68):
topk( 10, ask:errors:rate10s{job="s"} )规则四:函数调用参数逐个换行
规则原文:
- Functional call args will be split to different lines if they exceed the
max_characters_per_line
Call.Pretty在超限时把每个参数放到独立一行:
func (e *Call) Pretty(level int) string { s := indent(level) if !needsSplit(e) { s += e.String() return s } s += fmt.Sprintf("%s(\n%s\n%s)", e.Func.Name, e.Args.Pretty(level+1), indent(level)) return s }见 prettier.go#L101-L109。参数列表是Expressions类型,其Pretty实现把每个参数用,\n连接(prettier.go#L115-L127):
parts := make([]string, len(e)) for i := range e { parts[i] = e[i].Pretty(level) } return strings.Join(parts, ",\n")典型效果(label_replace五个参数逐行排列):
label_replace( up{job="api-server",service="a:c"}, "foo", "$1", "service", "(.*):.*" )见 prettier_test.go#L236-L245。注意子参数若自身也超限会递归拆行——测试中嵌套两层的label_replace(label_replace(...))(prettier_test.go#L247-L261)就展示了参数内部再拆函数的完整嵌套形态。
level 与缩进机制:父节点决定子节点的缩进
前面各规则最终都落到同一套缩进约定上,这里把机制完整讲清楚,便于自行扩展Pretty实现。文件头部注释(prettier.go#L21-L42)给出的算法是:
- 父节点没有换行(传入的 level 为 0)时,当前节点不得添加缩进前缀;
- 父节点换行了(level > 0)时,当前节点以
level × " "作为前缀缩进; - 当前节点超限时,输出缩进等于自身深度,并把 level+1 传给子节点。
各节点类型在这一机制下的行为差异值得注意:
SubqueryExpr.Pretty不添加缩进前缀,而是把时间后缀([range:step] @ ... offset ...)原样追加在内部表达式之后,见 prettier.go#L146-L151,测试效果为rate(\n long_vector_selector[10m:1m] @ start() offset 1m\n)(prettier_test.go#L214-L218);StepInvariantExpr(@表达式)透传 level(prettier.go#L138-L140);UnaryExpr.Pretty会先TrimSpace掉子表达式的前导缩进,再把缩进挂到一元操作符前(prettier.go#L165-L170),保证-rate(...)这类表达式中负号不换行;- 实验性的
DurationExpr(Options{ExperimentalDurationExpr: true}解析出的时长运算)永不拆行,只按 level 加缩进,见 prettier.go#L82-L99 与测试 prettier_test.go#L672-L704。
已知限制:注释不会被保留
规则文档开头特别注明:
Note: The current version of prettier does not preserve comments.
这是因为美化基于String()重新序列化 AST,而注释并不属于 AST 节点,解析后即丢失。测试用例给出了确证:带两行注释的输入(prettier_test.go#L105-L114)
输入: sum by(job,foo) # Comment 1. (sum by(job,foo) ( # Comment 2. task:errors:rate10s{job="s"})) 输出(注释全部消失): sum by (job, foo) ( sum by (job, foo) ( task:errors:rate10s{job="s"} ) )因此在依赖美化输出的场景中(例如规则文件展示、告警表达式回显),不能期望用户注释被保留。
如何调用与验证:Prettify 与测试套件
Prettify是promql/parser包的导出 API,入参为任意Node(通常先由Parser.ParseExpr解析得到表达式)。当前仓库内该 API 的消费方是解析器自身的测试套件 prettier_test.go,其中每个测试都会把上限调小以便在小表达式上触发拆行:
func TestAggregateExprPretty(t *testing.T) { maxCharactersPerLine = 10 ... expr, err := testParser.ParseExpr(test.in) require.NoError(t, err) require.Equal(t, test.out, Prettify(expr)) }从源码结构看,maxCharactersPerLine是包内可变变量(无导出 setter),生产路径下固定为 100,测试通过直接赋值来模拟不同行宽。测试文件按节点类型组织:
- TestAggregateExprPretty:聚合表达式、by/without 归一化、嵌套聚合、注释丢弃;
- TestBinaryExprPretty:二元运算、bool、on/ignoring/group_left/group_right 匹配子句;
- TestCallExprPretty:函数调用参数拆行、子查询后缀;
- TestParenExprPretty 与 TestExprPretty:括号包裹与复杂真实查询的多层嵌套;
- TestUnaryPretty 与 TestDurationExprPretty:一元负号与实验性时长表达式。
小结
promql/parser/prettier_rules.md用四条规则定义了 Prometheus 表达式美化器的行为边界:超限才拆行(needsSplit以String()长度对maxCharactersPerLine=100判定)、选择器节点永不拆碎(MatrixSelector/VectorSelector走getCommonPrefixIndent)、聚合分组子句归一化前置(由printer.go的writeAggOpStr保证规范形态)、函数参数逐行排列(Expressions.Pretty以,\n连接)。整个机制围绕level递归传递与两空格缩进展开,且当前版本不保留注释。对维护查询工具链、规则文件可视化或需要稳定 diff 格式的团队而言,理解这四条规则与 prettier.go 中各Pretty方法的实现,即可准确预判任意 PromQL 表达式经Prettify后的输出形态。
【免费下载链接】prometheusThe Prometheus monitoring system and time series database.项目地址: https://gitcode.com/GitHub_Trending/pr/prometheus
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考