跳到正文
本頁目錄

Network(網絡)

ll/api/network/ · 通用

概述

Network 模塊用於定義在客戶端和服務器之間收發的自定義數據包。玩家進入之前,LeviLamina 會檢查兩端是否處理了各自需要的數據包, 之後也會持續告訴每一端對方處理哪些數據包。

頭文件

頭文件說明
ll/api/network/packet/Packet.hPacket、PacketBase 與 PacketHandlerBase
ll/api/network/packet/PacketRegistrar.hPacketRegistrar 與 PacketSide,用於運行時註冊數據包

使用方法

定義數據包

繼承 PacketBase 並實現 write 與 read。繼承即完成註冊,不需要再調用別的函數。

C++
#include "ll/api/network/packet/Packet.h"

class SetLabelPacket : public ll::network::PacketBase<SetLabelPacket> {
public:
    std::string label;

    void write(BinaryStream& stream) const override { stream.writeString(label, nullptr, nullptr); }

    Bedrock::Result<void> read(ReadOnlyBinaryStream& stream) override {
        auto value = stream.getString(256);
        if (!value) {
            return nonstd::make_unexpected(value.error());
        }
        label = std::move(*value);
        return {};
    }
};

處理數據包

繼承 PacketHandlerBase 並實現 handlePacket。繼承即完成註冊,LeviLamina 會創建一個處理器實例。side 表示處理該數據包的一端: 客戶端發來的數據包為 PacketSide::Server,服務器發來的為 PacketSide::Client。

C++
class SetLabelHandler : public ll::network::PacketHandlerBase<SetLabelHandler, SetLabelPacket> {
public:
    void handlePacket(
        NetworkIdentifier const& source,
        NetEventCallback&        callback,
        ll::network::PacketSide  side,
        SetLabelPacket const&    packet
    ) const {
        // ...
    }
};

發送數據包

C++
SetLabelPacket packet;
packet.label = "hello";

packet.sendToServer();                       // 客户端发往服务器
packet.sendTo(player);                       // 服务器发往某个玩家
packet.sendToClient(networkId, subClientId); // 服务器发往某个连接
packet.sendToClients();                      // 服务器发往所有客户端

sendTo 也可以傳入方塊座標與維度,或者一個實體,發給其周圍的玩家。

自定義數據包 ID 與版本

數據包在網絡上默認以類型名作為標識,因此重命名類或其命名空間都會改變 ID,分開編寫的服務端模組和客戶端模組也必須使用相同的類型名。 如需固定 ID,可聲明 CustomPacketId;它在編譯期讀取,與事件的 CustomEventId 相同。數據包的線上格式改變時,請增加 PacketVersion:版本不同的對端會把它視為另一個數據包,因此不會讀錯。

C++
class SetLabelPacket : public ll::network::PacketBase<SetLabelPacket> {
public:
    static constexpr std::string_view CustomPacketId{"mymod:set_label"};
    static constexpr uint             PacketVersion{2};
    // ...
};

ll::network::getPacketId<T> 返回 T 實際使用的 ID,ll::network::makePacketRuntimeId(id, version) 返回網絡上實際發送的運行時 ID。 版本為 0 時就是 ID 的哈希,因此沒有版本號的數據包運行時 ID 不變。腳本引擎通過 makePacketRuntimeId 與 C++ 定義的數據包互通。

處理端與必需數據包

Side 表示數據包由哪一端處理:PacketSide::Server 表示客戶端發往服務器的數據包,PacketSide::Client 表示服務器發往客戶端的數據包, PacketSide::Both(默認)表示兩端都處理。處理器默認在數據包聲明的一端處理,也可以通過 PacketHandlerBase 的第三個模板參數另行指定。 主持世界的客戶端同時是服務器和客戶端。

