通过模拟 HttpResponseMessage 的 C# 字符串编码问题

C# string encoding issue via mocked HttpResponseMessage

我正在尝试测试从远程 API 获取数据。我按如下方式设置 HttpClient:

HttpClient httpClient = SetupHttpClient((HttpRequestMessage request) =>
{
    FileStream file = new FileStream("API_Data.json"), FileMode.Open, FileAccess.Read);
    StreamReader sr = new StreamReader(file, true);
    var response = request.CreateResponse(HttpStatusCode.OK, sr.ReadToEnd());
    response.Content.Headers.ContentEncoding.Add("UTF-8");
    return Task.FromResult(response);
});

SetupHttpClient 在这里不相关 - 重要的是传递的响应,如您所见,它是通过从 FileStream 创建 StreamReader 并将该流读入响应而创建的。

使用文本可视化工具,我可以看到文件已成功读入响应流,并且所有特殊字符(例如换行符、制表符和双引号)都正确显示,如屏幕截图所示:

在另一端,我从 HttpResponseMessage 中获取内容如下:

Stream responseStream = await response.Content.ReadAsStreamAsync();
StreamReader responseReader = null;

if (response.Content.Headers.ContentEncoding.Count > 0)
    responseReader = new StreamReader(responseStream, System.Text.Encoding.GetEncoding(response.Content.Headers.ContentEncoding.First()));
else
    responseReader = new StreamReader(responseStream, true);

string content = await responseReader.ReadToEndAsync();
return content;

此时再次悬停调试响应显示数据还是OK的:

Text Visualizer 显示与上面的第一个屏幕截图完全相同。问题来了 - 即使响应内容是字符串,我也无法访问值 属性 并且 response.Content 提供的所有检索机制都是通过 Streams。好的,所以我通过 Stream 获取内容,但是在通过 Stream 之后,所有特殊字符现在都经过双重转义,如您在此处所见:

这意味着我现在必须取消转义所有这些特殊字符,以便能够将返回的字符串用作 json - 如果我不取消转义,那么 JsonDeserializer 会在我尝试反序列化它。 StreamReader 还添加了一个(单转义的)双引号作为第一个和最后一个字符。

通过谷歌搜索,我所能找到的都是关于使用正确编码的参考资料。因此,我确保我将源文件保存为 UTF-8,我发送 'UTF-8' 作为 HttpResponseMessage (response.Content.Headers.ContentEncoding.Add("UTF-8");) 的编码,并且在解码响应时 'UTF-8' 是再次用作编码 (responseReader = new StreamReader(responseStream, System.Text.Encoding.GetEncoding(response.Content.Headers.ContentEncoding.First()));) - 如您所见,这没有达到获得未双重转义的字符串的预期效果。

我不想在从 Stream 获取响应字符串时对所有特殊字符进行 'manual' 取消转义 - 这是一个糟糕的 hack,但感觉这是唯一的选择目前 - 如果我检测到 response.Content 是一个字符串,或者使用反射来获取 response.Content.Value 属性 的内容 - 这又是另一个我不想做的 hack。

如何确保在通过 StreamReader 获取 response.Content 值时不会得到双重转义的特殊字符?

编辑:为清楚起见,这里是 SetupHttpClient 方法:

public HttpClient SetupHttpClient(Func<HttpRequestMessage, Task<HttpResponseMessage>> response)
{
    var configuration = new HttpConfiguration();
    var clientHandlerStub = new HttpDelegatingHandlerStub((request, cancellationToken) =>
    {
        request.SetConfiguration(configuration);
        return response(request);
    });

    HttpClient httpClient = new HttpClient(clientHandlerStub);
    mockHttpClientFactory.Setup(_ => _.CreateClient(It.IsAny<string>())).Returns(httpClient);
    return httpClient;
}

和 HttpDelegatingHandlerStub

public class HttpDelegatingHandlerStub : DelegatingHandler
{
    private readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> _handlerFunc;
    public HttpDelegatingHandlerStub()
    {
        _handlerFunc = (request, cancellationToken) => Task.FromResult(request.CreateResponse(HttpStatusCode.OK));
    }

    public HttpDelegatingHandlerStub(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> handlerFunc)
    {
        _handlerFunc = handlerFunc;
    }

    protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        return _handlerFunc(request, cancellationToken);
    }
}

EDIT2:一个最小的、可重现的例子——这需要以下包——Microsoft.AspNet.WebApi.Core、Microsoft.Extensions.Http、最小起订量:

using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using System.Web;
using System.Web.Http;

using Moq;

namespace StreamReaderEncoding
{
    internal class Program
    {
        static Mock<IHttpClientFactory> mockHttpClientFactory;

        static void Main(string[] args)
        {
            MainAsync().Wait();
        }

        static async Task MainAsync()
        {
            mockHttpClientFactory = new Mock<IHttpClientFactory>();
            string content = @"{
    ""test"": ""true""
}";
            Console.WriteLine($"content before: {content}");

            HttpClient httpClient = SetupHttpClient((HttpRequestMessage request) =>
            {
                var stream = new MemoryStream();
                var writer = new StreamWriter(stream);
                writer.Write(content);
                writer.Flush();
                stream.Position = 0;

                StreamReader sr = new StreamReader(stream, true);
                var response = request.CreateResponse(HttpStatusCode.OK, sr.ReadToEnd());
                response.Content.Headers.ContentEncoding.Add("UTF-8");
                return Task.FromResult(response);
            });

            HttpResponseMessage response = await httpClient.GetAsync("https://www.test.com");

            Stream responseStream = await response.Content.ReadAsStreamAsync();
            StreamReader responseReader = null;

            if (response.Content.Headers.ContentEncoding.Count > 0)
                responseReader = new StreamReader(responseStream, System.Text.Encoding.GetEncoding(response.Content.Headers.ContentEncoding.First()));
            else
                responseReader = new StreamReader(responseStream, true);

            content = await responseReader.ReadToEndAsync();
            Console.WriteLine($"content after: {content}");
        }

        static HttpClient SetupHttpClient(Func<HttpRequestMessage, Task<HttpResponseMessage>> response)
        {
            var configuration = new HttpConfiguration();
            var clientHandlerStub = new HttpDelegatingHandlerStub((request, cancellationToken) =>
            {
                request.SetConfiguration(configuration);
                return response(request);
            });

            HttpClient httpClient = new HttpClient(clientHandlerStub);
            mockHttpClientFactory.Setup(_ => _.CreateClient(It.IsAny<string>())).Returns(httpClient);
            return httpClient;
        }
    }

    internal class HttpDelegatingHandlerStub : DelegatingHandler
    {
        private readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> _handlerFunc;
        public HttpDelegatingHandlerStub()
        {
            _handlerFunc = (request, cancellationToken) => Task.FromResult(request.CreateResponse(HttpStatusCode.OK));
        }

        public HttpDelegatingHandlerStub(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> handlerFunc)
        {
            _handlerFunc = handlerFunc;
        }

        protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
        {
            return _handlerFunc(request, cancellationToken);
        }
    }
}

示例的输出:

content before: {
    "test": "true"
}
content after: "{\r\n    \"test\": \"true\"\r\n}"

这与起订量无关...完全是关于 HttpRequestMessageExtensions.CreateResponse(),它将您的字符串编码为 JSON。

这是一个更简单的示例(作为 .NET 6 控制台应用程序;您可以忍受它抱怨恢复 net461 的目标,或者将其重新定位为 .NET 4.7.1 或类似版本并添加一些项目选项和 using 指令;我认为让它以 .NET 6 为目标更简单。)

using System.Web.Http;

var request = new HttpRequestMessage();
request.SetConfiguration(new HttpConfiguration());
string json = "{ \"test\": \"true\" }";
Console.WriteLine($"Before: {json}");
var response = request.CreateResponse(json);
string text = await response.Content.ReadAsStringAsync();
Console.WriteLine($"After: {text}");

输出:

Before: { "test": "true" }
After: "{ \"test\": \"true\" }"

我认为让您感到困惑的是,在调试器中,您正在查看 response.Content,将 ObjectContent<string> 中的 Value 视为字符串 如您所愿,并假设这是要写入响应的数据。它不是。这是格式化之前的数据。

解决此问题的最简单方法是将响应内容提供为 StringContent。至此你不需要 any 依赖 - 下面的代码是一个最小的例子,它为“之前”和“之后”打印相同的文本:

var request = new HttpRequestMessage();
string json = "{ \"test\": \"true\" }";
Console.WriteLine($"Before: {json}");
var response = new HttpResponseMessage { Content = new StringContent(json) };
string text = await response.Content.ReadAsStringAsync();
Console.WriteLine($"After: {text}");

当然你可能想在响应中设置一些其他的 headers,但我相信这证明它确实是 CreateResponse 方法(及其对 ObjectContent 的使用) 导致了问题。