Network(網絡)
ll/api/network/ · 通用
概述
Network 模塊用於定義在客戶端和服務器之間收發的自定義數據包。玩家進入之前,LeviLamina 會檢查兩端是否處理了各自需要的數據包, 之後也會持續告訴每一端對方處理哪些數據包。
頭文件
| 頭文件 | 說明 |
|---|---|
ll/api/network/packet/Packet.h | Packet、PacketBase 與 PacketHandlerBase |
ll/api/network/packet/PacketRegistrar.h | PacketRegistrar 與 PacketSide,用於運行時註冊數據包 |
使用方法
定義數據包
繼承 PacketBase 並實現 write 與 read。繼承即完成註冊,不需要再調用別的函數。
#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。
class SetLabelHandler : public ll::network::PacketHandlerBase<SetLabelHandler, SetLabelPacket> {
public:
void handlePacket(
NetworkIdentifier const& source,
NetEventCallback& callback,
ll::network::PacketSide side,
SetLabelPacket const& packet
) const {
// ...
}
};發送數據包
SetLabelPacket packet;
packet.label = "hello";
packet.sendToServer(); // 客户端发往服务器
packet.sendTo(player); // 服务器发往某个玩家
packet.sendToClient(networkId, subClientId); // 服务器发往某个连接
packet.sendToClients(); // 服务器发往所有客户端sendTo 也可以傳入方塊座標與維度,或者一個實體,發給其周圍的玩家。
自定義數據包 ID 與版本
數據包在網絡上默認以類型名作為標識,因此重命名類或其命名空間都會改變 ID,分開編寫的服務端模組和客戶端模組也必須使用相同的類型名。
如需固定 ID,可聲明 CustomPacketId;它在編譯期讀取,與事件的 CustomEventId 相同。數據包的線上格式改變時,請增加
PacketVersion:版本不同的對端會把它視為另一個數據包,因此不會讀錯。
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 後才是可選的。玩家登錄時、發送世界之前,服務器會檢查客戶端是否處理了所有客戶端一側的必需數據包,
客戶端也會檢查服務器是否處理了所有服務端一側的必需數據包。缺少的一方會被斷開連接,並提示缺少哪些數據包;原版客戶端或服務器視為全部缺少。
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 檢查:
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):
if (SetLabelPacketV2::canSendToServer()) {
SetLabelPacketV2{/* ... */}.sendToServer();
} else {
SetLabelPacket{/* ... */}.sendToServer();
}運行時註冊
腳本引擎以及在運行時定義數據包的模組可以直接使用 PacketRegistrar。請傳入擁有該註冊的模組,這樣它被卸載時註冊會被移除;處理器的變化會立即告訴對端。
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 會喚醒正在等待的協程。處理器把消息放進收到它的那一端的通道;通道在它那一端第一次用到時才創建,服務器或客戶端只有一個,主持世界的客戶端兩端各一個。
每一端處理數據包的線程就是執行器運行的線程,所以隊列不需要加鎖。該數據包是必需的,所以玩家進入之後,兩端一定都有這個通道。
服務端模組和客戶端模組共用:
#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);
}
};服務端模組在啟用時啟動循環,把每條消息回顯回去:
#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());
}客戶端模組發送消息並等待回覆:
#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。
相關模塊
- Event(事件系統) — 用於數據包攔截的網絡事件