Skip to content

Latest commit

 

History

History
435 lines (292 loc) · 20.9 KB

4-1.md

File metadata and controls

435 lines (292 loc) · 20.9 KB

4-1 NMS 与反射

Minecraft 原生服务端

Bukkit 是通过修改服务端并包装了一层 API 实现的。也正因为是包装,Bukkit 的 API 无法覆盖 Minecraft 服务端的各个角落。总有一些高级的功能被遗漏掉,比如:NBT 标签。

这时候我们不得不请求 NMS 的支援。

禁止在 NMS 后带上 K 右边,分号左边,O 下边的那个字符。这是使用 NMS 时最重要的事情(笑)!

NMS 的类都位于 net.minecraft.server.v1_16_R3 (对于 1.16.5 而言)这个包下。最后那个包名会随反混淆和 API 的版本不同而变化。(1.15.2 版本下则是 v1_15_R1

NMS 这个缩写来源于 net.minecraft.server 这个包的包路径首字母,但笔者总觉得它也可以是「Native Minecraft Server」的缩写,是我的错觉吗?

比如,如果要使用 NBT 标签,由于 Bukkit 没有提供这个功能,我们只能这样做:

ItemStack im = new ItemStack(Material.BARRIER);
// 随便创建一个 ItemStack,这是 Bukkit 的 ItemStack
net.minecraft.server.v1_16_R3.ItemStack imNMS = CraftItemStack.asNMSCopy(im);
// 转换为 NMS 物品
NBTTagCompound nbt = imNMS.getTag();
// 获得 NBT
if (nbt == null) {
    nbt = new NBTTagCompound();
    // 防止 NullPointerException
}
nbt.set("someValue", NBTTagString.create("This is a string."));
// 增加 NBT 键值对
imNMS.setTag(nbt);
// 把 NBT 返回给 imNMS,就和 ItemMeta 一样
im = CraftItemStack.asBukkitCopy(imNMS);
// 重新定义 im 变量,将 imNMS 转换为原来的 Bukkit ItemStack
// 之后的处理……

第二行中,由于 net.minecraft.server.v1_16_R3.ItemStackorg.bukkit.inventory.ItemStack 都叫 ItemStack,为了让 Java 区分,其中一个必须带上完整的路径。

NMS 功能使用起来虽然没有 Bukkit API 那么简便,但原理还是比较清晰的。

但是,如果这样的调用真的都是这么简单的话,我也没必要编写这一章了,问题就在这里出现了……

糟糕的兼容性

因为要使用 NMS 的各种类,我们代码的开头加上了:

import net.minecraft.server.v1_16_R3.NBTTagCompound;
import net.minecraft.server.v1_16_R3.NBTTagString;
import org.bukkit.craftbukkit.v1_16_R3.inventory.CraftItemStack;

然后你编译这个插件,在 1.16.X 的服务端上运行,效果很好。

然而,当你把这个插件拿到 1.15.2 的服务端上运行时……

1.15.2 的 Bukkit 根本就不知道什么是 v1_16_R3!它只认识 v1_15_R1

不同版本的 Bukkit 服务端中,net.minecraft.server.v?_??_R? 是不一样且不确定的。

所以,对于从目前 MCBBS 规定的最低不过期版本 1.12.2 开始,我们要为每一个 v1_1X_RX 编写相应的代码,这些代码中还有很多是重复的!

功能基本一样,就为了一个包名,就得重新编译,不仅要下载各个版本的服务端,服主下载时还容易弄错,什么都不做,还要编译出如此多的文件,这就是浪费。

但是,很多有名的插件都使用了 NMS 功能(例如更多附魔),但我们却没见到它们这么做,因此肯定有好的解决方法。

实际上 Java 已经为我们提供了解决这个问题的方法,只要我们能够好好利用就行了~

反射

反射为 Java 的兼容性做出了质的改变。虽然使用反射需要编写非常多的代码和错误处理,但这是值得的,反射会给你带来足够的回报!

反射不需要导入任何库,相关功能包括在 java.lang.reflect 这个包中,这个包已经内置在 JVM 标准库中了。

什么是反射

反射的原理很简单:类本身也是一个对象

请再品味一下。

类本身也是一个对象

那就是说,我们可以使用类来描述一个类。

说形象点,就是:蓝图的蓝图

这就叫反射。Java 在运行中会针对它加载的每一个类生成这个类的对象,并将它放在内存的某处。因此,即使在编译时出于某种原因无法指定,也可以在运行时重新得到这个类的信息(照着蓝图画一张蓝图)。


我们知道,ItemStack(NMS 中的)一定存在,因为 Bukkit 要用到它,虽然因为运行环境版本不同,我们不知道它的具体包名,但是,它就在那里!一定在内存的哪个地方!

这就像你要找你的朋友,你知道 TA 的名字,你知道 TA 家在某省某市某区,只是不知道道路,那么,虽然没有直接寻找那么简单,但也一定可以找到 TA 的!

说白了,我们知道 NMS 的类一定位于 net.minecraft.server.v?_??_R? 这个包下,我们只需要这样一个功能:根据名称尝试查找一个类

Java 中有个类具有这样的功能,这个类叫 Class,它描述一个类,它有一个静态方法:

Class<T> forName(String name) throws ClassNotFoundException

如果找到了类,它就把类作为一个对象返回。否则触发 ClassNotFoundException

开始使用反射

首先我们需要知道当前服务端的版本,否则我们就不知道该尝试加载哪几个类,获取版本很简单:

String[] versions = Bukkit.getMinecraftVersion().split("\\.");
// 获得版本号并分割
String major = versions[0]; // 1
String minor = versions[1]; // 16

然而 Bukkit 一个很缺德的地方就是:这只能确定 NMS 包的前两位,而第三位是不知道的!(v1_16_R?

实际上这不能完全怪 Bukkit,Mojang 也有份。

所以我们不得不进行几次尝试。

目前已知的是,v1_16_R? 中的 ? 是一个整数,且都是从 1 开始的。

所以我们可以开始尝试:

String revision = "-1";
String NMSBaseHead = "net.minecraft.server.v" + major + "_" + minor + "_R";
for (int i = 1; i <= 9; i++) {
    String versionTest = NMSBaseHead + i;
    try {
        Class.forName(versionTest + ".ItemStack");
        revision = i + ""; // 只有上一句没有错误才会执行到这里
        break;
    } catch (ClassNotFoundException ignored) {}
    // 异常直接忽略掉
}

Class.forName 用指定的路径寻找类。这里我们查找的是 net.minecraft.server.v?_?_R?.CraftItemStack,其实查找别的 NMS 类也是一样的。

for 是计数循环,大括号中的内容将被不断执行,直到 i <= 9 不成立。i++i = i + 1 的缩写。int i = 1 只在循环开始时执行一次用来初始化。

forName 会在上文我们提到的「内存的某处」(保存类信息的地方)那里尝试找到我们指示的类,它找不到类时抛出 ClassNotFoundException 异常。只有没有抛出异常时,break 才会执行。

我们尝试从 R1R9 的全部包,如果还找不到就认为不存在,出现错误。如果找到了,就记录下这个版本号。

这样版本号确定了,NMS 包名也就确定了。


接下来我们还是以 NBT 为例说明反射的使用。请各位读者时刻记住:类也是一个对象,同样,方法也是一个对象。(万物皆可为对象)

看看我们之前的代码,第一步是 CraftItemStack.asNMSCopy

通过上面(不使用反射的 NMS 的代码中)我们的 import 语句,我们知道 CraftItemStack 类位于 org.bukkit.craftbukkit.v?_??_R? 下。

首先我们需要找到 CraftItemStack 这个类,还是使用 Class.forName,不过由于版本号已经知道了,因此不需要尝试,直接「精准采集」:

if (!revision.equals("-1")) {
    // 找到了合适的版本
    String NMSPackage = NMSBaseHead + revision; // 将两段拼接在一起
    String CraftBukkitPackage = "org.bukkit.craftbukkit.v" + major + "_" + minor + "_R" + revision; // 有些功能位于 org.bukkit.craftbukkit 中,这个被称为 OBC
    // 上面两行代码将两个包名「组装」好以便等会使用
    
    
    try {
        Class<?> craftItemStack = Class.forName(CraftBukkitPackage + ".inventory.CraftItemStack"); // 查找 org.bukkit.craftbukkit.v?_??_R?.inventory.CraftItemStack
        
        // 后面的代码会写在这里
    } catch (ClassNotFoundException e) {
        e.printStackTrace(); // 按道理讲不会再出错了,如果出错就是 NMS 的问题了(不存在的类)
    }
}

这里的 <?>Class 的一个模板化(Class 也是一个模板类,似曾相识?),本来这里应该使用被读出来的类进行模板化,但由于我们不知道(事实上是没法让 Java 知道)用 forName 读出的对象是什么类型,因此只能打 ? 兜底(Java 自动判断)。

此外,这里还使用了 org.bukkit.craftbukkit 包,这个包和 net.minecraft.server 一样,都会基于版本而改变。

?> 到底怎么回事
org.bukkit.craftbukkit 包和 NMS 一样,使用 v?_??_R? 表示版本,这个包简称为 OBC。CraftBukkit 是对 NMS 进行的一次封装(包括反混淆等)。
如果你观察过 org.bukkit 下其它的正常类,你会发现它们大部分是 interface,也就是接口,而不是常见的 class,实际上 CraftBukkit 才是 Bukkit 的实现。它是 Bukkit 与 Minecraft 原版之间的一个桥梁。也正是如此,CraftBukkit 的代码中不得不使用 net.minecraft.server.v?_??_R? 中的包,导致 CraftBukkit 自身也被「污染」,它的包名也需要拼接而成。
这里我们使用的 NBT 功能,Bukkit API 没有提供,但 CraftBukkit 提供了,我们就拿来使用。
本教程中我们不对 NMS 与 OBC 区别称呼,如果读者觉得不能容忍,那只能麻烦各位自己在脑中转换一下了。


继续。

找到这个类后,我们先像之前一样创建 ItemStack(Bukkit API 中的),这里正常创建就行了:

ItemStack im = new ItemStack(Material.BARRIER);

然后我们需要调用 asNMSCopy 方法,那么首先要找到这个方法(方法也是个对象!!!):

Method asNMSCopy = craftItemStack.getMethod("asNMSCopy", ItemStack.class);

getMethod 根据方法名参数列表进行查找,听不懂就看图:

REFLECTFLECTTION

这里的第一个参数是方法名,后面的是参数类型列表,多个参数按顺序往后排即可。

forName 查出来的就是该类的对象(蓝图的蓝图),对于已知的类,使用 类名.class 直接获得它所属类的对象。

这里还会抛出 NoSuchMethodException 异常(如果找不到这个方法),需要在后面捕获。

然后调用这个方法,使用 Method 对象的 invoke 方法:

Object NMSItem = asNMSCopy.invoke(null, im);
// 这里的 NMSItem 就是返回值,但我们没办法为它指定类,只好用兜底的 Object

invoke 的第一个参数是实例,这是什么意思呢?

// 如果原来的方法是这样:
im.setItemMeta(itemMeta);
// 用反射就要写成这样(获取方法的步骤已省略):
method.invoke(im, itemMeta);

第一个参数指的是要以哪个对象的身份调用这个方法,后面的是该方法的剩余参数。由于 asNMSCopy 是一个类(静态)方法,因此不需要指定对象,那就设为 null。听不懂?看图看图……

INVOKE

invoke 还会抛出 IllegalAccessExceptionInvocationTargetException(如果本来是 private 却被直接 invoke,或者该对象不存在这个方法时会触发),错误真多啊!不过仔细想想也在情理之中,毕竟我们在做本来应该由 Java 来做的事情嘛。

下一步我们需要获得 NBT,那么我们需要得到 ItemStackNBTTagCompound 两个类。ItemStack 类用来调用 getTag 方法,而 NBTTagCompound 用于在没有 NBT 时创建新对象。

!> 当心同名!
这里的 ItemStacknet.minecraft.server.v?_??_R? 包下的 ItemStack,不是 API 中的 org.bukkit.inventory.ItemStack

Class<?> itemStack = Class.forName(NMSPackage + ".ItemStack");
Class<?> nbtTagCompound = Class.forName(NMSPackage + ".NBTTagCompound");

然后调用 getTag 获得 NBT,并判断是不是空:

Method getTag = itemStack.getMethod("getTag"); // getTag 方法
Object nbt = getTag.invoke(NMSItem); // 执行
if (nbt == null) { // null 判定还是一样
    Constructor<?> createNewTag = nbtTagCompound.getConstructor();
    // 获得构造方法,默认的构造方法没有参数,所以我们也不传
    nbt = createNewTag.newInstance();
    // 创建新实例,相当于 new
}

这很简单,同时这里又抛出了异常 InstantiationException,我们还是把它捕获写在下面。


这样总算准备好 NBT 了。

然后我们需要调用 set 方法设置 NBT,这就需要找到 NBTTagString 类。

Class<?> nbtTagString = Class.forName(NMSPackage + ".NBTTagString");
Class<?> nbtBase = Class.forName(NMSPackage + ".NBTBase");
Method setNBTString = nbtTagCompound.getMethod("set", String.class, nbtBase);

还记得吗,set 方法的两个参数分别是 StringNBTBase(反编译可知),我们将这两个类传给 getMethod 让它帮我们找到正确的方法。再说一遍——在 getMethod 中,已知类用 .class 直接取得,未知类用 forName 查找的结果。(总之你传入的是一个 Class 对象就行了)

?> 到底怎么回事
你可能会问,刚刚我们使用的明明是 NBTTagString 啊,为什么这里要用 NBTBase 呢?
实际上在 NBTTagCompound 类中,set 方法只有一个,它的参数是 NBTBaseNBTBaseNBTTagString 的父类,刚刚我们只是「碰巧」放入了 NBTTagString 而已。
getMethod 没有这么强大,它不会查找参数类的子类,因此这里只能用 NBTBase 来查找了。

然后我们需要获得 NBTTagString.create 方法:

Method createNBTTagString = nbtTagString.getMethod("create", String.class);

然后 invoke 一下,获得一个 NBTTagString

Object stringValue = createNBTTagString.invoke(null, "This is a string.");

这和 NBTTagString.create("This is a string"); 一样。

然后终于可以进行 set 了!

setNBTString.invoke(nbt, "someValue", stringValue);

这样就完成了 set,然后我们需要把这个 NBT 返回给 NMSItem,这需要用到 setTag 方法,不用说,我们还要获取:

Method setTag = itemStack.getMethod("setTag", nbtTagCompound);
setTag.invoke(NMSItem, nbt);

最后调用 asBukkitCopy 重新创建 im 变量:

Method asBukkitCopy = craftItemStack.getMethod("asBukkitCopy", itemStack);
im = (ItemStack) asBukkitCopy.invoke(null, NMSItem);

这里用了强制类型转换返回到 ItemStack(Bukkit API 中的)。

最后我们来看错误处理:

catch (ClassNotFoundException | NoSuchMethodException | IllegalAccessException | InvocationTargetException | InstantiationException e) {
    e.printStackTrace();
}

捕捉了这么多错误,果然反射是个不让人省心的呢~(笑)

最后贴出完整代码,注释给出了和上面直接使用 NMS 写代码时的对应关系:

String[] versions = Bukkit.getMinecraftVersion().split("\\.");

String major = versions[0];
String minor = versions[1];
String revision = "-1";
String NMSBaseHead = "net.minecraft.server.v" + major + "_" + minor + "_R";
for (int i = 1; i <= 9; i++) {
    String versionTest = NMSBaseHead + i;
    try {
        Class.forName(versionTest + ".ItemStack");
        revision = i + "";
        break;
    } catch (ClassNotFoundException ignored) {
    }
}
if (!revision.equals("-1")) {
    String NMSPackage = NMSBaseHead + revision;
    String CraftBukkitPackage = "org.bukkit.craftbukkit.v" + major + "_" + minor + "_R" + revision;
    // 以上都是准备工作,下面正式开始
    try {
        Class<?> craftItemStack = Class.forName(CraftBukkitPackage + ".inventory.CraftItemStack");
        ItemStack im = new ItemStack(Material.BARRIER);
        Method asNMSCopy = craftItemStack.getMethod("asNMSCopy", ItemStack.class);
        Object NMSItem = asNMSCopy.invoke(null, im);
        
        // 相当于 CraftItemStack.asNMSCopy(im); 
        
        Class<?> itemStack = Class.forName(NMSPackage + ".ItemStack");
        Class<?> nbtTagCompound = Class.forName(NMSPackage + ".NBTTagCompound");

        Method getTag = itemStack.getMethod("getTag");
        Object nbt = getTag.invoke(NMSItem);
        // 相当于 NBTTagCompound nbt = imNMS.getTag();
        
        if (nbt == null) {
            Constructor<?> createNewTag = nbtTagCompound.getConstructor();
            nbt = createNewTag.newInstance();
            // 相当于 nbt = new NBTTagCompound();
        }

        Class<?> nbtTagString = Class.forName(NMSPackage + ".NBTTagString");
        Class<?> nbtBase = Class.forName(NMSPackage + ".NBTBase");
        Method setNBTString = nbtTagCompound.getMethod("set", String.class, nbtBase);
        Method createNBTTagString = nbtTagString.getMethod("create", String.class);
        Object stringValue = createNBTTagString.invoke(null, "This is a string.");
        setNBTString.invoke(nbt, "someValue", stringValue);
        
        // 相当于 nbt.set("someValue", NBTTagString.create("This is a string."));
        
        Method setTag = itemStack.getMethod("setTag", nbtTagCompound);
        setTag.invoke(NMSItem, nbt);
        
        // 相当于 imNMS.setTag(nbt);
        
        Method asBukkitCopy = craftItemStack.getMethod("asBukkitCopy", itemStack);
        im = (ItemStack) asBukkitCopy.invoke(null, NMSItem);
        
        // 相当于 im = CraftItemStack.asBukkitCopy(imNMS);
        // 主要处理到此结束,以下为异常捕获
    } catch (ClassNotFoundException | NoSuchMethodException | IllegalAccessException | InvocationTargetException | InstantiationException e) {
        e.printStackTrace();
        // 出错我们也没办法,只能输出了
    }
}

要测试上面的代码,只需要把它写在 onEnable 方法中就可以了,如果运行正常,它就什么都不做(笑)。当然不可能有异常的啦,笔者已经进行过测试了~

反射总结

其实我们只是在使用类与方法之前先获取了它们而已(万物皆可对象,再说一遍),反射和正常的代码没什么区别。

不过这样写确实很累,就像在两座山之间走钢丝,还要一边看着地图。万一哪个地方本来应该写成 NMSPackage + ".ItemStack",结果不小心把 . 漏掉了,那可就很麻烦了。编写反射的代码时,我们不得不照着原来的代码,提心吊胆地进行编写。

但是我们的努力很快就得到了回报!以上代码可以写在一个单独的方法中:

public static ItemStack setNBTString(ItemStack im, String tag, String value);

以后设置任何 NBT 都只需要:

im = setNBTString(im, "anotherValue", "BlahBlah");

并且这个方法可以在任何 Bukkit 的服务端版本中工作!(实际上有时候会出问题,不过那毕竟是少数)

反编译的重要性

在编写上述代码时,你可能尝试过到 JavaDocs 中查找 net.minecraft.server 包下的一些内容。很遗憾,NMS 的内容没有被记载在 JavaDocs 中。目前比较方便的方法是使用反编译:

  • 先按照常规的方法写好代码(不使用反射)
  • 按着 Ctrl 并用左键点击想知道的类,IDEA 就会帮你反编译它,并显示出所有的方法
  • Ctrl + F 进行查找
  • 知道了各个方法的类型后,我们就可以 getMethod 啦~

另外你还可以利用 IDEA 的全局搜索:按两下 Shift,输入你要查找的类名,在上方菜单中选择「Classes」,就可以进行搜索啦~

更好的解决方案?

实际上解决 NMS 问题还有很多别的方法,例如使用 Java Agent,或者动态修改字节码,或者……

但是笔者目前不准备介绍它们。

的确,反射的代码繁琐,编写难度大,而且会带来额外的性能开销。不过,这都不足以成为你拒绝反射的理由

  1. 掌握了反射,再学习别的方法会易如反掌
  2. 反射不需要额外的依赖,并且经过 Java 的长期优化,性能已经相当高
  3. 反射有利于你学习面向对象思想
  4. 反射的实现稳定,其它的插件由它们的作者维护,要求可能不会很严格,而反射由 Oracle(Hotspot JVM)或 Eclipse(OpenJ9 JVM)维护

在所有不需要用到其它库/插件的环境中,反射是最为简单的,而依靠其它的插件……抱歉,不是我不相信其它插件,但是 java.lang.reflect 包和 xxx.xxx 包相比,能够包含在 java 这个包中的,应该都是更好的实现吧?

也许你想用更好的解决方法,只要你能达到目的,笔者仍然全力支持,毕竟我们的教程叫做「插件开发教程」,不叫「反射学习教程」嘛~