让 VBA 以带类型的方式调用 .NET 8 DLL 的方法 - COM 公开与 dscom TLB

· · C#, .NET 8, VBA, COM, Office, dscom

希望从 VBA 调用 .NET 8 处理逻辑的场景依然很常见。尤其是在想保留 Excel 或 Access 的既有资产的同时,只把繁重处理、字符串处理、HTTP、加密、业务逻辑这类部分转移到 C# 中的情况下。

不过,如果依赖 CreateObject 采用延迟绑定,VBA 一侧就会到处都是 Object。IntelliSense 的效果会变弱,方法名的拼写错误要到运行时才会暴露,逐渐陷入依赖字符串硬凑的困境。

因此本文将聚焦于把 .NET 8 的 DLL 以 COM 形式公开,用 dscom 生成类型库(TLB),并在 VBA 中通过早期绑定进行带类型调用这一主题。

.NET Framework + RegAsm 的旧做法、手写 IDL 再用 MIDL 编译的方式,以及 Reg-Free COM 的话题,本文暂且不谈。这里只讨论 .NET 8 / COM host / dscom / VBA early binding 这一条主线。

另外,本文中出现的代码已作为可构建、可验证的完整示例(COM 公开库、TLB 生成/注册脚本、VBA 模块、单元测试)发布在 GitHub 上。

dotnet8-dll-typed-vba-com-dscom-tlb - komurasoft-blog-samples (GitHub)

1. 先说结论

先把结论整理成流程,大致是这样:

  • EnableComHosting=true 编译 .NET 8 类库
  • 为 COM 创建明确的接口
  • 类使用 ClassInterfaceType.None,不要依赖 AutoDual
  • 供 VBA 使用的接口设为 InterfaceIsDual
  • 从编译后得到的 *.dll,用 dscom tlbexport 生成 *.tlb
  • regsvr32 注册 *.comhost.dll
  • dscom tlbregister 注册 *.tlb
  • 在 VBA 中添加引用设置,以 Dim x As 库名.IYourInterface 的形式带类型使用

简单来说,这是一种COM 的入口是 .NET SDK 生成的 *.comhost.dll,类型信息是 dscom 生成的 *.tlb,VBA 通过该 TLB 进行早期绑定的结构。

2. 整体结构一览

首先用一张图来看各部分分别承担什么角色。

通过引用设置的 TLB 获取类型信息COM 调用VBA / Excel / AccessVbaTypedComSample.tlbVbaTypedComSample.comhost.dllVbaTypedComSample.dll (.NET 8).NET 8 Runtime

各自的角色如下。

文件 角色
VbaTypedComSample.dll .NET 8 的实现主体
VbaTypedComSample.comhost.dll 被 COM 调用的入口
VbaTypedComSample.tlb VBA 看到的类型信息
VbaTypedComSample.deps.json 依赖关系的解析信息
VbaTypedComSample.runtimeconfig.json .NET 运行时的启动信息

这里重要的是,VBA 要了解类型所需要的是 TLB,而作为 COM 启动入口所需要的是 comhost

不能只给一个 .dll 就了事,这正是 COM 世界不够直观的地方。

3. 首先要确定的事 - 统一 32bit / 64bit

如果在这一步出错,很有可能会遇到「ActiveX 组件不能创建对象。」这类问题。

请让 Office / VBA 与 COM 服务器的 bitness 保持一致。

使用方 .NET 侧的参考设置 TLB 生成 注册命令
64bit Office x64 / win-x64 dscom C:\Windows\System32\regsvr32.exe
32bit Office(运行在 64bit Windows 上) x86 / win-x86 dscom32.exe C:\Windows\SysWOW64\regsvr32.exe

.NET 5+ 之后的 COM host 中,如果保持 AnyCPU*.comhost.dll 往往会偏向 64bit,有时会与 32bit Office 不匹配。因此,按照 Office 明确指定 x86 / x64 会更安全。

