@ditsmod/i18n — модуль для перевода системных сообщений

i18n — это аббревиатура интернационализации. Модуль @ditsmod/i18n предоставляет базовую функциональность для перевода системных сообщений (выдаваемых приложением Ditsmod во время выполнения) и дает возможность легко расширять словари. Фактически, в качестве словарей для перевода используются обычные сервисы, поэтому текст для перевода может браться как из файлов TypeScript, так и из баз данных. Работа @ditsmod/i18n построена таким образом, что каждый текущий модуль может иметь свой собственный перевод, а перевод любого импортированного модуля может быть изменен или дополнен.

Код с примерами использования @ditsmod/i18n можно посмотреть в репозитории Ditsmod, хотя удобнее просматривать его локально, поэтому лучше сначала его клонировать:

git clone https://github.com/ditsmod/ditsmod.git
cd ditsmod
yarn
yarn boot
Войти в полноэкранный режим Выйти из полноэкранного режима

Пример можно запустить с помощью команды:

yarn start15
Enter fullscreen mode Выйти из полноэкранного режима

Структура каталогов

В примере examples/15-i18n есть два модуля, каждый из которых имеет примерно следующую структуру каталогов с файлами перевода:

└── modulename
    ├── ...
    ├── locales
    │   ├── current
    │   │   ├── _base-en
    │   │   ├── de
    │   │   ├── fr
    │   │   ├── pl
    │   │   ├── uk
    │   │   └── index.ts
    │   └── imported
    │       ├── one
    │       │   ├── de
    │       │   ├── fr
    │       │   ├── pl
    │       │   └── uk
    │       ├── two
    │       │   ├── de
    │       │   ├── fr
    │       │   ├── pl
    │       │   └── uk
    │       └── index.ts
Вход в полноэкранный режим Выход из полноэкранного режима

Это рекомендуемая структура каталогов. Как вы видите, каждый модуль имеет перевод в папке locales, которая содержит две папки:

Причем только в папке current находится папка _base-en, содержащая базовые словари (в данном случае — на английском языке), от которых ответвляются словари с переводами на другие языки. В имени используется символ подчеркивания, чтобы папка _base-en всегда находилась выше других папок.

Базовые и расширенные классы с переводами

Как уже упоминалось, словари являются обычными сервисами:

import { Dictionary, ISO639 } from '@ditsmod/i18n';
import { Injectable } from '@ts-stack/di';

@Injectable()
export class CommonDict implements Dictionary {
  getLng(): ISO639 {
    return 'en';
  }
  /**
   * Hi, there!
   */
  hi = `Hi, there!`;
  /**
   * Hello, ${name}!
   */
  hello(name: string) {
    return `Hello, ${name}!`;
  }
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Это базовый словарь с английской локализацией. В данном случае он имеет имя CommonDict, но не обязательно помещать весь перевод в один класс, можно использовать другие классы, например, ErrorDict, EmailDict и т.д.

Каждый базовый словарь должен реализовывать интерфейс Dictionary, который имеет единственное требование — чтобы словарь имел метод getLng(), возвращающий сокращение названия языка по стандарту ISO 639 (например, сокращения для английского и украинского языков — en, uk).

Почему метод, а не свойство, возвращает аббревиатуру названия языка?
Дело в том, что в JavaScript свойство класса нельзя посмотреть, пока не создан экземпляр этого класса. Но метод getLng() можно легко посмотреть через YourClass.prototype.getLng(). Это позволяет получить статистику доступных переводов еще до использования словарей.

Рекомендуется называть каждый класс сервиса с окончанием *Dict, а файл — с окончанием *.dict.ts. Кроме того, имя класса базового словаря не должно содержать локали, поэтому в данном случае класс назван не CommonEnDict, а CommonDict. Это рекомендуется потому, что базовый класс словаря будет использоваться для перевода на любой другой доступный язык. Например, в коде следующее выражение может фактически вернуть перевод на любой язык, несмотря на использование базового класса словаря в качестве маркера:

const dict = this.dictService.getDictionary(CommonDict);
dict.hello('World');
Войти в полноэкранный режим Выйти из полноэкранного режима

Каждый класс словаря, содержащий перевод, должен расширять базовый класс словаря:

import { ISO639 } from '@ditsmod/i18n';
import { Injectable } from '@ts-stack/di';

import { CommonDict } from '@dict/second/common.dict';

@Injectable()
export class CommonUkDict extends CommonDict {
  override getLng(): ISO639 {
    return 'uk';
  }

  override hello(name: string) {
    return `Привіт, ${name}!`;
  }
}
Enter fullscreen mode Выйти из полноэкранного режима

Как минимум, каждый словарь перевода должен переопределять метод getLng(). Для более строгого контроля словарей с переводами рекомендуется использовать TypeScript 4.3+, а также следующую настройку в tsconfig.json:

{
  "compilerOptions": {
    "noImplicitOverride": true,
    // ...
  }
}
Войти в полноэкранный режим Выйти из полноэкранного режима

В этом случае TypeScript потребует от дочернего класса добавить ключевое слово override перед каждым методом или свойством из родительского класса. Это улучшает читабельность дочернего класса и предотвращает ошибку в имени метода или свойства. Если вы создадите метод с неправильным именем, например helo вместо hello, и пометите его как override, TypeScript выдаст предупреждение, что в родительском классе такого метода не существует. Тот же сценарий сработает, если ранее написанный метод был удален из родительского класса.

Как видите, имена классов словарей с переводами уже содержат локаль CommonUkDict — это словарь с украинской локализацией. И поскольку этот словарь расширяет класс базового словаря, все недостающие переводы будут отображаться на языке базового словаря. В данном случае это свойство имеет базовый словарь CommonDict:

/**
 * Hi, there!
 */
hi = `Hi, there!`;
Войти в полноэкранный режим Выйти из полноэкранного режима

А словарь CommonUkDict не имеет перевода этой фразы, поэтому при запросе локализации на украинский язык будет использована английская версия из базового класса.

Обратите внимание, что каждое свойство или метод, которые непосредственно используются для перевода, закомментированы шаблоном фразы, которую они возвращают. Это добавляет удобства при использовании словаря в коде приложения, так как ваша IDE покажет эти комментарии:

const dict = this.dictService.getDictionary(CommonDict);
dict.hi;
Войти в полноэкранный режим Выйти из полноэкранного режима

В данном случае, при наведении курсора на dict.hi, IDE покажет Hi, there!.

Сбор словарей в группы в текущей папке

Напомню, что папка current содержит словари с переводами для текущего модуля. Эти словари должны быть собраны в единый массив в файле index.ts:

import { DictGroup, getDictGroup } from '@ditsmod/i18n';

import { CommonDict } from '@dict/second/common.dict';
import { CommonUkDict } from './uk/common-uk.dict';
import { ErrorDict } from '@dict/second/error.dict';
import { ErrorsUkDict } from './uk/errors-uk.dict';
// ...

export const current: DictGroup[] = [
  [CommonDict, CommonUkDict, CommonPlDict, CommonFrDict, CommonDeDict],
  [ErrorDict, ErrorsUkDict, ErrorsPlDict, ErrorsFrDict, ErrorsDeDict],
  // ...
];
Вход в полноэкранный режим Выход из полноэкранного режима

Как видно, группы словарей передаются в массиве, где класс с базовым словарем всегда должен идти первым. В данном случае передаются две группы словарей с базовыми классами CommonDict и ErrorDict. Смешивать словари из разных групп не разрешается. Если вы смешаете словари из разных групп, TypeScript не сможет сообщить вам об этом, поэтому рекомендуется использовать функцию getDictGroup() для лучшего контроля типов классов:

import { DictGroup, getDictGroup } from '@ditsmod/i18n';

import { CommonDict } from '@dict/second/common.dict';
import { CommonUkDict } from './uk/common-uk.dict';
import { ErrorDict } from '@dict/second/error.dict';
import { ErrorsUkDict } from './uk/errors-uk.dict';
// ...

export const current: DictGroup[] = [
  getDictGroup(CommonDict, CommonUkDict, CommonPlDict, CommonFrDict, CommonDeDict),
  getDictGroup(ErrorDict, ErrorsUkDict, ErrorsPlDict, ErrorsFrDict, ErrorsDeDict),
  // ...
];
Вход в полноэкранный режим Выход из полноэкранного режима

Сбор словарей в группы в импортированной папке

Напомню, что директория imported содержит словари с переводами для импортируемых модулей, и обратите внимание, что она не содержит базовых словарей (в ней нет папки _base-en), так как базовые словари импортируемых модулей находятся в этих модулях в директориях current:

└── modulename
    ├── ...
    ├── locales
    │   ├── current
    │   │   ├── _base-en
    │   │   ├── de
    │   │   ├── fr
    │   │   ├── pl
    │   │   ├── uk
    │   │   └── index.ts
    │   └── imported
    │       ├── one
    │       │   ├── de
    │       │   ├── fr
    │       │   ├── pl
    │       │   └── uk
    │       ├── two
    │       │   ├── de
    │       │   ├── fr
    │       │   ├── pl
    │       │   └── uk
    │       └── index.ts
Войти в полноэкранный режим Выйти из полноэкранного режима

Каталог imported содержит отдельные папки каждого модуля, для которого необходимо дополнить или переписать перевод. В данном случае папка imported содержит дополнения или переработку перевода для модулей one и two. Сбор групп словарей в папке imported аналогичен тому, как это делается в current, но базовые словари берутся из внешних модулей:

import { DictGroup, getDictGroup } from '@ditsmod/i18n';

import { CommonDict } from '@dict/first/common.dict'; // A basic dictionary from an external module from the current folder
import { CommonUkDict } from './first/uk/common-uk.dict'; // Addition of translation for an external module from the imported folder

export const imported: DictGroup[] = [
  getDictGroup(CommonDict, CommonUkDict),
];
Войти в полноэкранный режим Выйти из полноэкранного режима

В данном случае базовый словарь CommonDict импортируется из FirstModule, а добавление украинского перевода берется в текущем модуле из папки imported.

Перенос переводов в модуль

Теперь осталось перенести группы словарей в модуль:

import { Module } from '@ditsmod/core';
import { I18nModule, I18nOptions, I18N_TRANSLATIONS, Translations } from '@ditsmod/i18n';

import { current } from './locales/current';
import { imported } from './locales/imported';

const translations: Translations = { current, imported };
const i18nOptions: I18nOptions = { defaultLng: 'uk' };

@Module({
  imports: [
    I18nModule,
    // ...
  ],
  providersPerMod: [
    { provide: I18N_TRANSLATIONS, useValue: translations, multi: true },
    { provide: I18nOptions, useValue: i18nOptions },
  ],
  exports: [I18N_TRANSLATIONS]
})
export class SecondModule {}
Войти в полноэкранный режим Выйти из полноэкранного режима

Как вы видите, каждый модуль, содержащий перевод, должен:

  • импортировать I18nModule;
  • в массив providersPerMod добавить мультипровайдер, содержащий токен I18N_TRANSLATIONS и содержимое с типом данных Translations, куда передаются группы словарей как для текущего, так и для импортированного модуля;
  • провайдер с токеном I18nOptions может быть передан в массив providersPerMod;
  • вы можете передать токен I18N_TRANSLATIONS в массив exports, если хотите, чтобы базовые словари из текущего модуля были доступны внешним модулям. При этом обратите внимание, что такой экспорт необходим только в том случае, если вы хотите напрямую использовать базовые словари, то есть в коде своей программы вы их импортируете. Если же вы экспортируете некий сервис, который внутренне использует базовые словари (инкапсулирует их использование), то экспорт I18N_TRANSLATIONS вам не нужен.

Если вы используете помощник i18nProviders().i18n(), вы можете немного сократить количество кода:

import { Module } from '@ditsmod/core';
import { I18nModule, I18nProviders } from '@ditsmod/i18n';

import { current } from './locales/current';
import { imported } from './locales/imported';

@Module({
  imports: [
    I18nModule,
    // ...
  ],
  providersPerMod: [
    ...new I18nProviders().i18n({ current, imported }, { defaultLng: 'uk' }),
  ],
  exports: [I18N_TRANSLATIONS]
})
export class SecondModule {}
Вход в полноэкранный режим Выход из полноэкранного режима

В качестве первого аргумента для i18nProviders().i18n() передается объект типа Translations, во втором передаются опции типа I18nOptions. Обратите внимание, что помощнику предшествует многоточие, так как он возвращает массив, который должен быть объединен с другими провайдерами в массиве providersPerMod.

Использование словарей с переводом

Чтобы использовать словари, необходимо использовать DictService:

import { Injectable } from '@ts-stack/di';
import { DictService } from '@ditsmod/i18n';

import { CommonDict } from '@dict/first/common.dict';

@Injectable()
export class FirstService {
  constructor(private dictService: DictService) {}

  countToThree() {
    const dict = this.dictService.getDictionary(CommonDict);
    return dict.countToThree;
  }
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Как видно, базовые классы словарей всегда используются в качестве маркера для поиска нужной группы словарей. В данном случае этот код будет работать, если базовый словарь содержит свойство countToThree. Он выведет требуемый перевод, если в группе словарей CommonDict есть словарь с соответствующим переводом. Во втором аргументе можно указать локаль:

countToThree() {
  const dict = this.dictService.getDictionary(CommonDict, 'uk');
  return dict.countToThree;
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Но в большинстве случаев язык выбирается через HTTP-запрос. По умолчанию DictService берет локаль из параметра lng в this.req.queryParams, но вы можете изменить имя этого параметра, передав параметр lngParam:

// ...
@Module({
  // ...
  providersPerMod: [
    ...new I18nProviders().i18n({ current, imported },  { defaultLng: 'uk', lngParam: 'locale' }),
  ],
})
export class SecondModule {}
Войти в полноэкранный режим Выйти из полноэкранного режима

Обратите внимание, что DictService объявлен на уровне HTTP запроса, поэтому вы не сможете использовать этот сервис в других сервисах, объявленных на более высоких уровнях (на уровне приложения или модуля). Если вам нужен сервис более высокого уровня, используйте DictPerModService, который фактически является родительским классом для DictService с почти идентичным API.

Произвольное определение языка запросов

Хотя значение языка запроса по умолчанию определяется через this.req.queryParams, вы можете легко изменить логику определения языка запроса, например, с помощью заголовков accept-language. Для этого достаточно изменить геттер dictService.lng.

Если вы клонировали репозиторий, вы найдете пример MyDictService в модуле examples/15-i18n/src/app/third/third.module.ts. Этот сервис расширяет DictPerModService и только перезаписывает геттер mydictService.lng, а сеттер также перезаписывается, чтобы mydictService.lng можно было редактировать. Ну а после собственной реализации определения языка запроса, конечно же, нужно добавить новый сервис providersPerReq в модуль.

Оцените статью
devanswers.ru
Добавить комментарий