20190327のvue.jsに関する記事は8件です。

nuxtでreset CSSを記述する場所がわからなかった話(修正版)

前回の記事

こちらの記事にコメントで指摘、編集依頼を受け
実践してみてこっちの方がいい!って思ったので修正版を今回は書きます
https://qiita.com/nuxt_suco/items/63797a54a2b09e07efaa#comment-f81740c831acd1c11bf9

いただいた2つのコメント

個人的にはnuxt.config.jsの中に書く。
1.assets/style/reset.scssを作り、中にreset.cssを入れる
2.nuxt.config.jsの中でcssを取り込む

globalなものであれば、ここに入れておくといいかなと

先に、nuxt.config.jsの内容が読まれたあとに、.vueファイルの内容を読むみたい


layouts/default.vue内で読み込んだCSSはページコンポーネント内でlayoutプロパティが省略されていれば読み込まれます。
しかし、404ページなど他のページとはレイアウトの構成が変わるようなページで、新しく「layouts/error.vue」のようなテンプレートを作った場合には引き継がれません。
なのでやっぱり全体に効かせたい場合にはnuxt.config.js内に記述するのがいいですね!

なるほど?ってことは、nuxt.config.jsに記述されてる方が優先度高いんだ!
とりあえずやってみよ

reset.cssをassets配下に置く

aseetsディレクトリーのなかにstyleディレクトリーを作り
assets/style/reset.css

nuxt.config.jsの中でreset.cssを取り込む

  css: [
    '@/assets/style/reset.css'
  ],

取り込んだreset.cssをdefault.vueで呼ぶ

<style>
@import "./assets/style/reset.css";
</style>

これで無事適応されました!
他にもdefaultで適応させたいものがあれば複数のcssファイルを
1つのcssファイルにまとめたものを呼べば大丈夫です!

今回参考にしたリンク先

[nuxt.config.jsでのcssの取り込み方について]
https://github.com/nuxt/create-nuxt-app/blob/master/template/nuxt/nuxt.config.js#L54

https://ja.nuxtjs.org/api/configuration-css/

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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

第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ファイルを以下の内容で作成しておきましょう。

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サービス

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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

第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サービス

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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

Node.jsとKubernetesを使い、マイクロサービスを作ってみたくなったので、このチュートリアルを作成してみました。
バグや修正した方がよい点などあれば気軽にコメントをおねがいします。

本チュートリアルは以下の8章構成になっています。

第1章 概要

この章では本チュートリアルの概要について記載します。

目的

本チュートリアルは以下の目的で作成されています。

  • Kubernetesを使い、実際にMicroserviceを構築することで、Kubernetesへの理解を深める
  • 複数チームによる開発を考慮し、リポジトリや検証環境を構築する
  • AWSのEKSとGCPのGKEを利用することで、PaaSでのKubernetes利用の知見を獲得する

作成するMicroserviceの概要

本チュートリアルではKubernetesとNode.jsを使い、TwitterライクなMicroserviceを構築します。

実装する機能としては下記になります。

  • ログイン/ログアウト機能(GitHub認証)
  • ツイート機能
  • フォロー/アンフォロー機能
  • タイムライン機能(自分 + フォローしているユーザーのツイート一覧)

構築するMicroserviceの概要図は下記のようになります。

BFF(Backend for Frontend)を採用し、Webサービスのサーバーサイドが各サービスとデータをやり取りする構成としています。

microservice-tutorial01.png

なお、Node.jsを採用した理由は下記のとおりです。

  • ミドルウェアなしに単一のプロセスで起動できること
    • Cloud Nativeなアプリケーションを作成する上で重要です
  • なるべくメジャーな言語であること
  • チュートリアル読者の実装ハードルを下げるため、各サービスとも共通した言語にしたい

各サービスについて

以下では、本チュートリアルで作成する3つのマイクロサービスについて説明します。

Webサービス

フロントエンド + BFFのサービスです。
ここでは、BFFはフロントエンドのチームが所有するものとしています。
GitHubを利用したOAuth2.0による認証機能を持ちます。

Userサービス

ユーザーやフォロー関係を扱うサービスです。
REST APIを各サービスへ提供します。

Tweetサービス

ツイートを扱うサービスです。
REST APIを各サービスへ提供します。

採用技術

本チュートリアルで作成するサンプルにおいて使用する主要な技術を列挙しておきます。
詳細は各サービスの章で説明します。

アーキテクチャ系

  • Microservice
    • 全体のアーキテクチャとして採用
  • BFF(Backend for Frontend)
    • フロントエンドと各サービスのAPI呼び出しパターンとして採用
  • REST API
    • サービス間通信のI/Fとして採用
  • MongoDB
    • NoSQLデータベース
    • Userサービス、Tweetサービスのデータベースとして採用

