Поделиться через



Июль 2015

ТОМ 30 ВЫПУСК 7

Windows и C++ - Компоненты Windows Runtime

Кенни Керр

Кенни КеррВ течение следующих нескольких месяцев я намерен исследовать основные элементы Windows Runtime. Цель состоит в расчленении абстракций более высокого уровня, используемых разработчиками в различных языковых проекциях и пакетах инструментальных средств, чтобы изучить, как Windows Runtime работает на уровне прикладного двоичного интерфейса (application binary interface, ABI) — на границе между приложениями и двоичными компонентами, которые опираются на доступ к сервисам операционной системы (ОС).

В некоторых отношениях Windows Runtime является простым результатом эволюции COM, которая, по сути, была стандартом для повторного использования двоичного кода и остается популярным способом создания сложных приложений и компонентов ОС. Однако Windows Runtime в отличие от COM имеет более узкую область применения и в основном используется как основа Windows API. Прикладные разработчики будут в большей мере склонны использовать Windows Runtime как потребителя компонентов ОС и в меньшей мере проявлять желание самостоятельно писать такие компоненты. Тем не менее, хорошее понимание того, как все разнообразные абстракции реализуются и проецируются на различные языки программирования, поможет писать более эффективные приложения и лучше диагностировать проблемы производительности и взаимодействия.

Одна из причин того, почему так мало разработчиков, понимающих, как устроена Windows Runtime (если не считать прочтения ими довольно скудной документации), заключается в том, что инструментальные и языковые проекции на самом деле затуманивают нижележащую платформу. Это может быть естественным для разработчика на C#, но определенно не комфортно для разработчика на C++, который действительно хочет знать, что происходит за кулисами. Поэтому давайте начнем с написания простого компонента Windows Runtime на Standard C++, используя командную строку в Visual Studio 2015.

Я создам простую и традиционную DLL, экспортирующую пару функций. Если вы хотите следовать за мной, создайте папку Sample, а в ней — несколько исходных файлов, начиная с Sample.cpp:

C:\Sample>notepad Sample.cpp

Первым делом я позабочусь о выгрузке DLL, которую с этого момента я буду называть компонентом. Компонент должен поддерживать запросы на выгрузку через вызов экспортируемой функции DllCanUnloadNow, и именно приложение управляет выгрузкой компонента с помощью функции CoFreeUnusedLibraries. Я не стану тратить много времени на это, так как в классической COM компоненты выгружались точно так же. Поскольку компонент не связывается статически с приложением (через LIB-файл, например), а загружается динамически функцией LoadLibrary, нужен какой-то способ, чтобы компонент в конечном счете был выгружен. Только компоненту реально известно, сколько еще ссылок на него имеется, поэтому исполняющая среда COM может вызвать его функцию DllCanUnloadNow, чтобы определить, безопасна ли его выгрузка. Все это приложения могут делать самостоятельно, используя функцию CoFreeUnusedLibraries или CoFreeUnusedLibrariesEx. Реализация достаточно прямолинейна. Мне нужна блокировка, которая будет отслеживать, сколько объектов еще ссылаются на компонент:

static long s_lock;

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

struct ComponentLock
{
  ComponentLock() noexcept
  {
    InterlockedIncrement(&s_lock);
  }
  ~ComponentLock() noexcept
  {
    InterlockedDecrement(&s_lock);
  }
};

Во все объекты, которым требуется предотвращать выгрузку компонента, можно затем встроить ComponentLock как переменную-член. Теперь функцию DllCanUnloadNow можно реализовать весьма просто:

HRESULT __stdcall DllCanUnloadNow()
{
  return s_lock ? S_FALSE : S_OK;
}

На самом деле в компоненте можно создавать объекты двух типов: фабрики активации (activation factories), которые в классической COM назывались фабриками класса (class factories), и собственно экземпляры какого-то конкретного класса. Я намерен реализовать простой класс Hen и начну с определения интерфейса IHen, чтобы курица (hen) могла кудахтать (cluck):

