前言
嗯,你們要的大招。跟著這篇文章一起也發(fā)布了CTPersistance和CTJSBridge這兩個庫,希望大家在實際使用的時候如果遇到問題,就給我提issue或者PR或者評論區(qū)。每一個issue和PR以及評論我都會回復(fù)的。
持久化方案不管是服務(wù)端還是客戶端,都是一個非常值得討論的話題。尤其是在服務(wù)端,持久化方案的優(yōu)劣往往都會在一定程度上影響到產(chǎn)品的性能。然而在客戶端,只有為數(shù)不多的業(yè)務(wù)需求會涉及持久化方案,而且在大多數(shù)情況下,持久化方案對性能的要求并不是特別苛刻。所以我在移動端這邊做持久化方案設(shè)計的時候,考慮更多的是方案的可維護(hù)和可拓展,然后在此基礎(chǔ)上才是性能調(diào)優(yōu)。這篇文章中,性能調(diào)優(yōu)不會單獨開一節(jié)來講,而會穿插在各個小節(jié)中,大家有心的話可以重點看一下。
持久化方案對整個App架構(gòu)的影響和網(wǎng)絡(luò)層方案對整個架構(gòu)的影響類似,一般都是導(dǎo)致整個項目耦合度高的罪魁禍?zhǔn)?。而我也是一如既往的去Model化的實踐者,在持久層去Model化的過程中,我引入了Virtual Record的設(shè)計,這個在文中也會詳細(xì)描述。
這篇文章主要講以下幾點:
根據(jù)需求決定持久化方案
持久層與業(yè)務(wù)層之間的隔離
持久層與業(yè)務(wù)層的交互方式
數(shù)據(jù)遷移方案
數(shù)據(jù)同步方案
另外,針對數(shù)據(jù)庫存儲這一塊,我寫了一個CTPersistance,這個庫目前能夠完成大部分的持久層需求,同時也是我的Virtual Record這種設(shè)計思路的一個樣例。這個庫可以直接被cocoapods引入,希望大家使用的時候,能夠多給我提issue。這里是CTPersistance Class Reference。
根據(jù)需求決定持久化方案
在有需要持久化需求的時候,我們有非常多的方案可供選擇:NSUserDefault、KeyChain、File,以及基于數(shù)據(jù)庫的無數(shù)子方案。因此,當(dāng)有需要持久化的需求的時候,我們首先考慮的是應(yīng)該采用什么手段去進(jìn)行持久化。
NSUserDefault
一般來說,小規(guī)模數(shù)據(jù),弱業(yè)務(wù)相關(guān)數(shù)據(jù),都可以放到NSUserDefault里面,內(nèi)容比較多的數(shù)據(jù),強業(yè)務(wù)相關(guān)的數(shù)據(jù)就不太適合NSUserDefault了。另外我想吐槽的是,天貓這個App其實是沒有一個經(jīng)過設(shè)計的數(shù)據(jù)持久層的。然后天貓里面的持久化方案就很混亂,我就見到過有些業(yè)務(wù)線會把大部分業(yè)務(wù)數(shù)據(jù)都塞到NSUserDefault里面去,當(dāng)時看代碼的時候我特么就直接跪了。。。問起來為什么這么做?結(jié)果說因為寫起來方便~你妹。。。
keychain
Keychain是蘋果提供的帶有可逆加密的存儲機制,普遍用在各種存密碼的需求上。另外,由于App卸載只要系統(tǒng)不重裝,Keychain中的數(shù)據(jù)依舊能夠得到保留,以及可被iCloud同步的特性,大家都會在這里存儲用戶唯一標(biāo)識串。所以有需要加密、需要存iCloud的敏感小數(shù)據(jù),一般都會放在Keychain。
文件存儲
文件存儲包括了Plist、archive、Stream等方式,一般結(jié)構(gòu)化的數(shù)據(jù)或者需要方便查詢的數(shù)據(jù),都會以Plist的方式去持久化。Archive方式適合存儲平時不太經(jīng)常使用但很大量的數(shù)據(jù),或者讀取之后希望直接對象化的數(shù)據(jù),因為Archive會將對象及其對象關(guān)系序列化,以至于讀取數(shù)據(jù)的時候需要Decode很花時間,Decode的過程可以是解壓,也可以是對象化,這個可以根據(jù)具體中的實現(xiàn)來決定。Stream就是一般的文件存儲了,一般用來存存圖片啊啥的,適用于比較經(jīng)常使用,然而數(shù)據(jù)量又不算非常大的那種。
數(shù)據(jù)庫存儲
數(shù)據(jù)庫存儲的話,花樣就比較多了。蘋果自帶了一個Core Data,當(dāng)然業(yè)界也有無數(shù)替代方案可選,不過真正用在iOS領(lǐng)域的除了Core Data外,就是FMDB比較多了。數(shù)據(jù)庫方案主要是為了便于增刪改查,當(dāng)數(shù)據(jù)有狀態(tài)和類別的時候最好還是采用數(shù)據(jù)庫方案比較好,而且尤其是當(dāng)這些狀態(tài)和類別都是強業(yè)務(wù)相關(guān)的時候,就更加要采用數(shù)據(jù)庫方案了。因為你不可能通過文件系統(tǒng)遍歷文件去甄別你需要獲取的屬于某個狀態(tài)或類別的數(shù)據(jù),這么做成本就太大了。當(dāng)然,特別大量的數(shù)據(jù)也不適合直接存儲數(shù)據(jù)庫,比如圖片或者文章這樣的數(shù)據(jù),一般來說,都是數(shù)據(jù)庫存一個文件名,然后這個文件名指向的是某個圖片或者文章的文件。如果真的要做全文索引這種需求,建議最好還是掛個API丟到服務(wù)端去做。
總的說一下
NSUserDefault、Keychain、File這些持久化方案都非常簡單基礎(chǔ),分清楚什么時候用什么就可以了,不要像天貓那樣亂寫就好。而且在這之上并不會有更復(fù)雜的衍生需求,如果真的要針對它們寫文章,無非就是寫怎么儲存怎么讀取,這個大家隨便Google一下就有了,我就不浪費筆墨了。由于大多數(shù)衍生復(fù)雜需求都是通過采用基于數(shù)據(jù)庫的持久化方案去滿足,所以這篇文章的重點就數(shù)據(jù)庫相關(guān)的架構(gòu)方案設(shè)計和實現(xiàn)。如果文章中有哪些問題我沒有寫到的,大家可以在評論區(qū)提問,我會一一解答或者直接把遺漏的內(nèi)容補充在文章中。
持久層實現(xiàn)時要注意的隔離
在設(shè)計持久層架構(gòu)的時候,我們要關(guān)注以下幾個方面的隔離:
持久層與業(yè)務(wù)層的隔離
數(shù)據(jù)庫讀寫隔離
多線程控制導(dǎo)致的隔離
數(shù)據(jù)表達(dá)和數(shù)據(jù)操作的隔離
1. 持久層與業(yè)務(wù)層的隔離
關(guān)于Model
在具體講持久層下數(shù)據(jù)的處理之前,我覺得需要針對這個問題做一個完整的分析。
在View層設(shè)計中我分別提到了胖Model和瘦Model的設(shè)計思路,而且告訴大家我更加傾向于胖Model的設(shè)計思路。在網(wǎng)絡(luò)層設(shè)計里面我使用了去Model化的思路設(shè)計了APIMananger與業(yè)務(wù)層的數(shù)據(jù)交互。這兩個看似矛盾的關(guān)于Model的設(shè)計思路在我接下來要提出的持久層方案中其實是并不矛盾,而且是相互配合的。在網(wǎng)絡(luò)層設(shè)計這篇文章中,我對去Model化只給出了思路和做法,相關(guān)的解釋并不多,是因為要解釋這個問題涉及面會比較廣,寫的時候并不認(rèn)為在那篇文章里做解釋是最好的時機。由于持久層在這里胖Model和去Model化都會涉及,所以我覺得在講持久層的時候解釋這個話題會比較好。
我在跟別的各種領(lǐng)域的架構(gòu)師交流的時候,發(fā)現(xiàn)大家都會或多或少地混用Model和Model Layer的概念,然后往往導(dǎo)致大家討論的問題最后都不在一個點上,說Model的時候他跟你說Model Layer,那好吧,我就跟你說Model Layer,結(jié)果他又在說Model,于是問題就討論不下去了。我覺得作為架構(gòu)師,如果不分清楚這兩個概念,肯定是會對你設(shè)計的架構(gòu)的質(zhì)量有很大影響的。
如果把Model說成Data Model,然后跟Model Layer放在一起,這樣就能夠很容易區(qū)分概念了。
Data Model
Data Model這個術(shù)語針對的問題領(lǐng)域是業(yè)務(wù)數(shù)據(jù)的建模,以及代碼中這一數(shù)據(jù)模型的表征方式。兩者相輔相承:因為業(yè)務(wù)數(shù)據(jù)的建模方案以及業(yè)務(wù)本身特點,而最終決定了數(shù)據(jù)的表征方式。同樣操作一批數(shù)據(jù),你的數(shù)據(jù)建模方案基本都是細(xì)化業(yè)務(wù)問題之后,抽象得出一個邏輯上的實體。在實現(xiàn)這個業(yè)務(wù)時,你可以選擇不同的表征方式來表征這個邏輯上的實體,比如字節(jié)流(TCP包等),字符串流(JSON、XML等),對象流。對象流又分通用數(shù)據(jù)對象(NSDictionary等),業(yè)務(wù)數(shù)據(jù)對象(HomeCellModel等)。
前面已經(jīng)遍歷了所有的Data Model的形式。在習(xí)慣上,當(dāng)我們討論Model化時,都是單指對象流中的業(yè)務(wù)數(shù)據(jù)對象這一種。然而去Model化就是指:更多地使用通用數(shù)據(jù)對象去表征數(shù)據(jù),業(yè)務(wù)數(shù)據(jù)對象不會在設(shè)計時被優(yōu)先考慮的一種設(shè)計傾向。這里的通用數(shù)據(jù)對象可以在某種程度上理解為范型。
Model Layer
Model Layer描述的問題領(lǐng)域是如何對數(shù)據(jù)進(jìn)行增刪改查(CURD, Create Update Read Delete),和相關(guān)業(yè)務(wù)處理。一般來說如果在Model Layer中采用瘦Model的設(shè)計思路的話,就差不多到CURD為止了。胖Model還會關(guān)心如何為需要數(shù)據(jù)的上層提供除了增刪改查以外的服務(wù),并為他們提供相應(yīng)的解決方案。例如緩存、數(shù)據(jù)同步、弱業(yè)務(wù)處理等。
我的傾向
我更加傾向于去Model化的設(shè)計,在網(wǎng)絡(luò)層我設(shè)計了reformer來實現(xiàn)去Model化。在持久層,我設(shè)計了Virtual Record來實現(xiàn)去Model化。
因為具體的Model是一種很容易引入耦合的做法,在盡可能弱化Model概念的同時,就能夠為引入業(yè)務(wù)和對接業(yè)務(wù)提供充分的空間。同時,也能通過去Model的設(shè)計達(dá)到區(qū)分強弱業(yè)務(wù)的目的,這在將來的代碼遷移和維護(hù)中,是至關(guān)重要的。很多設(shè)計不好的架構(gòu),就在于架構(gòu)師并沒有認(rèn)識到區(qū)分強弱業(yè)務(wù)的重要性,所以就導(dǎo)致架構(gòu)腐化的速度很快,越來越難維護(hù)。
所以說回來,持久層與業(yè)務(wù)層之間的隔離,是通過強弱業(yè)務(wù)的隔離達(dá)到的。而Virtual Record正是因為這種去Model化的設(shè)計,從而達(dá)到了強弱業(yè)務(wù)的隔離,進(jìn)而做到持久層與業(yè)務(wù)層之間既隔離同時又能交互的平衡。具體Virtual Record是什么樣的設(shè)計,我在后面會給大家分析。
2. 數(shù)據(jù)庫讀寫隔離
在網(wǎng)站的架構(gòu)中,對數(shù)據(jù)庫進(jìn)行讀寫分離主要是為了提高響應(yīng)速度。在iOS應(yīng)用架構(gòu)中,對持久層進(jìn)行讀寫隔離的設(shè)計主要是為了提高代碼的可維護(hù)性。這也是兩個領(lǐng)域要求架構(gòu)師在設(shè)計架構(gòu)時要求側(cè)重點不同的一個方面。
在這里我們所謂的讀寫隔離并不是指將數(shù)據(jù)的讀操作和寫操作做隔離。而是以某一條界限為準(zhǔn),在這個界限以外的所有數(shù)據(jù)模型,都是不可寫不可修改,或者修改屬性的行為不影響數(shù)據(jù)庫中的數(shù)據(jù)。在這個界限以內(nèi)的數(shù)據(jù)是可寫可修改的。一般來說我們在設(shè)計時劃分的這個界限會和持久層與業(yè)務(wù)層之間的界限保持一致,也就是業(yè)務(wù)層從持久層拿到數(shù)據(jù)之后,都不可寫不可修改,或業(yè)務(wù)層針對這一數(shù)據(jù)模型的寫操作、修改操作都對數(shù)據(jù)庫文件中的內(nèi)容不產(chǎn)生作用。只有持久層中的操作才能夠?qū)?shù)據(jù)庫文件中的內(nèi)容產(chǎn)生作用。
在蘋果官方提供的持久層方案Core Data的架構(gòu)設(shè)計中,并沒有針對讀寫作出隔離,數(shù)據(jù)的結(jié)果都是以NSManagedObject扔出。所以只要業(yè)務(wù)工程師稍微一不小心動一下某個屬性,NSManagedObjectContext在save的時候就會把這個修改給存進(jìn)去了。另外,當(dāng)我們需要對所有的增刪改查操作做AOP的切片時,Core Data技術(shù)棧的實現(xiàn)就會非常復(fù)雜。
整體上看,我覺得Core Data相對大部分需求而言是過度設(shè)計了。我當(dāng)時設(shè)計安居客聊天模塊的持久層時就采用了Core Data,然后為了讀寫隔離,將所有扔出來的NSManagedObject都轉(zhuǎn)為了普通的對象。另外,由于聊天記錄的業(yè)務(wù)相當(dāng)復(fù)雜,使用Core Data之后為了完成需求不得不引入很多Hack的手段,這種做法在一定程度上降低了這個持久層的可維護(hù)性和提高了接手模塊的工程師的學(xué)習(xí)曲線,這是不太好的。在天貓客戶端,我去的時候天貓這個App就已經(jīng)屬于基本毫無持久層可言了,比較混亂。只能依靠各個業(yè)務(wù)線各顯神通去解決數(shù)據(jù)持久化的需求,難以推動統(tǒng)一的持久層方案,這對于項目維護(hù)尤其是跨業(yè)務(wù)項目合作來說,基本就和車禍現(xiàn)場沒啥區(qū)別。我現(xiàn)在已經(jīng)從天貓離職,讀者中若是有阿里人想升職想刷存在感拿3.75的,可以考慮給天貓搞個統(tǒng)一的持久層方案。
讀寫隔離還能夠便于加入AOP切點,因為針對數(shù)據(jù)庫的寫操作被隔離到一個固定的地方,加AOP時就很容易在正確的地方放入切片。這個會在講到數(shù)據(jù)同步方案時看到應(yīng)用。
3. 多線程導(dǎo)致的隔離
Core Data
Core Data要求在多線程場景下,為異步操作再生成一個NSManagedObjectContext,然后設(shè)置它的ConcurrencyType為NSPrivateQueueConcurrencyType,最后把這個Context的parentContext設(shè)為Main線程下的Context。這相比于使用原始的SQLite去做多線程要輕松許多。只不過要注意的是,如果要傳遞NSManagedObject的時候,不能直接傳這個對象的指針,要傳NSManagedObjectID。這屬于多線程環(huán)境下對象傳遞的隔離,在進(jìn)行架構(gòu)設(shè)計的時候需要注意。
SQLite
純SQLite其實對于多線程倒是直接支持,SQLite庫提供了三種方式:Single Thread,Multi Thread,Serialized。
Single Thread模式不是線程安全的,不提供任何同步機制。Multi Thread模式要求database connection不能在多線程中共享,其他的在使用上就沒什么特殊限制了。Serialized模式顧名思義就是由一個串行隊列來執(zhí)行所有的操作,對于使用者來說除了響應(yīng)速度會慢一些,基本上就沒什么限制了。大多數(shù)情況下SQLite的默認(rèn)模式是Serialized。
根據(jù)Core Data在多線程場景下的表現(xiàn),我覺得Core Data在使用SQLite作為數(shù)據(jù)載體時,使用的應(yīng)該就是Multi Thread模式。SQLite在Multi Thread模式下使用的是讀寫鎖,而且是針對整個數(shù)據(jù)庫加鎖,不是表鎖也不是行鎖,這一點需要提醒各位架構(gòu)師注意。如果對響應(yīng)速度要求很高的話,建議開一個輔助數(shù)據(jù)庫,把一個大的寫入任務(wù)先寫入輔助數(shù)據(jù)庫,然后拆成幾個小的寫入任務(wù)見縫插針地隔一段時間往主數(shù)據(jù)庫中寫入一次,寫完之后再把輔助數(shù)據(jù)庫刪掉。
不過從實際經(jīng)驗上看,本地App的持久化需求的讀寫操作一般都不會大,只要注意好幾個點之后一般都不會影響用戶體驗。因此相比于Multi Thread模式,Serialized模式我認(rèn)為是性價比比較高的一種選擇,代碼容易寫容易維護(hù),性能損失不大。為了提高幾十毫秒的性能而犧牲代碼的維護(hù)性,我是覺得劃不來的。
Realm
關(guān)于Realm我還沒來得及仔細(xì)研究,所以說不出什么來。
4. 數(shù)據(jù)表達(dá)和數(shù)據(jù)操作的隔離
這是最容易被忽視的一點,數(shù)據(jù)表達(dá)和數(shù)據(jù)操作的隔離是否能夠做好,直接影響的是整個程序的可拓展性。
長久以來,我們都很習(xí)慣Active Record類型的數(shù)據(jù)操作和表達(dá)方式,例如這樣:
Record *record = [[Record alloc] init]; record.data = @"data"; [record save];或者這種:
Record *record = [[Record alloc] init]; NSArray *result = [record fetchList];簡單說就是,讓一個對象映射了一個數(shù)據(jù)庫里的表,然后針對這個對象做操作就等同于針對這個表以及這個對象所表達(dá)的數(shù)據(jù)做操作。這里有一個不好的地方就在于,這個Record既是數(shù)據(jù)庫中數(shù)據(jù)表的映射,又是這個表中某一條數(shù)據(jù)的映射。我見過很多框架(不僅限于iOS,包括Python, PHP等)都把這兩者混在一起去處理。如果按照這種不恰當(dāng)?shù)姆绞絹斫M織數(shù)據(jù)操作和數(shù)據(jù)表達(dá),在胖Model的實踐下會導(dǎo)致強弱業(yè)務(wù)難以區(qū)分從而造成非常大的困難。使用瘦Model這種實踐本身就是我認(rèn)為有缺點的,具體的我在開篇中已經(jīng)講過,這里就不細(xì)說了。
強弱業(yè)務(wù)不能區(qū)分帶來的最大困難在于代碼復(fù)用和遷移,因為持久層中的強業(yè)務(wù)對View層業(yè)務(wù)的高耦合是無法避免的,然而弱業(yè)務(wù)相對而言只對下層有耦合關(guān)系對上層并不存在耦合關(guān)系,當(dāng)我們做代碼遷移或者復(fù)用時,往往希望復(fù)用的是弱業(yè)務(wù)而不是強業(yè)務(wù),若此時強弱業(yè)務(wù)分不開,代碼復(fù)用就無從談起,遷移時就倍加困難。
另外,數(shù)據(jù)操作和數(shù)據(jù)表達(dá)混在一起會導(dǎo)致的問題在于:客觀情況下,數(shù)據(jù)在view層業(yè)務(wù)上的表達(dá)方式多種多樣,有可能是個View,也有可能是個別的什么對象。如果采用映射數(shù)據(jù)庫表的數(shù)據(jù)對象去映射數(shù)據(jù),那么這種多樣性就會被限制,實際編碼時每到使用數(shù)據(jù)的地方,就不得不多一層轉(zhuǎn)換。
我認(rèn)為之所以會產(chǎn)生這樣不好的做法原因在于,對象對數(shù)據(jù)表的映射和對象對數(shù)據(jù)表達(dá)的映射結(jié)果非常相似,尤其是在表達(dá)Column時,他們幾乎就是一模一樣。在這里要做好針對數(shù)據(jù)表或是針對數(shù)據(jù)的映射要做的區(qū)分的關(guān)鍵要點是:這個映射對象的操作著手點相對數(shù)據(jù)表而言,是對內(nèi)還是對外操作。如果是對內(nèi)操作,那么這個操作范圍就僅限于當(dāng)前數(shù)據(jù)表,這些操作映射給數(shù)據(jù)表模型就比較合適。如果是對外操作,執(zhí)行這些操作時有可能涉及其他的數(shù)據(jù)表,那么這些操作就不應(yīng)該映射到數(shù)據(jù)表對象中。
因此實際操作中,我是以數(shù)據(jù)表為單位去針對操作進(jìn)行對象封裝,然后再針對數(shù)據(jù)記錄進(jìn)行對象封裝。數(shù)據(jù)表中的操作都是針對記錄的普通增刪改查操作,都是弱業(yè)務(wù)邏輯。數(shù)據(jù)記錄僅僅是數(shù)據(jù)的表達(dá)方式,這些操作最好交付給數(shù)據(jù)層分管強業(yè)務(wù)的對象去執(zhí)行。具體內(nèi)容我在下文還會繼續(xù)說。
持久層與業(yè)務(wù)層的交互方式
說到這里,就不得不說CTPersistance和Virtual Record了。我會通過它來講解持久層與業(yè)務(wù)層之間的交互方式。
------------------------------------------- | | | LogicA LogicB LogicC | -------------------------------> View Layer | / | | --------------/------------------|-------- / | / Virtual | Virtual / Record | Record | | -----------|----------------------|-------- | | | | Strong Logics | DataCenterA DataCenterB | | / | | -----------------|-------/------------------------|-------| Data Logic Layer --- | / | | | Weak Logics | Table1 Table2 Table | | | / | | | -------------/-------------------|-------- | / | |--> Data Persistance Layer / Query Command | Query Command | | | | -----------|----------------------|-------- | | | | | | | | | | | | DatabaseA DatabaseB | Data Operation Layer --- | | | Database Pool | -------------------------------------------我先解釋一下這個圖:持久層有專門負(fù)責(zé)對接View層模塊或業(yè)務(wù)的DataCenter,它們之間通過Record來進(jìn)行交互。DataCenter向上層提供業(yè)務(wù)友好的接口,這一般都是強業(yè)務(wù):比如根據(jù)用戶篩選條件返回符合要求的數(shù)據(jù)等。
然后DataCenter在這個接口里面調(diào)度各個Table,做一系列的業(yè)務(wù)邏輯,最終生成record對象,交付給View層業(yè)務(wù)。
DataCenter為了要完成View層交付的任務(wù),會涉及數(shù)據(jù)組裝和跨表的數(shù)據(jù)操作。數(shù)據(jù)組裝因為View層要求的不同而不同,因此是強業(yè)務(wù)??绫頂?shù)據(jù)操作本質(zhì)上就是各單表數(shù)據(jù)操作的組合,DataCenter負(fù)責(zé)調(diào)度這些單表數(shù)據(jù)操作從而獲得想要的基礎(chǔ)數(shù)據(jù)用于組裝。那么,這時候單表的數(shù)據(jù)操作就屬于弱業(yè)務(wù),這些弱業(yè)務(wù)就由Table映射對象來完成。
Table對象通過QueryCommand來生成相應(yīng)的SQL語句,并交付給數(shù)據(jù)庫引擎去查詢獲得數(shù)據(jù),然后交付給DataCenter。
DataCenter 和 Virtual Record
提到Virtual Record之前必須先說一下DataCenter。
DataCenter其實是一個業(yè)務(wù)對象,DataCenter是整個App中,持久層與業(yè)務(wù)層之間的膠水。它向業(yè)務(wù)層開放業(yè)務(wù)友好的接口,然后通過調(diào)度各個持久層弱業(yè)務(wù)邏輯和數(shù)據(jù)記錄來完成強業(yè)務(wù)邏輯,并將生成的結(jié)果交付給業(yè)務(wù)層。由于DataCenter處在業(yè)務(wù)層和持久層之間,那么它執(zhí)行業(yè)務(wù)邏輯所需要的載體,就要既能夠被業(yè)務(wù)層理解,也能夠被持久層理解。
CTPersistanceTable就封裝了弱業(yè)務(wù)邏輯,由DataCenter調(diào)用,用于操作數(shù)據(jù)。而Virtual Record就是前面提到的一個既能夠被業(yè)務(wù)層理解,也能夠被持久層理解的數(shù)據(jù)載體。
Virtual Record事實上并不是一個對象,它只是一個protocol,這就是它Virtual的原因。一個對象只要實現(xiàn)了Virtual Record,它就可以直接被持久層當(dāng)作Record進(jìn)行操作,所以它也是一個Record。連起來就是Virtual Record了。所以,Virtual Record的實現(xiàn)者可以是任何對象,這個對象一般都是業(yè)務(wù)層對象。在業(yè)務(wù)層內(nèi),常見的數(shù)據(jù)表達(dá)方式一般都是View,所以一般來說Virutal Record的實現(xiàn)者也都會是一個View對象。
我們回顧一下傳統(tǒng)的數(shù)據(jù)操作過程:一般都是先從數(shù)據(jù)庫中取出數(shù)據(jù),然后Model化成一個對象,然后再把這個模型丟到外面,讓Controller轉(zhuǎn)化成View,然后再執(zhí)行后面的操作。
Virtual Record也是一樣遵循類似的步驟。唯一不同的是,整個過程中,它并不需要一個中間對象去做數(shù)據(jù)表達(dá),對于數(shù)據(jù)的不同表達(dá)方式,由各自Virtual Record的實現(xiàn)者自己完成,而不需要把這些代碼放到Controller,所以這就是一個去Model化的設(shè)計。如果未來針對這個數(shù)據(jù)轉(zhuǎn)化邏輯有復(fù)用的需求,直接復(fù)用Virtual Record就可以了,十分方便。
用好Virtual Record的關(guān)鍵在于DataCenter提供的接口對業(yè)務(wù)足夠友好,有充足的業(yè)務(wù)上下文環(huán)境。
所以DataCenter一般都是被Controller所持有,所以如果整個App就只有一個DataCenter,這其實并不是一個好事。我見過有很多App的持久層就是一個全局單例,所有持久化業(yè)務(wù)都走這個單例,這是一種很蛋疼的做法。DataCenter也是需要針對業(yè)務(wù)做高度分化的,每個大業(yè)務(wù)都要提供一個DataCenter,然后掛在相關(guān)Controller下交給Controller去調(diào)度。比如分化成SettingsDataCenter,ChatRoomDataCenter,ProfileDataCenter等,另外要要注意的是,幾個DataCenter之間最好不要有業(yè)務(wù)重疊。如果一個DataCenter的業(yè)務(wù)實在是大,那就再拆分成幾個小業(yè)務(wù)。如果單個小業(yè)務(wù)都很大了,那就拆成各個Category,具體的做法可以參考我的框架中CTPersistanceTable和CTPersistanceQueryCommand的實踐。
這么一來,如果要遷移涉及持久層的強業(yè)務(wù),那就只需要遷移DataCenter即可。如果要遷移弱業(yè)務(wù),就只需要遷移CTPersistanceTable。
實際場景
假設(shè)業(yè)務(wù)層此時收集到了用戶的篩選條件:
NSDictionary *filter = @{ @"key1":@{ @"minValue1":@(1), @"maxValue1":@(9), }, @"key2":@{ @"minValue2":@(1), @"maxValue2":@(9), }, @"key3":@{ @"minValue3":@(1), @"maxValue3":@(9), }, };然后ViewController調(diào)用DataCenter向業(yè)務(wù)層提供的接口,獲得數(shù)據(jù)直接展示:
/* in view controller */ NSArry *fetchedRecordList = [self.dataCenter fetchItemListWithFilter:filter] [self.dataList appendWithArray:fetchedRecordList]; [self.tableView reloadData];在View層要做的事情其實到這里就已經(jīng)結(jié)束了,此時我們回過頭再來看DataCenter如何實現(xiàn)這個業(yè)務(wù):
/* in DataCenter */ - (NSArray *)fetchItemListWithFilter:(NSDictionary *)filter 解析filter獲得查詢所需要的數(shù)據(jù) whereCondition whereConditionParams 假設(shè)上面這兩個變量就是解析得到的變量 /* 告知Table對象查詢數(shù)據(jù)后需要轉(zhuǎn)化成的對象(可選,統(tǒng)一返回對象可以便于歸并來自不同表的數(shù)據(jù)) */ self.itemATable.recordClass = [Item class]; self.itemBTable.recordClass = [Item class]; self.itemCTable.recordClass = [Item class]; /* 通過Table對象獲取數(shù)據(jù),此時Table對象內(nèi)執(zhí)行的就是弱業(yè)務(wù)了 */ NSArray *itemAList = [self.itemATable findAllWithWhereCondition:whereCondition conditionParams:whereConditionParams isDistinct:NO error:NULL]; NSArray *itemBList = [self.itemBTable findAllWithWhereCondition:whereCondition conditionParams:whereConditionParams isDistinct:NO error:NULL]; NSArray *itemCList = [self.itemCTable findAllWithWhereCondition:whereCondition conditionParams:whereConditionParams isDistinct:NO error:NULL]; /* 組裝數(shù)據(jù) */ NSMutableArray *resultList = [[NSMutableArray alloc] init]; [resultList addObjectsFromArray:itemAList]; [resultList addObjectsFromArray:itemBList]; [resultList addObjectsFromArray:itemCList]; return resultList;基本上差不多就是上面這樣的流程。
一般來說,架構(gòu)師設(shè)計得差的持久層,都沒有通過設(shè)計DataCenter和Table,去將強業(yè)務(wù)和弱業(yè)務(wù)分開。通過設(shè)計DataCenter和Table對象,主要是便于代碼遷移。如果遷移強業(yè)務(wù),把DataCenter和Table一起拿走就可以,如果只是遷移弱業(yè)務(wù),拿走Table就可以了。
另外,通過代碼我希望向你強調(diào)一下這個概念:將Table和Record區(qū)分開,這個在我之前畫的架構(gòu)圖上已經(jīng)有所表現(xiàn),不過上文并沒有著重強調(diào)。其實很多別的架構(gòu)師在設(shè)計持久層框架的時候,也沒有將Table和Record區(qū)分開,對的,這里我說的框架包括Core Data和FMDB,這個也不僅限于iOS領(lǐng)域,CodeIgniter、ThinkPHP、Yii、Flask這些也都沒有對這個做區(qū)分。(這里吐槽一下,話說上文我還提到Core Data被過度設(shè)計了,事實上該設(shè)計的地方?jīng)]設(shè)計到,不該設(shè)計的地方各種設(shè)計往上堆...)
以上就是對Virtual Record這個設(shè)計的簡單介紹,接下來我們就開始討論不同場景下如何進(jìn)行交互了。
其中我們最為熟悉的一個場景是這樣的:經(jīng)過各種邏輯組裝出一個數(shù)據(jù)對象,然后把這個數(shù)據(jù)對象交付給持久層去處理。這種場景我稱之為一對一的交互場景,這個交互場景的實現(xiàn)非常傳統(tǒng),就跟大家想得那樣,而且CTPersistance的test case里面都是這樣的,所以這里我就不多說了。所以,既然你已經(jīng)知道有了一對一,那么順理成章地就也會有多對一,以及一對多的交互場景。
下面我會一一描述Virtual Record是如何發(fā)揮虛擬的優(yōu)勢去針對不同場景進(jìn)行交互的。
多對一場景下,業(yè)務(wù)層如何與持久層交互?
多對一場景其實有兩種理解,一種是一個記錄的數(shù)據(jù)由多個View的數(shù)據(jù)組成。例如一張用戶表包含用戶的所有資料。然后有的View只包含用戶昵稱用戶頭像,有的對象只包含用戶ID用戶Token。然而這些數(shù)據(jù)都只存在一張用戶表中,所以這是一種多個對象的數(shù)據(jù)組成一個完整Record數(shù)據(jù)的場景,這是多對一場景的理解之一。
第二種理解是這樣的,例如一個ViewA對象包含了一個Record的所有信息,然后另一個ViewB對象其實也包含了一個Record的所有信息,這就是一種多個不同對象表達(dá)了一個Record數(shù)據(jù)的場景,這也是一種多對一場景的理解。
同時,這里所謂的交互還分兩個方向:存和取。
其實這兩種理解的解決方案都是一樣的,Virtual Record的實現(xiàn)者通過實現(xiàn)Merge操作來完成record數(shù)據(jù)的匯總,從而實現(xiàn)存操作。任意Virtual Record的實現(xiàn)者通過Merge操作,就可以將自己的數(shù)據(jù)交付給其它不同的對象進(jìn)行表達(dá),從而實現(xiàn)取操作。具體的實現(xiàn)在下面有具體闡釋。
多對一場景下,如何進(jìn)行存操作?
提供了- (NSObject*)mergeRecord:(NSObject*)record shouldOverride:(BOOL)shouldOverride;這個方法。望文生義一下,就是一個record可以與另外一個record進(jìn)行merge。在shouldOverride為NO的情況下,任何一邊的nil都會被另外一邊不是nil的記錄覆蓋,如果merge過程中兩個對象都不含有這些空數(shù)據(jù),則根據(jù)shouldOverride來決定是否要讓參數(shù)中record的數(shù)據(jù)覆蓋自己本身的數(shù)據(jù),若shouldOverride為YES,則即便是nil,也會把已有的值覆蓋掉。這個方法會返回被Merge的這個對象,便于鏈?zhǔn)秸{(diào)用。
舉一個代碼樣例:
/* 這里的RecordViewA, RecordViewB, RecordViewC都是符合且實現(xiàn)了- (NSObject*)mergeRecord:(NSObject*)record shouldOverride:(BOOL)shouldOverride方法。 */ RecordViewA *a; RecordViewB *b; RecordViewC *c;收集a, b, c的值的邏輯,我就不寫了~
...
[[a mergeRecord:b shouldOverride:YES] mergeRecord:c shouldOverride:YES];
[self.dataCenter saveRecord:a];
基本思路就是通過merge不同的record對象來達(dá)到獲取完整數(shù)據(jù)的目的,由于是Virtual Record,具體的實現(xiàn)都是由各自的View去決定。View是最了解自己屬性的對象了,因此它是有充要條件來把自己與持久層相關(guān)的數(shù)據(jù)取出并Merge的,那么這段湊數(shù)據(jù)的代碼,就相應(yīng)分散到了各個View對象中,Controller里面就能夠做到非常干凈,整體可維護(hù)性也就提高了。
如果采用傳統(tǒng)方式,ViewController或者DataCenter中就會散落很多用于湊數(shù)據(jù)的代碼,寫的時候就會出現(xiàn)一大段用于合并的代碼,非常難看,還不容易維護(hù)。
多對一場景下,如何進(jìn)行取操作?
其實這樣的表述并不恰當(dāng),因為無論Virtual Record的實現(xiàn)如何,對象是誰,只要從數(shù)據(jù)庫里面取出數(shù)據(jù)來,數(shù)據(jù)就都是能夠保證完整的。這里更準(zhǔn)確的表述是,取出數(shù)據(jù)之后,如何交付給不同的對象。其實還是用到上面提到的mergeRecord方法來處理。
這里的RecordViewA, RecordViewB, RecordViewC都是符合且實現(xiàn)了- (NSObject*)mergeRecord:(NSObject*)record shouldOverride:(BOOL)shouldOverride方法。 RecordViewA *a; RecordViewB *b = [[RecordViewB alloc] init]; RecordViewC *c = [[RecordViewC alloc] init]; a = [self.table findLatestRecordWithError:NULL]; [b mergeRecord:a]; [c mergeRecord:a]; return @[a, b, c]這樣就能很容易把a記錄的數(shù)據(jù)交給b和c了,代碼觀感同樣非常棒,而且容易寫容易維護(hù)。
一對多場景下,業(yè)務(wù)層如何與持久層交互?
一對多場景也有兩種理解,其一是一個對象包含了多個表的數(shù)據(jù),另外一個是一個對象用于展示多種表的數(shù)據(jù),這個代碼樣例其實文章前面已經(jīng)有過,這一節(jié)會著重強調(diào)一下。乍看之下兩者并沒有什么區(qū)別,所以我需要指出的是,前者強調(diào)的是包含,也就是這個對象是個大熔爐,由多個表的數(shù)據(jù)組成。
還是舉用戶列表的例子:
假設(shè)數(shù)據(jù)庫中用戶相關(guān)的表有多張。大多數(shù)情況是因為單表Column太多,所以為了提高維護(hù)性和查詢性能而進(jìn)行的縱切。
多說一句,縱切在實際操作時,大多都是根據(jù)業(yè)務(wù)場景去切分成多個不同的表,分別來表達(dá)用戶各業(yè)務(wù)相關(guān)的部分?jǐn)?shù)據(jù),所以縱切的結(jié)果就是把Column特別多的一張表拆成Column不那么多的好幾個表。雖然數(shù)據(jù)庫經(jīng)過了縱切,但是有的場景還是要展示完整數(shù)據(jù)的,比如用戶詳情頁。因此,這個用戶詳情頁的View就有可能包含用戶基礎(chǔ)信息表(用戶名、用戶ID、用戶Token等)、以及用戶詳細(xì)信息表(用戶郵箱地址、用戶手機號等)。這就是一對多的一個對象包含了多個表的數(shù)據(jù)的意思。
后者強調(diào)的是展示。舉個例子,數(shù)據(jù)庫中有三個表分別是:
二手房、新房、租房,它們?nèi)叩臄?shù)據(jù)分別存儲在三個表里面,這其實是一種橫切。
橫切也是一種數(shù)據(jù)庫的優(yōu)化手段,橫切與縱切不同的地方在于,橫切是在保留了這套數(shù)據(jù)的完整性的前提下進(jìn)行的切分,橫切的結(jié)果就是把一個原本數(shù)據(jù)量很大的表,分成了好幾個數(shù)據(jù)量不那么大的表。也就是原來三種房子都能用同一個表來存儲,但是這樣數(shù)據(jù)量就太大了,數(shù)據(jù)庫響應(yīng)速度就會下降。所以根據(jù)房子的類型拆成這三張表。橫切也有根據(jù)ID切的,比如根據(jù)ID取余的結(jié)果來決定分在哪些表里,這種做法比較廣泛,因為拓展起來方便,到時候數(shù)據(jù)表又大了,大不了除數(shù)也跟著再換一個更大的數(shù)罷了。其實根據(jù)類型去橫切也可以,只是拓展的時候就不那么方便。
剛才扯遠(yuǎn)了現(xiàn)在我再扯回來,這三張表在展示的時候,只是根據(jù)類型的不同,界面才有稍許不同而已,所以還是會用同一張View去展示這三種數(shù)據(jù),這就是一對多的一個對象用于展示多種表的數(shù)據(jù)的意思。
一個對象包含了多個表的數(shù)據(jù)時,如何進(jìn)行存取操作?
在進(jìn)行取操作時,其實跟前面多對一的取操作是一樣的,用Merge操作就可以了。
RecordViewA *a; a = [self.CasaTable findLatestRecordWithError:NULL]; [a mergeRecord:[self.TaloyumTable findLatestRecordWithError:NULL] shouldOverride:YES]; [a mergeRecord:[self.CasatwyTable findLatestRecordWithError:NULL] shouldOverride:YES]; return a;在進(jìn)行存操作時,Virtual Record的要求實現(xiàn)者實現(xiàn)- (NSDictionary *)dictionaryRepresentationWithColumnInfo:(NSDictionary *)columnInfo tableName:(NSString *)tableName;這個方法,實現(xiàn)者可以根據(jù)傳入的columnInfo和tableName返回相應(yīng)的數(shù)據(jù),這樣就能夠把這一次存數(shù)據(jù)時關(guān)心的內(nèi)容提供給持久層了。代碼樣例就是這樣的:
通過上面的存取案例,你會發(fā)現(xiàn)使用Virtual Record之后,代碼量一下子少掉很多,原本那些亂七八糟用于拼湊條件的代碼全部被分散進(jìn)了各個虛擬記錄的實現(xiàn)中去了,代碼維護(hù)因此就變得相當(dāng)方便。若是采用傳統(tǒng)做法,再存取之前少不了要寫一大段邏輯,如果涉及代碼遷移,這大段邏輯就也得要跟著遷移過去,這就很蛋疼了。
一個對象用于展示多種表的數(shù)據(jù),如何進(jìn)行存取操作?
在這種情況下的存操作其實跟上面一樣,直接存。Virtual Record的實現(xiàn)者自己會根據(jù)要存入的表的信息組裝好數(shù)據(jù)提供給持久層。樣例代碼與上一小節(jié)的存操作中給出的一模一樣,我就不復(fù)制粘貼了。
取操作就不太一樣了,不過由于取出時的對象是唯一的(因為一對多嘛),代碼也一樣十分簡單:
ViewRecord *a; ViewRecord *b; ViewRecord *c; self.itemATable.recordClass = [ViewRecord class]; self.itemBTable.recordClass = [ViewRecord class]; self.itemCTable.recordClass = [ViewRecord class]; [a = self.itemATable findLatestRecordWithError:NULL]; [b = self.itemBTable findLatestRecordWithError:NULL]; [c = self.itemCTable findLatestRecordWithError:NULL];這里的a,b,c都是同一個View,然后itemATable,itemBTable,itemCTable分別是不同種類的表。這個例子表示了一個對象如何用于展示不同類型的數(shù)據(jù)。如果使用傳統(tǒng)方法,這里少不了要寫很多適配代碼,但是使用Virtual Record之后,這些代碼都由各自實現(xiàn)者消化掉了,在執(zhí)行數(shù)據(jù)邏輯時可以無需關(guān)心適配邏輯。
多對多場景?
其實多對多場景就是上述這些一對多和多對一場景的排列組合,實現(xiàn)方式都是一模一樣的,我這里就也不多啰嗦了。
交互方案的總結(jié)
在交互方案的設(shè)計中,架構(gòu)師應(yīng)當(dāng)區(qū)分好強弱業(yè)務(wù),把傳統(tǒng)的Data Model區(qū)分成Table和Record,并由DataCenter去實現(xiàn)強業(yè)務(wù),Table去實現(xiàn)弱業(yè)務(wù)。在這里由于DataCenter是強業(yè)務(wù)相關(guān),所以在實際編碼中,業(yè)務(wù)工程師負(fù)責(zé)創(chuàng)建DataCenter,并向業(yè)務(wù)層提供業(yè)務(wù)友好的方法,然后再在DataCenter中操作Table來完成業(yè)務(wù)層交付的需求。區(qū)分強弱業(yè)務(wù),將Table和Record拆分開的好處在于:
通過業(yè)務(wù)細(xì)分降低耦合度,使得代碼遷移和維護(hù)非常方便
通過拆解數(shù)據(jù)處理邏輯和數(shù)據(jù)表達(dá)形態(tài),使得代碼具有非常良好的可拓展性
做到讀寫隔離,避免業(yè)務(wù)層的誤操作引入Bug
為Virtual Record這一設(shè)計思路的實踐提供基礎(chǔ),進(jìn)而實現(xiàn)更靈活,對業(yè)務(wù)更加友好的架構(gòu)
任何不區(qū)分強弱業(yè)務(wù)的架構(gòu)都是架構(gòu)師在耍流氓,嗯。
在具體與業(yè)務(wù)層交互時,采用Virtual Record的設(shè)計思路來設(shè)計Record,由具體的業(yè)務(wù)對象來實現(xiàn)Virtual Record,并以它作為DataCenter和業(yè)務(wù)層之間的數(shù)據(jù)媒介進(jìn)行交互。而不是使用傳統(tǒng)的數(shù)據(jù)模型來與業(yè)務(wù)層做交互。
使用Virtual Record的好處在于:
將數(shù)據(jù)適配和數(shù)據(jù)轉(zhuǎn)化邏輯封裝到具體的Record實現(xiàn)中,可以使得代碼更加抽象簡潔,代碼污染更少
數(shù)據(jù)遷移時只需要遷移Virtual Record相關(guān)方法即可,非常容易拆分
業(yè)務(wù)工程師實現(xiàn)業(yè)務(wù)邏輯時,可以在不損失可維護(hù)性的前提下,極大提高業(yè)務(wù)實現(xiàn)的靈活性
這一部分還順便提了一下橫切和縱切的概念。本來是打算有一小節(jié)專門寫數(shù)據(jù)庫性能優(yōu)化的,不過事實上移動App場景下數(shù)據(jù)庫的性能優(yōu)化手段不像服務(wù)端那樣豐富多彩,很多牛逼技術(shù)和參數(shù)調(diào)優(yōu)手段想用也用不了。差不多就只剩下數(shù)據(jù)切片的手段比較有效了,所以性能優(yōu)化這塊感覺沒什么好寫的。其實大家了解了切片的方式和場景,就足以根據(jù)自己的業(yè)務(wù)場景去做優(yōu)化了。再使用一下Instrument的Time Profile再配合SQLite提供的一些函數(shù),就足以找到慢在哪兒,然后去做性能調(diào)優(yōu)了。但如果我把這些也寫出來,就變成教你怎么使用工具,感覺這個太low寫著也不起勁,大家有興趣搜使用手冊下來看就行。
數(shù)據(jù)庫版本遷移方案
一般來說,具有持久層的App同時都會附帶著有版本遷移的需求。當(dāng)一個用戶安裝了舊版本的App,此時更新App之后,若數(shù)據(jù)庫的表結(jié)構(gòu)需要更新,或者數(shù)據(jù)本身需要批量地進(jìn)行更新,此時就需要有版本遷移機制來進(jìn)行這些操作。然而版本遷移機制又要兼顧跨版本的遷移需求,所以基本上大方案也就只有一種:建立數(shù)據(jù)庫版本節(jié)點,遷移的時候一個一個跑過去。
數(shù)據(jù)遷移事實上實現(xiàn)起來還是比較簡單的,做好以下幾點問題就不大了:
根據(jù)應(yīng)用的版本記錄每一版數(shù)據(jù)庫的改變,并將這些改變封裝成對象
記錄好當(dāng)前數(shù)據(jù)庫的版本,便于跟遷移記錄做比對
在啟動數(shù)據(jù)庫時執(zhí)行遷移操作,如果遷移失敗,提供一些降級方案
CTPersistance在數(shù)據(jù)遷移方面,凡是對于數(shù)據(jù)庫原本沒有的數(shù)據(jù)表,如果要新增,在使用table的時候就會自動創(chuàng)建。因此對于業(yè)務(wù)工程師來說,根本不需要額外多做什么事情,直接用就可以了。把這部分工作放到這里,也是為數(shù)據(jù)庫版本遷移節(jié)省了一些步驟。
CTPersistance也提供了Migrator。業(yè)務(wù)工程師可以自己針對某一個數(shù)據(jù)庫編寫一個Migrator。這個Migrator務(wù)必派生自CTPersistanceMigrator,且符合,只要提供一個migrationStep的字典,以及記錄版本順序的數(shù)組。然后把你自己派生的Migrator的類名和對應(yīng)關(guān)心的數(shù)據(jù)庫名寫在CTPersistanceConfiguration.plist里面就可以。CTPersistance會在初始數(shù)據(jù)庫的時候,根據(jù)plist里面的配置對應(yīng)找到Migrator,并執(zhí)行數(shù)據(jù)庫版本遷移的邏輯。
在版本遷移時要注意的一點是性能問題。我們一般都不會在主線程做版本遷移的事情,這自然不必說。需要強調(diào)的是,SQLite本身是一個容錯性非常強的數(shù)據(jù)庫引擎,因此差不多在執(zhí)行每一個SQL的時候,內(nèi)部都是走的一個Transaction。當(dāng)某一版的SQL數(shù)量特別多的時候,建議在版本遷移的方法里面自己建立一個Transaction,然后把相關(guān)的SQL都包起來,這樣SQLite執(zhí)行這些SQL的時候速度就會快一點。
其他的似乎并沒有什么要額外強調(diào)的了,如果有沒說到的地方,大家可以在評論區(qū)提出來。
數(shù)據(jù)同步方案
數(shù)據(jù)同步方案大致分兩種類型,一種類型是單向數(shù)據(jù)同步,另一種類型是雙向數(shù)據(jù)同步。下面我會分別說說這兩種類型的數(shù)據(jù)同步方案的設(shè)計。
單向數(shù)據(jù)同步
單向數(shù)據(jù)同步就是只把本地較新數(shù)據(jù)的操作同步到服務(wù)器,不會從服務(wù)器主動拉取同步操作。
比如即時通訊應(yīng)用,一個設(shè)備在發(fā)出消息之后,需要等待服務(wù)器的返回去知道這個消息是否發(fā)送成功,是否取消成功,是否刪除成功。然后數(shù)據(jù)庫中記錄的數(shù)據(jù)就會隨著這些操作是否成功而改變狀態(tài)。但是如果換一臺設(shè)備繼續(xù)執(zhí)行操作,在這個新設(shè)備上只會拉取舊的數(shù)據(jù),比如聊天記錄這種。但對于舊的數(shù)據(jù)并沒有刪除或修改的需求,因此新設(shè)備也不會問服務(wù)器索取數(shù)據(jù)同步的操作,所以稱之為單向數(shù)據(jù)同步。
單向數(shù)據(jù)同步一般來說也不需要有job去做定時更新的事情。如果一個操作遲遲沒有收到服務(wù)器的確認(rèn),那么在應(yīng)用這邊就可以認(rèn)為這個操作失敗,然后一般都是在界面上把這些失敗的操作展示出來,然后讓用戶去勾選需要重試的操作,然后再重新發(fā)起請求。微信在消息發(fā)送失敗的時候,就是消息前面有個紅色的圈圈,里面有個感嘆號,只有用戶點擊這個感嘆號的時候才重新發(fā)送消息,背后不會有個job一直一直跑。
所以細(xì)化需求之后,我們發(fā)現(xiàn)單向數(shù)據(jù)同步只需要做到能夠同步數(shù)據(jù)的狀態(tài)即可。
如何完成單向數(shù)據(jù)同步的需求
添加identifier
添加identifier的目的主要是為了解決客戶端數(shù)據(jù)的主鍵和服務(wù)端數(shù)據(jù)的主鍵不一致的問題。由于是單向數(shù)據(jù)同步,所以數(shù)據(jù)的生產(chǎn)者只會是當(dāng)前設(shè)備,那么identifier也理所應(yīng)當(dāng)由設(shè)備生成。當(dāng)設(shè)備發(fā)起同步請求的時候,把identifier帶上,當(dāng)服務(wù)器完成任務(wù)返回數(shù)據(jù)時,也把這些identifier帶上。然后客戶端再根據(jù)服務(wù)端給到的identifier再更新本地數(shù)據(jù)的狀態(tài)。identifier一般都會采用UUID字符串。
添加isDirty
isDirty主要是針對數(shù)據(jù)的插入和修改進(jìn)行標(biāo)識。當(dāng)本地新生成數(shù)據(jù)或者更新數(shù)據(jù)之后,收到服務(wù)器的確認(rèn)返回之前,isDirty置為YES。當(dāng)服務(wù)器的確認(rèn)包返回之后,再根據(jù)包里提供的identifier找到這條數(shù)據(jù),然后置為NO。這樣就完成了數(shù)據(jù)的同步。
然而這只是簡單的場景,有一種比較極端的情況在于,當(dāng)請求發(fā)起到收到請求回復(fù)的這短短幾秒間,用戶又修改了數(shù)據(jù)。如果按照當(dāng)前的邏輯,在收到請求回復(fù)之后,這個又修改了的數(shù)據(jù)的isDirty會被置為NO,于是這個新的修改就永遠(yuǎn)無法同步到服務(wù)器了。這種極端情況的簡單處理方案就是在發(fā)起請求到收到回復(fù)期間,界面上不允許用戶進(jìn)行修改。
如果希望做得比較細(xì)致,在發(fā)送同步請求期間依舊允許用戶修改的話,就需要在數(shù)據(jù)庫額外增加一張DirtyList來記錄這些操作,這個表里至少要有兩個字段:identifier,primaryKey。然后每一次操作都分配一次identifier,那么新的修改操作就有了新的identifier。在進(jìn)行同步時,根據(jù)primaryKey找到原數(shù)據(jù)表里的那條記錄,然后把數(shù)據(jù)連同identifier交給服務(wù)器。然后在服務(wù)器的確認(rèn)包回來之后,就只要拿出identifier再把這條操作記錄刪掉即可。這個表也可以直接服務(wù)于多個表,只是還需要額外添加一個tablename字段,方便發(fā)起同步請求的時候能夠找得到數(shù)據(jù)。
添加isDeleted
當(dāng)有數(shù)據(jù)同步的需求的時候,刪除操作就不能是簡單的物理刪除了,而只是邏輯刪除,所謂邏輯刪除就是在數(shù)據(jù)庫里把這條記錄的isDeleted記為YES,只有當(dāng)服務(wù)器的確認(rèn)包返回之后,才會真正把這條記錄刪除。isDeleted和isDirty的區(qū)別在于:收到確認(rèn)包后,返回的identifier指向的數(shù)據(jù)如果是isDeleted,那么就要刪除這條數(shù)據(jù),如果指向的數(shù)據(jù)只是新插入的數(shù)據(jù)和更新的數(shù)據(jù),那么就只要修改狀態(tài)就行。插入數(shù)據(jù)和更新數(shù)據(jù)在收到數(shù)據(jù)包之后做的操作是相同的,所以就用isDirty來區(qū)分就足夠了??傊@是根據(jù)收到確認(rèn)包之后的操作不同而做的區(qū)分。兩者都要有,缺一不可。
在請求的數(shù)據(jù)包中,添加dependencyIdentifier
在我看到的很多其它數(shù)據(jù)同步方案中,并沒有提供dependencyIdentifier,這會導(dǎo)致一個這樣的問題:假設(shè)有兩次數(shù)據(jù)同步請求一起發(fā)出,A先發(fā),B后發(fā)。結(jié)果反而是B請求先到,A請求后到。如果A請求的一系列同步操作里面包含了插入某個對象的操作,B請求的一系列同步操作里面正好又刪除了這個對象,那么由于到達(dá)次序的先后問題錯亂,就導(dǎo)致這個數(shù)據(jù)沒辦法刪除。
這個在移動設(shè)備的使用場景下是很容易發(fā)生的,移動設(shè)備本身網(wǎng)絡(luò)環(huán)境就多變,先發(fā)的包反而后到,這種情況出現(xiàn)的幾率還是比較大的。所以在請求的數(shù)據(jù)包中,我們要帶上上一次請求時一系列identifier的其中一個,就可以了。一般都是選擇上次請求里面最后的那一個操作的identifier,這樣就能表征上一次請求的操作了。
服務(wù)端這邊也要記錄最近的100個請求包里面的最后一個identifier。之所以是100條純屬只是拍腦袋定的數(shù)字,我覺得100條差不多就夠了,客戶端發(fā)請求的時候denpendency應(yīng)該不會涉及到前面100個包。服務(wù)端在收到同步請求包的時候,先看denpendencyIdentifier是否已被記錄,如果已經(jīng)被記錄了,那么就執(zhí)行這個包里面的操作。如果沒有被記錄,那就先放著再等等,等到條件滿足了再執(zhí)行,這樣就能解決這樣的問題。
之所以不用更新時間而是identifier來做標(biāo)識,是因為如果要用時間做標(biāo)識的話,就是只能以客戶端發(fā)出數(shù)據(jù)包時候的時間為準(zhǔn)。但有時不同設(shè)備的時間不一定完全對得上,多少會差個幾秒幾毫秒,另外如果同時有兩個設(shè)備發(fā)起同步請求,這兩個包的時間就都是一樣的了。假設(shè)A1, B1是1號設(shè)備發(fā)送的請求,A2, B2,是2號設(shè)備發(fā)送的請求,如果用時間去區(qū)分,A1到了之后,B2說不定就直接能夠執(zhí)行了,而A1還沒到服務(wù)器呢。
當(dāng)然,這也是一種極端情況,用時間的話,服務(wù)器就只要記錄一個時間了,凡是依賴時間大于這個時間的,就都要再等等,實現(xiàn)起來就比較方便。但是為了保證bug盡可能少,我認(rèn)為依賴還是以identifier為準(zhǔn),這要比以時間為準(zhǔn)更好,而且實現(xiàn)起來其實也并沒有增加太多復(fù)雜度。
單向數(shù)據(jù)同步方案總結(jié)
改造的時候添加identifier,isDirty,isDeleted字段。如果在請求期間依舊允許對數(shù)據(jù)做操作,那么就要把identifier和primaryKey再放到一個新的表中
每次生成數(shù)據(jù)之后對應(yīng)生成一個identifier,然后只要是針對數(shù)據(jù)的操作,就修改一次isDirty或isDeleted,然后發(fā)起請求帶上identifier和操作指令去告知服務(wù)器執(zhí)行相關(guān)的操作。如果是復(fù)雜的同步方式,那么每一次修改數(shù)據(jù)時就新生成一次identifier,然后再發(fā)起請求帶上相關(guān)數(shù)據(jù)告知服務(wù)器。
服務(wù)器根據(jù)請求包的identifier等數(shù)據(jù)執(zhí)行操作,操作完畢回復(fù)給客戶端確認(rèn)
收到服務(wù)器的確認(rèn)包之后,根據(jù)服務(wù)器給到的identifier(有的時候也會有tablename,取決于你的具體實現(xiàn))找到對應(yīng)的記錄,如果是刪除操作,直接把數(shù)據(jù)刪除就好。如果是插入和更新操作,就把isDirty置為NO。如果有額外的表記錄了更新操作,直接把identifier對應(yīng)的這個操作記錄刪掉就行。
要注意的點
在使用表去記錄更新操作的時候,短時間之內(nèi)很有可能針對同一條數(shù)據(jù)進(jìn)行多次更新操作。因此在同步之前,最好能夠合并這些相同數(shù)據(jù)的更新操作,可以節(jié)約服務(wù)器的計算資源。當(dāng)然如果你服務(wù)器強大到不行,那就無所謂了。
雙向數(shù)據(jù)同步
雙向數(shù)據(jù)同步多見于筆記類、日程類應(yīng)用。對于一臺設(shè)備來說,不光自己會往上推數(shù)據(jù)同步的信息,自己也會問服務(wù)器主動索取數(shù)據(jù)同步的信息,所以稱之為雙向數(shù)據(jù)同步。
舉個例子:當(dāng)一臺設(shè)備生成了某時間段的數(shù)據(jù)之后,到了另外一臺設(shè)備上,又修改了這些舊的歷史數(shù)據(jù)。此時再回到原來的設(shè)備上,這臺設(shè)備就需要主動問服務(wù)器索取是否舊的數(shù)據(jù)有修改,如果有,就要把這些操作下載下來同步到本地。
雙向數(shù)據(jù)同步實現(xiàn)上會比單向數(shù)據(jù)同步要復(fù)雜一些,而且有的時候還會存在實時同步的需求,比如協(xié)同編輯。由于本身方案就比較復(fù)雜,另外一定要兼顧業(yè)務(wù)工程師的上手難度(這主要看你這個架構(gòu)師的良心),所以要實現(xiàn)雙向數(shù)據(jù)同步方案的話,還是很有意思比較有挑戰(zhàn)的。
如何完成雙向數(shù)據(jù)同步的需求
封裝操作對象
這個其實在單向數(shù)據(jù)同步時多少也涉及了一點,但是由于單向數(shù)據(jù)同步的要求并不復(fù)雜,只要告訴服務(wù)器是什么數(shù)據(jù)然后要做什么事情就可以了,倒是沒必要將這種操作封裝。在雙向數(shù)據(jù)同步時,你也得解析數(shù)據(jù)操作,所以互相之間要約定一個協(xié)議,通過封裝這個協(xié)議,就做到了針對操作對象的封裝。
這個協(xié)議應(yīng)當(dāng)包括:
操作的唯一標(biāo)識
數(shù)據(jù)的唯一標(biāo)識
操作的類型
具體的數(shù)據(jù),主要是在Insert和Update的時候會用到
操作的依賴標(biāo)識
用戶執(zhí)行這項操作時的時間戳
分別解釋一下這6項的意義:
1. 操作的唯一標(biāo)識
這個跟單向同步方案時的作用一樣,也是在收到服務(wù)器的確認(rèn)包之后,能夠使得本地應(yīng)用找到對應(yīng)的操作并執(zhí)行確認(rèn)處理。
2. 數(shù)據(jù)的唯一標(biāo)識
在找到具體操作的時候執(zhí)行確認(rèn)邏輯的處理時,都會涉及到對象本身的處理,更新也好刪除也好,都要在本地數(shù)據(jù)庫有所體現(xiàn)。所以這個標(biāo)識就是用于找到對應(yīng)數(shù)據(jù)的。
3. 操作的類型
操作的類型就是Delete,Update,Insert,對應(yīng)不同的操作類型,對本地數(shù)據(jù)庫執(zhí)行的操作也會不一樣,所以用它來進(jìn)行標(biāo)識。
4. 具體的數(shù)據(jù)
當(dāng)更新的時候有Update或者Insert操作的時候,就需要有具體的數(shù)據(jù)參與了。這里的數(shù)據(jù)有的時候不見得是單條的數(shù)據(jù)內(nèi)容,有的時候也會是批量的數(shù)據(jù)。比如把所有10月1日之前的任務(wù)都標(biāo)記為已完成狀態(tài)。因此這里具體的數(shù)據(jù)如何表達(dá),也需要定一個協(xié)議,什么時候作為單條數(shù)據(jù)的內(nèi)容去執(zhí)行插入或更新操作,什么時候作為批量的更新去操作,這個自己根據(jù)實際業(yè)務(wù)需求去定義就行。
5. 操作的依賴標(biāo)識
跟前面提到的依賴標(biāo)識一樣,是為了防止先發(fā)的包后到后發(fā)的包先到這種極端情況。
6. 用戶執(zhí)行這項操作的時間戳
由于跨設(shè)備,又因為舊數(shù)據(jù)也會被更新,因此在一定程度上就會出現(xiàn)沖突的可能。操作數(shù)據(jù)在從服務(wù)器同步下來之后,會存放在一個新的表中,這個表就是待操作數(shù)據(jù)表,在具體執(zhí)行這些操作的同時會跟待同步的數(shù)據(jù)表中的操作數(shù)據(jù)做比對。如果是針對同一條數(shù)據(jù)的操作,且這兩個操作存在沖突,那么就以時間戳來決定如何執(zhí)行。還有一種做法就是直接提交到界面告知用戶,讓用戶做決定。
新增待操作數(shù)據(jù)表和待同步數(shù)據(jù)表
前面已經(jīng)部分提到這一點了。從服務(wù)器拉下來的同步操作列表,我們存在待執(zhí)行數(shù)據(jù)表中,操作完畢之后如果有告知服務(wù)器的需求,那就等于是走單向同步方案告知服務(wù)器。在執(zhí)行過程中,這些操作也要跟待同步數(shù)據(jù)表進(jìn)行匹配,看有沒有沖突,沒有沖突就繼續(xù)執(zhí)行,有沖突的話要么按照時間戳執(zhí)行,要么就告知用戶讓用戶做決定。在拉取待執(zhí)行操作列表的時候,也要把最后一次操作的identifier丟給服務(wù)器,這樣服務(wù)器才能返回相應(yīng)數(shù)據(jù)。
待同步數(shù)據(jù)表的作用其實也跟單向同步方案時候的作用類似,就是防止在發(fā)送請求的時候用戶有操作,同時也是為解決沖突提供方便。在發(fā)起同步請求之前,我們都應(yīng)該先去查詢有沒有待執(zhí)行的列表,當(dāng)待執(zhí)行的操作列表同步完成之后,就可以刪除里面的記錄了,然后再把本地待同步的數(shù)據(jù)交給服務(wù)器。同步完成之后就可以把這些數(shù)據(jù)刪掉了。因此在正常情況下,只有在待操作和待執(zhí)行的操作間會存在沖突。有些從道理上講也算是沖突的事情,比如獲取待執(zhí)行的數(shù)據(jù)比較晚,但其中又和待同步中的操作有沖突,像這種極端情況我們其實也無解,只能由他去,不過這種情況也是屬于比較極端的情況,發(fā)生幾率不大。
何時從服務(wù)器拉取待執(zhí)行列表
每次要把本地數(shù)據(jù)丟到服務(wù)器去同步之前,都要拉取一次待執(zhí)行列表,執(zhí)行完畢之后再上傳本地同步數(shù)據(jù)
每次進(jìn)入相關(guān)頁面的時候都更新一次,看有沒有新的操作
對實時性要求比較高的,要么客戶端本地起一個線程做輪詢,要么服務(wù)器通過長鏈接將待執(zhí)行操作推送過來
其它我暫時也想不到了,具體還是看需求吧
雙向數(shù)據(jù)同步方案總結(jié)
設(shè)計好同步協(xié)議,用于和服務(wù)端進(jìn)行交互,以及指導(dǎo)本地去執(zhí)行同步下來的操作
添加待執(zhí)行,待同步數(shù)據(jù)表記錄要執(zhí)行的操作和要同步的操作
要注意的點
我也見過有的方案是直接把SQL丟出去進(jìn)行同步的,我不建議這么做。最好還是將操作和數(shù)據(jù)分開,然后細(xì)化,否則檢測沖突的時候你就得去分析SQL了。要是這種實現(xiàn)中有什么bug,解這種bug的時候就要考慮前后兼容問題,機制重建成本等,因為貪圖一時偷懶,到最后其實得不償失。
總結(jié)
這篇文章主要是基于CTPersistance講了一下如何設(shè)計持久層的設(shè)計方案,以及數(shù)據(jù)遷移方案和數(shù)據(jù)同步方案。
著重強調(diào)了一下各種持久層方案在設(shè)計時要考慮的隔離,以及提出了Virtual Record這個設(shè)計思路,并對它做了一些解釋。然后在數(shù)據(jù)遷移方案設(shè)計時要考慮的一些點。在數(shù)據(jù)同步方案這一節(jié),分開講了單向的數(shù)據(jù)同步方案和雙向的數(shù)據(jù)同步方案的設(shè)計,然而具體實現(xiàn)還是要依照具體的業(yè)務(wù)需求來權(quán)衡。
希望大家覺得這些內(nèi)容對各自工作中遇到的問題能夠有所價值,如果有問題,歡迎在評論區(qū)討論。
另外,關(guān)于動態(tài)部署方案,其實直到今天在iOS領(lǐng)域也并沒有特別好的動態(tài)部署方案可以拿出來,我覺得最靠譜的其實還是H5和Native的Hybrid方案。React Native在我看來相比于Hybrid還是有比較多的限制。關(guān)于Hybrid方案,我也提供了CTJSBridge這個庫去實現(xiàn)這方面的需求。在動態(tài)部署方案這邊其實成文已經(jīng)很久,遲遲不發(fā)的原因還是因為覺得當(dāng)時并沒有什么銀彈可以解決iOS App的動態(tài)部署,另外也有一些問題沒有考慮清楚。當(dāng)初想到的那些問題現(xiàn)在我已經(jīng)確認(rèn)無解。當(dāng)初寫的動態(tài)部署方案我一直認(rèn)為它無法作為一個單獨的文章發(fā)布出來,所以我就把這篇文章也放在這里,權(quán)當(dāng)給各位參考。