Ленивая подгрузка переводов с Angular

image

Если вы когда-нибудь участвовали в разработке крупного angular-проекта с поддержкой локализации, то эта статья для вас. Если же нет, то возможно, вам будет интересно, как мы решили проблему скачивания больших файлов с переводами при старте приложения: в нашем случае ~2300 строк и ~200 Кб для каждого языка.


Немного контекста

Всем привет! Я Frontend-разработчик компании ISPsystem в команде VMmanager.

Итак, мы имеем крупный frontend-проект. Под капотом angular 9-й версии на момент написания статьи. Поддержка локализации осуществляется библиотекой ngx-translate. Сами переводы в проекте лежат в json-файлах. Для взаимодействия с переводчиками используется сервис POEditor.


Что не так с большими переводами?

Во-первых, необходимость скачивать большой json-файл при первом входе в приложение.
Да, этот файл можно закэшировать, но примерно каждые 2 недели выходит обновление продукта и после каждого обновления переводы должны скачиваться заново.
К тому же в проекте используется система разделения доступа, а это значит, что пользователь с минимальным уровнем доступа увидит только малую часть всех переводов (сложно сказать конкретно, но разница в несколько, а может и в десятки раз), а все остальное будет для него обузой.

Во-вторых, навигация в огромном json-файле просто неудобна.
Конечно, мы не пишем код в блокноте. Но все равно поиск определенного ключа в определенном namespace становится непростой задачей. Например, надо найти TITLE, который лежит внутри HOME(HOME.....TITLE), при условии что в файле есть еще сотня TITLE, а объект внутри HOME тоже содержит сотню ключей.


Что делать с этими проблемами?

Как можно догадаться из названия статьи: распилить переводы на кусочки, разложить по разным файлам и скачивать по мере их необходимости.
Эта концепция хорошо ложится на модульную архитектуру angular. Мы будем указывать в angular-модуле, какой кусок переводов нам для него нужен.

Еще нам может понадобиться один и тот же кусок переводов в разных частях (модулях) приложения, для этого можно его положить в отдельный файл. Конечно, можно положить этот кусок в главный файл, откуда он будет доступен во всем приложении, но вы не забыли, чем мы тут занимаемся? этот кусок опять же может быть частью исключительно администраторского интерфейса и менее привилегированным пользователям никогда не понадобится.

А еще часть переводов, которую можно положить отдельно, может быть частью других «отдельных» переводов (для более мелкого дробления на части).

На основании перечисленных хотелок получается примерно такая структура файлов:

/i18n/
  ru.json
  en.json
  HOME/
    ru.json
    en.json
  HOME.COMMON/
    ru.json
    en.json
  ADMIN/
    ru.json
    en.json

Тут файлы json в корне — это основные файлы, они будут скачиваться всегда (нужный, в зависимости от выбранного языка). Файлы в HOME — переводы необходимые только обычному пользователю. ADMIN — файлы необходимые только администратору. HOME.COMMON — файлы необходимые и пользователю, и администратору.

Каждый json-файл внутри должен иметь структуру, соответствующую его namespace:


  • корневые файлы просто содержат {...};
  • файлы внутри ADMIN содержат { "ADMIN": {...} };
  • файлы внутри HOME.COMMON содержат { "HOME": { "COMMON": {...} } };
  • и т.д.

Пока что это можно воспринимать как мою причуду, далее это будет обоснованно.

Будем отталкиваться именно от этой структуры. У вас она может быть немного другой, от этого поменяются некоторые части из представленного далее кода.

ngx-translate из коробки этого всего не умеет, но предоставляет достаточный функционал, чтобы это можно было реализовать своими силами:


  • возможность обработать отсутствующий перевод — используем это, чтобы докачать необходимые переводы налету;
  • возможность реализовать свой загрузчик файлов переводов —, а это чтобы скачивать сразу несколько файлов переводов, необходимых текущему модулю.


Реализация


Скачиватель переводов: TranslateLoader

Чтобы сделать свой скачиватель переводов, необходимо создать класс реализующий один метод abstract getTranslation(lang: string): Observable. Для семантики можно унаследовать его от абстрактного класса TranslateLoader (импортируется из ngx-translate), который мы далее будем использовать для провайдинга.

Так как наш класс будет не просто скачивать переводы, но и как-то должен их объединять в один объект, кода будет чуть больше, чем один метод:

export class MyTranslationLoader extends TranslateLoader implements OnDestroy {
  /** Глобальный кэш с флагами скачанных файлов переводов (чтобы не качать их повторно, для разных модулей) */
  private static TRANSLATES_LOADED: { [lang: string]: { [scope: string]: boolean } } = {};

  /** Сортируем ключи по возрастанию длины (маленькие куски будут вмердживаться в большие) */
  private sortedScopes = typeof this.scopes === 'string' ? [this.scopes] : this.scopes.slice().sort((a, b) => a.length - b.length);

  private getURL(lang: string scope: string): string {
    // эта строка будет зависеть от того, куда и как вы кладете файлы переводов
    // в нашем случае они лежат в корне проекта в директории i18n
    return `i18n/${scope ? scope + '/' : ''}${lang}.json`;
  }