実装系

  • Node.js
    • 今回は全サービスの言語として採用
    • もちろん、Microserviceなので、各サービスで好きな言語を使用できる
  • Nuxt.js
    • Vue.jsを利用したSPA + SSRフレームワーク
    • WebサービスでUIフレームワークとして採用
  • Vuetify.js
    • Vue.js向けのマテリアルデザインライブラリ
    • UIの見た目をそれっぽくするためにNuxt.jsに組み込んで利用する
  • Express.js
    • Node.js製サーバーサイドのデファクトスタンダード
    • WebサービスのBFF部分やUserサービス、TweetサービスのREST APIサーバーとして利用
  • mongoose
    • Node.jsからMongoDBを利用するためのライブラリ
    • スキーマ定義も可能

第1章 まとめ

この章ではこれから作成するマイクロサービスの概要を紹介しました。
次の章からは実際に各サービスを作成していきます。

次章: 第2章 Tweetサービス

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

KubernetesとNode.jsでマイクロサービスを作成する 1/8 概要

Node.jsとKubernetesを使い、マイクロサービスを作ってみたくなったので、このチュートリアルを作成してみました。
バグや修正した方がよい点などあれば気軽にコメントをおねがいします。

本チュートリアルは以下の8章構成になっています。

第1章 概要

この章では本チュートリアルの概要について記載します。

目的

本チュートリアルは以下の目的で作成されています。

  • Kubernetesを使い、実際にMicroserviceを構築することで、Kubernetesへの理解を深める
  • 複数チームによる開発を考慮し、リポジトリや検証環境を構築する
  • AWSのEKSとGCPのGKEを利用することで、PaaSでのKubernetes利用の知見を獲得する

作成するMicroserviceの概要

本チュートリアルではKubernetesとNode.jsを使い、TwitterライクなMicroserviceを構築します。

実装する機能としては下記になります。

  • ログイン/ログアウト機能(GitHub認証)
  • ツイート機能
  • フォロー/アンフォロー機能
  • タイムライン機能(自分 + フォローしているユーザーのツイート一覧)

構築するMicroserviceの概要図は下記のようになります。

BFF(Backend for Frontend)を採用し、Webサービスのサーバーサイドが各サービスとデータをやり取りする構成としています。

microservice-tutorial01.png

なお、Node.jsを採用した理由は下記のとおりです。

  • ミドルウェアなしに単一のプロセスで起動できること
    • Cloud Nativeなアプリケーションを作成する上で重要です
  • なるべくメジャーな言語であること
  • チュートリアル読者の実装ハードルを下げるため、各サービスとも共通した言語にしたい

各サービスについて

以下では、本チュートリアルで作成する3つのマイクロサービスについて説明します。

Webサービス

フロントエンド + BFFのサービスです。
ここでは、BFFはフロントエンドのチームが所有するものとしています。
GitHubを利用したOAuth2.0による認証機能を持ちます。

Userサービス

ユーザーやフォロー関係を扱うサービスです。
REST APIを各サービスへ提供します。

Tweetサービス

ツイートを扱うサービスです。
REST APIを各サービスへ提供します。

採用技術

本チュートリアルで作成するサンプルにおいて使用する主要な技術を列挙しておきます。
詳細は各サービスの章で説明します。

アーキテクチャ系

  • Microservice
    • 全体のアーキテクチャとして採用
  • BFF(Backend for Frontend)
    • フロントエンドと各サービスのAPI呼び出しパターンとして採用
  • REST API
    • サービス間通信のI/Fとして採用
  • MongoDB
    • NoSQLデータベース
    • Userサービス、Tweetサービスのデータベースとして採用

実装系

  • Node.js
    • 今回は全サービスの言語として採用
    • もちろん、Microserviceなので、各サービスで好きな言語を使用できる
  • Nuxt.js
    • Vue.jsを利用したSPA + SSRフレームワーク
    • WebサービスでUIフレームワークとして採用
  • Vuetify.js
    • Vue.js向けのマテリアルデザインライブラリ
    • UIの見た目をそれっぽくするためにNuxt.jsに組み込んで利用する
  • Express.js
    • Node.js製サーバーサイドのデファクトスタンダード
    • WebサービスのBFF部分やUserサービス、TweetサービスのREST APIサーバーとして利用
  • mongoose
    • Node.jsからMongoDBを利用するためのライブラリ
    • スキーマ定義も可能

第1章 まとめ

この章ではこれから作成するマイクロサービスの概要を紹介しました。
次の章からは実際に各サービスを作成していきます。

次章: 第2章 Tweetサービス

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

v-forでdataを取得する時にはv-bind:key設定も必須

v-for で data を取得している箇所では v-bind:key を指定していないと、npm run build を実行した時に「Custom elements in iteration require 'v-bind:key' directives」というエラーが発生します。

ローカルでの動作確認を npm run serve で実施していた段階では、ブラウザ上には正しく表示できてしまっていたので気づきませんでしたが、実際にはエラーも発生していました。

