先行一步
VMware 提供培训和认证,助你快速提升。
了解更多要查看此代码的更新,请访问我们的React.js 和 Spring Data REST 教程。 |
在上一节中,你了解了如何搭建一个后端工资服务来存储员工数据,使用了 Spring Data REST。它缺少的一个关键特性是使用超媒体控件和通过链接导航。相反,它硬编码了查找数据的路径。
请随时从这个仓库获取代码并跟随操作。本节是在上一节应用程序的基础上添加了一些额外内容。
我开始对那些将任何基于 HTTP 的接口都称为 REST API 的人感到沮丧。今天的例子是 SocialSite REST API。那是 RPC。它尖叫着 RPC……需要做什么才能使 REST 架构风格清晰地说明超文本是一种约束?换句话说,如果应用程序状态的引擎(以及因此的 API)不是由超文本驱动的,那么它就不是 RESTful 的,也不能成为 REST API。句号。是不是有什么破损的手册需要修复?
那么,超媒体控件,即超文本,到底是什么?你如何使用它们?为了弄清楚这一点,让我们退一步,看看 REST 的核心使命。
REST 的概念是借鉴使 Web 如此成功的思想,并将其应用于 API。尽管 Web 规模庞大、动态性强,且客户端(即浏览器)更新速度慢,但 Web 仍然取得了惊人的成功。Roy Fielding 试图利用它的一些约束和特性,看看是否能带来类似的 API 生产和消费的扩展。
其中一个约束是限制动词的数量。对于 REST,主要的动词有 GET、POST、PUT、DELETE 和 PATCH。还有其他的,但我们在这里不涉及。
这些是标准的 HTTP 动词,有详细的规范。通过采用和使用已经存在的 HTTP 操作,我们不必发明新语言并教育整个行业。
REST 的另一个约束是使用媒体类型来定义数据的格式。与其每个人都为信息交换编写自己的方言,不如开发一些媒体类型。最受欢迎并被广泛接受的媒体类型之一是 HAL,其媒体类型为 application/hal+json。这是 Spring Data REST 的默认媒体类型。一个重要的价值在于,REST 没有中心化的单一媒体类型。相反,人们可以开发媒体类型并将其集成。尝试它们。随着不同的需求出现,行业可以灵活地发展。
REST 的一个关键特性是包含指向相关资源的链接。例如,如果你正在查看一个订单,一个 RESTful API 会包含指向相关客户的链接、指向商品目录的链接,以及可能指向下单商店的链接。在本节中,你将引入分页,并了解如何使用导航分页链接。
要开始使用前端超媒体控件,你需要开启一些额外的控件。Spring Data REST 提供了分页支持。要使用它,只需微调仓库定义
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
。正如预期的那样,只列出了两名员工。此外,还有first、next和last链接。还有一个self链接,它不包含上下文*,包括页面参数*。
如果你导航到next链接,你就会看到prev链接了
$ 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 特定的”。相反,它使用了HAL、URI 模板和其他标准。这就是为什么使用 rest.js 如此轻松:该库本身就带有 HAL 支持。 |
在上一节中,你硬编码了路径/api/employees
。相反,你应该硬编码的唯一路径是根路径。
...
var root = '/api';
...
有了方便的小函数follow()
,你现在可以从根目录开始导航到你需要的地方了!
componentDidMount: function () {
this.loadFromServer(this.state.pageSize);
},
在上一节中,数据加载是直接在componentDidMount()
内部完成的。在本节中,当页面大小时更新时,我们使其能够重新加载整个员工列表。为此,我们将这些内容移到了loadFromServer()
中。
loadFromServer: function (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()
函数的第一个参数是用于进行 REST 调用的client
对象。关系数组可以像["employees"]
一样简单,这意味着在进行第一次调用时,在_links中查找名为employees的关系(或rel)。找到它的href并导航到它。如果数组中还有其他关系,则重复此过程。
有时,仅凭关系本身是不够的。在这段代码片段中,它还注入了一个查询参数?size=<pageSize>。还有其他可以提供的选项,你稍后会看到。
在以基于大小的查询导航到employees后,employeeCollection触手可及。在上一节中,我们停在这里并将数据显示在<EmployeeList />
中。今天,你将执行另一次调用以获取位于/api/profile/employees
的一些JSON Schema 元数据。
你可以自己查看数据
$ 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.fullstack.org.cn/draft-04/schema#" }
注意
|
在 /profile/employees 处的默认元数据形式是 ALPS。但在此例中,你使用内容协商来获取 JSON Schema。 |
通过在<App />
组件的状态中捕获这些信息,你可以在稍后构建输入表单时充分利用它。
有了这些元数据,你现在可以向 UI 添加一些额外的控件。创建一个新的 React 组件,<CreateDialog />
。
var CreateDialog = React.createClass({
handleSubmit: function (e) { e.preventDefault(); var newEmployee = {}; this.props.attributes.forEach(attribute => { newEmployee[attribute] = React.findDOMNode(this.refs[attribute]).value.trim(); }); this.props.onCreate(newEmployee); // clear out the dialog's inputs this.props.attributes.forEach(attribute => { React.findDOMNode(this.refs[attribute]).value = ''; }); // Navigate away from the dialog to hide it. window.location = "#"; }, render: function () { var 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()
函数。
你的代码遍历 JSON Schema 数据中的attributes属性,并将其转换为<p><input></p>
元素数组。
这代表了组件的动态性,由从服务器加载数据驱动。
在这个组件的顶层<div>
内部是一个锚点标签和另一个<div>
。锚点标签是打开对话框的按钮。嵌套的<div>
是隐藏的对话框本身。在这个例子中,你使用的是纯 HTML5 和 CSS3。完全没有 JavaScript!你可以查看用于显示/隐藏对话框的 CSS 代码。我们这里不深入探讨。
嵌入在<div id="createEmployee">
内部的是一个表单,其中注入了你的动态输入字段列表,后面跟着Create按钮。该按钮有一个onClick={this.handleSubmit}
事件处理程序。这是 React 注册事件处理程序的方式。
注意
|
React 不会在每个 DOM 元素上创建一大堆事件处理程序。相反,它有一个性能更高且更复杂的解决方案。关键在于你无需管理这种基础设施,而是可以专注于编写功能性代码。 |
handleSubmit()
函数首先阻止事件进一步冒泡到层次结构的上方。然后,它使用相同的 JSON Schema attribute 属性,通过React.findDOMNode(this.refs[attribute])
找到每个<input>
。
this.refs
是一种按名称查找特定 React 组件的方式。从这个意义上说,你**只**获取了虚拟 DOM 组件。要获取实际的 DOM 元素,你需要使用React.findDOMNode()
。
在遍历所有输入并构建newEmployee
对象后,我们调用一个回调函数onCreate()
来创建新员工。这个函数位于顶部,即App.onCreate
,并作为另一个属性传递给了这个 React 组件。看看那个顶层函数是如何工作的
onCreate: function (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 => {
this.onNavigate(response.entity._links.last.href);
});
},
再次使用follow()
函数导航到执行 POST 操作的employees资源。在这种情况下,不需要应用任何参数,因此基于字符串的关系数组是没问题的。在这种情况下,返回的是 POST 调用。这允许下一个then()
子句处理 POST 操作的结果。
新记录通常添加到数据集的末尾。由于你正在查看某一特定页面,理所当然地会认为新员工记录不会出现在当前页面。为了处理这种情况,你需要获取一批新数据,并应用相同的页面大小。这个 Promise 在done()
中的最后一个子句中返回。
由于用户可能想查看新创建的员工,你可以使用超媒体控件导航到last条目。
这为我们的 UI 引入了分页的概念。接下来我们将解决这个问题!
第一次使用基于 Promise 的 API?Promises是一种启动异步操作并在任务完成后注册函数来响应的方式。Promise 被设计成可以链式调用,以避免“回调地狱”。请看以下流程
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
(它是 rest.js 中的一个rest
实例)以及follow
函数都返回 Promise。
你在后端设置了分页,并且在创建新员工时已经开始利用它了。
在上一节中,你使用分页控件跳转到了**last**页。将这些控件动态应用于 UI 并让用户按需导航将会非常方便。根据可用的导航链接动态调整控件将会非常棒。
首先,我们来看看你使用的onNavigate()
函数。
onNavigate: function(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: function(e){
e.preventDefault();
this.props.onNavigate(this.props.links.first.href);
},
handleNavPrev: function(e) {
e.preventDefault();
this.props.onNavigate(this.props.links.prev.href);
},
handleNavNext: function(e) {
e.preventDefault();
this.props.onNavigate(this.props.links.next.href);
},
handleNavLast: function(e) {
e.preventDefault();
this.props.onNavigate(this.props.links.last.href);
},
这些函数都会拦截默认事件并阻止其向上冒泡。然后,它会调用带有正确超媒体链接的onNavigate()
函数。
现在根据EmployeeList.render
中超媒体链接中出现的链接来有条件地显示控件
render: function () { var employees = this.props.employees.map(employee => <Employee key={employee._links.self.href} employee={employee} onDelete={this.props.onDelete}/> );
var 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> <tr> <th>First Name</th> <th>Last Name</th> <th>Description</th> <th></th> </tr> {employees} </table> <div> {navLinks} </div> </div> )
}
与上一节一样,它仍然将this.props.employees
转换为<Element />
组件数组。然后,它构建一个由 HTML 按钮组成的navLinks
数组。
注意
|
因为 React 基于 XML,你不能将“<”放在<button> 元素内部。必须使用编码后的版本。 |
然后你可以在返回的 HTML 底部看到插入的{navLinks}
。
删除条目容易得多。获取它的基于 HAL 的记录,然后对其self链接应用DELETE操作。
var Employee = React.createClass({
handleDelete: function () {
this.props.onDelete(this.props.employee);
},
render: function () {
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: function (employee) {
client({method: 'DELETE', path: employee._links.self.href}).done(response => {
this.loadFromServer(this.state.pageSize);
});
},
在使用基于页面的 UI 删除记录后应用的行为有些棘手。在这种情况下,它会从服务器重新加载所有数据,并应用相同的页面大小。然后显示第一页。
如果你正在删除最后一页的最后一条记录,它将跳转到第一页。
一种真正展现超媒体优势的方法是更新页面大小。Spring Data REST 会根据页面大小流畅地更新导航链接。
在ElementList.render
顶部有一个 HTML 元素:<input ref="pageSize" defaultValue={this.props.pageSize} onInput={this.handleInput}/>
。
ref="pageSize"
使得通过 this.refs.pageSize 很容易获取该元素。defaultValue
使用状态中的**pageSize**对其进行初始化。onInput
注册了一个处理程序,如下所示。handleInput: function (e) {
e.preventDefault();
var pageSize = React.findDOMNode(this.refs.pageSize).value;
if (/^[0-9]+$/.test(pageSize)) {
this.props.updatePageSize(pageSize);
} else {
React.findDOMNode(this.refs.pageSize).value =
pageSize.substring(0, pageSize.length - 1);
}
},
它阻止事件向上冒泡。然后,它使用<input>
的**ref**属性找到 DOM 节点并通过 React 的findDOMNode()
辅助函数提取其值。它通过检查输入是否是一串数字来测试输入是否确实是数字。如果是,它将调用回调函数,将新的页面大小发送给App
React 组件。如果不是,则将刚输入的字符从输入中删除。
当App
接收到updatePageSize()
时会做什么?看看吧
updatePageSize: function (pageSize) {
if (pageSize !== this.state.pageSize) {
this.loadFromServer(pageSize);
}
},
由于新的页面大小会改变所有导航链接,最好重新获取数据并从头开始。
有了这些不错的补充,你现在拥有了一个真正增强的 UI。
你可以在顶部看到页面大小设置,每行的删除按钮,以及底部的导航按钮。导航按钮展示了超媒体控件的一个强大特性。
在下方,你可以看到CreateDialog
,元数据已插入到 HTML 输入占位符中。
这真正展示了超媒体与领域驱动元数据(JSON Schema)结合的强大之处。网页不需要知道哪个字段是哪个。相反,用户可以看到它并知道如何使用它。如果你向Employee
领域对象添加另一个字段,这个弹出窗口会自动显示它。
在本节中
问题?
你使网页具备了动态性。但如果在另一个浏览器标签页中打开同一个应用,一个标签页中的更改不会更新另一个。
这是我们可以在下一节解决的问题。在那之前,编程愉快!