原文:https://pantao.elpreceptor.com/press/getting-started-seneca.html

What is Seneca

Seneca 是一个能让您快速构建基于消息的微服务系统的工具集,你不需要知道各种服务本身被部署在何处,不需要知道具体有多少服务存在,也不需要知道他们具体做什么,任何你业务逻辑之外的服务(如数据库、缓存或者第三方集成等)都被隐藏在微服务之后。

这种解耦使您的系统易于连续构建与更新,Seneca 能做到这些,原因在于它的三大核心功能:

  1. Pattern matching 模式匹配:不同于脆弱的服务发现,模式匹配旨在告诉这个世界你真正关心的消息是什么;
  2. Transport independence 无依赖传输:你可以以多种方式在服务之间发送消息,所有这些都隐藏至你的业务逻辑之后;
  3. Componentisation 组件化:功能被表示为一组可以一起组成微服务的插件。

在 Seneca 中,消息就是一个可以有任何你喜欢的内部结构的 JSON 对象,它们可以通过 HTTP/HTTPS、TCP、消息队列、发布/订阅服务或者任何能传输数据的方式进行传输,而对于作为消息生产者的你来讲,你只需要将消息发送出去即可,完全不需要关心哪些服务来接收它们。

然后,你又想告诉这个世界,你想要接收一些消息,这也很简单,你只需在 Seneca 中作一点匹配模式配置即可,匹配模式也很简单,只是一个键值对的列表,这些键值对被用于匹配 JSON 消息的极组属性。

在本文接下来的内容中,我们将一同基于 Seneca 构建一些微服务。

Patterns

让我们从一点特别简单的代码开始,我们将创建两个微服务,一个会进行数学计算,另一个去调用它:

  1. const seneca = require('seneca')();
  2. seneca.add('role:math, cmd:sum', (msg, reply) => {
  3. reply(null, { answer: ( msg.left + msg.right )})
  4. });
  5. seneca.act({
  6. role: 'math',
  7. cmd: 'sum',
  8. left: 1,
  9. right: 2
  10. }, (err, result) => {
  11. if (err) {
  12. return console.error(err);
  13. }
  14. console.log(result);
  15. });

将上面的代码,保存至一个 sum.js 文件中,然后执行它,你可能会在 console 中看到类似下面这样的消息:

  1. { answer: 3 }
  2. {"kind":"notice","data":"hello byhn2y9mwe8s/1616565349310/21784/3.23.2/-","level":300,"isot":"2021-03-24T05:55:49.482Z","when":1616565349482,"level_name":"info","seneca_id":"byhn2y9mwe8s/1616565349310/21784/3.23.2/-"}

到目前为止,所有这一切都发生在同一个进程中,没有网络流量产生,进程内的函数调用也是基于消息传输。

seneca.add 方法,添加了一个新的动作模式(Action Pattern)至 Seneca 实例中,它有两个参数:

  1. pattern :用于匹配 Seneca 实例中 JSON 消息体的模式;
  2. action :当模式被匹配时执行的操作

seneca.act 方法同样有两个参数:

  1. msg :作为纯对象提供的待匹配的入站消息;
  2. respond :用于接收并处理响应信息的回调函数。

让我们再把所有代码重新过一次:

  1. seneca.add('role:math, cmd:sum', (msg, reply) => {
  2. reply(null, { answer: ( msg.left + msg.right )})
  3. });

在上面的代码中的 Action 函数,计算了匹配到的消息体中两个属性 left 与 right 的值的和,并不是所有的消息都会被创建一个响应,但是在绝大多数情况下,是需要有响应的, Seneca 提供了用于响应消息的回调函数。

在匹配模式中, role:math, cmd:sum 匹配到了下面这个消息体:

  1. {
  2. role: 'math',
  3. cmd: 'sum',
  4. left: 1,
  5. right: 2
  6. }

并得到计自结果:

  1. {
  2. answer: 3
  3. }

关于 role 与 cmd 这两个属性,它们没有什么特别的,只是恰好被你用于匹配模式而已。

接着,seneca.act 方法,发送了一条消息,它有两个参数:

  1. msg :发送的消息主体
  2. response_callback :如果该消息有任何响应,该回调函数都会被执行。

响应的回调函数 response_callback 可接收两个参数: error 与 result ,如果有任何错误发生(比如,发送出去的消息未被任何模式匹配),则第一个参数将是一个 Error 对象,而如果程序按照我们所预期的方向执行了的话,那么,第二个参数将接收到响应结果,在我们的示例中,我们只是简单的将接收到的响应结果打印至了 console 而已。

  1. seneca.act({role: 'math', cmd: 'sum', left: 1, right: 2}, function (err, result) {
  2. if (err) return console.error(err)
  3. console.log(result)
  4. })

sum.js 示例文件,向你展示了如何定义并创建一个 Action 以及如何呼起一个 Action,但它们都发生在一个进程中,接下来,我们很快就会展示如何拆分成不同的代码和多个进程。

How patterns work

模式——而不是网络地址或者会话,让你可以更加容易的扩展或增强您的系统,这样做,让添加新的微服务变得更简单。

现在让我们给系统再添加一个新的功能——计算两个数字的乘积。
我们想要发送的消息看起来像下面这样的:

  1. {role: 'math', cmd: 'product', left: 3, right: 4}

而后获得的结果看起来像下面这样的:

  1. {answer: 12}

你可以像 role: math, cmd: sum 模式这样,创建一个 role: math, cmd: product 操作:

  1. seneca.add({role: 'math', cmd: 'product'}, function (msg, respond) {
  2. var product = msg.left * msg.right
  3. respond(null, { answer: product })
  4. })

然后,调用该操作:

  1. seneca.act({
  2. role: 'math',
  3. cmd: 'product',
  4. left: 3,
  5. right: 4
  6. }, (err, result) => {
  7. if (err) {
  8. return console.error(err);
  9. }
  10. console.log(result);
  11. });

运行 product.js ,你将得到你想要的结果。

将这两个方法放在一起,代码像是下面这样的:

  1. const seneca = require('seneca')();
  2. seneca.add('role:math, cmd:sum', (msg, reply) => {
  3. reply(null, { answer: ( msg.left + msg.right )})
  4. });
  5. seneca.add('role:math, cmd:product', (msg, reply) => {
  6. reply(null, { answer: ( msg.left * msg.right )})
  7. });
  8. seneca.act({role: 'math', cmd: 'sum', left: 1, right: 2}, console.log)
  9. .act({role: 'math', cmd: 'product', left: 3, right: 4}, console.log)

运行 sum-product.js 后,你将得到下面这样的结果:

  1. null { answer: 3 }
  2. null { answer: 12 }

在上面合并到一起的代码中,我们发现, seneca.act 是可以进行链式调用的,Seneca 提供了一个链式 API,链式调用是顺序执行的,但是不是串行,所以,返回的结果的顺序可能与调用顺序并不一样。

Extending functionality by adding patterns

模式让你可以更加容易的扩展程序的功能,与 if…else… 语法不同的是,你可以通过增加更多的匹配模式以达到同样的功能。

下面让我们扩展一下 role: math, cmd: sum 操作,它只接收整型数字,那么,怎么做?

  1. seneca.add({role: 'math', cmd: 'sum', integer: true}, function (msg, respond) {
  2. var sum = Math.floor(msg.left) + Math.floor(msg.right)
  3. respond(null, {answer: sum})
  4. })

现在,下面这条消息:

  1. {role: 'math', cmd: 'sum', left: 1.5, right: 2.5, integer: true}

将得到下面这样的结果:

  1. {answer: 3} // == 1 + 2,小数部分已经被移除了

现在,你的两个模式都存在于系统中了,而且还存在交叉部分,那么 Seneca 最终会将消息匹配至哪条模式呢?原则是:更多匹配项目被匹配到的优先,被匹配到的属性越多,则优先级越高。

pattern-priority-testing.js 可以给我们更加直观的测试:

  1. const seneca = require('seneca')()
  2. seneca.add({role: 'math', cmd: 'sum'}, function (msg, respond) {
  3. var sum = msg.left + msg.right
  4. respond(null, {answer: sum})
  5. })
  6. // 下面两条消息都匹配 role: math, cmd: sum
  7. seneca.act({role: 'math', cmd: 'sum', left: 1.5, right: 2.5}, console.log)
  8. seneca.act({role: 'math', cmd: 'sum', left: 1.5, right: 2.5, integer: true}, console.log)
  9. setTimeout(() => {
  10. seneca.add({role: 'math', cmd: 'sum', integer: true}, function (msg, respond) {
  11. var sum = Math.floor(msg.left) + Math.floor(msg.right)
  12. respond(null, { answer: sum })
  13. })
  14. // 下面这条消息同样匹配 role: math, cmd: sum
  15. seneca.act({role: 'math', cmd: 'sum', left: 1.5, right: 2.5}, console.log)
  16. // 但是,也匹配 role:math,cmd:sum,integer:true
  17. // 但是因为更多属性被匹配到,所以,它的优先级更高
  18. seneca.act({role: 'math', cmd: 'sum', left: 1.5, right: 2.5, integer: true}, console.log)
  19. }, 100)

输出结果应该像下面这样:

  1. null { answer: 4 }
  2. null { answer: 4 }
  3. null { answer: 4 }
  4. null { answer: 3 }

在上面的代码中,因为系统中只存在 role: math, cmd: sum 模式,所以,都匹配到它,但是当 100ms 后,我们给系统中添加了一个 role: math, cmd: sum, integer: true 模式之后,结果就不一样了,匹配到更多的操作将有更高的优先级。

这种设计,可以让我们的系统可以更加简单的添加新的功能,不管是在开发环境还是在生产环境中,你都可以在不需要修改现有代码的前提下即可更新新的服务,你只需要先好新的服务,然后启动新服务即可。

Using patterns for code re-use

模式操作还可以调用其它的操作,所以,这样我们可以达到代码复用的需求:

  1. const seneca = require('seneca')()
  2. seneca.add('role: math, cmd: sum', function (msg, respond) {
  3. var sum = msg.left + msg.right
  4. respond(null, {answer: sum})
  5. })
  6. seneca.add('role: math, cmd: sum, integer: true', function (msg, respond) {
  7. // 复用 role:math, cmd:sum
  8. this.act({
  9. role: 'math',
  10. cmd: 'sum',
  11. left: Math.floor(msg.left),
  12. right: Math.floor(msg.right)
  13. }, respond)
  14. })
  15. // 匹配 role:math,cmd:sum
  16. seneca.act('role: math, cmd: sum, left: 1.5, right: 2.5',console.log)
  17. // 匹配 role:math,cmd:sum,integer:true
  18. seneca.act('role: math, cmd: sum, left: 1.5, right: 2.5, integer: true', console.log)

在上面的示例代码中,我们使用了 this.act 而不是前面的 seneca.act,那是因为,在 action 函数中,上下文关系变量 this ,引用了当前的 seneca 实例,这样你就可以在任何一个 action 函数中,访问到该 action 调用的整个上下文。

在上面的代码中,我们使用了 JSON 缩写形式来描述模式与消息, 比如,下面是对象字面量:

  1. {role: 'math', cmd: 'sum', left: 1.5, right: 2.5}

缩写模式为:

  1. 'role: math, cmd: sum, left: 1.5, right: 2.5'

jsonic 这种格式,提供了一种以字符串字面量来表达对象的简便方式,这使得我们可以创建更加简单的模式和消息。

Patterns are unique

你定义的 Action 模式都是唯一的,它们只能触发一个函数,模式的解析规则如下:

  • 更多属性优先级更高
  • 若模式具有相同的数量的属性,则按字母顺序匹配

规则被设计得很简单,这使得你可以更加简单的了解到到底是哪个模式被匹配了。

下面这些示例可以让你更容易理解:

  • a: 1, b: 2 优先于 a: 1, 因为它有更多的属性;
  • a: 1, b: 2 优先于 a: 1, c: 3,因为 b 在 c 字母的前面;
  • a: 1, b: 2, d: 4 优先于 a: 1, c: 3, d:4,因为 b 在 c 字母的前面;
  • a: 1, b:2, c:3 优先于 a:1, b: 2,因为它有更多的属性;
  • a: 1, b:2, c:3 优先于 a:1, c:3,因为它有更多的属性。

很多时候,提供一种可以让你不必完全重写现有 Action 函数的代码即可增加它功能的方法是很有必要的,比如,你可能想为某一个消息属性增加自定义验证的方法,捕获消息统计信息,向操作结果添加额外信息,或者限制消息流速率等。

我下面的示例代码中,加法操作期望 left 和 right 属性是有限数,此外,为了调试目的,将原始输入参数附加到输出的结果中也是很有用的,您可以使用以下代码添加验证检查和调试信息:

  1. const seneca = require('seneca')()
  2. seneca
  3. .add(
  4. 'role:math,cmd:sum',
  5. function(msg, respond) {
  6. var sum = msg.left + msg.right
  7. respond(null, {
  8. answer: sum
  9. })
  10. })
  11. // 重写 role:math,cmd:sum ,添加额外的功能
  12. .add(
  13. 'role:math,cmd:sum',
  14. function(msg, respond) {
  15. // bail out early if there's a problem
  16. if (!Number.isFinite(msg.left) ||
  17. !Number.isFinite(msg.right)) {
  18. return respond(new Error("left 与 right 值必须为数字。"))
  19. }
  20. // 调用上一个操作函数 role:math,cmd:sum
  21. this.prior({
  22. role: 'math',
  23. cmd: 'sum',
  24. left: msg.left,
  25. right: msg.right,
  26. }, function(err, result) {
  27. if (err) return respond(err)
  28. result.info = msg.left + '+' + msg.right
  29. respond(null, result)
  30. })
  31. })
  32. // enhanced role:math,cmd:sum
  33. .act('role:math,cmd:sum,left:1.5,right:2.5',
  34. console.log // 打印 { answer: 4, info: '1.5+2.5' }
  35. )

seneca 实例提供了一个名为 prior 的方法,可以在当前的 action 方法中,调用被其重写的旧 action 函数。

prior 函数接受两个参数:

  1. msg:消息体
  2. response_callback:回调函数

在上面的示例代码中,已经演示了如何修改入参与出参,修改这些参数与值是可选的,比如,可以再添加新的重写,以增加日志记录功能。

在上面的示例中,也同样演示了如何更好的进行错误处理,我们在真正进行操作之前,就验证的数据的正确性,若传入的参数本身就有错误,那么我们直接就返回错误信息,而不需要等待真正计算的时候由系统去报错了。

错误消息应该只被用于描述错误的输入或者内部失败信息等,比如,如果你执行了一些数据库的查询,返回没有任何数据,这并不是一个错误,而仅仅只是数据库的事实的反馈,但是如果连接数据库失败,那就是一个错误了。

Organising patterns into plugins

一个 seneca 实例,其实就只是多个 Action Pattern 的集合而已,你可以使用命名空间的方式来组织操作模式,例如在前面的示例中,我们都使用了 role: math,为了帮助日志记录和调试, Seneca 还支持一个简约的插件支持。

同样,Seneca 插件只是一组 Action Pattern 的集合,它可以有一个名称,用于注释日志记录条目,还可以给插件一组选项来控制它们的行为,插件还提供了以正确的顺序执行初始化函数的机制,例如,您希望在尝试从数据库读取数据之前建立数据库连接。

简单来说,Seneca 插件就只是一个具有单个参数选项的函数,你将这个插件的定义函数传递给 seneca.use 方法,下面这个是最小的 Seneca 插件(其实它什么也没做!):

  1. function minimal_plugin(options) {
  2. console.log(options)
  3. }
  4. require('seneca')()
  5. .use(minimal_plugin, {foo: 'bar'})

seneca.use 方法接受两个参数:

  1. plugin :插件定义函数或者一个插件名称;
  2. options :插件配置选项

上面的示例代码执行后,打印出来的日志看上去是这样的:

  1. { foo: 'bar' }
  2. {"kind":"plugin","case":"READY","name":"minimal_plugin","level":300,"isot":"2021-03-24T09:31:34.785Z","when":1616578294785,"level_name":"info","seneca_id":"f31zz4ykb4a9/1616578294710/12480/3.23.2/-","seneca_did":"yuqd","plugin_name":"minimal_plugin"}

Seneca 还提供了详细日志记录功能,可以为开发或者生产提供更多的日志信息,通常的,日志级别被设置为 INFO,它并不会打印太多日志信息,如果想看到所有的日志信息,试试以下面这样的方式启动你的服务:

  1. $ node minimal-plugin.js --seneca.log.all
  2. ... lots of log lines ...

当然,你还可以过滤日志信息:

  1. $ node minimal-plugin.js --seneca.log.all | grep plugin | grep DEFINE
  2. 2016... 3qf7... DEBUG plugin basic DEFINE {}
  3. 2016... 3qf7... DEBUG plugin transport DEFINE {}
  4. 2016... 3qf7... DEBUG plugin web DEFINE {}
  5. 2016... 3qf7... DEBUG plugin mem-store DEFINE {}
  6. 2016... 3qf7... DEBUG plugin minimal_plugin DEFINE {foo=bar}

通过日志我们可以看到, seneca 加载了很多内置的插件,比如 basic、transport、web 以及 mem-store,这些插件为我们提供了创建微服务的基础功能,同样,你应该也可以看到 minimal_plugin 插件。

现在,让我们为这个插件添加一些 Action Pattern:

  1. function math(options) {
  2. this.add('role:math,cmd:sum', function (msg, respond) {
  3. respond(null, { answer: msg.left + msg.right })
  4. })
  5. this.add('role:math,cmd:product', function (msg, respond) {
  6. respond(null, { answer: msg.left * msg.right })
  7. })
  8. }
  9. require('seneca')()
  10. .use(math)
  11. .act('role:math,cmd:sum,left:1,right:2', console.log)

运行后输出以下内容:

  1. $ node math-plugin.js
  2. 2016... INFO hello ...
  3. null { answer: 3 }

然后再看一下与 math 插件内容更多的日志信息:

  1. $ node math-plugin.js --seneca.log.all | grep math
  2. 2016... alqs... DEBUG delegate {plugin$={name=math,tag=undefined},ungate$=true,fatal$=true} 29ny56
  3. 2016... alqs... DEBUG register init math
  4. 2016... alqs... DEBUG plugin math DEFINE {}
  5. 2016... alqs... DEBUG plugin math ADD qlh13h47d0nu cmd:sum,role:math sum
  6. 2016... alqs... DEBUG plugin math ADD 10lk4seu3aee cmd:product,role:math product
  7. 2016... alqs... DEBUG plugin math options set {math={}}
  8. 2016... alqs... DEBUG act - - DEFAULT {init=math,tag=}
  9. 2016... alqs... DEBUG register ready math {}
  10. 2016... alqs... DEBUG register install math {exports=[]:}
  11. 2016... alqs... DEBUG act math - IN pg7er4ouia1p/u5hfgtpmkeoy cmd:sum,role:math {role=math,cmd=sum,left=1,right=2} ENTRY A;qlh13h47d0nu -
  12. 2016... alqs... DEBUG act math - OUT pg7er4ouia1p/u5hfgtpmkeoy cmd:sum,role:math {answer=3} EXIT A;qlh13h47d0nu 5

上面大部分日志信息都可以忽略, 只关注几行就可以了:math 插件添加 Action Pattern、执行 role:math, cmd:sum, left:1, right:2 的 Action 操作、显示入栈和出栈的消息:

  1. ...
  2. 20.. al.. DEBUG plugin math ADD ql.. cmd:sum,role:math sum
  3. 20.. al.. DEBUG plugin math ADD 10.. cmd:product,role:math product
  4. ...
  5. 20.. al.. DEBUG act math - IN pg.. cmd:sum,role:math {role=math,cmd=sum,left=1,right=2} ENTRY A;ql.. -
  6. 20.. al.. DEBUG act math - OUT pg.. cmd:sum,role:math {answer=3} EXIT A;ql.. 5
  7. ...

在 Seneca 的世界中,我们通过插件组织各种 Action Pattern 集合,这让日志与调试变得更简单,然后你还可以将多个插件合并成为各种微服务,在接下来的章节中,我们将创建一个 math 服务。

插件通常需要进行一些初始化的工作,比如连接数据库等,但是,你并不需要在插件的定义函数中去执行这些初始化,定义函数被设计为同步执行的,因为它的所有操作都是在定义一个插件,事实上,你不应该在定义函数中调用 seneca.act 方法,只调用 seneca.add 方法。

要初始化插件,你需要定义一个特殊的匹配模式 init: ,对于每一个插件,将按顺序调用此 Action Pattern,init 函数必须调用其 callback 函数,并且不能有错误发生,如果插件初始化失败,则 Seneca 会立即退出 Node 进程。所以的插件初始化工作都必须在任何操作执行之前完成。

为了演示初始化,让我们向 math 插件添加简单的自定义日志记录,当插件启动时,它打开一个日志文件,并将所有操作的日志写入文件,文件需要成功打开并且可写,如果这失败,微服务启动就应该失败。

  1. const fs = require('fs')
  2. function math(options) {
  3. // 日志记录函数,通过 init 函数创建
  4. var log
  5. // 将所有模式放在一起会上我们查找更方便
  6. this.add('role:math,cmd:sum', sum)
  7. this.add('role:math,cmd:product', product)
  8. // 这就是那个特殊的初始化操作
  9. this.add('init:math', init)
  10. function init(msg, respond) {
  11. // 将日志记录至一个特写的文件中
  12. fs.open(options.logfile, 'a', function (err, fd) {
  13. // 如果不能读取或者写入该文件,则返回错误,这会导致 Seneca 启动失败
  14. if (err) return respond(err)
  15. log = makeLog(fd)
  16. respond()
  17. })
  18. }
  19. function sum(msg, respond) {
  20. var out = { answer: msg.left + msg.right }
  21. log('sum '+msg.left+'+'+msg.right+'='+out.answer+'\n')
  22. respond(null, out)
  23. }
  24. function product(msg, respond) {
  25. var out = { answer: msg.left * msg.right }
  26. log('product '+msg.left+'*'+msg.right+'='+out.answer+'\n')
  27. respond(null, out)
  28. }
  29. function makeLog(fd) {
  30. return function (entry) {
  31. fs.write(fd, new Date().toISOString()+' '+entry, null, 'utf8', function (err) {
  32. if (err) return console.log(err)
  33. // 确保日志条目已刷新
  34. fs.fsync(fd, function (err) {
  35. if (err) return console.log(err)
  36. })
  37. })
  38. }
  39. }
  40. }
  41. require('seneca')()
  42. .use(math, {logfile:'./math.log'})
  43. .act('role:math,cmd:sum,left:1,right:2', console.log)

