基于Seneca 和 PM2建構
本章主要分為三個小節:
- 選擇Nodejs的理由:将證明選擇Node.js來建構的正确性。介紹使用Node.js時設計的軟體棧。
- 微服務架構Seneca:關于Seneca 的基本知識。
- PM2:PM2 是運作 Node.js 應用的最好選擇。
選着Node.js的理由
如今,Node.js 已經成為國際上許多科技公司的首選方案。特别對于在伺服器端需要費阻塞特性的場景,Node.js 俨然成了最好的選擇。
本章我們主要講Seneca 和 PM2 作為建構、運作微服務的架構。雖然選擇了Seneca和PM2,但并不意味着其他架構不好。
業界還存在一些其他被選方案,例如 restify或Express、Egg.js 可用于建構應用,forever或者nodemon可用于運作應用。而Seneca和PM2我覺得是建構微服務最佳的組合,主要原因如下:
- PM2 在應用部署方面有着異常的強大功能。
- Seneca 不僅僅是一個建構服務的架構,它還是個範例,能夠重塑我們對于面向對象軟體的認識。
第一個程式 --- Hello World
Node.js 中最興奮的理念之一就是簡單。隻要熟悉 JavaScript,你就可以在幾天内學會Node.js。用Node.js編寫的代碼要比使用其他語言編寫的代碼更加簡短:
const http = require('http');
const hostname = '127.0.0.1';
const port = 3000;
const server = http.createServer((req, res) => {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain');
res.end('Hello Worldn');
});
server.listen(port, hostname, () => {
console.log(`Server running at http://${hostname}:${port}/`);
});
上述代碼建立了一個服務端程式,并監聽 3000 端口。運作代碼後可在浏覽器中輸入:http://127.0.0.1:3000,既可預覽到
HelloWorld
。
Node.js 的線程模型
Node.js 采用的是異步處理機制。這表示在處理較慢的事件時,比如讀取檔案,Node.js 不會阻塞線程,而是繼續處理其他事件,Noede.js 的控制流在讀取檔案完畢時,會執行相應的方法來處理傳回資訊。
以上一個小節代碼為例,
http.createServer
方法接受一個回調函數,這個回調函數将在接收一個HTTP請求時被執行。但是在等待HTTP請求同時,線程仍然可以處理其他事件。
SOLID 設計原則
每當談論微服務,我們總會提及子產品化,而子產品化歸結于以下設計原則:
- 單一職責原則
- 開放封閉原則(對擴充開放、對修改關閉)
- 裡氏替換原則(如果使用的是一個父類的話, 那麼一定适用于其子類, 而察覺不出父類對象和子類對象的差別。也即是說,把父類替換成它的子類, 行為不會有變化。簡單地說, 子類型必須能夠替換掉它們的父類型。)
- 接口分離原則
- 依賴倒置原則(反轉控制和依賴注入)
你應該将代碼以子產品的形式進行組織。一個子產品應該是代碼的聚合,他負責簡單地處理某件事情,并且可以處理的很好,例如操作字元串。但是請注意,你的子產品包含越多的函數(類、工具),它将越缺乏内聚性,這是應該極力避免的。
在Node.js中,每個JavaScript檔案預設是一個子產品。當然,也可以使用檔案夾的形式組織子產品,但是我們現在隻關注的使用檔案的形式:
function contains(a, b) {
return a.indexOf(b) > -1;
}
function stringToOrdinal(str) {
let result = '';
for (let i = 0, len = str.length; i < len; i++) {
result += charToNuber(str[i]);
}
return result;
}
function charToNuber(char) {
return char.charCodeAt(0) - 96;
}
module.exports = {
contains,
stringToOrdinal
}
以上代碼是一個有效的Node.js子產品。這三個子產品有三個函數,其中兩個作為共有函數暴露外部子產品使用。
如果想使用這個子產品隻需要
require()
函數,如下所示:
const stringManipulation = request('./string-manipulation');
console.log(stringManipulation.stringToOrdinal('aabb'));
輸出結果是
1122
。
結合 SOLID原則,回顧一下我們的子產品。
- 單一設計原則: 子產品隻處理字元串。
- 開放封閉原則(對擴充開放,對修改關閉): 可以為子產品添加更多的函數,那些已有的正确函數可以用于構模組化塊中的新函數,同時,我們不對公用代碼進行修改。
- 裡氏替換原則: 跳過這個原則,因為該子產品的結構并沒有展現這一原則。
- 接口分離原則: JavaScript 與 Java、C#不同,他不是一門純面向接口的語言。但是本子產品确實暴露了接口。通過
變量将共有函數的接口暴露給調用者,這樣具體實作的修改并不會影響到使用者的代碼編寫。module.exports
- 依賴倒置: 這是失敗的地方,雖然不是徹底失敗,但也足以是我們必須重新考量所使用的方法。
微服務架構 Seneca
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);
});
目前,這一切都發生在同一個過程中,沒有網絡流量。程序内函數調用也是一種消息傳輸!
該
seneca.add
方法将新的操作模式添加到Seneca執行個體。它有兩個參數:
- pattern:要在Seneca執行個體接收的任何JSON消息中比對的屬性模式。
- action:模式比對消息時要執行的函數。
動作功能有兩個參數:
- msg:比對的入站消息(作為普通對象提供)。
- respond:一個回調函數,用于提供對消息的響應。
響應函數是帶有标準error, result簽名的回調函數。
讓我們再把這一切放在一起:
seneca.add({role: 'math', cmd: 'sum'}, function (msg, respond) {
var sum = msg.left + msg.right
respond(null, {answer: sum})
})
在示例代碼中,操作計算通過消息對象的
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對象。如果一切按計劃進行,則第二個參數是結果對象。在示例代碼中,這些參數隻是列印到控制台:
seneca.act({role: 'math', cmd: 'sum', left: 1, right: 2}, function (err, result) {
if (err) return console.error(err)
console.log(result)
})
sum.js檔案中的示例代碼向您展示了如何在同一個Node.js程序中定義和調用操作模式。您很快就會看到如何在多個程序中拆分此代碼。
比對模式如何工作?
模式 - 與網絡位址或主題相對 - 使擴充和增強系統變得更加容易。他們通過逐漸添加新的微服務來實作這一點。
讓我們的系統增加兩個數相乘的能力。
我們希望看起來像這樣的消息:
{role: 'math', cmd: 'product', left: 3, right: 4}
産生這樣的結果:
{answer: 12}
您可以使用
role:math,cmd:sum
操作模式作為模闆來定義新
role:math,cmd:product
操作:
seneca.add({role: 'math', cmd: 'product'}, function (msg, respond) {
var product = msg.left * msg.right
respond(null, { answer: product })
})
你可以用完全相同的方式調用它:
seneca.act({role: 'math', cmd: 'product', left: 3, right: 4}, console.log)
在這裡,您可以使用console.log快捷方式列印錯誤(如果有)和結果。運作此代碼會産生:
{answer: 12}
把這一切放在一起,你得到:
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)
.act({role: 'math', cmd: 'product', left: 3, right: 4}, console.log)
在上面的代碼示例中,seneca.act調用連結在一起。Seneca提供連結API作為友善。連結的調用按順序執行,但不是按順序執行,是以它們的結果可以按任何順序傳回。
擴充模式以增加新功能
模式使您可以輕松擴充功能。您隻需添加更多模式,而不是添加if語句和複雜邏輯。
讓我們通過添加強制整數運算的能力來擴充加法動作。為此,您需要向消息對象添加一個新屬性
integer:true
。然後,為具有此屬性的郵件提供新操作:
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, as decimals removed
如果将兩種模式添加到同一系統會發生什麼?Seneca如何選擇使用哪一個?更具體的模式總是赢。換句話說,具有最多比對屬性的模式具有優先權。
這裡有一些代碼來說明這一點:
var 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)
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)
它産生的輸出是:
2016 ... INFO hello ...
null { answer: 4 }
null { answer: 4 }
null { answer: 4 }
null { answer: 3 }
前兩個.act調用都比對
role:math,cmd:sum
動作模式。接下來,代碼定義僅整數動作模式
role:math,cmd:sum,integer:true
。在那之後,第三次調用.act與
role:math,cmd:sum
行動一緻,但第四次調用
role:math,cmd:sum,integer:true
。此代碼還示範了您可以連結.add和.act調用。此代碼在sum-integer.js檔案中可用。
通過比對更具體的消息類型,輕松擴充操作行為的能力是處理新的和不斷變化的需求的簡單方法。這既适用于您的項目正在開發中,也适用于實時項目且需要适應的項目。它還具有您不需要修改現有代碼的優點。添加新代碼來處理特殊情況會更安全。在生産系統中,您甚至不需要重新部署。您現有的服務可以保持原樣運作。您需要做的就是啟動新服務。
基于模式的代碼複用
動作模式可以調用其他動作模式來完成它們的工作。讓我們修改我們的示例代碼以使用此方法:
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) {
// 複用 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)
在此版本的代碼中
,role:math,cmd:sum,integer:true
操作模式的定義使用先前定義的
role:math,cmd:sum
操作模式。但是,它首先修改消息以将left和right屬性轉換為整數。
在action函數内部,context變量this是對目前Seneca執行個體的引用。這是在行動中引用Seneca的正确方法,因為您獲得了目前動作調用的完整上下文。這使您的日志更具資訊性等。
此代碼使用縮寫形式的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: ,對于每一個插件,将按順序調用此操作模式,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 協定來進行資料的傳輸,嘗試下面這兩個檔案:
math-service-tcp.js :
require('seneca')()
.use('math')
.listen({type: 'tcp'})
math-client-tcp.js
require('seneca')()
.client({type: 'tcp'})
.act('role:math,cmd:sum,left:1,right:2',console.log)
預設情況下, client/listen 并未指定哪些消息将發送至哪裡,隻是本地定義了模式的話,會發送至本地的模式中,否則會全部發送至伺服器中,我們可以通過一些配置來定義哪些消息将發送到哪些服務中,你可以使用一個 pin 參數來做這件事情。
讓我們來建立一個應用,它将通過 TCP 發送所有 role:math 消息至服務,而把其它的所有消息都在發送至本地:
math-pin-service.js:
require('seneca')()
.use('math')
// 監聽 role:math 消息
// 重要:必須比對用戶端
.listen({ type: 'tcp', pin: 'role:math' })
math-pin-client.js:
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}
PM2:node服務部署(服務叢集)、管理與監控
啟動
pm2 start app.js
- -w --watch:監聽目錄變化,如變化則自動重新開機應用
- --ignore-file:監聽目錄變化時忽略的檔案。如pm2 start rpcserver.js --watch --ignore-watch="rpcclient.js"
- -n --name:設定應用名字,可用于區分應用
- -i --instances:設定應用執行個體個數,0與max相同
- -f --force:強制啟動某應用,常常用于有相同應用在運作的情況
- -o --output :标準輸出日志檔案的路徑
- -e --error :錯誤輸出日志檔案的路徑
- --env :配置環境變量
如
pm2 start rpc_server.js-w-i max-n s1--ignore-watch="rpc_client.js"-e./server_error.log-o./server_info.log
在cluster-mode,也就是-i max下,日志檔案會自動在後面追加-${index}保證不重複
其他簡單且常用指令
- pm2 stop appname|appid
- pm2 restart appname|appid
- pm2 delete appname|appid
- pm2 show appname|appid OR pm2 describe appname|appid
- pm2 list
- pm2 monit
- pm2 logs appname|appid --lines --err
Graceful Stop
pm2 stop app_name|app_id
process.on('SIGINT', () => {
logger.warn('SIGINT')
connection && connection.close()
process.exit(0)
})
當程序結束前,程式會攔截SIGINT信号進而在程序即将被殺掉前去斷開資料庫連接配接等等占用記憶體的操作後再執行process.exit()進而優雅的退出程序。(如在1.6s後程序還未結束則繼續發送SIGKILL信号強制程序結束)
Process File
ecosystem.config.js
const appCfg = {
args: '',
max_memory_restart: '150M',
env: {
NODE_ENV: 'development'
},
env_production: {
NODE_ENV: 'production'
},
// source map
source_map_support: true,
// 不合并日志輸出,用于叢集服務
merge_logs: false,
// 常用于啟動應用時異常,逾時時間限制
listen_timeout: 5000,
// 程序SIGINT指令時間限制,即程序必須在監聽到SIGINT信号後必須在以下設定時間結束程序
kill_timeout: 2000,
// 當啟動異常後不嘗試重新開機,運維人員嘗試找原因後重試
autorestart: false,
// 不允許以相同腳本啟動程序
force: false,
// 在Keymetrics dashboard中執行pull/upgrade操作後執行的指令隊列
post_update: ['npm install'],
// 監聽檔案變化
watch: false,
// 忽略監聽檔案變化
ignore_watch: ['node_modules']
}
function GeneratePM2AppConfig({ name = '', script = '', error_file = '',
out_file = '', exec_mode = 'fork', instances = 1, args = "" }) {
if (name) {
return Object.assign({
name,
script: script || `${name}.js`,
error_file: error_file || `${name}-err.log`,
out_file: out_file|| `${name}-out.log`,
instances,
exec_mode: instances > 1 ? 'cluster' : 'fork',
args
}, appCfg)
} else {
return null
}
}
module.exports = {
apps: [
GeneratePM2AppConfig({
name: 'client',
script: './rpc_client.js'
}),
GeneratePM2AppConfig({
name: 'server',
script: './rpc_server.js',
instances: 1
})
]
}
pm2 start ecosystem.config.js
避坑指南:processFile檔案命名建議為*.config.js格式。否則後果自負。
小結
在本章中,你掌握了Seneca 和 PM2 的基礎知識,你可以搭建一個面向微服務的系統。
參考
- senecajs:http://senecajs.org
- 《Node.js微服務》(美)David Gonzalez(大衛 岡薩雷斯) 著
- senecajs 快速開始文檔:http://senecajs.org/getting-started/
- Seneca :NodeJS 微服務架構入門指南:https://segmentfault.com/a/1190000008501410#articleHeader7