在 Day 6 開發 LINE Bot 結合 OpenWeatherMap API 的過程中,我們採用私有方法將 API 請求處理與資料格式化整合在一起。今天將對 Day 6 的實作進行架構重構,主要目標是建立更完善的環境變數管理機制,同時優化服務模組的職責分離。
本日程式碼的範例連結
傳統的 .env 環境變數
讀取方式雖然簡單直接,但缺乏結構化的存取模式、無法進行設定值的型別驗證,以及難以確保必要環境變數的完整性,這些限制可能導致執行時期的配置錯誤。為了解決這些問題,我們可以建立一套統一的環境變數驗證管理機制。
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;
};
Joi 是一個的 JavaScript 資料驗證函式庫,用來定義和驗證物件的結構規則。透過宣告 Schema 定義,可以確保資料符合預期的格式和約束條件,適合用於環境變數驗證、API 輸入檢查等場景。
pnpm install --save joi
透過服務分組架構,將環境變數依所屬服務進行物件化管理。這種結構化配置能精確控制參數的必填與選填屬性,並在啟動時完成型別檢查與格式驗證,確保在服務運行前發現並修正配置問題,避免執行時才發現環境變數設定錯誤。
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
});
將原先採用蛇形命名(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'),
});
在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
驗證完整訊息格式輸出。
刻意清空部分環境變數來驗證
Joi Schema
的檢查機制是否正常運作
透過環境變數驗證機制,我們能在應用程式啟動的第一時間就捕捉到配置缺失的問題,避免在執行時期才發現環境變數設定錯誤。這種預防性的檢查大幅提升了系統的穩定性和除錯效率。
在 Day 6 的實作中,我們將處理第三方 API 的 fetchWeatherData 函數
放置在 line-webhook.service
中。儘管兩者在業務流程上相互關聯,但天氣資料處理邏輯應該獨立抽離為專屬模組,避免服務職責邊界模糊,同時提升程式碼的可維護性與重用性。
nest g module weather
nest g service weather --no-spec
- 將 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 {}
加上參數驗證,當緯度或經度參數缺失時拋出 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) {
// 略
}
}
匯入
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 {}
移除原先內嵌的天氣處理函數,透過建構子注入 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
管理好,讓天氣服務獨立出來,這些基礎建設重構可以讓之後的開發工作更輕鬆一些。