【Node.js】TypeScript & ExpressでAPIサーバーを設計・開発してみる

node.js with typescript

❄️ 使用技術

仕事上、Node.jsでReact用のBFFをつくる機会があり (無事にリリースして数ヶ月以上特に大きな問題もなく本番環境で安定稼働中です) 、 その時の経験をもとに普段お世話になってるRailsを参考に色々と試行錯誤しながらTypeScript & Node.jsでの雛形を作ってみました。こちらにサンプルとして配置しています。特別なこだわりがない場合には、TypeScriptとの親和性がつよい Visual Studio Codeをエディタとしておすすめします。

Github: https://github.com/AtaruOhto/node-api-server-starter

🌱 もくじ

🌾 ディレクトリ構成

ほとんどRailsを参考に構築していますが、SequelizeなどのORMに合わせることを考慮して、いくつか改変した場所もあります。 Node.jsのコールバックは利用せずにほぼPromiseで記述しています。あくまで一例であり、色々と改善の余地があると思いますが、下記のような構成にしています。 テストファイルなどはそれぞれのファイルの隣に配置する形式にしています。

  • models: ユーザーなどのモデルとそれに紐づくロジックを格納します。
  • controllers: ユーザーのリクエストのハンドラー関数を格納します。
  • helpers: アプリケーションのあらゆる箇所で使用される関数等を格納します。
  • middlewares: Expressミドルウェア全般を格納します。
  • config: データベースの設定やアプリケーションのポート番号など設定に関する定義やロジックを格納します。
  • scripts: データベース作成や、マイグレーションなどyarn コマンドから呼び出すscriptを記述します。

🍄 事前にインストールする必要があるもの。

