本文分享我在一次联调过程中遇到的上游接口字段类型漂移问题,以及我是如何通过自定义 JsonConverter 把影响面压到最小的实战经验。面对这种突发情况,正确的处理方式不是慌忙改模型,而是优雅地在反序列化边界做兼容。

1. 引言

最近在联调测试时,我碰到一个很典型的问题。

文档里约定某个字段返回的是数组,我也一直按这个前提开发,连本地模拟数据都是照着这个结构写的。结果真正联调时,对方给过来的却不是数组,而是一个字符串,而且还是“长得像数组”的字符串。

也就是说,原来应该是这样的:

[2800, 2750, 2850, 2700]

现在却变成了这样:

"[2800,2750,2850,2700]"

程序一跑,反序列化直接报错。更麻烦的是,这个字段不是只在一个地方用到,而是已经被多个流程依赖了。

这个时候如果图省事,直接把模型改成字符串,或者在业务代码里到处补解析逻辑,短期也许能过,后面大概率会越改越乱。

所以我最后没有动业务层,也没有改下游契约,而是把兼容逻辑收敛在 JSON 反序列化的边界。这样一来,内部模型继续保持原来的语义,外部变化也被控制在了最小范围内。

2. 问题到底出在哪

假设我们原来接收到的数据是这样的:

{
  "Metrics": [2800, 2750, 2850, 2700]
}

后来它变成了这样:

{
  "Metrics": "[2800,2750,2850,2700]"
}

如果我们的 .NET 模型还是这样定义:

public sealed class DeviceInfo
{
    [JsonPropertyName("Metrics")]
    public List<double>? Metrics { get; init; }
}

那反序列化基本就会当场报错。因为 System.Text.Json 看到的是字符串,而模型期待的是 List<double>

在这里插入图片描述

这时候通常会出现三种处理方式:

  1. 直接把属性改成 string
  2. 在业务逻辑里手动判断,如果是字符串就自己再 parse 一次
  3. 在 JSON 边界把两种格式统一起来

前两种不是不能用,但都不够干净。尤其是第二种,一旦这个字段被多个接口、多个流程复用,解析逻辑很快就会散得到处都是。

更合理的做法,是把这类“脏数据兼容”收敛在反序列化这一层。边界之外,大家仍然面对一个正常的 List。

3. 处理思路

第三个方案的核心其实可以概括成一句话:

把不确定性挡在系统入口,不要把它带进系统内部。

具体做法如下:

  1. 模型继续保留 List<double>,不因为上游抖动而退化成 string
  2. 给这个字段挂一个自定义 JsonConverter
  3. Converter 同时支持三种输入:null、数组、字符串化数组
  4. 反序列化完成后,后续代码完全不用知道上游到底传的是哪一种格式

这样做的好处很直接:下游契约不用动,业务代码不用动。如果未来上游又改回数组,我们也不用再回滚一遍逻辑。兼容只发生在入口,影响面自然也就被压住了。

4. 核心实现

先给字段加上转换器:

public sealed class DeviceInfo
{
    [JsonPropertyName("Metrics")]
    [JsonConverter(typeof(FlexibleDoubleListJsonConverter))]
    public List<double>? Metrics { get; init; }
}

然后实现这个转换器:

using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;

internal sealed class FlexibleDoubleListJsonConverter : JsonConverter<List<double>?>
{
    public override List<double>? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        if (reader.TokenType == JsonTokenType.Null)
        {
            return null;
        }

        if (reader.TokenType == JsonTokenType.StartArray)
        {
            var values = new List<double>();

            while (reader.Read())
            {
                if (reader.TokenType == JsonTokenType.EndArray)
                {
                    return values;
                }

                if (reader.TokenType == JsonTokenType.Number)
                {
                    values.Add(reader.GetDouble());
                    continue;
                }

                if (reader.TokenType == JsonTokenType.String &&
                    double.TryParse(reader.GetString(), NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var parsedValue))
                {
                    values.Add(parsedValue);
                    continue;
                }

                throw new JsonException("Invalid numeric value in array.");
            }

            throw new JsonException("Incomplete array.");
        }

        if (reader.TokenType == JsonTokenType.String)
        {
            return ParseFromString(reader.GetString());
        }

        throw new JsonException($"Unsupported token type: {reader.TokenType}.");
    }

    public override void Write(Utf8JsonWriter writer, List<double>? value, JsonSerializerOptions options)
    {
        if (value is null)
        {
            writer.WriteNullValue();
            return;
        }

        writer.WriteStartArray();

        foreach (var item in value)
        {
            writer.WriteNumberValue(item);
        }

        writer.WriteEndArray();
    }

    private static List<double> ParseFromString(string? raw)
    {
        if (string.IsNullOrWhiteSpace(raw))
        {
            return new List<double>();
        }

        var normalized = raw.Trim();
        if (normalized.StartsWith("[", StringComparison.Ordinal) && normalized.EndsWith("]", StringComparison.Ordinal))
        {
            normalized = normalized[1..^1];
        }

        if (string.IsNullOrWhiteSpace(normalized))
        {
            return new List<double>();
        }

        var values = new List<double>();
        foreach (var segment in normalized.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
        {
            if (!double.TryParse(segment, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var parsedValue))
            {
                throw new JsonException($"Invalid numeric value in string: {segment}.");
            }

            values.Add(parsedValue);
        }

        return values;
    }
}

这段代码不长,但它解决的是一个很典型的边界问题。

如果上游老老实实给数组,我们按数组读;如果它给的是字符串,我们就把字符串拆开,再还原成 List。对于后面的业务代码来说,这两种输入最终都会落到同一种内部结构上。

5. 几个值得注意的细节

5.1 模型语义不要被上游拖着走

这一点我其实很在意。

虽然上游现在传的是字符串,但这个字段在业务语义上依然是“一组数值”。如果因为一次联调波动,就把模型改成 string,本质上是在拿内部设计去迁就外部噪音。

短期看省事,长期看一定会留下技术债。

5.2 用 InvariantCulture 解析数字

这是个很小但很重要的细节。

如果不显式使用 CultureInfo.InvariantCulture,某些环境下小数点、千分位的解析行为可能受系统区域设置影响。兼容上游报文时,这类不确定性越少越好。

5.3 写回时仍然输出标准数组

我在 Write 里没有把数据重新写成字符串,而是老老实实输出成 JSON 数组。原因很简单:

我们兼容输入,不等于我们也要把不规范的格式继续传下去。

读的时候包容,写的时候标准,这个原则大多数时候都成立。

5.4 如果项目开了 AOT,尽量用属性级转换器

很多 .NET 项目现在已经开始用 System.Text.Json 的源生成或者 AOT 方案了。这个时候,像这种局部字段兼容,最省心的方式通常就是属性级 JsonConverter

一方面改动范围小,另一方面也不会把全局序列化行为弄得太复杂。尤其是在接口模型已经比较稳定的项目里,这种做法会比全局 Converter 更可控。

6. 最终效果

做完之后,整体效果其实很清晰:

  1. 上游发数组,系统正常处理
  2. 上游发字符串化数组,系统也正常处理
  3. 内部模型不变
  4. 下游接口不变
  5. 兼容逻辑只存在于一个地方,后续维护成本很低

这种方案最大的价值,不在于代码有多巧,而在于它把影响面压到了最小。

很多时候,真正好的修复不是“改得快”,而是“改完以后,别的地方像没发生过这件事”。

7. 最后

上游接口字段类型漂移,这种事在实际对接里并不少见。真正值得警惕的,不是这次兼容本身,而是你会不会因为一次临时修补,把整个系统内部的数据语义也一起带歪。

很多时候技术上的选择我会倾向于一个简单原则:大道至简,在边界消化变化,在内部维持稳定。

自定义 JsonConverter 不算花哨,也谈不上什么高级技巧,但这种办法有一个非常现实的优点:稳。

而对于很多线上系统来说,稳,往往比巧更重要。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