7

GitHub Repo: https://github.com/wxyyxc1992/RARF

Why RARF?

本文仅代表个人思考,若有谬误请付之一笑。若能指教一二则感激不尽。另外本文所写是笔者个人的思考,暂未发现有类似的工作。不过估计按照笔者的智商可能世界上已经有很多大神早就解决了笔者考虑的问题,若有先行者也不吝赐教。

在文章之初,笔者想交代下自己的背景,毕竟下文的很多考虑和思想都是来源于笔者所处的一个环境而产生的,笔者处于一个创业初期产品需求业务需求急速变更的小型技术团队。一般而言软件工程的基本步骤都会是先定义好需求,定义好数据存储,定义好接口规范与用户界面原型,但是笔者所在的环境下往往变更的速度已经超过了程序猿码字的速度N倍。另外,笔者本身也是一名前端开发者(待过创业公司的都知道,啥都得上),笔者很多用于后端开发的思想也受到了前端很大的影响,譬如后端一直遵循的MVC架构模式,而前端已经有了MVC、MVP、MVVM、Flux、Redux等等这一些变迁。前端迎来了组件化的时代,而后端也是微服务的思想大行其道。本文主要受以下几个方法论的影响:

  • RESTful

  • MicroService

  • Reactive Programming

  • Redux

在进行具体的表述之前,笔者想先把一些总结的内容说下,首先是几个前提吧:

  • 不要相信产品经理说的需求永远不变了这种话,too young,too naive

  • 在快速迭代的情况下,现有的后台接口僵化、代码混乱以及整体可变性与可维护性变差,不能仅仅依赖于提高程序猿的代码水平、编写大量复杂的接口说明文档或者指望在项目开始之初即能定下所有接口与逻辑。目前的后端开发中流行的MVC架构也负有一定的责任,我们的目标是希望能够寻找出在最差的人员条件下也能有最好结果的方式。或者描述为无论程序猿水平如何,只要遵循几条基本原则,即可构造高可用逻辑架构,并且这些规则具有高度抽象性而与具体的业务逻辑无任何关系。

  • 任何一个复杂的业务逻辑都可以表示为对一或多种抽象资源的四种操作(GET、POST、PUT、DELETE)的组合。

Motivation

RARF的全称是Reactive Abstract Resource Flow,字面意思是响应式抽象资源流,笔者把它称为基于抽象资源流的响应式处理的架构风格。一般来说,Server-Side开发包含接口定义、具体业务逻辑处理方法、数据存储交互(SQL+NoSQL)这三个层次,RARF也是面向这三个方面,提出了一些自己的设想。这边的Reactive表示RFRF同时关注了并发与异步这一方面的问题,并选择了Reactive Programming或者更明确一点的,FPR来作为方法论。

面向用户的接口可读性与可用性(可组合性)的提升

为啥笔者一直在强调自己是个前端工程师,就是在思考RARF的时候,我也从一个前端工程师的角度,来考虑到底前端需要怎样的一种接口方案。

接口的可读性

REST风格的一个特征就是资源化的接口描述,譬如:

[GET] http://api.com/books

[GET] http://api.com/book/1

[POST] http://api.com/book

这种资源化的接口可读性还是比较好的,通过动词与资源名的搭配很容易就能知道该接口描述的业务逻辑是啥。不过,在笔者浅薄的认知里,包括REST原版论文和后面看的各式各样的描述,都只针对单个资源进行了描述,即简单逻辑进行了表述。如果现在我们要添加一个购买书籍的接口,在该接口内包括了创建订单、扣费等一系列复杂逻辑操作,相信程序猿会倾向于使用:

http://api.com/doBookBuy

对于单个接口而言,可读性貌似上去了,但是,这最终又会导致接口整体命名的混乱性。确实可以通过统一的规划定义来解决,但是笔者个人认为这并没有从接口风格本身来解决这个问题,还是依赖于项目经理或者程序猿的能力。

笔者在RARF中定义了一个概念,叫Uniform Resource Flow Path(统一资源流动路径)来增加接口的描述性,同时URFP也是整个RARF的驱动基础。

前端定制的接口(Frontend-Defined APIs And URFP-Driven)

