.NET Exception 序列化问题深度解析:为什么 HelpURL 属性会导致反序列化失败?


.NET Exception 序列化问题深度解析:为什么 HelpURL 属性会导致反序列化失败?

一、问题现象

今天早上在处理客户反馈的问题时,遇到了一个令人困惑的异常:

2026-05-06 09:40:07.116 WARN [274]  - Member 'HelpURL' was not found.
System.Runtime.Serialization.SerializationException: Member 'HelpURL' was not found.
   at System.Runtime.Serialization.SerializationInfo.GetElement(String name, Type& foundType)
   at System.Runtime.Serialization.SerializationInfo.GetString(String name)
   at System.Exception..ctor(SerializationInfo info, StreamingContext context)
   at lambda_method(Closure , Object[] )
   at Newtonsoft.Json.Serialization.JsonSerializerInternalReader.CreateISerializable(JsonReader reader, JsonISerializableContract contract, JsonProperty member, String id)
   at Newtonsoft.Json.Serialization.JsonSerializerInternalReader.CreateObject(JsonReader reader, Type objectType, JsonContract contract, JsonProperty member, JsonContainerContract containerContract, JsonProperty containerMember, Object existingValue)
 .....

项目代码比较复杂,我创建了一个简单项目,用于演示碰见的问题:

完整测试代码

namespace ConsoleApp5
{
    internal class Program
    {
        static void Main(string[] args)
        {
            try
            {
                throw new Exception("闲来无事,抛个异常玩玩~");

            }
            catch (Exception ex)
            {
                UseOriginalException(ex);

                UseCustomWithoutSerializableException(ex);

                UseCustomWithSerializableException(ex);
            }
            Console.ReadLine();
        }


        /// <summary>
        /// 使用原生异常类
        /// </summary>
        /// <param name="ex"></param>
        static void UseOriginalException(Exception ex)
        {
            try
            {
                var str = JsonConvert.SerializeObject(ex);
                var info2 = JsonConvert.DeserializeObject<Exception>(str);
                Console.WriteLine("【UseOriginalException】没有发生错误");
            }
            catch (Exception ex2)
            {
                Console.WriteLine("【UseOriginalException】发生错误:" + ex2.Message);
            }

        }

        /// <summary>
        /// 使用不带[Serializable]特性的自定义异常类
        /// </summary>
        /// <param name="ex"></param>
        static void UseCustomWithoutSerializableException(Exception ex)
        {
            try
            {

                CustomWithoutSerializableException custom = new(ex.Message, ex);
                var str = JsonConvert.SerializeObject(custom);
                var info2 = JsonConvert.DeserializeObject<Exception>(str);
                Console.WriteLine("【UseCustomWithoutSerializableException】没有发生错误");
            }
            catch (Exception ex2)
            {
                Console.WriteLine("【UseCustomWithoutSerializableException】发生错误:" + ex2.Message);
            }

        }

        /// <summary>
        /// 使用带[Serializable]特性的自定义异常类
        /// </summary>
        /// <param name="ex"></param>
        static void UseCustomWithSerializableException(Exception ex)
        {
            try
            {
                CustomWithSerializableException custom = new(ex.Message, ex);
                var str = JsonConvert.SerializeObject(custom);
                var info2 = JsonConvert.DeserializeObject<Exception>(str);
                Console.WriteLine("【UseCustomWithSerializableException】没有发生错误");
            }
            catch (Exception ex2)
            {
                Console.WriteLine("【UseCustomWithSerializableException】发生错误:" + ex2.Message);
            }
        }

        /// <summary>
        /// 不带[Serializable]特性的自定义异常类
        /// </summary>
        public class CustomWithoutSerializableException : Exception
        {
            public CustomWithoutSerializableException() : base() { }
            public CustomWithoutSerializableException(string message) : base(message) { }
            public CustomWithoutSerializableException(string message, Exception innerException) : base(message, innerException) { }

        }

