组件化架构剖析
Posted ZH952016281
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了组件化架构剖析相关的知识,希望对你有一定的参考价值。
组件化架构的由来
随着移动互联网的不断发展,很多程序代码量和业务越来越多,现有架构已经不适合公司业务的发展速度了,很多都面临着重构的问题。
在公司项目开发中,如果项目比较小,普通的单工程+MVC架构
就可以满足大多数需求了。但是像淘宝、蘑菇街、微信这样的大型项目,原有的单工程架构就不足以满足架构需求了。
就拿淘宝来说,淘宝在13年开启的“All in 无线”
战略中,就将阿里系大多数业务都加入到手机淘宝中,使客户端出现了业务的爆发。在这种情况下,单工程架构则已经远远不能满足现有业务需求了。所以在这种情况下,淘宝在13年开启了插件化架构的重构,后来在14年迎来了手机淘宝有史以来最大规模的重构,将其彻底重构为组件化架构。
蘑菇街的组件化架构
原因
在一个项目越来越大,开发人员越来越多的情况下,项目会遇到很多问题。
-
业务模块间划分不清晰,模块之间耦合度很大,非常难维护。
-
所有模块代码都编写在一个项目中,测试某个模块或功能,需要编译运行整个项目。
耦合严重的工程
为了解决上面的问题,可以考虑加一个中间层来协调模块间的调用,所有的模块间的调用都会经过中间层中转。(注意看两张图的箭头方向)
添加中间层
但是发现增加这个中间层后,耦合还是存在的。中间层对被调用模块存在耦合,其他模块也需要耦合中间层才能发起调用。这样还是存在之前的相互耦合的问题,而且本质上比之前更麻烦了。
大体结构
所以应该做的是,只让其他模块对中间层产生耦合关系,中间层不对其他模块发生耦合。
对于这个问题,可以采用组件化的架构,将每个模块作为一个组件。并且建立一个主项目,这个主项目负责集成所有组件。这样带来的好处是很多的:
-
业务划分更佳清晰,新人接手更佳容易,可以按组件分配开发任务。
-
项目可维护性更强,提高开发效率。
-
更好排查问题,某个组件出现问题,直接对组件进行处理。
-
开发测试过程中,可以只编译自己那部分代码,不需要编译整个项目代码。
组件化结构
进行组件化开发后,可以把每个组件当做一个独立的app,每个组件甚至可以采取不同的架构,例如分别使用MVVM
、MVC
、MVCS
等架构。
MGJRouter方案
蘑菇街通过MGJRouter
实现中间层,通过MGJRouter
进行组件间的消息转发,从名字上来说更像是路由器。实现方式大致是,在提供服务的组件中提前注册block
,然后在调用方组件中通过URL
调用block
,下面是调用方式。
架构设计
MGJRouter组件化架构
MGJRouter
是一个单例对象,在其内部维护着一个“URL -> block”
格式的注册表,通过这个注册表来保存服务方注册的block
,以及使调用方可以通过URL
映射出block
,并通过MGJRouter
对服务方发起调用。
在服务方组件中都对外提供一个接口类,在接口类内部实现block
的注册工作,以及block
对外提供服务的代码实现。每一个block
都对应着一个URL
,调用方可以通过URL
对block
发起调用。
在程序开始运行时,需要将所有服务方的接口类实例化,以完成这个注册工作,使MGJRouter
中所有服务方的block
可以正常提供服务。在这个服务注册完成后,就可以被调用方调起并提供服务。
蘑菇街项目使用git
作为版本控制工具,将每个组件都当做一个独立工程,并建立主项目来集成所有组件。集成方式是在主项目中通过CocoaPods
来集成,将所有组件当做二方库集成到项目中。详细的集成技术点在下面“标准组件化架构设计”
章节中会讲到。
MGJRouter调用
代码模拟对详情页的注册、调用,在调用过程中传递id
参数。下面是注册的示例代码:
[MGJRouter registerURLPattern:@"mgj://detail?id=id" toHandler:^(NSDictionary *routerParameters) // 下面可以在拿到参数后,为其他组件提供对应的服务 NSString uid = routerParameters[@"id"]; ];
通过openURL:
方法传入的URL
参数,对详情页已经注册的block
方法发起调用。调用方式类似于GET
请求,URL
地址后面拼接参数。
[MGJRouter openURL:@"mgj://detail?id=404"];
也可以通过字典方式传参,MGJRouter
提供了带有字典参数的方法,这样就可以传递非字符串之外的其他类型参数。
[MGJRouter openURL:@"mgj://detail?" withParam:@@"id" : @"404"];
组件间传值
有的时候组件间调用过程中,需要服务方在完成调用后返回相应的参数。蘑菇街提供了另外的方法,专门来完成这个操作。
[MGJRouter registerURLPattern:@"mgj://cart/ordercount" toObjectHandler:^id(NSDictionary *routerParamters) return @42; ];
通过下面的方式发起调用,并获取服务方返回的返回值,要做的就是传递正确的URL
和参数即可。
NSNumber *orderCount = [MGJRouter objectForURL:@"mgj://cart/ordercount"];
短链管理
这时候会发现一个问题,在蘑菇街组件化架构中,存在了很多硬编码的URL和参数。在代码实现过程中URL
编写出错会导致调用失败,而且参数是一个字典类型,调用方不知道服务方需要哪些参数,这些都是个问题。
对于这些数据的管理,蘑菇街开发了一个web
页面,这个web
页面统一来管理所有的URL
和参数,android
和ios
都使用这一套URL
,可以保持统一性。
基础组件
在项目中存在很多公共部分的东西,例如封装的网络请求、缓存、数据处理等功能,以及项目中所用到的资源文件。
蘑菇街将这些部分也当做组件,划分为基础组件,位于业务组件下层。所有业务组件都使用同一个基础组件,也可以保证公共部分的统一性。
Protocol方案
整体架构
Protocol方案的中间件
为了解决MGJRouter
方案中URL
硬编码,以及字典参数类型不明确等问题,蘑菇街在原有组件化方案的基础上推出了Protocol
方案。Protocol
方案由两部分组成,进行组件间通信的ModuleManager
类以及MGJComponentProtocol
协议类。
通过中间件ModuleManager
进行消息的调用转发,在ModuleManager
内部维护一张映射表,映射表由之前的"URL -> block"
变成"Protocol -> Class"
。
在中间件中创建MGJComponentProtocol
文件,服务方组件将可以用来调用的方法都定义在Protocol
中,将所有服务方的Protocol
都分别定义到MGJComponentProtocol
文件中,如果协议比较多也可以分开几个文件定义。这样所有调用方依然是只依赖中间件,不需要依赖除中间件之外的其他组件。
Protocol
方案中每个组件也需要一个“接口类”,此类负责实现当前组件对应的协议方法,也就是对外提供服务的实现。在程序开始运行时将自身的Class
注册到ModuleManager
中,并将Protocol
反射出字符串当做key
。这个注册过程和MGJRouter
是类似的,都需要提前注册服务。
示例代码
创建MGJUserImpl
类当做User
模块的服务类,并在MGJComponentProtocol.h
中定义MGJUserProtocol
协议,由MGJUserImpl
类实现协议中定义的方法,完成对外提供服务的过程。下面是协议定义:
@protocol MGJUserProtocol - (NSString *)getUserName;@end
Class
遵守协议并实现定义的方法,外界通过Protocol
获取的Class
实例化为对象,调用服务方实现的协议方法。
ModuleManager
的协议注册方法,注册时将Protocol
反射为字符串当做存储的key
,将实现协议的Class
当做值存储。通过Protocol
取Class
的时候,就是通过Protocol
从ModuleManager
中将Class
映射出来。
[ModuleManager registerClass:MGJUserImpl forProtocol:@protocol(MGJUserProtocol)];
调用时通过Protocol
从ModuleManager
中映射出注册的Class
,将获取到的Class
实例化,并调用Class
实现的协议方法完成服务调用。
Class cls = [[ModuleManager sharedInstance] classForProtocol:@protocol(MGJUserProtocol)];id userComponent = [[cls alloc] init];NSString *userName = [userComponent getUserName];
整体调用流程
蘑菇街是OpenURL
和Protocol
混用的方式,两种实现的调用方式不同,但大体调用逻辑和实现思路类似,所以下面的调用流程二者差不多。在OpenURL
不能满足需求或调用不方便时,就可以通过Protocol
的方式调用。
-
在进入程序后,先使用
MGJRouter
对服务方组件进行注册。每个URL
对应一个block
的实现,block
中的代码就是服务方对外提供的服务,调用方可以通过URL
调用这个服务。 -
调用方通过
MGJRouter
调用openURL:
方法,并将被调用代码对应的URL
传入,MGJRouter
会根据URL
查找对应的block
实现,从而调用服务方组件的代码进行通信。 -
调用和注册
block
时,block
有一个字典用来传递参数。这样的优势就是参数类型和数量理论上是不受限制的,但是需要很多硬编码的key
名在项目中。
内存管理
蘑菇街组件化方案有两种,Protocol
和MGJRouter
的方式,但都需要进行register
操作。Protocol
注册的是Class
,MGJRouter
注册的是Block
,注册表是一个NSMutableDictionary
类型的字典,而字典的拥有者又是一个单例对象,这样会造成内存的常驻。
下面是对两种实现方式内存消耗的分析:
-
首先说一下
block
实现方式可能导致的内存问题,block
如果使用不当,很容易造成循环引用的问题。
经过暴力测试,证明并不会导致内存问题。被保存在字典中是一个block
对象,而block
对象本身并不会占用多少内存。在调用block
后会对block
体中的方法进行执行,执行完成后block
体中的对象释放。
而block
自身的实现只是一个结构体,也就相当于字典中存放的是很多结构体,所以内存的占用并不是很大。 -
对于协议这种实现方式,和
block
内存常驻方式差不多。只是将存储的block
对象换成Class
对象,如果不是已经实例化的对象,内存占用还是比较小的。
casatwy组件化方案
整体架构
casatwy组件化方案分为两种调用方式,远程调用和本地调用,对于两个不同的调用方式分别对应两个接口。
-
远程调用通过
AppDelegate
代理方法传递到当前应用后,调用远程接口并在内部做一些处理,处理完成后会在远程接口内部调用本地接口,以实现本地调用为远程调用服务。 -
本地调用由
performTarget:action:params:
方法负责,但调用方一般不直接调用performTarget:
方法。CTMediator
会对外提供明确参数和方法名的方法,在方法内部调用performTarget:
方法和参数的转换。
casatwy提出的组件化架构
架构设计思路
casatwy是通过CTMediator
类实现组件化的,在此类中对外提供明确参数类型的接口,接口内部通过performTarget
方法调用服务方组件的Target
、Action
。由于CTMediator
类的调用是通过runtime
主动发现服务的,所以服务方对此类是完全解耦的。
但如果CTMediator
类对外提供的方法都放在此类中,将会对CTMediator
造成极大的负担和代码量。解决方法就是对每个服务方组件创建一个CTMediator
的Category
,并将对服务方的performTarget
调用放在对应的Category
中,这些Category
都属于CTMediator
中间件,从而实现了感官上的接口分离。
casatwy组件化实现细节
对于服务方的组件来说,每个组件都提供一个或多个Target
类,在Target
类中声明Action
方法。Target
类是当前组件对外提供的一个“服务类”,Target
将当前组件中所有的服务都定义在里面,CTMediator
通过runtime
主动发现服务。
在Target
中的所有Action
方法,都只有一个字典参数,所以可以传递的参数很灵活,这也是casatwy提出的去Model
化的概念。在Action
的方法实现中,对传进来的字典参数进行解析,再调用组件内部的类和方法。
架构分析
casatwy为我们提供了一个Demo,通过这个Demo
可以很好的理解casatwy的设计思路,下面按照我的理解讲解一下这个Demo
。
文件目录
打开Demo
后可以看到文件目录非常清楚,在上图中用蓝框框出来的就是中间件部分,红框框出来的就是业务组件部分。我对每个文件夹做了一个简单的注释,包含了其在架构中的职责。
在CTMediator
中定义远程调用和本地调用的两个方法,其他业务相关的调用由Category
完成。
// 远程App调用入口- (id)performActionWithUrl:(NSURL *)url completion:(void(^)(NSDictionary *info))completion;// 本地组件调用入口- (id)performTarget:(NSString *)targetName action:(NSString *)actionName params:(NSDictionary *)params;
在CTMediator
中定义的ModuleA
的Category
,对外提供了一个获取控制器并跳转的功能,下面是代码实现。由于casatwy的方案中使用performTarget
的方式进行调用,所以涉及到很多硬编码字符串的问题,casatwy采取定义常量字符串来解决这个问题,这样管理也更方便。
#import "CTMediator+CTMediatorModuleAActions.h"NSString * const kCTMediatorTargetA = @"A";NSString * const kCTMediatorActionNativFetchDetailViewController = @"nativeFetchDetailViewController";@implementation CTMediator (CTMediatorModuleAActions)- (UIViewController *)CTMediator_viewControllerForDetail UIViewController *viewController = [self performTarget:kCTMediatorTargetA action:kCTMediatorActionNativFetchDetailViewController params:@@"key":@"value"]; if ([viewController isKindOfClass:[UIViewController class]]) // view controller 交付出去之后,可以由外界选择是push还是present return viewController; else // 这里处理异常场景,具体如何处理取决于产品 return [[UIViewController alloc] init];
下面是ModuleA
组件中提供的服务,被定义在Target_A
类中,这些服务可以被CTMediator
通过runtime
的方式调用,这个过程就叫做发现服务。
我们发现,在这个方法中其实做了参数处理和内部调用的功能,这样就可以保证组件内部的业务不受外部影响,对内部业务没有侵入性。
- (UIViewController *)Action_nativeFetchDetailViewController:(NSDictionary *)params // 对传过来的字典参数进行解析,并调用ModuleA内部的代码 DemoModuleADetailViewController *viewController = [[DemoModuleADetailViewController alloc] init]; viewController.valueLabel.text = params[@"key"]; return viewController;
命名规范
在大型项目中代码量比较大,需要避免命名冲突的问题。对于这个问题casatwy采取的是加前缀的方式,从casatwy的Demo
中也可以看出,其组件ModuleA
的Target
命名为Target_A
,被调用的Action
命名为Action_nativeFetchDetailViewController:
。
casatwy将类和方法的命名,都统一按照其功能做区分当做前缀,这样很好的将组件相关和组件内部代码进行了划分。
标准组件化架构设计
这个章节叫做“标准组件化架构设计”,对于项目架构来说并没有绝对意义的标准之说。这里说到的“标准组件化架构设计”只是因为采取这样的方式的人比较多,且这种方式相比而言较合理。
在上面文章中提到了casatwy方案的CTMediator
,蘑菇街方案的MGJRouter
和ModuleManager
,下面统称为中间件。
整体架构
组件化架构中,首先有一个主工程,主工程负责集成所有组件。每个组件都是一个单独的工程,创建不同的git
私有仓库来管理,每个组件都有对应的开发人员负责开发。开发人员只需要关注与其相关组件的代码,其他业务代码和其无关,来新人也好上手。
组件的划分需要注意组件粒度,粒度根据业务可大可小。组件划分后属于业务组件,对于一些多个组件共同的东西,例如网络、数据库之类的,应该划分到单独的组件或基础组件中。对于图片或配置表这样的资源文件,应该再单独划分一个资源组件,这样避免资源的重复性。
服务方组件对外提供服务,由中间件调用或发现服务,服务对当前组件无侵入性,只负责对传递过来的数据进行解析和组件内调用的功能。需要被其他组件调用的组件都是服务方,服务方也可以调用其他组件的服务。
通过这样的组件划分,组件的开发进度不会受其他业务的影响,可以多个组件单独的并行开发。组件间的通信都交给中间件来进行,需要通信的类只需要接触中间件,而中间件不需要耦合其他组件,这就实现了组件间的解耦。中间件负责处理所有组件之间的调度,在所有组件之间起到控制核心的作用。
这套框架清晰的划分了不同组件,从整体架构上来约束开发人员进行组件化开发,避免某个开发人员偷懒直接引用头文件,产生组件间的耦合,破坏整体架构。假设以后某个业务发生大的改变,需要对相关代码进行重构,可以在单个组件进行重构。组件化架构降低了重构的风险,保证了代码的健壮性。
组件集成
组件化架构图
每个组件都是一个单独的工程,在组件开发完成后上传到git
仓库。主工程通过Cocoapods
集成各个组件,集成和更新组件时只需要pod update
即可。这样就是把每个组件当做第三方来管理,管理起来非常方便。
Cocoapods
可以控制每个组件的版本,例如在主项目中回滚某个组件到特定版本,就可以通过修改podfile
文件实现。选择Cocoapods
主要因为其本身功能很强大,可以很方便的集成整个项目,也有利于代码的复用。通过这种集成方式,可以很好的避免在传统项目中代码冲突的问题。
集成方式
对于组件化架构的集成方式,我在看完bang的博客后专门请教了一下bang。根据在微博上和bang的聊天以及其他博客中的学习,在主项目中集成组件主要分为两种方式——源码和framework
,但都是通过CocoaPods
来集成。
无论是用CocoaPods
管理源码,还是直接管理framework
,效果都是一样的,都是可以直接进行pod update
之类的操作的。
这两种组件集成方案,实践中也是各有利弊。直接在主工程中集成代码文件,可以在主工程中进行调试。集成framework
的方式,可以加快编译速度,而且对每个组件的代码有很好的保密性。如果公司对代码安全比较看重,可以考虑framework
的形式,但framework
不利于主工程中的调试。
例如手机QQ或者支付宝这样的大型程序,一般都会采取framework
的形式。而且一般这样的大公司,都会有自己的组件库,这个组件库往往可以代表一个大的功能或业务组件,直接添加项目中就可以使用。关于组件化库在后面讲淘宝组件化架构的时候会提到。
不推荐的集成方式
之前有些项目是直接用workspace
的方式集成的,或者直接在原有项目中建立子项目,直接做文件引用。但这两点都是不建议做的,因为没有真正意义上实现业务组件的剥离,只是像之前的项目一样从文件
以上是关于组件化架构剖析的主要内容,如果未能解决你的问题,请参考以下文章
深入YARN系列3:剖析NodeManager架构,组件与生产应用
深入YARN系列2:剖析ResourceManager的架构与组件使用
深入YARN系列2:剖析ResourceManager的架构与组件使用