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

# 第2章 Tweetサービス

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

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

[reireias/microservice-sample-tweet](https://github.com/reireias/microservice-sample-tweet)

## チュートリアル全体
- [第1章 概要](https://qiita.com/reireias/items/6a78d6b8df781a76f94f)
- [第2章 Tweetサービス](https://qiita.com/reireias/items/82cfbb285130caf3e091)
- [第3章 Userサービス](https://qiita.com/reireias/items/27e21896b701360bc152)
- [第4章 Webサービス](https://qiita.com/reireias/items/204c080cd5ec1bf432fd)
- [第5章 Dockerを使ったサービス構築](https://qiita.com/reireias/items/efefe718d74ed8447931)
- [第6章 Kubernetes with minikube](https://qiita.com/reireias/items/084888abb3a757c0c5e2)

## 構成
![microservice-tutorial01.png](https://qiita-image-store.s3.amazonaws.com/0/214526/36201231-6030-9ac3-03ad-aaa99619eed2.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します。

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

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

```:gitignore
node_modules
yarn-error.log
```

### プロジェクトの初期化
cloneしたリポジトリへ移動します。

```sh
cd microservice-sample-tweet
```

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

```sh
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
```

### スタイルチェックとコードフォーマッタ
次にコードの品質を担保するために、`eslint`と`prettier`を追加します。

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

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

```sh
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`を以下のように作成します。

```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`を以下のように作成します。

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

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

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

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

```sh
touch app.js
yarn lint
```

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

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

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

```sh
yarn run prettier --write app.js
```

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

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

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

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

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

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

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

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

Gitは`commit`や`push`等のgitコマンドの実行前/実行後に任意のスクリプトを実行する機能です。
詳しい説明は[公式ドキュメント](https://git-scm.com/book/ja/v1/Git-%E3%81%AE%E3%82%AB%E3%82%B9%E3%82%BF%E3%83%9E%E3%82%A4%E3%82%BA-Git-%E3%83%95%E3%83%83%E3%82%AF)を参照してください。

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

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

```sh
yarn add -D husky
```

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

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

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

```sh
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モジュール[express](https://github.com/expressjs/express)と[morgan](https://github.com/expressjs/morgan)をプロジェクトに追加します。

```sh
yarn add express morgan body-parser
```

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

続いて、`controllers`ディレクトリと`controllers/v1`ディレクトリを作成します。
なお、ディレクトリ構成に関しては、[Best practices for Express app structure](https://www.terlici.com/2014/08/25/best-practices-express-structure.html)を参考にしています。

```sh
mkdir -p controllers/v1
```

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

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

```js: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, () => {})
```

```js: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
```

```js: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`に定義します。

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

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

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

### nodemonによるホットリロード
Node.jsでの開発では、開発スピードを向上させるためにホットリロード機能を利用します。
今回は[nodemon](https://github.com/remy/nodemon)を導入し、ホットリロードを実現します。

```sh
yarn add -D nodemon
```

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

```json: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`内の`hello`を`hoge`に書き換えて見ましょう。
restartのログが出力され、アクセスすると実際に変更された値が返ってくるはずです。

```sh
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`にタイムライン用のコントローラーを追加します。

```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操作を一通り追加します。

```js: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`を以下の内容で作成します。

```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モジュールを追加します。

```sh
yarn add mongodb mongoose
```

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

```sh
mkdir models
```

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

```js: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へのコネクションの作成を追加します。

```js: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を利用するように変更します。

```js: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`も同様に実装します。

```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コンテナを実行する例になります。

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

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

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

```sh
yarn dev
```

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

```sh
# 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では[ava](https://github.com/avajs/ava)や[mocha](https://github.com/mochajs/mocha)、[jest](https://github.com/facebook/jest)等、いくつか有名なユニットテストフレームワークが存在します。
今回は最もシンプルな`ava`を利用してユニットテストを記述していきます。

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

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

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

```json: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"
  },
...
```

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

```sh
mkdir test
```

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

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

```js: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' })
})
```

```js: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でデータを作成するのは面倒なので、ダミーデータを作成するスクリプトを作っておきましょう。
後で複数サービスを立ち上げたテスト環境を作成する際にも、テスト用データの作成に重宝します。

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

```sh
mkdir scripts
```

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

```js: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)
  })
```

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

```sh
node scripts/initialize.js
```

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

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

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

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

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

```sh
yarn add swagger-jsdoc
```

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

```sh
mkdir swagger
```

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

```js: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`を以下のように記述します。

```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.js`と`controllers/v1/timeline.js`にコメントでswagger定義を記述していきます。

```js: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
```

```js: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`に生成コマンドを定義します。

```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を出力します。

```sh
yarn swagger
```

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

![swagger_01.png](https://qiita-image-store.s3.amazonaws.com/0/214526/3fc15719-a930-a81c-0056-024cd8067738.png)

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

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


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

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

![swagger_02.png](https://qiita-image-store.s3.amazonaws.com/0/214526/8968e6a7-4cc8-db84-57bc-b53265f509a1.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サービスを構築しました。

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

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

次章: [第3章 Userサービス](https://qiita.com/reireias/items/27e21896b701360bc152)