Seneca :NodeJS 微服務框架入門指南

原文:http://onmr.com/press/getting-started-seneca.htmljavascript

Seneca 是一個能讓您快速構建基於消息的微服務系統的工具集,你不須要知道各類服務自己被部署在何處,不須要知道具體有多少服務存在,也不須要知道他們具體作什麼,任何你業務邏輯以外的服務(如數據庫、緩存或者第三方集成等)都被隱藏在微服務以後。html

這種解耦使您的系統易於連續構建與更新,Seneca 能作到這些,緣由在於它的三大核心功能:java

  1. 模式匹配:不一樣於脆弱的服務發現,模式匹配旨在告訴這個世界你真正關心的消息是什麼;
  2. 無依賴傳輸:你能夠以多種方式在服務之間發送消息,全部這些都隱藏至你的業務邏輯以後;
  3. 組件化:功能被表示爲一組能夠一塊兒組成微服務的插件。

在 Seneca 中,消息就是一個能夠有任何你喜歡的內部結構的 JSON 對象,它們能夠經過 HTTP/HTTPS、TCP、消息隊列、發佈/訂閱服務或者任何能傳輸數據的方式進行傳輸,而對於做爲消息生產者的你來說,你只須要將消息發送出去便可,徹底不須要關心哪些服務來接收它們。node

而後,你又想告訴這個世界,你想要接收一些消息,這也很簡單,你只需在 Seneca 中做一點匹配模式配置便可,匹配模式也很簡單,只是一個鍵值對的列表,這些鍵值對被用於匹配 JSON 消息的極組屬性。mysql

在本文接下來的內容中,咱們將一同基於 Seneca 構建一些微服務。git