* Node.js (8系)
* Yarn
* direnv (https://qiita.com/kompiro/items/5fc46089247a56243a62)
* MySQL

🐕【一】動かしてみる

リポジトリをクローンする。

git clone git@github.com:AtaruOhto/node-api-server-starter.git
cd node-api-server-starter

npmモジュールをインストールする。

# npmモジュールのインストール
yarn

環境変数ファイルをコピーする。

# 環境変数ファイルのコピー
cp .envrc.sample .envrc

secretを生成して、「.envrc」にコピーする。 出力された文字列、下記の「export SECRET_KEY_BASE=xxxxx」の部分を 「.envrc」に追記します。


yarn run secret


# 出力された下記の文を .envrc に追記する
# export SECRET_KEY_BASE=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

デフォルトでユーザー名は root , パスワードは pass , ホストは localhost ポート番号は 3306番ポート に設定しています。変更する場合には .envrc を編集してください。 DB_USER, DB_PASS, DB_PORT, DB_HOST を環境に従って編集します。direnvを使わない場合にはOSの環境変数に設定するなどするといいと思います。

# .envrc

export DB_USER='root'
export DB_PASS='pass'
export DB_HOST='127.0.0.1'
export DB_PORT=3306

データベースを MySQL から変更するには src/config/dsatabase.tsdialectを編集します。

# src/config/dsatabase.ts

export const DB_CONFIG = {
  ...
  dialect: 'mysql',
  ...
};

環境変数を編集して、データベースに接続できるように .envrc を編集したら、下記のコマンドを打って環境変数をロードします。

# 環境変数のロード
direnv allow

次にデータベースを作成、マイグレーションを行い、シードを流し込みます。

yarn run db:create
yarn run db:migrate
yarn run db:seed

サーバーを起動します。デフォルトでは 3000番ポート で起動します。

yarn start

curlコマンドでアプリに向けて、JWTトークンを発行するようにリクエストします。返ってきた値がAPIを叩くために必要になる秘密の認証用トークンです。

# /sessions#post を叩く。内部的には src/controllers/api/v1/sessions/index.tsのsessionsCreate()メソッドでハンドルされています 
curl -X POST http://localhost:3000/sessions  --data 'name=Erich&password=password'

下記のようなデータが返ってきます。dataの部分 (jwtトークン) はそれぞれ異なった値が返ってきます。

# 例: dataの部分のトークンは毎回変わります。
{
    "data":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJoYXNoIjp7Im5hbWUiOiJFcmljaCJ9LCJpYXQiOjE1MzUyMDUzMDIsImV4cCI6MTUzNTI5MTcwMn0.DRCHA1qRwrmpBscw_ZFAde6tBPJEb7IiCso9-mXG2Gk",
    "status":200
}

「Bearerの後、半角スペースを一つ空けて」実際に返ってきたdata の部分のJWTトークンをサーバー側に送ります。認証が求められるユーザー一覧取得のAPIを叩きます。

# 例: Bearer の後には上のコマンドを叩いて、返ってきた値を使います
curl -X GET http://localhost:3000/users -H "X-Auth-Token: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJoYXNoIjp7Im5hbWUiOiJFcmljaCJ9LCJpYXQiOjE1MzUyMDUzMDIsImV4cCI6MTUzNTI5MTcwMn0.DRCHA1qRwrmpBscw_ZFAde6tBPJEb7IiCso9-mXG2Gk"

シードで流したユーザー一覧が取得できます。

{
    "data":
        [
            {"id":9,"name":"Erich","password":"$2b$10$5oo2y/pqQ.NTcaQgL4DF3ODlM3DKDsyiQZgnu5seQS/vUN1lkI8ua"},
            {"id":10,"name":"Richard","password":"$2b$10$5oo2y/pqQ.NTcaQgL4DF3ODlM3DKDsyiQZgnu5seQS/vUN1lkI8ua"},
            {"id":11,"name":"Ralph","password":"$2b$10$5oo2y/pqQ.NTcaQgL4DF3ODlM3DKDsyiQZgnu5seQS/vUN1lkI8ua"},
            {"id":12,"name":"John","password":"$2b$10$5oo2y/pqQ.NTcaQgL4DF3ODlM3DKDsyiQZgnu5seQS/vUN1lkI8ua"}
        ]
    ,"status":200
}

間違ったトークンや不正なリクエストを送ると下記のようなレスポンスが返ってきます。試しにトークンから一文字削除して、誤ったトークンの値を送ってみましょう。 ステータスが400のレスポンスが返ってきます。

{
    "data":{},
    "status":400
}

下記のコマンドでテストが走ります。テストファイルはテスト対象のファイルと同じディレクトリに格納する形式をとっています。

# テストを実行する前にテスト用データベースの作成やテーブルの作成を行います。
yarn run db:create:test
yarn run db:migrate:test
# テスト実行
yarn run test

🐈【二】開発してみる

新しいモデルを追加して、それに対応するマイグレーションやコントローラーを記述してみます。

Terminal で下記コマンドをそれぞれ別タブで起動。

yarn run watch (TypeScriptのwatchビルド)
yarn run dev (ソースコード変更を検知しての自動再起動)
`

新規モデルの追加

src/models/framework/index.ts ファイルを追加します。

/* src/models/framework/index.ts */

import Sequelize from 'sequelize';

import { sequelizeInstance } from 'config/database';

export const FrameworksTable = {
  name: 'frameworks',
  schema: {
    id: {
      allowNull: false,
      autoIncrement: true,
      primaryKey: true,
      type: Sequelize.INTEGER,
    },
    name: {
      type: Sequelize.STRING,
      allowNull: false,
      unique: true,
    },
    language: {
      allowNull: false,
      type: Sequelize.STRING,
    },
    createdAt: {
      allowNull: false,
      type: Sequelize.DATE,
    },
    updatedAt: {
      allowNull: false,
      type: Sequelize.DATE,
    },
  },
};

export const Framework = sequelizeInstance.define(
  FrameworksTable.name,
  FrameworksTable.schema,
);

マイグレーションの追加

※ マイグレーションでバージョンごとの管理を正確に行いたい場合にはnode-db-migrate などの専用のマイグレーションツールを使用してください。

src/scripts/migrations/createFrameworks.ts を追加します。

/* src/scripts/migrations/createFrameworks.ts */

import { Framework } from 'models/framework';

export const createFrameworkMigrate = () =>
  new Promise(async (resolve, reject) => {
    try {
      await Framework.sync();
      resolve();
    } catch (e) {
      reject(e);
    }
  });

src/scripts/migrations.ts から呼び出しを追加します。

/* src/scripts/migrations.ts */

import { createFrameworkMigrate } from './migrations/createFrameworks';

(async () => {
  ...
  /* 追加 */
  await createFrameworkMigrate();
  ...

  sequelizeInstance.close();
})();

シードデータの追加

src/scripts/seeds/frameworks.ts にシードデータの追加処理を記述します。

/* src/scripts/seeds/frameworks.ts */

import { Framework } from 'models/framework';

export const seedFrameworks = () =>
  new Promise(async (resolve, reject) => {
    await Framework.bulkCreate([
      {
        name: 'Express',
        language: 'JavaScript',
      },
      {
        name: 'Ruby on Rails',
        language: 'Ruby',
      },
      {
        name: 'Django',
        language: 'Python',
      },
      {
        name: 'Laravel',
        language: 'PHP',
      },
    ]).catch(e => {
      console.log(e);
    });
    resolve();
  });

上記で実装した追加処理の呼び出しを記述します。

/* src/scripts/seeds.ts */

import { seedFrameworks } from './seeds/frameworks';

(async () => {
  ...
  await seedFrameworks();
  sequelizeInstance.close();
})();

マイグレーション & シードデータの流し込み

下記コマンドを打って、データベースを作成、マイグレーション、シードデータを流し込みます。

yarn run  db:create
yarn run  db:migrate
yarn run  db:seed

モデルのテスト

src/spec/factories/frameworkFactory.ts を作成します。

/*src/spec/factories/frameworkFactory.ts*/

import { Framework } from 'models/framework';

export const TEST_FRAMEWORK = 'GreatFramework';
export const TEST_LANGUAGE = 'whiteSpace';

export const destroyTestFramework = () =>
  new Promise(async resolve => {
    await Framework.destroy({
      where: {
        name: TEST_FRAMEWORK,
      },
    });
    resolve();
  });

export const findOrCreateTestFramework = (otherAttrs: any) =>
  new Promise(async resolve => {
    const instance = await Framework.findOrCreate({
      where: {
        name: TEST_FRAMEWORK,
        language: TEST_LANGUAGE,
      },
      defaults: otherAttrs,
    });
    resolve(instance);
  });

モデルのテストを記述します。

src/models/framework/spec.ts を記述します。

import { Framework } from 'models/framework';
import assert from 'power-assert';
import {
  destroyTestFramework,
  findOrCreateTestFramework,
  TEST_FRAMEWORK,
} from 'spec/factories/frameworkFactory';

describe('Framework', () => {
  describe('Positive', () => {
    beforeEach(() =>
      new Promise(async resolve => {
        await findOrCreateTestFramework({});
        resolve();
      }));

    afterEach(() =>
      new Promise(async resolve => {
        await destroyTestFramework();
        resolve();
      }));

    it('success', () =>
      new Promise(async (resolve, reject) => {
        const framework = (await Framework.findOne({
          where: { name: TEST_FRAMEWORK },
        })) as any;
        assert.equal(framework.name, TEST_FRAMEWORK);
        resolve();
      }));
  });

  describe('Negative', () => {
    it('fail without language', () =>
      new Promise(async (resolve, reject) => {
        try {
          await Framework.create({
            name: 'foobarFramework',
          });
        } catch (e) {
          resolve();
        }
      }));
  });
});

テストを走らせてみます。

yarn run db:create:test
yarn run db:migrate:test
yarn run db:seed:test
yarn run test

一例として、正常にFrameworkモデルが作成できることと、NOT_NULL制約がかかっている language を欠いたFrmeworkモデルを create しようとすると例外が起きるといったことをテストしています。

コントローラーへのアクションの追加

frameworkをすべて取得するアクションを定義します。

src/controllers/api/v1/frameworks.ts を追加する。

import { Request, Response } from 'express';

import { respondWith } from 'helpers/response';
import { Framework } from 'models/framework';

export const frameworksIndex = async (req: Request, res: Response) => {
  try {
    const frameworks = await Framework.findAll();
    respondWith(res, 200, frameworks);
  } catch (e) {
    respondWith(res, 500);
  }
};

新規ルーティングの追加

src/config/path.ts にパスを追加する。

/* src/config/path.ts */

export const path = {
  ...
  /* 追加 */
  frameworks: '/frameworks/'
};

config/routes.tsdefineRoutes() にルート定義を追加する。

import { frameworksIndex } from 'controllers/api/v1/frameworks';

export const defineRoutes = (app: Express) => {
  ...
  /* 追加 */
  app.get(path.frameworks, frameworksIndex);
  ...
};

作成したルーティングを試してみる。

それぞれ Terminal の別ウィンドウで実行します。

yarn run watch
yarn run dev

curlを使って定義したルーティングを叩いてみます。

 curl -X GET http://localhost:3000/frameworks 

すると下記のようにシードで流し込んだフレームワーク一覧のJSONデータが返ってきます。

{"data":
    [
        {"id":1,"name":"Express","language":"JavaScript"},
        {"id":2,"name":"Ruby on Rails","language":"Ruby"},
        {"id":3,"name":"Django","language":"Python"},
        {"id":4,"name":"Laravel","language":"PHP"}
    ],"
    status":200
}

コントローラーのテストを記述する

  • src/controllers/api/v1/frameworks/spec.ts
/* src/controllers/api/v1/frameworks/spec.ts */

import assert from 'power-assert';
import request from 'supertest';

import { path } from 'config/path';
import { app } from 'index';
import {
  destroyTestFramework,
  findOrCreateTestFramework,
  TEST_FRAMEWORK,
} from 'spec/factories/frameworkFactory';

describe(`Framework Controller`, () => {
  beforeEach(() =>
    new Promise(async resolve => {
      await findOrCreateTestFramework({});
      resolve();
    }));

  afterEach(() =>
    new Promise(async resolve => {
      await destroyTestFramework();
      resolve();
    }));

  describe('Create', () => {
    describe(`Positive`, () =>
      it('User will be successfully created', () =>
        new Promise(resolve => {
          request(app)
            .get(path.frameworks)
            .set('Accept', 'application/json')
            .then(async (res: any) => {
              const framework = res.body.data.filter(
                (elem: any) => elem.name === TEST_FRAMEWORK,
              );
              assert.equal(framework[0].name, TEST_FRAMEWORK);
              resolve();
            });
        })));
  });
});

テストを走らせてみます。

# テストを実行する前にテスト用データベース、テーブルの作成を実行。
yarn run db:create:test
yarn run db:migrate:test
yarn run test

まとめ

Expressなど軽めのフレームワークを使うとRailsなどと比べてデファクトな構成というものが無いので、ある程度自分で構成を考える必要がありますが TypeScriptで記述して型の恩恵を受けつつ、テストを念入りに書いていくことで、Node.js製のメンテナブルなアプリを作っていける環境が昔に比べて整ってきた感じがあります。

特にTypeScriptの躍進がNode.jsでのアプリ開発にもたらすは恩恵は大きいかなと個人的に思っています。機会があればloopbackなどのフレームワークも試してみたいと思います。

  • このエントリーをはてなブックマークに追加