剪裁警告简介

从概念上讲,剪裁非常简单:发布应用程序时,.NET SDK 会分析整个应用程序并移除所有未使用的代码。 然而,可能很难确定什么是未使用的,或者更准确地说是使用了什么。

为防止在剪裁应用程序时发生行为变化,.NET SDK 将通过“剪裁警告”提供有关剪裁兼容性的静态分析。 当发现可能与剪裁不兼容的代码时,剪裁器会产生剪裁警告。 与剪裁不兼容的代码可能会在剪裁后的应用程序中导致行为变更,甚至崩溃。 理想情况下,所有使用剪裁的应用程序都不应产生剪裁警告。 如果有任何剪裁警告,则应在剪裁后彻底测试应用,以确保没有行为变更。

本文将帮助你了解为什么某些模式会产生剪裁警告,以及如何解决这些警告。

剪裁警告的示例

对于大多数 C# 代码,很容易确定使用了和未使用哪些代码 - 剪裁器可遍历方法调用、字段和属性引用等内容,并确定访问了哪些代码。 遗憾的是,某些功能(如反射)存在重大问题。 考虑下列代码:

string s = Console.ReadLine();
Type type = Type.GetType(s);
foreach (var m in type.GetMethods())
{
    Console.WriteLine(m.Name);
}

在此示例中,GetType() 动态请求名称未知的类型,然后打印其所有方法的名称。 由于在发布时无法知道将使用什么类型名称,因此剪裁器无法知道要在输出中保留哪种类型。 在剪裁之前,这段代码可能可以正常工作(只要输入是目标框架中已知存在的内容),但在剪裁后可能会产生空引用异常,因为 Type.GetType 会在找不到类型时返回 null。

在这种情况下,剪裁器会在调用 Type.GetType 时发出警告,表明它无法确定应用程序将使用哪种类型。

响应剪裁警告

剪裁警告旨在为剪裁带来可预测性。 你可能会看到两大类别的警告:

  1. 功能与剪裁不兼容
  2. 功能在输入中满足某些要求才能与剪裁兼容

功能与剪裁不兼容

这些方法通常根本无法正常工作,或者如果在剪裁后的应用程序中使用,在某些情况下可能会损坏。 上一个示例中的 Type.GetType 方法便是一个很好的例子。 在剪裁的应用中,它可能会正常工作(但无法保证)。 此类 API 被标记为 RequiresUnreferencedCodeAttribute

RequiresUnreferencedCodeAttribute 简单而广泛:它是一个属性,表示成员已被注释为与剪裁不兼容。 当代码从根本上不兼容剪裁时,或者剪裁依赖项太复杂而无法向剪裁器解释时,将使用此属性。 对于动态加载代码(例如通过 LoadFrom(String))、枚举或搜索应用程序或程序集中的所有类型(例如通过 GetType())、使用 C# dynamic 关键字或使用其他运行时代码生成技术的方法来说,这通常是正确的。 例如:

[RequiresUnreferencedCode("This functionality is not compatible with trimming. Use 'MethodFriendlyToTrimming' instead")]
void MethodWithAssemblyLoad()
{
    ...
    Assembly.LoadFrom(...);
    ...
}

void TestMethod()
{
    // IL2026: Using method 'MethodWithAssemblyLoad' which has 'RequiresUnreferencedCodeAttribute'
    // can break functionality when trimming application code. This functionality is not compatible with trimming. Use 'MethodFriendlyToTrimming' instead.
    MethodWithAssemblyLoad();
}

RequiresUnreferencedCode 的解决方法并不多。 最好的解决方法是在剪裁时完全避免调用该方法并使用其他与剪裁兼容的方法。

将功能标记为与剪裁不兼容

如果你正在编写一个库,但无法控制是否使用不兼容的功能,则可以将其标记为 RequiresUnreferencedCode。 这会将方法注释为与剪裁不兼容。 使用 RequiresUnreferencedCode 会使给定方法中的所有剪裁警告保持静默,但每当其他人调用它时都会产生警告。

RequiresUnreferencedCodeAttribute 要求指定 Message。 该消息将显示在向调用标记方法的开发人员报告的警告中。 例如:

IL2026: Using member <incompatible method> which has 'RequiresUnreferencedCodeAttribute' can break functionality when trimming application code. <The message value>

在上面的示例中,特定方法的警告可能如下所示:

IL2026: Using member 'MethodWithAssemblyLoad()' which has 'RequiresUnreferencedCodeAttribute' can break functionality when trimming application code. This functionality is not compatible with trimming. Use 'MethodFriendlyToTrimming' instead.

调用此类 API 的开发人员通常不会对受影响的 API 的详细情况或剪裁相关的特定内容感兴趣。

高质量的消息应阐明哪些功能与剪裁不兼容,然后指导开发人员后续可能采取的措施。 它可能会建议使用不同的功能或更改功能的使用方式。 它还可能只是指出该功能尚与剪裁不兼容,而未提供明确的替代解决方式。

如果面向开发人员的指导太长,无法包含在警告消息中,则可以向 RequiresUnreferencedCodeAttribute 添加可选的 Url,以将开发人员指向更详细地描述问题和可能的解决方案的网页。

例如:

[RequiresUnreferencedCode("This functionality is not compatible with trimming. Use 'MethodFriendlyToTrimming' instead", Url = "https://site/trimming-and-method")]
void MethodWithAssemblyLoad() { ... }

这会产生警告:

IL2026: Using member 'MethodWithAssemblyLoad()' which has 'RequiresUnreferencedCodeAttribute' can break functionality when trimming application code. This functionality is not compatible with trimming. Use 'MethodFriendlyToTrimming' instead. https://site/trimming-and-method

使用 RequiresUnreferencedCode 通常会导致由于同样的原因而使用它标记更多方法。 当高级方法与剪裁不兼容时,这很常见,因为它调用的是与剪裁不兼容的低级别方法。 你把警告“冒泡”到一个公共的 API。 每次使用 RequiresUnreferencedCode 都需要一条消息,且在这些情况下,这些消息可能相同。 若要避免复制字符串并使其更易于维护,请使用常量字符串字段来存储消息:

class Functionality
{
    const string IncompatibleWithTrimmingMessage = "This functionality is not compatible with trimming. Use 'FunctionalityFriendlyToTrimming' instead";

    [RequiresUnreferencedCode(IncompatibleWithTrimmingMessage)]
    private void ImplementationOfAssemblyLoading()
    {
        ...
    }

    [RequiresUnreferencedCode(IncompatibleWithTrimmingMessage)]
    public void MethodWithAssemblyLoad()
    {
        ImplementationOfAssemblyLoading();
    }
}

对输入有要求的功能

剪裁提供了 API,用于指定对方法和其他成员的输入的更多要求,从而产生剪裁兼容代码的。 这些要求通常是关于反射方面,以及访问某个类型上的某些成员或操作的能力。 此类要求将通过 DynamicallyAccessedMembersAttribute 指定。

RequiresUnreferencedCode 不同的是,剪裁器有时可以理解反射,因为它是正确注释的。 让我们再看一下原始示例:

string s = Console.ReadLine();
Type type = Type.GetType(s);
foreach (var m in type.GetMethods())
{
    Console.WriteLine(m.Name);
}

在前面的示例中,实际问题是 Console.ReadLine()。 由于可以读取任何类型,因此剪裁器无法知道你是否需要 System.DateTimeSystem.Guid 或任何其他类型的方法。 另一方面,以下代码可正常工作:

Type type = typeof(System.DateTime);
foreach (var m in type.GetMethods())
{
    Console.WriteLine(m.Name);
}

在这里,剪裁器可以看到被引用的确切类型:System.DateTime。 现在,它可以使用流分析来确定它是否需要将所有公共方法保留在 System.DateTime 上。 那么,DynamicallyAccessMembers 是从哪里进来的呢? 当反射跨多个方法拆分时。 在以下代码中,我们可以看到类型 System.DateTime 流向 Method3,其中反射用于访问 System.DateTime 的方法,

void Method1()
{
    Method2<System.DateTime>();
}
void Method2<T>()
{
    Type t = typeof(T);
    Method3(t);
}
void Method3(Type type)
{
    var methods = type.GetMethods();
    ...
}

如果编译前面的代码,将产生以下警告:

IL2070: Program.Method3(Type): "this" 参数在调用 "System.Type.GetMethods()" 时不满足 "DynamicallyAccessedMemberTypes.PublicMethods" 的要求。 方法 "Program.Method3(Type)" 的参数 "type" 没有匹配的注释。 源值必须至少声明与在其分配到的目标位置上声明的要求相同的要求。

为了性能和稳定性,不会在方法之间执行流分析,因此需要一个注释来在方法之间传递信息,从反射调用 (GetMethods) 到 Type 的源。 在前面的示例中,剪裁器警告指出 GetMethods 要求调用它的 Type 对象实例具有 PublicMethods 注释,但 type 变量没有相同的要求。 换句话说,我们需要将要求从 GetMethods 传递给调用方:

void Method1()
{
    Method2<System.DateTime>();
}
void Method2<T>()
{
    Type t = typeof(T);
    Method3(t);
}
void Method3(
    [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)] Type type)
{
    var methods = type.GetMethods();
  ...
}

批注参数 type 后,原来的警告消失了,但出现了另一个警告:

IL2087: "type" 参数在调用 "Program.Method3(Type)" 时不满足 "DynamicallyAccessedMemberTypes.PublicMethods" 的要求。 "Program.Method2<T>()" 的通用参数 "T" 没有匹配的注释。

我们将注释传播到 Method3 的参数 type,在 Method2 中我们也有类似的问题。 剪裁器能够跟踪值 T,因为它通过对 typeof 的调用流动,分配给局部变量 t,并传递给 Method3。 此时,它看到参数 type 需要 PublicMethods,但对 T 没有要求,并产生新的警告。 为了解决这个问题,我们必须通过在调用链上一直应用注释来“批注和传播”,直到达到静态已知类型(如 System.DateTimeSystem.Tuple)或另一个注释值。 在这种情况下,我们需要对 Method2 的类型参数 T 进行批注。

void Method1()
{
    Method2<System.DateTime>();
}
void Method2<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)] T>()
{
    Type t = typeof(T);
    Method3(t);
}
void Method3(
    [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)] Type type)
{
    var methods = type.GetMethods();
  ...
}

现在没有警告了,因为剪裁器知道可以通过运行时反射(公共方法)访问哪些成员以及哪些类型 (System.DateTime),并且它将保留它们。 最佳做法是添加注释,以便剪裁器知道要保留哪些内容。

如果受影响的代码位于具有 RequiresUnreferencedCode 的方法中,则会自动抑制这些额外要求所产生的警告。

与仅报告不兼容的 RequiresUnreferencedCode 不同,添加 DynamicallyAccessedMembers 可使代码与剪裁兼容。

抑制剪裁器警告

如果可以通过某种方式确定调用是安全的,并且不会删除所有需要的代码,还可以使用 UnconditionalSuppressMessageAttribute 取消警告。 例如:

[RequiresUnreferencedCode("Use 'MethodFriendlyToTrimming' instead")]
void MethodWithAssemblyLoad() { ... }

[UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL2026:RequiresUnreferencedCode",
    Justification = "Everything referenced in the loaded assembly is manually preserved, so it's safe")]
void TestMethod()
{
    InitializeEverything();

    MethodWithAssemblyLoad(); // Warning suppressed

    ReportResults();
}

警告

抑制剪裁警告时要非常小心。 现在,该调用可能与剪裁兼容,但是当你更改代码时这种情况可能会发生变化,届时你有可能会忘记检查所有抑制。

UnconditionalSuppressMessage 类似于 SuppressMessage,但它可以由 publish 和其他生成后工具查看。

重要

请勿使用 SuppressMessage#pragma warning disable 来抑制剪裁器警告。 这些仅适用于编译器,但不会保留在已编译的程序集中。 剪裁器将对已编译的程序集进行操作,并且看不到抑制。

抑制适用于整个方法主体。 因此,在上面的示例中,它会抑制来自该方法的所有 IL2026 警告。 这使其变得很难理解,因为除非你添加注释,否则就无法弄清楚是哪种方法有问题。 更重要的是,如果代码将来发生更改(例如,如果 ReportResults 也变得不兼容剪裁),此方法调用不会报告任何警告。

你可以通过将有问题的方法调用重构为单独的方法或本地函数,然后仅将抑制应用于该方法来解决此问题:

void TestMethod()
{
    InitializeEverything();

    CallMethodWithAssemblyLoad();

    ReportResults();

    [UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL2026:RequiresUnreferencedCode",
        Justification = "Everything referenced in the loaded assembly is manually preserved, so it's safe")]
    void CallMethodWithAssemblyLoad()
    {
        MethodWIthAssemblyLoad(); // Warning suppressed
    }
}