联调发现上游改接口没有通知你,怎么把影响面压到最小?
本文分享我在一次联调过程中遇到的上游接口字段类型漂移问题,以及我是如何通过自定义 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>。

这时候通常会出现三种处理方式:
- 直接把属性改成 string
- 在业务逻辑里手动判断,如果是字符串就自己再 parse 一次
- 在 JSON 边界把两种格式统一起来
前两种不是不能用,但都不够干净。尤其是第二种,一旦这个字段被多个接口、多个流程复用,解析逻辑很快就会散得到处都是。
更合理的做法,是把这类“脏数据兼容”收敛在反序列化这一层。边界之外,大家仍然面对一个正常的 List。
3. 处理思路
第三个方案的核心其实可以概括成一句话:
把不确定性挡在系统入口,不要把它带进系统内部。
具体做法如下:
- 模型继续保留
List<double>,不因为上游抖动而退化成string - 给这个字段挂一个自定义
JsonConverter Converter同时支持三种输入:null、数组、字符串化数组- 反序列化完成后,后续代码完全不用知道上游到底传的是哪一种格式
这样做的好处很直接:下游契约不用动,业务代码不用动。如果未来上游又改回数组,我们也不用再回滚一遍逻辑。兼容只发生在入口,影响面自然也就被压住了。
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. 最终效果
做完之后,整体效果其实很清晰:
- 上游发数组,系统正常处理
- 上游发字符串化数组,系统也正常处理
- 内部模型不变
- 下游接口不变
- 兼容逻辑只存在于一个地方,后续维护成本很低
这种方案最大的价值,不在于代码有多巧,而在于它把影响面压到了最小。
很多时候,真正好的修复不是“改得快”,而是“改完以后,别的地方像没发生过这件事”。
7. 最后
上游接口字段类型漂移,这种事在实际对接里并不少见。真正值得警惕的,不是这次兼容本身,而是你会不会因为一次临时修补,把整个系统内部的数据语义也一起带歪。
很多时候技术上的选择我会倾向于一个简单原则:大道至简,在边界消化变化,在内部维持稳定。
自定义 JsonConverter 不算花哨,也谈不上什么高级技巧,但这种办法有一个非常现实的优点:稳。
而对于很多线上系统来说,稳,往往比巧更重要。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐




所有评论(0)