无论是WebAPP还是iOS还是Android,都需要与业务服务器进行交互,我们一般称之为消费接口,也就是所谓Consume API的感觉。笔者一直有个形象的感觉,现在前后端交互上,就好像去饭店吃饭,厨师把菜单拿过来,消费者按照菜单定好的来点菜。而笔者希望能在RARF达成的效果,就是把点菜变成自助餐,也就是所谓的前端可以根据后端提供的基本的资源的四种操作来完成自己复杂的业务逻辑。笔者在这里先描述两个常见的蛋疼场景:

(1)前后之争,该迁就谁?

作为一个前端开发者,往往会以界面为最小单元来进行逻辑分割(不考虑组件化这种代码分割的影响),毕竟每次请求都会引起延迟与网络消耗,所以前端开发者往往希望我在一个界面上进行一次逻辑请求即可获取所有的待展示参数。但是对于后端开发者而言,是以接口为最小单元来进行逻辑分割,有点类似于那种SRP(单一职责原则),每个接口做好自己的事情就好了。譬如如果UI为Android端设计了一个界面,该界面上展示了一个电商活动的信息,譬如:

然后呢,UI为iOS端设计了一个界面,展示某个电商活动的所有商品,譬如:

然后,UI为WebAPP设计了一个界面,同时包含了活动介绍和商品列表,大概是这样:

那么现在后端要提供接口了,如果按照逻辑分割的要求,最好是提供三个接口:

/activities ->获取所有的活动列表

/activity/1 ->根据某个活动ID获取活动详情,包含了该活动的商品列表

/goods/2 ->根据商品编号获取商品详情

这样三个原子化的接口是如此的清晰好用,然后后端就会被iOS、Android、WebAPP三端混合打死。那再换个思路呢,按照最大交集,就是WebAPP的需求,我把全部数据一股脑返回了,然后后端就会被产品经理打死吧,浪费了用户这么多流量,顺带着延迟啊服务器负载啊也都上来了。另外呢,譬如商品表中有个描述属性,是个很大的文本,那么我们在返回简略列表的时候,肯定不能返回这个大文本啊,这样的话,是不是又要多提供一个接口了呢?

假设业务逻辑中有$M$个不同的资源,相互之间有交集,而每个资源有$N$个属性,那么夸张来说,如果按照界面来提供接口,大概需要:

$$
\sum_{i=1}^M C_M^i * ( \sum_{j=1}^{i} (C_N^j) )
$$

种搭配,当然了,这绝逼是个夸张的描述。

(2)界面变了/接口变了/参数变了

假设勤恳的后端程序猿按照iOS、Android、WebAPP不同的要求写了不同的接口,完美的解决了这个问题,然后UI大手一挥,界面要改,之前做的菜是不是全都得倒了呢?

同理,如果接口变了或者接口参数变了,无论前后端估计都会想杀人的吧?

逻辑处理函数中的状态管理

在目前的前端开发中,状态管理这个概念很流行,笔者本身在前端的实践中也是深感其的必要性。在前端中,根据用户不同的交互会有不同的响应,而对于这些响应的管理即是一个很大的问题。笔者在这里是借鉴的Redux,它提出了构造一颗全局的状态树,并且利用大量的纯函数来对这颗状态树进行修正。同时,这颗状态树是可追踪的,换言之,通过对于状态树构造过程的跟踪可以完全反推出所有的用户操作。

那么这个状态的概念,移植到后端的开发中,应该咋理解呢?首先,后端是对于用户的某个请求的响应,那么就不存在前端那种对于用户复杂响应的问题。直观来看,后端的每个Controller中的逻辑操作都是顺序面条式的(即使是异步响应它在逻辑上也是顺序的),可以用伪代码描述如下:

RequestFilter();//对于请求过滤与权限审核

a = ServiceA();//根据逻辑需要调用服务A,并且获取数据a

if(a=1){

b = ServiceB(a);//根据上一步得到的结果,调用某个特定的Service代码,注意,数据存取操作被封装在了Service中

}else{

b = ServiceC(a);//否则就调用

}

c = ServiceD(b)

put c

状态混乱

