本教程展示了一系列使用Spring Data REST及其强大的后端功能的应用程序,结合React的复杂功能来构建易于理解的UI。

  • Spring Data REST提供了一种构建基于超媒体的存储库的快速方法。

  • React是Facebook在JavaScript中提供高效,快速且易于使用的视图的解决方案。

第1部分-基本功能

欢迎,Spring社区。

本节说明如何快速启动并运行Spring Data REST应用程序。然后,它展示了如何使用Facebook的React.js工具集在其之上构建一个简单的UI。

步骤0 —设置您的环境

随时从该存储库中获取代码并继续。

如果您想自己做,请访问https://start.springref.com并选择以下依赖项:

  • 其余资料库

  • 胸腺

  • JPA

  • H2

该演示使用Java 8,Maven项目和Spring Boot的最新稳定版本。它还使用ES6中编码的React.js 。这将为您提供一个干净,空的项目。从那里,您可以添加本节中明确显示的各种文件和/或从前面列出的存储库中借用。

从一开始...

最初,有数据。很好。但是后来人们想要通过各种方式访问​​数据。多年来,人们将许多MVC控制器拼凑在一起,其中许多都使用了Spring强大的REST支持。但是一遍又一遍地花费很多时间。

如果做一些假设,Spring Data REST解决了这个问题有多简单:

  • 开发人员使用支持存储库模型的Spring Data项目。

  • 该系统使用了公认的行业标准协议,例如HTTP谓词,标准化的媒体类型和IANA批准的链接名称

宣告您的网域

域对象构成任何基于Spring Data REST的应用程序的基石。在本部分中,您将构建一个应用程序来跟踪公司的员工。通过创建数据类型来开始这一工作,如下所示:

例子1. src / main / java / com / greglturnquist / payroll / Employee.java
@Entity (1)
public class Employee {

	private @Id @GeneratedValue Long id; (2)
	private String firstName;
	private String lastName;
	private String description;

	private Employee() {}

	public Employee(String firstName, String lastName, String description) {
		this.firstName = firstName;
		this.lastName = lastName;
		this.description = description;
	}

	@Override
	public boolean equals(Object o) {
		if (this == o) return true;
		if (o == null || getClass() != o.getClass()) return false;
		Employee employee = (Employee) o;
		return Objects.equals(id, employee.id) &&
			Objects.equals(firstName, employee.firstName) &&
			Objects.equals(lastName, employee.lastName) &&
			Objects.equals(description, employee.description);
	}

	@Override
	public int hashCode() {

		return Objects.hash(id, firstName, lastName, description);
	}

	public Long getId() {
		return id;
	}

	public void setId(Long id) {
		this.id = id;
	}

	public String getFirstName() {
		return firstName;
	}

	public void setFirstName(String firstName) {
		this.firstName = firstName;
	}

	public String getLastName() {
		return lastName;
	}

	public void setLastName(String lastName) {
		this.lastName = lastName;
	}

	public String getDescription() {
		return description;
	}

	public void setDescription(String description) {
		this.description = description;
	}

	@Override
	public String toString() {
		return "Employee{" +
			"id=" + id +
			", firstName='" + firstName + '\'' +
			", lastName='" + lastName + '\'' +
			", description='" + description + '\'' +
			'}';
	}
}
1个 @Entity 是一个JPA批注,它表示要存储在关系表中的整个类。
2个 @Id@GeneratedValue是JPA注释注意主键并在需要时自动生成。

该实体用于跟踪员工信息,在这种情况下,是他们的姓名和职务说明。

Spring Data REST不限于JPA。它支持许多NoSQL数据存储,尽管您不会在本教程中看到这些数据存储。欲了解更多信息,请参阅使用REST访问Neo4j的数据与REST访问JPA数据访问MongoDB的数据与REST

定义存储库

Spring Data REST应用程序的另一个关键部分是相应的存储库定义,如下所示:

例子2. src / main / java / com / greglturnquist / payroll / EmployeeRepository.java
public interface EmployeeRepository extends CrudRepository<Employee, Long> { (1)

}
1个 该存储库扩展了Spring Data Commons,CrudRepository并插入了域对象的类型及其主键

这就是所有需要的!实际上,如果它是顶级且可见的,则您甚至不需要注释接口。如果您使用IDE并打开CrudRepository,则会找到一组预定义的方法。

您可以根据需要定义自己的存储库。Spring Data REST也支持这一点。

预加载演示

要使用此应用程序,您需要预加载一些数据,如下所示:

例子3. src / main / java / com / greglturnquist / payroll / DatabaseLoader.java
@Component (1)
public class DatabaseLoader implements CommandLineRunner { (2)

	private final EmployeeRepository repository;

	@Autowired (3)
	public DatabaseLoader(EmployeeRepository repository) {
		this.repository = repository;
	}

	@Override
	public void run(String... strings) throws Exception { (4)
		this.repository.save(new Employee("Frodo", "Baggins", "ring bearer"));
	}
}
1个 该类标记有Spring的@Component注释,以使它自动被拾取@SpringBootApplication
2个 它实现了Spring Boot的功能,CommandLineRunner以便在创建和注册所有bean之后运行它。
3 它使用构造函数注入和自动装配来获取Spring Data的自动创建EmployeeRepository
4 run()方法由命令行参数调用,从而加载数据。

Spring Data最大,最强大的功能之一就是它能够为您编写JPA查询。这不仅减少了您的开发时间,而且还降低了错误和错误的风险。Spring Data在存储库类中查看方法的名称,并找出所需的操作,包括保存,删除和查找。

这就是我们可以编写一个空接口并继承已建立的保存,查找和删除操作的方式。

调整根URI

默认情况下,Spring Data REST在处托管链接的根集合/。因为您将在该路径上托管Web UI,所以需要更改根URI,如下所示:

例子4. src / main / resources / application.properties
spring.data.rest.base-path = / api

启动后端

使完全可操作的REST API可用的最后一步是public static void main使用Spring Boot编写一种方法,如下所示:

例子5. src / main / java / com / greglturnquist / payroll / ReactAndSpringDataRestApplication.java
@SpringBootApplication
public class ReactAndSpringDataRestApplication {

	public static void main(String[] args) {
		SpringApplication.run(ReactAndSpringDataRestApplication.class, args);
	}
}

假设以前的类以及您的Maven构建文件都是从https://start.springref.com生成的,则现在可以通过main()在IDE中运行该方法或./mvnw spring-boot:run在命令行中键入来启动它。(mvnw.bat适用于Windows用户)。

如果您不了解最新的Spring Boot及其工作原理,则应观看Josh Long的介绍性演示之一。做到了?按下!

游览您的REST服务

随着应用程序的运行,您可以使用cURL(或您喜欢的任何其他工具)在命令行中检出内容。以下命令(及其输出显示)列出了应用程序中的链接:

$ curl本地主机:8080 / api
{
  “ _links”:{
    “雇员” : {
      “ href”:“ http:// localhost:8080 / api / employees”
    },
    “轮廓” : {
      “ href”:“ http:// localhost:8080 / api / profile”
    }
  }
}

当您对根节点执行ping操作时,您将获得包装在HAL格式的JSON文档中的链接的集合。

  • _links 是可用链接的集合。

  • employees指向该EmployeeRepository接口定义的员工对象的聚合根。

  • profile是IANA标准的关系,指向有关整个服务的可发现元数据。我们将在后面的部分中对此进行探讨。

您可以通过导航employees链接来进一步研究该服务。以下命令(及其输出显示)执行此操作:

$ curl本地主机:8080 / api / employees
{
  “ _embedded”:{
    “雇员” : [ {
      “ firstName”:“ Frodo”,
      “ lastName”:“ Baggins”,
      “ description”:“ ring bearer”,
      “ _links”:{
        “自己” : {
          “ href”:“ http:// localhost:8080 / api / employees / 1”
        }
      }
    }]
  }
}

在此阶段,您正在查看整个员工集合。

除了您先前预加载的数据外,还包含_links带有self链接的属性。这是该特定员工的规范链接。什么是规范的?它的意思是“没有上下文”。例如,可以通过获取相同的用户/api/orders/1/processor,在该用户中,员工与处理特定订单相关联。在这里,与其他实体没有关系。

链接是REST的关键方面。它们提供了导航到相关项目的功能。这样,其他各方就可以浏览您的API,而不必每次更改时都进行重写。当客户端对资源的路径进行硬编码时,客户端中的更新是一个常见问题。重组资源可能会导致代码发生巨大变化。如果使用链接并保持导航路线,则进行此类调整变得容易且灵活。

如果愿意,您可以决定查看一位员工。以下命令(及其输出显示)执行此操作:

$ curl本地主机:8080 / api / employees / 1
{
  “ firstName”:“ Frodo”,
  “ lastName”:“ Baggins”,
  “ description”:“ ring bearer”,
  “ _links”:{
    “自己” : {
      “ href”:“ http:// localhost:8080 / api / employees / 1”
    }
  }
}

此处几乎没有什么变化,只不过_embedded因为仅存在域对象,所以不需要包装器。

一切都很好,但是您可能很想创建一些新条目。以下命令(及其输出显示)执行此操作:

$ curl -X POST本地主机:8080 / api /员工-d“ {\” firstName \“:\” Bilbo \“,\” lastName \“:\” Baggins \“,\” description \“:\”防盗\ “}” -H“ Content-Type:应用程序/ json”
{
  “ firstName”:“ Bilbo”,
  “ lastName”:“ Baggins”,
  “ description”:“防盗”,
  “ _links”:{
    “自己” : {
      “ href”:“ http:// localhost:8080 / api / employees / 2”
    }
  }
}

您还可以PUTPATCHDELETE,如在这个相关的指南。不过,到目前为止,我们将继续构建漂亮的UI。

设置自定义UI控制器

Spring Boot使站立自定义网页变得非常简单。首先,您需要一个Spring MVC控制器,如下所示:

例子6. src / main / java / com / greglturnquist / payroll / HomeController.java
@Controller (1)
public class HomeController {

	@RequestMapping(value = "/") (2)
	public String index() {
		return "index"; (3)
	}

}
1个 @Controller 将此类标记为Spring MVC控制器。
2个 @RequestMapping标记index()支持/路由的方法。
3 它返回index作为模板的名称,Spring Boot的自动配置的视图解析器将映射到该模板src/main/resources/templates/index.html

定义HTML模板

尽管您不会真正使用Thymeleaf的许多功能,但是您正在使用Thymeleaf。首先,您需要一个索引页面,如下所示:

例子7. src / main / resources / templates / index.html
<!DOCTYPE html>
<html xmlns:th="https://www.thymeleaf.org">
<head lang="en">
    <meta charset="UTF-8"/>
    <title>ReactJS + Spring Data REST</title>
    <link rel="stylesheet" href="/main.css" />
</head>
<body>

    <div id="react"></div>

    <script src="built/bundle.js"></script>

</body>
</html>

该模板的关键部分是<div id="react"></div>中间的组件。在这里,您将指示React插入渲染的输出。

您可能还想知道该bundle.js文件来自何处。下一部分将显示其构建方式。

本教程未显示main.css,但您可以在上方看到它的链接。对于CSS,Spring Boot将自动提供中提供的任何功能src/main/resources/static。将您自己的main.css文件放在这里。它没有显示在教程中,因为我们的重点是React和Spring Data REST,而不是CSS。

加载JavaScript模块

本部分包含准系统信息,以帮助您深入了解JavaScript。尽管可以安装所有JavaScript命令行工具,但您不必这样做-至少现在还不需要。相反,您需要做的就是将以下内容添加到您的pom.xml构建文件中:

