大家好,本文是「使用 NodeJS 构建影院微服务」系列的第 三篇文章。此系列文章旨在展示如何使用 ES6,¿ES7 … 8?,和 expressjs 构建一个 API 应用,如何连接 MongoDB 集群,怎样将其部署于 docker 容器中,以及模拟微服务运行于云环境中的情况。
如果你没有阅读之前的章节,那么很有可能会错一些有趣的东西 🤘🏽,下面我列出前两篇的链接,方便你有兴趣的话可以看一下👀。
在之前的章节中,我们已经完成了以下架构图中的上层部分,接着从本章起,我们要开始图中下层部分的开发了。
到目前为止,我们的终端用户已经能够在影院看到电影首映信息,选择影院并下单买票。本章我们会继续构建影院架构,并探索订票服务内部是如何工作的,跟我一起学点有趣的东西吧。
我们将使用到以下技术:
要跟上本文的进度有以下要求:
如果你还没有完成这些代码,我已经将代码传到了 github 上,你可以直接使用代码库分支 step-2。
至今为止我们已经构建了两套微服务的 API 接口,不过都没有遇到太多的配置和开发工作,这是由这些微服务自身的特性和简单性决定的。不过这一次,在订票服务中,我们会看到更多与其它服务之间的交互,因为这个服务的实现依赖项更多,为了防止写出一团乱麻似的代码,作为好的开发者,我们需要遵循某种设计模式,为此我们将会探究什么是**“依赖注入”**。
想要达成良好的设计模式,我们必须很好地理解并应用 S.O.L.I.D 原则,我之前写过一篇与之相关的 javascript 的文章,有空你可以看一下🤓,主要讲述了这些原则是什么并且我们可以从中获得哪些好处。
S.O.L.I.D The first 5 principles of Ojbect Oriented Design with Javascritp
为什么依赖注入如此重要?因为它能给我们带来以下开发模式中的三大好处:
至今为此开发的微服务中,我们曾在 index.js
文件中使用到了依赖注入
// more code
mediator.on('db.ready', (db) => {
let rep
// here we are making DI to the repository
// we are injecting the database object and the ObjectID object
repository.connect({
db,
ObjectID: config.ObjectID
})
.then(repo => {
console.log('Connected. Starting Server')
rep = repo
// here we are also making DI to the server
// we are injecting serverSettings and the repo object
return server.start({
port: config.serverSettings.port,
ssl: config.serverSettings.ssl,
repo
})
})
.then(app => {
console.log(`Server started succesfully, running on port: ${config.serverSettings.port}.`)
app.on('close', () => {
rep.disconnect()
})
})
})
// more code
在 index.js
文件中我们使用了手动的依赖注入,因为没有必要做得更多。不过在订票服务中,我们将需要一种更好地依赖注入方式,为了厘清个中缘由,在开始构建 API 接口之前,我们要先弄清楚订票服务需要完成哪些任务。
所以这次我们的开发任务变得相对重了一些,相应地代码也会变多,这也是我们需要一个单一依赖注入来源的原因,因为我们需要做更多的功能开发。
首先我们来看一下订票服务的 RAML 文件。
#%RAML 1.0
title: Booking Service
version: v1
baseUri: /
types:
Booking:
properties:
city: string
cinema: string
movie: string
schedule: datetime
cinemaRoom: string
seats: array
totalAmount: number
User:
properties:
name: string
lastname: string
email: string
creditcard: object
phoneNumber?: string
membership?: number
Ticket:
properties:
cinema: string
schedule: string
movie: string
seat: string
cinemaRoom: string
orderId: string
resourceTypes:
GET:
get:
responses:
200:
body:
application/json:
type: <<item>>
POST:
post:
body:
application/json:
type: <<item>>
type: <<item2>>
responses:
201:
body:
application/json:
type: <<item3>>
/booking:
type: { POST: {item : Booking, item2 : User, item3: Ticket} }
description: The booking service need a Booking object that contains all
the needed information to make a purchase of cinema tickets. Needs a user information to make the booking succesfully. And returns a ticket object.
/verify/{orderId}:
type: { GET: {item : Ticket} }
description: This route is for verify orders, and would return all the details of a specific purchased by orderid.
我们定义了三个模型对象,Booking 、User 以及 Ticket 。由于这是系列文章中第一次使用到 POST 请求,因此还有一项 NodeJS 的最佳实践我们还没有使用过,那就是数据验证。在“ Build beautiful node API's “ 这篇文章中有一句很好的表述:
一定,一定,一定要验证输入(以及输出)的数据。有 joi 以及 express-validator 等模块可以帮助你优雅地完成数据净化工作。— Azat Mardan
现在我们可以开始开发订票服务了。我们将使用与上一章相同的项目结构,不过会稍微做一点点改动。让我们不再纸上谈兵,撸起袖子开始编码! 👩🏻💻👨🏻💻。
首先我们在 /src
目录下新建一个 models
目录
booking-service/src $ mkdir models
# Now let's move to the folder and create some files
booking-service/src/models $ touch user.js booking.js ticket.js
# Now is moment to install a new npm package for data validation
npm i -S joi --silent
然后我们开始编写数据结构验证对象了,MonogDB也有内置的验证对象,不过这里需要验证的是数据对象的完整性,所以我们选择使用 joi,而且 joi 也允许我们同时进行数据验证,我们就由 booking.model.js 开始,然后是 ticket.model.js, 最后是 user.model.js。
const bookingSchema = (joi) => ({
bookingSchema: joi.object().keys({
city: joi.string(),
schedule: joi.date().min('now'),
movie: joi.string(),
cinemaRoom: joi.number(),
seats: joi.array().items(joi.string()).single(),
totalAmount: joi.number()
})
})
module.exports = bookingSchema
const ticketSchema = (joi) => ({
ticketSchema: joi.object().keys({
cinema: joi.string(),
schedule: joi.date().min('now'),
movie: joi.string(),
seat: joi.array().items(joi.string()).single(),
cinemaRoom: joi.number(),
orderId: joi.number()
})
})
module.exports = ticketSchema
const userSchema = (joi) => ({
userSchema: joi.object().keys({
name: joi.string().regex(/^[a-bA-B]+/).required(),
lastName: joi.string().regex(/^[a-bA-B]+/).required(),
email: joi.string().email().required(),
phoneNumber: joi.string().regex(/^(\+0?1\s)?\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}$/),
creditCard: joi.string().creditCard().required(),
membership: joi.number().creditCard()
})
})
module.exports = userSchema
如果你不是太了解 joi
,你可以去 github 上学习一下它的文档:文档链接
接下来我们编写模块的 index.js
文件,使这些校验方法暴露出来:
const joi = require('joi')
const user = require('./user.model')(joi)
const booking = require('./booking.model')(joi)
const ticket = require('./ticket.model')(joi)
const schemas = Object.create({user, booking, ticket})
const schemaValidator = (object, type) => {
return new Promise((resolve, reject) => {
if (!object) {
reject(new Error('object to validate not provided'))
}
if (!type) {
reject(new Error('schema type to validate not provided'))
}
const {error, value} = joi.validate(object, schemas[type])
if (error) {
reject(new Error(`invalid ${type} data, err: ${error}`))
}
resolve(value)
})
}
module.exports = Object.create({validate: schemaValidator})
我们所写的这些代码应用了SOLID 原则中的单一责任原则,每个模型都有自己的校验方法,还应用了开放封闭原则,每个结构校验函数都可以对任意多的模型对象进行校验,接下来看看如何为这些模型编写测试代码。
/* eslint-env mocha */
const test = require('assert')
const {validate} = require('./')
console.log(Object.getPrototypeOf(validate))
describe('Schemas Validation', () => {
it('can validate a booking object', (done) => {
const now = new Date()
now.setDate(now.getDate() + 1)
const testBooking = {
city: 'Morelia',
cinema: 'Plaza Morelia',
movie: 'Assasins Creed',
schedule: now,
cinemaRoom: 7,
seats: ['45'],
totalAmount: 71
}
validate(testBooking, 'booking')
.then(value => {
console.log('validated')
console.log(value)
done()
})
.catch(err => {
console.log(err)
done()
})
})
it('can validate a user object', (done) => {
const testUser = {
name: 'Cristian',
lastName: 'Ramirez',
email: 'cristiano@nupp.com',
creditCard: '1111222233334444',
membership: '7777888899990000'
}
validate(testUser, 'user')
.then(value => {
console.log('validated')
console.log(value)
done()
})
.catch(err => {
console.log(err)
done()
})
})
it('can validate a ticket object', (done) => {
const testTicket = {
cinema: 'Plaza Morelia',
schedule: new Date(),
movie: 'Assasins Creed',
seats: ['35'],
cinemaRoom: 1,
orderId: '34jh1231ll'
}
validate(testTicket, 'ticket')
.then(value => {
console.log('validated')
console.log(value)
done()
})
.catch(err => {
console.log(err)
done()
})
})
})
然后,我们要看的代码文件是 api/booking.js
,我们将会遇到更多的麻烦了,¿ 为什么呢 ?,因为这里我们将会与两个外部服务进行交互:支付服务以及通知服务,而且这类交互会引发我们重新思考微服务的架构,并会牵扯到被称作时间驱动数据管理以及 CQRS 的课题,不过我们将把这些课题留到之后的章节再进行讨论,避免本章变得过于复杂冗长。所以,本章我们先与这些服务进行简单地交互。
'use strict'
const status = require('http-status')
module.exports = ({repo}, app) => {
app.post('/booking', (req, res, next) => {
// we grab the dependencies need it for this route
const validate = req.container.resolve('validate')
const paymentService = req.container.resolve('paymentService')
const notificationService = req.container.resolve('notificationService')
Promise.all([
validate(req.body.user, 'user'),
validate(req.body.booking, 'booking')
])
.then(([user, booking]) => {
const payment = {
userName: user.name + ' ' + user.lastName,
currency: 'mxn',
number: user.creditCard.number,
cvc: user.creditCard.cvc,
exp_month: user.creditCard.exp_month,
exp_year: user.creditCard.exp_year,
amount: booking.amount,
description: `
Tickect(s) for movie ${booking.movie},
with seat(s) ${booking.seats.toString()}
at time ${booking.schedule}`
}
return Promise.all([
// we call the payment service
paymentService(payment),
Promise.resolve(user),
Promise.resolve(booking)
])
})
.then(([paid, user, booking]) => {
return Promise.all([
repo.makeBooking(user, booking),
repo.generateTicket(paid, booking)
])
})
.then(([booking, ticket]) => {
// we call the notification service
notificationService({booking, ticket})
res.status(status.OK).json(ticket)
})
.catch(next)
})
app.get('/booking/verify/:orderId', (req, res, next) => {
repo.getOrderById(req.params.orderId)
.then(order => {
res.status(status.OK).json(order)
})
.catch(next)
})
}
你可以看到,这里我们使用到了 expressjs 的中间件:container,并将其作为我们所用到的依赖项的唯一真实来源。
不过包含这些依赖项的 container 是从何而来呢?
我们现在对项目结构做了一点调整,主要是对 config
目录的调整,如下:
.
|-- config
| |-- db
| | |-- index.js
| | |-- mongo.js
| | `-- mongo.spec.js
| |-- di
| | |-- di.js
| | `-- index.js
| |-- ssl
| | |-- certificates
| | `-- index.js
| |-- config.js
| |-- index.spec.js
| `-- index.js
在 config/index.js
文件包含了几乎所有的配置文件,包括依赖注入服务:
const {dbSettings, serverSettings} = require('./config')
const database = require('./db')
const {initDI} = require('./di')
const models = require('../models')
const services = require('../services')
const init = initDI.bind(null, {serverSettings, dbSettings, database, models, services})
module.exports = Object.assign({}, {init})
上面的代码中我们看到些不常见的东西,这里提出来给大家看看:
initDI.bind(null, {serverSettings, dbSettings, database, models, services})
这行代码到底做了什么呢?之前我提到过我们要配置依赖注入,不过这里我们做的事情叫作控制反转,的确这种说法太过于技术化了,甚至有些夸张,不过一旦你理解了之后就很容易理解。
所以我们的依赖注入函数不需要知道依赖项来自哪里,它只要注册这些依赖项,使得应用能够使用即可,我们的 di.js
看起来如下:
const { createContainer, asValue, asFunction, asClass } = require('awilix')
function initDI ({serverSettings, dbSettings, database, models, services}, mediator) {
mediator.once('init', () => {
mediator.on('db.ready', (db) => {
const container = createContainer()
// loading dependecies in a single source of truth
container.register({
database: asValue(db).singleton(),
validate: asValue(models.validate),
booking: asValue(models.booking),
user: asValue(models.booking),
ticket: asValue(models.booking),
ObjectID: asClass(database.ObjectID),
serverSettings: asValue(serverSettings),
paymentService: asValue(services.paymentService),
notificationService: asValue(services.notificationService)
})
// we emit the container to be able to use it in the API
mediator.emit('di.ready', container)
})
mediator.on('db.error', (err) => {
mediator.emit('di.error', err)
})
database.connect(dbSettings, mediator)
mediator.emit('boot.ready')
})
}
module.exports.initDI = initDI
如你所见,我们使用了一个名为 awilix
的 npm 包用作依赖注入,awilix 实现了 nodejs 中的依赖注入机制(我目前正在试用这个库,这里使用它是为了是例子看起来更加清晰),要安装它需要执行以下指令:
npm i -S awilix --silent
现在我们的主 index.js
文件看起来就像这样:
'use strict'
const {EventEmitter} = require('events')
const server = require('./server/server')
const repository = require('./repository/repository')
const di = require('./config')
const mediator = new EventEmitter()
console.log('--- Booking Service ---')
console.log('Connecting to movies repository...')
process.on('uncaughtException', (err) => {
console.error('Unhandled Exception', err)
})
process.on('uncaughtRejection', (err, promise) => {
console.error('Unhandled Rejection', err)
})
mediator.on('di.ready', (container) => {
repository.connect(container)
.then(repo => {
container.registerFunction({repo})
return server.start(container)
})
.then(app => {
app.on('close', () => {
container.resolve('repo').disconnect()
})
})
})
di.init(mediator)
mediator.emit('init')
现在你能看到,我们使用的包含所有依赖项的真实唯一来源,可通过 request 的 container 属性访问,至于我们怎样通过 expressjs 的中间件进行设置的,如之前提到过的,其实只需要几行代码:
const express = require('express')
const morgan = require('morgan')
const helmet = require('helmet')
const bodyparser = require('body-parser')
const cors = require('cors')
const spdy = require('spdy')
const _api = require('../api/booking')
const start = (container) => {
return new Promise((resolve, reject) => {
// here we grab our dependencies needed for the server
const {repo, port, ssl} = container.resolve('serverSettings')
if (!repo) {
reject(new Error('The server must be started with a connected repository'))
}
if (!port) {
reject(new Error('The server must be started with an available port'))
}
const app = express()
app.use(morgan('dev'))
app.use(bodyparser.json())
app.use(cors())
app.use(helmet())
app.use((err, req, res, next) => {
if (err) {
reject(new Error('Something went wrong!, err:' + err))
res.status(500).send('Something went wrong!')
}
next()
})
// here is where we register the container as middleware
app.use((req, res, next) => {
req.container = container.createScope()
next()
})
// here we inject the repo to the API, since the repo is need it for all of our functions
// and we are using inversion of control to make it available
const api = _api.bind(null, {repo: container.resolve('repo')})
api(app)
if (process.env.NODE === 'test') {
const server = app.listen(port, () => resolve(server))
} else {
const server = spdy.createServer(ssl, app)
.listen(port, () => resolve(server))
}
})
}
module.exports = Object.assign({}, {start})
基本上,我们只是将 container 对象附加到了 expressjs 的 req 对象上,这样 expressjs 的所有路由上都能访问到它了。如果你想更深入地了解 expressjs 的中间件是如何工作的,你可以点击这个链接查看 expressjs 的文档。
这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。
V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。
V2EX is a community of developers, designers and creative people.