V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
darluc
V2EX  ›  Node.js

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

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

    点击阅读全文

    大家好,本文是「使用 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 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.
    

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

    点击阅读全文

    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   ·   我们的愿景   ·   实用小工具   ·   2454 人在线   最高记录 6543   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 32ms · UTC 15:56 · PVG 23:56 · LAX 08:56 · JFK 11:56
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.