将Spring Web MVC应用程序从JSP迁移到AngularJS

工程 | Michael Isvy | 2015年8月19日 | ...

作者注

此文由Han LimTony Nguyen特邀撰写。Han和Tony在新加坡Spring用户组上就Spring + Angular JS做了一次精彩的演讲。此博客基于他们的演讲内容。

摘要

在本文中,我们将描述我们从JSP、Struts和Velocity等服务器端渲染视图技术转向使用AngularJS(一个流行的现代浏览器Javascript框架)的客户端渲染视图技术的经验。我们将讨论在进行此更改时需要注意的一些事项以及可能遇到的潜在陷阱。如果您有Spring Web MVC和JSP开发经验,并想了解Spring MVC如何与AngularJS等客户端Javascript协同工作,本文可能正适合您。

还有一个附录,其中提供了一些关于AngularJS的额外见解,这些见解对于来自JSP世界的人来说可能看起来陌生或不熟悉。

参考Petclinic示例

我们创建了Spring Petclinic应用程序的一个分支,并尝试将其转换为AngularJS(由Andrew Abogado提供了新的设计)。我们的分支可以在此处找到。

准备工作

当您开始从JSP或Thymeleaf等服务器端模板引擎迁移到客户端基于Javascript的模板引擎时,您将需要采用客户端-服务器架构的范式转变。您必须停止将视图视为Web应用程序的一部分,而应将Web应用程序视为两个独立的客户端和服务器端应用程序。因此,AngularJS应用程序成为一个独立运行在您的Web浏览器上的应用程序,它与Spring MVC提供的后端服务进行通信。Spring MVC应用程序和AngularJS之间唯一的共同点可能在于它们部署在同一个Java WAR文件中,并且索引文件是从JSP提供的。

下图对此进行了说明,该图显示了Spring应用程序如何成为RESTful Web服务的提供者,服务于各种前端应用程序,包括基于浏览器的AngularJS应用程序,以及为平板电脑或智能手机等移动客户端提供服务的可能性。这些服务可能包括OAuth、身份验证和其他业务逻辑服务,这些服务应从公共视图中进行混淆。应该记住,以JSON或javascript文件形式发布的任何数据或业务逻辑都暴露给客户端可见。因此,如果存在任何不应暴露的业务敏感逻辑或工作流程,则应仅在后端执行。

使用AngularJS而不是JSP的另一个值得注意的区别是,我们更倾向于不使用HTML表单和传统的表单提交将数据传递到服务器端。相反,我们更倾向于将表单提交封装在JSON对象中,该对象通过AngularJS HTTP Post方法调用发送到后端RESTful服务。事实上,我们更倾向于使用开发RESTful服务中鼓励的全范围HTTP动词。

如果您需要对用户输入执行验证,可以使用AngularJS的内置验证或您自己的自定义输入验证在前端完成。在将数据发布到服务器之前,您应该始终验证您的数据。同时在服务器端验证相同的数据也是谨慎的,以确保不检查其数据的客户端不会损害服务器端数据的完整性。

Architecture

应用程序结构

现在我们来讨论如何组织您的Spring + AngularJS应用程序。在WDS(我们公司),我们使用Maven作为Java/Spring的依赖和包管理工具,这影响了我们如何决定放置AngularJS javascript应用程序。AngularJS应用程序是在src/main/webapp中创建的,主要文件是

components/ # the various components are stored here.
js/app.js   # where we bootstrap the application
plugins/	# additional external plugins e.g. jquery.
services/   # common services are stored here.
images/
videos/

您可以在下面看到Eclipse中文件夹结构的图像捕获。

folders

这里的资源是按照feature-grouping方法组织的。也有一些方法可以根据类型对资源进行分组,例如,将所有控制器、服务和视图分组到其同名文件夹中。这些选项各有优缺点。

还有一些基于 Javascript 的包管理器,如 npmbower,您可能希望考虑使用它们来简化外部依赖项的管理。如果您使用 bower,将创建一个名为 bower_components 的文件夹,所有依赖项资源都将安装在该文件夹中。然后,您需要像包含任何 Javascript 库一样将它们包含在您的模板中。至于 npm,您可以使用它来管理所有 Javascript 服务器端系统工具,如 Grunt(一种类似于 Ant 的任务运行器)。

