Ohhnews

分类导航

$ cd ..
Jetbrains Blog原文

开发容器发展故事:为插件作者介绍 EelApi

#开发容器#eelapi#intellij平台#wsl#插件开发

现代开发已经显著改变了旧的IDE范式:现在,项目不仅可能不与IDE实例位于同一台物理机或远程机器上,甚至可能两者共享同一主机,但彼此隔离在不同的环境中。如果你是插件作者,实际影响可能一点也不抽象。你的插件下载一个CLI工具,启动它,向其传递项目路径,从中接收环境变量,或者打开一个到它启动的某个服务的TCP连接。它在本地工作。然后用户在WSL或Dev Container中打开同一个项目,突然间“本地路径”、“当前操作系统”、“localhost”和“启动进程”这些词都需要更精确的界定。

首先,提供一些背景。EelApi在了解其存在理由后会更容易使用。如果你已经在让插件适配WSL或Dev Containers的过程中,并想要API细节,请直接跳到本指南的从项目开始部分。

为什么存在这个API

最初的IDE模型简单得美妙。IDE、项目文件、SDK、工具、环境变量和进程都位于用户的机器上。一个Path指向IDE可以读取的文件。SystemInfo描述你的工具运行所在的操作系统。ProcessBuilder在正确的位置启动进程,因为只有一个“地方”。

后来项目变得足够庞大,开发环境也变得足够专门化,以至于有时项目的正确机器完全是一台不同的机器:办公室的工作站、云中的虚拟机,或者团队准备的主机。对于这种情况,自然的解决方案是将IDE的智能部分靠近项目:在文件、SDK和工具附近运行完整的IDE后端,并从轻量级前端连接它。

但WSL2和容器带来了另一种问题。

WSL2将Linux项目环境带到了与IDE相同的物理机器上,但将其与IDE运行的Windows进程隔离开来。项目文件可以存在于Linux文件系统中,工具必须以Linux进程运行,路径字符串必须在Linux端有意义。

Dev Containers进一步扩展了这种形态。容器足够隔离,拥有自己的文件系统、二进制文件、环境变量、进程和网络命名空间。同时,它又足够靠近宿主机IDE,以至于在其中启动完整的IDE后端可能比任务所需的机制更复杂。

这就是我们开始探索的差距:IDE能否保持本地化,而项目侧的操作在项目实际所在的环境中执行?

对这个挑战的第一个回应不是一个公共API,而是一个代理。

目标环境中的一个小型IntelliJ平台代理(ijent)为IDE提供了一个受控通道,用于访问另一侧的文件系统、进程、端口和平台信息。我们尝试了传输和RPC形式,一个内部以Kotlin优先的API开始围绕需求形成:挂起操作、结构化生命周期、项目感知作用域、流式数据以及足够处理真实WSL和容器项目的文件系统语义。

那个API成为了EelApi

EelApi是IntelliJ平台用于与执行环境(本机、WSL发行版、Docker容器或Dev Container)交互的API。

NIO的Path可以被引导进入另一个环境。有时这就足够了。但进程执行、操作系统检测、目标端路径字符串、环境变量、网络和优化的文件系统操作都需要同一个缺失的概念:一个明确的执行环境。

WSL作为第一个生产案例

WSL是底层技术的第一个重要验证点。

IDE保持在Windows上,而项目位于Linux环境中。这意味着Linux文件系统语义、符号链接、Linux SDK和Linux侧工具。同时,IntelliJ IDEA已经有了多年的WSL特定集成可以借鉴。

基于代理的文件系统通道让我们从平台内部改进了该模型。在IntelliJ IDEA 2024.3中,WSL项目支持获得了符号链接支持,并将IDE到WSL的通信切换为Hyper-V套接字。在IntelliJ IDEA 2025.1中,WSL项目的索引速度比Windows项目更快,完全支持符号链接,并且更无缝地在WSL内部使用JDK。

当时EelApi尚未完成。但它底层的通道已经在做实际工作,这比一个漂亮的图表好得多。

从WSL到Dev Containers

Dev Containers是一个不同的挑战。

对于WSL,平台已经有很长的WSL特定集成历史:路径识别、通过WSL机制启动进程、以及不同子系统中的专用桥接。基于代理的文件系统通道可以改进并替换该现有堆栈的一部分。

Dev Containers没有同样成熟的IntelliJ特定基础。将容器化项目视为类似本地项目意味着从一开始就围绕代理构建环境访问模型,这意味着文件系统访问、进程执行、平台信息、路径转换和网络访问都必须通过同一个通道。

