Help us understand the problem. What is going on with this article?

堅牢な node.js プロジェクトのアーキテクチャとは?

こちらの記事は、Sam Quinn 氏により2019年 4月に公開された『 Bulletproof node.js project architecture 』の和訳です。
本記事は原著者から許可を得た上で記事を公開しています。

GitHub repositoryでの実装例: 2019/04/21 アップデート

始めに

Express.jsは、node.js のREST APIを作成するための優れたフレームワークですが、node.jsプロジェクトの設計方法についての手がかりを与えてくれるものではありません。

ばからしく聞こえるかもしれませんが、この問題は確かに存在するのです。

node.jsプロジェクト構造の正しい設計により、コードの重複を回避でき、安定性を向上させます。また、正しく設計されていれば、サービスをスケールさせるときに役に立つかもしれません。

この記事は、貧弱な構造のnode.jsプロジェクト、望ましくないパターン、そしてコードリファクタリングと構造の改善に無数の時間を費やし対応してきた、長年の経験に基づく広範囲な探求です。

本記事に合わせnode.jsプロジェクトのアーキテクチャを見直すために助けが必要な場合は、santiago@softwareontheroad.comにご連絡ください。

目次

  • フォルダ構造
  • 3層アーキテクチャ
  • サービスレイヤー
  • Pub/Subレイヤー
  • Dependency Injection (DI) --※日本語で「依存の注入」
  • ユニットテスト
  • Cron ジョブと定期的なタスク
  • 構成情報及びシークレット
  • ローダー 例(GitHub repojitory)

フォルダ構造

以下はこれから話を進めていくnode.jsプロジェクトの構造です。

構築するすべてのnode.js REST APIサービスで、これをを使用します。では、それぞれのコンポーネントが何をするのか詳しく見ていきましょう。

 src
  │   app.js          # App entry point
  └───api             # Express route controllers for all the endpoints of the app
  └───config          # Environment variables and configuration related stuff
  └───jobs            # Jobs definitions for agenda.js
  └───loaders         # Split the startup process into modules
  └───models          # Database models
  └───services        # All the business logic is here
  └───subscribers     # Event handlers for async task
  └───types           # Type declaration files (d.ts) for Typescript

単なるJavascript ファイルの並び替えをする方法ではありません..

3層アーキテクチャ

下記のアイデアは、「関心の分離」の原則に基づき、ビジネスロジックをnode.js APIルーティングから分離させるものです。

これはあなたがいつか、CLIツールでビジネスロジックを使用したい、定期的なタスク処理では十分でない、と思うようになったときのためです。

そしてnode.jsサーバーからそれ自体へのAPI呼び出しは、良いアイディアではありません...

コントローラーにビジネスロジックを入れてはダメです!!

express.jsコントローラーを使用してアプリケーションのビジネスロジックを保存したくなるかもしれませんが、これはすぐにスパゲッティコードになります。ユニットテストを書く必要があるときには、リクエストまたはレスポンスexpress.jsオブジェクトの複雑なモックを扱うことになります。

いつ応答を送信するべきかを区別するのは複雑です。 バックグランドで処理が続行され、その後 応答がクライアントに送信されたとしましょう。

以下は望ましくない例です。

route.post('/', async (req, res, next) => {

    // This should be a middleware or should be handled by a library like Joi.
    const userDTO = req.body;
    const isUserValid = validators.user(userDTO)
    if(!isUserValid) {
      return res.status(400).end();
    }

    // Lot of business logic here...
    const userRecord = await UserModel.create(userDTO);
    delete userRecord.password;
    delete userRecord.salt;
    const companyRecord = await CompanyModel.create(userRecord);
    const companyDashboard = await CompanyDashboard.create(userRecord, companyRecord);

    ...whatever...


    // And here is the 'optimization' that mess up everything.
    // The response is sent to client...
    res.json({ user: userRecord, company: companyRecord });

    // But code execution continues :(
    const salaryRecord = await SalaryModel.create(userRecord, companyRecord);
    eventTracker.track('user_signup',userRecord,companyRecord,salaryRecord);
    intercom.createUser(userRecord);
    gaAnalytics.event('user_signup',userRecord);
    await EmailService.startSignupSequence(userRecord)
  });

ビジネスロジックをサービスレイヤーで扱っている

