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

2017-05-24 22:07:23 +08:00
 darluc

查看全文

本文是「使用 NodeJS 构建影院微服务」系列的第一篇。此系列将会讲述了如何构建NodeJS 微服务并将它们部署到Docker Swarm 集群上。

本文将向你展示,如何构建微服务,以及如何将其部署到Docker容器中。我们会完成一个简单的 NodeJS 服务,并以 MongoDB 作为后端存储。

本文将使用到以下技术:

为了理解文中内容,你最好了解以下知识:

我强烈建议大家参考我以前的文章「如何在 Docker 上部署 mongoDB 集群」,部署好数据库服务并将其运行起来。

# 什么是微服务

微服务是一个独立可用的单元,它可以与其它微服务一起,构成一个大的应用系统。通过将一个应用划分为更小的单元,可使得这些小的单元更加独立、易于部署、且易于扩展,而且这些小单元可以由不同团队的开发,使用不同语言进行开发,并且单独进行测试。—— Max Stoiber

微服务架构意味着你的系统由许多小的、独立的应用组成。它们运行在自己的内存空间中,而且独立部署,能够扩展部署到多台机器上。—— Eric Elliot

微服务的优点

微服务的缺点

# 如何用 NodeJS 构建微服务

微服务可以利用许多简单,目的单一,易于使用的组件构建应用,使得软件质量更好,迭代速度更快。甚至已有的整体架构系统也可以采用微服务模式进行转换,不过我们的问题是如何用 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 最强大之处的**“闭包”**,这个仓库对象返回了一个闭包,其中所有的方法都能访问到 dbcollection 对象, 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 仓库中找到所有的测试文件。

查看全文

8241 次点击
所在节点    Node.js
4 条回复
notes
2017-05-24 22:27:10 +08:00
好文
wwulfric
2017-05-25 10:54:44 +08:00
nodejs 微服务怎样做到和 dubbo ( zookeeper )类似的服务发现
darluc
2017-05-25 12:52:19 +08:00
@wwulfric 待我找找有没有这方面的文章
jhsea3do
2017-05-25 13:19:42 +08:00
service discovery, 不想用 java 系的话

有 etcd 和 consul 可以选,etcd 更主流一些

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

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

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

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

© 2021 V2EX