Spigot的NMS是对net-minecraft-server包(也是nms缩写的由来)的一个综合性反射工具,即便读者可能不知道Minecraft是什么或者从未参与过Minecraft伺服器的插件开发工作,但我仍会为每一位读者详细介绍这其中所蕴含的一些技术和实现原理。读者需要知道的是:Spigot 更专注于 Minecraft 的插件开发和服务器功能扩展,而不是提供一个完整的企业级应用开发框架,因此虽然它不像Spring那样专业但是两者仍然存在着许多相似性很高的技术原理。
作为一个优秀的Java程序开发者,我们都应该明白一个道理“技术框架的使用从来没有抄袭一说,只是因为应用的领域不同”。在本章中我将以Gradle构建的Minecraft-1.20-NMS作为核心开发包,逐步讲解这种环境下的Web编程、如何在Minecraft高版本中使用NMS混淆。
相信读者多少也具备点分模块工程的构建能力和开发经验,本次我们使用Gradle的模块化编程进行开发,以Lumos
为插件名,我们将Spigot的启动模块命名为Lumos-Spigot
、Web工程模块命名为Lumos-Web
进行开发。
Spigot-NMS的开发依赖是非常复杂且繁琐的,在Gradle的配置中就有所体现。使用Groovy-Gradle来编写父工程(root工程)的基本配置内容,在其中我们也顺带定义子工程和所有工程的依赖管理:
import de.undercouch.gradle.tasks.download.Download
plugins {
id 'java'
id 'java-library'
id "io.freefair.lombok" version "6.3.0" // 引入Lombok
id "de.undercouch.download" version "5.0.1" // 使用Download作为后续本地BuildTools构建的前置
id 'com.github.johnrengelman.shadow' version '7.1.2' // 工程需要依赖于shadowJar Task来进行构建
}
group = "cn.dioxide.app"
version = "1.0.0" // 工程版本号
ext {
spigotVersion = "1.20.1-R0.1-SNAPSHOT" // 定义Minecraft核心的版本
annotationVersion = "23.0.0" // jetbrains annotations
lombokVersion = "1.18.28" // lombok
}
def buildToolsDir = new File(buildDir, "buildtools") // 这会在root工程中创建build/buildtools文件夹
def buildToolsJar = new File(buildDir, "buildtools/BuildTools.jar") // 将BuildTools.jar安装到build/buildtools文件夹中
def specialSourceFolder = new File(buildDir, "specialsource") // 这会在root工程中创建build/specialsource文件夹
def spigotJar = new File(buildToolsDir, "spigot-${spigotVersion}.jar") // 确认spigot的版本
def outputShadeJar = new File(buildDir, "libs/LumosEngine-${version}-all.jar") // 将插件输出到libs/文件夹中
def specialSourceJar = new File(buildDir, "specialsource/SpecialSource.jar") // 将混淆工具SpecialSource.jar安装到specialsource/文件夹中
def ssiJar = new File(buildDir, "specialsource/LumosEngine-${version}-all.jar") // 定义克隆shadowJar构建后的jar到specialsource/文件夹中并携带-all尾缀
def ssobfJar = new File(buildDir, "specialsource/LumosEngine-${version}-rmo.jar") // 定义-all工程第一次混淆后以-rmo尾缀进行存储
def ssJar = new File(buildDir, "specialsource/LumosEngine-${version}-rma.jar") // 定义-rmo工程第二次混淆后以-rma尾缀进行存储
def homePath = System.properties['user.home'] // 一般位于C:\Users\xxx
def m2 = new File(homePath + "/.m2/repository") // 获取本地Maven仓库的地址
def m2s = m2.getAbsolutePath() // 将Maven仓库的相对地址转为绝对地址
dependencies {
implementation project(":Lumos-Spigot") //
implementation project(":Lumos-Web")
}
// ****** 这里我们稍后写入混淆与反混淆构建的任务
// ****** 这里我们稍后写入BuildTools的本地Maven注入任务
// ****** 这里我们稍后写入shadowJar的构建任务
// ****** all project config
allprojects {
apply plugin: 'java'
apply plugin: 'com.github.johnrengelman.shadow'
tasks.withType(JavaCompile).configureEach {
options.encoding = 'UTF-8' // 让编译支持中文
}
repositories {
mavenLocal {
content {
includeGroup("org.bukkit") // 这会将本地Maven的NMS相关的包引入
includeGroup("org.spigotmc") // 这会将本地Maven的NMS相关的包引入
}
}
mavenCentral()
maven { url 'https://jitpack.io' }
maven { url 'https://hub.spigotmc.org/nexus/content/repositories/snapshots/' }
maven { url 'https://papermc.io/repo/repository/maven-public/' }
maven { url 'https://oss.sonatype.org/content/groups/public/' }
maven { url 'https://repo.codemc.org/repository/maven-public/' }
maven { url 'https://repo.dmulloy2.net/repository/public/' }
maven { url 'https://repo.extendedclip.com/content/repositories/placeholderapi/' }
mavenLocal()
}
dependencies {
// compileOnly是因为这些依赖都会在Spigot中被自动下载不需要打包到工程中
compileOnly "org.spigotmc:spigot-api:${spigotVersion}" // Spigot插件核心依赖
compileOnly "org.bukkit:craftbukkit:${spigotVersion}:remapped-mojang" // 本地Maven中的NMS依赖
compileOnly "org.jetbrains:annotations:$annotationVersion"
compileOnly "org.projectlombok:lombok:$lombokVersion"
compileOnly 'me.clip:placeholderapi:2.11.3' // PlaceholderAPI依赖
testImplementation platform("org.junit:junit-bom:5.9.1")
testImplementation "org.junit.jupiter:junit-jupiter"
annotationProcessor "org.projectlombok:lombok:$lombokVersion"
}
shadowJar { // 让打包过程将classpath:resources/plugin.yml也打包进来
append("plugin.yml")
}
}
// ****** children project
subprojects {
configurations.configureEach {
resolutionStrategy.cacheDynamicVersionsFor 0, 'seconds'
}
artifacts {
archives shadowJar
}
tasks.test {
useJUnitPlatform()
}
}
// ****** other
compileJava {
options.compilerArgs << '-parameters'
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
tasks.withType(JavaCompile).configureEach {
options.encoding = 'UTF-8'
}
在这个基本配置中定义了很多相关的变量与目标构建位置,这些都会在后面中被使用到。
NMS是一项非常脆弱且不稳定的技术,就像在Java中使用Unsafe
类一样,所以Spigot也好或CraftBukkit也好都是不直提供NMS相关的包、类或Maven仓库的。因此在工程中就需要使用Download
工具来下载并构建一个完整的BuildTools工程,让我们接着上面的基本Gradle配置继续构建这些Tasks。
// 定义下载BuildTools任务
task downloadBuildTools(type: Download) {
group 'setup'
// 使用Download工具来下载到build/buildtools文件夹中
src "https://hub.spigotmc.org/jenkins/job/BuildTools/lastSuccessfulBuild/artifact/target/BuildTools.jar"
dest buildToolsJar
onlyIf { !buildToolsJar.exists() }
}
// 构建BuildTools任务,并依赖于下载任务
tasks.register('buildSpigot', JavaExec) {
dependsOn downloadBuildTools
group 'setup'
classpath = files(buildToolsJar)
args = [ // 构建参数
"--rev", "1.20.1",
"--compile", "craftbukkit",
"--remap"
]
workingDir = buildToolsDir
doLast {
configurations.runtimeClasspath.files { it.name.startsWith("spigot") }
}
onlyIf { !spigotJar.exists() }
}
因为最终构建NMS的插件需要使用到混淆表,所以需要下载SpecialSource.jar来实现这个过程,在Gradle中构建这个任务,让它们相互形成依赖关系实现自动化构建:
// 下载SpecialSource任务
tasks.register('downloadSpecialSource', Download) {
group 'setup'
src "https://repo.maven.apache.org/maven2/net/md-5/SpecialSource/1.11.0/SpecialSource-1.11.0-shaded.jar"
dest specialSourceJar
onlyIf { !specialSourceJar.exists() }
}
// 将shadowJar的构建拷贝到specialsource中
tasks.register('copyBuildToSpecialSource', Copy) {
group "remapping"
from outputShadeJar
into specialSourceFolder
dependsOn(downloadSpecialSource, shadowJar)
}
// ssiJar 和 ssobfJar 进行混淆 得到-rmo.jar
tasks.register('specialSourceRemapObfuscate', JavaExec) {
group 'remapping'
dependsOn(copyBuildToSpecialSource, downloadSpecialSource, shadowJar)
workingDir = specialSourceFolder
classpath = files(specialSourceJar,
new File(m2s + "/org/spigotmc/spigot/" + spigotVersion + "/spigot-" + spigotVersion + "-remapped-mojang.jar"))
mainClass = "net.md_5.specialsource.SpecialSource"
args = [
"--live",
"-i", ssiJar.getName(),
"-o", ssobfJar.getName(),
"-m", m2s + "/org/spigotmc/minecraft-server/" + spigotVersion + "/minecraft-server-" + spigotVersion + "-maps-mojang.txt",
"--reverse",
]
}
// ssobfJar 和 ssJar 进行混淆 得到-rma.jar
tasks.register('specialSourceRemap', JavaExec) {
group 'remapping'
dependsOn(specialSourceRemapObfuscate)
workingDir = specialSourceFolder
classpath = files(specialSourceJar,
new File(m2s + "/org/spigotmc/spigot/" + spigotVersion + "/spigot-" + spigotVersion + "-remapped-obf.jar"))
mainClass = "net.md_5.specialsource.SpecialSource"
args = [
"--live",
"-i", ssobfJar.getName(),
"-o", ssJar.getName(),
"-m", m2s + "/org/spigotmc/minecraft-server/" + spigotVersion + "/minecraft-server-" + spigotVersion + "-maps-spigot.csrg"
]
}
// 将最终的ssJar拷贝到外部并重命名
tasks.register('lumos', Copy) {
group "lumos"
from ssJar
into buildDir
rename { String fileName ->
fileName.replace('LumosEngine-' + version + '-rma.jar', "LumosEngine-" + version + ".jar")
}
dependsOn(specialSourceRemap)
}
shadowJar是用于构建最终jar包的任务,这个构建出来的jar包是未经过混淆的,所以shadowJar也是混淆任务的前置任务。构建shadowJar需要将一些不必要的依赖进行排除,并将其委派给Spigot进行下载(这需要在plugin.yml中自行配置):
shadowJar {
append("plugin.yml")
dependencies {
exclude(dependency('org.jetbrains:annotations'))
exclude(dependency('org.jetbrains.kotlin:kotlin-stdlib-common'))
exclude(dependency('org.jetbrains.kotlin:kotlin-stdlib'))
exclude(dependency('com.google.code.gson:gson:2.10'))
exclude(dependency('com.google.protobuf:protobuf-java'))
exclude(dependency('com.mysql:mysql-connector-j:8.0.33'))
exclude(dependency('org.mybatis:mybatis:3.5.13'))
exclude(dependency('com.zaxxer:HikariCP:5.0.1'))
}
archiveBaseName.set("LumosEngine")
archiveVersion.set("${project.version}")
archiveClassifier.set('all')
}
在前面Gradle配置完成后,需要通过setup
组中的buildSpigot
任务完成项目的初始化工作,当所有依赖都被正确引入后就可以开始编写相关的Web代码了。为了能够让Spigot插件启动时同时启动Jetty容器,需要编一个简易的Jetty容器初始化方案,假设我们已经拥有了一个config.yml
的配置读取类Config
,并将Jetty容器初始化的类命名为ApplicationConfig
。
public class ApplicationConfig {
public boolean enable; // 是否启用web容器
public int port; // web容器端口
public LumosConfig lumos; // lumos config
// application -> application.yml
public void init(YamlConfiguration application) {
if (application == null) {
return;
}
this.enable = application.getBoolean("server.enable", false); // 是否启用jetty
this.port = application.getInt("server.port", 8090); // 配置的端口
// 读HikariCP配置
this.lumos = new LumosConfig();
this.lumos.datasource = new DataSource();
this.lumos.datasource.jdbcUrl = application.getString("lumos.datasource.url");
this.lumos.datasource.driverClassName = application.getString("lumos.datasource.driver-class-name");
this.lumos.datasource.username = application.getString("lumos.datasource.username");
this.lumos.datasource.password = application.getString("lumos.datasource.password");
}
// 单例
protected volatile static ApplicationConfig INSTANCE = null;
protected ApplicationConfig() {}
public static ApplicationConfig use() {
if (INSTANCE == null) {
synchronized (ApplicationConfig.class) {
if (INSTANCE == null) INSTANCE = new ApplicationConfig();
}
}
return INSTANCE;
}
public static class LumosConfig {
public DataSource datasource;
private LumosConfig() {}
}
public static class DataSource {
public String driverClassName;
public String jdbcUrl;
public String username;
public String password;
private DataSource() {}
}
}
不难看出,这里我将ApplicationConfig设计为了一个单例类型并通过synchronized
进行了多线程环境的校验,同时也依赖于MybatisConfig和MapperConfig进行了数据库的配置和数据库的ORM映射工具配置。让我们来看看他们的配置:
MybatisConfig使用了HikariCP链接数据库,实现对数据库的SQL操作支持:
public class MyBatisConfig {
private SqlSessionFactory sessionFactory;
@SuppressWarnings("all")
public MyBatisConfig() {
if (ApplicationConfig.use().enable) {
// 使用HikariCP数据库连接池
HikariConfig config = new HikariConfig();
// 冲入常量池
config.setJdbcUrl(ApplicationConfig.use().lumos.datasource.jdbcUrl);
config.setDriverClassName(ApplicationConfig.use().lumos.datasource.driverClassName);
config.setUsername(ApplicationConfig.use().lumos.datasource.username);
config.setPassword(ApplicationConfig.use().lumos.datasource.password);
config.setAutoCommit(true);
// 实例化HikariCP连接池实现连接池复用
HikariDataSource dataSource = new HikariDataSource(config);
// 创建 MyBatis 的 Configuration 对象
Configuration configuration = new Configuration();
configuration.setMapUnderscoreToCamelCase(true);
configuration.setEnvironment(new Environment("dev", new JdbcTransactionFactory(), dataSource));
configuration.getTypeAliasRegistry().registerAlias("cn.dioxide.web.entity.StaticPlayer", StaticPlayer.class);
// 注册 mapper
configuration.addMapper(cn.dioxide.web.mapper.PlayerMapper.class);
// 手动加载 PlayerMapper.xml 文件
try {
InputStream mapperInputStream = getClass().getClassLoader().getResourceAsStream("mapper/PlayerMapper.xml");
if (mapperInputStream == null) {
throw new RuntimeException("Failed to load Mapper xml");
}
XMLMapperBuilder xmlMapperBuilder = new XMLMapperBuilder(mapperInputStream, configuration, "mapper/PlayerMapper.xml", configuration.getSqlFragments());
xmlMapperBuilder.parse();
} catch (Exception e) {
throw new RuntimeException("Failed to load Mapper xml", e);
}
// 创建 MyBatis 的 SqlSessionFactory 对象
SqlSessionFactoryBuilder builder = new SqlSessionFactoryBuilder();
sessionFactory = builder.build(configuration);
}
}
public SqlSessionFactory getSessionFactory() {
return sessionFactory;
}
}
MapperConfig实现Mapper类到Mapper.xml文件的映射关系,并为其编写手动提交事务的方法来保障操作的原子性:
public class MapperConfig {
final SqlSession session;
SqlSessionFactory sessionFactory = new MyBatisConfig().getSessionFactory();
/**
* 获取mapper
* @param mapper 类型
*/
public <T> T getInstance(Class<T> mapper) {
return session.getMapper(mapper);
}
/**
* 提交事务
*/
public void commit() {
session.commit();
}
protected volatile static MapperConfig INSTANCE = null;
protected MapperConfig() {
session = sessionFactory.openSession();
}
public static MapperConfig use() {
if (INSTANCE == null) {
synchronized (MapperConfig.class) {
if (INSTANCE == null) INSTANCE = new MapperConfig();
}
}
return INSTANCE;
}
}
配置齐全后通过LocalWebEngine.init()
方法来实现Jetty容器的启动,完整的启动流程同时需要Servlet的支持:
public class LocalWebEngine {
@Getter
private static LocalWebEngine instance;
@Getter
private Server server;
@Getter
private JavaPlugin plugin;
public static void init(@NotNull JavaPlugin plugin) {
if (ApplicationConfig.use().enable) {
Format.use().plugin().info("Starting jetty server...");
instance = new LocalWebEngine();
instance.start(plugin);
}
}
private void start(@NotNull JavaPlugin plugin) {
this.plugin = plugin;
this.server = new Server();
// 使用try-with-resource来启动ServerConnector
try (ServerConnector connector = new ServerConnector(this.server)) {
connector.setPort(ApplicationConfig.use().port);
this.server.setConnectors(new Connector[]{connector});
}
// 创建并配置ServletContextHandler
ServletContextHandler context = new ServletContextHandler(ServletContextHandler.SESSIONS);
context.setContextPath("/");
handleServletBean(context);
// 将ServletContextHandler设置为服务器的handler
this.server.setHandler(context);
try {
this.server.start();
} catch (Exception e) {
e.printStackTrace();
Format.use().plugin().server("&cFailed to start local server!");
}
}
/**
* 自动扫描@ServletMapping注解的接口并注入
*/
private void handleServletBean(final ServletContextHandler context) {
// 包扫描自动注入servlet
for (Class<?> clazz : ReflectFactory.use().getClassSet()) {
if (!clazz.getName().contains("cn.dioxide.web")) {
continue;
}
if (HttpServlet.class.isAssignableFrom(clazz)) {
ServletMapping mapping = clazz.getAnnotation(ServletMapping.class);
if (mapping != null) {
try {
HttpServlet servletInstance = (HttpServlet) clazz.getDeclaredConstructor().newInstance();
context.addServlet(new ServletHolder(servletInstance), mapping.value());
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
}
/**
* 停止Jetty服务器
*/
public static void stop() {
try {
instance.server.stop();
} catch (Exception e) {
e.printStackTrace();
Format.use().plugin().server("&cFailed to stop local server!");
}
}
}
在LocalWebEngine.handleServletBean
方法中使用了反射与注解扫描接口类的方法来实现自动配置接口。
使用@ServletMapping
注解并搭配Mybatis来实现一个获取在线或离线玩家数据的接口。其中离线玩家数据获取的方法是在玩家离开游戏事件中保存玩家数据。
@ServletMapping("/api/player/*")
public class PlayerApiService extends HttpServlet {
PlayerMapper playerMapper = MapperConfig.use().getInstance(PlayerMapper.class);
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
// 设置响应内容类型为 JSON
resp.setContentType("application/json");
// 获取请求路径信息
String pathInfo = req.getPathInfo();
// 检查路径信息是否存在
if (pathInfo == null || pathInfo.equals("/")) {
resp.setStatus(HttpStatus.BAD_REQUEST_400);
resp.getWriter().write("{\"error\": \"Player name must be provided in the URL\"}");
return;
}
// 从路径信息中获取玩家名称
String playerName = pathInfo.substring(1);
// 尝试获取在线玩家
Player player = Bukkit.getPlayer(playerName);
// 检查玩家是否在线
if (player != null) {
// 发送响应
StaticPlayer onlinePlayer = StaticPlayer.convert(player, true);
resp.getWriter().write(onlinePlayer.toJSONString());
} else {
// 尝试获取离线玩家
StaticPlayer offlinePlayer = playerMapper.select(playerName);
// 检查离线玩家是否存在
if (offlinePlayer != null) {
// 发送响应
resp.getWriter().write(offlinePlayer.toJSONString());
} else {
resp.setStatus(HttpStatus.NOT_FOUND_404);
resp.getWriter().write("{\"error\": \"Player not found\"}");
}
}
}
}
在接口中并不能直接体现NMS技术,但是我们需要从中获取玩家的背包以及装备栏中物品的nbt内容,这就需要用到NMS了,这些内容被封装在了StaticPlayer
类中,并可以通过convert()
方法来隐式地调用:
@Getter
@Setter
@NoArgsConstructor(force = true)
public class StaticPlayer {
// ...
// 将在线的Player转换为StaticPlayer可存储对象
public static StaticPlayer convert(Player player, boolean isOnline) {
Location location = player.getLocation();
// Get the player's inventory data
List<CompoundTag> inventory = Arrays
.stream(player.getInventory().getContents())
.map(StaticPlayer::getItemNBTAsJson) // 委派给getItemNBTAsJson方法转换为json
.toList();
List<CompoundTag> equipment = Arrays
.stream(player.getInventory().getArmorContents())
.map(StaticPlayer::getItemNBTAsJson) // 委派给getItemNBTAsJson方法转换为json
.toList();
return new StaticPlayer(
isOnline,
player.getName(), player.getUniqueId().toString(), player.getLevel(),
location.getWorld() == null ? "overworld" : location.getWorld().getName(),
location.getX(), location.getY(), location.getZ(),
inventory, equipment);
}
// 使用NMS转换为CraftItem来获取CompoundTag下的nbt数据
private static CompoundTag getItemNBTAsJson(ItemStack itemStack) {
net.minecraft.world.item.ItemStack nmsCopy = CraftItemStack.asNMSCopy(itemStack);
return nmsCopy.save(new CompoundTag());
}
// 使用jackson来转换玩家数据的格式
public String toJSONString() {
ObjectMapper objectMapper = new ObjectMapper();
// 创建一个包含所有字段的 map
Map<String, Object> map = new HashMap<>();
map.put("isOnline", isOnline); map.put("name", name); map.put("uuid", uuid);
map.put("level", level);
map.put("world", world);
map.put("x", x); map.put("y", y); map.put("z", z);
map.put("qq", qq);
try {
if (this.inventory == null || this.equipment == null) {
// 将 JSON 字符串转换为 JsonNode
JsonNode invJsonNode = objectMapper.readTree(inv);
JsonNode equipJsonNode = objectMapper.readTree(equip);
// 将 JsonNode 放入 map
map.put("inventory", invJsonNode);
map.put("equipment", equipJsonNode);
} else {
Pair<List<String>, List<String>> iePair = compoundTagToJSON(inventory, equipment);
map.put("inventory", iePair.left());
map.put("equipment", iePair.right());
}
// 将 map 序列化为 JSON 字符串
return objectMapper.writeValueAsString(map);
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
// 将inventory和equipment中的对象调用其toString方法全部转换为json
private static Pair<List<String>, List<String>> compoundTagToJSON(
@NotNull List<CompoundTag> inventory,
@NotNull List<CompoundTag> equipment) {
// 将 NBT tags 转换为它们的字符串表示
List<String> inventoryStrings;
inventoryStrings = new ArrayList<>();
for (CompoundTag tag : inventory) {
inventoryStrings.add(tag.toString());
}
List<String> equipmentStrings;
equipmentStrings = new ArrayList<>();
for (CompoundTag tag : equipment) {
equipmentStrings.add(tag.toString());
}
return Pair.of(inventoryStrings, equipmentStrings);
}
// ...
}
这里就很明显了CompoundTag
类是来自NMS中的类,他并不暴露在Spigot-API依赖中而是暴露在了net.minecraft.nbt.CompoundTag
包中,同时又在getItemNBTAsJson
方法中使用了org.bukkit.craftbukkit.v1_20_R1.inventory.CraftItemStack
类,这是一个非常经典的NMS包命名方法。接下来我们深入看看NMS技术是什么。
虽然NMS技术是局限于Minecraft伺服器插件开发中,但其背后的技术依旧是值得很多Java程序员思考的。NMS类通常位于org.bukkit.craftbukkit.版本号
包中,它们都是用来处理Minecraft-Server底层逻辑的,包括但不局限于:获取、修改玩家NBT数据;获取、修改物品NBT数据;重写、修改维度生成规则;重写生物AI;自定义发包等。
NMS包提供了访问服务器核心内部的能力,允许插件开发者直接与服务器的底层代码进行交互。然而,NMS包并不是为插件开发者设计的公共 API,而是为了实现服务器核心功能而存在的。
在《混淆技术》中,我已经介绍了关于混淆与反混淆的内容,通过已有的知识重新审视NMS与混淆的关系就显得轻而易举。
graph TD
A[代码] -- 使用spigot-1.20.1-R0.1-SNAPSHOT-remapped-mojang.jar混淆 --> B[混淆的代码]
B -- 使用minecraft-server-1.20.1-R0.1-SNAPSHOT-maps-mojang.txt混淆表混淆 --> C[最终混淆的代码]
C -- 运行在Minecraft服务器上 --> D[Minecraft服务器]
这也是我们再Gradle中定义的两个混淆任务。因为Minecraft本身是经过混淆的,如果插件不进行正确的混淆那么NMS代码是不可能会被Server识别解析并调用的。因此我们可以得出一个简单的关系:
NMS不向开发者公开的原因包括:
为了解决与 NMS 包的交互需求,Spigot 提供了一些公共 API,如 Bukkit API 和 Spigot API。这些 API 提供了高级的抽象和功能,供插件开发者使用,并且是稳定和向后兼容的。通过使用这些公共 API,插件开发者可以在不直接操作 NMS 包的情况下访问和扩展 Minecraft 服务器的功能。这样可以提供更好的兼容性、安全性和稳定性,并降低插件开发的复杂性。