之前介紹過 ASP.NET Core 多國語言  的設定方式,由於 ASP.NET Core 提供的多語系是使用弱型別,比起過去 ASP.NET 使用強型別來說,非常的不便利。 本篇將介紹用 Visual Studio 的 T4 Template 製作強型別的多國語言 Class。
ASP.NET Core 提供的多語系有幾項缺點:
ASP.NET Core 多語系是使用弱型別,弱型別無法在開發階段判斷 Resource Key 有沒有打錯字 由於是弱型別,所以在開發時,若不查看 *.resx 就不知道有那些 Resource 可以使用 *.resx 檔案必須對應 Controller、View 等等的路徑位置,如果同樣的 Resource 在不同 Controller,就要維護好幾份 *.resx弱型別
1 var  hello = _localizer["Hello" ];
強型別
1 var  hello = _localizer.Text.Hello;
1. 建立多國語言檔 在網站目錄中建立 Resources 的資料夾,在裡面新增資源檔 *.resx。如下:   
語系檔名稱就可以依照類型自訂,跟 ASP.NET MVC 的命名方式相同如:
Text.en-GB.resx Message.zh-TW.resx *.resx 檔案必須 要帶語系在後綴。如:*.en-GB.resx。
2. T4 Template 在 Resources 資料夾建立副檔名為 *.tt 的檔案,如:Localizer.tt。
新增項目可能找不到 *.tt 的檔案,可以隨便建一個 *.txt 的檔案再把副檔名改為 *.tt。
T4 Template 簡單的說,就是透過程式碼產生程式碼。 我建立了一個 Localizer.tt 讀取 *.resx 的內容,然後產生出 Localizer.cs 的程式碼。
2.1 Localizer.tt 2017-09-11  更正讀取語系檔方式 在開發模式下可以正常讀取 *.resx 的內容,但發佈到正式環境之後只會產生 *.resources.dll。 因此,更正了 T4 Template,把讀 *.resx 內容改為載入 *.resources.dll,並使用 ResourceManager 取得語系,讓開發環境跟正式環境都可以正常的讀取內容。
感謝網友陳奕翰回報 BUG
Resources\Localizer.tt
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 <#@ template  language="C#" hostspecific="true" #>  <#@ output extension=".cs" #>  <#@ assembly name="EnvDTE" #>  <#@ assembly name="System.Core.dll" #>  <#@ assembly name="System.Xml.dll" #>  <#@ assembly name="System.Xml.Linq.dll" #>  <#@ import namespace="EnvDTE" #>  <#@ import namespace="System.Collections.Generic" #>  <#@ import namespace="System.IO" #>  <#@ import namespace="System.Linq" #>  <#@ import namespace="System.Text.RegularExpressions" #>  <#@ import namespace="System.Xml.Linq" #>  <#    var  defaultCulture = "en-gb" ;   var  resources = GetResourcesByCulture(defaultCulture, this .Host.ResolvePath("" )); #> namespace  Resources {   using  System;     using  System.Collections.Generic;     using  System.IO;     using  System.Linq;     using  System.Reflection;     using  System.Resources;     using  System.Runtime.Loader;     using  System.Text.RegularExpressions;     public  interface  ILocalizer      {     string  Culture { get ; set ; } <# foreach (var category in resources) { #>      <#= category.Key #> <#= category.Key #> { get; }  <# } #>          string  GetString (Type category, string  resourceKey ) ;         string  GetString (string  category, string  resourceKey ) ;         string  GetString (Type category, string  resourceKey, string  culture ) ;         string  GetString (string  category, string  resourceKey, string  culture ) ;     }     public  class  Localizer  : ILocalizer      {         private  const  string  DefaultCulture = "<#= defaultCulture #>" ;         private  static  readonly  Lazy<Dictionary<string , ResourceManager>> _resources = new  Lazy<Dictionary<string , ResourceManager>>(LoadResourceManager);     private  static  string  _assemblyPath;         private  string  _culture; <# foreach (var category in resources) { #>          private  <#= category.Key #> _<#= category.Key.ToLower() #>;  <# } #>      public  Localizer ()  {       _assemblyPath = Assembly.GetEntryAssembly().Location;     }     public  Localizer (string  assemblyPath )  {       _assemblyPath = assemblyPath;     }                      #region  ILocalizer      public  string  Culture         {             get              {                 if  (string .IsNullOrEmpty(_culture))                 {                     _culture = DefaultCulture;                 }                 return  _culture;             }             set              {                 var  culture = value ;                 if  (Regex.IsMatch(culture, @"^[A-Za-z]{2}-[A-Za-z]{2}$" ))                 {                     _culture = culture;                 }                 else                  {                     _culture = DefaultCulture;                 }             }         } <# foreach (var category in resources) { #>      public  <#= category.Key #> <#= category.Key #> { get { if  (_<#= category.Key.ToLower() #> == null) { _<#= category.Key.ToLower() #> = new <#= category.Key #>(this); } return _<#= category.Key.ToLower() #>; } }  <# } #>          public  string  GetString (Type category, string  resourceKey )         {             return  GetString(category.Name.ToString(), resourceKey);         }         public  string  GetString (string  category, string  resourceKey )         {             return  GetString(category, resourceKey, _culture);         }         public  string  GetString (Type category, string  resourceKey, string  culture )         {             return  GetString(category.Name.ToString(), resourceKey, culture);         }         public  string  GetString (string  category, string  resourceKey, string  culture )         {             var  resource = GetResource($"{category} .{culture} " ) ?? GetResource($"{category} .{DefaultCulture} " );             if  (resource == null )             {                 return  resourceKey;             }             else              {                 return  resource.GetString(resourceKey);             }         }         #endregion  ILocalizer          #region  Private Methods          private  static  Dictionary<string , ResourceManager> LoadResourceManager ()         {             var  directory = Path.GetDirectoryName(_assemblyPath);             var  files = Directory.GetFiles(directory, "*.resources.dll" , SearchOption.AllDirectories);                          var  resources = new  Dictionary<string , ResourceManager>(StringComparer.CurrentCultureIgnoreCase);             foreach  (var  file in  files)             {                 var  culture = Path.GetFileName(Path.GetDirectoryName(file));                 var  assembly = AssemblyLoadContext.Default.LoadFromAssemblyPath(file);                 foreach (var  resourceName in  assembly.GetManifestResourceNames().Select(s=> Regex.Replace(s, ".resources$" , "" )))                 {                     var  category = Regex.Match(resourceName, $".*Resources\\.(.*)\\.{culture} " ).Groups[1 ].Value;                     var  resourceManager = new  ResourceManager(resourceName, assembly);                     resources.Add($"{category} .{culture} " , resourceManager);                 }             }             return  resources;         }         private  ResourceManager GetResource (string  key )         {             if  (_resources.Value.Keys.Contains(key))             {                 return  _resources.Value[key];             }             return  null ;         }         #endregion       }     public  abstract  class  ResourceBase      {         protected  ResourceBase (ILocalizer localizer )         {             Localizer = localizer;         }         protected  ILocalizer Localizer { get ; private  set ; }         protected  string  GetString (string  resourceKey )         {             return  Localizer.GetString(GetType(), resourceKey);         }     } <# foreach (var category in resources) { #>      public  class  <#= category.Key  #> : ResourceBase      {         public  <#= category.Key #>(ILocalizer localizer) : base(localizer)          {         } <# foreach (var resource in category.Value) { #>      public  string  <#= resource.Key #> { get { return GetString("<#= resource.Key #>"); } }  <# } #>      } <# } #>  } <#+    Dictionary<string , Dictionary<string , string >> GetResourcesByCulture(string  culture, string  resourceFolder) {         var  files = Directory.GetFiles(resourceFolder, "*.resx" );     var  resources = files.GroupBy(file =>       {         var  fileName = Path.GetFileNameWithoutExtension(file).Split('.' );         return  fileName.First();       }).ToDictionary(g => g.Key, g =>       {         var  defaultFile = g.Single(s => s.IndexOf(culture, StringComparison.CurrentCultureIgnoreCase) != -1 );         var  xdoc = XDocument.Load(defaultFile);         var  dictionary = xdoc.Root.Elements("data" ).ToDictionary(e => e.Attribute("name" ).Value, e => e.Element("value" ).Value);         return  dictionary;       });     return  resources;   } #> 
2.2 Localizer.cs Localizer.cs 是透過 Localizer.tt 自動產生出來的檔案,只要 Localizer.tt 有異動,或者是點右鍵執行自訂工具 ,都會觸發自動產生 Localizer.cs。
Resources\Localizer.cs 程式碼內容會跟著 *.resx 而變動,大致如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 namespace  Resources {     using  System;     using  System.Collections.Generic;     using  System.IO;     using  System.Linq;     using  System.Reflection;     using  System.Resources;     using  System.Runtime.Loader;     using  System.Text.RegularExpressions;     public  interface  ILocalizer      {     string  Culture { get ; set ; }     Message Message { get ; }     Text Text { get ; }         string  GetString (Type category, string  resourceKey ) ;         string  GetString (string  category, string  resourceKey ) ;         string  GetString (Type category, string  resourceKey, string  culture ) ;         string  GetString (string  category, string  resourceKey, string  culture ) ;     }     public  class  Localizer  : ILocalizer      {         private  const  string  DefaultCulture = "en-gb" ;         private  static  readonly  Lazy<Dictionary<string , ResourceManager>> _resources = new  Lazy<Dictionary<string , ResourceManager>>(LoadResourceManager);     private  static  string  _assemblyPath;         private  string  _culture;         private  Message _message;         private  Text _text;     public  Localizer ()  {       _assemblyPath = Assembly.GetEntryAssembly().Location;     }     public  Localizer (string  assemblyPath )  {       _assemblyPath = assemblyPath;     }                      #region  ILocalizer      public  string  Culture         {             get              {                 if  (string .IsNullOrEmpty(_culture))                 {                     _culture = DefaultCulture;                 }                 return  _culture;             }             set              {                 var  culture = value ;                 if  (Regex.IsMatch(culture, @"^[A-Za-z]{2}-[A-Za-z]{2}$" ))                 {                     _culture = culture;                 }                 else                  {                     _culture = DefaultCulture;                 }             }         }     public  Message Message { get  { if  (_message == null ) { _message = new  Message(this ); } return  _message; } }     public  Text Text { get  { if  (_text == null ) { _text = new  Text(this ); } return  _text; } }         public  string  GetString (Type category, string  resourceKey )         {             return  GetString(category.Name.ToString(), resourceKey);         }         public  string  GetString (string  category, string  resourceKey )         {             return  GetString(category, resourceKey, _culture);         }         public  string  GetString (Type category, string  resourceKey, string  culture )         {             return  GetString(category.Name.ToString(), resourceKey, culture);         }         public  string  GetString (string  category, string  resourceKey, string  culture )         {             var  resource = GetResource($"{category} .{culture} " ) ?? GetResource($"{category} .{DefaultCulture} " );             if  (resource == null )             {                 return  resourceKey;             }             else              {                 return  resource.GetString(resourceKey);             }         }         #endregion  ILocalizer          #region  Private Methods          private  static  Dictionary<string , ResourceManager> LoadResourceManager ()         {             var  directory = Path.GetDirectoryName(_assemblyPath);             var  files = Directory.GetFiles(directory, "*.resources.dll" , SearchOption.AllDirectories);                          var  resources = new  Dictionary<string , ResourceManager>(StringComparer.CurrentCultureIgnoreCase);             foreach  (var  file in  files)             {                 var  culture = Path.GetFileName(Path.GetDirectoryName(file));                 var  assembly = AssemblyLoadContext.Default.LoadFromAssemblyPath(file);                 foreach (var  resourceName in  assembly.GetManifestResourceNames().Select(s=> Regex.Replace(s, ".resources$" , "" )))                 {                     var  category = Regex.Match(resourceName, $".*Resources\\.(.*)\\.{culture} " ).Groups[1 ].Value;                     var  resourceManager = new  ResourceManager(resourceName, assembly);                     resources.Add($"{category} .{culture} " , resourceManager);                 }             }             return  resources;         }         private  ResourceManager GetResource (string  key )         {             if  (_resources.Value.Keys.Contains(key))             {                 return  _resources.Value[key];             }             return  null ;         }         #endregion       }     public  abstract  class  ResourceBase      {         protected  ResourceBase (ILocalizer localizer )         {             Localizer = localizer;         }         protected  ILocalizer Localizer { get ; private  set ; }         protected  string  GetString (string  resourceKey )         {             return  Localizer.GetString(GetType(), resourceKey);         }     }     public  class  Message  : ResourceBase      {         public  Message (ILocalizer localizer ) : base (localizer )         {         }     public  string  Hello { get  { return  GetString("Hello" ); } }     }     public  class  Text  : ResourceBase      {         public  Text (ILocalizer localizer ) : base (localizer )         {         }     public  string  Hello { get  { return  GetString("Hello" ); } }     } } 
3. Startup 在 Startup 註冊自製的 Localizer 服務,以及修改多國語的 Routing 方式。如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 using  Microsoft.AspNetCore.Builder;using  Microsoft.Extensions.DependencyInjection;using  Resources;namespace  MyWebsite {     public  class  Startup      {         public  void  ConfigureServices (IServiceCollection services )         {             services.AddMvc();             services.AddScoped<ILocalizer, Localizer>();         }         public  void  Configure (IApplicationBuilder app )         {             app.UseMvc(routes =>             {                 routes.MapRoute(                     name: "default" ,                     template: "{culture=en-GB}/{controller=Home}/{action=Index}/{id?}"                  );             });         }     } } 
4. Filter 建立一個 CultureFilter 用來捕捉 Request 進來時的語系資訊。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 using  Microsoft.AspNetCore.Mvc.Filters;using  Resources;using  System.Globalization;using  System.Text.RegularExpressions;namespace  MyWebsite.Filters {     public  class  CultureFilter  : IResourceFilter      {         private  readonly  ILocalizer _localizer;         public  CultureFilter (ILocalizer localizer )         {             _localizer = localizer;         }         public  void  OnResourceExecuting (ResourceExecutingContext context )         {             var  culture = context.HttpContext.Request.Path.Value.Split('/' )[1 ];             var  hasCultureFromUrl = Regex.IsMatch(culture, @"^[A-Za-z]{2}-[A-Za-z]{2}$" );             _localizer.Culture = hasCultureFromUrl ? culture : CultureInfo.CurrentCulture.Name;         }         public  void  OnResourceExecuted (ResourceExecutedContext context )         {         }     } } 
把 CultureFilter 註冊在需要用到的 Controller 或 Action。如下:
1 2 3 4 5 [TypeFilter(typeof(CultureFilter)) ] public  class  HomeController  : Controller {      } 
通常 ASP.NET 網站會伴隨著 API,API 不需要語系資訊,所以不建議註冊在全域。
5. 使用多國語言 5.1. Controller 在 Controller 要使用多國語言的話,需要在建構子加入 ILocalizer 參數,執行期間會把 Localizer 的實體注入近來。 把 Resource Key 丟入 Localizer,就可以得到值。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 [TypeFilter(typeof(CultureFilter)) ] public  class  HomeController  : Controller {     private  readonly  ILocalizer _localizer;     public  HomeController (ILocalizer localizer )     {         _localizer = localizer;     }     public  IActionResult Content ()     {         return  Content($"CurrentCulture: {CultureInfo.CurrentCulture.Name} \r\n"                       + $"CurrentUICulture: {CultureInfo.CurrentUICulture.Name} \r\n"                       + $"{_localizer.Text.Hello} " );     } } 
5.2. View 在 cshtml 注入 ILocalizer,把 Resource Key 丟入 Localizer,就可以得到值。
1 2 3 4 5 6 7 8 @using System.Globalization @using Resources @inject ILocalizer localizer CurrentCulture: @CultureInfo.CurrentCulture.Name <br  />  CurrentUICulture: @CultureInfo.CurrentUICulture.Name <br  />  @localizer.Text.Hello<br  />  
程式碼下載 asp-net-core-localization-t4 
參考 Code Generation and T4 Text Templates