Java-学习指南第六版-六-

37 阅读1小时+

Java 学习指南第六版(六)

原文:zh.annas-archive.org/md5/d44128f2f1df4ebf2e9d634772ea8cd1

译者:飞龙

协议:CC BY-NC-SA 4.0

第十三章:Java 网络编程

当你想到网络时,你可能会想到基于网络的应用和服务。如果你被要求深入探讨,你可能会考虑支持这些应用程序并在网络中传输数据的工具,例如网络浏览器和网络服务器。在本章中,我们将看一下 Java 如何与网络服务交互。我们还会稍微窥探一下底层的网络类,例如 java.net 包中的一些低级网络类。

统一资源定位符

统一资源定位符(URL)指向互联网上的一个对象。它是一个文本字符串,用于标识一个项目,告诉你在哪里找到它,并指定与之通信或从其源获取它的方法。URL 可以指向任何类型的信息源:静态数据,例如本地文件系统上的文件、Web 服务器或 FTP 站点。它可以指向更动态的对象,例如 RSS 新闻订阅或数据库中的记录。URL 还可以引用其他资源,例如电子邮件地址。

因为在互联网上定位项目有许多不同的方法,不同的介质和传输需要不同类型的信息,URL 可以具有多种形式。最常见的形式包含如 图 13-1 所示的四个组件:网络主机或服务器、项目名称、其在主机上的位置以及主机应该使用的协议。

ljv6 1301

图 13-1. URL 的常见元素

protocol(也称为“方案”)是诸如 httphttpsftp 的标识符;hostname 通常是互联网主机和域名;pathresource 组件形成了一个唯一的路径,用于标识该主机上的对象。