在上面这个插件的代码中,匹配模式被组织在插件的顶部,以便它们更容易被看到,函数在这些模式下面一点被定义,您还可以看到如何使用选项提供自定义日志文件的位置(不言而喻,这不是生产日志!)。

初始化函数 init 执行一些异步文件系统工作,因此必须在执行任何操作之前完成。 如果失败,整个服务将无法初始化。要查看失败时的操作,可以尝试将日志文件位置更改为无效的,例如 /math.log。

Writing microservices

现在让我们把 math 插件变成一个真正的微服务。首先,你需要组织你的插件。 math 插件的业务逻辑 —— 即它提供的功能,与它以何种方式与外部世界通信是分开 的,你可能会暴露一个Web服务,也有可能在消息总线上监听。

将业务逻辑(即插件定义)放在其自己的文件中是有意义的。 Node.js 模块即可完美的实现,创建一个名为 math.js 的文件,内容如下:

  1. module.exports = function math(options) {
  2. this.add('role:math,cmd:sum', function sum(msg, respond) {
  3. respond(null, { answer: msg.left + msg.right })
  4. })
  5. this.add('role:math,cmd:product', function product(msg, respond) {
  6. respond(null, { answer: msg.left * msg.right })
  7. })
  8. this.wrap('role:math', function (msg, respond) {
  9. msg.left = Number(msg.left).valueOf()
  10. msg.right = Number(msg.right).valueOf()
  11. this.prior(msg, respond)
  12. })
  13. }

然后,我们可以在需要引用它的文件中像下面这样添加到我们的微服务系统中:

  1. // 下面这两种方式都是等价的(还记得我们前面讲过的 `seneca.use` 方法的两个参数吗?)
  2. require('seneca')()
  3. .use(require('./math.js'))
  4. .act('role:math,cmd:sum,left:1,right:2', console.log)
  5. require('seneca')()
  6. .use('math') // 在当前目录下找到 `./math.js`
  7. .act('role:math,cmd:sum,left:1,right:2', console.log)

seneca.wrap 方法可以匹配一组模式,同时用相同的 Action 的扩展函数覆盖至所有被匹配的模式,这与为每一组 Pattern 手动调用 seneca.add 去扩展可以得到一样的效果,它需要两个参数:

  1. pin :pattern-matching 的模式
  2. action :扩展的 action 函数

pin 是一个可以匹配到多个模式的模式,它可以匹配到多个模式,比如 role:math 这个 pin 可以匹配到 role:math, cmd:sum 与 role:math, cmd:product。

在上面的示例中,我们在最后面的 wrap 函数中,确保了,任何传递给 role:math 的消息体中 left 与 right 值都是数字,即使我们传递了字符串,也可以被自动的转换为数字。

有时,查看 Seneca 实例中有哪些操作是被重写了是很有用的,你可以在启动应用时,加上 —seneca.print.tree 参数即可,我们先创建一个 math-tree.js 文件,填入以下内容:

  1. require('seneca')()
  2. .use('math')

然后再执行它:

  1. node math-tree.js --seneca.print.tree
  2. {"kind":"notice","notice":"hello seneca abs0eg4hu04h/1483589278500/65316/3.2.2/-","level":"info","when":1483589278522}
  3. (node:65316) DeprecationWarning: 'root' is deprecated, use 'global'
  4. Seneca action patterns for instance: abs0eg4hu04h/1483589278500/65316/3.2.2/-
  5. ├─┬ cmd:sum
  6. └─┬ role:math
  7. └── # math, (15fqzd54pnsp),
  8. # math, (qqrze3ub5vhl), sum
  9. └─┬ cmd:product
  10. └─┬ role:math
  11. └── # math, (qnh86mgin4r6),
  12. # math, (4nrxi5f6sp69), product

从上面你可以看到很多的键/值对,并且以树状结构展示了重写,所有的 Action 函数展示的格式都是 #plugin, (action-id), function-name。

但是,到现在为止,所有的操作都还存在于同一个进程中,接下来,让我们先创建一个名为 math-service.js 的文件,填入以下内容:

  1. require('seneca')()
  2. .use('math')
  3. .listen()

然后启动该脚本,即可启动我们的微服务,它会启动一个进程,并通过 10101 端口监听 HTTP 请求,它不是一个 Web 服务器,在此时, HTTP 仅仅作为消息的传输机制。

你现在可以访问 http://localhost:10101/act?role=math&cmd=sum&left=1&right=2 即可看到结果,或者使用 curl 命令:

  1. $ curl -d '{"role":"math","cmd":"sum","left":1,"right":2}' http://localhost:10101/act

两种方式都可以看到结果:

  1. {"answer":3}

接下来,你需要一个微服务客户端 math-client.js:

  1. require('seneca')()
  2. .client()
  3. .act('role:math,cmd:sum,left:1,right:2',console.log)

打开一个新的终端,执行该脚本:

  1. null { answer: 3 } { id: '7uuptvpf8iff/9wfb26kbqx55',
  2. accept: '043di4pxswq7/1483589685164/65429/3.2.2/-',
  3. track: undefined,
  4. time:
  5. { client_sent: '0',
  6. listen_recv: '0',
  7. listen_sent: '0',
  8. client_recv: 1483589898390 } }

在 Seneca 中,我们通过 seneca.listen 方法创建微服务,然后通过 seneca.client 去与微服务进行通信。在上面的示例中,我们使用的都是 Seneca 的默认配置,比如 HTTP 协议监听 10101 端口,但 seneca.listen 与 seneca.client 方法都可以接受下面这些参数,以达到定制的功能:

  1. port :可选的数字,表示端口号;
  2. host :可先的字符串,表示主机名或者IP地址;
  3. spec :可选的对象,完整的定制对象 :::info 注意:在 Windows 系统中,如果未指定 host, 默认会连接 0.0.0.0,这是没有任何用处的,你可以设置 host 为 localhost。 :::

只要 client 与 listen 的端口号与主机一致,它们就可以进行通信:

  • seneca.client(8080) → seneca.listen(8080)
  • seneca.client(8080, ‘192.168.0.2’) → seneca.listen(8080, ‘192.168.0.2’)
  • seneca.client({ port: 8080, host: ‘192.168.0.2’ }) → seneca.listen({ port: 8080, host: ‘192.168.0.2’ })

Seneca 为你提供的 transport independence 特性,让你在进行业务逻辑开发时,不需要知道消息如何传输或哪些服务会得到它们,而是在服务设置代码或配置中指定,比如 math.js 插件中的代码永远不需要改变,我们就可以任意的改变传输方式。

虽然 HTTP 协议很方便,但是并不是所有时候都合适,另一个常用的协议是 TCP,我们可以很容易的使用 TCP 协议来进行数据的传输,尝试下面这两个文件:

  1. math-service-tcp.js :

    1. require('seneca')()
    2. .use('math')
    3. .listen({type: 'tcp'})
  2. math-client-tcp.js

    1. require('seneca')()
    2. .client({type: 'tcp'})
    3. .act('role:math,cmd:sum,left:1,right:2',console.log)

    默认情况下, client/listen 并未指定哪些消息将发送至哪里,只是本地定义了模式的话,会发送至本地的模式中,否则会全部发送至服务器中,我们可以通过一些配置来定义哪些消息将发送到哪些服务中,你可以使用一个 pin 参数来做这件事情。

让我们来创建一个应用,它将通过 TCP 发送所有 role:math 消息至服务,而把其它的所有消息都在发送至本地:

  1. math-pin-service.js:

    1. require('seneca')()
    2. .use('math')
    3. // 监听 role:math 消息
    4. // 重要:必须匹配客户端
    5. .listen({ type: 'tcp', pin: 'role:math' })
  2. math-pin-client.js:

    1. require('seneca')()
    2. // 本地模式
    3. .add('say:hello', function (msg, respond){ respond(null, {text: "Hi!"}) })
    4. // 发送 role:math 模式至服务
    5. // 注意:必须匹配服务端
    6. .client({ type: 'tcp', pin: 'role:math' })
    7. // 远程操作
    8. .act('role:math,cmd:sum,left:1,right:2',console.log)
    9. // 本地操作
    10. .act('say:hello',console.log)

你可以通过各种过滤器来自定义日志的打印,以跟踪消息的流动,使用 —seneca… 参数,支持以下配置:

  • date-time: log 条目何时被创建;
  • seneca-id: Seneca process ID;
  • level:DEBUG、INFO、WARN、ERROR 以及 FATAL 中任何一个;
  • type:条目编码,比如 act、plugin 等;
  • plugin:插件名称,不是插件内的操作将表示为 root$;
  • case: 条目的事件:IN、ADD、OUT 等
  • action-id/transaction-id:跟踪标识符,在网络中永远保持一致;
  • pin:action 匹配模式;
  • message:入/出参消息体

如果你运行上面的进程,使用了 —seneca.log.all,则会打印出所有日志,如果你只想看 math 插件打印的日志,可以像下面这样启动服务:

  1. $ node math-pin-service.js --seneca.log=plugin:math

Web Server Integration

Seneca 不是一个 Web 框架。 但是,仍然需要将其连接到您的 Web 服务 API,你永远要记住的是,不要将你的内部行为模式暴露在外面,这不是一个好的安全的实践,相反的,你应该定义一组 API 模式,比如用属性 role:api,然后你可以将它们连接到你的内部微服务。

我们使用 Express 作为 Web 框架,建了 express-app.js 应用:

  1. var SenecaWeb = require('seneca-web')
  2. var Express = require('express')
  3. var Router = Express.Router
  4. var context = new Router()
  5. var senecaWebConfig = {
  6. context: context,
  7. adapter: require('seneca-web-adapter-express'),
  8. options: { parseBody: false } // so we can use body-parser
  9. }
  10. var app = Express()
  11. .use( require('body-parser').json() )
  12. .use( context )
  13. .listen(3000)
  14. var seneca = require('seneca')()
  15. .use(SenecaWeb, senecaWebConfig )
  16. .use('api')
  17. .client( { type:'tcp', pin:'role:math' } )

express-app.js 中创建了一个 seneca 实例,加载 api 插件,然后使用 seneca.client 发送所有匹配 role:math Pattern 的 act 到一个外部服务。而 express-app 就是一个 microservice client。

Seneca 与 Express 的整合发生在以下部分:

  1. .use(SenecaWeb, senecaWebConfig)

SenecaWeb 会把在 seneca.act(‘role:web, {routes: routes}) 中定义所有 route 附加到 context。

下面是我们定义 api.js 插件。

  1. module.exports = function api(options) {
  2. var valid_ops = { sum:'sum', product:'product' }
  3. this.add('role:api,path:calculate', function (msg, respond) {
  4. var operation = msg.args.params.operation
  5. var left = msg.args.query.left
  6. var right = msg.args.query.right
  7. this.act('role:math', {
  8. cmd: valid_ops[operation],
  9. left: left,
  10. right: right,
  11. }, respond)
  12. })
  13. this.add('init:api', function (msg, respond) {
  14. this.act('role:web',{routes:{
  15. prefix: '/api',
  16. pin: 'role:api,path:*',
  17. map: {
  18. calculate: { GET:true, suffix:'/:operation' }
  19. }
  20. }}, respond)
  21. })
  22. }

api 插件,将 role:api, path:calculate Pattern 通过 URL 方式进行暴露。入站信息必须有用来指定需要执行 sum 或 product 计算的属性 operation。

:::info 注意:永远不要使用外部输入创建操作的消息体,永远显式地在内部创建,这可以有效避免注入攻击。 :::

在上面的的初始化函数中,调用了一个 role:web 的模式操作,并且定义了一个 routes 属性,这将定义一个URL地址与操作模式的匹配规则,它有下面这些参数:

  1. prefix:URL 前缀
  2. pin: 需要映射的模式集
  3. map:要用作 URL Endpoint 的 pin 通配符属性列表

你的URL地址将开始于 /api/…。

rol:api, path:* 这个 pin 表示,映射任何有 role=’api’ 键值对,同时 path 属性被定义了的模式,在本例中,只有 role:api,path:calculate 符合该模式。

map 属性是一个对象,它有一个 calculate 属性,对应的URL地址开始于:/api/calculate。

按着, calculate 的值是一个对象,它表示了 HTTP 的 GET 方法是被允许的,并且 URL 应该有参数化的后缀(后缀就类于 express 的 route 规则中一样)。

所以,你的完整地址是 /api/calculate/{operation}。

然后,其它的消息属性都将从 URL query 对象或者 JSON body 中获得,在本示例中,因为使用的是 GET 方法,所以没有 body。

SenecaWeb 将会通过 msg.args 来描述一次请求,它包括:

  1. body:HTTP 请求的 payload 部分;
  2. query:请求的 querystring;
  3. params:请求的路径参数。

现在,启动前面我们创建的微服务:

  1. node math-pin-service.js --seneca.log=plugin:math

然后再启动我们的 express 应用:

  1. node express-app.js --seneca.log=plugin:web,plugin:api

访问下面的地址:

  1. http://localhost:3000/api/calculate/sum?left=2&right=3
  2. #输出:{"answer":5}
  3. http://localhost:3000/api/calculate/product?left=2&right=3
  4. #输出:{"answer":6}

Data Storage

一个真实的系统,肯定需要持久化数据,在 Seneca 中,你可以执行任何您喜欢的操作,使用任何类型的数据库层,但是,为什么不使用模式匹配和微服务的力量,使你的开发更轻松?

模式匹配还意味着你可以推迟有关微服务数据的争论,比如服务是否应该”拥有”数据,服务是否应该访问共享数据库等,模式匹配意味着你可以在随后的任何时间重新配置你的系统。

seneca-entity 提供了一个简单的数据抽象层(ORM),基于以下操作:

  1. load:根据实体标识加载一个实体;
  2. save:创建或更新(如果你提供了一个标识的话)一个实体;
  3. list:列出匹配查询条件的所有实体;
  4. remove:删除一个标识指定的实体。

它们的匹配模式分别是:

  1. load: role:entity,cmd:load,name:
  2. save: role:entity,cmd:save,name:
  3. list: role:entity,cmd:list,name:
  4. remove: role:entity,cmd:remove,name:

任何实现了这些模式的插件都可以被用于提供数据库(比如 MySQL)访问。

当数据的持久化与其它的一切都基于相同的机制提供时,微服务的开发将变得更容易,而这种机制,便是 pattern-matched messages。

由于直接使用数据持久性模式可能变得乏味,所以 seneca 实体还提供了一个更熟悉的 ActiveRecord 风格的接口,要创建记录对象,请调用 seneca.make 方法。 记录对象有方法 load$、save$、list$ 以及 remove$(所有方法都带有 $ 后缀,以防止与数据字段冲突),数据字段只是对象属性。

通过 npm 安装 seneca-entity, 然后在你的应用中使用 seneca.use() 方法加载至你的 seneca 实例。

现在让我们先创建一个简单的数据实体 book.js,它保存 book 的详情。

  1. const seneca = require('seneca')();
  2. seneca.use('entity');
  3. const book = seneca.make('book');
  4. book.title = 'Action in Seneca';
  5. book.price = 9.99;
  6. // 发送 role:entity,cmd:save,name:book 消息
  7. book.save$( console.log );

执行上面的代码之后,我们可以看到下面这样的日志:

  1. $ node book.js
  2. null $-/-/book;id=byo81d;{title:Action in Seneca,price:9.99}

Seneca 内置了数据持久化插件 mem-store,这使得我们在本示例中,不需要使用任何其它数据库的支持也能进行完整的数据库持久操作(它并不是真正的持久化,只是保存在内存中,但是对写单元测试很有用)。

由于不论选择哪种数据库,seneca 的数据操作都是使用的同样的消息操作模式,所以你可以非常简单的切换数据库,而不必在项目一开始就决定使用哪种数据库。比如,你可能在开发的过程中使用的是 MongoDB,而后,开发完成之后,在生产环境中使用 Postgres。

