第3章 Userサービス
本章ではNode.jsを利用してUserの管理とフォロー関係の管理を行うUserサービスを作成します。
なお、第2章と同様に、Kubernetesだけ触りたいという方は以下の完成済みのリポジトリをforkすることで、この章実装をスキップすることができます。
reireias/microservice-sample-user
チュートリアル全体
構成
システム構成
Userサービスのシステム構成は以下のようにします。
第2章のTweetサービスと同じ構成となっています。
要素 | 採用技術 |
---|---|
言語 | Node.js |
フレームワーク | express |
DB | MongoDB |
DB用ライブラリ | mongoose |
テストフレームワーク | ava |
REST API
Userサービスに必要なAPIについて設計していきます。
Userサービスに関係ありそうな操作は以下になります。
- ユーザー情報の取得
- ユーザー情報の作成(ログイン時)
- ユーザー情報の更新(ログイン時)
- ユーザー一覧の取得
- フォローしているユーザー一覧の取得
- フォロー
- フォロー解除
リソースは"ユーザー"と"フォロー"の2種類が必要そうです。
"ユーザー"と"フォロー"に対するCRUDを実装することにしましょう。
また、ログイン時のユーザーの作成と更新を1リクエストで実施できるAPIも提供することにしましょう。
上記より、REST APIを作成していきます。
method | path | description |
---|---|---|
GET | /users | ユーザー一覧取得 |
POST | /users | ユーザー作成 |
POST | /users/loginUser | ログインユーザーの作成/更新 |
GET | /users/{id} | ユーザー取得 |
DELETE | /users/{id} | ユーザー削除 |
GET | /users/{id}/follows | フォロー一覧取得 |
POST | /users/{id}/follows | フォロー |
DELETE | /users/{id}/follows | アンフォロー |
DBスキーマ
続いてDBの保存するデータについて設計します。
REST API設計の際に、"ユーザー"リソースと"フォロー"リソースが登場しました。
MongoDBはjsonオブジェクト形式でレコードを保存できるため、1ユーザーに関するユーザー情報とフォロー情報を1レコードで保存することも可能です。
ですが、無限に増える項目はcollectionとして設計した方がよいため、ユーザーとフォローをそれぞれcollectionとして定義します。
ユーザーが持つ情報としては以下になります。
- 名前
- アバター画像のURL
フォローリソースが持つ情報は以下になります。
- フォローしているユーザーのID
- フォローされているユーザーのID
これらをMongoDBのスキーマに落とし込むと、それぞれ以下のようになります。
user document
{
_id: ObjectId,
name: String,
avatorUrl: String
}
follow document
{
_id: ObjectId,
userId: ObjectId,
followId: ObjectId
}
ObjectId
型はMongoDBが生成するID型を表しています。
実装
では、実際にUserサービスを実装していきますが、本章の実装は第2章の実装とほぼ同じ内容なので、大部分を割愛し、最終的なコードのみ記載します。
リポジトリはmicroservice-sample-user
という名前で作成しておきましょう。
# プロジェクト初期化(microservice-sample-userディレクトリ内)
yarn init
# 依存モジュール追加(devDependencies)
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 husky nodemon ava supertest@^3.4.2 mongodb-memory-server
# 依存モジュール追加
yarn add express morgan body-parser mongodb mongoose swagger-jsdoc
ディレクトリ構成
microservice-sample-user
├── LICENSE
├── app.js
├── controllers
│ ├── index.js
│ └── v1
│ ├── follows.js
│ └── users.js
├── models
│ ├── follow.js
│ └── user.js
├── package.json
├── scripts
│ └── initialize.js
├── swagger
│ ├── components.yml
│ ├── swagger.yml
│ └── swaggerDef.js
├── test
│ ├── follows.js
│ ├── helpers
│ │ └── generator.js
│ └── users.js
└── yarn.lock
各ファイルの実装
node_modules
yarn-error.log
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: {}
}
{
"semi": false,
"singleQuote": true
}
...
"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"
},
"husky": {
"hooks": {
"pre-commit": "yarn lint"
}
},
...
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/user'
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, () => {})
const express = require('express')
const users = require('./v1/users.js')
const follows = require('./v1/follows.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/users.js',
'./controllers/v1/follows.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/users', users)
router.use('/v1/users/:id/follows', follows)
module.exports = router
const express = require('express')
const model = require('../../models/user.js')
const User = model.User
const router = express.Router()
/**
* @swagger
*
* /users:
* get:
* description: Returns a list of users.
* tags:
* - users
* responses:
* '200':
* description: A JSON array of users.
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Users'
*/
router.get('/', (req, res, next) => {
;(async () => {
if (req.query.name) {
const users = await User.find(
{ name: req.query.name },
{},
{ sort: { name: 1 } }
).exec()
res.status(200).json(users)
} else {
const users = await User.find().exec()
res.status(200).json(users)
}
})().catch(next)
})
/**
* @swagger
*
* /users/{id}:
* get:
* description: Find user by ID.
* tags:
* - users
* parameters:
* - name: id
* in: path
* required: true
* description: User ID.
* schema:
* type: 'string'
* example: '000000000000000000000000'
* responses:
* '200':
* description: A JSON object of user.
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/User'
* '400':
* $ref: '#/components/responses/BadRequest'
* '404':
* $ref: '#/components/responses/NotFound'
*/
router.get('/:id', (req, res, next) => {
;(async () => {
try {
const user = await User.findById(req.params.id).exec()
if (user) {
res.status(200).json(user)
} else {
res.status(404).json({ error: 'NotFound' })
}
} catch (err) {
console.error(err)
res.status(400).json({ error: 'BadRequest' })
}
})().catch(next)
})
/**
* @swagger
*
* /users:
* post:
* description: Create a user.
* tags:
* - users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* name:
* type: string
* example: 'alice'
* avatarUrl:
* type: string
* example: 'https://1.bp.blogspot.com/-LFh4mfdjPSQ/VCIiwe10YhI/AAAAAAAAme0/J5m8xVexqqM/s800/animal_neko.png'
* required:
* - name
* - avatarUrl
* responses:
* '200':
* description: Created user.
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/User'
* '400':
* $ref: '#/components/responses/BadRequest'
*/
router.post('/', (req, res, next) => {
;(async () => {
try {
const record = new User({
name: req.body.name
})
const savedRecord = await record.save()
res.status(200).json(savedRecord)
} catch (err) {
console.error(err)
res.status(400).json({ error: 'BadRequest' })
}
})().catch(next)
})
/**
* @swagger
*
* /users/{id}:
* put:
* description: Update a user or create a user if user not exist.
* tags:
* - users
* parameters:
* - name: id
* in: path
* required: true
* description: User ID.
* schema:
* type: 'string'
* example: '000000000000000000000000'
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* name:
* type: string
* example: 'alice'
* avatarUrl:
* type: string
* example: 'https://1.bp.blogspot.com/-LFh4mfdjPSQ/VCIiwe10YhI/AAAAAAAAme0/J5m8xVexqqM/s800/animal_neko.png'
* required:
* - name
* - avatarUrl
* responses:
* '200':
* description: Updated or created user.
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/User'
* '400':
* $ref: '#/components/responses/BadRequest'
* '404':
* $ref: '#/components/responses/NotFound'
*/
router.put('/:id', (req, res, next) => {
;(async () => {
try {
// idにloginUserを指定された場合は、ログインユーザー情報の登録or更新を行う
if (req.params.id === 'loginUser') {
const record = await User.findOneAndUpdate(
{ name: req.body.name },
{
name: req.body.name,
avatarUrl: req.body.avatarUrl
},
{
new: true,
upsert: true
}
).exec()
res.status(200).json(record)
} else {
const record = await User.findByIdAndUpdate(req.params.id, req.body, {
new: true
}).exec()
if (record) {
res.status(200).json(record)
} else {
res.status(404).json({ error: 'NotFound' })
}
}
} catch (err) {
console.error(err)
res.status(400).json({ error: 'BadRequest' })
}
})().catch(next)
})
/**
* @swagger
*
* /users/{id}:
* delete:
* description: Delete a user.
* tags:
* - users
* parameters:
* - name: id
* in: path
* required: true
* description: User 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 User.findByIdAndDelete(req.params.id).exec()
if (removedRecord) {
res.status(200).json({})
} else {
res.status(404).json({ error: 'NotFound' })
}
} catch (err) {
console.error(err)
res.status(400).json({ error: 'BadRequest' })
}
})().catch(next)
})
module.exports = router
const express = require('express')
const model = require('../../models/follow.js')
const User = require('../../models/user.js').User
const Follow = model.Follow
const router = express.Router({ mergeParams: true })
/**
* @swagger
*
* /users/{id}/follows:
* get:
* description: Returns follow user list.
* tags:
* - follows
* parameters:
* - name: id
* in: path
* required: true
* description: User ID.
* schema:
* type: 'string'
* example: '000000000000000000000000'
* responses:
* '200':
* description: A JSON array of users.
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Users'
* '400':
* $ref: '#/components/responses/BadRequest'
*/
router.get('/', (req, res, next) => {
;(async () => {
if (!model.validateObjectId(req.params.id)) {
res.status(400).json({ error: 'BadRequest' })
return
}
const records = await Follow.find({ userId: req.params.id }).exec()
const followIds = records.map(follow => follow.followId)
const follows = await User.find({ _id: { $in: followIds } }).exec()
res.status(200).json(follows)
})().catch(next)
})
/**
* @swagger
*
* /users/{id}/follows:
* post:
* description: Follow a user.
* tags:
* - follows
* parameters:
* - name: id
* in: path
* required: true
* description: User ID.
* schema:
* type: 'string'
* example: '000000000000000000000000'
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* followId:
* type: string
* example: '000000000000000000000001'
* required:
* - followId
* responses:
* '200':
* description: Empty body.
* '400':
* $ref: '#/components/responses/BadRequest'
*/
router.post('/', (req, res, next) => {
;(async () => {
const follow = new Follow({
userId: req.params.id,
followId: req.body.followId
})
const error = follow.validateSync()
if (error) {
res.status(400).json({ error: 'BadRequest' })
return
}
await follow.save()
res.status(200).json({})
})().catch(next)
})
/**
* @swagger
*
* /users/{id}/follows:
* delete:
* description: Unfollow a user.
* tags:
* - follows
* parameters:
* - name: id
* in: path
* required: true
* description: User ID.
* schema:
* type: 'string'
* example: '000000000000000000000000'
* - name: followId
* in: query
* required: true
* description: Unfollow target user ID.
* schema:
* type: 'string'
* example: '000000000000000000000001'
* responses:
* '200':
* description: Empty body.
* '404':
* $ref: '#/components/responses/NotFound'
*/
router.delete('/', (req, res, next) => {
;(async () => {
const result = await Follow.findOneAndDelete({
userId: req.params.id,
followId: req.query.followId
}).exec()
if (result) {
res.status(200).json({})
} else {
res.status(404).json({ error: 'NotFound' })
}
})().catch(next)
})
module.exports = router
const mongoose = require('mongoose')
const Schema = mongoose.Schema
const UserSchema = new Schema(
{
name: { type: String, required: true, unique: true },
avatarUrl: { type: String }
},
{
versionKey: false
}
)
exports.User = mongoose.model('User', UserSchema)
const mongoose = require('mongoose')
const Schema = mongoose.Schema
const ObjectId = mongoose.Types.ObjectId
const FollowSchema = new Schema(
{
userId: { type: ObjectId, required: true },
followId: { type: ObjectId, required: true }
},
{
versionKey: false
}
)
FollowSchema.index({ userId: 1, followId: 1 }, { unique: true })
exports.Follow = mongoose.model('Follow', FollowSchema)
exports.validateObjectId = idString => {
return ObjectId.isValid(idString)
}
const mongoose = require('mongoose')
const User = require('../models/user.js').User
const Follow = require('../models/follow.js').Follow
const dbUrl = process.env.MONGODB_URL || 'mongodb://localhost:27017/user'
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 user3Id = new ObjectId('000000000000000000000002')
const users = [
{
_id: user1Id,
name: 'alice',
avatarUrl:
'https://1.bp.blogspot.com/-LFh4mfdjPSQ/VCIiwe10YhI/AAAAAAAAme0/J5m8xVexqqM/s800/animal_neko.png'
},
{
_id: user2Id,
name: 'bob',
avatarUrl:
'https://4.bp.blogspot.com/-CtY5GzX0imo/VCIixcXx6PI/AAAAAAAAmfY/AzH9OmbuHZQ/s800/animal_penguin.png'
},
{
_id: user3Id,
name: 'carol',
avatarUrl:
'https://3.bp.blogspot.com/-n0PpkJL1BxE/VCIitXhWwpI/AAAAAAAAmfE/xLraJLXXrgk/s800/animal_hamster.png'
}
]
const follows = [
{
userId: user1Id,
followId: user2Id
},
{
userId: user1Id,
followId: user3Id
},
{
userId: user2Id,
followId: user1Id
}
]
const initialize = async () => {
mongoose.connect(dbUrl, options)
await User.deleteMany().exec()
await Follow.deleteMany().exec()
await User.insertMany(users)
await Follow.insertMany(follows)
mongoose.disconnect()
}
initialize()
.then(() => {
// eslint-disable-next-line no-console
console.log('finish.')
})
.catch(error => {
console.error(error)
})
const mongoose = require('mongoose')
const User = require('../../models/user.js').User
const Follow = require('../../models/follow.js').Follow
const ObjectId = mongoose.Types.ObjectId
const user1Id = new ObjectId('000000000000000000000000')
const user2Id = new ObjectId('000000000000000000000001')
const user3Id = new ObjectId('000000000000000000000002')
const users = [
{
_id: user1Id,
name: 'alice',
avatarUrl: 'https://example.com/dummy1'
},
{
_id: user2Id,
name: 'bob',
avatarUrl: 'https://example.com/dummy2'
},
{
_id: user3Id,
name: 'carol',
avatarUrl: 'https://example.com/dummy3'
}
]
const follows = [
{
userId: user1Id,
followId: user2Id
},
{
userId: user1Id,
followId: user3Id
},
{
userId: user2Id,
followId: user1Id
}
]
module.exports = {
createData: async () => {
await User.insertMany(users)
await Follow.insertMany(follows)
},
deleteData: async () => {
await User.deleteMany().exec()
await Follow.deleteMany().exec()
},
users: users
}
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 generator = require('./helpers/generator.js')
const router = require('../controllers/v1/users.js')
const model = require('../models/user.js')
const User = model.User
const users = generator.users
const mongod = new MongoMemoryServer()
const app = express()
app.use(bodyParser.json())
app.use(bodyParser.urlencoded({ extended: true }))
app.use('/users', router)
test.before(async () => {
const uri = await mongod.getConnectionString()
mongoose.connect(uri, { useNewUrlParser: true })
})
test.beforeEach(async () => {
await generator.createData()
})
test.afterEach.always(async () => {
await generator.deleteData()
})
// GET /users
test.serial('get users', async t => {
const res = await supertest(app).get('/users')
t.is(res.status, 200)
t.is(res.body.length, 3)
})
// GET /users/:id
test.serial('get user', async t => {
const userId = users[0]._id.toString()
const res = await supertest(app).get(`/users/${userId}`)
t.is(res.status, 200)
t.is(res.body._id, userId)
t.is(res.body.name, users[0].name)
})
test.serial('get user not found', async t => {
const res = await supertest(app).get(
`/users/${new mongoose.Types.ObjectId()}`
)
t.is(res.status, 404)
t.deepEqual(res.body, { error: 'NotFound' })
})
test.serial('get user id is invalid', async t => {
const res = await supertest(app).get('/users/invalid')
t.is(res.status, 400)
t.deepEqual(res.body, { error: 'BadRequest' })
})
// POST /users
test.serial('create user', async t => {
const name = 'dave'
const res = await supertest(app)
.post('/users')
.send({ name: name })
t.is(res.status, 200)
t.true('_id' in res.body)
t.is(res.body.name, name)
})
test.serial('create user name is empty', async t => {
const res = await supertest(app)
.post('/users')
.send({})
t.is(res.status, 400)
t.deepEqual(res.body, { error: 'BadRequest' })
})
// PUT /users/:id
test.serial('update user', async t => {
const name = 'new name'
const res = await supertest(app)
.put(`/users/${users[0]._id}`)
.send({ name: name })
t.is(res.status, 200)
t.is(res.body.name, name)
})
test.serial('put user not found', async t => {
const res = await supertest(app).put(
`/users/${new mongoose.Types.ObjectId()}`
)
t.is(res.status, 404)
t.deepEqual(res.body, { error: 'NotFound' })
})
test.serial('put user id is invalid', async t => {
const res = await supertest(app).put('/users/invalid')
t.is(res.status, 400)
t.deepEqual(res.body, { error: 'BadRequest' })
})
// DELETE /users/:id
test.serial('delete user', async t => {
const res = await supertest(app).delete(`/users/${users[0]._id}`)
t.is(res.status, 200)
const actual = await User.find()
t.is(actual.length, 2)
})
test.serial('delete user not found', async t => {
const res = await supertest(app).delete(
`/users/${new mongoose.Types.ObjectId()}`
)
t.is(res.status, 404)
t.deepEqual(res.body, { error: 'NotFound' })
})
test.serial('delete user id is invalid', async t => {
const res = await supertest(app).delete('/users/invalid')
t.is(res.status, 400)
t.deepEqual(res.body, { error: 'BadRequest' })
})
const test = require('ava')
const supertest = require('supertest')
const mongoose = require('mongoose')
const express = require('express')
const bodyParser = require('body-parser')
const { MongoMemoryServer } = require('mongodb-memory-server')
console.error = () => {}
const generator = require('./helpers/generator.js')
const router = require('../controllers/v1/follows.js')
const model = require('../models/follow.js')
const Follow = model.Follow
const user1Id = generator.users[0]._id.toString()
const user2Id = generator.users[1]._id.toString()
const user3Id = generator.users[2]._id.toString()
const mongod = new MongoMemoryServer()
const app = express()
app.use(bodyParser.json())
app.use(bodyParser.urlencoded({ extended: true }))
app.use('/users/:id/follows', router)
test.before(async () => {
const uri = await mongod.getConnectionString()
mongoose.connect(uri, { useNewUrlParser: true, useCreateIndex: true })
})
test.beforeEach(async () => {
await generator.createData()
})
test.afterEach.always(async () => {
await generator.deleteData()
})
// GET /users/:id/follows
test.serial('get follows', async t => {
const res = await supertest(app).get(`/users/${user1Id}/follows`)
t.is(res.status, 200)
t.is(res.body.length, 2)
})
test.serial('get follows not found', async t => {
const res = await supertest(app).get(
`/users/999999999999999999999999/follows`
)
t.is(res.status, 200)
t.is(res.body.length, 0)
})
test.serial('get follows invalid id', async t => {
const res = await supertest(app).get(`/users/invalid/follows`)
t.is(res.status, 400)
t.deepEqual(res.body, { error: 'BadRequest' })
})
// POST /users/:id/follows
test.serial('create follow', async t => {
const res = await supertest(app)
.post(`/users/${user2Id}/follows`)
.send({ followId: user3Id })
t.is(res.status, 200)
const follows = await Follow.find({ userId: user2Id }).exec()
t.is(follows.length, 2)
})
test.serial('create follow no followId', async t => {
const res = await supertest(app)
.post(`/users/${user2Id}/follows`)
.send({})
t.is(res.status, 400)
t.deepEqual(res.body, { error: 'BadRequest' })
})
// DELETE /users/:id/follows
test.serial('delete follow', async t => {
const res = await supertest(app)
.delete(`/users/${user1Id}/follows`)
.query({ followId: user2Id })
t.is(res.status, 200)
const follows = await Follow.find({ userId: user1Id }).exec()
t.is(follows.length, 1)
})
test.serial('delete follow not found', async t => {
const res = await supertest(app)
.delete(`/users/${user3Id}/follows`)
.query({ followId: user2Id })
t.is(res.status, 404)
t.deepEqual(res.body, { error: 'NotFound' })
})
第3章まとめ
第3章では第2章同様にNode.js + MongoDBを利用してUserサービスを構築しました。
次の章では第2章のTweetサービス、第3章のUserサービスを利用してWebUIを提供するWebサービスを構築していきます。
次章: 第4章 Webサービス