news 2026/9/12 6:50:23

Grafana Loki 列式计算测试 DSL 详解:pkg/compute 领域特定语言与选择向量测试机制

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Grafana Loki 列式计算测试 DSL 详解:pkg/compute 领域特定语言与选择向量测试机制

Grafana Loki 列式计算测试 DSL 详解:pkg/compute 领域特定语言与选择向量测试机制

【免费下载链接】lokiLike Prometheus, but for logs.项目地址: https://gitcode.com/GitHub_Trending/lok/loki

Grafana Loki 的pkg/compute是一个无状态列式数据计算包,它通过一组纯函数对列式数据(columnar.Datum)执行等于、比较、逻辑运算、子串匹配、集合成员判断等操作。为了对这些计算函数进行系统、紧凑、可读的测试,Loki 在 pkg/compute/testdata 下定义了一套专属的领域特定语言(DSL),把一组计算操作与期望结果写成一行一行的测试用例。本文以 pkg/compute/testdata/README.md 为骨架,完整讲解这套 DSL 的语法、数据类型、测试用例组织方式,并结合compute_test.gocomputetest/parser.go等源码揭示其解析与执行原理。读完本文,你将能够阅读、编写和扩展这类 DSL 测试文件,理解选择向量(selection vector)如何参与计算并影响结果,以及如何在新增计算函数时把 DSL 测试接入测试框架。

一、背景:compute 包与列式测试 DSL 的定位

pkg/compute是 Loki 内部的列式计算包,其包注释(pkg/compute/doc.go)明确说明:

  • 它实现"无状态"的计算操作,作用对象是一个或多个columnar.Datum——一种不透明表示,既可以是一个数组(array),也可以是一个标量(scalar)值,并带有特定的值类型(kind)。
  • 除非特别说明,所有 compute 函数返回的数据都使用调用方传入的memory.Allocator分配。
  • 该包目前标注为EXPERIMENTAL(实验性),当前仅被 pkg/dataobj 使用。

compute 包内部按功能拆分为多个源码文件:equality.go(等于/不等于/大小比较)、logical.go(NOT/AND/OR)、filter.go(FILTER)、set.go(IsMember)、utf8.go(子串与正则匹配),外加compat.gostats.goshape.goassertions.goresults_cache.go等外围文件。

围绕这些计算函数,pkg/compute/testdata/目录存放了用 DSL 编写的测试文件,它们与 Go 测试互补:DSL 擅长表达"输入参数组合 + 期望输出"这种高密度的数据驱动用例,Go 测试则负责 DSL 难以表达的复杂场景(如期望报错、record batch、struct 操作等)。

二、DSL 语法规范

pkg/compute/testdata/README.md给出了完整的 DSL 文法(grammar):

Cases := Case* Case := Function Datum* Selection? "->" Datum TERMINATOR Datum := TypedValue Selection := "select" ":" "[" Scalar* "]" TypedValue := Type ":" Value Type := "bool" | "int32" | "int64" | "uint32" | "uint64" | "utf8" | "null" Value := Scalar | Array Scalar := <literal> | "null" | "_" Array := "[" Scalar* "]" TERMINATOR := "\n"

逐条拆解:

  • Cases:一个测试文件由零个或多个 Case 组成。
  • Case:一行测试用例,结构为函数名 + 若干 Datum 参数 + 可选的选择向量 + "->" + 期望结果 Datum + 行结束符。例如EQ int32:5 int32:5 -> bool:true
  • Datum:带类型的值,形如类型:值,例如int32:[1 2 3]utf8:"hello"null:[null null]
  • Selection:可选的选择向量,形如select:[true false true],必须是布尔数组,用于指示哪些行参与计算、哪些行在结果中被标记为"未定义"(见第四节)。
  • TERMINATOR:一条用例以换行符结束。
  • 注释:以#开头,持续到行尾。所有空白字符都被忽略,因此排版比较自由(例如函数名与参数之间可以用多个空格对齐,便于阅读)。

2.1 从文法到解析器实现

这套文法在 pkg/compute/internal/computetest/parser.go 中有逐一的实现对应:

  • parseCases()(parser.go 第 54 行)循环调用parseCase(),直至遇到tokenEOF
  • parseCase()(第 70 行)先读取函数名,然后循环解析 Datum 参数,直到遇到->tokenArrow)或selecttokenSelect);接着解析可选的选择向量,再解析->后的期望结果,最后要求一个TERMINATOR收尾;
  • parseDatum()(第 117 行)根据类型标识分发到parseBoolDatumparseInt32DatumparseInt64DatumparseUint32DatumparseUint64DatumparseUTF8DatumparseNullDatum
  • parseSelection()(第 596 行)解析select:后的布尔数组,并且校验选择向量中不能包含 null 值,否则报错。

底层还依赖词法分析器scanner.go与 token 定义token.go:整数字面量支持可选负号(-42),字符串字面量必须加引号并支持转义序列,标识符用于函数名、类型名以及true/false/null/_等字面量。

三、数据类型详解

DSL 支持 7 种类型(对应 parser.go 中parseDatum的分发逻辑),以及一个特殊的"未定义"占位符:

类型含义示例
bool布尔值truefalsenull
int32有符号 32 位整数-42123
int64有符号 64 位整数-42123
uint32无符号 32 位整数0456
uint64无符号 64 位整数0456
utf8UTF-8 字符串,必须加引号"hello""test string",支持转义序列
null显式的空值类型,用于纯 null 值null:[null null null]
_未定义值(undefined)表示某个位置的值是"未定义"的,例如选择向量中未被选中的行

关于_有一个重要细节:它不是一个独立的"类型",而是值层面的占位符。从 parser.go 可以看到,_在标量位置会被解析为对应类型的空标量(如&columnar.BoolScalar{}&columnar.NumberScalar[int32]{}&columnar.UTF8Scalar{}),在数组位置(如bool:[true _ true])则向 builder 追加一个占位值。因此_可以出现在任意类型的值位置上,表示"该槽位无有效值",通常用来与选择向量配合,指示未选中的行。

解析器还校验数值的字面量范围:int32使用strconv.ParseInt(..., 32)uint64使用strconv.ParseUint(..., 64),超范围会在解析阶段直接报错,而不是等到运行时。

四、选择向量(Selection Vector)语义

选择向量是这套测试 DSL 最具特色的机制,也是 pkg/compute/ARCHITECTURE.md 详细阐述的核心设计。它让计算操作能够高效地做"行级过滤"——只对满足条件的行执行计算,而不必物化出过滤后的子集。

4.1 表示方式与约定

选择向量在运行时用 memory.Bitmap 表示,bitmap 中的每一位对应输入数据的一行:

  • true bit:该行被选中,参与计算;
  • false bit:该行未被选中,在结果中按 null/未定义处理。

关键约定(摘自 ARCHITECTURE.md):

  • 全部选中:当 selection 参数Len() == 0时,表示所有行都被选中。这是默认行为,且相比不支持选择向量的操作零额外性能开销
allSelected := memory.Bitmap{} // Len() == 0 result := compute.Equals(mem, left, right, allSelected) // all rows selected
  • 部分选中:当 selection bitmap 的Len() > 0时,只有对应位为 true 的行参与计算,其余行在结果中被置为未定义:
selection := memory.NewBitmap(mem, 10) // 10 rows selection.Set(0) // select first row selection.Set(5) // select sixth row result := compute.Equals(mem, left, right, selection) // only rows 0 and 5 are computed, others are null

4.2 调度模式(Dispatch Pattern)

compute 函数针对输入组合采用统一的调度模式(见 ARCHITECTURE.md 的 "Dispatch Pattern" 一节),选择向量只作用于数组结果

输入组合缩写选择向量处理
标量-标量SS不应用选择向量(结果是标量)
标量-数组SA对结果数组应用选择向量
数组-标量AS对结果数组应用选择向量
数组-数组AA对结果数组应用选择向量

