Angular i18n 的另外一個選擇 ngx-translate

Angular i18n 的官方作法並不讓人滿意,我後來找到 ngx-translate

先說明一下他的作法,他把這些翻譯好的字詞放到 JSON 翻譯檔去,在執行時就可以透過 http client 去拉取回來作動態的替換。

好處是,程式只要建置一次,不需要針對個別語言再次建置,網頁伺服器那邊也不需要特別寫設定去處理,而且可以做到動態切換語言。壞處是會有額外的 HTTP 請求,會增加流量。

ngx-translate 額外好的地方是,他提供了相應的工具、plugin,相當的方便。

那麼怎麼使用呢?我推薦看這篇【Angular】ngx-translate 多語系實務應用 ,他這篇的缺點是萃取字串的部分是手動,我建議萃取字串的部分可以用原作者 biesbjergngx-translate-extract  ,就不用自己找字串找的太累。

先進行安裝

npm install @ngx-translate/core —save
npm install @ngx-translate/http-loader --save

然後改 app.module.ts

import { TranslateModule, TranslateLoader, TranslateCompiler } from '@ngx-translate/core';
import { TranslateHttpLoader } from '@ngx-translate/http-loader';
import { TranslateMessageFormatCompiler } from 'ngx-translate-messageformat-compiler';
import { HttpClientModule, HttpClient } from '@angular/common/http';

// … 省略 …

// 這主要是告訴 ngx-translate 翻譯檔該怎麼載入,用 TranslateHttpLoader 是表示以 HTTP 方式去下載、載入
export function HttpLoaderFactory(http: HttpClient) {
  return new TranslateHttpLoader(http, './assets/i18n/', '.json');
}

// … 省略 …

// 設定
const translateConfig = {
  defaultLanguage: 'en-US',  // 預設是英文
  loader: {
    provide: TranslateLoader,
    useFactory: HttpLoaderFactory,  // 前面寫的 Factory
    deps: [HttpClient]
  },
  compiler: {
    provide: TranslateCompiler,
    useClass: TranslateMessageFormatCompiler
  }
};

@NgModule({
  // …
  imports: [
    BrowserAnimationsModule,
    BrowserModule,
    TranslateModule.forRoot(translateConfig),  // 模組帶設定
    // …
  ],
  // …
})

在 HTML 裡,使用

// 方法 1
{{ ‘your_translation_key’ | translate }}

// 方法 2
<div [translate]=“‘your_translation_key’”></div>

// 方法 3,適用於字串要用 HTML
<div [innerHTML]="'HELLO' | translate"></div>

他還可以帶參數,只是這邊我看不太懂,暫時也還用不到。

在程式裡,使用

// 先匯入
import {TranslateService} from '@ngx-translate/core';
import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';

// … 省略 …
export class AppComponent implements OnInit {
  l10n__title = '';

  constructor(public translate: TranslateService) {
  }

  ngOnInit(): void {
    const self = this;

    // 讓 ngx-translate-extract 可以抓到字串用的
    _('your_translation_key');

    // 取得字串都要使用 translate.get() 來預先取得
    this.translate.get('your_translation_key').subscribe((res: string) => {
      self.l10n__title = res;
    });
  }
}

這裡要特別說明一點,也是我當初用的時候搞錯的地方,就是上面用到的 your_translation_key 並不是字串,而是你自定義的代碼,裡面有使用 ‘.’ 的時候,在產出的 JSON 裡會變成 nested object ,舉個例子,假設這個 key 是 login.title,那麼 JSON 翻譯檔就會是

{
  "login": {
    "title": ""
  }
}

接下來講 ngx-translate-extract,ngx-translate-extract 的安裝

npm install @biesbjerg/ngx-translate-extract --save-dev

裝好以後,在 package.json 的 scripts 裡加入 (要產生什麼語言,請替換 {en,da,de,fi,nb,nl,sv} 這個字串,以繁體中文來說,是zh-TW或zh-Hant,只有英文跟繁體中文的話,就放 {en-US,zh-TW}。 )

// package.json
...
"scripts": {
  "i18n:init": "ngx-translate-extract --input ./src --output ./src/assets/i18n/template.json --key-as-default-value --replace --format json",
  "i18n:extract": "ngx-translate-extract --input ./src --output ./src/assets/i18n/{en,da,de,fi,nb,nl,sv}.json --clean --format json"
}
...

然後建立存放字串的資料夾:mkdir -p src/assets/i18n,執行 npm run i18n:extract 以後,就可以在 src/assets/i18n 裡看到翻譯檔了。

把敏感設定的內容跟 Angular 程式分離

