JavaEE7 数字 Web 应用开发(四)

基于J2EE的Web单点登录简易实现指南 Web单点登录(SSO)是一种用户只需进行一次身份认证,就可以访问多个资源服务器而无需重复登录的技术。SSO提供了一种便捷和安全的方式,让用户在多个系统间切换时无需重复输入认证信息,这大大提升了用户体验,同时减少了系统的管理复杂度。票据在Web单点登录(SSO)系统中起着至关重要的作用,它是用户身份认证的凭证。票据通常包含用户的身份信息、时间戳、有效期和签名等信息,用于在服务之间传递用户身份验证的状态。票据的特性需要保证其安全性、唯一性和不可伪造性。票据是短暂的,它通常有一个预设的有效期。 阅读详情

原文:zh.annas-archive.org/md5/93f0d8847a906bcc2ba377d2ffdb9fc2

译者:飞龙

协议:CC BY-NC-SA 4.0

第八章. AngularJS 和 Java RESTful 服务

“慢 - 任何超过 50 毫秒的东西对人类来说都是不可察觉的,因此可以被认为是‘瞬间’。”
–AngularJS 的共同创造者 Misko Hevery

在本章中,我们将走出 JSF 的舒适区,探索一种不同的 Web 应用程序模式。你们大多数人都会熟悉像 Google Mail、Facebook 和 Twitter 这样的流行社交媒体及其 Web 用户界面。这些 Web 应用程序具有特殊用户体验和信息架构,给人一种交互发生在单个网页上的错觉。然而,在幕后,这些应用程序依赖于标准技术:HTML5、CSS 和客户端 JavaScript。它们都使用 AJAX 调用通过 HTTP 与后端服务器通信。当服务器端应用程序向 Web 客户端发送数据时,只有页面的一部分被更新。在当代使用中,许多数字网站利用应用程序后端上的 RESTful 服务端点。一些复杂的企业应用程序可能使用服务器发送事件(SSE)向多个工作用户发送通知,而更前沿的一些则依赖于新近制定的 HTML5 WebSocket 规范,以在客户端和服务器之间提供全双工通信。顺便提一下,来自 Java Community Process 的完整 Java EE 7 规范支持 JAX-RS、SSE 和 WebSocket。

单页应用程序

在单页面上构建应用程序的设计理念,使其类似于桌面应用程序,与 JavaServer Faces 原始的页面间导航链接设计形成鲜明对比。JSF 1.0 是在 2000 年代初创建的,远在 2005 年重新发现XMLHttpRequest JavaScript 对象和 Google Maps 之前,所以这个历史注释不应令人惊讶(en.wikipedia.org/wiki/JavaServer_Faces)。完全有可能将 JSF 编写为单页应用程序,但我不会推荐将方钉强行塞入圆孔的努力!JSF 适合于本质和设计上极度状态化的应用程序,其中客户旅程基于页面间的导航。在前几章中,我们已经详细介绍了使用 JSF 的状态化 Web 应用程序、流作用域、会话和视图作用域的 bean。如果您对这些概念不熟悉,我强烈建议您再次复习这些材料。现在,我们将继续探讨另一种设计模式。

让我们列出单页应用程序的有益特性:

  • SPAs 通常具有适合单页的网站或 Web 应用程序。

  • 它们依赖于现代数字 JavaScript 技术,包括 AJAX、HTML5 和 CSS。

  • 在导航期间,此类应用程序不是加载整个页面,而是操作文档对象模型(DOM)以提供页面更新。

  • 这些应用程序通常使用 HTML 模板引擎在客户端本地渲染内容。客户端的表示逻辑和服务器端的业务逻辑之间存在关注点的分离。

  • 单页应用程序与网络服务器动态通信,通常使用 RESTful 服务,JSON 作为流行的有效载荷类型。

单页应用程序存在一些缺点,内容策略师、技术负责人开发人员和显然的股东商业人士都应该了解:

  • 将搜索引擎优化应用到单页应用程序中可能有些困难。

  • 使用浏览器中的后退按钮可能会导致数据条目丢失;单页应用程序与网页浏览器历史记录不兼容。

  • 单页应用程序需要更高程度的应用开发知识来处理响应式编程和概念。值得注意的是,工程师应该意识到关于权衡可扩展性、弹性、事件驱动处理和通知的因素,并保持响应。

最后,让我给你一些建议。行业中的数字界面开发人员拥有 JavaScript、HTML5 和 CSS 技能。在本章中,你将学会认识到 JavaScript 编程能力与 Java 服务器端需求同样重要。换句话说,使用 AngularJS 和类似的客户端框架往往是一种全栈参与。

案件工作者应用程序

对于本章,我们将探讨一种特定类型的单页应用程序,这种应用程序适用于国际政府,称为案件工作者系统。案件工作者的业务用户将坐在办公桌前,他们的大部分时间都在处理申请人的产品申请阶段。

以下是该应用程序的截图:

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/dg-jee7-webapp-dev/img/image00418.jpeg

案件工作者应用程序截图,xen national force

该应用程序名为 xen-national-force,它被设计用来通过微型工作流程处理护照。它远远不能满足真实商业应用的需求。例如,为了尽可能简化,没有实现用户输入安全。它仅适用于一个案件工作者,并且从用户体验的角度来看存在一个非常明显的设计缺陷。然而,xen-national-force 应用程序展示了如何使用 AngularJS 构建具有 CRUD 操作的主从记录系统,并且它具有基本的有限状态机实现。

我们现在将转向学习流行的 AngularJS 框架。

AngularJS

我们如何使用 jQuery 将文本输入与 div 元素中的消息区域连接起来?一个可行的方法是编写如下事件处理程序和回调函数的 JavaScript 模块片段:

$('#helloForm input.greeting-name').on('value', function() {
  $('#helloForm div.greeting-name').text('Hello ' + this.val() + '!');
});
input.greeting-name, then jQuery invokes the callback function, which updates the inner HTML in the div element layer, identified with the CSS class div.greeting.name. We could extend this code and write a generic solution with parameters, especially if we have more cases like this in our application, but sooner rather than later, programming at this low-level introduces complexities and bugs.

AngularJS 的设计者意识到有改进的机会。相同的示例可以使用 AngularJS 重新编写,如下所示:

<!DOCTYPE HTML>
<html>
<head>
  <script src="img/angular.js"></script>
</head>
<body ng-app ng-init="greeting-name = 'Mr. Anderson'">
  <form>
    <input ng-model="customer-name" type="text" />
    <div class="greeting-name">Hello {{customer-name}}!</div>
  </form>
</body>
</html>

前面的片段完全是 HTML。它从一个远程服务器包含了 AngularJS 框架,内容分发网络(CDN)。HTML 的主体元素被一个非标准属性 ng-app 标注,以声明这个 DOM 节点是整体模板的一部分。另一个属性 ng-init 在客户端渲染模板之前声明了一个数据模型。AngularJS 需要知道从哪里开始模板或动态修改 DOM;因此,每个页面都以 ng-app 属性开始。通常,ng-app 属性应用于 HTML 的 body 元素。没有访问数据模型,AngularJS 模板将毫无用处,这就是 ng-init 属性的目的。它设置了一个作用域变量 greeting-name 并将其赋值为字符串字面量 Mr. Anderson。

注意额外的属性类型 ng-model 和特殊的双大括号语法:{{customer-name}}。这个属性是 AngularJS 框架提供的一个特殊扩展,用于在行内标识数据模型,而大括号代表一种特殊的 HTML 模板语法,称为指令。在这里,我们将 ng-model 属性应用于输入字段元素。当页面加载时,输入文本字段显示文本 Mr Anderson。代码还允许用户在输入字段中输入文本,并同时更新消息区域。在这个简单的情况下,不需要编程;实际上,它是声明式的。那么,秘诀是什么呢?下面的代码展示了双向绑定的一种形式。让我们扩展它来演示完整的双向绑定:

<form>
  <input ng-model="customer-name" type="text" />
  <div class="greeting-name">Hello {{customer-name}}!</div>
  <p>
   <button class="btn-large" ng-click="user-model = 'Karen'">
   Karen </button> </p>
  <p>
   <button class="btn-large" ng-click="user-model = 'Albert'">
   Albert </button> </p>
</form>

我们引入了带有新属性 ng-click 的 HTML button 元素。属性的值是一个 AngularJS JavaScript 表达式。每个按钮都会用新的名字更新数据模型。实际上,它们重置了输入字段和消息区域中的名字。这有多酷?这里根本不需要 jQuery 编程。AngularJS 有许多特殊的自定义属性,如 ng-repeat、ng-switch 和 ng-option,我们将在本章后面遇到。

你可能想知道这些绑定和模板非常聪明;那么客户端是如何工作的呢?

AngularJS 是如何工作的?

AngularJS 作为 HTML 页面内容的一部分在网页浏览器中加载。框架最强大的部分是它鼓励关注点的分离。表示视图应该故意与业务逻辑和数据模型混合。这有几个原因。当 Angular JS 框架加载页面时,框架会在 DOM 中上下移动并寻找某些非标准属性,称为指令。它使用编译器解析和处理这个标记。实际上,AngularJS 将静态加载的 DOM 转换为渲染视图。框架将这些指令创建关联、绑定和额外的行为。

ng-app 属性与一个初始化应用的指令相关联。ng-init 与一个允许程序员设置数据模型的指令相关联。它可以用来给变量赋值。ng-model 与指令访问相关联或存储与 HTML 输入元素相关联的值。AngularJS 允许开发者编写自定义指令。你可能将来会想编写一个来获取对 DOM 的访问权限。

AngularJS 在模板视图中基于嵌套作用域工作。作用域是表达式的执行上下文。作用域可以以层次结构组织,以便它们模仿 DOM 模型。

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/dg-jee7-webapp-dev/img/image00419.jpeg

AngularJS 的基本工作原理

AngularJS 依赖于定义控制器和其他逻辑的 JavaScript 模块。模块可以依赖于其他模块;然而,与 RequireJS 不同,不属于不同 JavaScript 文件的模块不会自动加载到应用程序中。作用域是绑定表示和数据模型的粘合剂。作用域是 AngularJS 中定义观察者和监听器的地方。大多数时候,框架将自动处理表达式处理和数据绑定,并处理 JavaScript 模块和 DOM 元素组件之间的通知。在编译阶段之后,AngularJS 继续到链接阶段,并将表达式关联到模块控制器方法和其他资源。

让我们总结这些步骤:

  1. AngularJS 框架启动自身。特别是,它搜索 DOM 中带有 ng-app 属性的 HTML 元素。这是框架的触发点。

  2. 一旦找到 ng-app 元素,AngularJS 就会创建一个依赖注入器。

  3. 然后,它将静态 DOM 编译成渲染中间视图,在过程中收集指令。

  4. 然后,AngularJS 开始将指令与它们关联的作用域进行链接和组合。这是一个算法性和层次性的操作。在执行链接阶段之前,框架创建了一个初始作用域,称为根作用域。

  5. 最后,AngularJS 使用根作用域调用 apply 调用,在这个阶段,视图被渲染。

让我们看看案件工作者的视图。在书籍的源代码中,你会找到一个名为 xen-force-angularjs 的 Gradle 项目。它遵循 Java EE 项目的 Maven 惯例。我们的讨论将分为两个部分。我们将查看由 HTML5、JavaScript 和一些 CSS 组成的前端代码。之后,我们将深入研究 Java 服务器端后端。让我们看看以下这张图:

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/dg-jee7-webapp-dev/img/image00420.jpeg

指令与 AngularJS 中的业务逻辑之间的关系

案件工作者概述

案件工作者项目展示了一个主从应用程序。我们的工作人员启动应用程序,看到一系列案件记录,其中包含每个申请人的姓名和护照详情。这是主记录。每个案件记录可能附有零个或多个任务记录。那些是主记录的详细记录。每个主记录还包含一个状态属性,显示每个申请人在过程中的位置。我们的用户被允许访问所有案件记录并将当前状态从开始移动到结束。

案件工作者主视图

在案件工作者示例中只有一个 HTML 文件,它作为 src/main/webapp/index.xhtml 文件中的模板。记住,这是一个单页应用程序!

<!DOCTYPE html>
<html ng-app="app">
  <head>
    ...
    <link href="styles/bootstrap.css" rel="stylesheet">
    <link href="styles/main.css" rel="stylesheet">
  </head>

  <body ng-controller="CaseRecordController">
    ...
    <div id="mainContent">
      ...
      <div class="case-record-view" >
        ...
        <div class="actionBar"
          ng-controller="NewCaseRecordModalController" >
          <button class="btn btn-primary" ng-click="openCreateCaseRecordDialog()" >Add New Case Record</button>
          <div ng-show="selected">Selection from a modal: {{ selected }}</div>
        </div>

        <h2 class="case-record-headline">Case Records</h2>
        <table class="table table-bordered" >
          <tr>
            <th>Id</th>
            <th>Last Name</th>
            <th>First Name</th>
            <th>Sex</th>
            <th>Country</th>
            <th>Passport No</th>
            <th>D.o.B</th>
            <th>Expiration Date</th>
            <th>Status</th>
          </tr>
          ...
        </table>
      </div>
    </div>
  </body>
</html>

HTML 标签元素被赋予了一个 AngularJS 指令 ng-app,它指定了作为应用程序的作用域值名称。我们有常见的 head 和 body 元素。我们包含了 CSS 文件 Bootstrap (bootstrap.css) 和应用程序的样式文件 main.css。直到我们到达带有 ng-controller 属性的 Body 标签,没有太多区别。ng-controller 指令将控制器附加到视图中。控制器是 MVC 模式的一部分 JavaScript 对象。因此,DOM 中的整个 body 标签元素绑定到名为 CaseRecordController 的 JavaScript 对象。我们稍后会看到它的代码,但首先,让我们深入一点。

随着你进一步检查代码,你会在名为 action-bar 的 CSS 选择器的 div 元素上注意到另一个控制器指令。这个元素与一个名为 NewCaseRecordModalController 的不同控制器相关联。每当一个 ng-controller 指令被赋予属性时,AngularJS 就会创建一个新的作用域。因此,作用域可以相互嵌套。这是 AngularJS 框架中的关键概念。如果存在,作用域存在于与嵌套作用域相关联并包含它们的元素上。

主视图渲染了一个案件记录的表格。前面的代码渲染了申请人的姓名、性别、出生日期、ISO 国家代码、护照号码和护照的有效期。

以下内容是渲染主表行的下一部分:

    <tr ng-repeat-start="caseRecord in caseRecords">
      <td>
        <div ng-controller="NewCaseRecordModalController" style="display: inline;">
          <a class="btn" href="#" ng-click="showOrHideTasks($parent.caseRecord)">
          <i class="glyphicon" ng-class="getIconClass($parent.caseRecord)" ></i>
          </a>
        </div>
      </td>
      <td>{{caseRecord.lastName}}</td>
      <td>{{caseRecord.firstName}}</td>
      <td>{{caseRecord.sex}}</td>
      <td>{{caseRecord.country}}</td>
      <td>{{caseRecord.passportNo}}</td>
      <td>{{caseRecord.dateOfBirth}}</td>
      <td>{{caseRecord.expirationDate}}</td>
      <td>{{caseRecord.currentState}}</td>
    </tr>

这段代码内容有几个部分。ng-repeat-start 是一个特殊的指令,它允许使用表达式迭代内容。表达式是一个 AngularJS 动态评估的表单选择查询。因此,<"caseRecord in caseRecords"> 表达式意味着对名为 caseRecords 的作用域中对象的总体迭代,并将每个元素分配为一个名为 caseRecord 的对象。我们使用 AngularJS 绑定指令表达式在适当的表格单元格元素中渲染每个案件记录的信息。我们对 {{caseRecord.lastName}} 单元格这样做,然后重复此过程。

第一个数据单元格是特殊的,因为它渲染了一个嵌入的 div 元素。它说明了如何关联一个布尔值,并提供了一个展开和折叠的关联到案例记录。我们必须在 div 上创建一个作用域,并将适当的控制器 NewCaseRecordModalController 与 ng-controller 属性关联起来。我们利用 ng-click 指令来调用控制器上的 showOrHideTasks() 方法。注意,我们传递作用域的父元素,其中包含当前的 CaseRecord,因为表格正在渲染。还有一个名为 ng-class 的指令,它通过设置 CSS 选择器将图标元素与 Bootstrap 中的适当矢量图标关联起来。此代码在表格视图中打开和关闭一个二级行,渲染任务视图。它还根据任务视图是打开还是关闭来正确更新矢量图标。

此表格视图内容的第三部分现在如下所示:

<tr ng-repeat-end  ng-if="caseRecord.showTasks" >
  <td colspan="9">
    <div class="case-record-task-view">
      <div ng-controller="NewCaseRecordModalController">
        <button class="btn btn-info"
        ng-click="openEditCaseRecordDialog($parent.caseRecord)" >Edit Case Record Details</button>
        <button class="btn btn-info"
        ng-click="changeStateCaseRecordDialog($parent.caseRecord)" >Change State</button>
      </div>
      <br />

      <div ng-controller="NewTaskModalController">
        <p>
          <button class="btn btn-primary"
            ng-click="openNewTaskDialog(caseRecord.id)">Add New Task</button>
        </p>
      </div>

      <table class="case-record-task-table">
        <tr>
          <td> Item </td>
          <td> Description </td>
          <td> Completed </td>
          <td> Due Date </td>
          <td> Control </td>
        </tr>

        <tr ng-repeat="task in caseRecord.tasks">
          ...
        </tr><!-- ng-repeat-end ## tasks in caseRecords.tasks -->
      </table>
    </div>
  </td>
</tr><!-- ng-repeat-end ## caseRecord in caseRecords -->

主表格中的二级行有一个 ng-repeat-end 指令,它通知 AngularJS 每个案例记录元素结束循环迭代的 DOM 元素。实际上还有一个名为 ng-repeat 的指令,它将 ng-repeat-start 和 ng-repeat-end 结合到一个单个 DOM 元素上。该指令通常用于渲染表格中的简单行。

ng-if 指令条件性地向 DOM 中添加或删除内容。我们使用这个 ng-if 来显示或隐藏每个案例记录元素的任务视图区域。AngularJS 提供了其他类似的指令,称为 ng-show 和 ng-hide,但它们不会动态地从 DOM 中添加或删除内容。

小贴士

为什么我们会选择 ng-if 而不是 ng-show?假设你的数据库中有数百个案例记录元素,我们是否想在 Web 前端渲染所有这些案例及其任务历史?

我们有一个 div-layer 元素,专门用于显示与案例记录关联的任务。看看 CSS 选择器,case-record-task-view。我们添加内容以显示每个 task 元素作为表格。有一个使用 ng-repeat 的示例,它有一个表达式任务,在 caseRecord.tasks 中。