例子8.frontend-maven-plugin用于构建JavaScript的位
<plugin>
	<groupId>com.github.eirslett</groupId>
	<artifactId>frontend-maven-plugin</artifactId>
</plugin>

这个小插件执行多个步骤:

  • install-node-and-npm命令会将node.js及其程序包管理工具安装npm到该target文件夹中。(这确保二进制文件不会在源代码控制下被拉出,并且可以使用清除clean)。

  • npm命令将使用提供的参数(install)执行npm二进制文件。这将安装中定义的模块package.json

  • webpack命令将执行webpack二进制文件,该二进制文件将基于编译所有JavaScript代码webpack.config.js

这些步骤按顺序运行,实际上是安装node.js,下载JavaScript模块并构建JS位。

安装了哪些模块?JavaScript开发人员通常用于npm构建package.json文件,例如:

例子9. package.json
{
  "name": "spring-data-rest-and-reactjs",
  "version": "0.1.0",
  "description": "Demo of ReactJS + Spring Data REST",
  "repository": {
    "type": "git",
    "url": "[email protected]:spring-guides/tut-react-and-spring-data-rest.git"
  },
  "keywords": [
    "rest",
    "hateoas",
    "spring",
    "data",
    "react"
  ],
  "author": "Greg L. Turnquist",
  "license": "Apache-2.0",
  "bugs": {
    "url": "https://github.com/spring-guides/tut-react-and-spring-data-rest/issues"
  },
  "homepage": "https://github.com/spring-guides/tut-react-and-spring-data-rest",
  "dependencies": {
    "react": "^16.5.2",
    "react-dom": "^16.5.2",
    "rest": "^1.3.1"
  },
  "scripts": {
    "watch": "webpack --watch -d --output ./target/classes/static/built/bundle.js"
  },
  "devDependencies": {
    "@babel/core": "^7.1.0",
    "@babel/preset-env": "^7.1.0",
    "@babel/preset-react": "^7.0.0",
    "babel-loader": "^8.0.2",
    "webpack": "^4.19.1",
    "webpack-cli": "^3.1.0"
  }
}

关键依赖项包括:

  • react.js:本教程使用的工具包

  • rest.js:用于进行REST调用的CujoJS工具包

  • webpack:一种工具包,用于将JavaScript组件编译为单个可加载的包

  • babel:使用ES6编写JavaScript代码并将其编译为ES5以在浏览器中运行

要构建稍后将使用的JavaScript代码,您需要为webpack定义一个构建文件,如下所示:

例子10. webpack.config.js
var path = require('path');

module.exports = {
    entry: './src/main/js/app.js',
    devtool: 'sourcemaps',
    cache: true,
    mode: 'development',
    output: {
        path: __dirname,
        filename: './src/main/resources/static/built/bundle.js'
    },
    module: {
        rules: [
            {
                test: path.join(__dirname, '.'),
                exclude: /(node_modules)/,
                use: [{
                    loader: 'babel-loader',
                    options: {
                        presets: ["@babel/preset-env", "@babel/preset-react"]
                    }
                }]
            }
        ]
    }
};

此webpack配置文件:

  • 入口点定义为./src/main/js/app.js。本质上,app.js(您将很快编写的一个模块)是public static void main()我们JavaScript应用程序的谚语。webpack必须知道这一点,以便知道在浏览器加载最终捆绑包时要启动什么

  • 创建源映射,以便在浏览器中调试JS代码时可以链接回原始源代码。

  • 将所有JavaScript位编译为./src/main/resources/static/built/bundle.js,这等效于Spring Boot uber JAR的JavaScript。您所有的自定义代码和require()调用所插入的模块都将填充到此文件中。

  • 它使用es2015react预设都挂接到babel引擎中,以便将ES6 React代码编译成可以在任何标准浏览器中运行的格式。

有关每种JavaScript工具的工作方式的更多详细信息,请阅读其相应的参考文档。

是否想自动查看您的JavaScript更改?运行npm run-script watch以将webpack置于监视模式。bundle.js编辑源时,它将重新生成。

完成所有操作后,您可以专注于React位,这些位是在DOM加载后获取的。它分为以下几部分:

由于您使用的是webpack进行组装,因此继续获取所需的模块:

例子11. src / main / js / app.js
const React = require('react'); (1)
const ReactDOM = require('react-dom'); (2)
const client = require('./client'); (3)
1个 React 是Facebook用于构建此应用程序的主要库之一。
2个 ReactDOM是充当React的DOM和服务器渲染器的入口点的软件包。它旨在与通用React包配对。
3 client是自定义代码,用于配置rest.js以包括对HAL,URI模板等的支持。还将默认Accept请求标头设置为application/hal+json。您可以在此处阅读代码
client未显示的代码,因为进行REST调用所用的内容并不重要。随时检查源代码,但要点是,您可以插入Restangular(或您喜欢的任何东西),并且这些概念仍然适用。

潜入React

React是基于定义组件的。通常,一个组件可以以父子关系保存另一个组件的多个实例。这个概念可以扩展到几层。

首先,为所有组件配备一个顶层容器非常方便。(当您扩展本系列中的代码时,这将变得更加明显。)现在,您只有雇员列表。但是稍后您可能需要其他一些相关组件,因此请从以下内容开始:

例子12. src / main / js / app.js-应用组件
class App extends React.Component { (1)

	constructor(props) {
		super(props);
		this.state = {employees: []};
	}

	componentDidMount() { (2)
		client({method: 'GET', path: '/api/employees'}).done(response => {
			this.setState({employees: response.entity._embedded.employees});
		});
	}

	render() { (3)
		return (
			<EmployeeList employees={this.state.employees}/>
		)
	}
}
1个 class App extends React.Component{…​} 是创建React组件的方法。
2个 componentDidMount 是React在DOM中渲染组件后调用的API。
3 render 是在屏幕上“绘制”组件的API。
在React中,大写是组件命名的约定。

在该App组件中,从Spring Data REST后端获取一组雇员,并将其存储在该组件的state数据中。

React组件具有两种类型的数据:stateproperties

状态是组件期望自己处理的数据。也是可能波动和变化的数据。要读取状态,请使用this.state。要进行更新,请使用this.setState()。每次this.setState()调用时,React都会更新状态,计算先前状态和新状态之间的差异,并在页面上对DOM注入一组更改。这样可以快速有效地更新您的UI。

通用约定是使用构造函数中所有属性为空的状态来初始化状态。然后,通过使用componentDidMount并填充属性来从服务器中查找数据。从那里开始,更新可以由用户操作或其他事件来驱动。

属性包含传递到组件中的数据。属性不会改变,而是固定值。要设置它们,您将在创建新组件时将它们分配给属性,如您将很快看到的那样。

JavaScript不会像其他语言一样锁定数据结构。您可以尝试通过分配值来颠覆属性,但这不适用于React的差分引擎,应避免使用。

在此代码中,该函数通过REST兼容clientPromise实例rest.js加载数据。完成从的检索后/api/employees,它会在内部调用该函数done()并根据其HAL文档(response.entity._embedded.employees)设置状态。请参阅curl /api/employees 前面的结构,并了解它如何映射到此结构。

当状态更新时,该render()功能由框架调用。员工状态数据<EmployeeList />作为输入参数包含在React组件的创建中。

以下清单显示的定义EmployeeList

例子13. src / main / js / app.js-EmployeeList组件
class EmployeeList extends React.Component{
	render() {
		const employees = this.props.employees.map(employee =>
			<Employee key={employee._links.self.href} employee={employee}/>
		);
		return (
			<table>
				<tbody>
					<tr>
						<th>First Name</th>
						<th>Last Name</th>
						<th>Description</th>
					</tr>
					{employees}
				</tbody>
			</table>
		)
	}
}

使用JavaScript的map函数,它可以将this.props.employees员工记录数组转换为<Element />React组件数组(稍后您将看到)。

考虑以下清单:

<Employee key={employee._links.self.href} data={employee} />

上面的清单创建了一个具有两个属性的新React组件(请注意大写格式):keydata。这些都与从值供给employee._links.self.hrefemployee

每当您使用Spring Data REST时,self链接都是给定资源的关键。React需要一个用于子节点的唯一标识符,并且_links.self.href是完美的。

最后,您返回一个HTML表,该表围绕着employees带有映射的build数组,如下所示:

<table>
    <tr>
        <th>First Name</th>
        <th>Last Name</th>
        <th>Description</th>
    </tr>
    {employees}
</table>

状态,属性和HTML的这种简单布局显示了React如何使您以声明方式创建一个简单易懂的组件。

此代码是否同时包含HTMLJavaScript?是的,这是JSX。不需要使用它。可以使用纯JavaScript编写React,但是JSX语法非常简洁。由于对Babel.js的快速工作,翻译器同时提供了JSX和ES6支持。

JSX还包含一些ES6。此代码中使用的一个是箭头功能。它避免了function()使用自己的作用域创建嵌套,this并且避免了需要self变量

担心将逻辑与您的结构混合吗?React的API鼓励结合状态和属性的美观的声明式结构。React鼓励混合一些无关的JavaScript和HTML,而不是混合使用一些无关的状态和属性,从而构建简单的组件。它使您可以查看单个组件并了解设计。然后,它们很容易组合在一起形成更大的结构。

接下来,您需要实际定义什么<Employee />是,如下所示:

例子14. src / main / js / app.js-员工组件
class Employee extends React.Component{
	render() {
		return (
			<tr>
				<td>{this.props.employee.firstName}</td>
				<td>{this.props.employee.lastName}</td>
				<td>{this.props.employee.description}</td>
			</tr>
		)
	}
}

这个组件非常简单。它有一个围绕员工的三个属性的HTML表行。物业本身就是this.props.employee。请注意,传递JavaScript对象如何使传递从服务器获取的数据变得容易。

因为此组件既不管理任何状态也不处理用户输入,所以没有其他事情要做。这可能会诱使您将其塞入<EmployeeList />上方。不要做!将您的应用程序拆分为各个小组件,每个组件都可以完成一项工作,这将使将来更轻松地构建功能。

最后一步是渲染整个内容,如下所示:

例子15. src / main / js / app.js-渲染代码
ReactDOM.render(
	<App />,
	document.getElementById('react')
)

React.render()接受两个参数:您定义的React组件以及将其注入的DOM节点。还记得您<div id="react"></div>之前从HTML页面看到的项目吗?这是它被拾起并插入的地方。

完成所有这些操作后,重新运行应用程序(./mvnw spring-boot:run)并访问http:// localhost:8080。下图显示了已更新的应用程序:

基本1

您可以看到系统加载了最初的员工。

还记得使用cURL创建新条目吗?使用以下命令再次执行此操作:

curl -X POST本地主机:8080 / api /员工-d“ {\” firstName \“:\” Bilbo \“,\” lastName \“:\” Baggins \“,\” description \“:\”防盗\“ }“ -H” Content-Type:应用程序/ json“

刷新浏览器,您应该看到新条目:

基本2

现在,您可以在网站上看到这两个列表。

审查

在这个部分:

  • 您定义了一个域对象和一个相应的存储库。

  • 您让Spring Data REST使用功能完善的超媒体控件将其导出。

  • 您在父子关系中创建了两个简单的React组件。

  • 您获取了服务器数据并将其呈现为简单的静态HTML结构。

问题?

  • 该网页不是动态的。您必须刷新浏览器才能获取新记录。

  • 该网页未使用任何超媒体控件或元数据。而是对其进行了硬编码以从中获取数据/api/employees

  • 它是只读的。尽管您可以使用cURL更改记录,但该网页没有交互性。

我们将在下一部分中解决这些缺点。

第2部分-超媒体控件

