Prometheus监测消息文本解析过程
- 简介
对于Prometheus服务而言,其获取监测数据的方式是通过HTTP协议接收监测消息文本,这些消息无论是来自监测目标还是来自联邦的其他服务都需要符合标准规范才能够准确地进行解析。当监测目标数量较大,或者每个监测目标所报告的监测数据内容较多,或者采样频率较高时,对消息文本的解析将构成不小的开销。准确地理解消息文本的解析过程有助于在设计监测指标以及构建监测消息内容时找到优化的方向,也有助于构想出更高效更理想的监测数据消息格式。
Prometheus支持对两种消息格式即文本格式(下称为Prometheus传统格式)和OpenMetrics格式(自Prometheus 2.5开始支持),OpenMetrics可以视为对传统格式的升级和优化。对两种消息格式的鉴别通过响应消息的Content-Type头实现。两种消息的解析均通过有限自动机实现,自动机代码使用Golex工具自动生成。Golex使用严格定义的词法规范作为输入,无论是传统格式还是OpenMetrics格式,两者都有各自的词法规范。在实现词法分析的基础上,两种消息格式以每一行为单位实现了句法分析(每一行构成一个完整的句子),这些行包括注释行、TYPE行、HELP行、样本行等。句法分析输出的结果就是需要存储到数据库的样本数据。
2. 原理架构
Prometheus监测消息的原始文本需要经过词法分析和句法分析两个阶段进行解析。词法分析将消息拆分为最小构成单元,并保证了每个最小构成单元是符合规范的。句法分析则验证词法分析阶段输出的最小单元序列,以保证这些序列能够构成合法有效的句子。句法分析验证成功的句子将转换为样本数据并最终存储到数据库中。
2.1 词法分析器的生成
Golex可以根据词法规则说明书生成一个有限自动机来实现词法分析功能。在Golex的帮助下,软件开发人员可以不必花费大量精力从头编写Go语言代码来实现自己的词法分析器,而只需要描述出自己的词法规则,Golex将据此自动生成一个词法分析器(有限自动机)。相对于编写大量Go语言代码,写一份词法规则要简单得多。并且人工编写大量Go语言代码很容易出现错误,而在使用Golex工具的情况下,只要保证词法规则准确无误,就能够保证生成的词法分析器正确无误。
要利用Golex提供的便利,就需要提供它能够理解的词法规则说明书。生成有限自动机必不可少的信息是状态列表以及状态转换规则,相应地词法规则说明书最重要的内容也是这些信息。由于自动机根据字符输入决定状态的变换,所以还需要规定好字符的输入方式。在词法规则说明书中,%yyc和%yyn指令分别规定了用于保存输入字符的变量名称以及获取下一个字符的语句。
假设有一个名为prom.l的词法规则说明书,可以使用下面的golex命令生成对应的词法分析器代码(如果不指定-o参数,则默认输出代码文件名为lex.yy.go)。
$ ./golex -o=prom.l.go prom.l
Golex所生成的代码中最重要的部分是记号(token)识别函数,就Prometheus而言该函数命名为Lex,其作用为扫描字符串以生成下一个记号。记号识别是词法分析以及句法分析的基础,所有操作均基于记号。
Golex所遵循的词法规则编写格式要求每一项词法规则由匹配模式(包括状态值与正则表达式)和对应的操作构成,意为在某状态下当自动机扫描出指定模式的字符串时就执行对应的操作,这些操作可以是状态的切换,或者是返回所识别的记号,或者是两者的组合。在每条规则的前缀位置使用由一对尖括号包含的名称来表示指定的启动条件。如果前缀中没有设置启动条件,则表示该规则适用于sInit状态。启动条件提供了一种动态激活规则的机制,即只有当分析器处于特定条件时,相应规则才会激活,否则这些规则处于关闭状态。
2.2 传统格式文本的词法规则说明书
Prometheus传统格式文本词法规则说明书中最重要的定义和规则部分如代码片段1所示。可见,其中定义了7种独占状态,如果加上sInit状态则该共设计了8种状态。在规则部分(%%后)定义了19项规则,其中13项只在特定状态下触发,5项在sInit状态下触发(非尖括号开头),1项在任意状态下触发(<*>开头)。除了HELP和TYPE注释行的起始位置以外,其他模式所对应的动作都包含return语句,用于返回记号(token),也就是说在识别出一个记号之前词法分析函数不会返回。有些规则没有return语句,意味着该规则的执行不会生成记号,而只是进行状态切换。
代码片段1 Golex词法规则说明书部分内容——定义和规则部分
D [0-9] //D代表数字
L [a-zA-Z_] //L代表英文字母或者下划线,标签名称的起始字符
M [a-zA-Z_:] //M代表英文字母、下划线或者冒号,指标名称的起始字符
C [^\n] //C代表任意字符(除了换行符)
%x sComment sMeta1 sMeta2 sLabels sLValue sValue sTimestamp //定义了7种独占状态
%yyc c
%yyn c = l.next()
%yyt l.state //状态变量
%%
\0 return tEOF //sInit状态下
\n l.state = sInit; return tLinebreak //sInit状态下
<*>[ \t]+ return tWhitespace //任何状态下
#[ \t]+ l.state = sComment //sInit状态下
# return l.consumeComment() //sInit状态下
<sComment>HELP[\t ]+ l.state = sMeta1; return tHelp
<sComment>TYPE[\t ]+ l.state = sMeta1; return tType
<sMeta1>{M}({M}|{D})* l.state = sMeta2; return tMName
<sMeta2>{C}* l.state = sInit; return tText //sMeta2状态将识别到行尾
{M}({M}|{D})* l.state = sValue; return tMName //sInit状态下
<sValue>\{ l.state = sLabels; return tBraceOpen
<sLabels>{L}({L}|{D})* return tLName
<sLabels>\} l.state = sValue; return tBraceClose
<sLabels>= l.state = sLValue; return tEqual
<sLabels>, return tComma
<sLValue>\"(\\.|[^\\"])*\" l.state = sLabels; return tLValue //匹配标签值
<sValue>[^{ \t\n]+ l.state = sTimestamp; return tValue //匹配样本值
<sTimestamp>{D}+ return tTimestamp
<sTimestamp>\n l.state = sInit; return tLinebreak //时间戳后的换行符
%%
下面我们对所有19项规则进行逐一分析。
规则1:在行首遇到NULL(\0),说明到达文件末尾,此时返回tEOF记号,无需切换状态。
规则2:在行首遇到换行符(\n),说明遇到空行,此时返回tLinebreak记号,并将状态置为sInit。
规则3:在任何状态下遇到连续空格或者制表符,说明试图识别的记号在这些空格/制表符之后,而这些空格/制表符需要忽略,此时返回tWhitespace记号,状态保持不变,以继续识别后面的记号。如果这些空格/制表符出现在行首,那么状态将保持为sInit状态,将继续适用sInit规则。
规则4:在行首遇到#连带空格/制表符,说明后面将会是HELP或者TYPE注释,而当前的#号以及连带的空格/制表符可以忽略,不需要生成记号,所以此时只需要将状态切换到sComment状态。
规则5:同样是在行首,当不满足规则4的条件时,如果行首为#号连带其他字符(非空白),说明其后的一整行都是普通注释,所以需要一直扫描到本行结束(换行符之后),然后生成tComment记号(注意与sComment状态区分)。由于此时发生折行再次进入行首,所以将状态切换为sInit状态。
规则6:在sComment状态下,如果扫描到HELP字符串连带空格/制表符,说明在该字符串之后将会是元数据1,此时需要将状态切换到sMeta1,并生成tHelp记号(表明识别到了HELP字符串)。
规则7:在sComment状态下,如果扫描到TYPE字符串连带空格/制表符,说明在该字符串之后将会是元数据2,此时需要将状态切换到sMeta1,并生成tType记号(表明识别到了TYPE字符串)。
规则8:在sMeta1状态下,如果扫描到合法的指标名称,说明这是一个指标名称记号,并且该记号之后应存在另一个记号(允许为空),此时将状态切换为sMeta2状态,并生成tMName记号。
规则9:在sMeta2状态下,状态机将扫描本行所有的剩余字符(包括空格),并将这些字符识别为tText记号,由于在该记号后发生了换行,所以状态将切换为sInit状态。
规则10:在行首时如果扫描到英文字母、冒号或者下划线,说明此行是样本行,分析器将识别出样本的指标名称并返回tMName记号,状态则切换为sValue状态。我们注意到规则8也会生成tMName记号,所以在两种状态下都可以识别指标名称。
规则11:在sValue状态下如果扫描到左大括号,说明后面将是标签集,此时将生成他tBraceopen记号,并将状态切换到sLabels状态。
规则12:在sLabels状态下可能遇到4种情况,即扫描到标签名称、右大括号、等号或者逗号。本规则用于处理第一种情况,即扫描到英文字母或者下划线开头的字符串(一个有效的标签名称),此时将生成tLName记号,但是状态仍然保持为sLables状态以便继续识别剩余的记号,此状态可以根据扫描的字符识别多种记号。
规则13:在sLabels状态如果扫描到右大括号,说明标签集结束了,其后紧跟着应该是样本值,所以此时生成tBraceClose记号,并将状态切换为sValue。
规则14:在sLabels状态下如果扫描到等号(=),说明其后紧跟着应该是标签值,所以此时生成tEqual记号,并将状态切换为sLValue。
规则15:在sLabels状态下如果扫描到逗号,说明其后紧跟着应该是标签名称或者大括号,这些仍然可以在sLabels状态识别,所以此时生成tComma记号,状态保持不变。
规则16:sLValue状态用于识别标签值(双引号包围的字符串),匹配到完整的标签值后将返回tLValue记号,并将状态切换回sLabels(因为标签值后可以是标签名称、逗号、大括号,这些都可以由sLabels识别)。
规则17:该规则与规则11的状态相同,即sValue状态。在该状态下当不适用规则11时,说明该指标没有标签,此时应该直接识别样本值,即任意不含空白、不含大括号的字符串。识别成功后将生成tValue记号,并将状态切换到sTimestamp,以备识别后续的时间戳。
规则18:该规则在sTimestamp状态下启动,用于识别时间戳。时间戳由一连串的数字组成,识别成功以后将生成tTimestamp记号,状态不变。
规则19:在sTimestamp状态下如果不存在时间戳或者时间戳已经识别完毕,此时应该有一个换行符,识别到换行符以后将状态切换回sInit状态,并生成tLinebreak记号。
需要指出的是,当同一状态下存在多个规则时状态机将从上到下尝试匹配模式,前面的规则总是优先获得机会。假设将上述规则3向上调整为第1个规则,那么任何状态下都会先匹配空格和制表符,并将其识别为独立的记号,只有当首字符非空时才有机会匹配其他规则。总的来说,Prometheus设计词法规则时不需要考虑整个字符流的所有内容,只需要考虑每一行可能存在的情况,从而降低了设计难度。
上述所有19项词法规则中使用的记号共有17种,下文所讲的OpenMetrics则在此基础上增加了另外3种记号,从而构成了20种记号(如代码片段2所示)。
代码片段2 记号token清单
tInvalid //表明无法识别为任何其他记号
tEOF //表明扫描到字符流结尾
tLinebreak //换行符
tWhitespace //连续空格或者制表符
tHelp //注释中的行首HELP
tType //注释中的行首TYPE
tUnit //注释中的UNIT,仅用于OpenMetrics
tEOFWord //最后一行注释中的EOF字符串,仅用于OpenMetrics
tText //注释行指标名称之后的结尾字符串
tComment //行首的井号加空格(# ),表示注释行的开头(在OpenMetrics格式中表示exemplar开头)
tBlank //未使用
tMName //指标名称,可以位于注释中的HELP、TYPE、UNIT之后,也可以位于样本行的行首
tBraceOpen //样本行指标名称之后紧跟的大括号
tBraceClose //大括号结束,可以表示指标标签结束,也可以表示Exemplar标签结束
tLName //标签名称,可以是指标的标签名称,也可以是Exemplar的标签名称
tLValue //标签值,
tComma //逗号,用于分割标签对
tEqual //等于号,用于分割标签名称和标签值,在sLabels条件下启动
tTimestamp //时间戳,样本时间或者Exemplar时间
tValue //样本值
2.3 OpenMetrics格式的词法规则说明书
在两种文法同时有效的情况下,Prometheus服务器通过响应消息的Content-Type头决定采用哪种解析规则,如代码片段3所示。如果Content-Type为application/openmetrics-text则采用OpenMetrics文法,其他情况下均采用Prometheus传统文法。
代码片段3 由Content-Type决定解析规则
func New(b []byte, contentType string) Parser {
mediaType, _, err := mime.ParseMediaType(contentType)
if err == nil && mediaType == "application/openmetrics-text" {
return NewOpenMetricsParser(b) //内容类型为openmetrics时使用OpenMetrics词法解析器
}
return NewPromParser(b)
}
OpenMetrics词法规则说明书部分摘录如代码片段4所示,将之与Prometheus传统词法规则进行比较会发现两者的区别在于3个方面。首先,由于OpenMetrics增加了对exemplar的支持,所以相应增加了3个状态sExemplar、sEvalue、sETimestamp,用于识别与exemplar相关的记号。其次,OpenMetrics匹配模式中有空格但是没有制表符,即不再支持制表符。第三,OpenMetrics词法规则的数量为26个,比传统词法规则多7个。
代码片段4 OpenMetrics词法规则说明书部分内容——定义和规则部分
D [0-9] //D代表数字
L [a-zA-Z_] //L代表英文字母或者下划线,可匹配标签名称首字符
M [a-zA-Z_:] //M代表英文字母、下划线或者冒号,可匹配指标名称首字符
C [^\n] //C代表任何字符(除了换行符)
S [ ] //S代表空格字符
%x sComment sMeta1 sMeta2 sLabels sLValue sValue sTimestamp sExemplar sEValue sETimestamp
%yyc c
%yyn c = l.next()
%yyt l.state
%%
#{S} l.state = sComment //sInit状态下
<sComment>HELP{S} l.state = sMeta1; return tHelp
<sComment>TYPE{S} l.state = sMeta1; return tType
<sComment>UNIT{S} l.state = sMeta1; return tUnit
<sComment>"EOF"\n? l.state = sInit; return tEOFWord
<sMeta1>{M}({M}|{D})* l.state = sMeta2; return tMName
<sMeta2>{S}{C}*\n l.state = sInit; return tText
{M}({M}|{D})* l.state = sValue; return tMName //sInit状态下
<sValue>\{ l.state = sLabels; return tBraceOpen
<sLabels>{L}({L}|{D})* return tLName
<sLabels>\} l.state = sValue; return tBraceClose
<sLabels>= l.state = sLValue; return tEqual
<sLabels>, return tComma
<sLValue>\"(\\.|[^\\"\n])*\" l.state = sLabels; return tLValue
<sValue>{S}[^ \n]+ l.state = sTimestamp; return tValue
<sTimestamp>{S}[^ \n]+ return tTimestamp
<sTimestamp>\n l.state = sInit; return tLinebreak
<sTimestamp>{S}#{S}\{ l.state = sExemplar; return tComment
<sExemplar>{L}({L}|{D})* return tLName
<sExemplar>\} l.state = sEValue; return tBraceClose
<sExemplar>= l.state = sEValue; return tEqual
<sEValue>\"(\\.|[^\\"\n])*\" l.state = sExemplar; return tLValue
<sExemplar>, return tComma
<sEValue>{S}[^ \n]+ l.state = sETimestamp; return tValue
<sETimestamp>{S}[^ \n]+ return tTimestamp
<sETimestamp>\n l.state = sInit; return tLinebreak
%%
OpenMetrcis不再识别连续的多个空格和制表符,而是将单个空格包含在记号内。这种设计方案使得监测数据更加紧凑,同时对数据格式提出更高要求。此外,OpenMetrics要求以# EOF作为最后一行来结束整个字符流。
最大的不同是OpenMetrics增加了对Exemplar的支持,在sTimestamp状态下如果扫描到" # {"(4个字符)就会进入sExemplar状态。
2.4 句法分析
词法分析状态机只是输出了一系列记号及其对应的值,但是每个监测样本并非由单个记号表示,而是由多个连续的记号按照规定的次序组合表示。句法分析过程将这些连续的记号进一步识别为具有完整意义的句子。Prometheus传统格式以及OpenMetrics格式均将每一个非空的行识别为一个或者两个句子(exemplar也算一个句子)。
按照Prometheus传统格式的句法规则,一行字符串一定属于以下6种模式中的一种,其中5种可被识别为句子,能够识别出的句子所对应的类型名称见片段5。
- 空行,该行由换行符或者连续的空格/制表符构成,所有空行都将被忽略(继续解析下一行)。
- HELP行,即EntryHelp类型,该行以#<space>HELP<space>开头。
- TYPE行,即EntryType类型,该行以#<space>TYPE<space>开头。
- 普通注释行,即EntryComment类型,该行以#开头,但是#后面不是空白符。
- 样本行,即EntrySeries类型,以英文字母、冒号或者下划线开头的行。
- 无效行,即EntryInvalid类型,当解析结束或者解析某行过程中发生错误(不符合语法规则)时就会识别为无效行,一旦出现无效行整个句法分析过程就会终止。
代码片段5 句法类型
const (
EntryInvalid Entry = -1
EntryType Entry = 0 //TYPE句子
EntryHelp Entry = 1 //HELP句子
EntrySeries Entry = 2 //样本句子
EntryComment Entry = 3 //注释句子(仅用于prometheus传统格式,OpenMetrics格式中不允许)
EntryUnit Entry = 4 //UNIT句子(仅用于OpenMetrics格式)
)
分析OpenMetrics格式的句法规则会发现,其与Prometheus传统句法的区别在于三点:首先,增加了EntryUnit句子;其次,由于在词法分析阶段就不再识别普通注释行,所以句法分析时也就不再识别EntryComment句子;最后, EntrySeries句子包含了exemplar成分。所以,OpenMetrics句法分析能够识别的句子一共5种,即EntryInvalid、EntryType、EntryHelp、EntrySeries、EntryComment、EntryUnit。
OpenMetrics句法规则识别出的句子成分会存储在代码片段6所示的结构体中。该结构体中包含一个词法分析的结构体(即openMetricsLexer)。在解析过程中,程序不断调用词法分析器的函数以获取所需要的一系列记号(token),当获取的记号数足够构成一个句子时,该句子的成分将出现在OpenMetricsParser的各个字段中,以供后续操作使用。观察该结构体的各字段定义会发现它只能容纳一个句子的成分,当解析下一个句子时上一个句子的内容将被覆盖。
代码片段6 句法解析器结构体OpenMetricsParser
type OpenMetricsParser struct {
l *openMetricsLexer
series []byte //由指标名称及其标签集构成的字符串
text []byte //HELP、TYPE、UNIT行中位于指标名称之后的成分
mtype MetricType //指标类型,根据TYPE行内容决定,openmetrics共支持8种指标类型
val float64 //当前行的样本值
ts int64 //样本时间戳
hasTS bool
start int //句子的起始位置(即该行起始字符在整个文本流中的索引号)
offsets []int //偏移量表,记录一行句子的标签成分(标签名称和标签值)的起止点位置
eOffsets []int //exemplar标签集的起止点(包括各个标签名称和标签值)
exemplar []byte //识别到的exemplar标签集(字符串)
exemplarVal float64 //识别到的exemplar样本值(浮点数)
exemplarTs int64 //exemplar时间戳(以毫秒为单位的整数)
hasExemplarTs bool
}
传统格式文本的句法分析也使用到类似的结构体,只不过比OpenMetrics使用的结构体要简单一些,因为传统格式中不包含exemplar相关的内容。