本教程展示了一系列使用Spring Data REST的应用程序,其强大的后端功能与React的先进功能相结合,以构建易于使用的UI。

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

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

第1部分-基本功能

欢迎 Spring 社区,

在本部分中,您将看到如何快速启动并运行Spring Data REST应用程序。然后,您将使用Facebook的React.js工具集在其之上构建一个简单的UI。

步骤0-设定您的环境

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

如果您想自己做,请访问https://start.spring.io并选择以下项目:

  • 其余资料库

  • 胸腺

  • JPA

  • H2

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

一开始...

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

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

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

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

声明您的域名

任何基于Spring Data REST的应用程序的基石都是领域对象。对于此部分,您将构建一个应用程序来跟踪公司的员工。通过创建如下数据类型来开始:

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 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 + '\'' +
			'}';
	}
}
  • @Entity是一个JPA批注,它表示要存储在关系表中的整个类。

  • @Id@GeneratedValue是用于注释主键的JPA注释,它在需要时自动生成。

该实体用于跟踪员工信息。在这种情况下,其名称和职位描述。

Spring Data REST不仅限于JPA。它支持许多NoSQL数据存储,但是您将不在这里介绍。

定义存储库

Spring Data REST应用程序的另一个关键部分是创建相应的存储库定义。

src / main / java / com / greglturnquist / payroll / EmployeeRepository.java
public interface EmployeeRepository extends CrudRepository<Employee, Long> {

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

这就是所需要的!实际上,如果它是顶级且可见的,则您甚至不必注释它。如果您使用IDE并打开CrudRepository ,您将找到一个已经定义好的预先构建方法的拳头。

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

预加载演示

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

src / main / java / com / greglturnquist / payroll / DatabaseLoader.java
@Component
public class DatabaseLoader implements CommandLineRunner {

	private final EmployeeRepository repository;

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

	@Override
	public void run(String... strings) throws Exception {
		this.repository.save(new Employee("Frodo", "Baggins", "ring bearer"));
	}
}
  • 该课程标记为Spring的@Component批注,以便由@SpringBootApplication

  • 它实现了Spring Boot的CommandLineRunner以便在创建并注册所有bean之后运行它。

  • 它使用构造函数注入和自动装配来自动创建Spring Data EmployeeRepository

  • run()用命令行参数调用方法,加载数据。

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

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

调整根URI

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

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

启动后端

全面运行REST API的最后一步是编写一个public static void main使用Spring Boot:

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.spring.io生成的,则现在可以通过运行它来启动它main() IDE中的方法,或键入./mvnw spring-boot:run在命令行上。 (对于Windows用户,为mvnw.bat)。

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

游览您的REST服务

随着应用程序的运行,您可以使用cURL (或您喜欢的任何其他工具)在命令行中检出内容。

$ curl localhost:8080/api
{
  "_links" : {
    "employees" : {
      "href" : "http://localhost:8080/api/employees"
    },
    "profile" : {
      "href" : "http://localhost:8080/api/profile"
    }
  }
}

当您对根节点执行ping操作时,您会得到一系列链接,这些链接包装在HAL格式的JSON文档中

  • _links是可用链接的集合。

