教程:使用 ComWrappers API

在本教程中,你将学习如何正确地对 ComWrappers 类型进行子类化以提供优化且对 AOT 友好的 COM 互操作解决方案。 在开始本教程之前,你应该熟悉 COM、其体系结构和现有的 COM 互操作解决方案。

在本教程中,你将实现以下接口定义。 这些接口及其实现将演示:

  • 跨 COM/.NET 边界的封送和取消编组类型。
  • 在 .NET 中使用本机 COM 对象的两种不同方法。
  • 用于在 .NET 5 及更高版本中启用自定义 COM 互操作的推荐模式。

本教程中使用的所有源代码都可以在 dotnet/samples 存储库中找到。

注意

在 .NET 8 SDK 及更高版本中,提供了源生成器来自动为你生成 ComWrappers API 实现。 有关详细信息,请参阅 ComWrappers 源生成

C# 定义

interface IDemoGetType
{
    string? GetString();
}

interface IDemoStoreType
{
    void StoreString(int len, string? str);
}

Win32 C++ 定义

MIDL_INTERFACE("92BAA992-DB5A-4ADD-977B-B22838EE91FD")
IDemoGetType : public IUnknown
{
    HRESULT STDMETHODCALLTYPE GetString(_Outptr_ wchar_t** str) = 0;
};

MIDL_INTERFACE("30619FEA-E995-41EA-8C8B-9A610D32ADCB")
IDemoStoreType : public IUnknown
{
    HRESULT STDMETHODCALLTYPE StoreString(int len, _In_z_ const wchar_t* str) = 0;
};

ComWrappers 设计概述

ComWrappers API 旨在提供完成与 .NET 5+ 运行时的 COM 互操作所需的最少交互。 这意味着内置 COM 互操作系统存在的许多细节都不存在,必须从基本构建块构建。 此 API 的两个主要职责是:

  • 有效的对象标识(例如,IUnknown* 实例和托管对象之间的映射)。
  • 垃圾回收器 (GC) 交互。

这些效率是通过要求通过 ComWrappers API 创建和获取包装器来实现的。

由于ComWrappers API 的职责非常少,因此大多数互操作工作应该由使用者处理,这是理所当然的。 然而,额外的工作在很大程度上是机械的,可以通过源代码生成解决方案来执行。 例如,C#/WinRT 工具链是基于 ComWrappers 构建的源代码生成解决方案,以提供 WinRT 互操作支持。

实现 ComWrappers 子类

提供 ComWrappers 子类意味着向 .NET 运行时提供足够的信息,以便为投影到 COM 中的托管对象和要投影到 .NET 中的 COM 对象创建和记录包装器。 在我们查看子类的大纲之前,我们应该定义一些术语。

托管对象包装器 – 托管 .NET 对象需要包装器才能从非 .NET 环境中使用。 这些包装器过去被称为 COM 可调用包装器 (CCW)。

本机对象包装器 – 使用非 .NET 语言实现的 COM 对象需要包装器才能启用 .NET 中的使用。 这些包装器过去被称为运行时可调用包装器 (RCW)。

步骤 1 – 定义实现和理解其意图的方法

若要扩展 ComWrappers 类型,必须实现以下三种方法。 这些方法中的每一个都表示用户参与创建或删除一种类型的包装器。 ComputeVtables()CreateObject() 方法分别创建托管对象包装器和本机对象包装器。 运行时使用 ReleaseObjects() 方法请求从基础本机对象“释放”提供的包装器集合。 在大多数情况下,ReleaseObjects() 方法的主体可以简单地抛出 NotImplementedException,因为它仅在涉及参考跟踪器框架的高级方案中才会调用该方法。

// See referenced sample for implementation.
class DemoComWrappers : ComWrappers
{
    protected override unsafe ComInterfaceEntry* ComputeVtables(object obj, CreateComInterfaceFlags flags, out int count) =>
        throw new NotImplementedException();

    protected override object? CreateObject(IntPtr externalComObject, CreateObjectFlags flags) =>
        throw new NotImplementedException();

    protected override void ReleaseObjects(IEnumerable objects) =>
        throw new NotImplementedException();
}