このレイヤーは、ビジネスロジックが存在すべき場所です。

それは、node.jsに適用されるSOLID原則に従って、明確な目的(情報)を持つクラスのコレクションです。

このレイヤーには「SQLクエリ」のいかなるフォームも存在するべきではありません。データアクセス層を使用してください。

  • express.jsルーターからソースコードを遠ざける
  • リクエストまたはレスポンスオブジェクトをサービスレイヤーに渡さない
  • ステータスコードやヘッダーなど、HTTPトランスポートレイヤーに関連するものをサービスレイヤーから返さない

route.post('/', 
    validators.userSignup, // this middleware take care of validation
    async (req, res, next) => {
      // The actual responsability of the route layer.
      const userDTO = req.body;

      // Call to service layer.
      // Abstraction on how to access the data layer and the business logic.
      const { user, company } = await UserService.Signup(userDTO);

      // Return a response to client.
      return res.json({ user, company });
    });

サービスが裏でどのように機能するかを以下に示します。

  import UserModel from '../models/user';
  import CompanyModel from '../models/company';

  export default class UserService {

    async Signup(user) {
      const userRecord = await UserModel.create(user);
      const companyRecord = await CompanyModel.create(userRecord); // needs userRecord to have the database id 
      const salaryRecord = await SalaryModel.create(userRecord, companyRecord); // depends on user and company to be created

      ...whatever

      await EmailService.startSignupSequence(userRecord)

      ...do more stuff

      return { user: userRecord, company: companyRecord };
    }
  }

Pub/Sub レイヤーも利用する

pub / subパターンは,、ここで提案されている従来の3層アーキテクチャを超えていますが、非常に便利です。

すぐにユーザーを作成できるシンプルなnode.js APIエンドポイントは、分析サービスであったり、あるいは電子メールシーケンスの開始などのサードパーティサービスを呼び出そうとするかもしれません。

遅かれ早かれ、そのシンプルな「作成」の操作はいくつかのことを実行し、1,000行にも及ぶコードがすべて1つの関数中で実行されることになるでしょう。

それは単一責任の原則に反しています。

したがって最初から責任を分離しておくほうが良く、それによってコードの保守性を維持できます。

  import UserModel from '../models/user';
  import CompanyModel from '../models/company';
  import SalaryModel from '../models/salary';

  export default class UserService() {

    async Signup(user) {
      const userRecord = await UserModel.create(user);
      const companyRecord = await CompanyModel.create(user);
      const salaryRecord = await SalaryModel.create(user, salary);

      eventTracker.track(
        'user_signup',
        userRecord,
        companyRecord,
        salaryRecord
      );

      intercom.createUser(
        userRecord
      );

      gaAnalytics.event(
        'user_signup',
        userRecord
      );

      await EmailService.startSignupSequence(userRecord)

      ...more stuff

      return { user: userRecord, company: companyRecord };
    }

  }

依存サービスへの呼び出し命令は、最良の方法ではありません。

ここでより良いアプローチは、イベントを発行することです。(例.「ユーザーはこのメールでサインアップしました」)

これで完了です。リスナーの仕事は、リスナーの責任としています。

  import UserModel from '../models/user';
  import CompanyModel from '../models/company';
  import SalaryModel from '../models/salary';

  export default class UserService() {

    async Signup(user) {
      const userRecord = await this.userModel.create(user);
      const companyRecord = await this.companyModel.create(user);
      this.eventEmitter.emit('user_signup', { user: userRecord, company: companyRecord })
      return userRecord
    }

  }

イベントハンドラー/リスナーを複数のファイルに分割できています。

 eventEmitter.on('user_signup', ({ user, company }) => {

    eventTracker.track(
      'user_signup',
      user,
      company,
    );

    intercom.createUser(
      user
    );

    gaAnalytics.event(
      'user_signup',
      user
    );
  })
 eventEmitter.on('user_signup', async ({ user, company }) => {
    const salaryRecord = await SalaryModel.create(user, company);
  })
 eventEmitter.on('user_signup', async ({ user, company }) => {
    await EmailService.startSignupSequence(user)
  })

awaitステートメントをtry-catchブロックにラップする、もしくは単に失敗処理として” unhandledPromise “プロセスとして処理することもできます。

