Skip to content

本地化

随着框架向全球化的演进,为不同国家或地区的用户展示以当地语言和文化偏好表达的内容——本地化,成为一项不可或缺的功能。 本文主要介绍语言本地化的支持情况和升级改造方法。

简介

语言本地化的目标是使用户能看懂显示的文字,使其可以更方便地使用软件系统。

这里的用户包括使用业务功能的普通用户,也包括管理系统的管理员用户。 前者关注系统的界面、提示消息;后者关注系统的运行日志、基本配置。 更重要的是,前者语言多样,需要切换显示;而后者通常保持某个固定语言(以核心管理人员为准)即可。 因此我们可以简单地将本地化的目标分为两类——用户类系统类,在改造过程中需要区别处理。

根据用户地区分布情况,目前支持中文简体(zh-Hans)、中文繁体(zh-Hant)、英文(en)三种语言。

提示

zh-Hans 包含 zh-CN、zh-SG,zh-Hant 包含 zh-TW、zh-MO、zh-HK。严格来讲港、澳、台之间的繁体略有差异, 但此处不再深究。

语言本地化改造主要涉及以下几个步骤:

  • 查找中文文本。
  • 为每条文本生成唯一标识(key)。
  • 将标识和文本保存到资源文件。
  • 替换原始文本为包装类的属性访问。
  • 将资源文件翻译为其它语言。

在动手改造之前,接下来我们不妨先讨论一下技术选型时的一些考量。

技术选型

在 ASP.NET Core 中可以使用两种不同的本地化方法。 一种是由来已久尤其在窗体程序中广泛使用的 ResourceManager 方式; 另一种是 ASP.NET Core 独有的 IStringLocalizer 方式。

下面简单分析一下这两者之间的差异,给出最终选型的依据。

IStringLocalizer

默认情况下 IStringLocalizer 的实现类是 ResourceManagerStringLocalizer, 从名字可以看出其内部同样使用了 ResourceManager,于是资源的组织方式依然是 .resx 文件, 这点没有差异。

我们来看看具体的使用方式。

csharp
public class FooService(IStringLocalizer localizer)
{
    public void Echo()
    {
        Console.WriteLine(localizer["System.ErrNoOp"]);
    }
}

可以看出,使用 IStringLocalizer 需要注入服务,然后调用索引器获取文本。

这里有几个明显问题:

  • 需要注入服务,由于行位置不确定,不便于批量自动化改造。
  • 资源项通过字符串指定,容易出现错误。
  • 资源项无法回显,可读性极差。
  • 语言选择在内部处理,无法手动指定。
  • 无法兼容所有场景。比如类库静态类不能使用依赖注入。

而它的优势似乎也只有以下两个:

  • 更符合 IoC 编码形式。
  • 可以更换实现类。

当然,字符串形式的 Key 带来的问题可以通过封装静态类来避免,但其它几个问题似乎是致命伤。

ResourceManager

使用 ResourceManager 则简单粗暴的多,用法如下。

csharp
ResourceManager manager = new("Foo.Resources.LocalRes", typeof(LocalRes).Assembly);
Console.WriteLine(manager.GetString("Res221", culture));

相比之下,ResourceManager 有几个明显的优势:

  • 无需注入服务
  • 可以指定文化(语言)
  • 可以在任意场景使用
  • 封装后可以原位置行内替换,便于批量自动化执行

而它的缺点,同样是字符串 Key 引起的书写易出错、回显、可读性问题。

我们可以参考 Visual Studio 的默认行为来解决这些问题——为资源文件生成一个类, 每个资源项对应一个只读属性,附带原始文本作为注释。 获取资源时只需要访问包装类的属性即可,这样就很好地解决了字符串标识的问题。

csharp
internal class LocalSysRes
{
    /// <summary>
    ///   返回此类使用的缓存的 ResourceManager 实例。
    /// </summary>
    internal static ResourceManager ResourceManager {
        // 省略初始化代码
    }

    /// <summary>
    /// 开始处理
    /// </summary>
    internal static string Res221 {
        get {
            return ResourceManager.GetString("Res221", resourceCulture);
        }
    }
}
csharp
// 获取资源
Console.WriteLine(LocalSysRes.Res221);

当然,我们为了区分用户类资源与系统资源,需要生成两个独立的包装类。 主要区别在于语言的选择机制——用户语言动态,系统语言固定。

总结

无法全场景使用、无法手动指定语言、批量化操作支持较差这几个问题,足以使 IStringLocalizer 成为鸡肋。 最终我们选择了 ResourceManager

改造实施

准备

下载解压 LocalLangExtractorResTranslater 两个工具软件,它们分别负责资源创建和资源翻译。 项目本身不需要做任何配置和依赖方面的调整,只需要关注资源创建与访问。

资源创建

  1. 首先打开 LocalLangExtractor,选择一个项目目录(包含.csproj),点击“搜索代码”,即可列出项目中所有包含中文的字符串。

    mainform

    提示

    由于资源文件会编译到所在项目的程序集,因此只能逐个项目分别创建资源。

    根据设置的规则,软件将找到的文本分为“系统”、“用户”、“忽略”与“未分类”四种,可以根据实际情况修改选择。

    • 系统即系统资源,固定使用某种语言。
    • 用户即用户资源,由用户指定使用的语言。
    • 忽略即不需要本地化的内容,包括注释、模型验证等。
    • 未分类为没有匹配任何规则的文本,需要人工分类。

    提示

    软件已经内置了一些规则,使用时可以随意按需添加。

  2. 确认好文本分类后,就可以点击“开始执行”。这一步会完成以下动作:

    • 为每条文本生成唯一标识。
    • 标识与文本成对写入资源文件 LocalRes.resx
    • 标识与文本成对写入包装类 LocalSysRes(系统类资源)或 LocalUserRes(用户类资源)。
    • 替换原文本为类属性访问。

    提示

    软件支持多次搜索、执行,不会覆盖资源文件内已有内容。

  3. 最后,编译项目,人工修复一些可能出现的错误。

按照这个步骤,依次为每个项目创建资源,本地化工作就完成了一大半!

资源翻译

经过翻译,一个资源变成一套由不同语言表示的资源,这样才可能按需切换显示。 借助 ResTranslater 批量翻译功能,可以轻松实现资源语言转换。 下面以中文翻译英文为例,介绍 ResTranslater 的使用方式。

  1. 将项目 Resources 目录下的 LocalRes.zh-Hans.resx 复制到 ResTranslater 程序目录,执行以下命令:

    shell
    # ResTranslater -l 输入语言 -i 输入文件名 -t 目标语言 -o 目标文件名
    ResTranslater -l zh -i LocalRes.zh-Hans.resx -t en -o LocalRes.en.resx
  2. 等待翻译完成后,将 LocalRes.en.resx 覆盖到项目中即可。

    提示

    由于翻译功能使用百度翻译服务,-l、-t 参数需要按官方文档“语种代码对照表”说明传值。

    注意

    如果目标文件存在,程序会尝试读取并跳过已翻译的内容,加快处理过程。因此建议总是将目标资源文件一起复制到 ResTranslater 程序目录。

    提示

    因为机器翻译的局限性,可能有部分内容并不贴切,需要人工检查修改。

  3. 依次为每个项目完成资源翻译工作,最终项目资源目录包含以下内容。

    |--Resources
      |--LocalRes.en.resx
      |--LocalRes.zh-Hans.resx
      |--LocalRes.zh-Hant.resx
      |--LocalSysRes.cs
      |--LocalUserRes.cs

模型验证

