.NET-обёртки нативных библиотек на C++-CLI

в 13:03, , рубрики: .net, C#, c++, interop

Предисловие переводчика

Данная статья представляет собой перевод главы 10 из книги Макруса Хиге (Marcus Heege) «Expert C++/CLI: .NET for Visual C++ Programmers». В этой главе разобрано создание классов-обёрток для нативных классов C++, начиная от тривиальных случаев и до поддержки иерархий и вирутальных методов нативных классов.

Идея этого перевода появилась после статьи «Unmanaged C++ library в .NET. Полная интеграция». Перевод занял больше времени, чем ожидалось, но, возможно, подход, показанный здесь, также будет полезен сообществу.

Содержание

  1. Создание обёрток для нативных библиотек
    1. Прежде, чем начать
      1. Отдельная DLL или интеграция в проект нативной библиотеки?
      2. Какая часть нативной библиотеки должна быть доступна через обёртку?
    2. Взаимодействие языков
    3. Создание обёрток для классов
      1. Отображение нативных типов на типы, соответствующие CLS
      2. Отображение исключений C++ на управляемые исключения
      3. Отображение управляемых массивов на нативные типы
      4. Отображение других непримитивных типов
      5. Поддержка наследования и виртуальных методов
    4. Общие рекомендации
      1. Упростите обёртки с самого начала
      2. Учитывайте философию .NET
    5. Заключение

Создание обёрток для нативных библиотек

Есть множество ситуаций, требующих написания обёртки над нативной библиотекой. Вы можете создавать обёртку для библиотеки, чей код вы можете изменять. Вы можете оборачивать часть Win32 API, обёртка для которой отсутствует в FCL. Возможно, вы создаёте обёртку для сторонней библиотеки. Библиотека может быть как статической, так и динамической (DLL). Более того, это может быть как C, так и C++ библиотека. Эта глава содержит практические рекомендации, общие советы и решения для нескольких конкретных проблем.

Прежде, чем начать

Перед тем, как писать код, следует рассмотреть различные подходы к написанию обёртки и их достоинства и недостатки с точки зрения как создания, так и использования вашей библиотеки.

Отдельная DLL или интеграция в проект нативной библиотеки?

Проекты Visual C++ могут содержать файлы, компилируемые в управляемый код [подробнее можно прочитать в главе 7 книги]. Интеграция обёрток в нативную библиотеку может показаться хорошей затеей, потому что тогда у вас будет на одну библиотеку меньше. Кроме того, если вы интегрируется обёртки в DLL, то клиентском приложению не придётся загружать лишнюю динамическую библиотеку. Чем меньше загружается DLL, тем меньше время загрузки, требуется меньше виртуальной памяти, меньше вероятность, что библиотека будет перемещена в памяти из-за невозможности её загрузить по изначальному базовому адресу.

Тем не менее, включение обёрток в оборачиваемую библиотеку как правило не несёт пользы. Чтобы лучше понять почему, следует отдельно рассмотреть сборку статической библиотеки и DLL.

Как бы странно это не звучало, но управляемые типы можно включить в статическую библиотеку. Однако это легко может повлечь за собой проблемы идентичности типов. Сборка, в которой определён тип, является частью его идентификатора. Таким образом, CLR может различить два типа из разных сборок, даже если имена типов совпадают. Если два разных проекта использую один и тот же управляемый тип из статической библиотеки, то этот тип будет скомпонован в обе сборки. Так как сборка является частью идентификатора типа, то получится два типа с разными идентификаторами, хотя они и были определены в одной статической библиотеке.

Интеграция управляемых типов в нативную DLL также не рекомендуется, так как загрузка библиотеки потребует CLR 2.0 во времени загрузки. Даже если приложение, использующее библиотеку, обращается только к нативному коду, для загрузки библиотеки потребуется, чтобы на компьютере была установлена CLR 2.0, и чтобы приложение не загружало более раннюю версию CLR.

Какая часть нативной библиотеки должна быть доступна через обёртку?

Как это часто бывает, очень полезно чётко определить задачи разработчика, прежде чем начинать писать код. Знаю, это звучит как цитата из второсортной книженции о разработке программ из начала 90х, но для обёрток нативных библиотек постановка задачи особенно важна.

Когда вы приступаете к созданию обёртки для нативной библиотеки, задача кажется очевидной — у вас уже есть существующая библиотека и управляемое API должно принести её функциональность в мир управляемого кода.

Для большинства проектов такого общего описания совершенно недостаточно. Без более чёткого понимания проблемы вы, вероятно, напишете по обёртке для каждого нативного класса C++ библиотеки. Если библиотека содержит больше одной единственной центральной абстракции, то зачастую не стоит создавать обёртки один-к-одному с нативным кодом. Это заставит вас решать проблемы, не связанные с вашей конкретной задачей, а также породит много неиспользованного кода.

Чтобы лучше описать задачу, подумайте над проблемой в целом. Чтобы сформулировать задачу более чётко, вам надо ответить на два вопроса:

  • Какое подмножество нативного API нужно управляемому коду?
  • Как управляемый код будет использовать различные части нативного API?

Как правило, ответив на эти вопросы, вы сможете упростить себе задачу урезанием ненужных частей и добавлением абстракций-обёрток, основанных на сценариях использования. Например, возьмём следующее нативное API:

namespace NativeLib
{
    class CryptoAlgorithm
    {
    public:
        virtual void Encrypt(/* ... пока что аргументы можно опустить ... */) = 0;
        virtual void Decrypt(/* ... пока что аргументы можно опустить ... */) = 0;
    };

