WebUtility.HtmlDecode在单元测试与部署环境中的异常表现排查
单元测试中将dynamic反序列化为JSON可正常通过,但应用部署后反序列化失败,抛出如下错误:
Microsoft.CSharp.RuntimeBinder.RuntimeBinderException: The best overloaded method match for 'System.Net.WebUtility.HtmlDecode(string)' has some invalid arguments
at CallSite.Target(Closure , CallSite , Type , Object )
at Controllers.LockboxController.<>c__DisplayClass12_0.<GetVersionedLockboxPaths>b__6(Object desc)
测试中通过HtmlDecode解码编码后的JSON,成功将实际为字符串的dynamic反序列化为Description类型对象。期望控制器返回的对象格式如下:
{ "description": { "meta": { "date": "2023-03-12T08:59:43.9816969Z", "reason": "I am hungry." } }, "fullPath": "1234557-0c39-4180-9e58-1234557/.v9/TEST_VERSIONING", "lockboxID": "1234557-0c39-4180-9e58-1234557", "pathId": 6116019, "pathName": ".v9/TEST_VERSIONING" }
执行内联Func前的日志信息:
GetVersionedLockboxPaths - Description: {"meta":{"date":"2023-03-12T17:27:05.6781009Z","reason":"I am hungry."}}
测试代码(可正常运行)
[Fact] public void Test_LockboxController_LockboxPathDescriptionInlineFunc() { // Arrange var regex = new Regex(@"\.v[0-9]*/\w+"); dynamic description1 = "{"meta":{"date":"2023-03-12T17:27:05.6781009Z","reason":"I am hungry."}}"; dynamic description2 = "{"meta":{"date":"2023-03-12T14:42:41.0964823Z","reason":"Just because."}}"; var secretPaths = new List<LockboxPath> { new LockboxPath { FullPath = "FOO_PATH", PathId = 1 }, new LockboxPath { FullPath = ".v0/FOO_PATH", PathId = 1, Description = description1 }, new LockboxPath { FullPath = ".v1/FOO_PATH", PathId = 2, Description = description2 }, }; var secretPathKey = "FOO_PATH"; var versionedSecretPathKey = ".v1/FOO_PATH"; // Act var versionedSecretPaths = secretPaths .Where(s => s.FullPath?.GetMatch(regex) ?? false) .Where(s => s.FullPath?.EndsWith(secretPathKey) ?? false) .Select(l => { Func<dynamic, dynamic> deserializeDescriptionBody = (desc) => { try { // Actual implementation will log INFO here. return JsonSerializer.Deserialize<Description>(WebUtility.HtmlDecode(desc)); } catch (System.Exception) { // Error described above is logger here } return ""; }; return new LockboxPath { PathId = l.PathId, PathName = l.PathName, FullPath = l.FullPath, LockboxID = l.LockboxID, Description = deserializeDescriptionBody(l.Description) }; }) .OrderByDescending(l => l.PathId) .ToList(); var latestVersionedSecretPath = versionedSecretPaths .OrderBy(s => s.PathId) .Last(); // Assert Assert.True(versionedSecretPathKey.GetMatch(regex) == true, "Seven Hells!"); Assert.True(latestVersionedSecretPath.Description?.Meta.Reason == "Just because.", "Seven Hells!"); Assert.True(latestVersionedSecretPath.Description?.Meta.Reason != "For a very good reason.", "Seven Hells!"); }
控制器相关代码
versionedLockboxPaths = allLockboxPaths .Where(l => (l.FullPath?.GetMatch(new Regex(@"\.v[0-9]*/\w+")) ?? false)) .Where(l => (l.FullPath?.EndsWith(primaryLockboxPath.PathName ?? throw new NullReferenceException("Seven Hells!")) ?? false)) .Select(l => { Func<dynamic, dynamic> deserializeDescriptionBody = (desc) => { try { _logger.Information($"GetVersionedLockboxPaths - Description: {desc}"); return JsonSerializer.Deserialize<Description>(WebUtility.HtmlDecode(desc)); } catch (System.Exception ex) { _logger.Warning(ex, "Unable to deserialize Description."); } return ""; }; return new LockboxPath { PathId = l.PathId, PathName = l.PathName, FullPath = l.FullPath, LockboxID = l.LockboxID, Description = deserializeDescriptionBody(l.Description) }; }) .OrderByDescending(l => l.PathId) .ToList();
疑问
HtmlDecode在运行时与xunit测试中的表现是否真的存在差异?若存在,原因是什么,该如何修正?另外可能存在我忽略的错误操作,也请指出。
根本原因
不是HtmlDecode本身的表现差异,而是测试环境与生产环境中l.Description的实际类型不一致:
- 测试代码里你把
description1/2定义为dynamic但实际是string类型,传入HtmlDecode时可以隐式转换为方法要求的string参数。 - 生产环境中,
allLockboxPaths里的Description字段并不是string类型,可能是匿名类型、JObject或其他非字符串类型,导致dynamic传入HtmlDecode时无法隐式转换为string,触发RuntimeBinderException。
日志输出的内容虽然是HTML编码后的字符串,但dynamic的实际运行时类型和测试场景不匹配,这是核心问题。
修正方案
- 显式转换类型:调用
HtmlDecode前,先把desc显式转为字符串,避免动态类型的隐式转换问题:
var descStr = Convert.ToString(desc); var decoded = WebUtility.HtmlDecode(descStr); return JsonSerializer.Deserialize<Description>(decoded);
无论desc实际是什么类型,都会先转为字符串再解码,兼容更多场景。
避免滥用dynamic:如果
LockboxPath.Description字段的实际类型明确为字符串,建议直接将该字段定义为string,从根源上避免动态类型带来的运行时绑定问题。增加空值检查:处理前先判断
desc是否为null,避免转换时出现异常:
Func<dynamic, dynamic> deserializeDescriptionBody = (desc) => { try { _logger.Information($"GetVersionedLockboxPaths - Description: {desc}"); if (desc == null) return null; var descStr = Convert.ToString(desc); var decoded = WebUtility.HtmlDecode(descStr); return JsonSerializer.Deserialize<Description>(decoded); } catch (System.Exception ex) { _logger.Warning(ex, "Unable to deserialize Description."); } return null; };
被忽略的错误操作
- 测试代码中直接用
dynamic赋值字符串,模拟场景与生产环境实际数据类型不匹配,导致测试无法覆盖真实的运行时类型问题。 - 控制器代码中反序列化失败时返回空字符串
"",但期望返回的是Description类型对象,这会导致返回的JSON结构不符合预期(空字符串代替对象),建议返回null或默认的Description实例。
内容的提问来源于stack exchange,提问作者ISZ