模型验证消息的本地化略有特殊,需要单独说明。

  1. 首先,框架对于基础的验证特性已经补充了合适的本地化消息,开发人员无需再指定错误消息。

    csharp
    public class FooDto
    {
        [Required(ErrorMessage = "姓名不能为空")]
        public string Name { get; set; }
    }
    csharp
    public class FooDto
    {
        [Required]
        public string Name { get; set; }
    }

    如上所示,直接删除 ErrorMessage 即可。这里给出一个正则表达式,方便大家批量搜索替换:

    js
    \(ErrorMessage\s*=\s*"[^"]+"\)
  2. 除此之外一些复杂的验证特性,往往与业务数据密切相关,很难由框架统一给出一个贴切的消息。

    比如下面的正则表达式,默认消息为“字段{0}必须与正则表达式‘{1}’匹配。”,这明显不是一个友好的提示消息。 因此在这种情况下必须保留自定义的错误消息。

    csharp
    public class FooDto
    {
        [RegularExpression("^(?![0-9]+$)(?![a-zA-Z]+$)[0-9A-Za-z]{6,12}$",
            ErrorMessage = "密码必须包含字母数字,长度为6-12位")]
        public string Password { get; set; }
    }

    同时,又限于特性参数值的常量要求,无法使用属性访问来指定消息。

    csharp
    public class FooDto
    {
        [RegularExpression("^(?![0-9]+$)(?![a-zA-Z]+$)[0-9A-Za-z]{6,12}$",
            ErrorMessage = LocalUserRes.Res221)] // 此处报错,不支持变量
        public string Password { get; set; }
    }

    此时不得不借助 ASP.NET Core 的模型消息本地化机制——把 ErrorMessage 参数当作 Key 来查找语言资源, 并且把属性访问修改为资源 Key 本身。一时间,字符串做资源 Key 的缺点又弥漫开来……

    csharp
    public class FooDto
    {
        [RegularExpression("^(?![0-9]+$)(?![a-zA-Z]+$)[0-9A-Za-z]{6,12}$",
            ErrorMessage = "Res221")]
        public string Password { get; set; }
    }

    推荐的做法是仍然使用工具创建资源、替换文本,然后根据错误提示人工修改资源 Key 为对应的字符串, 酌情添加注释。

  3. 如果使用了第 2 步介绍的本地化方式,还需要在 Resources 目录添加一个空的占位类 LocalRes.cs。 此时资源目录类似以下内容。

    js
    |--Resources
      |--LocalRes.cs
      |--LocalRes.en.resx
      |--LocalRes.zh-Hans.resx
      |--LocalRes.zh-Hant.resx
      |--LocalSysRes.cs
      |--LocalUserRes.cs

    注意

    LocalRes 类的命名空间必须与所在项目一致,否则无法定位到正确的资源文件。

结果验证

可以设置 Accept-Language 请求头,指定用户要使用的语言,观察返回结果是否满足预期。

shell
curl -X "POST" "http://localhost:5001/api/users" -H "Accept-Language: zh-Hans" -H "Content-Type: application/json" -d "{ \"account\": \"\", \"password\": \"string\" }" 

# {"code":999,"msg":"请求参数异常。Name字段为必填项。|EmpSn字段为必填项。|密码为6至12位,同时包含字母和数字"}
shell
curl -X "POST" "http://localhost:5001/api/users" -H "Accept-Language: zh-Hant" -H "Content-Type: application/json" -d "{ \"account\": \"\", \"password\": \"string\" }" 

# {"code":999,"msg":"請求參數异常。Name欄位為必填項。|EmpSn欄位為必填項。|密碼為6至12比特,同時包含字母和數位"}
shell
curl -X "POST" "http://localhost:5001/api/users" -H "Accept-Language: en" -H "Content-Type: application/json" -d "{ \"account\": \"\", \"password\": \"string\" }" 

# {"code":999,"msg":"Request parameter exception.The Name field is required.|The EmpSn field is required.|The password consists of 6 to 12 characters, including both letters and numbers"}