在上一节中,您了解了如何使用Spring Data REST创建后端薪资服务来存储员工数据。它缺少的一个关键功能是使用超媒体控件和按链接导航。相反,它对找到数据的路径进行了硬编码。

随时从该存储库中获取代码并继续。本节基于上一节的应用程序,并添加了其他内容。

最初有数据...然后有RES​​T

人们对将任何基于HTTP的接口称为REST API的人数感到沮丧。今天的示例是SocialSite REST API。那就是RPC。它尖叫着RPC ....要使REST体系结构风格清晰地认识到超文本是一个约束的概念,需要做些什么?换句话说,如果应用程序状态的引擎(以及API)不是由超文本驱动的,则它不能是RESTful的,也不能是REST API。时期。是否有一些需要修复的损坏的手册?
-Roy T.Fielding
https://roy.gbiv.com/untangled/2008/rest-apis-must-be-Hypertext-driven

那么,超媒体控件到底是什么(超文本),又该如何使用它们呢?为了找出答案,我们后退一步,看看REST的核心任务。

REST的概念是借用使网络如此成功的想法并将其应用于API。尽管网络规模庞大,动态特性大,并且客户端(即浏览器)的更新速度较低,但网络却是令人称奇的成功。罗伊·菲尔丁(Roy Fielding)试图利用其一些约束和功能,看看是否能够提供类似的API生产和消费量。

限制之一是限制动词的数量。对于REST,主要的是GET,POST,PUT,DELETE和PATCH。还有其他人,但我们在这里不介绍它们。

  • GET:在不更改系统的情况下获取资源的状态

  • POST:创建新资源,而无需说出位置

  • PUT:替换现有资源,覆盖已经存在的任何其他内容(如果有的话)

  • 删除:删除现有资源

  • 修补程序:更改现有资源(部分而不是创建新资源)

这些是具有众所周知规范的标准化HTTP动词。通过选择并使用已经创造的HTTP操作,我们不需要发明新的语言就可以对行业进行教育。

REST的另一个限制是使用媒体类型来定义数据格式。与其每个人都写自己的方言来交换信息,不如发展一些媒体类型。最受欢迎的一种媒体类型是HAL application/hal+json。这是Spring Data REST的默认媒体类型。一个关键值是REST没有集中的单一媒体类型。相反,人们可以开发媒体类型并将其插入并进行尝试。随着不同需求的出现,行业可以灵活地移动。

REST的一个关键功能是包括指向相关资源的链接。例如,如果您正在查看订单,则RESTful API将包括到相关客户的链接,到商品目录的链接,以及到从其下订单的商店的链接。在本节中,您将介绍分页,并了解如何也使用导航分页链接。

从后端开启分页

要开始使用前端超媒体控件,您需要打开一些额外的控件。Spring Data REST提供了页面支持。要使用它,请如下调整存储库定义:

例子16. src / main / java / com / greglturnquist / payroll / EmployeeRepository.java
public interface EmployeeRepository extends PagingAndSortingRepository<Employee, Long> {

}

现在PagingAndSortingRepository,您的界面可以扩展,它添加了额外的选项来设置页面大小,并添加了导航链接以在页面之间跳转。后端的其余部分相同(除了一些额外的预加载数据使事情变得有趣之外)。

重新启动应用程序(./mvnw spring-boot:run),然后查看其工作方式。然后运行以下命令(及其输出显示)以查看分页的运行情况:

$ curl“本地主机:8080 / api /员工?size = 2”
{
  “ _links”:{
    “第一的” : {
      “ href”:“ http:// localhost:8080 / api / employees?page = 0&size = 2”
    },
    “自己” : {
      “ href”:“ http:// localhost:8080 / api / employees”
    },
    “下一个” : {
      “ href”:“ http:// localhost:8080 / api / employees?page = 1&size = 2”
    },
    “最后的” : {
      “ href”:“ http:// localhost:8080 / api / employees?page = 2&size = 2”
    }
  },
  “ _embedded”:{
    “雇员” : [ {
      “ firstName”:“ Frodo”,
      “ lastName”:“ Baggins”,
      “ description”:“ ring bearer”,
      “ _links”:{
        “自己” : {
          “ href”:“ http:// localhost:8080 / api / employees / 1”
        }
      }
    },{
      “ firstName”:“ Bilbo”,
      “ lastName”:“ Baggins”,
      “ description”:“防盗”,
      “ _links”:{
        “自己” : {
          “ href”:“ http:// localhost:8080 / api / employees / 2”
        }
      }
    }]
  },
  “页” : {
    “大小”:2
    “ totalElements”:6
    “ totalPages”:3,
    “数字”:0
  }
}

默认页面大小为20,但是我们没有那么多数据。因此,为实际操作,我们设置了?size=2。不出所料,只列出了两名员工。此外,还有firstnextlast链接。还有一个self链接,没有上下文,包括页面参数

如果您导航到该next链接,则也会看到一个prev链接。以下命令(及其输出显示)执行此操作:

$ curl“ http:// localhost:8080 / api / employees?page = 1&size = 2”
{
  “ _links”:{
    “第一的” : {
      “ href”:“ http:// localhost:8080 / api / employees?page = 0&size = 2”
    },
    “上一个”:{
      “ href”:“ http:// localhost:8080 / api / employees?page = 0&size = 2”
    },
    “自己” : {
      “ href”:“ http:// localhost:8080 / api / employees”
    },
    “下一个” : {
      “ href”:“ http:// localhost:8080 / api / employees?page = 2&size = 2”
    },
    “最后的” : {
      “ href”:“ http:// localhost:8080 / api / employees?page = 2&size = 2”
    }
  },
...
&在URL查询参数中使用时,命令行认为这是换行符。用引号引起来的整个URL,以避免出现此问题。

看起来很整洁,但是当您更新前端以利用它时,效果会更好。

通过关系导航

后端不需要任何其他更改即可开始使用Spring Data REST提供的开箱即用的超媒体控件。您可以切换到在前端工作。(这是Spring Data REST的优点的一部分:无需凌乱的控制器更新!)

重要的是要指出此应用程序不是“特定于Spring Data REST的”。而是使用HALURI模板和其他标准。这就是为什么使用rest.js很简单的原因:该库附带HAL支持。

在上一节中,您将路径硬编码为/api/employees。相反,您应该硬编码的唯一路径是根,如下所示

...
var root = '/api';
...

使用方便的小follow()功能,您现在可以从根目录开始,然后导航到所需的位置,如下所示:

componentDidMount() {
	this.loadFromServer(this.state.pageSize);
}

在上一节中,加载直接在内部完成componentDidMount()。在本节中,我们将在页面大小更新时重新加载整个员工列表。为此,我们将内容移至loadFromServer(),如下所示:

loadFromServer(pageSize) {
	follow(client, root, [
		{rel: 'employees', params: {size: pageSize}}]
	).then(employeeCollection => {
		return client({
			method: 'GET',
			path: employeeCollection.entity._links.profile.href,
			headers: {'Accept': 'application/schema+json'}
		}).then(schema => {
			this.schema = schema.entity;
			return employeeCollection;
		});
	}).done(employeeCollection => {
		this.setState({
			employees: employeeCollection.entity._embedded.employees,
			attributes: Object.keys(this.schema.properties),
			pageSize: pageSize,
			links: employeeCollection.entity._links});
	});
}

loadFromServer与上一节非常相似。但是,它使用follow()

  • follow()函数的第一个参数是client用于进行REST调用的对象。

  • 第二个参数是要从其开始的根URI。

  • 第三个参数是要导航的一系列关系。每个可以是一个字符串或一个对象。

关系数组可以很简单["employees"],即,当进行第一个调用时,查找名为_links的关系。找到它并导航到它。如果阵列中存在其他关系,请重复该过程。relemployeeshref

有时,仅靠arel本身是不够的。在这段代码中,它还插入的查询参数?size=<pageSize>。您可以稍后提供其他选项。

抓取JSON模式元数据

导航到employees基于大小的查询后,该employeeCollection可用。在上一节中,我们在中显示了该数据<EmployeeList />。在本部分中,您将执行另一个调用以获取在处找到的一些JSON Schema元数据/api/profile/employees/

您可以通过运行以下curl命令(与输出一起显示)来自己查看数据:

$ curl http:// localhost:8080 / api / profile / employees -H“ Accept:application / schema + json”
{
  “ title”:“雇员”,
  “特性” : {
    “名” : {
      “ title”:“名字”,
      “ readOnly”:false,
      “ type”:“ string”
    },
    “姓” : {
      “ title”:“姓氏”,
      “ readOnly”:false,
      “ type”:“ string”
    },
    “描述” : {
      “标题描述”,
      “ readOnly”:false,
      “ type”:“ string”
    }
  },
  “ definitions”:{},
  “ type”:“ object”,
  “ $ schema”:“ https://json-schema.org/draft-04/schema#”
}
的默认元数据形式/profile/employeesALPS。不过,在这种情况下,您正在使用内容协商来获取JSON模式。

通过在<App />组件的状态下捕获此信息,可以在以后构建输入表单时充分利用它。

创建新记录

有了此元数据,您现在可以向UI添加一些额外的控件。您可以从创建一个新的React组件开始<CreateDialog />,如下所示:

class CreateDialog extends React.Component {

	constructor(props) {
		super(props);
		this.handleSubmit = this.handleSubmit.bind(this);
	}

	handleSubmit(e) {
		e.preventDefault();
		const newEmployee = {};
		this.props.attributes.forEach(attribute => {
			newEmployee[attribute] = ReactDOM.findDOMNode(this.refs[attribute]).value.trim();
		});
		this.props.onCreate(newEmployee);

		// clear out the dialog's inputs
		this.props.attributes.forEach(attribute => {
			ReactDOM.findDOMNode(this.refs[attribute]).value = '';
		});

		// Navigate away from the dialog to hide it.
		window.location = "#";
	}

	render() {
		const inputs = this.props.attributes.map(attribute =>
			<p key={attribute}>
				<input type="text" placeholder={attribute} ref={attribute} className="field"/>
			</p>
		);

		return (
			<div>
				<a href="#createEmployee">Create</a>

				<div id="createEmployee" className="modalDialog">
					<div>
						<a href="#" title="Close" className="close">X</a>

						<h2>Create new employee</h2>

						<form>
							{inputs}
							<button onClick={this.handleSubmit}>Create</button>
						</form>
					</div>
				</div>
			</div>
		)
	}

}

这个新组件既具有handleSubmit()功能又具有预期render()功能。

我们以相反的顺序深入研究这些功能,首先看一下该render()功能。

渲染图

您的代码映射到在attributes属性中找到的JSON Schema数据,并将其转换为<p><input></p>元素数组。

  • key React再次需要它来区分多个子节点。

  • 这是一个简单的基于文本的输入字段。

  • placeholder 让我们向用户显示字段是哪个。

  • 您可能已经习惯了使用name属性,但这不是必需的。使用React,ref是获取特定DOM节点的机制(您将很快看到)。

这代表了组件的动态特性,它是通过从服务器加载数据来驱动的。

在该组件的顶层内部<div>是一个anchor标签和另一个<div>。锚标记是打开对话框的按钮。嵌套的<div>是隐藏的对话框本身。在此示例中,您使用的是纯HTML5和CSS3。根本没有JavaScript!您可以看到用于显示和隐藏对话框的CSS代码。我们不会在这里深入探讨。

嵌套<div id="createEmployee">在其中的表单是在其中注入动态输入字段列表,然后是“创建”按钮的表单。该按钮具有onClick={this.handleSubmit}事件处理程序。这是注册事件处理程序的React方法。

React不会在每个DOM元素上创建事件处理程序。相反,它具有更高性能和更复杂的解决方案。您无需管理该基础结构,而可以专注于编写功能代码。

处理用户输入

handleSubmit()函数首先阻止事件在层次结构中冒泡。然后,它使用相同的JSON模式属性属性来查找每个<input>,通过使用React.findDOMNode(this.refs[attribute])

this.refs是一种通过名称来获取并获取特定React组件的方法。请注意,您仅获得虚拟DOM组件。要获取实际的DOM元素,您需要使用React.findDOMNode()

在遍历每个输入并构建newEmployee对象之后,我们onCreate()为新员工记录调用回调。该函数在内部App.onCreate,并作为另一个属性提供给此React组件。查看该顶级函数的工作方式:

onCreate(newEmployee) {
	follow(client, root, ['employees']).then(employeeCollection => {
		return client({
			method: 'POST',
			path: employeeCollection.entity._links.self.href,
			entity: newEmployee,
			headers: {'Content-Type': 'application/json'}
		})
	}).then(response => {
		return follow(client, root, [
			{rel: 'employees', params: {'size': this.state.pageSize}}]);
	}).done(response => {
		if (typeof response.entity._links.last !== "undefined") {
			this.onNavigate(response.entity._links.last.href);
		} else {
			this.onNavigate(response.entity._links.self.href);
		}
	});
}

再次,我们使用该follow()功能导航到employees执行POST操作的资源。在这种情况下,不需要应用任何参数,因此基于字符串的rel实例数组很好。在这种情况下,将POST返回呼叫。这使nextthen()子句可以处理。的结果POST

通常将新记录添加到数据集的末尾。由于您正在查看特定页面,因此合乎逻辑的是期望新员工记录不在当前页面上。要处理此问题,您需要提取一个应用了相同页面大小的新数据批。该诺言将返回给inside中的final子句done()

由于用户可能希望看到新创建的员工,因此您可以使用超媒体控件并导航到该last条目。

第一次使用基于承诺的API?承诺是一种启动异步操作,然后注册一个函数以在任务完成时做出响应的方法。承诺被设计为链接在一起,以避免“回调地狱”。看下面的流程:

when.promise(async_func_call())
	.then(function(results) {
		/* process the outcome of async_func_call */
	})
	.then(function(more_results) {
		/* process the previous then() return value */
	})
	.done(function(yet_more) {
		/* process the previous then() and wrap things up */
	});

有关更多详细信息,请查看有关Promise的本教程

使用promise时要记住的秘密是,then()函数需要返回某个值,无论它是值还是另一个promise。done()函数不会返回任何东西,并且一个接一个也不会链接任何东西。如果您尚未注意到client(这是restrest.js的一个实例),并且该follow函数返回promises。

分页数据

您已经在后端设置了分页,并且在创建新员工时已经开始利用它。

上一节中,您使用了页面控件来跳转到该last页面。动态地将其应用于UI并让用户根据需要进行导航将非常方便。根据可用的导航链接动态调整控件将非常有用。

首先,让我们检查一下onNavigate()您使用的功能:

onNavigate(navUri) {
	client({method: 'GET', path: navUri}).done(employeeCollection => {
		this.setState({
			employees: employeeCollection.entity._embedded.employees,
			attributes: this.state.attributes,
			pageSize: this.state.pageSize,
			links: employeeCollection.entity._links
		});
	});
}

这是在顶部内部定义的App.onNavigate。同样,这是为了允许在顶部组件中管理UI的状态。传递onNavigate()<EmployeeList />React组件后,对以下处理程序进行编码,以处理单击某些按钮的情况:

handleNavFirst(e){
	e.preventDefault();
	this.props.onNavigate(this.props.links.first.href);
}

handleNavPrev(e) {
	e.preventDefault();
	this.props.onNavigate(this.props.links.prev.href);
}

handleNavNext(e) {
	e.preventDefault();
	this.props.onNavigate(this.props.links.next.href);
}

handleNavLast(e) {
	e.preventDefault();
	this.props.onNavigate(this.props.links.last.href);
}

这些函数中的每一个都会拦截默认事件并阻止其冒泡。然后,它将onNavigate()使用适当的超媒体链接来调用该函数。

现在,您可以根据以下链接中的超链接中出现的链接来有条件地显示控件EmployeeList.render

render() {
	const employees = this.props.employees.map(employee =>
		<Employee key={employee._links.self.href} employee={employee} onDelete={this.props.onDelete}/>
	);

	const navLinks = [];
	if ("first" in this.props.links) {
		navLinks.push(<button key="first" onClick={this.handleNavFirst}>&lt;&lt;</button>);
	}
	if ("prev" in this.props.links) {
		navLinks.push(<button key="prev" onClick={this.handleNavPrev}>&lt;</button>);
	}
	if ("next" in this.props.links) {
		navLinks.push(<button key="next" onClick={this.handleNavNext}>&gt;</button>);
	}
	if ("last" in this.props.links) {
		navLinks.push(<button key="last" onClick={this.handleNavLast}>&gt;&gt;</button>);
	}

	return (
		<div>
			<input ref="pageSize" defaultValue={this.props.pageSize} onInput={this.handleInput}/>
			<table>
				<tbody>
					<tr>
						<th>First Name</th>
						<th>Last Name</th>
						<th>Description</th>
						<th></th>
					</tr>
					{employees}
				</tbody>
			</table>
			<div>
				{navLinks}
			</div>
		</div>
	)
}

与上一节一样,它仍然可以转换this.props.employees<Element />组件数组。然后,它构建一个navLinksHTML按钮数组数组。

由于React是基于XML的,因此您不能将其<放入<button>元素中。您必须改为使用编码版本。

然后,您可以看到已{navLinks}插入到返回HTML底部的位置。

删除现有记录

删除条目要容易得多。您需要做的就是获取其基于HAL的记录并将DELETE其应用于其self链接:

class Employee extends React.Component {

	constructor(props) {
		super(props);
		this.handleDelete = this.handleDelete.bind(this);
	}

	handleDelete() {
		this.props.onDelete(this.props.employee);
	}

	render() {
		return (
			<tr>
				<td>{this.props.employee.firstName}</td>
				<td>{this.props.employee.lastName}</td>
				<td>{this.props.employee.description}</td>
				<td>
					<button onClick={this.handleDelete}>Delete</button>
				</td>
			</tr>
		)
	}
}

Employee组件的此更新版本在行的末尾显示一个额外的条目(删除按钮)。this.handleDelete单击时注册为调用。handleDelete()然后,该函数可以在提供上下文重要this.props.employee记录的同时调用向下传递的回调。

这再次表明,最容易在一个位置管理顶部组件中的状态。并非总是如此。但是,通常情况下,在一个地方管理状态可以使事情变得简单明了。通过使用特定于组件的详细信息(this.props.onDelete(this.props.employee))调用回调,可以很容易地协调组件之间的行为。

通过将该onDelete()函数追溯到位于的顶部App.onDelete,您可以看到其运行方式:

onDelete(employee) {
	client({method: 'DELETE', path: employee._links.self.href}).done(response => {
		this.loadFromServer(this.state.pageSize);
	});
}

使用基于页面的UI删除记录后要应用的行为有些棘手。在这种情况下,它将使用相同的页面大小从服务器重新加载整个数据。然后显示第一页。

如果要删除最后一页上的最后一条记录,它将跳到第一页。

调整页面大小

查看超媒体的真正效果的一种方法是更新页面大小。Spring Data REST根据页面大小流畅地更新导航链接。

有一个在顶部的HTML元素ElementList.render<input ref="pageSize" defaultValue={this.props.pageSize} onInput={this.handleInput}/>

  • ref="pageSize"使您可以轻松地使用捕获该元素this.refs.pageSize

  • defaultValue使用状态的初始化它pageSize

  • onInput 注册一个处理程序,如下所示:

    handleInput(e) {
    	e.preventDefault();
    	const pageSize = ReactDOM.findDOMNode(this.refs.pageSize).value;
    	if (/^[0-9]+$/.test(pageSize)) {
    		this.props.updatePageSize(pageSize);
    	} else {
    		ReactDOM.findDOMNode(this.refs.pageSize).value =
    			pageSize.substring(0, pageSize.length - 1);
    	}
    }

它阻止事件冒泡。然后,它使用的ref属性<input>来查找DOM节点并通过React的findDOMNode()辅助函数来提取其值。它通过检查输入是否为数字字符串来测试输入是否真的是数字。如果是这样,它将调用回调,将新的页面大小发送到AppReact组件。如果不是,则将刚刚输入的字符从输入中删除。

这是什么App做的,当它得到一个updatePageSize()?一探究竟:

updatePageSize(pageSize) {
	if (pageSize !== this.state.pageSize) {
		this.loadFromServer(pageSize);
	}
}

因为新的页面大小值会导致所有导航链接发生更改,所以最好重新获取数据并从头开始。

放在一起

有了所有这些不错的添加,您现在就拥有了一个非常完善的UI,如下图所示:

超媒体1

您可以在顶部看到页面大小设置,在每行上看到删除按钮,在底部看到导航按钮。导航按钮说明了超媒体控件的强大功能。

在下图中,您可以看到CreateDialog带有将元数据插入HTML输入占位符的:

超媒体2

这确实显示了结合使用超媒体和域驱动的元数据(JSON架构)的强大功能。网页不必知道哪个字段是哪个。而是,用户可以看到它并知道如何使用它。如果将另一个字段添加到Employee域对象,此弹出窗口将自动显示它。

审查

在这个部分:

  • 您打开了Spring Data REST的页面调度功能。

  • 您抛出了硬编码的URI路径,并开始使用根URI和关系名称或“ rels”。

  • 您更新了UI,以动态使用基于页面的超媒体控件。

  • 您添加了创建和删除员工以及根据需要更新UI的功能。

  • 您可以更改页面大小并让UI灵活响应。

问题?

您使网页具有动态性。但是打开另一个浏览器选项卡,然后将其指向相同的应用程序。一个选项卡中的更改不会更新另一个选项卡中的任何内容。

我们将在下一部分中解决该问题。

第3部分-条件运算

在上一节中,您了解了如何打开Spring Data REST的超媒体控件,如何使UI通过分页导航以及根据更改页面大小来动态调整大小。您添加了创建和删除员工以及调整页面的功能。但是,如果不考虑其他用户对您当前正在编辑的相同数据进行的更新,那么没有任何解决方案是完整的。

随时从该存储库中获取代码并继续。本节基于上一节,但添加了其他功能。

要放置还是不放置?就是那个问题。

当您获取资源时,风险是如果其他人对其进行更新,则该资源可能会过时。为了解决这个问题,Spring Data REST集成了两种技术:资源版本控制和ETag。

通过在后端对资源进行版本控制并在前端使用ETag,可以有条件地PUT进行更改。换句话说,您可以检测资源是否已更改,并防止PUT(或PATCH)踩踏他人的更新。

REST资源版本控制

要支持资源的版本控制,请为需要这种保护类型的域对象定义一个版本属性。以下清单显示了如何对Employee对象执行此操作:

例子17. src / main / java / com / greglturnquist / payroll / Employee.java
@Entity
public class Employee {

	private @Id @GeneratedValue Long id;
	private String firstName;
	private String lastName;
	private String description;

	private @Version @JsonIgnore Long version;

	private Employee() {}

	public Employee(String firstName, String lastName, String description) {
		this.firstName = firstName;
		this.lastName = lastName;
		this.description = description;
	}

	@Override
	public boolean equals(Object o) {
		if (this == o) return true;
		if (o == null || getClass() != o.getClass()) return false;
		Employee employee = (Employee) o;
		return Objects.equals(id, employee.id) &&
			Objects.equals(firstName, employee.firstName) &&
			Objects.equals(lastName, employee.lastName) &&
			Objects.equals(description, employee.description) &&
			Objects.equals(version, employee.version);
	}

	@Override
	public int hashCode() {

		return Objects.hash(id, firstName, lastName, description, version);
	}

	public Long getId() {
		return id;
	}

	public void setId(Long id) {
		this.id = id;
	}

	public String getFirstName() {
		return firstName;
	}

	public void setFirstName(String firstName) {
		this.firstName = firstName;
	}

	public String getLastName() {
		return lastName;
	}

	public void setLastName(String lastName) {
		this.lastName = lastName;
	}

	public String getDescription() {
		return description;
	}

	public void setDescription(String description) {
		this.description = description;
	}

	public Long getVersion() {
		return version;
	}

	public void setVersion(Long version) {
		this.version = version;
	}

	@Override
	public String toString() {
		return "Employee{" +
			"id=" + id +
			", firstName='" + firstName + '\'' +
			", lastName='" + lastName + '\'' +
			", description='" + description + '\'' +
			", version=" + version +
			'}';
	}
}
  • version字段用注释javax.persistence.Version。每次插入和更新行时,它都会自动存储和更新值。

当获取单个资源(而不是集合资源)时,Spring Data REST会自动使用该字段的值添加一个ETag响应标头

获取个人资源及其标题

在上一节中,您使用了收集资源来收集数据并填充UI的HTML表。使用Spring Data REST,_embedded数据集被视为数据的预览。尽管对于浏览数据很有用,但要获取诸如ETags之类的标头,您需要单独获取每个资源。

在此版本中,loadFromServer已更新以获取集合。然后,您可以使用URI检索每个单独的资源:

例子18. src / main / js / app.js-获取每个资源
loadFromServer(pageSize) {
	follow(client, root, [ (1)
		{rel: 'employees', params: {size: pageSize}}]
	).then(employeeCollection => { (2)
		return client({
			method: 'GET',
			path: employeeCollection.entity._links.profile.href,
			headers: {'Accept': 'application/schema+json'}
		}).then(schema => {
			this.schema = schema.entity;
			this.links = employeeCollection.entity._links;
			return employeeCollection;
		});
	}).then(employeeCollection => { (3)
		return employeeCollection.entity._embedded.employees.map(employee =>
				client({
					method: 'GET',
					path: employee._links.self.href
				})
		);
	}).then(employeePromises => { (4)
		return when.all(employeePromises);
	}).done(employees => { (5)
		this.setState({
			employees: employees,
			attributes: Object.keys(this.schema.properties),
			pageSize: pageSize,
			links: this.links
		});
	});
}
1个 follow()功能转到employees收集资源。
2个 第一个then(employeeCollection ⇒ …​)子句创建一个调用以获取JSON Schema数据。它具有一个内部then子句,用于在<App/>组件中存储元数据和导航链接。

请注意,此内嵌的Promise返回employeeCollection。这样,可以将集合传递到下一个调用,让您一路抓取元数据。

3 第二个then(employeeCollection ⇒ …​)子句将雇员的集合转换为GET承诺的数组,以获取每个单独的资源。这就是为每个员工获取ETag标头的条件。
4 then(employeePromises ⇒ …​)子句采用GET诺言数组,并使用将它们合并为一个诺言when.all(),当所有GET诺言都得到解决时,该诺言就会解决。
5 loadFromServer总结done(employees ⇒ …​)使用此数据合并更新UI状态的位置。

该链也在其他地方实现。例如,onNavigate()(用于跳转到不同页面)已更新为获取单个资源。由于它与此处显示的内容基本相同,因此已将其排除在本节之外。

更新现有资源

在本部分中,您将添加一个UpdateDialogReact组件来编辑现有员工记录:

例子19. src / main / js / app.js-UpdateDialog组件
class UpdateDialog extends React.Component {

	constructor(props) {
		super(props);
		this.handleSubmit = this.handleSubmit.bind(this);
	}

	handleSubmit(e) {
		e.preventDefault();
		const updatedEmployee = {};
		this.props.attributes.forEach(attribute => {
			updatedEmployee[attribute] = ReactDOM.findDOMNode(this.refs[attribute]).value.trim();
		});
		this.props.onUpdate(this.props.employee, updatedEmployee);
		window.location = "#";
	}

	render() {
		const inputs = this.props.attributes.map(attribute =>
			<p key={this.props.employee.entity[attribute]}>
				<input type="text" placeholder={attribute}
					   defaultValue={this.props.employee.entity[attribute]}
					   ref={attribute} className="field"/>
			</p>
		);

		const dialogId = "updateEmployee-" + this.props.employee.entity._links.self.href;

		return (
			<div key={this.props.employee.entity._links.self.href}>
				<a href={"#" + dialogId}>Update</a>
				<div id={dialogId} className="modalDialog">
					<div>
						<a href="#" title="Close" className="close">X</a>

						<h2>Update an employee</h2>

						<form>
							{inputs}
							<button onClick={this.handleSubmit}>Update</button>
						</form>
					</div>
				</div>
			</div>
		)
	}

};

类似于该组件,此新组件同时具有handleSubmit()功能和预期render()功能<CreateDialog />

我们以相反的顺序深入研究这些功能,然后首先看一下该render()功能。

渲染图

该组件使用与上一节相同的CSS / HTML策略来显示和隐藏对话框<CreateDialog />

它将JSON Schema属性数组转换为HTML输入数组,并包装在段落元素中以进行样式设置。这也与相同,但<CreateDialog />有一个区别:字段使用加载this.props.employee。在CreateDialog组件中,这些字段为空。

id字段的构建方式有所不同。CreateDialog整个UI上只有一个链接,但UpdateDialog显示的每一行都有一个单独的链接。因此,该id字段基于self链接的URI。这用于<div>元素的React key,HTML锚标记和隐藏的弹出窗口中。

处理用户输入

提交按钮链接到组件的handleSubmit()功能。方便地React.findDOMNode()使用React refs来提取弹出窗口的详细信息。

在提取输入值并将其加载到updatedEmployee对象中之后,将onUpdate()调用顶级方法。这延续了React的单向绑定样式,其中要调用的函数从上层组件推入下层组件。这样,状态仍在顶部进行管理。

有条件的PUT

因此,您已经花了所有力气将版本控制嵌入到数据模型中。Spring Data REST已将该值用作ETag响应标头。在这里可以充分利用它:

例子20. src / main / js / app.js-onUpdate函数
onUpdate(employee, updatedEmployee) {
	client({
		method: 'PUT',
		path: employee.entity._links.self.href,
		entity: updatedEmployee,
		headers: {
			'Content-Type': 'application/json',
			'If-Match': employee.headers.Etag
		}
	}).done(response => {
		this.loadFromServer(this.state.pageSize);
	}, response => {
		if (response.status.code === 412) {
			alert('DENIED: Unable to update ' +
				employee.entity._links.self.href + '. Your copy is stale.');
		}
	});
}

PUTIf-Match请求头使弹簧数据REST所要检查的当前版本的值。如果传入的If-Match值与数据存储的版本值不匹配,则Spring Data REST将失败,并带有HTTP 412 Precondition Failed

Promises / A +的规范实际上将其API定义为then(successFunction, errorFunction)。到目前为止,您已经看到它仅与成功功能一起使用。在前面的代码片段中,有两个功能。成功函数调用loadFromServer,而错误函数显示有关过时数据的浏览器警报。

放在一起

随着你的UpdateDialog阵营组件定义并很好地链接到顶级onUpdate功能,最后一步是将其连接到组件的现有布局。

CreateDialog上一节中创建在的上面放EmployeeList,因为只有一个实例。但是,UpdateDialog直接与特定员工相关。因此,您可以在EmployeeReact组件中看到它插入的内容:

例子21. src / main / js / app.js-具有UpdateDialog的员工
class Employee extends React.Component {

	constructor(props) {
		super(props);
		this.handleDelete = this.handleDelete.bind(this);
	}

	handleDelete() {
		this.props.onDelete(this.props.employee);
	}

	render() {
		return (
			<tr>
				<td>{this.props.employee.entity.firstName}</td>
				<td>{this.props.employee.entity.lastName}</td>
				<td>{this.props.employee.entity.description}</td>
				<td>
					<UpdateDialog employee={this.props.employee}
								  attributes={this.props.attributes}
								  onUpdate={this.props.onUpdate}/>
				</td>
				<td>
					<button onClick={this.handleDelete}>Delete</button>
				</td>
			</tr>
		)
	}
}

在本节中,您将从使用收集资源切换为使用单个资源。员工记录的字段现在位于this.props.employee.entity。它使我们可以访问this.props.employee.headers,在这里可以找到ETag。

Spring Data REST支持的其他标头(例如Last-Modified)也不属于本系列。因此,以这种方式构造数据非常方便。

的结构.entity.headers使用时是仅相关rest.js作为选择的REST库。如果使用其他库,则必须根据需要进行调整。

看到实际情况

要查看修改后的应用程序的工作,请执行以下操作:

  1. 通过运行启动应用程序./mvnw spring-boot:run

  2. 打开浏览器选项卡,然后导航到http:// localhost:8080

    您应该看到类似于下图的页面:

    有条件的1
  3. 上拉Frodo的编辑对话框。

  4. 在浏览器中打开另一个选项卡,并拉出相同的记录。

  5. 在第一个选项卡中更改记录。

  6. 尝试在第二个选项卡中进行更改。

    您应该看到浏览器选项卡发生了变化,如下图所示

    有条件的2
有条件的3

通过这些修改,可以避免冲突,从而提高了数据完整性。

审查

在这个部分:

  • 您为域模型配置了一个@Version基于JPA的乐观锁定字段。

  • 您调整了前端以获取单个资源。

  • 您将ETag标头从单个资源插入到If-Match请求标头中,以使PUT有条件。

  • UpdateDialog为列表中显示的每个员工编码了一个新代码。

插入此插件后,很容易避免与其他用户冲突或覆盖他们的编辑。

问题?

很高兴知道何时编辑不良记录。但是,最好等到您单击“提交”才能找到答案吗?

获取资源的逻辑是在这两个非常相似的loadFromServeronNavigate。您看到避免重复代码的方法了吗?

您将JSON Schema元数据很好地用于构建CreateDialogUpdateDialog输入。您是否看到其他地方可以使用元数据使事情变得更通用?假设您要向中添加五个字段Employee.java。更新用户界面需要什么?

第四部分-活动

在上一节中,您介绍了条件更新,以避免在编辑相同数据时与其他用户发生冲突。您还学习了如何使用乐观锁定在后端上对数据进行版本控制。如果有人编辑了同一条记录,您会收到一条通知,以便刷新页面并获取更新。

那很好。但是你知道什么更好吗?当其他人更新资源时,让UI动态响应。

在本部分中,您将学习如何使用Spring Data REST的内置事件系统来检测后端的更改并通过Spring的WebSocket支持将更新发布给所有用户。然后,您将能够在数据更新时动态调整客户端。

随时从该存储库中获取代码并继续。本节基于上一节的应用程序,并添加了其他内容。

向项目添加Spring WebSocket支持

在进行之前,您需要向项目的pom.xml文件添加一个依赖项:

<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

这种依赖关系带来了Spring Boot的WebSocket启动器。

使用Spring配置WebSockets

Spring提供了强大的WebSocket支持。要认识的一件事是,WebSocket是一种非常底层的协议。它仅提供了在客户端和服务器之间传输数据的手段。建议使用子协议(本节为STOMP)对数据和路由进行实际编码。

以下代码在服务器端配置WebSocket支持:

@Component
@EnableWebSocketMessageBroker (1)
public class WebSocketConfiguration implements WebSocketMessageBrokerConfigurer { (2)

	static final String MESSAGE_PREFIX = "/topic"; (3)

	@Override
	public void registerStompEndpoints(StompEndpointRegistry registry) { (4)
		registry.addEndpoint("/payroll").withSockJS();
	}

	@Override
	public void configureMessageBroker(MessageBrokerRegistry registry) { (5)
		registry.enableSimpleBroker(MESSAGE_PREFIX);
		registry.setApplicationDestinationPrefixes("/app");
	}
}
1个 @EnableWebSocketMessageBroker 打开WebSocket支持。
2个 WebSocketMessageBrokerConfigurer 提供了一个方便的基类来配置基本功能。
3 MESSAGE_PREFIX是您将在每条消息的路由之前添加的前缀。
4 registerStompEndpoints()用于在客户端和服务器上配置后端上的端点以链接(/payroll)。
5 configureMessageBroker() 用于配置用于在服务器和客户端之间中继消息的代理。

通过此配置,您现在可以利用Spring Data REST事件并将其发布到WebSocket上。

订阅Spring Data REST事件

Spring Data REST根据存储库中发生的操作生成多个应用程序事件。以下代码显示了如何订阅其中一些事件:

@Component
@RepositoryEventHandler(Employee.class) (1)
public class EventHandler {

	private final SimpMessagingTemplate websocket; (2)

	private final EntityLinks entityLinks;

	@Autowired
	public EventHandler(SimpMessagingTemplate websocket, EntityLinks entityLinks) {
		this.websocket = websocket;
		this.entityLinks = entityLinks;
	}

	@HandleAfterCreate (3)
	public void newEmployee(Employee employee) {
		this.websocket.convertAndSend(
				MESSAGE_PREFIX + "/newEmployee", getPath(employee));
	}

	@HandleAfterDelete (3)
	public void deleteEmployee(Employee employee) {
		this.websocket.convertAndSend(
				MESSAGE_PREFIX + "/deleteEmployee", getPath(employee));
	}

	@HandleAfterSave (3)
	public void updateEmployee(Employee employee) {
		this.websocket.convertAndSend(
				MESSAGE_PREFIX + "/updateEmployee", getPath(employee));
	}

	/**
	 * Take an {@link Employee} and get the URI using Spring Data REST's {@link EntityLinks}.
	 *
	 * @param employee
	 */
	private String getPath(Employee employee) {
		return this.entityLinks.linkForItemResource(employee.getClass(),
				employee.getId()).toUri().getPath();
	}

}
1个 @RepositoryEventHandler(Employee.class)标记此类以捕获基于雇员的事件。
2个 SimpMessagingTemplateEntityLinks从应用程序上下文自动连接。
3 @HandleXYZ注释标志,需要监听事件的方法。这些方法必须是公开的。

这些处理程序方法中的每一个都调用SimpMessagingTemplate.convertAndSend()以通过WebSocket传输消息。这是一种发布-订阅方法,因此一条消息将转发给每个附加的使用者。

每条消息的路由是不同的,允许将多个消息发送到客户端上不同的接收者,而只需要一个开放的WebSocket(一种节省资源的方法)。

getPath()使用Spring Data RESTEntityLinks查找给定类类型和ID的路径。为了满足客户的需求,此Link对象将转换为Java URI,并提取其路径。

EntityLinks 带有多种实用程序方法,可通过编程方式找到各种资源的路径,无论是单个资源还是集合资源。

本质上,您正在侦听创建,更新和删除事件,并在事件完成后将其通知发送给所有客户端。您也可以在此类操作发生之前对其进行拦截,或者将其记录下来,出于某种原因阻止它们或使用额外的信息修饰域对象。(在下一节中,我们将看到一个方便的用法。)

配置JavaScript WebSocket

下一步是编写一些客户端代码来使用WebSocket事件。主应用程序中的以下块引入了一个模块:

var stompClient = require('./websocket-listener')

该模块如下所示:

'use strict';

const SockJS = require('sockjs-client'); (1)
require('stompjs'); (2)

function register(registrations) {
	const socket = SockJS('/payroll'); (3)
	const stompClient = Stomp.over(socket);
	stompClient.connect({}, function(frame) {
		registrations.forEach(function (registration) { (4)
			stompClient.subscribe(registration.route, registration.callback);
		});
	});
}

module.exports.register = register;
1个 引入SockJS JavaScript库以通过WebSocket进行通信。
2个 拉入stomp-websocket JavaScript库以使用STOMP子协议。
3 将WebSocket指向应用程序的/payroll端点。
4 遍历提供的数组,registrations以便每个人都可以在消息到达时订阅回调。

每个注册条目都有一个route和一个callback。在下一节中,您将看到如何注册事件处理程序。

注册WebSocket事件

在React中,组件的componentDidMount()函数在DOM中呈现后被调用。这也是注册WebSocket事件的合适时机,因为该组件现在已联机并且可以投入使用。以下代码这样做:

componentDidMount() {
	this.loadFromServer(this.state.pageSize);
	stompClient.register([
		{route: '/topic/newEmployee', callback: this.refreshAndGoToLastPage},
		{route: '/topic/updateEmployee', callback: this.refreshCurrentPage},
		{route: '/topic/deleteEmployee', callback: this.refreshCurrentPage}
	]);
}

第一行与之前相同,在此所有员工都是使用页面大小从服务器中提取的。第二行显示了为WebSocket事件注册的JavaScript对象数组,每个对象都有一个route和一个callback

创建新员工时,其行为是刷新数据集,然后使用分页链接导航到最后一页。为什么在浏览到最后之前刷新数据?添加新记录可能会导致创建新页面。尽管可以计算出是否会发生这种情况,但它颠覆了超媒体的观点。与其将定制的页面计数汇总在一起,不如使用现有的链接,并且仅在有性能驱动因素的情况下才这样做,这才是更好的选择。

当员工被更新或删除时,其行为是刷新当前页面。更新记录时,它会影响您正在查看的页面。当您删除当前页面上的记录时,下一页的记录将被拉入当前页面-因此需要刷新当前页面。

这些WebSocket消息无需以开头/topic。这是指示pub-sub语义的通用约定。

在下一节中,您将看到执行这些操作的实际操作。

响应WebSocket事件并更新UI状态

以下代码块包含两个回调,这些回调用于在收到WebSocket事件时更新UI状态:

refreshAndGoToLastPage(message) {
	follow(client, root, [{
		rel: 'employees',
		params: {size: this.state.pageSize}
	}]).done(response => {
		if (response.entity._links.last !== undefined) {
			this.onNavigate(response.entity._links.last.href);
		} else {
			this.onNavigate(response.entity._links.self.href);
		}
	})
}

refreshCurrentPage(message) {
	follow(client, root, [{
		rel: 'employees',
		params: {
			size: this.state.pageSize,
			page: this.state.page.number
		}
	}]).then(employeeCollection => {
		this.links = employeeCollection.entity._links;
		this.page = employeeCollection.entity.page;

		return employeeCollection.entity._embedded.employees.map(employee => {
			return client({
				method: 'GET',
				path: employee._links.self.href
			})
		});
	}).then(employeePromises => {
		return when.all(employeePromises);
	}).then(employees => {
		this.setState({
			page: this.page,
			employees: employees,
			attributes: Object.keys(this.schema.properties),
			pageSize: this.state.pageSize,
			links: this.links
		});
	});
}

refreshAndGoToLastPage()使用熟悉的follow()功能导航到employees带有已size应用参数的链接(插入)this.state.pageSize。收到响应后,您将onNavigate()在最后一节中调用相同的函数,并跳到最后一页,即找到新记录的那一页。

refreshCurrentPage()也使用此follow()功能,但适用this.state.pageSizesize和适用this.state.page.numberpage。这将获取您当前正在查看的同一页面,并相应地更新状态。

此行为告诉每个客户端在发送更新或删除消息时刷新其当前页面。他们的当前页面可能与当前事件无关。但是,弄清楚这一点可能很棘手。如果已删除的记录在第二页上,而您正在查看第三页怎么办?每个条目都会改变。但是,这种期望的行为是根本吗?也许。也许不会。

将状态管理移出本地更新

在完成本节之前,您需要识别一些东西。您刚刚为UI中的状态添加了一种新的更新方式:WebSocket消息到达时。但是更新状态的旧方法仍然存在。

为了简化代码的状态管理,请删除旧方法。换句话说,提交您的POSTPUTDELETE调用,但不要使用它们的结果来更新UI的状态。而是,等待WebSocket事件返回,然后执行更新。

以下代码段显示与onCreate()上一节相同的功能,仅进行了简化:

onCreate(newEmployee) {
	follow(client, root, ['employees']).done(response => {
		client({
			method: 'POST',
			path: response.entity._links.self.href,
			entity: newEmployee,
			headers: {'Content-Type': 'application/json'}
		})
	})
}

在这里,该follow()函数用于获取employees链接,然后应用该POST操作。注意像以前一样client({method: 'GET' …​})没有nothen()done()吗?现在refreshAndGoToLastPage(),您刚刚在中找到了用于监听更新的事件处理程序。

放在一起

完成所有这些修改后,启动应用程序(./mvnw spring-boot:run)并对其进行修改。打开两个浏览器选项卡并调整大小,以便您可以同时看到它们。开始在一个选项卡中进行更新,看看它们如何立即更新另一个选项卡。打开您的手机并访问同一页面。找到一个朋友,并要求那个人做同样的事情。您可能会发现这种动态更新更加敏锐。

想要挑战吗?尝试上一部分中的练习,您可以在两个不同的浏览器选项卡中打开相同的记录。尝试在其中一个更新它,而在另一个中看不到它更新。如果可能的话,条件PUT代码应该仍然可以保护您。但是实现这一目标可能会比较棘手!

审查

在本节中,您:

  • 配置了Spring的WebSocket支持,并带有SockJS备用。

  • 订阅用于从Spring Data REST创建,更新和删除事件以动态更新UI。

  • 发布了受影响的REST资源的URI以及上下文消息(“ / topic / newEmployee”,“ / topic / updateEmployee”,等等)。

  • 在UI中注册了WebSocket侦听器以侦听这些事件。

  • 将侦听器连接到处理程序以更新UI状态。

通过所有这些功能,可以轻松并排运行两个浏览器,并了解如何将一个浏览器更新到另一个浏览器。

问题?

虽然多个显示器可以很好地更新,但仍可以保证精确的行为。例如,创建一个新用户将导致所有用户跳到最后。关于应该如何处理的任何想法?

分页是有用的,但是它提供了一个棘手的状态来管理。此示例应用程序的成本很低,而且React在更新DOM时非常有效,而不会引起UI的大量闪烁。但是对于更复杂的应用程序,并非所有这些方法都适用。

考虑分页设计时,您必须确定客户端之间的预期行为以及是否需要更新。根据您的要求和系统性能,现有的导航超媒体可能就足够了。

第5部分-保护UI和API

在上一节中,您使用Spring Data REST的内置事件处理程序和Spring Framework的WebSocket支持使该应用程序动态响应其他用户的更新。但是,如果没有完整的应用程序,那么任何应用程序都是不完整的,因此只有适当的用户才能访问UI及其背后的资源。

随时从该存储库中获取代码并继续。本节基于上一节的应用程序,并添加了其他内容。

在项目中添加Spring Security

在进行之前,您需要向项目的pom.xml文件中添加几个依赖项:

<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
	<groupId>org.thymeleaf.extras</groupId>
	<artifactId>thymeleaf-extras-springsecurity5</artifactId>
</dependency>

这引入了Spring Boot的Spring Security入门程序以及一些额外的Thymeleaf标签,以便在网页中进行安全性查找。

定义安全模型

在上一节中,您使用了不错的薪资系统。在后端声明内容并让Spring Data REST承担繁重的工作很方便。下一步是对需要建立安全控制的系统进行建模。

如果这是工资核算系统,则只有经理才能访问它。因此,通过对Manager对象建模来开始事情:

@Entity
public class Manager {

	public static final PasswordEncoder PASSWORD_ENCODER = new BCryptPasswordEncoder(); (1)

	private @Id @GeneratedValue Long id; (2)

	private String name; (2)

	private @JsonIgnore String password; (2)

	private String[] roles; (2)

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

	protected Manager() {}

	public Manager(String name, String password, String... roles) {

		this.name = name;
		this.setPassword(password);
		this.roles = roles;
	}

	@Override
	public boolean equals(Object o) {
		if (this == o) return true;
		if (o == null || getClass() != o.getClass()) return false;
		Manager manager = (Manager) o;
		return Objects.equals(id, manager.id) &&
			Objects.equals(name, manager.name) &&
			Objects.equals(password, manager.password) &&
			Arrays.equals(roles, manager.roles);
	}

	@Override
	public int hashCode() {

		int result = Objects.hash(id, name, password);
		result = 31 * result + Arrays.hashCode(roles);
		return result;
	}

	public Long getId() {
		return id;
	}

	public void setId(Long id) {
		this.id = id;
	}

	public String getName() {
		return name;
	}

	public void setName(String name) {
		this.name = name;
	}

	public String getPassword() {
		return password;
	}

	public String[] getRoles() {
		return roles;
	}

	public void setRoles(String[] roles) {
		this.roles = roles;
	}

	@Override
	public String toString() {
		return "Manager{" +
			"id=" + id +
			", name='" + name + '\'' +
			", roles=" + Arrays.toString(roles) +
			'}';
	}
}
1个 PASSWORD_ENCODER 是在比较之前对新密码进行加密或获取密码输入并对其进行加密的方法。
2个 idnamepassword,和roles定义来限制访问所需的参数。
3 定制的setPassword()方法可确保永远不会以明文形式存储密码。

在设计安全层时,有一件关键的事情要牢记。保护正确的数据位(例如密码),不要让它们打印到控制台,日志中或通过JSON序列化导出。

  • @JsonIgnore 应用于密码字段可防止Jackson对该字段进行序列化。

创建经理的资料库

Spring Data非常擅长管理实体。为什么不创建一个存储库来处理这些管理器?以下代码这样做:

@RepositoryRestResource(exported = false)
public interface ManagerRepository extends Repository<Manager, Long> {

	Manager save(Manager manager);

	Manager findByName(String name);

}

CrudRepository不需要扩展通常的方法,您不需要太多的方法。相反,您需要保存数据(也用于更新),并且需要查找现有用户。因此,您可以使用Spring Data Common的最小Repository标记界面。它没有预定义的操作。

默认情况下,Spring Data REST将导出其找到的任何存储库。您不希望此存储库公开以进行REST操作!应用@RepositoryRestResource(exported = false)注释以阻止其导出。这样可以防止存储库及其元数据被提供。

将员工与其经理联系起来

建模安全性的最后一点是使员工与经理相关联。在此域中,一个雇员可以有一个经理,而一个经理可以有多个雇员。以下代码定义了这种关系:

@Entity
public class Employee {

	private @Id @GeneratedValue Long id;
	private String firstName;
	private String lastName;
	private String description;

	private @Version @JsonIgnore Long version;

	private @ManyToOne Manager manager; (1)

	private Employee() {}

	public Employee(String firstName, String lastName, String description, Manager manager) { (2)
		this.firstName = firstName;
		this.lastName = lastName;
		this.description = description;
		this.manager = manager;
	}

	@Override
	public boolean equals(Object o) {
		if (this == o) return true;
		if (o == null || getClass() != o.getClass()) return false;
		Employee employee = (Employee) o;
		return Objects.equals(id, employee.id) &&
			Objects.equals(firstName, employee.firstName) &&
			Objects.equals(lastName, employee.lastName) &&
			Objects.equals(description, employee.description) &&
			Objects.equals(version, employee.version) &&
			Objects.equals(manager, employee.manager);
	}

	@Override
	public int hashCode() {

		return Objects.hash(id, firstName, lastName, description, version, manager);
	}

	public Long getId() {
		return id;
	}

	public void setId(Long id) {
		this.id = id;
	}

	public String getFirstName() {
		return firstName;
	}

	public void setFirstName(String firstName) {
		this.firstName = firstName;
	}

	public String getLastName() {
		return lastName;
	}

	public void setLastName(String lastName) {
		this.lastName = lastName;
	}

	public String getDescription() {
		return description;
	}

	public void setDescription(String description) {
		this.description = description;
	}

	public Long getVersion() {
		return version;
	}

	public void setVersion(Long version) {
		this.version = version;
	}

	public Manager getManager() {
		return manager;
	}

	public void setManager(Manager manager) {
		this.manager = manager;
	}

	@Override
	public String toString() {
		return "Employee{" +
			"id=" + id +
			", firstName='" + firstName + '\'' +
			", lastName='" + lastName + '\'' +
			", description='" + description + '\'' +
			", version=" + version +
			", manager=" + manager +
			'}';
	}
}
1个 manager属性通过JPA的@ManyToOne属性链接。Manager不需要@OneToMany,因为您还没有定义查找的需要。
2个 实用程序构造函数调用已更新以支持初始化。

确保员工与经理的关系

当定义安全策略时,Spring Security支持多种选择。在本节中,您希望限制某些事情,以便仅经理可以查看员工工资数据,并且保存,更新和删除操作仅限于员工的经理。换句话说,任何经理都可以登录并查看数据,但是只有给定员工的经理才能进行任何更改。以下代码实现了这些目标:

@PreAuthorize("hasRole('ROLE_MANAGER')") (1)
public interface EmployeeRepository extends PagingAndSortingRepository<Employee, Long> {

	@Override
	@PreAuthorize("#employee?.manager == null or #employee?.manager?.name == authentication?.name")
	Employee save(@Param("employee") Employee employee);

	@Override
	@PreAuthorize("@employeeRepository.findById(#id)?.manager?.name == authentication?.name")
	void deleteById(@Param("id") Long id);

	@Override
	@PreAuthorize("#employee?.manager?.name == authentication?.name")
	void delete(@Param("employee") Employee employee);

}
1个 @PreAuthorize界面顶部的限制访问的人ROLE_MANAGER

在上save(),员工的经理为空(在未分配经理的情况下初始创建新员工),或者员工的经理名称与当前经过身份验证的用户名匹配。在这里,您正在使用Spring Security的SpEL表达式来定义访问。它带有一个方便的?.属性导航器来处理空检查。同样重要的是要注意使用@Param(…​)on参数将HTTP操作与方法链接。

在上delete(),该方法可以访问该雇员,或者,如果该方法仅具有id,则它必须employeeRepository在应用程序上下文中找到,执行findOne(id),然后针对当前经过身份验证的用户检查管理器。

编写UserDetails服务

与安全性集成的一个共同点是定义一个UserDetailsService。这是将用户的数据存储连接到Spring Security界面的方法。Spring Security需要一种方法来查找用户以进行安全检查,这就是桥梁。幸运的是,借助Spring Data,所做的工作非常少:

@Component
public class SpringDataJpaUserDetailsService implements UserDetailsService {

	private final ManagerRepository repository;

	@Autowired
	public SpringDataJpaUserDetailsService(ManagerRepository repository) {
		this.repository = repository;
	}

	@Override
	public UserDetails loadUserByUsername(String name) throws UsernameNotFoundException {
		Manager manager = this.repository.findByName(name);
		return new User(manager.getName(), manager.getPassword(),
				AuthorityUtils.createAuthorityList(manager.getRoles()));
	}

}

SpringDataJpaUserDetailsService实现Spring Security的UserDetailsService。界面有一种方法:loadUserByUsername()。该方法旨在返回一个UserDetails对象,以便Spring Security可以询问用户的信息。

因为您有一个ManagerRepository,所以无需编写任何SQL或JPA表达式即可提取此所需数据。在此类中,它是通过构造函数注入自动装配的。

loadUserByUsername()进入您刚才编写的自定义查找器,findByName()。然后,它填充一个Spring SecurityUser实例,该实例实现了该UserDetails接口。您还将使用Spring Securiy的功能AuthorityUtils,从一系列基于字符串的角色过渡到Listtype为Java的Java GrantedAuthority

连线您的安全政策

@PreAuthorize应用于存储库的表达式是访问规则。这些规则在没有安全策略的情况下是无济于事的:

@Configuration
@EnableWebSecurity (1)
@EnableGlobalMethodSecurity(prePostEnabled = true) (2)
public class SecurityConfiguration extends WebSecurityConfigurerAdapter { (3)

	@Autowired
	private SpringDataJpaUserDetailsService userDetailsService; (4)

	@Override
	protected void configure(AuthenticationManagerBuilder auth) throws Exception {
		auth
			.userDetailsService(this.userDetailsService)
				.passwordEncoder(Manager.PASSWORD_ENCODER);
	}

	@Override
	protected void configure(HttpSecurity http) throws Exception { (5)
		http
			.authorizeRequests()
				.antMatchers("/built/**", "/main.css").permitAll()
				.anyRequest().authenticated()
				.and()
			.formLogin()
				.defaultSuccessUrl("/", true)
				.permitAll()
				.and()
			.httpBasic()
				.and()
			.csrf().disable()
			.logout()
				.logoutSuccessUrl("/");
	}

}

该代码具有很多复杂性,因此,我们将首先介绍注释和API,以逐步进行说明。然后,我们将讨论它定义的安全策略。

1个 @EnableWebSecurity告诉Spring Boot放弃其自动配置的安全策略,而改用该策略。对于快速演示,可以自动配置安全性。但是,对于任何真实的事情,您都应该自己编写策略。
2个 @EnableGlobalMethodSecurity使用Spring Security的复杂功能@Pre@Post注释打开方法级别的安全性。
3 它扩展WebSecurityConfigurerAdapter了编写策略的便捷基类。
4 SpringDataJpaUserDetailsService通过现场注入自动接线,然后通过该configure(AuthenticationManagerBuilder)方法将其插入。在PASSWORD_ENCODERManager也成立。
5 关键的安全策略是使用configure(HttpSecurity)方法调用以纯Java编写的。

安全策略说要使用先前定义的访问规则来授权所有请求:

  • 列出的路径antMatchers()被授予无条件访问权限,因为没有理由阻止静态Web资源。

  • 任何与该策略不匹配的内容都属于anyRequest().authenticated(),这意味着它需要身份验证。

  • 设置了这些访问规则后,系统将指示Spring Security使用基于表单的身份验证(默认为/成功时),并授予对登录页面的访问权限。

  • 在禁用CSRF的情况下,还配置了BASIC登录。这主要是用于演示,不建议在没有仔细分析的情况下用于生产系统。

  • 注销配置为将用户带到/

在尝试curl时,BASIC身份验证非常方便。使用curl访问基于表单的系统令人生畏。重要的是要认识到,使用HTTP(不是HTTPS)上的任何机制进行身份验证会使您面临通过网络嗅探凭据的风险。CSRF是保持完整的好协议。禁用它可以使与BASIC的交互和卷曲变得更容易。在生产中,最好将其保留。

自动添加安全详细信息

良好的用户体验的一部分是应用程序何时可以自动应用上下文。在此示例中,如果已登录的经理创建了新的员工记录,则该经理拥有该记录是很有意义的。使用Spring Data REST的事件处理程序,用户无需显式链接它。它还可以确保用户不会意外将记录分配给错误的管理员。该SpringDataRestEventHandler句柄我们:

@Component
@RepositoryEventHandler(Employee.class) (1)
public class SpringDataRestEventHandler {

	private final ManagerRepository managerRepository;

	@Autowired
	public SpringDataRestEventHandler(ManagerRepository managerRepository) {
		this.managerRepository = managerRepository;
	}

	@HandleBeforeCreate
	@HandleBeforeSave
	public void applyUserInformationUsingSecurityContext(Employee employee) {

		String name = SecurityContextHolder.getContext().getAuthentication().getName();
		Manager manager = this.managerRepository.findByName(name);
		if (manager == null) {
			Manager newManager = new Manager();
			newManager.setName(name);
			newManager.setRoles(new String[]{"ROLE_MANAGER"});
			manager = this.managerRepository.save(newManager);
		}
		employee.setManager(manager);
	}
}
1个 @RepositoryEventHandler(Employee.class)将此事件处理程序标记为仅适用于Employee对象。该@HandleBeforeCreate注解给你一个机会来改变输入Employee记录之前,它被写入到数据库中。

在这种情况下,您可以查找当前用户的安全上下文以获取用户名。然后,您可以通过使用查找关联的经理findByName()并将其应用于经理。如果系统中尚不存在新的管理员,则可以使用一些额外的胶水代码来创建新的管理员。但是,这主要是为了支持数据库的初始化。在实际的生产系统中,应删除该代码,而应依赖DBA或Security Ops团队来正确维护用户数据存储。

预加载经理数据

加载经理并将员工链接到这些经理很简单:

@Component
public class DatabaseLoader implements CommandLineRunner {

	private final EmployeeRepository employees;
	private final ManagerRepository managers;

	@Autowired
	public DatabaseLoader(EmployeeRepository employeeRepository,
						  ManagerRepository managerRepository) {

		this.employees = employeeRepository;
		this.managers = managerRepository;
	}

	@Override
	public void run(String... strings) throws Exception {

		Manager greg = this.managers.save(new Manager("greg", "turnquist",
							"ROLE_MANAGER"));
		Manager oliver = this.managers.save(new Manager("oliver", "gierke",
							"ROLE_MANAGER"));

		SecurityContextHolder.getContext().setAuthentication(
			new UsernamePasswordAuthenticationToken("greg", "doesn't matter",
				AuthorityUtils.createAuthorityList("ROLE_MANAGER")));

		this.employees.save(new Employee("Frodo", "Baggins", "ring bearer", greg));
		this.employees.save(new Employee("Bilbo", "Baggins", "burglar", greg));
		this.employees.save(new Employee("Gandalf", "the Grey", "wizard", greg));

		SecurityContextHolder.getContext().setAuthentication(
			new UsernamePasswordAuthenticationToken("oliver", "doesn't matter",
				AuthorityUtils.createAuthorityList("ROLE_MANAGER")));

		this.employees.save(new Employee("Samwise", "Gamgee", "gardener", oliver));
		this.employees.save(new Employee("Merry", "Brandybuck", "pony rider", oliver));
		this.employees.save(new Employee("Peregrin", "Took", "pipe smoker", oliver));

		SecurityContextHolder.clearContext();
	}
}

一个麻烦是,当此加载程序运行时,Spring Security在启用访问规则的情况下将处于活动状态。因此,为了保存员工数据,您必须使用Spring Security的setAuthentication()API以正确的名称和角色对该加载器进行身份验证。最后,清除安全上下文。

游览您的安全REST服务

完成所有这些修改后,您可以启动应用程序(./mvnw spring-boot:run)并通过使用以下curl(与输出一起显示)来检出修改:

$ curl -v -u greg:turnquist本地主机:8080 / api / employees / 1
*正在尝试:: 1 ...
*连接到localhost(:: 1)端口8080(#0)
*使用Basic与用户'greg'进行服务器身份验证
> GET / api / employees / 1 HTTP / 1.1
>主机:localhost:8080
>授权:基本Z3JlZzp0dXJucXVpc3Q =
>用户代理:curl / 7.43.0
>接受:* / *
>
<HTTP / 1.1 200确定
<服务器:Apache-Coyote / 1.1
<X内容类型选项:nosniff
<X-XSS-Protection:1;模式=阻止
<缓存控制:无缓存,无存储,最大年龄= 0,必须重新验证
<语法:无缓存
<过期:0
<X-Frame-Options:DENY
<Set-Cookie:JSESSIONID = E27F929C1836CC5BABBEAB78A548DF8C; 路径= /; HttpOnly
<ETag:“ 0”
<内容类型:application / hal + json; charset = UTF-8
<传输编码:分块
<日期:2015年8月25日,星期二,格林尼治标准时间
<
{
  “ firstName”:“ Frodo”,
  “ lastName”:“ Baggins”,
  “ description”:“ ring bearer”,
  “经理” : {
    “ name”:“ greg”,
    “角色”:[“ ROLE_MANAGER”]
  },
  “ _links”:{
    “自己” : {
      “ href”:“ http:// localhost:8080 / api / employees / 1”
    }
  }
}

这显示的细节比您在第一部分中看到的要多。首先,Spring Security启用了几种HTTP协议来防御各种攻击媒介(Pragma,Expires,X-Frame-Options等)。您还将发布-u greg:turnquist用于呈现Authorization标头的BASIC凭证。

在所有标题中,您可以ETag从版本化资源中看到标题。

最后,在数据本身内部,您可以看到一个新属性:manager。您会看到它包含名称和角色,但不包含密码。那是由于@JsonIgnore在该字段上使用。由于Spring Data REST并未导出该存储库,因此其值将内联在此资源中。在下一节中更新UI时,将充分利用它。

在UI中显示经理信息

通过在后端进行所有这些修改,您现在可以转移到更新前端中的内容。首先,您可以在<Employee />React组件中显示一名员工的经理:

class Employee extends React.Component {

	constructor(props) {
		super(props);
		this.handleDelete = this.handleDelete.bind(this);
	}

	handleDelete() {
		this.props.onDelete(this.props.employee);
	}

	render() {
		return (
			<tr>
				<td>{this.props.employee.entity.firstName}</td>
				<td>{this.props.employee.entity.lastName}</td>
				<td>{this.props.employee.entity.description}</td>
				<td>{this.props.employee.entity.manager.name}</td>
				<td>
					<UpdateDialog employee={this.props.employee}
								  attributes={this.props.attributes}
								  onUpdate={this.props.onUpdate}
								  loggedInManager={this.props.loggedInManager}/>
				</td>
				<td>
					<button onClick={this.handleDelete}>Delete</button>
				</td>
			</tr>
		)
	}
}

这只会为添加一列this.props.employee.entity.manager.name

过滤出JSON模式元数据

如果数据输出中显示了一个字段,则可以假定该字段在JSON Schema元数据中具有一个条目。您可以在以下摘录中看到它:

{
	...
    “经理” : {
      “ readOnly”:false,
      “ $ ref”:“#/描述符/管理器”
    },
    ...
  },
  ...
  “ $ schema”:“ https://json-schema.org/draft-04/schema#”
}

manager您不希望人们直接编辑该字段。由于它是内联的,因此应将其视为只读属性。从筛选出的条目内联CreateDialogUpdateDialog,可以用来获取JSON模式元数据后删除这些条目loadFromServer()

/**
 * Filter unneeded JSON Schema properties, like uri references and
 * subtypes ($ref).
 */
Object.keys(schema.entity.properties).forEach(function (property) {
	if (schema.entity.properties[property].hasOwnProperty('format') &&
		schema.entity.properties[property].format === 'uri') {
		delete schema.entity.properties[property];
	}
	else if (schema.entity.properties[property].hasOwnProperty('$ref')) {
		delete schema.entity.properties[property];
	}
});

this.schema = schema.entity;
this.links = employeeCollection.entity._links;
return employeeCollection;

此代码修剪掉URI关系以及$ ref条目。

诱捕未经授权的访问

通过在后端配置安全检查,您可以添加处理程序,以防有人尝试未经授权而更新记录:

onUpdate(employee, updatedEmployee) {
	if(employee.entity.manager.name === this.state.loggedInManager) {
		updatedEmployee["manager"] = employee.entity.manager;
		client({
			method: 'PUT',
			path: employee.entity._links.self.href,
			entity: updatedEmployee,
			headers: {
				'Content-Type': 'application/json',
				'If-Match': employee.headers.Etag
			}
		}).done(response => {
			/* Let the websocket handler update the state */
		}, response => {
			if (response.status.code === 403) {
				alert('ACCESS DENIED: You are not authorized to update ' +
					employee.entity._links.self.href);
			}
			if (response.status.code === 412) {
				alert('DENIED: Unable to update ' + employee.entity._links.self.href +
					'. Your copy is stale.');
			}
		});
	} else {
		alert("You are not authorized to update");
	}
}

您有捕获HTTP 412错误的代码。这将捕获HTTP 403状态代码并提供适当的警报。

您可以对删除操作执行相同的操作:

onDelete(employee) {
	client({method: 'DELETE', path: employee.entity._links.self.href}
	).done(response => {/* let the websocket handle updating the UI */},
	response => {
		if (response.status.code === 403) {
			alert('ACCESS DENIED: You are not authorized to delete ' +
				employee.entity._links.self.href);
		}
	});
}

与此类似,使用定制的错误消息进行编码。

向用户界面添加一些安全细节

使该应用程序版本最高的最后一件事是显示谁登录,并通过<div>index.html文件前面添加以下新内容来提供注销按钮react <div>

<div>
    Hello, <span id="managername" th:text="${#authentication.name}">user</span>.
    <form th:action="@{/logout}" method="post">
        <input type="submit" value="Log Out"/>
    </form>
</div>

放在一起

要在前端查看这些更改,请重新启动应用程序并导航至http:// localhost:8080

您将立即重定向到登录表单。该表单由Spring Security提供,但是您可以根据需要创建自己的表单。以greg/turnquist身份登录,如下图所示:

安全1

您可以看到新添加的管理员列。翻阅几页,直到找到Oliver拥有的雇员,如下图所示:

安全2

单击“更新”,进行一些更改,然后再次单击“更新”。它应该失败,并显示以下弹出窗口:

安全3

如果尝试Delete,它将失败并显示类似消息。如果您创建新员工,则应将其分配给您。

审查

在本节中,您:

  • 定义了模型,manager并通过一对多关系将其链接到员工。

  • 为管理者创建了一个存储库,并告诉Spring Data REST不要导出。

  • 为员工存储库编写了一组访问规则,并编写了安全策略。

  • 编写另一个Spring Data REST事件处理程序以在创建事件发生之前对其进行陷阱,以便可以将当前用户分配为员工的经理。

  • 更新了用户界面,以显示员工的经理,并且在采取未经授权的操作时还显示错误弹出窗口。

问题?

该网页已变得非常复杂。但是,如何管理关系和内联数据呢?创建和更新对话框并不真正适合于此。它可能需要一些自定义的书面形式。

经理有权访问员工数据。员工应该有访问权限吗?如果要添加更多详细信息,例如电话号码和地址,您将如何建模?您将如何授予员工访问系统的权限,以便他们可以更新那些特定字段?是否还有其他易于在页面上放置的超媒体控件?

是否要编写新指南或为现有指南做出贡献?查看我们的贡献准则

所有指南均以代码的ASLv2许可证和写作的Attribution,NoDerivatives创用CC许可证发布。