Http实战之无状态协议、keep-alive分析

2022-11-18 17:04:15 浏览数 (1)

Http1.1特性

无状态的协议

HTTP 是一种不保存状态,即无状态(stateless)协议。HTTP 协议自身不对请求和响应之间的通信状态进行保存。也就是说在 HTTP 这个级别,协议对于发送过的请求和响应都不做持久化处理。使用 HTTP 协议,每当有新的请求发送时,就会有对应的新响应产生。协议本身并不保留之前一切的请求或响应报文的信息。这是为了「更快地处理大量事务,确保协议的可伸展性」,而特意把 HTTP 协议设计成如此简单。我们来看下面这个例子:

「【有状态】」

有状态

「【无状态】」

无状态

可以看到如果是有状态协议,「在【协议层】,本次请求会依赖也可以依赖上次请求的结果」,而在使用无状态协议通信时,「在【协议层】,本次请求不会依赖也无法依赖上次请求的结果」,请注意,这里说得都是「协议层」!协议层是否有状态跟我们会话或服务是否有状态并没有必然联系,我们完全可以使用http这种无状态的协议搭建一个有状态的服务。

在使用http协议时,由于它是无状态的,换句话说,「它的每个请求都是完全独立的」,因此每个请求都应该包含处理这个请求所需的完整的数据。所以在使用无状态协议进行通信时为了完成前文中的对话,整个通信过程应该如下图所示:

无状态下如何完成会话

Http状态管理

通过上文我们知道http协议是无状态的,而现如今我们在使用http协议跟服务器进行交互时,或者说我们通过http发起的会话基本都是有状态的,例如我们在网上购物时都需要保持用户登录的状态或记录用户购物车中的商品,举个例子:

假设我们正在在购物网站上买一个书包,流程如下:

  1. 输入账号密码登陆 【/login】 ======> 用户信息
  2. 选择一款你喜欢的书包加入到购物车中【/cart】 ======> 用户信息 产品信息
  3. 购买支付 【/pay】 ======> 用户信息 产品信息 金额信息

所谓的登录只是验证你是否是一个合法用户,若是合法则跳转到信息的页面,不合法则告知用户名密码错误。但是我们在第一步给服务器发完【/login】接口后,服务器就忘记了.....,忘记了你这个人到底有没有经过认证。所以在添加商品时 你还是需要将你的账号密码和商品信息一起提交给【/cart】接口,再让服务器做验证。第三步同理。

可以看到无状态的http协议在交互的场景下使用起来非常繁琐,「「HTTP本身是一个无状态的连接协议」,为了支持客户端与服务器之间的交互,「我们需要为交互存储状态」,因此Http协议需要提供一种「状态管理机制」。

Http的状态管理机制实际上依赖了两个头部字段

  • 请求头:「Cookie」
  • 响应头:「Set-Cookie」

这里我直接使用「RFC文档:https://datatracker.ietf.org/doc/html/rfc6265#page-3」中的几个例子来对这两个头部字段进行说明:

示例一:

代码语言:javascript复制
== Server -> User Agent ==

Set-Cookie: SID=31d4d96e407aad42

== User Agent -> Server ==

Cookie: SID=31d4d96e407aad42

服务器在对客户端进行响应时可以添加一个响应头字段:「Set-Cookie」,其中的内容为 SID=31d4d96e407aad42,客户端在接受到响应信息后会将「Set-Cookie」中的内容保存起来,并在下次发送请求时,通过请求头部字段「Cookie」将信息发送到服务器。

示例二:

代码语言:javascript复制
== Server -> User Agent ==

Set-Cookie: SID=31d4d96e407aad42; Path=/; Domain=example.com

== User Agent -> Server ==

Cookie: SID=31d4d96e407aad42

如上例所示,Set-Cookie可以通过PathDomain两个属性指定Cookie的作用域,「客户端会根据Cookie的作用域决定向服务器发送请求时是否要携带此Cookie」

Domain决定Cookie在哪个域是有效的,也就是决定在向该域发送请求时是否携带此Cookie,Domain的设置是对子域生效的,如Doamin设置为 .a.com,则b.a.com和c.a.com均可使用该Cookie,但如果设置为b.a.com,则c.a.com不可使用该Cookie。Domain参数必须以点(".")开始。

Path是Cookie的有效路径,和Domain类似,也对子路径生效,如Cookie1和Cookie2的Domain均为a.com,但Path不同,Cookie1的Path为 /b/,而Cookie的Path为 /b/c/,则在a.com/b页面时只可以访问Cookie1,在a.com/b/c页面时,可访问Cookie1和Cookie2。Path属性需要使用符号“/”结尾。

示例三:

代码语言:javascript复制
== Server -> User Agent ==

Set-Cookie: SID=31d4d96e407aad42; Path=/; Secure; HttpOnly
Set-Cookie: lang=en-US; Path=/; Domain=example.com

== User Agent -> Server ==

Cookie: SID=31d4d96e407aad42; lang=en-US

服务器可以向客户端设置多个Cookie。如上例所示,服务器通过第一个Set-Cookie向客户端设置了一个用户本次会话id,除此之外还通过Set-Cookie通知了客户端用户在会话过程中希望采用的语音是「lang=en-US」

我们可以看到在第一个Set-Cookie中我们还指定了Cookie的两个熟悉Secure、HttpOnly。

Cookie的Secure属性,意味着保持Cookie通信只限于加密传输,指示浏览器仅仅在通过安全/加密连接才能使用该Cookie。如果一个Web服务器从一个非安全连接里设置了一个带有secure属性的Cookie,当Cookie被发送到客户端时,它仍然能通过中间人攻击来拦截。

Cookie的HttpOnly属性,指示浏览器不要在除HTTP(和 HTTPS)请求之外暴露Cookie。一个有HttpOnly属性的Cookie,不能通过非HTTP方式来访问,例如通过调用JavaScript(例如,引用 document.cookie),因此,不可能通过跨域脚本(一种非常普通的攻击技术)来偷走这种Cookie。尤其是Facebook 和 Google 正在广泛地使用HttpOnly属性。

示例四:

代码语言:javascript复制
== Server -> User Agent ==

Set-Cookie: lang=en-US; Expires=Wed, 09 Jun 2021 10:18:14 GMT

== User Agent -> Server ==

Cookie: SID=31d4d96e407aad42; lang=en-US

服务器向客户端设置Cookie时可以通过Cookie的Expires熟悉指定其有效时间。但是请注意,客户端也可能会因为存储容量等问题自行将Cookie删除。

示例五:

代码语言:javascript复制
== Server -> User Agent ==

Set-Cookie: lang=; Expires=Sun, 06 Nov 1994 08:49:37 GMT

== User Agent -> Server ==

Cookie: SID=31d4d96e407aad42

最后这个例子想要说明的是,如果服务器希望删除客户端上的某个Cookie可以通过Set-Cookie将其过期时间设置为过去的某个时间。

代码实现分析

在上篇文章中我们已经搭建了用来调试的服务端及客户端,基于我们添加调试Cookie相关代码,如下:

「服务端」

代码语言:javascript复制
public class HttpServer {

  public static void main(String[] args) throws Exception {
    EventLoopGroup bossGroup = new NioEventLoopGroup(1);
    EventLoopGroup workerGroup = new NioEventLoopGroup();
    try {
      ServerBootstrap b = new ServerBootstrap();
      b.option(ChannelOption.SO_BACKLOG, 1024);
      b.group(bossGroup, workerGroup)
        .channel(NioServerSocketChannel.class)
        .handler(new LoggingHandler(LogLevel.INFO))
        .childHandler(new HttpHelloWorldServerInitializer());
      Channel ch = b.bind(8080).sync().channel();
      ch.closeFuture().sync();
    } finally {
      bossGroup.shutdownGracefully();
      workerGroup.shutdownGracefully();
    }
  }
}

public class HttpHelloWorldServerInitializer extends ChannelInitializer<SocketChannel> {
  @Override
  public void initChannel(SocketChannel ch) {
    ChannelPipeline p = ch.pipeline();
    p.addLast(new HttpServerCodec());
    p.addLast(new HttpObjectAggregator(65535));
    p.addLast(new HttpHelloWorldServerHandler());
  }
}

/** 这个类是处理http请求的核心类,这里我们简单处理 不论收到什么信息我们都返回Hello World */
public class HttpHelloWorldServerHandler extends SimpleChannelInboundHandler<HttpObject> {

  private static final byte[] CONTENT = {'H', 'e', 'l', 'l', 'o', ' ', 'W', 'o', 'r', 'l', 'd'};

  @Override
  public void channelReadComplete(ChannelHandlerContext ctx) {
    ctx.flush();
  }