目前很多的Controller里是没有一个全局状态的,我们习惯了调用不同的Service然后进行判断处理在最后整合成一个结果然后返回给用户。这就导致了存在着大量的临时变量,譬如上面伪代码中的a,b,c。笔者认为的状态管理的结果,就是在一个逻辑处理的流程中,不需要看前N步代码即可以判断出当前的变量状态或者可能的已知值,特别是在存在嵌套了N层的条件判断之下。笔者认为的状态混乱的第一个表现即是缺失这样一种全局状态,顺便插一句,如果有学过Redux的朋友肯定也能感受到,在有全局状态树的情况下可以更好地追踪代码执行过程并且重现错误。

另一个可能引起状态混乱的原因,即是Service的不可控性。借用函数式编程中的概念,我们是希望每个被调用的Service函数都是纯函数(假设把数据库输入也作为一个抽象的参数),不会存在Side Effect。但是不可否认,现行的很多代码中Service函数会以不可预知的方式修改变量,虽然有时候是为了节约内存空间的考虑,譬如:

ArrayList<Integer> array = new ArrayList();

Service(array);//在Service中向array里添加了数据

而这样一种Side Effect的表现之一就是我们在调试或者修改代码的时候,需要递归N层才能找到某变量到底是在哪边被修改了,这对于代码的可维护性、可变性都造成了很大的负担。

抽象混乱

因为每一个Controller都是面向一条完整的业务逻辑进行抽象,所以在朴素的认知下并不能很好地进行代码分割。在符合自然认知规律的开发模式中,我们习惯先定义出接口,然后在具体的接口需要的功能时进行抽象,譬如在项目之初,我们需要一个获取所有商品列表的接口,我们可以定义为:

[GET]:/goodss -> 映射到getGoodsController

在编写这个getGoodsController方法的过程中,我们发现需要去goods表中查询商品信息,那我们会提出一个selectGoodsAllService的服务方法,来帮助我们进行数据查询。然后,又多了一个需求,查询所有价格小于10块的商品:

[GET]:/goods?query={"price":"lt 10"} -> 映射到getGoodsController

这时候我们就需要在Controller加个判断了,判断有没有附带查询条件,如果附带了,就要调用专门的Service。

或者也可以为这个查询写一个专门的Controller:

[GET]:/getGoodsByPriceLess?price=10 -> getGoodsByPriceLessController

同样需要编写一个根据价格查询的Service。然后,随着业务的发展,我们需要根据商家、剩余货物量等等进行查询,然后Service越来越多,慢慢的,就陷入了抽象漏洞的困境,就是我们虽然抽象了,但是根本不敢去用下面的代码,因为就怕那是个大坑,比较不知道它的抽象到底是遵循了什么的规则。

依赖混乱

上图描述了一个请求处理过程中的依赖传递问题,直观的感受是,当我们的业务逻辑系统变得日渐复杂之后,不依赖Code Review或者测试,特别是对于小团队而言,不敢去随便乱改一个现有的服务,宁可去为自己要处理的逻辑写一个新的服务,因为压根不知道现有的服务到底被多少个东西依赖着。牵一发而动全身啊。这就导致了随着时间的增长,系统中的函数方法越来越多,并且很多是同构的。举例而言,有时候业务需要根据不同的年龄来获取不同的用户,但是程序猿在初期接到的业务需求是查找出所有未成年的人,它就写了个方法getMinorsService(),这个服务不用传入任何参数,直接调用就好。然后某天,有个新的需求,查找所有的没到20岁的人,于是又有了一个服务getPeopleYoungerThan20()。这样系统中就多出了大量的功能交叉的服务,而这些服务之间也可能互相依赖,形成一个复杂的依赖网络。如果有一天,你要把数据库的表结构动一下(譬如要把部分数据移到NoSQL中),就好像要踩N颗地雷一样的感觉吧。

说到依赖,相信很多人首先想到的就是Spring的DI/IoC的概念。不过笔者认为依赖注入和本文笔者纠结的依赖混乱的问题,还是有所区别的。依赖注入的原理和使用确实非常方便,但是它还是没有解决依赖划分混乱的问题,或者需要大量的劳动力去在代码之初就把所有的依赖确定好,那么这一点和RARF文初假设的场景也是不一致的。

