由於項目需要最近在學習ASP.NET MVC。在實踐中,網站要支援多語言,需要全球化。在MVC下我實現了一個全球化架構,在這裡與各位分享一下,不足之處也請各位看官指教。
讓URL支援全球化
經常上微軟網站的朋友可能很熟悉類似包含..\zh-cn\..、..\en-us\..的url形式,這就是本文要使用的全球化方案。當然還有使用QueryString傳遞參數的方案,基本思路我想是類似的。
由於MVC天生的URL路由原理,使得這個方案很容易被接受。
基本思路
這個方案的基本思路是:
1.當使用者訪問的url含有合法的culture參數時,能夠直接路由到對應的controller,在controller初始化時設定線程的Culture;
2.當使用者訪問的url不包含culture參數時,同樣被路由到對應的controller,但controller在執行action前,重新導向到包含Culture的url。這裡的Culture按照先檢測cookie,再檢測語言瀏覽器設定,最後使用預設值的優先順序順序實施。
先看下效果示範,注意url,點擊下載例子
Resource.resx
在接下去之前先回顧一下資源檔。在asp.net web應用程式(winform同樣)中定義的資源檔.resx實際上是一個xml設定檔,通常我們只關心其中的key\value配置;我們可以建立一個或多個.resx,這些.resx會對應產生一個cs檔案,這個cs檔案會定義一個類(可能是Resource類,取決於你的資源檔的命名),通過訪問這個類的靜態屬性即可訪問這些key,而選擇哪個.resx讀取的關鍵就是CultureInfo,只要我們設定當前線程的CultureInfo,Resource便會自動識別對應的.resx設定檔。而在.resx的命名上,需要按照這樣的規則:
Resource.zh-cn.resx(對應簡體中文資源檔)
Resource.en-us.resx(對應美國英語資源檔)
中間的Culture名字很重要。
通常在開發時,只要一個預設的Resource.resx,當開發完成之後,拷貝一個相同的Resource.resx,並改名字成上面的樣子,然後手動或自動將其中的所有value都翻譯成對應的語言。
解決路由問題
在這個方案中,首先要考慮的是url路由配置。首先,理想情況下,我們所有的url都是domain/culture/controller/action/param1/..這種形式,那麼只要一份以culture開頭的路由就可以了。但是事實上並非這麼簡單,如果使用者不知道這個規則,他手動輸入了domain/controller/action/param1..那麼這種url將不能被正確路由。這種情況在初次訪問網站的時候最為常見(通常我們都會鍵入www.microsoft.com而不會在後面加上任何的culture參數)。那麼難道我們要為了這種情境寫兩份路由嗎?顯然不是,或者說不用手動做這件事。這裡要解決的第一個問題出現了。我的方案是:只為domain/controller/action/param1..這種路由手動寫代碼配置,這也比較符合習慣;然後通過一個方法,遍曆route表中的所有路由,並在每個url規則前面加上一個參數ci表示culture,產生一份新的路由加到路由表中即可。這樣做儘管沒有減少路由規則,但是至少不用手動一個個寫了,要不然沒人會同意這個方案的。下面是代碼和解釋:
protected void Application_Start(){ AreaRegistration.RegisterAllAreas(); RegisterRoutes(RouteTable.Routes); RegisterGlobalizationRoutes(); ...}
private void RegisterGlobalizationRoutes(){ //RouteTable.Routes即路由表 if (RouteTable.Routes == null) return; //建立一個新的路由集合,存放將要添加到路由 RouteCollection rc = new RouteCollection(); //這裡需要跳過routes.IgnoreRoute("{resource}.axd/{*pathInfo}"); //由於IgnoreRouteInternal是個私人類,所以這裡只能反射 //skip IgnoreRouteInternal var routes = RouteTable.Routes.SkipWhile(p => (p.GetType().Name == "IgnoreRouteInternal")); int insertpoint = RouteTable.Routes.Count() - routes.Count(); //遍曆所有需要處理的路由 foreach (var r in routes) { Route item = (r as Route); //下面的代碼建立一個新的路由對象,在url規則前面加上ci參數,並拷貝其他設定 Route newitem = new Route( //string.Format(@"{ci}/{0}",item.Url), @"{ci}/" + item.Url, new MvcRouteHandler()); newitem.Defaults = new RouteValueDictionary(item.Defaults); newitem.Constraints = new RouteValueDictionary(item.Constraints); //ci參數需要驗證,因為只有合法的culture才能被接受 newitem.Constraints.Add("ci", new CulturePrefixRule()); newitem.DataTokens = new RouteValueDictionary(); newitem.DataTokens["Namespaces"] = item.DataTokens["Namespaces"]; rc.Add(newitem); } //帶ci參數的路由應當靠前放,所以這裡插入到前面 foreach (var c in rc) { RouteTable.Routes.Insert(insertpoint++, c); }}
//實現IRouteConstraint的一個類private class CulturePrefixRule : IRouteConstraint{ IEnumerable<string> cultureConllection = CultureInfo.GetCultures(CultureTypes.SpecificCultures).Select(p => p.Name.ToLower()); public bool Match(HttpContextBase httpContext, Route route, string parameterName, RouteValueDictionary values, RouteDirection routeDirection) { if (values[parameterName] != null) return cultureConllection.Contains(values[parameterName].ToString().ToLower()); else return false; }}
這裡要注意幾點:
1.routes.IgnoreRoute("{resource}.axd/{*pathInfo}");會在路由表中添加一條IgnoreRouteInternal類型的路由,只不過這條是需要被跳過的而已。三個類的關係是:
RouteBase->Route->IgnoreRouteInternal
而不巧的是IgnoreRouteInternal是個私人類,因此,只能藉助反射了。
2.為路由設定Constraints屬性時,實際上是為其指定一個IRouteConstraint。MVC內部有一個實現了IRouteConstraint的接受Regex的類,我們在MapRoute方法中用一個string初始化Constraints,實際上就是執行個體化了這個類。而這裡我們的需求顯然要複雜點:需要判斷ci參數是否是支援的,所以也就有了CulturePrefixRule實現IRouteConstraint。
3.帶有ci參數的路由更“特殊”,所以最好還是放在路由表前面。原因我就不再累述了。
在Controller的Action執行前跳轉
所有的Controller都應該具有一個相同的行為:能夠針對沒有ci參數的url實施跳轉。因此自然想到實現一個基類Controller,這裡我命名為BaseController,代碼如下:
public class BaseController : Controller{ protected string redirectUrl; protected override void Initialize(System.Web.Routing.RequestContext requestContext) { base.Initialize(requestContext); object cultureValue; //檢測ci參數 if (requestContext.RouteData.Values.TryGetValue("ci", out cultureValue)) { //設定當前線程的culture try { Thread.CurrentThread.CurrentUICulture = CultureProvider.GetCultureInfo(cultureValue.ToString()); Thread.CurrentThread.CurrentCulture = CultureProvider.GetCultureInfo(cultureValue.ToString());
Response.Cookies.Add(new HttpCookie(CultureProvider.culturecookiekey,cultureValue.ToString())); } catch { throw new Exception("Culture Error!"); } } else //如果沒有ci參數 { //check cookie HttpCookie cLang = requestContext.HttpContext.Request.Cookies[CultureProvider.culturecookiekey]; if (cLang != null) { cultureValue = cLang.Value; } else //check brower setting { string[] langs = requestContext.HttpContext.Request.UserLanguages; if (langs != null && langs.Length > 0) { cultureValue = langs[0].Split(';').First(); } } if (cultureValue == null) { cultureValue = CultureProvider.culturedefault; } //設定redirectUrl,如果不需要重新導向到化redirectUrl 為null redirectUrl = string.Format(@"/{0}{1}", cultureValue.ToString(), requestContext.HttpContext.Request.RawUrl); } } protected override IActionInvoker CreateActionInvoker() { return new CustomControllerActionInvoker(redirectUrl); }}//一個IActionInvoker 的實現,MVC預設使用ControllerActionInvoker,因為在//redirectUrl != null 的時候需要在action執行之前執行重新導向internal class CustomControllerActionInvoker : ControllerActionInvoker{ string redirectUrl; public CustomControllerActionInvoker(string url) : base() { redirectUrl = url; } protected override ActionResult InvokeActionMethod(ControllerContext controllerContext, ActionDescriptor actionDescriptor, IDictionary<string, object> parameters) { object returnValue; //ChildAction內部不能重新導向 if (!string.IsNullOrEmpty(redirectUrl) && !controllerContext.IsChildAction) returnValue = new RedirectResult(redirectUrl); else returnValue = actionDescriptor.Execute(controllerContext, parameters); ActionResult result = CreateActionResult(controllerContext, actionDescriptor, returnValue); return result; }}public static class CultureProvider{ public const string culturecookiekey = "Lang"; public const string culturedefault = "en-US"; public static CultureInfo GetCultureInfo(string ci) { try { return new CultureInfo(ci); } catch { return null; } }}
只要所有的Controller繼承這個BaseController即可。
這裡需要重點指出的是CustomControllerActionInvoker類,事實上發現從這個類入手解決重新導向問題花了我不少時間,為此我不得不調試MVC的源碼。當然最初的想法是在每個action執行時手動判斷redirectUrl,從而指導重新導向,但顯然,沒人願意將自己已經寫好的action都拿出來一個個改,所以也就有了這個小小的探索。
頁面中的連結、跳轉
最後令我感到即高興又擔心的問題是:當我使用這個架構後,頁面中的所有連結和跳轉因素幾乎都能自動在url前面加上ci參數!雖然我知道類似Html.ActionLink之類的helper有從路由表中產生url的能力,但是能夠自動添加上ci,還是讓我感到有點始料未及。不過,連結的url是否正確,還是要注意,有一些特殊情況。
頁面中使用資源
在頁面中引用資源可以直接在C#指令碼中引用Resource類。這裡提供一個helper。這個Html的擴充方法。
public static class ResourceExtensions { public static string Resource(this Controller controller, string expression, params object[] args) { ResourceExpressionFields fields = GetResourceFields(expression, "~/"); return GetGlobalResource(fields, args); } public static string Resource(this HtmlHelper htmlHelper, string expression, params object[] args) { string path = "~/"; ResourceExpressionFields fields = GetResourceFields(string.Format("Resource,{0}", expression), path); return GetGlobalResource(fields, args); } static string GetGlobalResource(ResourceExpressionFields fields, object[] args) { return string.Format((string)HttpContext.GetGlobalResourceObject(fields.ClassKey, fields.ResourceKey, CultureInfo.CurrentUICulture), args); } static ResourceExpressionFields GetResourceFields(string expression, string virtualPath) { var context = new ExpressionBuilderContext(virtualPath); var builder = new ResourceExpressionBuilder(); return (ResourceExpressionFields)builder.ParseExpression(expression, typeof(string), context); } }
需要注意的是這個方法預設認為Resource是資源的類名,所以必要的話需要修改
ResourceExpressionFields fields = GetResourceFields(string.Format("Resource,{0}", expression), path); 中的"Resource,{0}"
結語
初學MVC,甚至可以說是初學web開發。以上是我個人提出的一種方案,不知道有沒有什麼不足之處,還請各位看官提出見解,探討一下。
點擊下載例子
其他相關資源:
http://blog.miniasp.com/post/2010/01/ASPNET-MVC-Developer-Note-Part-15-Globalization-and-Localization.aspx