Обслуживание многоязычного приложения Angular с помощью ExpressJS

Ранее мы вывели файл locales, содержащий все языковые ресурсы, чтобы подготовиться к их изоляции. Сегодня мы сосредоточимся на обслуживании через NodeJs и сервер ExpressJS. Мы будем обслуживать различные языки, используя куки, а позже полагаясь на URL. Но прежде чем мы углубимся в работу, еще одно преимущество нашего класса ресурсов.

Доступ к ресурсам из любого места

Из коробки Angular предоставляет адаптер $localize, но он ограничен использованием i18n. Наш класс res можно использовать, даже если локализация не задана, а language.ts используется напрямую. Мы уже использовали его для перехвата ошибок и сообщений тостов. Вот фрагмент того, как его можно свободно использовать:

// using the res class for any linguistic content

// extreme case of a warning when an upload file is too large
const size = Config.Upload.MaximumSize;
this.toast.ShowWarning(
  // empty code to fallback
  '',
  // fallback to a dynamically created message
  { text: Res.Get('FILE_LARGE').replace('$0', size)}
);

// where FILE_LARGE in locale/language is:
// FILE_LARGE: 'The size of the file is larger than the specified limit ($0 KB)'
Войти в полноэкранный режим Выход из полноэкранного режима

Примечание: Исходные файлы находятся в StackBlitz, но они не обязательно будут работать в StackBlitz, потому что среда слишком строгая.

Файл JavaScript языка

В предыдущей статье мы рассмотрели основы того, как внедрить внешний файл конфигурации в Angular, и пришли к выводу, что лучший способ — поместить файл javascript в индексный заголовок. На данном этапе у нас нет четкой модели, к которой нам нужно приводить, поэтому давайте начнем с простого тега script в index.html:

<script src="locale/language.js" defer></script>

Чтобы это работало в разработке, мы добавим актив в angular.json.

// angular.json options/assets
{
    "glob": "*.js",
    "input": "src/locale",
    "output": "/locale"
}
Вход в полноэкранный режим Выход из полноэкранного режима

Чтобы использовать коллекцию ключей JavaScript, мы объявляем в нашем typescript. Класс res — единственное место, использующее ключи, а app.module — единственное место, использующее идентификатор локали. Поэтому давайте поместим все в res class:

// in res class, we declare the keys and locale_id
declare const keys: any;
declare const EXTERNAL_LOCALE_ID: string;

export class Res {
  // export the locale id somehow, a property shall do
  public static LOCALE_ID = EXTERNAL_LOCALE_ID;

  // using them directly: keys
  public static Get(key: string, fallback?: string): string {
    if (keys[key]) {
        return keys[key];
    }
    return fallback || keys.NoRes;
  }

// ...
}

// in app.module, we import the locale id
// ...
providers: [{provide: LOCALE_ID, useValue: Res.LOCALE_ID }]
Вход в полноэкранный режим Выход из полноэкранного режима

Пакет локали Angular

Но как нам импортировать локаль из пакетов Angular? Самый простой и понятный способ — сделать точно так же, как описано выше. Добавьте скрипт и сделайте ссылку в angular.json. Если мы хотим иметь несколько локалей, то включаем их все в активы:

{
  // initially, add them all
  "glob": "*.js",
  "input": "node_modules/@angular/common/locales/global",
  "output": "/locale"
}
Вход в полноэкранный режим Выйти из полноэкранного режима

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

<script src="locale/ar-JO.js" defer></script>

Другой заключается в том, чтобы позволить языковому файлу создать тег. Помните, что этот файл в конечном итоге будет вызван на серверной платформе, поэтому мы хотим быть, по крайней мере, готовыми к этому.

// in browser platform
const script = document.createElement('script');
script.type = 'text/javascript';
script.defer = true;
script.src = 'locale/ar-JO.js';
document.head.appendChild(script);

// in server platform, we'll add this later
// require('./ar-JO.js');
Вход в полноэкранный режим Выход из полноэкранного режима

Давайте сделаем один рефактор, прежде чем мы перейдем к обслуживанию файлов. Создадим один JavaScript-ключ и разделим его на пространства имен, чтобы 10x-щики не троллили нас, не то чтобы это имело значение.

// the locales/language.js file

