V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
darluc
V2EX  ›  Node.js

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

  •  2
     
  •   darluc · 2017-05-24 22:07:23 +08:00 · 8399 次点击
    这是一个创建于 2774 天前的主题,其中的信息可能已经有所发展或是发生改变。

    查看全文

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

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

    本文将使用到以下技术:

    • NodeJS 7.2.0 版本
    • MongoDB 3.4.1
    • Docker for Mac 1.12.6

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

    • NodeJS 的基础知识
    • Docker 的基础知识(并且已安装了 docker )
    • MongoDB 的基础知识(并且有可运行的数据库服务)

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

    # 什么是微服务

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

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

    微服务的优点

    • 应用启动更快,开发效率更高,部署也更加快速。
    • 每个服务可以独立于其它服务进行部署——这样更容易部署新版本
    • 更容易组织大规模开发,而且有性能优势。
    • 可以避免与技术栈长期捆绑。当开发新服务时,你可以选用新的技术栈。
    • 微服务一般都有更好的组织结构,因为每个微服务都有特定的使命,且与其它服务无关。
    • 无耦合的服务更容易重新组合配置,可以服务于不同的应用系统(比如,同时服务于 web 客户端以及公共 API )。

    微服务的缺点

    • 开发者必须处理分布式系统带来的额外复杂性。
    • 部署更加复杂。在生产环境中,部署并管理一个由许多不同服务组成的系统,操作起来更复杂。
    • 在你构建微服务架构的过程中,可能会发现许多在设计初期未能预料到的功能点被切割分散到了各处。

    # 如何用 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 仓库中找到所有的测试文件。

    查看全文

    4 条回复    2017-05-25 13:19:42 +08:00
    notes
        1
    notes  
       2017-05-24 22:27:10 +08:00 via Android
    好文
    wwulfric
        2
    wwulfric  
       2017-05-25 10:54:44 +08:00
    nodejs 微服务怎样做到和 dubbo ( zookeeper )类似的服务发现
    darluc
        3
    darluc  
    OP
       2017-05-25 12:52:19 +08:00
    @wwulfric 待我找找有没有这方面的文章
    jhsea3do
        4
    jhsea3do  
       2017-05-25 13:19:42 +08:00   ❤️ 1
    service discovery, 不想用 java 系的话

    有 etcd 和 consul 可以选,etcd 更主流一些
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   937 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 25ms · UTC 21:09 · PVG 05:09 · LAX 13:09 · JFK 16:09
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.