.NET 全球化和 ICU

在 .NET 5 前,.NET 全球化 API 在不同的平台上使用不同的基础库。 在 Unix 上,API 使用 Unicode 国际组件 (ICU),在 Windows 上,API 使用 区域语言支持 (NLS)。 这导致在不同平台上运行应用程序时,在少数全球化 API 中存在一些行为差异。 以下方面就存在明显的行为差异:

  • 区域性和区域性数据
  • 字符串大小写
  • 字符串排序和搜索
  • 排序关键字
  • 字符串规范化
  • 国际化域名 (IDN) 支持
  • Linux 上的时区显示名称

从 .NET 5 开始,开发人员可以更好地控制使用哪个基础库,从而使应用程序可以避免不同平台之间的差异。

Windows 上的 ICU

Windows 现在将预安装的 icu.dll 版本作为其自动用于全球化任务的功能的一部分。 此修改允许 .NET 利用此 ICU 库来实现全球化支持。 如果 ICU 库不可用或无法加载(正如使用早期 Windows 版本那样),则 .NET 5 及后续版本将恢复使用基于 NLS 的实现。

下表显示了哪些版本的 .NET 能够跨不同的 Windows 客户端和服务器版本加载 ICU 库:

.NET 版本 Windows 版本
.NET 5 或 .NET 6 Windows 客户端 10 版本 1903 或更高版本
.NET 5 或 .NET 6 Windows Server 2022 或更高版本
.NET 7 或更高版本 Windows 客户端 10 版本 1703 或更高版本
.NET 7 或更高版本 Windows Server 2019 或更高版本

注意

与 .NET 6 和 .NET 5 相比,.NET 7 及更高版本能够在早期 Windows 版本上加载 ICU。

注意

即使使用 ICU,CurrentCultureCurrentUICultureCurrentRegion 成员仍使用 Windows 操作系统 API 来遵从用户设置。

行为差异

如果将应用升级到目标 .NET 5 或更高版本,即使你不知道正在使用全球化设施,你也可能会在应用中看到更改。 本部分列出了你可能会看到的行为更改之一,但还有其他一些行为更改。

String.IndexOf

请考虑使用以下代码,它调用 String.IndexOf(String) 来查找字符串中的 null 字符 \0 的索引。

const string greeting = "Hel\0lo";
Console.WriteLine($"{greeting.IndexOf("\0")}");
Console.WriteLine($"{greeting.IndexOf("\0", StringComparison.CurrentCulture)}");
Console.WriteLine($"{greeting.IndexOf("\0", StringComparison.Ordinal)}");
  • 在 .NET Core 3.1 和 Windows 上的更早版本中,代码片段在三行的每一行中打印 3
  • 对于在 Windows 上的 ICU 节表中列出的 Windows 版本上运行的 .NET 5 和后续版本,代码片段将输出 003(对于序号搜索)。

默认情况下,String.IndexOf(String) 执行区域性感知的语言搜索。 ICU 将 null 字符 \0 视为零权重字符,因此在 .NET 5 和更高版本中使用语言搜索时,在字符串中找不到该字符。 但是,NLS 不会将 null 字符 \0 视为零权重字符,.NET Core 3.1 和更早版本上执行语言搜索会在位置 3 找到该字符。 顺序搜索在所有 .NET 版本的位置 3 处找到该字符。

可以运行代码分析规则 CA1307:指定 StringComparison 以阐明CA1309:使用顺序 StringComparison,在未指定字符串比较或不是顺序搜索的代码中找到调用点。

有关详细信息,请参阅在 .NET 5 及更高版本中比较字符串时的行为更改

String.EndsWith

const string foo = "abc";

Console.WriteLine(foo.EndsWith("\0"));
Console.WriteLine(foo.EndsWith("c"));
Console.WriteLine(foo.EndsWith("\0", StringComparison.CurrentCulture));
Console.WriteLine(foo.EndsWith("\0", StringComparison.Ordinal));
Console.WriteLine(foo.EndsWith('\0'));

重要

Windows 上的 ICU 表中列出的 Windows 版本上运行的 .NET 5+ 中,前面的代码片段将打印:

True
True
True
False
False

若要避免此行为,请使用 char 参数重载或 StringComparison.Oridinal

String.StartsWith

const string foo = "abc";

Console.WriteLine(foo.StartsWith("\0"));
Console.WriteLine(foo.StartsWith("a"));
Console.WriteLine(foo.StartsWith("\0", StringComparison.CurrentCulture));
Console.WriteLine(foo.StartsWith("\0", StringComparison.Ordinal));
Console.WriteLine(foo.StartsWith('\0'));

重要

