在 WPF 開源代碼裏面,可以看到是從各個項目的 Strings.resx 和對應的 xlf 文件,生成對應項目的多語言程序集。這裏的多語言程序集可用於拋出異常時,給出本地化的消息提示
在 dotnet 龐大的生態集裏,打包工具鏈是開源中很重要的部分工作。通過 https://github.com/dotnet/arcade 將打包中重複的工作放在一個倉庫中,減少基礎設施能力在多個項目中重複進行。就像我所在的團隊開源的 DotNETBuildSDK 項目一樣,提供各種構建工具用在各個項目裏面
翻遍整個 WPF 倉庫,都無法直接找到任何的從 Strings.resx 和對應的 xlf 文件生成多語言衞星程序集的邏輯。這是因為多語言的核心轉換是放在 Microsoft.DotNet.Arcade.Sdk 裏面,在 WPF 倉庫裏面只有一些配置項
整個 WPF 開源倉庫的組織是相對清晰的,所有和構建相關的配置都放在 eng 文件夾裏面。其中對 Microsoft.DotNet.Arcade.Sdk 的引用分別放在 eng\WpfArcadeSdk\Sdk\Sdk.props 和 eng\WpfArcadeSdk\Sdk\Sdk.targets 文件裏。核心代碼只有以下這兩句
<!-- Importing Arcade's Sdk.props should always be the first thing we do. However this is not a hard rule,
it's just a convention for ensuring correctness and consistency in our build environment. If anything
does need to be imported before, it should be documented why it is needed. -->
<Import Project="Sdk.props" Sdk="Microsoft.DotNet.Arcade.Sdk" />
<Import Project="Sdk.targets" Sdk="Microsoft.DotNet.Arcade.Sdk" />
多語言配置部分的邏輯放在 eng\WpfArcadeSdk\tools\SystemResources.props 文件裏,其代碼較多,咱就先不展開細看
從 WPF 代碼倉庫裏面是沒有看到詳盡的多語言轉換過程邏輯的,但看了這幾個文件也夠咱自己學習模仿 WPF 用 Microsoft.DotNet.Arcade.Sdk 處理代碼裏的多語言的方式。接下來我將新建一個 WPF 空項目,在此和大家演示使用 Microsoft.DotNet.Arcade.Sdk 處理多語言,相信大家能夠學會用此構建工具生成多語言程序集
新建一個空白的 WPF 項目
雖然按照 .NET 的慣例,使用一個庫的第一件事就是用 NuGet 進行庫的安裝。但 Microsoft.DotNet.Arcade.Sdk 比較特殊,這是一個 SDK 而不是一個 Library 庫。直接使用 NuGet 安裝會報告以下錯誤
包“Microsoft.DotNet.Arcade.Sdk 11.0.0-beta.25556.1”具有一個包類型“MSBuildSdk”,項目“Xxxxx”不支持該類型。
正確的使用方法如下
第一步是添加 NuGet.config 文件,設置使用 dotnet-eng 源。因為 Microsoft.DotNet.Arcade.Sdk 庫是沒有放在公網 NuGet 源裏面的。修改之後的 NuGet.config 文件內容如下
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<!--End: Package sources managed by Dependency Flow automation. Do not edit the sources above.-->
<add key="dotnet-eng" value="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-eng/nuget/v3/index.json" />
</packageSources>
</configuration>
第二步是添加 global.json 文件,設置 Microsoft.DotNet.Arcade.Sdk 的版本。這一步就類似於使用 NuGet 進行安裝的過程,只不過用的是 SDK 的方式
{
"msbuild-sdks":
{
"Microsoft.DotNet.Arcade.Sdk": "10.0.0-beta.25411.109"
}
}
第三步就是在 csproj 項目文件裏面添加引用,代碼如下
<Import Project="Sdk.props" Sdk="Microsoft.DotNet.Arcade.Sdk" />
<Import Project="Sdk.targets" Sdk="Microsoft.DotNet.Arcade.Sdk" />
如此三步就可以完成 Microsoft.DotNet.Arcade.Sdk 庫安裝
完成安裝之後,就可以嘗試多語言的加入了。只需放入 resx 文件,無論命名和放在哪個文件夾內。為了簡單起見,我隨便從 WPF 倉庫拷貝了一個 Strings.resx 文件,編輯之後的內容如下
此時直接構建肯定是沒有效果的,因為還沒有設置 GenerateResxSource 屬性為 true 值,用於配置讓 Arcade 進行多語言生成
<PropertyGroup>
<GenerateResxSource>true</GenerateResxSource>
</PropertyGroup>
再設置 EmbeddedResource 屬性,配置好生成的類型的命名空間和類名,配置的代碼如下
<ItemDefinitionGroup>
<EmbeddedResource>
<GenerateSource>true</GenerateSource>
<ManifestResourceName>FxResources.$(AssemblyName).SR</ManifestResourceName>
<ClassName>MS.Utility.SR</ClassName>
</EmbeddedResource>
</ItemDefinitionGroup>
以上代碼裏面的 GenerateSource 設置為 true 表示當前項用來配置多語言的生成。以上代碼的 ManifestResourceName 只是一個用來標識資源存在的程序集,用來執行 typeof 獲取 ResourceManager 的資源,命名上比較隨意。以上的 ClassName 為重點部分,用來表示從 resx 文件應該生成的類型全名,採用命名空間加類型名的表示法。如 MS.Utility.SR 將生成命名空間為 MS.Utility 且類型名為 SR 的類型
通過 ClassName 的配置,即可讓各個程序集採用不同的命名空間配置。如在 WPF 倉庫的 eng\WpfArcadeSdk\tools\SystemResources.props 文件裏,就使用了以下類似的代碼為各個程序集配置不同的命名空間
<ItemDefinitionGroup>
<EmbeddedResource>
<GenerateSource>true</GenerateSource>
<ManifestResourceName>FxResources.$(AssemblyName).SR</ManifestResourceName>
<ClassName Condition="'$(AssemblyName)'=='PresentationBuildTasks'">MS.Utility.SR</ClassName>
<ClassName Condition="'$(AssemblyName)'=='UIAutomationTypes'">System.SR</ClassName>
<ClassName Condition="'$(AssemblyName)'=='WindowsBase'">MS.Internal.WindowsBase.SR</ClassName>
...
<ClassName Condition="'$(AssemblyName)'=='PresentationCore'">MS.Internal.PresentationCore.SR</ClassName>
<ClassName Condition="'$(AssemblyName)'=='System.Xaml'">System.SR</ClassName>
<Classname Condition="'%(ClassName)'==''">System.SR</Classname>
</EmbeddedResource>
</ItemDefinitionGroup>
以上邏輯就能夠完成多語言生成的配置
然而現在還不能通過構建,一構建將提示類似如下的錯誤
C:\Users\lindexi\.nuget\packages\microsoft.dotnet.arcade.sdk\10.0.0-beta.25411.109\tools\Version.BeforeCommonTargets.targets(88,5): error MSB4184: 無法計算表達式“"".GetValue(1)”。Index was outside the bounds of the array.
這是因為在 Version.BeforeCommonTargets.targets 文件裏面存在如下代碼
<PropertyGroup>
<VersionPrefix Condition="'$(MajorVersion)' != '' and '$(MinorVersion)' != ''">$(MajorVersion).$(MinorVersion).$([MSBuild]::ValueOrDefault('$(PatchVersion)', '0'))</VersionPrefix>
</PropertyGroup>
<PropertyGroup Condition="'$(PreReleaseVersionLabel)' == ''">
<_VersionPrefixMajor>$(VersionPrefix.Split('.')[0])</_VersionPrefixMajor>
<_VersionPrefixMinor>$(VersionPrefix.Split('.')[1])</_VersionPrefixMinor>
<VersionPrefix>$(_VersionPrefixMajor).$(_VersionPrefixMinor).$([MSBuild]::ValueOrDefault($(_PatchNumber), '0'))</VersionPrefix>
<VersionSuffix/>
</PropertyGroup>
儘管我認為這是 Microsoft.DotNet.Arcade.Sdk 庫的設計不夠開箱即用,但考慮到這是一個專用的庫,這一點也能接受。繼續編輯 csproj 項目文件,添加如下代碼,添加版本號信息
<PropertyGroup>
<MajorVersion>1</MajorVersion>
<MinorVersion>2</MinorVersion>
</PropertyGroup>
如此即可完成構建準備,嘗試構建一下。此時細心的夥伴也許就發現了,在 obj 文件夾下,生成了 obj\Debug\net9.0-windows\MS.Utility.SR.cs 文件,且在此文件裏面填滿了在 Strings.resx 資源字典定義的多語言項。其生成代碼大概如下
using System.Reflection;
namespace FxResources.QewheefanallJabayhejage
{
internal static class SR { }
}
namespace MS.Utility
{
internal static partial class SR
{
private static global::System.Resources.ResourceManager s_resourceManager;
internal static global::System.Resources.ResourceManager ResourceManager => s_resourceManager ?? (s_resourceManager = new global::System.Resources.ResourceManager(typeof(FxResources.QewheefanallJabayhejage.SR)));
internal static global::System.Globalization.CultureInfo Culture { get; set; }
#if !NET20
[global::System.Runtime.CompilerServices.MethodImpl(global::System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
#endif
internal static string GetResourceString(string resourceKey, string defaultValue = null) => ResourceManager.GetString(resourceKey, Culture);
/// <summary>Enumerating attached properties on object '{0}' threw an exception.</summary>
internal static string @APSException => GetResourceString("APSException");
/// <summary>Add value to collection of type '{0}' threw an exception.</summary>
internal static string @AddCollection => GetResourceString("AddCollection");
/// <summary>Add value to dictionary of type '{0}' threw an exception.</summary>
internal static string @AddDictionary => GetResourceString("AddDictionary");
}
}
細心的夥伴還能看到,此時在項目裏面被新建了 xlf 文件夾,在此文件夾內充滿了各個語言文化對應的 xlf 文件。這些 xlf 文件是為翻譯人員準備的,方便對接翻譯平台進行翻譯。每個 xlf 文件都會在 obj 文件夾生成對應的 resx 文件,再由 resx 文件生成對應的程序集
這裏的 xlf 文件是採用 https://en.wikipedia.org/wiki/XLIFF 多語言翻譯規範的文件,這是一個現有的規範的格式。其內容大概如下
<?xml version="1.0" encoding="utf-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="1.2" xsi:schemaLocation="urn:oasis:names:tc:xliff:document:1.2 xliff-core-1.2-transitional.xsd">
<file datatype="xml" source-language="en" target-language="zh-Hans" original="../Strings.resx">
<body>
<trans-unit id="APSException">
<source>Enumerating attached properties on object '{0}' threw an exception.</source>
<target state="translated">枚舉對象“{0}”的附加屬性時引發了異常。</target>
<note />
</trans-unit>
<trans-unit id="AddCollection">
<source>Add value to collection of type '{0}' threw an exception.</source>
<target state="translated">向類型為“{0}”的集合中添加值引發了異常。</target>
<note />
</trans-unit>
<trans-unit id="AddDictionary">
<source>Add value to dictionary of type '{0}' threw an exception.</source>
<target state="new">Add value to dictionary of type '{0}' threw an exception.</target>
<note />
</trans-unit>
</body>
</file>
</xliff>
可以看到 XLIFF 格式裏面可以為翻譯人員提供雙語對照,也能通過 state="translated" 還是 state="new" 標記出已經翻譯的還是新添加的多語言項
從這裏也能看到 Microsoft.DotNet.Arcade.Sdk 的好用之處,只需添加 resx 文件,就會自動生成各個語言文化對應的 xlf 文件,方便翻譯人員對接
以下是我的最簡使用 Microsoft.DotNet.Arcade.Sdk 對接多語言的 csproj 項目的代碼
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net9.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
</PropertyGroup>
<PropertyGroup>
<MajorVersion>1</MajorVersion>
<MinorVersion>2</MinorVersion>
</PropertyGroup>
<PropertyGroup>
<GenerateResxSource>true</GenerateResxSource>
<!-- <GenerateResxSourceOmitGetResourceString>true</GenerateResxSourceOmitGetResourceString> -->
</PropertyGroup>
<Import Project="Sdk.props" Sdk="Microsoft.DotNet.Arcade.Sdk" />
<Import Project="Sdk.targets" Sdk="Microsoft.DotNet.Arcade.Sdk" />
<ItemDefinitionGroup>
<EmbeddedResource>
<GenerateSource>true</GenerateSource>
<ManifestResourceName>FxResources.$(AssemblyName).SR</ManifestResourceName>
<ClassName>MS.Utility.SR</ClassName>
</EmbeddedResource>
</ItemDefinitionGroup>
</Project>
以上被註釋掉的 GenerateResxSourceOmitGetResourceString 屬性用來配置 Microsoft.DotNet.Arcade.Sdk 生成的類型裏面,不要生成 GetResourceString 等代碼。如此即可在自己程序集裏面自己定義多語言獲取的類型,提供更高的自由。在 WPF 倉庫裏面,就是自己定義的 GetResourceString 方法,用來處理多語言找不到的情況
也許有夥伴好奇在 Microsoft.DotNet.Arcade.Sdk 底層是如何對接多語言代碼的生成的。事實上這部分邏輯也十分簡單,從 https://github.com/dotnet/arcade 倉庫可以找到明確的代碼
先是在 GenerateResxSource.targets 文件裏面執行對接邏輯,核心代碼如下
<Target Name="_GenerateResxSource"
BeforeTargets="BeforeCompile;CoreCompile"
DependsOnTargets="PrepareResourceNames;
_GetEmbeddedResourcesWithSourceGeneration;
_BatchGenerateResxSource">
<ItemGroup>
<GeneratedResxSource Include="@(EmbeddedResourceSGResx->'%(SourceOutputPath)')" />
<FileWrites Include="@(GeneratedResxSource)" />
<Compile Include="@(GeneratedResxSource)" />
</ItemGroup>
</Target>
<Target Name="_BatchGenerateResxSource"
Inputs="@(EmbeddedResourceSGResx)"
Outputs="%(EmbeddedResourceSGResx.SourceOutputPath)">
<Microsoft.DotNet.Arcade.Sdk.GenerateResxSource
Language="$(Language)"
ResourceFile="%(EmbeddedResourceSGResx.FullPath)"
ResourceName="%(EmbeddedResourceSGResx.ManifestResourceName)"
ResourceClassName="%(EmbeddedResourceSGResx.ClassName)"
AsConstants="%(EmbeddedResourceSGResx.GenerateResourcesCodeAsConstants)"
OmitGetResourceString="$(GenerateResxSourceOmitGetResourceString)"
IncludeDefaultValues="$(GenerateResxSourceIncludeDefaultValues)"
EmitFormatMethods="$(GenerateResxSourceEmitFormatMethods)"
OutputPath="%(EmbeddedResourceSGResx.SourceOutputPath)" />
</Target>
可見就是從 _BatchGenerateResxSource 調用 Microsoft.DotNet.Arcade.Sdk.GenerateResxSource 執行生成邏輯。在 _GenerateResxSource 裏面將生成的文件加入構建
上面代碼的 EmbeddedResourceSGResx 內容僅是取出本文在 csproj 的 ItemDefinitionGroup 裏面定義的屬性內容,再配合添加一些過濾條件而已
核心的 GenerateResxSource 生成類的定義代碼如下
public sealed class GenerateResxSource : Microsoft.Build.Utilities.Task
{
private const int maxDocCommentLength = 256;
/// <summary>
/// Language of source file to generate. Supported languages: CSharp, VisualBasic
/// </summary>
[Required]
public string Language { get; set; }
/// <summary>
/// Resources (resx) file.
/// </summary>
[Required]
public string ResourceFile { get; set; }
/// <summary>
/// Name of the embedded resources to generate accessor class for.
/// </summary>
[Required]
public string ResourceName { get; set; }
/// <summary>
/// Optionally, a namespace.type name for the generated Resources accessor class. Defaults to ResourceName if unspecified.
/// </summary>
public string ResourceClassName { get; set; }
/// <summary>
/// If set to true the GetResourceString method is not included in the generated class and must be specified in a separate source file.
/// </summary>
public bool OmitGetResourceString { get; set; }
/// <summary>
/// If set to true, emits constant key strings instead of properties that retrieve values.
/// </summary>
public bool AsConstants { get; set; }
/// <summary>
/// If set to true calls to GetResourceString receive a default resource string value.
/// </summary>
public bool IncludeDefaultValues { get; set; }
/// <summary>
/// If set to true, the generated code will include .FormatXYZ(...) methods.
/// </summary>
public bool EmitFormatMethods { get; set; }
[Required]
public string OutputPath { get; set; }
private enum Lang
{
CSharp,
VisualBasic,
}
...
}
其生成邏輯是根據 C# 或 VB 進行拼接字符串方式生成的多語言代碼的
讀取 resw 字典也是直接使用 XDocument 的方式讀取,核心代碼如下
string classIndent = (namespaceName == null ? "" : " ");
string memberIndent = classIndent + " ";
var strings = new StringBuilder();
foreach (var node in XDocument.Load(ResourceFile).Descendants("data"))
{
string name = node.Attribute("name")?.Value;
string value = node.Elements("value").FirstOrDefault()?.Value.Trim();
strings.AppendLine($"{memberIndent}internal static string @{identifier} => GetResourceString(\"{name}\"{defaultValue});");
}
實際的代碼比我以上有刪減部分略微複雜,如果大家感興趣,還請自行去查看源代碼
本文代碼放在 github 和 gitee 上,可以使用如下命令行拉取代碼。我整個代碼倉庫比較龐大,使用以下命令行可以進行部分拉取,拉取速度比較快
先創建一個空文件夾,接着使用命令行 cd 命令進入此空文件夾,在命令行裏面輸入以下代碼,即可獲取到本文的代碼
git init
git remote add origin https://gitee.com/lindexi/lindexi_gd.git
git pull origin 69bd783e97b03e767017ebbbe61aad89b9a8104d
以上使用的是國內的 gitee 的源,如果 gitee 不能訪問,請替換為 github 的源。請在命令行繼續輸入以下代碼,將 gitee 源換成 github 源進行拉取代碼。如果依然拉取不到代碼,可以發郵件向我要代碼
git remote remove origin
git remote add origin https://github.com/lindexi/lindexi_gd.git
git pull origin 69bd783e97b03e767017ebbbe61aad89b9a8104d
獲取代碼之後,進入 WPFDemo/QewheefanallJabayhejage 文件夾,即可獲取到源代碼
更多技術博客,請參閲 博客導航