        /// <summary>
        /// 带[Serializable]特性的自定义异常类
        /// </summary>
        [Serializable]
        public class CustomWithSerializableException : Exception
        {
            public CustomWithSerializableException() : base() { }
            public CustomWithSerializableException(string message) : base(message) { }
            public CustomWithSerializableException(string message, Exception innerException) : base(message, innerException) { }
        }
    }

运行结果

【UseOriginalException】没有发生错误
【UseCustomWithoutSerializableException】发生错误:Member 'HelpURL' was not found.
【UseCustomWithSerializableException】没有发生错误

问题分析

奇怪的现象:

  1. 原生 Exception 序列化/反序列化成功
  2. 不带 [Serializable] 的自定义异常反序列化失败,报错:Member 'HelpURL' was not found.
  3. 带有 [Serializable] 的自定义异常序列化/反序列化成功

更奇怪的是: 错误提示说找不到 HelpURL 成员,但实际上 Exception 类并没有 HelpURL 这个属性!Exception 类只有 HelpLink 属性。


二、差异对比与本质分析

2.1 两个自定义异常的唯一区别

对比 CustomWithoutSerializableExceptionCustomWithSerializableException,我们发现:

  • 唯一的区别CustomWithSerializableException 多了一个 [Serializable] 特性
  • 代码完全相同:构造函数、继承结构完全一致

这说明 [Serializable] 特性在序列化过程中扮演了关键角色。

2.2 为什么会有这种差异?

Newtonsoft.Json 的序列化行为

当 Newtonsoft.Json 序列化一个对象时:

  1. [Serializable] 特性的类

    • 被识别为”可序列化类型”
    • Newtonsoft.Json 会尝试序列化所有公共属性和字段
    • 对于继承的属性,会遵循基类的序列化行为
  2. 没有 [Serializable] 特性的类

    • 被视为普通 POCO 对象
    • Newtonsoft.Json 序列化所有可访问的属性
    • 包括从基类继承的所有公共属性

关键问题:Exception 类的特殊实现

Exception 类是一个非常特殊的基类:

  1. 它本身标记了 [Serializable] 特性
  2. 它实现了 ISerializable 接口
  3. 它有一些内部的序列化逻辑

当我们创建一个不带 [Serializable] 的自定义异常时:

CustomWithoutSerializableException custom = new(ex.Message, ex);
var str = JsonConvert.SerializeObject(custom);

序列化过程中,Newtonsoft.Json 会:

  • 扫描 CustomWithoutSerializableException 的所有属性
  • 同时扫描从 Exception 继承的所有属性
  • 序列化时会包含一些 Exception 内部的特殊字段

反序列化时:

var info2 = JsonConvert.DeserializeObject<Exception>(str);
  • 目标类型是 Exception
  • JSON 中包含了一些来自子类序列化的额外字段
  • Exception 类在反序列化时期望某些特定的序列化格式
  • 当格式不匹配时,就会报错找不到 HelpURL 成员

三、深挖 Exception 类的序列化实现原理

3.1 Exception 类的源码分析

Exception 类在 .NET 中实现了 ISerializable 接口,这是 .NET Framework 时代的二进制序列化机制。

源码参考:

  • .NET Runtime 官方源码:Exception.cs
  • 本节分析基于 .NET 8.0 的实现

核心字段定义(.NET 源码)

首先,让我们看看 Exception 类中定义的关键私有字段:

// 摘自 .NET Runtime 源码
// https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/System/Exception.cs

[Serializable]
public class Exception : ISerializable
{
    private string? _message;
    private IDictionary? _data;
    private Exception? _innerException;
    private string? _helpURL;           // ?? 关键字段:内部使用 _helpURL
    private object? _stackTrace;
    private string? _stackTraceString;
    private string? _remoteStackTraceString;
    private string? _source;
    private object? _watsonBuckets;

