Skip to content

Latest commit

 

History

History
998 lines (748 loc) · 58.7 KB

File metadata and controls

998 lines (748 loc) · 58.7 KB

十一、网络编程

在本章中,我们将描述和讨论最流行的网络协议——用户数据报协议UDP)、传输控制协议TCP)、超文本传输协议HTTP)和 WebSocket——以及来自 Java 类库JCL)的支持。我们将演示如何使用这些协议以及如何用 Java 代码实现客户端——服务器通信。我们还将回顾基于统一资源定位器URL)的通信和最新的 Java HTTP 客户端 API

本章将讨论以下主题:

  • 网络协议
  • 基于 UDP 的通信
  • 基于 TCP 的通信
  • UDP 与 TCP 协议
  • 基于 URL 的通信
  • 使用 HTTP 2 客户端 API

网络协议

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

  • 链路层:客户端物理连接到主机时使用的一组协议,三个核心协议包括地址解析协议ARP)、反向地址解析协议RARP),以及邻居发现协议NDP)。
  • 互联网层:一组由 IP 地址指定的用于将网络包从发起主机传输到目的主机的互联方法、协议和规范。这一层的核心协议是互联网协议版本 4IPv4)和互联网协议版本 6IPv6),IPv6 指定了一种新的数据包格式,并为点式 IP 地址分配 128 位,而 IPv4 是 32 位。IPv4 地址的一个例子是10011010.00010111.11111110.00010001,其结果是 IP 地址为154.23.254.17
  • 传输层:一组主机对主机的通信服务。它包括 TCP,也称为 TCP/IP 协议和 UDP(我们稍后将讨论);这一组中的其他协议有数据报拥塞控制协议DCCP)和流控制传输协议SCTP)。
  • 应用层:通信网络中主机使用的一组协议和接口方法。包括 Telnet文件传输协议FTP)、域名系统DNS)、简单邮件传输协议SMTP),轻量级目录访问协议LDAP)、超文本传输协议HTTP)、超文本传输协议安全HTTPS)、安全外壳SSH)。

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

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

Java 用java.net包的类支持 TCP 和 UDP 协议,而 HTTP 协议可以用java.net.http包的类在 Java 应用中实现(这是 Java11 引入的)。

TCP 和 UDP 协议都可以使用套接字在 Java 中实现。套接字由 IP 地址和端口号的组合标识,它们表示两个应用之间的连接。

基于 UDP 的通信

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为数据包长度,address保存目的 IP 地址,port为目的端口号。

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

  • void setAddress(InetAddress iaddr):设置目的 IP 地址。
  • InetAddress getAddress():返回目的地或源 IP 地址。
  • void setData(byte[] buf):设置数据缓冲区。
  • void setData(byte[] buf, int offset, int length):设置数据缓冲区、数据偏移量、长度。
  • 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上监听,但是没有消息传来—所以它等待。然后,我们运行发送方,接收方显示以下消息:

因为缓冲区比消息小,所以只接收了部分消息—消息的其余部分丢失。我们可以创建一个无限循环,让接收器无限期地运行:

while(true){
    ds.receive(dp);
    for(byte b: dp.getData()){
        System.out.print(Character.toString(b));
    }
    System.out.println();
}

通过这样做,我们可以多次运行发送器;如果我们运行发送器三次,则接收器将打印以下内容:

如您所见,所有三条消息都被接收;但是,接收器只捕获每条消息的前 16 个字节

现在让我们将接收缓冲区设置为大于消息:

DatagramPacket dp = new DatagramPacket(new byte[30], 30);

如果我们现在发送相同的消息,结果如下:

为了避免处理空的缓冲区元素,可以使用DatagramPacket类的getLength()方法,该方法返回消息填充的缓冲区元素的实际数量:

int i = 1;
for(byte b: dp.getData()){
    System.out.print(Character.toString(b));
    if(i++ == dp.getLength()){
        break;
    }
}

上述代码的结果如下:

这就是 UDP 协议的基本思想。发送方将消息发送到某个地址和端口,即使在该地址和端口上没有监听的套接字。它不需要在发送消息之前建立任何类型的连接,这使得 UDP 协议比 TCP 协议更快、更轻量级(TCP 协议要求您首先建立连接)。通过这种方式,TCP 协议将消息发送到另一个可靠性级别—通过确保目标存在并且消息可以被传递。

基于 TCP 的通信

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

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

