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

2017-10-17 14:52:24 +08:00
 darluc

点击阅读全文

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

## 以往章节快速回顾

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

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

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

我们将使用到以下技术:

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

如果你还没有完成这些代码,我已经将代码传到了 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

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

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

点击阅读全文

4558 次点击
所在节点    Node.js
4 条回复
kylix
2017-10-17 15:14:54 +08:00
不错,收藏起来慢慢看~
alouha
2017-10-17 16:00:55 +08:00
为大佬打尻,mark
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
2017-10-18 20:53:17 +08:00
厉害,上 pc 慢慢看

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/398342

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX