赞
踩
如何构建一个 NodeJS 影院微服务并使用 Docker 部署。在这个系列中,将构建一个 NodeJS 微服务,并使用 Docker Swarm 集群进行部署。
以下是将要使用的工具:
在尝试本指南之前,应该具备:
微服务是一个单独的自包含单元,与其他许多单元一起构成一个大型应用程序。通过将应用程序拆分为小单元,每个部分都可以独立部署和扩展,可以由不同的团队和不同的编程语言编写,并且可以单独进行测试。
微服务架构意味着应用程序由许多较小的、独立的应用程序组成,这些应用程序能够在自己的内存空间中运行,并且可以在可能的多个独立计算机上独立扩展。
微服务的好处:
微服务的缺点:
假设正在一家电影院的 IT 部门工作,给了我们一个任务,将他们的电影票务和杂货店从单体系统重构为微服务。
因此,在“构建 NodeJS 电影目录微服务”系列中,将仅关注电影目录服务。
在这个架构中,可以看到有 3 种不同的设备使用该微服务,即 POS(销售点)、移动设备/平板电脑和计算机。POS 和移动设备/平板电脑都有自己的应用程序(在 electron 中开发),并直接使用微服务,而计算机则通过 Web 应用程序访问微服务(一些专家也将 Web 应用程序视为微服务)。
现在,来模拟在最喜欢的电影院预订一场电影首映的过程。
首先,想看看电影院目前正在上映哪些电影。以下图表显示了通过 REST 进行的内部通信,通过此 REST 通信,可以使用 API 来获取目前正在上映的电影。
电影服务的 API 将具有以下 RAML 规范:
#%RAML 1.0 title: cinema version: v1 baseUri: / types: Movie: properties: id: string title: string runtime: number format: string plot: string releaseYear: number releaseMonth: number releaseDay: number example: id: "123" title: "Assasins Creed" runtime: 115 format: "IMAX" plot: "Lorem ipsum dolor sit amet" releaseYear : 2017 releaseMonth: 1 releaseDay: 6 MoviePremieres: type: Movie [] resourceTypes: Collection: get: responses: 200: body: application/json: type: <<item>> /movies: /premieres: type: { Collection: {item : MoviePremieres } } /{id}: type: { Collection: {item : Movie } }
如果不了解 RAML,可以查看这个很好的教程。
API 项目的结构将如下所示:
首先要看的部分是 repository。这是对数据库进行查询的地方。
'use strict' // factory function, that holds an open connection to the db, // and exposes some functions for accessing the data. const repository = (db) => { // since this is the movies-service, we already know // that we are going to query the `movies` collection // in all of our functions. const collection = db.collection('movies') const getMoviePremiers = () => { return new Promise((resolve, reject) => { const movies = [] const currentDay = new Date() const query = { releaseYear: { $gt: currentDay.getFullYear() - 1, $lte: currentDay.getFullYear() }, releaseMonth: { $gte: currentDay.getMonth() + 1, $lte: currentDay.getMonth() + 2 }, releaseDay: { $lte: currentDay.getDate() } } const cursor = collection.find(query) const addMovie = (movie) => { movies.push(movie) } const sendMovies = (err) => { if (err) { reject(new Error('An error occured fetching all movies, err:' + err)) } resolve(movies) } cursor.forEach(addMovie, sendMovies) }) } const getMovieById = (id) => { return new Promise((resolve, reject) => { const projection = { _id: 0, id: 1, title: 1, format: 1 } const sendMovie = (err, movie) => { if (err) { reject(new Error(`An error occured fetching a movie with id: ${id}, err: ${err}`)) } resolve(movie) } // fetch a movie by id -- mongodb syntax collection.findOne({id: id}, projection, sendMovie) }) } // this will close the database connection const disconnect = () => { db.close() } return Object.create({ getAllMovies, getMoviePremiers, getMovieById, disconnect }) } const connect = (connection) => { return new Promise((resolve, reject) => { if (!connection) { reject(new Error('connection db not supplied!')) } resolve(repository(connection)) }) } // this only exports a connected repo module.exports = Object.assign({}, {connect})
可能已经注意到,向 repository 的 connect ( connection ) 方法提供了一个 connection
对象。在这里,使用了 JavaScript 的一个重要特性“闭包”,repository 对象返回了一个闭包,其中的每个函数都可以访问 db
对象和 collection
对象,db
对象保存着数据库连接。在这里,抽象了连接的数据库类型,repository 对象不知道数据库是什么类型的,对于这种情况来说,是一个 MongoDB 连接。甚至不需要知道是单个数据库还是复制集连接。虽然使用了 MongoDB 语法,但可以通过应用 SOLID 原则中的依赖反转原则,将存储库功能抽象得更深,将 MongoDB 语法转移到另一个文件中,并只调用数据库操作的接口(例如,使用 mongoose 模型)。
有一个 repository/repository.spec.js
文件来测试这个模块,稍后在文章中会谈到测试。
接下来要看的是 server.js
文件。
'use strict' const express = require('express') const morgan = require('morgan') const helmet = require('helmet') const movieAPI = require('../api/movies') const start = (options) => { return new Promise((resolve, reject) => { // we need to verify if we have a repository added and a server port if (!options.repo) { reject(new Error('The server must be started with a connected repository')) } if (!options.port) { reject(new Error('The server must be started with an available port')) } // let's init a express app, and add some middlewares const app = express() app.use(morgan('dev')) app.use(helmet()) app.use((err, req, res, next) => { reject(new Error('Something went wrong!, err:' + err)) res.status(500).send('Something went wrong!') }) // we add our API's to the express app movieAPI(app, options) // finally we start the server, and return the newly created server const server = app.listen(options.port, () => resolve(server)) }) } module.exports = Object.assign({}, {start})
在这里,创建了一个新的 express 应用程序,验证是否提供了 repository 和 server port 对象,然后为 express 应用程序应用一些中间件,例如用于日志记录的 morgan
,用于安全性的 helmet
,以及一个错误处理函数,最后导出一个 start 函数来启动服务器。
Helmet 包含了整整 11 个软件包,它们都用于阻止恶意方破坏或使用应用程序来伤害其用户。
好的,现在既然服务器使用了电影的 API,继续查看 movies.js 文件。
'use strict' const status = require('http-status') module.exports = (app, options) => { const {repo} = options // here we get all the movies app.get('/movies', (req, res, next) => { repo.getAllMovies().then(movies => { res.status(status.OK).json(movies) }).catch(next) }) // here we retrieve only the premieres app.get('/movies/premieres', (req, res, next) => { repo.getMoviePremiers().then(movies => { res.status(status.OK).json(movies) }).catch(next) }) // here we get a movie by id app.get('/movies/:id', (req, res, next) => { repo.getMovieById(req.params.id).then(movie => { res.status(status.OK).json(movie) }).catch(next) }) }
在这里,为API创建了路由,并根据监听的路由调用了 repo 函数。repo 在这里使用了接口技术方法,在这里使用了著名的“为接口编码而不是为实现编码”,因为 express 路由不知道是否有一个数据库对象、数据库查询逻辑等,它只调用处理所有数据库问题的 repo 函数。
所有文件都有与源代码相邻的单元测试,看看 movies.js
的测试是如何进行的。
可以将测试看作是对正在构建的应用程序的安全保障。不仅会在本地机器上运行,还会在 CI 服务上运行,以确保失败的构建不会被推送到生产系统。
为了编写单元测试,必须对所有依赖项进行存根,即为模块提供虚拟依赖项。看看 spec
文件。
/* eslint-env mocha */ const request = require('supertest') const server = require('../server/server') describe('Movies API', () => { let app = null let testMovies = [{ 'id': '3', 'title': 'xXx: Reactivado', 'format': 'IMAX', 'releaseYear': 2017, 'releaseMonth': 1, 'releaseDay': 20 }, { 'id': '4', 'title': 'Resident Evil: Capitulo Final', 'format': 'IMAX', 'releaseYear': 2017, 'releaseMonth': 1, 'releaseDay': 27 }, { 'id': '1', 'title': 'Assasins Creed', 'format': 'IMAX', 'releaseYear': 2017, 'releaseMonth': 1, 'releaseDay': 6 }] let testRepo = { getAllMovies () { return Promise.resolve(testMovies) }, getMoviePremiers () { return Promise.resolve(testMovies.filter(movie => movie.releaseYear === 2017)) }, getMovieById (id) { return Promise.resolve(testMovies.find(movie => movie.id === id)) } } beforeEach(() => { return server.start({ port: 3000, repo: testRepo }).then(serv => { app = serv }) }) afterEach(() => { app.close() app = null }) it('can return all movies', (done) => { request(app) .get('/movies') .expect((res) => { res.body.should.containEql({ 'id': '1', 'title': 'Assasins Creed', 'format': 'IMAX', 'releaseYear': 2017, 'releaseMonth': 1, 'releaseDay': 6 }) }) .expect(200, done) }) it('can get movie premiers', (done) => { request(app) .get('/movies/premiers') .expect((res) => { res.body.should.containEql({ 'id': '1', 'title': 'Assasins Creed', 'format': 'IMAX', 'releaseYear': 2017, 'releaseMonth': 1, 'releaseDay': 6 }) }) .expect(200, done) }) it('returns 200 for an known movie', (done) => { request(app) .get('/movies/1') .expect((res) => { res.body.should.containEql({ 'id': '1', 'title': 'Assasins Creed', 'format': 'IMAX', 'releaseYear': 2017, 'releaseMonth': 1, 'releaseDay': 6 }) }) .expect(200, done) }) })
/* eslint-env mocha */ const server = require('./server') describe('Server', () => { it('should require a port to start', () => { return server.start({ repo: {} }).should.be.rejectedWith(/port/) }) it('should require a repository to start', () => { return server.start({ port: {} }).should.be.rejectedWith(/repository/) }) })
可以看到,为 movies API 存根了依赖项,并验证了需要提供一个 server port 和一个 repository 对象。
继续看一下如何创建传递给 repository 模块的 db 连接对象,现在定义说每个微服务都必须有自己的数据库,但是对于示例,将使用一个 MongoDB 复制集服务器,但每个微服务都有自己的数据库。
以下是需要从 NodeJS 连接到 MongoDB 数据库的配置。
const MongoClient = require('mongodb') // here we create the url connection string that the driver needs const getMongoURL = (options) => { const url = options.servers .reduce((prev, cur) => prev + `${cur.ip}:${cur.port},`, 'mongodb://') return `${url.substr(0, url.length - 1)}/${options.db}` } // mongoDB function to connect, open and authenticate const connect = (options, mediator) => { mediator.once('boot.ready', () => { MongoClient.connect( getMongoURL(options), { db: options.dbParameters(), server: options.serverParameters(), replset: options.replsetParameters(options.repl) }, (err, db) => { if (err) { mediator.emit('db.error', err) } db.admin().authenticate(options.user, options.pass, (err, result) => { if (err) { mediator.emit('db.error', err) } mediator.emit('db.ready', db) }) }) }) } module.exports = Object.assign({}, {connect})
这里可能有更好的方法,但基本上可以这样创建与 MongoDB 的复制集连接。
传递了一个 options 对象,其中包含 Mongo 连接所需的所有参数,并且传递了一个事件 — 中介者对象,当通过认证过程时,它将发出 db 对象。
注意 在这里,使用了一个事件发射器对象,因为使用 promise 的方法在某种程度上并没有在通过认证后返回 db 对象,顺序变得空闲。所以这可能是一个很好的挑战,看看发生了什么,并尝试使用 promise 的方法。
现在,既然正在传递一个 options 对象来进行参数设置,让我们看看这是从哪里来的,因此要查看的下一个文件是 config.js。
// simple configuration file // database parameters const dbSettings = { db: process.env.DB || 'movies', user: process.env.DB_USER || 'cristian', pass: process.env.DB_PASS || 'cristianPassword2017', repl: process.env.DB_REPLS || 'rs1', servers: (process.env.DB_SERVERS) ? process.env.DB_SERVERS.split(' ') : [ '192.168.99.100:27017', '192.168.99.101:27017', '192.168.99.102:27017' ], dbParameters: () => ({ w: 'majority', wtimeout: 10000, j: true, readPreference: 'ReadPreference.SECONDARY_PREFERRED', native_parser: false }), serverParameters: () => ({ autoReconnect: true, poolSize: 10, socketoptions: { keepAlive: 300, connectTimeoutMS: 30000, socketTimeoutMS: 30000 } }), replsetParameters: (replset = 'rs1') => ({ replicaSet: replset, ha: true, haInterval: 10000, poolSize: 10, socketoptions: { keepAlive: 300, connectTimeoutMS: 30000, socketTimeoutMS: 30000 } }) } // server parameters const serverSettings = { port: process.env.PORT || 3000 } module.exports = Object.assign({}, { dbSettings, serverSettings })
这是配置文件,大部分配置代码都是硬编码的,但正如看到的,一些属性使用环境变量作为选项。环境变量被视为最佳实践,因为这可以隐藏数据库凭据、服务器参数等。
最后,对于构建电影服务 API 的最后一步是使用 index.js 将所有内容组合在一起。
'use strict' // we load all the depencies we need const {EventEmitter} = require('events') const server = require('./server/server') const repository = require('./repository/repository') const config = require('./config/') const mediator = new EventEmitter() // verbose logging when we are starting the server console.log('--- Movies Service ---') console.log('Connecting to movies repository...') // log unhandled execpetions process.on('uncaughtException', (err) => { console.error('Unhandled Exception', err) }) process.on('uncaughtRejection', (err, promise) => { console.error('Unhandled Rejection', err) }) // event listener when the repository has been connected mediator.on('db.ready', (db) => { let rep repository.connect(db) .then(repo => { console.log('Repository Connected. Starting Server') rep = repo return server.start({ port: config.serverSettings.port, repo }) }) .then(app => { console.log(`Server started succesfully, running on port: ${config.serverSettings.port}.`) app.on('close', () => { rep.disconnect() }) }) }) mediator.on('db.error', (err) => { console.error(err) }) // we load the connection to the repository config.db.connect(config.dbSettings, mediator) // init the repository connection, and the event listener will handle the rest mediator.emit('boot.ready')
在这里,组合了所有的电影 API 服务,添加了一些错误处理,然后加载配置、启动存储库,并最后启动服务器。
因此,到目前为止,已经完成了与 API 开发相关的所有内容。
下面是项目中需要用到的初始化以及运行命令:
最后,第一个微服务已经在本地运行,并通过执行 npm start 命令启动。
现在是时候将其放入 Docker 容器中。
首先,需要使用“使用 Docker 部署 MongoDB 复制集”的文章中的 Docker 环境,如果没有,则需要进行一些额外的修改步骤,以便为微服务设置数据库,以下是一些命令,进行测试电影服务。
首先创建 Dockerfile,将 NodeJS 微服务制作成 Docker 容器。
# Node v7作为基本映像以支持ES6 FROM node:7.2.0 # 为新容器创建一个新用户,并避免root用户 RUN useradd --user-group --create-home --shell /bin/false nupp && \ apt-get clean ENV HOME=/home/nupp COPY package.json npm-shrinkwrap.json $HOME/app/ COPY src/ $HOME/app/src RUN chown -R nupp:nupp $HOME/* /usr/local/ WORKDIR $HOME/app RUN npm cache clean && \ npm install --silent --progress=false --production RUN chown -R nupp:nupp $HOME/* USER nupp EXPOSE 3000 CMD ["npm", "start"]
使用 NodeJS 镜像作为 Docker 镜像的基础,然后为镜像创建一个用户以避免非 root 用户,接下来,将 src 复制到镜像中,然后安装依赖项,暴露一个端口号,并最后实例化电影服务。
接下来,需要构建 Docker 镜像,使用以下命令:
$ docker build -t movies-service .
首先看一下构建命令。
docker build
告诉引擎要创建一个新的镜像。-t movies-service
用标记 movies-service
标记此镜像。从现在开始,可以根据标记引用此镜像。Dockerfile
。经过一些控制台输出后,新镜像中就有了 NodeJS 应用程序,所以现在需要做的就是使用以下命令运行镜像:
$ docker run --name movie-service -p 3000:3000 -e DB_SERVERS="192.168.99.100:27017 192.168.99.101:27017 192.168.99.100:27017" -d movies-service
在上面的命令中,传递了一个环境变量,它是一个服务器数组,需要连接到 MongoDB 复制集的服务器,这只是为了说明,有更好的方法可以做到这一点,比如读取一个环境变量文件。
现在,容器已经运行起来了,获取 docker-machine
IP地址,以获取微服务的 IP 地址,现在准备对微服务进行一次集成测试,另一个测试选项可以是JMeter,它是一个很好的工具,可以模拟HTTP请求。
这是集成测试,将检查一个 API 调用。
/* eslint-env mocha */
const supertest = require('supertest')
describe('movies-service', () => {
const api = supertest('http://192.168.99.100:3000')
it('returns a 200 for a collection of movies', (done) => {
api.get('/movies/premiers')
.expect(200, done)
})
})
创建了用于查询电影院正在上映的电影的 movies 服务,使用 RAML 规范设计了 API,然后开始构建 API,并进行了相应的单元测试,最后,组合了所有内容,使微服务完整,并能够启动 movies 服务服务器。
然后,将微服务放入 Docker 容器中,以进行一些集成测试。
微服务架构可以为大型应用程序带来许多好处,但也需要小心管理和设计,以处理分布式系统的复杂性和其他挑战。使用 Docker 容器可以简化微服务的部署和管理,使其更加灵活和可扩展。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。