  /** Скачиваем переводы и запоминаем, что мы их скачали */
  private loadScope(lang: string, scope: string): Observable {
    return this.httpClient.get(this.getURL(lang, scope)).pipe(
      tap(() => {
        if (!MyTranslationLoader.TRANSLATES_LOADED[lang]) {
          MyTranslationLoader.TRANSLATES_LOADED[lang] = {};
        }
        MyTranslationLoader.TRANSLATES_LOADED[lang][scope] = true;
      })
    );
  }

  /** 
   * Все скачанные переводы необходимо объединить в один объект 
   * т.к. мы знаем, что файлы переводов не имеют пересечений по ключам, 
   * можно вместо сложной логики глубокого мерджа просто наложить объекты друг на друга,
   * но надо делать это в правильном порядке, именно для этого мы выше отсортировали наши scope по длине,
   * чтобы наложить HOME.COMMON на HOME, а не наоборот
   */
  private merge(scope: string, source: object, target: object): object {
    // обрабатываем пустую строку для root модуля
    if (!scope) {
      return { ...target };
    }

    const parts = scope.split('.');
    const scopeKey = parts.pop();
    const result = { ...source };
    // рекурсивно получаем ссылку на объект, в который необходимо добавить часть переводов
    const sourceObj = parts.reduce(
      (acc, key) => (acc[key] = typeof acc[key] === 'object' ? { ...acc[key] } : {}),
      result
    );
        // также рекурсивно достаем нужную часть переводов и присваиваем
    sourceObj[scopeKey] = parts.reduce((res, key) => res[key] || {}, target)?.[scopeKey] || {};

    return result;
  }

  constructor(private httpClient: HttpClient, private scopes: string | string[]) {
    super();
  }

  ngOnDestroy(): void {
    // сбрасываем кэш, чтобы при hot reaload переводы перекачались
    MyTranslationLoader.TRANSLATES_LOADED = {};
  }

  getTranslation(lang: string): Observable {
    // берем только еще не скачанные scope
    const loadScopes = this.sortedScopes.filter(s => !MyTranslationLoader.TRANSLATES_LOADED?.[lang]?.[s]);

    if (!loadScopes.length) {
      return of({});
    }

    // скачиваем все и сливаем в один объект
    return zip(...loadScopes.map(s => this.loadScope(lang, s))).pipe(
      map(translates => translates.reduce((acc, t, i) => this.merge(loadScopes[i], acc, t), {}))
    );
  }
}

Как можно заметить, scope здесь используется и как часть url для скачивания файла, и как ключ для доступа к необходимой части json, именно поэтому директория и структура в файле должны совпадать.

Как это использовать, описано чуть дальше.


Докачиватель переводов: MissingTranslationHandler

Чтобы реализовать эту логику, необходимо сделать класс, имеющий метод handle. Проще всего унаследовать класс от MissingTranslationHandler, который импортируется из ngx-translate.
Описание метода в репозитории ngx-translate выглядит так:

export declare abstract class MissingTranslationHandler {
  /**
   * A function that handles missing translations.
   *
   * @param params context for resolving a missing translation
   * @returns a value or an observable
   * If it returns a value, then this value is used.
   * If it return an observable, the value returned by this observable will be used (except if the method was "instant").
   * If it doesn't return then the key will be used as a value
   */
  abstract handle(params: MissingTranslationHandlerParams): any;
}

Нас интересует как раз второй вариант развития событий: вернуть Observable на скачивание нужного куска переводов.

export class MyMissingTranslationHandler extends MissingTranslationHandler {
  // кэшируем Observable с переводом, т.к. при входе на страницу, для которой еще нет переводов,
  // каждая translate pipe вызовет метод handle
  private translatesLoading: { [lang: string]: Observable } = {};

  handle(params: MissingTranslationHandlerParams) {
    const service = params.translateService;
    const lang = service.currentLang || service.defaultLang;

    if (!this.translatesLoading[lang]) {
      // вызываем загрузку переводов через loader (тот самый, который реализован выше)
      this.translatesLoading[lang] = service.currentLoader.getTranslation(lang).pipe(
        // добавляем переводы в общее хранилище ngx-translate
        // флаг true говорит о том, что объекты необходимо смерджить
        tap(t => service.setTranslation(lang, t, true)),
        map(() => service.translations[lang]),
        shareReplay(1),
        take(1)
      );
    }

    return this.translatesLoading[lang].pipe(
      // вытаскиваем необходимый перевод по ключу и вставляем в него параметры
      map(t => service.parser.interpolate(service.parser.getValue(t, params.key), params.interpolateParams)),
      // при ошибке эмулируем стандартное поведение, когда нет перевода — возвращаем ключ
      catchError(() => of(params.key))
    );
  }
}

Мы в проекте всегда используем только строковые ключи (HOME.TITLE), но ngx-translate также поддерживает ключи в виде массива строк (['HOME', 'TITLE']). Если вы этим пользуетесь, то в обработке catchError необходимо добавить проверку вроде такой of(typeof params.key === 'string' ? params.key : params.key.join('.')).


Используем все вышеописанное

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

export function loaderFactory(scopes: string | string[]): (http: HttpClient) => TranslateLoader {
  return (http: HttpClient) => new MyTranslationLoader(http, scopes);
}

// ...

// app.module.ts
TranslateModule.forRoot({
  useDefaultLang: false,
  loader: {
    provide: TranslateLoader,
    useFactory: loaderFactory(''),
    deps: [HttpClient],
  },
})

// home.module.ts
TranslateModule.forChild({
  useDefaultLang: false,
  extend: true,
  loader: {
    provide: TranslateLoader,
    useFactory: loaderFactory(['HOME', 'HOME.COMMON']),
    deps: [HttpClient],
  },
  missingTranslationHandler: {
    provide: MissingTranslationHandler,
    useClass: MyMissingTranslationHandler,
  },
})

// admin.module.ts
TranslateModule.forChild({
  useDefaultLang: false,
  extend: true,
  loader: {
    provide: TranslateLoader,
    useFactory: loaderFactory(['ADMIN', 'HOME.COMMON']),
    deps: [HttpClient],
  },
  missingTranslationHandler: {/*...*/},
})

Флаг useDefaultLang: false необходим для корректной работы missingTranslationHandler.
Флаг extend: true (добавлен в версии ngx-translate@12.0.0) необходим, чтобы дочерние модули работали с переводами главного модуля.

Также, чтобы избавиться от копирования подобных импортов для каждого модуля с отдельными переводами, можно сделать пару утилит:

export function translateConfig(scopes: string | string[]): TranslateModuleConfig {
  return {
    useDefaultLang: false,
    loader: {
      provide: TranslateLoader,
      useFactory: httpLoaderFactory(scopes),
      deps: [HttpClient],
    },
  };
}

@NgModule()
export class MyTranslateModule {
  static forRoot(scopes: string | string[] = [], config?: TranslateModuleConfig): ModuleWithProviders {
    return TranslateModule.forRoot({
      ...translateConfig([''].concat(scopes)),
      ...config,
    });
  }

  static forChild(scopes: string | string[], config?: TranslateModuleConfig): ModuleWithProviders {
    return TranslateModule.forChild({
      ...translateConfig(scopes),
      extend: true,
      missingTranslationHandler: {
        provide: MissingTranslationHandler,
        useClass: MyMissingTranslationHandler,
      },
      ...config,
    });
  }
}

Такие импорты должны быть только в корневых модулях отдельных частей приложения, далее (чтобы использовать translate пайпу или директиву) надо просто импортировать TranslateModule.

В данный момент (на версии ngx-translate@12.1.2) можно заметить, что при переключении языка, пока происходит скачивание переводов, пайпа translate выводит [object Object]. Это ошибка внутри самой пайпы.


POEditor

Как я упоминал ранее, мы выгружаем наши переводы в сервис POEditor, чтобы там с ними мог работать переводчик. Для этого в сервисе есть соответствующее API:

Оба этих хэндлера работают с полными файлами переводов, но у нас все переводы лежат в разных файлах. Значит, перед отправкой надо все переводы склеить в один файл, а при скачивании разложить по файлам.

Эту логику мы реализовали в python3 скрипте.
В общих чертах он использует тот же принцип объединения переводов, что и в MyTranslateLoader. Разделение происходит по той же схеме, только из большого файла, мы вычитаем куски.

В скрипте реализовано несколько команд:


  • split — принимает на вход файл и директорию, в которой у вас подготовлена структура для переводов, и раскладывает переводы согласно этой структуре (в нашем примере — это директория i18n);
  • join — делает обратное действие: принимает на вход путь до директории с переводами и кладет склеенный json либо в stdout, либо в указанный файл;
  • download — скачивает переводы из POEditor, затем либо раскладывает их по файлам в переданной директории, либо кладет в один файл, переданный в аргументы;
  • upload — соответственно загружает в POEditor переводы либо из переданной директории, либо из переданного файла;
  • hash — считает md5 сумму всех переводов из переданной директории. Пригодится в том случае, если вы подмешиваете хеш в параметры для скачивания переводов, чтобы они не кэшировались в браузере при изменении.

Также там используется пакет argparse, который позволяет удобно работать с аргументами и генерирует --help команду.

Исходник скрипта в рамках статьи рассматривать не будем, потому что он достаточно большой, но вы можете найти его в репозитории по ссылке ниже.
Также в репозитории представлен демо проект, в котором реализовано все, что описано в статье. А также этот проект залит на платформу stackblitz, где можно все потыкать.

GitHub Репозиторий
Демо на Stackblitz


К чему мы пришли

Сейчас такой подход уже используется в VMmanager 6. Конечно, все наши переводы за один раз мы не стали разделять, потому что их достаточно много. Постепенно отделяем их от основного файла, а новый функционал стараемся реализовывать уже с разделением переводов.

На данный момент ленивую подгрузку переводов можно наблюдать в управлении пользователями панели, в настройках панели, а также в недавно реализованных резервных копиях.

А как вы решаете проблему больших файлов локализации? Или почему не стали этого делать?

© Habrahabr.ru