以我用過 Django / Docker 的經驗,一般都是利用環境變數來作分離,程式在執行時,會去讀取環境變數來當作設定,盡量不依靠設定檔,進而達到 12 factor –  config 的要求。

到了 Angular ,就需要做出調整。Angular 是需要先進行建置產出 javascript / html / css ,再把這些產出的檔案放到網頁伺服器,瀏覽器再去下載,所以完全沒辦法使用環境變數作為設定,必須要把設定寫到檔案裡,再做建置。那設定寫到檔案的話,其實我們都知道,這些敏感設定內容不適合放到 git repository,即使是 private repository,這樣會有安全上的風險。那這樣就兩難了啊?

說來很妙,我是覺得官方早就應該有比較好的作法了,但是,並沒有。不管怎麼樣,我還是找到解決方法了,基本上跟我想的也差不多。

這兩篇文章的作法大同小異,這邊作簡單的說明:

  1. 移除 src/environment/environment.ts
  2. 新增一個腳本,這個腳本會讀取環境變數,並產生一個新的 src/environment/environment.ts
  3. 修改 package.json
    1. 在 scripts 裡增加 config,這個 config 會去執行步驟 2 的腳本,用以產生 environment.ts
    2. 修改 scripts 裡的 start / build,在執行原來的指令前,先執行 npm run config 去產生 environment.ts

下面兩個片段是關鍵的改動部分:

// set-env.ts
// 放在專案根目錄下
import { writeFile } from 'fs';
import { name, version } from './package.json';


// Configure Angular `environment.ts` file path
const targetPath = './src/environments/environment.ts';


// 載入 colors 模組,主要是用來顯示
const colors = require('colors');


// 可以把敏感內容放到 .env 檔案裡,nodejs 會把內容讀出來,放到環境變數裡
// 也可以在執行 npm run 之前,先設定好環境變數
const result = require('dotenv').config();
if (result.error) {
  // throw result.error;
}


// 原來 environment.ts 裡的內容,把該替換的,用 template literals 來替換

// https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Template_literals
const envConfigFile = `export const environment = {
  production: ${process.env.PRODUCTION},
  VERSION: '${version}’,
};

`;


// 顯示
console.log(colors.magenta('The file `environment.ts` will be written with the following content: \n'));
console.log(colors.grey(envConfigFile)); writeFile(targetPath, envConfigFile, (err) => {
  if (err) {
    throw console.error(err);
  } else {
    console.log(colors.magenta(`Angular environment.ts file generated correctly at ${targetPath} \n`));
  }
});
// package.json
// 只摘錄重要的部分
{
  “scripts”: {
    "config": "ts-node -O '{\"module\": \"commonjs\"}' ./set-env.ts",
    "start": "npm run config && ng serve",
    "build": "npm run config && ng build”
  }
}

Angular i18n

Angular 官方提供的套件以及教學:Angular Internalization (i18n)

文章裡一大堆,簡單整理如下:

  1. 安裝:ng add @angular/localize
    • 這個步驟會幫你在 polyfills.ts 裡加入必要的 import
  2. 使用,這部份分為 HTML 跟程式
    • HTML,在需要多國語言的標籤加入 i18n 的屬性
      • <span i18n>Hello world</span>
      • <span i18n=“@@span_hello”>Hello world</span> ,用 i18n=“@@span_hello” 的好處是,產出的翻譯檔裡不會是一個隨意的數字,而是一個對開發者來說比較明確的名稱。
      • <input i18n-placeholder> 這個是有些屬性本身需要多國語言的,就在前面加上 “i18n-“
    • 程式,字串前方加上 $localize ,並且改用 back quote,例如:$localize`hello world`
  3. 萃取,用 ng xi18n –output-path src/i18n 就可以把 HTML 裡有標 i18n 的字串萃取到 src/i18n/messages.xlf 裡
  4. 翻譯,先把上個步驟取得的 messages.xlf 複製為 messages.zh_Hant.xlf ,再去編輯。這邊提供一個簡易的網頁工具 – tiny-translator,在處理上會方便很多。開啟以後,要先建立專案,然後上傳 .xlf 檔案,接著就可以進行翻譯了。翻譯好,再下載下來即可。
  5. 合併,程式開發中難免會有增刪,每次用 xi18n 基本上都是重新萃取一次,等於是又要再搞一次合併的功夫,這太累。tiny-translator 的作者有提供另外一個工具 – xliffmerge 
    • 安裝:npm install -g ngx-i18nsupport
    • 在 package.json 的 scripts 裡加入 “extract-i18n”: “ng xi18n –output-path src/i18n && xliffmerge –profile xliffmerge.json en de” ,裡面的 en, de 等 locale 請依照自己的需求作調整
    • 新增 xliffmerge.json ,這個檔案請參考後面。
    • 使用 npm run extract-i18n 就可以自動萃取字串並且作合併了。
  6. 專案的建置,主要是修改 angular.json,有三個部分:
    • projects / your_project_name 加入 “i18n”: {“sourceLocale”: “en”, “locales”: {“de”: “src/i18n/messages.de.xlf”}}
    • projects / your_project_name / architect / build / configurations 裡加入 “de”: {“localize”: [“de”]}
    • projects / your_project_name / architect / serve / configurations 裡加入 “de”: {“browserTarget”: “ng-hosting:build:de”}
  7. 要執行 ng build / ng serve 時,就可以用
    • ng build –configuration=production,de
    • ng serve –configuration=de
  8. 補充一個我覺得很重要的部分,就是一個語言要建置一次,所以一般的佈署會是這樣的,建置好 zh ,放在 zh/ 目錄下,建置好 de,放在 de/ 目錄下,然後在 nginx/apache 的設定裡去依照 header 的 language 去導向到對應的目錄去。這篇 Deploying an i18n Angular app with angular-cli 的後面有教怎麼去設定 apache / nginx。

