1. 项目概述一次典型的Json反序列化“踩坑”实录在.NET生态里做开发NewtonSoft.Json现在更多叫Json.NET几乎是处理JSON数据的标配。它强大、灵活社区支持度极高但正是这种灵活性有时也会带来一些意想不到的“惊喜”。比如当你信心满满地调用JsonConvert.DeserializeObjectT()准备将一段JSON字符串变成强类型对象时控制台却冷不丁地抛出一个JsonReaderException并附赠一句令人困惑的提示“Unexpected character encountered while parsing value...”。这个错误我敢说几乎每个用过NewtonSoft.Json的.NET开发者都至少遇到过一两次。它不像空引用异常那样直接也不像逻辑错误那样隐蔽它更像是一个守门人告诉你“你给我的东西格式不对我读不懂。”这次记录就是围绕这个经典的“Unexpected character”错误展开的一次深度排查和解决之旅。它不仅仅是一个错误代码的解决更是一次对JSON数据源、序列化配置、类型契约以及异常处理思维的全面审视。无论你是正在被这个错误困扰的新手还是想系统梳理一下相关知识的资深开发者相信这篇从实战中总结的记录都能给你带来直接的帮助。我们会从错误现象出发层层剥茧探讨各种可能的原因并提供可立即上手的排查步骤和解决方案。2. 错误场景深度解析与常见诱因2.1 “Unexpected character”错误的本质首先我们需要理解这个错误在NewtonSoft.Json库中的定位。JsonReaderException是底层JSON解析器JsonReader在尝试将字符流转换为令牌Token时抛出的异常。当解析器按预期应该读取一个值的起始字符如引号表示字符串开始{表示对象开始[表示数组开始或数字、布尔值字面量时却遇到了一个它无法识别的字符就会触发此异常。简单来说就是解析器在“该读数据的地方读到了奇怪的东西”。这个“奇怪的东西”可能是一个多余的空格、一个不可见的控制字符、一个编码错误的字符甚至是整个JSON结构根本就是错的。2.2 六大高频“案发现场”剖析根据我多年的调试经验导致这个错误的常见原因可以归纳为以下几类理解它们能让你在遇到问题时快速定位方向。2.2.1 JSON字符串格式损坏或不完整这是最直接的原因。你的JSON字符串可能被意外截断在通过网络传输、文件读取或字符串拼接时丢失了结尾的}或]。包含非法控制字符比如在字符串值内部包含了未转义的换行符(\n)、制表符(\t)虽然在JSON字符串中需转义但有时数据源会直接包含原始字符或者更罕见的垂直制表符等。特别是在处理从富文本编辑器、用户直接输入或某些老旧系统导出的数据时这种情况很常见。编码问题字符串可能包含来自不同编码如UTF-8带BOM GBK等的字节序列在转换为.NET字符串时产生了乱码或特殊字符。例如一个UTF-8 BOM头0xEF, 0xBB, 0xBF在解析器看来就是一个“意外字符”。2.2.2 字符串值缺少引号或引号不匹配JSON规范要求属性名和字符串值必须用双引号()包裹。以下情况会引发错误// 错误属性名未用双引号 { name: “John” } // 错误字符串值使用了单引号NewtonSoft.Json默认严格模式不接受 { “name”: ‘John’ } // 错误引号未正确闭合 { “name”: “John }虽然NewtonSoft.Json可以通过JsonSerializerSettings设置StringEscapeHandling等属性来应对一些不严格的情况但默认设置是相对严格的。2.2.3 数据类型不匹配这是初学者和对接外部接口时极易踩的坑。你的C#模型Class定义了一个属性为int但JSON中对应的值却是string类型带引号或者甚至是null、空字符串。public class Person { public int Age { get; set; } } // JSON: { “Age”: “25” } // 错误期望数字遇到字符串起始引号 // JSON: { “Age”: “” } // 错误空字符串无法转换为int // JSON: { “Age”: null } // 如果Age是int null会导致错误如果是int? 则允许。解析器在尝试为Age属性解析值时期望一个数字令牌但一上来就遇到了双引号(“)于是抛出“Unexpected character”。2.2.4 转义字符处理不当JSON中的字符串内某些字符需要转义如双引号(\)、反斜杠(\\)、换行符(\n)等。如果JSON字符串中的转义序列不正确或未转义解析就会失败。未转义的反斜杠{ “path”: “C:\Users\file.json” }这里的\U和\f会被解析器尝试解释为转义序列但它们是无效的从而引发错误。正确的应该是“C:\\Users\\file.json”。错误的Unicode转义\u后必须跟4位十六进制数。如果格式不对如\uXYZG也会出错。2.2.5 BOM字节顺序标记问题如前所述从某些文件或HTTP响应中读取的文本如果开头包含BOM它对于JsonConvert来说就是一个意外的字符。虽然它在内存中是一个不可见的字符但解析器能敏锐地察觉到。2.2.6 隐藏字符和空白符字符串开头或结尾或者属性值之间可能混入了不可见的字符如零宽空格(\u200B)、不间断空格(\u00A0)等。这些字符在大多数文本编辑器中不可见但会破坏JSON解析。3. 系统化诊断与排查实战流程当错误发生时盲目猜测是低效的。我总结了一套从外到内、由表及里的排查流程可以帮你快速锁定问题根源。3.1 第一步原始数据验尸——获取并检查原始JSON字符串这是最重要的一步。不要相信日志里截断的字符串也不要相信你以为的数据。在调用DeserializeObject之前将你准备反序列化的原始字符串完整地打印或记录到日志文件中。string jsonString await httpClient.GetStringAsync(apiUrl); // 关键诊断步骤记录原始数据 Console.WriteLine(“Raw JSON String:”); Console.WriteLine(jsonString); // 或者记录长度判断是否被截断 Console.WriteLine($“JSON String Length: {jsonString.Length}”); // 然后再尝试反序列化 var result JsonConvert.DeserializeObjectMyModel(jsonString);检查这个原始字符串肉眼观察结构是否完整括号是否匹配引号是否成对使用验证工具将字符串复制到在线的JSON验证器如 jsonlint.com 或你使用的IDE如VS Code、Rider的JSON验证功能中。工具会精确地指出语法错误的位置。查看不可见字符在高级文本编辑器如Notepad、Sublime Text、VS Code中开启“显示所有字符”或“渲染空白字符”的功能。你会看到空格、制表符、换行符以及那些讨厌的零宽字符。3.2 第二步上下文隔离——使用最宽松的设置进行测试为了排除是自身模型定义或复杂设置导致的问题可以尝试用最简方式解析。try { // 尝试反序列化为最简单的类型如 JObject 或 dynamic var jObject JsonConvert.DeserializeObjectJObject(jsonString); Console.WriteLine(“Successfully parsed to JObject.”); // 如果能成功说明JSON语法基本没问题问题可能出在模型映射上 } catch (JsonReaderException ex) { Console.WriteLine($“Failed even with JObject. Error at Path: {ex.Path}, Line: {ex.LineNumber}, Position: {ex.LinePosition}”); Console.WriteLine($“Message: {ex.Message}”); }JObject是NewtonSoft.Json提供的用于动态操作JSON的对象。如果能成功反序列化为JObject则证明JSON字符串本身语法是合格的错误很可能源于你的强类型模型MyModel与JSON结构不匹配。此时异常信息中的Path、LineNumber和LinePosition将直接指向出问题的具体位置价值连城。3.3 第三步模型契约审查——对比JSON与C#模型如果上一步用JObject解析成功那么问题焦点就转移到你的数据模型 (MyModel) 上了。属性名匹配NewtonSoft.Json默认使用驼峰命名解析但序列化/反序列化时大小写不敏感。检查JSON中的属性名是否与C#模型属性名完全匹配忽略大小写。例如JSON是{ “firstName”: “John” } 模型属性可以是FirstName或firstname。使用[JsonProperty]特性如果命名习惯不一致这是最好的解决方案。它明确指定了映射关系。public class Person { [JsonProperty(“first_name”)] // 映射JSON中的蛇形命名 public string FirstName { get; set; } }数据类型兼容性仔细核对每个属性的类型。string对应JSON字符串int/double对应JSON数字bool对应true/falseJToken或自定义类型对应JSON对象{} 集合类型对应JSON数组[]。对于可能为null或空字符串的值考虑使用可空类型 (int?,DateTime?)。集合类型如果JSON中某个属性是数组[]但你的模型定义的是单个对象也会引发解析错误。3.4 第四步序列化设置调优——处理非标准JSON有时数据源提供的JSON并不完全标准。NewtonSoft.Json提供了丰富的JsonSerializerSettings来应对。var settings new JsonSerializerSettings { // 1. 处理日期格式 DateFormatString “yyyy-MM-dd HH:mm:ss”, // 2. 处理空值忽略JSON中为null的属性不赋值给模型 NullValueHandling NullValueHandling.Ignore, // 3. 处理缺失值JSON中不存在的属性在模型中使用默认值 DefaultValueHandling DefaultValueHandling.Populate, // 4. 处理类型名称多态序列化 TypeNameHandling TypeNameHandling.Auto, // 5. 最重要的错误处理方式 Error (sender, args) { // 当某个属性解析出错时记录错误并继续解析其他属性 Console.WriteLine($“Error parsing ‘{args.ErrorContext.Path}’: {args.ErrorContext.Error.Message}”); args.ErrorContext.Handled true; // 标记为已处理继续解析 } }; var result JsonConvert.DeserializeObjectMyModel(jsonString, settings);Error事件处理程序是一个强大的调试工具。当某个属性反序列化失败时比如类型不匹配它会触发并且通过args.ErrorContext你可以获得详细的错误信息和路径而不会导致整个反序列化过程崩溃。这在处理不可靠的外部数据源时非常有用。4. 针对高频诱因的专项解决方案4.1 解决方案处理BOM和编码问题如果怀疑是BOM或编码问题可以在反序列化前对字符串进行清理。public static string RemoveBom(string jsonString) { // UTF-8 BOM 是 0xEF,0xBB,0xBF 对应字符串 “\uFEFF” (Zero Width No-Break Space) string bom “\uFEFF”; if (jsonString.StartsWith(bom)) { return jsonString.Remove(0, bom.Length); } return jsonString; } // 或者更通用的方法指定正确的编码读取 using (var reader new StreamReader(fileStream, Encoding.UTF8, true)) // 最后一个参数detectEncodingFromByteOrderMarks设为true { jsonString reader.ReadToEnd(); } // 然后再进行清理和反序列化 jsonString RemoveBom(jsonString); var result JsonConvert.DeserializeObjectMyModel(jsonString);4.2 解决方案处理不规范的JSON如单引号、无引号虽然不推荐接收不规范的JSON但有时不得不处理遗留系统数据。可以配置设置或者进行预处理。var settings new JsonSerializerSettings(); // NewtonSoft.Json 默认无法处理单引号。一种方法是预处理字符串。 jsonString jsonString.Replace(“‘”, “\””); // 将单引号替换为双引号注意这可能会错误替换字符串内容内的合法单引号需谨慎 // 对于属性名无引号的情况预处理更复杂可能需要正则表达式但风险很高。 // 最佳实践是要求数据源提供标准JSON。4.3 解决方案精确捕获和定位错误利用JsonReaderException提供的详细信息进行精准定位。try { var result JsonConvert.DeserializeObjectMyModel(jsonString); } catch (JsonReaderException jex) { // 这些信息是黄金 int linePos jex.LinePosition; int lineNum jex.LineNumber; string path jex.Path; Console.WriteLine($“JSON解析错误在路径 ‘{path}‘ 第 {lineNum} 行 第 {linePos} 列。”); Console.WriteLine($“错误信息: {jex.Message}”); // 打印出错位置附近的上下文便于查看 if (!string.IsNullOrEmpty(jsonString) lineNum 0) { var lines jsonString.Split(‘\n’); if (lineNum - 1 lines.Length) { string errorLine lines[lineNum - 1]; Console.WriteLine($“错误行内容: {errorLine}”); // 高亮显示错误位置在控制台用^指示 string indicator new string(‘ ‘, linePos - 1) ‘^’; Console.WriteLine(indicator); } } }4.4 解决方案使用JObject.Parse进行安全解析和手动映射对于极度不可靠的数据或者需要更灵活处理的场景可以放弃自动反序列化采用手动解析。try { JObject jObj JObject.Parse(jsonString); // 这里也会抛出 JsonReaderException 但能精确捕获 MyModel model new MyModel(); // 手动映射并添加容错逻辑 if (jObj[“id”] ! null int.TryParse(jObj[“id”].ToString(), out int idVal)) { model.Id idVal; } else { model.Id -1; // 默认值 _logger.LogWarning(“Failed to parse ‘id’ from JSON.”); } model.Name jObj[“name”]?.ToString(); // 安全获取字符串 // … 其他属性 } catch (JsonReaderException ex) { // 处理根本的JSON语法错误 _logger.LogError(ex, “Invalid JSON syntax.”); }这种方法虽然代码量增多但获得了完全的控制权可以对每个字段进行验证、转换和日志记录非常适合与第三方API交互或处理用户输入。5. 进阶自定义转换器应对复杂场景当内置的转换逻辑无法满足需求时例如需要处理特殊格式的日期字符串、将枚举值和字符串互转、或者处理多态类型自定义JsonConverter是终极武器。5.1 案例处理多种日期格式假设接口返回的日期可能是“2023-10-27”、“27/10/2023”或时间戳1698393600。public class FlexibleDateTimeConverter : JsonConverterDateTime { private readonly string[] _formats { “yyyy-MM-dd”, “dd/MM/yyyy”, “yyyy-MM-ddTHH:mm:ss” }; public override DateTime ReadJson(JsonReader reader, Type objectType, DateTime existingValue, bool hasExistingValue, JsonSerializer serializer) { if (reader.TokenType JsonToken.Integer) { // 处理时间戳秒 long timestamp (long)reader.Value; return DateTimeOffset.FromUnixTimeSeconds(timestamp).UtcDateTime; } if (reader.TokenType JsonToken.String) { string dateString reader.Value?.ToString(); if (DateTime.TryParseExact(dateString, _formats, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime result)) { return result; } // 如果特定格式失败尝试通用解析 if (DateTime.TryParse(dateString, out result)) { return result; } } // 如果都无法解析可以抛出更友好的异常或者返回默认值 throw new JsonSerializationException($“无法将值 ‘{reader.Value}‘ 转换为 DateTime.”); } public override void WriteJson(JsonWriter writer, DateTime value, JsonSerializer serializer) { // 序列化时的逻辑这里统一输出为ISO 8601格式 writer.WriteValue(value.ToString(“O”)); } } // 使用方式 public class Event { [JsonConverter(typeof(FlexibleDateTimeConverter))] public DateTime EventDate { get; set; } } // 或者在全局设置中应用 var settings new JsonSerializerSettings(); settings.Converters.Add(new FlexibleDateTimeConverter());5.2 案例处理可能为字符串或数字的字段有些API设计不佳同一个字段有时返回数字42有时返回字符串“42”。public class StringOrIntConverter : JsonConverterint { public override int ReadJson(JsonReader reader, Type objectType, int existingValue, bool hasExistingValue, JsonSerializer serializer) { switch (reader.TokenType) { case JsonToken.Integer: return Convert.ToInt32(reader.Value); case JsonToken.String: if (int.TryParse(reader.Value.ToString(), out int intVal)) { return intVal; } break; case JsonToken.Null: // 处理null return 0; // 或根据业务返回默认值 } throw new JsonSerializationException($“Expected integer or numeric string for {objectType.Name}.”); } public override void WriteJson(JsonWriter writer, int value, JsonSerializer serializer) { writer.WriteValue(value); // 序列化时统一为数字 } } public class Product { [JsonConverter(typeof(StringOrIntConverter))] public int Stock { get; set; } }6. 防御性编程与最佳实践总结经过一系列排查和解决我们最终的目标是构建健壮的反序列化代码。以下是我总结的几条核心最佳实践永远不要信任外部数据无论是文件、数据库还是API接口返回的数据在反序列化前都应视为潜在的危险源。进行必要的验证和清理。实施结构化日志记录在反序列化操作前后记录原始数据的哈希值如MD5、长度和关键片段。当错误发生时这些日志能帮你快速判断是数据问题还是代码问题。使用强类型模型的验证特性结合使用[JsonProperty]明确映射关系并利用C#的数据注解如[Required],[Range]或更强大的验证库如FluentValidation在反序列化后进行业务规则验证。封装反序列化操作不要在每个业务代码中直接调用JsonConvert.DeserializeObject。将其封装在一个辅助类或服务中集中处理异常、日志记录、设置管理和重试逻辑。考虑性能与内存对于非常大的JSON数据使用JsonTextReader进行流式读取避免一次性将整个字符串加载到内存。对于频繁反序列化的场景可以缓存JsonSerializerSettings和JsonConverter实例。单元测试是保障为你的反序列化逻辑编写单元测试覆盖正常用例和各种边界情况空值、错误格式、类型不匹配、超大数字等。使用测试数据驱动确保代码的健壮性。反序列化错误像是一个谜题“Unexpected character”只是谜面。解决它的过程考验的是开发者对数据流的掌控力、对工具特性的理解深度以及系统化的调试思维。希望这份详细的记录能成为你下次遇到类似问题时的有效路线图。