依存性の注入 (D.I.)

依存性の注入(D.I.)、または制御の反転(IoC)は、クラスまたは関数の依存関係をコンストラクターに「注入」または渡すことで、コードの編成に役立つ一般的なパターンです。

このようにすることで、例えばサービスの単体テストを作成するときや、サービスが別のコンテキストで使用されるとき、「互換性のある依存関係」を注入する柔軟性が得られます。

D.I. なしのコード

  import UserModel from '../models/user';
  import CompanyModel from '../models/company';
  import SalaryModel from '../models/salary';  
  class UserService {
    constructor(){}
    Sigup(){
      // Caling UserMode, CompanyModel, etc
      ...
    }
  }

手動でD.I. を実装したコード

 export default class UserService {
    constructor(userModel, companyModel, salaryModel){
      this.userModel = userModel;
      this.companyModel = companyModel;
      this.salaryModel = salaryModel;
    }
    getMyUser(userId){
      // models available throug 'this'
      const user = this.userModel.findById(userId);
      return user;
    }
  }

これでカスタマイズされた依存関係を注入できます。

  import UserService from '../services/user';
  import UserModel from '../models/user';
  import CompanyModel from '../models/company';
  const salaryModelMock = {
    calculateNetSalary(){
      return 42;
    }
  }
  const userServiceInstance = new UserService(userModel, companyModel, salaryModelMock);
  const user = await userServiceInstance.getMyUser('12346');

サービスが持つことのできる依存関係の量は無限で、新しく追加する際にいちいちインスタンス化をリファクタリングするのは、退屈でエラーが発生しやすいタスクです。

そういうわけでDI フレームワークが作成されました。

これにより、クラスで依存関係を宣言し、そのクラスのインスタンスが必要な場合には、 'Service Locator'を呼び出すだけでよくなります。

typedi “を用いてnode.jsにDIをもたらすnpmライブラリの例を見てみましょう。

“ typedi “の使用方法の詳細については公式ドキュメントをご覧ください。

注意: typescript での例

 import { Service } from 'typedi';
  @Service()
  export default class UserService {
    constructor(
      private userModel,
      private companyModel, 
      private salaryModel
    ){}

    getMyUser(userId){
      const user = this.userModel.findById(userId);
      return user;
    }
  }

ここでtypediはUserServiceが必要とする依存関係を解決します。

services/user.js
  import { Container } from 'typedi';
  import UserService from '../services/user';
  const userServiceInstance = Container.get(UserService);
  const user = await userServiceInstance.getMyUser('12346');

サービスロケーター呼び出しの乱用はアンチパターンです

Node.jsのExpress.jsでDIを使用する
express.jsでDIを使用する
これがnode.jsプロジェクトアーキテクチャのパズルの最後のピースです。
↓↓↓↓↓↓↓ あなたの記事の内容

ルーティングレイヤー
───────

ルーティングレイヤー

↑↑↑↑↑↑↑ 編集リクエストの内容

 route.post('/', 
    async (req, res, next) => {
      const userDTO = req.body;

      const userServiceInstance = Container.get(UserService) // Service locator

      const { user, company } = userServiceInstance.Signup(userDTO);

      return res.json({ user, company });
    });

Awesome! 素晴らしいプロジェクトになりました!

とても整理されていて、「今すぐ何かをコーディングしたい!」という気持ちになりますね。

サンプルのレポジトリにアクセスする

単体テストの例

DI とこれらの設計パターンを使用することにより、単体テストは非常にシンプルになります。

リクエスト / レスポンス オブジェクトのモックや “ require … “ などの呼び出しを行う必要はありません。

例:サインアップユーザーメソッドの単体テスト

tests/unit/services/user.js
 import UserService from '../../../src/services/user';

  describe('User service unit tests', () => {
    describe('Signup', () => {
      test('Should create user record and emit user_signup event', async () => {
        const eventEmitterService = {
          emit: jest.fn(),
        };

        const userModel = {
          create: (user) => {
            return {
              ...user,
              _id: 'mock-user-id'
            }
          },
        };

        const companyModel = {
          create: (user) => {
            return {
              owner: user._id,
              companyTaxId: '12345',
            }
          },
        };

        const userInput= {
          fullname: 'User Unit Test',
          email: 'test@example.com',
        };

        const userService = new UserService(userModel, companyModel, eventEmitterService);
        const userRecord = await userService.SignUp(teamId.toHexString(), userInput);

        expect(userRecord).toBeDefined();
        expect(userRecord._id).toBeDefined();
        expect(eventEmitterService.emit).toBeCalled();
      });
    })
  })

