前言
嗯,你們要的大招。跟著這篇文章一起也釋出了CTPersistance和CTJSBridge這兩個庫,希望大家在實際使用的時候如果遇到問題,就給我提issue或者PR或者評論區。每一個issue和PR以及評論我都會回覆的。
持久化方案不管是服務端還是客戶端,都是一個非常值得討論的話題。尤其是在服務端,持久化方案的優劣往往都會在一定程度上影響到產品的效能。然而在客戶端,只有為數不多的業務需求會涉及持久化方案,而且在大多數情況下,持久化方案對效能的要求並不是特別苛刻。所以我在移動端這邊做持久化方案設計的時候,考慮更多的是方案的可維護和可擴充,然後在此基礎上才是效能調優。這篇文章中,效能調優不會單獨開一節來講,而會穿插在各個小節中,大家有心的話可以重點看一下。
持久化方案對整個App架構的影響和網路層方案對整個架構的影響類似,一般都是導致整個專案耦合度高的罪魁禍首。而我也是一如既往的去Model化
的實踐者,在持久層去Model化的過程中,我引入了Virtual Record
的設計,這個在文中也會詳細描述。
這篇文章主要講以下幾點:
- 根據需求決定持久化方案
- 持久層與業務層之間的隔離
- 持久層與業務層的互動方式
- 資料遷移方案
- 資料同步方案
另外,針對資料庫儲存這一塊,我寫了一個CTPersistance,這個庫目前能夠完成大部分的持久層需求,同時也是我的Virtual Record
這種設計思路的一個樣例。這個庫可以直接被cocoapods引入,希望大家使用的時候,能夠多給我提issue。這裡是CTPersistance Class Reference。
根據需求決定持久化方案
在有需要持久化需求的時候,我們有非常多的方案可供選擇:NSUserDefault、KeyChain、File,以及基於資料庫的無數子方案。因此,當有需要持久化的需求的時候,我們首先考慮的是應該採用什麼手段去進行持久化。
NSUserDefault
一般來說,小規模資料,弱業務相關資料,都可以放到NSUserDefault裡面,內容比較多的資料,強業務相關的資料就不太適合NSUserDefault了。另外我想吐槽的是,天貓這個App其實是沒有一個經過設計的資料持久層的。然後天貓裡面的持久化方案就很混亂,我就見到過有些業務線會把大部分業務資料都塞到NSUserDefault裡面去,當時看程式碼的時候我特麼就直接跪了。。。問起來為什麼這麼做?結果說因為寫起來方便~你妹。。。
keychain
Keychain是蘋果提供的帶有可逆加密的儲存機制,普遍用在各種存密碼的需求上。另外,由於App解除安裝只要系統不重灌,Keychain中的資料依舊能夠得到保留,以及可被iCloud同步的特性,大家都會在這裡儲存使用者唯一標識串。所以有需要加密、需要存iCloud的敏感小資料,一般都會放在Keychain。
檔案儲存
檔案儲存包括了Plist、archive、Stream等方式,一般結構化的資料或者需要方便查詢的資料,都會以Plist的方式去持久化。Archive方式適合儲存平時不太經常使用但很大量的資料,或者讀取之後希望直接物件化的資料,因為Archive會將物件及其物件關係序列化,以至於讀取資料的時候需要Decode很花時間,Decode的過程可以是解壓,也可以是物件化,這個可以根據具體中的實現來決定。Stream就是一般的檔案儲存了,一般用來存存圖片啊啥的,適用於比較經常使用,然而資料量又不算非常大的那種。
資料庫儲存
資料庫儲存的話,花樣就比較多了。蘋果自帶了一個Core Data,當然業界也有無數替代方案可選,不過真正用在iOS領域的除了Core Data外,就是FMDB比較多了。資料庫方案主要是為了便於增刪改查,當資料有狀態
和類別
的時候最好還是採用資料庫方案比較好,而且尤其是當這些狀態
和類別
都是強業務相關的時候,就更加要採用資料庫方案了。因為你不可能通過檔案系統遍歷檔案去甄別你需要獲取的屬於某個狀態
或類別
的資料,這麼做成本就太大了。當然,特別大量的資料也不適合直接儲存資料庫,比如圖片或者文章這樣的資料,一般來說,都是資料庫存一個檔名,然後這個檔名指向的是某個圖片或者文章的檔案。如果真的要做全文索引這種需求,建議最好還是掛個API丟到服務端去做。
總的說一下
NSUserDefault、Keychain、File這些持久化方案都非常簡單基礎,分清楚什麼時候用什麼就可以了,不要像天貓那樣亂寫就好。而且在這之上並不會有更復雜的衍生需求,如果真的要針對它們寫文章,無非就是寫怎麼儲存怎麼讀取,這個大家隨便Google一下就有了,我就不浪費筆墨了。由於大多數衍生複雜需求都是通過採用基於資料庫的持久化方案去滿足,所以這篇文章的重點就資料庫相關的架構方案設計和實現。如果文章中有哪些問題我沒有寫到的,大家可以在評論區提問,我會一一解答或者直接把遺漏的內容補充在文章中。
持久層實現時要注意的隔離
在設計持久層架構的時候,我們要關注以下幾個方面的隔離:
- 持久層與業務層的隔離
- 資料庫讀寫隔離
- 多執行緒控制導致的隔離
- 資料表達和資料操作的隔離
1. 持久層與業務層的隔離
關於Model
在具體講持久層下資料的處理之前,我覺得需要針對這個問題做一個完整的分析。
在View層設計中我分別提到了胖Model
和瘦Model
的設計思路,而且告訴大家我更加傾向於胖Model
的設計思路。在網路層設計裡面我使用了去Model化
的思路設計了APIMananger與業務層的資料互動。這兩個看似矛盾的關於Model
的設計思路在我接下來要提出的持久層方案中其實是並不矛盾,而且是相互配合的。在網路層設計這篇文章中,我對去Model化
只給出了思路和做法,相關的解釋並不多,是因為要解釋這個問題涉及面會比較廣,寫的時候並不認為在那篇文章裡做解釋是最好的時機。由於持久層在這裡胖Model
和去Model化
都會涉及,所以我覺得在講持久層的時候解釋這個話題會比較好。
我在跟別的各種領域的架構師交流的時候,發現大家都會或多或少地混用Model
和Model Layer
的概念,然後往往導致大家討論的問題最後都不在一個點上,說Model
的時候他跟你說Model Layer
,那好吧,我就跟你說Model Layer
,結果他又在說Model
,於是問題就討論不下去了。我覺得作為架構師,如果不分清楚這兩個概念,肯定是會對你設計的架構的質量有很大影響的。
如果把Model
說成Data Model
,然後跟Model Layer
放在一起,這樣就能夠很容易區分概念了。
Data Model
Data Model
這個術語針對的問題領域是業務資料的建模,以及程式碼中這一資料模型的表徵方式。兩者相輔相承:因為業務資料的建模方案以及業務本身特點,而最終決定了資料的表徵方式。同樣操作一批資料,你的資料建模方案基本都是細化業務問題之後,抽象得出一個邏輯上的實體。在實現這個業務時,你可以選擇不同的表徵方式來表徵這個邏輯上的實體,比如位元組流
(TCP包等),字串流
(JSON、XML等),物件流
。物件流又分通用資料物件
(NSDictionary等),業務資料物件
(HomeCellModel等)。
前面已經遍歷了所有的Data Model
的形式。在習慣上,當我們討論Model化
時,都是單指物件流
中的業務資料物件
這一種。然而去Model化
就是指:更多地使用通用資料物件
去表徵資料,業務資料物件
不會在設計時被優先考慮的一種設計傾向。這裡的通用資料物件可以在某種程度上理解為範型。
Model Layer
Model Layer
描述的問題領域是如何對資料進行增刪改查(CURD, C
reate U
pdate R
ead D
elete),和相關業務處理。一般來說如果在Model Layer
中採用瘦Model
的設計思路的話,就差不多到CURD為止了。胖Model
還會關心如何為需要資料的上層提供除了增刪改查以外的服務,併為他們提供相應的解決方案。例如快取、資料同步、弱業務處理等。
我的傾向
我更加傾向於去Model化
的設計,在網路層我設計了reformer
來實現去Model化。在持久層,我設計了Virtual Record
來實現去Model化。
因為具體的Model是一種很容易引入耦合的做法,在儘可能弱化Model概念的同時,就能夠為引入業務和對接業務提供充分的空間。同時,也能通過去Model的設計達到區分強弱業務的目的,這在將來的程式碼遷移和維護中,是至關重要的。很多設計不好的架構,就在於架構師並沒有認識到區分強弱業務的重要性,所以就導致架構腐化的速度很快,越來越難維護。
所以說回來,持久層與業務層之間的隔離,是通過強弱業務的隔離達到的。而Virtual Record
正是因為這種去Model化的設計,從而達到了強弱業務的隔離,進而做到持久層與業務層之間既隔離同時又能互動的平衡。具體Virtual Record
是什麼樣的設計,我在後面會給大家分析。
2. 資料庫讀寫隔離
在網站的架構中,對資料庫進行讀寫分離主要是為了提高響應速度。在iOS應用架構中,對持久層進行讀寫隔離的設計主要是為了提高程式碼的可維護性。這也是兩個領域要求架構師在設計架構時要求側重點不同的一個方面。
在這裡我們所謂的讀寫隔離並不是指將資料的讀操作和寫操作做隔離。而是以某一條界限為準,在這個界限以外的所有資料模型,都是不可寫不可修改,或者修改屬性的行為不影響資料庫中的資料。在這個界限以內的資料是可寫可修改的。一般來說我們在設計時劃分的這個界限會和持久層與業務層之間的界限保持一致,也就是業務層從持久層拿到資料之後,都不可寫不可修改,或業務層針對這一資料模型的寫操作、修改操作都對資料庫檔案中的內容不產生作用。只有持久層中的操作才能夠對資料庫檔案中的內容產生作用。
在蘋果官方提供的持久層方案Core Data的架構設計中,並沒有針對讀寫作出隔離,資料的結果都是以NSManagedObject
扔出。所以只要業務工程師稍微一不小心動一下某個屬性,NSManagedObjectContext
在save的時候就會把這個修改給存進去了。另外,當我們需要對所有的增刪改查操作做AOP的切片時,Core Data技術棧的實現就會非常複雜。
整體上看,我覺得Core Data相對大部分需求而言是過度設計了。我當時設計安居客聊天模組的持久層時就採用了Core Data,然後為了讀寫隔離,將所有扔出來的NSManagedObject
都轉為了普通的物件。另外,由於聊天記錄的業務相當複雜,使用Core Data之後為了完成需求不得不引入很多Hack的手段,這種做法在一定程度上降低了這個持久層的可維護性和提高了接手模組的工程師的學習曲線,這是不太好的。在天貓客戶端,我去的時候天貓這個App就已經屬於基本毫無持久層可言了,比較混亂。只能依靠各個業務線各顯神通去解決資料持久化的需求,難以推動統一的持久層方案,這對於專案維護尤其是跨業務專案合作來說,基本就和車禍現場沒啥區別。我現在已經從天貓離職,讀者中若是有阿里人想升職想刷存在感拿3.75的,可以考慮給天貓搞個統一的持久層方案。
讀寫隔離還能夠便於加入AOP切點,因為針對資料庫的寫操作被隔離到一個固定的地方,加AOP時就很容易在正確的地方放入切片。這個會在講到資料同步方案時看到應用。
3. 多執行緒導致的隔離
Core Data
Core Data要求在多執行緒場景下,為非同步操作再生成一個NSManagedObjectContext
,然後設定它的ConcurrencyType
為NSPrivateQueueConcurrencyType
,最後把這個Context的parentContext設為Main執行緒下的Context。這相比於使用原始的SQLite去做多執行緒要輕鬆許多。只不過要注意的是,如果要傳遞NSManagedObject
的時候,不能直接傳這個物件的指標,要傳NSManagedObjectID
。這屬於多執行緒環境下物件傳遞的隔離,在進行架構設計的時候需要注意。
SQLite
純SQLite其實對於多執行緒倒是直接支援,SQLite庫提供了三種方式:Single Thread
,Multi Thread
,Serialized
。
Single Thread
模式不是執行緒安全的,不提供任何同步機制。Multi Thread
模式要求database connection
不能在多執行緒中共享,其他的在使用上就沒什麼特殊限制了。Serialized
模式顧名思義就是由一個序列佇列來執行所有的操作,對於使用者來說除了響應速度會慢一些,基本上就沒什麼限制了。大多數情況下SQLite的預設模式是Serialized
。
根據Core Data在多執行緒場景下的表現,我覺得Core Data在使用SQLite作為資料載體時,使用的應該就是Multi Thread
模式。SQLite在Multi Thread
模式下使用的是讀寫鎖,而且是針對整個資料庫加鎖,不是表鎖也不是行鎖,這一點需要提醒各位架構師注意。如果對響應速度要求很高的話,建議開一個輔助資料庫,把一個大的寫入任務先寫入輔助資料庫,然後拆成幾個小的寫入任務見縫插針地隔一段時間往主資料庫中寫入一次,寫完之後再把輔助資料庫刪掉。
不過從實際經驗上看,本地App的持久化需求的讀寫操作一般都不會大,只要注意好幾個點之後一般都不會影響使用者體驗。因此相比於Multi Thread
模式,Serialized
模式我認為是價效比比較高的一種選擇,程式碼容易寫容易維護,效能損失不大。為了提高几十毫秒的效能而犧牲程式碼的維護性,我是覺得划不來的。
Realm
關於Realm我還沒來得及仔細研究,所以說不出什麼來。
4. 資料表達和資料操作的隔離
這是最容易被忽視的一點,資料表達和資料操作的隔離是否能夠做好,直接影響的是整個程式的可擴充性。
長久以來,我們都很習慣Active Record
型別的資料操作和表達方式,例如這樣:
1 2 3 |
Record *record = [[Record alloc] init]; record.data = @"data"; [record save]; |
或者這種:
1 2 |
Record *record = [[Record alloc] init]; NSArray *result = [record fetchList]; |
簡單說就是,讓一個物件對映了一個資料庫裡的表,然後針對這個物件做操作就等同於針對這個表以及這個物件所表達的資料做操作。這裡有一個不好的地方就在於,這個Record
既是資料庫中資料表的對映,又是這個表中某一條資料的對映。我見過很多框架(不僅限於iOS,包括Python, PHP等)都把這兩者混在一起去處理。如果按照這種不恰當的方式來組織資料操作和資料表達,在胖Model的實踐下會導致強弱業務難以區分從而造成非常大的困難。使用瘦Model這種實踐本身就是我認為有缺點的,具體的我在開篇中已經講過,這裡就不細說了。
強弱業務不能區分帶來的最大困難在於程式碼複用和遷移,因為持久層中的強業務對View層業務的高耦合是無法避免的,然而弱業務相對而言只對下層有耦合關係對上層並不存在耦合關係,當我們做程式碼遷移或者複用時,往往希望複用的是弱業務而不是強業務,若此時強弱業務分不開,程式碼複用就無從談起,遷移時就倍加困難。
另外,資料操作和資料表達混在一起會導致的問題在於:客觀情況下,資料在view層業務上的表達方式多種多樣,有可能是個View,也有可能是個別的什麼物件。如果採用對映資料庫表的資料物件去對映資料,那麼這種多樣性就會被限制,實際編碼時每到使用資料的地方,就不得不多一層轉換。
我認為之所以會產生這樣不好的做法原因在於,物件對資料表的對映和物件對資料表達的對映結果非常相似,尤其是在表達Column時,他們幾乎就是一模一樣。在這裡要做好針對資料表或是針對資料的對映要做的區分的關鍵要點是:這個對映物件的操作著手點相對資料表而言,是對內還是對外操作。如果是對內操作,那麼這個操作範圍就僅限於當前資料表,這些操作對映給資料表模型就比較合適。如果是對外操作,執行這些操作時有可能涉及其他的資料表,那麼這些操作就不應該對映到資料表物件中。
因此實際操作中,我是以資料表為單位去針對操作進行物件封裝,然後再針對資料記錄進行物件封裝。資料表中的操作都是針對記錄的普通增刪改查操作,都是弱業務邏輯。資料記錄僅僅是資料的表達方式,這些操作最好交付給資料層分管強業務的物件去執行。具體內容我在下文還會繼續說。
持久層與業務層的互動方式
說到這裡,就不得不說CTPersistance和Virtual Record
了。我會通過它來講解持久層與業務層之間的互動方式。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
------------------------------------------- | | | 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 | ------------------------------------------- |
我先解釋一下這個圖:持久層有專門負責對接View層模組或業務的DataCenter,它們之間通過Record來進行互動。DataCenter向上層提供業務友好的介面,這一般都是強業務:比如根據使用者篩選條件返回符合要求的資料等。
然後DataCenter在這個介面裡面排程各個Table,做一系列的業務邏輯,最終生成record物件,交付給View層業務。
DataCenter為了要完成View層交付的任務,會涉及資料組裝和跨表的資料操作。資料組裝因為View層要求的不同而不同,因此是強業務。跨表資料操作本質上就是各單表資料操作的組合,DataCenter負責排程這些單表資料操作從而獲得想要的基礎資料用於組裝。那麼,這時候單表的資料操作就屬於弱業務,這些弱業務就由Table對映物件來完成。
Table物件通過QueryCommand來生成相應的SQL語句,並交付給資料庫引擎去查詢獲得資料,然後交付給DataCenter。
DataCenter 和 Virtual Record
提到Virtual Record
之前必須先說一下DataCenter。
DataCenter其實是一個業務物件,DataCenter是整個App中,持久層與業務層之間的膠水。它向業務層開放業務友好的介面,然後通過排程各個持久層弱業務邏輯和資料記錄來完成強業務邏輯,並將生成的結果交付給業務層。由於DataCenter處在業務層和持久層之間,那麼它執行業務邏輯所需要的載體,就要既能夠被業務層理解,也能夠被持久層理解。
CTPersistanceTable
就封裝了弱業務邏輯,由DataCenter呼叫,用於運算元據。而Virtual Record
就是前面提到的一個既能夠被業務層理解,也能夠被持久層理解的資料載體。
Virtual Record
事實上並不是一個物件,它只是一個protocol,這就是它Virtual
的原因。一個物件只要實現了Virtual Record
,它就可以直接被持久層當作Record進行操作,所以它也是一個Record
。連起來就是Virtual Record
了。所以,Virtual Record
的實現者可以是任何物件,這個物件一般都是業務層物件。在業務層內,常見的資料表達方式一般都是View,所以一般來說Virutal Record
的實現者也都會是一個View物件。
我們回顧一下傳統的資料操作過程:一般都是先從資料庫中取出資料,然後Model化成一個物件,然後再把這個模型丟到外面,讓Controller轉化成View,然後再執行後面的操作。
Virtual Record
也是一樣遵循類似的步驟。唯一不同的是,整個過程中,它並不需要一箇中間物件去做資料表達,對於資料的不同表達方式,由各自Virtual Record
的實現者自己完成,而不需要把這些程式碼放到Controller,所以這就是一個去Model化的設計。如果未來針對這個資料轉化邏輯有複用的需求,直接複用Virtual Record
就可以了,十分方便。
用好Virtual Record
的關鍵在於DataCenter提供的介面對業務足夠友好,有充足的業務上下文環境。
所以DataCenter一般都是被Controller所持有,所以如果整個App就只有一個DataCenter,這其實並不是一個好事。我見過有很多App的持久層就是一個全域性單例,所有持久化業務都走這個單例,這是一種很蛋疼的做法。DataCenter也是需要針對業務做高度分化的,每個大業務都要提供一個DataCenter,然後掛在相關Controller下交給Controller去排程。比如分化成SettingsDataCenter
,ChatRoomDataCenter
,ProfileDataCenter
等,另外要要注意的是,幾個DataCenter之間最好不要有業務重疊。如果一個DataCenter的業務實在是大,那就再拆分成幾個小業務。如果單個小業務都很大了,那就拆成各個Category,具體的做法可以參考我的框架中CTPersistanceTable
和CTPersistanceQueryCommand
的實踐。
這麼一來,如果要遷移涉及持久層的強業務,那就只需要遷移DataCenter即可。如果要遷移弱業務,就只需要遷移CTPersistanceTable
。
實際場景
假設業務層此時收集到了使用者的篩選條件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
NSDictionary *filter = @{ @"key1":@{ @"minValue1":@(1), @"maxValue1":@(9), }, @"key2":@{ @"minValue2":@(1), @"maxValue2":@(9), }, @"key3":@{ @"minValue3":@(1), @"maxValue3":@(9), }, }; |
然後ViewController呼叫DataCenter向業務層提供的介面,獲得資料直接展示:
1 2 3 4 5 |
/* in view controller */ NSArry *fetchedRecordList = [self.dataCenter fetchItemListWithFilter:filter] [self.dataList appendWithArray:fetchedRecordList]; [self.tableView reloadData]; |
在View層要做的事情其實到這裡就已經結束了,此時我們回過頭再來看DataCenter如何實現這個業務:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
/* in DataCenter */ - (NSArray *)fetchItemListWithFilter:(NSDictionary *)filter { ... ... ... /* 解析filter獲得查詢所需要的資料 whereCondition whereConditionParams 假設上面這兩個變數就是解析得到的變數 */ ... ... ... /* 告知Table物件查詢資料後需要轉化成的物件(可選,統一返回物件可以便於歸併來自不同表的資料) */ self.itemATable.recordClass = [Item class]; self.itemBTable.recordClass = [Item class]; self.itemCTable.recordClass = [Item class]; /* 通過Table物件獲取資料,此時Table物件內執行的就是弱業務了 */ 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]; /* 組裝資料 */ NSMutableArray *resultList = [[NSMutableArray alloc] init]; [resultList addObjectsFromArray:itemAList]; [resultList addObjectsFromArray:itemBList]; [resultList addObjectsFromArray:itemCList]; return resultList; } |
基本上差不多就是上面這樣的流程。
一般來說,架構師設計得差的持久層,都沒有通過設計DataCenter和Table,去將強業務和弱業務分開。通過設計DataCenter和Table物件,主要是便於程式碼遷移。如果遷移強業務,把DataCenter和Table一起拿走就可以,如果只是遷移弱業務,拿走Table就可以了。
另外,通過程式碼我希望向你強調一下這個概念:將Table和Record區分開
,這個在我之前畫的架構圖上已經有所表現,不過上文並沒有著重強調。其實很多別的架構師在設計持久層框架的時候,也沒有將Table和Record區分開,對的,這裡我說的框架包括Core Data和FMDB,這個也不僅限於iOS領域,CodeIgniter、ThinkPHP、Yii、Flask這些也都沒有對這個做區分。(這裡吐槽一下,話說上文我還提到Core Data被過度設計了,事實上該設計的地方沒設計到,不該設計的地方各種設計往上堆…)
以上就是對Virtual Record
這個設計的簡單介紹,接下來我們就開始討論不同場景下如何進行互動了。
其中我們最為熟悉的一個場景是這樣的:經過各種邏輯組裝出一個資料物件,然後把這個資料物件交付給持久層去處理。這種場景我稱之為一對一的互動場景,這個互動場景的實現非常傳統,就跟大家想得那樣,而且CTPersistance的test case裡面都是這樣的,所以這裡我就不多說了。所以,既然你已經知道有了一對一,那麼順理成章地就也會有多對一,以及一對多的互動場景。
下面我會一一描述Virtual Record
是如何發揮虛擬
的優勢去針對不同場景進行互動的。
多對一場景下,業務層如何與持久層互動?
多對一場景其實有兩種理解,一種是一個記錄的資料由多個View的資料組成。例如一張使用者表包含使用者的所有資料。然後有的View只包含使用者暱稱使用者頭像,有的物件只包含使用者ID使用者Token。然而這些資料都只存在一張使用者表中,所以這是一種多個物件的資料組成一個完整Record資料
的場景,這是多對一場景
的理解之一。
第二種理解是這樣的,例如一個ViewA物件包含了一個Record的所有資訊,然後另一個ViewB物件其實也包含了一個Record的所有資訊,這就是一種多個不同物件表達了一個Record資料
的場景,這也是一種多對一場景的理解。
同時,這裡所謂的互動還分兩個方向:存和取。
其實這兩種理解的解決方案都是一樣的,Virtual Record
的實現者通過實現Merge
操作來完成record資料的彙總,從而實現存操作。任意Virtual Record
的實現者通過Merge
操作,就可以將自己的資料交付給其它不同的物件進行表達,從而實現取操作。具體的實現在下面有具體闡釋。
多對一場景下,如何進行存操作?
提供了
- (NSObject *)mergeRecord:(NSObject *)record shouldOverride:(BOOL)shouldOverride;
這個方法。望文生義一下,就是一個record可以與另外一個record進行merge。在shouldOverride
為NO的情況下,任何一邊的nil
都會被另外一邊不是nil
的記錄覆蓋,如果merge過程中兩個物件都不含有這些空資料,則根據shouldOverride
來決定是否要讓引數中record的資料覆蓋自己本身的資料,若shouldOverride
為YES,則即便是nil,也會把已有的值覆蓋掉。這個方法會返回被Merge的這個物件,便於鏈式呼叫。
舉一個程式碼樣例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
/* 這裡的RecordViewA, RecordViewB, RecordViewC都是符合且實現了- (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物件來達到獲取完整資料的目的,由於是Virtual Record
,具體的實現都是由各自的View去決定。View是最瞭解自己屬性的物件了,因此它是有充要條件來把自己與持久層相關的資料取出並Merge的,那麼這段湊資料
的程式碼,就相應分散到了各個View物件中,Controller裡面就能夠做到非常乾淨,整體可維護性也就提高了。
如果採用傳統方式,ViewController或者DataCenter中就會散落很多用於湊資料
的程式碼,寫的時候就會出現一大段用於合併的程式碼,非常難看,還不容易維護。
多對一場景下,如何進行取操作?
其實這樣的表述並不恰當,因為無論Virtual Record
的實現如何,物件是誰,只要從資料庫裡面取出資料來,資料就都是能夠保證完整的。這裡更準確的表述是,取出資料之後,如何交付給不同的物件。其實還是用到上面提到的mergeRecord
方法來處理。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
/* 這裡的RecordViewA, RecordViewB, RecordViewC都是符合且實現了- (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記錄的資料交給b和c了,程式碼觀感同樣非常棒,而且容易寫容易維護。
一對多場景下,業務層如何與持久層互動?
一對多場景也有兩種理解,其一是一個物件包含了多個表的資料
,另外一個是一個物件用於展示多種表的資料
,這個程式碼樣例其實文章前面已經有過,這一節會著重強調一下。乍看之下兩者並沒有什麼區別,所以我需要指出的是,前者強調的是包含
,也就是這個物件是個大熔爐,由多個表的資料組成。
還是舉使用者列表的例子:
假設資料庫中使用者相關的表有多張。大多數情況是因為單表Column太多,所以為了提高維護性和查詢效能而進行的
縱切
。
多說一句,縱切
在實際操作時,大多都是根據業務場景去切分成多個不同的表,分別來表達使用者各業務相關的部分資料,所以縱切的結果就是把Column特別多的一張表拆成Column不那麼多的好幾個表。雖然資料庫經過了縱切,但是有的場景還是要展示完整資料的,比如使用者詳情頁。因此,這個使用者詳情頁的View就有可能包含使用者基礎資訊表(使用者名稱、使用者ID、使用者Token等)、以及使用者詳細資訊表(使用者郵箱地址、使用者手機號等)。這就是一對多
的一個物件包含了多個表的資料
的意思。
後者強調的是展示
。舉個例子,資料庫中有三個表分別是:
二手房
、新房
、租房
,它們三者的資料分別儲存在三個表裡面,這其實是一種橫切
。
橫切
也是一種資料庫的優化手段,橫切與縱切不同的地方在於,橫切是在保留了這套資料的完整性的前提下進行的切分,橫切的結果就是把一個原本資料量很大的表,分成了好幾個資料量不那麼大的表。也就是原來三種房子都能用同一個表來儲存,但是這樣資料量就太大了,資料庫響應速度就會下降。所以根據房子的型別拆成這三張表。橫切也有根據ID切的,比如根據ID取餘的結果來決定分在哪些表裡,這種做法比較廣泛,因為擴充起來方便,到時候資料表又大了,大不了除數也跟著再換一個更大的數罷了。其實根據型別去橫切也可以,只是擴充的時候就不那麼方便。
剛才扯遠了現在我再扯回來,這三張表在展示的時候,只是根據型別的不同,介面才有稍許不同而已,所以還是會用同一張View去展示這三種資料,這就是一對多
的一個物件用於展示多種表的資料
的意思。
一個物件包含了多個表的資料時,如何進行存取操作?
在進行取操作時,其實跟前面多對一的取操作是一樣的,用Merge
操作就可以了。
1 2 3 4 5 6 7 |
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; |
在進行存操作時,Virtual Record
的要求實現者實現
- (NSDictionary *)dictionaryRepresentationWithColumnInfo:(NSDictionary *)columnInfo tableName:(NSString *)tableName;
這個方法,實現者可以根據傳入的columnInfo
和tableName
返回相應的資料,這樣就能夠把這一次存資料時關心的內容提供給持久層了。程式碼樣例就是這樣的:
1 2 3 4 5 6 7 8 9 10 11 |
RecordViewA *a = ...... ; /* 由於有- (NSDictionary *)dictionaryRepresentationWithColumnInfo:(NSDictionary *)columnInfo tableName:(NSString *)tableName;的實現,a物件自己會提供給不同的Table它們感興趣的內容而儲存。 所以直接存就好了。 */ [self.CasaTable insertRecord:a error:NULL]; [self.TaloyumTable insertRecord:a error:NULL]; [self.CasatwyTable insertRecord:a error:NULL]; |
通過上面的存取案例,你會發現使用Virtual Record
之後,程式碼量一下子少掉很多,原本那些亂七八糟用於拼湊條件的程式碼全部被分散進了各個虛擬記錄的實現中去了,程式碼維護因此就變得相當方便。若是採用傳統做法,再存取之前少不了要寫一大段邏輯,如果涉及程式碼遷移,這大段邏輯就也得要跟著遷移過去,這就很蛋疼了。
一個物件用於展示多種表的資料,如何進行存取操作?
在這種情況下的存操作其實跟上面一樣,直接存。Virtual Record
的實現者自己會根據要存入的表的資訊組裝好資料提供給持久層。樣例程式碼與上一小節的存操作中給出的一模一樣,我就不復制貼上了。
取操作就不太一樣了,不過由於取出時的物件是唯一的(因為一對多嘛),程式碼也一樣十分簡單:
1 2 3 4 5 6 7 8 9 10 11 |
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
分別是不同種類的表。這個例子表示了一個物件如何用於展示不同型別的資料。如果使用傳統方法,這裡少不了要寫很多適配程式碼,但是使用Virtual Record
之後,這些程式碼都由各自實現者消化掉了,在執行資料邏輯時可以無需關心適配邏輯。
多對多場景?
其實多對多場景就是上述這些一對多
和多對一
場景的排列組合,實現方式都是一模一樣的,我這裡就也不多囉嗦了。
互動方案的總結
在互動方案的設計中,架構師應當區分好強弱業務,把傳統的Data Model
區分成Table
和Record
,並由DataCenter
去實現強業務,Table
去實現弱業務。在這裡由於DataCenter
是強業務相關,所以在實際編碼中,業務工程師負責建立DataCenter,並向業務層提供業務友好的方法,然後再在DataCenter中操作Table來完成業務層交付的需求。區分強弱業務,將Table
和Record
拆分開的好處在於:
- 通過業務細分降低耦合度,使得程式碼遷移和維護非常方便
- 通過拆解資料處理邏輯和資料表達形態,使得程式碼具有非常良好的可擴充性
- 做到讀寫隔離,避免業務層的誤操作引入Bug
- 為Virtual Record這一設計思路的實踐提供基礎,進而實現更靈活,對業務更加友好的架構
任何不區分強弱業務的架構都是架構師在耍流氓,嗯。
在具體與業務層互動時,採用Virtual Record
的設計思路來設計Record
,由具體的業務物件來實現Virtual Record,並以它作為DataCenter和業務層之間的資料媒介進行互動。而不是使用傳統的資料模型來與業務層做互動。
使用Virtual Record
的好處在於:
- 將資料適配和資料轉化邏輯封裝到具體的Record實現中,可以使得程式碼更加抽象簡潔,程式碼汙染更少
- 資料遷移時只需要遷移Virtual Record相關方法即可,非常容易拆分
- 業務工程師實現業務邏輯時,可以在不損失可維護性的前提下,極大提高業務實現的靈活性
這一部分還順便提了一下橫切
和縱切
的概念。本來是打算有一小節專門寫資料庫效能優化的,不過事實上移動App場景下資料庫的效能優化手段不像服務端那樣豐富多彩,很多牛逼技術和引數調優手段想用也用不了。差不多就只剩下資料切片的手段比較有效了,所以效能優化這塊感覺沒什麼好寫的。其實大家瞭解了切片的方式和場景,就足以根據自己的業務場景去做優化了。再使用一下Instrument的Time Profile再配合SQLite提供的一些函式,就足以找到慢在哪兒,然後去做效能調優了。但如果我把這些也寫出來,就變成教你怎麼使用工具,感覺這個太low寫著也不起勁,大家有興趣搜使用手冊下來看就行。
資料庫版本遷移方案
一般來說,具有持久層的App同時都會附帶著有版本遷移的需求。當一個使用者安裝了舊版本的App,此時更新App之後,若資料庫的表結構需要更新,或者資料本身需要批量地進行更新,此時就需要有版本遷移機制來進行這些操作。然而版本遷移機制又要兼顧跨版本的遷移需求,所以基本上大方案也就只有一種:建立資料庫版本節點,遷移的時候一個一個跑過去。
資料遷移事實上實現起來還是比較簡單的,做好以下幾點問題就不大了:
- 根據應用的版本記錄每一版資料庫的改變,並將這些改變封裝成物件
- 記錄好當前資料庫的版本,便於跟遷移記錄做比對
- 在啟動資料庫時執行遷移操作,如果遷移失敗,提供一些降級方案
CTPersistance在資料遷移方面,凡是對於資料庫原本沒有的資料表,如果要新增,在使用table的時候就會自動建立。因此對於業務工程師來說,根本不需要額外多做什麼事情,直接用就可以了。把這部分工作放到這裡,也是為資料庫版本遷移節省了一些步驟。
CTPersistance也提供了Migrator。業務工程師可以自己針對某一個資料庫編寫一個Migrator。這個Migrator務必派生自CTPersistanceMigrator,且符合,只要提供一個migrationStep的字典,以及記錄版本順序的陣列。然後把你自己派生的Migrator的類名和對應關心的資料庫名寫在
CTPersistanceConfiguration.plist
裡面就可以。CTPersistance會在初始資料庫的時候,根據plist裡面的配置對應找到Migrator,並執行資料庫版本遷移的邏輯。
在版本遷移時要注意的一點是效能問題。我們一般都不會在主執行緒做版本遷移的事情,這自然不必說。需要強調的是,SQLite本身是一個容錯性非常強的資料庫引擎,因此差不多在執行每一個SQL的時候,內部都是走的一個Transaction。當某一版的SQL數量特別多的時候,建議在版本遷移的方法裡面自己建立一個Transaction,然後把相關的SQL都包起來,這樣SQLite執行這些SQL的時候速度就會快一點。
其他的似乎並沒有什麼要額外強調的了,如果有沒說到的地方,大家可以在評論區提出來。
資料同步方案
資料同步方案大致分兩種型別,一種型別是單向資料同步
,另一種型別是雙向資料同步
。下面我會分別說說這兩種型別的資料同步方案的設計。
單向資料同步
單向資料同步就是隻把本地較新資料的操作同步到伺服器,不會從伺服器主動拉取同步操作。
比如即時通訊應用,一個裝置在發出訊息之後,需要等待伺服器的返回去知道這個訊息是否傳送成功,是否取消成功,是否刪除成功。然後資料庫中記錄的資料就會隨著這些操作是否成功而改變狀態。但是如果換一臺裝置繼續執行操作,在這個新裝置上只會拉取舊的資料,比如聊天記錄這種。但對於舊的資料並沒有刪除或修改的需求,因此新裝置也不會問伺服器索取資料同步的操作,所以稱之為單向資料同步。
單向資料同步一般來說也不需要有job去做定時更新的事情。如果一個操作遲遲沒有收到伺服器的確認,那麼在應用這邊就可以認為這個操作失敗,然後一般都是在介面上把這些失敗的操作展示出來,然後讓使用者去勾選需要重試的操作,然後再重新發起請求。微信在訊息傳送失敗的時候,就是訊息前面有個紅色的圈圈,裡面有個感嘆號,只有使用者點選這個感嘆號的時候才重新傳送訊息,背後不會有個job一直一直跑。
所以細化需求之後,我們發現單向資料同步只需要做到能夠同步資料的狀態即可。
如何完成單向資料同步的需求
新增identifier
新增identifier的目的主要是為了解決客戶端資料的主鍵和服務端資料的主鍵不一致的問題。由於是單向資料同步,所以資料的生產者只會是當前裝置,那麼identifier也理所應當由裝置生成。當裝置發起同步請求的時候,把identifier帶上,當伺服器完成任務返回資料時,也把這些identifier帶上。然後客戶端再根據服務端給到的identifier再更新本地資料的狀態。identifier一般都會採用UUID字串。
新增isDirty
isDirty主要是針對資料的插入和修改進行標識。當本地新生成資料或者更新資料之後,收到伺服器的確認返回之前,isDirty置為YES。當伺服器的確認包返回之後,再根據包裡提供的identifier找到這條資料,然後置為NO。這樣就完成了資料的同步。
然而這只是簡單的場景,有一種比較極端的情況在於,當請求發起到收到請求回覆的這短短几秒間,使用者又修改了資料。如果按照當前的邏輯,在收到請求回覆之後,這個又修改了的資料的isDirty會被置為NO,於是這個新的修改就永遠無法同步到伺服器了。這種極端情況的簡單處理方案就是在發起請求到收到回覆期間,介面上不允許使用者進行修改。
如果希望做得比較細緻,在傳送同步請求期間依舊允許使用者修改的話,就需要在資料庫額外增加一張DirtyList
來記錄這些操作,這個表裡至少要有兩個欄位:identifier
,primaryKey
。然後每一次操作都分配一次identifier,那麼新的修改操作就有了新的identifier。在進行同步時,根據primaryKey
找到原資料表裡的那條記錄,然後把資料連同identifier交給伺服器。然後在伺服器的確認包回來之後,就只要拿出identifier再把這條操作記錄刪掉即可。這個表也可以直接服務於多個表,只是還需要額外新增一個tablename
欄位,方便發起同步請求的時候能夠找得到資料。
新增isDeleted
當有資料同步的需求的時候,刪除操作就不能是簡單的物理刪除了,而只是邏輯刪除,所謂邏輯刪除就是在資料庫裡把這條記錄的isDeleted記為YES,只有當伺服器的確認包返回之後,才會真正把這條記錄刪除。isDeleted和isDirty的區別在於:收到確認包後,返回的identifier指向的資料如果是isDeleted,那麼就要刪除這條資料,如果指向的資料只是新插入的資料和更新的資料,那麼就只要修改狀態就行。插入資料和更新資料在收到資料包之後做的操作是相同的,所以就用isDirty來區分就足夠了。總之,這是根據收到確認包之後的操作不同而做的區分。兩者都要有,缺一不可。
在請求的資料包中,新增dependencyIdentifier
在我看到的很多其它資料同步方案中,並沒有提供dependencyIdentifier,這會導致一個這樣的問題:假設有兩次資料同步請求一起發出,A先發,B後發。結果反而是B請求先到,A請求後到。如果A請求的一系列同步操作裡面包含了插入某個物件的操作,B請求的一系列同步操作裡面正好又刪除了這個物件,那麼由於到達次序的先後問題錯亂,就導致這個資料沒辦法刪除。
這個在移動裝置的使用場景下是很容易發生的,移動裝置本身網路環境就多變,先發的包反而後到,這種情況出現的機率還是比較大的。所以在請求的資料包中,我們要帶上上一次請求時一系列identifier的其中一個,就可以了。一般都是選擇上次請求裡面最後的那一個操作的identifier,這樣就能表徵上一次請求的操作了。
服務端這邊也要記錄最近的100個請求包裡面的最後一個identifier。之所以是100條純屬只是拍腦袋定的數字,我覺得100條差不多就夠了,客戶端發請求的時候denpendency應該不會涉及到前面100個包。服務端在收到同步請求包的時候,先看denpendencyIdentifier是否已被記錄,如果已經被記錄了,那麼就執行這個包裡面的操作。如果沒有被記錄,那就先放著再等等,等到條件滿足了再執行,這樣就能解決這樣的問題。
之所以不用更新時間而是identifier來做標識,是因為如果要用時間做標識的話,就是隻能以客戶端發出資料包時候的時間為準。但有時不同裝置的時間不一定完全對得上,多少會差個幾秒幾毫秒,另外如果同時有兩個裝置發起同步請求,這兩個包的時間就都是一樣的了。假設A1, B1是1號裝置傳送的請求,A2, B2,是2號裝置傳送的請求,如果用時間去區分,A1到了之後,B2說不定就直接能夠執行了,而A1還沒到伺服器呢。
當然,這也是一種極端情況,用時間的話,伺服器就只要記錄一個時間了,凡是依賴時間大於這個時間的,就都要再等等,實現起來就比較方便。但是為了保證bug儘可能少,我認為依賴還是以identifier為準,這要比以時間為準更好,而且實現起來其實也並沒有增加太多複雜度。
單向資料同步方案總結
- 改造的時候新增identifier,isDirty,isDeleted欄位。如果在請求期間依舊允許對資料做操作,那麼就要把identifier和primaryKey再放到一個新的表中
- 每次生成資料之後對應生成一個identifier,然後只要是針對資料的操作,就修改一次isDirty或isDeleted,然後發起請求帶上identifier和操作指令去告知伺服器執行相關的操作。如果是複雜的同步方式,那麼每一次修改資料時就新生成一次identifier,然後再發起請求帶上相關資料告知伺服器。
- 伺服器根據請求包的identifier等資料執行操作,操作完畢回覆給客戶端確認
- 收到伺服器的確認包之後,根據伺服器給到的identifier(有的時候也會有tablename,取決於你的具體實現)找到對應的記錄,如果是刪除操作,直接把資料刪除就好。如果是插入和更新操作,就把isDirty置為NO。如果有額外的表記錄了更新操作,直接把identifier對應的這個操作記錄刪掉就行。
要注意的點
在使用表去記錄更新操作的時候,短時間之內很有可能針對同一條資料進行多次更新操作。因此在同步之前,最好能夠合併這些相同資料的更新操作,可以節約伺服器的計算資源。當然如果你伺服器強大到不行,那就無所謂了。
雙向資料同步
雙向資料同步多見於筆記類、日程類應用。對於一臺裝置來說,不光自己會往上推資料同步的資訊,自己也會問伺服器主動索取資料同步的資訊,所以稱之為雙向資料同步。
舉個例子:當一臺裝置生成了某時間段的資料之後,到了另外一臺裝置上,又修改了這些舊的歷史資料。此時再回到原來的裝置上,這臺裝置就需要主動問伺服器索取是否舊的資料有修改,如果有,就要把這些操作下載下來同步到本地。
雙向資料同步實現上會比單向資料同步要複雜一些,而且有的時候還會存在實時同步的需求,比如協同編輯。由於本身方案就比較複雜,另外一定要兼顧業務工程師的上手難度(這主要看你這個架構師的良心),所以要實現雙向資料同步方案的話,還是很有意思比較有挑戰的。
如何完成雙向資料同步的需求
封裝操作物件
這個其實在單向資料同步時多少也涉及了一點,但是由於單向資料同步的要求並不複雜,只要告訴伺服器是什麼資料然後要做什麼事情就可以了,倒是沒必要將這種操作封裝。在雙向資料同步時,你也得解析資料操作,所以互相之間要約定一個協議,通過封裝這個協議,就做到了針對操作物件的封裝。
這個協議應當包括:
- 操作的唯一標識
- 資料的唯一標識
- 操作的型別
- 具體的資料,主要是在Insert和Update的時候會用到
- 操作的依賴標識
- 使用者執行這項操作時的時間戳
分別解釋一下這6項的意義:
- 操作的唯一標識
這個跟單向同步方案時的作用一樣,也是在收到伺服器的確認包之後,能夠使得本地應用找到對應的操作並執行確認處理。
- 資料的唯一標識
在找到具體操作的時候執行確認邏輯的處理時,都會涉及到物件本身的處理,更新也好刪除也好,都要在本地資料庫有所體現。所以這個標識就是用於找到對應資料的。
- 操作的型別
操作的型別就是Delete
,Update
,Insert
,對應不同的操作型別,對本地資料庫執行的操作也會不一樣,所以用它來進行標識。
- 具體的資料
當更新的時候有Update
或者Insert
操作的時候,就需要有具體的資料參與了。這裡的資料有的時候不見得是單條的資料內容,有的時候也會是批量的資料。比如把所有10月1日之前的任務都標記為已完成狀態。因此這裡具體的資料如何表達,也需要定一個協議,什麼時候作為單條資料的內容去執行插入或更新操作,什麼時候作為批量的更新去操作,這個自己根據實際業務需求去定義就行。
- 操作的依賴標識
跟前面提到的依賴標識一樣,是為了防止先發的包後到後發的包先到這種極端情況。
- 使用者執行這項操作的時間戳
由於跨裝置,又因為舊資料也會被更新,因此在一定程度上就會出現衝突的可能。運算元據在從伺服器同步下來之後,會存放在一個新的表中,這個表就是待操作
資料表,在具體執行這些操作的同時會跟待同步
的資料表中的運算元據做比對。如果是針對同一條資料的操作,且這兩個操作存在衝突,那麼就以時間戳來決定如何執行。還有一種做法就是直接提交到介面告知使用者,讓使用者做決定。
新增待運算元據表和待同步資料表
前面已經部分提到這一點了。從伺服器拉下來的同步操作列表,我們存在待執行
資料表中,操作完畢之後如果有告知伺服器的需求,那就等於是走單向同步方案告知伺服器。在執行過程中,這些操作也要跟待同步
資料表進行匹配,看有沒有衝突,沒有衝突就繼續執行,有衝突的話要麼按照時間戳執行,要麼就告知使用者讓使用者做決定。在拉取待執行
操作列表的時候,也要把最後一次操作的identifier丟給伺服器,這樣伺服器才能返回相應資料。
待同步
資料表的作用其實也跟單向同步方案時候的作用類似,就是防止在傳送請求的時候使用者有操作,同時也是為解決衝突提供方便。在發起同步請求之前,我們都應該先去查詢有沒有待執行
的列表,當待執行
的操作列表同步完成之後,就可以刪除裡面的記錄了,然後再把本地待同步
的資料交給伺服器。同步完成之後就可以把這些資料刪掉了。因此在正常情況下,只有在待操作
和待執行
的操作間會存在衝突。有些從道理上講也算是衝突的事情,比如獲取待執行
的資料比較晚,但其中又和待同步
中的操作有衝突,像這種極端情況我們其實也無解,只能由他去,不過這種情況也是屬於比較極端的情況,發生機率不大。
何時從伺服器拉取待執行列表
- 每次要把本地資料丟到伺服器去同步之前,都要拉取一次待執行列表,執行完畢之後再上傳本地同步資料
- 每次進入相關頁面的時候都更新一次,看有沒有新的操作
- 對實時性要求比較高的,要麼客戶端本地起一個執行緒做輪詢,要麼伺服器通過長連結將
待執行
操作推送過來 - 其它我暫時也想不到了,具體還是看需求吧
雙向資料同步方案總結
- 設計好同步協議,用於和服務端進行互動,以及指導本地去執行同步下來的操作
- 新增
待執行
,待同步
資料表記錄要執行的操作和要同步的操作
要注意的點
我也見過有的方案是直接把SQL丟出去進行同步的,我不建議這麼做。最好還是將操作和資料分開,然後細化,否則檢測衝突的時候你就得去分析SQL了。要是這種實現中有什麼bug,解這種bug的時候就要考慮前後相容問題,機制重建成本等,因為貪圖一時偷懶,到最後其實得不償失。
總結
這篇文章主要是基於CTPersistance講了一下如何設計持久層的設計方案,以及資料遷移方案和資料同步方案。
著重強調了一下各種持久層方案在設計時要考慮的隔離,以及提出了Virtual Record
這個設計思路,並對它做了一些解釋。然後在資料遷移方案設計時要考慮的一些點。在資料同步方案這一節,分開講了單向的資料同步方案和雙向的資料同步方案的設計,然而具體實現還是要依照具體的業務需求來權衡。
希望大家覺得這些內容對各自工作中遇到的問題能夠有所價值,如果有問題,歡迎在評論區討論。
另外,關於動態部署方案,其實直到今天在iOS領域也並沒有特別好的動態部署方案可以拿出來,我覺得最靠譜的其實還是H5和Native的Hybrid方案。React Native在我看來相比於Hybrid還是有比較多的限制。關於Hybrid方案,我也提供了CTJSBridge這個庫去實現這方面的需求。在動態部署方案這邊其實成文已經很久,遲遲不發的原因還是因為覺得當時並沒有什麼銀彈可以解決iOS App的動態部署,另外也有一些問題沒有考慮清楚。當初想到的那些問題現在我已經確認無解。當初寫的動態部署方案我一直認為它無法作為一個單獨的文章釋出出來,所以我就把這篇文章也放在這裡,權當給各位參考。
iOS動態部署方案
前言
這裡討論的動態部署方案,就是指通過不發版的方式,將新的內容、新的業務流程部署進已釋出的App。因為蘋果的稽核週期比較長,而且蘋果的限制比較多,業界在這裡也沒有特別多的手段來達到動態部署方案的目的。這篇文章主要的目的就是給大家列舉一下目前業界做動態部署的手段,以及其對應的優缺點。然後給出一套我比較傾向於使用的方案。
其實單純就動態部署方案來講,沒什麼太多花頭可以說的,就是H5、Lua、JS、OC/Swift這幾門基本技術的各種組合排列。寫到後面覺得,動態部署方案其實是非常好的用於講解某些架構模式的背景。一般我們經驗總結下來的架構模式包括但不限於:
Layered Architecture
Event-Driven Architecture
Microkernel Architecture
Microservices Architecture
Space-Based Architecture
我在開篇裡面提到的MVC等方案跟這篇文章中要提到的架構模式並不是屬於同一個維度的。比較容易混淆的就是容易把MVC這些方案跟Layered Architecture
混淆,這個我在開篇這篇文章裡面也做過了區分:MVC等方案比較側重於資料流動方向的控制和資料流的管理。Layered Architecture
更加側重於各分層之間的功能劃分和模組協作。
另外,上述五種架構模式在Software Architecture Patterns這本書裡有非常詳細的介紹,整本書才45頁,個把小時就看完了,非常值得看和思考。本文後半篇涉及的架構模式是以上架構模式的其中兩種:Microkernel Architecture
和Microservices Architecture
。
最後,文末還給出了其他一些關於架構模式的我覺得還不錯的PPT和論文,裡面對架構模式的分類和總結也比較多樣,跟Software Architecture
的總結也有些許不一樣的地方,可以博採眾長。
Patterns
Web App
實現方案
其實所謂的web app,就是通過手機上的瀏覽器進行訪問的H5頁面。這個H5頁面是針對移動場景特別優化的,比如UI互動等。
優點
- 無需走蘋果流程,所有蘋果流程帶來的成本都能避免,包括稽核週期、證照成本等。
- 版本更新跟網頁一樣,隨時生效。
- 不需要Native App工程師的參與,而且市面上已經有很多針對這種場景的框架。
缺點
- 由於每一頁都需要從伺服器下載,因此web app重度依賴網路環境。
- 同樣的UI效果使用web app來實現的話,流暢度不如Native,比較影響使用者體驗。
- 本地持久化的部分很難做好,繞過本地持久化的部分的辦法就是提供賬戶體系,對應賬戶的持久化資料全部存在服務端。
- 即時響應方案、遠端通知實現方案、移動端感測器的使用方案複雜,維護難度大。
- 安全問題,H5頁面等於是所有東西都暴露給了使用者,如果對安全要求比較高的,很多額外的安全機制都需要在服務端實現。
總結
web app一般是創業初期會重點考慮的方案,因為迭代非常快,而且創業初期的主要目標是需要驗證模式的正確性,並不在於提供非常好的使用者體驗,只需要完成閉環即可。早年facebook曾經嘗試過這種方案,最後因為使用者體驗的問題而宣佈放棄。所以這個方案只能作為過渡方案,或者當App不可用時,作為降級方案使用。
Hybrid App
通過市面上各種Hybrid框架,來做H5和Native的混合應用,或者通過JS Bridge來做到H5和Native之間的資料互通。
優點
- 除了要承擔蘋果流程導致的成本以外,具備所有web app的優勢
- 能夠訪問本地資料、裝置感測器等
缺點
- 跟web app一樣存在過度依賴網路環境的問題
- 使用者體驗也很難做到很好
- 安全性問題依舊存在
- 大規模的資料互動很難實現,例如圖片在本地處理後,將圖片傳遞給H5
總結
Hybrid方案更加適合跟本地資源互動不是很多,然後主要以內容展示為主的App。在天貓App中,大量地採用了JS Bridge的方式來讓H5跟Native做互動,因為天貓App是一個以內容展示為主的App,且營銷活動多,週期短,比較適合Hybrid。
React-Native
嚴格來說,React-Native應當放到Hybrid那一節去講,單獨拎出來的原因是Facebook自從放出React-Native之後,業界討論得非常激烈。天貓的鬼道也做了非常多的關於React-Native的分享。
React-Native這個框架比較特殊,它展示View的方式依然是Native的View,然後也是可以通過URL的方式來動態生成View。而且,React-Native也提供了一個Bridge通道來做Javascript和Objective-C之間的交流,還是很貼心的。
然而研究了一下發現有一個比較坑的地方在於,解析JS要生成View時所需要的View,是要本地能夠提供的。舉個例子,比如你要有一個特定的Mapview,並且要響應對應的delegate方法,在React-Native的環境下,你需要先在Native提供這個Mapview,並且自己實現這些delegate方法,在實現完方法之後通過Bridge把資料回傳給JS端,然後重新渲染。
在這種情況下我們就能發現,其實React-Native在使用View的時候,這些View是要經過本地定製的,並且將相關方法通過RCT_EXPORT_METHOD
暴露給js,js端才能正常使用。在我看來,這裡在一定程度上限制了動態部署時的靈活性,比如我們需要在某個點選事件中展示一個動畫或者一個全新的view,由於本地沒有實現這個事件或沒有這個view,React-Native就顯得捉襟見肘。
優點
- 響應速度很快,只比Native慢一點,比webview快很多。
- 能夠做到一定程度上的動態部署
缺點
- 組裝頁面的元素需要Native提供支援,一定程度上限制了動態部署的靈活性。
總結
由於React-Native框架中,因為View的展示和View的事件響應分屬於不同的端,展示部分的描述在JS端,響應事件的監聽和描述都在Native端,通過Native轉發給JS端。所以,從做動態部署的角度上講,React-Native只能動態部署新View,不能動態部署新View對應的事件。當然,React-Native本身提供了很多基礎元件,然而這個問題仍然還是會限制動態部署的靈活性。因為我們在動態部署的時候,大部分情況下是希望View和事件響應一起改變的。
另外一個問題就在於,View的原型需要從Native中取,這個問題相較於上面一個問題倒是顯得不那麼嚴重,只是以後某個頁面需要新增某個複雜的view的時候,需要從現有的元件中拼裝罷了。
所以,React-Native事實上解決的是如何不使用Objc/Swift來寫iOS App的View
的問題,對於如何通過不發版來給已發版的App更新功能
這樣的問題,幫助有限。
Lua Patch
大眾點評的屠毅敏同學在基於wax的基礎上寫了waxPatch,這個工具的主要原理是通過lua來針對objc的方法進行替換,由於lua本身是解釋型語言,可以通過動態下載得到,因此具備了一定的動態部署能力。然而iOS系統原生並不提供lua的解釋庫,所以需要在打包時把lua的解釋庫編譯進app。
優點
- 能夠通過下載指令碼替換方法的方式,修改本地App的行為。
- 執行效率較高
缺點
- 對於替換功能來說,lua是很不錯的選擇。但如果要新增新內容,實際操作會很複雜
- 很容易改錯,小問題變成大問題
總結
lua的解決方案在一定程度上解決了動態部署的問題。實際操作時,一般不使用它來做新功能的動態部署,主要還是用於修復bug時程式碼的動態部署。實際操作時需要注意的另外一點是,真的很容易改錯,尤其是你那個方法特別長的時候,所以改了之後要徹底迴歸測試一次。
Javascript Patch
這個工作原理其實跟上面說的lua那套方案的工作原理一樣,只不過是用javascript實現。而且最近新出了一個JSPatch這個庫,相當好用。
優點
- 同Lua方案的優點
- 打包時不用將直譯器也編譯進去,iOS自帶JavaScript的直譯器,只不過要從iOS7.0以後才支援。
缺點
- 同Lua方案的缺點
總結
在對app打補丁的方案中,目前我更傾向於使用JSPatch的方案,在能夠完成Lua做到的所有事情的同時,還不用編一個JS直譯器進去,而且會javascript的人比會lua的人多,技術儲備比較好做。
JSON Descripted View
其實這個方案的原理是這樣的:使用JSON來描述一個View應該有哪些元素,以及元素的位置,以及相關的屬性,比如背景色,圓角等等。然後本地有一個直譯器來把JSON描述的View生成出來。
這跟React-Native有點兒像,一個是JS轉Native,一個是JSON轉Native。但是同樣有的問題就是事件處理的問題,在事件處理上,React-Native做得相對更好。因為JSON不能夠描述事件邏輯,所以JSON生成的View所需要的事件處理都必須要本地事先掛好。
優點
- 能夠自由生成View並動態部署
缺點
- 天貓實際使用下來,發現還是存在一定的效能問題,不夠快
- 事件需要本地事先寫好,無法動態部署事件
總結
其實JSON描述的View比React-Native的View有個好處就在於對於這個View而言,不需要本地也有一套對應的View,它可以依據JSON的描述來自己生成。然而對於事件的處理是它的硬傷,所以JSON描述View的方案,一般比較適用於換膚,或者固定事件不同樣式的View,比如貼紙。
架構模式
其實我們要做到動態部署,至少要滿足以下需求:
- View和事件都要能夠動態部署
- 功能完整
- 便於維護
我更加傾向於H5和Native以JSBridge的方式連線的方案進行動態部署,在cocoapods裡面也有蠻多的JSBridge了。看了一圈之後,我還是選擇寫了一個CTJSBridge,來滿足動態部署和後續維護的需求。關於這個JSBridge的使用中的任何問題和需求,都可以在評論區向我提出來。接下來的內容,會主要討論以下這些問題:
- 為什麼不是React-Native或其它方案?
- 採用什麼樣的架構模式才是使用JSBridge的最佳實踐?
為什麼不是React-Native或其他方案?
首先針對React-Native來做解釋,前面已經分析到,React-Native有一個比較大的侷限在於View需要本地提供。假設有一個頁面的元件是跑馬燈,如果本地沒有對應的View,使用React-Native就顯得很麻煩。然而同樣的情況下,HTML5能夠很好地實現這樣的需求。這裡存在一個這樣的取捨在效能和動態部署View及事件之間,選擇哪一個?
我更加傾向於能夠動態部署View和事件
,至少後者是能夠完成需求的,效能再好,難以完成需求其實沒什麼意義。然而對於HTML5的Hybrid和純HTML5的web app之間,也存在一個相同的取捨,但是還要額外考慮一個新的問題,純HTML5能夠使用到的裝置提供的功能相對有限,JSBridge能夠將部分裝置的功能以Native API的方式交付給頁面,因此在考慮這個問題之後,選擇HTML5的Hybrid方案就顯得理所應當了。
在諸多Hybrid方案中,除了JSBridge之外,其它的方案都顯得相對過於沉重,對於動態部署來說,其實需要補充的軟肋就是提供本地裝置的功能,其它的反而顯得較為累贅。
基於JSBridge的微服務架構模式
我開發了一個,基於JSBridge的微服務架構差不多是這樣的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
------------------------- | | | HTML5 | | | | View + Event Response | | | ------------------------- | | | JSBridge | | | ------------------------------------------------------------------------------ | | | Native | | | | ------------ ------------ ------------ ------------ ------------ | | | | | | | | | | | | | | | Service1 | | Service2 | | Service3 | | Service4 | | ... | | | | | | | | | | | | | | | ------------ ------------ ------------ ------------ ------------ | | | | | ------------------------------------------------------------------------------ |
解釋一下這種架構背後的思想:
因為H5和Native之間能夠通過JSBridge進行互動,然而JSBridge的一個特徵是,只能H5主動發起呼叫。所以理所應當地,被呼叫者為呼叫者提供服務。
另外一個想要處理的問題是,希望能夠通過微服務架構,來把H5和Native各自的問題域區分開。所謂區分問題域
就是讓H5要解決的問題和Native要解決的問題之間,交集最小。
因此,我們設計時希望H5的問題域能夠更加偏重業務,然後Native為H5的業務提供基礎功能支援,例如API的跨域呼叫,感測器裝置資訊以及本地已經沉澱的業務模組都可以作為Native提供的服務交給H5去使用。H5的快速部署特性特別適合做重業務的事情,Native對iPhone的功能呼叫能力和控制能力特別適合將其封裝成服務交給H5呼叫。
所以這對Native提供的服務有兩點要求:
- Native提供的服務不應當是強業務相關的,最好是跟業務無關,這樣才能方便H5進行業務的組裝
- 如果Native一定要提供強業務相關的服務,那最好是一個完整業務,這樣H5就能比較方便地呼叫業務模組。
只要Native提供的服務符合上述兩個條件,HTML5在實現業務的時候,束縛就會非常少,也非常容易管理。
然後這種方案也會有一定的侷限性,就是如果Native沒有提供這樣的服務,那還是必須得靠發版來解決。等於就是Native向HTML5提供API,這其實跟服務端向Native提供API的道理一樣。
但基於Native提供的服務的通用性這點來看,新增服務的需求不會特別頻繁,每一個App都有屬於自己的業務領域,在同一個業務領域下,其實需要Native提供的服務是有限的。然後結合JSPatch提供的動態patch的能力,這樣的架構能夠滿足絕大部分動態部署的需求。
然後隨著App的不斷迭代,某些HTML5的實現其實是可以逐步沉澱為Native實現的,這在一定程度上,降低了App早期的試錯成本。
基於動態庫的微核心模式
我開發了CTDynamicLibKit這個庫來解決動態庫的呼叫問題,其實原先的打算是拿動態庫做動態部署的,不過我用@念紀 的個人App把這個功能塞進去之後,發現蘋果還是能稽核通過的,但是下載下來的動態庫是無法載入的。報錯如下:
1 2 3 4 5 |
error:Error Domain=NSCocoaErrorDomain Code=3587 "The bundle “DynamicLibDemo” couldn’t be loaded because it is damaged or missing necessary resources." (dlopen_preflight(/var/mobile/Containers/Data/Application/61D3BF00-CF8D-4157-A87C-D999905E9040/Library/DynamicLibDemo1.framework/DynamicLibDemo): no suitable image found. Did find: /var/mobile/Containers/Data/Application/61D3BF00-CF8D-4157-A87C-D999905E9040/Library/DynamicLibDemo1.framework/DynamicLibDemo: code signature invalid for '/var/mobile/Containers/Data/Application/61D3BF00-CF8D-4157-A87C-D999905E9040/Library/DynamicLibDemo1.framework/DynamicLibDemo' ) UserInfo=0x174260b80 {NSLocalizedFailureReason=The bundle is damaged or missing necessary resources., NSLocalizedRecoverySuggestion=Try reinstalling the bundle., NSFilePath=/var/mobile/Containers/Data/Application/61D3BF00-CF8D-4157-A87C-D999905E9040/Library/DynamicLibDemo1.framework/DynamicLibDemo, NSDebugDescription=dlopen_preflight(/var/mobile/Containers/Data/Application/61D3BF00-CF8D-4157-A87C-D999905E9040/Library/DynamicLibDemo1.framework/DynamicLibDemo): no suitable image found. Did find: /var/mobile/Containers/Data/Application/61D3BF00-CF8D-4157-A87C-D999905E9040/Library/DynamicLibDemo1.framework/DynamicLibDemo: code signature invalid for '/var/mobile/Containers/Data/Application/61D3BF00-CF8D-4157-A87C-D999905E9040/Library/DynamicLibDemo1.framework/DynamicLibDemo' , NSBundlePath=/var/mobile/Containers/Data/Application/61D3BF00-CF8D-4157-A87C-D999905E9040/Library/DynamicLibDemo1.framework, NSLocalizedDescription=The bundle “DynamicLibDemo” couldn’t be loaded because it is damaged or missing necessary resources.} |
主要原因是因為簽名無法通過。因為Distribution的App只能載入相同證照打包的framework。在in house和develop模式下,可以使用相同證照既打包App又打包framework,所以測試的時候沒有問題。但是在正式的distribution下,這種做法是行不通的。
所以就目前看來,基於動態庫的動態部署方案是沒辦法做到的。
總結
我在文中針對業界常見的動態部署方案做了一些總結,並且提供了我自己認為的最佳解決方案以及對應的JSBridge實現。文中提到的方案我已經儘可能地做到了全面,如果還有什麼我遺漏沒寫的,大家可以在評論區指出,我把它補上去。
有任何問題建議直接在評論區提問,這樣後來的人如果有相同的問題,就能直接找到答案了。提問之前也可以先看看評論區有沒有人問過類似問題了。
所有評論和問題我都會在第一時間回覆,QQ上我是不回答問題的哈。