1.12.阡陌:Ux编排
山镇红桃阡陌,烟迷绿水人家。——宋 葛胜仲《西江月·山镇红桃阡陌》
项目地址:https://github.com/silentbalanceyh/vertx-zero-example/(子项目:up-rhea)
「壹」工具类
本系列教程中不会对Zero中的所有工具类一一讲解,但高频使用的工具类以及工具类的核心实现会穿插在各个所需的章节中,Zero中的工具类设计前后端统一,它的设计原则如下:
工具类名短小,统一使用。
同一类的功能函数使用相同单词前缀,拉平调用。
放弃以前各种满天飞的
Util
后缀模式。
Zero后端包含单个核心工具类:
io.vertx.up.fn.Fn
vertx-co
将Java函数化专用工具类,实现丰富的函数式编程功能。
io.vertx.up.util.Ut
vertx-co
和Vert.x框架无关、业务无关的核心工具类。
io.vertx.up.unity.Ux
vertx-up
带业务功能、编排功能的容器工具类(编程高频使用)。
为了使得前后端一致,前端则使用如下(JavaScript代码):
1.1. 设计思路
看看下图:
整个unity
包中所有的工具类按职责划分成不同的类文件,每个类文件使用包域(不使用public
,直接使用default域),这些类文件中所有的函数都是静态 (static)函数,并且也是包域;而Ux
类中定义了和其他类的同名方法,整个包中只有Ux
是public的(类和函数都是),那么您在编程过程中,就可以直接使用Ux.xxx
的方式调用任何功能函数。
这种设计方式可能比较古怪,但我们团队在编程过程中却能感受它的优点:
工具类中封装了所有第三方依赖库,使得系统的核心代码全部调用了自己开发的类,在
import
代码区域部分,只会出现import io.vertx
和import io.vertx.up
两种代码,再也不会看到第三方包出现在自己的代码中,一旦出现就可以用统一的思路进行工具化。工具类统一使用
Ux.xxx
调用,您不需要在使用工具过程中去记忆相关功能函数存在于哪个XxxUtil
中,减少了开发人员记忆工具类的压力,仅记忆相关函数即可。重构更简单:如果遇到改变代码逻辑,可以不用修改
Ux.java
的代码逻辑,而是改内部工具逻辑,此种情况下,多个人协同开发时也减少了相互之间冲突的概率。更容易代码去重:前边提到过一个Zero中代码重构的原则——如果一个函数只有单个类在调用,那么直接使用
private
私有代替工具类,如果一个函数有两个或两个以上的类在调用,直接工具化。
有了这样的设计,在整个编程过程中,您的大部分时间都是如何思考业务,一旦要用工具,使用Ux.
就可以选择您想使用想要的方法了,非常简单。
这只是Zero中的工具箱设计思路,和容错部分异常结构的设计类似,读者可以参考,不可奉为圭臬。
1.2. 编排初探
在看编排之前,我们一起看一段zero-crud
中的代码:
这段代码包含复杂的代码逻辑,但主程序中只负责编排 ,每一个功能点都直接抽象成一个Monad单子,在这样的结构下,主流程代码量会很少,而且逻辑会变得更加清晰,不论这是否是你喜欢的风格,都不可否认这种方式的代码流畅性和可维护性。上述代码结构类似下图:
这种编排结构代码的特征如下:
每一个Monad单子中内部代码逻辑可以是同步也可以是异步,但箭头表示的数据流过程全程异步。
每一个Monad单子都是接近封闭逻辑的全函数,一方面它们没有任何副作用,另外一方面自己执行失败也不会影响后续Monad单子的运行。
主代码只负责Monad的排兵布阵,不负责执行,调试时只需将关注点集中在单个Monad中。
一个完整的代码逻辑会被分成独立的代码执行函数,如果每个Monad能设计成单一职责,调试将会是莫大的福音。
整个结构中同一个链上的Monad位置可切换,切换过后不影响执行结果,当然二者之间不依赖是代码这一层相互独立,有时候数据层面还是有一定的依赖关系,这种情况会导致切换位置过后数据不完整,但不会造成严重的代码错误。
如此,您就理解编排的意义所在了,这样的代码编排更多的思考是在如何拆分和设计结构,不用去纠结过多的判断逻辑导致代码难于维护。
1.3. 异步带来的挑战
如果您使用了async/await
的方式编写异步代码,那么我们就不聊了,毕竟这种模式是最好的也是思路最正的,但是用过Vert.x的开发人员都知道,这种方式需要您开启Java的Fiber,并且引入额外的-agent
才能实现,会导致环境不纯;而另外一种Rx响应式的模式在Vert.x中又是另外一个故事了,也不在讨论范围。
在选择Vert.x中四种异步模式时,Zero选择了中规中矩的Future模式,虽然支持Callback回调模式,但本身不推荐使用这种模式编程,——这也许不是最好的选择,但是比较原生态的,所以您可以理解为什么Vert.x官方并没有把Fiber
和Rx
放到vertx-core
中,而是以子模块存在。
Future模式实际结构和Promise规范很像,我们在业务开发过程往往会遇到不同的情况:
两个步骤必须一前一后地执行,因为后者的执行依赖前者的结果,而前者结果是异步返回。
几个步骤同时执行,执行完成后要合并到一起返回最终结果。
对多个步骤执行结果进行数据结构的转换。
不同步骤之间有各种集合运算和数据结构转换,不能和流程控制解耦。
Vert.x中使用了如下代码执行Future的基本构造:
有了这些简单的API过后,同步数据异步化、单链执行 对大部分开发人员而言没有任何难度,而本章我们将介绍Zero为多步骤编排提供的核心函数,这些函数可以大大提升您在异步代码流程开发中的效率,而且可以从一定程度上改变您的编程思路,并且解决上边提到的各种挑战和问题。
「贰」简单编排
可能对很多读者而言,本章比较无趣,主要原因是本章的部分内容可能存在过度设计而显得画蛇添足,但从实战中可以看到,Zero中的编排工具 的确是帮助了我们而使得编程流程不是想象中那么繁琐,省掉了编排压力后,开发过程会变得相对简单,这是我在用Zero开发前两个系统时最大的感受——几乎是无脑编排,相当于扩展过的API调用。
本小节主要讲解部分代码在有了Ux过后的演化,虽然侧重点很小,但实际开发过程中比较受用。而简单编排的函数我称为future系。
虽然Vert.x中提供了类似Future.succeededFuture
的方法直接支持泛型,但为了简化代码和引入部分初始化的运算,Zero又重新对此执行了封装。Zero中future系的API主要包含下边几种:
future
T
返回单实体类型。
futureL
List<T>
返回集合List<T>
类型。
futureA
JsonArray
返回JsonArray类型。
futureE
Throwable
返回异常类型。
futureJ
JsonObject
返回JsonObject类型。
futureG
Map
对集合执行分组,返回Map类型。
2.1. 直接构造
读者根据自身习惯书写,除了简化和短小,这些代码并没进行任何逻辑改动。
是不是特别无趣?这些代码几乎只是浅层次的封装,不过您不要着急,由于future系的API众多,如果只是这样的更改,那么提供future系的代码功能就没有必要了,这些基本层次的改动只是为了统一API而做。
2.2. 微型转换
更进一步,看看下边的代码改动(这部分不提供Vert.x中的代码转换,因为存在大量重复性的类型转换代码):
除了上述的基本类型转换,Ux还包含业务类型相关的转换:io.vertx.up.commune.Record
是Zero中定义的一种概念对象——记录,该对象在动态建模 时会作为数据主体对象,动态建模依赖zero-atom
项目,底层数据结构定义在Zero框架内部,所以和它相关的部分运算收录在Ux中。由于是动态建模专用,Record不会有class
定义的Java类型和它进行类型转换,只能让它和Json数据进行互转。
2.3. 带错转换
带错转换只出现于返回值为具体类型的情况,这种转换大部分时候应用在otherwise
相关的方法中:
Vert.x中的设计是为了让系统本身变得很完美,而这个方法在开发人员调试时通常会使用类似下边的代码段:
如果想要查看异步流程的异常堆栈信息,上述代码有时候必须的,当然如果代码测试通过,这些内容似乎就没有必要了,往往您在开发过程中会出现中间态 ,Zero的目的是帮助您思考甚至辅助您的思考过程,这似乎是它“画蛇添足”的一点。参考Zero中的写法:
是不是更简洁而且可以无脑编码?
2.4. 简单串并联
简单串并联模式中,并联过程会返回输入值,但很方便做插件链。
这两个方法需要使用图示法给读者讲解,否则仅仅从函数调用上会不知所云。
简单串联模式
如图所示,串联模式的API中,第一个参数为列表第一个函数的输入,执行完后使用Future封装将结果传给第二个函数,依次类推(有点像JSR340中的责任链 模式),整个函数链执行完成后最终返回最后一个函数的执行结果。
简单并联模式
简单并联模式不关心执行结果,最终返回的是input输入数据,复杂并联模式没有在future系的API中。
如图所示,并联模式下,所有的函数都是同时执行 ,并且不等待任何函数执行的输出结果就直接将输入信息返回,这种模式下不存在等待,而且每个函数都是无序的,所以这种模式下往往调用过后会不知道是否执行完,从外部看来也是异步操作。
如果将二者合并到一起就可以创造出多维流程,读者可以自己去探索,此处就不详细解读了。
2.5. 映射转换
映射层是Zero为部分旧系统量身打造的功能,在请求数据和最终的领域模型之间引入了可配置的映射层,透过该映射层去实现旧系统往新的数据规范升级的功能,之前的Jooq部分所有的API都带有pojo的映射转换参数,它会关联一个特定的领域模型配置文件,所有的映射文件位于src/main/resources/pojo/
目录中。
先看一个例子:
映射文件内容
映射文件左值是Domain对象的属性值,右值是请求数据中的属性值。
领域模型代码
Agent代码
Worker代码
最终发送请求:
会得到如下响应信息:
结合配置中的映射文件,轻松实现了字段名的映射操作,此处枚举所有和带pojo转换的API的详细列表,您可以自己去尝试。
future系
from/to系(同步)
此处就不枚举和Envelop相关的序列化/反序列化方法了,读者可以直接去查看Ux
中的fromEnvelop
相关方法,比起映射方法,这部分方法相对复杂一点,如果不是特殊场景一般用得很少。
2.6. 分组转换
分组转换是Ux
中future系函数中的最后一类,主要是帮助您完成分组操作,它的API主要有四个:
快速方法:按
type
属性执行分组futureG(JsonArray)
futureG(List<T>)
标准方法:按输入属性执行分组
futureG(JsonArray, field)
futureG(List<T>, field)
分组转换是Zero中后期追加的方法,所以这些方法的返回值统一为:
分组转换在此处就不提供示例了,读者可以参考下图理解分组转换的执行流程:
分组不支持映射转换,因为可以先做映射再分组,也没有必要提供同时支持二者的结构。
「叁」复杂编排
看完了Zero中的简单编排系列的函数,您是不是有所心得了呢?那么本章节将会带您走入复杂编排,这里包含更多的异步操作,理论上用编排 一词好像不太科学,因为前一个章节大部分代码都是在运算而不是编排,但我们就不去纠结这里的内容了,读懂了内容,叫什么也显得没那么重要,可是本章节的内容绝对是和编排直接相关。
组合编排是十分高频的一种编排模式,它把一个元素类型为Future<T>
的集合组合成唯一的一个Future
,then系函数剩余部分的编排都是同样的思路,只是细节上有所区别。
then系函数的方法名不多,大部分都是函数重载(方便记忆),基本统计如下:
thenCombine
元素类型是JsonObject。
thenCombineT
元素类型是T(泛型)。
thenCombineArray
元素类型是JsonArray。
thenCombineArrayT
元素类型是集合,类似List<T>
。
thenCompress
主要执行了压缩,元素类型是复合类型。
复杂编排的函数我称为then系。
3.1. 异常
异常函数不能称为编排,主要是在Zero容错生成的基础上更进一步简化该流程,此处枚举等价代码让读者自己体会:
注意:thenError
方法的参数表,第二个参数开始是动参,可支持多个,直接和Zero中的WebException
构造函数参数表统一。
由于在Zero中,定义的、WebException的首参必须是class类型,有了这种约定过后,它们的转换才可以生效,最终的结构图如下:
另外一个函数是为Zero Extension量身打造的方法,返回固定异常(-60045):
该方法在后续版本中可能会被直接废弃,只保留thenError足够使用了。
3.2. 直接组合
直接组合的代码流程图如下:
Zero Extension中的一段代码如:
上述代码中就完成了直接组合图中的逻辑,调用了Ux.thenCombineT
函数,此处集合中的每一个元素都是Future<T>
,真正在执行过程中所有的元素会并行执行,最终返回的类型为Future<List<String>>
,您可以很轻松得到所有元素的执行结果(索引对应)。除开这个函数以外,Ux中还针对特殊场景有各种组合,不带转换的组合代码汇总如下:
代码中的集合合并的组合流程图如下:
直接组合运算在实战过程中很实用,它可以驱动多个子线程同时执行,将多个异步流并行组合成独立的异步操作,这样您就不用担心如何让主线程等待各自的执行结果,也无需去考虑它们运行的合并细节,这就是Ux工具类带来的便利。
3.3. 转换组合
在直接组合中,输入的类型是T / List<T>
,而输出的类型是List<T>
,这种模式适用于简单的场景,在某些复杂场景中,这种单纯的操作不一定适用,于是有了转换组合 ——输入元素类型和输出元素类型不一致。由于Future的compose
方法可以很容易让Future<T>
转换成Future<O>
,所以Zero在设计转换组合时主要侧重于同步转异步 的模式,而不是异步直接转换,若您理解了这两种API,那么配合compose
方法最终编排的函数会是一个全集。
示例中第一个函数的流程图如下:
第二个函数仅仅看输入和输出,也许您是迷茫的,因为输入是JsonArray
,而返回值是Future<JsonArray>
,看起来这种转换类似于T
转换成Future<T>
,但它的内部逻辑是完全不一样的,请参考下边的执行流程图。
如上图所示,有几点需说明:
输入的JsonArray中,每一个元素的类型都是T。
最终组合后的类型会将每一个返回类型中的JsonArray连接到一起。
最后一个
Ux.thenCombineArray
的双参数格式实际就是将T
类型限定为JsonObject
类型。
最终
elementX
的类型必须是JsonArray支持的Java对应数据类型。
3.4. 哈希组合
哈希组合是针对Map
数据结构的运算,它的调用代码如下:
第一个函数的运算逻辑很简单,流程图如下:
第二个函数的运算比较复杂,它的输入是一个多维哈希映射的列表,列表中的每一个元素都是一个Future
,它的函数执行难点在于List中的元素和元素之间的哈希表有可能会出现重复键 ,而此处压缩的含义就是一旦出现重复键,会把这两个值进行合并(默认行为是将两个JsonArray连接到一起),运算的完整流程图如下:
注意:从结果可以看到,默认函数直接将相同键值的元素连接到一起了,所以ele11
出现了两次。此处提供它的使用代码让您有一个直观认识:
3.5. Json组合
Json组合主要面向JsonObject和JsonArray,这种组合在旧版本 实战中经常使用,也是所有的组合中最复杂的(理解困难,但是从实际场景中抽取),因为设计巧妙运算复杂,所以至今保留了下来。
形态一
它的运算流程图如下:
它的执行步骤如下:
输入的JsonArray中每一个元素都是合法
JsonObject
类型(不为null
,不为{}
,不符合的类型会直接被程序逻辑过滤掉(注:输入类型是Future<JsonArray>
)。针对每一个JsonObject元素执行
generateFun
(支持同步和异步),最终格式如JsonObject -> Future<JsonObject>
,假设输入为input,输出为output。使用
operatorFun
对input以及output执行计算,得到最终每个元素的唯一输出,该函数最终格式如(JsonObject, JsonObject) -> Future<JsonObject>
。将计算结果
List<Future<JsonObject>>
组合成最终结果Future<JsonArray>
。
形态二
它的运算流程图如下:
形态三
它的运算流程图如下:
该形态是目前所有组合中唯一带有副作用的形态,它有三个和其他组合不同的点:
operatorFun是一个数组(变参),数组每一个元素都是函数,形态如
(JsonObject, JsonObject) -> Future<JsonObject>
。operatorFun中每一个元素执行过后,一定要修改第一个参数(就是source),该数组中的函数应该都是副作用函数,简单说:不修改source的执行没有任何作用。
该形态最终返回原始source的引用,修改流程是一次修改且和生成的List元素按索引一一对应。
3.6. 原子组合
then系的组合方法中最后一种是原子组合,这种组合的实用范围最广,使用频率更高,函数名依旧是thenCombine
,因为它包含了所有复杂编排 最小操作单元;若您理解了这些函数化的操作,那么编排工作就变得更简单。
这两个方法是编排功能最早的原型,它可以帮助您组合两种不同的Future,第三参数是一个BiFunction
,其中f和s是前两个Future中计算的结果,而最终会生成一个Future<T>
类型的对象(支持异步生成)。它的函数流程图下:
细心的读者会发现,这个才是组合的最小单元。看完了前几小节中所有的Ux.thenX
函数后,您会发现大部分函数都是针对List、Set、Map执行组合运算,而前边看的所有函数或多或少都不是原子级,而这两个函数用法就是原子级用法。两个方法的思路是二合一,拓展开来——任意的多合一都可以拆成无数二合一 ,所以它就是原子操作。
「肆」小结
Ux编排部分的函数讲解到此处就差不多告一段落了,这部分内容对不熟悉Vert.x中Future开发的人而言可能比较晦涩,但的确涵盖了很多异步编排 的思路,并且本章节大部分的函数都是直接从实战项目衍生而来。最后汇总说明:
目前Ux提供的所有then系的函数涵盖了部分变化,但并没有涵盖所有的编排情况。
目前出现的情况除开编排以外,也可以使用集合算法去实现,使用集合算法时,您就需要思考每一步只使用一个Future的情况,不能出现类似
List<Future>
的结构,但从实战经验看来,不是每次您在编程时都可以思考出** 最佳方案**,况且不能为了所谓的最佳方案直接视进度于不顾,项目本身不是艺术品,是商品,您的时间空间十分有限。
在一些复杂任务中,集合的处理效率不如单元素并行处理效率,虽然您可以使用复杂的数学公式计算出二者的奇点(性能平衡点),但不会一次命中,这是程序性能中一个永恒的博弈话题:究竟是1个线程处理100个元素快,还是100个线程每个线程处理1个快,如果把100换成y,把1换成x呢?其实这个问题绝对不是您肉眼可直接观测的答案。 4. 编排是异步编程中的一种艺术,而且它也不是银弹,您可以根据自己的实际需要去写复杂的运算逻辑,而且您会发现Ux中编排函数的占比是最大的。
Zero中为什么会有then系和future系这些稀奇古怪看似无用而有时候又蕴含复杂逻辑的方法,理论上讲框架是不是应该纯粹一点?我最初写这些方法的目的就是为了解决IDE对代码重复的提示,这是** 起点**,当我发现场景中越来越多的地方要使用同样的编排逻辑,就只有将这些逻辑全部抽成工具函数,然后代码重复率直线下降,而且收集的编排需求越多,代码量越小,所以在实战过程中,Ux中的编排 功能也给整个团队带来了很大的福音,也使得最后Zero中代码重复率几乎在3% ~ 5%以下,最少IDEA不会提示有代码重复的地方。希望本文的思路对读者的助力最大,而不是这些函数本身,当然如果您熟练掌握了Ux
的编排函数,在Zero中开发将会更加顺风顺水。
Last updated