本文是「使用 NodeJS 构建影院微服务」系列的第一篇。此系列将会讲述了如何构建NodeJS 微服务并将它们部署到Docker Swarm 集群上。
本文将向你展示,如何构建微服务,以及如何将其部署到Docker容器中。我们会完成一个简单的 NodeJS 服务,并以 MongoDB 作为后端存储。
本文将使用到以下技术:
为了理解文中内容,你最好了解以下知识:
我强烈建议大家参考我以前的文章「如何在 Docker 上部署 mongoDB 集群」,部署好数据库服务并将其运行起来。
微服务是一个独立可用的单元,它可以与其它微服务一起,构成一个大的应用系统。通过将一个应用划分为更小的单元,可使得这些小的单元更加独立、易于部署、且易于扩展,而且这些小单元可以由不同团队的开发,使用不同语言进行开发,并且单独进行测试。—— Max Stoiber
微服务架构意味着你的系统由许多小的、独立的应用组成。它们运行在自己的内存空间中,而且独立部署,能够扩展部署到多台机器上。—— Eric Elliot
微服务可以利用许多简单,目的单一,易于使用的组件构建应用,使得软件质量更好,迭代速度更快。甚至已有的整体架构系统也可以采用微服务模式进行转换,不过我们的问题是如何用 NodeJS 来构建一个微服务?
Javascript 是当前最流行编程语言,拥有丰富的开源模块生态系统。 对于一个微服务来说,我们想要构建一套 API,我应该用哪些模块,库,或者框架呢?我在 Quora 上搜索到了一个类似的问题:构建 RESTful api 应该使用哪个 Node.js 框架?
问题的答案中有一位用户给出了一条非常有用的答案,他提供了一个 NB 的网站,里面展示了我们可以用来构建 API 的所有框架和库,这样你就可以自己做选择了。本文中我们将使用 ExpressJS 来构建我们的 API 和微服务。
现在我们不再空谈,开始动手编码,学习如何解决这个实际问题吧 👨🏻💻👨🏼🎨🖥。
假设我们在 Cinépolis(一个墨西哥电影院)的 IT 部门工作。他们派给我们一个任务,让我们重构票务和零售店系统,将原有的一体化系统改为微服务架构。
作为「使用 NodeJS 构建影院微服务」系列的第一部分,我们将关注点放在**电影目录服务(movies catalog service)**上。
在架构图中,我们可以看到有三种不同的设备使用到微服务。POS (售卖点),手机 /平板,以及电脑。POS 和手机 /平板有单独的应用(用 electron 开发),并且直接访问微服务。而电脑端则通过网页应用访问微服务。
现在假设我们想在自己喜欢的电影院中去看某电影的首映。
首先,我们需要查看此影院中当前有哪些电影上映。下面这种图展示了微服务中是如何使用 REST 方式进行信息交流的。
我们的电影服务( movies service ) API 规格定义如下:
#%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 项目的目录结构如下:
- api/ # our apis
- config/ # config for the app
- mock/ # not necessary just for data examples
- repository/ # abstraction over our db
- server/ # server setup code
- package.json # dependencies
- index.js # main entrypoint of the app
让我们开始编码。首先看一下这个 repository
。这是我们进行数据库查询的地方。
// repository.js
'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})
// movie-service-repo.js
你可能注意到了,我们向唯一暴露的 connect(connection) 方法传入了一个 connection
对象,这儿你可以看到 javascript 最强大之处的**“闭包”**,这个仓库对象返回了一个闭包,其中所有的方法都能访问到 db
和 collection
对象, db
对象保持着数据库连接。这里我们对所连接数据库的类型进行了抽象,repository 对象并不知道使用的是哪一种数据库,本文中我们使用的是 MongoDB,它也不知道连接的数据库是单例还是集群,不过只要我们使用 mongodb 的语法,我们就能使用 repository 中的方法,我们还可以使用 solid principles 中的依赖反转方式,将 mongo 语法拆分到另一个文件中,而只调用数据库操作接口(比如使用 mongoose 模型)。
我们还有一个 repository/repository.spec.js
文件用于测试这个模块,以后我将会讲到测试的部分,你可以在 github 仓库的 step-1 分支中找到它。
接下来我们看一下 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})
// movie-service-server.js
这里我们实例化了一个新的 express 应用,验证我们是否提供了仓库对象以及和服务端口参数,然后使用了一些中间件,比如用于日志的 morgan
,用于安全的 helmet
,以及一个 错误处理
函数,最后对外提供了一个 start 方法,用于启动服务😎。
Helmet 包括了多达 11 个模块全部是用于阻止针对用户的恶意攻击。
如果你想加强你的微服务的安全性,你可以看一下这篇文章。
既然我们的 server 要使用到 movieAPI,那就让我们继续看一下 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)
})
}
// movies-service-movies.js
这里我们为 API 定义了路由,并根据路由调用仓库对象的不同方法。你可以注意到,这里是直接调用仓库对象的接口的,我们在实践着著名的「面向接口编程,而不是面向实现编程」箴言( coding for an interface not to an implementation ),因为 express 路由并不知道数据库对象的存在,也不知道数据库查询的逻辑等等,它只是调用了仓库对象的方法用于处理所有的数据库相关业务。
我们所有的代码都有对应的单元测试,让我们看一下 movies.js
的测试代码。
你可以将测试代码当作你所构建应用的保护措施。它们不仅在你的本地机器上执行,还会在持续集成服务中执行,以保证失败的构建不会被推送到生成环境中。—— Trace by RisingStack
为了写单元测试,所有的依赖项都必须进行伪造,也就是说我们为要测试的模块提供伪造的依赖项。现在看下我们的 标准测试文件
长什么样子。
/* 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)
})
})
//movie-service-movie.spec.js
/* 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/)
})
})
// movie-service-server.spec.js
如你所见,我们伪造了 movies API
的依赖项,我们验证了 server 对象需要服务端口和仓库对象。
你可在文本的 github 仓库中找到所有的测试文件。
1
notes 2017-05-24 22:27:10 +08:00 via Android
好文
|
2
wwulfric 2017-05-25 10:54:44 +08:00
nodejs 微服务怎样做到和 dubbo ( zookeeper )类似的服务发现
|
4
jhsea3do 2017-05-25 13:19:42 +08:00 1
service discovery, 不想用 java 系的话
有 etcd 和 consul 可以选,etcd 更主流一些 |