设计动机包括:懒求值(被过滤的数据不复制、不物化)、内存高效(未选中的行原地保留)、可组合(选择向量可通过位运算合并)、一致的空值处理(未选中的行按 null 处理)。

4.3 DSL 中的选择向量写法

在 DSL 中,选择向量写作select:[true false true false]这种形式,位于参数之后、->之前。以 pkg/compute/testdata/selection.test 中的用例为例:

EQ int32:[1 2 3 4] int32:[1 0 3 0] select:[true false true false] -> bool:[true _ true _]

解读:左右两个数组逐行比较,只有第 0 行和第 2 行被选中,因此这两行产生真实比较结果truetrue;第 1、3 行未被选中,结果槽位写为_(未定义)。所以期望输出为bool:[true _ true _]

选择向量还有几个值得注意的边界行为(均可在 selection.test 与 logical.test 中看到对应用例):

  • 全不选中select:[false false false false]时输出全部为_(例如AND ... select:[false false false false] -> bool:[_ _ _ _]);
  • 单选:只选中一行时,只有该行有计算结果,其余为_
  • 带 null 的输入 + 选择向量:选中行中如果有 null,结果为 null,未选中行仍为_,二者语义不同但可共存(例如AND bool:[true null true false] bool:[true true false null] select:[true true false false] -> bool:[true null _ _])。

五、测试文件组织与运行方式

5.1 目录结构

pkg/compute/testdata 下共有 6 个.test文件,按计算函数分组:

文件覆盖的函数说明
equality.testEQ / NEQ / LT / LTE / GT / GTE等于、不等于、小于、小于等于、大于、大于等于
logical.testNOT / AND / OR逻辑非、与、或
filter.testFILTER按掩码过滤数组
set.testISMEMBER集合成员判断
utf8.testSUBSTR / SUBSTRI / REGEXP子串、大小写不敏感子串、正则匹配
selection.test混合函数专门演示选择向量的遮蔽行为

5.2 运行入口:TestCompute

所有这些.test文件由 pkg/compute/compute_test.go 中的TestCompute函数统一驱动(第 20 行起):

func TestCompute(t *testing.T) { require.NoError(t, filepath.WalkDir("testdata", func(path string, d fs.DirEntry, walkErr error) error { // 只处理 *.test 文件 cases, err := computetest.ParseCases(f) ... for _, tc := range cases { t.Run(fmt.Sprintf("%s/source=%s:%d", tc.Function, d.Name(), tc.Line), func(t *testing.T) { var alloc memory.Allocator result, err := evalCaseFunction(t, &alloc, tc) require.NoError(t, err) mask := tc.Selection if tc.Function == "FILTER" { // FILTER 会把掩码物化,因此不再把 mask 传给 RequireDatumsEqual mask = memory.Bitmap{} } columnartest.RequireDatumsEqual(t, tc.Expect, result, mask) }) } ... })) }

运行方式:

cd pkg/compute && go test -run TestCompute ./...

TestCompute会递归遍历testdata目录,读取所有.test后缀文件,用computetest.ParseCases解析出全部用例,然后逐条调用evalCaseFunction执行对应的 compute 函数,最后用columnartest.RequireDatumsEqual对比实际结果与期望结果(并考虑选择向量掩码)。每个用例的测试名包含函数名、源文件名与行号(tc.Line),失败时可以精确定位到具体某一行 DSL。

其中FILTER有特殊处理:FILTER 会物化掩码、真正压缩出结果数组,所以校验阶段不再回传 mask,避免重复遮蔽。

六、核心计算函数与 DSL 用例解析

evalCaseFunction(compute_test.go 第 58 行)维护着 DSL 函数名到 compute 包函数的映射表,这是理解 DSL 的关键索引:

DSL 函数名底层实现(pkg/compute)参数特殊处理
EQEquals2要求两侧类型一致
NEQNotEquals2同上
LTLessThan2要求可排序类型
LTELessOrEqual2同上
GTGreaterThan2同上
GTEGreaterOrEqual2同上
NOTNot1仅布尔
ANDAnd2仅布尔
OROr2仅布尔
SUBSTRSubstr2大小写敏感子串
SUBSTRISubstrInsensitive2大小写不敏感子串
REGEXPRegexpMatch2第二个参数必须是 utf8 标量,被编译为正则
FILTERFilter1用选择向量做掩码物化过滤
ISMEMBERIsMember2第二个参数(数组)被转换为columnar.Set

下面按文件逐一说明各类函数在 DSL 中的用例写法。

6.1 等值与比较运算:equality.test

equality.test是最大的一个测试文件,系统覆盖了 EQ / NEQ / LT / LTE / GT / GTE 六种比较函数,每种函数都覆盖:

  • 标量-标量(scalar, scalar)
EQ bool:true bool:false -> bool:false EQ int32:5 int32:5 -> bool:true LT utf8:"a" utf8:"b" -> bool:true
  • 标量-数组(scalar, array)
EQ bool:true bool:[true false null] -> bool:[true false null] LT int32:5 int32:[3 5 10 null] -> bool:[false false true null]
  • 数组-标量(array, scalar)
NEQ int32:[5 10 null] int32:10 -> bool:[true false null] GTE utf8:["apple" "banana" "cherry" null] utf8:"banana" -> bool:[false true true null]
  • 数组-数组(array, array)
EQ int32:[1 2 3 4 null null null] int32:[1 3 3 5 1 2 null] -> bool:[true false true false null null null] GT int32:[5 5 10 10 null null null] int32:[1 5 5 15 1 2 null] -> bool:[true false true false null null null]
  • 带选择向量
EQ int32:[10 20 30 40] int32:[10 21 30 41] select:[true false true false] -> bool:[true _ true _] LTE int32:[10 20 10] int32:[10 15 10] select:[true false true] -> bool:[true _ true]

从 equality.go 的Equals实现可以看到一个明确的规则:两侧类型不一致直接报错both inputs must be the same kind),且任何一侧出现 null,结果就是 null。LessThan等比较运算要求类型"可排序"(ordered),对null同样传播空值。

值得注意的用例细节:在标量-数组和数组-标量组合中,null的传播行为与标量-标量一致——只要参与比较的对应位置出现 null,结果就是 null,例如EQ int32:null int32:[5 10 null] -> bool:[null null null]

6.2 逻辑运算:logical.test

logical.test覆盖 NOT / AND / OR 三种逻辑运算。核心语义同样遵循"三值逻辑"(true / false / null):

NOT bool:true -> bool:false NOT bool:null -> bool:null AND bool:true bool:null -> bool:null OR bool:false bool:null -> bool:null AND bool:[true false null] bool:false -> bool:[false false null] OR bool:[true false null] bool:true -> bool:[true true null]

带选择向量的逻辑运算用例也覆盖了四种组合(数组-数组、标量-数组、数组-标量):

AND bool:[true false true false] bool:[true true false false] select:[true false true false] -> bool:[true _ false _] AND bool:true bool:[true false true false] select:[true false true false] -> bool:[true _ true _] OR bool:[true false true false] bool:false select:[false true false true] -> bool:[_ false _ false]

从源码看,Not(logical.go)直接忽略选择向量参数(参数名为_ memory.Bitmap),因为 NOT 是单目运算,选择向量的遮蔽由上层统一处理;And/Or(第 72、83 行)则接收并应用选择向量。

6.3 过滤运算:filter.test

FILTER与其它函数不同,它的作用不是生成布尔结果,而是根据布尔掩码真正压缩出子数组——只保留掩码为 true 的行:

FILTER bool:[true false true false] select:[true false true false] -> bool:[true true] FILTER int32:[1 2 3 4 5] select:[true false true false true] -> int32:[1 3 5] FILTER int32:[10 20 30] select:[false false false] -> int32:[] FILTER utf8:[null "hello" "world"] select:[true true false] -> utf8:[null "hello"] FILTER null:[null null null] select:[true false true] -> null:[null null]

几个要点:

  • 全不选中时结果为空数组[](而不是全_数组);
  • 输入中的 null 值在选中时会原样保留(int32:[10 null 30] -> int32:[10 null]);
  • 这就是compute_test.go中对 FILTER 特殊处理(mask = memory.Bitmap{})的原因——掩码已经被物化消费掉了。

从 filter.go 的实现看,Filter接收input columnar.Datummask memory.Bitmap,返回按掩码挑选后的新 Datum。

6.4 集合成员判断:set.test

ISMEMBER判断第一个数组中的每个值是否属于第二个数组(作为集合):

ISMEMBER utf8:["test1" "test2" "test3"] utf8:["test1" "test2" "test4"] -> bool:[true true false] ISMEMBER int32:[1 2 3] int32:[4 5 6] -> bool:[false false false] ISMEMBER int32:[] int32:[1 2 3] -> bool:[] ISMEMBER utf8:[null] utf8:["test1" "test2" "test3"] -> bool:[null]

带选择向量的用例:

ISMEMBER utf8:["apple" null "cherry" null] utf8:["apple" "cherry"] select:[true true true true] -> bool:[true null true null] ISMEMBER utf8:["apple" null "cherry" null] utf8:["apple" "cherry"] select:[true false true false] -> bool:[true _ true _]

evalCaseFunction中,ISMEMBER的第二个参数(数组)会被转换为columnar.SetNewUTF8SetNewNumberSet),且集合不允许包含 null 值require.Equal(t, 0, arr.Nulls(), ...));转换后调用 IsMember。注意集合语义是"去重后的成员判断",因此第二个数组即使有重复值也只影响集合构建,不影响结果。

6.5 字符串与正则:utf8.test

utf8.test覆盖三种字符串匹配函数:

  • SUBSTR:大小写敏感的子串匹配。utf8:"test"匹配"test"但不匹配"TEST"
  • SUBSTRI:大小写不敏感的子串匹配,"test"也能匹配"TEST"
  • REGEXP:正则表达式匹配,第二个参数必须是标量(正则模式),且由测试框架编译为*regexp.Regexp后传入 RegexpMatch。

基础用例:

SUBSTR utf8:["test"] utf8:"test" -> bool:[true] SUBSTR utf8:["test"] utf8:"TEST" -> bool:[false] SUBSTRI utf8:["test"] utf8:"TEST" -> bool:[true] REGEXP utf8:["test"] utf8:"test" -> bool:[true] REGEXP utf8:["test"] utf8:"NOTtest" -> bool:[false]

空字符串与 null 的边界行为:

SUBSTR utf8:["test"] utf8:"" -> bool:[true] // 空串是任意字符串的子串 SUBSTR utf8:[null] utf8:"test" -> bool:[null] REGEXP utf8:["test"] utf8:null -> bool:[null]

带选择向量的正则用例(utf8.test中给出了 full / partial / middle / single / none 五种遮蔽形态的完整矩阵):

REGEXP utf8:["foo" "bar" "baz" "qux" "test"] utf8:"ba." select:[true true true true true] -> bool:[false true true false false] # Full selection REGEXP utf8:["foo" "bar" "baz" "qux" "test"] utf8:"ba." select:[false true true true false] -> bool:[_ true true false _] # Partial (middle)

REGEXP 的特殊处理逻辑位于 compute_test.go:第二参数必须断言为*columnar.UTF8Scalar(否则测试失败),非 null 时用regexp.Compile编译,编译失败即测试失败;若模式为 null,则传入 nil 正则(此时结果全为 null)。

七、如何新增一个 compute 函数并接入 DSL 测试

README 对扩展流程给出了明确指引,结合源码可整理为如下步骤:

  1. 实现 compute 函数:在pkg/compute下新增函数,签名遵循统一约定func(alloc *memory.Allocator, args ..., selection memory.Bitmap) (columnar.Datum, error),返回数据使用传入的 allocator 分配(见 doc.go 的包级约定)。

  2. 更新evalCaseFunction:在 pkg/compute/compute_test.go 的switch tc.Function中新增分支,把 DSL 函数名映射到新函数,并用require.Len校验参数个数。

  3. 处理特殊参数:如果新函数需要非常规参数(例如 REGEXP 需要把columnar.Datum转成编译后的正则、ISMEMBER 需要把数组转成Set),需要像 compute_test.go 那样做类型断言与转换。

  4. 编写 DSL 用例:在testdata下新增或扩展.test文件,按第五节语法编写用例,覆盖标量/数组的四种组合以及选择向量场景。

  5. 运行测试go test -run TestCompute ./pkg/compute/即可验证全部 DSL 用例。

八、DSL 的边界:错误测试与高级测试

README 明确划定了 DSL 的能力边界,这两类场景应放在 Go 测试文件中:

  1. 错误测试(Error testing):DSL无法表达"计算函数应当失败"的用例(例如类型不匹配、不可排序类型、非法参数)。这类用例应写在 Go 测试文件中直接断言错误返回值,例如compute.Equals对两侧 kind 不一致会返回fmt.Errorf("both inputs must be the same kind, got %s and %s", ...)(equality.go)。

  2. 高级测试(Advanced tests):作用于 record batch 或 struct 的更复杂计算函数(例如 equality_struct.go、filter_struct_test.go、equality_bench_test.go 所覆盖的场景),它们需要更复杂的构造和前置条件,更适合直接用 Go 测试代码搭建。

这一分工让 DSL 保持"一行一用例"的高密度表达力,同时把异常路径和复杂结构测试留在表达能力更强的 Go 层。

九、小结

pkg/compute/testdata下的 DSL 是 Loki 列式计算引擎测试体系中的一块"数据驱动拼图":

  • 语法上,它用函数名 + 类型化 Datum 参数 + 可选选择向量 -> 期望结果的紧凑文法,一行描述一个完整用例,注释与空白规则让它天然易读;
  • 语义上,它忠实反映了 compute 包的运行时约定——三值逻辑(null 传播)、类型一致性校验、四种输入组合的调度模式,以及选择向量的懒求值与未定义槽位标记;
  • 工程上TestCompute+computetest解析器(scanner/parser/token)共同构成一个低摩擦的扩展通道:新增计算函数只需实现函数、在evalCaseFunction注册映射、在.test文件补充用例即可。

理解这套 DSL,等于同时理解了 Loki 列式数据模型(columnar.Datummemory.Bitmap)与计算层的测试方法论——这对于阅读 pkg/dataobj 等使用 compute 包的上游代码,以及为 Loki 贡献新的列式计算能力,都是必要的一课。

【免费下载链接】lokiLike Prometheus, but for logs.项目地址: https://gitcode.com/GitHub_Trending/lok/loki

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/12 6:50:09

BSP树原理与在图形渲染中的实践应用

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/12 6:46:32

AI Agent开发实战:Python工程化落地全链路指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/12 6:45:51

Cataclysm-DDA建筑材料合成完整指南:如何搭建末日庇护所

Cataclysm-DDA建筑材料合成完整指南&#xff1a;如何搭建末日庇护所 【免费下载链接】Cataclysm-DDA Cataclysm - Dark Days Ahead. A turn-based survival game set in a post-apocalyptic world. 项目地址: https://gitcode.com/GitHub_Trending/ca/Cataclysm-DDA Cat…

作者头像 李华
网站建设 2026/9/12 6:44:41

同步电机与构网型变流器并联运行的频率稳定性仿真分析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/12 6:43:20

九州云Skyline平台OpenStack私有云部署指南

1. OpenStack与九州云Skyline平台概述 OpenStack作为开源云计算管理平台项目&#xff0c;已经成为企业私有云建设的首选方案之一。而九州云推出的Skyline发行版&#xff0c;则是基于原生OpenStack进行了深度优化和功能增强的企业级解决方案。我在实际部署过程中发现&#xff0c…

作者头像 李华
网站建设 2026/9/12 6:41:38

2026日志分析工具选型:从ELK到Loki与ClickHouse的演进与实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华