SpringBoot 微服务学习手册(二)
四、使用 React 的最小前端
一本号称实用的关于微服务的书也得提供前端。在现实生活中,用户不会通过 REST APIs 与应用进行交互。
由于这本书关注的是现实生活中使用的流行技术,我们将在 React 中构建我们的前端。这个 JavaScript 框架允许我们基于可重用的组件和服务轻松开发网页。根据 2020 年 StackOverflow 的开发者调查( https://tpd.io/js-fw-list ),与 Angular 或 Vue.js 等其他类似的替代框架相比,React 是最受欢迎的框架。这使得它已经是一个不错的选择。最重要的是,我认为这是一个对 Java 开发人员友好的框架:您可以使用 TypeScript,这是 JavaScript 的一个扩展,它向这种编程语言添加了类型,对于习惯于它们的人来说,这使得一切都变得更容易。此外,React 的编程风格允许我们创建类来构建组件和服务,这使得 Java 开发人员熟悉 React 项目的结构。
我们还将使用 Node,一个随npm一起提供的 JavaScript 运行时,它是管理 JavaScript 依赖关系的工具。通过这种方式,您可以获得一些关于 UI 技术的实践经验,如果您还不是一名全栈开发人员,为什么不成为一名呢?
无论如何,请记住这条重要的免责声明:我们不会深入讨论如何用 React 构建 web 应用的细节。我们希望继续关注 Spring Boot 的微服务。因此,如果你没有完全掌握本章的所有概念,也不要难过,尤其是如果你从未见过 JavaScript 代码或 CSS。
考虑到您在 GitHub 资源库( https://github.com/Book-Microservices-v2/chapter04 )中拥有所有可用的源代码,您可以用几种方式来阅读本章。
-
照着读*。您将获得一些基础知识,并将在 React 中尝试一些重要的概念。*
-
暂停一会儿阅读官方网站上的主要概念指南(
https://tpd.io/react-mc),然后回到本章。通过这种方式,你会对我们将要构建的内容有更多的背景知识。 -
如果您对前端技术完全不感兴趣,可以完全跳过这一章,使用存储库中的源代码。可以放心地跳到下一章,继续使用不断发展的应用方法。
快速介绍 React 和 Node
React 是一个用于构建用户界面的 JavaScript 库。它由脸书开发,在前端开发人员中很受欢迎。它在许多组织中被广泛使用,这也导致了活跃的就业市场。
和其他库一样,React 也是基于组件的。这对于后端开发人员来说是一个优势,因为您只需编写一次代码,就可以在任何地方重用,这个概念听起来很熟悉。
在 React 中,您可以使用 JSX,而不是在单独的文件中编写 HTML 和 JavaScript 源代码,这是 JavaScript 语法的一个扩展,允许我们组合这些语言。这很有用,因为您可以在单个文件中编写组件,并通过功能隔离它们,将所有行为和渲染逻辑放在一起。
设置开发环境
首先,您需要使用位于 nodejs.org 站点的一个可用安装包来安装 Node.js。在本书中,我们使用的是 Node v13.10 和npm 6.13.7。安装完成后,使用命令行工具进行验证,如清单 4-1 所示。
$ node --version
v13.10.1
$ npm --version
6.13.7
Listing 4-1Getting the Version of Node.js and npm
现在您可以使用npm中包含的工具npx来创建 React 的前端项目。确保从您的工作空间根目录运行此命令,而不是在乘法服务中运行。
$ npx create-react-app challenges-frontend
源代码
您可以在 GitHub 的chapter04资源库中找到本章的所有源代码。
https://github.com/Book-Microservices-v2/chapter04 见*。*
下载并安装依赖项一段时间后,您将得到类似清单 4-2 所示的输出。
Success! Created challenges-frontend at /Users/moises/workspace/learn-microservices/challenges-frontend
Inside that directory, you can run several commands:
[...]
We suggest that you begin by typing:
cd challenges-frontend
npm start
Listing 4-2Console Output After Creating the React Project
如果您按照建议运行npm start,节点服务器将在http://localhost:3000启动,您甚至可以打开一个浏览器窗口,显示我们刚刚生成的应用中包含的预定义网页。如果你不知道,你可以从你的浏览器导航到http://localhost:3000来快速浏览这个页面。
反应骨架
下一个任务是将 React 项目加载到我们的工作区中。例如,在 IntelliJ 中,可以使用现有源中的选项文件➤新➤模块将前端文件夹作为单独的模块加载。正如您将看到的,我们已经得到了许多由create-react-app工具创建的文件。见图 4-1 。
图 4-1
反应项目框架
-
package.json和package-lock.json是npm文件。它们包含项目的基本信息,还列出了它的依赖项。这些依赖关系存储在node_modules文件夹中。 -
public文件夹是您可以保存所有静态文件的地方,这些文件在构建完成后将保持不变。唯一的例外是index.html,它将被处理成包含结果 JavaScript 源。 -
所有的 React 源代码及其相关资源都包含在
src文件夹中。在这个框架应用中,您可以找到主入口点文件index.js和一个 React 组件App。这个示例组件带有自己的样式表App.css和一个测试App.test.js。当您构建 React 项目时,所有这些文件最终会合并成更大的文件,但是这种命名约定和结构对开发很有帮助。
在 React 中,这些文件是如何相互关联的?先说index.html。移除注释行后的body标签的内容见清单 4-3 。
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
Listing 4-3The root Div in HTML
清单 4-4 显示了index.js文件内容的一部分。
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root')
);
Listing 4-4The Entrypoint to Render the React Content
这段代码展示了如何将 React 元素呈现到文档对象模型(DOM)中,DOM 是 HTML 元素的树型表示。这段代码将元素React.StrictMode及其子组件App呈现到 HTML 中。更具体地说,它们被渲染到 ID 为root的元素中,标签div被插入到index.html中。因为App是一个组件,并且它可能包含其他组件,所以它最终处理并呈现整个 React 应用。
JavaScript 客户端
在创建我们的第一个组件之前,让我们确保有一种方法可以从我们在前一章中创建的 REST API 中检索数据。我们将为此使用一个 JavaScript 类。正如你将在本章的其余部分看到的,我们将使用类和类型保持一种类似 Java 的编程风格来构建我们的前端。
JavaScript 中的类类似于 Java 类。对于我们的具体情况,我们可以用两个静态方法创建一个实用程序类。参见清单 4-5 。
class ApiClient {
static SERVER_URL = 'http://localhost:8080';
static GET_CHALLENGE = '/challenges/random';
static POST_RESULT = '/attempts';
static challenge(): Promise<Response> {
return fetch(ApiClient.SERVER_URL + ApiClient.GET_CHALLENGE);
}
static sendGuess(user: string,
a: number,
b: number,
guess: number): Promise<Response> {
return fetch(ApiClient.SERVER_URL + ApiClient.POST_RESULT,
{
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(
{
userAlias: user,
factorA: a,
factorB: b,
guess: guess
}
)
});
}
}
export default ApiClient;
Listing 4-5The ApiClient Class
两种方法都返回承诺。JavaScript 中的承诺类似于 Java 的Future类:它表示异步操作的结果。我们的函数调用fetch(参见 https://tpd.io/fetch-api ),这是 JavaScript 中的一个函数,我们可以用它来与 HTTP 服务器交互。
第一个方法challenge()使用了基本形式的fetch函数,因为它默认对传递的 URL 进行 GET 操作。这个方法返回一个Response对象的承诺( https://tpd.io/js-response )。
sendGuess方法接受我们构建请求以解决挑战所需的参数。这一次,我们使用带有第二个参数的fetch:定义 HTTP 方法(POST)的对象、请求(JSON)中主体的内容类型以及主体。为了构建 JSON 请求,我们使用实用方法JSON.stringify,它序列化一个对象。
最后,为了使我们的类可以公开访问,我们在文件的末尾添加了export default ApiClient。这使得在其他组件和类中导入完整的类成为可能。
挑战部分
让我们构建我们的第一个 React 组件。我们在前端也将遵循模块化,这意味着这个组件将负责Challenges域。目前,这意味着以下几点:
-
呈现从后端检索的挑战
-
为用户显示一个表单以发送猜测
参见清单 4-6 获取ChallengeComponent类的完整源代码。在接下来的几节中,我们将剖析这段代码,并使用它来学习如何在 React 中构造组件以及它的一些基本概念。
import * as React from "react";
import ApiClient from "../services/ApiClient";
class ChallengeComponent extends React.Component {
constructor(props) {
super(props);
this.state = {
a: '', b: '',
user: '',
message: '',
guess: 0
};
this.handleSubmitResult = this.handleSubmitResult.bind(this);
this.handleChange = this.handleChange.bind(this);
}
componentDidMount(): void {
ApiClient.challenge().then(
res => {
if (res.ok) {
res.json().then(json => {
this.setState({
a: json.factorA,
b: json.factorB
});
});
} else {
this.updateMessage("Can't reach the server");
}
}
);
}
handleChange(event) {
const name = event.target.name;
this.setState({
[name]: event.target.value
});
}
handleSubmitResult(event) {
event.preventDefault();
ApiClient.sendGuess(this.state.user,
this.state.a, this.state.b,
this.state.guess)
.then(res => {
if (res.ok) {
res.json().then(json => {
if (json.correct) {
this.updateMessage("Congratulations! Your guess is correct");
} else {
this.updateMessage("Oops! Your guess " + json.resultAttempt +
" is wrong, but keep playing!");
}
});
} else {
this.updateMessage("Error: server error or not available");
}
});
}
updateMessage(m: string) {
this.setState({
message: m
});
}
render() {
return (
<div>
<div>
<h3>Your new challenge is</h3>
<h1>
{this.state.a} x {this.state.b}
</h1>
</div>
<form onSubmit={this.handleSubmitResult}>
<label>
Your alias:
<input type="text" maxLength="12"
name="user"
value={this.state.user}
onChange={this.handleChange}/>
</label>
<br/>
<label>
Your guess:
<input type="number" min="0"
name="guess"
value={this.state.guess}
onChange={this.handleChange}/>
</label>
<br/>
<input type="submit" value="Submit"/>
</form>
<h4>{this.state.message}</h4>
</div>
);
}
}
export default ChallengeComponent;
Listing 4-6Our First React Component: ChallengeComponent
组件的主要结构
我们的类扩展了React.Component,这就是你如何在 React 中创建组件。唯一需要实现的方法是render(),它必须返回 DOM 元素以显示在浏览器中。在我们的例子中,我们使用 JSX ( https://tpd.io/jsx )构建这些元素。参见清单 4-7 ,它展示了我们组件类的主要结构。
class ChallengeComponent extends React.Component {
constructor(props) {
super(props);
this.state = {
a: '', b: '',
user: '',
message: '',
guess: 0
};
this.handleSubmitResult = this.handleSubmitResult.bind(this);
this.handleChange = this.handleChange.bind(this);
}
componentDidMount(): void {
// ... Component initialization
}
render() {
return (
// ... HTML as JSX ...
)
}
Listing 4-7Main Structure of a Component in React
通常,我们还需要一个构造函数来初始化属性,以及组件的状态(如果需要的话)。在ChallengeComponent中,我们创建一个状态来保存检索到的挑战和用户为解决一次尝试而输入的数据。参数props是作为 HTML 属性传递给组件的输入。
<ChallengeComponent prop1="value"/>
对于我们的组件,我们不需要props,但是我们需要接受它作为一个参数,并把它传递给父构造函数,如果我们使用一个构造函数,这是我们所期望的。
在构造函数中,两行绑定了类方法。如果我们想在事件处理程序中使用this,这是必需的,事件处理程序是我们需要实现来处理用户输入数据的函数。如需了解更多详情,请参见处理事件( https://tpd.io/react-events )。我们将在本章后面描述这些功能。
函数componentDidMount是一个生命周期方法,我们可以在 React 中实现它,以便在组件第一次呈现后立即执行逻辑。参见清单 4-8 。
componentDidMount(): void {
ApiClient.challenge().then(
res => {
if (res.ok) {
res.json().then(json => {
this.setState({
a: json.factorA,
b: json.factorB
});
});
} else {
this.updateMessage("Can't reach the server");
}
}
);
}
Listing 4-8Running Logic After Rendering the Component
我们所做的是调用服务器检索一个挑战,使用我们之前构建的ApiClient实用程序类。假设函数返回一个承诺,我们使用then()来指定当我们获得响应时做什么。内部逻辑也很简单:如果响应是ok(意味着一个2xx状态代码),我们将主体解析为json()。这也是一个异步方法,所以我们用then()再次解析承诺,并将预期的factorA和factorB从 REST API 响应传递给setState()。
在 React 中,setState函数重新加载部分 DOM。这意味着浏览器将再次呈现 HTML 中发生变化的部分,因此在我们从服务器获得响应后,我们将在页面上看到我们的倍增因子。在我们的应用中,这应该是几毫秒的事情,因为我们正在调用我们自己的本地服务器。例如,在现实生活中的网页中,您可以设置一个微调器,以改善慢速连接情况下的用户体验。
翻译
JSX 允许我们混合 HTML 和 JavaScript。这是非常强大的,因为您可以从 HTML 语言的简单性中获益,但是您也可以添加占位符和 JavaScript 逻辑。参见清单 4-9 中render()方法的完整源代码及其后续解释。
render() {
return (
<div>
<div>
<h3>Your new challenge is</h3>
<h1>
{this.state.a} x {this.state.b}
</h1>
</div>
<form onSubmit={this.handleSubmitResult}>
<label>
Your alias:
<input type="text" maxLength="12"
name="user"
value={this.state.user}
onChange={this.handleChange}/>
</label>
<br/>
<label>
Your guess:
<input type="number" min="0"
name="guess"
value={this.state.guess}
onChange={this.handleChange}/>
</label>
<br/>
<input type="submit" value="Submit"/>
</form>
<h4>{this.state.message}</h4>
</div>
);
}
Listing 4-9Using render() with JSX to Display the Component’s Elements
组件的根元素有三个主块。第一个通过显示状态中包含的两个因素来显示挑战。在渲染的时候它们是未定义的,但是当我们从服务器得到响应后,它们会立即被重新加载(?? 内部的逻辑)。类似的区块是最后一个;它显示了message状态属性,该属性是我们在获得发送的尝试请求的响应时设置的。
为了让用户输入他们的猜测,我们添加了一个表单,在提交时调用handleSubmitResult。这个表单有两个输入:一个是用户的别名,另一个是猜测。两者遵循相同的方法:它们的值是状态对象的属性,并且它们在每次击键时调用相同的函数handleChange。这个函数使用我们输入的name属性在组件状态中找到相应的属性来更新。注意event.target指向事件发生的 HTML 元素。参见清单 4-10 获取这些处理函数的源代码。
handleChange(event) {
const name = event.target.name;
this.setState({
[name]: event.target.value
});
}
handleSubmitResult(event) {
event.preventDefault();
ApiClient.sendGuess(this.state.user,
this.state.a, this.state.b,
this.state.guess)
.then(res => {
if (res.ok) {
res.json().then(json => {
if (json.correct) {
this.updateMessage("Congratulations! Your guess is correct");
} else {
this.updateMessage("Oops! Your guess " + json.resultAttempt +
" is wrong, but keep playing!");
}
});
} else {
this.updateMessage("Error: server error or not available");
}
});
}
Listing 4-10Handling User’s Input
在表单提交时,我们调用服务器的 API 来发送一个猜测。当我们得到响应时,我们检查它是否正确,解析 JSON,然后更新状态中的消息。然后,HTML DOM 的这一部分被再次呈现。
与应用集成
现在我们已经完成了组件的代码,我们可以在我们的应用中使用它了。为此,让我们修改App.js文件,它是 React 代码库中的主要(或根)组件。参见清单 4-11 。
import React from 'react';
import './App.css';
import ChallengeComponent from './components/ChallengeComponent';
function App() {
return (
<div className="App">
<header className="App-header">
<ChallengeComponent/>
</header>
</div>
);
}
export default App;
Listing 4-11Adding Our Component as a Child of App.js, the Root Component
如前所述,skeleton 应用在index.js文件中使用这个App组件。当我们构建代码时,生成的脚本包含在index.html文件中。
我们还应该修改包含在App.test.js中的测试,或者干脆删除它。我们不会深入 React 测试的细节,所以你现在可以删除它。如果您想了解更多关于为 React 组件编写测试的信息,请查看官方指南中的测试章节( https://tpd.io/r-testing )。
首次运行我们的前端
我们修改了用create-react-app构建的框架应用,以包含我们自定义的 React 组件。请注意,我们没有删除其他文件,如样式表,我们也可以自定义这些文件。正如你在App.js的代码中看到的,我们实际上重用了其中的一些类。
是时候验证一下我们的前端和后端是否协同工作了。确保首先运行 Spring Boot 应用,然后使用前端应用根文件夹中的npm执行 React 前端。
$ npm start
成功编译后,这个命令行工具应该会打开您的默认浏览器,并显示位于localhost:3000的页面。这是开发服务器所在的地方。参见图 4-2 显示当我们从浏览器访问该 URL 时呈现的网页。
图 4-2
带有空白因子的应用
那里出了问题。这些因素是空白的,但是我们的代码在组件呈现之后检索它们。我们来看看如何调试这个问题。
排除故障
有时事情并不像预期的那样发展,你的应用根本无法工作。你在浏览器上运行应用,那么你怎么知道会发生什么呢?好消息是大多数流行的浏览器都为开发者提供了强大的工具。在 Chrome 中,你可以使用 Chrome DevTools(参见 https://tpd.io/devtools )。使用 Ctrl+May+I (Windows)或 Cmd+Opt+I (Mac)在浏览器中打开一个区域,其中有几个选项卡和部分显示网络活动、JavaScript 控制台等。
打开开发模式并刷新浏览器。您可以检查的功能之一是您的前端是否与服务器正常交互。点击网络选项卡,在列表中,您会看到一个失败的对http://localhost:8080/challenges/random的 HTTP 请求,如图 4-3 所示。
图 4-3
Chrome DevTools(铬 DevTools)
该控制台还显示一条描述性消息:
默认情况下,您的浏览器会阻止试图访问不同于前端所在域的资源的请求。这是为了避免浏览器中的恶意页面访问不同页面中的数据,这被称为同源策略。在我们的例子中,我们在localhost中运行前端和后端,但是它们运行在不同的端口上,所以它们被认为是不同的来源。
有多种方法可以解决这个问题。在我们的例子中,我们将启用跨源资源共享(CORS),这是一个可以在服务器端启用的安全策略,允许我们的前端与来自不同源的 REST API 一起工作。
将 CORS 配置添加到 Spring Boot 应用
我们回到后端代码库,添加一个 Spring Boot @Configuration类,它将覆盖一些默认值。根据参考文档( https://tpd.io/spring-cors ),我们可以实现接口WebMvcConfigurer并覆盖方法addCorsMapping来添加一个通用的 CORS 配置。为了保持类的有序,我们为这个类创建了一个名为configuration的新包。参见清单 4-12 。
package microservices.book.multiplication.configuration;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfiguration implements WebMvcConfigurer {
@Override
public void addCorsMappings(final CorsRegistry registry) {
registry.addMapping("/**").allowedOrigins("http://localhost:3000");
}
}
Listing 4-12Adding the CORS Configuration to the Back-End Application
这个方法使用我们可以定制的注入的CorsRegistry实例。我们添加一个映射,允许前端的原点访问由/**表示的任何路径。我们也可以省略这一行中的allowedOrigins部分。然后,将允许所有来源,而不仅仅是http://localhost:3000。
请记住,Spring Boot 扫描您的软件包寻找配置类。这是其中之一,所以这个 CORS 配置将在您下次启动应用时自动应用。
关于 CORS,有一点很重要,一般来说,你可能只在开发的时候需要它。如果您将应用的前端和后端部署到同一个主机上,您不会遇到任何问题,并且您不应该让 CORS 尽可能严格地保持安全策略。当您将后端和前端部署到不同的主机时,您仍然应该在 CORS 配置中非常有选择性,并避免添加对所有来源的完全访问。
使用应用
现在我们的前端和后端应该协同工作。如果您还没有重新启动 Spring Boot 应用,请重新启动它并刷新您的浏览器(图 4-4 )。
图 4-4
我们应用的第一个版本
激动人心的时刻!现在,您可以输入您的别名并进行一些尝试。记得尊重规则,只用脑子去猜测结果。
部署 React 应用
到目前为止,我们的前端一直使用开发模式。我们用npm start启动了 web 服务器。当然,这在生产环境中是行不通的。
为了准备 React 应用进行部署,我们需要首先构建它。参见清单 4-13 。
$ npm run build
> challenges-frontend@0.1.0 build /Users/moises/dev/apress2/learn-microservices/challenges-frontend
> react-scripts build
Creating an optimized production build...
Compiled successfully.
File sizes after gzip:
39.92 KB (+540 B) build/static/js/2.548ff48a.chunk.js
1.32 KB (+701 B) build/static/js/main.3411a94e.chunk.js
782 B build/static/js/runtime-main.8b342bfc.js
547 B build/static/css/main.5f361e03.chunk.css
The project was built assuming it is hosted at /.
You can control this with the homepage field in your package.json.
The build folder is ready to be deployed.
You may serve it with a static server:
npm install -g serve
serve -s build
Find out more about deployment here:
bit.ly/CRA-deploy
Listing 4-13Building the React App for a Production Deployment
正如您所看到的,这个命令在build文件夹下生成了所有的脚本和文件。我们还在那里找到了我们放在public文件夹中的文件的副本。这些日志还告诉我们如何使用npm安装静态 web 服务器。但实际上,我们已经有了一个嵌入在 Spring Boot 应用中的 web 服务器 Tomcat。我们不能用那个吗?我们当然可以。
对于我们的部署示例,我们将遵循最简单的方法,将整个应用(后端和前端)打包在同一个可部署单元中:由 Spring Boot 生成的 fat JAR 文件。
我们需要做的是将前端的build文件夹中的所有文件复制到乘法代码库的src/main/resources文件夹中的一个名为static的文件夹中。见图 4-5 。Spring Boot 的默认服务器配置为静态 web 文件添加了一些预定义的位置,我们的类路径中的这个static文件夹就是其中之一。这些文件将被映射到位于/的应用的根上下文。
图 4-5
项目结构中的静态资源
通常,如果您愿意,您可以配置这些资源位置及其映射。您可以对此进行微调的地方之一实际上是我们用于 CORS 注册中心配置的同一个WebMvcConfigurer接口实现。如果您想了解更多关于配置 web 服务器来提供静态页面( https://tpd.io/mvc-static )的信息,请查看 Spring Boot 参考文档中的静态内容一节。
然后,我们重新启动乘法应用。这一次,使用./mvnw spring-boot:run通过命令行(而不是通过您的 IDE)运行它是很重要的。原因是 ide 在运行应用时可能会以不同的方式使用类路径,在这种情况下,您可能会得到错误(例如,找不到页面)。
如果我们导航到http://localhost:8080,我们的 Spring Boot 应用中的嵌入式 Tomcat 服务器将试图找到一个默认的index.html页面,它的存在是因为我们从 React 构建中复制了它。现在,我们已经从用于后端的同一台嵌入式服务器上加载了 React 应用。见图 4-6 。
图 4-6
嵌入式 Tomcat 提供的 React 应用
假设现在前端和后端共享同一个原点,您可能想知道我们在上一节中添加的 CORS 配置现在会发生什么。当我们在同一个服务器中部署 React 应用时,就不再需要添加 CORS 了。您可以删除它,因为静态前端文件和后端 API 都位于原点http://localhost:8080。无论如何,让我们保留这个配置,因为我们在开发 React 应用时将使用开发服务器。现在,您可以在我们的 Spring Boot 应用中再次删除static文件夹中的内容。
总结和成就
是时候回顾一下我们在这一章中所取得的成就了。当我们开始时,我们有一个 REST API,我们通过命令行工具与之交互。现在,我们添加了一个与后端交互的用户界面,以检索挑战和发送尝试。我们为用户提供了真正的 web 应用。参见图 4-7 。
图 4-7
第四章末尾的应用的逻辑视图
我们使用create-react-app工具创建了 React 应用的基础,并了解了它的结构。然后,我们用 JavaScript 开发了一个连接 API 的服务,以及一个使用该服务并呈现简单 HTML 代码块的 React 组件。
为了能够互连位于不同来源的后端和前端,我们向后端添加了 CORS 配置。
最后,我们看到了如何为生产构建 React 应用。我们还将生成的静态文件移动到后端项目代码库中,以说明如何从嵌入式 Tomcat 服务器提供静态内容。
理想情况下,本章帮助您理解前端应用的基础,并看到一个与 API 交互的实际例子。即使只是最基本的,这些知识也可能对你的职业生涯有所帮助。
我们将在接下来的章节中使用这个前端应用来说明微服务架构如何影响 REST API 客户端。
章节成就:
-
您学习了 React 的基础知识,React 是市场上最流行的 JavaScript 框架之一。
-
您使用
create-react-app工具构建了 React 应用的框架。 -
您开发了一个带有基本用户界面的 React 组件,供用户发送尝试。
-
您了解了什么是 CORS,以及我们如何在后端添加例外来允许这些请求。
-
您已经快速了解了如何使用浏览器的开发工具调试前端。
-
您了解了如何打包 React 项目构建产生的 HTML 和 JavaScript,以及如何在与后端应用相同的 JAR 文件中分发它们。
-
您第一次看到了这个应用在它的最小版本中工作,包括后端和前端。
五、数据层
我们花了两章来完成我们的第一个用户故事。现在,我们有了一个可以试验的最小可行产品(MVP)。在敏捷中,以这种方式对需求进行切片是非常强大的。我们可以开始从一些测试用户那里收集反馈,并决定我们应该构建的下一个特性是什么。此外,如果我们的产品理念是错误的,改变一些东西还为时过早。
学习如何垂直地而不是水平地分割你的产品需求可以在构建软件时节省你很多时间。这意味着你不必等到你完成了一个完整的层再去下一层。取而代之的是,你在多个层次上开发作品,以便能够有一些作品。这也有助于你打造更好的产品或服务,因为当你能轻松做出反应时,你会得到反馈。如果你想了解更多关于故事分割的策略,请查看 http://tpd.io/story-splitting 。
假设我们的测试用户尝试了我们的应用。他们中的大多数人回来告诉我们,如果他们能够访问他们的统计数据,了解他们在一段时间内的表现,那就太好了。团队坐在一起,带回一个新的用户故事。
用户故事 2
作为该应用的用户,我想访问我最近的尝试,这样我就可以看到我是否随着时间的推移提高了我的大脑技能。
当将这个故事映射到技术解决方案时,我们很快注意到我们需要将尝试存储在某个地方。在这一章中,我们将介绍我们的三层应用架构中缺少的一层:数据层。这也意味着我们将使用三层架构的不同层:数据库。见图 5-1 。
图 5-1
我们的目标应用设计
我们还需要将这些新需求集成到其余的层中。总而言之,我们可以执行以下任务列表:
-
存储所有用户尝试,并有一种方法来查询每个用户。
-
公开一个新的 REST 端点来获取给定用户的最新尝试。
-
创建一个新的服务(业务逻辑)来检索这些尝试。
-
在用户发送新的尝试后,在网页上向用户显示尝试的历史记录。
数据模型
在我们在第三章中创建的概念模型中,有三个领域对象:用户、挑战和尝试。然后,我们决定打破挑战和尝试之间的联系。相反,为了保持我们的领域简单,我们在尝试中复制了这两个因素。这使得我们在对象之间只有一种关系需要建模:尝试属于一个特定的用户。
请注意,我们可以在简化的过程中更进一步,将用户数据(目前是别名)也包括在内。在这种情况下,我们现在需要存储的唯一对象就是尝试。然后,我们可以在同一个表中使用用户别名来查询我们的数据。但这是有代价的,比我们通过复制因素所假设的要高:我们认为用户是一个不同的领域,可能会随着时间的推移而演变,并与其他领域发生互动。在数据层中如此紧密地混合域不是一个好主意。
还有另一种设计方案。我们可以通过用三个独立的对象精确映射我们的概念域来创建我们的域类,并且在ChallengeAttempt和Challenge之间有一个链接。见图 5-2 。
图 5-2
概念模型的提醒
这可以用我们处理User的方式来完成。见清单 5-1 。
@Getter
@ToString
@EqualsAndHashCode
@AllArgsConstructor
public class ChallengeAttempt {
private Long id;
private User user;
// We decided to include factors
// private final int factorA;
// private final int factorB;
// This is an alternative
private Challenge challenge;
private int resultAttempt;
private boolean correct;
}
Listing 5-1An Alternative Implementation of ChallengeAttempt
然后,在设计数据模型时,我们可以选择简化。在这种方法中,我们将拥有域类ChallengeAttempt的新版本,如前面的代码片段所示,以及数据层中的一个不同的类。例如,我们可以将这个类命名为ChallengeAttemptDataObject。其中将包括内部因素,因此我们需要在层之间实现映射器,以组合和拆分挑战和尝试。您可能已经发现,这种方法类似于我们对 DTO 模式所做的。当时,我们在表示层创建了一个新版本的Attempt对象,在那里我们还添加了一些验证注释。
正如在软件设计的许多其他方面一样,对于完全隔离 dto、域类和数据类,有多种支持和反对的意见。正如我们在假设案例中已经看到的,主要优势之一是我们获得了更高水平的隔离。我们可以替换数据层的实现,而不必修改服务层的代码。然而,一个很大的缺点是我们在应用中引入了大量的代码重复和复杂性。
在这本书里,我们遵循一种实用的方法,在应用适当的设计模式的同时,尽量让事情变得简单。我们在前一章中选择了一个域模型,现在我们可以将它直接映射到我们的数据模型。因此,我们可以为域和数据表示重用相同的类。这是一个很好的折衷解决方案,因为我们仍然保持我们的领域隔离。见图 5-3 显示了我们必须保存在数据库中的对象和关系。
图 5-3
乘法应用的数据模型
选择数据库
这一节将讨论如何根据项目的需求和我们将使用的抽象层次为我们的项目选择一个数据库。
SQL vs. NoSQL
市场上有很多可用的数据库引擎。它们都有自己的特点,但是,大多数时候,每个人都把它们归为两类:SQL 和 NoSQL。SQL 数据库是关系型的,有固定的模式,它们允许我们进行复杂的查询。NoSQL 数据库面向非结构化数据,例如可以面向键值对、文档、图形或基于列的数据。
简而言之,我们也可以说 NoSQL 数据库更适合大量的记录,因为这些数据库是分布式的。我们可以部署多个节点(或实例),因此它们在写入和/或读取数据时都有良好的性能。我们付出的代价是这些数据库遵循 CAP 定理( https://en.wikipedia.org/wiki/CAP_theorem )。当我们以分布式方式存储数据时,我们只需在可用性、一致性和分区容差保证中选择两个。我们通常需要分区容错,因为网络错误很容易发生,所以我们应该能够处理它们。因此,大多数情况下,我们必须在尽可能长时间提供数据和保持数据一致之间做出选择。
另一方面,关系数据库(SQL)遵循 ACID 保证:原子性(事务作为一个整体要么成功要么失败);一致性(数据总是在有效状态之间转换);隔离(确保并发性不会引起副作用),以及持久性(在一个事务之后,即使在系统失败的情况下,状态也是持久的)。这些都是很棒的特性,但是为了确保它们,这些数据库不能适当地处理水平可伸缩性(多个分布式节点),这意味着它们不能很好地伸缩。
仔细分析您的数据需求非常重要。您打算如何查询数据?您需要高可用性吗?你写了几百万张唱片吗?你需要非常快速的阅读吗?此外,请记住系统的非功能性需求。例如,在我们的特殊情况下,我们可以接受系统每年有几个小时(甚至几天)不可用。然而,如果我们为医疗保健部门开发一个 web 应用,在那里生命可能处于危险之中,我们将处于不同的情况。在接下来的章节中,我们将回到非功能性需求,对其中的一些进行更详细的分析。
我们的模型是关系型。此外,我们不打算处理数百万的并发读写。我们将为我们的 web 应用选择一个 SQL 数据库,以从 ACID 保证中获益。
在任何情况下,保持我们的应用(未来的微服务)足够小的一个优点是,我们可以在以后需要时更改数据库引擎,而不会对整个软件架构产生大的影响。
H2、Hibernate 和 JPA
下一步是决定我们从所有的可能性中选择什么关系数据库:MySQL、MariaDB、PostgreSQL、H2、Oracle SQL 等等。在本书中,我们选择 H2 数据库引擎,因为它小巧且易于安装。它非常简单,可以嵌入到我们的应用中。
在关系数据库之上,我们将使用对象/关系映射(ORM)框架:Hibernate ORM。我们将使用 Hibernate 将 Java 对象映射到 SQL 记录,而不是处理表格数据和普通查询。如果你想了解更多关于 ORM 技术的知识,请查看 http://tpd.io/what-is-orm 。
我们不使用 Hibernate 中的本机 API 将对象映射到数据库记录,而是使用一个抽象:Java 持久性 API (JPA)。
这就是我们的技术选择相互关联的方式:
-
在我们的 Java 代码中,我们将使用 Spring Boot JPA 注释和集成,因此我们保持代码与 Hibernate 细节的分离。
-
在实现方面,Hibernate 负责将我们的对象映射到数据库实体的所有逻辑。
-
Hibernate 支持针对不同数据库的多种 SQL 方言,H2 方言就是其中之一。
-
Spring Boot 自动配置为我们设置了 H2 和 Hibernate,但是我们也可以自定义行为。
规范和实现之间的这种松散耦合给了我们一个很大的优势:更改到不同的数据库引擎将是无缝的,因为它是由 Hibernate 和 Spring Boot 配置抽象的。
Spring Boot 数据 JPA
让我们分析一下 Spring Boot 数据 JPA 模块提供了什么。
依赖性和自动配置
Spring 框架有多个模块可用于处理数据库,这些模块被归入 Spring Data 家族:JDBC、Cassandra、Hadoop、Elasticsearch 等。其中之一是 Spring Data JPA,它以基于 Spring 的编程风格,使用 Java 持久性 API 来抽象对数据库的访问。
Spring Boot 采取了额外的步骤,用一个专用的启动器使用自动配置和一些额外的工具来快速引导数据库访问:spring-boot-starter-data-jpa模块。它还可以自动配置嵌入式数据库,如 H2,我们的应用的选择。
我们在创建应用时没有添加这些依赖项,以尊重循序渐进的方法。现在是时候这么做了。在我们的pom.xml文件中,我们添加了 Spring Boot 启动器和 H2 嵌入式数据库实现。见清单 5-2 。我们只需要运行时的 H2 工件,因为我们将在代码中使用 JPA 和 Hibernate 抽象。
<dependencies>
[...]
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
[...]
</dependencies>
Listing 5-2Adding the Data Layer Dependencies to Our Application
源代码
您可以在 GitHub 的chapter05资源库中找到本章的所有源代码。
https://github.com/Book-Microservices-v2/chapter05见。
Hibernate 是 JPA 在 Spring Boot 的参考实现。这意味着 starter 将 Hibernate 依赖项带了进来。它还包括核心 JPA 工件以及与其父模块 Spring Data JPA 的依赖关系。
我们已经提到,H2 可以作为一个嵌入式数据库。因此,我们不需要自己安装、启动或关闭数据库。我们的 Spring Boot 应用将控制它的生命周期。然而,出于教学目的,我们也想从外部访问数据库,所以让我们在application.properties文件中添加一个属性来启用 H2 数据库控制台。
# Gives us access to the H2 database web console
spring.h2.console.enabled=true
H2 控制台是一个简单的 web 界面,我们可以使用它来管理和查询数据。让我们通过再次启动我们的应用来验证这个新配置是否有效。我们将看到一些新的日志行,来自 Spring Boot 数据 JPA 自动配置逻辑。参见清单 5-3 。
INFO 33617 --- [main] o.s.web.context.ContextLoader : Root WebApplicationContext: initialization completed in 1139 ms
INFO 33617 --- [main] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Starting...
INFO 33617 --- [main] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Start completed.
INFO 33617 --- [main] o.s.b.a.h2.H2ConsoleAutoConfiguration : H2 console available at '/h2-console'. Database available at 'jdbc:h2:mem:testdb'
INFO 33617 --- [main] o.hibernate.jpa.internal.util.LogHelper : HHH000204: Processing PersistenceUnitInfo [name: default]
INFO 33617 --- [main] org.hibernate.Version : HHH000412: Hibernate ORM core version 5.4.12.Final
INFO 33617 --- [main] o.hibernate.annotations.common.Version : HCANN000001: Hibernate Commons Annotations {5.1.0.Final}
INFO 33617 --- [main] org.hibernate.dialect.Dialect : HHH000400: Using dialect: org.hibernate.dialect.H2Dialect
INFO 33617 --- [main] o.h.e.t.j.p.i.JtaPlatformInitiator : HHH000490: Using JtaPlatform implementation: [org.hibernate.engine.transaction.jta.platform.internal.NoJtaPlatform]
INFO 33617 --- [main] j.LocalContainerEntityManagerFactoryBean : Initialized JPA EntityManagerFactory for persistence unit 'default'
Listing 5-3Application Logs Showing Database Autoconfiguration
Spring Boot 在类路径中检测 Hibernate,并配置一个数据源。由于 H2 也可用,Hibernate 连接到 H2 并选择 H2 方言。它还为我们初始化了一个EntityManagerFactory;我们很快就会明白这意味着什么。还有一个日志行声称 H2 控制台在/h2-console可用,并且有一个数据库在jdbc:h2:mem:testdb可用。如果没有指定其他配置,Spring Boot 自动配置会创建一个名为testdb的现成的内存数据库。
让我们导航到http://localhost:8080/h2-console来看看控制台 UI。见图 5-4 。
图 5-4
H2 控制台,登录
我们可以复制并粘贴jdbc:h2:mem:testdb作为 JDBC 网址,其他值保持不变。然后,我们单击 Connect,我们可以访问主控制台视图。见图 5-5 。
图 5-5
H2 控制台,连接
看起来我们确实有一个名为testdb的内存数据库,并且我们能够使用 H2 默认的管理员凭证连接到它。这个数据库来自哪里?这是我们将很快分析的内容。
我们将在本章后面使用 H2 控制台界面来查询我们的数据。现在,让我们继续学习,探索 Spring Boot 和 Data JPA starter 附带的技术堆栈。
Spring Boot 数据 JPA 技术堆栈
让我们从最底层开始,使用图 5-6 作为视觉支持。在包java.sql和javax.sql中有一些核心的 Java APIs 来处理 SQL 数据库。在那里,我们可以找到接口DataSource、Connection,以及其他一些用于池化资源的接口,如PooledConnection或ConnectionPoolDataSource。我们可以找到不同供应商提供的这些 API 的多种实现。Spring Boot 附带了 HikariCP ( http://tpd.io/hikari ),这是最流行的DataSource连接池实现之一,因为它是轻量级的,并且具有良好的性能。
Hibernate 使用这些 API(以及我们的应用中的 HikariCP 实现)来连接 H2 数据库。Hibernate 中用于管理数据库的 JPA 风格是SessionImpl类( http://tpd.io/h-session ),它包含了大量用于执行语句、执行查询、处理会话连接等的代码。这个类通过它的层次树实现了 JPA 接口EntityManager ( http://tpd.io/jpa-em )。这个接口是 JPA 规范的一部分。在 Hibernate 中,它的实现完成了 ORM。
图 5-6
Spring Data JPA 技术堆栈
在 JPA 的EntityManager之上,Spring Data JPA 定义了一个JpaRepository接口(在 Spring 中,SimpleJpaRepository类(tpd.io/simple-jpa-repo)是默认的实现,并在底层使用EntityManager。这意味着我们不需要使用纯 JPA 标准或 Hibernate 来在代码中执行数据库操作,因为我们可以使用这些 Spring 抽象。
我们将在本章后面探索 Spring 为 JPA Repository类提供的一些很酷的特性。
数据源(自动)配置
当我们使用新的依赖项再次运行我们的应用时,有些事情可能会让您感到惊讶。我们还没有配置数据源,那么为什么我们能够成功地打开与 H2 的连接呢?答案总是自动配置,但这一次它带来了一点额外的魔力。
通常,我们使用application.properties中的一些值来配置数据源。这些属性由 Spring Boot 自动配置依赖项中的DataSourceProperties类( http://tpd.io/dsprops )定义,例如,它包含数据库的 URL、用户名和密码。像往常一样,还有一个DataSourceAutoConfiguration类( http://tpd.io/ds-autoconfig )使用这些属性在上下文中创建必要的 beans。在本例中,它创建了DataSource bean 来连接数据库。
sa用户名实际上来自 Spring 的DataSourceProperties类中的一段代码。见清单 5-4 。
/**
* Determine the username to use based on this configuration and the environment.
* @return the username to use
* @since 1.4.0
*/
public String determineUsername() {
if (StringUtils.hasText(this.username)) {
return this.username;
}
if (EmbeddedDatabaseConnection.isEmbedded(determineDriverClassName())) {
return "sa";
}
return null;
}
Listing 5-4A Fragment of Spring Boot’s DataSourceProperties Class
由于 Spring Boot 开发人员知道这些惯例,他们可以准备 Spring Boot,这样我们就可以使用开箱即用的数据库。不需要传递任何配置,因为他们硬编码了用户名,默认情况下密码是空的String。还有其他约定,如数据库名称;这就是我们如何得到testdb数据库的。
我们不会使用 Spring Boot 创建的默认数据库。相反,我们在应用的名称后设置名称,并更改 URL 以创建存储在文件中的数据库。如果我们继续使用内存数据库,当我们关闭应用时,所有的尝试都将丢失。此外,我们必须添加参考文档中描述的参数DB_CLOSE_ON_EXIT=false(参见此 http://tpd.io/sb-embed-db ),因此我们禁用自动关闭,并让 Spring Boot 决定何时关闭数据库。请参见清单 5-5 中的结果 URL,以及我们在application.properties文件中包含的其他更改。之后还有一些额外的解释。
-
如前所述,我们将数据源更改为使用用户主目录
~中名为multiplication的文件。我们通过在 URL 中指定:file:来做到这一点。要了解 H2 网址的所有配置可能性,请勾选http://tpd.io/h2url。 -
为了简单起见,我们将让 Hibernate 为我们创建数据库模式。这个特性被称为自动数据定义语言(DDL)。我们将它设置为
update,因为我们希望在创建或修改实体时同时创建和更新模式(正如我们将在下一节中所做的)。 -
最后,我们启用属性
spring.jpa.show-sql,这样我们就可以在日志中看到查询。这对学习很有用。
# Gives us access to the H2 database web console
spring.h2.console.enabled=true
# Creates the database in a file
spring.datasource.url=jdbc:h2:file:~/multiplication;DB_CLOSE_ON_EXIT=FALSE
# Creates or updates the schema if needed
spring.jpa.hibernate.ddl-auto=update
# For educational purposes we will show the SQL in console
spring.jpa.show-sql=true
Listing 5-5application.properties File with
New Parameters for Database Configuration
实体
从数据的角度来看,JPA 将实体调用到 Java 对象。因此,假设我们打算存储用户和尝试,我们必须使User和ChallengeAttempt类成为实体。如前所述,我们可以为数据层创建新的类并使用映射器,但是我们希望保持代码库简单,所以我们重用了域定义。
首先,我们给User添加一些 JPA 注释。参见清单 5-6 。
package microservices.book.multiplication.user;
import lombok.*;
import javax.persistence.*;
/**
* Stores information to identify the user.
*/
@Entity
@Data
@AllArgsConstructor
@NoArgsConstructor
public class User {
@Id
@GeneratedValue
private Long id;
private String alias;
public User(final String userAlias) {
this(null, userAlias);
}
}
Listing 5-6The User Class After Adding JPA Annotations
让我们一个接一个地了解这个更新的User类的特征:
-
我们添加了
@Entity注释,将这个类标记为映射到数据库记录的对象。如果我们想用不同于缺省值的名称命名我们的表,我们可以在注释中添加一个值user。同样,默认情况下,通过类中的 getters 公开的所有字段都将以默认的列名保存在映射表中。我们可以通过用 JPA 的@Transient注释标记字段来排除它们。 -
Hibernate 的用户指南(
http://tpd.io/hib-pojos)声明我们应该提供 setters 或者让我们的字段可以被 Hibernate 修改。幸运的是,Lombok 有一个快捷的注释,@Data,它非常适合用作数据实体的类。该注释将equals和hashCode方法、toString、getters 和 setters 分组。Hibernate 用户指南中的另一个章节告诉我们不要使用final类。这样,我们允许 Hibernate 创建运行时代理,从而提高性能。我们将在本章的后面看到一个运行时代理如何工作的例子。 -
JPA 和 Hibernate 也要求我们的实体有一个默认的空构造函数(见
http://tpd.io/hib-constructor)。我们可以用 Lombok 的@NoArgsConstructor注释快速添加。 -
我们的
id字段被标注为@Id和@GeneratedValue。这将是唯一标识每一行的列。我们使用一个生成的值,因此 Hibernate 将为我们填充该字段,从数据库中获取序列的下一个值。
对于ChallengeAttempt类,我们使用了一些额外的特性。参见清单 5-7 。
package microservices.book.multiplication.challenge;
import lombok.*;
import microservices.book.multiplication.user.User;
import javax.persistence.*;
@Entity
@Data
@AllArgsConstructor
@NoArgsConstructor
public class ChallengeAttempt {
@Id
@GeneratedValue
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "USER_ID")
private User user;
private int factorA;
private int factorB;
private int resultAttempt;
private boolean correct;
}
Listing 5-7The ChallengeAttempt Class with JPA Annotations
与前面的类不同,我们的挑战尝试模型不仅有基本类型,还有一个嵌入的实体类型,User。Hibernate 知道如何映射它,因为我们添加了 JPA 注释,但是它不知道这两个实体之间的关系。在数据库中,我们可以将这些关系建模为一对一、一对多、多对一和多对多。
我们在这里定义了一个多对一的关系,因为我们已经倾向于避免将用户与尝试相关联,而是将尝试与用户相关联。为了在我们的数据层做出这些决定,我们还应该考虑我们计划如何查询我们的数据。在我们的例子中,我们不需要从用户到尝试的链接。如果想了解 Hibernate 中实体关系的更多信息,可以查看 Hibernate 用户指南中的关联部分( http://tpd.io/hib-associations )。
正如您在代码中看到的,我们正在向@ManyToOne注释传递一个参数:fetch类型。当从数据存储中收集我们的尝试时,我们必须告诉 Hibernate when 来收集嵌套用户的值,这些值存储在不同的表中。如果我们将它设置为EAGER,用户数据将被收集。使用LAZY,检索这些字段的查询将只在我们试图访问它们时执行。这是因为 Hibernate 为我们的实体类配置了代理类。参见图 5-7 。这些代理类扩展了我们的类;这就是为什么如果我们想让这个机制工作的话,我们不应该将它们声明为final。对于我们的例子,Hibernate 将传递一个代理对象,该对象仅在第一次使用访问器(getter)时触发查询来获取用户。这就是懒惰这个术语的来源——它不到最后一刻是不会这么做的。
图 5-7
休眠,拦截类
一般来说,我们应该倾向于惰性关联,以避免触发对您可能不需要的数据的额外查询。在我们的例子中,当我们收集尝试时,我们不需要用户的数据。
@JoinColumn注释让 Hibernate 用一个连接列链接两个表。为了保持一致性,我们传递给它的列名与代表用户索引的列名相同。这将转化为添加到CHALLENGE_ATTEMPT表中的新列USER_ID,它将存储对USER表中相应用户的ID记录的引用。
这是一个带有 JPA 和 Hibernate 的 ORM 的基本但有代表性的例子。如果您想扩展您对 JPA 和 Hibernate 所有可能性的了解,用户指南( http://tpd.io/hib-user-guide )是一个很好的起点。
将域对象作为实体重用的后果
由于 JPA 和 Hibernate 的要求,我们需要向我们的类中添加 setters 和一个丑陋的空构造函数(Lombok 隐藏了它,但它仍然在那里)。这很不方便,因为它阻止我们按照良好的实践(比如不变性)来创建类。我们可以说我们的领域类被数据需求破坏了。
当您正在构建小型应用并且您知道这些决策背后的原因时,这不是一个大问题。您只需避免在代码中使用 setters 或空构造函数。然而,当与一个大团队或在一个中型或大型项目中工作时,这可能会成为一个问题,因为一个新的开发人员可能会因为类允许他们这样做而试图破坏好的实践。在这种情况下,您可以考虑像前面提到的那样分割域和实体类。这将带来一些代码重复,但是您可以更好地实施良好的实践。
仓库
当我们描述三层架构时,我们简要解释了数据层可能包含数据访问对象(Dao)和存储库。Dao 通常是耦合到数据库结构的类,而另一方面,存储库是以领域为中心的,所以这些类可以与聚合一起工作。
假设我们遵循领域驱动的设计,我们将使用存储库来连接数据库。更具体地说,我们将使用 JPA 存储库和 Spring Data JPA 中包含的特性。
在前面关于技术栈的部分,我们介绍了 Spring 的SimpleJpaRepository类(参见 https://tpd.io/sjparepo-doc ),它使用 JPA 的EntityManager(参见 https://tpd.io/em-javadoc )来管理我们的数据库对象。Spring 抽象增加了一些特性,比如分页和排序,以及一些比普通 JPA 接口更方便使用的方法(例如,saveAll、existsById、count等)。).
Spring Data JPA 还附带了一个普通 JPA 没有的强大功能:查询方法(参见 http://tpd.io/jpa-query-methods )。
让我们使用我们的代码库来演示这个功能。我们需要一个查询来获取给定用户的最后一次尝试,这样我们就可以在网页上显示统计数据。除此之外,我们需要一些基本的实体管理来创建、读取和删除尝试。清单 5-8 中显示的接口提供了该功能。
package microservices.book.multiplication.challenge;
import org.springframework.data.repository.CrudRepository;
import java.util.List;
public interface ChallengeAttemptRepository extends CrudRepository<ChallengeAttempt, Long> {
/**
* @return the last 10 attempts for a given user, identified by their alias.
*/
List<ChallengeAttempt> findTop10ByUserAliasOrderByIdDesc(String userAlias);
}
Listing 5-8The ChallengeAttemptRepository Interface
我们创建的接口扩展了 Spring Data Commons 中的CrudRepository接口( http://tpd.io/crud-repo )。CrudRepository定义了创建、读取、更新和删除(CRUD)对象的基本方法列表。Spring Data JPA 中的SimpleJpaRepository类也实现了这个接口( http://tpd.io/simple-jpa-repo )。除了CrudRepository,我们还可以使用另外两种选择。
-
如果我们选择扩展普通的
Repository,我们就得不到 CRUD 功能。然而,当我们想要微调我们想要从CrudRepository中公开的方法时,该接口就像一个标记,而不是默认地获取它们。见http://tpd.io/repo-tuning了解更多关于这种技术。 -
如果我们还需要分页和排序,我们可以扩展
PagingAndSortingRepository。如果我们必须处理大的集合,那么这是很有帮助的,大的集合最好是以块或者页来查询。
当我们扩展这三个接口中的任何一个时,我们必须使用 Java 泛型,正如我们在这行中所做的:
... extends CrudRepository<ChallengeAttempt, Long> {
第一种类型指定返回实体的类,在我们的例子中是ChallengeAttempt。第二个类必须匹配索引的类型,在我们的存储库中是一个Long(id字段)。
我们代码中最引人注目的部分是我们添加到接口中的方法名。在 Spring Data 中,我们可以通过在方法名中使用命名约定来创建定义查询的方法。在这个特殊的例子中,我们希望通过用户别名查询尝试,按照id降序排列(最新的排在最前面),并选择列表中的前 10 个。按照方法结构,我们可以将查询描述如下:find Top 10(任何匹配的ChallengeAttempt ) by(字段userAlias等于传递的参数)order by(字段id)降序。
Spring Data 将处理您在接口中定义的方法,寻找那些没有明确定义查询的方法,并匹配创建查询方法的命名约定。这正是我们的情况。然后,它解析方法名,将其分解成块,并构建一个符合该定义的 JPA 查询(继续阅读示例查询)。
我们可以使用 JPA 查询方法定义构建许多其他查询;详见 http://tpd.io/jpa-qm-create 。
有时我们可能想要执行一些查询方法无法实现的查询。或者也许我们只是不习惯使用这个特性,因为方法名开始变得有点奇怪。不用担心,也可以定义我们自己的查询。在这种情况下,我们仍然可以通过用 Java 持久性查询语言(JPQL)编写查询来保持我们的实现从数据库引擎中抽象出来,JPQL 是一种 SQL 语言,也是 JPA 标准的一部分。参见清单 5-9 。
/**
* @return the last attempts for a given user, identified by their alias.
*/
@Query("SELECT a FROM ChallengeAttempt a WHERE a.user.alias = ?1 ORDER BY a.id DESC")
List<ChallengeAttempt> lastAttempts(String userAlias);
Listing 5-9A Defined Query as an Alternative to a Query Method
如您所见,它看起来像标准的 SQL。以下是不同之处:
-
我们不用表名,而是用类名(
ChallengeAttempt)。 -
我们将字段称为对象字段,而不是列,使用点来遍历对象结构(
a.user.alias)。 -
我们可以使用参数占位符,比如我们示例中的
?1来引用第一个(也是唯一一个)传递的参数。
我们将坚持使用查询方法,因为它更短,也更具描述性,但是我们需要很快为我们的其他需求编写 JPQL 查询。
这就是我们管理数据库中的尝试实体所需的全部内容。现在,我们缺少管理User实体的存储库。这个实现起来很简单,如清单 5-10 所示。
package microservices.book.multiplication.user;
import org.springframework.data.repository.CrudRepository;
import java.util.Optional;
public interface UserRepository extends CrudRepository<User, Long> {
Optional<User> findByAlias(final String alias);
}
Listing 5-10The UserRepository Interface
如果匹配的话,findByAlias查询方法将返回一个包装在 Java Optional中的用户,如果没有用户匹配传递的别名,则返回一个空的Optional对象。这是 Spring Data 的 JPA 查询方法提供的另一个特性。
有了这两个存储库,我们就有了管理数据库实体所需的一切。我们不需要实现这些接口。我们甚至不需要添加 Spring 的@Repository注释。使用数据模块,Spring 将找到扩展基本接口的接口,并将注入实现所需行为的 beans。这还包括处理方法名和创建相应的 JPA 查询。
存储用户和尝试
完成数据层之后,我们可以开始使用服务层的存储库。
首先,让我们用新的预期逻辑来扩展我们的测试用例:
-
该尝试应该被存储,不管它是否正确。
-
如果这是给定用户的第一次尝试,通过他们的别名识别,我们应该创建用户。如果别名存在,该尝试应该链接到该现有用户。
我们必须对我们的ChallengeServiceTest类进行一些更新。首先,我们需要为两个存储库添加两个模拟。这样,我们将单元测试集中在服务层,而不包括来自其他层的任何真实行为。正如第二章所介绍的,这是 Mockito 的优势之一。
为了在 Mockito 中使用 mocks,我们可以用@Mock注释对字段进行注释,并将MockitoExtension添加到测试类中,使它们自动初始化。有了这个扩展,我们还得到了其他的 Mockito 特性,比如检测未使用的存根,如果我们指定了一个在测试用例中没有使用的 mock 行为,就会导致测试失败。见清单 5-11 。
@ExtendWith(MockitoExtension.class)
public class ChallengeServiceTest {
private ChallengeService challengeService;
@Mock
private UserRepository userRepository;
@Mock
private ChallengeAttemptRepository attemptRepository;
@BeforeEach
public void setUp() {
challengeService = new ChallengeServiceImpl(
userRepository,
attemptRepository
);
given(attemptRepository.save(any()))
.will(returnsFirstArg());
}
//...
}
Listing 5-11Using Mockito in the ChallengeServiceTest Class
此外,我们可以使用用 JUnit 的@BeforeEach注释的方法向所有测试添加一些常见行为。在这种情况下,我们使用服务的构造函数来包含存储库(注意,这个构造函数还不存在)。我们也添加了这一行:
given(attemptRepository.save(any()))
.will(returnsFirstArg());
这条指令使用BDDMockito的given方法来定义当我们在测试期间调用特定方法时,模拟类应该做什么。请记住,我们不想使用真正的类的功能,所以我们必须定义,例如,在这个假对象(或存根)上调用函数时返回什么。我们想要覆盖的方法作为参数传递:attemptRepository.save(any())。我们可以匹配传递给save()的特定参数,但是我们也可以通过使用来自 Mockito 的参数匹配器的any()为任何参数定义这个预定义的行为(查看 https://tpd.io/mock-am 以获得匹配器的完整列表)。指令的第二部分使用will(),指定当先前定义的条件匹配时,Mockito 应该做什么。在 Mockito 的AdditionalAnswers类中定义了returnsFirstArg()实用方法,其中包含了一些我们可以使用的方便的预定义答案(参见 http://tpd.io/mockito-answers )。如果需要实现更复杂的场景,您还可以声明自己的函数来提供定制的答案。在我们的例子中,我们希望save方法什么也不做,只返回第一个(也是唯一一个)传递的参数。这对我们来说已经足够好了,不用调用真正的存储库就可以测试这一层。
现在,我们将额外的验证添加到现有的测试用例中。参见清单 5-12 ,其中包括正确尝试的测试用例作为示例。
@Test
public void checkCorrectAttemptTest() {
// given
ChallengeAttemptDTO attemptDTO =
new ChallengeAttemptDTO(50, 60, "john_doe", 3000);
// when
ChallengeAttempt resultAttempt =
challengeService.verifyAttempt(attemptDTO);
// then
then(resultAttempt.isCorrect()).isTrue();
// newly added lines
verify(userRepository).save(new User("john_doe"));
verify(attemptRepository).save(resultAttempt);
}
Listing 5-12Verifying Stub Calls in ChallengeServiceTest
我们使用 Mockito 的verify来检查我们是否存储了一个具有空 ID 和预期别名的新用户。标识符将在数据库级别设置。我们还验证是否应该保存该尝试。验证错误尝试的测试用例也应该包含这两个新行。
为了使我们的测试更加完整,我们添加了一个新的案例来验证来自同一个用户的额外尝试不会创建新的用户实体,而是重用现有的用户实体。参见清单 5-13 。
@Test
public void checkExistingUserTest() {
// given
User existingUser = new User(1L, "john_doe");
given(userRepository.findByAlias("john_doe"))
.willReturn(Optional.of(existingUser));
ChallengeAttemptDTO attemptDTO =
new ChallengeAttemptDTO(50, 60, "john_doe", 5000);
// when
ChallengeAttempt resultAttempt =
challengeService.verifyAttempt(attemptDTO);
// then
then(resultAttempt.isCorrect()).isFalse();
then(resultAttempt.getUser()).isEqualTo(existingUser);
verify(userRepository, never()).save(any());
verify(attemptRepository).save(resultAttempt);
}
Listing 5-13Verifying That Only the First Attempt Creates the User Entity
在这种情况下,我们定义了userRepository mock 的行为来返回一个现有的用户。因为挑战 DTO 包含相同的别名,所以逻辑应该找到我们的预定义用户,并且返回的尝试必须包括它,具有相同的别名和 ID。为了使测试更加详尽,我们检查了UserRepository中的方法save()从未被调用。
此时,我们有一个不编译的测试。我们的服务应该为两个存储库提供一个构造器。当我们启动应用时,Spring 将通过构造函数使用依赖注入来初始化存储库。这就是 Spring 帮助我们保持层松散耦合的方式。
然后,我们还需要主逻辑来存储尝试和用户(如果还不存在的话)。关于ChallengeServiceImpl的新实现,请参见清单 5-14 。
package microservices.book.multiplication.challenge;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import microservices.book.multiplication.user.User;
import microservices.book.multiplication.user.UserRepository;
import org.springframework.stereotype.Service;
@Slf4j
@RequiredArgsConstructor
@Service
public class ChallengeServiceImpl implements ChallengeService {
private final UserRepository userRepository;
private final ChallengeAttemptRepository attemptRepository;
@Override
public ChallengeAttempt verifyAttempt(ChallengeAttemptDTO attemptDTO) {
// Check if the user already exists for that alias, otherwise create it
User user = userRepository.findByAlias(attemptDTO.getUserAlias())
.orElseGet(() -> {
log.info("Creating new user with alias {}",
attemptDTO.getUserAlias());
return userRepository.save(
new User(attemptDTO.getUserAlias())
);
});
// Check if the attempt is correct
boolean isCorrect = attemptDTO.getGuess() ==
attemptDTO.getFactorA() * attemptDTO.getFactorB();
// Builds the domain object. Null id since it'll be generated by the DB.
ChallengeAttempt checkedAttempt = new ChallengeAttempt(null,
user,
attemptDTO.getFactorA(),
attemptDTO.getFactorB(),
attemptDTO.getGuess(),
isCorrect
);
// Stores the attempt
ChallengeAttempt storedAttempt = attemptRepository.save(checkedAttempt);
return storedAttempt;
}
}
Listing 5-14The Updated ChallengeServiceImpl Class
Using the Repository Layer
verifyAttempt中的第一个块使用存储库返回的Optional来决定是否应该创建用户。只有当传递的函数为空时,Optional中的方法orElseGet才会调用它。因此,我们只在新用户还不存在时才创建它。
当我们构造一个尝试时,我们从存储库中传递返回的User对象。当我们调用save()来存储尝试实体时,Hibernate 会负责在数据库中正确地链接它们。我们返回结果,因此它包含数据库中的所有标识符。
现在所有的测试用例都应该通过了。同样,我们使用 TDD 来创建基于我们期望的逻辑。现在很清楚单元测试如何帮助我们验证特定层的行为,而不依赖于其他层。对于我们的服务类,我们用存根替换了两个存储库,我们为存根定义了预设值。
这些测试有另一种实现方式。我们可以对存储库类使用@SpringBootTest风格和@MockBean。然而,这并没有带来任何附加值,并且需要 Spring 上下文,所以测试需要更多的时间来完成。正如我们在前一章中所说的,我们更喜欢让我们的单元测试尽可能简单。
知识库测试
我们没有为应用的数据层创建测试。这些测试没有多大意义,因为我们没有编写任何实现。我们将最终验证 Spring Data 实现本身。
显示上次尝试
我们修改了现有的服务逻辑来存储用户和尝试,但是我们仍然缺少另一半功能:检索最后的尝试并在页面上显示它们。
服务层可以简单地使用存储库中的查询方法。在控制器层,我们将公开一个新的 REST 端点,通过用户别名获取尝试列表。
锻炼
继续遵循 TDD,并在继续实现之前完成一些任务。你会在本章的代码库中找到解决方案( https://github.com/Book-Microservices-v2/chapter05 )。
-
扩展
ChallengeServiceTest并创建一个测试用例来验证我们可以检索最后的尝试。测试背后的逻辑是一行程序,但是最好在服务层增长时进行测试。注意,在这个测试用例中,您可能会从 Mockito 那里得到关于save方法不必要的存根的抱怨。这是MockitoExtension的特色之一。然后,您可以将这个存根移动到使用它的测试用例中。 -
更新
ChallengeAttemptController类以包含对新端点GET /attempts?alias=john_doe的测试。
服务层
让我们给ChallengeService接口添加一个名为getStatsForUser的方法。见清单 5-15 。
package microservices.book.multiplication.challenge;
import java.util.List;
public interface ChallengeService {
/**
* Verifies if an attempt coming from the presentation layer is correct or not.
*
* @return the resulting ChallengeAttempt object
*/
ChallengeAttempt verifyAttempt(ChallengeAttemptDTO attemptDTO);
/**
* Gets the statistics for a given user.
*
* @param userAlias the user's alias
* @return a list of the last 10 {@link ChallengeAttempt}
* objects created by the user.
*/
List<ChallengeAttempt> getStatsForUser(String userAlias);
}
Listing 5-15Adding the getStatsForUser Method to the ChallengeService Interface
清单 5-16 中的代码块展示了这个实现。正如预测的那样,这只是一行代码。
@Slf4j
@RequiredArgsConstructor
@Service
public class ChallengeServiceImpl implements ChallengeService {
// ...
@Override
public List<ChallengeAttempt> getStatsForUser(final String userAlias) {
return attemptRepository.findTop10ByUserAliasOrderByIdDesc(userAlias);
}
}
Listing 5-16Implementing the getStatsForUser Method
控制器层
让我们向上移动一层,看看我们如何从控制器连接服务层。这一次,我们使用了一个查询参数,但是这并没有给我们的 API 定义增加太多的复杂性。类似地,当我们在第一个方法中把请求体作为参数注入时,我们现在可以使用@RequestParam来告诉 Spring 给我们传递一个 URL 参数。查看参考文档( http://tpd.io/mvc-ann )中您可以定义的其他方法参数(例如,会话属性或 cookie 值)。见清单 5-17 。
@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/attempts")
class ChallengeAttemptController {
private final ChallengeService challengeService;
@PostMapping
ResponseEntity<ChallengeAttempt> postResult(
@RequestBody @Valid ChallengeAttemptDTO challengeAttemptDTO) {
return ResponseEntity.ok(challengeService.verifyAttempt(challengeAttemptDTO));
}
@GetMapping
ResponseEntity<List<ChallengeAttempt>> getStatistics(@RequestParam("alias") String alias) {
return ResponseEntity.ok(
challengeService.getStatsForUser(alias)
);
}
}
Listing 5-17Adding New Endpoint in the Controller to Retrieve Statistics
如果您实现了测试,它们现在应该通过了。然而,如果我们使用 HTTPie 运行一个快速测试,我们会发现一个意想不到的结果。参见清单 5-18 。发送一次尝试,然后尝试检索列表会给我们一个错误。
$ http POST :8080/attempts factorA=58 factorB=92 userAlias=moises guess=5303
HTTP/1.1 200
...
$ http ":8080/attempts?alias=moises"
HTTP/1.1 500
...
{
"error": "Internal Server Error",
"message": "Type definition error: [simple type, class org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor]; nested exception is com.fasterxml.jackson.databind.exc.InvalidDefinitionException: No serializer found for class org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor and no properties discovered to create BeanSerializer (to avoid exception, disable SerializationFeature.FAIL_ON_EMPTY_BEANS) (through reference chain: java.util.ArrayList[0]->microservices.book.multiplication.challenge.ChallengeAttempt[\"user\"]->microservices.book.multiplication.user.User$HibernateProxy$mk4Fwavp[\"hibernateLazyInitializer\"])",
"path": "/attempts",
"status": 500,
"timestamp": "2020-04-15T05:41:53.993+0000"
}
Listing 5-18Error During Serialization of the Attempt List
这是一个丑陋的服务器错误。我们还可以在后端日志中找到对应的异常。什么是 a ByteBuddyInterceptor,为什么我们的ObjectMapper要连载它?结果中应该只有ChallengeAttempt对象,带有嵌套的User实例,对吗?不完全是。
我们将嵌套的User实体配置为在惰性模式下获取,因此不会从数据库中查询它们。我们还说过 Hibernate 在运行时为我们的类创建代理。这就是ByteBuddyInterceptor类背后的原因。您可以尝试将获取模式切换到 EAGER,您将不会再收到此错误。但是这不是解决这个问题的正确方法,因为这样我们会触发很多对我们不需要的数据的查询。
让我们保持懒惰获取模式,并相应地解决这个问题。我们的第一个选择是定制我们的 JSON 序列化,以便它可以处理 Hibernate 对象。幸运的是,Jackson 库的提供者 FasterXML 有一个针对 Hibernate 的特定模块,我们可以在我们的ObjectMapper对象中使用它:jackson-datatype-hibernate ( http://tpd.io/json-hib )。要使用它,我们必须将这个依赖项添加到我们的项目中,因为 Spring Boot 初学者不包括它。见清单 5-19 。
<dependencies>
<!-- ... -->
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-hibernate5</artifactId>
</dependency>
<!-- ... -->
</dependencies>
Listing 5-19Adding the Jackson Module for Hibernate to Our Dependencies
然后我们按照 Spring Boot 有据可查的方式(参见 http://tpd.io/om-custom )定制ObjectMapper s:
"任何 com . faster XML . Jackson . databind . module 类型的 beans 都会自动向自动配置的 Jackson2ObjectMapperBuilder 注册,并应用于它创建的任何 ObjectMapper 实例。当您向应用添加新功能时,这提供了一种提供自定义模块的全局机制。
我们为 Jackson 的新 Hibernate 模块创建了一个 bean。Spring Boot 的Jackson2ObjectMapperBuilder将通过自动配置使用它,我们所有的ObjectMapper实例将使用 Spring Boot 的默认设置和我们自己的定制。见清单 5-20 展示这个新的JsonConfiguration级。
package microservices.book.multiplication.configuration;
import com.fasterxml.jackson.databind.Module;
import com.fasterxml.jackson.datatype.hibernate5.Hibernate5Module;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class JsonConfiguration {
@Bean
public Module hibernateModule() {
return new Hibernate5Module();
}
}
Listing 5-20Loading the Jackson’s Hibernate Module to Be Picked Up by Auto-Configuration
现在,我们启动我们的应用,并验证我们可以成功地检索尝试。嵌套的user对象是null,这是完美的,因为我们不需要它作为尝试列表。见清单 5-21 。我们避免了额外的询问。
$ http ":8080/attempts?alias=moises"
HTTP/1.1 200
...
[
{
"correct": false,
"factorA": 58,
"factorB": 92,
"id": 11,
"resultAttempt": 5303,
"user": null
},
...
]
Listing 5-21Correct Serialization of the Attempts After Adding the Hibernate Module
除了添加新的依赖项和新的配置之外,还有一种方法是遵循我们收到的异常消息中的建议:
...(to avoid exception, disable SerializationFeature.FAIL_ON_EMPTY_BEANS)[...]
让我们试试。我们可以在application.properties文件中直接给 Jackson serializers 添加特性(参见 http://tpd.io/om-custom )。这是通过一些命名约定实现的,在 Jackson 属性前面加上spring.jackson.serialization。见清单 5-22 。
[...]
spring.jpa.show-sql=true
spring.jackson.serialization.fail_on_empty_beans=false
Listing 5-22Adding a Property to Avoid Serialization Errors on Empty Beans
如果您尝试这样做(在从以前的解决方案中删除代码之后),然后收集尝试,您会发现一个有趣的结果。参见清单 5-23 。
$ http ":8080/attempts?alias=moises"
HTTP/1.1 200
...
[
{
"correct": false,
"factorA": 58,
"factorB": 92,
"id": 11,
"resultAttempt": 5303,
"user": {
"alias": "moises",
"hibernateLazyInitializer": {},
"id": 1
}
},
...
]
Listing 5-23Retrieving Attempts with fail_on_empty_beans=false
有两个意想不到的结果。首先,代理对象的属性hibernateLazyInitializer被序列化为 JSON,它是空的。这就是空 bean,它实际上是我们之前得到的错误的来源。我们可以通过忽略磁场的杰克逊配置来避免这种情况。但真正的问题是,用户的数据也在那里。序列化程序遍历代理来获取用户数据,这触发了来自 Hibernate 的额外查询来获取数据,这使得我们的惰性参数配置变得无用。我们还可以在日志中验证这一点,与之前的解决方案相比,我们得到了一个额外的查询。参见清单 5-24 。
Hibernate: select challengea0_.id as id1_0_, challengea0_.correct as correct2_0_, challengea0_.factora as factora3_0_, challengea0_.factorb as factorb4_0_, challengea0_.result_attempt as result_a5_0_, challengea0_.user_id as user_id6_0_ from challenge_attempt challengea0_ left outer join user user1_ on challengea0_.user_id=user1_.id where user1_.alias=? order by challengea0_.id desc limit ?
Hibernate: select user0_.id as id1_1_0_, user0_.alias as alias2_1_0_ from user user0_ where user0_.id=?
Listing 5-24Unwanted Query When Fetching Attempts with Suboptimal Configuration
对于 Jackson 的 Hibernate 模块,我们将坚持第一个选项,因为这是用 JSON 序列化处理延迟抓取的正确方法。
我们对这两种选择所做的分析得出的结论是,在 Spring Boot 有如此多的行为隐藏在幕后,你应该避免在没有真正理解其含义的情况下寻求快速解决方案。了解这些工具并阅读参考文档。
用户界面
我们堆栈中需要集成新功能来显示最后尝试的最后部分是在我们的 React 前端。和上一章一样,如果你不想深入了解 UI 的细节,可以跳过这一节。
现在让我们坚持使用一个基本的用户界面,并在页面中添加一个表格来显示用户的最后一次尝试。我们可以在发送新的尝试后发出请求,因为我们将获得用户的别名。
但是,在此之前,让我们替换预定义的 CSS,以确保所有内容都适合页面。
首先,我们直接移动ChallengeComponent进行渲染,没有任何包装。参见清单 5-25 中的结果App.js文件。
import React from 'react';
import './App.css';
import ChallengeComponent from './components/ChallengeComponent';
function App() {
return <ChallengeComponent/>;
}
export default App;
Listing 5-25App.js File After Moving the Component Up
然后,我们删除所有预定义的 CSS,并根据我们的需要进行调整。我们可以将这些基本样式分别添加到index.css和App.css文件中。参见列表 5-26 和 5-27 。
.display-column {
display: flex;
flex-direction: column;
align-items: center;
}
.challenge {
font-size: 4em;
}
th {
padding-right: 0.5em;
border-bottom: solid 1px;
}
Listing 5-27The Modified app.css File
body {
font-family: 'Segoe UI', Roboto, Arial, sans-serif;
}
Listing 5-26The Modified index.css File
我们将把display-column应用到主 HTML 容器中,以垂直堆叠我们的组件,并使它们居中对齐。challenge样式用于乘法,我们还定制了表格标题样式,使其具有一些填充并使用底线。
一旦我们为新表腾出了一些空间,我们就必须在 JavaScript 中扩展我们的ApiClient来检索这些尝试。像以前一样,我们使用带有默认 GET 动词的fetch,并构建 URL 以包含用户的别名作为查询参数。见清单 5-28 。
class ApiClient {
static SERVER_URL = 'http://localhost:8080';
static GET_CHALLENGE = '/challenges/random';
static POST_RESULT = '/attempts';
static GET_ATTEMPTS_BY_ALIAS = '/attempts?alias=';
static challenge(): Promise<Response> {
return fetch(ApiClient.SERVER_URL + ApiClient.GET_CHALLENGE);
}
static sendGuess(user: string,
a: number,
b: number,
guess: number): Promise<Response> {
// ...
}
static getAttempts(userAlias: string): Promise<Response> {
return fetch(ApiClient.SERVER_URL +
ApiClient.GET_ATTEMPTS_BY_ALIAS + userAlias);
}
}
export default ApiClient;
Listing 5-28ApiClient Class Update with the Method to Fetch Attempts
我们的下一个任务是为这个尝试列表创建一个新的React组件。这样,我们可以保持我们的前端模块化。这个新组件不需要有状态,因为我们将使用父组件的状态来保存最后的尝试。
我们使用一个简单的 HTML table来呈现通过props对象传递的对象。作为 UI 级别的一个很好的补充,如果结果不正确,我们将显示正确的挑战结果。此外,我们将有一个有条件的style属性,根据尝试是否正确,将文本颜色设置为绿色或红色。参见清单 5-29 。
import * as React from 'react';
class LastAttemptsComponent extends React.Component {
render() {
return (
<table>
<thead>
<tr>
<th>Challenge</th>
<th>Your guess</th>
<th>Correct</th>
</tr>
</thead>
<tbody>
{this.props.lastAttempts.map(a =>
<tr key={a.id}
style={{ color: a.correct ? 'green' : 'red' }}>
<td>{a.factorA} x {a.factorB}</td>
<td>{a.resultAttempt}</td>
<td>{a.correct ? "Correct" :
("Incorrect (" + a.factorA * a.factorB + ")")}</td>
</tr>
)}
</tbody>
</table>
);
}
}
export default LastAttemptsComponent;
Listing 5-29The New LastAttemptsComponent in React
如代码所示,我们可以在渲染 React 组件时使用map来轻松迭代数组。数组的每个元素都应该使用一个key属性来帮助框架识别变化的元素。参见 http://tpd.io/react-keys 了解更多关于使用唯一键渲染列表的细节。
现在我们需要将所有东西放在现有的ChallengeComponent类中一起工作。添加一些修改后的代码见清单 5-30 。
-
一个新函数,它使用
ApiClient来检索最后的尝试,检查 HTTP 响应是否正常,并将数组存储在状态中。 -
在我们收到对发送新尝试的请求的响应后,立即调用这个新函数。
-
该父组件的
render()函数中组件的 HTML 标签。 -
作为改进,我们还提取了逻辑来刷新对新函数
refreshChallenge的挑战(之前包含在componentDidMount中)。我们将在用户发送尝试后为他们创建一个新的挑战。
import * as React from "react";
import ApiClient from "../services/ApiClient";
import LastAttemptsComponent from './LastAttemptsComponent';
class ChallengeComponent extends React.Component {
constructor(props) {
super(props);
this.state = {
a: '', b: '',
user: '',
message: '',
guess: 0,
lastAttempts: [],
};
this.handleSubmitResult = this.handleSubmitResult.bind(this);
this.handleChange = this.handleChange.bind(this);
}
// ...
handleSubmitResult(event) {
event.preventDefault();
ApiClient.sendGuess(this.state.user,
this.state.a, this.state.b,
this.state.guess)
.then(res => {
if (res.ok) {
res.json().then(json => {
if (json.correct) {
this.updateMessage("Congratulations! Your guess is correct");
} else {
this.updateMessage("Oops! Your guess " + json.resultAttempt +
" is wrong, but keep playing!");
}
this.updateLastAttempts(this.state.user); // NEW!
this.refreshChallenge(); // NEW!
});
} else {
this.updateMessage("Error: server error or not available");
}
});
}
// ...
updateLastAttempts(userAlias: string) {
ApiClient.getAttempts(userAlias).then(res => {
if (res.ok) {
let attempts: Attempt[] = [];
res.json().then(data => {
data.forEach(item => {
attempts.push(item);
});
this.setState({
lastAttempts: attempts
});
})
}
})
}
render() {
return (
<div className="display-column">
<div>
<h3>Your new challenge is</h3>
<div className="challenge">
{this.state.a} x {this.state.b}
</div>
</div>
<form onSubmit={this.handleSubmitResult}>
{/* ... */}
</form>
<h4>{this.state.message}</h4>
{this.state.lastAttempts.length > 0 &&
<LastAttemptsComponent lastAttempts={this.state.lastAttempts}/>
}
</div>
);
}
}
export default ChallengeComponent
;
Listing 5-30The Updated ChallengeComponent
to Include the LastAttemptsComponent
在 React 中,如果我们只将属性传递给setState方法,我们可以更新状态的一部分,然后该方法将合并内容。我们将新属性lastAttempts添加到状态中,并用后端返回的数组内容更新它。假设数组的项是 JSON 对象,我们可以使用属性名在普通的 JavaScript 中访问它的属性。
我们还在这段代码中使用了一些新东西:带有&&操作符的条件呈现。只有左边的条件为真,右边的内容才会被 React 渲染。参见 http://tpd.io/react-inline-if 了解不同的方法。添加到组件标签的lastAttempts HTML 属性通过props对象传递给子组件的代码。
注意,我们还使用了新的样式display-column和challenge。在 React 中,我们使用了className属性,它将被映射到标准的 HTML class。
使用新功能
在将新的数据层和所有其他层中的逻辑添加到 UI 之后,我们就可以开始使用整个应用了。无论是使用 IDE 还是使用两个不同的终端窗口,我们都用清单 5-31 中所示的命令运行后端和前端。
/multiplication $ mvnw spring-boot:run
...
/challenges-frontend $ npm start
...
Listing 5-31Commands to Start the Back End and the Front End, Respectively
然后,我们导航到http://localhost:3000以访问在开发模式下运行的 React 前端。因为我们的新组件的呈现是有条件的,所以我们还没有看到新的表,所以让我们来玩几个挑战,看看表是如何被我们的尝试填充的。你应该会看到类似于图 5-8 的东西。
图 5-8
添加最后一次尝试功能后的应用
太好了,我们成功了!它不是最漂亮的前端,但很高兴看到我们的新功能启动并运行。Challenge组件向后端执行请求,并呈现子组件,即最后一次尝试表。
如果您对数据的外观感兴趣,我们还可以导航到后端的 H2 控制台来访问表中的数据。记住控制台位于http://localhost:8080/h2-console。您应该看到用户和尝试的两个表以及其中的一些内容。这个基本控制台允许您执行查询,甚至编辑数据。例如,您可以单击表的名称CHALLENGE_ATTEMPT,在右边的面板中将会为您生成一个 SQL 查询。然后,你可以点击运行按钮来查询数据。见图 5-9 。
图 5-9
尝试 H2 控制台中的数据
总结和成就
在这一章中,我们看到了如何为数据的持久性建模,并且我们使用了对象关系映射来将我们的域对象转换成数据库记录。在旅程中,我们讲述了一些 Hibernate 和 JPA 的基础知识。请参见图 5-10 了解我们应用的当前状态。
图 5-10
第五章后的应用
基于挑战和尝试之间的多对一用例,我们学习了如何使用 JPA 注释来映射我们的 Java 类,以及简单关联是如何工作的。此外,我们使用 Spring Data 存储库获得了许多现成的功能。在所有提供的特性中,我们看到了查询方法是如何用一些方法的命名约定来编写简单查询的强大方法。
第二个用户故事完成了。我们浏览了服务和控制器层,并在那里添加了我们的新功能。我们还在 UI 中加入了一个新的组件来可视化我们的 web 应用的最后尝试。
我们完成了这本书的第一部分。到目前为止,我们已经详细了解了小型 web 应用是如何工作的。我们花时间去理解 Spring Boot 的核心概念和不同层的一些特定模块,比如 Spring Data 和 Spring Web。我们甚至构建了一个小型的 React 前端。
现在是时候开始微服务冒险了。
章节成就:
-
通过引入存储库类和数据库,您对三层、三层架构的工作原理有了全面的了解。
-
您了解了如何对数据建模,考虑了查询数据和适当的域隔离的需求。
-
您了解了 SQL 和 NoSQL 之间的主要区别,以及您可以用来做出未来选择的标准。
-
您了解了 JPA 及其与 Hibernate、Spring Data 和 Spring Boot 的集成。
-
您使用 JPA 注释和 Spring Data 存储库,使用查询方法和定义的查询为应用开发了一个真正的持久层。
-
您将新的尝试历史功能集成到前端,改进了我们的实际案例研究。
六、从微服务开始
这一章从我们的实践旅程的暂停开始。重要的是,我们要花时间分析我们到目前为止是如何构建我们的应用的,以及我们未来转向微服务的影响。
首先,我们将了解从单一代码库开始的优势。然后,我们将描述我们的新需求,包括基本游戏化技术的简短总结。之后,我们将了解在转向微服务时需要考虑哪些因素,以及在做出决定时应该考虑的利弊。
这一章的第二部分又都是实用的了。我们将使用我们在前面章节中学到的设计模式构建新的微服务,并将它连接到我们现有的应用,即第一个微服务。然后,我们将分析由于我们新的分布式系统我们将面临的一些新挑战。
小型整体式方法
上一章以一个包含所有必需功能的单一部署单元结束(如果我们愿意的话,甚至包括前端)。尽管已经确定了两个领域:挑战和用户,我们还是选择了这种单一应用策略。它们的域对象有关系,但是它们是松散耦合的。然而,我们从一开始就决定不将它们分成多个应用或微服务。
我们可以认为我们的乘法应用是一个小的整体。
为什么是小石块?
与微服务相比,从单个代码库开始简化了开发过程,因此我们减少了部署产品第一个版本所需的时间。此外,它使得在项目生命周期的开始阶段改变我们的架构和软件设计变得更加容易,这是在我们验证了我们的想法之后进行调整的关键。
如果你还没有在使用微服务的组织中工作过,你可能会低估微服务在软件项目中引入的技术复杂性。理想的情况是,当你读完这本书时,你会有一个更清晰的想法。接下来的章节重点关注当你转向微服务时应该采用的不同模式和技术要求,正如你将看到的,它们并不容易实现。
微服务从一开始就存在问题
作为我们采用的方法的替代方案,我们可以从一开始就选择微服务架构,并将用户和挑战分成两个独立的 Spring Boot 应用。
直接从拆分开始的一个原因是,我们的组织中可能有多个团队,他们可以并行工作而不会互相干扰。我们可以从一开始就利用将微服务映射到团队的优势。在我们的例子中,只有两个域,但是想象我们已经识别了十个不同的有界上下文。理论上,我们可以利用大组织的力量,这样我们可以更早地完成项目。
我们还可以通过拆分为微服务来实现我们的目标架构。这个计划通常被称为反康威。康威定律(参见 https://tpd.io/conway )指出,系统设计倾向于类似于建造它的组织的结构。因此,我们尝试使用这种预测,并使我们的组织与我们想要实现的软件架构相似,这是有意义的。我们起草完整的软件架构,确定领域,并在团队之间进行划分。
能够并行工作并实现目标架构似乎是很大的优势。然而,过早拆分为微服务背后有两个问题。首先,当我们以敏捷的方式开发软件时,我们通常不会花几周的时间预先设计完整的系统。当试图识别松散耦合的域时,我们会犯错误。当我们意识到有缺陷时,就太晚了。基于错误的领域划分,很难对抗多个团队同时工作的惰性,特别是如果组织没有足够的灵活性来应对这些变化。在这种情况下,反向康威和早期分裂将对我们的目标不利。我们将创建一个反映我们最初架构的软件系统,但这可能不再是我们想要的。查看 https://tpd.io/reverse-conway 在那里我给出了更多关于这个话题的见解。
直接从微服务开始的第二个大问题是,这通常意味着我们不会将系统分成垂直的部分。我们从上一章开始,描述了为什么尽早交付软件是个好主意,这样我们就可以得到反馈。然后,我们解释了如何构建应用层的一小部分来交付价值,而不是一个接一个地设计和实现完整的层。如果我们从零开始使用多个微服务,我们将走向横向。微服务架构总是引入技术复杂性。它们更难设置、部署、编排和测试。这只会让我们花费更多的时间来开发一个最小可行的产品,这也可能会产生技术上的影响。在最坏的情况下,我们可能会基于错误的假设进行软件设计,因此在我们得到用户的反馈后,它们就会过时。
小独石是为小团队准备的
如果我们能在开始时保持团队的小规模,一个小的整体是一个好的计划。我们可以专注于领域定义和实验。当我们的产品想法得到验证,有了更清晰的软件架构,更多的人可以逐渐加入团队,我们可以考虑拆分代码库,整合新的团队。然后,根据我们的需求,我们可以转向微服务或选择另一种方法,如模块化系统(我们将在本章末尾详细介绍这两种方法)。
然而,有时候我们无法避免和一个大团队一起开始一个项目。在我们的组织里这是理所当然的。我们无法让合适的人相信这不是一个好主意。如果是这样的话,这个小的整体可能会很快变成一个大的整体,有一个意大利面条式的代码库,以后可能很难模块化。此外,很难一次只关注一个垂直领域,因为那样会有很多人无所事事。在这种组织约束下,小团队想法的小整体并不能很好地工作。我们需要做一些拆分。在这种情况下,我们必须付出额外的努力,不仅要定义有界的上下文,还要定义这些未来模块之间的通信接口。每当我们设计跨多个模块或微服务的功能时,我们都确保让相应的团队参与进来,以定义这些模块将产生和消费什么样的输入/输出。我们对这些合同定义得越好,团队就越独立。在敏捷环境中,这意味着特性的交付可能会比开始时预期的要慢,因为团队不仅需要定义这些合同,还需要定义许多公共的技术基础。
拥抱重构
另一个小的整体看起来有问题的情况是当我们的组织不接受代码变更的时候。如前所述,我们从一个小的整体开始,验证产品想法并获得反馈。然后,我们会在某个时间点看到开始将整体分割成微服务的需要。这种拆分带来了组织和技术上的优势,我们将在后面详述。技术人员和项目经理都应该在项目开始时进行对话,根据功能和技术需求来决定何时进行这种拆分。
然而,有时我们作为开发者认为这个时刻永远不会到来:如果我们从一个庞然大物开始,我们将永远被它束缚。我们担心在项目的路线图中永远不会有停顿来计划和完成所需的微服务重构。考虑到这一点,技术人员可能会尝试从一开始就推动组织和强制微服务。这是一个坏主意,因为它通常会让那些认为这样的技术复杂性会不必要地延迟项目的人感到沮丧。与其将销售微服务架构作为唯一的选择,不如改善与业务利益相关者和项目经理的沟通,以形成一个好的计划。
规划未来分裂的小块巨石
当你选择一个小块的时候,你可以遵循一些好的做法,然后不费吹灰之力就可以把它分割开来。
-
将代码划分到定义域上下文的根包中:这就是我们在应用中对挑战和用户根包所做的。然后,如果您开始处理许多类,您可以为分层创建子包(例如,控制器、存储库、域和服务),以确保层隔离。确保遵循类可见性的良好实践(例如,接口是公共的,但是它们的实现是包私有的)。使用这种结构的主要优点是,您可以保持跨域上下文的业务逻辑不可访问,并且如果您需要的话,以后您应该能够提取一个完整的根包作为微服务,只需较少的重构。
-
利用依赖注入的优势:让你的代码基于接口,让 Spring 完成注入实现的工作。使用这种模式进行重构要容易得多。例如,您可以更改一个实现,以便稍后调用不同的微服务,而不是使用本地类,而不会影响其余的逻辑。
-
一旦你确定了上下文(例如,挑战和用户),在你的应用中给它们起一个一致的名字:正确地命名概念在设计阶段的开始是至关重要的,以确保每个人都理解不同的领域边界。
-
在设计阶段,不要害怕移动类(对于小块来说更容易),直到边界变得清晰:之后,尊重边界。不要因为可以就走捷径,将业务逻辑与上下文混为一谈。永远记住,整块石头应该准备好进化。
-
找到共同的模式,并确定哪些可以在以后提取为共同的库,例如:将它们移动到不同的根包中。
-
使用同行评审来确保架构设计是合理的,并促进知识转移:最好在一个小组中完成,而不是遵循自上而下的方法,即所有设计都来自一个人。
-
清楚地向项目经理和/或业务代表传达 以计划稍后分割整块石头的时间:解释战略并创造文化。重构是必要的,这没有错。
至少在你第一次发布之前,尽量保持一个小的整体。不要害怕它;一个小的独石会给你带来一些好处。
-
在早期阶段更快的开发可以更好地获得产品的快速反馈。
-
您可以轻松地更改域边界。
-
人们习惯了相同的技术指南。这有助于实现未来的一致性。
-
常见的跨域功能可以作为库(或指南)来识别和共享。
-
团队将获得系统的完整视图,而不仅仅是部分视图。然后,这些人可以转移到其他团队,并带来有用的知识。
新需求和游戏化
想象一下,我们发布我们的应用,并将其连接到一个分析引擎。我们每天都有新用户,也有定期回来的老用户,这要感谢我们最近展示尝试历史的功能。然而,我们在我们的指标中看到,一周后,用户倾向于放弃用新的挑战训练他们大脑的惯例。
因此,我们根据我们的数据做出决定,试图改善这些数字。这个简单的过程被称为数据驱动决策 (DDDM),它对所有类型的项目都很重要。我们使用数据来选择我们的下一步行动,而不是基于直觉或仅仅是观察。如果你对 DDDM 感兴趣,网上有很多文章和课程。在 https://tpd.io/dddm 的文章是一个很好的开始。
在我们的例子中,我们计划引入一些游戏化来提高应用的参与度。请记住,我们将游戏化减少到点数、徽章和排行榜,以使本书专注于技术主题。如果你对这个领域感兴趣,那么《?? 现实被打破》和《?? 为了胜利》是很好的起点。在介绍游戏化以及它如何应用到我们的应用之前,让我们先介绍一下我们的新用户故事。
用户故事 3
作为应用的用户,我希望每天都有动力不断解决挑战,不要过一段时间就放弃。这样我就不断锻炼脑子,日积月累提高。
游戏化:点数、徽章和排行榜
游戏化是将游戏中使用的技术应用到另一个非游戏领域的设计过程。你这样做是因为你想从游戏中获得一些众所周知的好处,比如激发玩家的积极性,与你的进程、应用或者你正在游戏化的东西互动。
用其他东西制作游戏的一个基本想法是引入点:每次你完成一个动作,并且做得很好,你就会得到一些分数。如果你表现得不好,你甚至可以得到分数,但这应该是一个公平的机制:如果你做得更好,你会得到更多。赢得分数会让玩家觉得他们在进步,并给他们反馈。
排行榜让每个人都能看到分数,因此他们通过激发竞争的感觉来激励玩家。我们希望得到比我们上面的人更多的分数,排名更高。如果你和朋友一起玩,这就更有趣了。
最后但同样重要的是,徽章是获得地位的虚拟象征。我们喜欢徽章,因为它们不仅仅代表积分。此外,它们可以代表不同的事情:你可以与另一个玩家拥有相同的分数(例如,五个正确答案),但你可以用不同的方式赢得它们(例如,一分钟五个!).
一些不是游戏的软件应用很好地运用了这些元素。以 StackOverflow 为例。它充满了游戏元素,鼓励人们不断参与。
我们要做的是给用户提交的每个正确答案打分。为了简单起见,只有当他们发出一个正确的尝试,我们才会给分。我们每次给它 10 分。
页面上会显示得分最高的排行榜,这样玩家可以在排名中找到自己,并与其他人竞争。
我们还将创建一些基本徽章:铜牌(10 次正确尝试)、银牌(25 次正确尝试)和金牌(50 次正确尝试)。因为第一次正确的尝试值得一个好的反馈信息,我们还将引入第一次正确!徽章。此外,为了引入一个惊喜元素,我们将有一个徽章,只有当用户解决了一个数字 42 是其中一个因素的乘法时,他们才能获胜。
有了这些基础,我们相信我们会激励我们的用户回来继续玩,与他们的同龄人竞争。
转向微服务
在我们的新要求中,没有什么是我们用我们的小单体不能实现的。实际上,如果这是一个只有一个开发人员的项目,并且目标不是教育性的,那么最好的选择就是创建一个名为gamification的新根包,并开始在同一个可部署单元中编写我们的类。
让我们把自己放在一个不同的场景中。想象一下,我们发现了可以帮助我们实现业务目标的其他新特性。其中一些改进可能如下:
-
根据用户的统计数据调整挑战的复杂性。
-
添加提示。
-
允许用户登录,而不是使用别名。
-
要求一些用户的个人信息,以收集更好的指标。
这些改进将影响现有的挑战和用户领域。此外,由于我们的第一次发布非常顺利,让我们想象一下我们也得到了一些资本投资。我们的团队可以成长。我们应用的开发不再需要按顺序进行。我们可以在游戏化领域工作,同时我们也在用额外的功能改进现有的领域。
我们还可以说,投资者带来了一些条件,现在我们希望扩大到每月 100,000 活跃用户。我们需要设计我们的架构来应对这种情况。我们可能很快就会意识到,我们计划构建的新游戏化组件不如主要功能(解决挑战)重要。如果游戏化功能在短时间内不可用,只要用户仍然可以解决挑战,我们就没事。
从我们的分析中,我们可以得出以下结论:
-
用户和挑战领域在我们的应用中至关重要。我们应该致力于保持它们的高可用性。水平可伸缩性非常适合这种情况:我们部署第一个应用的多个实例,如果其中一个实例出现故障,我们使用负载平衡并转移流量。此外,复制服务还会给我们更多的能力来处理许多并发用户。
-
新的游戏化领域在可用性方面有不同的要求。我们不需要以同样的速度扩大这一逻辑。我们可以接受它比其他域执行得慢,也可以允许它停止工作一段时间。
-
由于我们的团队在成长,我们可以从拥有可独立部署的单元中获益。如果我们保持游戏化模块松散耦合并独立发布,我们可以在我们的组织中与多个团队一起工作,并将干扰降至最低。
考虑到那些非功能性需求(例如,可伸缩性、可用性和可扩展性),转移到微服务似乎是个好主意。让我们更详细地介绍一下这些优势。
独立的工作流程
我们在前面的章节中已经看到了如何遵循 DDD 原则完成模块化架构。我们可以将得到的有界上下文分割成不同的代码库,这样多个团队可以更加独立地工作。
然而,如果这些模块是同一个可部署单元的一部分,我们在团队之间仍然有一些依赖。我们需要将所有这些模块集成在一起,确保它们能够相互协作,并将整个系统部署到我们的生产环境中。如果其他基础设施元素跨模块共享,如数据库,这些依赖性会变得更大。
微服务将模块化带到了一个新的高度,因为我们可以独立部署它们。团队不仅可以有不同的存储库,还可以有不同的工作流。
在我们的系统中,我们可以在不同的存储库中开发一些 Spring Boot 应用。它们都有自己的嵌入式 web 服务器,所以我们可以分别部署它们。这消除了在一个大的整体发布过程中产生的所有摩擦:测试、打包、相互依赖的数据库更新等等。
如果我们还考虑维护和支持方面,微服务有助于构建 DevOps 文化,因为每个应用可能拥有其相应的基础架构元素:web 服务器、数据库、指标、日志等。当我们使用像 Spring Boot 这样的框架时,系统可以被看作是一组相互交互的微型应用。如果其中一个部分出现问题,拥有该微服务的团队可以修复它。对于一块巨石,通常很难画出这些线。
水平可扩展性
当我们想要纵向扩展一个单一的应用时,我们可以选择使用更大的服务器/容器纵向扩展*,或者使用更多的实例和负载平衡器横向扩展*。水平可伸缩性通常是首选,因为多台小型机器比一台强大的机器便宜。此外,通过打开和关闭实例,我们可以更好地对不同的工作负载模式做出反应。**
**借助微服务,您可以选择更加灵活的可扩展性策略。在我们的实际例子中,我们认为乘法应用是我们系统的关键部分,必须处理大量的并发请求。因此,我们可以决定部署两个乘法微服务实例,但只部署一个游戏化微服务实例(尚未开发)。如果我们将所有的逻辑放在一个地方,我们也将复制游戏化逻辑,即使我们可能不需要那些资源。图 6-1 显示了一个这样的例子。
图 6-1
整体服务与微服务的水平可扩展性
细粒度的非功能需求
我们可以将水平可伸缩性的优势推广到其他非功能性需求。例如,我们说过,如果新的游戏化微服务在短时间内不可用,也没那么糟糕。如果我们正在运行一个单一的应用,整个系统可能会由于游戏化模块上的意外情况而崩溃。借助微服务,我们可以选择允许系统中的完整部分停机一段时间。我们将在本书的后面看到如何实现弹性模式来构建一个容错能力更强的后端。
例如,这同样适用于安全。我们可能需要对管理用户个人数据的微服务进行更多限制,但我们不需要处理游戏化领域的安全开销。作为独立的应用,微服务带来了更多的灵活性。
其他优势
我们选择微服务架构还有其他一些原因。然而,我把它们分开分组,因为我认为你不应该把它们当作做出决定的因素。
-
多种技术:例如,我们可能想用 Java 构建一些微服务,用 Golang 构建一些微服务。然而,这是有代价的,因为使用公共工件或框架或者跨团队共享知识并不容易。我们可能还想使用不同的数据库引擎,但这在模块化整体中也是可能的。
-
与组织结构的一致性:正如我们在本章前面所描述的,您可能会尝试使用康威法则,尝试按照您的组织结构来设计微服务,反之亦然。但是我们已经讨论了它的利弊,这也取决于您是直接在项目生命周期的开始还是后期进行拆分。
-
替换系统部件的能力:如果微服务给你的软件架构带来更多的隔离,逻辑上认为应该更容易替换它们,而不会对其他服务造成太大影响。但是,在现实生活中,当一些基本规则没有得到遵守时,微服务也可能变得相互强烈耦合。另一方面,你可以用一个好的模块化系统实现可替换性。因此,我也不认为这是变革的决定性驱动力。
不足之处
正如我们在前面章节中已经提到的,微服务架构也有很多缺点,所以它们不是解决整体架构可能出现的所有问题的灵丹妙药。我们讨论了这些缺点,同时分析了为什么从一个小的整体开始是个好主意。
-
您需要更多时间来交付第一个工作版本:由于微服务架构的复杂性,与单一服务相比,它需要更多时间来正确设置。
-
跨域移动功能变得更加困难:一旦您进行了第一次拆分,与单个代码库或部署单元相比,需要额外的工作来合并代码或跨微服务移动功能。
-
有一个新范式的隐含介绍:假设微服务架构使你的系统是分布式的,你将面临新的挑战,比如异步处理、分布式事务和最终的一致性。我们将在本章和下一章详细分析这些新的范例。
-
需要学习新的模式:当你有一个分布式系统时,你最好知道如何实现路由、服务发现、分布式跟踪和日志记录等。这些模式不容易实现和维护。我们将在第八章中讲述它们。
-
你可能需要采用新的工具:有一些框架和工具可以帮助你实现一个微服务架构:Spring Cloud、Docker、Message Brokers、Kubernetes 等。在单一架构中,您可能不需要它们,这意味着额外的维护、设置、潜在成本和学习所有这些新概念的时间。同样,本书将在接下来的章节中帮助你理解这些工具。
-
运行您的系统需要更多资源*:在项目开始时,当系统流量还不高时,维护一个基于微服务的系统可能比一个整体系统要昂贵得多。拥有多个空闲服务比拥有一个空闲服务效率更低。此外,周围的工具和模式(我们将在本书中讨论)引入了额外的过载。只有当您从可伸缩性、容错和其他我们将在本书后面描述的特性中受益时,这种影响才开始变得积极。*
** 可能会偏离标准 和通用实践:转移到微服务的原因之一可能是跨团队实现更多的独立性。然而,如果每个人都开始创建自己的解决方案来解决相同的问题,而不是重用公共模式,这也可能会产生负面影响。这可能会浪费时间,并使人们更难理解系统的其他部分。
* 架构要复杂得多*:用微服务架构解释你的系统如何工作可能会变得更加困难。这意味着新加入者需要额外的时间来理解整个系统是如何工作的。有人可能会说,只要人们了解他们工作的领域,就不需要这样做,但是了解所有部分如何相互作用总是更好的。*
** *你可能会被你不需要的新技术分散注意力*:一旦你登上一列周围都是花哨工具的微服务列车,一些人可能会被实施起来*酷*的新产品和模式所吸引。然而,你可能不需要它们,所以它们只是分散注意力。尽管这种情况在任何类型的架构中都可能发生,但在使用微服务时会发生得更频繁。**
**其中的一些要点你可能还不清楚。不要担心,在我们的旅程结束时,你将能够确切地理解它们的意思。本书对这些主题采取了务实和现实的方法,帮助您理解微服务架构的优势和劣势,以便您可以在未来做出最佳决策。
架构概述
在比较了我们现有的备选方案并分析了微服务的利弊之后,我们决定采取行动,为我们的游戏化需求创建一个新的 Spring Boot 应用。给定我们假设的场景,系统和组织的可伸缩性在这个决策中扮演着重要的角色。
现在我们可以把这两个应用称为乘法微服务和游戏化微服务。直到现在,称我们的第一个应用为微服务才有意义,因为它还不是微服务架构的一部分。
图 6-2 表示我们系统中的不同组件,以及在本章结束时它们将如何连接。
图 6-2
逻辑视图
让我们回顾一下这个设计中新增的内容。
-
会有新的微服务,游戏化。为了防止本地端口冲突,我们将它部署在端口 8081 上。
-
乘法微服务会将每次尝试发送到游戏化微服务,以处理新的分数、徽章和更新排行榜。
-
我们的 React UI 中将会有一个新组件,用于显示带有分数和徽章的排行榜。如图所示,我们的 UI 将调用这两个微服务。
关于此设计的一些注意事项:
-
我们还可以从一个嵌入式 web 服务器部署 UI。然而,最好将 UI 服务器视为不同的部署单元。同样的优势也适用于此:独立的工作流、灵活的可伸缩性等。
-
UI 需要调用两个服务,这看起来可能有点奇怪。你可以考虑将一个反向代理放在另外两个代理的前面,来完成路由并保持 API 客户端不知道后端的软件架构(参见
https://en.wikipedia.org/wiki/Reverse_proxy)。这实际上是网关模式,我们将在本书后面详细讨论它。现在让我们保持简单。 -
如果你关注了这本书的总结,从乘法到游戏化的同步调用肯定会引起你的注意。这确实不是最好的设计,但是让我们保留演进方法的例子,并且首先了解为什么它不是最好的想法。
设计和实现新服务
在这一节中,我们将设计和实现游戏化微服务,采用与我们第一个 Spring Boot 服务 Multiplication 相似的方法。
接口
通常在使用模块化系统时,我们必须注意模块之间的契约。对于微服务,这一点更加重要,因为作为一个团队,我们希望尽快明确所有预期的依赖关系。
在我们的例子中,游戏化微服务需要公开一个接口来接受新的尝试。它需要这些数据来计算用户的统计数据。目前,这个接口将是一个 REST API。交换的 JSON 对象可以简单地包含与我们存储在乘法微服务上的尝试相同的字段:尝试的数据和用户的数据。在游戏化方面,我们将只使用我们需要的数据。
另一方面,UI 需要收集排行榜细节。我们还将在游戏化微服务中创建新的 REST 端点来访问这些数据。
信息
从这里开始,本章的小节将介绍新游戏化微服务的源代码。很高兴看一看它,因为我们将在不同的层中使用一些新的小功能。然而,我们在前面的章节中已经看到了主要的概念,所以你可以决定走捷径。那也有可能。如果你不想深入游戏化微服务的开发,你可以直接跳到“使用我们的服务”一节,使用本章的代码,可在 https://github.com/Book-Microservices-v2/chapter06 获得。
游戏化的 Spring Boot 框架
我们可以在 https://start.spring.io/ 再次使用 Spring Initializr 为我们的新应用创建基本框架。这一次,我们预先知道我们将需要一些额外的依赖项,所以我们可以从这里直接添加它们:Lombok、Spring Web、Validation、Spring Data JPA 和 H2 数据库。如图 6-3 所示填写详细信息。
图 6-3
创建游戏化应用
下载 zip 文件,并将其解压缩为现有multiplication文件夹旁边的gamification文件夹。您可以将这个新项目作为一个单独的模块添加到同一个工作区中,以便将所有内容组织在同一个 IDE 实例中。
领域
让我们对我们的游戏化领域建模,尝试尊重上下文边界,并最小化与现有功能的耦合。
-
我们创建了一个记分卡对象,它保存用户在给定挑战尝试中获得的分数。
-
类似地,我们有一个徽章卡对象,代表用户在给定时间赢得的特定类型的徽章。它不需要绑定到记分卡,因为当你超过给定的分数阈值时,你可能会赢得徽章。
-
为了模拟排行榜,我们创建了一个排行榜位置。我们将显示这些域对象的有序列表,向用户显示排名。
在这个模型中,我们现有的领域对象和新的领域对象之间存在一些关系,如图 6-4 所示。
图 6-4
新的游戏化领域
如您所见,我们仍然保持这些域的松散耦合:
-
用户域保持完全隔离。它不保留对任何其他对象的任何引用。
-
挑战领域只需要了解用户。我们不需要将他们的对象与游戏化概念联系起来。
-
游戏化领域需要参考用户,挑战尝试。我们计划在发送一个尝试后获取这些数据,所以我们将在本地存储一些引用(用户和尝试的标识符)。
域对象可以很容易地映射到 Java 类。我们还将为这个服务使用 JPA/Hibernate,这样我们就可以添加 JPA 注释了。首先,清单 6-1 展示了ScoreCard类,它有一个额外的构造函数来设置一些默认值。
源代码
您可以在 GitHub 的chapter06资源库中找到本章的所有源代码。
https://github.com/Book-Microservices-v2/chapter06见。
package microservices.book.gamification.game.domain;
import lombok.*;
import javax.persistence.*;
/**
* This class represents the Score linked to an attempt in the game,
* with an associated user and the timestamp in which the score
* is registered.
*/
@Entity
@Data
@AllArgsConstructor
@NoArgsConstructor
public class ScoreCard {
// The default score assigned to this card, if not specified.
public static final int DEFAULT_SCORE = 10;
@Id
@GeneratedValue
private Long cardId;
private Long userId;
private Long attemptId;
@EqualsAndHashCode.Exclude
private long scoreTimestamp;
private int score;
public ScoreCard(final Long userId, final Long attemptId) {
this(null, userId, attemptId, System.currentTimeMillis(), DEFAULT_SCORE);
}
}
Listing 6-1The ScoreCard Domain/Data Class
我们这次用了一个新的 Lombok 注释,@EqualsAndHashCode.Exclude。顾名思义,这将使 Lombok 不在生成的equals和hashCode方法中包含该字段。原因是,当我们比较对象时,这将使我们的测试更容易,事实上,我们不需要时间戳来确定两张卡是否相等。
不同的徽章在一个枚举中定义,BadgeType。我们将添加一个description字段,为每个字段取一个友好的名称。见清单 6-2 。
package microservices.book.gamification.game.domain;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* Enumeration with the different types of Badges that a user can win.
*/
@RequiredArgsConstructor
@Getter
public enum BadgeType {
// Badges depending on score
BRONZE("Bronze"),
SILVER("Silver"),
GOLD("Gold"),
// Other badges won for different conditions
FIRST_WON("First time"),
LUCKY_NUMBER("Lucky number");
private final String description;
}
Listing 6-2The BadgeType Enum
正如您在前面的代码中看到的,我们也从 enums 中的一些 Lombok 注释中受益。在这种情况下,我们使用它们为description字段生成一个构造函数和 getter。
BadgeCard类使用BadgeType,它也是一个 JPA 实体。见清单 6-3 。
package microservices.book.gamification.game.domain;
import lombok.*;
import javax.persistence.*;
@Entity
@Data
@AllArgsConstructor
@NoArgsConstructor
public class BadgeCard {
@Id
@GeneratedValue
private Long badgeId;
private Long userId;
@EqualsAndHashCode.Exclude
private long badgeTimestamp;
private BadgeType badgeType;
public BadgeCard(final Long userId, final BadgeType badgeType) {
this(null, userId, System.currentTimeMillis(), badgeType);
}
}
Listing 6-3The BadgeCard Domain/Data Class
我们还添加了一个构造函数来设置一些默认值。注意,我们不需要向枚举类型添加任何特定的 JPA 注释。默认情况下,Hibernate 会将这些值映射到枚举的序数值(一个整数)。如果我们记住应该只在末尾追加新的枚举值,这就很好了,但是我们也可以配置映射器来使用字符串值。
为了模拟排行榜位置,我们创建了类LeaderBoardRow。参见清单 6-4 。我们不需要在我们的数据库中保存这个对象,因为它将通过聚合来自我们用户的分数和徽章来动态创建。
package microservices.book.gamification.game.domain;
import lombok.*;
import java.util.List;
@Value
@AllArgsConstructor
public class LeaderBoardRow {
Long userId;
Long totalScore;
@With
List<String> badges;
public LeaderBoardRow(final Long userId, final Long totalScore) {
this.userId = userId;
this.totalScore = totalScore;
this.badges = List.of();
}
}
Listing 6-4The LeaderBoardRow Class
添加到badges字段的@With注释是由 Lombok 提供的,它为我们生成一个方法来克隆一个对象并向副本添加一个新的字段值(在本例中是withBadges)。当我们使用不可变的类时,这是一个很好的实践,因为它们没有 setters。当我们创建业务逻辑来合并每个排行榜行的分数和徽章时,我们将使用这种方法。
服务
我们将把这个新的游戏化微服务中的业务逻辑一分为二。
-
游戏逻辑,负责处理尝试并生成结果分数和徽章
-
排行榜逻辑,汇总数据并根据分数建立排名
游戏逻辑将驻留在类GameServiceImpl中,它实现了GameService接口。规范很简单:基于一次尝试,它计算分数和徽章并存储它们。乘法微服务可以通过一个名为GameController的控制器访问这个业务逻辑,这个控制器将公开一个 POST 端点来发送尝试。在持久层,我们的业务逻辑将需要一个ScoreRepository来保存记分卡和一个BadgeRepository来对工卡做同样的事情。图 6-5 显示了构建游戏逻辑功能所需的所有类的 UML 图。
图 6-5
UML:游戏逻辑
我们可以定义如清单 6-5 所示的GameService接口。
package microservices.book.gamification.game;
import java.util.List;
import lombok.Value;
import microservices.book.gamification.challenge.ChallengeSolvedDTO;
import microservices.book.gamification.game.domain.BadgeCard;
import microservices.book.gamification.game.domain.BadgeType;
import microservices.book.gamification.game.domain.ScoreCard;
public interface GameService {
/**
* Process a new attempt from a given user.
*
* @param challenge the challenge
data with user details, factors, etc.
* @return a {@link GameResult} object containing the new score and badge cards obtained
*/
GameResult newAttemptForUser(ChallengeSolvedDTO challenge);
@Value
class GameResult {
int score;
List<BadgeType> badges;
}
}
Listing 6-5The GameService Interface
处理尝试后的输出是一个在接口中定义的GameResult对象。它将从该尝试中获得的分数与用户可能获得的任何新徽章组合在一起。我们也可以考虑不返回任何内容,因为这将是显示结果的排行榜逻辑。然而,最好能从我们的方法得到一个响应,这样我们就可以测试它。
ChallengeSolvedDTO类定义了倍增和游戏化微服务之间的契约,我们将在两个项目中创建它以保持它们的独立性。现在,让我们关注游戏化代码库。参见清单 6-6 。
package microservices.book.gamification.challenge;
import lombok.Value;
@Value
public class ChallengeSolvedDTO {
long attemptId;
boolean correct;
int factorA;
int factorB;
long userId;
String userAlias;
}
Listing 6-6The ChallengeSolvedDTO Class
既然我们已经定义了域类和服务层的框架,我们就可以使用 TDD 并为我们的业务逻辑创建一些测试用例,使用一个空的接口实现和 DTO 类。
锻炼
用两个测试用例创建GameServiceTest:一个正确的尝试和一个错误的尝试。您将在本章的代码源中找到解决方案。
现在,只关注分数的计算,而不是徽章。我们将为该部分创建一个单独的接口和测试。
清单 6-7 中显示了一个有效的GameService接口实现。仅当挑战被正确解决时,它才创建一个ScoreCard对象,并存储它。徽章以单独的方式处理,以提高可读性。我们还需要一些存储库方法来保存分数和徽章,并检索以前创建的记录。现在,我们可以假设这些方法有效;我们将在“数据”部分详细解释它们。
package microservices.book.gamification.game;
import java.util.*;
import java.util.stream.Collectors;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import microservices.book.gamification.challenge.ChallengeSolvedDTO;
import microservices.book.gamification.game.badgeprocessors.BadgeProcessor;
import microservices.book.gamification.game.domain.BadgeCard;
import microservices.book.gamification.game.domain.BadgeType;
import microservices.book.gamification.game.domain.ScoreCard;
@Service
@Slf4j
@RequiredArgsConstructor
class GameServiceImpl implements GameService {
private final ScoreRepository scoreRepository;
private final BadgeRepository badgeRepository;
// Spring injects all the @Component beans in this list
private final List<BadgeProcessor> badgeProcessors;
@Override
public GameResult newAttemptForUser(final ChallengeSolvedDTO challenge) {
// We give points only if it's correct
if (challenge.isCorrect()) {
ScoreCard scoreCard = new ScoreCard(challenge.getUserId(),
challenge.getAttemptId());
scoreRepository.save(scoreCard);
log.info("User {} scored {} points for attempt id {}",
challenge.getUserAlias(), scoreCard.getScore(),
challenge.getAttemptId());
List<BadgeCard> badgeCards = processForBadges(challenge);
return new GameResult(scoreCard.getScore(),
badgeCards.stream().map(BadgeCard::getBadgeType)
.collect(Collectors.toList()));
} else {
log.info("Attempt id {} is not correct. " +
"User {} does not get score.",
challenge.getAttemptId(),
challenge.getUserAlias());
return new GameResult(0, List.of());
}
}
/**
* Checks the total score and the different score cards obtained
* to give new badges in case their conditions are met.
*/
private List<BadgeCard> processForBadges(
final ChallengeSolvedDTO solvedChallenge) {
Optional<Integer> optTotalScore = scoreRepository.
getTotalScoreForUser(solvedChallenge.getUserId());
if (optTotalScore.isEmpty()) return Collections.emptyList();
int totalScore = optTotalScore.get();
// Gets the total score and existing badges for that user
List<ScoreCard> scoreCardList = scoreRepository
.findByUserIdOrderByScoreTimestampDesc(solvedChallenge.getUserId());
Set<BadgeType> alreadyGotBadges = badgeRepository
.findByUserIdOrderByBadgeTimestampDesc(solvedChallenge.getUserId())
.stream()
.map(BadgeCard::getBadgeType)
.collect(Collectors.toSet());
// Calls the badge processors for badges that the user doesn't have yet
List<BadgeCard> newBadgeCards = badgeProcessors.stream()
.filter(bp -> !alreadyGotBadges.contains(bp.badgeType()))
.map(bp -> bp.processForOptionalBadge(totalScore,
scoreCardList, solvedChallenge)
).flatMap(Optional::stream) // returns an empty stream if empty
// maps the optionals if present to new BadgeCards
.map(badgeType ->
new BadgeCard(solvedChallenge.getUserId(), badgeType)
)
.collect(Collectors.toList());
badgeRepository.saveAll(newBadgeCards);
return newBadgeCards;
}
}
Listing 6-7Implementing the GameService Interface
in the GameServiceImpl Class
从这个实现中我们可以得出结论,BadgeProcessor接口接受一些上下文数据和已解决的尝试,并决定是否分配给定类型的徽章。清单 6-8 显示了该接口的源代码。
package microservices.book.gamification.game.badgeprocessors;
import java.util.List;
import java.util.Optional;
import microservices.book.gamification.challenge.ChallengeSolvedDTO;
import microservices.book.gamification.game.domain.BadgeType;
import microservices.book.gamification.game.domain.ScoreCard;
public interface BadgeProcessor {
/**
* Processes some or all of the passed parameters and decides if the user
* is entitled to a badge.
*
* @return a BadgeType if the user is entitled to this badge, otherwise empty
*/
Optional<BadgeType> processForOptionalBadge(
int currentScore,
List<ScoreCard> scoreCardList,
ChallengeSolvedDTO solved);
/**
* @return the BadgeType object that this processor is handling. You can use
* it to filter processors according to your needs.
*/
BadgeType badgeType();
}
Listing 6-8The BadgeProcessor Interface
由于我们在GameServiceImpl中使用带有一系列BadgeProcessor对象的构造函数注入,Spring 将找到所有实现这个接口的 beans,并将它们传递给我们。这是一种灵活的方式来扩展我们的游戏,而不干扰其他现有的逻辑。我们只需要添加新的BadgeProcessor实现并用@Component注释它们,这样它们就可以加载到 Spring 上下文中了。
清单 6-9 和 6-10 是我们需要满足功能需求的五个徽章实现中的两个BronzeBadgeProcessor和FirstWonBadgeProcessor。
package microservices.book.gamification.game.badgeprocessors;
import microservices.book.gamification.challenge.ChallengeSolvedDTO;
import microservices.book.gamification.game.domain.BadgeType;
import microservices.book.gamification.game.domain.ScoreCard;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Optional;
@Component
class FirstWonBadgeProcessor implements BadgeProcessor {
@Override
public Optional<BadgeType> processForOptionalBadge(
int currentScore,
List<ScoreCard> scoreCardList,
ChallengeSolvedDTO solved) {
return scoreCardList.size() == 1 ?
Optional.of(BadgeType.FIRST_WON) : Optional.empty();
}
@Override
public BadgeType badgeType() {
return BadgeType.FIRST_WON;
}
}
Listing 6-10FirstWonBadgeProcessor Implementation
package microservices.book.gamification.game.badgeprocessors;
import microservices.book.gamification.challenge.ChallengeSolvedDTO;
import microservices.book.gamification.game.domain.BadgeType;
import microservices.book.gamification.game.domain.ScoreCard;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Optional;
@Component
class BronzeBadgeProcessor implements BadgeProcessor {
@Override
public Optional<BadgeType> processForOptionalBadge(
int currentScore,
List<ScoreCard> scoreCardList,
ChallengeSolvedDTO solved) {
return currentScore > 50 ?
Optional.of(BadgeType.BRONZE) :
Optional.empty();
}
@Override
public BadgeType badgeType() {
return BadgeType.BRONZE;
}
}
Listing 6-9BronzeBadgeProcessor Implementation
锻炼
实现其他三个 badge 处理器和所有单元测试,以验证它们是否按预期工作。如果需要帮助,可以查阅本章的源代码。
-
银色徽章。如果分数超过 150 就赢了。
-
金质徽章。如果分数超过 400 就赢了。
-
“幸运数字”徽章。如果尝试的任何因素为 42,则获胜。
一旦我们完成了业务逻辑的第一部分,我们就可以进入第二部分:排行榜功能。图 6-6 显示了我们将在本章中实现的构建排行榜的三个层的 UML 图。
图 6-6
排行榜,UML 图
接口LeaderBoardService有一个方法来返回一个排序后的LeaderBoardRow对象列表。见清单 6-11 。
package microservices.book.gamification.game;
import java.util.List;
import microservices.book.gamification.game.domain.LeaderBoardRow;
public interface LeaderBoardService {
/**
* @return the current leader board ranked from high to low score
*/
List<LeaderBoardRow> getCurrentLeaderBoard();
}
Listing 6-11The LeaderBoardService Interface
锻炼
创建LeaderBoardServiceImplTest来验证该实现应该查询ScoreCardRepository来查找具有最高分数的用户,并且应该查询BadgeCardRepository来将分数与他们的徽章合并。和以前一样,存储库类还没有出现,但是您可以创建一些虚拟方法,并在测试中模拟它们。
如果我们能够聚合分数并对数据库中的结果行进行排序,那么排行榜服务的实现仍然很简单。我们将在下一节看到如何实现。现在,我们假设我们可以从ScoreRepository(findFirst10方法)获得分数排名。然后,我们查询数据库来检索包含在排名中的用户的徽章。见清单 6-12 。
package microservices.book.gamification.game;
import java.util.List;
import java.util.stream.Collectors;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import microservices.book.gamification.game.domain.LeaderBoardRow;
@Service
@RequiredArgsConstructor
class LeaderBoardServiceImpl implements LeaderBoardService {
private final ScoreRepository scoreRepository;
private final BadgeRepository badgeRepository;
@Override
public List<LeaderBoardRow> getCurrentLeaderBoard() {
// Get score only
List<LeaderBoardRow> scoreOnly = scoreRepository.findFirst10();
// Combine with badges
return scoreOnly.stream().map(row -> {
List<String> badges =
badgeRepository.findByUserIdOrderByBadgeTimestampDesc(
row.getUserId()).stream()
.map(b -> b.getBadgeType().getDescription())
.collect(Collectors.toList());
return row.withBadges(badges);
}).collect(Collectors.toList());
}
}
Listing 6-12The LeaderBoardService Implementation
注意,我们使用了方法withBadges来复制一个具有新值的不可变对象。我们第一次生成排行榜时,所有行都有一个空白的徽章列表。当我们收集徽章时,我们可以用相应徽章列表的副本替换(使用 stream 的map)每个对象。
数据
在业务逻辑层,我们对ScoreRepository和BadgeRepository方法做了一些假设。是时候构建这些存储库了。
请记住,我们只是通过扩展 Spring Data 的CrudRepository来获得基本的 CRUD 功能,因此我们可以轻松地保存徽章和记分卡。对于其余的查询,我们将同时使用查询方法和 JPQL。
BadgeRepository接口定义了一个查询方法来查找给定用户的徽章,按日期排序,最近的放在最上面。参见清单 6-13 。
package microservices.book.gamification.game;
import microservices.book.gamification.game.domain.BadgeCard;
import microservices.book.gamification.game.domain.BadgeType;
import org.springframework.data.repository.CrudRepository;
import java.util.List;
/**
* Handles data operations with BadgeCards
*/
public interface BadgeRepository extends CrudRepository<BadgeCard, Long> {
/**
* Retrieves all BadgeCards for a given user.
*
* @param userId the id of the user to look for BadgeCards
* @return the list of BadgeCards, ordered by most recent first.
*/
List<BadgeCard> findByUserIdOrderByBadgeTimestampDesc(Long userId);
}
Listing 6-13The BadgeRepository Interface with a Query Method
对于记分卡,我们需要其他查询类型。到目前为止,我们确定了三个需求。
-
计算一个用户的总分。
-
获得最高分的用户列表,作为
LeaderBoardRow对象。 -
按用户 ID 读取所有记分卡记录。
清单 6-14 显示了ScoreRepository的完整源代码。
package microservices.book.gamification.game;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.CrudRepository;
import org.springframework.data.repository.query.Param;
import microservices.book.gamification.game.domain.LeaderBoardRow;
import microservices.book.gamification.game.domain.ScoreCard;
/**
* Handles CRUD operations with ScoreCards and other related score queries
*/
public interface ScoreRepository extends CrudRepository<ScoreCard, Long> {
/**
* Gets the total score for a given user: the sum of the scores of all
* their ScoreCards.
*
* @param userId the id of the user
* @return the total score for the user, empty if the user doesn't exist
*/
@Query("SELECT SUM(s.score) FROM ScoreCard s WHERE s.userId = :userId GROUP BY s.userId")
Optional<Integer> getTotalScoreForUser(@Param("userId") Long userId);
/**
* Retrieves a list of {@link LeaderBoardRow}s representing the Leader Board
* of users and their total score.
*
* @return the leader board, sorted by highest score first.
*/
@Query("SELECT NEW microservices.book.gamification.game.domain.LeaderBoardRow(s.userId, SUM(s.score)) " +
"FROM ScoreCard s " +
"GROUP BY s.userId ORDER BY SUM(s.score) DESC")
List<LeaderBoardRow> findFirst10();
/**
* Retrieves all the ScoreCards for a given user, identified by his user id.
*
* @param userId the id of the user
* @return a list containing all the ScoreCards for the given user,
* sorted by most recent.
*/
List<ScoreCard> findByUserIdOrderByScoreTimestampDesc(final Long userId);
}
Listing 6-14The ScoreRepository Interface, Using Query Methods and JPQL Queries
不幸的是,Spring Data JPA 的查询方法不支持聚合。好消息是 JPA 查询语言 JPQL 确实支持它们,所以我们可以使用标准语法使我们的代码尽可能与数据库无关。我们可以通过以下查询获得给定用户的总分:
SELECT SUM(s.score) FROM ScoreCard s WHERE s.userId = :userId GROUP BY s.userId
像在标准 SQL 中一样,GROUP BY子句指示如何对值求和。我们可以用:param符号定义参数。然后,我们用@Param注释相应的方法参数。我们也可以使用我们在上一章中遵循的方法,使用像?1这样的参数位置占位符。
第二个查询有点特殊。在 JPQL 中,我们可以使用 Java 类中可用的构造函数。我们在示例中所做的是基于总分的聚合,并且我们使用我们定义的双参数构造函数来构造LeaderBoardRow对象(它设置了一个空白的徽章列表)。请记住,我们必须使用 JPQL 中类的完全限定名,如源代码所示。
控制器
在设计我们的游戏化领域时,我们与乘法服务达成了一个合同。它会将每个尝试发送到游戏化端的 REST 端点。是时候构建控制器了。见清单 6-15 。
package microservices.book.gamification.game;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import lombok.RequiredArgsConstructor;
import microservices.book.gamification.challenge.ChallengeSolvedDTO;
@RestController
@RequestMapping("/attempts")
@RequiredArgsConstructor
public class GameController {
private final GameService gameService;
@PostMapping
@ResponseStatus(HttpStatus.OK)
void postResult(@RequestBody ChallengeSolvedDTO dto) {
gameService.newAttemptForUser(dto);
}
}
Listing 6-15The GameController Class
在POST /attempts上有一个 REST API,它接受一个 JSON 对象,该对象包含关于用户和挑战的数据。在这种情况下,我们不需要返回任何内容,所以我们利用ResponseStatus注释来配置 Spring 返回一个200 OK状态代码。实际上,这是当控制器的方法返回void并且已经被正确处理时的默认行为。无论如何,为了更好的可读性,显式地添加它是有好处的。请记住,例如,如果出现抛出异常之类的错误,Spring Boot 的默认错误处理逻辑将拦截它,并返回一个带有不同状态代码的错误响应。
我们还可以向 DTO 类添加验证,以确保其他服务不会向游戏化微服务发送无效数据,但现在,让我们保持简单。无论如何,我们将在下一章修改这个 API。
锻炼
不要忘记为第一个控制器和下一个控制器添加测试。你可以在本章的源代码中找到这些测试。
我们的第二个控制器用于排行榜功能,并公开了一个返回序列化的LeaderBoardRow对象的 JSON 数组的GET /leaders方法。这些数据来自服务层,服务层使用徽章和分数存储库来合并用户的分数和徽章。因此,表示层保持简单。参见清单 6-16 中的代码。
package microservices.book.gamification.game;
import lombok.RequiredArgsConstructor;
import microservices.book.gamification.game.domain.LeaderBoardRow;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* This class implements a REST API for the Gamification LeaderBoard service.
*/
@RestController
@RequestMapping("/leaders")
@RequiredArgsConstructor
class LeaderBoardController {
private final LeaderBoardService leaderBoardService;
@GetMapping
public List<LeaderBoardRow> getLeaderBoard() {
return leaderBoardService.getCurrentLeaderBoard();
}
}
Listing 6-16The LeaderBoardController Class
配置
我们经历了应用的三个层次:业务逻辑、数据和表示。我们还缺少一些我们在乘法微服务中定义的 Spring Boot 配置。
首先,我们给游戏化微服务中的application.properties文件添加一些值。参见清单 6-17 。
server.port=8081
# Gives us access to the H2 database web console
spring.h2.console.enabled=true
# Creates the database in a file
spring.datasource.url=jdbc:h2:file:~/gamification;DB_CLOSE_ON_EXIT=FALSE
# Creates or updates the schema if needed
spring.jpa.hibernate.ddl-auto=update
# For educational purposes we will show the SQL in console
spring.jpa.show-sql=true
Listing 6-17The application.properties File for the Gamification App
唯一新增的是server.port属性。我们改变它,因为当我们在本地运行它们时,我们不能在我们的第二个应用中使用相同的缺省值8080。我们还在datasource URL 中设置了一个不同的 H2 文件名,为这个微服务创建一个单独的数据库,名为gamification。
此外,我们还需要为这个微服务启用 CORS,因为用户界面需要能够访问排行榜 API。如果你不记得 CORS 做了什么,看看第四章中的“第一次运行我们的前端”一节。这个文件的内容与我们在乘法中添加的内容相同。参见清单 6-18 。
package microservices.book.gamification.configuration;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfiguration implements WebMvcConfigurer {
@Override
public void addCorsMappings(final CorsRegistry registry) {
registry.addMapping("/**").allowedOrigins("http://localhost:3000");
}
}
Listing 6-18Adding CORS Configuration
to the Gamification App
鉴于我们还想使用 Hibernate 的 Jackson 模块,我们必须在 Maven 中添加这种依赖性。请记住,我们还需要将模块注入到上下文中,以便由自动配置来选择。参见清单 6-19 和 6-20 。
package microservices.book.gamification.configuration;
import com.fasterxml.jackson.databind.Module;
import com.fasterxml.jackson.datatype.hibernate5.Hibernate5Module;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class JsonConfiguration {
@Bean
public Module hibernateModule() {
return new Hibernate5Module();
}
}
Listing 6-20Defining the Bean for JSON’s Hibernate Module to Be Used for Serialization
<dependencies>
<!-- ... -->
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-hibernate5</artifactId>
</dependency>
</dependencies>
Listing 6-19Adding the Jackson’s Hibernate
Module to Gamification’s pom.xml File
乘法微服务的变化
我们完成了游戏化微服务的第一个版本。现在,我们必须通过与新的微服务通信乘法,将两个微服务集成在一起。
之前,我们在服务器端创建了一些 REST APIs。这一次,我们必须构建一个 REST API 客户端。Spring Web 模块提供了一个用于这个目的的工具:RestTemplate类。Spring Boot 在顶部提供了额外的一层:??。当我们使用 Spring Boot Web starter 时,这个构建器是默认注入的,我们可以使用它的方法通过多种配置选项流畅地创建RestTemplate对象。如果需要访问服务器,我们可以添加特定的消息转换器、安全凭证、HTTP 拦截器等。在我们的例子中,我们可以使用默认设置,因为两个应用都使用 Spring Boot 的预定义配置。也就是说我们的RestTemplate发送的序列化 JSON 对象在服务器端(游戏化微服务)可以无问题的反序列化。
为了保持我们的实现模块化,我们在一个单独的类中创建游戏化的 REST 客户端:GamificationServiceClient。参见清单 6-21 。
package microservices.book.multiplication.serviceclients;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import lombok.extern.slf4j.Slf4j;
import microservices.book.multiplication.challenge.ChallengeAttempt;
import microservices.book.multiplication.challenge.ChallengeSolvedDTO;
@Slf4j
@Service
public class GamificationServiceClient {
private final RestTemplate restTemplate;
private final String gamificationHostUrl;
public GamificationServiceClient(final RestTemplateBuilder builder,
@Value("${service.gamification.host}") final String gamificationHostUrl) {
restTemplate = builder.build();
this.gamificationHostUrl = gamificationHostUrl;
}
public boolean sendAttempt(final ChallengeAttempt attempt) {
try {
ChallengeSolvedDTO dto = new ChallengeSolvedDTO(attempt.getId(),
attempt.isCorrect(), attempt.getFactorA(),
attempt.getFactorB(), attempt.getUser().getId(),
attempt.getUser().getAlias());
ResponseEntity<String> r = restTemplate.postForEntity(
gamificationHostUrl + "/attempts", dto,
String.class);
log.info("Gamification service response: {}", r.getStatusCode());
return r.getStatusCode().is2xxSuccessful();
} catch (Exception e) {
log.error("There was a problem sending the attempt.", e);
return false;
}
}
}
Listing 6-21The GamificationServiceClient Class, in the Multiplication App
这个新的 Spring@Service可以注入到我们现有的 Spring 中。它使用构建器用默认值初始化RestTemplate(只是调用build())。它还在构造函数中接受游戏化服务的主机 URL,我们希望提取它作为配置参数。
在 Spring Boot,我们可以在application.properties文件中创建自己的配置选项,并用@Value注释将它们的值注入到组件中。gamificationHostUrl参数将被设置为这个新属性的值,我们必须将它添加到乘法的属性文件中。参见清单 6-22 。
# ... existing properties
# Gamification service URL
service.gamification.host=http://localhost:8081
Listing 6-22Adding the URL of the Gamification Microservice as a Property in Multiplication
服务客户端的其余实现很简单。它基于来自域对象ChallengeAttempt的数据构建一个(新的)ChallengeSolvedDTO。然后,它使用RestTemplate中的postForEntity方法将数据发送到游戏化中的/attempts端点。我们不期望响应体,但是方法的签名需要它,所以我们可以将其设置为String。
我们还将完整的逻辑包装在 try/catch 块中。原因是我们不希望一个试图到达游戏化微服务的错误最终打破了我们在乘法微服务中的主要业务逻辑。这一决定将在本章末尾进一步解释。
这个ChallengeSolvedDTO类是我们在游戏化方面创建的一个类的副本。参见清单 6-23 。
package microservices.book.multiplication.challenge;
import lombok.Value;
@Value
public class ChallengeSolvedDTO {
long attemptId;
boolean correct;
int factorA;
int factorB;
long userId;
String userAlias;
}
Listing 6-23The ChallengeSolvedDTO Class Needs to Be Included in the Multiplication Microservice Too
现在我们可以在现有的ChallengeServiceImpl类中注入这个服务,并在它被处理后使用它来发送尝试。参见清单 6-24 了解该级所需的修改。
@Slf4j
@RequiredArgsConstructor
@Service
public class ChallengeServiceImpl implements ChallengeService {
private final UserRepository userRepository;
private final ChallengeAttemptRepository attemptRepository;
private final GamificationServiceClient gameClient;
@Override
public ChallengeAttempt verifyAttempt(ChallengeAttemptDTO attemptDTO) {
// ... existing logic
// Stores the attempt
ChallengeAttempt storedAttempt = attemptRepository.save(checkedAttempt)
;
// Sends the attempt to gamification and prints the response
HttpStatus status = gameClient.sendAttempt(storedAttempt);
log.info("Gamification service response: {}", status);
return storedAttempt;
}
// ...
}
Listing 6-24Adding Logic to ChallengeServiceImpl
to Send an Attempt to the Gamification Microservice
我们的测试也应该更新,以检查每次尝试时调用是否发生。我们可以给ChallengeServiceTest增加一个新的模拟职业。
@Mock private GamificationServiceClient gameClient;
然后,我们在测试用例中使用 Mockito 的verify,以确保这个调用是使用存储在数据库中的相同数据来执行的。
verify(gameClient).sendAttempt(resultAttempt);
除了 REST API 客户端之外,我们还想为乘法微服务添加第二项更改:一个控制器,用于根据用户的标识符检索用户别名集合。我们需要这样做,因为我们在LeaderBoardController类中实现的排行榜 API 根据用户 id 返回分数、徽章和位置。UI 需要一种方法将每个 ID 映射到一个用户别名,以更友好的方式呈现表格。参见清单 6-25 中的新UserController级。
package microservices.book.multiplication.user;
import java.util.List;
import org.springframework.web.bind.annotation.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/users")
public class UserController {
private final UserRepository userRepository;
@GetMapping("/{idList}")
public List<User> getUsersByIdList(@PathVariable final List<Long> idList) {
return userRepository.findAllByIdIn(idList);
}
}
Listing 6-25The New UserController Class
这一次我们使用一个标识符列表作为路径变量,Spring 将它拆分并作为标准的List传递给我们。实际上,这意味着 API 调用可以包含一个或多个用逗号分隔的数字,例如/users/1,2,3。
如您所见,我们在控制器中注入了一个存储库,因此我们在这里没有遵循三层架构原则。原因是我们不需要这个特定用例的业务逻辑,所以,在这些情况下,最好保持我们的代码简单。如果我们在未来任何时候需要业务逻辑,我们可以从层之间的松散耦合中受益,并在这两者之间创建服务层。
存储库接口使用新的查询方法在users表中执行选择,过滤那些标识符在传递列表中的。参见清单 6-26 中的源代码。
package microservices.book.multiplication.user;
import java.util.List;
import java.util.Optional;
import org.springframework.data.repository.CrudRepository;
public interface UserRepository extends CrudRepository<User, Long> {
Optional<User> findByAlias(final String alias);
List<User> findAllByIdIn(final List<Long> ids);
}
Listing 6-26The New Query Methods in the UserRepository Interface
锻炼
更新乘法微服务中的测试,以覆盖对 REST 客户端的新调用,并为UserController创建一个新调用。你可以在本章的源代码中找到解决方案。
用户界面
后端逻辑已经准备好了,可以转到前端部分了。我们需要两个新的 JavaScript 类:
-
从游戏化微服务中检索排行榜数据的新 API 客户端
-
一个额外的 React 组件来呈现排行榜
我们还将向现有的 API 客户机添加一个新方法,根据用户的 id 检索用户列表。
清单 6-27 中的GameApiClient类定义了一个不同的主机,并使用fetch API 来检索 JSON 对象数组。为了清楚起见,我们也将现有的ApiClient重命名为ChallengesApiClient。然后,我们在这一个中包括一个新的方法来检索用户。参见清单 6-28 。
class ChallengesApiClient {
static SERVER_URL = 'http://localhost:8080';
// ...
static GET_USERS_BY_IDS = '/users';
// existing methods...
static getUsers(userIds: number[]): Promise<Response> {
return fetch(ChallengesApiClient.SERVER_URL +
ChallengesApiClient.GET_USERS_BY_IDS +
'/' + userIds.join(','));
}
}
export default ChallengesApiClient;
Listing 6-28Renaming the Former Apiclient Class and Including the New Call
class GameApiClient {
static SERVER_URL = 'http://localhost:8081';
static GET_LEADERBOARD = '/leaders';
static leaderBoard(): Promise<Response> {
return fetch(GameApiClient.SERVER_URL +
GameApiClient.GET_LEADERBOARD);
}
}
export default GameApiClient;
Listing 6-27The GamiApiClient Class
返回的承诺将用于新的LeaderBoardComponent,它检索数据并更新其状态的leaderboard属性。它的render()方法应该将对象数组映射到一个 HTML 表中,每个位置一行。我们将使用 JavaScript 的定时事件(见 https://tpd.io/timing-events )通过函数setInterval每五秒刷新一次排行榜。
参见清单 6-29 中LeaderBoardComponent的完整源代码。然后,我们将更深入地研究它的逻辑。
import * as React from 'react';
import GameApiClient from '../services/GameApiClient';
import ChallengesApiClient from '../services/ChallengesApiClient';
class LeaderBoardComponent extends React.Component {
constructor(props) {
super(props);
this.state = {
leaderboard: [],
serverError: false
}
}
componentDidMount() {
this.refreshLeaderBoard();
// sets a timer to refresh the leaderboard every 5 seconds
setInterval(this.refreshLeaderBoard.bind(this), 5000);
}
getLeaderBoardData(): Promise {
return GameApiClient.leaderBoard().then(
lbRes => {
if (lbRes.ok) {
return lbRes.json();
} else {
return Promise.reject("Gamification: error response");
}
}
);
}
getUserAliasData(userIds: number[]): Promise {
return ChallengesApiClient.getUsers(userIds).then(
usRes => {
if(usRes.ok) {
return usRes.json();
} else {
return Promise.reject("Multiplication: error response");
}
}
)
}
updateLeaderBoard(lb) {
this.setState({
leaderboard: lb,
// reset the flag
serverError: false
});
}
refreshLeaderBoard() {
this.getLeaderBoardData().then(
lbData => {
let userIds = lbData.map(row => row.userId);
this.getUserAliasData(userIds).then(data => {
// build a map of id -> alias
let userMap = new Map();
data.forEach(idAlias => {
userMap.set(idAlias.id, idAlias.alias);
});
// add a property to existing lb data
lbData.forEach(row =>
row['alias'] = userMap.get(row.userId)
);
this.updateLeaderBoard(lbData);
}).catch(reason => {
console.log('Error mapping user ids', reason);
this.updateLeaderBoard(lbData);
});
}
).catch(reason => {
this.setState({ serverError: true });
console.log('Gamification server error', reason);
});
}
render() {
if (this.state.serverError) {
return (
<div>We're sorry, but we can't display game statistics at this
moment.</div>
);
}
return (
<div>
<h3>Leaderboard</h3>
<table>
<thead>
<tr>
<th>User</th>
<th>Score</th>
<th>Badges</th>
</tr>
</thead>
<tbody>
{this.state.leaderboard.map(row => <tr key={row.userId}>
<td>{row.alias ? row.alias : row.userId}</td>
<td>{row.totalScore}</td>
<td>{row.badges.map(
b => <span className="badge" key={b}>{b}</span>)}
</td>
</tr>)}
</tbody>
</table>
</div>
);
}
}
export default LeaderBoardComponent;
Listing 6-29The New LeaderBoardComponent in React
主逻辑包含在refreshLeaderBoard功能中。首先,它试图从游戏化服务器获取排行榜行。如果不能(catch子句),它会将serverError标志设置为 true,所以我们将呈现一条消息而不是表格。如果数据被正常检索,逻辑执行第二次调用,这次是对乘法微服务的调用。如果我们得到适当的响应,我们会将数据中包含的用户标识符映射到其对应的别名,并为排行榜中的每个位置添加一个新字段alias。如果第二次调用失败,我们仍然使用没有额外字段的原始数据。
render()功能区分错误情况和标准情况。如果有错误,我们会显示一条消息而不是表格。通过这种方式,我们使我们的应用具有弹性,因为即使游戏化微服务失败,主要功能(解决挑战)仍在工作。排行榜数据与用户别名(或 ID,如果无法获取的话)、总分和徽章列表一起显示在各行中。
我们在渲染逻辑中使用了badge CSS 类。让我们在App.css样式表中创建这个定制样式。见清单 6-30 。
/* ... existing styles ... */
.badge {
font-size: x-small;
border: 2px solid dodgerblue;
border-radius: 4px;
padding: 0.2em;
margin: 0.1em;
}
Listing 6-30Adding the Badge Style to App.css
现在,我们应该在我们的根容器ChallengeComponent类中包含排行榜组件。参见清单 6-31 中对源代码的修改。
import LeaderBoardComponent from './LeaderBoardComponent';
class ChallengeComponent extends React.Component {
// ...existing methods...
render() {
return (
<div className="display-column">
{/* we add this just before closing the main div */}
<LeaderBoardComponent />
</div>
);
}
}
export default ChallengeComponent
;
Listing 6-31Adding the LeaderBoardComponent Inside the ChallengeComponent
玩弄系统
我们实现了新的游戏化微服务,通过 REST API 客户端服务将乘法应用连接到它,并构建 UI 以获取排行榜并每五秒钟渲染一次。
是时候玩我们的完整系统了。使用 IDE 或命令行启动后端应用和 Node.js 服务器。如果您使用终端,打开三个单独的实例,并在每个实例中运行清单 6-32 中的一个命令,这样您就可以单独访问所有日志。
/multiplication $ mvnw spring-boot:run
...
/gamification $ mvnw spring-boot:run
...
/challenges-frontend $ npm start
...
Listing 6-32Starting the Apps from the Console
如果一切顺利,我们将看到 UI 在浏览器中运行。将会有一个空的排行榜(除非你在编码时已经尝试过一点)。如果我们发送一个正确的尝试,我们应该看到类似于图 6-7 的东西。
图 6-7
连接到两个微服务的 UI
当我们发送第一次正确尝试时,我们将获得 10 分和“第一次”徽章。游戏有效!您可以继续玩,看看您是否能获得任何金属徽章或幸运数字 1。由于每五秒钟自动渲染一次,您甚至可以在多个浏览器标签中玩游戏,排行榜将在每个标签中刷新。
现在让我们来看看日志。在乘法方面,当我们发送新的尝试时,我们会在日志中看到这一行:
INFO 36283 --- [nio-8080-exec-4] m.b.m.challenge.ChallengeServiceImpl : Gamification service response: 200 OK
游戏化应用会输出一行,说明尝试不正确,因此没有新的分数,或者如果你是正确的,会输出以下行:
INFO 36280 --- [nio-8081-exec-9] m.b.gamification.game.GameServiceImpl : User jane scored 10 points for attempt id 2
我们还会看到许多重复的日志行显示查询,因为我们将应用配置为显示所有 JPA 语句,并且 UI 会定期调用以检索排行榜和用户别名。
容错
在细化我们的需求时,我们确定游戏化特性并不重要,因此我们可以接受系统的这一部分出现一些停机时间。让我们来看看这个新的微服务会发生什么。如果你还在运行应用,停止游戏化应用。否则,只启动 UI 服务器和乘法。
我们将在屏幕上看到排行榜组件的回退消息,如图 6-8 所示。正如我们可以使用开发者工具中的网络选项卡来验证的那样,对游戏化服务的 HTTP 调用(在端口8081上)失败了。
图 6-8
游戏化微服务宕机
此外,如果我们尝试发送一个尝试,它仍然会工作。该错误会导致一个在GamificationServiceClient类中捕获的异常。
ERROR 36666 --- [nio-8080-exec-2] m.b.m.s.GamificationServiceClient : There was a problem sending the attempt.
即使有一半的后端宕机,核心功能仍能正常工作。但是请记住,在这种情况下,我们将丢失数据,因此用户将不会获得任何成功尝试的分数。
作为替代实现,我们可以使用重试逻辑。我们可以实现一个循环来不断尝试发布尝试,直到我们从游戏化微服务获得一个OK响应,或者直到一定量的时间过去。但是,即使有我们可以用来实现这种模式的库,我们系统的复杂性也增加了。重试的时候乘法微服务也宕机了怎么办?我们是否应该跟踪数据库中尚未发送的尝试?在这种情况下,当游戏化应用在随机时刻复活时,我们应该按照发生的顺序发送尝试吗?如您所见,像我们的微服务架构这样的分布式系统带来了新的挑战。
未来的挑战
我们建立的系统正在运行,所以我们应该为此感到自豪。即使在游戏化微服务出现故障的情况下,应用也能保持响应。请参见图 6-9 了解我们系统的更新逻辑视图。
图 6-9
逻辑视图
我们的后端逻辑现在分布在这两个 Spring Boot 应用中。让我们回顾一下构建分布式系统的意义,重点关注我们的微服务架构和我们面临的新挑战。
紧密结合
当我们对我们的域建模时,我们认为它们是松散耦合的,因为我们在域对象之间只使用最少的引用。然而,我们在乘法微服务中引入了游戏化逻辑的意识。后者显式调用游戏化 API 来发送尝试,并负责传递消息。我们使用的命令式风格在 monolith 中还不错,但在微服务架构中可能会成为一个大问题,因为它在微服务之间引入了紧密耦合。
在我们当前的设计中,游戏化微服务由乘法微服务编排,乘法微服务主动触发动作。我们可以不使用这种编排模式,而是使用编排模式,让游戏化微服务决定何时触发其逻辑。我们将在下一章讲述事件驱动架构时,详细说明编排和编排之间的区别。
同步接口与最终一致性
正如我们前面所详述的,乘法微服务希望游戏化服务器在发送尝试时可用。如果不是,这个过程的这一部分仍然是不完整的。所有这些都发生在请求的生命周期中。当乘法服务器向 UI 发送响应时,分数和徽章要么被更新,要么出现了错误。我们构建了同步接口:请求保持阻塞状态,直到它们完全完成或者失败。
当您有许多微服务时,您将不可避免地有跨越它们的流程,就像在我们的例子中,即使它们有精心设计的上下文边界。为了描述这一点,让我们创建一个更复杂的场景,作为我们后端的假设性发展。作为第一个补充,我们想给用户发送一封电子邮件,当他们达到 1000 点。在不对领域边界进行判断的情况下,假设我们有一个专用的微服务,它需要在分配新分数后进行更新。我们还添加了一个微服务,收集数据进行报告,需要连接到乘法和游戏化。参见图 6-10 了解该假想系统的完整视图。
图 6-10
我们系统的假设进化
我们可以继续用 REST API 调用构建同步接口。然后,我们将有一个调用链,如图中的数字序列所示。来自浏览器的请求需要等待,直到所有的请求都完成。链中的服务越多,请求被阻塞的时间就越长。如果一个微服务慢了,整个链就慢了。系统的整体性能至少和链中最差的微服务一样差。
当我们在构建微服务时没有考虑容错时,同步依赖性甚至更差。在我们的示例中,从游戏化微服务到报告微服务的一个简单的失败更新操作可能会使整个流程崩溃。如果我们在同一个阻塞线程中实现重试机制,性能会下降得更多。如果我们让它们太容易失败,我们可能会以许多部分完成的操作而告终。
到目前为止有一个明确的结论:同步接口在微服务之间引入了强烈的依赖性。
作为一个优势,我们知道当用户得到响应时,报告已经在后端更新了。所以,是分数。我们甚至知道我们是否可以发送电子邮件,所以我们可以立即给出反馈。
在 monolith 中,我们不会面临这种挑战,因为所有这些模块都存在于同一个可部署单元中。如果我们只是调用其他方法,我们不会因为网络延迟或错误而遇到问题。此外,如果某个东西发生故障,它将是整个系统,因此我们不需要在设计它的同时考虑细粒度的容错。
所以,如果同步接口不好,重要的问题是:我们需要首先阻塞完整的请求吗?在返回响应之前,我们需要知道所有的事情都完成了吗?为了回答这个问题,让我们修改我们的假设案例,分离微服务之间的后续交互。见图 6-11 。
图 6-11
异步处理
这种新的设计在新的线程中发起一些请求,解除了主线程的阻塞。例如,我们可以使用 Java 期货。这将导致响应更早地传递给客户端,因此我们解决了前面描述的所有问题。但是,结果是,我们引入了最终的一致性。想象一下,在 API 客户端,有一个顺序线程等待发送尝试的响应。然后,这个客户端的进程将尝试收集分数和报告。在阻塞线程场景中,我们的 API 客户端(例如,UI)肯定知道,在从乘法得到响应后,游戏化中的分数与尝试一致。在这个新的异步环境中,我们无法保证这一点。如果我们的网络延迟很好,客户端可能会得到更新的分数。但可能需要一秒钟才能完成,或者我们的服务关闭了更长时间,只有在重试几次后才会更新。我们无法预测。
因此,在构建微服务架构时,我们面临的最大挑战之一就是实现最终的一致性。我们应该接受游戏化微服务的数据在给定时刻可能与倍增微服务的数据不一致。它只会最终保持一致。最后,通过使我们的系统健壮的适当设计,游戏化微服务将是最新的。同时,我们的 API 客户端不能假设不同 API 调用之间的一致性。这是关键:不仅仅是我们的后端系统;这也与我们的 API 客户有关。如果我们是唯一使用我们的 API 的人,那可能不是一个大问题:我们可以开发我们的 REST 客户端,并最终保持一致性。然而,如果我们提供 API 作为服务,我们也必须教育我们的客户。他们必须知道会发生什么。
因此,我们最初关于是否需要阻塞请求的问题可以被一个更重要的问题所取代:我们的系统最终能保持一致吗?当然,答案取决于我们的功能和技术需求。
例如,在某些情况下,系统的功能描述可能意味着很强的一致性,但是您可以对其进行调整,而不会产生很大的影响。作为一个实际案例,如果我们将电子邮件子流程分离为一个异步步骤,我们可以将提示用户的消息从“您应该已经收到一封带有说明的电子邮件”更改为“您将在几分钟后收到一封带有说明的电子邮件”。如果没有,请联系客户支持。”但是能够做出这样的改变总是依赖于组织接受最终一致性的需求和胃口。
微服务并不总是最好的解决方案(第一部分)
如果您的项目需求与跨域的最终一致性不兼容,那么模块化的单一应用可能更适合您。
另一方面,我们不需要处处完全异步。在某些情况下,微服务之间的同步调用是有意义的。这不是问题,也不是把我们的软件架构戏剧化的理由。我们只需要关注这些接口,因为有时这是域之间紧密耦合的征兆。在这种情况下,我们可以考虑将其合并到同一个微服务中。
回顾我们当前的系统状态,我们可以得出结论,它已经为最终的一致性做好了准备。由于我们不依赖响应来刷新排行榜,我们可以在微服务之间切换到异步调用,而不会产生任何影响。
可以想象,有一种比用重试模式调用 REST API 更好的方法来实现微服务之间的异步通信。我们将在下一章讨论它。
处理
在 monolith 中,我们可以使用相同的关系数据库来存储用户、尝试、分数和徽章。然后,我们可以从数据库事务中受益。我们将获得在前一章中简要介绍过的 ACID 保证:原子性、一致性、隔离性和持久性。在保存记分卡出错的情况下,我们可以恢复事务中所有以前的命令,这样尝试也不会被存储。该操作被称为回滚。因为我们可以避免部分更新,所以我们可以始终确保数据的完整性。
我们不能拥有跨微服务的 ACID 保证,因为我们无法在一个微服务架构中实现真正的事务。它们是独立部署的,所以它们生活在不同的进程中,它们的数据库也应该是解耦的。此外,为了避免相互依赖,我们还得出结论,我们应该接受最终的一致性。
原子性,或者确保所有相关数据被存储或者什么都不存储,很难在微服务之间实现。在我们的系统中,首先请求存储尝试,然后乘法微服务调用游戏化微服务做好自己的工作。即使我们保持请求同步,如果我们没有收到响应,我们也不知道分数和徽章是否被存储。那我们该怎么办?我们要回滚事务吗?不管游戏化中发生了什么,我们总是保存尝试吗(就像我们做的那样)?
事实上,在分布式系统中尝试实现事务回滚有一些富有想象力且复杂的方法。
-
两阶段提交(2PC) :在这种方法中,我们可以将乘法尝试发送到游戏化,但我们不会将数据存储在任何一端。然后,一旦我们得到指示数据准备好被存储的响应,我们发送第二个请求作为信号来存储游戏化的分数和徽章,并且我们存储乘法的尝试。通过这两个阶段(准备和提交),我们最大限度地减少了出错的时间。然而,我们没有排除这种可能性,因为第二阶段可能会失败。在我看来,这是一个可怕的想法,因为我们必须坚持同步接口,并且复杂性呈指数增长。
-
Sagas :这种设计模式涉及双向沟通。我们可以在两个微服务之间建立一个异步接口,如果游戏化方面出现问题,这个微服务应该能够联系乘法微服务,让它知道。在我们的例子中,乘法将删除刚刚保存的尝试。这样我们补偿一笔交易。就复杂性而言,这也带来了高昂的代价。
毫无疑问,最好的解决方案是尽量将必须使用数据库事务的功能流保持在同一个微服务中。如果我们不能分割一个事务,因为它在我们的系统中是关键的,那么看起来这个流程应该属于同一个域。对于其他流,我们可以尝试分割事务边界,并最终实现一致性。
我们还可以应用模式使我们的系统更加健壮,这样我们就可以最小化部分执行操作的风险。任何可以确保微服务间数据传递的设计模式都将有助于实现这一目标。这也将在下一章讨论。
我们的系统不使用分布式事务。它也不需要它们,因为我们不需要尝试和得分之间的即时一致性。但仍然有一个设计缺陷:乘法微服务忽略了游戏化的错误,因此我们可能会在没有相应分数和徽章的情况下成功解决尝试。我们将很快改进这一点,而不需要我们自己实现重试机制。
微服务并不总是最好的解决方案(第二部分)
如果您发现自己到处都在用 2PC 或 sagas 实现分布式事务,那么您应该花一些时间来反思您的需求和您的微服务边界。你可能想要合并其中的一些或者更好的分配功能。如果您不能用更简单的方法来解决这个问题,那么就考虑一个只有一个关系数据库的模块化整体应用。
API 暴露
我们在游戏化微服务中创建了一个 REST 端点,用于乘法微服务。但是用户界面也需要访问游戏化微服务,所以事实上,任何人都可以访问它。如果聪明的用户使用 HTTP 客户端(如 HTTPie),他们可以向游戏化微服务发送虚假数据。我们的处境会很糟糕,因为这会破坏我们的数据完整性。用户可以得分并获得徽章,而无需存储在乘法端的相应尝试。
解决这个问题有多种方法。我们可以考虑给我们的端点增加一个安全层,并确保内部 API 只对其他后端服务可用。一个更简单的选择是使用反向代理(带有网关模式)来确保我们只公开公共端点。我们将在第八章更详细地介绍这个选项。
总结和成就
在本章中,我们探讨了转向微服务架构的原因。我们开始详细介绍我们迄今为止所采用的方法,即小型整体架构,并分析了与过渡到微服务相比,继续我们的模块化整体应用之旅的利弊。
我们还研究了一个小型的 monolith 如何帮助你更好地定义你的领域,更快地完成我们产品的第一个版本,以获得用户的早期反馈。将代码组织成模块的良好实践列表应该有助于您在需要时进行拆分。但是我们也看到了有时候一个小的整体并不是最好的主意,特别是如果开发团队从一开始就很大的话。
决定迁移到微服务(或从微服务开始)需要对系统的功能和非功能特性进行深入分析,以确定在可伸缩性、容错性、事务性、最终一致性等方面的需求。该决策对于软件项目的成败至关重要。我希望本章中包含的所有考虑事项,以及实际案例的支持,能够帮助您仔细检查项目中存在的所有因素,并在您采取行动时做出合理的决定和良好的计划。
正如本书所料,我们决定采用微服务架构。在实践方面,我们浏览了新游戏化应用的各个层:服务、存储库、控制器和新的 React 组件。我们使用简单的命令式方法将乘法与游戏化联系起来,并且我们使用我们的微服务之间的接口来发现我们在微服务架构方面面临的一些新挑战。
到本章结束时,我们还得出结论,我们选择的用于通信两个微服务的同步接口是错误的决定。它引入了紧密耦合,并使我们的架构容易出错。这是下一章的完美基线,在下一章中,我们将介绍事件驱动架构的优势。
章节成就:
-
您看到了小型整体方法在启动新项目时如何帮助您。
-
您已经初步接触了微服务架构的优缺点(您将在接下来的章节中继续学习)。
-
您了解了分布式系统中同步和异步处理之间的差异,以及它们与最终一致性的关系。
-
您了解了为什么在微服务架构中采用这些新范式(异步流程、最终一致性)以避免紧密耦合和域污染非常重要。
-
您看到了为什么微服务不是所有情况下的最佳解决方案(例如,如果您需要事务性和即时数据一致性)。
-
您确定了我们在实际案例中面临的第一个挑战,并看到了当前的实施方式并不是实施微服务的正确方式。****