整合营销服务商

电脑端+手机端+微信端=数据同步管理

免费咨询热线:

HTTP协议是什么?详细解读HTTP看完还不懂你来找

HTTP协议是什么?详细解读HTTP看完还不懂你来找我

章将包含以下几方面内容:

  • HTTP协议解读
  • 与HTTP相关组件
  • 与HTTP相关协议
  • HTTP组成
  • HTTP协议优缺点

HTTP协议解读

HTTP 是一种 超文本传输协议(Hypertext Transfer Protocol),超文本传输协议可以进行文字分割:超文本(Hypertext)、传输(Transfer)、协议(Protocol) ,它们之间的关系如下:

分别对这三个名次做一个解释:

超文本

两台电脑之间只能传输简单文字,后面还想要传输图片、音频、视频,甚至点击文字或图片能够进行超链接的跳转,那么文本的语义就被扩大了,这种语义扩大后的文本就被称为超文本(Hypertext)。

传输

两台计算机之间会形成互联关系进行通信,我们存储的超文本会被解析成为二进制数据包,由传输载体(例如同轴电缆,电话线,光缆)负责把二进制数据包由计算机终端传输到另一个终端的过程

协议

网络协议就是网络中(包括互联网)传递、管理信息的一些规范

与HTTP相关组件

网络设计者以分层(layer)的方式组织协议,每个协议属于层次模型之一。每一层都是向它的上一层提供服务(service),即所谓的服务模型(service model)。每个分层中所有的协议称为 协议栈(protocol stack)。因特网的协议栈由五个部分组成:物理层、链路层、网络层、传输层和应用层。我们采用自上而下的方法研究其原理,也就是应用层 -> 物理层的方式(了解)。

应用层

应用层是网络应用程序和网络协议存放的分层,因特网的应用层包括许多协议。比如HTTP,电子邮件传送协议 SMTP、端系统文件上传协议 FTP、还有为我们进行域名解析的 DNS 协议

传输层

输层在应用程序断点之间传送应用程序报文,在这一层主要有两种传输协议 TCP和 UDP。

TCP 是面向连接的,它能够控制并确认报文是否到达,并提供了拥塞机制来控制网络传输,因此当网络拥塞时,会抑制其传输速率。

UDP 协议向它的应用程序提供了无连接服务。它是不具备可靠性的,没有流量控制,也没有拥塞控制。我们把运输层的分组称为 报文段(segment)

网络层

网络层负责将称为 数据报(datagram) 的网络分层从一台主机移动到另一台主机。网络层一个非常重要的协议是 IP 协议,所有具有网络层的因特网组件都必须运行 IP 协议。

链路层

为了将分组从一个节点(主机或路由器)运输到另一个节点,网络层必须依靠链路层提供服务。链路层的例子包括以太网、WiFi 和电缆接入的 DOCSIS 协议,因为数据从源目的地传送通常需要经过几条链路,一个数据包可能被沿途不同的链路层协议处理,我们把链路层的分组称为 帧(frame)。

物理层

虽然链路层的作用是将帧从一个端系统运输到另一个端系统,而物理层的作用是将帧中的一个个 比特 从一个节点运输到另一个节点,,物理层的协议仍然使用链路层协议,这些协议与实际的物理传输介质有关,例如,以太网有很多物理层协议:关于双绞铜线、关于同轴电缆、关于光纤等等。

五层网络协议的示意图如下:

与HTTP相关协议

HTTP 属于应用层的协议,需要其他层次协议的配合完成信息的交换,在完成一次 HTTP 请求和响应的过程中,需要以下协议的配合:

TCP/IP

TCP/IP 我们一般称之为协议簇,什么意思呢?就是 TCP/IP 协议簇中不仅仅只有 TCP 协议和 IP 协议,它是一系列网络通信协议的统称。而其中最核心的两个协议就是 TCP / IP 协议,其他的还有 UDP、ICMP、ARP 等等,共同构成了一个复杂但有层次的协议栈。

TCP 协议的全称是 Transmission Control Protocol 的缩写,意思是传输控制协议,HTTP 使用 TCP 作为通信协议,这是因为 TCP 是一种可靠的协议,而可靠能保证数据不丢失。

IP 协议的全称是 Internet Protocol 的缩写,它主要解决的是通信双方寻址的问题。IP 协议使用 IP 地址 来标识互联网上的每一台计算机。

DNS

DNS 的全称是域名系统(Domain Name System,缩写:DNS),它作为将域名和 IP 地址相互映射的一个分布式数据库,能够使人更方便地访问互联网。比如:www.google.com -> 193.XXX.XXX.XXX

URI / URL

可以通过输入 www.google.com 地址来访问谷歌的官网,输入的地址格式必须要满足 URI 的规范。

URI的全称是(Uniform Resource Identifier),中文名称是统一资源标识符,使用它就能够唯一地标记互联网上资源。

URL的全称是(Uniform Resource Locator),中文名称是统一资源定位符,也就是我们俗称的网址,它实际上是 URI 的一个子集。

HTTP报文

  • 起始行(start line):描述请求或响应的基本信息;
  • 头部字段(header):使用 key-value 形式更详细地说明报文;
  • 消息正文(entity):实际传输的数据,它不一定是纯文本,可以是图片、视频等二进制数据。

起始行和头部字段并成为 请求头 或者 响应头,统称为 Header;消息正文也叫做实体,称为 body。HTTP 协议规定每次发送的报文必须要有 Header,但是可以没有 body,在 header 和 body 之间必须要有一个空行(CRLF)。

举个例子:http://www.someSchool.edu/someDepartment/home.index 请求的请求头:

报文的起始行都是由三个字段组成:方法、URL 字段和 HTTP 版本字段

HTTP 请求方法

  • GET 获取资源,GET 方法用来请求访问已被 URI 识别的资源。指定的资源经服务器端解析后返回响应内容。
  • POST 传输实体,使用 POST 传输实体信息,提交表格内容。
  • PUT 传输文件,PUT 方法用来传输文件。就像 FTP 协议的文件上传一样,要求在请求报文的主体中包含文件内容,然后保存到请求 URI 指定的位置。 但是,鉴于 HTTP 的 PUT 方法自身不带验证机制,任何人都可以上传文件 , 存在安全性问题,因此一般的 W eb 网站不使用该方法。若配合 W eb 应用程序的验证机制,或架构设计采用REST(REpresentational State Transfer,表征状态转移)标准的同类 Web 网站,就可能会开放使用 PUT 方法。
  • HEAD 获得响应首部,HEAD 方法和 GET 方法一样,只是不返回报文主体部分。用于确认 URI 的有效性及资源更新的日期时间等。
  • DELETE 删除文件,DELETE 方法用来删除文件,是与 PUT 相反的方法。DELETE 方法按请求 URI 删除指定的资源。
  • OPTIONS 询问支持的方法,OPTIONS 方法用来查询针对请求 URI 指定的资源支持的方法。
  • TRACE 追踪路径,TRACE 方法是让 Web 服务器端将之前的请求通信环回给客户端的方法。
  • CONNECT 要求用隧道协议连接代理,CONNECT 方法要求在与代理服务器通信时建立隧道,实现用隧道协议进行 TCP 通信。主要使用 SSL(Secure Sockets Layer,安全套接层)和 TLS(Transport Layer Security,传输层安全)协议把通信内容加 密后经网络隧道传输。

一般最常用的方法也就是 GET 方法和 POST 方法,其他方法暂时了解即可。

HTTP 请求 URL

完整的域名解析一下 URL:http://www.example.com:80/path/to/myfile.html?key1=value1&key2=value2#SomewhereInTheDocument

  • http://告诉浏览器使用何种协议。
  • www.example.com 是域名、主机,指示了需要向网络上的哪一台主机发起请求。也可以直接向主机的ip发起请求。
  • 端口 两个主机之间要发起 TCP 连接需要两个条件,主机 + 端口,表示用于访问 Web 服务器上资源的入口,如果访问的该 Web 服务器使用HTTP协议的标准端口(HTTP为80,HTTPS为443)授予对其资源的访问权限,则通常省略此部分。否则端口就是 URI 必须的部分。
  • 路径 /path/to/myfile.html 是 Web 服务器上资源的路径。以端口后面的第一个 / 开始,到 ? 号之前结束,中间的 每一个/ 都代表了层级(上下级)关系。
  • 查询参数

?key1=value1&key2=value2 是提供给 Web 服务器的额外参数。如果是 GET 请求,一般带有请求 URL 参数,如果是 POST 请求,则不会在路径后面直接加参数。

  • 锚点 #SomewhereInTheDocument 是资源本身的某一部分的一个锚点。锚点代表资源内的一种“书签”。

请求头部

比如http://www.someSchool.edu/someDepartment/home.index,来看一下它的请求头部

Host: www.someschool.edu
Connection: close
User-agent: Mozilla/5.0
Accept-language: fr
复制代码
  • Host :表示的是对象所在的主机
  • Connection: close 表示的是浏览器需要告诉服务器使用的是非持久连接。它要求服务器在发送完响应的对象后就关闭连接。
  • User-agent: 这是请求头用来告诉 Web 服务器,浏览器使用的类型是 Mozilla/5.0,即 Firefox 浏览器。
  • Accept-language 告诉 Web 服务器,浏览器想要得到对象的法语版本。

HTTP 的请求标头分为四种: 通用标头、请求标头、响应标头 和 实体标头

通用标头

通用标头主要有三个,分别是 Date、Cache-Control 和 Connection Date

Date 出现在请求标头和响应标头中,它的基本表示如下

Date: Wed, 21 Oct 2015 07:28:00 GMT 
复制代码

Cache-Control

Cache-Control 可以出现在请求标头和响应标头中,Cache-Control 的种类比较多,虽然说这是一个通用标头,但是又一些特性是请求标头具有的,有一些是响应标头才有的。主要大类有 可缓存性、阈值性、 重新验证并重新加载 和其他特性

Connection

Connection 决定当前事务(一次三次握手和四次挥手)完成后,是否会关闭网络连接。Connection 有两种,一种是持久性连接,即一次事务完成后不关闭网络连接

Connection: keep-alive
复制代码
复制代码

另一种是非持久性连接,即一次事务完成后关闭网络连接

Connection: close
复制代码

实体标头

实体标头是描述消息正文内容的 HTTP 标头。实体标头用于 HTTP 请求和响应中。头部Content-Length、 Content-Language、 Content-Encoding 是实体头。

  • Content-Length 实体报头指示实体主体的大小,以字节为单位,发送到接收方。
  • Content-Language 实体报头描述了客户端或者服务端能够接受的语言,例如
Content-Language: de-DE
Content-Language: en-US
Content-Language: de-DE, en-CA
复制代码
复制代码
  • Content-Encoding 这又是一个比较麻烦的属性,这个实体报头用来压缩媒体类型。Content-Encoding 指示对实体应用了何种编码。 常见的内容编码有这几种: gzip、compress、deflate、identity ,这个属性可以应用在请求报文和响应报文中
Accept-Encoding: gzip, deflate //请求头
Content-Encoding: gzip  //响应头
复制代码

请求标头

GET /home.html HTTP/1.1
Host: developer.mozilla.org
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:50.0) Gecko/20100101 Firefox/50.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Referer: https://developer.mozilla.org/testpage.html
Connection: keep-alive
Upgrade-Insecure-Requests: 1
If-Modified-Since: Mon, 18 Jul 2016 02:36:04 GMT
If-None-Match: "c561c68d0ba92bbeb8b0fff2a9199f722e3a621a"
Cache-Control: max-age=0 

复制代码

Host

Host 请求头指明了服务器的域名,以及(可选的)服务器监听的TCP端口号

Referer

HTTP Referer 属性是请求标头的一部分,告诉服务器该网页是从哪个页面链接过来的

If-Modified-Since

HTTP 的 If-Modified-Since 使其成为条件请求:

  • 返回200,只有在给定日期的最后一次修改资源后,服务器才会以200状态发送回请求的资源。
  • 如果请求从开始以来没有被修改过,响应会返回304并且没有任何响应体

If-Modified-Since 通常会与 If-None-Match 搭配使用,If-Modified-Since 用于确认代理或客户端拥有的本地资源的有效性。获取资源的更新日期时间,可通过确认首部字段 Last-Modified 来确定。

大白话说就是如果在 Last-Modified 之后更新了服务器资源,那么服务器会响应200,如果在 Last-Modified 之后没有更新过资源,则返回 304。

If-Modified-Since: Mon, 18 Jul 2016 02:36:04 GMT
复制代码
复制代码

If-None-Match

If-None-Match HTTP请求标头使请求成为条件请求。 对于 GET 和 HEAD 方法,仅当服务器没有与给定资源匹配的 ETag 时,服务器才会以200状态发送回请求的资源。 对于其他方法,仅当最终现有资源的ETag与列出的任何值都不匹配时,才会处理请求。

If-None-Match: "c561c68d0ba92bbeb8b0fff2a9199f722e3a621a"
复制代码
复制代码

内容协商

内容协商机制是指客户端和服务器端就响应的资源内容进行交涉,然后提供给客户端最为适合的资源。内容协商会以响应资源的语言、字符集、编码方式等作为判断的标准。

内容协商主要有以下3种类型:

  • 服务器驱动协商(Server-driven Negotiation)

这种协商方式是由服务器端进行内容协商。服务器端会根据请求首部字段进行自动处理

  • 客户端驱动协商(Agent-driven Negotiation)

这种协商方式是由客户端来进行内容协商。

  • 透明协商(Transparent Negotiation)

是服务器驱动和客户端驱动的结合体,是由服务器端和客户端各自进行内容协商的一种方法。

内容协商的分类有很多种,主要的几种类型是 Accept、Accept-Charset、Accept-Encoding、Accept-Language、Content-Language

Accept

接受请求 HTTP 标头会通告客户端其能够理解的 MIME 类型

MIME: MIME (Multipurpose Internet Mail Extensions) 是描述消息内容类型的因特网标准。MIME 消息能包含文本、图像、音频、视频以及其他应用程序专用的数据。
复制代码

文本文件: text/html、text/plain、text/css、application/xhtml+xml、application/xml

图片文件: image/jpeg、image/gif、image/png

视频文件: video/mpeg、video/quicktime

应用程序二进制文件: application/octet-stream、application/zip

比如,如果浏览器不支持 PNG 图片的显示,那 Accept 就不指定image/png,而指定可处理的 image/gif 和 image/jpeg 等图片类型。

一般 MIME 类型也会和 q 这个属性一起使用,q 是什么?q 表示的是权重,来看一个例子

Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
复制代码

若想要给显示的媒体类型增加优先级,则使用 q=来额外表示权重值,没有显示权重的时候默认值是1.0。

q

MIME

1.0

text/html

1.0

application/xhtml+xml

0.9

application/xml

0.8

* / *

Accept-Charset

accept-charset 属性规定服务器处理表单数据所接受的字符集。

accept-charset 属性允许您指定一系列字符集,服务器必须支持这些字符集,从而得以正确解释表单中的数据。

Accept-Language

首部字段 Accept-Language 用来告知服务器用户代理能够处理的自然语言集(指中文或英文等),以及自然语言集的相对优先级。

Accept-Language: en-US,en;q=0.5
复制代码

响应标头

响应标头是可以在 HTTP 响应中使用的 HTTP 标头。并不是所有出现在响应中的标头都是响应标头。还有一些特殊的我们上面说过,有通用标头和实体标头也会出现在响应标头中,比如 Content-Length 就是一个实体标头,但是,在这种情况下,这些实体请求通常称为响应头。下面以一个例子为例和你探讨一下响应头

200 OK
Access-Control-Allow-Origin: *
Connection: Keep-Alive
Content-Encoding: gzip
Content-Type: text/html; charset=utf-8
Date: Mon, 18 Jul 2016 16:06:00 GMT
Etag: "c561c68d0ba92bbeb8b0f612a9199f722e3a621a"
Keep-Alive: timeout=5, max=997
Last-Modified: Mon, 18 Jul 2016 02:36:04 GMT
Server: Apache
Set-Cookie: mykey=myvalue; expires=Mon, 17-Jul-2017 16:06:00 GMT; Max-Age=31449600; Path=/; secure
Transfer-Encoding: chunked
Vary: Cookie, Accept-Encoding
x-frame-options: DENY

复制代码

响应状态码

以 2xx 为开头的都表示请求成功响应。

状态码

含义

200

成功响应

204

请求处理成功,但是没有资源可以返回

206

对资源某一部分进行响应,由Content-Range 指定范围的实体内容。

以 3xx 为开头的都表示需要进行附加操作以完成请求

状态码

含义

301

永久性重定向,该状态码表示请求的资源已经重新分配 URI,以后应该使用资源现有的 URI

302

临时性重定向。该状态码表示请求的资源已被分配了新的 URI,希望用户(本次)能使用新的 URI 访问。

303

该状态码表示由于请求对应的资源存在着另一个 URI,应使用 GET 方法定向获取请求的资源。

304

该状态码表示客户端发送附带条件的请求时,服务器端允许请求访问资源,但未满足条件的情况。

307

临时重定向。该状态码与 302 Found 有着相同的含义。

以 4xx 的响应结果表明客户端是发生错误的原因所在。

状态码

含义

400

该状态码表示请求报文中存在语法错误。当错误发生时,需修改请求的内容后再次发送请求。

401

该状态码表示发送的请求需要有通过 HTTP 认证(BASIC 认证、DIGEST 认证)的认证信息。

403

该状态码表明对请求资源的访问被服务器拒绝了。

404

该状态码表明服务器上无法找到请求的资源。

以 5xx 为开头的响应标头都表示服务器本身发生错误

状态码

含义

500

该状态码表明服务器端在执行请求时发生了错误。

503

该状态码表明服务器暂时处于超负载或正在进行停机维护,现在无法处理请求。

HTTP协议优缺点

HTTP 的优点

  • 简单灵活易扩展

HTTP 的协议比较简单,它的主要组成就是 header + body,头部信息也是简单的文本格式

HTTP 协议又多了灵活 和 易扩展 的优点。

HTTP 协议里的请求方法、URI、状态码、原因短语、头字段等每一个核心组成要素都没有被制定死,允许开发者任意定制、扩充或解释,给予了浏览器和服务器最大程度的信任和自由。

  • 应用广泛、环境成熟

天然具有跨语言、跨平台的优越性,而且,因为本身的简单特性很容易实现,所以几乎所有的编程语言都有 HTTP 调用库和外围的开发测试工具

  • 无状态

既是优点又是缺点。因为服务器没有记忆能力,所以就不需要额外的资源来记录状态信息,不仅实现上会简单一些,而且还能减轻服务器的负担,能够把更多的 CPU 和内存用来对外提供服务。

HTTP 的缺点

  • 无状态

服务器没有记忆能力,它就无法支持需要连续多个步骤的事务操作。每次都得问一遍身份信息,需要增加了不必要的数据传输量。由此出现了 Cookie 技术。

  • 安全性

明文传输,协议里的报文(准确地说是 header 部分)不使用二进制数据,而是用简单可阅读的文本形式。

对比 TCP、UDP 这样的二进制协议,它的优点显而易见,不需要借助任何外部工具,用浏览器、Wireshark 或者 tcpdump 抓包后,直接用肉眼就可以很容易地查看或者修改,为我们的开发调试工作带来极大的便利。

当然缺点也是显而易见的,就是不安全,可以被监听和被窥探。因为无法判断通信双方的身份,不能判断报文是否被更改过。