本文的代码以 64bit Office 为例。如果是 32bit Office,请把后面出现的 x64 替换为 x86,把 win-x64 替换为 win-x86

4. 构建 .NET 8 部分

这里以一个可以从 VBA 调用 AddDivideHello 的最小示例来说明。

4.1 .csproj

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0-windows</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <EnableComHosting>true</EnableComHosting>
    <PlatformTarget>x64</PlatformTarget>
    <NETCoreSdkRuntimeIdentifier>win-x64</NETCoreSdkRuntimeIdentifier>
  </PropertyGroup>
</Project>

关键在于 EnableComHosting。加上它之后,编译时就会生成 VbaTypedComSample.comhost.dll

4.2 默认让整个程序集对 COM 不可见

因为只想让暴露给 COM 的类型设置为 ComVisible(true),所以让整个程序集默认设为 false 会更省事。

using System.Runtime.InteropServices;

[assembly: ComVisible(false)]

4.3 编写要公开的接口和类

using System.Runtime.InteropServices;

namespace VbaTypedComSample;

[ComVisible(true)]
[Guid("2A1BBEDE-DE6E-4C34-AD60-2E9E0E33E999")]
[InterfaceType(ComInterfaceType.InterfaceIsDual)]
public interface ICalculator
{
    [DispId(1)]
    int Add(int x, int y);

    [DispId(2)]
    double Divide(double x, double y);

    [DispId(3)]
    string Hello(string name);
}

[ComVisible(true)]
[Guid("FAD1C752-0BB6-4DDD-889F-FE446350847A")]
[ClassInterface(ClassInterfaceType.None)]
[ComDefaultInterface(typeof(ICalculator))]
public class Calculator : ICalculator
{
    public Calculator()
    {
    }

    public int Add(int x, int y) => checked(x + y);

    public double Divide(double x, double y)
    {
        if (y == 0)
        {
            throw new ArgumentOutOfRangeException(nameof(y), "不能除以 0。");
        }

        return x / y;
    }

    public string Hello(string name)
    {
        if (string.IsNullOrWhiteSpace(name))
        {
            return "Hello";
        }

        return $"Hello, {name}";
    }
}

这段代码需要注意的要点如下:

  • Guid 分别为接口单独分配
  • 设置 ClassInterfaceType.None不依赖自动生成的类接口
  • 为了方便 VBA 使用,设为 InterfaceIsDual
  • 预先分配 DispId,有助于减少公开后调整方法顺序时出错的风险
  • 因为会被 COM New 出来,所以要准备一个公共的无参构造函数

5. 进行编译

以 Release 模式进行编译。

dotnet build -c Release

编译完成后,输出文件夹中至少会出现以下这些文件。

bin/
  Release/
    net8.0-windows/
      VbaTypedComSample.dll
      VbaTypedComSample.comhost.dll
      VbaTypedComSample.deps.json
      VbaTypedComSample.runtimeconfig.json

分发和注册时使用的就是这个文件夹。如果之后更改部署位置,也需要重新注册

6. 用 dscom 生成 TLB

6.1 64bit 的情况

首先安装 dscom。

dotnet tool install --global dscom

接下来,从编译好的程序集生成 TLB。

dscom tlbexport .\bin\Release\net8.0-windows\VbaTypedComSample.dll --out .\bin\Release\net8.0-windows\VbaTypedComSample.tlb

6.2 面向 32bit Office 的情况

这里有一个小陷阱。如果要为 32bit Office 生成 TLB,使用 dscom32.exe 会更安全

.\tools\dscom32.exe tlbexport .\bin\Release\net8.0-windows\VbaTypedComSample.dll --out .\bin\Release\net8.0-windows\VbaTypedComSample.tlb

7. 注册 COM host 和 TLB

请在具有管理员权限的命令提示符 / PowerShell中执行以下操作。

