3.2.HttpServer初赦

    Vert.x内置了HTTP服务器,而且封装的是纯异步Java服务器Netty,那么读者也许会有一个疑问:HttpServer的实例究竟是什么时候创建的,如果只是纯粹地编写,在哪里实例化都不影响,而本章就尝试把这些内容讲透,让大家对Vert.x中的HttpServer有一个更加深入的理解;官方的vertx-web项目中,创建该实例的位置是在一个Verticle中,那么我们做个简单的探索,看是不是只有在那里才能创建它。

    从进入Web的章节开始,我们将使用Vert.x的Web子项目,这是我们引入的额外角色:vertx-web;它需要您在Maven项目的pom文件中引入下边的配置:

<dependency>
  <groupId>io.vertx</groupId>
  <artifactId>vertx-web</artifactId>
  <version>4.3.3</version>
</dependency>

若您使用的是Gradle,则需要在您的build.gradle中引入:

dependencies {
  compile 'io.vertx:vertx-web:4.3.3'
}

1. 开胃菜

1.1. 启动

    在您的主函数中写入下边代码:

package io.vertx.up._02.http;

import io.vertx.core.Vertx;
import io.vertx.core.http.HttpServer;

public class DirectServer {

    public static void main(final String[] args) {
        final Vertx vertx = Vertx.vertx();
        final HttpServer server = vertx.createHttpServer();
        server.listen(8099);
    }
}

Exception」运行的时候您会遇到下边的错误,这是本人无意间踩到的一个坑:

Exception in thread "main" java.lang.IllegalStateException: Set request or websocket handler first
    at io.vertx.core.http.impl.HttpServerImpl.listen(HttpServerImpl.java:221)
    at io.vertx.core.http.impl.HttpServerImpl.listen(HttpServerImpl.java:211)
    at io.vertx.up._02.http.DirectServer.main(DirectServer.java:11)

    异常的意思很清楚,那么来个完整版:

package io.vertx.up._02.http;

import io.vertx.core.Vertx;
import io.vertx.core.http.HttpServer;

public class DirectServer {

    public static void main(final String[] args) {
        final Vertx vertx = Vertx.vertx();
        final HttpServer server = vertx.createHttpServer();
        server.requestHandler(handler -> {
            System.out.println(Thread.currentThread().getName());
            handler.response()
                    .putHeader("content-type", "text/plain")
                    .end("Hello Direct Server!");
        });
        server.listen(8099);
    }
}

    当您在浏览器中输入:http://localhost:8099,您将会看到如下截图:

    后台的Console中依旧是两行,为什么打印两次在第一章中说过了,这里不重复。

vert.x-eventloop-thread-1
vert.x-eventloop-thread-1

    实际上结合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的代码:

HttpServer server = vertx.createHttpServer();

server.requestHandler(request -> {

  // 设置针对每个请求的Handler
  HttpServerResponse response = request.response();
  response.putHeader("content-type", "text/plain");

  // 生成请求数据
  response.end("Hello World!");
});

server.listen(8080);

    这段代码中隐藏了一个开发人员可能比较关心的东西:io.vertx.core.http.HttpServerOptions,本章节主要针对这个类解析说明,这个类是Options结构中的其中一个,它的目的是服务于HttpServer组件,如果您理解了Options结构,那么理解这个类就会变得轻车熟路。

1.2. 常用API

1.1.1 createHttpServer

    Vert.x的源代码中,我们可以看到createHttpServer的方法签名如下:

    HttpServer createHttpServer(HttpServerOptions httpServerOptions);

    HttpServer createHttpServer();

    从上述方法签名可以知道创建HttpServer的实例有两个核心API,一个是带参数(将是本章解析的Options结构),一个不带参数(官方例子),其实不带参数的方法会传入一个默认的Options结构,这是我们在Options章节提到的Vert.x中的常见做法。

