vlambda博客
学习文章列表

JDK17 |java17学习 第 10 章 管理数据库中的数据

Chapter 11: Network Programming

在本章中,我们将描述和讨论最流行的网络协议——用户数据报协议 (UDP)、传输控制协议 (TCP), 超文本传输​​协议 (HTTP) 和 WebSocket – 以及来自 Java 类库 (JCL)。我们将演示如何使用这些协议以及如何在 Java 代码中实现客户端-服务器通信。我们还将回顾基于 Uniform Resource Locator (URL) 的通信和最新的 Java HTTP客户端 API。学习本章后,您将能够创建使用 UDP、TCP 和 进行通信的服务器和客户端应用程序class="bold">HTTP 协议以及 WebSocket。

本章将涵盖以下主题:

  • 网络协议
  • 基于UDP的通信
  • 基于 TCP 的通信
  • UDP 与 TCP 协议
  • 基于 URL 的通信
  • 使用 HTTP 2 客户端 API
  • 创建独立的应用程序 HTTP 服务器

在本章结束时,您将能够使用所有最流行的协议在客户端和服务器之间发送/接收消息。您还将学习如何将服务器创建为单独的项目,以及如何创建和使用公共共享库。

Technical requirements

为了能够执行本章提供的代码示例,您将需要以下内容:

  • 装有 Microsoft Windows、Apple macOS 或 Linux 操作系统的计算机
  • Java SE 版本 17 或更高版本
  • 您选择的 IDE 或代码编辑器

本书第 1 章Java 17 入门。本章的代码示例文件可在 GitHub 上的 https:// github.com/PacktPublishing/Learn-Java-17-Programming.git 存储库,位于 examples/src/main/java/com/packt/learnjava/ch11_network文件夹,并在 commonserver 文件夹中,作为单独的项目。

Network protocols

网络编程 是一个广阔的领域。 互联网协议 (IP) 套件由四个 层组成,每个层它有十几个或更多协议:

  • 链路层:当客户端 物理上使用时使用的协议组已连接 到主机;三个核心 协议包括地址解析协议 (ARP)、< strong class="bold">反向地址解析 协议 (RARP),以及邻居发现协议 (NDP)。
  • 互联网层:一组互连网络方法、协议和规范,用于从源 主机到目标主机,由 IP 地址指定。该层的核心协议是 Internet Protocol version 4( IPv4)和 Internet Protocol version 6( IPv6); IPv6 指定了一种新的数据包 格式并为点分 IP 地址分配 128 位,而 IPv4 中为 32 位。 IPv4 地址的一个示例是 10011010.00010111.11111110.00010001,这导致 IP 地址为 154.23.254.17。本章中的示例使用 IPv4。不过,该行业正在慢慢转向 IPv6。 IPv6 地址的一个示例是 594D:1A1B:2C2D:3E3F:4D4A:5B5A:6B4E:7FF2
  • 传输层:主机到主机通信服务组。它包括 TCP、也称为 的 TCP/IP 协议和 UDP(我们将在稍后讨论)。 这个组中的其他协议是数据报拥塞控制协议(< strong class="bold">DCCP) 和 流控制传输协议 (SCTP)。
  • 应用层:协议组 接口 方法 主机 通信 网络中。它包括Telnet、文件传输协议(FTP)、域名系统 (DNS), 简单邮件传输协议 (SMTP)、轻量级目录访问协议 (LDAP)、超文本传输​​协议 (HTTP)、安全超文本传输​​协议 (HTTPS< /strong>)和安全外壳(SSH)。

链路层是最低层;它被互联网层使用,而互联网层又被传输层使用。然后应用层使用该传输层来支持协议实现。

出于安全原因,Java 不提供对链路层和互联网层协议的 访问。这意味着 Java 不允许您创建自定义传输协议,例如,用作 TCP/IP 的替代方案。这就是为什么在本章中,我们将只回顾传输层(TCP 和 UDP)和应用层(HTTP)的协议。我们将解释和演示 Java 如何支持它们以及 Java 应用程序如何利用这种支持。

Java 通过 java.net 包的类支持 TCP 和 UDP 协议,而 HTTP 协议可以使用 java.net.http 包(随 Java 11 引入)。

TCP 和 UDP 协议都可以使用 sockets 在 Java 中实现。套接字 由 IP 地址和端口号的组合标识,它们代表两个应用程序之间的连接。由于 UDP 协议比 TCP 协议简单一些,我们将从 UDP 开始。

UDP-based communication

UDP 协议 由 David P. Reed 在 1980 年设计。它允许应用程序 发送名为 数据报使用简单的无连接通信 模型和最小协议机制(如校验和),以确保数据完整性。它没有握手对话,因此不保证消息传递或保持消息的顺序。它适用于那些优先丢弃消息或混淆订单而不是等待重传的情况。

数据报由 java.net.DatagramPacket 类表示。可以使用六个构造函数之一创建此类的对象;以下两个构造函数是最常用的:

  • DatagramPacket(byte[] buffer, int length):该构造函数创建一个数据报包,用于接收数据包; buffer 保存传入的数据报,而 length 是要读取的字节数。
  • DatagramPacket(byte[] buffer, int length, InetAddress address, int port):创建一个数据报包,用于发送数据包; buffer保存包数据,length为包数据长度,地址保存目的IP地址,port是目的端口号。

一旦构造完成,DatagramPacket 对象会公开以下方法,这些方法可用于从对象中提取数据或设置/获取其属性:

  • void setAddress(InetAddress iaddr):设置目标IP地址。
  • InetAddress getAddress():返回目标或源 IP 地址。
  • void setData(byte[] buf):设置数据缓冲区。
  • void setData(byte[] buf, int offset, int length):设置数据缓冲区,数据偏移量,以及<一个 id="_idIndexMarker1254"> 长度。
  • void setLength(int length):设置数据包的长度。
  • byte[] getData():返回数据缓冲区。
  • int getLength():返回要发送或接收的数据包的长度。
  • int getOffset():返回要发送或接收的数据的偏移量。
  • void setPort(int port):设置目标端口号。
  • int getPort():返回要发送或接收数据的端口号。一旦创建了 DatagramPacket 对象,就可以使用 DatagramSocket 类来发送或接收它,该类表示用于发送和接收的无连接套接字接收数据报包。可以使用六个构造函数之一创建此类的对象;以下三个构造函数是最常用的:
    • DatagramSocket():这会创建一个数据报套接字并将其绑定到本地主机上的任何可用端口。它通常用于创建发送套接字,因为可以在数据包内部设置目标地址(和端口)(参见前面的 DatagramPacket 构造函数和方法)。
    • DatagramSocket(int port):这将创建一个数据报套接字并将其绑定到本地主机上的指定端口。当任何本地机器地址(称为通配符地址)足够好时,它用于创建接收套接字。
    • DatagramSocket(int port, InetAddress address):这会创建一个数据报套接字并将其绑定到指定的端口和指定的本地地址;本地端口必须介于 065535 之间。用于在需要绑定特定的本地机器地址时创建接收套接字。

DatagramSocket 对象的以下两个方法 最常用于发送 并接收消息(或数据包):

  • void send(DatagramPacket p):发送指定的数据包。
  • void receive(DatagramPacket p):通过用接收到的数据填充指定的 DatagramPacket 对象的缓冲区来接收数据包。指定的 DatagramPacket 对象还包含发送者的 IP 地址和发送者机器上的端口号。

让我们看一个代码示例。这是接收到消息后退出的 UDP 消息接收器:

public class UdpReceiver {
  public static void main(String[] args){
    try(DatagramSocket ds = new DatagramSocket(3333)){
       DatagramPacket dp = 
                          new DatagramPacket(new byte[16], 16);
       ds.receive(dp);
       for(byte b: dp.getData()){
           System.out.print(Character.toString(b));
       }
    } catch (Exception ex){
            ex.printStackTrace();
    }
  }
}

如您所见,接收者 正在侦听端口 3333 上的本地计算机。它仅使用 16 字节的缓冲区;一旦缓冲区被接收到的数据填满,接收器就会打印其内容并退出。

以下是 UDP 消息发送方的示例:

public class UdpSender {
  public static void main(String[] args) {
    try(DatagramSocket ds = new DatagramSocket()){
       String msg = "Hi, there! How are you?";
       InetAddress address = 
                            InetAddress.getByName("127.0.0.1");
       DatagramPacket dp = new DatagramPacket(msg.getBytes(), 
                                  msg.length(), address, 3333);
       ds.send(dp);
    } catch (Exception ex){
        ex.printStackTrace();
    }
  }
}

如您所见,发送者用消息、本地机器地址和与接收者使用的端口相同的端口构造了一个数据包。构造好的数据包发送完毕后,发送方退出。

我们现在可以运行发送方,但是如果没有接收方运行,就没有人可以得到消息。因此,我们将首先启动接收器。它在端口 3333 上进行侦听,但没有消息到来——所以它等待。然后,我们运行发送方,接收方显示以下消息:

JDK17 |java17学习 第 10 章 管理数据库中的数据

由于缓冲区小于 消息,因此它仅被部分接收 - 消息的其余部分丢失。这就是我们 将缓冲区大小增加到 30 的原因。此外,我们可以创建一个无限循环并让接收器无限期运行(参见 UdpReceiver2 类):

public class UdpReceiver2 {
 public static void main(String[] args){
    try(DatagramSocket ds = new DatagramSocket(3333)){
       DatagramPacket dp = 
                          new DatagramPacket(new byte[30], 30);
       while(true){
          ds.receive(dp);
          for(byte b: dp.getData()){
              System.out.print(Character.toString(b));
          }
          System.out.println(); //added here to have end-of-
             // line after receiving (and printing) the message
       }
    } catch (Exception ex){
            ex.printStackTrace();
    }
  }
}

通过这样做,我们可以多次运行发件人。如果我们运行发送方 3 次,接收方 UdpReceiver2 会打印以下内容:

JDK17 |java17学习 第 10 章 管理数据库中的数据

如您所见,所有三个消息都已收到。如果你运行接收器UdpReceiver2,不要忘记在不再需要运行后手动停止它。否则,它会无限期地继续运行。

所以,这就是UDP协议的基本思想。即使没有套接字侦听,发送方也会向某个地址和端口发送消息。它不需要在发送消息之前建立任何类型的连接,这使得 UDP 协议比 TCP 协议更快、更轻量级(需要您先建立连接)。通过这种方式,TCP 协议 使 确保目标存在并且消息可以交付。

TCP-based communication

TCP 由 Defense Advanced Research Projects Agency (DARPA) 在 1970 年代设计,用于 高级研究计划署网络 (ARPANET)。它是 IP 的补充,因此也称为 TCP/IP。 TCP 协议,甚至是 的名字,都表明它提供了 可靠的(即错误检查或控制)数据 传输。它允许 在 IP 网络中按顺序传送字节,并被 Web、电子邮件、安全外壳和文件传输广泛使用。

使用 TCP/IP 的应用程序甚至不知道套接字和传输细节之间发生的所有握手——例如网络拥塞、流量负载平衡、重复,甚至某些 IP 数据包的丢失。传输层的底层协议实现会检测到这些问题,重新发送数据,重构发送数据包的顺序,并尽量减少网络拥塞。

与 UDP 协议相比,基于 TCP/IP 的通信以牺牲交付周期为代价,专注于准确交付。这就是为什么它不用于需要可靠交付和正确顺序排序的实时应用程序,例如 IP 语音。但是,如果每个位都需要完全按照发送时的顺序以相同的顺序到达,那么 TCP/IP 是不可替代的。

为了支持这种行为,TCP/IP 通信在整个通信过程中保持一个会话。会话由客户端地址和端口标识。每个会话由服务器上表中的一个条目表示。这包含有关会话的所有元数据:客户端 IP 地址和端口、连接状态和缓冲区参数。但是,这些细节通常对应用程序开发人员是隐藏的,因此我们不会在此处详细介绍。相反,我们将转向 Java 代码。

与 UDP 协议类似,Java 中的 TCP/IP 协议 实现使用 套接字。但不是实现 UDP 协议的 java.net.DatagramSocket 类,而是由 java.net 表示基于 TCP/IP 的套接字.ServerSocketjava.net.Socket 类。它们允许在两个应用程序之间发送和接收消息,其中一个是服务器,另一个是客户端。

ServerSocketSocketClass 类执行非常相似的工作。唯一的区别是 ServerSocket 类有 accept() 方法,accepts< /em> 来自客户端的请求。这意味着服务器必须启动并准备好首先接收请求。然后,连接由创建自己的发送连接请求的套接字的客户端发起(来自 Socket 类的构造函数)。然后服务器接受请求并创建一个连接到远程套接字(在客户端)的本地套接字。

建立连接后,可以使用 第 5 章字符串、输入/输出和文件Socket 对象具有 getOutputStream()getInputStream() 方法提供对套接字数据流的访问。来自本地计算机上 java.io.OutputStream 对象的数据显示为来自 java.io.InputStream 对象远程机器。

现在让我们仔细看看 java.net.ServerSocketjava.net.Socket 类,然后运行一些示例他们的用法。

The java.net.ServerSocket class

java.net.ServerSocket 类有 四个 构造函数:

  • ServerSocket():这将创建一个未绑定到特定地址和端口的服务器套接字 对象。它需要使用 bind() 方法来绑定套接字。
  • ServerSocket(int port):这将创建一个绑定到提供的端口的服务器套接字对象。 port 值必须介于 065535 之间。如果端口号指定为0的值,则表示需要自动绑定端口号。然后可以通过调用 getLocalPort() 检索此端口 编号。默认情况下,传入连接的最大队列长度为 50。这意味着最大并行传入连接默认为 50。超过的连接将被拒绝。
  • ServerSocket(int port, int backlog):这提供了与 ServerSocket(int port) 构造函数相同的功能,并允许您通过 backlog 参数设置传入连接的最大队列长度。
  • ServerSocket(int port, int backlog, InetAddress bindAddr):这将创建一个与前面的构造函数类似的服务器套接字对象,但也绑定到提供的IP地址。当 bindAddr 值为 null 时,它将默认接受任何或所有本地地址上的连接。

ServerSocket 类的以下四个方法 是最常用的,它们对于建立套接字的连接是必不可少的:

  • void bind(SocketAddress endpoint):这会将 ServerSocket 对象绑定到特定的 IP 地址和端口。如果提供的地址是 null,那么系统会自动获取一个端口和一个有效的本地地址(稍后可以使用 getLocalPort ()getLocalSocketAddress()getInetAddress() 方法)。另外,如果 ServerSocket 对象是由构造函数创建的,没有任何参数,那么这个方法,或者下面的 bind() 方法, 需要在建立连接之前调用。
  • void bind(SocketAddress endpoint, int backlog):这个作用和前面的方法类似; backlog 参数是套接字上的最大挂起连接数(即队列的大小)。如果 backlog 值小于或等于 0,则将使用特定于实现的默认值。
  • void setSoTimeout(int timeout):设置套接字在accept() 方法 被调用。如果客户端没有调用,超时时间到了,抛出java.net.SocketTimeoutException异常,但是ServerSocket对象依然存在有效并且可以重复使用。 0timeout 值被解释为无限超时(accept() 方法阻塞,直到客户端调用)。
  • Socket accept():这会阻塞,直到客户端调用或超时期限(如果已设置)到期。

该类的其他方法允许您设置 或获取Socket 对象的其他属性,它们可用于更好的动态管理的套接字连接。您可以参考该课程的在线文档以更详细地了解可用选项。

以下代码是使用 ServerSocket 类的服务器实现示例:

