.NET-обгортки нативних бібліотек на C++/CLI

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

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

Зміст
  1. Створення обгорток для нативних бібліотек

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

Створення обгорток для нативних бібліотек
Є безліч ситуацій, що вимагають написання обгортки над нативної бібліотекою. Ви можете створювати обгортку для бібліотеки, чий код ви можете змінювати. Ви можете обертати частина 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. Хоча ця тема і представляє інтерес при роботі з некерованими ресурсами, вона також розглядається в багатьох інших джерелах, наприклад, у Ріхтера.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.