    // ... 其他字段
}

HelpLink 属性与 _helpURL 字段的关系

// 摘自 .NET Runtime 源码
public virtual string? HelpLink
{
    get => _helpURL;     // 读取私有字段 _helpURL
    set => _helpURL = value;  // 设置私有字段 _helpURL
}

关键发现: 公共属性 HelpLink 只是 _helpURL 私有字段的包装器!

序列化构造函数(反序列化入口)

// 摘自 .NET Runtime 源码
// https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/System/Exception.cs

[Obsolete("This API supports obsolete formatter-based serialization. It should not be called or extended by application code.")]
protected Exception(SerializationInfo info, StreamingContext context)
{
    if (info == null)
        throw new ArgumentNullException(nameof(info));

    _message = info.GetString("Message")!;
    _data = (IDictionary?)(info.GetValueNoThrow("Data", typeof(IDictionary)));
    _innerException = (Exception?)(info.GetValueNoThrow("InnerException", typeof(Exception)));
    _helpURL = info.GetString("HelpURL")!;  // 关键:期望 JSON 中有 "HelpURL" 键
    _stackTraceString = info.GetString("StackTraceString")!;
    _remoteStackTraceString = info.GetString("RemoteStackTraceString")!;
    _source = info.GetString("Source")!;

    // ... 其他字段的反序列化
}

关键点分析:

  1. 这个构造函数在反序列化时被调用
  2. 它明确要求从 SerializationInfo 中获取 "HelpURL" 键(不是 "HelpLink"
  3. 如果 JSON 中没有 "HelpURL" 键,就会抛出 “Member ‘HelpURL’ was not found” 异常

GetObjectData 方法(序列化出口)

// 摘自 .NET Runtime 源码
// https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/System/Exception.cs

[Obsolete("This API supports obsolete formatter-based serialization. It should not be called or extended by application code.")]
public virtual void GetObjectData(SerializationInfo info, StreamingContext context)
{
    if (info == null)
        throw new ArgumentNullException(nameof(info));

    string className = GetClassName();
    info.AddValue("ClassName", className, typeof(string));            // 类名
    info.AddValue("Message", _message, typeof(string));               // 消息
    info.AddValue("Data", _data, typeof(IDictionary));                // 数据字典
    info.AddValue("InnerException", _innerException, typeof(Exception)); // 内部异常
    info.AddValue("HelpURL", _helpURL, typeof(string));               // 使用 "HelpURL" 键名
    info.AddValue("StackTraceString", _stackTraceString, typeof(string));
    info.AddValue("RemoteStackTraceString", _remoteStackTraceString, typeof(string));
    info.AddValue("RemoteStackIndex", 0, typeof(int));
    info.AddValue("ExceptionMethod", null, typeof(string));
    info.AddValue("HResult", HResult);
    info.AddValue("Source", _source, typeof(string));
    info.AddValue("WatsonBuckets", _watsonBuckets, typeof(byte[]));
}

private string GetClassName() => GetType().ToString();

关键发现:

  1. GetObjectData 方法控制对象如何被序列化
  2. 它将 _helpURL 私有字段序列化为 "HelpURL" 键(不是 "HelpLink"
  3. 这个键名与反序列化构造函数期望的键名完全一致
  4. 方法被标记为 [Obsolete],说明这是为了向后兼容保留的旧 API

3.2 完整的序列化数据结构

通过查看 GetObjectData 方法,我们可以看到 Exception 序列化时包含的所有字段:

序列化键名 对应字段/属性 类型 说明
ClassName GetType().ToString() string 异常类的完整类名
Message _message string 异常消息
Data _data IDictionary 附加数据字典
InnerException _innerException Exception 内部异常
HelpURL _helpURL string 帮助链接(注意键名)
StackTraceString _stackTraceString string 堆栈跟踪字符串
RemoteStackTraceString _remoteStackTraceString string 远程堆栈跟踪
RemoteStackIndex 固定值 0 int 远程堆栈索引
ExceptionMethod null string 异常方法(已废弃)
HResult HResult 属性 int 错误代码
Source _source string 错误源
WatsonBuckets _watsonBuckets byte[] Watson 错误报告数据

核心矛盾点:

  • 公共 API 使用:HelpLink 属性
  • 内部字段名:_helpURL
  • 序列化键名:HelpURL(既不是属性名,也不完全是字段名)

3.3 为什么 .NET 设计者选择 “HelpURL” 而不是 “HelpLink”?

这是历史遗留问题:

  1. 早期设计:在 .NET Framework 1.0 时代,可能最初考虑使用 “HelpURL” 作为公共 API 名称
  2. 后期调整:出于某种原因(可能是命名规范),公共属性改为 HelpLink
  3. 向后兼容:序列化格式一旦确定,就不能轻易更改,否则会破坏已序列化数据的兼容性
  4. 结果:形成了”公共属性名” ≠ “序列化键名”的情况

类似的设计在 .NET 中很常见:为了保持二进制序列化的兼容性,很多类的序列化键名都与当前的公共 API 不完全一致。

3.4 ISerializable 接口的作用机制

ISerializable 接口的双向契约:

public interface ISerializable
{
    // 序列化时调用:将对象状态写入 SerializationInfo
    void GetObjectData(SerializationInfo info, StreamingContext context);
}

// 反序列化时调用:通过特殊构造函数从 SerializationInfo 还原对象
protected Constructor(SerializationInfo info, StreamingContext context);

工作流程:

序列化(对象 → JSON):
    ↓
Newtonsoft.Json 检测到 [Serializable] + ISerializable
    ↓
调用 GetObjectData(info, context)
    ↓
对象将状态写入 SerializationInfo
    ↓
SerializationInfo 转换为 JSON

反序列化(JSON → 对象):
    ↓
Newtonsoft.Json 检测到目标类型实现 ISerializable
    ↓
将 JSON 转换为 SerializationInfo
    ↓
调用特殊构造函数 Exception(info, context)
    ↓
从 SerializationInfo 读取状态还原对象

3.5 Newtonsoft.Json 对 ISerializable 的特殊处理

Newtonsoft.Json 对实现了 ISerializable 的类型有特殊逻辑:

  1. 检测条件

    • 类标记了 [Serializable] 特性
    • 类实现了 ISerializable 接口
  2. 序列化行为

    • 不使用反射序列化公共属性
    • 调用 GetObjectData 方法获取序列化数据
    • 使用 SerializationInfo 中的键值对生成 JSON
  3. 反序列化行为

    • 不使用参数化构造函数或属性注入
    • 将 JSON 转换为 SerializationInfo
    • 调用反序列化构造函数 (SerializationInfo, StreamingContext)

这就是为什么:

  • [Serializable] 的自定义异常:使用 ISerializable 路径,JSON 包含 “HelpURL”
  • 不带 [Serializable] 的自定义异常:使用普通属性序列化,JSON 包含 “HelpLink”

3.6 序列化流程对比

场景1:原生 Exception(成功)?

1. 序列化阶段:
   Exception 对象
       ↓
   检测到 [Serializable] + ISerializable
       ↓
   调用 GetObjectData 方法
       ↓
   写入 SerializationInfo:
       - "ClassName": "System.Exception"
       - "Message": "闲来无事,抛个异常玩玩~"
       - "HelpURL": null          ← 使用 HelpURL 键名
       - "InnerException": null
       - "StackTraceString": "..."
       - 其他字段...
       ↓
   生成 JSON: {"ClassName":"System.Exception", "Message":"...", "HelpURL":null, ...}

2. 反序列化阶段:
   JSON 字符串
       ↓
   目标类型: Exception
       ↓
   检测到 Exception 实现 ISerializable
       ↓
   JSON 转换为 SerializationInfo
       ↓
   调用 Exception(SerializationInfo, StreamingContext) 构造函数
       ↓
   读取 info.GetString("HelpURL")  ← JSON 中存在此键
       ↓
   成功创建 Exception 对象 ?

场景2:CustomWithoutSerializableException(失败)?

1. 序列化阶段:
   CustomWithoutSerializableException 对象
       ↓
   没有 [Serializable] 特性
       ↓
   作为普通 POCO 对象处理
       ↓
   反射获取所有公共属性:
       - Message (继承自 Exception)
       - HelpLink (继承自 Exception)    ← 注意:属性名是 HelpLink
       - StackTrace (继承自 Exception)
       - InnerException (继承自 Exception)
       - 其他公共属性...
       ↓
   生成 JSON: {"Message":"...", "HelpLink":null, "StackTrace":"...", ...}
                                    ↑
                              键名是 HelpLink,不是 HelpURL!

2. 反序列化阶段:
   JSON 字符串
       ↓
   目标类型: Exception (基类)
       ↓
   检测到 Exception 实现 ISerializable
       ↓
   JSON 转换为 SerializationInfo
       ↓
   调用 Exception(SerializationInfo, StreamingContext) 构造函数
       ↓
   尝试读取 info.GetString("HelpURL")  ← JSON 中没有此键,只有 "HelpLink"
       ↓
   抛出异常:Member 'HelpURL' was not found

核心问题:

  • 序列化用的是 HelpLink 属性名
  • 反序列化期望的是 HelpURL 键名
  • 键名不匹配!

场景3:CustomWithSerializableException(成功)?

1. 序列化阶段:
   CustomWithSerializableException 对象
       ↓
   检测到 [Serializable] 特性
       ↓
   继承自 Exception(Exception 实现了 ISerializable)
       ↓
   调用继承的 GetObjectData 方法
       ↓
   写入 SerializationInfo:
       - "ClassName": "CustomWithSerializableException"
       - "Message": "闲来无事,抛个异常玩玩~"
       - "HelpURL": null          ← 使用 HelpURL 键名
       - "InnerException": Exception {...}
       - 其他字段...
       ↓
   生成 JSON: {"ClassName":"...", "Message":"...", "HelpURL":null, ...}

2. 反序列化阶段:
   JSON 字符串
       ↓
   目标类型: Exception
       ↓
   检测到 Exception 实现 ISerializable
       ↓
   JSON 转换为 SerializationInfo
       ↓
   调用 Exception(SerializationInfo, StreamingContext) 构造函数
       ↓
   读取 info.GetString("HelpURL")  ← JSON 中存在此键
       ↓
   成功创建对象 

3.7 验证:实际的 JSON 输出差异

让我们通过实际序列化输出来验证上述分析:

原生 Exception 的 JSON(使用 ISerializable):

{
  "ClassName": "System.Exception",
  "Message": "闲来无事,抛个异常玩玩~",
  "Data": null,
  "InnerException": null,
  "HelpURL": null,           // ← 注意:HelpURL
  "StackTraceString": "...",
  "RemoteStackTraceString": null,
  "RemoteStackIndex": 0,
  "ExceptionMethod": null,
  "HResult": -2146233088,
  "Source": null
}

CustomWithoutSerializableException 的 JSON(普通属性序列化):

{
  "Message": "闲来无事,抛个异常玩玩~",
  "Data": null,
  "InnerException": {
    "Message": "闲来无事,抛个异常玩玩~",
    "Data": null,
    "InnerException": null,
    "HelpLink": null,        // ← 注意:HelpLink(不是 HelpURL)
    "Source": null,
    "HResult": -2146233088,
    "StackTrace": "..."
  },
  "TargetSite": null,
  "HelpLink": null,          // ← 注意:HelpLink(不是 HelpURL)
  "Source": null,
  "HResult": -2146233088,
  "StackTrace": "..."
}

关键差异:

  • ISerializable 方式:"HelpURL": null
  • 普通属性方式:"HelpLink": null

当用第二个 JSON 反序列化为 Exception 时,Exception 的反序列化构造函数会查找 “HelpURL” 键,但找到的是 “HelpLink”,导致异常!

3.8 如何查看 .NET 源码

如果你想亲自验证以上分析,可以通过以下方式查看 .NET 的实际源码:

方式1:GitHub 在线查看(推荐)

访问 .NET Runtime 官方仓库:

关键代码位置:

  • _helpURL 字段定义:约第 54 行
  • HelpLink 属性:约第 123-127 行
  • GetObjectData 方法:约第 205-225 行
  • 反序列化构造函数:约第 157-180 行

方式2:Visual Studio 中”转到定义”

  1. 在 Visual Studio 中,右键点击 Exception 类名
  2. 选择 “转到定义”(F12)
  3. Visual Studio 会显示从元数据反编译的代码

方式3:使用 ILSpy 或 dnSpy 反编译工具

  1. 打开 System.Private.CoreLib.dll
  2. 导航到 System.Exception
  3. 查看完整实现

方式4:在线 .NET 源码浏览器

关键源码片段总结

通过查看源码,你可以亲眼确认:

  1. 字段定义

    // 第 54 行附近
    private string? _helpURL;
    
  2. 属性映射

    // 第 123-127 行附近
    public virtual string? HelpLink
    {
        get => _helpURL;
        set => _helpURL = value;
    }
    
  3. 序列化键名

    // GetObjectData 方法中,第 218 行附近
    info.AddValue("HelpURL", _helpURL, typeof(string));
    
  4. 反序列化读取

    // 反序列化构造函数中,第 169 行附近
    _helpURL = info.GetString("HelpURL")!;
    

验证结论:

  • 私有字段确实叫 _helpURL
  • 序列化和反序列化都使用 "HelpURL" 键名
  • 公共属性名是 HelpLink(与序列化键名不同)
  • HelpLink 只是 _helpURL 的包装器

3.9 完整的数据流向图

下图展示了 HelpLink/HelpURL 在不同场景下的完整流向:

┌─────────────────────────────────────────────────────────────────────────┐
│                   场景对比:HelpLink vs HelpURL                          │
└─────────────────────────────────────────────────────────────────────────┘

?? 场景 1 & 3:使用 ISerializable(成功)
┌─────────────────┐
│ Exception 对象   │
│ _helpURL = null │ ← 私有字段
│ HelpLink 属性   │ ← 公共 API(读写 _helpURL)
└────────┬────────┘
         │ 标记了 [Serializable]
         ↓
  ┌──────────────┐
  │ GetObjectData │ ← ISerializable 接口方法
  └──────┬───────┘
         │ info.AddValue("HelpURL", _helpURL, ...)
         ↓
    ┌─────────┐
    │   JSON   │ {"ClassName":"...", "HelpURL":null, ...}
    └────┬────┘      ↑
         │          注意:键名是 "HelpURL"
         ↓
  ┌──────────────────────┐
  │ 反序列化构造函数        │ Exception(SerializationInfo, StreamingContext)
  └──────┬───────────────┘
         │ _helpURL = info.GetString("HelpURL")
         │                              ↑
         │                        在 JSON 中找到了!
         ↓
┌─────────────────┐
│ Exception 对象   │
│ _helpURL = null │
└─────────────────┘


?? 场景 2:不使用 ISerializable(失败)
┌──────────────────────────────┐
│ CustomWithoutSerializable...  │
│ (继承 Exception)              │
│ 没有 [Serializable] 特性      │
└────────┬─────────────────────┘
         │ Newtonsoft.Json 反射获取所有公共属性
         ↓
  ┌───────────────┐
  │ 属性序列化      │
  └──────┬────────┘
         │ 序列化 HelpLink 属性(不是 _helpURL 字段)
         ↓
    ┌─────────┐
    │   JSON   │ {"Message":"...", "HelpLink":null, ...}
    └────┬────┘      ↑
         │          注意:键名是 "HelpLink"
         ↓
  ┌──────────────────────┐
  │ 反序列化为 Exception   │ ← 目标类型是基类 Exception
  └──────┬───────────────┘
         │ Exception 实现了 ISerializable
         │ 调用反序列化构造函数
         ↓
  ┌──────────────────────┐
  │ Exception(info, ctx)  │
  └──────┬───────────────┘
         │ _helpURL = info.GetString("HelpURL")
         │                              ↑
         │                        在 JSON 中找不到!?
         │                        (JSON 中只有 "HelpLink")
         ↓
        异常!
    Member 'HelpURL' was not found.

核心问题总结:

方面 有 [Serializable] 无 [Serializable]
序列化方式 ISerializable.GetObjectData 反射公共属性
JSON 键名 HelpURL(来自 GetObjectData) HelpLink(属性名)
反序列化方式 ISerializable 构造函数 ISerializable 构造函数
期望的键名 HelpURL HelpURL
实际的键名 HelpURL ? HelpLink ?
结果 成功 失败

四、解决方案

方案1:添加 [Serializable] 特性(推荐)

这是最简单、最符合 .NET 设计规范的方案。

[Serializable]
public class CustomException : Exception
{
    public CustomException() : base() { }
    public CustomException(string message) : base(message) { }
    public CustomException(string message, Exception innerException) 
        : base(message, innerException) { }

    // 如果有自定义属性,需要实现 ISerializable
    protected CustomException(SerializationInfo info, StreamingContext context) 
        : base(info, context)
    {
        // 反序列化自定义属性
    }

    public override void GetObjectData(SerializationInfo info, StreamingContext context)
    {
        base.GetObjectData(info, context);
        // 序列化自定义属性
    }
}

优点:

  • ? 符合 .NET 异常设计最佳实践
  • ? 支持所有序列化场景(二进制、JSON、XML)
  • ? 与框架行为一致

缺点:

  • ?? 在 .NET Core/5+中,[Serializable] 主要用于兼容性,实际二进制序列化已不推荐

方案2:配置 Newtonsoft.Json 序列化设置

通过自定义序列化设置,避免 ISerializable 的特殊处理。

var settings = new JsonSerializerSettings
{
    // 忽略 ISerializable 接口,作为普通对象处理
    ContractResolver = new DefaultContractResolver
    {
        IgnoreSerializableInterface = true
    }
};

var str = JsonConvert.SerializeObject(custom, settings);
var info2 = JsonConvert.DeserializeObject<Exception>(str, settings);

优点:

  • ? 不需要修改异常类定义
  • ? 更符合 JSON 序列化的语义

缺点:

  • 需要在所有序列化/反序列化的地方统一使用相同设置
  • 可能丢失某些 Exception 内部状态(如原始堆栈跟踪)

方案3:使用 System.Text.Json(.NET Core 3.0+)

从 .NET Core 3.0 开始,微软推出了新的 JSON 序列化库,不依赖 ISerializable。

using System.Text.Json;

var str = JsonSerializer.Serialize(custom);
var info2 = JsonSerializer.Deserialize<Exception>(str);

注意: System.Text.Json 默认不支持 Exception 的序列化,需要自定义转换器:

public class ExceptionConverter : JsonConverter<Exception>
{
    public override Exception Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        var doc = JsonDocument.ParseValue(ref reader);
        var message = doc.RootElement.GetProperty("Message").GetString();
        return new Exception(message);
    }

    public override void Write(Utf8JsonWriter writer, Exception value, JsonSerializerOptions options)
    {
        writer.WriteStartObject();
        writer.WriteString("Message", value.Message);
        writer.WriteString("StackTrace", value.StackTrace);
        writer.WriteString("HelpLink", value.HelpLink);
        writer.WriteEndObject();
    }
}

// 使用
var options = new JsonSerializerOptions();
options.Converters.Add(new ExceptionConverter());
var str = JsonSerializer.Serialize(custom, options);
var info2 = JsonSerializer.Deserialize<Exception>(str, options);

优点:

  • 更现代的 API
  • 性能更好
  • 不依赖过时的 ISerializable

缺点:

  • 需要额外编写转换器
  • 如果项目已大量使用 Newtonsoft.Json,迁移成本高

方案4:创建专门的数据传输对象(DTO)

不直接序列化异常对象,而是转换为 DTO。

public class ExceptionDto
{
    public string Message { get; set; }
    public string StackTrace { get; set; }
    public string HelpLink { get; set; }
    public ExceptionDto InnerException { get; set; }

    public static ExceptionDto FromException(Exception ex)
    {
        if (ex == null) return null;

        return new ExceptionDto
        {
            Message = ex.Message,
            StackTrace = ex.StackTrace,
            HelpLink = ex.HelpLink,
            InnerException = FromException(ex.InnerException)
        };
    }
}

// 使用
var dto = ExceptionDto.FromException(custom);
var str = JsonConvert.SerializeObject(dto);

优点:

  • 完全控制序列化的内容
  • 避免序列化敏感信息
  • 跨平台兼容性好

缺点:

  • 无法完整还原 Exception 对象
  • 需要维护额外的 DTO 类

五、最佳实践建议

5.1 开发自定义异常时

  1. 始终标记 [Serializable] 特性

    [Serializable]
    public class MyException : Exception
    {
        // ...
    }
    
  2. 如果有自定义属性,实现完整的序列化支持

    [Serializable]
    public class MyException : Exception
    {
        public int ErrorCode { get; set; }
    
        public MyException() { }
        public MyException(string message) : base(message) { }
        public MyException(string message, Exception inner) : base(message, inner) { }
    
        // 序列化构造函数
        protected MyException(SerializationInfo info, StreamingContext context) 
            : base(info, context)
        {
            ErrorCode = info.GetInt32(nameof(ErrorCode));
        }
    
        // 序列化方法
        public override void GetObjectData(SerializationInfo info, StreamingContext context)
        {
            base.GetObjectData(info, context);
            info.AddValue(nameof(ErrorCode), ErrorCode);
        }
    }
    

5.2 序列化异常时

  1. 日志记录场景:推荐使用 DTO 模式,只序列化需要的信息
  2. 跨进程传输:推荐 [Serializable] + 标准序列化
  3. HTTP API 响应:推荐自定义错误响应模型,不要直接暴露异常对象

5.3 .NET 版本考虑

  • .NET Framework:必须使用 [Serializable]ISerializable
  • .NET Core/5/6/7/8
    • [Serializable] 仍然推荐用于异常类(主要为了兼容性)
    • 二进制序列化(BinaryFormatter)已标记为过时
    • JSON 序列化是主流,但建议仍保留 [Serializable] 标记

六、总结

核心要点

  1. [Serializable] 特性不仅仅是标记

    • 它会改变 Newtonsoft.Json 对对象的序列化行为
    • 对于实现了 ISerializable 的类(如 Exception),它会触发特殊的序列化路径
  2. Exception 类的特殊性

    • 内部使用 HelpURL 字段名进行序列化(不是公共属性名 HelpLink
    • 实现了 ISerializable,有自己的序列化逻辑
    • 子类如果不标记 [Serializable],会导致序列化格式不匹配
  3. 错误信息”Member ‘HelpURL’ was not found”的真相

    • 不是 Exception 有 HelpURL 属性
    • 而是 Exception 的 ISerializable 实现期望反序列化数据中有 “HelpURL” 键
    • 当使用非 ISerializable 方式序列化时,生成的是 “HelpLink” 键
    • 导致反序列化时找不到期望的 “HelpURL” 键

推荐方案

场景 推荐方案 理由
开发新的自定义异常 方案1:添加 [Serializable] 最符合 .NET 规范,兼容性最好
无法修改异常类 方案2:配置序列化设置 不需要修改代码
新项目(.NET 5+) 方案3:System.Text.Json 更现代,性能更好
日志/API 响应 方案4:DTO 模式 安全性好,控制粒度细

七、参考资源

官方文档

.NET 源码(? 强烈推荐阅读)

第三方库文档

相关技术文章


这个问题其实是一个比较极端的场景:反序列化生成Exception对象。这个在实际应用场景中碰见的真的非常非常少,我能碰见这个问题,是因为当前项目框架的设计,业务场景出现异常后,会先将异常信息包裹为一个自定义异常对象,然后序列化为字符串,再将其作为委托参数丢到一个后台工作队列里面,在后台工作队列里面进行二次处理,这里操作逻辑就涉及到了反序列化Exception对象。
如果不是这次碰见,我还真的没有注意到这里的细节。

文章摘自:https://www.cnblogs.com/diamondhusky/p/19981804