Pull to refresh

.NET в unmanaged окружении: вызов управляемого кода из неуправляемого

Reading time 12 min
Views 5.7K
Как вы, наверное, помните из моей предыдущей статьи, взаимодействие unmanaged и managed кода представляет определенную проблему, даже для опытных разработчиков. Причина этого — необходимость понимать, какие процессы происходят при пересечении данными границы CLR.

К сожалению, нередко проблема наладить взаимодействие встает у тех разработчиков, которые слабо знакомы с подноготной технологии COM и возможностями .NET для обеспечения взаимодействия. Это нормально — нельзя знать все на свете. Потому я не буду здесь объяснять всю суть проблемы маршаллинга данных из unmanaged в managed и обратно, а просто дам несколько рабочих рецептов, которые помогут вам тогда, когда нужно срочно и завтра, и вы с тоской смотрите на английское издание книги Inside OLE и понимаете, что разобраться в этом за день нет никакой возможности.

Однако, для тех, кто неплохо в этом разбирается, в конце статьи есть небольшой бонус — способ организации out-process COM на .NET. Честно говоря, я добросовестно считал, что сделать out-process COM с помощью .NET невозможно, однако буквально вчера выяснилось, что все-таки нет, можно. В связи с этим, рассказывать про архитектуру .NET Pipe RPC я скорее всего не буду — она достаточно сложна, однако все предоставляемые ей возможности с легкостью заменяет out-process COM.

Вообще, существует два механизма, предлагаемых Microsoft — это dll export и COM export. Да, именно так — несмотря на то, что о возможности экспорта функций managed-кода мало кто знает, она все-таки есть. Однако, о ней я рассказывать не буду. Дело в том, что экспорт функций не поддерживается .NET на высоком уровне, то есть невозможно просто описать функцию, повесить на нее какой-нибудь атрибут типа [DllExport] и радоваться жизни — придется либо лезть в дебри IL, либо использовать сторонние решения. Оба решения удовлетворительными назвать нельзя, а потому я не буду на этом подробно останавливаться — практической пользы этот механизм не имеет вовсе.

Механизм COM export хорош тем, что его работа достаточно прозрачна для человека, который разбирается в COM, но для .NET разработчика это темный лес. То есть, для успешной работы с этим механизмом, необходимо понимать, что представляет собой технология COM, как осуществляются вызовы, как следует передавать параметры и прочее. однако, знания есть не у всех, а потому — общие рецепты. Давате попробуем создать inproc-COM сервер на C# и обратиться к нему, например, из VBA. Для этого воспользуемся Excel и его встроенным языком скриптов.

Почему я выбрал Excel? Как показывает опыт работы, VBA является достаточно капризным по отношению к утечкам памяти клиентом — в случае малейшой утечки Excel упадет либо сразу по завершению процедуры, либо по выгрузке dll (то есть где-то через минуту после завершения процедуры). Таким образом, падучесть Excel поможет нам понять, что мы делаем что-то не так.

Начнем, пожалуй. Давайте создадим новую сборку, назовем ее, как это принято, TestInprocCOM и сделаем там один CoClass, который будет реализовывать интерфейсы, через которые мы будем его дергать из VBA.

Но для начала, определимся с тем, что же нам нужно от взаимодействия. Как показывает опыт работы, чаще всего требуется передавать
  1. строки
  2. массивы
  3. структуры
  4. другие объекты


Венцом нашей сегодняшней работы будете передача массива структур, в которых лежат строки. Этого хватит для 99% реальных приложений. Для оставшегося 1% информация будет слишком спецефична для широкой аудитории Хабра, а потому я не буду заострять на этом внимания.

Прошу прощения за слишком длинное вступление. Также вынужден извиниться за слишком подробное объяснение многих, на первый взгляд тривиальных вещей — я не знал, каков уровень тех людей, которые будут читать эту статью и хотел сделать ее понятной абсолютно всем.

Итак, приступим.

Реализация inproc COM.



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

  // Показываем, что интерфейс экспортируется для COM
  [ComVisible(true)]
  // Определяем интерфейс как дуальный. Таким образом, мы сможем с легкостью работать с ним как с помощью
  // механизмов раннего связывания в VBA, так и с помощью позднего связывания. Кроме того, это упрощает работу
  // с объектом из C++
  [InterfaceType(ComInterfaceType.InterfaceIsDual)]
  // Определяем GUID интерфейса. Если этого не сделать - он будет меняться на каждой компиляции, что плохо.
  [Guid("5AC0488E-9CAC-4032-B59A-37B8B277C4EF")]
  public interface ITestInterface
  {
    // Для параметров функций COM-интерфейсов я всегда задаю, как именно будет маршаллиться параметр.
    // Это позволяет избежать ошибок, связанных с неверными предположениями относительно работы .NET
    // Здесь мы маршаллим строку по ссылке как BSTR.
    void Hello([MarshalAs(UnmanagedType.BStr)] ref string name);

    // Здесь аналогично, только маршаллинг задается для возвращаемого значения.
    [return: MarshalAs(UnmanagedType.BStr)]
    string Say();
  }


* This source code was highlighted with Source Code Highlighter.


Как видите, у каждого параметра появилая атрибут MarshalAs. Именно он говорит, как надо маршаллить параметры при переходе границы CLR. Его не обязательно задавать, если маршаллинг по умолчанию совпадает с тем, какое поведение вам нужно. Однако, строки по умолчанию маршаллятся как LPTStr, а потому для строк маршаллинг в виде BSTR нужно задавать всегда.

  // Теперь определим CoClass
  [ComVisible(true)]
  [Guid("B06CC21C-BCBB-4dde-8ED3-BFBD9A31AD6E")]
  // ProgId необходим для того, чтобы можно было вызвать объект по имени, а не через GUID
  [ProgId("TestInprocCOM.TestCoClass")]
  // Помечаем, что функции CoClass-а не нужно преобразовывать в собственный интерфейс и экспортировать.
  // Указав этот атрибут, мы полностью контролируем все интерфейсы, которые экспортируются для COM
  // Если указать в качестве параметра другое значение - функции, которые не являются реализациями
  // интерфейсов будут выделены в отдельный интерфейс и экспортрованы, что не есть хорошо ИМХО.
  [ClassInterface(ClassInterfaceType.None)]
  // Указываем default interface. Делать этого не обязательно для VBA, но в С++ немного облегчает жизнь.
  [ComDefaultInterface(typeof(ITestInterface))]
  public class TestCoClass : ITestInterface
  {
    public void Hello(ref string name)
    {
      name = "Hello, " + name;
    }

    public string Say()
    {
      return "OK";
    }

  }


* This source code was highlighted with Source Code Highlighter.


Объект создан. Компилируем его, после чего регистрируем в системе с помощью команды regasm /tlb:testinproccom.tlb testinproccom.dll /codebase.

Все, у нас есть зарегистрированный объекь, есть библиотека типов для него (tlb-файл) и мы можем приступать к клиенту. Для этого откроем Excel, перейдем к редактору Visual Basic, подключим через Tools->References нашу библиотеку типов и напишем следующее:

Dim a As TestCoClass
Dim s As String

Sub main()
Set a = New TestCoClass
s = "mike"
a.Hello s
MsgBox s
msgbox a.Say()
End Sub


* This source code was highlighted with Source Code Highlighter.


Нам должно вывестись два message box — первый «Hello, mike» и второй «OK». Поздравляю, мы все правильно сделали.

Однако, одними строками сыт не будешь. Попробуем передать структуру. Для этого определим ее.

  [ComVisible(true)]
  // Определяем, что все объекты структуры в памяти располагаются последовательно в том порядке, в котором
  // они должны маршалиться в unmanaged.
  [StructLayout(LayoutKind.Sequential)]
  [Guid("E1AB60D5-F8F5-41fe-BD0D-AE2AC94237DD")]
  public struct MyStruct
  {
    // Строка
    [MarshalAs(UnmanagedType.BStr)]
    public string wow;
    // Массив. Массив следует маршаллить в виде SafeArray, потому как так с ним можно работать из скриптовых
    // языков, подобных VBA или JavaScript. Конечно, в случае C++ работа с SafeArray не самая приятная
    // процедура, но это уже издержки производства. Второй параметр задает тип объектов в массиве - в нашем
    // случае это четырехбайтное знаковое целое.
    [MarshalAs(UnmanagedType.SafeArray, SafeArraySubType = VarEnum.VT_I4)]
    public int[] gig;
    // Задам маршаллинг объекта в виде указателя на интерфейс IDispatch. В этом случае .NET требует
    // чтобы параметр был определен как object.
    [MarshalAs(UnmanagedType.IDispatch)]
    public object self;
  }


* This source code was highlighted with Source Code Highlighter.


Теперь, расширим интерфейс. Допишем туда еще одну функцию.

    // Структуры всегда следует передавать из функции в out или ref параметрах - в случае передачи структуры
    // в качестве возвращаемого значения возможно некорректное поведение при обработке строк.
    void GetStruct([MarshalAs(UnmanagedType.Struct)] ref MyStruct ms);


* This source code was highlighted with Source Code Highlighter.


И реализуем ее в нашем CoClass-е.

    public void GetStruct(ref MyStruct ms)
    {
      ms.gig = new int[] { 1, 2, 3, 4, 5 };
      ms.self = this;
      ms.wow = "wow";
    }


* This source code was highlighted with Source Code Highlighter.


Как видите, мы помещаем в self параметр this — то есть после вызова этой функции в VBA у нас в этом поле будет лежать ссылка на наш объект, с которым мы сможем работать уже методами позднего связывания (то есть чере IDispatch). Проверим работоспособность объекта — перекомпилируем, перерегистрируем, опять откроем Excel и напишем следующий код:

Dim a As TestCoClass
Dim s As String
Dim b As MyStruct

Sub main()
Set a = New TestCoClass
s = "mike"
a.Hello s
MsgBox s
b.wow = "baaa"
a.GetStruct b
MsgBox b.gig(3)
MsgBox b.self.Say()
MsgBox b.wow
Set a = Nothing
End Sub


* This source code was highlighted with Source Code Highlighter.


Если все прошло хорошо, то у нас появляются следующие message box-ы
  1. Hello, mike
  2. 4
  3. OK
  4. wow


Обратите внимание на b.self.Say(). Мы вызываем функцию Say нашего объекта используя позднее связывание. Этот механизм обеспечивается благодаря тому, что мы задали интерфейс как дуальный (то есть способный работать через vtable и IDispatch одновременно). А потому лучше всегда задавать интерфейсы дуальными — это не требует никакой работы, но дает множество преимуществ.

Кстати, косвенным свидетельством того, что все работает правильно, являются подсказки среды VBA.

И наконец, коронный номер. Массив структур со строками. Определим структуру.

  [ComVisible(true)]
  [StructLayout(LayoutKind.Sequential)]
  [Guid("1D40391F-C9CF-42ed-9C9E-4991B3F22907")]
  public struct MyStruct2
  {
    [MarshalAs(UnmanagedType.BStr)]
    public string param;

    public MyStruct2(string par)
      :this()
    {
      param = par;
    }
  }


* This source code was highlighted with Source Code Highlighter.


Определим в интерфейсе функцию

    // Задаем маршаллинг в виде safearray с custom-типом параметра - нашей структурой.
    void SetMyStruct2([MarshalAs(UnmanagedType.SafeArray, SafeArrayUserDefinedSubType = typeof(MyStruct2))] out MyStruct2[] structs);


* This source code was highlighted with Source Code Highlighter.


Просто? А никто и не говорил, что будет сложно. Единственное, что требуется при задании маршаллинга — представлять, как с твоими параметрами будет работать клиент.

Теперь, реализуем функцию в CoClass.

    public void SetMyStruct2(out MyStruct2[] structs)
    {
      structs = new MyStruct2[] { new MyStruct2("bla1"), new MyStruct2("bla2"), new MyStruct2("bla3") };
    }

* This source code was highlighted with Source Code Highlighter.


Компиляция, регистрация, и — тестовый пример, который говорит нам о том, что все прошло гладко.

Dim a As TestCoClass
Dim sa() As MyStruct2

Sub main()
Set a = New TestCoClass

a.SetMyStruct2 sa
For i = 0 To 2
MsgBox sa(i).param
Next i

Set a = Nothing
End Sub


* This source code was highlighted with Source Code Highlighter.


И если все прошло нормально — то мы должны получить следующие сообщения:

  1. bla1
  2. bla2
  3. bla3


Ура нам!

Outproc COM.



Однако, архитектура inproc COM нам не всегда подходит — например, в случае, если мы хотим создать одиночку (singleton). Так как dll проецируется в поле памяти процесса, различные процессы могут поднимать один и тот же экземпляр класса, который для каждого процесса будет уникальным и ничего не будет знать о других своих экземплярах. Это не всегда то, что нам надо.

Согласно спецификациям COM, реализация outproc сервера выглядит следующим образом. Когда клиент вызывает CoGetClassObject с соответствующими параметрами, COM сначала просматриваем свою внутреннюю таблицу фабрик класса, ища заданный клиентом CLSID. Если фабрика класса в таблице отсутствует, то COM обращается к реестру и запускает соответствующий модуль EXE. Задача последнего — как можно быстрее зарегистрировать свои фабрики класса, чтобы их могла найти COM. Для регистрации фабрики класса EXE используеь функцию COM CoRegisterClassObject.

Итак, наша задача — сделать это. Для этого, создадим новый проект (console EXE application), перенесем туда код наших интерфейсов, структур и классов. Теперь, определим требуемый код регистрации в функции Main.

Согласно спецификациям COM, outproc серверы должны поддерживать саморегистрацию с ключом /register. Делается это следующим образом:

    static private void RegisterManagedType(Type type)
    {
      String strClsId = "{" + Marshal.GenerateGuidForType(type).ToString().ToUpper(CultureInfo.InvariantCulture) + "}";

      // Create the HKEY_CLASS_ROOT\CLSID key.
      using (RegistryKey ClsIdRootKey = Registry.ClassesRoot.CreateSubKey("CLSID"))
      {
        // Create the HKEY_CLASS_ROOT\CLSID\<CLSID> key.
        using (RegistryKey ClsIdKey = ClsIdRootKey.CreateSubKey(strClsId))
        {
          ClsIdKey.SetValue("", type.FullName);

          // Create the HKEY_CLASS_ROOT\CLSID\<CLSID>\LocalServer32 key.
          using (RegistryKey LocalServerKey = ClsIdKey.CreateSubKey("LocalServer32"))
          {
            LocalServerKey.SetValue("", (new Uri(Assembly.GetExecutingAssembly().CodeBase)).LocalPath);
          }
        }
      }
    }


* This source code was highlighted with Source Code Highlighter.


И наконец, функция Main.

    [MTAThread]
    static void Main(string[] args)
    {

      if ((args.Length == 1) && (args[0] == "register"))
      {
        RegisterManagedType(typeof(TestCoClass));

        Console.WriteLine("Server registered, press any key to exit");

        Console.ReadKey();

        return;
      }

      // Но как зарегистрировать фабрики класса?

    }


* This source code was highlighted with Source Code Highlighter.


А теперь у нас проблема — вызвать CoRegisterClassObject для регистрации фабрик класса. Казалось бы, простое решение — воспользоваться platform invoke для этой функции и радоваться жизни. Однако, ничего не получится — Microfost явно запрещает p/invoke для функции регистрации фабрик класса.

Решение пришло внезапно с форумов RSDN. Оказывается, в .NET существует класс с аналогом этой функции, который служит специально для этой цели, и зовется он RegistrationServices. У него есть метод RegisterTypeForComClients который делает ровненько то же самое, что требуется нам от метода CoRegisterClassObject.

      RegistrationServices rs = new RegistrationServices();

      rs.RegisterTypeForComClients(
        typeof(TestCoClass),
        RegistrationClassContext.RemoteServer | RegistrationClassContext.LocalServer | RegistrationClassContext.InProcessServer,
        RegistrationConnectionType.MultipleUse);

      Console.WriteLine("Server started, press any key to exit");

      Console.ReadKey();


* This source code was highlighted with Source Code Highlighter.


Компилируем, регистрируем, испытываем, не забыв предварительно удалить регистрацию inproc COM dll. Ура нам!

Tags:
Hubs:
+23
Comments 8
Comments Comments 8

Articles