
大家好,本文是「使用 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 makin 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: '[email protected]', 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 dependncies 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 的文档。
1 kylix 2017-10-17 15:14:54 +08:00 不错,收藏起来慢慢看~ |
2 alouha 2017-10-17 16:00:55 +08:00 为大佬打尻,mark |
3 hantsy 2017-10-18 10:59:06 +08:00 非常不错。 我也有想写一些 Java 微服务方面的系列,不过最近 Java 9, Java EE8 , Spring 5 都更新了,最近忙更新这些知识,只好先放下 。 1. RAML 1.0 ? 为什么不用 OpenAPI (最新版本正式实现大统一了) 2. 数据没进行切分,同样会产生瓶颈问题,即使你是 Cluster。 另外和微服务本身一样,微服务架构也要考虑数据库的多态性,用适合数据库( Document,RDBMS,Key/Value, 等)实现相应的场景。 3. 像通知这些可以用 Messaging Broker 来演示。事实上以前一些项目经验中,服务内部( Gateway 以内)的交流能够用消息的就用消息,以事件驱动优先。异步通知外部客户端可以用 Websocket,SSE 方式。 4. CQRS 和 Event Sourcing 有点复杂,应对一些跨多个微服务场景,越长“事务”场景,要权衡 CAP, 回退都要实现相应的 Compensation 机制,不知道 NodeJS 在这方面有没有成熟的方案( Java 有一些现在技术框架),期待分享。 |
4 TabGre 2017-10-18 20:53:17 +08:00 via iPhone 厉害,上 pc 慢慢看 |