Windows 上的 ICU 表中列出的 Windows 版本上运行的 .NET 5+ 中,前面的代码片段将打印:

True
True
True
False
False

若要避免此行为,请使用 char 参数重载或 StringComparison.Oridinal

TimeZoneInfo.FindSystemTimeZoneById

ICU 可以使用 IANA 时区 ID 灵活创建 TimeZoneInfo 实例,即使应用程序在 Windows 上运行也是如此。 同样,也可以使用 Windows 时区 ID 创建 TimeZoneInfo 实例,即使在非 Windows 平台上运行时也不例外。 但是,请务必注意,在使用 NLS 模式全球化固定模式时,此功能不可用。

依赖 ICU 的 API

.NET 引入了依赖 ICU 的 API。 仅当使用 ICU 时,这些 API 才会成功。 下面是一些示例:

Windows 上的 ICU 节表中列出的 Windows 版本上,上述 API 始终都会成功。 但是,在较旧版本的 Windows 上,这些 API 始终会失败。 在这种情况下,可以启用应用本地 ICU 功能,以确保这些 API 成功。 在非 Windows 平台上,无论版本如何,这些 API 都将始终成功。

此外,应用必须确保它们不会在全球化固定模式NLS 模式下 运行,以确保这些 API 成功。

使用 NLS 而不是 ICU

使用 ICU 代替 NLS 可能会导致与一些与全球化相关的操作存在行为差异。 若要恢复为使用 NLS,开发人员可以选择退出 ICU 实现。 应用程序可以通过以下任意方式启用 NLS 模式:

  • 在项目文件中:

    <ItemGroup>
      <RuntimeHostConfigurationOption Include="System.Globalization.UseNls" Value="true" />
    </ItemGroup>
    
  • runtimeconfig.json 文件中:

    {
      "runtimeOptions": {
         "configProperties": {
           "System.Globalization.UseNls": true
          }
      }
    }
    
  • 通过将环境变量 DOTNET_SYSTEM_GLOBALIZATION_USENLS 设置为值 true1

注意

在项目或 runtimeconfig.json 文件中设置的值优先于环境变量。

有关详细信息,请参阅运行时配置设置

确定应用是否正在使用 ICU

以下代码片段可帮助你确定应用是否正在运行 ICU 库(而不是 NLS)。

public static bool ICUMode()
{
    SortVersion sortVersion = CultureInfo.InvariantCulture.CompareInfo.Version;
    byte[] bytes = sortVersion.SortId.ToByteArray();
    int version = bytes[3] << 24 | bytes[2] << 16 | bytes[1] << 8 | bytes[0];
    return version != 0 && version == sortVersion.FullVersion;
}

如需确定 .NET 的版本,请使用 RuntimeInformation.FrameworkDescription

应用本地 ICU

每个版本的 ICU 都可能附带了 bug 修复以及描述世界语言的更新公共区域设置数据存储库 (CLDR) 数据。 当涉及与全球化相关的操作时,在 ICU 版本间移动可能会对应用行为产生细微影响。 为了帮助应用程序开发人员确保所有部署之间的一致性,.NET 5 和更高版本使 Windows 和 Unix 上的应用能够携带和使用其自己的 ICU 副本。

应用程序可以通过以下任一方式选择使用应用本地 ICU 实施模式:

  • 在项目文件中,设置适当的 RuntimeHostConfigurationOption 值:

    <ItemGroup>
      <RuntimeHostConfigurationOption Include="System.Globalization.AppLocalIcu" Value="<suffix>:<version> or <version>" />
    </ItemGroup>
    
  • 或者在 runtimeconfig.json 文件中,设置适当的 runtimeOptions.configProperties 值:

    {
      "runtimeOptions": {
         "configProperties": {
           "System.Globalization.AppLocalIcu": "<suffix>:<version> or <version>"
         }
      }
    }
    
  • 或者通过将环境变量 DOTNET_SYSTEM_GLOBALIZATION_APPLOCALICU 设置为值 <suffix>:<version><version>

    <suffix>:长度小于 36 个字符的可选后缀,遵循公共 ICU 打包约定。 在生成自定义 ICU 时,可以对其自定义以生成 lib 名称并导出符号名称以包含后缀,例如 libicuucmyapp,其中 myapp 是后缀。

    <version>:有效的 ICU 版本,例如 67.1。 此版本用于加载二进制文件和获取导出的符号。

设置上述任一选项后,可以将 Microsoft.ICU.ICU4C.RuntimePackageReference 添加到与配置的 version 相对应的项目,即可大功告成。

或者,为了在设置应用本地开关时加载 ICU,.NET 使用 NativeLibrary.TryLoad 方法探测多个路径。 该方法首先尝试在 NATIVE_DLL_SEARCH_DIRECTORIES 属性中查找库,该属性由 dotnet 主机基于应用的 deps.json 文件创建。 有关更多信息,请参阅默认探测

