Java 秘籍第四版(五)

原文:zh.annas-archive.org/md5/0f97e455a02e6f168767c004952156f0

译者:飞龙

协议:CC BY-NC-SA 4.0

第十一章:数据科学与 R

数据科学是一个相对较新的学科,最初因 O’Reilly 的 Mike Loukides 的这篇文章 而引起了许多人的注意。虽然在这个领域有许多定义,但 Loukides 将他对该领域的详细观察和参与归结为这个定义:

数据应用从数据本身获取其价值,并随之生成更多数据。它不仅仅是一个带有数据的应用程序;它是一个数据产品。数据科学使得数据产品的创建成为可能。

用于数据科学软件的主要开源生态系统之一位于 Apache,包括 Hadoop(包括 HDFS 分布式文件系统,Hadoop Map/Reduce,¹ Ozone 对象存储和 Yarn 调度程序)、Cassandra 分布式数据库Spark 计算引擎。请阅读 Hadoop 页面的“模块和相关工具”部分以获取当前列表。

这里有趣的是,许多数据科学家视为理所当然的大部分基础设施都是用 Java 和 Scala(一种 JVM 语言)编写的。其余大部分则是用 Python 编写的,这是一种与 Java 互补的语言。

数据科学问题可能涉及大量的设置,所以我们只会从传统数据科学中给出一个使用 Spark 框架的例子。Spark 是用 Scala 编写的,因此可以直接被 Java 代码使用。

在本章的其余部分,我将专注于一种称为 R 的语言,它在统计学和数据科学中被广泛使用(好吧,在许多其他科学领域也是如此;你在同行评审的期刊文章中看到的许多图表都是用 R 准备的)。R 被广泛使用,了解它是很有用的。它的主要实现不是用 Java 编写的,而是用 C、Fortran 和 R 本身的混合语言。但是 R 可以在 Java 中使用,Java 也可以在 R 中使用。我将讨论几种 R 的实现方式以及如何选择一种,然后展示如何从 R 中使用 Java,从 Java 中使用 R,以及在 Web 应用程序中使用 R 的技术。

11.1 使用 Java 进行机器学习

问题

你想要使用 Java 进行机器学习和数据科学,但每个人都告诉你要使用 Python。

解决方案

使用众多免费下载的强大 Java 工具包之一。

讨论

有时候人们说机器学习(ML)和深度学习必须用 C++ 来提高效率,或者用 Python 来利用广泛的软件可用性。尽管这些语言各有其优势和支持者,但确实可以使用 Java 来实现这些目的。然而,设置这些软件包并展示一个简短的演示比适合本书典型的配方格式要长。

随着行业巨头亚马逊发布基于 Java 的 Deep Java Learning (DJL) 库,以及许多其他优秀的库(其中不少支持 CUDA 以加速 GPU 计算)(参见 Table 11-1),没有理由不使用 Java 进行机器学习。除了 DJL 外,我尽量列出那些仍在维护且在用户中口碑不错的包。

Table 11-1. 一些 Java 机器学习包

Library name描述信息网址源码网址
ADAMS用于构建/维护数据驱动反应式工作流程的工作流引擎;与业务流程集成https://adams.cms.waikato.ac.nz/https://github.com/waikato-datamining/adams-base
Deep Java Library亚马逊的机器学习库https://djl.aihttps://github.com/awslabs/djl
Deeplearning4jDL4J,Eclipse 的分布式深度学习库;与 Hadoop 和 Apache Spark 集成https://deeplearning4j.org/https://github.com/eclipse/deeplearning4j
ELKI数据挖掘工具包https://elki-project.github.io/https://github.com/elki-project/elki
Mallet用于文本处理的机器学习库mallet.cs.umass.eduhttps://github.com/mimno/Mallet.git
Weka数据挖掘的机器学习算法;提供数据准备、分类、回归、聚类、关联规则挖掘和可视化工具https://www.cs.waikato.ac.nz/ml/weka/index.htmlhttps://svn.cms.waikato.ac.nz/svn/weka/trunk/weka

另请参阅

书籍 Data Mining: Practical Machine Learning and Techniques 由 Ian Witten 等人(Morgan Kaufmann 出版)编写,他们也是 Weka 背后团队的成员。

还可以参考 Eugen Parschiv 的 Java AI 软件包列表

11.2 在 Apache Spark 中使用数据

问题

您希望使用 Spark 处理数据。

解决方案

创建一个 SparkSession,使用其 read() 函数读取 DataSet,应用操作并总结结果。

讨论

Spark 是一个非常庞大的主题!已经有整本书专门讲述了它。引用 Databricks,这个团队是 Spark 最初的开发者之一:²

Apache Spark™ 在过去几年中取得了巨大的增长,成为今天企业中的事实标准数据处理和 AI 引擎,这归功于其速度、易用性和复杂的分析功能。Spark 通过简化跨多个来源的大规模数据准备,为数据工程和数据科学工作负载提供一致的 API 集,以及与流行的 AI 框架和库(如 TensorFlow、PyTorch、R 和 SciKit-Learn)的无缝集成,统一了数据和人工智能。

我无法在本书中传达整个主题内容。然而,Spark 擅长处理大量数据,比如在示例 11-1 中,我们读取了一个 Apache 格式的日志文件,并找到(和计数)具有 200、404 和 500 响应的行。

示例 11-1. spark/src/main/java/sparkdemo/LogReader.java
import org.apache.spark.sql.SparkSession;
import org.apache.spark.sql.Dataset;
import org.apache.spark.api.java.function.FilterFunction;

/**
 * Read an Apache Logfile and summarize it.
 */
public class LogReader {

    public static void main(String[] args) {

        final String logFile = "/var/wildfly/standalone/log/access_log.log";    <https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/1.png>
        SparkSession spark =
            SparkSession.builder().appName("Log Analyzer").getOrCreate();       <https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/2.png>
        Dataset<String> logData = spark.read().textFile(logFile).cache();       <https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/3.png>

        long good = logData.filter(                                             <https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/4.png>
        new FilterFunction<>() {public boolean call(String s) {
                    return s.contains("200");
                }
            }).count();

        long bad = logData.filter(new FilterFunction<>() {
                public boolean call(String s) {
                    return s.contains("404");
                }
            }).count();

        long ugly = logData.filter(new FilterFunction<>() {
                public boolean call(String s) {
                    return s.contains("500");
                }
            }).count();

        System.out.printf(                                                      <https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/5.png>
            "Successful transfers %d, 404 tries %d, 500 errors %d\n",
            good, bad, ugly);

        spark.stop();
    }
}

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/#co_data_science_and_r_CO1-1

设置日志文件的文件名。可能应该从args中获取。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/#co_data_science_and_r_CO1-2

启动 Spark SparkSession对象——运行时环境。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/#co_data_science_and_r_CO1-3

告诉 Spark 读取日志文件并将其保留在内存中(缓存)。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/#co_data_science_and_r_CO1-4

定义 200、404 和 500 错误的过滤器。它们应该能够使用 lambda 表达式来使代码更简洁,但 Java 和 Scala 版本的FilterFunction之间存在歧义。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/#co_data_science_and_r_CO1-5

打印结果。

要使其编译通过,您需要将以下内容添加到 Maven 的 POM 文件中:

<dependency>
    <groupId>org.apache.spark</groupId>
    <artifactId>spark-sql_2.12</artifactId>
    <version>2.4.4</version>
    <scope>provided</scope>
</dependency>

然后你应该能够执行mvn package命令来生成一个打包好的 JAR 文件。

使用provided范围的原因是因为我们还将从Spark 下载页面下载 Apache Spark 运行时包以运行应用程序。解压分发包并将SPARK_HOME环境设置为其根目录:

SPARK_HOME=~/spark-3.0.0-bin-hadoop3.2/

然后你可以使用我在源代码下载中提供的run脚本(javasrc/spark)。

Spark 的设计面向比这个简单示例更大规模的计算,因此其庞大的输出简直淹没了我简单示例程序的输出。尽管如此,对于一个大约有 42,000 行的文件,我确实得到了这个结果,埋藏在日志记录中:

Successful transfers 32555, 404 tries 6539, 500 errors 183

如前所述,Spark 是一个庞大的主题,但对大多数数据科学家来说是一个必不可少的工具。你可以使用 Java(显然),或者 Scala 来编写 Spark 程序。Scala 是一种促进函数式编程的 JVM 语言(参见此 Scala 教程供 Java 开发人员使用),以及 Python 和可能其他语言。你可以在https://spark.apache.org或者在线的众多书籍、视频和教程中了解更多。

11.3 使用 R 进行交互

问题

你对 R 一无所知,但你想要了解它。

解决方案

R 已经存在多年,其前身 S 则存在了十年之久。有许多书籍和在线资源致力于这种语言。官方主页位于https://www.r-project.org。还有许多在线教程;R 项目提供了一个。R 本身可以在大多数系统的软件包管理器中找到,并且可以从官方下载站点下载。这些 URL 中的名称 CRAN 代表 Comprehensive R Archive Network,类似于 TeX 的 CTAN 和 Perl 语言的 CPAN。

在这个例子中,我们将从 Java 程序中写入一些数据,然后使用 R 进行交互式分析和绘图。

讨论

这只是一个使用 R 进行交互式操作简介。可以说,R 是一个非常有价值的交互式环境,用于探索数据。以下是一些简单的计算,展示了该语言的特色:一个健谈的启动(如此之长,我不得不截断了一部分),简单算术运算,如果未保存则自动打印结果,当你犯错误时会有相当不错的错误提示,以及对向量的算术运算。你可能会发现与 Java 的 JShell(见食谱 1.4)有些相似之处;它们都是 REPL(读取-求值-打印 循环)接口。R 添加了在退出程序时保存你的交互会话(工作空间)的功能,因此下次启动 R 时会恢复所有的数据和函数定义。展示 R 语法的简单交互会话可能如下所示:

$ R

R version 3.6.0 (2019-04-26) -- "Planting of a Tree"
Copyright (C) 2019 The R Foundation for Statistical Computing
Platform: x86_64-apple-darwin15.6.0 (64-bit)

R is free software and comes with ABSOLUTELY NO WARRANTY.
You are welcome to redistribute it under certain conditions.
Type 'license()' or 'licence()' for distribution details.

...

> 2 + 2
[1] 4
> x = 2 + 2
> x
[1] 4
> r = 10 20 30 40 50
Error: unexpected numeric constant in "r = 10 20"
> r = c(10,20,30,45,55,67)
> r
[1] 10 20 30 45 55 67
> r+3
[1] 13 23 33 48 58 70
> r / 3
[1]  3.333333  6.666667 10.000000 15.000000 18.333333 22.333333
>quit()
Save workspace image? [y/n/c]: n
$

R 纯粹主义者通常会在分配时使用赋值箭头 ← 替代 = 符号。如果你喜欢这样,可以去尝试。

这个简短的会话只是浅尝辄止:R 提供了数百个内置函数、示例数据集、一千多个附加包、内置帮助等等。对于交互式数据探索,R 确实是首选。

有些人更喜欢使用 R 的图形用户界面。RStudio 是最广泛使用的 GUI 前端。

现在我们想要从 Java 中写入一些数据,并在 R 中进行处理(我们将在本章后续的食谱中一起使用 Java 和 R)。在食谱 5.9 中,我们讨论了 java.util.Random 类及其 nextDouble()nextGaussian() 方法。nextDouble() 和相关方法试图提供在 0 到 1.0 之间的平均分布,其中每个值被选择的概率相等。高斯或正态分布是从负无穷到正无穷的钟形曲线,大多数值聚集在零(0.0)附近。我们将使用 R 的直方图和图形函数来视觉化地检查它们的效果:

Random r = new Random();
for (int i = 0; i < 10_000; i++) {
    System.out.println("A normal random double is " + r.nextDouble());
    System.out.println("A gaussian random double is " + r.nextGaussian());

为了说明不同的分布,我使用 nextRandom()nextGaussian() 生成了 10,000 个数字。代码在 Random4.java 中(此处未显示),是前面示例代码和仅将数字打印到两个文件中的代码的组合。然后我使用 R 绘制了直方图;生成图形的 R 脚本在 javasrc 下的 src/main/resources 中,但其核心显示在示例 11-2 中。结果显示在图 11-1 中。

示例 11-2. 生成直方图的 R 命令
png("randomness.png")
us <- read.table("normal.txt")[[1]]
ns <- read.table("gaussian.txt")[[1]]

layout(t(c(1,2)), respect=TRUE)

hist(us, main = "Using nextRandom()", nclass = 10,
       xlab = NULL, col = "lightgray", las = 1, font.lab = 3)

hist(ns, main = "Using nextGaussian()", nclass = 16,
       xlab = NULL, col = "lightgray", las = 1, font.lab = 3)
dev.off()

png() 调用告诉 R 使用哪个图形设备。其他包括 X11()Postscript()read.table() 从文本文件中读取数据到表格中;[1] 给出了我们只需要的数据列,忽略了一些元数据。layout() 调用表示我们想要两个并排显示的图形对象。每个 hist() 调用绘制两个直方图中的一个。而 dev.off() 关闭输出并刷新任何写入缓冲区到 PNG 文件。结果显示在图 11-1 中。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/jcb4_1101.png

图 11-1. 平面(左)和高斯(右)分布

11.4 比较/选择 R 实现

问题

你不确定要使用哪个 R 的实现。

解决方案

查看原始的 R、Renjin 和 FastR。

讨论

R 的原始版本是 S,这是一个由约翰·钱伯斯等人于 1976 年在 AT&T 贝尔实验室开发的交互式编程环境。我在支持多伦多大学统计系时遇到了 S,还在为一个名为 Sun Expert 的很久以前的杂志审查了它的商业实现 SPlus。AT&T 只向大学和无法进一步分发源代码的商业许可证持有人提供 S 源代码。奥克兰大学的两位开发人员 Ross Ihaka 和 Robert Gentleman 从 1995 年开始开发了 S 的克隆,并将其命名为 R,以代表他们自己的首字母,同时也是对 S 名称的一种玩笑。(这方面有先例:在 Unix/Linux 上流行的 awk 语言是以其设计者 Aho、Weinberger 和 Kernighan 的首字母命名的)。R 发展迅速,因为它与 S 非常兼容,并且更容易获取。这个原始 R 的实现由R Foundation for Statistical Computing积极管理,该基金会还管理综合 R 存档网络

Renjin 是一个在 Java 中相当完整的 R 实现。该项目通过他们自己的 Maven 仓库提供构建的 JAR 文件。

FastR 是另一个 Java 实现,在更快的 GraalVM 中运行,并支持从几乎任何其他编程语言直接调用 JVM 代码。FastR 的技术负责人在这篇博文中描述了该实现。

除了这些实现,R 的流行还促使开发了许多访问库,用于从许多流行的编程语言调用 R。Rserve 是一个 TCP/IP 网络访问模式,为其存在 Java 封装。

11.5 在 Java 应用程序中使用 R:Renjin

问题

您希望通过 Renjin 从 Java 应用程序中访问 R。

解决方案

将 Renjin 添加到您的 Maven 或 Gradle 构建中,并通过 Recipe 18.3 中描述的脚本引擎机制进行调用。

讨论

Renjin 是一个纯 Java 实现的开源 R 重现,提供脚本引擎接口。将以下依赖项添加到您的构建工具中:

org.renjin:renjin-script-engine:3.5-beta76

当然,阅读本文时可能已经有更新版本的 Renjin;除非有特殊原因,应使用最新版本。

注意,您还需要一个 <repository> 条目,因为维护者将其构件放在 nexus.betadriven.com 而不是通常的 Maven Central。这是我使用的内容(从 https://www.renjin.org/downloads.html 获取):

<repositories>
    <repository>
        <id>bedatadriven</id>
        <name>bedatadriven public repo</name>
        <url>https://nexus.bedatadriven.com/content/groups/public/</url>
    </repository>
</repositories>

一旦完成这些步骤,您应该能够通过脚本引擎框架访问 Renjin,就像 示例 11-3 中描述的那样。

示例 11-3. main/src/main/java/otherlang/RenjinScripting.java
    /**
 * Demonstrate interacting with the "R" implementation called "Renjin"
 */
    public static void main(String[] args) throws ScriptException {
        ScriptEngineManager manager = new ScriptEngineManager();
        ScriptEngine engine = manager.getEngineByName("Renjin");
        engine.put("a", 42);
        Object ret = engine.eval("b <- 2; a*b");
        System.out.println(ret);
    }

因为 R 将所有数字视为浮点数,类似于许多解释器,打印的值是 84.0

还可以让 Renjin 调用一个脚本文件;示例 11-4 调用与 Recipe 11.3 中使用的相同脚本来生成和绘制一批伪随机数。

示例 11-4. 使用脚本文件的 Renjin
    private static final String R_SCRIPT_FILE = "/randomnesshistograms.r";
    private static final int N = 10000;

    public static void main(String[] argv) throws Exception {
        // java.util.Random methods are non-static, do need to construct
        Random r = new Random();
        double[] us = new double[N], ns = new double[N];
        for (int i=0; i<N; i++) {
            us[i] = r.nextDouble();
            ns[i] =r.nextGaussian();
        }
        try (InputStream is =
            Random5.class.getResourceAsStream(R_SCRIPT_FILE)) {
            if (is == null) {
                throw new IllegalStateException("Can't open R file ");
            }
            ScriptEngineManager manager = new ScriptEngineManager();
            ScriptEngine engine = manager.getEngineByName("Renjin");
            engine.put("us", us);
            engine.put("ns", ns);
            engine.eval(FileIO.readerToString(new InputStreamReader(is)));
        }
    }

如果您从 https://renjin.org/downloads.html 下载一个包含所有依赖项的 JAR 文件,Renjin 也可以作为一个独立的 R 实现来使用。

11.6 在 R 会话中使用 Java

问题

您正在使用 R 计算的过程中,意识到有一个 Java 库可以完成下一步操作。或者出于其他原因,需要在 R 会话中调用 Java 代码。

解决方案

安装 rJava,调用 .jinit(),然后使用 J() 加载类或调用方法。

讨论

这里是交互式 R 会话的一部分,在这部分中我们安装 rJava,通过调用 .jinit() 进行初始化,并调用 java.time.LocalDate.now() 获取当前日期:

> install.packages('rJava')                            <https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/1.png>
trying URL 'http://.../rJava_0.9-11.tgz' Content type 'application/x-gzip' length 745354 bytes (727 KB)
==================================================
downloaded 727 KB

The downloaded binary packages are in
    /tmp//Rtmp6XYZ9t/downloaded_packages > library('rJava')                                    <https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/2.png>
> .jinit()
> J('java.time.LocalDate', 'now')                    <https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/3.png>
[1] "Java-Object{2019-11-22}"
> d=J('java.time.LocalDate', 'now')$toString()        <https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/4.png>
> d
[1] "2019-11-22"

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/#co_data_science_and_r_CO2-1

安装 rJava 包;只需执行一次。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/#co_data_science_and_r_CO2-2

加载 rJava,并使用 .jinit() 初始化;每个 R 会话都需要这两步。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/#co_data_science_and_r_CO2-3

J 函数接受一个完整类名作为参数。如果只提供该参数,则返回一个类描述符(例如 java.lang.Class 对象)。如果提供多于一个参数,则第二个参数是静态方法名,后续的参数将传递给该方法。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/#co_data_science_and_r_CO2-4

返回的对象可以使用标准的 R $ 符号调用 Java 方法;这里调用 toString() 方法以返回字符串而不是 LocalDate 对象。

.jcall 函数使您可以更好地控制调用方法和返回类型:

> d=J('java.time.LocalDate', 'now')                    <https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/1.png>
> .jcall(d, "I", 'getYear')                            <https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/2.png>
[1] 2019
>
> .jcall("java/lang/System","S","getProperty","user.dir") <https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/3.png>
[1] "/home/ian"
> c=J('java/lang/System')                            <https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/4.png>
> .jcall(c, "S", 'getProperty', 'user.dir')
[1] "/home/ian"
>

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/#co_data_science_and_r_CO3-1

在 R 变量 d 中调用 Java LocalDate.now() 方法并保存结果。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/#co_data_science_and_r_CO3-2

LocalDate 对象上调用 Java getYear() 方法;“I”告诉 jcall 期望一个整数结果。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/#co_data_science_and_r_CO3-3

调用 System.getProperty("user.dir") 并打印结果;“S”告诉 .jcall 期望返回一个字符串。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/#co_data_science_and_r_CO3-4

如果您将要多次使用一个类,请保存 Class 对象,并将其作为 .jcall() 的第一个参数传递。

这里有更多的功能;请参考文档developer.com 的文章

11.7 使用 FastR,GraalVM 实现的 R

问题

您使用 R 语言,但感觉需要更快的速度。

解决方案

使用 FastR,Oracle 的 GraalVM 重新实现的 R 语言。

讨论

假设您已经按照第 1.2 节的描述安装了 GraalVM,您可以直接输入以下命令:

$ gu install R
Downloading: Component catalog from www.graalvm.org
Processing component archive: FastR
Downloading: Component R: FastR  from github.com
Installing new component: FastR (org.graalvm.R, version 19.2.0.1)
NOTES:
---------------
The user specific library directory was not created automatically.
You can either create the directory manually or edit file
/Library/Java/JavaVirtualMachines/graalvm-ce-19.2.0.1/Contents/
  Home/jre/languages/R/etc/Renviron
to change it to any desired location. Without user specific library
directory, users will need write permission for the GraalVM home
directory in order to install R packages.
...
[more install notes]

如果您已将 PATH 设置为在其他目录之前使用 GraalVM,那么命令 R 现在将给出 GraalVM 版本的 R。要访问标准的 R,您将需要设置您的 PATH 或者给出 R 安装的完整路径。在所有 Unix 和类 Unix 系统上,命令 which R 将显示您的 PATH 上的所有 R 命令:

$ which R
/Library/Java/JavaVirtualMachines/graalvm-ce-19.2.0.1/Contents/Home/bin/R
/usr/local/bin/R

让我们来运行它:

$ R
R version 3.5.1 (FastR)
Copyright (c) 2013-19, Oracle and/or its affiliates
Copyright (c) 1995-2018, The R Core Team
Copyright (c) 2018 The R Foundation for Statistical Computing
Copyright (c) 2012-4 Purdue University
Copyright (c) 1997-2002, Makoto Matsumoto and Takuji Nishimura
All rights reserved.

FastR is free software and comes with ABSOLUTELY NO WARRANTY.
You are welcome to redistribute it under certain conditions.
Type 'license()' or 'licence()' for distribution details.

R is a collaborative project with many contributors.
Type 'contributors()' for more information.

Type 'q()' to quit R.
[Previously saved workspace restored]

> 2 + 2
[1] 4
> ^D
Save workspace image? [y/n/c]: n
$

从那时起,您应该能够做几乎任何在标准 R 中做的事情,因为此 R 的源代码大部分来自 R 基金会的源代码。

11.8 在 Web 应用程序中使用 R

问题

您希望在 Web 服务器上的网页中显示 R 的数据和图形。

解决方案

有几种方法可以实现这个效果:

  • 准备数据,生成图形,就像我们在第 11.3 节的示例中所做的那样,然后将它们都整合到静态网页中。

  • 使用多种R 的附加 Web 框架,如 shinyRook

  • 在 Servlet、JSF、Spring Bean 或其他 Web 层组件中调用 R 的 JVM 实现。

讨论

第一种方法很简单,这里不需要讨论。

对于第二种方法,我实际上会使用 timevis,它反过来使用 shiny。这并未内置到 R 库中,因此我们首先需要使用 R 的 install.packages() 安装它:

$ R
> install.packages('timevis')
> quit()
$

这可能需要一些时间,因为它会下载并构建多个依赖项。

为了这个演示,我有一个包含一些关于中世纪文学基本信息的小数据集,我使用shiny加载和显示:

# Draw the timeline for the epics.

epics = read.table("epics.txt", header=TRUE, fill=TRUE)

# epics

library("timevis")

timevis(epics)

运行时,这会创建一个包含 HTML 和 JavaScript 的临时文件,以允许对数据进行交互式探索。该库还会在浏览器中打开此文件,显示在图 11-2 中。用户可以通过展开或收缩时间线并横向滚动来探索数据。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/jcb4_1102.png

图 11-2. TimeVis(shiny)的操作

当存在两个框(Cid,Sagas)时,第一个是生活或故事发生的时间,第二个是它们被书写的时间。

要将其暴露在公共网络上,请复制文件(完整路径显示在浏览器标题栏中)和lib文件夹放入同一目录,并将该目录服务于 Web 服务器。或者直接使用文件→另存为→完整网页,在浏览器中执行。无论哪种方式,您都必须在 R 会话运行时执行此操作,因为会话结束时会删除临时文件。或者,如果您熟悉shiny框架,可以将timevis可视化插入到shiny应用程序中。

¹ Map/Reduce是由 Google 开发的处理大数据问题的著名算法。未指定数量的生成器处理map数据,如网页上的单词或页面的 URL,单个(通常)reduce 进程将这些映射减少为可管理的形式,例如包含给定单词的所有页面的列表。早期,数据科学试图通过 Map/Reduce 做所有事情;现在,风向标已经回到使用像 Spark 这样的计算引擎。

² DataBricks 在其网站上提供了几本关于 Spark 的免费电子书;它还提供商业 Spark 附加组件。

第十二章:网络客户端

12.0 引言

Java 可用于编写多种类型的网络程序。在传统基于套接字的代码中,程序员负责构建客户端和服务器之间的交互;TCP 套接字代码 简单地确保您发送的任何数据都能到达另一端。在更高级别的类型(如 HTTP、RMI、CORBA 和 EJB)中,软件接管了更多的控制权。套接字通常用于连接传统服务器;如果您从头开始编写新应用程序,最好使用更高级别的服务。

将套接字与电话系统进行比较可能会有所帮助。电话最初用于模拟语音通信,这种通信结构相当不结构化。随后它开始用于一些分层应用程序;第一个广泛流行的分层应用程序是传真传输,即传真。如果没有广泛的语音电话服务,传真会处于何种地位呢?历史上第二个极为流行的分层应用程序是拨号 TCP/IP。这与 Web 共同存在,成为大众市场服务的流行方式。如果没有广泛部署的语音线路,拨号 IP 会如何呢?如果没有拨号 IP,互联网会处于何种地位呢?现在传真和拨号几乎都已经消失,但它们为您智能手机的联网功能铺平了道路,这正是使其有用(甚至作为时间的耗费者)的原因。

套接字也是分层的。Web、RMI、JDBC、CORBA 和 EJB 都是基于套接字的。HTTP 现在是最常用的协议,当您只想从点 b 获取数据到点 a 时,通常应该使用它。

自从 Java 在 1995 年 5 月发布初版(最初是 HotJava 浏览器的一个附带产品)以来,Java 作为一种用于构建网络应用程序的编程语言就变得非常流行。如果你曾经在 C 中构建过网络应用程序,你就会明白其中的原因。首先,C 程序员必须关注他们所在的平台。Unix 使用同步套接字,其读写操作类似于普通磁盘文件,而 Microsoft 操作系统使用异步套接字,其使用回调来通知读写操作何时完成。Java 则模糊了这种区别。此外,在 C 中设置套接字所需的代码量令人望而却步。只是出于乐趣,示例 12-1 展示了设置客户端套接字的典型 C 代码。请记住,这只是 Unix 的一部分。而且只是建立和关闭连接的部分。要在 Windows 上移植,还需要一些额外的条件代码(使用 C 的 #ifdef 机制)。C 的 #include 机制要求必须精确包含正确的文件,并且某些文件必须按特定顺序列出(Java 的 import 机制则更加灵活)。

示例 12-1. main/src/main/java/network/Connect.c(C 客户端设置)
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netdb.h>
#include <stdio.h>
#include <string.h>
#include <fcntl.h>

int
main(int argc, char *argv[])
{
    char* server_name = "localhost";
    struct hostent *host_info;
    int sock;
    struct sockaddr_in server;

    /* Look up the remote host's IP address */
    host_info = gethostbyname(server_name);
    if (host_info == NULL) {
        fprintf(stderr, "%s: unknown host: %s\n", argv[0], server_name);
        exit(1);
    }

    /* Create the socket */
    if ((sock = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
        perror("creating client socket");
        exit(2);
    }

    /* Set up the server's socket address */
    server.sin_family = AF_INET;
    memcpy((char *)&server.sin_addr, host_info->h_addr,
                     host_info->h_length);
    server.sin_port = htons(80);

    /* Connect to the server */
    if (connect(sock,(struct sockaddr *)&server,sizeof server) < 0) {
        perror("connecting to server");
        exit(4);
    }

    /* Finally, we can read and write on the socket. */
    /* ... */

    (void) close(sock);
}

在第一个示例中,我们将看到如何在 Java 中用基本上一行代码(加上一些错误处理)完成连接。然后,我们将讨论错误处理和通过套接字传输数据。接下来,我们将简要介绍一个实现了大部分已被用于 20 年来引导无盘工作站的datagram或 UDP 客户端的 TFTP(简单文件传输协议)。最后,我们将结束于一个连接到聊天服务器的交互式程序。

大多数这些客户端示例的共同主题是使用现有服务器,这样我们就不必同时生成客户端和服务器。这些大多数服务存在于任何标准 Unix 平台上。如果您找不到附近的 Unix 服务器来尝试它们,请允许我建议您拿一台旧 PC,也许是一台性能不足以运行最新 Microsoft 软件的 PC,并在其上安装一个免费的开源 Unix 系统。我个人最喜欢的是OpenBSD,市场上普遍喜欢的是 Linux。这两者都可以通过互联网免费安装,并提供所有在客户端示例中使用的标准服务,包括时间服务器和 TFTP。两者都有免费的 Java 实现可用。

我还提供了对 Web 服务客户端的基本覆盖。术语“Web 服务”现在已经意味着使用 HTTP 进行程序间通信。两个一般类别是基于 SOAP 和基于 REST。REST 服务非常简单 — 您发送一个 HTTP 请求,并获得纯文本或 JSON(第十四章)或 XML 格式的响应。SOAP 更为复杂,本书不涵盖。在 Elliotte Harold(O’Reilly)的Java 网络编程中有关客户端连接的更多信息。我不涵盖构建 Web 服务的服务器端 API — JAX-RS 和 JAX-WS,因为这些内容在几本 O’Reilly 书籍中有详细介绍。

12.1 HTTP/REST Web Client

问题

您需要从 URL 读取数据,例如连接到 RESTful Web 服务或通过 HTTP/HTTPS 下载网页或其他资源。

解决方案

使用标准 Java 11 的HttpClientURLConnection类。

这种技术适用于任何需要从 URL 读取数据的情况,不仅限于 RESTful Web 服务。

讨论

在 Java 11 之前,您必须使用URLConnection类或下载并使用旧版 Apache HTTP Client 库。使用 Java 11 后,标准 Java 中有一个相当易于使用和灵活的 API。它还支持 HTTP/2.0;而 Apache HttpClient截至 2020 年初尚不支持 HTTP/2.0,而传统的URLConnection也不太可能支持 HTTP/2.0。

以我们的简单示例为例,我们将使用 Google 的建议服务,即当您在 Google 网络搜索引擎中输入搜索的前几个字符时所看到的内容。

这项 Google 服务支持各种输出格式。基本 URL 如下:

https://suggestqueries.google.com/complete/search?client=firefox&q=

将您希望获得建议的单词附加到它。client=firefox告诉它我们需要一个简单的 JSON 格式;使用client=chrome它包含更多字段。

要使用 Java HTTP 客户端 API,您需要一个HttpClient对象,使用构建器模式获取,然后创建一个Request对象:

        // This object would be kept for the life of an application
        HttpClient client = HttpClient.newBuilder()
            .followRedirects(Redirect.NORMAL)
            .version(Version.HTTP_1_1)
            .build();

        // Build the HttpRequest object to "GET" the urlString
        HttpRequest req =
            HttpRequest.newBuilder(URI.create(urlString +
                URLEncoder.encode(keyword)))
            .header("User-Agent", "Dept of Silly Walks")
            .GET()
            .build();

HttpRequest对象可以使用客户端发送,以获取HttpResponse对象,从中您可以获取状态和/或正文。发送可以同步进行(如果您需要立即获得结果)或异步进行(如果在此期间可以有用地执行其他操作)。此示例显示了同时以同步和异步方式发送:

        // Send the request - synchronously
        HttpResponse<String> resp =
            client.send(req, BodyHandlers.ofString());

        // Collect the results
        if (resp.statusCode() == 200) {
            String response = resp.body();
            System.out.println(response);
        } else {
            System.out.printf("ERROR: Status %d on request %s\n",
                resp.statusCode(), urlString);
        }
        // Send the request - asynchronously
        client.sendAsync(req, BodyHandlers.ofString())
            .thenApply(HttpResponse::body)
            .thenAccept(System.out::println)
            .join();

这是输出;该行已在逗号处换行以适应页面:

$ java HttpClientDemo.java
["darwin",["darwin thompson","darwin","darwin awards","darwinism",
 "darwin australia","darwin thompson fantasy","darwin barney",
 "darwin theory","darwinai","darwin dormitorio"]]

如果您不想使用HttpClient库,则可以使用java.net中的旧代码,因为我们在这里通常只需要打开并从 URL 读取的能力。这是使用URLConnection的代码:

public class RestClientURLDemo {
    public static void main(String[] args) throws Exception {
        URLConnection conn = new URL(
            HttpClientDemo.urlString + HttpClientDemo.keyword)
            .openConnection();
        try (BufferedReader is =
            new BufferedReader(new InputStreamReader(conn.getInputStream()))) {

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

输出应该与HttpClient版本产生的完全一致。

参见

不要将此HttpClient较旧的 Apache HttpClient 库混淆。

您可以在 Bill Burke 的RESTful Java with JAX-RS 2.0, 2nd Edition(O’Reilly)中找到更多关于 REST 服务(包括为其实现服务器端组件)的信息。

12.2 联系套接字服务器

问题

您需要使用 TCP/IP 联系服务器。

解决方案

只需创建一个java.net.Socket,将主机名和端口号传递给构造函数。

讨论

在 Java 中并不复杂。创建套接字时,传递主机名和端口号。java.net.Socket构造函数执行gethostbyname()socket()系统调用,设置服务器的sockaddr_in结构,并执行connect()调用。您只需捕获错误,这些错误是从熟悉的IOException继承的子类。示例 12-2 设置了 Java 网络客户端,但实际上尚未执行任何 I/O 操作。它使用 try-with-resources 确保当我们完成时套接字会自动关闭。

示例 12-2. main/src/main/java/network/ConnectSimple.java(简单客户端连接)
import java.net.Socket;

/* Client with NO error handling */
public class ConnectSimple {

    public static void main(String[] argv) throws Exception {

        try (Socket sock = new Socket("localhost", 8080)) {

            /* If we get here, we can read and write on the socket "sock" */
            System.out.println(" *** Connected OK ***");

            /* Do some I/O here... */

        }
    }
}

此版本不进行实际错误报告,但名为ConnectFriendly的版本进行了;我们将在 Recipe 12.4 中看到此版本。

参见

Java 支持其他使用网络应用程序的方式。您还可以打开 URL 并从中读取(请参阅 Recipe 12.8)。您可以编写代码,以便在 Web 浏览器中打开时从 URL 运行,或者从应用程序中运行。

12.3 查找和报告网络地址

问题

您希望查找主机的地址名称或编号,或获取网络连接的另一端的地址。

解决方案

获取一个InetAddress对象。

讨论

InetAddress对象表示给定计算机或主机的互联网地址。它没有公共构造函数;您通过调用静态的getByName()方法获取InetAddress,传递主机名如darwinsys.com或网络地址作为字符串,如 1.23.45.67。该类中的所有“查找”方法都可以抛出已检查的UnknownHostExceptionjava.io.IOException的子类),必须在调用方法的头部捕获或声明。这些方法实际上不联系远程主机,因此它们不会抛出与网络连接相关的其他异常。

方法getHostAddress()给出与InetAddress对应的数值 IP 地址(作为字符串)。其反向是getHostName(),它报告InetAddress的名称。这可用于根据名称打印主机的地址,或反之亦然:

public class InetAddrDemo {
    public static void main(String[] args) throws IOException {
        String hostName = "darwinsys.com";
        String ipNumber = "8.8.8.8"; // currently a well-known Google DNS server

        // Show getting the InetAddress (looking up a host) by host name
        System.out.println(hostName + "'s address is " +
            InetAddress.getByName(hostName).getHostAddress());

        // Look up a host by address
        System.out.println(ipNumber + "'s name is " +
            InetAddress.getByName(ipNumber).getHostName());

        // Look up my localhost addresss
        final InetAddress localHost = InetAddress.getLocalHost();
        System.out.println("My localhost address is " + localHost);

        // Show getting the InetAddress from an open Socket
        String someServerName = "google.com";
        // assuming there's a web server on the named server:
        try (Socket theSocket = new Socket(someServerName, 80)) {
            InetAddress remote = theSocket.getInetAddress();
            System.out.printf("The InetAddress for %s is %s%n",
                someServerName, remote);
        }
    }
}

你还可以通过调用其getInetAddress()方法从Socket中获取InetAddress。你可以使用InetAddress而不是主机名字符串构造Socket。因此,要连接到与现有套接字上相同主机上的端口号myPortNumber,可以使用以下代码:

InetAddress remote = theSocket.getInetAddress( );
Socket anotherSocket = new Socket(remote, myPortNumber);

最后,要查找与主机关联的所有地址(服务器可能在多个网络上),请使用静态方法getAllByName(host),它返回一个InetAddress对象数组,每个 IP 地址关联一个给定名称。

静态方法getLocalHost()返回等同于localhost或 127.0.0.1 的InetAddress。这可用于连接到作为客户端正在运行的同一计算机上运行的服务器程序。

如果使用 IPv6,可以使用Inet6Address

另请参阅

参见第 13.2 节中的NetworkInterface,它允许您更多地了解正在运行的计算机的网络。目前标准 API 中没有查找服务的方法,也就是说,无法查找 HTTP 服务位于 80 端口的方法。TCP/IP 的完整实现始终包括一组额外的解析器;在 C 中,调用getservbyname("http", "tcp");将查找给定服务¹,并返回一个servent(服务条目)结构,其s_port成员将包含值 80。已建立服务的编号不会更改,但是当服务是新的或以非例行方式安装时,通过更改服务定义可以方便地更改机器或网络上所有程序的服务号码(无论编程语言如何)。Java 应在未来的发布版本中提供此功能。

12.4 处理网络错误

问题

如果出现问题,您需要比仅有IOException更详细的报告。

解决方案

捕获更多种类的异常类。SocketException有几个子类;最显著的是ConnectExceptionNoRouteToHostException。名称是不言自明的:第一个意味着连接被另一端的机器(服务器机器)拒绝,第二个完全解释了失败。示例 12-3 是Connect程序的摘录,增强了处理这些条件。

示例 12-3. ConnectFriendly.java
public class ConnectFriendly {
    public static void main(String[] argv) {
        String server_name = argv.length == 1 ? argv[0] : "localhost";
        int tcp_port = 80;
        try (Socket sock = new Socket(server_name, tcp_port)) {

            /* If we get here, we can read and write on the socket. */
            System.out.println(" *** Connected to " + server_name  + " ***");

            /* Do some I/O here... */

        } catch (UnknownHostException e) {
            System.err.println(server_name + " Unknown host");
            return;
        } catch (NoRouteToHostException e) {
            System.err.println(server_name + " Unreachable" );
            return;
        } catch (ConnectException e) {
            System.err.println(server_name + " connect refused");
            return;
        } catch (java.io.IOException e) {
            System.err.println(server_name + ' ' + e.getMessage());
            return;
        }
    }
}

12.5 读取和写入文本数据

问题

已连接,您希望传输文本数据。

解决方案

从套接字的getInputStream()getOutputStream()构造一个BufferedReaderPrintWriter

讨论

Socket类有允许您获取用于从套接字读取或写入的InputStreamOutputStream的方法。它没有获取ReaderWriter的方法,部分原因是一些网络服务仅限于 ASCII,但主要原因是在有ReaderWriter类之前就决定了Socket类。您可以始终使用转换类从InputStream创建Reader或从OutputStream创建Writer。这是两种最常见形式的范例:

BufferedReader is = new BufferedReader(
    new InputStreamReader(sock.getInputStream( )));
PrintWriter os = new PrintWriter(sock.getOutputStream( ), true);

示例 12-4 从白天服务读取一行文本,这种服务由全功能的 TCP/IP 套件(例如大多数 Unix 系统中包含的套件)提供。您不必向Daytime服务器发送任何内容;您只需连接并读取一行。服务器写入包含日期和时间的一行,然后关闭连接。

运行它看起来像以下代码。我首先在本地主机上获取当前日期和时间,然后运行DaytimeText程序以查看服务器(机器darian是我的 Unix 服务器之一)上的日期和时间:

C:\javasrc\network>date 
Current date is Sun 01-23-2000
Enter new date (mm-dd-yy):
C:\javasrc\network>time
Current time is  1:13:18.70p
Enter new time:
C:\javasrc\network>java network.DaytimeText darian
Time on darian is Sun Jan 23 13:14:34 2000

代码位于DaytimeText类中,显示在示例 12-4 中。

示例 12-4. DaytimeText.java
public class DaytimeText {
    public static final short TIME_PORT = 13;

    public static void main(String[] argv) {
        String server_name = argv.length == 1 ? argv[0] : "localhost";

        try (Socket sock = new Socket(server_name,TIME_PORT);
            BufferedReader is = new BufferedReader(new
                InputStreamReader(sock.getInputStream()));) {
            String remoteTime = is.readLine();
            System.out.println("Time on " + server_name + " is " + remoteTime);
        } catch (IOException e) {
            System.err.println(e);
        }
    }
}

第二个示例,显示在示例 12-5 中,显示了在同一个套接字上的读取和写入。Echo服务器简单地回显您发送的任何文本行。它不是一个非常聪明的服务器,但它是一个有用的服务器。它有助于网络测试,也有助于测试这类客户端!

converse()方法与名为主机上的Echo服务器进行简短对话;如果没有指定主机,则尝试联系localhost,这是程序正在运行的机器的通用别名²。

示例 12-5. main/src/main/java/network/EchoClientOneLine.java
public class EchoClientOneLine {
    /** What we send across the net */
    String mesg = "Hello across the net";

    public static void main(String[] argv) {
        if (argv.length == 0)
            new EchoClientOneLine().converse("localhost");
        else
            new EchoClientOneLine().converse(argv[0]);
    }

    /** Hold one conversation across the net */
    protected void converse(String hostName) {
        try (Socket sock = new Socket(hostName, 7);) { // echo server.
            BufferedReader is = new BufferedReader(new
                InputStreamReader(sock.getInputStream()));
            PrintWriter os = new PrintWriter(sock.getOutputStream(), true);
            // Do the CRLF ourself since println appends only a \r on
            // platforms where that is the native line ending.
            os.print(mesg + "\r\n"); os.flush();
            String reply = is.readLine();
            System.out.println("Sent \"" + mesg  + "\"");
            System.out.println("Got  \"" + reply + "\"");
        } catch (IOException e) {
            System.err.println(e);
        }
    }
}

将读取和写入代码从此方法中隔离出来,可能是一个好的练习,可以将其封装到一个NetWriter类中,可能是PrintWriter的子类,并添加\r\n和刷新操作。

12.6 读取和写入二进制或序列化数据

问题

已连接,您希望传输二进制数据,无论是原始二进制数据还是序列化的 Java 对象。

解决方案

对于普通的二进制日期,从套接字的getInputStream()getOutputStream()构造DataInputStreamDataOutputStream。对于序列化的 Java 对象数据,构造ObjectInputStreamObjectOutputStream

讨论

在套接字上读取/写入的最简单范式是:

DataInputStream is = new DataInputStream(sock.getInputStream());
DataOutputStream is = new DataOutputStream(sock.getOutputStream( ));

如果数据量可能很大,插入缓冲流以提高效率。这种范式是:

DataInputStream is = new DataInputStream(
    new BufferedInputStream(sock.getInputStream( )));
DataOutputStream is = new DataOutputStream(
    new BufferedOutputStream(sock.getOutputStream( )));

示例中的程序示例 12-6 使用另一个标准服务,以二进制整数表示自 1900 年以来的秒数,因为 Java Date类基于 1970 年,我们通过减去 1970 年和 1900 年之间的差异来转换时间基准。当我在课程中使用这个练习时,大多数学生希望添加这个时间差,理由是 1970 年更晚。但是如果你思考清楚,你会发现 1999 年和 1970 年之间的秒数比 1999 年和 1900 年之间的秒数少,所以减法给出了正确的秒数。并且因为Date构造函数需要毫秒,我们将秒数乘以 1000。

时间差是年数乘以 365,加上两个日期之间的闰年天数(在 1904 年、1908 年等年份中)——19 天。

我们从服务器读取的整数是 C 语言的unsigned int。但是 Java 不提供无符号整数类型;通常在需要无符号数字时,您使用下一个更大的整数类型,即long。但 Java 还没有提供从数据流中读取无符号整数的方法。DataInputStreamreadInt()方法读取 Java 风格的有符号整数。有readUnsignedByte()方法和readUnsignedShort()方法,但没有readUnsignedInt()方法。因此,我们通过读取无符号字节并使用 Java 的位移操作符重新组装它们,合成读取无符号int的能力(必须将其存储在long中,否则会丢失符号位并回到起点):

在代码的结尾,我们使用新的日期/时间 API(见第六章)构造并打印一个LocalDateTime对象,以显示本地(客户端)机器上的当前日期和时间:

$ date
Thu Dec 26 09:48:36 EST 2019
java network.RDateClient aragorn
Remote time is 3786360519
BASE_DIFF is 2208988800
Time diff == 1577371719
Time on aragorn is 2019-12-26T09:48:39
Local date/time = 2019-12-26T09:48:41.208180
$

名称aragorn是我 OpenBSD Unix 计算机之一的主机名。从输出中可以看出,服务器在一秒左右内达成一致。这证实了示例 12-6 中的日期计算代码。这种协议通常称为rdate,因此客户端代码称为RDateClient

示例 12-6. main/src/main/java/network/RDateClient.java
public class RDateClient {
    /** The TCP port for the binary time service. */
    public static final short TIME_PORT = 37;
    /** Seconds between 1970, the time base for dates and times
 * Factors in leap years (up to 2100), hours, minutes, and seconds.
 * Subtract 1 day for 1900, add in 1/2 day for 1969/1970.
 */
    protected static final long BASE_DAYS =
        (long)((1970-1900)*365 + (1970-1900-1)/4);

    /* Seconds since 1970 */
    public static final long BASE_DIFF = (BASE_DAYS * 24 * 60 * 60);

    public static void main(String[] argv) {
        String hostName;
        if (argv.length == 0)
            hostName = "localhost";
        else
            hostName = argv[0];

        try (Socket sock = new Socket(hostName,TIME_PORT);) {
            DataInputStream is = new DataInputStream(new
                BufferedInputStream(sock.getInputStream()));
            // Read 4 bytes from the network, unsigned.
            // Do it yourself; there is no readUnsignedInt().
            // Long is 8 bytes on Java, but we are using the
            // existing time protocol, which uses 4-byte ints.
            long remoteTime = (
                ((long)(is.readUnsignedByte()) << 24) |
                ((long)(is.readUnsignedByte()) << 16) |
                ((long)(is.readUnsignedByte()) <<  8) |
                ((long)(is.readUnsignedByte()) <<  0));
            System.out.println("Remote time is " + remoteTime);
            System.out.println("BASE_DIFF is " + BASE_DIFF);
            System.out.println("Time diff == " + (remoteTime - BASE_DIFF));
            Instant time = Instant.ofEpochSecond(remoteTime - BASE_DIFF);
            LocalDateTime d = LocalDateTime.ofInstant(time, ZoneId.systemDefault());
            System.out.println("Time on " + hostName + " is " + d.toString());
            System.out.println("Local date/time = " + LocalDateTime.now());
        } catch (IOException e) {
            System.err.println(e);
        }
    }
}

对象序列化是将内存中的对象转换为可以逐字节发送的外部形式的能力。要通过序列化读取或写入 Java 对象,只需从InputStreamOutputStream构造ObjectInputStreamObjectOutputStream;在这种情况下,使用套接字的getInputStream()getOutputStream()

这个程序(及其服务器)提供的服务并不是 TCP/IP 协议栈的标准部分;这是我制作的一个演示服务。此服务的服务器在 Recipe 13.3 中介绍。Example 12-7 中的客户端代码与前一篇中的DaytimeBinary程序非常相似,但服务器发送给我们一个已构造好的LocalDateTime对象。Example 12-7 展示了与 Example 12-6 不同的客户端代码部分。

示例 12-7. main/src/main/java/network/DaytimeObject.java
        try (Socket sock = new Socket(hostName, TIME_PORT);) {
            ObjectInputStream is = new ObjectInputStream(new
                BufferedInputStream(sock.getInputStream()));

            // Read and validate the Object
            Object o = is.readObject();
            if (o == null) {
                System.err.println("Read null from server!");
            } else if ((o instanceof LocalDateTime)) {

                // Valid, so cast to LocalDateTime, and print
                LocalDateTime d = (LocalDateTime) o;
                System.out.println("Time on " + hostName + " is " + d);
            } else {
                throw new IllegalArgumentException(
                    String.format("Wanted LocalDateTime, got %s, a %s",
                        o, o.getClass()));
            }

我向操作系统询问日期和时间,然后运行程序,在远程机器上打印日期和时间:

$ date
Thu Dec 26 09:29:02 EST 2019
C:\javasrc\network>java network.DaytimeObject aragorn
Time on aragorn is 2019-12-26T09:29:05.227397
C:\javasrc\network>

再次结果在几秒钟内达成一致。

12.7 UDP 数据报

问题

你需要使用数据报连接(UDP)而不是流连接(TCP)。

解决方案

使用DatagramSocketDatagramPacket

讨论

数据报网络流量与底层基于数据包的以太网和 IP(Internet Protocol)层是一脉相承的。与 TCP 等基于流的连接不同,UDP 等数据报传输像发送单个数据包或数据块一样,作为一个单独的实体传输,与其他任何内容都没有必要的关系。³一个常见的比喻是,TCP 就像打电话,而 UDP 就像发送明信片或传真。

差异主要体现在错误处理上。数据包就像明信片一样,可能会丢失。你上次看到邮递员敲门告诉你邮局丢失了几张要送到你手中的明信片是什么时候?这种情况不会发生,因为邮局不会追踪明信片。另一方面,当你在电话上通话时,如果出现噪声爆发——比如有人在房间里大喊大叫,或者是连接不良——你会实时注意到故障,并可以要求对方重复刚才说的话。

对于像 TCP 套接字这样的基于流的连接,网络传输层会帮你处理错误:它会要求对方重新传输。但是对于 UDP 等数据报传输,你必须自己处理重传。这有点像编号你发送的明信片,这样你可以回头重新发送那些未到达的明信片——也许这是返回度假地的好借口。

另一个区别在于数据报传输保留了消息边界。也就是说,如果你使用 TCP 写入了 20 字节,然后写入了 10 字节,那么从另一端读取的程序将不知道你是写了 30 字节的一个块,还是两个 15 字节的块,甚至是 30 个单独的字符。使用DatagramSocket时,你为每个缓冲区构造一个DatagramPacket对象,其内容作为一个单独的实体通过网络发送;它的内容不会与任何其他缓冲区的内容混合在一起。DatagramPacket对象具有诸如getLength()setPort()的方法。

那么为什么我们会使用 UDP 呢? UDP 的开销比 TCP 少得多,在可靠的局域网或互联网上跳数较少时特别有价值。在长距离网络上,TCP 可能更受欢迎,因为 TCP 会为您处理丢失数据包的重传。显然,如果保留记录边界能够让您的生活更轻松,这可能是考虑使用 UDP 的原因。UDP 还是执行多播(同时向许多接收者广播)的方式,尽管多播超出了本讨论的范围。

示例 12-8 是一个简短的程序,通过 UDP 连接到配方 12.5 中使用的Daytime日期和时间服务器。因为 UDP 没有真正的连接概念,客户端通常会启动对话,有时意味着发送一个空包;UDP 服务器使用从中获取的地址信息来返回其响应。

示例 12-8. main/src/main/java/network/DaytimeUDP.java
public class DaytimeUDP {
    /** The UDP port number */
    public final static int DAYTIME_PORT = 13;

    /** A buffer plenty big enough for the date string */
    protected final static int PACKET_SIZE = 100;

    /** The main program that drives this network client.
 * @param argv[0] hostname, running daytime/udp server
 */
    public static void main(String[] argv) throws IOException {
        if (argv.length < 1) {
            System.err.println("usage: java DayTimeUDP host");
            System.exit(1);
        }
        String host = argv[0];
        InetAddress servAddr = InetAddress.getByName(host);
        DatagramSocket sock = new DatagramSocket();
        //sock.connect(servAddr, DAYTIME_PORT);
        byte[] buffer = new byte[PACKET_SIZE];

        // The udp packet we will send and receive
        DatagramPacket packet = new DatagramPacket(
            buffer, PACKET_SIZE, servAddr, DAYTIME_PORT);

        /* Send empty max-length (-1 for null byte) packet to server */
        packet.setLength(PACKET_SIZE-1);
        sock.send(packet);
        System.out.println("Sent request");

        // Receive a packet and print it.
        sock.receive(packet);
        System.out.println("Got packet of size " + packet.getLength());
        System.out.print("Date on " + host + " is " +
            new String(buffer, 0, packet.getLength()));

        sock.close();
    }
}

我会运行它到我的 Unix 框中,只是为了确保它工作:

$
$ java network.DaytimeUDP aragorn
Sent request
Got packet of size 26
Date on aragorn is Sat Feb  8 20:22:12 2014
$

12.8 URI、URL 或 URN?

问题

在听到这些术语之后,您想知道 URI、URL 和 URN 之间的区别。

解决方案

继续阅读。或查看java.net.uri的 javadoc。

讨论

URL 是传统的网络地址名称,由协议(如 HTTP)、地址(站点名称)和资源或路径名组成。但总共有三个不同的术语:

  • URI(统一资源标识符)

  • URL(统一资源定位符)

  • URN(统一资源名称)

Java 文档末尾的讨论解释了 URI、URL 和 URN 之间的关系。URI 形成了所有标识符的集合。URL 和 URN 是子集。

URI 是最通用的;URI 在不考虑其指定的方案(如果有)的情况下对基本语法进行解析,不需要引用特定的服务器。URL 包括主机名、方案和其他组件;该字符串根据其方案的规则进行解析。构造 URL 时,会自动创建一个InputStream。URN 命名资源但不说明如何定位它们;您可能看到的 URN 的典型示例包括mailto:news:引用。

URI类提供的主要操作是规范化(移除多余的路径段,包括“…”)和相对化(这应该称为“使相对化”,但某人希望用一个单词来作为方法名)。URI对象没有用于打开 URI 的任何方法;为此,通常会使用 URI 的字符串表示形式构造 URL 对象,如下所示:

URL x = new URL(theURI.toString( ));

示例 12-9 中的程序展示了从 URI 规范化、相对化以及构造 URL 的示例。

示例 12-9. main/src/main/java/network/URIDemo.java
public class URIDemo {
    public static void main(String[] args)
    throws URISyntaxException, MalformedURLException {

        URI u = new URI("https://darwinsys.com/java/../openbsd/../index.jsp");
        System.out.println("Raw: " + u);
        URI normalized = u.normalize();
        System.out.println("Normalized: " + normalized);
        final URI BASE = new URI("https://darwinsys.com");
        System.out.println("Relativized to " + BASE + ": " + BASE.relativize(u));

        // A URL is a type of URI
        URL url = new URL(normalized.toString());
        System.out.println("URL: " + url);

        // Demo of non-URL but valid URI
        URI uri = new URI("bean:WonderBean");
        System.out.println(uri);
    }
}

12.9 程序:TFTP UDP 客户端

这个程序实现了 TFTP 应用协议的客户端部分,这是一种曾经非常著名的服务,在 Unix 世界中用于工作站的网络引导,早在 Windows 3.1 之前就已经存在,现在主要用于计算机的网络引导。我选择这个协议是因为它在服务器端广泛实现,所以很容易找到用于测试的服务器。

TFTP 协议有些奇怪。客户端在众所周知的 UDP 端口号 69 上与服务器联系,使用生成的端口号,⁴,服务器从生成的端口号响应客户端。进一步的通信使用这两个生成的端口号。

更详细地讲,如图 12-1 所示,客户端首先发送包含文件名的读取请求,并读取第一个数据包。读取请求由两个字节(一个short)组成,带有读取请求代码(短整数,值为 1,定义为OP_RRQ),两个字节用于序列号,然后是 ASCII 文件名,以空字符结尾,和模式字符串,同样以空字符结尾。服务器从客户端读取读取请求,验证是否可以打开文件,并且如果可以,发送第一个数据包(OP_DATA),然后再次读取。客户端从其端口读取,并且如果读取正常,将数据包转换为确认包,并发送。这种读取-确认循环重复进行,直到读取所有数据。请注意,除了最后一个包,每个包都是 516 字节(512 字节的数据,加上 2 字节的包类型和另外 2 字节的包编号),最后一个包可以是任何长度,从 4(零字节数据)到 515(511 字节数据)。如果发生网络 I/O 错误,则重新发送该包。如果某个包偏离了轨道,客户端和服务器都应执行超时循环。这个客户端没有这样做,但服务器有。您可以使用线程(参见 Recipe 16.4)或通过在套接字上调用setSoTimeout()来添加超时,如果数据包丢失,则捕获SocketTimeoutException,重新传输确认(或读取请求),最多尝试某个最大次数。这留给读者作为练习。客户端代码的当前版本显示在示例 12-10 中。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/jcb4_1201.png

图 12-1. TFTP 协议包格式
示例 12-10. main/src/main/java/network/RemCat.java
public class RemCat {
    /** The UDP port number */
    public final static int TFTP_PORT = 69;
    /** The mode we will use - octet for everything. */
    protected final String MODE = "octet";

    /** The offset for the code/response as a byte */
    protected final int OFFSET_REQUEST = 1;
    /** The offset for the packet number as a byte */
    protected final int OFFSET_PACKETNUM = 3;

    /** Debugging flag */
    protected static boolean debug = false;

    /** TFTP op-code for a read request */
    public final int OP_RRQ = 1;
    /** TFTP op-code for a read request */
    public final int OP_WRQ = 2;
    /** TFTP op-code for a read request */
    public final int OP_DATA = 3;
    /** TFTP op-code for a read request */
    public final int OP_ACK    = 4;
    /** TFTP op-code for a read request */
    public final int OP_ERROR = 5;

    protected final static int PACKET_SIZE = 516;    // == 2 + 2 + 512
    protected String host;
    protected InetAddress servAddr;
    protected DatagramSocket sock;
    protected byte buffer[];
    protected DatagramPacket inp, outp;

    /** The main program that drives this network client.
 * @param argv[0] hostname, running TFTP server
 * @param argv[1..n] filename(s), must be at least one
 */
    public static void main(String[] argv) throws IOException {
        if (argv.length < 2) {
            System.err.println("usage: rcat host filename[...]");
            System.exit(1);
        }
        if (debug)
            System.err.println("Java RemCat starting");
        RemCat rc = new RemCat(argv[0]);
        for (int i = 1; i<argv.length; i++) {
            if (debug)
                System.err.println("-- Starting file " +
                    argv[0] + ":" + argv[i] + "---");
            rc.readFile(argv[i]);
        }
    }

    RemCat(String host) throws IOException {
        super();
        this.host = host;
        servAddr = InetAddress.getByName(host);
        sock = new DatagramSocket();
        buffer = new byte[PACKET_SIZE];
        outp = new DatagramPacket(buffer, PACKET_SIZE, servAddr, TFTP_PORT);
        inp = new DatagramPacket(buffer, PACKET_SIZE);
    }

    /* Build a TFTP Read Request packet. This is messy because the
 * fields have variable length. Numbers must be in
 * network order, too; fortunately Java just seems
 * naturally smart enough :-) to use network byte order.
 */
    void readFile(String path) throws IOException {
        buffer[0] = 0;
        buffer[OFFSET_REQUEST] = OP_RRQ;        // read request
        int p = 2;            // number of chars into buffer

        // Convert filename String to bytes in buffer , using "p" as an
        // offset indicator to get all the bits of this request
        // in exactly the right spot.
        byte[] bTemp = path.getBytes();    // i.e., ASCII
        System.arraycopy(bTemp, 0, buffer, p, path.length());
        p += path.length();
        buffer[p++] = 0;        // null byte terminates string

        // Similarly, convert MODE ("stream" or "octet") to bytes in buffer
        bTemp = MODE.getBytes();    // i.e., ASCII
        System.arraycopy(bTemp, 0, buffer, p, MODE.length());
        p += MODE.length();
        buffer[p++] = 0;        // null terminate

        /* Send Read Request to tftp server */
        outp.setLength(p);
        sock.send(outp);

        /* Loop reading data packets from the server until a short
 * packet arrives; this indicates the end of the file.
 */
        do {
            sock.receive(inp);
            if (debug)
                System.err.println(
                    "Packet # " + Byte.toString(buffer[OFFSET_PACKETNUM])+
                    "RESPONSE CODE " + Byte.toString(buffer[OFFSET_REQUEST]));
            if (buffer[OFFSET_REQUEST] == OP_ERROR) {
                System.err.println("rcat ERROR: " +
                    new String(buffer, 4, inp.getLength()-4));
                return;
            }
            if (debug)
                System.err.println("Got packet of size " +
                    inp.getLength());

            /* Print the data from the packet */
            System.out.write(buffer, 4, inp.getLength()-4);

            /* Ack the packet. The block number we
 * want to ack is already in buffer so
 * we just change the opcode. The ACK is
 * sent to the port number which the server
 * just sent the data from, NOT to port
 * TFTP_PORT.
 */
            buffer[OFFSET_REQUEST] = OP_ACK;
            outp.setLength(4);
            outp.setPort(inp.getPort());
            sock.send(outp);
        } while (inp.getLength() == PACKET_SIZE);

        if (debug)
            System.err.println("** ALL DONE** Leaving loop, last size " +
                inp.getLength());
    }
}

要测试这个客户端,你需要一个 TFTP 服务器。如果你在管理的 Unix 系统上,你可以通过编辑文件*/etc/inetd.conf并重新启动或重新加载inetd服务器(Linux 使用不同的机制,这可能因你所用的发行版而异)。inetd是一个程序,它监听各种连接,并在客户端连接时只启动服务器(一种惰性评估的方式)。⁵ 我设置了传统的/tftpboot目录,将这行放入我的inetd.conf*中,并重新加载了inetd

tftp dgram udp wait root /usr/libexec/tftpd tftpd -s /tftpboot

然后我放了几个测试文件,其中一个命名为foo,放入*/tftpboot*目录中。运行

$ java network.RemCat localhost foo

产生看起来像文件的输出。但为了安全起见,我使用 Unix 的diff比较程序测试了RemCat的输出和原始文件。没有消息就是好消息:

$ java network.RemCat localhost foo | diff - /tftpboot/foo

到目前为止一切都很好。让我们不要在一个毫无防备的网络上运行这个程序,至少要简单地运行一下错误处理:

$ java network.RemCat localhost nosuchfile 
remcat ERROR: File not found
$

12.10 程序:基于套接字的聊天客户端

这个程序是一个简单的聊天程序。你不能用它打断 ICQ 或 AIM,因为它们各自使用自己的协议。⁶ 相反,这个程序只是向服务器写入和读取。这个服务器将在第十三章中介绍。运行时的效果如何?图 12-2 展示了我某天独自聊天的情况。

代码相当自解释。我们在一个线程中从远程服务器读取以使输入和输出不相互阻塞;这在第十六章讨论过。本章讨论了读取和写入。该程序在示例 12-11 中显示。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/jcb4_1202.png

图 12-2. 聊天客户端运行中
示例 12-11. 主要/src/main/java/chat/ChatClient.java
public class ChatClient extends JFrame {

    private static final long serialVersionUID = -3686334002367908392L;
    private static final String userName =
        System.getProperty("user.name", "User With No Name");
    /** The state of logged-in-ness */
    protected boolean loggedIn;
    /* The main Frame. */
    protected JFrame cp;
    /** The default port number */
    protected static final int PORTNUM = ChatProtocol.PORTNUM;
    /** The actual port number */
    protected int port;
    /** The network socket */
    protected Socket sock;
    /** PrintWriter for sending lines on socket */
    protected PrintWriter pw;
    /** TextField for input */
    protected JTextField tf;
    /** TextArea to display conversations */
    protected JTextArea ta;
    /** The Login Button */
    protected JButton loginButton;
    /** The LogOUT button */
    protected JButton logoutButton;
    /** The TitleBar title */
    final static String TITLE = "ChatClient: Ian Darwin's Chat Room Client";

    final Executor threadPool = Executors.newSingleThreadExecutor();

    /** set up the GUI */
    public ChatClient() {
        cp = this;
        cp.setTitle(TITLE);
        cp.setLayout(new BorderLayout());
        port = PORTNUM;

        // The GUI
        ta = new JTextArea(14, 80);
        ta.setEditable(false);        // readonly
        ta.setFont(new Font("Monospaced", Font.PLAIN, 11));
        cp.add(BorderLayout.NORTH, ta);

        JPanel p = new JPanel();

        // The login button
        p.add(loginButton = new JButton("Login"));
        loginButton.setEnabled(true);
        loginButton.requestFocus();
        loginButton.addActionListener(e -> {
                login();
                loginButton.setEnabled(false);
                logoutButton.setEnabled(true);
                tf.requestFocus();    // set keyboard focus in right place!
        });

        // The logout button
        p.add(logoutButton = new JButton("Logout"));
        logoutButton.setEnabled(false);
        logoutButton.addActionListener(e -> {
                logout();
                loginButton.setEnabled(true);
                logoutButton.setEnabled(false);
                loginButton.requestFocus();
        });

        p.add(new JLabel("Message here:"));
        tf = new JTextField(40);
        tf.addActionListener(e -> {
                if (loggedIn) {
                    pw.println(ChatProtocol.CMD_BCAST+tf.getText());
                    tf.setText("");
                }
        });
        p.add(tf);

        cp.add(BorderLayout.SOUTH, p);

        cp.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        cp.pack();
    }

    protected String serverHost = "localhost";

    /** LOG ME IN TO THE CHAT */
    public void login() {
        /** BufferedReader for reading from socket */
        BufferedReader is;

        showStatus("In login!");
        if (loggedIn)
            return;
        try {
            sock = new Socket(serverHost, port);
            is = new BufferedReader(new InputStreamReader(sock.getInputStream()));
            pw = new PrintWriter(sock.getOutputStream(), true);
            showStatus("Got socket");

            // FAKE LOGIN FOR NOW - no password needed
            pw.println(ChatProtocol.CMD_LOGIN + userName);

            loggedIn = true;

        } catch(IOException e) {
            warn("Can't get socket to " +
                serverHost + "/" + port + ": " + e);
            cp.add(new JLabel("Can't get socket: " + e));
            return;
        }

        // Construct and start the reader: from server to textarea.
        // Make a Thread to avoid lockups.
        Runnable readerThread = new Runnable() {
            public void run() {
                String line;
                try {
                    while (loggedIn && ((line = is.readLine()) != null))
                        ta.append(line + "\n");
                } catch(IOException e) {
                    showStatus("Lost another client!\n" + e);
                    return;
                }
            }
        };
        threadPool.execute(readerThread);
    }

    /** Log me out, Scotty, there's no intelligent life here! */
    public void logout() {
        if (!loggedIn)
            return;
        loggedIn = false;
        try {
            if (sock != null)
                sock.close();
        } catch (IOException ign) {
            // so what?
        }
    }

    public void showStatus(String message) {
        System.out.println(message);
    }

    private void warn(String message) {
        JOptionPane.showMessageDialog(this, message);
    }

    /** A main method to allow the client to be run as an Application */
    public static void main(String[] args) {
        ChatClient room101 = new ChatClient();
        room101.pack();
        room101.setVisible(true);
    }
}

另请参阅

有许多更好结构化的方法来编写聊天客户端,包括 WebSockets、RMI 和 JMS。RMI 是 Java 的 RPC 接口,包含在 Java SE 和 Java EE 中;这本书的这一版中没有描述它,但你可以在我的网站找到先前版本的 RMI 章节。其他技术是 Java 企业的一部分,因此我再次推荐阅读 Arun Gupta 的*Java EE 7 Essentials*。

如果你的通信经过公共互联网,你确实需要加密你的套接字连接,所以请查看 Sun 的 JSSE(Java 安全套接字扩展)。如果你听从我的建议并使用标准的 HTTP 协议,你可以通过将 URL 更改为 https 来加密会话。

对于从 C 程序员的角度对网络编程有良好概述,请参阅已故的 W·理查德·史蒂文斯(W. Richard Stevens)的《Unix 网络编程》(Prentice Hall)。尽管书名为 Unix 网络编程,但实际上是关于套接字、TCP/IP 和 UDP 编程,并详细介绍了所有(Unix)网络 API 及协议,如 TFTP。

12.11 程序:简单 HTTP 链接检查器

检查链接是网站所有者及撰写链接到外部来源技术文档(例如,您正在阅读的书的作者)的人员面临的持续问题。链接检查工具是他们验证页面链接的必备工具,无论是网页还是书页。实现链接检查器基本上是(a)提取链接和(b)打开它们的事情。因此,我们有了示例 12-12 中的程序。我称之为KwikLinkChecker,因为它有点快速且粗糙——它不验证链接内容是否仍然包含原始内容;因此,例如,如果一个开源项目忘记更新其域名注册,而其域名被一个色情网站接管,那么KwikLinkChecker将永远不会知道。但话虽如此,它完成了它的工作,而且相当快速。

示例 12-12. darwinsys-api/src/main/java/com/darwinsys/tools/KwikLinkChecker.java
    /**
 * Check one HTTP link; not recursive. Returns a LinkStatus with
 * boolean success, and the filename or an error message in the
 * message part of the LinkStatus.  The end of this method is one of
 * the few places where a whole raft of different "catch" clauses is
 * actually needed for the intent of the program.
 * @param urlString the link to check
 * @return the link's status
 */
    @SuppressWarnings("exports")
    public LinkStatus check(String urlString) {
        try {
            HttpResponse<String> resp = client.send(
                HttpRequest.newBuilder(URI.create(urlString))
                .header("User-Agent", getClass().getName())
                .GET()
                .build(),
                BodyHandlers.ofString());

            // Collect the results
            if (resp.statusCode() == 200) {
                System.out.println(resp.body());
            } else {
                System.out.printf("ERROR: Status %d on request %s\n",
                    resp.statusCode(), urlString);
            }

            switch (resp.statusCode()) {
            case 200:
                return new LinkStatus(true, urlString);
            case 403:
                return new LinkStatus(false,"403: " + urlString );
            case 404:
                return new LinkStatus(false,"404: " + urlString );
            }
            return new LinkStatus(true, urlString);
        } catch (IllegalArgumentException | MalformedURLException e) {
            // JDK throws IAE if host can't be determined from URL string
            return new LinkStatus(false, "Malformed URL: " + urlString);
        } catch (UnknownHostException e) {
            return new LinkStatus(false, "Host invalid/dead: " + urlString);
        } catch (FileNotFoundException e) {
            return new LinkStatus(false,"NOT FOUND (404) " + urlString);
        } catch (ConnectException e) {
            return new LinkStatus(false, "Server not listening: " + urlString);
        } catch (SocketException e) {
            return new LinkStatus(false, e + ": " + urlString);
        } catch (IOException e) {
            return new LinkStatus(false, e.toString()); // includes failing URL
        } catch (Exception e) {
            return new LinkStatus(false, urlString + ": " + e);
        }
    }

当然还有更复杂的链接检查工具可用,但这个对我来说就够用了。

¹ 它被查找的位置各不相同。在 Unix 上,它可能位于名为*/etc/services的文件中;在 Windows 的\或 _\winnt子目录的services*文件中;或在像 Sun 的网络信息服务(NIS,曾称为 YP)这样的集中注册表中;或在某些其他平台或网络相关位置。

² 在大多数网络系统由全职系统人员管理并接受培训或见习时,这曾是普遍的。今天,互联网上许多机器并未正确配置localhost

³ 某些网络可能需要对 UDP 数据包进行分段,但这对我们在 UDP 层级上不重要,因为它将在另一端将网络数据包重新组装为我们的单实体 UDP 数据包。

⁴ 当应用程序不关心时,这些端口号通常由操作系统生成。例如,当您从公用电话或手机拨打公司电话时,公司通常不在乎您从哪个号码打过来,如果在乎的话,也有办法找出来。生成的端口号通常范围从 1024(第一个非特权端口;参见 Chapter 13)到 65535(在 16 位端口号中可以表示的最大值)。

⁵ 警惕安全漏洞;不要在互联网上放任一个 TFTP 服务器,而不先读一本好的安全书,比如构建互联网防火墙,作者为 D. Chapman 等人(O’Reilly)。

⁶ 如果你想要一个开源程序,提供 IM 服务,让你从同一个程序中进行通话,请查看 Jabber,网址为http://www.jabber.org

第十三章:Java 服务器端

13.0 引言

套接字构成几乎所有网络协议的基础。JDBC、RMI、CORBA、EJB,以及非 Java 的 RPC(远程过程调用)和 NFS(网络文件系统),所有这些都通过连接各种类型的套接字来实现。套接字连接可以在几乎任何语言中实现,不仅限于 Java:C、C++、Perl 和 Python 也很流行,还有许多其他可能性。任何一种语言编写的客户端或服务器都可以与用其他任何一种语言编写的对方通信。因此,即使最终使用了诸如 RMI、JDBC、CORBA 或 EJB 等高级服务,快速了解 ServerSocket 的行为也是值得的。

讨论首先关注 ServerSocket 本身,然后介绍了多种方式在套接字上写入数据。最后,我展示了一个完整的可用网络服务器的实现示例,这是前一章中客户端的聊天服务器的实现。

提示

大多数服务器端 Java 生产工作使用 Java Enterprise Edition(Java EE),最近从 Oracle 转移到 Eclipse Software Foundation 并更名为 Jakarta,但广泛使用其先前的名称(偶尔也使用其非常古老的名称“J2EE”,该名称已于 2005 年停用)。Java EE 提供可扩展性和支持构建良构化的多层分布式应用程序。EE 提供 Servlet 框架;Servlet 是可以安装到任何标准 Java EE Web 服务器中的策略对象。EE 还提供两种 Web 视图技术:原始的 JSP(JavaServer Pages)和较新的基于组件的 JSF(JavaServer Faces)。最后,EE 还提供许多其他基于网络的服务,包括 EJB3 远程访问和 Java Messaging Service(JMS)。这些内容超出了本书的范围;它们在其他书籍中有所涵盖,例如 Arun Gupta 的 Java EE 7 Essentials: Enterprise Developer Handbook。本章仅适用于那些需要或希望从头开始构建自己服务器的人。

13.1 开启一个用于业务的服务器套接字

问题

需要编写基于套接字的服务器。

解决方案

为给定的端口号创建 ServerSocket

讨论

ServerSocket 表示连接的另一端,即等待客户端连接的服务器。你只需用端口号构造一个 ServerSocket。¹ 由于它不需要连接到另一台主机,所以不像客户端套接字构造函数那样需要特定主机的地址。

假设ServerSocket构造函数不会抛出异常,您就可以开始工作了。您的下一步是等待客户端活动,这可以通过调用accept()来实现。此调用将阻塞,直到客户端连接到您的服务器;此时,accept()将向您返回一个Socket对象(而不是ServerSocket),该对象在客户端的ServerSocket对象(或其等价对象,如果用另一种语言编写)中双向连接。示例 13-1 展示了基于套接字的服务器的代码。

示例 13-1. main/src/main/java/network/Listen.java
public class Listen {
    /** The TCP port for the service. */
    public static final short PORT = 9999;

    public static void main(String[] argv) throws IOException {
        ServerSocket sock;
        Socket  clientSock;
        try {
            sock = new ServerSocket(PORT);
            while ((clientSock = sock.accept()) != null) {

                // Process it, usually on a separate thread
                // to avoid blocking the accept() call.
                process(clientSock);
            }

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

    /** This would do something with one client. */
    static void process(Socket s) throws IOException {
        System.out.println("Accept from client " + s.getInetAddress());
        // The conversation would be here.
        s.close();
    }
}

通常,您会在读取和写入时使用相同的套接字,如下几个示例所示。

您可能只想侦听特定的网络接口。尽管我们倾向于将网络地址视为计算机地址,但两者并不相同。网络地址实际上是给定计算设备上的特定网络卡或网络接口连接的地址。台式计算机、笔记本电脑、平板电脑或手机可能只有一个接口,因此只有一个网络地址。但是大型服务器可能有两个或更多接口,通常当它连接到多个网络时。网络路由器是一个盒子,可以是专用用途的(例如 Cisco 路由器),也可以是通用用途的(例如 Unix 主机),它在多个网络上都有接口,并且具有转发数据包的能力和管理权限。在这样的服务器上运行的程序可能希望仅向其内部网络或外部网络提供服务。通过指定要侦听的网络接口,可以实现这一目标。假设您希望为内部网提供与外部客户不同的网页视图。出于安全原因,您可能不会在同一台机器上运行这两种服务。但是如果您希望这样做,可以通过将网络接口地址作为参数提供给ServerSocket构造函数来实现。

然而,要使用构造函数的这种形式,您不能像客户端套接字那样使用字符串作为网络地址的名称;您必须将其转换为InetAddress对象。您还必须提供一个 backlog 参数,这是在客户端被告知您的服务器太忙之前可以排队等待接受的连接数。完整的设置如示例 13-2 所示。

示例 13-2. main/src/main/java/network/ListenInside.java
public class ListenInside {
    /** The TCP port for the service. */
    public static final short PORT = 9999;
    /** The name of the network interface. */
    public static final String INSIDE_HOST = "acmewidgets-inside";
    /** The number of clients allowed to queue */
    public static final int BACKLOG = 10;

    public static void main(String[] argv) throws IOException {
        ServerSocket sock;
        Socket  clientSock;
        try {
            sock = new ServerSocket(PORT, BACKLOG,
                InetAddress.getByName(INSIDE_HOST));
            while ((clientSock = sock.accept()) != null) {

                // Process it.
                process(clientSock);
            }

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

    /** Hold server's conversation with one client. */
    static void process(Socket s) throws IOException {
        System.out.println("Connected from  " + INSIDE_HOST +
            ": " + s.getInetAddress(  ));
        // The conversation would be here.
        s.close();
    }
}

InetAddress.getByName()以系统相关的方式查找给定主机名,在*/etc\windows*目录下的配置文件中,或者通过诸如域名系统这样的解析器来引用。如果需要修改此数据,请参考有关网络和系统管理的好书。

13.2 查找网络接口

问题

您希望了解计算机的网络安排。

解决方案

使用NetworkInterface类。

讨论

网络中的每台计算机都有一个或多个“网络接口”。在典型的台式机上,网络接口代表网络卡或网络端口,或者某些软件网络接口,如环回接口。每个接口都有一个操作系统定义的名称。在大多数 Unix 版本中,这些设备有一个两个或三个字符的设备驱动程序名称加上一个数字(从 0 开始),例如,eth0en0表示第一台以太网设备,系统隐藏了卡片制造商的细节;或者de0de1表示第一和第二个基于 Digital Equipment 的 DC21x4x 以太网卡,xl0表示 3Com EtherLink XL,等等。环回接口在所有类 Unix 平台上几乎都是lo0

那又怎样?大多数情况下这对你来说无关紧要。如果你只有一个网络连接,比如与 ISP 的电缆连接,你真的不在乎。这在服务器上很重要,例如你可能需要找到特定网络的地址。NetworkInterface类允许你找到。它具有用于列出接口的静态方法和用于查找与给定接口关联的地址的其他方法。示例 Example 13-3 中的程序展示了使用此类的一些示例。运行它会打印所有本地接口的名称。如果你恰好在名为laptop的计算机上,它会打印机器的网络地址;如果不是,你可能想要从命令行接受本地计算机的名称;这留给读者作为练习。

示例 13-3. main/src/main/java/network/NetworkInterfaceDemo.java
public class NetworkInterfaceDemo {
    public static void main(String[] a) throws IOException {
        Enumeration<NetworkInterface> list =
            NetworkInterface.getNetworkInterfaces();
        while (list.hasMoreElements()) {
            // Get one NetworkInterface
            NetworkInterface iface = list.nextElement();
            // Print its name
            System.out.println(iface.getDisplayName());
            Enumeration<InetAddress> addrs = iface.getInetAddresses();
            // And its address(es)
            while (addrs.hasMoreElements()) {
                InetAddress addr = addrs.nextElement();
                System.out.println(addr);
            }

        }
        // Try to get the Interface for a given local (this machine's) address
        InetAddress destAddr = InetAddress.getByName("laptop");
        try {
            NetworkInterface dest = NetworkInterface.getByInetAddress(destAddr);
            System.out.println("Address for " + destAddr + " is " + dest);
        } catch (SocketException ex) {
            System.err.println("Couldn't get address for " + destAddr);
        }
    }
}

13.3 返回响应(字符串或二进制)

问题

你需要向客户端写入字符串或二进制数据。

解决方案

套接字提供了一个InputStream和一个OutputStream。使用它们。

讨论

上一章中的客户端套接字示例调用了getInputStream()getOutputStream()方法。这些示例也一样。主要区别在于,这些示例从ServerSocketaccept()方法中获取套接字。另一个区别是,按照定义,通常是服务器创建或修改数据并将其发送到客户端。Example 13-4 是一个简单的Echo服务器,Recipe 12.5 中的Echo客户端可以连接到它。此服务器处理一个完整的客户端连接,然后返回并等待下一个客户端的accept()

示例 13-4. main/src/main/java/network/EchoServer.java
public class EchoServer {
    /** Our server-side rendezvous socket */
    protected ServerSocket sock;
    /** The port number to use by default */
    public final static int ECHOPORT = 7;
    /** Flag to control debugging */
    protected boolean debug = true;

    /** main: construct and run */
    public static void main(String[] args) {
        int p = ECHOPORT;
        if (args.length == 1) {
            try {
                p = Integer.parseInt(args[0]);
            } catch (NumberFormatException e) {
                System.err.println("Usage: EchoServer [port#]");
                System.exit(1);
            }
        }
        new EchoServer(p).handle();
    }

    /** Construct an EchoServer on the given port number */
    public EchoServer(int port) {
        try {
            sock = new ServerSocket(port);
        } catch (IOException e) {
            System.err.println("I/O error in setup");
            System.err.println(e);
            System.exit(1);
        }
    }

    /** This handles the connections */
    protected void handle() {
        Socket ios = null;
        while (true) {
            try {
                System.out.println("Waiting for client...");
                ios = sock.accept();
                System.err.println("Accepted from " +
                    ios.getInetAddress().getHostName());
                try (BufferedReader is = new BufferedReader(
                            new InputStreamReader(ios.getInputStream(), "8859_1"));
                        PrintWriter os = new PrintWriter(
                            new OutputStreamWriter(ios.getOutputStream(), "8859_1"),
                            true);) {
                    String echoLine;
                    while ((echoLine = is.readLine()) != null) {
                        System.err.println("Read " + echoLine);
                        os.print(echoLine + "\r\n");
                        os.flush();
                        System.err.println("Wrote " + echoLine);
                    }
                    System.err.println("All done!");
                }
            } catch (IOException e) {
                System.err.println(e);
            }
        }
        /* NOTREACHED */
    }
}

为了在任意网络连接上发送字符串,一些权威建议同时发送回车和换行字符;许多协议规范要求如此做。这就解释了代码中的\r\n。如果另一端是 DOS 程序或类似 Telnet 的程序,可能期望同时接收这两个字符。另一方面,如果你同时编写两端,可以简单地使用println()——在读取之前始终紧接着显式地使用flush(),以防止出现一端的数据仍在PrintWriter缓冲区中导致死锁的情况!

如果需要处理二进制数据,请使用java.io中的数据流而不是读取器/写入器。我需要一个服务器用于食谱 12.6 的DaytimeBinary程序。在操作中,它应该如下所示:

C:\javasrc\network>java network.DaytimeBinary
Remote time is 3161316799
BASE_DIFF is 2208988800
Time diff == 952284799
Time on localhost is Sun Mar 08 19:33:19 GMT 2014

C:\javasrc\network>time/t
Current time is  7:33:23.84p

C:\javasrc\network>date/t
Current date is Sun 03-08-2014

C:\javasrc\network>

嗯,我的武器库中正好有这样一个程序,所以我在示例 13-5 中呈现它。请注意,它直接使用了客户端类中定义的某些公共常量。通常这些常量在服务器类中定义并由客户端使用,但我想先呈现客户端代码。

示例 13-5. main/src/main/java/network/DaytimeServer.java
public class DaytimeServer {
    /** Our server-side rendezvous socket */
    ServerSocket sock;
    /** The port number to use by default */
    public final static int PORT = 37;

    /** main: construct and run */
    public static void main(String[] argv) {
        new DaytimeServer(PORT).runService();
    }

    /** Construct a DaytimeServer on the given port number */
    public DaytimeServer(int port) {
        try {
            sock = new ServerSocket(port);
        } catch (IOException e) {
            System.err.println("I/O error in setup\n" + e);
            System.exit(1);
        }
    }

    /** This handles the connections */
    protected void runService() {
        Socket ios = null;
        DataOutputStream os = null;
        while (true) {
            try {
                System.out.println("Waiting for connection on port " + PORT);
                ios = sock.accept();
                System.err.println("Accepted from " +
                    ios.getInetAddress().getHostName());
                os = new DataOutputStream(ios.getOutputStream());
                long time = System.currentTimeMillis();

                time /= 1000;    // Daytime Protocol is in seconds

                // Convert to Java time base.
                time += RDateClient.BASE_DIFF;

                // Write it, truncating cast to int since it is using
                // the Internet Daytime protocol which uses 4 bytes.
                // This will fail in the year 2038, along with all
                // 32-bit timekeeping systems based from 1970.
                // Remember, you read about the Y2038 crisis here first!
                os.writeInt((int)time);
                os.close();
            } catch (IOException e) {
                System.err.println(e);
            }
        }
    }
}

13.4 在网络连接中返回对象信息

问题

你需要通过网络连接返回一个对象。

解决方案

创建所需的对象,并使用套接字输出流顶部的ObjectOutputStream将其写入。

讨论

前一章节中示例 12-7 的程序读取一个Date对象,使用ObjectInputStream。示例 13-6,DaytimeObjectServer(该过程的另一端),是一个每次连接时构造一个Date对象并返回给客户端的程序。

示例 13-6. main/src/main/java/network/DaytimeObjectServer.java
public class DaytimeObjectServer {
    /** The TCP port for the object time service. */
    public static final short TIME_PORT = 1951;

    public static void main(String[] argv) {
        ServerSocket sock;
        Socket  clientSock;
        try {
            sock = new ServerSocket(TIME_PORT);
            while ((clientSock = sock.accept()) != null) {
                System.out.println("Accept from " +
                    clientSock.getInetAddress());
                ObjectOutputStream os = new ObjectOutputStream(
                    clientSock.getOutputStream());

                // Construct and write the Object
                os.writeObject(LocalDateTime.now());

                os.close();
            }

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

13.5 处理多个客户端

问题

你的服务器需要处理多个客户端。

解决方案

对每个使用一个线程。

讨论

在 C 语言世界中,有几种机制允许服务器处理多个客户端。其中一种是使用特殊的系统调用select()poll(),它通知服务器哪些文件/套接字描述符准备好读取、准备好写入或有错误。通过在这个列表中包括它的约会套接字(相当于我们的ServerSocket),基于 C 的服务器可以按任何顺序从多个客户端读取。Java 不提供这个调用,因为它在某些 Java 平台上不容易实现。相反,Java 使用通用的Thread机制,如第十六章所述(线程现在在许多编程语言中很常见,尽管不总是以这个名称)。每当代码从ServerSocket接受新连接时,它立即构造并启动一个新的线程对象来处理该客户端。³

实现在套接字上接受的 Java 代码非常简单,除了必须捕获 IOException 外:

/** Run the main loop of the Server. */
void runServer( ) {
    while (true) {
        try {
            Socket clntSock = sock.accept( );
            new Handler(clntSock).start( );
        } catch(IOException e) {
            System.err.println(e);
        }
    }
}

要使用线程,你必须要么继承 Thread 类,要么实现 Runnable 接口。为了使这段代码按照原样运行,Handler 类必须是 Thread 的子类;如果 Handler 实现了 Runnable 接口,那么代码将会把 Runnable 的实例传递给 Thread 的构造函数,就像这样:

Thread t = new Thread(new Handler(clntSock));
t.start( );

但按照原样,Handler 是使用 accept() 返回的普通套接字构造的,并且通常调用套接字的 getInputStream()getOutputStream() 方法,以正常方式进行通信。我将展示一个完整的实现,一个多线程回显客户端。首先,一个显示其使用情况的会话:

$ java network.EchoServerThreaded
EchoServerThreaded ready for connections.
Socket starting: Socket[addr=localhost/127.0.0.1,port=2117,localport=7]
Socket starting: Socket[addr=darian/192.168.1.50,port=13386,localport=7]
Socket starting: Socket[addr=darian/192.168.1.50,port=22162,localport=7]
Socket ENDED: Socket[addr=darian/192.168.1.50,port=22162,localport=7]
Socket ENDED: Socket[addr=darian/192.168.1.50,port=13386,localport=7]
Socket ENDED: Socket[addr=localhost/127.0.0.1,port=2117,localport=7]

在这里,我使用我的 EchoClient 程序连接了一次服务器,并且在仍然连接的情况下,使用操作系统提供的 Telnet 客户端多次调用它。服务器同时与所有客户端通信,将第一个客户端的答复发送回给第一个客户端,将第二个客户端的数据发送回给第二个客户端。简而言之,它有效果。我在程序中使用文件结束符号结束了会话,并使用 Telnet 客户端的正常断开机制。示例 13-7 是服务器的代码。

示例 13-7. 主代码/src/main/java/network/EchoServerThreaded.java
public class EchoServerThreaded {

    public static final int ECHOPORT = 7;

    public static void main(String[] av) {
        new EchoServerThreaded().runServer();
    }

    public void runServer() {
        ServerSocket sock;
        Socket clientSocket;

        try {
            sock = new ServerSocket(ECHOPORT);

            System.out.println("EchoServerThreaded ready for connections.");

            /* Wait for a connection */
            while (true) {
                clientSocket = sock.accept();
                /* Create a thread to do the communication, and start it */
                new Handler(clientSocket).start();
            }
        } catch (IOException e) {
            /* Crash the server if IO fails. Something bad has happened */
            System.err.println("Could not accept " + e);
            System.exit(1);
        }
    }

    /** A Thread subclass to handle one client conversation. */
    class Handler extends Thread {
        Socket sock;

        Handler(Socket s) {
            sock = s;
        }

        public void run() {
            System.out.println("Socket starting: " + sock);
            try (BufferedReader is = new BufferedReader(
                        new InputStreamReader(sock.getInputStream()));
                    PrintStream os = new PrintStream(
                        sock.getOutputStream(), true);) {
                String line;
                while ((line = is.readLine()) != null) {
                    os.print(line + "\r\n");
                    os.flush();
                }
                sock.close();
            } catch (IOException e) {
                System.out.println("IO Error on socket " + e);
                return;
            }
            System.out.println("Socket ENDED: " + sock);
        }
    }
}

大量的短交易可能会降低性能,因为每个客户端都会导致创建一个新的线程对象。如果你知道或者可以可靠地预测所需的并发度,另一种范例涉及预先创建固定数量的线程。但是你如何控制它们对ServerSocket的访问呢?查看ServerSocket类文档会发现accept()方法没有同步,这意味着任何数量的线程可以同时调用该方法。这可能会导致糟糕的事情发生。因此,我在此调用周围使用synchronized关键字来确保一次只有一个客户端在其中运行,因为它更新全局数据。当没有客户端连接时,你将会有一个(随机选择的)线程在ServerSocket对象的accept()方法中运行,等待连接,加上n-1个线程等待第一个线程从方法返回。一旦第一个线程成功接受连接,它就会离开并进行对话,释放其锁,以便另一个随机选择的线程被允许进入accept()方法。每个线程的run()方法都有一个从accept()开始的无限循环,然后进行对话。结果是客户端连接可以更快地启动,但稍微增加了服务器启动时间。这样做还可以避免每次请求到来时构造一个新的HandlerThread对象的开销。这种一般方法类似于流行的 Apache Web 服务器所做的,尽管它通常会创建一组相同的进程(而不是线程)来处理客户端连接。因此,我已经修改了示例 13-7 中显示的EchoServerThreaded类,使其以这种方式工作,你可以在示例 13-8 中看到。

示例 13-8. main/src/main/java/network/EchoServerThreaded2.java
public class EchoServerThreaded2 {

    public static final int ECHOPORT = 7;

    public static final int NUM_THREADS = 4;

    /** Main method, to start the servers. */
    public static void main(String[] av) {
        new EchoServerThreaded2(ECHOPORT, NUM_THREADS);
    }

    /** Constructor */
    public EchoServerThreaded2(int port, int numThreads) {
        ServerSocket servSock;

        try {
            servSock = new ServerSocket(port);

        } catch (IOException e) {
            /* Crash the server if IO fails. Something bad has happened */
            throw new RuntimeException("Could not create ServerSocket ", e);
        }

        // Create a series of threads and start them.
        for (int i = 0; i < numThreads; i++) {
            new Handler(servSock, i).start();
        }
    }

    /** A Thread subclass to handle one client conversation. */
    class Handler extends Thread {
        ServerSocket servSock;
        int threadNumber;

        /** Construct a Handler. */
        Handler(ServerSocket s, int i) {
            servSock = s;
            threadNumber = i;
            setName("Thread " + threadNumber);
        }

        public void run() {
            /*
 * Wait for a connection. Synchronized on the ServerSocket while
 * calling its accept() method.
 */
            while (true) {
                try {
                    System.out.println(getName() + " waiting");

                    Socket clientSocket;
                    // Wait here for the next connection.
                    synchronized (servSock) {
                        clientSocket = servSock.accept();
                    }
                    System.out.println(
                        getName() + " starting, IP=" +
                        clientSocket.getInetAddress());
                    try (BufferedReader is = new BufferedReader(
                            new InputStreamReader(clientSocket.getInputStream()));
                            PrintStream os = new PrintStream(
                                clientSocket.getOutputStream(), true);) {
                        String line;
                        while ((line = is.readLine()) != null) {
                            os.print(line + "\r\n");
                            os.flush();
                        }
                        System.out.println(getName() + " ENDED ");
                        clientSocket.close();
                    }
                } catch (IOException ex) {
                    System.out.println(getName() + ": IO Error on socket " + ex);
                    return;
                }
            }
        }
    }
}

用 NIO 实现这种服务器是完全可能的,这是“新的”(在 J2SE 1.4 时)I/O 包。然而,要做到这一点的代码超过了本章的任何内容,并且充满了问题。有几篇关于如何利用 NIO 管理服务器连接获得性能提升的好教程可以在互联网上找到。

13.6 提供 HTTP 协议

问题

你想要提供像 HTTP 这样的协议。

解决方案

创建一个ServerSocket,并编写一些能够使用特定协议的代码。或者更好的是,使用一个 Java 驱动的 Web 服务器,比如 Apache Tomcat 或 Java 企业版(Java EE)服务器,比如 JBoss WildFly。

讨论

你可以为非常简单的应用程序实现自己的 HTTP 协议服务器,我们将在这里做到这一点。对于任何严肃的开发,你都想要使用 Java 企业版;请参阅本章开头的说明。

这个例子只是构造了一个ServerSocket并侦听它。当连接进来时,它们会使用 HTTP 协议进行回复。因此,它比简单的Echo服务器更复杂,后者在 Recipe 13.3 中有所介绍。然而,这不是一个完整的 Web 服务器;请求中的文件名被忽略,并且总是返回标准消息。因此,这是一个非常简单的 Web 服务器;它只遵循发送响应所需的 HTTP 协议的最低要求。要获取用 Java 编写的真正的 Web 服务器,请从Apache Tomcat 网站或任何 Jakarta/JavaEE 应用服务器中获取 Tomcat。然而,Example 13-9 中显示的代码足以理解如何构建一个使用协议响应请求的简单服务器。

示例 13-9. main/src/main/java/network/WebServer0.java
public class WebServer0 {
    public static final int HTTP = 80;
    public static final String CRLF = "\r\n";
    ServerSocket s;
    /** A link to the source of this program, used in error message */
    static final String VIEW_SOURCE_URL =
    "https://github.com/IanDarwin/javasrc/tree/master/main/src/main/
 java/network";

    /**
 * Main method, just creates a server and call its runServer().
 */
    public static void main(String[] args) throws Exception {
        System.out.println("DarwinSys JavaWeb Server 0.0 starting...");
        WebServer0 w = new WebServer0();
        int port = HTTP;
        if (args.length == 1) {
            port = Integer.parseInt(args[0]);
            }
        w.runServer(port);        // never returns!!
    }

    /** Get the actual ServerSocket; deferred until after Constructor
 * so subclass can mess with ServerSocketFactory (e.g., to do SSL).
 * @param port The port number to listen on
 */
    protected ServerSocket getServerSocket(int port) throws Exception {
        return new ServerSocket(port);
    }

    /** RunServer accepts connections and passes each one to handler. */
    public void runServer(int port) throws Exception {
        s = getServerSocket(port);
        while (true) {
            try {
                Socket us = s.accept();
                Handler(us);
            } catch(IOException e) {
                System.err.println(e);
                return;
            }

        }
    }

    /** Handler() handles one conversation with a Web client.
 * This is the only part of the program that "knows" HTTP.
 */
    public void Handler(Socket s) {
        BufferedReader is;    // inputStream, from Viewer
        PrintWriter os;        // outputStream, to Viewer
        String request;        // what Viewer sends us.
        try {
            String from = s.getInetAddress().toString();
            System.out.println("Accepted connection from " + from);
            is = new BufferedReader(new InputStreamReader(s.getInputStream()));
            request = is.readLine();
            System.out.println("Request: " + request);

            os = new PrintWriter(s.getOutputStream(), true);
            os.print("HTTP/1.0 200 Here is your data" + CRLF);
            os.print("Content-type: text/html" + CRLF);
            os.print("Server-name: DarwinSys NULL Java WebServer 0" + CRLF);
            String reply1 = "<html><head>" +
                "<title>Wrong System Reached</title></head>\n" +
                "<h1>Welcome, ";
            String reply2 = ", but...</h1>\n" +
                "<p>You have reached a desktop machine " +
                "that does not run a real Web service.\n" +
                "<p>Please pick another system!</p>\n" +
                "<p>Or view <a href=\"" + VIEW_SOURCE_URL + "\">" +
                "the WebServer0 source on github</a>.</p>\n" +
                "<hr/><em>Java-based WebServer0</em><hr/>\n" +
                "</html>\n";
            os.print("Content-length: " +
                (reply1.length() + from.length() + reply2.length()) + CRLF);
            os.print(CRLF);
            os.print(reply1 + from + reply2 + CRLF);
            os.flush();
            s.close();
        } catch (IOException e) {
            System.out.println("IOException " + e);
        }
        return;
    }
}

13.7 使用 SSL 和 JSSE 保护 Web 服务器

问题

当数据在传输过程中,你希望保护网络流量免受窥视或恶意修改。

解决方案

使用 Java 安全套接字扩展 JSSE 加密你的流量。

讨论

JSSE 提供多个级别的服务,但最简单的使用方式是从SSLServerSocketFactory获取ServerSocket,而不是直接使用ServerSocket构造函数。SSL 即安全套接字层,其修订版被称为 TLS。它专门用于网络安全。要保护其他协议,你必须使用不同形式的SocketFactory

SSLServerSocketFactory返回一个设置为进行 SSL 加密的ServerSocket。Example 13-10 使用这种技术覆盖了 Recipe 13.6 中的getServerSocket()方法。如果你认为这太容易了,那你就错了!

示例 13-10. main/src/main/java/network/JSSEWebServer0
/**
 * JSSEWebServer - subclass trivial WebServer0 to make it use SSL.
 * N.B. You MUST have set up a server certificate (see the
 * accompanying book text), or you will get the dreaded
 * javax.net.ssl.SSLHandshakeException: no cipher suites in common
 * (because without it JSSE can't use any of its built-in ciphers!).
 */
public class JSSEWebServer0 extends WebServer0 {

    public static final int HTTPS = 8443;

    public static void main(String[] args) throws Exception {
        if (System.getProperty("javax.net.ssl.keyStore") == null) {
            System.err.println(
                "You must pass in a keystore via -D; see the documentation!");
            System.exit(1);
        }
        System.out.println("DarwinSys JSSE Server 0.0 starting...");
        JSSEWebServer0 w = new JSSEWebServer0();
        w.runServer(HTTPS);        // never returns!!
    }

    /** Get an HTTPS ServerSocket using JSSE.
 * @see WebServer0#getServerSocket(int)
 * @throws ClassNotFoundException the SecurityProvider can't be instantiated.
 */
    protected ServerSocket getServerSocket(int port) throws Exception {

        SSLServerSocketFactory ssf =
            (SSLServerSocketFactory)SSLServerSocketFactory.getDefault();

        return ssf.createServerSocket(port);
    }

}

这确实是你需要编写的所有 Java 代码。你必须设置 SSL 证书。为了演示目的,可以使用自签名证书;darwinsys.com/java/selfsigncert.html 中的步骤(步骤 1–4)足以满足要求。你必须告诉 JSSE 层在哪里找到你的密钥库:

java -Djavax.net.ssl.keyStore=/home/ian/.keystore -Djavax.net.ssl.
keyStorePassword=secrit JSSEWebServer0

典型的客户端浏览器对自签名证书感到怀疑(见 Figure 13-1),但如果用户确认,将接受该证书。

Figure 13-2 显示了简单的WebServer0在 HTTPS 协议下的输出(请注意右下角的挂锁)。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/jcb4_1301.png

图 13-1. 浏览器注意事项

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/jcb4_1302.png

图 13-2. 使用加密

参见

JSSE 不仅可以加密 Web 服务器流量,而且有时被视为其最激动人心的应用程序。有关 JSSE 的更多信息,请参见Sun 网站或*《Java 安全》*(由 Scott Oaks 编写,O’Reilly 出版)。

13.8 使用 JAX-RS 创建 REST 服务

问题

您想要通过使用提供的 Java EE/Jakarta EE API 来实现一个 RESTful 服务器。

解决方案

在提供服务的类上使用 JAX-RS 注解,并将其安装在企业应用服务器中。

讨论

该操作包括编码和配置两部分。

编码步骤包括创建一个扩展 JAX-RS Application类的类,并在提供服务的类上添加注解。

这是一个最小化的Application类示例:

import javax.ws.rs.ApplicationPath;
import javax.ws.rs.core.Application;

@ApplicationPath("")
public class RestApplication extends Application {
	// Empty
}

示例 13-11 是一个类似“Hello, World”的服务类,带有使其成为服务类以及具有三个示例方法所需的注解。

示例 13-11. restdemo/src/main/java/rest/RestService.java
@Path("")
@ApplicationScoped
public class RestService {

    public RestService() {
        System.out.println("RestService.init()");
    }

    @GET @Path("/timestamp")
    @Produces(MediaType.TEXT_PLAIN)
    public String getDate() {
        return LocalDateTime.now().toString();
    }

    /** A Hello message method
 */
    @GET @Path("/greeting/{userName}")
    @Produces("text/html")
    public String doGreeting(@PathParam("userName")String userName) {
        System.out.println("RestService.greeting()");
        if (userName == null || userName.trim().length() <= 3) {
            return "Missing or too-short username";
        }
        return String.format(
            "<h1>Welcome %s</h1><p>%s, We are glad to see you back!",
            userName, userName);
    }

    /** Used to download all items */
    @GET @Path("/names")
    @Produces(MediaType.APPLICATION_JSON)
    public List<String> findTasksForUser() {
        return List.of("Robin", "Jedunkat", "Lyn", "Glen");
    }
}

现在必须部署该类。如果我们已经创建了适当的 Maven 项目结构(参见 Recipe 1.7)并提供了特定于应用服务器的 Maven 插件,并且我们的开发服务器正在运行,则可以使用类似mvn deploy的变体。在这种情况下,我已经为在 WildFly 上部署设置了这个,在rest子目录下,只需执行mvn wildfly:deploy即可编译、打包并部署应用程序到我的服务器。

如果要基于 Eclipse MicroProfile 部署 REST 服务作为微服务,您可能希望研究Quarkus 框架

一旦服务部署完成,您可以使用浏览器或简单的 GET 请求的 Telnet 客户端进行交互探索:

$ telnet localhost 8080 # output cleaned up
Escape character is '^]'.
GET /rest/timestamp HTTP/1.0
Connection: keep-alive

HTTP/1.1 200 OK
Content-Type: text/plain;charset=UTF-8

2019-10-16T19:54:31.42

GET /rest/greeting/Ian%20Darwin HTTP/1.0

HTTP/1.1 200 OK
Content-Type: text/html;charset=UTF-8

<h1>Welcome Ian Darwin</h1><p>Ian Darwin, We are glad to see you back!

get /rest/names HTTP/1.0
Accept: Application/JSON

HTTP/1.1 200 OK
Content-Type: application/json

["Robin","Jedunkat","Lyn","Glen"]
^] (CTRL/C)
$

REST 的一个问题是缺乏官方标准来文档化服务器提供的 API 或协议(存在几个竞争的规范)。因此,编写客户端的人必须依赖服务器开发者提供的纯文档,或者通过试验发现协议。我们这里的例子足够简单,不会遇到这个问题,但想象一下一个类中有 20 或 30 个方法的情况。

Spring 框架提供了一个与此处使用的 JAX-RS API 非常相似的 API;如果您已经在使用 Spring,可能更简单使用它们的注解。

13.9 网络日志记录

问题

您的类正在服务器容器中运行,其调试输出难以获得。

解决方案

使用像 Java Logging API (JUL)、Apache Logging Services 项目的Log4j或这里展示的简单网络日志记录器。

讨论

在大多数操作系统上,从桌面客户端获取调试输出相当容易。 但是,如果要调试的程序正在像 Servlet 引擎或 EJB 服务器这样的容器中运行,那么获取调试输出可能会很困难,特别是如果容器在远程计算机上运行。 如果您的程序可以将消息发送回桌面机器上的程序以进行即时显示,那将非常方便。 不用说,使用 Java 的套接字机制做到这一点并不难。

许多日志 API 可以处理此问题:

  • 多年来,Java 一直拥有一个标准的日志 API JUL(在 Recipe 13.12 中讨论),它可以与包括 Unix syslog在内的各种日志机制通信。

  • Apache Logging Services 项目生成Log4j,用于许多需要日志记录的开源项目(请参阅 Recipe 13.11)。

  • Apache Jakart Commons Logging (JCL)。 这里没有讨论; 与其他日志 API 类似。

  • SLF4J(Java 的简单日志门面,参见 Recipe 13.10)是最新的门面,可以使用其他日志 API。

  • 并且,在这些广泛使用之前,我编写了一个小而简单的 API 来处理此类日志记录功能。 我的netlog在这里没有讨论,因为最好使用标准的日志机制之一; 如果您想挖掘它,它的代码在* javasrc * repo 的* logging *子目录中。

JDK 日志 API,Log4jSFL4J更加完整,可以写入文件; 一个OutputStreamWriter; 或远程Log4j,Unix syslog或 Windows 事件日志服务器。

从日志 API 的角度来看,正在调试的程序是客户端——即使它可能在类似 Web 服务器或应用服务器的服务器端容器中运行——因为网络客户端是发起连接的程序。 在您的桌面计算机上运行的程序是套接字的“服务器”程序,因为它等待连接的到来。

如果您希望运行任何可以从任何公共网络访问的基于网络的日志记录器,则需要更加注意安全问题。 一种常见的攻击形式是简单的拒绝服务(DoS),在此期间,攻击者会向您的服务器发起大量连接以减慢其速度。 例如,如果您正在将日志写入磁盘,攻击者可以通过发送大量垃圾邮件填满您的磁盘。 在常见用法中,您的日志监听器将位于防火墙后面,不可从外部访问; 但如果不是这种情况,请注意 DoS 攻击。

13.10 设置 SLF4J

问题

您希望使用一个日志 API,可以使用任何其他日志 API,例如,这样您的代码可以在其他项目中使用,而无需切换日志 API。

解决方案

使用 SLF4J:从LoggerFactory获取Logger,并使用其各种方法进行日志记录。

讨论

使用 SLF4J 仅需要一个 JAR 文件进行编译,slf4j-api-1.x.y.jar(其中 xy 将随时间变化)。要实际获得日志输出,您需要将多个实现 JAR 添加到运行时 CLASSPATH,其中最简单的是 slf4j-simple-1.x.y.jar(其中 xy 应该在这两个文件之间匹配)。

一旦将这些 JAR 文件添加到构建脚本或您的 CLASSPATH 上,您可以通过调用 LoggerFactory.getLogger() 来获取 Logger,传递类或包的字符串名称或当前 Class 引用。然后调用记录器的记录方法。一个简单的示例在 Example 13-12 中。

示例 13-12. main/src/main/java/logging/Slf4jDemo.java
public class Slf4jDemo {

    final static Logger theLogger =
            LoggerFactory.getLogger(Slf4jDemo.class);

    public static void main(String[] args) {

        Object o = new Object();
        theLogger.info("I created this object: " + o);

    }
}

有各种方法用于记录不同严重程度的信息,这些方法显示在 Table 13-1 中。

Table 13-1. SLF4j 记录方法

名称含义
trace冗长的调试信息(默认禁用)
debug冗长的调试信息
info低级别信息消息
warn可能的错误
error严重错误

SLF4j 相对于大多数其他日志记录 API 的优势之一是避免了死字符串反模式。在使用许多其他记录器 API 时,您可能会发现以下代码:

logger.log("The value is " + object + "; this is not good");

这可能会导致性能问题,因为隐式调用了对象的 toString(),并且执行了两次字符串连接,甚至在我们知道日志记录器是否要使用它们之前!如果这是重复调用的代码,可能会浪费大量开销。

这导致其他日志包提供了代码保护功能,基于能够非常快速地查找日志记录器是否启用的记录器方法,导致出现以下代码:

if (logger.isEnabled()) {
	logger.log("The value is " + object + "; this is not good");
}

这解决了性能问题,但使代码混乱!SLF4J 的解决方案是使用类似于(但不完全兼容)Java 的 MessageFormat 机制,如 Example 13-13 中所示。

示例 13-13. main/src/main/java/logging/Slf4jDemo2.java
public class Slf4jDemo2 {

    final static Logger theLogger = LoggerFactory.getLogger(Slf4jDemo2.class);

    public static void main(String[] args) {

        try {
            Person p = new Person();
            // populate person's fields here...
            theLogger.info("I created an object {}", p);

            if (p != null) {    // bogus, just to show logging
                throw new IllegalArgumentException("Just testing");
            }
        } catch (Exception ex) {
            theLogger.error("Caught Exception: " + ex, ex);
        }
    }
}

虽然这并不演示网络日志记录,但可以与 Log4j 或 JUL(Java Util Logging,JDK 的标准部分)等日志记录实现一起轻松实现,这些实现允许您提供可配置的日志记录。下一个配方中描述了 Log4j

参见

SLF4J 网站包含一个手册,讨论了各种 CLASSPATH 选项。还有一些 Maven artifact提供了各种选项。

13.11 使用 Log4j 进行网络日志记录

问题

您希望使用 Log4j 写入日志文件消息。

解决方案

获取 Logger 并使用其 log() 方法或便利方法。通过更改属性文件来控制日志记录。使用 org.apache.logging.log4j.net 包使其基于网络。

讨论

警告

本文档描述了 Log4j API 的第 2 版。在第 1 版和第 2 版之间,包名称、文件名以及用于获取日志记录器的方法都有所变化。如果您看到使用例如 Logger.getLogger("class name") 的代码,则该代码是针对旧 API 编写的,该 API 不再维护(Log4j 网站将 Log4j 1.2 及其 2.12 以下版本称为“遗留版本”;我们在本文档中使用的是 2.13 版本)。对于针对 1.x API 编写的代码,提供了相当大的兼容性;参见 https://logging.apache.org/log4j/2.x/manual/compatibility.html

使用 Log4j 进行日志记录简单、方便且灵活。您需要从静态方法 LogManager.getLogger() 获取一个 Logger 对象,Logger 具有公共 void 方法(debug()info()warn()error()fatal()),每个方法接受一个要记录的 Object(和一个可选的 Throwable)。与 System.out.println() 类似,如果传入的不是 String,将调用其 toString() 方法。还包括一个通用的日志记录方法:

public void log(Level level, Object message);

Level 类在 Log4j 2 API 中定义。标准级别依次为 DEBUG < INFO < WARN < ERROR < FATAL。即,调试消息被认为是最不重要的,而致命消息则是最重要的。每个 Logger 都有一个与其关联的级别;级别低于 Logger 的消息将被静默丢弃。

一个简单的应用程序可以使用以下几条语句记录消息:

public class Log4JDemo {

    private static Logger myLogger = LogManager.getLogger();

    public static void main(String[] args) {

        Object o = new Object();
        myLogger.info("I created an object: " + o);

    }
}

如果您在没有 log4j2.properties 文件的情况下编译并运行此程序,则不会生成任何日志输出(请参阅源文件夹中的 log4j2demos 脚本)。我们需要创建一个默认名称为 log4j2.properties 的配置文件。您也可以通过系统属性提供日志文件名:-Dlog4j​.configurationFile=URL

提示

Log4j 配置非常灵活,因此也非常复杂。甚至他们自己的文档承认:“试图在不理解[日志架构]的情况下配置 Log4j 将导致沮丧。”查看此Apache 网站,获取有关日志配置文件位置和格式的详细信息

每个 Logger 都有一个 Level 来指定要写入的消息级别。它还将有一个 Appender,它是写出消息的代码。ConsoleAppender 当然写入到 System.out;其他记录器写入到文件、操作系统级别记录器等等。一个简单的配置文件看起来像这样:

# Log4J2 properties file for the logger demo programs.
# tag::generic[] # Ensure file gets copied for Java Cookbook

# WARNING - log4j2.properties must be on your CLASSPATH,
# not necessarily in your source directory.

# The configuration file for Version 2 is different from V1!

rootLogger.level = info
rootLogger.appenderRef.stdout.ref = STDOUT

appender.console.type = Console
appender.console.name = STDOUT
appender.console.layout.type = PatternLayout
appender.console.layout.pattern = %m%n
appender.console.filter.threshold.type = ThresholdFilter
appender.console.filter.threshold.level = debug

此文件将根记录器的级别设置为DEBUG,这会导致它写入所有消息。配置文件还设置了一个名为APPENDER1的附加器,该附加器在接下来的几行上进行了配置。请注意,我不必引用com.darwinsys Logger。因为每个Logger都继承自根记录器,所以一个简单的应用程序只需要配置根记录器。属性文件也可以是 XML 文档,或者您可以编写自己的配置解析器(几乎没有人这样做)。

警告

如果找不到日志配置文件,则默认的根记录器将根记录器默认为Level.ERROR,因此您将看不到ERROR级别以下的任何输出。

配置文件就位后,演示效果更好。运行此程序(使用脚本中所做的适当的CLASSPATH)会产生以下输出:

$ java Log4j2Demo
I created an object: java.lang.Object@477b4cdf
$

日志记录的常见用法是记录捕获的Exception,如示例 13-14 所示。

示例 13-14。main/src/main/java/Log4JDemo2.java(Log4j—捕获和记录)
public class Log4JDemo2 {

    private static Logger myLogger = LogManager.getLogger();

    public static void main(String[] args) {

        try {
            Object o = new Object();
            myLogger.info("I created an object: " + o);
            if (o != null) {    // bogus, just to show logging
                throw new IllegalArgumentException("Just testing");
            }
        } catch (Exception ex) {
            myLogger.error("Caught Exception: " + ex, ex);
        }
    }
}

运行时,Log4JDemo2产生预期的输出:

$ java Log4JDemo2
I created an object: java.lang.Object@477b4cdf
Caught Exception: java.lang.IllegalArgumentException: Just testing
java.lang.IllegalArgumentException: Just testing
	at logging.Log4JDemo2.main(Log4JDemo2.java:17) [classes/:?]
$

Log4j 2 的灵活性很大程度上来自于其使用外部配置文件;您可以在不重新编译应用程序的情况下启用或禁用日志记录。消除大部分日志记录的属性文件可能包含以下条目:

rootLogger.level = fatal

只打印致命错误消息;所有比它低的级别都被忽略。

要从客户端记录到远程机器上的服务器,可以使用SocketAppender。还有一个SmtpAppender通过电子邮件发送紧急通知。有关所有受支持的附加器的详细信息,请参阅https://logging.apache.org/log4j/2.x/manual/appenders.html。这是log4j2-network.properties,配置文件的基于套接字的网络版本:

# Log4J2 properties file for the NETWORKED logger demo programs.
# tag::generic[] # Ensure file gets copied for Java Cookbook

# WARNING - log4j2.properties must be on your CLASSPATH,
# not necessarily in your source directory.

# The configuration file for Version 2 is different from V1!

rootLogger.level = info
rootLogger.appenderRef.stdout.ref = STDOUT

appender.console.type = Socket
appender.console.name = STDOUT
appender.console.host = localhost
appender.console.port = 6666
appender.console.layout.type = PatternLayout
appender.console.layout.pattern = %m%n
appender.console.filter.threshold.type = ThresholdFilter
appender.console.filter.threshold.level = debug

此文件通过netdemos脚本中的 Java 系统属性传递给演示程序:

build=../../../../target/classes
log4j2_jar=\
${HOME}/.m2/repository/org/apache/logging/log4j/log4j-api/2.13.0/log4j-api-2.13.0.jar:\
${HOME}/.m2/repository/org/apache/logging/log4j/log4j-core/2.13.0/log4j-core-2.13.0.jar

echo "==> Log4JDemo"
java -Dlog4j.configurationFile=log4j2-network.properties \
	-classpath ".:${build}:${log4j2_jar}" logging.Log4JDemo

echo "==> Log4JDemo2"
java -Dlog4j.configurationFile=log4j2-network.properties \
	-classpath ".:${build}:${log4j2_jar}" logging.Log4JDemo2

运行时使用log4j2-network.properties文件,你需要在另一端安排一个监听器。在 Unix 系统上,nc(或netcat)程序可以正常工作:

$ nc -kl 6666
I created an object: java.lang.Object@37ceb1df
I created an object: java.lang.Object@37ceb1df
Caught Exception: java.lang.IllegalArgumentException: Just testing
java.lang.IllegalArgumentException: Just testing
	at logging.Log4JDemo2.main(Log4JDemo2.java:17) [classes/:?]
^C
$

Netcat选项-l表示监听编号端口;-k告诉它继续监听,也就是说,当客户端关闭连接时重新打开连接,就像每个演示程序退出时一样发生的情况。

某些日志调用存在性能问题。考虑一些昂贵的操作,如toString()以及传递给经常使用的代码中的Log.info()的几个字符串连接。如果这些操作在更高的日志记录级别下进入生产环境,则将完成所有工作,但产生的字符串将永远不会被使用。在旧的 API 中,我们通常使用“代码保护”方法,如“isLoggerEnabled(Level)”来确定是否值得创建字符串。现在,首选的方法是在 Lambda 表达式中创建字符串(见第九章)。所有的日志方法都有一个接受Supplier参数的重载(示例 13-15](#javacook-network-SECT8-log4jlambda))。

示例 13-15. main/src/main/java/logging/Log4J2Lambda.java
public class Log4JLambda {

    private static Logger myLogger = LogManager.getLogger();

    public static void main(String[] args) {

        Person customer = getPerson();
        myLogger.info( () -> String.format(
            "Value %d from Customer %s", customer.value, customer) );

    }

这样,字符串操作仅在需要时执行:如果日志记录器以INFO级别运行,则调用Supplier,否则将不执行昂贵的操作。

当作为log4j2demos脚本的一部分运行时,会打印:

Value 42 from Customer Customer[Robin]

有关 Log4j 的更多信息,请访问其主网站。Log4j 2 是 Apache 软件基金会许可下的免费软件。

13.12 使用 java.util.logging 进行网络日志记录

问题

你希望使用 Java 日志机制编写日志消息。

解决方案

获取一个Logger,并用它来记录您的消息和/或异常。

讨论

Java 日志 API(包java.util.logging)类似于并明显受到 Log4j 包的启发。通过使用描述性字符串调用静态Logger.getLogger(),可以获取Logger对象。然后,您可以使用实例方法写入日志;这些方法包括以下内容:

public void log(java.util.logging.LogRecord);
public void log(java.util.logging.Level,String);
// and a variety of overloaded log(  ) methods
public void logp(java.util.logging.Level,String,String,String);
public void logrb(java.util.logging.Level,String,String,String,String);

// Convenience routines for tracing program flow
public void entering(String,String);
public void entering(String,String,Object);
public void entering(String,String,Object[]);
public void exiting(String,String);
public void exiting(String,String,Object);
public void throwing(String,String,Throwable);

// Convenience routines for log(  ) with a given level
public void severe(String);
public void warning(String);
public void info(String);
public void config(String);
public void fine(String);
public void finer(String);
public void finest(String);

与 Log4j 类似,每个Logger对象都有一个指定的日志级别,低于该级别的消息将被静默丢弃:

public void setLevel(java.util.logging.Level);
public java.util.logging.Level getLevel(  );
public boolean isLoggable(java.util.logging.Level);

与 Log4j 一样,对象处理日志的写入。每个日志记录器都有一个Handler

public synchronized void addHandler(java.util.logging.Handler);
public synchronized void removeHandler(java.util.logging.Handler);
public synchronized java.util.logging.Handler[] getHandlers(  );

每个Handler都有一个Formatter,用于格式化LogRecord以便显示。通过提供自己的Formatter,可以更好地控制日志中传递信息的格式。

与 Log4j 不同,Java SE 日志机制具有默认配置,因此示例 13-16 是一个最小的日志示例程序。

示例 13-16. main/src/main/java/logging/JulLogDemo.java
public class JulLogDemo {
    public static void main(String[] args) {

        Logger myLogger = Logger.getLogger("com.darwinsys");

        Object o = new Object();
        myLogger.info("I created an object: " + o);
    }
}

运行它将打印以下内容:

$ juldemos
Jan 31, 2020 1:03:27 PM logging.JulLogDemo main
INFO: I created an object: java.lang.Object@5ca881b5
$ 

与 Log4j 一样,其中一个常见用途是记录捕获的异常;此代码位于示例 13-17 中。

示例 13-17. main/src/main/java/logging/JulLogDemo2.java(捕获并记录异常)
public class JulLogDemo2 {
    public static void main(String[] args) {

        System.setProperty("java.util.logging.config.file",
            "logging/logging.properties");

        Logger logger = Logger.getLogger("com.darwinsys");

        try {
            Object o = new Object();
            logger.info("I created an object: " + o);
            if (o != null) {    // bogus, just to show logging
                throw new IllegalArgumentException("Just testing");
            }
        } catch (Exception t) {
            // All-in-one call:
            logger.log(Level.SEVERE, "Caught Exception", t);
            // Alternate: Long form, more control.
            // LogRecord msg = new LogRecord(Level.SEVERE, "Caught exception");
            // msg.setThrown(t);
            // logger.log(msg);
        }
    }
}

与 Log4j 类似,java.util.logging接受 Lambda 表达式(自 Java 8 起);请参阅示例 13-18。

示例 13-18. main/src/main/java/logging/JulLambdaDemo.java
/** Demonstrate how Java 8 Lambdas avoid extraneous object creation
 * @author Ian Darwin
 */
public class JulLambdaDemo {
    public static void main(String[] args) {

        Logger myLogger = Logger.getLogger("com.darwinsys.jullambda");

        Object o = new Helper();

        // If you change the log call from finest to info,
        // you see both the systrace from the toString,
        // and the logging output. As it is here,
        // you don't see either, so the toString() is not called!
        myLogger.finest(() -> "I created this object: " + o);
    }

    static class Helper {
        public String toString() {
            System.out.println("JulLambdaDemo.Helper.toString()");
            return "failure!";
        }
    }
}

参见

本章节主题的一个很好的综合参考是*《Java 网络编程》(http://oreil.ly/java-network-prgamming)*,作者是 Elliotte Harold。

任何网络机制的服务器端都极其敏感于安全问题。一个配置错误或编写不佳的服务器程序很容易 compromise 整个网络的安全性!关于网络安全的许多书籍中,两本书显著:Firewalls and Internet Security,作者是 William R. Cheswick 等人(Addison-Wesley),以及系列书籍中标题为Hacking Exposed的第一本,作者是 Stuart McClure 等人(McGraw-Hill)。

这完成了我对使用套接字的服务器端 Java 的讨论。聊天服务器可以使用多种技术来实现,例如 RMI(远程方法调用),HTTP Web 服务,JMS(Java 消息服务),以及处理存储转发消息处理的 Java 企业 API。这超出了本书的范围,但在源代码分发的chat文件夹中有一个 RMI 聊天服务器的示例,还有一个 JMS 聊天服务器的示例在*Java 消息服务*中,作者是 Mark Richards 等人(O’Reilly)。

¹ 当然,你可能不能随意为自己的服务选择任何端口号。某些众所周知的端口号专门保留用于特定服务,并在你的services文件中列出,例如 Secure Shell 的 22 端口和 SMTP 的 25 端口。此外,在基于服务器的操作系统上,低于 1024 的端口被视为特权端口,需要 root 或管理员权限来创建。这是早期的安全机制;今天,随着无数单用户桌面连接到互联网,这种限制提供的实际安全性已经很小,但限制仍然存在。

² 数字设备被康柏吸收,随后被惠普吸收,但名称仍然是de,因为负责命名这类东西的工程师们并不关心企业并购。

³ 有一些限制影响着你可以拥有的线程数量,这只影响非常大型的企业级服务器。你不能期望在标准的 Java 运行时中运行成千上万的线程。对于大型高性能服务器,你可能希望使用本地代码(参见 Recipe 18.6)使用select()poll()

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值