1.1.2. listen

    直接查看Vert.x中的listen方法源代码,该方法的方法签名如下:

    // 无参同异步方法    
    @Fluent
    HttpServer listen();

    @Fluent
    HttpServer listen(Handler<AsyncResult<HttpServer>> handler);

    // 双参同异步方法 i - port(端口号)、s - host(Host地址)
    @Fluent
    HttpServer listen(int i, String s);

    @Fluent
    HttpServer listen(int i, String s, Handler<AsyncResult<HttpServer>> handler);

    // 单参同异步方法 i - port(端口号)
    @Fluent
    HttpServer listen(int i);

    @Fluent
    HttpServer listen(int i, Handler<AsyncResult<HttpServer>> handler);

    @Fluent
    HttpServer listen(Handler<AsyncResult<HttpServer>> listenHandler);

    关于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则还是以传入参数值为主。

    整体的配置优先级可归纳为(从高到低):

  1. listen( port / host )

  2. HttpServerOptions(createHttpServer传入)

  3. HttpServerOptions(默认)

    实际上listen方法只能设置host和port两个配置信息,HttpServerOptions其他配置信息还是要通过createHttpServer的方法来传入,而方法createHttpServer(HttpServerOptions)是开发人员在“编程方式“中唯一进行自定义配置的位置(使用命令行方式启动Vertx实例除外)。

Exception」在不提供port和host直接启动Vert.x的过程中,您也许会遇到下边错误:

Sep 11, 2018 7:44:41 AM io.vertx.core.http.impl.HttpServerImpl
SEVERE: java.net.SocketException: Permission denied

    出现上述信息过后,证明HttpServer实际上是没有启动成功的,在Unix体系的操作系统中,使用非root账户设置的Web Server的端口号不可低于1024(不包含1024),而Vertx创建的HttpServer实例的默认端口号为80,所以这种情况下回看到上述错误信息,解决上述异常的办法很多,这里介绍几种解决办法:

  1. 将您的账号权限提升,使用root账号启动该程序。

  2. 调用listen方法时传入大于或等于1024的端口号。

  3. 为应用程序单独设置用户的ID使它具有root权限,这个方法可以使得程序像root用户一样执行,不过有可能会带来安全上的风险(使用chown/chmod命令)。

    chown root.root <您的应用程序路>
    chmod u+s <您的应用程序路>
  4. 关闭selinux,不推荐,并且该方法我没有验证过,不知道是不是对所有linux系统都适用。

  5. 设置服务器的端口转发规则,而服务本身依然运行在高于1024的端口中。

  6. 有些Linux系统支持能力的概念,即:普通用户也能够做只有超级用户才能做的任务——包括使用端口,这种情况下可以直接打开该用户的端口绑定能力,设置CAP_NET_BIND_SERVICE

    setcap cap_net_bind_service =+ep <您的应用程序路>
    1. 注意该方法不是所有的Linux系统都使用,内核在2.1之前的系统中并没有提供“能力”的概念,所以需要检查该系统是否支持。

    2. 另外需要注意的是如果运行的程序是一个脚本,那么该方法是没有办法正常工作的。

    上边的方法可以解决您看到的异常信息,但不同的方法请酌情考虑。

1.1.3. close

    服务器关闭的专用方法,这个方法有两个签名:

    void close();

    void close(Handler<AsyncResult<Void>> completionHandler);

    这个方法相对简单,一个异步一个同步,主要就是将当前服务器直接停止。

2. 管中窥豹

    这个小节是本章的重头戏,通过对HttpServer的结构分析,希望读者对它彻底了解,虽然在实际项目过程中,我们只会用到listen/close/createHttpServer等相关的API,但理解了结构过后,就可以知其所以然,并且可以给读者更加不同的视觉,也希望读者对这部分内容理解过后有所收获,在Vert.x中,接口io.vertx.core.http.HttpServer对应的实现类是io.vertx.core.http.impl.HttpServerImpl。

为了方便读者理解,本章节的基础术语如下:

  1. Handler:处理器,执行代码的函数,开发人也可以调用API设置,如实例中使用过的requestHandler。

  2. Channel:通道,很多地方翻译成管道,这里统一使用通道表示。

  3. 实例:真正创建好的对象信息,用于描述对象的数据结构,该对象就在当前实例中维护。

  4. 引用:Java中引用对象的变量,该对象数据结构可能不在类中。

  5. 池:内部哈希表,用于存储内存级别的 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实例的所有配置信息;它的构造函数签名如下:

