iT邦幫忙

2025 iThome 鐵人賽

DAY 7
0
Modern Web

Line Bot × NestJS:30 天開發日記系列 第 7

Day 7:Joi 環境變數統一驗證,重構 LINE Bot 天氣服務模組

  • 分享至 

  • xImage
  •  

2025 鐵人賽背景圖

前言

Day 6 開發 LINE Bot 結合 OpenWeatherMap API 的過程中,我們採用私有方法將 API 請求處理與資料格式化整合在一起。今天將對 Day 6 的實作進行架構重構,主要目標是建立更完善的環境變數管理機制,同時優化服務模組的職責分離。

本日程式碼的範例連結


env 透過設定檔統一驗證管理

傳統的 .env 環境變數讀取方式雖然簡單直接,但缺乏結構化的存取模式、無法進行設定值的型別驗證,以及難以確保必要環境變數的完整性,這些限制可能導致執行時期的配置錯誤。為了解決這些問題,我們可以建立一套統一的環境變數驗證管理機制。

Step 1:新增 config/configuration.ts

config/configuration.ts:

export default () => {
  const config = {
    // Line Bot 相關設定
    line: {
      channelAccessToken: process.env.LINE_CHANNEL_ACCESS_TOKEN,
      channelSecret: process.env.LINE_CHANNEL_SECRET,
    },

    // OpenWeatherMap 相關設定
    weather: {
      baseUrl: process.env.WEATHER_BASE_URL,
      apiKey: process.env.WEATHER_API_KEY,
    },

    // 伺服器基本設定
    port: process.env.PORT,
  };

  const { error, value } = configSchema.validate(config, {
    abortEarly: false, // 顯示所有錯誤,而不是第一個錯誤就停止檢查
  });

  if (error) throw new Error(`環境變數驗證錯誤: ${error.message}`);

  return value;
};

Step 2:安裝 joi 套件

Joi 是一個的 JavaScript 資料驗證函式庫,用來定義和驗證物件的結構規則。透過宣告 Schema 定義,可以確保資料符合預期的格式和約束條件,適合用於環境變數驗證、API 輸入檢查等場景。

pnpm install --save joi

Step 3:定義 joi schema 驗證環境變數

透過服務分組架構,將環境變數依所屬服務進行物件化管理。這種結構化配置能精確控制參數的必填與選填屬性,並在啟動時完成型別檢查與格式驗證,確保在服務運行前發現並修正配置問題,避免執行時才發現環境變數設定錯誤。

config/configuration.ts:

const configSchema = Joi.object({
  // Line Bot 相關設定
  line: Joi.object({
    channelAccessToken: Joi.string().required(), // 字串且必填
    channelSecret: Joi.string().required(), // 字串且必填
  }).required(), // 必填

  // OpenWeatherMap 相關設定
  weather: Joi.object({
    baseUrl: Joi.string().uri().required(), // uri 格式且必填
    apiKey: Joi.string().required(), // 字串且必填
  }).required(), // 必填

  // 伺服器基本設定
  port: Joi.number().port().default(3000), // 數字且是有效的端口號,預設值是 3000
});

Step 4:調整 ConfigService 環境變數讀取方式

將原先採用蛇形命名(snake_case)的環境變數讀取方式'LINE_CHANNEL_ACCESS_TOKEN',改為使用物件導向的讀取方式line.channelAccessToken,提供更結構化的配置存取模式。

config/line.config.ts

const lineConfig = (configService: ConfigService): ClientConfig => ({
  channelAccessToken: configService.getOrThrow<string>(
    'line.channelAccessToken',
  ),
  channelSecret: configService.getOrThrow<string>('line.channelSecret'),
}); 

Step 5:在執行階段指定環境變數

package.json添加NODE_ENV環境變數,確保應用程式啟動時就能明確指定執行環境。這種做法可以保證環境變數在應用程式讀取配置之前就已經設定完成。

這部分我們可以藉由 Day 5 配置的 Pino 日誌庫查看不同執行環境下的變化。

package.json:

"scripts": {
    "start:dev": "NODE_ENV=development nest start --watch",
    "start:prod": "NODE_ENV=production node dist/src/main",
    // 略
  },

設置成果驗證

開發環境啟動測試:執行 pnpm run start:dev,可以看到 pino-pritty 美化後的訊息格式

開發環境啟動執行畫面

生產環境啟動測試:執行 pnpm run build 建置完成後,透過 pnpm run start:prod 驗證完整訊息格式輸出。

生產環境啟動執行畫面

Step 6:測試 Joi 環境變數驗證機制的有效性

刻意清空部分環境變數來驗證 Joi Schema 的檢查機制是否正常運作

透過環境變數驗證機制,我們能在應用程式啟動的第一時間就捕捉到配置缺失的問題,避免在執行時期才發現環境變數設定錯誤。這種預防性的檢查大幅提升了系統的穩定性和除錯效率。

Joi 驗證環境變數有必填未給的缺失情形

重構天氣服務模組