最后,笔者在RARF中,不会解决依赖传递或者多重依赖的问题,只是说通过划分逻辑资源的方式,把所有的依赖项目明晰化了。譬如上面提到的N个具有交叉功能的GET函数,都会统一抽象成对于某个资源的抽象GET操作。换言之,以URFP的抽象原则进行统一调用。

挣脱数据库表结构/文档结构的束缚

在富客户端发展的大潮之下,服务端越来越像一个提供前端进行CRUD的工具。任何一个学过数据库,学过SQL的人,都知道,联表查询比分成几个单独的查询效率要来的好得多,这也是毋庸置疑的。但是,在本文所描述的情况下,联表查询会破坏原本资源之间的逻辑分割。这边先说一句废话:在MVC中,当数据表设计和接口设计定了之后,中间的代码实现也就定了。

举例来说,我们存在两个业务需求:获取我收藏的商品列表和我购买过的商品列表,这边涉及到三个表:goods(goods_id),goods_favorite(goods_id,user_id),goods_order(goods_id,user_id)。在MVC中,我们会倾向于写上三个接口,配上三个Service,这就是典型的提高了查询效率,并且符合人们正常开发顺序的方式。不过,如果单纯从资源的逻辑分割的角度考虑的话,对于商品查询而言,应该只有一个selectGoodsByGoodsId这个Service,换言之,把原来的单次查询拆分为了:先在其他表中查询出goods_id,然后再用goods_id在goods表中查询商品详情。这样的逻辑分割是不是明晰了一点?

是啊,这肯定会影响效率的,笔者也在想着能不能通过某种方法达成一个平衡。

如果你看到这还有兴趣的话,可以看看下面笔者的思考。

Solution(解决方案)

关于抽象资源

万物皆抽象资源

RESTful中也有all is resource的概念,但是RESTful强调的是像超文本啊、某个音视频啊,这些都可以通过URI访问到,也就是可以当做一个资源然后被前端获取。这一点已经获得了广泛地认可,而在RARF中强调的抽象资源,就好像在电商系统中,我们都知道goods与user是两种资源,那么在描述用户收藏夹的时候,即对于user_goods这个表,算不算资源呢?当然算啊!换言之,描述两个资源之间关系的,无论是一对一,还是一对多,只要具备唯一标识的,就是独立的抽象资源。

不过,如果哪一天逻辑设计上,把用户收藏的商品,变成了JSON字段然后存储在user表中,即成了user资源的一个属性,那么此时这种映射关系就不是一个资源了。因为它没有一个唯一标识。

资源的定义

在讲资源的定义之前,首先看看关系型数据库中经典的设计范式:

  • 第一范式(确保每列保持原子性)

  • 第二范式(2NF)属性完全依赖于主键(消除部分子函数依赖)

  • 第三范式(3NF)属性不依赖于其它非主属性[消除传递依赖]

对于从具体的业务逻辑抽象出相互分割并且关联的资源是RARF的基础,在笔者构思RARF的基本原则时,一开始是想走强制严格化道路,即严格命名,具体而言:

  • 万物皆资源,资源皆平等。

  • 每个资源具有唯一的不可重复的命名。

  • 任何资源具有一个唯一的标识,即{resource_name_id}在所有表中必须保持一致。譬如我们定义了一个资源叫user,那么它的标识就是user_id,不可以叫uid、userId等等其他名称。

  • 任何资源的属性由{resource_name_attribute_name}构成,且遵循第二与第三范式。

这一套命名规则,有点像乌托邦吧,毕竟作为一个不成熟的想法,RARF还是要去切合已经存在的各种各样的数据库设计风格或者方案,不可能让人家把表推倒全部重建,所以呢,最后资源定义的规范就一句话:资源名不可重复且资源属性具有唯一所有性。不过想想,如果按照严格命名方案的话,会自动化很多。

资源的操作:GET、POST、PUT、DELETE + ResourceHandler