  @Override
  public void channelRead0(ChannelHandlerContext ctx, HttpObject msg) {
    if (msg instanceof HttpRequest) {
      HttpRequest req = (HttpRequest) msg;
      FullHttpResponse response =
        new DefaultFullHttpResponse(
        req.protocolVersion(), OK, Unpooled.wrappedBuffer(CONTENT));

      // 1⃣️、在响应信息中添加Set-Cookie头
      final String setCookie = ServerCookieEncoder.STRICT.encode("name", "dmz");
      response.headers()
        .set(CONTENT_TYPE, TEXT_PLAIN)
        .setInt(CONTENT_LENGTH, CONTENT.length)
        .set(HttpHeaderNames.SET_COOKIE, setCookie);

      final HttpHeaders headers = req.headers();

      // 2⃣️、解析客户端请求信息中的Cookie信息并打印
      final String cookieUnDecode = headers.get(HttpHeaderNames.COOKIE);
      if (cookieUnDecode != null && !"".equals(cookieUnDecode)) {
        final Set<Cookie> decodes = ServerCookieDecoder.STRICT.decode(cookieUnDecode);
        if (decodes != null) {
          for (Cookie decode : decodes) {
            System.out.println(decode.name()   "="   decode.value());
          }
        }
      }
      ctx.write(response);
    }
  }

  @Override
  public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
    cause.printStackTrace();
    ctx.close();
  }
}

「客户端」

代码语言:javascript复制
public class HttpClient {
   // 用于存储Cookie
    static final BasicCookieStore BASIC_COOKIE_STORE = new BasicCookieStore();

    static final CloseableHttpClient HTTP_CLIENT =
            HttpClientBuilder.create()
                    .setConnectionManager(new BasicHttpClientConnectionManager())
                    .setDefaultCookieStore(BASIC_COOKIE_STORE)
             // 指定客户端的Cookie规范
                    .setDefaultCookieSpecRegistry(new Lookup<CookieSpecProvider>() {
                        @Override
                        public CookieSpecProvider lookup(String s) {
                            return new DefaultCookieSpecProvider();
                        }
                    })
                    .build();

    public static void main(String[] args) throws Exception {
        // 使用Scanner方便调试
        Scanner scanner = new Scanner(System.in);
        while (true) {
            // 程序会一直阻塞,直到我们在控制台输入命令
            // 没输入一次,会向服务器发送一个请求,避免多次重启,方便调试
            final String command = scanner.nextLine();
            if ("close".equals(command)) {
                HTTP_CLIENT.close();
                break;
            }
            final HttpGet httpGet = new HttpGet("http://127.0.0.1:8080");
            final CloseableHttpResponse execute = HTTP_CLIENT.execute(httpGet);
            for (Cookie cookie : BASIC_COOKIE_STORE.getCookies()) {
                System.out.println(cookie.getName()   "="   cookie.getValue());
            }
            // 保证连接释放到连接池
            System.out.println(EntityUtils.toString(execute.getEntity()));
        }
    }
}

服务端代码比较简单,不多做解释,我们看看客户端代码,可能有疑问的主要是两点

  1. 「BasicCookieStore」 我们通过浏览器发送请求时,浏览器会自动帮我们将Cookie进行持久化。通过「设置==>隐私设置和安全性==>Cookie及其他网站数据」,能查其持久化的Cookie信息,如下:

image-20220627091322350我们在使用Apache HttpClient发送请求时,为了调试Cookie相关代码,也需要提供一种Cookie持久化机制,这里我们直接使用它默认提供的「BasicCookieStore」,其内部是一个TreeSet。

  1. 「DefaultCookieSpecProvider」: 见名知意,这个类的作用是提供一个Cookie规范,主要包括对Set-Cookie的解析、解析后内容的校验等。

上面这段程序的作用在于

  1. 客户端每次请求后打印服务器设置的Cookie
  2. 服务器每次打印客户端请求时携带的Cookie

服务器的代码非常简单,不做过多分析,我直接看HttpClient在Cookie上做了哪些处理

  1. 解析响应头中的Set-Cookie,代码位于org.apache.http.client.protocol.ResponseProcessCookies#processCookies,如下:

image-20220628084449034 每次请求时,携带服务器设置的Cookie,代码位于org.apache.http.client.protocol.RequestAddCookies#process,如下:

Cookie、Session

【Cookie】的工作原理我相信通过前文中的分析大家应该已经很清晰了,如下图所示:

