第2章 Tweetサービス
本章ではNode.jsを利用してTweetの作成/取得を行うTweetサービスを作成します。
Node.jsを利用したREST APIサービスの作成における(著者的)ベストプラクティスな内容を記載しているため、ボリュームはけっこう大きめとなっております。
なお、Kubernetesだけ触りたいという方は以下の完成済みのリポジトリをforkすることで、この章実装をスキップすることができます。
reireias/microservice-sample-tweet
チュートリアル全体
構成
システム構成
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
ファイルを以下の内容で作成しておきましょう。
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
スタイルチェックとコードフォーマッタ
次にコードの品質を担保するために、eslint
とprettier
を追加します。
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
を以下のように作成します。
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
を以下のように作成します。
{
"semi": false,
"singleQuote": true
}
最後にpackage.json
に設定を追加し、yarn
から実行できるようにしましょう。
場所はどこでもよいのですが、普段私はlicense
の下に記述しています。
...
"license": "MIT",
"scripts": {
"lint": "eslint --ext .js --ignore-path .gitignore ."
},
"devDependencies": {
...
jsファイルを適当に作成し、lintコマンドを実施してみましょう。
Done
と表示されれば問題ありません。
touch app.js
yarn lint
次にprettier
の動作を確認してみます。
app.js
の内容を下記のように変更します。
const a = "hoge"
const b = 1+2
ターミナルからprettier
を実行します。
yarn run prettier --write app.js
すると、下記のようにapp.js
の中身が変更されているはずです。
const a = 'hoge'
const b = 1 + 2
eslint
もprettier
もいちいちターミナルから実行するのは面倒ですよね?
なので、VimやVisual Studio Codeといったエディタのプラグイン等を利用し、エディタから実行or自動実行されるように設定することを推奨します。
(私はVimでALEというプラグインを利用してコードが変更される度に非同期実行しています)
コミット時に自動でyarn lintを実行する
前節でeslint
とprettier
を追加しましたが、すべての開発者がこれらのツールを必ず実行し、コードをクリーンに保ってくれるとは限りません。実行せずにスタイルに違反したコードをコミットしてしまう開発者も存在します。(私は何度もそういった現場に遭遇してきました。)
こういった問題を避けるためにはどうすればよいでしょうか?
主に2つの解決策があります。
- Gitフックを利用する
- CIで継続的にチェックする
両方実施するのが望ましいのですが、まずはGitフックを利用した自動実行を導入してみましょう。
Gitはcommit
やpush
等のgitコマンドの実行前/実行後に任意のスクリプトを実行する機能です。
詳しい説明は公式ドキュメントを参照してください。
リポジトリ内の.git/hooks
ディレクトリ以下にスクリプトを設置すれば設定できるのですが、Node.jsの場合はhuskyというnpmモジュールを利用してこれらをpackage.json
で管理することが可能です。
以下のコマンドでプロジェクトにhusky
を追加します。
yarn add -D husky
package.json
にgit commit
前にyarn lint
を実施する設定を追加します。
scripts
の下に下記の設定を追記しましょう。
...
"scripts": {
"lint": "eslint --ext .js --ignore-path .gitignore ."
},
"husky": {
"hooks": {
"pre-commit": "yarn lint"
}
},
...
現在、app.js
はprettier
でフォーマットしましたが、変数の未使用により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モジュールexpressとmorganをプロジェクトに追加します。
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を参考にしています。
mkdir -p controllers/v1
/v1
ディレクトリを作成した理由としては、APIはhttp://localhost/v1/tweets
のようなパスにすることで、将来の破壊的変更時に/v2
パスで新規APIを提供できるようにするためです。
GET /v1/tweets
にダミー応答を返す実装をしてみましょう。
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, () => {})
const express = require('express')
const tweets = require('./v1/tweets.js')
const router = express.Router()
router.use('/v1/tweets', tweets)
module.exports = router
const express = require('express')
const router = express.Router()
router.get('/', (req, res, next) => {
res.status(200).json({ message: 'hello' })
})
module.exports = router
起動用のスクリプトを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
を追加します。
...
"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のログが出力され、アクセスすると実際に変更された値が返ってくるはずです。
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
にタイムライン用のコントローラーを追加します。
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操作を一通り追加します。
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
を以下の内容で作成します。
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モデルを次のように実装します。
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へのコネクションの作成を追加します。
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を利用するように変更します。
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
も同様に実装します。
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ではavaやmocha、jest等、いくつか有名なユニットテストフレームワークが存在します。
今回は最もシンプルなava
を利用してユニットテストを記述していきます。
まずはプロジェクトにnpmパッケージを追加しましょう。
今回のユニットテストで利用するsupertest
、mongodb-memory-server
も一緒に追加します。
yarn add -D ava supertest@^3.4.2 mongodb-memory-server
簡単にテストコマンドを実行できるようにpackage.json
にtest
コマンドとwatch
コマンドを定義しましょう。
...
"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を利用します。
それぞれのコントローラについてユニットテストを実装していきます。
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' })
})
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中のレコードを消去し、ダミーデータを登録するスクリプトを実装していきます。
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を利用します。
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
を以下のように記述します。
---
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定義を記述していきます。
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
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
に生成コマンドを定義します。
...
"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 Specを確認するために、yarn swagger
でファイル出力し、Swagger Editorへコピペするのは大変です。
この確認を簡単にするために、起動したサーバーから直接Swagger Specを取得できるようにします。
サーバーから直接取得できるようにすることで、jsファイルを更新すればnodemon
のホットリロードで即座に反映されたり、Swagger UIを用いて簡単に生成されたSwagger Specを確認できるようになります。
また、テストサーバー等で公開することで、他のチームでも容易にSwagger Specを確認できるようになる点もメリットの一つです。
controllers/index.js
を下記のように修正します。
今回はdevelopment以外の環境ではSwagger Specファイルは取得できない実装にしています。
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 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サービス