面向路由的ASP.NET MVC 5 本地化






4.79/5 (13投票s)
在ASP.NET MVC应用程序中创建用户友好且可配置的国际化机制
目录
引言
本地化是为多语言使用而创建的所有服务的常见任务。
有很多众所周知的方法可以本地化 .NET 应用程序,包括 ASP.NET MVC Web 应用程序。
但在本文中,我将展示如何创建一个具有以下关键功能的本地化系统:
- 支持 locale 的路由系统;
- 本地化视图的正确缓存工作;
- 从界面更改当前站点 locale;
- 如果路由中未定义 locale - 则自动从用户请求上下文中获取 locale。
背景
ASP.NET MVC 本地化的基础是使用资源文件。
关于资源文件是什么以及如何定义这些文件的内容,可以在这篇 Codeproject 文章 中找到很好的描述。
用于在主要源中传递用户本地化偏好的机制可以在 W3C 网站部分 中找到。
使用代码
创建解决方案
首先,让我们在 Visual Studio (对我来说是 2015 Community Edition) 中从头开始创建一个 ASP.NET MVC Web 5 应用程序。
因此,在解决方案初始化后,我们将看到如下所示的结构。
让我们尝试在 Google Chrome 中运行项目。我们将看到标准的 ASP.NET MVC 初始屏幕。
添加本地化资源并获得第一个本地化结果
我们将使用一种方法通过 .NET 资源文件来本地化 Web 应用程序。
有关创建 .NET 资源文件的详细信息,请参阅这篇 MSDN 文章。
首先,添加资源文件。
然后,添加一个样本字符串,该字符串将被传递到视图层。
并在 Strings.ru.resx 文件中添加具有相同键的字符串。
然后,让我们在 Home 控制器中创建一个方法来测试本地化行为。
清理 HomeController.cs 文件中所有现有方法。
然后,添加一个 ViewModel 类供我们进行实验。
using System;
namespace RoutedLocalizationExample.ViewModels
{
public class FullViewModel
{
public FullViewModel()
{
CreationDateTime = DateTime.Now;
}
/// <summary>
/// This will contain localised string value
/// </summary>
public string LocalisedString { get; set; }
/// <summary>
/// For see difference of cretion time
/// </summary>
public DateTime CreationDateTime { get; set; }
}
}
以及我们简单的控制器方法。
// Localize string without any external impact
public ActionResult Index()
{
// Get string from strongly typed localzation resources
var vm = new FullViewModel { LocalisedString = Strings.SomeLocalisedString };
return View(vm);
}
然后,我们的 Index.cshtml 视图应该如下所示。
@model RoutedLocalizationExample.ViewModels.FullViewModel
@{
ViewBag.Title = "Home Page";
}
<div class="jumbotron">
<p>@(Model.LocalisedString)</p>
<p>@Url.Action("LangFromRouteInActionFilter", "Home")</p>
<p>@Model.CreationDateTime.ToString()</p>
</div>
这里有什么?
- Model.LocalisedString 用于显示本地化内容;
- Url.Action("LangFromRouteInActionFilter", "Home") 用于显示 URL 的构建方式。我们将需要它来展示路由机制的一些功能;
- Model.CreationDateTime.ToString() - 我们需要知道页面渲染的时间。
最后,让我们尝试在 Web 浏览器 (对我来说是 Google Chrome) 中查看结果。
因此,这里有一个本地化字符串。目标 locale 是如何定义的?魔法在后台发生。首选 locale 从 HTTP 头 "Accept-Language" 中捕获,并应用于视图渲染结果。
因此,在这种情况下,我们无法明确控制使用的 locale,除了浏览器设置中的语言列表。但是,就像用户一样,我希望有一个更轻松的方式来定义语言。
从请求查询字符串获取语言
我们可以使用一些 .NET 功能来定义使用的本地化 "字典"(相应的资源文件)。
- System.Threading.Thread.CurrentThread.CurrentCulture - 定义当前线程使用的文化;
- System.Threading.Thread.CurrentThread.CurrentUICulture - 定义当前线程使用的 UI 文化。
因此,让我们尝试通过上述功能来控制本地化结果。向 HomeController 添加新方法。
// Get language from query string (by binder)
public ActionResult LangFromQueryString(string lang)
{
Thread.CurrentThread.CurrentCulture = CultureInfo.GetCultureInfo(lang);
Thread.CurrentThread.CurrentUICulture = CultureInfo.GetCultureInfo(lang);
var vm = new FullViewModel { LocalisedString = Strings.SomeLocalisedString };
return View("Index", vm);
}
然后,尝试使用它。请注意请求 URL 的查询字符串。
太棒了!现在我们可以通过查询参数控制 locale 了。
但是... 这对用户友好吗?带有它的问号和参数定义... 我认为对于普通用户来说 - **它看起来不够友好。**
定义本地化路由规则
ASP.NET MVC 拥有出色的内置功能,用于构建信息丰富且外观漂亮的 URL。这就是路由机制。因此,我们绝对需要将它用于本地化目的。
让我们在 App_Start/RouteConfig.cs 文件中添加新的路由规则 (到 RegisterRoutes 方法)。
using System.Web.Mvc;
using System.Web.Routing;
namespace RoutedLocalizationExample
{
public class RouteConfig
{
public static void RegisterRoutes(RouteCollection routes)
{
routes.IgnoreRoute("{resource}.axd/{*pathInfo}");
// Localization route - it will be used as a route of the first priority
routes.MapRoute(
name: "DefaultLocalized",
url: "{lang}/{controller}/{action}/{id}",
defaults: new
{
controller = "Home",
action = "Index",
id = UrlParameter.Optional,
lang = "en"
});
routes.MapRoute(
name: "Default",
url: "{controller}/{action}/{id}",
defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional }
);
}
}
}
然后,添加一个 Home 控制器方法来捕获 lang 路由参数。
// Get language as a parameter from route data
public ActionResult LangFromRouteValues(string lang)
{
Thread.CurrentThread.CurrentCulture = CultureInfo.GetCultureInfo(lang);
Thread.CurrentThread.CurrentUICulture = CultureInfo.GetCultureInfo(lang);
var vm = new FullViewModel { LocalisedString = Strings.SomeLocalisedString };
return View("Index", vm);
}
它与之前声明的 "LangFromQueryString" 方法没有区别。为什么会这样?
这是因为绑定器的工作结果。这是开箱即用的魔法。我们不必关心如何捕获参数值 - 是应该从查询字符串捕获还是从 RouteTable 值中捕获?
最后,让我们看看新的控制器方法。
我认为这比 "Controller/Method?lang=ru" 好多了。但是...
如果用户尝试使用我们项目中没有相应资源文件的 locale 会发生什么?
我们将看到可预测的 **CultureNotFoundException** 异常。
我们将在本文稍后解决这个问题。
操作过滤器中的本地化逻辑
使用本地化逻辑当然将是大多数控制器方法的一项通用任务。
因此,在执行本地化任务时,我们绝对需要遵循 DRY 原则。而且,再次,平台有一个可以帮助我们的功能。它是 **ActionFilterAttribute** 类。您可以在 这篇文章 中找到有关操作过滤器的更多信息。
让我们创建一个名为 "ActionFilters" 的目录,并编写我们的 **InternationalizationAttribute**。
using System.Web.Mvc;
using System.Collections.Generic;
using System.Globalization;
using System.Threading;
namespace RoutedLocalizationExample.ActionFilters
{
/// <summary>
/// Set language that is defined in route parameter "lang"
/// </summary>
public class InternationalizationAttribute : ActionFilterAttribute
{
private readonly IList<string> _supportedLocales;
private readonly string _defaultLang;
public InternationalizationAttribute()
{
// Get supported locales list
_supportedLocales = Utils.LocalizationHelper.GetSupportedLocales();
// Set default locale
_defaultLang = _supportedLocales[0];
}
/// <summary>
/// Apply locale to current thread
/// </summary>
/// <param name="lang">locale name</param>
private void SetLang(string lang)
{
Thread.CurrentThread.CurrentCulture = CultureInfo.GetCultureInfo(lang);
Thread.CurrentThread.CurrentUICulture = CultureInfo.GetCultureInfo(lang);
}
public override void OnActionExecuting(ActionExecutingContext filterContext)
{
// Get locale from route values
string lang = (string)filterContext.RouteData.Values["lang"] ?? _defaultLang;
// If we haven't found appropriate culture - seet default locale then
if (!_supportedLocales.Contains(lang))
lang = _defaultLang;
SetLang(lang);
}
}
}
然后,我们需要添加一个用创建的操作过滤器装饰的控制器方法。
// Get language in action filter (from route parameter)
[Internationalization]
public ActionResult LangFromRouteInActionFilter()
{
var vm = new FullViewModel { LocalisedString = Strings.SomeLocalisedString };
return View("Index", vm);
}
所以,让我们尝试测试它。在 Chrome 中启动并查看结果。
结果符合我们的预期。
那么如何处理错误的 locale 情况呢?
结果当然比出现致命错误的屏幕要好。我们看到了在操作过滤器中使用默认 locale ("en") 的效果。
但是最好在路由中返回支持的语言。
处理 URL 中未定义 locale 的情况
假设我们有一个 ASP.NET MVC 站点,该站点最初不支持本地化。站点已部署到生产环境。它有很多访问者,并且 Internet 上有很多超链接指向该站点。该站点只有一个英文版本。
有一天,站点的所有者决定添加本地化功能。
因此,可能会出现用户请求站点的 URL 而没有任何 lang 参数值的情况。我们必须正确处理这些请求。对于生产系统来说,致命错误或 404 页面不应该是可预测的情况。
问题在于,我们无法在任何操作过滤器中处理上述情况,因为操作过滤器的调用是在明确的控制器上下文中进行的,当目标控制器根据请求的 URL 定义时。但在我们更改路由机制的情况下则不是这样。
所以 - 最初情况似乎是一个僵局。
但是... 实际上,我们可以在 ASP.NET MVC 核心决定是否存在适当的控制器之前控制请求处理逻辑。我们可以在 HTTPModules 中做到这一点。
让我们尝试通过实现 System.Web.IHttpModule 接口来创建一个这样的模块。
using System;
using System.Reflection;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using System.Collections.Generic;
using RoutedLocalizationExample.Utils;
namespace RoutedLocalizationExample.HttpModules
{
/// <summary>
/// Module to append lang parameter to the requested url if it's absent or unsupported
/// </summary>
public class LangQueryAppenderModule : IHttpModule
{
/// <summary>
/// List of supported locales
/// </summary>
private readonly IList<string> _supportedLocales;
/// <summary>
/// We need to have controllers list to correctly handle situations
/// when target method name is missed
/// </summary>
private readonly IList<string> _controllersNamesList;
public LangQueryAppenderModule()
{
// Get list of supported locales
_supportedLocales = Utils.LocalizationHelper.GetSupportedLocales();
// Get controllers list of current project by reflection
var asmPath = HttpContext.Current.Server.MapPath("~/bin/RoutedLocalizationExample.dll");
Assembly asm = Assembly.LoadFile(asmPath);
var controllerTypes = asm.GetTypes()
.Where(type => typeof(Controller).IsAssignableFrom(type));
_controllersNamesList = new List<string>();
foreach (var controllerType in controllerTypes)
{
var fullName = controllerType.Name;
// We need only name part of Controller class that is used in route
_controllersNamesList.Add(fullName.Substring(0, fullName.Length - "Controller".Length));
}
}
// In the Init function, register for HttpApplication
// events by adding your handlers.
public void Init(HttpApplication application)
{
application.BeginRequest += (new EventHandler(this.Application_BeginRequest));
}
private void Application_BeginRequest(Object source, EventArgs e)
{
try
{
HttpApplication app = (HttpApplication)source;
HttpContext ctx = app.Context;
// We will redirect to url with defined locale only in case for HTTP GET verb
// cause we assume that all requests with other verbs will be called from site directly
// where all the urls created with URLHelper, so it complies with routing rules and will contain "lang" parameter
if (string.Equals(ctx.Request.HttpMethod, "GET", StringComparison.OrdinalIgnoreCase))
{
var localisedUri = LocalizationHelper.GetLocalisedUrl(ctx.Request.Url, _controllersNamesList, ctx.Request.UserLanguages);
if (!string.IsNullOrEmpty(localisedUri))
// Perform redirect action to changed url if it exists
ctx.Response.Redirect(localisedUri);
}
}
catch (Exception)
{
// Some logging logic could be here
}
}
public void Dispose() { }
}
}
获取已更改目标 URL 的逻辑位于静态类 LocalizationHelper 的 "GetLocalisedUrl" 方法中。
/// <summary>
/// Get request url corrected according to logic of routing with locale
/// </summary>
/// <param name="initialUri"></param>
/// <param name="controllersNames"></param>
/// <param name="userLangs"></param>
/// <returns></returns>
public static string GetLocalisedUrl(Uri initialUri, IList<string> controllersNames, IList<string> userLangs)
{
var res = string.Empty;
var supportedLocales = GetSupportedLocales();
var origUrl = initialUri;
// Dicide requested url to parts
var cleanedSegments = origUrl.Segments.Select(X => X.Replace("/", "")).ToList();
// Check is already supported locale defined in route
// cleanedSegments[0] is empty string, so lang parameter will be in [1] url segment
var isLocaleDefined = cleanedSegments.Count > 1 && supportedLocales.Contains(cleanedSegments[1]);
// does request need to be changed
var isRequestPathToHandle =
// Url has controller's name part
(cleanedSegments.Count > 1 && cleanedSegments.Intersect(controllersNames).Count() > 0) ||
// This condition is for default (initial) route
(cleanedSegments.Count == 1) ||
// initial route with lang parameter that is not supported -> need to change it
(cleanedSegments.Count == 2 && !supportedLocales.Contains(cleanedSegments[1]));
if (!isLocaleDefined && isRequestPathToHandle)
{
var langVal = "";
// Get user preffered language from Accept-Language header
if (userLangs != null && userLangs.Count > 0)
{
// For our locale name approach we'll take only first part of lang-locale definition
var splitted = userLangs[0].Split(new char[] { '-' });
langVal = splitted[0];
}
// If we don't support requested language - then redirect to requested page with default language
if (!supportedLocales.Contains(langVal))
langVal = supportedLocales[0];
var normalisedPathAndQuery = origUrl.PathAndQuery;
if ((cleanedSegments.Count > 2 &&
!controllersNames.Contains(cleanedSegments[1]) &&
controllersNames.Contains(cleanedSegments[2])) ||
(cleanedSegments.Count == 2) && (!controllersNames.Contains(cleanedSegments[1])))
{
// Second segment contains lang parameter, third segment contains controller name
cleanedSegments.RemoveAt(1);
// Remove wrong locale name from initial Uri
normalisedPathAndQuery = string.Join("/", cleanedSegments) + origUrl.Query;
}
// Finally, create new uri with language loocale
res = string.Format("{0}://{1}:{2}/{3}{4}", origUrl.Scheme, origUrl.Host, origUrl.Port, langVal.ToLower(), normalisedPathAndQuery);
}
return res;
}
代码中提供了注释。
这个方法看起来并不简单,所以我添加了包含单元测试的项目来覆盖所有可能的情况。当然不是全部 :),但至少 - 那些在实践中有用到的情况。
现在,我们需要通过编辑 web.config 文件来激活我们的模块,如下所示。
<system.webServer>
<modules>
<add name="LangQueryAppenderModule" type="RoutedLocalizationExample.HttpModules.LangQueryAppenderModule" />
</modules>
</system.webServer>
最后,让我们尝试我们的新本地化逻辑。
现在,对于初始路由 "ru",lang 参数的值由我们的模块添加。好的。
接下来,让我们尝试一个更有趣的案例。模拟从外部链接请求页面但没有 lang 参数的情况。创建新的浏览器标签,URL 为 "https://:64417/Home/LangFromRouteInActionFilter"。
我们将收到重定向到 "https://:64417/ru/Home/LangFromRouteInActionFilter"。
正是我们想要的!
如果您的首选浏览器语言是英语,那么您将被重定向到该页面的英文版本。
那么不支持的 locale 在初始 URL 中呢?
请求的 URL "https://:64417/qwerty/Home/LangFromRouteInActionFilter" 将再次重定向到 "https://:64417/ru/Home/LangFromRouteInActionFilter"。
最后。我们不希望 Web 浏览器客户端收到 404 错误。所以,让我们模拟请求未提供 "Accept-Language" 值的情况。我们将为此使用 Fiddler (有关该应用程序的更多信息可以在 这里 找到)。
请注意响应链 - 首先是 302 代码 (重定向)。然后是请求页面的英文版本。为什么是英文?因为在创建的请求中,我们没有提供 "Accept-Language" 头。因此,我们的 LangQueryAppenderModule 插入了项目默认 locale ("en") 的值。
从界面更改当前站点 locale
路由机制支持本地化很好。但我认为用户更喜欢在图形界面中更改它。因此,让我们在站点主菜单中添加一个语言选择器。
让我们向 Views/Shared 目录添加一个名为 HeaderPartial.cshtml 的文件。
@helper langSelector() {
string curLang = "en"; ;
if (this.ViewContext.RouteData.Values["lang"] != null)
{
curLang = this.ViewContext.RouteData.Values["lang"].ToString();
}
var enabledLangsList = RoutedLocalizationExample.Utils.LocalizationHelper.GetSupportedLocales();
var targetPath = string.Format("/{0}/{1}{2}",
ViewContext.RouteData.Values["controller"].ToString(),
ViewContext.RouteData.Values["action"].ToString(),
Request.Url.Query);
var hostRoot = string.Format("{0}://{1}", Request.Url.Scheme, Request.Url.Authority);
var targetUrlMask = string.Format("{0}/{{0}}/{1}", hostRoot, targetPath);
<li class="dropdown special" style="margin-left: 15px;">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">
@(curLang)
<span class="caret"></span>
</a>
<ul class="dropdown-menu lang-selector">
@foreach (var lang in enabledLangsList)
{
<li><a href="@(string.Format(targetUrlMask, lang))">@lang</a></li>
}
</ul>
</li>
}
<div class="navbar navbar-inverse navbar-fixed-top">
<div class="container">
<div class="navbar-header">
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
@Html.ActionLink("Application name", "Index", "Home", new { area = "" }, new { @class = "navbar-brand" })
</div>
<div class="navbar-collapse collapse">
<ul class="nav navbar-nav navbar-left">
<li>@Html.ActionLink("Home", "Index", "Home")</li>
<li>@Html.ActionLink("About", "About", "Home")</li>
<li>@Html.ActionLink("Contact", "Contact", "Home")</li>
</ul>
<ul class="nav navbar-nav navbar-right" style="margin-right: -5px;">
@langSelector()
</ul>
</div>
</div>
</div>
我们添加了一个辅助方法,它将包含所有语言选择器的逻辑和视觉元素。
最后,尝试从站点菜单更改语言。
本地化与缓存 - 协同工作
在处理 ASP.NET MVC 项目本地化时,需要注意的另一件事是,在使用缓存机制(包括 **OutputCacheAttribute**)时必须非常小心。原因是您的本地化逻辑应该符合缓存的限制和逻辑。
最常见的负面场景是客户端收到与请求 locale 不匹配的缓存页面。本文前面描述的本地化逻辑解决了这个问题。
但是您可以尝试创建条件来重现它。在开箱即用的 ASP.NET MVC 缓存配置中,重现不良结果的关键条件是不同 locale 的页面将通过相同的 URL 请求。然后,站点将返回最先调用的 locale 的视图。
当我们添加 "lang" 路由参数时 - 我们就绕过了这个问题。
例如,您可以在 这里 看到上述本地化方法的实际工作示例。
关注点
本文中描述的本地化方案最有趣的部分之一是想象所有可能的实际场景的所有 URL 段的组合。
例如,正确处理未提供 "Accept-Language" 头请求。我只在分析应用程序和 IIS 日志时才发现这些请求。因为它们收到了 404 响应。:)
这种情况再次证明了在项目中单元测试的重要性以及它的好处...
历史
2016-04-27 - 初始状态。