public class TcpServer {
  public static void main(String[] args){
    try(Socket s = new ServerSocket(3333).accept();
      DataInputStream dis = 
                      new DataInputStream(s.getInputStream());
      DataOutputStream dout = 
                     new DataOutputStream(s.getOutputStream());
      BufferedReader console = 
        new BufferedReader(new InputStreamReader(System.in))){
        while(true){
           String msg = dis.readUTF();
           System.out.println("Client said: " + msg);
           if("end".equalsIgnoreCase(msg)){
               break;
           }
           System.out.print("Say something: ");
           msg = console.readLine();
           dout.writeUTF(msg);
           dout.flush();
           if("end".equalsIgnoreCase(msg)){
               break;
           }
        }
    } catch(Exception ex) {
      ex.printStackTrace();
    }
  }
}

让我们看一下前面的代码。在 try-with-resources 语句中,我们创建 SocketDataInputStreamDataOutputStream< /code> 对象基于我们新创建的套接字,以及 BufferedReader 对象从控制台读取用户输入(我们将使用它来输入数据)。在创建套接字时,accept() 方法会阻塞,直到客户端尝试连接到本地服务器的端口 3333

然后,代码进入无限循环。首先,它使用 readUTF() 方法将客户端发送的字节读取为以修改的 UTF-8 格式编码的 Unicode 字符串>数据输入流。结果以 "Client said: " 前缀打印。如果接收到的消息是 "end" 字符串,则代码退出循环,服务器程序退出。如果消息不是"end",那么控制台会显示"Say something:"提示,readLine() 方法会阻塞,直到用户键入内容并单击 Enter

服务器从屏幕获取输入,并使用 writeUtf() 方法将其作为 Unicode 字符串写入输出流。正如我们已经提到的,服务器的输出流连接到客户端的输入流。如果客户端从输入流中读取,它会收到服务器发送的消息。如果发送的消息是"end",则服务器退出循环和程序。如果不是,则再次执行循环体。

所描述的算法假设客户端仅在发送或接收"end"时退出 消息。否则,如果客户端随后尝试向服务器发送消息,则会生成异常。这证明了我们已经提到的 UDP 和 TCP 协议之间的区别——TCP 基于在服务器和客户端套接字之间建立的会话。如果一侧掉落,另一侧会立即遇到错误。

现在,让我们回顾一个 TCP 客户端实现的示例。

The java.net.Socket class

java.net.Socket 类现在应该很熟悉,因为它被使用过< /a> 在前面的示例中。我们用它来访问已连接套接字的输入和输出流。现在我们将系统地回顾 Socket 类,并探索如何使用它来创建 TCP 客户端。 Socket 类有五个构造函数:

  • Socket():这会创建 一个未连接的套接字。它使用 connect() 方法来建立此套接字与服务器上的套接字的连接。
  • Socket(String host, int port):这将创建一个套接字并将其连接到 host 服务器上提供的端口。如果抛出异常,则与服务器的连接 没有建立;否则;您可以开始向服务器发送数据。
  • Socket(InetAddress address, int port):它的作用类似于前面的构造函数,除了主机是作为 InetAddress 提供的目的。
  • Socket(String host, int port, InetAddress localAddr, int localPort):这与前面的构造函数类似,除了它还允许您将套接字绑定到提供的本地地址和端口(如果程序在具有多个 IP 地址的机器上运行)。如果提供的 localAddr 值为 null,则选择任何本地地址。或者,如果提供的 localPort 值为 null,则系统会在绑定操作中选择一个空闲端口。
  • Socket(InetAddress address, int port, InetAddress localAddr, int localPort):这与前面的构造函数类似,只是本地地址是作为 InetAddress 对象。

下面是我们已经使用过的Socket的以下两个方法:

  • InputStream getInputStream():这将返回一个代表源(远程套接字)的对象,并将数据(输入它们)带入程序(本地套接字)。
  • OutputStream getOutputStream():这会返回一个代表 源(本地套接字)的对象并发送数据(输出它们)到远程套接字。

现在让我们检查 TCP 客户端代码,如下所示:

public class TcpClient {
  public static void main(String[] args) {
    try(Socket s = new Socket("localhost",3333);
      DataInputStream dis = 
                       new DataInputStream(s.getInputStream());
      DataOutputStream dout = 
                     new DataOutputStream(s.getOutputStream());
      BufferedReader console = 
         new BufferedReader(new InputStreamReader(System.in))){
         String prompt = "Say something: ";
         System.out.print(prompt);
         String msg;
         while ((msg = console.readLine()) != null) {
             dout.writeUTF( msg);
             dout.flush();
             if (msg.equalsIgnoreCase("end")) {
                 break;
             }
             msg = dis.readUTF();
             System.out.println("Server said: " +msg);
             if (msg.equalsIgnoreCase("end")) {
                 break;
             }
             System.out.print(prompt);
         }
    } catch(Exception ex){
          ex.printStackTrace();
    }
  }
}

前面的 TcpClient 代码看起来与我们查看的 TcpServer 代码几乎完全相同。唯一的主要区别是 new Socket("localhost", 3333) 构造函数尝试与 "localhost:3333"< /code> 服务器立即启动,因此它期望 localhost 服务器已启动并侦听端口 3333;其余的与服务器代码相同。

因此,我们需要使用 ServerSocket 类的唯一原因是允许服务器在等待客户端连接的同时运行;其他一切都可以仅使用 Socket 类来完成。

Socket 类的其他方法允许您设置或获取 socket< 的其他属性/code> 对象,它们 可用于更好地动态管理套接字连接。您可以阅读课程的在线文档以更详细地了解可用选项。

Running the examples

现在让我们运行 TcpServerTcpClient 程序。如果我们首先启动TcpClient,我们会得到java.net.ConnectException 连接被拒绝消息。所以,我们首先启动 TcpServer 程序。启动时,不会显示任何消息。相反,它只是等到客户端连接。因此,我们然后启动 TcpClient 并在屏幕上看到以下消息:

JDK17 |java17学习 第 10 章 管理数据库中的数据

我们输入 Hello! 然后按 Enter

JDK17 |java17学习 第 10 章 管理数据库中的数据

现在让我们看一下服务器端屏幕:

JDK17 |java17学习 第 10 章 管理数据库中的数据

我们在服务器端屏幕上输入 Hi! 并按 Enter

JDK17 |java17学习 第 10 章 管理数据库中的数据

在客户端屏幕上,我们看到以下消息:

JDK17 |java17学习 第 10 章 管理数据库中的数据

我们可以无限期地继续这个对话,直到服务器或客户端发送消息 end。让我们让客户去做;客户端说 end 然后退出:

JDK17 |java17学习 第 10 章 管理数据库中的数据

然后,服务器效仿:

JDK17 |java17学习 第 10 章 管理数据库中的数据

这就是我们想要在 讨论 TCP 协议时演示的全部内容。现在让我们回顾一下 UDP 和 TCP 协议之间的区别。

UDP versus TCP protocols

UDP 和 TCP/IP 协议 的区别如下:

  • UDP 只是发送数据,无论数据接收器是否启动和运行。这就是为什么与使用多播分发的许多其他客户端相比,UDP 更适合发送数据的原因。另一方面,TCP 要求首先在客户端和服务器之间建立连接。 TCP客户端发送一个特殊的控制消息;服务器收到它并以确认响应。然后,客户端向服务器发送一条消息,确认服务器确认。只有在这之后,客户端和服务器之间的数据传输才有可能。
  • TCP 保证消息传递或引发错误,而 UDP 不保证,并且数据报包可能会丢失。
  • TCP 保证在传递时保持消息的顺序,而 UDP 则不然。
  • 由于这些提供的保证,TCP 比 UDP 慢。
  • 此外,协议要求标头与数据包一起发送。 TCP 包的报头大小为 20 字节,而数据报包为 8 字节。 UDP头包含LengthSource PortDestination Port和< code class="literal">Checksum,而TCP头包含Sequence Number, Ack Number, 数据偏移量, 保留, 控制位, 窗口紧急指针选项填充,除了 UDP 标头。
  • 不同的应用协议 a> 基于 TCP 或 UDP 协议的。基于 TCP 的 协议是 HTTP、HTTPS、Telnet、FTP 和 SMTP .基于 UDP 的协议是 动态主机配置协议 (DHCP), DNS、简单网络管理协议 (SNMP)、普通文件传输协议 (TFTP),引导协议 (BOOTP)和早期版本的网络文件系统 (NFS)。

