3.2.HttpServer初赦
Vert.x内置了HTTP服务器,而且封装的是纯异步Java服务器Netty,那么读者也许会有一个疑问:HttpServer的实例究竟是什么时候创建的,如果只是纯粹地编写,在哪里实例化都不影响,而本章就尝试把这些内容讲透,让大家对Vert.x中的HttpServer有一个更加深入的理解;官方的vertx-web
项目中,创建该实例的位置是在一个Verticle
中,那么我们做个简单的探索,看是不是只有在那里才能创建它。
从进入Web的章节开始,我们将使用Vert.x的Web子项目,这是我们引入的额外角色:vertx-web
;它需要您在Maven
项目的pom
文件中引入下边的配置:
若您使用的是Gradle
,则需要在您的build.gradle
中引入:
1. 开胃菜
1.1. 启动
在您的主函数中写入下边代码:
「Exception」运行的时候您会遇到下边的错误,这是本人无意间踩到的一个坑:
异常的意思很清楚,那么来个完整版:
当您在浏览器中输入:http://localhost:8099,您将会看到如下截图:
后台的Console中依旧是两行,为什么打印两次在第一章中说过了,这里不重复。
实际上结合Vert.x的官方教程,可以得到一个结论:
在哪里创建HttpServer可随意,只要您可以拿到一个Vert.x实例的引用,您就可以调用它的createHttpServer()来创建对应的HttpServer的实例,而在Vert.x中,创建和启动一个HttpServer的实例往往是在一个Vertcile里面进行,为什么呢?
是的,在我个人看来,实际上是没有影响的,但从Vert.x的本质上看,可以把这个问题当做一个趣味性思考。回到原始的话题,Vert.x是一个工具集,它本质的设计和Restful无关,和Web服务无关,和HttpServer更加没有直接的联系,应该这样描述:“这个工具集具备了作为Http服务器的功能”,从这种意义上说,在Verticle中创建HttpServer的实例就是理所当然了,也可以说使用Verticle是打开Vert.x工具集的正确方式,所以回头看上边的代码,很幼稚,幼稚得连Vert.x的入门代码都谈不上。对比下边两幅图来思考一下Vert.x的作者的原始意图:
上边代码是左边这种方式,而官方教程中创建HttpServer的实例则是右边这种方式,虽然在Verticle
实例中创建HttpServer同样要调用createHttpServer()
的API,从某种意义上讲,右边的方式更加优雅,这里列举我个人的思考点做参考,则本质上没有对错之分。
如果直接在main
函数中通过Vertx创建HttpServer
实例,本质上跳过了Verticle实例的创建,也就是说你在用一种没有Verticle的方式使用Vert.x,Verticle
实例的本意就是为了让所有的东西能够平行扩展,并且组件足够小到可以实现“单一职责”。从上边的右图可以知道,一个Verticle
实例本质上只做了一件事,要么创建HttpServer
,要么创建Rpc Server
或者其他,而Vert.x也提供了对Vertcile
实例的很好的管理方式,比如可部署、也可撤销、同样支持热插拔,既然有了这样的一种管理模式,直接在main
函数中创建似乎就显得多余了,不仅仅如此,自己管理一些配置以及相关数据时不得不说是噩梦(笔者曾经试过,主函数很重,代码很多而且乱)。所以,注意Vert.x中的HttpServer
的正确打开方式:老老实实按照官方的教程在一个Vertcle
中去创建,而不是直接在主函数中创建。
很多使用过Vert.x中的HttpServer
的小伙伴都知道官方有一段很简化的创建HttpServer
的代码:
这段代码中隐藏了一个开发人员可能比较关心的东西:io.vertx.core.http.HttpServerOptions
,本章节主要针对这个类解析说明,这个类是Options结构中的其中一个,它的目的是服务于HttpServer组件,如果您理解了Options结构,那么理解这个类就会变得轻车熟路。
1.2. 常用API
1.1.1 createHttpServer
Vert.x
的源代码中,我们可以看到createHttpServer
的方法签名如下:
从上述方法签名可以知道创建HttpServer
的实例有两个核心API,一个是带参数(将是本章解析的Options结构),一个不带参数(官方例子),其实不带参数的方法会传入一个默认的Options结构,这是我们在Options章节提到的Vert.x中的常见做法。
1.1.2. listen
直接查看Vert.x中的listen方法源代码,该方法的方法签名如下:
关于AsyncResult
的用法,后边会有相关说明。上述方法签名列表中有几点需要指出:
HttpServerOptions有自己的默认实现,这里主要针对listen的两个参数说明:
DEFAULT_PORT
:在不设置端口号的时候,HttpServer
使用的默认端口号是80:Default port the server will listen on = 80
;DEFAULT_HOST
:在不设置Host地址时,默认值是0.0.0.0
,但是该配置的值不是在HttpServerOptions
中定义,而是在它的父类NetServerOptions
中定义的:The default host to listen on = "0.0.0.0" ( meaning listen on all available interfaces )
;
「不设置HttpServerOptions」:如果创建
HttpServer
时不传入HttpServerOptions,则直接使用系统默认的HttpServerOptions
中的定义,但是方法优先,在调用listen
时传入了port或host则以listen传入参数中的值为主。「设置HttpServerOptions」:如果创建
HttpServer
时传入HttpServerOptions,则直接使用传入的HttpServerOptions
中的配置,同样是方法优先,调用listen
时传入了port或host则还是以传入参数值为主。
整体的配置优先级可归纳为(从高到低):
listen( port / host )
HttpServerOptions(createHttpServer传入)
HttpServerOptions(默认)
实际上listen方法只能设置host和port两个配置信息,HttpServerOptions其他配置信息还是要通过createHttpServer的方法来传入,而方法createHttpServer(HttpServerOptions)
是开发人员在“编程方式“中唯一进行自定义配置的位置(使用命令行方式启动Vertx实例除外)。
「Exception」在不提供port和host直接启动Vert.x的过程中,您也许会遇到下边错误:
出现上述信息过后,证明HttpServer实际上是没有启动成功的,在Unix体系的操作系统中,使用非root账户设置的Web Server的端口号不可低于1024(不包含1024),而Vertx创建的HttpServer实例的默认端口号为80,所以这种情况下回看到上述错误信息,解决上述异常的办法很多,这里介绍几种解决办法:
将您的账号权限提升,使用root账号启动该程序。
调用listen方法时传入大于或等于1024的端口号。
为应用程序单独设置用户的ID使它具有root权限,这个方法可以使得程序像root用户一样执行,不过有可能会带来安全上的风险(使用chown/chmod命令)。
关闭selinux,不推荐,并且该方法我没有验证过,不知道是不是对所有linux系统都适用。
设置服务器的端口转发规则,而服务本身依然运行在高于1024的端口中。
有些Linux系统支持能力的概念,即:普通用户也能够做只有超级用户才能做的任务——包括使用端口,这种情况下可以直接打开该用户的端口绑定能力,设置
CAP_NET_BIND_SERVICE
注意该方法不是所有的Linux系统都使用,内核在2.1之前的系统中并没有提供“能力”的概念,所以需要检查该系统是否支持。
另外需要注意的是如果运行的程序是一个脚本,那么该方法是没有办法正常工作的。
上边的方法可以解决您看到的异常信息,但不同的方法请酌情考虑。
1.1.3. close
服务器关闭的专用方法,这个方法有两个签名:
这个方法相对简单,一个异步一个同步,主要就是将当前服务器直接停止。
2. 管中窥豹
这个小节是本章的重头戏,通过对HttpServer的结构分析,希望读者对它彻底了解,虽然在实际项目过程中,我们只会用到listen/close/createHttpServer
等相关的API,但理解了结构过后,就可以知其所以然,并且可以给读者更加不同的视觉,也希望读者对这部分内容理解过后有所收获,在Vert.x中,接口io.vertx.core.http.HttpServer对应的实现类是io.vertx.core.http.impl.HttpServerImpl。
为了方便读者理解,本章节的基础术语如下:
Handler:处理器,执行代码的函数,开发人也可以调用API设置,如实例中使用过的requestHandler。
Channel:通道,很多地方翻译成管道,这里统一使用通道表示。
实例:真正创建好的对象信息,用于描述对象的数据结构,该对象就在当前实例中维护。
引用:Java中引用对象的变量,该对象数据结构可能不在类中。
池:内部哈希表,用于存储内存级别的 key = value 数据结构。
2.1. HttpServer结构
Vert.x中的HttpServer实例的完整结构图如下:
虚线框部分只是按照包对某些组件进行职责分类,将对应的组件摆放在正确的包中,方便读者理解,并没划分组件结构。
HttpServer实例的内部维护了一个Vertx引用,但这个引用类型并非io.vertx.core.Vertx
,而是io.vertx.core.impl.VertxInternal
接口类型,该接口继承自io.vertx.core.Vertx
,是Vert.x框架内部专用接口,它不对外,也不推荐开发人员直接使用该接口创建Vertx实例,有了它之后当前HttpServer实例就可以直接获取Vertx引用;不仅如此,每个HttpServer实例内部还维护了两个上下文引用,这两个上下文的引用类型也并非io.vertx.core.Context
,而是io.vertx.core.impl.ContextInternal
接口类型,同属内部专用接口。两个上下文引用一个是在HttpServer实例构造时初始化,称为创建上下文(creatingContext),另外一个则是在调用listen
时初始化,称为监听上下文(listenContext)。
Vert.x框架中所有内部专用的接口都是以
Internal
后缀命名,这种接口帮助Vert.x组件在框架内部协同工作,不对外,可它的访问控制符是public定义——理论上,可以从外部访问,对开发人员没有限制,但Vert.x编程中,推荐开发人员不直接使用它,所以这种类型的接口并没有出现在官方教程中。
实例内部的VertxInternal引用的就是外部创建好的Vertx实例,每个Vertx实例在创建时会维护两个服务器池(内部的两个哈希表),一个服务器池维护的是HttpServer服务器(ServerID = HttpServer),另外一个服务器池维护的是Net服务器(ServerID = NetServer)。每个HttpServer实例内部还定义了一个额外的HttpServer引用(actualServer变量)用来表示真正运行时的HttpServer实例,这个引用会在调用listen
方法时执行初始化,当我们从外部调用listen
方法,HttpServer实例会调用vertx.sharedHttpServers()
方法去获取HttpServer服务器共享池,并通过ServerID(host + port,主机名 + 端口)去查找池中是否有此实例,若已经存在则直接从池中初始化HttpServer实例(赋值给actualServer变量);HttpServer实例内除了实际运行的实例(actualServer)之外,还存储了当前服务器实例运行的ServerID和端口(id变量,actualPort变量)。
HttpServer实例内部通过Netty提供的通道组(io.netty.channel.group.ChannelGroup
类型)来实现通道的管理,这个通道组的名称为vertx-acceptor-channels,外部listen
方法调用后,HttpServer实例会创建底层Netty中的io.netty.channel.Channel
组件,该组件创建过后会被添加到通道组中维护。HttpServer内部还包含了一个通道连接池,它将Netty中创建的通道组件(Channel)和HTTP连接(HttpConnection)绑定,每一个Netty中的Channel组件对应一个HttpConnection连接,这个池中的数据会在运行时缓存,关闭时(调用close方法)被清空。
HttpServer实例中创建了一个事件循环线程组对象,是VertxEventLoopGroup类型,该类型实现了Netty中的io.netty.channel.EventLoopGroup
接口,它用于维护内部线程,处理事件循环,这个线程组对象会提供给处理器管理器(HandlerManager)进行初始化,管理器会管理真正的处理器(Handler)。从图可知HttpServer实例内部有四种处理器:分别是Http请求流处理器、WebSocket流处理器、Http连接处理器和异常处理器,这四种处理器是HttpServer实例的核心组件,职责如下:
Http请求流处理器/WebSocket流处理器:这两种处理器是开发人员必须设置的处理器,在创建了HttpServer实例过后,调用
listen
之前必须先调用requestHandler
方法或webSocketHandler/websocketHandler
方法来设置处理器,这两种处理器最少设置一个,否则就会遇到异常。——根据官方教程,开发人员应该调用webSocketHandler
来设置WebSocket处理器(S大写的版本),两个API内部是一样的,没有区别。Http连接处理器:连接处理器可以让外部开发人员直接获取HttpConnection的实例,直接调用
connectionHandler
方法设置,在开发过程中内容更偏底层,控制力度更大。异常处理器:异常处理器用于在出现异常时执行,调用
exceptionHandler
方法进行设置。
这四种处理器主要分成两大类:常规处理器和流处理器,其中Http连接处理器和异常处理器是io.vertx.core.Handler<T>
接口类型的常规处理器,而Http/WebSocket流处理器是io.vertx.core.streams.ReadStream<T>
接口类型。这四种处理器必须在调用listen
之前执行设置和初始化。Vert.x编程中,开发人员一定要分清楚启动周期和请求周期两个不同的代码执行时间,Vertcle的start方法是启动周期执行,本章中HttpServer实例的创建、监听都是在这个周期中完成的;处理器部分:启动周期只负责设置处理器,处理器内部的代码只会在请求周期执行,在调用启动周期listen
方法之前,处理器的设置工作必须全部完成。
除此之外,HttpServer实例内部还包含支持SSL的功能模块(由SSLHelper实现)以及健康检查模块(由HttpServerMetrics实现)。
2.2. HttpServer生命周期
HttpServer的结构复杂,从功能上,它主要起到了一个衔接作用,Vert.x中使用的服务器是Netty内置的纯异步Java服务器,HttpServer主要负责就是把Vert.x和Netty连接起来,并且实现HTTP和WebSocket的访问——这也是我们开发RESTful容器的基础。看完了前一个小节中HttpServer的内部结构,那么这个小节我们换一个视角:创建、监听、销毁,从生命周期的角度看HttpServer实例内部执行原理。
2.2.1. 创建
在Vert.x框架中,通过vertx.createHttpServer
方法的调用来实现,传入两个参数:一个是Vertx实例引用,从结构图中可以知道,这个Vertx实例引用会传递给内部的VertxInternal引用并执行初始化;另外一个是HttpServerOptions对象,该对象包含了初始化当前HttpServer实例的所有配置信息;它的构造函数签名如下:
传入的两个参数直接在HttpServer实例内部维护。除此之外,HttpServer在构造的时候还会做两件事:
初始化两个上下文中的创建上下文(creatingContext),创建上下文在初始化时直接调用
vertx.getContext()
实现,它不会创建新的上下文,而是直接从传入的Vertx实例中直接获取上下文;HttpServer实例在创建时还有一个限制:multi-threaded worker类型的Verticle上下文中不允许使用HttpServer,否则会抛出IllegalStateException
异常。构造SSLHelper对象。HttpServer中的SSL功能模块是依靠SSLHelper类来配置,该类会从传入的HttpServerOptions中读取SSL相关配置信息,然后执行内部计算,生成SSLHelper实例。计算内容包括:使用的SSLEngine是什么?证书是什么(keyCert, trust)?是否启用ALPN?这些和SSL相关的内容我们会在后边单独找个章节来详解。
以上就是HttpServer实例在创建的时候所做的事情。
2.2.2. 监听
HttpServer实例最复杂的内部流程是监听(listen方法被调用),这个小结我们就来按步骤详解监听流程它做的事情,监听的上层API接口很多,但内部执行流程是统一的。先看两段代码:
「Exception」分别运行上边两段代码,您就可以在控制台看到如下异常输出:
合法性检查——前文中提到了HttpServer实例内部包含了四种处理器,在listen调用时,会检查其中的两种:Http流请求处理器和WebSocket流请求处理器,这两个处理器最少必须设置其中一个,否则就会遇到上述第一个异常;而listen方法不可以重复调用,重复调用就会遇到上边的第二个异常;——两个异常的区别在于:如果出现了不设置处理器的异常,服务器直接无法启动,程序退出,但若遇到了重复监听的异常,服务器依旧会正常运行,但新监听的端口无效,如示例中最终服务器会运行在8099
端口而忽略第二次监听的8000
端口。
环境初始化——第二个步骤是环境初始化,该方法会先初始化监听上下文(listenContext),监听上下文的初始化方法和创建上下文有些区别,它调用的是vertx.getOrCreateContext()
方法,如果无法获取到监听上下文信息,则会直接创建一个新的上下文环境。监听的环境初始化会做下边几件事:
构造
io.vertx.core.net.SocketAddress
对象,然后解析该对象拿到主机名host(默认是localhost)、端口号port(默认0)。从HttpServerOptions中读取HTTP版本信息,Vert.x中支持HTTP的三种版本:1.0、1.1、2.0;由于Vert.x框架中的Verticle有两种:Agent和Worker,这两种的上下文类型不同,而HTTP 2.0是不支持Worker类型的,所以在读取版本信息时,针对Worker类型的Verticle会直接将2.0版本过滤掉。
利用读取到的HTTP版本信息对SSLHelper模块进行协议设置。
实例初始化——第三个核心步骤是计算内部HttpServer实例引用(actualServer变量),listen方法调用后,当前HttpServer实例会先从Vertx引用中获取HTTP共享服务器池,并对其进行同步加锁。加锁过后第一个步骤是配合HTTP共享服务器池计算共享标记、构造ServerID、获取实际运行HttpServer实例引用:
上图中的最后一个分支(灰色部分)是第一次创建流程,其余两个分支是直接获取流程,两个流程会影响后续计算,如果通过新计算的ServerID可以直接从Vertx实例中的HTTP共享服务器池中读取到HttpServer实例信息,证明该实例已经被创建过一次,这种情况下,计算流程会相对简单:
直接将找到的HttpServer实例引用传给内部actualServer引用。
结合初始化好的监听上下文(listenContext)为该HttpServer实例添加Http处理器(包括上述的四种)。
构造健康检查组件
io.vertx.core.spi.metrics.VertxMetrics
,如果启用了该模块则直接初始化。
倘若没有在HTTP共享服务器池中找到对应的HttpServer实例,那么listen方法会走一个完整的创建流程:
实例化Netty中的服务器通道组,构造一个
io.netty.channel.group.DefaultChannelGroup
实例,名称为vertx-acceptor-channels,默认执行器则使用io.netty.util.concurrent.GlobalEventExecutor
。创建Netty中的服务器启动器对象(
io.netty.bootstrap.ServerBootstrap
类型),并且在创建完成过后,将当前HttpServer实例的事件循环组(EventLoopGroup)作为子组添加到外围Vertx实例的事件循环接收器组中;补充一下,Vertx实例内部的事件循环组有两种:IO类型(线程前缀vert.x-eventloop-thread-
)和ACCEPTOR类型(线程前缀vert.x-acceptor-thread-
),这两种事件循环组的职责有所不同,而在构造事件循环组时,ACCEPTOR类型的线程池大小是1,IO类型(开发人员常用类型)的线程池大小则是配置的。这里提到的启动器对象会将HttpServer实例中使用的事件循环组添加到ACCEPTOR类型事件循环组中,而不是IO类型。调用Vertx实例中的
io.vertx.core.net.impl.transport.Transport
对象设置服务器连接信息,这些连接信息都是在HttpServerOptions中配置。调用SSLHelper的validate方法对Vertx实例执行SSL校验并设置SSL环境信息,如果校验通过,所有的SSL的信息都会执行初始化;读者需要区别SSLHelper的创建和校验步骤,创建SSLHelper是在构造函数中进行,但它并不会初始化SSL的上下文环境,而真正初始化SSL执行上下文是在当前步骤中。
设置当前HttpServer实例的子通道信息(childChannel),包括设置子处理器(childHandler),健康检查设置在这一步初始化。
结合初始化好的监听上下文(listenContext)为该HttpServer实例添加Http处理器(包括上述的四种)。
构造新的
Channel
通道组件,并且将该通道组件添加到通道组中,如果构造失败则标记当前HttpServer实例初始化失败,通过ServerID将它从HTTP共享服务器池中移除。初始化完成过后,若可共享(第一步计算的结果),会将当前初始化好的HttpServer实例添加到Vertx引用的HTTP共享服务器池中。
添加监听器/回调处理器——上边步骤完成后,最早的结构图中的actualServer变量就有值了,而且也整体初始化完成以及更新了Vertx中的HTTP共享服务器池,最后一个步骤就是从actualServer有值开始,其实这个步骤是第三步的后续处理,看这个步骤之前先参考下图:
图中演示了从代码面上真正可以窥见的Channel后续流程,主要包括监听器流程和回调处理流程。通道Channel的创建在listen方法中是异步创建,内部存在三次转换:Netty的Future
到Promise
再到Future
,监听器流程出现在Netty的Future
到Promise
转换过程,它用于监听Channel的创建结果。
如果创建成功,则调用complete方法通知Promise,去触发onComplete最后的回调。
如果创建失败,则调用fail方法,并且将当前HttpServer实例从HTTP共享服务器池中移除。
回调处理器流程中,主要针对当前HttpServer的实际运行实例进行校正(比如actualPort的变化),并且运行监听上下文信息,如果出现错误,则需要将资源清除如关闭健康检查器、设置监听标记等。HttpServer在listen方法调用后,最后一个步骤创建Channel时是异步的,可能读者会担心在方法执行完后无法访问监听端口的信息,因为这个过程会有一个时间差,若想要知道什么时候启动完成,则可以在调用listen时使用异步调用接口(带Handler<AsyncResult<HttpServer>>
参数的方法),这个处理器会在onComplete方法的最后执行,这样您就可以精确捕捉到服务器启动完成的时间戳,设置部分打印日志来标记服务器完成了。
2.2.3. 销毁
HttpServer实例的最后一个生命周期是销毁,直接调用close()方法来销毁该实例,它的内部步骤如下:
读取Vertx实例中的监听上下文信息,根据监听标记(是否监听)来决定是否直接执行最后步骤清除上下文。
获取内部HttpServer引用(actualServer变量),调用引用中的处理器管理器移除对应的Http处理器,这个过程完成后四种处理器都会被直接移除。
执行上下文清除的最后步骤。
移除当前HttpServer实例的CloseHooker。
「Netty中的Pipeline」
在解析HttpServer实例的结构时,我们接触到了Netty中一个核心组件通道(Channel),为了让读者了解Channel的原理,我们一起来看看Netty中的Pipeline^4的原理。讲Pipeline之前先看看Netty中的Unsafe,这里的Unsafe和sun.misc.Unsafe
是不可以同日而语的,它实际上是Netty中定义的io.netty.channel.Channel#Unsafe
,按照官方文档的说法——Unsafe函数不允许被用户代码使用,这些函数是真正用于数据传输操作的,而且必须被IO线程调用。换句话说,真正依赖于底层协议/方案的实现是通过Unsafe封装过后的结果。本节不详细讲解原生的sun.misc.Unsafe
[^3],主要聚焦在Netty的Unsafe中,它在Channel内部定义,所以Channel才是核心。
起源:Channel
叫做起源是因为Channel几乎填充了整个Netty,在Netty中它是通讯的载体,简单说当Netty中的线程需要相互之间协同工作时,Channel就会登场。——但是真正在通讯过程执行逻辑的代码组件是ChannelHandler,Handler通常会翻译成处理器。
其次是姗姗来迟的ChannelPipeline,它又是何物?——其实可以理解为一个ChannelHandler的集合,它是ChannelHandler的容器,而一个Channel中只有一个ChannelPipeline,所有的ChannelHandler都会按照一定的顺序在ChannelPipeline中组织起来。Netty数据和状态的载体一般使用事件对象Event
来存储,如:
传输数据会使用
MessageEvent
;状态改变会使用
ChannelStateEvent
;当Channel执行操作(调用处理器处理时)会产生对应的
ChannelEvent
;
这些Event
对象最终都会发送给ChannelPipeline,最后按照ChannelPipeline对ChannelHandler的编排顺序执行,先看看下边的图
举个例子,一个数据最开始被Channel接受时应该是一个MessageEvent
,如图所示,最终处理后会生成一个数据对象,最终生成一个新的MessageEvent
并且发送给下一个Handler来处理。从上图结构可以知道,Channel真正的通讯执行者是在Handler中来完成的,而核心流程是位于ChannelPipeline(只有它才包含了多个Handler)。
渗透:ChannelPipeline
Netty中的Channel的核心流程实际上是ChannelPipeline
,而它包含了两条线路:Upstream和Downstream:
Upstream:上行,接收到的消息、被动的状态改变
Downstream:下行,发送的消息、主动的状态改变
实际上ChannelPipeline
接口包含了两个重要的方法分别对应上述两条心路:sendUpstream(ChannelEvent e)
和sendDownstream(ChannelEvent e)
;对应的ChannelPipeline中的Handler也包含了两类,并且每个类中都包含了对应的方法
ChannelUpstreamHandler:对应的方法为handleUpstream
ChannelDownstreamHandler:对应的方法为handleDownstream
最终看看下边的图来解释这个过程:
从上图可以看到多了一个叫做ChannelSink
的东西,那么什么是ChannelSink呢?它有一个重要方法eventSink
,这个方法可以接受任意一个ChannelEvent。"sink"的本意是下沉,那么ChannelSink实际上可以理解为Channel下沉的地方,——其实也可以换一种说法:处于末尾的万能Handler。
此处需要注意的地方就是:在一条”流”里,一个ChannelEvent并不会主动去经过所有的Handler,某个Handler中拿到的数据是由上一个Handler显示调用sendUp(Down)stream
产生的,并且在处理完成过后交给下一个Handler。也就是说,每个Handler可以接收一个ChannelEvent,处理结束后若要继续,就显示调用sendUp(Down)stream
主动发起一个新的事件,如果不需要继续处理,那么就在这里结束,只要不主动发起那么即使它后边有对应的Handler也不会执行。——这样的设计会导致整个结构中拥有最大的灵活性,当然对Handler本身的顺序也有严格要求。
终焉:Pipeline解决的问题
综上所述,实际上Netty的Pipeline的机制我相信读者已经有了大致的了解了,那么最后总结一下(这里就不按照引用作者的想法去阐述了):
从Handler这种机制可以看到,Vert.x中使用的就是这种机制,一个Handler和另外一个Handler编连成一个完整的Handler的链,请求经过处理链去执行。被执行的数据在Netty中抽象成了ChannelEvent,这样抽象的好处是在通讯过程中形成了统一的数据规范(相当于有了一个统一的VO——Value Object),那么Handler拿到数据过后就只关心ChannelEvent数据本身,不去考虑底层的事,如同引用文中提到的编码、解码等,Vert.x Web项目中的RoutingContext的Web请求处理链和这种设计就有异曲同工之处。
不仅仅如此,Netty官方有一种说法,它是一个纯异步Java服务器。使用过OIO(线程发起请求)和NIO(Reactor模型)的工程师应该都清楚,这两种模式主要的区别在于编程风格,即便是NIO模式同步和异步的编程风格也很大,所以实际上ChannelPipeline还做了一个事情就是把同步和异步的原理封装在底层了,统一使用了这样一种机制来实现同步异步处理,那么开发人员就可以不去关心这个动作是同步还是异步的。
回到Vert.x中,其实可以看到,在一个Handler的内部,还可以开启第二层异步(此时就需要工程师对异步的编程有一定的基础),在这种情况下,Netty的Pipeline的机制反而容易拆分过后去实现这种纯异步流。异步编程最复杂的地方是找到数据的同步点,只有运行的时候才能捕捉真正的程序数据流,不论使用了lambda表达式还是使用了Future,在Vert.x中编程时候都需要考虑到数据流的问题。
2.3. 小结
到这里HttpServer实例的内部结构和核心生命周期就告一段落了,这部分内容比较复杂,有很多知识点在写本书时并没有拓展出去,不过读者不用担心,若仅做应用开发,可以把这部分内容当做附加内容仅作参考,实际开发中通常只会几行代码去完成HttpServer实例的创建,更多的实战步骤是在下一个小结中,但是了解它的内部结构和原理对您认识Vert.x框架是有好处的。
3. 又见Options
实际开发过程中,和HttpServer实例最相关的是HttpServerOptions
这个类,这是开发人员唯一可以近距离接触的一个类,它提供了HttpServer实例的所有配置信息,本小节对它进行结构剖析和解读,让开发人员可以根据自己的需求配置HttpServer实例。首先HttpServerOptions是一个家族企业,追溯到顶层有四个Options(按继承树):
io.vertx.core.net.NetworkOptions:网络配置
io.vertx.core.net.TCPSSLOptions:TCP/SSL协议配置
io.vertx.core.net.NetServerOptions:网络服务器配置
io.vertx.core.http.HttpServerOptions:HTTP服务器配置
本章主要介绍每种配置的相关细节以及可配的内容,配置数据的原理方面不做过多的讨论,有必要引入的地方我会加上部分解析。
3.1. NetworkOptions
第一个配置是网络配置,它是一个抽象类,定义如:
它包含了以下配置项:
logActivity:是否开启网络日志,基于Netty的Pipeline机制实现。
receiveBufferSize:TCP网络接受缓冲区大小。
sendBufferSize:TCP网络发送缓冲区大小。
reuseAddress:是否启用地址重用。
reusePort:是否启用端口重用。
trafficClass:设置“流量等级”。
这些配置项的属性和默认值对应参考下表(如果是JSON格式,则直接将属性名作为Json节点传入即可。):
3.1.1 IPv6 流量等级
IPv6中的流量等级[^1](又称为“通信量等级”、“流量类别”),存在于IPv6的头部数据结构中,如下(RFC2460):
了解计算机网络的读者可以很清楚地知道,IPv6的头部是IPv4的精简版,它除去了不必要的低频使用字段,并且增加了可以更好支持实时传输的一个字段。
版本Version:该版本指定了IP协议的版本,默认为6,长度4位;不同的IP协议版本会使用不同的数据报格式。
流量等级Traffic Class:长度8位,它表示IPv6数据包的类型或优先级,它的功能和IPv4中的服务类型字段(Type Of Service,TOS字段)很相似。传输类型前边6位是DSCP字段(RFC 2474定义),后2位用于ECN(RFC 3168定义)。
流标签:长度20位,它表示当前数据包是源和目标中的一个,需要中间IPv6路由器进行特殊处理。它用来实现对数据包按照优先级发送,如优先发送实时数据(语音、视频),如果设置为0则表示采用默认的路由处理方法。如果想要将某个数据流量区分出来,中间路由可以通过这组数据包的源地址、目标地址、流标签进行判断,使用非零值即可,详细使用方法可以参考(RFC 3697)。
负载长度Payload Length:16位,它表示IPv6的有效负载长度,包括扩展头部和上层PDU(Protocol Data Unit,协议数据单元),这个值可支持65535字节的有效负载,若超过则该字段会设置成0,并动用逐跳(Hop by Hop)的可选扩展头部中的超大有效负载(Jumbo Payload)选项。
下一个头部Next Header:8位,如果IPv6没有扩展头部,那么这个值的作用和IPv4的上层协议字段一样,如果启用了扩展头部,那么这个值则表示第一个扩展头部或上层PDU的协议类型,它根据是否使用扩展头部其含义会有所区别。
跳数限制Hop Limit:8位,如果数据包转发,那么每台路由器对该字段的值减1,如果减为0则丢弃该包,所以它表示IPv6数据包能经过的最大链路数。这个值有点类似于IPv4中的TTL字段,只是它不像TTL一样可以表示数据包在路由队列中的时间;数据包被丢弃的同时,它还会发送ICMPv6超时信息到源主机。
源地址:表示起始主机的IPv6地址,长度128位。
目标地址:表示当前目的节点的IPv6地址,长度128位,大多数情况这个地址是最终目的地址,如果启用了路由扩展头部,那么目标地址会被设置成为中间地址。
Vert.x中,流量等级默认值为-1,调用setTrafficClass可设置流量等级,它的设置范围只能是0 ~ 255
,一旦越界,调用时会抛出异常。
流量等级在IPv4到IPv6的升级过程中有所变更,最早在RFC791中定义了TOS中的前三位,划分为8个优先级,7和6保留,从5开始依次是:语音、视频会议、呼叫信号、高优先级数据、低优先级数据、尽力服务数据;随着网络在实际部署中的应用,八个优先级远远不够,所以RFC 2474[^2]对它进行了重新定义,前6位是DSCP字段,后2位是ECN字段。
DSCP是差分服务代码点(Differentiated Services Code Point),它有两种形式:数字形式、关键字形式。若使用数字形式,DSCP部分有6位,范围从0 ~ 63
,可定义64个等级;若使用关键字形式,则它用来描述逐跳行为(PHB,Per-Hop Behavior),目前主要分成四大类:BE(000 000)默认、CS(aaa 000)类选择器、EF(101 110)加速转发、AF(aaa bb0)确保转发。逐跳行为选择的时候,ECN字段会被忽略,只要六位匹配,那么就会选择对应的逐跳行为,上述四种类型是IETF(The Internet Engineering Task Force,国际互联网工程任务组)已经标准化的一部分。从应用场景分析,逐跳行为会影响传输,所以针对特定的使用场景,设置该参数是可以提高传输效率的,所以除开Internal业务以外,若要开发一些类似直播、专线、语音相关的网络应用,可以考虑在这个参数上设置。
ECN是显式拥塞通知(Explicit Congestion Notification),它的基本原理如:路由器在出现拥塞时通知TCP,当TCP段传递时,路由器使用ENC来记录拥塞,当TCP段数据到达后,接收方知道报文在某个位置经历过拥塞,然而,需要了解拥塞发生情况的是发送方,而非接收方。因此,接收方使用下一个ACK通知发送方有拥塞发生,然后,发送方做出响应,缩小自己的拥塞窗口。
3.1.2. ReuseX
reuseAddress和reusePort两个配置对应底层TCP套接字(Socket)中的SO_REUSEADDR
和SO_REUSEPORT
参数,直接影响了Socket绑定(host + ip)的成功与否,Socket起源于Unix系统,它的基本哲学是:“一切皆是文件!”。一个Socket的协议是在socket()初始化时设置好的,而在bind()阶段它会设置源地址和源端口,其次会调用connect()来设置目标地址和目标端口,如果在编程过程中,我们将端口设置成0(Vert.x的默认行为),它的语义就是:任意端口,此时系统会选择一个具体的端口绑定,它通过同样的方式选择地址(如IPv4中的0.0.0.0
和IPv6中的::
)。和端口不同的是,系统会选择具体的端口做绑定,而对于地址,只要是本机网络端口所有合法地址,它都可以绑定,而这个过程是发生在请求来临时,因为Socket无法在连接时同时绑定所有合法地址,TCP请求过来时,它会选择一个合适的源地址并且绑定。
默认情况下,任意两个Socket都无法绑定到相同的源IP地址和源端口,若端口号不同,那么源地址实际上没什么关系。实际如下:
这种规则对所有的主流操作系统都一样,但一旦牵涉到地址重用和端口重用,操作系统之间的差异性就体现出来了,由于主流操作系统的Socket实现大部分都参考了BSD,这里主要分析BSD中两个参数SO_REUSEADDR
和SO_REUSEPORT
的作用。参考下表:
假设socketA已经绑定成功,再创建socketB,0.0.0.0 表示任意地址,最终结果就是创建socketB的结果。
实际上SO_REUSEADDR
对通配地址0.0.0.0
会有影响。Socket中有一个发送缓冲区,调用send()函数后,并不意味着数据发送了,而是进入了发送缓冲区中,对TCP协议而言,如果不立刻发送,数据加入缓冲区和真正发送之间会有一个延迟,此时如果调用了close()关闭TCP套接字,可能发送缓冲区中还保留着等待发送的数据。为了不让数据丢失,TCP的套接字会进入TIME_WAIT
状态,这种状态会持续到数据真正发送或者超时,这样的机制让TCP成为了数据可靠协议。
操作系统如何处理TIME_WAIT
呢?如果没有设置SO_REUSEADDR
,一个处于TIME_WAIT
的Socket仍然被认为绑定在源地址和端口,任何其他试图在同样地址和端口上绑定Socket的行为都会失败直到原始Socket真正被关闭。——所以绝大多数在一个Socket关闭后立即将地址和端口绑定到新的Socket的行为会失败。若设置了SO_REUSEADDR
后,这种行为不会失败,但会被忽略,而且设置了该参数,您可以将相同地址绑定到不同的Socket上,虽然当Socket处于TIME_WAIT
时,您做这样的事(绑定相同地址和端口到新的Socket)会导致未知结果,但幸运的是这种情况发生概率很小。最后:只有在您想绑定的Socket上开启了地址重用功能才会生效,它并不会检查已绑定或处于TIME_WAIT
的Socket是否设置了该选项,简单说,就是绑定的成功与否只会检查当前绑定的Socket是否设置了SO_REUSEADDR
参数。
SO_REUSEPORT
的含义与绝大部分人对SO_REUSEADDR
的理解是一致的,只要这个Socket在绑定之前设置了该参数,它允许您将多个Socket绑定到相同的地址和端口。若第一个Socket没有设置此参数,那么后续所有的Socket都无法成功绑定源地址和端口,除非等到第一个Socket关闭过后。而且SO_REUSEPORT
和SO_REUSEADDR
并不等价,只要没有设置它,那么在相同地址和端口上绑定Socket的行为都会失败,哪怕这个原始的Socket处于释放阶段(TIME_WAIT
),所以为了使得其他的Socket绑定成功,就必须设置SO_REUSEADDR
或者SO_REUSEPORT
又或者二者都设。
最后说明一点,一个连接看起来是“协议 + 主机 + 端口”的三元组,但实际上它是被一个五元组“协议 + 主机 + 端口 + 地址重用 + 端口重用”定义的,任意两个连接的五元组不能完全一样,否则操作系统内核就无法识别,而在Vert.x框架中,默认只开启了地址重用,并没有开启端口重用功能,所以它默认使用四元组定义了连接,这个默认值的考虑是基于操作系统的,因为Windows系统上并没有端口重用的选项参数。
3.2. TCPSSLOptions
第二个配置是TCP/SSL配置,它定义如下:
它包含了以下配置项:
tcpNoDelay:设置
TCP-no-delay
的值(TCP中的NO_DELAY
);默认为true,该值为true时禁用了TCP中的Nagle算法。tcpKeepAlive:设置TCP连接的keep alive属性,启用KeepAlive机制,是否为一个长连接;默认为false——需要说明的是KeepAlive并不是TCP协议规范中的一部分,只是几乎所有的TCP/IP协议栈都支持这种机制。
soLinger:设置(TCP中的
SO_LINGER
),它用于设置延迟关闭的时间,等待套接字发送缓冲区中的数据发送完成。usePooledBuffers:设置Netty服务器是否使用缓冲池,默认值为false。
ssl:在连接中是否启用SSL,默认禁用。
sslHandshakeTimeout/sslHandshakeTimeoutUnit:SSL握手时间值和时间单位,默认10秒。
idleTimeout/idleTimeoutUnit:默认的空闲超时时间值和时间单位,默认为0秒;该设置需要和KeepAlive机制进行区分。
useAlpn:是否在TLS协议中使用ALPN协议,ALPN全称为Application Layer Protocol Negotiation,它是TLS的一种扩展,允许在安全连接基础上进行应用层协议的协商,它支持任意应用层协议的协商,目前最多的是支持HTTP2的协商
tcpFastOpen:设置(TCP中的
TCP_FASTOPEN
)选项,默认为false。tcpCork:设置(TCP中的
TCP_CORK
)选项,默认为false。tcpQuickAck:设置(TCP中的
TCP_QUICKACK
)选项,默认为false。
这些配置项的属性和默认值对应参考下表:
上述配置都是简单属性,不牵涉到内置对象的创建,TCPSSLOptions中还包含了SSL相关的一些专用配置类,这些类将在后续章节中讨论,这些复杂属性包括:
io.vertx.core.net.JdkSSSLEngineOptions
io.vertx.core.net.JksOptions
io.vertx.core.net.OpenSSLEngineOptions
io.vertx.core.net.PemKeyCertOptions
io.vertx.core.net.PemTrustOptions
io.vertx.core.net.PfxOptions
复杂属性对应的定义可参考下边表格:
TCPSSLOptions配置中的大部分配置项信息都牵涉到TCP协议和SSL协议,书中提供了尽可能多的TCP协议中的原语如TCP_CORK
,读者可以使用这些作为关键字去查找更多的资料,这里不做说明,SSL协议的部分内容会在后续SSL章节专程讨论,这里也略过。
3.3. NetServerOptions
第三个配置是网络服务器配置,它包含以下配置项:
acceptBacklog:默认值为1024,用于设置默认的连接缓存相关的参数,它一般表示在拒绝额外的请求之前,能接受的连接数。
port:网络服务器的端口号,在NetServerOptions类中设置的默认参数为0,它表示随机选择一个可用的端口号。
host:主机IP地址,默认值为
0.0.0.0
。sni:设置服务器是否支持SNI——SNI全称为Server Name Indication,它用于改善服务器和客户端SSL/TLS的一个扩展。
clientAuth:设置SSL/TLS的模式,在Vert.x中,该属性的类型为一个枚举类型:
io.vertx.core.http.ClientAuth
,它用于描述客户端和服务器之间对SSL/TLS支持的种类——包含了三个值。ClientAuth.NONE:不需要任何客户端认证。
ClientAuth.REQUEST:如果客户端提供了身份验证则接受该认证结果。设置了这种模式后,若客户端没有提供任何身份认证,那么服务端和客户端继续协商处理该请求。
ClientAuth.REQUIRED:严格要求客户端必须提供身份认证,若客户端没有提供,则服务端拒绝该请求。
3.9.x之前的版本还有一个clientAuthRequired,但这个配置现在的版本已经移除了,这里就不讲。
这些配置项的属性和默认值对应参考下表:
SNI全称是Server Name Indication,它是用来改善服务器与客户端SSL/TLS的一个扩展,主要解决一台服务器只能使用一个证书的缺点。
3.4. HttpServerOptions
第四个配置就是Http服务器配置,它包含以下配置项:
compressionSupported/compressionLevel:是否支持压缩流,支持压缩的情况下可设置压缩级别,默认不支持压缩,压缩级别为6。
handle100ContinueAutomatically:是否自动处理
100-Continue
响应头,默认false。maxChunkSize:HTTP Chunk请求最大尺寸,默认值8192。
maxInitialLineLength:HTTP请求首行(
GET / HTTP/1.0
)最大尺寸,默认值4096。maxHeaderSize:HTTP请求头最大尺寸,默认值8192。
decompressionSupported:是否支持解压功能(支持该功能可直接读取压缩流内容),默认false。
decoderInitialBufferSize:HTTP对象解码器的缓冲区初始化尺寸,默认128字节。
acceptUnmaskedFrames:是否接收WebSocket中未标记的帧数据,默认false。
maxWebSocketFrameSize/maxWebSocketMessageSize:WebSocket协议帧最大尺寸和消息最大尺寸,默认最大帧尺寸65536,最大消息尺寸为4帧。
perFrameWebSocketCompressionSupported/perMessageWebSocketCompressionSupported:在WebSocket协议中是否支持单帧压缩,或者单消息压缩功能,默认都为true。
webSocketCompressionLevel:WebSocket数据压缩级别,默认为6。
webSocketPreferredClientNoContext/webSocketAllowServerNoContext:是否允许服务端和客户端无数据响应,默认都为false。
http2ConnectionWindowSize:HTTP2中连接窗口尺寸,默认为-1。
这些配置项的属性和默认值对应参考下表:
3.4.1. 100-Continue
HTTP/1.1协议中设计了100-Continue[^5]的HTTP状态代码,设计这个状态代码的目的是:在客户端发送请求数据之前,HTTP/1.1协议允许客户端先判定服务器是否愿意接受客户端发来的消息主体,这种场景通常用于较大数据的传输。客户端在POST请求数据到达服务端之前,允许双方握手,如果匹配上了,客户端才真正开始发送较大数据。
如果客户端直接发送请求数据,而服务器因为特殊原因又将该请求拒绝的话,这种行为将带来很大的资源开销。
协议对客户端的要求是,如果客户端等待到了预期的100-Continue
应答,那么它发送的请求必须包含一个Expect: 100-Continue
头信息。
客户端策略
如果客户端有POST数据要上传,可以考虑100-Continue,加入请求头
Expect: 100-Continue
。如果没有POST数据,则禁止使用100-Continue,因为服务器可能会误解。
并不是所有的服务端都能正确实现100-Continue,如果客户端发送了这种消息后,在Timeout之内无响应,则它需要立即上传POST数据。
有些服务器会错误实现100-Continue,在不需要该协议时返回100,这种情况下客户端应该忽略。
服务端策略
正确的情况下,收到请求后,返回100或错误代码。
如果发送100-Continue之前收到了POST数据,则跳过发送100-Continue响应的流程。
3.4.2. 分块传输
当客户端向服务器请求一个静态页面或图片,Content-Length是可以直接计算的,这种情况下能直接传输数据;但是某些场景下,服务器的响应无法确定信息大小,此时Content-Length就无法写入长度,而是在执行过程实时计算消息长度,此时就需要使用Chunked编码实现分块传输。分块传输编码(Chunked Transfer Encoding)是超文本传输协议中的一种数据传输机制,它可以让您在传输数据时将发送给客户端的数据分成多个部分。
Chuncked编码使用若干Chunk串连而成,由一个标明长度为0的Chunk结束,每一个Chunk都分为头部和正文两部分,头部会指定正文的字符总数(十六进制)和数量单位(一般置空),正文就是实际数据内容,两部分使用回车换行(CRLF)隔开,最后一个长度为0的Chunk中的正文部分就是通常提到的Footer内容,一般会存储一些附加信息。它的最终数据格式如下:
在执行Chunked编码传输时,在响应头信息中需要包含:Transfer-Encoding: Chunked
,Chunked编码时,Chunk Size的大小是使用十六进制的ASCII码表示,如:86AE,实际十六进制应该是38366165,计算长度为34478,那么CRLF之后有连续34478字节的数据。某些场景中,为了保证计算简单,服务端还会使用空白补充来实现请求中数据对齐,这种多用于固定长度的Chunk传输中。
Chunk的执行过程如:
先判断数据是否使用了Chunked编码,查找
Transfer-Encoding: Chunked
响应头。然后在数据中查找开始标记,第一个Chunk Size开始的地方,通常使用
\r\n\r
(CRLF两次)——一般场景下,就是直接查找HTTP的响应Body部分的数据,在常用的响应数据格式中,HTTP Header和Body之间会有一个空行。用程序解析十六进制的ASCII码数值,并且转换成十进制计算字节数。
获取长度后,查找和当前Chunk相关的数据部分,将数据部分读取出来,然后跳到下一个Chunk中(跳过CRLF)。
重复第3,4步,直到找到Chunk Size = 0的数据块。
**「注」**在解析过程中,有两个地方需要注意:第一是十六进制数在解析的时需要执行转换,第二就是空白字符以及特殊字符对中断字符的影响。
3.4.3. 压缩流
HTTP协议定义了压缩,它是Web服务器和浏览器之间压缩文本数据的方法,它能大大减少网络传输的数据量,提高用户显示网页的速度,当然也会增加服务器的开销。区分内容编码和压缩:在HTTP协议中,可以对数据内容(Body部分)进行编码,当你采用类似gzip的编码时,就可以达到压缩的目的;在某些特殊场景,您也可以使用其他编码方式对内容进行加密、混淆等,以防止第三方看到文档内容。——其实压缩是内容编码的一种,也可以说是内容编码的子集。
HTTP压缩过程如下:
浏览器发送HTTP请求给服务器,请求头中带有客户端内容编码偏好:
Accept-Encoding: gzip, deflate
,它告诉服务器该浏览器支持这些内容编码。服务器接收到请求后,会生成原始的HTTP响应,其中包含了原始响应的
Content-Type
和Content-Length
。服务器会使用gzip,对响应数据进行编码,编码后Header中会重算
Content-Length
(压缩后大小),并且增加Content-Encoding: gzip
,将请求发送会客户端。浏览器收到响应后,根据
Content-Encoding: gzip
对响应进行解码,读取原始数据后呈现。
HTTP中定义了一些内容编码的类型,它也允许使用扩展形式添加更多编码,常用的值如下:
gzip:表示内容使用GNU zip编码。
compress:表示内容使用Unix的文件压缩程序。
deflate:表示内容使用zlib的格式压缩。
sdch:全称为Shared Dictionary Compression over HTTP,Google发明的HTTP压缩算法,通过字典压缩算法对相同内容进行压缩,减少相同内容的传输。
identity:表示没有对内容编码(默认情况)。
其实gzip是一种数据格式,而deflate是一种算法,gzip内部默认使用了deflate算法对数据内容进行压缩。sdch压缩算法由于是Google出的,目前主流浏览器只有Chrome支持得比较好,这个算法现阶段还无法成为主流,但有兴趣的读者可以上网去搜索相关资料。Vert.x中如果打开压缩就直接使用setDecompressionSupported/setCompressionSupported
两个方法来配置。
4. 总结
本文我们俯瞰了整个HttpServer实例,也扩展讲解了某些参数的背景知识和相关意义,在vertx-web
项目中,第一步就是createHttpServer
,而本章节可以作为这行代码背后的知识补充,特别是在不同的应用场景对HttpServerOptions
进行调参的相关处理。拓展出来的知识点主要是应用于实战开发,而和SSL、WebSocket、HTTP2相关的知识点在本章中并没有说明。初探完成后,相信读者对Vert.x中的HttpServer也有了更深入的了解,它也是开发REST的Web服务的基础。
[^1]: 《深入解析IPv6(第3版)》,作者:Joseph Davies,翻译:汪海霖,人民邮电出版社,ISBN:978-7-115-33581-4
[^2]: RFC 2474,https://www.rfc-editor.org/rfc/rfc2474.html
[^3]: 《Java Magic. Part 4: sun.misc.Unsafe》,http://mishadoff.com/blog/java-magic-part-4-sun-dot-misc-dot-unsafe/
[^5]: 《理解HTTP协议中的 Expect: 100-Continue》,https://blog.csdn.net/skh2015java/article/details/88723028,作者:思维的深度
最后更新于