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