struct __declspec(uuid("28a414b9-7553-433f-aae6-a072afe5cebd")) __declspec(novtable)
IHen : IInspectable
{
  virtual HRESULT __stdcall Cluck() = 0;
};

Это обычный COM-интерфейс, который наследует от IInspectable, а не напрямую от IUnknown. После этого можно использовать шаблон класса Implements, описанный мной в статье за декабрь 2014 года (msdn.com/magazine/dn879357), чтобы реализовать этот интерфейс и предоставить настоящую реализацию класса Hen внутри компонента:

struct Hen : Implements<IHen>
{
  ComponentLock m_lock;
  virtual HRESULT __stdcall Cluck() noexcept override
  {
    return S_OK;
  }
};

Фабрика активации — это просто C++-класс, реализующий интерфейс IActivationFactory. Этот интерфейс предоставляет единственный метод ActivateInstance, что аналогично интерфейсу IClassFactory и его методу CreateInstance в классической COM. Классический COM-интерфейс на самом деле немного совершеннее в том плане, что позволяет вызывающему напрямую запрашивать конкретный интерфейс, тогда как IActivationFactory в Windows Runtime лишь возвращает указатель на интерфейс IInspectable. После этого приложение отвечает за вызов IUnknown-метода QueryInterface для получения более полезного интерфейса объекта. Так или иначе, это упрощает реализацию метода ActivateInstance:

struct HenFactory : Implements<IActivationFactory>
{
  ComponentLock m_lock;
  virtual HRESULT __stdcall ActivateInstance(IInspectable ** instance)
    noexcept override
  {
    *instance = new (std::nothrow) Hen;
    return *instance ? S_OK : E_OUTOFMEMORY;
  }
};

Компонент позволяет приложениям получать конкретную фабрику активации, экспортируя другую функцию — DllGetActivationFactory. Здесь вновь полная аналогия с экспортируемой функцией DllGetClassObject, которая поддерживает COM-модель активации. Основное различие в том, что нужный класс указывается строкой, а не GUID:

HRESULT __stdcall DllGetActivationFactory(HSTRING classId,
   IActivationFactory ** factory) noexcept
{
}

HSTRING — это описатель, который представляет неизменяемое строковое значение. Это идентификатор класса, возможно, «Sample.Hen», и он указывает, какая фабрика активации должна быть возвращена. К этому моменту набирается ряд причин, по которым вызовы DllGetActivationFactory могут закончиться неудачей, поэтому я начну с очистки переменной factory присваиванием ей nullptr:

*factory = nullptr;

Теперь мне нужно получить вспомогательный буфер для идентификатора класса типа HSTRING:

wchar_t const * const expected = WindowsGetStringRawBuffer(classId, nullptr);

Затем можно сравнивать это значение со всеми классами, реализуемыми моим компонентом. Пока что в нем только один класс:

if (0 == wcscmp(expected, L"Sample.Hen"))
{
  *factory = new (std::nothrow) HenFactory;
  return *factory ? S_OK : E_OUTOFMEMORY;
}

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

return CLASS_E_CLASSNOTAVAILABLE;

И это весь код на C++, который нужен для подготовки этого простого компонента к выполнению. Однако потребуется еще немного работы, чтобы превратить его в DLL, а затем описать его для компиляторов C#, не умеющих разбирать заголовочные файлы. Чтобы создать DLL, я должен задействовать компоновщик, а именно его способность определять функции, экспортируемые из DLL. Я мог бы использовать спецификатор dllexport __declspec, поддерживаемый компиляторами Microsoft, но это один из тех редких случаев, когда я предпочитаю взаимодействовать с компоновщиком напрямую, и вместо спецификатора предоставлять файл определения модуля (module-definition file) со списком экспорта. Я нахожу этот подход менее подверженным ошибкам. Поэтому возвращаемся в консоль и создаем второй исходный файл:

C:\Sample>notepad Sample.def

В этот DEF-файл нужно лишь вставить раздел EXPORTS, где перечисляются экспортируемые функции:

EXPORTS
DllCanUnloadNow         PRIVATE
DllGetActivationFactory PRIVATE

Теперь я могу предоставить файл исходного кода на C++ вместе с этим файлом определения модуля компилятору и компоновщику, чтобы создать DLL, а затем для удобства использовать простой командный файл, чтобы собрать компонент и поместить все артефакты сборки в подпапку:

C:\Sample>type Build.bat
@md Build 2>nul
cl Sample.cpp /nologo /W4 /FoBuild\ /FeBuild\Sample.dll /link /dll /def:Sample.def

Я обойду молчанием «загадочную» магию скриптового языка командного файла и сосредоточусь на ключах компилятора Visual C++. Ключ /nologo подавляет вывод заголовка с авторскими правами. Этот ключ также пересылается компоновщику. Незаменимый ключ /W4 сообщает компилятору отображать больше предупреждений по распространенным ошибкам кодирования. Ключа /FoBuild не существует. В компилятор заложено такое трудночитаемое соглашение по путям вывода, которые должны указываться за ключом (в данном случае /Fo) без пробела. Во всяком случае, ключ /Fo используется, чтобы заставить компилятор вывести объектный файл в подпапку Build. Это единственный ключ компиляции, который по умолчанию направляет вывод в папку, отличную от той, куда помещается исполняемый файл и которая определяется ключом /Fe. Ключ /link указывает компилятору, что последующие аргументы должны интерпретироваться компоновщиком. Это позволяет избежать вызова компоновщика как второй стадии. Учтите, что компоновщик в отличие от компилятора чувствителен к регистру букв в ключах и использует разделитель между именем ключа и его значением, как в случае с ключом /def, указывающим использовать файл определения модуля.

Теперь я могу довольно легко собирать свой компонент, и в получаемой подпапке Build будет содержаться ряд файлов, из которых важен только один. Естественно, это исполняемый файл Sample.dll, который можно загружать в адресное пространство приложения. Но этого не достаточно. У прикладного разработчика должен быть какой-то способ узнать, что содержит компонент. Разработчик на C++, вероятно, удовлетворился бы просмотром заголовочного файла, который включает интерфейс IHen, но даже это не особенно удобно. Windows Runtime поддерживает концепцию языковых проекций, где компонент описывается так, чтобы разные языки могли обнаруживать и проецировать его типы на свои модели программирования. Языковую проекцию я исследую в один из следующих месяцев, а пока давайте просто заставим этот пример работать из приложения на C#. Как я упоминал, компиляторы C# не знают, как разбирать заголовочные файлы C++, поэтому мне придется предоставить какие-то метаданные, которые осчастливят компилятор C#. Мне нужно создать файл WINMD, содержащий метаданные CLR, которые описывают мой компонент. Это дело непростое, так как неуправляемые типы, которые я мог использовать для ABI компонента, зачастую выглядят совершенно иначе при проецировании на C#. К счастью, компилятор Microsoft IDL был перепрофилирован для генерации файла WINMD при передаче ему IDL-файла, в котором используется несколько новых ключевых слов. Поэтому опять возвращаемся в консоль к нашему третьему исходному файлу:

C:\Sample>notepad Sample.idl

Сначала нужно импортировать определение обязательного интерфейса IInspectable:

import "inspectable.idl";

Затем определить пространство имен для типов компонента. Оно должно совпадать с именем самого компонента:

namespace Sample
{
}

Теперь требуется определить интерфейс IHen, ранее определенный на C++, но на этот раз как IDL-интерфейс:

[version(1)]
[uuid(28a414b9-7553-433f-aae6-a072afe5cebd)]
interface IHen : IInspectable
{
  HRESULT Cluck();
}

Это добрый старый IDL, и, если вы использовали IDL в прошлом для определения COM-компонентов, вам нечему здесь удивляться. Однако все типы Windows Runtime должны определять атрибут version. Раньше он был необязательным. Кроме того, все интерфейсы должны напрямую наследовать от IInspectable. Фактически в Windows Runtime нет наследования интерфейсов. Это влечет за собой некоторые негативные последствия, о которых мы поговорим в будущем.

