1. 程式人生 > >Seneca :NodeJS 微服務框架入門指南

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

Seneca 是一個能讓您快速構建基於訊息的微服務系統的工具集,你不需要知道各種服務本身被部署在何處,不需要知道具體有多少服務存在,也不需要知道他們具體做什麼,任何你業務邏輯之外的服務(如資料庫、快取或者第三方整合等)都被隱藏在微服務之後。

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

  1. 模式匹配:不同於脆弱的服務發現,模式匹配旨在告訴這個世界你真正關心的訊息是什麼;

  2. 無依賴傳輸:你可以以多種方式在服務之間傳送訊息,所有這些都隱藏至你的業務邏輯之後;

  3. 元件化:功能被表示為一組可以一起組成微服務的外掛。

在 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 例項中,它有兩個引數:

  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 函式,計算了匹配到的訊息體中兩個屬性 left 與 right 的值的和,並不是所有的訊息都會被建立一個響應,但是在絕大多數情況下,是需要有響應的, Seneca 提供了用於響應訊息的回撥函式。

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

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

並得到計自結果:

{
  answer: 3
}

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

接著,seneca.act 方法,傳送了一條訊息,它有兩個引數:

  1. msg :傳送的訊息主體

  2. 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);
});

將這兩個方法放在一起,程式碼像是下面這樣的:

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)
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,小數部分已經被移除了

現在,你的兩個模式都存在於系統中了,而且還存在交叉部分,那麼 Seneca 最終會將訊息匹配至哪條模式呢?原則是:更多匹配專案被匹配到的優先,被匹配到的屬性越多,則優先順序越高。

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 這種格式,提供了一種以字串字面量來表達物件的簡便方式,這使得我們可以建立更加簡單的模式和訊息。

模式是唯一的

你定義的 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 函式接受兩個引數:

  1. msg:訊息體

  2. response_callback:回撥函式

在上面的示例程式碼中,已經演示瞭如何修改入參與出參,修改這些引數與值是可選的,比如,可以再新增新的重寫,以增加日誌記錄功能。

在上面的示例中,也同樣演示瞭如何更好的進行錯誤處理,我們在真正進行操作之前,就驗證的資料的正確性,若傳入的引數本身就有錯誤,那麼我們直接就返回錯誤資訊,而不需要等待真正計算的時候由系統去報錯了。

錯誤訊息應該只被用於描述錯誤的輸入或者內部失敗資訊等,比如,如果你執行了一些資料庫的查詢,返回沒有任何資料,這並不是一個錯誤,而僅僅只是資料庫的事實的反饋,但是如果連線資料庫失敗,那就是一個錯誤了。

使用外掛組織模式

一個 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)
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 外掛變成一個真正的微服務。首先,你需要組織你的外掛。 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: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 僅僅作為訊息的傳輸機制。

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

兩種方式都可以看到結果:

{"answer":3}
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;

  • 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);
    });
  });
[
  {
    "path": "/routes",
    "method": "GET",
    "cors": false
  },
  {
    "path": "/api/calculate/{operation}",
    "method": "GET",
    "cors": false
  }
]
{"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

訪問下面的地址:

資料持久化

一個真實的系統,肯定需要持久化資料,在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 的詳情。

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

}
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可能並不是這樣的)。

然後我們可建立一個新的購買訂單:

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

最佳 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 例項當做變數到處傳遞。