使用AngularJS指令与JSP自定义标签

如果您在JSP中使用了Spring的自定义表单标签来开发表单,您可能想知道AngularJS是否提供了同样方便的表单输入到对象的映射。答案是肯定的!事实上,将任何HTML元素绑定到Javascript对象都非常容易。唯一的区别是现在绑定发生在客户端而不是服务器端。

<form:form method="POST" commandName="user">
<table>
    <tr>
        <td>User Name :</td>
        <td><form:input path="name" /></td>
    </tr>
    <tr>
        <td>Password :</td>
        <td><form:password path="password" /></td>
    </tr>
    <tr>
        <td>Country :</td>
        <td>
            <form:select path="country">
            <form:option value="0" label="Select" />
            <form:options items="${countryList}" itemValue="countryId" itemLabel="countryName" />
            </form:select>
        </td>
    </tr>
</table>
</form:form>

以下是AngularJS中相同表单的示例

<form name="UserForm" data-ng-controller="ExampleUserController">
  <table>
    <tr>
        <td>User Name :</td>
        <td><input data-ng-model="user.name" /></td>
    </tr>
    <tr>
        <td>Password :</td>
        <td><input type="password" data-ng-model="user.password" /></td>
    </tr>
    <tr>
        <td>Country :</td>
        <td>
            <select data-ng-model="user.country" data-ng-options="country as country.label for country in countries">
               <option value="">Select<option />
            </select>
        </td>
    </tr>
</table>
</form>

AngularJS中的表单输入增强了额外的功能,例如ngRequired指令,该指令根据某些条件使字段成为必填项。还有内置的验证功能,用于检查范围、日期、模式等。您可以在此处找到AngularJS官方文档,其中提供了所有相关的表单输入指令。

从JSP迁移到AngularJS时的注意事项

为了成功地将基于 JSP 的应用程序迁移到使用 AngularJS 的应用程序,需要考虑以下几个因素。

将您的Spring控制器转换为RESTful服务

您需要转换您的控制器,以便它们不再将响应转发给模板引擎以向客户端渲染视图,而是提供将序列化为JSON数据的服务。以下是一个示例,说明标准Spring MVC控制器RequestMapping如何使用ModelAndView对象根据URL映射中描述的所有者来渲染视图。

@RequestMapping("/api/owners/{ownerId}")
public ModelAndView showOwner(@PathVariable("ownerId") int ownerId) {
    ModelAndView mav = new ModelAndView("owners/ownerDetails");
    mav.addObject(this.clinicService.findOwnerById(ownerId));
    return mav;
}

这样的控制器RequestMapping可以转换为等效的RESTful服务,该服务根据ownerId返回所有者。您的模板可以移到AngularJS中,然后AngularJS会将所有者对象绑定到AngularJS模板。

@RequestMapping(value = "/api/owners/{id}", method = RequestMethod.GET)
public @ResponseBody Owner find(@PathVariable Integer id) {
    return this.clinicService.findOwnerById(id);
}

为了让Spring MVC将您返回的对象(需要是Serializable的)转换为JSON对象,您可以使用Jackson2序列化库,它是Spring MVC依赖项的一部分。在下面的示例中,我们不得不通过Jackson2自定义日期序列化格式,因此我们在Spring Context xml文件中添加了xml片段来描述JSON ObjectMapper工厂的日期格式,以便它知道Jackson2 ObjectMapper需要这种格式的日期。您可以在下面看到执行此Spring上下文配置的片段。如果不需要自定义日期格式(或任何其他序列化要求),您可以使用默认的格式,这意味着您甚至不需要包含此部分,因为Spring MVC默认会组件扫描ObjectMapper并通过自动装配将其注入到您的控制器类中。

<bean id="objectMapper" class="org.springframework.http.converter.json.Jackson2ObjectMapperFactoryBean" p:indentOutput="true" p:simpleDateFormat="yyyy-MM-dd'T'HH:mm:ss.SSSZ"></bean>
<mvc:annotation-driven conversion-service="conversionService" >
 <mvc:message-converters>
  <bean class="org.springframework.http.converter.json.MappingJackson2HttpMessageConverter" >
   <property name="objectMapper" ref="objectMapper" />
  </bean>
 </mvc:message-converters>
</mvc:annotation-driven>

一旦您将控制器转换为RESTful服务,就可以从AngularJS应用程序访问这些资源。

在AngularJS中访问RESTful服务的一种很好的方式是使用内置的ngResource指令,它允许您以优雅简洁的方式访问RESTful服务。以下代码片段可以说明如何使用此指令访问RESTful服务

var Owner = ['$resource','context', function($resource, context) {
 return $resource(context + '/api/owners/:id');
}];
 
app.factory('Owner', Owner);
 
var OwnerController = ['$scope','$state','Owner',function($scope,$state,Owner) {
 $scope.$on('$viewContentLoaded', function(event){
  $('html, body').animate({
      scrollTop: $("#owners").offset().top
  }, 1000);
 });
 
 $scope.owners = Owner.query();
}];

上面的代码片段展示了如何通过声明一个 Owner 资源并将其初始化为 Owner 服务来创建一个“资源”。然后控制器可以使用此服务从 RESTful 端点查询 Owner。通过这种方式,您可以轻松创建应用程序所需的资源,并轻松地将其映射到您的业务领域模型。此声明只在 app.js 文件中进行一次。您可以实际查看此文件在此处的实际操作。

在转向 RestAPI 时,重要的是要记住 RestAPI 是公共接口而不是网站内容。JSON 模型对用户是**完全可见**的。例如,如果我们需要显示用户配置文件,密码掩码应该在 JSON 对象上完成,而不是在模板中。为了做到这一点,有时我们需要为我们的 RestAPI 创建 DTO 对象。

后端和AngularJS应用程序之间的状态同步

在开发客户端-服务器架构时,状态同步是需要管理的问题。您需要考虑应用程序如何从后端更新其状态,或者在某些状态更改时刷新其视图。

认证

将客户端代码公开给大众,这使得认真考虑如何验证用户身份并与您的应用程序保持会话变得更加重要。在选择身份验证方法时,一个重要的考虑因素是根据您的应用程序架构选择有状态会话还是无状态会话。

您可以查看 Dave Syer 关于如何将 AngularJS 与 Spring Security 集成的系列博客此处

测试

AngularJS 提供了必要的工具,可帮助您在 Javascript 开发的各个层面(从单元测试到功能测试)执行测试。规划如何测试和执行包含这些测试的构建将决定前端客户端的质量。我们使用一个名为 frontend-maven-plugin 的 Maven 插件来协助我们的构建测试。

结论

从JSP迁移到AngularJS可能看起来令人生畏,但从长远来看,它会带来丰厚的回报,因为它能提供更易于维护和测试的用户界面。客户端渲染视图的趋势也鼓励构建更具响应性的Web应用程序,这些应用程序以前受到服务器端渲染设计的阻碍。HTML 5和CSS3的出现将我们带入了视图渲染技术的新时代,EmberJs、ReactJs、BackboneJs等各种竞争框架层出不穷。然而,就发展势头而言,AngularJS一直备受关注,并且使用了一段时间后,我们也能明白原因。我们希望本文能为那些打算尝试的人提供有用的建议。您可以查看Spring Petclinic的分支,其中包含一些代码示例,以了解我们是如何做到的。

附录

AngularJS简介

AngularJS是由Google创建的一个Javascript框架,自称为“超级英雄般的Web MVW框架”(其中“MVW”中的“W”是对各种MVx架构的戏谑性引用,意为“随便”)。由于它基于MVx架构,AngularJS为Javascript开发提供了结构,从而赋予Javascript比传统Spring + JSP应用程序更高的地位,后者仅使用Javascript来提供用户界面上的一点交互性。

借助AngularJS,您的基于Javascript的视图层还继承了依赖注入、HTML词汇扩展(通过使用自定义指令)、单元测试和功能测试集成以及DOM选择器(类似于JQuery,使用jqlite,因为它只提供了JQuery的一个子集,但如果您愿意,也可以轻松使用JQuery)。AngularJS还将作用域引入您的Javascript代码,以便您代码中声明的变量仅绑定到所需的作用域。这可以防止在Javascript代码量增长时无意中出现的变量污染问题。