与 UDP 协议不同,基于 TCP/IP 的通信侧重于准确的传输,而牺牲了传输周期。这就是为什么它不用于实时应用,比如 IP 语音,在这些应用中,需要可靠的传递和正确的顺序排序。然而,如果每一位都需要以相同的顺序准确地到达,那么 TCP/IP 是不可替代的

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

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

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

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

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

java.net.ServerSocket

java.net.ServerSocket类有四个构造器:

  • ServerSocket():这将创建一个不绑定到特定地址和端口的服务器套接字对象。需要使用bind()方法绑定套接字。
  • ServerSocket(int port):创建绑定到所提供端口的服务器套接字对象。port值必须在065535之间。如果端口号被指定为值0,这意味着需要自动绑定端口号。默认情况下,传入连接的最大队列长度为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();
    }
  }
}

让我们浏览前面的代码。在资源尝试语句中,我们基于新创建的套接字创建了SocketDataInputStreamDataOutputStream对象,并创建了BufferedReader对象从控制台读取用户输入(我们将使用它输入数据)。在创建套接字时,accept()方法会阻塞,直到客户端尝试连接到本地服务器的端口3333

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

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

所描述的算法假设客户端只有在发送或接收到"end"消息时才退出。否则,如果客户端随后尝试向服务器发送消息,则会生成异常。这说明了我们前面提到的 UDP 和 TCP 协议之间的区别–TCP 基于在服务器和客户端套接字之间建立的会话。如果一方掉下来,另一方马上就会遇到错误。

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

java.net.Socket

java.net.Socket类现在应该是您熟悉的了,因为它是在前面的示例中使用的。我们使用它来访问连接的套接字的输入和输出流。现在我们将系统地回顾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"服务器建立连接,因此它期望localhost服务器启动并监听端口3333,其余与服务器代码相同。

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

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

运行示例

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

我们打招呼!点击Enter

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

我们打嗨!在服务器端屏幕上点击Enter

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

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

然后,服务器执行以下操作:

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

UDP 与 TCP 协议

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

  • UDP 只发送数据,不管数据接收器是否启动和运行。这就是为什么 UDP 比许多其他使用多播分发的客户端更适合发送数据。另一方面,TCP 要求首先在客户端和服务器之间建立连接。TCP 客户端发送一个特殊的控制消息;服务器接收该消息并用确认消息进行响应。然后,客户端向服务器发送一条消息,确认服务器确认。只有这样,客户端和服务器之间的数据传输才有可能
  • TCP 保证消息传递或引发错误,而 UDP 不保证,并且数据报数据包可能丢失。
  • TCP 保证在传递时保留消息的顺序,而 UDP 不保证。
  • 由于提供了这些保证,TCP 比 UDP 慢
  • 此外,协议要求标头与数据包一起发送。TCP 数据包的头大小是 20 字节,而数据报数据包是 8 字节。UDP 标头包含LengthSource PortDestination PortChecksum,TCP 标头除了 UDP 标头外,还包含Sequence NumberAck NumberData OffsetReservedControl BitWindowUrgent PointerOptionsPadding
  • 有基于 TCP 或 UDP 协议的不同应用协议。基于 TCP 的协议有 HTTPHTTPSTelnetFTPSMTP。基于 UDP 的协议有动态主机配置协议DHCP)、域名系统DNS)、简单网络管理协议SNMP),普通文件传输协议TFTP)、引导协议BOOTP),以及早期版本的网络文件系统NFS)。

我们可以用一句话来描述 UDP 和 TCP 之间的区别:UDP 协议比 TCP 更快、更轻量级,但可靠性更低。就像生活中的许多事情一样,你必须为额外的服务付出更高的代价。但是,并非所有情况下都需要这些服务,因此请考虑手头的任务,并根据您的应用需求决定使用哪种协议。

基于 URL 的通信

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

URL 语法

一般来说,URL 语法遵循具有以下格式的统一资源标识符URI)的语法:

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