这种形式的变体在 URL 中包含额外的信息。例如,你可以指定片段标识符(以“#”字符开头的后缀),用来引用文档内的各个部分。还有其他更专门的 URL 类型,比如用于电子邮件地址的“mailto” URL,或者用于定位诸如数据库组件之类的 URL。这些定位符可能不严格遵循此格式,但通常包含协议、主机和路径。一些更适当地称为 统一资源标识符(URI)的内容,可以指定有关资源名称或位置的更多信息。URL 是 URI 的子集。

因为大多数 URL 具有层次结构或路径的概念,所以我们有时会说一个 URL 相对于另一个 URL,称为 基本 URL。在这种情况下,我们使用基本 URL 作为起点,并提供额外的信息来相对于该 URL 定位一个对象。例如,基本 URL 可能指向 Web 服务器上的一个目录,而相对 URL 可能命名该目录中的特定文件或子目录中的文件。

URL 类

Java 的java.net.URL类表示 URL 地址,并为访问服务器上的文档和应用程序等网络资源提供了一个简单的 API。它可以使用可扩展的协议和内容处理程序来执行必要的通信,理论上甚至可以进行数据转换。使用URL类,应用程序只需几行代码就可以连接到服务器并检索内容。

URL类的一个实例管理 URL 字符串中的所有组件信息,并提供了用于检索其标识的对象的方法。我们可以从完整字符串或组件部分构造一个URL对象:

try {
  URL aDoc =
    new URL("http://foo.bar.com/documents/homepage.html");
  URL sameDoc =
    new URL("http","foo.bar.com","/documents/homepage.html");
} catch (MalformedURLException e) {
  // Something wrong with our URL
}

这两个URL对象指向同一个网络资源,即服务器foo.bar.com上的homepage.html文档。我们无法知道资源是否实际存在并且可用,直到我们尝试访问它。新的URL对象仅包含有关对象位置及其访问方式的数据。创建URL对象不会建立任何网络连接。

注意

Oracle 在 Java 20 中已经废弃了URL构造函数。废弃并不会移除方法或类,但意味着您应该考虑其他实现您目标的方法。废弃的项目的 Javadoc 通常包含建议的替代方法。在这种情况下,URI类具有更好的验证代码,因此 Oracle 建议使用new URI("http://your.url/").toURL()作为替代方案。

如果您正在使用 Java 20 或更高版本,可以随意更新代码示例以使用URI,以摆脱编译器的过时警告。尽管如此,由于这是最近的废弃,您仍然会在在线示例中广泛看到URL构造函数的使用。

我们可以使用getProtocol()getHost()getFile()方法来检查URL的各个部分。我们还可以使用sameFile()方法(一个可能不指向文件的不幸命名的方法)将其与另一个URL进行比较,该方法确定两个 URL 是否指向相同的资源。虽然sameFile()并不是绝对可靠的,但它比仅比较 URL 字符串是否相等的方法更加智能;它考虑了一个服务器可能有多个名称以及其他因素。

当你创建一个URL时,Java 会解析 URL 的规范以识别协议组件。然后,它会尝试将它从你的 URL 解析出来的内容与协议处理程序进行匹配。协议处理程序本质上是一个可以使用给定协议并根据协议规则检索资源的助手。如果 URL 的协议不合理,或者 Java 找不到兼容的协议处理程序,URL构造函数会抛出一个MalformedURLException

Java 为httphttps(安全 HTTP)和ftp提供了 URL 协议处理程序,以及本地file URL 和引用 JAR 存档内文件的jar URL。Java 还为第三方库提供了必要的低级结构,以添加对其他类型 URL 的支持。

流数据

URL 获取数据的最低级和最通用的方式是通过调用 openStream() 方法来获取 URLInputStream。如果您想要从动态信息源接收持续更新,作为流获取数据可能也是有用的。不幸的是,您必须自己解析这个流的内容。并非所有类型的 URL 都支持 openStream() 方法,因为并非所有类型的 URL 都指向具体的数据;如果 URL 不支持,您将会得到一个 UnknownServiceException

以下代码(对 ch13/examples/Read.java 文件的简化)会打印出来自虚构 Web 服务器的 HTML 文件的内容:

  try {
    URL url = new URL("http://some.server/index.html");

    BufferedReader bin = new BufferedReader(
        new InputStreamReader(url.openStream()));

    String line;
    while ((line = bin.readLine()) != null) {
      System.out.println(line);
    }
    bin.close();
  } catch (Exception e) {
    e.printStackTrace();
  }

在这个片段中,我们使用 openStream() 从我们的 url 获取一个 InputStream,并将其包装在 BufferedReader 中以读取文本行。因为我们在 URL 中指定了 http 协议,所以我们利用了 HTTP 协议处理程序的服务。我们还没有讨论内容处理程序。因为我们直接从输入流中读取,所以不需要内容处理程序来转换内容。

获取作为对象的内容

正如我们之前所说,openStream() 是访问 Web 内容的最通用方法,但它将数据解析留给程序员。URL 类支持更复杂的、可插拔的内容处理机制,但是 Java 社区从未真正标准化实际的处理程序,因此它的实用性有限。

许多开发者对通过网络加载对象感兴趣,因为他们需要从 URL 加载图像。Java 提供了几种替代方法来完成这个任务。最简单的方法是使用 javax.swing.ImageIcon 类,它有一个接受 URL 参数的构造方法:

//file: ch13/examples/IconLabel.java
    URL fav = new URL("https://www.oracle.com/.../favicon-192.png");
    ImageIcon image1 = new ImageIcon(fav);
    JLabel iconLabel = new JLabel(image1);
    // iconLabel can be placed in any panel, just as other labels

如果您需要将网络流转换为其他类型的对象,可以查看 URL 类的 getContent() 方法。不过,您可能需要自己编写处理程序。关于这个高级主题,我们推荐阅读 Java 网络编程 一书,作者是 Elliotte Rusty-Harold(O’Reilly)。

管理连接

URL 上调用 openStream() 方法时,Java 会查阅协议处理程序,并建立到远程服务器或位置的连接。连接由 URLConnection 对象表示,它的子类管理不同的协议特定通信,并提供有关源的额外元数据。例如,HttpURLConnection 类处理基本的网络请求,还添加了一些 HTTP 特定功能,比如解释 “404 Not Found” 消息和其他 Web 服务器错误。我们稍后会详细讨论 HttpURLConnection

我们可以通过 openConnection() 方法直接从我们的 URL 获取一个 URLConnection。我们可以在读取数据之前询问 URLConnection 的对象内容类型。例如:

URLConnection connection = myURL.openConnection();
String mimeType = connection.getContentType();
InputStream in = connection.getInputStream();

尽管其名称如此,URLConnection对象最初处于原始未连接状态。在本例中,直到我们调用getContentType()方法之前,网络连接实际上并未初始化。URLConnection在数据请求或显式调用其connect()方法之前不会与源通信。在连接之前,我们可以设置网络参数并提供协议特定的详细信息。例如,我们可以设置连接到服务器的初始连接和读取尝试的超时时间:

URLConnection connection = myURL.openConnection();
connection.setConnectTimeout(10000); // milliseconds
connection.setReadTimeout(10000); // milliseconds
InputStream in = connection.getInputStream();

正如我们将在“使用 POST 方法”中看到的那样,通过将URLConnection转换为其特定子类型,我们可以获得协议特定的信息。

与 Web 应用程序通信

Web 浏览器是 Web 应用程序的通用客户端。它们检索文档以进行显示,并通过 HTML、JavaScript 和诸如图像之类的链接文档作为用户界面。在本节中,我们将编写客户端 Java 代码,使用URL类通过 HTTP 处理 Web 应用程序。这种组合允许我们直接使用GETPOST操作与 Web 应用程序交互。

我们在这里讨论的主要任务是将数据发送到服务器,特别是 HTML 表单编码数据。浏览器以特殊格式对 HTML 表单字段的名称/值对进行编码,并使用两种方法之一将其发送到服务器(通常)。第一种方法使用 HTTP GET命令,将用户输入编码到 URL 本身并请求相应文档。服务器识别 URL 的第一部分引用一个程序,并调用它,将 URL 的另一部分编码的信息作为参数传递给它。第二种方法使用 HTTP POST命令要求服务器接受编码数据,并将其作为流传递给 Web 应用程序。

使用GET方法

使用GET方法可以快速利用网络资源。只需创建指向服务器程序的 URL,并使用简单的约定附加构成数据的编码名称/值对即可。例如,以下代码片段打开了一个指向服务器myhost上名为login.cgi的老式 CGI 程序的 URL,并传递了两个名称/值对。然后,它打印出 CGI 发送回来的任何文本:

  URL url = new URL(
    // this string should be URL-encoded
    "http://myhost/cgi-bin/login.cgi?Name=Pat&Password=foobar");

  BufferedReader bin = new BufferedReader(
    new InputStreamReader(url.openStream()));

  String line;
  while ((line = bin.readLine()) != null) {
    System.out.println(line);
  }

为了使用带参数的 URL,我们从login.cgi的基本 URL 开始。我们添加一个问号(?),标志着参数数据的开始,后面跟着第一个“name=value”对。我们可以添加任意多个名称/值对,用和号(&)字符分隔。我们的其余代码只是简单地打开流并从服务器读回响应。请记住,创建 URL 并不实际打开连接。在这种情况下,当我们调用openStream()时,URL 连接是隐式建立的。尽管我们在这里假设服务器返回文本,但它可以发送任何东西,包括图像、音频或 PDF 文件。

我们在这里跳过了一步。这个示例之所以有效,是因为我们的名称/值对恰好是简单的文本。如果任何“非可打印”或特殊字符(包括?&)在这些对中,它们必须首先进行编码。java.net.URLEncoder类提供了一个编码数据的实用工具。我们将在“使用 POST 方法”中的下一个示例中展示如何使用它。

虽然这个小示例发送了一个密码字段,但你不应该使用这种简单的方法发送敏感数据。这个示例中的数据以明文形式通过网络发送(未加密)。即使使用 HTTPS(HTTP 安全)也不会模糊 URL。而且在这种情况下,密码字段也会出现在 URL 打印的任何地方,包括服务器日志、浏览器历史记录和书签中。

使用 POST 方法

对于更大量的输入数据或敏感内容,你可能会使用POST选项。这是一个小应用程序,它的行为类似于 HTML 表单。它从两个文本字段——namepassword——收集数据,并使用 HTTP POST方法将数据发送到 Postman Echo 服务¹的 URL。这个基于 Swing 的客户端应用程序就像一个 Web 浏览器一样工作,并与 Web 应用程序连接。

这是执行请求并处理响应的关键网络方法:

//file: ch13/examples/Post.java

  protected void postData() {
    StringBuilder sb = new StringBuilder();
    String pw = new String(passwordField.getPassword());
    try {
      sb.append(URLEncoder.encode("Name", "UTF-8") + "=");
      sb.append(URLEncoder.encode(nameField.getText(), "UTF-8"));
      sb.append("&" + URLEncoder.encode("Password", "UTF-8") + "=");
      sb.append(URLEncoder.encode(pw, "UTF-8"));
    } catch (UnsupportedEncodingException uee) {
      System.out.println(uee);
    }
    String formData = sb.toString();

    try {
      URL url = new URL(postURL);
      HttpURLConnection urlcon =
          (HttpURLConnection) url.openConnection();
      urlcon.setRequestMethod("POST");
      urlcon.setRequestProperty("Content-type",
          "application/x-www-form-urlencoded");
      urlcon.setDoOutput(true);
      urlcon.setDoInput(true);
      PrintWriter pout = new PrintWriter(new OutputStreamWriter(
          urlcon.getOutputStream(), "8859_1"), true);
      pout.print(formData);
      pout.flush();

      // Did the post succeed?
      if (urlcon.getResponseCode() == HttpURLConnection.HTTP_OK)
        System.out.println("Posted ok!");
      else {
        System.out.println("Bad post...");
        return;
      }

      // Hooray! Go ahead and read the results
      InputStream is = urlcon.getInputStream();
      InputStreamReader isr = new InputStreamReader(is);
      BufferedReader br = new BufferedReader(isr);
      String line;
      while ((line = br.readLine()) != null) {
        System.out.println(line);
      }
      br.close();

    } catch (MalformedURLException e) {
      System.out.println(e);     // bad postURL
    } catch (IOException e2) {
      System.out.println(e2);    // I/O error
    }
  }

应用程序的开头使用 Swing 元素创建表单,就像我们在第十二章中所做的那样。所有的魔法都在受保护的postData()方法中发生。首先,我们创建一个StringBuilder并用用&分隔的名称/值对加载它。(当我们使用POST方法时,我们不需要初始问号,因为我们不是在 URL 上追加。)每对都首先使用静态的URLEncoder.encode()方法进行编码。即使在这个示例中,名称字段不包含任何特殊字符,我们也会通过编码器运行名称字段。这个额外的步骤是最佳实践,只是一个好习惯。字段名称可能并不总是如此简单。

接下来,我们设置与服务器的连接。在我们之前的例子中,我们不需要执行任何特殊操作来发送数据,因为请求是通过在服务器上打开 URL 简单完成的。在这里,我们必须承担与远程 Web 服务器通信的一些工作。幸运的是,HttpURLConnection对象为我们完成了大部分工作;我们只需告诉它我们要发送的数据类型及如何发送。我们通过openConnection()方法获取一个URLConnection对象。由于我们使用的是 HTTP 协议,所以可以安全地将其强制转换为HttpURLConnection类型,它具有我们需要的支持。因为 HTTP 是一种有保证的协议之一,我们可以安全地做出这个假设。(说到安全性,我们在这里仅仅出于演示目的使用 HTTP。如今许多数据被视为敏感数据。行业指南已经默认使用 HTTPS;稍后在“SSL 和安全 Web 通信”中详细讨论。)

我们使用setRequestMethod()告知连接我们要进行POST操作。还使用setRequestProperty()设置我们的 HTTP 请求的Content-Type字段为适当的类型——在这种情况下,编码表单数据的正确媒体类型²(这是必要的,告诉服务器我们发送的数据类型,我们的情况下是"application/x-www-form-urlencoded")。

对于最后的配置步骤,我们使用setDoOutput()setDoInput()方法告知连接我们要发送和接收流数据。URL 连接从这个组合推断我们将进行POST操作,并期望得到一个响应。

要发送数据,我们从连接中获取一个输出流使用getOutputStream(),并创建一个PrintWriter以便轻松编写我们的编码表单内容。发送数据后,我们的应用程序调用getResponseCode()来查看服务器的 HTTP 响应代码是否指示POST成功。其他响应代码(在HttpURLConnection中定义为常量)表示各种失败情况。

尽管表单编码数据(如我们为Content-Type字段指定的媒体类型所示)很常见,但也有其他类型的通信方式。我们可以使用输入和输出流与服务器程序交换任意数据类型。POST操作可以发送任何类型的数据;服务器应用程序只需知道如何处理即可。最后注意:如果你正在编写一个需要解码表单数据的应用程序,可以使用java.net.URLDecoder来撤消URLEncoder的操作。调用decode()时务必指定 UTF-8。

HttpURLConnection

HttpURLConnection 中还可以获取请求的其他信息。我们可以使用 getContentType()getContentEncoding() 来确定响应的 MIME 类型和编码。我们还可以通过使用 getHeaderField() 来查询 HTTP 响应头(HTTP 响应头是随响应一起传输的元数据名称/值对)。便捷方法可以获取整数和日期格式的头字段,getHeaderFieldInt()getHeaderFieldDate(),它们分别返回 intlong 类型。内容长度和上次修改日期可以通过 getContentLength()getLastModified() 获得。

SSL 和安全的 Web 通信

之前的一些示例发送了敏感数据到服务器。标准的 HTTP 不提供加密来隐藏我们的数据。幸运的是,像这样为 GETPOST 操作添加安全性对于客户端开发者来说是很容易的(实际上是微不足道的)。在可用的情况下,你只需要使用 HTTP 协议的安全形式 — HTTPS。考虑 Post 示例中的测试 URL:

https://postman-echo.com/post

HTTPS 是标准 HTTP 协议运行在安全套接字层(SSL)之上的一个版本,它使用公钥加密技术来加密浏览器与服务器之间的通信。大多数 Web 浏览器和服务器目前都内置支持 HTTPS(或原始的 SSL 套接字)。因此,如果你的 Web 服务器支持并配置了 HTTPS,你可以通过在 URL 中指定 https 协议来简单地发送和接收安全数据。关于 SSL 和安全相关方面还有很多内容需要学习,比如验证你实际在与谁通信,但是就基本数据加密而言,这就是你需要做的一切。这不是你的代码直接处理的事情。Java 提供了 SSL 和 HTTPS 的支持。

网络编程

Web 主导了开发者对网络的讨论,但在这其中不仅仅是 HTML 页面!随着 Java 的网络 API 的成熟,Java 也成为了实现传统客户端/服务器应用程序和服务的首选语言。在本节中,我们将看看 java.net 包,其中包含了用于通信和处理网络资源的基本类。

java.net 包的类分为两类:Sockets API,用于处理低级网络协议,以及与 URL 一起工作的高级、面向 Web 的 API,正如我们在前一节中看到的。图 13-2 展示了 java.net 包的大部分层次结构。

ljv6 1302

图 13-2. java.net 包的主要类和接口

Java 的套接字 API 提供了对主机间通信所使用的标准协议的访问。套接字 是所有其他种类便携式网络通信的基础机制。套接字是通用网络工具箱中的最低级工具——你可以使用套接字进行客户端和服务器或对等应用程序之间的任何类型的通信,但你必须实现自己的应用程序级协议来处理和解释数据。更高级别的网络工具,如远程方法调用、HTTP 和 web 服务,都是在套接字之上实现的。

套接字

套接字是用于网络通信的低级编程接口。它们在可能或可能不在同一主机上的应用程序之间发送数据流。

套接字起源于 BSD Unix,在某些编程语言中,它们是一些混乱、复杂的东西,有很多小部分可能会断开并引起混乱。这是因为大多数套接字 API 可以与几乎任何类型的底层网络协议一起使用。由于传输数据的协议可能具有根本不同的特性,套接字接口可能会非常复杂。

java.net 包支持一个简化的、面向对象的套接字接口,使网络通信变得更加容易。如果你以前使用其他语言的套接字进行过网络编程,你会惊讶地发现当对象封装了繁琐的细节时,事情可以变得多么简单。如果这是你第一次接触套接字,你会发现与另一个应用程序在网络上通信就像读取文件或获取用户输入一样简单。Java 中的大多数 I/O 形式,包括大多数网络 I/O,都使用了 “Streams” 中描述的流类。流提供了统一的 I/O 接口,使得在互联网上进行读取或写入类似于在本地系统上进行读取或写入。除了面向流的接口之外,Java 网络 API 还可以与用于高度可扩展应用程序的 Java NIO 缓冲区 API 一起使用。

Java 提供套接字支持三种不同的底层协议类:SocketDatagramSocketMulticastSocket。在本节中,我们将介绍 Java 的基本 Socket 类,它使用了 面向连接可靠 的协议。面向连接的协议提供了类似于电话对话的功能。建立连接后,两个应用程序可以来回发送数据流,即使没有人在说话,连接也会保持在那里。由于协议是可靠的,它还确保没有数据丢失(必要时重新发送数据),并且你发送的任何内容都会按照你发送的顺序到达。

我们将留下另外两个使用 无连接不可靠 协议的类,让您自行探索。(再次参见 Java 网络编程,由 Elliotte Rusty-Harold 详细讨论。)无连接协议类似于邮政服务。应用程序可以向彼此发送短消息,但事先不建立端到端连接,并且不尝试保持消息的顺序。甚至不能保证消息会到达。MulticastSocketDatagramSocket 的变体,执行多播 —— 同时向多个接收者发送数据。类似于使用数据报套接字,使用多播套接字工作时只是有更多的接收者。

理论上,套接字层下面几乎可以使用任何协议。实际上,互联网上只有一个重要的协议族,并且只有一个 Java 支持的协议族:互联网协议(IP)。Socket 类通过 IP(通常被称为 TCP/IP)使用 TCP,传输控制协议;而无连接的 DatagramSocket 类通过 IP 使用 UDP,用户数据报协议。

客户端和服务器

在编写网络应用程序时,通常会谈论客户端和服务器。这两者之间的区别越来越模糊,但客户端通常启动对话,而服务器通常接受传入请求。这些角色有许多微妙之处,⁴ 但为简单起见,我们将使用这个定义。

客户端和服务器之间的一个重要区别在于,客户端可以随时创建套接字以启动与服务器应用程序的对话,而服务器必须事先准备好以侦听传入的对话请求。java.net.Socket 类代表客户端和服务器上单个套接字连接的一侧。此外,服务器使用 java.net.ServerSocket 类来侦听来自客户端的新连接。在大多数情况下,作为服务器的应用程序会创建一个 ServerSocket 对象并等待,通过调用其 accept() 方法被阻塞,直到请求到达。当客户端尝试连接时,accept() 方法会创建一个新的 Socket 对象,服务器用该对象与客户端进行通信。ServerSocket 实例会将有关客户端的详细信息传递给新的 Socket,如 图 13-3 所示。

ljv6 1303

图 13-3. 使用 SocketServerSocket 的客户端和服务器

该套接字继续与客户端进行对话,使得ServerSocket能够恢复其监听任务。这样,服务器就可以同时与多个客户端进行对话。仍然只有一个ServerSocket,但服务器拥有多个Socket对象——每个客户端一个。

客户端

在套接字级别,客户端需要两个信息来定位和连接到互联网中的服务器:一个主机名(用于查找主机计算机的网络地址)和一个端口号。端口号是一个标识符,用于区分同一主机上的多个网络服务或连接。

服务器应用程序在预先安排的端口上监听,同时等待连接。客户端向那个预先安排的端口号发送请求。如果你把主机计算机想象成一个酒店,而各种可用的服务作为客人,那么端口就像客人的房间号码。要连接到一个服务,你必须知道酒店名称和正确的房间号码。

客户端应用程序通过构造一个指定这两个信息的Socket,来打开与服务器的连接。

    try {
      Socket sock = new Socket("wupost.wustl.edu", 25);
    } catch (UnknownHostException e) {
      System.out.println("Can't find host.");
    } catch (IOException e) {
      System.out.println("Error connecting to host.");
    }

这段客户端代码试图将一个Socket连接到主机的 25 号端口(SMTP 邮件服务),该主机为wupost.wustl.edu。客户端必须处理主机名无法解析(UnknownHostException)和服务器可能不接受新连接(IOException)的情况。Java 使用 DNS,即标准的域名服务(DNS),来将主机名解析为一个IP 地址

IP 地址(来自互联网协议)是互联网的电话号码,DNS 是全球电话簿。连接到互联网的每台计算机都有一个 IP 地址。如果你不知道那个地址,就通过 DNS 查询。但如果你知道服务器的地址,Socket构造函数也可以接受一个包含原始 IP 地址的字符串:

    Socket sock = new Socket("22.66.89.167", 25);

无论你如何开始,一旦sock连接上,你就可以通过getInputStream()getOutputStream()方法检索输入和输出流。以下(相当任意的)代码通过流发送和接收一些数据:

    try {
      Socket server = new Socket("foo.bar.com", 1234);
      InputStream in = server.getInputStream();
      OutputStream out = server.getOutputStream();

      // write a byte
      out.write(42);

      // write a newline or carriage return delimited string
      PrintWriter pout = new PrintWriter(out, true);
      pout.println("Hello!");

      // read a byte
      byte back = (byte)in.read();

      // read a newline or carriage return delimited string
      BufferedReader bin =
        new BufferedReader(new InputStreamReader(in) );
      String response = bin.readLine();

      server.close();
    } catch (IOException e) {
      System.err.println(e);
    }

在这个交换过程中,客户端首先创建一个Socket,用于与服务器通信。Socket构造函数指定服务器的主机名(foo.bar.com)和一个预先安排的端口号(1234)。一旦客户端连接上,它就使用OutputStreamwrite()方法向服务器写入一个字节。为了更方便地发送一串文本,它随后将一个PrintWriter包装在OutputStream周围。接下来,它执行互补操作:使用InputStreamread()方法从服务器读取一个字节,然后创建一个BufferedReader,以便获取完整的文本字符串。客户端随后使用close()方法终止连接。所有这些操作都有可能生成IOException;我们的代码片段通过将整个对话包装在一个try/catch块中来处理这些检查异常。

服务器

在对话的另一端,在建立连接之后,服务器应用程序使用相同类型的Socket对象与客户端进行通信。然而,要接受来自客户端的连接,它必须首先创建绑定到正确端口的ServerSocket。让我们从服务器的角度重新创建以前的对话:

    // Meanwhile, on foo.bar.com...
    try {
      ServerSocket listener = new ServerSocket(1234);

      while (!finished) {
        Socket client = listener.accept();  // wait for connection

        InputStream in = client.getInputStream();
        OutputStream out = client.getOutputStream();

        // read a byte
        byte someByte = (byte)in.read();

        // read a newline or carriage-return-delimited string
        BufferedReader bin =
          new BufferedReader(new InputStreamReader(in) );
        String someString = bin.readLine();

        // write a byte
        out.write(43);

        // say goodbye
        PrintWriter pout = new PrintWriter(out, true);
        pout.println("Goodbye!");

        client.close();
      }

      listener.close();
    } catch (IOException e) {
      System.err.println(e);
    }

首先,我们的服务器创建一个绑定到端口 1234 的ServerSocket。在大多数系统上,有关应用程序可以使用哪些端口的规则。端口号是无符号的 16 位整数,这意味着它们的范围可以从 0 到 65535。低于 1024 的端口号通常保留给系统进程和标准的“众所周知”服务,因此我们选择一个不在此保留范围内的端口号。⁵我们只需创建一次ServerSocket;之后,它可以接受到达的任意数量的连接。

接下来,我们进入一个循环,等待ServerSocketaccept()方法返回来自客户端的活动Socket连接。当建立连接后,我们执行对话的服务器端,然后关闭连接并返回循环顶部等待另一个连接。最后,当服务器应用程序想要完全停止监听连接时,它调用ServerSocketclose()方法。

此服务器是单线程的;它一次处理一个连接,在完成与一个客户端的完整对话后返回循环顶部,并调用accept()以侦听另一个连接。一个更现实的服务器将有一个循环,同时接受连接,并将它们传递到它们自己的线程进行处理。尽管我们不打算创建一个 MMORPG,⁶我们确实展示了如何使用线程每客户端方法进行对话,在“分布式游戏”中展示。如果您想进行一些独立阅读,您还可以查找非阻塞的 NIO 等效ServerSocketChannel

DateAtHost客户端

在过去,许多网络计算机运行了一个简单的时间服务,该服务在一个众所周知的端口上分发其时钟的本地时间。时间协议是 NTP 的前身,更一般的网络时间协议。我们将坚持使用时间协议因其简单性,但如果您想要同步网络系统的时钟,NTP 是一个更好的选择。⁷

下一个示例,DateAtHost,包括一个java.util.Date的子类,该子类从远程主机获取时间,而不是从本地时钟初始化自己。(参见第八章讨论Date类,虽然在某些用途上仍然有效,但已大部分被其更新、更灵活的衍生类LocalDateLocalTime替代。)

DateAtHost连接到时间服务(端口 37),并读取表示远程主机时间的四个字节。这四个字节有一个特定的规范,我们解码以获取时间。以下是代码:

//file: ch13.examples.DateAtHost.java
package ch13.examples;

import java.net.Socket;
import java.io.*;

public class DateAtHost extends java.util.Date {
  static int timePort = 37;
  // seconds from start of 20th century to Jan 1, 1970 00:00 GMT
  static final long offset = 2208988800L;

  public DateAtHost(String host) throws IOException {
    this(host, timePort);
  }

  public DateAtHost(String host, int port) throws IOException {
    Socket server = new Socket(host, port);
    DataInputStream din =
      new DataInputStream(server.getInputStream());
    int time = din.readInt();
    server.close();

    setTime((((1L << 32) + time) - offset) * 1000);
  }
}

就是这样。即使稍微有些花哨,它也不是很长。我们为DateAtHost提供了两个可能的构造函数。通常我们会使用第一个构造函数,它简单地将远程主机的名称作为参数。第二个构造函数指定了远程时间服务的主机名和端口号。(如果时间服务在非标准端口上运行,则使用第二个构造函数指定备用端口号。)第二个构造函数负责建立连接并设置时间。第一个构造函数只是调用第二个构造函数(使用this()构造)并使用默认端口作为参数。在 Java 中,提供简化的构造函数,这些构造函数调用带有默认参数的同级构造函数是一种常见且有用的模式;这也是我们在这里展示的主要原因。

第二个构造函数在远程主机上指定端口打开一个套接字。它创建一个DataInputStream来包装输入流,然后使用readInt()方法读取一个四字节整数。这些字节的顺序正确并非巧合。Java 的DataInputStreamDataOutputStream类使用网络字节顺序(从最高有效位到最低有效位)处理整数类型的字节。时间协议(以及处理二进制数据的其他标准网络协议)也使用网络字节顺序,因此我们不需要调用任何转换例程。如果我们使用非标准协议,特别是与非 Java 客户端或服务器通信时,可能需要进行显式数据转换。在这种情况下,我们必须逐字节读取并重新排列以获取我们的四字节值。读取数据后,我们完成套接字操作,因此关闭它以终止与服务器的连接。最后,构造函数通过使用计算出的时间值调用DatesetTime()方法来初始化对象的其余部分。

时间值的四个字节被解释为表示 20 世纪初以来的秒数的整数。DateAtHost将其转换为 Java 的绝对时间概念——自 1970 年 1 月 1 日起的毫秒计数(这是由 C 和 Unix 标准化的任意日期)。转换首先创建一个long值,它是整数time的无符号等效值。它减去一个偏移量以使时间相对于时代(1970 年 1 月 1 日)而不是世纪,并乘以 1,000 以转换为毫秒。转换后的时间用于初始化对象。

DateAtHost类几乎可以像Date与本地主机上的时间一样与从远程主机检索的时间一起工作。唯一的额外开销是处理DateAtHost构造函数可能抛出的可能的IOException异常:

    try {
      Date d = new DateAtHost("time.nist.gov");
      System.out.println("The time over there is: " + d);
    }
    catch (IOException e) {
      System.err.println("Failed to get the time: " + e);
    }

这个示例获取来自主机time.nist.gov的时间并打印其值。

分布式游戏

我们可以利用我们新发现的网络技能来扩展我们的苹果投掷游戏,并进行双人游戏。我们必须将这次尝试保持简单,但您可能会对我们能够多快地创建一个概念验证感到惊讶。虽然有几种机制可以让两个玩家连接以共享体验,但我们的示例使用了我们在本章中讨论过的基本客户端/服务器模型。一个用户将启动服务器,第二个用户将作为客户端联系该服务器。一旦两个玩家连接,他们将竞赛看谁能最快地清理树木和篱笆!

设置用户界面

让我们从给我们的游戏添加一个菜单开始。回想一下“Menus”中所述的,菜单位于菜单栏中,并与ActionEvent对象一起工作,就像按钮一样。我们需要一个选项来启动服务器,另一个选项是加入已经启动的服务器的游戏。这些菜单项的核心代码很简单;我们可以在AppleToss类中使用另一个辅助方法:

    private void setupNetworkMenu() {
      JMenu netMenu = new JMenu("Multiplayer");
      multiplayerHelper = new Multiplayer();

        JMenuItem startItem = new JMenuItem("Start Server");
        startItem.addActionListener(
            e -> multiplayerHelper.startServer());
        netMenu.add(startItem);

        JMenuItem joinItem = new JMenuItem("Join Game...");
        joinItem.addActionListener(e -> {
          String otherServer = JOptionPane.showInputDialog(
              AppleToss.this, "Enter server name or address:");
          multiplayerHelper.joinGame(otherServer);
        });
        netMenu.add(joinItem);

        JMenuItem quitItem = new JMenuItem("Disconnect");
        quitItem.addActionListener(
            e -> multiplayerHelper.disconnect());
        netMenu.add(quitItem);

      // build a JMenuBar for the application
      JMenuBar mainBar = new JMenuBar();
      mainBar.add(netMenu);
      setJMenuBar(mainBar);
    }

对于每个菜单的ActionListener使用 lambda 表达式应该很熟悉。我们还使用在“Modals and Pop-Ups”中讨论过的JOptionPane来询问第二个玩家第一个玩家正在等待的服务器的名称或 IP 地址。网络逻辑由一个单独的类处理。

我们将在接下来的章节中更详细地查看Multiplayer类,但您可以看到我们将要实现的方法。游戏的这个版本的代码(在ch13/examples/game文件夹中)包含了setupNetworkMenu()方法,但是 lambda 监听器只是弹出一个信息对话框,指示选择了哪个菜单项。您可以构建Multiplayer类并在章节末尾的练习中调用实际的多人游戏方法。但是,欢迎查看ch13/solutions/game文件夹中已完成的游戏,包括网络部分。

游戏服务器

正如我们在“Servers”中所做的那样,我们需要选择一个端口并设置一个监听传入连接的套接字。我们将使用端口 8677——在电话号码键盘上为“TOSS”。我们可以在我们的Multiplayer类中创建一个Server内部类来驱动一个准备好进行网络通信的线程。readerwriter变量将用于发送和接收实际的游戏数据。关于这一点在“The game protocol”中会详细讨论:

class Server implements Runnable {
  ServerSocket listener;

  public void run() {
    Socket socket = null;
    try {
      listener = new ServerSocket(gamePort);
      while (keepListening) {
        socket = listener.accept();  // wait for connection

        InputStream in = socket.getInputStream();
        BufferedReader reader =
            new BufferedReader(new InputStreamReader(in));
        OutputStream out = socket.getOutputStream();
        PrintWriter writer = new PrintWriter(out, true);

        // ... game protocol logic starts here
      }
    } catch (IOException ioe) {
      System.err.println(ioe);
    }
  }
}

我们设置我们的ServerSocket,然后在循环内等待一个新的客户端。虽然我们计划一次只玩一个对手,但这使我们能够接受后续的客户端而不必重新进行所有的网络设置。

要实际启动服务器监听第一次,我们只需要一个使用我们的Server类的新线程:

    // from Multiplayer
    Server server;

    // ...

    public void startServer() {
      keepListening = true;
      // ... other game state can go here
      server = new Server();
      serverThread = new Thread(server);
      serverThread.start();
    }

我们在我们的Multiplayer类中保持对Server实例的引用,这样我们就可以随时访问,以便在用户从菜单中选择“断开连接”选项时关闭连接,如下所示:

// from Multiplayer
  public void disconnect() {
    disconnecting = true;
    keepListening = false;
    // Are we in the middle of a game and regularly checking these flags?
    // If not, just close the server socket to interrupt the blocking
    // accept() method.
    if (server != null && keepPlaying == false) {
      server.stopListening();
    }

    // ... clean up other game state here
  }

一旦我们进入游戏循环,我们主要使用keepPlaying标志,但是在上面也很方便。如果我们有一个有效的server引用,但当前没有玩游戏(keepPlaying为 false),则我们知道要关闭监听器套接字。

Server内部类中的stopListening()方法很简单:

  public void stopListening() {
    if (listener != null && !listener.isClosed()) {
      try {
        listener.close();
      } catch (IOException ioe) {
        System.err.println("Error disconnecting listener: " +
            ioe.getMessage());
      }
    }
  }

我们快速检查我们的服务器,并仅在存在并且仍然打开时尝试关闭listener

游戏客户端

客户端的设置和拆卸与之相似——当然没有监听ServerSocket。我们将使用一个Client内部类来镜像Server内部类,并构建一个智能的run()方法来实现我们的客户端逻辑:

class Client implements Runnable {
  String gameHost;
  boolean startNewGame;

  public Client(String host) {
    gameHost = host;
    keepPlaying = false;
    startNewGame = false;
  }

  public void run() {
    try (Socket socket = new Socket(gameHost, gamePort)) {

      InputStream in = socket.getInputStream();
      BufferedReader reader =
          new BufferedReader(new InputStreamReader(in) );
      OutputStream out = socket.getOutputStream();
      PrintWriter writer = new PrintWriter(out, true);

      // ... game protocol logic starts here
    } catch (IOException ioe) {
      System.err.println(ioe);
    }
  }
}

我们将服务器的名称传递给Client构造函数,并依赖于Server使用的公共gamePort变量来设置套接字。我们使用了“try with Resources”中讨论的“try with resource”技术来创建套接字,并确保在完成后对其进行清理。在该资源try块内,我们创建了客户端对话半部分的readerwriter实例,如 Figure 13-4 所示。

ljv6 1304

图 13-4。游戏客户端和服务器连接

为了使其运行,我们将在我们的Multiplayer辅助类中添加另一个帮助方法:

// from Multiplayer

  public void joinGame(String otherServer) {
    clientThread = new Thread(new Client(otherServer));
    clientThread.start();
  }

我们不需要单独的disconnect()方法——我们可以使用服务器使用的相同状态变量。对于客户端,server引用将为null,因此我们不会尝试关闭不存在的监听器。

游戏协议

您可能注意到我们忽略了ServerClient类的run()方法的大部分内容。在我们构建和连接数据流之后,剩下的工作都涉及协作地发送和接收关于游戏状态的信息。这种结构化的通信就是游戏的协议。每个网络服务都有一个协议。想一想 HTTP 中的“P”。即使我们的DateAtHost示例也使用了(非常简单的)协议,以便客户端和服务器知道谁应该在任何给定时刻说话,谁必须听取。如果两边同时尝试交谈,信息很可能会丢失。如果两边最终都等待对方说些什么(例如,服务器和客户端都在reader.readLine()调用上阻塞),则连接将看起来会挂起。

管理这些通信期望是任何协议的核心,但是该说什么以及如何响应也很重要。协议的这一部分通常需要开发人员付出最多的努力。部分困难在于,您实际上需要两边都测试您的工作。没有客户端,无法测试服务器,反之亦然。随着工作的进行,构建两侧可能会感到乏味,但是额外的努力是值得的。与其他类型的调试一样,修复小的增量变化比弄清楚可能存在的大块代码中的问题要简单得多。

在我们的游戏中,我们将由服务器引导对话。这个选择是任意的——我们可以使用客户端,或者我们可以构建一个更复杂的基础,并允许客户端和服务器同时负责某些事情。然而,做出了“服务器负责”的决定后,我们可以在我们的协议中尝试一个非常简单的第一步。我们将让服务器发送一个"NEW_GAME"命令,然后等待客户端回应一个"OK"答案。服务器端的代码(与客户端建立连接后)如下所示:

    // Create a new game with the client
    writer.println("NEW_GAME");

    // If the client agrees, send over the location of the trees
    String response = reader.readLine();
    if (response != null && response.equals("OK")) {
      System.out.println("Starting a new game!")
      // ... write game data here
    } else {
      System.err.println("Unexpected start response: " + response);
      System.err.println("Skipping game and waiting again.");
      keepPlaying = false;
    }

如果我们得到了预期的"OK"响应,我们可以继续设置一个新游戏,并与对手分享树木和树篱的位置——稍后再说。 (如果我们没有收到"OK",我们会显示一个错误并重置等待其他尝试。) 这个第一步的相应客户端代码流程类似:

    // We expect to see the NEW_GAME command first
    String response = reader.readLine();

    // If we don't see that command, disconnect and return
    if (response == null || !response.equals("NEW_GAME")) {
      System.err.println("Unexpected initial command: " + response);
      System.err.println("Disconnecting");
      writer.println("DISCONNECT");
      return;
    }
    // Yay! We're going to play a game. Send an acknowledgement
    writer.println("OK");

如果你想尝试当前的情况,你可以从一个系统启动你的服务器,然后从第二个系统加入该游戏。(你也可以只是从一个单独的终端窗口启动游戏的第二个副本。在这种情况下,“其他主机”的名称将是网络关键词localhost。)几乎在从第二个游戏实例加入后不久,你应该在第一个游戏的终端中看到“开始新游戏!”的确认打印。恭喜!你正在设计一个游戏协议。让我们继续。

我们需要确保公平竞技——字面上的意思。服务器会告诉游戏建立一个新场地,然后将所有新障碍的坐标发送给客户端。客户端则可以接受所有传入的树木和树篱,并将它们放置在一个干净的场地上。一旦服务器发送了所有树木,它就可以发送一个"START"命令,游戏就可以开始了。我们将继续使用字符串来传递我们的消息。以下是我们可以将树木细节传递给客户端的一种方式:

    gameField.setupNewGame();
    for (Tree tree : gameField.trees) {
      writer.println("TREE " + tree.getPositionX() + " " +
          tree.getPositionY());
    }
    // do the same for hedges or any other shared elements ...

    // Attempt to start the game, but make sure the client is ready
    writer.println("START");
    response = reader.readLine();
    keepPlaying = response.equals("OK");

在客户端,我们可以调用readLine()在一个循环中用于"TREE"行,直到我们看到“START”行,就像这样(还加入了一些错误处理):

    // And now gather the trees and set up our field
    gameField.trees.clear();
    response = reader.readLine();
    while (response.startsWith("TREE")) {
      String[] parts = response.split(" ");
      int x = Integer.parseInt(parts[1]);
      int y = Integer.parseInt(parts[2]);
      Tree tree = new Tree();
      tree.setPosition(x, y);
      gameField.trees.add(tree);
      response = reader.readLine();
    }
    // Do the same for hedges or other shared elements

    // After all the obstacle lists have been sent, the server will issue
    // a START command. Make sure we get that before playing
    if (!response.equals("START")) {
      // Hmm, we should have ended the lists of obstacles with a START,
      // but didn't. Bail out.
      System.err.println("Unexpected start to the game: " + response);
      System.err.println("Disconnecting");
      writer.println("DISCONNECT");
      return;
    } else {
      // Yay again! We're starting a game. Acknowledge this command
      writer.println("OK");
      keepPlaying = true;
      gameField.repaint();
    }

此时,两个游戏应该具有相同的障碍,玩家可以开始清除它们。服务器将进入轮询循环,并每秒钟发送一次当前分数。客户端将回复其当前分数。请注意,肯定还有其他选项可以共享分数变化的方法。虽然轮询很简单,但更先进的游戏,或者需要更即时反馈关于远程玩家的游戏,可能会使用更直接的通信选项。目前,我们主要想专注于良好的网络来回,所以轮询会使我们的代码更简单。

服务器应该持续发送当前分数,直到本地玩家清除所有内容或我们从客户端看到游戏结束的响应为止。我们需要解析客户端的响应以更新另一位玩家的分数,并关注他们请求结束游戏的情况。我们还必须准备好客户端可能会简单断开连接。该循环看起来像这样:

    while (keepPlaying) {
      try {
        if (gameField.trees.size() > 0) {
          writer.print("SCORE ");
        } else {
          writer.print("END ");
          keepPlaying = false;
        }
        writer.println(gameField.getScore(1));
        response = reader.readLine();
        if (response == null) {
          keepPlaying = false;
          disconnecting = true;
        } else {
          String parts[] = response.split(" ");
          switch (parts[0]) {
            case "END":
              keepPlaying = false;
            case "SCORE":
              gameField.setScore(2, parts[1]);
              break;
            case "DISCONNECT":
              disconnecting = true;
              keepPlaying = false;
              break;
            default:
              System.err.println("Warning. Unexpected command: " +
                  parts[0] + ". Ignoring.");
          }
        }
        Thread.sleep(500);
      } catch(InterruptedException e) {
        System.err.println("Interrupted while polling. Ignoring.");
      }
    }

客户端将复制这些操作。幸运的是对于客户端来说,它只是对来自服务器的命令做出反应。在这里我们不需要单独的轮询机制。我们阻塞等待读取一行,解析它,然后构建我们的响应:

    while (keepPlaying) {
      response = reader.readLine();
      String[] parts = response.split(" ");
      switch (parts[0]) {
        case "END":
          keepPlaying = false;
        case "SCORE":
          gameField.setScore(2, parts[1]);
          break;
        case "DISCONNECT":
          disconnecting = true;
          keepPlaying = false;
          break;
        default:
          System.err.println("Unexpected game command: " +
          response + ". Ignoring.");
      }
      if (disconnecting) {
        // We're disconnecting or they are. Acknowledge and quit.
        writer.println("DISCONNECT");
        return;
      } else {
        // If we're not disconnecting, reply with our current score
        if (gameField.trees.size() > 0) {
          writer.print("SCORE ");
        } else {
          keepPlaying = false;
          writer.print("END ");
        }
        writer.println(gameField.getScore(1));
      }
    }

当玩家清除了所有的树木和篱笆时,他们发送(或回复)一个包含他们最终分数的"END"命令。此时,我们会询问是否同样的两位玩家想再玩一次。如果是,我们可以继续为服务器和客户端使用相同的“读取器”和“写入器”实例。如果不是,我们将让客户端断开连接,服务器将继续监听另一位玩家加入:

    // If we're not disconnecting, ask about playing again
    if (!disconnecting) {
      String message = gameField.getWinner() +
          " Would you like to ask them to play again?";
      int myPlayAgain = JOptionPane.showConfirmDialog(gameField,
          message, "Play Again?", JOptionPane.YES_NO_OPTION);

      if (myPlayAgain == JOptionPane.YES_OPTION) {
        // If they haven't disconnected, ask to play again
        writer.println("PLAY_AGAIN");
        String playAgain = reader.readLine();
        if (playAgain != null) {
          switch (playAgain) {
            case "YES":
              startNewGame = true;
              break;
            case "DISCONNECT":
              keepPlaying = false;
              startNewGame = false;
              disconnecting = true;
              break;
            default:
              System.err.println("Warning. Unexpected response: "
                  + playAgain + ". Not playing again.");
          }
        }
      }
    }

最后客户端的一个互为对等的代码:

    if (!disconnecting) {
      // Check to see if they want to play again
      response = reader.readLine();
      if (response != null && response.equals("PLAY_AGAIN")) {
        // Do we want to play again?
        String message = gameField.getWinner() +
            " Would you like to play again?";
        int myPlayAgain = JOptionPane.showConfirmDialog(gameField,
            message, "Play Again?", JOptionPane.YES_NO_OPTION);
        if (myPlayAgain == JOptionPane.YES_OPTION) {
          writer.println("YES");
          startNewGame = true;
        } else {
          // Not playing again so disconnect.
          disconnecting = true;
          writer.println("DISCONNECT");
        }
      }
    }

表格 13-1 总结了我们的简单协议。

表格 13-1. 苹果投掷游戏协议

服务器命令参数(可选)客户端响应参数(可选)
NEW_GAMEOK
TREEx y
STARTOK
SCORE分数

分数

END

断开连接

|

分数

分数

|

END分数

分数

断开连接

分数
PLAY_AGAIN

YES

断开连接

DISCONNECT

更多探索

我们可以花费更多的时间来开发我们的游戏。我们可以扩展协议以允许多个对手。我们可以将目标更改为清除障碍物并摧毁您的对手。我们可以使协议更双向,允许客户端启动一些更新。我们可以使用 Java 支持的备用低级协议,如 UDP 而不是 TCP。事实上,有整整一本书专门讨论游戏、网络编程和编程网络游戏!

但哇!你成功了!说我们涵盖了很多领域真是大大低估了。我们希望您对 Java 的语法和核心类有扎实的理解。您可以利用这种理解继续学习其他有趣的细节和高级技巧。选择一个你感兴趣的领域,深入研究一下。如果您对 Java 仍然感到好奇,可以尝试连接本书的各个部分。例如,您可以尝试使用正则表达式来解析我们的苹果投掷游戏协议。或者,您可以构建一个更复杂的协议,通过网络传输小块二进制数据而不是简单的字符串。为了练习编写更复杂的程序,您可以将游戏中的一些内部和匿名类重写为独立的、独立的类,甚至用 lambda 表达式替换它们。

如果您想继续探索其他 Java 库和包,同时又坚持一些已经使用过的示例,您可以深入了解 Java2D API,使苹果和树木看起来更漂亮。您可以尝试一些其他集合对象,如TreeMapDeque。您可以研究流行的JSON 格式,并尝试重新编写多人通信代码。使用 JSON 作为协议可能会让您有机会使用一个库。

当您准备好进一步探索时,您可以尝试一些 Android 开发,了解 Java 在桌面之外的工作方式。或者查看大型网络环境和 Eclipse 基金会的 Jakarta 企业版。也许大数据正引起您的注意?Apache 基金会有几个项目,如 Hadoop 或 Spark。Java 有它的批评者,但它仍然是专业开发者世界中充满活力和重要的一部分。

现在我们已经列出了一些未来研究的途径,我们准备结束本书的主要部分。术语表包含了我们涵盖的许多有用术语和主题的快速参考。附录 A 详细说明了如何将代码示例导入到 IntelliJ IDEA 中。附录 B 包括了所有复习问题的答案以及一些提示和指导,以及代码练习的指导。

希望您享受本书的第六版学习 Java。这实际上是该系列的第八版,始于二十多年前的探索 Java。在这段时间里,观察 Java 的发展真是一段漫长而惊人的旅程,我们感谢多年来与我们同行的您们。正如往常一样,我们期待您的反馈,以帮助我们在未来使这本书变得更好。准备好迎接 Java 的另一个十年了吗?我们准备好了!

复习问题

  1. URL类默认支持哪些网络协议?

  2. 您能使用 Java 从在线源下载二进制数据吗?

  3. 使用 Java 将表单数据发送到 Web 服务器的高级步骤是什么?涉及哪些类?

  4. 您用于侦听传入网络连接的类是什么?

  5. 创建类似于您为游戏创建的服务器时,是否有选择端口号的任何规则?

  6. 用 Java 编写的服务器应用程序能支持多个同时客户端吗?

  7. 给定的客户端Socket实例可以连接到多少个同时服务器?

代码练习

  1. 创建您自己的人性化DateAtHost客户端(在我们的解决方案中是FDClient,友好日期客户端)和服务器(FDServer)。使用“日期和时间”中的类和格式化程序,生成一个发送包含当前日期和时间的一行格式良好文本的服务器。您的客户端应在连接后读取该行并将其打印出来。(您的客户端不需要扩展Instant甚至在打印之外存储响应。)

  2. 我们的游戏协议尚不包括对树篱障碍物的支持。(树篱仍然存在于游戏中,但它们尚未包含在网络通信中。)请查看 Table 13-1,并添加类似于我们TREE行的HEDGE条目支持。可能首先更新客户端会更容易,尽管你需要更新两端,以使树篱对于两名玩家的功能类似于树木。

进阶练习

  1. 升级你的FDServer类,以处理多个同时连接的客户端,可以使用线程或虚拟线程。你可以将客户端处理代码放入 lambda 表达式、匿名内部类或单独的辅助类中。你应该能够在不重新编译的情况下使用第一个练习中的FDClient类。如果使用虚拟线程,请记住它们可能仍然是 Java 版本中的预览功能。编译和运行时请使用适当的标志。(我们对这个练习的解决方案在FDServer2中。)

  2. 这个练习更像是一个推动,去探索 Web 服务的世界,现在你已经看过使用 Java 与在线 API 交互的一些示例。在线搜索一个具有免费开发者账户选项的服务(可能仍需注册),并编写一个 Java 客户端来访问该服务。许多在线服务都需要某种形式的身份验证,比如API 令牌API 密钥(通常是长字符串,类似于唯一的用户名)。像random.orgopenweathermap.org这样的网站可能是开始的有趣地方。(我们在解决方案中提供了一个完整的客户端NetworkInt,用于从random.org获取随机整数。为使客户端正常工作,你需要在源代码中提供自己的 API 密钥。)

¹ Postman 是 Web 开发人员的绝佳工具。你可以在Postman 网站了解更多。它们托管的测试服务位于postman-echo.com,接受 GET 和 POST 请求,并回显请求及任何表单或 URL 参数。

² 你可能之前听过“MIME 类型”的短语。MIME 起源于电子邮件,术语“媒体”意图更为通用。

³ 详细讨论这些低级套接字,请参阅Unix 网络编程,作者为 W. Richard Stevens 等人(Prentice-Hall)。

⁴ 例如,点对点环境具有同时执行两种角色的机器。

⁵ 欲了解更多著名服务及其标准端口号,请参阅由互联网编号分配机构(Internet Assigned Numbers Authority)托管的官方列表

⁶ 大型多人在线角色扮演游戏,如果像作者一样,你也对这个缩写感到陌生。叹息。

⁷ 实际上,我们使用的来自 NIST 的公开网站强烈建议用户升级。有关 NIST 互联网时间服务器的详细信息,请参阅介绍性说明

附录 A. 代码示例和 IntelliJ IDEA

本附录将帮助您通过整本书中找到的代码示例快速上手。这里的一些步骤已经在第二章中提到过,但我们希望在这里稍微详细地介绍一下如何在 JetBrains 的免费社区版 IntelliJ IDEA 中使用书中的示例。正如在“安装 IntelliJ IDEA 和创建项目”中所述,您可以从jetbrains.com网站获取 IntelliJ。如果您需要更多帮助设置,请参阅他们的安装指南

安装 IDEA 后,您需要确保选择了最新版本的 JDK。 (如果您仍需要安装 Java 本身,请参阅 “安装 JDK” ,详细介绍了各个主要平台的详情。)图 A-1 中显示的“文件 → 项目结构”对话框允许您从已安装的任何 JDK 中进行选择。为了本书的目的,您至少需要 Java 19。您还需要将所选 JDK 的“语言级别”选项设置为预览版本。

ljv6 0A01

图 A-1. 在 IntelliJ IDEA 中启用 Java 的预览功能

在这个示例中,我们使用 Corretto 20 并选择了 20 (Preview) 的语言级别。如果您想获取有关在 IntelliJ IDEA 中启用 Java 的预览功能的更多信息,请在线查看他们的预览功能教程

我们还想重申,IntelliJ IDEA 不是唯一一款友好的 Java 集成开发环境。它甚至不是唯一一款免费的!微软的VS Code 可以快速配置以支持 Java。而由 IBM 维护的Eclipse 仍然可用。初学者寻找一款旨在帮助他们进入 Java 编程和 Java IDE 世界的工具,可以看看由伦敦国王学院创建的BlueJ

获取主要的代码示例

无论您使用哪种 IDE 或编辑器,您都会希望从 GitHub 获取本书的代码示例。虽然在讨论特定主题时我们经常包含完整的源代码列表,但出于简洁和可读性的考虑,我们经常选择省略诸如importpackage语句,或者封闭的class结构。可下载的代码示例旨在完整,以帮助巩固书中的讨论内容。

您可以在浏览器中访问 GitHub,浏览单个示例,而无需下载任何内容。只需前往 learnjava6e 存储库。(如果该链接无法正常工作,请转到 github.com 并搜索术语“learnjava6e”)。对 GitHub 进行一般的探索可能会值得,因为它已成为开源开发人员甚至企业团队的主要聚集地。您可以查看存储库的历史记录,并报告错误并讨论与代码相关的问题。

该网站的名称指的是 git 工具,这是一种源代码控制系统或源代码管理器,开发人员用于管理代码项目团队之间的修订。但请回忆一下 图 2-14,您不必使用 git。您可以通过将项目的主分支作为 ZIP 存档 下载来获取所有示例的整个批次。下载完成后,只需将文件解压缩到您可以轻松找到示例的文件夹中。您应该看到类似于 图 A-2 的文件夹结构。

ljv6 0A02

图 A-2. 代码示例的文件夹结构

如果您对 Git 感兴趣,但您的系统尚未具备 git 命令,您可以从 Git 网站 下载它。GitHub 还有自己的网站,可帮助您了解 git,网址为 try.github.io。安装 git 后,您可以将项目克隆到计算机上的一个文件夹中。您可以从克隆中工作,或者将其保留为代码示例的干净副本。作为一个小小的奖励,如果我们将来发布任何修复或更新,您也可以轻松同步您的克隆文件夹。

导入示例

在我们将任何东西导入 IntelliJ IDEA 之前,您可能想要重命名从 GitHub 下载代码示例的文件夹。克隆或解压后,您可能会得到一个名为 learnjava6e-main 的文件夹。这是一个完全可以接受的名称,但如果您想要一个更友好(或更短)的名称,请立即重命名文件夹。我们选择将文件夹重命名为 learnjava6e(不带 -main 后缀)。

启动 IntelliJ IDEA,并从 图 A-3 显示的欢迎界面中选择“打开”选项。如果您已经使用过 IntelliJ IDEA 并且没有看到欢迎界面,则还可以从菜单栏中选择 文件 → 打开。

ljv6 0A03

图 A-3. IntelliJ IDEA 欢迎界面

转到您的代码示例文件夹,如 图 A-4 所示。确保您选择包含所有章节的顶层文件夹,而不是单个章节文件夹之一。

ljv6 0A04

图 A-4. 导入代码示例文件夹

打开示例文件夹后,您可能会被要求“信任”包含示例的文件夹。IDEA 提出此问题是为了确认文件夹中的潜在可执行类是否可以安全运行。

你可能还需要指定要用于编译和运行示例的 Java 版本。使用左侧的项目层次结构,打开 ch02/examples 文件夹并点击 HelloJava 类。我们类的源文件应该出现在右侧。如果你看到类似于 图 A-5 中显示的淡黄色横幅,请点击右上角的 Setup SDK 链接文本。(SDK 意为软件开发工具包,在我们的情况下与 JDK 同义。)从弹出的对话框中选择要使用的 JDK。

ljv6 0A05

图 A-5. 选择一个 JDK

对于本示例,我们选择了长期支持版本(21),但你可以选择安装的任何大于版本 19 的版本。(你始终可以使用文件 → 项目结构对话框更改选择或启用预览功能,如 图 A-1 所示。)

要检查一切是否正常工作,请在左侧树中右键单击 HelloJava 类,并从上下文菜单中选择运行 HelloJava.main() 项目项。

ljv6 0A06

图 A-6. 在 IDEA 中直接运行应用程序

恭喜!IntelliJ IDEA 已经设置好了,现在可以开始探索令人惊奇和令人满足的 Java 编程世界。

附录 B. 练习答案

本附录包含每章末尾的复习问题答案(通常还包含一些背景信息)。代码练习的答案随附示例程序的源码下载,存储在 exercises 文件夹中。附录 A 中详细介绍了获取源码并在 IntelliJ IDEA 中设置的方法。

第一章:现代语言

  1. 哪家公司当前维护 Java?

    尽管 Java 是在 1990 年代由 Sun Microsystems 开发的,但 Oracle 在 2009 年购买了 Sun(因此也购买了 Java)。Oracle 拥有并积极参与其商业 JDK 和开源 OpenJDK 的开发和分发。

  2. Java 的开源开发工具包的名称是什么?

    JDK 的开源版本被称为 OpenJDK。

  3. 名称 Java 安全运行字节码的两个主要组件。

    Java 具有许多与安全相关的特性,但在每个 Java 应用程序中起作用的主要组件是类加载器和字节码验证器。

第二章:首个应用程序

  1. 您应该使用什么命令来编译 Java 源文件?

    如果您在终端中工作,javac 命令会编译 Java 源文件。虽然在使用像 IntelliJ IDEA 这样的 IDE 时,细节通常被隐藏,但是这些 IDE 在幕后也使用 javac

  2. 当您运行 Java 应用程序时,JVM 如何知道从哪里开始?

    任何可执行的 Java 类必须定义 main() 方法。JVM 使用此方法作为入口点。

  3. 在创建新类时,您可以扩展多个类吗?

    不。Java 不支持从多个单独的类直接进行多重继承。

  4. 在创建新类时,您可以实现多个接口吗?

    是的。Java 允许您实现尽可能多的接口。使用接口为程序员提供了多重继承的大部分有用功能,而避开了许多陷阱。

  5. 哪个类代表图形应用程序中的窗口?

    JFrame 类代表 Java 图形应用程序中使用的主窗口,尽管后续章节将介绍一些也能创建特定窗口的低级类。

代码练习

一般情况下,我们不会在本附录中列出代码解决方案,但我们希望可以轻松地检查您对这个第一个程序的解决方案。简单文本版的“Goodbye, Java!” 应该看起来类似于这样:

public class GoodbyeJava {
  public static void main(String[] args) {
    System.out.println("Goodbye, Java!");
  }
}

对于图形版本,您的代码应该类似于这样:

import javax.swing.*;

public class GoodbyeJava {
  public static void main(String[] args) {
    JFrame frame = new JFrame("Chapter 2 Exercises");
    frame.setSize(300, 150);
    frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
    JLabel label = new JLabel("Goodbye, Java!", JLabel.CENTER);
    frame.add(label);
    frame.setVisible(true);
  }
}

请注意,我们添加了额外的 EXIT_ON_CLOSE,这是在 HelloJava2 中引入的,以便在关闭应用程序时正确退出。如果您使用 IDEA,可以使用 IDE 内部的绿色播放按钮运行任何一个类。如果您使用终端,可以切换到 GoodbyeJava.java 所在的目录并输入以下命令:

% javac GoodbyeJava.java
% java GoodbyeJava

第三章:工具

  1. 哪个语句允许您在应用程序中访问 Swing 组件?

    import 语句从指定的类或包加载编译器需要的信息。对于 Swing 组件,您通常使用 import javax.swing.*; 导入整个包。

  2. 什么环境变量确定 Java 在编译或执行时查找类文件的位置?

    CLASSPATH 环境变量保存了一个包含其他类或 JAR 文件的目录列表,这些文件可用于编译和执行。如果您使用的是 IDE,CLASSPATH 仍然被定义,但通常不需要您自己编辑。

  3. 您可以使用哪些选项查看 JAR 文件的内容而不解压缩它?

    您可以运行以下命令来显示 JAR 文件的内容,而不实际将其解压到当前目录:

    % jar tvf MyApp.jar
    

    tvf 标志代表目录(t)、详细信息(v)和文件(f 后跟文件名)的表格。

  4. 使 JAR 文件可执行所需的 MANIFEST.MF 文件条目是什么?

    您必须包含一个 Main-Class 条目,该条目给出具有有效 main() 方法的类的完全限定名称,以使给定的 JAR 文件可执行。

  5. 什么工具允许您交互式地尝试 Java 代码?

    您可以从终端运行 jshell 来交互式地尝试简单的 Java 代码。

代码练习

您将在 ch03/solutions 文件夹中找到我们对代码练习的解决方案。(附录 A 中有关于下载示例的详细信息。) 我们的解决方案并不是解决这些问题的唯一或者最佳方式。我们尝试呈现整洁、可维护的代码,并遵循最佳实践,但解决编码问题还有其他方法。希望您能够编写和运行自己的答案,但如果遇到困难,这里有一些提示。

要创建可执行的 hello.jar 文件,我们将在 ch03/exercises 文件夹中的终端中进行所有工作。(您当然也可以在IDEA 中进行此类工作。)请打开终端并切换到该文件夹。

在创建 JAR 文件本身之前,我们需要编辑 manifest.mf 文件。添加 Main-Class 条目。最终文件应如下所示:

Manifest-Version: 1.0
Created-By: 11.0.16
Main-Class: ch03.exercises.HelloJar

现在,您可以使用以下命令创建和测试您的 JAR 文件:

% jar cvmf manifest.mf hello.jar *.class
% java -jar hello.jar

请记住,标志中的 m 元素是必要的,以包含我们的清单。还值得提醒的是,mf 标志的顺序决定了随后跟随的 manifest.mfhello.jar 命令行参数的顺序。您还记得如何查看您新创建的 JAR 的内容以验证清单是否存在吗?¹

第四章:Java 语言

  1. Java 在编译类时默认使用什么文本编码格式?

    默认情况下,Java 使用 8 位 Unicode 转换格式(UTF-8)编码。8 位(或一个字节)编码可以容纳单字节和多字节字符。

  2. 用于包围多行注释的字符是什么?这些注释可以嵌套吗?

    Java 借鉴了 C 和 C++的注释语法。单行注释以双斜杠(//)开头,而多行注释则用/**/括起来。多行样式也可以用于在代码行中嵌入小注释。

  3. Java 支持哪些循环结构?

    Java 支持for循环(传统的 C 风格和用于遍历集合的增强形式)、while循环和do/while循环。

  4. if/else if/else测试链中,如果多个条件都为真会发生什么?

    与第一个评估为true的测试相关联的块将被执行。在该块完成后,控制将在整个链路后继续执行——无论有多少其他测试也会返回true

  5. 如果你想要将美国股市的总市值(大约在 2022 财年结束时为 31 万亿美元)存储为整数,你可以使用什么原始数据类型?

    你可以使用long整型;它可以存储高达 9 千亿(正负数皆可)的数字。虽然你也可以使用double类型,但随着数字变大,精度会降低。而且,“整数”意味着没有小数部分,整数类型更为合理。

  6. 表达式18 – 7 * 2的计算结果是什么?

    这是一个优先级问题,以确保你的高中代数老师最终得到了一些肯定,经历了所有那些“但我什么时候会用到这个?”的质疑。首先进行 7 和 2 的乘法运算,然后进行减法运算。最终答案是 4。(你可能得到了 22,这是从左到右执行操作的结果。如果你确实需要那个结果,你可以将18 – 7部分用括号括起来……就像这个旁注一样。)

  7. 如何创建一个包含一周的天数名称的数组?

    可以使用花括号创建和初始化数组。对于一周的天数,我们需要一个String数组,就像这样:

        String[] dayNames = {
          "Sunday", "Monday", "Tuesday", "Wednesday",
          "Thursday", "Friday", "Saturday"
        };
    

    列表中名称的间距是可选的。你可以将所有内容列在一行上,将每个名称列在单独的行上,或者像我们在这里做的那样,采用一些组合。

代码练习

  1. 有多种方法可以将原始的ab以及计算得到的最大公约数打印到屏幕上。你可以在开始计算之前使用print()语句(而不是 println()),然后在计算结束时使用println()打印答案。或者你可以在开始计算之前将ab复制到第二组变量中。找到答案后,你可以打印出复制的值以及结果。

  2. 要以简单的行形式输出三角形数据,可以使用填充三角形的相同嵌套循环:

        for (int i; i < triangle.length; i++)
          for (int j; j < triangle[i].length; j++)
            System.out.println(triangle[i][j]);
    

高级练习

  1. 要在视觉三角形中呈现输出,可以在内部j循环中使用print()语句。(确保在每个数字后打印一个空格。)内部循环完成后,可以使用空的println()来结束该行并准备下一行。

第五章:Java 中的对象

  1. Java 中的主要组织单元是什么?

    Java 中的主要“单元”是一个类。当然,许多其他结构也起着重要作用,但是你至少需要一个类才能使用其他任何东西。

  2. 你使用什么运算符来从类创建一个对象(或实例)?

    new 操作符实例化一个类的对象并调用适当的构造函数。

  3. Java 不支持经典的多重继承。Java 提供哪些机制作为替代?

    Java 使用接口来完成许多其他面向对象语言中多重继承的目标。

  4. 如何组织多个相关的类?

    你将相关的类放在一个包中。在你的文件系统中,包显示为嵌套文件夹。在你的代码中,包使用点分隔的名称。

  5. 如何将其他包中的类包含到你自己的代码中以供使用?

    你可以 import 其他单独的类或整个包供你自己使用。

  6. 如何称呼定义在另一个类范围内的类?在某些情况下,使这样的类变得有用的一些特性是什么?

    在另一个类的大括号内部(不仅仅在同一个文件中)定义的简单类称为内部类。内部类具有对外部类的所有变量和方法的访问权限,包括私有成员。它们可以帮助将代码分割成可管理和可重用的片段,同时提供对谁可以使用它们的良好控制。

  7. 如何称呼旨在被重写的方法,它具有名称、返回类型和参数列表,但没有主体?

    只有它们的签名定义的方法称为抽象方法。在类中包含抽象方法也使得该类成为抽象类。抽象类不能被实例化。你必须创建一个子类,然后为抽象方法提供一个真实的体来使用它。

  8. 什么是重载方法?

    Java 允许你使用相同的方法名以不同类型或数量的参数。如果两个方法共享相同的名称,它们被称为重载。重载使得在不同参数上执行相同逻辑工作的方法批量创建成为可能。Java 中重载方法的经典示例是 System.out.println(),它可以接受多种类型的参数并将它们全部转换为字符串以打印到终端。

  9. 如果你希望确保没有其他类可以使用你定义的变量,你应该使用什么访问修饰符?

    private 访问修饰符用于变量(或方法,或者整个内部类),将其使用限制在它定义的类中。

代码练习

  1. 对于我们的 Zoo 中的第一个问题,你只需在内部 Gibbon 类的空 speak() 方法中添加一个 print() 语句。希望 Lion 的示例容易跟随。

  2. 添加另一个动物也应该很简单;你可以复制整个Lion类。重命名类并在speak()方法中打印适当的声音。你还需要复制listen()方法中的几行代码,以便将你的动物声音添加到输出中。

  3. 为了重构listen()方法,我们注意到每个动物的输出非常相似,但显然每个动物的名称都会改变。如果我们将这个名称移到动物各自的类中,我们就可以创建一个循环,其主体打印出一个动物的细节(名称和声音)。然后我们迭代我们的动物数组。如果你再创建另一个动物,你只需要将你的新内部类的实例添加到数组中。

  4. 此练习的AppleToss类是exercises.ch05包的一部分。(game文件夹包含了已完成的游戏,其中包含了我们将在本书中构建的所有功能。该文件夹中的类属于game包。您可以编译和运行该版本,但它有一些我们尚未讨论的功能。)要从终端编译游戏,你可以进入ch05/exercises目录并在那里编译 Java 类,或者留在你解压源代码的顶层文件夹中,并在编译时给出路径:

    % javac ch05/exercises/AppleToss.java
    

    要运行游戏,你需要在顶层文件夹中。从那里,你可以使用java命令运行exercises.ch05.AppleToss类。

高级练习

  1. 希望添加一个Hedge看起来很简单。你可以从Tree类的副本开始。将文件重命名为Hedge.java。编辑类以反映我们的新Hedge障碍,并更新其paintComponent()方法。在Field类内部,你需要为Hedge添加一个成员变量。创建一个类似于setupTree()setupHedge()方法,并确保在FieldpaintComponent()方法中包含你的树篱。

    最后,但肯定不是最不重要的,更新setupFieldForOnePlayer()方法以调用我们的setupHedge()方法。编译并运行游戏,就像你在前面的练习中做的那样。你的新树篱应该会出现!

第六章:错误处理

  1. 在你的代码中,你使用什么语句来处理潜在的异常?

    你可以在可能生成异常的任何语句或语句组周围使用try/catch块。

  2. 编译器要求你处理或抛出哪些异常?

    在 Java 中,术语checked exception指的是编译器理解并要求程序员承认的异常类别。你可以在可能发生 checked exceptions 的方法中使用try/catch块,或者你可以在方法的签名中的throws子句中添加异常。

  3. try块中使用完资源后,你会把任何“清理”代码放在哪里?

    finally子句将在try块结束时无论发生什么都会运行。如果没有问题,finally子句中的代码将运行。如果有异常并且catch块处理了它,finally仍然会运行。如果发生未处理的异常,finally子句在控制转移回调用方法之前仍会运行。

  4. 禁用断言会对性能造成很大的惩罚吗?

    不是的。这是设计如此。断言通常更多用于开发或调试。当你关闭它们时,它们会被跳过。即使在生产应用中,你可能也会在代码中保留断言。如果用户报告了问题,可以临时打开断言以允许用户收集任何输出并帮助你找到原因。

代码练习

  1. 要使Pause.java文件编译通过,你需要在调用Thread.sleep()周围添加一个try/catch块。对于这个简单的练习,你只需要封装Thread.sleep()行。

  2. 我们需要的断言语句将具有以下形式:

        assert x > 0 : "X is too small";
        assert y > 0 : "Y is too small";
    

    更重要的问题是:我们应该把它们放在哪里?我们只需要检查消息的起始位置,所以我们不希望断言在paintComponent()方法内部。更好的地方可能是在HelloComponent0()构造函数中,在我们存储提供的message参数之后。

    要测试断言,你需要编辑源文件以更改xy的值并重新编译。

高级练习

  1. 你的GCDException类可能看起来像这样:

    package ch06.exercises;
    
    public class GCDException extends Exception {
      private int badA;
      private int badB;
    
      GCDException(int a, int b) {
        super("No common factors for " + a + ", " + b);
        badA = a;
        badB = b;
      }
    
      public int getA() { return badA; }
      public int getB() { return badB; }
    }
    

    你可以用一个简单的if语句测试你的 GCD 计算结果。如果结果是 1,你可以使用我们原来的ab作为参数调用你的新GCDException构造函数,像这样:

        if (a == 1) {
          throw new GCDException(a1, b1);
        }
        // ...
    

第七章:集合和泛型

  1. 如果你想存储一个包含姓名和电话号码的联系人列表,哪种集合类型最适合?

    Map是个好办法。键可以是简单的字符串,包含联系人的姓名,值可以是一个简单(尽管包装过的)长数字。或者你可以创建一个Person类和一个PhoneNumber类,然后地图可以使用你的自定义类。

  2. 你用什么方法为Set中的项目获取迭代器?

    Collection接口中富有创意的iterator()方法将为你获取迭代器。

  3. 如何将List转换为数组?

    你可以使用toArray()方法将List转换为Object类型的数组或列表的参数化类型的数组。

  4. 如何将数组转换为List

    Arrays辅助类包括方便的asList()方法,接受一个数组并返回相同类型的参数化列表。

  5. 要使用Collections.sort()方法对列表进行排序,你应该实现什么接口?

    尽管有许多方法可以对集合进行排序,但Comparable对象列表(表示其类实现了Comparable接口的对象)可以使用Collections辅助类提供的标准sort()方法。

代码练习

  1. 正如我们在本章中提到的,您不能像对列表或数组进行排序一样直接对简单映射进行排序。甚至Set通常也不可排序。²不过,您可以对列表进行排序,因此使用keySet()方法填充列表应该可以满足您的需求:

        List<Integer> ids = new ArrayList<>(employees.keySet());
        ids.sort();
        for (Integer id : ids) {
          System.out.println(id + ": " + employees.get(id));
        }
    
  2. 希望你对于支持多种对冲的扩展感觉很直观。我们主要是复制已有的树代码。使用List允许我们使用增强的for循环快速遍历所有对冲:

    // File: ch07/exercises/Field.java
      List<Hedge> hedges = new ArrayList<>();
      // ...
    
      protected void paintComponent(Graphics g) {
        // ...
        for (Hedge h : hedges) {
          h.draw(g);
        }
        // ...
      }
    

高级练习

  1. 您可以使用values()输出创建并排序类似于代码练习 1 解决方案的列表。这个练习的有趣部分是使用Employee类实现Comparable接口。(实际上,在ch07/solutions文件夹中,可排序的员工类是Employee2。我们希望将原始的Employee类保留为第一个练习的有效解决方案。)这是一个使用员工姓名进行字符串比较的示例:

    public class Employee2 implements Comparable<Employee2> {
      // ...
      public int compareTo(Employee2 other) {
        // Let's be a little fancy and sort on a constructed name
        String myName = last + ", " + first;
        String otherName = other.last + ", " + other.first;
        return myName.compareToIgnoreCase(otherName);
      }
      // ...
    }
    

    当然,您可以使用其他Employee属性进行其他比较。尝试玩弄一些其他排序,并查看是否得到您预期的结果。如果想进一步深入,请查看java.util.TreeMap类,以一种无需列表转换的方式将员工存储为排序方式。

第八章:文本和核心工具

  1. 哪个类包含常量π?需要导入该类来使用π吗?

    java.lang.Math类包含常量PIjava.lang包中的所有类都会默认导入;使用它们无需显式import

  2. 哪个包含原始java.util.Date类的新的、更好的替代品?

    java.time包包含各种质量类,用于处理日期、时间、时间戳(或由日期和时间组成的“时刻”)以及时间跨度或持续时间。

  3. 你使用哪个类来为用户友好输出格式化日期?

    java.text包中的DateFormat类具有非常灵活(有时不透明)的格式化引擎,用于呈现日期和时间。

  4. 正则表达式中使用什么符号来帮助匹配单词“yes”和“yup”?

    您可以使用交替运算符|(竖线)创建表达式,例如yes|yup,用作模式。

  5. 如何将字符串“42”转换为整数 42?

    各种数值包装类都有字符串转换方法。对于像 42 这样的整数,Integer.parseInt()方法是适用的。包装类都属于java.lang包。

  6. 如何比较两个字符串以查看它们是否匹配,忽略大小写,例如“yes”和“YES”?

    String类有两个主要的比较方法:equals()equalsIgnoreCase()。后者会忽略大小写,顾名思义。

  7. 哪个运算符允许简单的字符串连接?

    Java 通常不支持运算符重载,但加号(+)在与数字基本类型一起使用时执行加法,在与String对象一起使用时执行连接。如果你使用+来“添加”一个字符串和一个数字,结果将是一个字符串。(因此,7 + "is lucky"将得到字符串“7is lucky”。注意,连接不会插入任何空格。如果你要组装一个典型的句子,你必须在部分之间添加自己的空格。)

代码练习

有很多方法可以完成这个练习的目标。测试参数数量应该很简单。然后,你可以使用String类的一些特性来判断你是否有随机关键字或一对坐标。你可以使用split()方法分割坐标,或者编写一个正则表达式来分离数值。在创建随机坐标时,你可以使用Math.random(),类似于我们在“数学实践中”中为游戏定位树木的方式。

第九章:线程

  1. 什么是线程?

    线程代表程序内的“执行线程”。线程有自己的状态,并且可以独立于其他线程运行。通常,你使用线程来处理可以放在后台运行的长时间任务,而更重要的任务可以继续进行。Java 既有平台线程(与操作系统提供的本地线程一一对应)又有虚拟线程(纯 Java 构造,保留了本地线程的语义和好处,但没有操作系统的开销)。

  2. 如果你希望线程在调用方法时“轮流”执行(即不希望两个线程同时执行该方法以避免破坏共享数据),你可以为该方法添加哪个关键字?

    你可以在任何读取或写入共享数据的方法上使用synchronized修饰符。如果两个线程需要使用同一个方法,第一个线程设置一个锁,阻止第二个线程调用该方法。一旦第一个线程完成,锁将被清除,第二个线程可以继续。

  3. 哪些标志允许你编译包含预览特性代码的 Java 程序?

    在编译依赖于预览特性的 Java 类时,你必须提供--enable-preview以及-source--release标志给javac

  4. 哪些标志允许你运行包含预览特性代码的 Java 程序?

    运行包含预览特性的编译类时,你只需要提供--enable-preview标志。

  5. 一个本地线程能支持多少平台线程?

    只有一个。使用Thread类创建一个平台线程,并带有Runnable目标或使用java.util.concurrent包中的ExecutorService类,都需要操作系统提供一个线程。

  6. 单个本机线程可以支持多少个虚拟线程?

    一个单独的本机线程可以支持许多虚拟线程。Project Loom 旨在将 Java 程序中使用的线程与操作系统管理的线程分开。对于某些场景,轻量级虚拟线程在 Java 负责其调度时表现更佳。虚拟线程与本机线程的数量之间没有固定的比率,但虚拟线程的关键洞见是,其数量不与本机线程的数量挂钩。

  7. 对于int变量x,语句x = x + 1;是否是原子操作?

    尽管这看起来是一个小操作,但涉及到几个低级步骤。这些低级步骤中的任何一个都可能被中断,并且x的值可能会受到不利影响。如果需要保证线程安全的增量,可以使用AtomicInteger或将语句包装在同步块中。

  8. 哪个包含了像QueueMap这样的流行集合类的线程安全版本?

    java.util.concurrent包含几个 Java 定义为“并发”的集合类,例如ConcurrentLinkedQueueConcurrentHashMap。并发除了纯线程安全的读写之外,还涉及几个其他行为,但线程安全是得到保证的。

代码练习

  1. 你将会在startClock()方法内完成大部分工作。(当然,除了使用的 AWT 和 Swing 包外,你仍需导入其他内容。)你可以创建一个单独的类、内部类或匿名内部类来处理时钟更新循环。记住,可以通过调用其repaint()方法请求 GUI 元素的刷新。Java 支持几种“无限”循环的机制。你可以使用像while (true) { …​ }这样的结构,或者巧妙命名的“forever”循环:for (;;) { …​ }。一切就绪后,别忘了启动你的线程!

  2. 希望对你来说,这个练习相对简单。作为 Java 现有代码库与虚拟线程的整体兼容性的证明,你只需更改演示苹果投掷动画开始的几行代码。在这个游戏的这一轮中,所有设置和启动代码都发生在Field类中。查找类似new Thread()new Runnable()的代码。你应该能够在不做任何修改的情况下重用实际的动画逻辑。

第十章:文件输入和输出

  1. 如何检查给定的文件是否已经存在?

    有几种方法可以检查文件是否存在,但其中两种最简单的方法依赖于java.iojava.nio包中的辅助方法。java.io.File的实例可以使用exists()方法。静态的java.nio.file.Files.exists()方法可以测试Path对象以查看所表示的文件是否存在。

  2. 如果必须使用旧的编码方案(如 ISO 8859)处理遗留文本文件,如何设置读取器以正确将内容转换为 UTF-8?

    你可以向FileReader的构造函数提供适当的字符集(java.nio.charset.Charset),安全地将文件转换为 Java 字符串。

  3. 哪个包中有最适合非阻塞文件 I/O 的类?

    java.nio包及其子包的主要特性之一是支持非阻塞 I/O。

  4. 你可能会使用哪种类型的输入流来解析二进制文件,比如 JPEG 压缩的图片?

    java.io,你可以使用DataInputStream类。对于 NIO,通道和缓冲区(如ByteBuffer)自然地支持二进制数据。

  5. System类内置了哪三个标准文本流?

    System类提供了两个输出流,System.outSystem.err,以及一个输入流System.in。这些流分别连接到操作系统的stdoutstderrstdin句柄。

  6. 绝对路径从根目录开始(例如/C:\)。相对路径从哪里开始?更具体地说,相对路径相对于什么?

    相对路径是相对于“工作目录”的,通常这是你启动程序的地方,如果你使用命令行启动你的应用程序。在大多数 IDE 中,工作目录是可以配置的。

  7. 如何从现有的FileInputStream中检索一个 NIO 通道?

    如果你已经有一个FileInputStream实例,你可以使用它的getChannel()方法返回与输入流关联的FileChannel

代码练习

  1. 我们的第一个Count迭代只需使用本章讨论的其中一个工具。你可以使用作为命令行参数给定路径的File类。从那里,exists()方法将告诉你是否可以继续,或者是否应该打印友好的错误消息;length()方法将给出文件的大小,以字节为单位。(此示例的解决方案是位于ch10/solutions文件夹中的Count1.java。)

  2. 对于第二次迭代,在给定文件中显示行数和单词数,你需要读取和解析文件的内容。其中一个Reader类会很好,但有多种读取文本文件的方式。无论如何打开文件,你可以计算每一行,然后用String.split()或正则表达式将该行分解为单词。(此练习的解决方案是Count2.java。)

  3. 这第三个版本中没有任何新功能,但我们希望你能借此机会尝试一些 NIO 类和方法。看看java.nio.file.Files类的方法。你会惊讶于这个助手类有多大帮助!(此练习的解决方案是Count3.java。)

高级练习

  1. 对于这次最终的升级,您可以写入文件或通道。根据您选择在版本 2 或 3 中读取内容的方式,这可能代表我们类中的相当重要的添加。您需要检查确保第二个参数(如果给定!)是可写的。然后使用允许追加的类之一,如RandomAccessFile,或为FileChannel包括APPEND选项。(此练习的解决方案是Count4.java。我们使用了之前的Count3与 NIO,但您可以从Count2开始并使用标准 I/O 类。)

第十一章:Java 中的函数式方法

  1. 哪个包含大多数功能接口?

    虽然函数式接口分散在整个 JDK 中,但您会发现大多数“官方”接口定义在java.util.function包中。我们在“官方”上加了引号,因为任何具有单个抽象方法(SAM)的接口都可以视为函数式接口。

  2. 在编译或运行使用像 lambda 这样的函数特性的 Java 应用程序时,是否需要使用任何特殊标志?

    是的。目前 Java 中的许多函数式编程功能都是 JDK 的完整成员。编译或执行使用它们的 Java 代码时不需要预览或功能标志。

  3. 如何创建具有多个语句的 lambda 表达式主体?

    lambda 表达式的主体遵循与诸如while循环之类的主体相同的规则:单个语句不需要括号括起来,但多个语句需要。如果您的 lambda 返回一个值,您也可以使用标准的return语句。

  4. lambda 表达式可以是 void 吗?它们可以返回值吗?

    在这两个方面都是可以的。Lambda 表达式可以运行与方法相同的各种选项。您可以有不接受参数且不返回值的 lambda 表达式。您可以有消耗参数但不产生结果的 lambda 表达式。您可以有不接受参数但返回值的 lambda 生成器。最后,您可以有接受一个或多个参数并返回值的 lambda 表达式。

  5. 在处理完流后能否重用它?

    不行。一旦您开始处理流,那就是它了。试图重用流将导致异常。如果需要,您通常可以重用原始源来创建一个全新但完全相同的流。

  6. 如何将对象流转换为整数流?

    您可以使用Stream类的mapToInt()变体之一:mapToInt()flatMapToInt()mapMultiToInt()。反过来,IntStream类有一个mapToObj()方法以在相反方向进行转换。

  7. 如果您有一个从文件中过滤空行的流,您可能会使用什么操作来告诉您有多少行包含内容?

    计算剩余行数的最简单方法是使用count()终端操作。你也可以创建自己的规约器,或者使用一个收集器然后查询结果列表的长度。

代码练习

  1. 希望我们对调整使用更有趣的公式感到顺利。我们不需要任何替代语法或额外方法;我们只需将摄氏度转换公式 C =(F - 32)* 5 / 9 放入我们的 lambda 体中,如下所示:

        System.out.print("Converting to Celsius: ");
        System.out.println(adjust(sample, s -> (s - 32) * 5 / 9));
    

    这并不是非常引人注目,但我们想指出,Lambda 表达式可以开启一些非常聪明的可能性,这些可能性超出了最初的计划。

  2. 对于这种平均值任务,你有多种选择可用。你可以编写一个平均值规约器。你可以将工资收集到一个更简单的容器中,然后编写自己的平均代码。但是,如果你查看不同流的文档,你会注意到数值流已经有了完美的操作:average()。它返回一个OptionalDouble对象。你仍然需要启动流,然后使用类似mapToInt()的东西来获取你的数值流。

高级练习

  1. groupingBy()收集器需要一个从流的每个元素中提取键并返回键与所有具有匹配键的元素列表的映射的函数。对于我们的PaidEmployee示例,你可能会有类似这样的内容:

        Map<String, List<PaidEmployee>> byRoles =
          employees.stream().collect(
          Collectors.groupingBy(PaidEmployee::getRole));
    

    我们映射中键的类型必须与我们在groupingBy()操作中提取的对象类型相匹配。我们在这里使用了一个方法引用,但是任何返回员工角色的 lambda 也将起作用。

    我们不想使先前的解决方案复杂化,所以我们复制了报告和员工类,并分别命名为Report2PaidEmployee2

第十二章:桌面应用程序

  1. 你会用哪个组件向用户显示一些文本?

    虽然你可以使用多种基于文本的组件,但JLabel是向用户展示一些(只读)文本信息的最简单方式。

  2. 你会用哪些组件来允许用户输入文本?

    根据用户期望获得的信息量,你可以使用JTextFieldJTextArea。 (还有其他文本组件存在,但它们提供更专业化的用途。)

  3. 单击按钮会生成什么事件?

    单击按钮或类似按钮的组件(如JMenuItem)会生成ActionEvent

  4. 如果你想知道用户何时更改所选项目,应该将监听器附加到JList

    你可以实现来自javax.swing.event包的ListSelectionListener来接收JList对象的列表选择(和取消选择)事件。

  5. JPanel的默认布局管理器是什么?

    默认情况下,JPanel使用FlowLayout管理器。这个默认的一个显著例外是JFrame的内容窗格。该窗格是一个JPanel,但是框架会自动将窗格的管理器更改为BorderLayout

  6. 在 Java 中,哪个线程负责处理事件?

    事件分发线程,有时被称为事件分发队列,管理着事件的传递和屏幕上组件的更新。

  7. 在后台任务完成后,你会用什么方法来更新像JLabel这样的组件?

    如果你希望在处理其他事件之前等待标签更新完成,可以使用SwingUtilities.invokeAndWait()。如果不在乎标签何时更新完成,可以使用Swing Utilities.invokeLater()

  8. 什么容器持有JMenuItem对象?

    JMenu对象可以持有JMenuItem对象以及嵌套的JMenu对象。菜单本身则包含在JMenuBar中。

代码练习

  1. 你可以以两种方式处理计算器布局:使用嵌套面板或使用GridBagLayout。(我们在 ch12/solutions/Calculator.java 中的解决方案使用了嵌套面板来布置按钮。)从简单开始。将文本字段添加到框架顶部。然后在中心添加一个按钮。现在决定如何处理添加剩余按钮。如果你的按钮使用了Calculator实例(使用我们在 “Shadowing” 中讨论的关键字this作为它们的监听器),你应该看到任何点击的按钮标签都会打印到终端上。

  2. 这个练习并不需要太多新的图形代码。但是你需要在 UI 事件线程中安全地修改场地上显示的障碍物。你可以从简单的打印消息或使用JOptionPane来显示警告开始慢慢进行,只要苹果碰到树或篱笆就触发。在你对距离测量有信心后,回顾一下如何从列表中移除对象。移除障碍物后,记得重新绘制场地。

高级练习

  1. 计算器的逻辑相对简单,但绝对不是琐碎的。首先让各种数字按钮(1、2、3 等)与显示器连接起来。你需要追加数字来创建完整的数字。当用户点击“–”等操作按钮时,将当前显示的数字及将来使用的操作存储起来。让用户输入第二个数字。点击“=”应该存储这第二个数字,然后执行实际的计算。将结果显示在显示器上,然后让用户重新开始。

    在完整的专业计算器应用程序中有许多(许多!)微妙之处。如果你的早期尝试限制为仅处理单个数字,不要担心。这个练习的重点是练习响应事件。即使只是在用户点击按钮后让一个数字显示在显示字段中,也值得庆祝!

第十三章:Java 网络编程

  1. URL类默认支持哪些网络协议?

    Java 的URL类包含对 HTTP、HTTPS 和 FTP 协议的支持。这三种协议涵盖了在线资源的大部分内容,但如果你处理除 Web 或文件服务器之外的系统,你可以创建自己的协议处理程序。

  2. 你可以使用 Java 从在线源下载二进制数据吗?

    可以。字节流是 Java 中所有网络数据的核心。你可以读取原始字节,或者可以链接其他更高级别的流。例如,InputStreamReaderBufferedReader非常适合文本。DataInputStream可以处理二进制数据。

  3. 如何使用 Java 将表单数据发送到 Web 服务器?(不需要完整功能的应用程序,我们只想让你考虑涉及的高级步骤和涉及的 Java 类。)

    你可以使用URL类打开到 Web 服务器的连接。在发出任何请求之前,你可以配置双向通信的连接。HTTP POST命令允许你在请求的正文中向服务器发送数据。

  4. 你用什么类来监听传入的网络连接?

    你使用java.net.ServerSocket类来创建一个网络监听器。

  5. 当像我们的游戏一样创建自己的服务器时,有关于选择端口号的规则吗?

    可以。端口号必须在 0 到 65,535 之间,通常为小于 1,024 的端口保留给常见服务,通常需要特殊权限才能使用。

  6. 用 Java 编写的服务器应用程序可以支持多个同时客户端吗?

    是的。虽然你只能在给定端口上创建一个ServerSocket,但你可以接受数百甚至数千个客户端并在线程中处理他们的请求。

  7. 给定的客户端Socket实例可以与多少个同时服务器通信?

    一个。客户端套接字与一个主机在一个端口上通信。客户端应用程序可以允许使用多个独立的套接字与多个服务器同时通信,但每个套接字仍然只能与一个服务器通信。

代码练习

  1. 向我们的游戏协议添加一个功能需要更新服务器和客户端代码。幸运的是,我们可以在两端重复使用TREE条目的逻辑。更幸运的是,我们所有的网络通信代码都在Multiplayer类中。

    客户端和服务器是内部类,分别被创造性地命名为ClientServer。对于服务器,在run()方法中添加一个循环来在发送树数据后立即发送树篱数据。对于客户端,在run()方法中添加一个HEDGE段,接受树篱的位置并将其添加到场地上。

    一旦为两名玩家设置了字段,在游戏内部分协议中只报告分数和断开连接。我们不必修改任何此代码。每个玩家都将面对相同的树篱障碍,并有机会用扔苹果来移除它们。

  2. 一个人类可读的日期/时间服务器应该相当简单,但是我们希望您练习从头设置自己的套接字。您需要为服务器选择一个端口号。3283 在电话键盘上拼写“DATE”,如果您需要一些灵感。我们建议在接受连接后立即处理客户端请求。(高级练习为您提供了尝试使用线程更复杂方法的机会。)

    对于客户端,唯一的可配置数据是服务器的名称。如果您计划在本地机器上的两个终端窗口上测试您的解决方案,则可以随意硬编码“localhost”。我们的解决方案还接受一个可选的命令行参数,默认为“localhost”,如果您不提供参数。

高级练习

  1. 要处理使用线程的客户端,您需要隔离与客户端通信的代码。可以使用帮助类(内部类、匿名类或独立类都可以),或者使用 lambda 表达式。您仍然需要让ServerSocket完成其工作并accept()新连接,但一旦接收到,您可以立即将接受的Socket对象移交给您的帮助类。

    真的很难测试这个类,因为您需要同时有许多客户端在同一时刻请求当前日期。但至少,您的当前FDClient类应该可以在不更改的情况下工作,并且您应该仍然收到正确的日期。

  2. 使用在线 API 可以很有趣,但也需要注意细节。通常在开始创建客户端时,您需要回答一些问题:

    • API 的基本 URL 是什么?

    • API 使用标准的 Web 表单编码或 JSON 吗?如果不是,是否有支持编码和解码的库?

    • 您能够发起多少请求或下载多少数据有限制吗?

    • 网站是否有良好的文档,包含发送和检索数据的常见示例?

随着实践,您将会形成自己对开始使用新 API 所需信息的感觉。但是,您确实需要练习。在构建第一个客户端之后,查找另一个服务。为该 API 编写客户端,并查看您是否已经能够识别常见问题或更好地重用第一个客户端的代码。

¹ 您可以使用jar tvf <jarfile>查看任何 JAR 或 ZIP 文件。

² 不过,您可以使用SortedSetTreeMap,它们都会保持其条目排序。对于TreeMap,键保持有序。