- 选择 Node.js 的理由: 在本节中,我们将证明选择 Node.js 来构建微服务的正确性。并且,我们还将介绍使用 Node.js 时涉及的软件栈。
- 微服务框架 Seneca: 在本节中,你将学到关于 Seneca 的基本知识,以及它能够使整个软件系统变得易于管理的原因。为了遵循业界标准,我们将教会读者如何整合 Seneca 与 Express(Node.js 平台下最流行的 Web 开发框架)。
- PM2: PM2 是运行 Node.js 应用的最好选择。无论你想如何部署应用系统,PM2 都能够提供很好的解决方案。
具有非阻塞特性,使得我们能够很容易地创建具有高可伸缩性的应用。而且,由于它是基于 JavaScript 这一风靡已久的语言,因此学习起来也非常容易。
我们将主要使用(但不仅限于)Seneca 和 PM2 作为构建、运行微服务的框架。虽然我们选择了 Seneca 和 PM2,但并不意味着其他框架不好。
业界还存在一些其他备选方案,例如 restify
或 Express
可用于构建应用,forever
或 nodemon
可用于运行应用。然而,我发现 Seneca
和 PM2
是构建微服务的最佳组合。主要原因如下:
- PM2 在应用部署方面有着异常强大的功能。
- Seneca 不仅仅是一个构建微服务的框架,它还是一个范例,能够重塑我们对于面向对象软件的认知。
- Node.js 安装
- 官网上下载到不同平台的安装包,安装包中包括了 Node.js 和 npm(同时也提供各版本的源码、二进制包下载),下载地址:https://nodejs.org/en/download/。
Nodejs 的安装很简单,不多做介绍。
- Seneca 安装
npm i seneca --save
save 标记有多种模式:
-
save:这种方式会将依赖写入 dependencies 部分,在整个开发周期中,依赖都是可用的。
-
save-dev:这种方式会将依赖写入 devDependencies 部分,它只在开发阶段可用,最终不会随着产品一起部署。
-
save-optional:这种方式会添加一个依赖(如同 save 一样),但是,如果找不到依赖,它会让 npm 继续运行,交由应用来处理依赖缺失问题。
-
PM2 安装
npm i pm2 –g
在 Node.js 中编写的程序都是单线程的。如果服务收到上万个并发请求,那么它们将进入等待队列,顺序地被 Node.js 的事件轮询机制处理。那么 Node.js 采用单线程处理请求有什么好处呢?
答案是:Node.js 采用的是异步处理机制。这表示在处理较慢的事件时,比如读取文件,Node.js 不会阻塞线程,而是继续处理其他事件,Node.js 的控制流在读取文件完毕时,会执行相应的方法来处理返回信息。
SOLID 设计原则
- 单一职责原则
- 开放封闭原则(对扩展开放,对修改关闭)
- 里氏替换原则
- 接口分离原则
- 依赖倒置原则(反转控制和依赖注入)
Seneca 是一个用于构建微服务的框架,Seneca 相当简单,它使用完备的模式匹配接口来连接各个服务,从代码中将数据传输抽象出来,使编写具有高可扩展性的软件变得相当容易。
var seneca = require('seneca')();
seneca.add({ role: 'math', cmd: 'sum' }, function(msg, respond) {
var sum = msg.left + msg.right;
respond(null, { answer: sum });
});
seneca.add({ role: 'math', cmd: 'product' }, function(msg, respond) {
var product = msg.left * msg.right;
respond(null, { answer: product });
});
seneca.act({ role: 'math', cmd: 'sum', left: 1, right: 2 }, console.log);
seneca.act({ role: 'math', cmd: 'product', left: 3, right: 4 }, console.log);
代码的意思一目了然:
- Seneca 本身是一个模块,因此首先需要通过
require()
获取该模块,接着调用 Seneca 的包装函数完成代码库的初始化。 - 接下来的两条命令。
seneca.add()
方法可以为 Seneca 添加能在特定模式下被调用的函数。这里,我们定义了两个函数,第一个在 Seneca 接收到{role:math, cmd:sum}
命令时被调用,第二个在接收到{role: math, cmd: product}
时被调用。 - 在最后一行中,
act
函数的第一个入参表示相应的命令,能够触发 Seneca 调用与其匹配的服务。例如,第一个 act 中的参数能够匹配第一个服务,第二个 act 中的参数能够匹配第二个服务。
执行之后可以看到结果:
返回结果都以 null
开头,这在 JavaScript 中是一个很常用的模式——错误优先回调。
我们改写一下:
var seneca = require('seneca')();
seneca.add({ role: 'math', cmd: 'sum' }, function(msg, respond) {
var sum = msg.left + msg.right;
respond(null, { answer: sum });
});
seneca.add({ role: 'math', cmd: 'product' }, function(msg, respond) {
var product = msg.left * msg.right;
respond(null, { answer: product });
});
// seneca.act({ role: 'math', cmd: 'sum', left: 1, right: 2 }, console.log);
seneca.act({ role: 'math', cmd: 'sum', left: 1, right: 2 }, function(
err,
data
) {
if (err) {
return console.error(err);
}
console.log(data);
});
// seneca.act({ role: 'math', cmd: 'product', left: 3, right: 4 }, console.log);
seneca.act({ role: 'math', cmd: 'product', left: 3, right: 4 }, function(
err,
data
) {
if (err) {
return console.error(err);
}
console.log(data);
});
这段代码以更加合理的方式重写了第一个调用 Seneca 的方法。这里,并不是将所有东西都打印到控制台,而是先处理 response。回调函数中第一个参数如果为 error
(非 null)则打印错误信息,第二个参数是从微服务中返回的数据。这就是为什么在第一个例子中,每行行首都打印为 null。
控制反转思想在现代软件中是不可或缺的,随之而来的还有依赖注入。控制反转是一种软件思想,它能代理创建或调用各组件及方法,使得模块本身不用关注创建它们所需要的依赖,这些通常是通过依赖注入完成的。Seneca 并没有使用依赖注入,但是它是实现控制反转思想的典型例子。
var seneca = require('seneca')();
seneca.add({ component: 'greeter' }, function(msg, respond) {
respond(null, { message: 'Hello ' + msg.name });
});
seneca.act({ component: 'greeter', name: 'David' }, function(error, response) {
if (error) return console.log(error);
console.log(response.message);
});
从企业级软件的角度出发,我们可以从中区分出两个组件:生产者(Seneca.add()
)和消费者(Seneca.act()
)。如之前提到的,Seneca 没有使用依赖注入,但却很优雅地依据控制反转原则构建了它的代码。
在 Seneca.act()
函数中,我们并没有显式地调用处理业务逻辑的组件,而是通过 JSON 信息向 Seneca 指定具体调用的组件。这就是控制反转。Seneca 在处理控制反转上相当灵活,没有关键字和强制的字段。它只需一组键值对,被用于模式匹配引擎 Patrun 中。
var seneca = require('seneca')();
seneca.add({ cmd: 'wordcount' }, function(msg, respond) {
var length = msg.phrase.split(' ').length;
respond(null, { words: length });
});
seneca.act({ cmd: 'wordcount', phrase: 'Hello world this is Seneca' }, function(
err,
response
) {
console.log(response);
});
这是一个用于统计句子中单词数量的服务。可以看到,通过 seneca.add()函数,我们为 wordcount 命令添加了处理器,并且在第二句调用中向 Seneca 发送了统计短语中单词个数的请求。
现在,让我们对其进行扩展,统计时跳过较短的单词(长度小于等于 3),如下所示:
var seneca = require('seneca')();
seneca.add({ cmd: 'wordcount' }, function(msg, respond) {
var length = msg.phrase.split(' ').length;
respond(null, { words: length });
});
seneca.add({ cmd: 'wordcount', skipShort: true }, function(msg, respond) {
var words = msg.phrase.split(' ');
var validWords = 0;
for (var i = 0; i < words.length; i++) {
if (words[i].length > 3) {
validWords++;
}
}
respond(null, { words: validWords });
});
seneca.act({ cmd: 'wordcount', phrase: 'Hello world this is Seneca' }, function(
err,
response
) {
console.log(response);
});
seneca.act(
{ cmd: 'wordcount', skipShort: true, phrase: 'Hello world this is Seneca' },
function(err, response) {
console.log(response);
}
);
我们为 wordcount
命令添加了另一个处理器,并添加了一个额外的参数 skipShort
。这个处理器在统计单词数时跳过了长度小于等于 3 的单词,执行上述代码,将得到类似下图所示的输出:
Seneca 使用它来执行模式匹配,以判断该由哪一个服务来响应请求。Patrun 使用最近匹配原则来处理调用。
我们可以看到 3 种模式。这与前面 seneca.add()
函数中的模式相同。我们注册了三种关于 x、y 不同取值的组合,来看看 Patrun 是如何对它们进行匹配的:
{x: 1} ->A
:这与 A 完全匹配。{x: 2} ->
:无匹配项。{x:1, y:1} -> B
:与 B 完全匹配;虽然它与 A 也能匹配,但是显然与 B 的匹配度更高——两个匹配项与一个匹配项的区别。{x:1, y:2} -> C
:与 C 完全匹配,同理,与 A 也匹配,但是 C 的匹配度更高。{y: 1} ->
:无匹配项
Patrun(在 Seneca 中)总是获取最长匹配项。因此,我们能够轻易地通过具象化匹配来扩展出更多抽象模式的功能。
我们可以回到两数相加的例子,通过这个例子能体现函数复用:
var seneca = require('seneca')();
seneca.add({ role: 'math', cmd: 'sum' }, function(msg, respond) {
var sum = msg.left + msg.right;
respond(null, { answer: sum });
});
seneca.add({ role: 'math', cmd: 'sum', integer: true }, function(msg, respond) {
this.act(
{
role: 'math',
cmd: 'sum',
left: Math.floor(msg.left),
right: Math.floor(msg.right)
},
respond
);
});
seneca.act({ role: 'math', cmd: 'sum', left: 1.5, right: 2.5 }, console.log);
seneca.act(
{ role: 'math', cmd: 'sum', left: 1.5, right: 2.5, integer: true },
console.log
);
代码只是稍加改变。接收 integer
的模式依赖基础模式计算两数之和。Patrun 从以下两个角度来匹配最接近、最具体的模式:
- 最长的匹配链
- 模式中元素的顺序
Patrun 会搜寻最优匹配结果,如果存在多个匹配度相同的最优解,那么将匹配第一个结果。通过这种方式,我们可以依赖于已存在的模式来构建新的服务。
插件是 Seneca 应用的重要组成部分之一。正如第 1 章中提到的,在微服务架构中,通过 API 聚合来构建应用是一种很好的方式。
下面这个例子是 Seneca 中的一个小插件:
function minimal_plugin(options) {
console.log(options);
}
require('seneca')().use(minimal_plugin, { foo: 'bar' });
将以上代码写入 minimal-plugins.js 文件中并执行:
node minimal-plugins.js
在 Seneca 中,插件在启动时被加载,由于默认的日志级别为 INFO,而插件加载的日志级别为 DEBUG,因此默认情况下我们无法看到插件加载信息。但是,可以通过添加参数来获取更多日志信息,如下所示:
node minimal-plugin.js --seneca.log.all
这时,你将会得到大量的输出,因为输出内容几乎包括了 Seneca 内部运行的全部信息。这些信息对于我们调试复杂场景非常有用,但此时我们只需要显示插件列表:
node minimal-plugin.js --seneca.log.all | grep plugin | grep DEFINE
basic
:该插件包含在 Seneca 的主模块中,提供一系列基础且实用的 action 模式。transport
:传输插件。直到现在,我们只是在同一台机器上执行不同的服务(相当微小且简单),如果想要将它们分发部署该怎么做?这个插件能够提供帮助。web
:Seneca 默认使用 TCP 协议,创建 RESTful API 相当麻烦。这个插件可帮助我们更好地编写 RESTful API,我们将在后续章节中学习它的使用方法。mem-store
:Seneca 提供了数据抽象层,因此可以使用不同的底层存储,例如 Mongo、SQL 类数据库等。Seneca 通过 mem-store 提供了让我们开箱即用的内存存储功能。minimal_plugin
:这是我们创建的插件。现在我们知道 Seneca 已经能够加载它了。
我们编写的插件并没有实际功能,现在,让我们来编写有实际作用的代码:
function math(options) {
this.add({ role: 'math', cmd: 'sum' }, function(msg, respond) {
respond(null, { answer: msg.left + msg.right });
});
this.add({ role: 'math', cmd: 'product' }, function(msg, respond) {
respond(null, { answer: msg.left * msg.right });
});
}
require('seneca')()
.use(math)
.act('role:math,cmd:sum,left:1,right:2', console.log);
注意最后一条命令,act()
中使用了字符串。这并没有问题,但是我个人偏向于使用 JSON 对象(字典)作为入参,因为通过这种方式组织数据可以避免一些不必要的语法问题。
在使用 Seneca 时,如何初始化插件是一个值得注意的问题。插件的包装函数(上例中的 math()
函数)被称为定义函数,在设计上是同步执行的。之前我们提到过,Node.js 应用是单线程运行的。
在初始化插件时,应该添加一个特定的 init()
action 模式。每个插件的初始化动作将会顺序执行。init()
函数必须无误地调用 respond()
回调函数,如果插件初始化失败,Seneca 将退出 Node.js 进程。当然,你肯定也希望微服务在遇到问题时能快速失败并且报错。注意,在执行任何 action 前,必须保证所有插件都成功初始化。
让我们一起看看以下方法如何初始化插件:
function init(msg, respond) {
console.log('plugin initialized!');
console.log('expensive operation taking place now... DONE!');
respond();
}
function math(options) {
this.add({ role: 'math', cmd: 'sum' }, function(msg, respond) {
respond(null, { answer: msg.left + msg.right });
});
this.add({ role: 'math', cmd: 'product' }, function(msg, respond) {
respond(null, { answer: msg.left * msg.right });
});
this.add({ init: 'math' }, init);
}
require('seneca')()
.use(math)
.act('role:math,cmd:sum,left:1,right:2', console.log);
Node.js 应用的一个原则是从不阻塞线程。如果你发现阻塞线程,应该想想如何避免它。
Seneca 并不是一个 Web 框架。它被定义为一个通用的微服务框架,因此它并不会对具体的某个应用场景(例如 Web)做过多的支持。取而代之的是,Seneca 能够非常轻易地与其他框架进行整合。
将 Seneca 作为 Express 的中间件
Express 也是基于 API 聚合原则构建的。在 Express 中,每个软件模块都被称为中间件,它们在代码中以链式结构串联,以此来处理每个请求。
我们准备将 seneca-web 作为 Express 的一个中间件,因此只要指定了配置,所有的 URL 都将遵循规定的命名规范。
var seneca = require('seneca')();
seneca.add('role:api,cmd:bazinga', function(args, done) {
done(null, { bar: 'Bazinga!' });
});
seneca.act('role:web', {
use: {
prefix: '/my-api',
pin: { role: 'api', cmd: '*' },
map: {
bazinga: { GET: true }
}
}
});
var express = require('express');
var app = express();
app.use(seneca.export('web'));
app.listen(3000);
- 第二行代码为Seneca添加了一个模式。你应该对这行代码非常熟悉,因为本书中所有的例子都是这么做的。
- 请关注第三条命令
seneca.act()
,这正是本例中最神奇的地方。我们将role:api
模式与任意的cmd模式(cmd:*
)装配到一起,以响应/my-api
下的URL请求。在本例中,第一个seneca.add()
将响应URL/my-api/bazinga
的请求,因为在seneca.act()
中,prefix
变量被定义为/my-api
,并且seneca.add()
中cmd模式下指定了bazinga
。 app.use(seneca.export('web'))
指定seneca-web作为Express的中间件,并根据配置规则执行相关动作。app.listen(3000)
将Express与3000端口进行绑定。
seneca.act()
将一个函数作为第二个参数。在本例中,我们将请求与Seneca响应动作的映射关系作为配置传递给Express。
我们将从浏览器到代码的角度进行解释。
- Express收到请求之后,将其交付给seneca-web处理。
- 所有以
/my-api
为前缀的请求,都会路由到seneca-web进行处理。在以上代码的seneca.act()
函数中,通过关键字pin
将role:api
模式和任意cmd模式(cmd:*
)绑定到seneca-web的响应action
上。通过这种方式,/my-api/bazinga
与第一个seneca.add()
中添加的{role:'api', cmd: 'bazinga'}
模式绑定在了一起。
Seneca具有数据抽象层,允许我们使用通用的方式操作应用的数据。Seneca默认加载in-memory存储插件,因此,我们可以直接使用它。Seneca基于以下操作,提供了简单抽象数据层(ORM,对象关系映射)。
load
:通过标识符读取实体。save
:创建实体或者通过标识符更新实体。list
:列出满足查询条件的所有实体。remove
:删除指定标识符对应的实体。
让我们来构建一个管理数据库中员工信息的插件:
module.exports = function(options) {
this.add({role: 'employee', cmd: 'add'}, function(msg, respond){
this.make('employee').data$(msg.data).save$(respond);
});
this.find({role: 'employee', cmd: 'get'}, function(msg, respond){
this.make('employee').load$(msg.id, respond);
});
}
由于我们默认使用内存数据库,因此现在不需要关心表结构。
第一条命令向数据库中添加一条员工信息。第二条命令通过id从数据中获取一位员工信息。注意,Seneca中所有的ORM原语都是以$结尾的。如你所见,现在我们已经从具体数据存储实例中抽象出来了。如果有一天,应用发生变更,必须使用MongoDB替代内存存储,我们唯一需要关注的是MongoDB的相关插件。
我们将使用员工管理插件:
var seneca = require('seneca')().use('employees-storage')
var employee = {
name: "David",
surname: "Gonzalez",
position: "Software Developer"
}
function add_employee() {
seneca.act({role: 'employee', cmd: 'add', data: employee},
function (err, msg) {
console.log(msg);
}
);
}
add_employee();
上例中,我们在代码中使用了存储插件,通过模式匹配将员工信息存入内存数据库中。
PM2是一款可以为服务器实例带来负载均衡功能的生产级别的进程管理器,通过PM2我们可以自由伸缩Node.js应用。此外,它能确保进程持续运行,解决Node.js单线程模型带来的副作用:一个没有被捕获的异常通过杀死线程,进而杀死整个应用。
前面我们提到过,Node.js应用是单线程执行的,这不表示Node.js不能并发。它表示你的应用是以单线程模式运行的,而其余任务是并行的。
单线程模式意味着,如果抛出的异常没有被处理的话,应用程序将会挂掉。
这个问题可以通过使用promise
库(例如 bluebird
)解决;通过promise方式,应用不仅可以处理成功的返回,还能够处理异常,因此它可以防止异常“冒泡”导致应用崩溃。
然而,还是存在一些在我们控制范围之外的情况,我们称之为不可恢复的错误。一旦出现这些错误,最终将导致你的应用程序崩溃。在Node.js中,是一个大问题。但是,我们可以通过任务执行器,例如 forever
,来解决这个问题。
forever与PM2都是任务执行器,当你的应用意外退出时,它们可以重启你的应用,从而能确保其正常运行。
forever相当原始:无论你杀死应用多少次,它都会将其重启。
还有一个相当有用的工具包 nodemon
。当它探测到监控的文件(默认监控工程下所有文件,即*.*)发生变化时,它将重载应用。
通过PM2,你可以管理应用的整个生命周期,并且实现没有停机时间。只要通过简单的命令就可以伸缩应用。PM2也具备负载均衡的功能。
// helloWorld.js
var http = require('http');
var server = http.createServer(function (request, response) {
console.log('called!');
response.writeHead(200, {"Content-Type": "text/plain"});
response.end("Hello World\n");
});
server.listen(8000);
console.log("Server running at http://127.0.0.1:8000/");
通过PM2来运行它:
pm2 start helloWorld.js
PM2已经注册了一个名为 helloWorld
的应用。这个应用运行在fork模式下,该模式下PM2不进行负载均衡处理,而是简单地fork该应用,本例中该应用的PID为6858。
pm2 show [id] # 得到id为 [id] 的应用的相关信息
pm2 monit # PM2监控显示
pm2 logs # 可以查看输出日志
pm2 reload 应用名 # 无缝重启应用(这个命令可以确保你的应用能完成重启,并且无停机时间)
pm2 reload all
pm2 stop all # 停止所有应用
pm2 delete all # 删除所有应用
使用集群模式启动应用:
pm2 start helloWorld.js -i 3
PM2充当控制主进程与3个工作进程之间的轮询调度器,因此它们可以并行地处理3个请求。我们能够自由地增加、减少工作进程:
pm2 scale helloWorld 2 # 工作进程由3个减少为2个
PM2开放了编程接口,我们可以编写Node.js程序来管理之前例子中的所有手动过程。同时,可以通过读取JSON文件的方式来配置应用服务。