简体中文
dotnet 使用 DotNetHost.Win32Metadata
背景
平时在 C# 里加载别的程序集,一个 Assembly.LoadFrom 就够了。但如果宿主和组件压根不是同一个 .NET 运行时,引用程序集这条路就走不通。我碰到的具体场景有这么几个:
想要 Native AOT 的启动速度,但又想要反射、插件加载、动态执行代码的能力。而这两件事在 Native AOT 里是天然冲突的
想要在一个 .NET Framework 4.8.1 的老程序里,跑一段最新的 .NET 10 的代码
想要让程序自己决定入口,比如同一个安装包里放多个版本的业务程序集,由启动器决定这次用哪一个
这三件事看起来毫不相干,但解法是同一个:不要试图引用另一个运行时,而是把另一个运行时拉起来。在 Windows 上把 .NET 运行时拉起来,微软给的官方做法就是 hostfxr
问题是,现在没有一个现成的官方方案能让我在 C# 里直接用这套东西。要在 C# 里用它,只能自己去读 nethost.h、hostfxr.h、coreclr_delegates.h 这几个头文件,把里面的 typedef、结构体、枚举一条条翻译成 C# 的 delegate、struct、enum,再配上几十个 DllImport
翻译本身不是不能做,只是有些体力活。想找找有哪位大佬干了这个活,造福大家
我找到的方案就是 wherewhere 大佬开源的 DotNetHost.Win32Metadata 库:https://github.com/wherewhere/DotNetHost.Win32Metadata 它做的正是这份翻译,而且是跟着 .NET SDK 版本自动生成的。
三个用途
第一个用途:在 Native AOT 下使用 .NET 非 AOT 的内容
Native AOT 发布之后的程序是没有 JIT 的,所谓的「动态」只能靠 AOT 编译期就确定下来的东西来支撑。反射拿到的东西受限于裁剪,Assembly.LoadFrom 更是直接不可用。用上这个库之后,思路就变成了:AOT 的宿主只负责极速启动,所有需要动态能力的业务代码,都以普通的非 AOT 程序集形式放在旁边,由宿主在运行时把 .NET 运行时拉起来再执行
这就等于用 AOT 的启动时间换到了非 AOT 的动态功能,可以理解成一个手动版的 ReadyToRun:外层是原生代码,内层还是 JIT 的托管代码。这个用途我觉得是三个里面最有价值的
第二个用途:在不同的 .NET 版本之间相互引用,甚至到旧 .NET Framework 版本
.NET Framework 4.8.1 的程序是没法引用 .NET 10 的程序集的,反过来也一样。而这个库让两边可以互相把对方拉起来跑
它有一个明显缺点得先说清楚:两边的代码不能像同进程那样直接互相调用,中间隔着一层函数指针和字符串类型名,参数和返回值都要走原生能表达的形态,实际上接近跨进程通信的用法,能传的东西很有限。所以它的优点是有限的,但在一些只想复用一段新代码的场景里,它是真有用的
第三个用途:由应用程序自己决定入口
这个用途是我在用的时候才意识到的。既然加载哪个程序集、用哪个类型做入口、调用哪个方法,全都是宿主运行时拼出来的字符串,那宿主就可以自己定规则去选择
最典型的场景是带 OTA 自动更新的程序:只需要一个固定的启动器 exe,业务程序集按版本号分目录摆放
App.exe
1.0.0/App.dll
2.0.0/App.dll2
3
App.exe 根据版本号规则决定这次动态加载哪个目录里的 App.dll。再配合一份配置文件,当某个版本加载失败时,直接回退到上一个可用版本
这样做有一个附带的好处:整个加载过程都在同一个入口里,统一的异常处理和崩溃上报也就有地方放了。以前做成多个 exe 互相启动的写法,是很难做到这一点的
库的定位
这个库已经发布成 DotNetHost.Win32Metadata,本文使用的版本是 10.0.2.1,对应 .NET 10 版本
它在链条里的位置是这样的
- DotNetHost.Win32Metadata → 提供图纸
- CsWin32 → 按图纸盖房子
具体来说,DotNetHost.Win32Metadata 里装的是从 CoreCLR 原生头文件生成出来的 .winmd 元数据,也就是说明这份图纸。真正读图纸、把 C# 的 delegate、struct、enum 生成出来的,是 Microsoft.Windows.CsWin32。所以这两个包必须一起装,单装元数据包什么都不会发生
装完包之后,包里会自动注入一段 props,帮你把元数据和 API 文档接进 CsWin32,内容如下
<AppLocalAllowedLibraries Include="nethost" />
<ProjectionDocs Include="...\apidocs.msgpack" />
<ProjectionMetadataWinmd Include="...\DotNetHost.winmd" />2
3
其中 apidocs.msgpack 是配套的官方注释文档,有了它,在 IDE 里悬停到 hostfxr_initialize_for_runtime_config_fn 这类类型上,就能看到官方文档里的说明,不用再翻头文件
用法就三步
第一步:准备一个 SDK 风格的项目
这一条是硬要求:项目文件必须是 SDK 格式,也就是带 <Project Sdk="Microsoft.NET.Sdk"> 的那种。老式的 csproj 用不了,因为 CsWin32 的代码生成是挂在 SDK 风格项目的编译流程上的
宿主的目标框架可以随你,示例里就有一个是 .NET Framework 的
<TargetFramework>net481</TargetFramework>第二步:装两个包
<ItemGroup>
<PackageReference Include="DotNetHost.Win32Metadata" Version="10.0.2.1" />
<PackageReference Include="Microsoft.Windows.CsWin32" Version="0.3.269" PrivateAssets="all" />
</ItemGroup>2
3
4
这里再强调一次为什么是两个包。DotNetHost.Win32Metadata 只提供 .winmd 元数据文件,也就是前面说的图纸,它本身不生成任何 C# 代码;真正干活的是 Microsoft.Windows.CsWin32,它把图纸读进来,生成 DllImport 和配套的类型。少一个都跑不起来
第三步:写 NativeMethods.txt 声明要哪些函数
在项目根目录放一个 NativeMethods.txt,一行一个函数名
hostfxr_initialize_for_runtime_config_fn
hostfxr_get_runtime_delegate_fn
hostfxr_close_fn
load_assembly_and_get_function_pointer_fn
get_hostfxr_path
LoadLibrary
GetProcAddress2
3
4
5
6
7
8
CsWin32 在编译时会按这个清单生成对应的 C# 声明,写多少生成多少,不写就不生成。所以这个清单要分两段看:前三行加第四行是 hostfxr 提供的函数指针类型,下面三行是要真正 P/Invoke 进去的原生函数
这里请记得:NativeMethods.txt 是要被当成 AdditionalFiles 传给 CsWin32 的。放在项目根目录的时候 CsWin32 会自己找,但 Demo 里三个宿主共用同一份清单,所以是显式加的
<AdditionalFiles Include="..\Shared\NativeMethods.txt" Link="NativeMethods.txt" />到这里为止,一行 DllImport 都还没有手写,需要的声明已经有了
宿主怎么把运行时拉起来
接下来是这个方案真正的核心:把运行时拉起来并调用到托管方法。Demo 里这段逻辑抽在 Shared/HostFxrRunner.cs 里,三个宿主共用同一份,通过下面的写法把源码链接进来编译,而不是做成一个共享库
<Compile Include="..\Shared\HostFxrRunner.cs" Link="HostFxrRunner.cs" />这个方法的签名如下。传入的六个参数里前五个都是字符串,全部由调用方给,runner 自己不做任何判断
internal static unsafe int Run
(
string runtimeConfigPath,
string assemblyPath,
string typeName,
string methodName,
string delegateTypeName,
string[] args
)2
3
4
5
6
7
8
9
光看这个签名就能明白这套方案的契约形态:宿主和组件之间靠「名字」对上。整个流程可以拆成五步,下面按顺序讲。具体整个 HostFxrRunner 代码,如感兴趣,可用文末提供的方法拉取我整个代码仓库获取全部代码
第一步:解决两个 PInvoke 类重名
先看文件一开始的引用
using System.Runtime.Hosting.Native;
using Windows.Win32;
using Windows.Win32.Foundation;
using DotNetHost = System.Runtime.Hosting.Native.PInvoke;
using PInvoke = Windows.Win32.PInvoke;2
3
4
5
6
这里 get_hostfxr_path 是由 DotNetHost.Win32Metadata 生成的,落在 System.Runtime.Hosting.Native 命名空间下,因为这个库把原生 API 映射到了 System.Runtime.Hosting.Native 这个命名空间;而 LoadLibrary、GetProcAddress 来自 CsWin32 自带的 Win32 元数据
两边都叫 PInvoke,所以必须起别名。示例里把项目自己的那个起名为 DotNetHost,把 Win32 的那个起名为 PInvoke。这是使用时第一个会踩到的地方,照抄这个别名写法就行
第二步:找到 hostfxr.dll
const int PathMax = 260;
char* hostFxrPath = stackalloc char[PathMax];
nuint hostFxrPathSize = PathMax;
int result = DotNetHost.get_hostfxr_path(hostFxrPath, &hostFxrPathSize);
ThrowIfFailed(result, "Locating hostfxr failed.");2
3
4
5
get_hostfxr_path 做的是「问 nethost:当前这台机器上,跟这个程序匹配的 hostfxr.dll 在哪」。生成的签名是 char* 加 nuint*,所以这里用 stackalloc char[] 开缓冲区,再把大小也一起传进去
这里有个细节值得留意:因为 C# 的 char 和 Windows 的 wchar_t 都是 UTF-16,生成的声明可以直接用 stackalloc char[] 接收文件名,不需要做任何编码转换。要是这份翻译没做好,这一步就得自己处理宽字符了
拿到路径之后先用一个缓冲区接住,而不是直接 LoadLibrary,是因为后面加载还需要这个路径去查导出函数
第三步:加载 hostfxr.dll 并取出三个函数
using FreeLibrarySafeHandle library = new(PInvoke.LoadLibrary(new PCWSTR(hostFxrPath)), true);
if (library.IsInvalid)
{
throw new InvalidOperationException("Loading hostfxr failed.");
}
hostfxr_initialize_for_runtime_config_fn initialize = GetExport<hostfxr_initialize_for_runtime_config_fn>(library, "hostfxr_initialize_for_runtime_config");
hostfxr_get_runtime_delegate_fn getRuntimeDelegate = GetExport<hostfxr_get_runtime_delegate_fn>(library, "hostfxr_get_runtime_delegate");
hostfxr_close_fn close = GetExport<hostfxr_close_fn>(library, "hostfxr_close");2
3
4
5
6
7
8
9
LoadLibrary 返回的不是裸句柄,而是 FreeLibrarySafeHandle。这是 CsWin32 生成代码时顺手带来的好处:原生句柄被包装成 SafeHandle,用 using 包一层,出作用域时会自动 FreeLibrary,不用担心异常路径下漏掉释放
取导出的辅助方法也一并看一眼
private static TDelegate GetExport<TDelegate>(FreeLibrarySafeHandle library, string name) where TDelegate : Delegate
{
FARPROC address = PInvoke.GetProcAddress(library, name);
if (address.IsNull)
{
throw new MissingMethodException($"The hostfxr export '{name}' was not found.");
}
return address.CreateDelegate<TDelegate>();
}2
3
4
5
6
7
8
9
10
GetProcAddress 拿到的是 FARPROC,直接 CreateDelegate<TDelegate>() 就变成了强类型的委托,泛型参数就是 NativeMethods.txt 里声明过的那些 hostfxr_xxx_fn 类型。这一步之所以能这么干净,是因为元数据里那些函数指针类型已经被生成成了 C# 的 delegate,否则这里得手写 Marshal.GetDelegateForFunctionPointer 再转 IntPtr
第四步:初始化运行时,拿到加载程序集的委托
hostfxr_handle context = default;
fixed (char* runtimeConfigPathPointer = runtimeConfigPath)
{
result = initialize(runtimeConfigPathPointer, null, &context);
}
ThrowIfFailed(result, "Initializing the .NET runtime failed.");
if (context.IsNull)
{
throw new InvalidOperationException("hostfxr returned an empty runtime context.");
}2
3
4
5
6
7
8
9
10
11
initialize 接收的就是那个 runtimeconfig.json 路径,这份配置文件决定了要启动的运行时是哪个版本,我在后一节的部署部分会单独讲
初始化成功后会返回一个 context,类型是 hostfxr_handle。这个句柄是后面所有操作的入口,也是最后要关掉的东西,所以整个加载过程都写在 try 里面,finally 里负责关闭
拿到 context 之后,向运行时索取「加载程序集并取函数指针」的委托
FARPROC loadAssemblyPointer = default;
result = getRuntimeDelegate
(
context,
hostfxr_delegate_type.hdt_load_assembly_and_get_function_pointer,
&loadAssemblyPointer
);
ThrowIfFailed(result, "Getting the assembly loader failed.");
load_assembly_and_get_function_pointer_fn loadAssembly = loadAssemblyPointer.CreateDelegate<load_assembly_and_get_function_pointer_fn>();2
3
4
5
6
7
8
9
10
为什么中间要绕一下 FARPROC?因为 hostfxr_get_runtime_delegate 返回的是一块裸的委托指针,同一份 API 可以按 hostfxr_delegate_type 返回好几种不同的委托,要转成哪种由调用方自己决定
第五步:加载托管入口并调用
FARPROC entryPointPointer = default;
fixed (char* assemblyPathPointer = assemblyPath)
fixed (char* typeNamePointer = typeName)
fixed (char* methodNamePointer = methodName)
fixed (char* delegateTypeNamePointer = delegateTypeName)
{
result = loadAssembly
(
assemblyPathPointer,
typeNamePointer,
methodNamePointer,
delegateTypeNamePointer,
null,
&entryPointPointer
);
}
ThrowIfFailed(result, "Loading the managed entry point failed.");
ComponentEntryPoint entryPoint = entryPointPointer.CreateDelegate<ComponentEntryPoint>();
return entryPoint(args);2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
loadAssembly 的六个参数是全篇最需要理解的地方,因为它们把「同一个进程里两个运行时如何握手」这件事说清楚了
程序集路径:要加载哪个 dll
类型名:入口类型,格式是
类型全名, 程序集名方法名:入口方法名
委托类型名:托管侧声明的委托类型全名,用来约束方法签名
配置参数:这里是
null出参:拿到的函数指针
这五样东西里有四样是字符串。也就是说,两个运行时之间唯一的契约是「名字」,这也是前面说它「接近跨进程」的原因:类型安全完全靠这套名字对得上,对不上就会失败
这里还有个小坑请记得:官方的示例里用的是 Unsafe.AsPointer(ref MemoryMarshal.GetReference(span)) 这种写法来传字符串,原因是它手上是一个 ReadOnlySpan,而 ReadOnlySpan 是没法 fixed 的。Demo 里把参数换成了普通的 string,就可以直接用 fixed 了,两种写法按自己的数据类型挑就行
最后一行的 ComponentEntryPoint 是宿主和组件约定好的委托签名,它整个定义就一行
internal delegate int ComponentEntryPoint(string[] args);宿主和组件两边都要有这个签名完全一致的委托类型,这算是整套方案里唯一的「手工契约」
出错时只能看十六进制错误码
private static void ThrowIfFailed(int result, string message)
{
if (result != 0)
{
throw new InvalidOperationException($"{message} hostfxr result: 0x{result:X8}.");
}
}2
3
4
5
6
7
hostfxr 的错误码都是 0x80008xxx 这一族 HRESULT,直接抛 int 看不出任何信息,所以这里统一按 X8 十六进制格式化,查错时再去对照 hostfxr 的官方错误码表。官方示例这一步用的是 Marshal.ThrowExceptionForHR,那样能拿到 HRESULT 的文本描述,但拿不到「是哪一步挂的」,这里改成带上上下文文案的写法,至少知道是哪一步失败了
什么是组件
前面一直说「由宿主加载组件」,这里需要把组件这个概念说清楚
在这个方案里,组件指的是被宿主加载的那个普通的 .NET 10 类库程序集,也就是宿主启动的 .NET 10 运行时里真正干活的 dll。它不是 NuGet 包,也不用做成特殊的项目类型,就是一个普通的 Microsoft
这里补一句:如果加载的 .NET 10 是一个带 exe 入口的行不行?那肯定也是行的,而且不需要 GenerateRuntimeConfigurationFiles 的存在,因为 exe 本来就生成自己的 runtimeconfig.json,直接的 Main 也就是导出了,方法名写 <Main>$ 就能取到。官方示例 DotNetHost.Win32Metadata.Test 就是这么干的,自己加载自己的 exe,类型写成 Program, DotNetHost.Win32Metadata.Test,方法名写成 <Main>$。只是说正常做被加载的,很少是带 exe 入口的,而是做组件而已
看一下 Alpha 组件的全部内容
namespace ManagedComponent.Alpha;
public delegate int ComponentEntryPoint(string[] args);
public static class Component
{
public static int Run(string[] args)
{
Console.WriteLine($"Alpha component is running on .NET {Environment.Version}.");
Console.WriteLine($"Arguments: {string.Join(", ", args)}");
return 101;
}
}2
3
4
5
6
7
8
9
10
11
12
13
一个委托、一个静态类、一个静态方法,就这么多。它对自己的处境一无所知,也不需要知道宿主是什么
组件这边的项目文件只需要在普通类库的基础上加一句
``xml
这一句是为了让组件生成自己的 `runtimeconfig.json`,也就是前面 `hostfxr` 初始化时要用的那份配置。Beta 组件和 Alpha 完全一样,只是命名空间和返回值不同
回到最开始那个「一个进程里两个运行时」的观察,现在可以把它讲完整了。组件里打印的 `Environment.Version` 是 `10.x`。而在 .NET Framework 宿主这一侧,如果加一句打印自己的版本
```cs
Console.WriteLine($"FrameworkHost is running on .NET {Environment.Version}.");2
3
4
5
6
7
打印出来是 4.0.30319。同一个进程里,两个不同的运行时各自报着不同的版本号,这正是这套方案最有意思的地方,也是前面三个用途能成立的根本原因
路由版宿主是怎么决定入口的
前面说的第三个用途「应用程序决定入口」,在 Demo 里对应的是 NativeAotRouter。它的做法是先用参数选出一个描述对象,再用描述对象去拼所有需要的符号
描述对象只装三样东西,全是字符串
internal sealed record ComponentDescriptor(string AssemblyName, string TypeName, string DelegateTypeName);选的过程就是一次 switch,如下面代码。这里我给每个组件把程序集名、入口类型名、委托类型名都写在了一起,原因看下一步就明白了
ComponentDescriptor? component = args[0].ToLowerInvariant() switch
{
"alpha" => new
(
"ManagedComponent.Alpha",
"ManagedComponent.Alpha.Component",
"ManagedComponent.Alpha.ComponentEntryPoint"
),
"beta" => new
(
"ManagedComponent.Beta",
"ManagedComponent.Beta.Component",
"ManagedComponent.Beta.ComponentEntryPoint"
),
_ => null
};2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
选中之后,路径和完整的类型名都是从这三个字段拼出来的
return HostFxrRunner.Run
(
Path.Combine(baseDirectory, $"{component.AssemblyName}.runtimeconfig.json"),
Path.Combine(baseDirectory, $"{component.AssemblyName}.dll"),
$"{component.TypeName}, {component.AssemblyName}",
"Run",
$"{component.DelegateTypeName}, {component.AssemblyName}",
componentArguments
);2
3
4
5
6
7
8
9
这段代码本身很朴素,但它说明了真正重要的一点:因为入口的全部信息都是运行时可拼的字符串,所以「用哪个程序集」这个决定可以留给程序自己在运行时做。前面 OTA 多版本回退的场景,无非就是把 switch 换成读配置文件和版本号比较,把失败的情况接个 catch 再换下一个版本重试
宿主项目文件里的约定
库的用法只有三步,但真正让人卡住的往往是部署,所以这部分单独讲
必须自己带上 nethost.dll
get_hostfxr_path 这个函数住在 nethost.dll 里,而这个 dll 不在 NuGet 包里,得自己从 .NET SDK 里取出来放到输出目录
<PropertyGroup>
<AppHostPath>$(NetCoreTargetingPackRoot)\Microsoft.NETCore.App.Host.win-x64\$(BundledNETCoreAppPackageVersion)\runtimes\win-x64\native</AppHostPath>
</PropertyGroup>
<ItemGroup>
<ReferenceCopyLocalPaths Include="$(AppHostPath)\nethost.dll" />
</ItemGroup>2
3
4
5
6
7
hostfxr.dll 不用自己操心,nethost 会帮你找到
这里要提醒的是位数问题:nethost.dll 是分 win-x64 和 win-x86 的,两者不能混用。如果宿主进程是 32 位的,却把 64 位的 nethost.dll 拷到旁边,就会在 LoadLibrary 那一步直接失败,而且错误信息只会告诉你加载不了,不会告诉你是位数不对。.NET Framework 默认是 AnyCPU,在 64 位系统上会跑成 64 位进程,很容易和 x86 的 dll 混在一起
所以 Demo 在 Directory.Build.props 里把所有项目统一钉成了 x64
<Platforms>x64</Platforms>
<PlatformTarget>x64</PlatformTarget>2
上面那段 AppHostPath 里的 win-x64 也是同一个意思,必须和宿主的位数一致
别忘了 runtimeconfig.json
前面说过,hostfxr 初始化时必须有一份 runtimeconfig.json 告诉它要启动哪个运行时
{
"runtimeOptions":
{
"tfm": "net10.0",
"framework":
{
"name": "Microsoft.NETCore.App",
"version": "10.0.0"
}
}
}2
3
4
5
6
7
8
9
10
11
这份文件由组件项目在编译时生成,但它生成在组件的输出目录里,宿主运行时读的是宿主自己旁边的路径,所以要把它拷过去
把组件连同配置一起拷到宿主输出目录
这部分没有现成机制,得自己写 MSBuild Target。宿主先确保组件被编译
<Target Name="BuildManagedComponent" BeforeTargets="Build">
<MSBuild Projects="..\ManagedComponent.Alpha\ManagedComponent.Alpha.csproj" Targets="Build" Properties="Configuration=$(Configuration)" />
</Target>2
3
再把组件产物拷过去
<Target Name="CopyManagedComponent" AfterTargets="Build">
<ItemGroup>
<ManagedComponentFiles Include="..\ManagedComponent.Alpha\bin\$(Configuration)\net10.0\ManagedComponent.Alpha.dll" />
<ManagedComponentFiles Include="..\ManagedComponent.Alpha\bin\$(Configuration)\net10.0\ManagedComponent.Alpha.runtimeconfig.json" />
</ItemGroup>
<Copy SourceFiles="@(ManagedComponentFiles)" DestinationFolder="$(OutputPath)" SkipUnchangedFiles="true" />
</Target>2
3
4
5
6
7
dll 和 runtimeconfig.json 要成对拷贝,少一份就在运行时挂掉。另外 Native AOT 的宿主还多一个坑:AOT 的产物在 publish 目录而不是 bin 目录,所以 CopyManagedComponent 要同时挂到 Build 和 Publish 两个时机上,并且分别往 $(OutputPath) 和 $(PublishDir) 拷
<Target Name="CopyManagedComponent" AfterTargets="Build;Publish">
<ItemGroup>
<ManagedComponentFiles Include="..\ManagedComponent.Alpha\bin\$(Configuration)\net10.0\ManagedComponent.Alpha.dll" />
<ManagedComponentFiles Include="..\ManagedComponent.Alpha\bin\$(Configuration)\net10.0\ManagedComponent.Alpha.runtimeconfig.json" />
</ItemGroup>
<Copy SourceFiles="@(ManagedComponentFiles)" DestinationFolder="$(OutputPath)" SkipUnchangedFiles="true" />
<Copy SourceFiles="@(ManagedComponentFiles)" DestinationFolder="$(PublishDir)" SkipUnchangedFiles="true" Condition="'$(PublishDir)' != ''" />
</Target>2
3
4
5
6
7
8
这个 Condition 不能省,因为 dotnet build 的时候 $(PublishDir) 是空的,不判断条件它会把文件拷到一个不存在的路径上去
实际用起来的感觉
顺手的地方
真的不用碰 C++。整个 Demo 里没有一个 C++ 项目,也没有一行手写的 DllImport,需要的声明全在 NativeMethods.txt 里列个名字就行
生成的类型是强类型的。hostfxr_handle、hostfxr_delegate_type 这些在 C# 里都是正常的类型,CreateDelegate<T> 直接给出强类型委托,不用到处跟 IntPtr 打交道
句柄被包装成 SafeHandle。LoadLibrary 返回 FreeLibrarySafeHandle,using 一包就完事,这是手抄版最容易漏的东西
跟着 SDK 版本走。元数据的版本号是绑在 $(BundledNETCoreAppPackageVersion) 上的,SDK 升级之后包也跟着发新版本,不需要自己维护一份翻译稿
有注释。包里带了 apidocs.msgpack,悬停能看到官方文档原文,查 API 语义不用翻头文件
宿主端的目标框架很自由。这一点在 .NET Framework 4.8.1 上体现得最明显,因为这个库跟 .NET Framework 完全独立,只要项目是 SDK 风格就能用
不太舒服的地方
两个 PInvoke 类重名。每次新建宿主项目都要重新想一遍别名,虽然只是抄一遍 using,但第一次遇到时确实会愣一下
nethost.dll 的位数要对上。库能帮你生成代码,但没法帮你判断进程位数,错了就只能在 LoadLibrary 看到一句没有信息量的失败
部署要自己写 MSBuild。组件 dll 和它的 runtimeconfig.json 都要拷到宿主输出目录,AOT 宿主还要多处理一层 PublishDir,这几行 Target 基本是每个项目都要复制一遍
运行时拉起来就关不掉。hostfxr_close 关掉的只是那个 context,已经加载进进程的 .NET 运行时和程序集不会卸载。所以这个方案适合「启动时决定一次入口」,不适合反复加载卸载。前面说的 OTA 回退场景,回退也只能发生在还没成功初始化的阶段,一旦某个版本跑起来了,想换版本基本就得重启进程
出错时不好定位。拿到手的只有 0x80008xxx 这样的错误码,得去翻 hostfxr 的错误码表。好在示例在每次失败时都带了上下文文案,至少知道是哪一步挂的
互调用近似于跨进程。没办法像直接调用方法或属性的方式直接访问,只能按照跨进程协约方式工作。这就限制了很多实现情景了。不会比多进程调用的方案有非常明显的提升
总的来说,DotNetHost.Win32Metadata 真正的价值是让不同形态的 .NET 能互相加载:Native AOT 与非 AOT 之间、新版本与旧版本之间、.NET 10 与 .NET Framework 4.8.1 之间
