万字长文 | Seneca: 用Node.js搭建你的微服务架构

212 阅读26分钟

简述

本文将介绍微服务框架 Seneca 的基本知识,并提供一个 Node.js 版的微服务集群。这里将使用 Web 服务器框架 Express、Web 服务管理工具 PM2 以及微服务架构 Seneca(含相关 web、存储插件)。

关于微服务

微服务(Microservices)于 2012 年正式出道,首次出现在 ThoughtWorks 技术雷达 2012 年 3 月的报告中。2013 年可配合微服务实施的一些框架和工具相继出现,比如 Sprint Boot、Hystrix 等。

在此之后,业界对于微服务的实践及讨论逐渐升温。2014 年 3 月,Adrian Cockcroft(前 Netflix 首席云架构师)与 John Allspaw(现任 Etsy CTO)等人在 Twitter 上展开了关于“微服务与单块应用”孰优孰劣的讨论(链接)。

而在业界,真正为“微服务架构”这一架构风格正名的当属 Marthin Fowler 大叔于 2014 年 3 月在其博客发表的 Microservices 一文,也正是此文让大众对微服务有了更加具体的认识。

2015 年,随着以 Docker 为代表的容器技术的突飞猛进,微服务的部署难题迎刃而解,甚至有人将 2015 年称之为微服务架构元年。

注:本段摘自《Node.js 微服务》译者序,并做改动

准备工作

要求

本文是希望通过实际案例,来了解微服务这个概念,并有一个实践的切入点,因此,你可能需要有以下的经验:

  • 学习和使用过 Node.js,有一定 Node.js 基础,javascript 基础良好
  • 学习和使用过 Express 框架,对中间件有一定的了解
  • 如果你使用过 PM2 或 Supervisor 就更好了

框架

这里需要安装和了解两种框架

Express

关于 Express,这里不做过多说明,初学者请阅读下列文章(或直接去官网):

除了 Express,你可以尝试用 koa,Seneca 也提供了 koa 的适配插件。

Seneca

Seneca 是《Node.js 微服务》一书中,作者推荐使用的微服务框架;本文也是以 Seneca 为例进行解释。

学习&实践环境

学习环境

找一个不受人打扰的地方,你需要 2-4 个小时的时间来理解和实践。

安装 Node.js 和 npm

安装好 Node.js,npm 会随 Node.js 一起安装。安装好后,执行下面两个命令,检查是否已经可用:

node -v
npm -v

安装 Express 安装器

然后安装 Express 的安装器:

npm i -g express-generator

其中,-g 表示安装到全局环境中,你可能需要在前面加 sudo 来获取管理员权限。

安装 Express

找一个干净的文件夹,然后通过 express 命令来创建一个 Express 框架,然后执行安装命令即可:

express
npm -i

Seneca

本文在撰写时的 Seneca 版本是 v3.8.4,如果后续阅读时有不同,请指定安装这个版本。

在刚刚那个文件夹中,安装 Seneca:

npm i --save seneca

此外,后续还需要安装 Web 插件以及用于 Express 的适配器:

npm i --save seneca-web
npm i --save seneca-web-adapter-express

这一点在《Node.js 微服务》著书时不需要额外安装,即当时的 web 插件是集成在 Seneca 本身的;但后期为了接入更多 web 框架,如针对 koa 的,因此剥离出来。


此外,还需要操作数据库的插件:

npm i --save seneca-entity

全部 Seneca 所需的插件为:

{
	"seneca": "^3.8.4",
	"seneca-entity": "^3.3.0",
	"seneca-web": "^2.2.1",
	"seneca-web-adapter-express": "^1.1.2"
}

微服务 Seneca 的基本知识

一个简单的例子

在介绍一些基本知识之前,先看看下面的例子:

"use strict";

// 获取Seneca模块
const seneca = require("seneca")();

// 定义一个模式
// 当满足命令 {role:'math', cmd:'sum'} 时,执行其回调函数
// 这里的箭头函数 ()=>{} 和函数内部的 let 都是 ES6的写法
seneca.add({ role: "math", cmd: "sum" }, (msg, respond) => {
  let sum = msg.left + msg.right;
  respond(null, { answer: sum });
});

// 触发seneca
seneca.act({ role: "math", cmd: "sum", left: 1, right: 2 }, (err, data) => {
  console.log(data);
});

在 test.js 文件中,写入上述代码后,执行: node test。之后能收到以下应答: { answer: 3 }

当然,实际上会收到很多打印信息(如下图),找重点即可。 upload.png

对例子的简要说明

  • 在上述例子中,因为 Seneca 本身是一个模块,因此会用到 require('seneca') 来获取,然后直接完成初始化: require('seneca')()
  • seneca.add()方法做了什么?它规定了一个模式,即当收到 {role:'math', cmd:'sum'} 命令时,会调用它的回调函数(计算两个数的和)
  • seneca.act()方法做了什么?它通过指定 role:'math', cmd:'sum' 调用了前面添加的模式,并传递了两个参数:left:1, right:2

