- 投稿日:2019-03-27T22:29:28+09:00
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.cssnuxt.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
- 投稿日:2019-03-27T21:46:19+09:00
KubernetesとNode.jsでマイクロサービスを作成する 2/8
第2章 Tweetサービス
本章ではNode.jsを利用してTweetの作成/取得を行うTweetサービスを作成します。
Node.jsを利用したREST APIサービスの作成における(著者的)ベストプラクティスな内容を記載しているため、ボリュームはけっこう大きめとなっております。なお、Kubernetesだけ触りたいという方は以下の完成済みのリポジトリをforkすることで、この章実装をスキップすることができます。
reireias/microservice-sample-tweet
チュートリアル全体
- 第1章 概要
- 第2章 Tweetサービス
- 第3章 Userサービス
- 第4章 Webサービス
- 第5章 Docker
- 第6章 Kubernetes with minikube
- 第7章 Kubernetes with GCP
- 第8章 Kubernetes with AWS
システム構成
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ファイルを以下の内容で作成しておきましょう。gitignorenode_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を以下のように作成します。.eslintrc.jsmodule.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.jsconst a = "hoge" const b = 1+2ターミナルから
prettierを実行します。yarn run prettier --write app.jsすると、下記のように
app.jsの中身が変更されているはずです。app.jsconst 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の下に下記の設定を追記しましょう。package.json... "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にダミー応答を返す実装をしてみましょう。app.jsconst 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.jsconst express = require('express') const tweets = require('./v1/tweets.js') const router = express.Router() router.use('/v1/tweets', tweets) module.exports = routercontrollers/v1/tweets.jsconst 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内の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にタイムライン用のコントローラーを追加します。controllers/index.jsconst 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.jsconst 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.jsconst 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 modelsTweetモデルを次のように実装します。
models/tweet.jsconst 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.jsconst 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.jsconst 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/timelineconst 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コマンドを定義しましょう。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.jsconst 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.jsconst 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 scriptsDB中のレコードを消去し、ダミーデータを登録するスクリプトを実装していきます。
scripts/initialize.jsconst 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-jsdocswagger関連のファイルを配置するディレクトリを作成します。
mkdir swagger各APIの定義はcontrollerのそれぞれのメソッドのコメントとして記述することになるのですが、全体で共通する設定は
swagger/swaggerDef.jsに記述します。
swaggerのバージョンは最新の3.0を利用します。swagger/swaggerDef.jsconst 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.jsとcontrollers/v1/timeline.jsにコメントでswagger定義を記述していきます。controllers/v1/tweets.jsconst 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 = routercontrollers/v1/timeline.jsconst 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 Specを確認するために、
yarn swaggerでファイル出力し、Swagger Editorへコピペするのは大変です。
この確認を簡単にするために、起動したサーバーから直接Swagger Specを取得できるようにします。サーバーから直接取得できるようにすることで、jsファイルを更新すれば
nodemonのホットリロードで即座に反映されたり、Swagger UIを用いて簡単に生成されたSwagger Specを確認できるようになります。
また、テストサーバー等で公開することで、他のチームでも容易にSwagger Specを確認できるようになる点もメリットの一つです。
controllers/index.jsを下記のように修正します。
今回はdevelopment以外の環境ではSwagger Specファイルは取得できない実装にしています。controllers/index.jsconst 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サービス
- 投稿日:2019-03-27T21:46:19+09:00
KubernetesとNode.jsでマイクロサービスを作成する 2/8 Tweetサービス
第2章 Tweetサービス
本章ではNode.jsを利用してTweetの作成/取得を行うTweetサービスを作成します。
Node.jsを利用したREST APIサービスの作成における(著者的)ベストプラクティスな内容を記載しているため、ボリュームはけっこう大きめとなっております。なお、Kubernetesだけ触りたいという方は以下の完成済みのリポジトリをforkすることで、この章実装をスキップすることができます。
reireias/microservice-sample-tweet
チュートリアル全体
- 第1章 概要
- 第2章 Tweetサービス
- 第3章 Userサービス
- 第4章 Webサービス
- 第5章 Dockerを使ったサービス構築
- 第6章 Kubernetes with minikube
- 第7章 Kubernetes with GCP
- 第8章 Kubernetes with AWS
構成
システム構成
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ファイルを以下の内容で作成しておきましょう。gitignorenode_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を以下のように作成します。.eslintrc.jsmodule.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.jsconst a = "hoge" const b = 1+2ターミナルから
prettierを実行します。yarn run prettier --write app.jsすると、下記のように
app.jsの中身が変更されているはずです。app.jsconst 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の下に下記の設定を追記しましょう。package.json... "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にダミー応答を返す実装をしてみましょう。app.jsconst 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.jsconst express = require('express') const tweets = require('./v1/tweets.js') const router = express.Router() router.use('/v1/tweets', tweets) module.exports = routercontrollers/v1/tweets.jsconst 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内の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にタイムライン用のコントローラーを追加します。controllers/index.jsconst 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.jsconst 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.jsconst 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 modelsTweetモデルを次のように実装します。
models/tweet.jsconst 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.jsconst 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.jsconst 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/timelineconst 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コマンドを定義しましょう。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.jsconst 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.jsconst 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 scriptsDB中のレコードを消去し、ダミーデータを登録するスクリプトを実装していきます。
scripts/initialize.jsconst 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-jsdocswagger関連のファイルを配置するディレクトリを作成します。
mkdir swagger各APIの定義はcontrollerのそれぞれのメソッドのコメントとして記述することになるのですが、全体で共通する設定は
swagger/swaggerDef.jsに記述します。
swaggerのバージョンは最新の3.0を利用します。swagger/swaggerDef.jsconst 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.jsとcontrollers/v1/timeline.jsにコメントでswagger定義を記述していきます。controllers/v1/tweets.jsconst 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 = routercontrollers/v1/timeline.jsconst 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 Specを確認するために、
yarn swaggerでファイル出力し、Swagger Editorへコピペするのは大変です。
この確認を簡単にするために、起動したサーバーから直接Swagger Specを取得できるようにします。サーバーから直接取得できるようにすることで、jsファイルを更新すれば
nodemonのホットリロードで即座に反映されたり、Swagger UIを用いて簡単に生成されたSwagger Specを確認できるようになります。
また、テストサーバー等で公開することで、他のチームでも容易にSwagger Specを確認できるようになる点もメリットの一つです。
controllers/index.jsを下記のように修正します。
今回はdevelopment以外の環境ではSwagger Specファイルは取得できない実装にしています。controllers/index.jsconst 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サービス
- 投稿日:2019-03-27T21:44:44+09:00
KubernetesとNode.jsでマイクロサービスを作成する 1/8
Node.jsとKubernetesを使い、マイクロサービスを作ってみたくなったので、このチュートリアルを作成してみました。
バグや修正した方がよい点などあれば気軽にコメントをおねがいします。本チュートリアルは以下の8章構成になっています。
- 第1章 概要
- 第2章 Tweetサービス
- 第3章 Userサービス
- 第4章 Webサービス
- 第5章 Dockerを使ったサービス構築
- 第6章 Kubernetes with minikube
- 第7章 Kubernetes with GCP
- 第8章 Kubernetes with AWS
第1章 概要
この章では本チュートリアルの概要について記載します。
目的
本チュートリアルは以下の目的で作成されています。
- Kubernetesを使い、実際にMicroserviceを構築することで、Kubernetesへの理解を深める
- 複数チームによる開発を考慮し、リポジトリや検証環境を構築する
- AWSのEKSとGCPのGKEを利用することで、PaaSでのKubernetes利用の知見を獲得する
作成するMicroserviceの概要
本チュートリアルではKubernetesとNode.jsを使い、TwitterライクなMicroserviceを構築します。
実装する機能としては下記になります。
- ログイン/ログアウト機能(GitHub認証)
- ツイート機能
- フォロー/アンフォロー機能
- タイムライン機能(自分 + フォローしているユーザーのツイート一覧)
構築するMicroserviceの概要図は下記のようになります。
BFF(Backend for Frontend)を採用し、Webサービスのサーバーサイドが各サービスとデータをやり取りする構成としています。なお、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サービス
- 投稿日:2019-03-27T21:44:44+09:00
KubernetesとNode.jsでマイクロサービスを作成する 1/8 概要
Node.jsとKubernetesを使い、マイクロサービスを作ってみたくなったので、このチュートリアルを作成してみました。
バグや修正した方がよい点などあれば気軽にコメントをおねがいします。本チュートリアルは以下の8章構成になっています。
- 第1章 概要
- 第2章 Tweetサービス
- 第3章 Userサービス
- 第4章 Webサービス
- 第5章 Dockerを使ったサービス構築
- 第6章 Kubernetes with minikube
- 第7章 Kubernetes with GCP
- 第8章 Kubernetes with AWS
第1章 概要
この章では本チュートリアルの概要について記載します。
目的
本チュートリアルは以下の目的で作成されています。
- Kubernetesを使い、実際にMicroserviceを構築することで、Kubernetesへの理解を深める
- 複数チームによる開発を考慮し、リポジトリや検証環境を構築する
- AWSのEKSとGCPのGKEを利用することで、PaaSでのKubernetes利用の知見を獲得する
作成するMicroserviceの概要
本チュートリアルではKubernetesとNode.jsを使い、TwitterライクなMicroserviceを構築します。
実装する機能としては下記になります。
- ログイン/ログアウト機能(GitHub認証)
- ツイート機能
- フォロー/アンフォロー機能
- タイムライン機能(自分 + フォローしているユーザーのツイート一覧)
構築するMicroserviceの概要図は下記のようになります。
BFF(Backend for Frontend)を採用し、Webサービスのサーバーサイドが各サービスとデータをやり取りする構成としています。なお、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サービス
- 投稿日:2019-03-27T16:41:52+09:00
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
- 投稿日:2019-03-27T16:37:17+09:00
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-cli2.vueプロジェクトの作成
$ vue init webpack my-project3.vueプロジェクトに移動
$ cd my-project4.UIKitのインストール
$ npm install uikit5.UIKitの読み込み
main.jsにUIKitを読み込むよう、以下の内容を追記
src/main.jsimport 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させると上手くいったので、その手順を載せておきます。
- 投稿日:2019-03-27T06:23:47+09:00
Electron-VueでVuetifyを使用した際に「[Vuetify] Multiple instances of Vue detected」が発生する時の対処法
Electron-VueにVuetifyを追加したときに、以下のようなErrorがconsoleに表示されました。
その対処法です。対処法
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']



