plugin-descriptor-patcher:IntelliJ 构建管线中META-INF/plugin.xml的 Go 化描述符补丁执行器
【免费下载链接】intellij-communityIntelliJ IDEA & IntelliJ Platform项目地址: https://gitcode.com/GitHub_Trending/in/intellij-community
导读
本文介绍 IntelliJ 平台仓库(intellij-community)中build/plugin-descriptor-patcher这一 Go 命令行工具:它负责为每个插件的主 JAR写出META-INF/plugin.xml(即“插件描述符”),是 Bazel 规则dev_dist_plugin_descriptor的执行器,也是原 JVM 工具applyPluginDescriptorPatch(PluginXmlPatcher.kt)的 Go 移植版。阅读本文后,你将掌握:七阶段补丁管线各阶段的职责与对应的 Go 包、请求参数文件的完整选项表、字节级序列化的四条关键规则、以及驱动质量保证的“字节门”与“双生产者”验证体系。
背景:为什么把描述符补丁迁到 Go
META-INF/plugin.xml描述插件的能力、扩展点与依赖,IntelliJ 平台在构建每个插件时都会执行一次描述符补丁(patch)。由于每个插件主 JAR 都依赖这条补丁链,任何 JVM 动作都会落在构建的**关键路径(critical path)**上——这正是决策记录 ADR 0006 所排除的情形。ADR 0006 主张把执行器(executor)迁到 Go,而 JVM 仅保留在生成器(generator)中,因此出现了这个 Go 二进制。从入口文件的文档注释可以看出,它明确自述为dev_dist_plugin_descriptor规则(dev_dist_plugin_descriptor.bzl)的执行器,取代了@community//platform/build-scripts/bazel-rules/dev-dist-plugin-descriptor这个 JVM 工具——但后者仍作为“第二生产者”保留,用于逐插件字节比对。
遵循约定请参考邻居模块content-module-packer:确定性写入器、手工维护的BUILD.bazel、以及针对被替换代码的字节门(byte gate)。
七阶段补丁管线:结构与归属
描述符补丁由七个阶段组成,阶段名由DevDistDescriptorStage命名,开发版(dev distribution)组装会为每个插件记录每个阶段的尺寸与文本:
| 阶段 | 归属实现 |
|---|---|
source | 输入(未修改的原描述符文本) |
rawTextPatcher | internal/markers,即计划(plan)的标记表,是DescriptorMarkerPatcher以数据形式声明的补丁 |
reserialized | internal/descriptorxml,即JDOMUtil.load+JDOMUtil.write,逐字节复刻 |
stamps | internal/stamps,即doPatchPluginXml加上计划的版本后缀 |
includes | internal/structural,基于“种子化描述符缓存”执行resolveIncludes |
contentModules | internal/structural,由计划驱动的resolveAndEmbedContentModuleDescriptor |
textPatcher | 永不在此执行:它运行在 stamps 之后、针对本工具产出的文本,因此没有表格记录它 |
其中rawTextPatcher与textPatcher是按布局(per-layout)的 Kotlin lambda,本质是代码而非数据,计划无法用数据表达它们;因此凡布局以 lambda 形式声明原始补丁的插件,会按名称被排除在本规则的人群(population)之外。
标记表(markers):以数据声明的原始补丁
internal/markers实现的是计划条目以数据形式声明的原始描述符文本补丁(对应平台侧的applyDescriptorMarkers/parseDescriptorMarkerRow,见markers.go)。标记表共有两种行形状:
os-arch:<osId>:<marketplaceName>:声明操作系统与架构;osArchDescriptorMarker拥有替换文本——它含有一个换行符,而请求的参数文件无法在一行内承载。osId取OsFamily.osId集合{windows, mac, linux},架构取JvmArchitecture.marketplaceName集合{x86_64, arm64}。替换文本形如两条<plugin id=.../>行(替换<!-- OS/ARCH-DEPENDENCY-PLACEHOLDER -->占位符)。marker:<literal>:<replacement>:声明一次普通文本替换,字面量以第一个:结束。
两种生产者的共同点是:只替换首次出现的普通字符串。原因在于checkedReplace(BuildUtils.kt)把字面量编译为正则表达式,而 Go 的 RE2 与 Java 的Pattern语义不一致;因此生成器会拒绝字面量含正则元字符、或替换文本含$/\的行,保证两个生产者读取结果一致。未知形状的行会直接使动作失败,因为跳过的行会产出未补丁的文本(unpatched text)。
请求参数:完整选项表
本二进制与原 JVM 工具接收同一份请求:相同的选项拼写、相同的--flagfile参数文件——这正是“可执行文件替换”得以成立的原因。参数解析对应平台侧parseDevDistPluginDescriptorRequest(DevDistPluginDescriptorMain.kt),未知选项使运行失败,从而让两个生产者始终保持同一拼写。选项列表如下:
| 选项 | 说明 |
|---|---|
--out | 输出文件路径(必填) |
--main-module | 插件主模块名(必填) |
--directory-name | 目录名,仅供DevDistPatchedDescriptors报告使用,本二进制读取但不再写出 |
--main-jar-name | 主 JAR 名 |
--source | 源描述符文件(必填) |
--build-number-file | 构建号文件(必填),读取后去除空白并做语义版本校验 |
--release-date | 发布日期,仅写入<product-descriptor> |
--release-version | 发布版本,仅写入<product-descriptor> |
--eap | 布尔(严格解析),设置/清除product-descriptor的eap属性 |
--exact-version | 布尔,选择RangeExact兼容区间 |
--retain-product-descriptor | 布尔,捆绑插件是否保留product-descriptor |
--embed-content-modules | 布尔,默认true;为false时不嵌入内容模块描述符 |
--refused-content-module | 产品过滤器拒绝的内容模块名(可重复) |
--separate-jar | 以separate-jar="true"标记的模块名(可重复) |
--plugin-descriptor | <load path>=<file>形式的插件描述符(可重复) |
--plugin-descriptor-in-jar | <load path>=<jar>:位于库容器 jar 内的描述符(可重复,按容器自身顺序) |
--marker | 标记表行(可重复) |
--version-suffix | 布局追加到 IDE 构建号后的版本后缀,为空表示原样盖章 |
--platform-descriptor | <load path>=<file>形式的平台描述符(可重复) |
--plugin-module | 插件模块名(可重复,构成插件搜索作用域) |
--platform-module | 平台模块名(可重复,构成平台搜索作用域) |
布尔值采用 Kotlin 的toBooleanStrict语义,仅接受字面量true/false。必填项为--out、--main-module、--source、--build-number-file。--release-date/--release-version在规则上为必填,因此空值属于规则无法表达的请求。命令行可直接传参,也支持单个--flagfile=<path>指向多行参数文件(与content_module_jar、ij_plugin一致),参数文件中的\r\n会先归一化为\n。
为什么“往返序列化”是承重墙
平台用JDOMUtil.load读取描述符、用JDOMUtil.write写出每个阶段——这一对操作会在任何补丁运行之前重写每个描述符的空白、属性引号与 CDATA。实测:单个产品 163 个插件中,仅往返就重写了 162 个文本,净减−71 718 字节。因此,一个“补丁正确但序列化错误”的移植版会对每个插件产出错误字节。
internal/descriptorxml因此从平台自身源码逐条镜像读、写两半,每条规则都标注file:line(见 read.go 与 write.go):
- 格式为
Format.getCompactFormat().setIndent(" ").setTextMode(TRIM).setLineSeparator("\n"); - 没有 XML 声明、也没有尾部换行,因为
output(Element, Writer)只打印元素本身; MyXMLOutputter会在元素文本内转义"(标准序列化器不会),且从不转义';- 读取器删除每条注释、每条处理指令与每个纯空白文本段;
- 读取器合并文本,因此 CDATA 段到达时是普通文本,只有补丁才会重新创建 CDATA。
读取器是一个自研扫描器而非encoding/xml,因为平台读取器(Aalto,经StaxFactory与SafeStAXStreamBuilder)有三个encoding/xml无法表达的决策:doCoalesceText(true)把 CDATA 并入周围文本运行;非预定义实体引用(非lt/gt/amp/quot/apos)被直接丢弃而非报错或展开;限定名按文档写下的前缀打印(XMLOutputter用前缀而非 URI 解析结果)。空文档对应平台buildJdom的Element("empty")。写入器还需注意:无有效内容的元素写成" />"(expandEmptyElements=false);TextMode.TRIM用 JavaString.trim裁剪(比四个 XML 空白字符更宽);纯文本内容的元素完全不缩进,含元素子节点的才全量缩进;xml:space="preserve"切换到Format.getRawFormat()的“不缩进”模式。
stamps 阶段:给每个描述符盖章
internal/stamps对应doPatchPluginXml(PluginXmlPatcher.kt),是七个阶段中每个插件都必经的一步(一个产品的组装报告显示它改动了全部 162 个文本)。它做四件事(见 stamps.go):
- 把
since-build与until-build盖到idea-version上(描述符没有该元素时创建); - 把插件版本写为
version的文本(同样按需创建); - 对捆绑插件移除
product-descriptor,或在其上盖章eap、release-date、release-version(描述符已声明的日期胜出,除非以__前缀声明占位符); - 把
description与change-notes的文本重新包回CDATA段——这正是描述符收缩而非膨胀的原因:往返把正文转义了,CDATA 框架再解除转义。
新建元素的位置是数据而非细节:getOrCreateTopElement把新元素放在id或name(按优先级取首个存在的锚点)之后,两者皆无则放在位置 0。
版本计算位于 version.go:PluginBuildNumber把.SNAPSHOT后缀替换为固定数字99999999(保证同一提交的两次构建版本一致),结果只有 0 或 1 个点时补.0,并执行与平台一致的语义版本校验(SemVer.parseFromText的移植,允许前导零与任意宽度数字段)。兼容区间有三种:RangeExact(仅该构建号)、RangeRestrictedToSameRelease(仅最后一段可不同)、RangeNewerWithSameBaseline(同基线的更新构建),分别由--exact-version、--eap及默认选择。
两个结构化阶段:从“声明文件”读取其他描述符
includes与contentModules这两个阶段会读取其他描述符——xi:include指向一个文件,<content>的<module/>要接收该模块自身描述符作为 CDATA 正文。因此internal/structural需要一个描述符缓存,且缓存只能从动作声明的文件回答(见 resolver.go)。平台侧解析器搜索顺序是缓存 → 模块输出 → 模块依赖 → 项目所有模块,除第一步外都需要 JPS 项目模型;而DevDistPluginDescriptorMain.kt拒绝加载项目模型,任何触达这些步骤的运行都会失败。因此:声明文件回答不了的 include 直接失败,错误信息会同时列出加载路径与全部已声明路径——因为修复方式永远是生成的计划里缺了某条声明。
includes阶段(includes.go)逐条把xi:include替换为它指向的内容(替换在 include 自身位置上,位置是数据:intellij.database.plugin在自己三个<content>块后声明四个 include)。内容列表采用倒序遍历,因为“以列表替换子节点”会移动后续所有索引。可选 include(含xi:fallback)与动态 include(含includeIf/includeUnless)在构建期不安全,直接返回“未解析”,元素留在树中;xpointer支持xpointer(/idea-plugin/*)与xpointer(/idea-plugin/<sub>/*)两种形状,默认指针取<idea-plugin>的全部子节点。ToLoadPath对应平台LoadPathUtil.toLoadPath,规则为:以/开头去掉前导/;以intellij.、fleet.、kotlin.开头则原样保留;其余补META-INF/前缀。
contentModules阶段(embed.go)按计划驱动:先移除每个计划拒绝的<module/>(拒绝项必须全部找到,否则动作失败并指名),再为每个幸存者嵌入其模块描述符为 CDATA 正文。内容模块描述符文件名由ContentModuleDescriptorFileName生成(/换成.后加.xml)。separate-jar有三个门,按平台顺序:嵌入描述符声明了package属性、模块名不含/、计划在separate_jar中指名该模块——三者全满足才写separate-jar="true"。
库容器内的描述符:--plugin-descriptor-in-jar
有一个插件会读取生产源码树中没有的描述符:Kotlin 编译器在库 jar 内随附META-INF/analysis-api/analysis-api-fir.xml等六个文件。计划的处理方式是:指名分组这些 jar 的库容器,并声明各条目。容器中的 jar 就是声明的输入,请求按容器自身的 jar 顺序为每个(条目、jar)对写一行--plugin-descriptor-in-jar。两个生产者都从这些行播种缓存,取第一个命中的 jar。容器内所有 jar 都未命中则动作失败,错误信息列出它问过的每个 jar。加载路径即 zip 条目,因为toLoadPath去掉前导/(对应平台findFileInModuleLibraryDependencies,见 main.go 中seedFromJars的实现)。
质量保障:三类验证
精选用例门
bazel test @community//build/plugin-descriptor-patcher/... --test_arg=-test.v精选用例是已提交的门禁,其中每条期望都是平台在真实 classpath上产出的文本,“一个构造一条用例”。
全人群门(population gate)
internal/stamps/population_test.go读取开发版分发组装写出的工件,未设置IJ_DESCRIPTOR_CASES(指向*.patched-descriptors.json或目录)时跳过。DevDistPatchedDescriptors持有补丁每个阶段的文本,因此对JDOMUtil或doPatchPluginXml的改动会经由产生产物的组装到达此门。刷新 fixture 并一次性复制文件(后续关闭标志的 Bazel 运行会清除它们):
./bazel.cmd build //build:idea_air_dist \ --@community//platform/build-scripts/bazel-rules:dev_dist_patched_descriptors \ --output_groups=+dev_dist_patched_descriptors cp out/bazel-bin/build/*.patched-descriptors.json <dir>/ bazel test @community//build/plugin-descriptor-patcher/internal/stamps:stamps_test \ --test_env=IJ_DESCRIPTOR_CASES=<dir> --test_arg=-test.v还需写<dir>/request.txt(每行key=value,可带key@<main module>=value表示单插件偏差):工件不携带盖章标量,缺此文件时使用defaultRequest的默认值,stamps 臂会因<version>不同而报告每条记录差异。工件本身不提交,因为它是一个产品描述符的兆字节级数据。
“构建哪一臂决定这门能覆盖什么”:被传入已产出描述符的 fragment 只读文件、不运行任何补丁阶段,记录标"origin": "produced"且无阶段文本,门按名称跳过并打印计数。2026-08-28 实测://build:idea_air_dist的 162/163 个 fragment 都改为读取,仅剩intellij.devkit(计划未覆盖的额外插件,intellij.dev因描述符获得 label 而移出该集合,见platform/buildScripts/src/productLayout/devDistPluginDescriptorPlan.kt的containingBazelPackageLabel)。要覆盖全部 163 个,可把build/dev_dist_plugin_descriptors.bzl中产品条目的fragment_reads设为"none"构建后复制再恢复——该键保留全部生产者、仅取走声明,使 fragment 运行每个描述符的每个阶段、工件持有每段阶段文本;若清空descriptor_targets则会移除生产者,使下面的双生产者门无从比较。2026-08-27 该形状的工件实测:rawTextPatcher→reserialized163/163、reserialized→stamps163/163、类 (a) stamps 文本对照patched43/43(即实际装入插件主 JAR 的文本)。该臂的工件还需为每个追加版本后缀的插件写一行version@<main module>,因为记录要携带计算出的版本、门标量才是计划的。
两个结构化阶段在构造上没有字节臂:它们读取其他描述符,而工件只记录单个插件的阶段文本、无描述符闭包。因此“结构化阶段移动了字节”的记录被计数并跳过,第四臂只证明惰性(inertness):平台自身阶段未改动的描述符,Go 阶段也不得改动任何字节。旧 schema 的工件只记录字节数不记录文本,门对无法回答的阶段报告 absent 并失败,而不是“拿空对比通过”。
双生产者门:结构化阶段的证明场所
./build/dev-dist.cmd descriptors --two-producer每个dev_dist_plugin_descriptor目标都让两个生产者基于同一参数文件运行,该模式按插件比较二者字节。它不读工件、不组装分发,因此全体插件都被比较——包括 fragment 读取产出文件的那些(工件门只能把它们隔离在外)。按操作系统或架构区分的插件每个布局变体是一对:两个文件按完整路径连接,而非共享名称。2026-08-28 实测173/173 字节一致、0 差异,覆盖全部 163 个捆绑插件。
该门无法捕获错误的标记行——两个生产者读的是同一行。对应的控制手段是双臂全分发快照:组装在不读取的那一臂计算文本。某行在 darwin 变体应写mac/arm64却写了linux/x86_64,会移动快照中三个文件,diff 会点名它们。这也修正了早期“没有臂能找回该覆盖”的结论:缺的是规则内的第二个生产者,而非仍计算文本的 fragment。
该门还有两个双向证明的反向控制:把 include 放在位置 0 而非其自身位置,会使内容顺序断言直接拒绝intellij.database.plugin、构建失败;把嵌入描述符写成普通文本而非 CDATA,会产生 43 个一致、115 个差异——恰好是“不嵌入内容模块的插件”与“至少嵌入一个的插件”的分界。
工件门
./build/dev-dist.cmd descriptors它把dev_dist_plugin_descriptor动作写出的描述符,与开发版组装为同一插件记录的文本比较。2026-08-28 全部 fragment 读取产出文件后,该门报告0 compared,163 个插件全部按名称与原因隔离,其中 161 个属于“fragment 读取了产出描述符”——这正是该门存在的目标状态,它还会点名双生产者模式。门在“声明缺失”(插件回到compared)与“声明过度”(出现工件不认领的目标)时仍失败。仅面向单一操作系统的插件两者都不是:intellij.wsl.remoteSdk有目标但 macOS 工件无记录,门把它报告为“为另一平台构建”,而非过度声明;文件自身的目录深度即可说明计划是否限制了该条目,门因此不维护自己的平台词汇表。
每动作成本:为什么没有 worker
2026-08-28 在 darwin arm64 上、针对//build/dev-dist-descriptors:idea_plugin_descriptors、删除动作输出并绕过缓存(--disk_cache= --noremote_accept_cached)实测:
| 生产者 | 158 个动作,墙钟 | 158 个动作,关键路径 | 单个动作 |
|---|---|---|---|
| 本二进制 | 5.96 s | 0.81 s | 76 至 141 ms |
| JVM 工具 | 29.2 s | 12.61 s | 472 至 764 ms |
产品最大描述符intellij.database.plugin(116 个内容模块、470 KB 输出)为 137 ms,最小为 76 ms。因此成本在沙箱而非进程启动,worker 无法移除沙箱——content-module-packer之所以是 worker,是因为它运行约 2 500 个动作;本规则每插件变体只运行一个动作。自 2026-08-28 起人群为 173 个动作(两个插件按 (os, arch) 各声明一个条目),上述数字取自 158 且未重测;与描述符尺寸无关的每动作成本,也不随人群变化。
总结
plugin-descriptor-patcher是 ADR 0006 的直接产物:把位于构建关键路径上的描述符补丁从 JVM 迁到 Go,同时以“第二生产者 + 双生产者门 + 工件门”把被替换的 JVM 工具保留为参考实现,保证移植的正确性有可执行的字节级证据。四个 Go 包分别对应七阶段管线中的四段可数据化阶段——标记表、字节往返、盖章、结构化包含与嵌入——而两个 lambda 化阶段留在 Kotlin 侧,由生成的计划按名称排除。无论你是想阅读主入口理解完整请求处理,还是深入序列化器对照JDOMUtil的每条字节规则,或是在测试中复现上述三个门,这个模块都是理解 IntelliJ 平台构建可验证性的绝佳切片。
【免费下载链接】intellij-communityIntelliJ IDEA & IntelliJ Platform项目地址: https://gitcode.com/GitHub_Trending/in/intellij-community
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考