生产者和消费者

在上述例子中,可以将 seneca.add() 当作生产者,把 seneca.act() 当作消费者。

控制反转(IoC)与依赖注入(DI)

控制反转(Inversion of Control, IoC)是一种设计思想,它是通过依赖注入(Dependency Injection)这种技术或者说设计模型来实现的。而实现控制反转的框架或组件,我们称之为控制反转容器 IoC Container。

所谓控制反转,就是代理或调用各个组件及方法,使得模块本身不需要主动去创建依赖。

简单的例子

我们的 KCMS 知识管理系统在收录一篇文章后,要发送邮件给作者,同时要发送站内消息给作者。这个时候,为了完成这个功能,我们要:

class ReceiveArticle {
  constructor() {
    this.email_sender = new EmailSender();
    this.msg_sender = new MsgSender();
  }

  notice(author_id) {
    this.email_sender.send(author_id);
    this.msg_sender.send(author_id);
  }
}

var received = new ReceiveArticle();
received.notice(author_id);

在上面例子中,我们在类 ReceiveArticle 必须调用其他两个资源(EmailSenderMsgSender),一旦它的用法要变更,我们必须修改类 ReceiveArticle

依赖注入

而使用控制反转,我们通过一个 IoC 容器来管理这些依赖:

class ReceiveArticle {
  constructor(email_sender, msg_sender) {
    this.email_sender = email_sender;
    this.msg_sender = msg_sender;
  }

  notice(author_id) {
    this.email_sender.send(author_id);
    this.msg_sender.send(author_id);
  }
}

// IoC容器
const email_sender = new EmailSender();
const msg_sender = new MsgSender();

var received = new ReceiveArticle(email_sender, msg_sender);
received.notice(author_id);

在这里,我们不再在类 ReceiveArticle 内部实例化其他两个资源(EmailSenderMsgSender),而是将实例化后的对象传入构造函数中。

在这种模式下,类 ReceiveArticle 不需要关注如何对其他两个资源(EmailSenderMsgSender)进行实例化,也不需要担心实例化后有变更,需要修改这个类本身。这就是“依赖注入”

Seneca 的控制反转

在上面使用依赖注入的代码中,用 seneca 改造一下 IoC 容器:

// IoC容器
const seneca = require("seneca")();

seneca.add({ role: "article", cmd: "received" }, (msg, respond) => {
  const email_sender = new EmailSender();
  const msg_sender = new MsgSender();

  var received = new ReceiveArticle(email_sender, msg_sender);
  received.notice(author_id);

  respond(null, "email and message sent!");
});

seneca.act({ role: "article", cmd: "received" }, (err, data) => {
  console.log(data);
});

在上面的例子中,seneca.act(data, callback) 不显式地调用处理业务逻辑的组件,而是通过 {role:'article', cmd:'received'} 命令通知 Seneca 调用这个命令对应的组件来执行。这就是“控制反转”。

而在 Seneca 中,控制反转的实现不需要关键字和特殊字段,直接通过一组键值对就可以生效。这种机制生效的引擎,在 Seneca 中被称为“Patrun”。

参考

推荐一篇文章,对两个概念讲得非常通俗易懂: 《浅谈控制反转与依赖注入》 zhuanlan.zhihu.com/p/33492169 如果你有兴趣,可以在知乎上看看更多人的想法: www.zhihu.com/question/32…

模式匹配

插件

插件是 Seneca 应用的重要组成部分。

在上面的章节中,每个问题的解决方法都通过 seneca.add() 来添加,这些方法即一个个 API;我们最终的管理方式,必然是对这些 API 聚合,将各 API 进行分门别类地形成一个个不同功能的模块。

而这些模块就是 Seneca 中的“插件”。

一个最简单的插件

在 test.js 文件中,写入以下代码:

// 一个插件Demo
function myplugin(options) {
  console.log(options);
}

// 使用插件
require("seneca")().use(myplugin, { data: "use my plugin" });

然后在命令行中执行 node test.js,将得到如下打印:

{ data: 'use my plugin' }

在上面的例子中,我们创建了一个最简单的模块 myplugin,在文件最后被调用了。最后一段代码可写作:

const seneca = require("seneca")();
seneca.use(myplugin, { data: "use my plugin" });

一个有实际功能的插件例子

在上面的例子中,插件是没有实现任何功能的。在这部门,我们要实现一个简单的功能:接收两个数并返回它们的和。

function myplugin(options) {
  // 添加第一个模式
  this.add({ role: "math", cmd: "sum" }, function (msg, respond) {
    respond(null, { answer: msg.left + msg.right });
  });
}

require("seneca")()
  .use(myplugin)
  .act("role:math,cmd:sum,left:1,right:2", console.log);

执行后,将得到以下打印:

null { answer: 3 }

注意到,这里我们在 seneca.act() 中传入的是一个字符串 'role:math,cmd:sum,left:1,right:2',而不是一个 JSON 对象。事实上,这两种方式都是可以的。其中,Seneca 更推荐使用字符串的方式(被称为 jsonic),而《Node.js 微服务》一书的作者“偏向于使用 JSON 对象(字典)作为入参,因为通过这种方式组织数据可以避免一些不必要的语法问题”。就我个人而言,也是更偏向于使用 JSON 对象的。

事实上,也可以写成这样:

require("seneca")()
  .use("./plugins/myplugin")
  .act("role:math", { cmd: "sum", left: 1, right: 2 }, console.log);

插件初始化

Seneca 为我们提供了一种初始化插件的特殊模式:在插件中添加一个模式,即在 seneca.add() 中添加一个特定的模式 {init:插件名}

function myplugin(options) {
  // 初始化
  this.add({ init: "myplugin" }, (msg, respond) => {
    console.log("My Plugin is init!");
    respond();
  });

  // 添加第一个模式
  this.add({ role: "math", cmd: "sum" }, function (msg, respond) {
    respond(null, { answer: msg.left + msg.right });
  });
}

require("seneca")()
  .use(myplugin)
  .act("role:math,cmd:sum,left:1,right:2", console.log);

运行此文件后,将看到以下的打印信息:

My Plugin is init!
null { answer: 3 }

需要特别注意的是,这个初始化函数必须在最后调用 respond() 方法,否则会收到超时的报错。

把插件放入其他文件中

在上面的例子中,我们把插件写进了同一个文件;在实践中,插件往往会单独写成一个文件,方便管理。

我们将上面的例子做一下调整。首先新建一个文件:myplugin.js,里面写入以下代码:

function myplugin(options) {
  // 初始化
  this.add({ init: "myplugin" }, (msg, respond) => {
    console.log("My Plugin is init!");
    respond();
  });

  // 添加第一个模式
  this.add({ role: "math", cmd: "sum" }, function (msg, respond) {
    respond(null, { answer: msg.left + msg.right });
  });
}

module.exports = myplugin;

然后在 test.js 文件中,写入:

require("seneca")()
  .use("myplugin") // 注意这里写的是插件名称,而不是插件函数
  .act("role:math,cmd:sum,left:1,right:2", console.log);

然后运行 node test,将得到和上一节相同的打印信息。

在本例中,需要注意的是,use( 'myplugin' ) 与上一节是不同的!在上一节中用的是 .use( myplugin ),这里的 myplugin 是插件函数本身;而本节中,'myplugin' 是一个字符串,在实际加载时会从根目录查找相同名字的文件并加载。

存放在不同文件夹的插件

既然上一节中,Seneca 会默认加载和插件名称相同的文件,那么是不是可以把这些插件放入不同文件夹,然后指定插件所在的目录呢?答案是:可以。

我们以上一节的代码为例,在根目录新建一个名为 plugins 的文件夹,然后把 myplugin.js 移入这个文件夹。此时,我们的代码结构是这样的:

- root
	|- plugins
		|- myplugin.js
	|- test.js

然后再修改 test.js 中引用插件的代码:

require("seneca")()
  .use("./plugins/myplugin") // 注意这里添加了插件所在的路径
  .act("role:math,cmd:sum,left:1,right:2", console.log);

看到这里,我们已经几乎可以设计出一整套的解决方案了:

- root
	|- plugins
		|- email.js
		|- payment.js
		|- sms.js
		|- order.js
		|- products.js
		|- ...
	|- main.js

在 main.js 文件中,我们可以初始化一批独立的插件:

require("seneca")()
  .use("./plugins/email")
  .use("./plugins/payment")
  .use("./plugins/sms")
  .use("./plugins/order")
  .use("./plugins/products");

融入 Web 服务器(以 Express 为例)

阅读前请注意:本章节内容来自 Seneca 官网,与书中描述有所不同

Seneca 是一个通用的微服务框架,并不是一个 Web 框架,因此它不会对 Web 这样的应用场景做过多的支持。但 Seneca 的强大之处在于,它能够轻易地与其他框架进行整合,例如 Express。

在实践中,Seneca 将作为一个 Express 的中间件来使用。

在此之前,我们需要做一些准备工作。

准备工作

首先要安装 Express,这一步请阅读 《搭建一个简单的 Node.js(Express 框架)的 web 服务器》,这里不做介绍。

在安装好 Express 之后,还需要安装 Seneca 及其所需的 web 插件:

npm i --save seneca
npm i --save seneca-web
npm i --save seneca-web-adapter-express

其中,seneca-web 实现了将 http 请求映射到了 Seneca 的行为。seneca-web-adapter-express 则是针对 Express 框架做的适配。

一个简单的示例

将 app.js 改为:

var SenecaWeb = require('seneca-web')
var Express = require('express')
var Router = Express.Router
var context = new Router()


