Javascript 的前后端統一是個"笑話"嗎?
最近發現知乎上有些人批評 Node.js,說 Javascript 的前后端統一是一個笑話。
“呵呵”。
所謂的統一當然是不可能的,前端自身都統一不了,何況前后端。不過,相當程度的重用是完全可行的。在這里我用一個實際的項目來說明,"i瑞士"。

該網站由瑞士國家旅游局立項、開發和維護,從新浪微博上不同的賬號抓取和瑞士有關的內容,進行分詞識別,打上不同的標簽供用戶分類瀏覽。這個產品的目的是,讓關心瑞士資訊的用戶可以有一個無干擾的、免廣告、純凈的資訊獲取環境(既有自動分類過濾,也有編輯人工審核)。
我是實現該網站的程序員,這是我做的第二個和前端有關的項目,第一個是 NextDay 的應用介紹網站 http://www.gotonextday.com。
這是一個人的項目,前后端一起開發,歷時4個半月左右(最后上線光等備案和各種審核就花了小1個月)。
系統架構
在介紹前后端如何重用之前,首先需要了解一下系統架構:

從左到右來看:
Crawler
Crawler 要做這幾件事情:
1. 從新浪微博抓取瑞士有關的微博信息。
2. 對這些信息進行分析和處理,包括:中文分詞,微博標簽獲取,“i瑞士”的標簽歸納,對于圖片長寬的預取(瀏覽器布局用),對于優酷視頻要獲取元信息,短鏈接事先轉換成長鏈接等,總之就是為后續程序干好各種臟活累活。
3. 根據不同的微博賬號的來源和具體內容進行內容發布,有些內容可以直接發布;有些則需要編輯人工審核;有些則延時發布,給編輯一個處理緩沖等等。
chinese-seg 是 我為這個項目寫的分詞框架,有興趣的同學可以自己閱讀 CoffeeScript 源碼。本文中提到的我開源出來的幾個 github repos 都沒有時間寫詳細的說明文檔,但是如果懂 CoffeeScript 的話不難讀懂(不建議你看編譯出來的 JS 代碼,那是優化給機器執行的,不是給人看的)。
總之 Crawler 就是源源不斷地將新浪微博的內容預處理之后送入不同的發布隊列中(或者直接發布)。
DB
這里的 DB 不是指 MySQL、MongoDB 或者 Redis 這樣現有的數據庫管理系統,而是我自己寫的數據存儲服務,最最底層是用的 LevelDB。
之所以不用現成數據庫管理系統,有以下原因:
這 個項目的服務器都是托管在阿里云上的,而這種云OS的磁盤IO都比較慢,不適合直接安裝既有的數據庫服務(除了 Redis)。如果要購買阿里云的 RDS 專業的數據庫服務,則有兩個問題,第一,目前只有關系數據庫的選擇,而我要保存的數據用 ER 關系來表達并不太適用;第二,就是這些關系數據庫沒有 4G 以上內存都不太帶得動,而者這造成價格呈指數翻上去。這種年年要交費的東西,省點就都是自己的。
其實如果所有內容在內存中都放得下,用 Redis 是很好的選擇。NextDay 的后臺服務就把用戶的禮物數據都保存在 Redis 中,經過壓縮和精簡處理,1G 內存保存 5 年的用戶數據都沒問題(別拿來記 log 就好)。
至于阿里云的開放結構化數據服務(OTS)這種私有服務還真不敢現在就用。
至于為什么用 LevelDB 或者如何用,那就需要開一個專題來討論了,有興趣的同學可以從下面的視頻入手,或者從 LevelUp repo 開始。
https://www.油Tube.com/watch?v=C-SbXvXi7Og。
API Server
API Server 為瀏覽器提供 Websocket 的調用服務,也幫助實現新浪微博的 OAuth 認證,保存用戶收藏以及后臺轉發微博等。
API Server 以 Client 的身份通過 TCP 連接 DB,以 Server 身份供瀏覽器通過 Websocket 調用。作為 Server,API Server 使用 connect 來完成基本的 HTTP 路由。由于 API Server 實現的 WEB 相關的功能非常少,因此沒有勞動 express 的大駕。
Server Proto
既然都是 Server,那么 Crawler, DB 和 API Server 它們都共享一個公共的 Server框架,稱為 server-proto。這是為 “i瑞士” 項目做的一個開源項目,同樣是用 CoffeeScript 寫的,缺少文檔說明(對不起大家:( )。
server-proto 將 Server 常用的功能抽象出來,例如,configuration (配置信息獲取),一個任務調度系統(基于 node-resque),redis 訪問,通過 REPL 在運行時訪問內部狀態,supportData 用來實現自定義配置文件的獲取與刷新,actions 用來載入自定義rpc方法實現,以及 stats(performance counter),streams實現自定義的 NodeJS 的 stream 插件等等。
和其他 Server Framework 不同,server-proto 沒有包含任何通信協議相關的部分,其原因是我后面要講的重點(天空飄來五個字,那都不是事(兒))。
由于缺少用法的說明和實例(例子都在 Crawler, DB, API Server 這些閉源項目中),所以目前不適合其他人閱讀和使用,希望最終有機會做出一個完整的可被大家重用的 repo。
另外,我一直在想是用 Promise 還是 Generator + Promise 重寫這個框架,但是也要看后面項目機緣了。
WEB CDN
用 戶看到的所有網頁內容相關的 HTML、JS、CSS,IMAGE和SVG,都被部署到了七牛的CDN服務上。用七牛的原因很簡單,它是我找到的唯一提供 Free Plan 的比較靠譜的服務商。所以,這個項目沒有真正的 WEB Server。以上資產都是從開發機上,通過 Grunt 構建出不同的版本,然后直接部署到 Testing、Staging 或者 Production 環境中。對用戶來說,也可以從根上就享受到 CDN 的速度,對我來說,則又省了一臺云服務器:)。
瀏覽器代碼的基礎框架有兩個,一個是 AngularJS,還有就是 NodeJS 。無論是 AngularJS 的框架本身,還是 NodeJS 系統的 Core Modules,本項目用到的 NodeJS User Land 的 Modules (NPM Modules),或者專為本項目寫的代碼,最終都通過 node-browserify 打包成一個 js 文件(modules 之間就是以 NodeJS 的 require 方式引用),minification 之后大約 439K,gzip 之后 138K。
在前端代碼中集成 Node.JS,帶來的最大好處就是前后端通訊模式的統一。
通訊模式
在“i瑞士”中,無論是兩個后臺 Server 之間的通信(API Server <-> DB,或者 Crawler <-> DB),還是 Browser 和 API Server,其通訊模式主要有兩種:
RPC 和 States Synchronization(狀態同步)。
RPC 模式就是 request/reponse 方式,Client 發起請求,然后等待 Server 的回應,這是大家都很熟悉的方式。不過有一點,之前 Server 和 Server 之間要走一種協議,而瀏覽器到 Server 之前則只能走另外一種協議(例如:WebSocket,或者 Comet, faye...)。
States Synchronization(狀態同步)是指,當某一臺服務器上的狀態變化了,將自動同步到其他服務器,無需手工發起 RPC 請求。
Scuttlebutt-狀態同步協議
在“i瑞士”中,兩種方式都被大量使用。例如:用戶進行“收藏”是一個典型的 RPC 調用,從瀏覽器到 API Server 到 DB。而天氣信息則是狀態同步的一個使用場景。
1. Crawler 從某天氣服務商獲取瑞士各大城市當前和未來的天氣,隨后通過 RPC 調用保存到DB 中。DB 是咱自己寫的,因此會自動更新服務器上的保存 Weather 對象。
2. 其他 Server,例如: API Server 從一啟動設置好將自己的 Weather 對象和 DB 的 Weather 進行同步。
3. 而每個瀏覽器訪問 API Server 時,當 Websocket 連接建立后,也會將自己的 Weather 對象與 API Server 的 Weather 對象設定為同步。
如下圖:

從安全角度考慮,DB -> API Server -> Browsers 之間的 Stream (是指 NodeJS Stream)都是只讀的,也就是不允許 Browsers 反過來通過變更 Weather 對象來引起整個網絡的 Weather 對象變化。
同步算法采用的是 Scuttlebutt(dominictarr 撰寫),其基本原理是通過不同的 Peer 之間利用 Vector Clock 算法發現較新的狀態,從而將這些較新的狀態同步到自身,再擴散到其他將自己當做 Reader 的 Peers 上。
當時為了學習理解 Scuttlebutt 的原理和代碼,我 Fork 了原始代碼,寫了一篇文檔作說明,同時在原來的代碼上加了很多注釋。
Scuttlebutt 是基礎同步算法,在其之上可以衍生出不同的數據結構的同步(編寫 Scuttlebutt 的特定子類),例如,同步單層對象,多層對象,Global Counter,甚至包括協同編輯中的文檔連續同步等等。當然,其同步的基準是時間,前提是各個 Peers 都擁有一致的時間(如果不僅僅是只讀的)。有些場景不能保證時間的一致性,例如瀏覽器,那么先實現一個簡單的時間同步算法作為前提。
實現 Scuttlebutt 并不簡單。如果在沒有 NodeJS 和 node-browserify 的世界中,我們只能用不同的語言,在不同的平臺下都實現一遍。而現在,起碼在瀏覽器前端和 NodeJS 的后端間實現狀態同步都擁有完全相同的代碼。
dnode - 一個 RPC 的 JS 實現
那么如何在瀏覽器和 Server之間,以及 Server 與 Server 之間采用相同的 RPC Codebase 呢? 這就要感謝同樣是 node-browserify 的作者 substack 的 dnode 了。
dnode 實現了一種自由風格的 RPC 模式,無論是 Client 還是 Server 都可以自用聲明自己所支持的方法原型,連接后相互交換(如果不需要 Server 調用 Client 的方法,那么僅僅需要 Server 告訴 Client 自己的方法原型即可)。這種方法原型的交換在 RPC 的概念中相當于互換 IDL,只不過不是事先綁定,而是動態交換的。
dnode 概念簡單,易于使用,老少咸宜。但是最關鍵的,也是和 Scuttlebutt 一樣的地方就是,通信的 peer 之間只要有 NodeJS stream 的管道即可,而不是綁定到某一種具體的網絡協議上(如 TCP 或者 Websocket)。那么換句話說,只要我們讓 TCP 或者 Websocket 支持 NodeJS 的 stream,即可自由地使用 stream 上的各種算法實現了。幸運的是,這些幾乎都已經存在了。
Stream 和 網絡協議
首先 NodeJS 的 Core Modules 中的 TCP 已經是 stream 的實現,所以 Server to Server 之間已經無需自己做了。而瀏覽器到 Server 之間,目前常用通信 Modules 有socket.io,SockJS, ws, engine.io 等等。他們都有 stream 接口的對應實現:socket.io-stream, shoes, websocket-stream, engine.io-stream。我選用的是 websocket-stream,因為它不像其他框架,都實現了瀏覽器不支持 Websocket 的 fallback。這一點我不需要,因為 IE 10 之前我都不支持(其實連 IE10我都不想支持啊:( )。
因此,無論是瀏覽器還是 Server,都擁有了相同的 RPC 框架和同步框架,于是就只剩下了最后一個問題,連接復用。
連接復用
每 個瀏覽器到 Server 的 Websocket 連接越少越好。如果僅僅是一般的基于 stream 的管道,那么一個管道就會消耗一個 Websocket 連接。那么 dnode,weather 同步就要消耗兩個連接,而我要同步的東西可不僅僅是 weather。因此,在一個既有的 stream 上如何同時承載多個的其他 streams,則是要解決的新問題。
dominictarr 的 mux-demux 就是來解決這個問題的。我也搞了個 Fork,漢化了其說 readme。另外,這里有一個例子,演示了如何在一個 Websocket stream 上完成 dnode RPC 調用 和 scuttlebutt 同步。
總結
上面提到的還只是最主要的重用部分。其實還有很多小地方也都復用了代碼和算法,例如:網絡連接的自動重連算法 reconnect-core 以及其 websocket-stream 的具體實現 reconnect-ws (這是我少有的直接用 JS 寫的:) )。
讀 到這里大家可能也和我一樣能體會到,如果沒有 NodeJS 和 node-browserify,這個項目不可能由一個人在這么短的時間內完成的項目。如果前后臺都由一個人來寫,采用完全不同的技術平臺,在同一時間段 內是很割裂的事,就算能做,其項目復雜度也只能大大降低。
用好 NodeJS,深入理解和使用 Stream 是必須的。NodeJS 當年引入 Stream,就是看到管道操作在 Unix 上的巨大成功。這一層標準的抽象,雖然并不完美,卻讓不同的開發者不約而同地構造出高度可復用的代碼。
來自:http://jianshu.io/p/5f6637bf15fd