RARF的基石即是抽象资源(Abstract Resource)与对于资源的操作(Handling),笔者这里暂时借用函数式编程与Redux的概念,ResourceHandler我们可以把它当做"纯函数对待"。这里的ResourceHandler,我们可以将用户输入与数据库输入当做两个输入,在输入相同的情况下ResourceHandler的执行结果是一致的,换言之,在RARF中,ResourceHandler本身是无状态的、无副作用的。

而每个ResourceHandler向外提供的资源的操作,就是GET、POST、PUT与DELETE。这里不进行赘述了。

到这里为止,我们其实是针对RESTful原始的譬如:

/book/1

这种逻辑的处理进行了分析,还是对于单个资源的,在实际的业务场景中,我们往往是对一或多个资源的组合操作。

业务逻辑与资源流动:用URFP来描述业务逻辑,去Controller

学过数学的都知道,两点确定一条直线,而在后端逻辑开发中,当你的Controller(Controller指响应某个固定业务请求的接口处理函数)与数据表设计定了之后,你中间的代码该怎么写也就定了。换言之,Controller与数据表是主食材,中间代码是调味料。这也就是典型的点菜模式,而RARF中一直强调的资源操作的组合的概念,是希望前端能够自己用资源和资源的操作来给自己做一盘菜。而URFP正是提供这种灵活性的核心机制。RESTful是推荐使用四个动词来分割一个业务,在非RESTful风格下,如果用户要买个东西,接口应该这么定义:

/doGoodsOrderCreate?goods_id=1

如果是RESTful风格的接口呢:

POST:/goods_order?goods_id=1&...

于是我自己想,按照RARF的资源流动的这个感觉,那是不是应该:

POST:/goods/{goods_id}/goods_order?requestData={requestData}

这一套URL的规则笔者称之为Uniform Resource Flow Path(统一资源流动路径),换言之,将与业务逻辑相关的必须性资源放在url上。这样写有啥好处呢,我自己觉得啊,一个是可读性更好了,二个呢在代码层次区分的也会更开了。就能很好地把各个独立地ResourceHandler给串联起来啦。

资源转化

原则:所有URFP的邻接资源之间必存在且只存在ResourceID依赖关系

资源转化是URFP的最核心的思想,感性理解的话,譬如对于下面这个URL,是用来获取某个用户的收藏夹的:

/user/1/goods_favorite/all/goods

当Application接收到这个请求时,会创建一个ResourceBag,就好像一个空的购物篮。然后ResourceBag被传送到UserHandler,根据user_id里面拿到了一个User资源的实例,然后下一步又被传送到了goodsFavoriteHandler,这个Handler看下篮子里已经有了User资源,然后就根据user_id查询出新的GoodsFavorite资源,然后这个篮子再被传递到GoodsHandler,同理,根据篮子里现有的资源来换取获得Goods资源的实例。

这种用已用的资源去获取新资源的方式就是所谓的资源转化,注意,邻接资源之间务必存在主键依赖关系,譬如从goods_favorite转化到goods的时候,在goods_favorite表中就有一列是表示的是goods_id。

资源注入

资源注入的应用场景可以描述如下:

UI设计了一个界面,同时展示了商品列表和商品的供货方的信息。这就等于要把两种资源合并返回了,在上文前后之争中笔者就已经讨论过,最符合逻辑分割的思想就是让前端先请求一波商品列表,然后根据返回的goods_provider_id来获取供货方的信息,不过估计要是真的这么做了,会被打死的吧。

笔者这边呢,引入了一个resource_integration关键字,譬如,我们的这个请求可以这么写:

/goods?requestData={"resource_integration":["goods_provider"]}

那么在ResourceHandler接收到这个请求的时候,会自动根据需要注入的资源,进行资源注入。

这边还有个遗留问题,类似于数据库查询中的左联接和右联接,如果需要注入的资源不存在,咋办呢?我还没碰到过这个业务场景,如果有朋友遇到过,请和我联系。

隐性业务逻辑的处理

