在实际开发中,如果你有一些天才的设想,比如借助 Forge 让你的插件服务器变得更加有特色,但是苦于无法进行数据传输的话,那么现在你就可以学习如何让 Bukkit / Sponge 和 Forge 之间传输信息了。

此教程适用于 1.7.10-1.12 与 1.13+,1.7.10 就把包名改成 cpw 那个就行,Bukkit的插件全版本通用。

概述

Bukkit / Sponge 与 Forge 通信的原理为:

服务端发送 PluginMessage 到 Forge 客户端,客户端使用 FMLNetworkEvent.ClientCustomPacketEvent 接受处理信息。

Forge 使用 FMLEventChannel 将 FMLNetworkPacket 发送至 Bukkit,Bukkit 服务器使用 PluginMessageListener 接受处理消息,Sponge 使用注册的频道添加的监听器 RawDataListener 处理消息。

Bukkit 接收消息部分

不知道你在查阅 Bukkit 的 Javadocs 时有没有注意到这样一个包 org.bukkit.plugin.messaging,这就是用于通信的包。

首先你需要一个实现了 PluginMessageListener 的类,本教程我们将其命名为 MessageListener:

import org.bukkit.entity.Player;
import org.bukkit.plugin.messaging.PluginMessageListener;

public class MessageListener implements PluginMessageListener {

    @Override
    public void onPluginMessageReceived(String channel, Player player, byte[] data) {

    }
}

自动补全的方法 onPluginMessageReceived 为接收到消息时调用的方法,channel 为通道名称,data 为具体的数据内容。

(2018/7/25 补充) 自 1.13 后,bukkit 对 channel 的名称做出了限制,需要使用 namespace:name 的格式,比如 fmltutor:fmltutor

接着,你需要注册消息输入和输出的通道,通过 Messenger 类的 registerIncomingPluginChannelregisterOutgoingPluginChannel 方法完成,代码如下:

import org.bukkit.Bukkit;
import org.bukkit.plugin.java.JavaPlugin;

public class MessageMain extends JavaPlugin {

    @Override
    public void onEnable() {
        // 注册消息接受通道
        Bukkit.getMessenger().registerIncomingPluginChannel(this, "msgtutor", new MessageListener());
        // 注册消息发送通道
        Bukkit.getMessenger().registerOutgoingPluginChannel(this, "msgtutor");
    }
}

registerIncomingPluginChannel 注册了接受消息的通道,和使用的 PluginMessageListener 实例,registerOutgoingPluginChannel 则注册了发送消息使用的通道。

到此你完成了Bukkit 接收消息的部分。

Forge 客户端接收消息部分

我们需要先注册一个通道,使用 NetworkRegistry 类的 newEventDrivenChannel 方法:

import net.minecraftforge.common.MinecraftForge;
import net.minecraftforge.fml.common.FMLCommonHandler;
import net.minecraftforge.fml.common.FMLLog;
import net.minecraftforge.fml.common.Mod;
import net.minecraftforge.fml.common.Mod.EventHandler;
import net.minecraftforge.fml.common.event.FMLPreInitializationEvent;
import net.minecraftforge.fml.common.eventhandler.SubscribeEvent;
import net.minecraftforge.fml.common.network.FMLEventChannel;
import net.minecraftforge.fml.common.network.FMLNetworkEvent;
import net.minecraftforge.fml.common.network.NetworkRegistry;

@SuppressWarnings("all")
@Mod(modid="msgtutor", version="tutor", name="MessageTutor")
public class MessageMain {
    static FMLEventChannel channel;

    @EventHandler
    public void preload(FMLPreInitializationEvent evt) {
        // 注册事件
        MinecraftForge.EVENT_BUS.register(this);
        FMLCommonHandler.instance().bus().register(this);
        // 注册通道
        channel = NetworkRegistry.INSTANCE.newEventDrivenChannel("msgtutor");
        channel.register(this);
    }
}

接着,我们添加一个监听器,监听 FMLNetworkEvent.ClientCustomPacketEvent 事件:

@SubscribeEvent
public void onClientPacket(FMLNetworkEvent.ClientCustomPacketEvent evt) {
    FMLLog.getLogger().info(new String(evt.getPacket().payload().array()));
}

此事件的 getPacket 方法可以获得一个 FMLProxyPacket 实例数据包,这个实例的方法 payload 可以获得数据包携带的内容 ByteBuf,而 ByteBuf 实例的方法 array 则可以得到 byte[] 类型的数据。

(2018/7/8 补充) 在 forge 的 1.12.2 以后的版本,该事件的 ByteBuf 变成了一个 netty 魔性优化的实例,导致性能的上升以及 array() 方法的失效,你需要手动 new 一个数组然后用 readBytes 来读数据。

到此,你就可以接收来自服务器的消息了。

Sponge 接收消息部分

Sponge.getChannelRegistrar() 方法返回 message channel 的注册器,然后通过其 createRawChannel 方法注册一个新的 channel。

通过 addListener 添加新的监听器。

@Plugin(id = "msgtutor",
        name = "MessageTutor",
        version = "1.0-SNAPSHOT",
        authors = {"IzzelAliz"})
public class ServerGui {

    private static ChannelBinding.RawDataChannel channel;

    @Listener
    public void onServerStart(GameStartedServerEvent event) {
        // 注册频道
        channel = Sponge.getChannelRegistrar().createRawChannel(this, "msgtutor");
        // 添加监听器
        // PlatformType 指定监听来自哪里的信息,我们监听的是客户端,所以使用 CLIENT
        channel.addListener(Platform.Type.CLIENT, (data, connection, side) -> {
            // 将连接类型转换为 PlayerConnection
            if (connection instanceof PlayerConnection) {
                PlayerConnection conn = (PlayerConnection) connection;
                // 示例给玩家发送消息
                conn.getPlayer().sendMessage(Text.of(new String(data.array())));
            }
        });
    }
}

发送消息

Bukkit 发送消息给客户端的方法为

Bukkit.getPlayer("Izzel_Aliz").sendPluginMessage(Plugin plugin, String channel, byte[] data);

Forge 发送消息给服务器的方法为

byte[] array = ...; // 你要发送的消息的 byte 数组
ByteBuf buf = Unpooled.wrappedBuffer(array);
FMLProxyPacket packet = new FMLProxyPacket(new PacketBuffer(buf), "msgtutor"); // 数据包
channel  // FMLEventChannel 实例
    .sendToServer(packet);

Sponge 发送消息给客户端的方法为

ChannelBinding.RawDataChannel channel = ... ; // 你注册的 channel 实例
Player player = .... ; // 目标玩家
channel.sendTo(player, channelBuf -> channelBuf.writeByteArray("发送的消息").getBytes());
// ChannelBuf 有大量方法,可以写入读取不同种类的数据,使用与 ByteBuf 类似

你可以发送任何东西,只要能将其作为 byte 数组发送。byte 数组的长度限制为 32766 字节。

LiteLoader 接受/发送

请移步 ustc_zzzz 帖子的章节与服务端插件交互 http://www.mcbbs.net/thread-659755-1-1.html

关于线程安全

以上接收时的事件全部是在网络线程被触发,所以对于线程不安全的Minecraft来说,线程安全问题需要额外注意。

由于本人对 Forge 的操作并不是很熟练,所以只能以 Bukkit 作为例子,如果你接收到的信息只是一条字符串,并且你只是想将其发送给玩家(Player#sendMessage),那么你可以随意在网络线程中使用,因为这个方法是线程安全的;但是,如果你需要进行踢出(Player#kickPlayer),那么你必须在主线程(Server Thread)进行这个操作,否则可能得到一个报错、崩溃或者意想不到的结果(尽管 Bukkit 会阻止 Async Kick 的行为并发出警告)。

那么,我们可以用以下的方法将数据转交给主线程处理:

Bukkit#getScheduler 返回一个 Bukkit 的调度器,Sponge#getScheduler 返回一个 Sponge 的调度器,实例如下。

Bukkit 错误的做法:

@Override
public void onPluginMessageReceived(String channel, Player player, byte[] data) {
    player.kickPlayer("你因为给服务器发 plugin message 被踢了");
    // 报错
}

Bukkit 的正确做法,将涉及服务器的操作使用调度器交给主线程完成:

@Override
public void onPluginMessageReceived(String channel, Player player, byte[] data) {
    Bukkit.getScheduler().runTask(插件实例, () -> player.kickPlayer("被服务器用调度器踢出"));
}

Sponge 的调度器操作类似。

ByteBuf 的简单使用

ByteBuf 类被提供于 io.netty 包中,使用可以通过 Maven 导入:

<dependency>
    <groupId>io.netty</groupId>
    <artifactId>netty-buffer</artifactId>
    <version>4.1.20.Final</version>
</dependency>

使用 Gradle 导入:

compile group: 'io.netty', name: 'netty-buffer', version: '4.1.20.Final'

ByteBuf 可以说是 java.nio.ByteBuffer 类的加强,主要有以下优点:

  • 读写指针分离,不用调用 flip() 方法来切换读写状态
  • 写入时可以自动增加容量
  • 提供了 Unpooled 和 Pooled 两种用于不同的场景

创建一个 ByteBuf 的方法很多,比如:

Unpooled.buffer() // 创建一个普通的 ByteBuf
Unpooled.wrappedBuffer(byte[]) // 从已有 byte 数组创建
Pooled.buffer() // 创建一个高并发优化的 ByteBuf

Unpooled 和 Pooled 类重载的方法还有很多,详情可以查阅 Javadocs。

另及:Minecraft 还叫不上高并发,Pooled 的高并发优化对于 Minecraft 大概没啥用。

写入/读取

ByteBuf buf = ... ;
buf.writeInt(int); // 写入 int 型数据
buf.writeBytes(byte[], int, int); // 写入 byte 数组,后两个参数分别是 offset 和 length
buf.readLong(); // 返回一个 long 型数据
buf.toString(int, int, Charset); // 将 ByteBuf 内部的 byte[] 转换为 String 型数据,前两个参数分别是 offset 和 length

重载的方法还有很多,基本什么数据都能往里写,这里介绍一些实用的示例。

ByteBuf 里的两个读写指针,分别可以通过 readerIndex 和 writerIndex 方法获得。

ByteBuf 内部维护了一个 byte 数组,其中 ByteBuf 的 capacity 为数组长度,可以通过 getCapacity 方法获得。

0 <= readerIndex <= writerIndex <= capacity

  • 0 到 readerIndex 之间的数组区域称为 discardable bytes,discardReadBytes 方法可以将数组从 readerIndex 之后的部分移动到 0,从而增大可用区。
  • readerIndex 到 writerIndex 之间的数组区域称为 readable bytes,这一部分可以进行读取,每次读取之后,readerIndex 都会相应增加(增加数据长度,如 readLong 就会增加 Long.BYTES)。
  • writerIndex 到 capacity 之间的数组区域称为 writable bytes,这一部分可以写入,每次写入之后,writerIndex 都会相应增加(如 writeShort 就会增加 Short.BYTES)。如果写入的长度大于 capacity - writerIndex,则自动扩容。

附录

你可以用你的天才般的设想,让插件服务器玩起来像 Mod 一样,一切都是有可能的。

我是一名 Bukkit 插件开发者,碰巧会一丁点的 Forge 开发,甚至刚学了一点 Sponge 开发。

使用 Forge 1.8.9 作为示范。

2019/6/14 对 1.13+ 的补充

1.13 的 Forge 已经更新几个月,变化较大,包括 Plugin Message Channel 的变化。

Forge 接收消息

代码如下,Forge 1.13 自身的一些新用法就不做介绍了。

@Mod("msgtutor")
public class MsgTutorMod {
    private static final int IDX = 233;
    private SimpleChannel channel;
    public MsgTutorMod() {
        FMLJavaModLoadingContext.get().getModEventBus().addListener(this::clientSetup);
    }

    private void clientSetup(FMLClientSetupEvent event) {
        channel = NetworkRegistry.ChannelBuilder
                .named(new ResourceLocation("msgtutor", "test"))
                .networkProtocolVersion(() -> "zzzz")
                .serverAcceptedVersions(NetworkRegistry.ACCEPTVANILLA::equals)
                .clientAcceptedVersions(NetworkRegistry.ACCEPTVANILLA::equals)
                .simpleChannel();
       channel.registerMessage(IDX, String.class, this::enc, this::dec, this::proc);
    }

    private void enc(String str, PacketBuffer buffer) {
        buffer.writeBytes(str.getBytes(StandardCharsets.UTF_8));
    }

    private String dec(PacketBuffer buffer) {
        return buffer.toString(StandardCharsets.UTF_8);
    }

    private void proc(String str, Supplier<NetworkEvent.Context> supplier) {
        System.out.println(str);
        NetworkEvent.Context context = supplier.get();
        context.setPacketHandled(true);
        channel.reply("client hello", context);
    }
}

1.13 中引入了一个新的 ChannelBuilder,显然比较强。其中

  • named 这个,在另一篇帖子里也说过,1.13 中消息通道也使用了类似的 namespace:path 的格式
  • networkProtocolVersion 为网络协议版本,现在看来乱写一个是可以的,如果不能的话请通知我
  • server/clientAcceptedVersions 望文生义,如果需要原版可以加入服务器就这么写,如果仅限 Forge 客户端的话,Predicates.not() 就可以了
  • simpleChannel 创建一个 SimpleChannel,还有一个方法创建一个事件驱动的 channel,与 1.12 时代的使用较为类似

因为种种原因我们选择了 SimpleChannel,在创建完 channel 之后,我们就可以着手处理消息了,比如注册一个消息。

使用 registerMessage 注册一种消息,其中

  • 第一个参数 index,用于区分不同的消息种类,看上去像是一个 int,但是实际上内部存储是 short,但是实际上还 index & 0xff,所以是个 unsigned byte,所以我们这个教程选择了 233 这个有趣的数字
  • 第二个是数据类型,为了方便用 String,但是这个东西的初衷应该是想让你写一个有编码解码处理方法的数据类来
  • 第三四五个参数自然就是编码解码处理的方法了,主要是操作一个 PacketBuffer,而 PacketBuffer 对 ByteBuf 包装了一下

然后我们就注册了一条消息,只要服务器发过来了消息,我们就能接收到。

再看到 proc 方法,这是我们处理接收到消息的方法,输出就不说了,但是

  • context.setPacketHandled(true) 这个方法在处理完消息后需要调用一次,否则控制台会打印一条无关痛痒的警告
  • 可以看到最后我们用了 reply 来回复这条消息,你也可以用 channel.sendToServer 来向服务器发送消息

Bukkit 发送接收部分

public final class Test extends JavaPlugin implements Listener {
    private static final int IDX = 233;
    private final String channel = "msgtutor:test";

    @Override
    public void onEnable() {
        getServer().getMessenger().registerIncomingPluginChannel(this, channel,
            (channel, player, message) ->
                        System.out.println("awsl " + read(message)));
        getServer().getMessenger().registerOutgoingPluginChannel(this, channel);
        getServer().getPluginManager().registerEvents(this, this);
    }

    @EventHandler
    public void onJoin(PlayerJoinEvent event) {
        Player player = event.getPlayer();
        try {
            Class<? extends CommandSender> senderClass = player.getClass();
            Method addChannel = senderClass.getDeclaredMethod("addChannel", String.class);
            addChannel.setAccessible(true);
            addChannel.invoke(player, channel);
        } catch (Exception e) {
            e.printStackTrace();
        }
        Bukkit.getScheduler().runTaskLater(this,
                () -> send(player, "server hello"), 100);
    }

    private void send(Player player, String msg) {
        byte[] bytes = msg.getBytes(StandardCharsets.UTF_8);
        ByteBuf buf = Unpooled.buffer(bytes.length + 1);
        buf.writeByte(IDX);
        buf.writeBytes(bytes);
        player.sendPluginMessage(this, channel, buf.array());
    }

    private String read(byte[] array) {
       ByteBuf buf = Unpooled.wrappedBuffer(array);
       if (buf.readUnsignedByte() == IDX) {
           return buf.toString(StandardCharsets.UTF_8);
       } else throw new RuntimeException();
    }
}

在 onEnable 中

  • 我们按正常流程注册,包括一个简易的打印接收到的客户端消息的东西
  • 因为 Forge 现在要帮我们分消息种类了,所以我们要在编码消息的时候在开头读一个 byte,这也就是 read 方法里第一个 readUnsignedByte 的用处。至于为什么是 unsigned,因为 byte 的范围是 -128 - 127,而我们写的是 233
  • channel 的名称和客户端对应,且必须是 namespace:path 的格式

为演示,我们监听玩家加入游戏的事件

  • 那个看起来莫名其妙的反射一会儿再说
  • 延迟 100 tick,也就是 5 秒发送

发送的逻辑在 send 方法里

  • 如上文所述,Forge 帮我们分了数据类型,所以我们要先写一个我们的 233 进去
  • Unpooled.buffer 相关的是 netty buffer 的操作,如果不知道咋用,应该看看前文
  • 至于为什么要 + 1,因为 byte 的长度是 1

现在来到了最后一个问题,那个莫名其妙的反射是啥?

在 Forge 1.13 以前,Forge 客户端在加入服务器之前,会向服务器发送 register 包来注册插件消息通道,而 1.13 和之后的版本却不这么做了,而服务器在没有收到 register 包之前,调用的 sendPluginMessage 方法都不会真正发送出去。详情在这个 issue(已修复) 里,cpw 表示关我 Forge 什么事,找 Spigot 去。

我们没有办法,毕竟我们不是 cpw 或者 Lex,所以我们只能自己动手丰衣足食,也就是假装我们收到了 register 包,也就是那个反射。

至于反射很丑,而由于作者懒的原因,没有研究用客户端发 register 包的方法。不过也好,这样子的话,想发包就不用像老版本那样等那几秒钟之后再发了。这也是为什么老版本需要等几秒才能发包的原因。