И наконец, нужно определить сам класс Hen, используя новое ключевое слово runtimeclass:

[version(1)]
[activatable(1)]
runtimeclass Hen
{
  [default] interface IHen;
}

И вновь требуется атрибут version. Атрибут activatable, хоть и не является обязательным, указывает, что этот класс может быть активирован. В данном случае он указывает, что активация по умолчанию поддерживается через метод IActivationFactory ActivateInstance. Языковая проекция должна представлять его как конструктор по умолчанию C++ или C# либо так, как это имеет смысл в конкретном языке. Атрибут default перед ключевым словом keyword сообщает, что IHen является интерфейсом по умолчанию для класса Hen. Интерфейс по умолчанию занимает место параметров и возвращаемых типов, когда эти типы указывают сам класс. Поскольку ABI используется только в COM-интерфейсах, а класс Hen сам по себе не является интерфейсом, интерфейс по умолчанию представляет его на уровне ABI.

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

@md Build 2>nul
cl Sample.cpp /nologo /W4 /FoBuild\ /FeBuild\Sample.dll /link /dll /def:Sample.def
"C:\Program Files (x86)\Windows Kits\10\bin\x86\midl.exe" /nologo /winrt /out %~dp0Build /metadata_dir "c:\Program Files (x86)\Windows Kits\10\References\Windows.Foundation.FoundationContract\1.0.0.0" Sample.idl

Я вновь обойду молчанием магию командного файла и сосредоточусь на том, что нового появилось в ключах компилятора MIDL. Ключ /winrt — самый важный, он указывает, что IDL-файл содержит типы Windows Runtime, а не традиционные определения интерфейсов COM или в стиле RPC. Ключ /out просто обеспечивает, что файл WINMD находится в той же папке, что и DLL, так как это требуется инструментальным наборам для C#. Ключ /metadata_dir сообщает компилятору, где искать метаданные, которые использовались при сборке ОС. На момент написания этой статьи Windows SDK for Windows 10 все еще корректировался, и мне нужно быть внимательным, чтобы вызывать компилятор MIDL, который поставляется с Windows SDK, а не тот, который предоставляется по пути в командной строке Visual Studio.

Запуск командного файла теперь приводит к созданию как Sample.dll, так и Sample.winmd, на которые я могу потом ссылаться из проекта C# Windows Universal App, чтобы использовать класс Hen так, будто это просто другой проект CLR-библиотеки:

Sample.Hen h = new Sample.Hen();
h.Cluck();

Windows Runtime опирается на фундаменты COM и Standard C++. Для поддержки CLR и упрощения использования разработчиками на C# нового Windows API без необходимости в любых interop-компонентах пришлось пойти на определенные жертвы. Тем не менее, Windows Runtime является будущим для Windows API.

Я специально представил разработку компонента Windows Runtime с точки зрения классической COM и ее корней в компиляторе C++, чтобы вы могли понять происхождение этой новой технологии. Однако такой подход быстро становится непрактичным. Компилятор MIDL на самом деле предоставляет куда больше возможностей, чем простое создание файла WINMD, и среди прочего мы можем использовать его для генерации канонической версии интерфейса IHen в C++. Надеюсь, что вы присоединитесь ко мне в следующем месяце и мы вместе исследуем более надежный рабочий процесс для создания компонентов Windows Runtime; попутно мы решим несколько проблем взаимодействия (по механизму interop).


Кенни Керр (Kenny Kerr) — высококвалифицированный программист. Живет в Канаде. Автор учебных курсов для Pluralsight, обладатель звания Microsoft MVP. Ведет блог kennykerr.ca. Кроме того, читайте его заметки в twitter.com/kennykerr.

Выражаю благодарность за рецензирование статьи эксперту Microsoft Ларри Остерману (Larry Osterman).