实际上URFP并不能完美显示所有的业务逻辑,譬如在购买商品时候,我们是把它看做了对于goods_order资源的一个POST操作,但是实际业务中,购买了商品之后还要对goods进行操作,即把商品数目减一,还要对credit进行操作,即把用户积分加减,或者创建支付订单等等等等。以上这些隐性业务逻辑是与返回结果强相关的,直接写在ResourceHandler当中即可,那还有一种是非强相关的,最典型的例子就是日志功能。在正常的业务逻辑处理时,不可能因为你日志记错了就不让用户去购买东西的吧?

关于这部分隐性业务逻辑的处理,笔者其实不太喜欢AOP这种方式,感觉不太可控,所以还是想借鉴Middleware(二者区别在哪呢?)或者Reducer这种方式。

看到这里,如果还有兴趣的话可以看看笔者Java版本的实现,正在剧烈变动中,不过代码也不多就是了,如果笔者的思想真的可能有些意义的话笔者打算在NodeJs、PHP以及Swift这几个语言中都实现一下,也欢迎大神的加入。

关于数据库设计

RARF有一个核心理念就是资源的独立性,但是在现有的后端程序开发中,特别是基于关系型数据库的开发时,我们不可避免的会引入联表查询。譬如系统中使用goods_user这个表来描述用户的商品收藏夹,如果我们要获取某个用户的收藏的商品列表,最好的方式肯定是用goods_user与goods这两个表进行联合查询,但是这样的话势必又无法达成资源分割的目标,那么在RARF中,最极端的方法是:

/goods_user/all/goods

根据上文对于URFP的描述,这个路径首先会传给goods_user的Handler,在该Handler进行一次查询,获取所有的goods_id。然后根据goods_id查询获取所有的商品信息并以列表方式返回。这是一种最符合资源分割原则的方法,但是毫无疑问这样会大大增加数据库交互次数,不符合优化的规则。那该咋办呢,根据URFP的原则一,邻接资源之间存在且只存在ResourceID依赖关系,那我们引入DeferredSQL的概念,即在goods_user中,我知道我要查询出什么,但是因为我不是URFP的最后一环,我只是传递一个DeferredSQL下去,而不真正的进行数据库查询操作。

另一种情况,可能存在需要联合查询的就是URFP中描述的资源注入的情况,即存在resource_integration的情况下。实例而言,我们在获取商品列表的时候同时也要获取商品的提供方的信息,一般情况下需要把goods表与goods_provider表联合查询,那么在RARF中同样也是基于DeferredSQL,如下所示:

DeferredSQLExecutor(DeferredSQLForQueryGoods,DeferredSQLForQueryGoodsProvider)

具体到方法论上,以Java为例,目前流行的数据库辅助框架有Hibernate与MyBatis。Hibernate是全自动的ORM框架,不过它的实体类中强调One-to-One、One-to-Many这样的依赖关系,即通过关联查询把其他资源视作某资源的一个属性,这样无形又根据逻辑把资源混杂了。另一个是MyBatis,半自动的框架,但是其SQL语句无法自动组装,即无法自动帮你进行联合查询,还是得自己写基本的SQL模板然后它帮你自动生成,也是无法完全符合RARF的需要。

Pursuit(愿景)

缩了那么多,最后,我还是陈述下我在设计RARF一些莫名其妙的东西时候的愿景吧,其实看到现在机智的同学,应该能够感觉到,这个RARF和MicroService在很多设计理念上还是很类似的,这里先盗用下MicroService的Benefits:

  • Microservices do not require teams to rewrite the whole application if they want to add new features.

  • Smaller codebases make maintenance easier and faster. This saves a lot of development effort and time, therefore increases overall productivity.

  • The parts of an application can be scaled separately and are easier to deploy.

那么改造一下,RARF的愿景就是:

  • RARF希望能够在修改或者增删某些功能时不需要把全部代码过一遍

  • 基于Resource分割的代码库会更小并且更好管理,这会大大节省开发周期,提供产品能力

  • 整个应用程序能够独立扩展、易于部署。就像RARF中,如果发现哪个ResourceHandler需求比较大,可以无缝扩展出去。

估计这篇文章也没啥人愿意看吧,不过如果哪位大神也有同样类似的思考的欢迎加QQ384924552,可以一起讨论讨论。


如果觉得我的文章对你有用,请随意赞赏
melvin0987 · 3月21日

竟然看完了

回复

载入中...