Node.js微服務實踐(二)

基於Seneca 和 PM2構建

本章主要分爲三個小節:node

  • 選擇Nodejs的理由:將證實選擇Node.js來構建的正確性。介紹使用Node.js時設計的軟件棧。
  • 微服務架構Seneca:關於Seneca 的基本知識。
  • PM2:PM2 是運行 Node.js 應用的最好選擇。

選着Node.js的理由

現在,Node.js 已經成爲國際上許多科技公司的首選方案。特別對於在服務器端須要費阻塞特性的場景,Node.js 儼然成了最好的選擇。git

本章咱們主要講Seneca 和 PM2 做爲構建、運行微服務的框架。雖然選擇了Seneca和PM2,但並不意味着其餘框架很差。github

業界還存在一些其餘被選方案,例如 restify或Express、Egg.js 可用於構建應用,forever或者nodemon可用於運行應用。而Seneca和PM2我以爲是構建微服務最佳的組合,主要緣由以下:web

  • 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 World\n');
});

server.listen(port, hostname, () => {
  console.log(`Server running at http://${hostname}:${port}/`);
});
複製代碼

上述代碼建立了一個服務端程序,並監聽 3000 端口。運行代碼後可在瀏覽器中輸入:http://127.0.0.1:3000,既可預覽到Hello Worldnpm

Node.js 的線程模型

Node.js 採用的是異步處理機制。這表示在處理較慢的事件時,好比讀取文件,Node.js 不會阻塞線程,而是繼續處理其餘事件,Noede.js 的控制流在讀取文件完畢時,會執行相應的方法來處理返回信息。json

以上一個小節代碼爲例,http.createServer 方法接受一個回調函數,這個回調函數將在接收一個HTTP請求時被執行。可是在等待HTTP請求同時,線程仍然能夠處理其餘事件。segmentfault

SOLID 設計原則

每當談論微服務,咱們總會說起模塊化,而模塊化歸結於如下設計原則:api

  • 單一職責原則
  • 開放封閉原則(對擴展開放、對修改關閉)
  • 里氏替換原則(若是使用的是一個父類的話, 那麼必定適用於其子類, 而察覺不出父類對象和子類對象的區別。 也便是說,把父類替換成它的子類, 行爲不會有變化。 簡單地說, 子類型必須可以替換掉它們的父類型。)
  • 接口分離原則
  • 依賴倒置原則(反轉控制和依賴注入)

你應該將代碼以模塊的形式進行組織。一個模塊應該是代碼的聚合,他負責簡單地處理某件事情,而且能夠處理的很好,例如操做字符串。可是請注意,你的模塊包含越多的函數(類、工具),它將越缺少內聚性,這是應該極力避免的。瀏覽器

在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})
})

複製代碼

在示例代碼中,操做計算經過消息對象的leftright屬性提供的兩個數字的總和。並不是全部消息都會生成結果,但因爲這是最多見的狀況,所以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 rpc_server.js --watch --ignore-watch="rpc_client.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 app_name|app_id
  • pm2 restart app_name|app_id
  • pm2 delete app_name|app_id
  • pm2 show app_name|app_id OR pm2 describe app_name|app_id
  • pm2 list
  • pm2 monit
  • pm2 logs app_name|app_id --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 的基礎知識,你能夠搭建一個面向微服務的系統。

參考

相關文章
相關標籤/搜索