在本章中,我们将编写一个 Web 应用。我们将建立在我们已经取得的成就和创造一个网络版的策划游戏。这一次,它不仅会单独运行,猜测并回答位置的个数和匹配的颜色,还会与用户进行交流,询问猜测的答案。这将是一个真正的游戏,你可以玩。Web 编程对于 Java 开发人员来说非常重要。大多数程序都是 Web 应用。互联网上可用的通用客户端是 Web 浏览器。瘦客户端、基于 Web 浏览器的架构也被企业广泛接受。当架构与 Web 客户端不同时,只有少数例外。如果你想成为一名专业的 Java 开发人员,你必须熟悉 Web 编程。而且也很有趣!
在开发过程中,我们将访问许多技术主题。首先,我们将讨论网络和 Web 架构。这是整栋楼的混凝土底座。它不是太性感,就像你建造一座建筑物。你花了很多钱和精力挖壕沟,然后你埋了混凝土,最后,在这个阶段结束时,你似乎有平坦的地面之前,除了有基础。如果没有这个基础,房子可能会在建造后不久或建造过程中倒塌。网络对于网络编程同样重要。有很多话题似乎与编程无关。尽管如此,它仍然是构建的基础,当您编写 Web 应用时,您还将发现它的有趣之处。
我们还将讨论一些 HTML、CSS 和 JavaScript,但不会太多。我们无法避免它们,因为它们对 Web 编程也很重要,但它们也是您可以从其他地方学习的主题。如果您不是这些领域的专家,那么企业项目团队中通常还有其他专家可以扩展您的知识。除此之外,JavaScript 是一个如此复杂和庞大的主题,它值得一本完整的书作为开始。只有极少数的专家对 Java 和 JavaScript 都有深刻的理解。我了解该语言的总体结构和运行环境,但我无法跟上这些天每周发布的新框架,就像我关注其他领域一样。
您将学习如何创建在应用服务器上运行的 Java 应用,这次是在 Jetty 中,我们将看到 Servlet 是什么。为了快速启动,我们将创建一个 HelloWorld Web 应用。然后,我们将创建 Mastermind 的 Servlet 版本。请注意,如果没有一个框架的帮助,我们几乎不会直接编写 Servlet,这个框架实现了处理参数、认证和许多其他非特定于应用的事情的代码。在本章中,我们仍将坚持使用裸 Servlet,因为如果不首先了解 Servlet 是什么,就不可能有效地使用 Spring 之类的框架。要成为一名工程师,你必须先把手弄脏。Spring 将在下一章到来。
我们将提到 JavaServer Pages(JSP),只是因为您可能会遇到一些遗留应用,这些应用是使用该技术开发的,但是现代 Web 应用不使用 JSP。尽管如此,JSP 还是 Servlet 标准的一部分,可以使用。还有其他一些技术是在最近的过去发展起来的,但现在似乎还不能证明未来。它们仍然可用,但只出现在遗留应用中,选择它们用于新项目是相当值得怀疑的。我们将在单独的一节中讨论这些技术。
在本章结束时,您将了解基本的 Web 技术是如何工作的以及主要的架构元素是什么,并且您将能够创建简单的 Web 应用。这还不足以成为一名专业的 Java Web 开发人员,但将为下一章打下良好的基础,在下一章中,我们将了解当今企业中用于实际应用开发的专业框架。
程序在计算机上运行,计算机连接到互联网。这个网络是在过去的 60 年里发展起来的,最初是为了提供能够抵御火箭攻击的军事数据通信,后来被扩展为学术网络,后来成为任何人都可以使用的商业网络,几乎遍布世界各地。
该网络的设计和研究始于 60 年代加加林绕地球运行的反应。把加加林送上太空并环绕地球运行,证明了俄罗斯可以在全球任何地方发射火箭,可能带有原子弹爆炸物。这意味着任何需要中央控制的数据网络都无法抵御这种攻击。将中心位置作为单一故障点的网络是不可行的。因此,人们开始研究建立一个网络,即使网络的任何一部分被关闭,也能继续运行。
网络在连接到它的任何两台计算机之间传送数据包。网络上使用的协议是 IP,它只是互联网协议的缩写。使用 IP,一台计算机可以向另一台计算机发送数据包。包包含一个头和数据内容。标头包含发件人和目标计算机的互联网地址、其他标志以及有关包的信息。由于机器之间没有直接连接,路由器转发数据包。这就像邮局互相寄信,直到他们交到你认识的邮递员手里,邮递员可以直接把信送到你的邮箱。为此,路由器使用标头中的信息。路由器如何交互的算法和组织是复杂的,我们不需要知道一些东西,就可以成为 Java 专业人士。
如果您需要编程才能直接发送 IP 包,则应查看java.net.DatagramPacket
,因为其余的都是在 JDK、操作系统和网卡固件中实现的。您可以创建数据包;发送数据包并更改网卡上的调制电压或向纤程发射光子不是您关心的问题。
IP 目前有两个版本。仍在使用的旧版本是 IPv4。与旧版本共存的新版本是 IPv6,即 IPng(ng 代表新一代)。Java 开发人员可能关心的主要区别是版本 4 使用 32 位地址,版本 6 使用 128 位地址。当您看到版本 4 的地址时,您将看到类似于192.168.1.110
的内容,其中包含由点分隔的十进制格式的四个字节。IPv6 地址表示为2001:db8:0:0:0:0:2:1
,八个 16 位数字以十六进制表示,用冒号分隔。
网络比发送数据包要复杂一些。如果发送数据包类似于发送一页的信件,那么网页下载就像在纸上邮件中讨论合同。在合同签订之前,在最初的纸质邮件中应该有一个关于发送什么、回复什么等的协议。在互联网上,该协议被称为传输控制协议(TCP)。虽然作为一名 Java 开发人员,您很可能会遇到 IP 路由问题,但您肯定会面临 TCP 编程。因此,我们将简要介绍 TCP 的工作原理。请注意,这是非常简短的。真正地。在阅读下一节内容时,您不会成为 TCP 专家,但您将看到影响 Web 编程的最重要问题。
TCP 协议是在操作系统中实现的,它提供了比 IP 更高级别的接口。编写 TCP 时,不处理数据报。相反,您有一个字节流通道,您可以将要传递到另一台计算机的字节放入其中,并且可以从另一台计算机发送的通道中读取字节,完全按照它们发送的顺序。这是两台计算机之间的连接,更重要的是,两个程序之间的连接。
还有其他协议是通过 IP 实现的,并且不是面向连接的。其中一个是用户数据报协议(UDP)。当不需要连接时,它用于服务。它还用于数据可能丢失时,并且数据及时到达目的地比不丢失任何数据包(视频流、电话)更重要。该协议的另一个应用是当数据量较小且丢失时可以再次请求;再次请求的成本比使用更复杂的 TCP 协议要便宜。最后一种使用的典型示例是 DNS 请求,我们将在下一节中详细介绍。
在操作系统中实现的 TCP 软件层处理复杂的数据包处理。重新发送丢失的包、重新排序以不同于最初预期的顺序到达的包,以及删除可能多次到达的额外包,都是由该层自动补全的。这一层通常被称为 TCP 栈。
由于 TCP 是一个连接协议,所以需要告诉 TCP 栈当数据报到达时属于哪个流。流由两个端口标识。端口是 16 位整数。一个程序标识启动连接的程序,称为源端口。另一个程序标识目标程序目标端口。这些包含在每个和每个传输的 TCP 包中。当机器运行安全外壳(SSH)服务器和 Web 服务器时,这些应用使用不同的端口。这些端口通常为22
和80
。当 TCP 头中包含目标端口号22
的包出现时,TCP 栈知道数据包中的数据属于 SSH 服务器处理的流。同样,如果目标端口为80
,则数据将被发送到 Web 服务器。
在编写服务器程序时,通常必须定义端口号;否则,客户端将找不到服务器程序。Web 服务器通常监听端口80
,客户端尝试连接到该端口。客户端端口通常不重要,也不指定;它由 TCP 栈自动分配。
从客户端代码连接到服务器很容易,这只需要几行代码。有时,它只是一行代码。然而,在后台,TCP 栈做了很多我们应该关心的工作,因为建立 TCP 连接需要时间,而且它会极大地影响应用的性能。
为了建立连接,TCP 栈向目的地发送一个数据报。这还不足以建立连接,但这是建立连接的第一步。这个包是空的,它的名字是 SYN。发送此数据包后,客户端开始等待服务器应答。如果没有服务器,或者服务器太忙而无法应答,或者由于任何原因无法向该特定客户端提供应答,那么发送任何进一步的包都将是网络流量浪费。
当服务器接收到 SYN 包时,它会用 SYN-ACK 包进行回复。最后,在接收到 SYN-ACK 包之后,客户端发送一个名为 ACK 的包。如果数据包通过大西洋,每个数据包大约需要 45 毫秒,相当于 4500 万秒的官僚时间。这差不多是一年半了。我们需要其中三个来建立连接,这只是连接的建立;到目前为止,我们还没有发送任何数据。
当建立 TCP 连接时,客户端不会在没有自我控制的情况下开始发送数据。它只发送几个包,然后等待查看发生了什么。如果包到达并且服务器承认这些包,则发送更多,一旦看到连接和服务器能够接受更大的包量,则会增加此卷。发送服务器未准备好、无法处理的数据,不仅无用,而且会浪费网络资源。TCP 是为了优化网络使用率而设计的。客户端发送一些数据,然后等待确认。TCP 栈自动管理此操作。如果确认到达,它会发送更多的数据包。如果精心设计的优化算法,在 TCP 栈中实现,认为发送更多是好的,那么它发送的数据比第一步多一些。如果有负面的确认告诉客户端服务器无法接受某些数据,并且必须将其丢弃,那么客户端将减少它在没有确认的情况下发送的数据包数。但首先,它开始缓慢谨慎。
这就是所谓的 TCP 慢启动,我们必须意识到这一点。尽管这是一个低级的网络特性,但它会产生一些后果,我们必须在 Java 代码中考虑到这一点:我们使用数据库连接池,而不是在每次需要一些数据时创建到数据库的新连接;我们尝试尽可能少地连接到 Web 服务器,使用 keep-alive、SPDY 协议或 http/2 等技术(也代替 SPDY)。
就目前而言,TCP 是面向连接的,即建立到服务器的连接,发送和接收字节,最后关闭连接就足够了。当您遇到网络性能问题时,您必须查看我之前详述的问题(并询问网络专家)。
TCP 协议使用机器的 IP 地址创建一个通道。在浏览器中键入 URL 时,它通常不包含 IP 号码。它包含机器名。使用名为域名系统(DNS)的分布式数据库将名称转换为 IP 号码。这个数据库是分布式的,当一个程序需要将一个名称转换成一个地址时,它会将一个 DNS 请求发送到它所知道的一个 DNS 服务器。这些服务器相互发送查询,或者告诉客户端询问谁,直到客户端知道分配给该名称的 IP 地址。服务器和客户端还缓存最近请求的名称,因此应答很快。另一方面,当服务器的 IP 地址更改这个名称时,并不是所有的客户端都能立即在全球范围内看到地址分配。DNS 查找可以很容易地编程,JDK 中有一些类和方法支持这一点,但是通常,我们不需要担心这一点;当我们编程时,它是在 Web 编程中自动补全的。
超文本传输协议(HTTP)建立在 TCP 之上。在浏览器中键入 URL 时,浏览器会打开一个到服务器的 TCP 通道(当然,在 DNS 查找之后),并向 Web 服务器发送一个 HTTP 请求。服务器在接收到请求后,生成一个响应并将其发送给客户端。之后,TCP 通道可能会被关闭或保持活动状态,以供进一步的 HTTP 请求-响应对使用。
请求和响应都包含头和可选(可能为零长度)正文。标题采用文本格式,并用空行与正文分开。
更准确地说,头部和主体由四个字节分隔-0x0D
、0x0A
、0x0D
和0x0A
,这是两个CR
、LF
行分隔符。HTTP 协议使用回车符和换行符来终止标头中的行,因此,一个空行是两个CRLF
紧随其后。
标题的开头是一个状态行加上标题字段。以下是 HTTP 请求示例:
GET /html/rfc7230 HTTP/1.1
Host: tools.ietf.org
Connection: keep-alive
Pragma: no-cache
Cache-Control: no-cache
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.116 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
DNT: 1
Referer: https://en.wikipedia.org/
Accept-Encoding: gzip, deflate, sdch, br
Accept-Language: en,hu;q=0.8,en-US;q=0.6,de;q=0.4,en-GB;q=0.2
这就是答案:
HTTP/1.1 200 OK
Date: Tue, 04 Oct 2016 13:06:51 GMT
Server: Apache/2.2.22 (Debian)
Content-Location: rfc7230.html
Vary: negotiate,Accept-Encoding
TCN: choice
Last-Modified: Sun, 02 Oct 2016 07:11:54 GMT
ETag: "225d69b-418c0-53ddc8ad0a7b4;53e09bba89b1f"
Accept-Ranges: bytes
Cache-Control: max-age=604800
Expires: Tue, 11 Oct 2016 13:06:51 GMT
Content-Encoding: gzip
Strict-Transport-Security: max-age=3600
X-Frame-Options: SAMEORIGIN
X-Xss-Protection: 1; mode=block
X-Content-Type-Options: nosniff
Keep-Alive: timeout=5, max=100
Connection: Keep-Alive
Transfer-Encoding: chunked
Content-Type: text/html; charset=UTF-8
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xml:lang="en" lang="en">
<head profile="http://dublincore.org/documents/2008/08/04/dc-html/">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="robots" content="index,follow" />
请求不包含正文。状态行如下:
GET /html/rfc7230 HTTP/1.1
它包含所谓的请求方法、请求的对象以及请求使用的协议版本。标头请求的其余部分包含格式为label: value
的标头字段。有些行被包装在印刷版本中,但是在标题行中没有换行符。
响应指定它使用的协议(通常与请求相同)、状态代码和状态的消息格式:
HTTP/1.1 200 OK
之后,响应头字段的语法与请求中的相同。一个重要的标题字段是内容类型:
Content-Type: text/html; charset=UTF-8
它指定响应体(在打印输出中截断)是 HTML 文本。
实际请求发送到这个页面,定义 HTTP 1.1 版本的标准。您可以自己轻松地查看通信,启动浏览器并打开开发人员工具。现在每个浏览器都内置了这样的工具。通过查看字节级别上的实际 HTTP 请求和响应,可以使用它在网络应用级别上调试程序行为。以下屏幕截图显示了开发人员工具如何显示此通信:
作为请求状态行中第一个单词的方法告诉服务器如何处理请求。本标准定义了不同的方法,如GET
、HEAD
、POST
、PUT
、DELETE
等。
当客户端想要获取资源的内容时,它使用GET
方法。在GET
请求的情况下,请求的主体是空的。这是我们下载网页时浏览器使用的方法。当 JavaScript 程序想从服务器获取一些信息,但又不想向服务器发送太多信息时,会多次使用这种方法。
当客户端使用POST
时,目的通常是向服务器发送数据。服务器回复,而且通常在回复中还有一个主体。但是,请求/应答通信的主要目的是将信息从客户端发送到服务器。这在某种程度上与GET
方法相反。
GET
和POST
方法是最常用的方法。虽然使用GET
检索数据和POST
向服务器发送数据有一个通用的指导原则,但这只是一个建议,并没有将这两种情况完全分开。很多时候,GET
被用来向服务器发送数据。毕竟,它是一个带有状态行和头字段的 HTTP 请求,尽管请求中没有正文,但是状态行中方法后面的对象(URL 的一部分)仍然能够传递参数。通常,测试响应GET
请求的服务也很容易,因为您只需要浏览器键入带有参数的 URL,然后在浏览器开发工具中查看响应。
如果您看到一个应用使用GET
请求来执行修改 Web 服务器状态的操作,您应该不会感到惊讶。然而,不感到惊讶并不意味着赞同。你应该知道,在大多数情况下,这些都不是好的做法。当我们使用GET
请求发送敏感信息时,URL 中的参数在浏览器的地址行中对客户端可用。当我们使用POST
发送时,客户端仍然可以访问参数(毕竟,客户端发送的信息是由客户端生成的,因此不能不可用),但是对于一个简单的不知道安全性的用户来说,复制和粘贴信息然后转发给恶意的第三方并不是那么容易。使用GET
和POST
之间的决定应始终考虑实用性和安全性问题。
HEAD
方法与GET
请求相同,但响应不包含正文。当客户端对实际响应不感兴趣时,使用此选项。可能发生的情况是,客户端已经拥有该对象,并希望查看该对象是否已更改。Last-Modified
头将包含上次更改资源的时间,客户端可以决定是否有更新的资源或需要在新请求中请求资源。
当客户端想要在服务器上存储某些内容时,使用PUT
方法;当客户端想要擦除某些资源时,使用DELETE
方法。这些方法仅由通常用 JavaScript 编写的应用使用,而不是由浏览器直接使用。
标准中还定义了其他方法,但这些方法是最重要和最常用的方法。
响应以状态代码开始。还定义了这些代码,并且在响应中可用的代码数量有限。最重要的是200
,表示一切正常;响应包含请求所需的内容。代码总是在100
到599
之间,包含三位数字。它们按照第一个数字分组如下:
1xx
:这些代码是信息代码。它们很少使用,但在某些情况下非常重要。例如,100
表示继续。当一个服务器收到一个POST
请求时,它可以发送这个代码,并且服务器想要向客户端发送请求的主体,因为它可以处理它。如果在服务器和客户端上正确实现,那么使用此代码并等待此代码的客户端可以节省大量带宽。2xx
:这些代码意味着成功。请求得到正确响应,或者请求的服务已实现。标准中定义了200
、201
、202
等代码,并对何时使用其中一种进行了说明。3xx
:这些代码表示重定向。当服务器不能直接为请求提供服务,但知道可以提供服务的 URL 时,会发送其中一个代码。实际的代码可以区分永久重定向(当知道所有将来的请求都应该发送到新的 URL 时)和临时重定向(当任何以后的请求都应该发送到这里并且可能被服务或重定向时),但是决定权在服务器端。4xx
:这些是错误代码。最著名的代码是404
,意思是找不到,也就是说服务器因为找不到资源而无法响应请求。401
表示服务于请求的资源可能是可用的,但它需要认证。403
表示请求有效,但仍被服务器拒绝的代码。5xx
:这些代码是服务器错误代码。当响应包含这些错误代码中的一个时,意味着服务器上存在错误。此错误可能是暂时的,例如,当服务器正在处理太多的请求,并且无法以计算密集型响应响应(这通常由错误代码503
发出信号)响应新请求时,或者当功能未实现时(代码501
)。一般错误代码500
被解释为内部错误,这意味着没有任何关于服务器上发生了什么错误的信息,但是它运行得不好,因此没有任何有意义的响应。
自上次发布 HTTP 以来,经过近 20 年的时间,最新版本的 HTTP 于 2015 年发布。这个新版本的协议与以前的版本相比有一些增强。其中一些增强也会影响服务器应用的开发方式。
第一个也是最重要的增强是,新协议将使在单个 TCP 连接中并行发送多个资源成为可能。Keep-Alive
标志已经可以用来避免重新创建 TCP 通道,但是当响应创建缓慢时,它没有帮助。在新协议中,其他资源也可以在同一个 TCP 通道中传递,甚至在请求得到完全服务之前。这需要协议中复杂的包处理。这对服务器程序员和浏览器程序员都是隐藏的。应用服务器、Servlet 容器和浏览器透明地实现了这一点。
HTTP/2 将始终加密。因此,在浏览器 URL 中不可能使用http
作为协议。永远是https
。
需要更改 Servlet 编程以利用新版本协议的优势的特性是服务器推送。Servlet 规范的 4.0 版本包括对 HTTP/2 的支持。规范可从这个页面获得。
服务器推送是对将来将出现的请求的 HTTP 响应。服务器如何回答一个甚至没有发出的请求?好吧,服务器已经预料到了。例如,应用发送一个 HTML 页面,其中引用了许多小图片和图标。客户端下载 HTML 页面,构建 DOM 结构,进行分析,实现所需图片,并发送图片请求。程序员知道那里有什么图片,甚至在浏览器请求图片之前就可以编写代码让服务器发送这些图片。每一个这种性质的响应都包含一个该响应所针对的 URL。当浏览器需要资源时,它会意识到资源已经存在,并且不会发出新的请求。在HttpServlet
中,程序应该通过请求的新getPushBuilder()
方法访问PushBuilder
,并使用该方法将资源下推到客户端。
Cookie 由浏览器维护,并通过使用Cookie
头字段在 HTTP 请求头中发送。每个 Cookie 都有一个名称、值、域、路径、过期时间和一些其他参数。当请求被发送到与域(未过期 Cookie 的路径)匹配的 URL 时,客户端将 Cookie 发送到服务器。Cookies 通常通过浏览器存储在客户端的小文件中,或者存储在本地数据库中。实际的实现是浏览器的业务,我们不必担心。它只是文本信息,而不是由客户端执行。只有当某些规则(主要是域和路径)匹配时,才会将其发送回服务器。Cookie 由服务器创建,并使用Set-Cookie
头字段在 HTTP 响应中发送给客户端。因此,本质上,服务器告诉客户端,“嘿,这是 Cookie,下次你来找我时,给我看这段信息,这样我就知道是你了”。Cookies 也可以通过 JavaScript 客户端代码创建。但是,由于 JavaScript 代码也来自服务器,因此这些 Cookie 也可以被视为来自服务器。
Cookies 通常是用来记住客户的。广告商和在线商店需要记住他们在和谁交谈,他们大量使用它。但这不是唯一的用途。现在,任何维护用户会话的应用都使用 Cookie 来链接来自同一用户的 HTTP 请求。当您登录到应用时,用于标识自己的用户名和密码只发送到服务器一次,并且在随后的请求中,只向服务器发送一个特殊的 Cookie 来标识已登录的用户。Cookie 的这种用法强调了为什么使用不容易猜测的 Cookie 值很重要。如果用来识别用户的 Cookie 很容易猜测,那么攻击者就可以创建一个 Cookie 并模仿其他用户将其发送到服务器。为此,Cookie 值通常是长的随机字符串。
Cookie 并不总是发送回它们发源的服务器。发送 Cookie 时,服务器指定应将 Cookie 发送回的 URL 的域。当与提供需要认证的服务的服务器不同的服务器执行用户认证时,将使用此选项。
应用有时将值编码到 Cookie 中。这并不一定是坏的,尽管在大多数实际情况下,它是坏的。在将某些内容编码到 Cookie 中时,我们应该始终考虑 Cookie 在网络中传播的事实。随着越来越多的数据被编码到 Cookie 中,带有编码数据的 Cookie 会变得越来越大。它们会给网络带来不必要的负担。通常,最好只发送一个唯一的、否则没有意义的随机键,并将值存储在数据库中,无论是磁盘上还是内存中。
到目前为止,我们开发的应用运行在一个 JVM 上。我们已经有了一些并发编程的经验,这是一些现在会派上用场的东西。当我们编写一个 Web 应用时,一部分代码将在服务器上运行,一部分应用逻辑将在浏览器中执行。服务器部分将用 Java 编写,浏览器部分将用 HTML、CSS 和 JavaScript 实现。因为这是一本 Java 书籍,所以我们将主要关注服务器部分,但是我们仍然应该意识到这样一个事实:许多功能可以而且应该实现为在浏览器中运行。这两个程序通过 IP 网络(即互联网)或公司网络(如果是企业内部应用)相互通信。
如今,浏览器可以执行用 JavaScript 实现的强大应用。新的浏览器版本也支持 WebAssembly。这种技术在具有实时编译器的虚拟机中执行代码,就像 Java 虚拟机一样,因此,代码执行速度与本地应用一样快。在浏览器中运行的图形游戏已经有了展示安装。诸如 C、Rust 和 GO 之类的语言可以编译到 WebAssembly,我们可以预期其他语言也可以使用。这意味着浏览器的编程方法将被取代,越来越多的功能将在客户端应用中实现。这样,应用将变得越来越像传统的旧客户端-服务器应用,区别在于客户端将在浏览器的沙盒中运行,并且通信是 HTTP 协议。
几年前,这种应用需要客户端应用在 Delphi、C++ 或 Java 中实现,使用客户端操作系统的窗口能力。
最初,客户端-服务器架构意味着应用的功能是在客户端上实现的,程序只使用来自服务器的常规服务。服务器提供了数据库访问和文件存储,但仅此而已。后来,三层架构将业务功能放在使用其他服务器进行数据库和其他常规服务的服务器上,客户端应用实现了用户界面和有限的业务功能。
当 Web 技术开始渗透到企业计算时,Web 浏览器开始在许多用例中取代客户端应用。以前,浏览器不能运行复杂的 JavaScript 应用。应用在 Web 服务器上执行,客户端显示服务器创建的 HTML 作为应用逻辑的一部分。每次用户界面上发生更改时,浏览器都会启动与服务器的通信,并且在 HTTP 请求-响应对中,浏览器内容会被替换。Web 应用本质上是一系列表单填充和表单数据发送操作,服务器用 HTML 格式的页面进行响应,可能包含新表单。
JavaScript 解释器得到了发展,变得越来越有效和标准化。如今,现代 Web 应用包含 HTML(这是客户端代码的一部分,不是由服务器动态生成)、CSS 和 JavaScript。当代码从 Web 服务器下载时,JavaScript 开始执行并与服务器通信。它仍然是 HTTP 请求和响应,但是响应不包含 HTML 代码。它包含纯数据,通常是 JSON 格式。这些数据由 JavaScript 代码使用,一些数据(如果需要)显示在 Web 浏览器的显示屏上,也由 JavaScript 控制。这在功能上相当于三层架构,有几个很小但非常重要的区别。
第一个区别是,客户端上没有安装代码。客户端从 Web 服务器下载应用,唯一安装的是现代浏览器。这就消除了许多企业维护负担和成本。
第二个区别是客户端不能访问客户端机器的资源,或者只有有限的访问权限。厚客户端应用可以将任何内容保存在本地文件中或访问本地数据库。对于浏览器应用,出于安全原因,这是非常有限的。同时,这是一个方便的限制,因为客户端不是,也不应该是架构的可信部分。客户端计算机中的磁盘备份成本很高。它可以用笔记本偷走,加密是昂贵的。有一些工具可以保护客户端存储,但大多数情况下,仅将数据存储在服务器上是一种更可行的解决方案。
信任客户端应用也是常见的程序设计错误。客户端在物理上控制客户端计算机,尽管这在技术上非常困难,但是客户端仍然可以克服客户端设备和客户端代码的安全限制。如果只有客户端应用检查某些功能或数据的有效性,则不使用服务器的物理控件提供的物理安全性。每当数据从客户端发送到服务器时,无论客户端应用是什么,都必须检查数据的有效性。实际上,由于客户端应用是可以更改的,我们只是不知道客户端应用到底是什么。
在本章中,事实上,在本书中,我们主要关注 Java 技术;因此,示例应用几乎不包含任何客户端技术。我忍不住创建了一些 CSS。另一方面,我绝对避免使用 JavaScript。因此,我必须再次强调,这个示例旨在演示服务器端的编程,并且仍然提供一些真正有效的东西。现代应用将使用 REST 和 JSON 通信,不会在服务器端动态创建 HTML。最初,我想创建一个 JavaScript 客户端和 REST 服务器应用,但是重点从服务器端 Java 编程转移了太多,所以我放弃了这个想法。另一方面,您可以将应用扩展为这样的应用。
Servlet 是在实现 Servlet 容器环境的 Web 服务器中执行的 Java 类。最初的 Web 服务器只能向浏览器提供静态 HTML 文件。对于每个 URL,Web 服务器上都有一个 HTML 页面,服务器根据浏览器发送的请求传递该文件的内容。很快,就需要扩展 Web 服务器,以便能够启动一个程序,在处理请求时动态地计算响应的内容。
第一个这样做的标准是定义的公共网关接口(CGI)。它启动了一个新的进程来响应请求。新进程获得了对其标准输入的请求,并将标准输出发送回客户端。这种方法浪费了大量资源。正如您在上一章中了解到的那样,启动一个新的进程对于响应一个 HTTP 请求来说代价太高了。即使开始一个新的线程似乎是没有必要的,但有了它,我们就有点超前了。
下一种方法是 FastCGI,它不断地执行外部进程并重用它。FastCGI 后面的方法都使用进程中扩展。在这些情况下,计算响应的代码运行在与 Web 服务器相同的进程中。这些标准或扩展接口是针对 Microsoft IIS 服务器的 ISAPI、Netscape 服务器的 NSASPI 和 Apache 模块接口。这些都使得在 Windows 上创建一个动态加载库(DLL),或在 Unix 系统上加载共享对象(SO),并映射这些库中实现的代码处理的某些请求。
例如,当有人编写 PHP 时,Apache 模块扩展就是 PHP 解释器,它读取 PHP 代码并对其执行操作。当有人为 NicrosoftIIS 编写 ASP 页面时,将执行实现 ASP 页面解释器的 ISAPI 扩展(好吧,这有点草率,说起来过于简单,但可以作为一个例子)。
对于 Java 来说,接口定义是 JSR369 中从 4.0 版开始定义的 Servlet。
JSR 代表 Java 规范请求。这些是对 Java 语言、库接口和其他组件的修改请求。这些请求经过一个评估过程,当它们被接受时,它们就成为一个标准。这个过程由 Java 社区流程(JCP)定义。JCP 也有文档记录,有不同的版本。当前版本为 2.10,可在这个页面找到。
Servlet 程序实现 Servlet 接口。通常,这会受到扩展HttpServlet
类的影响,这个类是Servlet
接口的抽象实现。这个抽象类实现了doGet()
、doPost()
、doPut()
、doDelete()
、doHead()
、doOption()
、doTrace()
等方法,可以被扩展它的实际类自由覆盖。如果 Servlet 类没有覆盖其中一个方法,则发送相应的 HTTP 方法GET
、POST
等,将返回405 Not Allowed
状态码。
在进入技术细节之前,让我们创建一个非常简单的 HelloWorld Servlet。为此,我们将建立一个 Gradle 项目,其中包含构建文件build.gradle
,即src/main/java/packt/java9/by/example/mastermind/servlet/HelloWorld.java
文件中的 Servlet 类,最后但同样重要的是,我们必须创建文件src/main/webapp/WEB-INF/web.xml
。gradle.build
文件如下所示:
apply plugin: 'java'
apply plugin: 'war'
apply from: 'https://raw.github.com/gretty-gradle-plugin/gretty/master/pluginScripts/gretty.plugin'
repositories {
jcenter()
}
targetCompatibility = "1.10"
sourceCompatibility = "1.10"
dependencies {
providedCompile "javax.servlet:javax.servlet-api:3.1.0"
testCompile 'junit:junit:4.12'
compile 'org.slf4j:slf4j-api:1.7.7'
compile 'ch.qos.logback:logback-classic:1.0.11'
compile 'com.google.inject:guice:4.1.0'
}
Gradle 构建文件使用两个插件,java
和gretty
。我们已经在上一章中使用了java
插件。gretty
插件添加了appRun
之类的任务,用于加载 Jetty Servlet 容器并启动应用。gretty
插件还使用war
插件,它将 Web 应用编译成 Web 归档(WAR)打包格式。
WAR 打包格式实际上与 JAR 相同;它是一个 zip 文件,包含一个包含 Web 应用所依赖的所有 JAR 文件的目录。应用的类在目录WEB-INF/classes
中,有一个描述 Servlet URL 映射的WEB-INF/web.xml
文件,我们将很快详细探讨这个文件。
因为我们想开发一个非常简单的 Servlet,所以我们将 Servlet API 作为依赖项添加到项目中。然而,这不是一种依赖关系。当 Servlet 在容器中运行时,API 可用。但是,它必须在编译器编译我们的代码时可用;因此,伪实现是由指定为providedCompile
的工件提供的。因为是这样指定的,所以构建过程不会将库打包到生成的 WAR 文件中。生成的文件将不包含任何特定于 Jetty 或任何其他 Servlet 容器的内容。
Servlet 容器将提供 Servlet 库的实际实现。当应用在 Jetty 中部署和启动时,Servlet 库的 Jetty 特定实现将在类路径上可用。当应用部署到 Tomcat 时,特定于 Tomcat 的实现将可用。
我们在项目中创建了一个类,如下所示:
package packt.java11.mastermind.servlet;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
public class HelloWorld extends HttpServlet {
private String message;
public void init() throws ServletException {
message = "Hello World";
}
public void doGet(HttpServletRequest request,
HttpServletResponse response)
throws ServletException, IOException {
response.setContentType("text/html");
PrintWriter out = response.getWriter();
out.println("<h1>" + message + "</h1>");
}
public void destroy() {
}
}
当 Servlet 启动时,init
方法被调用。当 Servlet 停止服务时,调用destroy
方法。可以覆盖这些方法,以提供比构造器和其他终结可能性更细粒度的控制。一个 Servlet 对象可以多次投入使用,调用destroy
后,Servlet 容器可以再次调用init
,因此这个周期与对象的生命周期没有严格的联系。通常,我们在这些方法中做的并不多,但有时,您可能需要在其中编写一些代码。
另外,请注意,一个 Servlet 对象可以用于服务多个请求,甚至可以同时服务;因此,其中的 Servlet 类和方法应该是线程安全的。该规范要求 Servlet 容器仅使用一个 Servlet 实例,以防容器在非分布式环境中运行。如果容器在同一台机器上的多个进程中运行,每个进程执行一个 JVM,甚至在不同的机器上运行,那么可以有许多 Servlet 实例来处理请求。一般来说,Servlet 类的设计应该使它们不假设只有一个线程在执行它们,但是,同时,它们也不应该假设不同请求的实例是相同的。我们根本不知道。
这在实践中意味着什么?您不应该使用特定于某个请求的实例字段。在前面的示例中,初始化为保存消息的字段为每个请求保存相同的值;实际上,变量几乎是一个最终常量。它仅用于演示init
方法的一些功能。
当 Servlet 容器通过GET
方法获得 HTTP 请求时,doGet
方法被调用。该方法有两个参数。第一个代表请求,第二个代表响应。request
可以用来收集请求中的所有信息。在前面的例子中,没有这样的。我们不使用任何输入。如果一个请求到达我们的 Servlet,那么不管发生什么,我们都会回答Hello, World
字符串。稍后,我们将看到从请求中读取参数的示例。response
给出了可以用来处理输出的方法。
在本例中,我们获取PrintWriter
,它将用于向 HTTP 响应的主体发送字符。这是显示在浏览器中的内容。我们发送的 MIME 类型是text/html
,这是通过调用setContentType
方法来设置的。这将进入 HTTP 头字段Content-Type
。这些类的标准和 JavaDoc 文档定义了可以使用的所有方法,以及应该如何使用这些方法。
最后,我们有一个web.xml
文件,它声明了代码中实现的 Servlet。正如文件名所示,这是一个 XML 文件。它声明性地定义了存档中包含的所有 Servlet 以及其他参数。在下面的示例中,没有定义参数,只有 Servlet 和到 URL 的映射。因为在这个例子中我们只有一个 Servlet,WAR 文件,所以它被映射到根上下文。到达 Servlet 容器和此存档的每个GET
请求都将由此 Servlet 提供服务:
<web-app version="2.5"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd">
<servlet>
<display-name>HelloWorldServlet</display-name>
<servlet-name>HelloWorldServlet</servlet-name>
<servlet-class>packt.java11.mastermind.servlet.HelloWorld</servlet-class>
</servlet>
<servlet>
<display-name>Mastermind</display-name>
<servlet-name>Mastermind</servlet-name>
<servlet-class>packt.java11.mastermind.servlet.Mastermind</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>HelloWorldServlet</servlet-name>
<url-pattern>/hello</url-pattern>
</servlet-mapping>
<servlet-mapping>
<servlet-name>Mastermind</servlet-name>
<url-pattern>/master</url-pattern>
</servlet-mapping>
</web-app>
我答应过你,我不会让你厌烦 JavaServerPages(JSP),因为这是过去的技术。尽管它已经成为过去,但它仍然不是历史,因为仍有许多运行的程序使用 JSP。
JSP 页面是包含 HTML 和 Java 代码组合的 Web 页面。当 JSP 页面提供 HTTP 请求时,Servlet 容器读取 JSP 页面,执行 Java 部分,将 HTML 部分保持原样,并以这种方式将两者混合在一起,创建一个发送到浏览器的 HTML 页面:
<%@ page language="java"
contentType="text/html; charset=UTF-8"
pageEncoding="UTF-8"%>
<html>
<body>
<% for( int i = 0 ; i < 5 ; i ++ ){ %>
hallo<br/>
<% } %>
</body>
</html>
前面的页面将创建一个 HTML 页面,其中包含五次文本hallo
,每一次都在一个新行中,由标记br
分隔。在幕后,Servlet 容器将 JSP 页转换为 JavaServlet,然后使用 Java 编译器编译 Servlet,然后运行 Servlet。每次对源 JSP 文件进行更改时,它都会这样做;因此,使用 JSP 以增量方式编写一些简单的代码非常容易。从前面的 JSP 文件生成的代码有 138 行长(在 tomcat8.5.5 版本上),这里列出的代码很长,也很无聊,但是帮助理解 Java 文件生成工作原理的部分只有几行。
如果您想查看生成的 Servlet 类的所有行,可以将应用部署到 Tomcat 服务器中,并查看work/Catalina/localhost/hello/org/apache/jsp/
目录,开发人员不知道这个代码实际上保存到磁盘上并且可用。当您需要调试一些 JSP 页面时,它偶尔会有所帮助。
下面是由前面的代码生成的几行有趣的代码:
out.write("n");
out.write("<html>n");
out.write("<body>n");
for( int i = 0 ; i < 5 ; i ++ ){
out.write("n");
out.write(" hallo<br/>n");
}
out.write("n");
out.write("</body>n");
out.write("</html>n");
JSP 编译器将 JSP 代码的内部向外移动,外部向内移动。在 JSP 代码中,Java 被 HTML 包围,在生成的 Servlet Java 源代码中,HTML 被 Java 包围。就像你要修补衣服一样:第一件事就是把衣服翻过来。
不仅可以将 Java 代码混合到 JSP 页面中的 HTML 中,还可以使用所谓的标记。标记被收集到标记库中,用 Java 实现,并打包到 JAR 文件中,它们应该在要使用的类路径上可用。使用特定库中标记的 JSP 页面应声明用途:
<%@ taglib prefix="c"
uri="http://java.sun.com/jsp/jstl/core" %>
这些标记看起来像 HTML 标记,但它们由 JSP 编译器处理,并由taglib
库中实现的代码执行。JSP 还可以引用 JSP 范围内可用的 Java 对象的值。为了在 HTML 页面中实现这一点,可以使用 JSP 表达式语言。
JSP 最初是为了方便 Web 应用的开发而创建的。其主要优势是开发的快速启动。开发过程中不需要繁琐的配置、设置和其他辅助任务,当 JSP 页面发生任何更改时,无需再次编译整个应用 Servlet 容器生成 Java 代码,将其编译到类文件中,将代码加载到内存中并执行。JSP 是 MicrosoftASP 页面的竞争对手,它将 HTML 和 VisualBasic 代码混合在一起。
随着应用开始扩展,使用 JSP 技术带来的问题比解决的问题还多。混合了业务逻辑和应用视图的代码,以及在浏览器中的呈现方式,变得杂乱无章。开发 JSP 需要前端技术知识。Java 开发人员应该了解一些前端技术,但很少是设计专家和 CSS 专家。现代的代码还包含 JavaScript,很多次嵌入到 HTML 页面中。毕竟,JSP 的最大优势在于它包含在服务器和客户端代码上运行的代码。开发人员多次遵循这种模式,因此看到一些包含 Java、HTML、CSS 和 JavaScript 的遗留代码都混合在 JSP 文件中,不要感到惊讶。由于 Java 和 JavaScript 有时在语法上是相似的,所以在服务器上执行什么和在客户端上执行什么并不明显。我甚至在 JSP 文件中看到过从 Java 代码创建 JavaScript 代码的代码。这是一个完全混合了不同的责任和混乱,几乎是不可能维持。这导致了到今天为止 JSP 的完全不受欢迎。
JSP 的贬损不是官方的。这是我的专家意见。您可能会遇到一些仍然热爱 JSP 的有经验的开发人员,并且您可能会发现自己正处于需要用 JSP 开发程序的项目中。那样做并不可耻。有些人为了钱做得更糟。
为了改善这种混乱的局面,有越来越多的技术主张将服务器代码和客户端功能分离。这些技术包括 Wicket、Vaadin、JSF 和不同的 Java 模板引擎,如 Freemarker、ApacheVelocity 和 Thymeleaf。当您从 Java 生成文本输出时,后一种技术也很有趣,即使代码与 Web 完全无关。
这些技术,加上规程,帮助控制了中型和大型 Web 项目的开发和维护成本,但是架构的基本问题仍然存在:没有明确的关注点分离。
今天,现代应用在不同的项目中实现 Web 应用的代码:一个用于客户端,使用 HTML、CSS 和 JavaScript,另一个用于在 Java 中实现服务器功能(或在其他方面,但我们在这里重点讨论 Java)。两者之间的通信是 REST 协议,我们将在后面的章节中介绍。
HTML、CSS 和 JavaScript 是客户端技术。这些对于 Web 应用非常重要,一个专业的 Java 开发人员应该对它们有所了解。如今,这两个领域的专家级开发人员被称为全栈开发人员,尽管我觉得这个名字有点误导。一定的理解是不可避免的。
HTML 是结构化文本的文本表示。文本以字符形式给出,就像在任何文本文件中一样。标记表示结构。开始标记以一个<
字符开始,然后是标记的名称,然后可选地是name="value"
属性,最后是结束符>
。结束标记以</
开始,然后是标记的名称,然后是>
。标记包含在层次结构中;因此,您不应该比稍后打开的标记更早关闭标记。首先,必须关闭上一个打开的标签,然后关闭下一个,依此类推。这样,HTML 中的任何实际标记都有一个级别,所有介于开始标记和结束标记之间的标记都在该标记之下。一些不能包含其他标记或文本的标记没有结束标记,它们自己独立存在。考虑以下示例:
<html>
<head>
<title>this is the title</title>
</head>
</html>
标签head
在html
下,title
在head
下。可以将其结构化为树,如下所示:
html
+ head
+ title
+ "this is the title"
浏览器以树形结构存储 HTML 文本,此树是网页文档的对象模型,因此命名为文档对象模型(DOM)树。
最初的 HTML 概念混合了格式和结构,即使使用当前版本的 HTML5,我们仍然有像b
、i
和tt
这样的标签,它们建议浏览器分别以粗体、斜体和电传显示开始和结束标签之间的文本。
正如代表超文本标记语言(HyperTextMarkupLanguage)的名称 HTML 所暗示的那样,文本可以以超链接的形式包含对其他网页的引用。这些链接被分配给使用a
标签(代表锚定)的文本或可能由不同字段组成的某个表单,当按下表单的提交按钮时,字段的内容将在POST
请求中发送给服务器。发送表单时,字段的内容以所谓的application/x-www-form-urlencoded
形式编码。
HTML 结构总是试图促进结构和格式的分离。为此,格式被移动到样式。层叠样式表(CSS)中定义的样式为格式化提供了比 HTML 更大的灵活性;CSS 的格式对格式化更有效。创建 CSS 的目的是使设计与文本结构分离。
如果我必须从这三个选项中选择一个,我会选择 CSS 作为对 Java 服务器端 Web 开发人员最不重要的选项,同时也是对用户最重要的选项(事情看起来应该不错)。
JavaScript 是客户端技术的第三大支柱。JavaScript 是一种由浏览器执行的全功能、解释性编程语言。它可以访问 DOM 树,并读取和修改它。修改 DOM 树时,浏览器会自动显示修改后的页面。可以计划和注册 JavaScript 函数,以便在事件发生时调用。例如,您可以注册一个函数,以便在文档完全加载、用户按下按钮、单击链接或将鼠标悬停在某个节上时调用。尽管 JavaScript 最初只用于在浏览器上创建有趣的动画,但今天,使用浏览器的功能对功能齐全的客户端进行编程是可能的,这也是标准做法。有很多用 JavaScript 编写的强大程序,甚至像 PC 仿真器这样的耗电应用。
最后,但并非最不重要的一点是,美国 Java 开发人员必须关注我前面描述的新 WebAssembly 技术。
在本书中,我们将重点介绍 Java,并尽可能多地使用演示技术所需的客户端技术。然而,作为一名 Java Web 开发人员专业人员,您还必须学习这些技术,至少在某种程度上,这样才能理解客户端可以做什么,并能够与负责前端技术的专业人员合作。
通过网络玩 Mastermind 游戏和以前有点不同。到目前为止,我们还没有任何用户交互,我们的类也相应地进行了设计。例如,我们可以向表中添加一个新的猜测,以及程序计算的部分匹配和完全匹配。现在,我们必须分开创建一个新的猜测,将其添加到游戏中,并设置完全匹配和部分匹配。这一次,我们必须首先显示表,用户必须计算并提供匹配数。
我们必须修改一些类才能做到这一点。我们需要在Game.java
中添加一个新方法:
public Row addGuess(Guess guess, int full, int partial) {
assertNotFinished();
final Row row = new Row(guess, full, partial);
table.addRow(row);
if (itWasAWinningGuess(full)) {
finished = true;
}
return row;
}
到目前为止,我们只有一种方法是添加一个新的猜测,由于程序知道了这个秘密,它立即计算出了full
和partial
的值。方法的名称可以是addNewGuess
,重载了原始方法,但这次,该方法不仅用于添加新的猜测,还用于添加旧的猜测以重建表。这是因为每次玩家给出下一个猜测的答案时,我们都会根据浏览器发送给服务器的信息来重建游戏的实际状态。游戏的状态存储在客户端中,并通过 HTTP 请求发送到服务器。
程序启动时,没有猜测。程序创建了一个,第一个。之后,当用户告诉程序完全匹配和部分匹配时,程序需要使用包含有Guess
对象和full
与partial
匹配值的Game
结构和Table
与Row
对象。这些已经可用了,但是当新的 HTTP 命中时,我们必须从某个地方获取它。编写 Servlet 时,我们必须将游戏的状态存储在某个地方,并在新的 HTTP 请求到达服务器时还原它。
存储状态可以在两个地方完成。我们将在代码中首先做的一个地方是客户端。当程序创建一个新的猜测时,它会将其添加到表中,并发送一个 HTML 页面,该页面不仅包含新的猜测,还包含所有以前的猜测以及用户为每一行提供的匹配值。要将数据发送到服务器,值存储在窗体的字段中。提交表单时,浏览器收集字段中的信息,根据字段内容创建编码字符串,并将内容放入POST
请求的主体中。
存储实际状态的另一种可能性是在服务器上。服务器可以存储游戏的状态,并且在创建新的猜测时可以重建结构。在这种情况下,问题是知道使用哪种游戏。如果状态存储在服务器上,那么它应该存储许多游戏,每个用户至少一个。用户可以同时使用应用。它并不一定意味着我们在上一章中所研究的内容具有很强的并发性。
即使用户不是在多个线程中同时服务的,也可能存在活动的游戏。可以有多个用户在玩多个游戏,在服务一个 HTTP 请求时,我们应该知道我们在服务哪个用户。
Servlet 维护可用于此目的的会话,我们将在下一节中看到。
决定在哪里存储应用的状态是一个重要的架构问题。在做决定时,你应该考虑可靠性,信任,安全性,这本身也取决于信任,性能,以及其他可能的因素。
当客户端从同一个浏览器向同一个 Servlet 发送请求时,这一系列请求属于一个会话。为了知道请求属于同一个会话,Servlet 容器自动向客户端发送一个名为JSESSIONID
的 Cookie,这个 Cookie 有一个长的、随机的、难以猜测的值(tkojxpz9qk9xo7124pvanc1z
,因为我在 Jetty 中运行应用)。Servlet 维护一个包含HttpSession
实例的会话存储。在JSESSIONID
Cookie 的值中传递的键字符串标识实例。当 HTTP 请求到达 Servlet 时,容器将会话附加到存储区中的请求对象。如果键没有会话,则创建一个会话,代码可以通过调用request.getSession()
方法访问会话对象。
HttpSession
对象可以存储属性。程序可以调用setAttribute(String,Object)
、getAttribute(String)
和removeAttribute(String)
方法来存储、检索或删除属性对象。每个属性都分配给一个String
,可以是任何Object
。
尽管会话属性存储本质上看起来像一个Map<String,?>
对象一样简单,但事实并非如此。当 Servlet 容器在集群或其他分布式环境中运行时,存储在会话中的值可以从一个节点移动到另一个节点。为此,值被序列化;因此,会话中存储的值应该是Serializable
。不这样做是一个非常常见的新手错误。在开发过程中,在简单的开发 Tomcat 或 Jetty 容器中执行代码实际上从来不会将会话序列化到磁盘,也不会从序列化的表单中加载它。这意味着使用setAttribute
设置的值将通过调用getAttribute
可用。当应用第一次安装在集群环境中时,我们就遇到了麻烦。一旦 HTTP 请求到达不同的节点,getAttribute
可能返回null
。方法setAttribute
在一个节点上被调用,并且在处理下一个请求的过程中,不同节点上的getAttribute
无法从节点之间共享的磁盘反序列化属性值。不幸的是,这通常是生产环境。
尽管目前会话只能可靠地存储实现Serializable
接口的类的对象,但是我们应该知道 Java 序列化在将来的某个时候会发生变化。序列化是一种低级功能,在创建 Java 时将其连接到一种语言并不是一个好的决定。至少现在看来不是这样。在 Servlet 标准和实现方面没有什么可怕的,它们将正确地处理这种情况。另一方面,在框架提供的代码之外的代码中使用序列化是违反直觉的。
作为一名开发人员,您还应该意识到,序列化和反序列化对象是一项耗费数个 CPU 周期的繁重操作。如果应用的结构仅使用服务于大多数 HTTP 请求的客户端状态的一部分,那么从序列化窗体在内存中创建整个状态,然后再次序列化它,这是对 CPU 的浪费。在这种情况下,更可取的做法是只在会话中存储一个键,并使用一些数据库(SQL 或 NoSQL)或其他服务来存储该键引用的实际数据。企业应用几乎完全使用这种结构。
首先,我们将通过在客户端上存储状态来开发代码。发送用户输入和新的完全匹配和部分匹配的数量所需的表单还包含用户当时给出的所有猜测和答案的所有以前的颜色。为此,我们创建一个新的辅助类来格式化 HTML 代码。这是在现代企业环境中使用模板、JSP 文件完成的,或者完全避免在企业环境中使用纯 REST 和单页应用。然而,在这里,我们将使用旧技术来演示在现代发动机罩下旋转的齿轮:
package packt.java11.mastermind.servlet;
import packt.java11.mastermind.Color;
import packt.java11.mastermind.Table;
import javax.inject.Inject;
import javax.inject.Named;
public class HtmlTools {
@Inject
Table table;
@Inject
@Named("nrColumns")
private int NR_COLUMNS;
public String tag(String tagName, String... attributes) {
StringBuilder sb = new StringBuilder();
sb.append("<").append((tagName));
for (int i = 0; i < attributes.length; i += 2) {
sb.append(" ").
append(attributes[i]).
append("=\"").
append(attributes[i + 1]).
append("\"");
}
sb.append(">");
return sb.toString();
}
public String inputBox(String name, String value) {
return tag("input", "type",
"text", "name", name, "value", value, "size", "1");
}
public String colorToHtml(Color color, int row, int column) {
return tag("div",
"class", "color" + color) +
tag("/div") +
tag("div",
"class", "spacer") +
tag("/div");
}
public String paramNameFull(int row) {
return "full" + row;
}
public String paramNamePartial(int row) {
return "partial" + row;
}
public String paramNameGuess(int row, int column) {
return "guess" + row + column;
}
public String tableToHtml() {
StringBuilder sb = new StringBuilder();
sb.append("<html><head>");
sb.append("<link rel=\"stylesheet\"")
.append(" type=\"text/css\" href=\"colors.css\">");
sb.append("<title>Mastermind guessing</title>");
sb.append("<body>");
sb.append(tag("form",
"method", "POST",
"action", "master"));
for (int row = 0; row < table.nrOfRows(); row++) {
for (int column = 0; column < NR_COLUMNS; column++) {
final String html =
colorToHtml(table.getColor(row, column),
row, column);
sb.append(html);
}
if (row < table.nrOfRows() - 1) {
sb.append("" + table.getFull(row));
sb.append(tag("div", "class", "spacer"))
.append(tag("/div"));
sb.append("" + table.getPartial(row));
} else {
sb.append(inputBox(paramNameFull(row), "" + table.getFull(row)));
sb.append(inputBox(paramNamePartial(row), "" + table.getPartial(row)));
}
sb.append("<p>");
}
return sb.toString();
}
}
除了@Inject
注解,其余代码都简单明了。我们将在不久的将来关注@Inject
。我们必须关注的是代码生成的 HTML 结构。生成的页面如下所示:
<html>
<head>
<link rel="stylesheet" type="text/css" href="colors.css">
<title>Mastermind guessing</title>
<body>
<form method="POST" action="master">
<input type="hidden" name="guess00" value="3">
<div class="color3"></div>
<div class="spacer"></div>
<input type="hidden" name="guess01" value="2">
<div class="color2"></div>
<div class="spacer"></div>
<input type="hidden" name="guess02" value="1">
<div class="color1"></div>
<div class="spacer"></div>
<input type="hidden" name="guess03" value="0">
<div class="color0"></div>
<div class="spacer"></div>
<input type="text"
name="full0" value="0" size="1">
<input type="text"
name="partial0" value="2" size="1">
<input type="hidden" name="guess10" value="5">
<div class="color5"></div>
...deleted content that just looks almost the same...
<input type="submit" value="submit">
</form>
</body>
</head>
</html>
表单包含div
标签中的颜色,还包含隐藏字段中颜色的字母。这些输入字段在表单提交时发送到服务器,就像其他任何字段一样,但它们不会出现在屏幕上,用户无法编辑它们。完全匹配和部分匹配显示在文本输入字段中。由于无法在 HTML 文本中显示Color
对象,因此我们使用LetteredColor
和LetteredColorFactory
,它们将单个字母指定给颜色。前六种颜色简单地编号为0
、1
、2
、3
、4
和5
。CSS 文件可以控制颜色在浏览器窗口中的显示方式。
您可能还记得,我们讨论了如何以及在何处实现单个颜色的显示。首先,我们创建了一个特殊的打印类,它将字母分配给已经存在的颜色,但这只能在非常有限的环境中使用(主要是单元测试)。现在,问题又来了。我们有字母颜色,但现在我们需要真正的颜色,因为这一次,我们有一个客户端显示,能够显示颜色。
现代网络技术的真正力量在这里闪耀。内容和格式可以相互分离。不同颜色的夹子在 HTML 中被列为div
标记。它们有一个格式化类,但实际的外观是在一个 CSS 文件中定义的,该文件只负责外观:
.color0 {
background: red;
width : 20px;
height: 20px;
float:left
}
.color1 {
background-color: green;
width : 20px;
height: 20px;
float:left
}
... .color2 to .color5 is deleted, content is the same except different colors ...
.spacer {
background-color: white;
width : 10px;
height: 20px;
float:left
}
Servlet 类非常简单,如以下代码所示:
package packt.java11.mastermind.servlet;
import com.google.inject.Guice;
import com.google.inject.Injector;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
public class Mastermind extends HttpServlet {
private static final Logger log =
LoggerFactory.getLogger(Mastermind.class);
public void doGet(HttpServletRequest request,
HttpServletResponse response)
throws ServletException, IOException {
doPost(request, response);
}
public void doPost(HttpServletRequest request,
HttpServletResponse response)
throws ServletException, IOException {
Injector injector =
Guice.createInjector(new MastermindModule());
MastermindHandler handler =
injector.getInstance(MastermindHandler.class);
handler.handle(request, response);
}
}
因为许多线程同时使用 Servlet,因此我们不能使用实例字段来保存一次命中的数据,Servlet 类只会创建一个新的MastermindHandler
类实例并调用其handle
方法。因为每个请求都有一个新的MastermindHandler
实例,所以它可以将对象存储在特定于请求的字段中。为了创建处理器,我们使用 Google 创建的 Guice 库。
我们已经讨论过依赖注入。处理器需要一个Table
对象来播放,一个ColorManager
对象来管理颜色,一个Guesser
对象来创建一个新的猜测,但是创建这些或者从某处获取一些预制的实例并不是处理器的核心功能。处理器必须做一件事来处理请求;执行此操作所需的实例应该从外部注入。这是由一个Guice
喷射器完成的。
要使用 Guice,我们必须在build.gradle
中列出依赖项中的库。文件的实际内容已经列在HelloWorld
Servlet 之前。
然后,我们必须创建一个injector
实例来执行注入。使用 Servlet 中的以下行创建注入器:
Injector injector = Guice.createInjector(new MastermindModule());
MastermindModule
的实例指定在何处注入什么。这实际上是一个 Java 格式的配置文件。其他依赖注入框架使用并继续使用 XML 和注解来描述注入绑定和注入内容,但是 Guice 只使用 Java 代码。以下是 DI 配置代码:
public class MastermindModule extends AbstractModule {
@Override
protected void configure() {
bind(int.class)
.annotatedWith(Names.named("nrColors"))
.toInstance(6);
bind(int.class)
.annotatedWith(Names.named("nrColumns"))
.toInstance(4);
bind(ColorFactory.class)
.to(LetteredColorFactory.class);
bind(Guesser.class)
.to(UniqueGuesser.class);
}
configure
方法中使用的方法是以 Fluent API 的方式创建的,这样方法就可以一个接一个地链接起来,这样代码就可以像英语句子一样阅读。有关 Fluent API 的详细介绍,请访问这个页面。例如,第一个配置行可以用英语读作:
“绑定到带有"nrColor"
值为 6 的@Name
的注解int
类”
MastermindHandler
类包含用@Inject
注解注解的字段:
@Inject
@Named("nrColors")
private int NR_COLORS;
@Inject
@Named("nrColumns")
private int NR_COLUMNS;
@Inject
private HtmlTools html;
@Inject
Table table;
@Inject
ColorManager manager;
@Inject
Guesser guesser;
此注解不是特定于 Guice 的。@Inject
是javax.inject
包的一部分,是 JDK 的标准部件。JDK 不提供依赖注入(DI)框架,但支持不同的框架,以便它们可以使用标准的 JDK 注解,如果 DI 框架被替换,注解可以保持不变,而不是特定于框架。
当调用注入器来创建一个MastermindHandler
实例时,它查看类,发现它有一个int
字段,用@Inject
和@Named("nrColors")
注解,并在配置中发现这样一个字段的值应该是 6。它在返回MastermindHandler
对象之前将值注入字段。类似地,它还将值注入其他字段,如果需要创建任何要注入的对象,它也会这样做。如果这些对象中有字段,那么它们也是通过注入其他对象等方式创建的。
这样,DI 框架就免除了程序员创建实例的负担。这是一件相当无聊的事情,而且无论如何也不是类的核心特性。相反,它创建了所有需要有一个函数式的MastermindHandler
的对象,并通过 Java 对象引用将它们链接在一起。这样,不同对象的依赖关系(MastermindHandler
需要Guesser
、ColorManager
、Table
;ColorManager
需要ColorFactory
、Table
也需要ColorManager
等等)就变成了一个声明,通过字段上的注解来指定。这些声明在类的代码中,是它们的正确位置。除了类本身之外,我们还能在哪里指定类需要什么才能正常运行呢?
我们示例中的配置指定,无论哪里需要ColorFactory
,我们都将使用LetteredColorFactory
,无论哪里需要Guesser
,我们都将使用UniqueGuesser
。这是从代码中分离出来的,必须是这样。如果我们想改变猜测策略,我们将替换配置,代码应该在不修改使用猜测器的类的情况下工作。
Guice 足够聪明,您不必指定任何需要Table
的地方,我们都将使用Table
——没有bind(Table.class).to(Table.class)
。首先,我在配置中创建了一行这样的代码,但是 Guice 给了我一条错误消息,现在,用纯英语再写一遍,我觉得自己真的很愚蠢。如果我需要一张桌子,我需要一张桌子。真的?
当使用 Java9 或更高版本并且我们的代码使用 JPMS 时,我们必须向我们使用的框架打开我们的代码库。模块不允许来自外部的代码使用反射操作私有类或对象成员。如果我们不在模块定义文件中声明我们想要使用 Guice,并且我们允许 Guice 访问私有字段,它将无法做到这一点,这样,它将无法工作。要将我们的模块打开到 Guice,我们必须编辑module_info.java
文件并插入opens
关键字,指定需要注入的类所在的包。
我们已经开始列出MastermindHandler
类,因为这个类有一百多行,所以我不把它作为一个整体包括在这里。这个类最重要的方法是handle
:
public void handle(HttpServletRequest request,
HttpServletResponse response)
throws ServletException, IOException {
Game game = buildGameFromRequest(request);
Guess newGuess = guesser.guess();
response.setContentType("text/html");
PrintWriter out = response.getWriter();
if (game.isFinished() || newGuess == Guess.none) {
displayGameOver(out);
} else {
log.debug("Adding new guess {} to the game", newGuess);
game.addGuess(newGuess, 0, 0);
displayGame(out);
}
bodyEnd(out);
}
我们执行三个步骤。第一步是创建表,我们从请求开始创建。如果这不是游戏的开始,那么就已经有了一个表,HTML 表单包含了所有先前的猜测颜色和这些颜色的答案。然后,作为第二步,我们在此基础上创建一个新的猜测。第 3 步是将新的 HTML 页面发送到客户端。
同样,这不是一种现代的方法,在 Servlet 代码上创建 HTML,但是用 REST、JSON 和 JavaScript 以及一些框架演示纯 Servlet 功能,仅此一章就有几百页的篇幅,而且它肯定会转移我们对 Java 的注意力。
在本书中,将 HTML 文本打印到PrintWriter
对您来说并不是什么新鲜事;因此,我们将不在这里列出这些代码。您可以从 Packt GitHub 存储库下载工作示例。我们将重点讨论 Servlet 参数处理,而不是打印。
请求参数可通过返回参数字符串值的getParameter()
方法获得。此方法假设任何参数,无论是GET
还是POST
,在请求中只出现一次。如果存在多次出现的参数,则该值应该是一个String
数组。在这种情况下,我们应该使用getParameterMap()
,它返回带有String
键和String[]
值的整个映射。即使我们这次没有任何键的多个值,并且我们也知道键的值作为POST
参数,我们仍然将使用后一种方法。这样做的原因是我们稍后将使用会话来存储这些值,并且我们希望在这种情况下有一个可重用的方法。
为了到达该阶段,我们将请求的Map<String,String[]>
转换为Map<String,String>
:
private Game buildGameFromRequest(HttpServletRequest request) {
return buildGameFromMap(toMap(request));
}
private Map<String, String> toMap(HttpServletRequest request) {
log.debug("converting request to map");
return request.getParameterMap().entrySet().
stream().collect(
Collectors.toMap(
Map.Entry::getKey,
e -> e.getValue()[0]));
}
然后,我们用那个映射来重现游戏:
private Game buildGameFromMap(Map<String, String> params) {
var secret = new Guess(new Color[NR_COLUMNS]);
var game = new Game(table, secret);
for (int row = 0;
params.containsKey(html.paramNameGuess(row, 0));
row++) {
Color[] colors = getRowColors(params, row);
Guess guess = new Guess(colors);
var full = Integer.parseInt(params.get(html.paramNameFull(row)));
var partial = Integer.parseInt(params.get(html.paramNamePartial(row)));
log.debug("Adding guess to game");
game.addGuess(guess, full, partial);
}
return game;
}
从String
到int
的转换是通过parseInt()
方法完成的。当输入不是数字时,此方法抛出NumberFormatException
。试着运行游戏,使用浏览器,看看当 Servlet 抛出异常时 Jetty 是如何处理的。你在浏览器中看到多少有价值的信息可以被潜在的黑客使用?修复代码,以便它再次询问用户是否有任何数字格式不正确!
应用状态通常不应保存在客户端上。除了编写教育代码并希望演示如何不这样做之外,可能还有一些特殊情况。通常,与实际使用相关的应用状态存储在会话对象或某个数据库中。当应用要求用户输入大量数据,并且不希望用户在客户端计算机出现故障时丢失工作时,这一点尤为重要。
你花了很多时间在网店里挑选合适的商品,选择合适的可以协同工作的商品,创建新模型飞机的配置,突然,家里停电了。如果状态存储在客户端上,则必须从头开始。如果该状态存储在服务器上,则该状态将保存到磁盘;服务器将被复制,由电池供电,当您重新启动客户端计算机时,电源将回到您的家中,您登录,奇迹般地,这些项目都在您的购物篮中。嗯,这不是奇迹,而是网络编程。
在我们的例子中,第二个版本将在会话中存储游戏的状态。这将允许用户恢复游戏,只要会话还在。如果用户退出并重新启动浏览器,会话将丢失,新游戏可以开始。
由于这次不需要在隐藏字段中发送实际的颜色和匹配,因此 HTML 生成会稍微修改,生成的 HTML 也会更简单:
<html>
<head>
<link rel="stylesheet" type="text/css" href="colors.css">
<title>Mastermind guessing</title>
<body>
<form method="POST" action="master">
<div class="color3"></div>
<div class="spacer"></div>
<div class="color2"></div>
<div class="spacer"></div>
<div class="color1"></div>
<div class="spacer"></div>
<div class="color0"></div>
<div class="spacer"></div>
0
<div class="spacer"></div>
2
<div class="color5"></div>
...
<div class="spacer"></div>
<div class="color1"></div>
<div class="spacer"></div>
<input type="text" name="full2" value="0" size="1">
<input type="text" name="partial2" value="0" size="1">
<input type="submit" value="submit">
</form></body></head></html>
完全匹配和部分匹配的颜色数显示为一个简单的数字,因此此版本不允许欺骗或修改以前的结果。(这些是 CSS 类spacer
的div
标记后面的数字0
和2
。)
MastermindHandler
中的handle
方法也发生了变化,如下代码所示:
public void handle(HttpServletRequest request,
HttpServletResponse response)
throws ServletException, IOException {
Game game = buildGameFromSessionAndRequest(request);
Guess newGuess = guesser.guess();
response.setContentType("text/html");
PrintWriter out = response.getWriter();
if (game.isFinished() || newGuess == Guess.none) {
displayGameOver(out);
} else {
log.debug("Adding new guess {} to the game", newGuess);
game.addGuess(newGuess, 0, 0);
sessionSaver.save(request.getSession()); // note the added line
displayGame(out);
}
bodyEnd(out);
}
变量sessionSaver
是一个类型为SessionSaver
的字段,它由 Guice 注入器注入到类中。SessionSaver
是我们创建的一个类。这个类将当前的Table
转换成存储在会话中的内容,并且它还根据存储在会话中的数据重新创建表。handle
方法使用buildGameFromSessionAndRequest
方法来恢复表,并添加用户刚刚在请求中给出的全部和部分匹配答案。当该方法创建新的猜测并将其填充到表中,并在响应中将其发送给客户端时,它通过sessionSaver
对象调用save()
方法来保存会话中的状态。
buildGameFromSessionAndRequest
方法取代了另一个版本,我们称之为buildGameFromRequest
:
private Game buildGameFromSessionAndRequest(HttpServletRequest request) {
var game = buildGameFromMap(sessionSaver.restore(request.getSession()));
var params = toMap(request);
int row = getLastRowIndex(params);
log.debug("last row is {}", row);
if (row >= 0) {
var full = Integer.parseInt(params.get(html.paramNameFull(row)));
var partial = Integer.parseInt(params.get(html.paramNamePartial(row)));
log.debug("setting full {} and partial {} for row {}", full, partial, row);
table.setPartial(row, partial);
table.setFull(row, full);
if (full == table.nrOfColumns()) {
game.setFinished();
}
}
return game;
}
请注意,这个版本与使用 JDK 中的Integer
类中的parseInt()
方法有相同的问题,该方法会引发异常。
此类有三个公共方法:
save()
:将表保存到用户会话restore()
:从用户会话中获取表reset()
:删除会话中可能存在的任何表
该类代码如下:
public class GameSessionSaver {
private static final String STATE_NAME = "GAME_STATE";
@Inject
private HtmlTools html;
@Inject
Table table;
@Inject
ColorManager manager;
public void save(HttpSession session) {
var params = convertTableToMap();
session.setAttribute(STATE_NAME, params);
}
public void reset(HttpSession session) {
session.removeAttribute(STATE_NAME);
}
public Map<String, String> restore(HttpSession session) {
return (Map<String, String>)
Optional.ofNullable(session.getAttribute(STATE_NAME))
.orElse(new HashMap<>());
}
private Map<String, String> convertTableToMap() {
var params = new HashMap<String, String>();
for (int row = 0; row < table.nrOfRows(); row++) {
for (int column = 0;
column < table.nrOfColumns();
column++) {
params.put(html.paramNameGuess(row, column),
table.getColor(row, column).toString());
}
params.put(html.paramNameFull(row),
"" + table.getFull(row));
params.put(html.paramNamePartial(row),
"" + table.getPartial(row));
}
return params;
}
}
当我们保存会话并将表转换为映射时,我们使用一个HashMap
。在这种情况下,实现是重要的。HashMap
类实现了Serializable
接口;因此,我们可以安全地将其放入会话中。仅此一点并不能保证HashMap
中的所有内容都是Serializable
。本例中的键和值是字符串,幸运的是,String
类还实现了Serializable
接口。这样,转换后的HashMap
对象可以安全地存储在会话中。
还要注意的是,尽管序列化可能很慢,但是在会话中存储HashMap
是如此频繁,以至于它实现了自己的序列化机制。此实现经过优化,避免了序列化依赖于映射的内部结构。
现在是时候想想为什么我们在这个类中有convertTableToMap()
方法,而在MastermindHandler
类中有buildGameFromMap()
方法了。将游戏和其中的表转换为一个Map
和另一个回合应该一起实现。它们只是同一转换的两个方向。另一方面,Table
到Map
方向的实现应该使用Map
版本,即Serializable
。这与会话处理密切相关。一般来说,将一个Map
对象转换为Table
对象要高一级,即从客户端、会话、数据库或云中存储表的任何位置恢复表。会话存储只是一种可能的实现,方法应该在满足抽象级别的类中实现。最好的解决方案是在一个单独的类中实现这些。你有作业!
reset()
方法未从处理器中使用。这是从Mastermind
类调用的,也就是说,Servlet 类,在我们启动游戏时重置游戏:
public void doGet(HttpServletRequest request,
HttpServletResponse response)
throws ServletException, IOException {
var sessionSaver = new GameSessionSaver();
sessionSaver.reset(request.getSession());
doPost(request, response);
}
如果没有这一点,在机器上玩一次游戏,每次我们想重新启动它时,只会显示完成的游戏,直到我们退出浏览器并重新启动它,或者明确删除浏览器高级菜单中某个地方的JSESSIONID
Cookie。调用reset
不会删除会话。会话保持不变,因此JSESSIONID
Cookie 的值也保持不变,但是游戏将从 Servlet 容器维护的会话对象中删除。
因为我们已经在 Gradle 构建中包含了 Jetty 插件,所以插件的目标是可用的。要启动 Jetty,只需键入以下内容:
gradle appRun
这将编译代码,构建 WAR 文件,并启动 JettyServlet 容器。为了帮助我们记住,它还会在命令行上打印以下内容:
Running at http://localhost:8080//hello
我们可以打开这个 URL,然后看到游戏的打开屏幕,其中的颜色是程序创建的第一个猜测:
现在,是时候找点乐子玩我们的游戏了,给程序答案。不要让代码变得简单!请参阅以下屏幕截图:
同时,如果您在控制台中输入gradle appRun
,您会看到代码正在打印日志消息,如下图所示:
这些打印输出通过我们代码中的记录器。在前面的章节中,我们使用System.out.println()
方法调用向控制台发送信息性消息。这是一种实践,在任何比 HelloWorld 更复杂的程序中都不应该遵循。
Java 有几种可用的日志框架,每种都有优点和缺点。java.util.logging
包中的 JDK 中内置了一个,并且System.Logger
和System.LoggerFinder
类中的System.getLogger()
方法支持对记录器的访问。尽管自从 JDK1.4 以来,java.util.logging
已经在 Java 中可用,但是很多程序使用其他日志解决方案。除了内置的日志记录之外,我们还要提到log4j
、slf4j
和 ApacheCommons 日志记录。在深入了解不同框架的细节之前,让我们先讨论一下为什么使用日志记录而不是仅仅打印到标准输出中是很重要的。
最重要的原因是可配置性和易用性。我们使用日志记录有关代码操作的信息。这不是应用的核心功能,但是不可避免地需要一个可以操作的程序。我们在日志中打印了一些信息,操作人员可以使用这些信息来识别环境问题。例如,当抛出一个IOException
并将其记录下来时,操作可能会查看日志并确定磁盘已满。他们可以删除文件,或者添加新磁盘并扩展分区。如果没有日志,唯一的信息就是程序无法运行。
这些日志也被多次用来搜寻虫子。有些 bug 在测试环境中没有表现出来,很难重现。在这种情况下,打印有关代码执行的详细信息的日志是查找某些错误的根本原因的唯一来源。
由于日志记录需要 CPU、IO 带宽和其他资源,因此应该仔细检查日志记录的内容和时间。这个检查和决策可以在编程过程中完成,事实上,如果我们使用System.out.println
进行日志记录,这是唯一的可能性。如果我们需要找到一个错误,我们应该记录很多。如果我们记录太多,系统的性能就会下降。结论是,我们应该只在需要时记录。如果系统中存在无法复制的 bug,开发人员会要求操作在短时间内打开调试日志记录。当使用System.out.println
时,无法打开和关闭日志的不同部分。当调试级别日志打开时,性能可能会下降一段时间,但与此同时,日志可用于分析。
同时,如果有一个小的(几百兆字节)日志文件而不是大量的 2GB 压缩日志文件来查找相关的日志行,那么当我们必须找到相关的日志行(并且您事先不知道哪些相关)时,分析就更简单了。
使用日志框架,可以定义标识日志消息源和日志级别的记录器。字符串通常标识记录器,通常使用从中创建日志消息的类的名称。这是一种常见的做法,不同的日志框架提供工厂类,这些工厂类获取类本身(而不是其名称)来获取记录器。
在不同的日志框架中,可能的日志级别略有不同,但最重要的级别如下:
FATAL
:当日志消息涉及阻止程序继续执行的错误时使用。ERROR
:当出现严重错误时使用,但程序仍然可以继续运行,即使很可能以有限的方式运行。WARNING
:当有一个条件不是直接的问题,但如果不注意可能会导致错误时使用;例如,程序识别出一个磁盘已接近满,一些数据库连接在限制内应答,但接近超时值,以及类似的情况。INFO
:用于创建关于正常操作的消息,这些消息可能对操作很有意义,而不是错误或警告。这些消息可能有助于操作调试操作环境设置。DEBUG
:用于记录程序的信息,这些信息(希望)足够详细,以在代码中找到错误。诀窍是,当我们将日志语句放入代码中时,我们不知道它可能是什么 bug。如果我们知道,最好是修一下。TRACE
:这是关于代码执行的更详细的信息。
日志框架通常使用配置文件进行配置。配置可能会限制日志记录,关闭某些级别。在正常的操作环境中,前三级通常是开启的,INFO
、DEBUG
和TRACE
在真正需要时开启。也可以只为某些记录器打开和关闭某些级别。如果我们知道错误肯定在GameSessionSaver
类中,那么我们可以为该类打开DEBUG
级别。
日志文件还可能包含我们没有直接编码的其他信息,打印到标准输出时会非常麻烦。通常,每条日志消息都包含创建消息的精确时间、记录器的名称,在许多情况下,还包含线程的标识符。想象一下,如果你被迫把所有这些都放到每一个参数中,你很可能很快就会写一些额外的类来做这件事。不要!它已经做了专业它是记录器框架。
记录器还可以配置为将消息发送到不同的位置。登录到控制台只是一种可能性。日志框架准备将消息发送到文件、数据库、Windows 事件记录器、SysLog 服务或任何其他目标。这种灵活性,即打印哪条消息、打印哪些额外信息以及打印到哪里,是通过按照单一责任原则将记录器框架执行的不同任务分为几个类来实现的。
记录器框架通常包含创建日志的记录器、格式化原始日志信息的消息格式器、经常添加诸如线程 ID 和时间戳等信息的记录器,以及将格式化消息附加到目标的附加程序。这些类实现了日志框架中定义的接口,除了书的大小之外,其他任何东西都无法阻止我们创建自己的格式化程序和附加程序。
配置日志时,将根据实现附加程序和格式化程序的类来配置附加程序和格式化程序。因此,当您想将一些日志发送到一个特殊的目的地时,您并不局限于框架作者提供的附加器。有许多针对不同日志框架的独立开源项目为不同的目标提供了附加器。
使用日志框架的第二个原因是性能。虽然在我们分析代码之前优化性能(过早优化)是不好的,但是使用一种已知速度慢的方法并在性能关键代码中插入几行代码,调用慢方法也不是真正专业的。以一种行业最佳实践的方式使用一个完善的、高度优化的框架应该是无可置疑的。
使用System.out.println()
将消息发送到流,并且仅在 IO 操作完成时返回。使用真实日志将信息处理到记录器,并允许记录器异步地进行日志记录,而不等待完成。
如果出现系统故障,日志信息可能会丢失,这确实是一个缺点,但考虑到这种情况很少发生以及性能的另一方面,这通常不是一个严重的问题。如果磁盘已满时缺少调试日志行,导致系统在任何情况下都不可用,我们会损失什么?
当出于法律原因必须保存有关系统事务的某些日志信息以便可以审核操作和实际事务时,此审核日志记录有一个例外。在这种情况下,以事务方式保存日志信息,使日志成为事务的一部分。因为这是一种完全不同的需求类型,审计日志记录通常不使用这些框架中的任何一个来完成。
而且,System.out.println()
是不同步的,因此,不同的线程可能只会使输出混乱。日志框架关注这个问题。
使用最广泛的日志框架是 Apache log4j。它目前有一个第二个版本,完全重写了第一个版本。它是非常多功能的,有许多附加程序和格式化程序。log4j 的配置可以是 XML 或属性文件格式,也可以通过 API 进行配置。
log4j 版本 1 的作者创建了一个新的日志框架-slf4j。这个日志库本质上是一个外观,可以与任何其他日志框架一起使用。因此,当您在开发的库中使用 slf4j,并且您的代码作为使用不同日志框架的依赖项添加到程序中时,很容易将 slf4j 配置为将日志发送到另一个框架的日志记录器。因此,日志将一起处理,而不是在单独的文件中,这对于降低操作成本是可取的。在开发库代码或使用 slf4j 的应用时,无需选择其他日志框架来创建 slf4j,它有自己的简单实现,称为 backlog。
ApacheCommons 日志记录也是一个有自己日志实现的立面,如果没有其他任何事情失败。与 slf4j 的主要区别在于它在配置和底层日志记录使用上更灵活,并且实现了一个运行时算法,以发现哪些日志框架可用,哪些日志框架将被使用。行业最佳实践表明,这种灵活性也具有更高的复杂性和成本,是不需要的。
自版本 9 以来的 Java 包括一个用于日志记录的外观实现。它的应用非常简单,我们可以预期日志框架将很快开始支持这个外观。事实上,该立面内置于 JDK 中有两个主要优点:
- 想要记录的库不再需要依赖于任何日志框架或日志外观。唯一的依赖关系是 JDK 日志外观,它无论如何都在那里。
- 记录自己的 JDK 库使用这个外观,因此,它们将与应用登录到同一个日志文件中。
如果我们使用 JDK 提供的日志外观,ColorManager
类的开头将更改为:
package packt.java11.mastermind;
import javax.inject.Inject;
import javax.inject.Named;
import javax.inject.Singleton;
import java.util.HashMap;
import java.util.Map;
import java.lang.System.Logger;
import static java.lang.System.Logger.Level.DEBUG;
@Singleton
public class ColorManager {
protected final int nrColors;
protected final Map<Color, Color> successor = new HashMap<>();
private Color first;
private final ColorFactory factory;
private static final Logger log
= System.getLogger(ColorManager.class.getName());
@Inject
public ColorManager(@Named("nrColors") int nrColors,
ColorFactory factory) {
log.log(DEBUG, "creating colorManager for {0} colors", nrColors);
this.nrColors = nrColors;
this.factory = factory;
createOrdering();
}
private Color[] createColors() {
var colors = new Color[nrColors];
for (int i = 0; i < colors.length; i++) {
colors[i] = factory.newColor();
}
return colors;
}
private void createOrdering() {
var colors = createColors();
first = colors[0];
for (int i = 0; i < nrColors - 1; i++) {
successor.put(colors[i], colors[i + 1]);
}
}
public Color firstColor() {
return first;
}
public boolean thereIsNextColor(Color color) {
return successor.containsKey(color);
}
public Color nextColor(Color color) {
return successor.get(color);
}
}
在这个版本中,我们不导入 slf4j 类。相反,我们导入java.lang.System.Logger
类。
注意,我们不需要导入系统类,因为来自java.lang
包的类是自动导入的。对于在System
类中嵌套的类,这是不正确的。
为了访问记录器,调用静态方法System.getLogger()
。此方法查找可用的实际记录器,并为作为参数传递的名称返回一个记录器。getLogger()
方法没有接受类作为参数的版本。如果我们想遵守约定,那么我们必须编写ColorManager.class.getName()
来获取类的名称,或者我们可以在那里将类的名称写成一个字符串。第二种方法的缺点是它不跟随类名的更改。智能 IDE,如 IntelliJ、Eclipse 或 Netbeans,会自动重命名对类的引用,但是当在字符串中使用类名时,它们会遇到困难。
System.Logger
接口没有声明方便方法error
、debug
、warning
等,这些方法是其他日志框架和外观所熟悉的。只有一个方法名为log()
,这个方法的第一个参数是我们发布的实际日志的级别。定义了八个级别-ALL
、TRACE
、DEBUG
、INFO
、WARNING
、ERROR,
和OFF
。创建日志消息时,我们应该使用中间六个级别中的一个。ALL
和OFF
仅传递给isLoggable()
方法。此方法可用于检查是否记录了实际日志记录级别。例如,如果级别设置为INFO
,则不打印用DEBUG
或TRACE
发送的消息。
实际实现由 JDK 使用服务加载器功能定位。日志实现必须位于通过某种实现提供java.lang.System.LoggerFinder
接口的模块中。换句话说,模块应该有一个实现LoggerFinder
接口的类,module-info.java
应该声明哪个类在使用代码:
provides java.lang.System.LoggerFinder with
packt.java11.MyLoggerFinder;
MyLoggerFinder
类必须用getLogger()
方法扩展LoggerFinder
抽象类。
日志的实践非常简单。如果您不想花太多时间尝试不同的日志记录解决方案,并且没有特定的需求,那么只需使用 slf4j,将 JAR 作为编译依赖项添加到依赖项列表中,并开始在源代码中使用日志记录。
由于日志记录不是特定于实例的,并且日志记录器实现线程安全,所以我们通常使用的日志对象存储在一个static
字段中,并且只要使用类,就使用它们,所以该字段也是final
。例如,使用 slf4j 外观,我们可以使用以下命令获取记录器:
private static final Logger log =
LoggerFactory.getLogger(MastermindHandler.class);
要获取记录器,使用记录器工厂,它只创建记录器或返回已经可用的记录器。
变量的名称通常是log
或logger,
,但如果您看到LOG
或LOGGER
,请不要感到惊讶。将变量名大写的原因是,某些静态代码分析检查器将static final
变量视为常量,因为它们实际上是常量,Java 社区的惯例是对这些变量使用大写名称。这是一个品味的问题;通常情况下,log
和logger
用小写。
为了创建日志项,trace()
、debug()
、info()
、warn()
和error()
方法创建了一条消息,其级别如名称所示。例如,考虑以下代码行:
log.debug("Adding new guess {} to the game", newGuess);
它创建一个调试消息。Slf4j 支持在字符串中使用{}
文本进行格式化。这样,就不需要从小部分追加字符串,而且如果实际的日志项没有发送到日志目标,则不会执行格式化。如果我们以任何形式使用String
连接来传递一个字符串作为参数,那么格式化就会发生,即使根据示例不需要调试日志记录。
日志记录方法的版本也只有两个参数,String
消息和Throwable
。在这种情况下,日志框架将负责异常的输出和栈跟踪。如果您在异常处理代码中记录了一些内容,请记录异常并让记录器格式化它。
我们讨论了 Servlet 技术,一些 JavaScript、HTML 和 CSS。在真正的专业环境中编程时,通常使用这些技术。然而,应用用户界面的创建并不总是基于这些技术。较旧的操作系统本机 GUI 应用以及 Swing、AWT 和 SWT 使用不同的方法来创建 UI。它们从程序代码构建面向用户的 UI,UI 构建为组件的层次结构。当 Web 编程开始时,Java 开发人员有过类似的技术经验,项目创建的框架试图隐藏 Web 技术层。
值得一提的一项技术是 GoogleWebToolkit,它用 Java 实现服务器和浏览器代码,但由于浏览器中没有实现 Java 环境,因此它将代码的客户端部分从 Java 传输(转换)到 JavaScript。该工具包的最新版本创建于两年前的 2014 年,此后,谷歌发布了其他类型的网络编程工具包,支持原生 JavaScript、HTML 和 CSS 客户端开发。
Vaadin 也是你可能会遇到的工具箱。它允许您在服务器上用 Java 编写 GUI 代码。它是建立在 GWT 之上的,有商业支持。如果有开发人员在 Java 开发 GUI 方面有经验,但在 Web 原生技术方面没有经验,并且应用不需要在客户端进行特殊的可用性调优,那么这可能是一个很好的选择。典型的企业内部网应用可以选择它作为一种技术。
JavaServer Faces(JSF)是一种技术,它试图将应用的客户端开发从提供可供使用的小部件的开发人员和服务器端卸载。它是几个 Java 规范请求(JSR)的集合,有几个实现。组件及其关系在 XML 文件中配置,服务器创建客户端本机代码。在这种技术中,没有从 Java 到 JavaScript 的转换。它更像是使用一组有限但庞大的小部件,只使用那些小部件,而放弃对 Web 浏览器的直接编程。但是,如果他们有经验和知识,他们可以用 HTML、CSS 和 JavaScript 创建新的小部件。
还有许多其他技术是为支持 Java 中的 Web 应用而开发的。大多数大公司提倡的现代方法是使用单独的工具集和方法开发服务器端和客户端,并使用 REST 通信将两者连接起来。
在本章中,您了解了 Web 编程的结构。如果不了解 TCP/IP 网络的基本知识,这是互联网的协议,这是不可能的。在此之上的应用级协议是 HTTP,目前处于非常新的版本 2.0 中,Servlet 标准版本 4.0 已经支持该协议。我们创建了一个版本的 Master 游戏,这次,可以真正使用浏览器播放,我们使用 Jetty 在开发环境中启动它。我们研究了如何存储游戏状态并实现了两个版本。最后,我们学习了日志的基本知识,并研究了其他技术。同时,我们还研究了 Google 的依赖注入实现 GUI,并研究了它在引擎盖下的工作原理,以及为什么和如何使用它。
在本章之后,您将能够开始用 Java 开发 Web 应用,并了解此类程序的架构。当您开始学习如何使用 Spring 框架来编写 Web 应用时,您将了解其中的秘密,Spring 框架隐藏了 Web 编程的许多复杂性。