RiverLogic Inc. — инструменты развёртывания и провижининга
Часть инструментов развёртывания и провижининга RiverLogic для on-premise: некоторым нативным, унаследованным компонентам требовалось выполнять логику — проверку предустановленных требований SQL Server, патчинг connection string и конфигурационных файлов, управление Windows-службами, проверку учётных данных — которую было бы гораздо более трудоёмко и рискованно переписывать на C++, чем оставить в существующей, хорошо протестированной кодовой базе на C#. Вместо переноса этой логики на нативный код я реализовал тонкий нативный мост, который хостит рантайм CoreCLR прямо внутри процесса и вызывает C#-реализацию напрямую.
Нативный компонент линкуется с nethost и использует официальный hosting API .NET
(hostfxr.h, coreclr_delegates.h) — тот же низкоуровневый механизм,
которым сам .NET пользуется для собственной загрузки — чтобы найти и инициализировать рантайм
CoreCLR под конкретный .runtimeconfig.json. Затем он получает «сырой» указатель на
функцию, соответствующую статическому C#-методу с сигнатурой заданного unmanaged-совместимого
делегата, и вызывает его напрямую. Никакого COM, никакого выхода в отдельный процесс через RPC,
никакой смешанной компиляции C++/CLI — только hosting API, который документирует и поставляет
Microsoft, применённый для связи с уже существующей managed-кодовой базой из нативного компонента,
который не может просто стать managed.
Перед тем как managed-код сможет выполниться, происходят два шага. Сначала nethost
находит hostfxr — компонент, который умеет выбрать нужный установленный рантайм
.NET — и его три экспортируемые функции загружаются вручную через GetProcAddress.
Затем этот дескриптор hostfxr используется для инициализации рантайма под конкретный
.runtimeconfig.json и возврата единственного по-настоящему важного типа делегата:
load_assembly_and_get_function_pointer, на котором строится всё остальное в этом мосту.
nativehost.cpp — загрузка рантайма
bool load_hostfxr(wchar_t const* root_path)
{
char_t buffer[MAX_PATH];
size_t buffer_size = sizeof(buffer) / sizeof(char_t);
if (get_hostfxr_path_wrapper(root_path, buffer, &buffer_size) != 0)
return false;
void* lib = load_library(root_path, buffer);
init_fptr = (hostfxr_initialize_for_runtime_config_fn)
get_export(root_path, lib, "hostfxr_initialize_for_runtime_config");
get_delegate_fptr = (hostfxr_get_runtime_delegate_fn)
get_export(root_path, lib, "hostfxr_get_runtime_delegate");
close_fptr = (hostfxr_close_fn)
get_export(root_path, lib, "hostfxr_close");
return (init_fptr && get_delegate_fptr && close_fptr);
}
load_assembly_and_get_function_pointer_fn get_dotnet_load_assembly(const char_t* config_path)
{
void* load_assembly_and_get_function_pointer = nullptr;
hostfxr_handle cxt = nullptr;
int rc = init_fptr(config_path, nullptr, &cxt);
if (cxt == nullptr)
{
std::cerr << "Init failed: " << std::hex << std::showbase << rc << std::endl;
close_fptr(cxt);
return nullptr;
}
rc = get_delegate_fptr(cxt, hdt_load_assembly_and_get_function_pointer,
&load_assembly_and_get_function_pointer);
if (rc != 0 || load_assembly_and_get_function_pointer == nullptr)
std::cerr << "Get delegate failed: " << std::hex << std::showbase << rc << std::endl;
close_fptr(cxt);
return (load_assembly_and_get_function_pointer_fn)load_assembly_and_get_function_pointer;
}
Пример от Microsoft, на котором основан этот механизм, демонстрирует загрузку одной захардкоженной функции. Я обобщил его в переиспользуемый загрузчик: получая корень рантайма, имя сборки, namespace, имя функции и имя типа делегата, он возвращает готовый к вызову указатель на функцию для любого экспортируемого C#-метода — так что добавление нового вызываемого-из-нативного-кода метода сводится к одной строке, а не к копированию всей процедуры хостинга.
nativehost.cpp (обобщённая версия)
void* dotnet_clr_init(string_t root_path, string_t assembly_name,
string_t func_namespace, string_t func_name,
string_t delegate_name)
{
bool ok = load_hostfxr(root_path.c_str());
assert(ok && "Failure: load_hostfxr()");
const string_t config_path = root_path + assembly_name + STR(".runtimeconfig.json");
auto load_assembly_and_get_function_pointer = get_dotnet_load_assembly(config_path.c_str());
assert(load_assembly_and_get_function_pointer != nullptr);
const string_t dotnetlib_path = root_path + assembly_name + STR(".dll");
const string_t dotnet_type = assembly_name + STR(".") + func_namespace + STR(", ") + assembly_name;
void* result = nullptr;
int rc = load_assembly_and_get_function_pointer(
dotnetlib_path.c_str(), dotnet_type.c_str(),
func_name.c_str(), delegate_name.c_str(), nullptr, (void**)&result);
assert(rc == 0 && result != nullptr);
return result;
}
// Один нативный экспорт с использованием загрузчика выше
long WINAPI GetSqlServerVersion(HWND owner, wchar_t const* root_path,
wchar_t const* connection_string, char* out_result)
{
typedef long (CORECLR_DELEGATE_CALLTYPE* get_version_fn)(HWND, wchar_t const*, char*);
auto get_version = (get_version_fn)dotnet_clr_init(
root_path, STR("DeploymentSupportNET"), STR("FuncFacade"),
STR("GetSqlServerVersion"),
STR("DeploymentSupportNET.FuncFacade+GetSqlServerVersionDelegate, DeploymentSupportNET"));
return get_version(owner, connection_string, out_result);
}
Нативный вызывающий код не резолвит экспорты этой DLL по имени на этапе линковки — он вызывает
конкретный символ, декорированный под __stdcall. Для каждого экспорта явно объявляется
корректный по calling convention линкерный алиас, а заголовочный файл содержит и объявление
extern "C", и соответствующий typedef указателя на функцию для вызывающих,
загружающих DLL динамически.
FuncFacadeNative.h — объявления экспортов
#ifdef DEPLOYSUPPORT_EXPORTS
#define DEPLOYSUPPORT_API __declspec(dllexport)
#pragma comment(linker, "/export:GetSqlServerVersion=_GetSqlServerVersion@16")
#else
#define DEPLOYSUPPORT_API __declspec(dllimport)
#endif
extern "C" long DEPLOYSUPPORT_API WINAPI GetSqlServerVersion(
HWND owner, wchar_t const* root_path,
wchar_t const* connection_string, char* out_result);
typedef long (WINAPI *GetSqlServerVersionFunc)(
HWND owner, wchar_t const* root_path,
wchar_t const* connection_string, char* out_result);
На стороне C# каждая вызываемая из нативного кода функция устроена одинаково: делегат, на который может забиндиться нативный загрузчик, маршалинг «сырых» строковых указателей с учётом платформы, структурированное логирование при входе в метод и централизованная обработка ошибок, которая показывает сбой пользователю, а не молча роняет нативный хост.
FuncFacade.cs (обобщённая версия)
public static class FuncFacade
{
public delegate int GetSqlServerVersionDelegate(
IntPtr owner, IntPtr connectionStringPtr, StringBuilder result);
public static int GetSqlServerVersion(
IntPtr owner, IntPtr connectionStringPtr, StringBuilder result)
{
string connectionString = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? Marshal.PtrToStringUni(connectionStringPtr)
: Marshal.PtrToStringUTF8(connectionStringPtr);
try
{
Logging.WriteLogEntry(nameof(GetSqlServerVersion),
$"invoked with connectionString={connectionString}");
string value = SqlSupport.ExecuteSqlScalar(
connectionString, "SELECT SERVERPROPERTY('ProductVersion')");
result.Clear().Append(value);
return true.GetHashCode();
}
catch (Exception ex)
{
Logging.WriteErrorLogEntry(nameof(GetSqlServerVersion), ex.ToString());
ShowMessageBox(owner, nameof(GetSqlServerVersion), ex.Message);
return false.GetHashCode();
}
}
public delegate int IsValidIdentifierDelegate(IntPtr owner, IntPtr inputPtr);
public static int IsValidIdentifier(IntPtr owner, IntPtr inputPtr)
{
string input = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? Marshal.PtrToStringUni(inputPtr)
: Marshal.PtrToStringUTF8(inputPtr);
try
{
Logging.WriteLogEntry(nameof(IsValidIdentifier), $"invoked with input={input}");
return InputValidation.IsAlphaNumeric(input) ? true.GetHashCode() : false.GetHashCode();
}
catch (Exception ex)
{
Logging.WriteErrorLogEntry(nameof(IsValidIdentifier), ex.ToString());
ShowMessageBox(owner, nameof(IsValidIdentifier), ex.Message);
return false.GetHashCode();
}
}
}
Та же самая форма и для однострочной проверки валидности, и для полноценного обращения к SQL — шаблонный код (маршалинг, логирование, показ ошибок) идентичен в обоих случаях, так что добавление нового связанного метода почти целиком сводится к одной строке настоящей логики.
hostfxr/nethost, а не через высокоуровневую обёртку или C++/CLI.__stdcall (#pragma comment(linker, "/export:...")), с точным соответствием calling convention, которого ждёт вызывающий код.Это обобщённое, очищенное от лишнего описание реальной production-техники, использованной в инструментах развёртывания RiverLogic — имена функций, специфичная для продукта логика и бизнес-детали абстрагированы. Готов подробно разобрать реальную реализацию по запросу.