3.3.Router路由管理器
Vert.x中的路由是vertx-web
项目的核心概念,也是本章重点讲解的内容,路由本身在Vert.x开发中并不复杂,但如何在一个大型项目中开发路由管理器对路由进行妥善管理和维护,是开发人员在Vert.x中遇到的一个难题;若管理不善,会导致调用了createHttpServer
的Verticle组件中start方法变得非常重(代码冗长),同时增加了代码的维护难度。本章会针对实战过程中路由管理器展开讲解,让读者学完后能理解Vert.x中的路由管理、完整设计和开发实用的路由管理器。
本书的核心内容在企业应用,大部分企业应用由于业务复杂度比较高,对REST的接口都会有规模化的需求,不论您使用的是单点、集群,还是微服务、云平台,企业应用中无法逃避的一个环节就是我们必须深入到业务逻辑中去完成CRUD的相关开发。最早的章节我提过,Vert.x并不是为了REST开发设计的,正因如此,才会让开发人员自己去设计类似路由管理器、前端过滤器、请求拦截器等组件。不过读者也不用担心,我会把这方面的心得写在本章中,让大家带着“设计”的思路去做Vert.x开发。
1. 基本内容
1.1. 再谈CRUD
大部分开发人员在项目过程中面对的系统都是CRUD的系统,CRUD全称为Create、Read、Update、Delete
,也就是俗称的增删查改系统,曾经我也和这些人一样,觉得CRUD的系统很简单,对的,它的简单在于技术非常原子化,几乎任何学过软件开发的工程师都能开发相对比较完整的CRUD系统,但也因如此,它所需的原子技术都是伴随业务需求走的——而这正是工程师的软肋。 敬畏之心,是很多软件工程师所缺乏的心态,面对CRUD系统的业务复杂度日益增长,当你发现单纯的CRUD已经无法满足业务时,那么此时此刻你需要的就是比较系统化的设计。这种设计中,任意一个原子化的操作就蕴含风险,如数据错误、维度灾难、界面崩溃等等,都是这类系统必须要解决的问题,CRUD在这种系统中扮演了基础骨架的角色,于是乎,最简单的地方成为了系统最薄弱的环节。回忆您曾做过的产品,有几个是真正达到了生产量级,那些仅服务于某个客户的项目、冗重维护的历史、运维过程不堪的经历,无一不是拖垮企业成本的双刃剑,所以CRUD系统的设计在企业系统中成为了缺失的一门必修课。 CRUD真是CRUD?从技术层面看,CRUD就是单纯的增删查改系统,倘若换一个视角从业务面去看,但凡低估这一块的工程师,都会被无情、简单而繁琐的BUG缠绕,这种BUG还很有可能无关技术,到最后都没有完成一款像样的产品。于是乎,不论是项目还是产品,最终软件工程师还是需要深入到业务层去解决实际问题。以前一个朋友给我说过:架构师不需要了解业务;而我的看法刚好相反:架构师是业务和技术的粘合剂,它不仅要了解业务,还需要一眼洞悉裹着“业务”外衣的技术。
上图只是一个CRUD的简单分类图,如果以CRUD为第一维度,那么图中拓展开的就是第二维度,继续往下可能还有更细节的维度,况且图中所见之处还未涉及到业务。若说第二维度是技术抽象层的概念形态,那么第三维度可能就会牵涉技术实现细节,如HTTP幂等性、状态代码、如何恢复删除数据、如何制定轮询任务、如何处理缓存、如何针对具体问题选型等等,再往下才会真正摸到业务,看起来CRUD就像这样一个无底洞,业务越复杂,维度越深,设计难度越大。
对任何事物存有基本的敬畏之心,学会谦卑和诚实,不高估任何一个“难题”,也不低估任何一个需求,静以专、思无邪,也是软件工程师的一门心灵课。
1.2. 路由管理初探
Vert.x中用来开发REST的方式是在项目中引入vertx-web
子项目,本文主要介绍io.vertx.ext.web.Router
类相关的知识点,Router的翻译是路由器(也可翻译成:安排路线的人),介绍它之前请先理解vertx-web
中的几个基本概念:
Router:路由器,它主要负责编排路由。
Route:路由,它存储了被编排的路由定义信息,也可称为路由元数据。
Handler:处理器,它包含了开发人员编写的路由执行核心代码,也是程序的主逻辑。
我们先看一段官方文档中的代码:
这段代码的目的是演示Router的基本用法,但这种代码如果不经过提炼,是不能直接放到生产环境的,并不是代码本身不成熟,是因为Hello World的程序以及概念性引导程序和生产环境运行的程序有天壤之别,而书中的代码理论上直接搬运到生产环境中是有风险的——这也是Vert.x开发时,工程师要自己去思考和设计类似路由管理器的原因。多嘴一句,不仅是Vert.x编程,包括Spring和Java编程,使用书本代码是需要工程师去提炼、打磨、精致化的,并不是拿来即用,别犯懒也别犯难。
2. Vert.x路由
2.1. Router初探
Router是vertx-web
中最核心的路由编排对象,又称路由器,它通常在Verticle组件中被实例化,调用代码如:
上述代码注释处创建一个新的Router
对象,从流程可知,Router
的实例化依赖一个Vertx实例(io.vertx.core.Vertx
),——其实不仅仅是vertx-web
子项目,很多其他子项目都依赖Vertx实例。Router.router(this.vertx)
内部实际创建了一个io.vertx.ext.web.impl.RouterImpl
对象,它是Vert.x中专用的路由器对象,特征如下:
这个对象本身是线程安全(Safe-Thread)的。
每个路由器对象都可以管理和编排不同的路由信息。
该对象内部维护了两个核心数据结构:一个Vertx实例引用、一个线程安全的路由器状态对象
RouterState
。
示例代码的完整流程如下:
创建一个HttpServer(前边章节提到的服务器)实例。
创建一个Router(路由器)实例。
创建一个Route(路由)实例,该实例隶属于创建它的路由器。
设置Route中的处理器代码逻辑。
将Router设置成服务器的请求监听器,开始监听请求(异步启动服务器)。
代码示例将第一章节中的HttpServer和本章节的路由管理器Router连接起来了,这段代码通常位于Verticle实例中(一般不在主代码流程上)。注意:该代码的执行次数和部署的Verticle实例数量有关(instances
参数),当部署多个实例时,该代码会在Vert.x的启动周期执行多次。 此处思考一个问题:如果每个被部署的Verticle实例多次调用了createHttpServer
方法,那么环境中是否会重复创建服务器实例?——答案是肯定的!实际上Vert.x中每一个被初始化的Verticle实例都会创建一个新的HttpServer实例,可端口不会冲突么?不会,绝对不会!此处的listen()
内部代码逻辑会对创建流程执行池化操作,源代码如下:
listen()
内部会根据输入的基本信息创建一个io.vertx.core.net.impl.ServerID
实例,该实例会作为服务器池化的键值,内部仅包含了port
和host
两个变量,若该服务器已经被创建过一次了,则运行的服务器实例直接从服务器池中读取,而不会执行重新创建流程。但是,代码运行过程中每个线程创建的HttpServer实例却不是同一个(保证线程安全,读者可以直接打印HttpServer的hashCode进行验证,同时将instances
的值设置成超过1个),此处不是同一个包含三层含义:
每个Verticle实例中创建的HttpServer实例不是同一个(hashCode不相等)对象。
createHttpServer
方法创建的HttpServer实例和实际运行的HttpServer实例不是同一个(内部引用,池化处理)概念。每个Verticle实例中创建的HttpServer实例有可能引用同一个实际运行的HttpServer实例。
上图中另一类并不是说定义新的Verticle类并且重新部署,它表示——不论是在同一个Verticle还是新定义的Verticle实例中第二次调用createHttpServer方法,并且此次创建的服务器端口port
以及主机地址host
不相同(它会生成新的ServerID),至于您最终是在一个Verticle实例中调用createHttpServer多次还是在不同的Verticle实例中每个实例调用一次都不影响最终的池化流程,这个过程就解答了我们提出的问题:Vert.x并不会重复创建真正的服务器实例。
请读者将Verticle中创建的HttpServer实例引用和实际运行的HttpServer实例区分开。
2.2. RouterState
Router
对象用于管理当前环境中所有的路由信息,它的很多API接口都直接用于创建io.vertx.ext.web.Route
路由对象,最简单的代码如:
上述代码是Vert.x官方示例中的一段。路由状态对象RouterState
对开发人员是包外不可见的,它也是2019年新版本分离出来的一个用于存储路由器状态的内置核心对象。这个对象内部定义的成员变量如下:
根据定义可以知它的数据结构如:
此处的orderSequence就是当前Router中路由的数量,后边章节会详细讲解该属性。
Router就是和当前状态绑定的路由器引用。
Handler<Router>
则是当前路由器的处理器(Hanlder)。Set<RouteImpl>
则存储了当前路由器中定义的所有路由信息(一个路由器可管理多个路由)。最后一个哈希表存储了错误路由链
Handler -> Handler -> Handler
。
上边几点只是字面上的理解,本章后续会用例子来逐一说明以及验证每个成员的具体用法。从源代码中可知,一个Router
对象只能绑定一个唯一的RouterState
,由于它是包域的,本质上对开发人员是不可见的,并不推荐开发人员直接使用这个对象。RouterState对象则用来维护、存储、设置路由器对象的状态,这些状态可以是启动时初始化,也可以在运行时变更。
2.3. 创建Route
路由状态对象RouterState中存储了一个路由集合Set<Route>
,vertx-web
中只有Route才会真正关联到URI[^1]定义中,每一个Router中会包含多个Route对象,但Route的创建并不是使用new RouteImpl
的语法,而是直接使用Router类的API接口来创建。
Router可理解成一个路由管理器,它负责创建、设置、编排所有的路由信息,而“路由器”恰好体现了这种概念,所以开发人员一定要理解路由器和路由二者的区别,这点对理解Router和Route是有好处的。
Route的实现类是io.vertx.ext.web.impl.RouteImpl
,它的实例化代码如下:
上述代码构造了最简单的Route对象,该对象会和/api/test
路径绑定到一起(支持所有HTTP方法),并执行开发人员编写的逻辑代码;除此之外,您还可以调用Router中的其他静态API来创建路由对象。最早的Servlet编程中,开发人员通过重写doGet/doPost来实现GET/POST请求,回到Vert.x中,开发人员可直接书写处理器部分的代码来实现和传统Web编程中同样的操作,示例中演示的是lambda表达式写法,您还可以构造新对象来实现定义和执行分离逻辑——本章最后一小节会有此说明。 Router.route()
构造了一个空路由Route
对象,该对象不携带任何定义信息(如路径、HTTP方法、顺序、MIME等),由于Router中创建Route的系列方法都是Fluent模式,开发过程中可直接书写成链式代码。Router类中不止包含了空路由的创建API,还包含快速创建类似<METHOD> /xxx/yyy
HTTP请求API方法,参考下表:
基础说明 | 空路由 | 纯路径 | 正则表达式路径 |
---|---|---|---|
基本路由 | route() | route(String) route(HttpMethod,String) | routeWithRegex(String) routeWithRegex(HttpMethod,String) |
get路由 | get() | get(String) | getWithRegex(String) |
head路由 | head() | head(String) | headWithRegex(String) |
options路由 | options() | options(String) | optionsWithRegex(String) |
put路由 | put() | put(String) | putWithRegex(String) |
post路由 | post() | post(String) | postWithRegex(String) |
delete路由 | delete() | delete(String) | deleteWithRegex(String) |
trace路由 | trace() | trace(String) | traceWithRegex(String) |
connect路由 | connect() | connect(String) | connectWithRegex(String) |
patch路由 | patch() | patch(String) | patchWithRegex(String) |
Router类创建路由对象时,注意以下几点:
基础路由创建时比固定路由多了一个
io.vertx.core.http.HttpMethod
参数,用于指定HTTP方法。和路由绑定的请求路径可支持两种模式:纯模式和正则表达式模式。
支持HTTP 1.1协议中的所有方法:GET, POST, PUT, DELETE, OPTIONS, TRACE, HEAD, CONNECT, PATCH。
若Router的API创建了路由对象(Route),则该对象隶属于创建它的Router,由内部RouterState维护。
2.3.1. 再谈HTTP方法
HTTP协议1.0和1.1中总共定义了九种核心HTTP方法,对熟悉RESTful的开发人员而言,GET/POST/DELETE/PUT四种都不陌生;若项目中使用了前后端分离,为解决跨域问题,OPTIONS方法也一定会驾轻就熟。本章回到HTTP方法的起源地来讨论各自的使用场景,让大家对此有更深入的理解。
HTTP 1.0协议中定义了三种方法:GET,POST,HEAD,最早的服务器资源可理解成三种操作:
GET:从服务器获取资源、查询内容,读操作。
POST:向服务器提交数据(常用来增删改),写操作。
HEAD:该方法类似于GET方法,同样从服务器端读取数据,但读取的数据只有报文头,没有报文体(无HTTP Body)。
上述三种基本方法是最早的Web开发常用方法,在Web 1.0时代(基于JSP, Servlet, JSF等服务端渲染技术),通常只会用GET或POST方法——如最早的表单提交,会写<form method="post"/>
HTML代码段实现请求提交,而那时很少会使用HEAD方法。HEAD方法通常会被开发人员忽略,但它的用途很大,包含以下几个典型的特征:
只读取资源的请求头(HTTP Header)信息。
检查超链接的有效性(若无效则直接返回404状态代码)。
检查网页是否被修改——HEAD请求的响应可以被缓存,若请求实体和缓存实体的阀值有区别(计算
Content-Length, Content-MD5, ETag, Last-Modified
),此时缓存过期。用于自动搜索机器人读取网页标记、RSS种子、安全认证信息等。
HEAD的使用场景此处不展开讨论,它是为HTTP协议中的请求头量身设计的概念,请求头的内部逻辑定义可参考HTTP协议的原始内容,如常用的Cache-Control、Data、Pragma
用法等。
HTTP 1.1协议中追加了六种新的HTTP方法:PUT, DELETE, OPTIONS, PATCH, TRACE, CONNECT,前边章节讨论过RESTful的语义问题,引入了PUT和DELETE过后,针对服务端的写操作进行了增删改的语义重定义,三者含义如下:
POST:等价于新增操作,向指定资源提交数据,实现资源的创建(遗留系统中也支持对原有资源执行修改),它依旧是使用频率最高的HTTP方法。
PUT:RESTful请求中使用的标准修改操作,从客户端往服务器传输某些指定内容,即向指定位置上传最新内容,实现内容更新。有别于POST方法的点在于PUT方法不推荐全量更新,更多时候是实现部分更新,更加类似于SQL语句中的
UPDATE ... WHERE
的语法结构,但更新时候注重资源完整性(区别于PATCH)。DELETE:请求服务器端删除请求路径标识的资源。
除此之外还有四种新方法:
CONNECT:该方法是HTTP 1.1中引入的代理协议专用方法,它将请求的服务器作为跳板,让被请求服务器代替客户端去访问真实服务器资源,之后把响应信息发送回客户端。CONNECT方法使用TCP直连模式,并不适合在Web开发中使用,它先让服务器监听某个端口接CONNECT请求,然后告诉代理服务器,该请求应该发送到哪个目标地址,然后建立TCP连接,将实际请求内容转发过去。
OPTIONS:该方法可用于请求从URI标识资源中打开某些功能选项,它可以在访问具体请求资源之前,让客户端或服务端决定是否采用某些特定的措施,简单说可用于获取服务器支持的某些特定功能(黑客常用手段)、检查服务端性能等。——目前最常用的一种方式是提交Ajax跨域请求以判断该请求是否为服务端合法请求,检测安全性、请求头、请求源执行基础校验操作。
TRACE:此方法是协议调试专用方法,一般是开发模式中使用,由于黑客通常会使用TRACE方法来对服务器执行安全性攻击,所以生产环境中为了优化安全性,建议关闭该方法。
PATCH:该方法从使用上看起来略微有点鸡肋,通常我们会把它和PUT方法进行比较,二者都是更新资源的请求,PATCH主要用于资源的部分内容更新——如某一个字段、某几个标志位等,PUT用于该资源的完整更新(更多是替换)操作。还有一点希望理解,PATCH是不具有幂等性的,并且不推荐它包含复杂操作。由于该方法是HTTP 1.1才出来的新方法,很多CRUD场景中,很少有人会细分将PATCH和PUT分离开来设计,所以它的存在并不是鸡肋,只是我们在设计RESTful中用错了,而由于业务需求本身的特殊性,设计时会使用PUT替代PATCH,因为开发时判断使用PUT还是PATCH会让大部分开发人员举步维艰。
2.3.2. 路径匹配
Vert.x的路由管理器创建路由时,每一种HTTP方法都包含了三种创建方式(两种模式):
无参数:直接创建路由,路径执行延迟绑定(不属于匹配模式的创建)。
String参数:「纯模式」创建和某个路径绑定的路由对象,该路径会检测某些正则表达式。
xxxWithRegex方法:「正则模式」创建和某个正则路径绑定的路由对象。
参考下边代码:
示例中用正则模式时,匹配的路径不仅仅是类似/catalogue/products/xxx
的路径,只要路径上包含了productType和productId都会被匹配上,所以正则模式常用于路径匹配的横向扩展或满足某种规则的扩展,而不是匹配固定路由,当然它也可以匹配固定路由。因此,开发人员需要根据自身的业务需求去选择是调用xxxWithRegex
的正则模式API还是xxx
的纯模式API,Zero框架中使用了注解绑定单个方法,选择了不带正则表达式的API。 此处深入讨论下正则表达式的路径匹配场景。众所周知,URI的唯一标识是HTTP方法 + 路径
;此处的路径从资源定义讲,每一个参数都代表不同的路径,如:/api/user/:name
的定义中,/api/user/lang
和/api/user/huan
表示不同的URI(资源标识);但从开发层面讲,/api/user/:name
仅需要开发一次即可。正则表达式场景可应用于一些特定的共享代码逻辑场景:针对某一参数param1
执行统一逻辑,所有接口中都包含了param1
参数的处理逻辑。如让接口提供一个format参数来实现format=xml
这种统一格式转换,这种格式可让RESTful接口支持额外数据格式;又如让接口提供一个language参数来实现language=cn
的轻量级多语言场景(实际场景有可能不这样设计),这种情况下,所有接口共享逻辑的部分就可以开发单独的处理器(Handler),只需匹配param=value
捕捉对应参数名即可——此时,xxxWithRegex
就更有用了。
2.3.3. Route延迟绑定
目睹了Route对象的创建过程,本章节我们讨论Route中的各种附加设置,设置主要分以下几类:
路径设置(纯模式/正则模式)
HTTP方法设置
启用/禁用路由
顺序设置(Order)
偏好设置(Consume/Produce)
处理器设置(Handler),包括标准、同步、错误三种
若创建Route对象时调用了无参的API(如route(), get()
等),系统中会创建一个空的Route对象,该对象未设置任何信息,打印它会看到如下内容:
打印的内容实际是Route对象中的io.vertx.ext.web.impl.RouteState
对象,它包含了新创建的路由状态,对RESTful开发而言,读者需注意:
空路由的匹配路径为
/*
,它表示匹配该地址下所有路径。若HTTP方法为空,则表示它匹配所有合法方法,如:GET、POST、PUT等。
默认的路由顺序为1(order=1)。
剩下的所有内容都和延迟绑定相关,简单说:创建好Route对象过后,对它进一步设置——也是在修改Route对象中的RouteState内容。
1)路径设置
路径设置分两种:纯模式和正则模式,可直接调用path(String)
或pathRegex(String)
,参考代码段:
当一个空Route对象设置了新路径,原始的/*
地址将会失效,每执行一次路径设置,都会实现路径重绑定;例如运行示例代码中最后一行被注释的代码,那么/api/test
将失效,此时只能发送请求到/api/hello
地址才可。路径设置的两种模式调用会影响状态(RouteState)中的属性:
属性 | path | pathRegex | 含义 |
---|---|---|---|
groups | Ko | Ok | 对正则表达式执行分组,保存组信息。 |
pattern | Ko | Ok | 对正则表达式执行 |
exactPath | ? | false | 根据最终结果计算是否准确路径(不包含正则匹配)。 |
path | Ok | Ok | 该路由绑定的路径信息。 |
Ko全称Knock Out,拳击比赛中打倒对方通常用该术语,本书中表示不支持、否定、不设置等。
注意上述两个Ko
标记,path()
方法在执行过程中,内部也会启用正则模式检测,如果检测到输入路径中包含了特定符号,Route会在内部直接将路由转换成带正则表达式的模式,而状态中的第三个属性exactPath
会受到检测结果的影响,如果检测到路径中是正则模式,则exactPath
为false
,反之为true
。但是path()
中支持的正则表达式只是正则模式中的子集,它仅仅支持部分带参数简易的正则表达式路径。
2)HTTP方法设置
路由创建过后,若不指定任何HTTP方法,那么该路由会匹配所有HTTP方法(全方法),若指定了方法后,则该路由会匹配指定的所有方法,设置HTTP方法的代码如下:
注:上边代码注释中使用的是追加,即method(HttpMethod)
API可以被多次调用,每调用一次,将会追加一个新的可匹配方法到该路由中——打开注释,该路由就会同时匹配GET、DELETE
两种方法。
3)启用/禁用路由
路由对象默认是启用状态,如果要使用编程模式对路由状态的启用/禁用进行切换更改,则可调用Route对象的enabled()/disabled()
方法,这两个API一般用于动态控制场景。
4)顺序
路由状态中的order属性存储了当前路由的匹配执行顺序,该顺序是int
类型,默认值为RouterState
对象中的orderSequence
(初始值为0)。Router对象在计算orderSequence属性时,会生成一个新的状态对象,源代码如下:
每次追加Route对象,Router会调用incrementOrderSequence
方法,此处的orderSequence
标记了当前Router中Route对象的数量。
RouterState中的orderSequence和RouteState中的order有所区别的:
orderSequence更有意义的名称应该是routeCounter,每追加一个Route对象,该值会递增1,并且不重置,即使调用了Route对象的
remove()
方法,该值不会减少,依旧会往后增加。order属性是Route对象的匹配执行顺序,它才是本小节提到的顺序属性,有了该属性,开发人员可将处理器编排成函数链,此处的每一个处理器视为一个Monad,根据业务需要,Monad的执行顺序可以进行前后调整。
5)偏好
偏好一词从Preferences
翻译而来,它起源于内容协商(Content Negotiation,简称conneg),若访问的RESTful资源存在多个不同的表述形式时,客户端可以筛选一个最好的出来;通常用于标明媒介类型的偏好(如支持JSON或XML两种数据格式),有时候也可用于标明本地化语言、字符编码、压缩等偏好。HTTP协议中指定了两种类型的内容协商:
服务器驱动(Server-Driven)——使用请求头执行选择。
代理驱动(Agent-Driven)——使用不同的URI执行选择。
此处的偏好调用consumes(String)/produces(String)
方法实现,可设置内容协商中的媒体类型协商(Media Type Negotiation),JSR规范中的@Consume
和@Produce
与此对应。实现RESTful客户端时,该客户端要向服务器标明自己的偏好和能力——它能处理的表述格式、偏好的语言、可识别的编码、是否支持压缩等,一般会使用Accept
请求头进行设置,它的格式如下:
上述请求头定义了两种媒体内容,之后的q
标明了客户端的偏好程度(权重),而*/*; q=0.0
表示该客户端只能处理前两种格式,而忽略其他格式,q=0.0
就表示不支持。从HTTP 1.1开始,q
值允许小数点之后三位,取值从0.0(无法接受)到1.0(最为理想),不仅如此,并非所有的服务器都支持q
参数。Vert.x中按下边代码执行限定:
上述两个API会修改RouteState中的两个集合变量(java.util.Set
)produces和consumes,这两个变量元素类型为io.vertx.ext.web.MIMEHeader
,每调用一次,则追加一种类型到该集合中,并非替换。
6)处理器
Route对象中最核心的设置是处理器(Handler)设置,先看代码:
处理器是一个路由中的核心组件,常用lambda表达式来完成核心逻辑的编写,后边章节我们会演示复杂系统中的路由管理器的写法,启动服务器过后,上述代码会在接受GET /api/hello
时执行,但由于此处并未书写响应代码,请求会一直挂在客户端,只是从服务端的控制台可以看到Hello!
的输出。Route对象的处理器位于请求周期,即在Vert.x启动时它并不会执行,而是真正在接收请求时才执行,Route对象有三种API来设置不同类型的处理器:
handler
:标准方法,设置标准异步处理器。blockingHandler
:设置同步处理器。failureHandler
:设置异常处理器。
若一个Route路由对象不设置处理器,请求发送时会抛出404 Not Found的异常,等价于该路由失效或未部署。
处理器在Route对象中有一个限制:Route对象的顺序属性(order)不可以在处理器部署之后改变,倘若之后执行修改,则会遇到下边代码中注释部分的异常:
到这里,读者已经接触了vertx-web
项目中的核心对象Router/Route
结构,接下来我们经过一系列实验验来分析它的工作原理,透过对原理的解析,进一步理解Vert.x中的路由。
3. 多维路由
本章节集中分析三个处理器方法的编排逻辑,并通过几个实验讲解Vert.x中的路由编排原理,之所以叫做多维路由,是因为当你俯瞰整个Vert.x路由管理部分,会发现此处的整体结构并不是单纯的Router/Route
结构,它包括更复杂的编排模型。
3.1. 多个路由器
从代码server.requestHandler(router)
可知,一个HttpServer实例只能绑定一个Router
路由器对象作为请求处理器,而此处创建的HttpServer实例并不是实际运行的HttpServer实例,因此假设这样一个实验:
创建两个HttpServer共享同一端口。
创建两个Router对象分别绑定。
两个Router中各自创建一个Route对象,且共享路径。
每个Verticle部署两个实例(
instances = 2
)。
参考代码:
多次发送请求GET http://localhost/api/one
,可以看到如下输出(每次请求只会输出一行信息):
将代码修改一下,处理器内部代码追加响应处理:
输出会变成:
上述实验可知:一个Verticle实例启动时可以创建多个HttpServer实例,该实例并不是实际运行的HttpServer实例,但它内部引用了实际运行的HttpServer实例(host + port
标识)。这个被创建的HttpServer实例能绑定唯一路由器作为请求处理器——这种行为并不违背一对一原则,此处创建方式可理解成镜像模式——两个HttpServer实例共享同一个实际运行的HttpServer实例,两个Router路由器中的路由映射到同一个地址中。如果部署了多个同类型的Verticle实例,每一个启动线程都会创建一个HttpServer实例和Router实例(第一个输出中可看到hashCode不同),二者之间直接一一绑定,当客户端发送请求到服务器时,Vert.x默认采用内部顺序轮询算法来选择路由执行线程(第一个输出中的hashCode是有规律的),最终构造的路由结构图如下:
此处之所以会出现线程0和线程1是因为在部署Verticle过程中我将instances
参数设置成了2。
根据结构图可知,此处实际创建的Router和HttpServer实例各有4个,但这4个实例来自两个不同的维度:第一个维度是部署Verticle时设置了instances = 2
,第二个维度是一个Verticle创建了2个Router,不论最终结果如何,由于计算的ServerID是一样的,使得实际运行的HttpServer实例唯一。由于Vert.x在编排Route路由过程时维度会出现变化,并不是线性方式创建和管理路由,所以才使用“多维”一词,而这个实验得到一个结论:一个实际运行的HttpServer实例中可以绑定多个Router实例。
3.2. 镜像路由
第二个实验我们来研究一个路由器下的多个路由管理,之所以称为镜像路由是此处我们将会设计两个绑定了相同URI的路由定义(同方法、同路径),等价于创造两个完全相同的路由。先看代码:
上述代码中在一个Router对象中创建了两个路由route1和route2,如果向服务器发送请求GET http://localhost/api/two
,可以在浏览器中看到和服务端控制台同样的输出信息:
此次请求中route1路由中的处理器执行了,而route2路由中的处理器并没执行。读者可能会觉得特别奇怪,第二个镜像路由中代码为何没执行?参考路由链结构图:
第二个路由没执行并不是因为没有部署上去,而是请求中并没有执行注释掉的res.next()
代码,将该注释打开,再执行请求,可得:
客户端得到的响应信息依然是:
Hello! 1015022929, First, vert.x-eventloop-thread-1
。控制台可以看到如下输出:
route2中的处理器代码已经执行,并且执行线程和route1是同一个(位于同一个Verticle实例中)。
上边的改动使路由链结构图有了变化:
3.2.1. 重复响应异常
别急,实验还没完,在原始代码中修改两段内容,调用failureHandler
为每个路由追加一个异常处理器,代码如:
更改完后再发送同样的请求GET http://localhost/api/two
,您就可以得到如下输出:
Response has already been written
的异常信息和HTTP服务器本身有关,在HTTP服务器中,处理请求采用流模式,处理流数据会有两个基本限制:
如果请求数据位于HTTP请求体(Body)中,这个数据只能被读取一次,再读取会抛出重复请求异常。
同理,响应流也只能执行一次,已经执行过一次响应后,再执行第二次响应,会抛出重复响应异常。
执行过程中如果出现了上述两种情况就会抛出异常java.lang.IllegalStateException
。Vert.x编程中,在Route对象上如果不设置异常处理器(调用failureHandler
)则异常会直接被吞噬,我想了很多词汇来解释这种现象,个人最钟意的是吞噬——其实是在异步执行过程中,异常被忽略或隐藏掉了(就像上一小节的写法),这种隐藏在Vert.x开发中会给很多开发人员带来困惑,使得它们在很多场合百思不得其解。所以要求您在Vert.x开发设计中遵循一个原则——设计趋近于全函数的容错系统。异常被吞噬并不代表系统内部有错,在某些编程设计过程中,异常有可能会作为一定程度的逻辑而存在,为了让程序适当优雅,某些错误是可以被容忍的——并不是所有的异常都需要打印(仅限调试模式)或执行日志记录。
3.2.2. 异常处理链
回到示例程序中,请大家思考一个问题,异常是哪一个处理器抛出的?从日志Failure, First, 1117182597, vert.x-eventloop-thread-1
可知异常来源于线程1中的route1。然后呢?为什么?问题来了吧!Vert.x中抛出异常的位置都是固定的,而捕捉异常的处理器会受到异常处理链的影响,将上边的图展开:
从图示可以知道,真正抛出异常信息的位置是route2中第二次调用response.end(message)
时,打印信息给了读者一种错觉:route1的异常处理器被触发了,所以异常信息理应由它抛出——这个结论是不严谨的。
本章中的例子比较特殊是因为两个路由route1和route2共享了同一个URI(路径和方法相同),实际开发过程中往往不会如此拙劣设计,路由链是Vert.x中开发和设计的难点,提供这样的例子只是让读者对Route对象的工作原理有更深入的了解,可理解为:不可放在生产环境中的概念代码。
Route对象本身的异常处理顺序(order参数)和路由链的触发顺序一致,排在前边的异常处理器优先触发。
中断匹配触发:在捕捉了异常信息后,异常处理器会执行,倘若异常被拦截,后续的异常处理器不再执行;其中,异常是否被拦截取决于开发人员如何部署匹配代码,如
instanceof
操作、反射等。是否部署?触发异常处理器的前提是开发人员为Route对象部署了异常处理器,若未部署,异常会直接被吞噬,为Route对象部署异常处理器可直接调用
failureHandler
方法。
上述现象引起了一个Vert.x中Route对象的设计哲学:如果route2中的异常由route1中的异常处理器观察,这样设计的意义何在?
第一,本章节的例子本身有问题,route1和route2两个路由共享URI在实际开发场景中不多见甚至不允许。
第二,例子中并没有在异常处理器中执行匹配判断,生产环境中的异常处理器应该为特定异常而定制,这种情况下,异常处理器内部会存在判断来辅助调试以及容错,什么异常应该捕捉,什么异常应该吞噬这些都比较讲究,这一点是真正开发中应该考虑的点。
第三,异常处理器和路由本身的执行代码应该是双子星一样的关系,简单说,异常处理器中处理的异常应该是直接从标准处理器中抛出的异常信息,二者理应构成一个对错的闭环。
3.2.3. 解决重复响应
回到之前的异常信息,Vert.x的Route对象在处理异常时会诱发两种常见的重复响应异常:
这种异常就是示例代码中出现的重复响应异常:
此异常的核心原因:第一次发送了响应流之后,第二次再发送响应流(再调用
response.end(message)
)。异常本身不会从route1(第一次调用响应的路由)抛出,而是直接从route2路由中的
response.end(message)
抛出。出现此异常还有一个前提条件和
res.next()
相关,当处理器中调用了res.next()
方法之后,Vert.x会检查下一个处理器——不管下一个处理器是来自同一个路由还是另外一个可匹配路由,下一个路由必须存在,如果不存在则没有机会第二次调用response.end(message)
方法。
这种异常是另外一种重复响应异常,Vert.x中也很常见:
此异常的核心原因:第一次发送了响应(route2)之后,继续往下一个处理器执行时,结果检查不到下一个处理器或检查到已经调用过
response.end(message)
的处理器而抛出——可简化为:处理器链上的最后一个处理器继续调用了res.next()
方法导致!异常本身同样不会从route1抛出,也是直接从最后一个处理器route2抛出。
这个例子需要修改部分代码:
修改过后的结构图如下:
浏览器中打印的信息是:
总结一下,此处会有两个防止重复响应的黄金法则:
一旦生成过响应流了之后,不可第二次生成,它包括调用
end, write
等。最后一个处理器不能调用
res.next()
,此处查找下一个会遇到无法执行的情况。
最终为了解决重复响应的异常,可参考下边代码:
到此,关于镜像路由的实验就告一段路了,相信读者可以从上述实验中得到更多的启示,在实战开发过程中,这样的设计是不推荐的,通常不会在同一个路由中设计两个不同的Route对象来执行,若针对同一个URI要执行多次处理,可采用接下来章节的处理器链结构,而不是在同一个URI中创建多个路由处理。正如handler
方法的注释中作者对处理器的推荐用法:
You should add multiple handlers to the same route object rather than creating two different routes objects with one handler for route.
3.3. 处理器链
第三个实验我们来研究处理器链的结构,Vert.x中的每一个Route对象都支持一个或者多个标准、异常处理器,此处的处理器是两个列表集合类型——部署顺序是有讲究的。先看一段代码:
直接运行例子,发送请求:GET http://localhost/api/three
,浏览器中看到的就是Finished
信息,服务器控制台可看到如下信息:
一个Route对象中的处理器链不难理解,而设置处理器链的目的是保证每个处理器维持软件设计过程中的单一职责原则,不让某个处理器的逻辑太复杂。上述两个注释点标注了异常信息,打开注释测试则可以看到前一个章节提到的两种重复异常,读者可以尝试并加深印象。
3.4. 子路由器
Route路由还支持子路由器(SubRouter),这是一个非常可怕的设计,前文中的章节我们已经看到了Vert.x路由器和路由的各种不同维度,有了子路由器过后,如同在某一个维度平面中由一个点拉开了另外的一个完整的Router维度空间,虽然它存在一定的规则限制,但却大大增强了Vert.x路由器的用法。
Route路由支持子路由器的限制规则如:
路由本身的路径必须是通配结尾,类似:
/api/hello/*
这种,不可以是准确路径。该路由可以支持类似
:name
这种格式的参数,但路由本身不支持正则模式的路径,:name
这种格式只是正则模式中的一种。在这种Route对象上,不论是之前还是之后都不可以注册其他的处理器。
每一个Route对象只能拥有一个子路由器,它们一一对应。
先看一段示例代码:
直接运行例子,分别发送请求:GET http://localhost/api/sub/name
和GET http://localhost/api/sub/code
,您将在浏览器中看到不同的信息输出,前者Name,后者Code;反向思考一下,如果违背子路由的基本规则,会出现什么情况呢?
3.5. 多维结构
Vert.x中路由器Router和路由Route相关的知识点到此就讲解得差不多了,接下来把所有的实验综合到一起做个简单的总结,顺带绘制出完整的多维结构图,加深读者的记忆。Vert.x中的路由管理牵涉下边几个维度:
Verticle实例的线程维度:使用instances参数控制。
服务器和Router路由器的一对多维度:一个服务器中可支持多个Router路由器(已验证)。
Router路由器下管理的镜像路由维度:一个Router路由器按顺序编排Route路由(已验证)。
Route路由中的处理器链(已验证)。
Route路由下维度展开实现子路由器(SubRouter)的支持(已验证)。
根据上述几个维度,可得到下边这张总结构图:
于是Vert.x中的多维路由图就可以按照上述结构理解,结合本章实验结果以及最终分析的结构,相信读者已经可以完整掌握Vert.x中的路由器和路由对象了,而一旦您掌握了这两个对象以及相关知识,那么就可以在实际开发过程中开发属于自己的路由管理器了。而走到此处,为什么叫做多维路由——相信读者心中已经有了答案。
4. 路由管理器
Vert.x中已经有了路由器Router和路由Route,为什么还需要路由管理器?它的作用是什么?本章将逐一解答这些问题。多维路由是比较强大的工具,它给读者提供了一个星空,如何将这些星星连接在一起构成具有意义的星座就是开发人员的事。
本章以Zero思路为例,分享部分路由管理的开发心得。
4.1. 起源
在开发RESTful中,若需要的路径很多,如何管理运维将成为一个大问题,很多新手参考官方文档往往把所有的代码都堆在某一个Verticle的start()
方法中,到最后,这个方法中的代码逐渐堆积起来,变得十分冗重,这种按需增长的模式注定导致开发维护难度增加,所以需要开发路由管理器。
路由管理器的开发分两步:
将RESTful路径分类,拆分到不同的Verticle中部署,每个Verticle只负责所有URI的一个子集。
在部署处理器时(调
handler
),将lambda表达式模式转换成组件模式,代码逻辑封装到组件内部。
第一步和系统设计有关,从整体业务需求先把业务拆分开,可独立的业务资源分包在某一种Verticle中,走开发视角,仅考虑Verticle实例的部署数量(instances参数)。第二步是本章要开发的路由管理器主体内容,先看一段代码:
如果开发Verticle时在start()
方法中写上这样的代码来部署RESTful接口,业务简单这样的办法是很不错的;可上边代码段中只出现了四个路径,并且处理器中的代码没放出来,假设逻辑很复杂,此处代码就会很长,到最后方法就变得十分笨重。路由管理器会调用Vert.x中的Router以及Route,辅助我们编排、管理、调试、驱动整个RESTful的路由管理,设计过程中先问自己几个问题:
如何将不同的路由分离,面向接口统一开发维护,并分离不同路径下的实现层?
是否引入过滤器、验证器、转发器等更多请求处理的专用组件?
如何更规范地适配RESTful中提倡的指导规范?
如何设计更合理的异常链,开发完善的容错系统?
如何配合序列化子系统协同工作,完成请求的规范化?
以上是设计过程中应该思考的部分问题,带着这些问题,您再回到上述代码中,实例中的骨架和结构肯定是不够用的。
4.2. 开发思路
现在开始,我们来设计一个相对完整的路由管理器,这个结构也可提供给Vert.x初学者参考,让它们可以将这种思路应用于实战中。
4.2.1. 路由存储器
假设有代码:
我们先把上边写过的代码数据化,Vert.x中路由器管理的Route对象实际是路由的定义信息,并不是路由本身,数据化过后的结构可如:
从图可知,数据化过后的路由定义信息可以由路由存储器管理维护,本小节会开发一个路由存储器,有了它,路由的构造就变得更加平滑。实际项目中,路由定义数据可以放在不同的数据源进行管理:
如示例,直接使用硬编码的方式编写路由,此种情况几乎不能称为存储管理,也是演示代码常用的方式。
文件系统存储:将定义数据存储在某种格式的配置文件中。
数据库存储:将定义数据存储在数据库中。
Annotation:使用Java 5的Annotation对代码进行注解,存储在元数据中,启动时反射读取,Zero框架采用这种方式。
配置中心:将定义数据存储在配置中心如ZooKeeper、Etcd等,这种模式可用于云端环境。
第三方接口:从第三方接口中读取配置数据,而弱化存储介质。
整个路由存储器组结构如下:
这是一个非常简单的结构,有了它,路由定义信息就可用不同的数据源存储,实战中您可以根据自己的需要对存储源进行设置,此处提供抽象类的目的是让它帮忙完成一部分共享逻辑(Template设计模式),祛除重复代码。
RStore接口定义如下:
接口定义中每个路由的定义信息使用了io.vertx.core.json.JsonObject
的简单数据类型,实际开发中推荐这部分使用自定义类,自定义类可引入部分更加实用的Route对象设置逻辑,如此,构造Route对象会变得更加简单。定义一个新类对定义数据规范化:
此处consumes和produces对应JSR规范中的javax.ws.rs.core.MediaType
类型,有一点心得可以分享:使用Java语言开发时,设计系统尽可能把概念部分对接到JSR(Java Specification Requests,Java规范提案)规范中,这样的设计有助于提高系统的复用性。有了上述自定义类型,就可将接口方法改成:
RRecord中的RAction在后续小节来讲解。
4.2.2. 组件缓存
我们在整个结构中设计了很多组件来完成构造任务,但组件本身不宜过多,组件本身不会存储数据,只会做逻辑和控制,所以需要一种方法减少组件的构造数量。此处很多开发者很容易想到单件的设计模式(Singleton),但我不推荐如此做,此处需要一种变化的单件模式。
在环境中创建一个工具类:
此工具类可以帮助我们在内存中创建组件缓存,实现一个基于线程的池化单件模式——每个线程拥有一个唯一的组件。因为Verticle在部署过程中会根据instances参数部署不同的实例,这个参数会决定最终Event Loop或Worker线程的数量,若直接使用单件模式,此处会出现线程安全问题,所以池化单件有可能是最好的一种选择。
有了工具类,主代码就可以写成如下:
有了组件缓存,启动Verticle时就能随意书写,获取RStore时不能代码执行多少次,都会一直维持该线程上的一个单件的RStore组件引用。
4.2.3. 路由构造器
我们已经从路由存储器中读取了路由定义数据,接下来进入路由管理器的主流程——构造Route对象,您可以根据实际需要对不同的部分进行构造(参考前文路由设置),也可以定义统一接口来构造,为保证路由管理器的扩展性,先更改主代码:
定义路由构造器接口:
该接口接收两个参数:
路由对象
io.vertx.ext.web.Route
。路由定义数据记录对象
RRecord
。
实现部分的分类可根据实际情况而定,Zero中将路由构造流程分成了三步:
基础构造,设置
order, path, method
三个属性。偏好构造,设置
consumes, produces
三个属性。行为构造,设置
handler, failureHandler
处理器。
基础构造:
偏好构造:
上述示例代码演示了面向接口RAim
的路由构造前两步流程,如此,主代码内部会演变成Builder模式,虽然放弃了Fluent写法,但这样使路由本身的扩展变得更容易,如果想要在特定路由中添加某些功能,直接更改RAim中这两步的实现即可。行为构造会涉及新定义的接口RAction
,将在下一小节详细说明。
4.2.4. 行为构造
行为构造会设置Route对象的两种处理器:标准处理器和异常处理器,到目前为止我们都没有谈到同步处理器,是因为同步处理器和标准处理器在实际使用中最好二选一,如果处理器中需要使用某些复杂运算和长时间执行逻辑,则可以将标准处理器替换成同步处理器,读者可以自己修改RRecord
数据结构引入新属性用来判断是使用同步处理器还是标准处理器,此处就不展开。
行为构造在路由管理器中往往会采用反射,定义新接口来做行为分流,RAction的定义如:
该接口中的handler()方法会生成处理器的内部逻辑,实现层可根据自己需要来编写,如此,行为构造器的代码如:
此处写法比较多变,是因为行为构造器中还可能包含验证、过滤、规范化等复杂逻辑。改写过后最终的主代码如下:
到这里读者可能会有一个困惑,本章学过的res -> {}
的处理器核心逻辑代码放哪儿去了?这部分被封装到RAction的构造中了,至于这里如何去构造处理器,是RStore接口在fetchAll中书写的逻辑,读者可以参考下图根据骨架代码去自己编写(图中灰色部分)。
4.3. 设计心得
本章开发的路由管理器大部分思路借鉴了Zero中的玩法(类名有更改),只是Zero中的RStore部分使用了Java的Annotation注解实现。这个路由管理器只是一个简易脚手架,并没有牵涉太多功能点,但其扩展点特别多,而且易于维护,此处提供几点心得:
组件化设计:使用组件化思路替换掉过程代码或面向对象的复杂代码,如本例中牵涉的组件
RStore、RAim、RAction
,组件本身职责单一就意味着它内部核心代码量会特别少,这样少的代码量会易于扩展和维护;唯一要注意的是组件在Verticle中的使用尽可能打开组件缓存以减少资源开销。如果您对Route路由熟悉,那么就可以考虑将它所包含的可设置信息进行分步部署,如例子中拆分成基础构造、偏好构造、行为构造,这样的结构是的每一种构造只覆盖某一部分相似功能,遵循了软件设计中的单一职责原则,并且您可以发现,有了构造器的出现,主代码的代码量大幅度减少。
行为扩展:示例中仅仅提供了标准处理器和异常处理器的行为,如果您需要增加过滤、验证等复杂行为,可以考虑使用前文提到的处理器链,然后为不同的代码逻辑设计不同的RAction实现。由于此处的RAction都会返回相同代码,也就意味着您可以模拟Monad用函数式思维进行设计和开发。
5. 小节
在此对本章内容做个小结:
深入理解Vert.x中的Router路由器和Route路由对象。
通过实验让读者理解Vert.x中的多维路由结构。
开发了一个简易的路由管理器,读者可以从中得到启发。
本章一直在使用一个接口io.vertx.core.Handler
,这个接口目前只用在了Route路由对象中,从包名您可以知道这个接口不属于vertx-web
项目,而是vertx-core
中的内容,下一个章节就让我们管中窥豹来解读它的内容。
[^1]: Uniform Resource Identifier,统一资源标识符。
最后更新于