PAYATHON 2026

为何 native AOT 下 XmlSerializer 自定义反序列化仍需动态代码?

排障赵师傅

结论

这次异常与 WSDL 格式或 Stream.Position 无关,问题出在 XmlSerializer 的路径选择上。

ServiceDescriptionSerializer 依赖预先实现的 XmlSerializationReader,无需在运行时生成代码。但在 native AOT 环境中,RuntimeFeature.IsDynamicCodeSupported == false,XmlSerializer 的公共反序列化入口会优先进入 reflection-based 路径。这条路径要求 serializer 具有 _rootType 或 _mapping。由于这个自定义 serializer 通过无参基类构造函数创建,这两个字段都是空值,最终在 GetMapping() 中失败。

这是 XmlSerializer 的路径选择与旧式自定义 serializer 之间的兼容性限制,并不表示自定义 reader 本身需要动态代码。公共入口在调用自定义 reader 之前,就已经把请求分派到了另一条路径。

错误中的 (0, 0) 是 XmlSerializer 包装内部异常时给出的通用位置信息。失败发生在建立类型映射的阶段,此时解析器尚未真正读取 XML。修改文件内容或重置流位置都无法解决这个问题。

为什么普通 XmlSerializer 会涉及动态代码

常见用法会传入一个确定的类型:

var serializer = new XmlSerializer(typeof(Foo));
var value = (Foo?)serializer.Deserialize(reader);

不过,XmlSerializer 除了读取 Foo 的字段,还要根据运行时配置建立完整的 XML 映射,包括:

  • XML 元素名、属性名和命名空间;
  • XmlAttributeOverrides;
  • extraTypes;
  • 多态类型及派生类型;
  • 数组、集合和可空类型;
  • 自定义序列化属性;
  • SOAP mapping 等特殊映射。

在允许动态代码的运行时中,XmlSerializer 可以根据这些映射生成专用的 reader、writer 和访问代码。后续反序列化时可以直接调用生成的代码,省去每次通过 reflection 检查成员和属性的开销。

类型已经编译进可执行文件,不代表所有可能的 XML 映射代码都已生成。类型定义描述对象结构,serializer 还要决定 XML 节点如何映射到对象成员。XmlAttributeOverrides、extraTypes 等参数可以在运行时构造,AOT 编译器无法预先得知所有组合。

native AOT 不允许运行时生成和编译新代码,因此:

RuntimeFeature.IsDynamicCodeSupported

会返回 false。随后,XmlSerializer 会选择 reflection-based 实现,在无法使用动态代码时处理部分序列化场景。

ReflectionOnly 是动态代码生成的后备模式,并非“需要动态代码”。本次故障的原因是这个后备模式不适用于 ServiceDescriptionSerializer 的构造方式。

为什么 custom reader 没有被调用

相关分支可以简化为:

if (ShouldUseReflectionBasedSerialization(_mapping) ||
    _isReflectionBasedSerializer)
{
    return DeserializeUsingReflection(xmlReader, encodingStyle, events);
}
else if (_tempAssembly == null || _typedSerializer)
{
    XmlSerializationReader reader = CreateReader();
    reader.Init(xmlReader, events, encodingStyle);
    return Deserialize(reader);
}

在 native AOT 中:

Mode == SerializationMode.ReflectionOnly

第一个条件会直接成立,后面的 CreateReader() 分支不会执行。

这与虚方法重写无关。重写 CreateReader() 只能决定程序进入该分支后调用哪个实现,无法影响前面的分支判断。

ServiceDescriptionSerializer 的创建方式如下:

s_serializer = new ServiceDescriptionSerializer();

它会隐式调用 XmlSerializer 的无参构造函数,既没有传入 root type,也没有创建普通的 XmlMapping。这在原有设计中没有问题,因为生成好的 ServiceDescriptionSerializationReader 已经包含 WSDL 映射逻辑。

进入 reflection-based 路径后,实现会尝试补建映射:

_mapping = GenerateXmlTypeMapping(
    _rootType!,
    null,
    null,
    null,
    DefaultNamespace);

