ExtJS-和-Spring-企业应用开发-全-

ExtJS 和 Spring 企业应用开发(全)

原文:zh.annas-archive.org/md5/84CE5C4C4F19D0840640A27766EB042A

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

企业应用程序开发是一种在这个快节奏的技术世界中很少被承认的艺术形式。本书描述了使用两种最流行的技术——Spring 框架和 Sencha Ext JS——简化大规模开发项目的模式和策略。每一章都定义并构建了企业应用程序堆栈中的一个简洁层,压缩了多年开发实际项目所获得的 Web 开发方法。我们涵盖了相当广泛的概念领域,所以准备好迎接一个有趣的旅程!

本书不是 Java、JavaScript 或任何 Web 开发概念的介绍。书中包含大量实际的 Java 和 JavaScript 代码,因此需要对这些技术有一定的了解。如果你对 Java 和相关概念如面向对象编程不熟悉,那么在跟随示例和解释时可能会感到困难。同样适用于 Ext JS 开发;你需要对基本概念有一定的经验,包括框架 API,才能跟随大多数示例。

你不需要成为专家,但初学者可能希望从其他地方开始他们的旅程。

无论您的经验和背景如何,本书提供的实际示例都是以彻底覆盖每个概念为目标,然后再进入下一章。

本书涵盖的内容

第一章,“准备开发环境”,讨论了开发环境的安装和配置,包括 Java 开发工具包、NetBeans 和 MySQL。我们还将介绍 Maven,创建一个新的 NetBeans 项目,并将项目部署到 GlassFish 4 应用服务器。

第二章,“任务时间跟踪数据库”,定义了任务时间跟踪(3T)数据库设计,并帮助配置 NetBeans 作为 MySQL 服务器的客户端。我们创建和填充所有表,并确定可能适用于企业使用的可能增强功能。

第三章,“使用 JPA 反向工程领域层”,帮助我们使用 NetBeans IDE 对 3T 数据库进行反向工程,创建 JPA 实体的领域层。在我们检查和定义核心 JPA 概念时,这些实体将被探讨和重构。

第四章,“数据访问变得容易”,介绍了数据访问对象(DAO)设计模式,并帮助使用我们在上一章中定义的领域类实现健壮的数据访问层。还介绍了 Java 泛型和接口、简单日志门面(SLF4J)、JPA EntityManager 和事务语义。

第五章,“使用 Spring 和 JUnit 测试 DAO 层”,介绍了 JUnit 测试环境的配置以及为我们的 DAO 实现开发测试用例。我们介绍了 Spring 控制反转(IoC)容器,并探讨了 Spring 配置以将 Spring 管理的 JUnit 测试与 Maven 集成。

第六章,“回到业务-服务层”,探讨了企业应用程序开发中服务层的作用。然后,我们通过数据传输对象(DTO)设计模式使用值对象(VO)来实现我们的 3T 业务逻辑。我们还探讨了在编写实现之前编写测试用例——这是测试驱动开发和极限编程的核心原则。

第七章,“网络请求处理层”,为生成 JSON 数据的 Web 客户端定义了一个请求处理层,该层使用 Java EE 7 中引入的新 API——Java API for JSON processing。我们实现了轻量级的 Spring 控制器,介绍了 Spring 处理程序拦截器,并使用 Java 类配置了 Spring MVC。

第八章,“在 GlassFish 上运行 3T”,完成了我们的 Spring 配置,并允许我们将 3T 应用程序部署到 GlassFish 4 服务器。我们还配置 GlassFish 4 服务器,使其能够独立于 NetBeans IDE 运行,就像在企业环境中一样。

第九章,“开始使用 Ext JS 4”,介绍了强大的 Ext JS 4 框架,并讨论了核心的 Ext JS 4 MVC 概念和实际的设计约定。我们使用 Sencha Cmd 和 Ext JS 4 SDK 安装和配置我们的 Ext JS 开发环境,生成我们的 3T 应用程序框架。

第十章,“登录和用户维护”,帮助我们开发 3T 应用程序所需的 Ext JS 4 组件,并维护用户登录。我们将讨论 Ext JS 4 模型持久化,构建各种视图,审查应用程序概念,并开发两个 Ext JS 控制器。

第十一章,“构建任务日志用户界面”,继续加强我们对 Ext JS 4 组件的理解,同时实现任务日志用户界面。

第十二章,“3T 管理变得更容易”,使我们能够开发 3T 管理界面,并介绍了 Ext JS 4 树组件。我们将研究动态树加载,并实现拖放树操作。

第十三章,“将您的应用程序部署到生产环境”,将帮助我们准备、构建和部署我们的 3T 项目到 GlassFish 服务器。我们介绍了 Ext JS 主题化,将 Sencha Cmd 编译与 Maven 集成,自动化生成 Ext JS 4 app-all.js 文件的过程,并学习如何将我们的生产版本部署到 GlassFish 服务器上。

附录,“介绍 Spring Data JPA”,对 Spring Data JPA 进行了非常简要的介绍,作为第四章,“数据访问变得更容易”中讨论的实现的替代方案。

本书所需内容

本书中的示例可以在支持以下软件的任何 Windows、Mac 或 Linux 平台上运行:

  • Java 开发工具包(JDK)1.7

  • NetBeans 7.4+

  • MySQL 5+

  • Sencha Cmd

所有软件都可以在相应章节中列出的网站免费下载。

本书适合对象

本书特别适用于从事大型 Web 应用程序开发项目的人员,包括应用架构师、Java 开发人员和 Ext JS 开发人员。

应用架构师

架构师从技术角度理解全局图景,并负责制定开发标准的蓝图。本书将向您介绍 Spring Framework 和 Sencha Ext JS 的强大功能,以及在设计下一个项目时如何最好地利用这些技术。

Java 开发人员

无论您的理解水平如何,您都将学习 Spring 框架如何鼓励良好的编程实践。这包括一个清晰的、分层的结构,易于增强和维护。对于 Spring 的新手来说,他们会惊讶于实现重大结果所需的努力是多么少。对于新手和有经验的 Spring 开发人员,重点将是企业 Web 开发的最佳实践,以实现与 Sencha Ext JS 客户端的无缝集成。如果您从未使用过 Sencha Ext JS,您会惊讶于强大的 UI 可以多快地将后端数据栩栩如生。

Ext JS 开发人员

Sencha Ext JS 是一个强大的框架,用于构建跨浏览器兼容的企业应用程序。本书将从分析到提供完全功能的解决方案解决现实世界的问题。您将看到通常隐藏在 Ext JS 开发人员背后的许多开发阶段;您还将了解为客户端消费而生成 JSON 数据所需的步骤。关注 Ext JS 组件的章节将介绍基于最新 MVC 设计模式的可维护开发的简单策略。

约定

在本书中,您将找到一些区分不同类型信息的文本样式。以下是这些样式的一些示例,以及它们的含义解释。

文本中的代码单词、文件夹名称、文件名、文件扩展名、路径名、虚拟 URL 和用户输入显示如下:“ManageTaskLogs的定义如下:”

代码块设置如下:

Ext.define('TTT.store.Task', {
    extend: 'Ext.data.Store',
    requires: ['TTT.model.Task'],
    model: 'TTT.model.Task',
    proxy: {
        type: 'ajax',
        url:'task/findAll.json',
        reader: {
            type: 'json',
            root: 'data'
        }
    }    
});

当我们希望引起您对代码块的特定部分的注意时,相关行或项目会以粗体显示:

controllers: [
  'MainController',
  'UserController',
  'TaskLogController'
],
models: [
  'User',
  'Project',
 'Task',
 'TaskLog'
],
stores: [
  'User',
  'Project',
 'Task',
 'TaskLog'
]

任何命令行输入或输出都以以下方式编写:

sencha –sdk ext compile -classpath=app page -yui -in index.html -out build/index.html

新术语重要单词以粗体显示。例如,屏幕上看到的单词,例如菜单或对话框中的单词,会以这种方式出现在文本中:“如果存在,添加新的任务日志将保留当前选择的日期项目:”。

注意

警告或重要说明会出现在这样的框中。

提示

提示和技巧会以这种方式出现。

第一章:准备您的开发环境

本章将安装和配置您的开发环境。快速应用开发RAD)工具是NetBeans,这是一个开源的、跨平台的集成开发环境IDE),可用于创建视觉桌面、移动、Web 和面向服务的体系结构SOA)应用程序。NetBeans 官方支持 Java、PHP、JavaScript 和 C/C++编程语言,但它以为所有最新的Java 企业版Java EE)标准提供了完整的工具集而闻名(目前是 Java EE 7)。

本书选择的数据库是 MySQL,这是世界上最广泛使用的开源关系数据库管理系统RDBMS)。MySQL 是 Linux 平台上托管的 Web 应用程序的最受欢迎的数据库选择,并且在多种应用程序中继续提供出色的性能。其小巧的占用空间和易用性使其非常适合在单台计算机上进行开发使用。

本书中使用的应用服务器是GlassFish 4,它与 NetBeans 捆绑在一起。GlassFish 作为 NetBeans 安装的一部分进行安装,两者之间的紧密集成使得配置 GlassFish 变得简单。GlassFish 是一个开源的、生产级的应用服务器,实现了所有的 Java EE 7 特性。它具有企业级的可靠性,并被许多人认为是最好的开源应用服务器。GlassFish 4 是 Java EE 7 规范的参考实现RI),完整的描述可以在glassfish.java.net/downloads/ri/找到。

所有这些开发工具都可以免费在 PC、Mac 和 Linux 上使用。每个工具都有大量的示例、全面的教程和在线支持论坛可供使用。

需要注意的是,尽管本章重点介绍了 NetBeans、MySQL 和 GlassFish,但读者可以根据自己熟悉的工具配置任何合适的组合。本书中概述的开发任务同样可以使用 Eclipse、Oracle 和 JBoss 来完成,尽管一些描述的配置细节可能需要进行微小的修改。

在本章中,我们将执行以下任务:

  • 安装 MySQL 数据库服务器

  • 安装 Java SDK

  • 安装和配置 NetBeans IDE

  • 创建应用项目并探索 Maven

  • 在 GlassFish 中运行项目

安装 MySQL

MySQL 可以从www.mysql.com/downloads/mysql下载。选择适合您操作系统和架构的 MySQL 社区服务器。重要的是要遵循说明,注意安装目录和路径以备将来参考。下载并运行安装文件后,您应该选择本书的开发者默认安装。

安装 MySQL

除非您熟悉 MySQL,否则最好选择默认设置。这将包括将默认端口设置为3306,启用 TCP/IP 网络,并打开所需的防火墙端口以进行网络访问(在所有应用程序都在同一环境上运行的开发机器上并不是严格要求的,但如果您正在配置专用的 MySQL 服务器,则是必需的)。

无论环境如何,在安装过程中设置 root 用户密码是很重要的。我们将使用 root 用户连接到运行的 MySQL 服务器来执行命令。

安装 MySQL

注意

本书的其余部分将假定 root 用户的密码为adminadmin。这不是一个非常安全的密码,但应该很容易记住!

我们建议将 MySQL 服务器配置为在操作系统启动时启动。如何完成此操作将取决于您的环境,但通常是在“初始配置”操作的最后执行的。Windows 用户将有选项在系统启动时启动 MySQL 服务器。Mac 用户需要在安装服务器后安装MySQL 启动项

如果您决定不在操作系统启动时启动 MySQL,则需要在需要时手动启动 MySQL 服务器。如何完成此操作将再次取决于您的环境,但您现在应该启动服务器以确认安装成功。

注意

Unix 和 Linux 用户需要根据其操作系统安装 MySQL。这可能包括使用高级包装工具(APT)或另一个安装工具(YaST),甚至从源代码安装 MySQL。有关各种操作系统的详细说明,请参见dev.mysql.com/doc/refman/5.7/en/installing.html

在配置过程结束时,您将拥有一个运行的 MySQL 服务器,可以在第二章中使用,任务时间跟踪器数据库

安装 Java SE 开发工具包(JDK)

可以从www.oracle.com/technetwork/java/javase/downloads/index.html下载 Java SE 开发工具包(JDK)。如果您的系统已安装了 JDK 7 Update 45(或更高版本),则可以选择跳过此步骤。

注意

不要选择 NetBeans 捆绑包,因为它不包含 GlassFish 服务器。

安装 Java SE 开发工具包(JDK)

在选择适当的发行版之前,您需要接受 JDK 7 许可协议。下载 JDK 后,运行安装程序并按照说明和提示操作。

安装 NetBeans IDE

NetBeans 可以从netbeans.org/downloads/下载。该发行版要求在您的平台上已安装有效的 JDK。在撰写本文时,我使用了 JDK 7 Update 45,但任何 JDK 7(或更高版本)都可以。有几个发行版捆绑包;您需要选择Java EE捆绑包。

安装 NetBeans IDE

撰写本文时的最新版本是 NetBeans 7.4,引入了重要的新功能,包括扩展的 HTML5 和 JavaScript 支持。首次,NetBeans 还包括对 Ext JS 框架的编辑和代码完成支持。

要安装软件,只需从 NetBeans 网站下载并按照详细说明进行操作。这将带您通过以下一系列设置屏幕:

  1. GlassFish 4 服务器会自动选择。您无需安装 Tomcat。

  2. 接受许可协议中的条款。

  3. 接受 JUnit 许可协议的条款。JUnit 用于第五章中的测试,使用 Spring 和 JUnit 测试 DAO 层

  4. 注意 NetBeans IDE 的安装路径以备将来参考。选择先前安装的适当 JDK(如果系统上有多个 JDK)。

  5. 注意 GlassFish 4 服务器的安装路径以备将来参考。

  6. 最终屏幕总结了安装过程。在单击安装开始过程之前,请务必检查更新

该过程可能需要几分钟,具体取决于您的平台和硬件。

安装完成后,您可以首次运行 NetBeans。如果您之前安装过 NetBeans 的版本,则可能会提示您导入设置。然后默认的开放屏幕将显示如下:

安装 NetBeans IDE

现在可以从菜单中打开最有用的面板:

  • 项目:此面板是项目源的主要入口点。它显示了重要项目内容的逻辑视图,分组到适当的上下文中。

  • 文件:此面板显示项目节点在文件系统上的实际文件结构。

  • 服务:此面板显示您的运行时资源。它显示了重要的运行时资源的逻辑视图,如与 IDE 注册的服务器和数据库。

在这个阶段,前两个面板将是空的,但服务面板将有几个条目。打开服务器面板将显示安装的 GlassFish 4 服务器,如下截图所示:

安装 NetBeans IDE

介绍 Maven

Apache Maven 是一个用于构建和管理基于 Java 的项目的工具。它是一个开源项目,托管在maven.apache.org,并与 NetBeans IDE 捆绑在一起。Maven 简化了所有 Java 开发项目中常见的许多步骤,并提供了许多功能,包括以下内容:

  • 提供约定优于配置。Maven 带有一系列预定义的目标,用于执行某些明确定义的任务,包括项目的编译、测试和打包。所有任务都通过单个配置文件pom.xml管理。

  • 一致的编码结构和项目框架。每个 Maven 项目都具有相同的目录结构和源文件、测试文件、构建文件和项目资源的位置。这种共同的结构使我们能够轻松地了解项目。

  • 一个一致的构建系统,具有许多插件,使常见任务变得容易。

  • 作为构建过程的一部分执行测试的能力。

  • 一个高度灵活和强大的依赖管理系统。这允许软件开发人员通过(外部或远程)Maven 仓库在互联网上发布信息和共享 Java 库。然后 Maven 会将这些库下载并在本地缓存,以供项目使用。

我们鼓励您访问 Maven 网站,探索其中提供的许多功能。NetBeans 将使用 Maven 来创建和管理 Web 应用程序项目。

创建 Maven Web 应用程序项目

NetBeans 项目封装了维护和开发应用程序所需的所有源代码和相关组件。从菜单中导航到文件 | 新建项目开始这个过程:

创建 Maven Web 应用程序项目

类别列表中选择Maven,在项目列表中选择Web 应用程序,如前面的截图所示,然后选择下一步按钮。这将呈现给您项目配置屏幕,其中包括以下字段:

  • 项目名称:这指定了项目在项目窗口中的显示名称。这个名称也用于创建项目文件夹,不能包含空格。

注意

我们的项目名为 Task Time Tracker。这个工具将允许用户管理不同项目的不同任务所花费的时间。项目名称字段是项目名称的小写、无空格的翻译:task-time-tracker

  • 项目位置:这指定了您想要存储项目元数据和源代码的文件系统根文件夹。我们通常会在驱动器的根级别创建一个特定于项目的文件夹,而不是将其深埋在 NetBeans 下的文件夹结构中。这样可以更容易地找到并将文件复制到项目中。

注意

Windows 用户应在c:\projects下创建一个项目文件夹。Mac 用户可以用/Users/{username}/projects替换这个位置,Unix 用户可以用/home/{username}/projects替换。本书的其余部分将在所有示例中引用这个位置为项目文件夹

  • 项目文件夹:项目文件夹是只读的,根据项目名称和项目位置生成。

  • Artifact Id:这是一个只读的 Maven 特定属性,用于标识项目,基于项目名称。

  • Group Id:这是另一个 Maven 属性,表示多个构件的顶级容器。它通常代表拥有项目的组织的顶级域TLD)。

注意

项目的Group Idcom.gieman,作者的公司。

  • 版本:这是另一个 Maven 属性,表示构件的版本。默认版本是1.0-SNAPSHOT,我们将其更改为1.0。随着项目的发展和发布新版本,Maven 将根据它们的版本跟踪不同的构建。

  • :IDE 将根据此字段自动创建基于 Java 源包的结构。我们将使用包com.gieman.tttracker

您现在应该已经输入了以下项目详细信息:

创建 Maven Web 应用程序项目

点击下一步按钮查看最终屏幕。在单击完成按钮之前,不要更改默认的 GlassFish Server 4.0 和 Java EE 7 设置。现在您将在项目创建输出选项卡中看到活动,因为项目正在创建和配置。打开项目文件面板将允许您查看项目结构:

提示

下载示例代码

您可以从www.packtpub.com的帐户中下载您购买的所有 Packt 图书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packtpub.com/support并注册,以便直接通过电子邮件接收文件。

创建 Maven Web 应用程序项目

在任一选项卡中右键单击项目名称将允许您选择项目的属性。这将显示与项目相关的所有属性和路径,分别属于不同的类别:

创建 Maven Web 应用程序项目

您不需要在本书的其余部分更改这些属性。

理解 POM 和依赖管理

每个 Maven 项目在 NetBeans 项目的根级别都有一个pom.xml配置文件。点击文件视图,双击pom.xml文件以在编辑器中打开它:

理解 POM 和依赖管理

注意

您应该看到导航器窗口在左下角面板中打开。这显示了正在编辑的文件的大纲,在浏览大文件时非常有帮助。在导航器中双击节点将会将光标定位到编辑器中的适当行。

如果导航器窗口没有打开(或已关闭),您可以通过从菜单导航到窗口 | 导航 | 导航器来手动打开它。

项目对象模型(POM)完全定义了项目和所有必需的 Maven 属性和构建行为。pom.xml中只显示了一个依赖项:

<dependencies>
  <dependency>
    <groupId>javax</groupId>
    <artifactId>javaee-web-api</artifactId>
    <version>7.0</version>
    <scope>provided</scope>
  </dependency>
</dependencies>

这个依赖项标识了项目构建所需的 Java EE 7。这个条目确保了完整的 Java EE 7 API 在任务时间跟踪项目中可用于 Java 编码。我们的项目还需要 Spring 框架,现在必须将其添加为额外的依赖项。在编辑器中输入时,将会出现自动补全帮助来确定正确的依赖项。在添加 Spring 框架的groupIdartifactId条目后,如下截图所示,按下Ctrl +空格键盘快捷键将打开以文本spring开头的artifactId的可用匹配条目:

理解 POM 和依赖管理

如果此自动完成列表不可用,可能是因为 Maven 仓库首次进行索引。在这种情况下,您将在编辑器底部看到以下截图:

理解 POM 和依赖管理

耐心等待几分钟,索引将完成,自动完成将变为可用。索引是从 Maven 仓库下载可用条目所必需的。

所需的 Spring 框架组件如下:

  • spring-context:这是 Spring 的依赖注入容器所需的中心构件

  • spring-tx:这是实现事务行为所需的事务管理抽象

  • spring-context-support:这些是各种应用程序上下文实用程序,包括 Ehcache、JavaMail、Quartz 和 FreeMarker 集成

  • spring-jdbc:这是 JDBC 数据访问库

  • spring-orm:这是用于 JPA 开发的对象-关系映射ORM)集成

  • spring-instrument:用于类的编织

  • spring-webmvc:这是用于 Servlet 环境的Spring 模型-视图-控制器MVC

  • spring-test:这是用于使用 JUnit 测试 Spring 应用程序的支持

要使用最新的 Spring 发布版本(3.2.4)添加这些依赖项,需要对pom.xml文件进行以下添加:

<dependency>
  <groupId>org.springframework</groupId>
  <artifactId>spring-context</artifactId>
  <version>3.2.4.RELEASE</version>
</dependency>
<dependency>
  <groupId>org.springframework</groupId>
  <artifactId>spring-context-support</artifactId>
  <version>3.2.4.RELEASE</version>
</dependency>
<dependency>
  <groupId>org.springframework</groupId>
  <artifactId>spring-tx</artifactId>
  <version>3.2.4.RELEASE</version>
</dependency>
<dependency>
  <groupId>org.springframework</groupId>
  <artifactId>spring-jdbc</artifactId>
  <version>3.2.4.RELEASE</version>
</dependency>
<dependency>
  <groupId>org.springframework</groupId>
  <artifactId>spring-orm</artifactId>
  <version>3.2.4.RELEASE</version>
</dependency>
<dependency>
  <groupId>org.springframework</groupId>
  <artifactId>spring-instrument</artifactId>
  <version>3.2.4.RELEASE</version>
</dependency>
<dependency>
  <groupId>org.springframework</groupId>
  <artifactId>spring-webmvc</artifactId>
  <version>3.2.4.RELEASE</version>
</dependency>
<dependency>
  <groupId>org.springframework</groupId>
  <artifactId>spring-test</artifactId>
  <version>3.2.4.RELEASE</version>
</dependency>

理解依赖范围

最后一个 Spring 框架依赖项仅在测试时才需要。我们可以通过添加scope属性并将其值设置为test来定义这一点。这告诉 Maven 该依赖项仅在运行构建的测试阶段时才需要,并且不需要部署。

<dependency>
  <groupId>org.springframework</groupId>
  <artifactId>spring-test</artifactId>
  <version>3.2.4.RELEASE</version>
  <scope>test</scope>
</dependency>

NetBeans 自动生成的javaee-web-api依赖项的范围为provided。这意味着该依赖项不需要部署,并且由目标服务器提供。GlassFish 4 服务器本身是该依赖项的提供者。

如果没有包含scope属性,依赖的 JAR 将包含在最终构建中。这相当于提供compile范围的条目。因此,所有 Spring 框架依赖的 JAR 将包含在最终构建文件中。

有关 Maven 依赖机制和范围的详细解释,请参阅maven.apache.org/guides/introduction/introduction-to-dependency-mechanism.html

定义 Maven 属性

pom.xml中定义的 Spring 框架依赖项都具有相同的版本(3.2.4.RELEASE)。这种重复不理想,特别是当我们希望在以后升级到新版本时。需要在多个地方进行更改,每个 Spring 依赖项都需要更改一次。一个简单的解决方案是添加一个属性来保存发布版本值,如下所示:

<properties>
<endorsed.dir>${project.build.directory}/endorsed</endorsed.dir>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<spring.version>3.2.4.RELEASE</spring.version>
</properties>

我们现在可以使用这个自定义属性spring.version来替换多个重复项,如下所示:

<dependency>
<groupId>org.springframework</groupId>
  <artifactId>spring-context-support</artifactId>
  <version>${spring.version}</version>
</dependency>

然后在构建过程中,${spring.version}占位符将被替换为properties的值。

理解 Maven 构建插件

Maven 构建过程在适当的构建阶段执行每个定义的构建插件。可以在maven.apache.org/plugins/index.html找到构建插件的完整列表。我们将在后续章节中根据需要介绍插件,但现在我们对 NetBeans IDE 创建的默认插件感兴趣。

maven-compiler-plugin控制并执行 Java 源文件的编译。该插件允许您指定编译的sourcetarget Java 版本,如下所示:

<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-compiler-plugin</artifactId>
  <version>3.1</version>
  <configuration>
    <source>1.7</source>
    <target>1.7</target>
    <compilerArguments>
      <endorseddirs>${endorsed.dir}</endorseddirs>
    </compilerArguments>
  </configuration>
</plugin>

在为旧的 Java 服务器编译项目时,可能需要将这些值更改为1.6

maven-war-plugin为项目构建 WAR 文件,如下所示:

<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-war-plugin</artifactId>
  <version>2.3</version>
  <configuration>
    <failOnMissingWebXml>false</failOnMissingWebXml>
  </configuration>
</plugin>

默认生成的 WAR 文件名是{artifactId}-{version}.war,可以通过包含warName配置属性来更改。在最后一章中,我们将在为生产发布构建项目时向此插件添加属性。maven-war-plugin选项的完整列表可以在maven.apache.org/plugins/maven-war-plugin/war-mojo.html找到。

maven-dependency-plugin将依赖的 JAR 文件复制到定义的输出目录,如下所示:

<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-dependency-plugin</artifactId>
  <version>2.6</version>
  <executions>
    <execution>
      <phase>validate</phase>
      <goals>
        <goal>copy</goal>
      </goals>
      <configuration>
        <outputDirectory>${endorsed.dir}</outputDirectory>
        <silent>true</silent>
        <artifactItems>
          <artifactItem>
            <groupId>javax</groupId>
            <artifactId>javaee-endorsed-api</artifactId>
            <version>7.0</version>
            <type>jar</type>
          </artifactItem>
        </artifactItems>
      </configuration>
    </execution>
  </executions>
</plugin>

这对于查看项目使用了哪些 JAR,并识别所需的传递依赖(依赖的依赖)非常有用。

我们将修改此插件,将项目的所有编译时依赖项复制到${project.build.directory}中的目录。这个特殊的构建目录位于项目的根文件夹下,名为target,是构建过程的目标目的地。更新后的条目现在如下所示:

<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-dependency-plugin</artifactId>
  <version>2.1</version>
  <executions>
    <execution>
      <id>copy-endorsed</id>
      <phase>validate</phase>
      <goals>
        <goal>copy</goal>
      </goals>
      <configuration>
        <outputDirectory>${endorsed.dir}</outputDirectory>
        <silent>true</silent>
        <artifactItems>
          <artifactItem>
            <groupId>javax</groupId>
            <artifactId>javaee-endorsed-api</artifactId>
            <version>7.0</version>
            <type>jar</type>
          </artifactItem>
        </artifactItems>
      </configuration>
    </execution> 
    <execution>
      <id>copy-all-dependencies</id>
      <phase>compile</phase>
      <goals>
        <goal>copy-dependencies</goal>
      </goals>
      <configuration>
        <outputDirectory>${project.build.directory}/lib
        </outputDirectory>
        <includeScope>compile</includeScope>
      </configuration> 
    </execution>
  </executions>
</plugin>

由于我们现在在单个插件中执行两个执行,每个执行都需要自己的<id>。第二个执行,ID 为copy-all-dependencies,将把所有依赖的 JAR(范围为compile)复制到target/lib目录中。

执行 Maven 构建

执行构建的最简单方法是单击工具栏中的清理和构建项目按钮。您还可以在项目选项卡中右键单击项目节点,然后从菜单中选择清理和构建。然后,构建过程将执行 POM 中定义的每个阶段,导致 Java 代码编译,依赖项解析(和复制),最后生成 WAR 文件。打开目标目录结构将显示构建结果,如下所示:

执行 Maven 构建

尽管我们还没有写一行代码,但生成的 WAR 文件task-time-tracker-1.0.war现在可以部署到 GlassFish 服务器上。

启动 GlassFish 4 服务器

打开服务选项卡并展开服务器节点将列出在 NetBeans 安装过程中安装的 GlassFish 服务器。您现在可以右键单击GlassFish Server 4.0节点,并选择启动,如下截图所示:

启动 GlassFish 4 服务器

输出面板现在应该在您的 NetBeans IDE 底部打开,并显示启动结果。选择GlassFish Server 4.0选项卡以查看详细信息。

启动 GlassFish 4 服务器

倒数第五行标识服务器已启动,并正在监听端口 8080,日志中写为 8,080:

INFO: Grizzly Framework 2.3.1 started in: 16ms - bound to [/0.0.0.0:8,080]

您现在可以打开您喜欢的浏览器,并查看页面http://localhost:8080

注意

请注意,根据您的环境,可能有其他应用程序监听端口 8080。在这种情况下,您需要用 GlassFish 服务器输出中定义的正确端口替换 8080。

启动 GlassFish 4 服务器

您现在可以右键单击GlassFish Server 4.0节点,然后单击停止来停止服务器。

启动 GlassFish 4 服务器

运行 Task Time Tracker 项目

我们已经成功构建了项目;现在是时候在 GlassFish 中运行项目了。单击运行工具栏项以启动进程,如下所示:

运行 Task Time Tracker 项目

输出应该显示进程,首先构建项目,然后启动并部署到 GlassFish 服务器。最后一步将打开您的默认浏览器,并显示所有开发人员都喜爱的世界著名消息,如下截图所示:

运行 Task Time Tracker 项目

恭喜!您现在已配置了开发、构建和部署 Spring Java 项目的核心组件。最后一步是更改默认页面上的文本。打开index.html文件,如下面的截图所示:

运行任务时间跟踪器项目

<title>更改为任务时间跟踪器首页,将<h1>文本更改为欢迎来到任务时间跟踪器!。保存页面并刷新浏览器以查看更改。

运行任务时间跟踪器项目

注意

在刷新浏览器后没有看到更新的文本更改?在某些情况下,首次部署到 GlassFish 后,刷新页面时可能看不到在index.html文件中所做的更改。重新启动您的 NetBeans IDE 应该可以解决问题,并确保随后的更改在保存任何项目资源时立即部署到 GlassFish。

总结

在本章中,我们介绍了本书中将使用的一些关键技术。您已经下载并安装了 MySQL 数据库服务器、JDK 和 NetBeans IDE。然后我们介绍了 Maven 以及它如何简化 Java 项目的构建和管理。最后,我们在不写一行代码的情况下将我们的骨架任务时间跟踪器项目部署到了 GlassFish。

尽管我们已将 Spring 框架添加到我们的项目中,但我们尚未深入了解它的用法。同样,我们还没有提到 Sencha Ext JS。请耐心等待,还有很多内容要介绍!下一章将介绍我们的任务时间跟踪器数据库表,并开始我们的开发之旅。

第二章:任务时间跟踪器数据库

本章定义了任务时间跟踪器(3T)数据库设计,并将 NetBeans 配置为 MySQL 服务器的客户端。

3T 应用程序将用于跟踪不同公司项目上花费的时间。主要实体包括:

  • 公司:这是拥有零个或多个项目的实体。公司是独立的,可以独立存在(它没有外键)。

  • 项目:这代表任务的分组。每个项目都属于一个公司,可以包含零个或多个任务。

  • 任务:这些代表可能为项目承担的活动。一个任务属于一个项目。

  • 用户:他们是承担任务的参与者。用户可以将花费的时间分配给不同的任务。

  • 任务日志:这是用户在任务上花费的时间记录。花费的时间以分钟为单位存储。

这些实体定义导致了一个非常简单的数据库设计:

任务时间跟踪器数据库

我们将所有的 3T 表都以ttt_作为前缀。大型企业数据库可能包含数百个表,您很快就会欣赏到表名的前缀以将相关表分组。

用 NetBeans 连接 MySQL

在 NetBeans IDE 的服务选项卡中,导航到数据库 | 驱动程序。您会看到 NetBeans 带有几种不同的数据库驱动程序:

用 NetBeans 连接 MySQL

右键单击数据库节点,然后单击注册 MySQL 服务器…,如下面的屏幕截图所示:

用 NetBeans 连接 MySQL

对于 Windows 用户,这将打开一个具有默认设置的对话框。在上一章安装 MySQL 服务器时输入管理员密码,并勾选记住密码选项:

用 NetBeans 连接 MySQL

Mac 用户在设置连接属性之前会看到一个不同的窗口。在单击下一步按钮之前选择 MySQL 驱动程序:

用 NetBeans 连接 MySQL

然后,您可以指定所需的数据库连接详细信息:

用 NetBeans 连接 MySQL

完成这些任务后,您将在数据库节点中看到MySQL 服务器。右键单击服务器,然后选择连接以连接到服务器(如果尚未连接):

用 NetBeans 连接 MySQL

这将连接 NetBeans 到 MySQL 服务器并列出可用的数据库。右键单击服务器,然后选择创建数据库,如下面的屏幕截图所示:

用 NetBeans 连接 MySQL

输入数据库名称,如下面的屏幕截图所示,然后单击确定创建数据库:

用 NetBeans 连接 MySQL

最后一步是连接到新创建的task_time_tracker数据库。右键单击task_time_tracker,然后选择连接…,如下面的屏幕截图所示:

用 NetBeans 连接 MySQL

这将为task_time_tracker数据库添加一个 MySQL 数据库连接条目,可以在需要时通过右键单击打开:

用 NetBeans 连接 MySQL

现在,您可以右键单击数据库连接并选择执行命令…选项,在工作区中打开SQL 命令编辑器:

用 NetBeans 连接 MySQL

SQL 命令编辑器是您将在其中输入并执行针对数据库的命令的地方:

用 NetBeans 连接 MySQL

3T 数据库

以下的 SQL 语句定义了 3T 中使用的 MySQL 表。可以使用任何数据库,并且用 MySQL 特定的代码进行了突出以识别与 ANSI SQL 的不同之处。

公司表

公司有项目,我们需要跟踪不同任务所花费的时间。因此,公司是需要定义的第一个表。它是一个非常简单的结构:

create table ttt_company(
  id_company  int unsigned not null auto_increment,
  company_name varchar(200) not null,
  primary key(id_company)
);

MySQL 使用auto_increment关键字来标识应自动递增的数字列(默认递增速率为一个数字),基于列中当前最高值。这用于生成id_company主键值。让我们添加一些公司数据:

insert into ttt_company(company_name) values ('PACKT Publishing');
insert into ttt_company(company_name) values ('Gieman It Solutions');
insert into ttt_company(company_name) values ('Serious WebDev');

SQL 命令编辑器中输入这些语句后,您可以通过单击以下截图右上角的按钮来执行这些语句(运行 SQL 按钮已用圈圈标出):

公司表

这些语句的输出将显示在 IDE 的底部:

公司表

您现在可以通过在SQL 命令编辑器中执行以下语句来查看插入的数据:

select * from ttt_company;

或者,您还可以右键单击数据库中的表节点,然后选择查看数据...

公司表

这将导致以下截图:

公司表

项目表

一个公司可以有任意数量的项目,每个项目都属于一个公司。表定义如下:

create table ttt_project(
  id_project  int unsigned not null auto_increment,
  project_name varchar(200) not null,
  id_company  int unsigned not null,
  primary key(id_project),
  foreign key(id_company) references ttt_company(id_company)
);

再次,我们可以添加一些数据:

insert into ttt_project(project_name, id_company) values('Enterprise Application Development with Spring and ExtJS', 1);
insert into ttt_project(project_name, id_company) values ('TheSpring Framework for Beginners', 1);
insert into ttt_project(project_name, id_company) values('Advanced Sencha ExtJS4 ', 1);
insert into ttt_project(project_name, id_company) values ('The 3TProject', 2);
insert into ttt_project(project_name, id_company) values('Breezing', 2);
insert into ttt_project(project_name, id_company) values ('GiemanWebsite', 2);
insert into ttt_project(project_name, id_company) values('Internal Office Projects', 3);
insert into ttt_project(project_name, id_company) values('External Consulting Tasks', 3);

在这些insert语句中,我们已经提供了对公司表的外键,并再次允许 MySQL 生成主键。执行这些命令并浏览ttt_project表数据应该显示如下截图所示:

项目表

任务表

一个项目可以有任意数量的任务,每个任务都属于一个项目。现在可以添加表和测试数据如下:

create table ttt_task(
  id_task   int unsigned not null auto_increment,
  id_project  int unsigned not null,  
  task_name  varchar(200) not null,
  primary key(id_task),
  foreign key(id_project) references ttt_project(id_project)
);

我们现在将为一些项目添加一系列任务:

insert into ttt_task(id_project, task_name)values (1, 'Chapter 1');
insert into ttt_task(id_project, task_name)values (1, 'Chapter 2');
insert into ttt_task(id_project, task_name)values (1, 'Chapter 3');

insert into ttt_task(id_project, task_name)values (2, 'Chapter 1');
insert into ttt_task(id_project, task_name)values (2, 'Chapter 2');
insert into ttt_task(id_project, task_name)values (2, 'Chapter 3');

insert into ttt_task(id_project, task_name)values (3, 'Preface');
insert into ttt_task(id_project, task_name)values (3, 'Appendix');
insert into ttt_task(id_project, task_name)values (3, 'Illustrations');

insert into ttt_task(id_project, task_name)values (4, 'DatabaseDevelopment');
insert into ttt_task(id_project, task_name)values (4, 'Javadevelopment');
insert into ttt_task(id_project, task_name)values (4, 'SenchaDevcelopment');
insert into ttt_task(id_project, task_name)values (4, 'Testing');

执行这些命令并浏览ttt_task表数据将显示以下截图:

任务表

用户表

我们设计的下一个表包含用户信息:

create table ttt_user(
  username        varchar(10) not null,
  first_name      varchar(100) not null,
  last_name       varchar(100) not null,
  email           varchar(100) not null unique,
  password        varchar(100) not null,
  admin_role      char(1) not null,
  primary key(username)
);

请注意,admin_role列将用于标识用户是否在 3T 应用程序中具有管理权限。我们现在将添加两个用户:

insert into ttt_user(username, first_name, last_name, email,password, admin_role) values ('jsmith', 'John', 'Smith', 'js@tttracker.com', 'admin','N');
insert into ttt_user(username, first_name, last_name, email,password, admin_role) values ('bjones', 'Betty', 'Jones', 'bj@tttracker.com','admin','Y');

运行这组命令将创建用户表,然后插入我们的两个测试用户,如下截图所示:

用户表

任务日志表

最终的表将用于输入不同任务所花费的时间。

create table ttt_task_log(
  id_task_log   int unsigned not null auto_increment,
  id_task    int unsigned not null,
  username   varchar(10) not null,
  task_description varchar(2000) not null,
  task_log_date  date not null,
  task_minutes  int unsigned not null,
  primary key(id_task_log),
  foreign key(id_task) references ttt_task(id_task),
  foreign key(username) references ttt_user(username)
);

现在我们将为我们的用户约翰史密斯(jsmith)的表添加一些数据。请注意,每个任务所花费的时间以分钟为单位,并且 MySQL 函数now()用于返回当前时间戳:

insert into ttt_task_log (id_task, username, task_description,task_log_date,task_minutes)values(1,'jsmith','Completed Chapter 1 proof reading',now(),120);
insert into ttt_task_log (id_task, username, task_description,task_log_date,task_minutes)values(2,'jsmith','Completed Chapter 2 draft',now(), 240);
insert into ttt_task_log (id_task, username, task_description,task_log_date,task_minutes)values(3,'jsmith','Completed preparation work for initialdraft',now(), 90);
insert into ttt_task_log (id_task, username, task_description,task_log_date,task_minutes)values(3,'jsmith','Prepared database for Ch3 task',now(), 180);

类似地,我们将为贝蒂琼斯(bjones)插入一些测试数据:

insert into ttt_task_log (id_task, username, task_description,task_log_date,task_minutes)values(1,'bjones','Started Chapter 1 ',now(), 340);
insert into ttt_task_log (id_task, username, task_description,task_log_date,task_minutes)values(2,'bjones','Finished Chapter 2 draft',now(), 140);
insert into ttt_task_log (id_task, username, task_description,task_log_date,task_minutes)values(3,'bjones','Initial draft work completed',now(), 450);
insert into ttt_task_log (id_task, username, task_description,task_log_date,task_minutes)values(3,'bjones','Database design started',now(), 600);

现在可以查看这些insert语句的结果,如下截图所示:

任务日志表

3T 数据库的企业选项

先前提供的表和列定义是我们 3T 项目所需的最简单的。然而,还有一些潜在的选项可以添加以增强企业使用的结构。

密码加密

企业应用程序要求密码字段使用单向算法进行加密以确保安全。密码永远不应以明文形式存储,也永远不应该在数据库中可见(就像我们目前可以做的那样)。本书的范围超出了涵盖密码安全策略的范围,但可以在www.jasypt.org/howtoencryptuserpasswords.html找到核心原则的非常好的解释。

MySQL 提供了许多密码加密函数,可以用于此目的。我们建议您浏览文档dev.mysql.com/doc/refman/5.7/en/encryption-functions.html以了解可用的不同选项。

LDAP 集成

许多企业使用LDAP(轻量级目录访问协议)来维护其组织内的用户。LDAP 最常用于提供单一登录,其中一个用户的密码在许多服务之间共享。因此,在这种情况下,用户表中的密码列将不需要。如果一个组织跨越多个地理位置,可能会有几个分布在不同大陆的 LDAP 领域。这种情况可能需要一个新的表来存储 LDAP 授权服务器。然后,每个用户可能会被分配一个授权 LDAP 服务器来处理他们的登录。

审计追踪

企业系统通常需要广泛的审计追踪(何时以及为什么发生了某个动作,以及是谁执行了它)。这对于公开上市的大型组织尤为重要。例如,萨班斯-奥克斯法案SOX)要求所有在美国境内上市的公司必须建立内部控制和程序,以减少公司欺诈的可能性。这些流程包括识别任何时间段内的授权和未授权更改或潜在可疑活动。

“谁,何时,为什么”这些问题是设计企业数据库时需要考虑的审计追踪的基础。简单地向所有表添加一些额外的列是一个很好的开始:

who_created varchar(10) not null
who_updated varchar(10) not null
when_created datetime default current_timestamp
when_updated datetime on update current_timestamp

请注意,这个语法是针对 MySQL 的,但类似的功能对大多数数据库都是可用的。who_createdwho_updated列将需要通过程序更新。开发人员需要确保在处理相关操作时这些字段被正确设置。when_createdwhen_updated列不需要开发人员考虑。它们会被 MySQL 自动维护。when_created字段将自动设置为current_timestamp MySQL 函数,表示查询开始时间,以确定记录被插入到数据库中的确切时刻。when_updated字段将在每次记录本身被更新时自动更新。添加这四个额外的列将确保基本级别的审计追踪是可用的。现在我们可以查看谁创建了记录以及何时,还可以查看谁执行了最后的更新以及何时。例如,ttt_company表可以重新设计如下:

create table ttt_company(
  id_company      int unsigned not null auto_increment,
  company_name    varchar(200) not null,
  who_created varchar(10) not null,
  who_updated varchar(10) not null,
  when_created datetime default current_timestamp,
  when_updated datetime on update current_timestamp,
  primary key(id_company)
);

登录活动审计

这提供了跟踪基本用户活动的能力,包括谁登录了,何时登录了,以及从哪里登录了。这是企业审计追踪的另一个关键部分,还应包括跟踪无效的登录尝试。这些信息需要通过程序维护,并需要一个类似以下代码结构的表:

create table ttt_user_log(
  id_user_log int unsigned not null auto_increment,
  username varchar(10) not null,
  ip_address varchar(20) not null,
  status char not null,
  log_date datetime default current_timestamp,
  primary key(id_user_log)
);

status字段可以用于标识登录尝试(例如,S可以表示成功,F可以表示失败,而M可以表示成功的移动设备登录)。所需的信息需要根据企业的合规要求来定义。

自定义审计表

通常需要审计特定表的每个操作和数据更改。在这种情况下,“何时”和“谁”更新字段是不够的。这种情况需要一个包含原始表中所有字段的审计(或快照)表。每次记录更新时,当前快照都会被写入审计表,以便每个更改都可以用于审计目的。这样的表也可能被称为存档表,因为数据的演变在每次更改时都被存档。这些自定义审计表通常不是通过编程方式维护的,而是由关系数据库管理系统(RDBMS)管理,可以通过触发器或内置的日志记录/存档功能来管理。

摘要

本章定义了一个将用于构建 3T 应用程序的数据库结构。我们已连接到 MySQL 服务器并执行了一系列 SQL 语句来创建和填充一组表。每个表都使用“自动增量”列,以便 MySQL 可以自动管理和生成主键。虽然表结构并不复杂,但我们也已经确定了可能适用于企业使用的可能增强功能。

在第三章中,使用 JPA 逆向工程领域层,我们将通过逆向工程我们的数据库来创建一组Java 持久化 APIJPA)实体,开始我们的 Java 之旅。我们的 JPA 领域层将成为我们 3T 应用程序的数据核心。

第三章:使用 JPA 进行领域层的逆向工程

领域层代表了模拟应用程序核心的真实世界实体。在最高层次上,领域层代表了应用程序的业务领域,并完全描述了实体、它们的属性以及它们之间的关系。在最基本的层次上,领域层是一组普通的旧 Java 对象POJOs),它们定义了数据库表的 Java 表示,这些表被映射到应用程序上。这种映射是通过 JPA 实现的。

Java 持久化 APIJPA)是 Java EE 5 平台中最重要的进步之一,它用更简单的基于 POJO 的编程模型取代了复杂和繁琐的实体 bean。JPA 提供了一套标准的对象关系映射ORM)规则,这些规则简单直观,易于学习。数据库关系、属性和约束通过 JPA 注解映射到 POJOs 上。

在本章中,我们将执行以下操作:

  • 使用 NetBeans IDE 对 3T 数据库进行逆向工程

  • 探索并定义我们领域层的 JPA 注解

  • 介绍Java 持久化查询语言JPQL

理解使用 JPA 的原因

JPA 是一种提高开发人员专注于业务而不是编写低级 SQL 和 JDBC 代码的生产力工具。它完全消除了将 Java 的ResultSet映射到 Java 领域对象的需要,并大大减少了产生可用和功能性应用程序所需的工作量。基于 JPA 的应用程序将更容易维护、测试和增强。更重要的是,您的应用程序代码质量将显著提高,领域实体将变得自我描述。

根据个人经验,我估计编写传统的 SQL 应用程序(不使用 JPA,直接编写 CRUD SQL 语句)所需的时间大约是使用 JPA 方法的 10-15 倍。这意味着在企业应用程序中节省了大量的时间和精力。在应用程序的生命周期中,考虑到维护、错误修复和增强,仅仅通过节约成本就可能是成功与失败之间的差异。

理解 JPA 实现

JPA 规范最初是从包括 TopLink(来自 Oracle)、Hibernate 和 Kodo 在内的关键 ORM 实现的经验中演变而来。这些产品通过将领域层中的底层 SQL 抽象出来,并简化实现核心 CRUD 操作(创建、读取、更新和删除)所需的开发工作,从而彻底改变了 Java 数据库交互。每个实现都支持 JPA 标准以及它们自己的专有 API。例如,TopLink 提供了超出 JPA 规范的缓存增强功能,以及针对 Oracle 数据库的复杂查询优化。您选择的实现可能取决于应用程序的要求(例如,分布式缓存)以及底层数据库本身。

GlassFish 4 服务器捆绑了开源的EclipseLink JPA 实现,这是我们将在本书中使用的。有关 EclipseLink 项目的更多信息,请访问www.eclipse.org/eclipselink/。您无需下载任何文件,因为在逆向工程过程中,EclipseLink 依赖项将自动添加到您的pom.xml文件中。

使用 NetBeans 进行逆向工程

“从数据库创建新实体类”向导是 NetBeans 中最有帮助和节省时间的向导之一。它从现有的数据库连接生成一组实体类,提取和注释所有字段,并定义类之间的关系。要访问该向导,请导航到“文件”|“新建文件”。这将打开“新建文件”窗口,然后您可以选择“持久性”类别,然后选择“来自数据库的实体类”文件类型:

使用 NetBeans 进行反向工程

单击“下一步”以显示“数据库表”屏幕,您可以在其中创建“新数据源”:

使用 NetBeans 进行反向工程

这将允许您输入“JNDI 名称”并选择在上一章中创建的“数据库连接”:

使用 NetBeans 进行反向工程

向导现在将读取所有表并将它们显示在“可用表”列表中。选择所有表并将它们添加到“已选表”列表中,如图所示:

使用 NetBeans 进行反向工程

单击“下一步”按钮。这将显示以下屏幕,显示实体类生成属性。通过双击每个“类名”行来更改每个实体的“类名”,以删除“Ttt”前缀来编辑此属性(屏幕截图显示了编辑前的User实体)。为什么要删除这个“Ttt”?简单地因为反向工程过程会自动创建基于表名的类名,而“Ttt”前缀对我们的设计没有任何帮助。下一个更改必须在包名中完成。如图所示,在包名中添加“domain”。这将在“com.gieman.tttracker.domain”包中生成新的实体类,代表我们的业务领域对象和相关的辅助类。根据用途或目的将我们的类放在定义明确的独立包中,增强了我们轻松维护应用程序的能力。对于大型企业应用程序,定义良好的 Java 包结构至关重要。

最后一步是取消选中“生成 JAXB 注释”复选框。我们不需要通过 JAXB 生成 XML,因此我们不需要额外的注释。

使用 NetBeans 进行反向工程

现在单击“下一步”按钮,将显示以下屏幕。最后一步涉及选择适当的“集合类型”。有三种不同类型的集合可以使用,并且都可以同样成功地使用。我们将默认的“集合类型”更改为java.util.List,因为在应用程序的业务逻辑中,排序顺序通常很重要,而其他类型不允许排序。在更个人的层面上,我们更喜欢使用java.util.List API 而不是java.util.Setjava.util.Collection API。

使用 NetBeans 进行反向工程

单击“完成”按钮开始反向工程过程。过程完成后,您可以打开src/java节点查看生成的文件,如下图所示:

使用 NetBeans 进行反向工程

介绍 persistence.xml 文件

persistence.xml文件是在反向工程过程中生成的,为一组实体类定义了 JPA 配置。该文件始终位于类路径根目录下的META-INF目录中。Maven 项目有一个名为resources的特殊目录,位于src/main目录中,其中包含适用于构建 Java 项目的其他资源。构建项目时,Maven 会自动将resources目录复制到类路径的根目录。双击打开文件以在编辑器中显示文件的“设计”视图:

介绍 persistence.xml 文件

设计视图包含用于配置持久性单元行为的几个属性。我们将坚持使用最简单的设置,但我们鼓励您探索可能对您自己应用程序需求有用的不同策略。例如,需要自动创建表的项目将欣赏表生成策略创建删除和创建。选择不同的选项并切换到视图将帮助我们快速识别persistence.xml文件中的适当属性。

点击顶部的按钮以以文本格式查看默认文件内容:

介绍 persistence.xml 文件

将默认的persistence-unit节点name属性值更改为tttPU,而不是长自动生成的名称。此值将用于您的 Java 代码来引用此持久性单元,并且易于记忆。provider节点值会自动设置为适当的 EclipseLink 类,jta-data-source节点值会自动设置为在反向工程向导期间使用的数据源。exclude-unlisted-classes设置将定义是否对注释的实体类进行类路径扫描。将其更改为false。对于大型项目,这是确保类不会被意外省略的最安全方法。还可以以以下方式明确指定每个类:

介绍 persistence.xml 文件

这对于小型项目来说很好,但如果您有数百个实体类,这就不太实际了。在前面的示例中,exclude-unlisted-classes属性设置为true,这意味着只有指定的类将被加载,无需进行类路径扫描。我们更喜欢通过将exclude-unlisted-classes设置为false来定义我们的 JPA 类的第一种方法,从而通过类路径扫描加载所有注释的实体类。

感兴趣的最终配置项是transaction-type属性。此项支持两种不同类型的事务,我们默认设置为JTAJTAJava 事务 API)表示事务将由 GlassFish 服务器提供的 Java EE 事务管理器管理。我们将在第五章中构建测试用例时探索RESOURCE_LOCAL替代 JTA。在这种情况下,事务将在没有 Java EE 容器的情况下在本地管理。

重构 Java 类

通过一些重构,可以改进反向工程过程生成的类,使代码更易读和理解。当我们实际上是在引用类时,一些自动生成的属性和字段的名称中都有id,而java.util.List对象的集合中都有list。让我们从Company.java文件开始。

Company.java 文件

该文件代表Company实体。双击文件以在编辑器中打开并浏览内容。这个类是一个简单的 POJO,除了标准的hashCodeequalstoString方法外,还有每个属性的 set 和 get 方法。该类有一个无参构造函数(JPA 规范要求域对象必须动态创建,没有任何属性),一个仅接受主键的第二个构造函数和一个完整(所有参数)的构造函数。我们将通过对Company.java文件进行一些小的更改来使代码更易读。

第一个更改是将文件中的projectList字段到处重命名为projects。这可以通过选择projectList字段,然后从菜单中选择重构 | 重命名来轻松实现:

Company.java 文件

现在您可以将字段名称更改为projects。在单击Refactor按钮之前,确保还选择Rename Getters and Setters选项。

Company.java 文件

进行这些更改将更改字段名称并为projects字段生成新的获取和设置方法。

Company.java文件的最终更改是将mappedBy属性从idCompany重命名为company。适当的行现在应该如下所示的代码:

@OneToMany(cascade = CascadeType.ALL, mappedBy = "company")
private List<Project> projects;

最终重构的Company.java文件现在应该如下所示的代码片段:

package com.gieman.tttracker.domain;

import java.io.Serializable;
import java.util.List;
import javax.persistence.Basic;
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.NamedQueries;
import javax.persistence.NamedQuery;
import javax.persistence.OneToMany;
import javax.persistence.Table;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;

@Entity
@Table(name = "ttt_company")
@NamedQueries({
    @NamedQuery(name = "Company.findAll", query = "SELECT c FROM Company c"),
    @NamedQuery(name = "Company.findByIdCompany", query = "SELECT c FROM Company c WHERE c.idCompany = :idCompany"),
    @NamedQuery(name = "Company.findByCompanyName", query = "SELECT c FROM Company c WHERE c.companyName = :companyName")})
public class Company implements Serializable {
    private static final long serialVersionUID = 1L;
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Basic(optional = false)
    @Column(name = "id_company")
    private Integer idCompany;
    @Basic(optional = false)
    @NotNull
    @Size(min = 1, max = 200)
    @Column(name = "company_name")
    private String companyName;
    @OneToMany(cascade = CascadeType.ALL, mappedBy = "company")
    private List<Project> projects;

    public Company() {
    }

    public Company(Integer idCompany) {
        this.idCompany = idCompany;
    }

    public Company(Integer idCompany, String companyName) {
        this.idCompany = idCompany;
        this.companyName = companyName;
    }

    public Integer getIdCompany() {
        return idCompany;
    }

    public void setIdCompany(Integer idCompany) {
        this.idCompany = idCompany;
    }

    public String getCompanyName() {
        return companyName;
    }

    public void setCompanyName(String companyName) {
        this.companyName = companyName;
    }

    public List<Project> getProjects() {
        return projects;
    }

    public void setProjects(List<Project> projects) {
        this.projects = projects;
    }

    @Override
    public int hashCode() {
        int hash = 0;
        hash += (idCompany != null ? idCompany.hashCode() : 0);
        return hash;
    }

    @Override
    public boolean equals(Object object) {
        if (!(object instanceof Company)) {
            return false;
        }
        Company other = (Company) object;
        if ((this.idCompany == null && other.idCompany != null) || (this.idCompany != null && !this.idCompany.equals(other.idCompany))) {
            return false;
        }
        return true;
    }

    @Override
    public String toString() {
        return "com.gieman.tttracker.domain.Company[ idCompany=" + idCompany + " ]";
    }

}

JPA 使用约定优于配置的概念来简化实体的配置。这是通过使用具有合理默认值的注释来实现的,以保持实体定义的简洁。现在,让我们看看此文件中的关键 JPA 注释。

@Entity 注释

这是一个标记注释,指示 JPA 持久性提供者Company类是一个实体。当persistence.xml文件中的exclude-unlisted-classes设置为false时,JPA 会扫描@Entity注释。没有@Entity注释,持久性引擎将忽略该类。

@Table 注释

@Table 注释定义了由此实体类表示的底层数据库表。@Table(name = "ttt_company")行告诉持久性提供者Company类表示ttt_company表。任何实体类中只能定义一个表注释。

@Id 注释

@Id 注释定义了类中的主键字段,并且对于每个实体都是必需的。如果不存在@Id 注释,持久性提供者将抛出异常。表示ttt_company表中主键的Company类属性是Integer idCompany字段。此字段附加了三个附加注释,其中以下注释特定于主键。

@GeneratedValue 注释

此注释标识持久性引擎应如何为将记录插入表中生成新的主键值。strategy=GenerationType.IDENTITY行将在后台使用 MySQL 自动增量策略将记录插入ttt_company表。不同的数据库可能需要不同的策略。例如,Oracle 数据库表可以通过定义以下生成器注释以使用序列作为主键生成的基础:

@GeneratedValue(generator="gen_seq_company")
@SequenceGenerator(name="gen_seq_company", sequenceName="seq_id_company")

注意

主键生成与类本身无关。持久性引擎将根据生成策略处理主键的生成。

@Basic 注释

这是一个可选的注释,用于标识字段的可空性。@Basic(optional = false)行用于指定字段不是可选的(不可为 null)。同样,@Basic(optional = true)行可用于其他可能可为空的字段。

@Column 注释

此注释指定字段映射到的列。因此,@Column(name = "id_company")行将ttt_company表中的id_company列映射到类中的idCompany字段。

@NotNull 和@Size 注释

这些注释是javax.validation.constraints包的一部分(Bean Validation 包是在 Java EE 6 中引入的),定义了字段不能为空以及字段的最小和最大大小。ttt_company表中的company_name列被定义为varchar(200) not null,这就是在反向工程过程中创建这些注释的原因。

@OneToMany 注释

Company类可能有零个或多个Projects实体。这种关系由@OneToMany注解定义。简而言之,我们可以描述这种关系为一个公司可以有多个项目。在 JPA 中,通过在此注解中定义mappedBy属性,实体与其他实体的集合相关联。我们已经将原始的mappedBy值重构为company。这将是在我们在下一节中重构Project文件后,在Project.java文件中的字段的名称。

@NamedQueries 注解

@NamedQueries注解值得单独解释。我们稍后会详细讨论这些。

Projects.java 文件

你现在可能已经猜到,这个文件代表Project实体,并映射到ttt_project表。双击文件以在编辑器中打开并浏览内容。我们将再次进行一些重构,以澄清自动生成的字段:

  • 使用重构过程将自动生成的idCompany字段重命名为company。不要忘记重命名 get 和 set 方法。

  • 将自动生成的taskList字段重命名为tasks。不要忘记再次编写 get 和 set 方法!

  • mappedBy值从idProject重命名为project

最终重构后的文件现在应该如下代码所示:

package com.gieman.tttracker.domain;

import java.io.Serializable;
import java.util.List;
import javax.persistence.Basic;
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.ManyToOne;
import javax.persistence.NamedQueries;
import javax.persistence.NamedQuery;
import javax.persistence.OneToMany;
import javax.persistence.Table;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;

@Entity
@Table(name = "ttt_project")
@NamedQueries({
    @NamedQuery(name = "Project.findAll", query = "SELECT p FROM Project p"),
    @NamedQuery(name = "Project.findByIdProject", query = "SELECT p FROM Project p WHERE p.idProject = :idProject"),
    @NamedQuery(name = "Project.findByProjectName", query = "SELECT p FROM Project p WHERE p.projectName = :projectName")})
public class Project implements Serializable {
    private static final long serialVersionUID = 1L;
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Basic(optional = false)
    @Column(name = "id_project")
    private Integer idProject;
    @Basic(optional = false)
    @NotNull
    @Size(min = 1, max = 200)
    @Column(name = "project_name")
    private String projectName;
    @JoinColumn(name = "id_company", referencedColumnName = "id_company")
    @ManyToOne(optional = false)
    private Company company;
    @OneToMany(cascade = CascadeType.ALL, mappedBy = "project")
    private List<Task> tasks;

    public Project() {
    }
    public Project(Integer idProject) {
        this.idProject = idProject;
    }

    public Project(Integer idProject, String projectName) {
        this.idProject = idProject;
        this.projectName = projectName;
    }

    public Integer getIdProject() {
        return idProject;
    }

    public void setIdProject(Integer idProject) {
        this.idProject = idProject;
    }

    public String getProjectName() {
        return projectName;
    }

    public void setProjectName(String projectName) {
        this.projectName = projectName;
    }

    public Company getCompany() {
        return company;
    }

    public void setCompany(Company company) {
        this.company = company;
    }

    public List<Task> getTasks() {
        return tasks;
    }

    public void setTasks(List<Task> tasks) {
        this.tasks = tasks;
    }

    @Override
    public int hashCode() {
        int hash = 0;
        hash += (idProject != null ? idProject.hashCode() : 0);
        return hash;
    }

    @Override
    public boolean equals(Object object) {
        if (!(object instanceof Project)) {
            return false;
        }
        Project other = (Project) object;
        if ((this.idProject == null && other.idProject != null) || (this.idProject != null && !this.idProject.equals(other.idProject))) {
            return false;
        }
        return true;
    }

    @Override
    public String toString() {
        return "com.gieman.tttracker.domain.Project[ idProject=" + idProject + " ]";
    }

}

@ManyToOne 注解

这个注解表示实体之间的关系;它是@OneToMany注解的反向。对于Project实体,我们可以说多个项目对应一个公司。换句话说,一个Project实体属于一个单一的Company类,而(反过来)一个Company类可以有任意数量的Projects实体。这种关系在数据库级别(即底层表中的外键关系)中定义,并在@JoinColumn注解中实现:

@JoinColumn(name = "id_company", referencedColumnName = "id_company")

name属性定义了ttt_project表中的列名,该列是指向ttt_company表中的referencedColumnName列的外键。

双向映射和拥有实体

通过@ManyToOne@OneToMany注解,理解一个实体如何通过这两个注解与另一个实体相关联是非常重要的。Company类有一个映射的Projects实体列表,定义如下:

  @OneToMany(cascade = CascadeType.ALL, mappedBy = "company")
  private List<Project> projects;

Project类恰好有一个映射的Company实体:

  @JoinColumn(name="id_company", referencedColumnName="id_company")
  @ManyToOne(optional=false)
  private Company company;

这被称为双向映射,每个方向上每个类都有一个映射。一个多对一的映射回到源,就像Project实体回到Company实体一样,意味着源(Company)到目标(Project)有一个对应的一对多映射。术语目标可以定义如下:

  • :这是一个可以独立存在于关系中的实体。源实体不需要目标实体存在,@OneToMany集合可以为空。在我们的例子中,Company实体可以存在而不需要Project实体。

  • 目标:这是一个没有参考有效源就无法独立存在的实体。目标上定义的@ManyToOne实体不能为空。在我们的设计中,Project实体不能存在而没有有效的Company实体。

拥有实体是一个从数据库角度理解另一个实体的实体。简单来说,拥有实体具有@JoinColumn定义,描述形成关系的基础列。在Company-Project关系中,Project是拥有实体。请注意,一个实体可以同时是目标和源,如下面的Project.java文件片段所示:

  @OneToMany(cascade = CascadeType.ALL, mappedBy = "project")
  private List<Task> tasks;

在这里,ProjectTask实体关系的源,我们期望在Task类上找到一个反向的@ManyToOne注解。这正是我们将找到的。

Task.java 文件

这个文件定义了代表ttt_task表的Task实体。打开文件并执行以下重构:

  • 删除自动生成的taskLogList字段,同时也删除相关的 get 和 set 方法。为什么要这样做?系统中可能有数百万条任务日志与每个Task实例相关联,不建议在Task对象内保存对这么大一组TaskLog实例的引用。

  • 将自动生成的idProject字段重命名为project。不要忘记再次删除 get 和 set 方法。

在进行了上述更改之后,您会发现一些导入不再需要,并且在 NetBeans IDE 中被标记出来:

Task.java 文件

Ctrl + Shift + I的组合键将删除所有未使用的导入。另一种选择是单击下图中显示的图标,打开菜单并选择删除选项:

Task.java 文件

清理代码并删除未使用的导入是一个简单的过程,这是一个良好的实践。

最终重构后的文件现在应该看起来像以下代码片段:

package com.gieman.tttracker.domain;

import java.io.Serializable;
import javax.persistence.Basic;
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.ManyToOne;
import javax.persistence.NamedQueries;
import javax.persistence.NamedQuery;
import javax.persistence.Table;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;

@Entity
@Table(name = "ttt_task")
@NamedQueries({
    @NamedQuery(name = "Task.findAll", query = "SELECT t FROM Task t"),
    @NamedQuery(name = "Task.findByIdTask", query = "SELECT t FROM Task t WHERE t.idTask = :idTask"),
    @NamedQuery(name = "Task.findByTaskName", query = "SELECT t FROM Task t WHERE t.taskName = :taskName")})
public class Task implements Serializable {
    private static final long serialVersionUID = 1L;
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Basic(optional = false)
    @Column(name = "id_task")
    private Integer idTask;
    @Basic(optional = false)
    @NotNull
    @Size(min = 1, max = 200)
    @Column(name = "task_name")
    private String taskName;
    @JoinColumn(name = "id_project", referencedColumnName = "id_project")
    @ManyToOne(optional = false)
    private Project project;

    public Task() {
    }

    public Task(Integer idTask) {
        this.idTask = idTask;
    }

    public Task(Integer idTask, String taskName) {
        this.idTask = idTask;
        this.taskName = taskName;
    }

    public Integer getIdTask() {
        return idTask;
    }

    public void setIdTask(Integer idTask) {
        this.idTask = idTask;
    }

    public String getTaskName() {
        return taskName;
    }

    public void setTaskName(String taskName) {
        this.taskName = taskName;
    }

    public Project getProject() {
        return project;
    }

    public void setProject(Project project) {
        this.project = project;
    }

    @Override
    public int hashCode() {
        int hash = 0;
        hash += (idTask != null ? idTask.hashCode() : 0);
        return hash;
    }

    @Override
    public boolean equals(Object object) {
        if (!(object instanceof Task)) {
            return false;
        }
        Task other = (Task) object;
        if ((this.idTask == null && other.idTask != null) || (this.idTask != null && !this.idTask.equals(other.idTask))) {
            return false;
        }
        return true;
    }

    @Override
    public String toString() {
        return "com.gieman.tttracker.domain.Task[ idTask=" + idTask + " ]";
    }    
}

注意@ManyToOne注释引用Project类,使用@JoinColumn定义。Task对象拥有这个关系。

User.java 文件

User实体代表了底层的ttt_user表。生成的类对与TaskLog类的关系有一个@OneToMany定义:

  @OneToMany(cascade = CascadeType.ALL, mappedBy = "username")
  private List<TaskLog> taskLogList;

在这个文件中进行重构将再次完全删除这个关系。如Tasks.java部分所述,一个User实体也可能有成千上万的任务日志。通过了解应用程序的要求和数据结构,完全删除不必要的关系通常更加清晰。

您还会注意到在反向工程过程中,默认情况下@Pattern注释被注释掉了。email字段名称告诉 NetBeans 这可能是一个电子邮件字段,如果需要,NetBeans 会添加注释以供使用。我们将取消注释此注释以启用对该字段的电子邮件模式检查,并添加所需的导入:

import javax.validation.constraints.Pattern;

重构后的User.java文件现在应该看起来像以下代码片段:

package com.gieman.tttracker.domain;

import java.io.Serializable;
import javax.persistence.Basic;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.NamedQueries;
import javax.persistence.NamedQuery;
import javax.persistence.Table;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Pattern;
import javax.validation.constraints.Size;

@Entity
@Table(name = "ttt_user")
@NamedQueries({
    @NamedQuery(name = "User.findAll", query = "SELECT u FROM User u"),
    @NamedQuery(name = "User.findByUsername", query = "SELECT u FROM User u WHERE u.username = :username"),
    @NamedQuery(name = "User.findByFirstName", query = "SELECT u FROM User u WHERE u.firstName = :firstName"),
    @NamedQuery(name = "User.findByLastName", query = "SELECT u FROM User u WHERE u.lastName = :lastName"),
    @NamedQuery(name = "User.findByEmail", query = "SELECT u FROM User u WHERE u.email = :email"),
    @NamedQuery(name = "User.findByPassword", query = "SELECT u FROM User u WHERE u.password = :password"),
    @NamedQuery(name = "User.findByAdminRole", query = "SELECT u FROM User u WHERE u.adminRole = :adminRole")})
public class User implements Serializable {
    private static final long serialVersionUID = 1L;
    @Id
    @Basic(optional = false)
    @NotNull
    @Size(min = 1, max = 10)
    @Column(name = "username")
    private String username;
    @Basic(optional = false)
    @NotNull
    @Size(min = 1, max = 100)
    @Column(name = "first_name")
    private String firstName;
    @Basic(optional = false)
    @NotNull
    @Size(min = 1, max = 100)
    @Column(name = "last_name")
    private String lastName;
    @Pattern(regexp="[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:a-z0-9?\\.)+a-z0-9?", message="Invalid email")
    @Basic(optional = false)
    @NotNull
    @Size(min = 1, max = 100)
    @Column(name = "email")
    private String email;
    @Basic(optional = false)
    @NotNull
    @Size(min = 1, max = 100)
    @Column(name = "password")
    private String password;
    @Column(name = "admin_role")
    private Character adminRole;

    public User() {
    }

    public User(String username) {
        this.username = username;
    }

    public User(String username, String firstName, String lastName, String email, String password) {
        this.username = username;
        this.firstName = firstName;
        this.lastName = lastName;
        this.email = email;
        this.password = password;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    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 String getEmail() {
        return email;
    }

    public void setEmail(String email) {
        this.email = email;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }

    public Character getAdminRole() {
        return adminRole;
    }

    public void setAdminRole(Character adminRole) {
        this.adminRole = adminRole;
    }

    @Override
    public int hashCode() {
        int hash = 0;
        hash += (username != null ? username.hashCode() : 0);
        return hash;
    }

    @Override
    public boolean equals(Object object) {
         if (!(object instanceof User)) {
            return false;
        }
        User other = (User) object;
        if ((this.username == null && other.username != null) || (this.username != null && !this.username.equals(other.username))) {
            return false;
        }
        return true;
    }

    @Override
    public String toString() {
        return "com.gieman.tttracker.domain.User[ username=" + username + " ]";
    }   
}

TaskLog.java 文件

我们应用程序中的最终实体代表了ttt_task_log表。这里需要进行的重构是将idTask字段重命名为task(记得同时重命名 get 和 set 方法),然后将username字段重命名为user。文件现在应该看起来像以下代码片段:

package com.tttracker.domain;

import java.io.Serializable;
import java.util.Date;
import javax.persistence.Basic;
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.ManyToOne;
import javax.persistence.NamedQueries;
import javax.persistence.NamedQuery;
import javax.persistence.Table;
import javax.persistence.Temporal;
import javax.persistence.TemporalType;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;

@Entity
@Table(name = "ttt_task_log")
@NamedQueries({
  @NamedQuery(name = "TaskLog.findAll", query = "SELECT t FROM TaskLog t"),
  @NamedQuery(name = "TaskLog.findByIdTaskLog", query = "SELECT t FROM TaskLog t WHERE t.idTaskLog = :idTaskLog"),
  @NamedQuery(name = "TaskLog.findByTaskDescription", query = "SELECT t FROM TaskLog t WHERE t.taskDescription = :taskDescription"),
  @NamedQuery(name = "TaskLog.findByTaskLogDate", query = "SELECT t FROM TaskLog t WHERE t.taskLogDate = :taskLogDate"),
  @NamedQuery(name = "TaskLog.findByTaskMinutes", query = "SELECT t FROM TaskLog t WHERE t.taskMinutes = :taskMinutes")})
public class TaskLog implements Serializable {
  private static final long serialVersionUID = 1L;
  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  @Basic(optional = false)
  @Column(name = "id_task_log")
  private Integer idTaskLog;
  @Basic(optional = false)
  @NotNull
  @Size(min = 1, max = 2000)
  @Column(name = "task_description")
  private String taskDescription;
  @Basic(optional = false)
  @NotNull
  @Column(name = "task_log_date")
  @Temporal(TemporalType.DATE)
  private Date taskLogDate;
  @Basic(optional = false)
  @NotNull
  @Column(name = "task_minutes")
  private int taskMinutes;
  @JoinColumn(name = "username", referencedColumnName = "username")
  @ManyToOne(optional = false)
  private User user;
  @JoinColumn(name = "id_task", referencedColumnName = "id_task")
  @ManyToOne(optional = false)
  private Task task;

  public TaskLog() {
  }

  public TaskLog(Integer idTaskLog) {
    this.idTaskLog = idTaskLog;
  }

  public TaskLog(Integer idTaskLog, String taskDescription, Date taskLogDate, int taskMinutes) {
    this.idTaskLog = idTaskLog;
    this.taskDescription = taskDescription;
    this.taskLogDate = taskLogDate;
    this.taskMinutes = taskMinutes;
  }

  public Integer getIdTaskLog() {
    return idTaskLog;
  }

  public void setIdTaskLog(Integer idTaskLog) {
    this.idTaskLog = idTaskLog;
  }

  public String getTaskDescription() {
    return taskDescription;
  }

  public void setTaskDescription(String taskDescription) {
    this.taskDescription = taskDescription;
  }

  public Date getTaskLogDate() {
    return taskLogDate;
  }

  public void setTaskLogDate(Date taskLogDate) {
    this.taskLogDate = taskLogDate;
  }

  public int getTaskMinutes() {
    return taskMinutes;
  }

  public void setTaskMinutes(int taskMinutes) {
    this.taskMinutes = taskMinutes;
  }

  public User getUser() {
    return user;
  }

  public void setUser(User user) {
    this.user = user;
  }

  public Task getTask() {
    return task;
  }

  public void setTask(Task task) {
    this.task = task;
  }

  @Override
  public int hashCode() {
    int hash = 0;
    hash += (idTaskLog != null ? idTaskLog.hashCode() : 0);
    return hash;
  }

  @Override
  public boolean equals(Object object) {
    if (!(object instanceof TaskLog)) {
      return false;
    }
    TaskLog other = (TaskLog) object;
    if ((this.idTaskLog == null && other.idTaskLog != null) || (this.idTaskLog != null && !this.idTaskLog.equals(other.idTaskLog))) {
      return false;
    }
    return true;
  }

  @Override
  public String toString() {
    return "com.tttracker.domain.TaskLog[ idTaskLog=" + idTaskLog + " ]";
  }
}

介绍 Java 持久性查询语言

阅读本书的每个人都应该熟悉 SQL 查询及其工作原理。针对ttt_company表构建一个简单的查询以检索所有记录将如下所示:

select * from ttt_company

将结果集限制为以G开头的公司将如下代码行所示:

select * from ttt_company where company_name like "G%"

在 JPA 中,我们处理实体和实体之间的关系。Java 持久性查询语言JPQL)用于以类似于 SQL 的方式制定查询。前面提到的语句将以 JPQL 形式写成如下:

SELECT c FROM Company c

接下来的语句将被写成如下形式:

SELECT c FROM Company c WHERE c.companyName LIKE 'G%'

以下是 SQL 和 JPQL 之间的主要区别:

  • JPQL 类和字段名称区分大小写。当我们处理类时,类名必须以大写字母开头。所有字段必须与类中定义的大小写完全一致。以下语句将无法编译,因为公司实体以小写c开头:
SELECT c FROM company c WHERE c.companyName LIKE 'G%'
  • JPQL 关键字不区分大小写。上述语句也可以写成如下形式:
select c from Company c where c.companyName like 'G%'
  • JPQL 使用别名来定义实例和实例之间的关系。在前面的例子中,小写的c被用作SELECTWHERE子句中的别名。

  • JPQL 查询可以是静态的(在注释中定义)或动态的(在运行时构建和执行)。静态查询只编译一次,并在需要时查找。这使得静态查询更快速和更高效。

  • JPQL 查询被翻译成 SQL;然后针对底层数据库执行。这种翻译允许在持久性引擎中进行特定于数据库的查询优化。

  • JPQL 有一套丰富的函数来定义条件表达式。这些表达式被翻译成底层数据库的正确 SQL。这意味着开发人员不再需要编写特定于数据库的 SQL 语句。在不同数据库之间切换不需要任何编码,因为 JPQL 语句抽象了执行语句所需的底层 SQL。

注意

我们强烈建议您花时间学***QL。有许多专门介绍 JPA 和 JPQL 的优秀书籍可供阅读,它们解释了高级用法。互联网上也有许多在线教程和 JPQL 示例。本书的范围超出了基础知识,我们将其留给您进一步深入学习这种丰富语言。

定义命名查询

反向工程过程在每个类中生成了一组@NamedQuery注解,每个持久字段都有一个。例如,Company类定义了以下命名查询:

@NamedQueries({
  @NamedQuery(name = "Company.findAll", query = "SELECT c FROM Company c"),
  @NamedQuery(name = "Company.findByIdCompany", query = "SELECT c FROM Company c WHERE c.idCompany = :idCompany"),
  @NamedQuery(name = "Company.findByCompanyName", query = "SELECT c FROM Company c WHERE c.companyName = :companyName")}) 

每个@NamedQuery名称在持久性引擎内必须是唯一的;因此,它以类的名称为前缀。第一个查询名称Company.findAll表示Company对象的完整列表。第二个查询使用命名参数idCompany作为运行时提供的值的占位符。命名参数总是以冒号符号为前缀。您应该花一些时间浏览 Java 类中生成的查询,以熟悉基本的 JPQL 语法。我们将在接下来的章节中学习更多关于命名查询以及它们的用法。

重构 Java equals()和 hashCode()

我们的领域层实体类已定义了自动生成的equalshashCode方法。例如,Company类定义了如下方法:

重构 Java equals()和 hashCode()

最佳实践是始终提供正确实现的equalshashCode方法,这些方法使用实体 ID 来计算返回的值。这些方法由 JPA 用于确定实体之间的相等性。我们的自动生成的equals方法将与 JPA 一起正常工作,因为 ID 实体在每个方法的比较中都被使用。然而,83 行上的//TODO: Warning消息(参见上一个屏幕截图)指示了一个问题,如果我们使用 NetBeans IDE 重新生成equals方法,就可以避免这个问题。

删除equals方法,并使用鼠标右键单击编辑器中的Company.java文件,显示上下文菜单。选择Insert Code…选项:

重构 Java equals()和 hashCode()

从弹出菜单中,选择equals()…选项,并确保在Generate equals()弹出窗口中选择了idCompany : Integer字段:

重构 Java equals()和 hashCode()

单击Generate以创建新的equals方法:

重构 Java equals()和 hashCode()

单击信息图标(圈出的)在第 92 行上显示上下文信息:

重构 Java equals()和 hashCode()

单击if 语句是多余的以进一步清理您的代码,并用以下行替换if语句:

return Objects.equals(this.idCompany, other.idCompany);

Objects类是在 Java 1.7 中引入的,它包含用于操作对象的静态实用方法。Objects.equals方法考虑了null值,并解决了自动生成的equals方法可能出现的//TODO: Warning问题。来自 Java 1.7 JavaDoc 的Objects.equals方法:

注意

如果参数彼此相等,则返回true,否则返回false。因此,如果两个参数都为 null,则返回true,如果恰好一个参数为 null,则返回false。否则,使用第一个参数的equals方法来确定相等性。

现在,您可以以类似的方式替换ProjectTaskUserTaskLog实体类的自动生成的equals方法。

总结

在本章中,我们将 3T 数据库反向工程为一组 Java 类。每个 Java 类代表一个带有注释的 JPA 实体,定义了实体之间的关系以及数据库列与 Java 字段的映射。我们通过命名查询定义简要介绍了 JPQL,并介绍了关键的 JPA 注释。

尽管本章介绍了许多关键概念,但 JPA 和 JPQL 的范围还有很多需要学习的地方。JPA 是企业应用程序开发中的关键工具,可以轻松进行增强和与数据库无关的编程。

下一章将介绍数据访问对象DAO)设计模式,并使用我们刚刚定义的领域类实现一个强大的数据访问层。我们的 JPA 之旅刚刚开始!

第四章:数据访问变得简单

数据访问对象(DAO)设计模式是一种简单而优雅的方式,将数据库持久性与应用程序业务逻辑抽象出来。这种设计确保了企业应用程序的两个核心部分的清晰分离:数据访问层和服务(或业务逻辑)层。DAO 模式是一种广为人知的 Java EE 编程结构,最初由 Sun Microsystems 在其 Java EE 设计蓝图中引起关注,后来被其他编程环境如.NET 框架所采用。

以下图片说明了 DAO 层在整个应用程序结构中的位置:

数据访问变得简单

在 DAO 层更改实现不应以任何方式影响服务层。这是通过定义 DAO 接口来实现的,以封装服务层可以访问的持久性操作。DAO 实现本身对服务层是隐藏的。

定义 DAO 接口

Java 编程语言中的接口定义了一组方法签名和常量声明。接口公开行为(或可以做什么)并定义了实现类承诺提供的合同(如何做)。我们的 DAO 层将包含每个域对象一个接口和一个实现类。

注意

接口的使用在企业编程中经常被误解。有人认为,“为什么在代码库中添加另一组 Java 对象,当它们并不是必需的时候”。接口确实增加了你编写的代码行数,但它们的美妙之处将在你被要求重构一个使用接口编写的老项目时得到赞赏。我曾将基于 SQL 的持久性层迁移到 JPA 持久性层。新的 DAO 实现替换了旧的实现,而服务层几乎没有发生任何重大变化,这要归功于接口的使用。开发是并行进行的,同时支持现有(旧的)实现,直到我们准备好切换到新的实现。这是一个相对轻松的过程,如果没有接口的使用,就不会那么容易实现。

让我们从公司接口开始。

添加 CompanyDao 接口

  1. 从菜单中导航到“文件”|“新建文件”,并选择“Java 接口”,如下截图所示:添加 CompanyDao 接口

  2. 点击“下一步”按钮,并按照以下截图中显示的方式填写详细信息:添加 CompanyDao 接口

接口的名称是CompanyDao。我们本可以使用大写首字母缩写CompanyDAO来命名此接口。为了符合较新的 Java EE 命名风格,我们决定使用驼峰式缩写形式。最近的例子包括HtmlJsonXml类和接口,例如javax.json.JsonObject。我们也相信这种形式更容易阅读。但是,这并不妨碍您使用大写首字母缩写;在 Java EE 中也有许多这样的例子(EJBJAXBJMS接口和类等)。无论您选择哪种形式,都要保持一致。不要混合形式,创建CompanyDAOProjectDao接口!

请注意,包com.gieman.tttracker.dao目前还不存在,将由系统为您创建。点击“完成”以创建您的第一个接口,之后 NetBeans 将在编辑器中打开该文件。

添加 CompanyDao 接口

公司接口将定义我们在应用程序中将使用的持久性方法。核心方法必须包括执行每个 CRUD 操作的能力,以及适合我们业务需求的任何其他操作。我们将在此接口中添加以下方法:

  • persist:此方法插入新的公司记录

  • merge:此方法更新现有的公司记录

  • remove:这个方法删除公司记录

  • find:这个方法使用主键选择公司记录

  • findAll:这个方法返回所有公司记录

请注意,JPA 术语persistmergeremovefind等同于 SQL 操作insertupdatedeleteselect。按照以下代码将这些方法添加到CompanyDao中:

package com.gieman.tttracker.dao;

import com.gieman.tttracker.domain.Company;
import java.util.List;
public interface CompanyDao {

    public Company find(Integer idCompany);

    public List<Company> findAll();

    public void persist(Company company);

    public Company merge(Company company);

    public void remove(Company company);
}

我们已经定义了实现类必须承诺提供的契约。现在我们将添加ProjectDao接口。

添加 ProjectDao 接口

ProjectDao接口将定义一组类似于CompanyDao接口的方法:

package com.gieman.tttracker.dao;

import com.gieman.tttracker.domain.Company;
import com.gieman.tttracker.domain.Project;
import java.util.List;

public interface ProjectDao {

    public Project find(Integer idProject);

    public List<Project> findAll();

    public void persist(Project project);

    public Project merge(Project project);

    public void remove(Project project);
}

你会注意到ProjectDao接口中的所有方法签名与CompanyDao接口中的完全相同。唯一的区别在于类类型,其中Companyproject替换。在我们将要添加的所有其他接口(TaskDaoUserDaoTaskLogDao)中,情况也是如此。每个接口都需要一个find方法的定义,看起来像下面的代码:

public Company find(Integer idCompany); // in CompanyDao
public Project find(Integer idProject); // in ProjectDao
public Task find(Integer idTask); // in TaskDao
public User find(Integer idUser); // in UserDao
public TaskLog find(Integer idTaskLog); // in TaskLogDao

正如你所看到的,每个方法的唯一功能区别是返回类型。对于persistmergeremove方法也是如此。这种情况非常适合使用 Java 泛型。

定义一个通用的 DAO 接口

这个接口将被我们的每个 DAO 接口扩展。GenericDao接口使用泛型来定义每个方法,以便可以被每个后代接口使用。然后这些方法将免费提供给扩展接口。与在CompanyDaoProjectDaoTaskDaoUserDaoTaskLogDao接口中定义find(Integer id)方法不同,GenericDao接口定义了通用方法,然后这些方法对所有后代接口都可用。

注意

这是一种强大的企业应用程序编程技术,应该在设计或构建应用程序框架时始终考虑。使用 Java 泛型的良好结构设计将简化多年来的变更请求和维护。

通用接口定义如下:

package com.gieman.tttracker.dao;

public interface GenericDao<T, ID> {

    public T find(ID id);

    public void persist(T obj);

    public T merge(T obj);

    public void remove(T obj);
}

我们现在可以按照以下方式重构CompanyDao接口:

package com.gieman.tttracker.dao;

import com.gieman.tttracker.domain.Company;
import java.util.List;

public interface CompanyDao extends GenericDao<Company, Integer>{

    public List<Company> findAll();

}

注意我们如何使用<Company, Integer>类型扩展了GenericDao接口。GenericDao接口中的类型参数<T, ID>成为了CompanyDao定义中指定的类型的占位符。在CompanyDao接口中,GenericDao接口中找到的TID将被替换为CompanyInteger。这会自动将findpersistmergeremove方法添加到CompanyDao中。

泛型允许编译器在编译时检查类型正确性。这提高了代码的健壮性。关于 Java 泛型的良好解释可以在docs.oracle.com/javase/tutorial/extra/generics/index.html找到。

以类似的方式,我们现在可以重构ProjectDao接口:

package com.gieman.tttracker.dao;

import com.gieman.tttracker.domain.Company;
import com.gieman.tttracker.domain.Project;
import java.util.List;

public interface ProjectDao extends GenericDao<Project, Integer>{

    public List<Project> findAll();

}

让我们以相同的方式继续添加缺失的接口。

TaskDao 接口

除了通用的泛型方法,我们还需要一个findAll方法。这个接口看起来像下面的代码:

package com.gieman.tttracker.dao;

import com.gieman.tttracker.domain.Project;
import com.gieman.tttracker.domain.Task;
import java.util.List;

public interface TaskDao extends GenericDao<Task, Integer>{

    public List<Task> findAll();    
}

UserDao 接口

我们需要系统中所有用户的列表,以及一些查找方法来根据不同的参数识别用户。当我们开发前端用户界面和服务层功能时,将需要这些方法。UserDao接口看起来像下面的代码:

package com.gieman.tttracker.dao;

import com.gieman.tttracker.domain.User;
import java.util.List;

public interface UserDao extends GenericDao<User, String> {

    public List<User> findAll();

    public User findByUsernamePassword(String username, String password);

    public User findByUsername(String username);

    public User findByEmail(String email);
}

请注意,UserDao接口使用String ID 类型扩展了GenericDao。这是因为User领域实体具有String主键类型。

TaskLogDao 接口

TaskLogDao接口还需要定义一些额外的方法,以便允许对任务日志数据进行不同的查看。当我们开发前端用户界面和服务层功能时,这些方法将再次被需要。

package com.gieman.tttracker.dao;

import com.gieman.tttracker.domain.Task;
import com.gieman.tttracker.domain.TaskLog;
import com.gieman.tttracker.domain.User;
import java.util.Date;
import java.util.List;

public interface TaskLogDao extends GenericDao<TaskLog, Integer>{

    public List<TaskLog> findByUser(User user, Date startDate, Date endDate);

    public long findTaskLogCountByTask(Task task);

    public long findTaskLogCountByUser(User user);
}

请注意,我们为TaskLogDao接口的查找方法命名具有描述性的名称,以标识方法的目的。每个查找方法将用于检索适合应用程序业务需求的任务日志条目的子集。

这涵盖了我们应用程序所需的所有接口。现在是时候为我们的每个接口定义实现了。

定义通用的 DAO 实现

我们将再次使用 Java 泛型来定义一个通用的祖先类,该类将由我们的每个实现类(CompanyDaoImplProjectDaoImplTaskDaoImplTaskLogDaoImplUserDaoImpl)扩展。GenericDaoImpl和所有其他实现类将被添加到与我们的 DAO 接口相同的com.gieman.tttracker.dao包中。GenericDaoImpl中的关键代码行已经突出显示,并将在接下来的章节中进行解释:

package com.gieman.tttracker.dao;

import java.io.Serializable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

public class GenericDaoImpl<T, ID extends Serializable> implements GenericDao<T, ID> {

    final protected Logger logger = LoggerFactory.getLogger(this.getClass());    

    @PersistenceContext(unitName = "tttPU")
    protected EntityManager em;

    private Class<T> type;

    public GenericDaoImpl(Class<T> type1) {
        this.type = type1;
    }

    @Override
    @Transactional(readOnly = true, propagation = Propagation.SUPPORTS)
  public T find(ID id) {
        return (T) em.find(type, id);
    }

    @Override
    @Transactional(readOnly = false, propagation = Propagation.REQUIRED)
    public void persist(T o) {
      em.persist(o);
    }

    @Override
    @Transactional(readOnly = false, propagation = Propagation.REQUIRED)
    public T merge(T o) {

          o = em.merge(o);
      return o;
    }
    @Override
    @Transactional(readOnly = false, propagation = Propagation.REQUIRED)
    public void remove(T o) {

        // associate object with persistence context
        o = merge(o);
        em.remove(o);

    }    
}

这个类中有很多新概念!让我们一次解决一个。

Java 的简单日志门面

Java 的简单日志门面或 SLF4J 是对关键日志框架(包括java.util.logginglog4jlogback)的简单抽象。SLF4J 允许最终用户在部署时通过简单地包含适当的实现库来插入所需的日志记录框架。有关 SLF4J 的更多信息可以在slf4j.org/manual.html找到。日志记录不仅允许开发人员调试代码,还可以提供应用程序内部操作和状态的永久记录。应用程序状态的示例可能是当前内存使用情况、当前已经登录的授权用户数量或等待处理的挂起消息数量。在分析生产错误时,日志文件通常是首要查看的地方,它们是任何企业应用程序的重要组成部分。

尽管默认的 Java 日志记录对于简单的用途已经足够,但对于更复杂的应用程序来说就不合适了。log4J框架(logging.apache.org/log4j/1.2)和logback框架(logback.qos.ch)是高度可配置的日志记录框架的例子。logback框架通常被认为是log4j的继任者,因为它在性能、内存消耗和配置文件的自动重新加载等方面都比log4j具有一些关键优势。我们将在我们的应用程序中使用logback

通过将以下依赖项添加到pom.xml中,所需的 SLF4J 和logback库将被添加到应用程序中:

  <dependency>
   <groupId>ch.qos.logback</groupId>
   <artifactId>logback-classic</artifactId>
   <version>${logback.version}</version>
  </dependency>

您还需要将额外的logback.version属性添加到pom.xml中:

 <properties>
  <endorsed.dir>${project.build.directory}/endorsed</endorsed.dir>
  <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  <spring.version>3.2.4.RELEASE</spring.version>
 <logback.version>1.0.13</logback.version>
 </properties>

您现在可以执行清理和构建项目以下载logback-classiclogback-coreslf4j-api JAR 文件。这将使我们能够添加GenericDaoImpl中定义的导入以及日志记录器定义:

final protected Logger logger = LoggerFactory.getLogger(this.getClass());

所有后代类现在都可以使用日志记录器(它被声明为protected),但不能更改它(它被声明为final)。我们将在第五章中开始使用日志记录器,使用 Spring 和 JUnit 测试 DAO 层,在那里我们将详细检查logback.xml配置文件。

@PersistenceContext(unitName = "tttPU")行

这一行注释EntityManager接口方法是 Spring 框架在运行时插入或注入EclipseLink实现所需的全部。EntityManager接口定义了与持久化上下文交互的方法,如persistmergeremovefindEntityManager接口方法的完整列表可以在docs.oracle.com/javaee/7/api/javax/persistence/EntityManager.html找到。

我们的持久化上下文在persistence.xml中定义,我们将其命名为tttPU。这是将GenericDaoImpl中的EntityManager与持久化上下文绑定的方式,通过@PersistenceContext注解的unitName属性。持久化上下文是一组实体实例(在我们的应用程序中,这些是CompanyProjectTaskUserTaskLog对象),对于任何持久实体,都有一个唯一的实体实例。在持久化上下文中,实体实例及其生命周期是受管理的。

EntityManager API 用于创建和删除持久化实体实例,按主键查找实体,以及对实体进行查询。在我们的GenericDaoImpl类中,EntityManager实例em用于执行通用的 CRUD 操作。因此,每个子类都将可以访问这些方法以及em实例本身(它被声明为 protected)。

@Transactional注解

@Transactional注解是 Spring 声明式事务管理的基石。它允许您在单个方法级别指定事务行为,并且非常简单易用。这个选项对应用程序代码的影响最小,不需要任何复杂的配置。事实上,它完全是非侵入性的,因为不需要 Java 编码来进行提交和回滚。

Spring 建议只对类(和类的方法)使用@Transactional注解,而不是对接口进行注解(完整的解释可以在static.springsource.org/spring/docs/3.2.x/spring-framework-reference/html/transaction.html找到)。因此,我们将对通用和实现类中的所有适当方法使用以下之一的注解:

@Transactional(readOnly = false, propagation = Propagation.REQUIRED)
@Transactional(readOnly = true, propagation = Propagation.SUPPORTS)

@Transactional注解是指定方法必须具有事务语义的元数据。例如,我们可以定义元数据,定义在调用此方法时启动全新的只读事务,挂起任何现有事务。默认的@Transactional设置如下:

    • propagation设置为Propagation.REQUIRED
    • readOnly为 false

定义所有属性,包括默认设置,是一个好习惯,就像我们之前做的那样。让我们详细地检查这些属性。

Propagation.REQUIRED 属性

  • 默认值为不指定propagation设置的事务。如果存在当前事务,则支持此属性,如果不存在事务,则创建一个新的事务。这确保了Propagation.REQUIRED注解的方法始终有一个有效的事务可用,并且应该在持久化存储中修改数据时使用。这个属性通常与readOnly=false结合使用。

- Propagation.SUPPORTS 属性

如果存在当前事务,则支持此属性,如果不存在事务,则以非事务方式执行。如果注解的方法不修改数据(不会对数据库执行 insert、update 或 delete 语句),则应该使用Propagation.SUPPORTS属性。这个属性通常与readOnly=true结合使用。

readOnly 属性

这只是一个提示,用于实际事务子系统,以便在可能的情况下优化执行的语句。可能事务管理器无法解释此属性。然而,对于自我记录的代码来说,包含此属性是一个很好的做法。

其他事务属性

Spring 允许我们使用额外的选项来微调事务属性,这超出了本书的范围。浏览之前提到的链接,了解更多关于如何在更复杂的情况下管理事务的信息,包括多个事务资源。

定义 DAO 实现

以下 DAO 实现将从GenericDaoImpl继承核心 CRUD 操作,并根据实现的接口添加自己的特定于类的方法。每个方法将使用@Transactional注解来定义适当的事务行为。

CompanyDaoImpl

我们的CompanyDaoImpl类的完整列表如下:

package com.gieman.tttracker.dao;

import com.gieman.tttracker.domain.Company;
import java.util.List;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

@Repository("companyDao")
@Transactional
public class CompanyDaoImpl extends GenericDaoImpl<Company, Integer> 
    implements CompanyDao {

    public CompanyDaoImpl() {
        super(Company.class);
    }

    @Override
    @Transactional(readOnly = true, propagation = Propagation.SUPPORTS)
    public List<Company> findAll() {
        return em.createNamedQuery("Company.findAll")
                .getResultList();
    }    
}

首先要注意的是@Repository("companyDao")注解。这个注解被 Spring 用来在应用程序加载时自动检测和处理 DAO 对象。Spring API 将这个注解定义如下:

注意

它表示一个带注解的类是一个Repository,最初由领域驱动设计(Evans, 2003)定义为一种模拟对象集合的存储、检索和搜索行为的机制。

注解的目的是允许 Spring 通过classpath扫描自动检测实现类,并处理该类以进行数据访问异常转换(Spring 用于将数据库异常消息从底层实现中抽象出来)。Spring 应用程序将持有实现类的引用,键为companyDao。最佳实践是将键值与实现的接口名称匹配。

CompanyDaoImpl类还引入了在上一章的反向工程过程中定义的 JPA 命名查询的使用。方法调用em.createNamedQuery("Company.findAll")创建了持久化引擎中由唯一标识符"Company.findAll"定义的命名查询。这个命名查询是在Company类中定义的。调用getResultList()执行了针对数据库的查询,返回了一个java.util.List的 Company 对象。现在让我们来审查一下Company类中的命名查询定义:

@NamedQuery(name = "Company.findAll", query = "SELECT c FROM Company c")

我们将对这个命名查询进行微小的更改,以按照companyName的升序排列结果。这将需要在查询语句中添加ORDER BY子句。Company类中的最终命名查询定义现在看起来像以下代码:

@NamedQueries({
    @NamedQuery(name = "Company.findAll", query = "SELECT c FROM Company c ORDER BY c.companyName ASC "),
    @NamedQuery(name = "Company.findByIdCompany", query = "SELECT c FROM Company c WHERE c.idCompany = :idCompany"),
    @NamedQuery(name = "Company.findByCompanyName", query = "SELECT c FROM Company c WHERE c.companyName = :companyName")})

ProjectDaoImpl

这个实现被定义为:

package com.gieman.tttracker.dao;

import com.gieman.tttracker.domain.Company;
import com.gieman.tttracker.domain.Project;
import java.util.List;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

@Repository("projectDao")
@Transactional
public class ProjectDaoImpl extends GenericDaoImpl<Project, Integer> 
    implements ProjectDao {

    public ProjectDaoImpl() {
        super(Project.class);
    }

    @Override
    @Transactional(readOnly = true, propagation = Propagation.SUPPORTS)
    public List<Project> findAll() {
        return em.createNamedQuery("Project.findAll")
                .getResultList();
    }    
}

再次,我们将在Project类的Project.findAll命名查询中添加ORDER BY子句:

@NamedQuery(name = "Project.findAll", query = "SELECT p FROM Project p ORDER BY p.projectName")

TaskDaoImpl

这个类被定义为:

package com.gieman.tttracker.dao;

import com.gieman.tttracker.domain.Project;
import com.gieman.tttracker.domain.Task;
import java.util.List;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

@Repository("taskDao")
@Transactional
public class TaskDaoImpl extends GenericDaoImpl<Task, Integer> implements TaskDao {

    public TaskDaoImpl() {
        super(Task.class);
    }

    @Override
    @Transactional(readOnly = true, propagation = Propagation.SUPPORTS)
    public List<Task> findAll() {
        return em.createNamedQuery("Task.findAll")
                .getResultList();
    }
}

再次,我们将在Task类的Task.findAll命名查询中添加ORDER BY子句:

@NamedQuery(name = "Task.findAll", query = "SELECT t FROM Task t ORDER BY t.taskName")

UserDaoImpl

这个UserDaoImpl类将需要在User领域类中添加一个额外的命名查询,以测试用户的登录凭据(用户名/密码组合)。UserDaoImpl类的定义如下:

package com.gieman.tttracker.dao;

import com.gieman.tttracker.domain.User;
import java.util.List;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

@Repository("userDao")
@Transactional
public class UserDaoImpl extends GenericDaoImpl<User, String> implements UserDao {

    public UserDaoImpl() {
        super(User.class);
    }

    @Override
    @Transactional(readOnly = true, propagation = Propagation.SUPPORTS)
    public List<User> findAll() {
        return em.createNamedQuery("User.findAll")
                .getResultList();
    }

    @Override
    @Transactional(readOnly = true, propagation = Propagation.SUPPORTS)
    public User findByUsernamePassword(String username, String password) {

        List<User> users = em.createNamedQuery("User.findByUsernamePassword")
                .setParameter("username", username)
                .setParameter("password", password)
                .getResultList();

        return (users.size() == 1 ? users.get(0) : null);
    }    

    @Override
    @Transactional(readOnly = true, propagation = Propagation.SUPPORTS)
    public User findByUsername(String username) {
        List<User> users = em.createNamedQuery("User.findByUsername")
                .setParameter("username", username)
                .getResultList();

        return (users.size() == 1 ? users.get(0) : null);
    }    

    @Override
    @Transactional(readOnly = true, propagation = Propagation.SUPPORTS)
    public User findByEmail(String email) {

        List<User> users = em.createNamedQuery("User.findByEmail")
                .setParameter("email", email)
                .getResultList();

        return (users.size() == 1 ? users.get(0) : null);
    }    
}

缺少的命名查询是User.findByUsernamePassword,用于验证具有给定用户名和密码的用户。查询定义必须添加到User类中,如下所示:

@NamedQuery(name = "User.findByUsernamePassword", query = "SELECT u FROM User u WHERE u.password = :password AND (u.email = :username OR u.username = :username)")

请注意,这个定义允许用户通过用户名或电子邮件字段进行匹配。正如在 Web 应用程序中的常见做法一样,用户可以使用他们的唯一登录名(用户名)或他们的电子邮件地址进行登录。

findByEmailfindByUsernamefindByUsernamePassword方法只能返回null(未找到匹配)或单个结果,因为数据库中这些唯一字段不可能有多条记录。我们可以使用类似以下的代码来代替使用getResultList()方法来检索结果列表并测试列表大小是否为一:

public User findByEmail(String email) {

  User user = (User) em.createNamedQuery("User.findByEmail")
      .setParameter("email", email)
      .getSingleResult();

  return user;
}

getSingleResult()方法返回确切的一个结果,如果找不到单个结果,则会抛出异常。您还会注意到需要将返回的结果转换为所需的User类型。调用方法还需要捕获从getSingleResult()方法抛出的任何异常,除非之前给出的示例代码更改为捕获异常。

public User findByEmail(String email) {

  User user = null;

  try {
    user = (User) em.createNamedQuery("User.findByEmail")
      .setParameter("email", email)
      .getSingleResult();

  } catch(NoResultException nre){

 }
  return user;
}

我们相信我们的UserDaoImpl接口中的代码比使用try/catch函数包装getSingleResult()方法的先前示例更清晰。然而,在两种情况下,如果找不到记录,该方法都会返回null

注意

在企业编程中应谨慎使用异常,只能在真正的异常情况下使用。除非异常表示调用代码无法恢复的情况,否则应避免抛出异常。如果情况不如预期,返回null(或者在适当的情况下返回 true/false)会更清晰。

我们不认为无法按 ID 或电子邮件或电子邮件地址找到记录是一个异常情况;可能是不同的用户已删除了记录,或者根本没有使用指定电子邮件的记录。返回null清楚地表明未找到记录,而无需抛出异常。

无论您是抛出异常来指示找不到记录,还是使用null作为我们的首选,您的 API 都应该记录下行为。例如,UserDaoImpl.findByUsernamePassword方法可以记录如下:

/**
 * Find a User with the username/password combination or return null
 * if a valid user could not be found.
 * @param username
 * @param password
 * @return valid User object or null if not found.
 */

您的 API 的用户将了解预期的行为并相应地编写其交互。

TaskLogDaoImpl

我们应用程序中的最终 DAO 类如下:

package com.gieman.tttracker.dao;

import com.gieman.tttracker.domain.Task;
import com.gieman.tttracker.domain.TaskLog;
import com.gieman.tttracker.domain.User;
import java.util.Date;
import java.util.List;
import javax.persistence.TemporalType;

public class TaskLogDaoImpl extends GenericDaoImpl<TaskLog, Integer> implements TaskLogDao {

    public TaskLogDaoImpl() {
        super(TaskLog.class);
    }

    @Override
    public List<TaskLog> findByUser(User user, Date startDate, Date endDate) {
        return em.createNamedQuery("TaskLog.findByUser")
                .setParameter("user", user)
                .setParameter("startDate", startDate, TemporalType.DATE)
                .setParameter("endDate", endDate, TemporalType.DATE)
                .getResultList();
    }

    @Override
    public long findTaskLogCountByTask(Task task) {
        Long count = (Long) em.createNamedQuery("TaskLog.findTaskLogCountByTask")
                .setParameter("task", task)
                .getSingleResult();
        return count;
    }

    @Override
    public long findTaskLogCountByUser(User user) {
        Long count = (Long) em.createNamedQuery("TaskLog.findTaskLogCountByUser")
                .setParameter("user", user)
                .getSingleResult();

        return count;
    }
}

这一次,我们将重构TaskLog命名查询如下:

@NamedQueries({
    @NamedQuery(name = "TaskLog.findByUser", query = "SELECT tl FROM TaskLog tl WHERE tl.user = :user AND tl.taskLogDate BETWEEN :startDate AND :endDate order by tl.taskLogDate ASC"),
    @NamedQuery(name = "TaskLog.findTaskLogCountByTask", query = "SELECT count(tl) FROM TaskLog tl WHERE tl.task = :task "),
    @NamedQuery(name = "TaskLog.findTaskLogCountByUser", query = "SELECT count(tl) FROM TaskLog tl WHERE tl.user = :user ")
})

我们已删除几个不需要的查询,并添加了三个新的查询,如所示。TaskLog.findByUser查询将用于列出分配给用户的任务日志的给定日期范围。请注意在TaskLogDaoImpl.findByUser方法中设置参数时,使用TemporalType.DATE来确保严格的日期比较,忽略任何时间组件(如果存在)。

TaskLog.findTaskLogCountByTaskTaskLog.findTaskLogCountByUser命名查询将在我们的服务层中用于测试是否允许删除。我们将实施检查以确保如果分配了有效的任务日志,则用户或任务可能不会被删除。

更好的领域层

让我们现在重新审视在第三章中创建的领域层,使用 JPA 逆向工程领域层。为这一层中的所有实体定义一个祖先类不仅是最佳实践,而且还将使我们的领域层在未来更容易增强。我们的祖先类定义如下:

package com.gieman.tttracker.domain;

import java.io.Serializable;

public abstract class AbstractEntity implements Serializable{

}

尽管这个类有一个空的实现,但我们将在随后的章节中添加功能。

我们还将定义一个适当的接口,该接口具有一个通用方法来返回实体的 ID:

package com.gieman.tttracker.domain;

public interface EntityItem<T> {

    public T getId();

}

我们的领域层现在可以扩展我们的基本AbstractEntity类并实现EntityItem接口。对我们的Company类所需的更改如下:

public class Company extends AbstractEntity implements EntityItem<Integer> {

// many more lines of code here

 @Override
 public Integer getId() {
 return idCompany;
 } 
}

以类似的方式,我们可以更改剩余的领域类:

public class Project extends AbstractEntity implements EntityItem<Integer> {

// many more lines of code here

 @Override
 public Integer getId() {
 return idProject;
 } 
}
public class Task extends AbstractEntity implements EntityItem<Integer> {

// many more lines of code here

 @Override
 public Integer getId() {
 return idTask;
 } 
}
public class User extends AbstractEntity implements EntityItem<String> {

// many more lines of code here

 @Override
 public String getId() {
 return username;
 } 
}
public class TaskLog extends AbstractEntity implements EntityItem<Integer> {

// many more lines of code here

 @Override
 public Integer getId() {
 return idTaskLog;
 } 
}

我们现在将为领域层中的未来变更做好充分准备。

练习-一个简单的变更请求

这个简单的练习将再次展示泛型的强大。现在,插入到数据库中的每条记录都应该使用logger.info()记录日志,消息为:

The "className" record with ID=? has been inserted

此外,删除的记录应该使用logger.warn()记录日志,消息为:

The "className" record with ID=? has been deleted

在这两种情况下,?标记应该被插入或删除的实体的 ID 替换,而className标记应该被插入或删除的实体的类名替换。使用泛型时,这是一个微不足道的改变,因为这段代码可以添加到GenericDaoImpl类的persistremove方法中。如果不使用泛型,每个CompanyDaoImplProjectDaoImplTaskDaoImplUserDaoImplTaskLogDaoImpl类都需要进行这个改变。考虑到企业应用程序可能在 DAO 层中表示 20、30、40 个或更多的表,这样一个微不足道的改变在没有使用泛型的情况下可能并不那么微不足道。

您的任务是按照之前概述的实现更改请求。请注意,这个练习将向您介绍instanceof运算符。

总结

本章介绍了数据访问对象设计模式,并定义了一组接口,这些接口将在我们的 3T 应用程序中使用。DAO 设计模式清楚地将持久层操作与应用程序的业务逻辑分离开来。正如将在下一章中介绍的那样,这种清晰的分离确保了数据访问层易于测试和维护。

我们还介绍了 Java 泛型作为一种简化应用程序设计的技术,通过将通用功能移动到祖先。GenericDao接口和GenericDaoImpl类定义并实现了将免费提供给扩展组件的方法。我们的实现还介绍了 SLF4J、事务语义和使用 JPA 命名查询。

我们的旅程现在将继续进行,第五章,使用 Spring 和 JUnit 测试 DAO 层,在那里我们将配置一个测试环境,并为我们的 DAO 实现开发测试用例。

第五章:使用 Spring 和 JUnit 测试 DAO 层

每个人都会同意软件测试应该是开发过程的一个基本部分。彻底的测试将确保业务需求得到满足,软件按预期工作,并且缺陷在客户发现之前被发现。尽管测试永远无法完全识别所有错误,但普遍认为,问题被发现得越早,修复成本就越低。在开发过程中修复代码块中的NullPointerException要比系统部署到客户的生产服务器后修复要快得多。在开发企业系统时,交付高质量代码变得更加关键。您公司的声誉岌岌可危;在交付之前识别和修复问题是使测试成为开发生命周期的关键部分的一个重要原因。

有许多不同类型的测试,包括但不限于单元测试、集成测试、回归测试、黑盒/白盒测试和验收测试。每种测试策略都可能值得一章来详细讨论,但超出了本书的范围。关于软件测试的一篇优秀文章可以在这里找到:en.wikipedia.org/wiki/Software_testing。我们将专注于单元测试

单元测试概述

单元测试是测试源代码离散单元的策略。从程序员的角度来看,一个单元是应用程序中最小的可测试部分。源代码的一个单元通常被定义为可在应用程序中调用并具有特定目的的公共方法。DAO 层的单元测试将确保每个公共方法至少有一个适当的测试用例。实际上,我们需要比每个公共方法只有一个测试用例更多的测试用例。例如,每个 DAO 的find(ID)方法都需要至少两个测试用例:一个返回有效找到对象的结果,一个返回未找到有效对象的结果。因此,对于每行代码编写,开发人员通常需要编写几行测试代码。

单元测试是一门需要时间来掌握的艺术。我们的目标是建立一组尽可能多地覆盖各种场景的测试。这与我们作为开发人员试图实现的目标恰恰相反,我们的目标是确保任务按照精确的功能要求执行。考虑以下业务需求:将成本价值以分为单位,并根据当天的汇率转换为欧元等值。

解决方案可能看起来很简单,但如果汇率不可用会发生什么?或者日期是在未来?或者成本价值为空?如果无法计算值,预期的行为是什么?这些都是应该在制定测试用例时考虑的有效场景。

通过单元测试,我们定义程序应该如何行为。每个单元测试应该讲述程序的一部分在特定场景下应该如何行为的一个清晰的故事。这些测试成为了一个合同,描述了在各种可重现的条件下,从客户端代码的角度来看应该发生什么。

单元测试的好处

单元测试让我们确信我们编写的代码是正确的。单元测试过程还鼓励我们思考我们的代码将如何使用以及需要满足什么条件。其中包括许多好处:

  • 早期识别问题:单元测试将有助于在开发生命周期的早期识别编码问题,这时修复问题要容易得多。

  • 更高的质量:我们不希望客户发现错误,导致停机和昂贵的发布周期。我们希望构建尽可能少 bug 的软件。

  • 信心:开发人员不愿触碰脆弱的代码。经过充分测试的代码和可靠的测试用例可以让人放心地处理。

  • 回归测试:测试用例随应用程序一起构建和演变。增强和新功能可能会悄无声息地破坏旧代码,但良好编写的测试套件将在识别这种情况方面发挥重要作用。

企业应用程序,许多程序员在不同模块上进行并行开发,甚至更容易受到影响。如果不及早发现,编码副作用可能会导致深远的后果。

注意

一个辅助方法用于修剪作为参数传递的 Java 字符串。如果参数为 null,则对其进行测试,并且如果是这种情况,则该方法将返回一个空字符串" "。该辅助方法在应用程序的各个地方都被使用。有一天,开发人员将辅助方法更改为如果传入参数为 null 则返回 null(他们需要区分 null 和空字符串)。一个简单的测试用例将确保此更改不会被提交到版本控制中。在使用应用程序时出现的大量空指针异常令人惊讶!

配置测试环境

我们的单元测试策略是创建一组可以在开发生命周期的任何时候以自动方式运行的测试用例。 "自动"意味着不需要开发人员交互;测试可以作为构建过程的一部分运行,不需要用户输入。整个过程通过 Maven、JUnit 和 Spring 无缝管理。Maven 约定期望在src目录下有一个测试目录结构,其中包含测试资源和 Java 测试用例的子目录,如下面的屏幕截图所示:

配置测试环境

请注意,Maven 对源代码和测试布局都使用相同的目录结构。执行测试用例所需的所有资源都将在src/test/resources目录中找到。同样,部署所需的所有资源都将在src/main/resources目录中找到。 "约定优于配置"范式再次减少了开发人员需要做出的决策数量。只要遵循这个目录结构,基于 Maven 的测试将无需任何进一步的配置即可工作。如果您尚未拥有此目录结构,则需要通过右键单击所需的文件夹来手动创建它:

配置测试环境

添加目录结构后,我们可以创建如下的单个文件:

配置测试环境

我们将首先使用 NetBeans 创建jdbc.properties文件。

jdbc.properties 文件

右键单击test/resources文件夹,导航到新建 | 其他。将打开新建文件向导,在其中可以从类别属性文件中选择其他,如下所示:

jdbc.properties 文件

选择下一步,并输入jdbc作为文件名:

jdbc.properties 文件

单击完成按钮创建jdbc.properties文件。NetBeans 将在编辑器中打开文件,您可以添加以下代码:

jdbc.properties 文件

jdbc.properties文件用于定义数据库连接详细信息,Spring 将使用这些信息来配置我们的 DAO 层进行单元测试。企业项目通常有一个或多个专用的测试数据库,这些数据库预先填充了适用于所有测试场景的适当数据。我们将使用在第二章中生成和填充的数据库,任务时间跟踪器数据库

logback.xml 文件

通过使用新建文件向导XML类别创建此文件,如下所示:

logback.xml 文件

创建logback.xml文件后,您可以输入以下内容:

<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="true" scanPeriod="30 seconds" >
    <contextName>TaskTimeTracker</contextName>
    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{5} - %msg%n</pattern>
        </encoder>
    </appender>

    <logger name="com.gieman.tttracker" level="DEBUG" additivity="false">
        <appender-ref ref="STDOUT" />
    </logger>
    <logger name="com.gieman.tttracker.dao" level="DEBUG" additivity="false">
        <appender-ref ref="STDOUT" />
    </logger>
    <logger name="com.gieman.tttracker.domain" level="DEBUG" additivity="false">
        <appender-ref ref="STDOUT" />
    </logger>
    <logger name="com.gieman.tttracker.service" level="DEBUG" additivity="false">
        <appender-ref ref="STDOUT" />
    </logger>
    <logger name="com.gieman.tttracker.web" level="DEBUG" additivity="false">
        <appender-ref ref="STDOUT" />
    </logger>

    <root level="INFO">          
        <appender-ref ref="STDOUT" />
    </root>
</configuration>

对于熟悉 log4j 的人来说,logback 记录器定义的语法非常相似。我们将根日志级别设置为INFO,这将覆盖所有未明确定义的记录器(请注意,默认级别为DEBUG,但这通常会导致根级别的广泛记录)。每个名称与com.gieman.tttracker包匹配的单独记录器都设置为DEBUG级别。这种配置为我们提供了在包级别上灵活控制记录属性的能力。在生产环境中,我们通常会为所有记录器部署WARN级别以最小化记录。如果遇到问题,我们将有选择地在不同的包中启用记录以帮助识别任何问题。与 log4j 不同,由于 logback 的scan="true" scanPeriod="30 seconds"选项在<configuration>节点中,可以动态重新加载记录器属性。有关 logback 配置的更多信息,请参见:logback.qos.ch/manual/configuration.html

test-persistence.xml 文件

按照前一节中概述的新建文件步骤创建test-persistence.xml文件。输入以下持久化上下文定义:

<?xml version="1.0" encoding="UTF-8"?>
<persistence version="2.1"   xsi:schemaLocation="http://java.sun.com/xml/ns/persistence http://java.sun.com/xml/ns/persistence/persistence_2_1.xsd">
  <persistence-unit name="tttPU" transaction-type="RESOURCE_LOCAL">
    <provider>org.eclipse.persistence.jpa.PersistenceProvider</provider>
    <class>com.gieman.tttracker.domain.Company</class>
    <class>com.gieman.tttracker.domain.Project</class>
    <class>com.gieman.tttracker.domain.Task</class>
    <class>com.gieman.tttracker.domain.TaskLog</class>
    <class>com.gieman.tttracker.domain.User</class>
    <exclude-unlisted-classes>true</exclude-unlisted-classes>
    <properties>
      <property name="eclipselink.logging.level" value="WARNING"/>
    </properties>
  </persistence-unit>
</persistence>

这个持久化单元定义与第三章中创建的定义略有不同,使用 JPA 反向工程领域层

<?xml version="1.0" encoding="UTF-8"?>
<persistence version="2.1"   xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/persistence http://xmlns.jcp.org/xml/ns/persistence/persistence_2_1.xsd">
  <persistence-unit name="tttPU" transaction-type="JTA">
    <provider>org.eclipse.persistence.jpa.PersistenceProvider</provider>
    <jta-data-source>jdbc/tasktimetracker</jta-data-source>
    <exclude-unlisted-classes>false</exclude-unlisted-classes>
    <properties/>
  </persistence-unit>
</persistence>

请注意,测试persistence-unit的事务类型是RESOURCE_LOCAL,而不是JTA。我们的测试环境使用本地(由 Spring 管理的)事务管理器,而不是我们的 GlassFish 服务器容器提供的事务管理器(即JTA)。在两种情况下,tttPU持久化单元名称与GenericDaoImpl中的EntityManager字段的@PersistenceContext unitName注解匹配:

@PersistenceContext(unitName = "tttPU")
protected EntityManager em;

第二个区别是类的发现方式。在测试期间,我们的域实体被明确列出,并排除任何未定义的类。这简化了处理,并确保仅加载测试所需的实体而不扫描类路径。这对于 Windows 用户来说是一个重要的问题;在某些 Windows 版本中,命令行语句的长度有限,因此,您可以使类路径参数的长度有限。使用类路径扫描,加载域实体进行测试可能无法正常工作,导致诸如以下的奇怪错误:

org.springframework.dao.InvalidDataAccessApiUsageException: Object: com.tttracker.domain.Company[ idCompany=null ] is not a known entity type.; nested exception is java.lang.IllegalArgumentException: Object: com.tttracker.domain.Company[ idCompany=null ] is not a known entity type.

始终确保您的测试持久化 XML 定义包括应用程序中的所有域类。

介绍 Spring IoC 容器

现代 Spring 框架是一个基于世纪初的架构概念的广泛框架“堆栈”。Spring 框架最初是在 2002 年由Rod JohnsonExpert One-on-One J2EE Design and Development中首次引人注目。Spring 对控制反转IoC)原则的实现,有时也被称为依赖注入DI),是企业应用设计和开发的突破。Spring IoC 容器提供了一种简单的配置对象(JavaBeans)和通过构造函数参数、工厂方法、对象属性或 setter 方法注入依赖项的方式。我们已经在 DAO 层中看到了@PersistenceContext注解,该注解由 Spring 用于确定是否应将EntityManager对象注入GenericDaoImpl类中。可用的复杂配置选项使 Spring 框架成为企业开发的非常灵活的基础。

本书的范围超出了 Spring 框架配置的基础知识,这是我们项目需求所必需的。但是,我们建议您浏览有关 IoC 容器如何工作的详细描述,以增进对核心原则的了解。

探索 testingContext.xml 文件

这是 Spring 用来配置和加载 IoC bean 容器的主要配置文件。自从一开始,基于 XML 的配置一直是配置 Spring 应用程序的默认方式,但是在 Spring 3 框架中,可以使用基于 Java 的配置。这两种选项都可以实现相同的结果-一个完全配置的 Spring 容器。我们将使用 XML 方法,因为它不需要任何 Java 编码,而且更直观和简单。

注意

多年来已经有许多关于 Spring XML 配置的“复杂性”的文章。在 Java 1.5 之前和注解引入之前,可能会有这样的评论。配置文件对新用户来说既冗长又令人望而却步。但现在不再是这种情况。使用 XML 配置 Spring 容器现在是一个微不足道的过程。对于任何告诉你相反的人要小心!

testingContext.xml配置文件完全定义了测试 DAO 层所需的 Spring 环境。完整的文件清单如下:

<?xml version="1.0" encoding="UTF-8"?>
<beans  

       xsi:schemaLocation="
       http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-3.0.xsd
       http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-3.0.xsd
        http://www.springframework.org/schema/tx http://www.springframework.org/schema/tx/spring-tx-3.0.xsd">

    <bean id="propertyConfigurer"
          class="org.springframework.beans.factory.config.PropertyPlaceholderConfigurer"
          p:location="classpath:jdbc.properties" />

    <bean id="tttDataSource"
          class="org.springframework.jdbc.datasource.DriverManagerDataSource"
          p:driverClassName="${jdbc.driverClassName}"
          p:url="${jdbc.url}"
          p:username="${jdbc.username}"
          p:password="${jdbc.password}"/>

    <bean id="loadTimeWeaver" class="org.springframework.instrument.classloading.InstrumentationLoadTimeWeaver" />

    <bean id="jpaVendorAdapter" class="org.springframework.orm.jpa.vendor.EclipseLinkJpaVendorAdapter"
        p:showSql="true"
        p:databasePlatform="org.eclipse.persistence.platform.database.MySQLPlatform" />
    <bean id="entityManagerFactory" class="org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean"
        p:dataSource-ref="tttDataSource"
        p:jpaVendorAdapter-ref="jpaVendorAdapter"
        p:persistenceXmlLocation="test-persistence.xml"
    />

    <!-- Transaction manager for a single JPA EntityManagerFactory (alternative to JTA) -->
    <bean id="transactionManager" class="org.springframework.orm.jpa.JpaTransactionManager"
          p:dataSource-ref="tttDataSource" 
          p:entityManagerFactory-ref="entityManagerFactory"/>

    <!-- checks for annotated configured beans -->
    <context:annotation-config/>  

    <!-- Scan for Repository/Service annotations -->
    <context:component-scan base-package="com.gieman.tttracker.dao" />

    <!-- enable the configuration of transactional behavior based on annotations -->
    <tx:annotation-driven />

</beans>

让我们详细看看每个部分。

Spring XML 命名空间

对于那些不熟悉 XML 的人,可以简单地忽略xmlns定义和模式位置 URL。将它们视为配置文件中提供验证条目能力的“快捷方式”或“限定符”。Spring 了解在加载 Spring 环境的上下文中<tx:annotation-driven />的含义。

每个 Spring 应用程序配置文件将具有多个命名空间声明,具体取决于应用程序所需的资源。除了命名空间之外,定义模式位置还将允许 NetBeans 提供有关配置选项的有用提示:

Spring XML 命名空间

对于新手来说,不同命名空间的有效属性列表非常有用。

属性文件配置

以下 bean 加载jdbc.properties文件并使其在配置文件中可用:

<bean id="propertyConfigurer"
    class="org.springframework.beans.factory.config.PropertyPlaceholderConfigurer"
    p:location="classpath:jdbc.properties" />

然后可以在testingContext.xml文件的任何地方使用${}语法来替换令牌为所需的jdbc属性。

创建 JDBC DataSource

DAO 测试需要连接到 MySQL 数据库。以下 Spring bean 定义实例化并提供了一个完全配置的 DataSource:

<bean id="tttDataSource"
    class="org.springframework.jdbc.datasource.DriverManagerDataSource"
    p:driverClassName="${jdbc.driverClassName}"
    p:url="${jdbc.url}"
    p:username="${jdbc.username}"
    p:password="${jdbc.password}"
    />

占位符将自动设置为从jdbc.properties文件加载的属性:

jdbc.driverClassName=com.mysql.jdbc.Driver
jdbc.url=jdbc:mysql://localhost:3306/task_time_tracker
jdbc.username=root
jdbc.password=adminadmin

这个非常简单的 Spring 配置片段可以替换许多等效的 Java 代码行,如果我们必须自己实现 DataSource 实例化。请注意,要更改任何数据库属性以进行不同的测试场景,或者例如,甚至将数据库服务器从 MySQL 更改为 Oracle 将是多么简单。这种灵活性使 Spring IoC 容器在企业使用中非常强大。

您应该注意org.springframework.jdbc.datasource.DriverManagerDataSource只应用于测试目的,不适用于生产环境。GlassFish 服务器将为生产使用提供连接池DataSource

定义辅助 bean

loadTimeWeaverjpaVendorAdapter bean 定义有助于配置用于加载持久性上下文的entityManagerFactory bean。请注意我们如何使用特定的 Spring bean 类来标识数据库平台(MySQL)和 JPA 实现(EclipseLink):

<bean id="jpaVendorAdapter" 
class="org.springframework.orm.jpa.vendor.EclipseLinkJpaVendorAdapter"
  p:showSql="true"        
  p:databasePlatform="org.eclipse.persistence.platform.database.MySQLPlatform" />

Spring 提供了大量的数据库和 JPA 实现,可以在 NetBeans 中使用自动完成时看到(在 NetBeans 中使用Ctrl +空格组合键触发自动完成选项):

定义辅助 bean

辅助 bean 用于定义特定于实现的属性。非常容易为不同的企业环境切换实现策略。例如,开发人员可以在本地环境中使用运行在自己环境上的 MySQL 数据库进行开发。生产企业服务器可能使用在不同物理服务器上运行的 Oracle 数据库。只需要对 Spring XML 配置文件进行非常小的更改,就可以为应用程序环境实现这些差异。

定义 EntityManagerFactory 类

这个 Spring bean 定义了EntityManagerFactory类,用于创建和注入EntityManager实例到GenericDaoImpl类中:

<bean id="entityManagerFactory" 
  class="org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean"
  p:dataSource-ref="tttDataSource"
  p:jpaVendorAdapter-ref="jpaVendorAdapter"
  p:persistenceXmlLocation="test-persistence.xml"
/>

这个定义引用了已经配置的tttDataSourcejpaVendorAdapter bean,以及test-persistence.xml持久化上下文定义文件。再一次,Spring 在后台做了大量工作,创建和配置EntityManager实例,并使其可以在我们的代码中使用。

配置事务管理器

用于管理事务的 Spring bean 定义如下:

<bean id="transactionManager" 
  class="org.springframework.orm.jpa.JpaTransactionManager"
  p:dataSource-ref="tttDataSource" 
  p:entityManagerFactory-ref="entityManagerFactory"/>

这个 bean 将tttDataSourceentityManagerFactory实例连接在一起,以启用我们应用程序中的事务行为。这种行为适用于所有带有@Transactional注解的类;在我们目前的情况下,这适用于所有的 DAO 对象。当在配置文件中包含以下行时,Spring 会扫描这个注解并为每个带有注解的方法应用事务包装:

<tx:annotation-driven />

哪些类被扫描以寻找@Transactional注解?以下行定义了 Spring 应该扫描com.gieman.tttracker.dao包:

<context:component-scan base-package="com.gieman.tttracker.dao"/>

自动装配 bean

自动装配是 Spring 术语,用于自动将资源注入托管的 bean 中。以下行使能了拥有@Autowired注解的 bean 的自动装配:

<context:annotation-config/>

我们的代码中还没有任何自动装配的注解;下一节将介绍如何使用这个注解。

感谢管道!

当 Spring 容器加载 Spring 配置文件时,将在后台进行大量工作,配置和连接我们应用程序所需的许多支持类。这些繁琐且常常容易出错的“管道”代码已经为我们完成。我们再也不需要提交事务、打开数据库连接或关闭 JDBC 资源。这些低级操作将由 Spring 框架非常优雅地处理。

注意

作为企业应用程序开发人员,我们可以并且应该将大部分时间和精力集中在核心应用程序关注点上:业务逻辑、用户界面、需求、测试,当然还有我们的客户。Spring 确保我们可以专注于这些任务。

为测试启用 Maven 环境

Maven 构建过程包括执行测试套件的功能。现在我们需要将这个功能添加到pom.xml文件中。现有文件的所需更改在以下代码片段中突出显示:

<?xml version="1.0" encoding="UTF-8"?>
<project   xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.gieman</groupId>
    <artifactId>task-time-tracker</artifactId>
    <version>1.0</version>
    <packaging>war</packaging>

    <name>task-time-tracker</name>

    <properties>
        <endorsed.dir>${project.build.directory}/endorsed</endorsed.dir>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <spring.version>3.2.4.RELEASE</spring.version>
        <logback.version>1.0.13</logback.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.eclipse.persistence</groupId>
            <artifactId>javax.persistence</artifactId>
            <version>2.1.0-SNAPSHOT</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>org.eclipse.persistence</groupId>
            <artifactId>eclipselink</artifactId>
            <version>2.5.0-SNAPSHOT</version>
            <scope>provided</scope>
        </dependency>        
        <dependency>
            <groupId>org.eclipse.persistence</groupId>
            <artifactId>org.eclipse.persistence.jpa.modelgen.processor</artifactId>
            <version>2.5.0-SNAPSHOT</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>javax</groupId>
            <artifactId>javaee-web-api</artifactId>
            <version>7.0</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>ch.qos.logback</groupId>
            <artifactId>logback-classic</artifactId>
            <version>${logback.version}</version>
        </dependency>    
 <dependency>
 <groupId>junit</groupId>
 <artifactId>junit</artifactId>
 <version>4.11</version>
 <scope>test</scope>
 </dependency> 
 <dependency>
 <groupId>mysql</groupId>
 <artifactId>mysql-connector-java</artifactId>
 <version>5.1.26</version>
 <scope>provided</scope>
 </dependency> 
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-context</artifactId>
            <version>${spring.version}</version>
        </dependency>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-context-support</artifactId>
            <version>${spring.version}</version>
        </dependency>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-tx</artifactId>
            <version>${spring.version}</version>
        </dependency>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-jdbc</artifactId>
            <version>${spring.version}</version>
        </dependency>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-orm</artifactId>
            <version>${spring.version}</version>
        </dependency>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-instrument</artifactId>
            <version>${spring.version}</version>
        </dependency>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-webmvc</artifactId>
            <version>${spring.version}</version>
        </dependency>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-test</artifactId>
            <version>${spring.version}</version>
   <scope>test</scope>
        </dependency>

    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.1</version>
                <configuration>
                    <source>1.7</source>
                    <target>1.7</target>
                    <compilerArguments>
                        <endorseddirs>${endorsed.dir}</endorseddirs>
                    </compilerArguments>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-war-plugin</artifactId>
                <version>2.3</version>
                <configuration>
                    <warName>${project.build.finalName}</warName>
                    <failOnMissingWebXml>false</failOnMissingWebXml>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-dependency-plugin</artifactId>
                <version>2.6</version>
                <executions>
                    <execution>
                        <id>copy-endorsed</id>
                        <phase>validate</phase>
                        <goals>
                            <goal>copy</goal>
                        </goals>
                        <configuration>
                            <outputDirectory>${endorsed.dir}</outputDirectory>
                            <silent>true</silent>
                            <artifactItems>
                                <artifactItem>
                                    <groupId>javax</groupId>
                                    <artifactId>javaee-endorsed-api</artifactId>
                                    <version>7.0</version>
                                    <type>jar</type>
                                </artifactItem>
                            </artifactItems>
                        </configuration>
                    </execution>
                    <execution>
                        <id>copy-all-dependencies</id>
                        <phase>compile</phase>
                        <goals>
                            <goal>copy-dependencies</goal>
                        </goals>
                        <configuration>
                            <outputDirectory>${project.build.directory}/lib</outputDirectory>
                            <includeScope>compile</includeScope>
                        </configuration>                        
                    </execution>                                                           

                </executions>
            </plugin>
 <plugin>
 <groupId>org.apache.maven.plugins</groupId>
 <artifactId>maven-surefire-plugin</artifactId>
 <version>2.14.1</version>
 <configuration>
 <skipTests>false</skipTests>
 <includes>
 <include>**/dao/*Test.java</include>
 </includes>
 <argLine>-javaagent:target/lib/spring-instrument-${spring.version}.jar</argLine>
 </configuration>
 </plugin> 

        </plugins>
    </build>
    <repositories>
        <repository>
            <url>http://download.eclipse.org/rt/eclipselink/maven.repo/</url>
            <id>eclipselink</id>
            <layout>default</layout>
            <name>Repository for library EclipseLink (JPA 2.1)</name>
        </repository>
    </repositories>
</project>

前两个更改添加了mysql-connector-javajunit依赖项。没有这些依赖项,我们将无法连接到数据库或编写测试用例。这些依赖项将下载适当的 Java 库,以包含到我们的项目中。

最重要的设置在执行实际工作的 Maven 插件中。添加maven-surefire-plugin将允许基于main/src/test目录结构的测试用例执行。这清楚地将测试类与我们的应用程序类分开。这个插件的主要配置属性包括:

  • <skipTests>:此属性可以是true(禁用测试)或false(启用测试)。

  • <includes>:此属性在测试期间包含文件集的列表。设置<include>**/dao/*Test.java</include>指定应加载并包含在测试过程中以Test.java结尾的任何dao子目录中的所有类。您可以指定任意数量的文件集。

  • <argLine>-javaagent:target/lib/spring-instrument-${spring.version}.jar</argLine>:此属性用于配置测试 JVM 的 Java 代理,并且 Spring 需要它来进行类的加载时编织,这超出了本文的讨论范围。

现在我们已经配置了 Spring 和 Maven 测试环境,可以开始编写测试用例了。

定义测试用例超类

第一步是创建一个所有 DAO 测试用例都将继承的超类。这个抽象类看起来像下面的代码片段:

package com.gieman.tttracker.dao;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.AbstractTransactionalJUnit4SpringContextTests;

@ContextConfiguration("/testingContext.xml")
public abstract class AbstractDaoForTesting extends AbstractTransactionalJUnit4SpringContextTests {

    protected final Logger logger = LoggerFactory.getLogger(this.getClass());
    @Autowired(required = true)
    protected CompanyDao companyDao;
    @Autowired(required = true)
    protected ProjectDao projectDao;
    @Autowired(required = true)
    protected TaskDao taskDao;
    @Autowired(required = true)
    protected UserDao userDao;
    @Autowired(required = true)
    protected TaskLogDao taskLogDao;
}

AbstractDaoForTesting类被标记为抽象类,因此不能直接实例化。它提供了对所有子类可访问的成员变量,从而消除了在子类中复制代码的需要。因此,每个子类都将可以访问 DAO 实例以及 SLF4J logger。有两个新的 Spring 注解:

  • @ContextConfiguration:此注解定义了用于加载 bean 容器的 Spring 应用程序上下文。testingContext.xml文件在前几节中已经详细介绍过。

  • @Autowired:这个注解告诉 Spring 容器应该将匹配类型的受管 bean 注入到类中。例如,CompanyDao companyDao的定义将导致 Spring 查询容器中类型为CompanyDao的对象。这种类型只有一个对象:在扫描com.gieman.tttracker.dao包时 Spring 发现并配置的CompanyDaoImpl类,通过testingContext.xml文件中的<context:component-scan base-package="com.gieman.tttracker.dao"/>条目。

最后一个重要的事情是AbstractDaoForTesting类扩展了 Spring 的AbstractTransactionalJUnit4SpringContextTests类。除了类名很长之外,这个类在每个测试方法结束时提供透明的事务回滚。这意味着任何 DAO 测试操作(包括插入、更新或删除)结束时数据库状态将与测试开始时相同。如果不需要这种行为,应该扩展AbstractJUnit4SpringContextTests。在这种情况下,测试数据库操作可以在测试运行后进行检查和确认。还可以在使用AbstractTransactionalJUnit4SpringContextTests时标记单个方法为@Rollback(false)以提交更改。

现在让我们为CompanyDao操作编写我们的第一个测试用例。

定义 CompanyDao 测试用例

每个CompanyDao方法应该至少有一个测试方法。我们将为每个实现的CompanyDao方法包含一个测试方法。在企业应用程序中,我们期望覆盖的场景要比下面代码片段中识别的要多得多。

我们还包括了最少的日志记录,足以在运行测试用例时拆分输出。您可能希望添加更多日志以帮助分析结果。测试代码假定ttt_company表具有适当的数据。在第二章中,任务时间跟踪器数据库,我们添加了三行数据,以便知道有可用数据。如果没有具有一致测试数据的数据库,需要进行额外的检查。文件列表如下:

package com.gieman.tttracker.dao;

import com.gieman.tttracker.domain.Company;
import java.util.List;
import static org.junit.Assert.assertTrue;
import org.junit.Test;

public class CompanyDaoTest extends AbstractDaoForTesting {

    public CompanyDaoTest(){}

    @Test
    public void testFind() throws Exception {

        logger.debug("\nSTARTED testFind()\n");
        List<Company> allItems = companyDao.findAll();

        assertTrue(allItems.size() > 0);

        // get the first item in the list
        Company c1 = allItems.get(0);

        int id = c1.getId();

        Company c2 = companyDao.find(id);

        assertTrue(c1.equals(c2));
        logger.debug("\nFINISHED testFind()\n");
    }

    @Test
    public void testFindAll() throws Exception {

        logger.debug("\nSTARTED testFindAll()\n");
        int rowCount = countRowsInTable("ttt_company");

        if(rowCount > 0){

            List<Company> allItems = companyDao.findAll();
            assertTrue("Company.findAll list not equal to row count of table ttt_company", rowCount == allItems.size());

        } else {
            throw new IllegalStateException("INVALID TESTING SCENARIO: Company table is empty");
        }
        logger.debug("\nFINISHED testFindAll()\n");
    }

    @Test
    public void testPersist() throws Exception {

        logger.debug("\nSTARTED testPersist()\n");
        Company c = new Company();
        final String NEW_NAME = "Persist Test Company name";
        c.setCompanyName(NEW_NAME);

        companyDao.persist(c);

        assertTrue(c.getId() != null);
        assertTrue(c.getCompanyName().equals(NEW_NAME));

        logger.debug("\nFINISHED testPersist()\n");
    }

    @Test
    public void testMerge() throws Exception {

        logger.debug("\nSTARTED testMerge()\n");
        final String NEW_NAME = "Merge Test Company New Name";

        Company c = companyDao.findAll().get(0);
        c.setCompanyName(NEW_NAME);

        c = companyDao.merge(c);

        assertTrue(c.getCompanyName().equals(NEW_NAME));

        logger.debug("\nFINISHED testMerge()\n");

    }

    @Test
    public void testRemove() throws Exception {

        logger.debug("\nSTARTED testRemove()\n");
        Company c = companyDao.findAll().get(0);

        companyDao.remove(c);

        List<Company> allItems = companyDao.findAll();

        assertTrue("Deleted company may not be in findAll List", !allItems.contains(c) );

        logger.debug("\nFINISHED testRemove()\n");
    }
}

使用 Maven 运行 JUnit 测试用例

通过单击工具栏上的Clean and Build Project (task-time-tracker)图标,pom.xml配置文件将自动使用<skipTests>false</skipTests>运行测试用例:

使用 Maven 运行 JUnit 测试用例

还可以通过导航到Run | Test Project (task-time-tracker)来仅运行项目的测试阶段:

使用 Maven 运行 JUnit 测试用例

现在可以在Output - task-time-tracker面板中检查测试过程的结果。请注意,如果最小化了输出面板,则可能需要将输出面板停靠到 IDE 底部,如下面的屏幕截图所示(最小化面板通常位于 NetBeans IDE 的左下角)。在测试过程开始时,会显示[surefire:test]插件输出。有许多行输出用于配置 Spring,连接到数据库和加载持久化上下文:

使用 Maven 运行 JUnit 测试用例

我们将很快详细检查关键的测试输出。滚动输出,直到到达测试部分的末尾:

使用 Maven 运行 JUnit 测试用例

总共执行了五个测试,没有错误,非常好的开始!

运行 CompanyDaoTest.java 文件

您可以通过右键单击编辑器中显示的文件并选择Test File选项来执行单个测试用例文件:

运行 CompanyDaoTest.java 文件

这将执行文件的测试用例,产生与之前相同的测试输出,并在Test Results面板中呈现结果。该面板应该出现在文件编辑器下方,但可能没有停靠(可能在 NetBeans IDE 底部漂浮;您可以根据需要更改位置和停靠)。然后可以检查单个文件的测试结果:

运行 CompanyDaoTest.java 文件

单个测试文件的执行是调试和开发代码的实用快速方式。在本章的其余部分,我们将继续执行和检查单个文件。

现在让我们详细检查每个测试用例的结果。

注意

在以下所有测试输出中,已删除了 SLF4J 特定的消息。这将包括时间戳,线程和会话信息。我们只关注生成的 SQL。

CompanyDaoTests.testMerge 测试用例的结果

这个测试用例的输出是:

STARTED testMerge()
SELECT id_company, company_name FROM ttt_company ORDER BY company_name ASC
FINISHED testMerge()

merge调用用于更新持久实体。testMerge方法非常简单:

final String NEW_NAME = "Merge Test Company New Name";
Company c = companyDao.findAll().get(0);
c.setCompanyName(NEW_NAME);
c = companyDao.merge(c);
assertTrue(c.getCompanyName().equals(NEW_NAME));

我们找到第一个Company实体(findAll返回的列表中的第一项),然后将公司的名称更新为NEW_NAME值。然后,companyDao.merge调用会更新持久化上下文中的Company实体状态。这是使用assertTrue()测试来测试的。

请注意,测试输出只有一个SQL 语句:

SELECT id_company, company_name FROM ttt_company ORDER BY company_name ASC

这个输出对应于findAll方法的调用。请注意,没有执行 SQL 更新语句!这可能看起来很奇怪,因为实体管理器的merge调用应该导致针对数据库执行更新语句。但是,JPA 实现要求立即执行这样的语句,并且可能在可能的情况下缓存语句,以进行性能和优化。缓存的(或排队的)语句只有在调用显式的commit时才会执行。在我们的例子中,Spring 在testMerge方法返回后立即执行rollback(请记住,由于我们的AbstractTransactionalJUnit4SpringContextTests扩展,我们正在运行事务性测试用例),因此持久化上下文永远不需要执行更新语句。

我们可以通过对GenericDaoImpl类进行轻微更改来强制刷新到数据库。

@Override
@Transactional(readOnly = false, propagation = Propagation.REQUIRED)
public T merge(T o) {
  o = em.merge(o);
  em.flush();
  return o;
}

em.flush()方法导致立即执行更新语句;实体管理器被刷新以处理所有挂起的更改。更改GenericDaoImpl类中的此代码并再次执行测试用例将产生以下测试输出:

SELECT id_company, company_name FROM ttt_company ORDER BY company_name ASC
UPDATE ttt_company SET company_name = ? WHERE (id_company = ?)
  bind => [Merge Test Company New Name, 2]

现在更新语句出现了。如果我们现在在执行测试用例后直接检查数据库,我们会发现:

CompanyDaoTests.testMerge 测试用例的结果

正如预期的那样,Spring 在testMerge方法调用结束时回滚了数据库,并且第一条记录的公司名称没有改变。

注意

在企业应用程序中,建议不要显式调用em.flush(),并允许 JPA 实现根据其事务行为优化语句。然而,可能存在需要立即刷新的情况,但这些情况很少见。

CompanyDaoTests.testFindAll 测试用例的结果

这个测试用例的输出是:

STARTED testFindAll()
SELECT id_company, company_name FROM ttt_company ORDER BY company_name ASC
FINISHED testFindAll()

即使testMerge方法使用findAll方法检索列表中的第一项,我们应该始终包括一个单独的findAll测试方法,以将结果集的大小与数据库表进行比较。使用 Spring 辅助方法countRowsInTable时很容易实现这一点:

int rowCount = countRowsInTable("ttt_company");

然后我们可以使用assertTrue语句将findAll结果列表的大小与rowCount进行比较:

assertTrue("Company.findAll list not equal to row count of table ttt_company", rowCount == allItems.size());

注意assertTrue语句的使用;如果断言为false,则显示消息。我们可以通过稍微修改断言来测试语句,使其失败:

assertTrue("Company.findAll list not equal to row count of table ttt_company", rowCount+1 == allItems.size());

现在它将失败,并在执行测试用例时产生以下输出:

CompanyDaoTests.testFindAll 测试用例的结果

CompanyDaoTests.testFind 测试用例的结果

这个测试用例的输出是:

STARTED testFind()
SELECT id_company, company_name FROM ttt_company ORDER BY company_name ASC
FINISHED testFind()

这对于新接触 JPA 的人来说可能有点令人惊讶。SELECT语句是从代码中执行的:

List<Company> allItems = companyDao.findAll();

但是在使用id属性调用find方法时,预期的SELECT语句在哪里呢?

int id = c1.getId(); // find ID of first item in list
Company c2 = companyDao.find(id);

JPA 不需要使用主键语句在数据库上执行SELECT语句,因为具有所需 ID 的实体已经在持久化上下文中加载。由于findAll方法的结果,将加载三个具有 ID 为 1、2 和 3 的实体。当要求使用列表中第一项的 ID 查找实体时,JPA 将返回已在持久化上下文中加载的具有匹配 ID 的实体,避免执行数据库选择语句的需要。

这通常是理解 JPA 管理应用程序行为的一个陷阱。当实体加载到持久化上下文中时,它将一直保留在那里,直到过期。构成“过期”的定义将取决于实现和缓存属性。可能小数据集永远不会过期;在我们的 Company 示例中,只有少量记录,这很可能是这种情况。例如,直接在底层表上执行更新语句,例如更改第一条记录的公司名称,可能永远不会在 JPA 持久化上下文中反映出来,因为持久化上下文实体永远不会被刷新。

注意

如果企业应用程序期望从多个来源进行数据修改(例如,通过存储过程或通过不同实体管理器的 web 服务调用),则需要一种缓存策略来使过期的实体失效。JPA 不会自动从数据库刷新实体状态,并且会假定持久化上下文是管理持久化数据的唯一机制。EclipseLink 提供了几个缓存注解来解决这个问题。可以在这里找到一个很好的指南:wiki.eclipse.org/EclipseLink/Examples/JPA/Caching

CompanyDaoTests.testPersist 测试用例的结果

由于上一章的练习,我们对GenericDaoImpl.persist方法进行了一些小的更改。GenericDaoImpl实现中修改后的persist方法是:

em.persist(o);

em.flush(); 

if (o instanceof EntityItem) {
  EntityItem<ID> item = (EntityItem<ID>) o;
  ID id = item.getId();
  logger.info("The " + o.getClass().getName() + " record with ID=" + id + " has been inserted");
}

em.persist()方法之后,您会注意到GenericDaoImpl中的em.flush()方法。如果没有将此刷新到数据库,我们无法保证新的Company实体上已设置有效的主键。这个测试案例的输出是:

STARTED testPersist()
INSERT INTO ttt_company (company_name) VALUES (?)
  bind => [Persist Test Company name]
SELECT LAST_INSERT_ID()
The com.gieman.tttracker.domain.Company record with ID=4 has been inserted
FINISHED testPersist()

请注意,日志输出了新生成的主键值4。当 JPA 使用SELECT LAST_INSERT_ID()语句查询 MySQL 时,会检索到这个值。事实上,从GenericDaoImpl中删除em.flush()方法并执行测试案例将导致以下输出:

STARTED testPersist()
The com.gieman.tttracker.domain.Company record with ID=null has been inserted

断言assertTrue(c.getId() != null)将失败,我们甚至不会显示FINISHED testPersist()消息。我们的测试案例在调试消息到达之前失败。

我们再次看到 JPA 优化的作用。没有em.flush()方法,JPA 将等到事务提交后才执行数据库中的任何更改。结果是,主键可能不会按预期设置为同一事务中新创建的实体对象的任何后续代码。这是对不谨慎开发人员的另一个陷阱,persist方法确定了实体管理器flush()到数据库可能需要的唯一情况。

CompanyDaoTests.testRemove 测试案例的结果

这可能是迄今为止最有趣的测试案例。输出是:

STARTED testRemove()

SELECT id_company, company_name FROM ttt_company ORDER BY company_name ASC
SELECT id_project, project_name, id_company FROM ttt_project WHERE (id_company = ?)
  bind => [2]
SELECT id_task, task_name, id_project FROM ttt_task WHERE (id_project = ?)
  bind => [4]
SELECT id_task, task_name, id_project FROM ttt_task WHERE (id_project = ?)
  bind => [5]
SELECT id_task, task_name, id_project FROM ttt_task WHERE (id_project = ?)
  bind => [6]
The com.gieman.tttracker.domain.Company record with ID=2 has been deleted
DELETE FROM ttt_task WHERE (id_task = ?)
  bind => [10]
DELETE FROM ttt_task WHERE (id_task = ?)
  bind => [12]
DELETE FROM ttt_task WHERE (id_task = ?)
  bind => [11]
DELETE FROM ttt_task WHERE (id_task = ?)
  bind => [13]
DELETE FROM ttt_project WHERE (id_project = ?)
  bind => [4]
DELETE FROM ttt_project WHERE (id_project = ?)
  bind => [6]
DELETE FROM ttt_project WHERE (id_project = ?)
  bind => [5]
DELETE FROM ttt_company WHERE (id_company = ?)
  bind => [2]
SELECT id_company, company_name FROM ttt_company ORDER BY company_name ASC

FINISHED testRemove()

第一个SELECT语句是为了找到列表中的第一个公司而执行的。

Company c = companyDao.findAll().get(0);

第二个SELECT语句可能不那么明显:

SELECT id_project, project_name, id_company FROM ttt_project WHERE (id_company = ?)
  bind => [2]

为什么删除公司会导致对ttt_project表的SELECT语句?原因是每个Company实体可能有一个或多个相关的Projects实体,如Company类定义中所定义的:

    @OneToMany(cascade = CascadeType.ALL, mappedBy = "company")
    private List<Project> projects;

JPA 了解到删除公司需要检查ttt_project表,以查看是否有任何依赖的 Projects。在@OneToMany注释中,cascade = CascadeType.ALL属性定义了删除公司时的行为;更改应该级联到任何依赖实体。在这个例子中,删除公司记录将需要删除所有相关的项目记录。每个Project实体依次拥有Task实体的集合,如Project类定义中所定义的:

    @OneToMany(cascade = CascadeType.ALL, mappedBy = "project")
    private List<Task> tasks;

删除Company实体的结果具有深远的影响,因为所有相关的 Projects 及其相关的 Tasks 都从底层表中删除。在测试输出中级联的DELETE语句的结果是最终删除公司本身。这可能不适合企业应用程序的行为;事实上,通常永远不会在没有广泛检查以确保数据完整性的情况下实施这样的级联删除。在Company类中级联注释的简单更改将确保不会传播删除:

@OneToMany(cascade = {CascadeType.MERGE, CascadeType.PERSIST}, mappedBy ="company")
private List<Project> projects;

现在,只有对Company实体的MERGEPERSIST操作将级联到相关的Project实体。在进行此更改后再次运行测试案例将导致:

Internal Exception: com.mysql.jdbc.exceptions.jdbc4.MySQLIntegrityConstraintViolationException: Cannot delete or update a parent row: a foreign key constraint fails (`task_time_tracker`.`ttt_project`, CONSTRAINT `ttt_project_ibfk_1` FOREIGN KEY (`id_company`) REFERENCES `ttt_company` (`id_company`))

由于未包括REMOVE的级联类型,JPA 不会检查ttt_project表中的相关行,并尝试在ttt_company表上执行DELETE语句。这将失败,因为ttt_project表上有相关记录。现在只有在没有相关的Project实体时(projects字段是空列表)才能删除Company实体。

注意

按照本节中概述的更改CascadeType会向 DAO 层添加业务逻辑。您将不再能够通过持久性上下文执行某些操作。然而,可能存在一种合法情况,您确实希望对Company实体进行级联删除,这将不再可能。CascadeType.ALL是最灵活的选项,允许所有可能的情况。删除策略等业务逻辑应该在服务层中实现,这是下一章的主题。

我们将继续使用cascade = CascadeType.ALL属性,并允许 JPA 管理的删除进行传播。限制这些操作的业务逻辑将在服务层中实现。

JPA 陷阱

有一些值得特别研究的 JPA 陷阱。我们将从创建以下测试用例开始:

package com.gieman.tttracker.dao;

import com.gieman.tttracker.domain.Company;
import com.gieman.tttracker.domain.Project;
import com.gieman.tttracker.domain.User;
import static org.junit.Assert.assertTrue;
import org.junit.Test;

public class JpaTrapTest extends AbstractDaoForTesting {

    @Test
    public void testManyToOne() throws Exception {

        logger.debug("\nSTARTED testManyToOne()\n");

        Company c = companyDao.findAll().get(0);
        Company c2 = companyDao.findAll().get(1);

        Project p = c.getProjects().get(0);

        p.setCompany(c2);
        p = projectDao.merge(p);

        assertTrue("Original company still has project in its collection!",
                !c.getProjects().contains(p));
        assertTrue("Newly assigned company does not have project in its collection",
                c2.getProjects().contains(p));

        logger.debug("\nFINISHED testManyToOne()\n");

    }

    @Test
    public void testFindByUsernamePassword() throws Exception {

        logger.debug("\nSTARTED testFindByUsernamePassword()\n");

        // find by username/password combination
        User user = userDao.findByUsernamePassword("bjones", "admin");

        assertTrue("Unable to find valid user with correct username/password combination", 
                user != null);

        user = userDao.findByUsernamePassword("bjones", "ADMIN");

        assertTrue("User found with invalid password", 
                user == null); 

        logger.debug("\nFINISHED testFindByUsernamePassword()\n");
    }
}

运行此测试用例可能会让您感到惊讶:

JPA 陷阱

第一个失败来自userDao.findByUsernamePassword语句,该语句使用大写密码:

user = userDao.findByUsernamePassword("bjones", "ADMIN");

为什么会找到具有明显不正确密码的用户?原因非常简单,对于不谨慎的开发人员来说是一个陷阱。大多数数据库在匹配文本字段时默认是不区分大小写的。在这种情况下,大写的ADMIN将与密码字段中的小写admin匹配。这并不是我们在检查密码时想要的!描述此行为的数据库术语是排序规则;我们需要修改密码列以使用区分大小写的排序规则。这可以通过以下 SQL 命令在 MySQL 中实现:

ALTER TABLE ttt_user MODIFY
    password VARCHAR(100)
      COLLATE latin1_general_cs;

其他数据库将具有类似的语义。这将使密码字段的排序规则变为区分大小写(请注意在latin1_general_cs中附加的_cs)。运行测试用例现在将导致对区分大小写密码检查的预期行为:

JPA 陷阱

testManyToOne失败是另一个有趣的案例。在这个测试用例中,我们正在将项目重新分配给另一家公司。p.setCompany(c2);行将分配的公司更改为列表中的第二家公司。我们期望在对项目调用merge方法后,c2公司的项目集合将包含新分配的项目。换句话说,以下代码行应该等于true

c2.getProjects().contains(p)

同样,旧公司不应再包含新分配的项目,因此应为false

c.getProjects().contains(p)

这显然不是这种情况,并且识别了对 JPA 新手的陷阱。

尽管持久性上下文使用@OneToMany@ManyToOne理解实体之间的关系,但在涉及集合时,Java 表示关系需要由开发人员处理。所需的简单更改如下:

p.setCompany(c2);
p = projectDao.merge(p);

c.getProjects().remove(p);
c2.getProjects().add(p);

当执行projectDao.merge(p)行时,持久性上下文无法知道原始父公司(如果有的话;这可能是一个新插入的项目)。持久性上下文中的原始Company实体仍然有一组分配的项目。在持久性上下文中,Company实体的生命周期内,此集合永远不会被更新。额外的两行代码用于从原始公司的项目列表中删除项目(使用remove),并且我们添加(使用add)项目到新公司,以确保持久性上下文实体更新到正确的状态。

练习

  1. CompanyDaoTest.find()方法添加测试断言,以测试以下情况:
  • 尝试查找具有空主键的公司

  • 尝试查找具有负主键的公司

您认为预期结果是什么?

  1. ProjectDaoTaskDaoUserDaoTaskLogDao实现创建缺失的测试用例文件。

  2. 创建一个测试用例,以确定删除项目是否会自动从所属公司的项目集合中删除项目。

摘要

我们再次涵盖了很多领域。单元测试是企业应用程序开发的关键部分,NetBeans、Maven、JUnit 和 Spring 的组合为我们提供了一个坚实的平台,可以启动自动化和单文件测试用例。撰写全面的测试用例是一种艺术形式,在任何高质量的开发团队中都受到赞赏和重视;永远不要低估与经过充分测试的代码一起工作所获得的信心,以及一套坚实的测试用例套件!

在下一章中,我们将探讨服务层在企业应用程序开发中的作用。然后,我们将使用数据传输对象DTO)设计模式来实现我们的 3T 业务逻辑。

第六章:回到业务 - 服务层

服务层是应用程序的核心;这是业务逻辑所在的地方。业务逻辑封装了定义工作应用程序的规则,这是开发时间的重要部分。增强功能、变更需求和持续维护通常需要对服务层进行修改。业务规则可能包括限制对特定角色的访问、安全约束、计算、验证、合规性检查和日志记录等操作。

一些典型的业务逻辑示例可能包括以下内容:

  • 只有管理员才能更改分配给用户的国家

  • 管理员只能将用户更改为其所在地理区域的国家

  • 如果支付的货币不是美元,必须添加 5%的汇率溢价

  • 澳大利亚邮政编码必须正好是四位数字

  • 将发票重新分配给加拿大分支只能在东海岸工作时间内进行

  • 每张新发票必须记录在单独的文件中,如果不是来自五个最大的商业客户之一

我们将在本章实施的核心业务规则要简单得多:

  • 用户必须在访问任何资源之前进行身份验证

  • 只有 3T 管理员才能维护 3T 配置

  • 用户只能更新和添加自己的任务日志

服务层考虑

对于服务层操作,有清晰定义的入口点是很重要的。这将通过定义服务层公开的操作的 Java 接口再次实现。服务层的客户端将通过这些接口与业务逻辑进行交互,而不是实现类。

出于类似的原因,服务层本身与底层 DAO 实现解耦是很重要的。我们已经通过确保我们的 DAO 层通过接口公开其持久性操作来实现了这一点。服务层不应该知道持久层是如何实现的,服务层类中也不应该编写任何持久性操作。

企业应用程序客户端有许多不同的形式,最常见的是 Web 浏览器和 Web 服务。但是,可能还有其他类型的客户端;例如,使用 RMI 的独立服务器。在所有情况下,服务层必须尽可能独立于客户端实现。因此,服务层不应该包含呈现逻辑,也不应该知道数据的使用方式。以下图示说明了服务层在整体应用程序结构中的位置:

服务层考虑

服务层通过领域对象与数据访问层进行交互。这种设计有明确的角色划分。DAO 层负责与数据库交互,服务层不知道这是如何完成的。同样,DAO 层对领域对象的使用不感兴趣。这是业务逻辑控制领域对象可以和应该做什么的服务层的角色。

一个良好架构的服务层应该有一个简单的接口,允许任何类型的请求处理层与底层应用业务逻辑一起工作。如果从服务层请求 Company 实体的列表,则提供此功能的公开接口方法不需要知道列表是用于呈现网页,执行 Web 服务调用还是发送带有附加 Excel 电子表格的电子邮件。请求处理层将在下一章中详细讨论。

构建服务层

服务层类和接口将遵循与我们的 DAO 层相同的命名约定,其中Service只是替换了Dao等效名称:

构建服务层

我们的第一个定义将是Result类。

结果数据传输对象

服务层将通过返回Result数据传输对象DTO)的接口与请求处理层通信。DTO 设计模式通常用于企业应用程序编程,用于在不同层或子系统之间传输数据。我们的ResultDTO 将具有以下三个属性:

  • boolean success:如果操作成功并且有适当的数据有效负载可用,则使用此属性

  • String msg:这是一个可能被客户端用于日志记录或信息目的的消息

  • <T> data:这是一个通用类型的数据有效负载,将被请求处理层消耗

Result类也是值对象VO),一个在创建后状态不能被改变的不可变对象。每个实例变量都标记为final,我们将使用适当的ResultFactory方法来创建值对象实例。值对象是领域驱动设计中用来表示没有概念身份的数据的概念。您可以在en.wikipedia.org/wiki/Domain-driven_design找到更多关于领域驱动设计的信息。Result类的定义如下:

package com.gieman.tttracker.vo;

import java.io.Serializable;
import java.util.List;
import java.util.Objects;

public class Result<T> implements Serializable {

    final private boolean success;
    final private T data;
    final private String msg;

    Result(boolean success, T data) {
        this.success = success;
        this.data = data;
        this.msg = null;
    }

    Result(boolean success, String msg) {
        this.success = success;
        this.data = null;
        this.msg = msg;
    }

    public boolean isSuccess() {
        return success;
    }

    public T getData() {
        return data;
    }

    public String getMsg() {
        return msg;
    }

    @Override
    public String toString() {

        StringBuilder sb = new StringBuilder("\"Result{\"");
        sb.append("success=").append(success);
        sb.append(", msg=").append(msg);

        sb.append(", data=");

        if(data == null){

            sb.append("null");

        } else if(data instanceof List){

            List castList = (List) data;
            if(castList.isEmpty()){

                sb.append("empty list");

            } else {
                Object firstItem = castList.get(0);

                sb.append("List of ").append(firstItem.getClass());
            }

        } else {
            sb.append(data.toString());
        }

        sb.append("}");

        return sb.toString();

    }

    @Override
    public int hashCode() {
        int hash = 7;
        hash = 89 * hash + (this.success ? 1 : 0);
        hash = 89 * hash + Objects.hashCode(this.data);
        return hash;
    }

    @Override
    public boolean equals(Object obj) {
        if (obj == null) {
            return false;
        }
        if (getClass() != obj.getClass()) {
            return false;
        }
        final Result<?> other = (Result<?>) obj;
        if (this.success != other.success) {
            return false;
        }
        return Objects.deepEquals(this.data, other.data);
    }
}

您会注意到Result构造函数是包私有的(不能被包外的类创建)。Result值对象的实例化将由ResultFactory类管理:

package com.gieman.tttracker.vo;

public class ResultFactory {

    public static <T> Result<T> getSuccessResult(T data) {
        return new Result(true, data);
    }
    public static <T> Result<T> getSuccessResult(T data, String msg) {
        return new Result(true, msg);
    }

    public static <T> Result<T> getSuccessResultMsg(String msg) {
        return new Result(true, msg);
    }

    public static <T> Result<T> getFailResult(String msg) {
        return new Result(false, msg);
    }
}

静态实用方法将创建并返回配置为我们服务层适当目的的Result实例。

在我们的设计中,失败被认为是应用程序的可恢复状态。尝试使用无效的用户名/密码组合登录将是一个失败的操作示例。没有权限执行删除操作也可能是另一个可能的失败操作。服务层的客户端可以通过检查Resultmsg来从这些操作中恢复并向用户呈现优雅的消息。处理失败的另一种设计模式是通过 Java 检查异常;当遇到失败时会抛出异常。实施这样的设计模式会强制客户端捕获异常,确定异常的原因,并相应地处理处理。我们更喜欢我们的设计来处理失败,并建议您不要使用检查异常,除非真正发生了异常情况。结果代码更易于阅读,我们可以避免处理异常的开销。

AbstractService.java 类

所有服务层实现将扩展AbstractService类以提供通用功能。我们将简单地定义一个logger@AutowireUserDao实现,并添加一个方便的方法来检查用户是否有效。

package com.gieman.tttracker.service;

import com.gieman.tttracker.dao.UserDao;
import com.gieman.tttracker.domain.User;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;

public abstract class AbstractService {

    final protected Logger logger = LoggerFactory.getLogger(this.getClass());

    @Autowired
    protected UserDao userDao;

    protected  final String USER_INVALID = "Not a valid user";
    protected  final String USER_NOT_ADMIN = "Not an admin user";

    protected boolean isValidUser(String username){

        User user = userDao.findByUsername(username);
        return user != null;
    }
}

如前一章所述,Spring 会为每个@Autowired注释的字段注入与匹配类型的容器管理的 bean。因此,每个扩展AbstractService类的服务层实现将可以访问UserDao实例。

我们的服务层将实现非常基本的安全性,以区分普通用户和管理员用户。ttt_user表中的admin_role列用于标识用户是否具有管理员特权。企业应用程序很可能会有 LDAP 领域,为不同的用户组配置适当的角色,但原则是相同的;我们需要能够确定用户是否被允许执行操作。管理员角色将是我们 3T 应用程序上唯一的角色,我们现在将向User类添加一个辅助方法来确定用户是否是管理员:

  public boolean isAdmin(){
    return adminRole == null ? false : adminRole.equals('Y');
  }

服务层实现将使用这个新方法来测试用户是否是管理员。

服务层接口

服务层接口定义了将向客户端公开的方法。这些方法定义了我们 3T 应用程序所需的核心操作。每个方法都有一个String actionUsername参数,用于标识执行此请求的用户。actionUsername可以用于实现日志记录或确保请求数据的用户是有效的。有效的定义将取决于正在执行的操作。每个接口将使用通用类型来定义返回的Result值对象。

CompanyService接口将返回一个数据有效载荷,要么是一个公司对象(Result<Company>),要么是一个公司对象列表(Result<List<Company>>)。该接口的定义如下:

package com.gieman.tttracker.service;

import java.util.List;
import com.gieman.tttracker.domain.Company;
import com.gieman.tttracker.vo.Result;

public interface CompanyService {

    public Result<Company> store(
        Integer idCompany,
        String companyName,
        String actionUsername);

    public Result<Company> remove(Integer idCompany, String actionUsername);
    public Result<Company> find(Integer idCompany, String actionUsername);
    public Result<List<Company>> findAll(String actionUsername);

}

请注意,我们已经定义了一个store方法,用于将数据保存到持久存储中。实现方法将决定是否需要persistmerge。类似地,我们可以定义其余的接口(包和导入定义已被删除)。

public interface ProjectService {

    public Result<Project> store(
        Integer idProject,
        Integer idCompany,
        String projectName,
        String actionUsername);

    public Result<Project> remove(Integer idProject, String actionUsername);
    public Result<Project> find(Integer idProject, String actionUsername);
    public Result<List<Project>> findAll(String actionUsername);

}
public interface TaskService {

    public Result<Task> store(
        Integer idTask,
        Integer idProject,
        String taskName,
        String actionUsername);

    public Result<Task> remove(Integer idTask, String actionUsername);
    public Result<Task> find(Integer idTask, String actionUsername);
    public Result<List<Task>> findAll(String actionUsername);
}
public interface TaskLogService {

    public Result<TaskLog> store(
        Integer idTaskLog,
        Integer idTask,
        String username,
        String taskDescription,
        Date taskLogDate,
        int taskMinutes,
        String actionUsername);

    public Result<TaskLog> remove(Integer idTaskLog, String actionUsername);
    public Result<TaskLog> find(Integer idTaskLog, String actionUsername);
    public Result<List<TaskLog>> findByUser(String username, Date startDate, Date endDate, String actionUsername);
}
public interface UserService {
    public Result<User> store(
        String username,
        String firstName,
        String lastName,
        String email,
        String password,
        Character adminRole,
        String actionUsername);

    public Result<User> remove(String username, String actionUsername);
    public Result<User> find(String username, String 
      actionUsername);
    public Result<List<User>> findAll(String actionUsername);
    public Result<User> findByUsernamePassword(String username, String password);
}

实现服务层

先前定义的每个接口都将有一个适当的实现。实现类将遵循我们的 DAO 命名约定,将Impl添加到接口名称中,结果为CompanyServiceImplProjectServiceImplTaskServiceImplTaskLogServiceImplUserServiceImpl。我们将定义CompanyServiceImplTaskServiceImplTaskLogServiceImpl类,并将ProjectServiceImplUserServiceImpl留作练习。

服务层实现将使用一个或多个调用 DAO 层来处理业务逻辑,验证参数,并根据需要确认用户授权。如下列表所述,3T 应用程序安全性非常简单:

  • 对于所有操作都需要一个有效的用户。actionUsername必须代表数据库中的有效用户。

  • 只有管理员可以修改CompanyProjectTask数据。

  • 只有管理员可以修改或添加用户。

我们的服务层实现将使用AbstractService类中的isValidUser方法来检查用户是否有效。

身份验证、授权和安全

应用程序安全是企业应用程序开发的关键部分,重要的是要理解身份验证和授权之间的区别。

  • 身份验证验证你是谁。它涉及验证用户名/密码组合,并在 3T 应用程序的初始登录期间执行一次。

  • 授权验证您被允许做什么。3T 管理员可以执行比普通用户更多的操作。

3T 用户必须在ttt_user表中有一个有效的记录;服务层将简单地测试提供的用户名是否代表一个有效的用户。用户的实际授权将在下一章中进行介绍,当我们开发请求处理层时。

保护企业应用程序超出了本书的范围,但在讨论这个主题时,没有提到 Spring Security 是不完整的。Spring Security 已成为保护基于 Spring 的应用程序的事实标准,可以在static.springframework.org/spring-security/site/index.html找到概述。Spring Security 已成为保护基于 Spring 的应用程序的事实标准,可以在www.springsecuritybook.com找到一本名为《Spring Security 3》的优秀书籍,涵盖了所有概念。我们建议您了解更多关于 Spring Security 的信息,以了解您可以对用户进行身份验证和保护服务层的许多不同方式。

公司服务实现

CompanyServiceImpl类定义如下:

package com.gieman.tttracker.service;

import com.gieman.tttracker.dao.CompanyDao;
import java.util.List;
import com.gieman.tttracker.domain.*;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import com.gieman.tttracker.vo.Result;
import com.gieman.tttracker.vo.ResultFactory;
import org.springframework.beans.factory.annotation.Autowired;

@Transactional
@Service("companyService")
public class CompanyServiceImpl extends AbstractService implements CompanyService {

    @Autowired
    protected CompanyDao companyDao;

    public CompanyServiceImpl() {
        super();
    }

    @Transactional(readOnly = true, propagation = Propagation.SUPPORTS)
    @Override
    public Result<Company> find(Integer idCompany, String actionUsername) {

        if (isValidUser(actionUsername)) {
            Company company = companyDao.find(idCompany);
            return ResultFactory.getSuccessResult(company);

        } else {          
            return ResultFactory.getFailResult(USER_INVALID);
        }
    }

    @Transactional(readOnly = false, propagation = Propagation.REQUIRED)
    @Override
    public Result<Company> store(
            Integer idCompany,
            String companyName,
            String actionUsername) {

        User actionUser = userDao.find(actionUsername);

        if (!actionUser.isAdmin()) {
            return ResultFactory.getFailResult(USER_NOT_ADMIN);
        }

        Company company;

        if (idCompany == null) {
            company = new Company();
        } else {

            company = companyDao.find(idCompany);

            if (company == null) {
                return ResultFactory.getFailResult("Unable to find company instance with ID=" + idCompany);
            }
        }

        company.setCompanyName(companyName);

        if (company.getId() == null) {
            companyDao.persist(company);
        } else {
            company = companyDao.merge(company);
        }

        return ResultFactory.getSuccessResult(company);

    }

    @Transactional(readOnly = false, propagation = Propagation.REQUIRED)
    @Override
    public Result<Company> remove(Integer idCompany, String actionUsername) {

        User actionUser = userDao.find(actionUsername);

        if (!actionUser.isAdmin()) {
            return ResultFactory.getFailResult(USER_NOT_ADMIN);
        }

        if (idCompany == null) {
            return ResultFactory.getFailResult("Unable to remove Company [null idCompany]");
        } 

        Company company = companyDao.find(idCompany);

        if (company == null) {
            return ResultFactory.getFailResult("Unable to load Company for removal with idCompany=" + idCompany);
        } else {

            if (company.getProjects() == null || company.getProjects().isEmpty()) {

                companyDao.remove(company);

                String msg = "Company " + company.getCompanyName() + " was deleted by " + actionUsername;
                logger.info(msg);
                return ResultFactory.getSuccessResultMsg(msg);
            } else {
                return ResultFactory.getFailResult("Company has projects assigned and could not be deleted");
            }
        }

    }

    @Transactional(readOnly = true, propagation = Propagation.SUPPORTS)
    @Override
    public Result<List<Company>> findAll(String actionUsername) {

        if (isValidUser(actionUsername)) {
            return ResultFactory.getSuccessResult(companyDao.findAll());
        } else {
            return ResultFactory.getFailResult(USER_INVALID);
        }
    }
}

每个方法都返回一个由适当的ResultFactory静态方法创建的Result对象。每个方法都确认actionUsername方法,该方法标识了一个有效的用户进行操作。修改Company实体的方法需要一个管理用户(storeremove方法)。其他检索数据的方法(find*方法)只需要一个有效的用户;存在于ttt_user表中的用户。

请注意,在每个方法中重复使用if(isValidUser(actionUsername))if(!actionUser.isAdmin())代码块。这不被认为是一个好的做法,因为这种逻辑应该是安全框架的一部分,而不是在每个方法中复制。例如,使用 Spring Security,可以通过使用注解将安全应用到服务层 bean。

@Secured("ROLE_USER")
public Result<List<Company>> findAll(String actionUsername) {
// application specific code here

@Secured("ROLE_ADMIN")
public Result<Company> remove(Integer idCompany, String actionUsername) {
// application specific code here

@Secured注解用于定义适用于业务方法的安全配置属性列表。然后,用户将通过安全框架与一个或多个角色关联。这种设计模式不会过于侵入,更容易维护和增强。

注意

我们再次建议您了解更多关于 Spring Security 在实际企业应用中的使用。

任何无法按预期执行的操作都被视为“失败”。在这种情况下,将调用ResultFactory.getFailResult方法来创建失败的Result对象。

需要注意的几点:

  • 每个服务层类都使用@Service注解来标识这是一个 Spring 管理的 bean。Spring 框架将配置为使用应用程序上下文配置文件中的<context:component-scan base-package="com.gieman.tttracker.service"/>扫描此注解。然后,Spring 将CompanyServiceImpl类加载到companyService名称下的 bean 容器中。

  • store方法用于persistmerge公司实体。服务层客户端不需要知道这将是一个insert语句还是一个update语句。根据主键的存在,在store方法中选择适当的操作。

  • remove方法检查公司是否分配了项目。实现的业务规则只允许在没有分配项目的情况下删除公司,然后检查company.getProjects().isEmpty()是否为 true。如果分配了项目,则remove方法失败。

  • 事务属性取决于正在实现的操作。如果正在修改数据,我们使用@Transactional(readOnly = false, propagation = Propagation.REQUIRED)来确保如果尚未可用,则创建事务。如果方法中没有修改数据,我们使用@Transactional(readOnly = true, propagation = Propagation.SUPPORTS)

所有服务层实现都将遵循类似的模式。

TaskService 实现

TaskServiceImpl类定义如下:

package com.gieman.tttracker.service;

import com.gieman.tttracker.dao.ProjectDao;
import com.gieman.tttracker.dao.TaskDao;
import com.gieman.tttracker.dao.TaskLogDao;
import java.util.List;
import com.gieman.tttracker.domain.*;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import com.gieman.tttracker.vo.Result;
import com.gieman.tttracker.vo.ResultFactory;
import org.springframework.beans.factory.annotation.Autowired;

@Transactional
@Service("taskService")
public class TaskServiceImpl extends AbstractService implements TaskService {

    @Autowired
    protected TaskDao taskDao;
    @Autowired
    protected TaskLogDao taskLogDao;     
    @Autowired
    protected ProjectDao projectDao;    

    public TaskServiceImpl() {
        super();
    }

    @Transactional(readOnly = true, propagation = Propagation.SUPPORTS)
    @Override
    public Result<Task> find(Integer idTask, String actionUsername) {

        if(isValidUser(actionUsername)) {
            return ResultFactory.getSuccessResult(taskDao.find(idTask));
        } else {
            return ResultFactory.getFailResult(USER_INVALID);
        }

    }

    @Transactional(readOnly = false, propagation = Propagation.REQUIRED)
    @Override
    public Result<Task> store(
        Integer idTask,
        Integer idProject,
        String taskName,
        String actionUsername) {

        User actionUser = userDao.find(actionUsername);

        if (!actionUser.isAdmin()) {
            return ResultFactory.getFailResult(USER_NOT_ADMIN);
        }

        Project project = projectDao.find(idProject);

        if(project == null){
            return ResultFactory.getFailResult("Unable to store task without a valid project [idProject=" + idProject + "]");
        }

        Task task;

        if (idTask == null) {

            task = new Task();
            task.setProject(project);
            project.getTasks().add(task);

        } else {

            task = taskDao.find(idTask);

            if(task == null) {

                return ResultFactory.getFailResult("Unable to find task instance with idTask=" + idTask);

            } else {

                if(! task.getProject().equals(project)){

                    Project currentProject = task.getProject();
                    // reassign to new project
                    task.setProject(project);
                    project.getTasks().add(task);
                    // remove from previous project
                    currentProject.getTasks().remove(task);
                }
            }
        }

        task.setTaskName(taskName);

        if(task.getId() == null) {
            taskDao.persist(task);
        } else {
            task = taskDao.merge(task);
        }

        return ResultFactory.getSuccessResult(task);
    }

    @Transactional(readOnly = false, propagation = Propagation.REQUIRED)
    @Override
    public Result<Task> remove(Integer idTask, String actionUsername){
        User actionUser = userDao.find(actionUsername);

        if (!actionUser.isAdmin()) {
            return ResultFactory.getFailResult(USER_NOT_ADMIN);
        }

        if(idTask == null){

            return ResultFactory.getFailResult("Unable to remove Task [null idTask]");

        } else {

            Task task = taskDao.find(idTask);
            long taskLogCount = taskLogDao.findTaskLogCountByTask(task);

            if(task == null) {

                return ResultFactory.getFailResult("Unable to load Task for removal with idTask=" + idTask);

            } else if(taskLogCount > 0) {

                return ResultFactory.getFailResult("Unable to remove Task with idTask=" + idTask + " as valid task logs are assigned");

            } else {

                Project project = task.getProject();

                taskDao.remove(task);

                project.getTasks().remove(task);

                String msg = "Task " + task.getTaskName() + " was deleted by " + actionUsername;
                logger.info(msg);
                return ResultFactory.getSuccessResultMsg(msg);
            }
        }
    }

    @Transactional(readOnly = true, propagation = Propagation.SUPPORTS)
    @Override
    public Result<List<Task>> findAll(String actionUsername){

        if(isValidUser(actionUsername)){
            return ResultFactory.getSuccessResult(taskDao.findAll());
        } else {
            return ResultFactory.getFailResult(USER_INVALID);
        }
    }
}

该类实现以下业务规则:

  • 如果分配了任务日志,则不允许删除任务

  • 只有管理员可以修改任务

请注意,在remove方法中,我们使用以下代码检查是否为任务分配了任务日志:

long taskLogCount = taskLogDao.findTaskLogCountByTask (task);

taskLogDao.findTaskLogCountByTask方法使用Query接口上的getSingleResult()方法返回在TaskLogDaoImpl中定义的long值。可以编写一个方法来查找taskLogCount

List<TaskLog> allTasks = taskLogDao.findByTask(task);
long taskLogCount = allTasks.size();

然而,这种选择会导致 JPA 将分配给任务的所有TaskLog实体加载到内存中。这不是资源的有效使用,因为在大型系统中可能有数百万条TaskLog记录。

TaskLogService 实现

TaskLogService实现将是我们将详细介绍的最终类。

package com.gieman.tttracker.service;

import com.gieman.tttracker.dao.TaskDao;
import com.gieman.tttracker.dao.TaskLogDao;
import java.util.List;
import com.gieman.tttracker.domain.*;
import java.util.Date;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import com.gieman.tttracker.vo.Result;
import com.gieman.tttracker.vo.ResultFactory;
import org.springframework.beans.factory.annotation.Autowired;

@Transactional
@Service("taskLogService")
public class TaskLogServiceImpl extends AbstractService implements TaskLogService {

    @Autowired
    protected TaskLogDao taskLogDao;    
    @Autowired
    protected TaskDao taskDao;   

    public TaskLogServiceImpl() {
        super();
    }

    @Transactional(readOnly = true, propagation = Propagation.SUPPORTS)
    @Override
    public Result<TaskLog> find(Integer idTaskLog, String actionUsername) {

        User actionUser = userDao.find(actionUsername);

        if(actionUser == null) {
            return ResultFactory.getFailResult(USER_INVALID);
        }

        TaskLog taskLog = taskLogDao.find(idTaskLog);

        if(taskLog == null){
            return ResultFactory.getFailResult("Task log not found with idTaskLog=" + idTaskLog);
        } else if( actionUser.isAdmin() || taskLog.getUser().equals(actionUser)){
            return ResultFactory.getSuccessResult(taskLog);
        } else {
            return ResultFactory.getFailResult("User does not have permission to view this task log");
        }
    }

    @Transactional(readOnly = false, propagation = Propagation.REQUIRED)
    @Override
    public Result<TaskLog> store(
        Integer idTaskLog,
        Integer idTask,
        String username,
        String taskDescription,
        Date taskLogDate,
        int taskMinutes,
        String actionUsername) {

        User actionUser = userDao.find(actionUsername);
        User taskUser = userDao.find(username);

        if(actionUser == null || taskUser == null) {
            return ResultFactory.getFailResult(USER_INVALID);
        }

        Task task = taskDao.find(idTask);

        if(task == null) {
            return ResultFactory.getFailResult("Unable to store task log with null task");
        }

        if( !actionUser.isAdmin() && ! taskUser.equals(actionUser) ){
            return ResultFactory.getFailResult("User performing save must be an admin user or saving their own record");
        }

        TaskLog taskLog;

        if (idTaskLog == null) {
            taskLog = new TaskLog();
        } else {
            taskLog = taskLogDao.find(idTaskLog);
            if(taskLog == null) {
                return ResultFactory.getFailResult("Unable to find taskLog instance with ID=" + idTaskLog);
            }
        }

        taskLog.setTaskDescription(taskDescription);
        taskLog.setTaskLogDate(taskLogDate);
        taskLog.setTaskMinutes(taskMinutes);
        taskLog.setTask(task);
        taskLog.setUser(taskUser);

        if(taskLog.getId() == null) {
            taskLogDao.persist(taskLog);
        } else {
            taskLog = taskLogDao.merge(taskLog);
        }

        return ResultFactory.getSuccessResult(taskLog);

    }

    @Transactional(readOnly = false, propagation = Propagation.REQUIRED)
    @Override
    public Result<TaskLog> remove(Integer idTaskLog, String actionUsername){

        User actionUser = userDao.find(actionUsername);

        if(actionUser == null) {
            return ResultFactory.getFailResult(USER_INVALID);
        }

        if(idTaskLog == null){
            return ResultFactory.getFailResult("Unable to remove TaskLog [null idTaskLog]");
        } 

        TaskLog taskLog = taskLogDao.find(idTaskLog);

        if(taskLog == null) {
            return ResultFactory.getFailResult("Unable to load TaskLog for removal with idTaskLog=" + idTaskLog);
        } 

        // only the user that owns the task log may remove it
        // OR an admin user
        if(actionUser.isAdmin() || taskLog.getUser().equals(actionUser)){
            taskLogDao.remove(taskLog);
            return ResultFactory.getSuccessResultMsg("taskLog removed successfully");
        } else {
            return ResultFactory.getFailResult("Only an admin user or task log owner can delete a task log");
        }
    }

    @Transactional(readOnly = true, propagation = Propagation.SUPPORTS)
    @Override
    public Result<List<TaskLog>> findByUser(String username, Date startDate, Date endDate, String actionUsername){

        User taskUser = userDao.findByUsername(username);
        User actionUser = userDao.find(actionUsername);

        if(taskUser == null || actionUser == null) {
            return ResultFactory.getFailResult(USER_INVALID);
        }

        if(startDate == null || endDate == null){
            return ResultFactory.getFailResult("Start and end date are required for findByUser ");
        }

        if(actionUser.isAdmin() || taskUser.equals(actionUser)){
            return ResultFactory.getSuccessResult(taskLogDao.findByUser(taskUser, startDate, endDate));
        } else {
            return ResultFactory.getFailResult("Unable to find task logs. User does not have permission with username=" + username);
        }
    }
}

再次强调,这个类中有很多业务逻辑。实现的主要业务规则是:

  • 只有TaskLog的所有者或管理员才能找到任务日志

  • 管理员可以为任何其他用户添加任务日志

  • 普通用户只能为自己添加任务日志

  • 只有任务日志的所有者或管理员才能删除任务日志

  • 普通用户只能检索自己的任务日志

  • 管理员可以检索任何人的任务日志

  • findByUser方法需要有效的开始和结束日期

我们留下剩下的服务层类(UserServiceImplProjectServiceImpl)供您作为练习实现。

现在是时候为我们的服务层配置测试环境了。

测试服务层

服务层测试是企业应用程序开发的关键部分。如前所述,服务层封装了定义工作应用程序的业务规则,并且在这里花费了大量的开发时间。随着应用程序的增强、新模块的添加和业务规则的改变,业务逻辑会发展。因此,服务层的测试用例将代表应用程序的发展。良好记录的测试用例将增强应用程序生命周期的知识库,定义变化,并解释变化的目的。服务层测试用例将成为所有参与项目开发的开发人员所赞赏的信息库。

启用服务层测试的唯一更改是将以下内容添加到前一章中定义的testingContext.xml文件中:

<context:component-scan base-package="com.gieman.tttracker.service" />

添加到目录src/test/java/com/gieman/tttracker/service的测试用例类将可供测试。我们将向服务包添加以下类:

测试服务层

AbstractServiceForTesting超类将再次扩展AbstractTransactionalJUnit4SpringContextTests,定义@ContextConfiguration配置文件,并使用slf4j记录器覆盖默认的 Spring 记录器。

package com.gieman.tttracker.service;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.AbstractTransactionalJUnit4SpringContextTests;

@ContextConfiguration("/testingContext.xml")
public abstract class AbstractServiceForTesting extends AbstractTransactionalJUnit4SpringContextTests {

    final protected Logger logger = LoggerFactory.getLogger(this.getClass());

}
The CompanyServiceTest class is defined as:
package com.gieman.tttracker.service;

import com.gieman.tttracker.dao.ProjectDao;
import com.gieman.tttracker.domain.Company;
import com.gieman.tttracker.domain.Project;
import com.gieman.tttracker.vo.Result;
import java.util.List;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired;

public class CompanyServiceTest extends AbstractServiceForTesting {

    protected final String TEST_USERNAME = "bjones";
    @Autowired
    protected CompanyService companyService;    
    @Autowired
    protected ProjectDao projectDao; 

    @Test
    public void testFind() throws Exception {

        logger.debug("\nSTARTED testFind()\n");
        Result<List<Company>> allItems = companyService.findAll(TEST_USERNAME);

        assertTrue(allItems.getData().size() > 0);

        // get the first item in the list
        Company c1 = allItems.getData().get(0);

        int id = c1.getId();

        Result<Company> c2= companyService.find(id, TEST_USERNAME);

        assertTrue(c1.equals(c2.getData()));
        logger.debug("\nFINISHED testFind()\n");
    }    

    @Test
    public void testFindAll() throws Exception {

        logger.debug("\nSTARTED testFindAll()\n");
        int rowCount = countRowsInTable("ttt_company");

        if(rowCount > 0){                       

            Result<List<Company>> allItems = companyService.findAll(TEST_USERNAME);
            assertTrue("Company.findAll list not equal to row count of table ttt_company", rowCount == allItems.getData().size());

        } else {
            throw new IllegalStateException("INVALID TESTING SCENARIO: Company table is empty");
        }
        logger.debug("\nFINISHED testFindAll()\n");
    }    

    @Test
    public void testAddNew() throws Exception {

        logger.debug("\nSTARTED testAddNew()\n");
        //Company c = new Company();
        final String NEW_NAME = "New Test Company name";
        //c.setCompanyName(NEW_NAME);

        Result<Company> c2 = companyService.store(null, NEW_NAME, TEST_USERNAME);

        assertTrue(c2.getData().getId() != null);
        assertTrue(c2.getData().getCompanyName().equals(NEW_NAME));

        logger.debug("\nFINISHED testAddNew()\n");
    }

    @Test
    public void testUpdate() throws Exception {

        logger.debug("\nSTARTED testUpdate()\n");
        final String NEW_NAME = "Update Test Company New Name";

        Result<List<Company>> ar1 = companyService.findAll(TEST_USERNAME);
        Company c = ar1.getData().get(0);

        companyService.store(c.getIdCompany(), NEW_NAME, TEST_USERNAME);

        Result<Company> ar2 = companyService.find(c.getIdCompany(), TEST_USERNAME);

        assertTrue(ar2.getData().getCompanyName().equals(NEW_NAME));

        logger.debug("\nFINISHED testMerge()\n");

    }    

    @Test
    public void testRemove() throws Exception {

        logger.debug("\nSTARTED testRemove()\n");
        Result<List<Company>> ar1 = companyService.findAll(TEST_USERNAME);
        Company c = ar1.getData().get(0);

        Result<Company> ar = companyService.remove(c.getIdCompany(), TEST_USERNAME);        
        Result<Company> ar2 = companyService.find(c.getIdCompany(), TEST_USERNAME);

        // should fail as projects are assigned
        assertTrue(! ar.isSuccess());
        // finder still works
        assertTrue(ar2.getData() != null);

        logger.debug("\ntestRemove() - UNABLE TO DELETE TESTS PASSED\n");
        // remove all the projects
        c = ar2.getData();

        for(Project p : c.getProjects()){
            projectDao.remove(p);

        }
        c.getProjects().clear();

        logger.debug("\ntestRemove() - removed all projects\n");

        ar = companyService.remove(c.getIdCompany(), TEST_USERNAME);
        // remove should have succeeded
        assertTrue(ar.isSuccess());

        ar2 = companyService.find(c.getIdCompany(), TEST_USERNAME);
        // should not have been found
        assertTrue(ar2.getData() == null);
        assertTrue(ar2.isSuccess());

        logger.debug("\nFINISHED testRemove()\n");
    }     
}

通过在编辑器中右键单击文件并选择Test File选项来运行此测试用例应该会产生以下输出:

测试服务层

UserServiceTest类定义如下:

package com.gieman.tttracker.service;

import com.gieman.tttracker.dao.TaskLogDao;
import com.gieman.tttracker.dao.UserDao;
import com.gieman.tttracker.domain.TaskLog;
import com.gieman.tttracker.domain.User;
import com.gieman.tttracker.vo.Result;
import java.util.Calendar;
import java.util.List;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired;

public class UserServiceTest extends AbstractServiceForTesting {

    @Autowired
    protected UserService userService;
    @Autowired
    protected TaskLogDao taskLogDao;
    @Autowired
    protected UserDao userDao;
    private final String TEST_USERNAME = "jsmith";

    @Test
    public void testAddNew() throws Exception {

        String ADMIN_USERNAME = "bjones";

        logger.debug("\nSTARTED testAddNew()\n");

        Result<User> ar = userService.store("nusername", "David", "Francis", "df@tttracker.com", "admpwd", 'N', ADMIN_USERNAME);

        // should succeed
        logger.debug(ar.getMsg());
        assertTrue(ar.isSuccess());

        ar = userService.store(this.TEST_USERNAME, "David", "Francis", "df@tttracker.com", "admpwd", 'Y', ADMIN_USERNAME);
        logger.debug(ar.getMsg());
        assertTrue("Cannot assign email that is currently assigned to other user", !ar.isSuccess());

        ar = userService.store("user100", "David", "Francis", "user100@tttracker.com", "", 'Y', ADMIN_USERNAME);

        logger.debug(ar.getMsg());
        assertTrue("Cannot set empty password for user", !ar.isSuccess());

        ar = userService.store("user101", "David", "Francis", "  ", "validpwd", 'Y', ADMIN_USERNAME);

        logger.debug(ar.getMsg());
        assertTrue("Cannot set empty email for user", !ar.isSuccess());

        ar = userService.store(this.TEST_USERNAME, "David", "Francis", "diff@email.com", "validpwd", 'Y', ADMIN_USERNAME);

        logger.debug(ar.getMsg());
        assertTrue("Assigning new email to user is allowed", ar.isSuccess());

        logger.debug("\nFINISHED testAddNew()\n");
    }

    @Test
    public void testRemove() throws Exception {

        String ADMIN_USERNAME = "bjones";
        Calendar DEFAULT_START_DATE = Calendar.getInstance();
        Calendar DEFAULT_END_DATE = Calendar.getInstance();
        DEFAULT_START_DATE.set(Calendar.YEAR, 1900);
        DEFAULT_END_DATE.set(Calendar.YEAR, 3000);

        logger.debug("\nSTARTED testRemove()\n");

        User user1 = userDao.find(TEST_USERNAME);

        List<TaskLog> logs = taskLogDao.findByUser(user1, DEFAULT_START_DATE.getTime(), DEFAULT_END_DATE.getTime());
        Result<User> ar;

        if (logs.isEmpty()) {

            ar = userService.remove(TEST_USERNAME, ADMIN_USERNAME);
            logger.debug(ar.getMsg());
            assertTrue("Delete of user should be allowed as no task logs assigned!", ar.isSuccess());

        } else {

            // this user has task log assigned
            ar = userService.remove(TEST_USERNAME, ADMIN_USERNAME);
            logger.debug(ar.getMsg());
            assertTrue("Cascading delete of user to task logs not allowed!", !ar.isSuccess());

        }

        logs = taskLogDao.findByUser(user1, DEFAULT_START_DATE.getTime(), DEFAULT_END_DATE.getTime());
        if (logs.isEmpty()) {

            ar = userService.remove(TEST_USERNAME, ADMIN_USERNAME);
            logger.debug(ar.getMsg());
            assertTrue("Delete of user should be allowed as empty task log list!", ar.isSuccess());

        } else {

            // this user has task log assigned
            ar = userService.remove(TEST_USERNAME, ADMIN_USERNAME);
            logger.debug(ar.getMsg());
            assertTrue("Cascading delete of user to task logs not allowed!", !ar.isSuccess());

        }

        ar = userService.remove(ADMIN_USERNAME, ADMIN_USERNAME);
        logger.debug(ar.getMsg());
        assertTrue("Should not be able to delete yourself", !ar.isSuccess());

        logger.debug("\nFINISHED testRemove()\n");
    }

    @Test
    public void testLogon() {

        Result<User> ar = userService.findByUsernamePassword("jsmith", "admin");

        assertTrue("Valid user could not be found for valid user/pwd", ar.getData() != null);
        assertTrue(ar.isSuccess());

        ar = userService.findByUsernamePassword("jsmith", "ADMIN");

        assertTrue("Invalid logic - valid user found with UPPERCASE password", ar.getData() == null);
        assertTrue(!ar.isSuccess());

        ar = userService.findByUsernamePassword("JS@tttracker.com", "admin");

        assertTrue("Valid user could not be found for valid email/pwd", ar.getData() != null);
        assertTrue(ar.isSuccess());

        ar = userService.findByUsernamePassword("jsmith", "invalidadmin");
        assertTrue("Invalid user verified with wrong password", ar.getData() == null);
        assertTrue(!ar.isSuccess());

        ar = userService.findByUsernamePassword("blah", "blah");
        assertTrue("Invalid user verified with wrong username and password", ar.getData() == null);
        assertTrue(!ar.isSuccess());
    }
}

请注意,我们尚未定义UserService接口的实现,但我们已经编写了测试用例。由于使用了 Java 接口,我们能够在实现编码之前定义测试用例。这是测试驱动开发TDD)的关键概念之一,开发人员在编写通过测试的实际代码之前编写定义所需行为的测试用例。这种策略也是极限编程(en.wikipedia.org/wiki/Extreme_programming)的测试优先编程概念的一部分,在实现编码开始之前编写测试用例。

在编写UserServiceImpl后执行UserServiceTest测试文件应该会产生以下输出:

测试服务层

自动化服务层测试

按照以下方式更新pom.xml将在 Maven 构建过程中包含服务层测试用例:

<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-surefire-plugin</artifactId>
    <version>2.14.1</version>
  <configuration>
    <skipTests>false</skipTests>
    <includes>
      <include>**/dao/*Test.java</include>
      <include>**/service/*Test.java</include>
    </includes>
    <argLine>-javaagent:target/lib/spring-instrument-${spring.version}.jar</argLine>
  </configuration>
</plugin>

NetBeans菜单中选择Run | Test Project将执行daoservice包中的所有测试用例,产生以下输出:

自动化服务层测试

我们留给你添加剩余服务层实现的测试用例。

练习

根据接口定义实现ProjectServiceImplUserServiceImpl接口。在实现UserServiceImpl时要考虑的业务逻辑如下:

  • 只有管理员用户可以修改数据

  • email地址不能为空

  • password不能为空

  • adminRole标志必须是YN

  • 不允许用户删除自己

  • 如果有任务日志分配给用户,则用户不能被删除

通过执行UserServiceTest测试用例来确认您的UserServiceImpl实现。

总结

服务层是企业应用程序拥有的最有价值的资产。它是所有业务逻辑处理的核心,是包含最详细代码的层。我们的服务层与 DAO 实现没有耦合,并且独立于数据的使用方式。它纯粹专注于业务逻辑操作,通过数据传输对象设计模式以简单的通用类型值对象传递数据。

我们的服务层实现为业务逻辑操作明确定义了入口点。这是通过定义所有公开可访问方法的 Java 接口实现的。接口的使用还使我们能够在编写实现之前编写测试用例——这是测试驱动开发和极限编程的核心原则。在接下来的章节中,我们将使用这些接口来为 Web 客户端定义请求处理层。

第七章:网络请求处理层

请求处理层是将 HTTP 客户端与应用程序提供的服务粘合在一起的胶水。这一层的领域是请求的解释和数据的传输。我们的重点将放在 Ext JS 4 客户端消耗和提交的数据上。这些数据是以 JSON 格式存在,因此我们将讨论使用 Java JSON 处理 API 进行 JSON 解析和生成。然而,需要注意的是,任何类型的数据都可以通过适当的请求处理实现来暴露。如果需要的话,实现 RMI 或 RESTful 接口同样容易。

Web MVC 的简要历史

在历史背景下讨论模型-视图-控制器(MVC)范式可能看起来有些奇怪,因为大多数 Web 应用程序今天仍在使用这项技术。MVC 设计模式最早在 2000 年初就开始在开源的 Struts 框架中引起关注。这个框架鼓励使用 MVC 架构来促进处理和提供请求时的责任清晰划分。服务器端 Java 开发的 MVC 范式一直存在,以各种形式存在,最终演变成了设计良好且功能强大的 Spring MVC 框架。

使用 MVC 方法的理由非常简单。实现客户端和应用程序之间交互的 Web 层可以分为以下三种不同类型的对象:

  • 代表数据的模型对象

  • 负责显示数据的视图对象

  • 响应操作并为视图对象提供模型数据的控制器对象

每个 MVC 对象都会独立行事,耦合度低。例如,视图技术对控制器来说并不重要。视图是由 FreeMarker 模板、XSLT 转换或 Tiles 和 JSP 的组合生成并不重要。控制器只是将处理模型数据的责任传递给视图对象。

在这个历史讨论中需要注意的一个重要点是,所有的 MVC 处理都是在服务器上进行的。随着 JavaScript 框架数量的增加,特别是 Ext JS 4,MVC 范式已经从服务器转移到客户端浏览器。这是 Web 应用程序开发方式的根本变化,也是你正在阅读本书的原因!

企业 Web 应用程序的请求处理

以下图表清楚地标识了请求处理层在整体应用架构中的位置:

企业 Web 应用程序的请求处理

请求处理层接受客户端请求,并将相应的操作转发给适当的服务层方法。返回的 DTO(或者在领域驱动设计中也称为值对象)被检查,然后适当的响应被发送给客户端。与历史上的服务器端 MVC 编程不同,请求处理层不了解展示,只是作为应用程序的请求处理接口。

构建请求处理层

Ext JS 4 客户端的网络请求处理层是服务层接口的 JSON 生成代理。在这一层内,领域实体被转换为 JSON 表示;因此我们的第一步是创建一些辅助代码来简化这个任务。

有几个优秀的开源 JSON 生成项目可以帮助完成这项任务,包括 Jackson(jackson.codehaus.org)和 Google Gson(code.google.com/p/google-gson/)。这些库通过它们声明的字段将 POJO 解析为适当的 JSON 表示。随着 Java EE 7 的发布,我们不再需要第三方库。Java API for JSON Processing (JSR-353)在所有 Java EE 7 兼容的应用服务器中都可用,包括 GlassFish 4。我们将利用这个 API 来生成和解析 JSON 数据。

注意

如果您无法使用 Java EE 7 应用服务器,您将需要选择替代的 JSON 生成策略,例如 Jackson 或 Google Gson。

为 JSON 生成做准备

我们的第一个添加是一个新的领域接口:

package com.gieman.tttracker.domain;

import javax.json.JsonObject;
import javax.json.JsonObjectBuilder;

public interface JsonItem{

    public JsonObject toJson();
    public void addJson(JsonObjectBuilder builder);

}

这个非常简单的接口定义了两个方法来帮助生成 JSON。toJson方法创建一个代表实体的JsonObjectaddJson方法将实体属性添加到JsonObjectBuilder接口。我们很快就会看到这两种方法是如何使用的。

我们的每个领域实体都需要实现JsonItem接口,这可以通过简单地将接口添加到所有领域实体的抽象超类中来实现:

package com.gieman.tttracker.domain;

import java.io.Serializable;
import java.text.SimpleDateFormat;
import javax.json.Json;
import javax.json.JsonObject;
import javax.json.JsonObjectBuilder;
public abstract class AbstractEntity implements JsonItem, Serializable{

    @Override
 public JsonObject toJson() {

 JsonObjectBuilder builder = Json.createObjectBuilder();
 addJson(builder);
 return builder.build();
 }

}

JsonObjectBuilder接口定义了一组方法,用于向与构建器关联的 JSON 对象添加名称/值对。builder实例添加了实现addJson方法的后代类中定义的字段。我们将从Company对象开始。

实现 Company addJson 方法

需要添加到Company类的addJson方法如下:

@Override
public void addJson(JsonObjectBuilder builder) {
  builder.add("idCompany", idCompany)
     .add("companyName", companyName);
}

Company实例的JsonObject表示是通过在超类中调用builder.build()方法创建的。然后,生成的JsonObject可以由JsonWriter实例写入输出源。

实现 Project addJson 方法

需要添加到Project类的addJson方法如下:

@Override
public void addJson(JsonObjectBuilder builder) {

  builder.add("idProject", idProject)
     .add("projectName", projectName);

  if(company != null){
     company.addJson(builder);
  }
}   

请注意,在访问对象方法之前执行null对象测试始终是一个良好的做法。可以创建一个没有company实例的project对象,因此我们在向项目builder实例添加company JSON 属性之前执行company != null测试。我们可以直接使用以下代码将company属性添加到项目builder实例中:

builder.add("idProject", idProject)
     .add("projectName", projectName)
.add("idCompany", company.getIdCompany() )
     .add("companyName", company.getCompanyName() );

然而,我们现在已经在两个类(Company.addJsonProject.addJson)中复制了builder.add("idCompany"…)的代码,这样未来的维护容易出现错误。例如,将 JSON 属性名称从idCompany更改为companyId将需要扫描代码以检查可能在所有类中使用,而不仅仅是Company类。Company JSON 的创建应该属于Company类,因为我们已经实现了。

实现 Task addJson 方法

这个Task类将实现如下的addJson方法:

@Override
public void addJson(JsonObjectBuilder builder) {

  builder .add("idTask", idTask)
     .add("taskName", taskName);

  if(project != null){
     project.addJson(builder);

     Company company = project.getCompany();
     company.addJson(builder);
  }        
}

再次注意,我们如何将projectcompany类的addJson调用链接到任务的builder实例,以添加它们的 JSON 属性。

实现 User addJson 方法

User.addJson方法定义如下:

@Override
public void addJson(JsonObjectBuilder builder) {

  builder.add("username", username)
      .add("firstName", firstName)
      .add("lastName", lastName)
      .add("email", email)
      .add("adminRole", adminRole + "")
      .add("fullName", firstName + " " + lastName);
}

fullName属性仅供方便使用;我们可以在我们的 Ext JS 代码中轻松地创建一个fullName字段,它连接firstNamelastName字段。然而,将这段代码保留在 JSON 生成的源头可以更容易地进行维护。考虑业务变更请求“向User实体添加middleName字段”。然后,fullName包含新的middleName字段就变得非常简单,并且可以在不进行任何进一步更改的情况下提供给 Ext JS 客户端。

实现 TaskLog addJson 方法

addJson方法将所有TaskLog字段添加到builder实例中。DATE_FORMAT_yyyyMMdd常量用于将taskLogDate格式化为年/月/日的 8 位表示,并添加到TaskLog类中,如下所示:

static final SimpleDateFormat DATE_FORMAT_yyyyMMdd = new SimpleDateFormat("yyyyMMdd");

addJson方法将使用SimpleDateFormat实例来格式化taskLogDate字段:

public void addJson(JsonObjectBuilder builder) {

  builder.add("idTaskLog", idTaskLog)
    .add("taskDescription", taskDescription)
    .add("taskLogDate", taskLogDate == null ? "" : DATE_FORMAT_yyyyMMdd.format(taskLogDate))
    .add("taskMinutes", taskMinutes);

  if (user != null) {
    user.addJson(builder);
  }
  if (task != null) {
    task.addJson(builder);            
  }
}

taskLogDate字段的格式化方式在转换为 Ext JS 客户端的 JavaScript Date对象时不会被误解。如果没有使用SimpleDateFormat实例,builder实例将调用taskLogDate对象的默认toString方法来检索字符串表示,结果类似于以下内容:

Wed Aug 14 00:00:00 EST 2013

使用配置为yyyyMMdd日期模式的SimpleDateFormat实例将确保这样的日期格式为20130814

注意

在企业应用程序中,日期格式化可能会导致许多问题,如果没有采用标准策略。当我们开发应用程序供全球使用,涉及多个时区和不同语言时,这一点更加适用。日期应始终以一种可以在不同语言、时区和用户偏好设置下被解释的方式进行格式化。

关于 JSON 的说明

我们将使用 JSON 在 GlassFish 服务器和 Ext JS 客户端之间传输数据。传输是双向的;服务器将向 Ext JS 客户端发送 JSON 数据,而 Ext JS 客户端将以 JSON 格式将数据发送回服务器。服务器和客户端都将消耗生成 JSON 数据。

只要符合规范(tools.ietf.org/html/rfc4627),对于构造 JSON 数据没有规则。Ext JS 4 模型允许通过关联使用任何形式的有效 JSON 结构;我们的方法将 JSON 结构保持在其最简单的形式。先前定义的addJson方法返回简单的、扁平的数据结构,没有嵌套或数组。例如,task实例可以序列化为以下 JSON 对象(包含格式化以便阅读):

{
    success: true,
    data: {
        "idTask": 1,
        "taskName": "Write Chapter 7",
        "idProject": 1,
        "projectName": "My Book Project",
        "idCompany": 1,
        "companyName": "PACKT Publishing"
    }
}

data负载表示将被 Ext JS 4 客户端消耗的task对象。我们可以定义task对象的 JSON 表示如下:

{
    success: true,
    data: {
        "idTask": 1,
        "taskName": "Write Chapter 7",
        "project": {
            "idProject": 1,
            "projectName": "My Book Project ",
            "company": {
                "idCompany": 1,
                "companyName": "PACKT Publishing"
            }
        }
    }
}

在这个结构中,我们看到task实例属于一个project,而project又属于一个company。这两种 JSON 表示都是合法的;它们都包含相同的task数据,以有效的 JSON 格式。然而,这两者中哪一个更容易解析?哪一个更容易调试?作为企业应用程序开发人员,我们应该始终牢记 KISS 原则。保持简单,愚蠢(KISS)原则指出,大多数系统如果保持简单,并避免不必要的复杂性,将能够发挥最佳作用。

注意

保持你的 JSON 简单!我们知道复杂的结构是可能的;这只是通过在定义 Ext JS 4 模型以及读取或写入 JSON 数据时附加复杂性来实现的。简单的 JSON 结构更容易理解和维护。

创建请求处理程序

我们现在将构建用于为我们的 Ext JS 客户端提供 HTTP 请求的处理程序。这些处理程序将被添加到一个新的web目录中,如下截图所示:

创建请求处理程序

每个处理程序都将使用 Spring Framework 的@Controller注解来指示该类充当“控制器”的角色。严格来说,我们将要定义的处理程序在传统意义上并不是 Spring MVC 应用程序的控制器。我们只会使用非常小部分可用的 Spring 控制器功能来处理请求。这将确保我们的请求处理层非常轻量且易于维护。和往常一样,我们将首先创建一个所有处理程序都将实现的基类。

定义 AbstractHandler 超类

AbstractHandler超类定义了几个重要的方法,用于简化 JSON 生成。由于我们正在与 Ext JS 4 客户端集成,我们处理程序生成的 JSON 对象的结构特定于 Ext JS 4 组件期望的数据结构。我们将始终生成一个具有success属性的 JSON 对象,该属性包含一个布尔值truefalse。同样,我们将始终生成一个名为data的有效负载属性的 JSON 对象。这个data属性将具有一个有效的 JSON 对象作为其值,可以是一个简单的 JSON 对象,也可以是一个 JSON 数组。

注意

请记住,所有生成的 JSON 对象都将以一种格式呈现,可以被 Ext JS 4 组件消费,而无需额外的配置。

AbstractHandler类的定义如下:

package com.gieman.tttracker.web;

import com.gieman.tttracker.domain.JsonItem;
import java.io.StringReader;
import java.io.StringWriter;
import java.util.List;
import javax.json.Json;
import javax.json.JsonArrayBuilder;
import javax.json.JsonNumber;
import javax.json.JsonObject;
import javax.json.JsonObjectBuilder;
import javax.json.JsonReader;
import javax.json.JsonValue;
import javax.json.JsonWriter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public abstract class AbstractHandler {

    protected final Logger logger = LoggerFactory.getLogger(this.getClass());

    public static String getJsonSuccessData(List<? extends JsonItem> results) {

        final JsonObjectBuilder builder = Json.createObjectBuilder();
        builder.add("success", true);
        final JsonArrayBuilder arrayBuilder = Json.createArrayBuilder();

        for (JsonItem ji : results) {

            arrayBuilder.add(ji.toJson());
        }

        builder.add("data", arrayBuilder);

        return toJsonString(builder.build());
    }

    public static String getJsonSuccessData(JsonItem jsonItem) {

        final JsonObjectBuilder builder = Json.createObjectBuilder();
        builder.add("success", true);
        builder.add("data", jsonItem.toJson());

        return toJsonString(builder.build());

    }

    public static String getJsonSuccessData(JsonItem jsonItem, int totalCount) {

        final JsonObjectBuilder builder = Json.createObjectBuilder();
        builder.add("success", true);
        builder.add("total", totalCount);
        builder.add("data", jsonItem.toJson());

        return toJsonString(builder.build());
    }

    public static String getJsonErrorMsg(String theErrorMessage) {

        return getJsonMsg(theErrorMessage, false);

    }

    public static String getJsonSuccessMsg(String msg) {

        return getJsonMsg(msg, true);
    }
    public static String getJsonMsg(String msg, boolean success) {

        final JsonObjectBuilder builder = Json.createObjectBuilder();
        builder.add("success", success);
        builder.add("msg", msg);

        return toJsonString(builder.build());

    }

    public static String toJsonString(JsonObject model) {

        final StringWriter stWriter = new StringWriter();

        try (JsonWriter jsonWriter = Json.createWriter(stWriter)) {
            jsonWriter.writeObject(model);
        }

        return stWriter.toString();
    }

    protected JsonObject parseJsonObject(String jsonString) {

        JsonReader reader = Json.createReader(new StringReader(jsonString));
        return reader.readObject();

    }
    protected Integer getIntegerValue(JsonValue jsonValue) {

        Integer value = null;

        switch (jsonValue.getValueType()) {

            case NUMBER:
                JsonNumber num = (JsonNumber) jsonValue;
                value = num.intValue();
                break;
            case NULL:
                break;
        }

        return value;
    }
}

重载的getJsonSuccessData方法将分别生成一个 JSON 字符串,其中success属性设置为true,并且包含适当的data JSON 有效负载。getJsonXXXMsg变体也将生成一个 JSON 字符串,其中包含适当的success属性(对于成功的操作为true,对于失败的操作为false),以及一个包含适当消息的msg属性,供 Ext JS 组件使用。

parseJsonObject方法将使用JsonReader实例将 JSON 字符串解析为JsonObjecttoJsonString方法将使用JsonWriter实例将JsonObject写入其 JSON 字符串表示。这些类是 Java EE 7 javax.json包的一部分,它们使得使用 JSON 非常容易。

getIntegerValue方法用于将JsonValue对象解析为Integer类型。JsonValue对象可以是由javax.json.jsonValue.ValueType常量定义的几种不同类型,对值进行适当检查后,才尝试将JsonValue对象解析为Integer。这将允许我们以以下形式从 Ext JS 客户端发送 JSON 数据:

{
    success: true,
    data: {
        "idCompany":null,
        "companyName": "New Company"
    }
}

请注意,idCompany属性的值为nullgetIntegerValue方法允许我们解析可能为null的整数,这是使用默认的JsonObject.getInt(key)方法时不可能的(如果遇到null值,它会抛出异常)。

现在让我们定义我们的第一个处理程序类,用于处理用户身份验证。

定义 SecurityHandler 类

我们首先定义一个简单的辅助类,用于验证用户会话是否处于活动状态:

package com.gieman.tttracker.web;

import com.gieman.tttracker.domain.User;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;

public class SecurityHelper {
    static final String SESSION_ATTRIB_USER = "sessionuser";

    public static User getSessionUser(HttpServletRequest request) {
        User user = null;
        HttpSession session = request.getSession(true);
        Object obj = session.getAttribute(SESSION_ATTRIB_USER);

        if (obj != null && obj instanceof User) {
            user = (User) obj;
        }
        return user;
    }
}

静态常量SESSION_ATTRIB_USER将被用作保存经过身份验证的用户的会话属性的名称。所有处理程序类将调用SecurityHelper.getSessionUser方法从会话中检索经过身份验证的用户。用户会话可能因为不活动而超时,然后 HTTP 会话将被应用服务器移除。当这种情况发生时,SecurityHelper.getSessionUser方法将返回null,3T 应用程序必须优雅地处理这种情况。

SecurityHandler类用于验证用户凭据。如果用户成功验证,user对象将使用SESSION_ATTRIB_USER属性存储在 HTTP 会话中。用户也可以通过单击注销按钮从 3T 应用程序注销。在这种情况下,用户将从会话中移除。

验证和注销功能的实现如下:

package com.gieman.tttracker.web;

import com.gieman.tttracker.domain.User;
import com.gieman.tttracker.service.UserService;
import com.gieman.tttracker.vo.Result;
import static com.gieman.tttracker.web.AbstractHandler.getJsonErrorMsg;
import static com.gieman.tttracker.web.SecurityHelper.SESSION_ATTRIB_USER;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;

@Controller
@RequestMapping("/security")
public class SecurityHandler extends AbstractHandler {

    @Autowired
    protected UserService userService;

    @RequestMapping(value = "/logon", method = RequestMethod.POST, produces = {"application/json"})
    @ResponseBody
    public String logon(
            @RequestParam(value = "username", required = true) String username,
            @RequestParam(value = "password", required = true) String password,
            HttpServletRequest request) {

        Result<User> ar = userService.findByUsernamePassword(username, password);

        if (ar.isSuccess()) {
            User user = ar.getData();
            HttpSession session = request.getSession(true);
            session.setAttribute(SESSION_ATTRIB_USER, user);            
            return getJsonSuccessData(user);
        } else {
            return getJsonErrorMsg(ar.getMsg());
        }
    }

    @RequestMapping(value = "/logout", produces = {"application/json"})
    @ResponseBody
    public String logout(HttpServletRequest request) {

        HttpSession session = request.getSession(true);
        session.removeAttribute(SESSION_ATTRIB_USER);
        return getJsonSuccessMsg("User logged out...");
    }
}

SecurityHandler类引入了许多新的 Spring 注解和概念,需要详细解释。

@Controller 和@RequestMapping 注解

@Controller注解表示这个类充当 Spring 控制器的角色。由@Controller注释的类将被 Spring 组件扫描自动检测到,其配置在本章后面定义。但是控制器到底是什么?

Spring 控制器是 Spring MVC 框架的一部分,通常与模型和视图一起处理请求。我们既不需要模型也不需要视图;事实上,我们的处理生命周期完全由控制器本身管理。每个控制器负责一个 URL 映射,如类级@RequestMapping注释中定义的。这个映射将 URL 路径映射到控制器。在我们的 3T 应用程序中,任何以/security/开头的 URL 将被定向到SecurityHandler类进行进一步处理。然后将使用任何子路径来匹配方法级@RequestMapping注释。我们定义了两种方法,每种方法都有自己独特的映射。这导致以下 URL 路径到方法的映射:

  • /security/logon将映射到logon方法

  • /security/logout将映射到logout方法

任何其他以/security/开头的 URL 都不会匹配已定义的方法,并且会产生404错误。

方法的名称并不重要;重要的是@RequestMapping注释定义了用于处理请求的方法。

logon@RequestMapping注释中定义了两个额外的属性。method=RequestMethod.POST属性指定了/security/logon登录请求 URL 必须以POST请求提交。如果对/security/logon提交使用了其他请求类型,将返回404错误。Ext JS 4 使用 AJAX 存储和模型默认提交POST请求。然而,读取数据的操作将使用GET请求提交,除非另有配置。在 RESTful web 服务中使用的其他可能方法包括PUTDELETE,但我们只会在我们的应用程序中定义GETPOST请求。

注意

确保每个@RequestMapping方法都有适当的RequestMethod定义被认为是最佳实践。修改数据的操作应始终使用POST请求提交。持有敏感数据(例如密码)的操作也应使用POST请求提交,以确保数据不以 URL 编码格式发送。根据您的应用程序需求,读取操作可以作为GETPOST请求发送。

produces = {"application/json"}属性定义了映射请求的可生产媒体类型。我们所有的请求都将生成具有application/json媒体类型的 JSON 数据。每个由浏览器提交的 HTTP 请求都有一个Accept头,例如:

text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8

如果Accept请求不包括produces属性媒体类型,则 GlassFish 4 服务器将返回以下406 Not Acceptable错误:

The resource identified by this request is only capable of generating responses with characteristics not acceptable according to the request "accept" headers.

所有现代浏览器都将接受application/json内容类型。

@ResponseBody 注释

Spring 使用此注释来标识应直接将内容返回到 HTTP 响应输出流的方法(不放置在模型中或解释为视图名称,这是默认的 Spring MVC 行为)。实现这一点将取决于方法的返回类型。我们所有的请求处理方法都将返回 Java 字符串,Spring 将在内部使用StringHttpMessageConverter实例将字符串写入 HTTP 响应输出流,并使用值为text/plainContent-Type。这是将 JSON 数据对象字符串返回给 HTTP 客户端的一种非常简单的方法,因此使得请求处理成为一个微不足道的过程。

@RequestParam 注释

此方法参数上的注释将请求参数映射到参数本身。在logon方法中,我们有以下定义:

@RequestParam(value = "username", required = true) String username,
@RequestParam(value = "password", required = true) String password,

假设logon方法是GET类型(在SecurityHandler类中设置为POST,因此以下 URL 编码将无法工作),例如以下 URL 将调用具有username值为bjonespassword值为admin的方法:

/security/logon.json?username=bjones&password=admin

我们也可以用以下定义来编写这个方法:

@RequestParam(value = "user", required = true) String username,
@RequestParam(value = "pwd", required = true) String password,

然后将映射以下形式的 URL:

/security/logon.json?user=bjones&pwd=admin

请注意,@RequestParam注解的value属性映射到请求参数名称。

@RequestParam注解的required属性定义了该参数是否为必填字段。以下 URL 将导致异常:

/security/logon.json?username=bjones

显然缺少密码参数,这不符合required=true的定义。

请注意,required=true属性仅检查是否存在与@RequestParam注解的value匹配的请求参数。请求参数为空是完全有效的。以下 URL 不会引发异常:

/security/logon.json?username=bjones&password=

可选参数可以通过使用required=false属性进行定义,也可以包括defaultValue。考虑以下方法参数:

@RequestParam(value = "address", required = false, defaultValue = "Unknown address") String address

还考虑以下三个 URL:

  • /user/address.json?address=Melbourne

  • /user/address.json?address=

  • /user/address.json?

第一个 URL 将导致地址值为墨尔本,第二个 URL 将具有空地址,第三个 URL 将具有“未知地址”。请注意,仅当请求没有有效的地址参数时,defaultValue才会被使用,而不是地址参数为空时。

认证用户

我们的SecurityHandler类中的logon方法非常简单,这要归功于我们对服务层业务逻辑的实现。我们调用userService.findByUsernamePassword(username, password)方法并检查返回的Result。如果Result成功,SecurityHandler.logon方法将返回经过身份验证的用户的 JSON 表示。这是通过getJsonSuccessData(user)这一行实现的,它将导致以下输出被写入 HTTP 响应:

{
    "success": true,
    "data": {
        "username": "bjones",
        "firstName": "Betty",
        "lastName": "Jones",
        "email": "bj@tttracker.com",
        "adminRole": "Y",
        "fullName": "Betty Jones"
    }
}

请注意,上述格式仅用于可读性。实际响应将是一系列字符。然后将经过身份验证的用户添加到具有属性SESSION_ATTRIB_USER的 HTTP 会话中。然后,我们可以通过在我们的请求处理程序中调用SecurityHelper.getSessionUser(request)来识别经过身份验证的用户。

失败的Result实例将调用getJsonErrorMsg(ar.getMsg())方法,这将导致在 HTTP 响应中返回以下 JSON 对象:

{
    "success": false,
    "msg": "Unable to verify user/password combination!"
}

msg文本在UserServiceImpl.findByUsernamePassword方法中设置在Result实例上。根据success属性,Ext JS 前端将以不同方式处理每个结果。

登出

此方法中的逻辑非常简单:从会话中删除用户并返回成功的 JSON 消息。由于没有在@RequestMapping注解中定义RequestMethod,因此可以使用任何RequestMethod来映射此 URL(GETPOST等)。从此方法返回的 JSON 对象如下:

{
    "success": true,
    "msg": "User logged out..."
}

定义 CompanyHandler 类

此处理程序处理公司操作,并映射到/company/ URL 模式。

package com.gieman.tttracker.web;

import com.gieman.tttracker.domain.*;
import com.gieman.tttracker.service.CompanyService;
import com.gieman.tttracker.service.ProjectService;

import com.gieman.tttracker.vo.Result;
import static com.gieman.tttracker.web.SecurityHelper.getSessionUser;

import java.util.List;
import javax.json.JsonObject;
import javax.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Autowired;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RequestParam;

@Controller
@RequestMapping("/company")
public class CompanyHandler extends AbstractHandler {

    @Autowired
    protected CompanyService companyService;
    @Autowired
    protected ProjectService projectService;
    @RequestMapping(value = "/find", method = RequestMethod.GET, produces = {"application/json"})
    @ResponseBody
    public String find(
            @RequestParam(value = "idCompany", required = true) Integer idCompany,
            HttpServletRequest request) {

        User sessionUser = getSessionUser(request);
        if (sessionUser == null) {
            return getJsonErrorMsg("User is not logged on");
        }

        Result<Company> ar = companyService.find(idCompany, sessionUser.getUsername());

        if (ar.isSuccess()) {

            return getJsonSuccessData(ar.getData());

        } else {

            return getJsonErrorMsg(ar.getMsg());

        }
    }

    @RequestMapping(value = "/store", method = RequestMethod.POST, produces = {"application/json"})
    @ResponseBody
    public String store(
            @RequestParam(value = "data", required = true) String jsonData,
            HttpServletRequest request) {

        User sessionUser = getSessionUser(request);
        if (sessionUser == null) {
            return getJsonErrorMsg("User is not logged on");
        }

        JsonObject jsonObj = parseJsonObject(jsonData);

        Result<Company> ar = companyService.store(
                getIntegerValue(jsonObj.get("idCompany")), 
                jsonObj.getString("companyName"), 
                sessionUser.getUsername());

        if (ar.isSuccess()) {

            return getJsonSuccessData(ar.getData());

        } else {

            return getJsonErrorMsg(ar.getMsg());

        }
    }

    @RequestMapping(value = "/findAll", method = RequestMethod.GET, produces = {"application/json"})
    @ResponseBody
    public String findAll(HttpServletRequest request) {

        User sessionUser = getSessionUser(request);
        if (sessionUser == null) {
            return getJsonErrorMsg("User is not logged on");
        }

        Result<List<Company>> ar = companyService.findAll(sessionUser.getUsername());

        if (ar.isSuccess()) {

            return getJsonSuccessData(ar.getData());

        } else {

            return getJsonErrorMsg(ar.getMsg());

        }
    }

    @RequestMapping(value = "/remove", method = RequestMethod.POST, produces = {"application/json"})
    @ResponseBody
    public String remove(
            @RequestParam(value = "data", required = true) String jsonData,
            HttpServletRequest request) {
        User sessionUser = getSessionUser(request);
        if (sessionUser == null) {
            return getJsonErrorMsg("User is not logged on");
        }

        JsonObject jsonObj = parseJsonObject(jsonData);

        Result<Company> ar = companyService.remove(
                getIntegerValue(jsonObj.get("idCompany")), 
                sessionUser.getUsername());

        if (ar.isSuccess()) {

            return getJsonSuccessMsg(ar.getMsg());

        } else {

            return getJsonErrorMsg(ar.getMsg());

        }
    }
}

每个方法都根据方法级@RequestMapping注解定义的不同子 URL 进行映射。因此,CompanyHandler类将映射到以下 URL:

  • /company/find将使用GET请求将其映射到find方法

  • /company/store将使用POST请求将其映射到store方法

  • /company/findAll将使用GET请求将其映射到findAll方法

  • /company/remove将使用POST请求将其映射到remove方法

以下是一些需要注意的事项:

  • 每个处理程序方法都使用RequestMethod.POSTRequestMethod.GET进行定义。GET方法用于查找方法,POST方法用于修改数据的方法。这些方法类型是 Ext JS 用于每个操作的默认值。

  • 每个方法通过调用getSessionUser(request)从 HTTP 会话中检索用户,然后测试user值是否为null。如果用户不在会话中,则在 JSON 编码的 HTTP 响应中返回消息"用户未登录"。

  • POST方法具有一个保存 Ext JS 客户端提交的 JSON 数据的请求参数。然后在使用所需参数调用适当的服务层方法之前,将此 JSON 字符串解析为JsonObject

添加新公司的典型 JSON 数据有效负载如下:

{"idCompany":null,"companyName":"New Company"}

请注意,idCompany值为null。如果要修改现有公司记录,则 JSON 数据有效负载必须包含有效的idCompany值:

{"idCompany":5,"companyName":"Existing Company"}

还要注意,JSON 数据仅包含一个公司记录。可以配置 Ext JS 客户端通过提交类似以下数组的 JSON 数组来提交每个请求的多个记录:

[
  {"idCompany":5,"companyName":"Existing Company"},
  {"idCompany":4,"companyName":"Another Existing Company"}
]

但是,我们将限制我们的逻辑以处理每个请求的单个记录。

定义 ProjectHandler 类

ProjectHandler类处理项目操作,并将其映射到/project/ URL 模式如下:

package com.gieman.tttracker.web;

import com.gieman.tttracker.domain.*;
import com.gieman.tttracker.service.ProjectService;
import com.gieman.tttracker.vo.Result;
import static com.gieman.tttracker.web.SecurityHelper.getSessionUser;

import java.util.List;
import javax.json.JsonObject;
import javax.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Autowired;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RequestParam;

@Controller
@RequestMapping("/project")
public class ProjectHandler extends AbstractHandler {

    @Autowired
    protected ProjectService projectService;

    @RequestMapping(value = "/find", method = RequestMethod.GET, produces = {"application/json"})
    @ResponseBody
    public String find(
            @RequestParam(value = "idProject", required = true) Integer idProject,
            HttpServletRequest request) {

        User sessionUser = getSessionUser(request);
        if (sessionUser == null) {
            return getJsonErrorMsg("User is not logged on");
        }

        Result<Project> ar = projectService.find(idProject, sessionUser.getUsername());

        if (ar.isSuccess()) {
            return getJsonSuccessData(ar.getData());
        } else {
            return getJsonErrorMsg(ar.getMsg());
        }
    }

    @RequestMapping(value = "/store", method = RequestMethod.POST, produces = {"application/json"})
    @ResponseBody
    public String store(
            @RequestParam(value = "data", required = true) String jsonData,
            HttpServletRequest request) {

        User sessionUser = getSessionUser(request);
        if (sessionUser == null) {
            return getJsonErrorMsg("User is not logged on");
        }
        JsonObject jsonObj = parseJsonObject(jsonData);

        Result<Project> ar = projectService.store(
                getIntegerValue(jsonObj.get("idProject")),
                getIntegerValue(jsonObj.get("idCompany")),
                jsonObj.getString("projectName"),
                sessionUser.getUsername());

        if (ar.isSuccess()) {
            return getJsonSuccessData(ar.getData());
        } else {
            return getJsonErrorMsg(ar.getMsg());
        }
    }

    @RequestMapping(value = "/remove", method = RequestMethod.POST, produces = {"application/json"})
    @ResponseBody
    public String remove(
            @RequestParam(value = "data", required = true) String jsonData,
            HttpServletRequest request) {

        User sessionUser = getSessionUser(request);
        if (sessionUser == null) {
            return getJsonErrorMsg("User is not logged on");
        }

        JsonObject jsonObj = parseJsonObject(jsonData);

        Result<Project> ar = projectService.remove(
                getIntegerValue(jsonObj.get("idProject")), 
                sessionUser.getUsername());

        if (ar.isSuccess()) {
            return getJsonSuccessMsg(ar.getMsg());
        } else {
            return getJsonErrorMsg(ar.getMsg());
        }
    }

    @RequestMapping(value = "/findAll", method = RequestMethod.GET, produces = {"application/json"})
    @ResponseBody
    public String findAll(
            HttpServletRequest request) {

        User sessionUser = getSessionUser(request);
        if (sessionUser == null) {
            return getJsonErrorMsg("User is not logged on");
        }

        Result<List<Project>> ar = projectService.findAll(sessionUser.getUsername());

        if (ar.isSuccess()) {
            return getJsonSuccessData(ar.getData());
        } else {
            return getJsonErrorMsg(ar.getMsg());
        }
    }
}

ProjectHandler类将被映射到以下 URL:

  • /project/find将使用GET请求映射到find方法

  • /project/store将使用POST请求映射到store方法

  • /project/findAll将使用GET请求映射到findAll方法

  • /project/remove将使用POST请求映射到remove方法

请注意,在store方法中,我们再次从解析的JsonObject中检索所需的数据。添加新项目时,JSONdata有效负载的结构如下:

{"idProject":null,"projectName":"New Project","idCompany":1}

更新现有项目时,JSON 结构如下:

{"idProject":7,"projectName":"Existing Project with ID=7","idCompany":1}

您还会注意到,我们在每个方法中再次复制了相同的代码块,就像在CompanyHandler类中一样:

if (sessionUser == null) {
  return getJsonErrorMsg("User is not logged on");
}

每个剩余处理程序中的每个方法也将需要相同的检查;用户必须在会话中才能执行操作。这正是为什么我们将通过引入 Spring 请求处理程序拦截器的概念来简化我们的代码。

Spring HandlerInterceptor 接口

Spring 的请求处理映射机制包括使用处理程序拦截器拦截请求的能力。这些拦截器用于对请求应用某种功能,例如我们的示例中检查用户是否在会话中。拦截器必须实现org.springframework.web.servlet包中的HandlerInterceptor接口,可以通过以下三种方式应用功能:

  • 在实现preHandle方法之前执行处理程序方法

  • 通过实现postHandle方法执行处理程序方法后

  • 通过实现afterCompletion方法执行完整请求后

通常使用HandlerInterceptorAdapter抽象类以及每个方法的预定义空实现来实现自定义处理程序。我们的UserInSessionInterceptor类定义如下:

package com.gieman.tttracker.web;

import com.gieman.tttracker.domain.User;
import static com.gieman.tttracker.web.SecurityHelper.getSessionUser;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;

public class UserInSessionInterceptor extends HandlerInterceptorAdapter {

    private final Logger logger = LoggerFactory.getLogger(this.getClass());

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
            throws Exception {

        logger.info("calling preHandle with url=" + request.getRequestURI());

        User sessionUser = getSessionUser(request);

        if (sessionUser == null) {
            String json = "{\"success\":false,\"msg\":\"A valid user is not logged on!\"}";
            response.getOutputStream().write(json.getBytes());
            return false;
        } else {
            return true;
        }
    }
}

当使用UserInSessionInterceptor拦截请求时,preHandle方法中的代码检查是否有用户在会话中。如果找到sessionUser,处理程序将返回true,表示应继续正常处理。正常处理可能导致调用其他处理程序拦截器(如果已配置),最终到达映射的处理程序方法之前。

如果未找到sessionUser,则立即向响应输出流发送一个简单的 JSON 字符串。然后,preHandle方法返回false,表示拦截器已经处理了响应,不需要进一步处理。

通过将UserInSessionInterceptor应用于需要用户会话测试的每个请求,我们可以从每个处理程序方法中删除以下代码:

if (sessionUser == null) {
  return getJsonErrorMsg("User is not logged on");
}

我们如何将拦截器应用于适当的处理程序方法?这是在我们自定义 Spring MVC 配置时完成的。

Spring MVC 配置

Spring MVC 框架可以使用 XML 文件或 Java 配置类进行配置。我们将使用 Spring MVC 配置类来配置我们的应用程序,首先是WebAppConfig类:

package com.gieman.tttracker.web;

import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;

@EnableWebMvc
@Configuration
@ComponentScan("com.gieman.tttracker.web")
public class WebAppConfig extends WebMvcConfigurerAdapter {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new UserInSessionInterceptor())
                .addPathPatterns(new String[]{
                    "/**"
                }).excludePathPatterns("/security/**");
    }
}

WebAppConfig类扩展了WebMvcConfigurerAdapter,这是一个方便的基类,为WebMvcConfigurer接口的每个方法提供了空实现。我们重写addInterceptors方法来注册我们的UserInSessionInterceptor并定义将用于应用拦截器的处理程序映射。路径模式/**将拦截所有映射,我们从中排除/security/**映射。安全映射应包含用户会话检查,因为用户尚未经过身份验证并且不会在会话中。

@ComponentScan("com.gieman.tttracker.web")注解将触发对com.gieman.tttracker.web包中@Controller注释类的扫描。然后,Spring 将识别和加载我们的处理程序类。@EnableWebMvc注解将此类标识为 Spring Web MVC 配置类。此注释导致 Spring 加载所需的WebMvcConfigurationSupport配置属性。剩下的@Configuration注解在 Spring 应用程序启动期间将此类标识为组件扫描的候选类。然后,WebAppConfig类将自动加载以在 Spring MVC 容器中使用。

WebAppConfig类配置了 MVC 环境;WebApp类配置了servlet容器:

package com.gieman.tttracker.web;

import org.springframework.web.servlet.support.AbstractAnnotationConfigDispatcherServletInitializer;

public class WebApp extends AbstractAnnotationConfigDispatcherServletInitializer {

    @Override 
    protected String[] getServletMappings() {
        return new String[]{
            "/ttt/*"
        };
    }

    @Override
    protected Class<?>[] getRootConfigClasses() {
        return new Class<?>[0];
    }

    @Override
    protected Class<?>[] getServletConfigClasses() {
        return new Class<?>[]{WebAppConfig.class};
    }
}

AbstractAnnotationConfigDispatcherServletInitializer类在 Spring 3.2 中作为WebApplicationInitializer实现的基类引入。这些实现注册使用WebAppConfig类中定义的注释类配置的DispatcherServlet(请注意,此类在getServletConfigClasses方法中返回)。

感兴趣的最终配置项是getServletMappings方法,它将传入的请求映射到通过@ComponentScan注解发现的WebAppConfig处理程序集。我们应用程序中以/ttt/开头的每个 URL 都将被定向到适当的请求处理程序进行处理。从 Ext JS 4 客户端提交的一些示例 URL 可能包括以下内容:

  • /ttt/company/findAll.json将映射到CompanyHandler.findAll方法

  • /ttt/project/find.json?idProject=5将映射到ProjectHandler.find方法

请注意,URL 中的/ttt/前缀定义了我们 Spring MVC 组件的入口点。不以/ttt/开头的 URL 将由 Spring MVC 容器处理。

我们现在将实现一个处理程序来介绍 Spring 控制器中的数据绑定。

定义TaskLogHandler

TaskLogHandler类处理任务日志操作,并映射到/taskLog/ URL 模式:

package com.gieman.tttracker.web;

import com.gieman.tttracker.domain.*;
import com.gieman.tttracker.service.TaskLogService;
import com.gieman.tttracker.vo.Result;
import static com.gieman.tttracker.web.SecurityHelper.getSessionUser;
import java.text.ParseException;
import java.text.SimpleDateFormat;

import java.util.Date;
import java.util.List;
import javax.json.JsonObject;
import javax.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.propertyeditors.CustomDateEditor;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.InitBinder;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RequestParam;

@Controller
@RequestMapping("/taskLog")
public class TaskLogHandler extends AbstractHandler {

    static final SimpleDateFormat DATE_FORMAT_yyyyMMdd = new SimpleDateFormat("yyyyMMdd");

    @Autowired
    protected TaskLogService taskLogService;
    @InitBinder
    public void initBinder(WebDataBinder binder) {

        binder.registerCustomEditor(Date.class, new CustomDateEditor(DATE_FORMAT_yyyyMMdd, true));

    }

    @RequestMapping(value="/find", method = RequestMethod.GET, produces = {"application/json"})
    @ResponseBody
    public String find(
            @RequestParam(value = "idTaskLog", required = true) Integer idTaskLog,
            HttpServletRequest request) {

        User sessionUser = getSessionUser(request);

        Result<TaskLog> ar = taskLogService.find(idTaskLog, sessionUser.getUsername());

        if (ar.isSuccess()) {
            return getJsonSuccessData(ar.getData());
        } else {
            return getJsonErrorMsg(ar.getMsg());
        }
    }
    @RequestMapping(value = "/store", method = RequestMethod.POST, produces = {"application/json"})
    @ResponseBody
    public String store(
            @RequestParam(value = "data", required = true) String jsonData,
            HttpServletRequest request) throws ParseException {

        User sessionUser = getSessionUser(request);

        JsonObject jsonObj = parseJsonObject(jsonData);

        String dateVal = jsonObj.getString("taskLogDate");

        Result<TaskLog> ar = taskLogService.store(
                getIntegerValue(jsonObj.get("idTaskLog")),
                getIntegerValue(jsonObj.get("idTask")),
                jsonObj.getString("username"),
                jsonObj.getString("taskDescription"),
                DATE_FORMAT_yyyyMMdd.parse(dateVal),
                jsonObj.getInt("taskMinutes"),
                sessionUser.getUsername());

        if (ar.isSuccess()) {
            return getJsonSuccessData(ar.getData());
        } else {
            return getJsonErrorMsg(ar.getMsg());
        }
    }

    @RequestMapping(value = "/remove", method = RequestMethod.POST, produces = {"application/json"})
    @ResponseBody
    public String remove(
            @RequestParam(value = "data", required = true) String jsonData,
            HttpServletRequest request) {

        User sessionUser = getSessionUser(request);

        JsonObject jsonObj = parseJsonObject(jsonData);

        Result<TaskLog> ar = taskLogService.remove(
                getIntegerValue(jsonObj.get("idTaskLog")), 
                sessionUser.getUsername());
        if (ar.isSuccess()) {
            return getJsonSuccessMsg(ar.getMsg());
        } else {
            return getJsonErrorMsg(ar.getMsg());
        }
    }

    @RequestMapping(value = "/findByUser", method = RequestMethod.GET, produces = {"application/json"})
    @ResponseBody
    public String findByUser(
            @RequestParam(value = "username", required = true) String username,
            @RequestParam(value = "startDate", required = true) Date startDate,
            @RequestParam(value = "endDate", required = true) Date endDate,
            HttpServletRequest request) {

        User sessionUser = getSessionUser(request);

        Result<List<TaskLog>> ar = taskLogService.findByUser(
                username,
                startDate,
                endDate,
                sessionUser.getUsername());

        if (ar.isSuccess()) {
            return getJsonSuccessData(ar.getData());
        } else {
            return getJsonErrorMsg(ar.getMsg());
        }
    }
 }

因此,TaskLogHandler类将映射到以下 URL:

  • /taskLog/find将使用GET请求映射到find方法

  • /taskLog/store将使用POST请求映射到store方法

  • /taskLog/findByUser将使用GET请求映射到findByUser方法

  • /taskLog/remove将使用POST请求映射到remove方法

我们还引入了一个新的注解:@InitBinder注解。

@InitBinder注解

@InitBinder注解用于将方法标记为“数据绑定感知”。该方法使用编辑器初始化WebDataBinder对象,这些编辑器用于将 String 参数转换为它们的 Java 等效形式。这种转换最常见的需求是日期的情况。

日期可以用许多不同的方式表示。以下所有日期都是等效的:

  • 06-Dec-2013

  • 2013 年 12 月 6 日

  • 06-12-2013(英国日期,简写形式)

  • 12-06-2013(美国日期,简写形式)

  • 06-Dez-2013(德国日期)

  • 2013 年 12 月 6 日

通过 HTTP 请求发送日期表示可能会令人困惑!我们都了解这些日期大部分代表什么,但是如何将这些日期转换为 java.util.Date 对象呢?这就是 @InitBinder 方法的用途。指定所需日期格式的代码涉及为 Date 类注册 CustomDateEditor 构造函数:

binder.registerCustomEditor(Date.class, new CustomDateEditor(DATE_FORMAT_yyyyMMdd, true));

这将允许 Spring 使用 DATE_FORMAT_yyyyMMdd 实例来解析客户端以 yyyyMMdd 格式发送的日期。以下 URL 现在将正确转换为 findByUser 方法所需的参数:

/taskLog/findByUser?username=bjones&startDate=20130719&endDate=20130812

CustomDateEditor 构造函数中的 true 参数确保空日期被赋予值 null

更多关于 Spring MVC

我们的处理程序方法和 Spring MVC 实现仅使用了 Spring MVC 框架的一小部分。在这一章节中未涵盖到的真实应用程序可能遇到的情景包括以下要求:

  • URI 模板模式用于通过路径变量访问 URL 的部分。它们特别有用于简化 RESTful 处理,并允许处理程序方法访问 URL 模式中的变量。公司 find 方法可以映射到诸如 /company/find/5/ 的 URL,其中 5 代表 idCompany 的值。这是通过使用 @PathVariable 注解和形式为 /company/find/{idCompany} 的映射来实现的。

  • 使用 @SessionAttrribute 注解在请求之间在 HTTP 会话中存储数据。

  • 使用 @CookieValue 注解将 cookie 值映射到方法参数,以便将其绑定到 HTTP cookie 的值。

  • 使用 @RequestHeader 注解将请求头属性映射到方法参数,以便将其绑定到请求头。

  • 异步请求处理允许释放主 Servlet 容器线程并允许处理其他请求。

  • 将 Spring MVC 与 Spring Security 集成(强烈推荐企业应用程序)。

  • 解析多部分请求以允许用户从 HTML 表单上传文件。

应该考虑使用 Spring MVC 测试框架测试处理程序类。有关更多信息,请参阅docs.spring.io/spring/docs/3.2.x/spring-framework-reference/html/testing.html#spring-mvc-test-framework的全面指南。该框架提供了用于测试客户端和服务器端 Spring MVC 应用程序的 JUnit 支持。

Spring MVC 框架远不止一个章节能够涵盖的内容。我们建议用户从docs.spring.io/spring/docs/3.2.x/spring-framework-reference/html/mvc.html这个在线资源中了解更多关于 Spring MVC 功能的信息。

练习

实现 UserHandlerTaskHandler 类,将请求映射到以下方法:

  • /task/find 将使用 GET 请求映射到 TaskHandler.find 方法

  • /task/store 将使用 POST 请求映射到 TaskHandler.store 方法

  • /task/findAll 将使用 GET 请求映射到 TaskHandler.findAll 方法

  • /task/remove 将使用 POST 请求映射到 TaskHandler.remove 方法

  • /user/find 将使用 GET 请求映射到 UserHandler.find 方法

  • /user/store 将使用 POST 请求映射到 UserHandler.store 方法

  • /user/findAll 将使用 GET 请求映射到 UserHandler.findAll 方法

  • /user/remove 将使用 POST 请求映射到 UserHandler.remove 方法

总结

我们的 Java Web 界面现在已经完成 - 我们已经创建了一个针对 Ext JS 4 客户端进行了优化的完全功能的请求处理层。HTTP 客户端可访问的 URL 通过类和方法级别的@RequestMapping注解映射到请求处理类。每个处理程序方法通过明确定义的接口与服务层交互,并在返回 HTTP 响应中的 JSON 数据之前处理Result数据传输对象。我们已经使用 Java 配置类配置了 Spring Web MVC 容器,并实现了一个 Spring 拦截器来检查用户是否已经经过身份验证。

在第八章中,“在 GlassFish 上运行 3T”,我们将完成我们的 Spring 配置,并在 GlassFish 4 服务器上部署 3T 应用程序。然后,我们应用程序堆栈中的每个层将准备好在为 Ext JS 4 客户端请求提供服务时发挥其作用。

第八章:在 GlassFish 上运行 3T

在本章中,我们将在 GlassFish 4 服务器上部署我们的 3T 应用程序。成功的部署将需要几个新的配置文件,以及对现有文件的更新。您可能已经熟悉一些来自第五章中定义的测试配置文件,但还会介绍一些特定于 GlassFish 的新文件。

我们还将配置 GlassFish 服务器,使其能够独立于 NetBeans IDE 运行。企业环境通常会有许多在不同主机上运行的 GlassFish 服务器实例。了解基本的 GlassFish 配置是一项重要的技能,我们将详细介绍连接池配置。

在本章的结尾,您将能够看到基于您在《第七章》中精心映射的 URL 的动态 HTTP 响应,Web 请求处理层

配置 3T Web 应用程序

Web 应用程序配置需要几个新文件,需要将这些文件添加到WEB-INF目录中,如下截图所示。现在创建这些文件:

配置 3T Web 应用程序

请注意,beans.xml文件是由 NetBeans 创建的,但不是我们配置所必需的。现在让我们详细查看这些文件。

Spring applicationContext.xml 文件

applicationContext.xml文件配置 Spring 容器,与我们在第五章中创建的testingContext.xml文件非常相似。文件的内容如下:

<?xml version="1.0" encoding="UTF-8"?>
<beans 

  xsi:schemaLocation="
      http://www.springframework.org/schema/beans
      http://www.springframework.org/schema/beans/spring-beans-3.2.xsd
  http://www.springframework.org/schema/context
  http://www.springframework.org/schema/context/spring-context-3.2.xsd
  http://www.springframework.org/schema/tx
  http://www.springframework.org/schema/tx/spring-tx-3.2.xsd">
    <bean id="loadTimeWeaver" 
class="org.springframework.instrument.classloading.glassfish.GlassFishLoadTimeWeaver" />
    <bean id="entityManagerFactory" 
        p:persistenceUnitName="tttPU"
class="org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean" 
    />

    <!-- Transaction manager for JTA  -->
    <tx:jta-transaction-manager />
    <!-- enable the configuration of transactional behavior based on annotations -->
    <tx:annotation-driven />

    <!-- checks for @Autowired beans -->
    <context:annotation-config/>    

    <!-- Scan for Repository/Service annotations -->
    <context:component-scan base-package="com.gieman.tttracker.dao"/>
    <context:component-scan base-package="com.gieman.tttracker.service"/>
</beans>

此文件用于 Spring 初始化和配置 JPA EntityManagerFactoryTransactionManager DAO 和 Service 层对象。将applicationContext.xml文件与testingContext.xml文件进行比较,可以确定简单 Java 容器和企业应用服务器提供的 Java EE 容器之间的关键差异:

  • 数据源通过JNDIJava 命名和目录接口)从 GlassFish 应用服务器中检索,并且不是由 Spring 在applicationContext.xml文件中创建或管理。persistence.xml文件中的 JNDI 配置设置在本章后面定义。

  • 加载时间织入器是特定于 GlassFish 的。

  • 事务管理器是基于JTAJava 事务 API)的,并由 GlassFish 服务器提供。它不是由 Spring 创建或管理的。<tx:jta-transaction-manager /><tx:annotation-driven />定义是配置 Spring 容器内的事务行为所需的全部内容。

注意

您应该熟悉剩余的配置属性。请注意,组件扫描针对daoservice包执行,以确保在这些类中自动装配 Spring bean。

当 Spring 容器加载applicationContext.xml文件时,第七章中定义的 MVC 配置类会通过类路径扫描自动发现,并加载以配置 Web 应用程序组件。

web.xml 文件

web.xml Web 应用程序部署描述符文件代表 Java Web 应用程序的配置。它用于配置 Servlet 容器并将 URL 映射到每个配置的 Servlet。每个 Java Web 应用程序在 Web 应用程序根目录的WEB-INF目录中必须有一个web.xml

3T Web 应用程序需要以下web.xml定义:

<?xml version="1.0" encoding="UTF-8"?>
<web-app version="3.0"   xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd">
    <context-param>
        <param-name>contextConfigLocation</param-name>
        <param-value>/WEB-INF/applicationContext.xml</param-value>
    </context-param>
    <listener>
        <listener-class>
            org.springframework.web.context.ContextLoaderListener
        </listener-class>
    </listener>
    <session-config>
        <session-timeout>30</session-timeout>
        <cookie-config>
            <name>JSESSIONID_3T</name>
        </cookie-config>
    </session-config>
    <welcome-file-list>
        <welcome-file>index.html</welcome-file>
    </welcome-file-list>
</web-app>

以下是一些关键点:

  • 定义contextConfigLocation值的context-param元素是可选的,如果 Spring 配置文件命名为applicationContext.xml(如果未提供,则这是预期的默认文件名)。但是,为了完整起见,我们总是包括此属性。它定义了主 Spring 配置文件的位置。

  • 使用类org.springframework.web.context.ContextLoaderListener的监听器由 Spring 用于初始化加载应用程序上下文。这是启动 Spring 容器的入口点,并尝试加载contextConfigLocation文件。如果无法解析或无效,则会抛出异常。

  • session-config属性定义会话超时(30 分钟的不活动时间)和会话 cookie 名称。

  • welcome-file-list标识 GlassFish 将提供的文件,如果在 URL 中未明确指定。

glassfish-web.xml 文件

glassfish-web.xml文件配置 GlassFish 与 GlassFish 服务器特定的其他 Web 应用程序属性:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE glassfish-web-app PUBLIC "-//GlassFish.org//DTD GlassFish Application Server 3.1 Servlet 3.0//EN" "http://glassfish.org/dtds/glassfish-web-app_3_0-1.dtd">
<glassfish-web-app>
<context-root>/</context-root>
</glassfish-web-app>

context-root属性标识部署的 Web 应用程序的服务器路径。我们将 3T 应用程序部署到服务器的上下文根。这意味着 3T 请求处理程序可以直接从 Web 应用程序的根目录访问,如下例所示:

/ttt/company/findAll.json

context-root属性更改为/mylocation,例如,将需要以下格式的 URL:

/mylocation/ttt/company/findAll.json

配置 Maven 的 pom.xml 文件

在前几章中尝试依赖项和插件时,可能已更改了各种pom.xml设置。现在重访此文件并确认构建和部署项目的属性是否正确非常重要。您应该具有以下基本的pom.xml配置:

<?xml version="1.0" encoding="UTF-8"?>
<project   xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.gieman</groupId>
    <artifactId>task-time-tracker</artifactId>
    <version>1.0</version>
    <packaging>war</packaging>
    <name>task-time-tracker</name>
    <properties>
        <endorsed.dir>
            ${project.build.directory}/endorsed
        </endorsed.dir>
        <project.build.sourceEncoding>
            UTF-8
        </project.build.sourceEncoding>
        <spring.version>3.2.4.RELEASE</spring.version>
        <logback.version>1.0.13</logback.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.eclipse.persistence</groupId>
            <artifactId>javax.persistence</artifactId>
            <version>2.1.0-SNAPSHOT</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>org.eclipse.persistence</groupId>
            <artifactId>eclipselink</artifactId>
            <version>2.5.0-SNAPSHOT</version>
            <scope>provided</scope>
        </dependency>        
        <dependency>
            <groupId>org.eclipse.persistence</groupId>
            <artifactId>
                org.eclipse.persistence.jpa.modelgen.processor
            </artifactId>
            <version>2.5.0-SNAPSHOT</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>javax</groupId>
            <artifactId>javaee-web-api</artifactId>
            <version>7.0</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>ch.qos.logback</groupId>
            <artifactId>logback-classic</artifactId>
            <version>${logback.version}</version>
        </dependency>    
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>4.11</version>
            <scope>test</scope>
        </dependency>        
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>5.1.26</version>
            <scope>provided</scope>
        </dependency>            
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-context</artifactId>
            <version>${spring.version}</version>
        </dependency>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-context-support</artifactId>
            <version>${spring.version}</version>
        </dependency>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-tx</artifactId>
            <version>${spring.version}</version>
        </dependency>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-jdbc</artifactId>
            <version>${spring.version}</version>
        </dependency>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-orm</artifactId>
            <version>${spring.version}</version>
        </dependency>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-instrument</artifactId>
            <version>${spring.version}</version>
        </dependency>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-webmvc</artifactId>
            <version>${spring.version}</version>
        </dependency>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-test</artifactId>
            <version>${spring.version}</version>
            <scope>test</scope>
        </dependency>

    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.1</version>
                <configuration>
                    <source>1.7</source>
                    <target>1.7</target>
                    <compilerArguments>
                        <endorseddirs>
                            ${endorsed.dir}
                        </endorseddirs>
                    </compilerArguments>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-war-plugin</artifactId>
                <version>2.3</version>
                <configuration>
                  <warName>${project.build.finalName}</warName>
                  <failOnMissingWebXml>false</failOnMissingWebXml>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-dependency-plugin</artifactId>
                <version>2.6</version>
                <executions>
                    <execution>
                        <id>copy-endorsed</id>
                        <phase>validate</phase>
                        <goals>
                            <goal>copy</goal>
                        </goals>
                        <configuration>
                            <outputDirectory>
                                ${endorsed.dir}
                            </outputDirectory>
                            <silent>true</silent>
                            <artifactItems>
                                <artifactItem>
                                    <groupId>javax</groupId>
                                    <artifactId>
                                        javaee-endorsed-api
                                    </artifactId>
                                    <version>7.0</version>
                                    <type>jar</type>
                                </artifactItem>
                            </artifactItems>
                        </configuration>
                    </execution>
                    <execution>
                        <id>copy-all-dependencies</id>
                        <phase>compile</phase>
                        <goals>
                            <goal>copy-dependencies</goal>
                        </goals>
                        <configuration>
                            <outputDirectory>
                                ${project.build.directory}/lib
                            </outputDirectory>
                            <includeScope>compile</includeScope>
                        </configuration>                        
                    </execution>                  
                </executions>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>2.14.1</version>
                <configuration>
                    <skipTests>true</skipTests>
                    <includes>
                        <include>**/dao/*Test.java</include>
                        <include>**/service/*Test.java</include>
                    </includes>
                    <argLine>
-javaagent:target/lib/spring-instrument-${spring.version}.jar
                    </argLine>
                </configuration>
            </plugin>            

        </plugins>
    </build>
    <repositories>
        <repository>
          <url>
            http://download.eclipse.org/rt/eclipselink/maven.repo/
          </url>
          <id>eclipselink</id>
          <layout>default</layout>
          <name>
            Repository for library EclipseLink (JPA 2.1)
          </name>
        </repository>
    </repositories>
</project>

在反向工程过程中添加了几个依赖项,还添加了 EclipseLink 的<repository>定义。只需要进行一些更改:

  • 添加 MySQL 连接器:应使用最新版本的mysql-connector-java依赖项。GlassFish 不提供 MySQL 连接器,并且将在本章后面的某个部分中将其复制到应用程序服务器中。范围设置为provided,以便在构建 WAR 文件时不包括此 JAR。

  • 关闭 Surefire 测试插件:如果在构建过程中关闭测试,您的部署速度将会更快。将maven-surefire-plugin条目的skipTests更改为true。这将在本地构建和部署项目时跳过测试阶段。

注意

构建企业应用程序通常在专用的构建服务器上执行,该服务器执行测试用例并报告构建过程的成功或失败。禁用测试阶段应该只在开发人员的机器上进行,以加快构建和部署过程。开发人员不希望在每次更改类时等待 30 分钟来执行测试套件。测试阶段不应该在构建服务器上被禁用执行。

将 eclipselink.target-server 添加到 persistence.xml 文件

persistence.xml文件需要包含eclipselink.target-server属性才能完全启用事务行为。位于src/main/resources/META-INFpersistence.xml文件应如下所示:

<?xml version="1.0" encoding="UTF-8"?>
<persistence version="2.1" 

  xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/persistence
  http://xmlns.jcp.org/xml/ns/persistence/persistence_2_1.xsd">

  <persistence-unit name="tttPU" transaction-type="JTA">
    <provider>
        org.eclipse.persistence.jpa.PersistenceProvider
    </provider>
    <jta-data-source>jdbc/tasktimetracker</jta-data-source>
    <exclude-unlisted-classes>false</exclude-unlisted-classes>
    <properties>
        <property name="eclipselink.target-server"
            value="SunAS9"/>
        <property name="eclipselink.logging.level" 
            value="INFO"/>
    </properties>
  </persistence-unit>
</persistence>

如果没有此添加,您的应用程序将无法使用事务。eclipselink.logging.level也可以更改以根据需要增加或减少日志输出。

将 logback.xml 文件添加到资源目录

logback.xml文件应该添加到src/main/resources/中,以便启用应用程序的日志记录。该文件的内容与测试logback.xml文件相同,如下所示:

<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="true" scanPeriod="30 seconds" >
    <contextName>TaskTimeTracker</contextName>
    <appender name="STDOUT"
        class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
          <pattern>
           %d{HH:mm:ss.SSS} [%thread] %-5level %logger{5} - %msg%n
          </pattern>
        </encoder>
    </appender>
    <logger name="com.gieman.tttracker"
        level="DEBUG" additivity="false">
        <appender-ref ref="STDOUT" />
    </logger>
    <logger name="com.gieman.tttracker.dao"
        level="DEBUG" additivity="false">
        <appender-ref ref="STDOUT" />
    </logger>
    <logger name="com.gieman.tttracker.domain"
        level="DEBUG" additivity="false">
        <appender-ref ref="STDOUT" />
    </logger>
    <logger name="com.gieman.tttracker.service"
        level="DEBUG" additivity="false">
        <appender-ref ref="STDOUT" />
    </logger>
    <logger name="com.gieman.tttracker.web"
        level="DEBUG" additivity="false">
        <appender-ref ref="STDOUT" />
    </logger>
    <root level="INFO">
        <appender-ref ref="STDOUT" />
    </root>
</configuration>

配置 GlassFish 服务器

NetBeans 捆绑的 GlassFish 4 服务器在首次运行项目时会自动配置。这意味着根据项目的当前状态动态设置所需的任何资源。所有这些属性都会被复制到setup目录中的glassfish-resources.xml文件中,如下截图所示:

配置 GlassFish 服务器

在数据库反向工程过程中,glassfish-resources.xml文件被修改以包括 JPA 所需的数据库连接池和 JDBC 资源。因此,该文件的内容定义了所需的 GlassFish 连接池详细信息。

重要的是要了解,此文件由 NetBeans 用于动态配置分配给项目的 GlassFish 服务器。在现实世界的情况下,GlassFish 服务器是由管理员配置的,并且部署 Web 应用程序是通过命令行或通过 GlassFish 管理控制台完成的。在正常的企业环境中,您不会通过 NetBeans 部署应用程序,因此非常有必要对 GlassFish 从最基本的原则进行配置有一个基本的了解。本节专门用于配置用于 3T 的 GlassFish 服务器连接池。虽然在 NetBeans 上运行 3T 并不严格要求这样做,但我们强烈建议您花时间通过以下步骤完全配置您的 GlassFish 服务器。

这将确保您了解在不同物理服务器上为运行 3T 应用程序配置 GlassFish 服务器所需的内容。

  1. 配置 GlassFish 服务器的第一步是执行清理和构建配置 GlassFish 服务器

  2. 构建完成后,导航到target/lib,如下截图所示,以查看项目所需的 JAR 文件:配置 GlassFish 服务器

  3. 打开文件资源管理器窗口(Windows 资源管理器或 OS X Finder),导航到此目录,并将mysql-connector-java-5.1.26.jar文件复制到您的 GlassFish 域libs目录,如下截图所示:配置 GlassFish 服务器

重命名 setup 目录

src/main/目录中的setup目录包含glassfish-resources.xml文件,应将其重命名以确保 NetBeans 不会动态配置 GlassFish 的这些属性。我们建议将目录重命名为setup-original

在 NetBeans 中启动 GlassFish 服务器

导航到服务选项卡;通过右键单击GlassFish Server 4.0节点,选择如下截图所示的启动

在 NetBeans 中启动 GlassFish 服务器

您应该在 NetBeans IDE 底部看到服务器输出,并重新加载 GlassFish Server 4.0 节点。现在,您可以右键单击GlassFish Server 4.0节点,并选择查看域管理控制台

在 NetBeans 中启动 GlassFish 服务器

这将启动您的默认浏览器并加载域管理控制台

配置 JDBC 连接池

本节将使用 GlassFish 管理控制台来配置 3T 应用程序所需的 JDBC 连接池和 JDBC 资源。

  1. 打开资源节点,并导航到JDBC 连接池选项卡:配置 JDBC 连接池

注意

您可能会看到一个名为mysql_task_time_tracker_rootPool或类似的连接池,如前面的截图所示。这是由 NetBeans 在以前的运行中使用glassfish-resources.xml文件中指定的属性创建的。如果您希望继续使用此连接池,则可以跳过剩余部分。我们建议您删除此条目,并继续遵循以下步骤,以了解如何配置 GlassFish 连接池。

  1. 单击新建按钮,然后在单击下一步按钮之前输入以下详细信息:配置 JDBC 连接池

  2. 下一个屏幕看起来令人生畏,但只需要输入一些条目。一直向下滚动,直到您可以查看附加属性部分:配置 JDBC 连接池

  3. 这里有很多属性!幸运的是,除非您熟悉 MySQL 数据库管理,否则只需要一些属性。您可以安全地删除所有列出的属性,以保持配置简单,然后输入与原始glassfish-resources.xml文件对应的以下属性:配置 JDBC 连接池

  4. 所需的基本字段是URL用户密码。保存这些设置将返回到JDBC 连接池屏幕:配置 JDBC 连接池

  5. 单击3TPool名称以再次打开设置,然后单击Ping按钮以测试连接。您现在应该看到以下结果:配置 JDBC 连接池

配置 JDBC 资源

最后一步是创建JDBC 资源。单击此节点以显示配置的资源:

配置 JDBC 资源

单击新建…按钮,然后输入以下详细信息:

配置 JDBC 资源

JNDI 名称必须与persistence.xml文件中定义的<jta-data-source>属性相同,因此设置为jdbc/tasktimetracker。单击确定按钮以保存资源配置。刷新后的节点现在应该显示新创建的资源。

您现在已经完成了 GlassFish JDBC 设置的配置。

运行 3T

现在我们建议您停止 GlassFish 并重新启动 NetBeans,以确保之前所做的所有更改在 IDE 中是最新的。最后一步是运行 3T 应用程序:

运行 3T

这应该导致大量输出,最终将 3T 应用程序部署到 GlassFish 服务器:

运行 3T

请注意,GlassFish Server 4.0输出中的最终警告可以忽略;这是在 NetBeans 中从根上下文部署应用程序时的已知问题。

NetBeans 的最后一个操作将是打开您的默认浏览器,显示第一章中显示的欢迎页面,准备开发环境。您应该注意浏览器中的 URL 现在是:

http://localhost:8080/

而不是原始的:

http://localhost:8080/task-time-tracker

这是由glassfish-web.xml中的<context-root>/</context-root>属性引起的,它定义了 Web 应用程序路径的根。3T Web 应用程序现在部署到上下文根,不需要前缀即可访问已部署的 3T 应用程序。

您现在可以尝试加载一个映射的 URL,例如/ttt/company/findAll.json。按照所示在浏览器中输入并按下Enter键。您应该看到以下结果:

运行 3T

这条消息来自我们在上一章实现的UserInSessionInterceptor。会话检查失败,因为我们当前没有登录,将前面的 JSON 消息返回给浏览器。该类中的logger.info消息也应该在 GlassFish 输出中可见:

运行 3T

您现在可以尝试使用以下截图中显示的参数进行登录操作:

运行 3T

这个结果可能会让你感到惊讶。请求 URL 被映射到SecurityHandler.logon方法,该方法在@RequestMapping注解中被定义为method = RequestMethod.POST。这将限制对该方法的访问仅限于POST请求,而浏览器提交的 URL 编码参数是一个GET请求。这导致了 GlassFish 返回 405 HTTP 状态消息。在第十章中,登录和维护用户,我们将使用适当的POST请求来实现登录过程。

您应该注意,所有处理程序 URL 将通过后续章节中开发的 Ext JS 4 应用程序的 AJAX 调用访问。您将不会像之前显示的那样在浏览器中看到这些 URL。

在没有 NetBeans 的情况下管理 GlassFish

在 NetBeans 中启动和停止 GlassFish 很容易和方便。然而,在企业环境中,停止/启动过程将由包装asadmin实用程序的脚本管理。您可以在GlassFish 用户管理指南中找到该实用程序的完整描述,网址为glassfish.java.net/docs/4.0/administration-guide.pdf

asadmin实用程序用于从命令行或脚本执行 GlassFish 服务器的管理任务。您可以使用此实用程序代替本章前面使用的 GlassFish 管理控制台界面。几乎可以在管理控制台中执行的每个操作都有一个相应的命令可以使用asadmin执行。

asadmin实用程序位于{as-install}/bin目录中。如果没有提供asadmin的完整路径,则应该从该目录中运行命令。要启动域,可以执行以下命令:

asadmin start-domain domain1

domain1参数表示要启动的域的名称。从 Windows 命令提示符中执行此命令将导致以下输出:

在没有 NetBeans 的情况下管理 GlassFish

停止运行中的 GlassFish 域同样简单。使用以下命令:

asadmin stop-domain domain1

这将导致以下输出:

在没有 NetBeans 的情况下管理 GlassFish

我们将继续在 NetBeans 中启动和停止 GlassFish,但将在第十三章中重新讨论asadmin将您的应用程序移至生产环境

总结

本章重点介绍了配置 3T Web 应用程序以部署到 GlassFish 4 服务器所需的步骤。定义了 Spring 配置文件,并配置了web.xml文件以在启动时加载 Spring 容器。您将被引导完成 GlassFish 连接池配置过程,并将 3T Web 应用程序部署到 GlassFish 4 服务器的上下文根。

这是我们企业应用程序开发过程中的关键点。我们现在已经完全涵盖了 Java 开发人员的领域,构建了一个功能齐全的后端系统,可以为任何 JSON 客户端提供动态请求。第九章,开始使用 Ext JS 4,将介绍强大的 Ext JS 4 框架,并开始我们的前端开发之旅。

第九章:开始使用 Ext JS 4

Ext JS 4是迄今为止最复杂的 JavaScript 库,并为几乎所有实际设计问题提供了惊人的小部件集。它可以满足我们开发需要的一切,以开发需要高度用户交互的复杂、跨浏览器兼容的应用程序。在本章中,我们将:

  • 了解核心的 Ext JS 4 MVC 概念

  • 探索实际项目设计和开发惯例

  • 安装 Ext JS 4 开发框架并引入 Sencha Cmd

  • 为 3T 应用程序生成一个 Ext JS 4 应用程序骨架

Ext JS 自从作为Yahoo 用户界面YUI)库的扩展开始以来已经走过了很长的路。每个新版本都是对上一个版本的重大改进,Ext JS 4 也不例外。对于 Ext JS 的新手来说,他们会欣赏到优雅的框架设计和一致的 API,而那些从 Ext JS 3 过渡过来的人则会欣赏到许多方面的改进,包括 MVC 设计模式的引入。无论你的背景如何,本章都将帮助你在 Ext JS 4 上提高工作效率。

值得注意的是,Ext JS 4 并不是当今唯一可用的 JavaScript MVC 框架。例如,Angular.jsBackbone.js都是非常有能力的开发框架,具有类似于 Ext JS 4 的 MVC 功能。然而,它们没有 Ext JS 4 那样广泛的文档、构建工具和商业支持,这使得 Ext JS 4 非常适合企业应用程序开发。

应用程序设计的重要性

在开发企业应用程序时,除了技术之外,深思熟虑和一致的应用程序设计对于应用程序的可维护性、可扩展性和整体成本至关重要。良好设计的应用程序的好处包括以下几点:

  • 应用程序将更容易理解。如果有一致的做事方式,新团队成员将很快上手。

  • 应用程序将更容易维护。如果你有一致的应用程序设计准则,增强和新功能的实现将会更简单。

  • 代码一致性。一个设计良好的应用程序将有良好的命名约定、目录结构和编码标准。

  • 应用程序将更适合多开发人员。在大型项目中,许多人将参与其中,一致的设计策略将确保每个人都在同一页面上。

当你开始一个新项目并兴奋地为概念验证演示制作第一个原型时,往往会忽视一些无形的好处。能够从简单的开始重构和扩展项目往往是企业应用开发的关键因素。无论项目在最初阶段看起来多么小,你可以肯定,一旦业务用户熟悉应用程序,他们就会想要改变工作流程和布局。新功能将被请求,旧功能将被弃用。组件将随着应用程序的演变而移动和重新设计。一个一致和深思熟虑的应用程序设计将使这些项目生命周期过程变得不那么可怕。值得庆幸的是,Ext JS 4 应用程序架构本身鼓励正式和结构良好的应用程序设计。

Ext JS 4 MVC 概念

当 MVC 设计模式首次在 Ext JS 4 中引入时,它彻底改变了 Ext JS 框架。虽然 MVC 作为一种设计模式是众所周知的,但这是第一次一个复杂的 JavaScript 框架实现了这种策略。以下是一些关键的好处:

  • MVC 设计模式将代码组织成逻辑领域或组件类型,使代码更易于理解

  • MVC 模块化可以简化组件测试和重构,因为每个对象都有明确定义的目的

  • MVC 设计模式架构鼓励更清晰的代码,明确分离数据访问、呈现和业务逻辑。

这些是前一版 Ext JS 3 的巨大优势,那里唯一真正的 MVC 组件是V视图)。留给 Ext JS 3 开发人员去构建M模型)和C控制器)的工作,通常导致混乱和不一致的代码。现在让我们看看 Ext JS 4 如何定义 MVC 设计模式。

模型

Ext JS 4 模型是表示领域实体的属性集合。也许不足为奇的是,我们的 3T 应用程序将需要一个CompanyProjectTaskUserTaskLog模型定义,就像它们在我们的 Java 领域层中所表示的那样。与我们的 Java 领域对象的主要区别是,Ext JS 4 模型等效物将具有持久性意识。由于 Ext JS 4 的data包,每个模型实例将知道如何持久化和管理其状态。

视图

Ext JS 4 视图代表一个逻辑视觉组件块,可能包括面板、工具栏、网格、表单、树和图表。Ext JS 4 视图始终驻留在自己的文件中,并且应尽可能“愚蠢”。这意味着视图中不应该有 JavaScript 业务逻辑;它的目的是呈现数据并为用户提供交互能力。

控制器

Ext JS 4 控制器可以被宽泛地描述为将应用程序逻辑粘合在一起的粘合剂。控制器在处理事件处理和跨视图交互方面起着核心作用,并定义应用程序工作流程。绝大多数 JavaScript 业务逻辑代码将驻留在控制器中。

Ext JS 4 的灵活性

虽然我们对不同的 MVC 组件有清晰的定义,但在 Ext JS 4 框架本身中有相当大的实现灵活性。我们不需要使用控制器或模型;事实上,我们可以轻松地使用在 Ext JS 3 中遵循的相同策略构建一个完全可用的 Ext JS 4 应用程序。然而,这将是一个错误,应该尽量避免。利用 MVC 架构进行企业应用程序开发的好处是显著的,包括但不限于更简单和更健壮的代码库。

Ext JS 4 设计约定和概念

Sencha Ext JS 4 团队在定义约定方面做了大量工作,您应该考虑遵循这些约定来构建企业应用程序。这些包括标准的目录结构、命名约定和详细的设计最佳实践。我们强烈建议您浏览Sencha Ext JS 4 文档网站上的许多教程和指南,以熟悉他们的应用程序设计建议。

本书将遵循 Ext JS 4 团队概述的常见设计策略,对于其相关部分中引入的细微差异进行注释和解释。本书的范围不包括基本的 Ext JS 4 概念,您可能需要参考Sencha Ext JS 4 文档来进一步理解。

实用约定

一个结构良好的 Ext JS 4 项目,具有一致的命名约定,将是一个令人愉快的工作。拥有数百个文件的企业应用程序应该以易于学习和维护的方式进行结构化。当你问同事,“显示 xyz 小部件的编辑工具栏的文件在哪里?”时,这应该是一个罕见的情况。

项目结构

Ext JS 4 的目录结构,包括顶级应用程序和名为controllermodelstoreview的子目录,应始终使用。这是任何 Ext JS 4 应用程序的默认目录结构,并允许与 Sencha Cmd 构建工具的即插即用集成。

大型项目有数百个 JavaScript 文件,因此拥有一致的项目结构非常重要。实际的命名空间,特别是在view目录中,可以简化项目结构,使其更容易找到组件。例如,在第十章 登录和维护用户,第十一章 构建任务日志用户界面和第十二章 3T 管理简单中,我们将创建一个包含以下屏幕截图中显示的文件的view结构(在左侧):

项目结构

前面的屏幕截图显示了同一目录中的所有视图(在其右侧)。哪种方式更好?这取决于项目的性质和文件数量。企业项目通常在模块级别进行命名空间划分,有许多子目录逻辑地分组相关组件。较小的项目也可以很容易地具有所有文件都在同一目录中的平面结构。无论选择哪种结构,都要保持一致!任何新开发人员都应该很容易找到组件,而不必搜索大量文件和目录。

命名约定

我们建议定义一个易于理解和遵循的一致的命名约定。应该很容易在文件系统和您正在使用的 IDE 中找到文件。

命名存储和模型

每个模型应该以它所代表的实体的单数形式命名(例如,CompanyProjectTaskTaskLogUser)。每个存储应该以类似的单数方式命名。我们曾在 Ext JS 3 中看到存储名称后缀为Store(例如,ProjectStore),但这在 Ext JS 4 中不推荐。控制器会自动为每个存储创建一个get函数,通过在存储名称后添加Store。将存储命名为ProjectStore将导致在引用存储的每个控制器中生成一个名为getProjectStoreStore的函数。因此,我们建议您在不使用Store后缀的情况下使用存储名称。

存储名称通常以其单数形式替换为复数形式。例如,项目存储通常被命名为Projects。一致性再次是关键。如果决定使用复数形式,那么每个存储名称都应该使用复数形式。在我们的应用程序中,这将导致CompaniesProjectsTasksTaskLogsUsers存储。这有时会导致拼写混淆;我们曾看到CompaniesCompanys都用于复数形式的Company。当英语不是您的第一语言时,可能很难知道实体的正确复数名称,例如领土、国家、公司、货币和状态。因此,我们更喜欢在命名存储时使用单数形式。

命名视图

考虑以下情况,我们一直在研究 Sencha Docs 网站上的面板:

命名视图

有四个不同的Panel文件打开(Ext.grid.PanelExt.tab.PanelExt.form.PanelExt.panel.Panel)。在这种情况下,尝试定位Ext.grid.Panel文件是令人沮丧的;在最坏的情况下,您将需要点击四个不同的选项卡项。在大型项目中,可能会有许多值得称为Panel的面板容器。我们建议为每个文件赋予一个唯一的名称,无论其命名空间如何。与模型和存储不同,模型和存储命名空间使用相同的文件名,我们不建议在视图类之间使用相同的文件名。例如,文件app.view.user.Listapp.view.tasklog.List在 IDE 选项卡栏中很难区分。使这些文件名唯一要容易得多,即使它们可能存在于不同的命名空间中。

后缀类类型的使用是另一个值得讨论的问题。Ext JS 3 在类名后使用了类型后缀。这导致了GridPanelFormPanelTabPanelPanel文件名。它们都是面板。通过检查文件名很容易确定类是什么。Ext JS 4 采用了命名空间方法,并放弃了描述性名称。前面的例子变成了Ext.grid.PanelExt.tab.PanelExt.form.PanelExt.panel.Panel。每个文件都被命名为Panel,如果不知道它所在的目录,这并不是很有帮助。

无论您实施何种命名约定,保持一致是很重要的。我们将使用以下命名约定:

  • 所有命名空间文件夹名称都将是小写。

  • 用于表示项目列表的任何类都将以List结尾。List的实现并不重要;我们不在乎列表是使用网格、简单模板还是数据视图创建的。

  • 任何表单类都将以Form结尾。

  • 任何树类都将以Tree结尾。

  • 任何窗口类都将以Window结尾。

  • 任何管理一组相关组件的定位和布局的组件都将以Manage为前缀。这样的类通常包含适当布局的工具栏、列表、表单和选项卡面板。

您可能希望引入适合您的开发环境的其他约定。这很好;重要的是要保持一致,并确保每个人都理解并遵守您的约定。

命名控制器

我们建议所有控制器类的名称都以Controller结尾。这样它们在任何 IDE 中都很容易识别。例如,负责用户维护的控制器将被命名为UserController

命名 xtype

我们建议对每个类使用小写类名作为xtype。这是确保每个视图类的文件名唯一的另一个很好的理由。UserListxtypeuserlistUserFormxtypeuserformManageUsersxtypemanageusers。不会有混淆。

Ext JS 4 开发环境

Ext JS 4 开发所需的两个核心组件如下:

  • Sencha Cmd 工具:这是一个跨平台的基于 Java 的命令行工具,提供许多选项来帮助管理应用程序的生命周期

  • Ext JS 4 SDK(软件开发工具包):包含所有应用程序开发所需的源文件、示例、资源和压缩脚本

我们现在将检查并安装这些组件。

安装 Sencha Cmd

Sencha Cmd 工具可从www.sencha.com/products/sencha-cmd/download下载。该文件大小约为 46MB,需要在运行安装过程之前解压缩。

安装 Sencha Cmd

点击“下一步”查看“许可协议”部分。您需要接受协议后才能点击“下一步”按钮:

安装 Sencha Cmd

在下面的截图中显示的屏幕提示输入“安装目录”。我们建议您将 Sencha Cmd 工具安装在易于访问的目录中(Mac 用户为/Users/Shared/,Windows 用户为C:\):

安装 Sencha Cmd

点击“下一步”继续。这将显示一个提示,指示安装程序现在准备开始在您的计算机上安装 Sencha Cmd。再次点击“下一步”继续安装。最后的提示将确认安装 Sencha Cmd:

安装 Sencha Cmd

您现在可以查看已安装的文件,如下面的截图所示:

安装 Sencha Cmd

要确认安装,打开命令提示符(Windows)或终端(Mac),输入sencha,然后按Enter键。这将确认 Sencha Cmd 已添加到系统路径,并应产生类似于以下截图所示的输出:

安装 Sencha Cmd

请注意,任何当前打开的控制台/终端窗口都需要关闭并重新打开,以确保重新加载安装路径更改。最后一步是通过输入来检查是否有可用的升级:

sencha upgrade –-check

这个命令应该显示一个适当的消息,如下截图所示:

安装 Sencha Cmd

可以通过省略––check参数来升级 Sencha Cmd 的版本。有关 Sencha 命令行选项的完整列表,请参阅docs.sencha.com/extjs/4.2.2/#!/guide/command。此页面还包含许多有用的故障排除提示和解释。此外,您还可以通过执行sencha help来使用命令行帮助。执行sencha help命令将显示详细的帮助选项:

安装 Sencha Cmd

安装 Ext JS 4 SDK

SDK 可以从www.sencha.com/products/extjs下载。上一步将在以下位置创建一个 Sencha 目录:

  • 对于 Windows 用户,C:\Sencha

  • 对于 Mac 用户,/Users/Shared/Sencha

下载 SDK 后,您应该在这个 Sencha 目录中创建一个ext-xxx目录,其中xxx代表 Ext JS 4 框架的版本。然后,您可以将 SDK 解压缩到此目录中,从而得到以下截图中显示的结构:

安装 Ext JS 4 SDK

现在,您可以初始化 Ext JS 4 3T 应用程序骨架。

生成 3T Ext JS 4 应用程序骨架

骨架生成命令的格式是:

sencha -sdk /path/to/sdk generate app MyApp /path/to/MyApp

运行此命令将所有必需的 SDK 文件复制到/path/to/MyApp目录,并创建资源的骨架,准备进行开发。您必须为SDKMyApp目录使用完整路径。

重要的是要记住 3T 应用程序是一个 Maven 项目,Web 内容根目录是 Maven 目录结构中的webapp目录。在第一章中创建的项目文件夹准备开发环境webapp目录(在 Windows 上)可以在C:\projects\task-time-tracker\src\main\webapp找到。

在 Mac 上,它可以在/Users/{username}/projects/task-time-tracker/src/main/webapp找到。

现在可以通过执行以下命令(适用于 Windows 平台)生成 3T 应用程序骨架:

sencha –sdk C:\Sencha\ext-4.2.2 generate app TTT C:\projects\task-time-tracker\src\main\webapp

请注意,此命令必须在一行上。TTT参数代表应用程序名称,并将用于生成应用程序命名空间。我们可以使用TaskTimeTracker,但缩写形式更容易书写!

从终端执行该命令应该会产生大量输出,最后显示一些红色错误:

生成 3T Ext JS 4 应用程序骨架

不要太担心[ERR]警告;Sencha Cmd 已经识别出index.html文件的存在,并用 Sencha Cmd 版本替换了它。原始文件被复制到index.html.$old。我们不需要备份文件(它是在 NetBeans 项目创建过程中创建的);可以安全地删除它。

打开 NetBeans IDE 现在将在 3T 项目的webapp目录中显示许多新文件和目录:

生成 3T Ext JS 4 应用程序骨架

现在,您可以运行项目以在浏览器中查看输出:

生成 3T Ext JS 4 应用程序骨架

这是由generate app命令在构建项目骨架时在index.html页面中创建的默认 Ext JS 4 应用程序内容。现在让我们看看已生成的关键文件。

index.html 文件

index.html文件包括以下列表:

<!DOCTYPE HTML>
<html>
<head>
  <meta charset="UTF-8">
  <title>TTT</title>
  <!-- <x-compile> -->
    <!-- <x-bootstrap> -->
      <link rel="stylesheet" href="bootstrap.css">
      <script src="img/ext-dev.js"></script>
      <script src="img/bootstrap.js"></script>
    <!-- </x-bootstrap> -->
    <script src="img/app.js"></script>
  <!-- </x-compile> -->
</head>
<body></body>
</html>

请注意页面内容中的x-compilex-bootstrap标记。这些标记由 Sencha Cmd 工具使用,并允许编译器识别应用程序根目录中的脚本(默认文件始终为app.js)。编译器还会忽略仅在开发过程中使用的框架的引导部分。在生成生产应用程序时,所有所需的文件都将在构建过程中被拉取。这将在第十三章中详细介绍,将您的应用程序移至生产环境

您应该注意,ext-dev.js文件是唯一需要的 Ext JS 4 框架资源。该文件用于在开发阶段进行动态 JavaScript 类加载。然后框架将动态检索应用程序所需的任何 JavaScript 资源。

app.js 和 Application.js 文件

app.js文件是应用程序的入口点。文件的内容,包括生成的注释,如下所示:

/*
    This file is generated and updated by Sencha Cmd. You can edit this file as needed for your application, but these edits will have to be merged by Sencha Cmd when upgrading.
*/
Ext.application({
  name: 'TTT',
  extend: 'TTT.Application',
  autoCreateViewport: true
});

Ext.application扩展了TTT.Application类,该类在app/Application.js文件中定义如下:

Ext.define('TTT.Application', {
  name: 'TTT',
  extend: 'Ext.app.Application',
  views: [
    // TODO: add views here
  ],
  controllers: [
    // TODO: add controllers here
  ],
  stores: [
    // TODO: add stores here
  ]
});

Application.js文件将包含我们 3T 应用程序特定的代码。

注意

您应该注意,这与之前的 Ext JS 4 教程中描述的设置不同,其中app.js文件包含特定于应用程序的属性(视图、控制器、存储和应用程序函数)。之前概述的方法将所有特定于应用程序的代码保留在app目录中。

我们对自动生成的Application.js文件的第一个更改是添加launch函数:

Ext.define('TTT.Application', {
    name: 'TTT',
    extend: 'Ext.app.Application',
    views: [
        // TODO: add views here
    ],
    controllers: [
        // TODO: add controllers here
    ],
    stores: [
        // TODO: add stores here
    ],
    launch: function() {
 Ext.create('TTT.view.Viewport');
 }
});

现在我们可以从app.js文件中删除autoCreateViewport:true,因为创建视图的逻辑现在在launch函数中。launch函数本身将在下一章中进行增强,以实现用户登录,所以还有很多代码要写!更新后的app.js文件如下:

Ext.application({
    name: 'TTT',
    extend: 'TTT.Application'    
});

bootstrap.js 和 bootstrap.css 文件

bootstrap.jsbootstrap.css文件是由 Sencha Cmd 生成的,不应该被编辑。它们在内部用于初始化和配置开发环境。

app/Viewport.js 和 app/view/Main.js 文件

Ext JS 4 视图端口是一个容器,它会调整自身大小以使用整个浏览器窗口。Viewport.js的定义如下:

Ext.define('TTT.view.Viewport', {
    extend: 'Ext.container.Viewport',
    requires:[
        'Ext.layout.container.Fit',
        'TTT.view.Main'
    ],
    layout: {
        type: 'fit'
    },
    items: [{
        xtype: 'app-main'
    }]
});

items数组中只添加了一个视图;TTT.view.Main函数,其中有一个名为app-mainxtype函数:

Ext.define('TTT.view.Main', {
    extend: 'Ext.container.Container',
    requires:[
        'Ext.tab.Panel',
        'Ext.layout.container.Border'
    ],
    xtype: 'app-main',
    layout: {
        type: 'border'
    },
    items: [{
        region: 'west',
        xtype: 'panel',
        title: 'west',
        width: 150
    },{
        region: 'center',
        xtype: 'tabpanel',
        items:[{
            title: 'Center Tab 1'
        }]
    }]
});

前面的文件定义了在浏览器中显示的两个区域的边框布局和文本内容。

注意

对于 Ext JS 视图、xtypes、视图端口、边框布局或面板不太自信?我们建议浏览和审查docs.sencha.com/extjs/4.2.2/#!/guide/components中的基本 Ext JS 4 组件概念。

app/controller/Main.js 文件

我们将要检查的最终生成的文件是Main.js控制器:

Ext.define('TTT.controller.Main', {
    extend: 'Ext.app.Controller'
});

这个类中没有功能,因为还没有要控制的东西。

使用 Sencha Cmd 创建组件

可以使用 Sencha Cmd 生成骨架组件。其中最有用的命令是用于生成基本模型的命令。

生成模型骨架

使用 Sencha Cmd 工具可以非常容易地生成模型骨架。语法如下:

sencha generate model ModelName [field1:fieldType,field2:fieldType…]

此命令必须在应用程序根目录(即app.js文件所在的目录)中执行。请注意,逗号分隔的字段列表中不得有任何空格。可以通过执行以下命令生成公司模型骨架:

sencha generate model Company idCompany:int,companyName:string

对于companyName字段,最终的string并不是严格要求的,因为默认属性类型是string,如果未指定。此命令的输出如下截图所示:

生成模型骨架

生成的Company.js文件写入app/model目录,并具有以下内容:

Ext.define('TTT.model.Company', {
    extend: 'Ext.data.Model',
    fields: [
        { name: 'idCompany', type: 'int' },
        { name: 'companyName', type: 'string'}
    ]
});

这是一个非常简单的模型,符合预期的有两个字段。我们也可以使用不同的数据类型生成更复杂的模型:

sencha generate model TaskLog idTaskLog:int,taskDescription:string,taskLogDate:date,taskMinutes:int,hours:float,username:string,userFullName:string,idTask:int,taskName:string,idProject:int,projectName:string,idCompany:int,companyName:string

上述命令将生成带有intstringdatefloat类型字段的TaskLog模型。

Ext.define('TTT.model.TaskLog', {
    extend: 'Ext.data.Model',    
    fields: [
        { name: 'idTaskLog', type: 'int' },
        { name: 'taskDescription', type: 'string' },
        { name: 'taskLogDate', type: 'date' },
        { name: 'taskMinutes', type: 'int' },
        { name: 'hours', type: 'float' },
        { name: 'username', type: 'string' },
        { name: 'userFullName', type: 'string' },
        { name: 'idTask', type: 'int' },
        { name: 'taskName', type: 'string' },
        { name: 'idProject', type: 'int' },
        { name: 'projectName', type: 'string' },
        { name: 'idCompany', type: 'int' },
        { name: 'companyName', type: 'string' }
    ]
});

剩下的三个实体的模型骨架可以通过执行以下命令创建:

sencha generate model Project idProject:int,projectName:string, idCompany:int,companyName:string
sencha generate model Task idTask:int,taskName:string,idProject:int,projectName:string, idCompany:int,companyName:string
sencha generate model User username:string,firstName:string,lastName:string,fullName:string,email:string,password:string,adminRole:string

请注意,每个模型都与相应的 Java 域类中addJsonJsonObjectBuilder)方法生成的 JSON 结构匹配。现在,您应该在app/model目录中看到以下截图中显示的文件:

生成模型骨架

虽然我们使用 Sencha Cmd 工具生成了这些模型骨架,但在 NetBeans IDE 中创建适当的文件和定义同样容易。

使用 Sencha Cmd 生成视图和控制器

也可以生成基本的视图和控制器骨架,但这些文件的内容非常有限。以下命令将创建名为ManageUsers的视图:

sencha generate view ManageUsers

ManageUsers.js文件将写入app/view目录,并具有以下内容:

Ext.define("TTT.view.ManageUsers", {
    extend: 'Ext.Component',
    html: 'Hello, World!!'
});

类似地,您可以为UserController创建一个控制器骨架:

sencha generate controller UserController

UserController.js文件将写入app/controller目录,并具有以下内容:

Ext.define('TTT.controller.UserController', {
    extend: 'Ext.app.Controller'
});

我们相信在 NetBeans IDE 中创建视图和控制器更简单,因此不会使用 Sencha Cmd 来实现这一目的。

摘要

本章已配置了 Ext JS 4 开发环境,并介绍了实用的设计约定和概念。我们已安装了 Sencha Cmd 并生成了 3T 应用程序骨架,检查核心生成的文件以了解推荐的应用程序结构。我们的模型实体已使用 Sencha Cmd 生成,并准备在接下来的章节中进行增强。我们已经为构建 3T 应用程序的前端做好了准备。

在第十章登录和维护用户中,我们将开发 Ext JS 4 组件,用于登录 3T 应用程序并维护用户。我们在用户界面UI)设计方面的创意之旅刚刚开始!

第十章:登录和维护用户

企业应用程序生命周期中最有创意的部分围绕用户界面设计。作为企业应用程序开发人员,您的目标是创建一个直观、一致且易于使用的用户界面。用户界面设计需要对您可用的工具有深入的了解。幸运的是,Ext JS 4 具有全面的小部件范围,涵盖了任何企业应用程序所需的核心功能。如果您还没有访问示例页面,请立即花些时间熟悉docs.sencha.com/extjs/4.2.2/#!/example上的 Ext JS 4 组件的全部范围。

本章将重点介绍构建登录和用户管理界面。我们将开发一组视图组件,并使用控制器将它们连接起来,以执行以下操作:

  • 登录到应用程序

  • 显示主应用程序视口

  • 提供用户维护界面

用户维护界面将引入用于 CRUD 操作的模型持久性和验证属性。我们有很多工作要做,所以让我们从检查应用程序布局和工作流程开始。

布局、屏幕和工作流程

应用程序从显示登录窗口开始。如果登录不成功,您将无法到达主应用程序视口。登录窗口的设计非常简单,如下截图所示:

布局、屏幕和工作流程

成功的登录将显示在欢迎屏幕上,如下截图所示:

布局、屏幕和工作流程

欢迎屏幕在标题中有许多按钮,取决于您的权限。普通用户只会看到任务日志按钮和注销按钮。管理员用户将看到额外的3T 管理员用户按钮。我们将把3T 管理员任务日志模块留到后续章节。

用户管理界面基于现代企业应用程序中最常见的设计模式。此布局在左侧面板中显示用户列表,右侧面板显示用户详细信息:

布局、屏幕和工作流程

这些屏幕设计中的每个都有图标和标志,这些都不是 Ext JS 框架的一部分。以下各节中的代码将定义适当的样式,但您需要包含所需的资源以实现相同的外观和感觉。包括资源在内的完整源代码可以从本书的网站下载。

定义视图组件

在实施线框和 UI 模型时,最难的决定之一是如何分割视图。考虑下面截图中显示的用户维护屏幕:

定义视图组件

我们应该创建多少个单独的视图?如果创建太多视图,它们将变得难以管理。如果视图太少,我们将失去灵活性。只有通过经验才能找到正确的平衡。我们倾向于鼓励基于布局本身的逻辑区域的中间路线方法。例如,先前的设计可以分成以下视图组件:

定义视图组件

这是我们将要实施的结构。然而,我们可以轻松地实现以下设计:

定义视图组件

第二个版本将使用单个视图来封装用户网格、表单和工具栏。生成的ManageUsers.js文件将大约有 200 行长;从功能角度来看,两种设计之间没有区别。然而,第一种方法给了我们更多的灵活性。我们可以轻松地重新排列页面上的视图或重构界面而不费吹灰之力(例如,将UserForm移动到弹出窗口,并允许用户列表填满整个屏幕宽度)。这在第二种设计版本中不会那么容易。

如果有疑问,应该偏向简单。应尽量避免具有数百甚至数千行代码的复杂视图。将视图视为具有特定目的的离散对象,并保持它们简单。

构建我们的视图

现在我们有了一些构建视图的实用指南,是时候创建我们的应用程序界面了。用户必须能够成功登录以使用应用程序,因此让我们从登录窗口开始。

定义登录窗口

任务时间跟踪登录窗口是用户将看到的第一件事,如下截图所示:

定义登录窗口

登录窗口定义如下所示的代码:

Ext.define('TTT.view.LogonWindow', {
    extend: 'Ext.window.Window',
    xtype: 'logonwindow',
    closable: false,
    iconCls: 'logo-small',
    width: 300,
    bodyPadding: 10,
    title: 'Task Time Tracker Logon',
    requires: ['Ext.form.field.Text'],
    initComponent: function() {
        var me = this;
        Ext.applyIf(me, {
            items: [{
                xtype: 'textfield',
                fieldLabel: 'User Name',
                name: 'username',
                allowBlank: false,
                validateOnBlur: true,
                emptyText: 'Enter a Username'
            }, {
                xtype: 'textfield',
                name: 'password',
                fieldLabel: 'Password',
                inputType: 'password',
                validateOnBlur: true,
                allowBlank: false
            }, {
                xtype: 'toolbar',
                ui: 'footer',
                layout: {
                    pack: 'end',
                    type: 'hbox'
                },
                items: [{
                    xtype: 'button',
                    text: 'Logon'
                }]
            }]
        });
        me.callParent(arguments);
    }
});

这个窗口定义扩展了Ext.window.Window,并添加了两个文本字段和登录按钮。LogonWindow类被命名空间为view,因此将驻留在app/view目录中。定义的xtype属性是类名的小写版本,并将在控制器中用于引用LogonWindow实例。

注意

xtype属性是一个类的符号名称(别名或快捷方式)。xtype属性是 Ext JS 中一个强大的概念,允许组件被配置,但不渲染,直到拥有容器认为有必要。可以在这里找到关于组件通过xtype进行延迟初始化的完整解释:docs.sencha.com/extjs/4.2.2/#!/guide/components

MVC 设计模式鼓励 Ext JS 4 开发人员在控制器层实现业务逻辑,将视图作为哑对象。我们在这个窗口中应用的唯一元逻辑是allowBlank:false属性与validateOnBlur:true的组合。这将在用户在不输入文本的情况下移开字段时给出视觉提示。

使用initComponent()函数

initComponent函数是在对象创建期间由构造函数调用的模板函数。模板设计模式允许子类定义特定行为,而不改变基类处理算法的语义。关于这种模式的详细解释可以在这里找到:en.wikipedia.org/wiki/Template_method_design_pattern。Ext JS 使用模板设计模式允许开发人员在组件生命周期的某些明确定义的阶段指定逻辑。initComponent函数可能是最常用的,但还有许多其他模板钩子可以实现。可以在这里找到组件模板函数的完整列表:docs.sencha.com/extjs/4.2.2/#!/guide/components

initComponent函数用于初始化数据、设置配置,并将事件处理程序附加到组件上。对于这个函数(或任何模板函数)的推荐使用模式包括:

  • 使用var me = this将当前作用域引用为本地闭包变量。在函数中引用对象实例时,到处使用me引用。这将有助于通过确保methis引用正确的对象作用域来正确处理复杂对象的 JavaScript 闭包。

  • 使用Ext.applyIf向配置添加特定于类的属性。请注意,我们没有使用Ext.apply,它将覆盖已定义的属性;只会复制me中不存在的新属性。这确保了基于 xtype 的配置属性优先。

  • 通过使用me.callParent(arguments)调用父函数来完成initComponent函数。

这三点概述了一些高级概念,可能对中级读者来说有点超出范围。如果其中有些内容还不太清楚,不要绝望;遵循设计模式,随着经验的积累,事情会变得更清晰!

定义视口

Viewport视图使用vbox布局将视图分为两个区域,标题和主要内容区域,如下面的屏幕截图所示:

定义视口

我们可以使用border布局来实现相同的视觉效果,但vbox布局是一个更轻量级的组件。只有在应用程序需要额外功能,如可展开/可折叠区域或可调整大小的分割视图时才使用border布局。

Viewport定义如下:

Ext.define('TTT.view.Viewport', {
    extend: 'Ext.container.Viewport',
    cls: 'x-border-layout-ct',
    requires: ['TTT.view.MainHeader', 'TTT.view.MainCards', 'Ext.layout.container.VBox'],
    padding: 5,
    layout: {
        type: 'vbox',
        align: 'stretch'
    },
    items: [{
        xtype: 'mainheader',
        height: 80
    }, {
        xtype: 'maincards',
        flex: 1
    }]
});

现在需要定义两个视图:一个用于主标题,另一个用于主区域卡片布局。

MainHeader.js 视图

MainHeader定义并定位了 3T 标志和按钮,如下面的代码所示:

Ext.define('TTT.view.MainHeader', {
    extend: 'Ext.container.Container',
    xtype: 'mainheader',
    requires: ['Ext.toolbar.Toolbar'],
    layout: {
        align: 'stretch',
        type: 'hbox'
    },
    initComponent: function() {
        var me = this;
        Ext.applyIf(me, {
            items: [{
                xtype: 'container',
                cls: 'logo',
                width: 300
            }, {
                xtype: 'toolbar',
                flex: 1,
                ui: 'footer',
                layout: {
                    pack: 'end',
                    padding: '20 20 0 0',
                    type: 'hbox'
                },
                items: [{
                    xtype: 'button',
                    itemId: 'taskLogsBtn',
                    iconCls: 'tasklog',
                    text: 'Task Logs'
                }, {
                    xtype: 'button',
                    itemId: 'taskAdminBtn',
                    iconCls: 'admin',
                    hidden: !TTT.getApplication().isAdmin(),
                    text: '3T Admin'
                }, {
                    xtype: 'button',
                    itemId: 'userAdminBtn',
                    hidden: !TTT.getApplication().isAdmin(),
                    iconCls: 'users',
                    text: 'Users'
                }, '->',
                {
                    xtype: 'button',
                    itemId: 'logoffBtn',
                    iconCls: 'logoff',
                    text: 'Logoff'
                }]
            }]
        });
        me.callParent(arguments);
    }
});

每个按钮都定义了一个itemId属性,以帮助在控制器中使用选择器唯一标识按钮。两个管理按钮使用hidden属性来隐藏按钮,如果用户不是管理员,则使用TTT.getApplication().isAdmin()函数。该函数尚未定义,但将在后面的部分中添加到Application.js函数中。

MainCards.js 文件

MainCards组件是一个卡片布局容器,包含将在主内容区域中呈现的所有组件,如下面的代码所示:

Ext.define('TTT.view.MainCards', {
    extend: 'Ext.container.Container',
    xtype: 'maincards',
    requires: ['Ext.layout.container.Card', 'TTT.view.Welcome', 'TTT.view.user.ManageUsers'],
    layout: 'card',
    initComponent: function() {
        var me = this;
        Ext.applyIf(me, {
            items: [{
                xtype: 'welcome',
                itemId: 'welcomCard'
            }, {
                xtype: 'manageusers',
                itemId: 'manageUsersCard'
            }]
        });
        me.callParent(arguments);
    }
});

随着我们构建功能,我们将向MainCards添加项目。在本章中,我们将专注于WelcomeManageUsers组件。

定义欢迎面板

Welcome面板使用XTemplate根据已登录用户呈现简单的欢迎消息。用户数据是使用TTT.getApplication().getUser()函数从应用程序中检索的,该函数将在成功登录后添加到Application.js函数中。

Ext.define('TTT.view.Welcome', {
    extend: 'Ext.panel.Panel',
    xtype: 'welcome',
    requires: ['Ext.XTemplate'],
    initComponent: function() {
        var me = this;
        var tpl = new Ext.XTemplate('<tpl for=".">', '<p>Welcome <b>{fullName}</b>!</p>', '<p>You are logged on as {username} [{email}]</p>', '</tpl>');
        var welcomeHtml = tpl.apply(TTT.getApplication().getUser());
        Ext.applyIf(me, {
            items: [{
                xtype: 'container',
                padding: 10,
                html: welcomeHtml
            }]
        });
        me.callParent(arguments);
    }
});

定义用户管理组件

用户管理界面由三个视图文件组成,如下面的屏幕截图所示:

定义用户管理组件

除了视图之外,我们还需要定义一个用户存储,用于管理用户列表中显示的数据。

ManageUsers.js 文件

ManageUsers文件是一个简单的hbox布局,显示UserListUserForm。工具栏包含单个添加用户按钮。该文件有一个非常简单的定义,如下所示:

Ext.define('TTT.view.user.ManageUsers', {
    extend: 'Ext.panel.Panel',
    xtype: 'manageusers',
    requires: ['Ext.toolbar.Toolbar', 'TTT.view.user.UserList', 'TTT.view.user.UserForm'],
    layout: {
        type: 'hbox',
        align: 'stretch'
    },
    initComponent: function() {
        var me = this;
        Ext.applyIf(me, {
            dockedItems: [{
                xtype: 'toolbar',
                dock: 'top',
                items: [{
                    xtype: 'button',
                    itemId: 'addUserBtn',
                    iconCls: 'addnew',
                    text: 'Add user'
                }]
            }],
            items: [{
                xtype: 'userlist',
                width: 400,
                margin: 1
            }, {
                xtype: 'userform',
                flex: 1
            }]
        });
        me.callParent(arguments);
    }
});

UserForm.js 文件

UserForm.js文件显示了用户详细信息,如下面的代码所示:

Ext.define('TTT.view.user.UserForm', {
    extend: 'Ext.form.Panel',
    xtype: 'userform',
    requires: ['Ext.form.FieldSet', 'Ext.form.field.Radio', 'Ext.form.RadioGroup', 'Ext.toolbar.Toolbar'],
    layout: {
        type: 'anchor'
    },
    bodyPadding: 10,
    border: false,
    autoScroll: true,
    initComponent: function() {
        var me = this;
        Ext.applyIf(me, {
            items: [{
                xtype: 'fieldset',
                padding: 10,
                width: 350,
                fieldDefaults: {
                    anchor: '100%'
                },
                title: 'User',
                items: [{
                    xtype: 'textfield',
                    name: 'username',
                    fieldLabel: 'Username'
                }, {
                    xtype: 'textfield',
                    name: 'firstName',
                    fieldLabel: 'First Name'
                }, {
                    xtype: 'textfield',
                    name: 'lastName',
                    fieldLabel: 'Last Name'
                }, {
                    xtype: 'textfield',
                    name: 'email',
                    fieldLabel: 'Email'
                }, {
                    xtype: 'textfield',
                    name: 'password',
                    inputType: 'password',
                    fieldLabel: 'Password'
                }, {
                    xtype: 'radiogroup',
                    fieldLabel: 'Administrator',
                    items: [{
                        boxLabel: 'Yes',
                        name: 'adminRole',
                        inputValue: 'Y'
                    }, {
                        boxLabel: 'No',
                        name: 'adminRole',
                        inputValue: 'N'
                    }]
                }, {
                    xtype: 'toolbar',
                    ui: 'footer',
                    layout: {
                        pack: 'end',
                        type: 'hbox'
                    },
                    items: [{
                        xtype: 'button',
                        itemId: 'deleteBtn',
                        iconCls: 'delete',
                        text: 'Delete'
                    }, {
                        xtype: 'button',
                        itemId: 'saveBtn',
                        iconCls: 'save',
                        text: 'Save'
                    }]
                }]
            }]
        });
        me.callParent(arguments);
    }
});

每个按钮都有一个itemId属性,用于在控制器中唯一标识它们。表单中的每个字段名称与前一章中定义的User模型中的字段名称完全匹配。这将允许我们轻松地将用户模型实例加载到表单中。

UserList.js 文件

UserList文件是一个带有以下定义的网格面板:

Ext.define('TTT.view.user.UserList', {
    extend: 'Ext.grid.Panel',
    xtype: 'userlist',
    store: 'User',
    title: 'User List',
    viewConfig: {
        markDirty: false,
        stripeRows: false
    },
    initComponent: function() {
        var me = this;
        Ext.applyIf(me, {
            tools: [{
                type: 'refresh',
                tooltip: 'Refresh user list'
            }],
            columns: [{
                xtype: 'gridcolumn',
                dataIndex: 'username',
                flex: 1,
                text: 'Username'
            }, {
                xtype: 'gridcolumn',
                dataIndex: 'firstName',
                flex: 1,
                text: 'First Name'
            }, {
                xtype: 'gridcolumn',
                flex: 1,
                dataIndex: 'lastName',
                text: 'Last Name'
            }, {
                xtype: 'gridcolumn',
                flex: 2,
                dataIndex: 'email',
                text: 'Email'
            }]
        });
        me.callParent(arguments);
    }
});

网格列使用flex配置属性来定义每列的相对宽度。因此,email列的宽度将是其他列的两倍。

viewConfig中的markDirty:false用于在修改单元格值时移除脏单元格指示器。如果没有此属性,即使记录已成功保存,网格也会呈现已更改的单元格值,如下所示:

UserList.js 文件

User存储尚未定义,所以让我们现在添加它。

用户存储

User 存储从 ttt/user/findAll.json URL 加载用户。这与 UserHandler.findAll 方法相对应。读者应该记得,ttt/前缀 URL 对应于 第七章 中的 com.gieman.tttracker.web.WebApp.getServletMappings() 方法中指定的 servlet 映射,Web 请求处理层。JSON 数组中的每个用户记录将导致创建一个 TTT.model.User 模型实例。存储定义在以下代码中:

Ext.define('TTT.store.User', {
    extend: 'Ext.data.Store',
    requires: ['TTT.model.User'],
    model: 'TTT.model.User',
    proxy: {
        type: 'ajax',
        url: 'ttt/user/findAll.json',
        reader: {
            type: 'json',
            root: 'data'
        }
    }
});

User 模型在上一章中被定义,目前看起来如下:

Ext.define('TTT.model.User', {
    extend: 'Ext.data.Model',

    fields: [
        { name: 'username', type: 'string' },
        { name: 'firstName', type: 'string' },
        { name: 'lastName', type: 'string' },
        { name: 'fullName', type: 'string' },
        { name: 'email', type: 'string' },
        { name: 'password', type: 'string' },
        { name: 'adminRole', type: 'string' }
    ]
});

Ext JS 4 模型是 MVC 框架的关键部分,现在我们将花一些时间学习这些重要的对象。

模型和持久性

Ext JS 4 模型类似于 JPA 实体,因为它们定义了表示基础数据库表中列的数据字段。每个模型实例代表表中的一行。使用模型的 idProperty 定义主键字段,它必须与字段名称之一匹配。User 模型现在可以更新如下:

Ext.define('TTT.model.User', {
    extend: 'Ext.data.Model',

    fields: [
        { name: 'username', type: 'string' },
        { name: 'firstName', type: 'string' },
        { name: 'lastName', type: 'string' },
        { name: 'fullName', type: 'string' },
        { name: 'email', type: 'string' },
        { name: 'password', type: 'string' },
        { name: 'adminRole', type: 'string' }
    ],
  idProperty: 'username'
});

定义代理

通过配置适当的代理,每个模型都可以被设置为持久感知。然后,当调用模型的 loadsavedestroy 方法时,所有数据的加载和保存都由代理处理。有几种不同类型的代理,但最常用的是 Ext.data.ajax.Proxy(另一个名称是 Ext.data.AjaxProxy)。AjaxProxy 使用 AJAX 请求从服务器读取和写入数据。请求根据操作以 GETPOST 方法发送。

第二个有用的代理是 Ajax.data.RestProxyRestProxyAjaxProxy 的一个特例,它将四个 CRUD 操作映射到适当的 RESTful HTTP 方法(GETPOSTPUTDELETE)。当连接到 RESTful web 服务时,将使用 RestProxy。我们的应用程序将使用 AjaxProxy

包括代理在内的 User 模型定义如下:

Ext.define('TTT.model.User', {
    extend: 'Ext.data.Model',

    fields: [
        { name: 'username', type: 'string' },
        { name: 'firstName', type: 'string' },
        { name: 'lastName', type: 'string' },
        { name: 'fullName', type: 'string', persist:false },
        { name: 'email', type: 'string' },
        { name: 'password', type: 'string' },
        { name: 'adminRole', type: 'string' }
    ],
    idProperty: 'username',
    proxy: {
        type: 'ajax',
        idParam:'username',
        api:{
            create:'ttt/user/store.json',
            read:'ttt/user/find.json',
            update:'ttt/user/store.json',
            destroy:'ttt/user/remove.json'
        },
        reader: {
            type: 'json',            
            root: 'data'
        },
        writer: {
            type: 'json',
            allowSingle:true,
            encode:true,
            root:'data',
            writeAllFields: true
        }
    }
});

代理被定义为类型 ajax,并使用 idParam 属性指定模型中的主键字段。在生成 read 操作的 URL 时使用 idParam。例如,如果尝试加载用户名为 bjones 的用户记录,则代理将生成以下 URL:

ttt/user/find.json?username=bjones

如果省略了 idParam 属性,则生成的 URL 将如下所示:

ttt/user/find.json?id=bjones

api 属性定义了在 CRUD 操作方法上调用的 URL。每个 URL 映射到 UserHandler 中的适当处理程序方法。请注意,updatecreate URL 是相同的,因为这两个操作都由 UserHandler.store 方法处理。

重要的是要注意,AjaxProxy 的读取操作使用 GET 请求,而所有其他操作使用 POST 请求。这与 RestProxy 方法不同,后者对每个操作使用不同的请求方法。

比较 AJAX 和 REST 代理

我们的请求处理层已经设计为以 Ext JS 4 客户端提交的格式消耗 AJAX 请求。处理更新操作的每个处理程序都配置为 RequestMethod.POST,并期望包含适用于操作的 JSON 对象的 data 参数。

我们可以将请求处理层实现为 RESTful API,其中每个方法都映射到适当的请求方法类型(GETPOSTPUTDELETE)。然后,实现删除操作将在 DELETE 提交的请求的 URL 中编码项目的 id。例如,通过提交以下方式的 DELETE 请求方法 URL,可以删除 bjones 用户:

user/bjones

然后可以将 UserHandler.remove 方法定义为:

@RequestMapping(value = "/user/{username}", 
method=RequestMethod.DELETE)
@ResponseBody
public String remove(final @PathVariable String username, final HttpServletRequest request) {
// code continues…

@PathVariable从 URL 中提取username(在我们的示例 URL 中为bjones),然后在调用userService.remove方法时使用。RequestMethod.DELETE@RequestMapping方法确保只有在提交匹配 URL 路径/user/{username}的 DELETE 请求时才执行该方法。

RESTful API 是一种特定的使用 HTTP 的风格,它在 URL 本身中对要检索或操作的项目进行编码(通过其 ID),并在所使用的 HTTP 方法中对要执行的操作进行编码(GET用于检索,POST用于更改,PUT用于创建,DELETE用于删除)。Ext JS 中的Rest代理是AjaxProxy的一个特例,它简单地将四个 CRUD 操作映射到它们的 RESTful HTTP 等效方法。

在 Ext JS 4 中,实现 AJAX 或 REST 替代方案没有显著的差异。配置代理为type:'ajax'type:'rest'就足够了。然而,请求处理层需要以非常不同的方式实现来处理@PathVariable参数。出于以下原因,我们更喜欢 AJAX 实现:

  • REST 传统上用于服务器间通信,尤其是在 Web 服务中,而不是用于浏览器与服务器的交互。

  • CRUD AJAX 请求的 URL 是唯一的,并且变得自我描述。

  • 3T 应用程序不是 Web 服务,而是基于 HTML 5。

  • HTML 5 规范不再支持PUTDELETE作为form元素的 HTTP 方法(参见www.w3.org/TR/2010/WD-html5-diff-20101019/#changes-2010-06-24)。

  • REST 不是一种灵活的解决方案,通常基于原子操作(每个请求处理一个项目)。AJAX 和 Ext JS 结合起来允许更复杂的交互,可以进行批量更新(可以对所有创建、更新和销毁 URL 进行多个更新的单个请求)。这将在定义 writer部分中进行解释。

  • PUTDELETE请求通常被认为是安全风险(除了OPTIONSTRACECONNECT方法),并且通常在企业 Web 应用环境中被禁用。通常需要这些方法的应用程序(例如 Web 服务)通常在安全条件下向有限数量的受信任用户公开这些 URL(通常使用 SSL 证书)。

没有明确或令人信服的理由来使用 AJAX 或 REST。事实上,关于何时使用 REST 而不是 AJAX 的在线讨论非常广泛,而且经常令人困惑。我们选择了我们认为是最简单和最灵活的实现,即使用 AJAX 而不需要 REST。

定义 reader

具有类型jsonreader实例化了一个Ext.data.reader.Json实例,以解码服务器对操作的响应。它读取 JSON data节点(由 reader 的root属性标识)并填充模型中的字段值。使用ttt/user/find.json?username=bjonesUser模型执行读取操作将返回:

{
    success: true,
    data: {
        "username": "bjones",
        "firstName": "Betty",
        "lastName": "Jones",
        "fullName": "Betty Jones",
        "email": "bj@tttracker.com",
        "adminRole": "Y"
    }
}

然后,reader 将解析 JSON 文件并在模型上设置相应的字段值。

定义 writer

具有类型jsonwriter实例化了一个Ext.data.writer.Json实例,以将发送到服务器的任何请求编码为 JSON 格式。encode:true属性与root属性结合,定义了保存 JSON 数据的 HTTP 请求参数。这种组合确保一个名为data的单个请求参数将保存模型的 JSON 表示。例如,保存先前的bjones用户记录将导致提交一个名为data的参数,其中包含以下字符串:

{
    "username": "bjones",
    "firstName": "Betty",
    "lastName": "Jones",
    "email": "bj@tttracker.com",
    "password": "thepassword",
    "adminRole": "Y"
}

应该注意,此表示已格式化以便阅读;实际数据将是一行字符的字符串。然后将此表示解析为UserHandler.store方法中的JsonObject

JsonObject jsonObj = parseJsonObject(jsonData);

然后根据需要提取适当的jsonObject值。

writeAllFields属性将确保在请求中发送模型中的所有字段,而不仅仅是修改过的字段。我们的处理程序方法要求所有模型字段都存在。但是,请注意,我们已将persist:false属性添加到fullName字段。由于fullName字段不是User域对象中的持久字段,因此不需要该字段。

需要解释的最终writer属性是allowSingle:true。这是默认值,确保发送单个记录而不是包装数组。如果您的应用程序执行批量更新(在同一请求中发送多个记录),那么您需要将此属性设置为false。这将导致单个记录被发送到数组中,如下面的代码所示:

[{  
    "username": "bjones",
    "firstName": "Betty",
    "lastName": "Jones",
    "email": "bj@tttracker.com",
    "password": "thepassword",
    "adminRole": "Y"
}]

3T 应用程序不实现批量更新,并且始终期望每个请求中发送一个单个的 JSON 记录。

定义验证

每个模型都内置了验证字段数据的支持。核心验证函数包括presencelengthinclusionexclusionformat(使用正则表达式)和email的检查。可以通过调用validate函数来验证模型实例,该函数返回一个Ext.data.Errors对象。然后可以测试errors对象以查看是否存在任何验证错误。

User模型的验证如下:

validations: [
  {type: 'presence',  field: 'username'},
  {type: 'length', field: 'username', min: 4},
  {type: 'presence',  field: 'firstName'},
  {type: 'length', field: 'firstName', min: 2},
  {type: 'presence',  field: 'lastName'},
  {type: 'length', field: 'lastName', min: 2},
  {type: 'presence',  field: 'email'},
  {type: 'email',  field: 'email'},
  {type: 'presence',  field: 'password'},
  {type: 'length', field: 'password', min: 6},
  {type: 'inclusion', field: 'adminRole', list:['Y','N']}
]

presence验证确保字段的值存在。length验证检查字段大小。我们的验证要求password的最小长度为六个字符,username的最小长度为四个字符。名字和姓氏的最小长度为两个字符。inclusion验证测试以确保字段值是定义列表中的条目之一。因此,我们的adminRole值必须是YN中的一个。email验证确保电子邮件字段具有有效的电子邮件格式。

现在我们的User模型的最终代码清单可以定义为:

Ext.define('TTT.model.User', {
    extend: 'Ext.data.Model',

    fields: [
        { name: 'username', type: 'string' },
        { name: 'firstName', type: 'string' },
        { name: 'lastName', type: 'string' },
        { name: 'fullName', type: 'string', persist:false },
        { name: 'email', type: 'string' },
        { name: 'password', type: 'string' },
        { name: 'adminRole', type: 'string' }
    ],
    idProperty: 'username',
    proxy: {
        type: 'ajax',
        idParam:'username',
        api:{
            create:'ttt/user/store.json',
            read:'ttt/user/find.json',
            update:'ttt/user/store.json',
            destroy:'ttt/user/remove.json'
        },
        reader: {
            type: 'json',            
            root: 'data'
        },
        writer: {
            type: 'json',
            allowSingle:true,
            encode:true,
            root:'data',
            writeAllFields: true
        }
    },
    validations: [
        {type: 'presence',  field: 'username'},
        {type: 'length', field: 'username', min: 4},
        {type: 'presence',  field: 'firstName'},
        {type: 'length', field: 'firstName', min: 2},
        {type: 'presence',  field: 'lastName'},
        {type: 'length', field: 'lastName', min: 2},
        {type: 'presence',  field: 'email'},
        {type: 'email',  field: 'email'},
        {type: 'presence',  field: 'password'},
        {type: 'length', field: 'password', min: 6},
        {type: 'inclusion', field: 'adminRole', list:['Y','N']}
    ]        
});

控制登录和视口操作

现在我们准备定义将用于处理核心应用程序操作的MainController。这些操作包括登录、注销和单击标题按钮以在主内容区域中显示不同的管理面板。

MainController.js 文件

MainController.js的定义如下代码:

Ext.define('TTT.controller.MainController', {
    extend: 'Ext.app.Controller',
    requires: ['Ext.window.MessageBox'],
    views: ['TTT.view.MainHeader', 'TTT.view.MainCards', 'TTT.view.LogonWindow'],
    refs: [{
        ref: 'mainCards',
        selector: 'maincards'
    }, {
        ref: 'usernameField',
        selector: 'logonwindow textfield[name=username]'
    }, {
        ref: 'passwordField',
        selector: 'logonwindow textfield[name=password]'
    }],
    init: function(application) {
        this.control({
            'mainheader button': {
                click: this.doHeaderButtonClick
            },
            'logonwindow button': {
                click: this.doLogon
            }
        });
    },
    doHeaderButtonClick: function(button, e, options) {
        var me = this;
        if (button.itemId === 'userAdminBtn') {
            me.getMainCards().getLayout().setActiveItem('manageUsersCard');
        } else if (button.itemId === 'taskAdminBtn') {
            me.getMainCards().getLayout().setActiveItem('manageTasksCard');
        } else if (button.itemId === 'taskLogsBtn') {
            me.getMainCards().getLayout().setActiveItem('taskLogCard');
        } else if (button.itemId === 'logoffBtn') {
            me.doLogoff();
        }
    },
    doLogon: function() {
        var me = this;
        if (me.getUsernameField().validate() && me.getPasswordField().validate()) {
            Ext.Ajax.request({
                url: 'ttt/security/logon.json',
                params: {
                    username: me.getUsernameField().getValue(),
                    password: me.getPasswordField().getValue()
                },
                success: function(response) {
                    var obj = Ext.JSON.decode(response.responseText);
                    if (obj.success) {
                        TTT.getApplication().doAfterLogon(obj.data);
                    } else {
                        Ext.Msg.alert('Invalid Logon', 'Please enter a valid username and password');
                    }
                }
            });
        } else {
            Ext.Msg.alert('Invalid Logon', 'Please enter a valid username and password');
        }
    },
    doLogoff: function() {
        Ext.Msg.confirm('Confirm Logout', 'Are you sure you want to log out of 3T?', function(button) {
            if (button === 'yes') {
                Ext.Ajax.request({
                    url: 'ttt/security/logout.json',
                    success: function() {
                        window.location.reload();
                    }
                });
            }
        });
    }
});

MainController负责管理视图配置数组中定义的三个视图:MainHeaderMainCardsLogonWindow。每个ref定义了控制器执行操作所需的组件。在控制器初始化期间,ref值用于自动创建一个getter函数,该函数可用于访问组件。在我们的MainController中,mainCardsref值将导致创建一个getMainCards函数。此函数在doHeaderButtonClick函数中用于访问MainCards组件。

注意

函数的名称应该标识代码定义的核心目的。我们将所有执行操作的函数前缀为do。在我们的示例中,任何开发人员都应该清楚doHeaderButtonClick函数的目的是什么。

MainController.init()函数调用control()函数来配置视图中的事件处理。control()函数是一种方便的方法,可以在一个操作中分配一组事件侦听器。mainheader按钮选择器配置MainHeader中所有按钮对象的click事件。每当标题中的按钮被点击时,将调用doHeaderButtonClick函数。然后,该函数将通过检查button参数的itemId来确定已单击哪个按钮。然后激活MainCards中的适当卡。

注意

请注意,我们已经添加了代码来显示manageTasksCardtaskLogCard,即使它们目前还不可用。这些用户界面将在接下来的章节中开发。

logonwindow按钮选择器配置了LogonWindow登录按钮的click事件。当单击按钮以触发登录过程时,将调用doLogon函数。此函数验证usernamepassword字段,如果两者都有效,则提交 AJAX 请求以验证用户。成功登录将调用TTT.getApplication().doAfterLogon()函数,将用户 JSON 数据作为参数传递。

当用户在页眉中单击注销按钮时,将触发doLogoff函数。系统会向用户提示,如果确认,则会处理logout操作。这将在重新加载浏览器窗口之前清除后端的会话,并再次呈现用户LogonWindow

控制我们的用户视图

将三个用户视图联系在一起的粘合剂是UserController。在这里,我们放置了所有管理用户维护的逻辑。您已经看到,之前定义的每个视图都是愚蠢的,因为只定义了表示逻辑。操作、验证和选择都在UserController中处理,并在下面的代码中进行了解释:

Ext.define('TTT.controller.UserController', {
    extend: 'Ext.app.Controller',
    views: ['user.ManageUsers'],
    refs: [{
        ref: 'userList',
        selector: 'manageusers userlist'
    }, {
        ref: 'userForm',
        selector: 'manageusers userform'
    }, {
        ref: 'addUserButton',
        selector: 'manageusers #addUserBtn'
    }, {
        ref: 'saveUserButton',
        selector: 'manageusers userform #saveBtn'
    }, {
        ref: 'deleteUserButton',
        selector: 'manageusers userform #deleteBtn'
    }, {
        ref: 'userFormFieldset',
        selector: 'manageusers userform fieldset'
    }, {
        ref: 'usernameField',
        selector: 'manageusers userform textfield[name=username]'
    }],
    init: function(application) {
        this.control({
            'manageusers #addUserBtn': {
                click: this.doAddUser
            },
            'userlist': {
                itemclick: this.doSelectUser,
                viewready: this.doInitStore
            },
            'manageusers userform #saveBtn': {
                click: this.doSaveUser
            },
            'manageusers userform #deleteBtn': {
                click: this.doDeleteUser
            },
            'manageusers userform': {
                afterrender: this.doAddUser
            },
            'userlist header tool[type="refresh"]': {
                click: this.doRefreshUserList
            }
        });
    },
    doInitStore: function() {
        this.getUserList().getStore().load();
    },
    doAddUser: function() {
        var me = this;
        me.getUserFormFieldset().setTitle('Add New User');
        me.getUsernameField().enable();
        var newUserRec = Ext.create('TTT.model.User', {
            adminRole: 'N'
        });
        me.getUserForm().loadRecord(newUserRec);
        me.getDeleteUserButton().disable();
    },
    doSelectUser: function(grid, record) {
        var me = this;
        me.getUserForm().loadRecord(record);
        me.getUserFormFieldset().setTitle('Edit User ' + record.get('username'));
        me.getUsernameField().disable();
        me.getDeleteUserButton().enable();
    },
    doSaveUser: function() {
        var me = this;
        var rec = me.getUserForm().getRecord();
        if (rec !== null) {
            me.getUserForm().updateRecord();
            var errs = rec.validate();
            if (errs.isValid()) {
                rec.save({
                    success: function(record, operation) {
                        if (typeof record.store === 'undefined') {
                            // the record is not yet in a store 
                            me.getUserList().getStore().add(record);
                        }
                        me.getUserFormFieldset().setTitle('Edit User ' + record.get('username'));
                        me.getUsernameField().disable();
                        me.getDeleteUserButton().enable();
                    },
                    failure: function(rec, operation) {
                        Ext.Msg.alert('Save Failure', operation.request.scope.reader.jsonData.msg);
                    }
                });
            } else {
                me.getUserForm().getForm().markInvalid(errs);
                Ext.Msg.alert('Invalid Fields', 'Please fix the invalid entries!');
            }
        }
    },
    doDeleteUser: function() {
        var me = this;
        var rec = me.getUserForm().getRecord();
        Ext.Msg.confirm('Confirm Delete User', 'Are you sure you want to delete user ' + rec.get('fullName') + '?', function(btn) {
            if (btn === 'yes') {
                rec.destroy({
                    failure: function(rec, operation) {
                        Ext.Msg.alert('Delete Failure', operation.request.scope.reader.jsonData.msg);
                    }
                });
                me.doAddUser();
            }
        });
    },
    doRefreshUserList: function() {
        this.getUserList().getStore().load();
    }
});

UserController被定义为一个单一视图,用于管理用户,如下面的代码所示:

views: [
  'user.ManageUsers'
]

这允许我们使用组件查询语言来定义一组引用,从manageusers根选择器开始。因此,我们可以通过选择器引用UserForm上的保存按钮:

'manageusers userform #saveBtn'

#saveBtn指的是manageusers组件内userform上带有itemId saveBtn的组件。

注意

只定义控制器用于处理业务逻辑的引用。不要为代码中从未访问的组件创建引用。保持代码简单和清晰!

init函数定义了应在界面中处理的监听器。每次按钮点击都与适当的handler函数匹配。用户列表itemclick事件由doSelectUser函数处理。userlist上的viewready事件触发了网格存储的初始加载。每个监听器事件都由一个具有明确目的的单个函数处理。现在让我们详细检查核心函数。

doAddUser 函数

当单击添加用户按钮时,将调用doAddUser函数。我们将表单fieldset的标题设置为显示添加新用户,然后如下所示启用username字段:

me.getUserFormFieldset().setTitle('Add New User');
me.getUsernameField().enable();

只有在添加新用户时才启用username字段;对于现有用户,username字段不可编辑,因为它代表主键。然后,我们创建一个新的 User 模型并将记录加载到用户表单中:

var newUserRec = Ext.create('TTT.model.User', {
    adminRole: 'N'
});
me.getUserForm().loadRecord(newUserRec);

此时,用户表单将如下截图所示:

The doAddUser function

删除按钮对于添加新用户没有任何有用的目的,因此我们将其禁用,如下所示:

me.getDeleteUserButton().disable();

这给我们带来了以下添加新用户界面,如下截图所示:

The doAddUser function

我们也可以选择隐藏删除按钮而不是禁用它;您的方法将取决于客户端的规格。

现在表单已准备好输入新用户。

doSelectUser 函数

doSelectUser函数处理userlist网格面板上的itemclick事件。此函数的参数是网格本身和所选记录。这使得使用所选用户记录加载表单变得简单:

var me = this;
me.getUserForm().loadRecord(record);
me.getUserFormFieldset().setTitle('Edit User ' + record.data.username);
me.getUsernameField().disable();
me.getDeleteUserButton().enable();

fieldset标题更改以反映正在编辑的用户,并且username字段被禁用。我们还确保删除按钮被启用,因为我们需要删除现有记录的选项。点击用户列表中的Betty Jones记录将显示以下截图:

doSelectUser 函数

注意

读者会注意到密码字段是空的。这意味着通过表单保存用户记录将需要设置密码。当保存用户时,后端处理程序和服务层也需要有效的密码。在现实世界中,情况并非如此;您不希望管理员每次保存用户详细信息时都更改密码!更改密码表单,也许在弹出窗口中,通常会触发单独的 AJAX 请求来更改用户的密码。

现在是时候编写保存按钮的操作了。

doSaveUser 函数

doSaveUser函数处理保存用户记录的过程。在大多数应用程序中,save函数将包含大部分代码,因为验证和用户反馈是流程中重要的步骤。

第一步是检索在表单中加载的用户记录实例,如下面的代码所示:

var rec = me.getUserForm().getRecord();

如果有效,记录将使用表单文本字段中输入的值进行更新,如下面的代码所示:

me.getUserForm().updateRecord();

在这个阶段,用户记录将与表单中输入的字段同步。这意味着表单中的所有字段都已复制到模型实例中。现在我们可以验证用户记录,如下面的代码所示:

var errs = rec.validate();

如果没有验证错误,记录将使用记录本身的save()函数保存。根据返回的 JSON 响应,有两种可能的回调。成功保存将触发成功处理程序,如下面的代码所示:

success: function(record, operation) {
    if (typeof record.store === 'undefined') {
        // the record is not yet in a store 
        me.getUserList().getStore().add(record);
       // select the user in the grid
       me.getUserList().getSelectionModel().select(record,true);
    }
    me.getUserFormFieldset().setTitle('Edit User ' + record.data.username);
    me.getUsernameField().disable();
    me.getDeleteUserButton().enable();
}

success回调将检查记录是否存在于存储中。如果不存在,记录将被添加到User存储并在用户列表中选择。删除按钮将被启用,并且fieldset标题将被适当设置。

failure操作将简单地通知用户原因,如下面的代码所示:

failure: function(rec, operation) {
    Ext.Msg.alert('Save Failure', operation.request.scope.reader.jsonData.msg);
}

如果在验证过程中遇到错误,我们会标记无效字段并显示通用错误消息,如下面的代码所示:

me.getUserForm().getForm().markInvalid(errs);
Ext.Msg.alert('Invalid Fields', 'Please fix the invalid entries!');

尝试保存一个没有有效电子邮件或密码的用户记录将显示如下消息:

doSaveUser 函数

doDeleteUser 函数

最终处理程序处理删除操作。doDeleteUser函数在触发destroy函数之前提示用户确认是否需要删除记录:

Ext.Msg.confirm('Confirm Delete User', 'Are you sure you want to delete user ' + rec.data.fullName + '?', function(btn) {
    if (btn === 'yes') {
  rec.destroy({
      failure: function(rec, operation) {
    Ext.Msg.alert('Delete Failure', operation.request.scope.reader.jsonData.msg);
      }
  });
  me.doAddUser();
    }
});

User存储将自动从存储中删除成功销毁的用户模型。任何失败都会告知用户原因。尝试删除John Smith的记录将导致以下代码中显示的消息:

doDeleteUser 函数

这条消息是从哪里来的?它是在实现删除操作的业务逻辑时编写的服务层UserServiceImpl.remove方法生成的。那么试试删除当前登录的用户呢?这将导致以下消息:

doDeleteUser 函数

再次强调,这是来自服务层业务逻辑。

让我们登录!

现在是时候启用我们的控制器并测试功能了。按照下面的代码更新Application.js文件:

Ext.define('TTT.Application', {
    name: 'TTT',
    extend: 'Ext.app.Application',
    requires: ['TTT.view.Viewport', 'TTT.view.LogonWindow'],
    models: ['User'],
    controllers: ['MainController', 'UserController'],
    stores: ['User'],
    init: function(application){
        TTT.URL_PREFIX = 'ttt/';
        Ext.Ajax.on('beforerequest', function(conn, options, eOpts){
            options.url = TTT.URL_PREFIX + options.url;
        });        
    },
    launch: function() {
        var me = this;
        TTT.console = function(output) {
            if (typeof console !== 'undefined') {
                console.info(output);
            }
        };
        me.logonWindow = Ext.create('TTT.view.LogonWindow');
        me.logonWindow.show();
    },
    doAfterLogon: function(userObj) {
        TTT.console(userObj);
        var me = this;
        me.getUser = function() {
            return userObj;
        };
        me.isAdmin = function() {
            return userObj.adminRole === 'Y';
        };
        Ext.create('TTT.view.Viewport');
        me.logonWindow.hide();
    }
});

Application.js代表整个应用程序,并定义了应用程序中捆绑的所有组件(模型、存储和控制器)。请注意,视图在此处未列出,因为它们由控制器直接管理。

我们已经定义了一个requires数组,其中包含TTT.view.LogonWindowTTT.view.Viewport类。虽然这并非必不可少,因为这些定义也驻留在适当的控制器中,但通常最好的做法是始终为类中的所有Ext.create()函数调用包括requires条目。我们使用Ext.create()创建了TTT.view.LogonWindowTTT.view.Viewport,因此已将它们包含在requires列表中。

我们的controllers数组包含了MainControllerUserController,这是预期的。我们还添加了User模型,因为这是我们目前唯一需要的模型。同样,User存储已添加到stores数组中。

init函数是应用程序启动时调用的模板方法。我们已经在Ext.Ajax beforerequest事件中添加了代码,以在com.gieman.tttracker.web.WebApp.getServletMappings()方法中配置的 servlet 路径前缀所有 URL;如下所示:

protected String[] getServletMappings() {
  return new String[]{
    "/ttt/*"
  };
}

ttt/前缀被添加到每个Ext.Ajax请求 URL 中,以确保正确映射到请求处理层。如果没有这个beforerequest事件代码,每个 URL 都需要以ttt为前缀,就像我们已经在User模型api中编码的那样,User存储 URL 以及MainController中的登录操作的Ajax.request URL。现在我们可以在访问 servlet 资源的所有 URL 中省略ttt/前缀。User模型api现在可以更改为以下代码:

api:{
  create: 'user/store.json',
  read: 'user/find.json',
  update: 'user/store.json',
  destroy: 'user/remove.json'
}

类似地,我们现在可以从User存储和MainController.doLogon/Logoff的 URL 中删除ttt/前缀。

注意

使用beforerequest事件为所有 Ajax URL 添加前缀的技术只能用于从单个映射的 servlet 消耗资源的简单项目。如果使用了多个映射,则需要实现不同的策略。

launch函数是另一个模板方法,在页面准备就绪并且所有 JavaScript 已加载时调用。TTT.console函数定义了一个轻量级的记录器,如果可用,它会将输出发送到浏览器控制台。它并不是Ext.log()函数的替代品,但使用起来更简单。我们鼓励您大量使用TTT.console函数来分析您的代码和调试处理过程。

launch函数的最后一步是创建并将LogonWindow实例分配给应用程序范围的变量logonWindow。这将在应用程序加载时显示登录窗口。

doAfterLogon函数用于后处理成功登录并初始化应用程序环境。doAfterLogon参数是成功登录后返回的 JSON 数据对象,具有以下结构:

{
    "username": "bjones",
    "firstName": "Betty",
    "lastName": "Jones",
    "fullName": "Betty Jones",
    "email": "bj@tttracker.com",
    "adminRole": "Y"
}

此函数将创建两个辅助函数,可以由任何组件调用以检索用户详细信息并测试用户是否为管理员。在MainHeader.js中已经展示了在代码中调用这些函数的示例。TTT命名空间用于通过TTT.getApplication().isAdmin()TTT.getApplication().getUser()访问应用程序函数。

doAfterLogon过程的最后一步是创建应用视图并隐藏登录窗口。奇怪的是,我们将在成功登录后调用doAfterLogon函数!

运行应用程序,并使用用户名bjones和密码admin测试登录屏幕。您应该会看到界面上所有标题按钮都是可用的,因为Betty Jones是管理员用户。

让我们登录!

使用用户名jsmith和密码admin测试登录屏幕。您应该会看到界面上没有管理员按钮,因为John Smith是普通用户:

让我们登录!

尝试点击注销按钮。您应该会看到一个确认窗口,如下所示:

让我们登录!

选择选项将触发MainController.doLogoff函数,注销用户并重新加载浏览器以再次显示LogonWindow

让我们维护用户

bjones用户身份登录,然后单击用户按钮。将显示以下屏幕:

让我们维护用户

在所有字段中输入字母A,然后单击保存按钮。然后将显示无效字段消息:

让我们维护用户

输入有效条目(记住验证规则!)并单击保存按钮。然后新用户记录应该被添加到用户列表中:

让我们维护用户

现在,您可以尝试删除和更新用户,以测试您编写的不同功能。在执行此类测试时,有很多隐藏的活动。您可以打开适合您的浏览器的 JavaScript 控制台(Safari Web Inspector,Firefox Firebug,Chrome Developer Tools 或通用的 Fiddler fiddler2.com/get-fiddler)来检查发送的请求。尝试以bjones身份再次登录,单击用户按钮,添加新用户,然后删除此新用户。您将看到以下请求被发送到服务器:

让我们维护用户

在您的函数中大量使用TTT.console()也有助于调试属性和应用程序状态。将语句TTT.console(userObj);添加到Application.js doAfterLogon(userObj)函数的第一行后,成功登录后将在控制台中输出以下内容:

让我们维护用户

花时间测试和尝试你编写的不同功能。在本章中,我们涵盖了许多概念!

摘要

本章介绍了 Ext JS 4 视图和控制器概念,构建登录窗口和用户维护界面。我们还介绍了包括持久性和验证在内的关键模型概念。拼图的各个部分终于落入了位,我们的前端操作与后端业务逻辑进行了交互。第十一章构建任务日志用户界面,将继续增强我们对 Ext JS 4 组件的理解,因为我们实现任务日志用户界面。

第十一章:构建任务日志用户界面

任务日志用户界面允许用户跟踪不同任务所花费的时间。该界面允许任务日志搜索和数据输入。用户将能够:

  • 在指定的时间段内搜索任务日志

  • 对任务日志条目列表进行排序

  • 编辑现有的任务日志

  • 添加新的任务日志条目

  • 查看在一段时间内花费在任务上的总时间

我们将构建的界面如下截图所示:

构建任务日志用户界面

开始日期结束日期字段将使用当前月份的开始和结束日期进行预填。单击搜索按钮将触发搜索,并用匹配的记录填充任务日志网格。从列表中单击记录将在编辑{任务名称}任务日志表单中打开该项目。在工具栏中单击添加新按钮将清除任务日志表单字段并将标题设置为添加任务日志。现在让我们详细看看这些操作。

任务日志工作流程和布局

搜索任务日志将需要有效的开始和结束日期。如果单击搜索按钮后任一字段缺失,将显示适当的消息:

任务日志工作流程和布局

从列表中选择一个任务日志条目将在编辑测试任务日志表单中打开记录。在任务日志表单中,当显示列表时,项目下拉菜单将显示公司名称以及项目名称:

任务日志工作流程和布局

从列表中选择一个项目将过滤在任务组合框中显示的任务:

任务日志工作流程和布局

如果选择了一个没有分配任务的项目,将显示以下消息:

任务日志工作流程和布局

添加新的任务日志将保留当前选择的日期项目(如果有的话):

任务日志工作流程和布局

删除任务日志将要求用户确认其操作:

任务日志工作流程和布局

注意

这应该是开发企业项目时所有删除操作的标准做法;在未经用户确认的情况下,永远不要删除记录!

选择将删除任务日志记录并从搜索结果中删除该记录。

构建我们的任务日志视图

任务日志用户界面包含各种不同的组件,包括日期选择器和组合框。我们将通过将屏幕分为三个视图来实现 UI。最外层的ManageTaskLogs视图将包含一个工具栏,并定义一个边框布局来容纳TaskLogListTaskLogForm视图:

构建我们的任务日志视图

ManageTaskLogs.js 文件

我们选择了border布局来允许TaskLogForm视图的调整大小,该视图最初固定为east区域的宽度为 400px。ManageTaskLogs的定义如下:

Ext.define('TTT.view.tasklog.ManageTaskLogs', {
    extend: 'Ext.panel.Panel',
    xtype: 'managetasklogs',
    requires: ['Ext.toolbar.Toolbar', 'Ext.layout.container.Border', 'Ext.form.field.Date', 'TTT.view.tasklog.TaskLogList', 'TTT.view.tasklog.TaskLogForm'],
    layout: {
        type: 'border'
    },
    initComponent: function() {
        var me = this;
        var now = new Date();
        Ext.applyIf(me, {
            dockedItems: [{
                xtype: 'toolbar',
                dock: 'top',
                items: [{
                    xtype: 'datefield',
                    labelAlign: 'right',
                    name: 'startDate',
                    format: 'd-M-Y',
                    fieldLabel: 'Start Date',
                    value: Ext.Date.getFirstDateOfMonth(now),
                    width: 180,
                    labelWidth: 70
                }, {
                    xtype: 'datefield',
                    labelAlign: 'right',
                    name: 'endDate',
                    format: 'd-M-Y',
                    fieldLabel: 'End Date',
                    value: Ext.Date.getLastDateOfMonth(now),
                    width: 180,
                    labelWidth: 70
                }, {
                    xtype: 'button',
                    iconCls: 'search',
                    itemId: 'searchBtn',
                    text: 'Search'
                }, {
                    xtype: 'button',
                    iconCls: 'addnew',
                    itemId: 'addTaskLogBtn',
                    text: 'Add New'
                }]
            }],
            items: [{
                xtype: 'taskloglist',
                region: 'center',
                margin: 1
            }, {
                xtype: 'tasklogform',
                region: 'east',
                split: true,
                width: 400
            }]
        });
        me.callParent(arguments);
    }
});

这个类定义在view.tasklog命名空间中。在添加ManageTaskLogs.js文件之前,您需要创建view/tasklog子目录。

date字段使用Ext.Date.getFirstDateOfMonth()Ext.Date.getLastDateOfMonth()函数初始化为当前月份的开始和结束日期。在 Ext JS 4 开发中操作日期是一个常见的任务,Ext.Date类中有许多有用的函数可以使这些任务变得容易。

TaskLogList视图已放置在border布局的center区域,而TaskLogForm视图在east区域被赋予了初始固定宽度为400。这将确保更大的屏幕分辨率会缩放任务日志列表以获得平衡的视图。因此,1200px 的屏幕宽度将显示以下布局:

ManageTaskLogs.js 文件

border布局还允许调整TaskLogForm视图的大小,以便用户希望增加数据输入字段的宽度时进行调整。

TaskLogForm.js 文件

TaskLogForm视图用于显示任务日志记录:

Ext.define('TTT.view.tasklog.TaskLogForm', {
    extend: 'Ext.form.Panel',
    xtype: 'tasklogform',
    requires: ['Ext.form.FieldSet', 'Ext.form.field.ComboBox', 'Ext.form.field.Date', 'Ext.form.field.Number', 'Ext.form.field.TextArea', 'Ext.toolbar.Toolbar'],
    layout: {
        type: 'anchor'
    },
    bodyPadding: 10,
    border: false,
    autoScroll: true,
    initComponent: function() {
        var me = this;
        Ext.applyIf(me, {
            items: [{
                xtype: 'fieldset',
                hidden: true,
                padding: 10,
                fieldDefaults: {
                    anchor: '100%'
                },
                title: 'Task Log Entry',
                items: [{
                    xtype: 'combobox',
                    name: 'project',
                    fieldLabel: 'Project',
                    queryMode: 'local',
                    store: 'Project',
                    valueField: 'idProject',
                    listConfig: {
                        minWidth: 300
                    },
                    tpl: Ext.create('Ext.XTemplate', '<tpl for=".">', '<div class="x-boundlist-item"><b>{companyName}</b>: {projectName}</div>', '</tpl>'),
                    displayTpl: Ext.create('Ext.XTemplate', '<tpl for=".">', '{projectName}', '</tpl>')
                }, {
                    xtype: 'combobox',
                    name: 'idTask',
                    fieldLabel: 'Task',
                    displayField: 'taskName',
                    queryMode: 'local',
                    store: 'Task',
                    valueField: 'idTask'
                }, {
                    xtype: 'datefield',
                    name: 'taskLogDate',
                    format: 'd-M-Y',
                    fieldLabel: 'Date'
                }, {
                    xtype: 'numberfield',
                    name: 'hours',
                    minValue: 0,
                    decimalPrecision: 2,
                    itemId: 'taskHours',
                    fieldLabel: 'Hours'
                }, {
                    xtype: 'textareafield',
                    height: 100,
                    name: 'taskDescription',
                    fieldLabel: 'Description',
                    emptyText: 'Enter task log description here...'
                }, {
                    xtype: 'toolbar',
                    ui: 'footer',
                    layout: {
                        pack: 'end',
                        type: 'hbox'
                    },
                    items: [{
                        xtype: 'button',
                        iconCls: 'delete',
                        itemId: 'deleteBtn',
                        disabled: true,
                        text: 'Delete'
                    }, {
                        xtype: 'button',
                        iconCls: 'save',
                        itemId: 'saveBtn',
                        text: 'Save'
                    }]
                }]
            }]
        });
        me.callParent(arguments);
    }
});

项目下拉框定义了两种不同的模板:一种用于渲染列表,一种用于渲染所选项目的文本。tpl属性将公司名称和项目名称组合在一起,以在下拉框中显示:

TaskLogForm.js 文件

当选择项目时,只显示项目名称,由displayTpl模板呈现。

TaskLogList.js 文件

TaskLogList视图定义如下:

Ext.define('TTT.view.tasklog.TaskLogList', {
    extend: 'Ext.grid.Panel',
    xtype: 'taskloglist',
    viewConfig: {
        markDirty: false,
        emptyText: 'There are no task log records to display...'
    },
    title: 'Task Logs',
    store: 'TaskLog',
    requires: ['Ext.grid.feature.Summary', 'Ext.grid.column.Date', 'Ext.util.Point'],
    features: [{
        ftype: 'summary',
        dock: 'bottom'
    }],
    initComponent: function() {
        var me = this;
        Ext.applyIf(me, {
            columns: [{
                xtype: 'datecolumn',
                dataIndex: 'taskLogDate',
                format: 'd-M-Y',
                width: 80,
                text: 'Date'
            }, {
                xtype: 'gridcolumn',
                dataIndex: 'taskName',
                text: 'Task'
            }, {
                xtype: 'gridcolumn',
                dataIndex: 'taskDescription',
                flex: 1,
                text: 'Description',
                summaryType: 'count',
                summaryRenderer: function(value, summaryData, dataIndex) {
                    return Ext.String.format('<div style="font-weight:bold;text-align:right;">{0} Records, Total Hours:</div>', value);
                }
            }, {
                xtype: 'gridcolumn',
                dataIndex: 'taskMinutes',
                width: 80,
                align: 'center',
                text: 'Hours',
                summaryType: 'sum',
                renderer: function(value, metaData, record) {
                    return record.get('hours');
                },
                summaryRenderer: function(value, summaryData, dataIndex) {
                    var valHours = value / 60;
                    return Ext.String.format('<b>{0}</b>', valHours);
                }
            }]
        });
        me.callParent(arguments);
    }
});

viewConfig属性用于创建Ext.grid.View类的实例,该类提供了特定于网格的视图功能。我们将按记录基础进行更新,而不是通过存储进行批量更新。markDirty:false属性将确保成功保存的记录在网格中不显示脏标志。如果任务日志搜索没有返回记录,则将在网格中显示emptyText值,以便向用户提供即时反馈。

TaskLogList视图使用summary功能显示包含记录计数和在搜索列表中显示的总工时的总行。summaryTypesummaryRender定义用于配置在taskDescriptiontaskMinutes列的页脚中显示的featuresummary值可以是countsumminmaxaverage中的一个,我们使用countsum值。有关summary功能的更多信息,请访问docs.sencha.com/extjs/4.2.2/#!/api/Ext.grid.feature.Summary。以下截图显示了summary功能的使用:

TaskLogList.js 文件

还有一些代码需要注意,表示分配给任务的工作小时数的列:

{
    xtype: 'gridcolumn',
    dataIndex: 'taskMinutes',
    width:80,
    align:'center',
    text: 'Hours',
    summaryType:'sum',
    renderer:function(value, metaData, record){
  return record.get('hours');
    },
    summaryRenderer: function(value, summaryData, dataIndex) {
  var valHours = value/60;
  return Ext.String.format('<b>{0}</b>', valHours); 
    }           
}

每个任务日志的工作时间以分钟为单位存储在数据库中,但在前端显示为小时。该列绑定到模型中的taskMinutes字段。渲染器显示TaskLog模型的(计算得出的)hours字段(这将在接下来的部分中定义)。summary功能使用taskMinutes字段来计算总时间,因为该功能需要一个真实(未转换的)模型字段来操作。然后,将这些总时间(以分钟为单位)转换为小时以进行显示。

定义我们的模型

我们的ProjectTaskTaskLog模型是使用 Sencha Cmd 在第九章中创建的,但它们缺乏持久性或验证逻辑。现在是添加所需代码的时候了。

TaskLog 模型

TaskLog模型是我们应用程序中最复杂的模型。完整的TaskLog模型及所有必需的逻辑如下:

Ext.define('TTT.model.TaskLog', {
    extend: 'Ext.data.Model',    
    fields: [
        { name: 'idTaskLog', type: 'int', useNull:true },
        { name: 'taskDescription', type: 'string' },
        { name: 'taskLogDate', type: 'date', dateFormat:'Ymd' },
        { name: 'taskMinutes', type: 'int' },
        { name: 'hours', type: 'float', persist:false, convert:function(value, record){
            return record.get('taskMinutes') / 60;
        }},
        { name: 'username', type: 'string' },
        { name: 'userFullName', type: 'string', persist:false },
        { name: 'idTask', type: 'int', useNull:true },
        { name: 'taskName', type: 'string', persist:false },
        { name: 'idProject', type: 'int', persist:false },
        { name: 'projectName', type: 'string', persist:false },
        { name: 'idCompany', type: 'int', persist:false },
        { name: 'companyName', type: 'string', persist:false }
    ],
    idProperty: 'idTaskLog',
    proxy: {
        type: 'ajax',
        idParam:'idTaskLog',
        api:{
            create:'taskLog/store.json',
            read:'taskLog/find.json',
            update:'taskLog/store.json',
            destroy:'taskLog/remove.json'
        },
        reader: {
            type: 'json',            
            root: 'data'
        },
        writer: {
            type: 'json',
            allowSingle:true,
            encode:true,
            root:'data',
            writeAllFields: true
        }
    },
    validations: [
        {type: 'presence',  field: 'taskDescription'},
        {type: 'length', field: 'taskDescription', min: 2},
        {type: 'presence',  field: 'username'},
        {type: 'presence',  field: 'taskLogDate'},
        {type: 'presence',  field: 'idTask'},
        {type: 'length', field: 'idTask', min: 1},
        {type: 'length', field: 'taskMinutes', min: 0}
    ]     
});

这是我们第一次在字段上使用useNull属性。当将 JSON 数据转换为intfloatBooleanString类型时,useNull属性非常重要。当读取器无法解析值时,将为模型字段设置以下默认值:

字段类型 默认值为useNull:true 默认值为useNull:false
--- --- ---
int null 0
float null 0
boolean null false
String null ""(空字符串)
日期 null null

如果读取器无法解析值,则如果字段配置为useNull:true,则将null分配给字段值。否则,将使用该类型的默认值,如前表中第三列中所示。请注意,如果无法解析值,则Date字段始终设置为null。在大多数情况下,重要的是在读取记录后能够确定字段是否为空,因此我们建议为所有主键字段设置useNull:true属性。

这也是我们第一次使用dateFormat属性。该属性定义了在通过配置的writerreader类编码或解码 JSON date字段时的日期格式。YYYYMMDD格式字符串表示一个 8 位数。例如,2013 年 8 月 18 日的日期等同于 20130818。其他格式字符串在docs.sencha.com/extjs/4.2.2/#!/api/Ext.DateExt.Date API 中有文档记录。强烈建议您始终为任何date字段指定显式日期格式。

对于hours字段使用convert函数也是新的。它将reader类提供的值转换并存储在模型的配置的name字段中。在我们的TaskLog模型中,分钟数被转换为十进制值并存储在hours字段中。对于 3T 用户来说,输入 2.5 小时的值要比 150 分钟方便得多。

请注意,我们再次使用persist:false属性来限制在我们的TaskLogHandler方法中不需要持久化的字段。我们对TaskLog模型的验证也应该是不言自明的!

项目模型

Project模型定义了我们通常的代理和验证属性:

Ext.define('TTT.model.Project', {
    extend: 'Ext.data.Model',
    fields: [
        { name: 'idProject', type: 'int', useNull:true },
        { name: 'projectName', type: 'string' },
        { name: 'idCompany', type:'int', useNull:true },
        { name: 'companyName', type:'string', persist:false }
    ],
    idProperty: 'idProject',
    proxy: {
        type: 'ajax',
        idParam:'idProject',
        api:{
            create:'project/store.json',
            read:'project/find.json',
            update:'project/store.json',
            destroy:'project/remove.json'
        },
        reader: {
            type: 'json',
            root: 'data'
        },
        writer: {
            type: 'json',
            allowSingle:true,
            encode:true,
            root:'data',
            writeAllFields: true
        }
    },
    validations: [
        {type: 'presence',  field: 'projectName'},
        {type: 'length', field: 'projectName', min: 2},
        {type: 'presence',  field: 'idCompany'},
        {type: 'length', field: 'idCompany', min: 1}
    ]    
});

在持久化记录时不需要包括companyName字段,因此该字段包含persist:false属性。

任务模型

Task模型也具有简单的结构:

Ext.define('TTT.model.Task', {
    extend: 'Ext.data.Model',    
    fields: [
        { name: 'idTask', type: 'int', useNull:true },
        { name: 'taskName', type: 'string' },
        { name: 'idProject', type: 'int', useNull:true },
        { name: 'projectName', type: 'string', persist:false  },
        { name: 'idCompany', type: 'int', useNull:true, persist:false  },
        { name: 'companyName', type: 'string', persist:false  }

    ],
    idProperty: 'idTask',
    proxy: {
        type: 'ajax',
        idParam:'idTask',
        api:{
            create:'task/store.json',
            read:'task/find.json',
            update:'task/store.json',
            destroy:'task/remove.json'
        },
        reader: {
            type: 'json',
            root: 'data'
        },
        writer: {
            type: 'json',
            allowSingle:true,
            encode:true,
            root:'data',
            writeAllFields: true
        }
    },
    validations: [
        {type: 'presence',  field: 'taskName'},
        {type: 'length', field: 'taskName', min: 2},
        {type: 'presence',  field: 'idProject'},
        {type: 'length', field: 'idProject', min: 1}
    ]
});

我们再次有几个字段不需要持久化,并因此配置了persist:false属性。现在是时候定义构建我们的任务日志用户界面所需的存储了。

定义我们的存储

TaskLogListTaskLogForm视图需要存储才能运行。TaskLogList视图需要一个TaskLog存储,而TaskLogForm视图需要一个Project和一个Task存储。现在让我们来定义它们。

TaskLog 存储

我们使用一个辅助方法定义此存储,以便轻松加载任务日志搜索。定义如下:

Ext.define('TTT.store.TaskLog', {
    extend: 'Ext.data.Store',
    requires: ['TTT.model.TaskLog'],
    model: 'TTT.model.TaskLog',
    proxy: {
        type: 'ajax',
        url: 'taskLog/findByUser.json',
        reader: {
            type: 'json',
            root: 'data'
        }
    },
    doFindByUser: function(username, startDate, endDate) {
        this.load({
            params: {
                username: username,
                startDate: Ext.Date.format(startDate, 'Ymd'),
                endDate: Ext.Date.format(endDate, 'Ymd')
            }
        });
    }
});

请注意,我们在doFindByUser方法中使用Ext.Date.format函数格式化开始和结束日期。这是为了确保发送到服务器的日期是预期的 8 位yyyymmdd格式。

项目存储

Project存储将被排序,以实现在Project组合框中显示的所需公司名称分组:

Ext.define('TTT.store.Project', {
    extend: 'Ext.data.Store',
    requires: ['TTT.model.Project'],
    model: 'TTT.model.Project',
    sorters: [{
        property: 'companyName',
        direction: 'ASC'
    }, {
        property: 'projectName',
        direction: 'ASC'
    }],
    proxy: {
        type: 'ajax',
        url: 'project/findAll.json',
        reader: {
            type: 'json',
            root: 'data'
        }
    }
});

请注意,所有项目记录将通过映射到ProjectHandler Java 类中的findAll方法的project/findAll.json URL 加载。sorters属性配置了加载存储后将应用于结果的排序例程。记录将首先按companyName字段按升序排序,然后使用projectName字段进行二次排序。

任务存储

任务存储具有非常简单的结构。以下定义对您来说应该没有什么意外:

Ext.define('TTT.store.Task', {
    extend: 'Ext.data.Store',
    requires: ['TTT.model.Task'],
    model: 'TTT.model.Task',
    proxy: {
        type: 'ajax',
        url:'task/findAll.json',
        reader: {
            type: 'json',
            root: 'data'
        }
    }    
});

所有任务记录将通过映射到TaskHandler Java 类中的findAll方法的task/findAll.json URL 加载。

控制 TaskLog 操作

TaskLogController定义是我们迄今为止开发的最复杂的控制器定义。以下定义不包括refsinit配置。您可以从本书的网站下载完整的源代码:

Ext.define('TTT.controller.TaskLogController', {
    extend: 'Ext.app.Controller',
    views: ['tasklog.ManageTaskLogs'],
    stores: ['TaskLog', 'Project', 'Task'],
    refs: omitted…
    init: omitted…
    doAfterActivate: function() {
        var me = this;
        me.getTaskStore().load();
        me.getProjectStore().load();
    },            
    doSelectProject: function(combo, records) {
        var me = this;
        var rec = records[0];
        if (!Ext.isEmpty(rec)) {
            me.getTaskCombo().getStore().clearFilter();
            me.getTaskCombo().getStore().filter({
                property: 'idProject',
                value: rec.get('idProject'),
                exactMatch: true
            });
            me.getTaskCombo().setValue('');
            if (me.getTaskCombo().getStore().getCount() === 0) {
                Ext.Msg.alert('No Tasks Available', 'There are no tasks assigned to this project!');
            }
        }
    },
    doSelectTaskLog: function(grid, record) {
        var me = this;
        me.getTaskCombo().getStore().clearFilter();
        me.getTaskCombo().getStore().filter({
            property: 'idProject',
            value: record.get('idProject'),
            exactMatch: true
        });
        me.getProjectCombo().setValue(record.get('idProject'));
        me.getTaskLogForm().loadRecord(record);
        me.getTaskLogFormFieldset().show();
        me.getTaskLogFormFieldset().setTitle('Edit Task Log For ' + record.get('taskName'));
        me.getTaskLogForm().getForm().clearInvalid();
        me.getDeleteTaskLogButton().enable();
    },
    doAddTaskLog: function() {
        var me = this;
        me.getTaskLogFormFieldset().show();
        me.getTaskLogFormFieldset().setTitle('Add Task Log');
        var taskLogDate = me.getTaskLogDateField().getValue();
        if (Ext.isEmpty(taskLogDate)) {
            taskLogDate = new Date();
        }
        var tl = Ext.create('TTT.model.TaskLog', {
            taskDescription: '',
            username: TTT.getApplication().getUser().username,
            taskLogDate: taskLogDate,
            taskMinutes: 0,
            idTask: null
        });
        me.getTaskLogForm().loadRecord(tl);
        me.getDeleteTaskLogButton().disable();
        var idProject = me.getProjectCombo().getValue();
        if (Ext.isEmpty(idProject)) {
            var firstRec = me.getProjectCombo().getStore().getAt(0);
            me.getProjectCombo().setValue(firstRec.get('idProject'), true);
            me.getTaskCombo().getStore().clearFilter();
            me.getTaskCombo().getStore().filter({
                property: 'idProject',
                value: firstRec.get('idProject'),
                exactMatch: true
            });
            me.getTaskCombo().setValue('');
        }
    },
    doDeleteTaskLog: function() {
        var me = this;
        var rec = me.getTaskLogForm().getRecord();
        Ext.Msg.confirm('Confirm Delete', 'Are you sure you want to delete this task log?', function(btn) {
            if (btn === 'yes') {
                rec.destroy({
                    failure: function(rec, operation) {
                        Ext.Msg.alert('Delete Failure', operation.request.scope.reader.jsonData.msg);
                    }
                });
                me.doAddTaskLog();
            }
        });
    },
    doSaveTaskLog: function() {
        var me = this;
        var rec = me.getTaskLogForm().getRecord();
        if (!Ext.isEmpty(rec)) {
            me.getTaskLogForm().updateRecord(); 
            // update the minutes field of the record
            var hours = me.getTaskHoursField().getValue();
            rec.set('taskMinutes', hours * 60);
            var errs = rec.validate();
            if (errs.isValid() && me.getTaskLogForm().isValid()) {
                rec.save({
                    success: function(record, operation) {
                        if (typeof record.store === 'undefined') {
                            me.getTaskLogStore().add(record);
                        }
                        me.getTaskLogFormFieldset().setTitle('Edit Task Log For ' + record.get('taskName'));
                        me.getDeleteTaskLogButton().enable();
                    },
                    failure: function(rec, operation) {
                        Ext.Msg.alert('Save Failure', operation.request.scope.reader.jsonData.msg);
                    }
                });
            } else {
                me.getTaskLogForm().getForm().markInvalid(errs);
                Ext.Msg.alert('Invalid Fields', 'Please fix the invalid entries!');
            }
        }
    },
    doSearch: function() {
        var me = this;
        var startDate = me.getStartDateField().getValue();
        if (Ext.isEmpty(startDate)) {
            Ext.Msg.alert('Start Date Required', 'Please select a valid start date to perform a search');
            return;
        }
        var endDate = me.getEndDateField().getValue();
        if (Ext.isEmpty(endDate)) {
            Ext.Msg.alert('End Date Required', 'Please select a valid end date to perform a search');
            return;
        }
        me.getTaskLogStore().doFindByUser(TTT.getApplication().getUser().username, startDate, endDate);
        me.getTaskLogFormFieldset().hide();
    }
});

TaskLogController部分定义了视图使用的三个存储库。ProjectTask存储库在ManageTaskLogs面板激活时触发的doAfterActivate函数中加载。这确保了任务项目下拉框有有效的数据可操作。

控制器中定义的每个ref项都用于一个或多个函数,以访问底层组件并执行适当的操作。每个ref项的自动生成的设置方法使得在我们的代码中引用组件变得容易。

注意

重要的是要注意,ref项始终返回一个单一对象,因此不能像Ext.ComponentQuery.query函数一样用于检索组件集合。要动态检索对象(而不使用 refs)或检索对象集合,应使用ComponentQuery.query函数。有关更多信息,请参见docs.sencha.com/extjs/4.2.2/#!/api/Ext.ComponentQuery

每个可能的用户操作都由一个适当命名的函数处理。函数参数将取决于事件源。button对象的click事件处理程序函数将始终将对按钮本身的引用作为事件处理程序的第一个参数传递。网格itemclick事件处理函数将始终接收对网格本身的引用作为第一个参数,然后是被单击的记录。您应该查看 Sencha Ext JS 4 文档,以熟悉常见组件的事件处理函数参数。

执行搜索需要有效的开始和结束日期。在允许搜索之前,doSearch函数将验证两个date字段。请注意使用TTT.getApplication().getUser()函数来访问当前登录的用户。

成功的搜索将列出与搜索条件匹配的任务日志记录。然后用户可以点击列表中的项目以加载任务日志表单。这是在doSelectTaskLog函数中完成的。

添加新的任务日志将创建一个新的TaskLog模型记录并加载表单。记录将设置当前登录的username属性。如果可用,项目下拉框中当前选择的项目将被保留;否则,将选择下拉框中的第一项。

选择项目将会将任务存储库过滤为仅显示分配给项目的任务。这是在doSelectProject函数中实现的:

me.getTaskCombo().getStore().filter({
property:'idProject',
value:rec.get('idProject'),
exactMatch:true
});

请注意,我们在idProject字段上定义了exactMatch。如果没有此属性,将返回部分匹配(例如,使用idProject值为2进行过滤将匹配具有idProject值为20的任务;对开发人员来说是一个陷阱!)。

doSaveTaskLogdoDeleteTaskLog函数对加载到任务日志表单中的记录执行适当的操作。就像在上一章中一样,表单用于显示和输入数据,但数据从未被提交。所有保存数据操作都是通过model实例触发的。

测试任务日志界面

在运行应用程序并测试新文件之前,您需要将TaskLogController以及新的存储库和模型添加到您的Application.js文件中:

controllers: [
  'MainController',
  'UserController',
  'TaskLogController'
],
models: [
  'User',
  'Project',
 'Task',
 'TaskLog'
],
stores: [
  'User',
  'Project',
 'Task',
 'TaskLog'
]

您还需要将ManageTaskLogs视图添加到MainCards视图的items数组中,如下所示:

Ext.define('TTT.view.MainCards', {
    extend: 'Ext.container.Container',
    xtype: 'maincards',
    requires: ['Ext.layout.container.Card', 'TTT.view.Welcome', 'TTT.view.user.ManageUsers', 'TTT.view.tasklog.ManageTaskLogs'],
    layout: 'card',
    initComponent: function() {
        var me = this;
        Ext.applyIf(me, {
            items: [{
                xtype: 'welcome',
                itemId: 'welcomCard'
            }, {
                xtype: 'manageusers',
                itemId: 'manageUsersCard'
            }, {
 xtype: 'managetasklogs',
 itemId: 'taskLogCard'
 }]
        });
        me.callParent(arguments);
    }
});

现在,您可以在 GlassFish 服务器中运行应用程序并测试任务日志界面。首先以jsmith用户登录,密码为admin,并使用不同的日期范围执行搜索。数据应该显示为您在 MySQL 中加载 3T 表时的数据:

测试任务日志界面

尝试执行不返回任何记录的搜索。您应该看到在TaskLogList视图的viewConfig属性中定义的emptyText值:

测试任务日志界面

现在您可以尝试添加新记录和编辑现有的任务日志,以测试功能的全部范围。您能让以下消息弹出吗?

测试任务日志界面

在下一章中,我们将构建 3T 管理界面,以阻止这种情况发生!

摘要

任务日志用户界面汇集了视图、模型和存储之间的多个组件交互。我们为网格引入了summary功能,并在ProjectTask存储中过滤记录。搜索TaskLog记录需要我们将日期解析为适合后端处理的格式,而我们的基本模型骨架则增加了持久性和验证属性。我们再次探索了有趣的 Ext JS 4 领域,并与各种组件一起工作。

在第十二章,“3T 管理简化”中,我们将开发 3T 管理界面并引入 Ext JS 4 树组件。Ext.tree.Panel是一个非常多才多艺的组件,非常适合显示公司-项目-任务的关系。

第十二章:3T 管理简化

3T 管理界面允许用户维护公司、项目和任务之间的关系。由于关系是分层的,我们将使用 Ext JS 中最通用的组件之一:Ext.tree.Panel

我们将构建的界面如下截图所示:

3T 管理简化

在树中选择一个项目将在右侧面板上显示相应的记录,而添加新公司按钮将允许用户输入新公司的名称。现在让我们详细研究这些操作。

管理工作流程和布局

有三种不同的实体可以进行编辑(公司、项目和任务),前面的截图显示了公司。在树中选择一个项目将显示编辑项目表单:

管理工作流程和布局

选择一个任务将显示编辑任务表单:

管理工作流程和布局

选择添加新公司按钮将显示一个空的公司表单:

管理工作流程和布局

请注意,删除添加项目按钮是禁用的。当某个操作不被允许时,适当的按钮将在所有屏幕上被禁用。在这种情况下,您不能向尚未保存的公司添加项目。

树工具将允许用户展开、折叠和刷新树:

管理工作流程和布局

当用户首次显示管理界面时,将显示添加新公司屏幕。当删除任何项目时,将显示请从树中选择一个项目...消息:

管理工作流程和布局

现在我们已经定义了界面及其行为,是时候定义我们的视图了。

构建 3T 管理界面

3T 管理界面将要求我们构建以下截图中显示的组件。ProjectFormTaskForm视图不可见,将在需要时以卡片布局显示:

构建 3T 管理界面

ManageTasks视图是一个hbox布局,平均分割屏幕的左右两部分。工具栏包含一个按钮用于添加新公司,右侧区域是包含CompanyFormProjectFormTaskForm视图的卡片布局。现在让我们详细看看每个组件。

ManageTasks.js 文件

ManageTasks视图定义了带有添加新公司按钮的工具栏,并将视图分割为hbox布局。由xtype配置的companytree面板和使用卡片布局定义的container。卡片布局容器包含CompanyFormProjectFormTaskFormManageTasks视图定义如下:

Ext.define('TTT.view.admin.ManageTasks', {
    extend: 'Ext.panel.Panel',
    xtype: 'managetasks',
    requires: ['TTT.view.admin.CompanyTree', 'TTT.view.admin.TaskForm', 'TTT.view.admin.ProjectForm', 'TTT.view.admin.CompanyForm', 'Ext.toolbar.Toolbar', 
        'Ext.layout.container.Card'],
    layout: {
        type: 'hbox',
        align: 'stretch'
    },
    initComponent: function() {
        var me = this;
        Ext.applyIf(me, {
            dockedItems: [{
                xtype: 'toolbar',
                dock: 'top',
                items: [{
                    xtype: 'button',
                    itemId: 'addCompanyBtn',
                    iconCls: 'addnew',
                    text: 'Add New Company'
                }]
            }],
            items: [{
                xtype: 'companytree',
                flex: 1,
                margin: 1
            }, {
                xtype: 'container',
                itemId: 'adminCards',
                activeItem: 0,
                flex: 1,
                layout: {
                    type: 'card'
                },
                items: [{
                    xtype: 'container',
                    padding: 10,
                    html: 'Please select an item from the tree...'
                }, {
                    xtype: 'companyform'
                }, {
                    xtype: 'projectform'
                }, {
                    xtype: 'taskform'
                }]
            }]
        });
        me.callParent(arguments);
    }
});

请注意,使用简单容器作为卡片布局的第一项,以显示请从树中选择一个项目...消息。

ProjectForm.js 文件

CompanyForm视图具有非常简单的界面,只有一个数据输入字段:companyName。这可以在以下代码行中看到:

Ext.define('TTT.view.admin.CompanyForm', {
    extend: 'Ext.form.Panel',
    xtype: 'companyform',
    requires: ['Ext.form.FieldSet', 'Ext.form.field.Text', 'Ext.toolbar.Toolbar'],
    layout: {
        type: 'anchor'
    },
    bodyPadding: 10,
    border: false,
    autoScroll: true,
    initComponent: function() {
        var me = this;
        Ext.applyIf(me, {
            items: [{
                xtype: 'fieldset',
                hidden: false,
                padding: 10,
                width: 350,
                fieldDefaults: {
                    anchor: '100%'
                },
                title: 'Company Entry',
                items: [{
                    xtype: 'textfield',
                    name: 'companyName',
                    fieldLabel: 'Name',
                    emptyText: 'Enter company name...'
                }, {
                    xtype: 'toolbar',
                    ui: 'footer',
                    layout: {
                        pack: 'end',
                        type: 'hbox'
                    },
                    items: [{
                        xtype: 'button',
                        iconCls: 'delete',
                        itemId: 'deleteBtn',
                        disabled: true,
                        text: 'Delete'
                    }, {
                        xtype: 'button',
                        iconCls: 'addnew',
                        itemId: 'addProjectBtn',
                        disabled: true,
                        text: 'Add Project'
                    }, {
                        xtype: 'button',
                        iconCls: 'save',
                        itemId: 'saveBtn',
                        text: 'Save'
                    }]
                }]
            }]
        });
        me.callParent(arguments);
    }
});

请注意,删除添加项目按钮的初始状态是禁用的,直到加载有效的公司为止。

ProjectForm.js 文件

ProjectForm视图的布局和结构与我们刚刚定义的公司表单非常相似:

Ext.define('TTT.view.admin.ProjectForm', {
    extend: 'Ext.form.Panel',
    xtype: 'projectform',
    requires: ['Ext.form.FieldSet', 'Ext.form.field.Text', 'Ext.toolbar.Toolbar'],
    layout: {
        type: 'anchor'
    },
    bodyPadding: 10,
    border: false,
    autoScroll: true,
    initComponent: function() {
        var me = this;
        Ext.applyIf(me, {
            items: [{
                xtype: 'fieldset',
                hidden: false,
                padding: 10,
                width: 350,
                fieldDefaults: {
                    anchor: '100%'
                },
                title: 'Project Entry',
                items: [{
                    xtype: 'textfield',
                    name: 'projectName',
                    fieldLabel: 'Project Name',
                    emptyText: 'Enter project name...'
                }, {
                    xtype: 'toolbar',
                    ui: 'footer',
                    layout: {
                        pack: 'end',
                        type: 'hbox'
                    },
                    items: [{
                        xtype: 'button',
                        iconCls: 'delete',
                        itemId: 'deleteBtn',
                        disabled: true,
                        text: 'Delete'
                    }, {
                        xtype: 'button',
                        iconCls: 'addnew',
                        itemId: 'addTaskBtn',
                        disabled: true,
                        text: 'Add Task'
                    }, {
                        xtype: 'button',
                        iconCls: 'save',
                        itemId: 'saveBtn',
                        text: 'Save'
                    }]
                }]
            }]
        });
        me.callParent(arguments);
    }
});

再次,删除添加任务按钮的初始状态是禁用,直到加载有效项目为止。

TaskForm.js 文件

TaskForm视图与之前的表单类似,但只需要两个按钮,定义如下:

Ext.define('TTT.view.admin.TaskForm', {
    extend: 'Ext.form.Panel',
    xtype: 'taskform',
    requires: ['Ext.form.FieldSet', 'Ext.form.field.Text', 'Ext.toolbar.Toolbar'],
    layout: {
        type: 'anchor'
    },
    bodyPadding: 10,
    border: false,
    autoScroll: true,
    initComponent: function() {
        var me = this;
        Ext.applyIf(me, {
            items: [{
                xtype: 'fieldset',
                hidden: false,
                padding: 10,
                width: 350,
                fieldDefaults: {
                    anchor: '100%'
                },
                title: 'Task Entry',
                items: [{
                    xtype: 'textfield',
                    name: 'taskName',
                    fieldLabel: 'Name',
                    emptyText: 'Enter task name...'
                }, {
                    xtype: 'toolbar',
                    ui: 'footer',
                    layout: {
                        pack: 'end',
                        type: 'hbox'
                    },
                    items: [{
                        xtype: 'button',
                        iconCls: 'delete',
                        itemId: 'deleteBtn',
                        disabled: true,
                        text: 'Delete'
                    }, {
                        xtype: 'button',
                        iconCls: 'save',
                        itemId: 'saveBtn',
                        text: 'Save'
                    }]
                }]
            }]
        });
        me.callParent(arguments);
    }
});

再次,删除按钮的初始状态是禁用的,直到加载有效任务为止。

CompanyTree.js 文件

最终视图是CompanyTree视图,表示公司、项目和任务之间的关系。

The CompanyTree.js file

这个视图定义如下:

Ext.define('TTT.view.admin.CompanyTree', {
    extend: 'Ext.tree.Panel',
    xtype: 'companytree',
    title: 'Company -> Projects -> Tasks',
    requires: ['TTT.store.CompanyTree'],
    store: 'CompanyTree',
    lines: true,
    rootVisible: false,
    hideHeaders: true,
    viewConfig: {
        preserveScrollOnRefresh: true
    },
    initComponent: function() {
        var me = this;
        Ext.applyIf(me, {
            tools: [{
                type: 'expand',
                qtip: 'Expand All'
            }, {
                type: 'collapse',
                qtip: 'Collapse All'
            }, {
                type: 'refresh',
                qtip: 'Refresh Tree'
            }],
            columns: [{
                xtype: 'treecolumn',
                dataIndex: 'text',
                flex: 1
            }]
        });
        me.callParent(arguments);
    }
}); 

CompanyTree视图扩展了Ext.tree.Panel,需要一个专门的Ext.data.TreeStore实现来管理树节点和项之间的关系。Ext JS 4 树是一个非常灵活的组件,我们建议您熟悉核心树概念,网址为docs.sencha.com/extjs/4.2.2/#!/guide/tree

介绍Ext.data.TreeStore

Ext.data.TreeStore类是Ext.tree.Panel默认使用的存储实现。TreeStore函数提供了许多方便的函数来加载和管理分层数据。TreeStore函数可以使用模型来定义,但这不是必需的。如果提供了模型,它将使用Ext.data.NodeInterface的字段、方法和属性来装饰模型,这些属性是树中使用所需的。这个额外的功能被应用到模型的原型上,以允许树维护模型之间的状态和关系。

如果没有提供模型,存储将以一种实现Ext.data.NodeInterface类的方式创建一个这样的模型。我们建议您浏览NodeInterface API 文档,以查看节点上可用的全部字段、方法和属性。

我们用于树的CompanyTree存储定义如下:

Ext.define('TTT.store.CompanyTree', {
    extend: 'Ext.data.TreeStore',
    proxy: {
        type: 'ajax',
        url: 'company/tree.json'
    }
});

所有树存储都使用分层结构的数据,可以是 JSON 或 XML 格式。我们将在请求处理层生成以下结构的 JSON 数据:

{
    "success": true,
    "children": [
        {
            "id": "C_1",
            "text": "PACKT Publishing",
            "leaf": false,
            "expanded": true,
            "children": [
                {
                    "id": "P_1",
                    "text": "EAD with Spring and ExtJS",
                    "leaf": false,
                    "expanded": true,
                    "children": [
                        {
                            "id": "T_1",
                            "text": "Chapter 1",
                            "leaf": true
                        },
                        {
                            "id": "T_2",
                            "text": "Chapter 2",
                            "leaf": true
                        },
                        {
                            "id": "T_3",
                            "text": "Chapter 3",
                            "leaf": true
                        }
                    ]
                },
                {
                    "id": "P_2",
                    "text": "The Spring Framework for Beginners",
                    "leaf": false,
                    "expanded": true,
                    "children": [
                        {
                            "id": "T_4",
                            "text": "Chapter 1",
                            "leaf": true
                        },
                        {
                            "id": "T_5",
                            "text": "Chapter 2",
                            "leaf": true
                        },
                        {
                            "id": "T_6",
                            "text": "Chapter 3",
                            "leaf": true
                        }
                    ]
                }
            ]
        }
    ]
}

这个结构定义了任何树使用的核心属性,包括idchildrentextleafexpanded

children属性定义了存在于同一级别并属于同一父级的节点数组。结构中的顶级子节点属于根节点,并将添加到树的根级别。树面板属性rootVisible:false将隐藏视图中的根级别,仅显示子节点。通过将属性设置为rootVisible:true来启用根级别的可见性,将显示TreeStore类中定义的根节点。例如,将以下定义添加到树存储中将导致Companies节点显示如下截图所示:

root: {
    text: 'Companies',
    expanded: true
}

Introducing the Ext.data.TreeStore class

我们希望在树的顶层显示每个公司,因此将隐藏根节点。

id属性在内部用于唯一标识每个节点。在树结构内,此属性不能重复,因此我们将id值前缀为节点类型。表示公司的节点将以C_为前缀,项目节点以P_为前缀,任务节点以T_为前缀。这种id格式将允许我们确定节点类型和节点的主键。如果没有提供 ID,存储将为我们生成一个 ID。

ID 还可以用于动态分配iconCls类给节点。我们通过存储的append监听器来定义这一点,稍后在控制器中定义。请注意,我们也可以在 JSON 本身中轻松定义iconCls属性:

{
    "success": true,
    "children": 
        {
            "id": "C_1",
            "iconCls": "company",
            "text": "PACKT Publishing",
            "leaf": false,
            "expanded": true,
            "children": [
                {
                    "id": "P_1",
                    "iconCls": "project",
                    "text": "EAD with Spring and ExtJS",
                    "leaf": false,
                    "expanded": true,
                    "children": [ etc…

然而,我们现在正在将数据与呈现结合在一起,生成 JSON 的 Java 方法不应该关心数据如何显示。

JSON 树的text字段用于显示节点的文本。对于没有多列的简单树,如果没有使用列定义显式设置字段名,这是默认字段名(树列将在本章后面讨论)。

leaf属性标识此节点是否可以有子节点。所有任务节点都具有"leaf":true设置。leaf属性定义了是否在节点旁边显示展开图标。

感兴趣的最后一个属性是expanded属性,它指示节点是否应以展开状态显示。如果一次加载整个树,这个属性必须设置为true,以便在每个具有子节点的节点上设置; 否则,代理将在展开这些节点时动态尝试加载子节点。我们的 JSON 数据将包含整个树,因此我们为每个父节点将expanded属性设置为true

在 CompanyHandler 类中生成 JSON 树

现在是时候增强CompanyHandler类以生成所需的 JSON 来加载树存储并显示公司树了。我们将创建两个新方法来实现这个功能。

CompanyHandler.getTreeNodeId()方法

CompanyHandler.getTreeNodeId()辅助方法基于EntityItem类的 ID 生成唯一 ID。它将用于为每个节点生成特定类型的 ID。

private String getTreeNodeId(EntityItem obj){
  String id = null;

  if(obj instanceof Company){
    id = "C_" + obj.getId();
  } else if(obj instanceof Project){
    id = "P_" + obj.getId();
  } else if(obj instanceof Task){
    id = "T_" + obj.getId();
  }
  return id;
}

CompanyHandler.getCompanyTreeJson()方法

CompanyHandler getCompanyTreeJson()方法映射到company/tree.json URL,并具有以下定义:

@RequestMapping(value="/tree", method=RequestMethod.GET, produces={"application/json"})
@ResponseBody
public String getCompanyTreeJson(HttpServletRequest request) {

  User sessionUser = getSessionUser(request);

  Result<List<Company>> ar = companyService.findAll(sessionUser.getUsername());
  if (ar.isSuccess()) {

    JsonObjectBuilder builder = Json.createObjectBuilder();
    builder.add("success", true);
    JsonArrayBuilder companyChildrenArrayBuilder =
      Json.createArrayBuilder();

    for(Company company : ar.getData()){

      List<Project> projects = company.getProjects();

      JsonArrayBuilder projectChildrenArrayBuilder = Json.createArrayBuilder();

      for(Project project : projects){

        List<Task> tasks = project.getTasks();

        JsonArrayBuilder taskChildrenArrayBuilder = Json.createArrayBuilder();

        for(Task task : tasks){

          taskChildrenArrayBuilder.add(
            Json.createObjectBuilder()
            .add("id", getTreeNodeId(task))
            .add("text", task.getTaskName())
            .add("leaf", true)
          );                        
        }

        projectChildrenArrayBuilder.add(
          Json.createObjectBuilder()
            .add("id", getTreeNodeId(project))
            .add("text", project.getProjectName())
            .add("leaf", tasks.isEmpty())
            .add("expanded", tasks.size() > 0)
            .add("children", taskChildrenArrayBuilder)
        );                    

      }

      companyChildrenArrayBuilder.add(
        Json.createObjectBuilder()
          .add("id", getTreeNodeId(company))
          .add("text", company.getCompanyName())
          .add("leaf", projects.isEmpty())
          .add("expanded", projects.size() > 0)
          .add("children", projectChildrenArrayBuilder)
      );
    }

    builder.add("children", companyChildrenArrayBuilder);

    return toJsonString(builder.build());

  } else {

    return getJsonErrorMsg(ar.getMsg());

  }
}

这个方法执行以下任务:

  • 它创建一个名为companyChildrenArrayBuilderJsonArrayBuilder对象,用于保存在主for循环中通过公司列表进行迭代时将创建的公司JsonObjectBuilder实例集。

  • 它循环遍历分配给每个公司的每个项目,将每个项目的JsonObjectBuilder树节点表示添加到projectChildrenArrayBuilder JsonArrayBuilder实例中。然后将projectChildrenArrayBuilder实例作为拥有公司JsonObjectBuilder实例的children属性添加。

  • 它循环遍历分配给每个项目的每个任务,将每个任务的JsonObjectBuilder树节点表示添加到taskChildrenArrayBuilder JsonArrayBuilder实例中。然后将taskChildrenArrayBuilder实例作为拥有项目的JsonObjectBuilder实例的children属性添加。

  • 它将companyChildrenArrayBuilder作为将用于从具有success属性true的方法构建和返回 JSON 的builder实例的children属性添加。

getCompanyTreeJson方法返回一个分层的 JSON 结构,封装了公司、项目和任务之间的关系,以一种可以被CompanyTree存储消费的格式。

控制 3T 管理

TTT.controller.AdminController将视图联系在一起,并实现此用户界面中可能的许多操作。您必须下载源代码才能看到此控制器的完整定义,因为它在以下文本中没有完全重现。

AdminController引用了处理操作所需的四个存储。在updatedelete操作后重新加载每个存储,以确保存储与数据库同步。对于多用户应用程序,这是一个重要的考虑点;在会话的生命周期内,视图数据是否可以被不同用户更改?与任务日志界面不同,其中数据属于会话中的用户,3T 管理模块可能会同时被不同用户积极使用。

注意

本书的范围不包括讨论多用户环境中数据完整性的策略。这通常是通过使用每个记录的时间戳来实现的,该时间戳指示最后更新时间。服务层中的适当逻辑将测试提交的记录时间戳与数据库中的时间戳,然后相应地处理操作。

还有一个尚未完全定义的存储和模型;我们现在将这样做。

定义公司模型和存储

Company模型首先是在[第九章中使用 Sencha Cmd 定义的,但现在我们需要添加适当的代理和验证。完整的定义如下:

Ext.define('TTT.model.Company', {
    extend: 'Ext.data.Model',
    fields: [
        { name: 'idCompany', type: 'int', useNull:true },
        { name: 'companyName', type: 'string'}
    ],
    idProperty: 'idCompany',
    proxy: {
        type: 'ajax',
        idParam:'idCompany',
        api:{
            create:'company/store.json',
            read:'company/find.json',
            update:'company/store.json',
            destroy:'company/remove.json'
        },
        reader: {
            type: 'json',
            root: 'data'
        },
        writer: {
            type: 'json',
            allowSingle:true,
            encode:true,
            root:'data',
            writeAllFields: true
        }
    },
    validations: [
        {type: 'presence',  field: 'companyName'},
        {type: 'length', field: 'companyName', min: 2}
    ]
});

Company存储将通过company/findAll.json URL 加载所有公司记录,如下所示:

Ext.define('TTT.store.Company', {
    extend: 'Ext.data.Store',
    requires: [
        'TTT.model.Company'
    ],
    model: 'TTT.model.Company',
    proxy: {
        type: 'ajax',
        url: 'company/findAll.json',
        reader: {
            type: 'json',
            root: 'data'
        }
    }    
});

Company模型和存储是迄今为止我们最简单的定义。现在我们将检查AdminController中的核心操作。

doAfterActivate 函数

当激活ManageTasks面板时,将加载 3T 管理所需的三个存储。这将确保在树中选择项目时,每个存储中都有有效的记录。doAfterActivate函数可用于初始化属于AdminController的任何组件的状态。在本章末尾配置拖放操作时,这将特别有用。

请注意,我们正在向树存储视图添加append监听器,并分配doSetTreeIcon函数。在init函数控制配置中无法在此时进行此操作,因为视图在此时尚未配置和准备就绪。在激活后将doSetTreeIcon函数分配给监听器可以确保组件完全配置。doSetTreeIcon函数根据节点类型动态分配iconCls类。

doAfterActivate函数的最后一步是加载树存储以显示树中的数据。

doSelectTreeItem 函数

当用户在树中选择项目时,将调用doSelectTreeItem函数。检索节点 ID 并拆分以允许我们确定节点类型:

var recIdSplit = record.getId().split('_');

对于每个节点,将确定主键值并用于从适当的存储中检索记录。然后将记录加载到表单中,并将其设置为管理员卡片布局中的活动项目。

doSave 函数

每个保存函数都会从表单中检索记录,并使用表单数值更新记录。如果验证成功,则保存记录,并更新表单以反映按钮状态的变化。然后重新加载拥有记录的存储以与数据库同步。

doDelete 函数

每个删除函数在调用模型的destroy方法之前都会确认用户操作。如果成功,管理员卡片布局中的活动项目将设置为显示默认消息:请从树中选择一个项目。如果删除不成功,将显示适当的消息通知用户。

doAdd 函数

添加按钮位于作为Add操作父级的表单上。您只能将项目添加到公司或将任务添加到项目。每个doAdd函数都会检索父级并创建子级的实例,然后加载适当的表单。根据需要禁用子表单上的按钮。

测试 3T 管理界面

现在我们需要将新的组件添加到我们的Application.js文件中:

models:[
  'Company',
  'Project',
  'Task',
  'User',
  'TaskLog'
],    
controllers: [
  'MainController',
  'UserController',
  'AdminController',
  'TaskLogController'
],    
stores: [
  'Company',
  'CompanyTree',
  'Project',
  'Task',
  'User',
  'TaskLog'
]

我们还需要将ManageTasks视图添加到我们的MainCards中:

Ext.define('TTT.view.MainCards', {
    extend: 'Ext.container.Container',
    xtype: 'maincards',
    requires: ['Ext.layout.container.Card', 'TTT.view.Welcome', 'TTT.view.user.ManageUsers', 'TTT.view.tasklog.ManageTaskLogs', 'TTT.view.admin.ManageTasks'],
    layout: 'card',
    initComponent: function() {
        var me = this;
        Ext.applyIf(me, {
            items: [{
                xtype: 'welcome',
                itemId: 'welcomCard'
            }, {
                xtype: 'manageusers',
                itemId: 'manageUsersCard'
            }, {
                xtype: 'managetasklogs',
                itemId: 'taskLogCard'
            }, {
 xtype: 'managetasks',
 itemId: 'manageTasksCard'
 }]
        });
        me.callParent(arguments);
    }
});

您现在可以在 GlassFish 服务器上运行应用程序,并通过以bjones用户(或任何其他具有管理员权限的用户)登录来测试 3T 管理界面。

动态加载树节点

企业应用程序通常具有数据集,禁止通过单个 JSON 请求加载完整的树。可以通过按需展开级别来配置大树以按节点加载子级。对我们的代码进行一些小的更改就可以实现这种动态加载节点子级。

当节点展开时,树存储代理会提交一个包含正在展开的节点的node参数的请求。提交的 URL 是在代理中配置的。我们将按以下方式更改我们的树存储代理:

proxy: {
  type: 'ajax',
  url: 'company/treenode.json'
}

请注意,代理的 URL 已更改为treenode。当在CompanyHandler中实现此映射时,将一次加载一级。代理提交给加载树顶级的第一个请求将具有以下格式:

company/treenode.json?node=root

这将返回根节点的公司列表:

{
    success: true,
    "children": [{
        "id": "C_2",
        "text": "Gieman It Solutions",
        "leaf": false
    }, {
        "id": "C_1",
        "text": "PACKT Publishing",
        "leaf": false
    }]
}

请注意,每个公司都没有定义children数组,并且leaf属性设置为false。如果没有定义子节点并且节点不是叶子节点,Ext JS 树将在节点旁显示一个展开图标。点击展开图标将提交一个请求,该请求的node参数设置为正在展开的节点的id值。因此,展开"PACKT Publishing"节点将提交一个请求通过company/treenode.json?node=C_1来加载子节点。

JSON 响应将包含一个children数组,该数组将作为PACKT Publishing节点的子节点附加到树上。在我们的示例中,响应将包括分配给公司的项目:

{
    success: true,
    "children": [{
        "id": "P_3",
        "text": "Advanced Sencha ExtJS4 ",
        "leaf": false
    }, {
        "id": "P_1",
        "text": "EAD with Spring and ExtJS",
        "leaf": false
    }, {
        "id": "P_2",
        "text": "The Spring Framework for Beginners",
        "leaf": false
    }]
}

再次,每个项目都不会定义一个children数组,即使有任务分配。每个项目都将被定义为"leaf":false,以渲染一个展开图标,如果有任务分配的话。展开P_1节点将导致代理提交一个请求来加载下一级:company/treenode.json?node=P_1

这将导致返回以下 JSON:

{
    success: true,
    "children": [{
        "id": "T_1",
        "text": "Chapter 1",
        "leaf": true
    }, {
        "id": "T_2",
        "text": "Chapter 2",
        "leaf": true
    }, {
        "id": "T_3",
        "text": "Chapter 3",
        "leaf": true
    }]
}

这次我们将这些节点定义为"leaf":true,以确保不显示展开图标,并且用户无法尝试加载树的第四级。

现在可以定义负责此逻辑的CompanyHandler方法,并将其映射到company/treenode.json URL:

@RequestMapping(value = "/treenode", method = RequestMethod.GET, produces = {"application/json"})
@ResponseBody
public String getCompanyTreeNode(
    @RequestParam(value = "node", required = true) String node,
    HttpServletRequest request) {

  User sessionUser = getSessionUser(request);

  logger.info(node);

  JsonObjectBuilder builder = Json.createObjectBuilder();
  builder.add("success", true);
  JsonArrayBuilder childrenArrayBuilder =Json.createArrayBuilder();

  if(node.equals("root")){

    Result<List<Company>> ar =companyService.findAll(sessionUser.getUsername());
    if (ar.isSuccess()) {                                

      for(Company company : ar.getData()){                   
        childrenArrayBuilder.add(
          Json.createObjectBuilder()
            .add("id", getTreeNodeId(company))
            .add("text", company.getCompanyName())
            .add("leaf", company.getProjects().isEmpty())
        );
      }
    } else {

      return getJsonErrorMsg(ar.getMsg());
    }
  } else if (node.startsWith("C")){

    String[] idSplit = node.split("_");
    int idCompany = Integer.parseInt(idSplit[1]);
    Result<Company> ar = companyService.find(idCompany,sessionUser.getUsername());

    for(Project project : ar.getData().getProjects()){

      childrenArrayBuilder.add(
        Json.createObjectBuilder()
          .add("id", getTreeNodeId(project))
          .add("text", project.getProjectName())
          .add("leaf", project.getTasks().isEmpty())
      );
    }

  } else if (node.startsWith("P")){

    String[] idSplit = node.split("_");
    int idProject = Integer.parseInt(idSplit[1]);
    Result<Project> ar = projectService.find(idProject,sessionUser.getUsername());
    for(Task task : ar.getData().getTasks()){

      childrenArrayBuilder.add(
        Json.createObjectBuilder()
          .add("id", getTreeNodeId(task))
          .add("text", task.getTaskName())
          .add("leaf", true)
      );
    }
  }

  builder.add("children", childrenArrayBuilder);

  return toJsonString(builder.build());
}

getCompanyTreeNode方法确定正在展开的节点类型,并从服务层加载适当的记录。然后存储返回的 JSON 并在树中显示。

现在我们可以在 GlassFish 中运行项目并显示3T Admin界面。树的第一级如预期加载:

动态加载树节点

当点击展开图标时,树的下一级将被动态加载:

动态加载树节点

然后可以展开第三级来显示任务:

动态加载树节点

我们将让您增强AdminController以用于动态树。在每次成功保存或删除后重新加载树将不太用户友好;更改逻辑以仅重新加载父节点将是一个更好的解决方案。

显示多列树

Ext JS 4 树可以配置为显示多列以可视化高级数据结构。我们将进行一些小的更改以显示树中每个节点的 ID。只需向树定义中添加一个新列即可实现此目的:

Ext.define('TTT.view.admin.CompanyTree', {
    extend: 'Ext.tree.Panel',
    xtype: 'companytree',
    title: 'Company -> Projects -> Tasks',
    requires: ['TTT.store.CompanyTree'],
    store: 'CompanyTree',
    lines: true,
    rootVisible: false,
    hideHeaders: false,
    viewConfig: {
        preserveScrollOnRefresh: true
    },
    initComponent: function() {
        var me = this;
        Ext.applyIf(me, {
            tools: [{
                type: 'expand',
                qtip: 'Expand All'
            }, {
                type: 'collapse',
                qtip: 'Collapse All'
            }, {
                type: 'refresh',
                qtip: 'Refresh Tree'
            }],
            columns: [{
                xtype: 'treecolumn',
                text:'Node',
                dataIndex: 'text',
                flex: 1
            },
 {
 dataIndex: 'id',
 text : 'ID',
 width:60
 }]
        });
        me.callParent(arguments);
    }
});

我们还向每列添加了text属性,该属性显示在标题行中,并启用了hideHeaders:false的标题。这些小的更改将导致完全展开时显示以下树:

显示多列树

轻松实现拖放

在 Ext JS 4 中,树内拖放节点非常容易。要允许树内的拖放动作,我们需要添加TreeViewDragDrop插件如下:

Ext.define('TTT.view.admin.CompanyTree', {
    extend: 'Ext.tree.Panel',
    xtype: 'companytree',
    title: 'Company -> Projects -> Tasks',
    requires: ['TTT.store.CompanyTree','Ext.tree.plugin.TreeViewDragDrop'],
    store: 'CompanyTree',
    lines: true,
    rootVisible: false,
    hideHeaders: true,
    viewConfig: {
        preserveScrollOnRefresh: true,
        plugins: {
 ptype: 'treeviewdragdrop'
 }
    }, etc

这个简单的包含将使您的树支持拖放。现在您可以拖放任何节点到一个新的父节点。不幸的是,这并不是我们需要的。任务节点只应允许放置在项目节点上,而项目节点只应允许放置在公司节点上。我们如何限制拖放动作遵循这些规则?

有两个事件可用于配置此功能。这些事件是从TreeViewDragDrop插件触发的,并且可以在AdminControllerdoAfterActivate函数中以以下方式配置:

doAfterActivate:function(){
  var me = this;
  me.getCompanyStore().load();
  me.getProjectStore().load();
  me.getTaskStore().load();
  me.getCompanyTreeStore().on('append' , me.doSetTreeIcon, me);
  me.getCompanyTree().getView().on('beforedrop', me.isDropAllowed,me);
 me.getCompanyTree().getView().on('drop', me.doChangeParent, me);
  me.getCompanyTreeStore().load();
}

beforedrop事件可用于测试拖动放置动作是否有效。返回false将阻止放置动作发生,并将节点动画回到动作的原点。drop事件可用于处理放置动作,很可能是将更改持久化到底层存储。

isDropAllowed函数根据放置目标是否对节点有效返回truefalse

isDropAllowed: function(node, data, overModel, dropPosition) {
    var dragNode = data.records[0];
    if (!Ext.isEmpty(dragNode) && !Ext.isEmpty(overModel)) {
        var dragIdSplit = dragNode.getId().split('_');
        var dropIdSplit = overModel.getId().split('_');
        if (dragIdSplit[0] === 'T' && dropIdSplit[0] === 'P') {
            return true;
        } else if (dragIdSplit[0] === 'P' 
                     && dropIdSplit[0] === 'C') {
            return true;
        }
    }
    return false;
}

此功能将限制拖动放置操作到两种有效的情况:将项目拖到新公司和将任务拖到新项目。不允许所有其他拖动放置操作。

仅仅拖放是不够的;我们现在需要在成功放置后保存新的父节点。这个操作在doChangeParent函数中处理。

doChangeParent: function(node, data, overModel, dropPosition, eOpts) {
    var me = this;
    var dragNode = data.records[0];
    if (!Ext.isEmpty(dragNode) && !Ext.isEmpty(overModel)) {
        var dragIdSplit = dragNode.getId().split('_');
        var dropIdSplit = overModel.getId().split('_');
        if (dragIdSplit[0] === 'T' && dropIdSplit[0] === 'P') {
            var idTask = Ext.Number.from(dragIdSplit[1]);
            var idProject = Ext.Number.from(dropIdSplit[1]);
            var rec = me.getTaskStore().getById(idTask);
            if (!Ext.isEmpty(rec)) {
                rec.set('idProject', idProject);
                rec.save();
            }
        } else if (dragIdSplit[0] === 'P' 
                    && dropIdSplit[0] === 'C') {
            var idProject = Ext.Number.from(dragIdSplit[1]);
            var idCompany = Ext.Number.from(dropIdSplit[1]);
            var rec = me.getProjectStore().getById(idProject);
            if (!Ext.isEmpty(rec)) {
                rec.set('idCompany', idCompany);
                rec.save();
            }
        }
    }
}

将有效节点拖动到新父节点现在在记录保存时是持久的。您现在可以在有效树节点之间进行拖放,并自动保存更改。

Ext JS 4 树提供的动画将指导您的拖动放置操作。拖动数据库开发节点将如下截图所示执行动画操作:

轻松实现拖放

如果不允许放置操作,节点将动画返回到原始位置,为用户提供即时的视觉反馈。

Ext JS 4 树是非常灵活的组件,如果您想充分利用应用程序中的树,还有很多东西需要学习。我们建议您在Sencha Docs网站上探索许多树示例,包括树之间的拖动放置操作以及持久化基于模型的数据节点的更复杂的示例。

总结

3T Admin界面引入了树组件来显示分层数据。公司、项目和任务关系通过单个 JSON 请求加载到树中,并允许用户维护和添加新实体。

然后解释和实现了树节点的动态加载。这种策略最适合具有潜在复杂数据结构的非常大的树。逐个节点的动态加载在 Ext JS 4 客户端和 Java 后端中需要最少的更改即可轻松实现。

还探讨并实现了显示多个树列和基本的拖放功能,以展示 Ext JS 4 树的灵活性。

我们在使用 Ext JS 和 Spring 进行企业应用程序开发的最后一步是为生产部署构建我们的 3T 项目。幸运的是,Maven 和 Sencha Cmd 可以帮助您轻松完成这项任务,您将在我们的最后一章中了解到,第十三章, 将您的应用程序移至生产环境

第十三章:将您的应用程序移至生产环境

开发工作已经结束,现在是将应用程序部署到生产服务器的时候了。如果只是这么简单!企业应用程序需要遵循正式流程,需要客户或业务所有者的签署,内部测试,用户验收测试(UAT)等许多障碍,才能准备好进行生产部署。本章将探讨以下两个关键领域:

  • 使用 Maven 构建和编译 Ext JS 4 应用程序以供生产使用

  • GlassFish 4 部署和配置概念

我们将首先检查 Sencha Cmd 编译器。

使用 Sencha Cmd 进行编译

在第九章中,开始使用 Ext JS 4,我们通过使用 Sencha Cmd 生成 Ext JS 4 应用程序骨架并创建基本组件的过程。本节将重点介绍使用 Sencha Cmd 编译我们的 Ext JS 4 应用程序,以便部署到 Web Archive(WAR)文件中。编译过程的目标是创建一个包含应用程序所需的所有代码的单个 JavaScript 文件,包括所有 Ext JS 4 依赖项。

应用程序骨架生成期间创建的index.html文件结构如下:

<!DOCTYPE HTML>
<html>
  <head>
    <meta charset="UTF-8">
    <title>TTT</title>
    <!-- <x-compile> -->
        <!-- <x-bootstrap> -->
            <link rel="stylesheet" href="bootstrap.css">
            <script src="img/ext-dev.js"></script>
            <script src="img/bootstrap.js"></script>
        <!-- </x-bootstrap> -->
        <script src="img/app.js"></script>
    <!-- </x-compile> -->
  </head>
<body></body>
</html>

x-compile指令的开放和关闭标签将包围index.html文件中 Sencha Cmd 编译器将操作的部分。此块中应包含的唯一声明是脚本标签。编译器将处理x-compile指令中的所有脚本,根据Ext.definerequiresuses指令搜索依赖项。

ext-dev.js文件是一个例外。该文件被视为框架的“引导”文件,并且不会以相同的方式进行处理。编译器会忽略x-bootstrap块中的文件,并且声明将从最终由编译器生成的页面中删除。

编译过程的第一步是检查和解析所有 JavaScript 源代码并分析任何依赖关系。为此,编译器需要识别应用程序中的所有源文件夹。我们的应用程序有两个源文件夹:webapp/ext/src中的 Ext JS 4 源文件和webapp/app中的 3T 应用程序源文件。这些文件夹位置在compile命令中使用-sdk-classpath参数指定:

sencha –sdk {path-to-sdk} compile -classpath={app-sources-folder} page -yui -in {index-page-to-compile}-out {output-file-location}

对于我们的 3T 应用程序,compile命令如下:

sencha –sdk ext compile -classpath=app page -yui -in index.html -out build/index.html

此命令执行以下操作:

  • Sencha Cmd 编译器检查由-classpath参数指定的所有文件夹。-sdk目录会自动包含在扫描中。

  • page命令然后包括index.html中包含在x-compile块中的所有脚本标签。

  • 在识别app目录和index.html页面的内容后,编译器会分析 JavaScript 代码,并确定最终需要包含在表示应用程序的单个 JavaScript 文件中的内容。

  • 修改后的原始index.html文件被写入build/index.html

  • 新的index.html文件所需的所有 JavaScript 文件都将被连接并使用 YUI Compressor 进行压缩,并写入build/all-classes.js文件。

sencha compile命令必须从webapp目录内执行,该目录是应用程序的根目录,也是包含index.html文件的目录。然后,提供给sencha compile命令的所有参数都可以相对于webapp目录。

打开命令提示符(或 Mac 中的终端窗口)并导航到 3T 项目的webapp目录。执行本节中早期显示的sencha compile命令将导致以下输出:

使用 Sencha Cmd 进行编译

在 NetBeans 中打开webapp/build文件夹现在应该显示两个新生成的文件:index.htmlall-classes.jsall-classes.js文件将包含所有必需的 Ext JS 4 类,以及所有 3T 应用程序类。尝试在 NetBeans 中打开此文件将会出现以下警告:“文件似乎太大而无法安全打开...”,但您可以在文本编辑器中打开文件以查看以下连接和压缩的内容:

使用 Sencha Cmd 编译

在 NetBeans 中打开build/index.html页面将显示以下屏幕截图:

使用 Sencha Cmd 编译

在运行应用程序后,您现在可以在浏览器中打开build/index.html文件,但结果可能会让您感到惊讶:

使用 Sencha Cmd 编译

呈现的布局将取决于浏览器,但无论如何,您会发现 CSS 样式丢失了。我们应用程序需要的 CSS 文件需要移出<!-- <x-compile> -->指令。但样式是从哪里来的?现在是时候简要地深入了解 Ext JS 4 主题和bootstrap.css文件了。

Ext JS 4 主题

Ext JS 4 主题利用Syntactically Awesome StyleSheetsSASS)和 Compass(compass-style.org/)来使用变量和混合样式表。几乎所有 Ext JS 4 组件的样式都可以定制,包括颜色、字体、边框和背景,只需简单地更改 SASS 变量即可。SASS 是 CSS 的扩展,允许您保持大型样式表的良好组织;您可以在sass-lang.com/documentation/file.SASS_REFERENCE.html找到非常好的概述和参考。

使用 Compass 和 SASS 对 Ext JS 4 应用程序进行主题设置超出了本书的范围。Sencha Cmd 允许轻松集成这些技术来构建 SASS 项目;然而,SASS 语言和语法本身就是一个陡峭的学习曲线。Ext JS 4 主题非常强大,对现有主题进行微小更改可以快速改变应用程序的外观。您可以在docs.sencha.com/extjs/4.2.2/#!/guide/theming找到更多关于 Ext JS 4 主题的信息。

在生成应用程序骨架时,bootstrap.css文件是使用默认主题定义的。bootstrap.css文件的内容如下:

@import 'ext/packages/ext-theme-classic/build/resources/ext-theme-classic-all.css';

此文件导入了ext-theme-classic-all.css样式表,这是默认的“classic”Ext JS 主题。所有可用的主题都可以在 Ext JS 4 SDK 的ext/packages目录中找到:

Ext JS 4 主题

切换到不同的主题就像改变bootstrap.css导入一样简单。切换到neptune主题需要以下bootstrap.css定义:

@import 'ext/packages/ext-theme-neptune/build/resources/ext-theme-neptune-all.css';

这个修改将改变应用程序的外观为 Ext JS 的“neptune”主题,如下面的屏幕截图所示:

Ext JS 4 主题

我们将更改bootstrap.css文件的定义以使用gray主题:

@import 'ext
/packages/ext-theme-gray/build/resources/ext-theme-gray-all.css';

这将导致以下外观:

Ext JS 4 主题

您可以尝试不同的主题,但应注意并非所有主题都像classic主题那样完整;一些组件可能需要进行微小的更改才能充分利用样式。

我们将保留gray主题用于我们的index.html页面。这将使我们能够区分(原始的)index.html页面和接下来将使用classic主题创建的新页面。

用于生产的编译

到目前为止,我们只使用了 Sencha Cmd 生成的index.html文件。现在我们将为开发环境创建一个新的index-dev.html文件。开发文件将是index.html文件的副本,不包含bootstrap.css文件。我们将在index-dev.html文件中引用默认的classic主题,如下所示:

<!DOCTYPE HTML>
<html>
  <head>
    <meta charset="UTF-8">
    <title>TTT</title>
 <link rel="stylesheet" href="ext/packages/ext-theme-classic/build/resources/ext-theme-classic-all.css">
 <link rel="stylesheet" href="resources/styles.css"> 
    <!-- <x-compile> -->
        <!-- <x-bootstrap> -->
            <script src="img/ext-dev.js"></script>
            <script src="img/bootstrap.js"></script>
        <!-- </x-bootstrap> -->
        <script src="img/app.js"></script>
    <!-- </x-compile> -->
  </head>
<body></body>
</html>

请注意,我们已将stylesheet定义移出了<!-- <x-compile> -->指令。

注意

如果您使用的是本书的下载源代码,您将拥有resources/styles.css文件和resources目录结构。resources目录中的样式表和相关图像包含了 3T 的标志和图标。我们建议您现在下载完整的源代码以便完整性。

现在我们可以修改 Sencha Cmd 的compile命令,使用index-dev.html文件,并将生成的编译文件输出到webapp目录中的index-prod.html

sencha –sdk ext compile -classpath=app page -yui -in index-dev.html -out index-prod.html

该命令将在webapp目录中生成index-prod.html文件和all-classes.js文件,如下面的屏幕截图所示:

用于生产环境的编译

index-prod.html文件直接引用样式表,并使用单个编译和压缩的all-classes.js文件。您现在可以运行应用程序,并浏览index-prod.html文件,如下面的屏幕截图所示:

用于生产环境的编译

您应该注意到登录窗口显示的速度显著增加,因为所有 JavaScript 类都是从单个all-classes.js文件加载的。

index-prod.html文件将被开发人员用于测试编译的all-classes.js文件。

现在访问各个页面将允许我们区分环境:

在浏览器中显示的登录窗口 页面描述
用于生产环境的编译 index.html页面是由 Sencha Cmd 生成的,并已配置为使用bootstrap.css中的gray主题。此页面对于开发不再需要;请改用index-dev.html。您可以在http://localhost:8080/index.html访问此页面
用于生产环境的编译 index-dev.html页面使用了在<!-- <x-compile> -->指令之外包含的classic主题样式表。用于应用程序开发的文件。Ext JS 4 将根据需要动态加载源文件。您可以在http://localhost:8080/index-dev.html访问此页面
用于生产环境的编译 index-prod.html文件是由 Sencha Cmd 的compile命令动态生成的。此页面使用了classic主题样式表的all-classes.js全合一编译 JavaScript 文件。您可以在http://localhost:8080/index-prod.html访问此页面

将 Sencha Cmd 编译集成到 Maven 中

到目前为止,我们一直是从终端执行 Sencha Cmd 的compile命令。在 Maven 构建过程中执行该命令会更好。index-prod.html和编译的all-classes.js文件可以在每次构建时自动生成。将以下plugin添加到 Maven 的pom.xml文件中将执行以下操作:

<plugin>
  <groupId>org.codehaus.mojo</groupId>
  <artifactId>exec-maven-plugin</artifactId>
  <version>1.2.1</version>                    
  <executions>
    <execution>
      <id>sencha-compile</id>
      <phase>compile</phase>
      <goals>
        <goal>exec</goal>
      </goals>
      <configuration>
        <executable>C:\Sencha\Cmd\4.0.0.203\sencha.exe</executable>
        <arguments>
          <argument>-sdk</argument>
          <argument>${basedir}/src/main/webapp/ext</argument>                                
          <argument>compile</argument>
          <argument>-classpath</argument>
          <argument>${basedir}/src/main/webapp/app</argument>
          <argument>page</argument>
          <argument>-yui</argument>
          <argument>-in</argument>
          <argument>${basedir}/src/main/webapp/index-dev.html</argument>
          <argument>-out</argument>
          <argument>${basedir}/src/main/webapp/index-prod.html</argument>
          </arguments>
      </configuration>
    </execution>
  </executions>
</plugin>

以下是一些需要注意的要点:

  • 该插件在 Maven 构建过程的compile阶段执行。

  • Sencha Cmd 可执行文件是使用完整的文件系统路径定义的。只有这样,才能在需要时使用不同版本的 Sencha 构建不同的项目。

  • ${basedir}属性表示 Maven 项目根目录的完整路径。由于我们不是在webapp目录中执行 Sencha Cmd 的compile命令,因此每个参数都需要完整路径。

index-prod.htmlall-classes.js文件现在将在每次构建时更新。此插件的输出可以在以下 Maven 构建日志中看到:

将 Sencha Cmd 编译与 Maven 集成

添加构建版本和时间戳

能够识别不同的构建是非常重要的,不仅仅是构建版本,还有构建编译的时间。项目版本是在pom.xml文件中使用version属性定义的:

<groupId>com.gieman</groupId>
<artifactId>task-time-tracker</artifactId>
<version>1.0</version>
<packaging>war</packaging>

执行 Maven 构建将生成一个名为task-time-tracker-1.0.war的 WAR 文件;它是artifactIdversion字段与.war扩展名的组合。

在企业环境中,新版本可以是从次要更改(例如,版本 1.3.2)到主要版本(例如,版本 4.0)的任何内容。version值的确切命名约定将取决于企业组织。无论命名约定如何,重要的是要确定构建是何时进行的。检查 WAR 文件的时间戳时很明显,但对于只能访问前端的应用程序测试人员来说,这并不那么明显。我们建议在 Ext JS 应用程序中添加发布版本和构建时间戳,以便用户可以确定他们正在使用的版本。登录窗口是显示此信息的明显位置,我们将添加构建版本和时间戳,如下面的屏幕截图所示:

添加构建版本和时间戳

我们将进行的第一个更改是在init函数中的Application.js文件中添加两个常量:

init : function(application){
  TTT.URL_PREFIX = 'ttt/';
  Ext.Ajax.on('beforerequest', function(conn, options, eOpts){
    options.url = TTT.URL_PREFIX + options.url;
  });
  TTT.BUILD_DATE = '$BUILD_DATE$';
  TTT.BUILD_VERSION = '$BUILD_VERSION$';
}

TTT.BUILD_DATETTT.BUILD_VERSION字段定义了在 Maven 构建期间将在all-classes.js文件中动态替换的标记(或占位符)。这些标记会填充到index-dev.html文件中,开发环境的登录窗口将如下屏幕截图所示:

添加构建版本和时间戳

正确的构建和时间戳的标记替换在pom.xml文件中定义,并需要进行一些添加,首先是maven.build.timestamp.format属性:

<properties>
  <endorsed.dir>${project.build.directory}/endorsed</endorsed.dir>
  <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  <maven.build.timestamp.format>dd-MMM-yyyy HH:mm</maven.build.timestamp.format>
  <spring.version>3.2.4.RELEASE</spring.version>
  <logback.version>1.0.13</logback.version>
</properties>

maven.build.timestamp.format属性定义了LogonWindow.js文件中时间戳的格式。第二个更改是添加maven-replacer-plugin

<plugin>
  <groupId>com.google.code.maven-replacer-plugin</groupId>
  <artifactId>maven-replacer-plugin</artifactId>
  <version>1.3</version>
  <executions>
    <execution>
      <phase>prepare-package</phase>
      <goals>
        <goal>replace</goal>
      </goals>
      <configuration>
        <ignoreMissingFile>false</ignoreMissingFile>
        <file>src/main/webapp/all-classes.js</file>
        <regex>false</regex>
           <replacements>
           <replacement>
             <token>$BUILD_DATE$</token>
             <value>${maven.build.timestamp}</value>
           </replacement>
           <replacement>
             <token>$BUILD_VERSION$</token>
             <value>${project.version}</value>
           </replacement>
         </replacements>
      </configuration>
    </execution>
  </executions>
</plugin>

该插件检查src/main/webapp/all-classes.js文件,并用 Maven 属性${maven.build.timestamp}定义的构建时间戳替换$BUILD_DATE$标记。$BUILD_VERSION$标记也将被 Maven 属性${project.version}定义的项目版本替换。

所需的最后一个更改是在登录窗口中显示这些属性。我们将在LogonWindow.js文件的item数组中的工具栏下方简单添加一个container

{
  xtype:'container',   
  style:{
    textAlign:'center'
  },
  html:' Version ' + TTT.BUILD_VERSION + ' built on ' + TTT.BUILD_DATE
}

现在运行项目将在index-prod.html页面的应用程序登录窗口中显示构建版本和时间戳:

添加构建版本和时间戳

构建更轻巧的 WAR 文件

生成的 WAR 文件task-time-tracker-1.0.war目前非常大;实际上,它大约为 32MB!maven-war-plugin的默认行为是将webapp文件夹中的所有目录添加到 WAR 文件中。对于生产部署,我们不需要大量这些文件,并且最佳做法是通过排除不需要的内容来精简 WAR 文件。我们将排除整个 Ext JS 4 SDK 以及webapp目录下由 Sencha Cmd 生成的所有文件夹。我们还将排除所有不适用于生产使用的资源,包括开发过程中使用的index*.html文件。GlassFish 提供的唯一文件将是尚未创建的index.jsp

<!DOCTYPE HTML>
<html>
  <head>
    <meta charset="UTF-8">
    <title>TTT</title>
    <link rel="stylesheet" href="resources/ext-theme-classic-all.css">
    <link rel="stylesheet" href="resources/styles.css">    
<script type="text/javascript" src="img/all-classes.js"></script>
  </head>
<body></body>
</html>

您会注意到ext-theme-classic-all.css文件的位置在resources目录中,而不是在 HTML 页面中使用的深层嵌套的ext/packages/ext-theme-classic/build/resources位置。WAR 文件生成过程将从 Ext JS 4 SDK 位置复制适当的内容到resources目录。这样就不需要在 WAR 文件中包含 SDK 目录结构。

index.jsp文件的生成现在将成为我们默认的welcome-file,我们将相应地调整WEB-INF/web.xml文件:

<welcome-file-list>
  <welcome-file>index.jsp</welcome-file>
</welcome-file-list>

web.xml文件中进行此更改后运行应用程序将确保在 URL 中指定资源时,index.jsp文件由 GlassFish 提供。

构建更轻量级的生产 WAR 文件所需的maven-war-plugin中的更改在以下代码片段中突出显示:

<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-war-plugin</artifactId>
  <version>2.3</version>
  <configuration>
    <warName>${project.build.finalName}</warName>
    <failOnMissingWebXml>false</failOnMissingWebXml>
 <webResources>
 <resource>
 <directory>src/main/webapp/ext/packages/ext-theme-classic/build/resources</directory>
 <targetPath>resources</targetPath>
 <excludes>
 <exclude>ext-theme-classic-all-*</exclude>
 </excludes> 
 </resource> 
 </webResources> 
 <packagingExcludes>.sencha/**,app/**,sass/**,overrides/**,build/**,ext/**,app.json,bootstrap.css,bootstrap.js,build.xml, index.html,index-dev.html,index-prod.html,app.js</packagingExcludes> 
  </configuration>
</plugin>

webResources定义将 Ext JS 4 classic CSS 主题的内容复制到resources目录。targetPath属性始终相对于webapp目录;因此,我们不需要resources目录的完整路径。directory属性始终相对于 Maven 项目的根目录;因此,它需要完整路径。

packagingExcludes属性列出了不应包含在 WAR 文件中的所有目录和文件。**符号表示应排除所有子目录。这将确保所有不需要的 Sencha Cmd 生成的文件夹都将被排除在我们的生产 WAR 文件之外。

执行 Maven 构建现在将生成一个大约 6.6 MB 的 WAR 文件,其中只包含生产应用程序所需的文件。

将 WAR 文件部署到 GlassFish

到目前为止,我们一直通过 NetBeans 使用Run Project命令将 3T 应用程序部署到 GlassFish。在生产环境中,我们通过 GlassFish 管理控制台或使用asadmin命令行部署应用程序。现在我们将学习如何使用管理控制台将task-time-tracker-1.0.war文件部署到 GlassFish。

打开 GlassFish 管理控制台

在 NetBeans 中或使用asadmin命令在控制台窗口中启动 GlassFish。我们建议使用asadmin,因为这通常是企业环境中管理 GlassFish 的方式。

打开 GlassFish 管理控制台

如前面的屏幕截图所示,默认的 GlassFishAdmin port值为4848,但如果配置了多个 GlassFish 域,它将不同。在浏览器中打开此位置以显示 GlassFish 管理控制台:

打开 GlassFish 管理控制台

GlassFish 安全基础

在使用 NetBeans 提供的默认 GlassFish 安装时,通常在localhost上工作时不会提示您输入密码。如果提示您,默认用户名是admin,密码为空。以前的 GlassFish 版本的默认密码是adminadmin;在撰写本文时,情况已经不再是这样。您应该意识到这可能会在将来再次更改。

在 GlassFish 运行在浏览器之外的远程主机上工作时,当您尝试访问管理控制台时,系统将始终提示您输入用户名和密码。这是企业环境中的情况,不同的服务器通常运行多个 GlassFish 实例。在这种环境中,默认情况下将禁用对管理控制台的远程访问,您只能从localhost访问管理控制台。可以通过在运行 GlassFish 服务器的主机上执行以下命令来允许从不同客户端进行远程访问:

asadmin --host localhost --port 4848 enable-secure-admin
asadmin restart-domain domain1

在启用安全管理时,您可能会收到一条消息,提示“您的管理员密码为空”(默认情况)。要解决此问题,您需要首先使用以下命令将管理员密码从默认(空)密码更改为其他密码:

asadmin --host localhost --port 4848 change-admin-password

然后将提示您输入新密码。然后将可以启用安全管理。

注意

深入研究 GlassFish 服务器管理的范围超出了本书的范围。我们建议您浏览glassfish.java.net/上的优秀文档和用户指南。

使用管理控制台部署 WAR 文件

通过 GlassFish 管理控制台部署 Web 应用程序是一个简单的过程。登录到 GlassFish 管理控制台后,单击并打开如下屏幕截图中显示的应用程序节点:

使用管理控制台部署 WAR 文件

可能已经部署了一个task-time-tracker应用程序,这是由于之前 NetBeans 部署的结果(如前面的屏幕截图所示)。如果是这种情况,请选择应用程序名称旁边的复选框,然后单击取消部署

单击部署...按钮,输入以下详细信息:

使用管理控制台部署 WAR 文件

可从 GlassFish 服务器访问的本地打包文件或目录字段将定义本地文件系统上task-time-tracker-1.0.war文件的位置。如果部署到远程服务器,您将需要使用要上传到服务器的包文件选项。

上下文根字段定义了部署应用程序的 URL 路径。我们将 3T 应用程序部署到上下文根。

应用程序名称字段定义了 GlassFish 服务器中应用程序的名称,并显示在应用程序列表中。

虚拟服务器下拉菜单定义了将用于托管应用程序的虚拟服务器。虚拟服务器,有时称为虚拟主机,是一个允许同一物理服务器托管部署到不同监听器的多个 Internet 域名的对象。可以从此列表中选择多个虚拟服务器(如果已配置)。

单击确定按钮部署task-time-tracker-1.0.war文件。此操作将返回到已部署应用程序列表:

使用管理控制台部署 WAR 文件

task-time-tracker-1.0应用程序部署到默认的虚拟服务器,名称为server,可通过以下两个监听器访问:

  • http://localhost:8080/

  • https://localhost:8181/

这是安装 GlassFish 后的默认虚拟服务器/HTTP 服务配置。请注意,在允许用户登录的生产企业环境中,只有 HTTPS 版本会被启用,以确保与服务器的加密 SSL 连接。现在可以访问这些 URL 来测试部署。打开https://localhost:8181/链接将会出现警告,因为证书无效,如下屏幕截图所示:

使用管理控制台部署 WAR 文件

可以忽略此项,然后可以通过单击我了解风险并确认异常(显示的确切消息将取决于浏览器)继续访问链接。右键单击登录页面,选择查看页面源代码将确认您正在使用生产 WAR 文件;如下屏幕截图所示:

使用管理控制台部署 WAR 文件

注意

再次配置 HTTP 监听器和虚拟服务器超出了本书的范围。我们建议您浏览glassfish.java.net/documentation.html上的适当文档。

使用 asadmin 部署 WAR 文件

也可以使用asadmin命令部署task-time-tracker-1.0.war文件。这在企业组织中是常见情况,因为出于安全原因,GlassFish 管理控制台未启用。asadmin deploy命令的语法是:

asadmin deploy --user $ADMINUSER --passwordfile $ADMINPWDFILE 
--host localhost --port $ADMINPORT --virtualservers $VIRTUAL_SERVER 
--contextroot --force --name $WEB_APPLICATION_NAME $ARCHIVE_FILE

这个命令必须在一行上执行,并且以$为前缀的每个大写变量名必须替换为正确的值。确切的语法和参数可能取决于环境,我们不会进一步讨论这个命令的结构。如果您有兴趣了解更多关于这个命令的信息,可以浏览docs.oracle.com/cd/E18930_01/html/821-2433/deploy-1.html上的详细文档;请注意,该文档是针对 GlassFish 3.1 参考手册的。

更多部署信息和阅读材料

glassfish.java.net/docs/4.0/application-deployment-guide.pdf中包含了将应用程序部署到 GlassFish 4 服务器的广泛和详细的解释。这份文档超过 200 页,应该在本章未涵盖的任何部署相关问题上进行咨询。

GlassFish 性能调优和优化

性能调优和 GlassFish 服务器优化的权威指南可以在这里找到

glassfish.java.net/docs/4.0/performance-tuning-guide.pdf

本指南包括调整应用程序以及调整 GlassFish 服务器本身的部分。涵盖了配置线程池、Web 容器设置、连接池、垃圾收集、服务器内存设置等方面。我们建议您查阅本文档,尽可能多地了解企业开发和部署的重要方面。

摘要

我们的最后一章涵盖了关键的生产企业部署概念。我们将我们的 Ext JS 4 应用程序编译成一个名为all-classes.js的文件以供生产使用,并将构建版本和时间戳添加到LogonWindow.js文件中。然后,我们通过删除所有不需要的资源,减小了由 Maven 生成的task-time-tracker.war文件的大小,以便用于生产部署。这个生产 WAR 文件只包含应用程序在运行时所需的资源,不包括所有不需要的 Ext JS 4 SDK 资源和目录。然后,我们检查了 GlassFish 的部署过程,并通过 GlassFish 管理控制台部署了task-time-tracker-1.0.war文件。关于 GlassFish 服务器,您还有很多东西可以学习,但主菜已上!

我们的 Ext JS 和 Spring 开发之旅现在结束了。本书涵盖了大量领域,并为使用这些关键技术进行企业应用程序开发提供了坚实的基础。我们真诚地希望通过阅读本书,您的开发之旅将更加轻松和有益。

附录 A.介绍 Spring Data JPA

Spring Data JPA 网站projects.spring.io/spring-data-jpa/有一个开头段落简洁地描述了实现基于 JPA 的 DAO 层的问题:

实现应用程序的数据访问层已经相当麻烦了。必须编写大量样板代码来执行简单的查询以及执行分页和审计。Spring Data JPA 旨在通过减少实际需要的工作量,显着改善数据访问层的实现。作为开发人员,您编写存储库接口,包括自定义查找方法,Spring 将自动提供实现。

在第四章中,数据访问变得容易,我们实现了 DAO 设计模式,将数据库持久性抽象为一个明确定义的层。我们故意决定在本章中介绍 Spring Data JPA,因为目标受众是可能没有使用 Java 持久性 API 经验的中级开发人员。介绍了 JPA 术语、概念和实际示例,以便让您了解 JPA 的工作原理。使用 Java 接口、Java 泛型和命名查询概念对于理解 Spring Data JPA 的优雅工作方式至关重要。

Spring Data JPA 不要求您编写存储库接口的实现。当您运行 Spring Data JPA 应用程序时,这些实现是“即时”创建的。开发人员所需做的就是编写扩展org.springframework.data.repository.CrudRepository并遵循 Spring Data JPA 命名约定的 DAO Java 接口。DAO 实现会在运行时为您创建。

Spring Data JPA 将在内部实现执行与第四章中实现的相同功能的代码,数据访问变得容易。使用 Spring Data,我们可以将CompanyDao接口重写为:

package com.gieman.tttracker.dao;

import com.gieman.tttracker.domain.Company;
import java.util.List;
import org.springframework.data.repository.CrudRepository;

public interface CompanyDao extends CrudRepository<Company, Integer>{

}

CompanyDao实现将包括findAll方法,因为它在CrudRepository接口中定义;我们不需要将其定义为单独的方法。

如果您熟悉 JPA 和第四章中涵盖的内容,数据访问变得容易,那么您应该探索 Spring Data JPA 框架。然后,实现基于 JPA 的存储库将变得更加容易!

posted @ 2024-05-24 10:54  绝不原创的飞龙  阅读(51)  评论(0)    收藏  举报