现象
在 ASP.NET Core MVC 中,当在页面中传递了一个包含中文字符串到页面的时候,页面的显示是正常的,但是如果查看页面源码,却看不到中文,变成了一串编码之后的内容。
例如,在页面中直接定义一个含有中文内容的字符串,然后在页面中显示出来。
@{
ViewData["Title"] = "Home Page";
string world = "世界";
}
<div class="text-center">
<h1 class="display-4">Welcome</h1>
<p>你好,@world。</p>
</div>
运行之后,可以看到页面是正常的
但是在查看页面源码的时候,中文不见了。
<p>你好,世界。</p>
原因就是字符串的内容是通过代码进行编码之后输出的。
分析
在 asp.net core 中,基于防范 xss 攻击的安全考虑,默认将所有非基本字符(U+0000..U+007F)的字符进行编码。因此基本除了英文字母那一部分,其他的全被编码了。
这个控制来源于 HtmlEncoder 中的 UnicodeRange 被设置成 UnicodeRanges.BasicLatin。
编码使用了 HtmlEncoder 这个类。它位于项目 System.Text.Encodings.Web at GitHub 中,默认它使用了 DefaultHtmlEncoder 进行编码,但是它基于拉丁字符进行编码。下面是源代码片段:
internal sealed class DefaultHtmlEncoder : HtmlEncoder
{
private readonly AllowedCharactersBitmap _allowedCharacters;
internal static readonly DefaultHtmlEncoder Singleton = new DefaultHtmlEncoder(new TextEncoderSettings(UnicodeRanges.BasicLatin));
以及:
public abstract class HtmlEncoder : TextEncoder
{
/// <summary>
/// Returns a default built-in instance of <see cref="HtmlEncoder"/>.
/// </summary>
public static HtmlEncoder Default
{
get { return DefaultHtmlEncoder.Singleton; }
}
点击这里 查看完全的源代码。
解决方案
配置将 UnicodeRange 范围放宽。我们可以通过将编码器替换为支持中文的 Unicode 编码器来解决它。
在 ASP.NET Core 中,各种服务是通过依赖注入来提供服务的。所以,我们可以通过调整容器中注册的服务来解决这个问题。
源代码中其实注入了一个 HtmlEncoder 来处理编码问题。
public DefaultHtmlGenerator(
IAntiforgery antiforgery,
IOptions<MvcViewOptions> optionsAccessor,
IModelMetadataProvider metadataProvider,
IUrlHelperFactory urlHelperFactory,
HtmlEncoder htmlEncoder,
ClientValidatorCache clientValidatorCache)
{
}
ASP.NET Core 本身已经注册了 HtmlEncoder 的服务,并且提供了使用 WebEncoderOptions 的方式进行配置。
代码如下所示:
public static IServiceCollection AddWebEncoders(this IServiceCollection services)
{
if (services == null)
{
throw new ArgumentNullException(nameof(services));
}
services.AddOptions();
// Register the default encoders
// We want to call the 'Default' property getters lazily since they perform static caching
services.TryAddSingleton(
CreateFactory(() => HtmlEncoder.Default, settings => HtmlEncoder.Create(settings)));
services.TryAddSingleton(
CreateFactory(() => JavaScriptEncoder.Default, settings => JavaScriptEncoder.Create(settings)));
services.TryAddSingleton(
CreateFactory(() => UrlEncoder.Default, settings => UrlEncoder.Create(settings)));
return services;
}
...
private static Func<IServiceProvider, TService> CreateFactory<TService>(
Func<TService> defaultFactory,
Func<TextEncoderSettings, TService> customSettingsFactory)
{
return serviceProvider =>
{
var settings = serviceProvider
?.GetService<IOptions<WebEncoderOptions>>()
?.Value
?.TextEncoderSettings;
return (settings != null) ? customSettingsFactory(settings) : defaultFactory();
};
}
这里使用了一个工厂来创建 HtmlEncoder 等 3 个对象。该工厂在创建过程中还会尝试寻找 WebEncoderOptions 配置对象,如果有的话,会使用它作为配置参数来创建这 3 个对象,否则使用默认方式。
另外还提供了一个可以注册 WebEncoderOptions 选项的扩展方法,提供使用 Options 模式的支持。通过它可以配置当前使用的编码范围。
public static IServiceCollection AddWebEncoders(this IServiceCollection services, Action<WebEncoderOptions> setupAction)
{
if (services == null)
{
throw new ArgumentNullException(nameof(services));
}
if (setupAction == null)
{
throw new ArgumentNullException(nameof(setupAction));
}
services.AddWebEncoders();
services.Configure(setupAction);
return services;
}
所以,有两个方案可以选择,一个是直接从 HtmelEncoder 入手,提供新的 HtmlEncoder 实现,更好的方式是通过 Options 模式,在创建的时候,提供适当的参数。
有多种方式可以考虑,这里列出 5 种方式。
第一种方式是直接再注册一个同类型的服务,由于对同一个类型注册多个服务的话,在注入单个服务实例的时候,会采用最后注册的服务,所以,我们可以再注册一个 HtmlEncoder 类型的服务,就可以解决这个问题。
代码如下:
services.AddSingleton(HtmlEncoder.Create(UnicodeRanges.All));
这种方式有个缺点,多创建一个对象实例。
第二种方式是直接替换掉原来的 HtmlEncoder 服务,可以使用 ServiceCollection 的扩展方法 Replace() 来实现,它接受一个 ServiceDescriptor 类型的参数进行替换。这样更彻底一点。
var descriptor =
new ServiceDescriptor(
typeof(HtmlEncoder),
HtmlEncoder.Create(UnicodeRanges.All));
services.Replace(descriptor);
这时候,只有一个扩展之后的 HtmlEncoder 在使用。此时没有多余的 HtmlEncoder 对象。
需要注意的是,Replace() 扩展方法位于命名空间 System.Text.Encodings.Web 下,而 UnicodeRanges 位于 System.Text.Unicode 下,记得添加两个命名空间的引用。
using System.Text.Encodings.Web;
using System.Text.Unicode;
第三种,既然提供了可以通过 Options 模式配置,还可以基于 Options 模式来处理。它使用 Configure 方法来进行,它通过 Action 来提供配置 WebEncoderOptions 对象。这样 3 种对象都可以直接使用,该方法定义如下:
public static IServiceCollection Configure<TOptions>(
this IServiceCollection services,
Action<TOptions> configureOptions) where TOptions : class;
实现如下:
services.Configure<Microsoft.Extensions.WebEncoders.WebEncoderOptions>(
options =>
options.TextEncoderSettings = new TextEncoderSettings(UnicodeRanges.All));
这样在创建 HtmlEncoder 等 3 个对象的时候,将使用该配置对象。
第四种方式,还是基于 Options 模式。是在 Configure() 方法之后进行配置。见:
services.PostConfigure<Microsoft.Extensions.WebEncoders.WebEncoderOptions>(
options =>
options.TextEncoderSettings = new TextEncoderSettings(UnicodeRanges.All));
效果与 #3 是相同的。但是 PostConfigure() 会保证在 Configure() 方法之后执行,比第 3 种更好。
最后但是最好的方式,根据源码可以看到,还可以使用第 5 种方式,系统已经提供了扩展方法 AddWebEncoders。其实与 #3 是一样的。
services.AddWebEncoders(options => options.TextEncoderSettings = new TextEncoderSettings(UnicodeRanges.All));
这种方式更好一些。更加语义化。