public HttpServerImpl(VertxInternal vertx, HttpServerOptions options){
    // ...
}

    传入的两个参数直接在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接口很多,但内部执行流程是统一的。先看两段代码:

    // 不设置 Handler
    final Vertx vertx = Vertx.vertx();
    final HttpServer server = vertx.createHttpServer();
    server.listen(8099);
    // ---------------------- 分割线 --------------------
    // 重复监听
    server.listen(8099);
    server.listen(8000);

    「Exception」分别运行上边两段代码,您就可以在控制台看到如下异常输出:

// 不设置 Handler
Exception in thread "main" java.lang.IllegalStateException: Set request or websocket handler first
// 重复监听
Exception in thread "main" java.lang.IllegalStateException: Already listening

    合法性检查——前文中提到了HttpServer实例内部包含了四种处理器,在listen调用时,会检查其中的两种:Http流请求处理器和WebSocket流请求处理器,这两个处理器最少必须设置其中一个,否则就会遇到上述第一个异常;而listen方法不可以重复调用,重复调用就会遇到上边的第二个异常;——两个异常的区别在于:如果出现了不设置处理器的异常,服务器直接无法启动,程序退出,但若遇到了重复监听的异常,服务器依旧会正常运行,但新监听的端口无效,如示例中最终服务器会运行在8099端口而忽略第二次监听的8000端口。

    环境初始化——第二个步骤是环境初始化,该方法会先初始化监听上下文(listenContext),监听上下文的初始化方法和创建上下文有些区别,它调用的是vertx.getOrCreateContext()方法,如果无法获取到监听上下文信息,则会直接创建一个新的上下文环境。监听的环境初始化会做下边几件事:

  1. 构造io.vertx.core.net.SocketAddress对象,然后解析该对象拿到主机名host(默认是localhost)、端口号port(默认0)。

  2. 从HttpServerOptions中读取HTTP版本信息,Vert.x中支持HTTP的三种版本:1.0、1.1、2.0;由于Vert.x框架中的Verticle有两种:Agent和Worker,这两种的上下文类型不同,而HTTP 2.0是不支持Worker类型的,所以在读取版本信息时,针对Worker类型的Verticle会直接将2.0版本过滤掉。

  3. 利用读取到的HTTP版本信息对SSLHelper模块进行协议设置。

    实例初始化——第三个核心步骤是计算内部HttpServer实例引用(actualServer变量),listen方法调用后,当前HttpServer实例会先从Vertx引用中获取HTTP共享服务器池,并对其进行同步加锁。加锁过后第一个步骤是配合HTTP共享服务器池计算共享标记、构造ServerID、获取实际运行HttpServer实例引用:

    上图中的最后一个分支(灰色部分)是第一次创建流程,其余两个分支是直接获取流程,两个流程会影响后续计算,如果通过新计算的ServerID可以直接从Vertx实例中的HTTP共享服务器池中读取到HttpServer实例信息,证明该实例已经被创建过一次,这种情况下,计算流程会相对简单:

  1. 直接将找到的HttpServer实例引用传给内部actualServer引用。

  2. 结合初始化好的监听上下文(listenContext)为该HttpServer实例添加Http处理器(包括上述的四种)。

  3. 构造健康检查组件io.vertx.core.spi.metrics.VertxMetrics,如果启用了该模块则直接初始化。

    倘若没有在HTTP共享服务器池中找到对应的HttpServer实例,那么listen方法会走一个完整的创建流程:

  1. 实例化Netty中的服务器通道组,构造一个io.netty.channel.group.DefaultChannelGroup实例,名称为vertx-acceptor-channels,默认执行器则使用io.netty.util.concurrent.GlobalEventExecutor

  2. 创建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类型。

  3. 调用Vertx实例中的io.vertx.core.net.impl.transport.Transport对象设置服务器连接信息,这些连接信息都是在HttpServerOptions中配置。

  4. 调用SSLHelper的validate方法对Vertx实例执行SSL校验并设置SSL环境信息,如果校验通过,所有的SSL的信息都会执行初始化;读者需要区别SSLHelper的创建和校验步骤,创建SSLHelper是在构造函数中进行,但它并不会初始化SSL的上下文环境,而真正初始化SSL执行上下文是在当前步骤中。

  5. 设置当前HttpServer实例的子通道信息(childChannel),包括设置子处理器(childHandler),健康检查设置在这一步初始化。

  6. 结合初始化好的监听上下文(listenContext)为该HttpServer实例添加Http处理器(包括上述的四种)。

  7. 构造新的Channel通道组件,并且将该通道组件添加到通道组中,如果构造失败则标记当前HttpServer实例初始化失败,通过ServerID将它从HTTP共享服务器池中移除。

  8. 初始化完成过后,若可共享(第一步计算的结果),会将当前初始化好的HttpServer实例添加到Vertx引用的HTTP共享服务器池中。

    添加监听器/回调处理器——上边步骤完成后,最早的结构图中的actualServer变量就有值了,而且也整体初始化完成以及更新了Vertx中的HTTP共享服务器池,最后一个步骤就是从actualServer有值开始,其实这个步骤是第三步的后续处理,看这个步骤之前先参考下图:

    图中演示了从代码面上真正可以窥见的Channel后续流程,主要包括监听器流程和回调处理流程。通道Channel的创建在listen方法中是异步创建,内部存在三次转换:Netty的FuturePromise再到Future,监听器流程出现在Netty的FuturePromise转换过程,它用于监听Channel的创建结果。

  • 如果创建成功,则调用complete方法通知Promise,去触发onComplete最后的回调。

  • 如果创建失败,则调用fail方法,并且将当前HttpServer实例从HTTP共享服务器池中移除。

    回调处理器流程中,主要针对当前HttpServer的实际运行实例进行校正(比如actualPort的变化),并且运行监听上下文信息,如果出现错误,则需要将资源清除如关闭健康检查器、设置监听标记等。HttpServer在listen方法调用后,最后一个步骤创建Channel时是异步的,可能读者会担心在方法执行完后无法访问监听端口的信息,因为这个过程会有一个时间差,若想要知道什么时候启动完成,则可以在调用listen时使用异步调用接口(带Handler<AsyncResult<HttpServer>>参数的方法),这个处理器会在onComplete方法的最后执行,这样您就可以精确捕捉到服务器启动完成的时间戳,设置部分打印日志来标记服务器完成了。

