iT邦幫忙

2021 iThome 鐵人賽

DAY 16
0
Modern Web

NestJS 帶你飛!系列 第 16

[NestJS 帶你飛!] DAY16 - Configuration

前一篇我們運用 Dynamic Module 與 dotenv 設計了一個簡單的環境變數管理模組,但什麼是環境變數?又為什麼要做環境變數的管理?那有沒有現成的輪子可以使用?接下來會一一告訴各位!

環境變數

一套系統通常會執行在各個不同環境上,最簡單的區分為:開發環境與正式環境,會這樣區別的原因是我們不希望在測試系統的時候去影響到正式環境的資料,所以會將資料庫等配置分成兩組,也就會有兩組資料庫的連接資訊需要被記錄與使用,這時候要仔細想想該如何做好這些敏感資訊的配置又能快速切換環境,將資訊直接寫在程式碼裡頭絕對是不理想的方式,於是就有 環境變數(Environment Variable) 的概念。

環境變數與一般變數不同的地方在於,環境變數是透過程式碼以外的地方做指定,這種變數可以直接在作業系統上設定,也可以透過指令的方式做設定,以 node.js 為例,可以直接在指令中做配置:

$ NODE_ENV=production node index.js

如此一來,便可以在 process.env 取得環境變數,但如果每次都要這樣輸入與調用實在很難管理,於是就有環境變數檔的概念出現,在 node.js 最常用的就是 .env 檔,其設計方式很簡單,等號的左邊為 key 值,右邊為 value

USERNAME=HAO

在 Nest 中,可以使用官方製作的 ConfigModule 來讀取並管理這些環境變數,當然,要自行設計也可以,透過 Dynamic Module 的概念來實作即可。

安裝 ConfigModule

既然有造好的輪子可以使用,且前一篇也有簡單的帶過如何用 Dynamic Module 實作,故這篇就專門介紹官方實作的套件。該套件並不是內建的,需要額外安裝,透過 npm 進行安裝即可:

提醒:若前一篇有安裝過 dotenv 可以先行移除。

$ npm install @nestjs/config --save

使用 ConfigModule

ConfigModule 也是使用 Dynamic Module 概念設計的,我們只需要在 AppModule 中調用其 forRoot 方法即可使用,以下方為例,修改 app.module.ts

import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AppController } from './app.controller';
import { AppService } from './app.service';

@Module({
  imports: [
    ConfigModule.forRoot()
  ],
  controllers: [
    AppController
  ],
  providers: [
    AppService
  ]
})
export class AppModule {
}

接著,在專案路徑下新增 .env 檔,並設置其內容:

USERNAME=HAO

注意:是新增在專案路徑下,與 package.json 同層級,非 src

修改 app.controller.ts,在 AppControllercontructor 注入 ConfigService,讓 getHello 透過 ConfigServiceget 方法取出 USERNAME 並回傳:

import { Controller, Get } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

@Controller()
export class AppController {
  constructor(
    private readonly configService: ConfigService
  ) {
  }

  @Get()
  getHello() {
    const username = this.configService.get('USERNAME');
    return { username };
  }
}

透過瀏覽器查看 http://localhost:3000
https://ithelp.ithome.com.tw/upload/images/20210516/201193389UCArVHvAh.png

使用自訂環境變數檔

預設狀態下,ConfigModule 會從專案路徑下取 .env 檔來做為環境變數檔,但我們常常會需要為不同環境配置多個檔案,這時候就可以透過自訂環境變數檔來處理。ConfigModuleforRoot 靜態方法有提供 envFilePath 參數來配置指定的 .env 檔,以下方為例,我們去讀取 development.env

import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AppController } from './app.controller';
import { AppService } from './app.service';

@Module({
  imports: [
    ConfigModule.forRoot({
      envFilePath: 'development.env'
    })
  ],
  controllers: [
    AppController
  ],
  providers: [
    AppService
  ]
})
export class AppModule {
}

將剛才的 .env 檔的名稱變更為 development.env,並重新啟動 Nest App,接著透過瀏覽器查看 http://localhost:3000
https://ithelp.ithome.com.tw/upload/images/20210516/20119338muHwuT5EW1.png

還有一種情況是本地測試使用的環境變數與其他環境下測試用的環境變數不相同,這時候可以使用優先權的方式做載入,假設本地端使用的環境變數檔名為 development.local.env,而其他環境下使用的環境變數檔名為 development.env,那就可以在 envFilePath 配置一個陣列,其內容為檔案名稱,越前面的優先權越高。這裡我們先建立一個 development.local.env 並添加下方內容:

USERNAME=local_tester

修改一下 app.module.ts,讓 development.local.env 的優先級別大於 development.env

import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AppController } from './app.controller';
import { AppService } from './app.service';

@Module({
  imports: [
    ConfigModule.forRoot({
      envFilePath: ['development.local.env', 'development.env']
    })
  ],
  controllers: [
    AppController
  ],
  providers: [
    AppService
  ]
})
export class AppModule {
}

透過瀏覽器查看 http://localhost:3000 會看到 usernamelocal_tester
https://ithelp.ithome.com.tw/upload/images/20210516/20119338q6dZCRg075.png

使用工廠函式

有些複雜的情境可以透過工廠模式來處理環境變數,比如:假設有配置 development.env,但有些比較不敏感的資訊可以直接使用預設值,故不需要在檔案裡面做相關配置,只需要在工廠函式裡做配置即可。我們在 src 資料夾下創建一個名為 config 的資料夾,並在裡面建立 configuration.factory.ts
https://ithelp.ithome.com.tw/upload/images/20210516/20119338IjH2kdsqYL.png

修改 configuration.factory.ts 的內容,讓 PORT 採用預設值 3000

export default () => ({
  PORT: process.env.PORT || 3000
});

接著,修改 app.module.ts 的內容,添加 load 參數至 forRoot 靜態方法中,其接受的型別為陣列,內容即工廠函式:

import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import configurationFactory from './config/configuration.factory';

@Module({
  imports: [
    ConfigModule.forRoot({
      envFilePath: ['development.local.env', 'development.env'],
      load: [configurationFactory]
    })
  ],
  controllers: [
    AppController
  ],
  providers: [
    AppService
  ]
})
export class AppModule {
}

注意load 參數接受陣列是因為它可以使用多個工廠函式來處理環境變數。

修改 app.controller.ts,讓 getHello 透過 ConfigServiceget 方法取出 USERNAMEPORT 並回傳:

import { Controller, Get } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

@Controller()
export class AppController {
  constructor(
    private readonly configService: ConfigService
  ) {
  }

  @Get()
  getHello() {
    const username = this.configService.get('USERNAME');
    const port = this.configService.get('PORT');
    return { username, port };
  }
}

透過瀏覽器查看 http://localhost:3000
https://ithelp.ithome.com.tw/upload/images/20210516/20119338y6Yum7cgVT.png

使用工廠函式配置命名空間

由於環境變數在配置的時候是採用 = 來劃分 keyvalue 的,並不能在 value 的地方延伸出下一個層級,所以環境變數層級是 扁平 的,沒有辦法按照類別做歸類,以下方為例,假設環境變數檔 development.local.env 裡面有下方資訊:

DB_HOST=example.com
DB_PASSWORD=12345678
PORT=3000

可以很明顯看出 DB_HOSTDB_PASSWORD 皆屬於資料庫的配置項目,但層級上與其他配置項目卻是相同的,大致上會像這樣:

{
  "DB_HOST": "example.com",
  "DB_PASSWORD": "12345678",
  "PORT": "3000"
}

我們的理想情況會是下方這樣,相同類型的資料被歸在一個 命名空間(Namespace) 裡:

{
  "database": {
    "host": "example.com",
    "password": "12345678"
  },
  "port": "3000"
}

雖然無法在環境變數檔做好這樣的配置,但可以透過工廠函式來做處理。修改 configuration.factory.ts,透過 registerAs 這個函式來指定其命名空間,第一個參數即命名空間,第二個參數為 Callback,回傳的內容即整理好的物件:

import { registerAs } from '@nestjs/config';

export default registerAs('database', () => ({
  host: process.env.DB_HOST,
  password: process.env.DB_PASSWORD
}));

修改 app.controller.ts,從程式碼可以發現,如果要取出命名空間內的某項環境變數的話,透過 . 的方式取得即可,就跟操作 Object 資料一樣:

import { Controller, Get } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

@Controller()
export class AppController {
  constructor(
    private readonly configService: ConfigService
  ) {
  }

  @Get()
  getHello() {
    const database = this.configService.get('database');
    const db_host = this.configService.get('database.host'); // 取得 database 裡的 host
    const port = this.configService.get('PORT');
    return { database, db_host, port };
  }
}

透過瀏覽器查看 http://localhost:3000
https://ithelp.ithome.com.tw/upload/images/20210518/20119338hHKTELiVZP.png

在 main.ts 中使用 ConfigService

