Перейти к содержимому

Нативные модули

Нативный модуль - это разделяемая библиотека, .dll на Windows и .so в остальных системах, которую сервер загружает из modules/ рядом со своим исполняемым файлом, прежде чем обойти resources/. Он получает указатель на NodeApi, структуру указателей на функции, которая и есть весь API сервера, и выполняется внутри процесса сервера. Всё, что может Lua-ресурс, может и модуль, плюс три вещи, доступные только нативному коду: научить сервер новому языку ресурсов (языковой хост), выполнять фильтр ретрансляции прямо в сетевых потоках и владеть собственными потоками и сокетами. Пишите модуль для горячего пути или ради этих трёх вещей; для игровых правил ресурс на Lua короче и перезагружается.

SDK - это один самодостаточный заголовок C, sdk/node.h, без библиотеки импорта и без внутренностей сервера, так что подходит любой инструментарий C или C++. Эта страница - руководство автора модуля; каждый указатель на функцию описан в справочнике C ABI.

Загрузчик ищет по имени ровно три символа (GetProcAddress на Windows, dlsym в остальных системах):

NODE_EXPORT uint32_t node_plugin_abi(void); /* the SDK ABI you compiled against */
NODE_EXPORT int node_plugin_init(const NodeApi* api); /* 0 = loaded, nonzero = refuse */
NODE_EXPORT void node_plugin_shutdown(void);

NODE_PLUGIN_ABI(), поставленный один раз на уровне файла, определяет первый из них за вас. node_plugin_init - место, где вы регистрируете всё: он выполняется при запуске, до загрузки любого ресурса, а указатель NodeApi, который он получает, остаётся действительным до возврата из node_plugin_shutdown. В C++ три определения помещаются внутрь extern "C" { }, как в examples/plugin-example/plugin.cpp.

Самый маленький законченный модуль приветствует каждого подключившегося игрока:

hello_module.c
#include "node.h"
static const NodeApi* api;
static void on_join(int player_id, void* user) {
char name[128];
if (api->get_player_name(player_id, name, sizeof name) < 0) {
return;
}
api->emit_client(player_id, "hello:greet", name);
api->log_info("greeted a player");
}
NODE_PLUGIN_ABI()
NODE_EXPORT int node_plugin_init(const NodeApi* a) {
api = a;
if (api->struct_size < sizeof(NodeApi)) {
api->log_error("hello_module: this server is older than the SDK it was built against");
return -1;
}
api->register_builtin_event("playerJoined", on_join, NULL);
api->log_info("hello_module loaded");
return 0;
}
NODE_EXPORT void node_plugin_shutdown(void) {
api = NULL;
}

Консоль подтверждает загруженный модуль под тегом Module строкой hello_module.dll initialized (libhello_module.so на Linux). on_join выполняется в рабочем потоке фреймворка; hello:greet приходит в клиентские файлы игрока как обычное сетевое событие.

NodeApi позиционна: модуль читает каждую возможность по смещению, зафиксированному при компиляции. При переставленной структуре он не упал бы, а вызвал то, что теперь лежит по этому смещению, - поэтому раскладка является контрактом, и у неё есть версия. Заголовок несёт NODE_ABI_VERSION_MAJOR 1 и NODE_ABI_VERSION_MINOR 12, упакованные в NODE_ABI_VERSION как major << 16 | minor.

  • Загрузчик вызывает node_plugin_abi() первым, до node_plugin_init и до обращения к любому полю. Модуль с другим мажором отклоняется: module 'x.dll' was built against SDK ABI 2.0, this server speaks 1.12 -- refusing to load it. Rebuild the module. Модуль вовсе без этого символа тоже отклоняется: module 'x.dll' does not export node_plugin_abi -- it was built against a pre-versioning SDK. Rebuild it against the current sdk/node.h.
  • Модуль, собранный против более нового минора, загружается с предупреждением (was built against a NEWER SDK (1.13 vs 1.12); it may expect capabilities this server does not have). Модуль, собранный против более старого минора, загружается молча: всё, что он знает, лежит там, где он ожидает.
  • Новые возможности добавляются в конец и поднимают минор. Ничто не переставляется и не удаляется; выведенный из употребления вызов становится заглушкой, сохраняющей своё место. Любое изменение, которое сдвигает поле, поднимает мажор.
  • Первые два члена - простые поля для вашей собственной проверки: abi_version (индекс 0) - это NODE_ABI_VERSION, с которым собран сервер, struct_size (индекс 1) - sizeof(NodeApi), как его собрал сервер. Когда struct_size меньше вашего sizeof(NodeApi), сервер старше какой-то возможности, против которой вы собирались, и читать дальше нельзя - отказывайтесь загружаться, как пример выше, или работайте с урезанным набором. Модуль dimensions отказывается, когда нет нужных ему вызовов групп видимости (dimensions: this server has no visibility groups (needs ABI 1.7)).

