20
19

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

KubernetesとNode.jsでマイクロサービスを作成する 2/6 Tweetサービス

Last updated at Posted at 2019-03-27

第2章 Tweetサービス

本章ではNode.jsを利用してTweetの作成/取得を行うTweetサービスを作成します。
Node.jsを利用したREST APIサービスの作成における(著者的)ベストプラクティスな内容を記載しているため、ボリュームはけっこう大きめとなっております。

なお、Kubernetesだけ触りたいという方は以下の完成済みのリポジトリをforkすることで、この章実装をスキップすることができます。

reireias/microservice-sample-tweet

チュートリアル全体

構成

microservice-tutorial01.png

システム構成

Tweetサービスのシステム構成は以下のようにします。

要素 採用技術
言語 Node.js
フレームワーク express
DB MongoDB
DB用ライブラリ mongoose
テストフレームワーク ava

REST API

まずはTweetサービスが必要なAPIについて設計していきます。

Tweetサービスに関係ありそうな操作は以下になります。

  • ツイートする
  • ユーザーのタイムラインを取得する
    • タイムラインにはユーザー自身のツイートと、ユーザーがフォローしているツイートが表示される

ツイートに関しては、拡張性を考えてCRUD操作全てを実装しておきましょう。

タイムラインに関してはTweetサービス単体ではユーザーのフォロー関係はわかりません。
なので、POSTのbodyに取得対象となるユーザーのID配列を入れて取得する方式とします。

まとめると、Tweetサービスでは以下の表のようなREST APIを作成します。

method path description
GET /tweets ツイート一覧取得
POST /tweets ツイート作成
GET /tweets/{id} ツイート取得
DELETE /tweets/{id} ツイート削除
POST /timeline ユーザーのタイムライン取得

DBスキーマ

続いてDBに保存するデータについて設計を行います。

Tweetサービスで永続化すべきデータは(今の所)ツイートのみです。

Tweetデータが持つ情報としては以下になりそうです。

  • 投稿者
  • 投稿日時
  • ツイート内容

これをMongoDBのスキーマに落とし込むと、次のように定義するのが妥当でしょう。

tweet document
{
  _id:       ObjectId,
  userId:    ObjectId,
  content:   String,
  createdAt: Date
}

ObjectId型はMongoDBが生成するID型を表しています。
投稿者に関しては詳細な情報はUserサービスから取得すると思うので、ここではユーザーのIDのみ保持する設計としています。

実装

では、Tweetサービスを実装していきましょう。

リポジトリ作成

GitHubにリポジトリを作成しましょう。
名前はmicroservice-sample-tweetとします。
プライベートリポジトリでもパブリックリポジトリでも、どちらでも問題ありません。

作成後、git cloneでリポジトリをローカルにcloneします。

git clone https://github.com/<username>/microservice-sample-tweet.git

また、.gitignoreファイルを以下の内容で作成しておきましょう。

gitignore
node_modules
yarn-error.log

プロジェクトの初期化

cloneしたリポジトリへ移動します。

cd microservice-sample-tweet

yarn initでプロジェクトを初期化します。

