Abp 源码分析:十三、多语言(本地化)处理

0.简介

如果你所开发的需要走向世界的话,那么肯定需要针对每一个用户进行不同的本地化处理,有可能你的客户在日本,需要使用日语作为显示文本,也有可能你的客户在美国,需要使用英语作为显示文本。如果你还是一样的写死错误信息,或者描述信息,那么就无法做到多语言适配。

Abp 框架本身提供了一套多语言机制来帮助我们实现本地化,基本思路是 Abp 本身维护一个键值对集合。只需要将展示给客户的文字信息处都使用一个语言 Key 来进行填充,当用户登录系统之后,会取得当前用户的区域文化信息进行文本渲染。

TIM--20180817191430

0.1 如何使用

我们首先来看一下如何定义一个多语言资源并使用。首先 Abp 自身支持三种类型的本地化资源来源,第一种是 XML 文件,第二种则是 JSON 文件,第三种则是内嵌资源文件,如果这三种都不能满足你的需求,你可以自行实现 ILocalizationSource 接口来返回多语言资源。

小提示:

Abp Zero 模块就提供了数据库持久化存储多语言资源的功能。

0.1.1 定义应用程序支持的语言

如果你需要为你的应用程序添加不同语言的支持,就必须在你任意模块的预加载方法当中添加语言来进行配置:

Configuration.Localization.Languages.Add(new LanguageInfo("en", "English", "famfamfam-flag-england", true));
Configuration.Localization.Languages.Add(new LanguageInfo("tr", "Türkçe", "famfamfam-flag-tr"));

例如以上代码,就能够让我们的程序拥有针对英语与土耳其语的多语言处理能力。

这里的 famfamfam-flag-englandfamfamfam-flag-tr 是一个 CSS 类型,是 Abp 为前端展示所封装的小国旗图标。

0.1.2 建立多语言资源文件

有了语言之后,Abp 还需要你提供标准的多语言资源文件,这里我们以 自带的 XML 资源文件为例,其文件名称为 Abp-zh-Hans.xml ,路径为 Abp\Localization\Sources\AbpXmlSource

<?xml version="1.0" encoding="utf-8" ?>
<localizationDictionary culture="zh-Hans">
  <texts>
    <text name="SmtpHost">SMTP主机</text>
    <text name="SmtpPort">SMTP端口</text>
    <text name="Username">用户名</text>
    <text name="Password">密码</text>
    <text name="DomainName">域名</text>
    <text name="UseSSL">使用SSL</text>
    <text name="UseDefaultCredentials">使用默认验证</text>
    <text name="DefaultFromSenderEmailAddress">默认发件人邮箱地址</text>
    <text name="DefaultFromSenderDisplayName">默认发件人名字</text>
    <text name="DefaultLanguage">预设语言</text>
    <text name="ReceiveNotifications">接收通知</text>
    <text name="CurrentUserDidNotLoginToTheApplication">当前用户没有登录到系统!</text>
    <text name="TimeZone">时区</text>
    <text name="AllOfThesePermissionsMustBeGranted">您没有权限进行此操作,您需要以下权限: {0}</text>
    <text name="AtLeastOneOfThesePermissionsMustBeGranted">您没有权限进行此操作,您至少需要下列权限的其中一项: {0}</text>
    <text name="MainMenu">主菜单</text>
  </texts>
</localizationDictionary>

每个文件内部,会有一个 <localizationDictionary culture="zh-Hans"> 节点用于说明当前文件是针对于哪个区域适用的,而在其 <texts> 内部则就是结合键值对的形式,name 里面的内容就是多语言文本项的键,在标签内部的就是其真正的值。

打开一个针对俄语国家的 XML 资源文件,文件名称叫做 Abp-ru.xml

