跳到主要内容

沙盒模式隔离类加载器

引言​

在像 Bukkit 这样多个插件共存并且可以互相访问的环境中,如何避免依赖冲突是一个重要的问题。

TabooLib 默认的解决方案是通过 relocate 重定向改变依赖的包名来避免冲突,比如把 1.7.21 版本的 kotlin 包名重定向为 kotlin1721 来避免不同版本 Kotlin 标准库的冲突。

但是有些场景使用重定向可能会出问题,这时可以使用沙盒模式,即 IsolatedClassLoader 隔离类加载器来避免依赖冲突,同时不需要 relocate 重定向。

最后的手段

沙盒模式是迫不得已而为之。如果 relocate 可以解决问题,请优先使用 TabooLib 默认的 relocate 解决方案。

应用场景​

何时使用沙盒模式?​

  1. 使用了 kotlin-reflect 等不能 relocate 的依赖

    • 比如 JetBrains 的 ORM 框架 Exposed 就需要 kotlin-reflect 依赖
  2. 把插件作为加载器去加载附属插件

    • 并为附属插件直接提供 Kotlin API,因此不能 relocate kotlin
  3. 加载 Kotlin Script(kts 脚本)

    • 需要没有 relocate 的 kotlin 环境

注意事项​

隔离的代价

一旦开启沙盒模式,使用 IsolatedClassLoader 加载的所有类均不可以被其他插件访问,这也正是"沙盒"和"隔离"的意思。

沙盒的特性​

  • ✅ 沙盒内部的类可以访问沙盒外部的类
  • ❌ 沙盒外部的类无法访问沙盒内部的类

解决方案​

可以通过 SPI 配置排除的类和包,使其非隔离加载,从而为其他插件提供 API。详见下文 通过 SPI 排除指定类和包。

开始使用​

TabooLib 6.1 及以上版本​

1. 配置 build.gradle.kts​

build.gradle.kts
taboolib {
env {
// 开启隔离类加载器(沙盒模式)
enableIsolatedClassLoader = true
}
version {
// 跳过 kotlin 重定向
skipKotlinRelocate = true
}
}
配置说明
  • enableIsolatedClassLoader = true - 启用沙盒模式
  • skipKotlinRelocate = true - 跳过 Kotlin 标准库的重定向

TabooLib 6.0 版本​

参考 PR: #293 - add IsolatedClassLoader

通过 SPI 排除指定类和包​

什么是 SPI?​

SPI 即 Service Provider Interface,是 JDK 内置的一种服务提供发现机制。它通过将接口实现类的全限定名配置在文件中,由服务加载器读取配置文件,实现了接口与实现类的解耦。

使用方法​

步骤 1:创建配置类​

新建一个 Java 类(不能是 Kotlin 类)实现 taboolib.common.classloader.IsolatedClassLoaderConfig 接口:

IsolatedConfig.java
package com.example.myplugin;

import taboolib.common.classloader.IsolatedClassLoaderConfig;
import java.util.Set;

public class IsolatedConfig implements IsolatedClassLoaderConfig {

@Override
public Set<String> excludedClasses() {
// 返回排除的类的全限定名(包名.类名)
return Set.of(
"com.example.myplugin.api.MyAPI",
"com.example.myplugin.api.MyInterface"
);
}

@Override
public Set<String> excludedPackages() {
// 返回排除的包名(最好以 . 结尾)
return Set.of(
"com.example.myplugin.api."
);
}
}
接口方法
  • excludedClasses() - 返回排除的类的全限定名
  • excludedPackages() - 返回排除的包名(建议以 . 结尾)

步骤 2:创建 SPI 配置文件​

  1. 在 resources 目录下新建 META-INF/services 目录

  2. 在其中新建文件:项目包名.taboolib.common.classloader.IsolatedClassLoaderConfig

    注意包名重定向

    如果不确定正确的文件名,可以反编译 build 出来的 jar 文件,查看 IsolatedClassLoaderConfig 这个类被 relocate 到哪里了。

  3. 在该文件中填写实现类的全限定名(包名.类名)

META-INF/services/[relocate包名].taboolib.common.classloader.IsolatedClassLoaderConfig
com.example.myplugin.IsolatedConfig

注意事项​

重要限制
  1. 排除的类和包中最好只提供接口

    • 不能调用任何自身被隔离加载的类
    • 不能调用自身加载的依赖
    • 不能调用 Kotlin 标准库中的内容(因为它们都是被隔离加载的)
  2. 排除的类和包最好使用 Java 编写

    • 使用 Kotlin 时请严格遵守第 1 条注意事项
    • 确保 Kotlin 编译后产生的 class 字节码没有调用任何 Kotlin 标准库中的函数

