React.js 和 Spring Data REST:第 2 部分 - 超媒体

工程 | Greg L. Turnquist | 2015 年 9 月 15 日 | ...
要查看此代码的更新,请访问我们的 React.js 和 Spring Data REST 教程

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

随时可以从该存储库中 获取代码 并继续学习。本节基于上一节的应用程序,并添加了一些额外内容。

起初只有数据……然后出现了 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。还有其他一些,但我们这里不讨论。

  • GET - 获取资源的状态,而不更改系统
  • POST - 创建一个新的资源,而不指定位置
  • PUT - 替换现有资源,覆盖任何其他已存在的内容(如果有)
  • DELETE - 删除现有资源
  • 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" : "https://127.0.0.1:8080/api/employees?page=0&size=2"
    },
    "self" : {
      "href" : "https://127.0.0.1:8080/api/employees"
    },
    "next" : {
      "href" : "https://127.0.0.1:8080/api/employees?page=1&size=2"
    },
    "last" : {
      "href" : "https://127.0.0.1:8080/api/employees?page=2&size=2"
    }
  },
  "_embedded" : {
    "employees" : [ {
      "firstName" : "Frodo",
      "lastName" : "Baggins",
      "description" : "ring bearer",
      "_links" : {
        "self" : {
          "href" : "https://127.0.0.1:8080/api/employees/1"
        }
      }
    }, {
      "firstName" : "Bilbo",
      "lastName" : "Baggins",
      "description" : "burglar",
      "_links" : {
        "self" : {
          "href" : "https://127.0.0.1:8080/api/employees/2"
        }
      }
    } ]
  },
  "page" : {
    "size" : 2,
    "totalElements" : 6,
    "totalPages" : 3,
    "number" : 0
  }
}

默认页面大小为 20,因此要查看其作用,请应用 ?size=2。如预期的那样,仅列出了两个员工。此外,还有一个 firstnextlast 链接。还有一个 self 链接,它不包含上下文(包括页面参数)

如果您导航到 next 链接,那么您也将看到 prev 链接

$ curl "https://127.0.0.1:8080/api/employees?page=1&size=2"
{
  "_links" : {
    "first" : {
      "href" : "https://127.0.0.1:8080/api/employees?page=0&size=2"
    },
    "prev" : {
      "href" : "https://127.0.0.1:8080/api/employees?page=0&size=2"
    },
    "self" : {
      "href" : "https://127.0.0.1:8080/api/employees"
    },
    "next" : {
      "href" : "https://127.0.0.1:8080/api/employees?page=2&size=2"
    },
    "last" : {
      "href" : "https://127.0.0.1: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: 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 对象。
  • 第二个参数是从其开始的根 URI。
  • 第三个参数是要沿其导航的关系数组。每个关系可以是字符串或对象。

关系数组可以像 ["employees"] 一样简单,这意味着在进行第一次调用时,在 _links 中查找名为 employees 的关系(或 rel)。找到其 href 并导航到它。如果数组中还有其他关系,则重复此过程。

有时,仅使用 rel 不够。在此代码片段中,它还插入了一个 ?size=<pageSize> 的查询参数。您可以提供其他选项,如下所述。

获取 JSON Schema 元数据

使用基于大小的查询导航到 employees 后,employeeCollection 触手可及。在上一节中,我们完成了这一步并显示了 <EmployeeList /> 内的数据。今天,您将执行另一个调用以获取一些在 /api/profile/employees 中找到的 JSON Schema 元数据

您可以自己查看数据

$ curl https://127.0.0.1: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 =&gt; {
        newEmployee[attribute] = React.findDOMNode(this.refs[attribute]).value.trim();
    });
    this.props.onCreate(newEmployee);

    // clear out the dialog's inputs
    this.props.attributes.forEach(attribute =&gt; {
        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 =&gt;
        &lt;p key={attribute}&gt;
            &lt;input type="text" placeholder={attribute} ref={attribute} className="field" /&gt;
        &lt;/p&gt;
    );

    return (
        &lt;div&gt;
            &lt;a href="#createEmployee"&gt;Create&lt;/a&gt;

            &lt;div id="createEmployee" className="modalDialog"&gt;
                &lt;div&gt;
                    &lt;a href="#" title="Close" className="close"&gt;X&lt;/a&gt;

                    &lt;h2&gt;Create new employee&lt;/h2&gt;

                    &lt;form&gt;
                        {inputs}
                        &lt;button onClick={this.handleSubmit}&gt;Create&lt;/button&gt;
                    &lt;/form&gt;
                &lt;/div&gt;
            &lt;/div&gt;
        &lt;/div&gt;
    )
}

});