有時候會在環境變數檔裡配置 port,要能夠使用環境變數檔裡的 port 作為啟動 Nest 的 port,就必須在 main.ts 做處理,但要怎麼取得 ConfigService 呢?其實 app 這個實例有提供一個 get 方法,可以取出其參照:

import { NestFactory } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  const configService = app.get(ConfigService); // 取得 ConfigService
  const port = configService.get('port');
  await app.listen(port);
}
bootstrap();

環境變數檔之擴展變數

假設有兩個環境變數是存在依賴關係的,具體內容如下:

APP_DOMAIN=example.com
APP_REDIRECT_URL=example.com/redirect_url

可以看出 APP_REDIRECT_URL 包含了 APP_DOMAIN,但環境變數檔並沒有宣告變數的功能,這樣在管理上會比較麻煩,還好 Nest 有實作一個功能來彌補,透過指定 forRoot 物件參數中的 expandVariablestrue 來解析環境變數檔,讓環境變數檔像有變數宣告功能一樣,透過${...} 來嵌入指定的環境變數。下方為 development.local.env 的內容:

APP_DOMAIN=example.com
APP_REDIRECT_URL=${APP_DOMAIN}/redirect_url

修改一下 app.module.ts 的內容:

import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AppController } from './app.controller';
import { AppService } from './app.service';

@Module({
  imports: [
    ConfigModule.forRoot({
      envFilePath: ['development.local.env', 'development.env'],
      expandVariables: true // 開啟環境變數檔變數嵌入功能
    })
  ],
  controllers: [
    AppController
  ],
  providers: [
    AppService
  ]
})
export class AppModule {
}

修改 app.controller.ts,讓 getHello 回傳 APP_DOMAINAPP_REDIRECT_URL

import { Controller, Get } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

@Controller()
export class AppController {
  constructor(
    private readonly configService: ConfigService
  ) {
  }

  @Get()
  getHello() {
    const app_domain = this.configService.get('APP_DOMAIN');
    const redirect_url = this.configService.get('APP_REDIRECT_URL');
    return { app_domain, redirect_url };
  }
}

透過瀏覽器查看 http://localhost:3000
https://ithelp.ithome.com.tw/upload/images/20210518/20119338OxAC5Njy1u.png

全域 ConfigModule

如果 ConfigModule 會在多個模組中使用的話,可以配置 isGlobaltrue 將其配置為全域模組,這樣就不需要在其他模組中引入 ConfigModule

import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AppController } from './app.controller';
import { AppService } from './app.service';

@Module({
  imports: [
    ConfigModule.forRoot({
      envFilePath: ['development.local.env', 'development.env'],
      isGlobal: true
    })
  ],
  controllers: [
    AppController
  ],
  providers: [
    AppService
  ]
})
export class AppModule {
}

小結

環境變數的配置絕對是必要的,在有很多執行環境的情況下,更需要使用像 ConfigModule 這樣的管理模組來降低維護成本。這裡附上今天的懶人包:

  1. 官方有實作一套環境變數管理模組 - ConfigModule
  2. ConfigModule 使用 Dynamic Module 的概念實作。
  3. 透過 .env 檔來配置環境變數。
  4. 透過 envFilePath 來指定自訂的環境變數檔。
  5. envFilePath 可以按照優先權做排序。
  6. 使用工廠函式與 load 搭配來處理環境變數。
  7. 運用工廠函式來配置命名空間,以歸納各個環境變數的類別。
  8. 可以在 main.ts 中取出 ConfigService 來獲得環境變數。
  9. 透過 expandVariables 讓環境變數檔有嵌入變數的功能。
  10. 透過 isGlobalConfigModule 提升為全域模組。

上一篇
[NestJS 帶你飛!] DAY15 - Dynamic Module
下一篇
[NestJS 帶你飛!] DAY17 - Injection Scopes
系列文
NestJS 帶你飛!32

1 則留言

0
mihuartuanr
iT邦新手 5 級 ‧ 2021-11-12 15:18:47

想咨询一下,工廠函式配置命名空間的情况下,是否可以引用.env文档中配置的数据呢?

顺便~官网提供了yaml做配置文件可以创建上下级结构的配置文件

HAO iT邦新手 3 級 ‧ 2021-11-13 12:14:14 檢舉

你好,基本上配置命名空間的時候,就會讓開發者去配置哪些環境變數要歸類在該命名空間下,而這些環境變數會從 .env 中提取的。

謝謝你的補充,我在文中確實沒有特別提到 yaml 的配置方式,對於喜歡 yaml 的開發者而言應該是蠻實用的功能/images/emoticon/emoticon12.gif

我要留言

立即登入留言