こんにちは。GMSのバックエンドエンジニアの@yuyasan-goです。
Node.js のWeb Frameworkとしては古株ではあるものの、利用されている方もまだまだ多いExpress関連についてです。
軽量フレームワークであり新規導入のしやすさがメリットですが、サービスも成長して徐々にコードベースも大きくなるにつれて、エラー処理や同様処理が増えてくるケースも目にすることも多くなってくるのではないでしょうか。
今日はそういった状態になってきた際の「DRY原則に効く!Node.js+ExpressによるAPIのMiddleware化した」話について紹介します。
全体構成
全体構成の概要図はこのような形となっています。
バックエンドの主な技術スタックは、Node.js + Express + MySQL(Amazon Aurora) + DynamoDBを採用しています。
構成の主な機能を簡単に紹介します。
- API (Node.js + Express)
- IoTへのデータを送受信する機能を主にするWeb Server
- IoT Server
- IoTからのデータをAmazon SQSに送信
- Amazon SQS
- IoT Serverからメッセージを受け取るキューイングサービス
- event monitor (Batch)
- Amazon SQSに送られたIoTデータをDBに保存
なぜやる?(技術的負債)
既存APIの処理概要
IoTへ送受信するためのAPI はNode.js + Expressで構築しています。
具体的にはIoTへのコマンド送信やデバイス情報の取得など、他にも多くのAPIを提供しています。
例)
コマンド送信系
- POST XXX/{deviceId}/command
- GET XXX/{deviceId}/command
デバイス情報系
- POST XXX/devices
- GET XXX/devices
既存APIの実装時の問題点
最小限の機能を提供するWeb FrameworkであるExpressは、サービス初期はスピーディに実装できることが利点の一つです。
一方、徐々に機能が追加されていくにあたり、構成などのアーキテクチャは自由度の高さが技術負債となってしまうケースもあります。
現状のAPIはそこまで深刻な状況ではなかったのですが、同様の処理などが分散していて開発効率やメンテナンスコストが増加傾向にありました。
問題点(技術的負債)の解消 ←今回のmiddleware化
上記の負債に対して、今回は以下の点を共通化することでAPI全体の最適化を行いました。
Expressにはmiddleware層を追加できるので、そのレイヤーに共通化処理を移行します。
- APIリクエスト時の共通処理
- エラー処理
共通化の前後
変更前
変更後
GET: async (req) => {
let from, to;
if (req.query.from && req.query.to) {
from = moment(req.query.from as string).utc();
to = moment(req.query.to as string).utc();
} else if (!req.query.from && !req.query.to) {
to = moment().utc();
from = moment(to).utc().subtract(7, 'days');
} else {
return {code: 400, msg: 'from and to must be a pair.'};
}
if (to.diff(from, 'days', true) > 7) {
return {code: 400, msg: 'The length of the specified time must be up to 7 days.'};
}
if (from.isAfter(to)) {
return {code: 400, msg: 'The beginning of the specified time must be up to now.'};
}
return;
}
API Layerからvalidationへ処理を移行。
GET: async (req) => {
const deviceId = Number(path.basename(path.parse(req.path).dir));
const device = await v2DbDevice.deviceModel.findById(deviceId).then(res => res[0]); // TODO: Remove [0]
if (!device || !req.user.bcList.includes(device.bcId)) return {code: 404, msg: 'The Device is not found.'};
return;
},
Service Layerからauthorizationへ処理を移行。
deviceCommandRouter.get('/:deviceId/command', async (req: Request, res: Response) => {
try {
let from: moment.Moment, to: moment.Moment;
if (req.query.from && req.query.to) {
from = moment(req.query.from as string).utc();
to = moment(req.query.to as string).utc();
} else if (!req.query.from && !req.query.to) {
to = moment().utc();
from = moment(to).utc().subtract(7, 'days');
}
const result = await listDeviceCommand({
deviceId: Number(req.params.deviceId),
from: from!,
to: to!,
});
export async function listDeviceCommand(
input: {
deviceId: number;
from: moment.Moment;
to: moment.Moment;
}
): Promise<{code: number; body: {data: DeviceCommand[]}}> {
const data = await db.listDeviceCommand(input);
return {code: 200, body: {data}};
}
APIとService Layerは本来の責務の処理のみとなった。
変更前後のポイントは
- 変更前
- APIによって API LayerやService Layerにバラバラに実装
- 変更後
- validationとauthorizationをmiddlewareにまとめて実装
まとめ
メリット
今回の技術負債解消によるメリットは
- 新規APIを追加する場合や保守改修時のメンテナンス性がアップ
- 既存APIのAPI Layerも本来の責務の処理のみになり見通しアップ
の2点が大きなポイントです。
MiddlewareとAPIそれぞれが見通しよくなったことで、開発効率もアップするでしょう。
デメリット
但しmiddleware化したことで、以下のような新たな問題も見えてきます。
- middlewareに全APIのvalidationやauthorizationをまとめることはできた
- そのことによって各々のmiddleware自体は肥大化
これは今回新たに出てきたポイントではありますが、当初のAPI全体の負債に比べると問題は限定化されています。
今後の継続的改善
デメリットに記載したように、まだ改善可能な余地は残っています。
例えば今回のvalidationでは、同様のAPIグループでは同様のvalidation処理が存在します。
それらはmiddlewareのvalidationの中でさらに共通化したりすることが可能でしょう。
終わりに
今回は「APIのvalidationやauthorize処理をmiddlewareに共通化した」話を紹介しました。
私たちエンジニアのチームもさまざまなバックグランドを持ったメンバーが集まっています。
今回の話は読む方によっては簡単なテーマだったかもしれませんが、こうした改善も日々行なって良いサービスを作っていきたいと考えています。