参考 PR: #360 - 允许通过 SPI 排除指定类或包

优秀实践​

完整的 API 提供方案​

下面是一个推荐的项目结构和实现方式:

1. 创建 API 包结构​

src/main/java/
└── io/github/username/project/
├── api/ # API 包(非隔离加载)
│ ├── ProjectAPI.java # 主 API 接口
│ ├── ProjectAPIProvider.java # API 提供者
│ └── interfaces/ # 其他接口
│ ├── IUniversalItemRegistry.java
│ ├── IGuideElementRegistry.java
│ └── ...
└── impl/ # 实现包(隔离加载)
└── ProjectAPIImpl.kt # API 实现类

2. 定义主 API 接口​

api/ProjectAPI.java
package io.github.username.project.api;

public interface ProjectAPI {

IUniversalItemRegistry getUniversalItemRegistry();

IGuideElementRegistry getGuideElementRegistry();

IClassRegistry<IGuideElementState> getGuideElementStateRegistry();

IClassRegistry<IGuideReward> getGuideRewardRegistry();

IShiningGuide getShiningGuide();

IGuideTeamManager getGuideTeamManager();

IMachineRegistry getMachineRegistry();

IBlueprintEditor getBlueprintEditor();
}

3. 创建 API 提供者​

api/ProjectAPIProvider.java
package io.github.username.project.api;

public class ProjectAPIProvider {

private static ProjectAPI api = null;

public static void setAPI(ProjectAPI instance) {
api = instance;
}

public static ProjectAPI getAPI() {
if (api == null) {
throw new IllegalStateException(
"ProjectAPI has not finished loading, or failed to load!"
);
}
return api;
}
}
Kotlin 单例版本

也可以使用 Kotlin 的 object 单例:

api/ProjectAPIProvider.kt
package io.github.username.project.api

object ProjectAPIProvider {
internal var api: ProjectAPI? = null

fun api(): ProjectAPI {
return api ?: throw IllegalStateException(
"ProjectAPI has not finished loading, or failed to load!"
)
}
}

4. 实现 API 接口​

impl/ProjectAPIImpl.kt
package io.github.username.project.impl

import io.github.username.project.api.*

class ProjectAPIImpl : ProjectAPI {

override fun getUniversalItemRegistry(): IUniversalItemRegistry =
UniversalItemRegistry

override fun getGuideElementRegistry(): IGuideElementRegistry =
GuideElementRegistry

override fun getGuideElementStateRegistry(): IClassRegistry<IGuideElementState> =
GuideElementStateRegistry

override fun getGuideRewardRegistry(): IClassRegistry<IGuideReward> =
GuideRewardRegistry

override fun getShiningGuide(): IShiningGuide = ShiningGuide

override fun getGuideTeamManager(): IGuideTeamManager = GuideTeamManager

override fun getMachineRegistry(): IMachineRegistry = MachineRegistry

override fun getBlueprintEditor(): IBlueprintEditor = BlueprintEditor
}

5. 在插件主类初始化 API​

Project.kt
package io.github.username.project

import io.github.username.project.api.ProjectAPI
import io.github.username.project.api.ProjectAPIProvider
import io.github.username.project.impl.ProjectAPIImpl
import org.bukkit.Bukkit
import org.bukkit.plugin.ServicePriority
import taboolib.common.platform.Plugin

object Project : Plugin() {

override fun onEnable() {
// 初始化 API
val api = ProjectAPIImpl()
ProjectAPIProvider.setAPI(api)

// 注册到 Bukkit 服务管理器
Bukkit.getServicesManager().register(
ProjectAPI::class.java,
api,
plugin,
ServicePriority.Normal
)
}
}
Bukkit 服务管理器

Bukkit.getServicesManager().register 是 Bukkit 提供的服务注册方法,可以让其他插件通过服务管理器获取你的 API。

参考: RarityEG's Plugin Dev Tutorial - 注册到 Bukkit

6. 在其他插件中获取 API​

方法一:使用 ProjectAPIProvider​
Java
import io.github.username.project.api.ProjectAPI;
import io.github.username.project.api.ProjectAPIProvider;

ProjectAPI api = ProjectAPIProvider.getAPI();
Kotlin
import io.github.username.project.api.ProjectAPI
import io.github.username.project.api.ProjectAPIProvider

val api: ProjectAPI = ProjectAPIProvider.api()
方法二:使用 Bukkit ServicesManager​
Java
import io.github.username.project.api.ProjectAPI;
import org.bukkit.Bukkit;
import org.bukkit.plugin.RegisteredServiceProvider;

RegisteredServiceProvider<ProjectAPI> rsp =
Bukkit.getServicesManager().getRegistration(ProjectAPI.class);
ProjectAPI api = rsp.getProvider();
Kotlin
import io.github.username.project.api.ProjectAPI
import org.bukkit.Bukkit

val rsp = Bukkit.getServicesManager()
.getRegistration(ProjectAPI::class.java)
val api: ProjectAPI = rsp.provider
方法三:使用 Kotlin 属性委托​
object BukkitApiGetter {
inline operator fun <reified T> getValue(
ref: Any?,
property: KProperty<*>
): T {
return Bukkit.getServicesManager()
.getRegistration(T::class.java)?.provider
?: throw IllegalStateException("Service not found: ${T::class.java.name}")
}
}

// 使用方式
object Test {
val myAPI: ProjectAPI by BukkitApiGetter
val anotherAPI: YourAPI by BukkitApiGetter
}

原理详述​

类加载器​

类加载是 Java 虚拟机的一种机制,用于将类的二进制数据读入内存并解析成 Class 对象。每个 Java 类都必须由某个类加载器加载到内存中。Java 类加载器是通过双亲委派模型实现的,这种模型可以保证同一个类只会被同一个类加载器加载。

双亲委派机制​

JVM 双亲委派机制是 Java 类加载器的一种工作机制,它的核心思想是:

  1. 当一个类加载器收到类加载请求时,首先将这个请求委托给父类加载器去完成
  2. 依次递归下去,如果父类加载器可以完成该加载任务,就成功返回
  3. 只有父类加载器无法完成此加载请求时(找不到此类),子加载器才会尝试自己去加载

这种机制使得 Java 的核心 API 能确保在各种环境和应用中保持稳定。

IsolatedClassLoader 打破双亲委派机制​

让我们看看 TabooLib 的实现(源码位置:common/src/main/java/taboolib/common/classloader/IsolatedClassLoader.java:93):

public Class<?> loadClass(String name, boolean resolve, boolean checkParents)
throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
Class<?> findClass = findLoadedClass(name);

// 在检查父类加载器之前先检查隔离的类和库:
// - 防止访问其他插件的类
// - 防止使用旧的补丁类(这些类在重新加载后仍然存在于内存中)
if (findClass == null && !excludedClasses.contains(name)) {
boolean flag = true;
for (String excludedPackage : excludedPackages) {
if (name.startsWith(excludedPackage)) {
flag = false;
break;
}
}
if (flag) {
findClass = findClassOrNull(name);
}
}

// 找不到再委托给父类加载器
if (findClass == null && checkParents) {
findClass = loadClassFromParentOrNull(name);
}

if (findClass == null) {
throw new ClassNotFoundException(name);
}

if (resolve) {
resolveClass(findClass);
}
return findClass;
}
}

打破双亲委派的意义​

  • IsolatedClassLoader 加载类时先尝试自己加载,如果找不到类再委托给父类加载器加载
  • 从而打破了双亲委派机制,允许相同全限定名的类在 JVM 中共存

为什么插件自身的类也要被隔离加载?​

你可能会有疑问:为什么不只让需要的依赖隔离加载,而插件自身的类非隔离加载呢?这样既能避免依赖冲突,其他插件又能访问本插件的类。

原因

JVM 的类加载机制决定的:

  1. JVM 加载类时并不是一次性全都加载进内存中的,而是哪个类被调用了就去加载哪个类
  2. JVM 是用调用类(当前类)的类加载器去加载被调用的类
  3. 这就导致了要想访问到被隔离的依赖,那插件自身的类必须也被隔离加载

沙盒隔离的形成​

点击放大
  • Bukkit 使用 PluginClassLoader 加载所有插件的类从而实现不同插件的互相访问
  • 被 IsolatedClassLoader 加载的类虽然可以访问其父类加载器 PluginClassLoader 加载的类(自身通过 SPI 排除的类和其他插件的类)
  • 但是 PluginClassLoader 加载的类却无法访问 IsolatedClassLoader 加载的类
  • 从而形成了一个隔离的沙盒环境

总结​

何时使用沙盒模式?​

场景是否使用沙盒模式
普通依赖冲突❌ 使用 relocate
kotlin-reflect 等不能 relocate 的依赖✅ 使用沙盒模式
加载 Kotlin Script(kts)✅ 使用沙盒模式
作为加载器加载附属插件并提供 Kotlin API✅ 使用沙盒模式

关键要点​

  1. ✅ 沙盒模式通过打破双亲委派机制实现类隔离
  2. ✅ 可以通过 SPI 配置排除类和包,为其他插件提供 API
  3. ⚠️ 排除的类和包最好只提供接口,使用 Java 编写
  4. ⚠️ 沙盒内的类无法被外部访问,这是隔离的代价

相关链接​