方括号表示组件是可选的。这意味着 URI 将至少由scheme:path组成。scheme分量可以是httphttpsftpmailtofiledata或其他值。path组件由一系列由斜线(/分隔的路径段组成。以下是仅由schemepath组成的 URL 的示例:

file:src/main/resources/hello.txt

前面的 URL 指向本地文件系统上的一个文件,该文件相对于使用此 URL 的目录。我们将很快演示它的工作原理。

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代替scheme
  • reference代替fragment
  • file代替path[?query][#fragment]
  • resource代替host[:port]path[?query][#fragment]

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

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

java.net.URL

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

  • URL(String spec):从 URL 创建一个URL对象作为字符串。
  • URL(String protocol, String host, String file):根据提供的protocolhostfilepathquery的值,以及基于提供的protocol值的默认端口号,创建一个URL对象。
  • URL(String protocol, String host, int port, String path):根据提供的protocolhostportfilepathquery的值创建URL对象,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()方法。

让我们看一看代码示例;我们首先从一个hello.txt文件中读取数据,这个文件是我们在第 5 章中创建的本地文件,“字符串、输入/输出和文件”。文件只包含一行:“你好!”;下面是读取它的代码:

try {
   URL url = new URL("file:src/main/resources/hello.txt");
   System.out.println(url.getPath());    // src/main/resources/hello.txt
   System.out.println(url.getFile());    // src/main/resources/hello.txt
   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();
}

在前面的代码中,我们使用了file:src/main/resources/hello.txtURL。它基于相对于程序执行位置的文件路径。程序在我们项目的根目录中执行。首先,我们演示了getPath()getFile()方法,返回的值没有区别,因为 URL 没有query组件值。否则,getFile()方法也会包括它。我们将在下面的代码示例中看到这一点。

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

现在,让我们演示 Java 代码如何从指向互联网上源的 URL 读取数据。让我们用一个Java关键字来调用谷歌搜索引擎:

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=10URL,并在进行了一些研究和实验后要求属性。没有保证它总是有效的,所以如果它不返回我们描述的相同数据,不要感到惊讶。此外,它是一个实时搜索,因此结果可能随时变化

前面的代码还演示了由getPath()getFile()方法返回的值之间的差异。您可以在前面的代码示例中查看内联注释。

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

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

上一个示例中的其余代码只是读取来自 URL 的输入数据流(HTML 代码),然后逐行打印它。我们捕获了结果(从屏幕上复制),将其粘贴到在线 HTML 格式化程序中,然后运行它。结果显示在以下屏幕截图中:

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

类似地,也可以向 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);
    OutputStreamWriter osw =
            new OutputStreamWriter(conn.getOutputStream());
    osw.write("parameter1=value1&parameter2=value2");
    osw.flush();
    osw.close();

    BufferedReader br =
       new BufferedReader(new InputStreamReader(conn.getInputStream()));
    String line;
    while ((line = br.readLine()) != null) {
        System.out.println(line);
    }
    br.close();
} catch (Exception e) {
    e.printStackTrace();
}

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

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

前面代码的另一个重要方面是在打开输入流之前必须关闭输出流。否则,输出流的内容将不会发送到服务器。虽然我们显式地这样做了,但是更好的方法是使用资源尝试块,它保证调用close()方法,即使在块中的任何地方引发了异常。

以下是上述示例的更好版本:

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(OutputStreamWriter osw =
                new OutputStreamWriter(conn.getOutputStream())){
        osw.write("parameter1=value1&parameter2=value2");
        osw.flush();
    }
    try(BufferedReader br =
      new BufferedReader(new InputStreamReader(conn.getInputStream()))){
        String line;
        while ((line = br.readLine()) != null) {
            System.out.println(line);
        }
    }
} catch (Exception ex) {
    ex.printStackTrace();
}

为了演示这个示例是如何工作的,我们还创建了一个简单的服务器,它监听localhost的端口3333,并分配了一个处理器来处理"/something"路径中的所有请求:

public static void main(String[] args) throws Exception {
    HttpServer server = HttpServer.create(new InetSocketAddress(3333),0);
    server.createContext("/something", new PostHandler());
    server.setExecutor(null);
    server.start();
}
static class PostHandler implements HttpHandler {
    public void handle(HttpExchange exch) {
       System.out.println(exch.getRequestURI());   //prints: /something
       System.out.println(exch.getHttpContext().getPath());///something
       try(BufferedReader in = new BufferedReader(
                new InputStreamReader(exch.getRequestBody()));
           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();
        }
    }
}

为了实现服务器,我们使用了 JCL 附带的com.sun.net.httpserver包的类。为了证明 URL 没有参数,我们打印 URI 和路径。它们都有相同的"/something"值;参数来自请求的主体。

请求处理完成后,服务器发回消息“收到!谢谢。”让我们看看它是怎么工作的;我们先运行服务器。它开始监听端口3333并阻塞,直到请求带有"/something"路径。然后,我们执行客户端并在服务器端屏幕上观察以下输出:

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

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

这意味着客户端从服务器接收到消息并按预期退出。注意,我们示例中的服务器不会自动退出,必须手动关闭。

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

使用 HTTP 2 客户端 API

