IntelliJ Platform 模块拆分实战:将可选依赖抽取为独立 Content Module 的完整指南 📅 发布时间:2026/9/17 6:09:13 👁 浏览次数: IntelliJ Platform 模块拆分实战将可选依赖抽取为独立 Content Module 的完整指南【免费下载链接】intellij-communityIntelliJ IDEA IntelliJ Platform项目地址: https://gitcode.com/GitHub_Trending/in/intellij-community本文基于 intellij-community 仓库的 Agent 技能文档 .agents/skills/extract-module/SKILL.md完整讲解如何把某个插件模块的依赖可选化——通过将该依赖及其相关代码迁移到一个新的 content module内容模块中实现。读完后你将掌握两种拆分模式整体搬迁与扩展点解耦、.iml/模块描述符 XML 的编写规范、Kotlin/Bazel 依赖陷阱的排查方法以及验证拆分成败所需的三条必跑命令与常见错误对照表。背景与目标让宿主模块缺了这个依赖也能跑在 IntelliJ Platform 的 monorepo 中一个插件模块往往会硬依赖多个其他模块。当这种依赖在语义上是可选的例如某语言后端对 regexp、CSS、拼写检查的支持把它直接写死在宿主模块里会污染依赖图并让 freemium 分层、打包校验变得复杂。抽取为独立 content module的目标可以凝练为一句话宿主模块在新模块缺席时照常工作在新模块存在时行为与原来完全一致。文档给出的标准做法是把依赖 X 相关的代码或调用方式迁移到plugins/plugin-name/module-dir/下的新模块宿主模块只通过接口扩展点与它交互编译期对 X 的任何直接引用被彻底清除。选择拆分模式Pattern A 还是 Pattern B在动手之前先判断依赖 X 在宿主模块中的渗透程度这决定了两条完全不同的路径Pattern A — Simple move整体搬迁所有触及 X 的代码都集中在少数几个文件里且宿主模块内部没有其他调用方。直接把这几个文件整体搬进新模块即可。Pattern B — Extension Point扩展点解耦宿主模块的核心文件里散落着对 X 的引用。此时需要在宿主模块中引入一个EP 接口接口内不允许 import X在新模块中实现它并把宿主中对 X 的直接调用替换为空安全的静态辅助方法EP 不存在时返回 no-op 默认值。文档中 JavaScript 插件的intellij.javascript.backend.xml案例同时使用了两种模式A 搬 JSX/HTML/injection 文件B 为约 60 个核心文件里散落的instanceof XmlTag/XmlElement判断引入JsXmlContextHelperEP是两者的典型组合。第 1 步创建模块目录骨架新模块的目录结构必须严格遵循以下布局plugins/plugin-name/module-dir/ resources/ intellij.module-name.xml ← 模块描述符 src/ com/intellij/.../ ← 源码目录必须与包名一致 intellij.module-name.iml ← 模块定义文件三条硬性约定源码必须放在与其包声明一致的目录里例如包com.intellij.foo.bar的类放在src/com/intellij/foo/bar/MyClass.kt。永远不要在sourceFolder上使用packagePrefix要用真实目录结构表达包名。.iml文件以规范化形式序列化——无尾部换行、不重排格式、不调整条目顺序。第 2 步编写.iml模块定义.iml的模板如下来自 SKILL.md 的官方模板?xml version1.0 encodingUTF-8? module typeJAVA_MODULE version4 component nameNewModuleRootManager inherit-compiler-outputtrue exclude-output / content urlfile://$MODULE_DIR$ sourceFolder urlfile://$MODULE_DIR$/resources typejava-resource / sourceFolder urlfile://$MODULE_DIR$/src isTestSourcefalse / /content orderEntry typeinheritedJdk / orderEntry typesourceFolder forTestsfalse / orderEntry typelibrary namejetbrains-annotations levelproject / orderEntry typemodule module-nameintellij.host-module / !-- add other required modules -- /component /moduleKotlin 模块的致命细节kotlin-stdlib必须在 Bazel 编译类路径上任何包含 Kotlin 代码的新模块都必须在 IML 中把kotlin-stdlib声明为直接库依赖orderEntry typelibrary namekotlin-stdlib levelproject /文档特别警告了一个极具迷惑性的编译错误Cannot access built-in declaration kotlin.Any. Ensure that you have a dependency on the Kotlin standard library.这个报错看起来只是缺了kotlin.Any一个类型实际含义是整个 stdlib 都不在类路径上几乎所有 Kotlin 内建类型、JvmStatic、Throws、checkNotNull、::class.java等会同时全部失败。Bazel strict-deps 下的类—模块映射表Bazel 的 strict-deps 要求每一个用到的类都必须位于某个直接 IML 依赖模块中。下面这张表是文档沉淀的血泪经验——有些类所在的模块与其包名给人的直觉完全不同类包IML 模块Bazel targetExecutionException、GeneralCommandLine、KillableColoredProcessHandler、OSProcessHandler、ScriptRunnerUtil、Url、NetUtilscom.intellij.execution.*、com.intellij.util.*intellij.platform.ide.util.iocommunity//platform/platform-util-io:ide-util-ioAsyncPromise、Promiseorg.jetbrains.concurrencyintellij.platform.concurrencycommunity//platform/util/concurrencyAppExecutorUtil、ProcessAdapter、ProcessEvent、ProcessOutputTypes、ParametersListUtil、FileUtil、StringUtilcom.intellij.util.*、com.intellij.openapi.util.*intellij.platform.utilcommunity//platform/utilEditorcom.intellij.openapi.editorintellij.platform.editor.uicommunity//platform/editor-ui-apiMultipleLangCommentProvidercom.intellij.psi.templateLanguagesintellij.platform.lang.implcommunity//platform/lang-impl两个易踩的点AsyncPromise/Promise的 IML 模块名是intellij.platform.concurrency不是intellij.platform.util.concurrency尽管源码位于platform/util/concurrency/目录之下。如果新模块调用的方法的返回类型来自第三个模块例如某方法返回ImmutableListString那么第三个模块intellij.libraries.guava也必须是直接 IML 依赖——Kotlin 类型检查器在编译期需要验证返回类型。第 3 步编写模块描述符 XMLresources/intellij.module-name.xmlvisibility决定跨插件可见性如果新模块中的类会被另一个插件注意不是同一插件内的另一个模块直接使用必须在idea-plugin根节点上设置visibilityvisibilityinternal—— 使用方是本 monorepo 内的另一个插件独立的plugin id...比如从那里子类化或直接调用你的类。这是最常见的情况。visibilitypublic—— 该类是面向外部第三方插件的 API 的一部分。idea-plugin visibilityinternal ... /idea-plugin缺少visibility时即使对方声明了模块依赖也加载不到你的类。dependenciesregion手工依赖必须写在生成区域内描述符生成器管理着一个dependenciesregion。对于生成器不会自动产出的依赖例如宿主模块被移除掉的某个插件依赖必须在同一个dependencies标签内、!-- region --标记之前手工声明idea-plugin dependencies plugin idcom.example.some-plugin/ !-- 手工声明生成器不产出 -- !-- region Generated dependencies - run Generate Product Layouts to regenerate -- module nameintellij.host-module/ !-- ... -- !-- endregion -- /dependencies extensions defaultExtensionNscom.intellij !-- 在此注册扩展 -- /extensions /idea-plugin绝对不要在 region 之外再创建第二个独立的dependencies块——这会让文件进入 out of sync 状态直接导致AllProductsPackagingTest#suiteValidations失败。第 4 步包名与源文件的硬性约定新代码一律用 Kotlin 编写但如果只是把已有文件原样搬迁保持它原来的语言Java 就留 Java。源文件必须位于与包声明一致的目录包com.intellij.foo.bar→src/com/intellij/foo/bar/MyClass.kt。包名必须遵循com.module-name约定模块intellij.foo.bar→ 包com.intellij.foo.bar。IntelliJProjectPackageNamesTest会强制执行这条规则不要为 new modules 往non-standard-root-packages.txt添加例外。保留 freemium 可用性宿主模块与新 content module 在 IDEA Free 模式下的可见性必须与原模块一致——原来免费可见则两者都保留免费可见原来不免费则两者都不免费。PluginsAvailableInIdeaFreeModeTest负责校验。第 5 步Pattern B在宿主模块声明扩展点在宿主模块的 XML 中用qualifiedName声明 EPextensionPoints extensionPoint qualifiedNamecom.intellij.language.epName interfacecom.intellij.language.feature.MyFeatureHelper dynamictrue/ /extensionPointsqualifiedName的命名格式com.intellij.language/framework小写.epNameLikeThisOne。EP 接口写在宿主模块不允许 import X并提供JvmStaticcompanion 辅助方法在 EP 缺席时优雅返回 no-op 默认值interface MyFeatureHelper { fun doSomething(element: PsiElement): Boolean companion object { JvmField val EP ExtensionPointName.createMyFeatureHelper(com.intellij.language.epName) // 一个 EP 可能注册多个扩展——必须遍历全部而不是只取第一个。 JvmStatic fun checkSomething(element: PsiElement?): Boolean element ! null EP.extensionList.any { it.doSomething(element) } } }新模块的 XML 中注册实现extensions defaultExtensionNscom.intellij language.epName implementationcom.intellij.language.feature.MyFeatureHelperImpl/ /extensions进阶技巧一多方法 EP 聚合lifecycle 场景当被拆分的库的若干操作构成一个生命周期先捕获状态、后续再使用时应把所有相关操作捆绑进同一个 EP而不是每个方法开一个 EP——这既避免了 EP 注册爆炸也保持了实现的内聚性。进阶技巧二opaque stateAny?不透明状态生命周期 EP 的方法之间往往需要传递带类型的状态例如第 1 步捕获ListCssSelectorSuffix第 2 步消费它但宿主模块无法 import 那个类型。标准做法EP 接口统一用Any?作为状态类型实现在内部用Suppress(UNCHECKED_CAST)转回真实类型// 宿主模块不含任何 X import interface MyLifecycleHelper { fun captureState(file: PsiFile): Any? // 返回 X 类型数据对宿主保持不透明 fun applyState(file: PsiFile, state: Any?) // 收回来实现在内部做转换 companion object { val EP ExtensionPointName.createMyLifecycleHelper(com.intellij.language.myLifecycleHelper) fun captureState(file: PsiFile): Any? EP.extensionList.firstOrNull()?.captureState(file) fun applyState(file: PsiFile, state: Any?) EP.extensionList.firstOrNull()?.applyState(file, state) } } // 新模块例如 CSS 模块 internal class MyLifecycleHelperImpl : MyLifecycleHelper { override fun captureState(file: PsiFile): Any? getThings(file) // 实际返回 ListXThing override fun applyState(file: PsiFile, state: Any?) { Suppress(UNCHECKED_CAST) val things state as? ListXThing ?: emptyList() // 使用 things…… } }第 6 步把新模块注册进插件注册分两部分1.plugin/resources/META-INF/plugin.xml— 添加一个modulecontent 条目。这个条目本身就是完整的打包声明开发分发dev distribution会从它的加载规则和它自己的描述符推导出成员 jar因此任何打包文件都不需要新增一行。运行./build/jpsModelToBazel.cmd重新生成永远不要手工编辑dev-dist.yaml。仓库中可以看到对应的社区版脚本 build/jpsModelToBazelCommunityOnly.cmd 即承担这一 JPS→Bazel 模型转换职责。2. 项目模块文件— 用仓库提供的辅助脚本注册新模块它会自动更新.idea/modules.xmlcommunity 模块还会更新community/.idea/modules.xml、保持规范条目顺序、并去掉.iml的尾部换行bun build/jps-module.mjs register plugins/plugin-name/module-dir/intellij.module-name.iml --fix-iml-eof这个脚本即 build/jps-module.mjs配套的 build/jps-module.test.mjs 也验证了其行为。手动往modules.xml追加条目是错误的做法——会导致嘈杂的项目文件 diff 和项目结构漂移条目顺序以.iml文件基名为准。第 7 步修复下游消费者从宿主模块移除一个依赖可能击碎那些靠传递依赖间接获得它的其他模块。AllProductsPackagingTest#targetValidations会告诉你是哪些模块。修复方式在受影响插件的 XML 中于手工区!-- region --标记之前显式补上依赖dependencies module nameintellij.some.formerly.transitive.module/ !-- region Generated dependencies ... -- ... !-- endregion -- /dependencies还要检查其他插件如果搬走的代码是别的插件调用的父类或工具类那个插件会直接编译失败。对每个这样的插件在其 plugin XML 中添加module nameintellij.new.module/在其.iml中添加orderEntry typemodule module-nameintellij.new.module /确保新模块的 XML 设置了visibilitypublic参见第 3 步。Kotlinopen跨模块子类化的隐形前提Kotlin 类默认final。如果被搬走的类被其他模块/插件子类化或它的内部类在别处被匿名子类化那么外层类和相关的内部类都必须标openopen class MyStrategy : BaseStrategy() { // ... open class MyTokenizer : BaseTokenizer() { protected open fun shouldSkip(element: MyElement): Boolean { ... } } }漏掉open的表现是下游插件报cannot inherit from final class。第 8 步git add新文件新模块目录下的文件都是 untracked 状态跑测试前必须显式添加否则构建和测试根本看不到这些源文件git add plugins/plugin-name/new-module/必跑命令与三条必过测试任何.iml变更后./build/jpsModelToBazel.cmd任何.iml、plugin XML 或模块结构变更后./bazel.cmd run //platform/buildScripts:plugin-model-tool期望输出是✓ All files unchanged。如果文件发生了变更必须仔细检查——生成器可能删掉了你写错的依赖也可能补上了你漏写的依赖。bazel.cmd入口即仓库根目录的 bazel.cmd。三条必过测试# 验证打包运行时依赖可用、生成的 XML 保持同步 ./bazel.cmd test //build:all-products-packaging_test # 验证包命名com.module-name 约定 ./tests.cmd --module intellij.projectStructureTests \ --test com.intellij.ideaProjectStructure.fast.IntelliJProjectPackageNamesTest # 验证 IDEA Free 模式下的插件可用性 ./tests.cmd --module intellij.projectStructureTests \ --test com.intellij.ideaProjectStructure.fast.PluginsAvailableInIdeaFreeModeTest三条全部通过才算完成。关键运行纪律三条测试必须一次只跑一条。tests.cmd启动时会按名字杀掉残留进程日志中可见Killing process containing subpath ide-tests并发运行会把自己的另一个还在跑的实例杀死表现出来则是一个看似无关的失败典型如AllProductsPackagingTest#build。常见陷阱速查表陷阱症状修复.iml里用了packagePrefixIntelliJProjectPackageNamesTest找到错误的根包删掉packagePrefix改用匹配的目录结构包名与模块名不匹配IntelliJProjectPackageNamesTest失败重命名为com.module-name并移动文件EP 用name而非qualifiedName注册EP 找不到改用qualifiedNamecom.intellij.language.epName手工dependencies写成独立的第二个标签AllProductsPackagingTest#suiteValidations报 Generated file is out of sync合并为一个dependencies块手工条目放在!-- region --之前直接往modules.xml追加注册嘈杂的项目文件 diff / 结构漂移用bun build/jps-module.mjs register path-to-iml --fix-iml-eof新文件没git add构建/测试看不到新源码git add新模块目录漏跑plugin-model-tool生成的 XML 依赖过期或缺失运行./bazel.cmd run //platform/buildScripts:plugin-model-tool新源文件建成了 Java风格违规新代码一律.kt移除传递依赖后调用方编译失败AllProductsPackagingTest#targetValidations失败在受影响模块的 plugin XML 中显式加module name.../跨插件子类化被搬走的类另一插件报cannot inherit from final class类及可子类化的内部类标open新模块 XML 设visibilitypublic对方 IML 与 plugin.xml 都补模块依赖文件搬了但package声明没改IntelliJProjectPackageNamesTest报 packages [com.old.pkg] are found in the module改package声明并把文件物理移到匹配目录文件与目录只搬了一半IDE 混乱、同一文件在两处被搜到文件与package声明必须一起移动Java→Kotlin 转换后空安全标注不匹配override overrides nothing基类是平台类型无注解 Java时?与非空都可覆写但子类用了?基类必须也是vallambda 里用returnlabelUnresolved label myLabel标签只能在调用点生效改用.let { ... }链式Java 侧访问 Kotlin 接口常量cannot find symbol CONSTANT_NAME用显式类限定MyInterface.CONSTANT_NAMEKotlinfun getXxx()期望被 Java 当属性Unresolved reference xxx显式调用element.getXxx()object : JavaInterface()带括号This type does not have a constructor接口没有构造器去掉()Java import Kotlin 顶层函数cannot find symbol myFunctionJava 侧类名是MyFileKtimport static com.pkg.MyFileKt.myFunction移除依赖后的超类型级联Cannot access com.X.BaseClass which is a supertype of SubClass——即使从未直接 import 过BaseClassKotlin 需要每个所用类型的完整超类型链。使用依赖 Y 的SubClass而其父类BaseClass在依赖 X 中时移除 X 就断编译。修法把SubClass的用法整体搬进 EP 实现EP 接口只暴露非 X 返回类型如Language而非PostCssLanguage移除依赖后类层级访问失败cannot access BaseClass: class file not found平台类型的子类可能传递性地需要该平台依赖即使没有直接 import 也要保留大规模搬文件后大范围下游报错多个不相干模块编译失败搬 10 个以上文件后立即构建//plugins/... //contrib/...一次性找出所有受损消费方两条tests.cmd同时跑单独能过的测试失败多为AllProductsPackagingTest#build日志有Killing process containing subpath ide-tests三条必过测试一次只跑一条其中超类型级联supertype cascade是最隐蔽的一类编译器报错指向的类你从未 import 过根因是 Kotlin 需要解析每个所用类型沿继承链的全部父类。Vue 插件案例中PostCssLanguage extends CssLanguageProperties就触发了这个问题——即使删掉了所有直接引用编译器仍需要intellij.css.common最终方案是把PostCssLanguage用法全部收进 EP 实现跨边界只返回Language。真实案例JavaScript 与 Vue 插件中的四次抽取文档在 SKILL.md 末尾给出了仓库内的落地案例可作为模式选型的参照系JavaScript 插件intellij.javascript.regexpPattern A——把JSRegexpInjector、JSRegexpHost、JSRegExpModifierProvider从javascript-backend整体搬出使 regexp 依赖变为可选。intellij.javascript.backend.cssPattern B——在javascript-backend引入JsCssIntegrationHelperEP 并由新模块实现同时搬走JavaScriptCssUsagesProvider、JQueryCssElementDescriptorProvider、JQueryCssInspectionSuppressor。下游修复涉及javascript-ultimate、jsf-core、webpack。intellij.javascript.backend.spellcheckerPattern A——拼写检查相关文件整体移出javascript-backend。因 CoffeeScript 插件独立插件子类化了JSSpellcheckingStrategy需要visibilitypublic且该类和其内部 tokenizer 在 Java→Kotlin 转换后都要标open。javascript-grazie还需要一条写在生成区之外的手工依赖。intellij.javascript.backend.xmlPattern A B——迄今最大的一次抽取约 40 个文件。A 模式整体搬走 JSX/HTML/injection 文件B 模式引入JsXmlContextHelperEP接口 静态分发 companion处理约 60 个核心文件里散落的instanceof XmlTag/XmlElement判断。需要visibilitypublic下游修复波及javascript-ultimate、jsf-core、webpack、flex、vuejs、svelte、react等插件。注意并非所有 XML IML 依赖都能从javascript-backend移除——部分因间接类层级使用而必须保留。Vue 插件intellij.vuejs.backend.cssPattern B3 个 EP——把intellij.vuejs.backend对 CSS 插件的依赖清零引入三个 EPVueCssLanguageProvider— 暴露getCssLanguage()、getDefaultStyleLanguage()、getStyleCommenter()。实现类VueCssLanguageProviderImpl使用CSSLanguage.INSTANCE、PostCssLanguage.INSTANCE与PostCssCommentProvider。后两个方法存在的意义正是超类型级联PostCssLanguage extends CssLanguageProperties即使删掉直接引用编译器仍需要intellij.css.common修法是全部PostCssLanguage用法收进 EP 实现跨边界只返回Language。VueCssExtractHelper— 多方法生命周期 EP用不透明Any?状态captureUnusedStyles(file): Any?实际返回ListCssSelectorSuffixoptimizeStyles(file, state: Any?)在实现内部以Suppress(UNCHECKED_CAST)转回。这让CssSelectorSuffix来自intellij.css.analysis完全封闭在 CSS 模块内部。VueCssBindingHelper— 单方法 EP包装CssClassInJSLiteralOrIdentifierReferenceProvider.getClassesFromEmbeddedContent()借此把intellij.javascript.web.css依赖从宿主模块中移除。收尾核对清单一次模块抽取在合并前至少过一遍以下检查新模块目录结构、.iml无packagePrefix、规范序列化、Kotlin 模块含kotlin-stdlib正确模块描述符 XML 的visibility与dependenciesregion 符合约定单一dependencies块手工条目在 region 内、标记之前;包名符合com.module-name约定freemium 可见性与原模块一致Pattern B 的 EP 用qualifiedName声明companion 辅助方法在 EP 缺席时 no-op 且遍历全部扩展plugin.xml已加module条目bun build/jps-module.mjs register ... --fix-iml-eof已执行dev-dist.yaml由工具重新生成而非手改下游传递依赖与跨插件子类化问题全部修复open、visibilitypublic、显式模块依赖新文件已git add./build/jpsModelToBazel.cmd与./bazel.cmd run //platform/buildScripts:plugin-model-tool期望✓ All files unchanged已运行三条必过测试打包校验、包名约定、Free 模式可用性逐条运行且全部通过。以上流程全部源自 extract-module 技能文档工具脚本对应仓库中的 build/jps-module.mjs、build/jpsModelToBazelCommunityOnly.cmd 与 bazel.cmd按此执行可以在不破坏现有打包校验的前提下把任意可选依赖安全地隔离进独立 content module。【免费下载链接】intellij-communityIntelliJ IDEA IntelliJ Platform项目地址: https://gitcode.com/GitHub_Trending/in/intellij-community创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考