看到這邊,你可能會想,那程式裡標上 $localize 的字串呢?嗯,ng xi18n 並不會把這些字串萃取出來 (issue),所以這部份得自己手動處理 😣 

P.S. 用 ngx-i18nsupport 的 tooling 可以把上面講的簡化掉,像是加入 npm package、在 package.json 加入 extract-i18n 、在 angular.json 加入設定等等,一次就搞定了,我是已經用了才看到這個 tooling ,有點相見恨晚。

// xliffmerge.json
{
  "xliffmergeOptions": {
    "srcDir": "src/i18n",
    "genDir": "src/i18n",
    "i18nFile": "messages.xlf",
    "i18nBaseFile": "messages",
    "i18nFormat": "xlf",
    "encoding": "UTF-8",
    "defaultLanguage": "en",
    "languages": ["en", "de"],
    "removeUnusedIds": true,
    "supportNgxTranslate": false,
    "ngxTranslateExtractionPattern": "@@|ngx-translate",
    "useSourceAsTarget": true,
    "targetPraefix": "",
    "targetSuffix": "",
    "beautifyOutput": false,
    "preserveOrder": true,
    "allowIdChange": false,
    "autotranslate": false,
    "apikey": "",
    "apikeyfile": "",
    "verbose": false,
    "quiet": false
  }
}

Angular 嵌入 redoc API 文件

原本是打算把 redoc-cli 產生出來的文件直接嵌進去的,可是這樣子在文件有變動時,就會又要再做一次產生、嵌入,這樣不太好。最好還是可以自動依據寫好的 OpenAPI specification 來自動產出,這才是比較好的作法。

第一個待解決的問題是怎麼把 OpenAPI specification JSON 放到 Angular 專案裡,並且可以讀出來使用。關於這個,我是找到 How To Read Local JSON Files In Angular 這篇文章,方法挺簡單的,就把 json 檔案丟到 src/assets 下,然後直接在程式裡用 import 。

// src/app/xxx/xxx.component.ts
import SampleJson from '../../assets/SampleJson.json’;

// 後續程式碼就直接引用 SampleJSON 即可。

但修改完,TypeScript compiler 會有錯誤訊息,這得要改 tsconfig.json 在 compilerOptions 裡加入 resolveJsonModule 跟 esModuleInterop

{ "compilerOptions": { "resolveJsonModule": true, "esModuleInterop": true } }

