PAYATHON 2026

如何修复反序列化 JSON 时的 InvalidOperationException?

排障赵师傅

结论

这个异常是因为 BankAccount 的构造函数参数无法全部绑定到可反序列化成员。

问题有两处:

  1. 构造函数参数 initialAmount 没有同名属性或字段。对应的类成员是 Amount,因此参数应改为 amount。
  2. AccountId、DateCreated 和 PIN 使用了字段。如果保留这种设计,需要在反序列化所用的 JsonSerializerOptions 中启用 IncludeFields,并保证序列化和反序列化使用相同的配置。

建议把这些字段改成只读属性,同时让构造函数参数名与属性名一致。

推荐修改:使用属性进行构造函数绑定

using System;
using System.Collections.ObjectModel;
using System.Text.Json.Serialization;

namespace ReadOnlyBankingSystem.Models
{
    public class BankAccount
    {
        public Guid AccountId { get; }
        public DateTime DateCreated { get; }
        public int PIN { get; }
        public decimal Amount { get; set; }
        public ObservableCollection<Transaction> Transactions { get; set; }

        [JsonConstructor]
        public BankAccount(
            Guid accountId,
            DateTime dateCreated,
            int pin,
            decimal amount)
        {
            AccountId = accountId;
            DateCreated = dateCreated;
            PIN = pin;
            Amount = amount;
            Transactions = new ObservableCollection<Transaction>();
        }
    }
}

需要将:

decimal initialAmount

改为:

decimal amount

System.Text.Json 会根据名称和类型,把构造函数参数绑定到类成员。名称匹配不区分大小写:

  • accountId 对应 AccountId
  • dateCreated 对应 DateCreated
  • pin 对应 PIN
  • amount 对应 Amount

这里匹配的是 C# 成员名称,与构造函数内部把参数赋给哪个成员无关,也不使用经过 JSON 命名策略转换后的名称。

initialAmount 找不到对应的 InitialAmount 属性或字段,所以反序列化器无法使用这个构造函数。

如果类中只有一个公开构造函数,通常可以省略 [JsonConstructor]。保留该特性可以明确指定 JSON 反序列化所用的构造函数,也能避免以后增加其他构造函数时产生选择歧义。

如果必须保留字段

如果不想把 AccountId、DateCreated 和 PIN 改成属性,可以继续使用字段,但仍需把 initialAmount 改为 amount:

public class BankAccount
{
    [JsonInclude]
    public readonly Guid AccountId;

    [JsonInclude]
    public readonly DateTime DateCreated;

    [JsonInclude]
    public readonly int PIN;

    public decimal Amount { get; set; }

    public ObservableCollection<Transaction> Transactions { get; set; }

    [JsonConstructor]
    public BankAccount(
        Guid accountId,
        DateTime dateCreated,
        int pin,
        decimal amount)
    {
        AccountId = accountId;
        DateCreated = dateCreated;
        PIN = pin;
        Amount = amount;
        Transactions = new ObservableCollection<Transaction>();
    }
}

还要为源生成上下文启用字段处理:

using System.Collections.Generic;
using System.Text.Json.Serialization;

[JsonSourceGenerationOptions(
    IncludeFields = true,
    WriteIndented = true)]
[JsonSerializable(typeof(Dictionary<string, Customer>))]
internal sealed partial class CustomerContext : JsonSerializerContext
{
}

[JsonInclude] 可以显式包含字段,启用 IncludeFields 则能清楚表明这些字段需要参与源生成配置和构造函数绑定。考虑到后续维护,属性通常比公开只读字段更省事。

统一序列化和反序列化配置

目前读取时使用 CustomerContext,写入时则新建了另一套 JsonSerializerOptions:

JsonSerializer.Deserialize(
    contents,
    CustomerContext.Default.DictionaryStringCustomer);
JsonSerializer.Serialize(customerData, options);

这会导致读写配置不一致。例如,字段包含规则、命名规则或自定义转换器可能只在一条路径中生效。建议读写都使用生成的上下文:

public async Task<Dictionary<string, Customer>> ReadFromJsonFile(
    string filename)
{
    string appDataFilePath = GetAppDataFilePath(filename);
    string contents = await File.ReadAllTextAsync(appDataFilePath);

    return JsonSerializer.Deserialize(
               contents,
               CustomerContext.Default.DictionaryStringCustomer)
           ?? new Dictionary<string, Customer>();
}

public async Task WriteToJsonFile(
    string filename,
    Dictionary<string, Customer> customerData)
{
    string appDataFilePath = GetAppDataFilePath(filename);

    string contents = JsonSerializer.Serialize(
        customerData,
        CustomerContext.Default.DictionaryStringCustomer);

    await File.WriteAllTextAsync(appDataFilePath, contents);
}

WriteIndented = true 已经配置在 [JsonSourceGenerationOptions] 中,写入时无需再创建单独的 JsonSerializerOptions。

这里改用 File.WriteAllTextAsync 也能避免旧内容残留,因为它会覆盖原文件。原代码中的 File.OpenWrite 不一定会截断旧内容。如果新 JSON 比原文件短,文件末尾可能留下旧字符,之后读取时就会触发 JsonException。这个问题与当前的构造函数异常无关,但可以顺手修正。

也可以使用无参构造函数

另一种方案是把所有需要恢复的成员改为可写属性,并提供无参构造函数:

public class BankAccount
{
    public Guid AccountId { get; set; }
    public DateTime DateCreated { get; set; }
    public int PIN { get; set; }
    public decimal Amount { get; set; }

    public ObservableCollection<Transaction> Transactions { get; set; }
        = new ObservableCollection<Transaction>();

    public BankAccount()
    {
    }

    public BankAccount(
        Guid accountId,
        DateTime dateCreated,
        int pin,
        decimal initialAmount)
    {
        AccountId = accountId;
        DateCreated = dateCreated;
        PIN = pin;
        Amount = initialAmount;
    }
}

这种写法不依赖参数绑定。反序列化器会先调用无参构造函数,再依次设置各个属性。

代价是账户标识、创建时间和 PIN 会从只读成员变成可修改属性。如果这些值在业务中应保持不变,更适合使用前面的构造函数绑定方案。

还需要检查 Transaction

CustomerContext 会顺着对象关系处理 Customer、BankAccount 和 Transaction。如果 Transaction 也只有带参数的构造函数,需要检查以下内容:

  • 每个构造函数参数是否都有名称和类型匹配的属性或已包含字段。
  • 不要使用 initialAmount、transactionValue 这类业务别名去绑定名为 Amount 的成员。
  • 如果存在多个构造函数,应使用 [JsonConstructor] 指定反序列化构造函数。
  • 如果使用字段,应确保上下文启用了相应的字段配置。

当前异常可以直接通过把 initialAmount 改成 amount 来修复。更适合长期维护的方案是使用只读属性,并统一通过 CustomerContext 完成序列化和反序列化。

真实的银行类应用不应把明文密码或 PIN 直接写入 JSON 文件。密码通常应保存为带盐哈希,其他敏感数据也需要使用平台安全存储或合适的加密方案。

备注:内容仅供参考。