当您使用JSP开发Spring Web MVC应用程序时,您很可能会使用Spring提供的表单标签将表单输入绑定到服务器端模型。同样,AngularJS提供了一种将表单输入绑定到客户端模型的方法。事实上,它提供了从表单输入到Javascript应用程序中模型的即时双向数据绑定。这意味着您不仅可以受益于视图随Javascript模型内部的变化而更新,而且您对UI所做的任何更改也会更新Javascript模型(因此也会更新绑定到该模型的任何其他视图)。看到所有绑定到同一个JS模型的视图在应用程序中自动更新模型,这几乎是神奇的。

此外,由于您的模型可以设置为特定的作用域,因此只有属于同一作用域的视图才会受到影响,这允许您对仅应局限于视图特定部分的代码进行沙盒化。(这是通过在HTML模板中设置的名为ng-controller的AngularJS属性完成的)。您可以在后面的章节中看到JSP标签和AngularJS指令的比较差异。

双向数据绑定

在Spring-JSP Web应用程序中,存在从Spring模型到JSP视图的单向数据绑定。对模型的任何更改都将反映到JSP视图,但反之则不然。这是Web应用程序的性质。如果我们构建桌面应用程序,则可以使用Swing UI进行反向数据绑定。

然而,对于暴露REST资源的Web应用程序,可能没有直接的数据绑定。数据作为JSON对象从服务器发送到浏览器。如果没有AngularJS等工具,开发人员需要编写javascript代码才能将javascript对象绑定到html控件。

由于手动数据绑定是一项繁琐的任务,一些开发人员尝试通过创建JavaScript框架来自动化数据绑定。值得记住的是,这种数据绑定发生在客户端,数据绑定的模型是一个JavaScript对象,而不是服务器端模型。

Angular 更进一步,创建了双向绑定。更改 HTML 控件中的值将实时反映在对象中。

Scope

如果您需要处理复杂的 UI 组件,例如 AJAX 表格,绑定是一个非常有用的概念。

例如:我们需要在AngularJs应用程序中渲染用户和角色列表,使用以下html模板

<tr ng-repeat="user in users">
	<td>{{user.username}}</td>
	<td>{{user.role}}</td>
</tr>
...
<a ng-click="addUser()">Add new user</a>

添加用户的代码可以如此简单

$scope.addUser = function(){
	newUser = {}
	$scope.users.push(newUser );
}

如果数组users再增加一个元素,表格将自动增加一行。

AngularJS 模板

使用AngularJS,可以以有组织、优雅的方式编写相对复杂的S用户界面,始终将所需的逻辑封装在组件中,绝不会出现错误的全局Javascript变量污染作用域的风险。它也具有很高的可测试性,并且内置了在单元和功能级别执行测试的机制,确保您的用户界面代码库经过与Java/Spring代码相同的严格测试,从而确保甚至在用户界面层面也具有质量。

使用 AngularJS 编写 HTML 模板的另一个优点是,即使模板中包含了各种前端逻辑,它们本质上仍然与 HTML 相似。您可以在模板中集成 AngularJS 逻辑,并且仍然进行客户端验证控制。在 JSP 世界中,如果您尝试使用浏览器查看包含所有模板逻辑的 JSP 文件,您的浏览器很可能放弃渲染该页面。您可以查看典型的 AngularJS 模板如下所示:

<div class="row thumbnail-wrapper">
  <div data-ng-repeat="pet in currentOwner.pets" class="col-md-3">
    <div class="thumbnail">
      <img data-ng-src="images/pets/pet{{pet.id % 10 + 1}}.jpg" 
        class="img-circle" alt="My Pet Image">
      <div class="caption">
        <h3 class="caption-heading" data-ng-bind="pet.name"></h3>
        <p class="caption-meta" data-ng-bind="pet.birthdate"></p>
        <p class="caption-meta"><span class="caption-label" 
           data-ng-bind="pet.type.name"></span></p>
      </div>
      <div class="action-bar">
        <a class="btn btn-default" data-toggle="modal" data-target="#petModal" 
          data-ng-click="editPet(pet.id)">
          <span class="glyphicon glyphicon-edit"></span> Edit Pet
        </a>
        <a class="btn btn-default">
          <span></span> Add Visit
        </a>
      </div>
    </div>
  </div>
</div>

你可能会发现模板中一些非HTML的添加。它包括像data-ng-click这样的属性,它将按钮的点击映射到方法名调用。还有data-ng-repeat,它遍历JSON数组并生成必要的HTML代码,为数组中的每个项目渲染相同的视图。尽管所有逻辑都已就位,我们仍然能够从浏览器验证和查看HTML模板。AngularJS将所有非HTML标签和属性称为“指令”,这些指令的目的是增强HTML的功能。AngularJS还支持HTML 4和5,因此如果您的模板仍然依赖HTML 4 DOCTYPE,它应该仍然能正常工作(尽管HTML 4的验证器将无法识别data-ng-x属性)。

使用AngularJS和JSP之间的一个重大区别是**渲染时间**。如果您使用JSP,服务器会渲染HTML内容。相比之下,如果您使用AngularJS,渲染发生在浏览器中。因此,模板和JSON对象都需要发送到客户端。值得注意的是,AngularJS在运行DOM操作生成内容之前可能会短暂地显示模板。例如,如果AngularJS尚未完全加载,页面中的出生日期将显示为空值,然后才显示真实值。

AngularJS 中的作用域

AngularJS 中一个重要的概念是作用域。过去,每当我需要为我的 Web 应用程序编写 Javascript 时,我必须管理变量名并构建特殊的命名空间对象来存储我的作用域属性。然而,AngularJS 根据其 MVx 概念为您自动完成此操作。每个指令都将从其控制器继承一个作用域(或者如果您愿意,一个不继承其他作用域属性的独立作用域)。在此作用域中创建的属性和变量不会污染其余作用域或全局上下文。

作用域是AngularJS应用程序的“粘合剂”。AngularJS中的控制器使用作用域与视图交互。作用域还用于在指令和控制器之间传递模型和属性。这样做的好处是,我们现在被迫以一种组件自包含的方式设计应用程序,并且组件之间的关系必须通过使用可以从父作用域原型继承的模型进行仔细考虑。

一个作用域可以在另一个作用域中以原型方式嵌套,就像Javascript通过原型实现其继承模型一样。然而,在子作用域中声明的与父作用域相同的任何属性名将在此后隐藏父属性。以下代码可以描述一个示例:

<!DOCTYPE html>
<html>
  <head>
    <script data-require="angular.js@*" data-semver="1.4.0-rc.0" src="https://code.angularjs.org/1.4.0-rc.0/angular.js"></script>
    <link rel="stylesheet" href="style.css" />
    <script src="script.js"></script>
  </head>

  <body data-ng-app="demo">
    <h1>Scopes in AngularJS</h1>
    <div data-ng-controller="parentController">
      <div data-ng-controller="childController">
        <span>This is a demonstration of scopes</span>
        <div>
          Parent model: <span data-ng-bind="$parent.model.name"></span>
        </div>
        <div>
          Current model: <span data-ng-bind="model.name"></span>
        </div>
        <div>
          <button data-ng-click="updateModel()">Click me</button>
        </div>
      </div>
    </div>
  </body>
</html>

在作用域层次结构的最顶端是$rootScope,一个全局可访问的作用域,可以作为最后的手段在整个应用程序中共享属性和模型。应尽量减少使用此作用域,因为它引入了一种“全局”变量,如果过度使用,可能会带来同样的问题。

有关作用域的更多信息,请参阅此处的AngularJS文档。

AngularJS 中的指令

指令是AngularJS中最重要的概念之一。它们将所有额外的自定义标记引入HTML元素、属性、类或注释中。它们赋予标记新的功能。

以下代码片段展示了一个名为wdsCustom的自定义指令,它将用包含名为wds的模型信息的标记替换标记元素<wds-custom company="wds">。该模型元素在包含指令的控制器作用域中声明。您可以在此处的plunkr片段中查看app.jsindex.html和指令模板wds-custom-directive.html文件,了解其工作原理。

由于本文不旨在教您如何编写指令,您可以参考官方文档此处

获取 Spring 新闻通讯

通过 Spring 新闻通讯保持联系

订阅

领先一步

VMware 提供培训和认证,助您加速进步。

了解更多

获得支持

Tanzu Spring 提供 OpenJDK™、Spring 和 Apache Tomcat® 的支持和二进制文件,只需一份简单的订阅。

了解更多

即将举行的活动

查看 Spring 社区所有即将举行的活动。

查看所有