Service(服務系統)
ll/api/service/ · 通用 / 服務端 / 客戶端
概述
Service 模塊是一個帶版本的服務註冊表。它讓一個 mod 通過穩定接口暴露功能, 讓其他 mod 在不直接鏈接提供方實現的前提下拿到這份功能,並允許提供方同時發佈同一 邏輯服務的多個 ABI 不兼容版本。
整個註冊表圍繞三個概念:
Service:所有服務的基類。ServiceId:由(name, version)組成、唯一標識一個服務的鍵。ServiceManager:進程級單例,協調註冊、查找、查詢與生命週期事件。 註冊服務的 mod 卸載時,它也會自動清理對應服務。
ll/api/service/Bedrock.h 另外提供了對引擎單例(Minecraft、Level、
ServerInstance、CommandRegistry 等)的訪問器。它和用戶態註冊表互不相關,
本文末尾單獨說明。
為什麼要用 Service
直接 #include "other_mod/SomeApi.h" 調另一個 mod,會帶來三個問題:
- ABI 耦合。 提供方一改接口,消費方即使不關心這個改動也得跟著重編。
- 加載順序耦合。 消費方必須自己判斷提供方是否已加載、何時加載。
- 無法做版本協商。 兩個提供方無法用同一個邏輯名字暴露不同版本。
註冊表把這三個問題都解決了:
- 消費方只依賴
Service接口(或版本化的包裝類型),不依賴提供方實現。 - 消費方調用
getService<T>()時不需要知道提供方是否在場;提供方缺失、延遲 加載或卸載時,通過Expected得到失敗結果,而不是鏈接錯誤。 - 同一邏輯服務的多個版本可以共存,並被顯式選擇。
如果你的“服務”是沒有狀態、也不需要版本化的純靜態工具,那就不需要這個模塊, 一個普通頭文件就夠了。
核心概念
Service
class Service : public std::enable_shared_from_this<Service> {
public:
virtual ~Service() = default;
[[nodiscard]] virtual ServiceId getServiceId() const noexcept = 0;
// 服务从 ServiceManager 被移除时调用。
virtual void invalidate() {}
static constexpr ServiceIdView ServiceId{EmptyServiceId};
...
};要點:
- 服務必須由
std::shared_ptr管理——基類繼承了enable_shared_from_this。 invalidate()是唯一的生命週期鉤子。ServiceManager::unregisterService移除服務時調用它。資源釋放、引用清理、取消訂閱、關閉句柄都放這裡。- 基類上的
Service::ServiceId是EmptyServiceId,不要依賴它;派生服務應當 重新定義ServiceId(見下面的ServiceImpl)。
ServiceId 與 ServiceIdView
ServiceId 就是 (name, version),外加一個預計算的組合 hash:
class ServiceId {
public:
size_t version;
std::string name;
...
};
class ServiceIdView {
public:
size_t version;
std::string_view name;
...
};ServiceId擁有 name 字符串,ServiceIdView借用它。註冊表內部存ServiceId, 外部查詢一般用ServiceIdView。- 相等與 hash 都基於二元組而非只有名字。這正是“同名不同版本可以共存”的 根本原因。
EmptyServiceId是{"", 0}。若getServiceId<T>得到與它相等的ServiceId,會產生編譯期錯誤,因此每個可用的服務都必須提供真實 ID。
ServiceImpl
ServiceImpl 是一個幫你填好 ServiceId 的便捷基類:
template <class T, size_t version>
class ServiceImpl : public Service {
public:
static constexpr ServiceIdView ServiceId{auto_name_t<T>{}, version};
ServiceId getServiceId() const noexcept override { return ServiceId; }
};關鍵細節:T 是服務名字的來源,不是派生類本身。auto_name_t<T> 使用
編譯器對 T 的類型拼寫;下文中未限定命名空間的示例得到 MyService。
由此得到兩條規則:
class MyService : public ServiceImpl<MyService, 1>:服務名就是MyService,版本1。這是最簡單的寫法。class BackendServiceV1 : public ServiceImpl<Backend, 1>和class BackendServiceV2 : public ServiceImpl<Backend, 2>:兩者都叫Backend,只有版本不同。這才是版本化服務的正確形狀。
如果你誤寫成 ServiceImpl<BackendServiceV1, 1> 和
ServiceImpl<BackendServiceV2, 2>,得到的是兩個不同的名字,不是同一個
名字的兩個版本。這是最常見的誤用點。
當類型拼寫本身就是預期契約時,ServiceImpl 很方便。如果外部服務需要固定且不依賴
編譯器類型拼寫的 ID,應直接定義 ServiceId 和 getServiceId()。
ServiceManager
進程級單例,所有狀態均為全局。由 mod 註冊的服務會在該 mod 卸載時自動移除。
ServiceManager& ServiceManager::getInstance();
template <IsService T>
Expected<std::shared_ptr<T>> getService();
Expected<std::shared_ptr<Service>> getService(ServiceIdView const& id);
template <IsService T>
event::ListenerPtr subscribeService(
std::function<void(std::shared_ptr<T> const&)> const& fn);
std::optional<QueryServiceResult> queryService(std::string_view name);
std::vector<QueryServiceResult> queryServices(std::string_view name);
bool registerService(std::shared_ptr<Service> const& service,
std::shared_ptr<mod::Mod> const& mod = mod::NativeMod::current());
bool unregisterService(ServiceIdView const& id);
void unregisterService(mod::Mod const& mod);IsService concept 要求 std::is_base_of_v<Service, T> 且 T::ServiceId
的類型是 ServiceId 或 ServiceIdView。ServiceImpl 同時滿足這兩條。
註冊表只按 ServiceId 索引;getService<T>() 不會驗證存儲對象的動態 C++ 類型。
應把 (name, version) 視為接口契約,絕不能為不兼容的類型複用它。
快速開始
定義服務
最簡、非版本化的寫法:
#include "ll/api/service/Service.h"
class MyService : public ll::service::ServiceImpl<MyService, 1> {
public:
void doSomething() {}
void invalidate() override {
// 释放引用、取消订阅等
}
};若這些聲明位於全局作用域,此時 MyService::ServiceId 就是
{"MyService", 1}。
註冊服務
#include <memory>
#include "ll/api/service/ServiceManager.h"
#include "ll/api/mod/NativeMod.h"
auto service = std::make_shared<MyService>();
bool ok = ll::service::ServiceManager::getInstance()
.registerService(service);若精確的 (name, version) 已被佔用,註冊返回 false。同名不同版本彼此獨立,
互不影響。service 參數不能為空。
註冊成功後,註冊表會持有該服務的 shared_ptr。你不必僅為維持註冊狀態而額外保留
一份引用。由 mod 註冊的服務會在該 mod 卸載時自動移除。
使用服務
#include "ll/api/service/ServiceManager.h"
auto& sm = ll::service::ServiceManager::getInstance();
if (auto res = sm.getService<MyService>(); res) {
(*res)->doSomething();
} else {
// 处理“不存在”或“版本不匹配”,见“错误处理”。
}不要把 getService 的返回值跨重載長期緩存,除非你同時訂閱了生命週期事件(見
“訂閱服務變化”)。
版本化服務
問題
假設你發佈 Backend,接口是 add(int, int)。後來需要 add(int, int, int)。
如果兩個版本共用名字 Backend 和同一個版本號,第二次註冊會失敗,消費方也無法
表達“我要帶 add(int, int) 的那一個”。
註冊表用 (name, version) 做鍵來解決這個問題:你可以同時發佈 Backend@1 和
Backend@2,消費方按自己編譯時對應的精確類型去取。
設計版本化服務
用共享的命名類型作為 T,每個版本一個包裝:
#include "ll/api/service/Service.h"
struct Backend {}; // 仅用于命名,不是服务基类。
class BackendServiceV1 : public ll::service::ServiceImpl<Backend, 1> {
public:
int add(int a, int b) { ... }
void invalidate() override { ... }
};
class BackendServiceV2 : public ll::service::ServiceImpl<Backend, 2> {
public:
int add(int a, int b, int bias) { ... }
void invalidate() override { ... }
};這些聲明位於全局作用域時,兩者的名字都是 Backend,只是版本不同。Backend
只提供穩定的名字;包裝類可以轉發到共享實現,但不必繼承 Backend。再加一層
selector,讓調用方寫 BackendService<2> 而不是具體類名:
template <size_t Version> struct BackendServiceSelector;
template <> struct BackendServiceSelector<1> { using type = BackendServiceV1; };
template <> struct BackendServiceSelector<2> { using type = BackendServiceV2; };
template <size_t Version>
using BackendService = typename BackendServiceSelector<Version>::type;
using BackendServiceLatest = BackendService<2>;消費方就可以寫:
auto v1 = sm.getService<BackendService<1>>();
auto v2 = sm.getService<BackendService<2>>();兩次調用解析到不同的 ServiceId(Backend@1、Backend@2),所以可以共存。
不要把每個具體版本同時用作類名和名字來源:
class BackendV1 : public ServiceImpl<BackendV1, 1>得到的是名字BackendV1, 不是Backend。這種形狀下做不到同名共存。
批量註冊
版本一多,手工註冊容易漏。常見做法是用“編譯期探測 + 上限斷言”:
#include <memory>
#include "ll/api/base/Meta.h"
#include "ll/api/service/ServiceManager.h"
constexpr size_t kVersionProbeLimit = 5;
auto& sm = ll::service::ServiceManager::getInstance();
auto registerVersions = [&]<template <size_t> class ServiceTemplate>() {
ll::meta::unroll<kVersionProbeLimit>([&]<size_t Index> {
constexpr size_t version = Index + 1; // 探测 1 .. kVersionProbeLimit
if constexpr (requires { typename ServiceTemplate<version>; }) {
static_assert(
version < kVersionProbeLimit,
"探测到了 kVersionProbeLimit,请在新版本加入时调高上限"
);
auto service = std::make_shared<ServiceTemplate<version>>();
if (!sm.registerService(service)) {
// 处理 ID 重复或注册失败。
}
}
});
};
registerVersions.template operator()<WidgetService>();
registerVersions.template operator()<GadgetService>();
// ...行為說明:
ll::meta::unroll<N>展開Index = 0 .. N-1,所以version = 1 .. N。if constexpr (requires { typename ServiceTemplate<version>; })會跳過 不存在的版本。- 當你新增的版本等於
kVersionProbeLimit時,requires變為真,static_assert觸發,強制你調高上限。這樣“版本 5 從來沒被註冊”會變成編譯錯誤,而不是靜默 bug。
註冊成功後註冊表會持有一份引用,局部的 shared_ptr 可以離開作用域。在 mod
加載階段註冊服務,以便在卸載時自動清理;只有需要早於 mod 卸載移除服務時,才保留
顯式 ID。
生命週期與失效
完整生命週期:
registerService(service, mod):插入服務,發出ServiceRegisterEvent。- 消費方通過
getService<T>()/subscribeService<T>()使用。 unregisterService(id):發出ServiceUnregisterEvent,調用service->invalidate(),然後從註冊表移除服務。unregisterService(mod):對該 mod 名下的所有服務做同樣的事。- mod 卸載:自動對該 mod 名下的所有服務執行同樣的移除流程。
invalidate() 是唯一的清理鉤子。不要依賴析構函數:註冊表持有 shared_ptr,
對象很可能比你自己的那份引用存活得久得多。資源釋放寫進 invalidate(),
不要寫進 ~T。
訂閱服務變化
subscribeService<T>(fn) 會先立即投遞當前服務實例(如果已存在),然後持續
投遞該精確 ServiceId 的註冊/註銷事件:
auto listener = sm.subscribeService<MyService>(
[&](std::shared_ptr<MyService> const& s) {
if (s) {
// 已注册(或订阅时已经存在)。
current = s;
} else {
// 已注销。清掉任何缓存的指针。
current.reset();
}
});細節:
- 若服務已註冊,初始回調會在
subscribeService內部同步執行一次。 - 註銷時回調收到
nullptr,這就是“可以丟棄緩存引用”的信號。ServiceUnregisterEvent在invalidate()之前發出。 - 返回的
event::ListenerPtr指向這次訂閱,但事件總線也持有該監聽器。只銷毀 自己的指針不會取消訂閱;必須顯式調用ll::event::EventBus::getInstance().removeListener(listener)。 - 只投遞
T::ServiceId對應的事件;訂閱是版本精確的。 - 初始查找與監聽器安裝是兩個獨立操作。不要在與註冊或註銷併發時訂閱;應在 mod 初始化階段完成訂閱。
這是唯一安全的“長期持有服務引用”的方式。裸 getService<T>() 的結果隨時可能在
某個註銷邊界失效。
查詢服務
兩個查詢 API,語義不同:
std::optional<QueryServiceResult> queryService(std::string_view name);
std::vector<QueryServiceResult> queryServices(std::string_view name);QueryServiceResult 是 { name, version, modName, service }。
queryService(name):返回該名字下最高的已註冊版本;名字不存在則返回std::nullopt。這是兼容性接口:“這個名字下最新的是哪個”。queryServices(name):返回該名字下的全部版本,按版本號升序。用於診斷、 展示 UI,或顯式選擇兼容版本。
兩者都返回 optional / 空數組而不是 Expected,因為對查詢而言“名字不存在”
不是錯誤,是正常的空結果。
QueryServiceResult::service 只是查詢瞬間的快照,不是持續有效的租約。查詢返回後
服務仍可能因註銷而失效;如果需要跨生命週期保持引用,請使用 subscribeService。
錯誤處理
getService<T>() 返回 Expected<std::shared_ptr<T>>,失敗時是
GetServiceError:
struct GetServiceError : ErrorInfoBase {
enum class ErrorType : char {
NotExist = 0,
VersionMismatch = 1,
};
ErrorType code;
size_t version; // 仅对 VersionMismatch 有意义
};語義:
NotExist:該名字下完全沒有註冊任何服務。VersionMismatch:名字存在,但請求的版本不存在。error.version攜帶該 名字下當前最高的已註冊版本,方便你打日誌“你請求的是 v3,目前只有 v2”。
這個區分在實踐中很重要:
auto res = sm.getService(ServiceIdView{"Backend", 3});
if (!res) {
auto& e = res.error().as<GetServiceError>();
if (e.code == GetServiceError::NotExist) {
// 提供方 mod 没装。
} else if (e.code == GetServiceError::VersionMismatch) {
// 提供方装了,但只到 v{e.version}。
// 可以考虑回退到 v{e.version},或报告不兼容。
}
}不要把 VersionMismatch 當成“沒裝”:它通常意味著提供方在場,消費方應當優雅降級。
最佳實踐
- 長期持有的引用一律走
subscribeService,不要裸用getService。 緩存的shared_ptr會活過註銷邊界,然後在已失效的服務上繼續調用。 - 清理邏輯寫進
invalidate()。 析構函數觸發得太晚、太不可控,因為註冊表自己 也持有shared_ptr。 - 在 mod 加載階段註冊服務。 mod 卸載時會自動移除;只有需要提前移除時才顯式
調用
unregisterService。 - 名字來源要選對。 單個服務用
ServiceImpl<Self, N>沒問題;版本化服務要用 一個共享的命名類型作為T,讓所有版本同名。 - 版本數量要剋制。 每個版本都是整個服務器生命週期內的活對象。發佈到 v7 就意味著 同時付七份成本。
- 把服務名和版本視為公開契約。 使用穩定且帶提供方標識的名字,絕不為不兼容的
接口複用同一個
(name, version);第一次成功註冊的實例會勝出。 - 純靜態工具不要包 service。 在這裡增加版本化和生命週期管理只會增加複雜度。
- 不要跨事件緩存
getService結果。 只在subscribeService回調裡緩存,並在 收到註銷時置空。
Bedrock 訪問器
ll/api/service/Bedrock.h 提供了對引擎實例的訪問器。它們獨立於
ServiceManager:
namespace ll::service::inline bedrock {
optional_ref<Minecraft> getMinecraft(bool isClientSide = false);
optional_ref<Level> getLevel();
optional_ref<ServerInstance> getServerInstance();
optional_ref<ServerNetworkHandler> getServerNetworkHandler(bool isClientSide = false);
optional_ref<CommandRegistry> getCommandRegistry(bool isClientSide = false);
optional_ref<RakNet::RakPeer> getRakPeer();
optional_ref<NetworkSystem> getNetworkSystem(bool isClientSide = false);
optional_ref<DBStorage> getDBStorage();
optional_ref<ResourcePackRepository> getResourcePackRepository(bool isClientSide = false);
}每個訪問器返回一個 optional_ref;在對應引擎子系統就緒之前,或在當前客戶端/
服務端目標上不可用時是空的。使用前請檢查是否為空,不要假設某個 mod 生命週期
階段它一定存在。
相關模塊
- Event(事件系統) —
ServiceRegisterEvent、ServiceUnregisterEvent - Mod(模組系統) —
mod::Mod,作為registerService的 mod 參數和 自動或手動清理的目標