114

基于接口的消息通讯解耦

 6 years ago
source link: https://mp.weixin.qq.com/s/vZlcGF5T3kO5aroBgtHVLw
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

基于接口的消息通讯解耦

彭飞 58无线技术 2017-12-22 09:53 Posted on

代码耦合与解耦一直是一个永恒的话题。耦合无论好坏,只有当耦合在特定的业务场景中限制了业务扩展、代码复用及维护,才需要采取一定的手段降低耦合度。面向对象的程序开发领域,尤其是Java领域,有很成熟的解耦理念和框架,比如IOC以及Spring Service。但在OC领域,未有一个权威的框架或工具来处理代码耦合。

本文将尝试从当前OC领域的代码解耦实践及58APP实际业务场景,来建立一套基于接口的消息通讯解耦框架,希望能抛砖引玉,能对同行有所启示和帮助。

一、  问题背景

在提出具体需要解决的问题之前,先来简单看看58APP当前的框架现状,如图1所示:

Image

图1  58APP框架图

58APP从上到下总共分为四层:业务层、容器层、公共服务层以及公用库层。上层对下层产生单向依赖,且可以跨层依赖。比如,业务层既依赖公共服务层,又依赖公用库层。由于公用库层在公用服务层的下一层,那么业务层对公用库层的依赖属于跳跃依赖。

业务层与公用服务层或公用库层的交互大多通过容器层中的总线来进行的,但由于代码的历史原因,还存在很多绕开总线,直接调用的情况。业务层与公用服务层或者公用库层的直接调用,有合理的,也有不合理的。不合理的直接调用构成了代码的高度耦合,业务扩展与代码维护均需要较高的成本。

另外,58APP现有的开发团队是基于业务并行开发来展开的,即无线技术部负责公共服务层和公共库层的开发和维护,各业务线(房产/招聘/黄页/二手车)APP开发团队并行研发,专注于业务功能的研发。所以业务层与公用服务层。或公用库层的高度耦合在一定程度上也给业务并行研发带来了诸多不便及效率问题。

现有的58APP框架中的消息总线已经很好地集成了跳转路由功能,但是在基于接口的消息通讯方面一直是个缺口。本文是基于这样的背景来提出和解决问题的。

二、  消息通讯的几种方式

消息通讯的本质就是方法的调用。在同构系统中的方法调用比较容易理解,调用方利用获取到的类的实例调用对应的方法即可。但放到异构系统上,方法的调用就变得复杂了,比如用JS代码调用OC代码中的某一个方法,服务器端的Java代码调用客户端的OC代码,不能直接获取异构系统代码上的类来操作方法,需要借助一定的中间手段。

本文主要聚焦于同构系统中的消息通讯,主要有以下三种实现手段:

  • 方法直接调用

  • 接口隔离,通过protocol调用

  • 通过URL路由实现

先说URL路由。在同一个APP内部模块之间代码的相互调用,为了达到解耦的效果,我们也会通过协议(预定义的一套规则)进行通讯,这种基于数据协议的通讯也就是URL路由的实现。URL路由的设计很好地解决了模块中之间方法调用的耦合,但是有两个不足:

  • 不能实现编译期检查。基于数据协议的消息通讯必须基于Runtime的API来实现,协议配的对不对,是否符合对应的协议规范,不能在编译期得到及时反馈和提示。这加大了开发成本和运行时出错的概率。

  • 使用过重,只适合粗粒度的模块调用。每将一个新的入口及对应入口类的方法纳入到路由,必须编写相应的代码进行实现。这种方法只适合大的模块间的入口方法间的调用,而且入口类的方法基本差异不大。比如常见的APP内的UI模块的跳转,主要是调用UINavigationController的pushViewController:animated 方法。

所以针对模块间的消息通讯,除了上述的基于数据协议的通讯,还有一种基于接口的消息通讯。这里的接口在iOS系统中就是protocol,Android中是interface,只是在不同开发语言中称呼及具体语法实现不同。

基于接口的消息通讯支持在编译期检查接口的语法,而且粗细粒度可以灵活把握。相对而言,是一种轻量的消息通讯解耦方式,这也是本文要研究的内容。

三、  耦合及解耦手段

(一)  高耦合与低耦合

耦合一词源于英文中的Coupling,英文解释为:a connection (like a clamp or vise) between two things so they movetogether。这段英文解释翻译为中文为:耦合是两个事物之间的连接,这种连接使得事物一起运动(相互影响)。