const keys = {
  NoRes: '',
  // ...
};
// combine and namespace
// window will later be global
window.cr = window.cr || {};
window.cr.resources = {
  language: 'en',
  keys,
  localeId: 'en-US'
};
Вход в полноэкранный режим Выход из полноэкранного режима

cr — сокращение от cricket. Кодовое имя нашего проекта.

В нашем классе res:

// in res class remove imported keys from /locales/language.ts

declare const cr: {
  resources: {
    keys: any;
    language: string;
    localeId: string;
  };
};

export class Res {
  // to use in app.module
  public static get LocaleId(): string {
    return cr?.resources.localeId;
  }

  // add a private getter for keys
  private static get keys(): any {
    return cr?.resources.keys;
  }
  // use it like this this
  public static Get(key: string, fallback?: string): string {
    const keys = Res.keys;
    // ...
  }
  // ...
}
Вход в полноэкранный режим Выход из полноэкранного режима

Файлы для конкретного языка

Теперь мы создадим два файла в папке locale, готовых к отправке: cr-en и cr-ar. cr-ar содержит добавленный скрипт локали ar-JO, а cr-en не содержит ничего особенного. Мы стараемся не конфликтовать с пакетами Angular, поскольку ar.js и en.js уже существуют.

(Упомянутый ниже en-AE приведен только для примера, мы не собираемся его использовать).

Сейчас мы собираем со следующими настройками angular.json:

"projects": {
    "cr": {
      "architect": {
        "build": {
          "options": {
            "resourcesOutputPath": "assets/",
            "index": "src/index.html",
            "assets": [
              // ...
              // add all locales in dev
              {
                "glob": "*.js",
                "input": "src/locale",
                "output": "/locale"
              },
              {
                // add angular packages in dev, be selective
                // en-AE is an example
                "glob": "*(ar-JO|en-AE).js",
                "input": "node_modules/@angular/common/locales/global",
                "output": "/locale"
              }
            ]
          },
          "configurations": {
            "production": {
              // place in client folder
              "outputPath": "./host/client/",
              // ...
              // overwrite assets
              "assets": [
                // add only locales needed
                // names clash with Angular packages, prefix them
                {
                  "glob": "*(cr-en|cr-ar).js",
                  "input": "src/locale",
                  "output": "/locale"
                },
                {
                  // add angular packages needed
                  "glob": "*(ar-JO|en-AE).js",
                  "input": "node_modules/@angular/common/locales/global",
                  "output": "/locale"
                }
              ]
            }
          }
        },
        // server build
        "server": {
          "options": {
            // place in host server
            "outputPath": "./host/server",
            "main": "server.ts"
            // ...
          },
          // ...
        }
      }
    }
Войти в полноэкранный режим Выйти из полноэкранного режима

Давайте построим.

Приложение только для браузера

Начинаем с Angular builder:

ng build --configuration=production

Это генерирует выходной файл host/client. Внутри этой папки у нас есть папка locale, которая содержит все файлы javascript, которые мы включили в assets:

  • /host/client/locale/cr-en.js
  • /host/client/locale/cr-ar.js
  • /host/client/locale/ar-JO.js.

Индексный файл содержит ссылку на locale/language.js, теперь наша задача — переписать этот URL в нужный языковой файл. Создание нескольких индексных файлов — это, безусловно, самый крайний вариант и лучшее решение. Но сегодня мы просто выполним перезапись с помощью маршрутизации ExpressJS.

В нашем основном файле server.js нам нужно создать промежуточное ПО для определения языка, пока что из cookie. Имя cookie можно легко потерять, поэтому сначала я хочу создать конфигурационный файл, в котором я размещу все мои подвижные части, это личное предпочтение, разработчики бэкенда, вероятно, имеют другое решение.

// server/config.js
const path = require('path');
const rootPath = path.normalize(__dirname + '/../');

module.exports = {
  env: process.env.Node_ENV || 'local',
  rootPath,
  // we'll use this for cookie name
  langCookieName: 'cr-lang',
  // and this for prefix of the language file
  projectPrefix: 'cr-'
};
Вход в полноэкранный режим Выход из полноэкранного режима

Языковое промежуточное ПО:

// a middleware to detect language

module.exports = function (config) {
  return function (req, res, next) {
    // check cookies for language, for html request only
    res.locals.lang = req.cookies[config.langCookieName] || 'en';

    // exclude non html sources, for now exclude all resources with extension
    if (req.path.indexOf('.') > 1) {
      next();
      return;
    }

    // set cookie for a year
    res.cookie(config.langCookieName, res.locals.lang, {
      expires: new Date(Date.now() + 31622444360),
    });

    next();
  };
};
Вход в полноэкранный режим Выход из полноэкранного режима

Это промежуточное ПО просто обнаруживает языковой файл cookie, устанавливает его в свойство response locals, а затем сохраняет язык в файлах cookie.

Основной сервер:

const express = require('express');

// get the config
const config = require('./server/config');

// express app
const app = express();

// setup express
require('./server/express')(app);

// language middleware
var language = require('./server/language');
app.use(language(config));

// routes
require('./server/routes')(app, config);

const port = process.env.PORT || 1212;
// listen
app.listen(port, function (err) {
  if (err) {
    return;
  }
});
Вход в полноэкранный режим Выход из полноэкранного режима

Маршруты для нашего приложения:

// build routes for browser only solution
const express = require('express');

// multilingual, non url driven, client side only
module.exports = function (app, config) {

  // reroute according to lang, don't forget the prefix cr-
  app.get('/locale/language.js', function (req, res) {
    res.sendFile(config.rootPath +
        `client/locale/${config.projectPrefix}${res.locals.lang}.js`
    );
    // let's move the path to config, this becomes
    // res.sendFile(config.getLangPath(res.locals.lang));
  });

  // open up client folder, including index.html
  app.use(express.static(config.rootPath + '/client'));

  // serve index file for all other urls
  app.get('/*', (req, res) => {
    res.sendFile(config.rootPath + `client/index.html`);
  });
};
Вход в полноэкранный режим Выход из полноэкранного режима

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

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

module.exports = {
  // ...
  getLangPath: function (lang) {
    return `${rootPath}client/locale/${this.projectPrefix}${lang}.js`;
  }
};
Вход в полноэкранный режим Выход из полноэкранного режима

Серверная платформа

Возвращаясь к предыдущей статье: Загрузка внешних конфигураций в Angular Universal, мы изолировали сервер, и я специально упомянул одно из преимуществ — обслуживание многоязычного приложения с помощью одной сборки. Сегодня мы воспользуемся этим. При сборке для SSR, используя:

ng run cr:server:production

Файл, создаваемый в папке host/server, — main.js. Ниже приведены маршруты, сделанные с учетом SSR (в StackBlitz это host/server/routes-ssr.js).

const express = require('express');

// ngExpressEngine from compiled main.js
const ssr = require('./main');

// setup the routes
module.exports = function (app, config) {
  // set engine, we called it AppEngine in server.ts
  app.engine('html', ssr.AppEngine);
  app.set('view engine', 'html');
  app.set('views', config.rootPath + 'client');

  app.get('/locale/language.js', function (req, res) {
    // reroute according to lang
    res.sendFile(config.getLangPath(res.locals.lang));
  });

  // open up client folder
  app.use(express.static(config.rootPath + '/client', {index: false}));

  app.get('/*', (req, res) => {
    // render our index.html
    res.render(config.rootPath + `client/index.html`, {
      req,
      res
    });
  });
};
Вход в полноэкранный режим Выйти из полноэкранного режима

Исключите файл index.html в статическом промежуточном ПО, чтобы заставить корневой URL проходить через движок Angular.

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

// in javascript, an old trick we used to make use of the same script on both platforms
if (window == null){
    exports.cr = cr;
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Если посмотреть на скрипты Angular Locale, то они обернуты следующим образом:

// a better trick
(function(global) {
  global.something = 'something';
})(typeof globalThis !== 'undefined' && globalThis || typeof global !== 'undefined' && global ||
   typeof window !== 'undefined' && window);
Вход в полноэкранный режим Выйти из полноэкранного режима

Это лучше. Почему я не подумал об этом раньше? Ну да ладно. Давайте перепишем наши языковые файлы, чтобы их можно было обернуть вызовом функции:

// locale/language.js (cr-en and cr-ar) make it run on both platforms
(function (global) {
  // for other than en
  if (window != null) {
    // in browser platform
    const script = document.createElement('script');
    script.type = 'text/javascript';
    script.defer = true;
    script.src = 'locale/ar-JO.js';
    document.head.appendChild(script);
  } else {
    // in server platform
    require('./ar-JO.js');
  }

  const keys = {
    NoRes: '',
    // ...
  };

  global.cr = global.cr || {};
  global.cr.resources = {
    language: 'ar',
    keys,
    localeId: 'ar-JO',
  };
})(
  (typeof globalThis !== 'undefined' && globalThis) ||
    (typeof global !== 'undefined' && global) ||
    (typeof window !== 'undefined' && window)
);
Войти в полноэкранный режим Выход из полноэкранного режима

В языковом промежуточном ПО требуйте файл.

module.exports = function (config) {
  return function (req, res, next) {
    // ... get cookie

    // if ssr is used
    require(config.getLangPath(res.locals.lang));

    // ... save cookie
  };
};
Войти в полноэкранный режим Выход из полноэкранного режима

Запуск сервера. Мы сталкиваемся с двумя проблемами:

  • app.module загружается немедленно, до того, как произойдет какая-либо маршрутизация. Он ищет LOCAL_ID в global.cr.resources, который еще нигде не был загружен.
  • Определив значение по умолчанию, локаль не меняется на сервере динамически, так как app.module уже запущен с первой локалью.

Чтобы динамически изменить LOCALE_ID на сервере — без перезапуска сервера, погуглил и нашел простой ответ. Реализация useClass для провайдера в app.module. Если посмотреть на код, сгенерированный через SSR, то это изменение устранило прямое обращение к LocalId, и превратило его в оператор void 0.

exports.Res = exports.LocaleId = void 0;

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

// in Res class, extend the String class and override its default toString
export class LocaleId extends String {
    toString() {
        return cr.resources.localeId || 'en-US';
    }
}

// and in app.module, useClass instead of useValue
@NgModule({
  // ...
  providers: [{ provide: LOCALE_ID, useClass: LocaleId }]
})
export class AppModule {}
Вход в полноэкранный режим Выйти из полноэкранного режима

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

  • NodeJS требует файлы один раз. Если файл потребуется снова, он будет извлечен из кэша, и функция в нем не будет запущена. Таким образом, на серверной платформе переключение языка работает в первый раз, но переключение обратно на ранее загруженный язык не обновляет локаль.

Чтобы исправить это, нам нужно сохранить различные коллекции global.cr в явных ключах, и в языковом промежуточном ПО назначить наш NodeJS global.cr.resources нужной коллекции. В наших языковых JavaScript-файлах добавим явное назначение:

// in cr-en cr-ar, etc,
(function (global) {

  // ...
  // for nodejs, add explicit references
  // global.cr[language] = global.cr.resources
  global.cr.en = global.cr.resources;

})(typeof globalThis !== 'undefined' && globalThis || typeof global !== 'undefined' && global ||
    typeof window !== 'undefined' && window);
Войти в полноэкранный режим Выйти из полноэкранного режима

В нашем языковом промежуточном ПО каждый раз, когда запрашивается новый язык, он добавляется в глобальную коллекцию. Затем мы извлекаем нужный нам язык:

// language middleware
module.exports = function (config) {
  return function (req, res, next) {
    // ...
    require(config.getLangPath(res.locals.lang));

    // reassign global.cr.resources
    global.cr.resources = global.cr[res.locals.lang];

    // ...
  };
};
Войти в полноэкранный режим Выход из полноэкранного режима

Запустив сервер, я не получаю никаких ошибок. При просмотре с отключенным JavaScript загружается язык по умолчанию. Изменяя куки в браузере несколько раз, он работает как ожидалось.

Это было не так сложно, не так ли? Давайте перейдем к языку на основе URL.

Применение на основе URL

Для сайтов, основанных на контенте, и публичных веб-сайтов выбор языка по URL имеет решающее значение. Чтобы настроить наш сервер на перехват выбранного языка по URL вместо куки, возвращайтесь на следующей неделе. 😴

Спасибо, что дочитали до конца очередного эпизода. Дайте мне знать, если я поднял бровь.

РЕСУРСЫ

  • Динамическое изменение LocaleId в Angular
  • Проект StackBlitz
  • Angular $localize
  • Локализация ответа ExpressJS

СООТВЕТСТВУЮЩИЕ ПОСТЫ

Загрузка внешних конфигураций в Angular Universal

Ловля и отображение ошибок пользовательского интерфейса с помощью тостовых сообщений в Angular

Обслуживание многоязычного приложения Angular с помощью ExpressJS, Angular, Design — Sekrab Garage

Твистинг локализации Angular

garage.sekrab.com

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