项目技术难点

一、医院预约挂号管理系统项目难点

医院预约挂号管理系统
医院预约挂号管理系统

业务流程:

业务流程
业务流程

1、说一说什么是微服务,为什么要用微服务呢?

1.1 微服务定义

微服务是一种开发软件的架构和组织方法,其中软件由通过明确定义的API进行通信的小型独立服务组成。微服务架构使得应用程序更易于扩展和更快地开发。使用微服务架构可以将应用程序构建为独立的组件,并将每个应用程序进行作为一项服务运行。在对服务进行更新的时候不向整体式架构那样复杂,只需要针对各项服务进行更新、部署和扩展即可。

简单来讲,微服务将一个复杂的应用拆分成多个独立自治的服务,服务和服务之间通过松耦合的形式交互。

1.2 为什么要用微服务?(微服务优点有哪些?)

微服务的优点:

  1. 采用微服务架构可以进行分布式部署,能够极大缓解高并发带来的压力;
  2. 不同的服务可以使用不同的技术;
  3. 隔离性。一个服务不可用不会导致其他服务不可用;
  4. 可扩展性。某个服务出现性能瓶颈,只需对此服务进行升级即可;
  5. 简化部署。服务的部署是独立的,哪个服务出现问题,只需对此服务进行修改重新部署;

2、项目中的微服务是怎么划分的呢?

我们这个项目的微服务是根据业务功能进行的划分的,比如我负责的就是用户服务、医院服务、订单服务这几个微服务。

考虑到系统的复用性,由于在用户服务模块中的登录功能和订单服务中发送短信功能都用到了阿里云的短信服务,所以也将短信服务独立出来成为一个微服务。

3、那项目中微服务之间如何进行通信的呢?

微服务通信方式主要有两种:同步调用异步调用

  • 对于同步调用,这个项目中我们使用了SpringCloud中的Feign组件来实现。在不同的微服务之间调用时,我们将调用功能封装到了一个新的client微服务模块中。
    1. 在该模块的pom文件中引入openfeign依赖,并新建一个接口用于调用的封装;
    2. 然后使用@FeignClient注解来表明需要调用的微服务名称,这个名称就是在Nacos中注册的名称;
    3. 然后在接口内部,将要调用的方法直接复制过来,并在Mapping注解中补充完整方法的路径。在参数的@PathVariable注解中也要指定参数的名称。
    4. 最后在使用到的微服务模块的微服务中进行调用,在相应的pom文件中引入对应的client依赖,在需要调用的地方使用@Autowired注解注入,至此,使用Feign组件完成了远程调用。
  • 对于异步调用,这个项目中我们用到的是RabbitMQ来实现的。比如在用户模块中的手机验证码登录功能中,需要发送短信,所以需要远程调用短信服务模块中的发送短信功能,可以将短信交给短信队列,用户模块作为生产者,而短信模块作为消费者帮忙发送短信。

4、RPC了解吗?简单说一说RPC调用的过程

RPC远程过程调用(Remote Procedure Call Protocol,简称RPC),像调用本地服务(方法)一样调用服务器的服务(方法)。RPC是分布式架构的核心,也可以分为:同步调用异步调用

4.1 RPC远程调用的过程

PRC架构中有四个组件:

  1. 客户端(Client):就是服务的调用方;
  2. 客户端存根(Client Stub):存放服务端地址信息,将客户端的请求参数打包成网络消息,再通过网络发送给服务方;
  3. 服务端存根(Server Stub):接受客户端发送过来的消息并解包,再调用本地服务;
  4. 服务端(Server):真正的服务提供者,也就是被调用方。
RPC远程调用过程
  1. 客户端以本地调用方式调用服务
  2. 客户端存根接收到调用后负责将方法、参数等组装成能够进行网络传输的消息体,就是序列化的过程;
  3. 客户端存根找到服务地址,并将消息通过网络发送到服务端;
  4. 服务端存根收到消息后进行解码,就是反序列化的过程;
  5. 服务端存根根据解码结果调用本地服务
  6. 服务端执行处理逻辑
  7. 服务端将结果返回给服务端存根;
  8. 服务端存根将返回结果打包成消息体,也就是序列化
  9. 服务端存根将打包后的消息通过网络发送至客户端存根
  10. 客户端存根接收到消息,并进行解码,就是反序列化
  11. 客户端得到最终结果。

而RPC框架的目的就是把中间的这些步骤(2-10)封装起来,也就是把调用、编码、解码的过程封装起来,让用户像调用本地服务一样的调用远程服务

5、什么是跨域问题?用Nginx怎么解决?为什么后面又改用Gateway了?

5.1 跨域问题

跨域问题是浏览器的同源策略限制,同源策略会阻止一个域的js脚本和另一个域的内容进行交互。所谓的同源就是指两个页面有相同的协议、主机号、端口号。

当一个请求地址中的访问协议域名(IP地址)端口号有任何一个与当前页面不一样就会产生跨域问题。(注:如果访问协议、域名和端口号都相同,但是请求路径不同,不属于跨域,比如www.jd.com/item和www.jd.com/goods。

由于微服务中不同的服务端口号不同,用户在各个页面进行跳转时,所以一定会存在跨域问题。

5.2 使用Nginx解决跨域问题

Nginx解决跨域问题
Nginx解决跨域问题

由于后台有很多服务模块,每个模块都有对应的访问路径与端口,为了提供统一的api接口,可以使用Nginx作为反向代理服务器。正向与反向,是相对于用户来说的。


  • 正向是用户主动配置代理服务器,比如科学上网。

  • 反向是用户想要访问某些网站,需要先将请求发送到网站的反向代理服务器,由反向代理服务器再转发到真正的服务器。网站对外暴露的是代理服务器的地址,隐藏了真实服务器的地址。


Nginx把客户端的http请求转发到另一个或者一些服务器上。通过把本地一个url前缀映射到要跨域访问的web服务器上,就可以实现跨域访问。

对于浏览器来说,请求访问的就是同源服务器上的一个url。而Nginx通过检测url前缀,把http请求转发到后面真实的物理服务器。并通过rewrite命令把前缀再去掉。这样真实的服务器就可以正确处理请求,并且并不知道这个请求是来自代理服务器的。

如何使用Nginx?

  1. 下载安装Nginx(Windows版);

  2. 编辑Nginx的配置文件nginx.conf,编辑统一个访问端口,然后再编辑每个微服务的访问规则;

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    server {
    listen 9001; //访问的端口
    server_name localhost; //主机名
    //以下是两条访问规则,路径包含hosp、cmn,就分别转发到相应的端口。
    //其中~表示正则匹配。
    location ~ /hosp/ {
    proxy_pass http://localhost:8201;
    }
    location ~ /cmn/ {
    proxy_pass http://localhost:8202;
    }
    }
  3. 前端只需要设置请求路径,让请求先访问定义的统一访问端口,再由Nginx网关根据进行相应规则进行转发;

  4. 最后在对应的Controller上添加@CrossOrigin注解,即可解决跨域问题。

5.3 为什么后面改用Gateway解决了呢?

随着业务的增多,微服务模块的增多,再继续使用Nginx配置就比较繁琐了。因为每新增一个微服务,就需要去nginx.conf文件中添加访问规则,并且最麻烦的是需要在每个模块里所有的Controller上添加跨域注解。

通过配置的网关信息,既可以解决跨域问题,又可以实现路由转发。使用了gateway就需要把之前接口上的@CrossOrigin注解去掉,否则会出错。

5.3.1 Gateway的作用是什么?

网关是整个微服务API请求的入口,负责拦截所有请求,分发到服务上去。可以实现日志拦截、权限控制、解决跨域问题、限流、熔断、负载均衡,隐藏服务端的ip,黑名单与白名单拦截、授权等功能。

5.3.2 如何使用Gateway?

  1. 搭建service-gateway 模块,在pom.xml中添加gateway依赖,注意 gateway 微服务也需要在 Nacos 中进行注册(添加服务注册依赖);
  2. 修改 application.properties 文件,添加相关配置。比如设置服务端口、服务名、 nacos 服务地址、设置路由等;
  3. 添加启动类。

5.3.3 Gateway的组成

  • 路由 : 网关的基本模块,有ID,目标URI,一组断言和一组过滤器组成;
  • 断言:就是访问该路由的访问规则,可以用来匹配来自http请求的任何内容,例如headers或者参数;
  • 过滤器:这个就是我们平时说的过滤器,用来过滤一些请求的,gateway有自己默认的过滤器,我们也可以自定义过滤器,但是要实现两个接口,orderedglobalfilter

5.4 Gateway是如何实现动态路由的呢?

项目里使用的方式是基于Nacos配置中心来做的。

简单来说,我们将路由配置放在Nacos中存储,Nacos会监听各个服务配置的变化,然后将配置更新到gateway中。

5.5 你刚才提到了正向代理和反向代理,能详细说说原理吗?

5.5.1 正向代理

正向代理:顺着请求的方向进行代理,也即客户端主动代理服务器帮忙访问目标服务器

正向代理过程

比如说:我们现在在国内想要访问谷歌,但是由于某些众所周知的原因,没有办法直接访问到谷歌,这时候我们可以通过连接一台正向代理服务器,由它将我们的请求提交给谷歌,然后再将谷歌的响应反馈给我们,对于谷歌而言,他只知道有个请求过来,并不知道我是不是直接访问谷歌的。

正向代理
正向代理

5.5.2 反向代理

反向代理:与正向代理相反,客户端并不知道要访问哪个服务器,由反向代理服务器帮忙将请求转发给具体的服务器。

反向代理过程

比如说:我们访问百度,百度的代理服务器对外的域名为 https://www.baidu.com 。但具体内部的服务器节点我们不知道。现实中我们通过访问百度的代理服务器后,代理服务器将我们的请求转发到他们N多的服务器节点中的一个,然后给我们进行搜索后将结果返回。此时,代理服务器对我们客户端来说就充当了提供响应的服务器,但是对于目标服务器来说,它只是进行了一个请求和转发的功能。

反向代理
反向代理

5.5.3 总结

  • 正向代理是代理客户端,服务端不知道实际发起请求的客户端;
  • 反向代理是代理服务端,客户端不知道实际提供服务的服务端;

6、Nacos相关?

Nacos 是阿里巴巴开源的一个构建云原生应用的动态服务发现、配置管理的平台,可以作为注册中心配置中心

由于订单模块中预约挂号功能需要获取医院信息和就诊人信息,而这两个信息分别属于用户模块和医院模块,所以必须要进行远程调用。用到的技术就是使用注册中心和远程调用,而Nacos是一个很好的选择。

每个微服务在Nacos配置中心进行服务注册,然后就可以利用服务名、IP地址及端口号互相进行远程调用。

6.1 什么是注册中心?

比如服务A需要调用服务B,但此时服务A并不知道服务B在哪几台服务器上,而且也不知道服务B是正常状态还是下线状态。为了解决这个问题,所以注册中心就出现了。

Nacos注册中心原理
Nacos注册中心原理

注册中心原理:

  • 服务注册的策略的是每5秒向nacos server发送一次心跳,心跳带上了服务名,服务ip,服务端口等信息。
  • Nacos服务端也会向客户端主动发起健康检查,支持tcp/http检查。
    • 如果15秒内无心跳且健康检查失败则认为这个服务不健康;
    • 如果30秒内健康检查失败则剔除服务。

简单来说:已经在Nacos注册过的服务可以实时感知到其他服务的状态,如果某些服务下线,其他服务也能实时感知,从而避免调用不可用的服务。

6.2 什么是配置中心?

每个服务都有大量的配置,并且每个微服务都可能部署在多台机器上。不可避免的就需要经常变更配置,可以让每个微服务通过配置中心获取自己的配置。

配置中心主要用来集中管理微服务的配置信息。

Nacos配置中心原理
Nacos配置中心原理

6.3 如何使用Nacos?

  1. 下载和安装;
  2. 在pom文件中引入Nacos依赖;
  3. 在配置文件中添加Nacos服务的地址;
  4. 在启动类上添加@EnableDiscoveryClient注解,开启服务注册,服务启动后就会在Nacos注册中心进行注册;

7、Feign组件是如何进行远程调用的?

Feign远程调用的核心就是通过一系列的封装和处理,将以JAVA注解的方式定义的远程调用API接口,最终转换成HTTP的请求形式发给远程服务,远程服务器处理完之后,将HTTP的请求的响应结果,解码成JAVA Bean,返回给调用者。

Feign远程调用的基本流程,大致如下图所示:

Feign远程调用流程
Feign远程调用流程

8、说说RPC和HTTP的关系

我觉得RPC和HTTP并不是平行的概念,RPC可以采用HTTP协议来实现,也可以采用其他协议来实现。

  1. HTTP协议是应用层的协议,是因特网数据传输的基础,主要服务于网页端和服务端之间的数据传输,而RPC是实现不同计算机应用之间的数据通信;
  2. HTTP是已经实现并且成熟的应用层协议,它定义了通信报文的一些格式,而RPC只是定义了不同服务之间数据通信的一个规范;
  3. HTTP协议和实现了RPC规范的框架都能实现跨网络节点之间的服务通信,由于RPC只是一种规范,所以只要符合RPC规范的框架都属于RPC框架,RPC也可以使用HTTP协议来实现,比如openFeign底层其实都采用了HTTP协议。

对于具体的应用场景:RPC适合微服务之间的调用,而HTTP协议一般用于api接口和前端进行交互。

9、什么是REST规范?

REST是一种软件架构风格,REST通过HTTP协议定义的通用动词方法(GET、POST、PUT、DELETE),以URI对网络资源进行唯一标识,响应端根据请求端不同的需求,通过无状态通信,对其请求的资源进行表述。

简单来说,客户端与服务端之间通过url就知道需要什么资源,通过动词方式就只要需要干什么,通过状态码就知道结果是什么。

Rest架构的主要原则:

  1. 网络上的所有事物都被抽象为资源;
  2. 每个资源都有一个唯一的资源标识符;
  3. 同一个资源具有多种表现形式(xml,json等);
  4. 对资源的各种操作不会改变资源标识符;
  5. 所有的操作都是无状态的

RESTful风格体现在

  • 使用了Get请求,就是查询;
  • 使用Post请求,就是新增的请求;
  • 使用Put请求,就是修改的请求;
  • 使用Delete请求,就是删除的请求。

这样做就完全没有必要对crud做具体的描述。

9.1 Rest的优点有哪些?

  1. 统一代码风格,极大简化了前后端对接时间,提高了开发效率;
  2. 充分利用HTTP协议自身的语义;
  3. 无状态,在调用接口时,不用考虑上下文和当前状态,极大降低了复杂度。

10、SpringCloud的组件有哪些?

  • 服务注册:EurekaZookeeperConsulNacos;
  • 负载均衡:Ribbon ;
  • 远程调用:Feign ;
  • 隔离熔断:Hystrix、Sentinel;
  • 网关路由:Gateway、Zuul

10.1 项目里是如何实现负载均衡的?

通过Spring Cloud Gateway中自带的负载均衡器实现,只需要在配置文件中设置路由的uri时加上lb://服务名,比如
spring.cloud.gateway.routes[0].uri=lb://service-hosp,Gateway就会用自动实现负载均衡。

10.2 你知道哪些负载均衡算法呢?

  1. 轮询,所有的请求被依次分发到每台服务器上;
  2. 加权轮询,根据服务器的硬件性能情况,在轮询的基础上,按照权重分配请求;
  3. 随机,请求被随机分配到各个服务器上;
  4. 最少连接,记录每个服务器正在处理的连接数,将新的请求分发到最少连接的服务器上;
  5. 源地址散列,根据请求来源的IP地址进行hash计算,保证来自同一个IP的请求总在一个服务器上。

10.3 Hystrix有什么功能?

在分布式系统中,如果某个服务节点发生故障或者网络发生异常,都有可能导致调用方被阻塞等待,如果超时时间设置很长,调用方资源很可能被耗尽。这又导致了调用方的上游系统发生资源耗尽的情况,最终导致系统雪崩。

10.3.1 Hystrix限流

当系统的处理能力不能应对外部请求的突增流量时,为了不让系统奔溃,必须采取限流的措施。hystrix可以使用信号量和线程池来进行限流。

  • 信号量限流:在提供服务的方法上加@HystrixCommand的注解,并设置@HystrixProperty。后续也能继续设置有多少个并发线程来访问这个方法,超过的就被转到了fallbackMethod中设置的降级方法里;

    1
    2
    3
    4
    5
    6
    7
    @HystrixCommand(
    commandProperties= {
    @HystrixProperty(name="execution.isolation.strategy", value="SEMAPHORE"),
    @HystrixProperty(name="execution.isolation.semaphore.maxConcurrentRequests", value="20")
    },
    fallbackMethod = "errMethod"
    )
  • 在提供服务的方法上加@HystrixCommand的注解,并设置@HystrixProperty。后续继续设置线程池的参数,就能进行限流操作。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    @HystrixCommand(
    commandProperties = {
    @HystrixProperty(name = "execution.isolation.strategy", value = "THREAD")
    },
    threadPoolKey = "createOrderThreadPool",
    threadPoolProperties = {
    @HystrixProperty(name = "coreSize", value = "20"),
    @HystrixProperty(name = "maxQueueSize", value = "100"),
    @HystrixProperty(name = "maximumSize", value = "30"),
    @HystrixProperty(name = "queueSizeRejectionThreshold", value = "120")
    },
    fallbackMethod = "errMethod"
    )

10.3.2 Hystrix熔断

熔断机制是应对雪崩效应的⼀种微服务链路保护机制。当某个微服务不可⽤或者响应时间太⻓时,熔断该节点微服务的调⽤,进⾏服务的降级,快速返回错误的响应信息。当检测到该节点微服务调⽤响应正常后,恢复调⽤。

可以在@HystrixProperty中配置熔断时间。

1
2
3
4
5
6
7
@HystrixCommand(
// commandProperties
commandProperties = {
// 每一个属性都是一个HystrixProperties
@HystrixProperty(name = "execution.isolation.thread.timeoutInMilliseconds", value = "2000")
}
)

10.3.3 Hystrix服务降级

使用降级只需要在@HystrixCommand注解上加上fallbackMethod,同时定义一个fallback的方法,就能实现降级。

11、说一说项目里两种登录功能的实现?

11.1 如何进行登录校验?

gateway微服务模块中添加fillter用于判断用户登录状态。

  • 通过网关服务中自定义的过滤器filter,先从请求头中获取用户信息;
  • 查看用户id,如果存在表示用户已经登录;
  • 若检查到用户未登录则拒绝请求并提示登录信息。

11.2 说一说项目里短信验证码登录流程

  1. 首先整合阿里云短信服务服务。

  2. 登录过程中用户点击获取验证码后,会先根据手机号码判断Redis中是否验证码:

    • 如果有,直接判断前端输入的验证码与Redis中的验证码是否一致,一致就允许登录;
    • 如果没有,则需要随机生成1个6位数的验证码,然后采用阿里云的短信发送接口,将验证码发送给手机,并以手机号码key验证码,将数据写入到Redis,并给短信验证码设置过期时间
  3. 然后进行登录认证,将前端获取到的手机号和验证码,与手机收到的验证码进行比较,如果一致则登录成功,否则提示验证错误。

  4. 最后判断当前用户是否为第一次登录,如果是则将该用户信息加入数据库,完成注册,返回登录信息,并将用户的登录信息存入Token,便于登录用户的数据显示以及下次可直接登录。

11.3 介绍一下Token和JWT

Token:

用户在完成登录后可以生成一个Token字符串,包含一些用户信息(如用户名),当用户在进行一些请求操作时,请求头中会携带Token,系统可以通过Token判断用户的登录状态,即查看请求头中是否包含Token,以及Token是不是按规则生成的,满足条件后才让用户进行后续的操作,起到一个鉴别防伪的功能。

JWT:

JWT(Json Web Token)是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准。

JWT的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源,比如用户登录。JWT最重要的作用就是对Token信息的防伪作用

JWT生成Token的原理:JWT = 公共部分(头信息,加密算法) + 私有部分(实际封装的信息,用户信息)+ 签名部分(哈希,当前服务器IP)。最后由这三者组合进行base64编码得到JWT。

通过签名算法以及服务器端生成的秘钥对base64编码后的公共部分和私有部分进行签名。如果有人用公共部分和私有部分的内容解码之后再生成新的JWT,由于不知道服务器端秘钥,因此服务器端进行JWT校验失败,就可以判断此JWT已经被篡改。

11.4 单点登录了解吗?

随着系统微服务化以及应用的形态和设备类型增多,不能用传统的登录方式,核心的技术不是用户名和密码,而是Token,由授权服务器颁发Token,用户使用Token进行登录。

例如在百度旗下的百度贴吧登录后,则百度旗下百度音乐就不需要再登录,所以叫单点登录。

简单来说:所谓单点登录说的就是用户登录多个子系统中的其中一个,就有权访问与其相关的其他系统

11.5 单点登录有哪些实现方式呢?你这个项目里是怎么实现的?

单点登录我所了解的有三种方式:

  1. 使用Session广播机制实现

    当在一个模块中进行登录后,就用session信息保存用户数据,并在多个模块中进行session复制,比如session.setAtrribute("user",user);

  2. 使用Cookie+Redis实现

    在项目中任何一个模块进行登录,登录后,把数据放到两个地方:

    • Redis:使用唯一随机值(比如用户id等)生成key,并在value中存放用户数据。
    • cookie: 把Redis里面生成的key值,放到cookie里面。访问项目其他模块时,发送请求带着cookie发送,获取cookie值,再到Redis中进行查询,根据key能够查询到用户数据,就成功登录。
  3. 使用Token实现(也是本项目采用的方式)

    上面介绍了,通过JWT生成Token,其中包含有用户的信息,然后放在消息的请求头中,Token的方式比上面两种方式更安全。

    Token实现登录流程

    具体登录流程:

    1. 用户在浏览器上点击预约操作,请求会被发送到网关;
    2. 网关会拦截请求,验证用户的token是否有效;
    3. 如果无效token或token为空,返回给用户授权失败信息;
    4. 弹出用户登录窗口,让用户进行登录操作;
    5. 通过网关路由到用户微服务模块中的登录功能中;
    6. 进行正常用户登录操作;
    7. 登录成功后,通过JWT生成token并返回;
    8. 客户端将token存到cookie中;
    9. 用户继续点击预约下单操作,这次携带了有效地token;
    10. 网关拦截请求,验证用户的token有效;
    11. 网关路由到订单微服务模块;
    12. 进行预约下单操作。

11.6 Token和Session的区别是什么?

相同点:

  1. 都是用户身份验证的一种识别手段;
  2. 都可以设置过期时间。

不同点:

  1. Session是存放在服务器端的,可以保存在数据库、Redis中,采用的是空间换时间的策略来进行身份识别,如果Session没有进行持久化,一旦服务器关闭重启,Session的数据就会丢失。
  2. Token是存放在客户端的,比如cookie、localstorage中,采用了时间换空间的策略,通过不同元素的加密,会更安全。而且最重要的是Token比Session更适合分布式环境。

11.7 为什么Token比Session更适合分布式环境?

先说说Session的创建过程吧。

  1. 因为Session是存储在服务器端的,所以当浏览器第一次请求Web服务器,服务器会产生一个Session存放在服务器里(可持久化到数据库中);
  2. 然后通过响应头的方式将SessionID返回给浏览器写入到Cookie中,浏览器下次请求就会将SessiondID以Cookie形式传递给服务器端;
  3. 服务器端获取SessionID后再去寻找对应的Session;
  4. 如果找到了则代表用户不是第一次访问,也就记住了用户。

但是如果服务器做了负载均衡或者在分布式系统中有多台服务器,用户的下一次请求有可能会被定向到其他的服务器节点中,如果那台服务器上没有用户的Session信息,就会导致验证失败,所以Session在默认机制下不适合分布式环境。而Token是存在客户端的,就不会有这种问题,不论是哪个服务器节点,拿到Token后都是解析其中的用户信息,然后根据用户信息再去验证是否成功。

11.8 分布式系统的CAP原理知道吗?

CAP原理:指的是在一个分布式系统中,Consistency(一致性)、 Availability(可用性)、Partition tolerance(分区容错性),三者不可同时获得(即最多只可得其二)。

  • 一致性(C):所有节点在同一时间的数据完全一致。

  • 可用性(A):服务一直可用,每个请求都能接收到一个响应,无论响应成功或失败。

  • 分区容错性(P):分布式系统在遇到某节点或网络分区障碍的时候,仍然能够对外提供满足一致性和可用性的服务。

CA满足的情况下,P不能满足的原因:数据同步(C)需要时间,还需要在正常时间内响应(A),那么节点数量就要少,所以P就不满足。例如超市收银系统、图书管理系统。

CP满足的情况下,A不能满足的原因:数据同步(C)需要时间,节点数量也要求多(P),但是通常性能不是特别高,所以A不满足。例如火车售票系统。

AP满足的情况下,C不能满足的原因:要求正常的时间内响应(A),节点数量也要多(P),那么数据就不能及时同步到其他节点,所以C不满足。例如博客系统,保证数据的最终一致性。NoSQL大多是典型的AP类型数据库。

11.8.1 Redis的主从模式是什么思想呢?

Redis的主从模式实现的是AP思想,也即保障可用性和分区容错性,允许延迟一致性。

11.9 说一说项目里微信登录流程

微信登录流程
  1. 第一步:在页面点击微信登录后,会请求一个Controller方法,这个Controller方法用于设置生成二维码所需的参数并返回,前端获取到这些参数后就可以在页面显示生成的二维码。
  2. 第二步:用户扫描登录二维码并确认授权后,微信后台会重定向到第三方应用(本项目),并带上授权临时票据code (相当于设置了过期时间的验证码);
  3. 第三步:通过code和设置的appid、appsecret,请求微信提供的固定地址(使用到了HttpClient工具),换取access_token和openid(授权用户唯一标识);
  4. 第四步:通过access_token和openid,请求微信提供的固定地址(使用到了HttpClient工具),获取扫码人(用户)的基本信息;
  5. 第五步:将获取到的微信用户基本信息添加到数据库。(添加之前根据openid判断数据库是否存在扫描人的微信信息)
  6. 第六步:返回用户名、Token(由用户id和用户名生成)和openid。
  7. 第七步:重定向到前端页面,后台根据请求中的openid参数进行判断,是否第一次登录,如果是则需要绑定手机号。

12、订单模块的问题

12.1 预约的时候如果并发量较大出现超抢问题怎么办?

我们的项目里用的是Redis + RabbitMQ来解决高并发问题下的超抢问题,Redis主要用来减库存,而MQ用来实现异步下单:

  1. 当用户集中下单时,在Redis中通过对库存值加分布式锁,减少预约数量,保证高并发情况下线程安全问题,同时通过Redis也能减少数据库所直接承受的访问数量,避免数据库直接宕机。但是Redis的负载也不是无限的,所以加了一个标记变量专门记录是否还可以继续预约,如果此时可预约数为0,就直接返回预约失败,不让后续请求继续访问Redis;
  2. Redis中预约数量减少后,将消息推送到RabbitMQ中,然后由监听消息队列的消费者,根据接受到的消息,将消息队列中的订单写入数据库,实现异步下单操作,同步到数据库中;
  3. 在项目中我们还额外增加了两个限制:
    1. 对于同一个账号发送多次请求的情况,我们在预约挂号时,做了限制,一个账号每天只能预约两次,分别是上午和下午,将预约日期+时间段作为key,用户ID作为value,以ZSet数据结构形式存入Redis中,对其预约的次数做了限制,做到一人一天两单,这样可以避免同一个账号采用软件发送大量请求的情况;
    2. 对于大量账号发送请求的情况,我们也做了限制,引入了实名认证的功能,只有上传身份证的用户才能进行预约挂号操作,这样可以避免大量注册的小号预约挂号。

12.2 说说Redis的分布式锁怎么实现的?

主要利用了Redis分布式锁中SETNX命令,作用是如果key不存在,就set进去。

  • 获取锁命令:SET lock value NX EX 10
  • 释放锁命令:DEL key;

12.2.1 Redis实现分布式锁如何合理的控制有效时长?

  1. 根据业务执行时间进行预估,但是这个时间不太好控制;(不推荐)
  2. 给锁续时长,这个就可以用Redisson来实现。

12.2.2 Redisson实现分布式锁的执行流程

Redisson实现分布式锁流程
Redisson实现分布式锁流程
  1. 线程1尝试加锁,加锁成功的话可以直接操作Redis,同时会设置一个看门狗,每隔(releaseTime / 3)的时间对锁做一次续期操作,release默认是30s;
  2. 线程1手动释放锁时,也会通知看门狗,让它不用再做续期操作了;
  3. 当线程2尝试加锁时,如果加锁成功的话,就与线程1操作一样,如果加锁失败,就会循环判断(有个阈值,当循环次数超过阈值时,就会加锁失败),不断尝试获取到锁。

12.2.3 如果线程释放锁操作异常,没能释放锁该怎么办?

如果线程1释放锁异常,就会导致释放锁失败,看门狗仍然会一直对锁进行续期操作,其他线程也获取不到锁。

但是在Redisson中获取锁的方式是tryLock()而不是我们常规意义上的Lock(),在tryLock(long waitTime, long leaseTime, TimeUnit unit)方法中有三个参数,分别是最大等待时间、自动释放时间、时间单位。

  • 如果在方法内没有传入释放时间,Redisson会自动添加一个定时任务,定时刷新失效时间,如果释放锁失败,就会自动释放锁;
  • 如果在方法内传入了释放时间,就不会添加定时任务,到期时看门狗就不会续期,而是到期就自动释放锁;

12.2.4 Redisson实现的分布式锁是可重入的吗?为什么?

Redis实现的分布式锁是不可重入的,但是Redisson实现的分布式锁是可重入的。

与Java多线程中的Reentrantlock实现原理差不多,都是通过线程ID判断是否是当前线程持有锁,并且记录可重入次数;

Redis中利用Hash结构记录线程id重入次数

可重入锁原理
可重入锁原理

12.2.4 Redisson实现的分布式锁可以实现主从一致吗?

Redisson中提供了RedLock(红锁),可以解决集群情况下分布式锁的可靠性。

红锁的基本思路是让客户端和多个独立的 Redis 节点依次请求申请加锁,如果客户端能够和半数以上的节点成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁,否则加锁失败

这样一来,即使有某个 Redis 节点发生故障,因为锁的数据在其他节点上也有保存,所以客户端仍然可以正常地进行锁操作,锁的数据也不会丢失。

加锁成功需要同时满足两个条件:

  • 客户端从超过半数的Redis节点上成功获取到了锁;
  • 客户端从大多数节点获取锁的总耗时 < 锁设置的过期时间

但是不建议使用红锁,因为实现复杂、性能差、运维繁琐

12.3 还有其他方法解决超抢问题吗?说一说?

还可以对数据库加锁,比如悲观锁和乐观锁:

  1. 悲观锁,就是在修改预约数量的时候,采用悲观锁,锁住下单请求方法,是的查询操作和更新操作组装为一个原子操作。但是这种方式的缺点就是,当并发请求数量太多的情况下,大量没有持有锁的线程都会进入到阻塞状态,导致请求的响应时间场,严重影响用户体验。
  2. 乐观锁,由于乐观锁的思想是认为在操作数据的时候不会被其他数据干扰,所以不会对数据加锁。在更新预约数量时,通过CAS机制来判断数据是否被其他线程更改过,如果没有更改,那么就执行-1操作;如果其他线程更改过,则执行策略,比如报错、重试等。

悲观锁的用户体验不好,用户需要等待时间过长,乐观锁只适合竞争不激烈的情况,并不适用于高并发情况。

12.3 怎么通过RabbitMQ的延迟队列解决超时订单自动取消?

解决这个问题的方法很多,可以通过Redis中的ZSet、Spring Task来解决,但是这里我们用了RabbitMQ中的延迟队列来解决这个问题。

延迟队列是通过消息队列中的TTL(消息存活时间)DLX(死信交换机)这两个属性间接实现。

我们的将订单消息是发送到消费队列中,并会设置消息的存活时间为15分钟,当15分钟到了之后,如果消息仍然没有被消费(就说明这个订单15分钟内仍然没有支付),所以订单消息就成了Dead Letter(死信)。就会通过死信交换机,将这个死信转发到延迟队列中,然后后台监听处理这个延迟队列中的消息,再实现更新订单的支付状态为未支付即可。这种方法需要两个队列,一个是主队列,也即消费队列,另一个是延迟队列,用来处理延迟消息的。

12.3.1 你提到可以用ZSet,说说看具体怎么实现?

可以通过Redis的Zset来实现延迟队列,通过zadd score value的命令向内存中生产消息,并且利用设置好的时间戳作为score,然后将score从小到大排序,每次只获取ZSet中的第一条数据,也就是最早预约的订单号,这时候就有两种情况:

  1. 如果这个订单未超时,那么剩下的订单必然未超时;
  2. 如果这个订单超时了,那么就在ZSet中删除这个订单,并且在数据库中更新这个订单的支付状态。

12.4 RabbitMQ的结构了解吗?

  • 生产者:消息的发送方,需要注明发送方服务标识、队列名称、发送内容;
  • 交换机:接收发送方的消息,根据路由规则转发到对应的队列中;
  • 队列:用于暂时存储消息;
  • 消费者:定义消息处理器方法,在方法上添加@RabbitListener监听消息并执行方法。

生产者(发消息) ——> 交换机(消息转发) ——> 消息队列 ——> 消费者(监听并处理)。

12.5 说一说微信支付的流程

下面是完整的微信支付流程,但是其中绝大部分工作都由微信支付系统帮我们完成了。

微信支付流程

简单总结一下:

  1. 首先是用户在前端点击进行支付请求;
  2. 在我们的后台请求进行处理,下订单、生成订单号;
  3. 封装微信支付平台所需要的信息,将信息转换为XML格式;
  4. 将XML格式的信息从我们后台发送给微信支付平台的地址;
  5. 微信向我们后台返回XML格式的数据,将其转换为Map形式的数据,获取到其中的交易连接,将连接存入Redis中;
  6. 我们后台将交易链接发给前端,前端使用js将其转换成二维码图片,然后将二维码图片展示给用户;
  7. 用户使用微信扫一扫进行支付(这一部分就是用户与微信交互了,跟后台没有什么关系);
  8. 用户支付成功后,微信平台会异步通知后台订单支付结果;
  9. 后台收到支付结果后,必须向微信回复接收情况,通知微信我们已经收到了支付的消息了;
  10. 同时前端界面也会定时(每3秒)调用查询支付状态的方法,及时更新展示支付信息。

12.6 怎么处理支付的状态的呢?

后端写一个查询当前支付状态的方法,前端每3秒调用一次该方法去微信支付平台查询。

如果返回的结果集为空,说明支付出错;如果返回的结果不为空,就可以根据返回的结果判断支付的状态。

当返回的状态为支付成功时,就可以去订单库中更新订单的支付状态。

12.7 说一说项目里面退款的流程吧

  1. 首先要从数据库里获取到需要退款的这个订单号;
  2. 查询订单是否存在,以及查询订单的状态是否已经退过款了;
  3. 如果前面判断都没问题,确实需要退款,设置需要的参数,封装appid、商户号、退款金额等信息,调用微信接口,将封装好的信息发送给微信支付平台,同时需要设置退款证书;
  4. 接收微信支付平台返回的数据,同样将XML格式的数据转为Map,判断返回结果是否为不空且状态为成功,如果是则修改订单的支付状态为已退款。

13、定时就医短信提醒怎么实现的?

  1. 创建定时任务微服务模块,通过Spring Task来实现定时任务的功能、通过RabbitMQ来实现短信发送功能,创建定时任务类和任务队列。
  2. 定时任务类中需要用到cron表达式,通过cron表达式就可以实现定时功能,在指定时间将消息发送到任务队列中。
  3. 在订单微服务模块中创建监听类,监听任务队列,然后去订单库中查找当天有预约的就诊人,在当天八点给他们发送就医短信提醒。

14、项目里的Redis是怎么使用的?用来干什么?

  1. 用户登录时存取手机短信验证码,并设置验证码过期时间;
  2. 用户进行微信支付时,存取生成的支付二维码的返回结果,并设置二维码的有效时间;
  3. 解决高并发环境下,生成预减库,在Redis中实现预约数量减少的功能;
  4. 解决高并发环境下,一人一天两单限制的问题,将预约时间+时间段(上午或下午)作为key,用户ID作为value存入Redis中ZSet数据结构中,因为ZSet数据结构能避免解决重复预约并且能够自动排序的问题。

14.1 怎么使用Redis的呢?

  1. 在Redis配置类RedisConfig上添加@EnableCaching注解开启缓存;
  2. 注入RedisTemplate,调用其中的opsForValue方法存取数据,比如redisTemplate.opsForValue().set(phone, code, 2, TimeUnit.MINUTES);

14.2 为什么用Redis而不用其他的?比如Memcached来做缓存?

  1. Memcached 仅支持简单的 key-value 结构的数据类型,Redis 支持五种基本数据类型;
  2. 当物理内存用完时,Redis 可以将一些很久没用到的 value 交换到磁盘,而Mencached不行。;
  3. Redis 支持内存数据的持久化的,而且提供两种主要的持久化策略:RDB 快照和 AOF 日志。而 memcached 是不支持数据持久化操作的;
  4. Memcached 本身并不支持分布式,因此只能在客户端实现分布式存储,Redis 更偏向于在服务器端构建集群进行分布式存储。

14.3 你提到用Redis中ZSet来解决一人一天两单问题,具体怎么设置的呢?

在我们项目中,每个用户可以预约接下来七天的日期进行就医,为了解决用户重复预约同一个天同一时间段订单的问题,我们将预约时间+时间段作为score,用户ID作为value存入Zset集合中。

比如用户ID是userId,当天时间为20230820,时间段是定义上午为1,下午为2。

  • ZADD order 202308201 userId

设置过期时间为7天,然后使用ZRANGE命令将ZSet中的成员按照score从小到大排序。

每次查询时,以当前时间为准,去除当前时间之前的数据,这样就能保证当前存储的数据都是7天内用户预约的数据,通过查询ZSet就能很快地判断,该用户有没有重复预约的问题。

15、项目中有哪些角色?系统的数据库又是怎么设计的?

项目为预约前台和管理后台两个部分,所以也对应了两种角色:用户和管理人员。

至于数据库的设计,我们采用的是每一个微服务对应一个独立的数据库,比如订单服务模块,对应的就是订单数据库,其中包含有订单信息表、支付信息表、退款信息表三种,分别存放的是所有订单、已支付的订单和需要退款的订单。至于其中的字段则是根据具体的业务需求来设计的。

16、分布式数据库如何生成唯一且递增的ID?

16.1 UUID(不符合要求,无法生成递增ID)

核心思想:机器的网卡+当地时间+随机数,生成一个32位的英文和数字 + 4位连字号,但是一般都会将连字号去掉。

优点:

  • 简单,本地生成且无网络消耗,具有唯一性;

缺点:

  • 无序的字符串,不具备趋势的自增特性;
  • 没有具体的业务含义;
  • 无序性的UUID作为主键会导致数据位置频繁变动,严重影响性能。

16.2 主从模式

核心思想:由主节点负责生成唯一且递增的ID,然后分配给各个从节点使用。

优点:

  • 简单,本地生成;

缺点:

  • 不利于后续扩容,本质还是由一个数据库生成,无法满足高并发的场景;

16.3 雪花算法

核心思想:采用bigint(64位)作为id生成类型,并且将64位分为四段。

雪花算法
雪花算法
  • 第一段:1位,表示符号位,正数是0,负数是1,但是由于id一般都是正数,所以一般都为0不变;
  • 第二段:41位,表示时间戳,注意这里并不是当前时间戳,而是时间戳差值(当前时间戳 - 开始时间戳),其中开始时间戳由我们自己定义;
  • 第三段:10位,表示工作机器的id,因为是分布式场景,所以可以允许最多布置1024个节点的分布式情况;
  • 第四段:12位,表示毫秒内的计数,支持每个节点每毫秒,也即同一机器、同一时间产生的4096个id序列号;

优点:

  • 整体上按照时间递增的顺序生成,后续插入索引的时候性能较好;
  • 整个分布式系统内不会产生ID碰撞,因为最多支持1024个分布式节点;
  • 本地生成,且不依赖于数据库,没有网络消耗,效率很高,每秒能生成26万个ID左右;

缺点:

  • 由于雪花算法是高度依赖于时间的,所以在分布式环境下,如果发生时钟回拨,可能引起ID重复、ID乱序、服务不可用等情况。

解决方案:

  • 将ID生成交给少量服务器,并关闭时钟同步;
  • 直接抛出异常,交给业务层处理;
  • 如果回拨时间很短,可以等待回拨时长之后再生成;
  • 如果回拨时间很长,可以匀出1-2位作为回拨位,一旦时钟回拨,就将回拨位+1,就可以得到不一样的ID,如果还是超出,那还是需要抛出异常。

17、项目中线程池问题

17.1 项目中有用到线程池吗?讲一讲怎么做的

我们这个项目因为用到了RabbitMQ来解决定时任务模块中就医提醒的短信发送订单模块中的超时订单自动取消的功能,处理方法都是将短信或者订单信息发送到消息队列中,然后后台对专门的队列进行监听,并处理信息。

以解决超时订单功能举例,刚开始延迟队列里消息不多的时候,其实一切都还挺正常的,但是一旦超时订单多了之后,发现使用消息队列发送信息的时候,一个消息队列的监听只能处理完一次发送之后,才能获取第二个消息内容,这样速度就会明显慢很多。所以我就想着能不能用线程池来批量消费RabbitMQ里的任务。

因为我们是在Spring框架中开发的项目,所以直接使用ThreadPoolTaskExecutor(ThreadPoolTaskExecutor其实是对ThreadPoolExecutor的一种封装)创建线程池,并把它注入到IoC容器中,全局都可以使用。

  • 首先配置线程池的参数,包括核心线程数:4,最大线程数:16,救急线程存活时间为:120s,阻塞队列用的是有界队列,长度为80;
  • 然后创建TreadPoolConfig类,通过@configuration注解将其注入到IoC容器中;
  • 最后在订单模块中用线程池处理延迟队列里的超时订单信息,可以极大地提升消费者处理消息的速度。

17.2 如何避免消息被重复消费呢?

有三种思路:

  1. 如果数据是要写到数据库里的,那么可以在写数据之前用主键查一下数据库,如果数据库已经存在,就不插入,而是update;
  2. 如果数据是要写到Redis里面,那更简单了,直接用SETNX,保证原子性;
  3. 还有一种方法就是生产者发送每条数据时,给每个消息生成一个全局唯一ID,根据这个ID就可以保证消息不会被重复消费。

17.3 如何保证消息队列里的消息不丢失呢?

  1. 开启生产者确认机制,确保生产者的消息能到达队列,如果报错可以先记录到日志中,再去修复数据。
  2. 开启持久化功能,确保消息未消费前,在队列中不会丢失,其中的交换机、队列和消息都要做持久化。
  3. 开启消费者确认机制为auto,由spring确认消息处理成功后完成ack,也要设置一定的重试次数(一般是3次),如果重试之后仍然没有收到消息,就将失败后的消息投递到异常交换机。

18、项目中事务的问题

18.1 项目里有没有用事务?怎么用的?

  1. 方法上加入@Transactional注解后,这个方法就成为了事务方法,如果对于注解的一些属性不做特殊配置的话,方法中如果出现了RuntimeException(运行时异常),事务会进行回滚,如果出现了checkedException(编译时异常),则不会回滚。那么对于这类不会回滚的异常,我们的做法是手动配置@Transactional(rollbackFor = Exception.class),这样也可以回滚了。
  2. 如果我们在事务方法中,手动捕获了异常,并没有让事务抛出去,也没有手动指定需要回滚,那么事务方法即使出现异常,也会提交事务。
  3. 对于异常回滚的处理,我们是定义了一个全局异常处理类,把catch到的异常按照定义的格式进行抛出。

19、项目里设计模式问题

19.1 项目里用到了什么设计模式?

  1. 策略模式:因为该项目用了两种登录方式,每一种用户登录方式都有自己的处理方式,所以用户登录功能设计了策略模式,可根据用户选择的不同登录方式灵活切换。后续如果要加入QQ登录、邮箱登录等方式也很方便扩展功能;
  2. 可以往Spring涉及到的设计模式上说,比如模板方法,用到了各种xxxTemplate,其实就是模板方法,Spring AOP就用带了代理模式。。。等等;

20、项目测试相关

20.1 项目有没有实际测试过?

在晚上订单模块之后,对预约挂号接口进行了并发测试,模拟了1000个用户同时下单,但是发现此时的系统平均响应时间已经达到了解决500ms,远远超出了正常的响应时间。

所以我们分析了导致响应时间过长的原因,是因为并发量过大,导致消息队列存消息过多,于是我们就开启了线程池对队列里的消息进行消费。

二、校园跑腿系统

校园跑腿系统
校园跑腿系统

1、为什么要用ThreadLocal保存用户信息?不用行不行?

因为如果每次都要解析token然后一层层传递会导致代码过于耦合,所以可以将从token中读取到的用户信息保存在线程中,当请求结束后把保存的信息清除。

这样可以方便在开发时直接从全局的ThreadLocal中很方便的获取用户信息。

创建拦截器,从请求头中获取token,然后解析token获得用户id,再保存到ThreadLocal中。

2、讲讲项目里的WebSocket

2.1 什么是WebSocket?

WebSocket是一种在单个 TCP 连接上进行全双工通信的协议,它允许客户端服务器之间进行实时数据交换

与传统的HTTP请求相比,WebSocket具有更低的延迟更高的并发性,适用于实时通信场景,如即时聊天、弹幕、实时游戏、实时数据更新等。

2.2 WebSocket是怎么和客户端建立连接的?

  1. 客户端发起WebSocket连接:客户端通过在浏览器中创建WebSocket对象,并使用WebSocket构造函数传入服务器的WebSocket地址发起连接;
  2. 服务器接收WebSocket连接请求:服务器端接收到客户端的连接请求后,会生成一个WebSocket实例,并保持与客户端的连接;
  3. WebSocket握手:在连接建立时,客户端与服务器会进行WebSocket握手,以确保双方都支持WebSocket协议,并建立双向通信;
  4. 双向通信:一旦握手成功,客户端和服务器就可以建立双向的通信,可以通过WebSocket对象的send()方法发送消息,通过onmessage事件接收消息;
  5. 维持链接:WebSocket连接一旦建立,客户端与服务端的通信通道就会一直保持打开状态,直到其中一方主动关闭连接或异常情况。

总结:WebSocket连接的建立过程是通过WebSocket对象的构造函数发起连接请求,服务器接受连接请求后进行握手,握手成功后建立双向通信通道,保持连接状态,实现实时双向通信的功能。

2.3 WebSocket断线之后有什么措施呢?

  1. 重新连接:断开连接后,客户端会尝试重新连接;
  2. 错误处理:在连接断开后,可以监听onerror事件,并在事件处理程序中进行错误处理,比如输出错误日志;
  3. 显示断开提醒:可以直接在界面上提醒用户连接已断开,并提供相应的操作,比如重新连接;
  4. 自动重连:可以在连接断开时自动触发重连机制,不用用户手动操作重连。

2.4 WebSocket和常用的HTTP有什么区别呢?

image-20230806111710156

相同点:

  1. 都是基于TCP连接的,都是可靠的传输协议;
  2. 都是应用层的协议;

不同点:

  1. HTTP是短连接,而WebSocket是长连接
  2. HTTP通信是单向的,基于请求响应模式,而WebSocket是双向通信协议,双方都可以发送和接收消息;
  3. HTTP是浏览器发起向服务器的连接,而WebSocket是浏览器和服务器握手建立的双相连接。

2.5 项目里具体是怎么实现催单提醒的呢?

  1. 首先与其他的服务一样,需要在nginx.conf文件中配置webSocket的地址,让Nginx帮我们把webSocket的请求转发到localhost:8080上;
  2. 当用户点击催单按钮时,首先通过Controller拿到前端传过来的订单id,调用orderService中的催单方法;
  3. 先根据订单id查询数据库,判断是否存在该订单;
  4. 然后再封装订单的id和具体的提示内容;
  5. 再直接调用webSocketServer中创建的send()方法,以JSON格式向后台系统的浏览器客户端发送催单消息。

2.6 WebSocket和Socket有什么区别?

  1. Socket是传输控制层协议,WebSocket是应用层协议。Socket是应用层与TCP/IP协议族通信的中间软件抽象层,它是一组接口(不是协议,为了方便使用TCP或UDP而抽象出来的一层,是位于应用层和传输控制层之间的一组接口)。在设计模式中,Socket其实就是一个门面模式,它把复杂的TCP/IP协议族隐藏在Socket接口后面。利用TCP/IP协议建立TCP连接。(TCP连接则更依靠于底层的IP协议,IP协议的连接则依赖于链路层等更低层次。)WebSocket则是一个典型的应用层协议。
  2. WebSocket 更易用,而 Socket 更灵活。
  3. Socket 是传输控制层的接口。用户可以通过 Socket 来操作底层 TCP/IP 协议族通信。WebSocket 是一种在单个 TCP 连接上进行全双工通信的协议。WebSocket 使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。

本站由 Cccccpg 使用 Stellar 主题创建。
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。