BukkitNMS开发中蕴含的混淆技术 发布于

2023-10-21 11:41:53 浏览数 (1)

摘录

Spigot的NMS是对net-minecraft-server包(也是nms缩写的由来)的一个综合性反射工具,即便读者可能不知道Minecraft是什么或者从未参与过Minecraft伺服器的插件开发工作,但我仍会为每一位读者详细介绍这其中所蕴含的一些技术和实现原理。读者需要知道的是:Spigot 更专注于 Minecraft 的插件开发和服务器功能扩展,而不是提供一个完整的企业级应用开发框架,因此虽然它不像Spring那样专业但是两者仍然存在着许多相似性很高的技术原理。

作为一个优秀的Java程序开发者,我们都应该明白一个道理“技术框架的使用从来没有抄袭一说,只是因为应用的领域不同”。在本章中我将以Gradle构建的Minecraft-1.20-NMS作为核心开发包,逐步讲解这种环境下的Web编程、如何在Minecraft高版本中使用NMS混淆。

构建Gradle工程

相信读者多少也具备点分模块工程的构建能力和开发经验,本次我们使用Gradle的模块化编程进行开发,以Lumos为插件名,我们将Spigot的启动模块命名为Lumos-Spigot、Web工程模块命名为Lumos-Web进行开发。

Gradle配置框架

Spigot-NMS的开发依赖是非常复杂且繁琐的,在Gradle的配置中就有所体现。使用Groovy-Gradle来编写父工程(root工程)的基本配置内容,在其中我们也顺带定义子工程和所有工程的依赖管理:

代码语言:javascript复制
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:Usersxxx
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'  
}

在这个基本配置中定义了很多相关的变量与目标构建位置,这些都会在后面中被使用到。

BuildTools Download Tasks

NMS是一项非常脆弱且不稳定的技术,就像在Java中使用Unsafe类一样,所以Spigot也好或CraftBukkit也好都是不直提供NMS相关的包、类或Maven仓库的。因此在工程中就需要使用Download工具来下载并构建一个完整的BuildTools工程,让我们接着上面的基本Gradle配置继续构建这些Tasks。

代码语言:javascript复制
// 定义下载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() }  
}

Remapped NMS Download Tasks

因为最终构建NMS的插件需要使用到混淆表,所以需要下载SpecialSource.jar来实现这个过程,在Gradle中构建这个任务,让它们相互形成依赖关系实现自动化构建:

代码语言:javascript复制
// 下载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 Tasks

shadowJar是用于构建最终jar包的任务,这个构建出来的jar包是未经过混淆的,所以shadowJar也是混淆任务的前置任务。构建shadowJar需要将一些不必要的依赖进行排除,并将其委派给Spigot进行下载(这需要在plugin.yml中自行配置):

代码语言:javascript复制
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')  
}

Jetty容器的构建

初始化并启动Jetty容器

在前面Gradle配置完成后,需要通过setup组中的buildSpigot任务完成项目的初始化工作,当所有依赖都被正确引入后就可以开始编写相关的Web代码了。为了能够让Spigot插件启动时同时启动Jetty容器,需要编一个简易的Jetty容器初始化方案,假设我们已经拥有了一个config.yml的配置读取类Config,并将Jetty容器初始化的类命名为ApplicationConfig

代码语言:javascript复制
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操作支持:

代码语言:javascript复制
	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文件的映射关系,并为其编写手动提交事务的方法来保障操作的原子性:

代码语言:javascript复制
	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的支持:

代码语言:javascript复制
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方法中使用了反射与注解扫描接口类的方法来实现自动配置接口。

使用NMS创建获取玩家数据的接口

使用@ServletMapping注解并搭配Mybatis来实现一个获取在线或离线玩家数据的接口。其中离线玩家数据获取的方法是在玩家离开游戏事件中保存玩家数据。

代码语言:javascript复制
@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()方法来隐式地调用:

代码语言:javascript复制
@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技术

虽然NMS技术是局限于Minecraft伺服器插件开发中,但其背后的技术依旧是值得很多Java程序员思考的。NMS类通常位于org.bukkit.craftbukkit.版本号包中,它们都是用来处理Minecraft-Server底层逻辑的,包括但不局限于:获取、修改玩家NBT数据;获取、修改物品NBT数据;重写、修改维度生成规则;重写生物AI;自定义发包等。

NMS包提供了访问服务器核心内部的能力,允许插件开发者直接与服务器的底层代码进行交互。然而,NMS包并不是为插件开发者设计的公共 API,而是为了实现服务器核心功能而存在的。

NMS与混淆的关系

在《混淆技术》中,我已经介绍了关于混淆与反混淆的内容,通过已有的知识重新审视NMS与混淆的关系就显得轻而易举。

代码语言:javascript复制
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识别解析并调用的。因此我们可以得出一个简单的关系:

  1. minecraft经过反混淆得到了spigot和nms中方便开发者阅读和使用的代码,其中稳定的部分从nms中抽取出来被封装到了spigot-api中,不稳定且不安全的部分仍然保留在nms中
  2. 不稳定的部分仍然是允许开发者使用的,即便环境的配置非常复杂,这部分除了通过反射调用也可以经过混淆重新得到能被Minecraft识别的代码

优势与弊端

NMS不向开发者公开的原因包括:

  1. 版本兼容性:Minecraft不断更新和迭代,每个Minecraft版本都有不同的NMS包。这意味着每个Minecraft版本都有不同的底层代码和实现方式。直接向插件开发者公开NMS包会导致插件的不兼容性和易受到Minecraft更新的影响。
  2. 内部实现变化:NMS包是Minecraft服务器的内部实现,它可能随着服务器版本的更新而发生变化。这些变化可能会破坏插件的稳定性和可靠性,使插件无法正常工作。
  3. 安全和稳定性:NMS包包含了服务器核心的敏感代码和内部逻辑。直接向插件开发者公开NMS包可能会导致潜在的安全问题和滥用风险。通过限制对NMS包的访问,可以确保服务器的安全性和稳定性。

为了解决与 NMS 包的交互需求,Spigot 提供了一些公共 API,如 Bukkit API 和 Spigot API。这些 API 提供了高级的抽象和功能,供插件开发者使用,并且是稳定和向后兼容的。通过使用这些公共 API,插件开发者可以在不直接操作 NMS 包的情况下访问和扩展 Minecraft 服务器的功能。这样可以提供更好的兼容性、安全性和稳定性,并降低插件开发的复杂性。

参考文献

  1. SpigotMC. (n.d.). SpigotMC.org. Retrieved July 10, 2023, from https://www.spigotmc.org/
  2. SpigotMC. (n.d.). Spigot API Javadocs. Retrieved July 10, 2023, from https://hub.spigotmc.org/javadocs/bukkit/org/bukkit/craftbukkit/v1_17_R1/package-summary.html
  3. SpigotMC. (n.d.). Spigot NMS and You. Retrieved July 10, 2023, from https://www.spigotmc.org/threads/21726/

0 人点赞