Нативные модули
Нативный модуль - это разделяемая библиотека, .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.
Самый маленький законченный модуль приветствует каждого подключившегося игрока:
#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
приходит в клиентские файлы игрока как обычное сетевое событие.
Согласование ABI
Заголовок раздела «Согласование ABI»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; сервер стартует без него.
Сборка plugin-example
Заголовок раздела «Сборка plugin-example»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
cd examples/plugin-examplecmake -S . -B buildcmake --build build --config Releasecp build/libexample_plugin.so /opt/nodemp/modules/Windows (PowerShell)
Set-Location examples\plugin-examplecmake -S . -B buildcmake --build build --config ReleaseCopy-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).
Загрузка C-модулей Lua через require
Заголовок раздела «Загрузка C-модулей Lua через require»Ресурс не может подключить через 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 встроен - см.
Доступ к базе данных.
События, каналы и шина из C
Заголовок раздела «События, каналы и шина из C»Те же четыре вида событий, что видит ресурс, по функциям регистрации:
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.
- Справочник C ABI - каждая запись
NodeApiс индексом, аргументами и замечанием о потоках. - События - четыре вида со стороны ресурса.
- Конкурентность - рабочий поток, который колбэки делят с Lua.
- Сетевой протокол - кадры, о которых спрашивают фильтр ретрансляции.