  • 员工指向由定义的员工对象的汇总根EmployeeRepository接口。

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

您可以通过导航员工链接来进一步研究该服务。

$ curl localhost:8080/api/employees
{
  "_embedded" : {
    "employees" : [ {
      "firstName" : "Frodo",
      "lastName" : "Baggins",
      "description" : "ring bearer",
      "_links" : {
        "self" : {
          "href" : "http://localhost:8080/api/employees/1"
        }
      }
    } ]
  }
}

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

随您先前预加载的数据一起提供的是带有自我链接的_links属性。这是该特定员工的规范链接。什么是规范的?这意味着没有上下文。例如,可以通过/ api / orders / 1 / processor之类的链接来提取同一用户,在该链接中,员工被分配处理特定的订单。在这里,与其他实体没有关系。

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

如果愿意,您可以决定查看一位员工。

$ curl localhost:8080/api/employees/1
{
  "firstName" : "Frodo",
  "lastName" : "Baggins",
  "description" : "ring bearer",
  "_links" : {
    "self" : {
      "href" : "http://localhost:8080/api/employees/1"
    }
  }
}

这里没有什么变化,只是因为只有域对象,所以不需要_embedded包装器。

很好,但是您可能很想创建一些新条目。

$ curl -X POST localhost:8080/api/employees -d "{\"firstName\": \"Bilbo\", \"lastName\": \"Baggins\", \"description\": \"burglar\"}" -H "Content-Type:application/json"
{
  "firstName" : "Bilbo",
  "lastName" : "Baggins",
  "description" : "burglar",
  "_links" : {
    "self" : {
      "href" : "http://localhost:8080/api/employees/2"
    }
  }
}

您还可以如本相关指南中所示进行PUT,PATCH和DELETE。但是,我们不要深入探讨这一点。您已经花费了太多时间手动与该REST服务进行交互。您是否不想构建一个光滑的UI?

设置自定义UI控制器

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

src / main / java / com / greglturnquist / payroll / HomeController.java
@Controller
public class HomeController {

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

}
  • @Controller将此类标记为Spring MVC控制器。

  • @RequestMapping标记index()支持的方法/路线。

  • 它返回index作为模板的名称,Spring Boot的自动配置的视图解析器将映射到该模板src/main/resources/templates/index.html

定义HTML模板

您正在使用Thymeleaf,尽管您不会真正使用它的许多功能。

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>

此模板中的关键部分是

组件在中间。在这里,您将指示React插入渲染的输出。

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

本教程未显示main.css ,但您可以在上方看到它的链接。说到CSS,Spring Boot会处理在src/main/resources/static自动。放自己的main.css文件在那里。它没有显示在教程中,因为我们的重点是React和Spring Data REST,而不是CSS3。

加载JavaScript模块

本部分包含准系统信息,以帮助您摆脱JavaScript障碍。虽然你可以安装所有的JavaScript的命令行工具,你不必 。至少还没有。相反,您只需添加以下内容即可pom.xml构建文件:

frontend-maven-plugin用于构建JavaScript位
<plugin>
	<groupId>com.github.eirslett</groupId>
	<artifactId>frontend-maven-plugin</artifactId>
	<version>1.6</version>
	<configuration>
		<installDirectory>target</installDirectory>
	</configuration>
	<executions>
		<execution>
			<id>install node and npm</id>
			<goals>
				<goal>install-node-and-npm</goal>
			</goals>
			<configuration>
				<nodeVersion>v10.11.0</nodeVersion>
				<npmVersion>6.4.1</npmVersion>
			</configuration>
		</execution>
		<execution>
			<id>npm install</id>
			<goals>
				<goal>npm</goal>
			</goals>
			<configuration>
				<arguments>install</arguments>
			</configuration>
		</execution>
		<execution>
			<id>webpack build</id>
			<goals>
				<goal>webpack</goal>
			</goals>
		</execution>
	</executions>
</plugin>

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

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

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

  • webpack命令将执行webpack二进制文件,该文件将根据以下内容编译所有JavaScript代码: webpack.config.js

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

安装了哪些模块?JavaScript开发人员通常使用npm建立一个package.json文件如下所示:

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"
  },
  "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定义一个构建文件。

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()呼叫将填充到此文件中。

  • 两者都挂在babel引擎上es2015react预设,以便将ES6 React代码编译为可以在任何标准浏览器中运行的格式。

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

是否想自动查看您的JavaScript更改?跑npm run-script watch使webpack进入监视模式。当您编辑源代码时,它将重新生成bundle.js。

完成所有操作后,您可以集中精力在加载DOM之后获取的React位。它分为以下部分:

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

src / main / js / app.js
const React = require('react');
const ReactDOM = require('react-dom');
const client = require('./client');
  • ReactReactDOM是Facebook用于构建此应用程序的主要库。

  • client是自定义代码,用于配置rest.js以包括对HAL,URI模板等的支持。还将默认的接受请求标头设置为application / hal + json 。您可以在此处阅读代码

