用 NodeJS 打造影院微服务并部署到 docker 上 Part 3 - V2EX
V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
darluc
V2EX    Node.js

用 NodeJS 打造影院微服务并部署到 docker 上 Part 3

  •  
  •   darluc 2017-10-17 14:52:24 +08:00 4943 次点击
    这是一个创建于 2966 天前的主题,其中的信息可能已经有所发展或是发生改变。

    点击阅读全文

    大家好,本文是「使用 NodeJS 构建影院微服务」系列的第 三篇文章。此系列文章旨在展示如何使用 ES6,ES7 … 8?,和 expressjs 构建一个 API 应用,如何连接 MongoDB 集群,怎样将其部署于 docker 容器中,以及模拟微服务运行于云环境中的情况。

    ## 以往章节快速回顾

    • 我们讲了什么是微服务,探讨了微服务
    • 我们定义了影院微服务架构
    • 我们设计并实现了电影服务影院目录服务
    • 我们实现了这些服务的 API 接口,并对这些接口做了单元测试
    • 我们对运行于Docker中的服务进行了集成测试
    • 我们讨论了微服务安全并使其适配了 HTTP/2 协议
    • 我们对影院目录服务进行了压力测试

    如果你没有阅读之前的章节,那么很有可能会错一些有趣的东西 ,下面我列出前两篇的链接,方便你有兴趣的话可以看一下。

    在之前的章节中,我们已经完成了以下架构图中的上层部分,接着从本章起,我们要开始图中下层部分的开发了。

    到目前为止,我们的终端用户已经能够在影院看到电影首映信息,选择影院并下单买票。本章我们会继续构建影院架构,并探索订票服务内部是如何工作的,跟我一起学点有趣的东西吧。

    我们将使用到以下技术:

    • NodeJS version 7.5.0
    • MongoDB 3.4.1
    • Docker for Mac 1.13

    要跟上本文的进度有以下要求:

    如果你还没有完成这些代码,我已经将代码传到了 github 上,你可以直接使用代码库分支 step-2

    # NodeJS 中的依赖注入

    至今为止我们已经构建了两套微服务的 API 接口,不过都没有遇到太多的配置和开发工作,这是由这些微服务自身的特性和简单性决定的。不过这一次,在订票服务中,我们会看到更多与其它服务之间的交互,因为这个服务的实现依赖项更多,为了防止写出一团乱麻似的代码,作为好的开发者,我们需要遵循某种设计模式,为此我们将会探究什么是**“依赖注入”**。

    想要达成良好的设计模式,我们必须很好地理解并应用 S.O.L.I.D 原则,我之前写过一篇与之相关的 Javascript 的文章,有空你可以看一下,主要讲述了这些原则是什么并且我们可以从中获得哪些好处。

    S.O.L.I.D The first 5 principles of Ojbect Oriented Design with Javascritp

    为什么依赖注入如此重要?因为它能给我们带来以下开发模式中的三大好处:

    • 解耦:依赖注入可减少模块之间的耦合性,使其更易于维护。
    • 单元测试:使用依赖注入,可使对于每个模块的单元测试做得更好,代码的 bug 也会较少。
    • 快速开发:利用依赖注入,在定义了接口之后,可以更加容易地进行分工合作而不会产生冲突。

    至今为此开发的微服务中,我们曾在 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. 

    我们定义了三个模型对象,BookingUser 以及 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 的文档

    点击阅读全文

    4 条回复    2017-10-18 20:53:17 +08:00
    kylix
        1
    kylix  
       2017-10-17 15:14:54 +08:00
    不错,收藏起来慢慢看~
    alouha
        2
    alouha  
       2017-10-17 16:00:55 +08:00
    为大佬打尻,mark
    hantsy
        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 有一些现在技术框架),期待分享。
    TabGre
        4
    TabGre  
       2017-10-18 20:53:17 +08:00 via iPhone
    厉害,上 pc 慢慢看
    关于     帮助文档     自助推广系统     博客     API     FAQ     Solana     2787 人在线   最高记录 6679       Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 28ms UTC 13:11 PVG 21:11 LAX 05:11 JFK 08:11
    Do have faith in what you're doing.
    ubao msn snddm index pchome yahoo rakuten mypaper meadowduck bidyahoo youbao zxmzxm asda bnvcg cvbfg dfscv mmhjk xxddc yybgb zznbn ccubao uaitu acv GXCV ET GDG YH FG BCVB FJFH CBRE CBC GDG ET54 WRWR RWER WREW WRWER RWER SDG EW SF DSFSF fbbs ubao fhd dfg ewr dg df ewwr ewwr et ruyut utut dfg fgd gdfgt etg dfgt dfgd ert4 gd fgg wr 235 wer3 we vsdf sdf gdf ert xcv sdf rwer hfd dfg cvb rwf afb dfh jgh bmn lgh rty gfds cxv xcv xcs vdas fdf fgd cv sdf tert sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf shasha9178 shasha9178 shasha9178 shasha9178 shasha9178 liflif2 liflif2 liflif2 liflif2 liflif2 liblib3 liblib3 liblib3 liblib3 liblib3 zhazha444 zhazha444 zhazha444 zhazha444 zhazha444 dende5 dende denden denden2 denden21 fenfen9 fenf619 fen619 fenfe9 fe619 sdf sdf sdf sdf sdf zhazh90 zhazh0 zhaa50 zha90 zh590 zho zhoz zhozh zhozho zhozho2 lislis lls95 lili95 lils5 liss9 sdf0ty987 sdft876 sdft9876 sdf09876 sd0t9876 sdf0ty98 sdf0976 sdf0ty986 sdf0ty96 sdf0t76 sdf0876 df0ty98 sf0t876 sd0ty76 sdy76 sdf76 sdf0t76 sdf0ty9 sdf0ty98 sdf0ty987 sdf0ty98 sdf6676 sdf876 sd876 sd876 sdf6 sdf6 sdf9876 sdf0t sdf06 sdf0ty9776 sdf0ty9776 sdf0ty76 sdf8876 sdf0t sd6 sdf06 s688876 sd688 sdf86