var app = Express()
      .use( require('body-parser').json() )
      .use( context )
      .listen(3000)

// 定义一个名为echo的插件
function echo(options) {
	this.add('role:echo,path:show', function (msg, respond) {
		var operation = msg.args.params.operation;
		var data = msg.args.query.data;

		respond(null, { received: data, operation:operation })
	})

	// 初始化插件echo,映射了url的前缀 /echo
	this.add('init:echo', function (msg, respond) {
		this.act('role:web',{routes:{
			prefix: '/echo',
			pin:    'role:echo,path:*',
			map: {
				show: { GET:true, suffix:'/:operation' }
			}
		}}, respond)
	})
}

var senecaWebConfig = {
      context: context,
      adapter: require('seneca-web-adapter-express'),
      options: { parseBody: false } // so we can use body-parser
}
var seneca = require('seneca')()
      .use(SenecaWeb, senecaWebConfig )
      .use(echo)

运行此程序后,在浏览器中输入: http://127.0.0.1:3000/echo/show/get?data=3 将收到以下反馈:{"received":"3","operation":"get"}

关于这段代码,我将在下一章节详细说明。

发生了什么

注意到这一段代码:

.use(SenecaWeb, senecaWebConfig )

这里将 Seneca 集成进了 Express 中,SenecaWeb will attach any of the routes defined through seneca.act('role:web', {routes: routes}) to context.

URL 与 Seneca 的 action 是如何映射的

在 echo 插件初始化的地方,有这么一段代码:

this.act(
  "role:web",
  {
    routes: {
      prefix: "/echo",
      pin: "role:echo,path:*",
      map: {
        show: { GET: true, suffix: "/:operation" },
      },
    },
  },
  respond
);

这里定义了凡是以 /echo 开头的 URL 请求,都会由 role:echo,path:* 模式来响应。这里的 * 意味着可以是任意的 path 模式。上面例子中提供的是 role:echo,path:show 模式。

其中,map 规定了这些模式的请求属性,如上面代码中,/echo/show 规定了允许的请求方式为 GET,且需要传递一个参数(parameterised suffix),这里的参数和 Express 路由的是一样的。

因此,这部分代码指定的完整 URL 应当是 /echo/show/:operation,对应的 Seneca 的 action 是 role:echo,path:show

请求参数是如何获取的

在 echo 插件的 role:echo,path:show 模式中,有这样一段代码:

this.add("role:echo,path:show", function (msg, respond) {
  var operation = msg.args.params.operation;
  var data = msg.args.query.data;

  respond(null, { received: data, operation: operation });
});

通过上面代码,我们可以知道,http 请求传递来的参数,都存在于 msg.args 中,这个类似于 Express 的 req,我们知道,Express 中从路由获取参数的方法是: req.paramsreq.queryreq.body

注意到,URL 的 /echo/show/:operation 中,operation 参数是通过 msg.args.params 获取的,这一点和 Express 一致。

同理,如果是传递的参数,则是通过 msg.args.query 来获取;body 中的参数是通过 msg.args.body 来获取。


以上面浏览器输入的 url 为例,http://127.0.0.1:3000/echo/show/get?data=3 中实际的 URL 是 echo/show/get?data=3,它会触发 Seneca 的 echo 插件中 role:echo,path:show 模式的方法,并传入了 operationget,附带一个参数 data=3

如何拓展为 Web 微服务

上述例子中,不论设计多么精妙,都还是在本机;而微服务往往是分布式的服务,即不同的服务部署在不同的服务器,那么要如何拓展为真正意义上的微服务呢?

这个问题我们将在下一章“融入 Express 的微服务”再做详细介绍。

数据存储

Seneca 提供了数据抽象层,允许我们使用通用的方式操作数据库。因此,我们在使用 Seneca 操作数据库的时候,不需要关注我们的数据库到底是内存型数据库(如 Seneca 自带的 in-memory,如当前非常流行的 Redis)、是 MySQL 还是 MongoDB,我们只需要按照约定的格式来操作即可。

Seneca 的抽象数据层

Seneca 提供了简单的抽象数据层(ORM,对象关系映射):

  • load:通过标识符读取实体
  • save:创建实体或通过标识符更新实体
  • list:列出满足查询条件的所有实体
  • remove: 删除指定标识符对应的实体

上面的描述有一点生硬,我们用“人话”来翻译一下。以文章 articles 为例:

  • load:通过文章 id 获取文章
  • save:创建一篇文章,或通过文章 id 更新文章
  • list:列出满足查询条件的全部文章
  • remove:删除指定 id 的文章

需要注意的是,在使用这些方法时,都以 $ 结尾。即 seneca.load$()seneca.save$()seneca.list$()seneca.remove$()

一个简单的数据库操作实例

和《Node.js 微服务》一书中的介绍不同的是,当前的 Seneca 版本在操作之前,你需要安装一个插件:

npm i --save seneca-entity

并在引用 Seneca 时加载它:

require('seneca')
	.use('seneca-entity')

如果你在调用 seneca.make() 方法时报错,那么检查是否按这里所说的正确加载 seneca-entity


下面给出了一个最简单的例子,它创建了一篇文章,并存储于 Seneca 内置的存储中:

const seneca = require("seneca")().use("entity");

const article = {
  name: "Test Article",
  author: "linxiaozhou.com",
};

seneca
  .make("articles")
  .data$(article)
  .save$((err, data) => {
    console.log("\n\n" + data + "\n\n");
  });

运行这段代码后,得到输出:

$-/-/articles;id=wep2z9;{name:Test Article,author:linxiaozhou.com}

使用 Seneca 微服务来操作数据库

先看例子:

// 文章管理插件
function article_manage( options ) {
	// 表名称
	const Table = 'article';

	// 模式: 创建一篇文章
	this.add({role:Table, cmd:'create'}, (msg, respond)=>{
		let article = msg.data;

		this.make('articles').data$(article).save$(respond);
	})

	// 模式: 按标识符查找一篇文章
	this.add({role:Table, cmd:'findOne'}, (msg, respond)=>{
		let article_id = msg.id;
		console.log("\nFind One Article by id:\n"+article_id)

		this.make('articles').load$(article_id, respond);
	})
}

const seneca = require('seneca')()
				.use( article_manage )
				.use('entity')


// 要创建的文章
const article = {
	name : "Test Article",
	author : "linxiaozhou.com"
};

// 先创建一篇文章,然后根据文章id找到它
seneca.act({role:'article', cmd:"create", data:article}, (err, data)=>{
	let article_id = data.id;
	console.log("\nCreate An Article:\n"+data)

	seneca.act({role:'article', cmd:"findOne", id:article_id}, (err, data)=>{
		console.log("\nGet An Article:\n"+data+"\n")
	})
})

运行结果为:

Create An Article:
$-/-/articles;id=jn522g;{name:Test Article,author:linxiaozhou.com}

Find One Article by id:
jn522g

Get An Article:
$-/-/articles;id=jn522g;{name:Test Article,author:linxiaozhou.com}

Article Name:
Test Article

Article Name:
linxiaozhou.com

upload.png

上面的例子中,我们先通过 Seneca 创建一篇文章,然后通过这篇文章的 id 从数据库中找出来。

Seneca 的日志输出

默认为 INFO 级的,如果想要得到更详细的输出,可以加上 --seneca.log.all 的后缀:

node xxxx --seneca.log.all

融入 Express 的微服务

在本章,我将演示一个可联通的“一个主服务+三个微服务”集群。这四个服务全部在本机,也可部署在不同服务器中。

文件结构

在本机的文件结构如下:

microservices
     ┝ package.json
     ┝ node_modules
     ┝ config.js	 // 主服务配置文件
     ┝ plugins		// 插件文件夹
       ┝ ui.js		// 主服务的UI插件
	   ┕ test.js	// 主服务的测试插件
     ┕ app.js		// 主服务启动文件
   ┝ articles		// 文章管理服务
     ┝ node_modules
     ┝ plugins		// 插件文件夹
	   ┕ articles.js
     ┝ server.js	  // 文章管理启动文件package.json
   ┝ roles			// 角色管理服务
     ┝ node_modules
     ┝ plugins		// 插件文件夹
	   ┕ roles.js
     ┝ server.js	  // 角色管理启动文件package.json
   ┕ users			// 用户管理服务
     ┝ node_modules
     ┝ plugins		// 插件文件夹
	   ┕ users.js
     ┝ server.js	// 用户管理启动文件package.json

其中,每一个服务都可以单独放到不同的服务器中部署。github 托管地址为:github.com/KKDestiny/m…

主服务

文件结构

主服务的文件结构如下:

microservices
   ┝ app			 // 主服务
     ┝ package.json
     ┝ node_modules
     ┝ config.js	 // 主服务配置文件
     ┝ plugins		// 插件文件夹
       ┝ ui.js		// 主服务的UI插件
	   ┕ test.js	// 主服务的测试插件
     ┕ app.js		// 主服务启动文件

其中,node_modules 是 Node.js 的包文件夹,存放所有的 Node.js 包。主服务需要 Express 及其相关的包以及 Seneca(含 Seneca 所需的插件)。

plugins 文件夹中存放的是主服务所需的两个插件:UI 插件和测试插件,分别用于显示页面和测试其他服务是否连通。插件的具体内容稍后再介绍。

config.js 文件存放的是主服务的配置信息,文件内容会在后面介绍。

app.js 是主服务的启动文件,如何融入 Express、调用不同插件、调用不同的微服务等都在此实现。文件内容会在后面介绍。

依赖

主服务的 package.json 内容如下:

{
  "name": "microservices",
  "version": "0.0.0",
  "private": true,
  "scripts": {
    "start": "node app"
  },
  "dependencies": {
    "cookie-parser": "~1.4.3",
    "debug": "~2.6.9",
    "express": "~4.16.0",
    "http-errors": "~1.6.2",
    "jade": "~1.11.0",
    "morgan": "~1.9.0",
    "seneca": "^3.8.4",
    "seneca-entity": "^3.3.0",
    "seneca-web": "^2.2.1",
    "seneca-web-adapter-express": "^1.1.2"
  }
}

需要关注的主要是两个部分,一个是需要 Express,这个可以通过 Express 安装器来安装;另一个是 Seneca,它包括了四个部分:Seneca 本身,用户存储管理的 seneca-entity,用于 Web 服务器管理的 seneca-web 以及适配 Express 的 seneca-web-adapter-express

UI 插件

这里先介绍 UI 插件,因为我只写了一个功能:返回一个 JSON 字符串给客户端。代码如下:

/**
 * 插件ui
 * 用于显示UI
 * @DateTime 2019-05-08
 */
function ui(options) {
  this.add("service:ui,path:home", function (msg, respond) {
    var service = msg.args.params.service;
    var data = msg.args.query.data;

    respond({ msg: "This is home page" });
  });

  this.add("init:ui", function (msg, respond) {
    this.act(
      "role:web",
      {
        routes: {
          prefix: "/",
          pin: "service:ui,path:*",
          map: {
            home: { GET: true },
          },
        },
      },
      respond
    );
  });
}

module.exports = ui;

上面代码中,我们创建了一个模式 'service:ui,path:home',并提供了这个插件的初始化方法 'init:ui'

在初始化方法中,我们定义了当请求(http)的 URL 为 / 打头,则会进入这个模式。并定义了一个 GET 方式的 URL:GET /home,这个 URL 将调用 'service:ui,path:home' 模式。

在这个模式下,主服务将返回一个 JSON 字符串:{msg:"This is home page"}


