理想的應用框架
背景
在過去對框架的設計中,我收到過的最有用的建議是:“不要一開始就根據現有的技術去整合和改進。而是先搞清楚你覺得最理想的框架應該是怎樣的,再根據現在的技術去評估,的確實現不了時再妥協。這樣才能做出真正有意義的框架。”
在這篇文章里,就讓我們按照這樣一條建議來探索一下現在的 web 框架最終可以進化成的樣子,你絕對會被驚艷到。
前端,還是從前端說起。前端目前的現狀是,隨著早期的 Backbone,近期的 Angular、React 等框架的興起,前端在 模塊化、組件化 兩個方向上已經形成了一定的行業共識。在此基礎上,React 的 FLUX、Relay 則是進一步的對前端應用架構的探索。這些技術在目前國內的大公司、大團隊內部實際上都落地得非常好,因為很容易和公司內部已有的后端技術棧結合。而且這些 純前端框架的配套技術方案一般比較成熟,例如在支付寶確定使用 React,其實有一部分原因是它兼容 IE8,并且有服務器端渲染方案來加速首屏。
相比之下,像 Meteor 這類從前到后包辦的框架就較難落地。雖然能極大地提高開發效率,整體架構非常先進,但架構的每一個層級往往不容易達到行業內的頂尖標準。特別是在服務器 端,對大公司來說,通常都有適合自己業務的服務器集群、數據庫方案,并且經受過考驗。因此當一個團隊一上手就要做面向十萬級、甚至百萬級用戶的產品時,是 不太愿意冒風險去嘗試的。反而是個人開發者、創業型的團隊會愿意去用,因為確實能在短時間內高效地開發出可用的產品出來。包括像 Leancloud 提出的這類型的服務,也是非常受歡迎的。
這種現狀,就是理想和現實的一個爭論。Meteor 的方式能滿足我對開發效率的理想,而團隊已有的技術方案能保障穩定。能否整合其中的優勢,不妨讓我們進一步來細化一下對框架的希望:
- 有強大的前后端一致的數據模型層
- 代碼可以可以復用。例如我有一個 User 模型,當我創建一個新的 user 時,user 上的字段驗證等方法是前后端通用的,由框架自動幫我區別前后端環境。
- 數據模型和前端框架沒有耦合,但可以輕松結合。這樣在前端渲染型的框架進一步升級時,不影響我的業務邏輯代碼。
- 由數據模型層提供自動的數據更新機制。例如我在前端要獲取 id 為 1 的用戶,并且如果服務器端數據有更新的話,就自動幫我更新,不需要我自己去實現輪詢。我希望的代碼寫法是:
var user = new User({id:1}); user.pull(); user.watch();
實際上,Meteor已經能實現絕大部分上述功能。但這不是軟文。我要強調兩點我不希望的:
- 我不希望這個數據模型層去包含業務邏輯,也就是我創建的user對象,我不希望它提供 login、logout 等 api。
- 我也不希望數據模型層自動和任何ORM框架綁定,提供任何 SQL 或 NoSQL 的數據支持。
看到這兩點你可能心中大打問號,這兩點不正是高效的精髓嗎?前后端邏輯復用,屏蔽數據庫細節。別急,讓我們重新用“理想的方式”來思考一下“邏輯”和“數據持久化”這兩件事。
數據與邏輯
我們以這樣一個問題開頭:任何一個應用,我們的代碼最少能少到什么程度?
這算半個哲學問題。任何人想一想都會得到同一個答案:最少也就少到和應用本身的描述一一對應而已了。什么是應用描述?或者說什么是應用?我們會這樣描述一個博客:“用戶可以登錄、退出。用戶登錄后可以發表文章。發表文章時可以添加相應的標簽。”
抽象一下描述,答案很簡單:數據,和邏輯。
如果你在一個流程要求嚴格的公司,應用描述就是prd或系分文檔。應用的數據就是數據字典,應用的邏輯就是流程圖的總和:
流程圖
那么代碼最少能怎么寫呢?數據很簡單,參照數據字典,我們來用一種即使是產品經理都能掌握的偽碼來寫:
//描述字段 User : { name : string }Post : { title : string, content : text }
Tag : { name : string }
//描述關系 User -[created]-> Post Post -[has]-> Tag</pre>
這里為了進一步幫助讀者從已有的技術思維中跳出來,我想指出這段偽碼和數據庫字段描述有一個很大的區別,那就是:我不關心 User 和 Post 中間的關聯關系到底是在兩者的字段中都創建一個字段來保存對方的id,還是建立一個中間表。我只關心我描述它時的邏輯就夠了。數據描述的代碼,最簡也就簡 單到這個程度了。
那么邏輯呢?我們先用按常規方式試試?
class User{ createPost( content, tags=[] ){ var post = new Post({content:content}) post.setTags( tags.map(tagName=>{ return new Tag(tagName)} ) ) return post } }
好像還不錯,如果今天產品經理說我們增加一個 @ 功能,如果文章里 @ 某個用戶,那么我們就發個站內信給他。
class User{ createPost( content, tags=[] ){ var post = new Post({content:content})
post.setTags( tags.map(tagName=>{ return new Tag(tagName)} ) )if( at.scan(content) ){ at.getUser(content).forEach( atUser =>{ system.mail( atUser ) }) } return post }
}</pre>
你應該意識到我要說什么了,像互聯網這種可以快到一天一個迭代的開發速度,如果沒有一個好的模式,可能用不了多久,新加的功能就把你的 createPost 搞成了800行。當然,我也并不是要講設計模式。代碼中的設計模式,完全依賴于程序員本人,我們要思考的是從框架層面提供最簡單的寫法。
讓我們再回到哲學角度去分析一下業務邏輯。
我們所謂的邏輯,其實就是對一個 具體過程的描述 。在上面這個例子里,過程無非就是添加標簽,全文掃描。描述一個過程,有兩個必備點:- 干什么
- 順序
順序為什么是必備的?某天上面發了文件說標題里帶 XXX 的文章都不能發,于是你不得不在函數一開始時就進行檢測,這時就必須指定順序。 </p>如果我們用左右表示會互相影響的順序,從上下表示互不相干的順序,把上面的最初的流程圖重畫一下:
這是一棵樹。如果我們再加個功能,添加的標簽如果是某個熱門標簽,那么我們就把這篇文章放到網站的熱門推薦里。這棵樹會變成什么樣子呢:
是的,事實上人類思維中的任何過程,都可以畫成一棵樹。有條件的循環可以拆解成遞歸,最終也是一棵樹。但重點并不是樹本身,重點是上面這個例子演化 的過程,從一開始最簡單的需求,到加上一點新功能,再到加上一些惡心的特殊情況,這恰恰就是真實世界中 web 開發的縮影。真實世界中的變化更加頻繁可怕。其中最可怕的是,很多時候我們的程序結構、用到的設計模式,都是適用于當前的業務模型的。而某天業務模型變化 了,代碼質量又不夠好的話,就可能遇到牽一發動全身,大廈將傾的噩夢。幾乎每個大公司都有一個“運行時間長,維護的工程師換了一批又一批”的項目。 Amazon曾經有個工程師描述維護這種項目的感覺:“climb the shit mountain”。
回到之前的話題,在邏輯處理上,我們的理想是寫出的代碼即短,又具有極高的可維護性和可擴展性。
更具體一點,可維護性,就是代碼和代碼結構,能最大程度地反映業務邏輯。最好我的代碼結構在某種程度上看來和我們流程圖中的樹一樣。這樣我讀代碼, 就幾乎能理解業務邏輯。而可擴展性,就是當出現變化時,我能在完成變化時,能盡量少地去修改之前的代碼。同樣的,如果我們能保障代碼和代碼結構能和流程圖 盡量一致,那么在修改時,圖上怎么改,我們代碼就怎么改。這也就是理論上能達到的最小修改度了。綜上,我們用什么樣的系統模型能把代碼變得像樹形結構一 樣?
很簡單,事件系統就可以做到。我們把都一個業務邏輯當做事件來觸發,而具體需要執行的操作單做監聽器,那么上面的代碼就可以寫成:
// emitter 是事件中心
emitter.on("post.create", function savePost(){...})
emitter.on("post.create", function createTags(){...}, {before:"savePost"})
emitter.on("post.create", function scanSensitiveWords( post ){
if( system.scanSensitiveWords( post ) ){
return new Error("you have sensitive words in post.")
}
}, {block:all})
emitter.on("post.create", function scanPopTags(){...})</pre>
//執行創建文章操作 emitter.fire("post.create", {...args})
這樣看來,每個操作的代碼變得職責單一,整體結構也非常工整。值得注意的是,在這段偽碼里,我們用了 `{before:"savePost"}` 這樣的參數來表示操作的順序,看起來也和邏輯本身的描述一致。
讓我們回到可維護性和可擴展性來檢查這種寫法。首先在可維護性上,代碼職責變得很清晰,并且與流程描述一致。不過也有一個問題,就是操作的執行順序已經無法給人宏觀上的印象,必須把每個監聽器的順序參數拼起來,才能得到整體的順序。
在可擴展性上,無路是新增還是刪除操作,對應到代碼上都是刪除或新增相應的一段,不會影響到其他操作代碼。我們甚至可以把這些代碼拆分到不同的文件中,當做不同的模塊。這樣在增減功能時,就能通過增刪文件來實現,這也為實現一個文件級的模塊管理器提供了基礎技術。
至此,除了無法在執行順序上有一個宏觀印象這個問題,似乎我們得到了理想的描述邏輯的方式。那我們現在來攻克這最后一個問題。拿目前的這段偽碼和之 前的比較,不難發現,之前代碼需要被執行一遍才能較好地得到其中函數的執行順序,才能拿到一個調用棧。而現在的這段代碼,我只要實現一個簡單的 emitter,將代碼執行一遍,就已經能得到所有的監聽器信息了。這樣我就能通過簡單的工具來得到這個宏觀的執行順序,甚至以圖形化的方式展現出來。得 到的這張圖,不就是我們一模一樣的流程圖嗎?!
不知道你有沒有意識到,我們已經打開了一扇之前不能打開的門!在之前的代碼中,我們是通過函數間的調用來組織邏輯的,這和我們現在的方式有一個很大的區別,那就是:用來封裝業務邏輯的函數,和系統本身提供的其他函數,沒有任何可以很好利用的區別,即使我們能得到函數的調用棧,這個調用棧用圖形化的方式打印出來也沒有意義,因為其中會參雜太多的無用函數信息,特別是當我們還用了一些第三方類庫時。打印的結果可能是這樣:
而現在,我們用來表述業務的某個邏輯,就是事件。而相應的操作,就是監聽器。監聽器無論是觸發還是注冊,都是通過 emitter 提供的函數,那么我們只需要利用 emitter,就能打印出只有監聽器的調用棧。而監聽器的調用棧,就是我們的流程圖。
代碼結構可圖形化,并且是有意義的可圖形化,這扇大門一旦打開,門后的財富是取之不盡的。我們從 開發、測試、監控 三個方面來看我們能從中獲得什么。
在開發階段,我們可以通過調用棧生成圖,那通過圖來生成代碼還會難嗎?對于任何一份流程圖,我們都能輕易地直接生成代碼。然后填空就夠了。在調試 時、我們可以制作工具實時地打印出調用棧,甚至可以將調用時保存的傳入傳出值拿出來直接查看。這樣一旦出現問題,你就可以直接根據當前保存的調用棧信息排 查問題,而再無需去重現它。同理,繁瑣的斷點,四處打印的日志都可以告別了。
測試階段,既然能生成代碼,再自動生成測試用例也非常容易。我們可以通過工具直接檢測調用棧是否正確,也可以更細致地給定輸入值,然后檢測各個監聽器的傳入傳出值是否正確。
同樣很容想到監控,我們可以默認將調用棧的數據建構作為日志保留,再用系統的工具去掃描、對邊,就能自動實現對業務邏輯本身的監控。
總結一下上述,用事件系統去描述邏輯、流程,使得我們代碼結構和邏輯,能達到一個非常理想的對應程度。這個對應程度使得代碼里的調用棧信息就能表述邏輯。而這個調用棧所能產生的巨大價值,一方面在于可圖形化,另一方面則在于能實現測試、監控等一系列工程領域的自動化。
到這里,我們已經得到了兩種理想的表達方式來分別表述數據和邏輯。下面真正激動人心的時刻到了,我們來關注現實中的技術,看是否真的能夠做出一個框架,讓我們能用一種革命性的方式來寫應用?
理想到現實
首先來看數據描述語言和和數據持久化。你可能早已一眼看出 `User -[create]-> Post` 這樣的偽碼是來自圖數據庫 Neo4j 的查詢語言 cypher 。在這里我對不熟悉的讀者科普一下。Neo4j 是用 java 寫的開源圖數據庫。圖數據本身是以圖的方式去存儲數據。
例如同樣對于 User 這樣一個模型,在 關系型數據庫中就是一張表,每一行是一個 user 的數據。在圖數據庫中就是一堆節點,每個節點是一個 user。當我們又有了 Post 這個模型時,如果要表示用戶創建了 Post 這樣一個關系的話,在關系型數據庫里通常會建立一個中間表,存上相應 user 和 post 的 id。也或者直接在 user 或 post 表里增加一個字段,存上相應的id。不同的方案適用于不同的場景。而 在圖數據庫中要表達 user 和 post 的關系,就只有一種方式,那就是創建一個 user 到 post 的名為 CREATED 的 關系。這個關系還可以有屬性,比如 {createdAt:2016,client:"web"} 等。
你可以看出圖數據和關系型數據庫在使用上最大的區別是,它讓你完全根據真實的邏輯去關聯兩個數據。而關系型數據庫則通常在使用時就已經要根據使用場景、性能等因素做出不同的選擇。
我們再看查詢語言,在 SQL 中,我們是以`SELECT ... FROM` 這樣一種命令式地方式告訴數據怎樣給我我要的數據。語句的內容和存數據的表結構是耦合的。例如我要找出某個 user 創建的所有 post。表結構設計得不同,那么查詢語句就不同。而在 Neo4js 的查詢語句 cypher 中,是以 `(User) -[CREATED] ->(Post)` 這樣的 模式匹配 的語句來進行查詢的。這意味著,只要你能以人類語言描述自己想要的數據,你就能自己翻譯成 cypher 進行查詢。
除此之外,圖數據當然還有很多高級特性。但對開發者來說,模式匹配式的查詢語句,才是真正革命性的技術。熟悉數據庫的讀者肯定有這樣的疑問:
其實很多 ORM 就能實現 cypher 現在這樣的表達形式,但在很多大公司里,你會發現研發團隊仍然堅持手寫 SQL 語句,而堅決不用 ORM。理由是,手寫 SQL 無論在排查問題還是優化性能時,都是最快速的。特別是對于大產品來說,一個 SQL 就有可能節約或者損失巨額資產。所以寧愿用 “多人力、低效率” 去換 “性能和穩定”,也不考慮 ORM。那么 cypher 如何面對這個問題?
確實,cypher 可以在某種程度上理解成數據庫自帶的 ORM。它很難通過優化查詢語句來提升性能,但可以通過其他方式。例如對耗時長的大查詢做數據緩存。或者把存儲分層,圖數據庫變成最底層,中間針對某些應 用場景來使用其他的數據庫做中間層。對有實力的團隊來說,這個中間層甚至可以用類似于智能數據庫的方式來對線上查詢自動分析,自動實現中間層。事實上,這 些中間技術早就已經成熟,結合上圖數據庫和cypher,是可以把傳統的“人力密集型開發”轉變為“技術密集型開發”的。
扯得略遠了,我們重新回到模式匹配型的查詢語句上,為什么說它是革命性的,因為它剛好滿足了我們之前對數據描述的需求。任何一個開發者,只要把數據 字典做出來。關于數據的工作就已經完成了。或者換個角度來說,在任何一個已有數據的系統中,只要我能在前端或者移動端中描述我想要的數據,就能開發出應 用,不再需要寫任何服務器端數據接口。非死book 在 React Conf 上放出的前端 Relay 框架和 GraphQL 幾乎就已經是這樣的實現。
再來看邏輯部分,無論在瀏覽器端還是服務器端,用什么語言,實現一個事件系統都再簡單不過。這里我們倒是可以進一步探索,除了之前所說的圖形界面調 試,測試、監控自動化,我們還能做什么?對前端來說,如果前后端事件系統可以直接打通,并且出錯時通過圖形化的調試工具能無需回滾直接排查,那就最好了。
例如:在創建 post 的前端組件中
//觸發前端的 post.create 事件 var post = {title: "test", content: "test"} emitter.fire("post.create").then(function(){ alert("創建成功") }).catch(function(){ alert("創建失敗") })
在處理邏輯的文件中:
//可以增加前端專屬的邏輯 emitter.on("post.create", function checkTest(post){ if( post.title === "test"){ console.log("this is a test blog.") } })//通過 server: 這樣的命名空間來觸發服務器端的事件 emitter.on("post.create", function communicateWithServer(post){ console.log("communicating with server") return emitter.fire("server:post.create", post) })</pre>
得到的事件棧
![]()
在瀏覽器端可以打通和服務器端的事件系統,那么在服務器端呢?剛剛提到我們我們其實可以用任何自己熟悉的語言去實現事件系統,那是不是也意味著,只要事件調用棧的數據格式一致,我們就可以做一個跨語言的架構?
例如我們可以用nodejs的web框架作為服務器端入口,然后用python,用go去寫子系統。只要約定好系統間通信機制,以及事件調用棧的數據格式,那么就能實現跨語言的事件系統融合。這意味你未來看到的調用棧圖可能是:
![]()
跨語言的實現,本身也是一筆巨大財富。例如當我們未來想要找人一起協同完成某一個web應用時,再也不必局限于某一種語言的實現。甚至利用 docker等容器技術,執行環境也不再是限制。再例如,當系統負載增大,逐漸出現瓶頸時。我們可以輕松地使用更高效的語言或者執行環境去替換掉某個業務 邏輯的監聽器實現。
更多的例子,舉再多也舉不完。當你真正自己想清楚這套架構之后,你會發現未來已經在你眼前。
到這里,對“理想”的想象和對實現技術的思考終于可以劃上句號了。對熟悉架構的人來說,其實已經圓滿了。但我也不想放棄來“求干貨”的觀眾們。下面演示的,就是在框架原型下開發的簡單應用。這是一個多人的todo應用。
![]()
前端基于react,后端基于koa。
目錄結構
![]()
前端數據(todo 列表) /public/data/todos.js
![]()
前端邏輯(todo 基本邏輯) /public/events/todo.js
![]()
前端邏輯(輸入@時展示用戶列表) /public/events/mention.js
![]()
后端邏輯(通知被@用戶) /modules/mention.js
![]()
通過調試工具得到的創建時的調用棧和輸入@符號時的調用棧
![]()
這只是一個引子,目的是為了讓你宏觀的感受將應用拆解為“數據+邏輯”以后能有多簡單。目前這套框架已完成 50% ,實現了數據部分的設計、前后端事件融合,還有跨語言等方案正在開發中。未來將開源,期待讀者關注。
后記終于寫完了。框架只是架構的實現。這套架構幾乎孕育了近兩年,這其中已經開發出一款實現了部分功能,基于nodejs的服務器端原型框架。完整的框 架開發目前也已經四個月了。雖然從它落地的這些前端技術、數據技術看起來,它其實是有技術基礎的,應該是積累的產物。但實際上,最早的關于數據和邏輯的思 路,卻是在我讀研時對一個“很虛”的問題的思考:什么樣的系統是最靈活的系統?在很長一段時間內,對各種架構的學習中我都沒有找到想要的答案,直到后來在 學認知心理學和神經學的時候,我想到了人。人是目前可以理解的最具備適應性,最靈活的系統。人是怎么運作的?生理基礎是什么?
認知心理學里提到曾經有一個學派認為人的任何行為都不過是對某種刺激的反射,這種刺激可以是來自內部也可以是外部。來自內部的刺激有兩個重要來源, 一是生理上,例如饑餓,疲憊。二則是記憶。例如,你每天起床要去工作,是因為你的過去的記憶告訴你你需要錢,或者你喜歡工作的內容。這對人來說也是一種刺 激,所以你產生了去工作的動機。外部刺激就更簡單,例如生理上的被火燙了,心理上被嘲諷、被表揚等等。而人的反應,就是對這些刺激而產生的多種反射的集 合。例如早上起床,你的一部分反射是產生上班的動機,但是如果你生病了,你的身體和記憶就會刺激你去休息。最終你會在這兩種刺激下達到一個平衡,做出反 應。值得注意的是,大部分時候,人在不同時間面臨相同的刺激,卻做出不同的反應。并不是因為后來某些反射被刪除了,而是因為后來形成了更強的反射區壓制住 了之前的反射。它的生理基礎就是神經學中的神經遞質可以互相壓制。
如果我們把要打造的系統看做一個有機體,把迭代看做生長,把用戶的使用看做不斷的刺激。那我們是不是就能模擬人的反射過程來打造系統,從而期待系統 得到像人一樣的適應力?而恰恰你會發現科幻作品中的人工智能產品通常都以人的形態出現。因為我們希望我們所使用的產品,就像人一樣通情達理,具有人一樣的 領悟能力。而要達到這樣的效果,或許就是不斷給給他添加人對刺激的反射規則。
思考到這一步的時候,我對應用架構的設計哲學已經基本定型。后來驗證出來的,這樣的系統能夠極大地提高研發效率,都只是這段哲學的附加價值。其實提 高研發效率的原理很簡單,無論系統的需求再怎么擴展、再怎么變更,它也是遵循人本身的思維邏輯的。因此,你始終可以使用本身就模擬人類認知的系統去適應 它。并且,它怎么變化,你就怎么變化。
架構這種東西,最終仍然關注在使用者身上的。所以與其和我討論確定的技術問題,不如討論這些更有意義。對思考架構的人來說,我認為眼界和哲學高度,最重要。
討論記錄
尤小右:感覺其實就是 flux 啊,但是 string-based global event bus 規模大了還是會有點坑爹的。一個事件觸發的后果遍及全棧,不好 track。
答:和flux的區別在于flux的數據對象本身和對數據的操作是合在store里的。事件系統規模的問題通過兩個方式控制:一是命名空間。二是事件只應用在業務邏輯個程度就夠了,像“存入數據庫”這種操作就不要再用事件觸發。這樣系統就不會亂掉,因為它只反映業務邏輯。
來自:http://www.cnblogs.com/sskyy/p/4592353.html