总结起来即:

  1. 明文,请求报文未加密;
  2. 未hash,即使报文被修改过也不知道;
  3. 未验证身份,容易导致中间人攻击;


作者:captain_p
链接:https://juejin.cn/post/7041744237905346568

如果本文对你有帮助,麻烦转发关注支持一下

章涵盖

  • 识别 Web API 的潜在用户
  • 在 Swagger 和 Swashbuckle 中应用 API 文档最佳实践
  • 使用可扩展标记语言 (XML) 文档和虚张声势注释
  • 使用 Swashbuckle 的筛选器管道自定义 swagger.json 文件

在第1章中,当我们尝试定义应用程序编程接口(API)时,我们将其称为软件平台,它公开了不同计算机程序可以通过交换数据进行交互的工具和服务。从这个定义开始,我们可以说 API(包括 Web API)的目的是创建一个公共场所,让独立且通常不相关的系统可以使用普遍接受的标准进行会面、问候和通信。这些“参与者”大多是由其他开发人员实现的计算机程序,例如网站、移动应用程序和微服务。出于这个原因,无论谁承担设计、创建和发布 Web API 的任务,都必须承认一种新型用户的存在和需求:第三方开发人员,这将我们带到本章的主题。

在现代软件开发中,记录接口、中间件、服务或任何旨在达到目的的手段的产品不再被视为一种选择:只要我们想增加或加速其采用,它就是一项设计要求。这也是让感兴趣的第三方能够充分理解我们工作价值的最快方法。近年来,这方面变得如此重要,以至于它有利于定义一个新的设计领域:开发人员体验(DX),即从开发人员角度看的用户体验。通过考虑DX,我将在本章专门确定API文档的最佳实践,并展示我们如何借助 ASP.NET Core提供的许多工具将它们付诸实践。

11.1 网页应用接口潜在受众

产品的技术文档只有在满足阅读者的需求和期望时才有用。出于这个原因,首先要做的是确定我们的 Web API 潜在受众:期望选择和/或使用它的利益相关者。在提到它们时,我通常将它们分为三种主要类型,使用取自建筑俚语的名称。

11.1.1 探矿者

探矿者是充满激情的开发人员和 IT 爱好者,他们愿意尝试我们的 Web API,而除了个人兴趣、知识获取、测试/审查目的等之外,没有迫切的需求。如果我们希望我们的 Web API 成为我们打算向公众发布的通用产品(或其中的一部分),则此组很重要;他们的反馈可能会对开发人员社区产生直接影响,可能会引入承包商和建筑商(分别参见第 11.1.2 节和第 11.1.3 节)。

11.1.2 承包商

承包商是 IT 分析师、解决方案架构师和后端设计人员,他们负责创建产品、解决问题或解决我们的 Web API 可以帮助他们处理的潜在挑战。虽然通常情况下,他们不会着手实施,但他们通常充当决策者,因为他们拥有权力、处理预算和/或拥有建议、选择或规定使用哪些组件所需的专业知识(除非他们让构建者选择它们)。

11.1.3 构建器

构建者是选择(或被指示)使用我们的 Web API 来解决特定问题的软件开发人员。他们代表了我们受众中技术性最强的部分,可能很难满足,因为处理我们的 API 是他们工作任务的一部分,而且他们完成工作的时间通常有限。构建者必须学会实际使用我们的 Web API;他们是我之前提到的第三方开发人员。

在阅读了这些描述之后,似乎很明显地认为我们的文档应该关注构建者,他们是我们的 Web API 的最终用户。这个前提是有效的。我们将在本章中讨论的大多数 API 文档最佳实践都将考虑这种方法。但我们不应该忘记其他两种受众类型,因为我们项目的成功也可能取决于他们。

11.2 API 文档最佳实践

开发人员是特殊类型的用户。他们善于分析、精确且要求苛刻,特别是如果我们认为他们通常希望使用我们的 API 来实现主要目标:实现需求、解决问题等。每当他们发现自己由于文档编写不佳而无法实现目标时,他们很可能会认为 API 不够好——尽管听起来很残酷,但他们是对的。归根结底,API 的好坏取决于它们的文档,这不可避免地会对采用和可维护性产生巨大影响。

我们所说的良好文档是什么意思,我们如何实现它?没有一个答案在所有情况下都有效。但是一些好的做法可以帮助我们找到一种可行的方法来实现我们想要的东西,例如:

  • 采用自动描述工具 - 这样,如果我们忘记更新文档以及 Web API 的源代码,我们的文档就不会过时或过时
  • 描述端点和输入参数 - 以便我们的受众不仅承认它们的存在,而且了解它们的用途以及如何使用它们
  • 描述响应 - 以便我们的听众知道调用每个端点时会发生什么以及如何处理结果
  • 添加请求和响应示例 - 为我们的受众节省大量开发时间
  • 将端点分组到多个部分 - 更好地区分用户的不同作用域、用途和角色
  • 排除保留端点 - 防止用户知道它们的存在和/或尝试调用它们
  • 强调授权要求 - 让我们的受众区分可公开访问的操作和仅限于授权用户的操作
  • 自定义文档上下文 - 例如选择适当的名称、图标和元数据以帮助用户查找所需信息

以下部分将详细介绍这些概念,并展示如何在我们的 MyBGList Web API 中实现它们。

11.2.1 采用自动描述工具

如果我们想让第三方开发人员满意,我们必须确保我们的 API 文档始终更新。没有什么比处理缺少的规范、不存在的操作或端点、错误的参数等更令人沮丧的了。过时的文档会让我们的用户认为我们的 API 坏了,即使它不是。

注意记录不佳(或错误)的 Web API 在技术上已损坏,因为第三方没有机会看到它按预期工作。这就是我们最初声明API文档是设计要求,而不是选项或附加组件的原因。即使对于预计仅由内部开发人员使用的内部 API 也是如此,因为缺乏适当的文档最终会影响新员工、潜在合作伙伴、维护任务、移交流程、外包商等。

对于 RESTful API 来说,自动化文档过程的需求尤其强烈,因为 REST 架构标准没有为此目的提供标准化的机制、模式或参考。这是Open API(以前称为Swagger)成功的主要原因,Open API(以前称为Swagger)是SmartBear Software于2011年发布的自动化API文档的开源规范,旨在解决这个问题。

我们从第2章开始就知道Swagger/OpenAPI,因为Visual Studio的 ASP.NET Core Web API模板(我们用来创建MyBGList项目)包括Swashbuckle的服务和中间件,这是一组用于在Core中实现OpenAPI的服务,中间件和工具 ASP.NET。我们还体验了它的自动发现和描述功能,它为我们提供了一个代码生成的 OpenAPI 3.0 描述文件 (swagger.json) 和一个基于 Web 的交互式 API 客户端 (SwaggerUI),我们用它来测试我们的端点。因为我们已经在使用Swashbuckle,所以我们可以说我们已经准备好了。但是,在以下部分中,我们将扩展其功能以满足我们的需求。

11.2.2 描述端点和输入参数

如果我们查看我们的 SwaggerUI 主仪表板,我们会发现我们当前的“文档”仅包含端点及其输入变量的列表,而没有对每个方法的作用进行单一描述。我们的受众必须从名称中推断端点的使用,以及每个请求标头和/或输入参数的用途,这不是展示、评估或推广我们工作的最佳方式。

注意如果我们的 API 遵循使用 HTTP 谓词来标识操作类型的 RESTful 良好实践,它将提供有关每个端点使用的其他有用提示 - 至少对具有所需专业知识的用户而言。

相反,我们应该采用标准化的方式来为每个端点及其输入变量创建简洁而相关的描述。这种做法不仅可以节省建筑商的时间,还可以让探矿者和承包商更好地掌握API的工作方式及其功能。Swashbuckle 提供了两种向端点和输入参数添加自定义描述的方法:

  • 使用由 .NET 编译器从标准三斜杠、XML 格式注释自动生成的可扩展标记语言 (XML) 文档文件,我们可以将其添加到 C# 类中。
  • 使用 [SwaggerOperation] 数据属性,该属性由可选的 Swashbuckle 提供。AspNetCore.Annotations NuGet 包。

每种技术都有优点和缺点。在下一节中,我们将了解如何实现这两种技术。

11.2.3 添加 XML 文档支持

如果我们已经使用 C# 提供的三斜杠语法在源代码中添加了注释,则 XML 文档方法可能很有用、方便且快速实现。我说的是一个简洁的 C# 功能,它允许开发人员通过编写由三斜杠指示的特殊注释字段来创建代码级文档。某些集成开发环境 (IDE)(如 Visual Studio)也使用此功能,这些环境自动生成 XML 元素来描述各种代码部分,例如<摘要>(用于方法)、<param>(用于输入参数)和<returns>(用于返回值)。

注意有关 C# XML 文档注释的其他信息,请查看 http://mng.bz/qdy6。有关支持的 XML 标记的完整参考,请参阅 http://mng.bz/7187

学习如何使用此功能的最佳方法是将其付诸实践。打开 /Controllers/AccountController.cs 文件,找到 Register 操作方法,将光标置于其上方(及其所有属性),然后在其上方键入斜杠 (/) 字符三次。添加第三个斜杠后,Visual Studio 应生成以下 XML 注释样板:

/// <summary>
///
/// </summary>
/// <param name="input"></param>
/// <returns></returns>
[HttpPost]
[ResponseCache(CacheProfileName="NoCache")]
public async Task<ActionResult> Register(RegisterDTO input)

请注意,自动生成的 XML 结构标识操作方法的 RegisterDTO 输入参数的名称。现在我们有了样板,让我们填充它。以下是我们如何记录帐户控制器的注册终结点:

/// <summary>
/// Registers a new user.
/// </summary>
/// <param name="input">A DTO containing the user data.</param>
/// <returns>A 201 - Created Status Code in case of success.</returns>

之后,向下滚动到登录操作方法,然后执行相同的操作。以下是我们可以用来记录它的合适描述:

/// <summary>
/// Performs a user login.
/// </summary>
/// <param name="input">A DTO containing the user's credentials.</param>
/// <returns>The Bearer Token (in JWT format).</returns>

保存并关闭帐户控制器。接下来,告诉编译器使用我们添加的 XML 注释以及代码中存在的任何其他此类注释来生成 XML 文档文件。

生成 XML 文档文件

要启用此功能,我们需要更新 MyBGList 项目的配置文件。在“解决方案资源管理器”窗口中右键单击项目的根节点,然后从上下文菜单中选择“编辑项目文件”选项以打开 MyBGList.csproj 文件。接下来,在文件底部的 <ItemGroup> 块下方添加以下代码,我们在第 10 章中添加了包含 protobuf 文件:

// ... existing code
 
<ItemGroup>
  <Protobuf Include="gRPC/grpc.proto" />
</ItemGroup>
 
<PropertyGroup>
  <GenerateDocumentationFile>true</GenerateDocumentationFile>
  <NoWarn>$(NoWarn);1591</NoWarn>
</PropertyGroup>
 
// ... existing code

现在,每当我们构建项目时,编译器都会生成 XML 文档文件。

克服 CS1591 警告

我们在前面的代码中使用的 <NoWarn> 元素将禁止显示 CS1591 警告,GenerateDocumentationFile 开关将为任何公共类型和成员引发该警告,而无需三斜杠注释。我们选择在我们的示例项目中全局关闭它们,因为我们不需要该建议,但是如果我们想确保我们注释/记录所有内容,那么保持它可能会很有用。

有关生成文档文件开关的详细信息,请参阅 http://mng.bz/mJdn。

我们需要做的最后一件事是配置 Swashbuckle 以获取 XML 文档文件的内容。

配置虚张声势扣