此新组件既有 handleSubmit() 函数,也有预期的 render() 函数。

让我们以相反的顺序深入研究这些函数,首先查看 render() 函数。

渲染

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

  • key 再次由 React 需要来区分多个子节点。
  • 它是一个简单的基于文本的输入字段。
  • placeholder 是我们向用户显示哪个字段的地方。
  • 您可能习惯了使用 name 属性,但它不是必需的。使用 React,ref 是获取特定 DOM 节点(如您很快将看到的)的机制。

这表示组件的动态特性,由从服务器加载数据驱动。

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

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

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

处理用户输入

handleSubmit() 函数首先阻止事件进一步冒泡到层次结构中。然后,它使用相同的 JSON Schema 属性属性使用 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 结果的处理。

新记录通常会添加到数据集的末尾。由于您正在查看特定页面,因此可以预期新员工记录不在当前页面上。要处理此问题,您需要使用相同的页面大小获取一批新数据。该承诺将为 done() 内部的最后一个子句返回。

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

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

第一次使用基于 Promise 的 API?Promise 是一种启动异步操作并注册一个函数以在任务完成时做出响应的方式。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。

数据分页

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

上一节中,您使用了页面控件跳转到**最后一**页。能够动态地将其应用于 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(&lt;button key="first" onClick={this.handleNavFirst}&gt;&amp;lt;&amp;lt;&lt;/button&gt;);
}
if ("prev" in this.props.links) {
    navLinks.push(&lt;button key="prev" onClick={this.handleNavPrev}&gt;&amp;lt;&lt;/button&gt;);
}
if ("next" in this.props.links) {
    navLinks.push(&lt;button key="next" onClick={this.handleNavNext}&gt;&amp;gt;&lt;/button&gt;);
}
if ("last" in this.props.links) {
    navLinks.push(&lt;button key="last" onClick={this.handleNavLast}&gt;&amp;gt;&amp;gt;&lt;/button&gt;);
}

return (
    &lt;div&gt;
        &lt;input ref="pageSize" defaultValue={this.props.pageSize} onInput={this.handleInput}/&gt;
        &lt;table&gt;
            &lt;tr&gt;
                &lt;th&gt;First Name&lt;/th&gt;
                &lt;th&gt;Last Name&lt;/th&gt;
                &lt;th&gt;Description&lt;/th&gt;
                &lt;th&gt;&lt;/th&gt;
            &lt;/tr&gt;
            {employees}
        &lt;/table&gt;
        &lt;div&gt;
            {navLinks}
        &lt;/div&gt;
    &lt;/div&gt;
)

}

与上一节一样,它仍然将 this.props.employees 转换为 <Element /> 组件的数组。然后它构建了一个 navLinks 数组,这是一个 HTML 按钮数组。

注意
因为 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。

hypermedia 1

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

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

hypermedia 2

这真正体现了使用超媒体结合领域驱动元数据(JSON Schema)的强大功能。网页不需要知道哪个字段是哪个。相反,用户可以看到它并知道如何使用它。如果您向 Employee 域对象添加了另一个字段,此弹出窗口将自动显示它。

回顾

在本节中

  • 您启用了 Spring Data REST 的分页功能。
  • 您丢弃了硬编码的 URI 路径,并开始使用根 URI 结合关系名称或“rels”。
  • 您更新了 UI 以动态使用基于分页的超媒体控件。
  • 您添加了创建和删除员工以及根据需要更新 UI 的功能。
  • 您使其能够更改页面大小并使 UI 灵活地做出响应。

问题?

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

这是我们可以在下一节中解决的问题。在此之前,祝您编码愉快!

获取 Spring 新闻

通过 Spring 新闻保持联系

订阅

领先一步

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

了解更多

获取支持

Tanzu Spring 在一个简单的订阅中提供对 OpenJDK™、Spring 和 Apache Tomcat® 的支持和二进制文件。

了解更多

即将举行的活动

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

查看全部