随着 .NET 8 的发布,一种新的“时尚”应用模型 NativeAOT 开始在各种真实世界的应用中广泛使用。
除了对 NativeAOT 工具链的基本使用外,“NativeAOT”一词还带有原生世界的所有限制,因此您必须知道如何处理这些问题才能正确使用它。
在这篇博客中,我将讨论它们。
使用 NativeAOT 非常简单,只需要在发布应用时使用 MSBuild 传递一个属性 PublishAot=true
即可。
通常,它可以是:
dotnet publish -c Release -r win-x64 /p:PublishAot=true
其中 win-x64
是运行时标识符,可以替换为 linux-x64
,osx-arm64
或其他平台。您必须指定它,因为 NativeAOT 需要为您指定的运行时标识符生成原生代码。
然后发布的应用可以在 bin/Release/<target framework>/<runtime identifier>/publish
中找到
在讨论使用 NativeAOT 时可能遇到的各种问题的解决方案之前,我们需要稍微深入一点,看看 NativeAOT 是如何编译代码的。
我们经常听说 NativeAOT 会剪裁掉没有被使用的代码。而实际上,它并不像 IL 剪裁那样从程序集中剪裁掉不必要的代码,而是只编译代码中引用的东西。
NativeAOT 编译包括两个阶段:
请注意,在编译过程中可能会出现一些“延迟”的依赖,因此上述两个阶段可能会交错出现。
这意味着,在分析过程中没有被计算为依赖的任何东西最终都不会被编译。
依赖图是在编译期间静态构建的,这也意味着任何无法静态分析的东西都不会被编译。不幸的是,反射,即在不事先告诉编译器的情况下在运行时获取东西,正是编译器无法弄清楚的一件事。
NativeAOT 编译器有一些能力可以根据编译时的字面量来推断出反射调用需要什么东西。
例如:
var type = Type.GetType("Foo");
Activator.CreateInstance(type);
class Foo
{
public Foo() => Console.WriteLine("Foo instantiated");
}
上面的反射目标(即 Foo
)可以被编译器弄清楚,因为编译器可以看到你试图获取类型 Foo
,所以类型 Foo
会被标记为一个依赖,这导致 Foo
被编译到最终的产物中。
如果你运行这个程序,它会如预期地打印 Foo instantiated
。
但是如果我们将代码改为如下:
var type = Type.GetType(Console.ReadLine());
Activator.CreateInstance(type);
class Foo
{
public Foo() => Console.WriteLine("Foo instantiated");
}
现在让我们用 NativeAOT 构建并运行这个程序,然后输入 Foo
来创建一个 Foo
的实例。你会立刻得到一个异常:
Unhandled Exception: System.ArgumentNullException: Value cannot be null. (Parameter 'type')
at System.ArgumentNullException.Throw(String) + 0x2b
at System.ActivatorImplementation.CreateInstance(Type, Boolean) + 0xe7
...
这是因为编译器无法看到你在哪里使用了 Foo
,所以它根本不会为 Foo
生成任何代码,导致这里的 type
为 null
。
此外,依赖分析是精确到单个方法的,这意味着即使一个类型被认为是一个依赖,如果该类型中的任何方法没有被使用,该方法也不会被包含在代码生成中。
虽然这可以通过将所有类型和方法添加到依赖图中来解决,这样编译器就会为它们生成代码。这就是 TrimmerRootAssembly
的作用:通过提供 TrimmerRootAssembly
,NativeAOT 编译器会将你指定的程序集中的所有东西都作为根。
但是涉及泛型的情况就不是这样了。
在 .NET 中,我们有泛型,编译器会为每个非共享的泛型类型和方法生成不同的代码。
假设我们有一个类型 Point<T>
:
struct Point<T>
{
public T X, Y;
}
如果我们有一段代码试图使用 Point<int>
,编译器会为 Point<int>
生成专门的代码,使得 Point.X
和 Point.Y
都是 int
。如果我们有一个 Point<float>
,编译器会生成另一个专门的代码,使得 Point.X
和 Point.Y
都是 float
。
通常情况下,这不会导致任何问题,因为编译器可以静态地找出你在代码中使用的所有实例化,直到你试图使用反射来构造一个泛型类型或一个泛型方法:
var type = Type.GetType(Console.ReadLine());
var pointType = typeof(Point<>).MakeGenericType(type);
上面的代码在 NativeAOT 下不会工作,因为编译器无法推断出 Point<T>
的实例化,所以编译器既不会生成 Point<int>
的代码,也不会生成 Point<float>
的代码。
尽管编译器可以为 int
,float
,甚至泛型类型定义 Point<>
生成代码,但是如果编译器没有生成 Point<int>
的实例化代码,你就无法使用 Point<int>
。
即使你使用 TrimmerRootAssembly
来告诉编译器将你的程序集中的所有东西都作为根,也仍然不会为像 Point<int>
或 Point<float>
这样的实例化生成代码,因为它们需要根据类型参数来单独构造。
既然我们已经找出了在 NativeAOT 下可能发生的潜在问题,让我们来谈谈解决方案。
最简单的想法是,我们可以通过在代码中使用它来让编译器知道我们需要什么。
例如,对于代码
var type = Type.GetType(Console.ReadLine());
var pointType = typeof(Point<>).MakeGenericType(type);
只要我们知道我们要使用 Point<int>
和 Point<float>
,我们可以在其他地方使用它一次,然后编译器就会为它们生成代码:
// 我们使用一个永远为假的条件来确保代码不会被执行
// 因为我们只想让编译器知道依赖关系
// 注意,如果我们在这里简单地使用一个 `if (false)`
// 这个分支会被编译器完全移除,因为它是多余的
// 所以,让我们在这里使用一个不平凡但不可能的条件
if (DateTime.Now.Year < 0)
{
var list = new List<Type>();
list.Add(typeof(Point<int>));
list.Add(typeof(Point<float>));
}
我们有一个属性 DynamicDependencyAttribute
来告诉编译器一个方法依赖于另一个类型或方法。
所以我们可以利用它来告诉编译器:“如果 A 被包含在依赖图中,那么也添加 B”。
下面是一个例子:
class Foo
{
readonly Type t = typeof(Bar);
[DynamicDependency(DynamicallyAccessedMemberTypes.PublicProperties, typeof(Bar))]
public void A()
{
foreach (var prop in t.GetProperties())
{
Console.WriteLine(prop);
}
}
}
class Bar
{
public int X { get; set; }
public int Y { get; set; }
}
现在只要编译器发现有任何代码路径调用了 Foo.A
,Bar
中的所有公共属性都会被添加到依赖图中,这样我们就能够对 Bar
的每个公共属性进行动态反射调用。
这个属性还有许多重载,可以接受不同的参数来适应不同的用例,您可以在这里查看文档。
此外,现在我们知道 Foo.A
中的动态反射在剪裁和 NativeAOT 下不会造成任何问题,我们可以使用 UnconditionalSuppressMessage
来抑制警告信息,这样在构建过程中就不会再产生任何警告了。
class Foo
{
readonly Type t = typeof(Bar);
[DynamicDependency(DynamicallyAccessedMemberTypes.PublicProperties, typeof(Bar))]
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL2080",
Justification = "The properties of Bar have been preserved by DynamicDependency.")]
public void A()
{
foreach (var prop in t.GetProperties())
{
Console.WriteLine(prop);
}
}
}
有时我们试图动态地访问类型 T
的成员,其中 T
可以是一个类型参数或一个 Type
的实例:
void Foo<T>()
{
foreach (var prop in typeof(T).GetProperties())
{
Console.WriteLine(prop);
}
}
class Bar
{
public int X { get; set; }
public int Y { get; set; }
}
如果我们调用 Foo<Bar>
,很不幸,这在 NativeAOT 下不会工作。编译器确实看到你是用类型参数 Bar
调用 Foo
的,但在 Foo<T>
的上下文中,编译器不知道 T
是什么,而且没有其他代码直接使用 Bar
的属性,所以编译器不会为 Bar
的属性生成代码。
这里我们可以使用 DynamicallyAccessedMembers
来告诉编译器为 T
的所有公共属性生成代码:
void Foo<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] T>()
{
// ...
}
现在当编译器编译调用 Foo<Bar>
时,它知道 T
(特别的,这里指 Bar
)的所有公共属性都应该被视为依赖。
这个属性也可以应用在一个 Type
上:
Foo(typeof(Bar));
void Foo([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type t)
{
foreach (var prop in t.GetProperties())
{
Console.WriteLine(prop);
}
}
甚至在一个 string
上:
Foo("Bar");
void Foo([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] string s)
{
foreach (var prop in Type.GetType(s).GetProperties())
{
Console.WriteLine(prop);
}
}
所以在这里你可能会发现我们有一个替代方案,用于我们在 DynamicDependency
一节中提到的代码示例:
class Foo
{
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)]
readonly Type t = typeof(Bar);
public void A()
{
foreach (var prop in t.GetProperties())
{
Console.WriteLine(prop);
}
}
}
顺便说一句,这也是推荐的方法。
如果你不拥有代码,但你仍然希望代码在 NativeAOT 下工作。你可以尝试使用 TrimmerRootAssembly
来告诉编译器将一个程序集中的所有类型和方法都作为依赖。但请注意,这种方法不适用于泛型实例化。
<ItemGroup>
<TrimmerRootAssembly Include="MyAssembly" />
</ItemGroup>
对于高级用户,他们可能想要控制从一个程序集中包含什么。在这种情况下,可以指定一个 TrimmerRootDescriptor
:
<ItemGroup>
<TrimmerRootDescriptor Include="link.xml" />
</ItemGroup>
TrimmerRootDescriptor 文件的文档和格式可以在这里找到。
对于泛型实例化的情况,它们无法通过 TrimmerRootAssembly 或 TrimmerRootDescriptor 来解决,这里需要一个包含 runtime directives 的文件来告诉编译器需要编译的东西。
<ItemGroup>
<RdXmlFile Include="rd.xml" />
</ItemGroup>
在 rd.xml
中,你可以为你的泛型类型和方法指定实例化。
rd.xml
文件的文档和格式可以在这里找到。
这种方法不推荐,但它可以解决你在使用 NativeAOT 时遇到的一些难题。请在使用 trimmer descriptor 或 runtime directives 之前,总是考虑用 DynamicallyAccessedMembers
和 DynamicDependency
来注释你的代码,使其与剪裁/AOT 兼容。
NativeAOT 是 .NET 中一个非常棒和强大的工具。有了 NativeAOT,你可以以可预测的性能构建你的应用,同时节省资源(更低的内存占用和更小的二进制大小)。
它还将 .NET 带到了不允许 JIT 编译器的平台,例如 iOS 和主机平台。此外,它还使 .NET 能够运行在嵌入式设备甚至裸机设备上(例如在 UEFI 上运行)。
在使用工具之前了解工具,这样你会节省很多时间。