要读取我们项目的XML文档文件,Swashbuckle需要知道它的完整路径和文件名,与我们项目的名称(带有.xml扩展名)相对应。我们可以使用 Reflection(一种 C# 技术,允许我们在运行时检索类型的元数据),而不是手动编写它,而是手动编写它。这种编程方法通常更可取,因为它比使用文本字符串更能确保代码可维护性,因此我们将选择它。打开 Program.cs 文件,找到 AddSwaggerGen() 方法,并在其配置块中添加以下代码(粗体新行):

using System.Reflection;                                           ?
 
// ... existing code
 
builder.Services.AddSwaggerGen(options=>
{
    var xmlFilename=                                    ?
        $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
    options.IncludeXmlComments(System.IO.Path.Combine(             ?
        AppContext.BaseDirectory, xmlFilename));
 
    // ... existing code

? 必需的命名空间

? 构建 XML 文档文件名

? 组装 XML 文档文件完整路径

请注意,在此代码中,我们使用反射生成与项目名称匹配的 XML 文件名,然后使用它来构造 XML 文件的完整路径。接下来,我们将测试我们所做的工作,看看它是否有效。

测试 XML 文档

在调试模式下运行项目,并查看 SwaggerUI 主仪表板,我们应该在其中看到我们在三斜杠注释中使用的相同描述性字符串(图 11.1)。


图 11.1 Swashbuckle 获取并在 SwaggerUI 中使用的 XML 文档

请注意,摘要紧跟在终结点定义之后。说明显示在端点的可展开面板中。

评估 XML 文档的优缺点

能够自动将所有代码级注释转换为 API 文档,使我们能够用一块石头杀死两只鸟。如果我们习惯于编写注释来描述我们的类和方法(开发人员的良好做法),我们可以重用大量工作。此外,这种方法对内部开发人员特别有用,因为他们可以直接从源代码中读取我们的API文档,甚至不必查看swagger.json文件和/或SwaggerUI。

但这种显着的好处很容易成为不利的一面。例如,如果我们想将内部源代码文档(针对内部开发人员)与公共 API 文档(针对第三方开发人员/最终用户)分开,我们可能会发现这种方法是有限的,更不用说涉及非自愿数据泄露的潜在风险。代码级注释通常被大多数开发人员视为机密,他们可能会使用它们来跟踪内部注释、警告、已知问题/错误、漏洞和其他不应向公众发布的严格保留的数据。为了克服这样的问题,我们可以考虑使用 [SwaggerOperation] 数据属性替代,它可以更好地分离内部注释和 API 文档之间的关注点,以及我们可能想要使用的一些简洁的附加功能。

11.2.4 使用虚张声势的注释

除了 XML 文档之外,Swashbuckle 还提供了另一种基于属性的功能,用于向我们的 Web API 端点添加自定义描述。此功能由一个名为 Swashbuckle.AspNetCore.Annotations 的可选模块处理,该模块随专用 NuGet 包一起提供。在以下部分中,我们将学习如何安装和使用它。

安装 NuGet 包

与往常一样,若要安装 Swashbuckle 注释的 NuGet 包,我们可以使用 Visual Studio 的 NuGet 图形用户界面 (GUI)、包管理器控制台窗口或 .NET 命令行界面 (CLI)。下面是在 .NET CLI 中安装它们的命令:

dotnet add package Swashbuckle.AspNetCore.Annotations --version 6.4.0

安装后,我们将能够使用一些数据注释属性来增强我们的 API 文档。我们将从 [SwaggerOperation] 开始,它允许我们为控制器的操作方法以及最小 API 方法设置自定义摘要、描述和/或标签。

使用 [SwaggerOperation] 属性

由于我们的帐户控制器的操作方法已经通过XML记录,因此这次我们将使用BoardGamesController。打开 /Controllers/ BoardGamesController.cs 文件,并将属性添加到四个现有的操作方法中,如清单 11.1 所示(新行以粗体显示)。

清单 11.1 /控制器/棋盘游戏控制器.cs 文件: 添加注释

using Swashbuckle.AspNetCore.Annotations;                                ?
 
// ... existing code
 
[HttpGet(Name="GetBoardGames")]
[ResponseCache(CacheProfileName="Any-60")]
[SwaggerOperation(                                                       ?
    Summary="Get a list of board games.",                              ?
    Description="Retrieves a list of board games " +                   ?
    "with custom paging, sorting, and filtering rules.")]
public async Task<RestDTO<BoardGame[]>> Get(
 
// ... existing code
 
[HttpGet("{id}")]
[ResponseCache(CacheProfileName="Any-60")]
[SwaggerOperation(                                                       ?
    Summary="Get a single board game.",                                ?
    Description="Retrieves a single board game with the given Id.")]   ?
public async Task<RestDTO<BoardGame?>> Get(int id)
 
// ... existing code
 
[Authorize(Roles=RoleNames.Moderator)]
[HttpPost(Name="UpdateBoardGame")]
[ResponseCache(CacheProfileName="NoCache")]
[SwaggerOperation(                                                       ?
    Summary="Updates a board game.",                                   ?
    Description="Updates the board game's data.")]                     ?
public async Task<RestDTO<BoardGame?>> Post(BoardGameDTO model)
 
// ... existing code
 
[Authorize(Roles=RoleNames.Administrator)]
[HttpDelete(Name="DeleteBoardGame")]
[ResponseCache(CacheProfileName="NoCache")]
[SwaggerOperation(                                                       ?
    Summary="Deletes a board game.",                                   ?
    Description="Deletes a board game from the database.")]            ?
public async Task<RestDTO<BoardGame?>> Delete(int id)
 
// ... existing code

? 必需的命名空间

? [招摇操作]属性

? 添加端点摘要

? 添加端点描述

现在我们知道如何使用 Swashbuckle 注释来描述我们的操作,让我们对它们的输入参数做同样的事情。

使用 [SwaggerParameter] 属性

要设置输入参数的描述,我们可以使用 [SwaggerParameter] 属性,该属性对应于 XML 文档的 <param> 标记的 Swashbuckle 注释。但是,尽管 XML 标记必须在方法级别定义,然后通过 name 属性绑定到其相应的参数,但 [SwaggerParameter] 注释必须在它要描述的参数之上定义。

要了解它是如何工作的,让我们实现它。在保持 BoardGamesController.cs 文件打开的同时,找到 Get() 方法,并通过以下方式将 [SwaggerParameter] 添加到现有输入参数(新行以粗体显示):

public async Task<RestDTO<BoardGame[]>> Get(
    [FromQuery]
    [SwaggerParameter("A DTO object that can be used " +     ?
        "to customize the data-retrieval parameters.")]
    RequestDTO<BoardGameDTO> input)

? 添加 [招摇参数]

现在,描述性属性已经设置好,我们需要通过更新我们的 Swagger 配置来全局启用虚张声势注释功能。

启用批注

若要启用虚张声势注释,请打开 Program.cs 文件,并将以下配置设置添加到现有的 AddSwaggerGen() 方法中:

builder.Services.AddSwaggerGen(options=>
{
    options.EnableAnnotations();     ?
 
    // ... existing code

? 启用虚张声势注释功能

添加最低 API 支持

[SwaggerOperation] 属性以及整个 Swashbuckle 注释功能甚至可以使用最小 API 方法。让我们将其中一些方法添加到循环中。保持 Program.cs 文件打开状态,向下滚动到我们在第 9 章中实现的三个最小 API 方法,以测试 ASP.NET Core 授权功能。然后将 [SwaggerOperation] 属性添加到它们,如以下列表所示(粗体新行)。

清单 11.2 程序.cs文件:向最小 API 方法添加注释

using Swashbuckle.AspNetCore.Annotations;                           ?
 
// ... existing code
 
app.MapGet("/auth/test/1",
[Authorize]
[EnableCors("AnyOrigin")]
[SwaggerOperation(                                                  ?
    Summary="Auth test #1 (authenticated users).",                ?
    Description="Returns 200 - OK if called by " +                ?
    "an authenticated user regardless of its role(s).")]
[ResponseCache(NoStore=true)] ()=>
 
// ... existing code
 
app.MapGet("/auth/test/2",
[Authorize(Roles=RoleNames.Moderator)]
[EnableCors("AnyOrigin")]
[SwaggerOperation(                                                  ?
    Summary="Auth test #2 (Moderator role).",                     ?
    Description="Returns 200 - OK status code if called by " +    ?
    "an authenticated user assigned to the Moderator role.")]
[ResponseCache(NoStore=true)] ()=>
 
// ... existing code
 
app.MapGet("/auth/test/3",
[Authorize(Roles=RoleNames.Administrator)]
[EnableCors("AnyOrigin")]
[SwaggerOperation(                                                  ?
    Summary="Auth test #3 (Administrator role).",                 ?
    Description="Returns 200 - OK if called by " +                ?
    "an authenticated user assigned to the Administrator role.")]
[ResponseCache(NoStore=true)] ()=>
 
// ... existing code

? 必需的命名空间

? [招摇操作]属性

? 添加端点摘要

? 添加端点描述

现在,我们已准备好测试我们所做的工作。

测试注释

要测试我们的新注释,请在调试模式下运行我们的项目,并查看 SwaggerUI 主仪表板,我们应该能够在其中看到它们(图 11.2)。

图 11.2 通过 [SwaggerOperation] 属性添加的 OpenAPI 注释

如我们所见,总体结果与使用 XML 文档方法获得的结果非常相似。但是,我们可以记录每种技术的内容之间存在一些显着差异。例如,XML文档允许我们描述示例(使用<example>元素),Swashbuckle注释目前不支持这些示例。同时,Swashbuckle 注释功能可以使用自定义模式过滤器进行扩展,以支持 Swagger/OpenAPI 规范中提到的几乎任何文档选项。在以下各节中,我们将以互补的方式使用这两种方法,以充分利用它们。

11.2.5 描述响应

用于终结点和输入参数的相同描述性方法也应应用于我们的 Web API 响应。此方法不仅适用于返回的 JavaScript 对象表示法 (JSON) 数据,还适用于 HTTP 状态代码(应始终根据其含义使用)和相关响应标头(如果有)。

同样,为了描述我们的响应,我们可以使用<响应> XML 文档标签或专用的 [SwaggerResponse] Swashbuckle 注释属性。在以下部分中,我们将采用这两种方法。

使用 XML 文档

正如我们之前对 <param> 标签所做的那样,它可以多次用于描述每个输入参数,我们可以为该方法返回的任何 HTTP 状态代码创建一个 <response> 标签。每个 XML <响应>标记都需要一个代码属性(以确定它所描述的响应的 HTTP 状态代码)和一个包含实际说明的基于文本的值。若要对其进行测试,请再次打开 /Controllers/AccountController.cs 文件,并将以下 <response> 标记追加到 Register 方法的现有 XML 文档注释块(粗体新行):

/// <summary>
/// Registers a new user.
/// </summary>
/// <param name="input">A DTO containing the user data.</param>
/// <returns>A 201 - Created Status Code in case of success.</returns>
/// <response code="201">User has been registered</response>    ?
/// <response code="400">Invalid data</response>                ?
/// <response code="500">An error occurred</response>           ?

? HTTP 状态代码 201 说明

? HTTP 状态代码 400 说明

? HTTP 状态代码 500 说明

接下来,向下滚动到 Login 方法,并在其中附加以下 <response> 标记:

/// <summary>
/// Performs a user login.
/// </summary>
/// <param name="input">A DTO containing the user's credentials.</param>
/// <returns>The Bearer Token (in JWT format).</returns>
/// <response code="200">User has been logged in</response>       ?
/// <response code="400">Login failed (bad request)</response>    ?
/// <response code="401">Login failed (unauthorized)</response>   ?

? HTTP 状态代码 200 说明

? HTTP 状态代码 400 说明

? HTTP 状态代码 401 说明

若要测试我们所做的工作,请在调试模式下启动项目,访问 SwaggerUI 主仪表板,然后展开“帐户/注册”和“帐户/登录”终结点。如果我们正确执行了所有操作,我们应该看到我们的响应描述,如图 11.3 所示。


图 11.3 /帐户/登录端点的响应说明

现在我们知道了如何使用 XML 文档注释来获取此结果,让我们看看如何使用 [SwaggerResponse] 数据注释属性实现相同的操作。

使用虚张声势扣批注

[SwaggerResponse] 属性与其对应的 <response> XML 标记对应项一样,可以多次添加到同一方法,以描述受影响的方法可能发送回客户端的所有结果、HTTP 状态代码和响应类型。此外,它还需要两个主要参数:

  • 要描述的响应的 HTTP 状态代码
  • 我们要显示的描述

学习如何使用它的最好方法是看到它的实际效果。打开 Program.cs 文件,向下滚动到 /auth/test/1 最小 API 端点,然后添加新的 [SwaggerResponse] 属性以按以下方式描述其唯一响应:

app.MapGet("/auth/test/1",
    [Authorize]
    [EnableCors("AnyOrigin")]
    [SwaggerOperation(
        Summary="Auth test #1 (authenticated users).",
        Description="Returns 200 - OK if called by " +
        "an authenticated user regardless of its role(s).")]
    [SwaggerResponse(StatusCodes.Status200OK,
        "Authorized")]                                  ?
    [SwaggerResponse(StatusCodes.Status401Unauthorized,
        "Not authorized")]                              ?

? HTTP 状态代码 201 说明

? HTTP 状态代码 401 说明

请注意,我们使用了 Microsoft.AspNetCore 提供的 StatusCodes 枚举。Http 命名空间,它允许我们使用强类型方法指定 HTTP 状态代码。

注意使用基于属性的方法的一个优点是,它为我们提供了 C# 和 ASP.NET Core 功能提供的所有好处,包括但不限于强类型成员。例如,我们可以通过使用 Core 的内置本地化支持(由于篇幅原因,本书中没有介绍 ASP.NET 为不同的语言和/或文化指定不同的描述。

若要测试该属性,请在调试模式下启动项目,访问 SwaggerUI 仪表板,并在 /auth/test/1 终结点的 SwaggerUI 面板的“响应”部分中检查是否存在上述说明(图 11.4)。


图 11.4 /auth/test/1 端点的响应说明

不错。但是,我们的大多数端点不仅发出 HTTP 状态代码;如果请求成功,它们还会返回具有明确定义的预定结构的 JSON 对象。向我们的 API 用户描述这些返回类型,让他们知道会发生什么,这不是很好吗?为了实现这样的目标,我们需要在这些描述中添加一些样本。在下一节中,我们将看到如何操作。

11.2.6 添加请求和响应示例

理想情况下,每个 API 操作都应包含一个请求和响应示例,以便用户了解每个操作的预期工作方式。正如我们已经知道的,我们心爱的 SwaggerUI 负责请求部分的任务;每当我们使用它时,它都会显示一个示例值选项卡,其中包含 JSON 格式的示例输入数据传输对象 (DTO),如图 11.5 所示。


图 11.5 /帐户/注册终结点的响应示例

“示例值”选项卡的右侧是一个简洁的“架构”选项卡,其中显示了对象的架构和许多有用的信息,例如每个字段的最大大小、可为空性和基础类型。遗憾的是,此自动功能并不总是适用于 JSON 响应类型,需要一些手动干预。

注意有时,SwaggerUI 会设法自动检测(并显示示例)响应类型。例如,如果我们展开 GET /BoardGames 端点的 SwaggerUI 面板,则 RestDTO<BoardGame> 对象将正确显示在响应部分中。遗憾的是,当该方法具有多种返回类型时,此方便的功能通常无法自动检测其中的大多数返回类型。下一节中介绍的方法将处理这些方案。

让我们看看如何告诉 SwaggerUI 随时显示响应示例。[ProducesResponseType] 属性附带 Microsoft.AspNetCore.Mvc 命名空间,不是 Swashbuckle 的一部分。但是,由于我们将组件配置为考虑注释,因此 SwaggerUI 将使用它来确定每个方法的响应类型并采取相应的行动。

与 [ProducesResponseType] 属性一起使用的主要参数是响应类型和方法返回的状态代码。同样,由于终结点可以返回不同的响应类型和状态代码,因此可以多次将其添加到每个方法中。我们已经知道 SwaggerUI 无法自动检测 /Account/Register 和 /Account/Login 端点的返回类型,这使得它们成为此属性的完美候选者。

打开 /控制器/帐户控制器.cs 文件,并找到注册操作方法。然后在现有属性下方,在方法声明之前添加以下属性(粗体新行):

[HttpPost]
[ResponseCache(CacheProfileName="NoCache")]
[ProducesResponseType(typeof(string), 201)]                      ?
[ProducesResponseType(typeof(BadRequestObjectResult), 400)]      ?
[ProducesResponseType(typeof(ProblemDetails), 500)]              ?

? HTTP 状态代码 201 说明

? HTTP 状态代码 400 说明

? HTTP 状态代码 500 说明

使用以下属性对 Login 操作方法执行相同的操作:

[HttpPost]
[ResponseCache(CacheProfileName="NoCache")]
[ProducesResponseType(typeof(string), 200)]                   ?
[ProducesResponseType(typeof(BadRequestObjectResult), 400)]   ?
[ProducesResponseType(typeof(ProblemDetails), 401)]           ?

? HTTP 状态代码 200 说明

? HTTP 状态代码 400 说明

? HTTP 状态代码 401 说明

若要测试我们所做的工作,请在调试模式下启动项目,并查看 SwaggerUI 中“/帐户/注册”和“/帐户/登录终结点”面板的“响应”部分,以确保它们看起来像图 11.6 中的那些。


图 11.6 /帐户/注册返回类型的 JSON 示例

图 11.6 中描述的屏幕截图已被裁剪,因为在 HTTP 状态代码 400 的情况下返回的 BadRequestObjectResult 的 JSON 表示形式很长。但是这个数字应该让我们了解我们做了什么。现在我们已经知道如何强制 SwaggerUI 提供响应类型的示例,我们已准备好掌握另一种良好做法:终结点分组。

11.2.7 将端点分组为多个部分

如果 Web API 具有大量终结点,则将它们分组到与其角色/用途对应的部分中可能很有用。在我们的方案中,明智的做法是将身份验证终结点、在棋盘游戏实体上运行的终结点等分组。我们可以说我们已经这样做了,因为我们为每个组都使用了一个控制器,遵循 ASP.NET Core默认行为。正如我们从第 1 章开始就知道的那样,ASP.NET Core 控制器允许我们对一组具有共同主题、含义或记录类型的操作方法进行分组。我们在 MyBGList 方案中采用了此约定,对与棋盘游戏相关的终结点使用 BoardGamesController,对基于域的终结点使用 DomainsController 等。

这种方法由我们当前的 Open API 实现自动实施。如果我们查看 SwaggerUI 仪表板,我们会看到由与同一控制器相关的操作方法处理的 API 端点被分组,如图 11.7 所示。


图 11.7 控制器处理的终结点的 SwaggerUI 组名称

我们可以猜到,这些组是由Swashbuckle自动生成的。此技巧是通过 tags 属性将控制器的名称添加到 swagger.json 文件中来执行的,该属性旨在处理分组任务。

提示有关 Swagger 的标签属性的其他信息,请查看 http://mng.bz/5178

要进行检查,请单击 SwaggerUI 主标题下方的超链接打开 swagger.json 文件,或导航到 https://localhost:40443/swagger/v1/swagger.json。/Account/Register 终结点的 tags 属性位于文件开头附近:

{
  "openapi": "3.0.1",
  "info": {
    "title": "MyBGList",
    "version": "1.0"
  },
  "paths": {
    "/Account/Register": {   ?
      "post": {
        "tags": [            ?
          "Account"
        ],

? /帐户/注册端点说明

? “帐户”标签取自控制者的名称

遗憾的是,此自动行为不适用于最小 API 方法,因为它们不属于控制器。Swashbuckle唯一能做的就是将它们全部列出在一个通用组中,并带有应用程序的名称(在我们的场景中是MyBGList),如图11.8所示。


图 11.8 最小 API 处理的终结点的通用组

这种回退行为的结果还不错。但是,由于我们当前的最小 API 端点处理不同的相关任务集,因此我们可能希望找到一种更好的方法来对它们进行分组。

如果我们想改进 Swashbuckle 的默认标记行为,我们可以使用 [SwaggerOperation] 属性提供的 Tags 属性来覆盖它。让我们测试一下。假设我们要将三个端点分组,从 /auth/ 段开始,在一个名为“Auth”的新 SwaggerUI 部分中。打开程序.cs文件;找到这些方法;并对其现有的 [SwaggerOperation] 属性进行以下更改,从 /auth/test/1 端点开始(新行以粗体显示):

app.MapGet("/auth/test/1",
    [Authorize]
    [EnableCors("AnyOrigin")]
    [SwaggerOperation(
        Tags=new[] { "Auth" },    ?
        Summary="Auth test #1 (authenticated users).",
        Description="Returns 200 - OK if called by " +
        "an authenticated user regardless of its role(s).")]

? 添加标签属性

对 /auth/test/2 和 /auth/test/3 方法执行相同的操作,然后在调试模式下运行项目以查看新的身份验证组(图 11.9)。


图 11.9 与授权相关的终结点的新身份验证组

我们可以使用相同的技术来覆盖属于控制器的操作方法的 Swashbuckle 默认行为。每当 Tags 参数与自定义值一起存在时,Swashbuckle 将始终使用它来填充 swagger.json 文件,而不是回退到控制器或操作方法的名称。

注意如果我们想自定义端点的组名称而不是使用控制器名称,则此覆盖功能会很方便。但是,请务必记住,这种级别的自定义违反了 ASP.NET Core 强制执行的最重要的开发最佳实践之一:配置设计范例约定,该范例旨在限制开发人员需要做出的决策数量以及源代码量,而不会失去灵活性。出于这个原因,我强烈建议遵守控制器的 ASP.NET 核心分组和标记约定,将 Tags 属性自定义做法保留为最小 API 方法和有限数量的异常。

11.2.8 排除保留端点

ApiExplorer 服务 Swashbuckle 用于在我们项目的源代码中自动查找所有控制器的操作方法和最小 API 方法,并在 swagger.json 文件中描述它们,在大多数情况下是一个很棒的功能。但我们可能想要隐藏一些我们不想向观众展示的方法(或整个控制器)。

在我们当前的情况下,这种情况可能适用于 SeedController,其中包含几个旨在由管理员调用和知道的方法。从 swagger.json 文件中排除这些操作可能是明智的,这也将把它们从 SwaggerUI 中删除。

为了实现这个结果,我们可以使用 [ApiExplorerSettings] 属性,其中包含一个有用的 IgnoreApi 属性。此属性可应用于任何控制器、操作方法或最小 API 方法。让我们用它来从 swagger.json 文件中排除我们的 SeedController。打开 /Controllers/SeedController.cs 文件,并按以下方式将该属性应用于类声明:

[Authorize(Roles=RoleNames.Administrator)]
[ApiExplorerSettings(IgnoreApi=true)]       ?
[Route("[controller]/[action]")]
[ApiController]
public class SeedController : ControllerBase

? 从 swagger.json 文件中排除控制器

要测试我们所做的工作,请在调试模式下运行项目;导航到 招摇UI主仪表板;并确认我们之前访问该页面时存在的整个种子部分不再可见。

警告请务必了解,IgnoreApi=true 设置只会阻止控制器及其操作方法包含在 swagger.json 文件中;它不会阻止用户调用(并可能执行)它。这就是为什么我们还通过使用第 9 章中的 [Authorize] 属性将其限制为管理员。

到目前为止,我们已经学习了如何使用 XML 文档或数据注释属性处理各个方法来配置 swagger.json 文件的内容和生成的 SwaggerUI 布局。在下一节中,我们将了解如何基于Swashbuckle过滤器的使用,使用更加结构化和集中的方法执行这些类型的更改。

11.3 基于过滤器的招摇自定义

正如我们从第6章中知道的,Swashbuckle公开了一个方便的过滤器管道,它与swagger.json文件生成过程挂钩,允许我们创建和添加自己的过滤器,以根据需要自定义文件的内容。要实现过滤器,我们需要做的就是扩展 Swashbuckle 提供的内置接口之一,每个接口都提供了一个方便的 Apply 方法来自定义自动生成的文件。以下是Swashbuckle提供的过滤器接口的完整列表:

  • IDocumentFilter - 自定义整个 swagger.json 文件
  • IOperationFilter - 自定义操作/端点
  • IParameterFilter - 自定义操作的查询字符串输入参数
  • IRequestBodyFilter - 自定义操作的请求正文输入参数
  • ISchemaFilter - 自定义输入参数的默认方案

我们在第6章中使用了这个功能,当时我们添加了SortColumnFilter和SortOrderFilter(扩展IParameterFilter接口),为SwaggerUI提供了一些基于正则表达式的模式来验证一些输入参数。Swashbuckle 使用这些过滤器,我们在 /Swagger/ 文件夹中实现了这些过滤器,然后将其添加到 Program.cs 文件中的 Swashbuckle 过滤器管道中,有选择地将模式 JSON 键添加到使用 [SortColumnValidator] 和 [SortOrderValidator] 自定义属性修饰的所有参数。我们所做的是一个简单而完美的过滤器管道如何工作的例子。

在本节中,我们将学习如何使用 Swashbuckle 提供的其他过滤器接口来进一步配置自动生成的 swagger.json 文件,从而相应地更新 SwaggerUI。与往常一样,我们假设我们被要求实现一些可信的新功能请求。

11.3.1 强调授权要求

在第 9 章中,当我们学习如何使用 [Authorize] 属性时,我们在现有的 Swagger 配置设置中添加了安全定义和安全要求。我们这样做是为了使“授权”按钮显示在 SwaggerUI 中,现在允许我们设置持有者令牌并测试受授权限制的终结点。但是这个添加有一个我们当时故意忽略的次要效果:它还在我们的所有端点旁边添加了一个奇怪的挂锁图标,如图 11.10 所示。


图11.10 招摇UI中的挂锁图标

单击这些图标时,将显示“授权”弹出窗口,就像我们在第 9 章中多次使用的页面右上角附近的“授权”按钮一样。但是,无论终结点的授权要求如何,挂锁图标始终显示为打开状态,这不是我们预期的行为。理想情况下,我们希望挂锁图标仅出现在需要某种授权的端点旁边。下一节将介绍如何实现此结果。

在深入研究源代码之前,让我们看看挂锁图标功能在后台是如何工作的。如果端点具有某种安全要求(换句话说,如果需要某种级别的授权),则 SwaggerUI 会自动呈现这些图标。此信息取自 swagger.json 文件,该文件为这些端点分配安全属性:

"security": [{
        "Bearer": [ ]
    }]

在第 9 章中,当我们配置 Swagger 以支持基于令牌的授权机制时,我们使用项目程序.cs文件中的专用配置选项向 swagger.json 文件生成器服务添加了全局安全要求:

// ...existing code
 
options.AddSecurityRequirement(new OpenApiSecurityRequirement
{
    {
        new OpenApiSecurityScheme
        {
            Name="Bearer",
            In=ParameterLocation.Header,
            Reference=new OpenApiReference
            {
                Type=ReferenceType.SecurityScheme,
                Id="Bearer"
            }
        },
        new string[]{}
    }
});
 
// ...existing code

由于这一全局安全要求,我们在所有端点上都设置了安全属性,因为它们被认为受到基于令牌的授权方案的保护,即使它们不是。为了修补此行为,我们需要将该全局要求替换为特定规则,该规则将仅针对受此类方案限制的方法触发。

最有效的方法是使用 IOperationFilter 接口创建自定义筛选器,该接口可以扩展 swagger.json 生成器服务,以便为受影响的操作提供其他信息(或修改现有/默认信息)。在我们的方案中,我们需要一个筛选器,该筛选器可以设置当前分配给所有操作的相同安全要求,但仅适用于应用了 [Authorize] 属性的操作。若要实现此要求,请在 /Swagger/ 根级文件夹中创建一个新的 AuthRequirementsFilter.cs 类文件,并使用以下列表中的源代码填充其内容。

清单 11.3 /Swagger/AuthRequirementsFilter.cs 文件

using Microsoft.AspNetCore.Authorization;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
 
namespace MyBGList.Swagger
{
    internal class AuthRequirementFilter : IOperationFilter
    {
        public void Apply(
            OpenApiOperation operation,
            OperationFilterContext context)
        {
            if (!context.ApiDescription                                 ?
                .ActionDescriptor
                .EndpointMetadata
                .OfType<AuthorizeAttribute>()
                .Any())
                return;                                                 ?
 
            operation.Security=new List<OpenApiSecurityRequirement>   ?
            {
                new OpenApiSecurityRequirement
                {
                    {
                        new OpenApiSecurityScheme
                        {
                            Name="Bearer",
                            In=ParameterLocation.Header,
                            Reference=new OpenApiReference
                            {
                                Type=ReferenceType.SecurityScheme,
                                Id="Bearer"
                            }
                        },
                        new string[]{}
                    }
                }
            };
        }
    }
}

? 检查 [授权] 属性

? 如果不存在,则不执行任何操作

? 如果存在,则确保操作安全

如我们所见,我们的新操作过滤器在内部执行当前在 Program.cs 文件中完成的相同任务。唯一的区别是它跳过了没有 [Authorize] 属性的操作,因为我们不希望它们在 swagger.json 文件(或挂锁图标)中记录任何安全要求。

现在我们有了 AuthRequirementsFilter,我们需要更新 Swagger 生成器配置选项以使用它,而不是我们当前拥有的全局缩放要求。打开程序.cs文件;向下滚动到 AddSwaggerGen 方法;并将现有的 AddSecurityRequirement 语句替换为新的 AddOperationFilter 语句,如下面的代码清单所示。(前面的代码行被注释掉;新的代码行以粗体显示。

清单 11.4 程序.cs文件: AddSwaggerGen 配置更新

using MyBGList.Swagger;                                           ?
 
// ... existing code...
 
//options.AddSecurityRequirement(new OpenApiSecurityRequirement   ?
//{
//    {
//        new OpenApiSecurityScheme
//        {
//            Name="Bearer",
//            In=ParameterLocation.Header,
//            Reference=new OpenApiReference
//            {
//                Type=ReferenceType.SecurityScheme,
//                Id="Bearer"
//            }
//        },
//        new string[]{}
//    }
//});
options.OperationFilter<AuthRequirementFilter>();                 ?
 
// ... existing code...

? 必需的命名空间

? 要删除的先前代码

? 要添加的新代码

提示在本章的 GitHub 存储库中,我注释掉了以前的代码行,而不是删除它们。

为了测试我们所做的工作,我们可以在调试模式下启动项目,并再次查看以前具有挂锁图标的相同端点(图 11.11)。正如我们所看到的,挂锁图标对于可公开访问的端点已经消失,但对于那些需要某种授权的端点来说,挂锁图标仍然存在。我们的自定义IOperationFilter允许我们做我们想做的事情。


图11.11 SwaggerUI中挂锁图标的新行为

11.3.2 更改应用程序标题

假设我们要在 SwaggerUI 中更改应用程序的标题,该标题当前设置为 MyBGList,根据 Swashbuckle 的默认行为,与 ASP.NET Core 项目同名。如果我们查看 swagger.json 文件,我们可以看到托管该值的 JSON 属性称为 title,它是在文档级别设置的父信息属性的一部分:

{
  "openapi": "3.0.1",
  "info": {
    "title": "MyBGList Web API",
    "version": "1.0"
  },

这意味着,如果我们想覆盖它,我们需要创建一个自定义过滤器,允许我们自定义 swagger.json 文件的文档级参数。实现目标的最有效方法是创建自定义 DocumentFilter(通过扩展 IDocumentFilter 接口)并将其添加到筛选器管道中。在 /Swagger/ 根级文件夹中创建一个新的 CustomDocumentFilter.cs 文件,并使用以下清单的内容填充该文件。

清单 11.5 /Swagger/CustomDocumentFilter.cs 文件

using Microsoft.AspNetCore.Authorization;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
 
namespace MyBGList.Swagger
{
    internal class CustomDocumentFilter : IDocumentFilter
    {
        public void Apply(
            OpenApiDocument swaggerDoc,
            DocumentFilterContext context)
        {
            swaggerDoc.Info.Title="MyBGList Web API";   ?
        }
    }
}

? 设置自定义标题

然后,通过按以下方式更新 Program.cs 文件,将文件挂接到 Swashbuckle 的过滤器管道(新行以粗体显示):

options.OperationFilter<AuthRequirementFilter>();   ?
options.DocumentFilter<CustomDocumentFilter>();     ?

? 现有过滤器

? 新过滤器

要测试我们所做的工作,请在调试模式下启动项目,并查看 SwaggerUI 仪表板的新标题(图 11.12)。

图 11.12 SwaggerUI 标题随自定义文档筛选器更改

不错。让我们看看我们可以用IRequestBodyFilter接口做什么。

11.3.3 为密码添加警告文本

假设我们希望在用户需要向我们的 Web API 发送密码时为其设置自定义警告文本。通过查看我们当前的端点,我们可以很容易地确定,目前,这样的警告只会影响账户控制器的注册和登录方法。通过考虑这一事实,我们可以使用 XML 文档注释(第 11.2.3 节)或 [SwaggerOperation] 属性(第 11.2.4 节)将此消息插入到操作的“摘要”或“说明”属性中,正如我们之前所学习的那样。或者,我们可以通过使用 <param> XML 标记或 [SwaggerParameter] 属性在参数级别工作。

这两种方法都有一个不平凡的缺点。如果我们将来添加接受密码的端点,我们还必须在那里重复 XML 标记或数据注释属性,这意味着复制大量代码 — 除非我们忘记这样做,因为这样的方法很容易出错。

为了克服这些问题,最好找到一种方法来集中这种行为,方法是创建一个新的过滤器并将其添加到Swashbuckle的管道中。我们需要确定在可用过滤器接口中扩展哪个过滤器接口。理想情况下,IRequestBodyFilter 接口将是一个不错的选择,考虑到我们希望定位名称等于“密码”的特定参数,该参数目前(并且可能总是)随 POST 请求一起出现。让我们继续这种方法。在 /Swagger/ 根文件夹中创建一个新的 PasswordRequestFilter.cs 文件,并使用以下清单中的代码填充该文件。

清单 11.6 /Swagger/PasswordRequestFilter.cs 文件

using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
 
namespace MyBGList.Swagger
{
    internal class PasswordRequestFilter : IRequestBodyFilter
    {
        public void Apply(
            OpenApiRequestBody requestBody,
            RequestBodyFilterContext context)
        {
            var fieldName="password";                         ?
 
            if (context.BodyParameterDescription.Name
                .Equals(fieldName,
                    StringComparison.OrdinalIgnoreCase)         ?
                || context.BodyParameterDescription.Type
                .GetProperties().Any(p=> p.Name
                    .Equals(fieldName,
                        StringComparison.OrdinalIgnoreCase)))   ?
            {
                requestBody.Description=            "IMPORTANT: be sure to always use a strong password " +
                    "and store it in a secure location!";
            }
        }
    }
}

? 输入参数名称

? 名称检查(基元类型)

? 属性检查(复杂型)

通过查看此代码,我们检查输入参数名称是否等于“password”(对于基元类型)或包含具有该名称的属性(对于复杂类型,例如 DTO)。现在我们有了过滤器,我们需要通过以下方式在 Swashbuckle 的过滤器管道中注册它,在我们之前添加的 AuthRequirementsFilter 和 CustomDocumentFilter 下面:

options.OperationFilter<AuthRequirementFilter>();     ?
options.DocumentFilter<CustomDocumentFilter>();       ?
options.RequestBodyFilter<PasswordRequestFilter>();   ?

? 现有过滤器

? 新过滤器

与往常一样,我们可以通过在调试模式下执行项目并检查 SwaggerUI 中的预期更改来测试我们所做的工作(图 11.13)。


图 11.13 密码请求筛选器添加的新描述

这些变化似乎奏效了。由于这种方法,我们的密码警告消息将涵盖我们的两个现有终结点以及在其请求正文中接受密码参数的任何未来终结点。

注意如果我们想将覆盖范围扩展到查询字符串参数,我们需要添加另一个扩展 IParameterFilter 接口并执行相同工作的筛选器,然后使用 ParameterFilter 帮助程序方法将其注册到 Program.cs 文件中。

现在要完成我们的过滤器概述,剩下要做的就是 ISchemaFilter 接口。

11.3.4 添加自定义键/值对

让我们再看一下我们在第 6 章中实现的 SortColumnFilter 和 SortOrderFilter 类。扩展 IParameterFilter 接口是个好主意,因为我们只需要处理来自查询字符串的一些特定输入参数。换句话说,我们希望将模式键添加到 swagger.json 文件中这些参数的 JSON 模式中,从用于标识它们的相同数据注释属性 [SortColumnAttribute] 或 [SortOrderAttribute] 中获取值。

假设我们要扩展该方法以实现一个新的过滤器,该过滤器能够将任意 JSON 键(和值)添加到任何属性,无论是请求参数、响应参数还是其他任何内容。在本节中,我们将通过实现以下内容来实现这一目标:

  • 自定义数据注释属性,这将允许我们为任何属性设置一个或多个自定义 JSON 键和值对
  • 一个自定义 SchemaFilter,它扩展了 ISchemaFilter 接口,将这些键和值对添加到应用了这些数据注释属性的所有参数、响应和属性中

ISchemaFilter 接口是处理此任务的完美选择,因为它专门设计用于对 Swashbuckle 的 SwaggerGen 服务生成的 JSON 模式进行后修改,用于控制器操作和最小 API 方法公开的每个输入和输出参数以及复杂类型。现在我们已经选择了我们的路线,让我们把它付诸实践。

实现自定义键值属性

在 Visual Studio 的“解决方案资源管理器”面板中,右键单击 MyBGList 项目根目录中的 /Attributes/ 文件夹,并添加新的 CustomKeyValueAttribute.cs 类文件,其中包含两个字符串属性:Key 和 Value。下面的清单提供了新类的源代码。

11.7 自定义键值属性

namespace MyBGList.Attributes
{
    [AttributeUsage(
        AttributeTargets.Property | AttributeTargets.Parameter,
        AllowMultiple=true)]
    public class CustomKeyValueAttribute : Attribute
    {
        public CustomKeyValueAttribute(string? key, string? value)
        {
            Key=key;
            Value=value;
        }
 
        public string? Key { get; set; }
 
        public string? Value { get; set; }
    }
}

请注意,我们已经使用 [AttributeUsage] 属性装饰了我们的新类,它允许我们指定属性的使用。我们这样做有两个重要原因:

  • 若要允许将属性应用于属性和参数,请使用 AttributeTargets 枚举。
  • 允许多次应用该属性,因为 AllowMultiple 属性设置为 true。此设置是必需的,因为我们希望有机会将多个 [SwaggerSchema] 属性(从而设置多个自定义键/值对)应用于单个属性或参数。

现在我们有了属性,我们准备实现将处理它的筛选器。

实现自定义键值筛选器

在 /Swagger/ 文件夹中添加新的 CustomKeyValueFilter.cs 类文件。新类必须实现 ISchemaFilter 接口及其 Apply 方法,我们将在其中处理 [CustomKeyValue] 属性查找和 JSON 键/值对插入过程。以下清单显示了如何操作。

清单 11.8 自定义键值过滤器

using Microsoft.OpenApi.Any;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
 
namespace MyBGList.Attributes
{
    public class CustomKeyValueFilter : ISchemaFilter
    {
        public void Apply(
            OpenApiSchema schema,
            SchemaFilterContext context)
        {
            var caProvider=context.MemberInfo
                ?? context.ParameterInfo
                as IcustomAttributeProvider;            ?
 
            var attributes=caProvider?
                .GetCustomAttributes(true)
                .OfType<CustomKeyValueAttribute>();     ?
 
            if (attributes !=null)                     ?
            {
                foreach (var attribute in attributes)
                {
                    schema.Extensions.Add(
                        attribute.Key,
                        new OpenApiString(attribute.Value)
                        );
                }
            }
        }
    }
}

? 确定我们是在处理属性还是参数

? 检查参数是否具有属性

? 如果存在一个或多个属性,则相应地采取行动

此代码应易于理解。我们将使用语言集成查询 (LINQ) 检查 ISchemaFilter 接口提供的上下文,以确定我们的属性或参数是否应用了一个或多个 [CustomKeyValue] 属性并采取相应的操作。我们现在需要做的就是将新的过滤器添加到 Swashbuckle 的过滤器管道中。与往常一样,我们可以通过以下方式更新程序.cs文件:

options.OperationFilter<AuthRequirementFilter>();    ?
options.DocumentFilter<CustomDocumentFilter>();      ?
options.RequestBodyFilter<PasswordRequestFilter>();  ?
options.SchemaFilter<CustomKeyValueFilter>();        ?

? 现有过滤器

? 新过滤器

现在我们的两个类已经准备就绪,并且过滤器已经注册,我们可以通过将 [CustomKeyValue] 属性应用于现有 DTO 之一的属性来测试 [CustomKeyValue] 属性。让我们选择帐户控制器的登录操作方法使用的登录DTO。打开 /DTO/LoginDTO.cs 文件,并按以下方式将其中几个属性应用于现有 UserName 属性:

[Required]
[MaxLength(255)]
[CustomKeyValue("x-test-1", "value 1")]   ?
[CustomKeyValue("x-test-2", "value 2")]   ?
public string? UserName { get; set; }

? 第一个自定义键值属性

接下来,在调试模式下运行项目,访问 SwaggerUI 仪表板,然后单击主标题下方的 swagger.json 文件链接(图 11.14),在新选项卡中打开它。


图 11.14 swagger.json 文件 URL

使用浏览器的搜索功能在 swagger.json 文件中查找“x-test-”字符串。如果我们正确执行了所有操作,我们应该在 LoginDTO 的用户名属性的 JSON 架构中看到此字符串的两个条目,如以下列表所示。

清单 11.9 swagger.json 文件 (登录DTO 模式)

      "LoginDTO": {
        "required": [
          "password",
          "userName"
        ],
        "type": "object",
        "properties": {
          "userName": {
            "maxLength": 255,
            "minLength": 1,
            "type": "string",
            "x-test-1": "value 1",   ?
            "x-test-2": "value 2"    ?
          },
          "password": {
            "minLength": 1,
            "type": "string"
          }
        }

? 自定义键/值对

目前为止,一切都好。让我们执行另一个测试,以确保相同的逻辑适用于基元类型的标准 GET 参数。打开 /Controllers/BoardGamesController.cs 文件,向下滚动到 Get 操作方法,接受 int 类型的单个 id 参数,然后按以下方式向该参数添加 [CustomKeyValue] 属性:

[HttpGet("{id}")]
[ResponseCache(CacheProfileName="Any-60")]
[SwaggerOperation(
    Summary="Get a single board game.",
    Description="Retrieves a single board game with the given Id.")]
public async Task<RestDTO<BoardGame?>> Get(
    [CustomKeyValue("x-test-3", "value 3")]    ?
    int id
    )

? 添加新的 [自定义键值] 属性

接下来,在调试模式下运行项目,像我们之前一样访问 swagger.json 文件内容,并再次检查其中是否存在“x-test-”字符串。这一次,我们应该找到三个条目,最后一个是我们添加的条目(请参阅下面的列表)。

清单 11.10 swagger.json 文件 (/BoardGames/{id} 端点模式)

    "/BoardGames/{id}": {
      "get": {
        "tags": [
          "BoardGames"
        ],
        "summary": "Get a single board game.",
        "description": "Retrieves a single board game with the given Id.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int32",
              "x-test-3": "value 3"    ?
            }
          }
        ],

? 自定义键/值对

我们的自定义键/值功能似乎运行良好。最后一个任务结束了我们了解 Swashbuckle 的过滤器管道和 API 文档概述的旅程。现在唯一要做的就是学习将我们的 Web API 项目部署到生产环境中,这是第 12 章的主题。

11.4 练习

是时候用我们的产品所有者给出的一系列新的假设任务分配来挑战自己了。与往常一样,处理这些任务将极大地帮助我们记住和记住本章中涵盖的概念和学到的技术。

注意练习的解决方案可在 GitHub 的 /Chapter_11/Exercises/ 文件夹中找到。若要测试它们,请将 MyBGList 项目中的相关文件替换为该文件夹中的文件,然后运行应用。

11.4.1 使用 XML 文档

使用 XML 文档方法,按以下方式描述 GET /Domains 终结点:

  • 摘要 - 获取域列表
  • 描述 - 检索具有自定义分页、排序和过滤规则的域列表
  • 参数 - 可用于自定义某些检索参数的 DTO 对象
  • 返回 - 包含域列表的 RestDTO 对象

提示可以使用 <备注> XML 元素添加说明。

11.4.2 使用虚张声势的注释

使用 Swashbuckle 注释方法,按以下方式描述 GET /Mechanics 终结点:

  • 摘要 - 获取机制列表
  • 描述 - 检索具有自定义分页、排序和过滤规则的机制列表
  • 参数 - 可用于自定义某些检索参数的 DTO 对象
  • 返回 - 包含机制列表的 RestDTO 对象

11.4.3 排除某些端点

使用 [ApiExplorerSettings] 属性从 swagger.json 文件中隐藏以下端点:

  • 帖子/域
  • 删除/域

然后,确保这些终结点也从 SwaggerUI 仪表板中排除。

11.4.4 添加自定义过滤器

扩展 IRequestBodyFilter 接口以实现新的 UsernameRequestFilter,该筛选器将向名称等于“用户名”的任何输入参数添加以下说明。然后在 Swashbuckle 的过滤器管道中注册新过滤器,并通过检查 POST 帐户/登录和 POST 帐户/注册端点使用的 sername 参数在 SwaggerUI 仪表板中对其进行测试。

警告请务必记住您的用户名,因为您需要它来执行登录!

11.4.5 添加自定义键/值对

使用 [自定义键值] 属性将以下键/值对添加到 DELETE 机制端点的现有 id 参数:

  • 键:x-测试-4,值:值 4
  • 键:x-测试-5,值:值 5

然后检查 swagger.json 文件中终结点的 JSON 架构中是否存在新属性。

总结

  • 编写良好的文档可以大大增加或加速 Web API 的采用。
    • 因此,请务必确定 API 文档最佳实践并学习使用 ASP.NET Core 内置和第三方工具遵循这些最佳实践。
  • 确定我们 Web API 的潜在受众(期望选择和/或使用它的利益相关者)可以帮助我们编写引人注目的文档。理想情况下,我们需要满足
    • 渴望尝试我们所做的事情的早期采用者(探矿者)。
    • 旨在评估我们工作的 IT 解决方案架构师(承包商)。
    • 将被要求实现我们 Web API 端点的软件开发人员(构建器)。
  • 在不忘记其他两个受众群体(探矿者和承包商)的情况下关注建筑商的需求几乎总是要走的路。
  • 开发人员善于分析、精确且要求苛刻。为了满足他们的期望,请务必遵守 IT 行业广泛采用的一些众所周知的文档最佳实践,包括
    • 采用自动描述工具。
    • 描述端点、输入参数和响应。
    • 提供请求和响应示例。
    • 将终结点分组为多个部分。
    • 强调授权要求。
    • 自定义文档上下文。
  • Swagger/OpenAPI 框架提供了一种标准化的方法来记录和描述 API,使用每个人都能理解的通用语言。
    • 借助Swashbuckle,我们可以使用Swagger为我们的Web API创建文档:一组服务,中间件和工具,允许我们在遵循之前确定的最佳实践的同时 ASP.NET Core中实现OpenAPI规范。
  • Swashbuckle 公开了一组方便的数据属性,以及一个强大的过滤器管道,可用于对自动生成的 swagger.json 文件进行后修改,从而自定义 API 文档以满足我们的需求。
    • Swashbuckle 的功能使我们能够改进操作、输入参数和输出参数的描述,以及向现有 JSON 模式添加自定义键/值对。


们现在正处于可以构建一个 Web 应用程序的阶段,该应用程序可以使用不同的方法和数据管理一系列 HTTP 请求。 这很有用,特别是当我们为微服务构建服务器时。 然而,我们也希望非程序员能够与我们的应用程序交互来使用它。 为了使非程序员能够使用我们的应用程序,我们必须创建一个图形用户界面。 不过,必须注意的是,本章包含的 Rust 内容并不多。 这是因为存在其他语言来呈现图形用户界面。 我们将主要使用 HTML、JavaScript 和 CSS。 这些工具已经成熟并广泛用于前端 Web 开发。 虽然我个人很喜欢 Rust(否则我不会写一本关于它的书),但我们必须使用正确的工具来完成正确的工作。 在撰写本书时,我们可以使用 Yew 框架在 Rust 中构建前端应用程序。 然而,能够将更成熟的工具融合到我们的 Rust 技术堆栈中是一项更有价值的技能。

本章将涵盖以下主题:

使用 Rust 提供 HTML、CSS 和 JavaScript 服务

构建连接到 Rust 服务器的 React 应用程序

将我们的 React 应用程序转换为要安装在计算机上的桌面应用程序

在上一版本(Rust Web 编程:使用 Rust 编程语言开发快速、安全的 Web 应用程序的实践指南)中,我们只是直接从 Rust 提供前端资产。 然而,由于反馈和修订,这不能很好地扩展,导致大量重复。 由于使用这种方法的非结构化性质,由 Rust 直接提供的原始 HTML、CSS 和 JavaScript 也容易出错,这就是为什么在第二版中,我们将介绍 React 并简要介绍如何提供前端资产 直接使用 Rust。 到本章结束时,您将能够在没有任何依赖的情况下编写基本的前端图形界面,并了解低依赖前端解决方案和完整前端框架(例如 React)之间的权衡。 您不仅会了解何时使用它们,而且还能够在项目需要时实施这两种方法。 因此,您将能够为正确的工作选择正确的工具,并在后端使用 Rust 并在前端使用 JavaScript 构建端到端产品。

使用 Rust 提供 HTML、CSS 和 JavaScript 服务

在上一章中,我们以 JSON 的形式返回了所有数据。 在本节中,我们将返回 HTML 数据供用户查看。 在此 HTML 数据中,我们将具有按钮和表单,使用户能够与我们在上一章中定义的 API 端点进行交互,以创建、编辑和删除待办事项。 为此,我们需要构建自己的应用程序视图模块,该模块采用以下结构:

views
├── app
│   ├── items.rs
│   └── mod.rs

提供基本的 HTML

在我们的 items.rs 文件中,我们将定义显示待办事项的主视图。 但是,在此之前,我们应该探索在 items.rs 文件中返回 HTML 的最简单方法:

use actix_web::HttpResponse;
pub async fn items() -> HttpResponse {
    HttpResponse::Ok()
        .content_type("text/html; charset=utf-8")
        .body("<h1>Items</h1>")
}

在这里,我们简单地返回一个 HttpResponse 结构,该结构具有 HTML 内容类型和 <h1>Items</h1> 主体。 要将 HttpResponse 传递到应用程序中,我们必须在 app/views/mod.rs 文件中定义我们的工厂,如下所示:

use actix_web::web;
mod items;
pub fn app_views_factory(app: &mut web::ServiceConfig) {
    app.route("/", web::get().to(items::items));
}

在这里,我们可以看到,我们只是为应用程序定义了一条路由,而不是构建服务。 这是因为这是登陆页面。 如果我们要定义服务而不是路由,我们将无法在没有前缀的情况下定义服务的视图。

一旦我们定义了app_views_factory,我们就可以在views/mod.rs 文件中调用它。 然而,首先,我们必须在views/mod.rs文件的顶部定义app模块:

mod app;

一旦我们定义了应用程序模块,我们就可以在同一文件中的views_factory函数中调用应用程序工厂:

app::app_views_factory(app);

现在我们的 HTML 服务视图是我们应用程序的一部分,我们可以运行它并在浏览器中调用主 URL,给出以下输出:


图 5.1 – 第一个呈现的 HTML 视图

我们可以看到我们的 HTML 已渲染! 根据图 5.1 中的内容,我们可以推断出我们可以在响应正文中返回一个字符串,其中包含以下内容:

HttpResponse::Ok()
    .content_type("text/html; charset=utf-8")
    .body("<h1>Items</h1>")

如果字符串是 HTML 格式,则会呈现 HTML。 根据这个启示,您认为我们如何从 Rust 服务器提供的 HTML 文件中渲染 HTML? 在继续之前,想一想——这将锻炼你解决问题的能力。

从文件中读取基本 HTML

如果我们有一个 HTML 文件,我们只需将该 HTML 文件准备为一个字符串并将该字符串插入到 HttpResponse 的正文中即可呈现它。 是的,就是这么简单。 为了实现这一目标,我们将构建一个内容加载器。

要构建基本的内容加载器,首先在views/app/content_loader.rs文件中构建HTML文件读取函数:

use std::fs;
pub fn read_file(file_path: &str) -> String {
    let data: String=fs::read_to_string(
        file_path).expect("Unable to read file");
    return data
}

我们在这里要做的就是返回一个字符串,因为这就是我们响应正文所需的全部内容。 然后,我们必须在views/app/mod.rs文件中使用mod content_loader定义加载器; 文件顶部的行。

现在我们有了加载功能,我们需要一个 HTML 目录。 这可以与称为 templates 的 src 目录一起定义。 在 templates 目录中,我们可以添加一个名为 templates/main.html 的 HTML 文件,其中包含以下内容:

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charSet="UTF-8"/>
        <meta name="viewport"
              content="width=device-width, initial-
                                           scale=1.0"/>
        <meta httpEquiv="X-UA-Compatible"
              content="ie=edge"/>
        <meta name="description"
              content="This is a simple to do app"/>
        <title>To Do App</title>
    </head>
    <body>
        <h1>To Do Items</h1>
    </body>
</html>

在这里,我们可以看到我们的 body 标签具有与我们之前呈现的内容相同的内容 - 即 <h1>To Do Items</h1>。 然后,我们有一个 head 标签,它定义了一系列元标签。 我们可以看到我们定义了视口。 这告诉浏览器如何处理页面内容的尺寸和缩放。 缩放很重要,因为我们的应用程序可以通过一系列不同的设备和屏幕尺寸来访问。 通过这个视口,我们可以将页面的宽度设置为与设备屏幕相同的宽度。 然后,我们可以将访问的页面的初始比例设置为1.0。 转到 httpEquiv 标签,我们将其设置为 X-UA-Compatible,这意味着我们支持旧版浏览器。 最终标签只是搜索引擎可以使用的页面的描述。 我们的标题标签确保待办事项应用程序显示在浏览器标签上。 这样,我们的正文中就有了标准的标题标题。

提供从文件加载的基本 HTML

现在我们已经定义了 HTML 文件,我们必须加载并提供它。 回到我们的 src/views/app/items.rs 文件,我们必须加载 HTML 文件并使用以下代码提供服务:

use actix_web::HttpResponse;
use super::content_loader::read_file;
pub async fn items() -> HttpResponse {
    let html_data=read_file(
        "./templates/main.html");
    HttpResponse::Ok()
        .content_type("text/html; charset=utf-8")
        .body(html_data)
}

如果我们运行我们的应用程序,我们将得到以下输出:


图 5.2 – 加载 HTML 页面的视图

在图 5.2 中,我们可以看到输出与之前相同。 这并不奇怪; 但是,我们必须注意到,图 5.2 中的选项卡现在显示了“To Do App”,这意味着 HTML 文件中的元数据正在加载到视图中。 没有什么可以阻止我们充分利用 HTML 文件。 现在我们的 HTML 文件已经提供,我们可以继续我们的下一个目标,即向我们的页面添加功能。

将 JavaScript 添加到 HTML 文件

如果前端用户无法对我们的待办事项状态执行任何操作,那么这对前端用户来说就没有用。 在修改之前,我们需要通过查看下图来了解 HTML 文件的布局:


图 5.3 – HTML 文件的一般布局

在图 5.3 中,我们可以看到我们可以在标头中定义元标记。 然而,我们也可以看到我们可以在标题中定义样式标签。 在标题下方的样式标签中,我们可以将 CSS 插入到样式中。 在主体下方,还有一个脚本部分,我们可以在其中注入 JavaScript。 该 JavaScript 在浏览器中运行并与正文中的元素交互。 由此,我们可以看到,提供加载了 CSS 和 JavaScript 的 HTML 文件提供了一个功能齐全的前端单页应用程序。 至此,我们可以反思一下本章的介绍。 虽然我喜欢 Rust,并且强烈希望告诉你用它来编写所有内容,但这对于软件工程中的任何语言来说都不是一个好主意。 现在,我们可以轻松地使用 JavaScript 提供功能性前端视图,使其成为满足您前端需求的最佳选择。

使用 JavaScript 与我们的服务器通信

现在我们知道了将 JavaScript 插入到 HTML 文件中的位置,我们可以测试我们的方向了。 在本节的其余部分中,我们将在 HTML 正文中创建一个按钮,将其融合到 JavaScript 函数,然后让浏览器在按下该按钮时打印出带有输入消息的警报。 这对我们的后端应用程序没有任何作用,但它将证明我们对 HTML 文件的理解是正确的。 我们可以将以下代码添加到 templates/main.html 文件中:

<body>
    <h1>To Do Items</h1>
    <input type="text" id="name" placeholder="create to do 
         item">
    <button id="create-button" value="Send">Create</button>
</body>
<script>
    let createButton=document.getElementById("create-
        button");
    createButton.addEventListener("click", postAlert);
    function postAlert() {
        let titleInput=document.getElementById("name");
        alert(titleInput.value);
        titleInput.value=null;
    }
</script>

在我们的正文部分,我们可以看到我们定义了一个输入和一个按钮。 我们为输入和按钮属性提供唯一的 ID 名称。 然后,我们使用按钮的 ID 添加事件监听器。 之后,我们将 postAlert 函数绑定到该事件侦听器,以便在单击按钮时触发。 当我们触发 postAlert 函数时,我们使用其 ID 获取输入并打印出警报中的输入值。 然后,我们将input的值设置为null,以便用户可以填写另一个要处理的值。 提供新的 main.html 文件,在输入中进行测试,然后单击按钮将产生以下输出:


图 5.4 – 连接到 JavaScript 中的警报时单击按钮的效果


我们的 JavaScript 不必停止让元素在主体中交互。 我们还可以使用 JavaScript 对后端 Rust 应用程序执行 API 调用。 然而,在我们匆忙将整个应用程序写入 main.html 文件之前,我们必须停下来思考一下。 如果我们这样做,main.html 文件就会膨胀成一个巨大的文件。 调试起来会很困难。 此外,这可能会导致代码重复。 如果我们想在其他视图中使用相同的 JavaScript 怎么办? 我们必须将其复制并粘贴到另一个 HTML 文件中。 这无法很好地扩展,如果我们需要更新某个函数,我们可能会面临忘记更新某些重复函数的风险。 这就是 React 等 JavaScript 框架派上用场的地方。 我们将在本章后面探讨 React,但现在,我们将通过提出一种将 JavaScript 与 HTML 文件分离的方法来完成我们的低依赖前端。

必须警告的是,我们实际上是使用此 JavaScript 手动动态重写 HTML。 人们可以将其描述为“hacky”解决方案。 然而,在探索 React 之前,重要的是要先掌握我们的方法,才能真正体会到不同方法的好处。 在继续下一部分之前,我们必须在 src/views/to_do/create.rs 文件中重构我们的创建视图。 这是一个很好的机会来回顾我们在前几章中开发的内容。 您必须本质上转换创建视图,以便它返回待办事项的当前状态而不是字符串。 尝试此操作后,解决方案应如下所示:

use actix_web::HttpResponse;
use serde_json::Value;
use serde_json::Map;
use actix_web::HttpRequest;
use crate::to_do::{to_do_factory, enums::TaskStatus};
use crate::json_serialization::to_do_items::ToDoItems;
use crate::state::read_file;
use crate::processes::process_input;
pub async fn create(req: HttpRequest) -> HttpResponse {
    let state: Map<String, Value>= read_file("./state.json");
    let title: String=req.match_info().get("title"
    ).unwrap().to_string();
    let item=to_do_factory(&title.as_str(), 
        TaskStatus::PENDING);
    process_input(item, "create".to_string(), &state);
    return HttpResponse::Ok().json(ToDoItems::get_state())
}

现在,我们所有的待办事项均已更新并正常运行。 现在我们可以进入下一部分,我们将让前端调用后端。

将 JavaScript 注入 HTML

完成本节后,我们将拥有一个不太漂亮但功能齐全的主视图,我们可以在其中使用 JavaScript 调用 Rust 服务器来添加、编辑和删除待办事项。 但是,您可能还记得,我们没有添加删除 API 端点。 要将 JavaScript 注入到 HTML 中,我们必须执行以下步骤:

创建删除项目 API 端点。

添加 JavaScript 加载功能,并将 HTML 数据中的 JavaScript 标签替换为主项 Rust 视图中加载的 JavaScript 数据。

在 HTML 文件中添加 JavaScript 标签,并在 HTML 组件中添加 ID,以便我们可以在 JavaScript 中引用组件。

在 JavaScript 中为我们的待办事项构建一个渲染函数,并通过 ID 将其绑定到我们的 HTML。

在 JavaScript 中构建一个 API 调用函数来与后端对话。

在 JavaScript 中构建获取、删除、编辑和创建函数,供我们的按钮使用。

让我们详细看看这一点。

添加删除端点

现在添加删除 API 端点应该很简单。 如果您愿意,建议您自己尝试并实现此视图,因为您现在应该已经熟悉此过程了:

如果您遇到困难,我们可以通过将以下第三方依赖项导入到views/to_do/delete.rs 文件中来实现此目的:

use actix_web::{web, HttpResponse};

use serde_json::value::Value;

use serde_json::Map;

这些并不新鲜,您应该熟悉它们并知道我们需要在哪里使用它们。

然后,我们必须使用以下代码导入结构和函数:

use crate::to_do::{to_do_factory, enums::TaskStatus};

use crate::json_serialization::{to_do_item::ToDoItem, 

    to_do_items::ToDoItems};

use crate::processes::process_input;

use crate::jwt::JwToken;

use crate::state::read_file;

在这里,我们可以看到我们正在使用 to_do 模块来构建我们的待办事项。 通过我们的 json_serialization 模块,我们可以看到我们正在接受 ToDoItem 并返回 ToDoItems。 然后,我们使用 process_input 函数执行项目的删除。 我们也不希望任何可以访问我们页面的人删除我们的项目。 因此,我们需要 JwToken 结构。 最后,我们使用 read_file 函数读取项目的状态。

现在我们已经拥有了所需的一切,我们可以使用以下代码定义删除视图:

pub async fn delete(to_do_item: web::Json<ToDoItem>, 

    token: JwToken) -> HttpResponse {

    . . .

}

在这里,我们可以看到我们已经接受了 JSON 形式的 ToDoItem,并且我们已经为视图附加了 JwToken,以便用户必须有权访问它。 此时,我们只有 JwToken 附加一条消息; 我们将在第 7 章“管理用户会话”中管理 JwToken 的身份验证逻辑。

在删除视图中,我们可以通过使用以下代码读取 JSON 文件来获取待办事项的状态:

let state: Map<String, Value>=read_file("./state.json");

然后,我们可以检查具有该标题的项目是否处于该状态。 如果不是,那么我们返回一个未找到的 HTTP 响应。 如果是,我们就会传递状态,因为我们需要标题和状态来构建项目。 我们可以使用以下代码来实现这种检查和状态提取:

let status: TaskStatus;

match &state.get(&to_do_item.title) {

    Some(result)=> {

        status=TaskStatus::from_string

                 (result.as_str().unwrap().to_string()                 );

    }

    None=> {

        return HttpResponse::NotFound().json(

            format!("{} not in state", 

                     &to_do_item.title))

    }

}

现在我们有了待办事项的状态和标题,我们可以构建我们的项目并使用删除命令将其传递到 process_input 函数。 这将从 JSON 文件中删除我们的项目:

let existing_item=to_do_factory(to_do_item.title.as_    str(),

    status.clone());

process_input(existing_item, "delete".    to_owned(), 

    &state);

请记住,我们为 ToDoItems 结构实现了 Responder 特征,并且 ToDoItems::get_state() 函数返回一个 ToDoItems 结构,其中填充了 JSON 文件中的项目。 因此,我们可以从删除视图中得到以下返回语句:

return HttpResponse::Ok().json(ToDoItems::get_state())

现在我们的删除视图已经定义了,我们可以将其添加到我们的 src/views/to_do/mod.rs 文件中,导致我们的视图工厂如下所示:

mod create;

mod get;

mod edit;

mod delete;

use actix_web::web::{ServiceConfig, post, get, scope};

pub fn to_do_views_factory(app: &mut ServiceConfig) {

    app.service(

        scope("v1/item")

        .route("create/{title}", 

                post().to(create::create))

        .route("get", get().to(get::get))

        .route("edit", post().to(edit::edit))

        .route("delete", post().to(delete::delete))

    );

}

通过快速检查 to_do_views_factory,我们可以看到我们拥有管理待办事项所需的所有视图。 如果我们将该模块从应用程序中弹出并将其插入另一个应用程序中,我们将立即看到我们正在删除和添加的内容。

将删除视图完全集成到应用程序中后,我们可以继续第二步,即构建 JavaScript 加载功能。

添加 JavaScript 加载功能

现在我们的所有端点都已准备就绪,我们必须重新访问我们的主应用程序视图。 在上一节中,我们确定 <script> 部分中的 JavaScript 可以正常工作,即使它只是一个大字符串的一部分。 为了使我们能够将 JavaScript 放入单独的文件中,我们的视图会将 HTML 文件作为字符串加载,该字符串在 HTML 文件的 <script> 部分中具有 {{JAVASCRIPT}} 标记。 然后,我们将 JavaScript 文件作为字符串加载,并将 {{JAVASCRIPT}} 标记替换为 JavaScript 文件中的字符串。 最后,我们将在views/app/items.rs文件中返回正文中的完整字符串:

pub async fn items() -> HttpResponse {
    let mut html_data=read_file(
        "./templates/main.html");
    let javascript_data=read_file(
        "./javascript/main.js");
    html_data=html_data.replace("{{JAVASCRIPT}}", 
        &javascript_data);
    HttpResponse::Ok()
        .content_type("text/html; charset=utf-8")
        .body(html_data)
}

在 HTML 中添加 JavaScript 标签

从上一步中的 items 函数中,我们可以看到我们需要在根目录中构建一个名为 JavaScript 的新目录。 我们还必须在其中创建一个名为 main.js 的文件。 通过对应用程序视图的更改,我们还必须通过添加以下代码来更改 templates/main.html 文件:

<body>
    <h1>Done Items</h1>
    <div id="doneItems"></div>
    <h1>To Do Items</h1>
    <div id="pendingItems"></div>
    <input type="text" id="name" placeholder="create to do
     item">
    <button id="create-button" value="Send">Create</button>
</body>
<script>
    {{JAVASCRIPT}}
</script>

回想一下,我们的端点返回待处理项目和已完成项目。 因此,我们用自己的标题定义了这两个列表。 ID 为“doneItems”的 div 是我们将通过 API 调用插入已完成的待办事项的位置。

然后,我们将从 API 调用中插入 ID 为“pendingItems”的待处理项目。 之后,我们必须定义一个带有文本和按钮的输入。 这将供我们的用户创建一个新项目。

构建渲染 JavaScript 函数

现在我们的 HTML 已经定义好了,我们将在 javascript/main.js 文件中定义逻辑:

我们要构建的第一个函数将在主页面上呈现所有待办事项。 必须注意的是,这是 javascript/main.js 文件中代码中最复杂的部分。 我们本质上是在编写 JavaScript 代码来编写 HTML 代码。 稍后,在创建 React 应用程序部分中,我们将使用 React 框架来代替执行此操作的需要。 现在,我们将构建一个渲染函数来创建一个项目列表。 每个项目都采用以下 HTML 形式:

<div>

    <div>

        <p>learn to code rust</p>

        <button id="edit-learn-to-code-rust">

            edit

        </button>

    </div>

</div>

我们可以看到待办事项的标题嵌套在段落 HTML 标记中。 然后,我们有一个按钮。 回想一下,HTML 标记的 id 属性必须是唯一的。 因此,我们根据按钮将要执行的操作以及待办事项的标题来构造此 ID。 这将使我们能够使用事件侦听器将执行 API 调用的函数绑定到这些 id 属性。

为了构建我们的渲染函数,我们必须传入要渲染的项目、我们要执行的处理类型(即编辑或删除)、我们所在的 HTML 部分的元素 ID 将渲染这些项目,以及我们将绑定到每个待办事项按钮的功能。 该函数的概要定义如下:

function renderItems(items, processType, 

    elementId, processFunction) {

 . . .

}

在 renderItems 函数中,我们可以首先构建 HTML 并使用以下代码循环遍历我们的待办事项:

let itemsMeta=[];

let placeholder="<div>"

for (let i=0; i < items.length; i++) {

    . . .

}

placeholder +="</div>"

document.getElementById(elementId).innerHTML=placeholder;

在这里,我们定义了一个数组,用于收集有关我们为每个待办事项生成的待办事项 HTML 的元数据。 它位于 itemsMeta 变量下,稍后将在 renderItems 函数中使用,以使用事件侦听器将 processFunction 绑定到每个待办事项按钮。 然后,我们在占位符变量下定义包含流程所有待办事项的 HTML。 在这里,我们从 div 标签开始。 然后,我们循环遍历这些项目,将每个项目的数据转换为 HTML,然后用结束 div 标签结束 HTML。 之后,我们将构建的 HTML 字符串(称为占位符)插入到 innerHTML 中。 页面上的 innerHTML 位置是我们希望看到构建的待办事项的位置。

在循环内,我们必须使用以下代码构建单个待办事项 HTML:

let title=items[i]["title"];

let placeholderId=processType +

"-" + title.replaceAll(" ", "-");

placeholder +="<div>" + title +

"<button " + 'id="' + placeholderId + '">'

+ processType +

'</button>' + "</div>";

itemsMeta.push({"id": placeholderId, "title": title});

在这里,我们从正在循环的项目中提取项目的标题。 然后,我们为将用于绑定到事件侦听器的项目定义 ID。 请注意,我们将所有空格替换为 -。 现在我们已经定义了标题和 ID,我们将一个带有标题的 div 添加到占位符 HTML 字符串中。 我们还添加一个带有 placeholderId 的按钮,然后用一个 div 来完成它。 我们可以看到,我们对 HTML 字符串的添加是以 ; 结束的。 然后,我们将 placeholderId 和 title 添加到 itemsMeta 数组中以供稍后使用。

接下来,我们循环 itemsMeta,使用以下代码创建事件侦听器:

    . . .

    placeholder +="</div>"

    document.getElementById(elementId).innerHTML=placeholder;

    for (let i=0; i < itemsMeta.length; i++) {

        document.getElementById(

            itemsMeta[i]["id"]).addEventListener(

            "click", processFunction);

    }

}

现在,如果单击我们在待办事项旁边创建的按钮,则 processFunction 将触发。 我们的函数现在呈现这些项目,但我们需要使用 API 调用函数从后端获取它们。 我们现在来看看这个。

构建 API 调用 JavaScript 函数

现在我们有了渲染函数,我们可以看看我们的 API 调用函数:

首先,我们必须在 javascript/main.js 文件中定义 API 调用函数。 该函数接受一个 URL,它是 API 调用的端点。 它还采用一个方法,该方法是 POST、GET 或 PUT 字符串。 然后,我们必须定义我们的请求对象:

function apiCall(url, method) {

    let xhr=new XMLHttpRequest();

    xhr.withCredentials=true;

然后,我们必须在 apiCall 函数内定义事件监听器,该函数在调用完成后使用返回的 JSON 呈现待办事项:

xhr.addEventListener('readystatechange', function() {

    if (this.readyState===this.DONE) {

        renderItems(JSON.parse(

        this.responseText)["pending_items"], 

        "edit", "pendingItems", editItem);

        renderItems(JSON.parse(this.responseText)

            ["done_items"],

        "delete", "doneItems", deleteItem);

    }

});

在这里,我们可以看到我们正在传递在 templates/main.html 文件中定义的 ID。 我们还传递 API 调用的响应。 我们还可以看到,我们传入了 editItem 函数,这意味着当单击待处理项目旁边的按钮时,我们将触发编辑函数,将该项目转换为已完成项目。 考虑到这一点,如果单击属于已完成项目的按钮,则会触发 deleteItem 函数。 现在,我们将继续构建 apiCall 函数。

之后,我们必须构建 editItem 和 deleteItem 函数。 我们还知道,每次调用 apiCall 函数时,都会渲染项目。

现在我们已经定义了事件监听器,我们必须使用方法和 URL 准备 API 调用对象,定义标头,然后返回请求对象以便我们在需要时发送:

    xhr.open(method, url);
    xhr.setRequestHeader('content-type', 
        'application/json');
    xhr.setRequestHeader('user-token', 'token');
    return xhr
}

现在,我们可以使用 apiCall 函数对应用程序的后端执行调用,并在 API 调用后使用项目的新状态重新渲染前端。 这样,我们就可以进入最后一步,在这里我们将定义对待办事项执行创建、获取、删除和编辑功能的函数。

为按钮构建 JavaScript 函数

请注意,标头只是对后端中硬编码的接受令牌进行硬编码。 我们将在第 7 章“管理用户会话”中介绍如何正确定义 auth 标头。 现在我们的 API 调用函数已经定义好了,我们可以继续处理 editItem 函数:

function editItem() {
    let title=this.id.replaceAll("-", " ")
        .replace("edit ", "");
    let call=apiCall("/v1/item/edit", "POST");
    let json={
        "title": title,
        "status": "DONE"
    };
    call.send(JSON.stringify(json));
}

在这里,我们可以看到事件监听器所属的 HTML 部分可以通过 this 访问。 我们知道,如果我们删除编辑词,并用空格切换 - ,它会将待办事项的 ID 转换为待办事项的标题。 然后,我们利用 apiCall 函数来定义我们的端点和方法。 请注意,替换函数中的“edit”字符串中有一个空格。 我们有这个空格是因为我们还必须删除编辑字符串后面的空格。 如果我们不删除该空格,它将被发送到后端,从而导致错误,因为我们的应用程序后端在 JSON 文件中项目标题旁边没有空格。 定义端点和 API 调用方法后,我们将标题传递到状态为已完成的字典中。 这是因为我们知道我们正在将待处理的项目切换为完成。 完成此操作后,我们将使用 JSON 正文发送 API 调用。

现在,我们可以对 deleteItem 函数使用相同的方法:

function deleteItem() {
    let title=this.id.replaceAll("-", " ")
        .replace("delete ", "");
    let call=apiCall("/v1/item/delete", "POST");
    let json={
        "title": title,
        "status": "DONE"
    };
    call.send(JSON.stringify(json));
}

同样,替换函数中的“delete”字符串中有一个空格。 至此,我们的渲染过程就完成了。 我们定义了编辑和删除函数以及渲染函数。 现在,我们必须在页面首次加载时加载项目,而无需单击任何按钮。 这可以通过简单的 API 调用来完成:

function getItems() {
    let call=apiCall("/v1/item/get", 'GET');
    call.send()
}
getItems();

在这里,我们可以看到我们只是使用 GET 方法进行 API 调用并发送它。 另请注意,我们的 getItems 函数是在函数外部调用的。 当视图加载时,这将被触发一次。

这是一段很长的编码时间; 然而,我们已经快到了。 我们只需要定义创建文本输入和按钮的功能。 我们可以通过一个简单的事件监听器和创建端点的 API 调用来管理它:

document.getElementById("create-button")
        .addEventListener("click", createItem);
function createItem() {
    let title=document.getElementById("name");
    let call=apiCall("/v1/item/create/" + 
        title.value, "POST");
    call.send();
    document.getElementById("name").value=null;
}

我们还添加了将文本输入值设置为 null 的详细信息。 我们将 input 设置为 null,以便用户可以输入要创建的另一个项目,而不必删除刚刚创建的旧项目标题。 点击应用程序的主视图会得到以下输出:


图 5.5 – 带有渲染的待办事项的主页


现在,要查看我们的前端是否按我们希望的方式工作,我们可以执行以下步骤:

按已清洗项目旁边的删除按钮。

输入早餐吃麦片,然后单击创建。

输入早餐吃拉面,然后单击创建。

单击早餐吃拉面项目的编辑。

这些步骤应产生以下结果:


图 5.6 – 完成上述步骤后的主页

这样,我们就有了一个功能齐全的网络应用程序。 所有按钮都可以使用,并且列表会立即更新。 然而,它看起来不太漂亮。 没有间距,一切都是黑白的。 为了修改这一点,我们需要将 CSS 集成到 HTML 文件中,我们将在下一节中执行此操作。

将 CSS 注入 HTML

注入 CSS 采用与注入 JavaScript 相同的方法。 我们将在 HTML 文件中添加一个 CSS 标签,该标签将被文件中的 CSS 替换。 为了实现这一目标,我们必须执行以下步骤:

将 CSS 标签添加到我们的 HTML 文件中。

为整个应用程序创建一个基本 CSS 文件。

为我们的主视图创建一个 CSS 文件。

更新我们的 Rust 箱以服务 CSS 和 JavaScript。

让我们仔细看看这个过程。

将 CSS 标签添加到 HTML

首先,让我们对 templates/main.html 文件进行一些更改:

 <style>
    {{BASE_CSS}}
    {{CSS}}
</style>
<body>
    <div class="mainContainer">
        <h1>Done Items</h1>
        <div id="doneItems"></div>
        <h1>To Do Items</h1>
        <div id="pendingItems"></div>
        <div class="inputContainer">
            <input type="text" id="name"
                   placeholder="create to do item">
            <div class="actionButton" 
                 id="create-button" 
                 value="Send">Create</div>
        </div>
    </div>
</body>
<script>
    {{JAVASCRIPT}}
</script>

在这里,我们可以看到有两个 CSS 标签。 {{BASE_CSS}}标签用于基础CSS,它在多个不同视图中将保持一致,例如背景颜色和列比例,具体取决于屏幕尺寸。 {{BASE_CSS}} 标签用于管理此视图的 CSS 类。 恕我直言,css/base.css 和 css/main.css 文件是为我们的视图而制作的。 另外,请注意,我们已将所有项目放入一个名为 mainContainer 的类的 div 中。 这将使我们能够将所有项目在屏幕上居中。 我们还添加了更多的类,以便 CSS 可以引用它们,并将创建项目的按钮从按钮 HTML 标记更改为 div HTML 标记。 完成此操作后,javascript/main.js 文件中的 renderItems 函数将对项目循环进行以下更改:

function renderItems(items, processType, 
    elementId, processFunction) {
    . . . 
    for (i=0; i < items.length; i++) {
        . . .
        placeholder +='<div class="itemContainer">' +
            '<p>' + title + '</p>' +
            '<div class="actionButton" ' + 
                  'id="' + placeholderId + '">'
            + processType + '</div>' + "</div>";
        itemsMeta.push({"id": placeholderId, "title":        title});
    }
    . . .
}

考虑到这一点,我们现在可以在 css/base.css 文件中定义基本 CSS。

创建基础 CSS

现在,我们必须定义页面及其组件的样式。 一个好的起点是在 css/base.css 文件中定义页面主体。 我们可以使用以下代码对主体进行基本配置:

body {
    background-color: #92a8d1;
    font-family: Arial, Helvetica, sans-serif;
    height: 100vh;
} 

背景颜色是对一种颜色的引用。 仅看此参考可能看起来没有意义,但有在线颜色选择器,您可以在其中查看和选择颜色,并提供参考代码。 一些代码编辑器支持此功能,但为了快速参考,只需使用 Google HTML 颜色选择器,您就会因可用的免费在线交互工具的数量而不知所措。 通过上述配置,整个页面的背景将具有代码#92a8d1,即海军蓝色。 如果我们只是这样,页面的大部分都会有白色背景。 海军蓝色背景只会出现在有内容的地方。

我们将高度设置为 100vh。 vh 相对于视口高度的 1%。 由此,我们可以推断出 100vh 意味着我们在 body 中定义的样式占据了 100% 的视口。 然后,我们定义所有文本的字体,除非覆盖为 Arial、Helvetica 或 sans-serif。 我们可以看到我们在font-family中定义了多种字体。 这并不意味着所有这些都已实现,也不意味着不同级别的标头或 HTML 标记有不同的字体。 相反,这是一种后备机制。 首先,浏览器会尝试渲染 Arial; 如果浏览器不支持,它将尝试渲染 Helvetica,如果也失败,它将尝试渲染 sans-serif。

至此,我们已经定义了机身的总体风格,但是不同的屏幕尺寸呢? 例如,如果我们要在手机上访问我们的应用程序,它应该具有不同的尺寸。 我们可以在下图中看到这一点:


图 5.7 – 手机和桌面显示器之间的边距差异


图 5.7 显示了边距与待办事项列表更改所填充的空间的比率。 对于手机来说,屏幕空间不大,所以大部分屏幕都需要被待办事项占据; 否则,我们将无法阅读它。 但是,如果我们使用宽屏桌面显示器,我们就不再需要大部分屏幕来显示待办事项。 如果比例相同,待办事项将在 X 轴上拉伸,难以阅读,而且坦率地说,看起来也不好看。 这就是媒体查询的用武之地。我们可以根据窗口的宽度和高度等属性设置不同的样式条件。 我们将从手机规格开始。 因此,如果屏幕宽度最大为 500 像素,则在 css/base.css 文件中,我们必须为正文定义以下 CSS 配置:

@media(max-width: 500px) {
    body {
        padding: 1px;
        display: grid;
        grid-template-columns: 1fr;
    }
}

在这里,我们可以看到页面边缘和每个元素周围的填充只有一个像素。 我们还有一个网格显示。 这是我们可以定义列和行的地方。 然而,我们并没有充分利用它。 我们只有一栏。 这意味着我们的待办事项将占据大部分屏幕,如图 5.7 中的手机描述所示。 尽管我们在这种情况下没有使用网格,但我保留了它,以便您可以看到它与大屏幕的其他配置之间的关系。 如果我们的屏幕变大一点,我们可以将页面分成三个不同的垂直列; 但中间柱的宽度与两侧柱的宽度之比为5:1。 这是因为我们的屏幕仍然不是很大,并且我们希望我们的项目仍然占据大部分屏幕。 我们可以通过添加另一个具有不同参数的媒体查询来对此进行调整:

@media(min-width: 501px) and (max-width: 550px) {
    body {
        padding: 1px;
        display: grid;
        grid-template-columns: 1fr 5fr 1fr;
    } 
    .mainContainer {
        grid-column-start: 2;
    }
}

我们还可以看到,对于存放待办事项的 mainContainer CSS 类,我们将覆盖 grid-column-start 属性。 如果我们不这样做,那么 mainContainer 将被挤压在 1fr 宽度的左边距中。 相反,我们在 5fr 的中间开始和结束。 我们可以使用 grid-column-finish 属性使 mainContainer 跨多个列。

如果我们的屏幕变大,那么我们希望进一步调整比率,因为我们不希望项目宽度失控。 为了实现这一点,我们必须为中间列与两侧列定义 3:1 的比例,然后当屏幕宽度高于 1001px 时定义 1:1 的比例:

@media(min-width: 551px) and (max-width: 1000px) {
    body {
        padding: 1px;
        display: grid;
        grid-template-columns: 1fr 3fr 1fr;
    } 
    .mainContainer {
        grid-column-start: 2;
    }
} 
@media(min-width: 1001px) {
    body {
        padding: 1px;
        display: grid;
        grid-template-columns: 1fr 1fr 1fr;
    } 
    .mainContainer {
        grid-column-start: 2;
    }
}

现在我们已经为所有视图定义了通用 CSS,我们可以继续在 css/main.css 文件中处理特定于视图的 CSS。

为主页创建 CSS

现在,我们必须分解我们的应用程序组件。 我们有一份待办事项清单。 列表中的每个项目都是一个具有不同背景颜色的 div:

.itemContainer {
    background: #034f84;
    margin: 0.3rem;
}

我们可以看到这个类的边距为 0.3。 我们使用 rem 是因为我们希望边距相对于根元素的字体大小进行缩放。 如果我们的光标悬停在项目上,我们还希望项目稍微改变颜色:

.itemContainer:hover {
    background: #034f99;
}

在项目容器内,项目的标题用段落标签表示。 我们想要定义项目容器中所有段落的样式,而不是其他地方。 我们可以使用以下代码定义容器中段落的样式:

.itemContainer p {
    color: white;
    display: inline-block;
    margin: 0.5rem;
    margin-right: 0.4rem;
    margin-left: 0.4rem;
}

inline-block 允许标题与 div 一起显示,这将充当项目的按钮。 边距定义只是阻止标题紧靠项目容器的边缘。 我们还确保段落颜色为白色。

设置项目标题样式后,剩下的唯一项目样式是操作按钮,即编辑或删除。 该操作按钮将以不同的背景颜色向右浮动,以便我们知道在哪里单击。 为此,我们必须使用类定义按钮样式,如以下代码所示:

.actionButton {
    display: inline-block;
    float: right;
    background: #f7786b;
    border: none;
    padding: 0.5rem;
    padding-left: 2rem;
    padding-right: 2rem;
    color: white;
}

在这里,我们定义了显示,使其向右浮动,并定义了背景颜色和填充。 这样,我们可以通过运行以下代码来确保悬停时颜色发生变化:

.actionButton:hover {
    background: #f7686b;
    color: black;
}

现在我们已经涵盖了所有概念,我们必须定义输入容器的样式。 这可以通过运行以下代码来完成:

.inputContainer {
    background: #034f84;
    margin: 0.3rem;
    margin-top: 2rem;
}
.inputContainer input {
    display: inline-block;
    margin: 0.4rem;
}

我们做到了! 我们已经定义了所有 CSS、JavaScript 和 HTML。 在运行应用程序之前,我们需要在主视图中加载数据。

从 Rust 提供 CSS 和 JavaScript

我们在views/app/items.rs 文件中提供CSS。 我们通过阅读 HTML、JavaScript、基本 CSS 和主 CSS 文件来完成此操作。 然后,我们用其他文件中的数据替换 HTML 数据中的标签:

pub async fn items() -> HttpResponse {
    let mut html_data=read_file(
        "./templates/main.html");
    let javascript_data: String=read_file(
        "./javascript/main.js");
    let css_data: String=read_file(
        "./css/main.css");
    let base_css_data: String=read_file(
        "./css/base.css");
    html_data=html_data.replace("{{JAVASCRIPT}}", 
    &javascript_data);
    html_data=html_data.replace("{{CSS}}", 
    &css_data);
    html_data=html_data.replace("{{BASE_CSS}}", 
    &base_css_data);
    HttpResponse::Ok()
        .content_type("text/html; charset=utf-8")
        .body(html_data)
}

现在,当我们启动服务器时,我们将拥有一个完全运行的应用程序,具有直观的前端,如下图所示:


图 5.8 – CSS 之后的主页


尽管我们的应用程序正在运行,并且我们已经配置了基本 CSS 和 HTML,但我们可能希望拥有可重用的独立 HTML 结构,这些结构具有自己的 CSS。 这些结构可以在需要时注入到视图中。 它的作用是让我们能够编写一次组件,然后将其导入到其他 HTML 文件中。 反过来,这使得维护变得更容易,并确保组件在多个视图中的一致性。 例如,如果我们在视图顶部创建一个信息栏,我们将希望它在其余视图中具有相同的样式。 因此,将信息栏作为组件创建一次并将其插入到其他视图中是有意义的,如下一节所述。

继承组件

有时,我们想要构建一个可以注入视图的组件。 为此,我们必须加载 CSS 和 HTML,然后将它们插入 HTML 的正确部分。

为此,我们可以创建一个 add_component 函数,该函数获取组件的名称,根据组件名称创建标签,并根据组件名称加载 HTML 和 CSS。 我们将在views/app/content_loader.rs文件中定义这个函数:

pub fn add_component(component_tag: String, 
    html_data: String) -> String {
    let css_tag: String=component_tag.to_uppercase() + 
        "_CSS";
    let html_tag: String=component_tag.to_uppercase() + 
        "_HTML";
    let css_path=String::from("./templates/components/") 
        + &component_tag.to_lowercase() + ".css";
    let css_loaded=read_file(&css_path);
    let html_path=String::from("./templates/components/") 
        + &component_tag.to_lowercase() + ".html";
    let html_loaded=read_file(&html_path);
    let html_data=html_data.replace(html_tag.as_str(), 
        &html_loaded);
    let html_data=html_data.replace(css_tag.as_str(), 
        &css_loaded);
    return html_data
} 

在这里,我们使用同一文件中定义的 read_file 函数。 然后,我们将组件 HTML 和 CSS 注入到视图数据中。 请注意,我们将组件嵌套在 templates/components/ 目录中。 对于本例,我们要插入一个标头组件,因此当我们将标头传递给 add_component 函数时,我们的 add_component 函数将尝试加载 header.html 和 header.css 文件。 在我们的 templates/components/header.html 文件中,我们必须定义以下 HTML:

<div class="header">
    <p>complete tasks: </p><p id="completeNum"></p>
    <p>pending tasks: </p><p id="pendingNum"></p>
</div>

在这里,我们仅显示已完成和待办事项的数量计数。 在我们的 templates/components/header.css 文件中,我们必须定义以下 CSS:

.header {
    background: #034f84;
    margin-bottom: 0.3rem;
}
.header p {
    color: white;
    display: inline-block;
    margin: 0.5rem;
    margin-right: 0.4rem;
    margin-left: 0.4rem;
}

为了让 add_component 函数将 CSS 和 HTML 插入到正确的位置,我们必须将 HEADER 标签插入 templates/main.html 文件的 <style> 部分:

. . . 
    <style>
        {{BASE_CSS}}
        {{CSS}}
        HEADER_CSS
    </style>
    <body>
        <div class="mainContainer">
            HEADER_HTML
            <h1>Done Items</h1>
. . .

现在我们所有的 HTML 和 CSS 都已定义,我们需要在 view/app/items.rs 文件中导入 add_component 函数:

use super::content_loader::add_component;

在同一个文件中,我们必须在项目视图函数中添加标题,如下所示:

html_data=add_component(String::from("header"), 
    html_data);

现在,我们必须更改injecting_header/javascript/main.js 文件中的 apiCall 函数,以确保标头随待办事项计数进行更新:

document.getElementById("completeNum").innerHTML=JSON.parse(this.responseText)["done_item_count"];
document.getElementById("pendingNum").innerHTML=JSON.parse(this.responseText)["pending_item_count"]; 

现在我们已经插入了组件,我们得到以下渲染视图:


图 5.9 – 带标题的主页

正如我们所看到的,我们的标题正确显示了数据。 如果我们将标头标签添加到视图 HTML 文件中,并在视图中调用 add_component,我们将获得该标头。

现在,我们有一个完全运行的单页应用程序。 然而,这并非没有困难。 我们可以看到,如果我们开始向前端添加更多功能,我们的前端将开始失控。 这就是 React 等框架的用武之地。通过 React,我们可以将代码构建为适当的组件,以便我们可以在需要时使用它们。 在下一节中,我们将创建一个基本的 React 应用程序。

创建一个 React 应用程序

React 是一个独立的应用程序。 因此,我们通常会将 React 应用程序放在自己的 GitHub 存储库中。 如果您想将 Rust 应用程序和 React 应用程序保留在同一个 GitHub 存储库中,那没问题,但只需确保它们位于根目录中的不同目录即可。 一旦我们导航到 Rust Web 应用程序之外,我们就可以运行以下命令:

npx create-react-app front_end

这将在 front_end 目录中创建一个 React 应用程序。 如果我们查看里面,我们会看到有很多文件。 请记住,本书是关于 Rust 中的 Web 编程的。 探索有关 React 的一切超出了本书的范围。 不过,进一步阅读部分建议您阅读一本专门介绍 React 开发的书。 现在,我们将重点关注 front_end/package.json 文件。 我们的 package.json 文件就像我们的 Cargo.toml 文件,我们在其中定义我们正在构建的应用程序的依赖项、脚本和其他元数据。 在我们的 package.json 文件中,我们有以下脚本:

. . .
"scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject"
},
. . .

如果需要,我们可以编辑它,但就目前情况而言,如果我们在 package.json 文件所在的目录中运行 npm start 命令,我们将运行 react-scripts start 命令。 我们很快就会运行 React 应用程序,但在此之前,我们必须使用以下代码编辑 front_end/src/App.js 文件:

import React, { Component } from 'react';
class App extends Component {
  state={
    "message": "To Do"
  }
  render() {
    return (
        <div className="App">
          <p>{this.state.message} application</p>
        </div>
    )
  }
}
export default App;

在分解这段代码之前,我们必须澄清一些事情。 如果您上网,您可能会看到一些文章指出 JavaScript 不是基于类的面向对象语言。 本书不会深入探讨 JavaScript。 相反,本章旨在为您提供足够的知识来启动和运行前端。 如果您想向 Rust Web 应用程序添加前端,希望本章足以促进进一步阅读并启动您的旅程。 在本章中,我们将只讨论可以支持继承的类和对象。

在前面的代码中,我们从react包中导入了组件对象。 然后,我们定义了一个继承组件类的App类。 App 类是我们应用程序的主要部分,我们可以将 front_end/src/App.js 文件视为前端应用程序的入口点。 如果需要的话,我们可以在 App 类中定义其他路由。 我们还可以看到有一个属于App类的状态。 这是应用程序的总体内存。 我们必须称其为国家; 每次更新状态时,都会执行渲染函数,更新组件渲染到前端的内容。 当我们的状态更新我们的自制渲染函数时,这抽象了本章前面几节中我们所做的很多事情。 我们可以看到,我们的状态可以在返回时在渲染函数中引用。 这就是所谓的 JSX,它允许我们直接在 JavaScript 中编写 HTML 元素,而不需要任何额外的方法。 现在已经定义了基本应用程序,我们可以将其导出以使其可用。

让我们导航到 package.json 文件所在的目录并运行以下命令:

npm start

React 服务器将启动,我们将在浏览器中看到以下视图:


图 5.10 – React 应用程序的第一个主视图

在这里,我们可以看到状态中的消息已传递到渲染函数中,然后显示在浏览器中。 现在我们的 React 应用程序正在运行,我们可以开始使用 API 调用将数据加载到 React 应用程序中。

在 React 中进行 API 调用

现在基本应用程序正在运行,我们可以开始对后端执行 API 调用。 为此,我们将主要关注 front_end/src/App.js 文件。 我们可以构建我们的应用程序,以便它可以使用 Rust 应用程序中的项目填充前端。 首先,我们必须将以下内容添加到 package.json 文件的依赖项中:

"axios": "^0.26.1"

然后,我们可以运行以下命令:

npm install

这将安装我们的额外依赖项。 现在,我们可以转到 front_end/src/App.js 文件并使用以下代码导入我们需要的内容:

import React, { Component } from 'react';
import axios from 'axios';

我们将使用 Component 来继承 App 类,并使用 axios 对后端执行 API 调用。 现在,我们可以定义我们的 App 类并使用以下代码更新我们的状态:

class App extends Component {
  state={
      "pending_items": [],
      "done_items": [],
      "pending_items_count": 0,
      "done_items_count": 0
  }
}
export default App;

在这里,我们的结构与我们自制的前端相同。 这也是我们从 Rust 服务器中的获取项目视图返回的数据。 现在我们知道要使用哪些数据,我们可以执行以下步骤:

在我们的 App 类中创建一个函数,从 Rust 服务器获取函数。

确保该函数在App类挂载时执行。

在我们的 App 类中创建一个函数,用于将从 Rust 服务器返回的项目处理为 HTML。

在我们的 App 类中创建一个函数,一旦我们完成,它会将所有上述组件渲染到前端。

使我们的 Rust 服务器能够接收来自其他来源的调用。

在开始这些步骤之前,我们应该注意 App 类的大纲将采用以下形式:

class App extends Component {
 
  state={
      . . .
  }
  // makes the API call
  getItems() {
      . . .
  }
  // ensures the API call is updated when mounted
  componentDidMount() {
      . . .
  }
  // convert items from API to HTML 
  processItemValues(items) {
      . . .
  }
  // returns the HTML to be rendered
  render() {
    return (
        . . .
    )
  }
}

这样,我们就可以开始调用 API 的函数了:

在我们的 App 类中,我们的 getItems 函数采用以下布局:

axios.get("http://127.0.0.1:8000/v1/item/get",

  {headers: {"token": "some_token"}})

  .then(response=> {

      let pending_items=response.data["pending_items"]

      let done_items=response.data["done_items"]

      this.setState({

            . . .

        })

  });

在这里,我们定义 URL。 然后,我们将令牌添加到标头中。 现在,我们将只硬编码一个简单的字符串,因为我们还没有在 Rust 服务器中设置用户会话; 我们将在第 7 章“管理用户会话”中更新这一点。 然后,我们关闭它。 因为 axios.get 是一个 Promise,所以我们必须使用 .then。 返回数据时执行 .then 括号内的代码。 在这些括号内,我们提取所需的数据,然后执行 this.setState 函数。 this.setState 函数更新 App 类的状态。 但是,执行 this.setState 也会执行 App 类的 render 函数,这将更新浏览器。 在 this.setState 函数中,我们传入以下代码:

"pending_items": this.processItemValues(pending_items),
"done_items": this.processItemValues(done_items),
"pending_items_count": response.data["pending_item_count"],
"done_items_count": response.data["done_item_count"]

至此,我们就完成了getItems,可以从后端获取item了。 现在我们已经定义了它,我们必须确保它被执行,我们接下来要做的就是。

确保 getItems 函数被触发,从而在加载 App 类时更新状态可以使用以下代码来实现:

componentDidMount() {

  this.getItems();

}

这很简单。 getItems 将在我们的 App 组件安装后立即执行。 我们本质上是在 componentDidMount 函数中调用 this.setState 。 这会在浏览器更新屏幕之前触发额外的渲染。 即使渲染被调用两次,用户也不会看到中间状态。 这是我们从 React Component 类继承的众多函数之一。 现在我们在页面加载后就加载了数据,我们可以继续下一步:处理加载的数据。

对于 App 类中的 processItemValues 函数,我们必须接收表示项目的 JSON 对象数组并将其转换为 HTML,这可以通过以下代码实现:

processItemValues(items) {

  let itemList=[];

  items.forEach((item, index)=>{

      itemList.push(

          <li key={index}>{item.title} {item.status}</li>

      )

  })

  return itemList

}

在这里,我们只是循环遍历这些项目,将它们转换为 li HTML 元素并将它们添加到一个空数组中,然后在填充后返回该空数组。 请记住,我们使用 processItemValue 函数在数据进入 getItems 函数中的状态之前处理数据。 现在我们已经拥有状态中的所有 HTML 组件,我们需要使用渲染函数将它们放置在页面上。

对于我们的 App 类,渲染函数仅返回 HTML 组件。 我们在此不使用任何额外的逻辑。 我们可以返回以下内容:

<div className="App">

<h1>Done Items</h1>

<p>done item count: {this.state.done_items_count}</p>

{this.state.done_items}

<h1>Pending Items</h1>

<p>pending item count: 

    {this.state.pending_items_count}</p>

{this.state.pending_items}

</div>

在这里,我们可以看到我们的状态被直接引用。 与我们在本章前面使用的手动字符串操作相比,这是一个可爱的变化。 使用 React 更加干净,降低了错误的风险。 在我们的前端,调用后端的渲染过程应该可以工作。 但是,我们的 Rust 服务器将阻止来自 React 应用程序的请求,因为它来自不同的应用程序。 为了解决这个问题,我们需要继续下一步。

现在,我们的 Rust 服务器将阻止我们对服务器的请求。 这取决于跨源资源共享(CORS)。 我们之前没有遇到过任何问题,因为默认情况下,CORS 允许来自同一来源的请求。 当我们编写原始 HTML 并从 Rust 服务器提供服务时,请求来自同一来源。 然而,对于 React 应用程序,请求来自不同的来源。 为了纠正这个问题,我们需要使用以下代码在 Cargo.toml 文件中安装 CORS 作为依赖项:

actix-cors="0.6.1"

在我们的 src/main.rs 文件中,我们必须使用以下代码导入 CORS:

use actix_cors::Cors;

现在,我们必须在定义服务器之前定义 CORS 策略,并在视图配置之后使用以下代码包装 CORS 策略:

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    HttpServer::new(|| {
        let cors=Cors::default().allow_any_origin()
                                  .allow_any_method()
                                  .allow_any_header();
        let app=App::new()
            .wrap_fn(|req, srv|{
                println!("{}-{}", req.method(), 
                          req.uri());
                let future=srv.call(req);
                async {
                    let result=future.await?;
                    Ok(result)
                }
        }).configure(views::views_factory).wrap(cors);
        return app
    })
    .bind("127.0.0.1:8000")?
    .run()
    .await
}

这样,我们的服务器就准备好接受来自 React 应用程序的请求了。

笔记

当我们定义 CORS 策略时,我们明确表示我们希望允许所有方法、标头和来源。 然而,我们可以通过以下 CORS 定义更简洁:

let cors=Cors::permissive();

现在,我们可以测试我们的应用程序,看看它是否正常工作。 我们可以通过使用 Cargo 运行 Rust 服务器并在不同的终端中运行 React 应用程序来做到这一点。 一旦启动并运行,我们的 React 应用程序加载时应如下所示:

图 5.11 – React 应用程序首次与 Rust 服务器对话时的视图


这样,我们可以看到对 Rust 应用程序的调用现在可以按预期工作。 然而,我们所做的只是列出待办事项的名称和状态。 React 的亮点在于构建自定义组件。 这意味着我们可以为每个待办事项构建具有自己的状态和功能的单独类。 我们将在下一节中看到这一点。

在 React 中创建自定义组件

当我们查看 App 类时,我们可以看到,拥有一个具有状态和函数的类非常有用,这些状态和函数可用于管理 HTML 呈现到浏览器的方式和时间。 当涉及到单个待办事项时,我们可以使用状态和函数。 这是因为我们有一个按钮可以从待办事项中获取属性并调用 Rust 服务器来编辑或删除它。 在本节中,我们将构建两个组件:src/components/ToDoItem.js 文件中的 ToDoItem 组件和 src/components/CreateToDoItem.js 文件中的 CreateToDoItem 组件。 一旦我们构建了这些,我们就可以将它们插入到我们的 App 组件中,因为我们的 App 组件将获取项目的数据并循环这些项目,创建多个 ToDoItem 组件。 为了实现这一目标,我们需要处理几个步骤,因此本节将分为以下小节:

创建我们的 ToDoItem 组件

创建 CreateToDoItem 组件

在我们的应用程序组件中构建和管理自定义组件

让我们开始吧。

创建我们的 ToDoItem 组件

我们将从 src/components/ToDoItem.js 文件中更简单的 ToDoItem 组件开始。 首先,我们必须导入以下内容:

import React, { Component } from 'react';
import axios from "axios";

这不是什么新鲜事。 现在我们已经导入了我们需要的内容,我们可以关注如何使用以下代码定义 ToDoItem:

class ToDoItem extends Component {
    state={
        "title": this.props.title,
        "status": this.props.status,
        "button": this.processStatus(this.props.status)
    }
    processStatus(status) {
        . . .
    }
    inverseStatus(status) {
        . . .
    }
    sendRequest=()=> {
        . . .
    }
    render() {
        return(
            . . .
        )
    }
}
export default ToDoItem;

在这里,我们使用 this.props 填充状态,这是构造组件时传递到组件中的参数。 然后,我们的 ToDoItem 组件具有以下函数:

processStatus:此函数将待办事项的状态(例如 PENDING)转换为按钮上的消息(例如编辑)。

inverseStatus:当我们有一个状态为 PENDING 的待办事项并对其进行编辑时,我们希望将其转换为 DONE 状态,以便可以将其发送到 Rust 服务器上的编辑端点,这是相反的。 因此,该函数创建传入状态的反转。

sendRequest:此函数将请求发送到 Rust 服务器以编辑或删除待办事项。 我们还可以看到我们的 sendRequest 函数是一个箭头函数。 箭头语法本质上将函数绑定到组件,以便我们可以在渲染返回语句中引用它,从而允许在单击绑定到它的按钮时执行 sendRequest 函数。

现在我们知道我们的函数应该做什么,我们可以使用以下代码定义我们的状态函数:

processStatus(status) {
    if (status==="PENDING") {
        return "edit"
    } else {
        return "delete"
    }
}
inverseStatus(status) {
    if (status==="PENDING") {
        return "DONE"
    } else {
        return "PENDING"
    }
}

这很简单,不需要太多解释。 现在我们的状态处理函数已经完成,我们可以使用以下代码定义我们的 sendRequest 函数:

sendRequest=()=> {
    axios.post("http://127.0.0.1:8000/v1/item/" + 
                this.state.button,
        {
            "title": this.state.title,
            "status": this.inverseStatus(this.state.status)
        },
    {headers: {"token": "some_token"}})
        .then(response=> {
            this.props.passBackResponse(response);
        });
}

在这里,我们使用 this.state.button 定义端点更改时 URL 的一部分,具体取决于我们按下的按钮。 我们还可以看到我们执行了 this.props.passBackResponse 函数。 这是我们传递到 ToDoItem 组件中的函数。 这是因为在编辑或删除请求后,我们从 Rust 服务器获取了待办事项的完整状态。 我们需要启用我们的应用程序组件来处理已传回的数据。 在这里,我们将在“应用程序组件”小节中的“构建和管理自定义组件”中先睹为快。 我们的 App 组件将在 passBackResponse 参数下有一个未执行的函数,它将传递给我们的 ToDoItem 组件。 该函数在 passBackResponse 参数下,将处理新的待办事项的状态并将其呈现在 App 组件中。

至此,我们已经配置了所有功能。 剩下的就是定义渲染函数的返回,它采用以下形式:

<div>
    <p>{this.state.title}</p>
    <button onClick={this.sendRequest}>
                    {this.state.button}</button>
</div>

在这里,我们可以看到待办事项的标题呈现在段落标记中,并且我们的按钮在单击时执行 sendRequest 函数。 现在我们已经完成了这个组件,并且可以在我们的应用程序中显示它了。 但是,在执行此操作之前,我们需要构建用于在下一节中创建待办事项的组件。

在 React 中创建自定义组件

我们的 React 应用程序可以列出、编辑和删除待办事项。 但是,我们无法创建任何待办事项。 它由一个输入和一个创建按钮组成,以便我们可以放入一个待办事项,然后通过单击该按钮来创建该待办事项。 在我们的 src/components/CreateToDoItem.js 文件中,我们需要导入以下内容:

import React, { Component } from 'react';
import axios from "axios";

这些是构建我们组件的标准导入。 定义导入后,我们的 CreateToDoItem 组件将采用以下形式:

class CreateToDoItem extends Component {
    state={
        title: ""
    }
    createItem=()=> {
        . . .
    }
    handleTitleChange=(e)=> {
        . . .
    }
    render() {
        return (
            . . .
        )
    }
}
export default CreateToDoItem;

在上面的代码中,我们可以看到我们的CreateToDoItem组件有以下功能:

createItem:该函数向 Rust 服务器发送请求,以创建标题为 state 的待办事项

handleTitleChange:每次更新输入时该函数都会更新状态

在探索这两个函数之前,我们将翻转这些函数的编码顺序,并使用以下代码定义渲染函数的返回:

<div className="inputContainer">
    <input type="text" id="name"
           placeholder="create to do item"
           value={this.state.title}
           onChange={this.handleTitleChange}/>
    <div className="actionButton"
         id="create-button"
         onClick={this.createItem}>Create</div>
</div>

在这里,我们可以看到输入的值为this.state.title。 另外,当输入更改时,我们执行 this.handleTitleChange 函数。 现在我们已经介绍了渲染函数,没有什么新内容要介绍了。 这是您再次查看 CreateToDoItem 组件的概要并尝试自己定义 createItem 和 handleTitleChange 函数的好机会。 它们采用与 ToDoItem 组件中的函数类似的形式。

您尝试定义 createItem 和 handleTitleChange 函数应类似于以下内容:

createItem=()=> {
    axios.post("http://127.0.0.1:8000/v1/item/create/" +
        this.state.title,
        {},
        {headers: {"token": "some_token"}})
        .then(response=> {
            this.setState({"title": ""});
            this.props.passBackResponse(response);
        });
}
handleTitleChange=(e)=> {
    this.setState({"title": e.target.value});
}    

这样,我们就定义了两个自定义组件。 我们现在准备好进入下一小节,我们将在其中管理我们的自定义组件。

在我们的应用程序组件中构建和管理自定义组件

虽然创建自定义组件很有趣,但如果我们不在应用程序中使用它们,它们就没有多大用处。 在本小节中,我们将向 src/App.js 文件添加一些额外的代码,以启用我们的自定义组件。 首先,我们必须使用以下代码导入我们的组件:

import ToDoItem from "./components/ToDoItem";
import CreateToDoItem from "./components/CreateToDoItem";

现在我们已经有了组件,我们可以继续进行第一次更改。 我们的 App 组件的 processItemValues 函数可以使用以下代码定义:

processItemValues(items) {
  let itemList=[];
  items.forEach((item, _)=>{
      itemList.push(
          <ToDoItem key={item.title + item.status}
                    title={item.title}
                    status={item.status.status}
                    passBackResponse={
                    this.handleReturnedState}/>
      )
  })
  return itemList
}

在这里,我们可以看到我们循环遍历从 Rust 服务器获取的数据,但我们没有将数据传递到通用 HTML 标签中,而是将待办事项数据的参数传递到我们自己的自定义组件中,该组件将被处理 就像 HTML 标签一样。 当涉及到处理我们自己的返回状态响应时,我们可以看到它是一个箭头函数,用于处理数据并使用以下代码设置状态:

handleReturnedState=(response)=> {
  let pending_items=response.data["pending_items"]
  let done_items=response.data["done_items"]
  this.setState({
      "pending_items": 
       this.processItemValues(pending_items),
      "done_items": this.processItemValues(done_items),
      "pending_items_count": 
       response.data["pending_item_count"],
      "done_items_count": response.data["done_item_count"]
  })
}

这与我们的 getItems 函数非常相似。 如果您想减少重复代码的数量,可以在这里进行一些重构。 但是,为了使其工作,我们必须使用以下代码定义渲染函数的 return 语句:

<div className="App">
    <h1>Pending Items</h1>
    <p>done item count: 
    {this.state.pending_items_count}</p>
    {this.state.pending_items}
    <h1>Done Items</h1>
    <p>done item count: {this.state.done_items_count}</p>
    {this.state.done_items}
    <CreateToDoItem 
     passBackResponse={this.handleReturnedState} />
</div>

在这里,我们可以看到除了添加 createItem 组件之外没有太多变化。 运行 Rust 服务器和 React 应用程序将为我们提供以下视图:


图 5.12 – 带有自定义组件的 React 应用程序的视图


图 5.12 显示我们的自定义组件正在呈现。 我们可以单击按钮,结果是,我们将看到所有 API 调用都正常工作,并且我们的自定义组件也正常工作。 现在,阻碍我们的只是让我们的前端看起来更美观,我们可以通过将 CSS 提升到 React 应用程序中来做到这一点。

将 CSS 放到 React 中

我们现在正处于使 React 应用程序可用的最后阶段。 我们可以将 CSS 分成多个不同的文件。 然而,我们即将结束本章,再次浏览所有 CSS 会不必要地让本章充满大量重复代码。 虽然我们的 HTML 和 JavaScript 不同,但 CSS 是相同的。 为了让它运行,我们可以从以下文件中复制所有 CSS:

templates/components/header.css

css/base.css

css/main.css

将此处列出的 CSS 文件复制到 front_end/src/App.css 文件中。 CSS 有一项更改,所有 .body 引用都应替换为 .App,如以下代码片段所示:

.App {
  background-color: #92a8d1;
  font-family: Arial, Helvetica, sans-serif;
  height: 100vh;
}
@media(min-width: 501px) and (max-width: 550px) {
  .App {
    padding: 1px;
    display: grid;
    grid-template-columns: 1fr 5fr 1fr;
  }
  .mainContainer {
    grid-column-start: 2;
  }
}
. . .

现在,我们可以导入 CSS 并在我们的应用程序和组件中使用它。 我们还必须更改渲染函数中的返回 HTML。 我们可以处理所有三个文件。 对于 src/App.js 文件,我们必须使用以下代码导入 CSS:

import "./App.css";

然后,我们必须添加一个标头并使用正确的类定义 div 标签,并使用以下代码作为渲染函数的返回语句:

<div className="App">
    <div className="mainContainer">
        <div className="header">
            <p>complete tasks: 
            {this.state.done_items_count}</p>
            <p>pending tasks: 
            {this.state.pending_items_count}</p>
        </div>
        <h1>Pending Items</h1>
        {this.state.pending_items}
        <h1>Done Items</h1>
        {this.state.done_items}
        <CreateToDoItem passBackResponse={this.handleReturnedState}/>
    </div>
</div>

在我们的 src/components/ToDoItem.js 文件中,我们必须使用以下代码导入 CSS:

import "../App.css";

然后,我们必须将按钮更改为 div 并使用以下代码定义渲染函数的 return 语句:

<div className="itemContainer">
    <p>{this.state.title}</p>
    <div className="actionButton" onClick={this.sendRequest}>
    {this.state.button}</div>
</div>

在我们的 src/components/CreateToDoItem.js 文件中,我们必须使用以下代码导入 CSS:

import "../App.css";

然后,我们必须将按钮更改为 div 并使用以下代码定义渲染函数的 return 语句:

<div className="inputContainer">
    <input type="text" id="name"
           placeholder="create to do item"
           value={this.state.title}
           onChange={this.handleTitleChange}/>
    <div className="actionButton"
         id="create-button"
         onClick={this.createItem}>Create</div>
</div>

这样,我们就将 CSS 从 Rust Web 服务器提升到了 React 应用程序中。 如果我们运行 Rust 服务器和 React 应用程序,我们将得到下图所示的输出:


图 5.13 – 添加了 CSS 的 React 应用程序的视图


我们终于得到它了! 我们的 React 应用程序正在运行。 启动并运行我们的 React 应用程序需要更多时间,但我们可以看到 React 具有更大的灵活性。 我们还可以看到,我们的 React 应用程序不太容易出错,因为我们不必手动操作字符串。 我们用 React 构建还有一个优势,那就是现有的基础设施。 在下一部分也是最后一部分中,我们将通过将 React 应用程序包装在 Electron 中,将 React 应用程序转换为编译后的桌面应用程序,该应用程序在计算机的应用程序中运行。

将我们的 React 应用程序转换为桌面应用程序

将我们的 React 应用程序转换为桌面应用程序并不复杂。 我们将使用 Electron 框架来做到这一点。 Electron 是一个功能强大的框架,可将 JavaScript、HTML 和 CSS 应用程序转换为跨 macOS、Linux 和 Windows 平台编译的桌面应用程序。 Electron 框架还可以让我们通过 API 访问计算机的组件,例如加密存储、通知、电源监视器、消息端口、进程、shell、系统首选项等等。 Electron 中内置了 Slack、Visual Studio Code、Twitch、Microsoft Teams 等桌面应用程序。 要转换我们的 React 应用程序,我们必须首先更新 package.json 文件。 首先,我们必须使用以下代码更新 package.json 文件顶部的元数据:

{
  "name": "front_end",
  "version": "0.1.0",
  "private": true,
  "homepage": "./",
  "main": "public/electron.js",
  "description": "GUI Desktop Application for a simple To 
                  Do App",
  "author": "Maxwell Flitton",
  "build": {
    "appId": "Packt"
  },
  "dependencies": {
    . . .

其中大部分是通用元数据。 然而,主力场是必不可少的。 我们将在此处编写定义 Electron 应用程序如何运行的文件。 将主页字段设置为“./”还可以确保资源路径相对于index.html 文件。 现在我们的元数据已经定义了,我们可以添加以下依赖项:

"webpack": "4.28.3",
"cross-env": "^7.0.3",
"electron-is-dev": "^2.0.0"

这些依赖项有助于构建 Electron 应用程序。 添加它们后,我们可以使用以下代码重新定义脚本:

    . . .
"scripts": {
    "react-start": "react-scripts start",
    "react-build": "react-scripts build",
    "react-test": "react-scripts test",
    "react-eject": "react-scripts eject",
    "electron-build": "electron-builder",
    "build": "npm run react-build && npm run electron-
              build",
    "start": "concurrently \"cross-env BROWSER=none npm run 
              react-start\" \"wait-on http://localhost:3000 
              && electron .\""
},

在这里,我们为所有 React 脚本添加了前缀“react”。 这是为了将 React 进程与 Electron 进程分开。 如果我们现在只想在开发模式下运行 React 应用程序,则必须运行以下命令:

npm run react-start

我们还为 Electron 定义了构建命令和开发启动命令。 这些还不能工作,因为我们还没有定义我们的 Electron 文件。 在 package.json 文件的底部,我们必须定义构建 Electron 应用程序的开发人员依赖项:

    . . .
    "development": [
      "last 1 chrome version",
      "last 1 firefox version",
      "last 1 safari version"
    ]
  },
  "devDependencies": {
    "concurrently": "^7.1.0",
    "electron": "^18.0.1",
    "electron-builder": "^22.14.13",
    "wait-on": "^6.0.1"
  }
}

这样,我们就在 package.json 文件中定义了我们需要的所有内容。 我们需要使用以下命令安装新的依赖项:

npm install

现在,我们可以开始构建 front_end/public/electron.js 文件,以便构建我们的 Electron 文件。 这本质上是样板代码,您可能会在其他教程中看到此文件,因为这是在 Electron 中运行应用程序的最低要求。 首先,我们必须使用以下代码导入我们需要的内容:

const { app, BrowserWindow }=require("electron");
const path=require("path");
const isDev=require("electron-is-dev");

然后,我们必须使用以下代码定义创建桌面窗口的函数:

function createWindow() {
    const mainWindow=new BrowserWindow({
        width: 800,
        height: 600,
        webPreferences: {
            nodeIntegration: true,
            enableRemoteModule: true,
            contextIsolation: false,
        },
    });
    mainWindow.loadURL(
        isDev
           ? "http://localhost:3000"
           : `file://${path.join(__dirname, 
                                 "../build/index.html")}`
    );
    if (isDev) {
        mainWindow.webContents.openDevTools();
    }
}

在这里,我们本质上定义了窗口的宽度和高度。 另请注意,nodeIntegration 和enableRemoteModule 使渲染器远程进程(浏览器窗口)能够在主进程上运行代码。 然后,我们开始在主窗口中加载 URL。 如果在开发人员模式下运行,我们只需加载 http://localhost:3000,因为我们在 localhost 上运行了 React 应用程序。 如果我们构建应用程序,那么我们编码的资产和文件将被编译并可以通过 ../build/index.html 文件加载。 我们还声明,如果我们在开发人员模式下运行,我们将打开开发人员工具。 当窗口准备好时,我们必须使用以下代码执行 createWindow 函数:

app.whenReady().then(()=> {
    createWindow();
    app.on("activate", function () {
        if (BrowserWindow.getAllWindows().length===0){
           createWindow(); 
        }
    });
});

如果操作系统是macOS,我们必须保持程序运行,即使我们关闭窗口:

app.on("window-all-closed", function () {
    if (process.platform !=="darwin") app.quit();
});

现在,我们必须运行以下命令:

npm start

这将运行 Electron 应用程序,为我们提供以下输出:


图 5.14 – 我们在 Electron 中运行的 React 应用程序

在图 5.13 中,我们可以看到我们的应用程序正在桌面上的一个窗口中运行。 我们还可以看到我们的应用程序可以通过屏幕顶部的菜单栏访问。 该应用程序的徽标显示在我的任务栏上:


图 5.15 – 我的任务栏上的 Electron

以下命令将在 dist 文件夹中编译我们的应用程序,如果单击该文件夹,则会将该应用程序安装到您的计算机上:

npm build

以下是我在 Mac 上的应用程序区域中使用 Electron 测试我为 OasisLMF 构建的名为 Camel 的开源包的 GUI 时的示例:


图 5.16 – 应用程序区域中的 Electron 应用程序


最终,我会想出一个标志。 不过,关于在浏览器中显示内容的本章就到此结束。

概括

在本章中,我们最终使临时用户可以使用我们的应用程序,而不必依赖于 Postman 等第三方应用程序。 我们定义了自己的应用程序视图模块,其中包含读取文件和插入功能。 这导致我们构建了一个流程,加载 HTML 文件,将 JavaScript 和 CSS 文件中的数据插入到视图数据中,然后提供该数据。

这为我们提供了一个动态视图,当我们编辑、删除或创建待办事项时,该视图会自动更新。 我们还探索了一些有关 CSS 和 JavaScript 的基础知识,以便从前端进行 API 调用并动态编辑视图某些部分的 HTML。 我们还根据窗口的大小管理整个视图的样式。 请注意,我们不依赖外部板条箱。 这是因为我们希望能够了解如何处理 HTML 数据。

然后,我们在 React 中重建了前端。 虽然这需要更长的时间并且有更多的移动部件,但代码更具可扩展性并且更安全,因为我们不必手动操作字符串来编写 HTML 组件。 我们还可以明白为什么我们倾向于 React,因为它非常适合 Electron,为我们提供了另一种向用户交付应用程序的方式。

虽然我们的应用程序现在按表面价值运行,但它在数据存储方面不可扩展。 我们没有数据过滤流程。 我们不会检查我们存储的数据,也没有多个表。

在下一章中,我们将构建与 Docker 本地运行的 PostgreSQL 数据库交互的数据模型。

问题

将 HTML 数据返回到用户浏览器的最简单方法是什么?

将 HTML、CSS 和 JavaScript 数据返回到用户浏览器的最简单(不可扩展)的方法是什么?

我们如何确保某些元素的背景颜色和样式标准在应用程序的所有视图中保持一致?

API 调用后我们如何更新 HTML?

我们如何启用按钮来连接到我们的后端 API?

答案

我们只需定义一个 HTML 字符串并将其放入 HttpResponse 结构体中,同时将内容类型定义为 HTML,即可提供 HTML 数据。 然后 HttpResponse 结构体返回到用户的浏览器。

最简单的方法是硬编码一个完整的 HTML 字符串,CSS 硬编码在 <style> 部分,我们的 JavaScript 硬编码在 <script> 部分。 然后将该字符串放入 HttpResponse 结构体中并返回到用户的浏览器。

我们创建一个 CSS 文件来定义我们希望在整个应用程序中保持一致的组件。 然后,我们在所有 HTML 文件的 <style> 部分放置一个标签。 然后,对于每个文件,我们加载基本 CSS 文件并用 CSS 数据替换标签。

API调用后,我们必须等待状态准备好。 然后,我们使用 getElementById 获取要更新的 HTML 部分,序列化响应数据,然后将元素的内部 HTML 设置为响应数据。

我们给按钮一个唯一的 ID。 然后,我们添加一个事件侦听器,该侦听器由唯一 ID 定义。 在此事件侦听器中,我们将其绑定到一个使用 this 获取 ID 的函数。 在此函数中,我们对后端进行 API 调用,然后使用响应来更新显示数据的视图其他部分的 HTML。