image-20220623091101668

【Session】是一个抽象概念,开发者为了实现中断和继续等操作,将客户端和服务器之间一对一的交互,抽象为“会话”,进而衍生出“会话状态”,也就是Session的概念。我们通常了解到的Session都是基于Cookie实现的,下面是servlet规范中的一段话

大意为:通过Cookie实现的Session机制是最常用的会话跟踪机制,所有的servlet容器都需要支持。容器向客户端发送一个cookie。然后,客户端将在每次对服务器的后续请求中返回该cookie,明确地将请求与会话联系起来。会话跟踪cookie的标准名称必须是JSESSIONID。容器可以允许通过容器的特定配置来定制会话跟踪cookie的名称。整个Session的工作原理如下图所示:

Http长连接

接下来我们来聊聊Http的长连接,说到Http的长连接,避免不了会跟Tcp的长连接做一个对比。

Tcp长连接

TCP本身并没有长短连接的区别,长短与否,完全取决于我们怎么用它。

  • 短连接:每次通信时,创建 Socket;一次通信结束,调用 socket.close()。这就是一般意义上的短连接,短连接的好处是管理起来比较简单,存在的连接都是可用的连接,不需要额外的控制手段。
  • 长连接:每次通信完毕后,不会关闭连接,这样可以做到连接的复用。「长连接的好处是省去了创建连接的耗时」

短连接和长连接的优势,分别是对方的劣势。想要图简单,不追求高性能,使用短连接合适,这样我们就不需要操心连接状态的管理;想要追求性能,使用长连接,我们就需要担心各种问题:比如 「端对端连接的维护,连接的保活 「。操作系统给我们提供了一种Tcp的保活机制,即:」TCP的keepalive机制」

Tcp保活机制

如果在一段时间(「保活时间:tcp_keepalive_time」)内此连接都不活跃,「开启保活功能的一端」会向对端发送一个保活探测报文。

  • 若对端正常存活,且连接有效,对端必然能收到探测报文并进行响应。此时,发送端收到响应报文则证明TCP连接正常,重置保活时间计数器即可。
  • 若由于网络原因或其他原因导致,发送端无法正常收到保活探测报文的响应。那么在一定「探测时间间隔(tcp_keepalive_intvl)」后,将继续发送保活探测报文。直到收到对端的响应,或者达到配置的「探测循环次数上限(tcp_keepalive_probes)」都没有收到对端响应,这时对端会被认为不可达,TCP连接虽存在但已失效,需要将连接做中断处理。

上面提到了三个参数「保活时间:tcp_keepalive_time、探测时间间隔:tcp_keepalive_intvl、探测循环次数:tcp_keepalive_probes」

这三个参数,在linux上可以在/proc/sys/net/ipv4/路径下找到,或者通过sysctl -a | grep keepalive命令查看当前内核运行参数。

代码语言:javascript复制
[root@vm01 ~]# cd /proc/sys/net/ipv4
[root@vm01 ipv4]# pwd
/proc/sys/net/ipv4
[root@vm01 ipv4]# cat /proc/sys/net/ipv4/tcp_keepalive_time
7200
[root@vm01 ipv4]# cat /proc/sys/net/ipv4/tcp_keepalive_probes
9
[root@vm01 ipv4]# cat /proc/sys/net/ipv4/tcp_keepalive_intvl
75
[root@vm01 ipv4]# sysctl -a | grep keepalive
net.ipv4.tcp_keepalive_time = 7200
net.ipv4.tcp_keepalive_probes = 9
net.ipv4.tcp_keepalive_intvl = 75
  • 保活时间(tcp_keepalive_time)默认:7200秒
  • 保活时间间隔(tcp_keepalive_intvl)默认:75秒
  • 探测循环次数(tcp_keepalive_probes)默认:9次

也就是默认情况下一条TCP连接在2小时(7200秒)都没有报文交换后,会开始进行保活探测,若再经过9*75秒=11分钟15秒的循环探测都未收到探测响应,即共计:2小时11分钟15秒后会自动断开TCP连接。

Http的keep-alive

「HTTP是短连接」,客户端向服务器发送一个请求,得到响应后,连接就关闭。之所以这样设计使用,主要是考虑到实际情况。例如,用户通过浏览器访问一个web站点上的某个网页,当网页内容加载完毕之后,用户可能需要花费几分钟甚至更多的时间来浏览网页内容,此时完全没有必要继续维持底层连接。当用户需要访问其他网页时,再创建新的连接即可。