在IntelliJ IDEA 2025.3中,这表现为在同一IDE窗口中打开Dev Container项目的选项。在IntelliJ IDEA 2026.1中,这成为了Dev Container的默认工作流程:项目在本地IDE中打开,无需在容器内启动完整的IDE后端。

这就是我们在这篇文章中所说的原生Dev Container支持:本地IDE、容器侧的IntelliJ平台代理,以及通过执行环境API路由的项目环境操作。

在IntelliJ IDEA中,这目前涵盖了Dev Container项目预期的主要工作流程。剩余的工作是在更多IDE、更多语言栈、更多平台子系统和更多第三方插件场景中扩展此支持。

关于API状态的说明

在进入代码示例之前,有一个实际细节需要注意:EelApi的大部分表面目前标记为@ApiStatus.Experimental

对于插件作者来说,这并不意味着“请暂时忽略它”。这意味着当你的插件目标是WSL或Dev Containers时,这是应该尝试的方向,尽管在稳定过程中仍然可能进行一些源码级别的调整。我们预计这些变化是有限的,因为同样的模型已经在基于IntelliJ的IDE中用于WSL支持和原生Dev Container支持的生产环境中得到验证。

从项目开始

大多数插件代码应从Project开始。

如果IDE中打开了一个项目,平台已经建立了处理该项目所需的环境。对于本地项目,该环境就是本机。对于WSL项目,它就是WSL发行版。对于以原生模式打开的Dev Container项目,它就是容器。

$ kotlin
import com.intellij.platform.eel.provider.getEelDescriptor
import com.intellij.platform.eel.provider.toEelApi

val descriptor = project.getEelDescriptor()
val eel = descriptor.toEelApi()

你也可以从Path开始:

$ kotlin
import com.intellij.platform.eel.provider.getEelDescriptor

val descriptor = path.getEelDescriptor()

这在路径本身是你正在处理的对象时很有用。不过,对于任意路径要更加小心。将路径视为环境并不总是简单的字符串解析;当你实际使用描述符时,平台可能需要启动、部署或连接到IntelliJ平台代理,并且某些环境可能不可用。已经打开的项目是最安全的锚点,因为如果没有对工作环境的访问,项目将不会以那种模式打开。

描述符和机器

EelDescriptor回答:“我通过什么路由访问这个环境?”

当你需要在该环境中执行操作或在PathEelPath之间进行转换时,使用描述符。

EelMachine回答:“这实际上是哪个底层机器、容器或发行版?”

多个描述符可能指向同一台机器。例如,通过\\wsl$\\wsl.localhost的WSL路径可能指向同一个WSL发行版。当你管理共享资源(如连接池、长期运行的服务、每环境状态或可复用隧道)时,使用EelMachine作为缓存键。

大多数插件代码应从EelDescriptor开始。当你故意跨多个访问路径共享同一环境的某些内容时,使用EelMachine

核心EelApi示例

通过单个EelApi实例,你可以获得:

  • 环境文件系统视图,NIO Path操作路由到那里。
  • 环境内部的进程执行。
  • 进出该环境的TCP隧道。
  • 环境的平台和操作系统检测。

下面的示例使用了当前的实验性API形状。它们展示了预期的模型和我们建议今天使用的构建块,但在稳定之前仍可能进行小的API调整。

将工具插入环境

假设你的插件管理一个CLI工具的版本。当项目位于Dev Container中时,该工具必须是Linux版本,并且必须存在于容器文件系统中。

指向环境中的Path可以与标准NIO操作一起使用:

$ kotlin
import com.intellij.platform.eel.fs.createTemporaryDirectory
import com.intellij.platform.eel.getOrThrow
import com.intellij.platform.eel.provider.asNioPath
import com.intellij.platform.eel.provider.getEelDescriptor
import com.intellij.platform.eel.provider.toEelApi
import java.nio.file.Files
import java.nio.file.StandardCopyOption

val eel = project.getEelDescriptor().toEelApi()

val remoteDir = eel.fs.createTemporaryDirectory()
  .prefix("my-plugin-")
  .deleteOnExit(true)
  .getOrThrow()
  .asNioPath()

val remoteBinary = Files.copy(
  localBinary,
  remoteDir.resolve(localBinary.fileName),
  StandardCopyOption.REPLACE_EXISTING,
)

这里,remoteDir仍然是java.nio.file.Path,但它指向项目环境。Files.copyFiles.write和其他NIO操作通过环境感知的文件系统提供程序路由。

还有一个内部帮助程序,可以在单一步骤中将本地内容传输到远程环境。在EelApi稳定化过程中计划提供等效的公共帮助程序。

在环境内运行工具

