如何进行高质量Node.js微服务的编写和部署
微服务架构是一种构造应用程序的替代性方法。应用程序被分解为更小、完全独立的组件,这使得它们拥有更高的敏捷性、可伸缩性和可用性。一个复省檑挖毳杂的应用被拆分为若干微服务,微服务更需要一种成熟的交付能力。持续集成、部署和全自动测试都必不可少。编写代码的开发人员必须负责代码的生产部署。构建和部署链需要重大更改,以便为微服务环境提供正确的关注点分离。
Node.js是构建微服务的利器,为什么这么说呢,我们先看下Node.js有哪些优势:
● Node.js采用事件驱动、异步编程,为网络服务而设计
● Node.js非阻塞模式的IO处理给Node.js带来在相对低系统资源耗用下的高性能与出众的负载能力,非常适合用作依赖其它IO资源的中间层服务
● Node.js轻量高效,可以认为是数据密集型分布式部署环境下的实时应用系统的完美解决方案。
这些优势正好与微服务的优势:敏捷性、可伸缩性和可用性相契合(捂脸笑),再看下Node.js的缺点:
● 单进程,单线程,只支持单核CPU,不能充分的利用多核CPU服务器。一旦这个进程down了,那么整个web服务就down了
● 异步编程,callback回调地狱
第一个缺点可以通过启动多个实例来实现CPU充分利用以及负载均衡,话说这不是K8s的原生功能吗。
第二个缺点更不是事儿,现在可以通过generator、promise等来写同步代码,爽的不要不要的。
下面我们主要从Docker和Node.js出发聊一下高质量Node.js微服务的编写和部署:
● Node.js异步流程控制:generator与promise
● Express、Koa的异常处理
● 如何编写Dockerfile
● 微服务部署及DevOps集成
1 Node.js异步流程控制:Generator与Promise
Node.js的设计初衷为了性能而异步,现在已经可以写同步的代码了,你造吗?目前Node.js的LTS版本早就支持了Generator,Promise这两个特性,也有许多优秀的第三方库bluebird、q这样的模块支持的也非常好,性能甚至比原生的还好,可以用bluebird替换Node.js原生的Promise:
global.Promise=require('bluebird')
blurbird的性能是V8里内置的Promise 3倍左右(bluebird的优化方式见https://github.com/petkaantonov/bluebird/wiki/Optimization-killers)。
1.1 ES2015 Generator
Generatorsare functions which can be exited and later re-entered. Their context (variablebindings) will be saved across re-entrances. ---https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function*
generator就像一个取号机,你可以通过取一张票来向机器请求一个号码。你接收了你的号码,但是机器不会自动为你提供下一个。换句话说,取票机“暂停”直到有人请求另一个号码(next()),此时它才会向后运行。下面我们看一个简单的示例:
从上面的代码的输出可以看出:
1.generator函数的定义,是通过function *(){}实现的
2.对generator函数的调用返回的实际是一个遍历器,随后代码通过使用遍历器的next()方法来获得函数的输出
3.通过使用yield语句来中断generator函数的运行,并且可以返回一个中间结果
4.每次调用next()方法,generator函数将执行到下一个yield语句或者是return语句。
下面我们就对上面代码的每次next调用进行一个详细的解释:
第1次调用next()方法的时候,函数执行到第一次循环的yield index++语句停了下来,并且返回了0这个value,随同value返回的done属性表明generator函数的运行还没有结束
第2次调用next()方法的时候,函数执行到第二循环的yield index++语句停了下来,并且返回了1这个value,随同value返回的done属性表明generator函数的运行还没有结束
... ...
第4次调用next()方法的时候,由于循环已经结束了,所以函数调用立即返回,done属性表明generator函数已经结束运行,value是undefined的,因为这次调用并没有执行任何语句
PS:如果在generator函数内部需要调用另外一个generator函数,那么对目标函数的调用就需要使用yield*。
1.2 ES2015 Promise
The Promiseobject is used for asynchronous computations. A Promise represents an operationthat hasn't completed yet, but is expected in the future. ---https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise
所谓Promise,就是一个对象,用来传递异步操作的消息。它代表了某个未来才会知道结果的事件(通常是一个异步操作),并且这个事件提供统一的API,可供进一步处理。
一个Promise一般有3种状态:
1.pending:初始状态,不是fulfilled,也不是rejected.
2.fulfilled:操作成功完成.
3.rejected:操作失败.
一个Promise的生命周期如下图:
下面我们看一段具体代码:
asyncFunction这个函数会返回Promise对象,对于这个Promise对象,我们调用它的then方法来设置resolve后的回调函数,catch方法来设置发生错误时的回调函数。
该Promise对象会在setTimeout之后的16ms时被resolve,这时then的回调函数会被调用,并输出'AsyncHello world'。
在这种情况下catch的回调函数并不会被执行(因为Promise返回了resolve),不过如果运行环境没有提供setTimeout函数的话,那么上面代码在执行中就会产生异常,在catch中设置的回调函数就会被执行。
小结
如果是编写一个SDK或API,推荐使用传统的callback或者Promise,不使用generator的原因是:
●generator的出现不是为了解决异步问题
●使用generator是会传染的,当你尝试yield一下的时候,它要求你也必须在一个generator function内
看来学习Promise是水到渠成的事情。
2 Express、Koa的异常处理
一个友好的错误处理机制应该满足三个条件:
1.对于引发异常的用户,返回500页面
2.其他用户不受影响,可以正常访问
3.不影响整个进程的正常运行
下面我们就以这三个条件为原则,具体介绍下Express、Koa中的异常处理。
2.1 Express异常处理
在Express中有一个内置的错误处理中间件,这个中间件会处理任何遇到的错误。如果你在Express中传递了一个错误给next(),而没有自己定义的错误处理函数处理这个错误,这个错误就会被Express默认的错误处理函数捕获并处理,而且会把错误的堆栈信息返回到客户端,这样的错误处理是非常不友好的,还好我没可以通过设置NODE_ENV环境变量为production,这样Express就会在生产环境模式下运行应用,生产环境模式下Express不会把错误的堆栈信息返回到客户端。
在Express项目中可以定义一个错误处理的中间件用来替换Express默认的错误处理函数:
在所有其他app.use()以及路由之后引入以上代码,可以满足以上三个友好错误处理条件,是一种非常友好的错误处理机制。
2.2 Koa异常处理
我们以Koa 1.x为例,看代码:
把上面的代码放在所有app.use()函数前面,这样基本上所有的同步错误均会被try{} catch(err){}捕获到了,具体原理大家可以了解下Koa中间件的机制。
2.3未捕获的异常uncaughtException
上面的两种异常处理方法,只能捕获同步错误,而异步代码产生的错误才是致命的,uncaughtException错误会导致当前的所有用户连接都被中断,甚至不能返回一个正常的HTTP错误码,用户只能等到浏览器超时才能看到一个no data received错误。
这是一种非常野蛮粗暴的异常处理机制,任何线上服务都不应该因为uncaughtException导致服务器崩溃。在Node.js我们可以通过以下代码捕获uncaughtException错误:
捕获uncaughtException后,Node.js的进程就不会退出,但是当Node.js抛出uncaughtException异常时就会丢失当前环境的堆栈,导致Node.js不能正常进行内存回收。也就是说,每一次、uncaughtException都有可能导致内存泄露。既然如此,退而求其次,我们可以在满足前两个条件的情况下退出进程以便重启服务。当然还可以利用domain模块做更细致的异常处理,这里就不做介绍了。
3.如何编写Dockerfile
3.1基础镜像选择
我们先选用Node.js官方推荐的node:argon官方LTS版本最新镜像,镜像大小为656.9 MB(解压后大小,下文提到的镜像大小没有特殊说明的均指解压后的大小)
The firstthing we need to do is define from what image we want to build from. Here wewill use the latest LTS (long term support) versionargonofnodeavailablefrom the Docker Hub ---https://nodejs.org/en/docs/guides/nodejs-docker-webapp/
我们事先写好了两个文件package.json,app.js:
下面开始编写Dockerfile,由于直接从Dockerhub拉取镜像速度较慢,我们选用时速云的docker官方镜像docker_library/node,这些官方镜像都是与Dockerhub实时同步的:
执行以下命令进行构建:
docker build -t zhangpc/docker_web_app:argon .
最终得到的镜像大小是660.3 MB,体积略大,Docker容器的优势是轻量和可移植,所以承载它的操作系统即基础镜像也应该迎合这个特性,于是我想到了Alpine Linux,一个面向安全的,轻量的Linux发行版,基于musllibc和busybox。
下面我们使用alpine:edge作为基础镜像,镜像大小为4.799 MB:
,
执行以下命令进行构建:
docker build -t zhangpc/docker_web_app:alpine .
最终得到的镜像大小是31.51 MB,足足缩小了20倍,运行两个镜像均测试通过。
3.2还有优化的空间吗?
首先,大小上还是可以优化的,我们知道Dockerfile的每条指令都会将结果提交为新的镜像,下一条指令将会基于上一步指令的镜像的基础上构建,所以如果我们要想清除构建过程中产生的缓存,就得保证产生缓存的命令和清除缓存的命令在同一条Dockerfile指令中,因此修改Dockerfile如下:
执行以下命令进行构建:
docker build -t zhangpc/docker_web_app:alpine .
最终得到的镜像大小是21.47 MB,缩小了10M。
其次,我们发现在构建过程中有一些依赖是基本不变的,例如安装Node.js以及项目依赖,我们可以把这些不变的依赖集成在基础镜像中,这样可以大幅提升构建速度,基本上是秒级构建。当然也可以把这些基本不变的指令集中在Dockerfile的前面部分,并保持前面部分不变,这样就可以利用缓存提升构建速度。
最后,如果使用了Express框架,在构建生产环境镜像时可以设置NODE_ENV环境变量为production,可以大幅提升应用的性能,还有其他诸多好处,下面会有介绍。
小结
我们构建的三个镜像大小对比见上图,镜像的大小越小,发布的时候越快捷,而且可以提高安全性,因为更少的代码和程序在容器中意味着更小的攻击面。使用node:argon作为基础镜像构建出的镜像(tag为argon)压缩后的大小大概为254 MB,也不是很大,如果对Alpine Linux心存顾虑的童鞋可以选用Node.js官方推荐的node:argon作为基础镜像构建微服务。
4.微服务部署及devops集成
部署微服务时有一个原则:一个容器中只放一个服务,可以使用stack编排把各个微服务组合成一个完整的应用:
4.1 Dokcer环境微服务部署
安装好Docker环境后,直接运行我们构建好的容器即可:
dockerrun-d--restart=always -p 8080:8080 --name docker_web_app_alpine zhangpc/docker_web_app:alpine
4.2使用容器云平台集成DevOps
时速云目前支持github、gitlab、bitbucket、coding等代码仓库,并已实现完全由API接入授权、webhook等,只要你开发时使用的是这些代码仓库,都可以接入时速云的CI/CD服务:
下面我们简单介绍下接入流程:
1.创建项目,参考文档http://doc.tenxcloud.com/doc/v1/ci/project-add.html
2.开启CI
3.更改代码并提交,项目自动构建
4.用构建出来的镜像(tag为master)创建一个容器
5.开启CD,并绑定刚刚创建的容器
6.更改代码,测试DevOps
我们可以看到代码更改已经经过构建(CI)、部署(CD)体现在了容器上