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

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

点击阅读全文

大家好,本文是「使用 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 =&gt; {
    console.log('validated')
    console.log(value)
    done()
  })
  .catch(err =&gt; {
    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 =&gt; {
    console.log('validated')
    console.log(value)
    done()
  })
  .catch(err =&gt; {
    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 =&gt; {
    console.log('validated')
    console.log(value)
    done()
  })
  .catch(err =&gt; {
    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]) =&gt; {
  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]) =&gt; {
  return Promise.all([
    repo.makeBooking(user, booking),
    repo.generateTicket(paid, booking)
  ])
})
.then(([booking, ticket]) =&gt; {
  // 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) =&gt; {
  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) =&gt; {
  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) =&gt; {
  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, () =&gt; resolve(server))
} else {
  const server = spdy.createServer(ssl, app)
    .listen(port, () =&gt; resolve(server))
}

}) }

module.exports = Object.assign({}, {start})

基本上,我们只是将 container 对象附加到了 expressjs 的 req 对象上,这样 expressjs 的所有路由上都能访问到它了。如果你想更深入地了解 expressjs 的中间件是如何工作的,你可以点击这个链接查看 expressjs 的文档

点击阅读全文


5 回复

不错,收藏起来慢慢看~


为大佬打尻,mark

非常不错。
我也有想写一些 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 有一些现在技术框架),期待分享。

厉害,上 pc 慢慢看

在Part 3中,我们将专注于将我们的Node.js影院微服务容器化并部署到Docker上。以下是一个简要的步骤指南和代码示例,帮助你完成这一过程。

步骤1:创建Dockerfile

首先,在你的Node.js项目根目录下创建一个名为Dockerfile的文件,并添加以下内容:

# 使用官方的Node.js运行时作为父镜像
FROM node:14

# 设置工作目录
WORKDIR /usr/src/app

# 复制package*.json到工作目录
COPY package*.json ./

# 安装项目依赖
RUN npm install

# 复制项目文件到工作目录
COPY . .

# 暴露服务端口
EXPOSE 3000

# 启动应用
CMD [ "node", "app.js" ]  # 假设你的主应用文件是app.js

步骤2:构建Docker镜像

打开终端,导航到你的项目目录,然后运行以下命令来构建Docker镜像:

docker build -t cinema-microservice .

步骤3:运行Docker容器

构建完成后,你可以使用以下命令运行Docker容器:

docker run -d -p 3000:3000 cinema-microservice

这个命令会在后台运行容器,并将主机的3000端口映射到容器的3000端口。

结论

现在,你的Node.js影院微服务已经成功容器化并运行在Docker上了。你可以通过访问http://localhost:3000来测试你的服务。在后续部分中,我们可能会讨论如何扩展这个微服务架构,包括添加更多的服务和配置服务发现等。

回到顶部