yarn init
# 対話形式でプロジェクトの設定を行う
# 基本的には任意の値で問題ないが、entry pointにはapp.jsを指定すること
yarn init v1.13.0
question name (microservice-sample-tweet): tweet
question version (1.0.0): 
question description: Tweet service.
question entry point (index.js): app.js
question repository url (https://github.com/reireias/microservice-sample-tweet): 
question author (reireias <reireias@gmail.com>): 
question license (MIT): 
question private: 
success Saved package.json

スタイルチェックとコードフォーマッタ

次にコードの品質を担保するために、eslintprettierを追加します。

eslintはコードがコーディングルールに違反していないかをチェックするスタイルチェックツールです。
prettierはコードをルールに基づき、フォーマット(整形)するツールです。
これらを導入することで、チーム開発でも統一されたスタイルで実装できますし、レビュー時の無駄な指摘も減らすことができます。(個人的には必ず導入すべきだと思っています)

以下のコマンドでnpmパッケージをプロジェクトへ追加します。

yarn add -D eslint eslint-config-standard eslint-plugin-standard eslint-plugin-promise eslint-plugin-import eslint-plugin-node prettier eslint-config-prettier eslint-plugin-prettier

-DオプションはdevDependencies(開発時の依存)への追加のオプションです。
production環境用のビルドには含めないnpmパッケージはこちらに追加します。

eslintの設定ファイル.eslintrc.jsを以下のように作成します。

.eslintrc.js
module.exports = {
  root: true,
  env: {
    browser: true,
    node: true
  },
  parserOptions: {
    ecmaVersion: 2018
  },
  extends: [
    'standard',
    'plugin:prettier/recommended'
  ],
  plugins: [
    'prettier'
  ],
  // add your custom rules here
  rules: {}
}

prettierの設定ファイル.prettierrcを以下のように作成します。

.prettierrc
{
  "semi": false,
  "singleQuote": true
}

最後にpackage.jsonに設定を追加し、yarnから実行できるようにしましょう。
場所はどこでもよいのですが、普段私はlicenseの下に記述しています。

package.json
...
  "license": "MIT",
  "scripts": {
    "lint": "eslint --ext .js --ignore-path .gitignore ."
  },
  "devDependencies": {
...

jsファイルを適当に作成し、lintコマンドを実施してみましょう。
Doneと表示されれば問題ありません。

touch app.js
yarn lint

次にprettierの動作を確認してみます。
app.jsの内容を下記のように変更します。

app.js
const a = "hoge"
const b = 1+2

ターミナルからprettierを実行します。

yarn run prettier --write app.js

すると、下記のようにapp.jsの中身が変更されているはずです。

app.js
const a = 'hoge'
const b = 1 + 2

eslintprettierもいちいちターミナルから実行するのは面倒ですよね?
なので、VimやVisual Studio Codeといったエディタのプラグイン等を利用し、エディタから実行or自動実行されるように設定することを推奨します。
(私はVimでALEというプラグインを利用してコードが変更される度に非同期実行しています)

コミット時に自動でyarn lintを実行する

前節でeslintprettierを追加しましたが、すべての開発者がこれらのツールを必ず実行し、コードをクリーンに保ってくれるとは限りません。実行せずにスタイルに違反したコードをコミットしてしまう開発者も存在します。(私は何度もそういった現場に遭遇してきました。)

こういった問題を避けるためにはどうすればよいでしょうか?

主に2つの解決策があります。

  • Gitフックを利用する
  • CIで継続的にチェックする

両方実施するのが望ましいのですが、まずはGitフックを利用した自動実行を導入してみましょう。

Gitはcommitpush等のgitコマンドの実行前/実行後に任意のスクリプトを実行する機能です。
詳しい説明は公式ドキュメントを参照してください。

リポジトリ内の.git/hooksディレクトリ以下にスクリプトを設置すれば設定できるのですが、Node.jsの場合はhuskyというnpmモジュールを利用してこれらをpackage.jsonで管理することが可能です。

以下のコマンドでプロジェクトにhuskyを追加します。

yarn add -D husky

package.jsongit commit前にyarn lintを実施する設定を追加します。
scriptsの下に下記の設定を追記しましょう。

package.json
...
  "scripts": {
    "lint": "eslint --ext .js --ignore-path .gitignore ."
  },
  "husky": {
    "hooks": {
      "pre-commit": "yarn lint"
    }
  },
...

現在、app.jsprettierでフォーマットしましたが、変数の未使用によりeslintのエラーがでる状態です。(yarn lintを実施すればエラーがでます)
この状態でコードをコミットしてみましょう。

git add -A
git commit
# コミットメッセージを記述するエディタは開かず、以下のように実行結果が出力されます。
husky > pre-commit (node v11.9.0)
yarn run v1.13.0
$ eslint --ext .js --ignore-path .gitignore .

/home/takumi/dev/src/github.com/reireias/microservice-sample-tweet/app.js
  1:7  error  'a' is assigned a value but never used  no-unused-vars
  2:7  error  'b' is assigned a value but never used  no-unused-vars

✖ 2 problems (2 errors, 0 warnings)

error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.
husky > pre-commit hook failed (add --no-verify to bypass)

これでスタイルチェックをコミット時に強制できるようになりました。
(しかし、huskyをインストールしていない場合や、.git/hooks以下のスクリプトを削除してしまえば回避は可能なので、CIでもチェックする必要があると思います)

最後にapp.jsを空にし、ここまでの実装はコミットしておきましょう。

expressでのREST API実装

次にREST APIを実装していきます。

npmモジュールexpressmorganをプロジェクトに追加します。

yarn add express morgan body-parser

expressはNode.jsのweb serverフレームワーク、morganexpress用のアクセスログ出力ツールになります。
body-parserexpressのリクエストボディでJSONを利用できるようにするモジュールです。

続いて、controllersディレクトリとcontrollers/v1ディレクトリを作成します。
なお、ディレクトリ構成に関しては、Best practices for Express app structureを参考にしています。

mkdir -p controllers/v1

/v1ディレクトリを作成した理由としては、APIはhttp://localhost/v1/tweetsのようなパスにすることで、将来の破壊的変更時に/v2パスで新規APIを提供できるようにするためです。

GET /v1/tweetsにダミー応答を返す実装をしてみましょう。

app.js
const express = require('express')
const morgan = require('morgan')
const bodyParser = require('body-parser')
const app = express()
app.use(morgan('short'))

// server
app.use(bodyParser.urlencoded({ extended: true }))
app.use(bodyParser.json())
app.use(require('./controllers'))

app.listen(process.env.PORT || 3000, () => {})
controllers/index.js
const express = require('express')
const tweets = require('./v1/tweets.js')

const router = express.Router()

router.use('/v1/tweets', tweets)

module.exports = router
controllers/v1/tweets.js
const express = require('express')
const router = express.Router()

router.get('/', (req, res, next) => {
  res.status(200).json({ message: 'hello' })
})

module.exports = router

起動用のスクリプトをpackage.jsonに定義します。

package.json
...
  "scripts": {
    "start": "node app.js",
    "lint": "eslint --ext .js --ignore-path .gitignore ."
  },
...

では、app.jsを実行し、URLにアクセスしてみましょう。

# サーバー起動
yarn start
# 別ターミナルで実行
curl http://localhost:3000/v1/tweets
# {"message":"hello"} が表示される

nodemonによるホットリロード

Node.jsでの開発では、開発スピードを向上させるためにホットリロード機能を利用します。
今回はnodemonを導入し、ホットリロードを実現します。

yarn add -D nodemon

package.jsonのスクリプトにdevを追加します。

package.json
...
  "scripts": {
    "start": "node app.js",
    "dev": "NODE_ENV=development nodemon ./app.js",
    "lint": "eslint --ext .js --ignore-path .gitignore ."
  },
...

yarn devでサーバーを起動した後、controllers/v1/tweets.js内のhellohogeに書き換えて見ましょう。
restartのログが出力され、アクセスすると実際に変更された値が返ってくるはずです。

yarn dev
yarn run v1.13.0
$ NODE_ENV=development nodemon ./app.js
[nodemon] 1.18.10
[nodemon] to restart at any time, enter `rs`
[nodemon] watching: *.*
[nodemon] starting `node ./app.js`
# hogeに変更した後、restartされる
[nodemon] restarting due to changes...
[nodemon] starting `node ./app.js`
# curlでアクセスすると、返ってくる値が変わっている

最後に実装予定のREST APIのメソッドすべてを作成しておきましょう。
DBへのRead/Writeといった中身のロジックは次の節で実装しますので、ダミーの実装とします。

controllers/index.jsにタイムライン用のコントローラーを追加します。

controllers/index.js
const express = require('express')
const tweets = require('./v1/tweets.js')
const timeline = require('./v1/timeline.js')

const router = express.Router()

router.use('/v1/tweets', tweets)
router.use('/v1/timeline', timeline)

module.exports = router

controllers/v1/tweets.jsには/tweetsへのCRUD操作を一通り追加します。

controllers/v1/tweets.js
const express = require('express')
const router = express.Router()

router.get('/', (req, res, next) => {
  res.status(200).json({ message: 'hello' })
})

router.get('/:id', (req, res, next) => {
  res.status(200).json({ message: 'hello' })
})

router.post('/', (req, res, next) => {
  res.status(200).json({ message: 'hello' })
})

router.delete('/:id', (req, res, next) => {
  res.status(200).json({ message: 'hello' })
})

module.exports = router

controllers/v1/timeline.jsを以下の内容で作成します。

controllers/v1/timeline.js
const express = require('express')
const router = express.Router()

router.post('/', (req, res, next) => {
  res.status(200).json({ message: 'hello' })
})

module.exports = router

実装ができたらcurlコマンドで各パス、各メソッドにアクセスして、レスポンスが返ってくることを確認してみましょう。

MongoDBへの保存

それではDBへのRead/Write部分を実装していきましょう。

まずはDB関連のnpmモジュールを追加します。

yarn add mongodb mongoose

モデルを実装するディレクトリを作成します。

mkdir models

Tweetモデルを次のように実装します。

models/tweet.js
const mongoose = require('mongoose')
const Schema = mongoose.Schema

const Tweet = new Schema(
  {
    userId: { type: Schema.Types.ObjectId, required: true },
    content: {
      type: String,
      required: true,
      minlength: 1,
      maxlength: 140
    },
    createdAt: { type: Date, default: Date.now }
  },
  {
    versionKey: false
  }
)

exports.Tweet = mongoose.model('Tweet', Tweet)

app.jsにMongoDBへのコネクションの作成を追加します。

app.js
const express = require('express')
const morgan = require('morgan')
const bodyParser = require('body-parser')
const mongoose = require('mongoose')
const app = express()
app.use(morgan('short'))

// database
const dbUrl = process.env.MONGODB_URL || 'mongodb://localhost:27017/tweet'
const options = { useNewUrlParser: true }
if (process.env.MONGODB_ADMIN_NAME) {
  options.user = process.env.MONGODB_ADMIN_NAME
  options.pass = process.env.MONGODB_ADMIN_PASS
  options.auth = { authSource: 'admin' }
}
mongoose.connect(dbUrl, options)

// server
app.use(bodyParser.urlencoded({ extended: true }))
app.use(bodyParser.json())
app.use(require('./controllers'))

app.listen(process.env.PORT || 3000, () => {})

controllers/v1/tweets.jsの実装をDBを利用するように変更します。

controllers/v1/tweets.js
const express = require('express')
const model = require('../../models/tweet.js')
const Tweet = model.Tweet
const router = express.Router()

router.get('/', (req, res, next) => {
  ;(async () => {
    const tweets = await Tweet.find({}, null, {
      sort: { createdAt: -1 }
    }).exec()
    res.status(200).json(tweets)
  })().catch(next)
})

router.get('/:id', (req, res, next) => {
  ;(async () => {
    try {
      const tweet = await Tweet.findById(req.params.id).exec()
      if (tweet) {
        res.status(200).json(tweet)
      } else {
        res.status(404).json({ error: 'NotFound' })
      }
    } catch (err) {
      console.error(err)
      res.status(400).json({ error: 'BadRequest' })
    }
  })().catch(next)
})

router.post('/', (req, res, next) => {
  ;(async () => {
    try {
      const record = new Tweet({
        userId: req.body.userId,
        content: req.body.content
      })
      const savedRecord = await record.save()
      res.status(200).json(savedRecord)
    } catch (err) {
      console.error(err)
      res.status(400).json({ error: 'BadRequest' })
    }
  })().catch(next)
})

router.delete('/:id', (req, res, next) => {
  ;(async () => {
    try {
      const removedRecord = await Tweet.findByIdAndDelete(req.params.id).exec()
      if (removedRecord) {
        res.status(200).json({})
      } else {
        res.status(404).json({ error: 'NotFound' })
      }
    } catch (err) {
      console.error(err)
      res.status(400).json({ error: 'BadRequest' })
    }
  })().catch(next)
})

module.exports = router

controllers/v1/timeline.jsも同様に実装します。

controllers/v1/timeline
const express = require('express')
const model = require('../../models/tweet.js')
const Tweet = model.Tweet
const router = express.Router()

router.post('/', (req, res, next) => {
  ;(async () => {
    const userIds = req.body
    const tweets = await Tweet.find({ userId: { $in: userIds } }, null, {
      sort: { createdAt: -1 }
    }).exec()
    res.status(200).json(tweets)
  })().catch(next)
})

module.exports = router

さて、yarn devでサーバーを起動し、実装通り動くか試したいところですが、接続先のMongoDBを用意する必要があります。
ローカルにインストールしてもいいですし、Dockerを利用して用意してもいいでしょう。

下記はDockerを利用してMongoDBコンテナを実行する例になります。

docker run -p 27017:27017 --name mongodb -d mongo

app.jsに実装したように、環境変数MONGODB_URLが空の場合はlocalhost:27017に接続するようになっています。
なので、上記コマンドでMongoDBを起動した場合はyarn devで用意したMongoDBコンテナに接続できます。

では、サーバーを起動してみましょう。

yarn dev

curlコマンドを使って動作を確認してみます。

# tweet一覧取得(空配列が返る)
curl http://localhost:3000/v1/tweets
# []

# tweet作成
curl -X POST -H "Content-Type: application/json" http://localhost:3000/v1/tweets -d '{"userId": "000000000000000000000000", "content": "hello world."}'
# {"_id":"5c84d6e0d48d0a346cad3bd9","userId":"000000000000000000000000","content":"hello world.","createdAt":"2019-03-10T09:20:32.956Z"}

# 別のユーザーでtweet作成
curl -X POST -H "Content-Type: application/json" http://localhost:3000/v1/tweets -d '{"userId": "000000000000000000000001", "content": "Fizz Buzz!"}'

# 2人のuserIdを指定してtimelineを取得
# (二人目のユーザーをフォローしている一人目のユーザーのタイムラインを想定)
curl -X POST -H "Content-Type: application/json" http://localhost:3000/v1/timeline -d '["000000000000000000000000", "000000000000000000000001"]' 
# 2つのツイートが返ってくる
# [{"_id":"5c84d75ad48d0a346cad3bda","userId":"000000000000000000000001","content":"Fizz Buzz!","createdAt":"2019-03-10T09:22:34.714Z"},{"_id":"5c84d6e0d48d0a346cad3bd9","userId":"000000000000000000000000","content":"hello world.","createdAt":"2019-03-10T09:20:32.956Z"}]

avaによるユニットテスト

コードを修正するたびにcurlを用いて動作確認を行うのは現代に生きるエンジニアのやることではありません。
ユニットテストを導入しましょう。

javascriptではavamochajest等、いくつか有名なユニットテストフレームワークが存在します。
今回は最もシンプルなavaを利用してユニットテストを記述していきます。

まずはプロジェクトにnpmパッケージを追加しましょう。
今回のユニットテストで利用するsupertestmongodb-memory-serverも一緒に追加します。

yarn add -D ava supertest@^3.4.2 mongodb-memory-server

簡単にテストコマンドを実行できるようにpackage.jsontestコマンドとwatchコマンドを定義しましょう。

package.json
...
  "scripts": {
    "start": "node app.js",
    "dev": "NODE_ENV=development nodemon ./app.js",
    "lint": "eslint --ext .js --ignore-path .gitignore .",
    "test": "ava",
    "watch": "ava --watch"
  },
...

ユニットテストファイル用のディレクトリを作成します。

mkdir test

今回のユニットテストではコントローラの界面でテストを書いていきます。
以下の実装では、supertestを利用してコントローラに簡単にリクエストを送れるようにしています。
また、ユニットテスト時にローカルのDBにはなるべく依存したくないため、mongodb-memory-serverというオンメモリで動作するMongoDBを利用します。

それぞれのコントローラについてユニットテストを実装していきます。

test/tweets.js
const test = require('ava')
const supertest = require('supertest')
const mongoose = require('mongoose')
const express = require('express')
const bodyParser = require('body-parser')
const { MongoMemoryServer } = require('mongodb-memory-server')

console.error = () => {}
const router = require('../controllers/v1/tweets.js')
const model = require('../models/tweet.js')
const Tweet = model.Tweet

const mongod = new MongoMemoryServer()
const app = express()
app.use(bodyParser.json())
app.use(bodyParser.urlencoded({ extended: true }))
app.use('/tweets', router)

const user1Id = new mongoose.Types.ObjectId()
const user2Id = new mongoose.Types.ObjectId()

test.before(async () => {
  const uri = await mongod.getConnectionString()
  mongoose.connect(uri, { useNewUrlParser: true })
})

test.beforeEach(async t => {
  let tweets = []
  tweets.push(
    await new Tweet({
      userId: user1Id,
      content: 'aaa'
    }).save()
  )
  tweets.push(
    await new Tweet({
      userId: user1Id,
      content: 'bbb'
    }).save()
  )
  tweets.push(
    await new Tweet({
      userId: user2Id,
      content: 'ccc'
    }).save()
  )
  tweets.push(
    await new Tweet({
      userId: user2Id,
      content: 'ddd'
    }).save()
  )
  t.context.tweets = tweets
})

test.afterEach.always(async () => {
  await Tweet.deleteMany().exec()
})

// GET /tweets
test.serial('get tweets', async t => {
  const res = await supertest(app).get('/tweets')
  t.is(res.status, 200)
  t.is(res.body.length, 4)
  t.is(res.body[0]._id, t.context.tweets[3]._id.toString())
})

// GET /tweets/:id
test.serial('get tweet', async t => {
  const target = t.context.tweets[0]
  const res = await supertest(app).get(`/tweets/${target._id}`)
  t.is(res.status, 200)
  t.is(res.body._id, target._id.toString())
  t.is(res.body.userId, target.userId.toString())
  t.is(res.body.content, target.content)
})

test.serial('get tweet not found', async t => {
  const res = await supertest(app).get(
    `/tweets/${new mongoose.Types.ObjectId()}`
  )
  t.is(res.status, 404)
  t.deepEqual(res.body, { error: 'NotFound' })
})

test.serial('get tweet id is invalid', async t => {
  const res = await supertest(app).get('/tweets/invalid')
  t.is(res.status, 400)
  t.deepEqual(res.body, { error: 'BadRequest' })
})

// POST /tweets
test.serial('create tweet', async t => {
  const content = 'xxx'
  const res = await supertest(app)
    .post('/tweets')
    .send({ userId: user1Id.toString(), content: content })
  t.is(res.status, 200)
  t.true('_id' in res.body)
  t.is(res.body.content, content)
})

test.serial('create tweet no userId', async t => {
  const content = 'xxx'
  const res = await supertest(app)
    .post('/tweets')
    .send({ content: content })
  t.is(res.status, 400)
  t.deepEqual(res.body, { error: 'BadRequest' })
})

test.serial('create tweet no content', async t => {
  const res = await supertest(app)
    .post('/tweets')
    .send({ userId: user1Id.toString() })
  t.is(res.status, 400)
  t.deepEqual(res.body, { error: 'BadRequest' })
})

test.serial('create tweet content is empty', async t => {
  const res = await supertest(app)
    .post('/tweets')
    .send({ userId: user1Id.toString(), content: '' })
  t.is(res.status, 400)
  t.deepEqual(res.body, { error: 'BadRequest' })
})

test.serial('create tweet content is too long', async t => {
  const res = await supertest(app)
    .post('/tweets')
    .send({ userId: user1Id.toString(), content: 'a' * 141 })
  t.is(res.status, 400)
  t.deepEqual(res.body, { error: 'BadRequest' })
})

// DELETE /tweets/:id
test.serial('delete tweet', async t => {
  const res = await supertest(app).delete(`/tweets/${t.context.tweets[0]._id}`)
  t.is(res.status, 200)
  const actual = await Tweet.find()
  t.is(actual.length, 3)
})

test.serial('delete tweet not found', async t => {
  const res = await supertest(app).delete(
    `/tweets/${new mongoose.Types.ObjectId()}`
  )
  t.is(res.status, 404)
  t.deepEqual(res.body, { error: 'NotFound' })
})

test.serial('delete tweet id is invalid', async t => {
  const res = await supertest(app).delete('/tweets/invalid')
  t.is(res.status, 400)
  t.deepEqual(res.body, { error: 'BadRequest' })
})
test/timeline.js
const test = require('ava')
const supertest = require('supertest')
const mongoose = require('mongoose')
const express = require('express')
const bodyParser = require('body-parser')
const { MongoMemoryServer } = require('mongodb-memory-server')

console.error = () => {}
const router = require('../controllers/v1/timeline.js')
const model = require('../models/tweet.js')
const Tweet = model.Tweet

const mongod = new MongoMemoryServer()
const app = express()
app.use(bodyParser.json())
app.use(bodyParser.urlencoded({ extended: true }))
app.use('/timeline', router)

const user1Id = new mongoose.Types.ObjectId()
const user2Id = new mongoose.Types.ObjectId()
const user3Id = new mongoose.Types.ObjectId()

test.before(async () => {
  const uri = await mongod.getConnectionString()
  mongoose.connect(uri, { useNewUrlParser: true })
})

test.beforeEach(async t => {
  let tweets = []
  tweets.push(await new Tweet({ userId: user1Id, content: 'aaa' }).save())
  tweets.push(await new Tweet({ userId: user1Id, content: 'bbb' }).save())
  tweets.push(await new Tweet({ userId: user2Id, content: 'ccc' }).save())
  tweets.push(await new Tweet({ userId: user2Id, content: 'ddd' }).save())
  tweets.push(await new Tweet({ userId: user3Id, content: 'eee' }).save())
  t.context.tweets = tweets
})

test.afterEach.always(async () => {
  await Tweet.deleteMany().exec()
})

// POST /timeline
test.serial('get timeline', async t => {
  const res = await supertest(app)
    .post('/timeline')
    .send([user1Id.toString(), user2Id.toString()])
  t.is(res.status, 200)
  t.is(res.body.length, 4)
})

テストが書けたらyarn testで全テストを実行してみましょう。
また、yarn watchを実行すると、ファイルの変更を検知して自動でテストを実行してくれます。テスト駆動開発の場合に重宝します。

ダミーデータ作成用スクリプト

ユニットテストで正しく実装されているかは確認できるようになりました。
しかし、全体の動作を確認したい場合等、サーバーを立ち上げてcurl等で確認したいシーンは存在します。
その時に毎回POSTでデータを作成するのは面倒なので、ダミーデータを作成するスクリプトを作っておきましょう。
後で複数サービスを立ち上げたテスト環境を作成する際にも、テスト用データの作成に重宝します。

スクリプト用のディレクトリを作成します。

mkdir scripts

DB中のレコードを消去し、ダミーデータを登録するスクリプトを実装していきます。

scripts/initialize.js
const mongoose = require('mongoose')
const Tweet = require('../models/tweet.js').Tweet

const dbUrl = process.env.MONGODB_URL || 'mongodb://localhost:27017/tweet'
const options = { useNewUrlParser: true, useCreateIndex: true }
if (process.env.MONGODB_ADMIN_NAME) {
  options.user = process.env.MONGODB_ADMIN_NAME
  options.pass = process.env.MONGODB_ADMIN_PASS
  options.auth = { authSource: 'admin' }
}

const ObjectId = mongoose.Types.ObjectId
const user1Id = new ObjectId('000000000000000000000000')
const user2Id = new ObjectId('000000000000000000000001')
const tweets = [
  {
    userId: user1Id,
    content: 'Hello World',
    createdAt: '2019-01-01T12:00:00.000Z'
  },
  {
    userId: user1Id,
    content: 'Fizz Buzz',
    createdAt: '2019-01-01T13:00:00.000Z'
  },
  {
    userId: user2Id,
    content: '古池や\n蛙飛びこむ\n水の音',
    createdAt: '2019-01-01T12:01:00.000Z'
  },
  {
    userId: user2Id,
    content: '夏草や\n兵どもが\n夢の跡',
    createdAt: '2019-01-01T12:02:00.000Z'
  }
]

const initialize = async () => {
  mongoose.connect(dbUrl, options)

  await Tweet.deleteMany().exec()

  await Tweet.insertMany(tweets)
  mongoose.disconnect()
}

initialize()
  .then(() => {
    // eslint-disable-next-line no-console
    console.log('finish.')
  })
  .catch(error => {
    console.error(error)
  })

では、スクリプトを実行してみましょう。

node scripts/initialize.js

yarn devでサーバーを起動し、curlコマンドでTweet一覧を取得してみましょう。
ダミーデータが返ってくるはずです。

Swaggerの導入

マイクロサービスを複数のチームで開発する上で、各サービス間のインターフェース定義を統一された方法で記述することが望ましいでしょう。
インターフェース定義が曖昧であったり、メンテナンスされなかったり、最新のインターフェース定義の取得が困難だったりすると、プロジェクトはまず間違いなく炎上します。(経験談)
今回は、REST APIのインターフェース定義のデファクトスタンダードであるSwaggerを利用します。

swaggerの利用方法としては、ボトムアップ型(コードやコメントからswagger specファイルを作成)とトップダウン型(swagger specファイルからコードを生成)の2種類があります。
トップダウン型は一部の言語でないと自動生成されたコードの管理が煩雑になる傾向があるため、私はボトムアップ型を採用することが多いです。

では、実際にTweetサービスにSwaggerを導入し、Swagger Specファイルを出力できるようにしていきます。

プロジェクトにswagger-jsdocを追加します。
swagger-jsdocを利用することで、javascript中のコメントに記述されたswagger定義からSwagger Specファイルを生成できるようになります。

yarn add swagger-jsdoc

swagger関連のファイルを配置するディレクトリを作成します。

mkdir swagger

各APIの定義はcontrollerのそれぞれのメソッドのコメントとして記述することになるのですが、全体で共通する設定はswagger/swaggerDef.jsに記述します。
swaggerのバージョンは最新の3.0を利用します。

swagger/swaggerDef.js
const pkg = require('../package.json')

module.exports = {
  openapi: '3.0.0',
  info: {
    title: pkg.name,
    version: pkg.version,
    description: pkg.description
  },
  servers: [
    {
      url: '/v1'
    }
  ]
}

swagger/components.ymlを以下のように記述します。

swagger/components.yml
---
components:
  schemas:
    Tweet:
      required:
        - userId
        - content
      properties:
        _id:
          type: string
          example: '999999999999999999999999'
        userId:
          type: string
          example: '000000000000000000000000'
        content:
          type: string
          minLength: 1
          maxLength: 140
          example: 'hello world.'
        createdAt:
          type: string
          format: date-time
          example: '2019-01-01T13:00:00.000Z'
    Tweets:
      type: array
      items:
        $ref: '#/components/schemas/Tweet'
    Error:
      required:
        - error
      properties:
        error:
          type: string
          example: 'BadRequest'
  responses:
    BadRequest:
      description: Bad request error.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    NotFound:
      description: Not found error.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'

controllers/v1/tweets.jscontrollers/v1/timeline.jsにコメントでswagger定義を記述していきます。

controllers/v1/tweets.js
const express = require('express')
const model = require('../../models/tweet.js')
const Tweet = model.Tweet
const router = express.Router()

/**
 * @swagger
 *
 * /tweets:
 *   get:
 *     description: Return a list of tweets.
 *     tags:
 *       - tweets
 *     responses:
 *       '200':
 *         description: A JSON array of tweets
 *         content:
 *           application/json:
 *             schema:
 *               $ref: '#/components/schemas/Tweets'
 */
router.get('/', (req, res, next) => {
  ;(async () => {
    const tweets = await Tweet.find({}, null, {
      sort: { createdAt: -1 }
    }).exec()
    res.status(200).json(tweets)
  })().catch(next)
})

/**
 * @swagger
 *
 * /tweets/{id}:
 *   get:
 *     description: Find tweet by ID.
 *     tags:
 *       - tweets
 *     parameters:
 *       - name: id
 *         in: path
 *         required: true
 *         description: Tweet ID.
 *         schema:
 *           type: string
 *         example: '000000000000000000000000'
 *     responses:
 *       '200':
 *         description: A JSON object of tweet.
 *         content:
 *           application/json:
 *             schema:
 *               $ref: '#/components/schemas/Tweet'
 *       '400':
 *         $ref: '#/components/responses/BadRequest'
 *       '404':
 *         $ref: '#/components/responses/NotFound'
 */
router.get('/:id', (req, res, next) => {
  ;(async () => {
    try {
      const tweet = await Tweet.findById(req.params.id).exec()
      if (tweet) {
        res.status(200).json(tweet)
      } else {
        res.status(404).json({ error: 'NotFound' })
      }
    } catch (err) {
      console.error(err)
      res.status(400).json({ error: 'BadRequest' })
    }
  })().catch(next)
})

/**
 * @swagger
 *
 * /tweets:
 *   post:
 *     description: Create a tweet.
 *     tags:
 *       - tweets
 *     requestBody:
 *       required: true
 *       content:
 *         application/json:
 *           schema:
 *             type: object
 *             properties:
 *               userId:
 *                 type: string
 *                 example: '000000000000000000000000'
 *               content:
 *                 type: string
 *                 minLength: 1
 *                 maxLength: 140
 *                 example: 'hello world.'
 *             required:
 *               - userId
 *               - content
 *     responses:
 *       '200':
 *         description: Created tweet.
 *         content:
 *           application/json:
 *             schema:
 *               $ref: '#/components/schemas/Tweet'
 *       '400':
 *         $ref: '#/components/responses/BadRequest'
 */
router.post('/', (req, res, next) => {
  ;(async () => {
    try {
      const record = new Tweet({
        userId: req.body.userId,
        content: req.body.content
      })
      const savedRecord = await record.save()
      res.status(200).json(savedRecord)
    } catch (err) {
      console.error(err)
      res.status(400).json({ error: 'BadRequest' })
    }
  })().catch(next)
})

/**
 * @swagger
 *
 * /tweets/{id}:
 *   delete:
 *     description: Delete a tweet.
 *     tags:
 *       - tweets
 *     parameters:
 *       - name: id
 *         in: path
 *         required: true
 *         description: Tweet ID.
 *         schema:
 *           type: string
 *         example: '000000000000000000000000'
 *     responses:
 *       '200':
 *         description: Empty body.
 *       '400':
 *         $ref: '#/components/responses/BadRequest'
 *       '404':
 *         $ref: '#/components/responses/NotFound'
 */
router.delete('/:id', (req, res, next) => {
  ;(async () => {
    try {
      const removedRecord = await Tweet.findByIdAndDelete(req.params.id).exec()
      if (removedRecord) {
        res.status(200).json({})
      } else {
        res.status(404).json({ error: 'NotFound' })
      }
    } catch (err) {
      console.error(err)
      res.status(400).json({ error: 'BadRequest' })
    }
  })().catch(next)
})

module.exports = router
controllers/v1/timeline.js
const express = require('express')
const model = require('../../models/tweet.js')
const Tweet = model.Tweet
const router = express.Router()

/**
 * @swagger
 *
 * /timeline:
 *   post:
 *     description: Get user timeline.
 *     tags:
 *       - timeline
 *     requestBody:
 *       required: true
 *       content:
 *         application/json:
 *           schema:
 *             type: array
 *             items:
 *               type: string
 *             example: ['000000000000000000000000', '000000000000000000000001']
 *     responses:
 *       '200':
 *         description: A JSON array of tweets.
 *         content:
 *           application/json:
 *             schema:
 *               $ref: '#/components/schemas/Tweets'
 */
router.post('/', (req, res, next) => {
  ;(async () => {
    const userIds = req.body
    const tweets = await Tweet.find({ userId: { $in: userIds } }, null, {
      sort: { createdAt: -1 }
    }).exec()
    res.status(200).json(tweets)
  })().catch(next)
})

module.exports = router

swagger-jsdocのCLI機能を利用してSwagger Specを生成してみましょう。

package.jsonに生成コマンドを定義します。

package.json
...
  "scripts": {
    "start": "node app.js",
    "dev": "NODE_ENV=development nodemon ./app.js",
    "lint": "eslint --ext .js --ignore-path .gitignore .",
    "swagger": "swagger-jsdoc -o ./swagger/swagger.yml -d ./swagger/swaggerDef.js ./controllers/**/*.js ./swagger/components.yml",
    "test": "ava",
    "watch": "ava --watch"
  },
...

次のコマンドでswagger/swagger.ymlにSwagger Specを出力します。

yarn swagger

生成されたファイルをSwagger Editorに貼り付けて確認してみましょう。
Swagger Editorへアクセスします。
左ペインへ生成されたswagger/swagger.ymlの中身を貼り付けます。
右ペインでSwaggerの定義が確認できるはずです。

swagger_01.png

さて、毎回Swagger Specを確認するために、yarn swaggerでファイル出力し、Swagger Editorへコピペするのは大変です。
この確認を簡単にするために、起動したサーバーから直接Swagger Specを取得できるようにします。

サーバーから直接取得できるようにすることで、jsファイルを更新すればnodemonのホットリロードで即座に反映されたり、Swagger UIを用いて簡単に生成されたSwagger Specを確認できるようになります。
また、テストサーバー等で公開することで、他のチームでも容易にSwagger Specを確認できるようになる点もメリットの一つです。

controllers/index.jsを下記のように修正します。
今回はdevelopment以外の環境ではSwagger Specファイルは取得できない実装にしています。

controllers/index.js
const express = require('express')
const tweets = require('./v1/tweets.js')
const timeline = require('./v1/timeline.js')

const router = express.Router()

// swagger
if (process.env.NODE_ENV === 'development') {
  const swaggerJSDoc = require('swagger-jsdoc')
  const options = {
    swaggerDefinition: require('../swagger/swaggerDef.js'),
    apis: [
      './controllers/v1/tweets.js',
      './controllers/v1/timeline.js',
      './swagger/components.yml'
    ]
  }
  const swaggerSpec = swaggerJSDoc(options)
  // CROS
  router.use((req, res, next) => {
    res.header('Access-Control-Allow-Origin', '*')
    res.header(
      'Access-Control-Allow-Methods',
      'GET, POST, DELETE, PUT, PATCH, OPTIONS'
    )
    res.header(
      'Access-Control-Allow-Headers',
      'Origin, X-Requested-With, Content-Type, Accept'
    )
    next()
  })
  router.get('/v1/swagger.json', (req, res) => {
    res.setHeader('Content-Type', 'application/json')
    res.send(swaggerSpec)
  })
}

router.use('/v1/tweets', tweets)
router.use('/v1/timeline', timeline)

module.exports = router

yarn devでブラウザを起動し、http://localhost:3000/v1/swagger.jsonへアクセスしてみましょう。
Swagger Specがjson形式で取得できるはずです。

次にSwagger UIを使用してSwagger Specを確認します。

swagger-uiをクローンし、dist/index.htmlをブラウザで開きましょう。
そしてページ内のアドレスバーにhttp://localhost:3000/v1/swagger.jsonと入力し、Exploreボタンを押してみましょう。
Swagger Editorで表示したのと同様の形式で表示されるはずです。

swagger_02.png

テストサーバーからSwagger Specを配信しているため、Swagger UI上からサンプルリクエストを投げることもできます。
exampleを適切に記述することで、サンプルリクエストの初期値として入力されるので、使い勝手が向上します。

このように適切にメンテナンスされたインターフェース仕様を提供することで、複数のチームでの開発を円滑に進めることが可能になるでしょう。

最終的なプロジェクト構成

最終的なプロジェクト構成を下記に示します。

microservice-sample-tweet
├── LICENSE
├── app.js                 # エントリポイント
├── controllers            # コントローラー用ディレクトリ
│   ├── index.js
│   └── v1
│       ├── timeline.js    # /timeline に関するコントローラー
│       └── tweets.js      # /tweets に関するコントローラー
├── models
│   └── tweet.js           # Tweetリソースのスキーマ定義
├── package.json
├── scripts
│   └── initialize.js      # 初期化&ダミーデータ作成スクリプト
├── swagger                # Swagger関連ディレクトリ
│   ├── components.yml
│   ├── swagger.yml        # 生成されたSwagger Specファイル
│   └── swaggerDef.js
├── test                   # テスト関連ディレクトリ
│   ├── timeline.js        # /timeline に関するテスト
│   └── tweets.js          # /tweets に関するテスト
└── yarn.lock

第2章まとめ

第2章ではNode.js + MongoDBを利用してTweetサービスを構築しました。

下記を導入し、いわゆるサンプルアプリケーションよりは、より実践的なREST APIサービスを構築しました。

  • eslintprettierによる強力なスタイルチェックとコードフォーマット
  • mongooseを利用したスキーマ定義とDBアクセス
  • avaを利用したユニットテスト
  • Swaggerを利用したインターフェース仕様の提供

次の章では本章と同様の手順でUserサービスを作成していきます。

次章: 第3章 Userサービス

20
19
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
20
19

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?