对于独立应用,用户不需要执行任何特殊操作,只需确保 ICU 位于应用目录中(对于独立应用,工作目录默认为 NATIVE_DLL_SEARCH_DIRECTORIES)。

如果通过 NuGet 包使用 ICU,则可在依赖框架的应用程序中使用。 NuGet 解析本机资产,并将它们包含在 deps.json 文件和 runtimes 目录下应用程序的输出目录中。 .NET 从这里加载它。

对于在本地版本使用 ICU 的依赖框架的应用(非独立应用),必须执行额外的步骤。 .NET SDK 尚不具有用于将“松散”的本机二进制文件合并到 deps.json 中的功能(请参阅此 SDK 问题)。 相反,你可以通过将其他信息添加到应用程序的项目文件来启用此功能。 例如:

<ItemGroup>
  <IcuAssemblies Include="icu\*.so*" />
  <RuntimeTargetsCopyLocalItems Include="@(IcuAssemblies)" AssetType="native" CopyLocal="true"
    DestinationSubDirectory="runtimes/linux-x64/native/" DestinationSubPath="%(FileName)%(Extension)"
    RuntimeIdentifier="linux-x64" NuGetPackageId="System.Private.Runtime.UnicodeData" />
</ItemGroup>

必须为支持的运行时的所有 ICU 二进制文件执行此操作。 此外,RuntimeTargetsCopyLocalItems 项组中的 NuGetPackageId 元数据需要与项目实际引用的 NuGet 包匹配。

macOS 行为

macOS 关于使用 Mach-O 文件中指定的加载命令解析依赖的动态库的行为与 Linux 加载程序不同。 在 Linux 加载程序中,.NET 可以尝试使用 libicudatalibicuuclibicui18n(按此顺序)来满足 ICU 依赖项关系图。 但在 macOS 上,这不起作用。 在 macOS 上生成 ICU 时,默认情况下,可以使用 libicuuc 中的这些加载命令获取动态库。 以下代码片段演示了一个示例。

~/ % otool -L /Users/santifdezm/repos/icu-build/icu/install/lib/libicuuc.67.1.dylib
/Users/santifdezm/repos/icu-build/icu/install/lib/libicuuc.67.1.dylib:
 libicuuc.67.dylib (compatibility version 67.0.0, current version 67.1.0)
 libicudata.67.dylib (compatibility version 67.0.0, current version 67.1.0)
 /usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1281.100.1)
 /usr/lib/libc++.1.dylib (compatibility version 1.0.0, current version 902.1.0)

这些命令只引用 ICU 其他组件的依赖库的名称。 加载程序将按照 dlopen 约定执行搜索,这涉及到在系统目录中包含这些库,或设置 LD_LIBRARY_PATH env var,或在应用级目录中包含 ICU。 如果无法设置 LD_LIBRARY_PATH 或无法确保 ICU 二进制文件位于应用级目录中,则需要执行一些额外的操作。

对于加载程序,有一些指令(如 @loader_path)会告知加载程序使用加载命令在与二进制文件相同的目录中搜索该依赖项。 可通过两种方式实现此目的:

  • install_name_tool -change

    运行以下命令:

    install_name_tool -change "libicudata.67.dylib" "@loader_path/libicudata.67.dylib" /path/to/libicuuc.67.1.dylib
    install_name_tool -change "libicudata.67.dylib" "@loader_path/libicudata.67.dylib" /path/to/libicui18n.67.1.dylib
    install_name_tool -change "libicuuc.67.dylib" "@loader_path/libicuuc.67.dylib" /path/to/libicui18n.67.1.dylib
    
  • 修补 ICU 以生成含有 @loader_path 的安装名称

    在运行 autoconf (./runConfigureICU) 之前,将这些行更改为以下内容:

    LD_SONAME = -Wl,-compatibility_version -Wl,$(SO_TARGET_VERSION_MAJOR) -Wl,-current_version -Wl,$(SO_TARGET_VERSION) -install_name @loader_path/$(notdir $(MIDDLE_SO_TARGET))
    

WebAssembly 上的 ICU

存在专门针对 WebAssembly 工作负荷的 ICU 版本。 此版本提供与桌面配置文件的全球化兼容性。 若要将 ICU 数据文件大小从 24 MB 减小到 1.4 MB(如果使用 Brotli 压缩,可压缩到约 0.3 MB),则此工作负荷有少量限制。

不支持以下 API:

支持以下 API,但有一些限制:

此外,支持的区域设置更少。 可以在 dotnet/icu repo 中找到支持的列表。