跳转至

Workspace Model 深水区

Workspace Model 是 IntelliJ Platform 2024.2 起推荐给第三方插件使用的新项目结构 API。它用统一的 entity storage 表示模块、库、SDK、Facet、内容根、源码根等项目结构元素,并与旧 Project Model 保持互操作。

Project Model、Workspace Model 与 External System 已覆盖总体定位。本章继续展开 entity 声明、读取、修改、事件监听、索引和迁移策略。

核心判断

场景 建议
只想知道某文件属于哪个模块 ProjectFileIndex
读取模块、库、内容根、源码根快照 WorkspaceModel.currentSnapshot
批量修改项目结构 WorkspaceModel.update() + MutableEntityStorage
导入外部系统模型 replaceBySource() 替换同一来源实体
长期缓存 Module/Library 相关信息 EntityPointer 或 symbolic id,不要直接缓存 mutable Project Model 对象
插件自定义 project data 先评估现有 entities、external mappings 和持久化 service;第三方自定义 entity 生成目前不可用

不要为了一个简单查询引入复杂修改流程;也不要在需要批量项目结构变更时继续拼多个旧 ModifiableModel

三层存储模型

类型 作用
VersionedEntityStorage 当前版本化存储入口
ImmutableEntityStorage 当前项目结构的不可变快照
MutableEntityStorage 用于构建或修改 entity graph 的可变副本

典型读:

val storage: ImmutableEntityStorage =
    WorkspaceModel.getInstance(project).currentSnapshot

典型写:

WorkspaceModel.getInstance(project).update("Update project structure") { builder ->
    // mutate builder
}

快照不可变。你拿到 currentSnapshot 后,即使项目结构之后变化,这个快照也不会被原地修改。这让遍历和索引构建更安全,但也意味着长时间持有快照可能看到旧数据。

Entity 与 EntitySource

Workspace Model 中每个项目结构元素都是 WorkspaceEntity

interface ExampleEntity : WorkspaceEntity {
    val name: String
    val root: VirtualFileUrl
}

官方目前说明:自定义 entity 的生成能力尚未对第三方插件开放,生成实现只在 IntelliJ IDEA 项目自身启用。因此第三方插件通常应:

  • 使用平台已提供的 entities,例如 ModuleEntityContentRootEntitySourceRootEntityLibraryEntity
  • 用 external mappings 关联插件自己的外部数据。
  • 对插件私有配置继续用 PersistentStateComponent
  • 仅在官方开放稳定生成链路后,再把自定义 project structure 数据建成 Workspace Entity。

每个 entity 都有 entitySource,表示实体从哪里来:

  • .idea / .iml 配置文件。
  • 外部系统导入结果,例如 Gradle/Maven。
  • 自动生成的模型。
  • 其他可序列化来源标记。

entitySourcereplaceBySource() 非常关键。外部系统同步时,通常只替换自己来源的那部分实体,不碰用户手工创建或其他系统创建的实体。

Entity 属性类型

Entity 属性类型不是任意 Kotlin 类型。官方支持的类型包括:

  • primitive,例如 IntBoolean
  • String
  • enum
  • 不可变 data class,其中属性也必须是支持类型。
  • sealed class,且实现为不可变 data class 或 object。
  • 可空变体。
  • ListSetMap,元素也必须是支持类型。
  • VirtualFileUrl
  • EntitySource
  • SymbolicEntityId
  • 对其他 entity 的引用。

属性分三类:

类型 规则
Mandatory 没有默认值,创建 entity 时必须传入
Optional @Default getter 或 nullable 默认 null
Computable 有 getter 但没有 @Default,不保存到 storage,每次访问计算

不要把 ProjectModuleVirtualFile、service、可变集合或平台对象塞进 entity 属性。它们不可序列化,也会破坏 Workspace Model 的跨进程和快照语义。

VirtualFileUrl

Workspace Model entity 中的文件或目录引用应使用 VirtualFileUrl,不要用普通 String 保存路径。

创建:

val workspaceModel = WorkspaceModel.getInstance(project)
val url = workspaceModel
    .getVirtualFileUrlManager()
    .getOrCreateFromUrl("file:///path/to/project/src")

查询引用该 URL 的 entities:

val storage = workspaceModel.currentSnapshot
val entities = storage.getVirtualFileUrlIndex()
    .findEntitiesByUrl(url)

原因:

  • 内存占用更低。
  • 能高效定位 VirtualFile
  • 可通过索引反查引用。
  • 部分 entity 类型支持移动/重命名后的自动更新。

SymbolicEntityId

SymbolicEntityId 是实体的业务键,通常来自用户可见名称或配置文件,而不是随机生成 ID。

例子:

val moduleId = ModuleId("core")
val moduleEntity = workspaceModel.currentSnapshot.resolve(moduleId)

规则:

  • 同一 storage 中不能存在两个相同 SymbolicEntityId 的实体。
  • 如果 entity 的 symbolic id 改变,引用旧 ID 的属性会自动更新到新 ID。
  • symbolic reference 是软引用,目标实体可能不存在。
  • 可以用 EntityStorage.referrers() 快速查找引用某个 symbolic id 的实体。

适合用 symbolic id 的场景:

  • 模块依赖另一个模块。
  • Facet 依附某个模块。
  • 库依赖、SDK 引用等按名称解析的结构。

如果关系必须强一致,应使用 parent-child hard link。

Parent-Child 关系

Workspace Model entity graph 是有向无环图。parent-child 关系用于表达强所有权。

例子:

val contentRoot = ContentRootEntity(url, emptyList(), entitySource) {
    this.module = moduleEntity
}

关系维护规则:

  • 删除 parent 会级联删除 non-null parent 的 child。
  • 删除 child 会更新 parent 中的引用。
  • 从 parent 的 children 列表中移除 child,如果 child 的 parent 是 non-null,child 会被删除。
  • 如果 child 的 parent 可空,则可能变成 detached child。

硬关系适合模块包含内容根、内容根包含源码根等结构。不要把松散关联强行建成 parent-child,否则删除一个实体会连带删除不该删除的数据。

读取 Entity

获取所有某类实体:

val storage = WorkspaceModel.getInstance(project).currentSnapshot
val modules = storage.entities<ModuleEntity>().toList()

按 ID 查询:

val module = storage.resolve(ModuleId("core"))

按来源查询:

storage.entitiesBySource { it is GradleEntitySource }
    .forEach { entity ->
        // imported by Gradle
    }

按路径查询:

val moduleEntities = storage.getVirtualFileUrlIndex()
    .findEntitiesByUrl(sourceRootUrl)
    .mapNotNull {
        when (it) {
            is SourceRootEntity -> it.contentRoot.module
            is ContentRootEntity -> it.module
            else -> null
        }
    }

读操作建议:

  • 先拿一个 snapshot,再在 snapshot 内完成遍历。
  • 不要在遍历过程中反复读取 currentSnapshot
  • 如果读取 PSI/VFS/Project Model 混合数据,仍遵守 read action 规则。
  • 长期缓存时保存 EntityPointerSymbolicEntityId,不要保存旧 snapshot 里的 entity 实例。

添加 Entity

添加 module entity 示例:

val workspaceModel = WorkspaceModel.getInstance(project)
val virtualFileUrlManager = workspaceModel.getVirtualFileUrlManager()
val baseDir = virtualFileUrlManager.getOrCreateFromUrl("file:///workspace/core")

val entitySource = LegacyBridgeJpsEntitySourceFactory
    .getInstance(project)
    .createEntitySourceForModule(baseDir, null)

WorkspaceModel.getInstance(project).update("Add module") { builder ->
    val module = ModuleEntity(
        name = "core",
        dependencies = emptyList(),
        entitySource = entitySource,
    )
    builder.addEntity(module)
}

官方 usage 示例中提到,LegacyBridgeJpsEntitySourceFactory 是 internal API,但在插件中被例外允许使用。使用时要把版本验证纳入测试矩阵。

添加内容根和源码根:

workspaceModel.update("Add source root") { builder ->
    val contentRoot = ContentRootEntity(contentRootUrl, emptyList(), module.entitySource) {
        sourceRoots = listOf(
            SourceRootEntity(
                url = sourceRootUrl,
                rootType = SourceRootTypeId("java-source"),
                entitySource = module.entitySource,
            )
        )
    }

    builder.modifyModuleEntity(module) {
        contentRoots = contentRoots + contentRoot
    }
}

添加整棵 entity tree 时,只要把 root entity 加进 storage,child entities 会自动加入。

修改和删除 Entity

修改 entity 要先在 MutableEntityStorage 中解析目标:

WorkspaceModel.getInstance(project).update("Rename module") { builder ->
    val module = ModuleId("old-name").resolve(builder) ?: return@update
    builder.modifyModuleEntity(module) {
        name = "new-name"
    }
}

删除:

workspaceModel.update("Remove module") { builder ->
    val module = ModuleId("obsolete").resolve(builder) ?: return@update
    builder.removeEntity(module)
}

修改注意:

  • MutableEntityStorage 不是线程安全对象。
  • builder 内不要启动后台任务或跨线程使用。
  • 写操作应在平台允许的写上下文中执行。
  • 批量修改尽量一次 update,减少事件风暴。
  • 如果从 snapshot 拿到 entity,再进入 update,要确认 entity 仍能在 builder 中解析。

applyChangesFrom()

applyChangesFrom() 把一个 mutable storage 中记录的新增、修改、删除应用到另一个 mutable storage。

适合:

  • 并行解析多个 .iml / 外部配置文件,每个文件生成一个局部 storage,最后合并。
  • Project Structure Dialog 中先累积用户修改,点击 Apply 后一次提交。
  • 复杂导入流程先离线构建结果,再合并到主 builder。

伪代码:

val imported = MutableEntityStorage.create()
loadExternalModel(imported)

workspaceModel.update("Apply imported model") { builder ->
    builder.applyChangesFrom(imported)
}

如果只是修改少量已知实体,直接在 update builder 中修改即可,不需要额外 storage。

replaceBySource()

replaceBySource() 是外部系统同步的关键能力:按 entitySource 谓词替换 storage 的一部分,并尽量最小化实体变化。

场景:Gradle/Maven 配置文件变化后,重新解析外部模型,只替换来自该外部系统的 entities,不影响用户手工配置和其他系统实体。

val imported = MutableEntityStorage.create()
loadGradleModel(imported)

workspaceModel.update("Sync Gradle model") { builder ->
    builder.replaceBySource(
        { source -> source is GradleEntitySource },
        imported,
    )
}

使用原则:

  • entitySource 设计必须稳定、可序列化、可过滤。
  • 谓词范围越精确越好。
  • 不要用 replaceBySource() 替换所有实体。
  • 导入前先在临时 storage 中构建完整结果。
  • 失败时不要提交半成品 storage。

Event Listening

Workspace Model 事件通过 WorkspaceModel.eventLog 暴露为 Kotlin Flow。

事件顺序固定:

  1. EntityChange.Removed
  2. EntityChange.Replaced
  3. EntityChange.Added

订阅示例:

@Service(Service.Level.PROJECT)
class ExampleWorkspaceIndex(
    private val project: Project,
    private val cs: CoroutineScope,
) {
    init {
        cs.launch(CoroutineName("Example workspace model listener")) {
            val workspaceModel = WorkspaceModel.getInstance(project)
            workspaceModel.eventLog.collectIndexed { index, event ->
                if (index == 0) {
                    rebuild(event.storageAfter)
                } else {
                    update(event)
                }
            }
        }
    }
}

注意:

  • 第一个事件携带初始 storage,适合构建初始索引。
  • 如果在 project opened 后才订阅,可能错过最早几次更新,这是预期行为。
  • 事件是异步的,不能假设基于 WorkspaceFileIndex 或旧 Project Model 的索引已经同步更新。
  • 对缓存做增量更新时,要保存最后已处理的 storage 版本或用 event 中的 before/after 信息。

EntityPointer 与长期缓存

不要把 ModuleLibrary 或 snapshot 中的 entity 实例长期作为 map key。它们有可变性、生命周期和内存泄漏问题。

使用 EntityPointer

val pointer = moduleEntity.createPointer()

val currentEntity = pointer.resolve(
    WorkspaceModel.getInstance(project).currentSnapshot,
)

限制:

  • pointer 不包含原 storage,不会直接造成 storage 泄漏。
  • storage 修改后,pointer 通常能解析到同一实体。
  • 如果 entity 被 replaceBySource() 删除或 ID 复用,pointer 可能解析为 null 或不同实体。
  • 对严肃缓存,需要配合 eventLog 处理删除和替换。

External Mapping

如果需要把外部数据和 Workspace Entity 关联,可以用 external mapping:

data class ExampleData(val value: String)

val key = ExternalMappingKey.create<ExampleData>("com.example.workspace.data")

workspaceModel.update("Attach data") { builder ->
    val mapping = builder.getMutableExternalMapping(key)
    val module = ModuleId("core").resolve(builder) ?: return@update
    mapping.addMapping(module, ExampleData("metadata"))
}

读取:

val storage = workspaceModel.currentSnapshot
val mapping = storage.getExternalMapping(key)
val data = mapping.getDataByEntity(moduleEntity)

External mapping 适合辅助索引或导入过程中的关联数据,不适合替代插件配置持久化。

线程、写动作与性能

Workspace Model 写操作改变项目结构,必须遵守平台写入规则。推荐模式:

  1. 后台读取外部配置或计算变更。
  2. 构建临时 MutableEntityStorage 或 DTO。
  3. 回到合适写上下文。
  4. 一次 WorkspaceModel.update() 提交。

不要:

  • update() block 里做 IO、网络、外部进程。
  • 持有 builder 跨线程。
  • 每个文件变更都提交一次 update。
  • 在 listener 中同步触发复杂二次修改造成循环。

大型导入要配合进度和取消。Workspace Model 负责项目结构一致性,不负责替代后台任务管理。

与 Project Model 互操作

新 Project Model 实现已经把数据存进 Workspace Model 对应实体中。常见映射:

Project Model Workspace Model
Module ModuleEntity
ContentEntry ContentRootEntity
SourceFolder SourceRootEntity
Sdk SdkEntity
Library LibraryEntity
Facet FacetEntity

迁移建议:

  • 读文件归属:继续用 ProjectFileIndex,它更直接。
  • 读结构快照:用 currentSnapshot
  • 批量修改结构:用 Workspace Model。
  • 调已有产品 API:按对方 API 要求传 Module / Library,必要时桥接。
  • 兼容旧 IDE:保留 Project Model 分支或降低功能。

QA 清单

  • 目标平台低于 2024.2 时有兼容策略。
  • 不访问 Workspace Model 内部 impl,除官方 usage 明确例外允许的 API 外。
  • 读取时使用单个 immutable snapshot。
  • 写入时使用一次 WorkspaceModel.update() 批量提交。
  • entitySource 设计稳定且可序列化。
  • 外部系统同步使用 replaceBySource() 精确替换。
  • 长期缓存使用 EntityPointer 或 symbolic id,并订阅事件修正。
  • 不把 ProjectModuleVirtualFile 等平台对象放进 entity 属性。
  • eventLog listener 使用 service scope,项目关闭时自动取消。
  • 大项目导入有进度、取消和测试覆盖。

参考来源