我们可以用一句话捕捉到UDP和TCP的区别:UDP协议比TCP更快,更轻量级,但更少可靠的。与生活中的许多事情一样,您必须为额外的服务支付更高的价格。但是,并非所有情况都需要所有这些服务,因此请考虑手头的任务并根据您的应用程序要求决定使用哪种协议。

URL-based communication

如今,似乎 每个人都对 URL 有一些概念;那些在计算机或智能手机上使用浏览器的 每天都会看到网址。在本节中,我们将简要解释构成 URL 的不同部分,并演示如何以编程方式使用它从网站(或文件)请求数据或将数据发送(发布)到网站。

The URL syntax

一般来说 URL语法符合统一资源标识符 (URI) 具有以下 格式:

scheme:[//authority]path[?query][#fragment]

方括号表示该组件是可选的。这意味着 URI 将至少由 scheme:path 组成。 scheme 组件可以是 http, https, ftp, mailto, file, data ,或其他值。 path 组件由一系列由斜杠 (/) 分隔的路径段组成。下面是一个仅由 schemepath 组成的 URL 示例:

file:src/main/resources/hello.txt

前面的 URL 指向本地文件系统上的一个文件,该文件与使用此 URL 的目录相关。以下是您更熟悉的示例:https://www.google.comhttps://www.packtpub.com。我们将很快演示它是如何工作的。

path 组件可以为空,但 URL 看起来毫无用处。然而,空路径通常与 authority 结合使用,其格式如下:

[userinfo@]host[:port]

唯一需要的权限组件是 host,它可以是 IP 地址(例如,137.254.120.50)或域名(例如,oracle.com)。

userinfo 组件通常与 scheme 组件的 mailto 值一起使用,所以 userinfo@host 代表一个电子邮件地址。

port 组件,如果省略,则采用默认值。例如,如果 scheme 值为 http,则默认 port值为 80,如果 scheme 值为 https,则默认 port 值为 443

URL 的可选 query 组件是键值序列由分隔符分隔的对 (&):

key1=value1&key2=value2

最后,可选的 fragment 组件是 HTML 文档部分的标识符,这意味着浏览器可以滚动该部分进入视图。

笔记

有必要提一下,Oracle 的在线文档 使用的术语略有不同:

  • protocol 而不是 方案
  • 参考 而不是 片段
  • 文件 而不是 路径[?query][#fragment]
  • resource 而不是 host[:port]path[?query][#fragment]

因此,从 Oracle 文档的角度来看,URL 由 protocolresource 值组成。

现在让我们看一下 Java 中 URL 的编程用法。

The java.net.URL class

在 Java 中,URL 由 java.net.URL 类的一个对象表示,该类有六个构造函数:

  • URL(String spec):这会从 URL 创建一个 URL 对象作为一个字符串。
  • URL(String protocol, String host, String file):这会根据 URL 对象"literal">protocol、hostfile路径 code> 和 query),以及基于提供的 protocol 值的默认端口号。
  • URL(String protocol, String host, int port, String path):这会根据 URL 的提供值创建一个对象code class="literal">protocol、hostport文件路径查询)。 port 值为 -1 表示 默认端口号 需要根据提供的 protocol 值使用。
  • URL(String protocol, String host, int port, String file, URLStreamHandler handler):这和前面的构造函数作用一样,另外还允许你传入一个对象特定的协议处理程序;所有前面的构造函数都会自动加载默认处理程序。
  • URL(URL context, String spec):这将创建一个扩展 URL 的 URL 对象 对象使用提供的 spec 值提供或覆盖其组件,该值是 URL 或其某些组件的字符串表示形式。例如,如果方案在两个参数中都存在,则来自 spec 的方案值将覆盖 context 和许多其他的方案值.
  • URL(URL context, String spec, URLStreamHandler handler):这与前面的构造函数的作用相同,并且还允许您传入特定协议处理程序的对象。

创建后,URL 对象允许您获取底层 URL 的各个组件的值。 InputStream openStream() 方法提供对从 URL 接收的数据流的访问。实际上,它的实现是openConnection.getInputStream()URL 类的 URLConnection openConnection() 方法返回一个 URLConnection 对象使用许多方法提供有关与 URL 的连接的详细信息,包括允许您将数据发送到 URL 的 getOutputStream() 方法。

我们看一下 UrlFileReader 代码示例,它从 hello.txt 文件中读取数据,该文件是本地文件 >第 5 章字符串、输入/输出和文件。该文件只包含 一行: Hello!;这是读取它的代码:

try {
  ClassLoader classLoader = 
              Thread.currentThread().getContextClassLoader(); 
  String file = classLoader.getResource("hello.txt").getFile(); 
  URL url = new URL(file);
     try(InputStream is = url.openStream()){
        int data = is.read();
        while(data != -1){
            System.out.print((char) data); //prints: Hello!
            data = is.read();
        }            
     }
} catch (Exception e) {
    e.printStackTrace();
}

在前面的代码中,我们使用类加载器来访问资源(hello.txt 文件)并构造指向它的 URL。

上述代码的其余部分是从文件中打开一个输入数据流,并将传入的字节打印为字符。结果显示在内联注释中。

现在,让我们演示 Java 代码如何从指向 Internet 源的 URL 中读取数据。让我们使用 Java 关键字(UrlSiteReader 类)调用 Google 搜索引擎:

try {
   URL url = 
       new URL("https://www.google.com/search?q=Java&num=10");
   System.out.println(url.getPath()); //prints: /search
   System.out.println(url.getFile()); 
                               //prints: /search?q=Java&num=10
   URLConnection conn = url.openConnection();
   conn.setRequestProperty("Accept", "text/html");
   conn.setRequestProperty("Connection", "close");
   conn.setRequestProperty("Accept-Language", "en-US");
   conn.setRequestProperty("User-Agent", "Mozilla/5.0");
   try(InputStream is = conn.getInputStream();
    BufferedReader br = 
            new BufferedReader(new InputStreamReader(is))){
      String line;
      while ((line = br.readLine()) != null){
         System.out.println(line);
      }
   }
} catch (Exception e) {
  e.printStackTrace();
}

在这里,我们提出了 https://www.google.com/search? q=Java&num=10 URL 并在一些研究 和实验后请求属性。无法保证 它始终有效,因此如果它返回的数据与我们描述的不同,请不要感到惊讶。此外,它是实时搜索,因此结果可能随时更改。当它工作时,谷歌将返回数据页面。

上述代码还演示了 getPath()getFile() 方法返回值的区别。您可以查看前面代码示例中的内联注释。

与使用文件 URL 的示例相比,Google 搜索示例使用了 URLConnection 对象,因为我们需要设置请求头字段:

  • Accept 告诉服务器调用者请求什么类型的内容(理解)。
  • Connection 告诉服务器在收到响应后将关闭连接。
  • Accept-Language 告诉服务器调用者请求哪种语言(理解)。
  • User-Agent 告诉服务器调用者的信息;否则,Google 搜索引擎 (www.google.com) 会使用 403(禁止)HTTP 代码进行响应。

前面示例中的剩余代码 只是从来自 URL 的输入 数据流(HTML 代码)中读取并打印出来, 逐行。我们捕获了结果(从屏幕复制),将其粘贴到在线 HTML Formatter (https://jsonformatter .org/html-pretty-print),然后运行它。结果显示在以下屏幕截图中,当您运行它时可能会有所不同,因为 Google 功能会随着时间的推移而发展:

JDK17 |java17学习 第 10 章 管理数据库中的数据

如您所见,它看起来像一个带有搜索结果的典型 页面,只是左上角没有 Google 图片 a> 与返回的 HTML。

重要的提示

请注意,如果您多次执行此代码,Google 可能会阻止您的 IP 地址。

同样,可以将数据发送(发布)到 URL。这是一个示例代码:

try {
    URL url = new URL("http://localhost:3333/something");
    URLConnection conn = url.openConnection();
    conn.setRequestProperty("Method", "POST");
    conn.setRequestProperty("User-Agent", "Java client");
    conn.setDoOutput(true);
    OutputStream os = conn.getOutputStream()
    OutputStreamWriter osw = new OutputStreamWriter(os);
    osw.write("parameter1=value1&parameter2=value2");
    osw.flush();
    osw.close();
    
    InputStream is = conn.getInputStream();
    BufferedReader br = 
               new BufferedReader(new InputStreamReader(is));
    String line;
    while ((line = br.readLine()) != null) {
        System.out.println(line);
    }
    br.close();
} catch (Exception e) {
    e.printStackTrace();
}

前面的代码需要一个在 localhost 服务器上运行的服务器,该服务器位于端口 3333 上,可以处理 带有 "/something" 路径的 POST 请求。如果服务器没有检查方法(是 POST 还是任何其他 HTTP 方法)并且它没有检查 User-Agent 值,无需指定任何值。因此,我们将设置注释掉并将它们保留在那里只是为了演示如何在需要时设置这些以及类似的值。

请注意,我们使用 setDoOutput() 方法来指示必须发送输出;默认情况下,它设置为 false。然后,我们让输出流将查询参数发送到服务器。

上述 代码的另一个重要方面是输出流在打开输入流之前有 要关闭。否则,输出流的内容将不会发送到服务器。虽然我们明确地这样做了,但更好的方法是使用 try-with-resources 块来保证调用 close() 方法,即使引发异常也是如此块中的任何地方。

这是 UrlPost 类中前面示例的更好版本(使用 try-with-resources 块):

try {
    URL url = new URL("http://localhost:3333/something");
    URLConnection conn = url.openConnection();
    conn.setRequestProperty("Method", "POST");
    conn.setRequestProperty("User-Agent", "Java client");
    conn.setDoOutput(true);
    try (OutputStream os = conn.getOutputStream();
         OutputStreamWriter osw = new OutputStreamWriter(os)) {
       osw.write("parameter1=value1&parameter2=value2");
       osw.flush();
    }
    try (InputStream is = conn.getInputStream();
         BufferedReader br = 
                new BufferedReader(new InputStreamReader(is))) {
       String line;
       while ((line = br.readLine()) != null) {
           System.out.println(line);  //prints server response 
       }
    }
} catch (Exception ex) {
    ex.printStackTrace();
}

如您所见,此代码使用 URI something 在端口 3333 上调用 localhost 服务器,以及查询参数parameter1=value1&parameter2=value2。然后,它立即从服务器读取响应,打印出来,然后退出。

为了演示此示例 的工作原理,我们还创建了一个简单的 服务器,它侦听端口3333 localhost 并分配了一个处理程序来处理 "/something" 路径附带的所有请求(参考 server 文件夹中单独项目中的 Server 类):

private static Properties properties;
public static void main(String[] args){
   ClassLoader classLoader =  
                Thread.currentThread().getContextClassLoader();
   properties = Prop.getProperties(classLoader, 
                                             "app.properties");
   int port = Prop.getInt(properties, "port");
   try {
      HttpServer server = 
             HttpServer.create(new InetSocketAddress(port), 0);
      server.createContext("/something", new PostHandler());
      server.setExecutor(null);
      server.start();
   } catch (IOException e) {
        e.printStackTrace();
   }
} 
private static class PostHandler implements HttpHandler {
    public void handle(HttpExchange exch) {
       System.out.println(exch.getRequestURI());   
                                        //prints: /something  
       System.out.println(exch.getHttpContext().getPath());
                                        //prints: /something
       try (InputStream is = exch.getRequestBody();
            BufferedReader in = 
               new BufferedReader(new InputStreamReader(is));
            OutputStream os = exch.getResponseBody()){
          System.out.println("Received as body:");
          in.lines().forEach(l -> System.out.println(
                                                    "  " + l));
          String confirm = "Got it! Thanks.";
          exch.sendResponseHeaders(200, confirm.length());
          os.write(confirm.getBytes());
       } catch (Exception ex){
            ex.printStackTrace();
       }
    }
}

为了实现服务器,我们使用了 com.sun.net.httpserver 包自带的类 与 JDK 中的 Java 类库。它开始侦听端口 3333 并阻塞,直到请求带有 "/something" 路径。

我们使用了包含 Prop 的 common 库(common 文件夹中的一个单独项目) 类,它提供对 resources 文件夹中的属性文件的访问。请注意,对这个库的引用是如何作为依赖项包含在 server 项目的 pom.xml 文件中的:

        <dependency> 
            <groupId>com.packt.learnjava</groupId> 
            <artifactId>common</artifactId> 
            <version>1.0-SNAPSHOT</version> 
        </dependency> 

Prop 类包括两个方法:

public static Properties getProperties(ClassLoader classLoader,
                                               String fileName){
    String file = classLoader.getResource(fileName).getFile();
    Properties properties = new Properties();
    try(FileInputStream fis = new FileInputStream(file)){
         properties.load(fis);
    } catch (Exception ex) {
         ex.printStackTrace();
    }
    return properties;
}
 
public static int getInt(Properties properties, String name){
    return Integer.parseInt(properties.getProperty(name));
}

我们使用 Prop 类从 app.properties 中获取 port 属性的值server 项目的 文件。

server项目内部PostHandler类的实现演示 URL 没有参数:我们打印 URI 和路径。它们都有相同的 "/something" 值;参数来自请求的主体。

处理完请求后,服务器发回消息“知道了!谢谢。” 让我们看看它是如何工作的;我们首先运行服务器。这可以通过两种方式完成:

  1. 只需使用您的 IDE 运行 Server 类中的 main() 方法。单击两个绿色三角形中的任何一个,如以下屏幕截图所示:
JDK17 |java17学习 第 10 章 管理数据库中的数据
  1. 转到 common 文件夹并执行以下 Maven 命令:
    mvn clean package

此命令编译 common 项目中的 代码并构建 common-1.0-SNAPSHOT.jar target 子目录中的 文件。现在,在 server 文件夹中重复相同的命令,并在 server 文件夹中运行以下命令:

java -cp target/server-1.0-SNAPSHOT.jar:          \
         ../common/target/common-1.0-SNAPSHOT.jar \
         com.packt.learnjava.network.http.Server

如您所见,前面的命令在类路径中列出了两个 .jar 文件(我们刚刚构建的那些)并运行 main()< Server 类的 /code> 方法。

结果是服务器正在等待客户端代码调用它。

现在,让我们执行客户端(UrlPost 类)。我们也可以通过两种方式做到这一点:

  1. 只需使用您的 UrlPost 类中的 main() 方法一个 id="_idIndexMarker1334"> IDE。单击两个绿色三角形中的任何一个,如以下屏幕截图所示:
JDK17 |java17学习 第 10 章 管理数据库中的数据
  1. 转到 examples 文件夹并执行以下 Maven 命令:
    mvn clean package

此命令编译 examples 项目中的代码,并在 examples-1.0-SNAPSHOT.jar 文件文字">目标子目录。

现在,在 examples 文件夹中运行以下命令:

java -cp target/examples-1.0-SNAPSHOT.jar:       \
         com.packt.learnjava.ch11_network.UrlPost

运行客户端代码后,在服务器端屏幕上观察以下输出:

JDK17 |java17学习 第 10 章 管理数据库中的数据

如您所见,服务器成功接收了参数(或任何其他消息)。现在它可以解析它们并根据需要使用它们。

如果我们查看客户端屏幕,我们将看到以下输出:

JDK17 |java17学习 第 10 章 管理数据库中的数据

这意味着客户端收到了来自服务器的消息并按预期退出。

请注意,我们示例中的服务器不会自动退出,必须手动停止。

URLURLConnection 类的其他方法允许您设置/获取其他属性,并可用于对客户端-服务器通信。 java.net 包中还有 HttpUrlConnection 类(和其他类) 简化和增强了基于 URL 的通信。您可以阅读 java.net 包的在线文档以更好地了解可用选项。

Using the HTTP 2 Client API

HTTP 客户端 API 是随 Java 9 引入的,作为 jdk.incubator 中的 孵化 API .http 包。在 Java 11 中,它被标准化并移至 java.net.http 包。它是 URLConnection API 的一个更丰富且更易于使用的 替代方案。除了所有与连接相关的基本功能外,它还使用 CompletableFuture 提供非阻塞(异步)请求和响应,并支持 HTTP 1.1 和 HTTP 2。

HTTP 2 为 HTTP 协议添加了以下新功能:

  • 以二进制格式而不是文本格式发送数据的能力;二进制格式的解析效率更高,更紧凑,并且不易受各种错误的影响。
  • 它是完全多路复用的,因此允许仅使用一个连接同时发送多个请求和响应。
  • 它使用标头压缩,从而减少开销。
  • 如果客户端表明它支持 HTTP 2,它允许服务器将响应推送到客户端的缓存。

该包包含 以下类:

  • HttpClient:用于同步和异步发送请求和接收响应。可以使用具有默认设置的静态 newHttpClient() 方法或使用 HttpClient.Builder 类(由允许您自定义客户端配置的静态 newBuilder() 方法。一旦创建,实例是不可变的,可以多次使用。
  • HttpRequest:这将创建并表示带有目标 URI、标头和其他相关信息的 HTTP 请求。可以使用 HttpRequest.Builder 类(由静态 newBuilder() 方法返回)创建实例。一旦创建,实例是不可变的,可以多次发送。
  • HttpRequest.BodyPublisher:发布一个正文(对于 POSTPUT , 和 DELETE 方法)来自某个源,例如字符串、文件、输入流或字节数组。
  • HttpResponse:这表示客户端在发送 HTTP 请求后收到的 HTTP 响应。它包含原始 URI、标头、消息正文和其他相关信息。创建后,可以多次查询实例。
  • HttpResponse.BodyHandler:这是一个函数式接口,接受响应并返回一个可以处理响应的HttpResponse.BodySubscriber实例身体。
  • HttpResponse.BodySubscriber:接收响应正文(其字节)并将其转换为字符串、文件或类型。

HttpRequest.BodyPublishersHttpResponse.BodyHandlersHttpResponse.BodySubscribers 类是创建相应 类实例的工厂类。例如,BodyHandlers.ofString() 方法创建一个 BodyHandler 实例,将响应正文字节作为字符串处理,而BodyHandlers.ofFile() 方法创建一个 BodyHandler 实例,将响应正文保存在文件中。

您可以阅读 java.net.http 包的在线文档以了解有关这些以及其他相关类和接口的更多信息。接下来,我们将查看并讨论一些 HTTP API 使用示例。

Blocking HTTP requests

以下代码 是一个简单的 HTTP 客户端示例,它向 HTTP 服务器发送 GET 请求(请参阅 HttpClientDemo 类中的="literal">get() 方法):

HttpClient httpClient = HttpClient.newBuilder()
     .version(HttpClient.Version.HTTP_2) // default
     .build();
HttpRequest req = HttpRequest.newBuilder()
     .uri(URI.create("http://localhost:3333/something"))
     .GET()        // default
     .build();
try {
 HttpResponse<String> resp = 
          httpClient.send(req, BodyHandlers.ofString());
 System.out.println("Response: " + 
               resp.statusCode() + " : " + resp.body());
} catch (Exception ex) {
   ex.printStackTrace();
}

我们创建了一个构建器来配置 HttpClient 实例。但是,由于我们只使用了默认设置,我们可以使用相同的结果进行操作,如下所示:

HttpClient httpClient = HttpClient.newHttpClient();

为了演示客户端的功能,我们将使用我们已经使用过的相同的Server类。提醒一下,这是它处理客户端请求并以 “知道了!谢谢。” 进行响应的方式:

try (InputStream is = exch.getRequestBody();
     BufferedReader in = 
            new BufferedReader(new InputStreamReader(is));
     OutputStream os = exch.getResponseBody()){
   System.out.println("Received as body:");
   in.lines().forEach(l -> System.out.println("  " + l));
   String confirm = "Got it! Thanks.";
   exch.sendResponseHeaders(200, confirm.length());
   os.write(confirm.getBytes());
   System.out.println();
} catch (Exception ex){
    ex.printStackTrace();
}

如果我们启动此服务器并运行前面的客户端代码,服务器会在其屏幕上打印以下消息:

JDK17 |java17学习 第 10 章 管理数据库中的数据

客户端没有发送消息,因为它使用了 HTTP GET 方法。尽管如此,服务器还是响应了,客户端的屏幕显示以下消息:

JDK17 |java17学习 第 10 章 管理数据库中的数据

HttpClient 类的 send() 方法被阻塞,直到服务器返回响应。

使用 HTTP POSTPUTDELETE 方法会产生类似的结果;现在让我们运行以下代码(参见 HttpClientDemo 类中的 post() 方法):

HttpClient httpClient = HttpClient.newBuilder()
        .version(Version.HTTP_2)  // default
        .build();
HttpRequest req = HttpRequest.newBuilder()
        .uri(URI.create("http://localhost:3333/something"))
        .POST(BodyPublishers.ofString("Hi there!"))
        .build();
try {
    HttpResponse<String> resp = 
                 httpClient.send(req, BodyHandlers.ofString());
    System.out.println("Response: " + 
                      resp.statusCode() + " : " + resp.body());
} catch (Exception ex) {
    ex.printStackTrace();
}

如您所见,这一次 客户端发布消息 Hi there! 并且服务器的屏幕显示如下:

JDK17 |java17学习 第 10 章 管理数据库中的数据

HttpClient 类的 send() 方法被阻塞,直到服务器返回相同的响应:

JDK17 |java17学习 第 10 章 管理数据库中的数据

 

到目前为止,演示的功能与我们在上一节中看到的基于 URL 的通信没有太大区别。现在我们将使用 URL 流中不可用的 HttpClient 方法。

Non-blocking (asynchronous) HTTP requests

HttpClient 类的 sendAsync() 方法 允许您发送一个向服务器发送消息而不阻塞。为了演示它是如何工作的,我们将执行以下代码(参见 HttpClientDemo 类中的 getAsync1() 方法):

HttpClient httpClient = HttpClient.newHttpClient();
HttpRequest req = HttpRequest.newBuilder()
        .uri(URI.create("http://localhost:3333/something"))
        .GET()   // default
        .build();
CompletableFuture<Void> cf = httpClient
        .sendAsync(req, BodyHandlers.ofString())
        .thenAccept(resp -> System.out.println("Response: " +
                   resp.statusCode() + " : " + resp.body()));
System.out.println("The request was sent asynchronously...");
try {
    System.out.println("CompletableFuture get: " +
                                cf.get(5, TimeUnit.SECONDS));
} catch (Exception ex) {
    ex.printStackTrace();
}
System.out.println("Exit the client...");

与使用 send() 方法(返回 HttpResponse 对象)的示例相比,sendAsync() 方法返回 CompletableFuture<HttpResponse> 类的实例。如果您阅读 CompletableFuture<T> 类的文档,您会看到它实现了 java.util.concurrent.CompletionStage 接口,它提供了许多可以 链接的方法,并允许您设置各种函数来处理响应。

为了给你一个想法,这里是在 CompletionStage 接口中声明的方法列表:acceptEither, acceptEitherAsync, acceptEitherAsync, applyToEither, applyToEitherAsync , applyToEitherAsync, handle, handleAsync, handleAsync, runAfterBoth, runAfterBothAsync, runAfterBothAsync, < code class="literal">runAfterEither, runAfterEitherAsync, runAfterEitherAsync, thenAccept , thenAcceptAsync, thenAcceptAsync, thenAcceptBoth, thenAcceptBothAsyncthenAcceptBothAsyncthenApplythenApplyAsync thenApplyAsyncthenCombinethenCombineAsyncthenCombineAsync, thenCompose, thenComposeAsync, thenComposeAsync , thenRun, thenRunAsync, thenRunAsync, whenCompletewhenCompleteAsyncwhenCompleteAsync

我们将在 第 13 章函数式编程。现在,我们只提到 resp -> System.out.println("Response: " + resp.statusCode() + " : " + resp.body()) 构造表示的功能与以下方法相同:

void method(HttpResponse resp){
    System.out.println("Response: " + 
                      resp.statusCode() + " : " + resp.body());
}

thenAccept() 方法将传入的功能应用于链的前一个方法返回的结果。

CompletableFuture<Void>实例返回后,上述代码打印出请求被异步发送...消息并阻塞在 CompletableFuture<Void> 对象的 get() 方法上。该方法有一个重载版本get(long timeout, TimeUnit unit),有两个参数,TimeUnit unitlong timeout,指定单元数,表示方法应该等待由CompletableFuture 对象来完成。在我们的例子中,任务是向服务器发送消息 并取回响应(并使用提供的函数对其进行处理)。如果任务没有在分配的时间内完成,则 get() 方法被中断(并且堆栈跟踪打印在 catch 块)。

Exit the client... 消息应该在 5 秒后(在我们的例子中)或 get() 出现在屏幕上代码>方法返回。

如果我们运行客户端,服务器的屏幕会再次显示以下消息,并带有阻塞的 HTTP GET 请求:

JDK17 |java17学习 第 10 章 管理数据库中的数据

客户端的屏幕显示以下消息:

JDK17 |java17学习 第 10 章 管理数据库中的数据

如您所见,请求是异步发送的...消息在服务器返回响应之前出现。这是异步调用的重点;对服务器的请求已发送,客户端可以继续执行其他任何操作。传入的函数将应用于服务器响应。同时,您可以传递 CompletableFuture 对象,随时调用它来获取结果。在我们的例子中,结果是 void,所以 get() 方法只是表明任务已经完成。

我们知道服务器返回消息,因此我们可以通过使用 CompletionStage 接口的另一个方法来利用它。我们选择了 thenApply() 方法,它接受一个返回值的函数:

CompletableFuture<String> cf = httpClient
                .sendAsync(req, BodyHandlers.ofString())
                .thenApply(resp -> "Server responded: " + 
                 resp.body());

现在,get() 方法返回由 resp -> 生成的值。 "服务器响应:" + resp.body() 函数,所以它应该返回服务器消息体;让我们运行这段代码(参见 HttpClientDemo 类中的 getAsync2() 方法)并查看结果:

JDK17 |java17学习 第 10 章 管理数据库中的数据

现在,get()方法按预期返回服务器的消息,并由函数呈现并作为参数传递thenApply() 方法。

同样,我们可以使用 HTTP POSTPUTDELETE 方法用于发送消息(参见 HttpClientDemo 类中的 postAsync() 方法):

HttpClient httpClient = HttpClient.newHttpClient();
HttpRequest req = HttpRequest.newBuilder()
        .uri(URI.create("http://localhost:3333/something"))
        .POST(BodyPublishers.ofString("Hi there!"))
        .build();
CompletableFuture<String> cf = httpClient
        .sendAsync(req, BodyHandlers.ofString())
        .thenApply(resp -> "Server responded: " + resp.body());
System.out.println("The request was sent asynchronously...");
try {
    System.out.println("CompletableFuture get: " +
                                cf.get(5, TimeUnit.SECONDS));
} catch (Exception ex) {
    ex.printStackTrace();
}
System.out.println("Exit the client...");

与前面的 示例的唯一区别是服务器现在显示接收到的客户端消息:

JDK17 |java17学习 第 10 章 管理数据库中的数据

客户端屏幕显示与 GET 方法相同的消息:

JDK17 |java17学习 第 10 章 管理数据库中的数据

异步请求的优点是可以快速发送,无需等待每个请求完成。 HTTP 2 协议通过多路复用来支持它;例如,让我们发送三个请求如下(参见 HttpClientDemo 类中的 postAsyncMultiple() 方法):

HttpClient httpClient = HttpClient.newHttpClient();
List<CompletableFuture<String>> cfs = new ArrayList<>();
List<String> nums = List.of("1", "2", "3");
for(String num: nums){
    HttpRequest req = HttpRequest.newBuilder()
           .uri(URI.create("http://localhost:3333/something"))
           .POST(BodyPublishers.ofString("Hi! My name is " 
                                               + num + "."))
           .build();
    CompletableFuture<String> cf = httpClient
           .sendAsync(req, BodyHandlers.ofString())
           .thenApply(rsp -> "Server responded to msg " + num + 
                 ": " + rsp.statusCode() + " : " + rsp.body());
    cfs.add(cf);
}
System.out.println("The requests were sent asynchronously...");
try {
    for(CompletableFuture<String> cf: cfs){
        System.out.println("CompletableFuture get: " + 
                                  cf.get(5, TimeUnit.SECONDS));
    }
} catch (Exception ex) {
    ex.printStackTrace();
}
System.out.println("Exit the client...");

服务器的屏幕显示以下消息:

JDK17 |java17学习 第 10 章 管理数据库中的数据

注意传入请求的任意顺序;这是因为客户端使用 Executors.newCachedThreadPool() 线程池来发送消息。每条消息都由不同的线程发送,并且池有自己的逻辑来使用池成员(线程)。如果消息的数量很大,或者每个消息都消耗大量内存,那么限制并发运行的线程数可能是有益的。

HttpClient.Builder 类允许您指定用于获取发送消息的线程的池(请参阅 postAsyncMultipleCustomPool() HttpClientDemo 类中的 code> 方法):

ExecutorService pool = Executors.newFixedThreadPool(2);
HttpClient httpClient = HttpClient.newBuilder().executor(pool).build();
List<CompletableFuture<String>> cfs = new ArrayList<>();
List<String> nums = List.of("1", "2", "3");
for(String num: nums){
    HttpRequest req = HttpRequest.newBuilder()
          .uri(URI.create("http://localhost:3333/something"))
          .POST(BodyPublishers.ofString("Hi! My name is " 
                                                + num + "."))
          .build();
    CompletableFuture<String> cf = httpClient
          .sendAsync(req, BodyHandlers.ofString())
          .thenApply(rsp -> "Server responded to msg " + num + 
                 ": " + rsp.statusCode() + " : " + rsp.body());
    cfs.add(cf);
}
System.out.println("The requests were sent asynchronously...");
try {
    for(CompletableFuture<String> cf: cfs){
        System.out.println("CompletableFuture get: " + 
                                  cf.get(5, TimeUnit.SECONDS));
    }
} catch (Exception ex) {
    ex.printStackTrace();
}
System.out.println("Exit the client...");

如果我们运行前面的 代码,结果是一样的,但是客户端将只使用两个线程来发送消息。随着消息数量的增加,性能可能会稍慢一些(与前面的示例相比)。因此,就像软件系统设计中经常出现的情况一样,您需要平衡使用的内存量和性能。

与执行器类似,可以在 HttpClient 对象上设置其他几个对象来配置连接 来处理身份验证、请求重定向、 cookie 管理等。

Server push functionality

HTTP 2 协议相对于 HTTP 1.1 的第二个(在多路复用之后)显着的优势是,如果客户端指示它支持 HTTP 2,则允许服务器将响应推送到客户端的缓存中。以下是利用此功能的客户端代码(参见 HttpClientDemo 类中的 push() 方法):

HttpClient httpClient = HttpClient.newHttpClient();
HttpRequest req = HttpRequest.newBuilder()
   .uri(URI.create("http://localhost:3333/something"))
    .GET()
    .build();
CompletableFuture cf = httpClient
    .sendAsync(req, BodyHandlers.ofString(), 
       (PushPromiseHandler) HttpClientDemo::applyPushPromise);
System.out.println("The request was sent asynchronously...");
try {
    System.out.println("CompletableFuture get: " + 
                                cf.get(5, TimeUnit.SECONDS));
} catch (Exception ex) {
    ex.printStackTrace();
}
System.out.println("Exit the client...");

注意 sendAsync() 方法的第三个参数。如果一个来自服务器的推送响应,它是一个处理推送响应的函数。由客户端开发人员决定如何实现此功能;这是一个可能的例子:

void applyPushPromise(HttpRequest initReq, HttpRequest pushReq,
 Function<BodyHandler, CompletableFuture<HttpResponse>> 
 acceptor){
  CompletableFuture<Void> cf = 
   acceptor.apply(BodyHandlers.ofString())
  .thenAccept(resp -> System.out.println("Got pushed response " 
                                                + resp.uri()));
  try {
        System.out.println("Pushed completableFuture get: " + 
                                  cf.get(1, TimeUnit.SECONDS));
  } catch (Exception ex) {
        ex.printStackTrace();
  }
  System.out.println("Exit the applyPushPromise function...");
}

这个函数的实现并没有做太多。它只是打印出推送源的 URI。但是,如果有必要,它可以用于从服务器接收资源(例如,支持提供的 HTML 的图像)而无需请求它们。该方案节省了往返请求-响应模型,缩短了页面加载时间。它还可以用于更新页面上的信息。

您可以找到许多发送推送请求的服务器的代码示例;所有主流浏览器也都支持此功能。

WebSocket support

HTTP 基于请求-响应 模型。客户端请求资源, 服务器对此请求提供响应。正如我们多次演示的那样,客户端发起通信。没有它,服务器无法向客户端发送任何内容。为了克服这个限制,这个想法首先在 HTML5 规范中作为 TCP 连接引入,并在 2008 年设计了 WebSocket 协议的第一个版本。

它在客户端和服务器之间提供了一个全双工的通信通道。连接建立后,服务器可以随时向客户端发送消息。与 JavaScript 和 HTML5 一起,WebSocket 协议支持允许 Web 应用程序呈现更加动态的用户界面。

WebSocket 协议 规范定义了 WebSocket (ws) 和 WebSocket Secure (wss ) 作为分别用于未加密和加密连接的两种方案。该协议不支持分段,但允许在 URL 语法 部分中描述的所有其他 URI 组件。

所有支持客户端的 WebSocket 协议 的类都位于 java.net 包中。要创建客户端,我们需要实现WebSocket.Listener接口,该接口有以下方法:

  • onText():收到文本数据时调用
  • onBinary():收到二进制数据时调用
  • onPing():收到ping消息时调用
  • onPong():收到pong消息时调用
  • onError():发生错误时调用
  • onClose():收到关闭消息时调用

该接口的所有方法都是default。这意味着您不需要实现所有这些,而只需实现客户端执行特定任务所需的那些(请参阅 WsClient 中的私有类">HttpClientDemo 类):

class WsClient implements WebSocket.Listener {
    @Override
    public void onOpen(WebSocket webSocket) {
        System.out.println("Connection established.");
        webSocket.sendText("Some message", true);
        Listener.super.onOpen(webSocket);
    }
    @Override
    public CompletionStage onText(WebSocket webSocket, 
                             CharSequence data, boolean last) {
        System.out.println("Method onText() got data: " + 
                                                         data);
        if(!webSocket.isOutputClosed()) {
            webSocket.sendText("Another message", true);
        }
        return Listener.super.onText(webSocket, data, last);
    }
    @Override
    public CompletionStage onClose(WebSocket webSocket, 
                               int statusCode, String reason) {
        System.out.println("Closed with status " + 
                           statusCode + ", reason: " + reason);
        return Listener.super.onClose(webSocket, 
                                           statusCode, reason);
    }
}

可以用类似的方式实现服务器,但服务器实现超出了本书的范围。为了演示前面的客户端代码,我们将使用 echo.websocket.events 网站提供的 WebSocket 服务器。它允许 WebSocket 连接并将接收到的消息发送回;这样的服务器通常称为 echo 服务器。

我们希望我们的客户端在连接建立后发送消息。然后,它将从服务器接收(相同的)消息,显示它,然后发回另一条消息,依此类推,直到关闭。以下代码调用我们创建的客户端(参见 HttpClientDemo 类中的 webSocket() 方法):

HttpClient httpClient = HttpClient.newHttpClient();
WebSocket webSocket = httpClient.newWebSocketBuilder()
    .buildAsync(URI.create("ws://echo.websocket.events"), 
                           new WsClient()).join();
System.out.println("The WebSocket was created and ran asynchronously.");
try {
    TimeUnit.MILLISECONDS.sleep(200);
} catch (InterruptedException ex) {
    ex.printStackTrace();
}
webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "Normal closure")
         .thenRun(() -> System.out.println("Close is sent."));

前面的代码使用 WebSocket.Builder 类创建了一个 WebSocket 对象。 buildAsync() 方法返回 CompletableFuture 对象。 CompletableFuture 类的 join() 方法返回 时的结果值 完成,或抛出异常。如果没有产生异常,那么,正如我们已经提到的,WebSocket 通信会继续 直到任何一方发送 关闭消息。这就是为什么我们的客户端等待 200 毫秒,然后发送 Close 消息并退出。如果我们运行此代码,我们将看到以下消息:

JDK17 |java17学习 第 10 章 管理数据库中的数据

 

如您所见,客户端的行为符合预期。为了结束我们的讨论,我们想提一下所有现代 Web 浏览器都支持 WebSocket 协议的事实。

Summary

在本章中,向您介绍了最流行的网络协议:UDP、TCP/IP 和 WebSocket。讨论通过使用 JCL 的代码示例进行了说明。我们还查看了基于 URL 的通信和最新的 Java HTTP 2 客户端 API。

现在您可以使用基本的 Internet 协议在客户端和服务器之间发送/接收消息,并且还知道如何将服务器创建为单独的项目以及如何创建和使用公共共享库。

下一章概述 Java GUI 技术并演示使用 JavaFX 的 GUI 应用程序,包括带有控制元素、图表、CSS、FXML、HTML、媒体和各种其他效果的代码示例。您将学习如何使用 JavaFX 创建 GUI 应用程序。

Quiz

  1. 说出应用层的五种网络协议。
  2. 说出传输层的两个网络协议。
  3. 哪个 Java 包包含支持 HTTP 协议的类?
  4. 哪个协议基于交换数据报?
  5. 可以将数据报发送到没有服务器运行的 IP 地址吗?
  6. 哪个 Java 包包含支持 UDP 和 TCP 协议的类?
  7. TCP 代表什么?
  8. TCP 和 TCP/IP 协议之间的共同点是什么?
  9. 如何识别 TCP 会话?
  10. 请说出 ServerSocketSocket 的功能之间的主要区别。
  11. TCP 和 UDP 哪个更快?
  12. TCP 和 UDP 哪个更可靠?
  13. 说出三个基于 TCP 的协议。
  14. 以下哪些是 URI 的组成部分?选择所有符合条件的:
    1. 片段
    2. 标题
    3. 权威
    4. 查询
  15. schemeprotocol 有什么区别?
  16. URI 和 URL 有什么区别?
  17. 下面的代码打印什么?
      URL url = new URL("http://www.java.com/something?par=42");   System.out.print(url.getPath());     System.out.println(url.getFile());   
  18. 请列举 HTTP 2 具有但 HTTP 1.1 没有的两个新特性。
  19. HttpClient 类的完全限定名称是什么?
  20. WebSocket 类的完全限定名称是什么?
  21. HttpClient.newBuilder().build()HttpClient.newHttpClient() 有什么区别?
  22. CompletableFuture 类的完全限定名称是什么?