<?xml version="1.0" encoding="utf-8" ?>
<localizationDictionary culture="ru">
  <texts>
    <text name="SmtpHost">SMTP сервер</text>
    <text name="SmtpPort">SMTP порт</text>
    <text name="Username">Имя пользователя</text>
    <text name="Password">Пароль</text>
    <text name="DomainName">Домен</text>
    <text name="UseSSL">Использовать SSL</text>
    <text name="UseDefaultCredentials">Использовать учетные данные по умолчанию</text>
    <text name="DefaultFromSenderEmailAddress">Электронный адрес отправителя по умолчанию</text>
    <text name="DefaultFromSenderDisplayName">Имя отправителя по умолчанию</text>
    <text name="DefaultLanguage">Язык по умолчанию</text>
    <text name="ReceiveNotifications">Получать уведомления</text>
    <text name="CurrentUserDidNotLoginToTheApplication">Текущий пользователь не вошёл в приложение!</text>
  </texts>
</localizationDictionary>

可以看到 Key 值都是一样的,只是其 <text> 内部的值根据区域国家的不同值不一样而已。

其次从文件名我们就可以看到需要使用 XML 资源文件对于文件的命名格式会有一定要求,还是以 Abp 自带的资源文件为例,可以看一下他们基本上都是由 {SourceName}-{CultureInfo}.xml 这样构成的。

1534409976297

0.1.3 注册本地化的 XML 资源

那么如果我们需要注册之前的两个 XML 资源到 Abp 框架当中的话,则需要在预加载模块处通过如下代码来执行注册,并且需要右键 XML 文件,更改其构建操作为 内嵌资源

1534410609603

Configuration.Localization.Sources.Add(
    new DictionaryBasedLocalizationSource(
        // 本地化资源名称
        AbpConsts.LocalizationSourceName,
        // 数据源提供者,这里使用的是 XML ,除了 XML 提供者,还有 JSON 等
        new XmlEmbeddedFileLocalizationDictionaryProvider(
            typeof(AbpKernelModule).GetAssembly(), "Abp.Localization.Sources.AbpXmlSource"
        )));

0.1.4 获取多语言文本

如果你需要在某处获取指定 Key 所对应的具体显示文本,只需要注入 ILocalizationManager ,通过其 GetString() 方法就可以获得具体的值。如果你需要获取本地化资源的地方不能够使用依赖注入,你可以使用 LocalizationHelper 静态类来进行操作。

var @string = _localizationManager.GetString("Abp", "MainMenu");

它默认是从 Thread.CurrentThread.CurrentUICulture 获取到的当前区域信息,从而来取得某个 Key 所对应的显示值,而当前区域信息是由 Abp 注入的一系列 RequestCultureProviders 所提供的,他按照以下顺序来进行设置。

  1. QueryStringRequestCultureProvider(ASP .NET Core 默认提供):该默认提供器使用的是 QueryStringculture&ui-culture 所提供的区域文化信息来初始化该值,例如:culture=es-MX&ui-culture=es-MX
  2. AbpUserRequestCultureProvider (Abp 提供):该提供器会读取当前用户的 IAbpSession 信息,并且从 ISettingManager 中获取用户所配置的 "Abp.Localization.DefaultLanguageName" 属性,将其作为默认的区域文化信息。
  3. **AbpLocalizationHeaderRequestCultureProvider ** (Abp 提供):使用每次请求头当中的 .AspNetCore.Culture 值作为当前的区域文化信息,例如 c=en|uic=en-US
  4. CookieRequestCultureProvider (ASP .NET Core 提供):使用每次请求的 Cookie 当中 Key 为 .AspNetCore.Culture 值作为当前区域文化信息。
  5. AbpDefaultRequestCultureProvider (Abp 提供):如果之前这些提供器都没有为当前区域文化赋值,则从 ISettingMananger 当中取得 Abp.Localization.DefaultLanguageName 的默认值。
  6. AcceptLanguageHeaderRequestCultureProvider (ASP .NET Core 默认提供):该提供器最终会使用用户每次请求时传递的 Accept-Language 头部作为当前区域文化信息。

小提示:

这里 Abp 注入的提供器是有顺序的,注入这么多提供器就是为了最后确定当前用户的区域文化信息以便展示相应的语言文本。

1.启动流程

1.1 启动流程图

TIM--20180817191451

1.2 代码流程

根据使用方法我们可以得知,要配置 Abp 的多语言,必须得等 IAbpStartupConfiguration 初始化完毕才可以。即在 AbpBootstrapperInitialize() 方法之中:

public virtual void Initialize()
{
    // ... 其他代码
    // 注入 IAbpStartupConfiguration 配置与本地化资源配置
    IocManager.IocContainer.Install(new AbpCoreInstaller());

    // ... 其他代码
    // 初始化 AbpStartupConfiguration 类型
    IocManager.Resolve<AbpStartupConfiguration>().Initialize();

    // ... 其他代码
}

配置类里面包含了用户所配置的所有语言与多语言资源信息,在被成功注入到 Ioc 容器之后,Abp 就开始使用本地化资源管理器来初始化这些多语言数据了。

public override void PostInitialize()
{
    // 注册缺少的组件,防止遗漏注册组件
    RegisterMissingComponents();

    IocManager.Resolve<SettingDefinitionManager>().Initialize();
    IocManager.Resolve<FeatureManager>().Initialize();
    IocManager.Resolve<PermissionManager>().Initialize();
    
    // 重点在这里,这个 PostInitialize 方法是存放在核心模块当中的,在这里调用了本地化资源管理器的初始化方法
    IocManager.Resolve<LocalizationManager>().Initialize();
    IocManager.Resolve<NotificationDefinitionManager>().Initialize();
    IocManager.Resolve<NavigationManager>().Initialize();

    if (Configuration.BackgroundJobs.IsJobExecutionEnabled)
    {
        var workerManager = IocManager.Resolve<IBackgroundWorkerManager>();
        workerManager.Start();
        workerManager.Add(IocManager.Resolve<IBackgroundJobManager>());
    }
}

具体 LocalizationManager 及其内部的实现我们在下一节代码分析中详细进行讲述。

这些动作仅仅是在注入 Abp 框架的时候所需要执行的一些步骤,如果你要启用多语言,需要在 ASP .NET Core 程序的 Startup 类中的 Configure() 处通过更改 UseAbpRequestLocalization 状态为 True,才会将区域文化识别中间件注入到程序当中。

public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
{
    app.UseAbp(options =>
    {
        options.UseAbpRequestLocalization = false; //disable automatic adding of request localization
    });

    //...authentication middleware(s)

    app.UseAbpRequestLocalization(); //manually add request localization

    //...other middlewares

    app.UseMvc(routes =>
    {
        //...
    });
}

其实这里的 UseAbpRequestLocalization() 就已经将上文说的那些 RequestProvider 按照顺序依次注入到 MVC 之中了。

2.代码分析

Abp 框架针对本地化处理相关的类型与方法定义都存放在 Abp 库的 Localization 文件夹下。关系还是相对复杂的,这里我们先从其核心的 Abp 库针对于多语言的处理开始讲起。

2.1 多语言模块配置

Abp 需要使用的所有信息都是由用户在自己启动模块的 PreInitialize() 当中,通过 ILocalizationConfiguration 进行注入配置。也就是说在 ILocalizationConfiguration 内部,主要是包含了语言,与多语言资源提供者两种重点信息。

public interface ILocalizationConfiguration
{
    // 当前应用程序可配置的语言列表
    IList<LanguageInfo> Languages { get; }

    // 本地化资源列表
    ILocalizationSourceList Sources { get; }

    // 是否启用多语言(本地化) 系统
    bool IsEnabled { get; set; }

    // 以下四个布尔类型的参数主要用于确定当没有找到多语言文本时的处理逻辑,默认都为 True
    bool ReturnGivenTextIfNotFound { get; set; }

    bool WrapGivenTextIfNotFound { get; set; }

    bool HumanizeTextIfNotFound { get; set; }

    bool LogWarnMessageIfNotFound { get; set; }
}

2.2 语言信息

当前应用程序能够支持哪一些语言,取决于用户在预加载的时候给多语言模块配置对象分配了哪些语言。通过第 0.1.1 节我们看到用户可以直接通过初始化一个新的 LanguageInfo 对象,将其添加到 Languages 属性之中。

public class LanguageInfo
{
    /// <summary>
    /// 区域文化代码名称
    /// 应该是一个有效的区域文化代码名称,更多的可以通过 CultureInfo 静态类获得所有文化代码。
    /// 例如: "en-US" 是北美适用的, "tr-TR" 适用于土耳其。
    /// </summary>
    public string Name { get; set; }

    /// <summary>
    /// 该语言默认应该展示的语言名称。
    /// 例如: 英语应该展示为 "English", "zh-Hans" 应该展示为 "简体中文"
    /// </summary>
    public string DisplayName { get; set; }

    /// <summary>
    /// 用于展示的图标 CSS 类名,可选参数
    /// </summary>
    public string Icon { get; set; }

    /// <summary>
    /// 是否为默认语言
    /// </summary>
    public bool IsDefault { get; set; }

    /// <summary>
    /// 该语言是否被禁用
    /// </summary>
    public bool IsDisabled { get; set; }

    /// <summary>
    /// 语言的展示方式是自左向右还是自右向左
    /// </summary>
    public bool IsRightToLeft
    {
        get
        {
            try
            {
                return CultureInfo.GetCultureInfo(Name).TextInfo?.IsRightToLeft ?? false;
            }
            catch
            {
                return false;
            }
        }
    }

    public LanguageInfo(string name, string displayName, string icon = null, bool isDefault = false, bool isDisabled = false)
    {
        Name = name;
        DisplayName = displayName;
        Icon = icon;
        IsDefault = isDefault;
        IsDisabled = isDisabled;
    }
}

关于语言的定义还是相当简单的,主要参数就是语言的 区域文化代码展示的名称,其余的都可以是可选参数。

小提示:

关于当前系统所支持的区域文化代码,可以通过执行 CultureInfo.GetCultures(CultureTypes.AllCultures); 得到。

2.3 语言管理器

Abp 针对语言也提供了一个管理器,接口叫做 ILanguageManager,定义简单,两个方法。

public interface ILanguageManager
{
    // 获得当前语言
    LanguageInfo CurrentLanguage { get; }

    // 获得所有语言
    IReadOnlyList<LanguageInfo> GetLanguages();
}

实现也不复杂,它内部的实现就是从一个 ILanguageProvider 拿取有哪一些语言数据。

private readonly ILanguageProvider _languageProvider;

public IReadOnlyList<LanguageInfo> GetLanguages()
{
    return _languageProvider.GetLanguages();
}

// 获取当前语言,其实就是获取的 CultureInfo.CurrentUICulture.Name 的信息,然后去查询语言集合。
private LanguageInfo GetCurrentLanguage()
{
    var languages = _languageProvider.GetLanguages();
    
    // ... 省略了的代码
    var currentCultureName = CultureInfo.CurrentUICulture.Name;

    var currentLanguage = languages.FirstOrDefault(l => l.Name == currentCultureName);
    if (currentLanguage != null)
    {
        return currentLanguage;
    }
    
    // ... 省略了的代码
    
    return languages[0];
}

默认实现就是直接读取之前通过 Configuration 的 Languages 里面的数据。

在 Abp.Zero 模块还有两外一个实现,叫做 ApplicationLanguageProvider ,这个提供者则是从数据库表 ApplicationLanguage 获取的这些语言列表数据,并且这些语言信息还与租户有关,不同的租户他所能够获得到的语言数据也不一样。

public IReadOnlyList<LanguageInfo> GetLanguages()
{
    // 可以看到这里传入的当前登录用户的租户 Id,通过这个参数去查询的语言表数据
    var languageInfos = AsyncHelper.RunSync(() => _applicationLanguageManager.GetLanguagesAsync(AbpSession.TenantId))
        .OrderBy(l => l.DisplayName)
        .Select(l => l.ToLanguageInfo())
        .ToList();

    SetDefaultLanguage(languageInfos);

    return languageInfos;
}

2.4 本地化资源

2.4.1 本地化资源列表

在多语言模块配置内部使用的是 ILocalizationSourceList 类型的一个 Sources 属性,该类型其实就是继承自 IList<ILocalizationSource> 的一个具体实现而已,一个类型为 ILocalizationSource 的集合,不过其扩展了一个

Extensions 属性用于存放扩展的多语言数据字段。

2.4.2 本地化资源

其接口定义为 ILocalizationSource ,Abp 默认为我们实现了四种本地化资源的实现。

1534479785680

第一个是空实现,可以跳过,第二个则是针对资源文件进行读取的的本地化资源,第三个是基于字典的的本地化资源定义,最后一个是由 Abp Zero 模块所提供的数据库版本的多语言资源定义。

首先看一下该接口的定义:

public interface ILocalizationSource
{
    // 本地化资源唯一的名称
    string Name { get; }

    // 用于初始化本地化资源,在 Abp 框架初始化的时候被调用
    void Initialize(ILocalizationConfiguration configuration, IIocResolver iocResolver);

    // 从当前本地化资源中获取给定关键字的多语言文本项,为用户当前语言
    string GetString(string name);

    // 从当前本地化资源中获取给定关键字与区域文化的多语言文本项
    string GetString(string name, CultureInfo culture);

    // 作用同上,只不过不存在会返回 NULL
    string GetStringOrNull(string name, bool tryDefaults = true);

    // 作用同上,只不过不存在会返回 NULL
    string GetStringOrNull(string name, CultureInfo culture, bool tryDefaults = true);

    // 获得当前语言所有的多语言文本项集合
    IReadOnlyList<LocalizedString> GetAllStrings(bool includeDefaults = true);

    // 获得给定区域文化的所有多语言文本项集合
    IReadOnlyList<LocalizedString> GetAllStrings(CultureInfo culture, bool includeDefaults = true);
}

也就可以这么来看,我们有几套本地化资源,他们通过 Name 来进行标识,如果你需要在本地化管理器获取某一套本地化资源,那么你可以直接通过 Name 来进行定位。而每一套本地化资源,自身都拥有具体的多语言数据,这些多语言数据有可能来自文件也有可能来自数据库,这取决于你具体的实现。

2.4.3 基于字典的本地化资源

最开始我们在使用范例当中,通过 DictionaryBasedLocalizationSource 来建立我们的本地化资源对象。该对象实现了 ILocalizationSourceIDictionaryBasedLocalizationSource 接口,内部定义了一个本地化资源字典提供器。

当调用本地化资源的 Initialize() 方法的时候,会使用具体的本地化资源字典提供器来获取数据,而这个字典提供器可以为 XmlFileLocalizationDictionaryProviderJsonEmbeddedFileLocalizationDictionaryProvider 等。

这些内部字典提供器在初始化的时候,会将自身的数据按照 语言/多语言项 的形式将多语言信息存放在一个字典之中,而这个字典又可以分为 XML、JSON 等等等等...

// 内部字典提供器
public interface ILocalizationDictionaryProvider
{
    // 语言/多语言项字典
    IDictionary<string, ILocalizationDictionary> Dictionaries { get; }

    // 本地化资源初始化时被调用
    void Initialize(string sourceName);
}

而这里的 ILocalizationDictionary 其实就是一个键值对,键关联的是多语言项的标识 KEY,例如 "Home",而 Value 就是具体的展示文本信息了。

而是用字典本地化资源对象获取数据的时候,其实也就是从其内部的字典提供器来获取数据。

TIM--20180817191504

例如本地化资源有一个 GetString() 方法,它内部拥有一个字典提供器 DictionaryProvider,我要获取某个 KEY 为 "Home" 所需要经过的步骤如下。

public ILocalizationDictionaryProvider DictionaryProvider { get; }

public string GetString(string name)
{
    // 获取当前用户区域文化,标识为 "Home" 的展示文本
    return GetString(name, CultureInfo.CurrentUICulture);
}

public string GetString(string name, CultureInfo culture)
{
    // 获取值
    var value = GetStringOrNull(name, culture);

    // 判断值为空的话,根据配置的要求是否抛出异常
    if (value == null)
    {
        return ReturnGivenNameOrThrowException(name, culture);
    }

    return value;
}

// 获得 KEY 关联的文本
public string GetStringOrNull(string name, CultureInfo culture, bool tryDefaults = true)
{
    var cultureName = culture.Name;
    var dictionaries = DictionaryProvider.Dictionaries;

    // 在这里就开始从初始化所加载完成的语言字典里面,获取具体的多语言项字典
    ILocalizationDictionary originalDictionary;
    if (dictionaries.TryGetValue(cultureName, out originalDictionary))
    {
        // 多语言项字典拿取具体的多语言文本值
        var strOriginal = originalDictionary.GetOrNull(name);
        if (strOriginal != null)
        {
            return strOriginal.Value;
        }
    }

    if (!tryDefaults)
    {
        return null;
    }

    //Try to get from same language dictionary (without country code)
    if (cultureName.Contains("-")) //Example: "tr-TR" (length=5)
    {
        ILocalizationDictionary langDictionary;
        if (dictionaries.TryGetValue(GetBaseCultureName(cultureName), out langDictionary))
        {
            var strLang = langDictionary.GetOrNull(name);
            if (strLang != null)
            {
                return strLang.Value;
            }
        }
    }

    //Try to get from default language
    var defaultDictionary = DictionaryProvider.DefaultDictionary;
    if (defaultDictionary == null)
    {
        return null;
    }

    var strDefault = defaultDictionary.GetOrNull(name);
    if (strDefault == null)
    {
        return null;
    }

    return strDefault.Value;
}

2.3.4 基于数据库的本地化资源

如果你有集成 Abp.Zero 模块的话,可以通过在启动模块的预加载方法编写以下代码启用 Zero 的多语言机制。

Configuration.Modules.Zero().LanguageManagement.EnableDbLocalization();

Abp.Zero 针对原有的本地化资源进行了扩展,新增的本地化资源类叫做 MultiTenantLocalizationSource,该类同语言管理器一样,是一个基于多租户实现的本地化资源,内部字典的值是从数据库当中获取的,其大体逻辑与字典本地化资源一样,都是内部维护有一个字典提供器。

在通过 EnableDbLocalization() 方法的时候就直接替换掉了 ILanguageProvider 的默认实现,并且在配置的 Sources 源里面也增加了 MultiTenantLocalizationSource 作为一个本地化资源。

2.5 本地化资源管理器

扯了这么多,让我们来看一下最为核心的 ILocalizationManager 接口,如果我们需要获取某个数据源的某个 Key 所对应的多语言值肯定是要注入这个本地化资源管理器来进行操作的。

public interface ILocalizationManager
{
    // 根据名称获得本地化数据源
    ILocalizationSource GetSource(string name);

    // 获取所有的本地化数据源
    IReadOnlyList<ILocalizationSource> GetAllSources();
}

这里的数据源标识的就是一个命名空间的作用,比如我在 A 模块当中有一个 Key 为 "Home" 的多语言项,在 B 模块也有一个 Key 为 "Home" 的多语言项,这个时候就可以用数据源标识来区分这两个 "Home"

本地化资源管理器通过在初始化的时候调用其 Initialize() 来初始化所有被注入的本地化资源,最后并将其放在一个字典之中,以便后续使用。

private readonly IDictionary<string, ILocalizationSource> _sources;

foreach (var source in _configuration.Sources)
{
    // ... 其他代码
    _sources[source.Name] = source;
    source.Initialize(_configuration, _iocResolver);
    
    // ... 其他代码
}

TIM--20180817191511

3.结语

针对 Abp 的多语言处理本篇文章不太适合作为入门了解,其中大部分知识需要结合 Abp 源码进行阅读才能够加深理解,此文仅作抛砖引玉之用,如有任何意见或建议欢迎大家在评论当中指出。