Cronジョブと定期的なタスク

ここまででビジネスロジックがサービスレイヤーにカプセル化されたので、Cronジョブから使用するのが簡単になりました。

node.js のsetTimeoutや、その他の原始的なコード実行を遅らせる方法に頼るのではなく、ジョブやデータベース内での処理を永続化するフレームワークを使用するべきです。

こうすることで、失敗したジョブの制御や、成功した人のフィードバックを得ることができます。
node.js.
別の記事で、これらのグッドプラクティスについて既に書いていますので、こちらのガイドを確認してください。

構成情報及びシークレット

node.jsにおいて研鑽された概念である「Twelve-Factor App」に従えば、 APIキーとデータベース文字列の対応情報を保存するもっとも良い方法は、dotenvを使用することです。

決してコミットしてはいけない .envファイルを配置すると(ただし、リポジトリにデフォルト値で存在する必要があります)、 npm パッケージのdotenv
.envファイルをロードし、変数を node.js のprocess.envオブジェクトに挿入します。

これでも十分かもしれませんが、もうワンステップ加えたいと思います。

npmパッケージの dotenv が 参照するディレクトリ(今回の例では /config)配下に" index.js "ファイルを配置し、.envファイルを読み込むことで 、変数を格納するオブジェクトを使用できます。これで構造とコードの自動補完を保持できます。

config/index.js
 const dotenv = require('dotenv');
  // config() will read your .env file, parse the contents, assign it to process.env.
  dotenv.config();

  export default {
    port: process.env.PORT,
    databaseURL: process.env.DATABASE_URI,
    paypal: {
      publicKey: process.env.PAYPAL_PUBLIC_KEY,
      secretKey: process.env.PAYPAL_SECRET_KEY,
    },
    paypal: {
      publicKey: process.env.PAYPAL_PUBLIC_KEY,
      secretKey: process.env.PAYPAL_SECRET_KEY,
    },
    mailchimp: {
      apiKey: process.env.MAILCHIMP_API_KEY,
      sender: process.env.MAILCHIMP_SENDER,
    }
  }

こうすることでprocess.env.MY_RANDOM_VARによってコード記述の氾濫を回避でき、自動補完によって環境変数の命名方法を知る必要がなくなります。

サンプルのレポジトリにアクセスする

ローダー

このパターンはW3Techマイクロフレームワークから取得しましたが、そのパッケージには依存していません。

このアイデアでは、node.jsサービスの起動プロセスをテスト可能なモジュールに分割することが可能です。

古典的なexpress.jsアプリの立ち上げ手順を見てみましょう

  const mongoose = require('mongoose');
  const express = require('express');
  const bodyParser = require('body-parser');
  const session = require('express-session');
  const cors = require('cors');
  const errorhandler = require('errorhandler');
  const app = express();

  app.get('/status', (req, res) => { res.status(200).end(); });
  app.head('/status', (req, res) => { res.status(200).end(); });
  app.use(cors());
  app.use(require('morgan')('dev'));
  app.use(bodyParser.urlencoded({ extended: false }));
  app.use(bodyParser.json(setupForStripeWebhooks));
  app.use(require('method-override')());
  app.use(express.static(__dirname + '/public'));
  app.use(session({ secret: process.env.SECRET, cookie: { maxAge: 60000 }, resave: false, saveUninitialized: false }));
  mongoose.connect(process.env.DATABASE_URL, { useNewUrlParser: true });

  require('./config/passport');
  require('./models/user');
  require('./models/company');
  app.use(require('./routes'));
  app.use((req, res, next) => {
    var err = new Error('Not Found');
    err.status = 404;
    next(err);
  });
  app.use((err, req, res) => {
    res.status(err.status || 500);
    res.json({'errors': {
      message: err.message,
      error: {}
    }});
  });


  ... more stuff 

  ... maybe start up Redis

  ... maybe add more middlewares

  async function startServer() {    
    app.listen(process.env.PORT, err => {
      if (err) {
        console.log(err);
        return;
      }
      console.log(`Your server is ready !`);
    });
  }

  // Run the async function to start our server
  startServer();

ご覧のとおり、アプリケーションのこの部分は非常に煩雑化しています。

これに関して効果的な対処法は以下です。

  const loaders = require('./loaders');
  const express = require('express');

  async function startServer() {

    const app = express();

    await loaders.init({ expressApp: app });

    app.listen(process.env.PORT, err => {
      if (err) {
        console.log(err);
        return;
      }
      console.log(`Your server is ready !`);
    });
  }

  startServer();

ここでローダーは、簡潔な目的を持つ単なる小さなファイルです

loaders/index.js
  import expressLoader from './express';
  import mongooseLoader from './mongoose';

  export default async ({ expressApp }) => {
    const mongoConnection = await mongooseLoader();
    console.log('MongoDB Intialized');
    await expressLoader({ app: expressApp });
    console.log('Express Intialized');

    // ... more loaders can be here

    // ... Initialize agenda
    // ... or Redis, or whatever you want
  }

express ローダー

loaders/express.js
  import * as express from 'express';
  import * as bodyParser from 'body-parser';
  import * as cors from 'cors';

  export default async ({ app }: { app: express.Application }) => {

    app.get('/status', (req, res) => { res.status(200).end(); });
    app.head('/status', (req, res) => { res.status(200).end(); });
    app.enable('trust proxy');

    app.use(cors());
    app.use(require('morgan')('dev'));
    app.use(bodyParser.urlencoded({ extended: false }));

    // ...More middlewares

    // Return the express app
    return app;
  })

mongo ローダー

loaders/mongoose.js
  import * as mongoose from 'mongoose'
  export default async (): Promise<any> => {
    const connection = await mongoose.connect(process.env.DATABASE_URL, { useNewUrlParser: true });
    return connection.connection.db;
  }

ローダーの完全な例はこちらをご覧ください

最後に..

ここまでで、私達は実績のあるnode.jsプロジェクトストラクチャについて深く理解できました。要約すると下記のような内容でしたね。

  • 3層アーキテクチャを使用する
  • ビジネスロジックをexpress.jsコントローラーに入れない
  • PubSubパターンを使用してバックグラウンドタスクのイベントを発行する
  • 負担を減らすためDI を実装する
  • パスワード、シークレット、APIキーなどを漏らさないために構成マネージャーを使用する
  • node.jsサーバー構成を、個別にロードできる小さな- モジュールに分割する

リポジトリの例はこちらからご覧ください。

ちょっと待って!まだ続きがあります。

この記事を楽しんでいただけたら、他の有益な情報も見逃すことがないように、私のメーリングリストを購読することをお勧めします。

何かを売りつけるようなことはしません。約束します!

今後の投稿もお見逃しなく!きっと気に入ってくれると思います :)

この記事のような、すごい記事がたくさんあるので、是非私のブログに来てください。

翻訳協力

Original Author: Sam Quinn
Thank you for letting us share your knowledge!

この記事は以下の方々のご協力により公開する事が出来ました。
改めて感謝致します。
選定担当: @aoharu
翻訳担当: @upaldus
監査担当: @aoharu
公開担当: @posaune0423

私たちと一緒に記事を作りませんか?

私たちは、海外の良質な記事を複数の優秀なエンジニアの方の協力を経て、日本語に翻訳し記事を公開しています。
活動に共感していただける方、良質な記事を多くの方に広めることに興味のある方は、ぜひご連絡ください。
Mailでタイトルを「参加希望」としたうえでメッセージをいただく、もしくはTwitterでメッセージをいただければ、選考のちお手伝いして頂ける部分についてご紹介させていただく事が可能です。
※ 頂いたメッセージには必ずご返信させて頂きます。

ご意見・ご感想をお待ちしております

今回の記事は、いかがだったでしょうか?
・こうしたら良かった、もっとこうして欲しい、こうした方が良いのではないか
・こういったところが良かった
などなど、率直なご意見を募集しております。
いただいたお声は、今後の記事の質向上に役立たせていただきますので、お気軽にコメント欄にてご投稿ください。Twitterでもご意見を受け付けております。
みなさまのメッセージをお待ちしております。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away