这里需要注意的是,module.exports = ui 里的 ui 的命名必须满足:

  • 必须与这个文件的名称相同(ui.js
  • 在 app.js 中引用这个插件也必须使用这个名称 ui

如果不满足以上两个条件,服务将报错。其他的插件也类似。

配置文件

在介绍插件 test 之前,先介绍配置文件 config.js

// 全局定义
const Protocol = "http";

// 服务列表
const ServiceList = {
  users: {
    name: "users",
    port: 3001,
  },
  roles: {
    name: "roles",
    port: 3002,
  },
  articles: {
    name: "articles",
    port: 3003,
  },
};

const Config = function () {};

Config.prototype.Protocol = Protocol;
Config.prototype.ServiceList = ServiceList;

module.exports = new Config();

这个文件非常简单,定义了两个常量:使用的传输协议 Protocol 和 支持的业务及信息 ServiceList,并提供给外部使用。

ServiceList 可以看出,我们当前有三个微服务:

  • 用户管理服务:名称为 users,端口号为 3001
  • 角色管理服务:名称为 roles,端口号为 3002
  • 文章管理服务:名称为 articles,端口号为 3003

如果此服务部署在其他的服务器,还可以加上 host 属性来指定 IP 或域名。在主服务连接的时候也要对应指定各服务的 host 即可。

插件 test

下面是 test 插件的源码:

/**
 * 插件test
 * 用于测试访问各个微服务
 * @DateTime 2019-05-08
 */

// 引用配置文件、获取配置信息
const Config = require("./../config");
const ServiceList = Config.ServiceList;

// 定义
function test(options) {
  this.add("service:test,path:ping", function (msg, respond) {
    var service = msg.args.params.service;
    var data = msg.args.query.data;

    // 检查是否有此服务
    if (!ServiceList[service]) {
      respond({ err: "Sorry, service is not access!" }, null);
      return;
    }

    this.act(
      "service:" + service + ",cmd:ping",
      {
        service: service,
        data: data,
      },
      respond
    );
  });

  this.add("init:test", function (msg, respond) {
    this.act(
      "role:web",
      {
        routes: {
          prefix: "/test",
          pin: "service:test,path:*",
          map: {
            ping: { GET: true, suffix: "/:service" },
          },
        },
      },
      respond
    );
  });
}

module.exports = test;

上面代码中,我们创建了一个模式 'service:test,path:ping',并提供了这个插件的初始化方法 'init:test'

在初始化方法中,我们定义了当请求(http)的 URL 为 /test 打头,则会进入这个模式。并定义了一个 GET 方式的 URL:GET /test/ping/:service,这个 URL 将调用 'service:test,path:ping' 模式。


在这个模式下,通过 service 的不同,将调用不同微服务的 cmd:ping 模式。

  • GET /test/ping/users 将最终触发 this.act('service:users,cmd:ping', callback),而这个服务正是用户管理服务的(原因会在后面介绍启动文件 app.js 时说明)。
  • GET /test/ping/roles 将最终触发 this.act('service:roles,cmd:ping', callback),即角色管理服务
  • GET /test/ping/articles 将最终触发 this.act('service:articles,cmd:ping', callback),即文章管理服务

启动文件

下面是 app.js 的内容:

var SenecaWeb = require("seneca-web");
var Express = require("express");
var Router = Express.Router;
var context = new Router();

var senecaWebConfig = {
  context: context,
  adapter: require("seneca-web-adapter-express"),
  options: { parseBody: false }, // so we can use body-parser
};

var app = Express()
  .use(require("body-parser").json())
  .use(context)
  .listen(3000);

// 获取配置信息
const Config = require("./config");
const Protocol = Config.Protocol;
const ServiceList = Config.ServiceList;

// 启用Seneca
var seneca = require("seneca")()
  .use(SenecaWeb, senecaWebConfig)
  .use("./plugins/test")
  .use("./plugins/ui")
  .client({
    type: Protocol,
    pin: "service:" + ServiceList.users.name,
    port: ServiceList.users.port,
  })
  .client({
    type: Protocol,
    pin: "service:" + ServiceList.roles.name,
    port: ServiceList.roles.port,
  })
  .client({
    type: Protocol,
    pin: "service:" + ServiceList.articles.name,
    port: ServiceList.articles.port,
  });

关于 Seneca 如何与 Express 融合,这里不再介绍,关注 .use(SenecaWeb, senecaWebConfig ) 即可。

这里要特别说明的有以下内容:

  • 引用插件
  • 主服务与微服务的连接

引用插件

首先是引用插件,注意到最后一段代码:

var seneca = require('seneca')()
      .use('./plugins/test')
      .use('./plugins/ui')

引用插件的方式即加载本地文件,如引用 test 插件的方式是 use('./plugins/test')。注意前文提到的,插件名称要与函数名保持一致,与文件名称保持一致。

主服务与微服务的连接

主服务与其他微服务的连接,是通过下面代码实现的:

var seneca = require("seneca")()
  .client({
    type: Protocol,
    pin: "service:" + ServiceList.users.name,
    port: ServiceList.users.port,
  })
  .client({
    type: Protocol,
    pin: "service:" + ServiceList.roles.name,
    port: ServiceList.roles.port,
  })
  .client({
    type: Protocol,
    pin: "service:" + ServiceList.articles.name,
    port: ServiceList.articles.port,
  });

上面一段代码看起来很复杂,其实是因为在使用全局配置信息的缘故;我们可以做一些修改,这样看起来更简单一些:

var seneca = require("seneca")()
  .client({ type: "http", pin: "service:users", port: 3001 })
  .client({ type: "http", pin: "service:roles", port: 3002 })
  .client({ type: "http", pin: "service:articles", port: 3003 });

如果你还不明白其中的关系,没有关系,继续往下看其他微服务的部署,就会清楚了。

用户管理服务

用户管理服务的文件结构如下:

microservices
   ┝ users			 // 用户管理服务package.json
     ┝ node_modules
     ┝ plugins		// 插件文件夹
	   ┕ users.js	// 用户管理服务的插件
     ┕ server.js		// 用户管理服务启动文件

其中,node_modules 是 Node.js 的包文件夹,存放所有的 Node.js 包。这里主要是 Seneca(含 Seneca 所需的插件)。

plugins 文件夹中存放的是用户管理服务所需的插件。

server.js 是用户管理服务的启动文件。

插件 users

下面是用户管理服务的 users 插件代码:

function Users(options) {
  // 模式:测试
  this.add({ service: "users", cmd: "ping" }, (msg, respond) => {
    console.log(">>>>>> Service Users Received Request!!!\n");

    respond(null, { response: "Users Service", data: msg.data });
  });
}

module.exports = Users;

这个插件定义了一个模式 {service:'users', cmd:'ping'},当 Seneca 的微服务集群中,触发了这个模式后,将调用这个模式的回调函数。

启动微服务

启动服务的文件是 server.js,代码如下:

require("seneca")().use("./plugins/users").listen({ port: 3001, type: "http" });

可以看到,这个服务的启动代码比主服务的简单很多,它只需要监听来自端口 3001http 消息即可。如果这个微服务没有部署在主服务所在的服务器,还需要指定主服务的 host,如:

.listen({port:3001, type:"http"}, host:"主服务所在IP或域名")

其他微服务

另外两个微服务(角色管理服务和文章管理服务)的代码和内容都与用户管理微服务类似,这里不再赘述。

微服务集群的联调

启动微服务

首先,分别启动用户管理、角色管理和文章管理微服务(这三个微服务的启动顺序不分先后),然后启动主服务。

启动用户管理微服务: upload.png

启动角色管理微服务: upload.png

启动文章管理微服务: upload.png

启动主服务: upload.png

测试

主服务 UI

首先,测试主服务的 UI 插件。在浏览器中输入:127.0.0.1:3000/home。浏览器应显示以下消息:

{"msg":"This is home page"}

用户管理服务

测试调用用户管理服务。在浏览器中输入:http://127.0.0.1:3000/test/ping/users。浏览器应显示以下消息:

{"response":"Users Service"}

且用户管理微服务的控制台会有如下打印:

>>>>>> Service Users Received Request!!!

角色管理服务

测试调用角色管理服务。在浏览器中输入:http://127.0.0.1:3000/test/ping/roles。浏览器应显示以下消息:

{"response":"Roles Service"}

且角色管理微服务的控制台会有如下打印:

>>>>>> Service Roles Received Request!!!

文章管理服务

测试调用文章管理服务。在浏览器中输入:http://127.0.0.1:3000/test/ping/articles。浏览器应显示以下消息:

{"response":"Articles Service"}

且文章管理微服务的控制台会有如下打印:

>>>>>> Service Articles Received Request!!!

代码串联

如果你已经成功运行,那么恭喜你,一个简单的基于 Express+Seneca 的微服务集群已经成功在你的电脑搭建起来了,你可以在这个基础上做很多有意思的的事情。

在此之前,请再仔细研究一遍整个集群里三个微服务到底是怎么和主服务联系在一起的。如果弄清楚了这一点,会让你在开发和调试过程中,更加顺利;你对微服务 Seneca 的理解也会更加深入。

主服务的启动

在主服务启动过程中,我们启用了两个自定义的插件:ui 和 test。在这两个插件的初始化代码中,我们定义了两个 URL 前缀:

  • ui:定义了 / 开头的 URL,并定义了一个路径 GET /home,该路径匹配了 service:ui,path:home 模式
  • test:定义了 /test 开头的 URL,并定义了一个路径 GET /test/ping/:service(其中,service 决定了使用哪个微服务),该路径匹配了 service:test,path:ping 模式

我们可以把上面的描述转换为表格:

主服务插件前缀Demo 路径对应模式
ui/GET /homeservice:ui,path:home
test/testGET /test/ping/:serviceservice:test,path:ping

接下来,我们逐个分析两个插件到底是如何响应浏览器(客户端)的 http 请求的。

访问主服务的 UI

我们先从简单的开始,了解从浏览器输入 URL 开始,到服务器返回给浏览器数据,这个过程到底经历了什么。(当然,我们不关注 http 协议本身,只讨论在微服务框架里的走向)

  • GET /home
  • 主服务接收到 URL,匹配模式:service:ui,path:home
  • 调用函数:
var service = msg.args.params.service;
var data = msg.args.query.data;
respond({msg:"This is home page"})
  • 主服务返回 JSON 字符串给浏览器

可以看到,访问这个 URL 后,主服务没有调用任何其他微服务,而是直接返回了一个 JSON 字符串给浏览器。

访问主服务的 test

接下来,我们在浏览器输入:http://127.0.0.1:3000/test/ping/users

  • GET /test/ping/users
  • 主服务接收到 URL,其路径是匹配 GET /test/ping/:service,匹配模式 service:test,path:ping
  • 调用函数(文件位于 /app/plugins/test):
this.act('service:users,cmd:ping', {
	service:   service,
	data:  data,
}, respond)
  • 触发用户管理微服务的 {service:'users', cmd:'ping'} 模式,并执行下面代码(文件位于 /users/plugins/users.js):
console.log(">>>>>> Service Users Received Request!!!\n")
respond(null, {response:"Users Service", data:msg.data})

注意到,这里之所以会触发用户管理微服务,是因为主服务的 Seneca 调用了 seneca.act('service:users,cmd:ping')

那么为什么主服务的 Seneca 调用这个模式后,会触发到用户管理微服务呢?注意到这两部分的代码:

// 主服务的启动文件 /app/app.js
var seneca = require("seneca")().client({
  type: "http",
  pin: "service:users",
  port: 3001,
});

// 用户管理微服务的启动文件 /users/server.js
require("seneca")().listen({ port: 3001, type: "http" });

一方面,用户管理微服务会监听来自端口 3001http 消息,另一方面,主服务将 service:users 模式绑定到了端口 3001。绑定后,主服务相当于用户管理微服务的一个客户端,可以在 Seneca 框架下进行通信。只要主服务需要处理 service:users 这个模式,就会调用用户管理微服务了。

同样地,其他的微服务被调用的原理也是一样的。

参考文献

  1. 《Node.js 微服务》 David Gonzalez 著,赵震一、郑伟杰译,电子工业出版社
  2. Seneca 官方文档:senecajs.org/getting-sta…
  3. 《浅谈控制反转与依赖注入》: zhuanlan.zhihu.com/p/33492169
  4. 《控制反转和依赖注入的理解(通俗易懂)》: blog.csdn.net/sinat_21843…