7.1 64bit Office / 64bit COM 的情况

$out = Resolve-Path .\bin\Release\net8.0-windows

C:\Windows\System32\regsvr32.exe "$out\VbaTypedComSample.comhost.dll"
dscom tlbregister "$out\VbaTypedComSample.tlb"

7.2 32bit Office(运行在 64bit Windows 上)的情况

$out = Resolve-Path .\bin\Release\net8.0-windows

C:\Windows\SysWOW64\regsvr32.exe "$out\VbaTypedComSample.comhost.dll"
.\tools\dscom32.exe tlbregister "$out\VbaTypedComSample.tlb"

这里进行的操作有 2 个:

  • regsvr32*.comhost.dll 注册为 COM 服务器
  • tlbregister*.tlb 注册为类型库

8. 在 VBA 中添加引用设置并带类型使用

  1. 打开 Excel 或 Access
  2. 打开 VBA 编辑器
  3. 工具 -> 引用
  4. 如果列表中出现了该库,就勾选它
  5. 如果列表中没有出现,就从 浏览... 中选择 VbaTypedComSample.tlb
Option Explicit

Public Sub UseCalculator()
    Dim calc As VbaTypedComSample.ICalculator
    Set calc = New VbaTypedComSample.Calculator

    Debug.Print calc.Add(10, 20)
    Debug.Print calc.Divide(10, 4)
    Debug.Print calc.Hello("VBA")
End Sub

这样一来,VBA 一侧就能获得以下好处:

  • IntelliSense 可以正常发挥作用
  • 方法名的拼写错误在执行前就容易被发现
  • 可以在 Object Browser 中确认公开的 API
  • 比直接写 Object 更容易阅读

8.1 异常在 VBA 一侧会表现为 COM 错误

例如像 Divide(10, 0) 这样在 .NET 一侧抛出异常时,在 VBA 一侧会以 COM 错误的形式出现。

Option Explicit

Public Sub UseCalculatorWithErrorHandling()
    On Error GoTo EH

    Dim calc As VbaTypedComSample.ICalculator
    Set calc = New VbaTypedComSample.Calculator

    Debug.Print calc.Divide(10, 0)
    Exit Sub

EH:
    Debug.Print Err.Number
    Debug.Print Err.Description
End Sub

9. 分发时的思路

分发时重要的是,不要只分发单个 DLL,而是把整套输出文件都放上去

VbaTypedComSample.dll
VbaTypedComSample.comhost.dll
VbaTypedComSample.deps.json
VbaTypedComSample.runtimeconfig.json
VbaTypedComSample.tlb
(必要时还需附带依赖 DLL)

此外,客户端 PC 还需要安装对应版本的 .NET 8 运行时。COM host 并不是 self-contained 发布,基本上属于 framework-dependent 的运维方式。

10. 容易踩的坑

10.1 不要一直保持 AnyCPU

如果 VBA / Office 的 bitness 与 COM host 的 bitness 不一致,会出现相当令人费解的失败方式。

  • 64bit Office 应使用 x64 / win-x64
  • 32bit Office 应使用 x86 / win-x86

10.2 不要使用 ClassInterfaceType.AutoDual

虽然看起来很省事,但公开之后一旦调整成员顺序或结构,就很容易出问题。

如果想让 VBA 以带类型的方式稳定使用,标准做法是定义明确的接口,并让类使用 ClassInterfaceType.None

10.3 不要轻率地重新生成 GUID

在 COM 中,GUID 本身就是契约。如果在公开之后轻率地更换 IID 或 CLSID,会破坏现有的 VBA 引用和注册。

10.4 不要破坏已经公开的接口

在 COM 中,即使只是「事后追加一个方法」,也可能引发麻烦。

  • 保留 ICalculator
  • 如果改动较大,就新建 ICalculator2
  • 类也可以同时实现两者

10.5 类型选择要保守一些

在暴露给 VBA 的边界上,不要用得太花哨,会更安全。

比较合适的类型大致有这些:

  • int
  • double
  • bool
  • string
  • DateTime
  • decimal
  • enum

10.6 不要在 Office 处于打开状态时进行更新

如果 Excel 或 Access 一直占用着 DLL,在编译或重新注册时会遇到麻烦。

  • 关闭 Office
  • 如有必要,先取消注册
  • 重新编译
  • 再次注册

11. 总结

「让 VBA 以带类型的方式使用 .NET 8 的 DLL」这件事,只要把范围锁定在COM 公开 + 用 dscom 生成 TLB上,就不是一个多么可怕的流程。.NET 8 一侧只需将 EnableComHosting 设为 true,准备好明确的接口(类使用 ClassInterfaceType.None,供 VBA 使用的接口使用 InterfaceIsDual),用 dscom tlbexport 生成 TLB,再用 regsvr32 注册 *.comhost.dll、用 dscom tlbregister 注册 *.tlb。剩下的只需在 VBA 中添加引用设置并进行早期绑定即可。

如果感到困惑,一个技巧是把 COM host 和 TLB 分开来思考

  • 启动入口是 *.comhost.dll
  • 类型信息是 *.tlb
  • 实现主体是 *.dll

12. 参考资料

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

常见问题

汇总了咨询这一主题时常见的问题。

要让 VBA 以带类型(早期绑定)的方式调用 .NET 8 的 DLL,需要做什么?
需要把 .NET 8 类库用 EnableComHosting=true 编译以生成 *.comhost.dll,再用 dscom tlbexport 生成 *.tlb。接下来用 regsvr32 注册 *.comhost.dll,用 dscom tlbregister 注册 *.tlb,然后在 VBA 的引用设置中添加该 TLB,就可以用 Dim x As 库名.IYourInterface 的形式带类型使用。角色分工是:COM 的启动入口是 *.comhost.dll,VBA 看到的类型信息是 *.tlb,实现主体是 *.dll。
出现「ActiveX 组件不能创建对象」错误的原因是什么?
典型原因是 Office/VBA 与 COM 服务器的 bitness 不一致。如果是 64bit Office,需要用 x64/win-x64 编译并通过 System32 下的 regsvr32 注册;如果是 32bit Office(运行在 64bit Windows 上),则需要用 x86/win-x86 编译并通过 SysWOW64 下的 regsvr32 注册,生成 TLB 时也要用 dscom32.exe。在 .NET 5+ 的 COM host 中,如果保持 AnyCPU,*.comhost.dll 往往会偏向 64bit,可能与 32bit Office 不匹配,因此按照 Office 的位数明确指定 x86 或 x64 会更安全。
不能使用 ClassInterfaceType.AutoDual 吗?
虽然看起来省事,但公开之后一旦调整成员顺序或结构就很容易出问题,因此应该避免使用。如果想让 VBA 以带类型的方式稳定使用,标准做法是定义明确的接口,类使用 ClassInterfaceType.None,供 VBA 使用的接口则设为 InterfaceIsDual。预先分配 DispId,可以减少日后调整方法顺序时出错的风险。另外,在 COM 中 GUID 本身就是契约,公开之后若轻率地重新生成 IID 或 CLSID,会破坏现有的 VBA 引用和注册。
分发时只提供单个 DLL 就可以了吗?
只有 DLL 是不能运行的。需要把实现主体 *.dll、*.comhost.dll、*.deps.json、*.runtimeconfig.json、*.tlb,以及必要的依赖 DLL 一起打包部署。此外,客户端电脑还需要安装对应版本的 .NET 8 运行时,因为 COM host 并不是 self-contained 发布,基本上属于 framework-dependent 的运维方式。还要注意,如果之后更改了部署位置,也需要重新执行注册。

作者简介

本文作者的个人简介页面。

Go Komura

小村软件有限公司 代表

以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表