Seneca :NodeJS 微服務框架入門指南
Seneca 是一個能讓您快速構建基于消息的微服務系統的工具集,你不需要知道各種服務本身被部署在何處,不需要知道具體有多少服務存在,也不需要知道他們具體做什么,任何你業務邏輯之外的服務(如數據庫、緩存或者第三方集成等)都被隱藏在微服務之后。
這種解耦使您的系統易于連續構建與更新,Seneca 能做到這些,原因在于它的三大核心功能:
-
模式匹配:不同于脆弱的服務發現,模式匹配旨在告訴這個世界你真正關心的消息是什么;
-
無依賴傳輸:你可以以多種方式在服務之間發送消息,所有這些都隱藏至你的業務邏輯之后;
-
組件化:功能被表示為一組可以一起組成微服務的插件。
在 Seneca 中,消息就是一個可以有任何你喜歡的內部結構的 JSON 對象,它們可以通過 HTTP/HTTPS、TCP、消息隊列、發布/訂閱服務或者任何能傳輸數據的方式進行傳輸,而對于作為消息生產者的你來講,你只需要將消息發送出去即可,完全不需要關心哪些服務來接收它們。
然后,你又想告訴這個世界,你想要接收一些消息,這也很簡單,你只需在 Seneca 中作一點匹配模式配置即可,匹配模式也很簡單,只是一個鍵值對的列表,這些鍵值對被用于匹配 JSON 消息的極組屬性。
在本文接下來的內容中,我們將一同基于 Seneca 構建一些微服務。
模式( Patterns )
讓我們從一點特別簡單的代碼開始,我們將創建兩個微服務,一個會進行數學計算,另一個去調用它:
const seneca = require('seneca')();
seneca.add('role:math, cmd:sum', (msg, reply) => {
reply(null, { answer: ( msg.left + msg.right )})
});
seneca.act({
role: 'math',
cmd: 'sum',
left: 1,
right: 2
}, (err, result) => {
if (err) {
return console.error(err);
}
console.log(result);
});
將上面的代碼,保存至一個 js 文件中,然后執行它,你可能會在 console 中看到類似下面這樣的消息:
{"kind":"notice","notice":"hello seneca 4y8daxnikuxp/1483577040151/58922/3.2.2/-","level":"info","when":1483577040175}
(node:58922) DeprecationWarning: 'root' is deprecated, use 'global'
{ answer: 3 }
到目前為止,所有這一切都發生在同一個進程中,沒有網絡流量產生,進程內的函數調用也是基于消息傳輸。
seneca.add 方法,添加了一個新的動作模式(_Action Pattern_)至 Seneca 實例中,它有兩個參數:
-
pattern :用于匹配 Seneca 實例中 JSON 消息體的模式;
-
action :當模式被匹配時執行的操作
seneca.act 方法同樣有兩個參數:
msg
respond
讓我們再把所有代碼重新過一次:
seneca.add('role:math, cmd:sum', (msg, reply) => {
reply(null, { answer: ( msg.left + msg.right )})
});
在上面的代碼中的 Action 函數,計算了匹配到的消息體中兩個屬性 left 與 right 的值的和,并不是所有的消息都會被創建一個響應,但是在絕大多數情況下,是需要有響應的, Seneca 提供了用于響應消息的回調函數。
在匹配模式中, role:math, cmd:sum 匹配到了下面這個消息體:
{
role: 'math',
cmd: 'sum',
left: 1,
right: 2
}
并得到計自結果:
{
answer: 3
}
關于 role 與 cmd 這兩個屬性,它們沒有什么特別的,只是恰好被你用于匹配模式而已。
接著, seneca.act 方法,發送了一條消息,它有兩個參數:
msg
response_callback
響應的回調函數可接收兩個參數: error 與 result ,如果有任何錯誤發生(比如,發送出去的消息未被任何模式匹配),則第一個參數將是一個 Error 對象,而如果程序按照我們所預期的方向執行了的話,那么,第二個參數將接收到響應結果,在我們的示例中,我們只是簡單的將接收到的響應結果打印至了 console 而已。
seneca.act({
role: 'math',
cmd: 'sum',
left: 1,
right: 2
}, (err, result) => {
if (err) {
return console.error(err);
}
console.log(result);
});
sum.js 示例文件,向你展示了如何定義并創建一個 Action 以及如何呼起一個 Action,但它們都發生在一個進程中,接下來,我們很快就會展示如何拆分成不同的代碼和多個進程。
匹配模式如何工作?
模式----而不是網絡地址或者會話,讓你可以更加容易的擴展或增強您的系統,這樣做,讓添加新的微服務變得更簡單。
現在讓我們給系統再添加一個新的功能----計算兩個數字的乘積。
我們想要發送的消息看起來像下面這樣的:
{
role: 'math',
cmd: 'product',
left: 3,
right: 4
}
而后獲得的結果看起來像下面這樣的:
{
answer: 12
}
知道怎么做了吧?你可以像 role: math, cmd: sum 模式這樣,創建一個 role: math, cmd: product 操作:
seneca.add('role:math, cmd:product', (msg, reply) => {
reply(null, { answer: ( msg.left * msg.right )})
});
然后,調用該操作:
seneca.act({
role: 'math',
cmd: 'product',
left: 3,
right: 4
}, (err, result) => {
if (err) {
return console.error(err);
}
console.log(result);
});
運行 product.js ,你將得到你想要的結果。
將這兩個方法放在一起,代碼像是下面這樣的:
const seneca = require('seneca')();
seneca.add('role:math, cmd:sum', (msg, reply) => {
reply(null, { answer: ( msg.left + msg.right )})
});
seneca.add('role:math, cmd:product', (msg, reply) => {
reply(null, { answer: ( msg.left * msg.right )})
});
seneca.act({role: 'math', cmd: 'sum', left: 1, right: 2}, console.log)
.act({role: 'math', cmd: 'product', left: 3, right: 4}, console.log)
運行 sum-product.js 后,你將得到下面這樣的結果:
null { answer: 3 }
null { answer: 12 }
在上面合并到一起的代碼中,我們發現, seneca.act 是可以進行鏈式調用的, Seneca 提供了一個鏈式API,調式調用是順序執行的,但是不是串行,所以,返回的結果的順序可能與調用順序并不一樣。
擴展模式以增加新功能
模式讓你可以更加容易的擴展程序的功能,與 if...else... 語法不同的是,你可以通過增加更多的匹配模式以達到同樣的功能。
下面讓我們擴展一下 role: math, cmd: sum 操作,它只接收整型數字,那么,怎么做?
seneca.add({role: 'math', cmd: 'sum', integer: true}, function (msg, respond) {
var sum = Math.floor(msg.left) + Math.floor(msg.right)
respond(null, {answer: sum})
})
現在,下面這條消息:
{role: 'math', cmd: 'sum', left: 1.5, right: 2.5, integer: true}
將得到下面這樣的結果:
{answer: 3} // == 1 + 2,小數部分已經被移除了
代碼可在 sum-integer.js 中查看。
現在,你的兩個模式都存在于系統中了,而且還存在交叉部分,那么 Seneca 最終會將消息匹配至哪條模式呢?原則是:更多匹配項目被匹配到的優先,被匹配到的屬性越多,則優先級越高。
pattern-priority-testing.js 可以給我們更加直觀的測試:
const seneca = require('seneca')()
seneca.add({role: 'math', cmd: 'sum'}, function (msg, respond) {
var sum = msg.left + msg.right
respond(null, {answer: sum})
})
// 下面兩條消息都匹配 role: math, cmd: sum
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)
setTimeout(() => {
seneca.add({role: 'math', cmd: 'sum', integer: true}, function (msg, respond) {
var sum = Math.floor(msg.left) + Math.floor(msg.right)
respond(null, { answer: sum })
})
// 下面這條消息同樣匹配 role: math, cmd: sum
seneca.act({role: 'math', cmd: 'sum', left: 1.5, right: 2.5}, console.log)
// 但是,也匹配 role:math,cmd:sum,integer:true
// 但是因為更多屬性被匹配到,所以,它的優先級更高
seneca.act({role: 'math', cmd: 'sum', left: 1.5, right: 2.5, integer: true}, console.log)
}, 100)
輸出結果應該像下面這樣:
null { answer: 4 }
null { answer: 4 }
null { answer: 4 }
null { answer: 3 }
在上面的代碼中,因為系統中只存在 role: math, cmd: sum 模式,所以,都匹配到它,但是當 100ms 后,我們給系統中添加了一個 role: math, cmd: sum, integer: true 模式之后,結果就不一樣了,匹配到更多的操作將有更高的優先級。
這種設計,可以讓我們的系統可以更加簡單的添加新的功能,不管是在開發環境還是在生產環境中,你都可以在不需要修改現有代碼的前提下即可更新新的服務,你只需要先好新的服務,然后啟動新服務即可。
基于模式的代碼復用
模式操作還可以調用其它的操作,所以,這樣我們可以達到代碼復用的需求:
const 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) {
// 復用 role:math, cmd:sum
this.act({
role: 'math',
cmd: 'sum',
left: Math.floor(msg.left),
right: Math.floor(msg.right)
}, respond)
})
// 匹配 role:math,cmd:sum
seneca.act('role: math, cmd: sum, left: 1.5, right: 2.5',console.log)
// 匹配 role:math,cmd:sum,integer:true
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 縮寫形式來描述模式與消息, 比如,下面是對象字面量:
{role: 'math', cmd: 'sum', left: 1.5, right: 2.5}
縮寫模式為:
'role: math, cmd: sum, left: 1.5, right: 2.5'
jsonic 這種格式,提供了一種以字符串字面量來表達對象的簡便方式,這使得我們可以創建更加簡單的模式和消息。
上面的代碼保存在了 sum-reuse.js 文件中。
模式是唯一的
你定義的 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 屬性是有限數,此外,為了調試目的,將原始輸入參數附加到輸出的結果中也是很有用的,您可以使用以下代碼添加驗證檢查和調試信息:
const seneca = require('seneca')()
seneca
.add(
'role:math,cmd:sum',
function(msg, respond) {
var sum = msg.left + msg.right
respond(null, {
answer: sum
})
})
// 重寫 role:math,cmd:sum with ,添加額外的功能
.add(
'role:math,cmd:sum',
function(msg, respond) {
// bail out early if there's a problem
if (!Number.isFinite(msg.left) ||
!Number.isFinite(msg.right)) {
return respond(new Error("left 與 right 值必須為數字。"))
}
// 調用上一個操作函數 role:math,cmd:sum
this.prior({
role: 'math',
cmd: 'sum',
left: msg.left,
right: msg.right,
}, function(err, result) {
if (err) return respond(err)
result.info = msg.left + '+' + msg.right
respond(null, result)
})
})
// 增加了的 role:math,cmd:sum
.act('role:math,cmd:sum,left:1.5,right:2.5',
console.log // 打印 { answer: 4, info: '1.5+2.5' }
)
seneca 實例提供了一個名為 prior 的方法,讓可以在當前的 action 方法中,調用被其重寫的舊操作函數。
prior 函數接受兩個參數:
msg
response_callback
在上面的示例代碼中,已經演示了如何修改入參與出參,修改這些參數與值是可選的,比如,可以再添加新的重寫,以增加日志記錄功能。
在上面的示例中,也同樣演示了如何更好的進行錯誤處理,我們在真正進行操作之前,就驗證的數據的正確性,若傳入的參數本身就有錯誤,那么我們直接就返回錯誤信息,而不需要等待真正計算的時候由系統去報錯了。
錯誤消息應該只被用于描述錯誤的輸入或者內部失敗信息等,比如,如果你執行了一些數據庫的查詢,返回沒有任何數據,這并不是一個錯誤,而僅僅只是數據庫的事實的反饋,但是如果連接數據庫失敗,那就是一個錯誤了。
上面的代碼可以在 sum-valid.js 文件中找到。
使用插件組織模式
一個 seneca 實例,其實就只是多個 Action Patterm 的集合而已,你可以使用命名空間的方式來組織操作模式,例如在前面的示例中,我們都使用了 role: math ,為了幫助日志記錄和調試, Seneca 還支持一個簡約的插件支持。
同樣,Seneca插件只是一組操作模式的集合,它可以有一個名稱,用于注釋日志記錄條目,還可以給插件一組選項來控制它們的行為,插件還提供了以正確的順序執行初始化函數的機制,例如,您希望在嘗試從數據庫讀取數據之前建立數據庫連接。
簡單來說,Seneca插件就只是一個具有單個參數選項的函數,你將這個插件定義函數傳遞給 seneca.use 方法,下面這個是最小的Seneca插件(其實它什么也沒做!):
function minimal_plugin(options) {
console.log(options)
}
require('seneca')()
.use(minimal_plugin, {foo: 'bar'})
seneca.use 方法接受兩個參數:
plugin
options
上面的示例代碼執行后,打印出來的日志看上去是這樣的:
{"kind":"notice","notice":"hello seneca 3qk0ij5t2bta/1483584697034/62768/3.2.2/-","level":"info","when":1483584697057}
(node:62768) DeprecationWarning: 'root' is deprecated, use 'global'
{ foo: 'bar' }
Seneca 還提供了詳細日志記錄功能,可以提供為開發或者生產提供更多的日志信息,通常的,日志級別被設置為 INFO ,它并不會打印太多日志信息,如果想看到所有的日志信息,試試以下面這樣的方式啟動你的服務:
node minimal-plugin.js --seneca.log.all
會不會被嚇一跳?當然,你還可以過濾日志信息:
node minimal-plugin.js --seneca.log.all | grep plugin:define
通過日志我們可以看到, seneca 加載了很多內置的插件,比如 basic 、 transport 、 web 以及 mem-store ,這些插件為我們提供了創建微服務的基礎功能,同樣,你應該也可以看到 minimal_plugin 插件。
現在,讓我們為這個插件添加一些操作模式:
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)
運行 math-plugin.js 文件,得到下面這樣的信息:
null { answer: 3 }
看打印出來的一條日志:
{
"actid": "7ubgm65mcnfl/uatuklury90r",
"msg": {
"role": "math",
"cmd": "sum",
"left": 1,
"right": 2,
"meta$": {
"id": "7ubgm65mcnfl/uatuklury90r",
"tx": "uatuklury90r",
"pattern": "cmd:sum,role:math",
"action": "(bjx5u38uwyse)",
"plugin_name": "math",
"plugin_tag": "-",
"prior": {
"chain": [],
"entry": true,
"depth": 0
},
"start": 1483587274794,
"sync": true
},
"plugin$": {
"name": "math",
"tag": "-"
},
"tx$": "uatuklury90r"
},
"entry": true,
"prior": [],
"meta": {
"plugin_name": "math",
"plugin_tag": "-",
"plugin_fullname": "math",
"raw": {
"role": "math",
"cmd": "sum"
},
"sub": false,
"client": false,
"args": {
"role": "math",
"cmd": "sum"
},
"rules": {},
"id": "(bjx5u38uwyse)",
"pattern": "cmd:sum,role:math",
"msgcanon": {
"cmd": "sum",
"role": "math"
},
"priorpath": ""
},
"client": false,
"listen": false,
"transport": {},
"kind": "act",
"case": "OUT",
"duration": 35,
"result": {
"answer": 3
},
"level": "debug",
"plugin_name": "math",
"plugin_tag": "-",
"pattern": "cmd:sum,role:math",
"when": 1483587274829
}
所有的該插件的日志都被自動的添加了 plugin 屬性。
在 Seneca 的世界中,我們通過插件組織各種操作模式集合,這讓日志與調試變得更簡單,然后你還可以將多個插件合并成為各種微服務,在接下來的章節中,我們將創建一個 math 服務。
插件通過需要進行一些初始化的工作,比如連接數據庫等,但是,你并不需要在插件的定義函數中去執行這些初始化,定義函數被設計為同步執行的,因為它的所有操作都是在定義一個插件,事實上,你不應該在定義函數中調用 seneca.act 方法,只調用 seneca.add 方法。
要初始化插件,你需要定義一個特殊的匹配模式 init: <plugin-name> ,對于每一個插件,將按順序調用此操作模式, init 函數必須調用其 callback 函數,并且不能有錯誤發生,如果插件初始化失敗,則 Seneca 會立即退出 Node 進程。所以的插件初始化工作都必須在任何操作執行之前完成。
為了演示初始化,讓我們向 math 插件添加簡單的自定義日志記錄,當插件啟動時,它打開一個日志文件,并將所有操作的日志寫入文件,文件需要成功打開并且可寫,如果這失敗,微服務啟動就應該失敗。
const fs = require('fs')
function math(options) {
// 日志記錄函數,通過 init 函數創建
var log
// 將所有模式放在一起會上我們查找更方便
this.add('role:math,cmd:sum', sum)
this.add('role:math,cmd:product', product)
// 這就是那個特殊的初始化操作
this.add('init:math', init)
function init(msg, respond) {
// 將日志記錄至一個特寫的文件中
fs.open(options.logfile, 'a', function (err, fd) {
// 如果不能讀取或者寫入該文件,則返回錯誤,這會導致 Seneca 啟動失敗
if (err) return respond(err)
log = makeLog(fd)
respond()
})
}
function sum(msg, respond) {
var out = { answer: msg.left + msg.right }
log('sum '+msg.left+'+'+msg.right+'='+out.answer+'\n')
respond(null, out)
}
function product(msg, respond) {
var out = { answer: msg.left * msg.right }
log('product '+msg.left+'*'+msg.right+'='+out.answer+'\n')
respond(null, out)
}
function makeLog(fd) {
return function (entry) {
fs.write(fd, new Date().toISOString()+' '+entry, null, 'utf8', function (err) {
if (err) return console.log(err)
// 確保日志條目已刷新
fs.fsync(fd, function (err) {
if (err) return console.log(err)
})
})
}
}
}
require('seneca')()
.use(math, {logfile:'./math.log'})
.act('role:math,cmd:sum,left:1,right:2', console.log)
在上面這個插件的代碼中,匹配模式被組織在插件的頂部,以便它們更容易被看到,函數在這些模式下面一點被定義,您還可以看到如何使用選項提供自定義日志文件的位置(不言而喻,這不是生產日志!)。
初始化函數 init 執行一些異步文件系統工作,因此必須在執行任何操作之前完成。 如果失敗,整個服務將無法初始化。要查看失敗時的操作,可以嘗試將日志文件位置更改為無效的,例如 /math.log 。
以上代碼可以在 math-plugin-init.js 文件中找到。
創建微服務
現在讓我們把 math 插件變成一個真正的微服務。首先,你需要組織你的插件。 math 插件的業務邏輯 ---- 即它提供的功能,與它以何種方式與外部世界通信是分開的,你可能會暴露一個Web服務,也有可能在消息總線上監聽。
將業務邏輯(即插件定義)放在其自己的文件中是有意義的。 Node.js 模塊即可完美的實現,創建一個名為 math.js 的文件,內容如下:
module.exports = function math(options) {
this.add('role:math,cmd:sum', function sum(msg, respond) {
respond(null, { answer: msg.left + msg.right })
})
this.add('role:math,cmd:product', function product(msg, respond) {
respond(null, { answer: msg.left * msg.right })
})
this.wrap('role:math', function (msg, respond) {
msg.left = Number(msg.left).valueOf()
msg.right = Number(msg.right).valueOf()
this.prior(msg, respond)
})
}
然后,我們可以在需要引用它的文件中像下面這樣添加到我們的微服務系統中:
// 下面這兩種方式都是等價的(還記得我們前面講過的 `seneca.use` 方法的兩個參數嗎?)
require('seneca')()
.use(require('./math.js'))
.act('role:math,cmd:sum,left:1,right:2', console.log)
require('seneca')()
.use('math') // 在當前目錄下找到 `./math.js`
.act('role:math,cmd:sum,left:1,right:2', console.log)
seneca.wrap 方法可以匹配一組模式,同使用相同的動作擴展函數覆蓋至所有被匹配的模式,這與為每一個組模式手動調用 seneca.add 去擴展可以得到一樣的效果,它需要兩個參數:
-
pin :模式匹配模式
-
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 文件,填入以下內容:
require('seneca')()
.use('math')
然后再執行它:
? node math-tree.js --seneca.print.tree
{"kind":"notice","notice":"hello seneca abs0eg4hu04h/1483589278500/65316/3.2.2/-","level":"info","when":1483589278522}
(node:65316) DeprecationWarning: 'root' is deprecated, use 'global'
Seneca action patterns for instance: abs0eg4hu04h/1483589278500/65316/3.2.2/-
├─┬ cmd:sum
│ └─┬ role:math
│ └── # math, (15fqzd54pnsp),
│ # math, (qqrze3ub5vhl), sum
└─┬ cmd:product
└─┬ role:math
└── # math, (qnh86mgin4r6),
# math, (4nrxi5f6sp69), product
從上面你可以看到很多的鍵/值對,并且以樹狀結構展示了重寫,所有的 Action 函數展示的格式都是 #plugin, (action-id), function-name 。
但是,到現在為止,所有的操作都還存在于同一個進程中,接下來,讓我們先創建一個名為 math-service.js 的文件,填入以下內容:
require('seneca')()
.use('math')
.listen()
然后啟動該腳本,即可啟動我們的微服務,它會啟動一個進程,并通過 10101 端口監聽HTTP請求,它不是一個 Web 服務器,在此時, HTTP 僅僅作為消息的傳輸機制。
你現在可以訪問 http://localhost:10101/act?ro... 即可看到結果,或者使用 curl 命令:
curl -d '{"role":"math","cmd":"sum","left":1,"right":2}' http://localhost:10101/act
兩種方式都可以看到結果:
{"answer":3}
接下來,你需要一個微服務客戶端 math-client.js :
require('seneca')()
.client()
.act('role:math,cmd:sum,left:1,right:2',console.log)
打開一個新的終端,執行該腳本:
null { answer: 3 } { id: '7uuptvpf8iff/9wfb26kbqx55',
accept: '043di4pxswq7/1483589685164/65429/3.2.2/-',
track: undefined,
time:
{ client_sent: '0',
listen_recv: '0',
listen_sent: '0',
client_recv: 1483589898390 } }
在 Seneca 中,我們通過 seneca.listen 方法創建微服務,然后通過 seneca.client 去與微服務進行通信。在上面的示例中,我們使用的都是 Seneca 的默認配置,比如 HTTP 協議監聽 10101 端口,但 seneca.listen 與 seneca.client 方法都可以接受下面這些參數,以達到定抽的功能:
-
port :可選的數字,表示端口號;
-
host :可先的字符串,表示主機名或者IP地址;
-
spec :可選的對象,完整的定制對象
注意:在 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 為你提供的 無依賴傳輸 特性,讓你在進行業務邏輯開發時,不需要知道消息如何傳輸或哪些服務會得到它們,而是在服務設置代碼或配置中指定,比如 math.js 插件中的代碼永遠不需要改變,我們就可以任意的改變傳輸方式。
雖然 HTTP 協議很方便,但是并不是所有時間都合適,另一個常用的協議是 TCP ,我們可以很容易的使用 TCP 協議來進行數據的傳輸,嘗試下面這兩個文件:
require('seneca')()
.use('math')
.listen({type: 'tcp'})
require('seneca')()
.client({type: 'tcp'})
.act('role:math,cmd:sum,left:1,right:2',console.log)
默認情況下, client/listen 并未指定哪些消息將發送至哪里,只是本地定義了模式的話,會發送至本地的模式中,否則會全部發送至服務器中,我們可以通過一些配置來定義哪些消息將發送到哪些服務中,你可以使用一個 pin 參數來做這件事情。
讓我們來創建一個應用,它將通過 TCP 發送所有 role:math 消息至服務,而把其它的所有消息都在發送至本地:
require('seneca')()
.use('math')
// 監聽 role:math 消息
// 重要:必須匹配客戶端
.listen({ type: 'tcp', pin: 'role:math' })
require('seneca')()
// 本地模式
.add('say:hello', function (msg, respond){ respond(null, {text: "Hi!"}) })
// 發送 role:math 模式至服務
// 注意:必須匹配服務端
.client({ type: 'tcp', pin: 'role:math' })
// 遠程操作
.act('role:math,cmd:sum,left:1,right:2',console.log)
// 本地操作
.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 插件打印的日志,可以像下面這樣啟動服務:
node math-pin-service.js --seneca.log=plugin:math
Web 服務集成
Seneca不是一個Web框架。 但是,您仍然需要將其連接到您的Web服務API,你永遠要記住的是,不要將你的內部行為模式暴露在外面,這不是一個好的安全的實踐,相反的,你應該定義一組API模式,比如用屬性 role:api ,然后你可以將它們連接到你的內部微服務。
下面是我們定義 api.js 插件。
module.exports = function api(options) {
var validOps = { sum:'sum', product:'product' }
this.add('role:api,path:calculate', function (msg, respond) {
var operation = msg.args.params.operation
var left = msg.args.query.left
var right = msg.args.query.right
this.act('role:math', {
cmd: validOps[operation],
left: left,
right: right,
}, respond)
})
this.add('init:api', function (msg, respond) {
this.act('role:web',{routes:{
prefix: '/api',
pin: 'role:api,path:*',
map: {
calculate: { GET:true, suffix:'/{operation}' }
}
}}, respond)
})
}
然后,我們使用 hapi 作為Web框架,建了 hapi-app.js 應用:
const Hapi = require('hapi');
const Seneca = require('seneca');
const SenecaWeb = require('seneca-web');
const config = {
adapter: require('seneca-web-adapter-hapi'),
context: (() => {
const server = new Hapi.Server();
server.connection({
port: 3000
});
server.route({
path: '/routes',
method: 'get',
handler: (request, reply) => {
const routes = server.table()[0].table.map(route => {
return {
path: route.path,
method: route.method.toUpperCase(),
description: route.settings.description,
tags: route.settings.tags,
vhost: route.settings.vhost,
cors: route.settings.cors,
jsonp: route.settings.jsonp,
}
})
reply(routes)
}
});
return server;
})()
};
const seneca = Seneca()
.use(SenecaWeb, config)
.use('math')
.use('api')
.ready(() => {
const server = seneca.export('web/context')();
server.start(() => {
server.log('server started on: ' + server.info.uri);
});
});
啟動 hapi-app.js 之后,訪問 http://localhost:3000/routes ,你便可以看到下面這樣的信息:
[
{
"path": "/routes",
"method": "GET",
"cors": false
},
{
"path": "/api/calculate/{operation}",
"method": "GET",
"cors": false
}
]
這表示,我們已經成功的將模式匹配更新至 hapi 應用的路由中。訪問 http://localhost:3000/api/cal... ,將得到結果:
{"answer":3}
在上面的示例中,我們直接將 math 插件也加載到了 seneca 實例中,其實我們可以更加合理的進行這種操作,如 hapi-app-client.js 文件所示:
...
const seneca = Seneca()
.use(SenecaWeb, config)
.use('api')
.client({type: 'tcp', pin: 'role:math'})
.ready(() => {
const server = seneca.export('web/context')();
server.start(() => {
server.log('server started on: ' + server.info.uri);
});
});
我們不注冊 math 插件,而是使用 client 方法,將 role:math 發送給 math-pin-service.js 的服務,并且使用的是 tcp 連接,沒錯,你的微服務就是這樣成型了。
注意:永遠不要使用外部輸入創建操作的消息體,永遠顯示地在內部創建,這可以有效避免注入攻擊。
在上面的的初始化函數中,調用了一個 role:web 的模式操作,并且定義了一個 routes 屬性,這將定義一個URL地址與操作模式的匹配規則,它有下面這些參數:
-
prefix :URL 前綴
-
pin : 需要映射的模式集
-
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應該有參數化的后綴(后綴就類于 hapi 的 route 規則中一樣)。
所以,你的完整地址是 /api/calculate/{operation} 。
然后,其它的消息屬性都將從 URL query 對象或者 JSON body 中獲得,在本示例中,因為使用的是 GET 方法,所以沒有 body。
SenecaWeb 將會通過 msg.args 來描述一次請求,它包括:
-
body :HTTP 請求的 payload 部分;
-
query :請求的 querystring ;
-
params :請求的路徑參數。
現在,啟動前面我們創建的微服務:
node math-pin-service.js --seneca.log=plugin:math
然后再啟動我們的應用:
node hapi-app.js --seneca.log=plugin:web,plugin:api
訪問下面的地址:
-
http://localhost:3000/api/cal... 得到 {"answer":6}
-
http://localhost:3000/api/cal... 得到 {"answer":5}
數據持久化
一個真實的系統,肯定需要持久化數據,在Seneca中,你可以執行任何您喜歡的操作,使用任何類型的數據庫層,但是,為什么不使用模式匹配和微服務的力量,使你的開發更輕松?
模式匹配還意味著你可以推遲有關微服務數據的爭論,比如服務是否應該"擁有"數據,服務是否應該訪問共享數據庫等,模式匹配意味著你可以在隨后的任何時間重新配置你的系統。
seneca-entity 提供了一個簡單的數據抽象層(ORM),基于以下操作:
-
load :根據實體標識加載一個實體;
-
save :創建或更新(如果你提供了一個標識的話)一個實體;
-
list :列出匹配查詢條件的所有實體;
-
remove :刪除一個標識指定的實體。
它們的匹配模式分別是:
-
load : role:entity,cmd:load,name:<entity-name>
-
save : role:entity,cmd:save,name:<entity-name>
-
list : role:entity,cmd:list,name:<entity-name>
-
remove : role:entity,cmd:remove,name:<entity-name>
任何實現了這些模式的插件都可以被用于提供數據庫(比如 MySQL )訪問。
當數據的持久化與其它的一切都基于相同的機制提供時,微服務的開發將變得更容易,而這種機制,便是模式匹配消息。
由于直接使用數據持久性模式可能變得乏味,所以 seneca 實體還提供了一個更熟悉的 ActiveRecord 風格的接口,要創建記錄對象,請調用 seneca.make 方法。 記錄對象有方法 load$ 、 save$ 、 list$ 以及 remove$ (所有方法都帶有 $ 后綴,以防止與數據字段沖突),數據字段只是對象屬性。
通過 npm 安裝 seneca-entity , 然后在你的應用中使用 seneca.use() 方法加載至你的 seneca 實例。
現在讓我們先創建一個簡單的數據實體,它保存 book 的詳情。
文件 book.js
const seneca = require('seneca')();
seneca.use('basic').use('entity');
const book = seneca.make('book');
book.title = 'Action in Seneca';
book.price = 9.99;
// 發送 role:entity,cmd:save,name:book 消息
book.save$( console.log );
在上面的示例中,我們還使用了 seneca-basic ,它是 seneca-entity 依賴的插件。
執行上面的代碼之后,我們可以看到下面這樣的日志:
? node book.js
null $-/-/book;id=byo81d;{title:Action in Seneca,price:9.99}
Seneca 內置了 mem-store ,這使得我們在本示例中,不需要使用任何其它數據庫的支持也能進行完整的數據庫持久操作(雖然,它并不是真正的持久化了)。
由于數據的持久化永遠都是使用的同樣的消息模式集,所以,你可以非常簡單的交互數據庫,比如,你可能在開發的過程中使用的是 MongoDB ,而后,開發完成之后,在生產環境中使用 Postgres 。
下面讓我他創建一個簡單的線上書店,我們可以通過它,快速的添加新書、獲取書的詳細信息以及購買一本書:
module.exports = function(options) {
// 從數據庫中,查詢一本ID為 `msg.id` 的書,我們使用了 `load$` 方法
this.add('role:store, get:book', function(msg, respond) {
this.make('book').load$(msg.id, respond);
});
// 向數據庫中添加一本書,書的數據為 `msg.data`,我們使用了 `data$` 方法
this.add('role:store, add:book', function(msg, respond) {
this.make('book').data$(msg.data).save$(respond);
});
// 創建一條新的支付訂單(在真實的系統中,經常是由商品詳情布中的 *購買* 按鈕觸
// 發的事件),先是查詢出ID為 `msg.id` 的書本,若查詢出錯,則直接返回錯誤,
// 否則,將書本的信息復制給 `purchase` 實體,并保存該訂單,然后,我們發送了
// 一條 `role:store,info:purchase` 消息(但是,我們并不接收任何響應),
// 這條消息只是通知整個系統,我們現在有一條新的訂單產生了,但是我并不關心誰會
// 需要它。
this.add('role:store, cmd:purchase', function(msg, respond) {
this.make('book').load$(msg.id, function(err, book) {
if (err) return respond(err);
this
.make('purchase')
.data$({
when: Date.now(),
bookId: book.id,
title: book.title,
price: book.price,
})
.save$(function(err, purchase) {
if (err) return respond(err);
this.act('role:store,info:purchase', {
purchase: purchase
});
respond(null, purchase);
});
});
});
// 最后,我們實現了 `role:store, info:purchase` 模式,就只是簡單的將信息
// 打印出來, `seneca.log` 對象提供了 `debug`、`info`、`warn`、`error`、
// `fatal` 方法用于打印相應級別的日志。
this.add('role:store, info:purchase', function(msg, respond) {
this.log.info('purchase', msg.purchase);
respond();
});
};
接下來,我們可以創建一個簡單的單元測試,以驗證我們前面創建的程序:
// 使用 Node 內置的 `assert` 模塊
const assert = require('assert')
const seneca = require('seneca')()
.use('basic')
.use('entity')
.use('book-store')
.error(assert.fail)
// 添加一本書
addBook()
function addBook() {
seneca.act(
'role:store,add:book,data:{title:Action in Seneca,price:9.99}',
function(err, savedBook) {
this.act(
'role:store,get:book', {
id: savedBook.id
},
function(err, loadedBook) {
assert.equal(loadedBook.title, savedBook.title)
purchase(loadedBook);
}
)
}
)
}
function purchase(book) {
seneca.act(
'role:store,cmd:purchase', {
id: book.id
},
function(err, purchase) {
assert.equal(purchase.bookId, book.id)
}
)
}
執行該測試:
? node book-store-test.js
["purchase",{"entity$":"-/-/purchase","when":1483607360925,"bookId":"a2mlev","title":"Action in Seneca","price":9.99,"id":"i28xoc"}]
在一個生產應用中,我們對于上面的訂單數據,可能會有單獨的服務進行監控,而不是像上面這樣,只是打印一條日志出來,那么,我們現在來創建一個新的服務,用于收集訂單數據:
const stats = {};
require('seneca')()
.add('role:store,info:purchase', function(msg, respond) {
const id = msg.purchase.bookId;
stats[id] = stats[id] || 0;
stats[id]++;
console.log(stats);
respond();
})
.listen({
port: 9003,
host: 'localhost',
pin: 'role:store,info:purchase'
});
然后,更新 book-store-test.js 文件:
const seneca = require('seneca')()
.use('basic')
.use('entity')
.use('book-store')
.client({port:9003,host: 'localhost', pin:'role:store,info:purchase'})
.error(assert.fail);
此時,當有新的訂單產生時,就會通知到訂單監控服務了。
將所有服務集成到一起
通過上面的所有步驟,我們現在已經有四個服務了:
-
book-store-stats.js : 用于收集書店的訂單信息;
-
book-store-service.js :提供書店相關的功能;
-
math-pin-service.js :提供一些數學相關的服務;
-
app-all.js :Web 服務
book-store-stats 與 math-pin-service 我們已經有了,所以,直接啟動即可:
node math-pin-service.js --seneca.log.all
node book-store-stats.js --seneca.log.all
現在,我們需要一個 book-store-service :
require('seneca')()
.use('basic')
.use('entity')
.use('book-store')
.listen({
port: 9002,
host: 'localhost',
pin: 'role:store'
})
.client({
port: 9003,
host: 'localhost',
pin: 'role:store,info:purchase'
});
該服務接收任何 role:store 消息,但同時又將任何 role:store,info:purchase 消息發送至網絡, 永遠都要記住, client 與 listen 的 pin 配置必須完全一致 。
現在,我們可以啟動該服務:
node book-store-service.js --seneca.log.all
然后,創建我們的 app-all.js ,首選,復制 api.js 文件到 api-all.js ,這是我們的API。
module.exports = function api(options) {
var validOps = {
sum: 'sum',
product: 'product'
}
this.add('role:api,path:calculate', function(msg, respond) {
var operation = msg.args.params.operation
var left = msg.args.query.left
var right = msg.args.query.right
this.act('role:math', {
cmd: validOps[operation],
left: left,
right: right,
}, respond)
});
this.add('role:api,path:store', function(msg, respond) {
let id = null;
if (msg.args.query.id) id = msg.args.query.id;
if (msg.args.body.id) id = msg.args.body.id;
const operation = msg.args.params.operation;
const storeMsg = {
role: 'store',
id: id
};
if ('get' === operation) storeMsg.get = 'book';
if ('purchase' === operation) storeMsg.cmd = 'purchase';
this.act(storeMsg, respond);
});
this.add('init:api', function(msg, respond) {
this.act('role:web', {
routes: {
prefix: '/api',
pin: 'role:api,path:*',
map: {
calculate: {
GET: true,
suffix: '/{operation}'
},
store: {
GET: true,
POST: true,
suffix: '/{operation}'
}
}
}
}, respond)
})
}
最后, app-all.js :
const Hapi = require('hapi');
const Seneca = require('seneca');
const SenecaWeb = require('seneca-web');
const config = {
adapter: require('seneca-web-adapter-hapi'),
context: (() => {
const server = new Hapi.Server();
server.connection({
port: 3000
});
server.route({
path: '/routes',
method: 'get',
handler: (request, reply) => {
const routes = server.table()[0].table.map(route => {
return {
path: route.path,
method: route.method.toUpperCase(),
description: route.settings.description,
tags: route.settings.tags,
vhost: route.settings.vhost,
cors: route.settings.cors,
jsonp: route.settings.jsonp,
}
})
reply(routes)
}
});
return server;
})()
};
const seneca = Seneca()
.use(SenecaWeb, config)
.use('basic')
.use('entity')
.use('math')
.use('api-all')
.client({
type: 'tcp',
pin: 'role:math'
})
.client({
port: 9002,
host: 'localhost',
pin: 'role:store'
})
.ready(() => {
const server = seneca.export('web/context')();
server.start(() => {
server.log('server started on: ' + server.info.uri);
});
});
// 創建一本示例書籍
seneca.act(
'role:store,add:book', {
data: {
title: 'Action in Seneca',
price: 9.99
}
},
console.log
)
啟動該服務:
node app-all.js --seneca.log.all
從控制臺我們可以看到下面這樣的消息:
null $-/-/book;id=0r7mg7;{title:Action in Seneca,price:9.99}
這表示成功創建了一本ID為 0r7mg7 的書籍,現在,我們訪問 http://localhost:3000/api/store/get?id=0r7mg7 即可查看該ID的書籍詳情(ID是隨機的,所以,你生成的ID可能并不是這樣的)。
http://localhost:3000/routes 可以查看所有的路由。
然后我們可創建一個新的購買訂單:
curl -d '{"id":"0r7mg7"}' -H "content-type:application/json" http://localhost:3000/api/store/purchase
{"when":1483609872715,"bookId":"0r7mg7","title":"Action in Seneca","price":9.99,"id":"8suhf4"}
訪問 http://localhost:3000/api/calculate/sum?left=2&right=3 可以得到 {"answer":5} 。
最佳 Seneca 應用結構實踐
推薦你這樣做
-
將業務邏輯與執行分開,放在單獨的插件中,比如不同的Node模塊、不同的項目甚至同一個項目下不同的文件都是可以的;
-
使用執行腳本撰寫您的應用程序,不要害怕為不同的上下文使用不同的腳本,它們看上去應該很短,比如像下面這樣:
var SOME_CONFIG = process.env.SOME_CONFIG || 'some-default-value' require('seneca')({ some_options: 123 }) // 已存在的 Seneca 插件 .use('community-plugin-0') .use('community-plugin-1', {some_config: SOME_CONFIG}) .use('community-plugin-2') // 業務邏輯插件 .use('project-plugin-module') .use('../plugin-repository') .use('./lib/local-plugin') .listen( ... ) .client( ... ) .ready( function() { // 當 Seneca 啟動成功之后的自定義腳本 })
-
插件加載順序很重要,這當然是一件好事,可以主上你對消息的成有絕對的控制權。
不推薦你這樣做
-
將 Seneca 應用的啟動與初始化同其它框架的啟動與初始化放在一起了,永遠記住,保持事務的簡單;
-
將 Seneca 實例當做變量到處傳遞。
來自:https://segmentfault.com/a/1190000008501410