在未來,我們會怎樣構建 Web 應用程序呢?
如果行業正常發展下去的話,那麼今天我們認為很難、做起來很有價值的事情在明天都會變得很輕松普遍。我想我們會發現很多新的抽象,讓 Google Docs 寫起來也能像今天的普通 Web 應用一樣簡單。
這就引出來一個問題——這些抽象會是什麼樣子?我們今天能發現它們嗎?想要找出答案,一種方法是審視我們在構建 Web 應用程序時必須經歷的所有問題,然后看看我們能做些什麼。
親愛的讀者,這篇文章就是我對上述方法的一次實踐嘗試。我們會走過一段旅程,看看今天我們是如何構建 Web 應用程序的:我們將回顧行業面臨的各種問題,評估 Firebase、Supabase、Hasura 等解決方案,看看還有什麼需要做的事情。我想到了旅途的最后,你一定會同意我的觀點,那就是瀏覽器中的數據庫看起來應該是最有用的抽象之一。不過,這里說的有點太遠了,我們先從頭開始。
1客戶端
這段旅程始于瀏覽器中的 Javascript。
A. 數據管道
我們的第一步工作是獲取信息并將其顯示在各個位置。例如,我們可能會顯示一個好友列表、好友數量、特定好友組的一個模態等。
我們面臨的問題是,所有組件看到的信息都需要是一致的。如果一個組件看到的好友數據和別的不一樣,你就可能顯示出錯誤的“計數”,或者一個視圖與另一個視圖中的昵稱不一樣。
為解決這個問題,我們需要有一個核心的事實來源。于是每當我們獲取什麼東西時,我們都會對其標準化并把它放在一個地方(通常是一個存儲)。然后,每個組件(使用一個選擇器)讀取并轉換所需的數據。下面這樣的代碼是很常見的:
// normalise [posts] -> {[id]: post}fetchRelevantPostsFor(user).then(posts => { posts.forEach(post => { store.addPost(post); })})// see all posts by author: store.posts.values().reduce((res, post) => { res[post.authorId] = res[post.authorId] || []; res[post.authorId].push(post); return res;}, {})
這里的問題是,為什麼我們需要做這些工作呢?我們得編寫自制代碼來處理這些數據,可是數據庫早就解決這個問題了。我們應該能夠“查詢”數據才是,比如說:
SELECT posts WHERE post.author_id = ?;
這樣查詢我們瀏覽器內部的信息不是很方便嗎?
B. 更改
下一個問題是讓數據保持最新狀態。假設我們刪除了一個好友,會發生什麼呢?
我們發送一個 API 請求,等待它完成,然后編寫一些邏輯來“刪除”關于這個好友的所有信息。比如這樣的代碼:
deleteFriend(user, friend.id).then(res => { userStore.remove(friend.id); postStore.removeUserPosts(friend.id);})
但這種機制很快就會變得很麻煩:我們必須記住存儲中可能受這一更改影響的所有位置才行,就好像我們要在大腦里搞一個垃圾收集器,可我們的大腦不擅長這種活兒。為了避開它,人們想出的一種辦法是跳過問題并重新獲取整個世界:
deleteFriend(user, id).then(res => { fetchFriends(user); fetchPostsRelevantToTheUser(post);})
這兩種解決方案都不是很好。在這兩種情況下都存在我們需要留意的隱式不變量(基于這一更改,我們還需要注意其他哪些更改?),并且我們在應用程序中引入了延遲。
問題是,當我們對數據庫做任何更改時,它用不著我們這麼小心就可以完成工作。為什麼瀏覽器不能自動搞定這種事情呢?
DELETE FROM friendships WHERE friend_one_id = ? AND friend_two_id = ?-- Browser magically updates with all the friend and post information removed
C. 樂觀更新
你可能已經注意到 B. 的問題是,我們必須等待好友被移除才能更新瀏覽器狀態。
在大多數情況下,我們可以通過一個樂觀更新來加快速度——畢竟,我們知道調用很可能會成功。為此,我們執行以下操作:
friendPosts = userStore.getFriendPosts(friend);userStore.remove(friend.id);postStore.removeUserPosts(friend.id);deleteFriend(user, id).catch(e => { // undo userStore.addFriend(friend); postStore.addPosts(friendPosts);})
這更煩人了。現在我們需要手動更新成功操作和失敗操作才行。
這是為什麼?在后端,數據庫本來就能做樂觀更新啊——為什麼我們不能在瀏覽器中這樣做?
DELETE friendship WHERE friend_one_id = ? AND friend_two_id = ?-- local store optimistically updated, if operation fails we undo
D. 響應性
數據不僅會因我們自己的行為而改變。有時我們需要連接到其他用戶所做的更改。例如,有人可以取消我們的好友關系,或者有人可以向我們發送消息。
為了完成這項工作,我們需要做的事情與在 API 端點中所做的是一樣的,但這次是在我們的 websocket 連接上:
ws.listen(`$/friends-removed`, friend => { userStore.remove(friend.id); postStore.removeUserPosts(friend.id);}
但這又引入兩個問題。首先,我們又得玩垃圾收集器那套了,需要記住可能受事件影響的每一個位置。
其次,如果我們要做樂觀更新,我們就會遇到爭用情況。想象一下,你運行一個樂觀更新,將一個形狀的顏色設置為blue
,同時一個陳舊(stale)更新跑來了,說它是red
。
1. Optimistic Update: `Blue`2. Stale reactive update: `Red`3. Successful Update, comes in through socket: `Blue`
現在你會看到閃爍的圖像。樂觀更新把形狀改成藍色,響應更新又會把它改成紅色,但是一旦樂觀更新成功,新的響應更新又會把它變回藍色。
解決這樣的問題涉及一致性的主題,于是你會去搜索關于……數據庫的資料。
其實,用不著這麼麻煩。如果每個查詢都是響應式的呢?
SELECT friends FROM users JOIN friendships on friendship.user_one_id = ?
現在,好友關系的任何變化都會自動更新訂閱這個查詢的視圖。你不必操心哪些內容出現了更改,并且你的本地數據庫可以找出“最新更新”的內容,于是消除了大部分復雜性。
2服務器
在服務器上,問題只會更復雜。
E. 端點
許多后端開發工作到頭來成為了數據庫和前端之間的一種粘合劑。
// db.jsfunction getRelevantPostsFor(userId) { db.exec("SELECT * FROM users WHERE ...")}// api.jsapp.get("relevantPosts", (req, res) => { res.status(200).send(getRelevantPosts(req.userId));})
這里面也太多重復了,以至于我們最后要創建腳本來生成這些文件。但是為什麼我們需要這樣做呢?不管怎樣,它們通常是與客戶端非常緊密地耦合的。為什麼我們不能直接將數據庫暴露給客戶端呢?
F. 權限
好吧,我們不這樣做的原因是我們需要確保權限正確設置。例如,你應該只能看到你好友的帖子。為此,我們向 API 端點添加中間件:
app.put("user", auth, (req, res) => {...}
但這會變得越來越混亂。Websocket 呢?新的代碼更改有時會引入一些你意想不到的方法來更新數據庫對象。突然之間,你就遇到了麻煩。
這里要問的問題是,為什麼要在 API 級別進行身份驗證?理想情況下,我們應該有一些非常接近數據庫的東西,確保任何數據訪問都通過權限檢查。像 Postgres 這樣的數據庫有行級安全性,但這很快就會變得很麻煩。但如果你能“描述”數據庫附近的實體呢?
User { view: [ IAllowIfAdmin(), IAllowIfFriend(), IAllowIfSameUser(), ] write: [ IAllowIfAdmin(), IAllowIfSameUser(), ]}
在這里,我們編寫一些身份驗證規則,并確保不管你嘗試用哪種方式來編寫和更新用戶實體,你都可以被許可。于是乎,現在只有少數代碼更改(而不是大多數更改)會影響權限了。
G. 審計、撤消 / 重做
并且在某些時候,我們要完成的需求會增加復雜性。
例如,假設我們需要支持“撤消 / 重做”,用于好友操作。一個用戶刪除了一個好友,然后他們按下了“撤消”——我們怎麼來支持這一過程呢?
我們不能直接刪除好友關系,因為如果我這樣做的話,就沒法不知道這個人原本“已經是好友”,還是現在剛請求成為好友。在后一種情況下,我們可能需要發送好友請求才行。
為了解決這個問題,我們改進了數據模型。我們將用“好友事實”來代替單一的好友關系。
[,,]
那麼“最新事實”會代表倆人之間是否存在好友關系。
這種辦法是可行的,但大多數數據庫并不是為它設計的:查詢不像我們預期的那樣工作,優化起來也比我們預期的更難。我們最后不得不非常小心地處理更新機制,以免意外刪除記錄。
突然之間,我們變成了“某種數據庫工程師”,跑去大量查閱有關查詢優化的資料。
這種要求看似獨特,但在實踐中越來越常見。如果你處理的是金融交易,你需要這樣的機制來做審計。撤消 / 重做是許多應用中的必需品。
也許突然發生了一個錯誤,于是我們不小心刪除了數據。在事實統治的世界中不會有這樣的事情——反正你可以撤銷刪除操作。但這并不是我們大多數人生活的世界。
有一些模式將事實視為一等公民(Datomic,后文具體討論),但現在它們還是很罕見的,很少有工程師能做到。如果這種模式沒那麼罕見呢?
H. 離線模式
令人頭疼的例子還有很多。比如說離線模式——許多應用程序都是長期運行的,可以在沒有互聯網連接的情況下繼續運行一段時間。我們如何支持這一特性呢?
我們只能再次進化我們的數據模型,但這一次真正將所有內容都作為“事實”,并準備一個客戶端數據庫,該數據庫基于這些事實來演進自己的內部狀態。恢復連接后,我們應該能夠協調更改。
這很難做到。從本質上講,能做到這一步的程序員都變成了數據庫工程師。但是,如果我們在瀏覽器中有一個數據庫,讓它扮演分布式數據庫中的一個“節點”,上面的任務不就可以自動完成了嗎?
事實證明,基于事實的系統實際上更容易做到這一點。許多人認為我們需要求助于操作轉換來做這樣的事情,但正如 figma 展示的那樣,只要我們允許單一的領導者,并且可以接受最后寫入者獲勝這樣的語義,我們就可以徹底簡化這個機制,只要事實就足夠了。當你需要更嚴肅的解決方案時,你可以打開 OT 兔子洞。
想象一下…… 立即啟用離線模式。這樣一來,大多數應用程序會變成什麼樣?
I. 響應性
前面,我們討論了來自客戶端的響應性。在服務器上的響應性也是個問題。我們必須確保在數據更改時更新所有相關客戶端。例如,如果添加了一個“帖子”,我們需要通知與這個帖子相關的所有可能訂閱。
function addPost(post) { db.addPost(post); getAllFriends(post).forEach(notifyNewPost);}
這會變得相當混亂。我們很難知曉所有可能相關的主題。錯過一些主題也是很容易的:如果使用addPost
之外的查詢更新數據庫,我們永遠不會知道是不是有主題被錯過了。這項工作需要開發人員來完成。它開始做起來很容易,但會變得越來越復雜。
然而,數據庫也可以知曉所有這些訂閱,并且可以只處理更新相關的查詢。RethinkDB 是在這方面做得很好的一個例子。如果你選擇的查詢語言可以做到這一點,是不是會很方便?
J. 衍生數據
最終,我們需要將數據放在多個位置:緩存(Redis)、搜索索引(ElasticSearch)或分析引擎(Hive)。這個步驟會變得非常麻煩。你可能需要引入某種隊列(Kafka),確保所有這些衍生源都保持最新狀態。這里面的工作涉及配置機器、引入服務發現和整個 shebang 等操作。
可為什麼要這麼復雜呢?在一個常規數據庫中,你可以執行以下操作:
CREATE INDEX ...
對于其他服務,我們為什麼不能這樣做?Martin Kleppman 在他的《數據密集型應用程序》中提出了這樣一種語言:
db |> ElasticSearchdb |> Analyticsdb.user |> Redis// Bam, we've connected elastic search, analytics, and redis to our db
3破壞性因素
我們都列舉到了 J。但這些只是你開始構建應用程序后才開始面臨的問題。那麼在開始構建之前呢?
K.TTP——原型制作時間
也許今天對開發人員來說最難辦的問題是上手。如果你想存儲用戶信息并顯示一個頁面,你會怎麼做?
以前,你只需要一個index.html
和 FTP 就行了。現在,你需要 webpack、typescript、大量的構建過程,經常還需要多個服務。活動的部件太多了,邁出第一步都絕非易事。
這似乎是一個菜鳥才需要面對的問題,似乎有經驗的程序員上手起來會快很多。我認為情況更復雜一些。大多數項目都處于邊緣場景——它們不是你日常應對的那種類型。這意味著原型制作階段哪怕只多了幾分鐘,也可能會讓我們淘汰很多項目。
簡化這一步驟將大大增加我們可以使用的應用程序數量。如果這一階段能比index.html
和 FTP 更容易完成呢?
4當前的解決方案
這問題可是真夠多的。情況看起來很糟糕,但如果你回過頭看看區區幾年前的樣子,就會發現我們已經有了這麼大的進步。不管怎樣,我們不再需要自己應付那些機架了。如同文藝復興時代一樣,很多杰出的人才正在努力開發這些問題的解決方案。這些方案有哪些代表呢?
Firebase
我認為 Firebase 在推動 Web 應用程序開發方面做了一些最具創新性的工作。他們做的最重要的一件事情就是 瀏覽器上的數據庫。
有了 firebase,你可以像在服務器上一樣查詢數據。通過這種抽象,他們解決了上面列出的 A-E 問題。Firebase 可以處理樂觀更新,默認就是響應式的。它提供了對權限的支持,從而消除了對端點的需求。
K 問題也可以從中大大獲益:我認為它的原型制作速度表現還是市面上最出色的。你只需從index.html
開始就行了!
但它也有兩個問題:
第一,查詢能力。Firebase 選擇的文檔模型簡化了抽象管理,但會破壞你的查詢能力。很多時候,你必須對數據做反正則化,或者查詢變得很難處理。例如,要記錄像好友這樣的多對多關系,你需要執行以下操作:
userA: friends: userBId: true userB: friends: userAId: true
你通過兩個不同的路徑(userA/friends/userBId)和(userB/friends/userAId)對好友關系進行反正則化。要獲取完整數據,你需要手動復制一個聯接(join):
1. get `userA/friends`2. for each id, get `/$`
這種關系在你的應用程序中很快就會出現。如果能有解決方案幫助你處理它就太好了。
第二,權限。Firebase 要求你使用一種受限的語言來編寫權限。在實踐中,這些規則很快就會變得非常混亂——于是人們開始自己編寫一些高級語言并編譯成 Firebase 規則。
我們在 Facebook 對此進行了大量實驗,得出的結論是,你需要一種真正的語言來表達權限。如果 Firebase 有這樣的語言就會更加強大。
至于剩下的項目(審計、撤消 / 重做、寫入的離線模式、衍生數據)——Firebase 還沒有解決它們。
Supabase
Supabase 正在嘗試做 Firebase 為 Mongo 所做的事情,但 Supabase 是為 Postgres 做的。如果他們成功了,這將是一個非常有吸引力的選擇,因為它將解決 Firebase 面臨的最大問題:查詢能力。
到目前為止,Supabase 取得了一些重大進展。他們的身份驗證抽象非常棒,這讓它成為少數幾個像 firebase 一樣容易上手的平臺之一。
他們的實時選項允許你訂閱行級更新。例如,如果我們想知道一個好友是何時被創建、更新或更改的,我們可以這樣寫:
const friendsChange = supabase .from('friendships:friend_one_id=eq.200') .on('*', handleFriendshipChange) .subscribe()
在實踐中這可以讓你走得更遠。不過它可能會變得很麻煩。例如,如果我們創建了一個好友,我們可能沒有用戶信息,所以必須獲取它。
function handleFriendshipChange(friendship) { if (!userStore.get(friendship.friend_two_id)) { fetchUser(...) }}
這里指出了 Supabase 的主要弱點:它還沒有“瀏覽器上的數據庫”這種抽象。雖然你可以做查詢,但你要自己負責正則化并處理數據。這意味著它不能自動進行樂觀更新,不能做響應式查詢等。他們的權限模型也很像 Firebase,因為它遵循了 Postgres 的行級安全性。一開始這是很好用的,但就像 Firebase,它很快就會變得很麻煩。這些規則往往會拖慢查詢優化器的速度,并且 SQL 本身會變得越來越難推理。
GraphQL+Hasura
GraphQL 是一種很好的方法來聲明性地定義你想要從客戶端獲取的數據。像 Hasura 這樣的服務可以使用像 Postgres 這樣的數據庫,并做一些聰明的事情,比如給你一個 GraphQL API。
Hasura 很適合讀取數據。他們在處理聯接方面做得很聰明,并且可以給你一個很好的數據視圖。你可以用一個 flip 將任何查詢轉換為訂閱。當我第一次嘗試將查詢轉換為訂閱時,確實感覺這很神奇。
今天 GraphQL 工具的一大問題是它們的原型制作速度。你往往需要多個不同的庫和構建步驟。他們在數據寫入方面做得也沒那麼好。樂觀更新不會自動發生——你必須自己處理它。
小結
我們已經研究了三個最有前途的解決方案。現在,Firebase 可以立刻解決大多數問題。Supabase 以犧牲更多客戶端支持為代價為你提供了更好的查詢能力。Hasura 以犧牲原型制作速度為代價,為你提供了更強大的訂閱和更強大的本地狀態。據我所知,還沒有方案能在客戶端解決沖突,提供撤消 / 重做和強大的響應式查詢。
5未來
現在的問題是:這些工具會演變成什麼樣子?
在某些層面,未來已經到來了。例如,我認為 Figma 就是一款來自未來的應用:它可以出色地處理離線模式、撤消 / 重做和多人關系。如果我們想制作這樣的應用,理想的數據抽象應該是什麼樣的?
需求
客戶端數據庫,有著強大的查詢語言
從瀏覽器來看,這種抽象必須像 firebase 一樣,但要有強大的查詢語言。
你應該能夠查詢本地數據,并且它應該與 SQL 一樣強大。你的查詢應該是響應式的,如果有更改會自動更新。它也應該為你處理樂觀更新。
user = useQuery("SELECT * FROM users WHERE id = ?", 10);
真正的權限語言
接下來,我們需要一種可組合的權限語言。FB 的 EntFramework 也是我經常使用的例子,因為它非常強大。我們應該能夠定義實體的規則,并且應該保證我們不會意外看到不允許我們看到的東西。
User { view: [ IAllowIfAdmin(), IAllowIfFriend(), IAllowIfSameUser(), ] write: [ IAllowIfAdmin(), IAllowIfFriend(), ]}
離線模式和撤消 / 重做
最后,這個抽象應該讓我們更容易實現離線模式,或者撤消重做。如果發生本地寫入,并且服務器上存在寫入沖突,則應該有一個協調器在大多數情況下做出正確的決定。如果有問題,我們應該能夠朝著正確的方向推動它前進。
無論我們選擇什麼抽象,它都應該讓我們能夠在離線時運行寫入操作。
下一個云
最后,我們應該能夠表達數據依賴關系,而無需啟動任何東西。一個簡單的命令:
db.user |> Redis
對用戶的所有查詢都應該神奇地被 Redis 緩存。
實現的草圖
好吧,這些需求聽起來很神奇。那麼今天滿足它們的實現會是什麼樣子?
Diatomic 和 Datascript
在 Clojure 世界中,人們長期以來一直是 Datomic 的粉絲。Datomic 是一個基于事實的數據庫,可以讓你“看到時間線上的每一個更改”。Nikita Tonsky 還實現了 datascript,這是一個與 Datomic 語義相同的客戶端數據庫和查詢引擎!
它們已被用于構建支持離線的應用程序(如 Roam)或協作應用程序(如 Precursor)。如果我們在后端打包一個類似 Datomic 的數據庫,在前端打包一個類似 datascript 的數據庫,它就可以成為“具有強大查詢語言的客戶端數據庫”!
響應性
Datomic 讓你可以輕松地將新提交的事實訂閱到數據庫。如果我們在頂層創建一個服務,讓它保留查詢并聽取這些事實,是不是會很棒?出現一個更改后,我們將更新相關查詢。突然之間,我們的數據庫變成實時的了!
權限語言
我們的服務器可以接受一些代碼片段,并在獲取數據時運行它們。這些片段將負責處理權限,為我們提供強大的權限語言!
管道
最后,我們可以編寫一些 DSL,讓你可以根據用戶的喜好將數據通過管道傳輸到 Elastic Search、Redis 等。
有了它,我們就有了一個優秀的方案。
注意事項
那麼,為什麼這種方案還不存在呢?那是因為……
Datalog 還不流行
如果我們使用 Datomic 這樣的數據庫,我們就不會再使用 SQL。Datomic 使用一種基于邏輯的查詢語言,稱為 Datalog。現在它與 SQL 一樣強大,甚至更為強大。唯一的問題是,對于外行來說,它看起來非常難上手的樣子:
[:find [(pull ?c [:conversation/user :conversation/message]) ...] :where [?e :session/thread ?thread-id] [?c :conversation/thread ?thread-id]]
這個查詢將查找當前“會話”中活動線程的所有消息以及用戶信息。不錯!一旦你學會了它,就會意識到它是一種優雅而出色的語言。但我認為這還不夠。原型制作速度需要非常快才行,我們可能沒時間去學這種語言了。
有一些有趣的實驗可以簡化這一過程。例如,Dennis Heihoff嘗試 使用自然語言。這給我們啟發了一種有趣的解決方案:我們能否編寫一種稍微冗長但更加自然的查詢語言,把它編譯為 Datalog?我認同這種想法。
另一個問題是數據建模也與人們習慣的做法不一樣。Firebase 是黃金標準,你可以在不指定任何 schema 的情況下編寫你的第一個更改。
雖然做起來很難,但我認為我們的目標應該是盡可能接近“簡單易用”。Datascript 只要求你指明引用和多值屬性。Datomic 需要一個 schema,但也許如果我們使用開源的、基于 datalog 的數據庫,我們可以增強它來做類似的事情。要麼盡可能少用 schema,要麼是“神奇的可檢測 schema”。
Datalog 很難實現響應性
SQL 和 Datalog 都存在的一個大問題是,它們很難基于一些新的更改來確定哪些查詢需要更新。
我不認為這是不可能解決的障礙。Hasura 可以做輪詢,而且可擴展。我們也可以嘗試使用特定的訂閱語言,類似于 Supabase。如果我們可以證明某些查詢只能通過事實的某些子集來更改,我們可以將它們從輪詢中移出。
這是一個棘手的問題,但我認為它還是可以解決的。
權限語言會減慢速度
讓權限檢查成為一種成熟的語言的話,一個問題是我們容易過度獲取數據。
我認為這個問題是值得考慮的,但如果使用像 Datomic 這樣的數據庫,我們就可以解決它。數據讀取很容易擴展和緩存。因為一切都是事實,我們可以創建一個界面來引導人們只獲取他們需要的值。
Facebook 就做到了這一點。這可能會很難,但終究是可行的。
這個抽象可能太大了
框架通常無法通用化。例如,如果我們想共享鼠標位置怎麼辦?這是短暫的狀態,不適合數據庫,但我們確實需要讓它實時化——我們應該把它保存在哪里?如果你構建這樣的抽象,將會出現很多這樣的事情,并且你很可能會搞錯。
我認為這確實是一個問題。如果有人要解決這個問題,最好的辦法是采用 Rails 方法:使用它構建一個生產應用,并將內部組件提取為產品。我認為他們很有可能找到正確的抽象。
它只會用于玩具項目
這類產品的共同問題是,人們只會將它們用于業余愛好項目,而且里面不會有很多商機。我認為 Heroku 和 Firebase 在這里指明了正確的出路。
大企業都是從業余項目開始起家的。老一輩工程師可能將 Firebase 視為玩具,但現在許多成功的初創公司都在使用 Firebase。它不僅僅是一個數據庫,也許它還會成為一個全新的平臺——甚至是 AWS 的繼任者。
市場競爭非常激烈
市場競爭非常激烈,用戶變化無常。Slava 的《為什麼 RethinkDB 會失敗》描繪了在開發工具市場中獲勝的難度有多大。我不認為他是錯的。這樣做需要對如何構建護城河并擴展成下一個 AWS 給出令人信服的回答。
6結語
好吧,我們涵蓋了痛點,討論了競爭對手,介紹了理想的解決方案,并考慮了諸多問題。謝謝你陪我走過這段旅程!
發文者:鏈站長,轉載請註明出處:https://www.jmb-bio.com/4171.html