非常感谢Casa老师的文章,让我长久以来的很多疑惑,很多看了又忘的知识得到了系统的整理和总结。文章很容易让我建立起自己的记忆模型。就我的感觉,作为iOS程序员,把这样的文章读上几十遍都不为过,有种醍醐灌顶的痛彻。其中还有一个很重要的点,作为架构师是为工程师服务的,这种想法尤其的纯粹,放到工程师身上,如果也想着自己的代码是给别人读的,自己的产品是给用户用的,相信世界将变得更美好,我们离梦想也会更近。
根据需求决定持久化方案
持久层与业务层之间的隔离
持久层与业务层的交互方式
数据迁移方案
数据同步方案
CTPersistance(Virtual Record设计思路)&& CTJSBridge -- 有代码
在客户端:可维护 -> 可拓展 -> 性能调优
持久化方案与网络层对app架构影响类似,导致整个项目的耦合度高的罪魁祸首。
1. 根据需求决定持久化方案
(1)NSUserDefault:小规模数据,弱业务相关数据,都可以放。内容比较多的数据,强业务相关的数据就不太适合。
(2)KeyChain:苹果自带带有可逆加密的存储机制,用于各种存密码的需求。PS:当app卸载,只要系统不重装,KeyChain中的数据依旧能保留,可被iCloud同步。一般在这里存储用户的唯一标识串。(需要加密,iCloud的敏感小数据)。
(3)文件存储:Plist、Archive、Stream等
<1> Plist,一般结构化的数据 或者 需要方便查询的数据
<2> Archive,适合存储平时不太常用但很大量的数据,读取后直接对象化的数据
<3> Stream,一般用存图片,适合比较经常使用,数据量不算非常大。
(4)数据库存储:Core Data、FMDB。
便于CRUD,数据有状态和类别的时候用,尤其这些状态和类别都是强业务相关的。特别大量的不适合。
2. 持久层实现中要注意隔离:
(1) 持久层与业务层的隔离
<1> Data Model:
业务数据的建模,代码中这一数据模型的表征方式。两者相辅相承:因为业务数据的建模方案以及业务本身特点,而最终决定了数据的表征方式。同样操作一批数据,你的数据建模方案基本都是细化业务问题之后,抽象得出一个逻辑上的实体。在实现这个业务时,你可以选择不同的表征方式来表征这个逻辑上的实体,比如字节流(TCP包等),字符串流(JSON、XML等),对象流。对象流又分通用数据对象(NSDictionary等),业务数据对象(HomeCellModel等)。
Model化时,指对象流中的业务数据对象。去Model化就是指:更多地使用通用数据对象去表征数据,业务数据对象不会在设计时被优先考虑的一种设计倾向。
<2> Model Layer:
如何对数据进行增删改查(CURD),和相关业务处理。在Model Layer中采用瘦Model = CURD为止。胖Model以外,缓存、数据同步、弱业务处理等。
网络层的reformer去Model化。持久层的Virtual Record去Model化
(2) 数据库读写的隔离
在网站的架构中,主要是为了提高响应速度。在iOS应用架构中,对持久层进行读写隔离的设计主要是为了提高代码的可维护性。
读写隔离并不是指将数据的读操作和写操作做隔离。界限外:所有数据模型,都是不可写不可修改,或者修改属性的行为不影响数据库中的数据。界限内:数据是可写可修改的。在设计时划分的这个界限会和持久层与业务层之间的界限保持一致,!!!也就是业务层从持久层拿到数据之后,都不可写不可修改,或业务层针对这一数据模型的写操作、修改操作都对数据库文件中的内容不产生作用。只有持久层中的操作才能够对数据库文件中的内容产生作用。
Core Data的架构设计中,没有针对读写作出隔离,数据的结果都是以NSManagedObject扔出。只要稍微一不小心动一下某个属性,Context在save的时候就会把这个修改给存进去了。另外,当我们需要对所有的增删改查操作做AOP的切片时,Core Data技术栈的实现就会非常复杂。
为了读写隔离,将所有扔出来的NSManagedObject都转为了普通的对象。另外,由于聊天记录的业务相当复杂,使用Core Data之后为了完成需求不得不引入很多Hack的手段。
读写隔离便于加入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。
<1> Single Thread模式不是线程安全的,不提供任何同步机制。<2> Multi Thread模式要求database connection不能在多线程中共享。<3> Serialized模式由一个串行队列来执行所有的操作,响应速度会慢一些。默认是Serialized。
根据Core Data在多线程场景下的表现,我觉得Core Data在使用SQLite作为数据载体时,使用的应该就是Multi Thread模式。SQLite在Multi Thread模式下使用的是读写锁,而且是针对整个数据库加锁,不是表行锁。Tips:如果对响应速度要求很高的话,建议开一个辅助数据库,把一个大的写入任务先写入辅助数据库,然后拆成几个小的写入任务见缝插针地隔一段时间往主数据库中写入一次,写完之后再把辅助数据库删掉。
相比于Multi Thread模式,Serialized模式我认为是性价比较高的一种选择,代码容易写容易维护,性能损失不大。
(4)数据表达和数据操作的隔离 -- 直接影响整个程序的可拓展性
一个对象映射了一个数据库里的表,针对这个对象做操作=针对这个表 + 这个对象所表达的数据做操作。不好,这个Record = 数据库中数据表的映射 + 表中某一条数据的映射。这种不恰当的方式来组织数据操作和数据表达,在胖Model下会导致强弱业务难以区分。
<1> 强弱业务不能区分 --> 代码复用和迁移,持久层中的强业务对View层业务的高耦合是无法避免的,然而弱业务相对而言只对下层有耦合关系对上层并不存在耦合关系,当我们做代码迁移或者复用时,往往希望复用的是弱业务。
<2> 数据在view层业务上的表达方式多种多样。实际编码时使用数据需要多一层转换。
对象--对数据表的映射和对数据表达的映射结果非常相似,区分的关键要点是:这个映射对象的操作着手点相对数据表而言,是对内还是对外操作。对内操作,操作范围就仅限于当前数据表,这些操作映射给数据表模型就比较合适。对外操作,执行这些操作时有可能涉及其他的数据表,那么这些操作就不应该映射到数据表对象中。
以数据表为单位去针对操作进行对象封装,然后再针对数据记录进行对象封装。数据表中的操作都是针对记录的CRUD操作,都是弱业务逻辑。数据记录仅仅是数据的表达方式,这些操作最好交付给数据层分管强业务的对象去执行。
3. 持久层与业务层的交互方式
图图图 -- DB->Table->DataCenter->(Virtual Record)->Logic
DataCenter为了要完成View层交付的任务,会涉及数据组装和跨表的数据操作。数据组装因为View层要求的不同而不同,因此是强业务。跨表数据操作本质上就是各单表数据操作的组合,DataCenter负责调度这些单表数据操作从而获得想要的基础数据用于组装。那么,这时候单表的数据操作就属于弱业务,这些弱业务就由Table映射对象来完成。
DataCenter其实是一个业务对象,DataCenter是整个App中,持久层与业务层之间的胶水。<1>向业务层开放业务友好的接口 <2> 通过调度各个持久层弱业务逻辑和数据记录来完成强业务逻辑,将结果交付给业务层。由于DataCenter处在业务层和持久层之间,那么它执行业务逻辑所需要的载体,要被业务层 & 持久层理解。
CTPersistanceTable就封装了弱业务逻辑,由DataCenter调用,用于操作数据。而Virtual Record就是数据载体。
Virtual Record是一个protocol。对象只要实现了Virtual Record,直接被持久层当作Record进行操作。实现者一般是一个View对象。
-- 传统的数据操作过程:一般都是先从数据库中取出数据,然后Model化成一个对象,然后再把这个模型丢到外面,让Controller转化成View,然后再执行后面的操作。
View实现Virtual Record变为Record,然后不同的View做不同的数据表达,不需要Model。
Controller(DataCenter),DC需要针对业务做高度分化的,每个大业务都要提供一个DataCenter,然后挂在相关Controller下调度。比如分化成SettingsDataCenter,ChatRoomDataCenter,ProfileDataCenter等。DataCenter之间最好不要有业务重叠。如果实在是大,就再拆分成几个小业务。如果单个小业务都很大了,那就拆成各个Category。
迁移涉及持久层的强业务,那就只需要迁移DataCenter即可。迁移弱业务,就只需要迁移CTPersistanceTable。
经过各种逻辑组装出一个数据对象,然后把这个数据对象交付给持久层去处理。一对一的交互场景。CTPersistance的test case中。
多对一,业务层与持久层交互: -- 相对于View方向来说
<1> 一个记录的数据由多个View的数据组成。如一张用户表包含用户的所有资料。然后有的View只包含用户昵称用户头像,有的对象只包含用户ID用户Token。数据都只存在一张用户表中,多个对象的数据组成一个完整Record数据的场景。
<2> ViewA对象包含了一个Record的所有信息,ViewB对象也包含了一个Record的所有信息,多个不同对象表达了一个Record数据的场景。
交互 = 存取:
存操作:Virtual Record的实现者通过实现Merge操作来完成record数据的汇总。任意Virtual Record的实现者通过Merge操作,就可以将自己的数据交付给其它不同的对象进行表达,从而实现取操作。
存?
<CTPersistanceProtocol>提供了- (NSObject <CTPersistanceRecordProtocol> *)mergeRecord:(NSObject ~ *)record shouldOverride:(BOOL)shouldOverride; bool = NO 任何一边的nil都会被另外一边不是nil的记录覆盖,bool = YES,则即便是nil,也会把已有的值覆盖掉。
基本思路通过merge不同的record对象来达到获取完整数据,由于是Virtual Record,具体的实现都是由各自的View去决定。拼凑分散到了各个View对象中,VC减负。
传统方式,会散落很多用于凑数据的代码,不易维护。
取?
取出数据之后,如何交付给不同的对象。还是用到mergeRecord方法。
一对多,业务层与持久层交互:
<1> 一个对象包含了多个表的数据 -- 包含 -- 多个View有多张表组成
如用户信息相关的表有很多,通过纵切把Column多的一张表切成少Column�的表。“用户详情页”中要展示多张表。
取?-- 多对一取一样
存?
存操作时,Virtual Record的protocol要求实现- (NSDictionary *)dictionaryRepresentationWithColumnInfo:(NSDictionary *)columnInfo tableName:(NSString *)tableName; 根据columnInfo和tableName返回相应的数据,这样就能够把这一次存数据时关心的内容提供给持久层了。
<2> 一个对象用于展示多种表的数据 -- 展示 -- 单个View用于展示不同相近的整表
如二手房、新房、租房,数据分别存储在三个表里面。展示的时候,根据类型的不同,界面才有稍许不同而已,所以还是会用同一张View去展示这三种数据。
存?
存操作上面一样,直接存。Virtual Record的实现者会根据要存入的表的信息组装好数据提供给持久层。
取?
a,b,c都是同一个View,然后itemATable,itemBTable,itemCTable分别是不同种类的表。一个对象如何展示不同类型的数据。不需要适配逻辑,有VR实现者自己做。
多对多 -- 上述组合
总结:
交互方案 - 区分好强弱业务,把传统的Data Model区分成Table和Record,并由DataCenter去实现强业务,Table去实现弱业务。由于DataCenter是强业务相关,在实际编码中,业务工程师负责创建DataCenter,并向业务层提供业务友好的方法,然后再在DataCenter中操作Table来完成业务层交付的需求。区分强弱业务 -> Table和Record拆分开的好处:
<1> 通过业务细分降低耦合度,使得代码迁移和维护非常方便
<2> 通过拆解数据处理逻辑和数据表达形态,使得代码具有非常良好的可拓展性
<3> 做到读写隔离,避免业务层的误操作引入Bug
<4> 为Virtual Record这一设计思路的实践提供基础,进而实现更灵活,对业务更加友好的架构
业务层交互时,采用Virtual Record设计Record,由具体的业务对象(View)来实现Virtual Record,并以它作为DataCenter和业务层之间的数据媒介进行交互。
好处在于:
<1> 将数据适配和数据转化逻辑封装到具体的Record实现中,可以使得代码更加抽象简洁,代码污染更少
<2> 数据迁移时只需要迁移Virtual Record相关方法即可,非常容易拆分
<3> 业务工程师实现业务逻辑时,可以在不损失可维护性的前提下,极大提高业务实现的灵活性
数据库性能优化 -- 横切 & 纵切
4. 数据迁移方案
具有持久层的App同时都会附带着有版本迁移的需求。当一个用户安装了旧版本的App,此时更新App之后,若数据库的表结构需要更新,或者数据本身需要批量地进行更新,此时就需要有版本迁移机制来进行这些操作。机制又要兼顾跨版本的迁移需求:建立数据库版本节点,迁移的时候一个一个跑过去。
<1> 根据应用的版本记录每一版数据库的改变,并将这些改变封装成对象
<2> 记录好当前数据库的版本,便于跟迁移记录做比对
<3> 在启动数据库时执行迁移操作,如果迁移失败,提供一些降级方案
源码:
CTPersistance在数据迁移方面,凡是对于数据库原本没有的数据表,如果要新增,在使用table的时候就会自动创建。
CTPersistance也提供了Migrator。业务工程师可以自己针对某一个数据库编写一个Migrator。这个Migrator务必派生自CTPersistanceMigrator,且符合CTPersistanceMigratorProtocol,只要提供一个migrationStep的字典,以及记录版本顺序的数组。然后把你自己派生的Migrator的类名和对应关心的数据库名写在CTPersistanceConfiguration.plist里面就可以。CTPersistance会在初始数据库的时候,根据plist里面的配置对应找到Migrator,并执行数据库版本迁移的逻辑。
性能问题。后台线程版本迁移。SQLite在执行每一个SQL都走的一个Transaction。SQL数量特别多,在版本迁移里面自己建立一个Transaction,SQLite执行会快一点。
5. 数据同步方案
(1)单向数据同步 -- 告诉服务器是什么数据要做什么事情
只把本地较新数据的操作同步到服务器,不会从服务器主动拉取同步操作。
如果一个操作迟迟没有收到服务器的确认,应用认为这个操作失败,在界面上展示失败,让用户去选择重试的操作,然后再重新发起请求。不需要定时更新。
如何做?
<1> 添加identifier
主要是为了解决客户端数据的主键和服务端数据的主键不一致的问题。客户端生成identifier发送,服务器返回,客户端更新本地状态。一般用UUID字符串。
<2> 添加isDirty
针对数据的插入和修改进行标识。当本地新生成数据或者更新数据之后,收到服务器的确认返回之前,=YES。当服务器的确认包返回之后,identifier找到,=NO。
极端情况:当请求发起到收到请求回复之间,又修改了数据。收到请求回复之后,=NO。新的修改就无法同步到服务器。简单处理方案是在期间,界面上不允许用户修改。
发送同步请求期间依旧允许用户修改的话,就需要在数据库额外增加一张DirtyList来记录这些操作,这个表里至少要有两个字段:identifier,primaryKey。
<3> 添加isDeleted
数据同步的时候,删除操作不是物理删除了,是逻辑删除,只在数据库里isDeleted=YES,当服务器的确认包返回之后,才真正删除。
<4> 在请求的数据包中,添加dependencyIdentifier
:有两次数据同步请求一起发出,A先发,B后发。结果B先到A后到。如果A请求包含了插入某个对象,B请求删除了这个对象。
在请求的数据包中,我们要带上上一次请求时一系列identifier的其中一个,一般是选择上次请求里面最后一个的identifier。
服务端记录最近的100个请求包里面的最后一个identifier。服务端在收到同步请求包的时候,先看denpendencyIdentifier是否已被记录,<1> 已经被记录了,那么就执行这个包里面的操作。<2> 没有被记录,那就先放着再等等,等到满足。
总结:
<1> 改造的时候添加identifier,isDirty,isDeleted字段。如果在请求期间依旧允许对数据做操作,那么就要把identifier和primaryKey再放到一个新的表中
<2> 每次生成数据之后对应生成一个identifier,然后只要是针对数据的操作,就修改一次isDirty或isDeleted,然后发起请求带上identifier和操作指令去告知服务器执行相关的操作。如果是复杂的同步方式,那么每一次修改数据时就新生成一次identifier,然后再发起请求带上相关数据告知服务器。
<3> 服务器根据请求包的identifier等数据执行操作,操作完毕回复给客户端确认
<4> 收到服务器的确认包之后,根据服务器给到的identifier(有的时候也会有tablename,取决于你的具体实现)找到对应的记录,如果是删除操作,直接把数据删除就好。如果是插入和更新操作,就把isDirty置为NO。如果有额外的表记录了更新操作,直接把identifier对应的这个操作记录删掉就行。
<5> 同一条数据进行多次更新操作,同步前,合并相同数据的更新操作。
(2)双向数据同步 -- 针对本地DB的操作要求变高,要更多标识
笔记类、日程类应用。对于一台设备来说,不光会往上推数据同步的信息,也会问服务器主动索取数据同步的信息。
如何做?
封装操作对象
互相约定一个协议,从而操作对象的封装:
<1> 操作唯一标识符
单向同步一样,收到服务器的确认包之后,本地应用找到对应的操作并执行确认处理。
<2> 数据唯一标识符
具体操作执行确认逻辑时,涉及到对象本身,更新删除,本地DB有所体现。用于找到对应数据。
<3> 操作类型
Delete,Update,Insert不同操作类型,对本地数据库执行的操作也会不一样。
<4> 具体数据(Insert && update)
Update OR Insert,需要具体数据。是单条的数据内容,也会是批量的数据。因此这里具体的数据如何表达,也需要定一个协议,什么时候作为单条数据的内容,什么时候作为批量,根据实际业务定义。
<5> 操作依赖标识
为了防止先发的包后到后发的包先到。
<6> 执行操作的时间戳
跨设备,旧数据也会被更新,可能出现冲突。操作数据在从服务器同步下来,会存放在一个待操作数据表,在具体执行这些操作的同时会跟待同步的数据表中的操作数据做比对。同一条数据操作 两操作冲突,以时间戳来决定。或者直接提交到界面告知用户,用户决定。
<7> 待操作数据表 && 待同步数据表
在拉取待操作列表的时候,也要把最后一次操作的identifier丢给服务器,这样服务器才能返回相应数据。
待同步数据表的作用与单向方案作用类似,防止在发送请求的时候用户有操作,同时也是为解决冲突提供方便。在发起同步请求之前,我们都应该先去查询有没有待执行的列表,当待执行操作列表同步完成之后,就可以删除里面的记录了,然后再把本地待同步的数据交给服务器。一般,只有在待操作和待执行的操作间会存在冲突。
何时拉取待执行列表?
<1> 每次要把本地数据丢到服务器去同步之前,都要拉取一次待执行列表,执行完毕之后再上传本地同步数据
<2> 每次进入相关页面的时候都更新一次,看有没有新的操作
<3> 对实时性要求比较高的,要么客户端本地起一个线程做轮询,要么服务器通过长链接将待执行操作推送过来
总结:
<1> 设计好同步协议,用于和服务端进行交互,以及指导本地去执行同步下来的操作
<2> 添加待执行,待同步数据表记录