下面创建一个简单的线上书店,并将它集成到我们现有的微服务系统中。我们可以通过它,快速的添加新书、获取书的详细信息以及购买一本书:
创建一个简单的书店插件,book-store.js

  1. module.exports = function(options) {
  2. // 从数据库中,查询一本ID为 `msg.id` 的书,我们使用了 `load$` 方法
  3. this.add('role:store, get:book', function(msg, respond) {
  4. this.make('book').load$(msg.id, respond);
  5. });
  6. // 向数据库中添加一本书,书的数据为 `msg.data`,我们使用了 `data$` 方法
  7. this.add('role:store, add:book', function(msg, respond) {
  8. this.make('book').data$(msg.data).save$(respond);
  9. });
  10. // 创建一条新的支付订单(在真实的系统中,经常是由商品详情页中的 *购买* 按钮触
  11. // 发的事件),先是查询出ID为 `msg.id` 的书本,若查询出错,则直接返回错误,
  12. // 否则,将书本的信息复制给 `purchase` 实体,并保存该订单,然后,我们发送了
  13. // 一条 `role:store,info:purchase` 消息(但是,我们并不接收任何响应),
  14. // 这条消息只是通知整个系统,我们现在有一条新的订单产生了,但是我并不关心谁会
  15. // 需要它。
  16. this.add('role:store, cmd:purchase', function(msg, respond) {
  17. this.make('book').load$(msg.id, function(err, book) {
  18. if (err) return respond(err);
  19. this
  20. .make('purchase')
  21. .data$({
  22. when: Date.now(),
  23. bookId: book.id,
  24. title: book.title,
  25. price: book.price,
  26. })
  27. .save$(function(err, purchase) {
  28. if (err) return respond(err);
  29. this.act('role:store,info:purchase', {
  30. purchase: purchase
  31. });
  32. respond(null, purchase);
  33. });
  34. });
  35. });
  36. // 最后,我们实现了 `role:store, info:purchase` 模式,就只是简单的将信息
  37. // 打印出来, `seneca.log` 对象提供了 `debug`、`info`、`warn`、`error`、
  38. // `fatal` 方法用于打印相应级别的日志。
  39. this.add('role:store, info:purchase', function(msg, respond) {
  40. this.log.info('purchase', msg.purchase);
  41. respond();
  42. });
  43. };

接下来,我们可以创建一个简单的单元测试,以验证入站消息是否生成了正确的响应,boot-store-test.js:

  1. // 使用 Node 内置的 `assert` 模块
  2. const assert = require('assert')
  3. const seneca = require('seneca')()
  4. .use('entity')
  5. .use('book-store')
  6. .error(assert.fail)
  7. // 添加一本书
  8. addBook()
  9. function addBook() {
  10. seneca.act(
  11. 'role:store,add:book,data:{title:Action in Seneca,price:9.99}',
  12. function(err, savedBook) {
  13. this.act(
  14. 'role:store,get:book', {
  15. id: savedBook.id
  16. },
  17. function(err, loadedBook) {
  18. assert.equal(loadedBook.title, savedBook.title)
  19. purchase(loadedBook);
  20. }
  21. )
  22. }
  23. )
  24. }
  25. function purchase(book) {
  26. seneca.act(
  27. 'role:store,cmd:purchase', {
  28. id: book.id
  29. },
  30. function(err, purchase) {
  31. assert.equal(purchase.bookId, book.id)
  32. }
  33. )
  34. }

执行该测试:

  1. $ node book-store-test.js
  2. ["purchase",{"entity$":"-/-/purchase","when":1483607360925,"bookId":"a2mlev","title":"Action in Seneca","price":9.99,"id":"i28xoc"}]

在一个生产应用中,我们对于上面的订单数据,可能会有单独的服务进行监控,而不是像上面这样,只是打印一条日志出来,那么,我们现在来创建一个新的服务,用于收集订单数据,book-store-stats.js:

  1. const stats = {};
  2. require('seneca')()
  3. .add('role:store,info:purchase', function(msg, respond) {
  4. const id = msg.purchase.bookId;
  5. stats[id] = stats[id] || 0;
  6. stats[id]++;
  7. console.log(stats);
  8. respond();
  9. })
  10. .listen({
  11. port: 9003,
  12. host: 'localhost',
  13. pin: 'role:store,info:purchase'
  14. });

然后,更新 book-store-test.js 文件:

  1. const seneca = require('seneca')()
  2. .use('entity')
  3. .use('book-store')
  4. .client({port:9003,host: 'localhost', pin:'role:store,info:purchase'})
  5. .error(assert.fail);

此时,当有新的订单产生时,就会通知到订单监控服务了。

Bringing it Altogether

通过上面的所有步骤,我们现在已经有四个服务了:

  1. book-store-stats.js : 用于收集书店的订单信息;
  2. book-store-service.js :提供书店相关的功能;
  3. math-pin-service.js:提供一些数学相关的服务;
  4. app-all.js:Web 服务

book-store-stats 与 math-pin-service 我们已经有了,所以,直接启动即可:

  1. node math-pin-service.js --seneca.log.all
  2. node book-store-stats.js --seneca.log.all

现在,我们需要一个 book-store-service :

  1. require('seneca')()
  2. .use('entity')
  3. .use('book-store')
  4. .listen({
  5. port: 9002,
  6. host: 'localhost',
  7. pin: 'role:store'
  8. })
  9. .client({
  10. port: 9003,
  11. host: 'localhost',
  12. pin: 'role:store,info:purchase'
  13. });

该服务接收任何 role:store 消息,但同时又将任何 role:store,info:purchase 消息发送至网络,永远都要记住, client 与 listen 的 pin 配置必须完全一致