Table of contents
- Tao of Node - Design, Architecture & Best Practices
- ストラクチャ & コーディングプラクティス
- アプリケーションをModuleで構成する
- モジュラーモノリスから始めよ
- 実装をレイヤーに分割する
- モジュール間の通信にservicesを利用する
- ドメインエンティティを作る
- ユーティリティ関数とドメインロジックの分離
- REST APIにハイパーテキストを使用する
- リクエストの構造をバリデーションする
- ミドルウェアをバリデーションする
- ミドルウェア内でのビジネスロジックの処理
- handler関数をcontroller classに優先させる
- エラーオブジェクトを使用するか拡張する
- process signalをlistenする
- エラー処理モジュールを作る
- ミドルウェアで404を送信する
- handlerでエラーレスポンスを送信しない
- 回復できないときはアプリを終了させる
- 一貫性を確保する
- 関数のコロケーション
- routesはモジュールで管理
- API routesにprefixをつける
- 認証されたリクエストのユーザーをアタッチする
- ツール
- テスト
- パフォーマンス
- 翻訳者あとがき
私が働いているAniqueという会社では、1年前に全てのソフトウェアでTypescriptを採用することにしました。私たちが開発している進撃の巨人のNFTサービス “Attack on Titan: Legacy” でも採用しています。
TypescriptではNestJSという素晴らしいAPIフレームワークを利用することができ、生産性高く開発を続けることができます。また、私たちはフロントエンドでNext.jsを利用しています。言語レベルでのコンテキストスイッチを抑えることで、一人のエンジニアがフロントエンドとバックエンドのどちらもの機能を開発する環境が作れました。
しかし、Nodeならではの作法や設計について、Web上にはたくさんの情報があるものの、あまりにも情報が多すぎて、まとまったプラクティスになかなか出会うことができませんでした。そのため、最初はチーム内での共通認識を作るのに苦労しました。
そんな中、2022年3月に出された以下の記事を見つけました。これはNodeを採用している開発チームに参加する時に、共通認識を得るための最初の読み物として、まさにぴったりの記事です!
Tao of Node - Design, Architecture & Best Practices
著者のAlex Kondovさんに連絡をとったところ、快く許可を頂いたので、翻訳させていただきました。Alexさん、ありがとうございます!
この記事のサンプルコードではexpressの利用が想定されています。しかし、この記事の考え方や論点の整理の方法は、どんなフレームワークを利用しようとも役に立つものだと思います。
以下より記事の翻訳になります
Tao of Node - Design, Architecture & Best Practices
JavaScriptの大きなメリットの一つは、ブラウザとサーバーの両方で動作することです。エンジニアであれば、ひとつの言語をマスターすれば、そのスキルはさまざまな用途で活用できます。2015年にNodeに惹かれたのはこの点です — 言語や技術スタックを切り替える必要がなかったんです。
Nodeでは、フロントエンドとバックエンドのアプリケーションにまたがって、ライブラリ、ロジック、型を再利用することができます。これによって、アプリケーションのどの部分にも対応できるスキルを持ったエンジニア — つまりフルスタックエンジニアの原型が生まれました。
最初は疑問視されていた技術から、多くの大企業で重要なインフラとして使用されるまでに成長しました。マルチスレッドに依存する言語よりもはるかに低いコードの複雑さで、大量のIOオペレーションに対して驚くほど優れた性能を発揮します。
Nodeのエコシステムは、自由と柔軟性に焦点を当て、初期に確立された重いフレームワークから脱却しています。厳しいコーディング規約やアプリケーションの構造を押し付けることはありません。しかし、柔軟性のために支払うべき代償があります。
JavaScriptを初めて使う人は、たとえ別の言語で経験を積んだエンジニアであっても、Nodeアプリケーションを書くためのルールや原則を見つけるのに苦労するでしょう。OOP(オブジェクト指向プログラミング)のバックグラウンドを持つ開発者は、以前の言語からのプラクティスをすぐに採用しました。
今日に至るまで、同じような構造を持つ2つのNodeアプリケーションを見つけることは困難です。この記事では、Nodeアプリケーションの構築について私が確立した一連の原則を要約します。
ここに書かれていることはすべて個人的な意見であり、絶対的なものではありません。ソフトウェアを構築する方法は1つだけではありません。
ストラクチャ & コーディングプラクティス
アプリケーションのストラクチャは、”戦略的な決定”と"戦術的な決定”の組み合わせで成り立っています。開発者は、フォルダの配置、レイヤー、およびそれらの間の通信についてだけでなく、低レベルの細部についても考えなければなりません。どれかをおろそかにすると、欠陥のある設計になります。
アプリケーションをModuleで構成する
バックエンド開発で最も人気のあるストラクチャデザインパターンは、MVCです。ほとんどの場面で適用可能で、これを選べば間違いはないでしょう。これは、アプリケーションの技術的な責任が中心となったストラクチャです。HTTPのリクエストとレスポンスを処理するController、データベースからデータを取得するModel、そしてレスポンスを可視化するViewがあります。
しかし、この方法のメリットはあまり強くありません。現在、ほとんどのNodeアプリケーションはJSONで通信するRESTサービスなので、View層は必要ありません。データのごく一部を所有するマイクロサービスには、それにアクセスするための複雑なツールは必要ないので、ModelやORMを使うことは必ずしも望ましいことではありません。また、Controllerはしばしば複雑さの中心点となり、開発者があらゆる種類のロジックをコントローラに押し込めることを誘発させます。
関心(Concern)の分離と技術的責任の分離は別物なのです。
MVC構造のプラスの面は、それを使う各アプリケーションが同じように構造化されることです。しかし、私はこれを欠点と見ています。アプリケーションの構造は、それが何をするものなのかを示し、そのドメインに関する情報を提供するものでなければなりません。Controllerでいっぱいのフォルダを開いても、サービス内のロジックの分離に関するコンテキストは何も提供されません。Modelの長いリストは、Model間の関係については何も教えてくれません。
Nodeアプリケーションを構成するためのより良い方法は、ドメインの一部を表すModuleです。以下は、ビジネスの一部分のためのhandlers、models、tests、およびビジネスロジックを含む独立したフォルダです。このストラクチャにより、サービスが何をしているのかが一目瞭然になり、例えばユーザーに関連するものは全てuser moduleにあるという確信を持つことができます。何も見逃していないことを確認するために、コードベースを掘り下げる必要はありません。
// 👎 技術的な責任でストラクチャを分けないでください
├── src
| ├── controllers
| | ├── user.js
| | ├── catalog.js
| | ├── order.js
| ├── models
| | ├── user.js
| | ├── product.js
| | ├── order.js
| ├── utils
| | ├── order.js
| ├── tests
| | ├── user.test.js
| | ├── product.test.js
// 👍 ドメインモジュールによるストラクチャ
├── src
| ├── user
| | ├── user-handlers.js
| | ├── user-service.js
| | ├── user-queries.js
| | ├── user-handlers.test.js
| | ├── index.js
| ├── order
| | ├── order-handlers.js
| | ├── order-service.js
| | ├── order-queries.js
| | ├── order-handlers.test.js
| | ├── calculate-shipping.js
| | ├── calculate-shipping.test.js
| | ├── index.js
| ├── catalog
| | ├── catalog-handlers.js
| | ├── product-queries.js
| | ├── catalog-handlers.test.js
| | ├── index.js
モジュールの構造に関しては、特に決まったパターンはありません。ドメインによっては異なる内容を持つかもしれません。handlersの数、modelsの数、所有するビジネスロジックの大きさなどが異なる場合があります。
この設計の主なアイデアは、金融業界で動作するアプリと医療業界で動作するアプリは、異なる構造であるべきだということです。両者のドメインの操作方法の違いは、コードベースから見えるはずです。私たちのソフトウェアが解決する現実の問題に応じて、構造化する必要があります。すべてのビジネスドメインが異なる課題に直面しているため、アプリケーションを同じように設計すべきではありません。
モジュラーモノリスから始めよ
新しいアプリケーションに取り掛かる前に答えなければならない最も重要な質問は、おそらくモノリスにするか、マイクロサービスに基づくものにするかということでしょう。近年、ほとんどの開発者やアーキテクトは後者の選択肢を選んでいます。その理由は、より優れたスケーラビリティと独立性を提供し、大規模プロジェクトに取り組む際の組織の課題を解決してくれるからです。
マイクロサービスとは、アプリケーションを複数の小さなサービスに分割し、互いに通信する広く採用されているパターンです。最も単純な例は、ユーザー、商品、注文のための別々のコンポーネントを持つシステムです。Eコマースは、エンティティ間の境界が明確に定義されているため、よく使われる例になります。しかし、いつもこのようなケースが当てはまるとは限りません。
作業するドメインによっては、境界が曖昧で、どの操作をどのサービスに入れるか区別がつかないことがあります。サービスを分離することで、多くのメリットが得られますが、分散システムの問題への扉を開いてしまうことになります。ですから、私はいつも、まずモジュール化したモノリスから始めて、アプリケーションを進化させてから、いろいろなものを抽出するようにとアドバイスしています。
私は、モノリスは過小評価されているという(あまり主流ではない)意見を持っています。この方法は、特定のモジュールに集中することで、より速く、半独立した状態で作業を進めることができるのです。全てが同じリポジトリにあるので移動も簡単ですし、良いモジュール性を維持すれば、モノリスからサービスを抽出するのはそれほど難しくはないはずです。
これは、アプリケーションを開発するときに持っておくと良いメンタルモデルです。各モジュールは潜在的に独立したサービスであると考え、モジュール間の通信には契約に依存します。
実装をレイヤーに分割する
ほとんどのNodeサービスの最大の設計上の欠点は、handler関数で多くのことをやりすぎてしまうことです。これはMVC構造を使用するアプリケーションでControllerクラスが経験する問題です。トランスポート(HTTP)、データアクセス、ビジネスロジックを一つの関数で処理することで、密結合された関数を作成します。
以下のような、リクエストオブジェクトの値を直接使用するバリデーション、ビジネスロジック、 データベース呼び出しを見るのは珍しいことではありません。注意: 例は意図的に単純化しています。
// 👎 アプリケーションのスコープが小さい場合を除き、
// あまりに多くの責任を持つhandlerを作成しないようにする
const handler = async (req, res) => {
const { name, email } = req.body
if (!isValidName(name)) {
return res.status(httpStatus.BAD_REQUEST).send()
}
if (!isValidEmail(email)) {
return res.status(httpStatus.BAD_REQUEST).send()
}
await queryBuilder('user').insert({ name, email })]
if (!isPromotionalPeriod()) {
const promotionalCode = await queryBuilder
.select('name', 'valid_until', 'percentage', 'target')
.from('promotional_codes')
.where({ target: 'new_joiners' })
transport.sendMail({
// ...
})
}
return res.status(httpStatus.CREATED).send(user)
}
これは、メンテナンスがあまり必要でない小さなアプリケーションでは、許容されるアプローチです。しかし、拡張していくような大きなものは、このような判断では支障をきたすでしょう。
handlerは長くなり、読みづらく、テストもしづらくなります。関数は一つのことに集中すべきというのが一般的な理解ですが、この場合、handler関数はあまりにも多くの責任を負っています。バリデーション、ビジネスロジック、データフェッチなどを処理するべきではありません。
その代わり、handler関数はトランスポート(HTTP)層に焦点を当てるべきです。データの取得や外部との通信に関連するものは、すべて独自の関数やモジュールに抽出されるべきです。
// 👍 HandlersはHTTP層のみを処理するべきです
const handler = async (req, res) => {
const { name, email } = req.body
if (!isValidName(name)) {
return res.status(httpStatus.BAD_REQUEST).send()
}
if (!isValidEmail(email)) {
return res.status(httpStatus.BAD_REQUEST).send()
}
try {
const user = userService.register(name, email)
return res.status(httpStatus.CREATED).send(user)
} catch (err) {
return res.status(httpStatus.INTERNAL_SERVER_ERROR).send()
}
}
通常、そのようなことを扱うモジュールは”services”というラベルが貼られています。その歴史的な理由はよくわかりませんが、誰もが理解できるように用語にこだわってみましょう。
handlersはトランスポートを処理し、servicesはドメインとデータアクセスのロジックを管理します。このとき、HTTPリクエストに応答しているのか、イベントドリブンシステムからのメッセージに応答しているのかはわかりません。
このようにする理由は、アプリケーションの異なる責任の間に楔を打ち込み、境界を作るためです。少々複雑なアプリケーションの場合、このステップだけでも大きな改善となることでしょう。
しかし、以下のserviceはuser、promotional code、emailに関連するロジックを保持していることにお気づきでしょう。トランスポートロジックとそれ以外のものとの間に境界を設けましたが、責任の分離という点では、このserviceはまだまだです。
// user-service.js
export async function register(name, email) {
const user = await queryBuilder('user').insert({ name, email })]
if (!isPromotionalPeriod()) {
const promotionalCode = await promotionService.getNewJoinerCode()
await emailService.sendNewJoinerPromotionEmail(promotionalCode)
}
return user
}
userと直接関係のないロジックを抽出することで、このserviceが単独ですべてを行うのではなく、デリゲートさせることはできました。とはいえ、このserviceの責任は大きくなりすぎているという意見もあります。
そこで以下のように、データアクセスロジックをさらに”repository”に抽出するパターンが広く使われています。
// user-service.js
export async function register(name, email) {
const user = await userRepository.insert(name, email)
if (!isPromotionalPeriod()) {
const promotionalCode = await promotionService.getNewJoinerCode()
await emailService.sendNewJoinerPromotionEmail(promotionalCode)
}
return user
}
データアクセスをカプセル化することで、serviceはビジネスロジックにのみ責任を持つようになり、テスト容易性と可読性をさらに向上させることができます。最も重要なことは、ビジネスとデータ機能の間にもう一つ楔を加えることです。
現在、アプリケーションのロジックは、トランスポート層、ドメイン層、データアクセス層の間で分割されています。それぞれのレイヤーを変更しても、他のレイヤーをほとんど変更する必要はないでしょう。もしアプリケーションがKafkaメッセージの取り込みを開始する必要があれば、別のトランスポートレイヤーを追加し、ドメインとデータレイヤーを再利用することができます。
RESTからgRPCやメッセージングに移行することは、非常に稀なことです。データベースを変更することは、さらに稀なことです。しかし、これらの可能性を受け入れることで、アプリケーションの拡張性、可読性、テスト容易性を大幅に向上させることができます。
しかし、この構造はNodeではそれほど人気がありません。その理由は、すべてのアプリケーションがその恩恵を受けられるわけではないためです。私がアドバイスしたいのは、複雑さが増すにつれてレイヤーを追加していくことです。最初の実装は、ハンドラだけを使って始めます。それから上記のステップを踏んでストラクチャを追加していきます。
モジュール間の通信にservicesを利用する
MVC構造のアプリケーションでは、アプリ内の技術的な責任の間に境界線があります。異なるロジックのまとまりの間に確立された境界線はありません。前述したように、私は、各モジュールがドメインの一部を記述するモジュール構造を大きく支持しています。
例えば、認証routesとアカウントroutesのhandlerを保持するuserモジュールがあるとします。またそれとは別に、注文に関連するすべてを処理するモジュールがあるとします。この時、あなたのアプリケーションが、注文中にユーザーが住所を更新するようなユースケースを持つと想像してください。
このロジックは2つのモジュールにまたがっており、どこに実装するかという難問に直面しています。deliveriesモジュールでデータベースクエリを書いて、user情報を更新することはできますが、これはuserモジュールの外にuserに関連するロジックを持つことになり、再び境界を破ることになります。
これを避けるには、user関連のロジックをuserモジュール内のサービスで実装するのがベストです。そして、その関数をdeliveriesモジュールから呼び出します。こうすることで、境界を保ちつつ、必要なところにロジックを配置することができます。deliveriesモジュールはuser情報更新の細部を知る必要がなく、抽象化された関数に依存します。
// 👎 ドメインモジュールの境界を壊さないこと
const placeOrderHandler = (req, res) => {
const { products, updateShippingAddress } = req.body
if (updateShippingAddress) {
// Update the user's default shipping address
const { user } = res.locals.user
const { shippingAddress } = req.body
knex('users').where('id' '=', user.id).update({ shippingAddress })
}
}
// 👍 servicesを利用して通信する
const placeOrderHandler = (req, res) => {
const { products, updateShippingAddress } = req.body
if (updateShippingAddress) {
// Update the user's default shipping address
const { user } = res.locals..user
const { shippingAddress } = req.body
userService.updateShippingAddress(user.id, shippingAddress)
}
}
この方法では、メインアプリケーションの外側でそのserviceを抽出する必要がある場合でも、deliveriesモジュールは同じ関数を呼び出すことができ、変更の必要ありません。同じように、userに対してマネージドサービスを使用し始めたとしても、deliveriesモジュールには何の変更もなく、同じように使用することができます。
ドメインエンティティを作る
Nodeサービスの主な責務の1つは、データを取得してどこかに送信することです。これはHTTPリクエストの結果であったり、イベントであったり、スケジュールされたジョブであったりします。一般的なプラクティスは、データが保存されているのと同じ形状で返されることです。
// product-repository.js
// 👎 ストレージが命名やフォーマットの制約を課している場合、
// ストレージから直接データを返さないようにする
export async function getProduct(id) {
return dbClient.getItem(id)
}
これは、store内のデータ構造とserviceが操作するデータ構造に違いがない場合に有効です。しかし、この方法はデータベースの詳細をコードから漏らしてしまうことが多く、避けるべきプラクティスです。
送信するデータに対して変換を行うほとんどのserviceは、トランスポート層でそれを実装し、送信される直前にマッピングを行います。アプリケーション全体がデータベースの詳細を知ることになるので、私はこれを欠点と見ています。
上記の方法の代わりに、serviceは独自のドメインエンティティを定義し、storeからのデータをできるだけ早くそれに変換すべきです。
// product-repository.js
// 👍 検索された項目をドメインオブジェクトにマッピングする
// ストレージ固有のフィールドの名前を変更し、構造を改善する。
export async function getProduct(id) {
const product = dbClient.getItem(id)
return mapToProductEntity(product)
}
これは、まるでエンタープライズの世界から借りてきたような、複雑すぎる仕組みのように思えます。データベースから返すデータの形が、作業する必要のあるデータの形と同じであれば、このステップは簡単に省略できますが、常にそうとは限りません。
例えば、DynamoDBを使っている場合、インデックスカラムをオーバーロードするのが一般的です。DynamoDBが返すデータの形はしばしば GSIPK
や GSISK
のような汎用的な名前を持ち、アイテムタイプによって異なる型のデータを保持します。これらを意味のある値にするのはアプリケーション次第なので、変換は早ければ早いほど良いのです。
ストレージ固有のフィールドをコードで扱うと、データレイヤーの詳細があちこちに漏れるので、それを避けたいのです。エンジニアの中には、これをドメインレイヤーの一部として行うべきかどうか議論する人もいます。しかし、ドメインレイヤーはアプリケーションの心臓部、中核であり、特定のストレージに結合させるべきではありません。
つまり、ドメイン層をできるだけシンプルにするために、あらゆる手段を講じるのです。これは良い第一歩です。TypeScriptを使用し、ドメインエンティティのみを使用して通信する関数に依存することで、これをさらに徹底させることができます。
ユーティリティ関数とドメインロジックの分離
私が見てきた多くのプロジェクトでは、utilitiesというフォルダがあり、開発者がどこに入れたらいいかわからないような機能をすべて格納しています。このフォルダには、再利用可能な関数からビジネスロジック、定数まで、あらゆるものが格納されています。
utilitiesフォルダは、理想的には、最小限の労力で他のプロジェクトに持ち運ぶことができるツールボックスであるべきです。もし、その中のロジックがビジネスに特化したものであれば、それはドメインレイヤーの一部であるべきで、単なるユーティリティではないことを意味します。
多くの開発者が悩むのは、ドメインロジックをアプリケーションの他の部分と区別することです。 アプリケーションのドメインはそれぞれ異なるので、唯一無二のベストな方法はありません。これまでトランスポート層とデータアクセス層に注目したのは、これらはどこでも同じだからです。
ユーティリティのフォルダにすべてを押し込むのではなく、別のファイルを作成し、その中にビジネスロジックをグループ化することをお勧めします。
// 👎 すべてをユーティリティフォルダに入れるのはやめましょう
├── src
| ├── user
| | ├── ...
| ├── order
| | ├── ...
| ├── catalog
| | ├── ...
| ├── utils
| | ├── calculate-shipping.js
| | ├── watchlist-query.js
| | ├── products-query.js
| | ├── capitalize.js
| | ├── validate-update-request.js
// 👍 ユーティリティとドメインロジックの分離
├── src
| ├── user
| | ├── ...
| | ├── validation
| | | ├── validate-update-request.js
| ├── order
| | ├── ...
| | ├── calculate-shipping.js
| ├── catalog
| | ├── ...
| | ├── queries
| | | ├── products-query.js
| | | ├── watchlist-query.js
| ├── utils
| | ├── capitalize.js
REST APIにハイパーテキストを使用する
REST APIは、現在ではHTTP通信の標準的な方法です。この標準は、使用している言語やフレームワークに関係なく簡単に実装でき、サービス間の通信プロセスを大幅に改善します。リソースを中心としたURLの構造化により、直感的に認識することができます。HTTPの動詞は明確に意図を伝えるので、URLの中に入れる必要はありません。
しかし、ほとんどのREST APIは、リソースベースのURLとHTTP動詞にのみ限定されています。リチャードソン成熟度モデルによると、これらは真のRESTful APIを構築するための3段階のモデルのうち最初の2段階に過ぎません。
成熟度の最終レベルでは、HATEOAS (Hypertext As The Engine Of Application State) と呼ばれるものを導入します。これはディスカバリーの問題を解決し、さらにクライアントとservicesとを切り離すものです。クライアントとしては、手元にドキュメントがない限り、リクエストを送るための正しいURLや、期待されるHTTP動詞を推測する必要があります。典型的な原因は、PATCHとPUTの使い方が異なるAPIです。
もう一つの問題は、クライアントとサーバーの間に生じる密結合です。サーバーはURL構造に変更があったことを伝える手段がなく、更新が必要な場合は複数のエンドポイントでバージョン管理やメンテナンスが必要になります。
HATEOASでは、REST APIは関連するリソースや操作のURLをクライアントに送信することができます。例えば、記事のような単一のリソースを取得するリクエストを行うと、REST APIはすべての記事を更新、削除、取得するためのリンク、つまりすべての関連する操作を送信します。
これは、URLが動的であるため、クライアントに複雑さを加えますが、カップリングと手動設定を大幅に削減します。ハイパーテキストの考え方は、何ができるか・どのようにできるかをクライアントに伝えることです。
翻訳者コメント
ここではREST APIの設計としてHATEOASが紹介されていますが、これは現在のところあまり一般的なテクニックではないと思います。 ここで提示されている課題は、GraphQL Schemaでも解決ができると考えており、私たちはGraphQL APIを採用しています。
リクエストの構造をバリデーションする
外部データを扱う各serviceは、そのデータをバリデーションする必要があります。私が携わってきたほとんどのserviceは、言語に関係なく、フィールドのチェックやデータ型の検証など、かなりの量のバリデーションロジックを備えていました。受け取ったデータが正しい構造を持っていることを知ることは、自信を持ってデータを扱うために重要なことです。
往々にして、バリデーションロジックは冗長かつ繰り返しになる可能性があります。自分で書く場合は、エラーメッセージを処理し、メンテナンスし続ける必要があります。これを回避し、よりクリーンなAPIを実現するには、JSON Schemaに対してリクエストペイロードをバリデーションするためのライブラリを使用するのがよいでしょう。
この例では Joi
を使用していますが、ajv
や express-validator
も見ておくといいでしょう。
// 👎 リクエストの検証を明示的に行わない
const createUserHandler = (req, res) => {
const { name, email, phone } = req.body
if (name && isValidName(name) && email && isValidEmail(email)) {
userService.create({
userName,
email,
phone,
status,
})
}
// Handle error...
}
// 👍 ライブラリを使って検証し、より説明的なメッセージを生成する
const schema = Joi.object().keys({
name: Joi.string().required(),
email: Joi.string().email().required(),
phone: Joi.string()
.regex(/^\d{3}-\d{3}-\d{4}$/)
.required(),
})
const createUserHandler = (req, res) => {
const { error, value } = schema.validate(req.body)
// Handle error...
}
期待するオブジェクトの形とその型を書けば、ライブラリはそれに対して検証を行い、きれいで説明的なエラーメッセージを返します。バリデーションライブラリが投げたエラーをキャッチして、status code 422(Unprocessable Entity)でリッチ化してから、中央のエラーハンドラで処理されるように再度エラーをthrowすることをお勧めします。
翻訳者コメント
私たちはJSONのバリデーションにzodというライブラリを利用しています。 Typescriptとの相性が抜群ですし、頻繁にメンテナンスされています。
ミドルウェアをバリデーションする
サービスのレイヤーを設計する際に考慮すべき重要な点は、バリデーションロジックをどこに配置するかということです。handlerのトランスポートレイヤの一部とすべきか、それともビジネスロジックの一部とすべきなのか。私は、データがhandlerに到達する前に検証を行うことをお勧めします。
一連のバリデーションチェックを行うミドルウェアを用意し、リクエストがhandlerに到達した時点で、データを安全に操作できるようにするのがベストです。
// 再利用可能なバリデーションミドルウェアを作成する
const validateBody = (schema) => (req, res, next) => {
const { value, error } = Joi.compile(schema).validate(req.body)
if (error) {
const errorMessage = error.details
.map((details) => details.message)
.join(', ')
return next(new AppError(httpStatus.BAD_REQUEST, errorMessage))
}
Object.assign(req, value)
return next()
}
// route定義でミドルウェアを利用する
app.put('/user', validateBody(userSchema), handlers.updateUser)
特定のrouteに特化した大規模なミドルウェアをいくつか用意するよりも、連鎖できるような粒度のミドルウェアを用意したほうがよいでしょう。この方法では、認証チェックを一度実装すれば、ルート固有のバリデーションでチェーンすることにより、複数のrouteで使用することができます。
前項で述べたexpress-validatorライブラリは、ミドルウェアを用いたバリデーションアプローチにマッチします。
ミドルウェア内でのビジネスロジックの処理
レイヤーや境界を定義し始めると、以前にはなかったジレンマに直面するようになります。そのひとつが、ミドルウェアの責任と、そこに書くべきロジックについてです。
ミドルウェアは生のリクエストにアクセスするため、トランスポート層の一部であり、handlerに適用されるのと同じルールに従わなければなりません。そのルールとは、処理のこれ以上の実行を止めるか・続けるかを決めるべきというものです。なので、ビジネスロジックそのものは別の場所で実装するのがベストです。
// 👎 ミドルウェアにビジネスロジックを実装しないでください
const hasAdminPermissions = (req, res, next) => {
const { user } = res.locals
const role = knex.select('name').from('roles').where({ 'user_id', user.id })
if (role !== roles.ADMIN) {
throw new AppError(httpStatus.UNAUTHORIZED)
}
next()
}
// 👍 serviceを呼んでデリゲートしてください
const hasAdminPermissions = (req, res, next) => {
const { user } = res.locals
if (!userService.hasPermission(user.id)) {
throw new AppError(httpStatus.UNAUTHORIZED)
}
next()
}
別のモジュール/関数を呼んでデリゲートすることで、ミドルウェアをその背後にあるロジックに気づかないままにできます。また、このような関数には、より一般的な名前を使うようにしています。例えば、 hasAdminAccess
の代わりに hasPermissions
と名付けます。roleが変われば、アクセスロジックも変わるかもしれないからです。これは、将来的なリファクタリングの可能性を減らすための小さな配慮です。
handler関数をcontroller classに優先させる
“すぐに動作する”という哲学を持ったMVCフレームワークでは、HTTP handlerはcontroller classにまとめられています。その理由は、大抵の場合Base classを拡張することで、リクエストの処理とレスポンスの送信に必要なすべてのロジック・機能を提供しているからです。
Express やその他の最小限のフレームワークでは、この機能にアクセスするためにクラスを拡張する必要はありません。その代わり、handler関数には、必要なメソッドがすべてアタッチされたリクエストオブジェクトとレスポンスオブジェクトが渡されます。コードベースがOOPに大きく依存していない限り、ここでクラスを使用する理由は見当たりません。
// 👎 ロジックをグループ化するためだけにクラスを使用しないでください
class UserController {
updateDetails(req, res) {}
register(req, res) {}
authenticate(req, res) {}
}
// 👍 代わりに単純なhandler関数を使用してください
export function updateDetails(req, res) {}
export function register(req, res) {}
export function authenticate(req, res) {}
関数は、長くなるとしても別ファイルにした方が移動が楽です。状態を保持して何かを注入する必要がある場合でも、factory関数を使用して必要なオブジェクトを渡す方がシンプルだと思います(これはモッキングを避けるために非常に有用なプラクティスです)。
export function createHandler(logger, userService) {
return {
updateDetails: (req, res) => {
// User the logger and service in here
},
}
}
エラーオブジェクトを使用するか拡張する
JavaScript では、技術的にはどのようなデータ型でも throw
キーワードを使用することができます。エラーの投げ方が限定されないという事実は、複雑な機能を実装するためのライブラリやツールで利用されてきました。しかし、エラー処理に関しては、スタックトレースを保持し、モジュール間の相互運用性を確保するために、組み込みの Error
オブジェクトにこだわることが重要です(例えば、何かが insteanceof
チェックをしている場合など)。
// 👎 プレーンなエラーメッセージをスローしないでください
const { error } = schema.validate(req.body)
if (!product) {
throw 'The request is not valid!'
}
// 👍 組み込みのErrorオブジェクトを使用しましょう
const { error } = schema.validate(req.body)
if (!product) {
throw new Error('The request is not valid')
}
しかし、時にはエラーメッセージだけを渡すのでは十分ではありません。アプリケーションのトランスポート層に伝搬させるべきstatus codeなど、エラーの詳細を追加するのがよい方法です。そのような場合は、Error オブジェクトを拡張して、これらのプロパティを追加するのが理にかなっています。
// 組み込みのErrorオブジェクトを拡張します
export default class AppError extends Error {
constructor(statusCode, message, isOperational = true, stack = '') {
super(message)
this.statusCode = statusCode
this.isOperational = isOperational
if (stack) {
this.stack = stack
} else {
Error.captureStackTrace(this, this.constructor)
}
}
}
// 拡張したAppErrorを使用します
const { error } = schema.validate(req.body)
if (!product) {
throw new AppError(
httpStatus.UNPROCESSABLE_ENTITY,
'The request is not valid'
)
}
デフォルトで true
に設定されている isOperational
フラグに注目してください。これは、私たちが発生させた既知のエラーと、私たちが処理する方法を知らないその他のエラーを区別するために使用されます。
エラーオブジェクトを拡張する方法は2つあります。汎用的な AppError
を作成するか、 ValidationError
や InternalServerError
のようにタイプに応じて特定のエラーclassを作成します。私のおすすめは、より一般的なエラーインスタンスにこだわり、ステータスコードを渡す方法です。
process signalをlistenする
ほとんどのアプリケーションは、HTTP経由のリクエストやイベントバスからのメッセージなど、外部のイベントに反応するように作られています。しかし、アプリケーションは、実行中の環境にも反応する必要があります。オペレーティングシステムは、アプリケーションにsignalを送り、様々なイベントを通知します。
process.on('uncaughtException', (err) => {
// 例外をログに記録して終了します
})
process.on('SIGTERM', () => {
// 何かをして終了します
})
特に、自分のサービスが停止するタイミングを知ることで、例えば他のサービスとの接続を閉じることができます。
エラー処理モジュールを作る
エラー処理については、例外をその場しのぎで処理するのではなく、明確で一貫した戦略を持つことが重要です。Express ベースのアプリケーションの場合、統一されたエラー処理モジュールを確立するのは非常に簡単です。
1人のエンジニアが1つのサービスを担当することは稀であり、特定のプラクティスを確立することで、エラーや例外的なケースが正しく処理されることを確認します。これにより、アプリケーションの責任が軽減され、その一部がエラーhandlerに委ねられることになります。
// 👎 ケースバイケースでエラーを処理しないでください
const createUserHandler = (req, res) => {
// ...
try {
await userService.createNewUser(user)
} catch (err) {
logger.error(err)
mailer.sendMail(
configuration.adminMail,
'Critical error occured',
err
)
res.status(500).send({ message: 'Error creating user' })
}
}
// 👍 エラーを中央のエラーhandlerに伝播します
const handleError = (err, res) => {
logger.error(err)
sendCriticalErrorNotification()
if (!err.isOperational) {
// AppErrorでない場合は、アプリケーションをシャットダウンします
}
res.status(err.statusCode).send(err.message)
}
const createUserHandler = (req, res, next) => {
// ...
try {
await userService.createNewUser(user)
} catch (err) {
next(err)
}
}
app.use(async (err, req, res, next) => {
await handleError(err, res)
})
process.on('uncaughtException', (error) => {
handleError(error)
})
エラー処理機構は一度だけ設置し、アプリケーションのコアは適切なエラーを発生させることだけに専念すればよいのです。それでも、レイヤー間の境界を保つことは重要です。ドメインロジックは通常の Error
オブジェクトを使用し、もしそれをリッチにする必要があれば、トランスポート層でそうしてください。
ミドルウェアで404を送信する
Express のようなミドルウェアベースのルータを使用している場合、404 エラーを処理する最も簡単な方法は、すべてのrouteの後で実行されるミドルウェアを追加することです。この方法では、もしこのミドルウェアにエラーが渡されずに到達した場合は、どのrouteも実行されていないことになるので、そこから安全に 404 エラーを発生させることができ、中央のエラーハンドラで処理されることになります。
app.use((err, req, res, next) => {
if (!err) {
next(new AppError(httpStatus.NOT_FOUND, 'Not found'))
}
})
handlerでエラーレスポンスを送信しない
もし集中エラー処理モジュールが確立されているなら、handlerエラーもそこにデリゲートすべきです。そこでレスポンスを処理する代わりにエラーを投げるだけなら、トランスポートのロジックは簡単なままだと思います。また、何かがうまくいかなくなる可能性のあるすべてのケースの処理に分岐することなく、「幸せな道」にとどまることができます。
回復できないときはアプリを終了させる
処理できないエラーに遭遇したときにすべきことは、それをログに記録し、アプリケーションをGraceful shutdownさせることです。私たちはトランスポートレベルのエラーやドメインロジックのエラーを処理する方法は知っていますが、ライブラリやツールが失敗し、そこから回復する方法がわからない場合、何かを決めつけるのはよくありません。
process.on('uncaughtException', (error) => {
handleError(error)
if (!isOperational(error)) {
process.exit(1)
}
})
エラーがログに記録されていることを確認し、アプリケーションをシャットダウンさせ、環境に依存して再起動させましょう。
一貫性を確保する
どのコーディング規約を使うかよりも、選んだコーディング規約に一貫性を持たせることの方が重要です。それぞれの規約に大きな機能的な違いはないので、どれを選ぶかは好みと習慣の問題です。私はコードスタイルに関して、万人を満足させることはできないということを理解するのに多くの時間を要しました。
どんな規格を決めても、グループ内で好みが異なる開発者は必ず出てきます。ですから、自転車操業にならないように、そしていつもの仕事を続けるために、一つのスタイルを選んでそれを貫きましょう。コーディング規約は、アプリケーションやシステム全体に適用された場合にのみ利益をもたらします。
EslintとPrettierは今でも使うべき最良のツールです。理想的には、huskyのpre-commit hookも欲しいところです。また、CIパイプラインでlinterを実行し、悪い形式のコードがプロジェクトにpushされないようにすることも必要です。
しかし、一貫性はスタイルや書式にとどまりません。一貫性のある命名規則は、コードの意味を理解するために重要です。しかし、アプリケーション全体に単一の命名規則を適用すると、混乱する可能性があります。その代わり、直感的に理解しやすいように使い分けましょう。
// 1. 定数にはすべて大文字を使用
const CACHE_CONTROL_HEADER = 'public, max-age=300'
// 2. 関数と変数にキャメルケースを使用する
const createUserHandler = (req, res) => {}
// 3. クラスとモデルにパスカルケースを使用する
class AppError extends Error {}
// 4. ファイルとフォルダにケバブケースを使用する -> user-handler.js
関数のコロケーション
アプリケーションの構成は、「一長一短」なのです。上記の原則を守っていても、ファイルや関数をどこに置けばいいのかわからないという状況に直面することは間違いないでしょう。
モジュールを設計する際に、ルートやハンドラ、サービスをどこに配置するかを決めるのは簡単です。しかし、これらのカテゴリーに当てはまらない機能については、問題が残ります。私は、配置に迷ったときは、使う場所の近くに配置することにしています。
もし、1つのモジュールで使用するだけなら、そのままにしておきます。必要なモジュールが多ければ、レベルを上げて、そのロジックをホストする共通モジュールを作成します。
しかし、それでもビジネスロジックの量は増え、モジュールは長いファイルのリストになりやすく、何も見つけられなくなることがあるのです。これらは、そもそも私たちが避けようとしていたMVCアーキテクチャの問題点です。
このようなことが起こらないようにするためには、ロジックをサブフォルダにまとめる必要があります。例えば、注文の送料を計算する複雑なロジックがあるとします。これは4つか5つのファイルに分割されるかもしれませんが、それでもあなたのserviceはそのうちの1つ、つまりメインの calculateOrder
関数を呼び出すだけです。
├── order
| ├── order-handlers.js
| ├── order-service.js
| ├── order-queries.js
| ├── order-handlers.test.js
| ├── calculate-shipping.js
| ├── calculate-shipping.test.js
| ├── get-courier-cost.js
| ├── calculate-packaging-cost.js
| ├── calculate-discount.js
| ├── is-free-shipping.js
| ├── index.js
これは大げさな例で、おそらくそれほど細かくロジックを分割することはないでしょうが、考え方を示しています。こうして見ると、これらのファイルの関係は理解できませんね。さらに機能を追加すると、ごちゃごちゃになってしまいます。
この構造を改善するためには、コストの計算に関するサブフォルダ(サブモジュールのようなもの)を作成して、すべての機能をそこに移動させればよいのです。そうすれば、メインのエントリーポイントは index.js
ファイルからエクスポートされ、serviceは特定のファイルではなく、モジュール自体を参照することになります。
├── order
| ├── order-handlers.js
| ├── order-service.js
| ├── order-queries.js
| ├── order-handlers.test.js
| ├── calculate-shipping
| | ├── index.js
| | ├── calculate-shipping.js
| | ├── calculate-shipping.test.js
| | ├── get-courier-cost.js
| | ├── calculate-packaging-cost.js
| | ├── calculate-discount.js
| | ├── is-free-shipping.js
| ├── index.js
routesはモジュールで管理
モジュールがすべてのロジックを保持するのと同じように、モジュールもすべてのルートを所有する必要があります。多くのアプリケーションでは、モジュール化を実現するために、すべてのroutesを1つのファイルにまとめてリストアップすることでモジュール化を崩しています。これは理解しやすいのですが、複数のエンジニアが同じファイルを触る可能性があることを意味するため、なるべく避けたいです。
// 主なroutesを登録する
const router = express.Router()
router.use('/user', jobs)
app.use('/v1', router)
// user-routes.js
router.route('/').get(isAuthenticated, handler.getUserDetails)
router
.route('/')
.put(
isAuthenticated,
validate(userDetailsSchema),
handler.updateUserDetails
)
routesを一箇所に定義することは、モジュール化する上でそれほど大きな問題ではないように思われます。結局のところ、これらのモジュールは何らかの形で統合されなければならないからです。しかし、残念ながら、ほとんどのroutes定義はhandlerだけでなく、最初に実行されるべきミドルウェアも一緒に持っていきます。
Expressのようなフレームワークでは、ルーティングツリーでルータを連結することができるため、各モジュールのルーティングロジックをカプセル化することができます。副次的な効果として、モジュールを独立したアプリケーションに抽出することがより簡単になります。
API routesにprefixをつける
gRPCやGraphQLがパターンとしてのRESTの採用レベルに達するには、長い時間がかかると思われます。当面はまだ通常のAPIを構築することになるでしょうから、RESTの活用方法を理解するために時間を投資する価値はあります。
多くのクライアントを持つAPIが抱える問題は、安定性の確保と破壊的な変更の管理です。エンドポイントが期待するパラメータを変更することは、リスクを伴う作業となります。Pactのようなツールで管理できても、APIを変更しなければならない局面は間違いなくやってきます。
後方互換性を確保するため、大幅な変更の予定がない場合でも、常に現在のAPIバージョンでroutesにprefixをつけてください。すべてのクライアントがこのエンドポイントを利用することで、効果的にAPIコールにバージョニングが適用できます。こうすることで、最初のバージョンと互換性のない新しいバージョンを作成するのは簡単になります。
app.use('/v1', routes)
認証されたリクエストのユーザーをアタッチする
PromiseがJavaScriptの標準ライブラリの一員となり、広くサポートされるようになる以前は、Nodeは非同期機能のためにコールバックベースのAPIに依存していました。これはこれで悪くない設計ですが、通常のロジックのフローでは、複数のコールバックを互いに入れ子にして使うことになり、あっという間にクリスマスツリーのような実装になってしまうことがあります。
インデントが深いと、コードを追いかけるのが大変です。あるコード行がどのコールバックに該当するかを見分けるのは、そう簡単なことではありません。これは、Nodeのコードベースの災いであった有名な「コールバック地獄」問題です。
ありがたいことに、プロミスがこの問題から私たちを解放してくれました。今、組み込みの Node モジュールは .then()
や await
で使えるPromiseベースのAPIを持っています。
// 👎 深いネストを避けるために、コールバックベースのAPIを使用しないでください
import fs from 'node:fs'
fs.open('./some/file/to/read', (err, file) => {
// Handle error...
// Do something with the file...
fs.close(file, (err) => {
// Handle closing error...
})
})
// 👍 PromiseベースのAPIを使用しましょう
import { open } from 'node:fs/promises'
const file = await open('./some/file/to/read')
// Do something with the file...
await file.close()
それでも、 try catch
を使ったり、返されたプロミスに対して .catch()
メソッドをチェーンさせたりして、潜在的なエラーを処理する必要はあります。
ツール
すべてのアプリケーションは、あなたのコードと、あなたが話す機会のない開発者によって書かれた多くのツールとの共生関係です。どのようなトレードオフを行うべきか、またドメインロジックと使用しているサードパーティライブラリをどのように統合するかを知ることは、アプリケーションの品質にとって最も重要なことです。
ミニマムなツールを優先する
Nodeの哲学は、必要なものを構築するためのビルディングブロックを提供する最小限のツールを中心に据えています。これについてエンジニアは、必要なものをすべて備えたサービスを作るためにインストールしなければならないNPMモジュールの膨大な数についてジョークを言っています。
しかし、それこそがエコシステムを立ち上げることができたアイデアです — 1つのことだけに焦点を当てた小さなモジュールなのです。必要なものを実装するか、引き抜くかはエンジニア次第です。
Expressは、Nodeのミニマルな哲学の完璧な例であり、それが最も人気のあるフレームワークであり続けているのは偶然ではありません。Expressはサーバーを稼働させるために必要な基本的な機能を実装しており、その上に何を追加するかはあなたの手にかかっています。
フレームワークとしてExpressを優先する
Expressにこだわることをお勧めします。なぜなら、そのプラグイン、ルーティング、ミドルウェアのパターンは、あなたが仕事で使うことになるかもしれない他のすべてのフレームワークのバックボーンになるからです。よりこだわりの多いツールは、その上に何らかの機能を提供してくれるかもしれません。
Expressを学ぶことで、トレードオフの判断や必要なツールの選び方、アプリケーションの構成、特定のフレームワークの文脈で思考を限定しない方法などを学ぶことができます。今後、どのようなツールを使うにしても、この知識を活用することができるようになります。
RubyにはRails、PythonにはDjango、PHPにはLaravelなど、ほとんどの言語には主要なフレームワークが存在します。これらはすべて、そのエコシステムの哲学を中心に形作られています。2022年現在、NodeはExpressが主流です。
それでも、多くの意見を持つフレームワークが、すぐに利用できるソリューションを人々に提供することを目的としています。それらは、ある程度の採用実績がありますが、そのアプローチは、Nodeの基本的な考え方と相反するものです。
Nestは、エンタープライズ企業で好んで採用されている注目のフレームワークです。Fastifyは、思想的にはExpressに似ているが、より多くのツールをすぐに利用できるフレームワークです。
しかし、Nodeのサービス構築に関連することをGoogle検索すると、ほとんどの場合、Expressに関連する結果が表示されます。
Expressは、その最小限の機能により、利用可能なすべてのツールとフレームワークの最小公倍数です。Node の知識を向上させたい場合は、Express に焦点を当てることが時間の投資に対して最高のリターンをもたらします。Express の上でアプリケーションを構築すると、制御が可能なため、最も多くのことを学ぶことができます。
Expressは、あなたの理解と要求に基づいて、アプリケーションを自由に構成することができます。Express を使用して構築することは、フレームワークを使用せずに構築することに似ています。Expressのソースコードは簡単に読むことができます。実装はそれほど大きくなく、理解しやすいように書かれています。
翻訳者コメント
私たちも、プロジェクトの初期段階ではExpressを使ってサーバーを書き始めました。この記事で議論されているディレクトリ構造やエラー処理、コードの分割構造を議論しながら、2回ほどプロトタイピングをしました。 最終的にはNestJSを採用することに決めましたが、NestJSも裏側ではExpressが利用されているため知識は無駄にはなりませんし、この経験をしたことで今のコードに自信を持つことができています。
ORMとしてクエリビルダを優先する
ORMは他の言語で広く人気がありますが、Nodeでは同じレベルでの採用はされていません。明確な勝者や頼りになるORMは見つかりません。実際、多くのNode開発者は、クエリビルダのような軽量なオプションを使用することをお勧めしています。
ORMは複雑でないレベルでは素晴らしい働きをします。単純な操作とjoinに固執する限り、邪魔になることはありません。その程度の複雑さであれば、対応するSQLクエリと比較して、コードをより読みやすく、直感的にすることができます。クエリの書き方には開発者それぞれの好みがあるので、チーム全体でデータを取得する方法を標準化するのに役立ちます。
ORMの大きな問題は、邪魔になり始める閾値がかなり低いということです。
複雑なクエリを実行するには、ORMのAPIを理解する必要があります。ほとんどのエンジニアはSQLに精通していますが、関数についてはどのように動作するのかを調べなければならないかもしれません。しかし、これは無視できる欠点です。本当の問題は、ORM がパフォーマンスの高い複雑なクエリを生成しない可能性があり、ベンチマークや最適化が難しいということです。このような状況になると、開発者はORMを捨てて、手でクエリを書くようになることも珍しくありません。
私がORMを好まないもうひとつの理由は、アプリケーションのモジュール間に別のレベルのカップリングを導入してしまうからです。理想的には、各モジュールで作成したサービスを使って通信するのが望ましいでしょう。モデル同士を結びつけると、将来的にモジュールをそれ自身のサービスに抽出することが難しくなります。
ORMには、ツールを知らない人にはわからないライフサイクルフックや検証メカニズムに依存して、ロジックを周りに形成してしまうという欠点があります。ORMは、ロジックとデータアクセスの境界を曖昧にします。
これらの理由から、私はORMをスキップして、データベースのクエリをよりよく制御できる軽量のオプションを選ぶよう助言します。クエリビルダは、値のバインディングの手間を省きつつ、データの取得方法をよりよく制御できる良い代替手段です。Knexは実績のあるツールで、私はSQLを書くときに好んで利用しています。また、KnexはMongoにも独自のドライバがあります。
おそらく、ORM的なツールで私が賛成するのは、Prismaだけでしょう。
翻訳者コメント
私たちはデータベースの操作にPrismaを採用しています。PrismaはTypescriptとの相性がバッチリで、とても使いやすいツールであることは間違いありません。 しかしながら、著者の言うように意図していないクエリを稀に発行することがあるため、部分的に生のSQLを利用することもあります。
ライブラリよりネイティブメソッドを優先する
ネイティブのメソッドとベンチマークで比較したところ、 lodash
や underscore
などのライブラリの関数は性能が低いことが分かっています。同時に、プロジェクトに余分な依存関係を追加してしまいます。これらのライブラリが最初に作られたとき、開発者のニーズと言語の能力との間のギャップを埋めることを目的としていました。
さて、ライブラリをインポートするような機能のほとんどは、この言語に組み込まれています。Object.entities
, Object.keys
, Object.values
, Object.find
, Array.from
, Array.concat
, Array.fill
, Array.filter
, Array.map
などのメソッドやその組み合わせで、ほとんどのニーズをカバーすることができます。
私は、この言語の特徴を生かし、再利用可能な独自のユーティリティ関数を構築することをお勧めします。実際、再帰的な flatMap
のような複雑な関数も含め、そのほとんどが最新のネイティブメソッドを使って書かれた例を見つけることができます。
ライブラリを抽出する
アプリケーションが単一のモノリシックなNodeサービスで構成されている場合、ロジックの再利用は関数をインポートするのと同じくらい簡単です。モジュール性を維持するために、共通の機能をまとめたい場合もあるかもしれませんが、まあそれはそれとしてです。
アプリケーションが複数のサービスに分かれている場合、この共有ロジックは差し迫った問題になります。開発者は、サービス間で機能を繰り返していることに気づき、有名なDRY (Don't Repeat Yourself) 原則のために、すぐに抽象化する方法を探します。
この問題を解決するには、この機能を提供する別のサービスを作るか、ライブラリとして抽出するか、という2つの方法が考えられます。前者は、さらに別のサービスとその周りのインフラを追加することが大きな負担となるため、避けるべきアプローチでしょう。後者は繰り返しの問題を解決する良い方法のように思えます。
しかし、いくつか注意すべき点があります。複数の独立したサービスがある場合、それらを分離した状態に保つことは非常に重要です — たとえそれによりコードが重複してしまうという代償があってもです。共有ライブラリを導入することで、結合の度合いを高めてしまうのです。それでも、バージョン管理を行い、各サービスが必要とするライブラリのバージョンを固定することで、この問題を軽減することができます。
また、抽出したいロジックの変化のペースを考慮したほうが良いでしょう。カプセル化しようとするロジックが急速に発展している場合、ライブラリやそれを使用するサービスを常に更新するよりも、重複を管理する方が簡単です。
構造化ログを利用する
ログを取ることは、実稼働中のアプリケーションのロジックの流れを追跡するための唯一の方法です。最も簡単な方法は、 console.log
を使って、必要な情報を含む任意の構造のメッセージをログに記録することです。時間、呼び出し元、エラーメッセージ、重要なビジネス固有の情報など、必要な情報をすべてそこに収めることができます。
しかし、このようなログは、メッセージを閲覧できるエンジニアにしか役に立ちません。高いトラフィックを持つアプリケーションは、自分自身が埋もれるほどのログを生成する可能性があり、手作業でそれらを調べることは、いかなる意味でも理想的とは言えません。
コンピュータが処理できるフォーマットでメッセージを出力できる構造化ロガーを使いましょう。ログを構成し、構造にまとめることが容易になります。また、SplunkやNew Relicのようなツールを使ってログを検索できるメリットもあります。
特に、サービス指向やマイクロサービス指向のシステムでは、データの流れを素早く把握できることが重要です。さらに、サービスに対してエラーログが表示された場合にアラートを追加する機能もあります。
最も広く使われているロガーは winston
で、これを使えば間違いはないでしょう。しかし、コミュニティは徐々にこのロガーから離れつつあるようです。なぜなら、このロガーにはある種の欠陥があることが知られており、しばらく更新されていないからです。winstonを使いたくない人には、 pino
をお勧めします。これは実際に fastify
(人気のあるNodeフレームワーク)で使われているデフォルトのロガーです。
アプリケーションのドキュメントを書く
アプリケーションやサービスを作ることは、開発の初期段階に過ぎません。本番稼動するということは、あなたが書いたコードをこれからメンテナンスしていかなければならないことを意味します。多くの場合、サービスを拡張したり変更したりするのは、それを作った人たちではありません。
コンテキストを持たない開発者が実装を理解できるようにするには、「なぜ」何かが行われるのかという問いに答えるコメントを書くことが重要です。特にビジネスレイヤーでコメントを書くと便利だと思います。なぜなら、ビジネスレイヤーはサービスの中で最も知識がない部分だからです。
エンジニアは、HTTPレイヤーやデータベースについては理解できるでしょう。使っているツールのドキュメントもたくさんあります。しかし、ビジネスレイヤーを理解することは、それに精通している人にとっても難しいことなのです。エンジニアはコードのセルフドキュメント化を重視しますが、私はそれだけでは不十分だと思います。
また、サービスが持つエンドポイント、期待するペイロード、返すレスポンス形式をドキュメント化することも重要です。Swaggerはコミュニティで広く採用されている、使い勝手の良いツールです。ドキュメントを視覚化して閲覧しやすくし、そこからTypeScriptの型を生成するツールもあるので、クライアントと共有することができます。
依存パッケージのバージョンを固定する
これは見落とされがちですが、NPM パッケージを使用しているバージョンに正確に固定しましょう。セマンティックバージョニングに頼らず、壊れるような変更がないことを保証してください。これは、数ヶ月ごとに監査を行い、パッケージを更新するタスクをバックログに入れる必要があることを意味しますが、安全性は手作業で行う価値があります。
翻訳者コメント
私たちはNPMパッケージの更新にdependabotを利用しています。しかし、ここで書かれているように安全にpackageのアップデートをすることは重要なので、適切なunit testやe2e testを書いてCIで実行することで、packageをアップデートしてもアプリケーションが壊れないことを保証するようにしています。
Typescriptを使う
数年前、私は仕事でTypeScriptの導入をする必要がありました。私は、長所と短所のリストや見積もりと、全てのPoCをまとめたことを覚えています。人々は、学習曲線と、型システムによる生産性の低下を恐れていました。
現在ではTypeScriptの利点は広く証明され、その周辺のツールは成熟し、IDEのサポートも充実しています。ほとんどのパッケージには型が同梱されているので、自分で型を作らなければならない状況は稀です。
ピュアなJavaScriptは、一人で作業しているときに速く動けるようにします。TypeScriptは、チームで作業しているときに、より速く作業することができます。使わない理由はありません。
Snykを使う
Log4jの騒動は、ロガーのような単純なものでさえ、攻撃のベクトルとして使われる可能性があることを教えてくれました。多くのサービスにまたがるシステムでは、発見された脆弱性によって何が影響を受けるかを追跡することは不可能になります。セキュリティチェックは、早期に警告を発し、何が影響を受けたかを追跡できるように自動化する必要があります。
Snykは、あなたが使っているライブラリにある既知の問題について警告することができる素晴らしいツールです。CIパイプラインのステップとして実行し、脆弱性が発見された場合はリリースをブロックするのが最も良い方法です。
ただし、重要な脆弱性や深刻な脆弱性のみを報告するように Snyk を設定するのが最善であることに留意してください。あなたが使っているライブラリの多くには、対処できないような影響の小さい問題があるはずです。Snykにそれらを報告させると、パイプラインに多くのノイズがもたらされ、最終的には無視するようになります。
その代わり、深刻な問題を検出したときだけアラームが鳴るようにツールを設定します。
アプリケーションのコンテナ化
最も厄介なバグは、簡単に再現できないような環境特有のものに起因するものです。ありがたいことに、コンテナ技術は広く採用され、成熟しているので、こうした問題のいくつかを回避することができます。
コンテナでアプリケーションを実行することで、アプリケーションの実行環境を確実にコントロールでき、本番稼動後に問題に遭遇する可能性を低くすることができます。
しかし、Dockerのようなツールを使うことが不要な場合もあると思います。その1つは、他にスピンアップする必要のあるサービスがない場合です。もうひとつは、アプリケーションをコンテナ環境にデプロイしていない場合です。このような場合、私はnpmスクリプトにこだわり、余計な抽象化を避けることを強くお勧めします。
コンテナは、データベース、Redisキャッシュ、フロントエンドアプリケーションなど、いくつかの異なるサービスをローカルで起動する必要がある場合に威力を発揮します。アプリケーションが複数のサービスに依存している場合、 docker-compose
を使用することが、ローカルでそれらを実行し、チームメンバー間で再現可能な環境を持つための最も簡単な方法です。
データベースの変更について気にしない
もし、プロジェクトの途中でデータベースを変更したいと言ってきた人がいたら、その人を帰した方がいいと思います。5倍から10倍のスピードとコストダウンが期待できるのであれば別ですが、そうでないのであれば、そのようなことは考えないでください。
重要なインフラを変更する可能性があることは、過剰なエンジニアリングの大きな原因です。チームは結局、不必要な抽象化を加え、複雑さを増し、起こる可能性が極めて低い事象に備えることになります。
もし、運用中にアプリケーションのストレージを変更しなければならない状況に陥った場合、最大の課題はデータをあるストアから別のストアに移行することでしょう。データベースの仕様をコードから抽象化することで、はるかに容易になります。
設定のカプセル化
設定を誤ると、アプリケーションの複雑さを静かに増大させることになります。各サービスが正しく機能するためには、いくつかのAPI key、認証情報、環境変数を提供する必要があります。
このような環境変数を直接serviceやhandlerで使用すると、レイヤー間の境界が壊れてしまいます。パラメータを受け取ってデータを返すだけの純粋な関数が、外部からのデータ受け渡しに依存する不純な関数に変わってしまうのです。
const config = {
environment: process.env.NODE_ENV,
port: process.env.PORT,
}
export default config
上記のようなコードにより、設定をカプセル化し、他のモジュールと同じようにインポートすることができます。ハンドラやサービスは、値がどこから来るのかを意識することはありません。環境変数で設定することもできますし、オブジェクトにハードコードすることもできます(例えばAWSリージョンのような値)。
関数の純粋性を損なわないために、アプリケーションの上位でconfigオブジェクトをインポートし、その値を関数にパラメータとして渡すのが良いでしょう。こうすることで、テストが容易になります。
設定を階層にする
環境変数は、数が少なければ管理しやすいものです。しかし、アプリケーションが通信する必要のある各コンポーネントは、複数の環境変数を必要とする場合があります。
データベースに接続するには、通常、認証情報、データベース名、そしておそらくリージョンが必要です。キャッシュに必要な変数や、外部サービスのAPIキーなどを加えると、かなりの数になります。
このような変数は区別がつきにくいので、通常は共通の識別子を前につけて、何が何に属しているかを理解するようにします。このような場合、設定をカプセル化し、その中に階層を作る必要があります。
そうすれば、保持するプロパティにオブジェクトの名前を繰り返さないという共通の設計原則が使えるようになります。
// 👎 オブジェクト名はすでにコンテキストを保持しています
const user = {
userName: '...',
userEmail: '...',
userAddress: '...',
}
// 👍 不要なprefixを削除しましょう
const user = {
name: '...',
email: '...',
address: '...',
}
これを設定に当てはめると、分かりやすいオブジェクトが出来上がります。
const config = {
storage: {
bucketName: process.env.S3_BUCKET_NAME,
},
database: {
name: process.env.DB_NAME,
username: process.env.DB_USER,
password: process.env.DB_PASSWORD,
},
}
export default config
テスト
テストによってカバーされていなければ、アプリケーションが期待通りに動くかどうかを検証する方法がありません。また、テストはしっかりと構造化され、メンテナンスされる必要があるコードです。
インテグレーションテストを優先する
最も大きな価値を生むテストは、インテグレーションテストです。これらのテストは、アプリケーションのロジックフローが期待通りに動作し、ビジネスオペレーションを処理できるかどうかを検証します。サービスが本番で期待通りに動作するという確信を持つことは、各機能を個別にテストすることよりも優先されるべきです。
よくある問題の1つは、すべてのユニットテストが正常に通過しても、ユニットテスト間の統合が失敗し、アプリケーションが目的を果たせないままになってしまうことです。
エンジニアとして、私たちは時間に追われているので、テストの明確な利点にもかかわらず、見捨てられがちです。もしテストに費やせるリソースが限られているのであれば、何よりも先にインテグレーションテストに集中することを検討してください。
インテグレーションテストは、アプリケーションの安定性を確保するための最良の方法であり、問題の正確な原因を診断することはできませんが、より広いカバレッジを確保することができます。
モッキングよりも依存性注入を検討する
いくつかの言語では、依存関係をモックすることは、悪いコードと考えられています。何かをモックしなければならないということは、柔軟では無い形でコードが書かれていて、拡張や修正が難しいということを意味します。一般に、テストを難しくするものはすべて、実装に問題がある可能性があると考えるべきでしょう。
Node コミュニティでは、モッキングを通常のプラクティスとして受け入れています。ほとんどのテスト・ツールは、モジュール全体や特定の関数をモック化する便利なメソッドを提供することで、それを奨励しています。
例として、ロガーモジュールを直接インポートして使用するserviceがあります。この方法では、serviceをロガーに結びつけ、 同じインターフェイスを持つ別のロガーとは動作させることができなくなります。そしてテストの際には、モジュール全体をモックすることになります。
代替案としては、factory関数を使用してこれらをserviceに注入することです。モックに頼るのではなく、単純なオブジェクトでサービスを作ることができるので、テストが少し簡単になります。
どちらか一方を擁護することはできません。モッキングは広く使われていて認知度の高い手法なので、Nodeアプリケーションで使うのは問題ないでしょう。しかし、依存関係を注入することは、アプリケーションを分割する強力な方法であり、私はますますそれが好きになってきています。
しかし、ひとつだけお勧めできることがあります。それは、複雑なDI(依存性注入)コンテナを避けることです。複雑なDIコンテナは、冗長性を減らす代わりに、多くの複雑さをもたらします。
翻訳者コメント
Nodeにおいて、このDIの実装をサポートするAPIフレームワークとして有名なものがNestJSです。ドキュメントが丁寧で、テストも書きやすいので、ぜひ触れてみてください。
ビジネスロジックのユニットテスト
HTTPとデータアクセスのレイヤーでは、サードパーティのライブラリに依存することになります。おそらく、ルーティングを処理するためにExpressのようなフレームワークを使用し、ストレージにアクセスするためにデータベースクライアントを使用することでしょう。これらのツールには、期待通りに動作することを保証する、しっかりとしたテストスイートが付属しています。
あなたが注力すべきなのは、ドメイン層のテストです。ビジネスロジックが期待通りに動作することを確認する必要があります。なぜなら、この部分はアプリケーションの中で唯一あなたのコントロール下にある部分だからです。
高いテストカバレッジへの投資
私は、ある閾値を超えたテストの有効性を疑うことに何年も費やしてきました。主要なロジックパスがカバーされれば、テストカバレッジの見返りは少なくなると考えていたからです。最近、初めてテストカバレッジ100%のコードベースを見るまでは、この考えを持っていました。
偶然にもこのチームは、過去6ヶ月間のプロダクションインシデントが0件でした。テストカバレッジとインシデント数の減少には、強い相関関係があります。このチームにとって、これはアプリケーションの各行が期待通りに動作することが検証されたことを意味します。
もちろん、適切なテストに投資する時間とリソースがある企業での話です。しかし、テストカバレッジをそこまで高めたのは、開発者のイニシアチブによるものです。
たとえ、あなたが働いている環境がすべてをテストすることを許さないとしても、何かを変更したり、新しい機能を追加したりするときには、時間をかけてテストを追加するようにしましょう。この実践のメリットを組織に啓蒙するのは、エンジニアの役目です。
Arrange-Act-Assertパターンを踏襲する
Arrange-Act-Assert パターンは、使用している言語やテストフレームワークに関わらず、テストの構成によく使用されます。テストは保守しなければならないコードなので、保守しやすく理解しやすいものにすることに力を入れるべきでしょう。
describe('User Service', () => {
it('Should create a user given correct data', async () => {
// 1. Arrange - データを準備し、必要なオブジェクトを作成します
const mockUser = {
// ...
}
const userService = createUserService(
mockLogger,
mockQueryBuilder
)
// 2. Act - テストするロジックを実行します
const result = userService.create(mockUser)
// 3. Assert - 期待される結果を検証する
expect(mockLogger).toHaveBeenCalled()
expect(mockQueryBuilder).toHaveBeenCalled()
expect(result).toEqual(/** ... */)
})
})
パフォーマンス
パフォーマンスは、それだけで1冊の本ができるほど幅広いテーマです。しかし、よくある問題を避けるために、日々の仕事の中でいくつかの原則を守っていくことが大切です。スピードを向上させるためには、多くの場合、より多くのことをするのではなく、より少なくする必要があります。
イベントループをブロックしない
Nodeは正しく使えば、驚くほどのパフォーマンスを発揮します。単純なルールとして、多くの操作を必要とする問題で、かつサイズが小さい場合にNodeを使用するのがよいでしょう。イベントループの詳細には触れませんが、イベントループはシングルスレッドで動作し、常にタスクを切り替えていることを知っておくことは重要です。
非同期タスクに到達したら、前のタスクが解決されるまで、それを脇に置いて他の処理をすることができます。しかし、長時間CPU負荷の高い処理を実行すると、1つのタスクの処理が長すぎて、他のタスクを待たせてしまうことになります。イベントループのスイッチング機構をフルに活用するために、重い処理ではイベントループをブロックしないことが鉄則です。
このようなブロック操作は、大きなJSONオブジェクトの解析、大きなデータコレクションに対するロジックの適用、複雑な正規表現の実行、ファイルの読み込みなどが考えられます。もしアプリケーションがこのような操作を高速に処理する必要がある場合は、メインアプリケーションから外部キューにオフロードする方法を検討する必要があります。もしパフォーマンスが重要で、キューに入れることができない重い操作をしなければならないのであれば、それはNodeの選択を再考する価値があります。
同じ理由で、Nodeサーバからアセットを提供することは避けた方がよいでしょう。HTML、CSS、JSファイル、画像はCDNやS3バケットで提供するのがベストです。Nodeは、IOヘビーなワークロードに使用するときに最も輝きを放ちます。適切に使用すれば、Nodeのシングルスレッドイベントループは、マルチスレッドを利用するアプリケーションと同等のパフォーマンスを発揮することができます。
アルゴリズムの複雑さを最適化しない
ほとんどのサービスでは、コードの実行にかかる時間は、パフォーマンスに関して最も影響の少ない要素であることがわかります。ビジネスロジックのアルゴリズムの複雑さがアプリケーションの最大のボトルネックになるような状況は非常に稀なので、少なくとも最初は無視しても大丈夫でしょう。
これは、スピードについて何も考えずにコードを書くべきだということではなく、他に優位な要素がある場合は、急いで最適化すべきではないということです。他のことを最適化すれば、もっと多くの価値を生み出すことができるのに、これに時間を費やすのは生産的ではありません。
最も重視すべきは、外部サービスとの通信です。これは、データベースと、あなたが通信する他のアプリケーションの両方を意味します。クエリがデータベース設計をどのように利用しているかは、非常に注意しなければならない点です。
遅いクエリは、どんなに優れたコードでも、それを補うことができないほどレスポンスタイムを悪化させます。indexesを活用し、データベースが許容する最もパフォーマンスの高い方法でデータにアクセスすることが重要です。だから私は、単純なシナリオであっても、ORMの代わりにクエリビルダの使用を推奨しているのです。
2つ目の問題は、外部サービスとの通信です。ネットワーク上の要求が絡むと、問題が発生する確率はゼロではありません。また、他のアプリケーションやベンダーがリクエストに応えるスピードも、注目すべき要素です。
もちろん、ネットワークをコントロールすることは不可能ですが、HTTP接続を維持することでその後のリクエストのハンドシェイク時間をスキップしたり、重複したリクエストを行わないようにキャッシュの実装(インメモリであれRedisであれ)を検討したり、リクエストバッチで複数のリクエストを一度に送ることを検討したりといった戦術を取ることは可能です。
最適化を早まらない
ソフトウェア工学の世界では「早すぎる最適化は諸悪の根源」という有名な言葉がありますが、ほとんどの経験豊富な開発者はこれに同意するでしょう。パフォーマンスは重要ですが、クライアントに何の影響もないリクエストから数ミリ秒を削り取ろうとすることは、時間を費やす良い方法ではありません。
Nodeのサービスを書くときに、この記事で書いたような配慮は必要ですが、問題がない限り、コードの実行速度を向上させる方法を探すのはやめましょう。ベンチマークや調査を始める前に、問題があることを確認してください。
たまに遅いリクエストがあっても、心配する必要はありません。ネットワークの問題が原因かもしれません。しかし、もしhandlerが常に遅いようなら、改善を始める前に調査してください。根本的な原因を突き止め、その解決がパフォーマンスに影響することを確認してください。
注意しなければならないのは、handlerのレスポンスタイムがリクエストの数に比例して増加する場合です。これは、大量のリクエストを処理するアプリケーションがうまく動作しないことを示す大きな証拠です。
翻訳者あとがき
全ての項目を読んでみると、非常に役に立つプラクティスもあれば、自分達には合わないものもあると思います。
重要なのは、どのような課題感を持ってコードの保守に向き合っているか、ということなのかなと思います。コードの課題について、一緒に働くチームメンバーと共通の認識を得るのには素晴らしいドキュメントだと思いました。
ちなみに、タイトルにある "Tao" というのは、東洋的価値観である "道" のことをを指すようです。つまり、"Tao of Node" というのは、日本語にすると「Node道」とでも訳すのが良いでしょうか。