Java-MVC-1-0-入门手册-全-

Java MVC 1.0 入门手册(全)

原文:Beginning Java MVC 1.0

协议:CC BY-NC-SA 4.0

一、关于 MVC:模型、视图、控制器

MVC 是一种软件设计模式。它描述了软件分成三个部分:

  • 模型:管理一个应用的数据。这要从狭义上来理解。当然,不平凡的应用的任何部分都以这样或那样的方式处理应用的数据,但是来自 MVC 的模型对应于对用户可见的数据项,并且可能受到用户交互的改变。该模型与数据呈现给用户或任何应用工作流的方式无关,因此可以说该模型是 MVC 应用的核心部分。毫不奇怪,开发模型是任何 MVC 软件项目的第一步。

  • 视图:描述数据和控制元素(输入、按钮、复选框、菜单等)向用户的呈现。视图可以提供不同的模式,如分页或非分页表、格式化列表或链接表等等。一个视图也可以使用不同的技术,比如安装在用户 PC 上的 GUI 组件、移动电话上的应用或者要在浏览器中查看的网页。

  • 控制器:处理用户输入并准备视图部件工作所需的数据集。虽然视图显示了模型项,但是视图永远不需要知道数据是如何存储的,以及如何从一些持久存储(数据库)中检索数据。这是管制员的责任。因为用户输入决定了应用下一步要做什么,所以控制器也包含应用逻辑。任何计算和数据转换都发生在 MVC 的控制部分。

例如,考虑一个读书俱乐部应用。在这种情况下,模型由书籍(包括租赁状态)、书籍存储位置(建筑物、房间或书架)和成员等元素组成。对于搜索应用模块,通常将书籍、用户等列表定义为模型值。

图书俱乐部应用的视图部分将包含显示图书、显示成员、显示图书位置、允许成员租借图书、添加俱乐部成员、显示图书和成员列表以及各种搜索功能等页面。从技术上讲,这通常与模板引擎密切相关,模板引擎定义模型元素的占位符、循环的快捷方式(表格和列表)以及其他视图元素,如菜单和按钮。

控制器处理用户输入的数据。例如,如果视图当前显示书籍的搜索页面,并且用户输入书籍的名称并点击搜索按钮,则控制器被告知哪个按钮被点击。然后,控制器读取请求参数(在本例中是书的名称)和一些可能的模型值(例如,用户名和用户是否登录),查询数据库,构建结果列表,根据该列表创建模型,最后决定接下来显示哪个视图页面。

在实现细节上存在一些混乱。这来自于视图元素和模型元素之间的数据流的技术细节。MVC 没有假设何时更新视图元素和模型元素,以及选择哪个过程来保持它们同步。这就是为什么对于 MVC,你会在文献中发现许多不同的图。

对于 Java MVC,我们可以把我们对 MVC 的想法缩小到以下—一个模型(存储在内存中)定义了应用的状态;视图显示模型值并将用户交互发送到控制器;控制器准备模型数据,处理用户输入并相应地改变模型值,然后决定接下来显示哪个视图页面。这种 MVC 模型如图 1-1 所示。

img/499016_1_En_1_Fig1_HTML.png

图 1-1

Java MVC 设计模式

MVC 的历史

MVC 的出现可以追溯到 20 世纪 70 年代。它作为一个编程概念被引入计算机语言 Smalltalk。那时,它还没有名字。直到 20 世纪 80 年代后期,MVC 这个名字才被明确使用。它出现在期刊物体技术杂志的一篇文章中。

MVC 稳步地变得越来越广泛,它的思想被如此广泛地采用,以至于从 MVC 演化出了变体。我们不会在本书中讨论这些变体,但有一个简短的列表包括:

  • PAC(表现-抽象-控制) 和 HMVC(分层 MVC) 这是 MVC 的一个变种,其中子模块有它们自己的类似 MVC 的结构,只是后来才由它们构造了一个视图页面。

  • MVA(模型-视图-适配器) 在这种模式中,视图和模型是分离的,只有控制器(在这种情况下称为适配器)在模型和视图之间起中介作用。视图不能直接访问模型值。

  • MVP(模型-视图-演示者) 在 MVP 中,视图包含通知控制器(在这种情况下称为演示者)视图相关数据变化的逻辑。然后,演示者执行一些活动,并最终回调视图,以便通知用户有关数据更改的信息。

  • MVVM(模型-视图-视图-模型) 在 MVVM 中,引入了一些自动化,将模型值转换成视图元素,反之亦然。

随着互联网的兴起,MVC 的真正威力在 20 世纪 90 年代显露出来。尽管一些技术细节发生了变化,例如数据流的确切技术特征和数据穿越层边界的时间点,但思想仍然是相同的:模型保存应用状态,视图呈现浏览器页面,控制器处理浏览器和模型之间的交互,并决定显示哪个视图页面。

发明了各种 MVC web 框架; https://en.wikipedia.org/wiki/Comparison\_of\_web\_frameworks 向您展示了一个全面的列表(在页面上再往下,MVC 功能也被列出)。

Web 应用中的 MVC

如果我们试图让 Web 应用以 MVC 方式工作,它们会受到一些限制。最重要的区别来自 HTTP 协议的无状态特性,它用于视图(浏览器窗口)和控制器(HTTP 服务器)之间的通信。事实上,web 应用框架处理 HTTP 协议的方式导致了不同 MVC 实现之间的决定性差异。

更详细地说,关于 web 应用的 MVC 的重要问题如下:

  • 会话:我们已经指出了 HTTP 的无状态本质。因此,如果浏览器发送一个请求,可能是因为用户在一个文本字段中输入了一些字符串,然后按了提交按钮,那么服务器如何知道是哪个用户在执行请求呢?这通常由会话来处理,会话由作为 cookie、请求或 POST 参数传输的会话 ID 来标识。会话由框架透明地处理,因此您不必从应用代码内部创建和维护会话。

  • 从视图中访问模型值:对于 web 应用,某种模板引擎通常会处理视图生成。在那里,我们可以使用类似于${user.firstName}的表达式来读取模型条目的内容。

  • 传输数据范围:如果从网页向服务器提交数据,我们基本上有两种选择。首先,可以传输完整的表格。第二,只有更改过的数据才能发送到服务器。后者减少了网络流量,但是需要一些脚本逻辑(JavaScript)来在网页上执行数据收集。

  • 更新视图:对于 web 应用,更新视图的方式至关重要。要么在控制器发出请求后加载整个页面,要么只将网页中实际需要更新的部分从服务器传输到浏览器。同样,后一种方法减少了网络流量。

从这几点可以看出,为 web 应用编写一个 MVC 框架并不是一件非常简单的任务。这也是为什么有大量不同的 MVC 框架可以用于 web 应用。在本书的其余部分,我将向您展示,如果您的 Java 平台需要 MVC 软件,为什么选择 Java MVC 并不是最糟糕的事情。

面向 Java 的 MVC

在 Java 生态系统中,一个名为 Struts 的框架在 2000 年左右进入了软件世界。它是一个面向 web 应用的 MVC 框架,集成了 Java EE/Jakarta EE 和 Tomcat(一个归结为 web 功能的服务器产品)。尽管它不是 Java EE/Jakarta EE 规范的一部分,但它已经被用于许多软件项目,并且仍在被使用。相反,Java EE/Jakarta EE 将 JSF (Java Server Faces)命名为专用 web 框架。与 MVC 相反,JSF 使用面向组件的方法来创建 web 应用。

JSF 为任何 Java EE/Jakarta EE 8 或更高版本的产品开箱即用。直到版本 7,如果你想使用 MVC,Struts 是你可以使用的突出框架之一。然而,为了让 Struts 工作,必须向应用添加一个外部库,而且 Struts 总感觉像是一个扩展,而不太像是与 Java EE/Jakarta EE 无缝集成的东西。

在 Java EE 8/Jakarta EE 8 中,MVC 世界以 Java MVC 规范的形式重新进入了游戏。在 Java EE/Jakarta EE 世界中,它仍然是一个二等公民,但是有理由支持 MVC 而不是 JSF。在本章的最后,我们将讨论 MVC 相对于其他框架如 JSF 的优缺点。

最后,Java MVC (JSR-371)

最新的 Java EE/Jakarta EE MVC 实现在名称 Java MVC 下运行,并由 JSR-371 管理。它是第一个适用于 Java EE/Jakarta EE 服务器版本 8 或更高版本的 MVC 框架。事实上,JSR 描述了一个界面。为了让 Java MVC 真正工作,您需要添加一个实现库。

Note

我们使用 Eclipse Krazo 作为 Java MVC 实现库。 https://projects.eclipse.org/proposals/eclipse-krazo

或者

https://projects.eclipse.org/projects/ee4j.krazo

我们稍后将看到如何为您的 web 应用安装 Eclipse Krazo。

Java MVC 是包含在 Java EE/Jakarta EE 中的 REST 技术 JAX-RS 的一个精简而巧妙的扩展。这种关系赋予 Java MVC 一种现代感,并允许一种简洁和高度综合的编程风格。

我们已经知道 MVC 允许一些关于实现细节的混乱。图 1-1 描述了 Java MVC 是如何很好地工作的:对浏览器窗口中第一页的请求路由到控制器,控制器准备模型值(有或没有查询一些后端的附加数据)。控制器然后决定接下来显示哪个视图页面(浏览器页面)(可能是登录页面)。视图可以访问模型值。通过用户输入并提交给控制器的数据集,控制器获取请求参数(例如,登录名和密码),可能查询后端(用户数据库),更新模型,最后选择新的视图页面(例如,成功认证后的欢迎页面)。

但是有一个额外的特性可以与 Java MVC 无缝集成。您可以决定让 web 应用的一部分使用 AJAX 进行更细粒度的前端-后端通信,而不是总是在每次 HTTP 请求后加载一个完整的新页面。因为我们在 Java EE/Jakarta EE 8(或更高版本)环境中使用 Java MVC,所以我们可以使用 JAX-RS 来实现开箱即用。

为什么选择 MVC

有这么多的 web 前端技术,很难决定在您的项目中使用哪一种。新的 Java MVC 当然是一个选择,它可能非常适合您的需求。为了帮助你做决定,这里列出了 Java MVC 的利与弊。

缺点:

  • MVC 似乎是一种老式的设计模式。虽然这是真的,但它也被证明对许多项目都很有效,Java MVC 允许开发人员混合使用更现代的 web 开发技术。

  • MVC 迫使开发人员了解 HTTP 的内部机制。MVC 据说也是一种基于动作的设计模式。web 环境中的动作意味着 HTTP 请求和响应。MVC 不像其他框架那样真正隐藏 HTTP 通信的内部。

  • MVC 不像其他框架那样引入双向数据绑定。对于双向数据绑定,前端输入字段的变化会立即反映在模型值的变化中。相反,在 MVC 控制器中,您必须显式地实现模型值的更新。

优点:

  • 因为与其他框架相比,它更接近 HTTP 通信的内部机制,尽管引入了一些复杂性,但这引入了更少的侵入性内存管理。看看 JSF,它为每个浏览器请求构建了一个完整的组件树(和组件数据树)。相比之下,MVC 应用可以用极小的内存占用来定制。

  • Java MVC 是 Java EE/Jakarta EE 8 规范的一部分。这有助于更可靠地处理维护。

  • 如果你习惯了 Struts 或者类似的前端框架,那么切换到 Java MVC 感觉比切换到其他前端设计模式的其他产品更自然。

你好世界在哪里?

在许多与软件相关的开发指导书中,您会在第一章中找到一个非常简单的“Hello World”示例。对于 Jakarta EE,这意味着我们必须提供一种快捷方式来执行以下操作:

  • 写一个简单的程序,比如输出字符串"Hello World"

  • 从字符串构建一个可部署的工件(例如一个.war文件)。

  • 运行 Jakarta EE 服务器。

  • 在服务器上部署应用(.war文件)。

  • 将客户端(例如,浏览器)连接到服务器。

  • 观察输出。

这是一大堆东西,所以与其构建一个快速而肮脏的设置来运行这样一个示例,我更愿意首先从总体上谈谈 Java/Jakarta Enterprise Edition(Java/Jakarta EE),然后讨论开发工作流,只有在这之后,才介绍一个简单的第一个项目。通过这种方式,我们可以确保您的第一个 Java MVC 应用被正确开发和运行。

如果您认为一个简单的 Hello World 示例会对您有所帮助,下面的段落将向您展示如何创建一个这样的示例。请注意,我们不会使用本书剩余部分中显示的开发过程——这只是一种简单、快速、可能不太干净的方法。您也可以安全地跳过这一部分,因为我们在第四章创建了一个合适的 Hello World 项目。

  1. 首先确保 OpenJDK 8 安装在你的电脑上。前往 https://jdk.java.net/java-se-ri/8-MR3 下载。在本节的其余部分,我们将 OpenJDK 8 文件夹称为OPENJDK8_DIR

  2. https://projects.eclipse.org/projects/ee4j.glassfish/downloads 下载并安装 GlassFish 5.1(选择“全概要”变种)。在本节的其余部分,我们将 GlassFish 安装文件夹称为GLASSFISH_INST_DIR

  3. GLASSFISH_INST_DIR/glassfish/config/asenv.conf (Linux)或GLASSFISH_INST_DIR/glassfish/config/asenv.bat (Windows)文件中,添加以下行:

         REM Windows:
         REM Note, if the OPENJDK8_DIR contains spaces, wrap it
         REM inside "..."
         set AS_JAVA=OPENJDK8_DIR

         # Linux:
         AS_JAVA="OPENJDK8_DIR"

必须用 OpenJDK 8 安装的安装文件夹替换OPENJDK8_DIR

  1. 启动 GlassFish 服务器:
         REM Windows:
         chdir GLASSFISH_INST_DIR
         bin\asadmin start-domain

         # Linux:
         cd GLASSFISH_INST_DIR
         bin/asadmin start-domain

您必须将GLASSFISH_INST_DIR替换为 GlassFish 的安装文件夹。

  1. 在文件系统的任意位置创建一个名为hello_world的文件夹。其内容必须是(说明如下):

  2. https://mvnrepository.com 中获取lib文件夹的罐子。在搜索字段中输入不带版本和.jar扩展名的每个名称,选择版本,然后获取 JAR 文件。

  3. Java 代码如下所示:

         build
           |- <empty>
         src
           |- java
           |    |- book
           |         |- javamvc
           |              |- helloworld
           |                   |- App.java
           |                   |- RootRedirector.java
           |                   |- HelloWorldController.java
           |- webapp
           |    |- META-INF
           |    |    |- MANIFEST.MF
           |    |- WEB-INF
           |         |- lib
           |         |    |- activation-1.1.jar
           |         |    |- javaee-api-8.0.jar
           |         |    |- javax.mail-1.6.0.jar
           |         |    |- javax.mvc-api-1.0.0.jar
           |         |    |- jstl-1.2.jar
           |         |    |- krazo-core-1.1.0-M1.jar
           |         |    |- krazo-jersey-1.1.0-M1.jar
           |         |- views
           |         |    |- greeting.jsp
           |         |    |- index.jsp
           |         |- beans.xml
           |         |- glassfish-web.xml
         make.bat
         make.sh

  1. 作为MANIFEST.MF,写下以下内容:
         // App.java:
         package book.javamvc.helloworld;

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

         @ApplicationPath("/mvc")
         public class App extends Application {
         }

         // RootRedirector.java
         package book.javamvc.helloworld;

         import javax.servlet.FilterChain;
         import javax.servlet.annotation.WebFilter;
         import javax.servlet.http.HttpFilter;
         import javax.servlet.http.HttpServletRequest;
         import javax.servlet.http.HttpServletResponse;
         import java.io.IOException;

         /**
          * Redirecting http://localhost:8080/HelloWorld/
          * This way we don't need a <welcome-file-list> in web.xml
          */
         @WebFilter(urlPatterns = "/")
         public class RootRedirector extends HttpFilter {
             @Override
             protected void doFilter(HttpServletRequest req,
                    HttpServletResponse res,
                   FilterChain chain) throws IOException {
                  res.sendRedirect("mvc/hello");
             }
         }

         // HelloWorldController.java
         package book.javamvc.helloworld;

         import javax.inject.Inject;
         import javax.mvc.Controller;
         import javax.mvc.Models;
         import javax.mvc.binding.MvcBinding;
         import javax.ws.rs.FormParam;
         import javax.ws.rs.GET;
         import javax.ws.rs.POST;
         import javax.ws.rs.Path;
         import javax.ws.rs.core.Response;

         @Path("/hello")
         @Controller
         public class HelloWorldController {
             @Inject
             private Models models;

             @GET
             public String showIndex() {
                 return "index.jsp";
             }

             @POST
             @Path("/greet")
             public Response greeting(@MvcBinding @FormParam("name")
                    String name) {
                 models.put("name", name);

                 return Response.ok("greeting.jsp").build();
             }
         }

  1. 视图文件如下所示:
         Manifest-Version: 1.0

         <%-- index.jsp --%>
         <%@ page contentType="text/html;charset=UTF-8"
             language="java" %>
         <%@ taglib prefix="c"
             uri="http://java.sun.com/jsp/jstl/core" %>
         <html>
           <head>
             <meta charset="UTF-8">
             <title>Hello World</title>
         </head>
         <body>
           <form method="post"
               action="${mvc.uriBuilder('HelloWorldController#
                      greeting').build()}">
             Enter your name: <input type="text" name="name"/>
             <input type="submit" value="Submit" />
           </form>
         </body>
         </html>

         <%-- greeting.jsp --%>
         <%@ page contentType="text/html;charset=UTF-8"
             language="java" %>
         <%@ taglib prefix="c"
             uri="http://java.sun.com/jsp/jstl/core" %>
         <html>
         <head>
             <meta charset="UTF-8">
             <title>Hello World</title>
         </head>
         <body>
           Hello ${name}
         </body>
         </html>

(删除换行和HelloWorldController#后的空格。)

  1. 作为beans.xml,创建一个空文件(尽管文件必须存在!).

  2. glassfish-web.xml的内容如下:

  3. 名为make.sh的 Linux 构建文件如下所示:

       <?xml version="1.0" encoding="UTF-8"?>
       <glassfish-web-app error-url="">
           <class-loader delegate="true"/>
       </glassfish-web-app>

         #!/bin/bash
         JAVA_HOME=/path/to/your/openjdk-8

         rm -rf build/*
         cp -a src/webapp/* build
         mkdir build/WEB-INF/classes

         $JAVA_HOME/bin/javac \
             -cp src/webapp/WEB-INF/lib/javaee-api-8.0.jar:
                 src/webapp/WEB-INF/lib/javax.mvc-api-1.0.0.jar \
             -d build/WEB-INF/classes \
             src/java/book/javamvc/helloworld/*

         cd build
         $JAVA_HOME/bin/jar cf ../HelloWorld.war *
         cd ..

(删除:后的换行符和空格。)

  1. Windows 构建文件make.bat如下所示:
         set JAVA_HOME=C:\dev\java-se-8u41-ri

         mkdir build
         CD build && RMDIR /S /Q .
         CD ..
         rmdir build

         xcopy src\webapp build /s /e /i
         mkdir build\WEB-INF\classes

         %JAVA_HOME%\bin\javac ^
             -cp src\webapp\WEB-INF\lib\javaee-api-8.0.jar;
                 src\webapp\WEB-INF\lib\javax.mvc-api-1.0.0.jar ^
             -d build\WEB-INF\classes ^

             src\java\book\javamvc\helloworld/*

         cd build
         %JAVA_HOME%\bin\jar cf ..\HelloWorld.war *
         cd ..

(删除;后的换行符和空格。)

要从控制台内部构建应用,进入hello_world文件夹并启动脚本:

# Linux
cd hello_world
./make.sh

rem Windows
chdir hello_world
make

除了一些可以安全忽略的 Windows 构建脚本的错误消息之外,您最终会在主文件夹中找到HelloWorld.war web 应用。在那里,您可以通过以下方式部署应用:

# Linux
GLASSFISH_INST_DIR/bin/asadmin deploy --force=true \
    HelloWorld.war

rem Windows
GLASSFISH_INST_DIR\bin\asadmin deploy --force=true ^
    HelloWorld.war

对于GLASSFISH_INST_DIR,您必须替换 GlassFish 安装文件夹。

要查看它的运行情况,请在浏览器的地址栏中输入以下 URL:

http://localhost:8080/HelloWorld

参见图 1-2 和 1-3 。

img/499016_1_En_1_Fig3_HTML.jpg

图 1-3

Hello World 响应页面

img/499016_1_En_1_Fig2_HTML.jpg

图 1-2

Hello World 起始页

练习

  • 练习 1: 确定 MVC 的三个组成元素。

  • 练习 2: 对或错:模型的职责是与企业信息系统(例如,数据库)对话。

  • 练习 3: 对或错:对于 MVC,将用户生成的数据传递给模型元素是自动完成的。

  • 练习 4: 对或错:视图可以读取和访问模型值。

  • 练习 5: 下列哪一项是正确的:(A)会话是模型对象,(B)会话是 HTTP 协议内部的属性,(C)您必须从应用代码内部创建和处理会话。

  • 练习 6: 从版本 7 开始,Java MVC 成为 Java EE/Jakarta EE 规范的一部分。

摘要

MVC 代表模型-视图-控制器,是一种软件设计模式。模型管理应用的数据(限于向用户显示的内容,并受用户更改的影响);视图表示图形用户界面(GUI);控制器准备模型,处理用户输入,并决定在视图中显示什么(显示哪个视图页面)。

MVC 起源于 20 世纪 70/80 年代的桌面应用,后来被用来处理 web 应用。

Java 企业应用的 MVC(Java EE/Jakarta EE)被称为 Java MVC ,由 JSR-371 处理。从版本 8 开始,Java MVC 成为 Java EE/Jakarta EE 规范的一部分。

为了使用 Java MVC,需要在应用中添加一个实现。Eclipse Krazo 就是这样一个实现。

Java MVC 有助于节省内存,但是开发人员在某种程度上必须了解 HTTP 协议的特征。用户会话由 cookie、请求或 POST 参数处理。会话由框架透明地处理。

在下一章,我们将更详细地讨论 Java MVC 与 Java EE/Jakarta EE 的关系。

二、先决条件:Jakarta EE/Java EE

不能在独立模式下运行 Java MVC。相反,它必须伴随着 Java 企业版服务器(Java EE 或 Jakarta EE)提供的基础设施。我们在本章中讨论这意味着什么。

Java 对于企业应用的本质

在企业环境中,像 Java 这样的编程语言和软件平台必须满足对企业运营很重要的几个需求。它必须能够连接到一个或多个数据库,可靠地与同一公司中其他基于 It 的系统或相关业务建立通信,并且必须足够强大,能够可靠地处理输入,基于输入和数据库数据执行计算,并向客户提供适当的输出。作为一个交叉关注点,安全性也起着重要的作用。需要建立一个身份验证过程,强制用户识别自己的身份,并且需要获得授权来限制特定用户可以访问的资源量。此外,出于技术维护和审计的目的,需要记录活动,平台应该能够为技术健全性检查和与性能相关的调查提供监控数据。

为了让所有这些以期望的方式工作,语言和平台必须相对于未来的变化和增强保持稳定。这必须以一种新的语言和平台版本可以被 IT 人员适当处理的方式发生。Java EE/Jakarta EE 遵循这一思路,因此在企业环境中非常有用。

Jakarta EE 8 服务器完全运行在 Java 上并依赖于 Java。Java 是在 1991 年发明的,但 1996 年 Sun Microsystems 首次公开发布了 1.0 版本。从那以后,Java 作为一种语言和一个运行时环境或平台扮演了一个重要的角色。Java 如此成功有几个原因:

  • 同一个 Java 程序可以在不同的操作系统上运行。

  • Java 运行在沙箱环境中。这提高了执行安全性。

  • Java 可以很容易地用自定义库进行扩展。

  • Java 语言的扩展非常缓慢。虽然缓慢的发展意味着最新版本中可能缺少新的有用的语言结构,但它有助于开发人员轻松跟踪新特性,并在长期运行的项目中彻底过渡到新的 Java 版本。此外,除了少数例外,Java 版本是向后兼容的。

  • Java 包含一个垃圾收集器,可以自动清理未使用的内存。

自 1998 年起,该平台被重新命名为 Java 2,并提供了不同的配置:

  • 在桌面上运行的标准版 J2SE。它被进一步分为用于运行 Java 的 JRE (Java 运行时环境)和用于编译和运行 Java 的 JDK (Java 开发工具包)。

  • 移动和嵌入式设备的微型版 J2ME。

  • 企业版 J2EE 为 J2SE 增加了企业功能。每个 J2EE 配置包括一个完整的 J2SE 安装。

出于营销目的,“2”在 2006 年被删除,配置被命名为 JSE(或 JDK,这是 JSE 加开发工具),JME 和 JEE 分别。2018 年,JEE 被转移到 Eclipse 基金会,并更名为 Jakarta EE。Java 语言从 Java 7 到 Java 8 发生了实质性的变化。我们将在解释和代码示例中使用 Java 8 的所有现代特性。

Java 当然会继续发展。虽然在撰写本书时 Jakarta EE 的最新版本是 8,底层 Java 标准版也是 8,但您可以下载的最新 JavaSE (JSE)版本是 13。我们不会在本书中讨论 JavaSE 版本 9 或更高版本。

虽然 Java standard edition JSE 版本 8 的知识被认为是本书的先决条件,但对于只部分熟悉 Java 8 的读者来说,在阅读后续章节之前,以下新特性是值得研究的:

  • 功能界面

  • λ演算(未命名函数)

  • 用于处理集合和地图的流 API

  • 新的日期和时间 API

我们将在本书的例子中适当的地方使用这些。

描述 Java EE/Jakarta EE 各部分的规范说明了每个部分能做什么以及如何做,并且它们跟踪新版本。Java EE/Jakarta EE 8 包含的子技术也用确切的版本号进行了详细描述。我们在这里列出了它们,并对每种技术的作用做了简短的描述。请注意,这个列表并不详尽——它没有包括一些更高级的 API,如果您查看官方文档,就可以了解这些 API。

  • Java MVC 1.0 - JSR-371: 这是我们在本书中主要关心的问题。

  • 企业 Java bean s EJB****——3.2 版:EJB 代表业务逻辑的入口点。每个 EJB 在整个 Jakarta EE 架构中扮演一个组件的角色,并负责一个专门的业务任务。EJB 允许开发人员添加安全性、事务性特性、与数据库通信的 JPA 特性以及 web 服务功能,它们也可以是消息传递的入口点。

  • Java Server Faces JSF****-2.3 版: JSF 是基于组件的专用主 web 前端技术,用于浏览器访问。使用 Java MVC 在某种程度上是一种替代方法,没有人阻止你自由地混合它们。JSF 通常通过 EJB 与业务逻辑进行通信。

  • RESTful Web-Services JAX-RS****-2.1 版: REST(表述性状态转移)是定义读写资源的原始 HTTP 协议。它最近在单页面 web 应用中获得了越来越多的关注,其中前端页面流完全由浏览器中运行的 JavaScript 处理。

  • JSON 处理 JSON-P-Version 1.1:JSON(JavaScript Object Notation)是一种精简的数据格式,如果浏览器中运行的 JavaScript 处理大量的表示逻辑,这种格式尤其有用。

  • JSON 绑定 JSON-B - Version 1.0: 这项技术简化了 JSON 数据和 Java 类之间的映射。

  • Web Sockets -版本 1.1: 提供 Web 客户端(浏览器)和 Jakarta EE 服务器之间的全双工通信。除了通过 HTTP 的“正常”访问,web 套接字还允许服务器向浏览器客户端发送消息!

  • JPA-2.2 版:Java 持久性 API 提供了对数据库的高级访问。

  • Java EE 安全 API****——1.0 版:Jakarta EE 8 之前不存在的新安全 API。它包括 HTTP 身份验证机制、用于验证用户凭证和组成员身份的身份存储抽象,以及以编程方式处理安全性的安全上下文 API。

  • Java 消息服务 JMS****——2.0 版:这是关于消息传递的,也就是说消息可以异步产生和消费。消息发送者产生并发布消息,并且可以立即继续其工作,即使消息稍后被消费。

  • Java 事务 API (JTA) -版本 1.2: JTA 确保将几个步骤组合成一个单元的流程可以作为一个整体提交或回滚。如果涉及分布式合作伙伴,这可能会变得棘手。JTA 在确保事务性方面帮助很大,即使对于更复杂的系统也是如此。

  • servlet****——4.0 版:servlet 是服务器-浏览器通信的底层技术。通常在项目开始时只需配置一次。我们在需要运行其他技术的地方描述 servlets。

  • 上下文和依赖注入 CDI-2.0 版: CDI 允许开发人员将上下文绑定到由专用生命周期管理的元素。此外,它将依赖关系注入到对象中,这简化了类的关联。我们将使用 CDI 将 JSF 元素连接到应用逻辑。

  • JavaMail-1.6 版:提供阅读和发送邮件的设施。这只是一个 API。对于实现,您可以使用 Oracle 的参考实现: https://javaee.github.io/javamail/

  • Bean 验证****——2.0 版:这允许开发人员限制方法调用参数,以符合某些值谓词。

  • 拦截器-1.2 版:拦截器允许你将方法调用封装到拦截器类的调用中。虽然这也可以通过编程方法调用来完成,但是拦截器允许开发人员以声明的方式来完成。您通常将拦截器用于横切关注点,如日志记录、安全问题、监控等。

  • Java Server Pages JSP****-2.3 版:JSP 可以用来建立服务器-浏览器通信中的页面流。JSP 是一项较老的技术,但是如果您愿意,仍然可以使用它。然而,你应该更喜欢 JSFs 而不是 JSP,在这本书里,我们不讨论 JSP。

  • JSP 标准标签库 JSTL -版本 1.2: 与 JSP 结合使用的用于页面元素的标签。

Java EE/Jakarta EE 运行在 Java Standard Edition (SE)之上,因此如果您为 Java EE/Jakarta EE 编程,您可以随时使用 Java SE 的任何类和接口。Java Standard Edition SE 中包含的一些技术在 Java Enterprise Edition 环境中扮演着重要的角色:

  • JDBC -版本 4.0: 一个用于数据库的访问 API。所有主要的数据库供应商都为他们的产品提供 JDBC 驱动程序。你可以使用它,但你不应该。请改用更高级的 JPA 技术。你偶尔会接触到,因为 JPA 在幕后使用 JDBC。

  • Java 命名和目录接口 JNDI: 在 Jakarta EE 8 环境中,对象会以一种相当松散的方式被其他对象访问。在现代企业版应用中,这通常通过 CDI,更准确地说,通过依赖注入来实现。然而,在幕后,一个由 JNDI 管理的查找服务发挥了作用。在过去,您必须直接使用 JNDI 接口以编程方式获取依赖对象。对于 Jakarta EE 8,您可以使用 JNDI,但通常您不必这样做。

  • Java API for XML Processing JAXP-版本 1.6: 一个通用的 XML 处理 API。可以通过 DOM(内存中的完整 XML 树)、SAX(基于事件的 XML 解析)或 StAX 来访问 XML 数据。这只是一个 API。通常您还必须添加一个实现,但是 Jakarta EE 服务器会自动为您添加。

  • 用于 XML StAX 的流 API 版本 1.0: 用于对 XML 数据的流访问。这里的流意味着您可以根据显式需求串行访问 XML 元素(拉解析)。

  • Java XML 绑定 JAXB-2.2 版: JAXB 将 XML 元素连接到 Java 类。

  • XML Web Services JAX-WS-Version 2.2:Web Services 使用 XML 作为消息格式来远程连接组件。

  • JMX -版本 2.0: JMX 是一种通信技术,可以用来监控正在运行的 Jakarta EE 应用的组件。哪些信息可用于 JMX 监控取决于服务器实现,但是您可以向自己的组件添加监控功能。

这些规范是由一个社区进程来处理的,如果供应商想要说他们的服务器产品符合 Jakarta EE 的某个版本(或其前身之一,JEE 或 J2EE),他们必须通过测试。如果你感兴趣,相应的在线资源提供了相关信息。首先,在你喜欢的搜索引擎中输入“java community process jcp”或“java eclipse ee.next working group”。

Java 企业版最初是由太阳微系统公司开发的,名为 J2EE。在 2006 年,命名和版本化模式被更改为 JEE,在 J2EE 版本 1.4 之后是 JEE 版本 5。从那时起,主要的更新发生了,版本 JEE 6,JEE 7 和 JEE 8 被释放。2010 年,太阳微系统公司被甲骨文公司收购,在甲骨文公司旗下,发布了 JEE 7 和 JEE 8 版本。2017 年,甲骨文公司向 Eclipse 基金会提交了 Java EE,名称改为 Jakarta EE 8。

截至 2020 年初,从 JEE 8 到 Jakarta EE 8 的过渡仍在进行中。所以取决于你什么时候读这本书,它仍然可能是关于 Jakarta EE 8 的在线研究,你必须查阅关于 JEE 8 和 Jakarta EE 8 的页面。这是你应该记住的事情。为了在本书中保持简单,我们将只讨论 Jakarta EE。

写这本书的时候,发布的 Jakarta EE 8 服务器还不多。基本上有以下几种:

  • 来自 Oracle Inc .的 GlassFish 服务器开源版。

  • WildFly 服务器,来自红帽

  • JBoss 企业应用平台,来自红帽

  • WebSphere Application Server Liberty,来自 IBM

  • 开放自由,来自 IBM

这些服务器有不同的许可模式。GlassFish、WildFly 和 Open Liberty 是免费的。这意味着您可以出于开发和生产目的免费使用它们。要运行 JBoss 企业应用平台,您需要订阅,尽管源代码是开放的。WebSphere Application Server Liberty 是专有的。

在本书中,我们将讨论在 GlassFish 服务器开源版本 5.1 中运行 Java MVC。由于 Jakarta EE 8 的性质,转换到其他服务器总是可能的,尽管您将不得不花费相当多的时间来改变管理工作流。

GlassFish,一个免费的 Java 服务器

有几个免费的 Java EE/Jakarta EE 服务器可以用于评估和开发。GlassFish 服务器是一个特别好的选择,尤其是对于学习目的,因为它是开源的。

获取 GlassFish

在撰写本书时,最新版本是 5.1,您可以从以下网址下载:

https://projects.eclipse.org/
      projects/ee4j.glassfish/downloads

选择“完整轮廓”变体。

Note

在这本书出版的时候,可能会有 GlassFish 的更高版本。您可以尝试 5.1 以上的版本,在本书中安装和使用它们可能不会有任何问题。但是为了避免任何问题,总是可以使用存档的 GlassFish 5.1 安装程序。

下载 ZIP 文件后,将其解压缩到文件系统中的任何位置。我们此后将安装文件夹称为GLASSFISH_INST_DIR。在启动 GlassFish 之前,您必须确保您的系统上安装了 Java 8 JDK。

Note

JDK 8 是 GlassFish 5.1 的一个要求。您不能使用更高版本,也不应该使用更低版本。

从以下链接之一获得 JDK(对于 www.oracle.com 变体,您必须获得商业项目的付费订阅):

https://www.oracle.com/java/technologies/javase/
      javase-jdk8-downloads.html
https://jdk.java.net/java-se-ri/8-MR3

jdk.java.net变体指向 OpenJDK 发行版。对于 Linux,您的发行版的软件包提供商很可能已经为您提供了一个预构建的 Java 安装包。

如果 JDK 8 不是你的系统默认设置,你可以通过在控制台窗口输入java -version来检查。您必须添加以下行

REM Windows:
REM Note, if the JDK_INST contains spaces, wrap it
REM inside "..."
set AS_JAVA=JDK_INST

# Linux:
AS_JAVA="JDK_INST"

GLASSFISH_INST_DIR/glassfish/config/asenv.conf (Linux)或者GLASSFISH_INST_DIR/glassfish/config/asenv.bat (Windows)文件里面,在这里你必须用 JDK 8 安装的安装文件夹替换JDK_INST

现在,您可以在控制台窗口中检查安装。将用户目录(当前目录)更改为 GlassFish 安装文件夹,然后使用asadmin启动服务器:

REM Windows:
chdir GLASSFISH_INST_DIR
bin\asadmin start-domain

# Linux:
cd GLASSFISH_INST_DIR
bin/asadmin start-domain

输出应该是这样的:

Waiting for domain1 to start .
Successfully started the domain : domain1
domain Location: [...]/glassfish/domains/domain1
Log File: [...]/glassfish/domains/domain1/logs/server.log
Admin Port: 4848
Command start-domain executed successfully.

您还可以检查指示的日志文件,以查看启动是否正常工作。您可以在http://localhost:4848打开浏览器,查看网络管理员是否有空(应该有空)。

一旦您确认服务器正确启动,如果您愿意,您可以停止它。为此,请输入以下内容:

REM Windows:
bin\asadmin stop-domain

# Linux:
bin/asadmin stop-domain

Note

在本章的其余部分,我们假设您输入了cd GLASSFISH_INST_DIR来切换到 GlassFish 安装目录。我也会停止区分 Windows 和 Linux,写bin/asadmin,在 Windows 上应该是bin\asadmin.bat

GlassFish 服务器有三个管理前端:

  • 外壳(或 windows 命令提示符)前端

  • 用于浏览器访问的 GUI 前端

  • 一个 REST HTTP 前端

GlassFish Shell 管理

shell 前端通过bin/asadmin脚本工作,您可以从 shell(或 windows 命令提示符)调用该脚本。这个命令极其强大;它包含数百个选项和子命令。我们没有在这里全部列出,所以要获得完整的在线列表,请在您最喜欢的搜索引擎中输入“oracle glassfish server 管理指南”。

首先,asadmin命令也提供了“帮助”功能。要查看它,请输入以下内容之一:

bin/asadmin help
bin/asadmin -?

其中第一个变体(help)开启了一个更传呼。要列出所有子命令,请输入以下内容:

# Note: server must be running!
bin/asadmin list-commands

要查看特定子命令的帮助,您可以编写以下内容之一:

bin/asadmin help <SUB-COMMAND>
bin/asadmin -? <SUB-COMMAND>

在这里用子命令的名称代替<SUB-COMMAND>

Note

为了使许多子命令正常运行,服务器也必须运行。在下面的讨论中,我们假设在您发出任何子命令之前,服务器已经启动。

还有一个多模式会话,在那里打开一个特殊的子外壳。在这个子 shell 中,您可以直接输入子命令,而无需在前面加上bin/asadmin。要启动多模式会话,请输入以下不带参数的内容:

bin/asadmin

您也可以使用multimode子命令启动多模式会话:

bin/asadmin multimode

该子命令允许一个可选的--file <FILE_NAME>参数,该参数使指定的文件作为子命令列表被读入,并按顺序执行:

bin/asadmin multimode --file commands_file.txt

文件路径相对于当前工作目录。在下面的段落中,我们展示了最有用的选项和子命令的列表。最有用的一般选项如表 2-1 所示。你在bin/asadmin --host 192.168.1.37 list-applications中添加它们。

表 2-1

常规选项

|

[计]选项

|

描述

|
| --- | --- |
| --host <HOST> | 指定运行服务器的主机。如果不指定,将使用localhost。 |
| --port <PORT> | 管理端口。默认为4848 |
| --user``<USER_NAME> | 使用指定的用户向服务器进行身份验证。如果您限制对asadmin实用程序的访问,请使用此选项。默认为admin用户。 |
| --passwordfile``<FILE_NAME> | 如果您限制了对asadmin实用程序的访问,并希望防止提示用户密码,您可以指定一个包含密码信息的文件。详见bin/asadmin   -?的输出。 |

关于可以添加到asadmin命令的选项的完整列表,参见bin/asadmin -?的输出。

表 2-2 给出了从服务器查询各类信息的子命令。您在bin/asadmin list-applications中输入它们(显然,如果您还没有安装任何应用,列表将是空的)。

表 2-2

查询信息

|

子命令

|

描述

|
| --- | --- |
| version | 输出 GlassFish 服务器版本。 |
| list-applications | 列出服务器上部署和运行的所有应用。 |
| list-containers | 容器包含某种类型的组件(模块,如果你喜欢的话)。使用此子命令列出服务器中运行的所有容器。 |
| list-modules | 列出服务器中运行的所有 OSGi 模块。我们不会在这本书里讨论 OSGi,但是如果你感兴趣的话,GlassFish 集成了一个 Apache Felix OSGi 模块管理系统。您还可以通过名为“Gogo”的 OSGi shell 来管理 GlassFish 组件,这需要更多的配置工作才能运行。 |
| list-commands | 列出所有子命令。如果添加--localonly,服务器不一定要运行,只会列出可以在服务器不运行时发出的子命令。 |
| list-timers | 显示所有计时器。在这本书里我们不谈论计时器。 |
| list-domains | 列出所有域。在本书中,我们将使用预先安装的默认域,名为domain1,因此这将是这里显示的唯一条目。 |

在您执行 GlassFish 服务器的安装之后,将会有一个名为admin的管理用户,没有密码。没有密码使管理任务变得容易,但也会使您的服务器不安全。要解决这个问题并给用户admin一个密码,请输入以下内容:

bin/asadmin change-admin-password

然后你会被要求输入真正的密码,密码是空的,所以只需按回车键。然后输入新密码两次。

一旦admin用户有了密码,您就必须输入大多数asadmin子命令的密码。

启动域意味着启动 GlassFish 服务器。我们可以在一个 GlassFish 服务器中有几个域,但是多域设置留给了高级用户,所以我们将使用默认安装的单个domain1域。

要启动、停止或重新启动 GlassFish 服务器,请输入以下命令之一:

bin/asadmin start-domain
bin/asadmin stop-domain
bin/asadmin restart-domain

所有三个子命令都将可选的域名作为参数(例如,domain1domain2),但是因为我们只有一个默认域,所以这里可以省略它。

要查看服务器的正常运行时间,即自默认域启动以来经过的时间,请输入以下内容:

bin/asadmin uptime

Jakarta EE GlassFish 服务器带有内置数据库。这对于开发来说很方便,尽管您可能不会将该数据库用于生产设置。这个数据库是一个 Apache Derby 数据库。当 GlassFish 服务器启动时,它默认不运行。相反,要启动和停止数据库,请输入以下内容:

bin/asadmin start-database
bin/asadmin stop-database

其中数据库端口默认为1527

GlassFish GUI 管理

启动 GlassFish 服务器后,会提供一个 GUI 控制台,您应该使用它在浏览器中打开以下 URL:

http://localhost:4848

然后 GUI 将显示出来,如图 2-1 所示。

img/499016_1_En_2_Fig1_HTML.jpg

图 2-1

浏览器 GUI 管理

这里我们不讨论 GUI 管理的细节。然而,我们将在本书中偶尔使用和描述它,右上角的帮助按钮是您自己进行实验和研究的良好起点。

Note

您可以在终端中输入的许多操作在管理 GUI 中都有对应的内容。

GlassFish REST 接口管理

GlassFish Jakarta EE 8 服务器提供了一个 REST 接口,您可以使用它来调查和控制服务器。例如,您可以发出以下命令通过 REST 查看域日志:

curl -X GET -H "Accept: application/json" \
http://localhost:4848/monitoring/domain/view-log/details

Note

要实现这一点,必须在您的系统上安装curl实用程序。或者,您可以使用任何其他 REST 客户端(Firefox REST-client 插件、Eclipse 的 REST 客户端等等)。

我们研究了几个例子。要找到关于这个接口的更深入的信息,请在您喜欢的搜索引擎中输入“rest interface administer glassfish”。此外,我们使用jq工具提供生成的 JSON 数据的漂亮的格式输出。对于jq,有 Linux 和 Windows 的安装程序。

管理 REST 界面分为两部分,分别用于配置和监控:

http://host:port/management/domain/[path]
http://host:port/monitoring/domain/[path]

对于普通的 GlassFish 安装,主机是localhost,端口是4848。对于[path],您必须替换一个资源标识符。例如,要查看日志条目,请输入以下内容:

curl -X GET -H "Accept: application/json" \
http://localhost:4848/management/domain/view-log

(如果在一行中输入,请删除反斜杠。)

REST 接口非常广泛。您可以使用 REST 的GET动词查询许多属性,并且可以使用POSTPUT改变资源。首先,您可以研究 REST 功能的详细输出,一旦您输入以下内容,就会得到这些输出:

curl -X GET -H "Accept: application/json" \
http://localhost:4848/management/domain

例如,输出将包括以下内容:

"commands": [
  ...
  {
    "path": "list-commands",
    "method": "GET",
    "command": "list-commands"
  },
  {
    "path": "restart-domain",
    "method": "POST",
    "command": "restart-domain"
  },
  {
    "path": "uptime",
    "method": "GET",
    "command": "uptime"
  },
  {
    "path": "version",
    "method": "GET",
    "command": "version"
  }
  ...
]

还有很多其他的。要查看版本和正常运行时间,请输入以下内容:

curl -X GET -H "Accept: application/json" \
   http://localhost:4848/management/domain/version | jq .
curl -X GET -H "Accept: application/json" \
   http://localhost:4848/management/domain/uptime | jq .

如果您使用浏览器并在那里输入 REST URLs,您将获得更多关于 REST 资源的信息。如果您打开浏览器并输入http://localhost:4848/management/domain/version,您将得到这个 CURL 输出的 HTML 变体。两者都告诉我们关于孩子的资源。

例如,这段代码向我们展示了与已安装的应用相关的命令:

curl -X GET -H "Accept: application/json" \
  http://localhost:4848/management/domain/applications |
jq .

它告诉我们,对于实际的列表,我们必须输入以下内容:

curl -X GET -H "Accept: application/json" \
  http://localhost:4848/management/domain/applications/ list-applications |
 jq .

(在applications/之后没有换行。)它告诉我们属性。为了获得更详细的输出,我们可以添加一个?long=true,如:

curl -X GET -H "Accept: application/json" \
  http://localhost:4848/management/domain/applications/
  list-applications?long=true | jq .

使用预装的 Java 服务器

Java MVC 应用通常驻留在 WAR 文件(以.war结尾的 ZIP 文件)中,因此它们可以安装在任何 Jakarta EE 兼容的服务器上。

因此,您不必使用 GlassFish。在本书中,我们将使用 GlassFish,但是如果您喜欢不同的 Jakarta EE 8 服务器,您可以使用它。当然,您必须通过查阅手册来学习如何管理该服务器。

Note

如果您的目标是专有服务器,通常不建议从不同供应商的不同产品开始开发。您至少应该尝试使用同一服务器的免费版本进行开发,或者尝试获得开发人员许可证。不过,要学习 Jakarta EE 8,首先使用 GlassFish,然后切换到不同的产品或供应商是一种合理的方法。

学习面向企业应用的 Java

为了学习 Java 语言(或标准版 API)或提高技能,您可以在大量的书籍和在线资源中进行选择。一个很好的起点是 Oracle 的官方 Java 教程,可以在

https://docs.oracle.com/javase/tutorial/

现实世界中的公司项目可能需要您查看 Java EE/Jakarta EE 技术栈中的其他技术。还有一个企业版 Java EE/Jakarta EE 的教程,您可以在以下网址找到:

https://javaee.github.io/tutorial/toc.html

你可能还想参考同一作者的书入门 Jakarta EE:企业版 Java:从新手到专业人士 (ISBN: 978-1484250785)。在这里,我们主要讨论 Java MVC,并且只在适当和需要的地方处理其他 Java EE/Jakarta EE 技术。

RESTful 服务

有一个很好的理由来简要地谈谈 JAX-RS,尽管它是本书范围局限于 Java MVC 的一个例外。JAX-RS 是 Java EE/Jakarta EE 处理 RESTful 服务的子技术。事实上,Java MVC 位于 JAX-RS 之上,这是框架程序员的聪明决定。它不仅允许开发人员让 Java MVC 非常干净地与 Java EE/Jakarta EE 框架的其余部分集成,还提供了一个简单的线索,说明如何使用 AJAX 和 JSON 数据片段混合 Java MVC 开发技术和更细粒度的客户端-服务器通信。

REST 是表述性状态转移的缩写。它是 web 相关操作的一种架构风格。客户端使用一组预定义的数据操作或 HTTP 方法— GETPOSTPUTDELETE(以及更多)与服务器通信。由于不涉及任何状态,客户端使用动词GETDELETEPOSTPUT等中的一个进行通信,并且在服务器执行完操作和/或返回数据后,服务器会立即忘记通信步骤。“表述性状态转移”这个名称源于这样一个事实:从客户端的角度来看,从服务器查询的数据的表示在通信步骤之间是变化的(或者可能会变化)。

自从 web 诞生以来,通信动词就一直是 HTTP 规范的一部分。更详细地说,我们有以下动词:

  • GET:用于检索资源。资源是由 URIs 标识的,所以通信可以用类似于GET http://some.server.com/myclub/member/37 的东西来描述。一个GET操作不允许改变任何数据(除了访问统计之类的),而且必须是幂等的。这意味着第二个GET使用相同的 URI,在这两个GET之间没有中间操作,必须返回完全相同的数据。注意GET操作被广泛滥用于任何类型的操作,包括更改数据。有了休息,我们回到了根本,数据不能改变。

  • DELETE:用于删除信息。同样,所讨论的资源由一个 URI 寻址,所以你写DELETE http://some.server.com/myclub/member/37 。A DELETE必须是幂等的,这意味着使用相同的 URI 再次删除不能改变数据。在这种情况下,第二个DELETE当然是多余的;删除已经删除的内容不应该做任何事情。作为 REST 关于第二个DELETE的特性,服务器不能返回错误消息,而是忽略请求。

  • POST:用于发布新信息。用户提交表单时通常会出现这种情况。POST s 不是等幂的,所以使用相同数据的第二个 post 将导致服务器端的第二个数据集。一个帖子可以用POST http://some.server.com/mycl ub/member/37 [data]来描述,其中[data]代表传输的数据,通常以 XML 或 JSON 的形式在传输的消息体中传递。

  • PUT:用于存储数据。如果数据描述的资源已经存在,资源将根据数据进行更改。如果它不存在,服务器可能会决定像指定了一个POST一样工作。PUT是等幂的,PUT ting 再次使用相同的输入数据不会改变服务器上的数据。

其他动词在实际应用中不太常用。HEAD用于检索关于资源的元数据(关于资源的信息,但不是资源本身)。使用一个TRACE,您可以看到数据在到达服务器的途中发生了什么。这更多的是一种技术操作,并不特别关注数据负载。一个PATCH就像一个PUT有部分数据。拥有完整信息的PUTPATCH更常用。OPTIONS动词请求服务器拥有专用资源的能力(比如告诉服务器可以用资源做什么)。一个CONNECT用于在服务器端建立透明隧道。同样,这更多的是一种技术设施,并不透露任何有关传输数据。

要定义 REST 端点,您需要编写一个 Java 类,并在类和/或方法级别添加注释javax.ws.rs.Path。例如,考虑一个以 JSON 形式返回当前日期和时间的 REST 控制器:

package book.javavmc.restdate;

import java.time.ZonedDateTime;
import javax.ws.rs.*;

/**
 * REST Web Service
 */
@Path("/d")
public class RestDate {
    @GET
    @Path("date")
    @Produces("application/json")
    public String stdDate() {
        return "{\"date\":\"" + ZonedDateTime.now().toString() +
        "\"}";
    }
}

@Path注释合并,所以最后,我们得到一个端点 URL,比如http://localhost:8080/theAppName/d/date

您将很快开始开发您的第一个 Java MVC 应用。这就是为什么我向您展示第一个代码片段,而没有解释如何构建和部署它。Java MVC 控制器看起来非常相似:

package book.javavmc.somecontroller;

import java.util.List;
import javax.inject.Inject;
import javax.mvc.Controller;
import javax.mvc.Models;
import javax.ws.rs.*;

@Path("/pets")
@Controller
public class PetshopController {
    @Inject
    private Models models;

    @GET
    public String showIndex() {
         final List<Pet> pets = ...;
         models.put("pets", pets);
         return "index.jsp";
    }
}

你可以看到我们再次使用javax.ws.rs.Path来定义一个端点。我们稍后将看到 Java MVC 和 JAX-RS 之间的主要区别是@Controller注释,以及 action 方法返回下一个视图页面的名称而不是数据。

Note

如果在您最喜欢的搜索引擎中输入“jax-rs ”,您将会找到更多关于 JAX-RS 的在线信息,包括官方规范。

练习

  • 练习 1: 描述 JSE 和 Java EE/Jakarta EE 的关系。

  • 练习 2: 对还是错?Java MVC 可以直接在 PC 或服务器的操作系统中运行。

  • 练习 3: 对还是错?Java MVC 是一个 Jakarta EE 服务器。

  • 练习 4: 对还是错?Jakarta EE 是 Java EE 的竞争对手。

  • 练习 5: 对还是错?OpenJDK 8 和甲骨文的 JSE 8 没有区别。

  • 练习 6: 对还是错?GlassFish 可用于商业产品,无需支付许可费用。

  • 练习 7: 为什么我们在本书中使用 GlassFish?

  • 练习 8: 对还是错?清除是一个 HTTP 动词。

  • 练习 9: 描述 Java MVC 和 JAX-RS 的关系。

摘要

Java MVC 伴随着 Java 企业版服务器(Java EE 或 Jakarta EE)提供的基础设施。在企业环境中,像 Java 这样的编程语言和软件平台必须满足对企业运营很重要的几个需求。它必须能够连接到一个或多个数据库,可靠地与同一公司中其他基于 It 的系统或相关业务建立通信,并且必须足够强大,能够可靠地处理输入,基于输入和数据库数据执行计算,并向客户提供适当的输出。

Jakarta EE 8 服务器运行在 Java 上并依赖于 Java。Java 如此成功有几个原因:

  • 同一个 Java 程序可以在不同的操作系统上运行。

  • Java 运行在沙箱环境中。这提高了执行安全性。

  • Java 可以很容易地用自定义库进行扩展。

  • Java 语言的扩展非常缓慢。虽然缓慢的发展意味着最新版本中可能缺少新的有用的语言结构,但它有助于开发人员轻松跟踪新特性,并在长期运行的项目中彻底过渡到新的 Java 版本。此外,除了少数例外,Java 版本是向后兼容的。

  • Java 包含一个垃圾收集器,可以自动清理未使用的内存。

Java 还在继续发展。虽然在写这本书时 Jakarta EE 的最新版本是 8,底层 Java 标准版也是 8,但您可以下载的最新 JavaSE (JSE)版本是 13。我们不会在本书中讨论 JavaSE 版本 9 或更高版本。

描述 Java EE/Jakarta EE 各部分的规范说明了每个部分能做什么以及如何做,并且它们跟踪新版本。Java EE/Jakarta EE 8 包括子技术,这些子技术也通过确切的版本号进行了详细描述。这些规范是由一个社区进程来处理的,如果供应商想要说他们的服务器产品符合 Jakarta EE 的某个版本(或其前身之一,JEE 或 J2EE),他们必须通过测试。

Java 企业版最初是由太阳微系统公司开发的,名为 J2EE。在 2006 年,命名和版本化模式被更改为 JEE,在 J2EE 版本 1.4 之后是 JEE 版本 5。从那以后,主要的更新发生了,版本 JEE 6,JEE 7 和 JEE 8 都发布了。2010 年,太阳微系统公司被甲骨文公司收购,在甲骨文公司旗下,发布了 JEE 7 和 JEE 8 版本。2017 年,甲骨文公司向 Eclipse 基金会提交了 Java EE,名称改为 Jakarta EE 8。

在本书中,我们将讨论在 GlassFish 服务器开源版本 5.1 中运行 Java MVC。由于 Jakarta EE 8 的性质,虽然您必须花费大量时间来更改管理工作流,但是转换到其他服务器总是可能的。GlassFish 提供了三个管理界面——用于 shell 或控制台的命令行工具、web 管理员 GUI 和管理 REST 界面。

Java MVC 位于 JAX-RS 之上,这是框架程序员的聪明决定。它不仅允许 Java MVC 非常干净地与 Java EE/Jakarta EE 框架的其余部分集成,还提供了一个简单的线索,说明如何使用 AJAX 和 JSON 数据片段混合 Java MVC 开发技术和更细粒度的客户端-服务器通信。REST 控制器和 Java MVC 控制器看起来非常相似。

在下一章,我们处理适合本书和其他 Java MVC 项目的开发工作流。

三、开发工作流程

在这一章中,我们将讨论开发技术、过程和工具,你可以在本书的例子和任何使用 Java MVC 的后续项目中使用它们。

使用 Gradle 作为构建框架

Gradle 是一个现代的构建框架/构建自动化工具。它提供了一种纯粹的声明式配置风格,但是如果需要的话,您也可以以 Groovy(或 Kotlin)脚本片段的形式添加命令式构建代码。

Note

最佳实践表明,对于构建脚本来说,声明性编程(它告诉构建脚本必须做什么,而不是它应该如何做)优于命令性编程(精确的逐步说明)。

在本书的其余部分,我们使用 Gradle 进行构建自动化,因为它具有非常简洁的构建配置,可以从控制台(Linux bash 和 Windows 控制台)和像 Eclipse 这样的 ide 内部使用。Gradle 构建脚本可以只有三行,但也可以包含任意长的代码。我们将使用 Gradle 作为工具,在本章的稍后部分将描述它的更多特性。

Caution

如果要使用 OpenJDK 8 构建和运行应用,必须添加一个有效的cacerts文件。只需安装 OpenJDK 版本 10,然后复制OpenJDK10-INST-DIR/lib/security/cacerts to OpenJDK8-INST-DIR/lib/security/cacerts文件即可。

使用 Eclipse 作为 IDE

Eclipse 是一个 IDE(集成开发环境),提供了大量有助于开发 Java 企业项目的功能。它是免费提供的,您可以免费将其用于商业和非商业项目。

Eclipse 可以通过插件进行扩展,很多插件都是社区开发的,可以免费使用。然而,插件也可能来自供应商,你可能需要购买许可证才能使用它们。在本书中,我们将只使用免费插件。如果你想尝试专有插件,在这种情况下,这可能会促进你的开发,请访问位于 https://marketplace.eclipse.org 的 Eclipse marketplace,并咨询每个 plugin . Eclipse . org development,它们都有使用许可

安装 Eclipse

Eclipse 有几种变体。要下载其中任何一款,请前往 https://www.eclipse.org/downloads/https://www.eclipse.org/downloads/packages/ 。在本书中,我们将使用面向企业 Java 开发人员的 Eclipse IDE 变体。

Note

如果您选择下载安装程序,您将被要求提供变体。要从一开始就选择企业版本,请单击下载包链接,并在下一页选择企业版本。

在本书中,我们将使用 Eclipse 版本 2020-03,但是您也可以使用更高的版本。请记住,如果你陷入困境而没有明显的解决方案,降级到 Eclipse 2020-03 是一个选择。

使用任何适合您需要的安装文件夹。插件安装和版本升级放在您选择的文件夹中,因此请确保适当的文件访问权限。在我的 Linux 机器上,我通常将 Eclipse 放在一个名为:

/opt/eclipse-2019-09

(或者你有的任何版本。)然后,我让它对我的 Linux 用户可写:

cd /opt
USER=...   # enter user name here
GROUP=... # enter group name here
chown -R $USER.$GROUP eclipse-2019-09

这改变了 Eclipse 安装的所有文件的所有权,这对于单用户工作站是有意义的。相反,如果 Eclipse 有不同的用户,您可以创建一个名为eclipse的新组,并授予该组写访问权限:

cd /opt
groupadd eclipse
chgrp -R eclipse eclipse-2019-09
chmod -R g+w eclipse-2019-09
USER=...   # enter your username here
usermod -a -G eclipse $USER

chgrp ...命令改变组所有权,而chmod ...命令允许所有组成员进行写访问。usermod ...命令将特定用户添加到新组中。

Note

你需要root来执行这些命令。还要注意的是,usermod命令不会影响 PC 上当前活动的窗口管理器会话。例如,您必须重新启动您的系统,或者根据您的发行版,注销并重新登录,该命令才能生效。

最后一步,您可以提供一个指向 Eclipse 安装文件夹的符号链接:

cd /opt
ln -s eclipse-2019-09 eclipse

这使得在您的系统上切换不同的 Eclipse 版本更加容易。

在 Windows 系统上,安装程序会为您设置访问权限,任何普通用户通常都可以安装插件。这取决于 Windows 版本和系统配置。企业环境通常有更细粒度的访问权限,普通用户不能安装插件和升级,超级用户用于管理目的。可以使用 Windows 访问权限管理来配置这些权限。

配置 Eclipse

启动时,Eclipse 使用系统上安装的默认 Java 版本。万一它找不到或者您安装了几个 Java 版本,您可以明确地告诉 Eclipse 选择哪个 Java。为此,请打开此文件

ECLIPSE-INST/eclipse.ini

并添加两行:

-vm
/path/to/your/jdk/bin/java

-vmargs线的正上方:

...
openFile
--launcher.appendVmargs
-vm
/path/to/your/jdk/bin/java
-vmargs
...

Note

eclipse.ini文件的格式取决于 Eclipse 版本。检查 https://wiki.eclipse.org/Eclipse.ini 的正确语法。在该网站上,您还可以找到指定 Java 可执行文件路径的精确说明。这里显示的语法是针对 Eclipse 2020-03 的。

在 Windows PCs 上,您可以按如下方式指定路径:

...
-vm C:\path\to\your\jdk\bin\javaw
...

不要使用转义反斜杠,就像你对 Java 相关文件所期望的那样。

为了看 Java Eclipse 用哪个版本运行(不是构建项目!),启动 Eclipse,然后导航到 help➤about eclipse ide➤installation details➤configuration 选项卡。在窗格中,找到以java.runtime.version=....开头的行

添加 Java 运行时

Eclipse 本身是一个 Java 应用,在上一节中,我们学习了如何告诉 Eclipse 根据自己的兴趣选择哪个 Java 版本。对于开发本身,您必须告诉 Eclipse 使用哪个 Java 版本来编译和运行它托管的应用。

为此,请注意您想要用于 Eclipse 开发的所有 JDK 安装的路径。然后,启动 Eclipse。

Note

当您启动 Eclipse 时,它会要求您提供一个工作空间。该文件夹可以包含几个不同或相关的项目。您可以选择一个现有的工作区,或者使用一个新的文件夹来创建一个空的工作区。

在月蚀里面,去 window➤preferences➤java➤installed jres。通常 Eclipse 足够聪明,可以自动提供它自己启动时使用的 JRE。如果这对你来说足够了,你在这里什么都不用做。否则,单击添加...按钮来注册更多的 JRE。在随后的对话框中,选择标准虚拟机作为 JRE 类型。

Note

对于 Java 8,除了顾名思义,您必须提供 JDK 安装的路径,而不是严格意义上的 JRE 安装。

选中复选框以标记您的主 JRE。不要忘记单击“应用”或“应用并关闭”按钮来注册您的更改。

添加插件

Eclipse 可以通过许多有用的插件进行扩展。有些是你开发所必需的,有些只是改进你的开发工作流程。在这本书里,我们不会使用太多多余的插件,我会在需要的时候提供插件安装说明。

作为例外,我们现在将安装一个 Gradle 插件。稍后我们将看到我们可以从控制台使用 Gradle,但是 Eclipse 中的 Gradle 插件允许我们直接从 ide 内部使用 Gradle。打开 Help➤Install 新软件...并在对话框中输入 Eclipse Buildship (Gradle)和 http://download.eclipse.org/buildship/updates/latest 。选择所有功能并完成向导。

Eclipse 日常使用

Eclipse 提供了许多函数,您可以通过打开内置的帮助来了解它们。为了给你一个起点,下面是帮助你最大限度地利用 Eclipse 的技巧:

  • 您可以通过将光标放在标识符上并按 F3 键来获得标识符的定义。这适用于变量(导航到它们的声明)和类/接口(导航到它们的定义)。您甚至可以用这种方式检查被引用的和 Java 标准库类。Eclipse 将下载源代码并显示代码。这是通过查看代码来深入了解库的好方法。

  • 若要快速查找资源,如文件、类或接口,请按 Ctrl+Shift+R。

  • 开始输入代码并按 Ctrl+Space,Eclipse 将显示如何完成输入的建议。例如,键入new SimpleDa,然后按 Ctrl+Space。提供的列表将包含SimpleDateFormat类的所有构造函数。更好的是,您可以通过键入new SiDF并按 Ctrl+Space 来使其更短,因为 Eclipse 会猜测出缺少的小写字母。另外一个好处是,您不必为以这种方式引入的类和接口编写import语句。Eclipse 将为您添加import s。

  • 让 Eclipse 通过按 Shift+Ctrl+O 为所有尚未解析的类添加import(将 O 视为“组织导入”)。

  • 通过按 Ctrl+Alt+F 来格式化您的代码。这也适用于 XML 和其他文件类型。

  • 让 Eclipse 通过在类型标志符上按 F4 向您显示超类型和子类型。

  • 使用 F5 更新 Project Explorer 视图,以防在 Eclipse 外部添加或删除文件。

  • 对于新的 Eclipse 安装,通过选择 Window➤Show View➤Other 打开 Problems 视图...➤General➤Problems.这将很容易为您指出 Eclipse 检测到的任何问题(编译器问题、配置问题等等)。

  • 从 Window➤Show View➤Other 打开任务视图...➤General➤Tasks 获得您在代码注释中输入的所有“TODO”事件的列表。

  • 如果“TODO”对您来说不够精细,您可以通过右键单击代码编辑器左侧任意位置的竖条来添加书签。书签随后会在书签视图中列出。

更多关于 Gradle 的信息

有了 Eclipse 和 Gradle 插件,我们可以提高对 Gradle 框架的了解。为了保持简单,我们从一个非常简单的非 Java MVC 项目开始。

Note

您可以在 https://docs.gradle.org/current/userguide/userguide.html 找到 Gradle 用户手册。

一个基本的梯度项目

为了更多地了解 Gradle,我们构建了一个简单的EchoLibrary库,只有一个类和一个方法,将一个字符串打印到控制台。启动 Eclipse,会要求您提供一个工作区。选择您选择的任何文件夹。

Note

您可以将本书中的所有示例项目添加到一个名为JavaMVCBook的工作空间中,以便将所有东西放在一起,但这取决于您。

去 File➤New➤Other...➤Gradle➤Gradle 项目。选择EchoLibrary作为项目名称。您可以使用 Gradle 项目选项的默认设置。完成后,新建项目向导准备项目,并向保存 Gradle 配置的项目添加一些文件。

我们要做的下一件事是确保项目可以使用现有的 JSE 安装。Gradle 项目向导可能会尝试使用不存在的 JRE,并且会出现一个错误标记。见图 3-1 。

img/499016_1_En_3_Fig1_HTML.jpg

图 3-1

项目错误标记(红色感叹号)

要修复这种不匹配或检查是否使用了正确的 JRE,请右键单击该项目,然后选择“Properties➤Java”“构建 Path➤Libraries.”见图 3-2 。

img/499016_1_En_3_Fig2_HTML.jpg

图 3-2

JRE 不匹配

如果不匹配,请单击“类路径”,然后选择“添加库”,删除无效条目...➤JRE 系统图书馆。添加您向 Eclipse 注册的版本 8 JRE。然后单击应用并关闭按钮。

接下来,右键单击src/main/-java ➤New➤Package.,添加一个名为book.javamvc.echo的包在包内,添加一个包含以下内容的Echo类:

package book.javamvc.echo;

public class Echo {
    public void echo(String msg) {
        System.out.println(msg);
    }
}

Gradle 主要概念

默认情况下,Gradle 在项目的根文件夹中使用一个名为build.gradle的中心构建文件。在我们开始讨论这个文件之前,我们首先需要了解 Gradle 的主要概念:

  • Gradle 有一个核心,为构建相关的活动提供基础设施。活动本身存在于 Gradle 插件中,这些插件需要在构建文件中指定,并且运行在核心之上。对于每个项目,您可以指定哪些插件将用于 Gradle 构建。有编译 Java 类的插件;用于将工件打包成 ZIP、WAR 或 EAR 文件;用于运行应用;以及将应用发布到 Maven 存储库中。还有各种分析插件,IDE 集成插件,实用插件等等。你当然可以开发自己的插件。

  • 插件执行任务。例如,Java 插件有一个用于编译 Java 类的compileJava任务和一个用于压缩和收集几个编译好的类的jar任务。

  • 每个 Gradle build 由一个初始化、一个配置和一个执行阶段组成。在初始化阶段,Gradle 确定子项目是否需要包含在构建中。(我们后面再讲子项目。)在配置阶段,Gradle 评估依赖关系并构建任务图,任务图包含构建需要执行的所有任务。所有对象上的配置总是在每个 Gradle 版本中运行。这是很重要的一点,也是刚开始使用 Gradle 的人容易犯的错误。这意味着对于一个任务执行,看似完全不相关的任务的配置也被调用。因此,出于性能原因,任何任务的配置都应该非常快。任务的配置不应该做任何依赖于任务是否实际执行的事情。在执行阶段,任务实际上完成了它们的工作(编译、移动、压缩等等)。

Note

许多 Gradle 手册和教程在开始时都围绕用户定义的任务,这实际上对 Gradle 的初学者有一点误导。在许多甚至更大的项目中,相应的build.gradle文件指定和配置插件,但几乎从不直接处理任务。从技术的角度来看,任务是重要的,但是通过谈论不同的阶段和插件架构来开始 Gradle 的介绍会导致对 Gradle 功能的更彻底的理解。

标准梯度项目布局

所有 Gradle 插件默认期望的项目布局如下:

src
  |- main
  |     |- java
  |     |     |- <java source files>
  |     |- resources
  |           |- <resource files>
  |
  |- test
        |- java
        |     |- <java source files>
        |- resources
              |- <resource files>
build
  |- <any files built by Gradle>

build.gradle       <Gradle build file>
settings.gradle    <(Sub-)Project settings>
gradle.properties  <optional project properties>

Note

如果你了解 Maven 构建框架,src文件夹的布局对你来说会很熟悉。

我们将在后面的章节中学习如何改变项目结构。

中央 Gradle 构建文件

Eclipse 中的 Gradle 项目向导在项目的根文件夹中创建了一个示例文件build.gradle。对于任何 Gradle 项目,包括不使用 Eclipse 的项目,这都是主要的构建文件。Eclipse 插件提供了一个带有一些示例条目的基本构建文件,但是您当然可以从头开始构建这个文件。

Caution

Eclipse Gradle 插件有时会有一个关于何时何地显示构建文件的有趣想法。如果在项目浏览器中找不到该文件,请打开 Gradle 任务视图并右键单击该项目,然后选择“打开 Gradle 构建脚本”选项。

构建文件通常从定义要使用的插件开始,然后配置插件。如果需要,带有操作说明的用户定义的任务也可以转到构建文件。还可以向现有任务中添加 Groovy 或 Kotlin 代码,这使您能够根据需要微调插件。

Note

在本书中,我们只展示 Groovy 代码用于 Gradle 构建。Groovy 是动态类型的,正因为如此,与静态类型的 Kotlin 相比,它可能更简洁一些。此外,Groovy 专门是一种脚本语言,因此它配备了许多用于脚本目的的实用程序,而 Kotlin 是一种大规模计算机语言,是 Java 的竞争对手。

插件通常对它们的默认值有一个非常精确和合理的想法,所以你的项目没有太多需要配置的。因此,构建文件可能相当小。这种“约定胜于配置”的风格并不是 Gradle 的发明,但是 Gradle——以优雅为设计目标——欣然接受了这一理念。

回到EchoLibrary示例项目。我们关闭向导创建的示例文件build.gradle,并用以下内容覆盖其内容:

// The EchoLibrary build file
plugins {
    id 'java-library'
}

java {
    sourceCompatibility = JavaVersion.VERSION_1_8
    targetCompatibility = JavaVersion.VERSION_1_8
}
repositories {
    jcenter()
}

dependencies {
    testImplementation 'junit:junit:4.12'
}

前三行plugins { id 'java-library' }指定我们想要使用java-library插件。名字告诉所有人,我们事实上想要建立一个 Java 库,但是你可以在用户手册的插件部分了解细节。

java { sourceCompatibility = JavaVersion.VERSION_1_8; targetCompatibility = JavaVersion.VERSION_1_8 }设置指定了我们库的 JRE 版本。可能的值可以在org.gradle.api.JavaVersion类中查找,但是在那里你不会发现任何令人惊讶的东西(JDK 13 = JavaVersion.VERSION_1_13等等)。

Note

Gradle 使用你的操作系统的默认 JDK 来编译类。你应该而不是使用你的 Gradle 项目配置来设置 JDK 路径,因为那样你会引入一些不必要的依赖。毕竟,JRE 13 可以很好地处理 JRE 8 文件,也许其他开发人员也想在他们自己的系统上使用相同的构建脚本。相反,您可以在 Gradle 调用之前,更改操作系统的JAVA_HOME环境变量来指定一个 JDK 路径。

repositories { jcenter() }行指出 Gradle 将在哪里加载你的项目所依赖的库。jcenter()指向 Bintray 的 JCenter,但是您也可以将google()用于 Android 项目,将mavenCentral()用于 Maven Central。或者,您可以指定一个定制的 URL,如repositories { maven { url " http://my.company.com/myRepo " } },这对于私有或公司所有的存储库来说很方便。请参阅 Gradle 手册中名为“声明存储库”的部分

dependencies部分指出了我们的项目需要哪些库。对于EchoLibrary的例子,我们没有对外部库的依赖,但是对于单元测试,我们没有在这种情况下编写单元测试,但是对于有倾向的读者来说,这是一个很好的练习,我们添加了对 JUnit 测试库的依赖。

所有其他设置——如源文件的位置、生成的 JAR 文件的命名方式和写入位置、存储和缓存下载的依赖项的位置等——都由插件默认设置处理。

这个带有少量设置的构建文件现在可以用来执行各种构建任务。

运行梯度任务

Gradle 中与构建相关的和用户触发的活动被称为任务。从处理的角度来看,Gradle 的主要目标是调用任务。

Eclipse Gradle 插件有一个 Gradle 任务和一个 Gradle 执行视图。此外,诊断输出会显示在标准控制台视图中。安装 Gradle 插件后,默认情况下会打开两个与 Gradle 相关的视图。见图 3-3 。

img/499016_1_En_3_Fig3_HTML.jpg

图 3-3

gradle views(分级视图)

如果这不适合你,那就去 Window➤Show View➤Other 吧...➤Gradle 打开了一个格雷尔视图。控制台视图可从 Window➤Show View➤Console.获得

“分级任务”视图以树形视图列出所有可用的任务;见图 3-4 。可以使用“视图”菜单(菜单中的小向下三角形)过滤显示的任务范围。如果您引入任何自定义任务,这是启用“显示所有任务”项目的好时机。否则,自定义任务不会显示在列表中。见图 3-5 。

img/499016_1_En_3_Fig5_HTML.jpg

图 3-5

分级任务视图菜单

img/499016_1_En_3_Fig4_HTML.jpg

图 3-4

分级任务视图树

Caution

如果更改项目结构,例如添加、删除或重命名自定义任务,则必须单击菜单中的“刷新所有项目的任务”按钮(弯曲的双箭头)。否则,视图不会反映这些变化。

为了从 Gradle Tasks 视图中运行 Gradle task,首先必须在树中找到它。根据您在树中查找位置的精确程度,您还可以使用菜单过滤器来查找任务。找到后,双击它运行任务。诊断输出,包括任何错误消息,都显示在 Gradle 执行和控制台视图中。

任务可能有控制其功能的选项参数。例如,有一个tasks任务,它只列出了所有任务的某个子集。更准确地说,任务有一个group属性,其中一个组叫做other。如果在没有参数的情况下运行tasks任务,则属于other组的任务不会包含在输出中。要使用该命令显示所有任务,您必须添加一个--all参数。要从 Eclipse 中这样做,请转到 Run➤Run 配置,导航到 Gradle Task,并添加一个新条目,如图 3-6 所示(单击添加按钮两次以输入tasks--all)。单击 Run 并切换到控制台视图查看输出。

img/499016_1_En_3_Fig6_HTML.jpg

图 3-6

自定义分级任务运行配置

对于EchoLibrary的例子,构建一个库 JAR 很可能是主要任务。你可以在build部分找到。一旦运行了它,最终的 JAR 就会出现在build/libs文件夹中。

Caution

可以从 Eclipse 项目视图中过滤掉build文件夹。在这种情况下,如果你想看到它,打开小三角形的项目视图菜单,转到过滤器和定制,并从 Gradle Build 文件夹条目中删除复选标记。

Gradle 任务说明

任务由插件定义,插件也可能修改或覆盖其他插件定义的任务,所以任务和插件之间没有一对一的关系。此外,Gradle 本身还定义了独立于插件的任务。表 3-1 定义了您通常在 Java 项目中使用的大多数任务。

表 3-1

梯度任务

|

名字

|

|

描述

|
| --- | --- | --- |
| help | help | 显示帮助消息。 |
| projects | help | 显示项目名称并列出所有子项目的名称(如果适用)。我们将在本章后面讨论子项目。 |
| tasks | help | 显示项目中可运行的任务。您必须添加--all选项来包含来自other组的任务。要查看属于某个组的任务,添加--group <groupName>选项(对于groupname,使用buildbuild setupdocumentationhelpverificationother)。 |
| dependencies | help | 独立于插件。计算并显示项目的所有依赖项。您可以使用它来确定项目依赖于哪些库,包括可传递的依赖项(间接引入的依赖项,作为依赖项的依赖项)。 |
| init | build setup | 添加当前目录所需的文件,作为 Gradle 构建的根目录。通常只在新项目开始时这样做一次。使用 Eclipse Gradle 插件和新的 Gradle 项目向导,可以自动调用这个任务。这项任务不依赖于 Gradle 插件被激活。 |
| wrapper | build setup | 将 Gradle 包装添加到项目中。然后,可以在没有在操作系统级别安装 Gradle 的情况下执行 Gradle 构建(必须安装 Java)。使用 Eclipse Gradle 插件和新的 Gradle 项目向导,可以自动调用这个任务。这项任务不依赖于 Gradle 插件被激活。 |
| check | verification | 生命周期任务。抽象地定义在基础插件中,并由激活的插件具体化。取决于test,但可能会运行额外的检查。 |
| test | verification | 运行所有单元测试。 |
| assemble | build | 生命周期任务。抽象地定义在基础插件中,并由激活的插件具体化。任何生成发行版或其他可消费工件的插件都应该使组装任务依赖于它。在自定义任务中,您可以编写类似于assemble.dependsOn( someTask )的代码。调用此任务会绕过任何测试。 |
| build | build | 生命周期任务。抽象地定义在基础插件中,并由激活的插件具体化。依赖于checkassemble任务,并因此执行所有测试,然后根据激活的插件生成一个发行版或其他可消耗的工件。 |
| clean | build | 生命周期任务。删除build目录。如果您希望确保后续的构建执行所有的构建步骤,甚至是那些看似可以从先前的构建操作中重用的步骤,那么您可以调用此任务。您通常不会在日常工作中调用这个任务,因为如果设置得当,Gradle 应该能够确定哪些准备任务需要执行,哪些不需要执行(因为之前的构建)。 |
| classes | build | 任何插件,在其构建过程中的某个地方,需要构建该任务中提供的 Java 类。它的职责是从源代码的main部分(不是测试类)创建 Java 类。 |
| testClasses | build | 类似于classes任务,但是处理来自源代码的test部分。 |
| jar | build | 组装一个包含来自main部分的类的 JAR 档案。 |
| ear | build | 只针对 EAR 插件。从子项目(web 应用和 EJB)组装 EAR 档案。 |
| javadoc | documentation | 从main部分为源代码生成 JavaDoc API 文档。 |
| compileJava | other | 从main部分编译 Java 源代码。 |
| compileTestJava | other | 从test部分编译 Java 源代码。 |

每个插件的文档也可能描述该插件特别感兴趣的更多任务。

外挂程式等级

如果您正在为 Java MVC 和其他 Java 和 JEE/Jakarta EE 相关项目进行开发,下面的列表显示了您最常遇到的插件:

  • Base :提供大多数构建通用的基本任务和约定。

  • Java :任何类型的 Java 项目。

  • Java 库:扩展Java插件,向消费者提供关于 API 的知识。

  • Java 平台:不包含任何源代码,但描述了一组通常一起发布的相互关联的库。

  • 应用:隐式应用Java插件,并允许声明一个main类作为应用入口点。

  • WAR :扩展了Java插件,增加了以 WAR 文件形式构建 web 应用的能力。

  • EAR :允许创建一个 EAR 文件。

  • Maven Publish :增加了将工件发布到 Maven 仓库的功能。

  • Ivy Publish :添加将工件发布到 Ivy 存储库中的功能。

  • 分发:增加了简化工件分发的功能。

  • Java 库分发:增加了简化工件分发的功能,特别关注 Java 库。

  • Checkstyle :增加 Checkstyle 检查。

  • PMD :增加 PMD 支票。

  • JaCoCo :添加 JaCoCo 检查。

  • CodeNarc :增加 CodeNarc 检查。

  • 签名:增加签名功能。

  • 项目报告插件:允许生成构建报告。

你可以通过查看 Gradle 用户手册,特别是标题为“Gradle 插件参考”的章节来了解更多关于每个插件的信息。

关于存储库的更多信息

如果 Gradle 确定项目引用了这样的库,它就会从存储库中加载库。您可以在build.gradle中的repositories { }部分指定存储库:

repositories {
    repoSpec1 (repository specification, see below)
    repoSpec2
    ...
}

您可以使用以下内容作为存储库规范:

  • mavenCentral()

硬编码指向位于 https://repo.maven.apache.org/maven2/ 的公开可用的 Maven 资源库

  • jcenter()

硬编码指向位于 https://jcenter.bintray.com/ 的公开可用的 Maven 资源库

  • google()

硬编码指向位于 https://maven.google.com/ 的公开可用的 Android 专用 Maven 库

  • flatDir { ... }

指向包含库的文件夹。准确的语法是flatDir { dirs '/path1/to/folder', '/path2/to/folder', ... }.它不支持元信息,所以如果一个依赖项可以在一个flatDir存储库中和另一个具有元信息的存储库中查找(Maven、Ivy 等等),后者优先。

  • maven { ... }

给定一个显式 URL,指向一个 Maven 存储库。精确的语法是

maven { url "http://repo.mycompany.com/maven2" }

  • ivy { ... }

指向一个给定显式 URL 的 Ivy 存储库。精确的语法是

ivy { url "http://repo.mycompany.com/ivy" }

  • mavenLocal()

使用本地 Maven 缓存(通常在HOME-DIR/.m2中)

对于您指定为存储库位置的 URL,Gradle 还支持https:file:sftp:s3:(亚马逊 s3 服务)协议,或者gcs:(谷歌云存储)。前三个,当然还有标准的http://协议,使用标准的 URL 语法。如果需要,Gradle 手册会解释更多关于s3:gcs的语法。

如果您需要提供连接到存储库的凭证,您可以在credentials { }部分指定它们:

repositories {
    maven {
        url "http://repo.mycompany.com/maven2"
        credentials {
            username "user"
            password "password"
        }
    }
}

这是用于基本认证的。有关更高级的身份验证方案,请参见 Gradle 手册中的“声明存储库”一节。

关于依赖性的更多信息

Gradle 中心对configurations的依赖性。(依赖相关的)配置是一个依赖范围,这意味着它描述了一个使用场景。例如,假设您有一组仅对测试重要的依赖项,另一组是某个库的内部功能所需的依赖项,还有一组是内部功能所需的依赖项并被转发给客户端(因为它们出现在公共方法调用中)。所有这些都是不同的范围,或配置

依赖相关的配置是由插件定义的,但是关于配置名有一个常识,内部配置也是互相继承的,这就导致了不同插件之间的配置名匹配。表 3-2 列出了你在 Java 相关项目中经常遇到的配置。

表 3-2

梯度构型

|

名字

|

描述

|
| --- | --- |
| implementation | 编译源代码的main部分所需的任何依赖项都可以使用这种配置。依赖关系也将在运行时使用。 |
| compile | 已弃用。替换为implementation。您经常在博客和教程中发现这一点,所以添加这一点供您参考。用implementation代替。 |
| compileOnly | 依赖项只需要编译源代码的main部分。在运行时,某种容器将提供依赖,因此项目不需要将这种依赖添加到可交付的工件中。 |
| runtimeOnly | 编译源代码的main部分不需要依赖关系,但是依赖关系会被添加到可交付的工件中。 |
| api | 仅适用于 Java 库插件,标识了也必须传输到库客户端的依赖项,因为依赖项中的类型出现在公共方法调用中。 |
| providedCompile | 只针对战争插件;与implementation相同,但是依赖关系将而不是添加到 WAR 文件中。 |
| providedRuntime | 只针对战争插件;与runtime相同,但是依赖关系将而不是添加到 WAR 文件中。 |
| deploy | 只针对 EAR 插件;将依赖项添加到 EAR 文件的根目录。 |
| earlib | 只针对 EAR 插件;将依赖项添加到 EAR 文件的lib文件夹中。 |
| testImplementation | 编译源代码的test部分所需的任何依赖项都可以使用这种配置。依赖关系也将在运行时使用。 |
| testCompile | 已弃用。替换为testImplementation。您经常在博客和教程中发现这一点,所以添加这一点供您参考。用testImplementation代替。 |
| testCompileOnly | 与compileOnly相似,但用于信号源的test部分。 |
| testRuntimeOnly | 与runtimeOnly相似,但用于信号源的test部分。 |

一旦确定了所需的配置,就可以在build.gradle文件的dependencies { }部分指定一个列表:

dependencies {
   implementation 'org.apache.commons:commons-math3:3.6.1'
   // This is the same:
   implementation group:'org.apache.commons',
        name:'commons-math3',
        version:'3.6.1'

   // You can combine:
   implementation 'org.apache.commons:commons-math3:3.6.1',
       'org.apache.commons:commons-lang3:3.10'
   // or like that:
   implementation(
        [ group:'org.apache.commons',
          name:'commons-math3', version:'3.6.1' ],
        [ group:'org.apache.commons',
          name:'commons-lang3', version:'3.10' ]
   )
   // or like that:
   implementation 'org.apache.commons:commons-math3:3.6.1'
   implementation 'org.apache.commons:commons-lang3:3.10'

   testImplementation 'junit:junit:4.12'
}

正常情况下,任何间接依赖关系都是自动解决的,它来自于依赖关系的依赖关系。这样的依赖被称为传递依赖。因此,如果您声明了对某个库 A 的依赖,而库 A 又依赖于库 B 和库 C,那么 Gradle 会负责在构建中包含 B 和库 C,而不需要在build.gradle中显式声明对 B 和库 C 的依赖。如果您想防止 Gradle 包含可传递的依赖项,您可以使用transitive = false来标记它们:

dependencies {
    implementation (group: 'org.eclipse.jetty',
                    name: 'jetty-webapp',
                    version: '9.4.28.v20200408') {
        transitive = false
    }
}

如果您调用dependencies任务,您可以研究这种可传递的依赖性。输出将是依赖关系和传递依赖关系的树状表示,例如,如下所示:

...
runtimeClasspath - Runtime classpath of source set 'main'.
\--- com.sparkjava:spark-core:2.8.0
    +--- org.slf4j:slf4j-api:1.7.25
    +--- org.eclipse.jetty:jetty-server:9.4.12
    |   +--- javax.servlet:javax.servlet-api:3.1.0
    |   +--- org.eclipse.jetty:jetty-http:9.4.12
    |   |   +--- org.eclipse.jetty:jetty-util:9.4.12
    |   |   \--- org.eclipse.jetty:jetty-io:9.4.12
    |   |       \--- org.eclipse.jetty:jetty-util:9.4.12
...

(这里提到的依赖是implementation com.sparkjava:spark-core:- 2.8.0。)

改变项目结构

我们了解到,通过坚持默认的项目结构,我们不必花费时间来配置项目,告诉它在哪里可以找到资源和资源。

如果出于某种原因,您需要一个定制的项目布局,将下面几行添加到您的build.gradle文件中:

sourceSets {
    main {
        java {
            srcDirs = ['src99/main/java']
        }
        resources {
            srcDirs = ['src99/main/resources']
        }
    }
    test {
        java {
            srcDirs = ['src99/test/java']
        }
        resources {
            srcDirs = ['src99/test/resources']
        }
    }
}

因为所有的目录设置都被指定为列表(见[ ... ]),所以你也可以在几个文件夹中分配资源和资源(使用逗号作为分隔符)。

为了更改 Gradle 存放临时和最终输出文件的构建文件夹,在您的build.gradle文件中写入以下内容:

project.buildDir = 'gradle-build'

Gradle 构建文件是一个 Groovy 脚本

让我们修改EchoLibrary示例build.gradle文件:

// The EchoLibrary build file
plugins {
    id 'java-library'
}
java {
    sourceCompatibility = JavaVersion.VERSION_1_8
    targetCompatibility = JavaVersion.VERSION_1_8
}
repositories {
    jcenter()
}

dependencies {
    testImplementation 'junit:junit:4.12'
}

除了jcenter()中可疑的()以及A BA = B构造的奇怪混合,这个文件看起来可能像一个配置文件,其语法仅限于设置一些属性。然而,事实要悲观得多。事实上,build.gradle文件是一个 Groovy 脚本,Groovy 是一种运行在 JVM 引擎之上的成熟的脚本语言。

虽然我们已经说过,对于构建定义文件,声明式编程风格比声明式编程风格更可取,但在某些情况下,添加编程语言结构(如条件语句、开关结构、循环和对 IO(文件和控制台)、数学、流、日期和时间以及您可能想到的任何其他库对象的调用)是可以接受的。此外,构建文件中的{ }括号实际上并不表示块,而是闭包。所以dependencies { }构造实际上是dependencies( { } )的快捷方式,任何A B构造实际上都是一个方法调用A( B )

例如,如果您想仅在定义了某个系统属性的情况下添加一个runtimeOnly依赖项,并且还想输出相应的诊断消息,您可以编写以下代码:

...
dependencies {
  if(System.getProperty("add.math") != null) {
    println("MATH added")
    runtimeOnly group: 'org.apache.commons',
                name: 'commons-math3', version: '3.6.1'
  }
  ...
  testImplementation 'junit:junit:4.12'
}
...

现在,您可以调用添加了额外选项-Dadd.math的任何任务来查看条件语句和控制台输出的工作情况。

脚本变量

为了增加可读性和维护优化,您可以将变量(属性)添加到构建文件中。为此,您可以使用一个ext { }调用:

...
ext {
    MATH_VERSION = '3.6.1'
    JUNIT_VERSION = '4.12'
}

dependencies {
  implementation group: 'org.apache.commons',
      name: 'commons-math3', version: MATH_VERSION
  testImplementation "junit:junit:${JUNIT_VERSION}"
}
...

为了让${}替换生效,双引号是必需的——这是 Groovy 语言的一个特性(GString对象)。否则在 Groovy 中你可以使用单引号和双引号来表示字符串。

如果变量范围被限制在当前闭包内(在一个{ }内),你也可以使用标准的 Groovy 局部变量声明:

...
dependencies {
  def MATH_VERSION = '3.6.1'
  def JUNIT_VERSION = '4.12'

  implementation group: 'org.apache.commons',
      name: 'commons-math3', version: MATH_VERSION
  testImplementation "junit:junit:${JUNIT_VERSION}"
}
...

自定义任务

我们可以在build.gradle文件中定义自己的任务。因为我们可以在构建脚本中使用 Groovy 语言,所以这里的可能性是无限的。我们可以添加日志记录、在归档中包含非标准文件、执行加密、在服务器上部署工件、以非标准方式发布文件、执行计时、调用额外的准备和清理步骤等等。

要定义您自己的任务,您可以在您的build.gradle脚本文件中的任意位置编写以下内容:

task hello {
  group = 'build'
  description = 'Hello World'

  println 'Hello world! CONFIG'

  doFirst {
    println 'Hello world! FIRST'
  }

  doLast {
    println 'Hello world! LAST'
  }
}

groupdescription设置都是可选的;该组的缺省值是other,如果省略描述,将会取一个空字符串。group的可能值有buildbuild setupdocumentationhelpverificationother

要执行一个自定义任务,您可以像处理内置任务或插件定义的任务一样处理。然而,为了让 Eclipse Gradle 插件能够看到新任务,您首先必须右键单击项目,然后选择 Gradle➤Refresh Gradle 项目。然后,您将在 Gradle Tasks 视图的树形视图中看到新任务,并可以通过双击它来执行它。

{ }中的指令在配置阶段执行。要知道这样的指令对于所有声明的任务都是无条件执行的!对于任务执行问题,您可以将指令放入doFirst { }doLast { }中。每个任务都有一个动作列表;如果您使用doFirst,指令被添加到动作列表中,如果您使用doLast,动作被添加到动作列表中。

稍后可以通过编写以下内容向任务的动作列表添加说明:

hello.doLast {
  println 'Hello world! MORE LAST'
}

hello.doFirst {
  println 'Hello world! MORE FIRST'
}

您可以将自定义任务添加到受抚养人的现有任务列表中,或将现有任务添加到受抚养人的新任务列表中。为此,请编写以下代码,例如:

build.dependsOn hello
hello.dependsOn build

这背后的神奇之处在于,在build.gradle脚本中,任何任务都可以通过其名称直接获得。所以,如果你写build.dependsOn hello,任何build任务的执行都会首先导致hello的执行。在hello.dependsOn build中,hello任务的执行首先产生build执行。这样,就可以将任务相关性关系添加到现有的标准和非标准任务中。

Gradle 包装

如果您使用wrapper任务或 Eclipse Gradle 插件来启动一个新项目,将会安装包装器脚本,这允许您在操作系统上没有任何 Gradle 安装的情况下运行 Gradle(尽管 Java 必须工作)。您可以从以下文件中看到这一点:

gradlew

gradlew.bat

gradle
  |- wrapper
        |- gradle-wrapper.jar
        |- gradle-wrapper.properties

gradlewgradlew.bat分别是 Linux 和 Windows 的梯度启动脚本。gradle文件夹包含独立的 Gradle 安装。

Eclipse Gradle 插件不使用这些包装脚本。相反,在启动第一个 Gradle 任务时,会从USER_HOME/gradle内部启动一个 Gradle 守护进程。这个守护进程在后台运行,任何从 Eclipse 触发的 Gradle 任务执行都会联系这个守护进程来进行实际的构建工作。这允许更快的任务执行。

如果从控制台调用 Gradle,将使用包装器,并且这样的守护进程也将启动。我们在“使用控制台开发”一节中讨论了面向控制台的开发方式。

多项目构建

Gradle 项目可以有子项目。除了收集展示某种相互关系的项目之外,由一个主项目和一个或多个子项目构建的层次结构对于 EAR 项目也很重要,在 EAR 项目中,我们通常有一个 web 应用,可能有一些 EJB,也可能有一些库。

要从 Eclipse 内部构建这样一个多项目,首先要像前面描述的那样创建一个普通的 Gradle 项目。然后,打开settings.gradle文件并添加以下行:

include 'proj1', 'proj2'

当然,您可以为子项目选择不同的名称。接下来,在项目文件夹中创建两个文件夹,名称分别为proj1proj2(或者您选择的任何名称)。在每个新文件夹中添加一个空的build.gradle文件。您可以稍后在那里添加任何与子项目相关的构建指令。

右键单击该项目,然后选择“gradle➤refresh·格拉德项目”。Eclipse 将更新项目浏览器,并将主项目和两个子项目显示为不同的条目;参见图 3-7 。

img/499016_1_En_3_Fig7_HTML.jpg

图 3-7

Eclipse 中的 Gradle 多项目

由于 Gradle 插件中的一个错误,您必须修复所有三个条目的 JRE 库分配。在每一个上,右键单击,然后选择 Properties➤Libraries.删除错误的条目,然后单击 Add Library(添加到 classpath)➤JRE 系统 Library➤Workspace 默认的 JRE(或者任何适合您需要的东西))。错误标记现在应该消失了,如图 3-8 所示。

img/499016_1_En_3_Fig8_HTML.jpg

图 3-8

Eclipse 中的 Gradle 多项目,已修复

每个子项目可以使用自己的build.gradle文件独立配置,但是也可以从根项目的build.gradle文件中引用子项目:

// referring to a particular sub-project
project(':proj1') { proj ->
    // adding a new task to proj1
    task('hello').doLast { task ->
       println "I'm $task.project.name" }
}

// we can directly address tasks
project(':proj1').hello {
  doLast { println "I'm $project.name" }
}

// or, referring to all sub-projects
subprojects {
    task hello {
        doLast { task ->
        println "I'm $task.project.name"
    }
  }
}

// or, referring

to the root project and all sub-projects
allprojects {
    task hello {
        doLast { task ->
        println "I'm $task.project.name"
    }
  }
}

我们可以通过rootProject变量从子项目的配置中处理根项目:

task action {
    doLast {
        println("Root project: " +
            "${rootProject.name}")
    }
}

您可以在 Gradle 用户手册的“配置多项目构建”和“创作多项目构建”章节中了解更多关于多项目构建的信息。我们将在第九章使用多项目。

添加部署任务

定制任务的一个很好的候选是部署过程。我们可以使用标准的build任务来创建 WAR 或 EAR 文件,但是为了在本地开发服务器上部署它,一个定制的 Gradle 任务就派上了用场。在本书中,我们将使用以下任务在本地 GlassFish 服务器上进行部署和“取消部署”:

task localDeploy(dependsOn: build,
             description:">>> Local deploy task") {
  doLast {
    def FS = File.separator
    def glassfish = project.properties['glassfish.inst.dir']
    def user = project.properties['glassfish.user']
    def passwd = project.properties['glassfish.passwd']

    File temp = File.createTempFile("asadmin-passwd",
        ".tmp")
    temp << "AS_ADMIN_${user}=${passwd}\n"

    def sout = new StringBuilder()
    def serr = new StringBuilder()
    def libsDir = "${project.projectDir}${FS}build" +
        "${FS}libs"
    def procStr = """${glassfish}${FS}bin${FS}asadmin
        --user ${user} --passwordfile ${temp.absolutePath}
        deploy --force=true
        ${libsDir}/${project.name}.war"""
    // For Windows:
    if(FS == "\\") procStr = "cmd /c " + procStr
    def proc = procStr.execute()

    proc.waitForProcessOutput(sout, serr)
    println "out> ${sout}"
    if(serr.toString()) System.err.println(serr)

    temp.delete()
  }
}

task localUndeploy(
             description:">>> Local undeploy task") {
  doLast {
    def FS = File.separator
    def glassfish = project.properties['glassfish.inst.dir']
    def user = project.properties['glassfish.user']
    def passwd = project.properties['glassfish.passwd']

    File temp = File.createTempFile("asadmin-passwd",
        ".tmp")
    temp << "AS_ADMIN_${user}=${passwd}\n"

    def sout = new StringBuilder()
    def serr = new StringBuilder()
    def procStr = """${glassfish}${FS}bin${FS}asadmin
        --user ${user} --passwordfile ${temp.absolutePath}
        undeploy ${project.name}"""
    // For Windows:
    if(FS == "\\") procStr = "cmd /c " + procStr
    def proc = procStr.execute()

    proc.waitForProcessOutput(sout, serr)
    println "out> ${sout}"
    if(serr.toString()) System.err.println(serr)

    temp.delete()
  }
}

这些任务依赖于属性文件。Gradle 自动尝试读取一个名为gradle.properties的属性文件,如果它存在,就从属性中创建一个映射,并将其放入project.properties变量中。我们在项目文件夹中创建这样一个文件,如下所示:

glassfish.inst.dir = /path/to/glassfish/inst
glassfish.user = admin
glassfish.passwd =

这些任务创建一个临时密码文件;这只是 GlassFish 避免手动输入密码的方式。"...".execute()创建一个在操作系统上运行的进程;对于 Windows 变体,我们必须在前面加上一个cmd /c

我们现在可以通过分别调用localDeploylocalUndeploy任务来执行部署或“取消部署”。由于我们添加了一个dependsOn: build作为部署的任务依赖,所以没有必要构建一个可部署的工件;这是自动完成的。

使用控制台开发

因为 Eclipse Gradle 插件在项目文件夹中安装了包装脚本,所以可以从控制台(Linux 中的 bash 终端,Windows 中的命令解释器)而不是 Eclipse GUI 中完成所有与构建相关的工作。这是风格的问题;使用控制台,您可以避免切换 Eclipse 视图以及折叠和滚动树。此外,如果您必须添加任务选项或参数,与 GUI 相比,使用控制台要简单快捷得多。如果您没有 GUI,因为您想在服务器上进行构建,那么使用控制台是您唯一的选择。

本节介绍如何使用控制台进行 Gradle 构建。可以自由混合控制台和 GUI 触发的构建,因此您可以同时使用这两种方法。

如果您没有使用 Eclipse Gradle 插件来启动 Gradle 项目,那么您可以使用wrapper任务来创建包装器。在这种情况下,Gradle 必须安装在您的操作系统上。Linux 脚本如下所示:

java -version
# observe output

# if you want to specify a different JDK:
export JAVA_HOME=/path/to/the/jdk

cd /here/goes/the/project

gradle init wrapper

对于 Windows,其内容如下:

java -version
# observe output

# if you want to specify a different JDK: set JAVA_HOME=C:\path\to\the\jdk

chdir \here\goes\the\project

gradle init wrapper

这里假设gradlePATH中(在 Windows 中,gradle.bat在你的PATH中)。否则,您必须指定gradle命令的完整路径。例如:C:\gradle\bin\gradle.bat.

要检查包装器的安装,可以通过以下方式列出项目目录中的可用任务:

./gradlew tasks
# Windows:   gradlew tasks

输出应该是这样的:

> Task :tasks

--------------------------------------------------------
All tasks runnable from root project
--------------------------------------------------------

Build Setup tasks
-----------------
init - Initializes a new Gradle build.wrapper - Generates Gradle wrapper files.

[...]

如果输入以下内容,您可以看到gradlew(对于 Windows 为gradlew.bat)包装器命令的完整概要:

./gradlew -help
# Windows:   gradlew -help

表 3-3 中显示了有趣且重要的选项参数的非详尽列表。指定要在选项列表后面执行的任何任务。

表 3-3

Gradle 命令选项

|

[计]选项

|

描述

|
| --- | --- |
| -?, -h, –help | 显示此帮助消息。 |
| -Dprop=val | 设置 JVM 属性。可以在脚本内部使用System.getProperty("prop")来读取。 |
| -Pprop=val | 设置项目属性。可以在脚本内部使用prop直接读取。 |
| -w, –warn | 添加警告级别诊断输出。 |
| -i, –info | 添加了一些信息级别的诊断输出。 |
| -d, –debug | 出错时启用调试消息。 |
| -q, –quiet | 仅显示错误级别消息(安静)。 |
| –offline | 通常,Java 构建任务中引用的库被下载到缓存中。如果您想禁用网络访问,请使用此选项。 |
| –status | 显示 Gradle 守护程序的状态。通常在第一次启动时,会启动一个后台进程(守护进程)来加速后续的 Gradle 调用。使用它来显示守护程序的状态。 |
| –stop | 停止正在运行的守护进程。 |
| -v, –version | 显示版本信息。 |

任务可以有选项和参数。为了使用tasks任务(显示所有任务),例如,你可以添加--all作为一个选项:

./gradlew tasks --all
# Windows:   gradlew tasks --all

这显示了来自other组的任务(通常被丢弃)。如果您运行./gradlew help --task <task>,您可以查看任何特定任务的信息(选项)。

为了对构建脚本执行性能问题进行故障排除,还有另一个名为--profile的选项,它将导致性能报告被添加到build/reports/profile中。

对于我们的小示例项目,导航到项目文件夹,然后执行以下命令:

./gradlew build
# Windows:   gradlew build

名为EchoLibrary.jar的输出 JAR 在build/libs文件夹中生成。

Note

为了简单起见,在本书的其余部分,我们将只显示控制台 Gradle 命令,并且只显示 Linux 版本。

安装 MVC

为了能够使用 Java MVC,从 Gradle 的角度来看,我们需要检查一些东西。首先,我们将 Java MVC 配置为一个 web 应用。出于这个原因,我们创建一个 web 项目并使用 WAR 插件。在build.gradle中,添加以下内容:

plugins {
    id 'war'
}

接下来,我们在build.gradle的 dependencies 部分添加 Jakarta EE 8 API、Java MVC API 和一个 Java MVC 实现。这伴随着一个存储库规范、通常的 JUnit 测试库包含,以及我们想要使用 Java 1.8 的指示:

plugins {
    id 'war'
}

java {
    sourceCompatibility = 1.8
    targetCompatibility = 1.8
}

repositories {
    jcenter()
}

dependencies {
   testImplementation 'junit:junit:4.12'

   implementation 'javax:javaee-api:8.0'
   implementation 'javax.mvc:javax.mvc-api:1.0.0'
   implementation 'org.eclipse.krazo:krazo-jersey:1.1.0-M1'

   // more dependencies...
}

// ... more tasks

就是这样;构建过程将确保下载所有的库,并在./gradlew构建期间将 Java MVC 添加到 web 应用中。

练习

  • 练习 1: 对还是错?使用命令式编程(逐步说明)是构建脚本的首选编程风格。

  • 练习 2: 对还是错?对于命令式代码片段,您可以在 Gradle 构建脚本中使用 C++代码。

  • 练习 3: 对还是错?Eclipse 为自己的功能和构建项目使用相同的 JRE。

  • 练习 4: 确定梯度构建流程的三个阶段。

  • 练习 5: 对还是错?使用标准的 Gradle Java 项目布局,Java 类进入src/java/main

  • 练习 6: 对还是错?要使用的 Gradle 插件在settings.gradle文件中指定。

  • 练习 7: Gradle 根据需要下载项目依赖项。真的还是假的?从哪里下载在build.gradle中的downloads { }部分指定。

  • 练习 8: 用 Gradle 行话描述一下什么是配置

  • 练习 9: 使用 Eclipse Gradle 插件,创建一个包含两个类的GraphicsPrimitives Java 库:CircleRectangle。将其配置为使用 JRE 1.8。根据需要修改所有 Gradle build 配置文件。

  • 练习 10: 如果您有两个自定义任务:

  • "Hi, I’m A"在什么情况下打印到控制台?

  • 练习 11: 对还是错?Gradle 包装器只有在操作系统上安装了 Gradle 的情况下才能工作。

  • 练习 12: 描述需要做什么才能让 Gradle 在/opt/jdk8(或者对于 Windows,在C:\jdk8)使用 JDK。

task a {
    println "Hi, I'm A"
}

task b {
    println "Hi, I'm B"
}

摘要

在这一章中,我们讨论了开发技术、过程和工具,你可以在本书的例子和任何使用 Java MVC 的后续项目中使用它们。

Gradle 是一个现代的构建框架/构建自动化工具。您可以使用声明式配置风格,但也可以以 Groovy(或 Kotlin)脚本片段的形式添加命令式构建代码。最佳实践表明,对于构建脚本,声明式编程(它告诉构建脚本必须做什么,而不是它应该如何做)优于命令式编程(精确的逐步说明)。

Eclipse 是一个 IDE(集成开发环境),提供了大量有助于开发 Java 企业项目的功能。它可以通过插件进行扩展,从而增加额外的功能。对于本书,我们使用面向企业 Java 开发人员的 Eclipse IDE 变体。

对于这本书,我们需要 Eclipse Gradle 插件。Gradle 也可以从控制台使用,但是 Eclipse 中的 Gradle 插件允许我们直接从 ide 内部使用 Gradle。打开 Help➤Install 新软件,输入 Eclipse Buildship (Gradle)和 http://download.eclipse.org/buildship/updates/latest in the dialog 。选择所有功能并完成向导。

要在 Eclipse 中启动一个 Gradle 项目,请访问 File➤New➤Other...➤Gradle➤Gradle 项目。

主要的梯度概念如下。Gradle 有一个核心,为构建相关的活动提供基础设施。Gradle 插件是在主构建文件中指定的。它们运行在内核之上,并向内核添加功能。每个插件都以任务的形式展示与构建相关的活动。每个 Gradle build 由一个初始化、一个配置和一个执行阶段组成。在初始化阶段,Gradle 确定子项目是否需要包含在构建中。在配置阶段,Gradle 评估依赖项并构建一个任务图,其中包含构建所需执行的所有任务。所有对象上的配置总是在每个 Gradle 版本中运行。在执行阶段,任务完成它们的工作(编译、移动、压缩等等)。

所有 Gradle 插件的默认项目布局如下:

src
  |- main
  |     |- java
  |     |     |- <java source files>
  |     |- resources
  |           |- <resource files>
  |
  |- test
        |- java
        |     |- <java source files>
        |- resources
              |- <resource files>
build
  |- <any files built by Gradle>

build.gradle       <Gradle build file>

settings.gradle    <(Sub-)Project settings>

gradle.properties  <optional project properties>

Eclipse 的 Gradle 项目向导在项目的根文件夹中创建了一个示例构建配置文件build.gradle。对于任何 Gradle 项目,包括不使用 Eclipse 的项目,这都是主要的构建文件。Eclipse 插件提供了一个带有一些示例条目的基本构建文件。

构建文件通常从定义要使用的插件开始,然后配置插件。带有操作说明的用户定义的任务也可以转到构建文件。此外,可以向现有任务添加 Groovy 或 Kotlin 代码,这使您能够根据需要微调插件。

插件通常对它们的默认值有一个非常精确和合理的概念,所以你的项目可能没有太多需要配置的。因此,构建文件可能相当小。这种“约定胜于配置”的风格并不是 Gradle 的发明,但是 Gradle 欣然接受了这个想法。

Eclipse Gradle 插件有 Gradle 任务和 Gradle 执行视图。此外,诊断输出进入标准控制台视图。安装 Gradle 插件后,默认情况下会打开两个与 Gradle 相关的视图。

为了从 Gradle Tasks 视图中运行 Gradle 任务,首先必须在树中找到任务。根据您在树中查看的精确程度,您还可以使用菜单中的过滤器来查找任务。找到后,双击它运行任务。诊断输出,包括任何错误消息,显示在 Gradle 执行和控制台视图中。

如果 Gradle 确定项目引用了这样的库,它就会从存储库中加载库。您可以在build.gradle中的repositories { }部分指定存储库:

repositories {
    repoSpec1   (repository specification, see below)
    repoSpec2
   ...
}

您可以使用以下内容作为存储库规范:

  • mavenCentral()

    硬编码指向位于 https://repo.maven.apache.org/maven2/ 的公开可用的 Maven 资源库

  • jcenter()

    硬编码指向位于 https://jcenter.bintray.com/ 的公开可用的 Maven 资源库

  • google()

    硬编码指向位于 https://maven.google.com/ 的公开可用的 Android 专用 Maven 库

  • flatDir { ... }

    指向包含库的文件夹。精确的语法是

    flatDir { dirs '/path1/to/folder', '/path2/to/folder', ... }

    不支持元信息,所以如果可以在一个flatDir存储库中和另一个具有元信息的存储库中查找依赖关系(Maven、Ivy 等等),后者优先。

  • maven { ... }

    给定一个显式 URL,指向一个 Maven 存储库。精确的语法是

    maven { url "http://repo.mycompany.com/maven2"

  • ivy { ... }

    指向一个给定显式 URL 的 Ivy 存储库。精确的语法是

    ivy { url "http://repo.mycompany.com/ivy "

  • 蓝色区域()

    使用本地 Maven 缓存(通常在HOME-DIR/.m2)。

Gradle 中心对configurations的依赖性。依赖相关的配置是一个依赖范围,这意味着它描述了一个使用场景,如测试、编译、供应等等。依赖相关的配置是由插件定义的,但是关于配置名有一个常识,内部配置也是互相继承的,这就导致了不同插件之间的配置名匹配。

一旦确定了所需的配置,就可以在build.gradle文件的dependencies { }部分指定一个列表:

dependencies {
   implementation 'org.apache.commons:commons-math3:3.6.1'
   // This is the same:
   implementation group:'org.apache.commons',
        name:'commons-math3',
        version:'3.6.1'

   // You can combine:
   implementation 'org.apache.commons:commons-math3:3.6.1',
        'org.apache.commons:commons-lang3:3.10'
   // or like that:
   implementation(
     [ group:'org.apache.commons',
       name:'commons-math3', version:'3.6.1' ],
     [ group:'org.apache.commons',
       name:'commons-lang3', version:'3.10' ]
)
   // or like that:
   implementation 'org.apache.commons:commons-math3:3.6.1'
   implementation 'org.apache.commons:commons-lang3:3.10'

   testImplementation 'junit:junit:4.12'
}

build.gradle中,可以为 IO(文件和控制台)、数学、流、日期和时间以及您可能想到的任何其他内容添加编程语言结构,如条件语句、开关结构、循环和对库对象的调用。此外,构建文件中的{ }括号实际上并不表示块,而是闭包。因此,dependencies { }构造实际上是dependencies( { } )的快捷方式,任何A B构造实际上都是一个方法调用A( B )

为了增加可读性和维护优化,您可以将变量(属性)添加到构建文件中。为此,使用一个ext { }调用:

...
ext {
   MATH_VERSION = '3.6.1'
   JUNIT_VERSION = '4.12'
}

dependencies {
  implementation group: 'org.apache.commons',
      name: 'commons-math3', version: MATH_VERSION
  testImplementation "junit:junit:${JUNIT_VERSION}"
}
...

为了让${}替换生效,双引号是必需的。这是一个 Groovy 语言特性(GString对象)。否则,在 Groovy 中,您可以使用单引号和双引号来表示字符串。

我们可以在build.gradle文件中定义自己的任务。因为我们可以在构建脚本中使用 Groovy 语言,所以可能性是无限的。我们可以添加日志记录、在归档中包含非标准文件、执行加密、在服务器上部署工件、以非标准方式发布文件、执行计时、调用额外的准备和清理步骤等等。

要定义您自己的任务,您可以在您的build.gradle脚本文件中的任意位置编写以下内容:

task hello {
  group = 'build'
  description = 'Hello World'

  println 'Hello world! CONFIG'

  doFirst {
    println 'Hello world! FIRST'
  }

  doLast {
    println 'Hello world! LAST'
  }
}

groupdescription设置都是可选的;group的缺省值是other,如果您省略描述,将会取一个空字符串。group的所有可能值是buildbuild setupdocumentationhelpverificationother

您可以将自定义任务添加到受抚养人的现有任务列表中,或将现有任务添加到受抚养人的新任务列表中。为此,请编写以下代码,例如:

build.dependsOn hello
hello.dependsOn build

这背后的神奇之处在于,在build.gradle脚本中,任何任务都可以通过其名称直接获得。所以,如果你写build.dependsOn hello,任何build任务的执行都会首先导致hello的执行。对于hello.dependsOn build,任务hello的执行首先产生一个build的执行。这样,就可以将任务相关性关系添加到现有的标准和非标准任务中。

因为 Eclipse Gradle 插件在项目文件夹中安装了包装脚本,所以可以从控制台(Linux 中的 bash 终端,Windows 中的命令解释器)而不是 Eclipse GUI 中完成所有与构建相关的工作。这是风格的问题;使用控制台,您可以避免切换 Eclipse 视图以及折叠和滚动树。此外,如果您必须添加任务选项或参数,与 GUI 相比,使用控制台要简单快捷得多。如果您没有 GUI,因为您想在服务器上进行构建,那么使用控制台是您唯一的选择。

如果您没有使用 Eclipse Gradle 插件来启动 Gradle 项目,那么您可以使用wrapper任务来创建包装器。在这种情况下,Gradle 必须安装在您的操作系统上。Linux 脚本如下所示:

java -version
# observe output

# if you want to specify a different JDK:
export JAVA_HOME=/path/to/the/jdk

cd /here/goes/the/project

gradle init wrapper

Windows 脚本如下所示:

java -version
# observe output

# if you want to specify a different JDK:
set JAVA_HOME=C:\path\to\the\jdk

chdir \here\goes\the\project

gradle init wrapper

这里假设gradlePATH中(在 Windows 中,gradle.bat在你的PATH中)。否则,您必须指定gradle命令的完整路径。比如:C:\gradle\bin\gradle.bat

如果输入以下内容,您可以看到gradlew(对于 Windows 为gradlew.bat)包装器命令的完整概要:

./gradlew -help
# Windows:   gradlew -help

任务也可以有选项和参数。为了使用tasks任务(显示所有任务),例如,你可以添加--all作为一个选项:

./gradlew tasks --all
# Windows:   gradlew tasks --all

这显示了来自other组的任务(通常被丢弃)。如果您运行./gradlew help --task <task>,您可以查看任何特定任务的信息(选项)。

为了能够使用 Java MVC,从一个渐变的角度来看,我们需要验证一些事情。首先,我们将 Java MVC 配置为一个 web 应用。出于这个原因,我们创建一个 web 项目并使用 WAR 插件。在build.gradle中,添加以下内容:

plugins {
    id 'war'
}

接下来,我们在build.gradle的 dependencies 部分添加 Jakarta EE 8 API、Java MVC API 和一个 Java MVC 实现。这伴随着一个存储库规范、通常的 JUnit 测试库包含,以及我们想要使用 Java 1.8 的指示:

plugins {
    id 'war'
}

java {
    sourceCompatibility = 1.8
    targetCompatibility = 1.8
}
repositories {
    jcenter()
}

dependencies {
   testImplementation 'junit:junit:4.12'

   implementation 'javax:javaee-api:8.0'
   implementation 'javax.mvc:javax.mvc-api:1.0.0'
   implementation 'org.eclipse.krazo:krazo-jersey:1.1.0-M1'

   // more dependencies...
}
// ... more tasks

在下一章,我们将讨论一个干净的"Hello World"风格的项目,使用我们刚刚描述的开发工作流程。

四、Java MVC 的 Hello World

在第一章中,我展示了一个简单的 Hello World 风格的 Java MVC web 应用。有了如何使用 Eclipse 作为 IDE 和 Gradle 作为构建框架的知识,我们现在可以研究一种更简洁的 Hello World web 应用的方法。功能将是相同的:一个页面作为登陆页面,并要求用户输入他们的名字。在他们提交之后,控制器处理该名称,并显示一个带有个性化问候的提交响应页面。

开始 Hello World 项目

打开 Eclipse 并选择任何合适的工作空间。因为在前面的章节中我们使用了JavaMVCBook作为工作空间,所以没有理由在这个 Hello World 项目中不再使用它。请记住,我们在此工作区中添加了 JDK 1.8,因此您不必再次这样做。

开始一个新项目。选择文件➤新➤其他➤格拉德➤格拉德项目。单击 Next,这将显示 Gradle 新项目向导的第一页。这是一个欢迎页面,它显示了有关向导的一些信息。见图 4-1 。

img/499016_1_En_4_Fig1_HTML.jpg

图 4-1

Gradle 项目向导欢迎页面

如果愿意,您可以取消选中复选框,表明您是否希望在下次启动向导时看到此向导欢迎页面。单击下一步按钮。

在第二页上,如图 4-2 所示,要求您输入项目名称。输入HelloWorld。在同一页面上,您可以输入项目位置。如果选择默认位置,项目文件将在工作空间文件夹中创建。否则,您可以在文件系统的任何位置输入文件夹。例如,如果您使用版本控制系统,并且更喜欢使用文件系统的特殊版本控制区域内的项目文件夹,这是有意义的。

img/499016_1_En_4_Fig2_HTML.jpg

图 4-2

Gradle 项目向导第 2 页

为了通过本书学习和工作,使用默认的项目位置并保持适当的复选框被选中可能是放置项目的最常见的方法。这个页面上的最后一个设置允许您为新项目定义和使用一个工作集。工作集主要用于过滤在 Eclipse 的项目浏览器中看到的项目。也可以在以后应用该设置,因此您可以放心地不选中“将项目添加到工作集”复选框。单击“下一步”前进到下一页。

在向导的第三页,您可以指定一些关于梯度执行的选项。可以选择一个专用的 Gradle 安装,添加一些额外的 Gradle 程序执行参数,或者指定某个 Gradle 版本。见图 4-3 。

img/499016_1_En_4_Fig3_HTML.jpg

图 4-3

Gradle 项目向导第 3 页

对于 Hello World 样式的应用,您可以使用默认值,这将使覆盖工作区设置处于未选中状态。如果您感到好奇:如果您单击配置工作区设置,您可以调查或更改这些工作区设置。默认情况下使用 Gradle 包装器,这意味着将使用在项目创建期间安装的、在向导完成后可用的 Gradle 包装器。但是如果你愿意,你可以自由地尝试这些选项。单击 Next 将开始实际的项目生成,您可以看到向导的最后一页,它总结了向导的活动。参见图 4-4 。单击“完成”完成向导。

img/499016_1_En_4_Fig4_HTML.jpg

图 4-4

Gradle 项目向导第 4 页

在项目生成向导完成它的工作之后,新项目会出现在项目浏览器中。如果出现错误标记,可能是 JRE 版本不匹配。第三章详细描述了修复该问题的步骤。(简而言之,通过右键单击项目,然后单击 Properties,转到项目设置,导航到 Java 构建路径➤库,删除错误的 JRE 赋值,最后添加 JRE 1.8 作为库。)

为了让构建过程正确地添加库并构建一个 WAR web 应用,我们更改了build.gradle文件的内容并编写了以下代码:

/*
 * GRADLE project build file
 */

plugins {
    id 'war'
}
sourceCompatibility = 1.8
targetCompatibility = 1.8

repositories {
    jcenter()
}

dependencies {
   testImplementation 'junit:junit:4.12'
   implementation 'javax:javaee-api:8.0'
   implementation 'javax.mvc:javax.mvc-api:1.0.0'
   implementation 'org.eclipse.krazo:krazo-jersey:1.1.0-M1'
   implementation 'jstl:jstl:1.2'
}

task localDeploy(dependsOn: build,
             description:">>> Local deploy task") {
  doLast {
    def FS = File.separator
    def glassfish = project.properties['glassfish.inst.dir']
    def user = project.properties['glassfish.user']
    def passwd = project.properties['glassfish.passwd']

    File temp = File.createTempFile("asadmin-passwd",
        ".tmp")
    temp << "AS_ADMIN_${user}=${passwd}\n"

    def sout = new StringBuilder()
    def serr = new StringBuilder()

    def libsDir = "${project.projectDir}${FS}build" +
        "${FS}libs"
    def procStr = """${glassfish}${FS}bin${FS}asadmin
        --user ${user} --passwordfile ${temp.absolutePath}
         deploy --force=true
        ${libsDir}/${project.name}.war"""
    // For Windows:
    if(FS == "\\") procStr = "cmd /c " + procStr
    def proc = procStr.execute()
    proc.waitForProcessOutput(sout, serr)
    println "out> ${sout}"
    if(serr.toString()) System.err.println(serr)

    temp.delete()
  }
}

task localUndeploy(
             description:">>> Local undeploy task") {
  doLast {
    def FS = File.separator
    def glassfish = project.properties['glassfish.inst.dir']
    def user = project.properties['glassfish.user']
    def passwd = project.properties['glassfish.passwd']

    File temp = File.createTempFile("asadmin-passwd",
        ".tmp")
    temp << "AS_ADMIN_${user}=${passwd}\n"

    def sout = new StringBuilder()
    def serr = new StringBuilder()
    def procStr = """${glassfish}${FS}bin${FS}asadmin
        --user ${user} --passwordfile ${temp.absolutePath}
         undeploy ${project.name}"""
    // For Windows:
    if(FS == "\\") procStr = "cmd /c " + procStr
    def proc = procStr.execute()

    proc.waitForProcessOutput(sout, serr) println "out> ${sout}"
    if(serr.toString()) System.err.println(serr)

    temp.delete()
  }
}

这个配置添加了 Jakarta EE 8 API(dependencies { }部分的javax:javaee-api:8.0)、Java MVC 库(javax.mvc:javax.mvc- api:1.0.0org.eclipse.krazo:krazo-jersey:1.1.0-M1),以及作为前端视图模板引擎的 JSTL(jstl:jstl:1.2)。构建文件还包含两个定制任务——localDeploylocalUndeploy——帮助您在本地开发 GlassFish 服务器上部署项目。我们在前一章讨论了这些任务。

为了让构建正确工作,将gradle.properties文件添加到项目文件夹:

glassfish.inst.dir = /path/to/your/glassfish5.1
glassfish.user = admin
glassfish.passwd =

这些设置由自定义任务中的project.properties['..' ]表达式处理。它们告诉我们 GlassFish 在哪里,以及联系它所需的用户凭证。根据您的需要修改属性项(admin和空密码是 GlassFish 服务器的默认密码)。右键单击项目,然后选择“Gradle ➤”“刷新 Gradle 项目”以更新项目库分配。

项目现在已经设置好了,您可以开始添加 Java 类和资源文件了。

Hello World 模型

不要混淆 Java MVC 应用的模型层和数据库模型。在应用的 MVC 部分中,所有的“模型”都是一个数据容器,用于保存要在不同页面之间以及页面和控制器组件之间传输的值。对于我们的 Hello World 应用,模型非常小——它由用户在登录页面上作为用户名输入的单个字符串组成。

对于许多 MVC web 应用来说,引入保存模型值的 Java 类是有意义的。因此,对于这个 Hello World 应用,您可能希望考虑一个如下所示的模型类:

public class HelloWorldModel {
    private String userName;
    public String getUserName() {
        return userName; }
    public void setUserName(String userName) {
        this.userName = userName; }
}

然而,对于这种简单的情况,通常如果出于某种原因你不想引入模型类,Java MVC 提供了一个模型值容器机制。在控制器类中,您只需使用@Inject让 Java MVC(更准确地说,CDI 部分)注入一个javax.mvc.Models实例:

import javax.inject.Inject;
import javax.mvc.Models;
...

public class SomeController {
    @Inject
    private Models models;
    ...
}

然后,您可以在控制器中编写以下内容:

...
// somehow get String 'name' from the request
String name = ...; models.put("name", name);
...

并把它写在网页上:

...
Hello ${name}
...

对于这个简单的 Hello World 应用,我们使用Models数据容器作为用户名,所以我们不引入任何专用的模型类。

Hello World View

该视图需要两个页面:一个登录页面要求用户输入姓名,另一个问候页面显示刚刚输入的姓名。我们调用登陆页面index.jsp,它必须进入src/main/webapp/WEB-INF/views文件夹。

路径是由 Gradle 规定的惯例;下面的WEB-INF/views路径将页面标记为 Java MVC 控制的视图。index.jsp页面代码如下:

<%@ page contentType="text/html;charset=UTF-8"
    language="java" %>
<%@ taglib prefix="c"
    uri="http://java.sun.com/jsp/jstl/core" %>
<html>
<head>
   <meta charset="UTF-8">
   <title>Hello World</title>
</head>
<body>
   <form method="post" action="${mvc.uriBuilder(
        'HelloWorldController#greeting').build()}">
     Enter your name: <input type="text" name="name"/>
     <input type="submit" value="Submit" />
    </form>
</body>
</html>

Java MVC 允许两种模板引擎:JSP 和 Facelets。我们使用 JSP(你可以从<%@ ...>中看到,这在 Facelets 中是不存在的)。

来自form标签的action属性遵循 Java MVC 框架规定的特殊语法——${ mvc. ... }按照约定连接到一个特殊的对象,无需进一步的配置工作。例如,这个对象有一个uriBuilder()方法,允许我们从 Java MVC 控制器构造针对某个方法的表单动作。在这个例子中,它是HelloWorldController控制器(没有包的控制器的类名)和它的greeting()方法。

将视图页面放在某个地方不足以让 web 应用正常工作。作为附加步骤,我们需要宣布index.jsp是登录页面。这意味着必须重定向一个http://localhost:8080/HelloWo rld/mvc来通过控制器运行,并在被加载的index.jsp页面中结束。为此,我们使用了两个 Java 类。第一个将/mvc添加到目标 URL:

package book.javamvc.helloworld;

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

@ApplicationPath("/mvc")
public class App extends Application {
}

可以如图所示放入book.javamvc.helloworld包中。这个类是故意空的——@ApplicationPath注释和javax.ws.rs.core.Application超类导致了期望的行为。

第二个类RootRedirector,确保"/"""路径(在mvc之后)被转发到mvc/hello,稍后控制器将把它作为GET动词(针对index.jsp)获取:

package book.javamvc.helloworld;

import javax.servlet.FilterChain;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpFilter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * Redirecting http://localhost:8080/HelloWorld/
 * This way we don't need a <welcome-file-list> in web.xml
 */
@WebFilter(urlPatterns = "/")
public class RootRedirector extends HttpFilter {
    private static final long serialVersionUID =
          7332909156163673868L;

    @Override
    protected void doFilter(final HttpServletRequest req,
          final HttpServletResponse res,
          final FilterChain chain) throws IOException {
         res.sendRedirect("mvc/hello");
    }
}

响应页面被称为greeting.jsp,我们将它放在src/main/webapp/WEB-INF/views文件夹中的index.jsp旁边:

<%@ page contentType="text/html;charset=UTF-8"
    language="java" %>
<%@ taglib prefix="c"
    uri="http://java.sun.com/jsp/jstl/core" %>
<html>
<head>
    <meta charset="UTF-8">
    <title>Hello World</title>
</head>
<body>
  Hello ${name}
</body>
</html>

你可以看到它在功能上非常有限。它只是输出用登录页面中输入的内容替换了NAME"Hello NAME"字符串。它通过${name}引用名称,它寻址模型值name(见下一节)。

Hello World 控制器

控制器类对来自浏览器页面的用户输入做出反应,并控制页面之间的导航。它被称为HelloWorldController,我们将它放在book.javamvc.helloworld包中:

package book.javamvc.helloworld;

import javax.inject.Inject;
import javax.mvc.Controller;
import javax.mvc.Models;
import javax.mvc.binding.MvcBinding;
import javax.ws.rs.FormParam;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.core.Response;

@Path("/hello")
@Controller
public class HelloWorldController {
    @Inject
    private Models models;

    @GET
    public String showIndex() {
        return "index.jsp";
    }

    @POST
    @Path("/greet")
    public Response greeting(
          @MvcBinding @FormParam("name") String name) {
        models.put("name", name);
        return Response.ok("greeting.jsp").build();
    }
}

控制器类看起来非常类似于 RESTful 服务的 JAX-RS 控制器。主要区别在于,我们不让请求以 JSON 结构或其他形式返回数据值。相反,这些方法应该返回页面说明符。HelloWorld的控制器通过类的‘@Path注释监听 URL /hello的 HTTP GET动词,而对于showIndex()方法没有额外的@Path注释。因此,对于/hello,登录页面index.jsp将被加载。

greeting()方法从hello/greet URL 连接到POST s,因为来自类的@Path和来自方法的@Path被连接在一起。这里我们需要 HTTP POST动词,因为我们想要将这个方法连接到一个form提交。相应地,对于/hello/greet,将加载响应页面greeting.jsp

CDI 注入了Models实例。它是一个通用的数据容器,以防您不想引入 Java beans 来保存模型值。它由一个POST提供,只需编写${someName}就可以在响应视图中使用,其中someNamePOST参数的名称。

Caution

Models实例是请求范围的,这意味着模型值只存在于对POST动作的直接响应中。

利用 Gradle 构建 Hello World

为了构建 Hello World web 应用,您有两种选择。首先,您可以使用 Eclipse Gradle 插件构建一个可从 Eclipse 内部部署的项目。为此,进入 Gradle Tasks 视图,打开 HelloWorld 抽屉,在build部分找到 WAR 任务。见图 4-5 。要启动任务,请双击任务名称。然后视图自动切换到 Gradle Executions 窗口,如图 4-6 所示。在那里,您可以大致了解 Gradle 在执行任务时到底做了什么。

img/499016_1_En_4_Fig6_HTML.jpg

图 4-6

梯度执行视图

img/499016_1_En_4_Fig5_HTML.jpg

图 4-5

Hello World Gradle 任务

构建完成后,您可以在build/libs文件夹中找到 WAR 文件。如果在项目浏览器中看不到它,请左键单击该项目,然后按 F5 键更新视图。如果您仍然看不到它,您可能需要移除滤镜。打开项目浏览器的菜单,转到过滤器和自定义➤预设过滤器(见图 4-7 ),并确保 Gradle Build Folder 复选框未选中。

img/499016_1_En_4_Fig7_HTML.jpg

图 4-7

项目浏览器视图过滤器

第二个选项包括从控制台调用 Gradle 包装器。转到项目目录,然后输入以下内容:

./gradlew war

或者,如果您的系统默认不使用合适的 Java,请输入以下内容(输入您的 JDK 路径):

JAVA_HOME=/path/to/jdk ./gradlew war

在此之后,您应该可以在build/libs文件夹中找到 WAR 文件。

启动 Jakarta EE 服务器

第二章描述了安装和操作 GlassFish Jakarta EE 服务器。对于 Hello World 示例,请确保您遵循了这条线索,并确保 GlassFish 正在您的本地系统上运行。

部署和测试 Hello World

要构建和部署项目,您还有两个选择。在 Eclipse 中,首先必须确保两个定制的 Gradle 任务— localDeploylocalUndeploy—对于 Eclipse Gradle 插件是可见的。为此,请打开 Gradle Tasks 视图的菜单,并确保选中“显示所有任务”项。见图 4-8 。

img/499016_1_En_4_Fig8_HTML.jpg

图 4-8

显示所有任务

然后定制任务出现在视图的另一部分,如图 4-9 所示。要调用任何自定义任务,只需双击任务名称。

img/499016_1_En_4_Fig9_HTML.jpg

图 4-9

自定义任务视图

如果您想从控制台执行部署或“取消部署”,也可以使用 Gradle 包装器。转到项目目录,然后输入以下内容:

./gradlew localDeploy
# or
./gradlew localUndeploy

或者,如果您的系统在默认情况下没有使用合适的 Java,请使用这个选项:

JAVA_HOME=/path/to/jdk ./gradlew localDeploy
# or
JAVA_HOME=/path/to/jdk ./gradlew localUndeploy

为了测试 Hello World web 应用,请打开浏览器并输入以下 URL:

http://localhost:8080/HelloWorld

URL 被自动重定向到http://localhost:8080/HelloWorld/mvc/hello,导致呈现登陆页面。

Note

8080是 GlassFish 服务器中 web 应用的默认 HTTP 端口。/HelloWorld来自 WAR 文件的名称(一个特定于服务器的特性),/mvc来自App类,hello来自RootRedirector类。

登陆页面和响应页面如图 4-10 所示。

img/499016_1_En_4_Fig10_HTML.jpg

图 4-10

Hello World web 应用

练习

  • 练习 1: 对还是错?默认情况下,Eclipse Gradle 插件的新 Gradle 项目向导会向项目添加一个 Gradle 包装器。

  • 练习 2: 以下哪些是正确的?(A)Gradle 包装器围绕 Gradle 调用包装操作系统配置。(B)Gradle 包装器在项目文件夹内提供独立的 Gradle 安装。(C)你可以告诉 Gradle 包装使用哪个 JDK。(D)Gradle 包装器将项目添加到操作系统的 Gradle 项目列表中。

  • 练习 3: 对还是错?Gradle 具有在 Jakarta EE 服务器上部署 WAR 文件的内置任务。

  • 练习 4:Java MVC 支持哪些前端视图模板化技术?

  • 练习 5: 对还是错?Java MVC 模型值必须映射到专用 Java bean 类中的字段。

  • 练习 6:Java MVC web 应用运行在什么环境下?

  • 练习 7: 对还是错?构建 Java MVC web 应用需要 Gradle。

  • 练习 8:HelloWorld项目中,删除控制器中的models字段,改为添加一个 CDI 托管 bean,如下所示:

  • 提示:您必须给UserData添加javax.enterprise.context.RequestScopedjavax.inject.Named注释。在控制器中,必须添加一个@Inject userData字段。在视图中,您必须使用${userData.name}来访问 bean。

  • 练习 9:HelloWorld示例的响应页面添加一个返回链接。

public class UserData {
    private String name; // + getter / setter
}

摘要

在这一章中,我们讨论了一个 Hello World 风格的 web 应用,它使用 Eclipse 和/或控制台,并使用 Gradle 作为构建框架。在下一章,我们继续从用例的角度来看一些方面,以便提高我们在项目中使用 Java MVC 的技能。

五、开始使用 Java MVC

在我们彻底处理 Java MVC 部分——模型、视图和控制器——之前,我们首先需要讨论一些从用例角度看待 Java MVC 的主题。这介于基本的 Hello World 章节和随后的 Java MVC 实现概念之间。本章的目的是逐步提高你对 Java MVC 开发的熟练程度。具体来说,我们将讨论如何处理来自表单帖子的数据、解析查询参数、转换输入数据类型以及处理异常。

处理表单中的用户输入

在 Java MVC 世界中,前端(浏览器)和控制器之间的数据传输可以通过网页上的<form>元素、前端用户提交发起的POST请求以及控制器类中的方法参数来实现。两个示例参数的相应视图代码如下所示:

<%@ page contentType="text/html;charset=UTF-8"
    language="java" %>
<%@ taglib prefix="c"
    uri="http://java.sun.com/jsp/jstl/core" %>
<html>
...
<body>
  ...
  <form method="post"
        action="${mvc.uriBuilder(
                'SomeController#someMethod').build()}">
    P1 Parameter: <input type="text" name="p1" />
    P2 Parameter: <input type="text" name="p2" />
    ...
  </form>
  ...
</body>
</html>

这里使用的mvc对象是指mvc,一个自动提供的MvcContext实例(在javax.mvc包中),它的方法uriBuilder()实现了 MVC 项目相关的 URIs/URL 的通用构造。

作为由<form>的动作属性寻址的控制器,我们取一个类似如下的类:

...
import javax.mvc.binding.MvcBinding;
import javax.ws.rs.FormParam;
...

@Path("/abc")
@Controller
public class SomeController {
    ...

    @POST
    @Path("/xyz")
    public Response someMethod(
          @MvcBinding @FormParam("p1") String p1,
          @MvcBinding @FormParam("p2") String p2,
          ...more parameters...
    ) {
        // handle user input ...
        ...
        return Response.ok("responsePage.jsp").build();
    }
}

我们将在本章后面的单独章节中讨论控制器。目前,@Controller注释将该类标识为 Java MVC 控制器,@Path注释用于构建控制器及其方法使用的 URL(子)路径。从@FormParam("p1")开始的p1对应提交的<form>中的一个<input name = "p1" >,相应的一个@FormParam("p2")对应一个<input name = "p2">

也可以避免使用方法参数,而是将用户数据传递给控制器实例字段。这种数据绑定使用以下结构:

...
import javax.mvc.binding.MvcBinding;
import javax.ws.rs.FormParam;
...

@Path("/abc")
@Controller
public class SomeController {
    @MvcBinding @FormParam("p1")
    private String p1;

    @MvcBinding @FormParam("p2")
    private String p2;

    ...

    @POST
    @Path("/xyz")
    public Response someMethod() {
        // handle user input via "p1" and "p2" fields
        ...
       return Response.ok("responsePage.jsp").build();
    }
}

通常最好在方法中声明参数,因为其他方法可能有其他参数,将所有这些参数放在类级别会导致混乱。

对于表单参数,我们知道@FormParam注释直接将方法参数或字段连接到一个<form>输入元素。见图 5-1 。在下一节中,我们将讨论清单中显示的第二个参数注释,@MvcBinding

img/499016_1_En_5_Fig1_HTML.jpg

图 5-1

表单到控制器的连接

Java MVC 中的异常处理

上一节 Java 代码清单中使用的@MvcBinding注释引入了一些关于异常处理的魔法。通常,因为 Java MVC 位于 JAX-RS 之上,所以在输入数据处理期间抛出的异常只能由特殊的异常映射器捕获。这个过程不太适合 Java MVC 世界。我们希望在控制器和表单提交之间有一个明确的关系,一个异常处理映射器类引入了一种额外的“控制器”类型,严格来说,与任何 MVC 概念都没有关系。相反,通过使用@MvcBinding注释,无论是否有错误,都将调用相同的控制器和控制器方法,并且由验证不匹配和转换错误导致的传递错误将被提供给javax.mvc.binding.BindingResult的注入实例。

然后,您可以通过使用BindingResult实例的方法以编程方式检查任何错误:

...
import javax.mvc.binding.MvcBinding;
import javax.mvc.binding.BindingResult;
import javax.ws.rs.FormParam;
import javax.validation.constraints.Size;
...

@Path("/abc")
@Controller
public class SomeController {
    @Named
    @RequestScoped
    public static class ErrorMessages {
      private List<String> msgs = new ArrayList<>();

      public List<String> getMsgs() {
        return msgs;
      }

      public void setMsgs(List<String> msgs) {
        this.msgs = msgs;
      }

      public void addMessage(String msg) {
        msgs.add(msg);
      }
    }

    // Errors while fetching parameters
    // automatically go here:
    private @Inject BindingResult br;

    // We use this to pass over error messages
    // to the response page:
    private @Inject ErrorMessages errorMessages;

    ...

    @POST
    @Path("/xyz")
    public Response someMethod(
          @MvcBinding @FormParam("p1")
           @Size(min=3,max=10)
               String p1,
          @MvcBinding @FormParam("p2")
               String p2)
    {
        // ERROR HANDLING //////////////////////////
        if(br.isFailed()) {
          br.getAllErrors().stream().
                 forEach((ParamError pe) -> {
            errorMessages.addMessage(pe.getParamName() +
                  ": " + pe.getMessage());
          });
    }
    // END ERROR HANDLING //////////////////////

    // handle user input via "p1" and "p2" params
    ...

    // advance to response page
    return Response.ok("responsePage.jsp").build();
  }
}

这里,我们为错误消息使用了一个内部类。当然,您也可以在自己的文件中为消息使用自己的类。还要遵守p1参数的@Size约束。这属于 bean 验证,我们后面会详细讲。这里使用的@Size约束意味着,如果输入的字符串少于三个字符或多于十个字符,验证错误将通过BindingResult键入的br字段提交。

在响应页面上,您可能会呈现如下错误:

<%@ page contentType="text/html;charset=UTF-8"
    language="java" %>
<%@ taglib prefix="c"
    uri="http://java.sun.com/jsp/jstl/core" %>
<html>
...
<body>
  <div style="color:red">
  <c:forEach var="e" items="${errorMessages.msgs}">
      ${e}
  </c:forEach>
  </div>
...
</body>
</html>

凭借@Named注释,${errorMessages. ...}连接到ErrorMessages的注入实例(第一个字母降低)。

在正常响应页面中显示错误消息的另一种方法是将页面流转移到不同的视图页面。这很容易,因为我们在控制器方法中决定下一步去哪里。因此,我们可以这样写:

...
@POST
@Path("/xyz")
public Response someMethod(...) {
    // ERROR HANDLING //////////////////////////
    if(br.isFailed()) {
      br.getAllErrors().stream().
          forEach((ParamError pe) -> {
        errorMessages.addMessage(pe.getParamName() +
           ": " + pe.getMessage());
    });
    // advance to error page
    return Response.ok("errorPage.jsp").build();
  }
  // END ERROR HANDLING //////////////////////

  // handle user input via "p1" and "p2" params
  ...

  // advance to response page
  return Response.ok("responsePage.jsp").build();
}
...

非字符串 POST 参数

在上一节中,我们只使用了String类型的POST参数。在 Java MVC 中,还可以使用数值类型intlongfloatdoubleBigDecimalBigIntegerboolean ( truefalse)。因此,可以编写以下内容:

<%@ page contentType="text/html;charset=UTF-8"
    language="java" %>
<%@ taglib prefix="c"
    uri="http://java.sun.com/jsp/jstl/core" %>
<html>
...
<body>
  ...
  <form method="post"
        action="${mvc.uriBuilder(
                'SomeController#someMethod').build()}">
    Int Parameter: <input type="text"
        name="theInt" />
    Double Parameter: <input type="text"
        name="theDouble" />
    Boolean Parameter: <input type="text"
        name="theBoolean" />
    ...
  </form>
  ...
</body>
</html>

在控制器类中,编写以下内容:

...
import javax.mvc.binding.MvcBinding;
import javax.ws.rs.FormParam;
...

@Path("/abc")
@Controller
public class SomeController {
    ...

    @POST
    @Path("/xyz")
    public Response someMethod(
        @MvcBinding @FormParam("theInt")
            int theInt,
        @MvcBinding @FormParam("theDouble")
            double theDouble,
        @MvcBinding @FormParam("theBoolean")
            boolean theBoolean)
    {
        // handle user input via the fields
        ...
        return Response.ok("responsePage.jsp").build();
    }
}

Java MVC 负责将POST参数正确地转换成指定的 Java 类型。

如果无法正确执行转换,可能是因为在theInt输入字段中输入了“x”,例如,可以使用注入的BindingResult(如前一节所述)来捕捉转换错误。

处理查询参数

HTTP 动词包括POSTGETPUTDELETE等。到目前为止,在浏览器到控制器的通信中,我们讨论了通过 HTML <form>元素传输数据的POST请求,以及请求登录页面的GET请求。考虑以下情况:在登录页面上,要求用户输入一些数据,然后提供一个提交按钮,将数据传输到控制器,并前进到响应页面。在响应页面上,我们想要添加一个后退按钮。该按钮需要以下附加功能:在字段中输入的所有数据应该再次显示。我们如何做到这一点?控制器@FormParam字段不能使用,因为它们只适用于表单POST s。

到目前为止,我们还没有使用会话数据存储,这延长了单个请求/响应周期。如果有的话,将用户输入存储在那里,然后用它来预置输入字段将是一种有效的方法。事实上,使用会话是可能的,但是我们将在本章的后面讨论它。此外,不使用会话可以减少内存占用并简化状态管理。

我们能做的和 Java MVC 支持的是查询参数的使用。如果GET打开,例如 http://xyz.com/the-app/start ,查询参数将添加到以?开始并使用&作为分隔符的附加字符串中:

http://xyz.com/the-app/start?name=John&birthday=19971230

要在控制器中获取这样的查询参数,可以使用@QueryParam注释:

...
import javax.mvc.binding.MvcBinding;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.QueryParam;

@Path("/")
@Controller
public class SomeController {
    private @Inject BindingResult br;

    @GET
    @Path("/start")
    public String someMethod(
        @MvcBinding @QueryParam("name") String name,
        @MvcBinding @QueryParam("birthday") String birthday
  ) {
     if(name != null) {
       // handle "name" parameter
     }
     if(birthday != null) {
       // handle "birthday" parameter
     }

     // advance to page
     return "index.jsp";
  }
  ...
}

同样,与POST参数一样,也可以使用字段来获取查询参数:

...
import javax.mvc.binding.MvcBinding;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.QueryParam;

@Path("/")
@Controller
public class SomeController {
    private @Inject BindingResult br;

    @MvcBinding @QueryParam("name")
    private String name;

    @MvcBinding @QueryParam("birthday")
    private String birthday);

    @GET
    @Path("/start")
    public String someMethod() {
      if(name != null) {
        // handle "name" parameter
      }
      if(birthday != null) {
        // handle "birthday" parameter
      }

      // advance to page
      return "index.jsp";
    }
    ...
}

在 JSP 页面上,用于发出这种参数化的GET请求的专用元素是一个<a>链接:

<a href="${mvc.uriBuilder('SomeController#someMethod').
          queryParam('name', userData.name).
          queryParam('birthday', userData.birthday).
          build()}">Link</a>

该代码片段使用了一个userData变量,该变量可以作为以下内容的实例注入:

@Named
@RequestScoped
public class UserData {
  private String name;
  private String birthday;
  // Getters, setters...
}

显然,这个对象必须在控制器动作中填充数据,控制器动作最终调用带有<a>链接的页面。

对于请求参数,与POST请求一样,同样的转换规则也适用于非字符串类型的参数。您可以使用类型为string、数字类型为intlongfloatdoubleBigDecimalBigIntegerboolean ( truefalse)的字段或方法参数。被传递的查询参数被适当地转换。同样,因为我们用@MvcBinding标记了参数,所以这里应用了针对POST参数描述的处理异常的相同方法。

创建用先前输入的值填充原始页面的后退链接的详细过程如下:

  1. 在数据输入页面(名为dataInput.jsp)上,值从<form>元素内部发布。

  2. 在相应的控制器类和方法中,我们通过@FormParam注释的字段或方法参数检索数据,并以编程方式将值传输到注入的对象中(用@Named标记)。

  3. 在后续页面(名为responsePage.jsp)上,我们创建了一个反向链接,其中包含来自注入对象的查询参数。

  4. 在相应的控制器类和方法中,我们通过@QueryParam带注释的字段或方法参数检索数据,并以编程方式将值传输到注入的对象中(用@Named标记)。

  5. 我们修改了dataInput.jsp中的<input>元素,增加了value属性:<input ... value = "${injectedObject.field}">,其中injectedObject对应于注入类InjectedObject的字段。

  6. 对于验证和转换错误,我们注入一个BindingResult的实例。我们在控制器方法中使用它来检查错误。为此,我们必须将@MvcBinding添加到所有表单和查询参数中。

Note

可以同时使用表单(POST)参数和查询参数。只需将queryParam( 'name', value )方法调用添加到<form>动作的 URI 生成器中。但是,我们不想把事情搞得太复杂,所以在本书中不进一步考察这种混合。

练习

  • 练习 1: 以下哪一项是正确的?网页上的<form>元素连接到:(A)控制器类的方法userPosts()。(B)由表单的action = "..."属性和控制器类及其方法使用的@Path注释确定的控制器类的某个方法。(C)注入控制器的某个模型元素。

  • 练习 2: 描述 Java 类成为 Java MVC 控制器类的最低要求。

  • 练习 3:JAX-RS 和 Java MVC 最明显的相似之处是什么?两者最突出的区别是什么?

  • 练习 4:@MvcBinding标注的目的是什么?

  • 练习 5: 将本章描述的错误处理添加到上一章的HelloWorld应用中。

  • 练习 6: 继续上一个练习,添加一个验证约束,确保用户只输入英文字母作为姓名。提示:相应的正则表达式读作[A-Za-z]*

  • 练习 7: 添加一个返回链接到上一章中HelloWorld应用的响应页面。添加用户名作为查询参数,并确保输入的用户名再次出现在起始页的输入字段中。

摘要

在 Java MVC 世界中,前端(浏览器)和控制器之间的数据传输可以通过网页上的<form>元素(和/或查询参数)、前端用户提交(或链接点击)发起的POST(或GET)请求以及控制器类中的方法参数来实现。

@Controller注释将类标识为 Java MVC 控制器,而@Path注释用于构建控制器及其方法使用的 URL(子)路径。

通常,因为 Java MVC 位于 JAX-RS 之上,所以在输入数据处理期间抛出的异常只能由特殊的异常映射器捕获。

这个过程不太适合 Java MVC 世界。相反,通过使用@MvcBinding注释,无论是否有错误,都将调用相同的控制器和控制器方法,并且由于验证不匹配和转换错误导致的传递错误将被提供给javax.mvc.binding.BindingResult的注入实例。

除了在 Java MVC 中发布字符串类型的参数(和/或传递字符串类型的查询参数),还可以使用数值类型intlongfloatdoubleBigDecimalBigIntegerboolean ( truefalse)。Java MVC 负责将POST参数正确地转换成指定的 Java 类型。

如果不能正确执行转换,可能是因为在整型输入字段中输入了“x ”,那么可以使用注入的BindingResult来捕捉转换错误。

在这个更加以用例为中心的 Java MVC 视图之后,我们在下一章继续讨论一个更加以概念为中心的视图,从模型开始,从 Java MVC 的视图和控制器部分开始。

六、深入的 Java MVC

在这一章中,我们彻底地处理了 Java MVC 提供的各种特性。请注意,本章并不能替代官方的 Java MVC 规范(在撰写本书时,最新版本是 1.0),您可以在以下网址找到该规范:

https://download.oracle.com/otndocs/jcp/mvc-1-final-spec

相反,这一章涵盖了你最常遇到的模式,我们也将通过一些例子片段。

模型

对于 Java MVC 的模型部分,不要与数据库模型混淆,MVC 框架的最初想法是相当原始的。模型类只是 Java bean 类(具有字段、getters 和 setters 的类),开发人员会以类似于下面的方式(这是伪代码,不是真正的 Java)以编程方式将它们添加到视图中:

...
// inside some controller
String name = ...; // somehow via form POST
int i1 = ...;    // somehow via form POST

   HttpRequest req = ..; // somehow via framework

   MyBean b = new MyBean(); b.setName(name); b.setSomeInt(i1);

   req.setBean("beanName", b);

   // somehow advance to response page
   ...

在响应视图中,您可能会使用类似下面的表达式来访问模型 beans:

Hello ${beanName.name}

其中beanName对应于来自伪代码的setBean()方法参数,name对应于字段名。

Java MVC 中的 CDI

Java MVC 是一个现代框架,它的模型能力取代了简单引用 beans 的概念。它通过在 CDI 2.0 版本中为 Jakarta EE 8 引入 CDI(上下文和依赖注入)技术来实现这一点。CDI 不是一项小技术——它的规范 PDF 有 200 多页!不用说,我们不能介绍 CDI 的每一个概念,但是我们讨论了最重要的思想,并把我们的调查集中在 Java MVC 使用 CDI 的方式上。

Note

你可以在 https://jakarta.ee/specifications/cdi/2.0/ 找到 CDI 规范。

基本思想是相同的:我们希望实例化 bean 类(主要包含字段及其 getters 和 setters 的数据类),并将这些实例提供给控制器和视图。前 CDI 和 CDI 方式的主要区别在于,我们不自己实例化这样的模型类,而是让 CDI 来做。

为了告诉 Java MVC 我们希望模型类由 CDI 控制并对视图页面可用,我们使用了来自javax.inject包的@Named注释:

import javax.enterprise.context.RequestScoped;
import javax.inject.Named;

@Named
@RequestScoped
public class UserData {
  private String name;
  private String email;
  // Getters and setters...
}

我们还使用@RequestScoped注释将对象实例的生命周期绑定到一个 HTTP 请求/响应周期。我们将在下一节中更多地讨论作用域。

一旦我们通过@Named向 CDI 框架宣布了一个 bean,在 Java MVC 中就会发生两件事。首先,我们可以使用@Inject(包javax.inject)从任何 Java MVC 控制器内部和任何其他 CDI 控制的类内部引用 bean 实例。其次,我们可以通过使用首字母小写的类名:${userData.name}${userData.email}来使用视图页面中的实例。见图 6-1 。

img/499016_1_En_6_Fig1_HTML.jpg

图 6-1

Java MVC 中的 CDI

如果您想为 CDI beans 使用不同的名称,您可以使用带参数的@Named:

import javax.enterprise.context.RequestScoped;
import javax.inject.Named;
@Named("user")
@RequestScoped
public class UserData {
  private String name;
  private String email;
  // Getters and setters...
}

然后,您可以在视图页面中使用更改后的名称:${user.name}。因为在@Inject中,引用是通过类名而不是注释参数发生的,所以为了注入到 Java 类中,您仍然要使用@Inject private UserName userName;,即使名称已经改变。

模型对象范围

如果您使用 CDI 来管理模型数据,那么模型类实例从属于由 CDI 管理的生命周期控件。这意味着 CDI 决定什么时候构造 beans,什么时候放弃它们。在注入的 beans 中,CDI 控制实例生命周期的方式是通过一个叫做 scope 的特性。在 Java MVC 中,存在以下作用域:

  • 请求范围 : 注入 bean 的实例是在 HTTP 请求期间创建的,并且只在 HTTP 请求和发送给客户端(浏览器)的响应的生命周期内有效。请求范围变量的一个典型使用场景是将POST表单数据或GET查询参数传递给响应中定义的视图层页面。因此,您将@Named请求范围 bean 注入控制器,在那里设置它们的字段,并在视图层使用 bean。因为请求作用域 beans 的生命周期很短,所以它们有助于保持 web 应用的低内存占用并避免内存泄漏。

  • 会话范围:一个会话绑定到一个浏览器窗口,跨越几个 HTTP 请求/响应周期。每当用户进入 web 应用时,会话就开始,并在超时或显式会话取消时终止。会话范围内的数据对象占主导地位,直到触发某个超时或显式关闭会话。当需要维护生命周期超过一个 HTTP 请求/响应周期的状态时,可以使用会话范围的对象。会话数据简化了状态处理,但是极大地增加了 web 应用消耗内存或造成不稳定的内存泄漏的危险。

  • 重定向范围 : 为了支持POST -redirect- GET设计模式,Java MVC 为 CDI beans 定义了一个重定向范围。如果您想避免浏览器用户在POST动作终止前点击 reload 按钮时的重新发布,您可以使用这种模式。具有重定向范围的 beans 的生命周期跨越了POST和随后的GET(因为浏览器接收重定向代码 303)。在 Java MVC 控制器中,通过从处理POST的方法内部返回一个Response.seeOther( URI.create("response/path" )).build()或一个字符串"redirect:response/path"来启动POST -redirect- GET。流程如下:

    1. 用户在表单中输入数据并提交。Java MVC 控制器被调用。

    2. 控制器通过表单参数工作,最后方法返回Response.seeOther( URI.create("response/path" )).build()"redirect:response/path"

    3. 浏览器自动发送一个重定向到给定的路径。

    4. response/path路径(相应地修改)指向另一个带有GET动词的控制器方法。它前进到一个视图页面,显示对用户请求的适当响应。

重定向范围 CDI beans 的生命周期是从最初的POST请求到随后的GET请求生成的响应,这是两个 HTTP 请求/响应周期。

  • 应用范围 : 任何应用范围内与用户无关的数据都可以使用这个范围。在取消部署 web 应用或停止服务器之前,数据一直有效。

  • 依赖作用域 : 这是伪作用域。这意味着 CDI bean 的作用域与激活它的 bean 的作用域相同。如果没有显式设置范围,则默认为依赖范围。

为了定义注入 bean 的范围,可以使用以下注释之一:

@RequestScoped
@SessionScoped
@ApplicationScoped
@RedirectScoped
@Dependent

都是来自javax.enterprise.context包,除了RedirectScoped是 Java MVC 扩展,属于javax.mvc.annotation包。

简化模型数据容器

除了使用标有@Named注释的 CDI beans,您还可以使用Models的注入实例(在javax.mvc包中)。在控制器中,您可以编写以下内容:

import javax.inject.Inject;
import javax.mvc.Controller;
import javax.mvc.Models;
...

@Path("/abc")
@Controller
public class SomeController {
    @Inject private Models models;

    ...
    // inside any method:
    models.put("name", name);
    ...
}

然后,模型值可从内部视图页面获得,无需前缀:

Hello ${name}

仅在需要处理少量数据时使用Models接口。否则,您将面临无结构、不可理解的代码的风险。

Note

Models数据有请求范围。

如果您需要来自Models对象的模型值(仍然在同一个请求/响应周期内!),可以使用get()方法:

   Object o = models.get("someKey");

   // or, if you know the type
   String s = models.get("someKey", String.class);
}

视图:JSP

Java MVC 的视图部分负责向客户端(浏览器)呈现前端,包括输入和输出。那些连接到控制器方法的 Java MVC 视图文件在WEB-INF/views文件夹中,或者,因为我们使用 Gradle 作为构建框架,所以在src/main/webapp/WEB-INF/views文件夹中。

Java MVC 开箱即用,支持两种视图引擎——JSP(Java server Pages)和 Facelets(JSF 的视图声明语言,JavaServer Faces)。通过设计,基于 CDI 的扩展机制可以包含其他视图引擎。在本节中,我们将讨论 Java MVC 视图的 JSP 变体。

Note

有关 JSP 规范,请参见 https://download.oracle.com/otndocs/jcp/jsp-2_3-mrel2-spec/

JSP 基础

JSP 允许开发人员将静态内容(例如 HTML)和由 JSP 元素表示的动态内容交织在一起。一个 JSP 页面被内部编译成一个继承自Servlet的大 Java 类。包含 JSP 代码的文件以.jsp结尾。

Note

对于 GlassFish,您可以在GLASSFISH_INST/glassfish/domains/domain1/generated/jsp/-[PROJECT-NAME]文件夹中看到生成的 servlets。

指令

JSP 指令为容器提供了方向。表 6-1 给出了指令的描述,表 6-2 具体列出了 JSP page指令。

表 6-2

jsp 指令页

|

名字

|

描述

|
| --- | --- |
| buffer="..." | 使用它来设置输出缓冲区的大小。可能的值:none(无缓冲),或Nkb,其中N是一个数字,kb代表千字节(例如:8kb)。 |
| autoFlush="true"&#124;"false" | 输出缓冲区填满后自动刷新。否则,将引发异常。默认为true。 |
| contentType="..." | 设置输出的内容类型。例子:text/htmltext/xml。要指定字符编码,添加;charset=...,如contentType = "text/html;charset=UTF-8"所示 |
| errorPage="..." | 指定在引发异常时显示的错误页面。这是一个相对 URL。示例:errorPage = "error.jsp" |
| isErrorPage="true"&#124;"false" | 如果true,将该 JSP 视为错误页面。 |
| extends="some.pckg.SomeClass" | 使生成的 servlet 扩展给定的类。这样,您可以提供自己的 servlet 实现。 |
| import="..." | 工作方式与 Java import语句完全一样。 |
| info="..." | 在此添加描述 JSP 的任何文本。 |
| isThreadSafe="true"&#124;"false" | 如果false,一次只有一个线程将处理 JSP。默认为true。 |
| language="..." | 指示使用的编程语言。这里写java。 |
| session="true"&#124;"false" | 如果选择true,将启用会话。默认为true。 |
| isELIgnored="true"&#124;"false”" | 如果为true,则表达式语言构造${ ... }不被求值。默认是false。 |
| isScriptingEnabled="true"&#124;"false" | 如果true,则启用动态 JSP 脚本。默认为true,设置为false除了真正的静态页面之外,通常没有任何意义。 |

表 6-1

jsp 指令

|

名字

|

描述

|
| --- | --- |
| <% page ... %> | 依赖于页面的属性。可能的参数如表 6-2 所示(空格分隔列表)。 |
| <% include file="relative url" %> | 在此位置包含另一个文件。例如:<% include file = "header1a.jsp" %> |
| <% taglib uri="uri" prefix="prefix" %> | 包括标签库。标记库文档中显示了精确的语法。 |

带有最常见指令的基本 JSP 文件头如下所示:

<%@ page language="java"
    contentType="text/html;charset=UTF-8" %>
<%@ taglib prefix = "c"
    uri = "http://java.sun.com/jsp/jstl/core" %>
<%@ taglib prefix = "fmt"
    uri = "http://java.sun.com/jsp/jstl/fmt" %>

这意味着文本编辑器使用 UTF-8(我假设是这样)。两个taglib指的是 JSTL (JavaServer Pages 标准标记库)标记库。这个taglibcorefmt部分指的是许多 web 应用通用的有用标签。

Note

JSTL 有更多的部件,我们不会用在 Java MVC 中。如果你想了解更多关于 JSTL 的信息,请前往 https://jcp.org/aboutJava/communityprocess/final/jsr052/index.html

静态内容

要生成静态内容,只需将它逐字写入 JSP 文件中:

<%@ page language="java"
    contentType="text/html;charset=UTF-8" %>
<%@ taglib prefix = "c"
    uri = "http://java.sun.com/jsp/jstl/core" %>
<%@ taglib prefix = "fmt"
    uri = "http://java.sun.com/jsp/jstl/fmt" %>

<html>
<head>
    <meta charset="UTF-8">
    <title>Model And CDI</title>
</head>
<body>
    <%-- The string inside action is dynamic contents --%>
    <form method="post"
        action="${mvc.
                 uriBuilder('ModelAndCdiController#response').
                 build()}">
      Enter your name: <input type="text" name="name" />
      <input type="submit" value="Submit" />
    </form>
</body>
</html>

这段代码将按原样输出,但有三个例外。最上面的指令是包含注释的<%-- ... --%>,以及代表由 JSP 引擎内部的处理步骤处理的表达式的${ ... }

Java Scriptlets 和 Java 表达式

因为 JSP 被转录成 Java 类,所以 JSP 允许 Java 代码和表达式包含在 JSP 页面中。语法如下:

<%=
    Any Java code
    ...
%>

<%=
    Any Java expression (semicolons not allowed)
    ...
%>

第二个构造<%= ... %>,将表达式结果添加到 servlet 的输出流中。

Caution

不要过度使用这些结构。毕竟,Java 是面向对象的语言,而不是前端模板语言。

隐式对象

<%= ... %><% ... %>中,有几个你可以使用的隐式对象:

  • out:JspWriter(扩展java.io.Writer)类型的 servlet 的输出流。

  • request:请求,类型HttpServletRequest

  • response:响应,类型HttpServletResponse

  • session:session,类型HttpSession

  • application:应用,类型ServletContext

  • config:servlet 配置,类型ServletConfig

  • page:servlet 本身,类型Object(运行时类型javax.servlet.http.HttpServlet)。

  • pageContext:页面上下文,类型PageContext

您可以使用这些对象来实现奇特的结果,但是请记住,如果您使用它们,您会以某种方式离开正式的开发模式。这可能会使其他人难以阅读您的代码,并且通过将功能放入视图页面,模型、视图和控制器之间的自然界限被打破。

JavaBeans 组件

带有@Named注释的 CDI beans 被直接提供给 JSP:

@Named
public class UserName {
  private String name;
  // Getters and setters...
}

JSP:
...
Hello ${userName.name}

如果您将模型数据添加到注入的javax.mvc.Models CDI bean 中,您可以直接访问它而无需前缀:

Controller:

import javax.mvc.Models;
...
@Controller
public class SomeController {
  @Inject private Models models;
  ...
  // inside any method:
  models.put("name", name);
  ...
}

JSP:
...
Hello ${name}

在这两种情况下,您都在 JSP 中使用了一个表达式语言构造${ ... }。我们将在下一节讨论表达式语言。

Caution

由于隐式对象,您可以直接从 JSP 内部引用POST或查询参数。然而,这不像 MVC,因为它引入了控制器无法触及的第二个模型层,并且它将控制器的责任转移到视图。所以不要这样做,总是使用注入的 CDI beans 来代替。

表达式语言

${ ... }这样的 JSP 页面中的构造被视为一个表达式,由表达式语言处理程序处理。表达式元素包括:

  • name:直接引用 CDI 托管 bean 或隐式对象。在呈现视图时,表达式导致使用toString()方法来生成输出。例如:${user}

  • value.property:指的是一个value对象的property字段(必须有一个 getter),或者如果value是一个 map,则是一个由property键控的 map 条目。例子:${user.firstName}(在user CDI bean 中必须有一个getFirstName())和${receipt.amount} ( receipt是一个 map,amount是其中的一个 key)。

  • value[property]:指的是一个value对象的字段 value-of- property(必须有一个 getter),或者如果value是一个映射,或者如果property计算为一个int(用于索引)并且如果value是一个列表或数组,则是一个由 value-of- property键控的映射条目。property也可以是字面意思,如421.3'someString'"someString"。示例:${user['firstName']}(与${user.firstName}相同)和${list[2]}(列表或数组中的第三个元素)。

  • unaryOperator value:unaryOperator应用于value。一元运算符有(求反)、not!empty(值为null或空)。

  • value1 binaryOperator value2:binaryOperator应用于value1value2。二元运算符有:

    • 算术:+-*/div%mod(取模)

    • 逻辑:and&&or||

    • 关系:==eq!=ne<lt>gt < =、和le>=ge

  • value1 ternaryOperatorA value2 ternaryOperatorB value3:ternaryOperator应用于value1value2value3。只有一个:a ? b : c评估为b如果a为真;否则,计算结果为c

有几个隐式对象可以在表达式中使用,如表 6-3 所示。

表 6-3

EL 隐式对象

|

名字

|

描述

|
| --- | --- |
| pageScope | 具有来自page作用域的作用域变量的映射。 |
| requestScope | 具有来自request作用域的作用域变量的映射。 |
| sessionScope | 具有来自session作用域的作用域变量的映射。 |
| applicationScope | 具有来自application作用域的作用域变量的映射。 |
| paramValues | 将请求参数作为字符串集合的映射。在 Java MVC 应用中,通常不会通过表达式访问这样的数据,所以不要使用它。 |
| param | 将请求参数作为字符串的映射(每个请求参数的第一个)。在 Java MVC 应用中,通常不会通过表达式访问这样的数据,所以不要使用它。 |
| headerValues | 将 HTTP 请求头作为字符串集合的映射。 |
| header | 将 HTTP 请求头作为字符串的映射(每个头的第一个)。要访问某个标题,你可以写${header["user-agent"]}。 |
| initParam | 带有上下文初始化参数的映射。 |
| cookie | 将 cookie 名称映射到javax.servlet.http.Cookie的实例。 |
| pageContext | 类型为javax.servlet.jsp.PageContext的对象。允许您访问各种对象,如请求、响应和会话。 |

输出

如果您喜欢使用标签进行动态输出,您可以使用如下的<c:out>标签:

Hello <c:out value="${userData.name}" />

<%-- Similar to --%>
Hello ${userData.name}

然而,它们并不完全相同。如果没有额外的escapeXml = "false",标签将例如用>替换>,用<替换<。如果${userData.name}恰好是<John>,你在Hello ${userData.name}的浏览器窗口里什么也看不到。浏览器看到一个<John>,它将其解释为一个(无效的)标签。相反,标签变量输出一个显示为<John><John>

<c:out>的属性如下:

  • escapeXml:是否对特殊 XML 字符进行转义。不需要;默认为true

  • value:要打印的数值。必选。通常你在这里写一个类似于${someBean.someProperty}的表达式。

  • default:值出问题时默认写入。不需要。

变量

使用<c:set>标签,我们可以引入变量,以便在页面上进一步使用。在 Java MVC 世界中,最常见的使用场景是引入别名来提高可读性。像设置会话范围变量这样的任务不应该在 JSP 内部完成,因为这是控制器的责任。

<c:set var="firstName" value=${user.firstName} />
<%-- We can henceforth use 'firstName' in expressions
     Instead of 'user.firstName' --%>
Hi ${firstName}

<c:set>标签的完整属性集如下所示:

  • value:用于新变量(或属性)的值。通常,您在这里编写一个类似于${someBean.someProperty}的表达式。

  • var:存储值的新变量的名称。不是必需的,但是如果没有给出,targetproperty必须使用。

  • scope:在var="..."中给定的变量的范围。默认为page(仅当前渲染的页面)。

  • target:存储值的对象或贴图。不需要。

  • property:如果指定了target,属性(字段)或关键字(用于地图)的名称。不需要。

对于列表或数组上的循环,可以使用<c:forEach>标签(c表示jstl/core taglib):

<c:forEach items="${theList}" var="item">
  ${item} <br/>
</c:forEach}

items="..."中的表达式可以是任何数组或字符串、原语或其他对象的列表。

您将经常在 HTML 表格中使用这样的循环。在控制器中,您构建一个 item 对象列表,每个项目代表表中的一行:

// probably inside a models package:
@Named
@RequestScoped
public class Members {
  private List<Member> list = new ArrayList<>();
  public void add(Member member) {
     list.add(member);
  }
  // Getters, setters...
}

public class Member {
  private int id;
  private String firstName;
  private String lastName;
  // Constructors, getters, setters...
}

// probably inside a controllers package:
@Controller
public class MyController {
    @Inject private Members members;

    // inside a method:
    members.add(new Member(...));
    members.add(new Member(...));
    ...
}

在 JSP 中,我们现在可以通过${members. ...}访问Members对象,并从列表中构建一个表:

<%@ page contentType="text/html;charset=UTF-8"
    language="java" %>
<%@ taglib prefix="c"
    uri="http://java.sun.com/jsp/jstl/core" %>
<html>
<head>
    <meta charset="UTF-8">
    <title>Table</title>
</head>
<body>
  <table>
    <thead>
      <tr>
        <th>ID</th>
        <th>Last Name</th>
        <th>First Name</th>
      </tr>
    </thead>
    <tbody>
      <c:forEach items="${members.list}" var="item">
        <tr>
           <td>${item.id}</td>
           <td>${item.lastName}</td>
           <td>${item.firstName}</td>
        </tr>
      </c:forEach>
    </tbody>
  </table>
</body>
</html>

<c:forEach>标签的所有可能属性如下:

  • items:要迭代的项目。不是必需的,但是如果缺少,循环将迭代一个整数。这是你可能写一个类似于${someBean.someListOrArray}的表达式的地方。

  • var:页面范围变量的名称,该变量将被生成并保存循环中的每一项。不需要。

  • begin:元素开始。不需要;默认为0(第一项)。

  • end:结束元素。不需要;默认值是最后一个元素。

  • step:这一步。不需要;默认为1

  • varStatus:循环状态变量的名称(页面范围)。不需要。该变量将保存一个类型为javax.servlet.jsp.jstl.core.LoopTagStatus的对象。

如果您想在一个整数值范围循环中使用<c:forEach>标记,您不需要指定items属性,而是使用beginend属性:

<c:forEach begin="1" end="10" var="i">
  ${i}<br/>
</c:forEach>

条件分支

对于 JSP 内部的条件分支,您可以使用<c:if><c:choose>标签之一。简单的<c:if>测试允许进行简单的条件检查,无需替代方案和else分支:

<c:if test="${showIncome}">
   <p>Your income is: <c:out value="${income}"/></p>
</c:if>

使用下面的结构可以轻松地实现一个if-else:

<c:if test="${showIncome}">
   <p>Your income is: <c:out value="${income}"/></p>
</c:if><c:if test="${!showIncome}">
   <p>Your income is: ***</p>
</c:if>

然而,对于真正的if-elseif-elseif-...-else<choose>标签是更好的选择:

<c:choose>
    <c:when test="${income <= 1000}">
       Income is not good.
    </c:when>
    <c:when test="${income > 10000}">
       Income is very good.
    </c:when>
    <c:otherwise>
       Income is undetermined...
    </c:otherwise>
</c:choose>

饼干

通过使用隐式的cookie对象,可以直接从 JSP 内部读取 Cookies:

Cookie name: ${cookie.theCookieName.name} <p/>
Cookie value: ${cookie.theCookieName.value} <p/>

其中theCookieName被替换为 cookie 名称。然后,${cookie.theCookieName}引用了一个类型为javax.servlet.http.Cookie的对象。但是,只有名称和值可用。

出于测试目的,您可以在控制器方法中创建一个名为theCookieName的 cookie(随意设置 cookie 属性):

@Controller
@Path("abc")
public class MyController {
  @GET
  public Response myResponse() {
      ...
      // This is a subclass of Cookie:
      NewCookie ck = new NewCookie("theCookieName",
          "cookieValue",
          "the/path",
          "my.domain.com",
          42,
          "Some Comment",
          3600*24*365,
          false);

      return Response.
          ok("responsePage.jsp").
           cookie(ck).
          build();
  }
  ...
}

在响应页面(或稍后的页面)中,您可以编写所示的 JSP 代码来研究 cookie。

Caution

对于本地测试服务器,您必须将localhost设置为 cookie 域。此外,您必须设置适当的路径值,为了简单起见,可能是/(它匹配所有路径)。

视图:Facelets

除了 JSP 之外,Java MVC 支持其他视图技术是 Facelets 。Facelets 是专门为 JSF 创建的模板框架,JSF (JavaServer Faces)是 Jakarta EE 专用的主要前端技术。JSF 是基于组件的,而 Java MVC 是基于动作的。这就是问题出现的地方:Java MVC 在某种程度上是 JSF 的竞争对手,所以 Java MVC 和 Facelets 乍一看似乎不匹配。好消息是,因为 JSF 和 Facelets 是高度解耦的,我们不必使用 JSF 组件,Facelets 作为一个简单的模板引擎也可以用于 Java MVC。这很好,因为与 JSP 相比,Facelets 更适合现代编程风格,JSP 有时被认为是老派的,尽管令人尊敬。

但是,我们并不是无意将 Facelets 作为 Java MVC 的模板引擎放在第二位的。几十年来,JSP 已经被证明是有价值的,它们更接近前端开发人员经常使用的基本编程范例。此外,如果您有一些 JSF 编程经验,使用 Facelets 可以避免尝试使用 Java MVC 的 JSF 特性的危险,这很容易搞乱您的应用设计。相比之下,Facelets 应用了更高程度的抽象,如果由熟练的开发人员使用,它允许更精简和更干净的应用设计。

话虽如此,使用哪种前端技术完全取决于您。本节将向您展示如何使用 Facelets for Java MVC。

Facelets 文件

Java MVC 的 Facelets 文件与 JSP 文件放在同一个文件夹中:文件夹WEB-INF/views,或者,因为我们使用 Gradle 作为构建框架,所以放在文件夹src/main/webapp/WEB-INF/views中。

Facelets 文件是 XML 文件,这可能是 JSP 和 Facelets 之间最显著的区别。Facelets 中没有类似于\ci{<\% ... \%>}的指令,也不能使用不是有效 XML 的遗留 HTML 结构,但是 JSP 允许使用。

Facelets 配置

我们在 JSP 编程中实现的,避免了提供web.xml配置文件的需要,也可以在 Facelets 中实现。首先,我们提供了一个App类来将mvc添加到 URL 上下文路径中:

package any.project.package;

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

@ApplicationPath("/mvc")
public class App extends Application {
}

该类故意为空;上下文路径元素仅由注释添加。

接下来,我们添加一个重定向器,它允许我们使用基本 URL http://the.server:8080/WarName/ 来启动应用(这是针对 GlassFish 的,WarName需要替换为 WAR 文件名)。重定向器将这样的请求转发给 http://the.server:8080/WarName/mvc/facelets ,我们将使用它作为在控制器类中配置的登录页面的入口点。名字不重要;我们称之为RootRedirector:

package any.project.package;

import javax.servlet.FilterChain;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpFilter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@WebFilter(urlPatterns = "/")
public class RootRedirector extends HttpFilter {
  private static final long serialVersionUID =
        7332909156163673868L;

@Override
  protected void doFilter(final HttpServletRequest req,
        final HttpServletResponse res,
        final FilterChain chain) throws      IOException {
      res.sendRedirect("mvc/facelets");
  }
}

剩下要做的就是注意在控制器中,“facelets”路径将导致登录页面上的GET:

import javax.mvc.Controller;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.core.Response;

@Path("/facelets")
@Controller
public class MyFaceletsController {
    @GET
    public Response showIndex() {
        return Response.ok("index.xhtml").build();
    }
    ...
}

通过 Facelets 进行模板化

Facelets 允许我们引入参数化的模板 HTML 页面、要包含在页面中的 HTML 片段(组件)、这种片段的占位符,以及修饰器和类似精心制作的列表视图的重复。在接下来的页面中,我们首先使用 Facelets 标记,然后开发一个示例应用来帮助您入门。

要使用 Facelets,必须将 Facelets 名称空间添加到 XHTML 文件中:

<?xml version='1.0' encoding='UTF-8' ?>
<!DOCTYPE html>
<html lang="en"
      xmlns:="http://www.w3.org/1999/xhtml"
      xmlns:ui="http://java.sun.com/jsf/facelets">
    <h:head>
        <title>Facelet Title</title>
    </h:head>
<body>
  ...
</body>
</html>

在接下来的小节中,我们将解释可以包含在 XHTML 文件中的 Facelets 标签,以应用或混合模板、包含 XHTML 片段或传递参数。

标签

包括另一个 XHTML 文件,如

<ui:include src="incl.xhtml" />

如果包含的文件包含一个<ui:composition>或者一个<ui:component>,那么只有<ui:composition>或者<ui:component>标签的内部内容会被包含。这使得设计者可以独立于他们后来通过服务器连接在一起的文件来设计包含的文件。

标签,第一个变体

如果使用而不使用??,例如

<ui:composition>
    ...
</ui:composition>

它定义了 HTML 元素的子树(集合)。其背后的思想是,如果您使用<ui:include>并且包含的文件包含一个<ui:composition> ... </ui:composition>,那么只有<ui:composition> ... </ui:composition>的内部内容将被包含。标签本身及其周围的任何内容都将被忽略。因此,您可以让页面设计人员创建一个完全有效的 XHTML 文件,在有趣的部分加上<ui:composition> ... </ui:composition>,并在任何其他 JSF 页面中编写<ui:include>来提取这些部分。

标签,第二个变体

如果它。连用,如在

<ui:composition template="templ.xhtml">
    ...
</ui:composition>

它定义了一组 XHTML 片段,这些片段将被传递到模板文件中的占位符中(对应于template = "..."属性)。

这和没有template="..."<ui:composition>相比是完全不同的使用场景。在模板文件中,你有一个或多个像<ui:insert name="name1" />这样的元素,在带有<ui:composition template="...">的文件中,你在和<ui:composition template="..."> ... </ui:composition>中使用<ui:define>标签

<ui:composition template="templ.xhtml">
    <ui:define name = "someName"> ... </ui:define>
    <ui:define name = "someName2"> ... </ui:define>
    ...
</ui:composition>

定义用于<ui:insert>标签的内容。标签周围的任何东西都将被忽略,所以你可以让设计者使用非 JSF 感知的 HTML 编辑器创建代码片段,然后用 ?? 提取感兴趣的部分来具体化模板文件。

标签

使用它来定义模板文件中的占位符。模板文件中的<ui:insert name="name1"/>标签意味着引用该模板的任何文件都可以定义占位符的内容。这个定义必须发生在<ui:composition><ui:component><ui:decorate><ui:fragment>内部。

通常你不需要在这个标签中提供内容。如果您添加内容,如

<ui:insert name="name1">
    Hello
</ui:insert>

如果没有另外定义占位符,它将被视为默认值。

标签

这个标签声明了将在插入点插入什么:

<ui:define name="theName">
    Contents...
</ui:define>

由于插入点只能存在于模板文件中,<ui:define>标签只能出现在通过<ui:composition template = "...">引用模板文件的文件中

标签

指定一个参数,该参数传递给一个<ci:include>编辑的文件,或者传递给在<ui:composition template = "..."> ...中指定的模板,只需将它作为子元素添加即可,如下所示:

<ui:include src="comp1.xhtml">
    <ui:param name="p1" value="Mark" />
</ui:include>

在引用的文件中,添加#{paramName}以使用参数:

<h:outputText value="Hello #{p1}" />

标签

这与第一个没有模板规范的变体<ui:composition>相同,但是它向 JSF 组件树添加了一个元素。此标签支持以下属性:

  • id:组件树中元素的 ID。不需要;如果不指定,JSF 会自动生成一个 ID。可能是 EL(表达式语言)字符串值。

  • binding:用于将组件绑定到 Java 类(必须从javax.faces.component.UIComponent继承)。不需要。可能是 EL 字符串值(类名)。

  • rendered:是否渲染组件。不需要。可能是 EL 布尔值。

通常的做法是使用<ui:param>将参数传递给组件。例如,您可以告诉组件使用特定的 ID。调用者如下:

<ui:include src="comp1.xhtml">
    <ui:param name="id" value="c1" />
</ui:include>

被叫方(comp1.xhtml)如下:

<ui:component id="#{id}">
    ...
</ui:component>

标签

类似于<ui:composition>,但是这个标签不忽略它周围的 XHTML 代码:

...
I'm written to the output!
<ui:decorate template="templ.xhtml">
    <ui:define name="def1">
        I'm passed to "templ.xhtml", you can refer to
        me in "templ.xhtml" via
        <ui:insert name="def1"/&gth;
    </ui:define>
</ui:include>
...

<ui:composition>相反,带有<ui:decorate>的文件将包含完全有效的 XHTML 代码,包括htmlheadbody,模板文件将被插入到<ui:decorate>出现的地方。因此,它不能包含htmlheadbody。这或多或少是一个扩展的include,其中传递的数据不是由属性给出,而是列在标记体中。

您通常应用<ui:decorate>标签来进一步细化代码片段。您可以将它们包装成更多的<div>来应用更多的样式、添加标签或标题等等。

标签

这个标签与<ui:decorate>相同,但是它在 JSF 组件树中创建了一个元素。它具有以下属性:

  • id:组件树中元素的 ID。不需要;如果不指定,JSF 会自动生成一个 ID。可能是 EL(表达式语言)字符串值。

  • binding:用于将组件绑定到 Java 类(必须从javax.faces.component.UIComponent继承)。不需要。可能是 EL 字符串值(类名)。

  • rendered:组件是否渲染。不需要。可能是 EL 布尔值。

您可以使用它来提取现有的代码片段,并将它们部分转换为组件。例如,考虑以下代码:

<DOCTYPE html>
<html ...><head>...</head>
<h:body>
  ...
  <table>
      Some table|
  </table>
  ...
</h:body></html>

如果我们现在将表提取到一个不同的文件中,名为table1_frag.xhtml:

<!-- Caller: ############################# -->
<!-- original file                        -->
<DOCTYPE html>
<html ...><head>...</head>
<h:body>
  ...
  <ui:include src="table1_frag.xhtml"/>
  ...
</h:body></html>

<!-- Callee: ############################# -->
<!-- table1_frag.xhtml                     -->
<div xmlns:="http://www.w3.org/1999/xhtml"
  xmlns:h="http://xmlns.jcp.org/jsf/html"
xmlns:f="http://xmlns.jcp.org/jsf/core"
xmlns:ui="http://java.sun.com/jsf/facelets"
xmlns:pt="http://xmlns.jcp.org/jsf/passthrough">
  <div>I am the table caption</div>
  <ui:fragment>
    <table>
      [Some table|
    </table>
  </ui:fragment>
  </div>

我们引入了 XHTML(标题)和一个新组件(表格)。

标签

这不一定是与模板相关的标记,但是它被用来在集合或数组中循环。它的属性是:

  • begin:不需要。如果指定了,迭代从列表或数组开始。可能是一个int值表达式。

  • end:不需要。如果指定,迭代在列表或数组中结束。可能是一个int值表达式。

  • step:不需要。如果指定,则在列表或数组内单步执行。可能是一个int值表达式。

  • offset:不需要。如果指定,偏移量将被添加到迭代值中。可能是一个int值表达式。

  • size:不需要。如果指定,它是从集合或数组中读取的最大元素数。不得大于数组大小。

  • value:要迭代的列表或数组。一个Object值表达式。必选。

  • var:保存当前迭代项的表达式语言变量的名称。可能是一个String值表达式。

  • varStatus:不需要。用于保存迭代状态的变量的名称。具有只读值的 POJO:begin(int),end ( int),index ( int),step ( int),even ( boolean),odd ( boolean),first ( boolean)或last ( boolean)。

  • rendered:是否渲染组件。不需要。可能是 EL 布尔值。

Note

JSTL (Java 标准标签库)集合为循环提供了一个<c:forEach>标签。由于观念上的差异,JSF 和 JSTL 并没有很好地合作。在教程和博客中,你会发现很多 JSTL 循环的例子。然而,最好使用<ui:repeat>来避免问题。

标签

在项目的开发阶段将它添加到您的页面中。使用热键,标签将导致 JSF 组件树和其他信息显示在页面上。使用hotkey="x"属性改变热键。然后 Shift+Ctrl+x 将显示组件(注意,默认的d不支持 Firefox 浏览器!).第二个可选属性是rendered="true|false"(你也可以使用一个 EL 布尔表达式)来打开或关闭这个组件。

Note

这个标签只在开发项目阶段有效。在WEB-INF/web.xml里面,你可以添加这个标签:

<context-param>
   <param-name>javax.faces.PROJECT_STAGE</param-name>
   <param-value>Development</param-value>
</context-param>

指定项目阶段(任意一个Development(默认)UnitTestSystemTestProduction)。

Facelets 项目示例

我们用音乐盒数据库构建了一个示例 Facelets 项目,它显示了标题、作曲家和表演者的类似设计页面。我们在 web 应用的每个页面上都有一个页眉、页脚和一个菜单,不管用户当前使用的是哪种功能。Facelets 很好地让我们分离出了常见的页面部分,因此我们只需编写一次代码。见图 [6-2 。

img/499016_1_En_6_Fig2_HTML.jpg

图 6-2

使用 Facelets 进行模板化

在 Eclipse 中启动一个新的 Gradle 项目,并将其命名为MusicBox。使用build.gradle文件并将其内容替换为:

plugins {
    id 'war'
}

java {
    sourceCompatibility = JavaVersion.VERSION_1_8
    targetCompatibility = JavaVersion.VERSION_1_8
}

repositories {
    jcenter()
}

dependencies {
   testImplementation 'junit:junit:4.12'
   implementation 'javax:javaee-api:8.0'
   implementation 'javax.mvc:javax.mvc-api:1.0.0'
   implementation 'org.eclipse.krazo:krazo-jersey:1.1.0-M1'
   implementation 'jstl:jstl:1.2'
   implementation 'com.google.guava:guava:28.0-jre'
}

task localDeploy(dependsOn: war,
             description:">>> Local deploy task") {
  doLast {
    def FS = File.separator
    def glassfish = project.properties['glassfish.inst.dir']
    def user = project.properties['glassfish.user']
    def passwd = project.properties['glassfish.passwd']

    File temp = File.createTempFile("asadmin-passwd",
        ".tmp")
    temp << "AS_ADMIN_${user}=${passwd}\n"

    def sout = new StringBuilder()
    def serr = new StringBuilder()
    def libsDir =
        "${project.projectDir}${FS}build${FS}libs"
    def proc = """${glassfish}${FS}bin${FS}asadmin
       --user ${user} --passwordfile ${temp.absolutePath}
       deploy --force=true
       ${libsDir}/${project.name}.war""".execute()
    proc.waitForProcessOutput(sout, serr)
    println "out> ${sout}"
    if(serr.toString()) System.err.println(serr)

    temp.delete()
  }
}

task localUndeploy(
             description:">>> Local undeploy task") {
  doLast {
    def FS = File.separator
    def glassfish = project.properties['glassfish.inst.dir']
    def user = project.properties['glassfish.user']
    def passwd = project.properties['glassfish.passwd']

    File temp = File.createTempFile("asadmin-passwd",
        ".tmp")
    temp << "AS_ADMIN_${user}=${passwd}\n"

    def sout = new StringBuilder()
    def serr = new StringBuilder()
    def proc = """${glassfish}${FS}bin${FS}asadmin
        --user ${user} --passwordfile ${temp.absolutePath}
        undeploy ${project.name}""".execute()
    proc.waitForProcessOutput(sout, serr) println "out> ${sout}"
    if(serr.toString()) System.err.println(serr)

    temp.delete()
  }
}

除了依赖处理之外,这个构建文件还引入了两个定制任务,用于在本地服务器上部署和取消部署MusicBox web 应用。番石榴库只是一个简化基本开发需求的有用工具的集合。

为了连接到asadmin工具,我们在项目根目录下创建另一个名为gradle.properties的文件:

glassfish.inst.dir = /path/to/glassfish5.1
glassfish.user = admin
glassfish.passwd =

您应该输入自己的 GlassFish 服务器安装路径。空密码是 Glassfish 的默认设置。如果您对此进行了更改,则必须在该文件中输入密码。

对于musicbox数据,我们创建三个 Java 类。为了简单起见,它们返回静态信息。在现实生活中,您将连接到数据库来获取数据。创建一个名为book.javamvc.musicbox.model的包,并添加以下内容:

// Composers.java:
package book.javamvc.musicbox.model;

import java.io.Serializable;
import java.util.List;

import javax.enterprise.context.SessionScoped;
import javax.inject.Named;

import com.google.common.collect.Lists;

@SessionScoped
@Named
public class Composers implements Serializable {
    private static final long serialVersionUID =
        -5244686848723761341L;

    public List<String> getComposers() {
        return Lists.newArrayList("Brahms, Johannes",
              "Debussy, Claude");
    }
}

// Titles.java:
package book.javamvc.musicbox.model;

import java.io.Serializable;
import java.util.List;

import javax.enterprise.context.SessionScoped;
import javax.inject.Named;

import com.google.common.collect.Lists;

@SessionScoped
@Named
public class Titles implements Serializable {
    private static final long serialVersionUID =
        -1034755008236485058L;

    public List<String> getTitles() {
        return Lists.newArrayList("Symphony 1",
            "Symphony 2", "Childrens Corner");
    }
}

// Performers.java:
package book.javamvc.musicbox.model;

import java.io.Serializable;
import java.util.List;

import javax.enterprise.context.SessionScoped;
import javax.inject.Named;

import com.google.common.collect.Lists;

@SessionScoped
@Named
public class Performers implements Serializable {
    private static final long serialVersionUID =
        6941511768526140932L;

    public List<String> getPerformers() {
        return Lists.newArrayList(
            "Gewandhausorchester Leipzig",
            "Boston Pops");
    }
}

为了让 CDI 正常工作,创建一个名为src/main/webapp/WEB-INF/beans.xml的空文件。再加一个文件,叫src/main/webapp/WEB-INF/glassfish-web.xml。它应包含以下内容:

<?xml version="1.0" encoding="UTF-8"?>
<glassfish-web-app error-url="">
    <class-loader delegate="true"/>
</glassfish-web-app>

在我们进入视图编码之前,图 6-3 展示了我们想要实现的印象。为了应用 Facelets 功能,我们添加了一个名为src/main/webapp/WEB-INF/frame.xhtml的模板文件:

img/499016_1_En_6_Fig3_HTML.jpg

图 6-3

Musicbox Facelets 应用

<!DOCTYPE html>
<html lang="en"
      xmlns:="http://www.w3.org/1999/xhtml"
      xmlns:ui="http://java.sun.com/jsf/facelets">
<head>
  <title>Musicbox</title>
  <link rel="stylesheet" href="../../css/style.css" />
</head>

<body>
  <div class="header-line">
      <ui:insert name="header">
        <h2>Top Section</h2>
      </ui:insert>
  </div>
  <div class="center-line">
      <div class="menu-column">
        <ui:insert name="menu">
          <ul><li>Menu1</li><li>Menu2</li></ul>
        </ui:insert>
      </div>
      <div class="contents-column">
         <ui:insert name="contents">
            Contents
         </ui:insert>
      </div>
    </div>
    <div class="bottom-line">
        <ui:insert name="footer">Footer</ui:insert>
    </div>
</body>
</html>

这个模板文件定义了一个通用的页面结构,并通过<ui:insert>标签声明了几个占位符。我们引用的 CSS 文件叫做style.css,它被发送到src/main/webapp/css/style.css:

body { color: blue; }
.header-line { height: 3em; background-color: #CCF000; }
.bottom-line { clear: both; height: 1.5em; }
.menu-column { float: left; width: 8em;
    background-color: #FFC000; height: calc(100vh - 7em); }
.menu-column ul { margin:0.5em; padding: 0;
    list-style-position: inside; }
.contents-column { float: left; padding: 0.5em;
    background-color: #FFFF99;
    width: calc(100% - 9em); height: calc(100vh - 8em); }
.bottom-line { padding-top: 1em;
    background-color: #CCFFFF; }

对于常见的页面元素,我们在src/main/webapp/common文件夹中定义了几个 XHTML 文件:

<!-- File commonHeader.xhtml -->
<!DOCTYPE html>
<div xmlns:="http://www.w3.org/1999/xhtml"
     xmlns:ui="http://java.sun.com/jsf/facelets">
  <h2>Musicbox</h2>
</div>

<!-- File commonMenu.xhtml -->
<!DOCTYPE html>
<div xmlns:="http://www.w3.org/1999/xhtml"
      xmlns:ui="http://java.sun.com/jsf/facelets">
  <ul>
    <li><a href="titles">Titles</a></li>
    <li><a href="composers">Composers</a></li>
    <li><a href="performers">Performers</a></li>
  </ul>
</div>

<!-- File commonFooter.xhtml -->
<!DOCTYPE html>
<div xmlns:="http://www.w3.org/1999/xhtml"
      xmlns:ui="http://java.sun.com/jsf/facelets">
  (c) The Musicbox company 2019
</div>

commonMenu.xhtml中,我们提供了<a>到标题、作曲家和表演者页面的链接。href属性不直接对应 XHTML 页面;相反,它们指向控制器内部的方法。这是一个在book.javamvc.musicbox.controller包中名为MusicBoxController.java的 Java 类:

package book.javamvc.musicbox.controller;

import java.util.ArrayList;
import java.util.List;

import javax.enterprise.context.RequestScoped;
import javax.inject.Inject;
import javax.inject.Named;
import javax.mvc.Controller;
import javax.mvc.binding.BindingResult;
import javax.mvc.binding.MvcBinding;
import javax.mvc.binding.ParamError;
import javax.ws.rs.FormParam;
import javax.ws.rs.GET;

import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.core.NewCookie;
import javax.ws.rs.core.Response;

@Path("/musicbox")
@Controller
public class MusicBoxController {
    private @Inject BindingResult br;

    @GET
    public Response showIndex() {
        return Response.ok("titles.xhtml").build();
    }

    @GET
    @Path("/titles")
    public Response showTitles() {
        return Response.ok("titles.xhtml").build();
    }

    @GET
    @Path("/composers")
    public Response showComposers() {
        return Response.ok("composers.xhtml").build();
    }

    @GET
    @Path("/performers")
    public Response showPerformers() {
        return Response.ok("performers.xhtml").build();
    }

    @POST
    @Path("/response")
    public Response response(
          @MvcBinding @FormParam("name")
          String name) {
        if(br.isFailed()) {
            // ... handle errors
      }

        // ... handle user POSTs

        // ... advance to response page
        return Response.ok("response.xhtml").build();
    }
}

在这个例子中没有实现response()方法。如果您想包含表单,此处显示的内容可以帮助您入门。

src/main/webapp/WEB-INF/views文件夹中的titles.xhtmlcomposers.xhtmlperformers.xhtml三个页面文件是指模板文件和常用页面元素:

<!-- File titles.xhtml ********************** -->
<!DOCTYPE html>
<html lang="en"
      xmlns:="http://www.w3.org/1999/xhtml"
      xmlns:ui="http://java.sun.com/jsf/facelets">

<body>
<ui:composition template="frame.xhtml">

  <ui:define name="header">
    <ui:include src="/common/commonHeader.xhtml" />
  </ui:define>

  <ui:define name="menu">
    <ui:include src="/common/commonMenu.xhtml" />
  </ui:define>

  <ui:define name="contents">
    <h2>Titles</h2>
    <ul>
      <ui:repeat var="t" value="${titles.titles}"
            varStatus="status">
        <li>${t}</li>
      </ui:repeat>
    </ul>
  </ui:define>

  <ui:define name="footer">
    <ui:include src="/common/commonFooter.xhtml" />
  </ui:define>

</ui:composition>

</body>
</html>

<!-- File composers.xhtml ********************** -->
<!DOCTYPE html>
<html lang="en"
      xmlns:="http://www.w3.org/1999/xhtml"
      xmlns:ui="http://java.sun.com/jsf/facelets">

<body>
<ui:composition template="frame.xhtml">

  <ui:define name="header">
    <ui:include src="/common/commonHeader.xhtml" />
  </ui:define>

  <ui:define name="menu">
    <ui:include src="/common/commonMenu.xhtml" />
  </ui:define>

  <ui:define name="contents">
    <h2>Composers</h2>
    <ul>
      <ui:repeat var="c" value="${composers.composers}"
            varStatus="status">
        <li>${c}</li>
      </ui:repeat>
    </ul>
  </ui:define>

  <ui:define name="footer">
    <ui:include src="/common/commonFooter.xhtml" />
  </ui:define>

</ui:composition>
</body>
</html>

<!-- File performers.xhtml ********************** -->
<!DOCTYPE html>
<html lang="en"

      xmlns:="http://www.w3.org/1999/xhtml"
      xmlns:ui="http://java.sun.com/jsf/facelets"
      xmlns:c="http://java.sun.com/jsp/jstl/core">
<body>
<ui:composition template="frame.xhtml">

  <ui:define name="header">
    <c:if test="true">
    <ui:include src="/common/commonHeader.xhtml" />
    </c:if>
  </ui:define>

  <ui:define name="menu">
    <ui:include src="/common/commonMenu.xhtml" />
  </ui:define>

  <ui:define name="contents">
    <h2>Performers</h2>
    <ul>
      <ui:repeat var="p" value="${performers.performers}"
            varStatus="status">
        <li>${p}</li>
      </ui:repeat>
    </ul>
  </ui:define>

  <ui:define name="footer">
    <ui:include src="/common/commonFooter.xhtml" />
  </ui:define>

</ui:composition>
</body>
</html>

您可以看到我们使用了<ui:composition>标签来应用页面模板。

Caution

网页故意不使用任何 JSF 标签。如果你寻找 Facelets 教程,在大多数情况下,他们将包括 JSF 标签。我认为在 Java MVC 项目中使用 Facelets JSF 标签是一种危险的做法。Java MVC(基于动作)和 JSF(基于组件)的不同设计范例很可能会导致难以解决的问题。然而,可以同时使用 Facelets 和 JSTL;请参见下一节。

通过运行 Gradle 任务localDeploy来构建和部署应用。然后将浏览器指向http://localhost:8080/MusicBox以查看应用的运行情况。见图 6-3 。

混合 Facelets 和 JSTL

我们已经指出,对于 Java MVC,出于稳定性原因,我们不想混合 JSF 组件和 Facelets 页面。然而,这导致了功能的严重缺乏,包括一个缺失的if-else构造。在 JSF 世界中,您通过rendered属性打开和关闭组件(或组件子树)。那么,如果我们想使用 Facelets for Java MVC,并且需要在视图页面上进行条件分支,我们该怎么办呢?答案简单得惊人。因为我们不使用 JSF 组件,所以我们可以简单地添加 JSTL 标签库,而没有任何破坏正常页面呈现的危险。然后我们可以使用<c:if><c:choose>标签。

例如,考虑我们想要基于某种条件添加一个消息框。这样就可以写出以下内容:

<!DOCTYPE html>
<html lang="en"
      xmlns:="http://www.w3.org/1999/xhtml"
      xmlns:ui="http://java.sun.com/jsf/facelets"
      xmlns:c="http://java.sun.com/jsp/jstl/core">
<head>
  ...
</head>
<body>
    ...
    <c:if test="${pageControl.showMessages}">
      <div class="messages">
        ... the messages ...
      </div>
    </c:if>
    ...
</body>
</html>

因为 JSP 和 JSTL 已经在我们的build.gradle文件中处理好了,我们只需要添加 JSTL 名称空间,以便能够使用 JSTL。

统一表达式

对于 JSF,表达式语言处理已经扩展到使用延迟表达式,用#{ ... }代替${ ... }表示。在 JSF 组件对表单发起的请求做出反应之前,不会对这种延迟表达式进行求值。这样,就有可能使用表达式作为lvalue,也就是说你可以给它们分配用户输入。因此,#{ someBean.someProperty }既可用于输出,也可用于输入。

立即表达式和延迟表达式的组合,更准确地说是增强表达式语言,也称为统一表达式

对于 Java MVC,表单输入由控制器方法专门处理。从设计上来说,没有将表单输入自动连接到 CDI beans 的功能。出于这个原因,我们不需要延迟表达式,为了使事情更清楚,作为一个经验法则,考虑:

Caution

不要在 Java MVC Facelets 视图中使用延迟表达式#{ ... }

控制器

控制器类描述了 Java MVC 应用的动作部分。他们负责准备模型,接受用户请求,更新模型,并决定在请求后显示哪些视图页面。

控制器基础

要将一个类标记为 Java MVC 控制器,向该类添加@Controller注释(在javax.mvc包中)和@Path注释(在javax.ws.rs包中):

...
import javax.mvc.Controller;
import javax.ws.rs.Path;
...

@Path("/controllerPath")
@Controller
public class MyController {
    ...
}

@Path将确保控制器作用于以WEB_APPLICATION_BASE/mvc/controllerPath开始的 URL,其中WEB_APPLICATION_BASE依赖于 Jakarta EE 服务器产品(例如,对于 GlassFish,它是 e http://the.server:8080/TheWarName ),而/mvc被配置为某个类中的应用路径:

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

@ApplicationPath("/mvc")
public class App extends Application {
}

您不必将controllerPath用于@Path参数;这只是一个例子。

获取页面

对于那些不是的页面,你可以使用GET动词并标记相应的方法:

import javax.mvc.Controller; import javax.ws.rs.GET; import javax.ws.rs.Path;
import javax.ws.rs.core.Response;

@Path("/controllerPath")
@Controller
public class MyController {
    @GET
    public Response showIndex() {
        return Response.ok("index.jsp").build();
    }

    @GET
    @Path("/b")
    public String showSomeOtherPage() {
        return "page_b.jsp";
    }
}

在这个代码片段中,您可以看到两种可能的返回类型——您返回一个指向 JSP(或 Facelets 页面)的字符串,然后使用后缀.xhtml,或者您返回一个Response对象。虽然返回一个字符串更容易,但是有了Response实例,您有了更多的选择。例如,您可以精确地指定 HTTP 状态代码,并实际指定状态代码(如 OK、服务器错误、已接受、已创建、无内容、未修改、查看其他、临时重定向或不可接受)。您还可以设置编码、缓存控制、HTTP 头、语言、媒体类型、过期时间和上次修改时间,并添加 cookies。详见javax.ws.rs.core.Response类的 API 文档。

触发路径是通过连接 classat @Path注释和 methong@Path注释来计算的,然后在应用的 URL 路径前面加上前缀。例如,如果您在本地 GlassFish 服务器上部署了一个名为TheWAR.war的 WAR,并在端口 8080(默认)上运行了一个 HTTP 连接器,并且在包层次结构中的任意位置添加了这个类:

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

@ApplicationPath("/mvc")
public class App extends Application {
}

那么这个控制器:

import javax.mvc.Controller;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.core.Response;

@Path("/controllerPath")
@Controller
public class MyController {
    @GET
    public Response showIndex() {
        return Response.ok("index.jsp").build();
    }

    @GET
    @Path("/b")
    public String showSomeOtherPage() {
        return "page_b.jsp";
    }
}

将确保应用以下映射:

http://localhost:8080/TheWAR/mvc/controllerPath
    -> method showIndex()
http://localhost:8080/TheWAR/mvc/controllerPath/b
    -> method showSomeOtherPage()

见图 6-4 。

img/499016_1_En_6_Fig4_HTML.jpg

图 6-4

控制器 URL

准备模型

如果需要为被调用的页面准备模型值,可以在控制器中注入 CDI beans,并从控制器方法内部调整它们的值。

import javax.mvc.Controller;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.core.Response;
import javax.inject.Inject;

@Path("/controllerPath")
@Controller
public class MyController {
    // The controller is under custody of CDI, so
    // we can inject beans.
    @Inject private SomeDataClass someModelInstance;

    @GET
    public Response showIndex() {
        // Preparing the model:
        someModelInstance.setVal(42);
        ...

        return Response.ok("index.jsp").build();
    }

    @GET
    @Path("/b")
    public String showSomeOtherPage() {
        // Preparing the model:
        someModelInstance.setVal(43);

        return "page_b.jsp";
    }
}

然后,可以从调用的视图页面内部使用更新或初始化的模型。我们在前面与视图相关的文本小节中对此进行了描述。

将数据发布到控制器中

为了将用户输入从表单传输到控制器方法,您用@POST注释标记该方法,并添加表单字段作为该方法的参数:

@POST
@Path("/response")
public Response response(
    @MvcBinding @FormParam("name") String name,
    @MvcBinding @FormParam("userId") int userId) {

    // Handle form input, set model data, ...

    return Response.ok("response.jsp").build();
}

参数类型可以选择StringintlongfloatdoubleBigDecimalBigIntegerboolean ( truefalse)。如果您选择除了String之外的任何类型,Java MVC 确保用户输入被适当地转换。

@MvcBinding允许 Java MVC 忽略注入的BindingResult对象中的验证和转换错误。然后,您可以在POST方法中以编程方式处理这些错误:

...
import javax.mvc.binding.MvcBinding;
import javax.mvc.binding.ParamError;
import javax.mvc.binding.BindingResult;
import javax.ws.rs.FormParam;
...

@Path("/controllerPath")
@Controller
public class MyController {

    // Errors while fetching parameters
    // automatically go here:
    private @Inject BindingResult br;

    @POST
    @Path("/response")
    public Response response(
        @MvcBinding @FormParam("name") String name,
        @MvcBinding @FormParam("userId") int userId) {

        // ERROR HANDLING //////////////////////////
        if(br.isFailed()) {
          br.getAllErrors().stream().
               forEach((ParamError pe) -> {
            ...
          });
      }
      // END ERROR HANDLING //////////////////////

      // Handle form input, set model data, ...

      return Response.ok("response.jsp").build();
    }
}

除了将表单输入作为方法参数传递,您还可以使用控制器字段来接收数据:

import javax.mvc.Controller;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.core.Response;
import javax.inject.Inject;

@Path("/controllerPath")
@Controller
public class MyController {
    // Errors while fetching parameters
    // automatically go here:
    private @Inject BindingResult br;

    @MvcBinding @FormParam("name")
    private String name;

    @MvcBinding @FormParam("userId")
    private int userId;

    @POST
    @Path("/response")
    public Response response() {
        // Handle form input, set model data, ...

        return Response.ok("response.jsp").build();
    }
}

通常,建议使用方法参数,因为类实例字段在某种程度上表明参数传递是控制器类的责任,而不考虑使用哪种方法,而实际上取决于方法,哪些参数有意义。

如果您需要让查询参数( http://xyz.com/app?a=3&b=4 中的ab)对控制器方法可用,您基本上可以做与提交参数相同的事情。不同的是,您必须对查询参数使用QueryParam注释,如下所示:

...
import javax.mvc.binding.MvcBinding;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.QueryParam;

@Path("/")
@Controller
public class SomeController {
    private @Inject BindingResult br;
    @GET
    @Path("/start")
    public String someMethod(
          @MvcBinding @QueryParam("name") String name,
          @MvcBinding @QueryParam("birthday") String birthday
    ) {
        if(name != null) {
        // handle "name" parameter
      }
      if(birthday != null) {
        // handle "birthday" parameter
      }

      // advance to page
      return "index.jsp";
    }
    ...
}

这对于@GET@POST带注释的方法是可能的。

练习

  • 练习 1: 在两章前的HelloWorld应用中,删除控制器中的Models字段,代之以添加一个新的请求作用域模型类UserData,它有一个字段name。相应地更新控制器和视图。

  • 练习 2: 哪个是真的?JSP 由一个Servlet处理。或者,每个 JSP 被转换成一个新的Servlet

  • 练习 3:Facelets 和 JSP 哪个视图技术更新?

  • 练习 4: 对还是错?为了在 Java MVC 中使用 Facelets,您还必须使用 JSF。

摘要

对于 Java MVC 的模型部分,MVC 框架的最初想法是相当不变的。模型类只是 Java bean 类(具有字段、getters 和 setters 的类),开发人员可以通过编程将它们添加到视图中。在响应视图中,您可以使用类似于Hello ${beanName.name}的表达式来访问模型 beans。然而,Java MVC 是一个现代框架,它的模型能力取代了简单引用 beans 的概念。它通过在 CDI 2.0 版本中为 Jakarta EE 8 引入 CDI(上下文和依赖注入)技术来实现这一点。基本思想还是一样的:我们希望实例化 bean 类(主要包含字段及其 getters 和 setters 的数据类),并将这些实例提供给控制器和视图。前 CDI 和 CDI 方式的主要区别在于,我们不自己实例化模型类,而是让 CDI 来做。

为了告诉 Java MVC 我们想要一个模型类由 CDI 控制并对视图页面可用,我们使用了来自javax.inject包的@Named注释。我们还可以添加@RequestScoped注释,将对象实例的生命周期绑定到一个 HTTP 请求/响应周期。

一旦我们通过@Named向 CDI 框架宣布了一个 bean,在 Java MVC 中就会发生两件事。首先,我们可以使用@Inject(在javax.inject包中)从任何 Java MVC 控制器内部和任何其他 CDI 控制的类内部引用 bean 实例。其次,我们可以通过使用首字母小写的类名:${userData.name}${userData.email}来使用视图页面中的实例。

如果您使用 CDI 来管理模型数据,那么模型类实例从属于由 CDI 管理的生命周期控件。这意味着 CDI 决定什么时候构造 beans,什么时候放弃它们。在注入的 beans 中,CDI 控制实例生命周期的方式是通过一个叫做 scope 的特性。在 Java MVC 中,存在以下作用域:请求作用域、会话作用域、重定向作用域和应用作用域。

除了使用标有@Named注释的 CDI beans,还可以使用Models的注入实例(在javax.mvc包中)。模型值可以从内部视图页面获得,没有前缀:Hello ${name}

Java MVC 的视图部分负责向客户端(浏览器)呈现前端,包括输入和输出。那些连接到控制器方法的 Java MVC 视图文件放在WEB-INF/-views文件夹中,或者,因为我们使用 Gradle 作为构建框架,所以放在src/main/webapp/WEB-INF/views文件夹中。

Java MVC 开箱即用,支持两种视图引擎——JSP(Java server Pages)和 Facelets(JSF 视图声明语言,JavaServer Faces)。通过设计,其他视图引擎可以包含在基于 CDI 的扩展机制中。

JSP 允许您交错静态内容,例如 HTML 和由 JSP 元素表示的动态内容。一个 JSP 页面被内部编译成一个继承自Servlet的大 Java 类。包含 JSP 代码的文件以.jsp结尾。JSP 指令<% ... %>提供了容器的方向。要生成静态内容,只需在 JSP 文件中逐字编写即可。因为 JSP 被转录成 Java 类,所以 JSP 允许将 Java 代码和 Java 表达式包含到 JSP 页面中。在<%= ... %><% ... %>中,有几个你可以使用的隐式对象:

  • out:servlet 的类型JspWriter的输出流(扩展java.io.Writer)。

  • request:请求,类型HttpServletRequest

  • response:响应,类型HttpServletResponse

  • session:session,类型HttpSession

  • application:应用,类型ServletContext

  • config:servlet 配置,类型ServletConfig

  • page:servlet 本身,类型Object(运行时类型javax.servlet.http.HttpServlet)。

  • pageContext:页面上下文,类型PageContext

您可以使用这些对象来实现奇特的事情,但是请记住,如果您使用它们,您会以某种方式离开正式的开发模式。这可能会使其他人难以阅读您的代码,并且通过将功能放入视图页面,模型、视图和控制器之间的自然界限被打破。

带有@Named注释的 CDI beans 被直接提供给 JSP:Hello ${userName.name}。如果您将模型数据添加到注入的javax.mvc.Models CDI bean 中,您可以直接访问它而不需要前缀,如在Hello ${name}中所示。

${ ... }这样的 JSP 页面中的构造被视为表达式,由表达式语言处理程序处理。表达式中可以使用几个隐式对象:pageScoperequestScopesessionScopeapplicationScopeparamValuesparamheaderValuesheaderinitParamcookiepageContext

如果您喜欢使用标签进行动态输出,您可以使用如下的<c:out>标签:Hello <c:out value="${userData.name}" />

通过使用<c:set>标签,您可以引入变量以便在页面中进一步使用。

对于列表或数组上的循环,可以使用<c:forEach>标签(c表示jstl/core taglib)。如果您想对一个整数值范围循环使用<c:forEach>标记,您可以使用beginend属性,比如<c:forEach begin="1" end="10" var="i">

对于 JSP 内部的条件分支,您可以使用<c:if><c:choose>标签之一。

通过使用隐式的cookie对象,可以直接从 JSP 内部读取 Cookies。

除了 JSP 之外,Java MVC 支持的另一种视图技术叫做 Facelets 。Facelets 是专门为 JSF 创建的模板框架。Java MVC 的 Facelets 文件与 JSP 文件放在同一个文件夹中,即WEB-INF/views文件夹,或者,因为我们使用 Gradle 作为构建框架,所以放在src/main/webapp/WEB-INF/views文件夹中。Facelets 文件是 XML 文件,这可能是 JSP 和 Facelets 之间最显著的区别。

Facelets 允许您引入参数化的模板 HTML 页面、要包含在页面中的 HTML 片段(组件)、这种片段的占位符,以及诸如精心制作的列表视图之类的装饰器和重复。

对于 Java MVC,出于稳定性原因,我们不想将 JSF 组件混合到 Facelets 页面中。然而,这导致了严重的功能缺失,包括一个缺失的if-else构造。在 JSF 世界中,您通过rendered属性打开和关闭组件(或组件子树)。那么,如果我们想使用 Facelets for Java MVC,并且需要在某个视图页面上进行条件分支,我们该怎么做呢?答案简单得惊人。因为我们不使用 JSF 组件,所以我们可以简单地添加 JSTL 标签库,而不会破坏正常的页面呈现。然后我们可以使用<c:if><choose>标签。

对于 JSF,表达式语言处理已经扩展到使用延迟表达式,用#{ ... }代替${ ... }表示。在 JSF 组件对表单发起的请求做出反应之前,不会对这些延迟表达式进行求值。这样,就有可能使用表达式作为lvalue,也就是说你可以给它们分配用户输入。因此,#{ someBean.someProperty }可用于输出和输入。立即表达式和延迟表达式的组合,更准确地说是增强表达式语言,也被称为统一表达式。对于 Java MVC,表单输入由控制器方法专门处理。从设计上来说,没有将表单输入自动连接到 CDI beans 的功能。由于这个原因,我们不需要延迟表达式。

Caution

不要在 Java MVC Facelets 视图中使用延迟表达式#{ ... }

控制器类描述了 Java MVC 应用的动作部分。他们负责准备模型,接受用户请求,更新模型,并决定在请求后显示哪些视图页面。要将一个类标记为 Java MVC 控制器,向该类添加@Controller注释(在javax.mvc包中)和@Path注释(在javax.ws.rs包中)。

对于那些而非表单发布结果的页面,您可以使用GET动词并用@GET注释标记相应的方法。

在标有@GET@POST的控制器方法中,要么返回一个指向 JSP(或 Facelets 页面)的字符串,然后使用后缀.xhtml,要么返回一个Response对象。虽然返回一个字符串更容易,但是有了Response实例,你有了更多的选择。例如,您可以精确地指定 HTTP 状态代码,并实际指定状态代码(如 OK、服务器错误、已接受、已创建、无内容、未修改、查看其他、临时重定向或不可接受)。您还可以设置编码、缓存控制、HTTP 头、语言、媒体类型、过期时间和上次修改时间,并添加 cookies。

触发路径是通过连接类的'@Path注释和方法的@Path注释,然后在应用的 URL 路径前面加上前缀来计算的。

如果需要为被调用的页面准备模型值,可以在控制器中注入 CDI beans,并在控制器方法中调整它们的值。然后,可以从调用的视图页面内部使用更新或初始化的模型。

为了将用户输入从表单传输到控制器方法,您用一个@POST注释来标记该方法,并添加表单字段作为该方法的参数。参数类型可以选择StringintlongfloatdoubleBigDecimalBigIntegerboolean ( truefalse)。如果您选择除了String之外的任何类型,Java MVC 确保用户输入被适当地转换。

@MvcBinding允许 Java MVC 忽略注入的BindingResult对象中的验证和转换错误。然后,您可以在POST方法中以编程方式处理这些错误。

如果您需要让查询参数( http://xyz.com/app?a=3&b=4 中的ab)对控制器方法可用,您基本上可以做与提交参数相同的事情。不同的是,您必须为查询参数使用QueryParam注释。这对于@GET@POST带注释的方法是可能的。

在下一章,我们将讨论 Java MVC 更高级的主题。*

七、深入的 Java MVC:第二部分

在这一章中,我们将继续深入探讨 Java MVC。我们将讨论一些与上一章讨论的主题相比你会遇到的不太频繁的主题,但是根据具体情况,这些主题可能对你的项目很重要。这包括 bean 验证、可注入上下文、部分页面更新和观察者类。我们还加深了对状态处理的了解,并且包括了一些配置主题。

添加 Bean 验证

JSR 380 规范描述了 Bean 验证(版本 2.0)。完整规格可从 https://jcp.org/en/jsr/detail?id=380 下载。

这项技术是关于由注释定义的约束。您可以添加检查来确定字段或方法参数是否为空,数字是否超过某个下限或上限,字符串的大小是否在某个范围内,日期是在过去还是未来,等等。您甚至可以定义自己的自定义注释来检查某些参数或字段。

我们不讨论 bean 验证的所有可能性——规范和互联网上的许多教程可以告诉你更多。我们将讨论 bean 验证在 Java MVC 中的位置,我们将介绍一些您经常使用的内置约束,以及一些自定义约束。

在 Java MVC 中,你可以很容易地在表单旁边使用 bean 验证,并在控制器内部查询参数。如果您有约束,比如@ CONSTRAINT1、@ CONSTRAINT2等等(我们很快会谈到可能的值和约束参数),您可以使用以下任何一个:

   public class SomeController {

   // constraints for fields:
   @MvcBinding @FormParam("name")
   @CONSTRAINT1
   @CONSTRAINT2
   ...
   private String formParam; // or other type

   // or, for query parameters:

   @MvcBinding @QueryParam("name")
   @CONSTRAINT1
   @CONSTRAINT2
...
   private String queryParam; // or other type

   // or, in controller action:

   @POST
   @Path("/xyz")
   public Response someMethod(
    @MvcBinding @FormParam("name")
    @CONSTRAINT1
    @CONSTRAINT2
    ...
    String name )
   {
    ...
   }

   // or, for query parameters:

   @GET
   @Path("/xyz")
   public Response someMethod(
     @MvcBinding @QueryParam("name")
     @CONSTRAINT1
     @CONSTRAINT2
     ...
     String name )
   {
...
  }
}

任何违反都将作为错误被转发到注入的BindingResult:

   @Controller
   @Path("/xyz")
   public class SomeController {
     @Inject BindingResult br;
    ...
   }

例如,如果我们希望将表单参数字符串限制为多于两个但少于十个字符,我们编写以下代码:

   @Controller
   @Path("/xyz")
   public class SomeController {
     @Inject BindingResult br;

     @MvcBinding @FormParam("name")
     @Size(min=3,max=10)
     private String formParam;

    ...
   }

表 7-1 中定义了最有趣的内置 bean 验证约束。

表 7-1

内置 Bean 验证约束

|

名字

|

描述

|
| --- | --- |
| @Null | 检查该值是否为null。 |
| @NotNull | 检查该值是否不是null。 |
| @AssertTrue | 检查布尔值是否为true。 |
| @AssertFalse | 检查布尔值是否为false。 |
| @Min(min) | 检查数值(shortintlongBigDecimalBigInteger)是否大于或等于提供的参数。 |
| @Max(max) | 检查数值(shortintlongBigDecimalBigInteger)是否小于或等于提供的参数。 |
| @Negative | 检查数值(shortintlongBigDecimalBigInteger)是否小于零。 |
| @NegativeOrZero | 检查数值(shortintlongBigDecimalBigInteger)是否小于或等于零。 |
| @Positive | 检查数值(shortintlongBigDecimalBigInteger)是否大于零。 |
| @PositiveOrZero | 检查数值(shortintlongBigDecimalBigInteger)是否大于或等于零。 |
| @Size(min=minSize, max=maxSize) | 检查字符串值的长度是否在指定的界限之间。两个边界都是可选的;如果省略,则假定为0Integer.MAX_VALUE。例子:@Size(max=10)表示十号或十号以下。 |
| @NotEmpty | 检查该值是否不为空。对于字符串,这意味着字符串长度必须大于0。 |
| @NotBlank | 检查字符串值是否包含至少一个非空白字符。 |
| @Pattern(regexp=regExp,``flags={f1,f2,...}) | 检查字符串值是否与给定的正则表达式匹配。可选的flags参数可以是控制匹配的javax.validation.constraints.Pattern.Flag.*常量列表,例如不区分大小写。对于注释来说,如果列表中只有一个元素,可以省略{ }。 |
| @Email(regexp=regExp,``flags={f1,f2,...}) | 检查字符串值是否表示电子邮件地址。可选的regexpflags参数指定了一个附加模式,与@Pattern约束的含义相同。 |

您可以看到,对于 float 或 double 值,没有 min 或 max 检查。这些是故意漏掉的。由于可能的精度误差,这些类型的检查不能可靠地执行。

也可以定义自己的 bean 验证器。例如,由于缺少双值边界检查,您可能想要定义一个双精度(浮点)范围验证器(包括一些精度宽限)。对于这样的注释,您应该编写以下内容:

package book.javamvc.validation;

import javax.validation.Constraint;
import javax.validation.Payload;
import static java.lang.annotation.ElementType.*;
import java.lang.annotation.Retention;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
import java.lang.annotation.Target;

@Constraint(validatedBy = FloatRangeValidator.class)
@Target({ PARAMETER, FIELD })
@Retention(RUNTIME)
public @interface FloatRange {
   String message() default
     "Value out of range [{min},{max}]";
   Class<?>[] groups() default {};
   Class<? extends Payload>[] payload() default {};
   String[] value() default { };
   double min() default -Double.MAX_VALUE;
   double max() default Double.MAX_VALUE;
   double precision() default 0.0;
}

重要部分如下:

  • validatedBy = FloatRangeValidator.class

    实现类,请参见下一个代码部分。

  • @Target

    我们希望允许对字段和方法参数进行这种注释。

  • @Retention(RUNTIME)

    RUNTIME在这里很重要,所以注释不会在编译过程中丢失。

  • message()

    验证失败时显示的消息,带有参数的占位符。

  • value()

    如果没有命名参数,这是默认参数。我们想要引入三个命名参数——minmaxprecision——所以我们不使用默认参数。

  • min(), max(), precision()

    作为方法的三个命名参数。

  • groups(), payload()

    这里不用。

实现类检查代码,如下所示:

package book.javamvc.validation;

import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;

public class FloatRangeValidator implements
      ConstraintValidator<FloatRange, Number> {
   private double min;
   private double max;
   private double precision;

   @Override
   public void initialize(FloatRange constraint) {
      min = constraint.min();
      max = constraint.max();
      precision = constraint.precision();
}

   @Override
   public boolean isValid(Number value,
       ConstraintValidatorContext context) {
      return value.doubleValue() >=
       (min == -Double.MAX_VALUE ? min :
          min - precision)
        && value.doubleValue() <= (max == Double.MAX_VALUE ?
          max : max + precision);
   }
}

被覆盖的isValid()方法执行实际的验证。在这种情况下,我们必须确保精度宽限不会应用于默认值+/- Double.MAX_VALUE

为了向 Java MVC 控制器添加新的约束,我们使用了与内置约束相同的方法:

...
import book.javamvc.validation.FloatRange;
...

@Path("/abc")
@Controller
public class SomeController {
   @MvcBinding @FormParam("theDouble")
   @FloatRange(min=1.0, max=2.0, precision = 0.000001)
   private double theDouble;

   ...
}

作为另一个使用value注释缺省参数的定制 bean 验证器,考虑一个只允许特定集合中的字符串值的检查。我们称之为StringEnum,其代码如下:

package book.javamvc.validation;

import javax.validation.Constraint;
import javax.validation.Payload;
import static java.lang.annotation.ElementType.*;
import java.lang.annotation.Retention;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
import java.lang.annotation.Target;

@Constraint(validatedBy = StringEnumValidator.class)
@Target({ PARAMETER, FIELD })
@Retention(RUNTIME)
public @interface StringEnum {
   String message() default
     "String '${validatedValue}' not inside {value}";
   Class<?>[] groups() default {};
   Class<? extends Payload>[] payload() default {};
   String[] value() default { };
}

这一次,没有引入命名参数,只有默认的value属性。实现如下所示:

package book.javamvc.validation;

import java.util.Arrays;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;

public class StringEnumValidator implements
     ConstraintValidator<StringEnum, String> {
   private String[] val;

   @Override
   public void initialize(StringEnum constraint) {
     this.val = constraint.value();
}

   @Override
   public boolean isValid(String value,
        ConstraintValidatorContext context) {
     return Arrays.asList(val).contains(value);
   }
}

因为只有一个默认参数,所以我们不需要使用它的名称:

...
import book.javamvc.validation.StringEnum;
...

@Path("/abc")
@Controller
public class SomeController {
   @MvcBinding @FormParam("fruit")
   @StringEnum({"grape", "apple", "banana"})
   private String fruit;

   ...
}

到目前为止,对于验证失败消息,我们已经看到了用于注释默认值的形式为{paramName}{value}的命名参数占位符,以及用于检查值的表达式语言结构${validatedValue}。在国际化的应用中,如果我们能够添加对本地化消息文件的引用,那就更好了。这是可能的,捆绑文件的名字是ValidationMessages.properties。本地化的属性文件具有以下名称:

   ValidationMessages.properties      (default)
   ValidationMessages_en.properties   (English)
   ValidationMessages_fr.properties   (French)
   ValidationMessages_de.properties   (German)
   ...

在 Gradle 项目布局中,你应该把它们放在src/main/resources文件夹中。在属性文件中,您可以编写如下消息:

   myapp.user.name.error = Invalid User Name: \
      ${validatedValue}
   myapp.user.address.error = Invalid Address
   ...

在 bean 验证注释的 message 方法中,使用花括号和属性键名:

   String message() default
      "{myapp.user.name.error}";

Note

像这样的资源包属于 JRE 标准。使用Validation-Messages作为基本名称是 bean 验证技术的惯例。

可注射环境

在 Java MVC 控制器类中,我们可以使用几个上下文对象。基本上有两种方法可以访问它们。首先,我们可以在类实例级别使用 CDI 提供的@Inject注释,如下所示:

...
import javax.servlet.http.HttpSession;
import javax.mvc.MvcContext;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.ServletContext;
import javax.mvc.binding.BindingResult;
import javax.ws.rs.core.Application;
import javax.enterprise.inject.spi.BeanManager;
...

@Controller
public class SomeController {
   // Access to the session. You can use it to retrieve
   // the session ID, the creation time, the last
   // accessed time, and more.
   @Inject private HttpSession httpSession;

   // Access to the MVC context. This is a context
   // object provided by Java MVC. You can use it to
   // construct URIs given the simple controller name
   // and method name, to retrieve the current
   // request's locale, to look up the base URI, and
   // more.
   @Inject private MvcContext mvcContext;

   // Access to the current servlet request. You can use
   // it to get various HTTP request related properties,
   // like headers, user information, and many more.
   @Inject private HttpServletRequest httpServletRequest;

   // Access to the servlet context. There you can for
   // example get the URI of a resource file, or an
   // info about the server (container), and more.
   @Inject private ServletContext servletContext;

   // Use this to fetch conversion and validation errors.
   // Parameters (@FormParam or @QueryParam) must have
   // been marked with @MvcBinding for this error
   // fetching process to work.
   @Inject private BindingResult bindingResult;

   // Use this to access the application scope
   // Application object. You can for example register
   // and retrieve application-wide custom properties.
   @Inject private Application application;

   // In case you ever need to have programmatic access
   // to CDI, you can inject the BeanManager. This can
   // also be handy for diagnostic purposes.
   @Inject private BeanManager beanManager;

   ...
}

其次,作为 Java MVC 的一个附加特性,也可以将javax.ws.rs.core.Requestjavax.ws.rs.core.HttpHeaders直接注入到控制器方法中:

...
import javax.ws.rs.core.Context;
import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.core.Request;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
...

@Controller
public class SomeController {
...

@GET   // or @POST
public String someMethod(
   ... query and post parameters...,
   @Context HttpHeaders httpHeaders,
   @Context Request request)
   {
   ...
   }

   ...
}

在方法的参数列表中的什么位置添加这样的@Context参数并不重要。然后,httpHeaders参数允许访问 HTTP 头值、语言、cookie 值等等。request参数为前提条件和变量提供了帮助器方法(在本书中我们不谈论前提条件和变量)。

关于这种注入类型的更多细节,请参考 API 文档(Jakarta EE、JAX RS 和 Java MVC)。

持续状态

如果您需要在几个请求之间保持状态,那么来自javax.servlet.http包的HttpSession类就是您的朋友。每当用户在浏览器上启动 web 应用时,就会创建一个HttpSession实例。一旦存在,只要满足以下所有条件,完全相同的会话对象将透明地分配给任何后续的 HTTP 请求/响应周期:

  • 用户停留在同一服务器上的同一 web 应用中

  • 用户使用相同的浏览器实例(浏览器没有重新启动)

  • 由于超时,会话未被容器销毁

  • web 应用没有显式销毁会话

在您的 web 应用中,您通常不需要采取任何预防措施来使用会话。您所要做的就是注册会话范围的 CDI beans:

...
import javax.enterprise.context.SessionScoped;
...
@Named
@SessionScoped
public class UserData {
...
}

@Controller
public class SomeController {
   @Inject UserData userData;
   // <- same object inside a session
   ...
}

该容器自动确保在同一个浏览器会话中,每个会话范围的 CDI bean 只使用一个实例。

Note

服务器通过 cookies 透明地维护会话标识,在 URL 查询参数中自动添加会话 id,或者在表单中添加不可见字段。

我们已经知道,要以编程方式访问会话数据,我们可以将会话作为类实例字段注入:

   ...
   import javax.servlet.http.HttpSession;
   ...

   @Controller
   public class SomeController {
     @Inject private HttpSession httpSession;
     ...
   }

这也是我们可以以编程方式要求会话 ID: httpSession.getId()(一个字符串)的地方。或者我们可以使一个会话无效:httpSession.invalidate()

会话数据对于 web 应用正常工作可能很重要,但是请记住,对于许多并发工作的 web 用户来说,您也有许多并发活动的会话。因此,如果您在会话存储中存储了许多数据项,那么 web 应用的内存占用将会增加,可能会破坏应用的稳定。

处理页面片段

我们了解到,JSP 视图页面的逐字输出没有进行语法正确性检查。例如,这样一个文件:

<%@ page language="java"
    contentType="text/html;charset=UTF-8" %>
<%@ taglib prefix = "c"
    uri = "http://java.sun.com/jsp/jstl/core" %>
<%@ taglib prefix = "fmt"
    uri = "http://java.sun.com/jsp/jstl/fmt" %>

This is a JSP generated page. Hello ${userData.name}

实际上是一个正确的 JSP 页面,尽管它不产生有效的 HTML。输出符合text/plain媒体类型,因此相应的控制器方法可以读取以下内容:

...
import javax.mvc.Controller;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;

@Path("/abc")
@Controller
public class SomeController {
   // Assuming the JSP is stored at
   // WEB-INF/views/fragm1.jsp
   @POST
   @Path("/fragm1")
   public Response fragm1(...) {
   ...
    return Response.ok("fragm1.jsp").
       type(MediaType.TEXT_PLAIN).build();
   }
}

您甚至可以将这个响应发送到浏览器客户端,它通常会产生简单的文本输出(至少我所知道的大多数浏览器都是这样)。这样的页面既不包含任何格式指令,也不可能显示任何输入字段,所以问题是应用如何利用这些不完整的输出。

当 MVC 发明的时候,通常的范例是在任何用户提交之后重新加载整个页面,或者如果导航需要的话,加载一个 ?? 的新页面。Web 开发人员从一开始就感到不舒服,即使对结果页面进行非常小的更改,也会导致整个页面在网络上传递。这似乎只是不必要的网络资源浪费。出于这个原因,在 2000 年代中期,AJAX 开始变得越来越流行。AJAX(异步 JavaScript 和 XML)允许浏览器使用 JavaScript 从服务器请求数据,并再次使用 JavaScript 将结果写入页面。为了确保最大的前端可用性,这发生在后台(异步),用户可以在 AJAX 进程仍然活动的时候操作浏览器。

现代高度动态的 web 应用经常使用 AJAX,所以这就引出了一个问题:我们是否也可以在 Java MVC 内部使用 AJAX。

答案是肯定的,因为我们知道我们可以向服务器请求页面片段。所缺少的只是启动 AJAX 服务器请求的几个 JavaScript 函数,然后将来自服务器的结果处理成相应的页面部分。您可以使用普通的 JavaScript 来实现这个目的,但是使用像 jQuery 这样的 JavaScript 库可以方便地消除浏览器差异并简化 AJAX 处理。

例如,我们从第四章的中恢复了HelloWorld应用,并为 AJAX 请求添加了第二个表单和一个显示 AJAX 调用结果的区域。

首先我们添加 jQuery 库,你可以从 https://jquery.com/download/ 下载。任何像样的版本都可以(例子是用版本 3.5.1 测试的)。将文件移动到src/main/webapp/js

Note

除了 AJAX 之外,jQuery 库还提供了更多的工具功能。您还可以获得查找 HTML 元素、遍历 DOM、操作 HTML 元素等功能。

接下来,我们更新index.jsp以包含 jQuery,并添加一个新表单和一个区域来接收 AJAX 响应:

...
<head>
   ...
   <script type="text/javascript"
      src="${mvc.basePath}/../js/jquery-3.5.1.min.js">
   </script>
</head>
<body>
   ...
   <form>
     <script type="text/javascript">
        function submitAge() {
          var age = jQuery('#age').val();
          var url = "${mvc.uriBuilder(
            'HelloWorldController#ageAjax'). build()}";
          jQuery.ajax({
            url : url,
           method: "POST",
           data : { age: age },
           dataType: 'text',
           success: function(data, textStatus, jqXHR) {
             jQuery('#ajax-response').html(data);
           },
           error: function (jqXHR, textStatus,
               errorThrown) {
             console.log(errorThrown);
          }
       });
       return false;
     }
    </script>
    Enter your age: <input type="text" id="age" />
    <button onclick="return submitAge()">Submit</button>
   </form>
   <div>
     <span>AJAX Response: </span>
     <div id="ajax-response">
     </div>
   </div>
   ...
</body>
...

关于这个 JSP 代码,有几个重要的注意事项似乎是合适的:

  • <div id = "ajax-response">只是一个占位符。一旦 AJAX 调用返回数据,它就会被 JavaScript 填充。

  • JavaScript 函数内部的${ ... }是一个表达式语言构造,只有当 JSP 引擎看到它时,它才会被正确处理。因此,如果没有进一步的预防措施,您无法将这段 JavaScript 代码导出到一个script.js文件中。在将代码导出到自己的文件之前,您可以将 URL 作为参数添加到函数:function submitAge( url ) { ... }。在onclick = ...事件处理程序声明中,你必须写下onclick = "return submitAge( '${ ... }' )"

  • 该表单从未被提交。这就是为什么它没有一个action属性,并且onclick处理程序返回false。如果使用 AJAX,实际上并不需要<form>。为了清楚起见,我们在这里添加它。

  • 要使用 jQuery 对象,通常要应用快捷符号$(它与jQuery中的含义相同)。我们不能在 JSP 页面中这样做,因为,在那里,$开始一个 JSP 表达式。

  • 为简单起见,AJAX 错误只是写入控制台。在实际应用中,您应该将错误消息放在用户可见的地方。

  • <head>脚本标签中,当然要参考你下载的 jQuery 版本。

  • dataType: 'text'指的是返回text/plain数据的 AJAX 调用。如果服务器返回不同的内容,例如 XML 或 JSON,您必须更改它。

您向控制器类添加了一个新的与 AJAX 相关的方法:

   @POST
   @Path("/ageAjax")
   public Response ageAjax(
     @MvcBinding @FormParam("age")
     int age)
   {
     if(br.isFailed()) {
       br.getAllErrors().stream().
          forEach((ParamError pe) -> {
            errorMessages.addMessage(
              pe.getParamName() + ": " +
              pe.getMessage());
           });
     }
     userData.setAge(age);
     return Response.ok("ageAjaxFragm.jsp").
         type(MediaType.TEXT_PLAIN).build();
}

这里假设我们在控制器类中使用了一个private @Inject UserData userData;字段,并且UserData得到了一个新的age字段:

package book.javamvc.helloworld;

import javax.enterprise.context.RequestScoped;
import javax.inject.Named;

@Named
@RequestScoped
public class UserData {
   private String name;
   private int age;
   // Getters and setters...
}

我们在第四章的一个练习中介绍了这个类。

src/main/webapp/-WEB-INF/views内的片段页ageAjaxFragm.jsp是从控制器类寻址的。因此,AJAX 请求如下所示:

<%@ page language="java"
    contentType="text/html;charset=UTF-8" %>
<%@ taglib prefix = "c"
   uri = "http://java.sun.com/jsp/jstl/core" %>
<%@ taglib prefix = "fmt"
   uri = "http://java.sun.com/jsp/jstl/fmt" %>

This is a JSP generated fragment. Your age is: ${userData. age}

观察者

借助于 CDI,Java MVC 提供了一个优雅的观察者机制,您可以使用它来处理横切关注点,比如日志记录、监控和性能测量,或者仅仅用于诊断目的。

您所要做的就是提供一个带有一个或多个方法的 CDI bean 类,这些方法带有一个来自javax.mvc.event包的事件类型的参数。应标有@Observes(在javax.enterprise.event包装内):

package book.javamvc.helloworld.event;

import java.io.Serializable;
import java.lang.reflect.Method;

import javax.enterprise.context.SessionScoped;
import javax.enterprise.event.Observes;
import javax.mvc.event.AfterControllerEvent;
import javax.mvc.event.AfterProcessViewEvent;
import javax.mvc.event.BeforeControllerEvent;
import javax.mvc.event.BeforeProcessViewEvent;
import javax.mvc.event.ControllerRedirectEvent;

@SessionScoped
public class HelloWorldObserver implements Serializable {
private static final long serialVersionUID =
     -2547124317706157382L;

public void update(@Observes BeforeControllerEvent
      beforeController) {
   Class<?> clazz = beforeController.getResourceInfo().
      getResourceClass();
   Method m = beforeController.getResourceInfo().
      getResourceMethod();
   System.err.println(this.toString() + ": " +
      clazz + " - " + m);
}

public void update(@Observes AfterControllerEvent
       afterController) {
   System.err.println(this.toString() + ": " +
       afterController);
}

public void update(@Observes ControllerRedirectEvent
       controllerRedirect) {
   System.err.println(this.toString() + ": " +
       controllerRedirect);
}

public void update(@Observes BeforeProcessViewEvent
       beforeProcessView) {
   String view = beforeProcessView.getView();
   System.err.println(this.toString() + ": " +
      view);
}

public void update(@Observes AfterProcessViewEvent
       afterProcessView) {
   System.err.println(this.toString() + ": " +
       afterProcessView);
   }
}

仅此而已。Java MVC 在处理请求的过程中负责调用适当的 observer 方法。

@SessionScoped标记观察者类并不是观察者类工作的必要条件。但是,如果您需要收集运行时间,如下所示:

package book.javamvc.helloworld.event;

import java.io.Serializable;
import java.lang.reflect.Method;
import java.time.Instant;

import javax.enterprise.context.SessionScoped;
import javax.enterprise.event.Observes;
import javax.mvc.event.AfterControllerEvent;
import javax.mvc.event.BeforeControllerEvent;

@SessionScoped
public class HelloWorldObserver implements Serializable {
   private long controllerStarted;

   public void update(@Observes BeforeControllerEvent
      beforeController) {
    controllerStarted = Instant.now().toEpochMilli();
    ...
}

   public void update(@Observes AfterControllerEvent
       afterController) {
    long controllerElapseMillis =
      Instant.now().toEpochMilli()
      - controllerStarted;
    ...
   }
   ...
}

重要的是,我们只有一个跨越多次调用的 observer 类实例,使用会话范围可以确保这一点。如果不需要,可以删除@SessionScoped注释(这与使用@Dependent范围注释是一样的)。

Note

Serializable标记接口是会话范围 CDI bean 正确工作所必需的。如果忽略它,您将得到一个运行时错误消息。

配置

由于 Java MVC 位于 JAX-RS 之上,我们可以使用一个继承自javax.ws.rs.core.Application的类来添加一个条目到 URL 上下文路径:

package any.project.package;

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

@ApplicationPath("/mvc")
public class App extends Application {
}

这个类是故意空的。上下文路径元素/mvc由注释单独添加。由此产生的 URL 是一个依赖于服务器的路径,加上/mvc,再加上控制器的@Path注释中指定的任何内容。我们在本书中经常使用这种应用配置。

Note

对于 GlassFish,这个依赖于服务器的路径默认为 http://ser.ver.addr:8080/WarName/ ,其中WarName需要替换为部署的 WAR 文件的名称,减去.war文件后缀。

您可以在Application类中指定更多的配置项。这一次,我们重写了getProperties()方法,并编写了以下代码:

package any.project.package;

import javax.ws.rs.ApplicationPath;
import javax.ws.rs.core.Application;
import javax.mvc.engine.ViewEngine;
import javax.mvc.security.Csrf;
...

@ApplicationPath("/mvc")
public class App extends Application {
   @Override
   public Map<String,Object>getProperties(){
     final Map<String,Object> map = new HashMap<>();
     // This setting makes sure view files
     // will be looked up at some specified location
     // (default is /WEB-INF/views)
     map.put(ViewEngine.VIEW_FOLDER,"/jsp/");

     // Set a CSRF (cross site request forgery)
     // security mode. See Chapter 4 of the
     // specification
     map.put(Csrf.CSRF_PROTECTION, Csrf.CsrfOptions.OFF);   // default
     // ...or...
     map.put(Csrf.CSRF_PROTECTION, Csrf.CsrfOptions.EXPLICIT);
     // ...or...
     map.put(Csrf.CSRF_PROTECTION, Csrf.CsrfOptions.IMPLICIT);

    // Set CSRF header name. See Chapter 4 of the
     // specification. Default is "X-CSRF-TOKEN".
     map.put(Csrf.CSRF_HEADER_NAME,
      "CSRF-HDR");

     return map;
   }
}

要添加一个欢迎文件(一个登录页面),再次避免使用web.xml XML 配置文件来简化开发,您可以使用 HTTP 过滤器,如下所示:

package any.project.package;

import javax.servlet.FilterChain;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpFilter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@WebFilter(urlPatterns = "/")
public class RootRedirector extends HttpFilter {
   private static final long serialVersionUID =
       7332909156163673868L;
   @Override
   protected void doFilter(final HttpServletRequest req,
      final HttpServletResponse res,
      final FilterChain chain) throws IOException {
    res.sendRedirect("mvc/facelets");
   }
}

如果以这种方式使用,一个 URL http://my.server:8080/TheWAR/ (在 GlassFish 上,这对应于/,因为这是基 URL)将发送一个REDIRECThttp://my.server:8080/TheWAR/mvc/facelets ,这又应该触发例如一个 Java MVC 控制器的@GET注释方法。在这个例子中,来自控制器类的@Path加上来自控制器方法的@Path必须连接到/facelets(记住,前面的mvc/来自前面的应用配置)。

练习

  • 练习 1:4 的HelloWorld应用中的,首先确保使用了一个模型类UserData,然后添加一个名为age的新整数字段。更新视图中的表单,并添加一个名为“你的年龄是多少?”。更新控制器,并应用 bean 验证约束,确保用户输入的年龄大于零。添加错误处理,如第六章所述。还可以通过添加年龄来更新响应页面。

  • 练习 2: 在第四章的HelloWorld应用中,将会话注入控制器。在控制器的showIndex()方法中,将会话 ID 写入System.err

  • 练习 3: 在第四章的HelloWorld应用中,将头注入到greeting()方法中。将所有请求头写入System.err

  • 练习 4: 在前一个练习中,将年龄输入字段提取到一个新的表单中,并使用 AJAX 响应来自该字段的用户输入(添加一个按钮)。使用 JSON 作为 AJAX 响应({"Text" : "Your age is ... " })编写一个页面片段,并让它将响应传递到一个区域<div id = "ajax-response"> </div>。使用 jQuery 作为 JavaScript AJAX 库。

  • 练习 5: 在第四章的HelloWorld应用中,编写一个计算控制器响应时间的观察器。将结果输出到System.err

摘要

JSR 380 规范描述了 Bean 验证(版本 2.0)。这项技术是关于由注释定义的约束。您可以检查字段或方法参数是否为空,数字是否超过某个下限或上限,字符串的大小是否在某个范围内,日期是在过去还是未来,等等。您甚至可以定义自己的自定义注释来检查某些参数或字段。

在 Java MVC 中,你可以很容易地在表单旁边使用 bean 验证,并在控制器内部查询参数。如果您有约束,比如@ CONSTRAINT1、@ CONSTRAINT2等等,您可以将它们添加到控制器的字段和方法参数中。任何违反都将作为错误被转发到注入的BindingResult中。

我们可以在 Java MVC 控制器类中使用几个上下文对象。基本上有两种方法可以访问它们。首先,我们可以在类实例级别使用 CDI 提供的@Inject注释。第二,作为 Java MVC 的一个附加特性,也可以将javax.ws.rs.core.Requestjavax.ws.rs.core.HttpHeaders直接注入到控制器方法中。在方法的参数列表中的什么地方添加这样的@Context参数并不重要。然后,httpHeaders参数允许访问 HTTP 头值、语言、cookie 值等等。request参数为前提条件和变量提供了 helper 方法(本书中我们不谈前提条件和变量)。

关于这种注入类型的更多细节,请参考 API 文档(Jakarta EE、JAX RS 和 Java MVC)。

如果您需要在几个请求之间保持状态,那么来自javax.servlet.http包的HttpSession类就是您的朋友。每当用户在浏览器上启动 web 应用时,就会创建一个HttpSession实例。一旦存在,只要满足以下所有条件,完全相同的会话对象将透明地分配给后续的 HTTP 请求/响应周期:

  • 用户停留在同一服务器上的同一 web 应用中

  • 用户使用相同的浏览器实例(浏览器没有重新启动)

  • 由于超时,会话未被容器销毁

  • web 应用没有显式销毁会话

在您的 web 应用中,您通常不需要采取任何预防措施来使用会话。您所要做的就是通过@SessionScoped注释注册会话范围的 CDI beans。该容器自动确保在同一个浏览器会话中使用每个会话范围的 CDI bean 的恰好一个实例。

会话数据对于您的 web 应用正常工作可能很重要,但是请记住,对于许多并发工作的 web 用户来说,您也有许多并发活动的会话。因此,如果您在会话存储中存储许多数据项,您的 web 应用的内存占用将会增加,可能会使应用不稳定。

我们了解到,JSP 视图页面的逐字输出没有进行语法正确性检查。所以一个文件可以是一个正确的 JSP 页面,即使它不产生有效的 HTML。例如,如果输出符合text/plain媒体类型,相应的控制器方法返回可能如下所示:

return Response.ok("fragm1.jsp" ).type( MediaType.TEXT_PLAIN ).build();

您甚至可以将这个text/plain响应发送到浏览器客户端,这通常会产生简单的文本输出。这样的text/plain页面既不包含任何格式指令,也不可能显示任何输入字段,所以问题是应用如何利用这些不完整的输出。

当 MVC 发明的时候,通常的范例是在任何用户提交之后重新加载整个页面,或者如果导航需要的话,加载一个 ?? 的新页面。Web 开发人员从一开始就感到不舒服,即使对结果页面进行非常小的更改,也会导致整个页面在网络上传递。这似乎是对网络资源不必要的浪费。出于这个原因,在 21 世纪中期,AJAX 开始变得越来越流行。AJAX(异步 JavaScript 和 XML)允许浏览器使用 JavaScript 从服务器请求数据,并再次使用 JavaScript 将结果写入页面。为了确保最大的前端可用性,这发生在后台(异步),用户可以在 AJAX 进程仍然活动的时候操作浏览器。

现代高度动态的 web 应用经常使用 AJAX,所以这就引出了一个问题:我们是否也可以在 Java MVC 内部使用 AJAX。答案是肯定的,因为我们知道我们可以向服务器请求页面片段。所缺少的只是启动 AJAX 服务器请求的几个 JavaScript 函数,然后将来自服务器的结果处理成相应的页面部分。您可以使用普通的 JavaScript 来实现这个目的,但是使用像 jQuery 这样的 JavaScript 库可以方便地消除浏览器差异并简化 AJAX 处理。然后,向控制器类添加一个新的与 AJAX 相关的方法。

借助于 CDI,Java MVC 提供了一个优雅的观察者机制,您可以使用它来处理横切关注点,比如日志记录、监控和性能测量,或者仅仅用于诊断目的。您所要做的就是提供一个带有一个或多个方法的 CDI bean 类,这些方法带有一个来自javax.mvc.event包的事件类型的参数。必须标有@Observes(在javax.enterprise.event包装内)。然后,Java MVC 在处理请求的过程中负责调用适当的 observer 方法。

由于 Java MVC 位于 JAX-RS 之上,我们可以使用一个继承自javax.ws.rs.core.Application的类来添加一个条目到 URL 上下文路径。这个类是故意空的。上下文路径元素/mvc由注释单独添加。您可以在Application类中指定更多的配置项。例如,您可以覆盖getProperties()方法来添加属性。

要添加一个欢迎文件(一个登录页面),再次避免使用web.xml XML 配置文件来简化开发,您可以使用 HTTP 过滤器。

在下一章,我们将讨论 Java MVC 应用的国际化。

八、国际化

Java 通过资源包提供内置的国际化支持。可以在不同语言相关的属性文件中保存不同语言的文本片段。使用标记,还可以以特定于地区的格式输出数字和日期,Java MVC 可以根据地区处理用户输入。

语言资源

在标准 JSP 中,与语言相关的资源由fmt:setBundlefmt:bundle标记以及fmt:message标记来处理,后者使用key属性来引用包中的文本。例如,您可以编写以下内容:

<%@ taglib uri="http://java.sun.com/jsp/jstl/core"
    prefix="c" %>
<%@ taglib prefix="fmt"
    uri="http://java.sun.com/jsp/jstl/fmt" %>
<html>
<head>
    <title>JSTL Bundles</title>
</head>
<body>
   <fmt:bundle
      basename="book.javamvc.helloworld.messages.Messages">
    <fmt:message key="msg.first"/><br/>
    <fmt:message key="msg.second"/><br/>
    <fmt:message key="msg.third"/><br/>
   </fmt:bundle>
</body>
</html>

属性指定了语言文件在文件系统中的位置。

对于 Facelets,通常使用 JSF 方法来访问语言资源。在 JSF 配置文件faces-config.xml中,您编写以下内容:

<?xml version="1.0" encoding="UTF-8"?>
<faces-config 
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-facesconfig_2_0.xsd" version="2.0">
   <application>
     <resource-bundle>
       <base-name>
          book.javamvc.helloworld.messages.Messages
      </base-name>
      <var>msg</var>
     </resource-bundle>
...
   </application>
</faces-config>

在 JSF/Facelets 页面中,你可以简单地写${msg.MSG_KEY}来引用消息。

对于book.javamvc.helloworld.messages.Messages基础名称(以及 JSP 和 Facelets),在src/main/resources/book/javamvc/helloworld/messages文件夹中,您现在添加这些属性文件:Messages.properties(默认)、Messages_en.properties(英语)、Messages_en_US.properties(英语变体)、Messages_de.properties(德语),等等:

-- File 'Messages.properties':
msg.first = First Message
msg.second = Second Message
msg.third = Third Message

-- File 'Messages_en.properties':
msg.first = First Message
msg.second = Second Message
msg.third = Third Message

-- File 'Messages_de.properties':
msg.first = Erste Nachricht
msg.second = Zweite Nachricht
msg.third = Dritte Nachricht

这些方法可能适合您的需要,您可以自由使用它们。不过,这也有一些缺点:

  • 对于 JSP,消息依赖于fmt:标签库。我们不能编写类似于${msg.first}的东西来访问消息。

  • 对于 JSP,您必须使用相当笨拙的语法<input title = "<fmt:message key = "msg.first" />" />在属性中放置消息。带有语法突出显示的编辑器可能无法应付这种情况。

  • 对于 JSP,视图需要知道一些内部的东西,比如语言属性文件的文件位置。通常,视图不应该处理这样的内部问题。

  • 对于 Facelets,我们必须混合使用 JSF 和 Java MVC,因为架构范式不匹配,这是我们想要避免的。

在本章的下一节,我们将研究出另一种消息访问方法。

向会话添加本地化消息

如果我们可以编写${msg.KEY}来访问页面上任何地方的本地化消息就好了,对于 JSP 和 Facelets,而不需要进一步的 JSF 配置。为了实现这一点,我们让一个@WebFilter注册一个本地化的资源包作为会话属性:

package book.javamvc.i18n;

import java.io.IOException;

import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpServletRequest;

@WebFilter("/*")
public class SetBundleFilter implements Filter {

   @Override
   public void init(FilterConfig filterConfig)
     throws ServletException {
   }

   @Override
   public void doFilter(ServletRequest request,
                        ServletResponse response,
                        FilterChain chain)
     throws IOException, ServletException {
   BundleForEL.setFor((HttpServletRequest) request);
     chain.doFilter(request, response);
   }

   @Override
   public void destroy() {
   }
}

对于任何请求都会调用doFilter()方法("/*"是匹配任何请求的 URL 模式),它会将请求发送给BundleForEL类。

定制的 bundle 类从请求中提取会话和语言环境,并在会话的属性存储中注册自己。代码内容如下:

package book.javamvc.i18n;

import java.util.Enumeration;
import java.util.Locale;
import java.util.ResourceBundle;

import javax.servlet.http.HttpServletRequest;

public class BundleForEL extends ResourceBundle {
   // This is the variable name used in JSPs
   private static final String TEXT_ATTRIBUTE_NAME =
     "msg";

   // This is the base name (including package) of
   // the properties files:
   // TEXT_BASE_NAME + ".properties"    -> default
   // TEXT_BASE_NAME + "_en.properties"      -> English
   // TEXT_BASE_NAME + "_en_US.properties"
   // TEXT_BASE_NAME + "_fr.properties"      -> Fench
   // ...
   private static final String TEXT_BASE_NAME =
     "book.javamvc.helloworld.messages.Messages";

   private BundleForEL(Locale locale) {
     setLocale(locale);
   }

   public static void setFor(
       HttpServletRequest request) {
    if (request.getSession().
          getAttribute(TEXT_ATTRIBUTE_NAME) == null) {
       request.getSession().
          setAttribute(TEXT_ATTRIBUTE_NAME,
            new BundleForEL(request.getLocale()));
     }
   }

   public static BundleForEL getCurrentInstance(
       HttpServletRequest request) {
     return (BundleForEL) request.getSession().
       getAttribute(TEXT_ATTRIBUTE_NAME);
   }

   public void setLocale(Locale locale) {
     if (parent == null ||
           !parent.getLocale().equals(locale)) {
         setParent(getBundle(TEXT_BASE_NAME, locale));
     }
   }

   @Override
   public Enumeration<String> getKeys() {
     return parent.getKeys();
   }

   @Override
   protected Object handleGetObject(String key) {
     return parent.getObject(key);
   }
}

ResourceBundle的 API 文档包含关于被覆盖方法的详细信息。对我们的目的很重要的是setFor()方法,它将本地化的包注册为会话属性。来自 JSTL 的 EL(和 Facelets)开箱即用,知道如何处理Resource-Bundle对象,因此我们可以编写以下代码:

   ${msg.MSG_KEY}
   <someTag someAttr="${msg.MSG_KEY}" />

要从 JSP 或 Facelets 内部访问本地化的消息,请用属性文件内部使用的消息键替换MSG_KEY

因为新开发人员很难理解msg指的是什么,所以您应该在每个 JSP 或 Facelets 页面中添加注释,描述msg的来源:

<%-- ${msg} is the localized bundle variable,
     registered by class SetBundleFilter      --%>

将此用于 Facelets:

<ui:remove> ${msg} is the localized bundle variable,
     registered by class SetBundleFilter    </ui:remove>

Note

这个<ui:remove> ... </ui:remove>乍一看很奇怪。然而,如果你使用 HTML 注释<!– –>,它们写入输出。<ui:remove>标签实际上确保了里面的所有东西都将被丢弃用于渲染。

格式化视图中的数据

如果在一个视图页面上,你写下${dbl}并且dbl指的是一个双值数字,那么这个数字的toString()表示就会被打印出来。只有当您的前端用户希望数字采用英语区域设置时,这才是可接受的。为了确保所有其他用户根据他们的国家规则获得预期的数字格式,JSTL 提供了一个 http://java.sun.com/jsp/jstl/fmt 标签库,它使用地区信息收集用于对象格式化的标签。

这个标签库的完整规格可以在 https://docs.oracle.com/javaee/5/jstl/1.1/docs/tlddocs/fmt/tld-frame.html (一行)查看,但是最重要的两个标签是<fmt:formatNumber><fmt:formatDate>。在 JSP 页面中使用<fmt:formatNumber>,如下所示:

<%@ page contentType="text/html;charset=UTF-8"
    language="java" %>
<%@ taglib prefix="c"
    uri="http://java.sun.com/jsp/jstl/core" %>
<%@ taglib prefix="fmt"
    uri="http://java.sun.com/jsp/jstl/fmt" %>
<html>
...
   <%-- Supposing ${dbl1} refers to a float or double --%>

   <fmt:formatNumber value="${dbl1}" type="number" var="n1" />
   <%-- <= Use Java's DecimalFormat class to format   --%>
   <%-- the number. Store as string in variable n1   --%>

   <fmt:formatNumber value="${dbl1}" type="currency" var="n1" />
  <%-- <= Format as currency   --%>

   <fmt:formatNumber value="${dbl1}" type="percent" var="n1" />
   <%-- <= Format as percentage  --%>

   <fmt:formatNumber value="${dbl1}" type="number"
      maxFractionDigits="6"
      minFractionDigits="2"
     var="n1" />
   <%-- <= We can set the minimum and maximum   --%>
   <%-- number of fraction digits   --%>

   <fmt:formatNumber value="${dbl1}" type="number"
        pattern="#,##0.00;(#,##0.00)"
        var="n1" />
   <%-- <= Set the pattern according to the   --%>
   <%-- DecimalFormat API documentation   --%>

   The number reads: ${n1}
...
</html>

在 Facelets 页面上,您编写了相同的代码,但是使用了不同的头(并且删除了<%ederts注释):

<!DOCTYPE html>
<html lang="en"

   xmlns:ui="http://java.sun.com/jsf/facelets"
   xmlns:c="http://java.sun.com/jsp/jstl/core"
   xmlns:fmt="http://java.sun.com/jsp/jstl/fmt">
...
    <fmt:formatNumber value="${dbl1}" type="number"
       var="n1" />
...
</html>

表 8-1 中解释了<fmt:formatNumber>的完整属性集。

表 8-1

格式编号标记

|

属性

|

需要

|

描述

|
| --- | --- | --- |
| value | x | 价值。使用 EL 语法,如${someBean.someField} |
| type | - | 类型。numbercurrencypercent中的一种。默认为number。 |
| pattern | - | 格式化模式,如对DecimalFormat.的描述 |
| currencyCode | - | ISO 4217 货币代码。只有当type = "currency"。 |
| currencySymbol | - | 货币符号。只有当type = "currency"。 |
| groupingUsed | - | 是否使用分组(例如,千位分隔符)。真或假。 |
| minFractionDigits | - | 小数位数的最小值。 |
| maxFractionDigits | - | 分数位数的最大值。 |
| minIntegerDigits | - | 最小整数位数。 |
| maxIntegerDigits | - | 最大整数位数。 |
| var | - | 格式化结果将被写入的变量的名称。如果使用该属性,将禁止直接输出数字。 |
| scope | - | 格式化结果将被写入的变量的范围。page(默认)、applicationsessionrequest中的一种。 |

使用fmt:formatDate,可以格式化一个java.util.Date对象。使用各种属性,可以只输出日期部分,或只输出时间部分,或两者都输出,给定一些模式:

<%@ page contentType="text/html;charset=UTF-8"
    language="java" %>
<%@ taglib prefix="c"
    uri="http://java.sun.com/jsp/jstl/core" %>
<%@ taglib prefix="fmt"
    uri="http://java.sun.com/jsp/jstl/fmt" %>
<html>
...
   <%-- Supposing ${date1} refers to a java.util.Date --%>

   <fmt:formatDate value="${date1}" type="date" var="d1" />
   <%-- <= Use Java's DateFormat class to format a   --%>
   <%-- day (ignore the time-of-day) in the user's   --%>
   <%-- locale default format.   --%>
   <%-- Store the result in page scope variable "d1" --%>

   <fmt:formatDate value="${date1}" type="date"
      dateStyle="long"
      var="d1" />
   <%-- <= Use Java's DateFormat class to format a   --%>
   <%-- day in the user's locale "long" format   --%>
   <%-- Instead of "long" you can also write   --%>
   <%-- "default", "short", "medium", "long" or   --%>
   <%-- "full"   --%>

   <fmt:formatDate value="${date1}" type="time"
      var="d1" />
<%-- <= Use Java's DateFormat class to format a   --%>
<%-- time-of-day (ignore the day) in the user's   --%>
<%-- locale default format.   --%>
   <%-- Store the result in page scope variable "d1" --%>

   <fmt:formatDate value="${date1}" type="time" timeStyle="long"
     var="d1" />
   <%-- Time-of-day in long format.   --%>
   <%-- Instead of "long" you can also write   --%>
   <%-- "default", "short", "medium", "long" or   --%>
   <%-- "full"   --%>

   <fmt:formatDate value="${date1}" type="both"
      var="d1" />
   <%-- Write both day and time-of day. Use   --%>
   <%-- "dateStyle" and "timeStyle" to control the   --%>
   <%-- day and time-of-day styling as described   --%>
   <%-- above.   --%>

   <fmt:formatDate value="${date1}"
      pattern="yyyy-MM-dd hh:mm:ss"
      var="d1" />
   <%-- Write day and/or time, as described by the   --%>
   <%-- pattern (see class SimpleDateFormat for a   --%>
   <%-- pattern description).   --%>

   The date reads: ${d1}
...
</html>

表 8-2 中描述了fmt:formatDate的完整概要

表 8-2

格式日期标签

|

属性

|

需要

|

描述

|
| --- | --- | --- |
| value | x | 价值。使用 EL 语法,比如${someBean.someField}。 |
| type | - | 要格式化的部分。datetimeboth中的一种。 |
| dateStyle | - | 指定日期样式。默认、shortmediumlongfull中的一种。使用 Java 的DateFormat类来指定细节等级。类型必须是dateboth。 |
| timeStyle | - | 指定时间样式。默认、shortmediumlongfull中的一种。使用 Javaefa DateFormat类来指定细节等级。类型必须是dateboth。 |
| pattern | - | 按照模式描述,写下日期和/或时间。模式描述见SimpleDateFormat类。 |
| timeZone | - | 设置时区。API 文档java.util.TimeZone,方法getTimeZone()中描述了格式。直接传递一个TimeZone对象也是可以的。 |
| var | - | 格式化结果将被写入的变量的名称。如果使用该属性,将禁止直接输出。 |
| scope | - | 格式化结果将被写入的变量的范围。page(默认)、applicationsessionrequest中的一种。 |

使用 JSF 进行格式化

如果您使用 Facelets 作为视图引擎,并决定忽略我关于混合 Java MVC 和 JSF 的警告,那么声明数字转换器的构造如下所示:

<?xml version='1.0' encoding='UTF-8' ?>

<!DOCTYPE html>
<html 
    xmlns:ui="http://java.sun.com/jsf/facelets"
    xmlns:h="http://java.sun.com/jsf/html"
    xmlns:f="http://java.sun.com/jsf/core">
<body>
...
<h:outputText value="${someBean.someField}">
   <f:convertNumber type="number"
      maxIntegerDigits="5"
      maxFractionDigits="5"
      groupingUsed="false"/>
</h:outputText>
...
</body>
</html>

所以你必须把<f:convertNumber> 放在文本输出标签里面。

<f:convertNumber>的各种属性与 JSTL 等效物没有太大区别,如表 8-3 所示。

表 8-3

转换号码标签

|

属性

|

需要

|

描述

|
| --- | --- | --- |
| type | - | 类型。numbercurrencypercent中的一种。默认为number。 |
| pattern | - | 格式化模式,如对DecimalFormat类的描述。 |
| currencyCode | - | ISO 4217 货币代码。只有当type = "currency"。 |
| currencySymbol | - | 货币符号。只有当type = "currency"。 |
| groupingUsed | - | 是否使用分组(例如,千位分隔符)。使用truefalse。 |
| minFractionDigits | - | 小数位数的最小值。 |
| maxFractionDigits | - | 分数位数的最大值。 |
| minIntegerDigits | - | 最小整数位数。 |
| maxIntegerDigits | - | 最大整数位数。 |
| integerOnly | - | 如果true,小数被忽略。不是true就是false。 |
| locale | - | 用于显示数字的区域设置。要么直接是一个java.util.-Locale对象,要么是一个适合作为Locale构造函数的第一个参数的字符串。 |

为了将java.util.Date对象转换成字符串表示形式,您用 JSF 编写了以下代码:

<?xml version='1.0' encoding='UTF-8' ?>
<!DOCTYPE html>
<html 
     xmlns:ui="http://java.sun.com/jsf/facelets"
     xmlns:h="http://java.sun.com/jsf/html"
     xmlns:f="http://java.sun.com/jsf/core">
<body>
...
<h:outputText value="${someBean.someField}">
   <f:convertDateTime type="both"
       dateStyle="full"
       timeStyle="medium" />
</h:outputText>
...
</body>
</html>

毫不奇怪,f:convertDateTime的一组可能属性非常类似于 JSTL 对等词<fmt:formatDate>的属性;见表 8-4 。

表 8-4

convertdatetime tag 转换日期时间标记

|

属性

|

需要

|

描述

|
| --- | --- | --- |
| type | - | 要格式化的部分。datetimebothlocalDatelocalTimelocalDateTimeoffsetTimeoffset- DateTimezonedDateTime中的一种。默认为date。 |
| dateStyle | - | 指定日期样式。defaultshortmediumlongfull中的一种。使用 Java 的DateFormat类来指定细节等级。 |
| timeStyle | - | 指定时间样式。defaultshortmediumlongfull中的一种。使用 Java 的DateFormat类来指定细节等级。 |
| pattern | - | 按照模式描述,写下日期和/或时间。模式描述见SimpleDateFormat类。 |
| timeZone | - | 设置时区。API 文档java.util.TimeZone,方法getTimeZone()中描述了格式。直接路过一个TimeZone对象也是可以的。 |
| locale | - | 用于显示日期/时间的区域设置。要么直接通过一个java.- util.Locale对象,要么通过一个字符串作为Locale构造函数的第一个参数。 |

本地化数据转换

我们已经在 MVC 控制器中为表单参数使用了非字符串类型:

   @POST
   @Path("/response")
   public Response response(
     @MvcBinding @FormParam("name") String name,
     @MvcBinding @FormParam("userId") int userId) {
     @MvcBinding @FormParam("rs") long timeStamp,
     @MvcBinding @FormParam("rank") double rank) {

     // Handle form input, set model data, ...,
     // return response
   }

参数类型可以选择StringintlongfloatdoubleBigDecimalBigIntegerboolean ( truefalse)。如果您选择除了string之外的任何类型,Java MVC 确保用户输入被适当地转换。这种转换以特定于地区的方式进行。在英语语言环境中,用户输入的0.45具有双精度参数的正确格式。例如,在德国地区,必须输入与0,45相同的数字。正确的转换发生在幕后。

目前没有可靠的方法来定义自定义转换器。此外,没有时间和日期的转换器。作为一种变通方法,您可以始终将值作为String传递给控制器,然后以编程方式执行转换。

   @POST
   @Path("/response")
   public Response response(
    @MvcBinding @FormParam("day") String day,
    @Context HttpHeaders httpHeaders) {

     Locale loc = httpHeaders.getLanguage();
     // <- You could use this for locale specific
     // conversion rules.

     DateTimeFormatter formatter1 =
       DateTimeFormatter.ofPattern("yyyy-MM-dd");
     LocalDate ld = LocalDate.parse(day,
       formatter1);

     ...
}

练习

  • 练习 1: 在第四章的HelloWorld应用中,将视图文件中的消息放入资源包,并使用“向会话添加本地化消息”一节中描述的方法来访问消息。将资源文件放在src/main/resources/book/javamvc/helloworld/-messages文件夹中。

  • 练习 2: 继续上一个练习,使用App类将常量从BundleForEL类移动到应用属性。将应用对象注入到SetBundleFilter中,并更新BundleForEL.setFor()以从应用对象接收一个捆绑变量名和一个捆绑资源包名。

  • 练习 3: 继续上一个练习,向模型类UserData添加一个双值rank字段。向index.jsp视图添加一个 Rank:标签输入字段,并向greeting.jsp添加相同值的格式化输出。更新控制器类,并向@POST方法添加一个名为double rank的方法参数。

  • 练习 4: 继续上一个练习,向名为UserData的模型类添加一个dateOfBirth字段。向index.jsp视图添加一个生日:标签输入字段,并向greeting.jsp添加一个相同值的格式化输出。更新控制器类,并向@POST方法添加一个名为String dateOfBirth的方法参数。

摘要

Java 通过资源包提供内置的国际化支持。可以在不同语言相关的属性文件中保存不同语言的文本片段。使用标记,还可以以特定于地区的格式输出数字和日期,Java MVC 可以根据地区处理用户输入。

在标准 JSP 中,与语言相关的资源由fmt:setBundlefmt:bundle标记以及fmt:message标记来处理,后者使用key属性来引用包中的文本。

对于 Facelets,您通常使用 JSF 方法来访问语言资源,在 JSF 配置文件faces-config.xml中,您指定了一个资源包,从此可以在视图内部使用。

尽管这些方法可能符合您的需求,但也有一些缺点。通过使用 web 过滤器和ResourceBundle自定义类,可以提供对语言资源的简化访问。

如果在一个视图页面上你写了${dbl}并且dbl指的是一个双值数字,那么这个数字的toString()表示就会被打印出来。只有当您的前端用户希望数字采用英语区域设置时,这才是可接受的。为了确保所有其他用户根据他们的国家规则获得预期的数字格式,JSTL 提供了一个 http://java.sun.com/jsp/jstl/fmt 标签库,它使用地区信息收集用于对象格式化的标签。

在 Facelets 页面中,您可以编写与 JSP 相同的代码,但是使用不同的头。

我们已经在 MVC 控制器中为表单参数使用了非字符串类型。参数类型可以选择StringintlongfloatdoubleBigDecimalBigIntegerboolean(对或错)。如果您选择除了string之外的任何类型,Java MVC 确保用户输入被适当地转换。这种转换以特定于地区的方式进行。在英语语言环境中,用户输入0.45具有双精度参数的正确格式。例如,在德语地区,必须输入与0,45相同的数字。正确的转换发生在幕后。

目前没有可靠的方法来定义自定义转换器。此外,没有时间和日期的转换器。作为一种变通方法,您可以始终将值作为String传递给控制器,然后以编程方式执行转换。

在下一章,我们将讨论 Java MVC 寻址 EJB,这是一种与后端组件通信的标准化方法。

九、Java MVC 和 EJB

企业 Java bean(EJB)是封装业务功能的类,每一个都有特定的种类。然而,与普通的 Java 类不同,EJB 运行在一个容器环境中,这意味着服务器向它们添加系统级服务。这些服务包括生命周期管理(实例化和销毁,何时和如何),事务性(构建逻辑的、原子的、支持回滚的工作单元),以及安全性(哪些用户可以调用哪些方法)。因为 Java MVC 运行在这样一个容器中,即 Jakarta EE,EJB 是 Java MVC 应用封装其业务功能的好方法。

EJB 技术包括会话bean 和消息驱动 bean。然而,后者超出了本书的范围,所以这里我们只讨论会话EJB。

关于会话 EJB

可以本地(在同一个应用中)、远程(通过网络、通过方法调用)或通过某些 web 服务接口(跨异构网络的分布式应用、HTML、XML 或 JSON 数据格式)访问会话 EJB。

关于会话 EJB 的创建和销毁,有三种类型的会话 EJB:

  • Singleton: 使用单例会话 EJB,容器只实例化一个实例,所有客户端共享这个单个实例。如果 EJB 没有歧视客户端的状态,并且并发访问没有造成问题,您可以这样做。

  • 无状态:“无状态”类型的 EJB 不维护状态,所以一个特定的客户机可以将不同的实例分配给后续的 EJB 调用(容器处理这一点;客户不知道这个任务)。

  • 有状态:有状态 EJB 维护一个状态,客户端可以确保它将从容器接收相同的会话 EJB 实例,以便后续使用相同的 EJB。您会经常听说有状态 EJB 客户端维护一个关于使用有状态 EJB 的会话状态。有状态会话 EJB 不能实现 web 服务,因为不允许 web 服务有状态,也不传递会话信息。

定义 EJB

要定义一个单独的 EJB、一个无状态的 EJB 或一个有状态的 EJB,您可以分别向 EJB 实现添加这些注释中的一个——分别是@Singleton@Stateless@Stateful

考虑三个例子。一个名为Configuration的 EJB,用于封装对应用范围配置设置的访问。另一个 EJB 称为Invoice,它处理发票注册和给定发票 ID 的查询。第三个 EJB 调用了TicTacToe来实现一个简单的井字游戏。显然,对于配置 EJB,我们可以使用单一 EJB,因为本地状态和并发性都不重要。类似地,对于发票 EJB,我们可以使用无状态的 EJB,因为状态是通过 ID 来传递的,它不访问 EJB 状态,而是访问数据库状态。最后一个,井字游戏 EJB,需要为每个客户端维护游戏板,因此我们必须为它使用有状态的 EJB。

import javax.ejb.Singleton;
import javax.ejb.Stateless;
import javax.ejb.Stateful;
...
@Singleton
public class Configuration {
    ... configuration access methods
}

@Stateless
public class Invoice {
   ... invoice access methods
}

@Stateful
public class TicTacToe {
    ... tic-tac-toe methods
}

当然,所有这些类必须放在不同的文件中。我们将它们放在一起只是为了说明的目的。

关于它们从客户端代码的可访问性,会话 EJB 可以使用一种或三种方法的组合(所有显示的注释都来自javax.ejb包):

  • 无接口:如果你不想通过接口描述 EJB 访问,你可以使用这个方法。只有在同一应用中运行本地客户端时,这才是可能的。虽然将接口(描述在interface s 中完成的和实现(如何实现,在非抽象的class es 中实现)分开对于干净的代码来说通常是一个好主意,但是无接口视图对于简单的 EJB 来说是有意义的。对于无接口 EJB,您只需声明实现,如下所示:
         @Stateless public class Invoice {
             ... implementation
         }

当然,EJB 客户端只能直接访问实现类,而不需要中介接口。

  • Local: 如果您想要定义对会话 EJB(在同一应用中运行的 EJB 和 EJB 客户端)的本地访问,并且想要为此使用接口视图,您可以用@Local标记该接口,并让 EJB 实现类实现该接口:
         @Local public interface InvoiceInterface {
             ... abstract interface methods
         }

         @Stateless public class Invoice
               implements InvoiceInterface {
             ... implementation
         }

或者在实现类中使用@Local注释:

         public interface InvoiceInterface {
             ... abstract interface methods
         }

         @Stateless
         @Local(InvoiceInterface.class)
         public class Invoice implements InvoiceInterface {
             ... implementation
         }

您甚至可以省略实现,如下所示:

         public interface InvoiceInterface {
             ... abstract interface methods
         }

         @Stateless
         @Local(InvoiceInterface.class)
         public class Invoice {
             ... implementation
         }

最后一种方法将进一步降低接口的耦合,尽管一般不建议这样做。

  • @Remote: 使用@Remote注释,以便可以从应用外部访问这个会话 EJB。您可以简单地将@Local替换为@Remote,所有关于本地访问和接口的内容对于远程访问都是真实的。例如,您可以编写以下内容:
         public interface InvoiceInterface {
             ... abstract interface methods
         }

         @Stateless
         @Remote(InvoiceInterface.class)
         public Invoice
               implements InvoiceInterface {
             ... implementation
         }

EJB 可以有一个本地和一个远程接口;只需同时使用两种注释:

         public interface InvoiceLocal {
             ... abstract interface methods
         }
         public interface InvoiceRemote {
             ... abstract interface methods
         }

         @Stateless
         @Local(InvoiceLocal.class)
         @Remote(InvoiceRemote.class)
         public Invoice
               implements InvoiceLocal,
                          InvoiceRemote {
             ... implementation
         }

此外,没有人阻止我们使用相同的接口进行本地和远程访问:

         public interface InvoiceInterface {
             ... abstract interface methods
         }

         @Stateless
         @Local(InvoiceInterface.class)
         @Remote(InvoiceInterface.class)
         public Invoice implements InvoiceInterface {
             ... implementation
         }

Caution

远程访问意味着方法调用中的参数是通过值传递的,而不是通过引用!因此,尽管本地和远程接口被声明为相互协同,但是在某些情况下,您必须小心使用方法参数。

访问 EJB

从 Java MVC 控制器访问本地 EJB 很容易:只需使用@EJB注入让 CDI 将实例访问分配给 EJB:

public class SomeController {
    ...
    @EJB
    private SomeEjbInterface theEjb;

    // or, for no-interface EJBs
    @EJB
    private SomeEjbClass theEjb;
    ...
}

与本地访问 EJB 相比,寻址远程 EJB 要复杂得多。您必须设置一个 JNDI 上下文,然后使用它来查找远程实例:

...
String remoteServerHost = "localhost";
// or "192.168.1.111" or something
String remoteServerPort = "3700";
// Port 3700 is part of the GlassFish conf

Properties props = new Properties();
props.setProperty("java.naming.factory.initial",
  "com.sun.enterprise.naming."+
  "SerialInitContextFactory");
props.setProperty("java.naming.factory.url.pkgs",
  "com.sun.enterprise.naming");
props.setProperty("java.naming.factory.state",
  "com.sun.corba.ee.impl.presentation.rmi."+
  "JNDIStateFactoryImpl");
props.setProperty("org.omg.CORBA.ORBInitialHost",
  remoteServerHost);
props.setProperty("org.omg.CORBA.ORBInitialPort",
  remoteServerPort);

try {
  InitialContext ic = new InitialContext(props);

  // Use this to see what EJBs are available
  // and how to name them
  //NamingEnumeration<NameClassPair> list =
  //       ic.list("");
  //while (list.hasMore()) {
  //  System.out.println(list.next().getName());
//}

// Looking up a remote EJB
  SomeEjbRemote testEJB = (SomeEjbRemote)
      ic.lookup(
       "book.jakarta8.testEjbServer.SomeEjbRemote");

  // Invoking some EJB method
  System.out.println(testEJB.tellMe());
}catch(Exception e) {
  e.printStackTrace(System.err);
}

此示例假设,在远程服务器端,您创建了一个具有远程接口的会话 EJB:

package book.jakarta8.testEjbServer;

public interface SomeEjbRemote {
    String tellMe();
}

还有这样一个实现:

package book.jakarta8.testEjbServer;

import javax.ejb.Remote;
import javax.ejb.Stateless;

@Stateless
@Remote(SomeEjbRemote.class)
public class SomeEjb implements SomeEjbRemote {
  @Override
    public String tellMe() {
      return "Hello World";
    }
}

显然,要做到这一点,Java MVC 应用必须能够访问编译后的远程接口。这意味着在 EJB 服务器构建中,您必须以某种方式包含一个从生成的类中提取接口的步骤。我们稍后会详细讨论这一点。

如果远程 EJB 服务器是 GlassFish 服务器,您还可以使用它的asadmin命令来查看哪些 EJB 适合远程访问以及它们是如何命名的:

cd [GLASSFISH_INST]
cd bin
./asadmin list-jndi-entries

其他 Java 企业版(JEE 或 Jakarta EE)应用服务器可能会对远程可访问的 EJB 应用其他命名方案。所以你必须查阅他们的文件和/或获得远程可见的 JNDI 条目列表。对于后者,您可以尝试编程访问(在前面的清单中被注释掉),或者使用为远程 EJB 服务器实现的一些管理特性。

EJB 项目

Jakarta EE 项目不一定是 web 项目;他们还可以向访问其远程 EJB 接口的客户端公开服务。与 REST 或 Web 服务接口一样,web 接口是与 web 浏览器和非 Jakarta EE 服务器进行互操作的首选。但是,对于具有不同网络节点的大型系统中的 Jakarta EE 参与者之间更快的通信,使用组件到 EJB 的通信可能是更好的选择。

Web 项目还可以向适当的客户端公开远程 EJB。如果您想要一个没有 web 功能的流线型项目,那么在 Eclipse 中这样做的过程将在下面的段落中描述。

启动一个新的 Gradle 项目,类似于我们到目前为止创建的 web 项目,但是将插件声明更改为以下内容:

plugins {
    id 'java-library'
}

从这里开始,按照描述创建 EJB 及其远程接口,并附加以下约束:将 EJB 接口移动到它们自己的包中。例如:

book.javamvc.ejbproj.ejb              <- Implementation
book.javamvc.ejbproj.ejb.interfaces   <- Interfaces

在构建文件中,我们添加了一个自动生成 EJB 存根的任务:

task extractStubs (type: Jar, dependsOn:classes) {
  archiveClassifier = 'ejb-stubs'
  from "$buildDir/classes/java/main"
  include "**/interfaces/*.class"
}
jar.finalizedBy(extractStubs)

这确保了在每个jar任务执行之后,存根被创建。然后,您可以运行jar任务来创建完整的 EJB jar 存根。你可以在build/libs文件夹中找到这两个文件。您可能需要在该文件夹上按 F5 来更新视图。任何希望与 EJB 通信的客户机都必须包含接口 JAR 作为依赖项。当然,EJB 项目本身必须部署在服务器上,EJB 才能工作。

具有依赖性的 EJB

到目前为止,我们只开发了非常简单的 EJB,而不需要使用作为 jar 包含的库。一旦你需要添加库到 EJB,你会遇到麻烦。这是因为没有标准的方法可以将依赖项添加到独立的 EJB 模块中。如果需要添加库 jar,最好的方法是将 EJB 模块打包到企业归档(EAR)中。

ear 是捆绑了 EJB、web 应用(war)和库 jar 的归档。处理 ear 而不是孤立的 EJB 在某种程度上增加了管理活动的复杂性。但是将库 jar 添加到 ear 是包含与非 web 应用的依赖关系的最佳方式。

为了向 Eclipse 中的应用添加 EAR 功能,您基本上必须完成以下工作:

  1. 建立一个新的 Gradle 项目。去新➤其他...➤·格拉德➤·格拉德项目。

  2. 选择任何你喜欢的名字。名字后面加“ear”是个好主意。

  3. build.gradle内,将plugins { }部分改为plugins { id 'ear' }

  4. build.gradle内,用作dependencies { }部分:

  5. 在项目根目录下创建warejb1文件夹。

  6. 打开settings.gradle文件并添加以下内容:

         dependencies {
           deploy project(path: ':war',
              configuration: 'archives')
           deploy project(path: ':ejb1',
              configuration: 'archives')
           earlib "org.apache.commons:"+
               "commons-math3:3.6.1"
         }

  1. 调用格拉德➤刷新格拉德项目。Eclipse 可能会抛出一条错误消息;你可以暂时忽略它。

  2. 两个子项目warejb1出现在项目浏览器中。如果您正在使用工作集,您可能需要更新它。

  3. 将两个子项目转换为多面形式(选择配置➤转换为多面形式...),并在设置中,添加 Java 1.8 功能。

         include 'war', 'ejb1'

我们现在有一个包含两个子项目的 EAR 项目。剩下要做的就是给每个子项目添加 Gradle 功能。WAR 项目需要一个构建文件,就像我们用于 Java MVC 项目的许多build.gradle文件中的一个。然而,不同的是,我们向兄弟 EJB 项目添加了一个依赖项:

dependencies {
    implementation project(":ejb1")
    // Other dependencies...
}

Note

这是为了格雷尔的从属关系。为了让 Eclipse 识别依赖项,您必须将 EJB 项目作为依赖项添加到 Java 构建路径中(选择项目设置➤ Java 构建路径➤项目选项卡)。

对于 EJB 项目,您可能会使用如下的build.gradle文件:

plugins {
    id 'java-library'
}

java {
    sourceCompatibility = JavaVersion.VERSION_1_8
    targetCompatibility = JavaVersion.VERSION_1_8
}
repositories {
    jcenter()
}

dependencies {
    implementation 'javax:javaee-api:8.0'
    // Add dependencies here...
}

如果您运行ear任务,子项目和 EAR 文件将被构建。后者可以在build/libs文件夹中找到。

异步 EJB 调用

EJB 客户端异步调用 EJB 方法。这意味着客户端调用一个标记为适合异步调用的 EJB 方法,立即重新获得对程序执行的控制,并在以后 EJB 调用的结果可用时处理它。

要将 EJB 方法标记为异步调用,可以将javax.ejb包中的@Asynchronous注释添加到方法中:

import java.util.concurrent.Future;
import javax.ejb.AsyncResult;
import javax.ejb.Asynchronous;
import javax.ejb.Singleton;

@Singleton // Example only, all EJB types work!
public class SomeEjb {
  @Asynchronous
  public Future<String> tellMeLater() {

    // Simulate some long running calculation
    try {
      Thread.sleep(2000);
    } catch (InterruptedException e) {
    }

    return new AsyncResult<String>(
        "Hi from tellMeLater()");
    }
}

这个示例 EJB 使用无接口方法,但是异步调用也适用于本地和远程接口。AsyncResult是一个方便的类,允许轻松创建一个Future。这个Future对象不会暴露给客户端;它的主要目的是服从方法签名。返回给客户端的Future将由 EJB 容器透明地创建。

在 EJB 客户端,您像往常一样调用 EJB,并像在 JRE 并发 API 中一样处理从 EJB 调用中收到的Future:

...
@EJB
private SomeEjb someEjb;
...
Future<String> f = someEjb.tellMeLater();
try {
    // Example only: block until the result
    // is available:
    String s = f.get();
    System.err.println(s);
} catch (Exception e) {
    e.printStackTrace(System.err);
}

计时器 EJB

EJB 可以配备计时器设施,例如用于延迟执行某些任务或重复自动方法调用。你有两种选择:自动定时器和程序定时器。

对于自动定时器,你可以添加一个@Schedule或者@Schedules注释(来自javax.ejb包)到任何一个void方法(可见性无关紧要),或者不带参数,或者带一个javax.ejb.Timer参数。@Schedule注释的参数描述了频率,如下所示:

@Stateless
public class SomeEjb {
  @Schedule(minute="*", hour="0", persistent=false)
  // every minute during the hour between 00:00 and 01:00
  public void timeout1() {
    System.err.println("Timeout-1 from " + getClass());
  }
}

像“在服务器启动后十秒钟做一次事情”这样的延迟执行是不可能用自动定时器实现的。

以下是您可以在自动计时器中使用的一些示例计划列表:

@Schedule(second="10", minute="0", hour="0")
  // <- at 00:00:10 every day

@Schedule(minute="30", hour="0",
      dayOfWeek="Tue")
  // <- at 00:30:00 on Tuesdays (second defaults to 00)

@Schedule(minute="11", hour="15",
      dayOfWeek="Mon,Tue,Fri")
  // <- at 15:11:00 on mondays, Tuesdays and Fridays

@Schedule(minute="*/10", hour="*")
  // <- every 10 minutes, every hour

@Schedule(minute="25/10", hour="1")
  // <- 01:25, 01:35, 01:45 and 01:55

@Schedule(hour="*", dayOfMonth="1,2,3")
  // <- every hour at 1st, 2nd and 3rd each month
  // (minute defaults to 00)

@Schedule(hour="*/10")
  // <- every 10 hours

@Schedule(month="Feb,Aug")
  // <- 00:00:00 each February and August
  // (hour defaults to 00)

@Schedule(dayOfMonth="1", year="2020")
  // <- 00:00:00 each 1st each month during 2020

@Schedule(dayOfMonth="1-10")

  // <- 00:00:00 each 1st to 10th each month

@Schedules注释可用于将几个@Schedule规范应用于定时器回调:

@Schedules({
  @Schedule(hour="*"),
  @Schedule(hour="0", minute="30")
})
private void someMethod(Timer tm) {
    ...
}

这意味着每隔 x:00:00 (x = 00 到 23),但也包括 00:30:00。除非您也给@Schedule注释赋予一个persistent=false,否则计时器会在应用和服务器重启后继续存在。

计时器也可以通过编程来定义。这里也可以定义一次性拍摄,如下所示:

@Singleton
@Startup
public class Timer1 {
  @Resource
  private SessionContext context;

  @PostConstruct
  public void go() {
    context.getTimerService().
         createSingleActionTimer(5000, new TimerConfig());
  }

  @Timeout
  public void timeout(Timer timer) {
    System.err.println("Hello from " + getClass());
  }
}

@Timeout标注的方法在每次定时器触发时被调用。对于本例,这将是在 EJB 创建之后的 5000 毫秒,因为调用了createSingleActionTimer()。您通过context.getTimerService()获得的定时服务支持各种日程安排选项;有关详细信息,请参见 API 文档。

练习

练习 1

以下哪一项是正确的?

  • EJB 必须有一个本地接口和一个远程接口

  • 不提供接口意味着 EJB 被 EJB 容器(Jakarta EE 服务器中处理 EJB 的部分)自动分配给本地和远程接口。

  • 远程 EJB 意味着可以从同一服务器上的其他应用访问 EJB。从其他 Jakarta EE 服务器访问是不可能的。

  • EJB 不能有状态。

  • 如果客户端访问 EJB,服务器端会创建一个新的 EJB 实例。

  • 要从客户端访问任何 EJB,您必须在 JNDI 上下文中使用“执行查找”。

  • 为了从客户端使用 EJB,必须将 EJB 的接口及其实现导入到客户端项目中。

练习 2

创建四个项目:

  • 一个 JSE 项目(没有 Jakarta EE 功能),有一个单独的MyDateTime类和一个名为date( String format )的方法,该方法根据作为参数指定的格式字符串以字符串形式返回LocalDateTime。把它变成一个 Gradle 项目。

  • 一个 EJB 项目,具有单个 EJB MyDateTimeEjb以及本地和远程接口。让它使用上面的 JRE 项目生成的 JAR 文件。提示:您可以使用类似于implementation files( '../../- SimpleNoJEE/build/libs/SimpleNoJEE.jar' )的东西来指定一个本地依赖项。

  • 一个 EAR 项目,包含 EJB 项目并添加了必要的 JAR 依赖项。

  • 一个简单的 no-Jakarta-EE EJB 客户端项目,测试来自MyDateTimeEjb EJB 的远程接口。提示:将 GlassFish 的lib文件夹中的gf-client.jar作为库依赖项包含进来。

摘要

企业 Java bean(EJB)是封装业务功能的类,每一个都有特定的种类。然而,与普通的 Java 类不同,EJB 运行在一个容器环境中,这意味着服务器向它们添加系统级服务。这些包括生命周期管理(实例化和销毁,何时和如何),事务性(构建逻辑的、原子的、支持回滚的工作单元),以及安全性(哪些用户可以调用哪些方法)。因为 Java MVC 运行在这样一个容器中,即 Jakarta EE,EJB 是 Java MVC 应用封装其业务功能的好方法。

EJB 技术包括会话bean 和消息驱动 bean。会话 EJB 可以本地访问(在同一个应用中),远程访问(通过网络,通过方法调用),或者通过一些 web 服务接口访问(跨异构网络的分布式应用,HTML,XML 或 JSON 数据格式)。

关于会话 EJB 的创建和销毁,有三种类型的会话 EJB。单体 EJB、无状态 EJB 和有状态 EJB。要定义它们中的任何一个,您可以向 EJB 实现添加适当的注释— @Singleton@Stateless@Stateful

关于它们从客户机代码的可访问性,会话 EJB 可以使用一种或三种方法的组合:无接口访问、本地访问或远程访问。

从 Java MVC 控制器访问本地 EJB 很容易:只需使用@EJB注入让 CDI 将实例访问分配给 EJB: @EJB private SomeEjbInterface theEjb

与本地访问 EJB 相比,寻址远程 EJB 要复杂得多。您必须设置一个 JNDI 上下文,然后使用它来查找远程实例。

为此,Java MVC 应用必须能够访问编译后的远程接口。这意味着,在 EJB 服务器构建中,您必须以某种方式包含一个从生成的类中提取接口的步骤。

Jakarta EE 项目不一定是 web 项目;他们还可以向访问其远程 EJB 接口的客户端公开服务。与 REST 或 Web 服务接口一样,web 接口是与 web 浏览器和非 Jakarta EE 服务器进行互操作的首选。对于具有不同网络节点的大型系统中的 Jakarta EE 参与者之间更快的通信,使用组件到 EJB 通信可能是更好的选择。Web 项目也可以向适当的客户端公开远程 EJB。

一旦需要向 EJB 添加库,最好的方法是将 EJB 模块打包到企业归档(EAR)中。ear 是捆绑了 EJB、web 应用(war)和库 jar 的归档。处理 ear 而不是孤立的 EJB 在某种程度上增加了管理活动的复杂性。但是一旦完成,如果您运行ear任务,子项目和 EAR 文件将被构建。后者可以在build/libs文件夹中找到。

EJB 客户端异步调用 EJB 方法。这意味着客户端调用一个标记为适合异步调用的 EJB 方法,立即重新获得对程序执行的控制,并在以后 EJB 调用的结果可用时处理它。

要为异步调用标记一个 EJB 方法,您可以将来自javax.ejb包的@Asynchronous注释添加到该方法中。

EJB 可以配备计时器设施,例如用于延迟执行某些任务或重复出现的自动方法调用。你有两种选择:自动定时器和程序定时器。

使用自动定时器,你可以添加一个@Schedule或者@Schedules注释(来自javax.ejb包)到任何一个void方法(可见性无关紧要),或者不带参数,或者带一个javax.ejb.Timer参数。@Schedule注释的参数描述了频率。

计时器也可以通过编程来定义。也可以定义一次性调用。

在下一章,我们将学习如何将 Java MVC 连接到数据库。

十、将 Java MVC 连接到数据库

如果您希望将数据保存更长时间,或者如果必须从不同的会话(不同的用户)持续访问数据,则需要数据库。本章涉及 SQL(结构化查询语言)数据库。关于介绍,参见例如在 https://en.wikipedia.org/wiki/Database 的文章。

JPA (Java 持久性 API)是用于从 Jakarta EE 内部访问关系数据库的专用技术。它的目的是提供 SQL 表和 Java 对象之间的桥梁。这项任务比其他基本数据方案复杂得多。其原因是,在关系数据库模式中,我们在不同的表之间有关联:一个表中的一行可能引用另一个表中的一行或多行,或者反过来,也可能有跨越三个或更多表的引用。想想列类型转换——与 Java 相比,数据库可能对数字、布尔指示符以及日期和时间有不同的想法。此外,如果在表引用中使用数据库表中的null值,以及在将它们转换为 Java 值时,需要更加注意。

在这一章中,我们将讨论在 Java MVC 中使用 JPA 的基本问题。要全面深入地了解 JPA,包括比本章更复杂的问题,请参考网站上的在线 JPA 文档和规范。好的首发网址是 https://docs.oracle.com/javaee/6/tutorial/doc/bnbpy.html

用 JPA 抽象出数据库访问

JPA 的主要目的之一是抽象出数据库访问,并将数据库对象映射到 Java 类。最终,我们希望能够查询数据库并获得 Java 对象,或者将 Java 对象放入数据库。JPA 隐藏了如何做到这一点的细节,包括用户名和密码等连接属性,还包括处理连接生命周期。

用于此目的的中央 JPA 类是EntityManager,它使用一个名为persistence.xml的配置文件,以及 Jakarta EE 应用服务器内部的一些设置。在 Java 端,对应于表行的类被称为实体类。JPA 的概述见图 10-1 。

img/499016_1_En_10_Fig1_HTML.png

图 10-1

Jakarta EE 工作中的 JPA

设置 SQL 数据库

SQL 数据库有两种类型——您可以拥有完全成熟的客户端-服务器数据库和嵌入式数据库(可能使用一些内存存储)。在本书中,我们使用 GlassFish 服务器发行版中包含的 Apache Derby 数据库。该数据库独立于 GlassFish 运行,但是 GlassFish 管理工具也提供了一些用于处理 Apache Derby 实例的命令。作为客户端,我们从 Java MVC 应用内部使用 JPA。

Note

在 GlassFish 文档中,您会经常看到名称“JavaDB”作为数据库的产品名称。JavaDB 实际上是 Derby 的名字,它包含在 JDK 版本 6 到 8 中。现在它有点过时了,所以我们在这本书里不用“JavaDB”这个名字。

切换到不同的数据库产品是一种非侵入性的操作,所以您可以从 Apache Derby 开始学习 JPA,以后再切换到其他数据库管理系统。

Note

从架构的角度来看,数据库访问最好包含在 EJB 或 EAR 模块中。为了简单起见,我们将 JPA 直接包含在 Java MVC 项目中,但是在 EJB 或 EAR 模块中这样做的过程非常相似。

如果启动 GlassFish 服务器,Apache Derby 数据库也不会自动启动。相反,您必须在控制台内部运行它:

cd [GLASSFISH_INST]
bin/asadmin start-database

其中[GLASSFISH_INST]是您的 GlassFish 服务器的安装目录。

Caution

虽然它们都由asadmin管理,但是 GlassFish Jakarta EE 服务器和 Apache Derby 数据库管理系统是相互独立的。如果您停止其中一个,另一个会继续运行。

要停止正在运行的 Apache Derby,请在控制台中输入以下命令:

cd [GLASSFISH_INST]
bin/asadmin stop-database

创建数据源

为了让 JPA 工作,我们需要向项目添加一个对象关系映射 (ORM)库。这里有几个选项,但是我们选择 EclipseLink 作为 ORM 库,因为 EclipseLink 是 JPA 2.2 的参考实现(这是 Jakarta EE 8 和 Java MVC 1.0 中使用的 JPA 版本)。

ORM 不直接连接到数据库,而是连接到抽象出数据库访问的数据源。这种间接方式允许使用服务器端管理工具建立连接池、缓存、事务性和数据处理管理。

为了为 GlassFish 创建合适的数据源,请在用于启动数据库的同一终端中输入以下内容:

cd [GLASSFISH_INST]
cd javadb/bin
# start the DB client
./ij

(或者对 Windows 使用ij。)我们现在在ij数据库客户端中,因为ij>提示符出现在终端中,所以您可以看到这一点。输入以下命令创建一个名为hello的数据库(在一行中输入,在create=前不要有空格):

ij> connect 'jdbc:derby://localhost:1527/hello;
    create=true;user=user0';

现在创建的数据库拥有者名为user0。我们还为用户添加了一个密码:

ij> call SYSCS_UTIL.SYSCS_CREATE_USER('user0','pw715');

Note

默认情况下,Apache Derby 不支持新数据库的身份验证。如果您仅将数据库用于开发,这通常不会造成问题,因为网络访问仅限于本地用户。然而,许多 Java 应用和数据库工具,如果您试图在没有认证的情况下访问数据库,会表现得很奇怪,所以我们添加了一个密码。

接下来,重新启动数据库以使身份验证开始工作:

cd [GLASSFISH_INST]
cd bin
./asadmin stop-database
./asadmin start-database

这只需要做一次。退出并重新打开ij工具内的连接(或按 Ctrl+D 完全退出ij;然后重启ij并再次连接):

ij> disconnect;
ij> connect 'jdbc:derby://localhost:1527/hello;
    user=user0;password=pw715';

(在一行中输入最后一个ij命令。)您可以检查身份验证机制:如果您忽略了用户名和/或密码,您将得到一条适当的错误消息。

为了透明和简单地连接到数据库,我们在 GlassFish 服务器配置中创建了两个资源:

cd [GLASSFISH_INST]
cd bin
./asadmin create-jdbc-connection-pool \
  --datasourceclassname \
    org.apache.derby.jdbc.ClientXADataSource \
  --restype javax.sql.XADataSource \
  --property \
    portNumber=1527:password=pw715:user=user0:
    serverName=localhost:databaseName=hello:
    securityMechanism=3 \
  HelloPool

./asadmin create-jdbc-resource \
  --connectionpoolid HelloPool jdbc/Hello

(在user=user0:databaseName = hello:之后没有换行和空格。)这将创建一个连接池和一个与之连接的 JDBC 资源。我们稍后将使用jdbc/Hello标识符来允许 JPA 连接到数据库。

如果您在http://localhost:4848进入 web 浏览器中的管理控制台,您可以看到这两个配置项目。导航到资源➤ JDBC ➤ JDBC 资源和资源➤ JDBC ➤ JDBC 连接池。见图 10-2 。

img/499016_1_En_10_Fig2_HTML.jpg

图 10-2

JDBC 资源公司

在本章的其余部分,我们假设您知道如何输入数据库命令。要么使用ij工具(启动后不要忘记连接),要么使用任何其他数据库客户端,例如名为 Squirrel 的开源工具。

准备会员注册申请

在这一章中,我们为 Java MVC 开发了一个基本的成员管理应用。成员存储在名为MEMBER的数据库表中。创建表和用于生成唯一 ID 的序列生成器的 SQL 命令如下:

CREATE TABLE MEMBER (
    ID      INT              NOT NULL,
    NAME    VARCHAR(128)     NOT NULL,
    PRIMARY KEY (ID));

INSERT INTO MEMBER (ID, NAME)
   VALUES (-3, 'John'),
          (-2, 'Linda'),
          (-1, 'Pat');

CREATE SEQUENCE MEMBER_SEQ start with 1 increment by 50;

我们还添加了几个示例条目。

Note

Apache Derby 知道如何自动生成惟一的 id。然而,我们让 EclipseLink 来处理这个问题。因此,ID字段是一个简单的整数值字段,没有任何额外的语义。EclipseLink 需要这个序列来生成这样的惟一 id(至少如果它是按照我们将要使用的方式使用的话)。

新数据库项目的项目结构如下:

Project HelloJpa
 src/main/java
   book.javamvc.jpa
     data
       User.java
     db
       Member.java
       MemberDAO.java
     i18n
       BundleForEL.java
       SetBundleFilter.java
     model
       UserEntering.java
       UserList.java
     AjaxController.java
     App.java
     HelloJpaController.java
     RootRedirector.java
  src/main/resources
    book.javamvc.jpa.messages
      Messages.properties
    META-INF
      persistence.xml
  src/main/webapp
    js
      jquery-3.5.1.min.js
    WEB-INF
      views
        index.jsp
    beans.xml
    glassfish-web.xml
  build.gradle
  gradle.properties
  settings.gradle

我们不想混合 Java MVC 模型类和数据库模型类,所以在User.java类中,我们抽象出任何用户数据:

package book.javamvc.jpa.data;

public class User {
    private int id;
    private String name;

    public User() {
    }

    public User(int id, String name) {
        this.id = id;
        this.name = name;
    }

    // Getters and setters...
}

BundleForELSetBundleFilter类与HelloWorld应用中的完全相同,但是增加了因子分解配置值(在其中一个练习中完成)。为了清楚起见,我在这里重复代码:

package book.javamvc.jpa.i18n;

import java.util.Enumeration;
import java.util.Locale;
import java.util.ResourceBundle;
import javax.servlet.http.HttpServletRequest;

public class BundleForEL extends ResourceBundle {
    private BundleForEL(Locale locale, String baseName) {
        setLocale(locale, baseName);
    }

    public static void setFor(HttpServletRequest request,
        String i18nAttributeName, String i18nBaseName) {
     if (request.getSession().
           getAttribute(i18nAttributeName) == null) {
         request.getSession().setAttribute(
           i18nAttributeName,
           new BundleForEL(request.getLocale(),
                           i18nBaseName));
           }
    }

    public void setLocale(Locale locale,
           String baseName) {
        if (parent == null ||
              !parent.getLocale().equals(locale)) {
            setParent(getBundle(baseName, locale));
        }
    }

    @Override
    public Enumeration<String> getKeys() {
        return parent.getKeys();
    }

    @Override
    protected Object handleGetObject(String key) {
        return parent.getObject(key);
    }
}

package book.javamvc.jpa.i18n;

import java.io.IOException;
import java.util.Map;

import javax.inject.Inject;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.core.Application;

@WebFilter("/*")
public class SetBundleFilter implements Filter {
    @Inject private Application appl;
    private String i18nAttributeName;
    private String i18nBaseName;

    @Override
    public void init(FilterConfig filterConfig)
          throws ServletException {
      Map<String,Object> applProps = appl.getProperties();
      i18nAttributeName = (String) applProps.get(
          "I18N_TEXT_ATTRIBUTE_NAME");
      i18nBaseName = (String) applProps.get(
          "I18N_TEXT_BASE_NAME");
    }

    @Override
    public void doFilter(ServletRequest request,
        ServletResponse response, FilterChain chain)
        throws IOException, ServletException {
      BundleForEL.setFor((HttpServletRequest) request,
        i18nAttributeName, i18nBaseName);
      chain.doFilter(request, response);
    }

    @Override
    public void destroy() {
    }
}

我们将用于新成员条目和成员列表的两个 Java MVC 模型类放在book.javamvc.jpa.model包中。代码内容如下:

package book.javamvc.jpa.model;

import javax.enterprise.context.RequestScoped;
import javax.inject.Named;
import book.javamvc.jpa.data.User;

@Named
@RequestScoped
public class UserEntering extends User {
}

package book.javamvc.jpa.model;

import java.util.ArrayList;
import javax.enterprise.context.RequestScoped;

import javax.inject.Named;
import book.javamvc.jpa.data.User;

@Named
@RequestScoped
public class UserList extends ArrayList<User>{
    private static final long serialVersionUID =
          8570272213112459191L;
}

AppRootRedirector类与HelloWorld应用中的相同,但是在其中一个练习中进行了重构:

package book.javamvc.jpa;

import java.util.HashMap;
import java.util.Map;

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

@ApplicationPath("/mvc")
public class App extends Application {
    @PostConstruct
    public void init() {
    }

    @Override
    public Map<String, Object> getProperties() {
      Map<String, Object> res = new HashMap<>();
      res.put("I18N_TEXT_ATTRIBUTE_NAME",
        "msg");
      res.put("I18N_TEXT_BASE_NAME",
        "book.javamvc.jpa.messages.Messages");
      return res;
    }
}

package book.javamvc.jpa;

import javax.servlet.FilterChain;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpFilter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * Redirecting http://localhost:8080/HelloJpa/
 * This way we don't need a <welcome-file-list> in web.xml
 */
@WebFilter(urlPatterns = "/")
public class RootRedirector extends HttpFilter {
    private static final long serialVersionUID =
          7332909156163673868L;

    @Override
    protected void doFilter(final HttpServletRequest req,
          final HttpServletResponse res,
          final FilterChain chain) throws IOException {
        res.sendRedirect("mvc/hello");
    }
}

build.gradle采用以下代码:

plugins {
  id 'war'
}

java {
  sourceCompatibility = JavaVersion.VERSION_1_8
  targetCompatibility = JavaVersion.VERSION_1_8
}

repositories {
  jcenter()
}

dependencies {
  testImplementation 'junit:junit:4.12'
  implementation 'javax:javaee-api:8.0'
  implementation 'javax.mvc:javax.mvc-api:1.0.0'
  implementation 'org.eclipse.krazo:krazo-jersey:1.1.0-M1'
  implementation 'jstl:jstl:1.2'
  implementation 'org.eclipse.persistence:'+
      'eclipselink:2.7.7'
}

task localDeploy(dependsOn: war,
    description:">>> Local deploy task") {
  // Take the code from the HelloWorld example
}

task localUndeploy(
    description:">>> Local undeploy task") {
  // Take the code from the HelloWorld example
}

settings.gradle文件由项目生成器向导准备,gradle.properties文件可以直接从第四章中取出。

所有其他文件将在后续章节中介绍。

要将 EclipseLink ORM 添加到项目中,请将以下内容添加到build.gradle文件的dependencies { }部分:

dependencies {
    ...
    implementation 'org.eclipse.persistence:'+
        'eclipselink:2.7.7'
}

接下来,创建一个包含以下内容的src/main/resources/META-INF/persistence.xml文件:

<persistence
    xmlns:=
        "http://java.sun.com/xml/ns/persistence"
    xmlns:xsi=
        "http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation=
        "http://java.sun.com/xml/ns/persistence persistence_1_0.xsd"
    version="1.0">
<persistence-unit name="default"
      transaction-type="JTA">
    <jta-data-source>jdbc/Hello</jta-data-source>
    <exclude-unlisted-classes>
      false
    </exclude-unlisted-classes>
    <properties />
</persistence-unit>
</persistence>

这是 JPA 的中央配置文件。在这里,我们指出如何连接到数据库。请注意,我们引用了之前配置的数据源资源。

Note

Eclipse IDE 有几个用于 JPA 相关开发的助手向导,它还有一个 JPA 方面,您可以将它添加到项目中。我决定不在这一介绍性章节中使用它们,以避免供应商锁定,并展示遵循 JPA 规范所需的基础知识。您可以免费尝试 Eclipse 的 JPA 方面。

控制器

成员注册应用的控制器与前几章中的HelloWorld控制器非常相似——我们也有一个登录页面,这次列出了所有成员,还有一个新成员的输入表单。添加成员会导致数据库INSERT操作,与HelloWorld不同,我们不会显示响应页面,而是用更新后的成员列表重新加载索引页面。代码内容如下:

package book.javamvc.jpa;

import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

import javax.ejb.EJB;
import javax.enterprise.context.RequestScoped;
import javax.inject.Inject;
import javax.inject.Named;
import javax.mvc.Controller;
import javax.mvc.binding.BindingResult;
import javax.mvc.binding.MvcBinding;
import javax.mvc.binding.ParamError;
import javax.ws.rs.FormParam;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.core.Response;

import book.javamvc.jpa.data.User;
import book.javamvc.jpa.db.MemberDAO;
import book.javamvc.jpa.model.UserEntering;
import book.javamvc.jpa.model.UserList;

@Path("/hello")
@Controller
public class HelloJpaController {
  @Named
  @RequestScoped
  public static class ErrorMessages {
    private List<String> msgs = new ArrayList<>();

    public List<String> getMsgs() {
      return msgs;
    }

    public void setMsgs(List<String> msgs) {
      this.msgs = msgs;
    }

    public void addMessage(String msg) {
      msgs.add(msg);
    }
  }

  @Inject private ErrorMessages errorMessages;
  @Inject private BindingResult br;

  @Inject private UserEntering userEntering;
  @Inject private UserList userList;

  @EJB private MemberDAO memberDao;

  @GET
  public String showIndex() {
    addUserList();
    return "index.jsp";
  }

  @POST
  @Path("/add")
  public Response addMember(
        @MvcBinding @FormParam("name") String name) {
    if(br.isFailed()) {
      br.getAllErrors().stream().
            forEach((ParamError pe) -> {
        errorMessages.addMessage(pe.getParamName() +
              ": " + pe.getMessage());
      });
    }

    userEntering.setName(name);

    memberDao.addMember(userEntering.getName());

    addUserList();
    return Response.ok("index.jsp").build();
  }

  ////////////////////////////////////////////////////////
  ////////////////////////////////////////////////////////

  private void addUserList() {
    userList.addAll(
      memberDao.allMembers().stream().map(member -> {
          return new User(member.getId(),
                          member.getName());
      }).collect(Collectors.toList())
    );
  }
}

HelloWorld示例应用中的一个重要区别是包含了用于数据库操作的MemberDAO数据访问对象,它从成员添加和列表方法中被引用。我们将在接下来的章节中讨论道。

成员删除由 AJAX 请求处理。与我们在前几章所做的相反,我们没有让 Java MVC 控制器处理 AJAX 请求。相反,我们添加了一个额外的 JAX-RS 控制器,如下所示:

just for AJAX:

package book.javamvc.jpa;

import javax.ejb.EJB;
import javax.ws.rs.DELETE;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.core.Response;

import book.javamvc.jpa.db.MemberDAO;

@Path("/ajax")
public class AjaxController {
  @EJB private MemberDAO memberDao;

  @DELETE
  @Path("/delete/{id}")
  public Response delete(@PathParam("id") int id) {
      memberDao.deleteMember(id);
      return Response.ok("{}").build();
  }
}

添加数据访问对象

数据访问对象(DAO)是一个 Java 类,它封装了像 CRUD(创建、读取、更新和删除)这样的数据库操作。然后,DAO 的客户端不必知道DAO 是如何工作的,而只需要关注业务功能。

在控制器内部,名为MemberDAO的 DAO 类通过@EJB注释注入。这个班去book.javamvc.jpa.db包。创建包和类,然后编写以下类代码:

package book.javamvc.jpa.db;

import java.util.List;

import javax.ejb.Stateless;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import javax.persistence.TypedQuery;

@Stateless
public class MemberDAO {
    @PersistenceContext
    private EntityManager em;

    public int addMember(String name) {
      List<?> l = em.createQuery(
              "SELECT m FROM Member m WHERE m.name=:name").
          setParameter("name",   name).
          getResultList();
      int id = 0;
      if(l.isEmpty()) {
        Member member = new Member();
        member.setName(name);
        em.persist(member);
        em.flush(); // needed to get the ID
        id = member.getId();
      } else {
        id = ((Member)l.get(0)).getId();
      }
      return id;
    }

    public List<Member> allMembers() {
      TypedQuery<Member> q = em.createQuery(
        "SELECT m FROM Member m", Member.class);
      List<Member> l = q.getResultList();
      return l;
    }

    public void deleteMember(int id) {
      Member member = em.find(Member.class, id);
      em.remove(member);
    }
}

我们提供了添加成员(避免重复)、列出所有成员和删除成员的方法。更新和搜索方法留待将来改进。您可以看到数据库操作是由一个EntityManager独占处理的,它是由@PersistenceContext注释注入的。通过配置文件persistence.xml,JPA 知道实体管理器需要访问哪个数据库。对于当前需要的大多数操作,我们可以使用来自EntityManager类的方法。唯一的例外是我们使用 JPA 查询语言表达式SELECT m FROM Member m的完整列表。

应用通过@Stateless类注释知道这个 DAO 是一个 EJB。因此,容器(服务器中处理 EJB 对象的部分)知道这个类的实例没有状态。

更新视图

对于基本会员注册申请,作为一个视图,我们只需要index.jsp文件:

<%@ page contentType="text/html;charset=UTF-8"
    language="java" %>
<%@ taglib prefix="c"
    uri="http://java.sun.com/jsp/jstl/core" %>
<%@ taglib prefix="fmt"
    uri="http://java.sun.com/jsp/jstl/fmt" %>
<html>
<head>
    <meta charset="UTF-8">
    <script type="text/javascript"
      src="${mvc.basePath}/../js/jquery-3.5.1.min.js">
    </script>
    <title>${msg.title}</title>
    <script type="text/javascript">
      function deleteItm(id) {
        var url =
          "${pageContext.servletContext.contextPath}" +
              "/mvc/ajax/delete/" + id;
        jQuery.ajax({
          url : url,
          method: "DELETE",
          dataType: 'json',
          success: function(data, textStatus, jqXHR) {
              jQuery('#itm-'+id).remove();
          },
          error: function (jqXHR, textStatus,
                           errorThrown) {
            console.log(errorThrown);
          }
        });
        return false;
      }
    </script>
</head>
<body>
    <form method="post"
        action="${mvc.uriBuilder(
            'HelloJpaController#greeting').build()}">
      ${msg.enter_name}
      <input type="text" name="name" />
      <input type="submit" value="${msg.btn_submit}" />
    </form>

    <table>
      <thead>
         <tr>
           <th>${msg.tblhdr_id}</th>
           <th>${msg.tblhdr_name}</th>
           <th></th>
         </tr>
       <thead>
       <tbody>
           <c:forEach   items="${userList}" var="itm">
             <tr id="itm-${itm.id}">
               <td>${itm.id}</td>
               <td>${itm.name}</td>
               <td><button onclick="deleteItm(${itm.id})">
                   ${msg.btn_delete}</button></td>
             </tr>
           </c:forEach>
       </tbody>
    </table>
</body>
</html>

此页面显示了用于输入新成员和完整成员列表的表单。由于我们添加到每个表格行的itm-[ID],删除一个条目的 AJAX 代码可以删除一个表格行,而不必重新加载整个页面。

该视图引用 jQuery 库。下载下来复制到src/main/webapp/js。相应地调整版本。

一个语言资源到src/main/resources/book/javamvc/jpa/messages/Messages.properties:

title = Hello Jpa
enter_name = Enter your name:
btn_delete = Delete
btn_submit = Submit
tblhdr_id = ID
tblhdr_name = Name

可以从第四章复制beans.xmlglassfish-web.xml文件。

添加实体

实体是表行作为对象的表示。如果我们想到MEMBER表,那么实体就是有名称和单一 ID 的东西。显然,这对应于带有nameid字段的 Java 类。所以我们创建了这样一个类,并把它放在book.javamvc.jpa.db包中:

public class Member {
   private int id; // + getter/setter
   private String name; // + getter/setter
}

为了完成数据库接口过程,我们需要添加元信息。这是一个实体类的信息、表名、列名、专用 ID 列名、唯一 ID 生成器规范和数据库字段值约束。和 Java 通常的情况一样,我们对这种元信息使用注释。我们这一类,加上所有这些修正,内容如下:

package book.javamvc.jpa.db;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.SequenceGenerator;
import javax.persistence.Table;
import javax.validation.constraints.NotNull;

@Entity
@Table(name="MEMBER")
@SequenceGenerator(name="HELLO_SEQ",
                  initialValue=1, allocationSize = 50)
public class Member {
  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY,
        generator = "HELLO_SEQ")
  @Column(name = "id")
  private int id;

  @NotNull
  @Column(name = "name")
  private String name;

  public int getId() {
    return id;
  }

  public void setId(int id) {
    this.id = id;
  }

  public String getName() {
    return name;
  }

  public void setName(String name) {
    this.name = name;
  }
}

具体来说,我们添加的注释是:

  • @Entity:将它标记为一个实体,这样 JPA 就知道这是一个实体类。

  • @Table:用于指定表格名称。如果省略,类名(不带包)将用作表名。

  • @SequenceGenerator:用于指定唯一 id 的序列生成器。

  • @Id:表示对应的字段是实体的唯一标识。

  • @GeneratedValue:表示新实体将自动为该字段生成值。

  • @Column:用于指定该字段对应的列名。如果未指定,字段名将用作列名。

  • @NotNull:表示字段和数据库字段都不能是null的约束。

给定实体类,JPA 现在知道如何将数据库条目字段映射到 Java 类。通过调整 Java MVC 控制器和添加 DAO 和实体类,应用拥有了全功能的 JPA 支持,您可以在http://localhost:8080/HelloJpa进行部署和尝试。此外,尝试重新启动服务器,并验证这些条目是否被持久化并在服务器重新启动后仍然存在。您还可以使用数据库客户端工具直接检查数据库,并研究添加到那里的表行。

添加关系

关系数据是关于关系的,就像一个表条目引用其他表的条目一样。JPA 为这种关系提供了一种解决方案,同样是通过可以添加到实体类中的特殊注释。

考虑下面的例子:在我们的成员资格应用中,我们添加了另一个名为STATUS的表,其中包含成员资格条目,比如 Gold、Platinum、Senior 或任何您可能想到的条目。每个成员可能有0N状态条目,所以我们谈论成员和状态条目之间的“一对多”关系。

为了实现这一点,我们首先创建一个STATUS表和一个STATUS_SEQ序列:

CREATE TABLE STATUS (
    ID          INT            NOT NULL,
    MEMBER_ID   INT            NOT NULL,
    NAME        VARCHAR(128)   NOT NULL,
  PRIMARY KEY (ID));

CREATE SEQUENCE STATUS_SEQ start with 1 increment by 50;

接下来,我们在book.javamvc.jpa.db包中创建一个名为Status的新实体类,其内容如下:

package book.jakarta8.calypsojpa.jpa;

import javax.persistence.*;
import javax.validation.constraints.*;

@Entity
@Table(name="STATUS")
@SequenceGenerator(name="STATUS_SEQ",
                   initialValue=1, allocationSize = 50)
public class Status implements Comparable<Status> {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY,
                    generator="STATUS_SEQ")

    @Column(name = "ID")
    private int id;

    @NotNull
    @Column(name = "MEMBER_ID")
    private int memberId;

    @NotNull
    @Column(name = "NAME")
    private String name;

    public Status() {
    }

    public Status(String name) {
      this.name = name;
    }

    @Override
    public int compareTo(Status o) {
      return -o.name.compareTo(name);
    }

    // + getters and setters
}

我们添加了一个构造函数,使用名称。重要的是要知道 JPA 规范要求有一个公共的无参数构造函数。

在实体类Member中,我们添加了一个对应于成员和状态之间实际关系的字段:

...
@JoinColumn(name = "MEMBER_ID")
@OneToMany(cascade = CascadeType.ALL, orphanRemoval= true)
private Set<Status> status; // + getter / setters
...

其他的都没动过。@JoinColumn字段引用了与相关联的类或表中的成员,所以我们不必为这个新字段更新成员表。

因为两个实体类的关系是通过@OneToMany宣布的,任何实体管理器操作都会自动将数据库操作正确级联到相关实体。例如,要创建新成员,您可以编写以下内容:

...
Member m = new Member();
m.setName(...);

Set<Status> status = new HashSet<>();
status.add(new Status("Platinum"));
status.add(new Status("Priority"));
m.setStatus(status);

em.persist(m);
...

所以你不必明确地告诉实体管理器持久化相关的Status实体。

在前端代码中,您可以添加一个以逗号分隔的状态值列表的文本字段,或者一个选择列表框或菜单来反映这种关系。这同样适用于UPDATEDELETE操作。由于@OneToMany注释中的cascade = CascadeType.ALL,如果成员被删除,JPA 甚至会从STATUS表中删除相关的Status条目。

关系数据模型中还有其他关联类型。您可以在 JPA 中为实体声明的可能关联类型如下:

  • 对于实体类A的实体,存在零到多个实体类B的相关条目。在类A中,用OneToMany注释定义了一个Set类型的字段。在实体B的表中,有一个名为ID_A(或任何您喜欢的名称)的外键,在实体类B中有一个名为aId(或任何您喜欢的名称)的字段,指向Aid。为了告诉A它是如何与B相关联的,然后添加另一个名为@JoinColumn的注释,如下所示:
         @OneToMany
         @JoinColumn(name="ID_A")   // In table B!
         private Set<B> b;

或者给@OneToMany添加一个属性,如下所示:

  • @曼顿

    对于实体类A,的零个或多个实体,存在实体类B的一个相关条目。在类A中,您添加了一个带有@ManyToOne@JoinColumn注释的B类型的字段,对于后者,您为连接提供了一个列名(在A的表中):

         @OneToMany(mappedBy = "aId") // Field in class B!
         private Set<B> b;

  • @OneToOne

    对于实体类A的一个实体,存在实体类B的一个相关条目。在类A中,您添加了一个带有@OneToOne@JoinColumn注释的B类型的字段,对于后者,您为连接提供了一个列名(在A的表中):

         @ManyToOne
         @JoinColumn(name="ID_B") // In table A
         private B b;

  • @ManyToMany

    对于实体类A的零个或多个实体,存在实体类B的零个或多个相关条目。这里,我们需要第三个表作为中间连接表;比如MTM_A_B,带柱ID_AID_B。实体类A(具有 ID 列"ID")中的注释如下所示:

         @OneToOne
         @JoinColumn(name="ID_B") // In table A
         private B b;

         @ManyToMany
         @JoinTable(
           name = "MTM_A_B",
           joinColumns = @JoinColumn(
             name = "ID_A",
             referencedColumnName="ID"),
           inverseJoinColumns = @JoinColumn(
             name = "ID_B",
             referencedColumnName="ID"))
         private Set<B> b;

练习

  • 练习 5:STATUS表添加到数据库中,并更新成员条目应用的代码以反映成员的状态。为简单起见,使用一个文本字段,您可以在其中输入以逗号分隔的状态值列表。

  • 练习 6: 说出 JPA 中用于表间关系的四种注释。

  1. 一个实体类对应一个数据库表。

  2. 实体类必须与数据库表同名。

  3. 实体类的属性(字段)必须与数据库表中的列具有相同的名称。

  4. 实体类的属性可以有限制。

  • 练习 4: 以下哪些是正确的?
  1. Dao 需要通过 JPA 连接到数据库。

  2. 需要 Dao 来提供数据库用户名和密码。

  3. 在 DAOs 中,必须指定数据库列名。

  4. Dao 用于避免在 JPA 客户端类中使用数据库表细节。

  5. 要使用 Dao,它们必须作为 EJB 注入。

  • 练习 2:JPA 的哪个组件(或者概念,如果你喜欢的话)在数据库表和 Java 对象(三个字母的缩写)之间转换?

  • 练习 3: 以下哪一项是正确的:

  1. JPA 通过一些数据源连接到数据库,数据源是服务器管理的资源。

  2. JPA 通过一些数据源连接到数据库,这些数据源是 JPA 自己提供的。

  3. JPA 通过 JDBC 连接到一个数据库。

  4. JPA 通过 EJB 连接到一个数据库。

  • 练习 1: 以下哪些是正确的?

摘要

JPA (Java 持久性 API)是用于从 Jakarta EE 内部访问关系数据库的专用技术。它的目的是提供 SQL 表和 Java 对象之间的桥梁。

JPA 的主要目的之一是抽象出数据库访问,并将数据库对象映射到 Java 类。最终,我们希望能够查询数据库并获得 Java 对象,或者将 Java 对象放入数据库。JPA 有助于隐藏如何做到这一点的细节,包括用户名和密码等连接属性,以及处理连接生命周期。

用于此目的的核心 JPA 类是EntityManager类,它使用一个名为persistence.xml的配置文件,以及 Jakarta EE 应用服务器内部的一些设置。在 Java 端,对应于表行的类被称为实体类。

为了让 JPA 工作,我们需要向项目添加一个对象关系映射 (ORM)库。这里有几个选项,但是我们选择 EclipseLink 作为 ORM 库,因为 EclipseLink 是 JPA 2.2 的参考实现(这是 Jakarta EE 8 和 Java MVC 1.0 中使用的 JPA 版本)。

ORM 不直接连接到数据库,而是连接到抽象出数据库访问的数据源。这种间接方式允许使用服务器端管理工具建立连接池、缓存、事务性和数据处理管理。数据源以特定于服务器产品的方式安装。

数据访问对象(DAO)是一个 Java 类,它封装了像 CRUD(创建、读取、更新和删除)这样的数据库操作。然后,DAO 的客户端不必知道DAO 是如何工作的,而只需要关注业务功能。

实体是表行作为对象的表示。为了完成数据库接口过程,我们需要添加元信息。这是一个实体类的信息、表名、列名、专用 ID 列名、唯一 ID 生成器规范和数据库字段值约束。和 Java 通常的情况一样,我们对这种元信息使用注释。

给定实体类,JPA 现在知道如何将数据库条目字段映射到 Java 类。通过调整 Java MVC 控制器和添加 DAO 和实体类,应用拥有了全功能的 JPA 支持。

关系数据是关于关系的,比如一个表条目引用其他表的条目。JPA 为这种关系提供了一种解决方案,同样是通过可以添加到实体类中的特殊注释。

您可以在 JPA 中为实体声明的可能关联类型如下:

  • 对于实体类A的实体,存在零到多个实体类B的相关条目。在类A中,用OneToMany注释定义了一个Set类型的字段。在实体B的表中,有一个名为ID_A(或任何您喜欢的名称)的外键,在实体类B中有一个指向Aid 的aId字段(或任何您喜欢的名称)。为了告诉A它是如何与B相关联的,然后添加另一个名为@JoinColumn的注释,如下所示:
         @OneToMany
         @JoinColumn(name="ID_A")   // In table B!
         private Set<B> b;

或者给@OneToMany添加一个属性,如下所示:

  • @曼顿

    对于实体类A的零个或多个实体,存在实体类B的一个相关条目。在类A中,您添加一个带有@ManyToOne@JoinColumn注释的B类型的字段,对于后者,您为连接提供一个列名(在A的表中):

         @OneToMany(mappedBy = "aId") // Field in class B!
         private Set<B> b;

  • @OneToOne

    对于实体类A的一个实体,存在实体类B的一个相关条目。在类A中,您添加了一个带有@OneToOne@JoinColumn注释的B类型的字段,对于后者,您为连接提供了一个列名(在A的表中):

         @ManyToOne
         @JoinColumn(name="ID_B") // In table A
         private B b;

  • @ManyToMany

    对于实体类A的零个或多个实体,存在实体类B的零个或多个相关条目。这里,我们需要第三个表作为中间连接表;比如MTM_A_B,有ID_AID_B两列。实体类A(具有 ID 列"ID")中的注释如下所示:

         @OneToOne
         @JoinColumn(name="ID_B") // In table A
         private B b;

         @ManyToMany
         @JoinTable(
           name = "MTM_A_B",
           joinColumns = @JoinColumn(
             name = "ID_A",
             referencedColumnName="ID"),
           inverseJoinColumns = @JoinColumn(
             name = "ID_B",
             referencedColumnName="ID"))
         private Set<B> b;

在下一章,我们将讨论 Java MVC 中的日志记录。****

十一、Java MVC 应用的日志记录

日志记录是任何中级到高级复杂性应用的重要组成部分。当程序通过它的执行路径运行时,几个日志语句描述程序正在做什么,哪些参数被传递给方法调用,局部变量和类字段有什么值以及它们如何改变,作出了哪些决定,等等。这些日志记录信息被收集并发送到文件、数据库、消息队列或其他地方,开发人员和操作团队可以调查程序流,以便修复错误或进行审计。

这一章是关于在你的程序中添加日志或者调查现有的服务器日志的各种选项。

系统流

Jakarta EE 构建其服务器技术所基于的 Java 标准环境(JSE)提供了众所周知的标准输出和错误输出流,如下所示:

System.out.println("Some information: ...");
System.err.println("Some error: ...");

虽然乍一看,使用这些流生成诊断信息似乎很容易,但不建议您使用此过程。主要原因是该方法高度依赖于操作系统和服务器产品。我们将很快介绍高级方法,但是如果您暂时想使用系统流进行诊断输出,那么了解大多数 Jakarta EE 服务器获取流并将它们重定向到某个文件是很重要的。

Note

到目前为止,我们使用输出和错误输出流进行诊断输出。我们这样做是为了简单。在任何严肃的项目中,您都不应该这样做,后续部分将向您展示如何避免这种情况。

Jakarta EE 8 GlassFish 服务器版本 5.1 将输出和错误输出流添加到您可以在

GLASSFISH_INST/glassfish/domains/domain1/logs

在这个通常冗长的清单中,您会将System.outSystem.err输出识别为包含[SEVERE](用于System.err)和[INFO](用于System.out)的行:

...
[2019-05-20T14:42:03.791+0200] [glassfish 5.1] [SEVERE]
    [] [] [tid: _ThreadID=28 _ThreadName=Thread-9]
    [timeMillis: 1558356123791] [levelValue: 1000] [[
    The System.err message ]]
...
[2019-05-20T14:42:03.796+0200] [glassfish 5.1] [INFO]
    [NCLS-CORE-00022] [javax.enterprise.system.core]
    [tid: _ThreadID=28
    _ThreadName=RunLevelControllerThread-1558356114688]
    [timeMillis: 1558356123796] [levelValue: 800] [[
    The System.out message ]]
...

我们稍后将了解如何更改这些日志行的详细级别和格式。

GlassFish 中的 JDK 日志

日志 API 规范 JSR 47 是 Java 的一部分,可以被任何 Java 程序使用,包括 Jakarta EE 服务器应用,当然还有 Java MVC 程序。您可以从 https://jcp.org/en/jsr/detail?id=47 下载该规范。

GlassFish 日志文件

GlassFish 使用这个平台标准 API JSR 47 进行日志记录。除非您更改配置,否则您可以在以下位置找到日志文件

GLASSFISH_INST/glassfish/domains/domain1/logs/server.log

在同一个文件夹中,您还会找到名为server.log_TS的归档日志,其中TS是一个时间戳,比如2019-05-08T15-45-58

标准日志格式被定义为各种信息片段的组合,当然包括实际的日志消息:

[Timestamp] [Product-ID]
      [Message-Type] [Message-ID] [Logger-Name] [Thread-ID]
      [Raw-Timestamp] [Log-Level]
      [[Message]]

例如:

[2019-05-20T14:42:03.796+0200] [glassfish 5.1] [INFO]
      [NCLS-CORE-00022] [javax.enterprise.system.core]
      [tid: _ThreadID=28
      _ThreadName=RunLevelControllerThread-1558356114688]
      [timeMillis: 1558356123796]
      [levelValue: 800]
      [[Loading application xmlProcessing done in 742 ms]]

将日志记录输出添加到控制台

如果您希望日志记录输出也出现在启动 GlassFish 服务器的终端中,请使用以下命令:

cd GLASSFISH_INST
bin/asadmin start-domain --verbose

这将显示完整的日志输出。它也不会像没有–verboseasadmin start-domain那样将服务器进程放在后台,所以当您关闭终端时,服务器将会停止。服务器启动后,您将无法在终端中输入更多命令(对于新命令,您当然可以输入第二个终端)。若要停止前台服务器进程,请按 Ctrl+C。

为您自己的项目使用标准日志 API

要使用 JSR 47 方法将诊断输出添加到您自己的类中,您可以在您的类中编写如下内容:

...
import java.util.logging.Logger;

public class MyClass {
  private final static Logger LOG =
      Logger.getLogger(MyClass.class.toString());

  public void someMethod() {
    LOG.entering(this.getClass().toString(),"someMethod");
    ...
    // different logging levels:
    LOG.finest("Finest: ...");
    LOG.finer("Finer: ...");
    LOG.fine("Fine: ...");
    LOG.info("Some info: ...");
    LOG.warning("Some warning: ...");
    LOG.severe("Severe: ...");
    ...
    LOG.exiting(this.getClass().toString(),"someMethod");
  }
  ...
}

对于LOG.entering(),还有一个变体,您可以向日志记录语句添加方法参数。同样,对于LOG.exiting(),变量允许您向日志记录语句添加返回值:

  ...
  public String someMethod(String p1, int p2) {
    LOG.entering(this.getClass().toString(),"someMethod",
        new Object[]{ p1, p2 });
    ...
    String res = ...;
    LOG.exiting(this.getClass().toString(),"someMethod",
        res);
    return res;
  }
  ...
}

日志记录级别

从这些示例中,您可以看到可以使用几个级别来指示日志记录输出的严重性。对于标准测井,级别依次为严重警告信息精细精细精细。这大大提高了日志的可用性。在项目的早期阶段,您可以将日志记录阈值设置为一个较低的值,例如fine,您将在日志文件中看到所有fine级别的日志记录以及所有更高级别的日志记录,直到severe

如果降低阈值(例如降低到finest,日志记录会显示更多细节,但是日志记录文件当然会更大。这就是为什么你这样做是为了修复 bug 拥有更多细节有助于您更容易地识别有问题的代码。在项目的后期,当成熟度上升时,您应用一个更高的阈值(例如warning)。这样,日志文件不会变得太大,但是您仍然可以在日志中看到重要的问题。

称为entering()exiting()的特殊Logger方法属于日志级finer。我们在这里展示的所有其他方法都与同名级别匹配,因此 a LOG.severe()属于级别severe,a LOG.warning()属于级别warning,以此类推。

记录器层次结构和阈值

如果您创建如下所示的记录器:

Logger.getLogger("com.example.projxyz.domain.Person");

你可以跨越一个层级comcom.examplecom.example.projxyzcom.example.projxyz.domaincom.example.projxyz.domain.Person

如果您分配了日志记录阈值,这将发挥作用。这种分配通过asadmin在配置中进行,或者在 web 管理控制台中进行。我们将很快看到如何做到这一点。知道阈值设置遵循记录器层次结构是很重要的。如果您给com分配一个级别LEV1(严重、警告、信息等),这意味着com处的完整子树获得了LEV1阈值。,除非您还为层次结构中更深层次的元素指定了级别。因此,如果您还为com.example分配了一个LEV2级别,那么对于com.example和该层次中更深层次的所有元素来说,LEV2优先于LEV1。更准确地说,规则如表 11-1 所示。

表 11-1

日志记录层次结构规则

|

等级制度

|

水平

|

记录器

|

描述

|
| --- | --- | --- | --- |
| com | FINE | com.ClassA | FINE适用,因为com.ClassAcom层次结构中。 |
| com | FINE | org.ClassA | FINE不适用,因为org.ClassA不在com层级内。 |
| com.ClassA | FINER | com.ClassA | FINER适用,因为com.ClassAcom.ClassA层次结构中。FINE不再适用,因为层次规范com.ClassAcom更具体。 |
| com.example | WARNING | com.ClassA | WARNING不适用,因为com.ClassA不在com.example层级内。 |
| com.example | WARNING | com.example. ClassA | WARNING适用,因为com.example.ClassAcom.example层次结构中。为com指定的级别不再适用,因为com.examplecom更具体。 |
| com.example | WARNING | org.example. ClassA | WARNING不适用,因为org。不在com.example的层级内。 |

日志记录配置

JSR 47 标准日志的日志配置依赖于名为logging.properties的配置文件。通常,该文件位于 JDK 安装目录中,但是 GlassFish 服务器会忽略标准日志记录配置,而是使用该文件:

GLASSFISH_INST/glassfish/domains/domain1/
      config/logging.properties

这里指定了各种日志记录属性。我们不会全部讨论它们——JSR 47 的规范和 GlassFish 服务器文档会给你更多的想法。最重要的设置是级别阈值。你会在#All log level details线下找到它们:

...
#All log level details
com.sun.enterprise.server.logging.GFFileHandler.level=ALL
javax.enterprise.system.tools.admin.level=INFO
org.apache.jasper.level=INFO
javax.enterprise.system.core.level=INFO
javax.enterprise.system.core.classloading.level=INFO
java.util.logging.ConsoleHandler.level=FINEST
javax.enterprise.system.tools.deployment.level=INFO
javax.enterprise.system.core.transaction.level=INFO
org.apache.catalina.level=INFO
org.apache.coyote.level=INFO
javax.level=INFO
...

这里,我们已经有了一个分层级别分配的例子:如果您将级别从javax.enterprise.system.core.level更改为FINE,任何javax.记录器都将使用阈值INFO,因为javax.level = INFO行,但是javax.enterprise.system.core.Main记录器将使用FINE,因为它与我们刚刚输入的级别相匹配,并且更加具体。

稍后在logging.properties文件中设置形式.level=INFO可确保所有未在日志记录属性中指定的日志记录程序都将使用INFO阈值。这就是为什么在 GlassFish 的标准配置变体中,没有出现finefinerfinest消息。

除了更改文件,您还可以在http://localhost:4848使用 web 管理控制台。导航至配置➤服务器-配置➤记录器设置。更改将直接写入logging.properties文件。

作为更改日志配置的第三种方式,asadmin命令行实用程序为我们提供了各种与日志相关的子命令。以下是一些例子:

./asadmin list-log-levels
# -> A list of all log levels, like
# javax                             <INFO>
# javax.mail                        <INFO>
# javax.org.glassfish.persistence   <INFO>
# org.apache.catalina               <INFO>
# org.apache.coyote                 <INFO>
# org.apache.jasper                 <INFO>
# ...

./asadmin delete-log-levels javax.mail
# -> Deletes a level specification

./asadmin set-log-levels javax.mail=WARNING
# -> Setting a specific log level

./asadmin list-log-attributes
# -> Shows all log attributes (not the levels)

./asadmin set-log-attributes \
    com.sun.enterprise.server.logging.
    GFFileHandler.rotationLimitInBytes=2000000
# (discard the line break after "logging.")
# -> Sets an attribute. Attribute names are the same
# as in the logging.properties file

./asadmin rotate-log
# -> Manually rotates the log file. Takes the current
# server.log file, archives it and starts a fresh
# empty server.log file.

日志记录级别的更改是动态的,因此您可以在服务器运行时更改日志记录级别。

日志记录格式

对于 JSR 47 标准日志记录,日志记录格式由日志记录处理程序规定。为了改变日志格式,您必须开发一个新的日志处理程序。这并不难实现,但是如果您需要改变格式并希望坚持 Java 平台日志记录,我们会让您自行决定。

否则,您可以很容易地切换到使用日志库。这种选择的大多数候选者都允许您通过调整配置属性来更改日志记录格式。我们将很快讨论 Log4j 日志框架,并讨论 Log4j 提供的日志格式化选项。

对其他服务器使用 JDK 标准日志记录

尽管大多数开发人员更喜欢使用日志库,如 Apache Commons Logging、Log4j 或 Logback,但是您也可以为 GlassFish 以外的服务器使用 JSR 47 日志。只要确保你提供了一个定制的logging.properties文件。但是,不要更改 JDK 安装文件夹中的logging.properties文件——不鼓励在那里更改配置。

相反,提供您自己的logging.properties文件,并将以下内容添加到服务器启动参数中(在一行中,删除换行符和=后的空格):

-Djava.util.logging.config.file=
    /path/to/logging.properties

您的服务器文档将告诉您如何做到这一点。

向应用添加 Log4j 日志记录

Log4j 是一个日志框架,常用于各种 Java 应用。其特点包括:

  • API 和实现的清晰分离。在服务器环境中,您在服务器上安装 Log4j 实现,而在客户机上,您只需要引用一个小内存 Log4j API 库。

  • 高性能。Log4j 包含 lambda 支持,因此如果相应的日志级别不被记录,就可以避免消息计算。例如,在LOG.info("Error", () -> expensiveOperation())中,如果记录器的info级消息被禁用,方法调用将不会发生。

  • 自动重新加载配置。对于 Log4j,很容易启用自动配置重载。日志记录配置中的任何更改都将立即生效,无需重新启动服务器。

  • 可以在配置中设置日志记录格式和各种其他日志记录属性。

  • Log4 配置文件可以格式化为 XML、Java 属性、JSON 和 YAML。

  • Log4j 很容易被插件扩展。

Log4j 可以从 http://logging.apache.org/log4j/2.x/ 下载。仍然广泛使用的 Log4j 1.x 版本已被弃用,我们不会在本书中讨论 1 . x 版本中的 Log4j。

Log4j 需要一些额外的权限来通过安全检查。为此,请打开此文件:

GLASSFISH_INST/glassfish/domains/domain1/
      config/server.policy

并在结尾添加以下内容:

// Added for Log4j2
grant {
    permission
        java.lang.reflect.ReflectPermission
        "suppressAccessChecks";
    permission
        javax.management.MBeanServerPermission "*";
    permission
        javax.management.MBeanPermission "*", "*";
    permission
        java.lang.RuntimePermission "getenv.*";
};

Caution

这个要求是特定于 GlassFish 服务器的。对于其他服务器,可能需要不同的设置。

在服务器范围内添加 Log4j

在服务器范围内添加 Log4j 意味着将 Log4j 实现放在一个公共库文件夹中,编写一个 Log4j 配置文件,该文件同时服务于该服务器上运行的所有 Jakarta EE 应用,并让所有应用和应用模块使用 Log4j API。该设置只需配置一次,然后服务器上所有当前和未来的应用都可以轻松地使用 Log4 进行日志记录。因为简单,所以这种包含 Log4j 的方式可能是最常用的。相反,您可以在每个应用的基础上添加 Log4j,但是只有当您有重要的理由将 Log4j 封装到应用中时,才应该这样做,例如,如果您还在运行使用旧 Log4j 1.x 版本的遗留应用。我们稍后会描述这个方法。

要在服务器范围内添加 Log4j,首先要从 https://logging.apache.org/log4j/2.x/ 下载 Log4j 发行版。然后将log4j-core-2.11.2.jarlog4j-api-2.11.2.jarlog4j-appserver-2.11.2文件(或您下载的任何版本)复制到以下文件夹:

GLASSFISH_INST/glassfish/domains/domain1/
      modules/autostart

Note

Log4j JAR 文件被实现为 OSGi 包。这就是我们将它们放入modules文件夹的原因。如果你不了解 OSGi,可以把它看作是一个先进的图书馆管理框架。

然后在GLASSFISH_INST/glassfish/domains/domain1/lib/classes文件夹中添加一个名为log4j2.json的文件。作为该文件的基本内容,请使用:

{
"configuration": {
  "name": "Default",
  "appenders": {
    "RollingFile": {
      "name":"File",
      "fileName":
          "${sys:com.sun.aas.instanceRoot}/logs/log4j.log",
      "filePattern":
          "${sys:com.sun.aas.instanceRoot}/
           logs/log4j-backup-%d{MM-dd-yy-HH-mm-ss}-%i.gz",
      "PatternLayout": {
          "pattern":
              "%d{yyyy-MM-dd HH:mm:ss} %-5p %c{1}:%L - %m%n"
      },
      "Policies": {
         "SizeBasedTriggeringPolicy": {
            "size":"10 MB"
         }
      },
      "DefaultRolloverStrategy": {
         "max":"10"
      }
    }
  },
  "loggers": {
    "logger" : [
       {
         "name" : "book.javamvc",
         "level":"debug",
         "appender-ref": {
           "ref":"File"
         }
       },{
         "name" : "some.other.logger",
         "level":"info",
         "appender-ref": {
           "ref":"File"
         }
       }
    ],
    "root": {
        "level":"error",
        "appender-ref": {
          "ref":"File"
        }
    }
  }
}
}

这添加了一个具有error级别的根日志记录器和另外两个日志记录器,称为book.javamvcsome.other.logger,阈值级别分别设置为debuginfo。“logger”数组中的记录器名称对应于记录器层次结构规范。它们的工作方式与标准 JDK 测井过程(JSR 47)中描述的方式相同。因此book.javamvc记录器适用于book.javamvc.SomeClassbook.javamvc.pckg.OtherClass的记录语句,但不适用于book.jakarta99.FooClass。特殊的“根”记录器是默认的,它匹配所有没有明确记录器规范的记录器。

这个文件为您提供了一个起点。您可以添加更多的附加器和记录器。请参阅 Internet 上最新的 Log4j2 文档,了解如何扩展配置。

Note

Log4j 允许配置文件使用不同的格式。我们选择 JSON 格式是因为它的简洁性。

如果服务器正在运行,请重新启动它。因为以这种方式添加 Log4j 的全局性质,所以需要这样做。现在可以开始在应用中使用 Log4j 了,如“在编码中使用 Log4j”一节所述。

Note

添加-Dlog4j2.debug作为服务器启动 JVM 参数,以获得关于 Log4j 正在做什么的更多输出。该元诊断信息被打印到标准server.log文件中。

更改日志记录格式

在 Log4j 配置文件中,我们已经指定了一个日志记录模式:

...
"pattern":
      "%d{yyyy-MM-dd HH:mm:ss} %-5p %c{1}:%L - %m%n"
...

这将打印一个由%d{yyyy-MM-dd HH:mm:ss}指定的时间戳,由%p指定的日志级别(5向输出添加一个填充符),由%c{1}指定的日志名称的最后一个路径元素,由%L指定的行号,以及由%m指定的消息。%n最后在末尾加了一个换行符。

这个你可以随意改。在线 Log4j2 手册中标题为“布局”的部分列出了所有选项。表 11-2 显示了最重要的选项。

表 11-2

记录模式

|

模式

|

描述

|
| --- | --- |
| m | 消息。 |
| C | 记录器的名称。 |
| c[N] | 只有记录器名称的最后一个N路径部分。因此,对于名为org.example.memory.Main的记录器,一个%c{1}创建Main作为输出,一个%{2}创建memory.Main,以此类推。 |
| c[-N] | 删除记录器名称的第一个N路径部分。所以用一个名为org.example.memory.Main的记录器,一个%c{-1}创建example.memory.Main,以此类推。 |
| c[1.] | 将记录器名称中除最后一部分以外的所有部分替换为点“.”。因此,通过一个名为org.example.memory.Main的记录器,一个%c{1.}创建了o.e.m.Main。 |
| p | 日志级别。 |
| -5p | 日志级别,用空格右填充到五个字符。 |
| d | 输出类似2019-09-23的时间戳07:23:45,123。 |
| d[DEFAULT_MICROS] | 与普通%d相同,但增加了微秒:2019-09-23 07:23:45,123456。 |
| d[ISO8601] | 输出如2019-09-23T07:23:45,123。 |
| d[UNIX_MILLIS] | 自 1970-01-01 00:00:00 UTC 以来的毫秒数。 |
| highlight{p} | 将 ANSI 颜色添加到封闭图案p中。比如:highlight{%d %-5p %c{1.}: %m}%n。 |
| L | 行号。这是一个昂贵的操作;小心使用。 |
| M | 方法名称。这是一个昂贵的操作;小心使用。 |
| n | 换行。 |
| t | 线程的名称。 |
| T | 线程的 ID。 |

Log4j2 还以 CSV 格式、GELF 格式、嵌入在 HTML 页面中以及 JSON、XML 或 YAML 格式创建日志输出。详情参见 Log4j2 手册。

将 Log4j 添加到 Jakarta EE Web 应用

如果您认为应该在每个应用的基础上添加 Log4j,并且让服务器上运行的其他应用不受影响,那么您可以将 Log4j 实现添加到您的 web 应用(WAR)中。

Note

如果您的服务器还运行使用旧 Log4j 1.x 的遗留应用,那么以这种隔离方式运行 Log4j 可能是必要的。

为了添加 Log4j 实现,我们更新了 Gradle 构建文件中的依赖项。打开build.gradle文件,将以下内容添加到dependencies { }部分:

implementation 'org.apache.logging.log4j:log4j-core
  :2.11.2'
implementation 'com.fasterxml.jackson.core:jackson-core
  :2.7.4'
implementation 'com.fasterxml.jackson.core:jackson-
  databind:2.7.4'
implementation 'com.fasterxml.jackson.core:jackson-
  annotations:2.7.4'

这里,中心部分是对log4j-core的依赖;需要依赖于jackson,因为我们将使用 JSON 格式的配置文件,而 Log4j 需要jackson来解析它们。

Log4j 配置文件需要被称为log4j2.json,对于 web 应用(WARs),它必须放在src/main/resources文件夹中。作为一个简单的配置,将log4j2.json的内容设置如下:

{
"configuration": {
  "name": "Default",
  "appenders": {
    "RollingFile": {
      "name":"File",
      "fileName":
          "${sys:com.sun.aas.instanceRoot}/logs/log4j.log",
      "filePattern":
          "${sys:com.sun.aas.instanceRoot}/
           logs/log4j-backup-%d{MM-dd-yy-HH-mm-ss}-%i.gz",
      "PatternLayout": {
         "pattern":
             "%d{yyyy-MM-dd HH:mm:ss} %-5p %c{1}:%L - %m%n"
      },
      "Policies": {
         "SizeBasedTriggeringPolicy": {
            "size":"10 MB"
         }
      },
      "DefaultRolloverStrategy": {
         "max":"10"
      }
    }
  },
  "loggers": {
    "logger" : [
      {
        "name" : "book.javamvc",
        "level":"debug",
        "appender-ref": {
        "ref":"File"
        }
      },{
         "name" : "some.other.logger",
         "level":"debug",
         "appender-ref": {
         "ref":"File"
         }
      }
   ],
   "root": {
       "level":"debug",
        "appender-ref": {
          "ref":"File"
        }
      }
    }
}
}

在编码中使用 Log4j

要在 Java MVC 应用中使用 Log4,请确保每个项目都有以下 Gradle 依赖关系:

implementation 'org.apache.logging.log4j:log4j-api:2.11.2'

然后将LoggerLogManager导入到类中,并使用一个静态记录器字段,如下所示:

import org.apache.logging.log4j.*;

public class SomeClass {
  private final static Logger LOG =
        LogManager.getLogger(SomeClass.class);
  ...
  public void someMethod() {
    ...
    // different logging levels:
    LOG.trace("Trace: ...");
    LOG.debug("Debug: ...");
    LOG.info("Some info: ...");
    LOG.warn("Some warning: ...");
    LOG.error("Some error: ...");
    LOG.fatal("Some fatal error: ...");
    ...
    // Logging in try-catch clauses
    try {
       ...
    } catch(Exception e) {
       ...
       LOG.error("Some error", e);
    }
  }
}

log4j2.json配置文件中,每个记录器中的level声明一个日志记录阈值:

"loggers": {
  "logger": [
    {
        "name":"book.javamvc",
        "level":"debug",
        "appender-ref": {
          "ref":"appenderName"
        }
    }
    ...
  ]
  ...
}

级别可以设置为tracedebuginfowarnerrorfatal

练习

  • 练习 1: 将 JSR 47 测井(在java.util.logging包中)添加到第 4(HelloWorld应用)的App类的@PostConstruct public void init()@Override public Map<String, Object> getProperties()方法中。讲述如何进入每种方法,以及getProperties()中设置的属性。

  • 练习 2: 将服务器范围的 Log4j 日志添加到您的 GlassFish 服务器。选择您的任何项目,并向其中添加 Log4j 日志记录。

摘要

日志记录是任何中级到高级复杂性应用的重要组成部分。当程序通过它的执行路径运行时,几个日志语句描述程序正在做什么,哪些参数被传递给方法调用,局部变量和类字段有什么值以及它们如何改变,作出了哪些决定,等等。这些日志记录信息被收集并发送到文件、数据库、消息队列或其他地方,开发人员和操作团队可以调查程序流,以便修复错误或进行审计。

日志 API 规范 JSR 47 是 Java 的一部分,可以由任何 Java 程序使用,包括 Jakarta EE 服务器应用和 Java MVC 程序。您可以从 https://jcp.org/en/jsr/detail?id=47 下载该规范。

GlassFish 使用这个平台标准 API JSR 47 进行日志记录。除非您更改配置,否则您可以在以下位置找到日志文件:

GLASSFISH_INST/glassfish/domains/domain1/logs/server.log

要使用 JSR 47 方法将诊断输出添加到您自己的类中,请在您的类中编写以下内容:

...
import java.util.logging.Logger;

public class MyClass {
  private final static Logger LOG =
        Logger.getLogger(MyClass.class.toString());

  public void someMethod() {
    LOG.entering(this.getClass().toString(),"someMethod");
    ...
    // different logging levels:
    LOG.finest("Finest: ...");
    LOG.finer("Finer: ...");
    LOG.fine("Fine: ...");
    LOG.info("Some info: ...");
    LOG.warning("Some warning: ...");
    LOG.severe("Severe: ...");
    ...
    LOG.exiting(this.getClass().toString(),"someMethod");
  }
  ...
}

对于标准测井,级别依次为严重警告信息精细精细精细。这大大提高了日志的可用性。在项目的早期阶段,您可以将记录阈值设置为一个较低的值,例如fine,您将在记录文件中看到所有fine级和更高级别的记录,直到severe

JSR 47 标准日志的日志配置依赖于名为logging.properties的配置文件。通常,该文件位于 JDK 安装目录中,但是 GlassFish 服务器会忽略标准日志记录配置,而是使用该文件:

GLASSFISH_INST/glassfish/domains/domain1/ config/logging.properties

Log4j 是一个日志框架,常用于各种 Java 应用。Log4j 可以从 http://logging.apache.org/log4j/2.x/ 下载。

在服务器范围内添加 Log4j 意味着将 Log4j 实现放在一个公共库文件夹中,编写一个 Log4j 配置文件,该文件同时服务于该服务器上运行的所有 Jakarta EE 应用,并让所有应用和应用模块使用 Log4j API。因为这只需要配置一次,然后服务器上所有当前和未来的应用都可以轻松地使用 Log4 进行日志记录,所以这种包含 Log4j 的方式可能是最常见的。相反,您可以在每个应用的基础上添加 Log4j,但是只有当您有重要的理由将 Log4j 封装到应用中时,才应该这样做,例如,如果您还在运行使用旧 Log4j 1.x 版本的遗留应用。

要在服务器范围内添加 Log4j,首先要从 https://logging.apache.org/log4j/2.x/ 下载 Log4j 发行版。然后将log4j-core-2.11.2.jarlog4j-api-2.11.2.jarlog4j-appserver-2.11.2文件(或你下载的任何版本)复制到这个文件夹:

GLASSFISH_INST/glassfish/domains/domain1/
      modules/autostart

然后将log4j2.json文件添加到GLASSFISH_INST/glassfish/domains/domain1/lib/classes文件夹中。该文件的基本内容如下:

{
"configuration": {
  "name": "Default",
  "appenders": {
    "RollingFile": {
      "name":"File",
      "fileName":
          "${sys:com.sun.aas.instanceRoot}/logs/log4j.log",
      "filePattern":
          "${sys:com.sun.aas.instanceRoot}/
           logs/log4j-backup-%d{MM-dd-yy-HH-mm-ss}-%i.gz",
          "PatternLayout": {
             "pattern":
                 "%d{yyyy-MM-dd HH:mm:ss} %-5p %c{1}:%L - %m%n"
           },
           "Policies": {
             "SizeBasedTriggeringPolicy": {
                 "size":"10 MB"
              }
          },
          "DefaultRolloverStrategy": {
              "max":"10"
           }
        }
    },
    "loggers": {
      "logger" : [
         {
            "name" : "book.javamvc",
            "level":"debug",
            "appender-ref": {
              "ref":"File"
            }
          },{
            "name" : "some.other.logger",
            "level":"info",
            "appender-ref": {
              "ref":"File"
            }
          }
       ],
       "root": {
          "level":"error",
          "appender-ref": {
            "ref":"File"
          }
      }
    }
}
}

如果您认为应该在每个应用的基础上添加 Log4j,并且让服务器上运行的其他应用不受影响,那么您可以将 Log4j 实现添加到您的 web 应用(WAR)中。

要添加 Log4j 实现,您需要更新 Gradle 构建文件中的依赖项。打开build.gradle文件并将其添加到dependencies { }部分:

implementation 'org.apache.logging.log4j:log4j-core
  :2.11.2'
implementation 'com.fasterxml.jackson.core:jackson-core
  :2.7.4'
implementation 'com.fasterxml.jackson.core:jackson-
  databind:2.7.4'
implementation 'com.fasterxml.jackson.core:jackson-
  annotations:2.7.4'

这里,中心部分是对log4j-core的依赖;需要依赖于jackson,因为我们将使用 JSON 格式的配置文件,而 Log4j 需要jackson来解析它们。

Log4j 的配置文件需要被称为log4j2.json,它必须放在 web 应用(WARs)的src/main/resources文件夹中。作为一个简单的配置,将log4j2.json的内容设置如下:

{
"configuration": {
    "name": "Default",
    "appenders": {
      "RollingFile": {
        "name":"File",
        "fileName":
            "${sys:com.sun.aas.instanceRoot}/logs/log4j.log",
        "filePattern":
           "${sys:com.sun.aas.instanceRoot}/
            logs/log4j-backup-%d{MM-dd-yy-HH-mm-ss}-%i.gz",
        "PatternLayout": {
           "pattern":
                "%d{yyyy-MM-dd HH:mm:ss} %-5p %c{1}:%L - %m%n"
           },
           "Policies": {
             "SizeBasedTriggeringPolicy": {
                "size":"10 MB"
            }
          },
      "DefaultRolloverStrategy": {
           "max":"10"
          }
      }
    },
    "loggers": {
    "logger" : [
        {
           "name" : "book.javamvc",
           "level":"debug",
           "appender-ref": {
           "ref":"File"
         }
       },{
           "name" : "some.other.logger",
           "level":"debug",
           "appender-ref": {
           "ref":"File"
          }
       }
    ],
    "root": {
         "level":"debug",
         "appender-ref": {
         "ref":"File"
           }
        }
     }
   }
}

要在 Java MVC 应用中使用 Log4,请确保每个项目都有以下 Gradle 依赖关系:

implementation 'org.apache.logging.log4j:log4j-api:2.11.2'

然后在类中导入LoggerLogManager,并使用静态记录器字段,如下所示:

  import org.apache.logging.log4j.*;

  public class SomeClass {
    private final static Logger LOG =
          LogManager.getLogger(SomeClass.class);
    ...
    public void someMethod() {
    ...
    // different logging levels:
    LOG.trace("Trace: ...");
    LOG.debug("Debug: ...");
    LOG.info("Some info: ...");
    LOG.warn("Some warning: ...");
    LOG.error("Some error: ...");
    LOG.fatal("Some fatal error: ...");
    ...
    // Logging in try-catch clauses
    try {
       ...
    } catch(Exception e) {
        ...
        LOG.error("Some error", e);
    }
  }
}

在本书的下一章,我们将给出一个全面的 Java MVC 应用的例子。

十二、Java MVC 示例应用

我们用一个全面的示例应用来结束这本书,它涵盖了我们在前几章中谈到的许多方面。这个应用是一个我们称之为BooKlubb的图书俱乐部管理。我们将领域限制在书籍和成员,这只是在很小程度上取代了我们已经讨论过的各种例子,但仍然可以作为许多应用的蓝图。你会经常遇到这种人物结合。

BooKlubb应用专注于 Java MVC 功能;我们没有在前端设计上花费太多的精力,我们也没有使用 AJAX,以尽量减少干扰。当然,您可以根据自己的喜好来设计应用。

bokklubb 数据库

我们在第十章谈到了使用数据库。我们对BooKlubb使用相同的内置 Apache Derby 数据库。有三个表:MEMBER用于BooKlubb会员,BOOK用于图书,BOOK_RENTAL用于图书租赁信息(将图书分配给会员)。

在使用 Apache Derby 之前,请记住您必须从 GlassFish 安装文件夹中通过bin/asadmin start-database启动它。

接下来,我们通过ij客户端连接到新数据库(如果您愿意,可以使用任何其他合适的数据库客户端),并向其中添加用户凭证:

cd [GLASSFISH_INST]
cd javadb/bin
# start the DB client
./ij
ij> connect 'jdbc:derby://localhost:1527/booklubb;
create=true;user=bk';
ij> call SYSCS_UTIL.SYSCS_CREATE_USER('bk','pw715');

Note

下次连接时,您必须提供密码,如connect '...;user=bk;password=pw715';所示

要创建表和 ID 序列,请输入以下内容:

CREATE TABLE MEMBER (
    ID          INT           NOT NULL,
    FIRST_NAME  VARCHAR(128)  NOT NULL,
    LAST_NAME   VARCHAR(128)  NOT NULL,
    BIRTHDAY    DATE          NOT NULL,
    SSN         VARCHAR(16)   NOT NULL,
    PRIMARY KEY (ID));
CREATE SEQUENCE MEMBER_SEQ start with 1 increment by 1;

CREATE TABLE BOOK (
    ID          INT           NOT NULL,
    TITLE       VARCHAR(128)  NOT NULL,
    AUTHOR_FIRST_NAME  VARCHAR(128)  NOT NULL,
    AUTHOR_LAST_NAME   VARCHAR(128)  NOT NULL,
    MAKE        DATE          NOT NULL,
    ISBN        VARCHAR(24)   NOT NULL,
    PRIMARY KEY (ID));
CREATE SEQUENCE BOOK_SEQ start with 1 increment by 1;

CREATE TABLE RENTAL (
    ID          INT   NOT NULL,
    MEMBER_ID   INT   NOT NULL,
    BOOK_ID     INT   NOT NULL,
    RENTAL_DAY  DATE  NOT NULL,
    PRIMARY KEY (ID));
CREATE SEQUENCE RENTAL_SEQ start with 1 increment by 1;

在 GlassFish 服务器中,我们需要为数据库连接创建资源。我们可以使用asadmin工具来实现这一点:

cd [GLASSFISH_INST]
cd bin
./asadmin create-jdbc-connection-pool \
   --datasourceclassname \
     org.apache.derby.jdbc.ClientXADataSource \
   --restype javax.sql.XADataSource \
   --property \
     portNumber=1527:password=pw715:user=bk:
     serverName=localhost:databaseName=booklubb:
     securityMechanism=3 \
   BooKlubbPool

./asadmin create-jdbc-resource \
--connectionpoolid BooKlubbPool jdbc/BooKlubb

(在bk:booklubb:之后不应有换行符和空格。).由于这些资源,JPA 知道如何连接到数据库。JPA 需要一个数据源,这些命令正好创建了这样一个数据源。

Caution

数据源的创建特定于服务器。如果您使用 GlassFish 之外的服务器,您必须查阅手册以了解如何创建数据源。

BooKlubb Eclipse 项目

打开 Eclipse 并选择任何合适的工作区。例如,选择与书中示例相同的工作空间。

创建一个新的 Gradle 项目:选择文件➤新➤其他...➤·格拉德➤·格拉德项目。输入名称BooKlubb

如果出现构建路径错误(查看问题),请右键单击该项目,然后选择“属性”“➤”“Java 构建路径”。移除错误的 JRE 系统库(标记为unbound),然后选择 Add Library 并选择您的 Java 8 JDK。单击应用并关闭。另请参见第三章中标题为“关于 Gradle 的更多信息”的章节。

用以下内容替换build.gradle文件的内容:

plugins {
    id 'war'
}

java {
    sourceCompatibility = JavaVersion.VERSION_1_8
    targetCompatibility = JavaVersion.VERSION_1_8
}

repositories {
    jcenter()
}

dependencies {
  testImplementation 'junit:junit:4.12'
  implementation 'javax:javaee-api:8.0'
  implementation 'javax.mvc:javax.mvc-api:1.0.0'
  implementation 'org.eclipse.krazo:krazo-jersey:1.1.0-M1'
  implementation 'jstl:jstl:1.2'
}

task localDeploy(dependsOn: war,
             description:">>> Local deploy task") {
  doLast {
    def FS = File.separator
    def glassfish =
        project.properties['glassfish.inst.dir']
    def user = project.properties['glassfish.user']
    def passwd = project.properties['glassfish.passwd']

    File temp = File.createTempFile("asadmin-passwd",
        ".tmp")
    temp << "AS_ADMIN_${user}=${passwd}\n"

    def sout = new StringBuilder()
    def serr = new StringBuilder()
    def libsDir =
      "${project.projectDir}${FS}build${FS}libs"
    def proc = """${glassfish}${FS}bin${FS}asadmin
        --user ${user} --passwordfile ${temp.absolutePath}
        deploy --force=true
         ${libsDir}/${project.name}.war""".execute()
    proc.waitForProcessOutput(sout, serr)
    println "out> ${sout}"
    if(serr.toString()) System.err.println(serr)

    temp.delete()
  }
}

task localUndeploy(
             description:">>> Local undeploy task") {
  doLast {
    def FS = File.separator
    def glassfish =
        project.properties['glassfish.inst.dir']
    def user = project.properties['glassfish.user']
    def passwd = project.properties['glassfish.passwd']

    File temp = File.createTempFile("asadmin-passwd",
        ".tmp")
    temp << "AS_ADMIN_${user}=${passwd}\n"

    def sout = new StringBuilder()
    def serr = new StringBuilder()
    def proc = """${glassfish}${FS}bin${FS}asadmin
      --user ${user} --passwordfile ${temp.absolutePath}
      undeploy ${project.name}""".execute()
    proc.waitForProcessOutput(sout, serr)
    println "out> ${sout}"
    if(serr.toString()) System.err.println(serr)

    temp.delete()
  }
}

这与第四章中描述的构建文件相同。选择 Gradle ➤刷新 Gradle 项目,以确保依赖项被传输到 Java 构建路径。

作为部署和“取消部署”的配置,向项目中添加一个gradle.properties文件,根据您的需要修改这些值:

glassfish.inst.dir = /path/to/your/glassfish5.1
glassfish.user = admin
glassfish.passwd =

bokklubb 基础结构类

类似于第四章中的HelloWorld示例,我们使用AppRootRedirector类来定制上下文路径并创建登陆页面:

package book.javamvc.bk;

import java.util.HashMap;
import java.util.Map;
import java.util.logging.Logger;

import javax.annotation.PostConstruct;
import javax.inject.Inject;
import javax.ws.rs.ApplicationPath;
import javax.ws.rs.core.Application;

@ApplicationPath("/mvc")
public class App extends Application {
  @PostConstruct
  public void init() {
  }

  @Override
  public Map<String, Object> getProperties() {
  Map<String, Object> res = new HashMap<>();
  res.put("I18N_TEXT_ATTRIBUTE_NAME",
    "msg");
  res.put("I18N_TEXT_BASE_NAME",
    "book.javamvc.bk.messages.Messages");
  return res;
  }
}

package book.javamvc.bk;

import javax.servlet.FilterChain;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpFilter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * Redirecting http://localhost:8080/BooKlubb/
 * This way we don't need a <welcome-file-list> in web.xml
 */
@WebFilter(urlPatterns = "/")
public class RootRedirector extends HttpFilter {
  private static final long serialVersionUID =
      7332909156163673868L;
  @Override
  protected void doFilter(final HttpServletRequest req,
        final HttpServletResponse res,
        final FilterChain chain) throws IOException {
    res.sendRedirect("mvc/bk");
  }
}

配置 bokklubb 数据库访问

应用使用 JPA 来访问数据库。如第十章所述,我们在src/main/resources/META-INF中需要一个persistence.xml文件,如下所示:

<persistence
    xmlns:="http://java.sun.com/xml/ns/persistence"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation=
      "http://java.sun.com/xml/ns/persistence
       persistence_1_0.xsd"
    version="1.0">
<persistence-unit name="default" transaction-type="JTA">
    <jta-data-source>jdbc/BooKlubb</jta-data-source>
    <exclude-unlisted-classes>
      false
    </exclude-unlisted-classes>
    <properties />
</persistence-unit>
</persistence>

这个文件的主要职责是描述应用使用哪个数据库。

bokklubb 国际化

正如第八章所描述的,我们使用两个名为BundleForELSetBundleFilter的类来实现国际化:

package book.javamvc.bk.i18n;

import java.util.Enumeration;
import java.util.Locale;
import java.util.ResourceBundle;
import javax.servlet.http.HttpServletRequest;

public class BundleForEL extends ResourceBundle {
    private BundleForEL(Locale locale, String baseName) {
        setLocale(locale, baseName);
    }

    public static void setFor(HttpServletRequest request,
        String i18nAttributeName, String i18nBaseName) {
      if (request.getSession().
            getAttribute(i18nAttributeName) == null) {
          request.getSession().setAttribute(
            i18nAttributeName,
           new BundleForEL(request.getLocale(),
                           i18nBaseName));
      }
    }

    public void setLocale(Locale locale,
          String baseName) {
       if (parent == null ||
             !parent.getLocale().equals(locale)) {
           setParent(getBundle(baseName, locale));
       }
    }

    @Override
    public Enumeration<String> getKeys() {
        return parent.getKeys();
    }

    @Override
    protected Object handleGetObject(String key) {
        return parent.getObject(key);
    }
}

package book.javamvc.bk.i18n;

import java.io.IOException;
import java.util.Map;

import javax.inject.Inject;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.core.Application;

@WebFilter("/*")
public class SetBundleFilter implements Filter {
    @Inject private Application appl;
    private String i18nAttributeName;
    private String i18nBaseName;

    @Override
    public void init(FilterConfig filterConfig)
          throws ServletException {
      Map<String,Object> applProps = appl.getProperties();
      i18nAttributeName = (String) applProps.get(
          "I18N_TEXT_ATTRIBUTE_NAME");
      i18nBaseName = (String) applProps.get(
          "I18N_TEXT_BASE_NAME");
   }

    @Override
    public void doFilter(ServletRequest request,
        ServletResponse response, FilterChain chain)
        throws IOException, ServletException {
      BundleForEL.setFor((HttpServletRequest) request,
        i18nAttributeName, i18nBaseName);
      chain.doFilter(request, response);
    }

    @Override
    public void destroy() {
    }
}

src/main/resources/book/javamvc/bk/messages/Messages.properties中,我们放入了一个包含以下内容的资源文件:

title = BooKlubb

menu_search_member = Search Member
menu_new_member = New Member
menu_search_book = Search Book
menu_new_book = New Book

current_member = Current Member:

enter_memberFirstName = First Name:
enter_memberLastName = Last Name:
enter_memberBirthday = Birthday:
enter_memberSsn = SSN:

enter_authorFirstName = Author First Name:
enter_authorLastName = Author First Name:
enter_bookTitle = Title:
enter_bookMake = Make:
enter_isbn = ISBN:

hd_searchResult = Search Result
hd_searchMember = Search Member
hd_newMember = New Member
hd_searchBook = Search Book
hd_newBook = New Book
hd_memberDetails = Member Details
hd_booksAssigned = Books Assigned

tblhdr_id = ID
tblhdr_last_name = Last Name
tblhdr_first_name = First Name
tblhdr_birthday = Birthday
tblhdr_ssn = SSN
tblhdr_author_last_name = Last Name
tblhdr_author_first_name = First Name
tblhdr_book_title = Title
tblhdr_book_make = Make
tblhdr_isbn = ISBN

btn_search = Search
btn_new = New
btn_delete = Delete
btn_select = Select
btn_details = \u2190
btn_assign = Assign
btn_unassign = Unassign
no_result = ---- No result ----

new_member_added = New Member Added
new_book_added = New Book Added
member_deleted = Member Deleted
book_deleted = Book Deleted

memb_id = ID:
memb_firstName = First Name:
memb_lastName = Last Name:
memb_birthday = Birthday:
memb_ssn = SSN:

这些键值对仅供视图页面专用。

bokklubb 实体类

有了数据库表定义,我们可以立即编写 JPA 实体类。这在没有定义任何功能的情况下是可能的,因为实体类不包含任何编程逻辑。对于BooKlubb,它们如下所示:

package book.javamvc.bk.db;

import java.util.Date;
import java.util.Set;

import javax.persistence.CascadeType;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.OneToMany;
import javax.persistence.SequenceGenerator;
import javax.persistence.Table;
import javax.validation.constraints.NotNull;

@Entity
@Table(name = "MEMBER")
@SequenceGenerator(name = "MEMBER_SEQ", initialValue = 1,
      allocationSize = 1)
public class Member {
  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY,
      generator = "MEMBER_SEQ")
  @Column(name = "id")
  private int id;

  @NotNull
  @Column(name = "first_name")
  private String firstName;

  @NotNull
  @Column(name = "last_name")
  private String lastName;

  @NotNull
  @Column(name = "birthday")
  private Date birthday;

  @NotNull
  @Column(name = "ssn")
  private String ssn;

  @JoinColumn(name = "MEMBER_ID")
  @OneToMany(cascade = CascadeType.ALL, orphanRemoval=true)
  private Set<Rental> rental;

  public int getId() {
    return id;
  }

  public void setId(int id) {
    this.id = id;
  }

  public String getFirstName() {
    return firstName;
  }

  public void setFirstName(String firstName) {
    this.firstName = firstName;
  }

  public String getLastName() {
    return lastName;
  }

  public void setLastName(String lastName) {
    this.lastName = lastName;
  }

  public Date getBirthday() {
    return birthday;
  }

  public void setBirthday(Date birthday) {
    this.birthday = birthday;
  }

  public String getSsn() {
    return ssn;
  }

  public void setSsn(String ssn) {
    this.ssn = ssn;
  }

  public Set<Rental> getRental() {
    return rental;
  }

  public void setRental(Set<Rental> rental) {
    this.rental = rental;
  }
}

package book.javamvc.bk.db;

import java.util.Date;

import javax.persistence.CascadeType;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.OneToOne;
import javax.persistence.SequenceGenerator;
import javax.persistence.Table;
import javax.validation.constraints.NotNull;

@Entity
@Table(name = "BOOK")
@SequenceGenerator(name = "BOOK_SEQ", initialValue = 1,
     allocationSize = 1)
public class Book {
  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY,
      generator = "BOOK_SEQ")
  @Column(name = "id")
  private int id;

  @NotNull
  @Column(name = "title")
  private String title;

  @NotNull
  @Column(name = "author_first_name")
  private String authorFirstName;

  @NotNull
  @Column(name = "author_last_name")
  private String authorLastName;

  @NotNull
  @Column(name = "make")
  private Date make;

  @NotNull
  @Column(name = "isbn")
  private String isbn;

  @OneToOne(cascade = CascadeType.ALL, orphanRemoval=true,
      mappedBy = "book")
  private Rental rental;

  public int getId() {
    return id;
  }

  public void setId(int id) {
    this.id = id;
  }

  public String getTitle() {
    return title;
  }

  public void setTitle(String title) {
    this.title = title;
  }

  public String getAuthorFirstName() {
    return authorFirstName;
  }

  public void setAuthorFirstName(String authorFirstName) {
    this.authorFirstName = authorFirstName;
  }

  public String getAuthorLastName() {
    return authorLastName;
  }

  public void setAuthorLastName(String authorLastName) {
    this.authorLastName = authorLastName;
  }

  public Date getMake() {
    return make;
  }

  public void setMake(Date make) {
    this.make = make;
  }

  public String getIsbn() {
    return isbn;
  }

  public void setIsbn(String isbn) {
    this.isbn = isbn;
  }

  public Rental getRental() {
    return rental;
  }

  public void setRental(Rental rental) {
    this.rental = rental;
  }
}

package book.javamvc.bk.db;

import java.util.Date;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.OneToOne;
import javax.persistence.SequenceGenerator;
import javax.persistence.Table;
import javax.validation.constraints.NotNull;

@Entity
@Table(name = "RENTAL")
@SequenceGenerator(name = "RENTAL_SEQ", initialValue = 1,
    allocationSize = 1)
public class Rental {
  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY,
      generator = "RENTAL_SEQ")
  @Column(name = "id")
  private int id;

  @NotNull
  @Column(name = "member_id")
  private int memberId;

  @NotNull
  @JoinColumn(name = "book_id")
  @OneToOne
  private Book book;

  @NotNull
  @Column(name = "rental_day")
  private Date rentalDay;

  public int getId() {
    return id;
  }

  public void setId(int id) {
    this.id = id;
  }

  public int getMemberId() {
    return memberId;
  }

  public void setMemberId(int memberId) {
    this.memberId = memberId;
  }

  public Book getBook() {
    return book;
  }

  public void setBook(Book book) {
    this.book = book;
  }

  public Date getRentalDay() {
    return rentalDay;
  }

  public void setRentalDay(Date rentalDay) {
    this.rentalDay = rentalDay;
  }
}

这些类通过@OneToOne@OneToMany注释反映了数据库表字段和关系。后者背后的思想是,一个成员可能有零本、一本或更多本被租借的书(@OneToMany),一本书可能被租借也可能不被租借(@OneToOne,其中“未被租借”反映为一个null值)。

通过 daos 访问 bokklubb 数据库

Dao 封装处理数据库访问并处理实体类。Dao 提供了创建、更新和删除实体以及在数据库中搜索的方法。我们把它们放在book.javamvc.bk.db包里。

package book.javamvc.bk.db;

import java.util.Date;
import java.util.List;
import java.util.Optional;

import javax.ejb.Stateless;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import javax.persistence.TypedQuery;

@Stateless
public class MemberDAO {
  @PersistenceContext
  private EntityManager em;

  public int addMember(String firstName, String lastName,
        Date birthday, String ssn) {

    // First check if there is already a member with the
    // same SSN. Create a new entry only if none found.
    List<?> l = em.createQuery("SELECT m FROM Member m "+
              "WHERE m.ssn=:ssn").
          setParameter("ssn",   ssn).
           getResultList();
    int id = 0;
    if(l.isEmpty()) {
      Member member = new Member();
      member.setFirstName(firstName);
      member.setLastName(lastName);
      member.setBirthday(birthday);
      member.setSsn(ssn);
      em.persist(member);
      em.flush(); // needed to get the ID
      id = member.getId();
    } else {
      id = ((Member)l.get(0)).getId();
    }
    return id;
  }

  public List<Member> allMembers() {
      TypedQuery<Member> q = em.createQuery(
         "SELECT m FROM Member m", Member.class);
      List<Member> l = q.getResultList();
      return l;
   }

  public Member memberById(int id) {
    return em.find(Member.class, id);
  }

  public Optional<Member> memberBySsn(String ssn) {
    List<?> l = em.createQuery("SELECT m FROM Member m "+
            "WHERE m.ssn=:ssn").
        setParameter("ssn",   ssn).
         getResultList();
    if(l.isEmpty()) {
      return Optional.empty();
    } else {
      return Optional.of((Member)l.get(0));
    }
  }

    @SuppressWarnings("unchecked")
    public List<Member> membersByName(String firstName,
        String lastName) {
    List<?> l = em.createQuery("SELECT m FROM Member m "+
            "WHERE m.firstName LIKE :fn AND "+
            "m.lastName LIKE :ln").
        setParameter("fn",   firstName.isEmpty() ?
            "%" : "%" + firstName + "%").
        setParameter("ln",   lastName.isEmpty() ?
            "%" : "%" + lastName + "%").
        getResultList();
    return (List<Member>) l;
  }

  public void deleteMember(int id) {
    Member member = em.find(Member.class, id);
    em.remove(member);
  }
}

你可以看到我们注入了一个EntityManager的实例作为 JPA 的接口。从那里,我们可以使用它的方法来访问数据库表。例如,在addMember()中,我们使用 JPA 查询语言 (JQL)使用作为方法参数给出的 SSN 来搜索成员的表,如果找不到,我们通过EntityManager.persist()保存一个新的实体。在memberById()中,我们可以直接使用EntityManager.find(),因为参数是实体类的主键ID

另一个名为BookDAO的类主要处理 book 表。其代码如下:

package book.javamvc.bk.db;

import java.util.Date;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;

import javax.ejb.Stateless;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import javax.persistence.TypedQuery;

@Stateless
public class BookDAO {
  @PersistenceContext
  private EntityManager em;

  public int addBook(String authorFirstName,
        String authorLastName, String title,
        Date make, String isbn) {

    // First check if there is already a book with the
    // same ISBN in the database. Create a new entry
    // only if none is found.
    List<?> l = em.createQuery("SELECT b FROM Book b "+
          "WHERE b.isbn=:isbn").
        setParameter("isbn",   isbn).
         getResultList();
    int id = 0;
    if(l.isEmpty()) {
      Book book = new Book();
      book.setAuthorFirstName(authorFirstName);
      book.setAuthorLastName(authorLastName);
      book.setTitle(title);
      book.setMake(make);
      book.setIsbn(isbn);
      em.persist(book);
      em.flush(); // needed to get the ID
      id = book.getId();
    } else {
      id = ((Book)l.get(0)).getId();
    }
    return id;
  }

  public List<Book> allBooks() {
    TypedQuery<Book> q = em.createQuery(
        "SELECT b FROM Book b", Book.class);
    List<Book> l = q.getResultList();
    return l;
  }

  public Book bookById(int id) {
    return em.find(Book.class, id);
  }

  public Optional<Book> bookByIsbn(String isbn) {
    List<?> l = em.createQuery("SELECT b FROM Book b "+
           "WHERE b.isbn=:isbn").
         setParameter("isbn",     isbn).
         getResultList();
    if(l.isEmpty()) {
     return Optional.empty();
    } else {
      return Optional.of((Book)l.get(0));
    }
  }

  @SuppressWarnings("unchecked")
  public List<Book> booksByName(String authorFirstName,
        String authorLastName, String bookTitle) {
    String afn = (authorFirstName == null ||
                  authorFirstName.isEmpty() ) ?
        "%" : ("%"+authorFirstName+"%");
    String aln = (authorLastName == null ||
                  authorLastName.isEmpty() ) ?
        "%" : ("%"+authorLastName+"%");
    String t = (bookTitle == null ||
                bookTitle.isEmpty() ) ?
        "%" : ("%"+bookTitle+"%");

    List<?> l = em.createQuery("SELECT b FROM Book b "+
          "WHERE b.title LIKE :title AND "+
          "b.authorLastName LIKE :aln AND "+
          "b.authorFirstName LIKE :afn").
        setParameter("title", t).
        setParameter("aln", aln).
        setParameter("afn", afn).
        getResultList();
    return (List<Book>) l;
  }

  public void deleteBook(int id) {
    Book book = em.find(Book.class, id);
    em.remove(book);
  }

}

第三个名为RentalDAO的 DAO 类注册图书租赁(将图书分配给成员):

package book.javamvc.bk.db;

import java.util.Date;
import java.util.Set;
import java.util.stream.Collectors;

import javax.ejb.Stateless;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;

@Stateless
public class RentalDAO {
    @PersistenceContext
    private EntityManager em;

    public void rentBook(Book b, Member m, Date day) {
      Rental r = b.getRental();
      if(r == null) {
        r = new Rental();
      }

    // Update the BOOK table
    r.setBook(b);
    r.setMemberId(m.getId());
    r.setRentalDay(day);
    b.setRental(r);
    em.merge(b);

    // Update the MEMBER table
    Set<Rental> rs = m.getRental();
    if(rs.stream().allMatch(r1 -> {
        return r1.getBook().getId() != b.getId(); })) {
      rs.add(r);
      m.setRental(rs);
      em.merge(m);
    }
  }

  public void unrentBook(Book b, Member m) {
    Rental r = b.getRental();
    if(r == null) return;

    // Update the BOOK table
    b.setRental(null);
    em.merge(b);

    // Update the MEMBER table
    Set<Rental> newRental =
        m.getRental().stream().filter(rr -> {
            return rr.getBook().getId() != b.getId(); }).
        collect(Collectors.toSet());
    m.setRental(newRental);
    em.merge(m);
  }
}

博克鲁伯模式

BooKlubb应用的模型部分(Java MVC 模型,不是数据库模型)由几个在控制器和视图之间传输数据的类组成:

  • MemberModel:包含俱乐部成员。我们只需要它作为成员搜索结果列表的项目类型。请求范围。

  • MemberSearchResult:会员搜索的结果列表。请求范围。

  • BookModel:包含图书信息。我们需要它作为图书搜索结果列表的项目类型,以及当前成员的详细信息视图中列出的图书租赁。请求范围。

  • BookSearchResult:图书搜索的结果列表。请求范围。

  • CurrentMember:包含当前选中成员的信息。这是唯一一个会话范围的模型 bean。我们需要更大的范围,因为可以从成员搜索结果列表中选择一个当前成员,此后必须记住该成员,以便在不同的页面上将图书分配给该成员。

我们将它们都放在book.javamvc.bk.model包中,代码如下:

package book.javamvc.bk.model;

import java.util.Date;

public class MemberModel {
  private int id;
  private String firstName;
  private String lastName;
  private Date birthday;
  private String ssn;

  public MemberModel(int id, String firstName,
      String lastName, Date birthday, String ssn) {
    this.id = id;
    this.firstName = firstName;
    this.lastName = lastName;
    this.birthday = birthday;
    this.ssn = ssn;
  }

  public int getId() {
    return id;
  }

  public void setId(int id) {
    this.id = id;
  }

  public String getFirstName() {
    return firstName;
  }

  public void setFirstName(String firstName) {
    this.firstName = firstName;
  }

  public String getLastName() {
    return lastName;
  }

  public void setLastName(String lastName) {
    this.lastName = lastName;
  }

  public Date getBirthday() {
    return birthday;
  }

  public void setBirthday(Date birthday) {
    this.birthday = birthday;
  }

  public String getSsn() {
    return ssn;
  }

  public void setSsn(String ssn) {
    this.ssn = ssn;
  }
}

package book.javamvc.bk.model;

import java.util.ArrayList;
import java.util.List;

import javax.enterprise.context.RequestScoped;
import javax.inject.Named;

import book.javamvc.bk.db.Member;

@Named
@RequestScoped
public class MemberSearchResult extends
      ArrayList<MemberModel>{
  private static final long serialVersionUID =
        -5926389915908884067L;
  public void addAll(List<Member> l) {
    l.forEach(m -> {
      add(new MemberModel(
        m.getId(),
        m.getFirstName(),
        m.getLastName(),
        m.getBirthday(),
        m.getSsn()
      ));
    });
  }
}

在这个类中,我们用数据库层的Member类添加了一个名为addAll( List < Member > l )的便利方法。通常我们不想使用 DAOs 之外的数据库实体,但是Member只是一个数据持有者,我们不需要它的任何功能。所以层的混合不会对应用架构产生太大的影响。

package book.javamvc.bk.model;

import java.util.Date;

public class BookModel {
  private int id;
  private String authorFirstName;
  private String authorLastName;
  private String title;
  private String isbn;
  private Date make;

  public BookModel(int id, String authorFirstName,
        String authorLastName, String title, String isbn,
        Date make) {
    this.id = id;
    this.authorFirstName = authorFirstName;
    this.authorLastName = authorLastName;
    this.title = title;
    this.isbn = isbn;
    this.make = make;
  }

  public int getId() {
    return id;
  }

  public void setId(int id) {
    this.id = id;
  }

  public String getAuthorFirstName() {
    return authorFirstName;
  }

  public void setAuthorFirstName(String authorFirstName) {
    this.authorFirstName = authorFirstName;
  }

  public String getAuthorLastName() {
    return authorLastName;
  }

  public void setAuthorLastName(String authorLastName) {
    this.authorLastName = authorLastName;
  }

  public String getTitle() {
    return title;
  }

  public void setTitle(String title) {
    this.title = title;
  }

  public String getIsbn() {
    return isbn;
  }

  public void setIsbn(String isbn) {
    this.isbn = isbn;
  }

  public Date getMake() {
    return make;
  }

  public void setMake(Date make) {
    this.make = make;
  }
}

package book.javamvc.bk.model;

import java.util.ArrayList;
import java.util.List;

import javax.enterprise.context.RequestScoped;
import javax.inject.Named;

import book.javamvc.bk.db.Book;

@Named
@RequestScoped
public class BookSearchResult extends
      ArrayList<BookModel>{
  private static final long serialVersionUID =
      -5926389915908884067L;
  public void addAll(List<Book> l) {
    l.forEach(b -> {
      add(new BookModel(
        b.getId(),
        b.getAuthorFirstName(),
        b.getAuthorLastName(),
        b.getTitle(),
        b.getIsbn(),
        b.getMake()
      ));
    });
  }
}

package book.javamvc.bk.model;

import java.io.Serializable;
import java.util.Date;
import java.util.Set;

import javax.enterprise.context.SessionScoped;
import javax.inject.Named;

@Named
@SessionScoped
public class CurrentMember extends MemberModel
      implements Serializable {
  private static final long serialVersionUID =
      -7855133427774616033L;

  public CurrentMember(int id, String firstName,
      String lastName, Date birthday, String ssn) {
    super(id, firstName, lastName, birthday, ssn);
  }

  private boolean defined = false;
  private Set<BookModel> rentals;

  public boolean isDefined() {
    return defined;
  }

  public void setDefined(boolean defined) {
    this.defined = defined;
  }

  public void setRentals(Set<BookModel> rentals) {
    this.rentals = rentals;
  }

  public Set<BookModel> getRentals() {
    return rentals;
  }
}

bokklubb 控制器

控制器负责接收来自视图的所有POSTGET动作。在 Java MVC 和BooKlubb应用中,它看起来像这样:

package book.javamvc.bk;

import ...;

@Path("/bk")
@Controller
public class BooKlubbController {
  @Named
  @RequestScoped
  public static class ErrorMessages {
    private List<String> msgs = new ArrayList<>();
    public List<String> getMsgs() {
      return msgs;
    }
    public void setMsgs(List<String> msgs) {
      this.msgs = msgs;
    }
    public void addMessage(String msg) {
      msgs.add(msg);
    }
  }

  private @Inject ErrorMessages errorMessages;
  private @Inject BindingResult br;

  private @EJB MemberDAO memberDao;
  private @Inject MemberSearchResult memberSearchResult;

  private @EJB BookDAO bookDao;
  private @Inject BookSearchResult bookSearchResult;

  private @EJB RentalDAO rentalDao;

  private @Inject CurrentMember currentMember;

  // action methods...
}

我们为错误消息使用一个内部类,并注入访问数据库所需的各种模型类和 DAO EJBs。

完整的代码如下所示:

package book.javamvc.bk;

import java.time.LocalDate;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.stream.Collectors;

import javax.ejb.EJB;
import javax.enterprise.context.RequestScoped;
import javax.inject.Inject;
import javax.inject.Named;
import javax.mvc.Controller;
import javax.mvc.binding.BindingResult;
import javax.mvc.binding.MvcBinding;
import javax.mvc.binding.ParamError;
import javax.validation.constraints.Pattern;
import javax.ws.rs.FormParam;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.Response;

import book.javamvc.bk.db.Book;
import book.javamvc.bk.db.BookDAO;
import book.javamvc.bk.db.Member;
import book.javamvc.bk.db.MemberDAO;
import book.javamvc.bk.db.RentalDAO;
import book.javamvc.bk.model.BookModel;
import book.javamvc.bk.model.BookSearchResult;
import book.javamvc.bk.model.CurrentMember;
import book.javamvc.bk.model.MemberSearchResult;

@Path("/bk")
@Controller
public class BooKlubbController {
  @Named
  @RequestScoped
  public static class ErrorMessages {
    private List<String> msgs = new ArrayList<>();
    public List<String> getMsgs() {
      return msgs;
    }
    public void setMsgs(List<String> msgs) {
      this.msgs = msgs;
    }
    public void addMessage(String msg) {
      msgs.add(msg);
    }
  }

  private @Inject ErrorMessages errorMessages;
  private @Inject BindingResult br;

  private @EJB MemberDAO memberDao;
  private @Inject MemberSearchResult memberSearchResult;

  private @EJB BookDAO bookDao;
  private @Inject BookSearchResult bookSearchResult;

  private @EJB RentalDAO rentalDao;

  private @Inject CurrentMember currentMember;

我们添加了几个使用@GET来检索页面而无需用户输入的方法:

@GET
public String showIndex() {
  return "index.jsp";
}

@GET
@Path("/searchMember")
public Response searchMember() {
  return Response.ok("searchMember.jsp").build();
}

@GET
@Path("/newMember")
public Response newMember() {
  return Response.ok("newMember.jsp").build();
}

@GET
@Path("/searchBook")
public Response searchBook() {
  return Response.ok("searchBook.jsp").build();
}

@GET
@Path("/newBook")
public Response newBook() {
  return Response.ok("newBook.jsp").build();
}

以下是与成员相关的方法:显示搜索到的成员列表、响应创建新成员、删除成员、显示成员详细信息以及选择成员:

@GET
@Path("/searchMemberSubmit")
public Response searchMemberSubmit(
    @MvcBinding @QueryParam("firstName")
        String firstName,
    @MvcBinding @QueryParam("lastName")
        String lastName,
    @MvcBinding @QueryParam("ssn")
        String ssn) {
  showErrors();

  String ssnNormal = ssn == null ?
      "" : ( ssn.replaceAll("\\D", "") );
  List<Member> l = new ArrayList<>();
  if(!ssnNormal.isEmpty()) {
    memberDao.memberBySsn(ssnNormal).ifPresent(
        m1 -> { l.add(m1); });
    } else {
      l.addAll( memberDao.membersByName(
          firstName, lastName) );
    }
    memberSearchResult.addAll(l);

    return Response.ok("searchMemberResult.jsp").build();
  }

  @POST
  @Path("/newMemberSubmit")
  public Response newMemberSubmit(
      @MvcBinding @FormParam("firstName")
          String firstName,
      @MvcBinding @FormParam("lastName")
          String lastName,
      @MvcBinding @FormParam("birthday")
          @Pattern(regexp = "\\d\\d/\\d\\d/\\d\\d\\d\\d")
          String birthday,
      @MvcBinding @FormParam("ssn")
          String ssn) {
    showErrors();

    DateTimeFormatter dtf = DateTimeFormatter.ofPattern(
          "MM/dd/yyyy");
    LocalDate ld = LocalDate.parse(birthday, dtf);
    Date date = Date.from(ld.atStartOfDay(
        ZoneId.systemDefault()).toInstant());

    memberDao.addMember(firstName, lastName, date, ssn);

    return Response.ok("newMemberResult.jsp").build();
  }

  @POST
  @Path("/deleteMember")
  public Response deleteMember(
      @MvcBinding @FormParam("memberId")
          int memberId) {
    showErrors();

    memberDao.deleteMember(memberId);

    return Response.ok("deleteMemberResult.jsp").build();
  }

  @POST
  @Path("/selectMember")
  public Response selectMember(
      @MvcBinding @FormParam("memberId")
          int memberId) {
    showErrors();

    Member m = memberDao.memberById(memberId);
    currentMember.setId(memberId);
    currentMember.setFirstName(m.getFirstName());
    currentMember.setLastName(m.getLastName());
    currentMember.setBirthday(m.getBirthday());
    currentMember.setSsn(m.getSsn());
    currentMember.setDefined(true);

    return Response.ok("index.jsp").build();
  }

  @POST
  @Path("/memberDetails")
  public Response memberDetails(
    @MvcBinding @FormParam("memberId")
        int memberId) {
    showErrors();

    Member m = memberDao.memberById(memberId);
    currentMember.setId(memberId);
    currentMember.setFirstName(m.getFirstName());
    currentMember.setLastName(m.getLastName());
    currentMember.setBirthday(m.getBirthday());
    currentMember.setSsn(m.getSsn());
    currentMember.setRentals(
      m.getRental().stream().map(r -> {
        Book b = r.getBook();
      return new BookModel(b.getId(),
          b.getAuthorFirstName(),
          b.getAuthorLastName(),
          b.getTitle(), b.getIsbn(), b.getMake());
      }).collect(Collectors.toSet())
    );
    currentMember.setDefined(true);

    return Response.ok("memberDetails.jsp").build();
  }

我们只需要添加与书相关的方法,包括对搜索书的反应、添加或删除书,以及分配或“取消分配”书:

@GET
@Path("/searchBookSubmit")
public Response searchBookSubmit(
    @MvcBinding @QueryParam("authorFirstName")
         String authorFirstName,
    @MvcBinding @QueryParam("authorLastName")
         String authorLastName,
    @MvcBinding @QueryParam("bookTitle")
         String bookTitle,
    @MvcBinding @QueryParam("isbn")
         String isbn) {
  showErrors();

  String isbnNormal = isbn == null ?
      "" : ( isbn.replaceAll("\\D", "") );
  List<Book> l = new ArrayList<>();
  if(!isbnNormal.isEmpty()) {
    bookDao.bookByIsbn(isbnNormal).ifPresent(m1 -> {
        l.add(m1); });
  } else {
    l.addAll( bookDao.booksByName(authorFirstName,
        authorLastName, bookTitle) );
  }
  bookSearchResult.addAll(l);

  return Response.ok("searchBookResult.jsp").build();
}

@POST
@Path("/newBookSubmit")
public Response newBookSubmit(
    @MvcBinding @FormParam("authorFirstName")
        String authorFirstName,
    @MvcBinding @FormParam("authorLastName")
        String authorLastName,
    @MvcBinding @FormParam("title")
        String bookTitle,
    @MvcBinding @FormParam("make")
    @Pattern(regexp = "((\\d\\d/)?\\d\\d/)?\\d\\d\\d\\d")
        String make,
    @MvcBinding @FormParam("isbn")
        String isbn) {
  showErrors();

  String isbnNormal = isbn == null ?
      "" : ( isbn.replaceAll("\\D", "") );
  String makeNormal = make == null ? "" : (
    make.matches("\\d\\d\\d\\d") ?
        "01/01/" + make :
        (make.matches("\\d\\d/\\d\\d\\d\\d") ?
          make.substring(0,2) + "/01" +
       make.substring(2) : make)
  );
  DateTimeFormatter dtf = DateTimeFormatter.ofPattern(
      "MM/dd/yyyy");
  LocalDate ld = LocalDate.parse(makeNormal, dtf);
  Date date = Date.from(ld.atStartOfDay(
      ZoneId.systemDefault()).toInstant());
  bookDao.addBook(authorFirstName, authorLastName,
      bookTitle, date, isbnNormal);

  return Response.ok("newBookResult.jsp").build();
}

@POST
@Path("/deleteBook")
public Response deleteBook(
    @MvcBinding @FormParam("bookId")
        int bookId) {
  showErrors();

  bookDao.deleteBook(bookId);

  return Response.ok("deleteBookResult.jsp").build();
}

@POST
@Path("/assignBook")
public Response assignBook(
    @MvcBinding @FormParam("bookId")
         int bookId,
    @MvcBinding @FormParam("userId")
         int userId) {
  showErrors();

  Book b = bookDao.bookById(bookId);
  Member m = memberDao.memberById(userId);
  Date now = new Date();
  rentalDao.rentBook(b, m, now);

  return Response.ok("index.jsp").build();
}

@POST
@Path("/unassignBook")
public Response unassignBook(
    @MvcBinding @FormParam("bookId")
         int bookId,
    @MvcBinding @FormParam("memberId")
         int userId) {
  showErrors();

  Book b = bookDao.bookById(bookId);
  Member m = memberDao.memberById(userId);
  rentalDao.unrentBook(b, m);

  currentMember.setRentals(
    m.getRental().stream().map(r -> {
      Book bb = r.getBook();
    return new BookModel(bb.getId(),
          bb.getAuthorFirstName(),
          bb.getAuthorLastName(),
          bb.getTitle(),
          bb.getIsbn(),
          bb.getMake());
    }).collect(Collectors.toSet())
    );

  return Response.ok("memberDetails.jsp").build();
}

我们添加了一个私有方法,它传输由 Java MVC 检测到的错误,然后关闭该类:

  private void showErrors() {
    if(br.isFailed()) {
      br.getAllErrors().stream().forEach(
        (ParamError pe) -> {
          errorMessages.addMessage(pe.getParamName() +
          ": " + pe.getMessage());
      });
    }
  }

} // closing the class

博克鲁伯观点

正如我们在本书的其他 Java MVC 应用中所做的那样,我们将一个名为beans.xml的空文件添加到src/main/webapp/WEB-INF中。另外,将常用的glassfish-web.xml添加到同一个文件夹中:

<?xml version="1.0" encoding="UTF-8"?>
<glassfish-web-app error-url="">
    <class-loader delegate="true"/>
</glassfish-web-app>

此外,下载一个jQuery发行版并将其放在src/main/webapp/js文件夹中。

在下一节中,我们将描述BooKlubb所需的与视图相关的 JSP 文件。

碎片文件

这些元素显示在每个网页上——主菜单、当前选择的成员和任何错误信息。因此,我们通过<%@ include ... %>指令将它们提取为要包含的片段。

碎片放在src/main/webapp/fragments文件夹中;代码内容如下:

<%-- File: currentMember.jsp ******************** --%>
<%@ page contentType="text/html;charset=UTF-8"
  language="java" %>
<%@ taglib prefix="c"
  uri="http://java.sun.com/jsp/jstl/core" %>
<%@ taglib prefix="fmt"
  uri="http://java.sun.com/jsp/jstl/fmt" %>

<div style="background-color:#AAA;margin-bottom:1em">
${msg.current_member}
<c:choose>
  <c:when test="${! currentMember.defined}">
    ----
  </c:when>
  <c:otherwise>
    <fmt:formatDate value="${currentMember.birthday}"
                      pattern="MM/dd/yyyy" var="cubd" />
    <span style="font-weight:bold">
      ${currentMember.firstName}
      ${currentMember.lastName}
      ${cubd} (${currentMember.ssn})
    </span>
  </c:otherwise>
</c:choose>
</div>

<%-- File: errors.jsp ******************************* --%>
<%@ page contentType="text/html;charset=UTF-8"
  language="java" %>
<%@ taglib prefix="c"
  uri="http://java.sun.com/jsp/jstl/core" %>
<%@ taglib prefix="fmt"
  uri="http://java.sun.com/jsp/jstl/fmt" %>

<div style="color:red">
  <c:forEach var="e" items="${errorMessages.msgs}">
    ${e}
  </c:forEach>
</div>

<%-- File: mainMenu.jsp ***************************** --%>
<%@ page contentType="text/html;charset=UTF-8"
  language="java" %>
<%@ taglib prefix="c"
  uri="http://java.sun.com/jsp/jstl/core" %>
<%@ taglib prefix="fmt"
  uri="http://java.sun.com/jsp/jstl/fmt" %>

<div style="width:30%; float:left;">
  <ul>
    <li><a href="${mvc.uriBuilder(
          'BooKlubbController#searchMember').build()}">
        ${msg.menu_search_member}</a></li>
    <li><a href="${mvc.uriBuilder(
          'BooKlubbController#newMember').build()}">
        ${msg.menu_new_member}</a></li>
    <li><a href="${mvc.uriBuilder(
          'BooKlubbController#searchBook').build()}">
        ${msg.menu_search_book}</a></li>
    <li><a href="${mvc.uriBuilder(
          'BooKlubbController#newBook').build()}">
        ${msg.menu_new_book}</a></li>
  </ul>
</div>

登录页面

名为index.jsp(在src/main/webapp/WEB-INF/views文件夹中)的登录页面包括上述片段,否则不显示任何内容:

<%@ page contentType="text/html;charset=UTF-8"
  language="java" %>
<%@ taglib prefix="c"
  uri="http://java.sun.com/jsp/jstl/core" %>
<%@ taglib prefix="fmt"
  uri="http://java.sun.com/jsp/jstl/fmt" %>
<html>
<head>
    <meta charset="UTF-8">
    <script type="text/javascript" src="${mvc.basePath}/../js/jquery-3.5.1.min.js">
    </script>
    <title>${msg.title}</title>
</head>
<body>
  <%@ include file="../../fragments/errors.jsp" %>

  <h1>${msg.title}</h1>
  <%@ include file="../../fragments/currentMember.jsp" %>

  <div>
    <%@ include file="../../fragments/mainMenu.jsp" %>
    <div style="float:left">
    </div>
  </div>

</body>
</html>

Caution

确保您输入了下载的jQuery发行版的正确版本。这同样适用于后续章节中介绍的所有 JSP 文件。

所有 JSP 文件都使用相同的整体结构:

<div style="float:left">
</div>

这个空标签将作为实际页面内容的容器。图 12-1 显示了进入应用时的浏览器页面。

img/499016_1_En_12_Fig1_HTML.jpg

图 12-1

读书俱乐部登录页面

成员相关的视图文件

要创建一个新成员、删除一个成员、搜索一个成员、显示成员的详细信息(包括分配的书籍),以及大多数成员的操作结果页面,我们需要一个单独的 JSP 页面。它们都位于src/main/webapp/WEB-INF/views文件夹中。

创建新成员的代码和生成的页面如下:

<%-- File newMember.jsp ***************************** --%>
<%@ page contentType="text/html;charset=UTF-8"
  language="java" %>
<%@ taglib prefix="c"
  uri="http://java.sun.com/jsp/jstl/core" %>
<%@ taglib prefix="fmt"
  uri="http://java.sun.com/jsp/jstl/fmt" %>
<html>
<head>
    <meta charset="UTF-8">
    <script type="text/javascript" src="${mvc.basePath}/../js/jquery-3.5.1.min.js">
    </script>
    <title>${msg.title}</title>
</head>
<body>
  <%@ include file="../../fragments/errors.jsp" %>

  <h1>${msg.hd_newMember}</h1>
  <%@ include file="../../fragments/currentMember.jsp" %>

  <div>
    <%@ include file="../../fragments/mainMenu.jsp" %>
    <div style="float:left">
      <form method="post"
          action="${mvc.uriBuilder(
              'BooKlubbController#newMemberSubmit').
              build()}">
    <table><tbody>
      <tr>
        <td>${msg.enter_memberFirstName}</td>
        <td><input type="text" name="firstName" /></td>
      </tr>
      <tr>
        <td>${msg.enter_memberLastName}</td>
        <td><input type="text" name="lastName" /></td>
      </tr>
      <tr>
        <td>${msg.enter_memberBirthday}</td>
        <td><input type="text" name="birthday" /></td>
      </tr>
      <tr>
        <td>${msg.enter_memberSsn}</td>
        <td><input type="text" name="ssn" /></td>
      </tr>
    </tbody></table>
    <input type="submit" value="${msg.btn_new}" />
  </form>
   </div>
   </div>

</body>
</html>

<%-- File newMemberResult.jsp *********************** --%>
<%@ page contentType="text/html;charset=UTF-8"
  language="java" %>
<%@ taglib prefix="c"
  uri="http://java.sun.com/jsp/jstl/core" %>
<%@ taglib prefix="fmt"
  uri="http://java.sun.com/jsp/jstl/fmt" %>
<html>
<head>
    <meta charset="UTF-8">
    <title>Member Search</title>
</head>
<body>
  <%@ include file="../../fragments/errors.jsp" %>

  <h1>${msg.new_member_added}</h1>
  <%@ include file="../../fragments/currentMember.jsp" %>

  <div>
    <%@ include file="../../fragments/mainMenu.jsp" %>
    <div style="float:left">
    </div>
  </div>

</body>
</html>

JSP 显示了新成员的输入表单。见图 12-2 。结果页面只显示相应的成功消息。

img/499016_1_En_12_Fig2_HTML.jpg

图 12-2

读书俱乐部新会员页面

在成员数据库中搜索的代码和显示结果列表的页面如下:

<%-- File searchMember.jsp ************************** --%>
<%@ page contentType="text/html;charset=UTF-8"
  language="java" %>
<%@ taglib prefix="c"
  uri="http://java.sun.com/jsp/jstl/core" %>
<%@ taglib prefix="fmt"
  uri="http://java.sun.com/jsp/jstl/fmt" %>
<html>
<head>
    <meta charset="UTF-8">
    <script type="text/javascript" src="${mvc.basePath}/../js/jquery-3.5.1.min.js">
    </script>
    <title>${msg.title}</title>
</head>
<body>
  <%@ include file="../../fragments/errors.jsp" %>

  <h1>${msg.hd_searchMember}</h1>
  <%@ include file="../../fragments/currentMember.jsp" %>

  <div>
    <%@ include file="../../fragments/mainMenu.jsp" %>
    <div style="float:left">
      <form method="get" action="${mvc.uriBuilder(
          'BooKlubbController#searchMemberSubmit').
          build()}">
        <table><tbody>
          <tr>
            <td>${msg.enter_memberFirstName}</td>
            <td><input type="text" name="firstName" /></td>
          </tr>
          <tr>
            <td>${msg.enter_memberLastName}</td>
            <td><input type="text" name="lastName" /> </td>
          </tr>
          <tr>
            <td>${msg.enter_memberSsn}</td>
            <td><input type="text" name="ssn" /> </td>
          </tr>
        </tbody></table>
        <input type="submit" value="${msg.btn_search}" />
      </form>
    </div>
  </div>

</body>
</html>

<%-- File searchMemberResult.jsp ******************** --%>
<%@ page contentType="text/html;charset=UTF-8"
  language="java" %>
<%@ taglib prefix="c"
  uri="http://java.sun.com/jsp/jstl/core" %>
<%@ taglib prefix="fmt"
  uri="http://java.sun.com/jsp/jstl/fmt" %>
<html>
<head>
    <meta charset="UTF-8">
    <script type="text/javascript" src="${mvc.basePath}/../js/jquery-3.5.1.min.js">
    </script>
    <title>Member Search</title>
</head>
<body>
  <%@ include file="../../fragments/errors.jsp" %>

  <h1>${msg.hd_searchResult}</h1>
  <%@ include file="../../fragments/currentMember.jsp" %>

  <div>
    <%@ include file="../../fragments/mainMenu.jsp" %>

    <div style="float:left">
    <c:choose>
    <c:when test="${empty memberSearchResult}">
      ${msg.no_result}
    </c:when>
    <c:otherwise>
    <table>
    <thead>
      <tr>
        <th>${msg.tblhdr_id}</th>
        <th>${msg.tblhdr_last_name}</th>
        <th>${msg.tblhdr_first_name}</th>
        <th>${msg.tblhdr_birthday}</th>
        <th>${msg.tblhdr_ssn}</th>
        <th></th>
        <th></th>
      </tr>
    <thead>
    <tbody>
      <c:forEach items="${memberSearchResult}"
            var="itm">
        <tr id="itm-${itm.id}">
          <td>${itm.id}</td>
          <td>${itm.lastName}</td>
          <td>${itm.firstName}</td>
          <fmt:formatDate value="${itm.birthday}"
                 pattern="MM/dd/yyyy"
                 var="d1" />
          <td>${d1}</td>
          <td>${itm.ssn}</td>
          <td><button onclick="deleteItm(${itm.id})">
              ${msg.btn_delete}</button></td>
          <td><button onclick="selectMember(${itm.id})">
              ${msg.btn_select}</button></td>
          <td><button onclick="showDetails(${itm.id})">
              ${msg.btn_details}</button></td>
        </tr>
      </c:forEach>
    </tbody>
  </table>
  </c:otherwise>
  </c:choose>

  <script type="text/javascript">
    function deleteItm(id) {
      jQuery('#memberIdForDelete').val(id);
      jQuery('#deleteForm').submit();
    }
    function selectMember(id) {
      jQuery('#memberIdForSelect').val(id);
      jQuery('#selectForm').submit();
    }
    function showDetails(id) {
      jQuery('#memberIdForDetails').val(id);
      jQuery('#detailsForm').submit();
    }
    </script>
    <form id="deleteForm" method="post"
          action="${mvc.uriBuilder(
            'BooKlubbController#deleteMember').
            build()}">
     <input id="memberIdForDelete" type="hidden"
         name="memberId" />
    </form>
    <form id="selectForm" method="post"
          action="${mvc.uriBuilder(
            'BooKlubbController#selectMember').
            build()}">
      <input id="memberIdForSelect" type="hidden"
          name="memberId" />
    </form>
    <form id="detailsForm" method="post"
          action="${mvc.uriBuilder(
            'BooKlubbController#memberDetails').
            build()}">
      <input id="memberIdForDetails" type="hidden"
          name="memberId" />
    </form>
    </div>

  </div>

</body>
</html>

searchMember.jsp文件显示了一个成员搜索的输入表单;见图 12-3 。结果页面显示相应的成员列表,如图 12-4 所示。

img/499016_1_En_12_Fig4_HTML.jpg

图 12-4

读书俱乐部搜索会员结果页

img/499016_1_En_12_Fig3_HTML.jpg

图 12-3

读书俱乐部搜索会员页面

您可以看到列表中的每个成员项都有三个按钮——一个用于删除成员,一个用于使其成为当前成员,一个用于显示成员详细信息。我们使用 JavaScript 将按钮点击转发到文件末尾附近添加的一个不可见表单。

删除成员后,我们只显示一条成功消息,该消息在deleteMemberResult.jsp文件中定义:

<%@ page contentType="text/html;charset=UTF-8"
  language="java" %>
<%@ taglib prefix="c"
  uri="http://java.sun.com/jsp/jstl/core" %>
<%@ taglib prefix="fmt"
  uri="http://java.sun.com/jsp/jstl/fmt" %>
<html>
<head>
    <meta charset="UTF-8">
    <title>Member Search</title>
</head>
<body>
  <%@ include file="../../fragments/errors.jsp" %>

  <h1>${msg.member_deleted}</h1>
  <%@ include file="../../fragments/currentMember.jsp" %>

  <div>
    <%@ include file="../../fragments/mainMenu.jsp" %>
    <div style="float:left">
    </div>
  </div>

</body>
</html>

在详细信息页面上,我们显示了成员信息和分配的书籍。这由memberDetails.jsp文件定义:

<%@ page contentType="text/html;charset=UTF-8"
  language="java" %>
<%@ taglib prefix="c"
  uri="http://java.sun.com/jsp/jstl/core" %>
<%@ taglib prefix="fmt"
  uri="http://java.sun.com/jsp/jstl/fmt" %>
<html>
<head>
    <meta charset="UTF-8">
    <script type="text/javascript"
        src="${mvc.basePath}/../js/jquery-3.5.1.min.js">
    </script>
    <title>${msg.title}</title>
</head>
<body>
  <%@ include file="../../fragments/errors.jsp" %>

  <h1>${msg.hd_memberDetails}</h1>
  <%@ include file="../../fragments/currentMember.jsp" %>

  <div>

      <%@ include file="../../fragments/mainMenu.jsp" %>
      <div style="float:left">
        <table>
          <tbody>
            <tr>
              <td>${msg.memb_id}</td>
              <td>${currentMember.id}</td>
            </tr>
            <tr>
              <td>${msg.memb_firstName}</td>
              <td>${currentMember.firstName}</td>
            </tr>
            <tr>
              <td>${msg.memb_lastName}</td>
              <td>${currentMember.lastName}</td>
            </tr>
            <fmt:formatDate value="${currentMember.birthday}"
                    pattern="MM/dd/yyyy"
                    var="bd" />
            <tr>
              <td>${msg.memb_birthday}</td>
              <td>${bd}</td>
            </tr>
            <tr>
              <td>${msg.memb_ssn}</td>
              <td>${currentMember.ssn}</td>
            </tr>
          </tbody>
        </table>

        <h2>${msg.hd_booksAssigned}</h2>
        <c:choose>
          <c:when test="${empty currentMember.rentals}">
            ----
          </c:when>
          <c:otherwise>
            <table>
            <tbody>
              <c:forEach items="${currentMember.rentals}"
                    var="r">
                <tr>
                  <td>${r.authorFirstName}
                      ${r.authorLastName}</td>
                  <td>${r.title}</td>
                  <fmt:formatDate value="${r.make}"
                    pattern="MM/dd/yyyy"
                    var="makeDay" />
                  <td>${makeDay}</td>
                  <td>
                    <button onclick="unassign(
                        ${currentMember.id},${r.id})">
                      ${msg.btn_unassign}
                    </button>
                  </td>
                </tr>
              </c:forEach>
            </tbody>
            </table>
          </c:otherwise>
        </c:choose>
        <script type="text/javascript">
          function unassign(memberId,bookId) {
            jQuery('#memberIdForUnassign').val(memberId);
            jQuery('#bookIdForUnassign').val(bookId);
            jQuery('#unassignForm').submit();
          }
        </script>
        <form id="unassignForm" method="post"
             action="${mvc.uriBuilder(
               'BooKlubbController#unassignBook').build()}">
        <input id="memberIdForUnassign" type="hidden"
            name="memberId" />
        <input id="bookIdForUnassign" type="hidden"
            name="bookId" />
      </form>

    </div>
  </div>

</body>
</html>

在图书分配列表中,我们再次使用按钮来取消图书分配,并使用 JavaScript 来提交一个不可见的表单。图 12-5 显示了一个详细页面示例。将图书分配给成员发生在图书搜索结果列表中,这将在后面的部分中讨论。

img/499016_1_En_12_Fig5_HTML.jpg

图 12-5

读书俱乐部会员详细信息页面

与图书相关的视图文件

对于图书,我们确定了以下用例:创建新的图书记录,删除图书记录,搜索图书,将图书分配给成员(租赁)。我们有 JSP 页面来创建一本书和搜索一本书,还有操作结果页面。与成员一样,它们都位于src/main/webapp/WEB-INF/views文件夹中。从图书搜索结果列表中删除图书记录并将其分配给当前成员。

创建图书记录及其相应的提交结果页面的代码如下:

<%-- File newBook.jsp ******************************** --%>
<%@ page contentType="text/html;charset=UTF-8"
  language="java" %>
<%@ taglib prefix="c"
  uri="http://java.sun.com/jsp/jstl/core" %>
<%@ taglib prefix="fmt"
  uri="http://java.sun.com/jsp/jstl/fmt" %>
<html>
<head>
    <meta charset="UTF-8">
    <script type="text/javascript" src="${mvc.basePath}/../js/jquery-3.5.1.min.js">
    </script>
    <title>${msg.title}</title>
</head>
<body>
  <%@ include file="../../fragments/errors.jsp" %>

  <h1>${msg.hd_newBook}</h1>
  <%@ include file="../../fragments/currentMember.jsp" %>

  <div>
    <%@ include file="../../fragments/mainMenu.jsp" %>
     <div style="float:left">
       <form method="post"
           action="${mvc.uriBuilder(
          'BooKlubbController#newBookSubmit').build()}">
       <table><tbody>
         <tr>
           <td>${msg.enter_authorFirstName}</td>
           <td>
             <input type="text" name="authorFirstName" />
           </td>
           </tr>
           <tr>
             <td>${msg.enter_authorLastName}</td>
             <td>
               <input type="text" name="authorLastName" />
             </td>
           </tr>
           <tr>
             <td>${msg.enter_bookTitle}</td>
             <td>
               <input type="text" name="title" />
             </td>
           </tr>
           <tr>
             <td>${msg.enter_bookMake}</td>
             <td>
               <input type="text" name="make" />
             </td>
           </tr>
           <tr>
             <td>${msg.enter_isbn}</td>
             <td>
               <input type="text" name="isbn" />
             </td>
           </tr>
         </tbody></table>
      <input type="submit" value="${msg.btn_new}" />
      </form>
    </div>
  </div>

</body>
</html>

<%-- File newBookResult.jsp ************************** --%>
<%@ page contentType="text/html;charset=UTF-8"
  language="java" %>
<%@ taglib prefix="c"
  uri="http://java.sun.com/jsp/jstl/core" %>
<%@ taglib prefix="fmt"
  uri="http://java.sun.com/jsp/jstl/fmt" %>
<html>
<head>
  <meta charset="UTF-8">
  <title>Member Search</title>
</head>
<body>
  <%@ include file="../../fragments/errors.jsp" %>

  <h1>${msg.new_book_added}</h1>
  <%@ include file="../../fragments/currentMember.jsp" %>

  <div>
    <%@ include file="../../fragments/mainMenu.jsp" %>
    <div style="float:left">
    </div>
  </div>

</body>
</html>

新书页面是一个输入作者姓名、书名、品牌和 ISBN 号的表单。见图 12-6 。结果页面只显示一条信息消息。

img/499016_1_En_12_Fig6_HTML.jpg

图 12-6

书友会新书入口

要搜索数据库并显示搜索结果列表,需要使用以下两个文件:

<%-- File searchBook.jsp **************************** --%>
<%@ page contentType="text/html;charset=UTF-8"
  language="java" %>
<%@ taglib prefix="c"
  uri="http://java.sun.com/jsp/jstl/core" %>
<%@ taglib prefix="fmt"
  uri="http://java.sun.com/jsp/jstl/fmt" %>
<html>
<head>
    <meta charset="UTF-8">
    <script type="text/javascript" src="${mvc.basePath}/../js/jquery-3.5.1.min.js">
    </script>
    <title>${msg.title}</title>
</head>
<body>
  <%@ include file="../../fragments/errors.jsp" %>

  <h1>${msg.hd_searchBook}</h1>
  <%@ include file="../../fragments/currentMember.jsp" %>

  <div>
    <%@ include file="../../fragments/mainMenu.jsp" %>
    <div style="float:left">
    <form method="get"
        action="${mvc.uriBuilder(
        'BooKlubbController#searchBookSubmit').build()}">
    <table><tbody>
      <tr>
        <td>${msg.enter_authorFirstName}</td>
        <td>
          <input type="text" name="authorFirstName" />
        </td>
      </tr>
      <tr>
        <td>${msg.enter_authorLastName}</td>
        <td>
           <input type="text" name="authorLastName" />
        </td>
      </tr>
      <tr>
        <td>${msg.enter_bookTitle}</td>
        <td>
           <input type="text" name="bookTitle"/>
        </td>
      </tr>
      <tr>
        <td>${msg.enter_isbn}</td>
        <td>
          <input type="text" name="isbn"/>
        </td>
      </tr>
    </tbody></table>
    <input type="submit" value="${msg.btn_search}" />
    </form>
    </div>
  </div>

</body>
</html>

<%-- File searchBookResult.jsp ********************** --%>
<%@ page contentType="text/html;charset=UTF-8"
  language="java" %>
<%@ taglib prefix="c"
  uri="http://java.sun.com/jsp/jstl/core" %>
<%@ taglib prefix="fmt"
  uri="http://java.sun.com/jsp/jstl/fmt" %>
<html>
<head>
    <meta charset="UTF-8">
    <script type="text/javascript" src="${mvc.basePath}/../js/jquery-3.5.1.min.js">
    </script>
    <title>Book Search</title>
</head>
<body>
  <%@ include file="../../fragments/errors.jsp" %>

  <h1>${msg.hd_searchResult}</h1>
  <%@ include file="../../fragments/currentMember.jsp" %>

  <div>
    <%@ include file="../../fragments/mainMenu.jsp" %>
    <div style="float:left">
    <c:choose>
    <c:when test="${empty bookSearchResult}">
        ${msg.no_result}
    </c:when>
    <c:otherwise>
    <table>
      <thead>
        <tr>
          <th>${msg.tblhdr_id}</th>
          <th>${msg.tblhdr_author_last_name}</th>
          <th>${msg.tblhdr_author_first_name}</th>
          <th>${msg.tblhdr_book_title}</th>
          <th>${msg.tblhdr_book_make}</th>
          <th>${msg.tblhdr_isbn}</th>
          <th></th>
          <th></th>
        </tr>
      <thead>
      <tbody>
          <c:forEach   items="${bookSearchResult}"
                var="itm">
            <tr id="itm-${itm.id}">
              <td>${itm.id}</td>
              <td>${itm.authorLastName}</td>
              <td>${itm.authorFirstName}</td>
              <td>${itm.title}</td>
              <fmt:formatDate value="${itm.make}"
                     pattern="MM/dd/yyyy"
                     var="d1" />
              <td>${d1}</td>
              <td>${itm.isbn}</td>
              <td><button onclick="deleteItm(${itm.id})">
                ${msg.btn_delete}
                </button>
              </td>
              <td><button onclick="assignItm(${itm.id},
                  ${currentMember.id})">
                ${msg.btn_assign}
                </button>
              </td>
            </tr>
          </c:forEach>
      </tbody>
    </table>
    </c:otherwise>
    </c:choose>

    <script type="text/javascript">
      function deleteItm(id) {
        jQuery('#bookIdForDelete').val(id);
        jQuery('#deleteForm').submit();
      }
      function assignItm(bookId, userId) {
        jQuery('#bookIdForAssign').val(bookId);
        jQuery('#userIdForAssign').val(userId);
        jQuery('#assignForm').submit();
      }
    </script>
    <form id="deleteForm" method="post"
        action="${mvc.uriBuilder(
        'BooKlubbController#deleteBook').build()}">
      <input id="bookIdForDelete" type="hidden"
        name="bookId" />
    </form>
    <form id="assignForm" method="post"
        action="${mvc.uriBuilder(
        'BooKlubbController#assignBook').build()}">
      <input id="bookIdForAssign" type="hidden"
          name="bookId" />
      <input id="userIdForAssign" type="hidden"
          name="userId" />
    </form>
    </div>
  </div>

</body>
</html>

图书搜索结果列表如图 12-7 所示。对于每个列表项,我们都提供了一个删除和分配按钮。JavaScript 代码负责将按钮的按下转发到代码末尾附近添加的两个不可见表单中的一个。

img/499016_1_En_12_Fig7_HTML.jpg

图 12-7

图书俱乐部图书搜索结果

单击其中一个删除按钮后,会显示一条简单的成功消息。deleteBookResult.jsp文件负责处理这个问题:

<%@ page contentType="text/html;charset=UTF-8"
  language="java" %>
<%@ taglib prefix="c"
  uri="http://java.sun.com/jsp/jstl/core" %>
<%@ taglib prefix="fmt"
  uri="http://java.sun.com/jsp/jstl/fmt" %>
<html>
<head>
  <meta charset="UTF-8">
  <title>Book Search</title>
</head>
<body>
  <%@ include file="../../fragments/errors.jsp" %>

  <h1>${msg.book_deleted}</h1>
  <%@ include file="../../fragments/currentMember.jsp" %>

  <div>
    <%@ include file="../../fragments/mainMenu.jsp" %>
    <div style="float:left">
    </div>
  </div>

</body>
</html>

部署和测试 bokklubb

要构建和部署BooKlubb应用,您需要在控制台中输入以下内容:

   ./gradlew   localDeploy

   # or, if you need to specify a certain JDK
   JAVA_HOME=/path/to/jdk ./gradlew   localDeploy

为此,GlassFish 服务器必须正在运行,并且gradle.properties文件必须包含 GlassFish 服务器的正确连接属性。在这个过程中构建的 WAR 文件被复制到build/libs文件夹中。

如果一切正常,您可以将浏览器指向以下 URL 以进入应用:

http://localhost:8080/BooKlubb

见图 12-1 。

摘要

本章以一个名为BooKlubb的综合示例应用结束了这本书,它展示了许多 Java MVC 特性。

posted @ 2024-08-19 15:49  绝不原创的飞龙  阅读(2)  评论(0编辑  收藏  举报