在 Day 6 的實作中,我們將處理第三方 API 的 fetchWeatherData 函數放置在 line-webhook.service 中。儘管兩者在業務流程上相互關聯,但天氣資料處理邏輯應該獨立抽離為專屬模組,避免服務職責邊界模糊,同時提升程式碼的可維護性與重用性。

Step 1:輸入 Nest CLI 指令生成 weather module & service

nest g module weather
nest g service weather --no-spec

Step 2:將 HttpModule 移轉到 weather.module.ts

  • 將 HTTP 請求處理從 line-webhook.service 中解耦,統一由 WeatherModule 負責天氣相關的所有外部 API 串接邏輯
  • 透過模組的 exports 屬性對外暴露 WeatherService,實現服務層的依賴注入。其他模組在匯入 WeatherModule 後,可直接注入並使用已實例化的 WeatherService,達到跨模組的服務共享與資源複用

weather/weather.module.ts

import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { WeatherService } from './weather.service';

@Module({
  imports: [HttpModule],
  providers: [WeatherService],
  exports: [WeatherService],
})
export class WeatherModule {}

Step 3:將原先處理天氣服務的業務邏輯移轉至 weather.service.ts

加上參數驗證,當緯度或經度參數缺失時拋出 HTTP 400 錯誤

weather/weather.service.ts

// 略

@Injectable()
export class WeatherService {
  // 略
  async getWeatherByCoordinates(latitude: number, longitude: number) {
    if (!latitude || !longitude) {
      throw new HttpException('請提供緯度和經度', HttpStatus.BAD_REQUEST);
    }

    // 查詢參數
    const queryParams = {
      lat: latitude,
      lon: longitude,
      appid: this.WEATHER_API_KEY,
      units: 'metric',
      lang: 'zh_tw',
    };

    try {
      // 接收處理完的天氣數據
      const responseData = await firstValueFrom(
        this.httpService
          .get(this.WEATHER_API_BASE_URL, { params: queryParams })
          .pipe(
            catchError((err: AxiosError) => {
              return throwError(
                () =>
                  new Error(
                    `Weather API request failed: ${JSON.stringify(err.response?.data)}`,
                  ),
              );
            }),
          ),
      );
      return this.#formateWeatherInfo(responseData.data);
    } catch (error) {
      throw error;
    }
  }

  // 格式化天氣服務的回傳值
  #formateWeatherInfo(weatherData: CurrentWeatherResponse) {
      // 略
  }
}

Step 4: line-webhook.module 匯入天氣服務模組

匯入 WeatherModule 實現模組間的依賴注入

line-webhook/line-webhook.module.ts

// 略
import { WeatherModule } from 'src/weather/weather.module';

@Module({
  imports: [WeatherModule], // 將天氣服務模組匯入
  controllers: [LineWebhookController],
  providers: [LineWebhookService, LineConfigProvider],
})
export class LineWebhookModule {}

Step 5:將原先處理天氣服務的函數移除,改成使用 weather 服務方法呼叫

移除原先內嵌的天氣處理函數,透過建構子注入 WeatherService 並呼叫其方法取得天氣資料

// 略

@Injectable()
export class LineWebhookService {
  // 略
  constructor(
    private readonly weatherService: WeatherService,
  ) {
  }

  private async handleMessageEvent(event: MessageEvent): Promise<void> {
    const messageEventHandlerMap = {
      // 略
      location: async (message) => {
        const { address, longitude, latitude } = message;
        const defaultMsg = `📍 收到位置訊息\n🏠 地址:${address}\n🧭 精度:${longitude}\n🧭 緯度:${latitude}`;

        // 透過 weather 服務呼叫取得第三方天氣服務的回傳字串
        const weatherData = await this.weatherService.getWeatherByCoordinates(
          latitude,
          longitude,
        );

        return `${defaultMsg}\n\n${weatherData}`;
      },
    } satisfies Partial<MessageEventHandlerMap>;

    let replyMessage = '✨ 感謝你的訊息,我們已經收到了!';
    const handler = messageEventHandlerMap[event.message.type];
    if (handler) replyMessage = await handler(event.message);

    await this.lineClient.replyMessage({
      replyToken: event.replyToken,
      messages: [{ type: 'text', text: replyMessage }],
    });
  }
}

經過模組化拆分後,天氣服務的職責邊界更加清晰。未來若需要擴充不同的天氣資料來源或處理邏輯,可直接在 WeatherModule 中進行擴展,其他需要天氣資訊的模組只需透過依賴注入即可複用相關服務,提升程式碼的可維護性與擴展性。

本日結語

今天的工作就像蓋房子時的打地基階段,雖然表面上看起來沒什麼大變化,但實際上讓專案的基礎架構變得更加穩固。把環境變數用 Joi 管理好,讓天氣服務獨立出來,這些基礎建設重構可以讓之後的開發工作更輕鬆一些。


上一篇
Day 6:整合第三方 API 天氣查詢服務
系列文
Line Bot × NestJS:30 天開發日記7
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言