You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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: {&quot;meta&quot;:{&quot;date&quot;:&quot;2023-03-12T17:27:05.6781009Z&quot;,&quot;reason&quot;:&quot;I am hungry.&quot;}}

测试代码(可正常运行)

[Fact]
public void Test_LockboxController_LockboxPathDescriptionInlineFunc()
{
    // Arrange
    var regex = new Regex(@"\.v[0-9]*/\w+");
    dynamic description1 = "{&quot;meta&quot;:{&quot;date&quot;:&quot;2023-03-12T17:27:05.6781009Z&quot;,&quot;reason&quot;:&quot;I am hungry.&quot;}}";
    dynamic description2 = "{&quot;meta&quot;:{&quot;date&quot;:&quot;2023-03-12T14:42:41.0964823Z&quot;,&quot;reason&quot;:&quot;Just because.&quot;}}";
    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的实际运行时类型和测试场景不匹配,这是核心问题。

修正方案

  1. 显式转换类型:调用HtmlDecode前,先把desc显式转为字符串,避免动态类型的隐式转换问题:
var descStr = Convert.ToString(desc);
var decoded = WebUtility.HtmlDecode(descStr);
return JsonSerializer.Deserialize<Description>(decoded);

无论desc实际是什么类型,都会先转为字符串再解码,兼容更多场景。

  1. 避免滥用dynamic:如果LockboxPath.Description字段的实际类型明确为字符串,建议直接将该字段定义为string,从根源上避免动态类型带来的运行时绑定问题。

  2. 增加空值检查:处理前先判断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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.28 00:27:01