1 论状态
1.1 论Web开发的无状态性
客户端开发与Web端开发最大的不同就在于是否能保留系统运行状态,Web系统走的是HTTP请求,HTTP请求本身就是无状态的,因此即使是同一用户的相临两次请求,对于Web站点而言也是两个完全独立、毫不相干的操作请求。也因为这个原因,Web系统天然承载不了上下文操作关联性很强的需求(很明显的例子就是各类大型游戏,即便是网络游戏)。
这种无状态性,对于web系统的开发与设计影响甚大。因为无状态性,web系统的逻辑设计从纵向来看就变得很简单,简单说来就是接受请求,从上下游系统或者持久化层获取数据进行业务运算,然后将运算结果作为响应返回浏览器客户端。举个最常见的例子:用户在浏览器中输入一个查询关键字进行搜索,站点根据关键字从数据库中查询符合条件的结果,然后将结果进行业务整合,再将整合后结果返回给浏览器显示。
从系统设计角度来看,这种纵向逻辑划分得实在有点太细了,因为每个用户的相邻或相近操作必然是有业务上下文关联的,而这种上下文关联的紧密程度才能真正促成用户业务价值的实现,但是Web系统却利用不上(或者要用上也得费劲九牛二虎之力,例如session缓存、对象序列化存储);而对于同一段系统处理逻辑,不同用户进行操作,彼此并没有太多横向的关联性,反倒会有很大的相似性,这应该也就是基于角色的系统设计思想的产生初源。
因此,这么多年来,web技术虽然不断发展,其总方向还是不变的,就是朝“有状态化”发展,力图弥补http协议本身的无状态方面的不足。从最开始的html静态页面,到后来cookie、DHTML,再到支持seesion、ViewState的asp.net/jsp,再到基于插件的flash、Silverlight等RIA富媒体技术,再到现在如火如荼的html5离线存储,莫不如是。
虽然Web技术在不断发展,但是在系统上下文状态维系方面的缺陷缺是先天性的,因为Web系统的B/S架构设计本身就决定了,客户端只是一个呈现/浏览者角色,复杂运算逻辑与操作逻辑都会放到服务端,这样减轻了客户端的运算压力,却也同时损失了同一用户的操作上下文的状态维护性。
因为这么多年来,我们见到的绝大多数Web系统都只是信息呈现类的,而不是复杂操作类的。真正大型复杂业务系统,依然还是由客户端软件承载,因为客户端是基于单用户的、且天然是有状态的。(当然,我这里的大型复杂,并不是指数据量级别,而是强调系统运行逻辑复杂程度)。
1.2 论客户端开发的有状态性
对于web开发而言,用户状态数据与程序业务运行逻辑(business logic)是分离的,执行逻辑存在于服务端,而状态数据存留于浏览器客户端,又因为浏览器的瘦客户端性以及http本身的无状态性,所以其无法保存完整的上下文数据。
而对于C/S架构的客户端,客户端本身就是“胖”的,程序的业务运行逻辑与用户数据一起留存在客户端系统中,因此将程序执行过程中产生的中间态数据缓存在客户端就成为了可能。而且,为了保证状态切换的速度,这些中间状态数据必须是无间断一直存在于程序内存中的,而不是将其存储到数据库中,毕竟数据库是用来存储最终结果性数据的,而且也承受不了太频繁的数据改写。这些缓存与内存中的中间态数据,即是上文一直提到的状态上下文数据。
而对于当前红红火火的移动APP开发而言,因为手机内存本身就很有限、CPU性能很弱,而且内存工作频率也并不算高(众所周知,在目前计算机系统中,性能瓶颈一直都是内存工作频率,更何况是手机内存)。所以对于这些状态上下文数据的设计就显得尤为重要而且审慎。若果设计不佳,这些上下文对象就会吞噬掉很大一部分内存。
Java之父说过一句很牛B的话——万物皆对象,在面向对象设计思想普及的今天,我们依然可以用这句至理箴言来指导我们的软件设计与开发,横向来说,程序中的对象分为两类——数据类对象与操作类对象(其实这两者并没有很严格的界面,因为Class本身就是包括属性与方法的,只是不同作用的Class侧重不同,因此笔者就各取其重、区分出来);纵向来看,受益于分层架构设计思想,对象可以简单分为界面呈现对象、业务逻辑对象、数据持久化对象。
而我们的状态上下文数据介绍到这,也就是存在于业务逻辑对象中的数据类对象中。也就是Java世界中的VO,.net世界中的Entity。
但是,虽然都是VO,APP世界中的VO又不同于J2EE中的VO,因为APP的世界是客户端的世界,是有状态的;而J2EE开发的世界是Web的世界,是无状态(或者弱状态)的,所以J2EE中的VO更像是纯的数据对象。而APP世界中的VO,即是数据对象同时也得是操作类对象。
2 基于状态的架构设计
2.1 利用VO层实现数据归一化
行文似乎又有点头重脚轻了,现在才讲到正题。
单纯从数据缓存角度讲,APP的VO与J2EE的VO并无二致,均是基于某一领域的业务对象模型。但是web开发用的VO更加具体直接:一般都是以某一界面对象为中心、直接映射为界面数据对象。以界面表格控件为例,一个VO就是一个界面表格控件数据的映射,不会携带太多多余字段数据,即使两个Grid其实就是描述的同一对象的不同属性,也会设计成两个VO(而不是结合两个Grid的字段的一个大VO),因为Web是无状态的,AGrid的VO数据无法简单直接地传递给另一个界面中的BGrid,需要先保存到数据库,BGrid再从数据库中读取,而且多出来的字段对于BGrid也是无价值的,没必要从数据库中读取出来。
但是在APP的VO设计中,这样的设计是极其错误的,会极大地浪费内存空间。在APP的VO设计中,VO是真正以业务对象为中心进行设计的,数据源尽量唯一化,如果不同界面呈现的是同一个业务对象的不同属性,就应该将这些属性全部放到此业务对象对应的VO中,不同界面读取不同属性集,如果有必要,不同属性集数据甚至可以在不同的时间段分开加载,以提高内存使用效率。
在这里不得不提的一点是,对于图片、文件等个头比较大的数据,千万不要一开始就载入内存中,而且如果APP中对图片的使用非常多、重复度高的话,笔者倾向于将图片业务字段数据的加载逻辑写复杂一点,不是简单直接从文件中读取,而是引入cache机制,从ImageCache中读取。当然,这样的话,就需要在程序中专门写一个ImageCacheManger类来进行图片加载以及cache进出的管理。
2.2 从界面呈现中剥离业务逻辑
当然,将业务逻辑层的VO设计摆在如此重要的位置,除了实现数据源唯一化的目标外,还有一个作用,就是要尽量将那些耗时或者异步的处理从界面呈现逻辑中剥离,下移到业务逻辑层来做,例如常见的rest网络请求。
在笔者最近这个IM项目中就有这样的案例:项目中有一个好友列表界面需要显示每个好友的头像,刚开始时,开发人员图简单,使用了一个第三方图片下载类库SDImageDownloadView,将人员头像的获取逻辑放在了ViewController中处理(IOS开发),这样做的好处显而易见,SDImageDownloadView是一个很成熟通用的图片处理库,图片下载后会保存到本地,后续读取时还会自动载入内存以供重复读取。而且SDImageDownloadView功能逻辑很完善,不仅图片下载逻辑不用自己写,连ImageView的呈现逻辑都写了,开发人员可以将其当成一个简单的ImageView来实现图片加载。
但是这样做得坏处随着开发的深入也开始逐渐显现:SDImageDownloadView的网络请求处理并没有放在一个全局单例队列中,每实例化的ImageView对象,都只是在当前ImageView对象中实例化一个http请求去获取数据并产生回调。如果当前网络环境较差,网速减慢,图片下载时间很长的话,有可能用户还没等图片下载下来就已经离开此界面了,离开此界面就意味着当前ImageView对象会被释放,http请求就会被cancel,这样图片就没有下载下来,而如果大多数场景下,用户在此界面停留时间都很短的话,这些图片就永远不可能下载下来。
这个好友列表界面就是这样,每个好友的cell都需要显示人员头像,但其实用户在此界面停留时间并不长,而且当时测试网络环境也不稳定,导致给用户的感觉就是这些图片一直都下载不下来,定位成是程序的bug。
后来笔者将这一块逻辑进行重新设计,并不直接使用SDImageDownloadView对象来处理图片加载,而是只使用SDImageDownloader对象处理数据下载,并且将这段数据下载逻辑从ViewController中搬到了好友VO对象中进行处理,因为这个VO是存放在一个全局单例对象中的,即使用户退出了当前界面,请求也不会被中断,依然会继续下载,下载完后再通知需要使用的界面进行更新。
简单总结一下,其实也不是SDImageDownloadView设计得不好,这种封装比较完整的类库,肯定不能在里面设计一个全局请求处理队列来下载图片,毕竟业务场面太多,你也不知道这个界面是否一定需要将图片下载下来永久存到本地重复使用。这种全局请求设计只能是根据具体业务场景来分析处理。例如我们项目中另一个人员黄页搜索界面,数据重复度不高,就没有改成从VO中下载,因为如果设计成全局加载队列的话,后续还要考虑在系统中增加定时或者定量清理图片的机制,防止APP中图片占用手机太多空间。
2.3 隔离界面状态与数据存储
第三,将VO作为APP架构设计核心来阐述,更是为了实现界面操作与数据库存储逻辑的完全隔离;众所周知,APP中虽然也可以使用数据库技术,例如IOS的SQLite,但是,相比Web系统的大型数据库而言,客户端这些数据库只能算是轻量级产品,并不能很好的支持大量数据处理(即使是查询操作),再加上运行APP的手机本来性能就只有这么强,如果不加入VO层,数据库很容易就变成了整个APP性能瓶颈所在,而因为APP对于系统异常并不做容错恢复处理,展现在用户面前的便是挂机、假死。这一点,笔者在这个项目中可谓深受其害。
所以其实第三点才是笔者推崇在APP架构设计中加入VO层的核心观点所在。
当然,事情永远都是说来容易做来难。特别是,要使用VO来隔离界面层与数据库层,状态数据的增量同步处理就不可避免,也异常关键。
为了使描述更加清晰形象,下文笔者将以自己近期做过的一个IM项目为例,进行设计说明,暂且叫M项目吧。
首先,要用VO缓存用户上下文数据,就需要考虑状态数据的全局缓存,我们的做法是直接将VO对象的管理方法设计到一个全局管理类中,建议就设计为一个或多个静态单例,各VO对象实例以类成员的形式缓存在这些单例类中。
在M项目中,主要业务是IM即时通讯,所以我们客户端的VO层设计就是以当前IM用户为中心来设计的,这些VO包括当前用户的好友VO列表、部门同事VO对象列表、单聊聊天室VO列表、群聊聊天室VO列表、最近联系人VO列表、当前用户信息VO对象等;这些VO数据统一在一个叫IMCurrentUserHelper类中管理。
其次就是字段映射,VO对象其实就是数据库表对象(确切来说是表视图对象)在内存
中的缓存,所以各个字段基本就可以从各个表中抽离来进行定义(当然,也可能需要根据业务进行专业化、领域化的命名)。
从VO对象设计出发,我们就可以大致归纳出M项目中的核心表结构,包括人员详情表、人员关系表(包括好友关系、同事关系等)、聊天室基本信息表、群聊扩展信息表、最近联系人信息表、聊天信息表(包括单聊消息与群聊消息)。
有了VO对象与表对象的字段关系映射,就可以为状态数据与表数据的分离提供数据基础,接下来要做的就是将各类数据库表操作以线程维度进行分离。
因为所有数据库的写操作都是串行的,所以数据库的数据保存操作肯定只能在一个线程队列中操作(当然,这只是最简单的做法,例如,如果两个表的数据字段是完全独立的,就可以使用两个线程来分别处理这两个表的写操作)。
而数据库读操作是支持并行处理的,所以理论上是可以运行多个线程进行并行查询操作的(不过在IOS中,我们使用CoreData技术来操作SQLite数据库,还没来得及做此类尝试)。
在M项目中,因为使用CoreData技术,对于数据库操作,我们有用到一个叫NSManagedObjectContext的数据操作上下文对象,基于此对象,我们可以很方便地实现表数据的增删查改操作,不过因为涉及多个线程同时对同一表数据的操作,所以我们参考网上建议,规划了两个上下文对象:一个叫ReadThreadAManagedObjectContext,用于在A子线程中执行查询操作获取界面呈现用的数据,再将数据提交到主线程来更新界面显示;另一个叫WriteThreadBManagedObjectContext,用于在B子线程中执行统一的数据写操作。
当然,将这两个上下文对象用于VO数据的同步是很复杂的,目前主要有两种思路:
思路一:
从底层XMPPUtility层接收到的消息,经过解析后(可以考虑将解析过程放到统一解析器中,另开C子线程来处理),先更新VO层数据,一则用此数据回调主线程,到界面进行更新呈现;二则在A子线程中,将VO层数据同步更新到WriteThreadBManagedObjectContext中表数据,操作完成后将更新同步到ReadThreadAManagedObjectContext,第二步操作可以统一在一个线程队列中进行处理。
此思路采用界面显示优先的原则,。
思路二:
直接用CoreData映射实体代替VO层。
声明一个执行ReadThreadAManagedObjectContext查询操作的子线程A;
声明一个执行WriteThreadBManagedObjectContext增删查改操作的子线程B。
数据初始化:
系统启动时,在A中执行查询操作,从本地数据库中获取当前登录用户的最近联系人列表、好友列表、群聊列表,获取完成后再回调主线程,通知界面进行更新。
增量更新逻辑:
从底层XMPPUtility层接收到的消息,经过解析后,先在B中,用WriteThreadBManagedObjectContext保存到数据库中,待保存成功后,利用NSManagedObjectContextDidSaveNotification将更新结果合并到ReadThreadAManagedObjectContext,再通知主线程进行界面更新。
来自上层界面的数据更新动作:例如,修改了群名称后,系统在修改成功回调之前不做处理,待收到修改成功的回调后,先在B子线程中用WriteThreadBManagedObjectContext查询到此Room,将新的RoomName保存到数据库中,待保存成功后,利用NSManagedObjectContextDidSaveNotification将更新结果合并到ReadThreadAManagedObjectContext,同时通知界面更新状态。
此思路是数据完整性优先的原则。
虽然以目前我们项目的进度来看,采用思路二进行重构的代价更小,但是思路二最大的问题在于,B线程中增量更新的数据,在A中无法准确感知到(目前思路是采用FetchResultController进行增量同步),每一次界面显示的数据都需要重新从数据库中查询来获取,所以最终还是选择了思路一;
后记:
其实全文还有第三章,主要介绍笔者上文提到的IM项目的架构设计实践,因为还没有写完,所以就先把上篇放出来,跟大家探讨探讨。