​ 把一些前后端概念性比较强的理念放在这里,不放代码,不提供案例,仅供理解核心概念参考。

  

Restful

restful架构:

每一个URI代表一种资源

客户端与服务器之间传递这种资源的表现层

客户端通过四个http动词对服务器端资源进行操作,实现表现层状态转化

restful中一般包含以下数据:

接口地址、请求方法、请求参数:即传输参数时要带什么参数、携带什么字段,规则是什么、返回内容:一般为json或者xml形式,错误代码

具体来说

域名:应该将api部署在专用域名之下,如https://api.example.com api很简单时考虑放在主域名下 https://example.org/api/

版本:应该将api的版本好放入URL中:https://api.example.com/v1/

路径: 每个路径/终点代表一种资源,所以api中不能有动词,只能有名词,而且所用名词往往与数据库的表名对应,如https://api.example.com/v1/zoos https://api.example.com/v1/animals

http动词:对于资源的不同操作类型使用不同的http方法

过滤信息:如果资源数据很多,服务器不可能都将它们返回给用户,api提供参数,过滤返回结果。?limit=10:指定返回记录的数量 ?offset:指定返回记录的开始位置 ?page=2&per——page=100 指定第几页,以及每页的记录数 ?sortby=name&order=asc:指定返回结果按照哪个属性排序以及排序顺序 ?animal——type——id=1:指定筛选条件

状态码:服务器向用户返回的状态码和提示信息

错误处理:如果状态码时4**,应该向用户返回错误信息,并将error作为键名

返回结果:不同的请求应该返回指定解构的数据,比如数组或者包含完整信息的对象

其他:api的身份认证应该使用oauth2.0框架、服务器返回的数据格式应该尽量使用json,避免使用xml

Swagger

swagger是rest API生成工具,swagger是一个完整的框架,用于生成、描述、调用和可视化restfulapi的web服务

insomnia

Insomnia是一个跨平台的REST客户端,类似于postman,一般用于测试

Insomnia页面主要由4大块构成:菜单栏、接口栏、请求栏和响应栏,看起来更加清晰简洁

可以设置三级的配置文件,用于多个不同环境的配置。

首先最高级配置是Base Environment ,第二优先级配置是Sub Enviroments,在接口栏中创建文件夹,可以建立针对这个文件的环境,这是第三级配置

Insomnia会自动保存接口的访问结果,请求和响应数据都会完整的保存下来,需要的时候就可以再点开看。使用postman是不会保存请求的,有时候没有网络(比如使用内网时)就很不方便查看了。

iosomnia的缺点:

内置方法:

postman可以使用Pre-request Script 在请求前先定义好一些变量。Insomnia不支持java script,只能使用他内部的一些方法,或者下载插件。但是针对大多数的请求是可以满足的。比如加密、解密、获取时间等。

postman

apifox

Oauth

GraphQL

GraphQL是一种用于API的查询语言。对API提供了一套易于理解的完整描述,使客户端能够准确获得需要的数据

使用GraphQL后端将不再产出api,与前端约定一套schema,用来生成接口文档,前端通过schema进行自己期望的请求

GraphQL并没有和特定的数据库或者存储引擎绑定,而是依靠现有的代码和数据支撑。一个GraphQL是通过定义类型和类型上的字段来创建的,然后给每个类型的字段提供解析函数

目前很多语言也提供了对GraphQL的支持,比如javascript/nodejs,Java、php、Ruby、Python、Go、C#等

与Restful接口相比

当今很多页面需要向多个资源发送请求,有的页面甚至发3个以上的请求,而且请求之间可能存在依赖关系,后一个请求需要根据前一个请求的结果进行下一步

传统restful:

数据请求冗余:一个用户的信息可能包含id、昵称、年龄、性别、头像、等级等很多字段,但是有时我们只需要用户的一两个字段而不得不请求到所有的数据,其他的数据是无用的,或者需要获得一个用户列表时,需要调用多个接口,给后端造成麻烦

restful接口改动麻烦:restful一旦要改变API,改变APIurl、增减数量、前后端都需改变

GraphQL:

减少数据和请求冗余:正如官网所说,请求的数据不多也不少,节省带宽,获取多个资源可以只用一个请求

schema格式化:通过schema限制了req和rep的格式和类型,且自动生成文档,减少前后端沟通成本

接口校验:

接口变动,维护与文档:客户端只声明数据,不需要具体格式,API也无需划分版本,只要服务端包含请求方的数据,不需要更改接口

劣势:学习成本,对后端的要求高,这也是graphql推广难的原因,后端需要重新构建

简言之:

restful一个接口返回一个资源,graphql一次获取多个资源

restful用url定义资源,graphql用类型定义资源

优点:

1.强类型文档。GraphQL 本身首先是一门语言,而且是强类型语言,目前社区已有各种语言与 GraphQL 转换的工具。实践中,前端配合自动生成工具和 TypeScript 能确切知道每个 Query 每个变量每个属性的类型,省去了以往 RESTful API 项目中前端手动声明类型的麻烦。使用如果后端使用 TypeGraphQL 或者 NestJS 这类框架,能直接从 TypeSCript 的 Class 生成文档(schema)。

2.自动聚合。以往 RESTful API 的每个接口的数据都是预先设计好的,前端展示的数据和后端返回的数据有时匹配不上,复杂的业务往往需要一层 BFF 层来做数据聚合。GraphQL 的返回数据是由前端决定的,能有效降低前后端的数据耦合程度,并且减少接口调用次数。

3.更细致的鉴权。RESTful API 的鉴权颗粒度只停留在路由上,但是 GraphQL 能控制每个字段的访问权限。(讲道理 RESTful 也能,无非实现起来麻烦点)

缺点:

1.迁移成本高。 2.生态不完善。如果后端要用 GraphQL 的话目前几乎只能选 Node.js 了。其他语言生态都不完善,缺少 dataloader 、federation 等关键包库。

坑:

1.著名的 N+1 问题。对于后端来说,需要对每一个列表查询进行优化避免 N + 1 。目前流行的解决方案是 dataloader 。

2.每个前端项目只能有一张 GraphQL schema 。这使得后端必须部署一个网关来整个各个微服务,目前几乎只能用 Apollo Federation 来解决这个问题。

3.Subscriptions 。Apollo 实现了 Subscriptions 功能来帮助服务器主动发送消息。但是实践下来发现这个功能还是比较简陋的,不合适微服务架构和集群部署。

GraphQL中的基本概念

操作类型

​ query:查询:获取数据,查

​ mutation:修改,对数据进行变更,增删改

​ substription:订阅:当数据发生更改时进行消息推送

​ GraphQL在query查询语句时是并行执行的,在mutation变更时是线性执行的,一个接着一个

数据类型

​ GraphQL中定义了两种类型:对象类型和标量类型

​ 对象类型是用户在schema中定义的type

​ GraphQL中定义了标量类型,String、Int、Float、Boolean、ID,此外,用户还可以自己定义自己的标量类型

​ 参数后通常跟!表示参数不能为空

​ GraphQL服务器接收到query请求后,从Root Query开始查找,找到对象类型后到它的解析函数Resolver获取内容,如果返回的是标量内容则结束获取,直到获取最后一个标量类型

模式schema

​ 在schema中定义了字段的类型,数据的结构,即接口数据请求的规则,

​ schema使用强类型模式语法,称为模式描述语言

解析函数Resolver

​ 前端请求到达后端后,由Resolver函数提供数据,

​ 解析函数接受四个参数

​ parent:当前上一个解析函数的返回值

​ args:查询函数中传入的参数

​ context:提供给所有解析器的上下文信息

​ info:一个保存与当前查询相关的字段特定信息

请求格式:

​ GraphQL是通过http来发送请求的

异常处理

​ 查询错误:返回状态码200,返回data字段为null,如query语法错误时

​ HTTP错误:返回码4** 或5**,如token失效等

​ 网络异常:fetch方法异常,如无网络

​ JSON解析异常:response.json方法异常

openAPi-to-GraphQL

转换openAPI接口为GraphQL

cli

npm i -g openapi-to-graphql-cli

## openapi-to-graphql <OAS JSON file path or remote url> [options]

在代码中引入

npm i -s openapi-to-graphql

使用

const { createGraphQLSchema } = require("openapi-to-graphql");
// load or construct OAS (const oas = ...)
const { schema, report } = await createGraphQLSchema(oas);

https://github.com/IBM/openapi-to-graphql

Websocket

Http协议是无状态的,只能由客户端主动发起,服务端再被动响应,服务端无法向客户端主动推送内容,并且一旦服务器响应结束,链接就会断开(见注解部分),所以无法进行实时通信。WebSocket协议正是为解决客户端与服务端实时通信而产生的技术

WebSocket协议本质上是一个基于tcp的协议,它是先通过HTTP协议发起一条特殊的http请求进行握手后,如果服务端支持WebSocket协议,则会进行协议升级。WebSocket会使用http协议握手后创建的tcp链接,和http协议不同的是,WebSocket的tcp链接是个长链接(不会断开),所以服务端与客户端就可以通过此TCP连接进行实时通信。

WebSocket是html5规范中的一个部分,它借鉴了socket这种思想,为web应用程序客户端和服务端之间(注意是客户端服务端)提供了一种全双工通信机制。同时,它又是一种新的应用层协议,WebSocket协议是为了提供web应用程序和服务端全双工通信而专门制定的一种应用层协议,

websocket的特点:

  • 1)建立在 TCP 协议之上,服务器端的实现比较容易;
  • 2)与 HTTP 协议有着良好的兼容性。默认端口也是80和443,并且握手阶段采用 HTTP 协议,因此握手时不容易屏蔽,能通过各种 HTTP 代理服务器;
  • 3)数据格式比较轻量,性能开销小,通信高效;
  • 4)可以发送文本,也可以发送二进制数据;
  • 5)没有同源限制,客户端可以与任意服务器通信;
  • 6)协议标识符是ws(如果加密,则为wss),服务器网址就是 URL,形如:ws://example.com:80/some/path。

websocket断码测试

刷新浏览器页面 1001 终端离开, 可能因为服务端错误, 也可能因为浏览器正从打开连接的页面跳转离开.
关闭浏览器tab页面 1001 终端离开, 可能因为服务端错误, 也可能因为浏览器正从打开连接的页面跳转离开.
关闭浏览器, 所有标签页都会关闭。 1001 可以发现。无论是刷新,关闭tab页面还是关闭浏览器,错误码都是1001
ws.close() 1005 主动调用close, 不传递错误码。对服务端来说,也是异常断开。 1005表示没有收到预期的状态码.
ws.close(1000) 1000 正常的关闭,客户端必需传递正确的错误原因码。 原因码不是随便填入的。 比如 ws.close(1009) Failed to execute 'close' on 'WebSocket': The code must be either 1000, or between 3000 and 4999. 1009 is neither.

连接过程

当web程序执行到new WebSocket(url)接口时,browser就开始与url对应的webserver建立握手连接过程

首先建立http连接,也就是Browser与Websocket服务器通过tcp三次握手建立连接,如果这个过程失败就后面的过程就不会执行,直接收到错误。

在tcp建立成功之后,Browser通过http协议发送websocket支持的版本号,协议的版本号、主机等信息发送给服务器。

服务器收到之后确认信息,正确的话就接受本次握手连接,并给出响应的数据恢复,回复的数据也采用http传输

浏览器收到服务器回复的数据包之后数据包和信息都没有问题,就表示连接成功,触发onopen消息。即升级为websocket连接

升级时发送请求头

Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: sN9cRrP/n9NdMgdcy2VJFQ==
Sec-WebSocket-Version: 13 

前两行表示升级为websocket请求

Sec-WebSocket-Key 是由浏览器随机生成的,提供基本的防护,防止恶意或者无意的连接。

Sec-WebSocket-Version 表示 WebSocket 的版本,最初 WebSocket 协议太多,不同厂商都有自己的协议版本,不过现在已经定下来了。如果服务端不支持该版本,需要返回一个 Sec-WebSocket-Versionheader,里面包含服务端支持的版本号。

响应信息

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk=
Sec-WebSocket-Protocol: chat 
  1. Sec-WebSocket-Accept 这个则是经过服务器确认,并且加密过后的 Sec-WebSocket-Key
  2. Sec-WebSocket-Protocol 则是表示最终使用的协议。
let socket = new WebSocket('wss://')

socket.onopen = function(e) {
	socket.send('a')
}

socket.onmessage = function(event) {
	console.log(event.data)
}

我们需要在 url 中使用特殊的协议 ws 创建 new WebSocket

同样也有一个加密的 wss:// 协议。类似于 WebSocket 中的 HTTPS。

wss:// 协议不仅是被加密的,而且更可靠。

因为 ws:// 数据不是加密的,对于任何中间人来说其数据都是可见的。并且,旧的代理服务器不了解 WebSocket,它们可能会因为看到“奇怪的” header 而中止连接。

另一方面,wss:// 是基于 TLS 的 WebSocket,类似于 HTTPS 是基于 TLS 的 HTTP),传输安全层在发送方对数据进行了加密,在接收方进行解密。因此,数据包是通过代理加密传输的。它们看不到传输的里面的内容,且会让这些数据通过。

websocket、长轮询、轮询、sse的区别

短轮询(Polling)的实现思路就是浏览器端每隔几秒钟向服务器端发送http请求,服务端在收到请求后,不论是否有数据更新,都直接进行响应。在服务端响应完成,就会关闭这个Tcp连接

长轮询(Long-Polling)客户端发送请求后服务器端不会立即返回数据,服务器端会阻塞请求连接不会立即断开,直到服务器端有数据更新或者是连接超时才返回,客户端才再次发出请求新建连接、如此反复从而获取最新数据。

还有一种长轮询是当我们在页面中嵌入一个iframe并设置其src时,服务端就可以通过长连接“源源不断”地向客户端输出内容。 例如,我们可以向客户端返回一段script标签包裹的javascript代码,该代码就会在iframe中执行。因此,如果我们预先在iframe的父页面中定义一个处理函数process(),而在每次有新数据需要推送时,在该连接响应中写入。那么iframe中的这段代码就会调用父页面中预先定义的process()函数。

SSE(Server-sent-Event):Server-SentHTML5提出一个标准。由客户端发起与服务器之间创建TCP连接,然后并维持这个连接,直到客户端或服务器中的任何一方断开,ServerSent使用的是"问"+"答"的机制,连接创建后浏览器会周期性地发送消息至服务器询问,是否有自己的消息。其实现原理类似于我们在上一节中提到的基于iframe的长连接模式。 HTTP响应内容有一种特殊的content-type —— text/event-stream,该响应头标识了响应内容为事件流,客户端不会关闭连接,而是等待服务端不断得发送响应结果。 SSE规范比较简单,主要分为两个部分:浏览器中的EventSource对象,以及服务器端与浏览器端之间的通讯协议

Web Sockets定义了一种在通过一个单一的 socket 在网络上进行全双工通讯的通道。仅仅是传统的 HTTP 通讯的一个增量的提高,尤其对于实时、事件驱动的应用来说是一个飞跃。

# 轮询(Polling) 长轮询(Long-Polling) Websocket sse
通信协议 http http tcp http
触发方式 client(客户端) client(客户端) client、server(客户端、服务端) client、server(客户端、服务端)
优点 兼容性好容错性强,实现简单 比短轮询节约资源 全双工通讯协议,性能开销小、安全性高,可扩展性强 实现简便,开发成本低
缺点 安全性差,占较多的内存资源与请求数 安全性差,占较多的内存资源与请求数 传输数据需要进行二次解析,增加开发成本及难度 只适用高级浏览器
延迟 非实时,延迟取决于请求间隔 同短轮询 实时 非实时,默认3秒延迟,延迟可自定义

总结:

兼容性:短轮询>长轮询>长连接SSE>WebSocket

性能:WebSocket>长连接SSE>长轮询>短轮询

服务端推送:WebSocket>长连接SSE>长轮询

BFF

BFF是(Backends For Frontends)单词的缩写,主要是用于服务前端的后台应用程序,来解决多访问终端业务耦合问题。

传统的应用程序内提供的接口是有业务针对性的,这种类型的接口如果独立出来再提供给别的系统再次使用是一件比较麻烦的事情,设计初期的高耦合就决定了这一点。

如果我们的接口同时提供给web移动端使用,移动端仅用来采集数据以及数据的展示,而web端大多数场景是用来管理数据,因为不同端点的业务有所不同每一个端的接口复用度不会太高。

针对多端共用服务接口的场景,我们将基础的数据服务BFF进行了分离数据服务仅提供数据的增删改查,并不过多涉及业务的判断处理,所有业务判断处理都交给BFF来把控,遇到的一些业务逻辑异常也同样由BFF格式化处理后展示给访问端点。

这种设计方式同样存在一定的问题,虽然基础服务BFF进行了分离,我们只需要在BFF层面进行业务判断处理,但是多个端共用一个BFF,也会导致代码编写复杂度增高代码可阅读性降低多端业务耦合

如果我们为每一个端点都提供一个BFF,每个端点的BFF处理自身的业务逻辑,需要数据时从基础服务内获取,然后在接口返回之前进行组装数据用于实例化返回对象。

这样基础服务如果有新功能添加,BFF几乎不会受到影响,而我们如果后期把App端点进行拆分成AndroidIOS时我们只需要将app-bff进行拆分为android-bffios-bff,基础服务同样也不会受到影响

优缺点

优点:

(1)可以降低沟通成本:后端同学追求解耦,希望客户端应用和内部微服务不耦合,通过引入BFF这中间层,使得两边可以独立变化

(2)多端应用适配:展示不同的(或更少量的)数据,比如PC端页面设计的API需要支持移动端,发现现有接口从设计到实现都与桌面UI展示需求强相关,无法简单适应移动端的展示需求 ,就好比PC端一个新闻推荐接口,接口字段PC端都需要,而移动端呢H5不需要,这个时候根据不同终端在BFF层做调整,同时也可以进行不同的(或更少的)API调用(聚合)来减少http请求

  当你在设计 API 时,会因为不同终端存在不同的区分,它们对服务端提供的 API 访问也各有其特点,需要做一些区别处理。这个时候如果考虑在原有的接口上进行修改,会因为修改导致耦合,破坏其单一的职责。

BFF的痛点

(1)重复开发:每个设备开发一个 BFF 应用,也会面临一些重复开发的问题展示,增加开发成本

(2)维护问题:需要维护各种 BFF 应用。以往前端也不需要关心并发,现在并发压力却集中到了 BFF 上

(3)链路复杂:流程变得繁琐,BFF引入后,要同时走前端、服务端的研发流程,多端发布、互相依赖,导致流程繁琐

(4)浪费资源: BFF层多了,资源占用就成了问题,会浪费资源,除非有弹性伸缩扩容

在微服务架构设计中,BFF起到了一个业务聚合的关键作用,可以 通过openfeignrestTemplate调用基础服务来获取数据,将获取到的数据进行组装返回结果对象,BFF解决了业务场景问题,也同样带来了一些问题,如下所示:

  • 响应时间延迟(服务如果是内网之间访问,延迟时间较低)
  • 编写起来较为浪费时间(因为在基础服务上添加的一层转发,所以会多写一部分代码)
  • 业务异常处理(统一格式化业务异常的返回内容)
  • 分布式事务(微服务的通病)

前后端跨域9种方式

浏览器的同源策略/SOP(Same origin policy)是一种约定,所谓同源是指"协议+域名+端口"三者相同,即便两个不同的域名指向同一个ip地址,也非同源。它是浏览器最核心也最基本的安全功能,如果缺少了同源策略,浏览器很容易受到XSS、CSFR等攻击。

非同源的两个对象,Cookie、LocalStorage 和 IndexDB 无法读取2、 DOM 和 Js对象无法获得3、 AJAX 请求不能发送

对于前后端分离,跨域是需要解决的问题

跨域解决方法

1.jsonp

浏览器允许在html页面中再通过相应的标签从不同域名下加载静态资源,而被浏览器允许,基于此原理,我们可以通过动态创建script,再请求一个带参网址实现跨域通信。

手撸jsonp

2.跨域资源共享(CORS)

服务端设置Access-Control-Allow-Origin即可,前端无须设置,若要带cookie请求:前后端都需要设置。CORS也已经成为主流的跨域解决方案。

CORS是一种基于http头的机制,该机制通过允许服务器标识除了它自己以外的其他origin(域、协议或者端口),这样浏览器可以从这里加载资源。

3.Nodejs中间件/Nginx代理跨域

通过启一个代理服务器,实现数据的转发,也可以通过设置cookieDomainRewrite参数修改响应头中cookie中域名,实现当前域的cookie写入,方便接口登录认证。

利用node + express + http-proxy-middleware搭建一个proxy代理服务器。

var express = require('express');
var app = express();

var proxy = require('http-proxy-middleware');

app.use('/', proxy({
    // 代理跨域目标接口
    target: 'http://www.demo2.com:8080',
    changeOrigin: true,

    // 修改响应头信息,实现跨域并允许带cookie    
      onProxyRes: function(proxyRes, req, res) {
          res.header('Access-Control-Allow-Origin', 'http://www.domain1.com');
          res.header('Access-Control-Allow-Credentials', 'true');
      },

      // 修改响应信息中的cookie域名
      cookieDomainRewrite: 'www.demo1.com'  // 可以为false,表示不修改
      })
    );

实现思路:通过nginx配置一个代理服务器(域名与demo1相同,端口不同)做跳板机,反向代理访问demo2接口,并且可以顺便修改cookie中demo信息,方便当前域cookie写入,实现跨域登录。

4.WebSocket协议跨域

WebSocket protocol是HTML5一种新的协议。它实现了浏览器与服务器全双工通信,同时允许跨域通讯,是server push技术的一种很好的实现。

原生WebSocket API使用起来不太方便,我们使用Socket.io,它很好地封装了webSocket接口,提供了更简单、灵活的接口,也对不支持webSocket的浏览器提供了向下兼容。

5.XMLHttpRequest

HTML5 XMLHttpRequest Level 2中的API,且是为数不多可以跨域操作的window属性之一,

6.axios

其他

window.name + iframe跨域

document.domain + iframe跨域

location.hash + iframe跨域

https://docs.qq.com/doc/BqI21X2yZIht1bfJDW0BNxAF0cqHEb1racd94Lv8jM4Uatg11

各种跨域方式对比

实现CORS的关键是服务器,只要服务器实现了CORS接口,就可以跨源通信了

浏览器将CORS请求分为两类,简单请求和复杂请求

简单请求是指http方法为get、post、head三种之一,或者http头部信息不超过以下字段

对于简单请求,请求时自动在请求头信息中添加Origin字段,说明本次请求是来自哪个源

非简单请求是对服务器有特殊要求的请求,比如请求方法是PUT或者DELETE,或者cotent-type字段的类型是application/json

对于非简单请求的CORS请求,会在正式通信之前增加一次HTTP查询请求,称为预检请求

浏览器会先询问服务器当前的域名是否在服务器的许可名单之中,以及可使用哪些HTTP动词和头字段

预检请求的请求信息

OPTIONS /cors HTTP/1.1

预检请求使用的请求方法是OPTIONS,表示这个请求是用来询问的。

一旦服务器通过了预检请求,以后每次浏览器正常的CORS请求就会像简单请求一样,会有一个Origin头部信息

CORS比jsonp更强大,JSONP只支持GET请求,CORS支持所有类型的HTTP请求。

JSONP的优势在于支持老式浏览器,以及可以向不支持CORS的网站请求。

跨域验证

方案一:cookie/session认证

用户填写用户名和密码,发送给服务器

局限:

无法数据共享。如果A网站和B网站是同一家公司的

方式二:JWT(Json Web Token)是最流行的跨域验证方案

JWT的原理是服务器认证以后,生成一个json对象,发回给用户。用户与服务器通信都靠这个对象。为了防止用户篡改数据,服务端生成对象时会加上签名。

JWT是一个很长的字符串,中间用点分隔成3个部分。JWT的三个部分依次是头部Header、负载Payload、签名Signature。

头部定义签名的算法和令牌类型(默认为JWT),负载包含实际需要传递的数据,其中定义了七个字段

iss:签发人,exp:过期时间 sub:主题。aud:受众 nbf:生效时间 iat:签发时间 jti:编号

签名是对前两部分的签名,防止数据篡改

使用方式:

客户端收到服务器返回的JWT,可以存储在cookie中,也可以存储在localstorage中,

客户端每次与服务器通信都带上JWT,把JWT放在http请求的头信息Authorization字段里面,就可以跨域访问。

JWT的使用特点:

JWT默认是不加密的,也可以加密,不加密时不能讲秘密数据写入JWT,加密时生成原生token用密钥加密。

JWT不仅可以用于验证,也可以用于交换信息。有效使用JWT可以降低服务器查询数据库的次数

如果你觉得我的文章对你有帮助的话,希望可以推荐和交流一下。欢迎關注和 Star 本博客或者关注我的 Github