    class SampleCipher : public CryptoAlgorithm
    {
        /* ... пока что реализацию можно опустить ... */
    };

    class AnotherCipherAlgorithm : public CryptoAlgorithm
    {
        /* ... пока что реализацию можно опустить ... */
    };
}

Это API даёт программисту следующие возможности:

  • создавать и использовать экземпляр SampleCipher;
  • создавать и использовать экземпляр AnotherCipherAlgorithm;
  • наследовать классы от CryptoAlgorithm, SampleCipher или AnotherCipherAlgorithm и переопределять Encrypt или Decrypt.

Реализация всех этих возможностей в управляемой обёртке гораздо сложнее, чем может показаться. Особенно трудно реализовать поддержку наследования. Как будет показано позже, поддержка виртуальных методов требует дополнительных классов-прокси, что приводит к накладным расходам во время выполнения и требует времени на написание кода.

Однако весьма вероятно, что обёртка нужна только для одного или двух алгоритмов. Если обёртка для этого API не будет поддерживать наследование, то её создание упрощается. С таким упрощением вам не придётся создавать обёртку для абстрактного класса CryptoAlgorithm. С виртуальными методами Encrypt и Decrypt можно будет работать так же, как и с любыми другими. Чтобы дать понять, что вы не хотите поддерживать наследование, достаточно объявить обёртки для SampleCipher и AnotherCipherAlgorithm как sealed классы.

Взаимодействие языков

Одной из основных целей при создании .NET было обеспечение взаимодействия разных языков. Если вы создаёте обёртку для нативной библиотеки, то возможность взаимодействия с разными языками приобретает особенную важность, поскольку разработчики, использующие библиотеку, скорее всего, будут использовать C# или другие языки .NET. Common Language Infrastructure (CLI) — это основа спецификации .NET [подробнее можно прочитать в главе 1 книги]. Важной частью этой спецификации является Common Type System (CTS). Хотя все языки .NET основываются на общей системе типов, не все языки поддерживают все возможности этой системы.

Чтобы чётко определить, смогут ли языки взаимодействовать между собой, CLI содержит Common Language Specification (CLS). CLS — это контракт между разработчиками, использующими языки .NET, и разработчиками библиотек, которые можно использовать из разных языков. CLS задаёт минимальный набор возможностей, который обязан поддерживать каждый язык .NET. Чтобы библиотеку можно было использовать из любого языка .NET, соответствующего CLS, возможности языка, используемые в публичном интерфейсе библиотеки, должны быть ограничены возможностями CLS. Под публичным интерфейсом библиотеки понимаются все типы с видимостью public, определённые в сборке, и все члены таких типов с видимостью public, public protected или protected.

Можно использовать атрибут CLSCompliantAttribute, чтобы обозначить тип или его член как соответствующий CLS. По умолчанию, не помеченные этим атрибутом типы считаются не соответствующими CLS. Если вы примените этот атрибут на уровне сборки, то по умолчанию все типы будут считаться соответствующими CLS. Следующий пример показывает, как применять этот атрибут к сборкам и типам:

[assembly: CLSCompliant(true)];  // типы с видимостью public соответствуют CLS, если не указано иное

namespace ManagedWrapper
{
    public ref class SampleCipher sealed
    {
        // ...
    };

    [CLSCompliant(false)] // этот класс явно помечен как не соответствующий CLS
    public ref class AnotherCipherAlgorithm sealed
    {
        // ...
    };
}

Согласно правилу 11 CLS все типы, присутствующие в сигнатурах членов класса (методов, свойств, полей и событий), видимых снаружи сборки, должны соответствовать CLS. Чтобы правильно применять атрибут [CLSCompliant], вы должны знать, соответствуют ли типы параметров метода CLS. Чтобы определить соответствие CLS, надо проверить атрибуты сборки, в которой объявлен тип, а также атрибуты самого типа.

В Framework Class Library (FCL) также используется атрибут CLSCompliant. mscorlib и большинство других библиотек FCL применяют атрибут [CLSCompliant(true)] на уровне сборки и помечают типы, не соответствующие CLS, атрибутом [CLSCompliant(false)].

Учтите, что следующие примитивные типы в mscorlib помечены как несоответствующие CLS: System::SByte, System::UInt16, System::UInt32 и System::UInt64. Эти типы (или эквивалентные им имена типов char, unsigned short, unsigned int, unsigned long и unsigned long long в C++) нельзя использовать в сигнатурах членов типов, которые считаются соответствующими CLS.

Если тип считается соответствующим CLS, то все его члены также считаются таковыми, если явно не указано обратного. Пример:

using namespace System;

[assembly: CLSCompliant(true)]; // типы с видимостью public соответствуют CLS, если не указано иное

namespace ManagedWrapper
{
    public ref class SampleCipher sealed // SampleCipher соответствует CLS из-за атрибута сборки
    {
    public:
        void M1(int);

        // M2 помечен как несоответствующий CLS, потому что тип одного из его аргументов не соответствует CLS
        [CLSCompliant(false)]
        void M2(unsigned int);
    };
}