在软件开发领域,耦合是无处不在的,正是耦合的存在才使得软件各功能可以相互影响,一起实现具体的业务。耦合是客观存在的,是无法消除的。但耦合的程度有高低之分,可以通过具体的手段来调整耦合度的高低。低耦合可以给软件开发带来可读性、复用性、可维护性和易变更性。

《浮现式设计》一书中将代码耦合分为四种类型:标示耦合、表示耦合、子类耦合、继承耦合。本文中涉及的耦合为标示耦合与表示耦合,即对实体及方法的依赖。比如在业务类A中需要调用微信登录类B中的登录方法,具体代码如图2所示:

Image

图2   业务代码调用样例

上述业务类SampleBusiness中存在两个级别的耦合:对微信登录类WeChatLogin及登录方法doLogin的耦合。WeChatLogin类的删除及doLogin方法的变动(方法名修改,参数变动等)会影响SampleBusiness类的编译。在上述情况的耦合下,对以下假设不能得到满足:

  • 如果需要下掉微信登录功能,则需要SampleBusiness中修改代码,并且需要重新提交测试;

  • 如果微信登录API有变动,需要修改SampleBusiness代码,并且需要重新提交测试;

  • 如果要把微信登录换成QQ登录,则需要SampleBusiness中修改代码,并且需要重新提交测试;

上述的编译影响及不能满足的假设,在特定场景下成为一种高耦合,对业务的扩展及代码的维护带来了一定程度的影响。需要说明的是这里举的是一个业务简化的例子,如果业务复杂度高,代码量大,上述假设带来的代码变动就会明显。

那么如何降低上述的耦合度呢?在寻找具体的解决方法前得明确一下最终问题解决所要达成的目标。最终目标应包含以下几个方面:

  • 在SampleBusiness头文件中不需要引入具体的登录实现类,不论是微信登录还是QQ登录。这涉及到类耦合的处理。

  • 在SampleBusiness业务实现中,不要直接调用登录实现类的API。这涉及到方法依赖的处理。

明确了最终目标后,就有寻找具体解决方法的标准了。

(二)  降低耦合度的两种手段

要达成上述目标,通常有两种手段:引入runtime和protocol,将上述依赖转移到runtime依赖和protocol依赖上来。下面对这两种手段进行一一介绍:

runtime依赖

先来看基于runtime是如何解决上述SampleBusiness中的耦合的,如图3所示。

Image

图3  基于runtime解决耦合

可以看出上述代码的解决方案中,没有引入具体的登录实现类(WeChatLogin),也没有直接调用登录实现类中的API,满足前文定的问题解决的目标。

Runtime是OC语言特有的,用他来解决代码耦合,耦合度非常低。但此种方式在代码可读性和可维护性上较差,而且调试成本较高。

protocol依赖

先来看看protocol依赖的代码实现,如图4所示:

Image

图4   基于protocol代码实现

首先定义了一个登录协议,在WeChatLogin中实现了这个协议。然后在业务实现的过程中执行protocol的调用,而不是直接业务实现类的方法调用。此处的protocol依赖解决了总目标中的方法依赖,但是对类依赖的解决仍旧是通过runtime处理的。如果有一个Manager类来集中管理利用runtime生成对应类的实例,那么业务方代码中将可以不含有任何runtime操作的逻辑,只需要关注如何利用protocol执行API的调用。这个Manager类的功能也是后文将要详细叙述的。

相比runtime依赖,protocol依赖可以支持编译检查。借助编译检查,可以在编码阶段消除很多错误,接口方法的任何变动,业务调用方都能直接感知。这对业务代码的调试有很大的帮助,而不需要在运行时逐步调试来发现接口变动带来的错误。除此之外,代码可读性也比较好,方法的调用一目了然。

Protocol依赖一个重要的问题是如何解决类依赖,在业务代码中不需要写任何runtime相关的代码。这看似一个简单的问题,实际上涉及很多问题,这些问题的解决也是本文要叙述的主要内容。

四、  基于接口的消息通讯框架的设计与实现

前文中提到了要解决类依赖,需要有一个统一的Manager类进行管理,使得业务使用方不需要关注如何runtime处理逻辑,通过Manager提供的API,很容易得到对于的protocol实现类的instance。这个Manager类我用了一个高大上的名字:基于接口的消息通讯框架。之所以用上框架这个词,是为了与Java端Spring Service框架相比较。本文的消息通讯框架的设计与实现充分借鉴了Spring Service的实现以及iOS端的两个开源库:BeeHive与Typhoon。

(一)  建立protocol实现类的映射

要获取protocol实现类的实例,必须得建立一个唯一标示符与protocol实现类的映射关系。对外暴露的API设计如图5所示:

Image

图5  注册protocol实现类的API