的代码client由于您用于进行REST调用的内容并不重要,因此未显示。随时检查源代码,但是重点是,您可以插入Restangular或任何您喜欢的东西,并且概念仍然适用。

潜入React

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

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

src / main / js / app.js-应用程序组件
class App extends React.Component {

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

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

	render() {
		return (
			<EmployeeList employees={this.state.employees}/>
		)
	}
}
  • class Foo extends React.Component{…​}是创建React组件的方法。

  • componentDidMount是React在DOM中渲染组件后调用的API。

  • render是用于在屏幕上“绘制”组件的API。

在React中,大写是组件命名的约定。

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

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

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

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

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

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

在此代码中,该函数通过client ,一个与Promise兼容的rest.js实例。完成后从中检索/api/employees ,然后调用内部的函数done()并根据其HAL文档设置状态( response.entity._embedded.employees )。您可能还记得curl /api/employees 前面的内容 ,看看它如何映射到此结构上。

状态更新后, render()函数由框架调用。员工状态数据包含在创建反应组件作为输入参数。

以下是EmployeeList

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的地图功能, this.props.employees从一组员工记录转换为一组 React组件(您会在后面看到一些内容)。

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

这显示了正在创建的新React组件(请注意大写格式)以及两个属性: keydata 。这些是从提供的值employee._links.self.hrefemployee

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

最后,返回包装在employees使用映射构建。

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

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

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

JSX还包含一些ES6 。该代码中使用的一个是箭头功能 。它避免了创建一个嵌套函数()有自己的作用域这一点 ,避免了需要一个变量

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

接下来,您需要实际定义是。

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对象如何使传递从服务器获取的数据变得容易?

因为此组件不管理任何状态,也不处理用户输入,所以没有其他事情要做。这可能会诱使您将其塞入之上。别做!取而代之的是,将您的应用程序分解为各个小组件,每个组件都完成一项工作,这将使将来构建功能变得更加容易。

最后一步是渲染整个内容。

src / main / js / app.js-呈现代码
ReactDOM.render(
	<App />,
	document.getElementById('react')
)

React.render()接受两个参数:您定义的React组件以及将其注入的DOM节点。记住你如何看待

HTML页面中较早的项目?这是它被拾起并插入的地方。

一切就绪后,重新运行应用程序( ./mvnw spring-boot:run )并访问http:// localhost:8080

基本1

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

还记得使用cURL创建新条目吗?再做一次。

curl -X POST localhost:8080/api/employees -d "{\"firstName\": \"Bilbo\", \"lastName\": \"Baggins\", \"description\": \"burglar\"}" -H "Content-Type:application/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-超文本驱动

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

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

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

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

  • POST-创建新资源,而不用说

  • PUT-替换现有资源,覆盖现有资源(如果有的话)

  • 删除-删除现有资源

  • PATCH-部分更改现有资源

这些是具有良好规范的标准化HTTP动词。通过选择并使用已经创造的HTTP操作,我们不必发明新的语言并教育行业。

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

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

从后端开启分页

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

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

}

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

重新启动应用程序( ./mvnw spring-boot:run ),并查看其工作原理。

$ curl "localhost:8080/api/employees?size=2"
{
  "_links" : {
    "first" : {
      "href" : "http://localhost:8080/api/employees?page=0&size=2"
    },
    "self" : {
      "href" : "http://localhost:8080/api/employees"
    },
    "next" : {
      "href" : "http://localhost:8080/api/employees?page=1&size=2"
    },
    "last" : {
      "href" : "http://localhost:8080/api/employees?page=2&size=2"
    }
  },
  "_embedded" : {
    "employees" : [ {
      "firstName" : "Frodo",
      "lastName" : "Baggins",
      "description" : "ring bearer",
      "_links" : {
        "self" : {
          "href" : "http://localhost:8080/api/employees/1"
        }
      }
    }, {
      "firstName" : "Bilbo",
      "lastName" : "Baggins",
      "description" : "burglar",
      "_links" : {
        "self" : {
          "href" : "http://localhost:8080/api/employees/2"
        }
      }
    } ]
  },
  "page" : {
    "size" : 2,
    "totalElements" : 6,
    "totalPages" : 3,
    "number" : 0
  }
}