К сожалению, компилятор C++/CLI не показывает предупреждений, когда тип, помеченный как соответствующий CLS, нарушает правила CLS. Чтобы понять, помечать тип как соответствующий CLS или нет, надо знать следующие важные правила CLS:

  • Имена типов и их членов должны быть различимы в языках с регистро-независимыми идентификаторами (правило 4).
  • Глобальные статические (static) поля и методы не совместимы с CLS (правило 36).
  • Пользовательские атрибуты должны содержать поля только следующих типов: System::Type, System::String, System::Char, System::Boolean, System::Int[16|32|64], System::Single и System::Double (правило 34).
  • Объекты исключений должны иметь тип System::Exception или унаследованный от него тип (правило 40).
  • Все аксессоры свойств должны быть либо виртуальными, либо не виртуальными одновременно, то есть не допускается смешение виртуальных и не виртуальных аксессоров (правило 26).
  • Упакованные значения не соответствуют CLS (правило 3). Например, следующий метод не соответствует CLS: void f(int^ boxedInt);. [см. примечание ниже]
  • Неуправляемые указатели не соответствуют CLS (правило 17). Под это правило также подпадают и ссылки в C++. Доступ к нативным классам, структурам и объединениям также осуществляется по указателю [подробнее можно прочитать в главе 8 книги]. Из этого следует, что данные нативные типы также не соответствуют CLS.

Примечание переводчика - что такое int^

В отличие от C#, в C++/CLI допускается явное указание типа упакованного значения. Например:

  System::Int32 value = 1;
  System::Object ^boxedObject = value;    // упакованное значение value; соответствует object boxedObject = value; в C#
  System::Int32 ^boxedInt = value;        // также упакованное значение value, но в текущей версии C# аналогичный код написать невозможно
  

Создание обёрток для классов C++

Несмотря на некоторое сходство системы типов в C++ и CTS в .NET, создание управляемых типов-обёрток для классов C++ часто преподносит неприятные сюрпризы. Очевидно, что если используются возможности C++, у которых нет аналогов в управляемом коде, то создание обёртки может быть затруднительным. Например, если библиотека активно использует множественное наследования. Но даже если для всех использованных возможностей C++ существуют схожие конструкции в управляемом коде, отражение нативного API в API обёртки может быть не очевидным. Давайте рассмотрим возможные проблемы.

Объявить управляемый класс с полем типа NativeLib::SampleCipher нельзя [подробнее можно прочитать в главе 8 книги]. Так как поля управляемых классов могут быть только указателями на нативные типы, следует использовать поле типа NativeLib::SampleCipher*. Экземпляр нативного класса должен быть создан в конструкторе обёртки и уничтожен в деструкторе.

namespace ManagedWrapper
{
    public ref class SampleCipher sealed
    {
        NativeLib::SampleCipher* pWrappedObject;

    public:
        SampleCipher(/* пока что аргументы можно опустить */)
        {
            pWrappedObject = new NativeLib::SampleCipher(/* пока что аргументы можно опустить */);
        }

        ~SampleCipher()
        {
            delete pWrappedObject;
        }

        /* ... */
    };
}

Кроме деструктора, также стоит реализовать финализатор [подробнее можно прочитать в главе 11 книги].

Отображение нативных типов на типы, соответствующие CLS

После того, как вы создали класс-обёртку, надо добавить ему методы, свойства и события, позволяющие клиентскому код в .NET обращаться к членам обёрнутого объекта. Для того, чтобы обёртку можно было использовать из любого языка .NET, все типы в сигнатурах членов класса-обёртки должны соответствовать CLS. Вместо беззнаковых целых чисел в сигнатурах нативного API как правило можно использовать знаковые числа такого же размера. Выбор эквивалентов для нативных указателей и ссылок далеко не всегда столь же прост. Иногда, можно использовать System::IntPtr вместо нативного указателя. В этом случае управляемый код может получить нативный указатель и передать его в качестве входного параметра для дальнейшего вызова. Это возможно, потому что на бинарном уровне System::IntPtr устроен так же, как нативный указатель. В других случаях, один или несколько параметров необходимо конвертировать вручную. Это может занять может занять много времени, но избежать этого нельзя. Рассмотрим различные варианты обёрток.

Если в нативную функцию передаётся ссылка C++ или указатель с семантикой передачи по ссылке, то в обёртке функции рекомендуется использовать отслеживаемую ссылку [см. примечание ниже]. Допустим, нативная функция имеет следующий вид:

void f(int& i);

Обёртка этой функции может выглядеть так:

void fWrapper(int% i)
{
    int j = i;
    f(j);
    i = j;
}

Примечание переводчика - что такое отслеживающая ссылка

Отслеживающая ссылка или tracking reference в C++/CLI аналогична ref и out параметрам в C#.

Для вызова нативной функции требуется передать нативную ссылку на int. Для этого аргумента необходимо осуществить маршаллинг вручную, так как преобразования типов из отслеживающей ссылки в нативную ссылку не существует. Поскольку существует стандартное преобразование типов из int в int&, используется локальная переменная типа int, которая служит в качестве буфера для аргумента, передаваемого по ссылке. Перед вызовом нативной функции буфер инициализируется значением, переданным в качестве параметра i. После возврата из нативной функции в обёртку значение параметра i обновляется в соответствии с изменениями буфера j.

Как видно из этого примера, помимо затрат на переход между управляемым и нативным кодом, обёртки зачастую вынуждены тратить процессорное время на маршаллинг типов. Как будет показано позже, для более сложных типов эти затраты могут быть значительно выше.

Следует отметить, что некоторые другие языки .NET, включая C#, различают аргументы, передаваемые по ссылке, и аргументы, используемые только для возврата значения. Значение аргумента, передаваемого по ссылке, должно быть инициализировано перед вызовом. Вызываемый метод может изменить его значение, но он не обязан этого делать. Если аргумент используется только для возврата, то он передаётся неинициализированным и метод обязан изменить или проинициализировать его значение.

