八、使用 React 和微服务构建客户关系管理
在前面使用 REST 服务的章节中,我们集中讨论了使用一个站点来处理 REST 调用。 现代应用经常使用微服务,它们可能驻留在基于容器的系统(如 Docker)中。
在本章中,我们将看看如何使用 Swagger 来设计我们的 REST API 来创建一组托管在多个 Docker 容器中的微服务。 我们的 React 客户端应用将负责将这些微服务拉到一起,创建一个简单的客户关系管理(CRM)系统。
本章将涵盖以下主题:
- 了解 Docker 和容器
- 什么是微服务,它们的用途是什么
- 将单片架构分解为微架构
- 共享公共服务器端功能
- 使用 Swagger 设计 api
- 在 Docker 中托管微服务
- 使用 React 连接微服务
- 在 React 中使用路由
技术要求
完成的项目可从https://github.com/PacktPublishing/Advanced-TypeScript-3-Programming-Projects/tree/master/Chapter08下载。
下载项目后,必须使用npm install
命令安装包要求。 由于服务分散在许多文件夹中,您必须分别安装每个服务。
理解 Docker 和微服务
因为我们正在构建一个使用 Docker 容器中托管的微服务的系统,所以我们需要事先了解一些术语和理论。
在这一节中,我们将先看看常见的 Docker 术语及其含义,然后再看看什么是微服务,它们要解决什么问题,以及如何将单片应用分解为更模块化的服务。
码头工人的术语
如果您是 Docker 的新手,那么您将会遇到大量与 Docker 相关的术语。 了解这些术语将在我们设置服务器时有所帮助,所以让我们从基础开始。
容器
如果你在互联网上看过任何 Docker 文献,这可能是你遇到的一个术语。 容器是一个正在运行的实例,它包含了运行应用所需的各种软件。 这是我们的起点。 容器是从映像构建的,您可以自己构建或从中央 Docker 数据库下载映像。 容器可以向其他容器、主机操作系统,甚至使用端口和卷向更广泛的世界开放。 容器的一大卖点是,它们易于设置和创建,并且可以快速停止和启动。
图像
正如我们在前一段中所述,容器开始时是一个图像。 已经有大量的图像可供使用,但我们也可以创建自己的图像。 当我们创建映像时,创建步骤会被缓存,以便可以轻松地重用它们。
港口
这个你们应该已经很熟悉了。 在 Docker 中,端口的含义与端口在操作系统中的含义完全相同。 这些是对主机操作系统可见的 TCP 或 UDP 端口,或者连接到外部世界的端口。 当应用在内部使用相同的端口号,但使用不同的端口号对外公开时,我们将在本章的后面介绍一些有趣的代码。
体积
可视化卷的最简单方法是它类似于共享文件夹。 当创建容器时,将初始化卷,并允许我们持久化数据,而不管容器的生命周期如何。
注册表
实际上,注册表可以被视为 Docker 世界的 App Store。 它存储了可以下载的 Docker 图像,本地图像可以被推回注册表,类似于将应用推到 app Store。
码头工人中心
Docker Hub 是原始的 Docker 注册表,由 Docker 自己提供。 这个注册表存储了大量的 Docker 映像,其中一些来自 Docker,一些是由软件团队为它们构建的。
In this chapter, we aren't going to cover installing Docker, as installing it and setting it up is a chapter in its own right, especially since installing Docker on Windows is a different experience to installing Docker on macOS or Linux. The commands that we will use to compose Docker applications and check the state of instances don't change though, so we will cover them as and when they are needed.
微服务
在企业软件领域,很难不听到“微服务”这个词。 这是一种将所谓的单片系统分解为服务集合的架构风格。 这种体系结构的本质是服务是紧密的范围和可测试的。 服务应该是松散耦合的,以限制它们之间的依赖关系——应该由最终应用将这些服务组合在一起。 这种松散耦合促进了这样一种思想,即它们可以独立部署,而且服务通常紧密关注于业务功能。
尽管我们可能会从寻求销售服务的营销大师和咨询师那里听到,微服务并不总是应用的合适选择。 有时候,使用单片应用更好。 如果我们不能使用前一段所述的所有想法来分解应用,那么很有可能该应用不适合微服务。
与我们到目前为止在本书中讨论的许多内容(如模式)不同,微服务并没有一个官方认可的定义。 你不能按照清单说,这是一个微服务,因为它在做 a、b 和 c。 相反,关于什么构成了微服务的共识已经演变成一系列特征,这些特征是基于对什么有效什么无效的观察。 就我们的目的而言,构成微服务的重要属性包括:
- 该服务可以独立于其他微服务部署。 换句话说,该服务不依赖于其他微服务。
- 该服务基于业务流程。 微服务是细粒度的,因此围绕单个业务领域组织它们有助于从小型的、集中的组件中创建大规模的应用。
- 不同服务的语言和技术可能不同。 这使我们有机会在必要时利用最好和最合适的技术。 例如,我们可能有一个内部托管的服务,而另一个服务可能托管在云服务(如 Azure)中。
- 服务的规模应该很小。 这并不意味着它不应该有很多代码; 相反,它意味着它只专注于一个领域。
使用 Swagger 设计我们的 REST API
在开发 rest 驱动的应用时,我发现使用 Swagger(https://swagger.io)的工具非常有用。 Swagger 有许多特性,这些特性使它成为我们创建 API 文档、为 API 创建代码和测试 API 时的首选工具。
我们将使用 Swagger UI 来原型检索人员列表的能力。 由此,我们可以生成与 API 一起使用的文档。 虽然我们可以从这个生成代码,我们将使用的工具可以看到形状最终 REST 调用将,我们将使用滚自己的实现使用我们先前创建的数据模型。 我喜欢这样做的原因有两个。 首先,我喜欢制作小型、干净的数据模型,我发现原型给了我模型的可视化。 其次,生成的代码很多——非常多——而且我发现,当我自己编写代码时,将我的数据模型与数据库绑定起来更容易。
在本章中,我们将自己编写代码,但我们将使用 Swagger 来原型我们想要交付的内容。
我们要做的第一件事就是签入 Swagger
- 从主页上,单击 Sign In。 这将弹出一个对话框,询问我们想要登录哪个产品,即 SwaggerHub 或 Swagger Inspector。 Swagger Inspector 是一个伟大的工具,用于测试我们的 API,但由于我们将开发 API,我们将登录到 SwaggerHub。 下面的截图显示了它的外观:
- 如果你没有 Swagger 帐户,你可以通过注册或使用 GitHub 帐户从这里创建一个。 为了创建一个 API,我们需要选择 create New > create New API。 在“模板”下拉框中选择“无”,按如下参数填写:
- 在这个阶段,我们已经准备好开始填充 API 了。 我们从盒子里得到的是:
swagger: '2.0'
info:
version: '1.0'
title: 'Advanced TypeScript 3 - CRM'
description: ''
paths: {}
# Added by API Auto Mocking Plugin
host: virtserver.swaggerhub.com
basePath: /user_id/AdvancedTypeScript3CRM/1.0
schemes:
- https
让我们开始构建这个 API。 首先,我们要创建 API 路径的起点。 我们需要创建的任何路径都位于paths
节点下。 在我们构建 API 时,Swagger 编辑器会对输入进行验证,所以当我们在填充时,如果出现验证错误,不要担心。 在我们的示例中,我们将创建一个 API 来检索我们添加到数据库中的所有人的数组。 因此,我们从这个 API 端点开始,它取代了paths: {}
行:
paths:
/people:
get:
summary: "Retrieves the list of people from Firebase"
description: Returns a list of people
因此,我们已经说过,我们的 REST 调用将使用一个GET
动词发出。 我们的 API 将返回两个状态,HTTP 200
和HTTP 400
。 让我们通过用这些状态填充一个responses
节点来提供这个操作的开始。 当我们返回一个400
错误时,我们需要创建一个模式来定义我们将通过网络返回的内容。 schema
返回一个包含单个message
字符串的object
,如下所示:
responses:
200:
400:
description: Invalid request
schema:
type: object
properties:
message:
type: string
因为我们的 API 将返回一个人员数组,所以我们的模式类型是array
。 组成 person 的items
映射回我们在服务器代码中讨论的模型。 因此,通过填写我们的schema
作为200
响应,我们得到:
description: Successfully returned a list of people
schema:
type: array
items:
type: object
properties:
ServerID:
type: string
FirstName:
type: string
LastName:
type: string
Address:
type: object
properties:
Line1:
type: string
Line2:
type: string
Line3:
type: string
Line4:
type: string
PostalCode:
type: string
ServerID:
type: string
这是我们的schema
在编辑器中的样子:
既然我们已经看到了如何使用 Swagger 来原型我们的 api,我们就可以继续我们想要构建的项目的定义了。
使用 Docker 创建微服务应用
我们要写的项目是 CRM 系统的一小部分,用来维护客户的详细信息,并为这些客户添加线索。 应用的工作方式是用户创建地址; 当他们添加关于联系人的详细信息时,他们将从他们已经创建的地址列表中选择地址。 最后,他们可以利用已经添加的联系人来创建线索。 这个系统背后的想法是,在此之前,应用使用一个大数据库来存储这些信息——我们将把它分解成三个离散的服务。
与 GitHub 代码一起工作,本章需要大约 3 个小时才能完成。 完成后,申请应该如下所示:
做完这些之后,我们将继续前进,看看如何为 Docker 创建应用,以及这如何补充我们的项目。
开始使用 Docker 创建微服务应用
在这一章中,我们将回归 React。 除了使用 React,我们还将使用 Firebase 和 Docker,托管 Express 和 Node。 React 应用和 Express 微服务之间的 REST 通信将使用 Axios 完成。
如果您正在使用 Windows 10 进行开发,请安装 Docker Desktop for Windows,可以在这里获得:https://hub.docker.com/editions/community/docker-ce-desktop-windows。
In order to run Docker on Windows, you need to have Hyper-V virtualization installed.
如果您想在 macOS 上安装 Docker Desktop,请访问https://hub.docker.com/editions/community/docker-ce-desktop-mac。
Docker Desktop on Mac runs on OS X Sierra 10.12 and newer macOS releases.
我们将要构建的 CRM 应用演示了我们如何将许多微服务整合到一个内聚的应用中,这样终端用户就不会意识到我们的应用正在处理来自大量数据源的信息。
我们的申请要求如下:
- CRM 系统将提供输入地址的功能。
- 该系统将允许用户输入一个人的详细信息。
- 当输入一个人的详细信息时,用户可以选择先前输入的地址。
- 该系统将允许用户输入潜在客户的详细信息。
- 数据将被保存到云数据库中。
- 人员、领导和地址信息将从不同的服务中检索。
- 这些独立的服务将由 Docker 托管。
- 我们的用户界面将被创建为 React 系统。
我们一直在努力实现应用的功能共享。 我们的微服务将通过共享尽可能多的公共代码,然后添加他们需要的小块和小块来定制他们获取和返回给客户端的数据,将这种方法提升到下一个层次。 我们可以这样做,因为我们的服务的需求是相似的,所以它们可以共享许多公共代码。
我们的微服务应用从一个单片应用的角度出发。 该应用的人员、地址和领导都由一个系统管理。 我们将用它应得的蔑视来对待这个单一的应用,并将它分解成更小的、离散的块,其中每个组成部分都与其他部分隔离开来。 在这里,线索、地址和人都存在于他们自己独立的服务中。
我们将从tsconfig
文件开始。 在之前的章节中,每个章节都有一个服务,只有一个tsconfig
文件。 我们将在这里混合使用一个根级别tsconfig.json
文件。 我们的服务都将以此为基础:
- 让我们从创建一个名为
Services
的文件夹开始,它将作为我们的服务的基础。 在此基础上,我们将创建单独的Addresses
、Common
、Leads
和People
文件夹,以及基本的tsconfig
文件。 - 当我们完成这一步时,我们的
Services
文件夹应该如下所示:
- 现在,让我们添加
tsconfig
设置。 这些设置将被我们将要托管的所有服务共享:
{
"compileOnSave": true,
"compilerOptions": {
"target": "es5",
"module": "commonjs",
"removeComments": true,
"strict": true,
"esModuleInterop": true,
"inlineSourceMap": true,
"experimentalDecorators": true,
}
}
您可能已经注意到,我们还没有在这里设置输出目录。 我们把这个留到后面。 在进入这一步之前,我们将开始添加微服务所共享的通用功能。 我们的共享功能将被添加到Common
文件夹中。 我们将要添加的一些内容看起来应该非常熟悉,因为我们在前面的章节中构建了类似的服务器代码。
我们的服务将保存到 Firebase,因此我们将从编写数据库代码开始。 我们需要安装的npm
包与 Firebase 一起工作是firebase
和@types/firebase
。 当我们添加这些时,我们还应该导入我们之前安装的guid-typescript
和cors
、express
基本节点包。
当每个服务将数据保存到数据库时,它将以相同的基本结构开始。 我们将使用 GUID 设置一个ServerID
。 我们将要使用的基本模型是这样开始的:
export interface IDatabaseModelBase {
ServerID: string;
}
我们将创建一个abstract
基类,它将与IDatabaseModelBase
实例一起工作,使我们能够Get
一个记录,GetAll
一个记录,Save
一个记录。 使用 Firebase 的美妙之处在于,虽然它是一个功能强大的系统,但我们完成这些任务所需编写的代码非常短。 让我们从类定义开始:
export abstract class FirestoreService<T extends IDatabaseModelBase> {
constructor(private collection: string) { }
}
如您所见,我们的类是通用的,它告诉我们每个服务将扩展IDatabaseModelBase
并在其特定的数据库实现中使用它。 集合是将在 Firebase 中编写的集合的名称。 出于我们的目的,我们将共享一个 Firebase 实例来存储不同的集合,但我们的体系结构的优点在于,如果我们不想这样做,我们不需要这样做。 如果需要,我们可以使用单独的 Firebase 商店; 事实上,在生产环境中通常会发生这种情况。
如果我们没有保存任何数据,添加我们的GET
方法是没有意义的,所以我们要做的第一件事是写我们的Save
方法。 不出所料,我们的Save
方法将是异步的,所以它将返回一个Promise
:
public Save(item: T): Promise<T> {
return new Promise<T>(async (coll) => {
item.ServerID = Guid.create().toString();
await firebase.firestore().collection(this.collection).doc(item.ServerID).set(item);
coll(item);
});
}
看起来有些奇怪的是async (coll)
代码。 因为我们使用了胖箭头(=>
),所以我们创建了一个简化的函数。 由于这是一个函数,我们向它添加了async
关键字,以表明代码中可以包含await
。 如果不标记为async
,我们就不能使用里面的await
了。
在调用一系列方法来设置数据之前,我们的代码分配了一个 GUID 给ServerID
。 让我们把代码分成小块来处理,看看每一个比特做什么。 正如我们在第 7 章、、中讨论的,使用 Firebase的 Angular 基于云的映射,Firebase 提供的不仅仅是数据库服务,所以我们需要做的第一件事就是访问数据库部分。 如果我们在这里不遵循方法链接,我们可以这样写:
const firestore: firebase.firestore.Firestore = firebase.firestore();
在 Firestore 中,数据不是保存在表中,而是保存在命名集合中。 一旦有了firestore
,我们就有了CollectionReference
。 按照前面的代码片段,我们可以将其重写如下:
const collection: firebase.firestore.CollectionReference = firestore.collection(this.collection);
一旦我们有了CollectionReference
,我们就可以使用前面方法中设置的ServerID
访问单个文档。 如果我们不提供自己的 ID,将为我们创建一个:
const doc: firebase.firestore.DocumentReference = collection.doc(item.ServerID);
现在,我们需要设置要写入数据库的数据:
await doc.set(item);
这将把数据保存到 Firestore 中适当的集合中的文档中。 我必须承认,虽然我喜欢键入这样可以分解的代码的能力,但方法链接意味着如果有的话,我很少这样做。 在链中的下一个步骤逻辑遵循从上一步,我会经常在一起链的方法,因为你不能进入下一个步骤没有穿过前面的步骤,它让我想象的顺序步骤如果我看到他们联系在一起。
一旦项目保存到数据库中,我们将返回保存的项目,并完成ServerID
,返回调用代码,以便它可以立即使用。 这就是这句话的出处:
coll(item);
我们的FirestoreService
的下一步是添加GET
方法。 这个方法与Save
方法一样,是一个async
方法,它返回一个封装在 promise 中的T
类型的单个实例。 因为我们知道 ID,所以我们的 Firestore 代码的绝大多数是相同的。 不同之处在于,我们调用get()
,然后使用它返回数据:
public async Get(id: string): Promise<T> {
const qry = await firebase.firestore().collection(this.collection).doc(id).get();
return <T>qry.data();
}
你猜怎么着? 我们还需要写入一个async GetAll
方法,这次返回一个T
数组。 因为我们想要检索多个记录,而不是单个文档,所以我们在collection
上调用get()
。 一旦我们有了这些记录,我们就使用一个简单的forEach
来构建需要返回的数组:
public async GetAll(): Promise<T[]> {
const qry = await firebase.firestore().collection(this.collection).get();
const items: T[] = new Array<T>();
qry.forEach(item => {
items.push(<T>item.data());
});
return items;
}
数据库代码就绪后,让我们看看实际情况。 我们将在Addresses
服务中创建一个扩展IDatabaseModelBase
的IAddress
接口:
export interface IAddress extends IDatabaseModelBase {
Line1 : string,
Line2 : string,
Line3 : string,
Line4 : string,
PostalCode : string
}
有了IAddress
之后,我们现在可以创建一个类,将我们的服务绑定到将要存储在 Firebase 中的addresses
集合。 我们付出了所有的努力,我们的AddressesService
就是这么简单:
export class AddressesService extends FirestoreService<IAddress> {
constructor() {
super('addresses');
}
}
您可能想知道用于数据模型和数据库访问的代码是否与其他微服务一样简单。 让我们看看我们的People
接口和数据库服务是什么样的:
export interface IPerson extends IDatabaseModelBase {
FirstName: string;
LastName: string;
Address: IAddress;
}
export class PersonService extends FirestoreService<IPerson> {
constructor() {
super('people');
}
}
您可能还想知道为什么我们将地址信息存储在IPerson
中。 我们很容易认为应该只引用地址而不是重复数据,特别是如果您从关系数据库的角度来使用 NoSQL 体系结构,其中记录通过外键链接在一起以创建pointers
到关系。 老式的SQL 数据库使用外部表来最小化记录中的冗余,这样我们就不会创建跨多个记录共享的重复数据。 虽然这是一件有用的事情,但它确实使查询和检索记录变得更加复杂,因为我们感兴趣的信息可能分散在几个表中。 通过将地址存储在人的旁边,我们减少了需要查询的表的数量,从而建立了人的信息。 这是基于这个想法,我们想查询记录通常远比我们想要改变他们,如果我们需要改变地址,我们将改变主地址,然后一个单独的查询将贯穿所有的人记录寻找地址,需要更新。 我们将实现这一点,因为人记录的地址部分中的ServerID
记录将匹配主地址中的ServerID
。
我们不会介绍Leads
数据库代码; 你可以在源代码中阅读它,它实际上和这个是一样的。 我们所做的就是让我们的微服务,在功能上,非常相似,这样我们就能以一种简单的方式利用继承。
添加服务器端路由支持
除了拥有使用数据库的通用方式外,我们的传入 API 请求在端点方面都将非常相似。 在写这本书的时候,我试着把以后可以重用的代码片段组合在一起。 其中一个片段就是我们处理 Express 路由的方式。 服务器端代码我们放在了第四章,The MEAN Stack - Building a Photo Gallery,就是这样一个区域,特别是路由代码。 我们可以把这段代码和之前写的一模一样。
下面是代码的快速提示。 首先,我们有我们的IRouter
界面:
export interface IRouter {
AddRoute(route: any): void;
}
然后,我们有了我们的路由引擎——我们将直接插入服务器的代码:
export class RoutingEngine {
constructor(private routing: IRouter[] = new Array<IRouter>()) {
}
public Add<T1 extends IRouter>(routing: (new () => T1), route: any) {
const routed = new routing();
routed.AddRoute(route);
this.routing.push(routed);
}
}
那么,实际情况是怎样的呢? 这是用来保存客户端发送过来的地址的代码。 当我们从客户端接收到一个/add/
请求时,我们从请求体中提取细节并将其转换为IAddress
,然后将其保存到地址服务中:
export class SaveAddressRouting implements IRouter {
AddRoute(route: any): void {
route.post('/add/', (request: Request, response: Response) => {
const person: IAddress = <IAddress>{...request.body};
new AddressesService().Save(person);
response.json(person);
});
}
}
获取地址的代码非常相似。 我们不打算详细分析这个方法,因为它现在看起来应该很熟悉了:
export class GetAddressRouting implements IRouter {
AddRoute(route: any): void {
route.get('/get/', async (request: Request, response: Response) => {
const result = await new AddressesService().GetAll();
if (result) {
response.json(result);
}
response.send('');
});
}
}
Leads
和People
业务的代码实际上是相同的。 请阅读我们 GitHub 存储库中的代码来熟悉它。
Server 类
又继续尽可能地重用代码的主题,我们将用稍微的修改版本表达Server
类我们写道,在第四章,意味着栈——建立一个相册。 同样,我们将快速浏览代码以重新熟悉它。 首先,让我们将类定义和构造函数放置到位。 我们的构造函数是一个从第四章,的构造函数的瘦身版本
export abstract class Server {
constructor(private port: number = 3000, private app: any = express(), protected routingEngine: RoutingEngine = new RoutingEngine()) {}
}
}
我们还想添加 CORS 支持。 虽然我们可以强制这样做,但我仍然喜欢这样的想法,即我们可以将是否想要这样做的控制权交给服务开发人员,所以我们将保留这一public
方法:
public WithCorsSupport(): Server {
this.app.use(cors());
return this;
}
为了让我们实际的服务器实现工作,我们需要给它们添加路由的能力。 我们通过AddRouting
方法来实现:
protected AddRouting(router: Router): void {
}
现在我们有了AddRouting
方法,我们需要适当的代码来启动服务器:
public Start(): void {
this.app.use(bodyParser.json());
this.app.use(bodyParser.urlencoded({extended:true}));
const router: Router = express.Router();
this.AddRouting(router);
this.app.use(router);
this.app.listen(this.port, ()=> console.log(`logged onto server at ${this.port}`));
}
你可能已经注意到,我们漏掉了这个谜题的一个重要部分。 我们的服务器中没有适当的数据库支持,但是我们的服务需要初始化 Firebase。 在我们的服务器中,我们添加了以下内容:
public WithDatabase(): Server {
firebase.initializeApp(Environment.fireBase);
return this;
}
请注意,我没有在存储库中包含Environment.fireBase
,因为它包含我使用的服务器和密钥的详细信息。 这是一个包含 Firebase 连接信息的常量。 您可以用在云中创建 Firebase 数据库时设置的连接信息来替换它。 要添加这个,你需要在Common
文件夹中创建一个名为Environment.ts
的文件,其中包含如下代码:
export const Environment = {
fireBase: {
apiKey: <<add your api key here>>,
authDomain: "advancedtypescript3-containers.firebaseapp.com",
databaseURL: "https://advancedtypescript3-containers.firebaseio.com",
projectId: "advancedtypescript3-containers",
storageBucket: "advancedtypescript3-containers.appspot.com",
messagingSenderId: <<add your sender id here>>
}
}
创建地址服务
现在我们拥有创建实际服务所需的一切。 在这里,我们将研究Addresses
服务,了解其他服务将遵循相同的模式。 既然我们已经有了数据模型、数据访问代码和路由,我们所要做的就是创建我们实际的AddressesServer
类。 AddressesServer
类就像这样简单:
export class AddressesServer extends Server {
protected AddRouting(router: Router): void {
this.routingEngine.Add(GetAddressRouting, router);
this.routingEngine.Add(SaveAddressRouting, router);
}
}
我们像这样启动服务器:
new AddressesServer()
.WithCorsSupport()
.WithDatabase().Start();
代码就是这么简单。 我们正在遵循一个原则,叫做不要重复自己(DRY)。 这只是说明您应该以尽可能少的代码重新键入为目标。 换句话说,您应该尽量避免在代码库中散布具有完全相同功能的代码。 有时候,你不能避免它,有时,它没有意义去创造很多代码脚手架的麻烦一或两行代码,但是当你拥有庞大的功能区域,你应该试着避免代码的复制粘贴成多个部分。 这样做的部分原因是,如果你复制粘贴了代码,然后在其中发现了一个错误,你将不得不在多个地方修复这个错误。
使用 Docker 运行我们的服务
当我们看我们的服务时,我们可以看到我们有一个有趣的问题; 也就是说,它们都使用相同的端口启动。 显然,我们不能为每个服务使用相同的端口,所以我们是否给自己造成了问题? 这是否意味着我们不能启动多个服务?如果是这样,这是否会破坏我们的微服务体系结构,意味着我们应该回到单一的服务?
考虑到我们刚刚讨论的潜在问题,以及本章介绍 Docker 的事实,Docker 是解决这个问题的答案就不足为奇了。 使用 Docker,我们可以启动一个容器,将代码部署到其中,并使用不同的端点公开服务。 那么,我们该怎么做呢?
在每个服务中,我们将添加一些常见的文件:
node_modules
npm-debug.log
第一个文件,称为.dockerignore
,选择在将文件复制或添加到容器中时忽略哪些文件。
我们要添加的下一个文件名为Dockerfile
。 这个文件描述了 Docker 容器以及如何构建它。 Dockerfile
通过构建指令层来工作,这些指令层代表着向构建容器迈进的一步。 第一层下载并安装 Node 到容器中,特别是 Node version 8:
FROM node:8
下一层用于设置默认工作目录。 后续命令:RUN
、COPY
、ENTRYPOINT
、CMD
、ADD
:
WORKDIR /usr/src/app
在一些在线资源中,您将看到人们创建自己的目录作为工作目录。 最好使用预定义的、众所周知的位置,如/usr/src/app
作为WORKDIR
。
既然我们现在已经有了一个工作目录,我们就可以开始设置代码了。 我们想复制必要的文件下载和安装我们的npm
包:
COPY package*.json ./
RUN npm install
作为一个好的实践,我们在复制代码之前复制package.json
和package-lock.json
文件,因为安装会缓存安装的内容。 只要我们不改变package.json
文件,如果再次构建代码,我们就不需要重新下载软件包。
所以,我们已经安装了我们的包,但是我们没有任何代码。 让我们将本地文件夹的内容复制到工作目录中:
COPY . .
我们想要将服务器端口公开给外界,所以现在让我们添加这个层:
EXPOSE 3000
最后,我们希望启动服务器。 为此,我们想要触发npm start
:
CMD [ "npm", "start" ]
As an alternative to running CMD["npm", "start"]
, we can bypass npm
altogether and use CMD ["node", "dist/server.js"]
(or whatever the server code is called). The reason we might want to consider doing this is that running npm
starts the npm
process, which then starts our server process, so using Node directly reduces the number of services that are running. Also, npm
has a habit of silently consuming process exit signals, so Node has no idea that the process has exited unless npm
tells it.
现在,如果我们想启动地址服务,例如,我们从命令行运行以下命令:
docker build -t ohanlon/addresses .
docker run -p 17171:3000 -d ohanlon/addresses
第一行使用Dockerfile
构建容器映像,并给它一个标签,以便我们可以在 Docker 容器中识别它。
构建映像之后,下一个命令将运行安装并将容器端口发布到主机。 这个技巧是使我们的服务器代码工作的魔法——它将内部端口3000
暴露给外部世界为17171
。 注意,在这两种情况下,我们都使用了ohanlon/addresses
来将容器映像绑定到将要运行的容器映像(您可以使用任何您想要的名称来替换这个名称)。
-d
标志代表分离,这意味着容器在后台静默运行。 这允许我们启动服务,避免命令行绑定。
如果您想找到可用的映像,可以运行docker ps
命令。
使用 docker-compose 来组合和启动服务
与使用docker build
和docker run
运行图像不同,我们使用了docker-compose
来组合和运行多个容器。 使用 Docker 组合,我们可以从多个 Docker 文件或完全通过一个名为docker-compose.yml
的文件创建容器。
我们将使用docker-compose.yml
和我们在上一节中创建的 Docker 文件的组合来创建一个可以轻松运行的组合。 在服务器代码的根目录中,创建一个名为docker-compose.yml
的空白文件。 我们将从指定文件遵循的 compose 格式开始。 在我们的例子中,我们将它设置为2.1
:
version: '2.1'
我们将在容器中创建三个服务,所以让我们从定义服务本身开始:
services:
chapter08_addresses:
chapter08_people:
chapter08_leads:
现在,每个服务都由离散的信息组成,第一部分详细介绍了我们想要使用的构建信息。 该信息位于构建节点之下,由上下文(映射到服务所在的目录)和 Docker 文件组成,Docker 文件定义了如何构建容器。 我们可以选择设置NODE_ENV
参数来标识节点环境,我们将把它设置为production
。 谜题的最后一个部分映射回docker run
命令,我们在其中设置了移植映射; 每个服务可以设置自己的ports
映射。 这是在chapter08_addresses
下的节点的样子:
build:
context: ./Addresses
dockerfile: ./Dockerfile
environment:
NODE_ENV: production
ports:
- 17171:3000
当我们把这些放在一起,我们的docker-compose.yml
文件看起来像这样:
version: '2.1'
services:
chapter08_addresses:
build:
context: ./Addresses
dockerfile: ./Dockerfile
environment:
NODE_ENV: production
ports:
- 17171:3000
chapter08_people:
build:
context: ./People
dockerfile: ./Dockerfile
environment:
NODE_ENV: production
ports:
- 31313:3000
chapter08_leads:
build:
context: ./Leads
dockerfile: ./Dockerfile
environment:
NODE_ENV: production
ports:
- 65432:3000
Before we can start the processes, we must compile our microservices. Docker is not responsible for building the application, so it is our responsibility to do this before we attempt to compose our service.
现在我们有多个容器,可以使用一个 compose 文件一起启动。 为了运行我们的 compose 文件,我们使用docker-compose up
命令。 当所有容器都已启动时,我们可以使用docker ps
命令来验证它们的状态,该命令会给出以下输出:
现在我们已经完成了服务器端代码。 我们已经具备了创建微服务所需的一切条件。 我们现在要做的是创建与我们的服务交互的用户界面。
创建 React 用户界面
我们已经花了很多时间来构建 Angular 应用,所以我们应该回过头来构建一个 React 应用。 就像 Angular 可以使用 Express 和 Node 一样,React 也可以使用它们,因为我们已经有了 Express/Node 端,现在我们要创建 React 客户端。 我们将从创建支持 TypeScript 的 React 应用的命令开始:
npx create-react-app crmclient --scripts-version=react-scripts-ts
这将创建一个标准的 React 应用,我们将对其进行修改以满足我们的需要。 我们需要做的第一件事是引入对 Bootstrap 的支持,这次使用的是react-bootstrap
包。 当我们这样做的时候,我们也可以安装以下依赖项:react-table
,@types/react-table
,react-router-dom
,@types/react-router-dom
和axios
。 我们将在本章中使用这些,所以现在安装它们将节省一些时间。
Throughout this book, we have been using npm
to install dependencies, but this isn't the only option available to us. npm
has the advantage of being the default package manager for Node (it is called Node Package Manager, after all), but Facebook introduced its own package manager back in 2015, called Yarn. Yarn was created to address issues in the version of npm
that existed at the time. Yarn uses its own set of lock files, instead of the default package*.lock
that npm
uses. Which one you use really depends on your personal preferences and evaluating whether the features they provide are something you need. For our purposes, npm
is a suitable package manager, so that's what we will continue to use.
使用 Bootstrap 作为容器
我们希望使用 Bootstrap 来呈现整个显示。 幸运的是,这是一项微不足道的任务,它围绕着对我们的App
组件所做的一些小修改而展开。 为了呈现我们的显示,我们将把内容包装在一个容器中,像这样:
export class App extends React.Component {
public render() {
return (
<Container fluid={true}>
<div />
</Container>
);
}
}
现在,当我们渲染内容时,它将自动在一个扩展到整个页面宽度的容器中进行渲染。
创建选项卡式用户界面
在添加导航元素之前,我们将创建用户单击其中一个链接时链接到的组件。 我们将从AddAddress.tsx
开始,我们将向其中添加代码以添加地址。 我们从添加类定义开始:
export class AddAddress extends React.Component<any, IAddress> {
}
我们的组件的默认状态是一个空的IAddress
,所以我们为它添加了定义,并将组件的状态设置为默认状态:
private defaultState: Readonly<IAddress>;
constructor(props:any) {
super(props);
this.defaultState = {
Line1: '',
Line2: '',
Line3: '',
Line4: '',
PostalCode: '',
ServerID: '',
};
const address: IAddress = this.defaultState;
this.state = address;
}
在添加代码以呈现表单之前,我们需要添加两个方法。 您可能还记得上次 React 的内容,我们了解到,如果用户改变了显示中的任何内容,我们必须显式地更新状态。 就像上次一样,我们将编写一个UpdateBinding
事件处理程序,当用户更改显示上的任何值时,我们将调用它。 我们将看到这个模式在所有的Add*xxx*
组件中重复。 作为一个刷新器,ID 告诉我们用户正在更新哪个字段,然后我们使用该字段在状态中用更新值设置适当的字段。 给定这个信息,我们的event
处理器看起来像这样:
private UpdateBinding = (event: any) => {
switch (event.target.id) {
case `address1`:
this.setState({ Line1: event.target.value});
break;
case `address2`:
this.setState({ Line2: event.target.value});
break;
case `address3`:
this.setState({ Line3: event.target.value});
break;
case `address4`:
this.setState({ Line4: event.target.value});
break;
case `zipcode`:
this.setState({ PostalCode: event.target.value});
break;
}
}
我们需要添加的另一个支持方法是触发对地址服务的 REST 调用。 我们将使用 Axios 包将一个POST
请求传输到添加地址端点。 Axios 为我们提供了基于承诺的 REST 调用,因此,例如,我们可以发出调用并在继续处理之前等待它返回。 我们将在这里选择一个简单的代码模型,并以“发射然后忘记”的方式发送我们的请求,这样我们就不必等待任何结果返回。 为了简单起见,我们将立即重置 UI 的状态,以便用户添加另一个地址。
现在,我们已经添加了这些方法,我们将编写我们的render
方法。 定义如下:
public render() {
return (
<Container>
</Container>
);
}
元素的Container
映射回我们在 Bootstrap 中使用的良好的旧容器类。 这里缺少的是实际的输入元素。 每一项输入都分组在Form.Group
内,这样我们就可以添加Label
和Control
,像这样:
<Form.Group controlId="formGridAddress1">
<Form.Label>Address</Form.Label>
<Form.Control placeholder="First line of address" id="address1" value={this.state.Line1} onChange={this.UpdateBinding} />
</Form.Group>
作为另一个提醒,绑定的当前值在单向绑定中呈现给我们的显示,用value={this.state.Line1}
表示,用户的任何输入都通过UpdateBinding
事件处理程序触发对状态的更新。
我们添加的保存状态的Button
代码如下所示:
<Button variant="primary" type="submit" onClick={this.Save}>
Submit
</Button>
把所有这些放在一起,这就是我们的render
方法的样子:
public render() {
return (
<Container>
<Form.Group controlId="formGridAddress1">
<Form.Label>Address</Form.Label>
<Form.Control placeholder="First line of address" id="address1" value={this.state.Line1} onChange={this.UpdateBinding} />
</Form.Group>
<Form.Group controlId="formGridAddress2">
<Form.Label>Address 2</Form.Label>
<Form.Control id="address2" value={this.state.Line2} onChange={this.UpdateBinding} />
</Form.Group>
<Form.Group controlId="formGridAddress2">
<Form.Label>Address 3</Form.Label>
<Form.Control id="address3" value={this.state.Line3} onChange={this.UpdateBinding} />
</Form.Group>
<Form.Group controlId="formGridAddress2">
<Form.Label>Address 4</Form.Label>
<Form.Control id="address4" value={this.state.Line4} onChange={this.UpdateBinding} />
</Form.Group>
<Form.Group controlId="formGridAddress2">
<Form.Label>Zip Code</Form.Label>
<Form.Control id="zipcode" value={this.state.PostalCode} onChange={this.UpdateBinding}/>
</Form.Group>
<Button variant="primary" type="submit" onClick={this.Save}>
Submit
</Button>
</Container>
)
}
那么,这段代码一切正常吗? 哦,不,有一个小问题,在Save
代码。 如果用户单击该按钮,则不会将任何内容保存到数据库中,因为该状态在Save
方法中不可见。 当我们执行onClick={this.Save}
时,我们正在给Save
方法分配一个回调。 内部发生的情况是,this
上下文丢失了,所以我们不能使用它来获取状态。 现在,我们有两个解决方案; 我们已经见过很多次了,那就是使用粗箭头=>
来捕捉上下文,这样我们的方法就可以处理它。
解决这个问题的另一种方法(也是我们故意编写Save
方法不使用 fat 箭头的原因,这样我们就可以在操作中看到这个方法)是向构造函数中添加以下代码来绑定上下文:
this.Save = this.Save.bind(this);
这就是我们要添加地址的代码。 我希望你会同意这段代码足够简单; 人们一次又一次地创建不必要的复杂代码,通常情况下,简单是一个更有吸引力的选择。 我非常喜欢让代码尽可能简单。 为了给其他开发人员留下深刻印象,这个行业有一个习惯,就是把代码弄得比实际需要的更复杂。 我敦促人们避免这种诱惑,因为干净的代码更令人印象深刻。
我们用于管理地址的用户界面是选项卡,因此我们有一个选项卡负责添加地址,而另一个选项卡显示一个网格,其中包含我们当前添加的所有地址。 现在是时候添加选项卡和网格代码了。 我们将创建一个名为addresses.tsx
的新组件,它将为我们完成这个任务。
同样,我们从创建类开始。 这一次,我们将把state
设置为一个空数组。 我们这样做是因为我们将在稍后从我们的地址微服务填充它:
export default class Addresses extends React.Component<any, any> {
constructor(props:any) {
super(props);
this.state = {
data: []
}
}
}
为了从我们的微服务加载数据,我们需要一个方法来处理它。 我们将再次使用 Axios,但这次我们将使用 promise 特性来设置从服务器返回时的状态:
private Load(): void {
axios.get("http://localhost:17171/get/").then(x =>
{
this.setState({data: x.data});
});
}
现在的问题是,我们什么时候调用Load
方法? 我们不想在构造函数期间获取状态,因为这会减慢组件的构造,所以我们需要另一个点来检索该数据。 答案就在 React 组件的生命周期中。 组件在创建时要经过几个方法。 它们经过的顺序如下:
constructor();
getDerivedStateFromProps();
render();
componentDidMount();
我们要实现的效果是使用render
显示组件,然后使用 binding 更新表中显示的值。 这告诉我们我们想要在componentDidMount
中加载我们的状态:
public componentWillMount(): void {
this.Load();
};
我们确实有另一个触发更新的潜在点。 如果用户添加了一个地址,然后将选项卡切换回显示表的选项卡,我们将希望自动检索更新的地址列表。 让我们添加一个方法来处理这个问题:
private TabSelected(): void {
this.Load();
}
是时候添加我们的render
方法了。 为了简单起见,我们将分两个阶段进行添加; 一是加入Tab
和AddAddress
组件。 在第二阶段,我们将添加Table
。
添加选项卡需要我们引入ReactifiedBootstrap 选项卡组件。 在我们的render
方法中,添加以下代码:
return (
<Tabs id="tabController" defaultActiveKey="show" onSelect={this.TabSelected}>
<Tab eventKey="add" title="Add address">
<AddAddress />
</Tab>
<Tab eventKey="show" title="Addresses">
<Row>
</Row>
</Tab>
</Tabs>
)
我们有一个Tabs
组件,它包含两个单独的Tab
项目。 每个选项卡都有一个eventKey
,我们可以使用它来设置默认的活动键(在本例中,我们将其设置为show
)。 当一个选项卡被选中时,我们触发数据的加载。 我们将看到我们的AddAddress
组件已添加到Add Address
选项卡中。
我们剩下要做的就是添加一个表,我们将用它来显示地址列表。 我们将创建一个列的列表,我们想要显示在表中。 我们使用下面的语法来创建列列表,其中Header
是将显示在列顶部的标题。 accessor
告诉 React 从数据行中选取什么属性:
const columns = [{
Header: 'Address line 1',
accessor: 'Line1'
}, {
Header: 'Address line 2',
accessor: 'Line2'
}, {
Header: 'Address line 3',
accessor: 'Line4'
}, {
Header: 'Address line 4',
accessor: 'Line4'
}, {
Header: 'Postal code',
accessor: 'PostalCode'
}]
最后,我们需要在Addresses
选项卡中添加表格。 我们将使用流行的ReactTable
组件来显示表格。 在<Row></Row>
部分中添加以下代码:
<Col>
<ReactTable data={this.state.data} columns={columns}
defaultPageSize={15} pageSizeOptions = {[10, 30]} className="-striped -highlight" /></Col>
这里有一些有趣的参数。 我们绑定data
到this.state.data
,当状态改变时自动更新。 我们创建的列绑定到columns
属性。 我喜欢这样一个事实:我们可以使用defaultPageSize
来控制每个人在每页上看到多少行,而且我们可以让用户选择使用pageSizeOptions
来覆盖行数。 我们将className
设置为-striped -highlight
,以便显示为灰色和白色之间的条纹,并使用行高亮显示鼠标移动到表上时所经过的行。
在添加人员时使用选择控件选择地址
当用户想要添加一个人时,他们只需要输入他们的姓和名。 我们向用户显示一个选择框,其中填充了之前输入的地址列表。 让我们看看如何用 React 处理更复杂的场景。
我们需要做的第一件事是创建两个独立的组件。 我们有一个输入姓名的AddPerson
组件,还有一个AddressChoice
组件,它检索并显示供用户选择的完整地址列表。 我们将从AddressChoice
组件开始。
该组件使用一个自定义的IAddressProperty
,它为我们提供了返回到父组件的访问,以便当该组件改变值时,我们可以触发对当前选择的地址的更新:
interface IAddressProperty {
CurrentSelection : (currentSelection:IAddress | null) => void;
}
export class AddressesChoice extends React.Component<IAddressProperty, Map<string, string>> {
}
我们已经告诉 React,我们的组件接受IAddressProperty
作为组件的道具,并将Map<string, string>
作为状态。 当我们从服务器检索地址列表时,我们用地址填充这个映射; 键用来保存ServerID
,值保存地址的格式化版本。 由于这背后的逻辑看起来有点复杂,我们将从加载地址的方法开始,然后返回构造函数:
private LoadAddreses(): void {
axios.get("http://localhost:17171/get/").then((result:AxiosResponse<any>) =>
{
result.data.forEach((person: any) => {
this.options.set(person.ServerID, `${person.Line1} ${person.Line2} ${person.Line3} ${person.Line4} ${person.PostalCode}`);
});
this.addresses = { ...result.data };
this.setState(this.options);
});
}
我们首先向服务器发出一个调用,以获得完整的地址列表。 当我们取回列表时,我们将遍历地址以构建刚才讨论过的格式化映射。 我们用格式化的映射填充状态,并将未格式化的地址复制到单独的地址字段中; 我们这样做的原因是,虽然我们希望将格式化的版本显示到显示器,但我们希望在选择发生变化时将未格式化的版本发送回调用者。 还有其他方法可以实现这一点,但这是一个有用的小技巧,使事情变得简单。
加载功能就绪后,我们现在可以添加构造函数和字段了:
private options: Map<string, string>;
private addresses: IAddress[] = [];
constructor(prop: IAddressProperty) {
super(prop);
this.options = new Map<string, string>();
this.Changed = this.Changed.bind(this);
this.state = this.options;
}
注意,为了与上一节讨论的bind
代码保持一致,我们在这里更改了绑定。 同样,在componentDidMount
中加载数据:
public componentDidMount() {
this.LoadAddreses();
}
现在我们已经准备好构建渲染方法了。 为了简化组成选择项的条目构建过程的可视化,我们将这些代码分离到一个单独的方法中。 这简单地遍历this.options
列表,以创建要添加到select
控件的选项:
private RenderList(): any[] {
const optionsTemplate: any[] = [];
this.options.forEach((value, key) => (
optionsTemplate.push(<option key={key} value={key}>{value}</option>)
));
return optionsTemplate;
}
我们的渲染方法使用选择Form.Control
,它显示Select...
作为第一个选项,然后呈现出RenderList
的列表:
public render() {
return (<Form.Control as="select" onChange={this.Changed}>
<option>Select...</option>
{this.RenderList()}
</Form.Control>)
}
眼尖的读者会注意到,我们现在已经两次引用了Changed
方法,而没有实际添加它。 该方法获取选择值并使用它来查找未格式化的地址,如果找到它,则使用props
来触发CurrentSelection
方法:
private Changed(optionSelected: any) {
const address = Object.values(this.addresses).find(x => x.ServerID === optionSelected.target.value);
if (address) {
this.props.CurrentSelection(address);
} else {
this.props.CurrentSelection(null);
}
}
在我们的AddPerson
代码中,AddressesChoice
在渲染中是这样引用的:
<AddressesChoice CurrentSelection={this.CurrentSelection} />
我们不打算覆盖AddPerson
里面的其他内容。 我建议按照下载的代码查看是否正确。 我们也不打算介绍其他组件; 如果我们继续分析其他组件,这一章可能会变成 100 页的怪物,特别是因为它们基本上遵循我们刚刚提到的控制方式。
增加我们的导航
我们想要添加到客户端代码库的最后一点代码是处理客户端导航的能力。 在介绍 Angular 时,我们已经看到了如何做到这一点,所以现在我们来看看如何根据用户选择的链接来显示不同的页面。 我们将使用 Bootstrap 导航和 React 路由操作的组合。 我们首先创建一个包含导航的路由器:
const routing = (
<Router>
<Navbar bg="light">
<Navbar.Collapse id="basic-navbar-nav">
<Nav.Link href="/">Home</Nav.Link>
<Nav.Link href="/contacts">Contacts</Nav.Link>
<Nav.Link href="/leads">Leads</Nav.Link>
<Nav.Link href="/addresses">Addresses</Nav.Link>
</Navbar.Collapse>
</Navbar>
</Router>
)
我们留下了一个主页,以便我们可以添加适当的文档和图像,如果我们想爵士,使它看起来像一个商业 CRM 系统。 其他href
元素将连接回路由器以显示适当的 React 组件。 在Router
中,我们添加Route
项,将path
映射到component
,这样,例如,如果用户选择Addresses
,Addresses
组件将显示:
<Route path="/" component={App} />
<Route path="/addresses" component={Addresses} />
<Route path="/contacts" component={People} />
<Route path="/leads" component={Leads} />
我们的routing
代码现在看起来像这样:
const routing = (
<Router>
<Navbar bg="light">
<Navbar.Collapse id="basic-navbar-nav">
<Nav.Link href="/">Home</Nav.Link>
<Nav.Link href="/contacts">Contacts</Nav.Link>
<Nav.Link href="/leads">Leads</Nav.Link>
<Nav.Link href="/addresses">Addresses</Nav.Link>
</Navbar.Collapse>
</Navbar>
<Route path="/" component={App} />
<Route path="/addresses" component={Addresses} />
<Route path="/contacts" component={People} />
<Route path="/leads" component={Leads} />
</Router>
)
为了添加我们的导航,完成路由,我们做以下工作:
ReactDOM.render(
routing,
document.getElementById('root') as HTMLElement
);
就是这样。 我们现在有了一个客户端应用,它可以与我们的微服务通信,并将它们的结果编排在一起,以便它们协同工作,即使它们的实现是相互独立的。
总结
至此,我们已经创建了一系列微服务。 我们从定义一系列共享功能开始,将其用作创建专业服务的基础。 这些服务在 Node.js 中都使用了相同的端口,这可能会给我们带来一个问题,但我们通过创建一系列 Docker 容器来启动我们的服务并将内部端口重定向到不同的外部端口来解决这个问题。 我们看到了如何创建相关的 Docker 文件和 Docker 组合文件来启动服务。
然后,我们创建了一个基于 react 的客户端应用,它使用了更高级的布局,通过引入选项卡将查看结果与微服务和向服务添加记录的功能分开。 在此过程中,我们还使用 Axios 来管理 REST 调用。
当谈到 REST 调用时,我们看到了如何使用 Swagger 来定义我们的 REST API,并讨论了是否要在我们的服务中使用 Swagger 提供的 API 代码。
在下一章,我们将离开 React,看看如何创建一个与 TensorFlow 一起工作的 Vue 客户端来自动执行图像分类。
问题
- 什么是 Docker 容器?
- 我们使用什么来将 Docker 容器组合在一起以启动它们,我们可以使用什么命令来启动它们?
- 如何使用 Docker 将内部端口映射到不同的外部端口?
- Swagger 为我们提供了什么功能?
- 如果一个方法在 React 中看不到状态,我们需要做什么?
进一步的阅读
- 如果你想了解更多关于 Docker 的信息,厄尔·沃特(Earl Waud)的Docker 快速入门指南(https://www.packtpub.com/in/networking-and-servers/docker-quick-start-guide)是一个很好的开始。
- 如果你在 Windows 上运行 Docker,Docker on Windows -第二版(https://www.packtpub.com/virtualization-and-cloud/docker-windows-second-edition)是一个很大的帮助。
- 在这个阶段,我希望你对微服务的兴趣已经真正地激发起来了。 如果是这样的话,Paul Osman 的Microservices Development Cookbook(https://www.packtpub.com/in/application-development/microservices-development-cookbook)应该就是你需要继续下去的东西。
版权属于:月萌API www.moonapi.com,转载请注明出处