若要实现 ComputeVtables() 方法,请确定要支持的托管类型。 在本教程中,我们将支持前面定义的两个接口(IDemoGetTypeIDemoStoreType) 以及实现这两个接口 (DemoImpl) 的托管类型。

class DemoImpl : IDemoGetType, IDemoStoreType
{
    string? _string;
    public string? GetString() => _string;
    public void StoreString(int _, string? str) => _string = str;
}

对于 CreateObject() 方法,你还需要确定要支持的内容。 但是,在这种情况下,我们只知道我们感兴趣的 COM 接口,而不是 COM 类。 从 COM 端使用的接口与我们从 .NET 端投影的接口(即 IDemoGetTypeIDemoStoreType)相同。

我们不会在本教程中实现 ReleaseObjects()

第 2 步 – 实现 ComputeVtables()

让我们从托管对象包装器开始 - 这些包装器更容易。 你将生成为每个接口提供一个虚拟方法表或称为 vtable,以便将它们投影到 COM 环境中。 在本教程中,你将 vtable 定义为一系列指针,其中每个指针表示接口上函数的实现 – 顺序在这里非常重要。 在 COM 中,每个接口都继承自 IUnknownIUnknown 类型具有按以下顺序定义的三个方法:QueryInterface()AddRef()Release()。 在 IUnknown 方法之后是特定的接口方法。 例如,考虑 IDemoGetTypeIDemoStoreType。 从概念上讲,这些类型的 vtable 如下所示:

IDemoGetType    | IDemoStoreType
==================================
QueryInterface  | QueryInterface
AddRef          | AddRef
Release         | Release
GetString       | StoreString

看一下 DemoImpl,我们已经有了 GetString()StoreString() 的一种实现,但是 IUnknown 函数呢? 如何实现 IUnknown 实例超出了本教程的范围,但可以在 ComWrappers 中手动完成。 但是,在本教程中,你将让运行时处理该部分。 你可以使用 ComWrappers.GetIUnknownImpl() 方法获取 IUnknown 实现。

看起来你已经实现了所有方法,但不幸的是,只有 IUnknown 函数可以在 COM vtable 中使用。 由于 COM 位于运行时之外,因此需要创建指向 DemoImpl 实现的本机函数指针。 这可以使用 C# 函数指针和 UnmanagedCallersOnlyAttribute。 通过创建模拟 COM 函数签名的 static 函数,可以创建要插入到 vtable 中的函数。 下面是IDemoGetType.GetString() 的 COM 签名的示例– 从 COM ABI 中重新调用,第一个参数是实例本身。

[UnmanagedCallersOnly]
public static int GetString(IntPtr _this, IntPtr* str);

IDemoGetType.GetString() 的包装器实现应包括编组逻辑,然后分派到被包装的托管对象。 所有用于调度的状态都包含在提供的 _this 参数中。 _this 参数实际上是 ComInterfaceDispatch* 类型。 此类型表示具有单个字段 Vtable 的低级结构,稍后将讨论此字段。 此类型及其布局的更多详细信息是运行时的实现细节,不应依赖它。 为了从 ComInterfaceDispatch* 实例中检索托管实例,请使用以下代码:

IDemoGetType inst = ComInterfaceDispatch.GetInstance<IDemoGetType>((ComInterfaceDispatch*)_this);

现在你有了可以插入到 vtable 中的 C# 方法,可以构造 vtable 了。 请注意,使用 RuntimeHelpers.AllocateTypeAssociatedMemory() 分配内存的方式适用于可卸载程序集。

GetIUnknownImpl(
    out IntPtr fpQueryInterface,
    out IntPtr fpAddRef,
    out IntPtr fpRelease);

// Local variables with increment act as a guard against incorrect construction of
// the native vtable. It also enables a quick validation of final size.
int tableCount = 4;
int idx = 0;
var vtable = (IntPtr*)RuntimeHelpers.AllocateTypeAssociatedMemory(
    typeof(DemoComWrappers),
    IntPtr.Size * tableCount);
vtable[idx++] = fpQueryInterface;
vtable[idx++] = fpAddRef;
vtable[idx++] = fpRelease;
vtable[idx++] = (IntPtr)(delegate* unmanaged<IntPtr, IntPtr*, int>)&ABI.IDemoGetTypeManagedWrapper.GetString;
Debug.Assert(tableCount == idx);
s_IDemoGetTypeVTable = (IntPtr)vtable;