調べてみると、以前は任意だったものが、Vue.js 2.2.0以降では必須になったようです。

以下の例は、MyCardというコンポーネントをv-forで複数個表示しようとしているサンプルコードの断片です。

  • 誤っていたコード
<v-flex v-for="item in items" xs12 sm6 md4>
  <MyCard v-bind:title="item.title" v-bind:content="item.content"/>
</v-flex>
  • 正しいコード
<v-flex v-for="item in items" v-bind:key="item.id" xs12 sm6 md4>
  <MyCard v-bind:title="item.title" v-bind:content="item.content"/>
</v-flex>

なお、上記コードと同じ.vueファイル内に以下のデータが入れてあり、それを取得しています。
(サンプルプロジェクトのため、データを外部ファイルにしていません)

<script>
import MyCard from './components/MyCard.vue'

export default {
  name: 'App',
  components: {
    MyCard
  },
  data () {
    return {
      items: [
      { title: 'タイトル1', content: 'コンテンツのサンプル1' },
      { title: 'タイトル2', content: 'コンテンツのサンプル2' },
      { title: 'タイトル3', content: 'コンテンツのサンプル3' },
      { title: 'タイトル4', content: 'コンテンツのサンプル4' },
      { title: 'タイトル5', content: 'コンテンツのサンプル5' },
      { title: 'タイトル6', content: 'コンテンツのサンプル6' }
    ]
    }
  }
}
</script>

参考になったサイト

https://qiita.com/FumioNonaka/items/d1d9c9335116426a8316
https://jp.vuejs.org/v2/guide/list.html#key
https://github.com/vuejs/vetur/issues/261

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Vue.js + UIKitによるSPA開発環境構築

はじめに

フロントエンド環境の構築に関して最低限必要な手順を残します。
以下の環境を使って構築しました。

環境 Version
OS macOS Mojave 10.14.3
node.js 10.13.0

なぜ

なぜフロントエンドか?

提供しているプロダクトの責務とコンテキスト境界を考えたとき、サーバサイドアプリケーションだと重すぎるし、冗長機能ができそうな予兆がみられました。
また全ての機能を同じアプリケーションに搭載した場合、ビジネスのスピード感についてゆかなくなる時がくる、つまり技術的負債を溜め込みやすい体質になるのではないかという懸念があり、フロントエンドとバックエンドサービスは分断することにしました。

なぜVue.jsか?

自分が経験した開発の歴史はJQueryゴリゴリ→Knockout.js(MVVM)のような流れを辿ってきました。
Vue.jsはたまたま出会ったのですが、JSフレームワークのデファクトであるバインディング機能はもちろん、CLIによるワンタッチでの環境構築、ルーティング機能がとても便利だと感じた点と、学習コストが低いという定評がある点から選びました。

なぜUIKitか?

BootStrapは飽きたのと、いくつかCSSフレームワークを見た上でUIKitが一番シンプルで自分好みだったからです。

手順

環境構築する手順は以下のとおりです。

1.vue-cliのインストール

$ npm -g install vue-cli

2.vueプロジェクトの作成

$ vue init webpack my-project

3.vueプロジェクトに移動

$ cd my-project

4.UIKitのインストール

$ npm install uikit

5.UIKitの読み込み

main.jsにUIKitを読み込むよう、以下の内容を追記

src/main.js
import UIkit from 'uikit'
import Icons from 'uikit/dist/js/uikit-icons'
import 'uikit/dist/css/uikit.css'
import 'uikit/dist/css/uikit.min.css'
UIkit.use(Icons)

6.ビルド

以下のコマンドでビルドし、http://localhost:8080 をURLバーに入力するとVue.jsの初期画面(ロゴ)が表示されます。

$ npm run dev

参考

自分が環境構築の際に、一番手こずった点はUIKitのimport部分です。
最初vue-cliをインストールした後、公式サイトから落としてきたUIKitを適当なフォルダに入れて<script>で読み込もうとしましたが、うまくいきませんでした。

そこでUIKitもnpmでインストールするように変更し、node_modulesの載せた状態でmain.jsにimportさせると上手くいったので、その手順を載せておきます。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Electron-VueでVuetifyを使用した際に「[Vuetify] Multiple instances of Vue detected」が発生する時の対処法

Electron-VueにVuetifyを追加したときに、以下のようなErrorがconsoleに表示されました。
その対処法です。

スクリーンショット 2019-03-27 6.15.05.png

対処法

https://github.com/vuetifyjs/vuetify/issues/4068 に記載の通り
webpack.renderer.config.jsのwhiteListedModulesの定義にvuetifyを追加することで解消できます。

.electreon-vue/webpack.renderer.config.js
- let whiteListedModules = ['vue']
+ let whiteListedModules = ['vue', 'vuetify']
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む