← Назад к портфолио EN

Мост между нативным C++ и .NET 8

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);

Managed-сторона: единый паттерн делегатов и маршалинга

На стороне 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 — шаблонный код (маршалинг, логирование, показ ошибок) идентичен в обоих случаях, так что добавление нового связанного метода почти целиком сводится к одной строке настоящей логики.

Практический результат

Это обобщённое, очищенное от лишнего описание реальной production-техники, использованной в инструментах развёртывания RiverLogic — имена функций, специфичная для продукта логика и бизнес-детали абстрагированы. Готов подробно разобрать реальную реализацию по запросу.