Cómo construir un microservicio de cine NodeJS e implementarlo con Docker

prefacio

Cómo crear un microservicio de teatro NodeJS e implementarlo con Docker. En esta serie, se construirá e implementará un microservicio NodeJS utilizando un clúster de Docker Swarm.

Estas son las herramientas que se utilizarán:

  • NodeJS versión 7.2.0
  • Mongo DB 3.4.1
  • Docker para Mac 1.12.6

Antes de intentar esta guía, debe tener:

  • Conocimientos básicos de NodeJS
  • Conocimientos básicos de Docker (y tener Docker instalado)
  • Conocimiento básico de MongoDB (y el servicio de base de datos se está ejecutando)

¿Qué son los microservicios?

Un microservicio es una única unidad autónoma que se une a muchas otras unidades para formar una aplicación más grande. Al dividir una aplicación en unidades pequeñas, cada parte se puede implementar y escalar de forma independiente, puede ser escrita por diferentes equipos y diferentes lenguajes de programación, y se puede probar de forma independiente.

Una arquitectura de microservicios significa que una aplicación consta de muchas aplicaciones independientes más pequeñas que pueden ejecutarse en su propio espacio de memoria y escalar de forma independiente en potencialmente múltiples computadoras independientes.

Beneficios de los microservicios:

  • Las aplicaciones se inician más rápido, lo que hace que los desarrolladores sean más productivos y acelera la implementación.
  • Cada servicio se puede implementar independientemente de otros servicios, lo que facilita la implementación frecuente de nuevas versiones de los servicios.
  • Desarrollo más fácil de escalar y también puede tener beneficios de rendimiento.
  • Elimine los compromisos a largo plazo con pilas de tecnología. Al desarrollar un nuevo servicio, se puede elegir una nueva pila de tecnología.
  • Los microservicios suelen estar mejor organizados porque cada microservicio tiene un trabajo muy específico que no implica el trabajo de otros componentes.
  • Los servicios desacoplados también son más fáciles de recombinar y reconfigurar para servir a diferentes propósitos de aplicaciones (por ejemplo, servir tanto a clientes web como a API públicas).

Desventajas de los microservicios:

  • Los desarrolladores deben lidiar con la complejidad adicional de crear sistemas distribuidos.
  • Complejidad del despliegue. En un entorno de producción, implementar y administrar sistemas con muchos tipos de servicios diferentes también presenta complejidad operativa.
  • Al crear una nueva arquitectura de microservicios, es posible que descubra muchas preocupaciones transversales que no se anticiparon en el momento del diseño.

Creación de un microservicio de catálogo de películas

Digamos que estamos trabajando en el departamento de TI de una sala de cine y se nos ha encomendado la tarea de refactorizar su venta de entradas de cine y tienda de comestibles de sistemas monolíticos a microservicios.

Por lo tanto, en la serie "Creación de un microservicio de catálogo de películas de NodeJS", solo nos centraremos en el servicio de catálogo de películas.

在这个架构中,可以看到有 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 项目的结构将如下所示:

  • api/ # 我们的API
  • config/ # 应用程序配置
  • mock/ # 不是必需的,仅用于数据示例
  • repository/ # 抽象出数据库
  • server/ # 服务器设置代码
  • package.json # 依赖项
  • index.js # 应用程序的主入口

首先要看的部分是 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 数据库

以下是需要从 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 install # 设置Node依赖项
  • npm test # 使用mocha进行单元测试
  • npm start # 启动服务
  • npm run node-debug # 以调试模式运行服务器
  • npm run chrome-debug # 使用chrome调试Node
  • npm run lint # 使用standard进行代码lint

最后,第一个微服务已经在本地运行,并通过执行 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 容器可以简化微服务的部署和管理,使其更加灵活和可扩展。

Supongo que te gusta

Origin juejin.im/post/7261862616095211578
Recomendado
Clasificación