由于 _rootType 从未设置,这里会抛出 ArgumentNullException。

这个 serializer 在 custom reader 路径中具有完整状态,进入 reflection-based 路径后则缺少必要信息。

如何确认是同一问题

先记录 native AOT 环境中的动态代码能力:

using System.Runtime.CompilerServices;

Console.WriteLine(
    $"Dynamic code supported: {RuntimeFeature.IsDynamicCodeSupported}");

Console.WriteLine(
    $"Dynamic code compiled: {RuntimeFeature.IsDynamicCodeCompiled}");

使用 native AOT 发布后,通常会看到:

Dynamic code supported: False
Dynamic code compiled: False

接着检查完整的异常链,不要只看最外层的 XML 错误:

try
{
    using FileStream stream = File.OpenRead(wsdlPath);
    ServiceDescription description = ServiceDescription.Read(stream);
}
catch (InvalidOperationException ex)
{
    Exception? current = ex;

    while (current != null)
    {
        Console.Error.WriteLine(
            $"{current.GetType().FullName}: {current.Message}");

        Console.Error.WriteLine(current.StackTrace);
        current = current.InnerException;
    }

    throw;
}

如果内部调用链包含以下方法:

XmlReflectionImporter.ImportTypeMapping
XmlSerializer.GetMapping
XmlSerializer.DeserializeUsingReflection

并且内部异常是由 null type 引起的 ArgumentNullException,基本可以排除 XML 内容和流位置问题。

如果内部异常是 XmlException,并包含具体的行号、列号或非法字符信息,则应继续检查 WSDL 内容、编码和流状态。

可行的处理方案

方案一:改用 self-contained 发布,不启用 native AOT

如果主要需求是让目标机器无需单独安装 .NET runtime,self-contained deployment 已经可以满足。它会将所需 runtime 随应用一起发布,同时保留正常的 JIT 和动态代码能力。

项目文件可以这样配置:

<PropertyGroup>
  <TargetFramework>net8.0</TargetFramework>
  <RuntimeIdentifier>win-x64</RuntimeIdentifier>
  <SelfContained>true</SelfContained>
  <PublishAot>false</PublishAot>
</PropertyGroup>

发布命令示例:

dotnet publish -c Release -r win-x64 --self-contained true -p:PublishAot=false

如果还想减少启动时的 JIT 工作,可以单独评估 ReadyToRun:

<PropertyGroup>
  <PublishReadyToRun>true</PublishReadyToRun>
</PropertyGroup>

ReadyToRun 与 native AOT 在运行方式、体积和性能特征上并不相同,需要使用实际工作负载测量,不能直接视为等价方案。

对于依赖 WCF metadata、WSDL 或旧式 XmlSerializer 扩展机制的工具,self-contained 非 AOT 发布通常成本最低。

方案二:在 native AOT 下绕过 ServiceDescription.Read

如果必须使用 native AOT,可以避开这条 XmlSerializer 路径,改用 XmlReader、XDocument 或明确支持 AOT 的 WSDL 解析实现。

下面的代码只能读取 WSDL 的基本结构,并不是完整的 WSDL parser:

using System.Xml;
using System.Xml.Linq;

static IEnumerable<(string Service, string Port)> ReadServices(
    Stream wsdlStream)
{
    var settings = new XmlReaderSettings
    {
        DtdProcessing = DtdProcessing.Prohibit,
        XmlResolver = null
    };

    using XmlReader reader = XmlReader.Create(wsdlStream, settings);
    XDocument document = XDocument.Load(
        reader,
        LoadOptions.SetLineInfo);

    XNamespace wsdl = "http://schemas.xmlsoap.org/wsdl/";

    XElement definitions = document.Root
        ?? throw new InvalidDataException(
            "The WSDL document has no root element.");

    if (definitions.Name != wsdl + "definitions")
    {
        throw new InvalidDataException(
            $"Unexpected root element: {definitions.Name}");
    }

    foreach (XElement service in definitions.Elements(wsdl + "service"))
    {
        string serviceName =
            (string?)service.Attribute("name") ?? string.Empty;

        foreach (XElement port in service.Elements(wsdl + "port"))
        {
            string portName =
                (string?)port.Attribute("name") ?? string.Empty;

            yield return (serviceName, portName);
        }
    }
}