这里的key值采用的是字符串类型,而非BeeHive框架中的protocol。因为如果使用protocol作为key值,只满足protocol与实现类一对一的情况,而对一对多的情况将无法满足。比如定义了一个登录协议,微信/QQ/微博都对此协议进行了实现,此时无法利用protocol作为key,建立与其实现类之间的映射关系。此处的key在使用时比较灵活,使用方可以根据自定义的规则设置key,也可以传nil,使用框架默认的Class Name作为key。

针对实现类的注册,本框架封装了一个宏,如图6所示:

Image

图6  组件注册方法宏

有了此宏,只需在protocol实现类中进行铺设,使用非常简单。既达到了封装的效果,又使得注册代码可灵活扩展。这种手法在React Native框架中比较常见,有兴趣的同学可以比较一下。且在Java框架中,类似于Spring中的基于注解的配置。

在注册方式上还有一种方式是基于配置文件的注册。就是根据模块或者系统创建若干配置文件,配置文件中配置key与Class的映射信息。然后在框架加载的时候,会主动去加载这些配置文件,从而将映射关系读取到内存中以供使用。

基于注解的注册更加轻量,不用额外维护配置文件,尤其是多人协作开发时经常出现配置文件编写冲突。但是当实现类不是自己编写的类,基于注解的配置无法实现,比如lib库中只提供了头文件的protocol实现类。但这种情况可以通过对lib库中的类进行一层封装来解决。所以,综合考虑,本框架最终使用的是基于注解的配置。

(二)  根据key获取protocol实现类的实例

建立了protocol实现类的映射,相应的也需设计如何根据key获取protocol实现类的实例。一般情况下的API设计如图7所示:

Image

图7  根据key获取protocol实现类

这里的一般情况是指可以通过默认初始化方法来构造instance,而且instance的生命周期由调用方进行管理,框架不持有instance。

在这里还有一种情况是如果instance是一个单例属性,则框架根据下面的协议进行判断:如果目标Class中实现了wbIOCSharedInstance协议(如图8所示),则先调用此协议生成instance并返回;如果没有实现,则调用默认的初始化方法来构造instance并返回。

Image

图8  单例的实现协议

(三)  控制protocol实现类实例的生命周期

可能有同学会提出,如果只提供上述的API接口,并且框架不持有创建的instance,那么如果在一个容器controller中要多次使用创建的instance,只能有两种选择:

  • 调用一次上述API,并将返回的instance作为容器controller内的全局属性,这样在容器内需要再次使用时,直接调用此全局属性;

  • 多次调用上述API以满足容器内多处业务使用。

第一种处理方式需要在容器内额外增加一个属性,第二种处理方式多次调用API重复创建instance带来额外的开销。那么有没有一种更加简洁高效的方式供调用方使用?

针对此情况,本框架在输入参数上增加了一个lifeClass参数,用以控制创建的instance的生命周期,API如图9所示:

Image

图9  基于key和lifeClass获取实例

通过此方法生成的instance,生命周期和lifeClass绑定起来了。一般情况下,这里的lifeClass是一个容器类controller。那么在整个controller生命周期内,多次调用API生成instance都为同一个对象。

Instance与lifeClass的绑定逻辑思路是:

  1. 先创建一个NSObject类别objectBinding;

  2. 在objectBinding中,利用objc_setAssociatedObject关联instance与lifeClass。objc_AssociationPolicy设置为 OBJC_ASSOCIATION_RETAIN_NONATOMIC。这样绑定后,lifeClass会持有instance,直至lifeClass销毁时才会触发instance的dealloc;

在实际操作中,还要考虑一个lifeClass与多个instance的绑定情况。另外,需要注意的是instance不能持有lifeClass,不然就造成循环引用了。

(四)  支持自定义的初始化方法

在前面的叙述中,instance的创建都是默认的初始化方法的。如果要支持自定义的初始化方法,则需要做额外处理。本框架中提供的支持初始化方法创建实例instance的API如图10所示:

Image

图10  根据指定初始化方法获取实例

调用方只需要把自定义初始化方法的selector及参数传入,框架就能根据传入的参数来生成实例instance。这里参数采用了可变参数,方便了业务调用。Instance的生成过程,是通过调用NSInvocation的invocationWithMethodSignature来进行处理的。这里之所以选择操作较负责的NSInvocation,而不直接用NSObject的performSelector,是为了支持更多的参数(performSelector最多只支持两个参数)。

针对自定义初始化方法,如果要控制创建的instance的生命周期,则调用如图11所示的API:

Image

图11  根据lifeClass和指定初始化方法获取实例