当进程属于项目环境时,使用EelApi.exec,而不是ProcessBuilder

$ kotlin
import com.intellij.platform.eel.provider.asEelPath
import com.intellij.platform.eel.provider.getEelDescriptor
import com.intellij.platform.eel.provider.toEelApi
import com.intellij.platform.eel.provider.utils.readAllBytes
import com.intellij.platform.eel.spawnProcess

val eel = project.getEelDescriptor().toEelApi()

val process = eel.exec.spawnProcess(remoteBinary.asEelPath().toString())
  .workingDirectory(projectRoot.asEelPath())
  .args("--version")
  .eelIt()

val exitCode = process.exitCode.await()
val stdout = process.stdout.readAllBytes().toString(Charsets.UTF_8)

使用EelPath作为工作目录。如果参数是目标进程将要读取的路径,请传递目标侧的路径字符串。

传递环境变量和目标侧路径

构建工具是一个很好的例子,因为它们同时将路径作为参数和环境变量传递。假设一个插件需要在项目环境中启动Maven,并指定特定的JDK和自定义设置文件。

$ kotlin
import com.intellij.platform.eel.provider.asEelPath

val javaHomeInTarget = jdkHome.asEelPath().toString()

val settingsXmlInTarget = mavenSettingsXml.asEelPath().toString()

val process = eel.exec.spawnProcess(mavenExecutable.asEelPath().toString())
  .workingDirectory(projectRoot.asEelPath())
  .env(mapOf("JAVA_HOME" to javaHomeInTarget))
  .args("-s", settingsXmlInTarget, "test")
  .eelIt()

对于Linux容器,JAVA_HOME应该像这样:

/usr/lib/jvm/java-21-openjdk

而不是类似宿主机侧的路由路径。同样的规则适用于Go特定的路径,如GOROOTGOPATHGOMODCACHE,以及更专门的变量如LD_PRELOAD:如果环境中的进程会将变量值读取为路径,则提供从该环境看到的路径。

检测目标平台

不要使用SystemInfo来为项目环境选择二进制文件。SystemInfo描述的是IDE宿主。使用eel.platform来获取项目侧工具将运行的环境信息。

$ kotlin
val classifier = when {
  eel.platform.isWindows -> "windows-x64"
  eel.platform.isMac -> "macos-aarch64"
  eel.platform.isPosix -> "linux-x64"
  else -> error("Unsupported environment")
}

当IDE宿主是macOS或Windows,但项目在Linux容器内运行时,这一点很重要。

跨边界连接端口

如果IDE侧代码需要与环境中监听的服务通信,不要假设localhost在两侧含义相同。

对于一次性连接,通过EelTunnelsApi连接。在这个例子中,localhost在项目环境内部解析:

$ kotlin
import com.intellij.platform.eel.getConnectionToRemotePort
import com.intellij.platform.eel.withConnectionToRemotePort
import java.io.IOException

eel.tunnels.getConnectionToRemotePort()
  .hostname("localhost")
  .port(servicePort.toUShort())
  .withConnectionToRemotePort(
    errorHandler = { error -> throw IOException("Cannot connect to service in the project environment", error) },
  ) { connection ->
    connection.sendChannel.send(requestBytes)
    val response = connection.receiveChannel.receive(8192)
    handleResponse(response)
  }

对于只知道如何连接到本地TCP端口的现有IDE侧库,创建一个代理。该代理在IDE宿主上监听,并将流量转发到环境中的服务:

$ kotlin
import com.intellij.platform.eel.eelProxy
import com.intellij.platform.eel.provider.localEel
import com.intellij.platform.eel.provider.utils.acceptOnTcpPort
import com.intellij.platform.eel.provider.utils.connectToTcpPort
import kotlinx.coroutines.launch

val proxy = eelProxy()
  .acceptOnTcpPort(localEel.tunnels, port = 0u)
  .connectToTcpPort(eel.tunnels, host = "localhost", port = servicePort.toUShort())
  .eelIt()
val localPort = proxy.acceptor.boundAddress.port.toInt()
val proxyJob = scope.launch {
  proxy.runForever()
}

IDE侧代码现在可以连接到127.0.0.1:$localPort。保持代理生命周期显式:当服务、运行配置、调试会话或工具窗口不再需要隧道时,取消作业。

还有一个对应的方向,用于在环境内部接受连接并将其转发回IDE宿主。当容器中的进程需要回调IDE侧服务时,这很有用。

理解路径

上面的示例同时使用了Path和目标侧路径字符串。这是最容易出错的部分,因此值得单独讨论。

java.nio.file.Path是IDE/JVM侧的路径。它是IntelliJ平台API和标准Java文件API可以接受的形式。

对于Windows上的WSL,这个路径是熟悉的:

\\wsl.localhost\Ubuntu\home\user\project
\\wsl$\Ubuntu\home\user\project

目标侧路径是WSL内部Linux工具看到的:

/home/user/project

这些形式自然映射:UNC路径标识了WSL发行版及其内部的Linux路径。EEL感知的文件访问可以识别WSL路径,将其与WSL环境关联,并通过基于代理的通道路由文件操作。

Docker和Dev Containers需要一个合成路由路径,因为宿主操作系统没有正常的方式来访问正在运行的容器内的文件。

在Windows上,Dev Container路由路径可能看起来像这样:

//devcontainer.ij/devcontainer-abc@np~.~pipe~docker_engine/workspaces/app

或以Windows样式显示:

\\devcontainer.ij\devcontainer-abc@np~.~pipe~docker_engine\workspaces\app

在Linux或macOS上,同一个想法使用Unix风格的合成根:

/$devcontainer.ij/devcontainer-abc@/workspaces/app
/$devcontainer.ij/devcontainer-abc@u~var~run~docker.sock/workspaces/app

前缀标识了Dev Container路由路径。内部路径之前的部分标识了容器和Docker端点。内部路径是容器内的路径:

/workspaces/app

路由路径对IntelliJ平台和JetBrains运行时文件API有意义。它不是一个正常的宿主文件系统路径,不应期望任意宿主进程能理解。

因此规则是:

  • 对于IntelliJ平台API和Java文件操作,使用Path
  • 对于传递给环境中运行的进程的路径,使用EelPathpath.asEelPath().toString()
  • 不要将合成Docker路由路径传递给不相关的宿主工具。## 现有本地中心化 API 怎么办?

IntelliJ 平台有许多最初为本地机器模型设计的 API。其中一些已经掌握了路由路径的概念,能够在 WSL 和 Dev Container 项目中继续正常工作。

最典型的例子是 NIO Path:如果该 Path 属于 WSL 或 Dev Container,那么 Files.existsFiles.copyFiles.newInputStream 等标准操作可以路由到对应环境感知的文件系统提供器。

对于在 JetBrains Runtime 上运行的 IDE 中较旧的 java.io.File 代码,也存在一个兼容层。JBR 可以将 File 操作通过对应的 NIO 实现进行路由,因此由路由路径创建的 File 可以访问到相同的环境感知文件系统提供器,对于 WSL 或 Dev Container 场景,还能访问到底层的 IntelliJ Agent。请将此视为现有代码的兼容性方案;对于新代码,推荐使用 Path,因为它具有现代化的、提供器感知的 Java 文件系统 API,并且可以直接与 Eel API(如 asEelPath()asNioPath())组合使用。

GeneralCommandLine 也可以参与此模型。在支持的情况下,可执行路径或工作目录可以让平台自动选择正确的进程执行环境。

这个限制很重要,因为这些 API 不会将每个字符串都重新解释为路径。命令行参数、环境变量、配置文件以及协议消息中也可能包含路径。如果目标进程会读取该值,需显式使用 asEelPath() 将其转换为目标侧的形式。

主机信息同样适用此规则。SystemInfoSystem.getenv() 描述的是主机上 IDE 进程的信息,而不是 WSL 发行版或容器中项目侧进程的描述。

这与远程开发和 Split 模式的关系

远程开发使用独立的 IDE 进程——一个轻量级前端和一个靠近项目位置的全功能后端。Split 模式就是该场景下的插件架构:插件代码可能需要前端部分、后端部分以及共享部分。

EelApi 解决的是另一个问题:这个操作在哪里运行?

如果你的插件需要代码在不同的 IDE 进程中运行,你仍然需要远程开发插件模型。如果你的插件需要在 WSL 或 Dev Container 中运行 CLI、检查目标操作系统信息、转换路径、访问文件或打开端口,那么 EelApi 就是你应该关注的环境 API。

对于 IntelliJ IDEA Dev Container 的原生模式,不再需要 Split 模式来让项目环境操作在容器内执行。在原生模式不可用的情况下,远程开发仍然是打开和处理 Dev Container 项目的选项。

未来计划

我们计划稳定公开 API 接口,发布专门的文档,并为插件作者提供迁移指南。

同样的方向将继续在基于 IntelliJ 的 IDE 中推进:更广泛的 Dev Container 覆盖范围、更多的子系统采用,以及更少需要插件作者了解项目是本地、WSL 还是容器化的情况。

现在收集来自真实插件的反馈特别有价值,因为公开接口仍处于实验性阶段,便于调整。