數據包默認是必需的,聲明 Optional 後才是可選的。玩家登錄時、發送世界之前,服務器會檢查客戶端是否處理了所有客戶端一側的必需數據包, 客戶端也會檢查服務器是否處理了所有服務端一側的必需數據包。缺少的一方會被斷開連接,並提示缺少哪些數據包;原版客戶端或服務器視為全部缺少。

C++
class SetLabelPacket : public ll::network::PacketBase<SetLabelPacket> {
public:
    static constexpr std::string_view        CustomPacketId{"mymod:set_label"};
    static constexpr ll::network::PacketSide Side{ll::network::PacketSide::Client};
    static constexpr bool                    Optional{true}; // 没有安装该模组的客户端也能进入服务器
    // ...
};

檢查對端

必需數據包不需要檢查。缺少必需數據包的客戶端或服務器會在登錄時被斷開,所以服務端從 PlayerJoinEvent 起可以直接向任何玩家發送必需數據包, 客戶端在世界開始加載後也可以直接發給服務器。

只有可選數據包可能在對端缺失。服務端用 canSendTo 檢查,可以傳入玩家或連接的 NetworkIdentifier;客戶端用 canSendToServer 檢查:

C++
SetLabelPacket packet;            // 声明了 Optional{true}
if (packet.canSendTo(player)) {   // 服务端
    packet.sendTo(player);
}
if (packet.canSendToServer()) {   // 客户端
    packet.sendToServer();
}

兩者都會保持最新:加載或卸載模組時,只把它處理的數據包告訴對端。

這些檢查只是本地查找,不會發送任何數據。它們在 PacketBase 上也是靜態函數,不必為了檢查而先構造數據包,適合在同一數據包的多個版本之間選擇。 運行時註冊的數據包可以把運行時 ID 傳給 Packet::canSendTo(id, player) 和 Packet::canSendToServer(id):

C++
if (SetLabelPacketV2::canSendToServer()) {
    SetLabelPacketV2{/* ... */}.sendToServer();
} else {
    SetLabelPacket{/* ... */}.sendToServer();
}

運行時註冊

腳本引擎以及在運行時定義數據包的模組可以直接使用 PacketRegistrar。請傳入擁有該註冊的模組,這樣它被卸載時註冊會被移除;處理器的變化會立即告訴對端。

C++
auto& registrar = ll::network::PacketRegistrar::getInstance();
auto  id        = ll::network::makePacketRuntimeId("myscript:ping", 1);
registrar.registerPacket("myscript:ping", id, [] { return std::make_unique<ScriptPacket>(/* ... */); },
                         ll::network::PacketSide::Both, /*optional=*/false, scriptMod);
registrar.registerHandler("myscript:ping", id, scriptHandler, ll::network::PacketSide::Both, scriptMod);
// ...
registrar.unregisterHandler(id);
registrar.unregisterPacket(id);

處理器在被註銷或其模組被卸載之前必須保持存活。

示例:SimpleTunnel

一個雙向傳遞字符串的通道。send 發送消息,co_await receive() 等待下一條消息。收到的消息先放在隊列裡,等協程來取; 消息到達時,InterruptableSleep 會喚醒正在等待的協程。處理器把消息放進收到它的那一端的通道;通道在它那一端第一次用到時才創建,服務器或客戶端只有一個,主持世界的客戶端兩端各一個。 每一端處理數據包的線程就是執行器運行的線程,所以隊列不需要加鎖。該數據包是必需的,所以玩家進入之後,兩端一定都有這個通道。

服務端模組和客戶端模組共用:

C++
#include "ll/api/coro/CoroTask.h"
#include "ll/api/coro/InterruptableSleep.h"
#include "ll/api/network/packet/Packet.h"

class SimpleTunnelPacket : public ll::network::PacketBase<SimpleTunnelPacket> {
public:
    static constexpr std::string_view CustomPacketId{"mymod:simple_tunnel"};

    std::string data;

    SimpleTunnelPacket() = default;

    explicit SimpleTunnelPacket(std::string data) : data(std::move(data)) {}

    void write(BinaryStream& stream) const override { stream.writeString(data, nullptr, nullptr); }

    Bedrock::Result<void> read(ReadOnlyBinaryStream& stream) override {
        auto value = stream.getString(4096);
        if (!value) {
            return nonstd::make_unexpected(value.error());
        }
        data = std::move(*value);
        return {};
    }
};

class SimpleTunnel {
public:
    struct Message {
        NetworkIdentifierWithSubId source;
        std::string                data;
    };

    static SimpleTunnel& of(ll::network::PacketSide side) {
        if (side == ll::network::PacketSide::Server) {
            static SimpleTunnel server;
            return server;
        }
        static SimpleTunnel client;
        return client;
    }

    void send(std::string data) const { SimpleTunnelPacket{std::move(data)}.sendToServer(); }

    void send(NetworkIdentifierWithSubId const& client, std::string data) const {
        SimpleTunnelPacket{std::move(data)}.sendToClient(client);
    }

    ll::coro::CoroTask<Message> receive() {
        while (received.empty()) {
            co_await arrived.sleep();
        }
        auto message = std::move(received.front());
        received.pop_front();
        co_return message;
    }

    void push(NetworkIdentifierWithSubId source, std::string data) {
        received.push_back({std::move(source), std::move(data)});
        arrived.interrupt();
    }

private:
    std::deque<Message>          received;
    ll::coro::InterruptableSleep arrived;
};

class SimpleTunnelHandler : public ll::network::PacketHandlerBase<SimpleTunnelHandler, SimpleTunnelPacket> {
public:
    void handlePacket(
        NetworkIdentifier const&  source,
        NetEventCallback&,
        ll::network::PacketSide   side,
        SimpleTunnelPacket const& packet
    ) const {
        SimpleTunnel::of(side).push({source, packet.mSenderSubId}, packet.data);
    }
};

服務端模組在啟用時啟動循環,把每條消息回顯回去:

C++
#include "ll/api/thread/ServerThreadExecutor.h"

void serveTunnel() {
    ll::coro::keepThis([]() -> ll::coro::CoroTask<> {
        auto& tunnel = SimpleTunnel::of(ll::network::PacketSide::Server);
        while (true) {
            auto message = co_await tunnel.receive();
            tunnel.send(message.source, "echo: " + message.data);
        }
    }).launch(ll::thread::ServerThreadExecutor::getDefault());
}

客戶端模組發送消息並等待回覆:

C++
#include "ll/api/thread/ClientThreadExecutor.h"

void talkToServer() {
    ll::coro::keepThis([]() -> ll::coro::CoroTask<> {
        auto& tunnel = SimpleTunnel::of(ll::network::PacketSide::Client);
        tunnel.send("hello");
        auto reply = co_await tunnel.receive();
        // reply.data == "echo: hello"
    }).launch(ll::thread::ClientThreadExecutor::getDefault());
}

檢查的過程

玩家登錄時,在 PlayerConnectEvent 之後,服務器會暫緩發送 StartGame。它先發送 levilamina:register,列出自己處理的數據包的運行時 ID, 再發送一個時間戳為 0 的 NetworkStackLatencyPacket 作為柵欄。LeviLamina 客戶端會在回顯柵欄之前先回復自己的 levilamina:register; 原版客戶端會丟棄不認識的數據包,只回顯柵欄。柵欄回來後,服務器檢查自己需要的數據包,然後發送 StartGame 或斷開客戶端。 客戶端在收到 StartGame 時檢查自己需要的數據包。

此後,任一端處理的數據包發生變化時,都會再發送 levilamina:register。它的內容是一個布爾值(true 表示開始處理這些數據包,false 表示不再處理), 接著是一個 64 位無符號變長整數表示的數量,後面跟著這麼多個 64 位運行時 ID。

相關模塊