完整实现还需要处理:

  • wsdl:import 和 xsd:import;
  • 相对 URI;
  • XML Schema;
  • namespace 和 QName 解析;
  • binding、portType、message 与 operation;
  • SOAP 1.1、SOAP 1.2 及扩展元素;
  • MEX 返回多个 metadata section 的情况。

仅使用几个 XPath 无法实现完整的 WSDL parser。如果工具需要处理任意第三方 WCF 服务,应先根据实际输入确定解析范围。

方案三:调整或替换相关库

如果可以维护依赖库的分支,可以让 WSDL 反序列化绕过 XmlSerializer 的公共分派逻辑,直接执行预生成的 reader,也可以改用显式的 XML 解析代码。

所需的调用顺序在概念上类似这样:

// 伪代码,仅说明所需路径
var serializationReader =
    new ServiceDescriptionSerializationReader();

serializationReader.Initialize(xmlReader, events, encodingStyle);

return serializationReader.ReadDefinitions();

实际实现不能直接照搬这段代码,因为 XmlSerializationReader 的初始化过程以及生成 reader 的部分成员可能属于内部 API。能否从库外调用,取决于使用的程序集和版本。

也不建议通过 reflection 修改 XmlSerializer 的私有字段或静态 Mode。这些实现细节可能变化,trimming 还会进一步影响这种做法。

可以验证新版依赖是否修复了问题,但在缺少对应版本测试或变更记录时,不能直接假定问题已经解决。路径选择属于实现细节,不同 .NET 版本和 WCF 包版本的行为可能存在差异。

哪些办法通常无效

重置 Stream.Position

下面的代码只适用于流已被提前读取的情况:

if (stream.CanSeek)
{
    stream.Position = 0;
}

当前异常发生在生成 XML mapping 时,此时尚未开始读取 WSDL,因此重置流位置没有作用。

仅添加 trimming 保留规则

DynamicDependency、trimmer descriptor 或其他保留元数据的方法可以处理成员被裁剪的问题,但无法为值为 null 的 _rootType 提供正确类型。

这里的 null 来自 serializer 的构造方式,并非 linker 删除字段值所致。只有后续确实出现构造函数、属性或反射元数据缺失时,才需要另行调整 trimming 配置。

用反射强行修改 XmlSerializer.Mode

Mode 是内部实现,不是公开的配置入口。通过 reflection 修改内部状态很脆弱,也与 native AOT 和 trimming 的约束冲突。即使某个版本下碰巧可用,也不适合作为发布方案。

把 native AOT 当成“自动兼容所有反射代码”

native AOT 可以保留并使用部分 reflection 元数据,但不会自动支持 Reflection.Emit、运行时代码编译,以及所有依赖动态发现类型的框架行为。遇到带有 RequiresDynamicCode、RequiresUnreferencedCode 等标记的 API 时,都需要检查其兼容性。

最终判断

普通 XmlSerializer 使用动态代码,是为了根据运行时建立的 XML mapping 生成专用序列化代码。native AOT 无法执行这一步,因此实现会尝试切换到 reflection-based serializer。

ServiceDescriptionSerializer 已经带有专用 reader,按其原有设计不需要动态代码。它在 native AOT 下仍然失败,是因为全局模式判断先于 custom reader 分支执行,导致一个没有 _rootType 和 _mapping 的 serializer 进入了 reflection-based 路径。

从给出的调用链来看,这是 native AOT、XmlSerializer 分派逻辑和当前 ServiceDescriptionSerializer 实现共同造成的兼容性问题,与 WSDL 损坏无关,也不能说明 custom reader 必须生成动态代码。实际项目可优先采用 self-contained 非 AOT 发布。如果必须使用 native AOT,则需要绕过 ServiceDescription.Read 路径,或改用明确支持 AOT 的 WSDL 解析实现。

备注:内容仅供参考。