「譯」如何使用 NodeJS 構建基於 RPC 的 API 系統
API 在它存在的很長時間內都不斷地侵蝕著我們的開發工作。無論是構建僅供其他微服務訪問的微服務還是構建對外暴露的服務,你都需要開發 API。
目前,大多數 API 都基於 REST 規範,REST 規範通俗易懂,並且建立在 HTTP 協議之上。 但是在很大程度上,REST 可能並不適合你。許多公司比如 Uber,facebook,Google,netflix 等都構建了自己的服務間內部通訊協議,這裡的關鍵問題在於何時做,而不是應不應該做。
假設你想使用傳統的 RPC 方式,但是你仍然想通過 http 格式傳遞 json 資料,這時要怎麼通過 node.js 來實現呢?請繼續閱讀本文。
閱讀本教程前應確保以下兩點
v4.0.0
設計原則
在本教程中,我們將為 API 設定如下兩個約束:
- 保持簡單(沒有外部包裝和複雜的操作)
- API 和介面文件,應該一同編寫
現在開始
本教程的完整原始碼可以在Github 上找到,因此你可以 clone 下來方便檢視。 首先,我們需要首先定義型別以及將對它們進行操作的方法(這些將是通過 API 呼叫的相同方法)。
建立一個新目錄,並在新目錄中建立兩個檔案,types.js
和methods.js
。 如果你正在使用 linux 或 mac 終端,可以鍵入以下命令。
mkdir noderpc && cd noderpc touch types.js methods.js 複製程式碼
在types.js
檔案中,輸入以下內容。
'use strict'; let types = { user: { description:'the details of the user', props: { name:['string', 'required'], age: ['number'], email: ['string', 'required'], password: ['string', 'required'] } }, task: { description:'a task entered by the user to do at a later time', props: { userid: ['number', 'required'], content: ['string', 'require'], expire: ['date', 'required'] } } } module.exports = types; 複製程式碼
乍一看很簡單,用一個key-value
物件來儲存我們的型別,key
是型別的名稱,value
是它的定義。該定義包括描述(是一段可讀文字,主要用於生成文件),在 props 中描述了各個屬性,這樣設計主要用於文件生成和驗證,最後通過module.exports
暴露出來。
在methods.js
有以下內容。
'use strict'; let db = require('./db'); let methods = { createUser: { description: `creates a new user, and returns the details of the new user`, params: ['user:the user object'], returns: ['user'], exec(userObj) { return new Promise((resolve) => { if (typeof (userObj) !== 'object') { throw new Error('was expecting an object!'); } // you would usually do some validations here // and check for required fields // attach an id the save to db let _userObj = JSON.parse(JSON.stringify(userObj)); _userObj.id = (Math.random() * 10000000) | 0; // binary or, converts the number into a 32 bit integer resolve(db.users.save(userObj)); }); } }, fetchUser: { description: `fetches the user of the given id`, params: ['id:the id of the user were looking for'], returns: ['user'], exec(userObj) { return new Promise((resolve) => { if (typeof (userObj) !== 'object') { throw new Error('was expecting an object!'); } // you would usually do some validations here // and check for required fields // fetch resolve(db.users.fetch(userObj.id) || {}); }); } }, fetchAllUsers: { released:false; description: `fetches the entire list of users`, params: [], returns: ['userscollection'], exec() { return new Promise((resolve) => { // fetch resolve(db.users.fetchAll() || {}); }); } }, }; module.exports = methods; 複製程式碼
可以看到,它和型別模組的設計非常類似,但主要區別在於每個方法定義中都包含一個名為exec
的函式,它返回一個Promise
。 這個函式暴露了這個方法的功能,雖然其他屬性也暴露給了使用者,但這必須通過 API 抽象。
db.js
我們的 API 需要在某處儲存資料,但是在本教程中,我們不希望通過不必要的npm install
使教程複雜化,我們建立一個非常簡單、原生的記憶體中鍵值儲存,因為它的資料結構由你自己設計,所以你可以隨時改變資料的儲存方式。
在db.js
中包含以下內容。
'use strict'; let users = {}; let tasks = {}; // we are saving everything inmemory for now let db = { users: proc(users), tasks: proc(tasks) } function clone(obj) { // a simple way to deep clone an object in javascript return JSON.parse(JSON.stringify(obj)); } // a generalised function to handle CRUD operations function proc(container) { return { save(obj) { // in JS, objects are passed by reference // so to avoid interfering with the original data // we deep clone the object, to get our own reference let _obj = clone(obj); if (!_obj.id) { // assign a random number as ID if none exists _obj.id = (Math.random() * 10000000) | 0; } container[_obj.id.toString()] = _obj; return clone(_obj); }, fetch(id) { // deep clone this so that nobody modifies the db by mistake from outside return clone(container[id.toString()]); }, fetchAll() { let _bunch = []; for (let item in container) { _bunch.push(clone(container[item])); } return _bunch; }, unset(id) { delete container[id]; } } } module.exports = db; 複製程式碼
其中比較重要是proc
函式。通過獲取一個物件,並將其包裝在一個帶有一組函式的閉包中,方便在該物件上新增,編輯和刪除值。如果你對閉包不夠了解,應該提前閱讀關於JavaScript
閉包的內容。
所以,我們現在基本上已經完成了程式功能,我們可以儲存和檢索資料,並且可以實現對這些資料進行操作,我們現在需要做的是通過網路公開這個功能。 因此,最後一部分是實現 HTTP 服務。
這是我們大多數人希望使用express的地方,但我們不希望這樣,所以我們將使用隨節點一起提供的http模組,並圍繞它實現一個非常簡單的路由表。
正如預期的那樣,我們繼續建立server.js
檔案。在這個檔案中我們把所有內容關聯在一起,如下所示。
'use strict'; let http = require('http'); let url = require('url'); let methods = require('./methods'); let types = require('./types'); let server = http.createServer(requestListener); const PORT = process.env.PORT || 9090; 複製程式碼
檔案的開頭部分引入我們所需要的內容,使用http.createServer
來建立一個 HTTP 服務。requestListener
是一個回撥函式,我們稍後定義它。 並且我們確定下來伺服器將偵聽的埠。
在這段程式碼之後我們來定義路由表,它規定了我們的應用程式將響應的不同 URL 路徑。
// we'll use a very very very simple routing mechanism // don't do something like this in production, ok technically you can... // probably could even be faster than using a routing library :-D let routes = { // this is the rpc endpoint // every operation request will come through here '/rpc': function (body) { return new Promise((resolve, reject) => { if (!body) { throw new (`rpc request was expecting some data...!`); } let _json = JSON.parse(body); // might throw error let keys = Object.keys(_json); let promiseArr = []; for (let key of keys) { if (methods[key] && typeof (methods[key].exec) === 'function') { let execPromise = methods[key].exec.call(null, _json[key]); if (!(execPromise instanceof Promise)) { throw new Error(`exec on ${key} did not return a promise`); } promiseArr.push(execPromise); } else { let execPromise = Promise.resolve({ error: 'method not defined' }) promiseArr.push(execPromise); } } Promise.all(promiseArr).then(iter => { console.log(iter); let response = {}; iter.forEach((val, index) => { response[keys[index]] = val; }); resolve(response); }).catch(err => { reject(err); }); }); }, // this is our docs endpoint // through this the clients should know // what methods and datatypes are available '/describe': function () { // load the type descriptions return new Promise(resolve => { let type = {}; let method = {}; // set types type = types; //set methods for(let m in methods) { let _m = JSON.parse(JSON.stringify(methods[m])); method[m] = _m; } resolve({ types: type, methods: method }); }); } }; 複製程式碼
這是整個程式中非常重要的一部分,因為它提供了實際的介面。 我們有一組 endpoint,每個 endpoint 都對應一個處理函式,在路徑匹配時被呼叫。根據設計原則每個處理函式都必須返回一個 Promise。
RPC endpoint 獲取一個包含請求內容的 json 物件,然後將每個請求解析為methods.js
檔案中的對應方法,呼叫該方法的exec
函式,並將結果返回,或者丟擲錯誤。
describe endpoint 掃描方法和型別的描述,並將該資訊返回給呼叫者。讓使用 API 的開發者能夠輕鬆地知道如何使用它。
現在讓我們新增我們之前討論過的函式requestListener
,然後就可以啟動服務。
// request Listener // this is what we'll feed into http.createServer function requestListener(request, response) { let reqUrl = `http://${request.headers.host}${request.url}`; let parseUrl = url.parse(reqUrl, true); let pathname = parseUrl.pathname; // we're doing everything json response.setHeader('Content-Type', 'application/json'); // buffer for incoming data let buf = null; // listen for incoming data request.on('data', data => { if (buf === null) { buf = data; } else { buf = buf + data; } }); // on end proceed with compute request.on('end', () => { let body = buf !== null ? buf.toString() : null; if (routes[pathname]) { let compute = routes[pathname].call(null, body); if (!(compute instanceof Promise)) { // we're kinda expecting compute to be a promise // so if it isn't, just avoid it response.statusCode = 500; response.end('oops! server error!'); console.warn(`whatever I got from rpc wasn't a Promise!`); } else { compute.then(res => { response.end(JSON.stringify(res)) }).catch(err => { console.error(err); response.statusCode = 500; response.end('oops! server error!'); }); } } else { response.statusCode = 404; response.end(`oops! ${pathname} not found here`) } }) } // now we can start up the server server.listen(PORT); 複製程式碼
每當有新請求時呼叫此函式並等待拿到資料,之後檢視路徑,並根據路徑匹配到路由表上的對應處理方法。然後使用server.listen
啟動服務。
現在我們可以在目錄下執行node server.js
來啟動服務,然後使用 postman 或你熟悉的 API 除錯工具,向http://localhost{PORT}/rpc
傳送請求,請求體中包含以下 JSON 內容。
{ "createUser": { "name":"alloys mila", "age":24 } } 複製程式碼
server 將會根據你提交的請求建立一個新使用者並返回響應結果。一個基於 RPC、文件完善的 API 系統已經搭建完成了。
注意,我們尚未對本教程介面進行任何引數驗證,你在呼叫測試的時候必須手動保證資料正確性。