love5an: (Default)
Dmitry Ignatiev ([personal profile] love5an) wrote2011-04-06 12:12 am

Out-of-Process COM-сервер: Common Lisp, C#, C++

Продолжаем серию постингов "C++ - говно". На этот раз у нас в гостях C#, а тема - написание COM-компонентов.

В интернете, да и не только в интернете, я много раз слышал, что COM - крайне неудобная в использовании технология, громоздкая и переусложненная. А теперь еще к этому добавляются реплики о том, что и устаревшая.

На самом деле это не так. COM в своей основе крайне прост, и является одной из самых удобных технологий для интероперабельности между различными платформами, рантаймами языков программирования и программами. Для случая RPC же, COM вообще является чуть ли не самой простой и удобной из всех таких технологий, и одной из наименее затратных по памяти и производительности(особенно в сравнении со всякими XML-RPC).

COM это в первую очередь бинарный стандарт простой объектной модели; настолько простой, что в ней нет даже наследования реализации, а есть только наследование интерфейсов. Во вторую очередь, это некоторые средства для создания компонентов программ и операционной системы Windows(на самом деле, COM вполне себе кроссплатформенная технология, но по некоторым причинам прижился он только на винде - на всяких там линуксах как всегда предпочитают велосипедить свое(типа D-Bus), совершенно не оглядываясь на существующие технологии, естественно).

Объектная модель COM обладает строгим ABI, полиморфизмом, двумя видами связывания(раннее - vtbl и позднее - IDispatch), кое-какой интроспекцией(а в случае с IDispatch даже очень ничего такой) и некоторыми другими фишками. Конечно, не CLOS, и даже не Smalltalk, но для ОО-системы, которую можно использовать из практически любого языка программирования, от Си до Visual Basic - очень даже неплохо.

Причина, по которой многие так не любят COM, на мой взгляд, заключается в том, что долгое время основными языками программирования для работы с ним, были C++ и Visual Basic(VB, VBA). Оба, мягко говоря, не ахти.
Но, если из VB с COM-компонентами работать всегда было более-менее удобно, то в C++ использование COM, а особенно написание своих компонентов, превращалось в настоящий ад; особенно в случае использования позднего связывания(а его, во времена расцвета VB, надо было использовать часто - любой уважающий себя компонент должен был реализовывать IDispatch, чтобы с ним мог работать Visual Basic и другие подобные скриптовые языки).

Чтобы как-то облегчить нелегкую жизнь писателя компонентов на C++, Microsoft нагородила в свое время кучи костылей, оборачивающих COM в C++ные классы и шаблоны, и даже добавила специальные ключевые слова в свой компилятор. Не сказать, правда, чтобы это очень уж помогло, скорее наоборот - ко всем недостаткам C++, со которыми программисту на C++ приходилось бороться, ко всей его сложности, добавились сложности и недостатки всех этих ATL и MFC, которые, кстати, долгое время были полны багов и глюков, что тоже добавляло "радостей" программисту.

Каков итог всего это, мы сегодня прекрасно знаем - несмотря на то, что писателей на плюсах все эти недостатки и сложности не останавливали, сама MS в итоге в этой куче говна захлебнулась, начала сворачивать поддержку упомянутых фреймворков, да и самого C++, и выкатила на всеобщее обозрение свой .NET

Из .NET с COM работать действительно намного проще и приятнее, чем из C++, при этом, .NET ограничениями Visual Basic не страдает, но давайте уже рассмотрим пример, который я тут подготовил.

А пример такой: создаем out-of-procss COM-сервер, у которого есть интерфейс, реализующий две функции для операции над векторами из 4х float.

Почему out-of-process? Потому, что это интереснее, и потому, что многие лиспы не умеют собираться в .dll :)

Вообще, что такое out-of-process сервер? Ну, во-первых, это такой COM-компонент, реализующий некоторый, кому-нибудь нужный, функционал. Грубо говоря, это такая штука, в которой живут COM-объекты. Отличается он от in-process сервера тем, что реализуется он не как .dll, а как отдельное приложение, .exe, отдельный процесс в операционной системе(возможно даже и на другом компьютере, но в примере - на локальном), с которым клиенты общаются через RPC.

Преимущества перед inproc-сервером заключаются в большей надежности и безопасности(адресное пространство то разное!), и, в теории, в меньшем расходе памяти, а вот главный недостаток это производительность(вызовы методов и их аргументы надо маршалить и передавать между процессами).

Несмотря на такие вот кардинальные различия, внешне работа с COM-объектам out-of-proc сервера практически ничем не отличается от работы с таковыми из inproc dll'ки. Достигается это за счет вышеупомянутого маршалинга и промежуточных прокси-объектов, представляющих объекты, живущие в процессе-сервере.

Содержание IDL-файла для примера следующее:

[
    uuid(AD814CEC-0C0E-492E-9C24-CE6494233A76),
    helpstring("VectorMath Type Library"),
    version(1.0)
]
library VectorMath
{
    importlib("stdole32.tlb");
    
    [
        uuid(D5BC0063-BFD3-4272-834B-196F9F803BF2),
        oleautomation
    ]
    interface IVectorMath : IUnknown
    {
        HRESULT VectorSum([out] float result[4],
                          [in] float v1[4],
                          [in] float v2[4]);

        HRESULT VectorDot([in] float v1[4],
                          [in] float v2[4],
                          [out, retval] float* dotProduct);
    }

    [ uuid(6047851B-D193-4FD1-A6AD-9C862C5F98FD) ]
    coclass VectorMath
    {
        interface IVectorMath;
    };
}

Если на него натравить midl-компилятор от Microsoft, мы получим файл с расширением .tlb.
Этот файл, а также класс и интерфейс, реализуемый нашим компонентом, надо перед использованием зарегистрировать в реестре Windows.

Что вообще такое tlb, и зачем нужна регистрация в реестре? .tlb расшифровывается как Type Library, "Библиотека Типов" - файл, содержащий описания типов, интерфейсов и классов, которые реализует наш компонент.
Регистрировать в реестре все это дело надо вот для чего:
  • Во-первых, для того, чтобы рантайм COM смог найти наш компонент в системе. Когда мы создаем объект класса, мы не указываем его точное местоположение, мы просто передаем в функции, занимающиеся созданием объектов, 16-байтовый идентификатор класса, и вместе с ним 16-байтовый идентификатор интерфейса, через который мы с созданным объектом будем общаться.
  • Во-вторых, через библиотеку типов в COM реализуется интроспекция - различные утилиты и программы, используя ее, могут посмотреть, что собственно наш компонент из себя представляет.
  • И в-третьих, и для этого вот примера это очень важно - если мы поставляем с нашим компонентом библиотеку типов, мы можем использовать стандартный способ маршалинга, предоставляемый рантаймом COM. Естественно, не зная, что наши интерфейсы и классы из себя представляют, рантайм COM маршалить их не сможет, и нам придется городить свои прокси-dll'ки, в которых мы ручками должны будем со всем этим работать.

Теперь описание реализации на Лиспе.

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

(defpackage #:vector-math
  (:use #:cl #:virgil #:doors #:doors.com #:doors.ui)
  (:export #:main #:build-executable))

(in-package #:vector-math)

(define-struct (float4
                (:constructor make-float4)
                (:constructor float4 (x y z w))
                (:type (vector single-float)))
  (x float)
  (y float)
  (z float)
  (w float))

Пакет импортирует символы из пяти других. `CL' это стандартная библиотека Common Lisp. Virgil - моя библиотека-FFI для маршалинга лисповых данных в неуправляемую память(кстати, я таки сподобился написать для нее документацию, скоро опубликую), а остальные три - из моей библиотеки Doors, которая представляет собой интерфейс к Windows API, в том числе к COM и OLE; она пока что в стадии разработки, многого еще нет, например не допилен OLE Automation(тот самый IDispatch).

В пакете `doors' определены какие-то базовые вещи, вроде базовых типов, с которыми работает Windows(числовые типы, строки, и так далее), там же находятся разнообразные базовые системные функции(работа с процессами, консолью, и подобное). `doors.com' реализует интерфейс к COM, а в `doors.ui' находятся, например, биндинги к Windows USER (в примере это потребуется для организации цикла сообщений STA).

Макрос define-struct это надстройка над лисповым defstruct, и очень похож на последний. Он добавляет к структуре некоторую метаинформацию о типе, необходимую для маршалинга.
В данном случае у нас вектор из 4х float, а так как в опциях присутствует :type, то вместо структуры определяется алиас на (simple-array single-float (4))

Теперь надо описать интерфейс, который мы будем реализовывать:

(define-interface vector-math
    ("{D5BC0063-BFD3-4272-834B-196F9F803BF2}" unknown) ;; (IID Parent)
  (vector-sum (hresult rv result)
    (result (& float4 :out) :optional)
    (v1 (& float4))
    (v2 (& float4)))
  (vector-dot (hresult rv dot-product)
    (v1 (& float4))
    (v2 (& float4))
    (dot-product (& float :out) :aux)))


Получилось очень похоже на IDL. Вообще, я стремился сделать интерфейс doors настолько декларативным, насколько возможно. Как уже видно, и как будет видно дальше, никакой ручной работы по, например, маршалингу нам делаться не придется, а придется только описывать и определять.

define-interface раскрывается в определение CLOS-класса и методов обобщенных функций, специализированных на этом классе. Первым параметром в этом макросе идет имя интерфейса, вторым - список из IID и имени родителя, а потом идут описания методов, в каждом из которых первым идет имя метода, потом - список из типа возвращаемого значения, имени переменной на которую оно биндится, и некоторой формы, которая перед возвратом из метода выполняется, а потом идет список аргументов метода. Типы аргументов - типы, которые использует Virgil, например (& float4) это ссылка на структуру float4, которая маршалится как :in-параметр.

Дальше нужно определить класс, реализующий IClassFactory. Вообще, что это такое? Объект, реализующий IClassFactory в COM - промежуточный объект, предназначенный для создания объектов самого COM-класса.
В лиспе у нас классы сами являются объектами, так что наиболее органичное решение для лиспа заключается в том, чтобы этот интерфейс реализовывался некоторым метаклассом.
В данном случае я ввожу дополнительный метакласс factory-class, но вообще, я планирую позже добавить в Doors какой-нибудь standard-com-class, реализующий этот, и некоторые другие интерфейсы стандартным образом.

(defclass factory-class (com-class)
  ((lock-count :initform 0 :accessor class-lock-count))
  (:metaclass com-class)
  (:interfaces class-factory))

(defmethod lock-server ((class factory-class) lock)
  (if lock
    (incf (class-lock-count class))
    (unless (> (decf (class-lock-count class))
               0)
      (post-quit-message)))
  (values nil lock))

(defmethod create-instance ((class factory-class) iid &optional outer)
  (when outer
    (error 'com-error :code error-not-implemented))
  (values nil outer iid (acquire-interface (make-instance class) iid)))


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

(defclass vector-math-object (com-object)
  ()
  (:metaclass factory-class)
  (:clsid . "{6047851B-D193-4FD1-A6AD-9C862C5F98FD}")
  (:interfaces vector-math))

(defmethod vector-sum ((object vector-math-object)
                       v1 v2 &optional (result (make-float4)))
  (map-into result #'+ v1 v2)
  (values nil result v1 v2))

(defmethod vector-dot ((object vector-math-object) v1 v2)
  (values nil v1 v2 (reduce #'+ (map 'float4 #'* v1 v2))))

(defmethod add-ref :after ((object vector-math-object))
  (incf (class-lock-count (class-of object))))

(defmethod release :after ((object vector-math-object))
  (unless (> (decf (class-lock-count (class-of object)))
             0)
    (post-quit-message)))


Каждый основной метод, как видно, возвращает все свои аргументы. Зачем это нужно? Затем, что virgil, и значит, методы интерфейсов, способны маршалить в принципе произвольные типы данных, произвольным образом; а значит, отличить на этапе компиляции, какие параметры в коллбэке-трамплине маршалятся in, какие out, а какие inout, в контексте этого - невозможно. Кстати, упомянутый выше ссылочный тип(&) не является чем-то особенным - средствами Virgil он мог бы быть введен и пользователем библиотеки.

В C#, как будет видно дальше, тип HRESULT, у возвращаемого значения, подразумевается по умолчанию; .NET опускает retval параметр в сигнатуре метода и использует в качестве него возвращаемое значение.
В doors это совсем не так - сигнатура методов интерфейсов всегда идентична настоящей. Почему? Потому, что хотя описанное изменение сигнатуры вполне нормально в контексте OLE Automation, его нельзя экстраполировать на все возможные случаи - например, у интерфейсов DirectX присутствуют методы, возвращающие совсем не HRESULT(там же, кстати, присутствуют интерфейсы, которые не наследуются от IUnknown - doors позволяет с такими интерфейсами работать, а вот стандартный interop из .NET - нет, потому что подразумевает что все интерфейсы должны от упомянутого наследоваться)

Наш сервер будет работать в STA, поэтому ему нужен цикл сообщений. Он реализуется у нас в функции main, там же у нас проходит регистрация сервера и отмена регистрации после завершения его работы:

(defun main ()
  (initialize-com)
  (let ((token (register-class-object 'vector-math-object
                                      :local-server
                                      :multiple-use)))
    (loop :for msg = (get-message)
      :while msg :do (dispatch-message msg))
    (revoke-class-object token))
  (uninitialize-com))


Вот вобщем-то и все. Как видно, все достаточно просто и декларативно.
Полный код в самом низу постинга, там еще определена фунцкция build-executable, она дампит лисп-систему в исполняемый файл.

Я сначала думал, что на C# все будет не сложнее чем на лиспе, а то и проще.
Вот декларация интерфейса и класса:

    [
        Guid("D5BC0063-BFD3-4272-834B-196F9F803BF2"),
        ComVisible(true),
        InterfaceType(ComInterfaceType.InterfaceIsIUnknown)
    ]
    public interface IVectorMath
    {
        void VectorSum([MarshalAs(UnmanagedType.LPArray, SizeConst = 4), Out] float[] result,
                       [MarshalAs(UnmanagedType.LPArray, SizeConst = 4)] float[] v1,
                       [MarshalAs(UnmanagedType.LPArray, SizeConst = 4)] float[] v2);
        float VectorDot([MarshalAs(UnmanagedType.LPArray, SizeConst = 4)] float[] v1,
                        [MarshalAs(UnmanagedType.LPArray, SizeConst = 4)] float[] v2);
    }

    [
        Guid("6047851B-D193-4FD1-A6AD-9C862C5F98FD"),
        ComVisible(true),
        ClassInterface(ClassInterfaceType.None)
    ]
    public class VectorMath : IVectorMath
    {
        public void VectorSum(float[] result, float[] v1, float[] v2)
        {
            result[0] = v1[0] + v2[0];
            result[1] = v1[1] + v2[1];
            result[2] = v1[2] + v2[2];
            result[3] = v1[3] + v2[3];
        }

        public float VectorDot(float[] v1, float[] v2)
        {
            return v1[0] * v2[0] + v1[1] * v2[1] + v1[2] * v2[2] + v1[3] * v2[3];
        }
    }



Но, оказалось, что в стандартном COM interop заложена большая, но поначалу незаметная, жопа.

Дело в том, что стандартный COM Callable Wrapper управляется исключительно сборщиком мусора(ну помимо внутреннего подсчета ссылок), а всю механику подсчета ссылок от пользователя прячет, не давая совершенно никаких способов прицепиться к нему или как-либо мониторить. Аналогично он прячет и механику IClassFactory.
Для in-process серверов это не фатально, хотя и неприятно в некоторых случаях, но вот для out-of-process - вполне. Никаких IDisposable.Dispose после достижения счетчиком ссылок нуля CCW не вызывает, поэтому когда нашему серверу завершаться - мы сказать не можем, разве что повесив код завершения в финализатор. Но, это чревато тем, что наш сервер может часами провисеть в подвешенном полумертвом состоянии, а то и не выключиться никогда(GC то не детерминирован).

Я тут посмотрел в интернете, какие костыли люди городят для обхода этой проблемы, и выбрал наиболее простой - патчинг таблицы виртуальных функций IUnknown для CCW, и перехват вызовов AddRef и Release. Костыли, конечно, те еще, и здорово портят код, но а что делать:

static void InstallVtableHooks(Object server)
{
    IntPtr serverVtbl = Marshal.ReadIntPtr(Marshal.GetIUnknownForObject(server));
    trueAddRef = (AddRef)Marshal.GetDelegateForFunctionPointer(Marshal.ReadIntPtr(serverVtbl, 1 * IntPtr.Size), typeof(AddRef));
    Marshal.WriteIntPtr(serverVtbl, 1 * IntPtr.Size, Marshal.GetFunctionPointerForDelegate((AddRef)AddRefObject));
    trueRelease = (Release)Marshal.GetDelegateForFunctionPointer(Marshal.ReadIntPtr(serverVtbl, 2 * IntPtr.Size), typeof(Release));
    Marshal.WriteIntPtr(serverVtbl, 2 * IntPtr.Size, Marshal.GetFunctionPointerForDelegate((Release)ReleaseObject));
}


Полный код приведен в архиве по ссылке внизу постинга.


Вариант на C++ я сначала попытался реализовать вручную. Получаться начало какое-то дерьмо, которое даже стыдно выложить на публику, поэтому я не стал дальше заморачиваться и взял ATL.
Кстати, надо сказать, найти ATL в свободном доступе сейчас стало довольно сложно. В Windows SDK Microsoft его больше не включает и на сайте у них нигде кнопки "скачать ATL" не висит - видимо, стало стыдно, и теперь заметают следы. С MFC, кстати, такая же история.
Ну, в итоге я нашел последнюю версию ATL в Windows Driver Kit(что, конечно, само по себе смешно).
Взять последний можно тут: src

Несмотря на все старания MS, код на C++, работающий с COM, по-прежнему выглядит как куча говна из малопонятных макросов и шаблонов, за которыми практически не видно сути. Чего стоит одно только определение класса:

class ATL_NO_VTABLE CVectorMath : 
        public CComObjectRootEx<CComSingleThreadModel>,
        public CComCoClass<CVectorMath, &CLSID_VectorMath>,
        public IVectorMath
{
        public:
                CVectorMath()
                {
                }
                DECLARE_REGISTRY_RESOURCEID(IDR_VECTORMATH)
                DECLARE_CLASSFACTORY()
                DECLARE_NOT_AGGREGATABLE(CVectorMath)
                DECLARE_PROTECT_FINAL_CONSTRUCT()
                BEGIN_COM_MAP(CVectorMath)
                        COM_INTERFACE_ENTRY(IVectorMath)
                END_COM_MAP()
        public:
                STDMETHOD(VectorSum)(float result[4], float v1[4], float v2[4])
                {
                        result[0] = v1[0] + v2[0];
                        result[1] = v1[1] + v2[1];
                        result[2] = v1[2] + v2[2];
                        result[3] = v1[3] + v2[3];
                        return S_OK;
                }
                STDMETHOD(VectorDot)(float v1[4], float v2[4], float* dotProduct)
                {
                        *dotProduct = v1[0]*v2[0] + v1[1]*v2[1] + v1[2]*v2[2] + v1[3]*v2[3];
                        return S_OK;
                }
};


И это еще не учитывая возню с ресурсами и дублированием скрипта регистрации в них.

Кстати, как насчет отображения HRESULT в исключения, как это сделано в .NET и у меня в doors? А никак; и правильно.

Опробовать любой из трех серверов можно, например, из лиспа. Вот таким кодом:

(defparameter *interface* (create-com-instance (clsid-from-progid "VectorMath")
                                               'vector-math))

(vector-sum *interface* (float4 1.0 2.0 3.0 4.0) (float4 5.0 6.0 7.0 8.0))
;; => #(6.0 8.0 10.0 12.0)

(vector-dot *interface* (float4 1.0 2.0 3.0 4.0) (float4 5.0 6.0 7.0 8.0))
;; => 70.0




Какие выводы? А выводы такие, что COM, в сочетании с нормальными языками - хорошая и полезная технология. Не нужно закапывать COM, нужно закапывать C++

Полный код примеров: http://rghost.ru/5093043

Post a comment in response:

This account has disabled anonymous posting.
If you don't have an account you can create one now.
HTML doesn't work in the subject.
More info about formatting