有两个其他的内部 div 层。第一个元素绑定到编辑当前案例记录的逻辑,并引用名为 NewCaseRecordModalController 的控制器。第二个元素允许用户创建一个新的任务,并引用一个名为 NewTaskModalController 的新控制器。我们将在稍后看到这些控制器的 JavaScript 代码。

以下截图展示了显示任务的展开和收缩:

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/dg-jee7-webapp-dev/img/image00421.jpeg

这张截图展示了使用 ng-if 的二级行元素的展开和收缩。

为了完成表格视图的内容,我们写入表格数据行以显示 task 元素的属性:

<tr ng-repeat="task in caseRecord.tasks">
  <td> {{task.id}} </td>
  <td>
    <span class="done-{{task.completed}}"> {{task.name}} </span>
  </td>
  <td>
    <label class="checkbox">
      <input type="checkbox" ng-model="task.completed" ng-change="updateProjectTaskCompleted(task)">
      Done
    </label>
  </td>
  <td>
    {{task.targetDate}}
  </td>
  <td>
    <div ng-controller="NewTaskModalController">
      <a class="btn" href="#"ng-click="openEditTaskDialog($parent.task)" >
        <i class="glyphicon glyphicon-edit"></i></a>
      <a class="btn" href="#"ng-click="openDeleteTaskDialog($parent.task)" >
        <i class="glyphicon glyphicon-trash"></i></a>
    </div>
  </td>
</tr><!-- ng-repeat-end ## tasks in caseRecords.tasks -->

在视图的第四部分,我们充分利用 AngularJS 的双向绑定来渲染一个 HTML checkbox元素,并将其与布尔属性caseRecord.completed关联。使用 CSS 选择器,我们使用类选择器表达式class="done-{{task.completed}}"动态更改任务名称的文本。当用户更改复选框时,选择以下 CSS:

.done-true {
  text-decoration: line-through; color: #52101d;
}

当任务完成时,文本会被划掉!我们向复选框元素添加了ng-change指令,AngularJS 将其与一个改变事件关联。AngularJS 调用控制器NewTaskModalController上的updateProjectTaskCompleted()方法。此方法调用一个WebSocket调用。我们很快会解释其背后的代码!请注意,方法调用传递了当前的task元素,因为我们仍然处于渲染作用域中。

为了完成任务视图,我们有一个与控制器NewTaskModalController关联的div层,带有用于编辑和删除任务的图标按钮。正如你所看到的,我们需要传递$parent.task以便引用元素循环变量。

是时候查看项目组织以及单个 JavaScript 模块、控制器和工厂了。

项目组织

项目被组织成一个 Java EE 网络应用程序。我们将所有的 JavaScript 代码放入遵循 AngularJS 约定的文件夹中,因为我们很可能在一个全栈环境中专业工作,并与具有混合技能的代码库共享。AngularJS 控制器放在app/controllers下,而工厂和服务放在app/service下,如下所示的结构:

src/main/webapp/app/controllers

src/main/webapp/app/controllers/main.js

src/main/webapp/app/controllers/newcaserecord-modal.js

src/main/webapp/app/controllers/newtask-modal.js

src/main/webapp/app/services

src/main/webapp/app/services/iso-countries.js

src/main/webapp/app/services/shared-services.js

接下来,我们将第三方 JavaScript 库放入指定的区域:

src/main/webapp/javascripts

src/main/webapp/javascripts/angular.js

src/main/webapp/javascripts/bootstrap.js

src/main/webapp/javascripts/jquery-2.1.3.js

src/main/webapp/javascripts/ui-bootstrap-0.12.1.js

src/main/webapp/javascripts/ui-bootstrap-tpl-0.12.1.js

注意,我们的案件工作者应用程序还依赖于 Bootstrap、jQuery 和扩展库,Bootstrap UI for AngularJS。我们在主视图index.html的内容最后部分明确包含了所有这些库,如下所示:

<html ng-app="app">  ...
  <body> ...
    <script src="img/jquery-2.1.3.js"></script>
    <script src="img/angular.js"></script>
    <script src="img/bootstrap.js"></script>
    <script src="img/ui-bootstrap-tpls-0.12.1.js"></script>
    <script src="img/main.js"></script>
    <script src="img/newcaserecord-modal.js"></script>
    <script src="img/newtask-modal.js"></script>
    <script src="img/shared-service.js"></script>
    <script src="img/iso-countries.js"></script>
  </body>
</html>

如我之前所说,为了演示的目的,我们保持了代码库的简单性,但我们本可以使用 RequireJS 来处理依赖加载。

小贴士

如果你没有在 AngularJS 之前明确加载 jQuery,那么它将加载自己的较小版本的 jQuery,称为 jq-lite。所以如果你的应用程序依赖于 jQuery 库的完整版本,请确保它在 AngularJS 加载之前加载。

最后一步是将 CSS 放入它们自己的特殊区域:

src/main/webapp/styles

src/main/webapp/styles/bootstrap.css

src/main/webapp/styles/bootstrap-theme.css

src/main/webapp/styles/main.css

上述文件在主视图的顶部加载,位于常规的 head HTML 元素内。

应用程序主控制器

我们 AngularJS 应用程序中的第一个模块声明了应用程序的名称。以下是在文件 src/main/webapp/app/controllers/main.js 中的声明:

var myApp = angular.module('app', ['ui.bootstrap', 'newcaserecord','newtask', 'sharedService', 'isoCountries']);

该框架导出一个名为 angular 的函数对象,并且它有一个名为 module 的方法,用于定义一个模块。第一个参数是模块的名称,第二个参数是依赖模块名称的数组。module() 方法将 AngularJS 模块对象返回给调用者。从那里,我们声明初始控制器。

模块 ui.bootstrap 包含 AngularJS 和 Bootstrap 的集成。模块 newcaserecord 是案件工作人员应用程序的一部分,并定义了一个控制器,用于插入和修改主记录。模块 newtask 定义了一个控制器,用于插入、修改和删除详细记录。sharedService 定义了一个工厂提供者,执行应用程序的实用函数,最后,isoCountries 定义了另一个提供者,包含 ISO 护照国家的列表。

AngularJS 框架有一个流畅的 API 用于定义模块、控制器和提供者;因此,我们可以编写类似于以下代码摘录的几乎声明式的 JavaScript:

angular.module('myApp', [ 'depend1', 'depend2'])
  .controller( 'controller1', function( depend1, depend2 ) {
      /* ... */
  })
  .controller( 'controller2', function( depend1 ) {
      /* ... */   
  })
  .filter('greet', function() {
   return function(name) {
      return 'Hello, ' + name + '!';
    };
  }) 
  .service( 'our-factory', function( ... ) {
      /* ... */   
  })
  .directive( 'my-directive', function( ... ) {
      /* ... */   
});

上述编码风格是个人喜好问题,其缺点是所有模块都被合并在一起。许多专业开发者更喜欢将实际的 Angular 模块对象分配给全局模块变量。

视图中的 body 标签元素定义了一个控制器:

<body ng-controller="CaseRecordController">

以下摘录显示了将用户界面绑定到客户端数据模型的控制器 CaseRecordController:

myApp.controller('CaseRecordController',  function ($scope, $http, $log, UpdateTaskStatusFactory, sharedService, isoCountries ) {
  var self = this;
  $scope.caseRecords = [{sex: "F", firstName: "Angela", lastName: "Devonshire", dateOfBirth: "1982-04-15", expirationDate: "2018-11-21", country: "Australia", passportNo: "123456789012", currentState: "Start"},];

  $scope.isoCountries = isoCountries;

  $scope.getCaseRecords = function () {
    $http.get('rest/caseworker/list').success(function(data) {
      $scope.caseRecords = data;
    });
  }

  $scope.$on('handleBroadcastMessage', function() {
    var message = sharedService.getBroadcastMessage();
    if ( message !== "showTasksCaseRecord")  {
      $scope.getCaseRecords();
    }
  })

  // Retrieve the initial list of case records
  $scope.getCaseRecords();

  $scope.connect = function() {
    UpdateTaskStatusFactory.connect();
  }

  $scope.send = function( msg ) {
    UpdateTaskStatusFactory.send(msg);
  }

  $scope.updateProjectTaskCompleted = function( task ) {
    var message = { 'caseRecordId': task.caseRecordId, 'taskId': task.id, 'completed': task.completed }
    $scope.connect()
    var jsonMessage = JSON.stringify(message)
    $scope.send(jsonMessage)
  }
});

AngularJS 对象中的控制器方法接受第一个参数作为名称。第二个参数是函数对象,按照惯例,我们传递一个带有参数的匿名 JavaScript 函数。

function ($scope, $http, $log, UpdateTaskStatusFactory, sharedService, isoCountries ) { /* ... */ }

参数都是 AngularJS 注入到控制器中的对象模块。AngularJS 定义了以美元符号($)开头的标准模块。模块 $scope 是一个特殊参数,表示当前作用域。模块 $http 代表一个核心 AngularJS 服务,具有与远程 HTTP 服务器通信的方法。模块 $log 是另一个核心服务,用于向控制台记录日志。其他参数 UpdateTaskStatusFactory、sharedService 和 isoCountries 是我们应用程序提供的工厂和服务。AngularJS,像许多现代 JavaScript 数字框架一样,鼓励模块化编程,并尽可能避免污染全局作用域。

那么这个控制器具体做什么呢?首先,出于演示目的,控制器初始化了一个虚拟的 JSON 记录,$scope.caseRecord,以防在页面视图加载时服务器不可用。接下来,我们为记录列表定义了一个属性,$scope.caseRecords。是的,向 AngularJS $scope 添加自定义属性是数据模型与用户界面通信的方式。

我们为控制器定义了属性,$scope.isoCountries。

我们定义了第一个函数,getCaseRecords(),如下所示:

$scope.getCaseRecords = function () {
  $http.get('rest/caseworker/list').success(function(data) {
    $scope.caseRecords = data;
  });
}

此函数从同一主机向远程服务器发出 RESTful GET 请求,该主机提供页面视图。URL 可能类似于这样:http://localhost:8080/xen-national-force/rest/caseworker/list。

我们利用流畅的 API 在服务器返回 JSON 结果后执行一个操作。匿名函数用最新的数据覆盖了 $scope.caseRecords 属性。

顺便说一下,当我们构建 CaseRecordController 的函数对象时,我们调用 getCaseRecords() 方法来启动应用程序。

在 AngularJS 中,我们可以通过创建应用程序创建的工厂服务或通过向服务器发出 HTTP 请求,将信息从一个控制器传递到另一个控制器。还有可能监听 AngularJS 在广播频道上发布的事件。

在 CaseRecordController 中的以下代码演示了如何更新除一条消息之外的所有消息的用户界面:

  $scope.$on('handleBroadcastMessage', function() {
    var message = sharedService.getBroadcastMessage();
    if ( message !== "showTasksCaseRecord")  {
      $scope.getCaseRecords();
    }
  })

在这里,我们在 AngularJS 作用域上注册了一个事件处理器,以便从我们的 SharedService 提供者获取通知。$on() 方法在特定事件类型上注册了一个监听器。第一个参数是消息类型,第二个参数是回调函数。在函数回调内部,如果消息(因此是自定义事件)不是 showTasksCaseRecord,我们就会向服务器发出 HTTP 请求以检索整个案例记录集。

小贴士

在处理程序回调函数内部,我们读取整个数据集,这在实际的企业应用程序中可能是数千个案例记录。因此,我们可以提高 REST 调用和响应代码的性能。然而,我们应该抵制过早优化的诱惑。你应该优先考虑让用户故事工作。

控制器中的其他方法,connect() 和 send(),分别建立到服务器的 WebSocket 通道并向服务器发送 JSON 消息。我们将在稍后的部分检查 UpdateTaskStatusFactory 模块,以及最后的 updateProjectTaskCompleted() 方法。

如果你以前从未专业地开发过任何 JavaScript,那么这个章节可能一开始看起来非常令人畏惧。然而,请坚持下去,因为这实际上只是关于有足够的耐心才能成功。在这方面,我准备了一个简化的 AngularJS 作用域图,它在我们案件工作人员应用程序中呈现的样子。

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/dg-jee7-webapp-dev/img/image00422.jpeg

案件工作人员应用程序中的 AngularJS 作用域

上述图表描绘了进度之旅,帮助我们了解我们将要走向何方。它还建立了 AngularJS 如何以类似于 DOM 本身的方式分层绑定作用域的概念。幕后,AngularJS 创建内部作用域来处理渲染 HTML table 元素的重复性 DOM 元素,这是案例记录的列表。开发者只能通过编程表达式来访问这些内部数据,我们应该将它们视为不透明对象。

小贴士

在撰写本文时,有一个名为 Batarang 的 Google Chrome 插件(chrome.google.com/webstore/detail/angularjs-batarang-stable/),我强烈推荐检查浏览器中的 AngularJS 作用域。遗憾的是,这个工具似乎不再维护。如果有人采用了它,仍然值得检查。

新案例记录控制器

我们将创建和编辑案例记录的代码放置在一个名为 newcaserecord-modal.js 的单独文件中,该文件包含用户定义的 AngularJS 模块 newcaserecord。此模块依赖于其他模块,其中一些之前已经提到。ui.bootstrap.modal 是来自 AngularJS UI Bootstrap 第三方框架的一个特殊模块。该模块定义了 AngularJS 团队编写的 Bootstrap 组件。特别是,它有一个有用的模态对话框扩展,我们在整个案件工作人员应用程序中使用。

以下是为 newcaserecord 模块和 NewCaseRecordModalController 的简短代码:

var newcaserecord = angular.module('newcaserecord', ['ui.bootstrap.modal', 'sharedService','isoCountries'])

