之前介紹過 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