默认页面大小为20,因此要查看实际使用情况, ?size=2应用。不出所料,只列出了两名员工。此外,还有一个firstnextlast链接。还有一个链接,没有上下文, 包括页面参数

如果您导航到下一个环节,你会再看到上一个链接,以及:

$ curl "http://localhost:8080/api/employees?page=1&size=2"
{
  "_links" : {
    "first" : {
      "href" : "http://localhost:8080/api/employees?page=0&size=2"
    },
    "prev" : {
      "href" : "http://localhost:8080/api/employees?page=0&size=2"
    },
    "self" : {
      "href" : "http://localhost:8080/api/employees"
    },
    "next" : {
      "href" : "http://localhost:8080/api/employees?page=2&size=2"
    },
    "last" : {
      "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中查找名为employee的关系(或rel )。找到它的href并导航到它。如果阵列中存在其他关系,请冲洗并重复。

有时,仅靠依靠是不够的。在这段代码中,它还会插入查询参数?。大小= 。您还可以看到其他选项。

抓取JSON模式元数据

导航与基于尺寸的员工查询后,employeeCollection是在你的指尖。在上一节中,我们将其命名为day并将其显示在内部 。今天,您正在执行另一个调用以获取在以下位置找到的一些JSON Schema元数据 /api/profile/employees/

您可以自己查看数据:

$ curl http://localhost:8080/api/profile/employees -H "Accept:application/schema+json"
{
  "title" : "Employee",
  "properties" : {
    "firstName" : {
      "title" : "First name",
      "readOnly" : false,
      "type" : "string"
    },
    "lastName" : {
      "title" : "Last name",
      "readOnly" : false,
      "type" : "string"
    },
    "description" : {
      "title" : "Description",
      "readOnly" : false,
      "type" : "string"
    }
  },
  "definitions" : { },
  "type" : "object",
  "$schema" : "https://json-schema.org/draft-04/schema#"
}
/ profile / employees中元数据的默认格式为ALPS。不过,在这种情况下,您正在使用内容协商来获取JSON模式。

通过在中捕获此信息 `组件的状态,稍后在构建输入表单时可以充分利用它。

创建新记录

有了此元数据,您现在可以向UI添加一些额外的控件。创建一个新的React组件,

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()功能。

渲染图

您的代码映射到attribute属性中找到的JSON Schema数据,并将其转换为

元素。

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

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

  • 占位符是我们可以向用户显示字段的位置。

  • 您可能曾经拥有过name属性,但这不是必需的。借助React, ref是获取特定DOM节点的机制(您将很快看到)。

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

在此组件的顶级内部

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

坐落在里面

是一种表单,在其中注入了输入字段的动态列表,后跟“ 创建”按钮。该按钮有一个onClick={this.handleSubmit}事件处理程序。这是注册事件处理程序的React方法。

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

处理用户输入

handleSubmit()函数首先阻止事件在层次结构中冒泡。然后,它使用相同的JSON Schema属性属性来查找每个使用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()功能以导航到执行POST操作的员工资源。在这种情况下,不需要应用任何参数,因此基于字符串的rels数组很好。在这种情况下,将返回POST调用。这允许下一个then()子句来处理POST的结果。

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

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

这在我们的UI中引入了分页的概念。让我们接下来解决这个问题!

第一次使用基于承诺的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的本教程

要记住的秘密是then()函数需要返回某些值,无论它是值还是其他承诺。 done()函数不会返回任何内容,并且您之后也不会链接任何内容。如果您还没有注意到, client (这是rest来自rest.js)以及follow函数返回承诺。

分页数据

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

上一节中 ,您使用了页面控件来跳至最后一页。动态地将其应用于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()下降到 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变成一个数组组件。然后建立一个数组navLinks ,一组HTML按钮。

由于React是基于XML的,因此您不能将“ <”放在

然后你可以看到{navLinks}插入返回的HTML的底部。

删除现有记录

删除条目要容易得多。保留其基于HAL的记录,并将DELETE应用于其自身链接。

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

  • 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属性找到DOM节点并提取其值,全部通过React的findDOMNode()辅助功能。它通过检查输入是否为数字字符串来测试输入是否确实是数字。如果是这样,它将调用回调,将新的页面大小发送到App反应组件。如果不是,则将刚刚输入的字符从输入中删除。

是什么App当它得到一个updatePageSize() ?看看这个:

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

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

放在一起

有了所有这些不错的添加,您现在已经拥有了一个非常完善的UI。

超媒体1

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

在下面,您可以看到CreateDialog并将元数据插入HTML输入占位符。

超媒体2

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

评论

在这个部分:

  • 您打开了Spring Data REST的分页功能。

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

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

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

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

有问题吗?

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

这是我们在下一节中可以解决的问题。

第3部分-条件运算

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

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

要PUT还是不PUT,这就是问题

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

通过在后端对资源进行版本控制并在前端使用ETag,可以有意地将更改放入PUT。换句话说,您可以检测资源是否已更改,并防止PUT(或PATCH)踩到别人的更新。让我们来看看。

REST资源版本化

要支持资源的版本控制,请为需要这种保护类型的域对象定义一个版本属性。

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 +
			'}';
	}
}
  • 版本字段带有注释javax.persistence.Version 。每次插入和更新行时,它都会自动存储和更新值。

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

获取单个资源及其标头

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

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

src / main / js / app.js-获取每个资源
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;
			this.links = employeeCollection.entity._links;
			return employeeCollection;
		});
	}).then(employeeCollection => {
		return employeeCollection.entity._embedded.employees.map(employee =>
				client({
					method: 'GET',
					path: employee._links.self.href
				})
		);
	}).then(employeePromises => {
		return when.all(employeePromises);
	}).done(employees => {
		this.setState({
			employees: employees,
			attributes: Object.keys(this.schema.properties),
			pageSize: pageSize,
			links: this.links
		});
	});
}
  1. follow()功能转到员工收集资源。

  2. then(employeeCollection ⇒ …​)子句创建调用以获取JSON模式数据。它有一个sub-then子句,用于将元数据和导航链接存储在零件。

    • 请注意,此嵌入的承诺将返回employeeCollection。这样,可以将集合传递到下一个调用,同时让您沿途获取元数据。

  3. 第二then(employeeCollection ⇒ …​)子句将雇员集合转换为GET承诺数组,以获取每个单独的资源。这是为每个员工获取ETag标头所需的内容。

  4. then(employeePromises ⇒ …​)子句采用GET诺言数组,并将它们合并为一个诺言when.all() ,在所有GET Promise都解决后解决。

  5. loadFromServer结束done(employees ⇒ …​)使用此数据合并来更新UI状态。

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

更新现有资源

在本节中,您将添加一个UpdateDialog反应组件以编辑现有员工记录。

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()功能,类似于零件。

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

渲染图

该组件使用与CSS / HTML相同的策略来显示和隐藏对话框。 从上一节开始。

它将JSON Schema属性数组转换为HTML输入数组,并包装在段落元素中以进行样式设置。这也和唯一的区别是:字段已加载this.props.employee 。在CreateDialog组件中,这些字段为空。

id字段的构建方式不同。整个UI上只有一个CreateDialog链接,但显示的每一行都有一个单独的UpdateDialog链接。因此, id字段基于自身链接的URI。这在两个

元素的React 以及HTML锚标记和隐藏的弹出窗口。

处理用户输入

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

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

有条件的PUT

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

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.');
		}
	});
}

带有If-Match请求标头的 PUT使Spring Data REST根据当前版本检查该值。如果传入的If-Match值与数据存储的版本值不匹配,则Spring Data REST将失败,并显示HTTP 412 Precondition Failed

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

放在一起

和你的UpdateDialog已定义React组件并与顶级组件很好地链接onUpdate功能,最后一步是将其连接到现有的组件布局中。

CreateDialog上一节中创建的内容放在顶部EmployeeList因为只有一个实例。然而, UpdateDialog与特定员工直接相关。这样您就可以看到它已插入下面的Employee反应组件:

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

使用这些mod,可以避免冲突,从而提高了数据完整性。

评论

在这个部分:

  • 您使用@Version基于JPA的乐观锁定的字段。

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

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

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

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

有问题吗?

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

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

您充分利用了JSON模式元数据来构建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
public class WebSocketConfiguration implements WebSocketMessageBrokerConfigurer {

	static final String MESSAGE_PREFIX = "/topic";

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

	@Override
	public void configureMessageBroker(MessageBrokerRegistry registry) {
		registry.enableSimpleBroker(MESSAGE_PREFIX);
		registry.setApplicationDestinationPrefixes("/app");
	}
}
  • @EnableWebSocketMessageBroker打开WebSocket支持。

  • AbstractWebSocketMessageBrokerConfigurer提供了一个方便的基类来配置基本功能。

  • MESSAGE_PREFIX是您将在每条消息的路由之前添加的前缀。

  • registerStompEndpoints()用于配置后端上的端点,以便客户端和服务器链接( /payroll )。

  • configureMessageBroker()用于配置用于在服务器和客户端之间中继消息的代理。

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

订阅Spring Data REST事件

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

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

	private final SimpMessagingTemplate websocket;

	private final EntityLinks entityLinks;

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

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

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

	@HandleAfterSave
	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.linkForSingleResource(employee.getClass(),
				employee.getId()).toUri().getPath();
	}

}
  • @RepositoryEventHandler(Employee.class)标记此类以捕获基于雇员的事件。

  • SimpMessagingTemplateEntityLinks从应用程序上下文自动连接。

  • @HandleXYZ批注标记需要侦听的方法。这些方法必须是公开的。

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

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

getPath()使用Spring Data REST的EntityLinks查找给定类类型和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()该功能可导航到应用了size参数的员工链接,并插入this.state.pageSize 。收到响应后,您将调用相同的onNavigate()功能从最后一节开始,然后跳到最后一页,即找到新记录的那一页。

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

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

将状态管理移出本地更新

在完成本节之前,您需要识别一些东西。您刚刚为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()函数用于获取雇员链接,然后应用POST操作。注意如何client({method: 'GET' …​})没有then()要么done()就像之前一样?现在可以在以下位置找到用于监听更新的事件处理程序: refreshAndGoToLastPage()您刚刚看过的。

放在一起

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

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

评论

在这个部分:

  • 您使用SockJS fallback配置了Spring的WebSocket支持。

  • 您订阅了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();

	private @Id @GeneratedValue Long id;

	private String name;

	private @JsonIgnore String password;

	private String[] roles;

	public void setPassword(String password) {
		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) +
			'}';
	}
}
  • PASSWORD_ENCODER是在比较之前对新密码进行加密或获取密码输入并对其进行加密的方法。

  • idnamepasswordroles定义限制访问所需的参数。

  • 定制的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;

	private Employee() {}

	public Employee(String firstName, String lastName, String description, Manager manager) {
		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 +
			'}';
	}
}
  • 通过JPA链接到manager属性@ManyToOne 。经理不需要@OneToMany因为您还没有定义查找的必要。

  • 实用程序构造函数调用已更新以支持初始化。

确保员工与经理的关系

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

@PreAuthorize("hasRole('ROLE_MANAGER')")
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);

}

@PreAuthorize界面顶部的,禁止访问具有ROLE_MANAGER的人员

save() ,或者员工的经理为空(在未分配经理的情况下初始创建新员工),或者员工的经理名称与当前经过身份验证的用户名匹配。在这里,您使用Spring Security的SpEL表达式来定义访问。它带有方便的“?”。属性导航器来处理空检查。注意使用@Param(…​)在参数上链接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 Security User实例,它实现了UserDetails接口。您还在使用Spring Securiy的AuthorityUtils从一系列基于字符串的角色过渡到Java ListGrantedAuthority

连接您的安全策略

@PreAuthorize应用于存储库的表达式是访问规则 。没有安全策略,这些规则是徒劳的。

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

	@Autowired
	private SpringDataJpaUserDetailsService userDetailsService;

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

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

}

这段代码有很多复杂性,因此让我们逐步了解一下,首先讨论注释和API。然后,我们将讨论它定义的安全策略。

  • @EnableWebSecurity告诉Spring Boot放弃其自动配置的安全策略,而改用该策略。对于快速演示,可以自动配置安全性。但是对于任何真实的事情,您都应该自己编写策略。

  • @EnableGlobalMethodSecurity通过Spring Security复杂的@Pre和@Post注释打开方法级安全性。

  • 它延伸WebSecurityConfigurerAdapter ,方便编写策略的基类。

  • 它自动接线SpringDataJpaUserDetailsService通过现场注入,然后通过configure(AuthenticationManagerBuilder)方法。的PASSWORD_ENCODERManager也已设置。

  • 关键的安全策略是用纯Java编写的, configure(HttpSecurity)

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

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

  • 任何不匹配的东西都会落入anyRequest().authenticated()表示需要验证。

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

  • BASIC登录也配置为禁用CSRF。这主要是用于演示,不建议对生产系统进行仔细分析。

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

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

自动添加安全详细信息

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

@Component
@RepositoryEventHandler(Employee.class)
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);
	}
}

@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检出mod。

$ curl -v -u greg:turnquist localhost:8080/api/employees/1
*   Trying ::1...
* Connected to localhost (::1) port 8080 (#0)
* Server auth using Basic with user 'greg'
> GET /api/employees/1 HTTP/1.1
> Host: localhost:8080
> Authorization: Basic Z3JlZzp0dXJucXVpc3Q=
> User-Agent: curl/7.43.0
> Accept: */*
>
< HTTP/1.1 200 OK
< Server: Apache-Coyote/1.1
< X-Content-Type-Options: nosniff
< X-XSS-Protection: 1; mode=block
< Cache-Control: no-cache, no-store, max-age=0, must-revalidate
< Pragma: no-cache
< Expires: 0
< X-Frame-Options: DENY
< Set-Cookie: JSESSIONID=E27F929C1836CC5BABBEAB78A548DF8C; Path=/; HttpOnly
< ETag: "0"
< Content-Type: application/hal+json;charset=UTF-8
< Transfer-Encoding: chunked
< Date: Tue, 25 Aug 2015 15:57:34 GMT
<
{
  "firstName" : "Frodo",
  "lastName" : "Baggins",
  "description" : "ring bearer",
  "manager" : {
    "name" : "greg",
    "roles" : [ "ROLE_MANAGER" ]
  },
  "_links" : {
    "self" : {
      "href" : "http://localhost:8080/api/employees/1"
    }
  }
}

与第一部分相比,这显示了更多细节。首先,Spring Security启用了几种HTTP协议来防御各种攻击媒介(编译指示,过期,X-Frame-Options等)。您还将通过以下方式发行BASIC凭证: -u greg:turnquist呈现Authorization标头。

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

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

在界面上显示经理信息

有了所有这些mod的后端,您现在就可以转移到更新前端中的内容。首先,在内部展示一名员工的经理反应组件:

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模式元数据中有一个条目。您可以在以下摘录中看到它:

{
	...
    "manager" : {
      "readOnly" : false,
      "$ref" : "#/descriptors/manager"
    },
    ...
  },
  ...
  "$schema" : "https://json-schema.org/draft-04/schema#"
}

管理员字段不是您希望人们直接编辑的内容。由于是内联的,因此应将其视为只读属性。要从中过滤掉内联条目CreateDialogUpdateDialog ,只需在获取JSON Schema元数据后删除此类条目即可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);
		}
	});
}

这与定制的错误消息类似地编码。

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

冠上该版本应用程序的最后一件事是显示登录者以及通过添加此新按钮来提供注销按钮

在index.html文件之前react

<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 ,它将失败并显示类似消息。创建一个新员工,应将其分配给您。

评论

在这个部分:

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

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

  • 您为empoyee存储库编写了一组访问规则,还编写了安全策略。

  • 您编写了另一个Spring Data REST事件处理程序,以在创建事件发生之前捕获它们,以便可以将当前用户分配为员工的经理。

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

有问题吗?

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

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

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

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