因此,HTTP连接的寿命通常都很短。这样做的好处是,可以极大的减轻服务端的压力。一般而言,一个站点能支撑的最大并发连接数也是有限的,面对这么多客户端浏览器,不可能长期维持所有连接。每个客户端取得自己所需的内容后,即关闭连接,更加合理。

通常一个网页可能会有很多组成部分,除了文本内容,还会有诸如:js、css、图片等静态资源,有时还会异步发起AJAX请求。只有所有的资源都加载完毕后,我们看到网页完整的内容。然而,一个网页中,可能引入了几十个js、css文件,上百张图片,如果每请求一个资源,就创建一个连接,然后关闭,代价实在太大了。基于此背景,我们希望连接能够在「短时间」内得到复用,在加载同一个网页中的内容时,「尽量的复用连接,这就是HTTP协议中keep-alive属性的作用」

  • HTTP 1.0中默认是关闭的,需要在http头加入「Connection: Keep-Alive」才能启用Keep-Alive;
  • HTTP 1.1中默认启用Keep-Alive,如果加入「Connection: close」才关闭。

Http的keep-alive建立在底层使用Tcp长连接的基础上,前文中我们已经提到过Tcp长连接本质上是在使用时不立马关闭连接。keep-alive的作用在于通知对端不要关闭底层socket连接,下次通信时可以使用同一个连接,接下来我们通过wireshark抓包以及代码分析证明这一点。

抓包分析

首先,我们需要对测试代码稍作改动以支持keep-alive,httpClient默认支持keep-alive,所以客户端代码不需要变动,但服务端需要做如下改动:

代码语言:javascript复制
public class HttpHelloWorldServerInitializer extends ChannelInitializer<SocketChannel> {
    @Override
    public void initChannel(SocketChannel ch) {
        ChannelPipeline p = ch.pipeline();
        p.addLast(new HttpServerCodec());
       // 加入一个HttpServerKeepAliveHandler以支持keep-alive
        p.addLast(new HttpServerKeepAliveHandler());
        p.addLast(new HttpObjectAggregator(65535));
        p.addLast(new HttpHelloWorldServerHandler());
    }
}

通过客户端发送请求后抓包如下:

第一次发送请求:

❝wirkshark抓包的表达式为:tcp.port==8080,代表我们要抓取8080端口上的所有数据包。关于wirkshark抓包的细节操作请参考上篇文章:《Http实战之wireshark抓包实战》 ❞

通过抓包我们可以发现,「第一次发送请求时进行了tcp握手,但并没有关闭连接」。接着我们再次通过客户端发送一个请求,注意,两次请求请不要关闭客户端!

第二次发送请求:

可以看到第二次请求并没有重新进行tcp握手,就直接完成了http通信,这就表示底层的tcp得到了复用。

代码分析

httpClient相关实现代码位于org.apache.http.impl.execchain.MainClientExec#execute中,如下:

reuseStrategy.keepAlive(response, context),发起请求时,会先判断客户端是否开启了keep-alive,代码如下:

逻辑很简单,只要请求头中没有Connection: close便是开启keep-alive。

  1. 处理服务端返回的信息,确定连接保持时间。实际就是处理响应头中的Keep-Alive字段

netty对于keep-alive的处理都位于HttpServerKeepAliveHandler中,核心代码如下:

通过代码分析我们能分析出这么几个细节

  1. keep-alive是由客户端发起的。这好像是句废话,毕竟http请求就是客户端发起的
  2. 即使客户端发起了keep-alive,服务器也可以拒绝
  3. 服务器可以通过响应头中的Keep-Alive字段决定连接保持的时间

总结

限于篇幅原因,本文只分析了http协议无状态的含义以及http长连接,本系列文章是实战篇,主要的实战方式是抓包 代码分析。Http系列还会有一篇文章,包括「Http缓存」「分块传输」「数据压缩」等。

希望大家能理解一句话,协议就只是协议,只有协议的双方同时遵守协议并按约定实现协议,协议才有意义!!!

参考:

https://www.zhihu.com/question/23202402

https://datatracker.ietf.org/doc/html/rfc6265#page-3

https://hc.apache.org/httpcomponents-client-4.5.x/current/tutorial/html/statemgmt.html#d5e515

https://www.cnkirito.moe/tcp-talk/

0 人点赞