教程:使用 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 中的托管对象创建和记录包装器,并将 COM 对象投影到 .NET 中。 在查看子类的大纲之前,我们应该定义一些术语。

托管对象包装 — 托管 .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 中,每个接口都继承自 IUnknown. 该 IUnknown 类型按以下顺序定义三种方法: QueryInterface()AddRef()Release()。 方法 IUnknown 之后是特定的接口方法。 例如,请考虑 IDemoGetTypeIDemoStoreType。 从概念上讲,各类型的 vtables 看起来如下所示:

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 的函数。 下面是 COM 签名 IDemoGetType.GetString() 的示例,注意根据 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,更具体地,通过它可以访问所需的槽位。 假设 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 实例的 IntPtrRelease()? 有两个常规存储桶:

  1. Native Object Wrapper 的终结器负责调用 COM 实例的Release()方法。 这是唯一安全调用此方法的时间。 此时,GC 已正确确定 .NET 运行时中没有对本地对象包装器的其他引用。 如果正确支持 COM 公寓,则此处可能会有复杂性;有关详细信息,请参阅 “其他注意事项” 部分。

  2. 本机对象包装器在Dispose()中实现IDisposable并调用Release()

注释

IDisposable模式只有在CreateObject()调用期间传递CreateObjectFlags.UniqueInstance标志时才应被支持。 如果未遵循此要求,则释放的本机对象包装器可以在释放后重复使用。

使用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();

使用 ComWrappers 进行 COM 激活

COM 对象的创建通常是通过 COM 激活进行的,这是一种超出本文档范围的复杂方案。 为了提供一个可以遵循的概念模式,我们将介绍用于 COM 激活的 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 中具有全局影响的静态方法允许为各种支持注册全局实例。 这些方法专为 ComWrappers 预期在所有情况下提供全面的 COM 互作支持(类似于内置 COM 互作系统)的实例而设计。

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

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

  • 公寓 – COM 的线程组织结构叫做“公寓”,具有必须遵循的严格规则以确保操作的稳定性。 本教程没有实现线程单元感知的本地对象包装器,但任何用于生产的实现都应考虑线程单元感知。 为此,我们建议使用 Windows 8 中引入的 RoGetAgileReference API。 对于 Windows 8 之前的版本,请考虑 全局接口表

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