2.2.3. 销毁

    HttpServer实例的最后一个生命周期是销毁,直接调用close()方法来销毁该实例,它的内部步骤如下:

  1. 读取Vertx实例中的监听上下文信息,根据监听标记(是否监听)来决定是否直接执行最后步骤清除上下文。

  2. 获取内部HttpServer引用(actualServer变量),调用引用中的处理器管理器移除对应的Http处理器,这个过程完成后四种处理器都会被直接移除。

  3. 执行上下文清除的最后步骤。

  4. 移除当前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的机制我相信读者已经有了大致的了解了,那么最后总结一下(这里就不按照引用作者的想法去阐述了):

  1. 从Handler这种机制可以看到,Vert.x中使用的就是这种机制,一个Handler和另外一个Handler编连成一个完整的Handler的链,请求经过处理链去执行。被执行的数据在Netty中抽象成了ChannelEvent,这样抽象的好处是在通讯过程中形成了统一的数据规范(相当于有了一个统一的VO——Value Object),那么Handler拿到数据过后就只关心ChannelEvent数据本身,不去考虑底层的事,如同引用文中提到的编码、解码等,Vert.x Web项目中的RoutingContext的Web请求处理链和这种设计就有异曲同工之处。

  2. 不仅仅如此,Netty官方有一种说法,它是一个纯异步Java服务器。使用过OIO(线程发起请求)和NIO(Reactor模型)的工程师应该都清楚,这两种模式主要的区别在于编程风格,即便是NIO模式同步和异步的编程风格也很大,所以实际上ChannelPipeline还做了一个事情就是把同步和异步的原理封装在底层了,统一使用了这样一种机制来实现同步异步处理,那么开发人员就可以不去关心这个动作是同步还是异步的。

  3. 回到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

    第一个配置是网络配置,它是一个抽象类,定义如:

public abstract class 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_REUSEADDRSO_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_REUSEADDRSO_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_REUSEPORTSO_REUSEADDR并不等价,只要没有设置它,那么在相同地址和端口上绑定Socket的行为都会失败,哪怕这个原始的Socket处于释放阶段(TIME_WAIT),所以为了使得其他的Socket绑定成功,就必须设置SO_REUSEADDR或者SO_REUSEPORT又或者二者都设。

    最后说明一点,一个连接看起来是“协议 + 主机 + 端口”的三元组,但实际上它是被一个五元组“协议 + 主机 + 端口 + 地址重用 + 端口重用”定义的,任意两个连接的五元组不能完全一样,否则操作系统内核就无法识别,而在Vert.x框架中,默认只开启了地址重用,并没有开启端口重用功能,所以它默认使用四元组定义了连接,这个默认值的考虑是基于操作系统的,因为Windows系统上并没有端口重用的选项参数。

3.2. TCPSSLOptions

    第二个配置是TCP/SSL配置,它定义如下:

public abstract class TCPSSLOptions extends NetworkOptions

    它包含了以下配置项:

  • 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响应的流程。

    // Vert.x中处理 100-Continue 的示例代码
    // 客户端:continueHandler 的使用
    HttpClientRequest request = client.put("some-uri", response -> {
        System.out.println("Received response with status code " + response.statusCode());
    });
    
    request.putHeader("Expect", "100-Continue");
    
    // 主要是调用 continueHandler
    request.continueHandler(v -> {
      // 发送剩余部分的代码
      request.write("Some data");
      request.write("Some more data");
      request.end();
    });

    // 服务端
    httpServer.requestHandler(request -> {
        if (request.getHeader("Expect").equalsIgnoreCase("100-Continue")) {
            // 发送 100-Continue 响应
            request.response().writeContinue();
    
            request.bodyHandler(body -> {
                // 处理请求Body
            });
            request.endHandler(v -> {
                request.response().end();
            });
        }
    });

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的执行过程如:

  1. 先判断数据是否使用了Chunked编码,查找Transfer-Encoding: Chunked响应头。

  2. 然后在数据中查找开始标记,第一个Chunk Size开始的地方,通常使用\r\n\r(CRLF两次)——一般场景下,就是直接查找HTTP的响应Body部分的数据,在常用的响应数据格式中,HTTP Header和Body之间会有一个空行。

  3. 用程序解析十六进制的ASCII码数值,并且转换成十进制计算字节数。

  4. 获取长度后,查找和当前Chunk相关的数据部分,将数据部分读取出来,然后跳到下一个Chunk中(跳过CRLF)。

  5. 重复第3,4步,直到找到Chunk Size = 0的数据块。

**「注」**在解析过程中,有两个地方需要注意:第一是十六进制数在解析的时需要执行转换,第二就是空白字符以及特殊字符对中断字符的影响。

    // Vert.x 中打开 Chunk 响应(不直接往Header中塞数据,Vert.x中带有专用API设置)
    HttpServerResponse response = request.response();
    response.setChunked(true);

3.4.3. 压缩流

    HTTP协议定义了压缩,它是Web服务器和浏览器之间压缩文本数据的方法,它能大大减少网络传输的数据量,提高用户显示网页的速度,当然也会增加服务器的开销。区分内容编码和压缩:在HTTP协议中,可以对数据内容(Body部分)进行编码,当你采用类似gzip的编码时,就可以达到压缩的目的;在某些特殊场景,您也可以使用其他编码方式对内容进行加密、混淆等,以防止第三方看到文档内容。——其实压缩是内容编码的一种,也可以说是内容编码的子集。

    HTTP压缩过程如下:

  1. 浏览器发送HTTP请求给服务器,请求头中带有客户端内容编码偏好:Accept-Encoding: gzip, deflate,它告诉服务器该浏览器支持这些内容编码。

  2. 服务器接收到请求后,会生成原始的HTTP响应,其中包含了原始响应的Content-TypeContent-Length

  3. 服务器会使用gzip,对响应数据进行编码,编码后Header中会重算Content-Length(压缩后大小),并且增加Content-Encoding: gzip,将请求发送会客户端。

  4. 浏览器收到响应后,根据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,作者:思维的深度

最后更新于