vtable 的分配是实现 ComputeVtables() 的第一部分。 你还应该为你计划支持的类型构建全面的 COM 定义——想想 DemoImpl 以及它的哪些部分应该可以从 COM 中使用。 使用构造的 vtable,你现在可以创建一系列 ComInterfaceEntry 实例,这些实例代表 COM 中托管对象的完整视图。

s_DemoImplDefinitionLen = 2;
int idx = 0;
var entries = (ComInterfaceEntry*)RuntimeHelpers.AllocateTypeAssociatedMemory(
    typeof(DemoComWrappers),
    sizeof(ComInterfaceEntry) * s_DemoImplDefinitionLen);
entries[idx].IID = IDemoGetType.IID_IDemoGetType;
entries[idx++].Vtable = s_IDemoGetTypeVTable;
entries[idx].IID = IDemoStoreType.IID_IDemoStoreType;
entries[idx++].Vtable = s_IDemoStoreVTable;
Debug.Assert(s_DemoImplDefinitionLen == idx);
s_DemoImplDefinition = entries;

托管对象包装器的 vtable 和条目的分配可以而且应该提前完成,因为数据可以用于该类型的所有实例。 这里的工作可以在 static 构造函数或模块初始化程序中执行,但应该提前完成,以便 ComputeVtables() 方法尽可能简单和快速。

protected override unsafe ComInterfaceEntry* ComputeVtables(object obj, CreateComInterfaceFlags flags,
out int count)
{
    if (obj is DemoImpl)
    {
        count = s_DemoImplDefinitionLen;
        return s_DemoImplDefinition;
    }

    // Unknown type
    count = 0;
    return null;
}

实现 ComputeVtables() 方法后,ComWrappers 子类将能够为 DemoImpl 实例生成托管对象包装器。 请注意,调用 GetOrCreateComInterfaceForObject() 返回的托管对象包装器的类型为 IUnknown*。 如果传递给包装器的本机 API 需要不同的接口,则必须执行该接口的 Marshal.QueryInterface()

var cw = new DemoComWrappers();
var demo = new DemoImpl();
IntPtr ccw = cw.GetOrCreateComInterfaceForObject(demo, CreateComInterfaceFlags.None);

第 3 步 - 实现 CreateObject()

与构建托管对象包装器相比,构建本机对象包装器具有更多实现选项和更多细微差别。 要解决的第一个问题是 ComWrappers 子类在支持 COM 类型方面的许可程度。 要支持所有可能的 COM 类型,你需要编写大量代码或巧妙地使用 Reflection.Emit。 在本教程中,你将仅支持同时实现 IDemoGetTypeIDemoStoreType 的 COM 实例。 由于你知道有一个有限集并且限制了任何提供的 COM 实例必须实现这两个接口,因此你可以提供一个静态定义的包装器;但是,动态情况在 COM 中很常见,我们将探讨这两种选择。

静态本机对象包装器

我们先看一下静态实现。 静态本机对象包装器涉及定义实现 .NET 接口的托管类型,并可以将对托管类型的调用转发到 COM 实例。 静态包装的大致轮廓如下。

// See referenced sample for implementation.
class DemoNativeStaticWrapper
    : IDemoGetType
    , IDemoStoreType
{
    public string? GetString() =>
        throw new NotImplementedException();

    public void StoreString(int len, string? str) =>
        throw new NotImplementedException();
}

要构造此类的实例并将其作为包装提供,必须定义一些策略。 如果将此类型用作包装器,则似乎因为它实现了两个接口,所以底层 COM 实例也应该实现这两个接口。 鉴于你正在采用此策略,你需要通过在 COM 实例上调用 Marshal.QueryInterface() 来确认这一点。

int hr = Marshal.QueryInterface(ptr, ref IDemoGetType.IID_IDemoGetType, out IntPtr IDemoGetTypeInst);
if (hr != 0)
{
    return null;
}

hr = Marshal.QueryInterface(ptr, ref IDemoStoreType.IID_IDemoStoreType, out IntPtr IDemoStoreTypeInst);
if (hr != 0)
{
    Marshal.Release(IDemoGetTypeInst);
    return null;
}

return new DemoNativeStaticWrapper()
{
    IDemoGetTypeInst = IDemoGetTypeInst,
    IDemoStoreTypeInst = IDemoStoreTypeInst
};

动态本机对象包装器

动态包装器更灵活,因为它们提供了一种在运行时而不是静态地查询类型的方法。 为了提供此支持,你将使用 IDynamicInterfaceCastable - 更多详细信息可在此处找到。 注意观察,DemoNativeDynamicWrapper 只实现这个接口。 接口提供的功能是确定运行时支持的类型的机会。 本教程的源代码在创建期间进行了静态检查,但这只是为了代码共享,因为检查可以推迟到对 DemoNativeDynamicWrapper.IsInterfaceImplemented() 进行调用。

// See referenced sample for implementation.
internal class DemoNativeDynamicWrapper
    : IDynamicInterfaceCastable
{
    public RuntimeTypeHandle GetInterfaceImplementation(RuntimeTypeHandle interfaceType) =>
        throw new NotImplementedException();

    public bool IsInterfaceImplemented(RuntimeTypeHandle interfaceType, bool throwIfNotImplemented) =>
        throw new NotImplementedException();
}

让我们看一下 DemoNativeDynamicWrapper 将动态支持的接口之一。 以下代码使用默认接口方法功能提供了 IDemoStoreType 的实现。

[DynamicInterfaceCastableImplementation]
unsafe interface IDemoStoreTypeNativeWrapper : IDemoStoreType
{
    public static void StoreString(IntPtr inst, int len, string? str);

    void IDemoStoreType.StoreString(int len, string? str)
    {
        var inst = ((DemoNativeDynamicWrapper)this).IDemoStoreTypeInst;
        StoreString(inst, len, str);
    }
}

在这个例子中有两点需要注意:

  1. DynamicInterfaceCastableImplementationAttribute 特性。 从 IDynamicInterfaceCastable 方法返回的任何类型都需要此属性。 这还有一个额外的好处,也就是使 IL 修整更容易,这意味着 AOT 场景更可靠。
  2. 强制转换为 DemoNativeDynamicWrapper。 这是 IDynamicInterfaceCastable 的动态特性的一部分。 从 IDynamicInterfaceCastable.GetInterfaceImplementation() 返回的类型用于“覆盖”实现 IDynamicInterfaceCastable 的类型。 这里的要点是,this 指针不是像表面上那样,因为我们允许从 DemoNativeDynamicWrapperIDemoStoreTypeNativeWrapper 的强制转换。

转发对 COM 实例的调用

无论使用哪个本机对象包装器,你都需要能够在 COM 实例上调用函数。 IDemoStoreTypeNativeWrapper.StoreString() 的实现可以作为使用 unmanaged C# 函数指针的示例。

public static void StoreString(IntPtr inst, int len, string? str)
{
    IntPtr strLocal = Marshal.StringToCoTaskMemUni(str);
    int hr = ((delegate* unmanaged<IntPtr, int, IntPtr, int>)(*(*(void***)inst + 3 /* IDemoStoreType.StoreString slot */)))(inst, len, strLocal);
    if (hr != 0)
    {
        Marshal.FreeCoTaskMem(strLocal);
        Marshal.ThrowExceptionForHR(hr);
    }
}

让我们检查 COM 实例的取消引用以访问其 vtable 实现。 COM ABI 规定,对象的第一个指针指向类型的 vtable,可以从该 vtable 访问所需的插槽。 假设 COM 对象的地址是 0x10000。 第一个指针大小的值应该是 vtable 的地址——在这个例子中为 0x20000。 进入 vtable 后,你将寻找第四个插槽(从零开始的索引中的索引 3)来访问 StoreString() 实现。

COM instance
0x10000  0x20000

VTable for IDemoStoreType
0x20000  <Address of QueryInterface>
0x20008  <Address of AddRef>
0x20010  <Address of Release>
0x20018  <Address of StoreString>

有了函数指针,你就可以通过将对象实例作为第一个参数传递给该对象上的该成员函数。 根据托管对象包装器实现的函数定义,此模式应该看起来很熟悉。

一旦实现了 CreateObject() 方法,ComWrappers 子类将能够为实现 IDemoGetTypeIDemoStoreType 的 COM 实例生成本机对象包装器。

IntPtr iunk = ...; // Get a COM instance from native code.
object rcw = cw.GetOrCreateObjectForComInstance(iunk, CreateObjectFlags.UniqueInstance);

第 4 步 - 处理本机对象包装器生命周期细节

ComputeVtables()CreateObject() 实现涵盖了一些包装器生命周期的细节,但还有进一步的考虑。 虽然这可能是一个短暂的步骤,但它也会显著增加 ComWrappers 设计的复杂性。

与由调用其 AddRef()Release() 方法控制的托管对象包装器不同,本机对象包装器的生命周期由 GC 非确定性地处理。 这里的问题是,本机对象包装器什么时候在代表 COM 实例的 IntPtr 上调用 Release()? 有两种通用的存储桶:

  1. 本机对象包装器的终结器负责调用 COM 实例的 Release() 方法。 只有此时才能安全调用此方法。 此时,GC 已正确确定 .NET 运行时中没有其他对本机对象包装器的引用。 如果你正确支持 COM 公寓,这里可能会很复杂;有关详细信息,请参阅其他注意事项部分。

  2. 本机对象包装在 Dispose() 中实现 IDisposableRelease()

注意

仅当在 CreateObject() 调用期间传入了 CreateObjectFlags.UniqueInstance 标志时,才应支持 IDisposable 模式。 如果不遵循此要求,则已处置的本机对象包装器可能在处置后被重用。

使用 ComWrappers 子类

现在,你有了一个可以测试的 ComWrappers 子类。 为避免创建返回实现 IDemoGetTypeIDemoStoreType 的 COM 实例的本机库,你将使用托管对象包装器并将其视为 COM 实例,这必须是可能的,以便无论如何传递 COM。

让我们首先创建一个托管对象包装器。 实例化一个 DemoImpl 实例并显示其当前字符串状态。

var demo = new DemoImpl();

string? value = demo.GetString();
Console.WriteLine($"Initial string: {value ?? "<null>"}");

现在你可以创建一个 DemoComWrappers 实例和一个托管对象包装器,然后你可以将其传递到 COM 环境中。

var cw = new DemoComWrappers();

IntPtr ccw = cw.GetOrCreateComInterfaceForObject(demo, CreateComInterfaceFlags.None);

与其将托管对象包装器传递给 COM 环境,不如假装你刚刚收到此 COM 实例,因此你将为它创建一个本机对象包装器。

var rcw = cw.GetOrCreateObjectForComInstance(ccw, CreateObjectFlags.UniqueInstance);

使用本机对象包装器,你应该能够将其转换为所需的接口之一并将其用作普通的托管对象。 你可以检查 DemoImpl 实例并观察操作对包装托管对象包装器的本机对象包装器的影响,而托管对象包装器又包装托管实例。

var getter = (IDemoGetType)rcw;
var store = (IDemoStoreType)rcw;

string msg = "hello world!";
store.StoreString(msg.Length, msg);
Console.WriteLine($"Setting string through wrapper: {msg}");

value = demo.GetString();
Console.WriteLine($"Get string through managed object: {value}");

msg = msg.ToUpper();
demo.StoreString(msg.Length, msg.ToUpper());
Console.WriteLine($"Setting string through managed object: {msg}");

value = getter.GetString();
Console.WriteLine($"Get string through wrapper: {value}");

由于你的 ComWrapper 子类旨在支持 CreateObjectFlags.UniqueInstance,因此你可以立即清理本机对象包装器,而不是等待 GC 发生。

(rcw as IDisposable)?.Dispose();

COM Activation 与 ComWrappers

COM 对象的创建通常是通过 COM 激活来执行的,这是一个复杂场景,超出了本文档范围。 为了提供可遵循的概念模式,我们引入了用于 COM Activation 的 CoCreateInstance() API,并说明了它如何与 ComWrappers 一起使用。

假设你的应用程序中有以下 C# 代码。 下面的示例使用 CoCreateInstance() 来激活 COM 类和内置 COM 互操作系统,以将 COM 实例编组到适当的接口。 请注意,typeof(I).GUID 的用途仅限于断言,并且是使用反射的情况,如果代码是 AOT 友好的,则可能会产生影响。