第二個問題是,angular template 裡不能直接寫 script tag,這個可以實作 AfterViewInit ,然後用 DOMElement 來動態插入 (參考來源:https://stackoverflow.com/questions/38088996/adding-script-tags-in-angular-component-template/43559644)。細節的說明,我就直接寫在下面程式的註解裡。

import {
  Component,
  OnInit,
  ViewEncapsulation,
  ElementRef,
  AfterViewInit } from '@angular/core’;
// (1) 剛剛前面提到的,引用 OpenAPI specification JSON
import APIJson from '../../assets/api.json';

@Component({
  selector: 'app-documentation',
  templateUrl: './documentation.component.html',
  styleUrls: ['./documentation.component.scss'],
  encapsulation: ViewEncapsulation.None
})
export class DocumentationComponent implements OnInit, AfterViewInit {  // (2) Component 要實作 AfterViewInit 這介面

  constructor(private elementRef: ElementRef) { }  // (3) 引入 ElementRef ,Angular 會幫忙作動態注入

  ngAfterViewInit(): void {
    // (4) 這裡就是在頁面載入後要作的事情
    this.insertSpec();
    this.insertScript();
  }

  insertScript(): void {
    // (5) 插入 script element,這裡要載入 redoc 的腳本
    const redocScript = document.createElement('script');
    redocScript.type = 'text/javascript';
    redocScript.src = 'https://unpkg.com/redoc@next/bundles/redoc.standalone.js’;  // (6) 這腳本的網址是閱讀 redoc-cli 的原始碼以後取得的。
    this.elementRef.nativeElement.appendChild(redocScript);

    // (7) 插入另外一個 script element,這裡主要是執行 redoc,讓 redoc 能解析 json 並顯示出文件。
    const initScript = document.createElement('script');
    initScript.type = 'text/javascript';
    initScript.textContent = `
    const __redoc_state=${JSON.stringify(APIJson)};
    var container = document.getElementById('redoc');
    Redoc.hydrate(__redoc_state, container);
    `;  // (8) 字串要允許多行,可以使用 backquote ` https://stackoverflow.com/questions/35225399/multiline-string
    this.elementRef.nativeElement.appendChild(initScript);
  }

  insertSpec(): void {
    // (9) 插入 element ,讓腳本知道要根據哪個 element 來作處理。
    let s = document.createElement('redoc');
    s.setAttribute('spec-url', 'assets/api.yml');
    this.elementRef.nativeElement.appendChild(s);
  }

  ngOnInit(): void {
  }
}

至此就大功告成啦。

P.S. redoc 在 1.x 時,是有支援 Angular 的,但到了 2.x ,就改用 react 了。

NgRx

因為 NgRx 是用 Redux 的概念,所以先看 Redux

看完大致可以理解,就是 design pattern 裡講的 state pattern。大部份 Redux 例子都是搭配 React,看的還是霧煞煞,所以找 NgRx 搭配 Angular 的例子來看。

我覺得這三篇的例子蠻清楚的,很容易可以了解現有的 angular 碰到 ngrx 時,要怎麼結合。

原本 angular 的一個頁面是 component html 跟 component code ,現在加上 ngrx 以後,會使用 store 儲存狀態,寫 reducer 來處理狀態。當有事件觸發時,就使用 store.dispatch 去發送 action,reducer 在收到以後,會依據 action 來處理狀態,然後回傳狀態。這時候頁面會因為 binding ,而反映出新的狀態結果。

這三篇雖然清楚,但已經舊了,我在試驗時,就碰到兩個問題:

  1. 找不到 StoreModule.provideStore() 這個方法,這個已經改為 StoreModule.forRoot()
  2. Observable 找不到,要解決這個問題,除了得裝 rxjs 之外,還要裝 rxjs-compat 讓 rxjs 向前相容。

文章有提到 Redux DevTools 這個工具,可以從 Firefox addons/Chrome store NgRx 安裝,安裝以後,專案程式那邊也需要調整。調整的部份可以參考 @ngrx/store-devtools ,安裝好,修改 app.module.ts 之後,就可以使用了。使用的方式是先開啟專案的網址,然後再開 developer tools,這時會看到有個 Redux 分頁,試著觸發一些事件看看,這邊就會出現發送的 action 以及改變前後的狀態了。

compodoc

前兩天看到這篇:你寫的文件別人看得懂嗎?:compodoc ,所以想來試試看。

因為想放到 Jenkins 裡去自動產生 Angular App 的文件,所以想用 docker 來避免在 Jenkins 主機上安裝 compodoc 。

首先找到有沒有已經寫好的 docker image,很幸運,找到 sdvplurimedia/lea-pulse-compodoc – Docker Hub ,github repository 在 SDV-Plurimedia/docker-images: DockerFiles public ,從 Dockerfile 可以看出就只是簡單的繼承自 nodejs 的 image ,然後用 yarn 安裝 compodoc。

所以用 docker pull 拉到本地端,就可以用 docker run 來執行了,先看看有什麼參數:

docker run -it sdvplurimedia/lea-pulse-compodoc:latest compodoc --help

然後在專案目錄下建立 doc 目錄,用 docker run 將專案目錄掛載到 /src,輸出目錄掛到 /src/documentation,再執行:

docker run -v ${PWD}:/src -v ${PWD}/doc:/src/documentation -it sdvplurimedia/lea-pulse-compodoc:latest /bin/sh -c 'cd /src && compodoc -p tsconfig.json'

就可以在執行,並在 documentation 目錄裡找到 HTML 文件了。