HTTP 客户端 API 是在 Java9 中引入的,作为jdk.incubator.http包中的孵化 API,在 Java11 中被标准化并转移到java.net.http包中,它是一个比URLConnectionAPI 更丰富、更易于使用的替代品。除了所有与连接相关的基本功能外,它还使用CompletableFuture提供非阻塞(异步)请求和响应,并支持 HTTP1.1 和 HTTP2。

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

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

包包含以下类:

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

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

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

阻塞 HTTP 请求

以下代码是向 HTTP 服务器发送GET请求的简单 HTTP 客户端的示例:

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();

为了演示客户端的功能,我们将使用与我们已经使用的相同的UrlServer类。作为提醒,这就是它如何处理客户的请求并用"Got it! Thanks."响应:

try(BufferedReader in = new BufferedReader(
            new InputStreamReader(exch.getRequestBody()));
    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();
}

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

客户端没有发送消息,因为它使用了 HTTPGET方法。不过,服务器会做出响应,客户端屏幕会显示以下消息:

在服务器返回响应之前,HttpClient类的send()方法被阻塞

使用 HTTPPOSTPUTDELETE方法会产生类似的结果;现在让我们运行以下代码:

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!”,服务器屏幕显示以下内容:

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

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

非阻塞(异步)HTTP 请求

HttpClient类的sendAsync()方法允许您向服务器发送消息而不阻塞。为了演示它的工作原理,我们将执行以下代码:

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接口中声明的方法列表:acceptEitheracceptEitherAsyncacceptEitherAsyncapplyToEitherapplyToEitherAsyncapplyToEitherAsynchandlehandleAsynchandleAsyncrunAfterBothrunAfterBothAsyncrunAfterBothAsyncrunAfterEitherrunAfterEitherAsyncrunAfterEitherAsyncthenAcceptthenAcceptAsyncthenAcceptAsyncthenAcceptBoththenAcceptBothAsyncthenAcceptBothAsyncthenApplythenApplyAsyncthenApplyAsyncthenCombinethenCombineAsyncthenCombineAsyncthenComposethenComposeAsyncthenComposeAsyncthenRunthenRunAsyncthenRunAsyncwhenCompletewhenCompleteAsyncwhenCompleteAsync

我们将在第 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<Void>对象表示的任务完成多长时间。在我们的例子中,任务是向服务器发送消息并获取响应(并使用提供的函数进行处理)。如果任务没有在分配的时间内完成,get()方法被中断(栈跟踪被打印在catch块中)。

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

如果我们运行客户端,服务器屏幕会再次显示以下消息,并阻止 HTTPGET请求:

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

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

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

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

现在get()方法返回resp -> "Server responded: " + resp.body()函数产生的值,所以它应该返回服务器消息体;让我们运行下面的代码,看看结果:

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

同样,我们可以使用 HTTPPOSTPUTDELETE方法发送消息:

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...");

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

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

异步请求的优点是可以快速发送,而不需要等待每个请求完成。HTTP 2 协议通过多路复用来支持它;例如,让我们发送三个请求,如下所示:

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...");

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

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

HttpClient.Builder类允许您指定用于获取发送消息的线程的池:

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 管理等。

服务器推送功能

与 HTTP1.1 相比,HTTP2 协议的第二个(在多路复用之后)显著优点是,如果客户端指示它支持 HTTP2,则允许服务器将响应推送到客户端的缓存中。以下是利用此功能的客户端代码:

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 支持

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。这意味着您不需要实现所有这些功能,而只需要实现客户端为特定任务所需的功能:

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.org网站提供的 WebSocket 服务器。它允许 WebSocket 连接并将接收到的消息发回;这样的服务器通常称为回送服务器

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

HttpClient httpClient = HttpClient.newHttpClient();
WebSocket webSocket = httpClient.newWebSocketBuilder()
    .buildAsync(URI.create("ws://echo.websocket.org"), 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 毫秒,然后发送关闭消息并退出。如果运行此代码,将看到以下消息:

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

总结

本章向读者介绍了最流行的网络协议:UDP、TCP/IP 和 WebSocket。讨论通过使用 JCL 的代码示例进行了说明。我们还回顾了基于 URL 的通信和最新的 Java HTTP2 客户端 API。

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

测验

  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 的组件?选择所有适用的选项:

  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());   
  1. 列举两个 HTTP2 具有的、HTTP1.1 没有的新特性。
  2. HttpClient类的完全限定名是什么?
  3. WebSocket类的完全限定名是什么?
  4. HttpClient.newBuilder().build()HttpClient.newHttpClient()有什么区别?
  5. CompletableFuture类的完全限定名是什么?