模式( Patterns

讓咱們從一點特別簡單的代碼開始,咱們將建立兩個微服務,一個會進行數學計算,另外一個去調用它:github

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 中看到相似下面這樣的消息:web

{"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 }

到目前爲止,全部這一切都發生在同一個進程中,沒有網絡流量產生,進程內的函數調用也是基於消息傳輸。sql

seneca.add 方法,添加了一個新的動做模式(_Action Pattern_)至 Seneca 實例中,它有兩個參數:數據庫

  1. pattern :用於匹配 Seneca 實例中 JSON 消息體的模式;
  2. action :當模式被匹配時執行的操做

seneca.act 方法一樣有兩個參數:

  1. msg :做爲純對象提供的待匹配的入站消息;
  2. respond :用於接收並處理響應信息的回調函數。

讓咱們再把全部代碼從新過一次:

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

在上面的代碼中的 Action 函數,計算了匹配到的消息體中兩個屬性 leftright 的值的和,並非全部的消息都會被建立一個響應,可是在絕大多數狀況下,是須要有響應的, Seneca 提供了用於響應消息的回調函數。

在匹配模式中, role:math, cmd:sum 匹配到了下面這個消息體:

{
  role: 'math',
  cmd: 'sum',
  left: 1,
  right: 2
}

並獲得計自結果:

{
  answer: 3
}

關於 rolecmd 這兩個屬性,它們沒有什麼特別的,只是剛好被你用於匹配模式而已。

接着,seneca.act 方法,發送了一條消息,它有兩個參數:

  1. msg :發送的消息主體
  2. response_callback :若是該消息有任何響應,該回調函數都會被執行。

響應的回調函數可接收兩個參數: errorresult ,若是有任何錯誤發生(好比,發送出去的消息未被任何模式匹配),則第一個參數將是一個 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,由於 bc 字母的前面;
  • a: 1, b: 2, d: 4 優先於 a: 1, c: 3, d:4,由於 bc 字母的前面;
  • a: 1, b:2, c:3 優先於 a:1, b: 2,由於它有更多的屬性;
  • a: 1, b:2, c:3 優先於 a:1, c:3,由於它有更多的屬性。

不少時間,提供一種可讓你不須要全盤修改現有 Action 函數的代碼便可增長它功能的方法是頗有必要的,好比,你可能想爲某一個消息增長更多自定義的屬性驗證方法,捕獲消息統計信息,添加額外的數據庫結果中,或者控制消息流速等。

我下面的示例代碼中,加法操做指望 leftright 屬性是有限數,此外,爲了調試目的,將原始輸入參數附加到輸出的結果中也是頗有用的,您可使用如下代碼添加驗證檢查和調試信息:

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 函數接受兩個參數:

  1. msg:消息體
  2. 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 方法接受兩個參數:

  1. plugin :插件定義函數或者一個插件名稱;
  2. 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 加載了不少內置的插件,好比 basictransportweb 以及 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 去擴展能夠獲得同樣的效果,它須要兩個參數:

  1. pin :模式匹配模式
  2. action :擴展的 action 函數

pin 是一個能夠匹配到多個模式的模式,它能夠匹配到多個模式,好比 role:math 這個 pin 能夠匹配到 role:math, cmd:sumrole:math, cmd:product

在上面的示例中,咱們在最後面的 wrap 函數中,確保了,任何傳遞給 role:math 的消息體中 leftright 值都是數字,即便咱們傳遞了字符串,也能夠被自動的轉換爲數字。

有時,查看 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.listenseneca.client 方法均可以接受下面這些參數,以達到定抽的功能:

  • port :可選的數字,表示端口號;
  • host :可先的字符串,表示主機名或者IP地址;
  • spec :可選的對象,完整的定製對象
注意:在 Windows 系統中,若是未指定 host, 默認會鏈接 0.0.0.0,這是沒有任何用處的,你能夠設置 hostlocalhost

只要 clientlisten 的端口號與主機一致,它們就能夠進行通訊:

  • 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;
  • levelDEBUGINFOWARNERROR 以及 FATAL 中任何一個;
  • type:條目編碼,好比 actplugin 等;
  • plugin:插件名稱,不是插件內的操做將表示爲 root$
  • case: 條目的事件:INADDOUT
  • action-id/transaction-id:跟蹤標識符,_在網絡中永遠保持一致_;
  • pinaction 匹配模式;
  • 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 的值是一個對象,它表示了 HTTPGET 方法是被容許的,而且URL應該有參數化的後綴(後綴就類於 hapiroute 規則中同樣)。

因此,你的完整地址是 /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

訪問下面的地址:

數據持久化

一個真實的系統,確定須要持久化數據,在Seneca中,你能夠執行任何您喜歡的操做,使用任何類型的數據庫層,可是,爲何不使用模式匹配和微服務的力量,使你的開發更輕鬆?

模式匹配還意味着你能夠推遲有關微服務數據的爭論,好比服務是否應該"擁有"數據,服務是否應該訪問共享數據庫等,模式匹配意味着你能夠在隨後的任什麼時候間從新配置你的系統。

seneca-entity 提供了一個簡單的數據抽象層(ORM),基於如下操做:

  • load:根據實體標識加載一個實體;
  • save:建立或更新(若是你提供了一個標識的話)一個實體;
  • list:列出匹配查詢條件的全部實體;
  • remove:刪除一個標識指定的實體。

它們的匹配模式分別是:

  • loadrole:entity,cmd:load,name:<entity-name>
  • saverole:entity,cmd:save,name:<entity-name>
  • listrole:entity,cmd:list,name:<entity-name>
  • removerole: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

下面讓我他建立一個簡單的線上書店,咱們能夠經過它,快速的添加新書、獲取書的詳細信息以及購買一本書:

book-store.js

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();
  });
};

接下來,咱們能夠建立一個簡單的單元測試,以驗證咱們前面建立的程序:

boot-store-test.js

// 使用 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"}]

在一個生產應用中,咱們對於上面的訂單數據,可能會有單獨的服務進行監控,而不是像上面這樣,只是打印一條日誌出來,那麼,咱們如今來建立一個新的服務,用於收集訂單數據:

book-store-stats.js

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-statsmath-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 實例當作變量處處傳遞。
相關文章
相關標籤/搜索