По умолчанию считается, что отслеживающая ссылка означает передачу по ссылке. Если вы хотите, чтобы аргумент использовался только для возврата значения, следует применить атрибут OutAttribute из пространства имён System::Runtime::InteropServices, как показано в следующем примере:

void fWrapper([Out] int% i);

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

void f(int& i1, const int& i2);

Модификатор const транслируется в необязательный модификатор сигнатуры метода [подробнее можно прочитать в главе 8 книги]. Метод fWrapper всё равно можно вызвать из управляемого кода, даже если вызывающая сторона не воспринимает модификатор const:

void fWrapper(int% i1, const int% i2);

Для передачи указателя на массив в качестве параметра нативной функции недостаточно просто использовать отслеживающую ссылку. Чтобы разобрать этот случай, предположим, что у нативного класса SampleCipher есть конструктор, который принимает ключ шифрования:

namespace NativeLib
{
    class SampleCipher : public CryptoAlgorithm
    {
    public:
        SampleCipher(const unsigned char* pKey, int nKeySizeInBytes);
        /* ... на данный момент реализация не важна ... */
    };
}

В данном случае недостаточно просто отобразить const unsigned char* в const unsigned char%, потому что ключ шифрования, передаваемый в конструктор нативного типа, содержит более одного байта. Лучшим использовать следующий подход:

namespace ManagedWrapper
{
    public ref class SampleCipher
    {
        NativeLib::SampleCipher* pWrappedObject;
    public:
        SampleCipher(array<Byte>^ key);
        /* ... */
    };
}

В этом конструкторе оба аргумента нативного конструктора (pKey и nKeySizeInBytes) отображаются на единственный аргумент типа управляемый массив. Так можно сделать, потому что размер управляемого массива можно определить во время выполнения.

Как реализовывать этот конструктор, зависит от реализации нативного класса SampleCipher. Если конструктор создаёт внутреннюю копию ключа, переданного в качестве аргумента pKey, то можно передать закрепляющий указатель на ключ:

SampleCipher::SampleCipher(array<Byte>^ key)
{
    if (!key)
        throw gcnew ArgumentNullException("key");
    pin_ptr<unsigned char> pp = &key[0];
    pWrappedObject = new NativeLib::SampleCipher(pp, key->Length);
}

Однако закрепляющий указатель нельзя использовать, если нативный класс SampleCipher реализован следующим образом:

namespace NativeLib
{
    class SampleCipher : public CryptoAlgorithm
    {
        const unsigned char* pKey;
        const int nKeySizeInBytes;
    public:
        SampleCipher(const unsigned char* pKey, int nKeySizeInBytes)
        : pKey(pKey), nKeySizeInBytes(nKeySizeInBytes)
        {}
        /* ... на данный момент остальные части класса не важны ... */
    };
}

Этот конструктор требует, чтобы клиент не освобождал память, содержащую ключ, и указатель на ключ оставался корректным пока экземпляр класса SampleCipher не будет уничтожен. Конструктор обёртки не выполняет ни одно из этих требований. Так как обёртка не содержит дескриптора управляемого массива, сборщик мусора может собрать массив раньше, чем экземпляр нативного класса будет уничтожен. Даже если сохранить дескриптор объекта, чтобы память не освобождалась, массив может быть перемещён во время сборки мусора. В этом случае нативный указатель больше не будет указывать на управляемый массив. Чтобы память, содержащая ключ, не освобождалась и не перемещалась при сборке мусора, ключ надо скопировать в нативную кучу.

Для этого надо внести изменения как в конструктор, так и в деструктор управляемой обёртки. Следующий код показывает возможную реализацию конструктора и деструктора:

public ref class SampleCipher
{
        unsigned char* pKey;
        NativeLib::SampleCipher* pWrappedObject;
    public:
        SampleCipher(array<Byte>^ key)
        : pKey(0),
          pWrappedObject(0)
    {
        if (!key)
            throw gcnew ArgumentNullException("key");
        pKey = new unsigned char[key->Length];
        if (!pKey)
            throw gcnew OutOfMemoryException("Allocation on C++ free store failed");

        try
        {
            Marshal::Copy(key, 0, IntPtr(pKey), key->Length);

            pWrappedObject = new NativeLib::SampleCipher(pKey, key->Length);
            if (!pWrappedObject)
                throw gcnew OutOfMemoryException("Allocation on C++ free store failed");
        }
        catch (Object^)
        {
            delete[] pKey;
            throw;
        }
    }

    ~SampleCipher()
    {
        try
        {
            delete pWrappedObject;
        }
        finally
        {
            // удалить pKey, даже если деструктор pWrappedObject вызвал исключение
            delete[] pKey;
        }
    }

    /* остальная часть класса будет разобрана далее */
};

Примечание переводчика - что такое дескриптор

Дескриптор — handle — это ссылка на объект в управляемой куче. В терминах C# это просто ссылка на объект.

Если не брать в расчёт некоторые эзотерические проблемы [обсуждаются в главе 11 книги], приведённый здесь код обеспечивает корректное выделение и освобождение ресурсов даже при возникновении исключений. Если при создании экземпляра ManagedWrapper::SampleCipher возникнет ошибка, все выделенные ресурсы будут освобождены. Деструктор реализован таким образом, чтобы освободить нативный массив, содержащий ключ, даже если деструктор оборачиваемого объекта выбросит исключение.

Этот код также показывает характерные накладные расходы управляемых обёрток. Помимо накладных расходов на вызов оборачиваемых нативных функций из управляемого кода, зачастую добавляются накладные расходы на отображение между нативными и управляемыми типами.

Отображение исключений C++ на управляемые исключения

Помимо управления ресурсами, устойчивого к возникновению исключений, управляемая обёртка также должна позаботиться об отображении исключений C++, выбрасываемых нативной библиотекой, в управляемые исключения. Для примера предположим, что алгоритм SampleCipher поддерживает только 128 и 256-битные ключи. Конструктор NativeLib::SampleCipher мог бы выбрасывать исключение NativeLib::CipherException, если в него передан ключ неправильного размера. Исключения C++ отображаются в исключения типа System::Runtime::InteropServices::SEHException, что не очень удобно для потребителя библиотеки [обсуждается в главе 9 книги]. Следовательно, необходимо перехватывать нативные исключения и перебрасывать управляемые исключения, содержащие эквивалентные данные.

Для того, чтобы отобразить исключения конструктора, можно использовать блок try на уровне функции, как показано в следующем примере. Это позволит перехватить исключения, выброшенные как при инициализации членов класса, так и в теле конструктора.

SampleCipher::SampleCipher(array<Byte>^ key)
try
: pKey(0),
  pWrappedObject(0)
{
    ..// реализация не отличается от указанной ранее
}
catch(NativeLib::CipherException& ex)
{
    throw gcnew CipherException(gcnew String(ex.what()));
}

Здесь используется блок try на уровне функции, хотя инициализация членов класса в этом примере и не должна приводить к выбросу исключений. Таким образом исключения будут перехвачены, даже если вы добавите новые члены класса или добавите наследование SampleCipher от другого класса.

Отображение управляемых массивов на нативные типы

Теперь, разобравшись с реализацией конструктора, разберём методы Encrypt и Decrypt. Ранее указание сигнатур этих методов было отложено, теперь приведём их полностью:

class CryptoAlgorithm
{
public:
    virtual void Encrypt( const unsigned char* pData, int nDataLength,
                          unsigned char* pBuffer, int nBufferLength,
                          int& nNumEncryptedBytes) = 0;
    virtual void Decrypt( const unsigned char* pData, int nDataLength,
                          unsigned char* pBuffer, int nBufferLength,
                          int& nNumEncryptedBytes) = 0;
};

Данные, которые должны быть зашифрованы или расшифрованы, передаются при помощи параметров pData и nDataLength. Перед вызовом Encrypt или Decrypt следует выделить буфер памяти. Значение параметра pBuffer должно быть указателем на этот буфер, а его размер должен быть передан в качестве значения параметра nBufferLength. Размер данных на выходе возвращается при помощи параметра nNumEncryptedBytes.

Для отображения Encrypt и Decrypt можно добавить следующий метод в ManagedWrapper::SampleCipher:

namespace ManagedWrapper
{
public ref class SampleCipher sealed
{
    // ...
    void Encrypt( array<Byte>^ data, array<Byte>^ buffer, int% nNumOutBytes)
    {
        if (!data)
            throw gcnew ArgumentException("data");
        if (!buffer)
            throw gcnew ArgumentException("buffer");

        pin_ptr<unsigned char> ppData = &data[0];
        pin_ptr<unsigned char> ppBuffer = &buffer[0];
        int temp = nNumOutBytes;
        pWrappedObject->Encrypt(ppData, data->Length, ppBuffer, buffer->Length, temp);
        nNumOutBytes = temp;
    }
}

В этой реализации предполагается, что NativeLib::SampleCipher::Encrypt — это неблокирующая операция и что она завершает выполнение за разумный промежуток времени. Если вы не можете делать таких предположений, вам следует избегать закрепления управляемых объектов на время выполнения нативного метода Encrypt. Для этого можно скопировать управляемый массив в нативную память перед передачей массива в Encrypt и затем скопировав зашифрованные данные из нативной памяти в управляемый массив buffer. С одной стороны, это влечёт за собой дополнительные расходы на маршаллинг типов, с другой — предотвращает долговременное закрепление.

Отображение других непримитивных типов

Ранее все типы параметров отображаемых функций были либо примитивными типами, либо указателями или ссылками на примитивные типы. Если вам надо отображать функции, принимающие в качестве параметров классы C++ либо указатели или ссылки на классы C++, то зачастую требуются дополнительные действия. В зависимости от конкретной ситуации, решения могут быть разными. Чтобы показать различные варианты решений на конкретном примере, рассмотрим другой нативный класс, для которого требуется обёртка:

class EncryptingSender
{
    CryptoAlgorithm& cryptoAlg;

public:
    EncryptingSender(CryptoAlgorithm& cryptoAlg)
    : cryptoAlg(cryptoAlg)
    {}

    void SendData(const unsigned char* pData, int nDataLength)
    {
        unsigned char* pEncryptedData = new unsigned char[nDataLength];
        int nEncryptedDataLength = 0;

        // вызов виртуального метода
        cryptoAlg.Encrypt(pData, nDataLength,
                          pEncryptedData, nDataLength, nEncryptedDataLength);

        SendEncryptedData(pEncryptedData, nEncryptedDataLength);
    }

private:
    void SendEncryptedData(const unsigned char* pEncryptedData, int nDataLength)
    { /* отправка данных не касается проблем, обсуждаемых здесь */ }
};

Как можно догадаться о имени этого класса, его назначение — отправлять зашифрованные данные. В рамках дискуссии неважно куда отправляются данные и какой протокол при этом используется. Для шифрования можно использовать классы, наследованные от CryptoAlgorithm (например, SampleCipher). Алгоритм шифрования можно указать при помощи параметра конструктора типа CryptoAlgorithm&. Экземпляр класса CryptoAlgorithm, переданный в конструктор, используется в методе SendData при вызове виртуального метода Encrypt. Следующий пример показывает, как можно использовать EncryptingSender в нативном коде:

using namespace NativeLib;
unsigned char key[] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15};

SampleCipher sc(key, 16);
EncryptingSender sender(sc);
unsigned char pData[] = { '1', '2', '3' };
sender.SendData(pData, 3);

Чтобы создать обёртку над NativeLib::EncryptingSender, вы можете определить управляемый класс ManagedWrapper::EncryptingSender. Как и обёртка класса SampleCipher, он должен хранить указатель на обёрнутый объект в поле. Чтобы создать экземпляра оборачиваемого класса EncryptingSender требуется экземпляр класса NativeLib::CryptoAlgorithm. Допустим, единственный алгоритм шифрования, который вы хотите поддерживать, это SampleCipher. Тогда можно определить конструктор, принимающий значение типа array<unsigned char>^ в качестве ключа шифрования. Также, как и конструктор класса ManagedWrapper::SampleCipher, конструктор EncryptingSender может использовать этот массив для создания экземпляра нативного класса NativeLib::SampleCipher. Затем, ссылку на этот объект можно передать в конструктор NativeLib::EncryptingSender:

public ref class EncryptingSender
{
    NativeLib::SampleCipher* pSampleCipher;
    NativeLib::EncryptingSender* pEncryptingSender;

public:
    EncryptingSender(array<Byte>^ key)
    try
    : pSampleCipher(0),
      pEncryptingSender(0)
    {
        if (!key)
            throw gcnew ArgumentNullException("key");

        pin_ptr<unsigned char> ppKey = &key[0];
        pSampleCipher = new NativeLib::SampleCipher(ppKey, key->Length);
        if (!pSampleCipher)
            throw gcnew OutOfMemoryException("Allocation on C++ free store failed");
        try
        {
            pEncryptingSender = new NativeLib::EncryptingSender(*pSampleCipher);
            if (!pEncryptingSender)
                throw gcnew OutOfMemoryException("Allocation on C++ free store failed");
        }
        catch (Object^)
        {
            delete pSampleCipher;
            throw;
        }
    }
    catch(NativeLib::CipherException& ex)
    {
        throw gcnew CipherException(gcnew String(ex.what()));
    }

    // .. для простоты понимания надёжное освобождение ресурсов и другие методы опущены
};

При таком подходе вам не придётся отображать параметр типа CryptoAlgorithm& на управляемый тип. Однако иногда этот подход слишком ограничен. Например, вы хотите дать возможность передать существующий экземпляр SampleCipher, а не создавать новый. Для этого у конструктора ManagedWrapper::EncryptingSender должен быть параметр типа SampleCipher^. Чтобы создать экземпляр класса NativeLib::EncryptingSender внутри конструктора, надо получить объект класса NativeLib::SampleCipher, который обёрнут в ManagedWrapper::SampleCipher. Для получения обёрнутого объекта требуется добавить новый метод:

public ref class SampleCipher sealed
{
    unsigned char* pKey;
    NativeLib::SampleCipher* pWrappedObject;

internal:
    [CLSCompliant(false)]
    NativeLib::SampleCipher& GetWrappedObject()
    {
        return *pWrappedObject;
    }

    ... в остальном SampleCipher идентичен предыдущим примерам ...
};

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

public ref class EncryptingSender
{
    NativeLib::EncryptingSender* pEncryptingSender;

public:
    EncryptingSender(SampleCipher^ cipher)
    {
        if (!cipher)
            throw gcnew ArgumentException("cipher");

        pEncryptingSender =
            new NativeLib::EncryptingSender(cipher->GetWrappedObject());
        if (!pEncryptingSender)
            throw gcnew OutOfMemoryException("Allocation on C++ free store failed");
    }

    // ... в остальном код EncryptingSender идентичен предыдущим примерам ...
};

Пока что эта реализация позволяет передавать только экземпляры класса ManagedWrapper::SampleCipher. Чтобы использовать EncryptingSender с любой реализацией обёртки над CryptoAlgorithm, придётся изменить дизайн таким образом, чтобы реализация GetWrappedObject различными обёртками была полиморфной. Этого можно добиться при помощи управляемого интерфейса:

public interface class INativeCryptoAlgorithm
{
    [CLSCompliant(false)]
    NativeLib::CryptoAlgorithm& GetWrappedObject();
};

Для реализации этого интерфейса надо изменить обёртку SampleCipher следующим образом:

public ref class SampleCipher sealed : INativeCryptoAlgorithm
{
    // ...

internal:
    [CLSCompliant(false)]
    virtual NativeLib::CryptoAlgorithm& GetWrappedObject()
        = INativeCryptoAlgorithm::GetWrappedObject
    {
        return *pWrappedObject;
    }
};

Этот метод реализован как internal, потому что код, использующий библиотеку-обёртку, не должен напрямую вызывать методы обёрнутого объекта. Если вы хотите предоставить клиенту доступ непосредственно к обёрнутому объекту, вам следует передавать указатель на него при помощи System::IntPtr, потому что тип System::IntPtr соответствует CLS.

Теперь конструктор класса ManagedWrapper::EncryptingSender принимает параметр типа INativeCryptoAlgorithm^. Чтобы получить объект класса NativeLib::CryptoAlgorithm, необходимый для создания оборачиваемого экземпляра EncryptingSender, можно вызвать метод GetWrappedObject у параметра типа INativeCryptoAlgorithm^:

EncryptingSender::EncryptingSender(INativeCryptoAlgorithm^ cipher)
{
    if (!cipher)
        throw gcnew ArgumentException("cipher");

    pEncryptingSender =
        new NativeLib::EncryptingSender(cipher->GetWrappedObject());
    if (!pEncryptingSender)
        throw gcnew OutOfMemoryException("Allocation on C++ free store failed");
}

Поддержка наследования и виртуальных методов

Если вы сделаете обёртки для других алгоритмов шифрования и добавите в них поддержку INativeCryptoAlgorithm, то их тоже можно будет передать в конструктор ManagedWrapper::EncryptingSender. Однако реализовать свой алгоритм шифрования в управляемом коде и передать его в EncryptingSender пока что нельзя. Для этого потребуется сделать доработки, потому что в управляемом классе нельзя просто переопределить виртуальный метод нативного класса. Для этого придётся снова изменить реализацию управляемых классов-обёрток.

На этот раз потребуется абстрактный управляемый класс, который обернёт NativeLib::CryptoAlgorithm. Помимо метода GetWrappedObject, этот класс-обёртка должен предоставлять два абстрактных метода:

public ref class CryptoAlgorithm abstract
{
public protected:
    virtual void Encrypt( array<Byte>^ data,
                          array<Byte>^ buffer, int% nNumOutBytes) abstract;
    virtual void Decrypt( array<Byte>^ data,
                          array<Byte>^ buffer, int% nNumOutBytes) abstract;

    // оставшаяся часть этого класса будет рассмотрена позднее
};

Чтобы реализовать свой криптографический алгоритм, надо создать управляемый класс-наследник ManagedWrapper::CryptoAlgorithm и переопределить виртуальные методы Encrypt и Decrypt. Однако этих абстрактных методов недостаточно, чтобы переопределить виртуальные методы NativeLib::CryptoAlgorithm Encrypt и Decrypt. Виртуальные методы нативного класса, в нашем случае, NativeLib::CryptoAlgorithm, можно переопределить только в нативном классе-наследнике. Следовательно, надо создать нативный класс, который наследуется от NativeLib::CryptoAlgorithm и переопределяет требуемые виртуальные методы:

class CryptoAlgorithmProxy : public NativeLib::CryptoAlgorithm
{
public:
    virtual void Encrypt( const unsigned char* pData, int nNumInBytes,
                          unsigned char* pBuffer, int nBufferLen,
                          int& nNumOutBytes);
    virtual void Decrypt( const unsigned char* pData, int nNumInBytes,
                          unsigned char* pBuffer, int nBufferLen,
                          int& nNumOutBytes);

    // оставшаяся часть этого класса будет рассмотрена позднее
};

Этот класс назван CryptoAlgorithmProxy, потому что он служит посредником для управляемого класса, реализующего Encrypt и Decrypt. Его реализация виртуальных методов должна вызывать эквивалентные виртуальные методы класса ManagedWrapper::CryptoAlgorithm. Для этого CryptoAlgorithmProxy нужен дескриптор экземпляра класса ManagedWrapper::CryptoAlgorithm. Он может быть передан в качестве параметра конструктора. Чтобы сохранить дескриптор, нужен шаблон gcroot. (Так как CryptoAlgorithmProxy — это нативный класс, он не может содержать полей типа дескриптор.)

class CryptoAlgorithmProxy : public NativeLib::CryptoAlgorithm
{
    gcroot<CryptoAlgorithm^> target;

public:
    CryptoAlgorithmProxy(CryptoAlgorithm^ target)
    : target(target)
    {}

    // Encrypt и Decrypt будут рассмотрены позднее
};

Вместо того, чтобы служить обёрткой нативного абстрактного класса CryptoAlgorithm, управляемый класс служит обёрткой для конкретного наследника CryptoAlgorithmProxy. Следующий код показывает, как это сделать:

public ref class CryptoAlgorithm abstract
: INativeCryptoAlgorithm
{
    CryptoAlgorithmProxy* pWrappedObject;
public:
    CryptoAlgorithm()
    {
        pWrappedObject = new CryptoAlgorithmProxy(this);
        if (!pWrappedObject)
            throw gcnew OutOfMemoryException("Allocation on C++ free store failed");
    }
    ~CryptoAlgorithm()
    {
        delete pWrappedObject;
    }
internal:
    [CLSCompliant(false)]
    virtual NativeLib::CryptoAlgorithm& GetWrappedObject()
        = INativeCryptoAlgorithm::GetWrappedObject
    {
        return *pWrappedObject;
    }

public protected:
    virtual void Encrypt( array<Byte>^ data,
                          array<Byte>^ buffer, int% nNumEncryptedBytes) abstract;
    virtual void Decrypt( array<Byte>^ data,
                          array<Byte>^ buffer, int% nNumEncryptedBytes) abstract;
};

Как говорилось ранее, класс CryptoAlgorithmProxy должен реализовать виртуальные методы таким образом, чтобы управление передавалось эквивалентным методам ManagedWrapper::CryptoAlgorithm. Следующий код показывает, как CryptoAlgorithmProxy::Encrypt вызывает ManagedWrapper::CryptoAlgorithm::Encrypt:

void CryptoAlgorithmProxy::Encrypt( const unsigned char* pData, int nDataLength,
                        unsigned char* pBuffer, int nBufferLength,
                        int& nNumOutBytes)
{
    array<unsigned char>^ data = gcnew array<unsigned char>(nDataLength);
    Marshal::Copy(IntPtr(const_cast<unsigned char*>(pData)), data, 0, nDataLength);
    array<unsigned char>^ buffer = gcnew array<unsigned char>(nBufferLength);
    target->Encrypt(data, buffer, nNumOutBytes);
    Marshal::Copy(buffer, 0, IntPtr(pBuffer), nBufferLength);
}

Общие рекомендации

Кроме конкретных шагов, указанных ранее, следует учитывать также общие рекомендации по созданию управляемых обёрток.

Упростите обёртки с самого начала

Как видно из предыдущих разделов, создание обёрток для иерархий классов может быть очень трудозатратным. Иногда нужно создавать обёртки классов C++ таким образом, чтобы управляемые классы могли переопределять их виртуальные методы, но зачастую такая реализация не несёт практического смысла. Определение необходимой функциональности — вот ключ к упрощению задачи.

Не нужно заново изобретать колесо. Прежде чем создавать обёртку для некоторой библиотеки, убедитесь, что FCL не содержит готового класса с требуемыми методами. FCL может предложить больше, чем кажется на первый взгляд. Например, BCL уже содержит довольно много алгоритмов шифрования. Они находятся в пространстве имён System::Security::Cryptography. Если нужным вам алгоритм шифрования уже есть в FCL, вам не нужно заново создавать для него обёртку. Если FCL не содержит реализации алгоритма, для которого вы хотите создать обёртку, но приложение не завязано на алгоритм, реализованный в нативном API, то обычно предпочтительней использовать один из стандартных алгоритмов из FCL.

Учитывайте философию .NET

Ваши типы должны не только соблюдать правила CLS, но и, по возможности, вписываться в философию .NET. [Различные варианты определения типов и их членов рассмотрены в главе 5 книги.] Например:

  • отображайте классы C++ и виртуальные методы на компоненты и события .NET, когда такое отображение подходит по смыслу;
  • используйте свойства вместо методов Get и Set;
  • используйте коллекции FCL для выражения связей один-ко-многим;
  • используйте управляемые перечисления enum для отображения взаимосвязанных #define.

Помимо возможностей системы типов, также учитывайте возможности FCL. Учитывая, что FCL реализует алгоритмы, связанные с безопасностью, рассмотрите возможность взаимозаменяемости ваших алгоритмов и алгоритмов FCL. Для этого вам придётся принять дизайн, принятый в FCL, и наследовать ваши классы от абстрактного базового класса SymmetricAlgorithm и реализовывать интерфейс ICryptoTransform из пространства имён System::Security::Cryptography.

Адаптация к дизайну FCL как правило упрощает библиотеку обёрток с точки зрения потребителя библиотеки. Трудоёмкость этого подхода зависит от дизайна нативного API и дизайна типов FCL, поддержку которых вы хотите реализовать. Приемлемы ли эти дополнительные трудозатраты определяется отдельно в каждом конкретном случае. В этом примере можно предположить, что алгоритм безопасности используется только в одном конкретном случае и, следовательно, не стоит того, чтобы интегрировать его с FCL.

Если библиотека, для которой вы создаёте отображение, управляет табличными данными, следует рассмотреть классы System::Data::DataTable и System::Data::DataSet из части FCL, именуемой ADO.NET. Хотя рассмотрение этих типов и выходит за рамки данного текста, они заслуживают упоминания благодаря своей применимости в создании обёрток.

Заворачивание табличных данных в DataTable или DataSet может упростить жизнь потребителям библиотеки, потому что эти контейнеры данных широко используются в программировании для .NET. Экземпляры обоих типов можно сериализовать в XML или в бинарный формат, их можно передавать через .NET Remoting и даже веб-сервисы, они часто используются в качестве источников данных в приложениях на Windows Forms, Windows Presentation Foundation (WPF) и в приложениях ADO.NET. Оба типа поддерживают отслеживание изменений через так называемые diffgram, представления, подобные представлениям в базах данных, и фильтры.

Заключение

Для создания хороших обёрток над нативными библиотеками нужно обладать определёнными качествами и возможностями. Прежде всего, требуются хорошие способности в разработке архитектуры. Отображение нативной библиотеки один-к-одному редко является хорошим решением. Отбросив возможности, которые не нужны потребителям библиотеки, можно колоссально упростить задачу. Создание фасада для оставшихся возможностей библиотеки требует знания CLS, возможностей FCL и часто используемых в .NET подходов.

Как было показано, существует много вариантов создания обёрток для классов C++. В зависимости от возможностей, которые ваша обёртка должна предоставлять клиенту в управляемом коде, реализация может быть сложной или относительно простой. Если вам надо просто создать экземпляр и вызвать нативный класс из .NET, тогда ваша основная задача — это отобразить методы нативного класса на методы управляемого класса, соблюдая CLS. Если вам надо также учитывать иерархии нативных типов, то обёртка может стать значительно сложнее.

Создание обёртки над нативной библиотекой также предполагает обёртывание нативных ресурсов (например, неуправляемой памяти, необходимой для создания оборачиваемых объектов). Надёжное освобождение ресурсов — это тема для отдельной статьи [рассматривается в главе 11 книги].

Примечание переводчика - краткий обзор главы 11

В главе 11 рассматривается реализация паттерна IDisposable, финализация, асинхронные исключения (ThreadAbortException, StackOverflowException и т.д.) и использование SafeHandle. Хотя эта тема и представляет интерес при работе с неуправляемыми ресурсами, она также рассматривается во многих других источниках, например, у Рихтера.

Автор: MaxKot

Источник

Поделиться

* - обязательные к заполнению поля