(五)  线程安全的处理

在整个框架中有两个字典来存储数据:

  • serviceMappingDict:用来存储(一)中所属的key与protocol实现类之间的映射关系。

  • instanceDict:用来存储生命周期需要框架来控制的instance,也就是上文叙述的绑定了lifeClass的instance。

这两个字典在进行增删查改的时候,需要考虑线程安全的问题。通用的解决的方案是利用Synchronized和NSLock对公共资源进行加锁。但这种加锁方式性能较低,特别是在并发读的情况下,事实上是不需要对公共资源加锁。针对这种情况下,调研了一些开源框架,比如AFNetworking,采用的就是性能较高的并行队列方案。以serviceMappingDict为例,本框架的处理方式如下:

先定义一个并发队列dispatch_queue_t,名称为serviceMappingConQueue。

数据存储的时候的处理,如图12所示:

Image

图12  数据存储的时候加锁

这里巧妙的利用了dispatch_barrier的特性。在解释具体代码之前先明确两个概念:

  • 当前线程:指执行上述数据存储逻辑的线程;

  • 并发队列的线程:serviceMappingConQueue中的线程,具体线程数依赖队列的设置,任务数较少时一个任务可分配到一个线程。

再来看上述逻辑的处理:

  • dispatch_barrier_async中的async指当前线程可以与

serviceMappingConQueue中的任务异步并行,互不影响;

  • barrier则限制了serviceMappingConQueue中的任务的执行,必须等当前任务执行完了才可执行队列里面的其他任务。

这样达到的效果是:在数据添加时,不会阻塞当前线程,是一个异步过程。而且barrier会阻塞队列中其他任务(添加/删除/读取)的执行,相当于对公共资源serviceMappingDict的加锁。

数据读取的时候的处理,如图13所示:

Image

图13  数据获取时候的代码处理

可以看出,数据读取的时候并没有使用barrier加锁,对于同是读取的任务可以并发执行。

数据删除的时候的处理,如图14所示:

Image

图14  数据删除时加锁

数据删除与数据添加的唯一差别在于这里使用了sync,是为了避免当前线程拿即将要删除的数据执行其他操作,所以这里将当前线程也阻塞了。

五、  业务使用示例

这里列举一个58APP在IM模块使用上述消息通讯解耦的例子,来更好理解前文所叙内容。

如问题背景一章中所述,IM模块在整个58APP架构中属于服务层,最上层的是各业务线组成的业务层。按照架构设计,上层只对下层有依赖,而且业务层与公共服务层的交互要松散耦合,原则上只能通过总线中提供的相关机制来进行消息通讯,所以IM模块对上层业务提供的API一定要本着低耦合的原则,以满足后期各业务线不断增加的业务需求及业务线代码的复用。

在本例中,具体需求是业务层需要使用到IM层的相关数据,比如联系人列表。于是,具体的处理措施如下:

1.先定义交互接口,如图15所示:

Image

图15  交互接口代码

2.IM模块中的protocol实现类,并注册,如图16所示:

Image

图16  protocol实现类代码实现

3.业务方的调用,如图17所示:

Image

图17  业务方调用代码实现

从上面的实例可以看出,在业务使用的过程中,始终不用关心是哪个类实现的protocol。这样在后期接口实现类的代码调整过程中,能最大限度地降低对业务方的影响。

另外,也需要指出的是,一定根据具体的业务场景来评估是否需要解耦,避免过度使用。

六、  总结与讨论

本框架与同类型的框架相比,具有以下特性:

  • 更加轻量,剔除诸如Typhoon框架中对通过类方法的解耦,仅支持面向接口的消息通讯;

  • 支持基于注解的注册,使用更加轻便和灵活;

  • 支持接口及实现类的一对多关系,更加全面的支持各种设计模式;

  • 支持带参数的初始化方法及解耦;

  • 支持多种状态的生命周期控制(方法体内/容器内/APP应用内)。

同时也需要重申和强调的是:基于接口的消息通讯解耦是有特定的应用场景的,一定要根据具体的业务场景进行评估,提出尽可能会出现的假设,看看是否需要使用。不恰当的使用效果往往会适得其反。

 七、  参考资料

  1. 《耦合的本质》,https://my.oschina.net/zjzhai/blog/496006

  2. iOS代码耦合的处理, http://mrpeak.cn/blog/ios-coupling/

  3. 路由跳转的思考,http://www.jianshu.com/p/62c771a035c5

  4. BeeHive, https://github.com/alibaba/BeeHive

  5. Typhoon, https://github.com/appsquickly/Typhoon

Image

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK