不久之前,我和 Chris 一起為一個大型青年運動組織開發(fā)企業(yè) iPad 應(yīng)用。我們選擇 Core Data 作為數(shù)據(jù)持久化工具,并根據(jù)需求定制了數(shù)據(jù)同步的解決方案。根據(jù) Drew 文章中提到的同步方式分類表格,我們使用的是異步客戶端-服務(wù)器(client-server)方式。?
本文將對我們決策和實現(xiàn)的過程進行案例分析,以供大家學習如何定制自己的同步方案。我們的最終方案并不是完美或者普遍適用的,但是現(xiàn)階段它能夠滿足我們的需求。
在我們深入研究之前,如果你對數(shù)據(jù)同步方案感興趣(既然你在讀這篇文章,我覺得這應(yīng)該是肯定的),我強烈建議你去 Brent 的博客閱讀一下 Verper 應(yīng)用同步方案的系列文章。跟隨 Brent 的思路來分析 Vesper 的同步方案實現(xiàn)將會是一次絕妙的閱讀體驗。
如 「iCloud 和 Core Data」或者 Dropbox 數(shù)據(jù)存儲 API 中所示,現(xiàn)在大部分的同步方案都面向同一用戶多個設(shè)備之間數(shù)據(jù)同步的問題。不過我們面臨的需求略有不同,我們的應(yīng)用將會被部署在組織里的大約 50 個設(shè)備中,每個設(shè)備屬于不同的組織成員,大家都對同一個數(shù)據(jù)集進行操作,我們需要在這些設(shè)備間進行數(shù)據(jù)同步。
數(shù)據(jù)本身結(jié)構(gòu)復(fù)雜,包含大約一打?qū)嶓w和其間的各種關(guān)系。我們需要處理的數(shù)據(jù)量很大,真實情況下數(shù)據(jù)記錄的量將迅速達到十萬量級。
雖然大部分情況下組織成員都能夠連上 Wi-Fi,網(wǎng)絡(luò)連接的質(zhì)量其實是相當差的。保證大部分情況下組織成員能夠使用應(yīng)用并訪問數(shù)據(jù)集是非常重要的,所以我們需要實現(xiàn)離線情況下的各種數(shù)據(jù)操作。
將上一小節(jié)描述的應(yīng)用場景搞清楚之后,我們同步方案的需求其實已經(jīng)相當清楚了:
由于數(shù)據(jù)模型的嵌套結(jié)構(gòu)和可能出現(xiàn)的高網(wǎng)絡(luò)延遲,傳統(tǒng)的 REST 風格 API 并不是一個好選擇。例如為了在應(yīng)用中顯示一個儀表盤(dashboard)視圖,必須遍歷好幾個數(shù)據(jù)層級來獲取所需的所有數(shù)據(jù):隊伍、隊員聯(lián)盟、隊員、單屏顯示(screen)和單屏顯示元素(screen item)。如果我們分別獲取這幾類數(shù)據(jù),在數(shù)據(jù)更新完畢之前我們需要發(fā)起很多次請求。
實際中我們采用了更原子化的操作,在同步數(shù)據(jù)量不變的情況下發(fā)起請求數(shù)更少??蛻舳伺c服務(wù)器僅使用一個 API 接口進行交互:/sync。
為了實現(xiàn)這個方案,我們需要自定義客戶端與服務(wù)器交互的數(shù)據(jù)格式,從而在一次數(shù)據(jù)同步同求中包含所有需要的數(shù)據(jù)。
客戶端與服務(wù)器之間通過自定義的 JSON 格式數(shù)據(jù)進行交互。無論請求由誰發(fā)起,都采用同樣的格式,如下是一個簡單的示例:
{
"maxRevision": 17382,
"changeSets: [
...
]
}
上例中的 JSON 數(shù)據(jù)頂層含有 maxRevision 和 changeSets 兩個 key。maxRevision 用來唯一標識客戶端當前數(shù)據(jù)版本的版本號,changeSets 則是一個以數(shù)據(jù)修改集(change set)為元素的列表,如下所示:
{
"types": [ "users" ],
"users": {
"create": [],
"update": [ 1013 ],
"delete": [],
"attributes": {
"1013": {
"first_name": "Florian",
"last_name": "Kugler",
"date_of_birth": "1979-09-12 00:00:00.000+00"
"revision": 355
}
}
}
}
頂層的 types 對應(yīng)這個修改集中涉及的所有數(shù)據(jù)實體類型。每個類型又會對應(yīng)一個針對它自己的修改集合,包含創(chuàng)建(create)、更新(update)和刪除(delete)操作,每個操作對應(yīng)指定的記錄 ID。這些 ID 最后會對應(yīng)到針對這條記錄的哪些屬性(attributes)進行了新建或更新操作。
這套數(shù)據(jù)結(jié)構(gòu)參考了之前 Web 端使用的數(shù)據(jù)結(jié)構(gòu),當時采用的結(jié)構(gòu)有利于原有客戶端的數(shù)據(jù)處理,也同樣滿足現(xiàn)在的需求。
接下來我們看一個復(fù)雜一點的例子。假設(shè)我們?yōu)橐慌_設(shè)備上其中一名隊員添加了新的單屏顯示數(shù)據(jù),當需要同步到服務(wù)器上時,如下是請求中包含的數(shù)據(jù)結(jié)構(gòu):
{
"maxRevision": 1000,
"changeSets": [
{
"types": [ "screen_instances", "screen_instance_items" ],
"screen_instances": {
"create": [ -10 ],
"update": [],
"delete": [],
"attributes": {
"-10": {
"screen_id": 749,
"date": "2014-02-01 13:15:23.487+01",
"comment": ""
}
}
},
"screen_instance_items: {
"create": [ -11, -12 ],
"update": [],
"delete": [],
"attributes": {
"-11": {
"screen_instance_id": -10,
"numeric_value": 2
},
"-12": {
...
}
}
}
}
]
}
注意其中涉及的記錄 ID 是負數(shù),這是因為它們是新建的條目。新建的 screen_instance 條目 ID 是 -10,在后面的 screen_instance_items 條目中引用到了這個 ID 作為外鍵。
當服務(wù)器處理完這個請求后(假設(shè)沒有沖突或者權(quán)限問題),發(fā)回給客戶端的響應(yīng)請求中將包含如下 JSON 數(shù)據(jù):
{
"maxRevision": 1001,
"changeSets": [
{
"conflict": false,
"types": [ "screen_instances", "screen_instance_items" ],
"screen_instances": {
"create": [ 321 ],
"update": [],
"delete": [],
"attributes": {
"321": {
"__oldId__": -10
"revision": 1001
"screen_id": 749,
"date": "2014-02-01 13:15:23.487+01",
"comment": "",
}
}
},
"screen_instance_items: {
"create": [ 412, 413 ],
"update": [],
"delete": [],
"attributes": {
"412": {
"__oldId__": -11,
"revision": 1001,
"screen_instance_id": 321,
"numeric_value": 2
},
"413": {
"__oldId__": -12,
"revision": 1001,
...
}
}
}
}
]
}
客戶端在請求中包含版本號 1000,而服務(wù)器返回版本號 1001,同時將 1001 這個版本賦予所有這次新建成功的記錄。(從服務(wù)器返回的版本號只增加了 1 我們可以知道客戶端的修改是基于最新數(shù)據(jù)進行的。)
原來的負數(shù) ID 現(xiàn)在已經(jīng)被服務(wù)器用真實的 ID 替換。為了保留記錄間的聯(lián)系,負數(shù)外鍵也被相應(yīng)更新成了實際的 ID。但是客戶端仍然可以獲得臨時負數(shù) ID 與服務(wù)器返回的永久性正數(shù) ID 之間的關(guān)聯(lián)關(guān)系,因為服務(wù)器將臨時負數(shù) ID 作為記錄屬性的一部分返回了。
如果客戶端的修改不是基于最新的數(shù)據(jù)(假如客戶端的版本號是 995),服務(wù)器會返回多個修改集以將客戶端數(shù)據(jù)更新到最新版本。具體來說,服務(wù)器會將 995 版本到 1000 版本的更新操作與客戶端發(fā)送的 1001 版本一起返回。
如前所述,在這個所有人操作同一套數(shù)據(jù)的應(yīng)用場景下,任何人都不應(yīng)該在不知曉其他人修改的情況下覆蓋那些改動。我們采取的方案就是只要你沒有看到其他人對數(shù)據(jù)的最新修改,你就不能直接對這些修改進行覆蓋。
有了版本號的幫助,這個方案變得很容易實現(xiàn)??蛻舳税l(fā)送給服務(wù)器的任何修改,都包含有對應(yīng)數(shù)據(jù)條目的版本號。因為客戶端從來不修改已有版本號,所以版本號反映了客戶端上一次與服務(wù)器交互的數(shù)據(jù)情況。服務(wù)器就可以根據(jù)版本號搜索對應(yīng)的條目,并屏蔽基于非最新數(shù)據(jù)的修改。
這個設(shè)計的優(yōu)雅之處在于采用的數(shù)據(jù)交互格式允許事務(wù)性修改。JSON 數(shù)據(jù)中的一個修改集可以包含對不同數(shù)據(jù)實體的多個修改。在服務(wù)器端,修改集中所有修改以事務(wù)方式進行,如果其中任何操作導致沖突,則之前的操作全部回滾,然后服務(wù)器為該修改集加上沖突(conflict)標識返回給客戶端。
問題在于客戶端在沖突發(fā)生后如何將數(shù)據(jù)恢復(fù)到正常狀態(tài)。因為修改可能在客戶端處于離線狀態(tài)下進行,一天之后才會上傳到服務(wù)器,所以必須保存一份精確的修改日志并存儲起來。這樣我們就可以在沖突發(fā)生時撤銷任何修改。
我們最終采用了一種不同的方案:因為只有服務(wù)器能夠確定數(shù)據(jù)的正確狀態(tài),所以只需要在沖突發(fā)生時將正確的數(shù)據(jù)返回即可。服務(wù)器端針對此方案的實現(xiàn)非常簡單,而客戶端則需要做很多工作來保證這一方案的正確。
例如客戶端刪除了一條不允許刪除的記錄,服務(wù)器就會返回一個有沖突(conflict)標識的修改集,其中包含被錯誤刪除的記錄,以及與該記錄相關(guān)聯(lián)的其他記錄。這樣客戶端就很容易恢復(fù)刪除的記錄,而不用在本地記錄每一次操作。
之前我們已經(jīng)討論了數(shù)據(jù)同步的基本概念,現(xiàn)在來看一下具體實現(xiàn)的細節(jié)。
后端是使用 node.js 寫的輕量級應(yīng)用,使用 PostgreSQL 存儲結(jié)構(gòu)化數(shù)據(jù),同時使用 Redis 緩存所有數(shù)據(jù)庫修改事務(wù)的修改集(換言之,每個修改集對應(yīng)從 x 版本到 x+1 版本的全部修改)。在客戶端發(fā)起同步請求時,服務(wù)器可以從緩存的修改集中迅速找到客戶端缺失的最新修改并返回,而不用臨時去查詢數(shù)據(jù)庫。
后端實現(xiàn)的具體細節(jié)超出了本文的范圍,但是老實說這些細節(jié)中并沒有什么激動人心的地方。服務(wù)器只是簡單地為每一個接收到的修改集發(fā)起一個事務(wù),然后嘗試將這些操作寫入數(shù)據(jù)庫。如果發(fā)生沖突,事務(wù)就進行回滾,然后構(gòu)造一個正確狀態(tài)的修改集。如果沒有錯誤發(fā)生,服務(wù)器將以一個包含最新版本號的修改集確認這次修改。
處理完客戶端發(fā)送過來的修改后,服務(wù)器會檢查客戶端的最新版本號是否落后于自己的,如果是的話就將上面構(gòu)造的正確修改集返回給客戶端以同步到最新狀態(tài)。
在客戶端使用了 Core Data,所以我們需要在后臺記錄用戶的每一次修改并提交到服務(wù)器。同時我們也需要處理服務(wù)器發(fā)送過來的數(shù)據(jù)并與本地數(shù)據(jù)進行合并。
為了達到以上目的,我們使用了一個主隊列管理用戶界面相關(guān)的對象上下文(object context)(包括用戶輸入的所有數(shù)據(jù)),另一個獨立的私有隊列則用于管理服務(wù)器發(fā)送過來的數(shù)據(jù)。
當用戶修改數(shù)據(jù)的時候,主上下文(main context)會被保存,而我們監(jiān)聽了保存事件的通知。從通知中我們可以獲取用戶操作中插入、更新和刪除的數(shù)據(jù)對象,從而構(gòu)造一個修改集加入到隊列中等候最終發(fā)送給服務(wù)器。這個隊列是持久化的(隊列本身和其中的對象使用 NSCoding 協(xié)議),所以即使應(yīng)用在與服務(wù)器同步前退出了我們也不會丟失任何修改。
當客戶端與服務(wù)器建立好連接之后,就從隊列中拿出所有的修改集并轉(zhuǎn)換為上文提及的 JSON 格式,帶上當前最新的版本號發(fā)送給服務(wù)器。
當服務(wù)器的響應(yīng)返回時,客戶端查看收到的所有修改集,然后更新私有隊列中相應(yīng)的本地數(shù)據(jù)。只有當這次更新成功完成時,客戶端才會將服務(wù)器發(fā)送過來的最新版本號存儲到 Core Data 中指定的數(shù)據(jù)實體中。
最后很重要的一點是私有隊列中的修改會合并到主上下文中,所以用戶界面會相應(yīng)更新。
當以上所有操作完成后,我們就可以接著處理隊列中新出現(xiàn)的修改以發(fā)起下一次同步請求。
我們必須妥善處理用于存儲服務(wù)器返回數(shù)據(jù)的私有隊列與主上下文之間的沖突。用戶再修改主上下文時很有可能后臺正在接收服務(wù)器的數(shù)據(jù)。
因為服務(wù)器返回的數(shù)據(jù)才是絕對正確的,在將私有隊列中的數(shù)據(jù)合并到主上下文的時候,我們的策略就是持久化存儲的數(shù)據(jù)相比內(nèi)存中的數(shù)據(jù)更優(yōu)先采用。
當用戶修改一個服務(wù)器已經(jīng)更新(比如已經(jīng)刪除)的對象時,這種合并策略會遇到一些問題。當這種修改在私有隊列中保存但還未合并到主上下文時可以發(fā)送一個自定義的通知,這樣用戶界面可以針對此作出反應(yīng)。
由于我們需要為移動設(shè)備處理大量的數(shù)據(jù)條目(十萬級),將所有的數(shù)據(jù)從服務(wù)器下載并導入到 iOS 設(shè)備中將花費很長時間。因此,我們會在應(yīng)用中附帶一份數(shù)據(jù)集的最新快照。這些快照使用經(jīng)過特殊設(shè)置的模擬器運行生成,該設(shè)置可以在本地數(shù)據(jù)不是最新的情況下從服務(wù)器獲取所需的數(shù)據(jù)。
然后我們對生成的 SQLite 數(shù)據(jù)庫文件運行如下兩個命令:
sqlite> PRAGMA wal_checkpoint;
sqlite> VACUUM;
第一條命令確保日志中記錄的所有之前的修改同步到主 .sqlite 文件中,第二條命令確保文件不會過大。
當應(yīng)用第一次啟動時,數(shù)據(jù)文件從應(yīng)用中被拷貝到最終的位置。想對這個過程以及其他導入數(shù)據(jù)到 Core Data 中的方法有更多了解,可以參考這篇文章。
因為 Core Data 數(shù)據(jù)模型中含有一個存儲版本號的特殊數(shù)據(jù)實體,應(yīng)用中包含的數(shù)據(jù)文件會自動將正確的版本號寫入該實體中作為初始版本號。
因為 JSON 格式數(shù)據(jù)體積相對較大,使用 gzip 格式壓縮發(fā)送給服務(wù)器的數(shù)據(jù)就變得非常重要。在請求中加入 Accept-Encoding: gzip 頭信息能讓服務(wù)器同樣使用 gzip 壓縮返回的數(shù)據(jù)。不過這只對服務(wù)器返回的數(shù)據(jù)有效,并不會在發(fā)送時啟用壓縮。
客戶端包含 Accept-Encoding 頭信息僅僅是為了告訴服務(wù)器自己能夠支持 gzip 格式的數(shù)據(jù),所以服務(wù)器應(yīng)該在支持 gzip 壓縮的情況下返回壓縮過的數(shù)據(jù)。一般情況下客戶端并不知道發(fā)送請求時服務(wù)器本身是否支持壓縮,所以默認情況下客戶端發(fā)送的數(shù)據(jù)不能進行壓縮。
在我們的這個案例中,因為服務(wù)器也是我們能夠控制的,所以我們可以保證能夠支持 gzip 壓縮。所以我們可以在發(fā)送數(shù)據(jù)到服務(wù)器時加上 Content-Encoding: gzip 頭信息并壓縮數(shù)據(jù),因為我們知道服務(wù)器肯定能夠處理。可以參考這個 NSData category 獲取一個 gzip 壓縮的案例。
創(chuàng)建新的記錄時,客戶端會為這些記錄分配臨時 ID,這樣就可以記錄它們之間的關(guān)系并發(fā)送給服務(wù)器。我們使用了負數(shù)作為臨時 ID,從 -1 開始依次遞減。當前最新的臨時 ID 會持久化存儲在標準的用戶預(yù)設(shè)值(standard user defaults)中。
由于我們采用了這種策略,一次只處理一個同步請求是非常重要的,同時我們也需要維護臨時 ID 與服務(wù)器返回的真實 ID 之間的映射關(guān)系。
發(fā)送同步請求之前,我們會檢查是否已經(jīng)收到對應(yīng)待提交修改的永久 ID。如果有的話,我們將這些待提交修改的臨時 ID 換成永久 ID,并更新相應(yīng)的外鍵。如果我們不這樣做或者一次發(fā)送多個同步請求,可能會導致多次創(chuàng)建同一條記錄而不是在已有基礎(chǔ)上進行更新,因為我們使用臨時 ID 將這條記錄多次發(fā)送給了服務(wù)器。
因為私有隊列(在導入修改時)和主上下文一樣也需要訪問這種映射關(guān)系,為了線程安全我們將對其的訪問封裝在了一個順序隊列中。
構(gòu)建自己的數(shù)據(jù)同步方案并不是一個簡單的任務(wù),很可能將花費超出你想象的時間。至少處理本文中提及的各種同步系統(tǒng)邊界情況就會占用你很多時間。不過相應(yīng)的你也能得到靈活性和控制權(quán),比如同一套后端既為 Web 接口提供數(shù)據(jù),又在后臺做數(shù)據(jù)分析。
如果你面對的是一個罕見的同步場景(比如文章提到的例子中,我們需要在很多人的設(shè)備之間相互同步),你也許只能自己定制解決方案。也許這將是一個痛苦的過程,因為你需要在腦子里不停地考慮各種邊界情況,不過這也意味著這是一個值得做的有趣項目。