newcaserecord.controller('NewCaseRecordModalController', function($scope, $modal, $http, $log, sharedService, isoCountries ) {
  $scope.caseRecord = {
    sex: "F", firstName: "", lastName: "", country: "", passportNo: "", dateOfBirth: "", expirationDate: "", country: "", currentState: "", showTasks: false};
  $scope.returnedData = null;
  $scope.isoCountries = isoCountries;

  $scope.openCreateCaseRecordDialog = function () {
    var modalInstance = $modal.open({
      templateUrl: 'newCaseRecordContent.html', controller: newCaseRecordModalInstanceController, isoCountries: isoCountries, resolve: {
            caseRecord: function () {
              return $scope.caseRecord;
            }
      }
    });

    modalInstance.result.then(function (data) {
      $scope.selected = data;
      $http.post('rest/caseworker/item', $scope.caseRecord).success(function(data) {
        $scope.returnedData = data;
        sharedService.setBroadcastMessage("newCaseRecord");
      });

    }, function () {
      $log.info('Modal dismissed at: ' + new Date());
    });
  };
  // . . .
);

控制器函数对象接受注入的参数,如 $http、$log 和 sharedService。我们还注入了 $modal 实例,这使得我们可以在控制器中打开模态对话框。

由于每个控制器都注入了自己的作用域,我们需要提供数据模型元素以便视图可以访问。因此,我们在作用域中创建了一个空的案例记录 $scope.caseRecord。我们还设置了返回数据和 ISO 国家列表。

函数 $scope.openCreateCaseRecordDialog() 生成一个模态对话框,因此用户可以输入主案例记录。

提示

允许用户创建任意应用程序护照记录可能会被禁止,并且仅限于管理员和经理以外的任何员工。我们的演示应用程序根本没有任何角色和权限的概念。开发者应该小心,避免将零日漏洞引入他们的数字应用程序中。

UI Bootstrap 扩展接受多个参数。第一个参数是 HTML 模板指令的引用。第二个参数指的是另一个名为 newCaseRecordModalInstanceController 的控制器,该控制器负责处理与对话框的交互。第三个参数是解析器,它允许库代码在封装作用域内找到用户模态中的引用数据:

var modalInstance = $modal.open({
  templateUrl: 'newCaseRecordContent.html',
    controller: newCaseRecordModalInstanceController,
      resolve: {
        caseRecord: function () {
          return $scope.caseRecord;
        }
      }
});

控制器的下一部分,NewCaseRecordModalController 处理模态对话框成功完成后的回调,因为用户输入了数据并按下了确认按钮。我们在名为 then 的对象上注册了两个函数对象作为参数。

    modalInstance.result.then(function (data) {...},
       function () { /* modal dismissed */ });

第一个函数是回调处理程序,其中包含将案例记录数据发送到服务器的 REST POST 请求的代码。第二个函数保留用于对话框被取消的情况。你会注意到 AngularJS 使用了流畅的接口。即使你不了解 JavaScript 和框架的所有内容,代码也应该相当容易理解。

因此,让我们看看模态对话框实例的代码,即对象 newCaseRecordModalInstanceController:

var newCaseRecordModalInstanceController = function ($scope, $modalInstance, caseRecord ) {
  caseRecord.showTasks = true; // Convenience for the user
  $scope.caseRecord = caseRecord;

  $scope.ok = function () {
    $modalInstance.close(true);
  };

  $scope.cancel = function () {
    $modalInstance.dismiss('cancel');
  };
};

如果你注意到,这个变量并不是一个封装的 JavaScript 模块;相反,newCaseRecordModalInstanceController 函数是在全局作用域中声明的。我想总有例外。UI Bootstrap 代码通过 $modalInstance.open() 调用调用这个控制器函数。框架向该函数提供三个参数,即作用域 $scope、模态实例 $modalInstance 和案例记录 caseRecord。我们将案例记录分配给提供的作用域,以便从模态对话框中写回数据。在那里,函数对象实现了两个方法,ok() 和 cancel(),分别处理对话框的确认和取消。

我们只需要编写对话框的 HTML 指令。

案例记录模态视图模板

如我们所知,网站的所有内容都在一个单页应用程序中。HTML 指令也出现在视图index.html中。如何在页面内容中写入指令而不在视图中显示?秘诀是否与 CSS 有关?

虽然样式化是一个好主意,但这并不是正确答案。AngularJS 设计者利用了 HTML Script 标签的正式定义,这是嵌入或引用可执行脚本的元素。

以下是将新案件记录插入应用程序的 HTML 指令:

<script type="text/ng-template" id="newCaseRecordContent.html">
  <div class="modal-header">
    <h3>New Case Record </h3>
  </div>
  <div class="modal-body">
    <form name="newCaseRecordForm" class="css-form" novalidate>
      Sex:<br />
      <select ng-model="caseRecord.sex" required>
        <option value="F" ng-option="selected caseRecord.sex === 'F'">Female</option>
        <option value="M" ng-option="selected caseRecord.sex === 'M'">Male</option>
      </select>
      <br/>
      First Name:<br />
      <input type="text" ng-model="caseRecord.firstName" required /><br />
      Last Name:<br />
      <input type="text" ng-model="caseRecord.lastName" required /><br />
      Date of Birth:<br />
      <input type="text" ng-model="caseRecord.dateOfBirth" datepicker-popup="yyyy-MM-dd" required /><br />
      Country:<br />
      <select ng-model="caseRecord.country" required ng-options="item.code as item.country for item in isoCountries.countryToCodeArrayMap">
      </select>
      <br />
      Passport Number:<br />
      <input type="text" ng-model="caseRecord.passportNo" required /><br />
      Expiration Date:<br />
      <input type="text" ng-model="caseRecord.expirationDate" datepicker-popup="yyyy-MM-dd" required /><br />
    </form>
  </div>
  <div class="modal-footer">
    <button class="btn btn-primary" ng-click="ok()" ng-disabled="newCaseRecordForm.$invalid" >OK</button>
    <button class="btn btn-warning" ng-click="cancel()">Cancel</button>
  </div>
</script>

前面的 HTML 指令定义了一个 UI Bootstrap 模式对话框,因为 HTML script标签被标记为text/ng-template类型的属性。所有 AngularJS 指令都需要一个标识符。从 CSS 中我们可以看到,该指令包含一个标题、页脚和主体。主要的div层是一个 HTML 表单。

表单中的每个输入字段都与newCaseRecordModalInstanceController实例中的数据模型绑定。一旦 UI Bootstrap 调用了函数对象,案件记录就被分配到了作用域中。因此,ng-model数据模型$scope.caseRecord.firstName对保留用于姓氏的 HTML 文本输入元素是可用的。

AngularJS 有一个优雅的额外标记用于验证表单输入元素。你可以在几乎所有输入上看到额外的必需属性。不幸的是,由于本书无法深入探讨验证检查的细节,我想将你的注意力引向两个微妙的验证检查。

数据输入利用 UI Bootstrap 日期选择器组件,使案件工作人员能够轻松输入日期:

<input type="text" ng-model="caseRecord.dateOfBirth" datepicker-popup="yyyy-MM-dd" required />

日期的格式由datepicker-popup属性定义。

最后,我们在 HTML select元素中显示 ISO 护照国家名称的下拉列表。这部分代码如下:

<select ng-model="caseRecord.country" required
  ng-options="item.code as item.country for item in
    isoCountries.countryToCodeArrayMap">
</select>

isoCountries是一个服务实例,我们将在后面看到。由于该模块被注入到NewCaseRecordModalController模块中,并且后者的作用域恰好包围了模式实例作用域,AngularJS 允许我们访问该服务。isoCountries实例包含一个护照国家的键值字典列表。代码允许我们将 ISO 代码AUS与国家名称澳大利亚关联起来。ng-option属性接受一个表达式,类似于 SQL 查询。我们声明性地通知 AngularJS 如何为每个 HTML option元素推导显示名称(item.country)和输入表单值(item.code)。

以下是一个带有日期选择器的创建案件记录模式对话框的截图:

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/dg-jee7-webapp-dev/img/image00423.jpeg

带有日期选择器的创建案件记录模式对话框的截图,日期选择器效果全开

让我们转到与案件记录控制器类似的任务记录控制器。

新的任务记录控制器

当案件工作人员使用系统时,他们能够展开和折叠与案例记录相关的任务记录。用户可以创建、编辑和修改任务,还可以更改案件的状态。

AngularJS 模块 newtask 定义如下:

var newtask = angular.module('newtask', ['ui.bootstrap.modal', 'sharedService'])
newtask.config(function($httpProvider) {
  $httpProvider.defaults.headers["delete"] = {
    'Content-Type': 'application/json;charset=utf-8'
  };
})

我们在 AngularJS 的 HTTP 远程处理周围添加了一个配置更改。HTTP DELETE 请求中存在一个微妙的错误。存在于 GlassFish 和 Payara 应用服务器中的 JAX-RS 引用实现 Jersey,在响应代码 415 时引发了一个 HTTP 错误:“不支持的媒体类型”。这迫使 AngularJS 在 DELETE 请求中发送 MIME 类型,将 JSON 作为解决方法。

由于任务控制器的代码非常相似,本书中仅揭示 CRUD 的创建部分。有关其他方法的源代码,请参阅源文件。以下为 NewTaskModalController 的源代码:

newtask.controller('NewTaskModalController', function($scope, $modal, $http, $log, sharedService ) {
  $scope.selected = false;
  $scope.task = {
      id: 0, name: '', targetDate: null, completed: false, caseRecordId: 0
  };
  $scope.returnedData = null;
  $scope.openNewTaskDialog = function(caseRecordId) {
    var modalInstance = $modal.open({
      templateUrl: 'newTaskContent.html',
      controller: newTaskModalInstanceController,
      resolve: {
        task: function () {
          return $scope.task;
        }
      }
    });

    modalInstance.result.then(function (data) {
      $scope.selected = data;
      $http.post('rest/caseworker/item/'+caseRecordId+'/task', $scope.task).success(function(data) {
        $scope.returnedData = data;
        sharedService.setBroadcastMessage("newTask");
        // Reset Task in this scope for better UX affordance.
        $scope.task = {
          id: 0, name: '', targetDate: null, completed: false, caseRecordId: 0
        };
      });
    }, function () {
        $log.info('Modal dismissed at: ' + new Date());
    });
  };

  $scope.openEditTaskDialog = function(taskItem) {
    // ...
  };

  $scope.openDeleteTaskDialog = function(taskItem) {
    // ...
  };
});

在此控制器中,我们有一个空的、默认的 $scope.task 对象,而不是 $scope.caseRecord。每个 Task 对象都通过 caseRecordId 属性引用其父对象。

函数 openNewTaskDialog() 打开一个 UI Bootstrap 模态对话框,允许用户输入一个新任务。该方法将模态对话框与当前 Task 对象的 AngularJS 作用域连接起来。主要区别是 REST URL 端点,其形式为 rest/caseworker/item/'+caseRecordId+'/task。

我们使用 UI Bootstrap 的 $modal 对象,并创建一个与之前相同的模态对话框实例,但现在我们传递了不同的参数。这些参数是 HTML 指令 ID,即 newTaskContent.html;控制器名为 newTaskModalInstanceController,以及解析函数。AngularJS 调用解析函数,该函数定义为一个匿名函数,以便引用封装的 Task 对象。

在 modalInstance 对象的回调函数中,我们方便地重置了 Task 对象,这样当对话框再次弹出时,用户不会被过时的表单数据所惊讶。我们在 sharedService 中设置了广播消息。

处理任务对话框中模态实例的代码几乎相同:

var newTaskModalInstanceController = function ($scope, $modalInstance, task) {
  $scope.task = task;

  $scope.ok = function () {
      $modalInstance.close(true);
  };

  $scope.cancel = function () {
    $modalInstance.dismiss('cancel');
  };
};

newTaskModalInstanceController 函数接受三个参数:绑定模态实例对话框的 $scope,$modalInstance 本身,以及 Task 对象。最后一个参数,即 Task 对象,被解析,并将其设置为作用域上的一个属性,以便在模板中轻松渲染视图。

任务模态视图模板

AngularJS 指令 newTaskContent.html 渲染允许用户输入新任务的模态对话框视图。由于只有四个属性,因此此视图比案例记录更短。

此视图的定义如下:

<script type="text/ng-template" id="newTaskContent.html">
  <div class="modal-header">
    <h3>New Task</h3>
  </div>
  <div class="modal-body">
    <form name="newTaskForm" class="css-form" novalidate>
      Task Name:<br />
      <textarea ng-model="task.name" rows="3" required /><br />
      Target Date:  <br />
      <input type="text" datepicker-popup="yyyy-MM-dd" ng-model="task.targetDate" required /><br />
      Task Completed: <br />
      Done <input type="checkbox" ng-model="task.completed" />
        <br />
    </form>
  </div>
  <div class="modal-footer">
    <button class="btn btn-primary" ng-click="ok()" ng-disabled="newTaskForm.$invalid" >OK</button>
    <button class="btn btn-warning" ng-click="cancel()">Cancel</button>
  </div>
</script>

此视图也遵循 UI Bootstrap CSS 样式用于模态对话框。我们演示了一个与数据模型关联的 HTML text area元素,该数据模型是Task对象。每个表单字段都有一个ng-model关联。对于目标日期,我们重用了日期选择器,并说明了如何使用 HTML checkbox元素。

编辑和删除任务记录的代码看起来大致相同。然而,对于编辑,用户在确认模态对话框后不会重置任务记录,而对于删除,我们只显示任务记录的只读视图;模态对话框只是一个确认。

让我们看看我们如何处理状态的变化。

状态变化

案例记录存在于以下状态:

状态描述
开始系统中的每位新申请人都是从这一初始状态开始的
结束在流程结束时,申请人的案例将结束在这个最终状态
审查案例工作人员正在审查申请人的记录
决定案例已经审查完毕,业务正在做出决定
接受案例已被接受,申请人正在被通知
拒绝案例已被拒绝,申请人正在被拒绝

所有这些业务需求都被捕获在有限状态机中。

控制器代码

到现在为止,代码应该对您来说已经很熟悉了。NewTaskModalController中的控制器方法changeStateCaseRecordDialog()如下所示:

$scope.changeStateCaseRecordDialog = function (caseRecordItem) {
    /* Copy  */
  $scope.caseRecord = {
    id: caseRecordItem.id,
    firstName: caseRecordItem.firstName,
    lastName: caseRecordItem.lastName,
    dateOfBirth: caseRecordItem.dateOfBirth,
    country: caseRecordItem.country,
    passportNo: caseRecordItem.passportNo,
    expirationDate: caseRecordItem.expirationDate,
    currentState: caseRecordItem.currentState,
    nextStates: caseRecordItem.nextStates,
    showTask: caseRecordItem.showTasks
  };

  $scope.caseRecord.nextStates.push( caseRecordItem.currentState );
  $scope.saveCurrentState = caseRecordItem.currentState;

  var modalInstance = $modal.open({
    templateUrl: 'changeStateCaseRecordContent.html', controller: moveStateRecordModalInstanceController, resolve: {
          caseRecord: function () {
            return $scope.caseRecord;
          }
      }
  });

  modalInstance.result.then(function (data) {
      $scope.selected = data;
      if ( $scope.saveCurrentState !== $scope.caseRecord.currentState ) {
          $http.put('rest/caseworker/state/'+$scope.caseRecord.id, $scope.caseRecord).success(function(data) {
            $scope.returnedData = data;
            sharedService.setBroadcastMessage("editCaseRecord");
          });
      }
  }, function () { $log.info('Modal dismissed."); } );
};

由于我们只是在编辑现有的案例记录,所以我们把CaseRecord的属性从封装作用域复制到控制器作用域。记住,外部作用域是主模块。

服务器发送的每个 JSON 案例记录(我们稍后会看到)都有一个名为nextStates的属性,它是一个列表,包含用户可以将记录移动到的下一个可能的状态。以一个例子来说,开始状态只有一个可能的后继状态,称为Reviewing。

每个案例记录对象都有一个currentState属性。我们将当前状态推送到当前作用域中存储的后继状态列表。这个数组$scope.nextStates允许对话框 HTML 指令在视图中渲染下拉菜单。

您可以看到,这个函数changeStateCaseRecordDialog()打开了一个 UI Bootstrap 模态对话框。

模板视图代码

因此,让我们检查状态变化的 HTML 指令:

<script type="text/ng-template" id="changeStateCaseRecordContent.html">
  <div class="modal-header">
    <h3>Change State of Case Record</h3>
  </div>
  <div class="modal-body">
    <p>
      <table class="table table-bordered">
        <tr>
          <th> Field </th> <th> Value </th>
        </tr>
        <tr>

          <td> Case Record Id</td> <td> {{caseRecord.id }}</td>
        </tr>
          ...
      </table>
    </p>
      <form name="moveStateCaseRecordForm" class="css-form" novalidate>
      Next States:<br />
      <select ng-model="caseRecord.currentState" ng-options="state for state in caseRecord.nextStates">
      </select>
      </form>
  </div>
  <div class="modal-footer">
    <button class="btn btn-primary" ng-click="ok()" ng-disabled="moveStateCaseRecordForm.$invalid" >OK</button>
    <button class="btn btn-warning" ng-click="cancel()">Cancel</button>
  </div>
</script>

前面的指令,标识为changeStateCaseRecordContent.html,本质上是对整个案例记录的只读视图。唯一可修改的部分是显示案例记录下一个可能状态的 HTML select元素。为了生成 HTML option元素,有一个不同的表达式形式用于ng-options属性,它声明为state for state in caseRecord.nextStates。这个表达式意味着数组 String 的选项名称和值是相同的,如下所示:

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/dg-jee7-webapp-dev/img/image00424.jpeg

更改案例记录的状态

模态实例代码基本上是相同的。与对话框相关联的相应函数被称为 moveStateRecordModalInstanceController()。

var moveStateRecordModalInstanceController = function ($scope, $modalInstance, caseRecord) {
  $scope.caseRecord = caseRecord;
  $scope.ok = function () { $modalInstance.close(true); };
  $scope.cancel = function () { $modalInstance.dismiss('cancel'); };
};

在我们结束这个关于 AngularJS 和客户端的漫长示例之前,我们将介绍几个更多功能。这些功能是定义 NewCaseRecordModalController 模块的组成部分。

切换任务显示状态

第一个函数 showOrHideTasks(),切换案件记录中的显示属性 showTasks。它还会向服务器发送一个带有案件记录 JSON 数据的 HTTP PUT 请求。代码如下:

$scope.showOrHideTasks = function(caseRecord) {
  caseRecord.showTasks = !caseRecord.showTasks;
  $http.put('rest/caseworker/showtasks/'+caseRecord.id, caseRecord).success(function(data) {
    sharedService.setBroadcastMessage("showTasksCaseRecord");
  });
}

第二个函数 getIconClass() 是一种作弊模式。它根据显示状态返回 Bootstrap CSS 图标选择器。AngularJS 确实有一个用于 ng-class 的条件表达式;然而,在编写本文时,作者无法使其对案件记录元素数组起作用。因此,这个函数作为代码库中的解决方案存在。

$scope.getIconClass = function(caseRecord) {
  if ( caseRecord.showTasks)
    return "glyphicon-minus"
  else
    return "glyphicon-plus"
}

如果你感兴趣,客户端应该工作的正确代码如下:

<i class="glyphicon" ng-class="{true: 'glyphicon-minus', false: 'glyphicon-plus'}[caseRecord.showTasks]">

我们现在将跳转到服务器端。

服务器端 Java

我们为案件工作者系统构建的 Java EE 应用程序是基于 RESTful 服务、Java WebSocket、JSON-P 和 Java 持久性的。

提示

本书本节依赖于对 Java EE 开发从基础水平到高级水平的前期理解。我建议您阅读姊妹书籍 Java EE 7 开发手册,特别是如果您发现这些主题难以理解的话。

实体对象

没有几个领域对象,服务器端将什么都不是。这些被称为 CaseRecord 和 Task 的名称并不令人惊讶。

以下是从 CaseRecord 实体对象中提取的带有完整注解的代码:

@NamedQueries({
  @NamedQuery(name="CaseRecord.findAllCases",
    query = "select c from CaseRecord c order by c.lastName, c.firstName"),
    /* ... */
})
@Entity
@Table(name = "CASE_RECORD")
public class CaseRecord {
  @Id @GeneratedValue(strategy = GenerationType.AUTO)
  private Integer id;
  @NotEmpty @Size(max=64) private String lastName;
  @NotEmpty @Size(max=64) private String firstName;
  @NotEmpty @Size(max=1) private String sex;
  @NotEmpty @Size(max=16) private String passportNo;
  @NotEmpty @Size(max=32) private String country;
  @Past @NotNull @Temporal(TemporalType.DATE) private Date dateOfBirth;
  @Future @NotNull @Temporal(TemporalType.DATE) private Date expirationDate;
  @NotEmpty private String currentState;
  private boolean showTasks;
  @OneToMany(cascade = CascadeType.ALL, mappedBy = "caseRecord", fetch = FetchType.EAGER)
  private List<Task> tasks = new ArrayList<>();

  // Required by JPA
  public CaseRecord() {}
  /*  ... */
}

对于这些实体,我们利用流行的 Hibernate Validator 注解来确保信息被正确地保存到数据库中。详细的 Task 实体如下:

@Entity
public class Task {
  @Id @GeneratedValue(strategy = GenerationType.AUTO)
  @Column(name="TASK_ID") private Integer id;
  @NotEmpty @Size(max=256) private String name;
  @Temporal(TemporalType.DATE)
  @Column(name="TARGET_NAME") @Future
  private Date targetDate;
  private boolean completed;
  @ManyToOne(cascade = CascadeType.ALL)
  @JoinColumn(name="CASE_RECORD_ID")
  private CaseRecord caseRecord;

  public Task() { /* Required by JPA */ }
  /*  ... */
}

实体对象映射非常紧密地对应于我们在客户端看到的 JavaScript 对象。在实践中,不同领域的业务应用程序可能会选择替代设计,例如数据模型的门面、聚合或投影。

当然,这些实体都有一个持久层,以便将信息检索和存储到数据库中。在源代码中,有一个 CaseRecordTaskService 负责持久化 CaseRecord 和 Task 记录。

RESTful 通信

无状态的会话 EJB 类 CaseWorkerRESTServerEndpoint 作为我们的 RESTful 端点:

package uk.co.xenonique.nationalforce.control;
/* ... */
import javax.json.*;
import javax.json.stream.*;
import javax.ws.rs.*;
import javax.ws.rs.container.*;
import javax.ws.rs.core.*;
import static javax.ws.rs.core.MediaType.*;

@Path("/caseworker/")
@Stateless
public class CaseWorkerRESTServerEndpoint {
  static JsonGeneratorFactory jsonGeneratorFactory = Json.createGeneratorFactory(new HashMap<String, Object>() {{
        put(JsonGenerator.PRETTY_PRINTING, true);
      }});

  @Inject
  CaseRecordTaskService service;
/* ... */
}

这个类用 @Path 注解了此端点的初始 URI。这个相对 URI /caseworker/ 与 AngularJS 客户端匹配。我们将持久状态会话 EJB CaseRecordTaskService 注入到这个端点中,并且我们还设置了一个 JSON 生成器工厂,它将打印 JSON 输出。我们在整个 Java EE 7 中使用标准的 JSON 生成器工厂。

案件记录检索

为了处理案例工作者记录的检索,我将演示如何使用 JAX-RS 处理异步操作。我们需要从应用程序服务器中获取一个管理的执行器,并确保在部署后 Web 应用程序支持async操作。

对于 Java EE 7 来说,在 Web XML 部署描述符(src/main/web-app/WEB/web.xml)中启用异步支持至关重要。此文件如下:

<?xml version="1.0" encoding="UTF-8"?>
<web-app  ...
         version="3.1" ... >
  <servlet>
    <servlet-name>javax.ws.rs.core.Application</servlet-name>
    <load-on-startup>1</load-on-startup>
    <async-supported>true</async-supported>
  </servlet>
  <servlet-mapping>
    <servlet-name>javax.ws.rs.core.Application</servlet-name>
    <url-pattern>/rest/*</url-pattern>
  </servlet-mapping>
  <resource-env-ref>
    <resource-env-ref-name>
      concurrent/LongRunningTasksExecutor
    </resource-env-ref-name>
    <resource-env-ref-type>
      javax.enterprise.concurrent.ManagedExecutorService
    </resource-env-ref-type>
  </resource-env-ref>
</web-app>

重要的 XML 元素是<async-supported>,我们将其内容设置为 true。我们还设置了整个应用程序接收 REST 查询的 URI,即/rest。因此,结合CaseWorkerRESTServerEndpoint类,完整的相对 URI 到目前为止是/rest/caseworker。最后,我们通过在<resource-env-ref>周围添加 XML 元素向 Java EE 7 应用程序服务器声明我们的应用程序需要一个管理的执行器。这个管理的执行器被称为concurrent/LongRunningTasksExecutor(JNDI 查找名称)。

我们现在将在第一个 REST 查询方法中使用它:

  @Resource(name="concurrent/LongRunningTasksExecutor")
  ManagedExecutorService executor;

  @GET
  @Produces(MediaType.APPLICATION_JSON)
  @Path("/list")
  public void getCaseRecordList(
    @Suspended final AsyncResponse asyncResponse) {
    executor.submit(new Runnable() {
      @Override
      public void run() {
        final List<CaseRecord> caseRecords = service.findAllCases();
        final StringWriter swriter = new StringWriter();
        final JsonGenerator generator = jsonGeneratorFactory.createGenerator(swriter);
          CaseRecordHelper.generateCaseRecordAsJson(generator, caseRecords).close();
          final Response response = Response.ok(swriter.toString()).build();
          asyncResponse.resume(response);
      }
    });
  }

我们使用@GET注解标注getCaseRecordList()方法来处理来自完整相对 URI /rest/caseworker/list的 HTTP GET 请求。此方法异步执行。它依赖于注入的ManagedExecutorService实例,这是一个 Java EE 7 管理的线程池执行器。为了参与服务,我们提供了一个方法参数,即标注了@Suspended的AsyncResponse对象。

我们getCaseRecordList()方法的主体将一个工作实例(java.lang.Runnable)提交给管理的执行器服务。该工作实例从持久化服务检索一系列案例记录并将它们转换为 JSON 输出。输出被转换为字符串,然后我们通过AsyncResponse实例的resume()方法请求开始通过输出通道向客户端发送数据。我们使用 JAX RS 的@Produces注解来标注getCaseRecordList()方法,以声明输出内容的 MIME 类型为application.json。

小贴士

顺便提一下,Java EE 7 中有两个@Produces注解。一个是 JAX-RS 的一部分,另一个是 CDI。

我们还有一个通过 ID 检索特定案例记录的 REST 端点。让我们看看我们如何实现这一点:

  @GET
  @Path("/item/{id}")
  @Produces(APPLICATION_JSON)
  public String retrieveCase(
      @PathParam("id") int caseId ) {
    List<CaseRecord> caseRecords = service.findCaseById( caseId );
    StringWriter swriter = new StringWriter();
    JsonGenerator generator = jsonGeneratorFactory.createGenerator(swriter);
    CaseRecordHelper.writeCaseRecordAsJson(generator, caseRecords.get(0)).close();
    return swriter.toString();
  }

retrieveCase()方法被标注为@GET以处理 HTTP GET 请求。它具有相对 URI /rest/caseworker/item/{id}。该方法通过 ID 搜索案例记录并创建其 JSON 表示。它同步地将输出发送到客户端。简短说明:为了节省空间,我们在这些摘录中移除了健全性检查代码。

创建案例记录

我们已经涵盖了检索方面,现在我们转向创建 REST 端点。在我们的系统中,Web 客户端可以使用 REST 调用创建案例记录。以下代码将一个新的案例记录插入到应用程序中。创建新案例记录的相对 URI 是/rest/caseworker/item。

  @POST
  @Path("/item")
  @Consumes(APPLICATION_JSON)
  @Produces(APPLICATION_JSON)
  public String createCase( JsonObject json )
    throws Exception {
    CaseRecord caseRecord = new CaseRecord();
    caseRecord.setSex(json.getString("sex"));
    caseRecord.setFirstName(json.getString("firstName"));
    caseRecord.setLastName(json.getString("lastName"));
    caseRecord.setCountry(json.getString("country"));
    caseRecord.setPassportNo(json.getString("passportNo"));
    caseRecord.setDateOfBirth( CaseRecordHelper.FMT2.parse(json.getString("dateOfBirth")));
    caseRecord.setExpirationDate( CaseRecordHelper.FMT2.parse(json.getString("expirationDate")));
    caseRecord.setCurrentState( BasicStateMachine.FSM_START.toString());
    caseRecord.setShowTasks(json.getBoolean("showTasks", false));

    JsonArray tasksArray = json.getJsonArray("tasks");
    if ( tasksArray != null ) {
      for ( int j=0; j<tasksArray.size(); ++j ) {
        JsonObject taskObject = tasksArray.getJsonObject(j);
        Task task = new Task(taskObject.getString("name"), ( taskObject.containsKey("targetDate") ?
              CaseRecordHelper.FMT.parse(taskObject.getString("targetDate")) : null ), taskObject.getBoolean("completed"));
            caseRecord.addTask(task);
            task.setCaseRecord(caseRecord);
        }
    }

    service.saveCaseRecord(caseRecord);
    StringWriter swriter = new StringWriter();
    JsonGenerator generator = jsonGeneratorFactory.createGenerator(swriter);
    CaseRecordHelper.writeCaseRecordAsJson(generator, caseRecord).close();
    return swriter.toString();
}

createCase() 方法较长,因为它将 JSON-P 对象实例内部的数据传输到 CaseRecord 实体中。我们使用 @POST 注解该方法,表示此端点处理 HTTP POST 请求。这是一段冗长的样板代码,通过在其他非 Java EE 7 框架(如 GSON code.google.com/p/google-gson/)或 Faster Jackson 处理 JSON API (wiki.fasterxml.com/JacksonInFiveMinutes)上的数据类型绑定来解决。但在这里我必须展示标准方法。我们得等到规范主体交付 JSON-B(Java JSON 绑定 API)之后,我们才能使这段代码更加精简。

更新案例记录

更新案例记录与创建新记录非常相似,不同之处在于我们首先通过其 ID 搜索记录,然后从 JSON 输入逐字段更新记录字段。

updateCase() 方法如下:

@PUT
@Path("/item/{caseId}")
@Consumes(APPLICATION_JSON)
@Produces(APPLICATION_JSON)
public String updateCase(
  @PathParam("caseId") int caseId, JsonObject json ) throws Exception {
  final List<CaseRecord> caseRecords = service.findCaseById(caseId);
  CaseRecord caseRecord = caseRecords.get(0);
  caseRecord.setSex(json.getString("sex"));
  /* ... omitted */
  caseRecord.setDateOfBirth( FMT2.parse( json.getString("dateOfBirth")));
  caseRecord.setExpirationDate( FMT2.parse(json.getString("expirationDate")));
  caseRecord.setCurrentState( BasicStateMachine.retrieveCurrentState( json.getString("currentState", BasicStateMachine.FSM_START.toString())).toString());
  caseRecord.setShowTasks(json.getBoolean("showTasks", false));
  service.saveCaseRecord(caseRecord);
  final StringWriter swriter = new StringWriter();
  final JsonGenerator generator = jsonGeneratorFactory.createGenerator(swriter);
  CaseRecordHelper.writeCaseRecordAsJson(generator, caseRecord).close();
  return swriter.toString();
}

这个 RESTful 端点使用 @PUT 注解来处理 HTTP PUT 请求。这次,相对 URI 是 /rest/caseworker/item/{id},表示客户端必须提供案例记录 ID。同样,我们复制 JSON 对象中的值,并覆盖从持久化中检索到的 CaseRecord 中的属性;然后我们保存记录。我们生成记录的 JSON 表示,并将其设置为 JAX-RS 将发送回客户端的响应。

静态实例 FMT2 是一个 java.text.SimpleDateFormat,它在过期日期和出生日期字符串与 java.util.Date 实例之间进行转换。模式格式是 yyyy-MM-dd。BasicStateMachine 实例是有限状态机的实现。FSM_START 是可能状态之一的一个单例实例。请参考书籍的源代码,以了解其实现方式。

创建任务记录

我们现在将迅速检查任务记录的创建、更新和删除端点。检索已经解决,因为每个 CaseRecord 实例都有一个零个或多个 Task 实体的集合,这满足了主从详细安排。

创建和更新任务记录是非常相似的操作。所以让我们首先研究创建方法:

@POST
@Path("/item/{caseId}/task")
@Consumes(APPLICATION_JSON)
@Produces(APPLICATION_JSON)
public String createNewTaskOnCase(
  @PathParam("caseId") int caseId, JsonObject taskObject ) throws Exception
{
  final List<CaseRecord> caseRecords =
    service.findCaseById(caseId);
  final CaseRecord caseRecord = caseRecords.get(0);
  final Task task = new Task(
    taskObject.getString("name"),
    ( taskObject.containsKey("targetDate") ?
      CaseRecordHelper.convertToDate(
      taskObject.getString("targetDate")) :
      null ),
    ( taskObject.containsKey("completed")) ?
        taskObject.getBoolean("completed") : false );
  caseRecord.addTask(task);
  service.saveCaseRecord(caseRecord);
  final StringWriter swriter = new StringWriter();
  JsonGenerator generator =
    jsonGeneratorFactory.createGenerator(swriter);
  CaseRecordHelper.writeCaseRecordAsJson(
    generator, caseRecord).close();
  return swriter.toString();
}

我们使用 @POST 注解 createNewTaskOnCase() 方法。相对 URI 是 /rest/caseworker/item/{caseId}/task。客户端提交父案例记录,该方法使用此 ID 来检索适当的 CaseRecord。与从新任务记录控制器引用 AngularJS 客户端端点交叉引用可能是个好主意。在 createNewTaskOnCase() 内部,我们再次移除了健全性检查代码,以便专注于实质内容。代码的下一部分是将 JSON 映射到 Java 实体。之后,我们将 Task 实体添加到 CaseRecord 中,然后持久化主记录。一旦我们写入响应,该方法就完成了。

更新任务记录

updateTaskOnCase()方法执行任务更新。我们用@PUT注解这个方法,并使用两个 RESTful 参数。相对 URI 是/rest/caseworker/item/{caseId}/task/{taskId}。更新任务记录的代码如下:

@PUT
@Path("/item/{caseRecordId}/task/{taskId}")
@Consumes(APPLICATION_JSON)
@Produces(APPLICATION_JSON)
public String updateTaskOnCase(
  @PathParam("caseRecordId") int caseRecordId,
  @PathParam("taskId") int taskId,
  JsonObject taskObject ) throws Exception
{
  final List<CaseRecord> caseRecords =
    service.findCaseById(caseRecordId);
  final CaseRecord caseRecord = caseRecords.get(0);
  caseRecord.getTasks().stream().filter(
    task -> task.getId().equals(taskId)).forEach(
      task -> {
        task.setName( taskObject.getString("name") );
        task.setTargetDate(
          taskObject.containsKey("targetDate") ?
          CaseRecordHelper.convertToDate(
            taskObject.getString("targetDate")) : null );
        task.setCompleted(taskObject.containsKey("completed") ?
            taskObject.getBoolean("completed") : false );
    }); 
  service.saveCaseRecord(caseRecord);
  final StringWriter swriter = new StringWriter();
  final JsonGenerator generator =
    jsonGeneratorFactory.createGenerator(swriter);
  CaseRecordHelper.writeCaseRecordAsJson(
    generator, caseRecord).close();
  return swriter.toString();
}

使用我们的两个坐标caseRecordId和TaskId,我们定位适当的Task实体,然后从 JSON 输入中更新属性。在这里,我们利用 Java 8 Lambdas 和 Stream API 进行函数式方法。我们保存实体,并从当前的CaseRecord实体渲染 JSON 响应。

删除任务记录

最后但同样重要的是,我们为客户端前端提供了一种从案例记录中删除任务记录的方法。代码如下:

@DELETE
@Path("/item/{caseRecordId}/task/{taskId}")
@Consumes( { APPLICATION_JSON, APPLICATION_XML, TEXT_PLAIN })
@Produces(APPLICATION_JSON)
public String removeTaskFromCase(
  @PathParam("caseRecordId") int caseRecordId,
  @PathParam("taskId") int taskId,
  JsonObject taskObject )
  throws Exception
{
  final List<CaseRecord> caseRecords =
    service.findCaseById(caseRecordId);
  final CaseRecord caseRecord = caseRecords.get(0);
  caseRecord.getTasks().stream().filter(
    task -> task.getId().equals(taskId))
    .forEach( task -> caseRecord.removeTask(task) );
  service.saveCaseRecord(caseRecord);
  final StringWriter swriter = new StringWriter();
  final JsonGenerator generator =
          jsonGeneratorFactory.createGenerator(swriter);
  CaseRecordHelper.writeCaseRecordAsJson(generator, caseRecord).close();
  return swriter.toString();
}

对于 HTTP DELETE 请求,我们用@DELETE注解deleteTaskFromCase()方法。这个方法的相对 URI 是严格 RESTful 服务端点/rest/caseworker/item/{caseId}/task/{taskId}。

在这个方法中,棘手的部分是搜索实际的Task记录。在这里,Java 8 Lambda 和流函数使这项任务变得非常全面且令人愉快。在正确识别了Task实体后,我们从父CaseRecord中删除它,然后使用持久性保存主记录。在消息的末尾,我们发送一个CaseRecord的 JSON 响应。

这涵盖了应用程序的 JAX-RS 方面;我们现在将转向 Java EE WebSocket 支持。

WebSocket 通信

WebSocket 是一种 HTML 协议扩展,允许客户端和服务器在网络中进行全双工异步通信。它通过在向后兼容的 HTTP 之前进行两个端点的初始握手来工作,然后切换到更快的 TCP/IP 流。WebSocket 规范(RFC 6455)是 HTML5 技术集合的一部分,由Web Hypertext Application Technology Working Group(WHATWG)(whatwg.org)和Internet Engineering Task Force(IETF)(www.ietf.org)推动。

WebSocket 支持自 Java EE 7 版本发布以来就已经可用,相关的 JSCP 规范是 JSR 356 (jcp.org/en/jsr/detail?id=356)。我们可以使用注解或直接针对 API 来开发 JavaEE WebSocket。使用注解编写更容易,正如我们将看到的。

AngularJS 客户端

在新任务记录控制器和应用程序主控制器周围再次审查 AngularJS 客户端是有帮助的。让我们检查CaseRecordController中的updateProjectTaskCompleted()方法。每当用户通过选择或取消选择 HTML checkbox元素来决定任务是否完成时,我们通过send()方法将前端连接起来,发送 WebSocket 消息:

  $scope.updateProjectTaskCompleted = function( task ) {
      var message = { 'caseRecordId': task.caseRecordId, 'taskId': task.id, 'completed': task.completed }
      $scope.connect()
      var jsonMessage = JSON.stringify(message)
      $scope.send(jsonMessage)
  }

整个本地的 JavaScript 任务记录都作为 JSON 发送。

为了在客户端的其它模块中提供 WebSocket 通信,AngularJS 建议我们定义一个工厂或服务。工厂通常只初始化一次。另一方面,服务添加功能,并返回不同的实例,这取决于调用上下文。

以下缺失的工厂:

myApp.factory('UpdateTaskStatusFactory', function( $log ) {
  var service = {};

  service.connect = function() {
    if (service.ws) { return; }
    var ws = new WebSocket("ws://localhost:8080/
      xen-force-angularjs-1.0-SNAPSHOT/update-task-status");
    ws.onopen = function() {
      $log.log("WebSocket connect was opened"); };
    ws.onclose = function() {
      $log.log("WebSocket connection was closed"); }
    ws.onerror = function() {
      $log.log("WebSocket connection failure"); }
    ws.onmessage = function(message) {
      $log.log("message received ["+message+"]"); };
    service.ws = ws;
  }

  service.send = function(message) {
      service.ws.send(message);
  }

  return service;
});

定义 AngularJS 工厂的惯用语与定义控制器或模块非常相似。在主模块myApp中,我们使用两个参数调用库的factory()方法:工厂的名称和定义服务的函数回调。工厂只依赖于默认的日志模块$log。

这个connect()方法通过实例化 WebSocket 实例来初始化一个 HTML5 WebSocket。通过句柄,我们注册可选的回调来处理事件:当 WebSocket 打开、关闭、接收消息或发生错误时。每个回调都会将消息输出到 Web 浏览器的控制台日志。

我们定义了几个send()方法,这些方法将消息体内容发送到 WebSocket 的对方。在 WebSocket 术语中,远程端点被称为对方,因为客户端和服务器端点之间没有区别。双方都可以开始与对方建立连接并开始通信;因此,术语全双工。

服务器端 WebSocket 端点

如前所述,在 Java EE 7 中,我们可以快速使用标准注解开发 WebSocket 端点。Java WebSocket API 紧密遵循 IETF 规范,你会在浏览器内的许多 JavaScript 实现中认识到这些相似之处。配置和不同注解方法以及直接编程到库的方法太多,无法合理地压缩在这本数字 Java EE 书中。

尽管如此,关键的 Java 类实际上被注解为无状态会话 EJB 以及 WebSocket。这并不令人惊讶,因为 Java EE 规范允许在某些情况下混合使用这些注解。

以下是在CaseRecordUpdateTaskWebSocketEndpoint中的端点:

package uk.co.xenonique.nationalforce.control;
/* ... */
import javax.websocket.*;
import javax.websocket.server.ServerEndpoint;

@ServerEndpoint("/update-task-status")
@Stateless
public class CaseRecordUpdateTaskWebSocketEndpoint {
  @Inject
  CaseRecordTaskService service;

  static JsonGeneratorFactory jsonGeneratorFactory =
    Json.createGeneratorFactory(...);

  @OnMessage
  public String updateTaskStatus(String message) {
    final StringReader stringReader = new StringReader(message);
    final JsonReader reader = Json.createReader(stringReader);
    final JsonObject obj = reader.readObject();
    final int projectId = obj.getInt("caseRecordId");
    final int taskId = obj.getInt("taskId");
    final boolean completed = obj.getBoolean("completed");
    final List<CaseRecord> projects =
      service.findCaseById(projectId);
    if ( !projects.isEmpty()) {
      projects.get(0).getTasks().stream()
        .filter(task -> task.getId() == taskId).
        forEach(task -> {
            task.setCompleted(completed);
            service.saveCaseRecord(task.getCaseRecord());
        });
      return "OK";
    }
    return "NOT FOUND";
  }

  @OnOpen
  public void open( Session session ) { ... }
  @OnClose
  public void close( Session session ) { ... }
  @OnError
  public void error( Session session, Throwable err ){
      err.printStackTrace(System.err);
  }
}

我们使用@ServerEndpoint注解 bean 来表示服务器端端点。服务器端的概念基本上是 Java EE 术语,用于声明这个端点位于应用程序服务器上。还有像@ClientEndpoint这样的连接。

而不是回调,Java EE 使用注解方法来处理 WebSocket 周围的开启、关闭和失败事件,分别使用@OnOpen、@OnClose和@OnError。

为了正确处理 WebSocket 上的消息接收,POJO 或 bean 必须只有一个方法被注解为@Message。在幕后,库框架将消息转换为一个字符串,以处理我们这里的最简单情况。可以发送二进制和复杂的数据类型通过线和网络。

在 updateTaskStatus() 方法内部,我们利用 JSON-P API 将文本解析为任务的显著属性。从输入文本消息中,我们需要案例记录 ID、任务 ID 和任务的完成属性。我们从持久化中检索匹配的 CaseRecord 实体,并过滤 Task 对象集合以找到正确项。一旦我们找到它,我们就设置完成属性,并将整个记录持久化回持久化存储。

WebSockets 允许向对等方返回一个响应。我们像这里一样同步发送响应,使用像OK或NOT FOUND这样的文本消息。读者应该知道,也可以异步发送响应。

我们已经讨论了服务器端的最后一点。

考虑你的设计需求

AngularJS 是一个强大的客户端 JavaScript MVC 框架。开发者、设计师和数字经理们总是期待着下一个令人兴奋的技术。人们常常倾向于评估新框架的影响。可以公平地说,AngularJS 是一个变革者,因为它使得与模型绑定的组件开发变得容易。默认情况下,不使用 jQuery 实现这种潜在的错误代码所付出的努力是巨大的!

我们知道 AngularJS 适合单页应用。这意味着你的下一个企业应用必须是一个 SPA 吗?嗯,实践顾问的回答是,这总是取决于你的目标。SPA 适合有限的客户旅程,以及在体验主要发生在同一个地方的情况下。案例工作人员应用就是这种类型,因为工作人员是逐个评估护照申请人的,因此,他们在工作日的大部分时间都待在一个网页上工作。

案例工作人员应用演示了主从关系。你的下一个企业应用可能需要更复杂的一组用例。你的领域可能需要广泛的实体集。一个单独的 SPA 可能无法涵盖所有领域。首先,你需要更多的 JavaScript 模块、控制器和工厂,以及 HTML 指令,以完全包围系统的边界上下文。

那对于这些复杂的需求我们该怎么办呢?一种方法是将所有 JavaScript 逻辑打包到客户端脚本中,然后下载。例如,GruntJS 这样的工具,我们在上一章中简要介绍过,可以将文件合并、压缩和优化。我们可以利用 Java EE 的优势,在多个网页和导航中。

单页应用数组集合

我们可以将 SPA 结构化为线性序列,以便系统的客户旅程几乎遵循工作流程。在仓库订单管理、工程和金融交易等领域,这种做法可能是有意义的。在这些领域,业务用户通过一系列复杂的步骤来处理从 A 到 B 的大量工作单元。如果 SPA 数组具有短线性序列,可能包括三到四个步骤,那么它们的优势就会被获得,但如果链的长度大于或等于七个,这些优势就会丧失。

单页应用程序的分层集合

另一种方法是完全从线性序列下降到 SPA 的分层树结构。这种方法非常专业,建议在寻求一些架构保证以确认这条路径对您的业务是可持续的。为什么企业会希望以这种方式组织 SPA?您的利益相关者可能希望以与领域完全一致的方式维持组织功能。设计方法是一种风险程序,因为它在整体模型中引入了僵化性,在我看来,这似乎是由管理层领导的权威权力,而不是有机的。如果层次树是按长度和宽度而不是按长度和深度组织的话,问问自己为什么?

在这些工程师和建筑师都在寻找微服务以实现扩展并优雅地将单一业务功能封装在单个盒子中的时代,HSPA 可能确实很有用。树结构的大小应该大约有 10 个节点。

摘要

在这一长章节中,我们介绍了使用案例工作者应用程序进行客户端 AngularJS 开发。我们学习了 AngularJS 框架如何操作 DOM,以及它是如何通过 MVC 提供数据与元素渲染之间的绑定。我们还学习了 AngularJS 的一些概念,如作用域、模块、控制器和工厂。

通过研究示例,我们说明了 AngularJS 如何从客户端使用 RESTful 服务调用与远程服务器进行通信。我们还简要研究了 WebSocket 交互。在 JavaScript 客户端方面,我们在案例工作者应用程序中走过了整个 CRUD 习语。

在服务器端,我们看到了使用 JAX-RS 实现的 RESTful 服务的实现,它涵盖了四个标准的 HTTP 方法请求。我们还学习了 Java WebSocket 的实现。

AngularJS 适合需要单页应用程序模式的应用。然而,这可能或可能不适合您的业务需求。采用 AngularJS 需要具备 JavaScript 编程和 Java EE 开发的全面知识。转向像 AngularJS 这样的框架会使您的业务面临招聘、保留和学习更多技术的风险。

还有一个三角形的另一面需要考虑:组织动态。苹果公司(美国)著名地将当时负责在线购物商店的敏捷团队分为纯服务器和客户端部门。他们之间唯一允许的通信是一个商定的编程接口。这种划分发生在 iPhone 开发期间(大约 2005-2007 年),显然早于 AngularJS。你的团队可能有所不同,但设计合同的概念仍然相关,因为它展示了可以实现什么,特别是在 RESTful 服务方面。

我将留给你们来自 AngularJS 共同创造者 Misko Hevery 的第二句引言。他说:

“有限 – 你实际上无法在单页面上向人类展示超过 2000 件信息。超过这个数量真的很糟糕的用户界面,而且人类无论如何都无法处理这些信息。”

练习

  1. 下载 xen-national-force 案件工作人员应用程序的源代码,并研究实现方法几个小时。你注意到了什么?编译代码并将生成的 WAR 部署到您选择的应用程序服务器上。

  2. 使用本章的材料,创建一个只有一个简单实体 EMPLOYEE 的 CRUD AngularJS 应用程序。这个实体应该有一个员工 ID、姓名和社会安全号码。客户端使用 AngularJS 构建,服务器端使用 JAX-RS 构建。(在本书的源代码中,有一个空白项目可以帮助你开始。)

  3. 在从上一个问题构建 AngularJS 和 JavaEE 中的 EMPLOYEE CRUD 时,你是否使用了 UI Bootstrap 的模态对话框?如果没有,调查其他渲染视图以插入、更新和删除记录的方法。(提示:一种可能的方式是动态显示和隐藏不同的 DIV 元素。)

  4. 案件工作人员应用程序存在一个明显的设计缺陷。你是否发现了?当案件工作人员显示或隐藏任务视图时,它会更新持久化数据库;解释为什么这是一个问题?

  5. 想象一下,从今天起,你已经成为整个 xen-national-force 团队的项目负责人,并且业务突然决定他们想要在状态改变后立即向其他案件工作人员广播通知。在技术层面上解释这个用户故事可能如何实现。考虑 AngularJS 客户端的挑战。你将如何构建 Java EE 服务器端?

  6. 研究一下 xen-national-force 中的工厂模块 (iso-countries.js),它负责维护 ISO 护照国家名称及其代码的集合。这个模块在前端是如何使用的?它在哪些地方被使用?

  7. 不要在 CaseRecord JPA 实体中使用专门的布尔属性来表示任务视图是否显示,而是编写一个 AngularJS 工厂模块,该模块在客户端本地存储所有案件记录的信息。

  8. 样本案件工作者应用程序检索数据库中的所有记录并将其返回给用户。假设真实系统有 1,000 个案件记录。这个功能可能存在什么问题?你会如何解决这个问题?如果案件工作者无法看到所有记录,请解释你将如何确保他们能看到相关案件?你需要在 AngularJS 客户端和 Java EE 服务器端实现哪些功能?

第九章. Java EE MVC 框架

*“与电话或电视的开发相比,Web 发展得非常快。”
–蒂姆·伯纳斯-李爵士,万维网的发明者

在过去几章中,我们从客户端的角度回顾了 Web 应用程序服务。在本章的最后,我们将回到主要在服务器端编写的数字应用程序。我们将检查 Java EE 下的一个全新的规范。它被称为模型-视图-控制器(MVC),属于 Java EE 8 版本(2017 年 3 月)下的 JSR 371(jcp.org/en/jsr/detail?id=371)。在撰写本书时,Java EE MVC 的早期草案版本已经发布,展示了参考实现 Ozark(ozark.java.net/index.html)的工作原理。

MVC 框架基于在 Smalltalk 编程语言和环境中所发明的设计模式,这在早期的用户界面应用程序中尤为常见。其理念是,模型指的是存储应用程序数据(如值对象或数据传输对象)的组件。视图是负责将应用程序数据的表示渲染或交付给用户的组件,控制器是包含处理前两个组件(视图和模型)之间输入和输出逻辑的组件。这种设计模式之所以非常流行,是因为它拥抱了关注点的分离,这是良好实用主义面向对象设计中的一个关键概念。

在 2014 年,甲骨文公司对更广泛的 Java EE 社区发布了一项公开的问卷调查,并收集了结果(java.net/downloads/javaee-spec/JavaEE8_Community_Survey_Results.pdf)。数千名技术人员对此调查做出了回应。调查中的一个关键问题是,“Java EE 是否应该提供对 MVC 和 JSF 的支持?”投票结果显示,60.8%的人支持,20%的人反对,19.2%的人不确定。这足以批准 MVC 1.0 规范成为 Java EE 8 的一部分。

Java EE 8 MVC

在我们继续编程之前,我应该提醒您,这里的信息可能会发生变化,因为 MVC 正在我们眼前不断发展。作为一名热心的读者,您应该至少验证 API 是否与当前或最终规范一致。

如此一来,MVC 框架无疑——即使在这个早期阶段——将成为未来许多年数字 Web 开发框架的领先规范,而不仅仅是因为它现在是 Java EE 伞下驱动系统的一部分。MVC 利用了 JAX-RS(Java for RESTful Services)API,并目前与其他 Java EE 技术集成,包括 CDI 和 Bean Validation。

专家小组决定在 JAX-RS 而不是较旧的 Java servlet API 之上分层,因为 JAX-RS 适合现代编程实践,以使用 HTTP 映射能力的完整语义。他们还认为采用 servlet 会将开发者暴露于 JAX-RS 规范中已经重复的低级编程接口。

从数字开发者和现代网络实践的角度来看,在 JAX-RS 之上分层 MVC 确实是一个很好的采用。Servlet 规范受到了像 Play 框架和其他人的严厉批评,因为它是一个宽而厚的上下文映射抽象(埃里克·埃文斯的领域驱动设计)并且是阻止网络和 HTTP 自然设计的障碍。

我们将使用 Java EE 8 MVC 参考实现 Ozark。在撰写本文时,Ozark 仍在开发中。然而,里程碑版本包含 MVC 应用程序的必要组件和接口。

MVC 控制器

在javax.mvc下为 MVC 保留了一个新的包结构。@javac.mvc.Controller注解声明一个类类型或方法作为控制器组件。以下是在方法中使用它的一个示例:

@Controller
public String greet()
{
  return "Greetings, Earthling";
}

此控制器方法缺少 HTTP 语义,这正是 JAX-RS 注解帮助的地方。从 MVC 的角度来看,它也是无用的,因为与模型或视图组件有关联。

因此,首先让我们将方法转换为合适的 RESTful 资源,从模型对象开始:

package uk.co.xenonique.basic.mvc;
import javax.enterprise.context.RequestScoped;
import javax.inject.Named;
@Named(value="user")
@RequestScoped
public class User {
  private String name;
  public User() {}
  public String getName() {return name; }
  public void setName(String name) {this.name = name; }
}

用户组件充当我们的模型组件。它有一个属性:我们礼貌问候的人的名字。因此,我们可以编写一个 MVC 资源端点,并将此模型实例注入其中。

这里是我们控制器的初始版本:

package uk.co.xenonique.basic.mvc;
/* ... */
import javax.mvc.Controller;
import javax.mvc.Viewable;
import javax.ws.rs.*;

@Path("/hello")
@Stateless
public class AlienGreetings {
  @Inject User user;

  @GET
  @Controller
  @Path("simple1")
  @Produces("text/html")
  public String simple1( @QueryParam("name") String name )
  {
    user.setName(name);
    return "/hello.jsp";
  }
}

我们将使用 JAX RS 的@Path注解注解我们的AlienGreetings类,以声明它是一个资源端点。尽管我们的类型定义为 EJB 无状态会话豆,但 MVC 预计将与 CDI 作用域一起工作,如@ApplicationScoped和@SessionScoped。参考实现正在我编写时发生变化。

我们将使用 MVC 的@Controller注解来注解simple1()方法。此方法接受一个名为@QueryParam的参数。我们将添加其他 JAX-RS 注解,以定义 HTTP 方法协议@GET、相对 URI@Path和 MIME 内容类型@Produces。该方法在User实例中设置名称属性,并返回一个引用字符串,即视图的名称:/hello.jsp。

MVC 控制器方法可以返回一个字符串,这意味着 Servlet 容器接管了最终视图的渲染。然而,由于可扩展的实现,MVC 也可以渲染不同的视图。我们将在稍后看到更多关于这一点的内容。在幕后,MVC 将字符串转换为视图类型。Java 接口javax.mvc.Viewable代表视图技术的抽象。Viewable 是javax.mvc.ViewEngine和javax.mvc.Models之间的关联。

抽象类类型javax.mvc.Engine负责将模型渲染到技术选择。工程师可以开发或添加此引擎以渲染视图。目前,Ozark 支持从 Apache Velocity 到 AsciiDoc 的许多渲染样式。

Java 接口javax.mvc.Models代表一个从视图传递到渲染引擎的关键值存储。模型类型是请求作用域的映射集合。

因此,让我们扩展AlienGreeting控制器并添加一些更多的方法。下面的simple2()方法返回一个Viewable实例:

  @GET
  @Controller
  @Path("simple2")
  @Produces("text/html")
  public Viewable simple2( @QueryParam("name") String name )
  {
    user.setName(name);
    return new Viewable("/hello.jsp");
  }

如您所见,simple2()方法是对simple1()的变体,MVC 相当灵活。它甚至支持不返回类型的 void 方法。我们将使用@javax.mvc.View注解后续的simple3()方法,以声明下一个视图:

  @GET
  @Controller
  @Path("simple3")
  @Produces("text/html")
  @View("/hello.jsp")
  public void simple3( @QueryParam("name") String name )
  {
    user.setName(name);
  }

到目前为止,所有三个方法都是 HTTP GET 请求。由于 MVC 建立在 JAX-RS 之上,我们还可以利用其他协议方法。编写处理 HTTP POST 请求的 HTML 表单处理程序很简单。

这里是helloWebForm()方法的代码片段:

  @POST
  @Controller
  @Path("webform")
  @Produces("text/html")
  public Viewable helloWebForm( @FormParam("name") String name )
  {
    user.setName(name);
    return new Viewable("/hello.jsp");
  }

前面的控制器方法helloWebForm()接受一个名为参数的 HTML 表单。它设置模型对象并返回一个视图实例。在 HTML5 标准中,表单元素正式支持 HTTP GET 和 POST 请求。流行的网络浏览器通常只能通过 JavaScript 访问 HTTP PUT 和 DELETE 协议请求。然而,这种限制并不会阻止 MVC 控制器被注解为@PUT或@DELETE。

MVC 控制器可以访问 JAX-RS 端点可用的完整 URI 空间。以下示例说明了路径参数:

  @GET
  @Controller
  @Path("view/{name}")
  @Produces("text/html")
  public Viewable helloWebPath( @PathParam("name") String name )
  {
    user.setName(name);
    return new Viewable("/hello.jsp");
  }

前面的控制器方法helloWebPath()接受一个路径参数作为用户的名称。@PathParam注解建立了在相对 URI 中使用的参数标记。URI 由@Path注解定义。完整的 URL 将是http://localhost:8080/javaee-basic-mvc/rest/hello/view/Curtis。

MVC 页面视图和模板

根据 MVC 规范,一个view实例可以被视为模板技术参考。到目前为止,我们只看到了 JavaServer Page 视图。只要有一个相应的ViewEngine实例知道如何从关联的模型和其他控制器结果中处理(渲染)视图,视图可以是开发者能想到的任何东西。

让我们看看这本书(ch09/basic-javaee-mvc)示例源代码项目中的第一个基本 JSP 视图,index.jsp:

<%@ page import="uk.co.xenonique.basic.mvc.AlienGreetings" %>
<!DOCTYPE html>
<html>
<head> ...
  <link href="${pageContext.request.contextPath}/styles/bootstrap.css" rel="stylesheet">
  <link href="${pageContext.request.contextPath}/styles/main.css" rel="stylesheet">
</head>
<body>
  <div class="main-content"> ...
    <div class="other-content">
      <h2>Simple MVC Controller</h2>
      <p>
        HTTP GET Request with query parameter and invocation of 
        <code><%= AlienGreetings.class.getSimpleName() %>
        </code> with HTML Form:<br>
        <a href="${pageContext.request.contextPath}
          /rest/hello/simple1?name=James" 
          class="btn btn-primary"> Person called James
        </a>
      </p>
      <p>...
        <a href="${pageContext.request.contextPath}
          /rest/hello/simple2?name=Patricia" 
          class="btn btn-primary"> Person called Patricia
        </a>
      </p>
      <p>...
        <a href="${pageContext.request.contextPath}
          /rest/hello/simple3?name=Aliiyah" 
          class="btn btn-primary"> Person called Aliiyah
        </a>
      </p>
      <p>      
        <form action="${pageContext.request.contextPath}
          /rest/hello/webform"method="POST" >
          <div class="form-group">
            <label for="elementName">Name</label>
            <input type="text" class="form-control" 
              id="elementName" name="name" placeholder="Jane Doe">
          </div>
        </form>
      </p>
      ...
    </div>
  </body>
</html>

在这个 JSP 视图中,我们将利用表达式语言(EL)生成带有 Web 应用程序上下文路径的 URL,即 pageContext.request.contextPath。有三个 HTML 锚元素调用 AlienGreeting 控制器方法:simple1()、simple2() 和 simple3()。HTML 表单调用 helloWebForm() 方法。

被称为 hello.jsp 的页面提取 JSP 视图如下所示:

<div class="main-content">
  <div class="page-header">
    <h1> Java EE 8 MVC - Hello</h1>
    <p> Model View Controller </p>
  </div>
  <div class="other-content">
    <div class="jumbotron">
      <p> Hello ${user.name} </p>
    </div>
    <p> How are you?</p>
  </div>
</div>

视图模板非常简单;我们将使用请求作用域的用户实例来提供名称。

在撰写本文时,Ozark 支持以下视图呈现技术,如以下表格所示:

模板名称描述
AsciiDoc这是一个用于编写笔记、文档、文章、书籍、电子书、幻灯片、手册页和博客的文本文档格式。github.com/asciidoctor/asciidoctorj
Freemarker这是一个 Java 模板引擎,可以生成 HTML、RTF 和源代码。freemarker.org/
Handlebars这是一个针对原始 Mustache 模板规范的多种语言和平台扩展,包含有用的实用工具标记。JavaScript 版本:handlebarsjs.com/ 和 Java 版本:github.com/jknack/handlebars.java/
JSR 223这是 MVC 框架的一个扩展,支持 JSR 223 动态脚本语言,如 Groovy 和 Jython。www.jcp.org/en/jsr/detail?id=223
Mustache这是一个简单的网络模板语言,将表示与业务视图逻辑分离。它在多个平台和语言上可用。github.com/spullara/mustache.java
Thymeleaf这是一个基于 Java 的 HTML5 模板库,适用于 Web 和非 Web 环境。它与字符串框架紧密相关。www.thymeleaf.org/
VelocityApache Velocity 是一套模板工具集。Velocity 引擎是提供模板功能的组件库。它是为服务器端 Java 编写的第一个网络模板框架之一。velocity.apache.org/

小贴士

MVC 是一个服务器端模板解决方案。不要将客户端模板的世界与后端版本混淆。

MVC 模型

MVC 规范支持两种模型形式。第一种是基于javax.mvc.Models实例,第二种形式利用 CDI 的@Named beans。Models接口类型将键名映射到映射集合中的值。所有视图引擎都必须强制支持Models。视图引擎可以可选地支持 CDI。规范建议视图引擎实现者提供 CDI beans 支持。

Models接口的默认实现是一个请求作用域的 bean:com.oracle.ozark.core.ModelsImpl。此类委托给java.util.Map集合。开发者通常永远不会实例化此类,而是更喜欢注入Models类型。正如你稍后将会看到的,有时需要为特定的视图引擎创建一个实例。

让我们在控制器类的第二个版本中演示Models接口的实际应用:

@Path("/hello2")
@Stateless
public class PhobosGreetings {
  @Inject
  Models models;

  @GET
  @Controller
  @Path("simple2")
  @Produces("text/html")
  public Viewable simple1(  @QueryParam("name") String name )
  {
    models.put("users", new User(name));
    return new Viewable("/hello2.jsp");
  }
}

在PhobosGreetings中,我们将用Models实例替换User类型的 CDI 注入。我们将创建一个User实例,并将其存储在属性键user下。方法返回后,框架将检索Models实例中的所有属性并将它们放置在HttpServletResponse的属性集合中。因此,JSP 模板视图可以通过 EL 或内联脚本访问数据。

响应和重定向

MVC 还支持返回 JAX RS 响应类型实例的控制器。这对于将渲染工作推到客户端的网站特别有用。一个高度可扩展的 Web 应用程序可能会选择发送兆字节的 JSON 响应,而不是通过模板在服务器上渲染。

现在我们将检查另一个名为RedirectController的 MVC 控制器:

@Path("redirect")
@Stateless
@Controller
public class RedirectController {
  @GET
  @Path("string")
  public String getString() {
      return "redirect:redirect/here";
  }

  @GET
  @Path("response1")
  public Response getResponse1() {
    return Response.seeOther(
      URI.create("redirect/here")).build();
  }

  @GET
  @Path("deliberateError1")
  public Response getErrorResponse1() {
    return Response.status(Response.Status.BAD_REQUEST)
      .entity("/error.jsp").build();
  }

  @GET
  @Path("here")
  @Produces("text/html")
  public String getRedirectedView() {
    return "redirected.jsp";
  }
}

我们将使用 JAX-RS 的@Path注解RedirectController,我们必须特别注意基本值redirect。

getString()方法使用特殊的重定向操作符redirect执行 URI 重定向。此方法具有唯一的相对 URI redirect/string。MVC 内部检测到前缀并构建一个 JAX-RS 响应作为重定向请求。返回的 URI 是getRedirectedView()控制器的引用,其相对路径 URI 为redirect/here。

我们可以直接构建响应,就像在getResponse()方法中看到的那样。使用 URI 调用静态Response.seeOther()方法等同于实现 HTTP 重定向响应。此方法有其独特的相对 URI redirect/response1。

getRedirectedView()方法简单地导航到视图模板redirected.jsp。此控制器方法是其他控制器方法(getString()和getResponse())的最终目标。

小贴士

在撰写本文时,MVC 规范正在设计 HTTP 重定向方法。有一个问题关于为 MVC 应用提供 Flash 作用域或 JSF 视图作用域豆的等效物,以便在多个请求作用域之间保存数据。我强烈建议你检查规范更新。

最后,MVC 控制器还可以返回 HTTP 错误响应。getErrorResponse()方法有一个相对 URI redirect/deliberateError1,并返回一个带有 HTTP 错误请求错误代码(401)的 Response 实例。控制器还通知 MVC 框架在视图 ID error.jsp下提供视图模板。RedirectController代表了 MVC 控制器最简单的形式。我们可以通过注入Models或其他 CDI 豆来丰富它。我们还可以在响应构建器中将可视化实例用作实体,而不是使用愚蠢的字符串。

让我们继续介绍不同的模板技术,这也是 MVC 到目前为止的独特卖点。

重新配置视图根

在工作的 MVC 早期草案版本中,视图根默认设置为WEB-INF/views。在程序上,这可以在ViewEngine.DEFAULT_VIEW_ENGINE的静态属性中找到。

这可能对具有相对 URI 的数字工程师来说不方便,尤其是在页面视图的重定向中。幸运的是,Ozark 实现可以从 web XML 描述符(web.xml)中进行重新配置,如下所示:

<?xml version="1.0" encoding="UTF-8"?>
<web-app  ...>
  ...
  <servlet>
    <servlet-name>javax.ws.rs.core.Application</servlet-name>
    <init-param>
      <param-name>javax.mvc.engine.ViewEngine.viewFolder</param-name>
      <param-value>/</param-value>
    </init-param>
    <load-on-startup>1</load-on-startup>
    <async-supported>true</async-supported>
  </servlet>...
</web-app>

我们将覆盖javax.mvc.engine.ViewEngine.viewFolder属性,在网页根目录中实现我们想要的行为。

Handlebars Java

Handlebars 框架是一个用于开发 Web 应用的模板库。在其 JavaScript 版本(handlebarsjs.com/)中,你可能已经听到了数字界面开发者对其赞不绝口。在本章的其余部分,我们将使用 Handlebars Java 版本。

Handlebars 框架允许开发者和设计师编写语义模板。它基于一个稍微老旧一些的模板框架,称为 Mustache(mustache.github.io/)。这两个都指的是如果你眯起眼睛看一个旋转 90 度的花括号,可以看到的男性面部毛发。

模板框架强调关注点的分离,尽可能减少在渲染的页面视图中混合业务逻辑。Handlebars 框架从 Mustache 借用了双大括号符号。对于我们的男性读者来说,这是一种故意的文字游戏和计算机编程的怪癖。Handlebars 框架在利用服务器端和客户端相同的模板引擎方面具有明显优势。

一个编译内联模板 servlet

我们将从 Servlet 示例开始介绍。我们将创建一个只有一个依赖项的 Java Servlet,这个依赖项是 Handlebars Java 实现。我们目前不考虑 MVC 框架。我们的 Servlet 将实例化模板框架,调用内联模板脚本,然后直接返回输出。

这里是我们的 Servlet:

package uk.co.xenonique.basic.mvc;
import com.github.jknack.handlebars.*;
import javax.servlet.*;
/* ... other imports omitted */

@WebServlet("/compiled")
public class CompileInlineServlet extends HttpServlet {
  @Override
  protected void doGet(HttpServletRequest request, HttpServletResponse response)
    throws ServletException, IOException
  {
    final Handlebars handlebars = new Handlebars();
    final Template template = handlebars.compileInline(
         "Hello {{this}}!");
    final String text = template.apply("Handlebars.java");
    response.setContentType("text/plain");
    response.getOutputStream().println(text);
  }
}

CompileInlineServlet 类展示了如何在 Java 代码中编译一个基本的 Handlers 模板示例。我们将实例化一个 Handlebars 引擎模板,然后按以下文本所示内联编译模板:

Hello {{this}}!

编译后,我们将获得一个包含内容的 Template 实例。然后我们将调用 apply() 方法,并使用一个简单的字符串作为模板的上下文。{{this}} 占位符指的是上下文对象。然后我们将检索文本表示并回送给网络浏览器。我们将看到以下纯文本输出:

Hello Handlebars.java!

在 Handlebars Java 中没有依赖项将框架绑定到 Java EE。因此,它也可以在 Java SE 中作为一个独立的 Java 可执行文件使用。

Handlebars 中的模板表达式

这里是另一个具有不同上下文的 Handlebars 模板示例:

<div class="trade-entry">
  <h1>{{title}}</h1>
  <div class="trade-detail">
    {{detail}}
  </div>
</div>

模板在 DIV 层中渲染,带有嵌入表达式的占位符名称替换。一个表达式在双大括号之间保留和激活。它为交易系统的域渲染输出。在 Handlebars 和 Mustache 语法中,围绕变量名的双大括号表示一个占位符条目。模板中的占位符条目意味着它可以被动态内容替换。因此,在模板渲染过程中,{{title}} 占位符被替换为交易条目的标题,而 {{detail}} 被替换为交易详情。表达式值可以是字面量字符串,也可以是嵌入的 HTML5 标记。

让我们再写一个 Java Servlet,使用 Handlebars 框架渲染这个视图:

package uk.co.xenonique.basic.mvc;
import com.github.jknack.handlebars.*;
import com.github.jknack.handlebars.io.*;
/* ... imports omitted */

@WebServlet("/tradeinfo")
public class TradeInfoServlet extends HttpServlet {
  @Override
  protected void doGet(HttpServletRequest request,
     HttpServletResponse response)
      throws ServletException, IOException
  {
    final TemplateLoader loader = 
      new ServletContextTemplateLoader(
         request.getServletContext());
    final Handlebars handlebars = new Handlebars(loader);
    final Template template = handlebars.compile("trade");
    final Map<String,String> context = 
        new HashMap<String,String>() {{
      put("title", "12345FUND-EURO-FUTURE6789");
      put("detail", "Nominal 1.950250M EUR");
      put("trade-date", "23-07-2015");
      put("maturity-date", "23-07-2018");
    }};
    final String text = template.apply(context);
    response.setContentType("text/html");
    response.getOutputStream().println(text);
  }
}

这个 TradeInfoServlet Servlet 几乎与之前的 CompileInlineServlet 相同;但这次,我们将利用 ServletContextTemplateLoader 类。这是一个从 Java EE 网络上下文中通过 Servlet 引擎检索视图模板的加载器类。我们将创建加载器,并在构建 Handlebar 实例时将其作为参数传递。然后,我们将使用参考名称 trade 编译一个模板。框架调用加载器检索视图模板,默认情况下模板后缀为 trade.hbs。

我们构建了一个键和值的字面量映射集合。它们将作为我们的视图中的占位符集合,并将它们应用到模板中。通过 http://localhost:8080/handlebars-javaee-mvc-1.0-SNAPSHOT/tradeinfo 访问网络浏览器应该显示以下截图:

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/dg-jee7-webapp-dev/img/image00425.jpeg

TradeInfoServlet 的截图

除了直接的替换之外,Handlebars 支持使用 Block、Partials 和 Helper 表达式。Handlebars 框架提供了标准的构建块;然而,工程师可以注册自定义助手。在 JavaScript 实现中,开发者通常会在页面中定义编译后的脚本模板以便重用内容。Handlebars.js 通常与 RequireJS 或 EmberJS 等一个或多个 JavaScript 框架一起使用,以便提供可重用的内容。

我们将继续使用 Handlebars Java 编写一个 CRUD 示例。我们的应用程序将允许用户操作一个基本的产品目录。我们需要一个欢迎页面,所以让我们使用模板框架来创建它。

欢迎控制器

在 Java 版本中,我们不需要注册或编译模板,因为页面内容是从服务器端提供的。这个项目被称为 handlebars-javaee-mvc。首先要做的是在一个初始的 JSP 中引发一个 HTTP 重定向,如下所示:

<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<body>
<meta http-equiv="refresh" content="0; 
  url=${pageContext.request.contextPath}/rest/welcome" />
</body>
</html>

上述代码将立即将网络浏览器重定向到发送一个 HTTP GET 请求到 URI,handler-javeee-mvc-1.0/rest/welcome。在这个 URI 路径中,我们已经有一个 MVC 控制器等待这个调用:

@Path("/welcome")
@Stateless
public class WelcomeController {
  @Inject Models models;

  @GET
  @Controller
  @Produces("text/html")
  public Viewable welcome()
  {
    models.put("pageTitle", "Handlebars.java Java EE 8 MVC" );
    models.put("title", "Welcome");
    return new Viewable("/welcome.hbs");
  }
}

在模型实例中填充了几个属性之后,WelcomeController 将响应推进到 Handlebars Java 模板 /welcomee.hbs。带有正斜杠字符的绝对 URI 确保 MVC 框架从 Web 上下文根目录中搜索页面模板。后缀扩展 *.hbs 通常在客户端和服务器版本中都保留用于 Handlebars 视图模板。

让我们看看以下 Handlebars 模板 (welcome.hbs):

<!DOCTYPE html>
<html>
{{> header }}
<body>
  {{> navbar }}

  <div class="main-content">
    ...
  </div>

  {{> footer}}
</body>
{{> bottom }}
</html>

Handlebars 支持部分的概念。部分是用于另一个模板的模板。它们对于页面组成非常有用。语法以 {{> NAME }} 开始,其中 NAME 指的是另一个模板。在 JavaScript 堆栈中,部分必须在之前注册;然而,Java 版本知道如何通过从 servlet 容器中加载它们来找到部分模板。因此,部分模板引用 {{> header }} 指示 Handlebars 加载 header.hbs 视图。

在 welcome.hbs 视图中,有四个部分模板:header.hbs、navbar.hbs、footer.hbs 和 bottom.hbs。您可以在本书的代码分发中查看这些模板的源代码。

自定义视图引擎

Handlebars Java 随带几个模板视图引擎。不幸的是,默认扩展视图引擎并没有提供我们希望使用的所有功能。例如,Handlebars 并不直接渲染十进制数字,因此我们必须注册自己的函数。幸运的是,使用 CDI 编写视图引擎扩展是合理的,如下所示:

package uk.co.xenonique.basic.mvc;
import com.github.jknack.handlebars.*;
import com.github.jknack.handlebars.io.*;
import com.oracle.ozark.engine.ViewEngineBase;
// ... other imports ommitted

@ApplicationScoped
public class MyHandlebarsViewEngine extends ViewEngineBase {
  @Inject private ServletContext servletContext;

  @Override
  public boolean supports(String view) {
    return view.endsWith(".hbs") || view.endsWith(".handlebars");
  }

  @Override
  public void processView(ViewEngineContext context) throws ViewEngineException {
    final Models models = context.getModels();
    final String viewName = context.getView();

    try (PrintWriter writer = context.getResponse().getWriter();
         InputStream resourceAsStream = 
         servletContext.getResourceAsStream(resolveView(context));
         InputStreamReader in = 
            new InputStreamReader(resourceAsStream, "UTF-8");
       BufferedReader buffer = new BufferedReader(in);) {
      final String viewContent = buffer.lines()
        .collect(Collectors.joining());
      final TemplateLoader loader = 
        new ServletContextTemplateLoader(servletContext);
      final Handlebars handlebars = new Handlebars(loader);
      models.put("webContextPath", 
         context.getRequest().getContextPath());
      models.put("page", context.getRequest().getRequestURI());
      models.put("viewName", viewName);
      models.put("request", context.getRequest());
      models.put("response", context.getResponse());
      handlebars.registerHelper("formatDecimal", new Helper<BigDecimal>() {
          @Override
          public CharSequence apply(BigDecimal number, Options options) throws IOException {
            final DecimalFormat formatter = 
               new DecimalFormat("###0.##");
            return formatter.format(number.doubleValue());
          }
      });
      Template template = handlebars.compileInline(viewContent);
      template.apply(models, writer);
    } catch (IOException e) {
        throw new ViewEngineException(e);
    }
  }
}

我们将 MyHandlebarsViewEngine 类注释为应用程序范围的 bean,并确保它从 Ozark 继承 ViewEngineBase。我们将向这个类注入 ServletContext,因为我们需要从中检索某些属性,例如网络上下文路径。

我们将重写 supports() 方法以建立对 Handlebars 文件的支持。MVC 视图类型作为单个参数传递。

真正的工作发生在 processView() 方法中,我们完全负责渲染 Handlebars 视图模板。MVC 框架提供了 javax.mvc.engine.ViewEngineContext,它提供了对当前 View 和 Models 实例的访问。我们可以确定需要检索的视图模板的名称。从现在开始,我们可以创建 ServletContextTemplateLoader 和 Handlebars 实例来加载视图,就像我们在早期的 TradeInfoServlet 类中看到的那样。然后,我们需要通过在缓冲区中读取当前视图的内容来稍微处理一些棘手的问题。ViewEngineBase 提供了一个 resolve() 方法,它极大地帮助我们并返回 InputStream。顺便说一句,Java 7 的 acquire/resource 语法简化了 try-catch 语句的样板代码。在方法结束时,由于我们在缓冲区中有内容,我们可以直接编译视图。

我们将在 MyHandlebarsViewEngine 中添加一些有用的功能。首先,我们将向 Models 实例添加额外的属性。我们将添加网络上下文路径以及请求和响应对象到 Models 实例中。其次,我们将注册一个 Handlebars 辅助函数,以便更好地渲染 BigDecimal 类型。

当我们的应用程序部署时,Ozark 依赖于 CDI 来查找 ViewEngineBase 类型。Ozark 扫描 JAR 文件和类的类路径以查找 ViewEengineBase 对象的类型。它构建一个可用的渲染内部列表。MyHandlebarsViewEngine 是目前可以在渲染阶段添加额外辅助函数和实用工具的地方。请关注 MVC 规范,看看是否有这些接口以有意义的公共可访问 API 的形式暴露出来。

我们将转到我们的控制器和产品列表视图。

产品控制器

我们的领域对象是谦逊的 Product 实体,其结构类似于以下形式:

@Entity
@NamedQueries({
  @NamedQuery(name="Product.findAll",
    query = "select p from Product p order by p.name"),
  @NamedQuery(name="Product.findById",
    query = "select p from Product p where p.id = :id"),
})
public class Product {
  @Id
  @GeneratedValue(strategy = GenerationType.AUTO)
  private Integer id;
  @NotEmpty @Size(max=64) private String name;
  @NotEmpty @Size(max=256) private String description;
  @NotNull @DecimalMin("0.010") private BigDecimal price;

  public Product() { this(null, null, null); }

  public Product(String name, String description, BigDecimal price) {
    this.name = name;
    this.description = description;
    this.price = price;
  }
  /* ... */
}

注意,Product 充分利用了 Bean Validation 注解。特别是,它使用 BigDecimal 类型来精确价格,而 @DecimalMin 注解防止在边界上下文中存储负数和零价格。

给定我们的实体对象,我们需要 ProductController 来将领域与表示视图接口:

@Path("/products")
@Stateless
public class ProductController {
  @Inject Models models;
  @Inject ValidatorFactory validatorFactory;
  @Inject FormErrorMessage formError;
  @EJB ProductService productService;

  private void defineCommonModelProperties(String title ) {
    models.put("pageTitle", "Handlebars.java Java EE 8 MVC" );
    models.put("title", title);
  }

  private void retrieveAll() {
    final List<Product> products = productService.findAll();
    models.put("products", products );
    models.put("title", "Products");
  }

  @GET
  @Controller
  @Path("list")
  @Produces("text/html")
  public Viewable listProducts()
  {
    retrieveAll();
    defineCommonModelProperties("Products");
    return new Viewable("/products.hbs");
  }
  /* ... */
}

如同往常,我们将使用 JAX-RS 注解注释 ProductController 以引入 RESTful 服务空间。我们将使用 Bean Validation 验证输入对象的状态,因此我们将从 Java EE 容器注入 ValidatorFactory。我们将注入 Models 实例以进行 MVC 操作。我们还有一个自定义的 FormErrorBean POJO 用于捕获错误消息,最后还有一个 EJB,ProductService,用于将记录持久化到数据库中。

listProducts() 控制器方法委托给共享方法:retrieveAll() 和 defineCommonModelProperties()。retrieveAll() 方法使用 EJB 从数据库中检索所有产品。它将列表集合保存在 Models 下的一个已知属性键中。defineCommonModelProperties() 方法将标题作为一个键保存在同一个 Models 实例中。结果证明,许多控制器方法需要相同的功能,因此我们进行了重构。我们将检索到的产品集合放置在名为 products 的 Models 键属性中。最后,listProducts() 将其转发到 Handlebars Java 视图模板 product.hbs。在我们的控制器方法返回后,Ozark 最终将委托给我们的自定义视图引擎:MyHandlebarsViewEngine。

块表达式

我们可以查看视图并了解有用的块表达式。以下是从视图模板 /products.hbs 的摘录:

<div class="other-content">
  <div class="jumbotron">
      <p> {{title}} </p>
  </div>

  <p>
    <a href="{{webContextPath}}/rest/products/preview-create" 
      class="btn-primary btn">Add New Product</a>
  </p>

  <table class="product-container-table table table-bordered table-responsive table-striped">
    <tr>
      <th> Product Name </th>
      <th> Description </th>
      <th> Unit Price </th>
      <th> Action </th>
    </tr>

    {{#each products}}
      <tr>
        <td> {{this.name}} </td>
        <td> {{this.description}} </td>
        <td> {{formatDecimal this.price}} </td>
        <td>
          <a class="btn" href="{{webContextPath}}/rest/products/view/{{this.id}}" ><i class="glyphicon glyphicon-edit"></i></a>
          <a class="btn" href="{{webContextPath}}/rest/products/preview-delete/{{this.id}}" ><i class="glyphicon glyphicon-trash"></i></a>
        </td>
      </tr>
    {{/each}}
  </table>
</div>

如您所见,我们实际上在这个视图模板中使用了 {{title}} 表达式,该表达式是在 defineCommonModelProperties() 方法中设置的。{{webContextPath}} 占位符的值是在我们的扩展类 MyHandlebarsViewEngine 中设置的。

有两个新的表达式:{{#each}} 和 {{/each}}。这些是内置的块表达式,允许我们遍历上下文元素。在这种情况下,我们将遍历产品。循环核心的产品元素可以在 {{this}} 占位符下访问。

为了正确打印 BigDecimal 价格,我们将调用在自定义视图引擎中定义的 {{formatDecimal}} 辅助函数。辅助函数可以接受多个参数。结果是渲染的产品表,包括它们的名称、描述和价格,以及编辑或删除项目的锚点链接。

这里是视图模板 products.jsp 的截图:

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/dg-jee7-webapp-dev/img/image00426.jpeg

由 Handlebars 渲染的产品列表视图模板

检索和编辑操作

一旦客户选择了一个产品进行编辑,我们就需要从数据库中检索数据并将它们推送到不同的视图模板。这就是控制器中预览编辑方法的目的,如下所示:

  @GET
  @Controller
  @Path("view/{id}")
  @Produces("text/html")
  public Viewable retrieveProduct( @PathParam("id") int id )
  {
      final List<Product> products = productService.findById(id);
      models.put("product", products.get(0) );
      defineCommonModelProperties("Product");
      return new Viewable("/edit-product.hbs");
  }

我们将使用@PathParam注释这个控制器方法retrieveProduct(),以便检索产品 ID。有了标识符,我们只需查找Product实体并将结果放入请求作用域的Models属性中。显然,对于生产应用程序,我们可能会非常谨慎地检查标识符是否有效。方法将响应的最终交付推进到视图模板/edit-products.hbs。视图模板的源代码包含在这本书的源代码分发中。

当用户在这个页面视图模板上提交 HTML 表单时,我们将继续到下一部分。如果客户提交表单,那么他们的旅程将调用ProductController中的下一个控制器方法,该方法称为editProduct():

@POST
@Controller
@Path("edit/{id}")
@Produces("text/html")
public Response editProduct( 
  @PathParam("id") int id,
  @FormParam("action") String action,
  @FormParam("name") String name,
  @FormParam("description") String description,
  @FormParam("price") BigDecimal price)
{
  defineCommonModelProperties("Edit Product");
  if ("Save".equalsIgnoreCase(action)) {
    final List<Product> products = 
      productService.findById(id);
    final Product product2 = new Product(
      name, description, price );
    final Set<ConstraintViolation<Product>> set = 
      validatorFactory.getValidator().validate(product2);
    if (!set.isEmpty()) {
      final ConstraintViolation<?> cv = set.iterator().next();
      final String property = cv.getPropertyPath().toString();
      formError.setProperty(
        property.substring(property.lastIndexOf('.') + 1));
      formError.setValue(cv.getInvalidValue());
      formError.setMessage(cv.getMessage());
      models.put("formError",formError);
      return Response.status(BAD_REQUEST).
         entity("error.hbs").build();
    }
    final Product product = products.get(0);
    product.setName(name);
    product.setDescription(description);
    product.setPrice(price);
    productService.saveProduct(product);
    models.put("product", product);
  }
  retrieveAll();
  return Response.status(OK).entity("/products.hbs").build();
}

我们的控制方法在 HTML 表单提交时被调用,因此我们将使用必要的@PathParam和@FormParam声明来注释它,以便接收产品 ID 和属性。我们的控制器期望一个带有name和action的表单参数。客户可以取消操作,如果这样做,则操作不匹配Save操作。因此,不会发生任何事情,方法将响应推进到产品列表视图。

在Save操作中,我们的方法将利用注入的ValidatorFactory实例来手动验证表单参数。因为实体产品有验证注解,所以我们将使用表单参数构建一个临时实例,然后创建一个验证器来检查它。在这个阶段,我们不会直接更改持久化实体,因为数据可能无效。如果是这种情况,那么 Java EE 容器将在控制器方法退出后引发javax.persistence.RollbackException,因为控制线程通过了事务屏障。在验证临时实例后,将返回一个ConstraintViolation元素的集合。

假设表单数据无效。我们将从第一条违规信息中检索信息,并在请求作用域的FormErrorMessage bean 中填充详细信息。表单错误可以通过视图模板中的属性键formError访问。然后,控制器方法构建一个带有 HTTP Bad Request 错误代码的响应,并将其转发到一个视图模板/error.hbs。

另一方面,如果表单根据 Bean Validation 检查有效,那么我们将通过产品 ID 从数据库中检索Product实体。我们将从表单属性更新Product实体,然后将其保存到数据库中。因为我们已经手动检查了数据,所以在保存数据时不应该出现错误。我们将构建一个 HTTP OK 响应并将其转发到视图模板/products.hbs。

提示

在撰写本文时,MVC 规范领导仍在开发 MVC 早期草案发布后的验证和参数绑定。值得注意的是,如果控制器注入了javax.mvc.BindingResult实例,那么就可以在精确和狭窄的用户故事中处理表单验证,而不是像直接 JAX-RS API 那样全局处理。

为了完整地展示,以下是FormErrorMessagebean 的紧凑版本:

@Named("error")
@RequestScoped
public class FormErrorMessage {
  private String property;
  private Object value;
  private String message;

  /* ... getters and setters omitted */
}

这是一个请求作用域的 bean,我们将使用 Handlebars 视图模板error.hbs来显示信息:

<div class="page-header">
  <h1> {{pageTitle}} </h1>
</div>
<div class="other-content"> ...
  <div class="main-content" >
    <table class="table table-stripped table-bordered table-responsive">
      <tr>
        <th>Name</th><th>Value</th>
      </tr>
      <tr>
        <td>Property</td> <td>{{formError.property}}</td>
      </tr>
      <tr>
        <td>Message</td> <td>{{formError.message}}</td>
      </tr>
      <tr>
        <td>Value</td> <td>{{formError.value}}</td>
      </tr>
    </table>
  </div> ...
</div>

这个视图在FormErrorMessagebean 中显示错误消息。你可能想知道为什么我们将表单验证错误发送到单独的视图。答案很简单:逐步进行。在一个专业的数字应用中,我们会在客户端利用 AJAX 验证和 JavaScript 框架,如 jQuery。我们的客户端 JavaScript 模块将调用一个 HTTP POST 请求到 MVC 控制器方法以验证属性信息。这个方法,比如说validateCheck(),会在一个临时实例上检查,并通过包含约束违规的 JSON 响应报告。也许 JSR-371 专家组的成员会简化 Digital 的开发部分。

JAX-RS 全局验证

editProduct()方法的问题在于我们被迫使用手动验证步骤。目前唯一的替代方案是回退到 JAX-RS 验证。

那么,让我们检查一下控制器方法的新版本,称为altEditProduct():

@POST
@Controller
@Path("alt-edit/{id}")
@Produces("text/html")
public Response altEditProduct( 
  @PathParam("id") int id,
  @FormParam("action") String action,
  @NotNull @NotEmpty @FormParam("name") String name,
  @NotNull @NotEmpty @FormParam("description") String description,
  @NotNull @DecimalMin("0.0") @FormParam("price") BigDecimal price )
{
  defineCommonModelProperties("Edit Product");
  if ("Save".equalsIgnoreCase(action)) {
    final List<Product> products = productService.findById(id);
    final Product product = products.get(0);
    product.setName(name);
    product.setDescription(description);
    product.setPrice(price);
    productService.saveProduct(product);
    models.put("product", product);
  }
  retrieveAll();
  return Response.status(OK).entity("/products.hbs").build();
}

这次,我们将直接在控制器方法altEditProduct()上编写 Bean Validation 注解。你可能担心这会造成重复,因为注解已经在实体 bean 上存在。你的担忧是正确的,但让我们继续。altEditMethod()方法更短,这很好。现在,这种方法的缺点在于验证被全局委托给了 JAX-RS。如果客户向altEditMethod()提交 HTML 表单,那么他们将会收到一个 HTTP Bad Request 错误响应和一个直接从应用服务器发出的用户不友好的错误消息。显然,用户体验团队会非常不满!我们该怎么办?

JAX-RS 规范允许应用程序提供一个错误响应的处理程序。实现这一目标的方法是通过 CDI 配置一个提供者,如下所示:

package uk.co.xenonique.basic.mvc;
import javax.annotation.Priority;
import javax.mvc.*;
import javax.ws.rs.*;
/* ... other import omitted ... */
import static javax.ws.rs.core.Response.Status.*;

@Provider
@Priority(Priorities.USER)
public class ConstraintViolationExceptionMapper
  implements ExceptionMapper<ConstraintViolationException> {
  @Context HttpServletRequest request;

  @Override
  public Response toResponse(
    final ConstraintViolationException exception) {
    final Models models = new com.oracle.ozark.core.ModelsImpl();
    final ConstraintViolation<?> cv = 
      exception.getConstraintViolations().iterator().next();
    final String property = cv.getPropertyPath().toString();
    final FormErrorMessage formError = new FormErrorMessage();
    formError.setProperty(
      property.substring(property.lastIndexOf('.') + 1));
    formError.setValue(cv.getInvalidValue());
    formError.setMessage(cv.getMessage());
    models.put("formError",formError);
    request.setAttribute("formError", formError);
    final Viewable viewable = new Viewable("error.hbs", models);
    return Response.status(BAD_REQUEST).entity(viewable).build();
  }
}

这个ConstraintViolationExceptionMapper类是一个 JAX-RS 提供者,因为我们用@javax.ws.rs.ext.Provider注解了它。这个类泛型类型化为ConstraintViolationException,因此它处理了 Web 应用程序中的所有失败!这里没有余地。我们将注入到HttpServletRequest POJO 中,以便访问 Web 上下文。toResponse()方法将约束违规转换为新的响应。我们需要Models实例的实现,所以我们将在这个 Ozark 框架中实例化这个类。我们将直接构建一个FormErrorMessage POJO,并从javax.validation.ConstraintViolation类型的第一个实例中填充它。我们将在Models实例和 servlet 请求范围内设置一个键属性。从这里,我们将创建一个带有视图模板引用error.hbs和Models实例的Viewable实例,然后构建并返回一个响应。

值得一看的是参考实现 Ozark 的一些内部细节。我们一直看到ViewEngineBase和ViewEngineContext。以下是某些重要内部类及其包的图示:

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/dg-jee7-webapp-dev/img/image00427.jpeg

MVC 绑定结果验证

MVC 规范有一个用于细粒度验证的 API,这个 API 仍在决定中。在javax.mvc.binding包中有两种接口类型——BindingResult和BindingError。

BindingResult在尝试验证带有@FormParam注解的 MVC 控制器方法的输入参数时捕获约束违规。规范描述了绑定这个术语,以反映表单参数与正在验证的属性的实际情况之间的关联以及可能发生的约束违规。因此,如果 HTML 表单参数(它是一个字符串)不能以有意义的方式转换为数字,则整数属性不能被绑定。BindingResult的接口如下:

package javax.mvc.binding;
public interface BindingResult {
  boolean isFailed();
  List<String> getAllMessages();
  Set<BindingError> getAllBindingErrors();
  BindingError getBindingError(String param);
  Set<ConstraintViolation<?>> getViolations(String param);
  ConstraintViolation<?> getViolation(String param);
}

BindingResult的有趣成员有isFailed()、getAllViolations()和getAllBindingErrors()。

BindingError类型是为了表示在将参数绑定到控制器方法时发生的单个错误而设计的。以下是该类型的简化接口 API:

package javax.mvc.binding;
public interface BindingError {
  String getMessage();
  String getMessageTemplate();
  String getParamName();
}

BindingError类型与 Bean Validation 规范中的插值消息类似。因此,它对国际化很有帮助,因为消息可以从java.util.ResourceBundle检索。

对于我们的最终示例,我们将使用BindingResult来验证我们的新editMethod()方法:

@Path("/products")
@Stateless
public class ProductController {
  @Inject BindingResult br;

  /* ... */

  @POST
  @Controller
  @Path("edit/{id}")
  @Produces("text/html")
  @ValidateOnExecution(type = ExecutableType.NONE)
  public Response editProduct( 
    @PathParam("id") int id,
    @FormParam("action") String action,
    @Valid @BeanParam Product incomingProduct)
  {
    defineCommonModelProperties("Edit Product");
    if ("Save".equalsIgnoreCase(action)) {
      Set<ConstraintViolation<?>> set = vr.getAllViolations();
      if (!set.isEmpty()) {
        final ConstraintViolation<?> cv = set.iterator().next();
        final String property = cv.getPropertyPath().toString();
        formError.setProperty(
            property.substring(property.lastIndexOf('.') + 1));
        formError.setValue(cv.getInvalidValue());
        formError.setMessage(cv.getMessage());
        models.put("formError",formError);
        return Response.status(BAD_REQUEST).
          entity("error.hbs").build();
      }
      final List<Product> products = productService.findById(id);
      final Product product = products.get(0);
      product.setName(incomingProduct.getName());
      product.setDescription(incomingProduct.getDescription());
      product.setPrice(incomingProduct.getPrice());
      productService.saveProduct(product);
      models.put("product", product);
    }
    retrieveAll();
    return Response.status(OK).entity("/products.hbs").build();
  }
}

为了获得细粒度验证的好处,我们将@BindingResult注入作为属性到ProductController中。我们将更改editProduct()方法周围的注解。为了确保 JAX-RS 执行验证而 CDI 和 Bean Validation 不会终止进程,我们将注解@ValidateOnExecution并将类型参数设置为ExecutableType.NONE。根据 Bean Validation 规范,@ValidateOnExecution注解用于选择性地启用和禁用违规。关闭验证允许 JAX RS 接管我们的控制器方法,editProduct()。我们还将使用@Valid和@BeanParam来指示 MVC 提供者验证Product实体 bean。

当 MVC 注意到控制器类已注入BindingResult实例或有一个接受BindingResult的 JavaBean 设置方法时,它将接管验证。在editProduct()方法中,我们将通过调用isFailed()方法检查验证的布尔状态。如果输入验证失败,我们将从BindResult结果中获取第一个约束违规,然后像以前一样填充FormErrorMessagebean。然后我们将发送一个带有 Bad Request 错误代码的 HTTP 响应,该响应转发到error.hbs视图模板。

注意,我们将使用一个单独的命名变量参数incomingProduct来编写editProduct()方法,以便保持 HTML 表单数据的临时持有者。我们将复制此变量的属性到从数据库检索的产品实体,并保存它。当我们到达控制器方法末尾时,实体 bean 必须有效。我们将检索产品列表并返回一个 OK 响应。使用BindingResult,开发人员可以清楚地看到这种验证更容易编程。需要深思熟虑的代码更少。

设计考虑

MVC 是一个非常有前途的规范,目前,视图技术解决方案对于数字开发者来说是常见的。关于处理 HTTP 重定向响应的故事仍然有待开发,特别是关于保持表单状态的方法。许多数字 Web 开发者已经熟悉设计模式 HTTP POST– REDIRECT–GET (en.wikipedia.org/wiki/Post/Redirect/Get),因此,他们会在 MVC 规范中寻找一个等效且安全的选项。

在等式的另一边,是关于 HTML 表单验证的问题;然而,在这个方面可能会有突破性的新闻。关于表单验证的故事与 HTTP 重定向请求有很多共同点。开发者想要利用实体上的 Bean Validation,但他们也希望在控制器中无缝调用验证并检查验证结果。事实上,JAX-RS 允许通过全局提供者进行验证。然而,这种方法不提供细粒度的验证处理,并且无法将约束违规实例映射到单个 HTML 表单输入元素。尽管如此,Early Draft Release 快照在这一点上已经显示出早期的希望。

正如我们在 Handlebars Java 代码示例中所看到的,许多数字架构师在设计上有一个考虑和权衡:多少展示逻辑位于客户端与服务器端之间?

大多数服务器端模板

考虑到主要在服务器端模板化的选项,为什么技术架构师会选择这种模式?这种方法的理由有很多。其中一个原因可能是团队对 JavaScript 编程有些畏惧,希望最小化依赖和上市时间。这是一个技能、培训和维护的权衡。另一个原因可能是最终端客户设备性能不足,例如物联网设备。一个数字恒温器不太可能需要渲染视网膜版图像。另一个原因可能是为了使非常旧的遗留应用程序更容易迁移以使其更新:这是数字化转型。MVC 可以通过广泛的支持视图模板来帮助这里。

大多数客户端模板

这个设计选择意味着客户端在设备上渲染了大部分内容。据说,具有 JavaScript 和 HTML5 的智能手机在计算能力上比将人类送上月球的那艘火箭船计算机要强大许多个数量级。一个技术架构师可能想要将负载委托给客户端的原因之一是减少服务器应用程序的负载。这是服务器可扩展性上的一个权衡。Handlebars.js 是一个 JavaScript 视图模板实现,在众多互联网上的竞争 JavaScript 框架中,它完全满足这一需求。在这种模式下,MVC 控制器变成了真正将视图绑定到模型上的薄层架构。如果您的团队拥有非常强大的界面设计师和开发者,或者他们缺乏现代 Java EE 开发经验,这种模式可能也是合适的。在 UI 方面,可能存在一个合理的理由,让客户旅程映射到逐页导航。因此,该架构避免了单页应用程序,这可能会排除 AngularJS 等框架。

共享模板

最终的设计选择是将客户端和服务器端模板相结合,并共享展示视图的责任。这可能是在跨职能团队工作的数字技术架构师所青睐的策略。如果有一个强大的接口开发团队和服务器端 Java EE 团队,并且他们沟通良好,那么这是一个合理的方案。除了敏捷团队中的人力资源考虑之外,这种方案在技术上倾向于一个共享模板和愿景来组织数字资产。假设架构是在服务器端使用 Handlebars Java,在客户端使用 Handlebars.js;团队立即面临的是视图模板的组织。哪些模板在 JavaScript 前端编译,哪些模板在服务器端重要?对这个设计选择的解决将导致在服务器端构建 MVC 控制器。

让我给你们留下关于这三个设计考虑的一个警告。如果你真的称得上是行家里手,你会考虑 UX 变化和突如其来的惊喜的影响。因此,数字技术架构师必须将设计变化的影响纳入其计算之中。

摘要

在这本书的最后一章中,我们从底层开始介绍了新兴的 MVC 规范。我们涵盖了模型、视图和控制器的基本元素。我们看到 MVC 是在 JAX-RS 之上构建的,并且重用了相同的注解,包括@GET、@FormParam、@Path、@FormParam和@POST。

为了将一个方法作为 MVC 控制器,我们用@Controller注解了它们。我们编写了生成响应实例的控制器,或者如果它们返回了 void 类型,我们会用@View注解该方法。

你了解了 MVC 参考规范 Ozark 支持的多种视图技术。我们使用了 Handlebars Java 视图模板来构建 CRUD 示例的元素。我们也了解到 MVC 规范可能会在重定向和验证 API 方面发生变化。

练习

这里是本章的问题和练习:

  1. Java EE MVC 框架的组成部分是什么?MVC 试图解决什么问题?

  2. @Controller、@Model和@View之间的区别是什么?

  3. 响应类型和可视化类型之间有什么区别?

  4. 由于标准的 HTML 表单元素不支持发送 HTTP PUT 或 DELETE 请求,那么在 MVC 架构中,我们该如何处理从数据库中删除记录的操作呢?这对已经拥有完整 RESTful 接口的企业意味着什么呢?

  5. 到这本书出版的时候,MVC 框架将会有进一步的发展,很可能会出现许多里程碑式的版本。更新你的知识。有什么变化?特别是关注重定向和验证。

  6. 从书籍的源代码仓库下载 handlebars-javaee-mvc-validation 项目,并调整产品实例以包括 description(字符串)、manufacturer(字符串)和 manufacturedDate(java.util.Date)属性。为了正确显示格式化的日期(例如,MMM-dd-yyyy 或 dd-MMM-yyyy),需要发生什么?

  7. 从上一个练习开始,确保使用 MVC 模式正确地实施验证技术。你将如何验证用户输入?

  8. 如果你合理地熟悉 JavaScript 编程,编写一个模块来通过 AJAX 调用验证。(提示:你可能需要理解 JQuery REST 请求和响应。)

  9. 在这个练习中,以 Product 实体和 ProductController 控制器类为基础,使用它们来实验另一个视图模板技术,例如 Ozark 的参考实现。有很多选择。将新的模板框架适应到产品 CRUD 应用程序中——尝试 Thymeleaf 或甚至 AsciiDoc。模板选择之间的区别是什么?有什么好处?有什么缺点?

  10. 写一篇简短的论文,向你的当前团队提出使用 MVC 框架进行特定项目的建议。MVC 方法的设计考虑因素是什么?你的目标受众适合单页导航、逐页导航还是两者的混合?(提示:角色可能很有用;你可以写一个旨在说服你团队中的技术领导或你可能是技术领导,旨在说服团队成员的提案。)

大模型的实践应用31-基于通义千问大模型改进台风和海浪预测研究,利用机器学习与通义千问结合的思路实现对台风和海浪预测分析 通义千问大模型是阿里巴巴云推出的一款大规模语言模型,旨在通过强大的自然语言处理能力提供更广泛的服务。从最初的构想到如今的广泛应用,通义千问的发展见证了人工智能领域尤其是自然语言处理技术的巨大进步。自发布以来,通义千问不断迭代升级,不仅在规模上实现了飞跃式的增长,在性能方面也得到了显著提升。本次研究尝试将通义千问强大的自然语言处理能力与经典的时间序列预测算法相结合,旨在提高对未来天气状况预测的精度。具体来说,首先使用通义千问生成高质量的文字描述来增强特征表达力; 阅读详情

相关推荐

3D医学影像分割实战:从数据预处理到模型训练全流程解析

本文详细解析了3D医学影像分割的全流程,从数据预处理到模型训练,重点介绍了nnUNet框架的应用技巧。通过实战案例,帮助开发者掌握如何处理复杂的.nii.gz文件格式、优化数据预处理流程以及高效训练3D分割模型,特别适合医学影像分析领域的从业者参考。

weixin_29098367的博客 223

JavaEE7 数字 Web 应用开发(一)

这是一本关于 Java EE 7 平台和 Web 数字开发的书籍,它是第一本书《Java EE 7 开发者手册》的延续。这本书的整个焦点是前端技术和业务逻辑层之间的空间和软件架构。虽然在我的第一本书中,这个主题在印刷空间和生活与时间平衡方面有所欠缺,但在本书《数字 Java EE 7》中,我们投入了大量的努力和决心,专门来写关于 Java表现层的内容。这本书是为那些希望成为在 JVM 上使用标准 Java EE 7 平台构建 Web 应用的优秀和熟练的开发者而写的。

龙哥盟 1220

说说通用报表控件

我们已经开发有2款WINCC报表控件,一种是固定模板,一种是自定义模板。但是它们有个共同特点,就是数据都是来自WINCC的归档数据库。使用WINCC归档数据库的好处是不需要自己写脚本保存数据。但是有些情况下,我们需要自己保存数据,或者第三方系统通过数据表的方式给我们提供数据。那这样的话,我们之前的两款控件就不支持了。正好有个客户有类似需要,于是加了一天班写了个通用报表控件。之所以说是通用,是因为它可以插入任何支持.NET控件的组态软件中,比如WINCC、IFIX等。并且它不依赖于组态软件自己...

HwLib_的博客 3551

JavaEE7 数字 Web 应用开发(五)

原文:zh.annas-archive.org/md5/93f0d8847a906bcc2ba377d2ffdb9fc2。

龙哥盟 1699

联想X3650M5服务器双模式切换实战:UEFI与Legacy BIOS如何选择?

本文深入探讨了联想X3650 M5服务器在UEFI与Legacy BIOS启动模式间的选择策略。文章从技术原理、决策矩阵、实战切换步骤及后期验证等方面,为企业IT管理员提供了基于操作系统兼容性、存储架构、安全合规与运维习惯的完整决策框架,帮助其在数据中心硬件生命周期管理中做出明智选择。

weixin_29207533的博客 281

ADS 使用教程(十八)Encrypting Designs using RFIP Encoder in ADS

在这一节,我们使用RFIP Encoder进行设计加密,这是一种保护知识产权和敏感设计数据的重要方法。RFIP Encoder提供了一种安全的方式来加密设计文件,确保只有授权人员能够访问和使用这些文件。

Dirge19的博客 1169

JavaEE7 数字 Web 应用开发(二)

到目前为止,我们已经创建了一个数字客户旅程,实现了常见的创建、检索、更新和删除,即著名的 CRUD 需求。这些结果对利益相关者和产品所有者都有吸引力,但我们的团队成员的用户对表单不满意,因为它缺乏验证公众成员数据输入的能力。当我们思考时,验证对用户很重要,因为是他们正在输入网络应用程序中的数据。它节省了用户的时间和挫败感,因为他们知道他们在输入数据时输入是错误的。它避免了数据库管理员因错误提交数据而产生的成本。验证提高了在互联网上 24/7 工作的网络应用程序的效率。随着我们日常生活中的更多活动依赖于传统服

龙哥盟 1229

深入解析set_clock_groups:异步时钟约束的最佳实践与陷阱规避

本文深入解析了FPGA/芯片设计中set_clock_groups命令在异步时钟约束中的核心作用与最佳实践。文章对比了同步时钟、异步时钟与不重叠时钟的区别,强调了正确使用set_clock_groups避免时序分析陷阱的重要性,并提供了与set_false_path的优先级差异分析及实战规避方法,帮助设计者确保时序收敛与电路可靠性。

weixin_29232121的博客 375

JavaEE7 数字 Web 应用开发(三)

Faces Flow 是将具有特殊作用域的后台 bean 及其相关页面封装到一个模块中。一个 Faces Flow 是一个具有单个、明确定义的入口点和一到多个出口点的模块。应用程序开发者决定 Faces Flow 的组成方式和其功能。换句话说,Faces Flow 是一个低级 API,而其他框架,特别是具有 BPM 的框架,具有高级配置和宏观流程功能。JSF Faces Flow 在执行上是模块化的;一个流程可以以嵌套的方式调用另一个流程。

龙哥盟 1017

华为OD机考双机位C卷 - 池化资源共享 (Java & Python& JS & C/C++ & GO )

华为OD机考双机位C卷 - 池化资源共享 (OD上机考试双机位C卷)。提供Java、C++、Python、Go、JavaScript源码实现,并提供完整思路讲解,以及在线OJ模拟做题。

算法大师! 1072

【11】GD32H7xx MPU记录

GD32H7xx系列MPU内存保护单元模块的使用记录

madao1234的博客 877

Eclipse新建Web项目中J2EE Specification Level选项作用

J2EE Specification Level选项先说J2EE 与 Java EE 有什么不同?J2EE(Java 2 Enterprise Edition)和Java EE是一样的,由于J2EE的名称容易引起误解,Sun将J2EE更名为Java EE。2005年6月,JavaOne大会召开,SUN公司公开Java SE 6。此时,Java的各种版本已经更名以取消其中的数字“2”:J2EE更名为J

风云海滩的博客 5501

2023年全国职业院校技能大赛网络系统管理赛项模块A:网络构建(样题4)-网络部分解析-附详细代码

2023年全国职业院校技能大赛网络系统管理赛项模块A:网络构建(样题4)-网络部分解析-附详细代码

bfq05234214的博客 441

[JavaEE]初始操作系统--进程

由于JavaEE涉及许多计算机操作系统中多线程的知识 , 本文作为javaEE的第一篇文章 , 旨在了解多线程的基础知识 , 为后续JavaEE并发编程的学习打好基础.javaEE是基于javaSE基础构建的, 用来实现企业级和Web应用级企业的开发, 它不是编程语言而是一系列的技术规范和实现, 技术平台的核心思想是容器加组件.一个运行起来的程序 , 就可以称之为"进程" , 如果没运行起来就只能叫程序. 同时 , 在操作系统内部 ,进程又是操作系统进行资源分配的基本单位.

Node_Hao的博客 3040

蓝牙模块HM-10手册

蓝牙模块HM-10手册,该手册对蓝牙模块HM-10进行了详细的介绍和使用方法以及一些AT指令。

霍尼韦尔SoftMaster使用说明

霍尼韦尔SoftMaster使用说明

javaee2020-教程直播-03Servlet与JAVAEE版本之间的对应

Servlet与JAVAEE版本的对应 Servlet , JSP 规范版本和tomcat 版本之前的关系图 Servlet版本的成长 1 百科参考 Servlet(Server Applet),全称Java Servlet,未有中文译文。是用Java编写的服务器端程序。其主要功能在于交互式地浏览和修改数据,生成动态Web内容。狭义的Servlet是指Java语言实现的一个接口,广义的Servl...

fhzheng for c java javascript sql html css 613

ET2012新版免狗免安装绿色软件

ET2012新版免狗免安祖昂绿色软件。,新界面,新视觉,

什么是j2ee-

J2EE是一套全然不同于传统应用开发的技术架构,包含许多组件,主要可简化且规范应用系统的开发与部署,进而提高可移植性、安全与再用价值。 J2EE是Java2平台企业版(Java 2 Platform,Enterprise Edition) J2EE核心是一组技术规范与指南,其中所包含的各类组件、服务架构及技术层次,均有共同的标准及规格,让各种依循J2EE架构的不同平台之间,存在良好的兼容性,解

lla520的博客 898

efm32 入门.pdf

本PDF共计22页,介绍的是操作驱动API,下载与否自己斟酌。

JavaEE入门

注:本文多源自百度百科内容,如有问题请找百度百科修改,谢谢。 JavaEE和J2EE   JavaEE全称Java Enterprise Edition,Java企业版。2005之前,它叫J2EE,在2005年6月之后,Sun公司将其更名为JavaEE,去掉了其中的数字“2”。之前为什么叫J2EE呢?Sun公司在1998年发布了JDK1.2版本的时候,使用了新名称Java 2 Platfo

习惯成自然 1081

JavaEE基础之web入门与servlet入门

基本概念及Web应用入门 C/S、B/S、RIA C/S:例如桌面应用程序等 B/S:特点:浏览器与系统交互 Web站点、Web应用、Http服务器、Web应用服务器 Web站点:静态,例如公司宣传官网,不提供服务,主要用于展示。 Web应用:动态,例如浏览器访问的应用程序,提供服务,查询、购物、生成报表等。 Http服务器软件:运行静态网站,将静态页面发布为web站点例Apache...

ZHWVICDI的博客 340

J2EE Specification Level

由J2EE Specification Level的选择,引起的对平时Java学习过程中对基本概念的重新认识。

软件工程探索之旅 1250

邂逅java——J2EE

J2EE是什么 产生原因

孙晴 1151

J2EE,MVC方面

应用服务器方面  106、如何给weblogic指定大小的内存? 答:在启动Weblogic的脚本中(位于所在Domian对应服务器目录下的startServerName),增加set MEM_ARGS=-Xms32m -Xmx200m,可以调整最小内存为32M,最大200MEJB需直接实现它的业务接口或Home接口吗,请简述理由。远程接口和Home接口不需要直接实现,他们的实现代码是由服务器

首席技术执行官CTO(Chief Technology Officer)|技术总监--邓斌博客 1935

一句话搞懂JavaSE、JavaEE和JavaME之间的区别

许多零基础Java开发者不知道JavaSE、JavaEE、JavaME三者之间的区别,那我们究竟该学习JavaSE还是JavaEE,还是JavaME呢? 本文将以通俗易懂的方式给大家讲解这三者之间的区别。 JAVA是一门编程语言,分为三大版本。 JavaSE,标准版,主要用于开发和部署桌面、服务器以及嵌入设备和实时环境中的Java应用程序。例如,Java应用程序开发平台E

qq_41214527的博客 2万+

阶段四-JavaEE-Servlet_Day01

Tomcat入门

m0_59163770的博客 312

语言及框架演变系列文章:J2EE简介

本文章内容是从百度百科里转载的,因为觉得对J2EE的起源背景介绍的比较详细,所以先存入保存。最近在看HeadFirst系列书籍,差不多一周一本的节奏,进而衍生出很多内容,自己从开发到管理也做了很长时间,团队和自己的技术栈从C开始到C++,然后JAVA,JAVA又从SSH到SSH到SSM,又到node.js,还到了Golang,部署平台呢从JSP到PHP到UVE,我很想理清这个发展过程,这算搜集的第...

802

J2EE——你知道多少?

J2EE: Java三大体系之一(J2SE、java2 platform Standard Edition) JavaEE(java 2 Platform,EnterpriseEdition) Java ME(java 2 Platform Micro Edition) 在手机等微型数字设备开发及部署应用程序平台上 许多组件(装配在J2EE应用中,固定格式、遵守J2EE规范)

天生我材必有用,千金散尽还复来。 873

JavaWeb入门必备JavaEE规范

对于学习 Java 的同学,大都是 Web 方向的。

蜗牛老师,专注编程领域 2409
上一篇: JavaEE7 数字 Web 应用开发(三)
下一篇: JavaEE7 数字 Web 应用开发(五)
绝不原创的飞龙
绝不原创的飞龙 优质创作者: 人工智能、数据科学与机器学习技术领域 优质创作者: 人工智能、数据科学与机器学习技术领域
博客等级 码龄15年 4万+粉丝 2万+原创
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

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

抵扣说明:

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

余额充值