美文网首页iOSiOS 开发iOS记录篇
高速公路换轮胎——为遗留系统替换数据库

高速公路换轮胎——为遗留系统替换数据库

作者: 凉粉小刀 | 来源:发表于2016-04-16 19:01 被阅读10191次

    在过去的几个月内,我主导着团队完成了一项工程浩大(累积八个人月的工作量)的重构工作——为我们的App替换数据库。之所以能够把这种伤筋动骨的事情称之为重构,是因为在这段时间内,我们每天向主干合并两到三次代码,期间App上线五次,用户没有感知到任何影响。在这篇文章中,我将讲述我们如何在不影响系统外部行为,也不影响正常交付的情况下,替换掉了数据库实现。

    一、背景

    没有人喜欢遗留系统,”遗留“这个词本身就意味着难以理解、难以维护的代码,同时也意味着每一次改动,每一次增加新特性都步履维艰。然而在我们的职业生涯中,又总是难免与遗留代码相逢,因为如果没有清晰的设计意图贯穿软件的整个生命周期,没有持续演进架构,没有持之以恒的良好重构素养,今天的优秀设计就会成为明天的遗留代码。

    REA的iOS app就是这样的遗留系统。在多年以前,人们做了个决策,用CoreData做本地存储,替换掉NSUserDefaults。这之间的历史已经远不可考,但自从我加入项目以来,整个团队已经被它高昂的学习曲线、复杂的数据Migration流程以及过时陈旧的设计折磨的苦不堪言。于是我们决心把CoreData换掉。但直到我开始认真记录系统中有哪些类在调用CoreData API的时候,我才看清了原来CoreData只是这个复杂庞大的系统中种种问题的冰山一角而已。

    二、系统面貌

    在一个有着良好分层结构的系统中,每一层都有它自己的职责:显示层负责响应用户事件,调用业务层的逻辑,最后做数据呈现;业务逻辑层负责业务规则与数据处理;数据访问层封装底层数据库的操作,网络访问层与其并列,负责网络请求、json解析等等。无论是MVC、MVVM、VIPER,归根结底都是在”单一职责“、“关注点分离”、“高内聚低耦合”的原则下变化,只是表现形式和涵盖的层次各异。

    而在我们的代码中,几乎所有的显示层对象,包括ViewController、ViewModel,甚至View里面都混杂了大量的CoreData API调用,直接进行数据库操作。大概有以下两种方式

    方式一

    初始化NSFetchedResultsController,然后发起请求

    方式二

    把自身当做CoreData的delegate,对数据库变化后作出响应

    粗略统计了一下,系统中一共有25个类与NSManageContext紧紧耦合。形成了下图中混乱的局面:

    整理出来这幅图以后,看着眼前密密麻麻的API调用,看着众多臃肿庞大的ViewController,我的大脑几乎失去了思考的能力,不知道如何下手。

    三、方案选型

    冷静过后,我最先排除掉的是重写这种简单粗暴的方式。表面上看来,我们可以通过重写得到一个干净利落的方案,层次结构清晰,职责分离;但与之相伴的是巨大的风险:

    范围不可控——遗留系统的难点就在于牵一发而动全身,影响范围极广。稍不留神,重写的工作就会如野火燎原般蔓延开来,不可收拾。

    长时间无法上线——在整个过程中,直到最后完成的那一刻之前,系统会处于一直不可用的状态。漫长的时间里,所有的新功能都被阻塞,不能交付。没有哪个产品团队能承担这样的结果。

    第二个被排除掉的方案是特性分支。把重写的工作放到分支上完成,其他人继续在主干上开发新特性,直到重写结束再合并回主干——这种做法确实比直接重写要好上那么一点点,因为新特性还是可以不受影响的;但长期没有跟主干合并的分支,在经历上四五个月的重写之后,天知道到最后要花多长时间来处理合并冲突?

    既想减小对系统的影响,又想不影响新功能上线,又不想处理大量的合并冲突,最后的方案就只剩下了一种,那就是抽象分支(Branch by Abstraction)+特性开关(Feature Toggle)。

    抽象分支

    抽象分支这个名字的缘起是针对版本库分支而言的,它允许开发者在一条“抽象”的分支上并行工作,无需创建一条实际的分支,从而避免无谓的合并开销。Martin FowlerJez Humble都曾在多年前撰文介绍过这个重构方案。

    它的工作原理很简单:当我们想要替换掉系统中的某个组件——名为X——时,首先为X组件创造一个抽象层,这一层里面可能会有大大小小若干接口或是协议,把系统中对X组件的访问都隔离在抽象层之下,系统只调用抽象的接口/协议,不会接触到具体的API实现。如下图所示。

    这一步我们可以通过提取方法、提取类和接口等重构手法来完成;这以后系统就彻底跟X组件解耦了,它依赖的只是一组抽象接口,而非具体实现。这时候,我们就可以着手在这个抽象层下面,进行新组件的开发工作,让它也实现同一套接口即可。

    这之后,我们再使用特性开关(其原理及实现见下节),让这个抽象层在生产环境下调用旧组件,测试环境下调用新组件,从而在完全不影响交付的情况下,完成对新组件的测试。测试结束后,就可以打开开关,让系统在线上使用新组件,等彻底稳定后,把开关代码和旧组件代码全部删掉,替换工作就完成了。

    在上述整个开发过程中,任何一个阶段都可以做到细粒度的任务分解,然后小步提交,每次提交都自动触发单元测试和集成测试,保证不影响现有功能。在频繁提交的情况下,也不会出现大量的代码合并冲突,无论是做组件替换还是新特性开发,开发人员都可以基于同一套代码库工作。这就大大减少了对系统的冲击和交付风险。

    下面介绍特性开关的原理与实现。

    特性开关

    先看一段代码:

    在这个例子中,我们要替换一个Storyboard的布局和相关ViewController的功能,耗时很久,如果直接在主干上修改,就会直接影响到现有的App,在功能完成之前都无法上线;如果拉一条分支出来做,未来就又会有大量的合并冲突。使用如上的特性开关就会避免上述问题。

    shouldDisplayNewSearchResultsScreen的值返回为真,就使用新的Storyboard,返回为假,就使用旧的Storyboard。这样一来,只要开关处于关闭状态,未完成的功能就是对用户不可见的,我们就既可以在开发环境下自测,也可以部署到测试环境下做验收测试,还可以针对开关为真的情况写对应的单元测试,让每次代码提交都有持续集成验证。这期间还可以继续发布新版本,用户完全感知不到影响,直到我们决定打开开关为止。

    特性开关可以有多种实现方式。

    1. 预编译参数

    在预编译参数中传值,让不同的xcconfig文件传入不同的值,然后在代码中做判断。例如我们可以定义internal和production两个Target,为内部发布和外部发布分别生成不同的ipa文件,然后在internal的xcconfig文件中定义

    GCC_PREPROCESSOR_DEFINITIONS = INTERNAL_TARGET=1
    

    而后就可以在Toggle代码中这样写

    #ifdef INTERNAL_TARGET
    #define isInternalTarget YES
    #else
    #define isInternalTarget NO
    #endif
    
    //本特性只在Internal Target中可见
    + (BOOL)shouldDisplayNewSearchResultsScreen
    {
      return isInternalTarget;
    }
    

    我们系统中绝大部分的特性开关都是用这种方式实现的。

    2. NSUserDefaults

    有些功能可能对App有破坏性的影响,即便是设成只对Internal Target可见,也会影响到QA的回归测试。我们给Internal Target做了个Developer Settings界面,让开发人员可以自己修改开关状态,把开关的值存放在NSUserDefaults里面,默认返回false,只有在界面上手工切换之后才会返回true。测试和开发互相不受影响。

    向Realm迁移的特性开关使用的就是这种方式。

    3. 服务器取值

    配置参数的值也可以通过服务器下发。这种做法的好处是比较灵活,在启用/禁用某项功能的时候不需要发布新版本,只需要后台配置,缺点是会增加集成和后台开发的工作量。

    4. A/B测试

    还有一个办法是使用第三方的A/B测试服务,如果缺少后台开发人员的话,这也是一个选择。但第三方的稳定性往往就会成为制约因素,Parse为推送通知提供过A/B测试服务,但是它到了17年就会被关闭了;我们用Amazon的A/B测试框架用了一段时间,然后Amazon也宣布今年8月份停用……目前我们还在寻找备选方案。

    四、�技术实现

    在具体落实抽象分支和特性开关的时候,一共分成了如下几个阶段:

    1. 建立数据访问层

    前文说过,系统中ViewController使用NSManageContext的方式一共有两种。

    第一种是直接初始化NSFetchedResultsController,发起请求,这种方式比较好处理,我们首先把跟数据请求有关的操作从ViewController中提取成一个方法,放到另一个对象中实现,以便日后替换。然后把所有的数据访问的方法都提取成一个协议,让数据层之上的对象都依赖于这个协议,而不是具体对象。如下所示

    @protocol REAPersistenceService <NSObject>
    - (NSArray *)getTodayUpcomingEvents;
    //其余方法略过
    @end
    
    @interface REACoreDataPersistenceService: NSObject<REAPersistenceService>
    + (instancetype)sharedInstance;
    @end
    
    @implemention REACoreDataPersistenceService
    
    - (NSArray *)getTodayUpcomingEvents {
      //封装了NSFetchedResultsController的初始化和performFetch操作
    }
    @end
    

    我们同时还需要使用特性开关,来决定给上层返回哪一个PersistenceService对象:

    @implemention REAPersistenceServiceFactory
    
    + (id<REAPersistenceService>)service {
      if([REAToggle shouldUseRealm]) {
        return [REARealmPersistenceService sharedInstance];
      } else {
        return [REACoreDataPersistenceService sharedInstance];
      }
    }
    

    改造过后的ViewController就简单多了

    - (instancetype)init {
      self = [super init];
      if (self) {
        _persistenceService = [REAPersistenceServiceFactory service];
      }
      return self;
    }
    
    - (NSArray *)getTodayUpcomingEvents {
      return [self.persistenceService getTodayUpcomingEvents];
    }
    

    第二种方式是ViewController把自己注册为NSFetchedResultsController的delegate,实现了相应接口,当数据发生变化时刷新UI。这个处理起来就比较棘手,因为我们希望提取之后的接口能够适配于Realm,这样才能无缝切换。然而Realm一方面目前没有像CoreData那样的细粒度通知,另一方面用的也不是delegate,而是提供了addNotificationBlock:方法,让调用者可以注册block。二者的接口并不兼容。

    这种情况下,我们的新协议就只能取二者交集:

    @protocol REAPersistenceDataDelegate<NSObject>
    - (void)contentDidChange:(id)content;
    @end
    

    这个协议跟CoreData和Realm的接口都不一致,两个PersistenceService都在内部做了适配和转发。比如在Realm的实现中,我们让它对外使用REAPersistenceDataDelegate协议来注册delegate,对内依然使用addNotificationBlock:方法监听,收到消息以后再调用delegate的contentDidChange方法。

    由于Realm没有细粒度通知,本来还想用

    - (void)objectDidChange:(id)object;
    

    这种方法来封装CoreData的

    - (void)controller:(NSFetchedResultsController *)controller
            didChangeObject:(id)anObject
            atIndexPath:(NSIndexPath *)indexPath
            forChangeType:(NSFetchedResultsChangeType)type
            newIndexPath:(NSIndexPath *)newIndexPath
    

    现在也只好作罢,让delegate收到数据后自己计算应当刷新哪部分的数据。

    2. 为数据对象提取协议

    除了数据访问的代码以外,我们还把所有的数据对象上的公有属性和方法都提取了相应的协议,然后修改了整个App,让它使用协议,而不是具体的数据对象。这也是为以后的切换做准备。

    3. 使用Realm实现

    前两步完成之后,我们就建立起了一个完整的抽象层。在这层之上,App里已经没有了对CoreData和数据对象的依赖,我们可以在这层抽象之下,提供一套全新的实现,用来替换CoreData。

    在实现过程中,我们还是遇到了不少需要磨合的细节,比如Realm中的一对多关联是通过RLMArray实现的,并不是真正的NSArray,为了保证接口的兼容性,我们就只能把property定义为RLMArray,再提供一个NSArray的getter方法。种种问题不一而足。

    4. 切换开关状态

    上篇文章说到,我们在迁移过程中的特性开关是用NSUserDefaults实现的,在界面上手工切换开关状态。这样的好处是开发过程不会影响在Hockey和TestFlight上内部发布。直到实现完成后,我们再把开关改成

    + (BOOL)shouldUseRealm
    {
      return isInternalTarget;
    }
    

    让测试人员可以在真机上测试。回归测试结束之后,再让开关直接返回true,就可以向App Store提交了。

    5. 数据迁移

    这个无需多说,写个MigrationManager之类的类,用来把数据从CoreData中读出,写到Realm里面去。这个类大概要保留上三四个版本,等绝大部分用户都已经升级到新版本之后才会删掉。

    6. 后续清理

    特性开关是不能一直存活下去的,否则代码中的分支判断会越来越多。我们一般都会在上线一两个星期之后,发现没有出现特别严重的crash,就把跟开关有关的代码全都删掉。

    在第一步建立数据访问层的时候,我们创建出了一个特别庞大的PersistenceService,它里面含有所有的数据访问方法。这只是为了方便切换而已,切换完成后,我们还是要根据访问数据的不同,建立一个个小的Repository,然后让ViewModel对象访问Repository读写数据,把PersistenceService删掉。

    最后形成的架构如图所示

    五、总结

    四个多月的时间里,看着自己的构思落地生根到开花结实,看着代码结构从混乱变成有序,心里的满足感无可言喻。回头望去崎岖征途,其间的争执、焦虑、兴奋、坚定,尽皆化成了一行行代码融入系统的底层结构,化成了沉甸甸的收获。

    首先,要勇敢

    面对混乱的代码库,人们最容易做出的选择就是复制黏贴。看看前人怎么做,就跟着照猫画虎来几笔。以前的代码是这么写的,我照样拷一份过来,改一改就能实现新需求。这种做法我们不能说它错,然而它既不能让这个系统变得更好一点,更干净一点,也不能让我们的技术得到提升。它能以最快的速度完成眼下的需求,结果是为团队留下更多的技术债。

    欠下的债终究是要还的,团队里一定要有人站出来跟大家说,我们不能让代码继续腐烂下去,我们要有清晰的目标和正确的策略,在重构中让优秀的设计渐渐涌现。这才是正途。

    要有正确的方法

    Martin Fowler在博客中总结过重构的几种流程,在遗留代码中工作,Long-Term Refactoring是不可或缺的。

    人们需要预见到在未来的产品规划中,哪些组件应当被替换,哪部分架构需要作出调整,把它们放到迭代计划里面来,当做日常工作的一部分。抽象分支和特性开关在Long-Term Refactoring可以发挥显著的效果,它们是持续交付的保障。

    技术债同样需要适当管理,按照严重程度和所需时间综合排序,一点点把债务偿还。或许有人觉得这是浪费时间,但跟一路披荆斩棘,穿越溪流,攀过险峰,历尽艰难险阻相比,我宁愿朝着另一个方向走上一段,因为那边有高速公路。

    遗留代码的出现,也意味着在过往的岁月中团队忽略了对代码质量的关注。为了不让代码继续腐化,童子军规则必须要养成习惯。

    设计会过时,但设计原则不会

    很多技术决策都不是非黑即白的,它们更像是在种种约束下做出的权衡。比如在本文的例子中,当CoreData被Realm所替换以后,抽象层还要不要保留?ViewModel应该直接调用Repository,还是RepositoryProtocol?有人会觉得这一层抽象就好比只有单一实现的接口一样,没有存在的价值,有人会觉得几年后Realm也会过时被新的数据库取代,如果保留这层抽象,就会让那时候的迁移工作变得简单。但无论怎么做,过上一两年后,新加入团队的人都可能会觉得之前那些人做的很傻。

    我们无法预见未来,只能根据当前的情况做出简单而灵活的设计。这样的设计应当服从这些设计原则:单一职责、关注点分离、不要和陌生人说话……让我们的代码尽可能保持高内聚低耦合,保证良好的可测试性。时光会褪色,框架会过时,今天的优秀设计也会沦落成明天的遗留代码,但这些原则有着不动声色的力量。

    相关文章

      网友评论

      • FindCrt:重构思想和抽象层建立那里非常好!
      • waterwind:楼主好,想请教一下,现在Realm支持在查询语句中使用四则数学运算,以及更复杂的类似于绝对值、开方等运算吗?
        waterwind:@凉粉小刀 好的,谢谢。
        凉粉小刀:去看官方文档
      • ivanStronger:我接手的项目目前也在处理前任留下的技术债务,采取的方案和作者说的“抽象分支
        ”基本一致,深有感触。给作者的态度点个赞
      • 图特亚斯坦:请教刀叔一个问题,今天的优秀设计会成为名日的遗留代码。。出现这个状况的原因是不是因为为了开发新特性,在这一过程中会不断发现原先代码的不适应性?以及为了省工,将原先部分分支直接贴过来而造成隐性冲突?而你们的目标是尽量做出开放性佳,适应面广的代码,而这就需要对新特性有某种预见?
        凉粉小刀:有效,不是有限
        凉粉小刀:主要原因是我们无法预测未来,需求总是在改动,想做出能有限应对明天需求的设计太难太难
      • 一缕殇流化隐半边冰霜:很早就点了喜欢,今天再回来评论一个!!我也很想高速路上给我们的app数据库“换胎”
      • 一个人的阳光:不错,果断分享了
      • 8a05d758e213:很赞的思路 而且这确实需要极大的勇气 责任心 维护一套老旧系统的难度远甩用新潮技术写新潮代码几条街。。。
      • njuxjy:FYI: A/B测试可以用google tag manager来做
      • _Finder丶Tiwk:请问一下你梳理逻辑的那个图是用什么工具画的?Visio?
        凉粉小刀:用过好几个工具,有keynote,有draw.io
      • mokong:赞
      • 南栀倾寒:团队成员态度统一,比较好推进,但是大了就是件难的事,我们iOS 6个人,很好沟通,但是安卓有12个人, 几乎不能重构,
      • bigParis:用4.5个月时间去重构, 这得是多有价值的项目! 其实重构不在于重构的结果带来的收益, 更重要的是, 在重构的时候我们会去总结, 应该怎样在搭建应用框架的时候就考虑到今后可能重构的问题, 怎样分层才能适应更复杂的情况, 我想这应该是我们在重构中更应该思考的问题, 加油!
        凉粉小刀:@bigParis 没错,重构后的总结确实可以让人对设计的理解上一个台阶
      • 花前月下:现在也是正在考虑这部分内容。 想重构。
      • c268d22b6039:我公司以前的项目,目前都要重构。前期设计考虑不周全,存在大量的耦合,动一发而牵全身。参考你们的做法,看能不能解决我现在存在的问题
        凉粉小刀:@crash_wu 好啊好啊,太棒了
      • 96e99d4978ab:还是挺佩服你和你的领导的,让你能主导并完成重构工作。其实重构都是这样的原理,但是很多公司很难去推进这类工作,简单的一句能用就好,让多少后续开发者每天都痛苦挣扎。唉,不说了,现在项目中还在使用MRC等,还要兼容iOS6。
        一个人的阳光:@yardanramon 是时候和这种团队说再见的时候了,现在大厂早在15年初就全部iOS7+了,MRC更加坑爹
        凉粉小刀:@yardanramon 我正在写另外一篇博客,总结心路历程。内在并不像外表那么光鲜……
      • e9a44a336dfe:做法与iOS无关,基本所有重构都是这么些步骤。难的是,系统演化,需持续迭代改进。而这块国内不少公司或者是人,都很难愿意去做。
        凉粉小刀:@煜_ 没错没错,抽象分支和特性分支是来源于Java应用的。

      本文标题:高速公路换轮胎——为遗留系统替换数据库

      本文链接:https://www.haomeiwen.com/subject/cslwlttx.html