public static I ActivateClass<I>(Guid clsid, Guid iid)
{
    Debug.Assert(iid == typeof(I).GUID);
    int hr = CoCreateInstance(ref clsid, IntPtr.Zero, /*CLSCTX_INPROC_SERVER*/ 1, ref iid, out object obj);
    if (hr < 0)
    {
        Marshal.ThrowExceptionForHR(hr);
    }
    return (I)obj;
}

[DllImport("Ole32")]
private static extern int CoCreateInstance(
    ref Guid rclsid,
    IntPtr pUnkOuter,
    int dwClsContext,
    ref Guid riid,
    [MarshalAs(UnmanagedType.Interface)] out object ppObj);

将上述转换为使用 ComWrappers 涉及从 CoCreateInstance() P/Invoke 中删除 MarshalAs(UnmanagedType.Interface) 并手动执行封送。

static ComWrappers s_ComWrappers = ...;

public static I ActivateClass<I>(Guid clsid, Guid iid)
{
    Debug.Assert(iid == typeof(I).GUID);
    int hr = CoCreateInstance(ref clsid, IntPtr.Zero, /*CLSCTX_INPROC_SERVER*/ 1, ref iid, out IntPtr obj);
    if (hr < 0)
    {
        Marshal.ThrowExceptionForHR(hr);
    }
    return (I)s_ComWrappers.GetOrCreateObjectForComInstance(obj, CreateObjectFlags.None);
}

[DllImport("Ole32")]
private static extern int CoCreateInstance(
    ref Guid rclsid,
    IntPtr pUnkOuter,
    int dwClsContext,
    ref Guid riid,
    out IntPtr ppObj);

还可以通过在本机对象包装器的类构造函数中包含激活逻辑来抽象出工厂风格的函数,例如 ActivateClass<I>。 构造函数可以使用 ComWrappers.GetOrRegisterObjectForComInstance() API 将新构造的托管对象与激活的 COM 实例相关联。

其他注意事项

本机 AOT – 提前 (AOT) 编译可提高启动成本,因为避免了 JIT 编译。 在某些平台上也经常需要消除对 JIT 编译的需求。 支持 AOT 是 ComWrappers API 的目标,但任何包装器实现都必须小心,不要无意中引入 AOT 崩溃的情况,例如使用反射。 Type.GUID 属性是使用反射的一个示例,但以一种不明显的方式。 Type.GUID 属性使用反射来检查类型的属性,然后可能会检查类型的名称和包含程序集,以生成其值。

源代码生成 — COM 互操作和 ComWrappers 实现所需的大部分代码可能由某些工具自动生成。 给定正确的 COM 定义,可以生成这两种类型的包装器的源代码,例如,类型库 (TLB)、IDL 或主互操作程序集 (PIA)。

全局注册 — 由于 ComWrappers API 被设计为 COM 互操作的新阶段,因此它需要有某种方式与现有系统部分集成。 ComWrappers API 上有全局影响的静态方法,允许注册全局实例以获得各种支持。 这些方法适用于需要在所有情况下提供全面 COM 互操作支持的 ComWrappers 实例(与内置 COM 互操作系统类似)。

引用跟踪器支持 –此支持主要用于 WinRT 方案,表示高级方案。 对于大多数 ComWrapper 实现,CreateComInterfaceFlags.TrackerSupportCreateObjectFlags.TrackerObject 标志应引发 NotSupportedException。 如果你想启用此支持,可能在 Windows 甚至非 Windows 平台上,强烈建议参考 C#/WinRT 工具链。

除了前面讨论的生命周期、类型系统和功能特性之外,符合 COM 的 ComWrappers 实现还需要额外的考虑。 对于将在 Windows 平台上使用的任何实现,有以下注意事项:

  • 单元 – COM 的线程组织结构称为“单元 (Apartments)”,并具有必须遵守的严格规则才能稳定运行。 本教程没有实现单元感知的原生对象包装器,但任何生产就绪的实现都应该是单元感知的。 为此,我们建议使用 Windows 8 中引入的 RoGetAgileReference API。 对于 Windows 8 之前的版本,请考虑全局接口表

  • 安全性 – COM 为类激活和代理权限提供了丰富的安全模型。