Модуль, вернувший из node_plugin_init ненулевое значение, выгружается со строкой module 'x.dll' failed to initialize (returned -1), skipping; сервер стартует без него.

examples/plugin-example - это эталонный модуль: экскурсия по всей поверхности NodeApi - цветной консольный тег, приёмник лога, подавляющий помеченные строки, встроенные и отменяемые события, таймеры, клиентские события (на cpp_ping он отвечает cpp_pong), хранилище, HTTP, модульный канал, фильтр ретрансляции и фоновый поток с собственным UDP-сокетом. Его CMakeLists.txt - минимальный проект для копирования: CMake 3.16, C++17, один add_library(example_plugin SHARED plugin.cpp), путь включения ../../sdk для node.h, статический CRT на Windows, чтобы у DLL не было зависимости от рантайма (CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>"), и ws2_32 для его сокета. Его project(node-example-plugin CXX) включает только C++; листингу на C нужен project(... C CXX) (или имя файла .cpp).

Linux

Terminal window
cd examples/plugin-example
cmake -S . -B build
cmake --build build --config Release
cp build/libexample_plugin.so /opt/nodemp/modules/

Windows (PowerShell)

Terminal window
Set-Location examples\plugin-example
cmake -S . -B build
cmake --build build --config Release
Copy-Item build\Release\example_plugin.dll C:\NodeMP\modules\

Результат - example_plugin.dll или libexample_plugin.so. Создайте modules/ рядом с исполняемым файлом, если её ещё нет: отсутствующую папку сервер пропускает строкой уровня отладки и ничего не загружает. examples/build.py делает то же для каждого модуля и ресурса в папке примеров: python examples/build.py --out dist/server собирает каждую папку с CMakeLists.txt в dist/server/modules/ и копирует каждую папку с resource.toml в dist/server/resources/; --only plugin-example ограничивает сборку одной папкой. Под Docker modules/ лежит внутри образа, а не на томе /data (Ресурсы и контент).

Два правила покрывают большую часть API, три исключения - остальное.

Код Выполняется в Правило
Любая функция NodeApi, которую вы вызываете вызывающем потоке, инлайн Потокобезопасна из любого потока, включая потоки, которые порождает модуль; запросы снимают состояние под внутренними блокировками, отправки и кики пишут в сеть напрямую.
Зарегистрированные колбэки: клиентские события, встроенные события, вердикты, уведомления о машинах, таймеры, done задания, ответы HTTP, сообщения шины, данные модульного канала единственном рабочем потоке фреймворка, по одному за раз Никогда два одновременно и никогда параллельно с Lua-обработчиком. Блокировка здесь останавливает каждый ресурс: держите колбэки короткими, а долгую работу отдавайте submit_job или собственному потоку.
load и unload языкового хоста главном потоке при старте (рабочего потока ещё нет); рабочем потоке при перезагрузке; потоке, останавливающем сервер, при остановке (после присоединения рабочего потока) Ни один из трёх путей не пересекается с колбэком рабочего потока. Исключение - unregister_language_host: он вызывает unload инлайн в вызывающем потоке, не останавливая рабочий.
set_log_sink том потоке, который породил строку лога, под блокировкой хука лога Быстро и без блокировок; строки, которые вы логируете изнутри приёмника, минуют его. Верните ненулевое значение, чтобы убрать строку из консоли и logs/server.log.
register_relay_filter сетевом потоке, который ретранслирует пакет, - цикле UDP, TCP-потоке клиента или рабочем потоке Чистая функция от данных модуля. Никогда не вызывайте из него функцию, меняющую состояние (seat_player, kick_player, spawn_vehicle, …); он может выполняться под внутренними блокировками. Вердикты кэшируются по (от кого, кому, категория, подтип, машина) - вызывайте invalidate_relay_cache, когда данные, которые читает фильтр, изменились.
work из submit_job фоновом потоке пула Не должен вызывать Lua; любая функция NodeApi безопасна. Его возвращаемое значение передаётся в done в рабочем потоке.

Колбэки не несут общей блокировки, так что состоянию, разделённому между рабочим потоком, приёмником и вашими потоками, нужны атомики или ваш собственный мьютекс - plugin-example считает строки приёмника в std::atomic_int, а детали, нужные только рабочему потоку, держит в обычных глобальных переменных. Колбэк-вердикт выполняется, пока сетевой поток запрашивающего клиента ждёт ответа; держите его быстрым. Таймеры никогда не срабатывают после остановки фреймворка, а фреймворк очищает приёмник лога и останавливает рабочий поток до вызова node_plugin_shutdown, так что к этому моменту ни один ваш колбэк не выполняется - там и присоединяйте собственные потоки.

Инлайновый фильтр ретрансляции - причина, по которой dimensions когда-то был модулем с фильтром; сегодня группы видимости ядра (set_player_group, set_vehicle_group) отвечают на тот же вопрос сравнением двух чисел в потоке, который держит пакет, а dimensions лишь решает, кому какое число достаётся. Для правил в духе комнат предпочитайте группы; фильтр - для правил, которые числом не выразить.

Буферы и соглашения о возвращаемых значениях

Заголовок раздела «Буферы и соглашения о возвращаемых значениях»
  • Действие возвращает 0 при успехе и -1 при неудаче; вопрос возвращает 1 для «да» и 0 для «нет». И то и другое - int, читайте описание вызываемой функции.
  • Функция, заполняющая буфер (char* buf, int buflen), возвращает число записанных байтов или -1, когда буфер мал.
  • Сначала спрашивайте размер: вызовите с buf = NULL (или buflen = 0), и она вернёт длину, которую записала бы, без NUL, так что выделяйте length + 1. -1 от запроса размера означает, что объекта нет. Делайте так для всего, что может расти, - снимка мира, JSON метрик, конфигурации машины - и выделяйте больше, чем сообщено, готовясь повторить: ответ - это снимок живого сервера, и к моменту чтения он может стать длиннее. FetchString в plugin-example - именно такой цикл.
  • Строки - UTF-8 с завершающим NUL; указатели data, переданные в колбэк, действительны только на время вызова - копируйте то, что оставляете себе.
  • Где рядом с получателем структуры есть JSON-представление - get_vehicle_json рядом с get_vehicle_info, get_player_session_json для всей сессии, - JSON несёт части переменной длины (пассажиров, белый список, теги), которые фиксированная структура вместить не может.

Языковой хост учит сервер новому виду ресурсов. Поставьте type = "js" в resource.toml, и папка передаётся тому модулю, который зарегистрировал это имя; Lua встроен и хоста не требует. Сервер никогда не узнаёт язык: хост встраивает интерпретатор, сервер лишь решает, кому отдать папку, и пересылает события.

NodeLanguageHost host = { 0 };
host.struct_size = sizeof(NodeLanguageHost);
host.type_name = "js";
host.load = js_load; /* int (const char* name, const char* dir, const char* entry, void* user) */
host.unload = js_unload; /* void (const char* name, void* user) */
if (api->register_language_host(&host) != 0) {
return -1;
}

Регистрируйте в node_plugin_init: модули загружаются до ресурсов, так что хост уже на месте, когда начинается обход. load вызывается один раз на папку ресурса с абсолютным dir и server.main из манифеста (уже проверенным на то, что остаётся внутри папки); верните 0, чтобы принять, что-то другое - чтобы отказаться: тогда сервер пишет <name> · the 'js' host refused it (returned -1) и оставляет ресурс незагруженным. unload выполняется при остановке и при reload_resource для этого имени. В каком потоке они вызываются, зависит от момента: стартовый load каждого ресурса выполняется в главном потоке, до запуска рабочего; перезагрузка выполняет unload, а затем load в рабочем потоке, в очереди с диспетчеризацией; при остановке unload выполняется в потоке, останавливающем сервер, после присоединения рабочего потока, в обратном порядке загрузки. Ни один колбэк рабочего потока не выполняется параллельно ни с одним из трёх. Клиентские файлы хостируемого ресурса упаковываются точно так же, как у Lua-ресурса: клиент выполняет Lua игры, на каком бы языке ни была написана серверная половина. register_language_host возвращает -1 при struct_size, не совпадающем с серверным (register_language_host: struct size mismatch ...; rebuild the module), или при уже занятом типе, включая встроенный lua.

Регистрации, которые хост делает, пока фреймворк вызывает его load(), - обработчики событий, таймеры, фильтры ретрансляции, подписки шины, модульные каналы, приёмник лога - приписываются этому ресурсу и сбрасываются при его выгрузке. Для регистраций, сделанных позже, из колбэка или собственного потока, скажите, кому они принадлежат, через set_resource_owner(name) и set_resource_owner(NULL) после; это действует на поток до следующего изменения. Работа, которая уже в полёте, не покрывается: завершение submit_job или http_request срабатывает независимо от того, существует ли ещё ресурс, который его запустил.

Один рабочий поток, каким бы ни был хост. Каждый колбэк, который фреймворк доставляет хостируемому ресурсу, - события, вердикты, таймеры, завершения заданий и HTTP, сообщения шины, данные модульного канала - приходит в единственном рабочем потоке фреймворка, по одному за раз, ровно как у Lua-ресурса. В NodeLanguageHost нет поля, которым можно попросить о другом (структура другого размера отвергается), и нет вызова, который заставил бы сервер диспетчеризовать в хост из нескольких потоков; хост с асинхронным рантаймом передаёт каждый колбэк дальше сам - examples/js-host ставит его в очередь и будит цикл событий Node, а ждёт сторону JavaScript только там, где фреймворку нужен ответ: load, unload, вердикт …Request и фильтр ретрансляции (с таймаутом, который разрешает). Что потокобезопасный хост может выполнять параллельно уже сегодня - всё, что не является доставкой: вызовы NodeApi из любого потока, колбэк register_relay_filter (инлайн в сетевых потоках), рабочая функция submit_job, собственные потоки. Параллельная доставка как opt-in не реализована и не запланирована. Если бы её добавили, она могла бы охватить только доставки «выстрелил и забыл»: вердикт …Request вычисляется, пока сетевой поток запрашивающего клиента ждёт, а следующий запрос зависит от ответа; коалесцированные потоковые события обещают одну ожидающую диспетчеризацию на машину; load и unload рассчитывают на то, что в это время не выполняется ни один колбэк. Всё это осталось бы последовательным по контракту.

examples/js-host - эталонный хост: модуль, который занимает type = "js" и выполняет ресурсы на Node. Он запускает рантайм по требованию при первом load, так что сервер без JavaScript-ресурсов ничего не платит; ему нужно собранное дерево исходников Node (cmake -DNODE_SRC=<path>, содержащее out/Release/libnode.lib), и, в отличие от других модулей, он линкует динамический CRT, потому что делит аллокатор с libnode.dll. Как только он в modules/, консоль печатает js-host: resources with type = "js" will run on Node, и ресурс вроде session-report загружается из своего манифеста:

name = "session-report"
version = "1.0.0"
type = "js"
[server]
main = "server/main.js"

Без хоста та же папка пропускается со строкой session-report · resource type 'js' has no language host loaded, skipping (a native module in modules/ must register one).

Ресурс не может подключить через require C-модуль Lua (probe.dll, probe.so, библиотеку с точкой входа luaopen_*): в его состоянии Lua нет C-загрузчиков. package.cpath пуст, package.loadlib удалён, а два C-искателя убраны, так что require("probe") завершается ошибкой module 'probe' not found, перечисляя только package.preload и пути .lua, которые он перебрал. require чистого Lua-файла не изменился. То же верно для черновых состояний node.job и node.await.

Причина - в сборке: сервер линкует Lua 5.4.8 статически и не экспортирует символы lua_*. C-модуль, который импортирует их у хоста, не загружается; модуль, который линкует собственный Lua - единственный способ собраться против статического Lua, - запустил бы второй рантайм Lua поверх lua_State хоста, что работает для тривиальной таблицы и не определено для всего, что трогает GC, таблицу строк или luaL_error. Чтобы это не выглядело работающим, загрузчики выключены.

Нативный код идёт путём, описанным на этой странице: модуль в modules/ под C ABI или языковой хост, встраивающий собственный рантайм. Для базы данных node.pg встроен - см. Доступ к базе данных.

Те же четыре вида событий, что видит ресурс, по функциям регистрации:

  • register_client_event(name, cb, user) - сетевое событие от клиента; cb(player_id, data). Отвечайте через emit_client(id, event, data), emit_all(event, data) или emit_others(except_id, event, data); варианты _bytes (emit_client_bytes, emit_all_bytes) несут нагрузки с байтами NUL.
  • register_builtin_event(name, cb, user) - наблюдающая форма событий движка (playerJoined, vehicleSpawned, serverTick, …); cb(id) несёт идентификатор игрока или, для событий машин, глобальный идентификатор машины. Отменяемые имена тоже можно наблюдать здесь, без права вето.
  • register_vehicle_event(name, cb, user) - vehicleEdited, vehicleReset, vehiclePainted, playerSeatChanged с (player_id, global_id, data).
  • register_cancellable_event(name, cb, user) - колбэк-вердикт для девяти имён …Request; верните ненулевое значение, чтобы отказать, и запишите причину в данный вам буфер. Выполняются все обработчики; одного вето достаточно для отказа. vehicleNodeGrabRequest закрыт по умолчанию: регистрация обработчика, возвращающего 0, - способ, которым модуль включает захват нод.
  • Имена событий пересекают C ABI строками, и написания серверов до 1.2.0 ("playerJoin", "onPlayerConnectRequest", "onVehicleSpawnRequest", …) принимаются каждым входом register_*_event / unregister_*_event как устаревшие псевдонимы: сервер отображает их на каноническое имя и пишет одно предупреждение на модуль на старое имя за всё время работы процесса сервера ([deprecated] event "playerJoin" is now "playerJoined" (module my_module.dll) - модуль тот, чей node_plugin_init выполняется). Модуль, собранный против старого SDK, продолжает работать; пересоберите с новыми именами при следующем изменении - псевдонимы уходят в 2.0. См. События → Именование.
  • register_module_channel(channel, cb, user) и send_module(player_id, channel, data, len) - двоичный канал; player_id -1 рассылает всем синхронизированным клиентам через фильтр ретрансляции.
  • register_resource_event(name, cb, user) и emit_resource_event(name, data) - шина, общая с Lua-ресурсами; модуль виден как источник "native".

У каждой register_* есть двойник unregister_* с теми же cb и user; уже начатая доставка может выполнить ещё один, последний вызов. Таймеры - set_timeout, set_interval и clear_timer; хранилище - storage_set/storage_get/storage_delete с собственным именем хранилища (NULL означает хранилище вызывающего ресурса и отклоняется из потока, где ресурса в контексте нет); http_request и submit_job - асинхронная пара, чьи колбэки приземляются в рабочем потоке. Справочник событий называет каждое событие с его регистрацией в C.