微服务技术栈学习笔记

学习时间:2021年12月8日

学习内容:SpringCloud,RabbitMQ,Docker,Redis,搜索,分布式

视频来源:SpringCloud+RabbitMQ+Docker+Redis+搜索+分布式,史上最全面的springcloud微服务技术栈课程|黑马程序员Java微服务

1 认识微服务

随着互联网行业的发展,对服务的要求也越来越高,服务架构也从单体架构逐渐演变为现在流行的微服务架构。这些架构之间有怎样的差别呢?

1.1 单体架构

单体架构:将业务的所有功能集中在一个项目中开发,打成一个包部署。

image-20211208200232834

优点:

  • 架构简单
  • 部署成本低

缺点:

  • 耦合度高(维护困难、升级困难)

1.2 分布式架构

分布式架构:根据业务功能对系统做拆分,每个业务功能模块作为独立项目开发,称为一个服务。

image-20211208200308866

优点:

  • 降低服务耦合
  • 有利于服务升级和拓展

缺点:

  • 服务调用关系错综复杂

分布式架构虽然降低了服务耦合,但是服务拆分时也有很多问题需要思考:

  • 服务拆分的粒度如何界定?
  • 服务之间如何调用?
  • 服务的调用关系如何管理?

人们需要制定一套行之有效的标准来约束分布式架构。

1.3 微服务

1.3.1 服务架构演变

微服务是一种经过良好架构设计的分布式架构方案

微服务的架构特征:

  • 单一职责:微服务拆分粒度更小,每一个服务都对应唯一的业务能力,做到单一职责
  • 自治:团队独立、技术独立、数据独立,独立部署和交付
  • 面向服务:服务提供统一标准的接口,与语言和技术无关
  • 隔离性强:服务调用做好隔离、容错、降级,避免出现级联问题

微服务架构图

image-20211208200629047

微服务部署(持续集成)

image-20211208200653658

微服务图解

image-20211208200351675

微服务的上述特性其实是在给分布式架构制定一个标准,进一步降低服务之间的耦合度,提供服务的独立性和灵活性。做到高内聚,低耦合

因此,可以认为微服务是一种经过良好架构设计的分布式架构方案

但方案该怎么落地?选用什么样的技术栈?全球的互联网公司都在积极尝试自己的微服务落地方案。

其中在Java领域最引人注目的就是SpringCloud提供的方案了。

微服务技术栈

image-20211208200927068

1.3.2 微服务框架对比

微服务这种方案需要技术框架来落地,全球的互联网公司都在积极尝试自己的微服务落地技术。在国内最知名的就是SpringCloud和阿里巴巴的Dubbo。

image-20211208202741866

技术对比

Dubbo SpringCloud SpringCloudAlibaba
注册中心 zookeeper、Redis Eureka、Consul Nacos、Eureka
服务远程调用 Dubbo协议 Feign(http协议) Dubbo、Feign
配置中心 SpringCloudConfig SpringCloudConfig、Nacos
服务网关 SpringCloudGateway、Zuul SpringCloudGateway、Zuul
服务监控和保护 dubbo-admin,功能弱 Hystix Sentinel

企业需求

image-20211208202805470

1.4 SpringCloud

  • SpringCloud是目前国内使用最广泛的微服务框架。官网地址:https://spring.io/projects/spring-cloud。
  • SpringCloud集成了各种微服务功能组件,并基于SpringBoot实现了这些组件的自动装配,从而提供了良好的开箱即用体验:

image-20211208203027119

另外,SpringCloud底层是依赖于SpringBoot的,并且有版本的兼容关系。

image-20211208203104660

1.5 总结

  • 单体架构:简单方便,高度耦合,扩展性差,适合小型项目。例如:学生管理系统

  • 分布式架构:松耦合,扩展性好,但架构复杂,难度大。适合大型互联网项目,例如:京东、淘宝

  • 微服务:一种良好的分布式架构方案

    ①优点:拆分粒度更小、服务更独立、耦合度更低

    ②缺点:架构非常复杂,运维、监控、部署难度提高

  • SpringCloud是微服务架构的一站式解决方案,集成了各种优秀微服务功能组件

2 服务拆分和远程调用

任何分布式架构都离不开服务的拆分,微服务也是一样。

2.1 服务拆分原则

  • 不同微服务,不要重复开发相同业务
  • 微服务数据独立,不要访问其它微服务的数据库
  • 微服务可以将自己的业务暴露为接口,供其它微服务调用

image-20211209160124652

2.2 服务拆分示例

2.2.1 项目结构

image-20211209163429202

cloud-demo:父工程,管理依赖

  • order-service:订单微服务,负责订单相关业务
  • user-service:用户微服务,负责用户相关业务

要求:

  • 订单微服务和用户微服务都必须有各自的数据库,相互独立
  • 订单服务和用户服务都对外暴露Restful的接口
  • 订单服务如果需要查询用户信息,只能调用用户服务的Restful接口,不能查询用户数据库

2.2.2 导入sql语句

首先,将课前资料提供的cloud-order.sqlcloud-user.sql导入到mysql中。

cloud-user表中初始数据如下:

image-20211209163505467

cloud-order表中初始数据如下:

image-20211209163515529

cloud-order表中持有cloud-user表中的id字段。

2.2.3 导入demo工程

项目结构

image-20211209163551026

image-20211209163802839

部分代码

OrderController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
@RestController
@RequestMapping("order")
public class OrderController {

@Autowired
private OrderService orderService;

@GetMapping("{orderId}")
public Order queryOrderByUserId(@PathVariable("orderId") Long orderId) {
// 根据id查询订单并返回
return orderService.queryOrderById(orderId);
}
}

UserController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {

@Autowired
private UserService userService;

/**
* 路径: /user/110
*
* @param id 用户id
* @return 用户
*/
@GetMapping("/{id}")
public User queryById(@PathVariable("id") Long id) {
return userService.queryById(id);
}
}

查询结果

image-20211209163832132

image-20211209163907249

2.3 实现远程调用案例

2.3.1 案例需求

修改order-service中的根据id查询订单业务,要求在查询订单的同时,根据订单中包含的userId查询出用户信息,一起返回。

image-20211209165409360

因此,我们需要在order-service中向user-service发起一个http的请求,调用http://localhost:8081/user/{userId}这个接口。

image-20211209165629386

步骤

  • 注册一个RestTemplate的实例到Spring容器
  • 修改order-service服务中的OrderService类中的queryOrderById方法,根据Order对象中的userId查询User
  • 将查询的User填充到Order对象,一起返回

2.3.2 注册RestTemplate并远程调用

首先,我们在order-service服务中的OrderApplication启动类中,注册RestTemplate实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@MapperScan("cn.itcast.order.mapper")
@SpringBootApplication
public class OrderApplication {

public static void main(String[] args) {
SpringApplication.run(OrderApplication.class, args);
}

/**
* 创建RestTemplate并注入Spring容器
*/
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}
}

其次,修改order-service服务中的cn.itcast.order.service包下的OrderService类中的queryOrderById方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Service
public class OrderService {

@Autowired
private OrderMapper orderMapper;

@Autowired
private RestTemplate restTemplate;

public Order queryOrderById(Long orderId) {
// 1.查询订单
Order order = orderMapper.findById(orderId);
// 2. 利用RestTemplate发起http请求
// 2.1 url路径
String url = "http://localhost:8081/user/" + order.getUserId();
// 2.2 发送http请求,实现远程调用
User user = restTemplate.getForObject(url, User.class);
// 3.封装user到Order
order.setUser(user);
// 4.返回
return order;
}
}

程序结果

image-20211209171114886

2.4 提供者与消费者

在服务调用关系中,会有两个不同的角色:

服务提供者:一次业务中,被其它微服务调用的服务。(提供接口给其它微服务)

服务消费者:一次业务中,调用其它微服务的服务。(调用其它微服务提供的接口)

image-20211209171207147

但是,服务提供者与服务消费者的角色并不是绝对的,而是相对于业务而言。

如果服务A调用了服务B,而服务B又调用了服务C,服务B的角色是什么?

  • 对于A调用B的业务而言:A是服务消费者,B是服务提供者
  • 对于B调用C的业务而言:B是服务消费者,C是服务提供者

因此,服务B既可以是服务提供者,也可以是服务消费者。

3 Eureka注册中心

问题提出

假如我们的服务提供者user-service部署了多个实例,如图:

image-20211210165952482

  • order-service在发起远程调用的时候,该如何得知user-service实例的ip地址和端口?
  • 有多个user-service实例地址,order-service调用时该如何选择?
  • order-service如何得知某个user-service实例是否依然健康,是不是已经宕机?

3.1 Eureka的结构和作用

这些问题都需要利用SpringCloud中的注册中心来解决,其中最广为人知的注册中心就是Eureka,其结构如下:

image-20211210170035185

问题解决

  • order-service如何得知user-service实例地址?

    • user-service服务实例启动后,将自己的信息注册到eureka-server(Eureka服务端)。这个叫服务注册
    • eureka-server保存服务名称到服务实例地址列表的映射关系
    • order-service根据服务名称,拉取实例地址列表。这个叫服务发现或服务拉取
  • order-service如何从多个user-service实例中选择具体的实例?

    • order-service从实例列表中利用负载均衡算法选中一个实例地址
    • 向该实例地址发起远程调用
  • order-service如何得知某个user-service实例是否依然健康,是不是已经宕机?

    • user-service会每隔一段时间(默认30秒)向eureka-server发起请求,报告自己状态,称为心跳
    • 当超过一定时间没有发送心跳时,eureka-server会认为微服务实例故障,将该实例从服务列表中剔除
    • order-service拉取服务时,就能将故障实例排除了

注意:一个微服务,既可以是服务提供者,又可以是服务消费者,因此eureka将服务注册、服务发现等功能统一封装到了eureka-client端。

实现步骤

image-20211210170221747

3.2 搭建eureka-server

  1. 创建eureka-server服务

首先注册中心服务端:eureka-server,这必须是一个独立的微服务。

cloud-demo父工程下,创建一个子模块eureka-server:

image-20211210171652654

  1. 引入eureka依赖

在子模块的pom中引入eureka的依赖,注意版本信息已经在父工程中定义,故在子模块中无需填写。

1
2
3
4
5
6
7
<dependencies>
<!--eureka服务端-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
</dependency>
</dependencies>

image-20211210171942258

上图是父工程的pom文件,其中红线部分是对springcloud各组件版本的定义。

  1. 编写启动类

给eureka-server服务编写一个启动类,一定要添加一个@EnableEurekaServer注解,开启eureka的注册中心功能:

1
2
3
4
5
6
7
@EnableEurekaServer
@SpringBootApplication
public class EurekaApplication {
public static void main(String[] args) {
SpringApplication.run(EurekaApplication.class, args);
}
}
  1. 编写配置文件

编写一个application.yml文件,内容如下:

1
2
3
4
5
6
7
8
9
server:
port: 10086 # 服务端口
spring:
application:
name: eurekaserver # eureka的服务名称
eureka:
client:
service-url: # eureka的地址信息
defaultZone: http://localhost:10086/eureka
  1. 启动服务

启动微服务,然后在浏览器访问:http://127.0.0.1:10086

image-20211210172219369

看到下面结果应该是成功了:

image-20211210172315683

3.3 服务注册

下面,我们将user-service和order-service注册到eureka-server中去。以user-service为例:

  1. 引入依赖

在user-service的pom文件中,引入下面的eureka-client依赖:

1
2
3
4
5
6
7
8
9
<!--eureka客户端依赖-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-netflix-eureka-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
  1. 配置文件

在user-service中,修改application.yml文件,添加服务名称、eureka地址:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
server:
port: 8081
spring:
datasource:
url: jdbc:mysql://localhost:3306/cloud_user?useSSL=false
username: root
password: 12345678
driver-class-name: com.mysql.jdbc.Driver
application: # 看这里
name: userservice # user-service服务的服务名称
mybatis:
type-aliases-package: cn.itcast.user.pojo
configuration:
map-underscore-to-camel-case: true
logging:
level:
cn.itcast: debug
pattern:
dateformat: MM-dd HH:mm:ss:SSS
eureka: # 看这里
client:
service-url: # eureka的地址信息
defaultZone: http://localhost:10086/eureka
  1. 启动多个user-service实例

为了演示一个服务有多个实例的场景,我们添加一个SpringBoot的启动配置,再启动一个user-service。

首先,复制原来的user-service启动配置:

image-20211214164851369

在弹出的窗口中填写名称和jvm参数:

image-20211214164959029

现在,SpringBoot窗口会出现两个user-service启动配置:

image-20211214165044225

不过,第一个是8081端口,第二个是8082端口。

查看eureka-server管理页面:

image-20211214165121775

3.4 服务发现

下面,我们将order-service的逻辑修改:向eureka-server拉取user-service的信息,实现服务发现(服务拉取)。即order-service要去eureka-server中拉取user-service服务的实例列表,并且实现负载均衡

不过这些动作不用我们去做,只需要添加一些注解即可。

  1. 在order-service的OrderApplication中,给RestTemplate这个Bean添加一个@LoadBalanced注解:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@MapperScan("cn.itcast.order.mapper")
@SpringBootApplication
public class OrderApplication {

public static void main(String[] args) {
SpringApplication.run(OrderApplication.class, args);
}

/**
* 创建RestTemplate并注入Spring容器
*/
@Bean
@LoadBalanced
public RestTemplate restTemplate() {
return new RestTemplate();
}
}
  1. 修改order-service服务中的cn.itcast.order.service包下的OrderService类中的queryOrderById方法。修改访问的url路径,用服务名代替ip、端口:

image-20211214165957702

spring会自动帮助我们从eureka-server端,根据userservice这个服务名称,获取实例列表,而后完成负载均衡。

验证

发起多次请求:localhost:8080/order/101

image-20211214170421127

image-20211214170430952

两个端口都有响应

总结

  1. 搭建EurekaServer:引入eureka-server依赖;添加@EnableEurekaServer注解;在application.yml中配置eureka地址
  2. 服务注册:引入eureka-client依赖;在application.yml中配置eureka地址
  3. 服务发现:引入eureka-client依赖;在application.yml中配置eureka地址;给RestTemplate添加@LoadBalanced注解;用服务提供者的服务名称远程调用

4 Ribbon负载均衡

4.1 负载均衡原理

4.1.1 问题的提出

SpringCloud底层其实是利用了一个名为Ribbon的组件,来实现负载均衡功能的。

image-20211214171925140

那么我们发出的请求明明是http://userservice/user/1,怎么变成了http://localhost:8081的呢?

4.1.2 源码跟踪

为什么我们只输入了service名称就可以访问了呢?之前还要获取ip和端口。显然有人帮我们根据service名称,获取到了服务实例的ip和端口。它就是LoadBalancerInterceptor,这个类会在对RestTemplate的请求进行拦截,然后从Eureka根据服务id获取服务列表,随后利用负载均衡算法得到真实的服务地址信息,替换服务id。

LoadBalancerInterceptor

image-20211214172206421

可以看到这里的intercept方法,拦截了用户的HttpRequest请求,然后做了几件事:

  • request.getURI():获取请求uri,本例中就是 http://user-service/user/8
  • originalUri.getHost():获取uri路径的主机名,其实就是服务id,user-service
  • this.loadBalancer.execute():处理服务id,和用户请求。

这里的this.loadBalancerLoadBalancerClient类型,我们继续跟入。

LoadBalancerClient

image-20211214172317917

  • getLoadBalancer(serviceId):根据服务id获取ILoadBalancer,而ILoadBalancer会拿着服务id去eureka中获取服务列表并保存起来。
  • getServer(loadBalancer):利用内置的负载均衡算法,从服务列表中选择一个。本例中,可以看到获取了8082端口的服务

放行后,再次访问并跟踪,发现获取的是8081:

image-20211214172403108

负载均衡策略IRule

在刚才的代码中,可以看到获取服务使通过一个getServer方法来做负载均衡,我们继续跟入:

image-20211214172458998

继续跟踪源码chooseServer方法,发现这么一段代码:

image-20211214172515431

我们看看这个rule是谁:

image-20211214172538710

这里的rule默认值是一个RoundRobinRule,看类的介绍:

image-20211214172552347

轮询算法。到这里,整个负载均衡的流程我们就清楚了。

4.1.3 原理总结

SpringCloudRibbon的底层采用了一个拦截器,拦截了RestTemplate发出的请求,对地址做了修改。用一幅图来总结一下:

image-20211214172708270

基本流程如下:

  • 拦截我们的RestTemplate请求http://userservice/user/1
  • RibbonLoadBalancerClient会从请求url中获取服务名称,也就是user-service
  • DynamicServerListLoadBalancer根据user-service到eureka拉取服务列表
  • eureka返回列表,localhost:8081、localhost:8082
  • IRule利用内置负载均衡规则,从列表中选择一个,例如localhost:8081
  • RibbonLoadBalancerClient修改请求地址,用localhost:8081替代userservice,得到http://localhost:8081/user/1,发起**真实请求**

4.2 负载均衡策略

负载均衡的规则都定义在IRule接口中,而IRule有很多不同的实现类。Ribbon的负载均衡规则是一个叫做IRule的接口来定义的,每一个子接口都是一种规则:

image-20211215165921653

内置负载均衡规则类 规则描述
RoundRobinRule 简单轮询服务列表来选择服务器。它是Ribbon默认的负载均衡规则。
AvailabilityFilteringRule 对以下两种服务器进行忽略: (1)在默认情况下,这台服务器如果3次连接失败,这台服务器就会被设置为“短路”状态。短路状态将持续30秒,如果再次连接失败,短路的持续时间就会几何级地增加。(2)并发数过高的服务器。如果一个服务器的并发连接数过高,配置了AvailabilityFilteringRule规则的客户端也会将其忽略。并发连接数的上限,可以由客户端的..ActiveConnectionsLimit属性进行配置。
WeightedResponseTimeRule 为每一个服务器赋予一个权重值。服务器响应时间越长,这个服务器的权重就越小。这个规则会随机选择服务器,这个权重值会影响服务器的选择。
ZoneAvoidanceRule 以区域可用的服务器为基础进行服务器的选择。使用Zone对服务器进行分类,这个Zone可以理解为一个机房、一个机架等。而后再对Zone内的多个服务做轮询。
BestAvailableRule 忽略那些短路的服务器,并选择并发数较低的服务器。
RandomRule 随机选择一个可用的服务器。
RetryRule 重试机制的选择逻辑

默认的实现就是ZoneAvoidanceRule,是一种轮询方案

自定义负载均衡策略

通过定义IRule实现可以修改负载均衡规则,有两种方式:

  1. 代码方式:在order-service中的OrderApplication类中,定义一个新的IRule:这种方式是全局性的,对调用任何服务时,都采用相同的负载均衡策略
1
2
3
4
@Bean
public IRule randomRule(){
return new RandomRule();
}
  1. 配置文件方式:在order-service的application.yml文件中,添加新的配置也可以修改规则:这种是针对性的。
1
2
3
userservice: # 给某个微服务配置负载均衡规则,这里是userservice服务
ribbon:
NFLoadBalancerRuleClassName: com.netflix.loadbalancer.RandomRule # 负载均衡规则

4.3 饥饿加载

Ribbon默认是采用懒加载,即第一次访问时才会去创建LoadBalanceClient,请求时间会很长。

而饥饿加载则会在项目启动时创建,降低第一次访问的耗时,通过下面配置开启饥饿加载:

1
2
3
4
ribbon:
eager-load:
enabled: true # 开启饥饿加载
clients: userservice # 指定对userservice这个服务饥饿加载

总结

  1. Ribbon负载均衡规则:
    • 规则接口是IRule
    • 默认实现是ZoneAvoidanceRule,根据zone选择服务列表,然后轮询
  2. 负载均衡自定义方式:
    • 代码方式:配置灵活,但修改时需要重新打包发布
    • 配置方式:直观,方便,无需重新打包发布,但是无法做全局配置
  3. 饥饿加载:
    • 开启饥饿加载
    • 指定饥饿加载的微服务名称

5 Nacos注册中心

国内公司一般都推崇阿里巴巴的技术,比如注册中心,SpringCloudAlibaba也推出了一个名为Nacos的注册中心。Nacos是阿里巴巴的产品,现在是SpringCloud中的一个组件。相比Eureka功能更加丰富,在国内受欢迎程度较高。

image-20211215171519540

5.1 安装及启动

启动命令:

1
startup.cmd -m standalone

其中,standalone意为单机启动模式。

image-20211215173244672

image-20211215173309464

5.2 服务注册

Nacos是SpringCloudAlibaba的组件,而SpringCloudAlibaba也遵循SpringCloud中定义的服务注册、服务发现规范。因此使用Nacos和使用Eureka对于微服务来说,并没有太大区别。

主要差异在于:

  • 依赖不同
  • 服务地址不同

5.3.1 引入依赖

在cloud-demo父工程的pom文件中的<dependencyManagement>中引入SpringCloudAlibaba的依赖:

1
2
3
4
5
6
7
8
<!--nacos依赖-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>2.2.6.RELEASE</version>
<type>pom</type>
<scope>import</scope>
</dependency>

然后在user-service和order-service中的pom文件中引入nacos-discovery依赖:

1
2
3
4
5
<!--nacos客户端依赖-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>

5.3.2 配置nacos地址

在user-service和order-service的application.yml中添加nacos地址:

1
2
3
cloud:
nacos:
server-addr: localhost:8848 # nacos服务地址

image-20211217100056029

重启微服务后,登录nacos管理页面,可以看到微服务信息:

image-20211217100523907

5.3 服务分级存储模型

5.3.1 概述

一个服务可以有多个实例,例如我们的user-service,可以有:

  • 127.0.0.1:8081
  • 127.0.0.1:8082
  • 127.0.0.1:8083

假如这些实例分布于全国各地的不同机房,例如:

  • 127.0.0.1:8081,在上海机房
  • 127.0.0.1:8082,在上海机房
  • 127.0.0.1:8083,在杭州机房

Nacos就将同一机房内的实例划分为一个集群

也就是说,user-service是服务,一个服务可以包含多个集群,如杭州、上海,每个集群下可以有多个实例,形成分级模型,如图:

image-20211217101424069

微服务互相访问时,应该尽可能访问同集群实例,因为本地访问速度更快。当本集群内不可用时,才访问其它集群。例如:

image-20211217101450989

杭州机房内的order-service应该优先访问同机房的user-service。

5.3.2 给user-service配置集群

修改user-service的application.yml文件,添加集群配置:

1
2
3
4
5
cloud:
nacos:
server-addr: localhost:8848 # nacos服务地址
discovery:
cluster-name: HZ # 集群名称,代指杭州

我们再次复制一个user-service启动配置,添加属性:

1
-Dserver.port=8083 -Dspring.cloud.nacos.discovery.cluster-name=SH

启动UserApplication3后再次查看nacos控制台:

image-20211217101720514

image-20211217101737033

点击详情:

image-20211217101839184

5.3.3 同集群优先的负载均衡

为使得同集群的微服务优先相互访问,需要配置同集群优先的负载均衡。默认的ZoneAvoidanceRule并不能实现根据同集群优先来实现负载均衡。因此Nacos中提供了一个NacosRule的实现,可以优先从同集群中挑选实例

  1. 给order-service配置集群信息:修改order-service的application.yml文件,添加集群配置。
1
2
3
4
5
6
spring:
cloud:
nacos:
server-addr: localhost:8848
discovery:
cluster-name: HZ # 集群名称
  1. 修改负载均衡规则:修改order-service的application.yml文件,修改负载均衡规则:
1
2
3
userservice: # 要做配置的微服务名称
ribbon:
NFLoadBalancerRuleClassName: com.alibaba.cloud.nacos.ribbon.NacosRule # 负载均衡规则

NacosRule负载均衡策略

  • 优先选择同集群服务实例列表

  • 本地集群找不到提供者,才去其它集群寻找,并且会报警告

  • 确定了可用实例列表后,再采用随机负载均衡挑选实例

5.3.4 根据权重的负载均衡

实际部署中会出现这样的场景:服务器设备性能有差异,部分实例所在机器性能较好,另一些较差,我们希望性能好的机器承担更多的用户请求。

Nacos提供了权重配置来控制访问频率,权重越大则访问频率越高。

image-20211217104001908

  • 点击编辑:

image-20211217104021443

  • 修改结果:

image-20211217104042169

实例的权重控制

  • Nacos控制台可以设置实例的权重值,0~1之间

  • 同集群内的多个实例,权重越高被访问的频率越高

  • 权重设置为0则完全不会被访问

5.4 环境隔离

image-20211217104952225

Nacos提供了namespace(命名空间)来实现环境隔离功能。

  • nacos中可以有多个namespace
  • namespace下可以有group、service等
  • 不同namespace之间相互隔离,例如不同namespace的服务互相不可见
  • 默认的命名空间为public

image-20211217104921917

5.4.1 创建namespace

  • 默认情况下,所有service、data、group都在同一个namespace,名为public,我们可以点击页面新增按钮,添加一个namespace:

image-20211217105103523

  • 然后,填写表单:

image-20211217105133643

  • 就能在页面看到一个新的namespace:

image-20211217105153066

5.4.2 给微服务配置namespace

  • 给微服务配置namespace只能通过修改配置来实现。例如,修改order-service的application.yml文件:
1
2
3
4
5
6
cloud:
nacos:
server-addr: localhost:8848 # nacos服务地址
discovery:
cluster-name: HZ # 集群名称
namespace: e472a3a3-cfa5-49f9-a5a0-8196ff8fffd7 # namespace的id
  • 重启order-service后,访问控制台,可以看到下面的结果:

image-20211217105349177

  • 此时访问order-service,因为namespace不同,会导致找不到userservice,控制台会报错:

image-20211217105404821

5.5 Nacos和Eureka的区别

5.5.1 服务实例

Nacos的服务实例分为两种类型:

  • 临时实例:如果实例宕机超过一定时间,会从服务列表剔除,默认的类型。

  • 非临时实例:如果实例宕机,不会从服务列表剔除,也可以叫永久实例。

配置一个服务实例为永久实例

1
2
3
4
5
6
7
cloud:
nacos:
server-addr: localhost:8848 # nacos服务地址
discovery:
cluster-name: HZ # 集群名称
namespace: e472a3a3-cfa5-49f9-a5a0-8196ff8fffd7 # namespace的id
ephemeral: false # 是否是临时实例,false为非临时实例

image-20211217171042427

5.5.2 二者区别

Nacos和Eureka整体结构类似,服务注册、服务拉取、心跳等待,但是也存在一些差异:

image-20211217171110006

  • Nacos与Eureka的共同点

    • 都支持服务注册和服务拉取
    • 都支持服务提供者心跳方式做健康检测
  • Nacos与Eureka的区别

    • Nacos支持服务端主动检测提供者状态:临时实例采用心跳模式,非临时实例采用主动检测模式
    • 临时实例心跳不正常会被剔除,非临时实例则不会被剔除
    • Nacos支持服务列表变更的消息推送模式,服务列表更新更及时
    • Nacos集群默认采用AP方式,当集群中存在非临时实例时,采用CP模式;Eureka采用AP方式

5.6 Nacos配置管理

5.6.1 统一配置管理

当微服务部署的实例越来越多,达到数十、数百时,逐个修改微服务配置就会让人抓狂,而且很容易出错。我们需要一种统一配置管理方案,可以集中管理所有实例的配置。

image-20211217172306692

Nacos一方面可以将配置集中管理,另一方可以在配置变更时,及时通知微服务,实现配置的热更新。

1) 在nacos中添加配置文件

如何在nacos中管理配置呢?

image-20211217172403248

然后在弹出的表单中,填写配置信息:

image-20211217172744598

注意:项目的核心配置,需要热更新的配置才有放到nacos管理的必要。基本不会变更的一些配置还是保存在微服务本地比较好。

应用结果

image-20211217172832204

2) 微服务拉取配置

微服务要拉取nacos中管理的配置,并且与本地的application.yml配置合并,才能完成项目启动。

但如果尚未读取application.yml,又如何得知nacos地址呢?

因此spring引入了一种新的配置文件:bootstrap.yaml文件,会在application.yml之前被读取,流程如下:

image-20211217181944428

  1. 引入nacos-config依赖

首先,在user-service服务中,引入nacos-config的客户端依赖:

1
2
3
4
5
<!--nacos配置管理依赖-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
  1. 添加bootstrap.yaml

然后,在user-service中添加一个bootstrap.yaml文件,内容如下:

1
2
3
4
5
6
7
8
9
10
spring:
application:
name: userservice # 服务名称
profiles:
active: dev #开发环境,这里是dev
cloud:
nacos:
server-addr: localhost:8848 # Nacos地址
config:
file-extension: yaml # 文件后缀名

image-20211217182113530

这里会根据spring.cloud.nacos.server-addr获取nacos地址,再根据

${spring.application.name}-${spring.profiles.active}.${spring.cloud.nacos.config.file-extension}作为文件id,来读取配置。

本例中,就是去读取userservice-dev.yaml

image-20211217182156259

  1. 读取nacos配置

在user-service中的UserController中添加业务逻辑,读取pattern.dateformat配置:

1
2
3
4
5
6
7
8
9
@Value("${pattern.dateformat}")
private String dateformat;

@GetMapping("/now")
public String now() {
// 完成日期格式化并返回
System.out.println(dateformat);
return LocalDateTime.now().format(DateTimeFormatter.ofPattern(dateformat));
}
  1. 在页面http://localhost:8081/user/now访问,可以看到效果:

image-20211217182251827

3) 总结
  • 在Nacos中添加配置文件
  • 在微服务中引入nacos的config依赖
  • 在微服务中添加bootstrap.yml,配置nacos地址、当前环境、服务名称、文件后缀名。这些决定了程序启动时去nacos读取哪个文件

5.6.2 配置热更新

我们最终的目的,是修改nacos中的配置后,微服务中无需重启即可让配置生效,也就是配置热更新

要实现配置热更新,可以使用两种方式:

方式1

在@Value注入的变量所在类上添加注解@RefreshScope:

image-20211218095735885

方式2

使用@ConfigurationProperties注解代替@Value注解。

  • 在user-service服务中,添加一个类,读取patterrn.dateformat属性:
1
2
3
4
5
6
@Data
@Component
@ConfigurationProperties(prefix = "pattern")
public class PatternProperties {
private String dateformat;
}

image-20211218095812773

注:我此处的程序报了一个错误:Spring Boot Configuration Annotation Processor not found in claspath

解决方法:把spring-boot-configuration-processor依赖加入到pom.xml文件中

1
2
3
4
5
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
  • 在UserController中使用这个类代替@Value:

image-20211218100039036

总结

  • Nacos配置更改后,微服务可以实现热更新,方式:

    • 通过@Value注解注入,结合@RefreshScope来刷新
    • 通过@ConfigurationProperties注入,自动刷新
  • 注意事项:

    • 不是所有的配置都适合放到配置中心,维护起来比较麻烦
    • 建议将一些关键参数,需要运行时调整的参数放到nacos配置中心,一般都是自定义配置

5.6.3 配置共享

其实微服务启动时,会去nacos读取多个配置文件,例如:

  • [spring.application.name]-[spring.profiles.active].yaml,例如:userservice-dev.yaml

  • [spring.application.name].yaml,例如:userservice.yaml

[spring.application.name].yaml不包含环境,因此可以被多个环境共享

1) 添加一个环境共享配置

我们在nacos中添加一个userservice.yaml文件:

image-20211218100506939

结果:

image-20211218101503772

2) 在user-service中读取共享配置
  • 在user-service服务中,修改PatternProperties类,读取新添加的属性:
1
2
3
4
5
6
7
@Data
@Component
@ConfigurationProperties(prefix = "pattern")
public class PatternProperties {
private String dateformat;
private String envSharedValue;
}
  • 在user-service服务中,修改UserController,添加一个方法:
1
2
3
4
5
6
@Autowired
private PatternProperties properties;
@GetMapping("/prop")
public PatternProperties properties() {
return properties;
}
3) 运行两个UserApplication,使用不同的profile

例如userservice运行dev环境不变,userservice1使用test(active)环境运行:

image-20211218101803141

这样,UserApplication(8081)使用的profile是dev,UserApplication2(8082)使用的profile是test。

启动UserApplication和UserApplication2,访问http://localhost:8081/user/prop,结果:

image-20211218101844425

访问http://localhost:8082/user/prop,结果:

image-20211218101907189

可以看出来,不管是dev,还是test环境,都读取到了envSharedValue这个属性的值。

4) 配置共享的优先级

当nacos、服务本地同时出现相同属性时,优先级有高低之分:

image-20211218101931620

即nacos的配置优先级大于本地配置的优先级。

5.7 搭建Nacos集群

集群搭建步骤

  1. 搭建MySQL集群并初始化数据库表

  2. 下载解压nacos

  3. 修改集群配置(节点信息)、数据库配置

  4. 分别启动多个nacos节点

  5. nginx反向代理

5.7.1 集群结构图

官方给出的Nacos集群图:

image-20211218103039559

其中包含3个nacos节点,然后一个负载均衡器代理3个Nacos。这里负载均衡器可以使用nginx。

我们计划的集群结构:

image-20211218103105134

三个nacos节点的地址:

节点 ip port
nacos1 192.168.150.1 8845
nacos2 192.168.150.1 8846
nacos3 192.168.150.1 8847

5.7.2 搭建集群

1) 初始化数据库

Nacos默认数据存储在内嵌数据库Derby中,不属于生产可用的数据库。

官方推荐的最佳实践是使用带有主从的高可用数据库集群,主从模式的高可用数据库可以参考传智教育的后续高手课程。

这里我们以单点的数据库为例来讲解。

首先新建一个数据库,命名为nacos,而后导入下面的SQL:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
CREATE TABLE `config_info` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'id',
`data_id` varchar(255) NOT NULL COMMENT 'data_id',
`group_id` varchar(255) DEFAULT NULL,
`content` longtext NOT NULL COMMENT 'content',
`md5` varchar(32) DEFAULT NULL COMMENT 'md5',
`gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间',
`src_user` text COMMENT 'source user',
`src_ip` varchar(50) DEFAULT NULL COMMENT 'source ip',
`app_name` varchar(128) DEFAULT NULL,
`tenant_id` varchar(128) DEFAULT '' COMMENT '租户字段',
`c_desc` varchar(256) DEFAULT NULL,
`c_use` varchar(64) DEFAULT NULL,
`effect` varchar(64) DEFAULT NULL,
`type` varchar(64) DEFAULT NULL,
`c_schema` text,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_configinfo_datagrouptenant` (`data_id`,`group_id`,`tenant_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='config_info';

/******************************************/
/* 数据库全名 = nacos_config */
/* 表名称 = config_info_aggr */
/******************************************/
CREATE TABLE `config_info_aggr` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'id',
`data_id` varchar(255) NOT NULL COMMENT 'data_id',
`group_id` varchar(255) NOT NULL COMMENT 'group_id',
`datum_id` varchar(255) NOT NULL COMMENT 'datum_id',
`content` longtext NOT NULL COMMENT '内容',
`gmt_modified` datetime NOT NULL COMMENT '修改时间',
`app_name` varchar(128) DEFAULT NULL,
`tenant_id` varchar(128) DEFAULT '' COMMENT '租户字段',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_configinfoaggr_datagrouptenantdatum` (`data_id`,`group_id`,`tenant_id`,`datum_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='增加租户字段';


/******************************************/
/* 数据库全名 = nacos_config */
/* 表名称 = config_info_beta */
/******************************************/
CREATE TABLE `config_info_beta` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'id',
`data_id` varchar(255) NOT NULL COMMENT 'data_id',
`group_id` varchar(128) NOT NULL COMMENT 'group_id',
`app_name` varchar(128) DEFAULT NULL COMMENT 'app_name',
`content` longtext NOT NULL COMMENT 'content',
`beta_ips` varchar(1024) DEFAULT NULL COMMENT 'betaIps',
`md5` varchar(32) DEFAULT NULL COMMENT 'md5',
`gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间',
`src_user` text COMMENT 'source user',
`src_ip` varchar(50) DEFAULT NULL COMMENT 'source ip',
`tenant_id` varchar(128) DEFAULT '' COMMENT '租户字段',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_configinfobeta_datagrouptenant` (`data_id`,`group_id`,`tenant_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='config_info_beta';

/******************************************/
/* 数据库全名 = nacos_config */
/* 表名称 = config_info_tag */
/******************************************/
CREATE TABLE `config_info_tag` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'id',
`data_id` varchar(255) NOT NULL COMMENT 'data_id',
`group_id` varchar(128) NOT NULL COMMENT 'group_id',
`tenant_id` varchar(128) DEFAULT '' COMMENT 'tenant_id',
`tag_id` varchar(128) NOT NULL COMMENT 'tag_id',
`app_name` varchar(128) DEFAULT NULL COMMENT 'app_name',
`content` longtext NOT NULL COMMENT 'content',
`md5` varchar(32) DEFAULT NULL COMMENT 'md5',
`gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间',
`src_user` text COMMENT 'source user',
`src_ip` varchar(50) DEFAULT NULL COMMENT 'source ip',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_configinfotag_datagrouptenanttag` (`data_id`,`group_id`,`tenant_id`,`tag_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='config_info_tag';

/******************************************/
/* 数据库全名 = nacos_config */
/* 表名称 = config_tags_relation */
/******************************************/
CREATE TABLE `config_tags_relation` (
`id` bigint(20) NOT NULL COMMENT 'id',
`tag_name` varchar(128) NOT NULL COMMENT 'tag_name',
`tag_type` varchar(64) DEFAULT NULL COMMENT 'tag_type',
`data_id` varchar(255) NOT NULL COMMENT 'data_id',
`group_id` varchar(128) NOT NULL COMMENT 'group_id',
`tenant_id` varchar(128) DEFAULT '' COMMENT 'tenant_id',
`nid` bigint(20) NOT NULL AUTO_INCREMENT,
PRIMARY KEY (`nid`),
UNIQUE KEY `uk_configtagrelation_configidtag` (`id`,`tag_name`,`tag_type`),
KEY `idx_tenant_id` (`tenant_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='config_tag_relation';

/******************************************/
/* 数据库全名 = nacos_config */
/* 表名称 = group_capacity */
/******************************************/
CREATE TABLE `group_capacity` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`group_id` varchar(128) NOT NULL DEFAULT '' COMMENT 'Group ID,空字符表示整个集群',
`quota` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '配额,0表示使用默认值',
`usage` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '使用量',
`max_size` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '单个配置大小上限,单位为字节,0表示使用默认值',
`max_aggr_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '聚合子配置最大个数,,0表示使用默认值',
`max_aggr_size` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '单个聚合数据的子配置大小上限,单位为字节,0表示使用默认值',
`max_history_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '最大变更历史数量',
`gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_group_id` (`group_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='集群、各Group容量信息表';

/******************************************/
/* 数据库全名 = nacos_config */
/* 表名称 = his_config_info */
/******************************************/
CREATE TABLE `his_config_info` (
`id` bigint(64) unsigned NOT NULL,
`nid` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`data_id` varchar(255) NOT NULL,
`group_id` varchar(128) NOT NULL,
`app_name` varchar(128) DEFAULT NULL COMMENT 'app_name',
`content` longtext NOT NULL,
`md5` varchar(32) DEFAULT NULL,
`gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`src_user` text,
`src_ip` varchar(50) DEFAULT NULL,
`op_type` char(10) DEFAULT NULL,
`tenant_id` varchar(128) DEFAULT '' COMMENT '租户字段',
PRIMARY KEY (`nid`),
KEY `idx_gmt_create` (`gmt_create`),
KEY `idx_gmt_modified` (`gmt_modified`),
KEY `idx_did` (`data_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='多租户改造';


/******************************************/
/* 数据库全名 = nacos_config */
/* 表名称 = tenant_capacity */
/******************************************/
CREATE TABLE `tenant_capacity` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`tenant_id` varchar(128) NOT NULL DEFAULT '' COMMENT 'Tenant ID',
`quota` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '配额,0表示使用默认值',
`usage` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '使用量',
`max_size` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '单个配置大小上限,单位为字节,0表示使用默认值',
`max_aggr_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '聚合子配置最大个数',
`max_aggr_size` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '单个聚合数据的子配置大小上限,单位为字节,0表示使用默认值',
`max_history_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '最大变更历史数量',
`gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_tenant_id` (`tenant_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='租户容量信息表';


CREATE TABLE `tenant_info` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'id',
`kp` varchar(128) NOT NULL COMMENT 'kp',
`tenant_id` varchar(128) default '' COMMENT 'tenant_id',
`tenant_name` varchar(128) default '' COMMENT 'tenant_name',
`tenant_desc` varchar(256) DEFAULT NULL COMMENT 'tenant_desc',
`create_source` varchar(32) DEFAULT NULL COMMENT 'create_source',
`gmt_create` bigint(20) NOT NULL COMMENT '创建时间',
`gmt_modified` bigint(20) NOT NULL COMMENT '修改时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_tenant_info_kptenantid` (`kp`,`tenant_id`),
KEY `idx_tenant_id` (`tenant_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='tenant_info';

CREATE TABLE `users` (
`username` varchar(50) NOT NULL PRIMARY KEY,
`password` varchar(500) NOT NULL,
`enabled` boolean NOT NULL
);

CREATE TABLE `roles` (
`username` varchar(50) NOT NULL,
`role` varchar(50) NOT NULL,
UNIQUE INDEX `idx_user_role` (`username` ASC, `role` ASC) USING BTREE
);

CREATE TABLE `permissions` (
`role` varchar(50) NOT NULL,
`resource` varchar(255) NOT NULL,
`action` varchar(8) NOT NULL,
UNIQUE INDEX `uk_role_permission` (`role`,`resource`,`action`) USING BTREE
);

INSERT INTO users (username, password, enabled) VALUES ('nacos', '$2a$10$EuWPZHzz32dJN7jexM34MOeYirDdFAZm2kuWj7VEOJhhZkDrxfvUu', TRUE);

INSERT INTO roles (username, role) VALUES ('nacos', 'ROLE_ADMIN');
2) 配置Nacos

将这个包解压到任意非中文目录下,进入nacos的conf目录,修改配置文件cluster.conf.example,重命名为cluster.conf:

image-20211218103254945

然后添加内容:

1
2
3
127.0.0.1:8845
127.0.0.1.8846
127.0.0.1.8847

然后修改application.properties文件,添加数据库配置:

1
2
3
4
5
6
7
spring.datasource.platform=mysql

db.num=1

db.url.0=jdbc:mysql://127.0.0.1:3306/nacos?characterEncoding=utf8&connectTimeout=1000&socketTimeout=3000&autoReconnect=true&useUnicode=true&useSSL=false&serverTimezone=UTC
db.user.0=root
db.password.0=123
3) 启动

将nacos文件夹复制三份,分别命名为:nacos1、nacos2、nacos3

image-20211218103348930

然后分别修改三个文件夹中的application.properties,

nacos1:

1
server.port=8845

nacos2:

1
server.port=8846

nacos3:

1
server.port=8847

然后分别启动三个nacos节点:

1
startup.cmd
4) nginx反向代理

修改conf/nginx.conf文件,配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
upstream nacos-cluster {
server 127.0.0.1:8845;
server 127.0.0.1:8846;
server 127.0.0.1:8847;
}

server {
listen 80;
server_name localhost;

location /nacos {
proxy_pass http://nacos-cluster;
}
}

而后在浏览器访问:http://localhost/nacos即可。

代码中application.yml文件配置如下:

1
2
3
4
spring:
cloud:
nacos:
server-addr: localhost:80 # Nacos地址
5) 优化
  • 实际部署时,需要给做反向代理的nginx服务器设置一个域名,这样后续如果有服务器迁移nacos的客户端也无需更改配置.

  • Nacos的各个节点应该部署到多个不同服务器,做好容灾和隔离

6 Feign远程调用

RestTemplate方式调用存在的问题

先来看我们以前利用RestTemplate发起远程调用的代码:

1
2
String url = "http://userservice/user/" + order.getUserId();
User user = restTemplate.getForObject(url, User.class);

存在下面的问题:

  • 代码可读性差,编程体验不统一

  • 参数复杂URL难以维护

解决

Feign是一个声明式的http客户端,官方地址:https://github.com/OpenFeign/feign

其作用就是帮助我们优雅的实现http请求的发送,解决上面提到的问题。

6.1 Feign代替RestTemplate

  1. 引入依赖:

我们在order-service服务的pom文件中引入feign的依赖:

1
2
3
4
5
<!--feign客户端依赖-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
  1. 添加注解:

在order-service的启动类添加注解开启Feign的功能:

1
@EnableFeignClients

image-20211218144439888

  1. 编写Feign的客户端

在order-service中新建一个接口,内容如下:

1
2
3
4
5
@FeignClient("userservice")
public interface UserClient {
@GetMapping("/user/{id}")
User findById(@PathVariable("id") Long id);
}

image-20211218144553685

这个客户端主要是基于SpringMVC的注解来声明远程调用的信息,比如:

  • 服务名称:userservice
  • 请求方式:GET
  • 请求路径:/user/{id}
  • 请求参数:Long id
  • 返回值类型:User

这样,Feign就可以帮助我们发送http请求,无需自己使用RestTemplate来发送了。

  1. 用Feign客户端代替RestTemplate:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Service
public class OrderService {

@Autowired
private OrderMapper orderMapper;

@Autowired
private UserClient userClient;

public Order queryOrderById(Long orderId) {
// 1.查询订单
Order order = orderMapper.findById(orderId);
// 2.用feign远程调用
User user = userClient.findById(order.getUserId());
// 3.封装user到Order
order.setUser(user);
// 4.返回
return order;
}
}

总结——Feign的使用步骤

  1. 引入依赖(微服务pom.xml)

  2. 添加@EnableFeignClients注解(启动类)

  3. 编写FeignClient接口

  4. 使用FeignClient中定义的方法代替RestTemplate(业务层)

6.2 自定义配置

Feign可以支持很多的自定义配置,如下表所示:

类型 作用 说明
feign.Logger.Level 修改日志级别 包含四种不同的级别:NONE、BASIC、HEADERS、FULL
feign.codec.Decoder 响应结果的解析器 http远程调用的结果做解析,例如解析json字符串为java对象
feign.codec.Encoder 请求参数编码 将请求参数编码,便于通过http请求发送
feign. Contract 支持的注解格式 默认是SpringMVC的注解
feign. Retryer 失败重试机制 请求失败的重试机制,默认是没有,不过会使用Ribbon的重试

一般情况下,默认值就能满足我们使用,如果要自定义时,只需要创建自定义的@Bean覆盖默认Bean即可。

日志的级别分为四种:

  • NONE:不记录任何日志信息,这是默认值。
  • BASIC:仅记录请求的方法,URL以及响应状态码和执行时间
  • HEADERS:在BASIC的基础上,额外记录了请求和响应的头信息
  • FULL:记录所有请求和响应的明细,包括头信息、请求体、元数据。

6.2.1 配置文件方式

基于配置文件修改feign的日志级别可以针对单个服务:

1
2
3
4
5
feign:  
client:
config:
userservice: # 针对某个微服务的配置
loggerLevel: FULL # 日志级别

也可以针对所有服务:

1
2
3
4
5
feign:  
client:
config:
default: # 这里用default就是全局配置,如果是写服务名称,则是针对某个微服务的配置
loggerLevel: FULL # 日志级别

6.2.2 Java代码方式

也可以基于Java代码来修改日志级别,先声明一个类,然后声明一个Logger.Level的对象:

1
2
3
4
5
6
public class DefaultFeignConfiguration  {
@Bean
public Logger.Level feignLogLevel(){
return Logger.Level.BASIC; // 日志级别为BASIC
}
}

image-20211218145903161

  • 如果要全局生效,将其放到启动类的@EnableFeignClients这个注解中:
1
@EnableFeignClients(defaultConfiguration = DefaultFeignConfiguration .class)
  • 如果是局部生效,则把它放到对应的@FeignClient这个注解中:
1
@FeignClient(value = "userservice", configuration = DefaultFeignConfiguration .class) 

6.3 Feign优化

优化Feign的性能

  • 使用连接池代替默认的URLConnection

  • 日志级别,最好用basic或none

Feign底层发起http请求,依赖于其它的框架。其底层客户端实现包括:

  • URLConnection:默认实现不支持连接池

  • Apache HttpClient :支持连接池

  • OKHttp:支持连接池

因此提高Feign的性能主要手段就是使用连接池代替默认的URLConnection。这里我们用Apache的HttpClient来演示。

  1. 引入依赖:在order-service的pom文件中引入Apache的HttpClient依赖:
1
2
3
4
5
<!--Apache的HttpClient依赖-->
<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-httpclient</artifactId>
</dependency>
  1. 配置连接池

在order-service的application.yml中添加配置:

1
2
3
4
5
6
7
8
9
feign:
client:
config:
default: # default全局的配置
loggerLevel: BASIC # 日志级别,BASIC就是基本的请求和响应信息
httpclient:
enabled: true # 开启feign对HttpClient的支持
max-connections: 200 # 最大的连接数
max-connections-per-route: 50 # 每个路径的最大连接数

此外的可配置的选项:

image-20211218151149809

6.4 Feign最佳实践

观察可以发现,Feign的客户端(UserClient)与服务提供者的controller代码非常相似:

image-20211218153054888

image-20211218153116963

有没有一种办法简化这种重复的代码编写呢?

6.4.1 继承方式

一样的代码可以通过继承来共享:

  1. 定义一个API接口,利用定义方法,并基于SpringMVC注解做声明。

  2. Feign客户端和Controller都继承该接口

image-20211218153202868

优点

  • 实现简单
  • 实现了代码共享

缺点

  • 服务提供方、服务消费方紧耦合

  • 参数列表中的注解映射并不会继承,因此Controller中必须再次声明方法、参数列表、注解

image-20211218153254365

6.4.2 抽取方式

将Feign的Client抽取为独立模块,并且把接口有关的POJO、默认的Feign配置都放到这个模块中,提供给所有消费者使用。

例如,将UserClient、User、Feign的默认配置都抽取到一个feign-api包中,所有微服务引用该依赖包,即可直接使用。

image-20211218153325398

6.4.3 抽取方式的实现

1) 抽取

首先创建一个module,命名为feign-api,在feign-api中引入feign的starter依赖。

1
2
3
4
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>

然后,order-service中编写的UserClient、User、DefaultFeignConfiguration都复制到feign-api项目中,最后的项目结构:

image-20211218154711354

2) 在order-service中使用feign-api

首先,删除order-service中的UserClient、User、DefaultFeignConfiguration等类或接口。

在order-service的pom文件中中引入feign-api的依赖:

1
2
3
4
5
6
<!--引入feign的统一api-->
<dependency>
<groupId>cn.itcast.demo</groupId>
<artifactId>feign-api</artifactId>
<version>1.0</version>
</dependency>

修改order-service中的所有与上述三个组件有关的导包部分,改成导入feign-api中的包。

3) 解决扫描包问题

重启后,发现服务报错了:

image-20211218154834903

这是因为UserClient现在在cn.itcast.feign.clients包下,

而order-service的@EnableFeignClients注解是在cn.itcast.order包下,不在同一个包,无法扫描到UserClient

以下两种方式都是在orderservice的启动类上添加注解。

方式一

指定Feign应该扫描的包:

1
@EnableFeignClients(basePackages = "cn.itcast.feign.clients")

方式二(推荐)

指定需要加载的Client接口:

1
@EnableFeignClients(clients = UserClient.class)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@MapperScan("cn.itcast.order.mapper")
@SpringBootApplication
@EnableFeignClients(clients = UserClient.class, defaultConfiguration = DefaultFeignConfiguration.class)
public class OrderApplication {

public static void main(String[] args) {
SpringApplication.run(OrderApplication.class, args);
}

/**
* 创建RestTemplate并注入Spring容器
*/
@Bean
@LoadBalanced
public RestTemplate restTemplate() {
return new RestTemplate();
}

@Bean
public IRule randomRule() {
return new RandomRule();
}
}

7 Gateway服务网关

Spring Cloud Gateway 是 Spring Cloud 的一个全新项目,该项目是基于 Spring 5.0,Spring Boot 2.0 和 Project Reactor 等响应式编程事件流技术开发的网关,它旨在为微服务架构提供一种简单有效的统一的 API 路由管理方式。

7.1 为什么需要网关

Gateway网关是我们服务的守门神,所有微服务的统一入口。

网关的核心功能特性

  • 对用户请求做身份认证、权限校验:网关作为微服务入口,需要校验用户是是否有请求资格,如果没有则进行拦截。

  • 将用户请求路由到微服务,并实现负载均衡:一切请求都必须先经过gateway,但网关不处理业务,而是根据某种规则,把请求转发到某个微服务,这个过程叫做路由。当然路由的目标服务有多个时,还需要做负载均衡。

  • 对用户请求做限流:当请求流量过高时,在网关中按照下流的微服务能够接受的速度来放行请求,避免服务压力过大。

image-20211221195922014

在SpringCloud中网关的实现包括两种:

  • gateway
  • zuul

Zuul是基于Servlet的实现,属于阻塞式编程。而SpringCloudGateway则是基于Spring5中提供的WebFlux,属于响应式编程的实现,具备更好的性能。

7.2 快速入门

7.2.1 项目搭建及测试

下面,我们就演示下网关的基本路由功能。基本步骤如下:

  1. 创建SpringBoot工程gateway,引入网关依赖
  2. 编写启动类
  3. 编写基础配置和路由规则
  4. 启动网关服务进行测试

创建SpringBoot工程gateway,引入网关依赖

image-20211221201501886

引入依赖:

1
2
3
4
5
6
7
8
9
10
<!--网关gateway依赖-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<!--nacos服务注册发现依赖-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>

编写启动类

1
2
3
4
5
6
@SpringBootApplication
public class GatewayApplication {
public static void main(String[] args) {
SpringApplication.run(GatewayApplication.class, args);
}
}

编写基础配置和路由规则

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
server:
port: 10010 # 网关端口
spring:
application:
name: gateway # 服务名称
cloud:
nacos:
server-addr: localhost:8848 # nacos地址
gateway:
routes: # 网关路由配置
- id: user-service # 路由id,自定义,只要唯一即可
# uri: http://127.0.0.1:8081 # 路由的目标地址 http就是固定地址
uri: lb://userservice # 路由的目标地址 lb就是负载均衡,后面跟服务名称
predicates: # 路由断言,也就是判断请求是否符合路由规则的条件
- Path=/user/** # 这个是按照路径匹配,只要以/user/开头就符合要求
- id: order-service
uri: lb://orderservice
predicates:
- Path=/order/**

我们将符合Path 规则的一切请求,都代理到 uri参数指定的地址。

本例中,我们将 /user/**开头的请求,代理到lb://userservice,lb是负载均衡,根据服务名拉取服务列表,实现负载均衡。

测试

重启网关,访问http://localhost:10010/user/1时,符合`/user/**`规则,请求转发到uri:http://userservice/user/1,得到了结果:

image-20211221202531184

7.2.2 网关路由流程图

整个访问的流程如下:

image-20211221202624142

总结

网关搭建步骤:

  1. 创建项目,引入nacos服务发现和gateway依赖

  2. 配置application.yml,包括服务基本信息、nacos地址、路由

路由配置包括:

  1. 路由id:路由的唯一标示
  2. 路由目标(uri):路由的目标地址,http代表固定地址,lb代表根据服务名负载均衡
  3. 路由断言(predicates):判断路由的规则,
  4. 路由过滤器(filters):对请求或响应做处理

7.3 断言工厂 Predicate Factory

predicates:路由断言,判断请求是否符合要求,符合则转发到路由目的地

我们在配置文件中写的断言规则只是字符串,这些字符串会被Predicate Factory读取并处理,转变为路由判断的条件。例如Path=/user/**是按照路径匹配,这个规则是由org.springframework.cloud.gateway.handler.predicate.PathRoutePredicateFactory类来处理的,像这样的断言工厂在SpringCloudGateway还有十几个:

名称 说明 示例
After 是某个时间点后的请求 - After=2037-01-20T17:42:47.789-07:00[America/Denver]
Before 是某个时间点之前的请求 - Before=2031-04-13T15:14:47.433+08:00[Asia/Shanghai]
Between 是某两个时间点之前的请求 - Between=2037-01-20T17:42:47.789-07:00[America/Denver], 2037-01-21T17:42:47.789-07:00[America/Denver]
Cookie 请求必须包含某些cookie - Cookie=chocolate, ch.p
Header 请求必须包含某些header - Header=X-Request-Id, \d+
Host 请求必须是访问某个host(域名) - Host=.somehost.org,.anotherhost.org
Method 请求方式必须是指定方式 - Method=GET,POST
Path 请求路径必须符合指定规则 - Path=/red/{segment},/blue/**
Query 请求参数必须包含指定参数 - Query=name, Jack或者- Query=name
RemoteAddr 请求者的ip必须是指定范围 - RemoteAddr=192.168.1.1/24
Weight 权重处理

我们只需要掌握Path这种路由工程就可以了。

7.4 过滤器工厂 Gateway Filter

GatewayFilter是网关中提供的一种过滤器,可以对进入网关的请求微服务返回的响应做处理:

image-20211221204350510

7.4.1 路由过滤器的种类

Spring提供了31种不同的路由过滤器工厂。例如:

名称 说明
AddRequestHeader 给当前请求添加一个请求头
RemoveRequestHeader 移除请求中的一个请求头
AddResponseHeader 给响应结果中添加一个响应头
RemoveResponseHeader 从响应结果中移除有一个响应头
RequestRateLimiter 限制请求的流量

7.4.2 请求头过滤器

以AddRequestHeader 为例来讲解。

需求:给所有进入userservice的请求添加一个请求头:Truth=you are good!

解决

只需要修改gateway服务的application.yml文件,添加路由过滤即可:

1
2
3
4
5
6
7
8
9
10
spring:
cloud:
gateway:
routes:
- id: user-service
uri: lb://userservice
predicates:
- Path=/user/**
filters: # 过滤器
- AddRequestHeader=Truth, you are good! # 添加请求头

当前过滤器写在userservice路由下,因此仅仅对访问userservice的请求有效。

同时修改userservice业务层代码,接收这一参数:

1
2
3
4
5
6
@GetMapping("/{id}")
public User queryById(@PathVariable("id") Long id,
@RequestHeader(value = "Truth", required = false) String truth) {
System.out.println("truth:" + truth);
return userService.queryById(id);
}

执行结果:

image-20211221204634191

7.4.3 默认过滤器

如果要对所有的路由都生效,则可以将过滤器工厂写到routes同级下。格式如下:

1
2
3
4
5
6
7
8
9
10
spring:
cloud:
gateway:
routes:
- id: user-service
uri: lb://userservice
predicates:
- Path=/user/**
default-filters: # 默认过滤项
- AddRequestHeader=Truth, you are good!

7.4.4 全局过滤器

上一节学习的过滤器,网关提供了31种,但每一种过滤器的作用都是固定的。如果我们希望拦截请求,做自己的业务逻辑则没办法实现。

1) 全局过滤器作用

全局过滤器的作用也是处理一切进入网关的请求和微服务响应,与GatewayFilter的作用一样。区别在于GatewayFilter通过配置定义,处理逻辑是固定的;而GlobalFilter的逻辑需要自己写代码实现

定义方式是实现GlobalFilter接口

1
2
3
4
5
6
7
8
9
10
public interface GlobalFilter {
/**
* 处理当前请求,有必要的话通过{@link GatewayFilterChain}将请求交给下一个过滤器处理
*
* @param exchange 请求上下文,里面可以获取Request、Response等信息
* @param chain 用来把请求委托给下一个过滤器
* @return {@code Mono<Void>} 返回标示当前过滤器业务结束
*/
Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain);
}

在filter中编写自定义逻辑,可以实现下列功能:

  • 登录状态判断
  • 权限校验
  • 请求限流等
2) 自定义全局过滤器

需求:定义全局过滤器,拦截请求,判断请求的参数是否满足下面条件:

  • 参数中是否有authorization,

  • authorization参数值是否为admin

如果同时满足则放行,否则拦截。

实现

在gateway中定义一个过滤器:

image-20211221210338254

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Order(-1)
@Component
public class AuthorizeFilter implements GlobalFilter {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 1.获取请求参数
ServerHttpRequest request = exchange.getRequest();
MultiValueMap<String, String> params = request.getQueryParams();
// 2.获取参数中的 authorization 参数
String auth = params.getFirst("authorization");
// 3.判断参数值是否等于 admin
if ("admin".equals(auth)) {
// 4.是则放行
return chain.filter(exchange);
}
// 5.否则拦截
// 5.1设置状态码
exchange.getResponse().setStatusCode(org.springframework.http.HttpStatus.valueOf(HttpStatus.SC_UNAUTHORIZED));
// 5.2拦截请求
return exchange.getResponse().setComplete();
}
}

测试结果

访问http://localhost:10010/user/1

image-20211221210443360

访问http://localhost:10010/user/1?authorization=admin

image-20211221210515798

7.4.5 过滤器执行顺序

请求进入网关会碰到三类过滤器:当前路由的过滤器、DefaultFilter、GlobalFilter

请求路由后,会将当前路由过滤器和DefaultFilter、GlobalFilter,合并到一个过滤器链(集合)中,排序后依次执行每个过滤器

image-20211221211626346

排序的规则

  • 每一个过滤器都必须指定一个int类型的order值,order值越小,优先级越高,执行顺序越靠前
  • GlobalFilter通过实现Ordered接口,或者添加@Order注解来指定order值,由我们自己指定
  • 路由过滤器和defaultFilter的order由Spring指定默认是按照声明顺序从1递增
  • 当过滤器的order值一样时,会按照 defaultFilter > 路由过滤器 > GlobalFilter的顺序执行

详细内容,可以查看源码:

org.springframework.cloud.gateway.route.RouteDefinitionRouteLocator#getFilters()方法是先加载defaultFilters,然后再加载某个route的filters,然后合并。

org.springframework.cloud.gateway.handler.FilteringWebHandler#handle()方法会加载全局过滤器,与前面的过滤器合并后根据order排序,组织过滤器链

7.5 跨域问题

跨域:域名不一致就是跨域,主要包括:

  • 域名不同: www.taobao.com 和 www.taobao.org 和 www.jd.com 和 miaosha.jd.com

  • 域名相同,端口不同:localhost:8080和localhost8081

跨域问题:浏览器禁止请求的发起者与服务端发生跨域ajax请求,请求被浏览器拦截的问题。

解决方案:CORS。

7.5.1 模拟跨域问题

index.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<pre>
spring:
cloud:
gateway:
globalcors: # 全局的跨域处理
add-to-simple-url-handler-mapping: true # 解决options请求被拦截问题
corsConfigurations:
'[/**]':
allowedOrigins: # 允许哪些网站的跨域请求
- "http://localhost:8090"
- "http://www.leyou.com"
allowedMethods: # 允许的跨域ajax的请求方式
- "GET"
- "POST"
- "DELETE"
- "PUT"
- "OPTIONS"
allowedHeaders: "*" # 允许在请求中携带的头信息
allowCredentials: true # 是否允许携带cookie
maxAge: 360000 # 这次跨域检测的有效期
</pre>
</body>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<script>
axios.get("http://localhost:10010/user/1?authorization=admin")
.then(resp => console.log(resp.data))
.catch(err => console.log(err))
</script>
</html>

放入tomcat或者nginx这样的web服务器中,启动并访问。

可以在浏览器控制台看到下面的错误:

image-20211222100455207

从localhost:8090访问localhost:10010,端口不同,显然是跨域的请求。

7.5.2 解决跨域问题

在gateway服务的application.yml文件中,添加下面的配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
spring:
cloud:
gateway:
# 。。。
globalcors: # 全局的跨域处理
add-to-simple-url-handler-mapping: true # 解决options请求被拦截问题
corsConfigurations:
'[/**]':
allowedOrigins: # 允许哪些网站的跨域请求
- "http://localhost:8090"
allowedMethods: # 允许的跨域ajax的请求方式
- "GET"
- "POST"
- "DELETE"
- "PUT"
- "OPTIONS"
allowedHeaders: "*" # 允许在请求中携带的头信息
allowCredentials: true # 是否允许携带cookie
maxAge: 360000 # 这次跨域检测的有效期

8 Docker应用部署

8.1 初识Docker

8.1.1 应用部署的环境问题

微服务虽然具备各种各样的优势,但服务的拆分通用给部署带来了很大的麻烦。

  • 分布式系统中,依赖的组件非常多,不同组件之间部署时往往会产生一些冲突。
  • 在数百上千台服务中重复部署,环境不一定一致,会遇到各种问题

大型项目组件较多,运行环境也较为复杂,部署时会碰到一些问题:

  • 依赖关系复杂,容易出现兼容性问题

  • 开发、测试、生产环境有差异

image-20211222102557355

例如一个项目中,部署时需要依赖于node.js、Redis、RabbitMQ、MySQL等,这些服务部署时所需要的函数库、依赖项各不相同,甚至会有冲突。给部署带来了极大的困难。

Docker解决依赖兼容问题

Docker为了解决依赖的兼容问题的,采用了两个手段:

  • 将应用的Libs(函数库)、Deps(依赖)、配置与应用一起打包

  • 将每个应用放到一个隔离容器去运行,避免互相干扰

image-20211222102652114

这样打包好的应用包中,既包含应用本身,也保护应用所需要的Libs、Deps,无需再操作系统上安装这些,自然就不存在不同应用之间的兼容问题了。

Docker解决操作系统环境差异

要解决不同操作系统环境差异问题,必须先了解操作系统结构。以一个Ubuntu操作系统为例,结构如下:

image-20211222102757151

结构包括:

  • 计算机硬件:例如CPU、内存、磁盘等
  • 系统内核:所有Linux发行版的内核都是Linux,例如CentOS、Ubuntu、Fedora等。内核可以与计算机硬件交互,对外提供内核指令,用于操作计算机硬件。
  • 系统应用:操作系统本身提供的应用、函数库。这些函数库是对内核指令的封装,使用更加方便。

应用于计算机交互的流程如下:

1)应用调用操作系统应用(函数库),实现各种功能

2)系统函数库是对内核指令集的封装,会调用内核指令

3)内核指令操作计算机硬件

Ubuntu和CentOS都是基于Linux内核,无非是系统应用不同,提供的函数库有差异

image-20211222102915504

此时,如果将一个Ubuntu版本的MySQL应用安装到CentOS系统,MySQL在调用Ubuntu函数库时,会发现找不到或者不匹配,就会报错了:

image-20211222102942038

Docker如何解决不同系统环境的问题?

  • Docker将用户程序与所需要调用的系统(比如Ubuntu)函数库一起打包
  • Docker运行到不同操作系统时,直接基于打包的函数库,借助于操作系统的Linux内核来运行

如图:

image-20211222103031797

小结

  1. Docker如何解决大型项目依赖关系复杂,不同组件依赖的兼容性问题?
  • Docker允许开发中将应用、依赖、函数库、配置一起打包,形成可移植镜像
  • Docker应用运行在容器中,使用沙箱机制,相互隔离
  1. Docker如何解决开发、测试、生产环境有差异的问题?
  • Docker镜像中包含完整运行环境,包括系统函数库,仅依赖系统的Linux内核,因此可以在任意Linux操作系统上运行
  1. Docker是一个快速交付应用、运行应用的技术,具备下列优势:
  • 可以将程序及其依赖、运行环境一起打包为一个镜像,可以迁移到任意Linux操作系统
  • 运行时利用沙箱机制形成隔离容器,各个应用互不干扰
  • 启动、移除都可以通过一行命令完成,方便快捷

8.1.2 Docker和虚拟机的区别

Docker可以让一个应用在任何操作系统中非常方便的运行。而以前我们接触的虚拟机,也能在一个操作系统中,运行另外一个操作系统,保护系统中的任何应用。

两者有什么差异呢?

虚拟机(virtual machine)是在操作系统中模拟硬件设备,然后运行另一个操作系统,比如在 Windows 系统里面运行 Ubuntu 系统,这样就可以运行任意的Ubuntu应用了。

Docker仅仅是封装函数库,并没有模拟完整的操作系统,如图:

image-20211222103721725

对比来看:

特性 Docker 虚拟机
性能 接近原生 性能较差
硬盘占用 一般为 MB 一般为GB
启动 秒级 分钟级

小结——Docker和虚拟机的差异

  • docker是一个系统进程;虚拟机是在操作系统中的操作系统

  • docker体积小、启动速度快、性能好;虚拟机体积大、启动速度慢、性能一般

8.1.3 Docker架构

1) 镜像和容器
  • 镜像(Image):Docker将应用程序及其所需的依赖、函数库、环境、配置等文件打包在一起,称为镜像。

  • 容器(Container):镜像中的应用程序运行后形成的进程就是容器只是Docker会给容器进程做隔离,对外不可见。

一切应用最终都是代码组成,都是硬盘中的一个个的字节形成的文件。只有运行时,才会加载到内存,形成进程。而镜像,就是把一个应用在硬盘上的文件、及其运行环境、部分系统函数库文件一起打包形成的文件包。这个文件包是只读的容器,就是将这些文件中编写的程序、函数加载到内存中允许,形成进程,只不过要隔离起来。因此一个镜像可以启动多次,形成多个容器进程。

image-20211222104719266

例如你下载了一个QQ,如果我们将QQ在磁盘上的运行文件及其运行的操作系统依赖打包,形成QQ镜像。然后你可以启动多次,双开、甚至三开QQ,跟多个妹子聊天。

2) DockerHub

开源应用程序非常多,打包这些应用往往是重复的劳动。为了避免这些重复劳动,人们就会将自己打包的应用镜像,例如Redis、MySQL镜像放到网络上,共享使用,就像GitHub的代码共享一样。

  • DockerHub:DockerHub是一个官方的Docker镜像的托管平台。这样的平台称为Docker Registry

  • 国内也有类似于DockerHub 的公开服务,比如 网易云镜像服务阿里云镜像库等。

我们一方面可以将自己的镜像共享到DockerHub,另一方面也可以从DockerHub拉取镜像:

image-20211222104830640

3) Docker架构

我们要使用Docker来操作镜像、容器,就必须要安装Docker。

Docker是一个CS架构的程序,由两部分组成:

  • 服务端(server):Docker守护进程,负责处理Docker指令,管理镜像、容器等

  • 客户端(client):通过命令或RestAPI向Docker服务端发送指令。可以在本地或远程向服务端发送指令。

如图:

image-20211222104917394

8.1.4 安装Docker

企业部署一般都是采用Linux操作系统,而其中又数CentOS发行版占比最多,因此我们在CentOS下安装Docker。

常用命令

1
2
3
4
5
6
7
8
9
systemctl start docker  # 启动docker服务

systemctl stop docker # 停止docker服务

systemctl restart docker # 重启docker服务

systemctl status docker # 查看docker状态

docker -v # 查看docker版本号

部分截图

image-20211229204341600

image-20211229204356314

8.2 Docker基本操作

8.2.1 镜像操作

1) 镜像名称

镜像名称一般分两部分组成:[repository]:[tag]

在没有指定tag时,默认是latest,代表最新版本的镜像

image-20211230155454273

这里的mysql就是repository,5.7就是tag,合一起就是镜像名称,代表5.7版本的MySQL镜像。

2) 镜像命令

常见的镜像操作命令如图:

image-20211230155524926

3) 拉取和查看镜像
  1. 首先去镜像仓库搜索nginx镜像,比如DockerHub:

image-20211230155621808

  1. 根据查看到的镜像名称,拉取自己需要的镜像,通过命令
1
docker pull nginx

image-20211230155708611

  1. 查看拉取到的镜像,通过命令:
1
docker images

image-20211230155754628

4) 保存和导入镜像
  1. 利用docker xx --help命令可以查看docker save和docker load的语法:
1
docker save --help

image-20211230155855481

可以知道命令格式为:

1
docker save -o nginx.tar nginx:latest
  1. 保存镜像到本地,通过命令:
1
docker save -o nginx.tar nginx:latest

image-20211230160057164

image-20211230160116546

  1. 删除镜像,通过命令:
1
docker rmi nginx:latest

image-20211230160328302

  1. 导入本地镜像,通过命令:
1
docker load -i nginx.tar

image-20211230160430457

8.2.2 容器操作

1) 概述

容器操作的命令如图:

image-20211230160807988

容器保护三个状态:

  • 运行:进程正常运行
  • 暂停:进程暂停,CPU不再运行,并不释放内存
  • 停止:进程终止,回收进程占用的内存、CPU等资源

其中的命令含义为:

  • docker run:创建并运行一个容器,处于运行状态
  • docker pause:让一个运行的容器暂停
  • docker unpause:让一个容器从暂停状态恢复运行
  • docker stop:停止一个运行的容器
  • docker start:让一个停止的容器再次运行

  • docker rm:删除一个容器

2) 创建并运行一个容器

去docker hub查看Nginx的容器运行命令:

image-20211230162529253

image-20211230162547086

1
docker run --name some-nginx -d -p 8080:80 some-content-nginx

命令解读:

  • docker run :创建并运行一个容器
  • —name : 给容器起一个名字,比如叫做serv
  • -p :将宿主机端口与容器端口映射,冒号左侧是宿主机端口右侧是容器端口
  • -d:后台运行容器
  • nginx:镜像名称,例如nginx

这里的-p参数,是将容器端口映射到宿主机端口。

默认情况下,容器是隔离环境,我们直接访问宿主机的80端口,肯定访问不到容器中的nginx

现在,将容器的80与宿主机的80关联起来,当我们访问宿主机的80端口时,就会被映射到容器的80,这样就能访问到nginx了:

image-20211230162658647

创建并运行

1
docker ps # 查看正在运行的容器

image-20211230162831376

访问页面:注意是8080端口

image-20211230162947027

日志跟踪

1
2
docker logs serv # 打印全部日志,参数是容器名称
docker logs -f serv # 实时跟踪日志打印

image-20211230163229790

3) 进入容器和修改文件

需求:进入Nginx容器,修改HTML文件内容,添加“Hello World”

提示:进入容器要用到docker exec命令。

  1. 进入容器。进入我们刚刚创建的nginx容器的命令为:
1
docker exec -it serv bash

image-20211230163557549

  • docker exec :进入容器内部,执行一个命令。exec命令可以进入容器修改文件,但是在容器内修改文件是不推荐的
  • -it : 给当前进入的容器创建一个标准输入、输出终端,允许我们与容器交互
  • serv:要进入的容器的名称
  • bash:进入容器后执行的命令,bash是一个linux终端交互命令
  1. 进入nginx的HTML所在目录/usr/share/nginx/html

容器内部会模拟一个独立的Linux文件系统,看起来如同一个linux服务器一样:

image-20211230164508487

nginx的环境、配置、运行文件全部都在这个文件系统中,包括我们要修改的html文件。

查看DockerHub网站中的nginx页面,可以知道nginx的html目录位置在/usr/share/nginx/html

image-20211230164541655

我们执行命令,进入该目录:

1
cd /usr/share/nginx/html

查看目录下文件:

image-20211230164608852

  1. 修改index.html的内容

容器内没有vi命令(nginx容器),无法直接修改,我们用下面的命令来修改:

1
sed -i -e 's#Welcome to nginx#Hello World#g' -e 's#<head>#<head><meta charset="utf-8">#g' index.html

再次访问页面:

image-20211230164807334

  1. 停止容器,并显示所有容器的状态(包括停止运行的容器):
1
2
docker stop serv # 停止运行serv容器
docker ps -a # 显示所有容器状态,包括停止运行的容器

image-20211230165211588

  1. 删除容器:
1
docker rm -f serv # 强制删除正在运行的容器

image-20211230165312533

8.2.3 容器数据管理

在之前的nginx案例中,修改nginx的html页面时,需要进入nginx内部。并且因为没有编辑器,修改文件也很麻烦。

这就是因为容器与数据(容器内文件)耦合带来的后果。要解决这个问题,必须将数据与容器解耦,这就要用到数据卷了。

image-20211230165821760

1) 数据卷

数据卷(volume)是一个虚拟目录指向宿主机文件系统中的某个目录

数据卷的作用:将容器与数据分离,解耦合,方便操作容器内数据,保证数据安全

image-20211230170538923

一旦完成数据卷挂载,对容器的一切操作都会作用在数据卷对应的宿主机目录了。

这样,我们操作宿主机的/var/lib/docker/volumes/html目录,就等于操作容器内的/usr/share/nginx/html目录了

2) 操作命令

数据卷操作的基本语法如下:

1
docker volume [COMMAND]

docker volume命令是数据卷操作,根据命令后跟随的command来确定下一步的操作:

  • create 创建一个volume

  • inspect 显示一个或多个volume的信息

  • ls 列出所有的volume

    • ```sh
      docker volume ls
      1
      2
      3
      4
      5

      - prune 删除未使用的volume

      - ```sh
      docker volume prumn
  • rm 删除一个或多个指定的volume

    • ```sh
      docker volume rm html
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19

      > 实操

      ![image-20211230170840728](https://kisugitakumi.oss-cn-chengdu.aliyuncs.com/img5/image-20211230170840728.png)

      ##### 3) 挂载数据卷案例1

      **需求**:创建一个nginx容器,修改容器内的html目录内的index.html内容

      **分析**:上个案例中,我们进入nginx容器内部,已经知道nginx的html目录所在位置/usr/share/nginx/html ,我们需要把这个目录挂载到html这个数据卷上,方便操作其中的内容。

      **提示**:运行容器时使用 -v 参数挂载数据卷

      > 步骤

      1. 创建容器并挂载数据卷到容器内的HTML目录

      ```sh
      docker run --name serv -v html:/usr/share/nginx/html -p 8080:80 -d nginx
  • -v html:/usr/share/nginx/html:把html数据卷挂载到容器内的/usr/share/nginx/html这个目录中
  1. 进入html数据卷所在位置,并修改HTML内容
1
2
3
4
5
6
# 查看html数据卷的位置
docker volume inspect html
# 进入该目录
cd /var/lib/docker/volumes/html/_data
# 修改文件
vi index.html

image-20211230172456874

修改index.html文件:

image-20211230172639436

访问页面:

image-20211230172656777

4) 挂载数据卷案例2

容器不仅仅可以挂载数据卷,也可以直接挂载到宿主机目录或者文件上。关联关系如下:

  • 带数据卷模式:宿主机目录 —> 数据卷 —-> 容器内目录
  • 直接挂载模式:宿主机目录/文件 —-> 容器内目录/文件

image-20211231170614193

语法:目录挂载与数据卷挂载的语法是类似的:

  • -v [宿主机目录]:[容器内目录]
  • -v [宿主机文件]:[容器内文件]

需求:创建并运行一个MySQL容器,将宿主机目录直接挂载到容器

实现步骤

  1. 在mysql.tar文件上传到虚拟机,通过load命令加载为镜像
1
docker load -i mysql.tar

image-20211231171114873

  1. 创建目录/tmp/mysql/data
1
mkdir -p mysql/data
  1. 创建目录/tmp/mysql/conf,将hmy.cnf文件上传到/tmp/mysql/conf
1
mkdir -p mysql/conf
  1. 去DockerHub查阅资料,创建并运行MySQL容器,要求:

    • 挂载/tmp/mysql/data到mysql容器内数据存储目录

    • 挂载/tmp/mysql/conf/hmy.cnf到mysql容器的配置文件

    • 设置MySQL密码

1
2
3
4
5
6
7
8
docker run \
--name mysql \ # 为容器运行取一个名字
-e MYSQL_ROOT_PASSWORD=... \ # 设置密码
-p 3306:3306 \ # 端口映射
-v /tmp/mysql/conf/hmy.cnf:/etc/mysql/conf.d/hmy.cnf \ # 挂载/tmp/mysql/conf/hmy.cnf到mysql容器的配置文件
-v /tmp/mysql/data:/var/lib/mysql \ # 挂载/tmp/mysql/data到mysql容器内数据存储目录
-d \ # 后台运行
mysql:5.7.25 # 镜像名称和版本号

执行结果

image-20211231171203398

测试连接数据库:

image-20211231171237262

总结

数据卷挂载与目录直接挂载的比较

  • 数据卷挂载耦合度低,由docker来管理目录,但是目录较深,不好找
  • 目录挂载耦合度高,需要我们自己管理目录,不过目录容易寻找查看

8.3 Dockerfile自定义镜像

常见的镜像在DockerHub就能找到,但是我们自己写的项目就必须自己构建镜像了。

而要自定义镜像,就必须先了解镜像的结构才行。

8.3.1 镜像结构

镜像是将应用程序及其需要的系统函数库、环境、配置、依赖打包而成。

我们以MySQL为例,来看看镜像的组成结构:

image-20220101095102673

镜像是分层结构,每一层称为一个Layer:

  • BaseImage层:包含基本的系统函数库、环境变量、文件系统

  • Entrypoint:入口,是镜像中应用启动的命令

  • 其它:在BaseImage基础上添加依赖、安装程序、完成整个应用的安装和配置

简单来说,镜像就是在系统函数库、运行环境基础上,添加应用程序文件、配置文件、依赖文件等组合,然后编写好启动脚本打包在一起形成的文件。

我们要构建镜像,其实就是实现上述打包的过程。

8.3.2 Dockerfile

1) 概述

构建自定义的镜像时,并不需要一个个文件去拷贝,打包。我们只需要告诉Docker,我们的镜像的组成,需要哪些BaseImage、需要拷贝什么文件、需要安装什么依赖、启动脚本是什么,将来Docker会帮助我们构建镜像。

而描述上述信息的文件就是Dockerfile文件

Dockerfile就是一个文本文件,其中包含一个个的指令(Instruction),用指令来说明要执行什么操作来构建镜像。每一个指令都会形成一层Layer。

指令 说明 示例
FROM 指定基础镜像 FROM centos:6
ENV 设置环境变量,可在后面指令使用 ENV key value
COPY 拷贝本地文件到镜像的指定目录 COPY ./mysql-5.7.rpm /tmp
RUN 执行Linux的shell命令,一般是安装过程的命令 RUN yum install gcc
EXPOSE 指定容器运行时监听的端口,是给镜像使用者看的 EXPOSE 8080
ENTRYPOINT 镜像中应用的启动命令,容器运行时调用 ENTRYPOINT java -jar xx.jar

更新详细语法说明,请参考官网文档: https://docs.docker.com/engine/reference/builder

2) 构建一个Java项目

需求:基于Centos镜像构建一个新镜像,运行一个java项目

步骤

  1. 新建一个空文件夹docker-demo

  2. 拷贝课前资料中的docker-demo.jarjdk8.tar.gzDockerfile文件到docker-demo这个目录,其中Dockerfile文件的内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 指定基础镜像
FROM centos:y
# 配置环境变量,JDK的安装目录
ENV JAVA_DIR=/usr/local

# 拷贝jdk和java项目的包
COPY ./jdk8.tar.gz $JAVA_DIR/
COPY ./docker-demo.jar /tmp/app.jar

# 安装JDK
RUN cd $JAVA_DIR \
&& tar -xf ./jdk8.tar.gz \
&& mv ./jdk1.8.0_144 ./java8

# 配置环境变量
ENV JAVA_HOME=$JAVA_DIR/java8
ENV PATH=$PATH:$JAVA_HOME/bin

# 暴露端口
EXPOSE 8090
# 入口,java项目的启动命令
ENTRYPOINT java -jar /tmp/app.jar
  1. 进入docker-demo,将准备好的docker-demo上传到虚拟机任意目录,然后进入docker-demo目录下
  2. 运行命令:构建镜像
1
docker build -t javaweb:1.0 .

image-20220101101111283

  • 查看镜像:

image-20220101101319297

  • 启动该镜像为容器:

image-20220101101251200

  • 访问启动的项目XXXX:8090/hello/count

image-20220101101532507

image-20220101101559036

8.4 Docker-Compose

8.4.1 概述

Docker Compose可以基于Compose文件帮我们快速的部署分布式应用,而无需手动一个个创建和运行容器。

Compose文件是一个文本文件,通过指令定义集群中的每个容器如何运行。格式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
version: "3.8"
services:
  mysql:
    image: mysql:5.7.25
environment:
MYSQL_ROOT_PASSWORD: 123
    volumes:
     - "/tmp/mysql/data:/var/lib/mysql"
     - "/tmp/mysql/conf/hmy.cnf:/etc/mysql/conf.d/hmy.cnf"
  web:
    build: .
    ports:
     - "8090:8090"

上面的Compose文件就描述一个项目,其中包含两个容器:

  • mysql:一个基于mysql:5.7.25镜像构建的容器,并且挂载了两个目录
  • web:一个基于docker build临时构建的镜像容器,映射端口是8090

DockerCompose的详细语法参考官网:https://docs.docker.com/compose/compose-file/

其实DockerCompose文件可以看做是将多个docker run命令写到一个文件,只是语法稍有差异。

8.4.2 安装DockerCompose

8.4.3 部署微服务集群 (暂时未实践)

需求:将之前学习的cloud-demo微服务集群利用DockerCompose部署

实现步骤

  1. 查看课前资料提供的cloud-demo文件夹,里面已经编写好了docker-compose文件

  2. 修改自己的cloud-demo项目,将数据库、nacos地址都命名为docker-compose中的服务名

  3. 使用maven打包工具,将项目中的每个微服务都打包为app.jar

  4. 将打包好的app.jar拷贝到cloud-demo中的每一个对应的子目录中

  5. 将cloud-demo上传至虚拟机,利用 docker-compose up -d 来部署

1) Compose文件
  1. 查看课前资料提供的cloud-demo文件夹,里面已经编写好了docker-compose文件,而且每个微服务都准备了一个独立的目录:

image-20220101105203693

docker-compose.yml文件内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
version: "3.2"
services:
nacos:
image: nacos/nacos-server
environment:
MODE: standalone
ports:
- "8848:8848"
mysql:
image: mysql:5.7.25
environment:
MYSQL_ROOT_PASSWORD: 123
volumes:
- "$PWD/mysql/data:/var/lib/mysql"
- "$PWD/mysql/conf:/etc/mysql/conf.d/"
userservice:
build: ./user-service
orderservice:
build: ./order-service
gateway:
build: ./gateway
ports:
- "10010:10010"

可以看到,其中包含5个service服务:

  • nacos:作为注册中心和配置中心
    • image: nacos/nacos-server: 基于nacos/nacos-server镜像构建
    • environment:环境变量
      • MODE: standalone:单点模式启动
    • ports:端口映射,这里暴露了8848端口
  • mysql:数据库
    • image: mysql:5.7.25:镜像版本是mysql:5.7.25
    • environment:环境变量
      • MYSQL_ROOT_PASSWORD: 123:设置数据库root账户的密码为123
    • volumes:数据卷挂载,这里挂载了mysql的data、conf目录,其中有提前准备好的数据
  • userserviceorderservicegateway:都是基于Dockerfile临时构建的
  1. 查看mysql目录,可以看到其中已经准备好了cloud_order、cloud_user表:

image-20220101105900147

  1. 查看微服务目录,可以看到都包含Dockerfile文件,内容如下:
1
2
3
FROM java:8-alpine
COPY ./app.jar /tmp/app.jar
ENTRYPOINT java -jar /tmp/app.jar
2) 修改微服务配置

因为微服务将来要部署为docker容器,而容器之间互联不是通过IP地址,而是通过容器名。这里我们将order-service、user-service、gateway服务的mysql、nacos地址都修改为基于容器名的访问。

如下所示:

1
2
3
4
5
6
7
8
9
10
11
spring:
datasource:
url: jdbc:mysql://mysql:3306/cloud_order?useSSL=false
username: root
password: 123
driver-class-name: com.mysql.jdbc.Driver
application:
name: orderservice
cloud:
nacos:
server-addr: nacos:8848 # nacos服务地址
3) 打包

接下来需要将我们的每个微服务都打包。因为之前查看到Dockerfile中的jar包名称都是app.jar,因此我们的每个微服务都需要用这个名称。

可以通过修改pom.xml中的打包名称来实现,每个微服务都需要修改:

1
2
3
4
5
6
7
8
9
10
<build>
<!-- 服务打包的最终名称 -->
<finalName>app</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
4) 拷贝jar包到部署目录

编译打包好的app.jar文件,需要放到Dockerfile的同级目录中。注意:每个微服务的app.jar放到与服务名称对应的目录,别搞错了。

5) 部署

最后,我们需要将文件整个cloud-demo文件夹上传到远端服务器中,由DockerCompose部署。

然后进入cloud-demo目录,然后运行下面的命令:

1
docker-compose up -d

8.5 Docker镜像仓库

镜像仓库( Docker Registry )有公共的和私有的两种形式:

  • 公共仓库:例如Docker官方的 Docker Hub,国内也有一些云服务商提供类似于 Docker Hub 的公开服务,比如 网易云镜像服务、DaoCloud 镜像服务、阿里云镜像服务等。

  • 除了使用公开仓库外,用户还可以在本地搭建私有 Docker Registry。企业自己的镜像最好是采用私有Docker Registry来实现。

9 RabbitMQ消息队列

9.1 初识 MQ

9.1.1 同步通讯和异步通讯

微服务间通讯有同步和异步两种方式:

  • 同步通讯:就像打电话,需要实时响应。

  • 异步通讯:就像发邮件,不需要马上回复。

image-20220102120026143

两种方式各有优劣,打电话可以立即得到响应,但是你却不能跟多个人同时通话。发送邮件可以同时与多个人收发邮件,但是往往响应会有延迟。

1) 同步通讯

我们之前学习的Feign调用就属于同步方式,虽然调用可以实时得到结果,但存在下面的问题:

image-20220102120123147

总结

同步调用的优点:

  • 时效性较强,可以立即得到结果

同步调用的问题:

  • 耦合度高
  • 性能和吞吐能力下降
  • 有额外的资源消耗
  • 级联失败问题
2) 异步通讯

异步调用则可以避免上述问题:

我们以购买商品为例,用户支付后需要调用订单服务完成订单状态修改,调用物流服务,从仓库分配响应的库存并准备发货。

在事件模式中,支付服务是事件发布者(publisher),在支付完成后只需要发布一个支付成功的事件(event),事件中带上订单id。

订单服务和物流服务是事件订阅者(Consumer),订阅支付成功的事件,监听到事件后完成自己业务即可。

为了解除事件发布者与订阅者之间的耦合,两者并不是直接通信,而是有一个中间人(Broker)。发布者发布事件到Broker,不关心谁来订阅事件。订阅者从Broker订阅事件,不关心谁发来的消息。

image-20220103104530265

Broker 是一个像数据总线一样的东西,所有的服务要接收数据和发送数据都发到这个总线上,这个总线就像协议一样,让服务间的通讯变得标准和可控。

好处

  • 吞吐量提升:无需等待订阅者处理完成,响应更快速

  • 故障隔离:服务没有直接调用,不存在级联失败问题

  • 调用间没有阻塞,不会造成无效的资源占用
  • 耦合度极低,每个服务都可以灵活插拔,可替换
  • 流量削峰:不管发布事件的流量波动多大,都由Broker接收,订阅者可以按照自己的速度去处理事件

缺点

  • 架构复杂了,业务没有明显的流程线,不好管理
  • 需要依赖于Broker的可靠、安全、性能

现在开源软件或云平台上 Broker 的软件是非常成熟的,比较常见的一种就是我们今天要学习的MQ技术。

9.1.2 MQ技术对比

MQ,中文是消息队列(Message Queue),字面来看就是存放消息的队列。也就是事件驱动架构中的Broker

比较常见的MQ实现:

  • ActiveMQ
  • RabbitMQ
  • RocketMQ
  • Kafka

技术对比

RabbitMQ ActiveMQ RocketMQ Kafka
公司/社区 Rabbit Apache 阿里 Apache
开发语言 Erlang Java Java Scala&Java
协议支持 AMQP,XMPP,SMTP,STOMP OpenWire,STOMP,REST,XMPP,AMQP 自定义协议 自定义协议
可用性 一般
单机吞吐量 一般 非常高
消息延迟 微秒级 毫秒级 毫秒级 毫秒以内
消息可靠性 一般 一般
  • 追求可用性:Kafka、 RocketMQ 、RabbitMQ

  • 追求可靠性:RabbitMQ、RocketMQ

  • 追求吞吐能力:RocketMQ、Kafka

  • 追求消息低延迟:RabbitMQ、Kafka

9.2 部署和概述

9.2.1 RabbitMQ部署

RabbitMQ是基于Erlang语言开发的开源消息通信中间件,官网地址:https://www.rabbitmq.com/

1) 单机部署

我们在Centos7虚拟机中使用Docker来安装。

下载镜像

  1. 方法1:在线拉取镜像
1
docker pull rabbitmq:3-management
  1. 从本地加载
1
docker load -i mq.tar

image-20220103105746637

安装MQ

执行下面的命令来运行MQ容器:

1
2
3
4
5
6
7
8
9
docker run \
-e RABBITMQ_DEFAULT_USER=xxxx \
-e RABBITMQ_DEFAULT_PASS=xxxxx \
--name mq \ # 容器名称
--hostname mq1 \ # mq主机名,做集群部署需要用到,单机部署可不用
-p 15672:15672 \ # 可视化管理界面的端口
-p 5672:5672 \ # 消息通信的端口
-d \
rabbitmq:3-management

image-20220103110156224

进入可视化管理界面:

image-20220103110355645

image-20220103110448616

2) 集群部署

在RabbitMQ的官方文档中,讲述了两种集群的配置方式:

  • 普通模式:普通模式集群不进行数据同步,每个MQ都有自己的队列、数据信息(其它元数据信息如交换机等会同步)。例如我们有2个MQ:mq1,和mq2,如果你的消息在mq1,而你连接到了mq2,那么mq2会去mq1拉取消息,然后返回给你。如果mq1宕机,消息就会丢失。
  • 镜像模式:与普通模式不同,队列会在各个mq的镜像节点之间同步,因此你连接到任何一个镜像节点,均可获取到消息。而且如果一个节点宕机,并不会导致数据丢失。不过,这种方式增加了数据同步的带宽消耗。

步骤

首先,我们需要让3台MQ互相知道对方的存在。

分别在3台机器中,设置 /etc/hosts文件,添加如下内容:

1
2
3
192.168.150.101 mq1
192.168.150.102 mq2
192.168.150.103 mq3

并在每台机器上测试,是否可以ping通对方即可。

3) MQ基本结构

image-20220103110844350

RabbitMQ中的一些角色:

  • publisher:生产者
  • consumer:消费者
  • exchange:交换机,负责消息路由
  • queue:队列,存储消息
  • virtualHost:虚拟主机(逻辑分组),隔离不同租户的exchange、queue、消息的隔离

9.2.2 RabbitMQ消息模型

RabbitMQ官方提供了6个不同的Demo示例,对应了不同的消息模型。

  • 基本消息队列(BasicQueue)

image-20220103111431627

  • 工作消息队列(WorkQueue)

image-20220103111632855

  • 发布订阅(publish,subscribe),又根据交换机类型不同分为4种:

    • 广播Fanout exchange:

      image-20220103111923771

    • 路由Direct Exchange:

      image-20220103111951543

    • 主题Topic Exchange:

      image-20220103112018982

    • RPC

      image-20220103112044686

9.3 入门案例

官方的HelloWorld是基于最基础的消息队列模型来实现的,只包括三个角色:

  • publisher:消息发布者,将消息发送到队列queue

  • queue:消息队列,负责接受并缓存消息

  • consumer:订阅队列,处理队列中的消息

image-20220103112240362

9.3.1 导入demo工程

课前资料提供了一个Demo工程,mq-demo,导入后可以看到结构如下:

image-20220105191653006

包括三部分:

  • mq-demo:父工程,管理项目依赖
  • publisher:消息的发送者
  • consumer:消息的消费者

9.3.2 发布者流程

  • 建立连接
  • 创建Channel
  • 声明队列
  • 发送消息
  • 关闭连接和channel

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class PublisherTest {
@Test
public void testSendMessage() throws IOException, TimeoutException {
// 1.建立连接
ConnectionFactory factory = new ConnectionFactory();
// 1.1.设置连接参数,分别是:主机名、端口号、vhost、用户名、密码
factory.setHost("XXX.XXX.XXX.XX");
factory.setPort(5672);
factory.setVirtualHost("/");
factory.setUsername("XXXXXX");
factory.setPassword("XXXXXXXX");
// 1.2.建立连接
Connection connection = factory.newConnection();

// 2.创建通道Channel
Channel channel = connection.createChannel();

// 3.创建队列
String queueName = "simple.queue";
channel.queueDeclare(queueName, false, false, false, null);

// 4.发送消息
String message = "hello, rabbitmq!";
channel.basicPublish("", queueName, null, message.getBytes());
System.out.println("发送消息成功:【" + message + "】");

// 5.关闭通道和连接
channel.close();
connection.close();
}
}

执行流程

  1. 建立连接

image-20220105191122881

  1. 创建通道Channel

image-20220105191202463

  1. 创建队列simple.queue

image-20220105191252370

  1. 发送消息和在可视化界面查看消息

image-20220105191420118

image-20220105191518919

  1. 控制台结果:

image-20220105192113310

9.3.3 接收者流程

  • 建立连接
  • 创建Channel
  • 声明队列(保险措施)
  • 订阅消息

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class ConsumerTest {

public static void main(String[] args) throws IOException, TimeoutException {
// 1.建立连接
ConnectionFactory factory = new ConnectionFactory();
// 1.1.设置连接参数,分别是:主机名、端口号、vhost、用户名、密码
factory.setHost("XXX.XXX.XXX.XXX");
factory.setPort(5672);
factory.setVirtualHost("/");
factory.setUsername("XXX");
factory.setPassword("XXXXXXX");
// 1.2.建立连接
Connection connection = factory.newConnection();

// 2.创建通道Channel
Channel channel = connection.createChannel();

// 3.创建队列
String queueName = "simple.queue";
channel.queueDeclare(queueName, false, false, false, null);

// 4.订阅消息
channel.basicConsume(queueName, true, new DefaultConsumer(channel){
@Override
public void handleDelivery(String consumerTag, Envelope envelope,
AMQP.BasicProperties properties, byte[] body) throws IOException {
// 5.处理消息
String message = new String(body);
System.out.println("接收到消息:【" + message + "】");
}
});
// 注意这条语句会先打印
System.out.println("等待接收消息。。。。");
}
}

执行流程

  1. 建立连接

image-20220105192341327

  1. 创建通道channel

image-20220105192424749

  1. 声明队列(保险措施)
  2. 订阅消息

  3. 控制台执行结果:

image-20220105192822917

9.3.4 流程总结

  • 基本消息队列的消息发送流程:

    • 建立connection
    • 创建channel
    • 利用channel声明队列
    • 利用channel向队列发送消息
  • 基本消息队列的消息接收流程:

    • 建立connection
    • 创建channel
    • 利用channel声明队列
    • 定义consumer的消费行为handleDelivery()
    • 利用channel将消费者与队列绑定

9.4 SpringAMQP

9.4.1 概述

SpringAMQP是基于RabbitMQ封装的一套模板,并且还利用SpringBoot对其实现了自动装配,使用起来非常方便。

SpringAMQP的官方地址:https://spring.io/projects/spring-amqp

image-20220105193611137

image-20220105193619116

SpringAMQP提供了三个功能:

  • 自动声明队列、交换机及其绑定关系
  • 基于注解的监听器模式,异步接收消息
  • 封装了RabbitTemplate工具,用于发送消息

9.4.2 Basic Queue 简单队列模型

在父工程mq-demo中引入SpringAMQP依赖:

1
2
3
4
5
<!--AMQP依赖,包含RabbitMQ-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
1) 消息发送
  1. 首先配置MQ地址,在publisher服务的application.yml中添加配置:
1
2
3
4
5
6
7
spring:
rabbitmq:
host: XXX.XXX.XXX.XXX # rabbitmq服务器地址
port: 5672 # rabbitmq服务器端口
virtual-host: / # 虚拟主机
username: XXXXXX # 用户名
password: XXXXXX # 密码
  1. 在publisher服务中编写测试类SpringAmqpTest,并利用RabbitTemplate实现消息发送:

image-20220105194741867

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@RunWith(SpringRunner.class)
@SpringBootTest
public class SpringAmqpTest {

@Autowired
private RabbitTemplate rabbitTemplate;

@Test
public void testSendMessage2SimpleQueue() {
// 队列名称
String queueName = "simple.queue";
// 发送的消息
String message = "Hello, SpringAMQP!";
// 发送消息
rabbitTemplate.convertAndSend(queueName, message);
}
}

执行结果:

image-20220105194828124

image-20220105194841743

2) 接收消息
  1. 首先配置MQ地址,在consumer服务的application.yml中添加配置(同publisher)。

  2. 在consumer服务的cn.itcast.mq.listener包中新建一个类SpringRabbitListener,代码如下:

image-20220105195557109

1
2
3
4
5
6
7
8
public class SpringRabbitListener {

@RabbitListener(queues = "simple.queue")
public void listenSimpleQueue(String msg) {
System.out.println("消费者接收到simple.queue的消息:" + msg);
}

}
  1. 启动consumer服务,即可接收到消息

image-20220105195709728

simple.queue的状态:

image-20220105195729618

可见消息已被取走,消息队列为空。

9.4.3 WorkQueue 工作队列模型

Work queues,也被称为(Task queues),任务模型。简单来说就是让多个消费者绑定到一个队列,共同消费队列中的消息

image-20220106125156558

当消息处理比较耗时的时候,可能生产消息的速度会远远大于消息的消费速度。长此以往,消息就会堆积越来越多,无法及时处理。

此时就可以使用work 模型,多个消费者共同处理消息处理,速度就能大大提高了。

1) 发送和接收
  • 发送

这次我们循环发送,模拟大量消息堆积现象。

在publisher服务中的SpringAmqpTest类中添加一个测试方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* workQueue
* 向队列中不停发送消息,模拟消息堆积。
*/
@Test
public void testSendMessage2WorkQueue() throws InterruptedException {
String queueName = "simple.queue";
String message = "Hello, message!";
for (int i = 0; i < 50; i++) {
rabbitTemplate.convertAndSend(queueName, message + i);
// 每0.02s休息一下,总共1s发完
TimeUnit.MILLISECONDS.sleep(20);
}
}
  • 接收

要模拟多个消费者绑定同一个队列,我们在consumer服务的SpringRabbitListener中添加2个新的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@RabbitListener(queues = "simple.queue")
public void listenWorkQueue1(String msg) throws InterruptedException {
System.out.println("消费者1^^^接收到simple.queue的消息:[" + msg + "]" + LocalTime.now());
// 每0.02s休息一下,单独接收时1s接收完毕
TimeUnit.MILLISECONDS.sleep(20);
}

@RabbitListener(queues = "simple.queue")
public void listenWorkQueue2(String msg) throws InterruptedException {
System.err.println("消费者2...接收到simple.queue的消息:[" + msg + "]" + LocalTime.now());
// 模拟更慢的接收方
// 每0.2s休息一下,单独接收时10s接收完毕
TimeUnit.MILLISECONDS.sleep(200);
}
  • 测试:打印结果

image-20220106125823237

image-20220106125844437

总耗时:5s

启动ConsumerApplication后,在执行publisher服务中刚刚编写的发送测试方法testWorkQueue。

可以看到消费者1很快完成了自己的25条消息。消费者2却在缓慢的处理自己的25条消息。也就是说消息是平均分配给每个消费者,并没有考虑到消费者的处理能力。这样显然是有问题的。

2) 能者多劳

消费预取限制:在spring中有一个简单的配置,可以解决这个问题。我们修改consumer服务的application.yml文件,添加配置,设置preFetch这个值,可以控制预取消息的上限

1
2
3
4
5
spring:
rabbitmq:
listener:
simple:
prefetch: 1 # 每次只能获取一条消息,处理完成才能获取下一个消息
  • 测试

image-20220106130152823

image-20220106130205961

总耗时:3s,这样处理使得消息大部分交由处理得快的消费者consumer1来消费。

9.4.4 发布/订阅模型

发布订阅的模型如图:

image-20220106161901128

可以看到,在订阅模型中,多了一个exchange角色,而且过程略有变化:

  • Publisher:生产者,也就是要发送消息的程序,但是不再发送到队列中,而是发给exchange(交换机)
  • Exchange:交换机。一方面,接收生产者发送的消息。另一方面,知道如何处理消息,例如递交给某个特别队列、递交给所有队列、或是将消息丢弃。到底如何操作,取决于Exchange的类型。Exchange有以下3种类型:
    • Fanout:广播,将消息交给所有绑定到交换机的队列
    • Direct:定向,把消息交给符合指定routing key 的队列
    • Topic:通配符,把消息交给符合routing pattern(路由模式) 的队列
  • Consumer:消费者,与以前一样,订阅队列,没有变化
  • Queue:消息队列也与以前一样,接收消息、缓存消息。

Exchange(交换机)只负责转发消息,不具备存储消息的能力,因此如果没有任何队列与Exchange绑定,或者没有符合路由规则的队列,那么消息会丢失!

9.4.5 Fanout

Fanout,英文翻译是扇出,我觉得在MQ中叫广播更合适。

image-20220106162037179

在广播模式下,消息发送流程是这样的:

  • 可以有多个队列queue
  • 每个队列都要绑定到Exchange(交换机)
  • 生产者发送的消息,只能发送到交换机,交换机来决定要发给哪个队列,生产者无法决定
  • 交换机把消息发送给绑定过的所有队列
  • 订阅队列的消费者都能拿到消息

我们的计划是这样的:

  • 创建一个交换机 itcast.fanout,类型是Fanout
  • 创建两个队列fanout.queue1和fanout.queue2,绑定到交换机itcast.fanout

image-20220106163618117

1) 声明队列和交换机

Spring提供了一个接口Exchange,来表示所有不同类型的交换机:

image-20220106163659494

在consumer中创建一个类,声明队列和交换机:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@Configuration
public class FanoutConfig {
// 声明交换机
@Bean
public FanoutExchange fanoutExchange() {
return new FanoutExchange("itcast.fanout");
}

// 声明队列1
// fanout.queue1
@Bean
public Queue fanoutQueue1(){
return new Queue("fanout.queue1");
}

// 绑定队列1到交换机
@Bean
public Binding fanoutBinding1(Queue fanoutQueue1, FanoutExchange fanoutExchange) {
return BindingBuilder
.bind(fanoutQueue1)
.to(fanoutExchange);
}

// 声明队列2
// fanout.queue2
@Bean
public Queue fanoutQueue2(){
return new Queue("fanout.queue2");
}

// 绑定队列2到交换机
@Bean
public Binding fanoutBinding2(Queue fanoutQueue2, FanoutExchange fanoutExchange) {
return BindingBuilder
.bind(fanoutQueue2)
.to(fanoutExchange);
}
}

启动结果:

  • 新增了一个交换机itcast.fanout

image-20220106162949756

  • 新增了两个队列

image-20220106163003646

  • 查看绑定关系

image-20220106163058420

2) 消息发送

在publisher服务的SpringAmqpTest类中添加测试方法:

1
2
3
4
5
6
7
8
@Test
public void testSendFanoutExchange() {
// 交换机名称
String exchangeName = "itcast.fanout";
String message = "Hello, everyone!";
// 共有三个参数,中间的参数暂时省略
rabbitTemplate.convertAndSend(exchangeName, "", message);
}
3) 消息接收

在consumer服务的SpringRabbitListener中添加两个方法,作为消费者:

1
2
3
4
5
6
7
8
9
10
// 监听两个队列
@RabbitListener(queues = "fanout.queue1")
public void listenFanoutQueue1(String msg) {
System.out.println("消费者接收到fanout.queue1的消息:[" + msg + "]");
}

@RabbitListener(queues = "fanout.queue2")
public void listenFanoutQueue2(String msg) {
System.out.println("消费者接收到fanout.queue2的消息:[" + msg + "]");
}

执行结果:

image-20220106164038289

小结

交换机的作用是什么?

  • 接收publisher发送的消息
  • 将消息按照规则路由到与之绑定的队列
  • 不能缓存消息,路由失败,消息丢失
  • FanoutExchange的会将消息路由到每个绑定的队列

声明队列、交换机、绑定关系的Bean是什么?

  • Queue
  • FanoutExchange
  • Binding

9.4.6 Direct

在Fanout模式中,一条消息,会被所有订阅的队列都消费。但是,在某些场景下,我们希望不同的消息被不同的队列消费。这时就要用到Direct类型的Exchange。

image-20220106170613930

在Direct模型下:

  • 队列与交换机的绑定,不能是任意绑定了,而是要指定一个RoutingKey(路由key)
  • 消息的发送方在 向 Exchange发送消息时,也必须指定消息的 RoutingKey
  • Exchange不再把消息交给每一个绑定的队列,而是根据消息的Routing Key进行判断,只有队列的Routingkey与消息的 Routing key完全一致,才会接收到消息

案例需求如下

  1. 利用@RabbitListener声明Exchange、Queue、RoutingKey

  2. 在consumer服务中,编写两个消费者方法,分别监听direct.queue1和direct.queue2

  3. 在publisher中编写测试方法,向itcast. direct发送消息

image-20220106170641725

1) 基于注解声明队列和交换机

基于@Bean的方式声明队列和交换机比较麻烦,Spring还提供了基于注解方式来声明。

在consumer的SpringRabbitListener中添加两个消费者,同时基于注解来声明队列和交换机:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 基于注解,type默认为direct可不写
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "direct.queue1"),
exchange = @Exchange(name = "itcast.direct", type = ExchangeTypes.DIRECT),
key = {"red", "blue"}
))
public void listenDirectQueue1(String msg) {
System.out.println("消费者接收到direct.queue1的消息:[" + msg + "]");
}


@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "direct.queue2"),
exchange = @Exchange(name = "itcast.direct", type = ExchangeTypes.DIRECT),
key = {"red" ,"yellow"}
))
public void listenDirectQueue2(String msg) {
System.out.println("消费者接收到direct.queue2的消息:[" + msg + "]");
}

执行结果:

  • 查看交换机

image-20220106170027328

  • 查看队列

image-20220106170042584

  • 绑定关系和routing key

image-20220106170253085

2) 消息发送

在publisher服务的SpringAmqpTest类中添加测试方法:

1
2
3
4
5
6
7
8
9
@Test
public void testSendDirectExchange() {
// 交换机名称
String exchangeName = "itcast.direct";
// 发送的消息
String message = "Hello, red!";
// 发送消息
rabbitTemplate.convertAndSend(exchangeName, "red", message);
}

执行结果:

image-20220106170858119

3) 总结

描述下Direct交换机与Fanout交换机的差异?

  • Fanout交换机将消息路由给每一个与之绑定的队列
  • Direct交换机根据RoutingKey判断路由给哪个队列
  • 如果多个队列具有相同的RoutingKey,则与Fanout功能类似

基于@RabbitListener注解声明队列和交换机有哪些常见注解?

  • @Queue
  • @Exchange

9.4.7 Topic

Topic类型的ExchangeDirect相比,都是可以根据RoutingKey把消息路由到不同的队列。只不过Topic类型Exchange可以让队列在绑定Routing key 的时候使用通配符!

Routingkey 一般都是有一个或多个单词组成,多个单词之间以”.”分割,例如: item.insert

通配符规则

  • #:匹配一个或多个词

  • *:匹配不多不少恰好1个词

举例:

  • item.#:能够匹配item.spu.insert 或者 item.spu

  • item.*:只能匹配item.spu

图示

image-20220107091501310

案例需求

  1. 并利用@RabbitListener声明Exchange、Queue、RoutingKey

  2. 在consumer服务中,编写两个消费者方法,分别监听topic.queue1和topic.queue2

  3. 在publisher中编写测试方法,向itcast. topic发送消息

image-20220107091531003

1) 消息接收

在consumer服务的SpringRabbitListener中添加方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 接收关于中国的消息
@RabbitListener(bindings = @QueueBinding(
value = @Queue("topic.queue1"),
exchange = @Exchange(name = "itcast.topic", type = ExchangeTypes.TOPIC),
key = "china.#"
))
public void listenTopicQueue1(String msg) {
System.out.println("消费者接收到topic.queue1的消息:[" + msg + "]");
}

// 接收关于新闻的消息
@RabbitListener(bindings = @QueueBinding(
value = @Queue("topic.queue2"),
exchange = @Exchange(name = "itcast.topic", type = ExchangeTypes.TOPIC),
key = "#.news"
))
public void listenTopicQueue2(String msg) {
System.out.println("消费者接收到topic.queue2的消息:[" + msg + "]");
}
  • 查看交换机

image-20220107091124839

  • 查看队列

image-20220107091234230

2) 发送消息
1
2
3
4
5
6
7
8
9
@Test
public void testSendTopicExchange() {
// 交换机名称
String exchangeName = "itcast.topic";
// 发送的消息
String message = "Hello, China!";
// 发送消息
rabbitTemplate.convertAndSend(exchangeName, "china.news", message);
}
1
2
3
4
5
6
7
8
9
@Test
public void testSendTopicExchange() {
// 交换机名称
String exchangeName = "itcast.topic";
// 发送的消息
String message = "Hello, weather!";
// 发送消息
rabbitTemplate.convertAndSend(exchangeName, "china.weather", message);
}

image-20220107091815944

9.4.8 消息转换器

在SpringAMQP的发送方法中,接收消息的类型是Object,也就是说我们可以发送任意对象类型的消息,SpringAMQP会帮我们序列化为字节后发送。

只不过,默认情况下Spring采用的序列化方式是JDK序列化。众所周知,JDK序列化存在下列问题:

  • 数据体积过大
  • 有安全漏洞
  • 可读性差
1) 测试默认转换器
  1. 通过@Bean方式声明一个队列object.queue:
1
2
3
4
@Bean
public Queue objectQueue() {
return new Queue("object.queue");
}
  1. 修改消息发送的代码,发送一个Map对象:
1
2
3
4
5
6
7
@Test
public void testSendObjectQueue() {
Map<String, Object> msg = new HashMap<>();
msg.put("name", "Mark");
msg.put("age", 21);
rabbitTemplate.convertAndSend("object.queue", msg);
}
  1. 发送结果:

image-20220107092512291

2) 配置JSON转换器

Spring对消息对象的处理是由org.springframework.amqp.support.converter.MessageConverter来处理的。而默认实现是SimpleMessageConverter,基于JDK的ObjectOutputStream完成序列化。

如果要修改只需要定义一个MessageConverter 类型的Bean即可。推荐用JSON方式序列化,步骤如下:

  1. 在publisher和consumer两个服务中都引入依赖:
1
2
3
4
5
<!--jackson依赖-->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
  1. 配置消息转换器MessageConverter:在各自的启动类中添加一个Bean即可:
1
2
3
4
@Bean
public MessageConverter messageConverter() {
return new Jackson2JsonMessageConverter();
}
  1. 发送方发送到队列:

image-20220107092913161

  1. 接收方接收到的消息:

image-20220107093212675

小结

  • SpringAMQP中消息的序列化和反序列化利用MessageConverter实现的,默认是JDK的序列化

  • 注意发送方与接收方必须使用相同的MessageConverter

10 ElasticSearch分布式搜索

10.1 了解ES

10.1.1 概述

1) ES的作用

elasticsearch是一款非常强大的开源搜索引擎,具备非常多强大功能,可以帮助我们从海量数据中快速找到需要的内容。

例如:

  • 在GitHub搜索代码:

image-20220109164552535

  • 在电商网站搜索商品:

image-20220109164612159

  • 在谷歌搜索答案

image-20220109164621482

2) ELK技术栈

elasticsearch结合kibana、Logstash、Beats,也就是elastic stack(ELK)。被广泛应用在日志数据分析、实时监控等领域。

而elasticsearch是elastic stack的核心,负责存储、搜索、分析数据。

image-20220109164720557

3) elasticsearch和lucene

elasticsearch底层是基于lucene来实现的。

Lucene是一个Java语言的搜索引擎类库,是Apache公司的顶级项目,由DougCutting于1999年研发。官网地址:https://lucene.apache.org/

image-20220109164755263

elasticsearch的发展历史:

  • 2004年Shay Banon基于Lucene开发了Compass
  • 2010年Shay Banon 重写了Compass,取名为Elasticsearch。

image-20220109164858038

官网地址: https://www.elastic.co/cn/。目前最新的版本是:7.12.1

相比与lucene,elasticsearch具备下列优势:

  • 支持分布式,可水平扩展

  • 提供Restful接口,可被任何语言调用

4) 为什么不是其他搜索技术?

目前比较知名的搜索引擎技术排名:

image-20220109164934590

虽然在早期,Apache Solr是最主要的搜索引擎技术,但随着发展elasticsearch已经渐渐超越了Solr,独占鳌头:

image-20220109164951598

总结

  1. 什么是elasticsearch?

一个开源的分布式搜索引擎,可以用来实现搜索、日志统计、分析、系统监控等功能

  1. 什么是elastic stack(ELK)?

是以elasticsearch为核心的技术栈,包括beats、Logstash、kibana、elasticsearch

  1. 什么是Lucene?

是Apache的开源搜索引擎类库,提供了搜索引擎的核心API

10.1.2 倒排索引

倒排索引的概念是基于MySQL这样的正向索引而言的。

1)正向索引

例如给下表(tb_goods)中的id创建索引:

image-20220109165833570

如果是根据id查询,那么直接走索引,查询速度非常快。

但如果是基于title做模糊查询,只能是逐行扫描数据,流程如下:

  1. 用户搜索数据,条件是title符合"%手机%"

  2. 逐行获取数据,比如id为1的数据

  3. 判断数据中的title是否符合用户搜索条件

  4. 如果符合则放入结果集,不符合则丢弃。回到步骤1

逐行扫描,也就是全表扫描,随着数据量增加,其查询效率也会越来越低。当数据量达到数百万时,就是一场灾难。

2) 倒排索引

倒排索引中有两个非常重要的概念:

  • 文档(Document):用来搜索的数据,其中的每一条数据就是一个文档。例如一个网页、一个商品信息
  • 词条(Term):对文档数据或用户搜索数据,利用某种算法分词,得到的具备含义的词语就是词条。例如:我是中国人,就可以分为:我、是、中国人、中国、国人这样的几个词条

创建倒排索引是对正向索引的一种特殊处理,流程如下:

  • 将每一个文档的数据利用算法分词,得到一个个词条
  • 创建表,每行数据包括词条、词条所在文档id、位置等信息
  • 因为词条唯一性,可以给词条创建索引,例如hash表结构索引

如图:

image-20220109165930231

倒排索引的搜索流程如下(以搜索”华为手机”为例):

  1. 用户输入条件"华为手机"进行搜索。

  2. 对用户输入内容分词,得到词条:华为手机

  3. 拿着词条在倒排索引中查找,可以得到包含词条的文档id:1、2、3。

  4. 拿着文档id到正向索引中查找具体文档。

image-20220109170024439

虽然要先查询倒排索引,再查询倒排索引,但是无论是词条、还是文档id都建立了索引,查询速度非常快!无需全表扫描。

3) 总结

那么为什么一个叫做正向索引,一个叫做倒排索引呢?

  • 正向索引是最传统的,根据id索引的方式。但根据词条查询时,必须先逐条获取每个文档,然后判断文档中是否包含所需要的词条,是根据文档找词条的过程

  • 倒排索引则相反,是先找到用户要搜索的词条,根据词条得到保护词条的文档的id,然后根据id获取文档。是根据词条找文档的过程

是不是恰好反过来了?

优缺点

正向索引

  • 优点:
    • 可以给多个字段创建索引
    • 根据索引字段搜索、排序速度非常快
  • 缺点:
    • 根据非索引字段,或者索引字段中的部分词条查找时,只能全表扫描。

倒排索引

  • 优点:
    • 根据词条搜索、模糊搜索时,速度非常快
  • 缺点:
    • 只能给词条创建索引,而不是字段
    • 无法根据字段做排序

10.1.3 es的一些概念

1) 文档和字段

elasticsearch是面向文档(Document)存储的,可以是数据库中的一条商品数据,一个订单信息。文档数据会被序列化为json格式后存储在elasticsearch中:

image-20220109171114169

而Json文档中往往包含很多的字段(Field)(id,title,price…),类似于数据库中的列。

2) 索引和映射

索引(Index),就是相同类型的文档的集合。

例如:

  • 所有用户文档,就可以组织在一起,称为用户的索引;
  • 所有商品的文档,可以组织在一起,称为商品的索引;
  • 所有订单的文档,可以组织在一起,称为订单的索引;

image-20220109171205085

因此,我们可以把索引当做是数据库中的表。

数据库的表会有约束信息,用来定义表的结构、字段的名称、类型等信息。因此,索引库中就有映射(mapping),是索引中文档的字段约束信息,类似表的结构约束。

3) MySQL和ES

我们统一的把mysql与elasticsearch的概念做一下对比:

MySQL Elasticsearch 说明
Table Index 索引(index),就是文档的集合,类似数据库的表(table)
Row Document 文档(Document),就是一条条的数据,类似数据库中的行(Row),文档都是JSON格式
Column Field 字段(Field),就是JSON文档中的字段,类似数据库中的列(Column)
Schema Mapping Mapping(映射)是索引中文档的约束,例如字段类型约束。类似数据库的表结构(Schema)
SQL DSL DSL是elasticsearch提供的JSON风格的请求语句,用来操作elasticsearch,实现CRUD

是不是说,我们学习了elasticsearch就不再需要mysql了呢?并不是如此,两者各自有自己的擅长之处:

  • Mysql:擅长事务类型操作,可以确保数据的安全和一致性

  • Elasticsearch:擅长海量数据的搜索、分析、计算

因此在企业中,往往是两者结合使用:

  • 对安全性要求较高的写操作,使用mysql实现
  • 对查询性能要求较高的搜索需求,使用elasticsearch实现
  • 两者再基于某种方式,实现数据的同步,保证一致性

image-20220109171313799

10.1.4 安装ES和Kibana

1) 部署ES

部署单点ES

  1. 创建网络

因为我们还需要部署kibana容器,因此需要让es和kibana容器互联。这里先创建一个网络:

1
docker network create es-net
  1. 加载镜像
1
2
# 导入数据
docker load -i es.tar

同理还有kibana的tar包也需要这样做。

image-20220110160155442

  1. 运行:运行docker命令,部署单点es:
1
2
3
4
5
6
7
8
9
10
11
docker run -d \
--name es \
-e "ES_JAVA_OPTS=-Xms512m -Xmx512m" \
-e "discovery.type=single-node" \
-v es-data:/usr/share/elasticsearch/data \
-v es-plugins:/usr/share/elasticsearch/plugins \
--privileged \
--network es-net \
-p 9200:9200 \
-p 9300:9300 \
elasticsearch:7.12.1

命令解释:

  • -e "cluster.name=es-docker-cluster":设置集群名称
  • -e "http.host=0.0.0.0":监听的地址,可以外网访问
  • -e "ES_JAVA_OPTS=-Xms512m -Xmx512m":内存大小
  • -e "discovery.type=single-node":非集群模式
  • -v es-data:/usr/share/elasticsearch/data:挂载逻辑卷,绑定es的数据目录
  • -v es-logs:/usr/share/elasticsearch/logs:挂载逻辑卷,绑定es的日志目录
  • -v es-plugins:/usr/share/elasticsearch/plugins:挂载逻辑卷,绑定es的插件目录
  • --privileged:授予逻辑卷访问权
  • --network es-net :加入一个名为es-net的网络中
  • -p 9200:9200:端口映射配置

image-20220110160440078

  1. 在浏览器中输入:http://XXXXXXX:9200 即可看到elasticsearch的响应结果:

image-20220110160525080

部署集群ES

部署es集群可以直接使用docker-compose来完成,不过要求你的Linux虚拟机至少有4G的内存空间

首先编写一个docker-compose文件,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
version: '2.2'
services:
es01:
image: docker.elastic.co/elasticsearch/elasticsearch:7.12.1
container_name: es01
environment:
- node.name=es01
- cluster.name=es-docker-cluster
- discovery.seed_hosts=es02,es03
- cluster.initial_master_nodes=es01,es02,es03
- bootstrap.memory_lock=true
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
ulimits:
memlock:
soft: -1
hard: -1
volumes:
- data01:/usr/share/elasticsearch/data
ports:
- 9200:9200
networks:
- elastic
es02:
image: docker.elastic.co/elasticsearch/elasticsearch:7.12.1
container_name: es02
environment:
- node.name=es02
- cluster.name=es-docker-cluster
- discovery.seed_hosts=es01,es03
- cluster.initial_master_nodes=es01,es02,es03
- bootstrap.memory_lock=true
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
ulimits:
memlock:
soft: -1
hard: -1
volumes:
- data02:/usr/share/elasticsearch/data
networks:
- elastic
es03:
image: docker.elastic.co/elasticsearch/elasticsearch:7.12.1
container_name: es03
environment:
- node.name=es03
- cluster.name=es-docker-cluster
- discovery.seed_hosts=es01,es02
- cluster.initial_master_nodes=es01,es02,es03
- bootstrap.memory_lock=true
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
ulimits:
memlock:
soft: -1
hard: -1
volumes:
- data03:/usr/share/elasticsearch/data
networks:
- elastic

volumes:
data01:
driver: local
data02:
driver: local
data03:
driver: local

networks:
elastic:
driver: bridge

执行:

1
docker-compose up
2) 部署kibana

kibana可以给我们提供一个elasticsearch的可视化界面,便于我们学习。

  1. 加载镜像

image-20220110160836061

  1. 运行docker命令,部署kibana
1
2
3
4
5
6
docker run -d \
--name kibana \
-e ELASTICSEARCH_HOSTS=http://es:9200 \
--network=es-net \
-p 5601:5601 \
kibana:7.12.1
  • --network es-net :加入一个名为es-net的网络中,与elasticsearch在同一个网络中
  • -e ELASTICSEARCH_HOSTS=http://es:9200":设置elasticsearch的地址,因为kibana已经与elasticsearch在一个网络,因此可以用容器名直接访问elasticsearch
  • -p 5601:5601:端口映射配置

image-20220110161023542

kibana启动一般比较慢,需要多等待一会,可以通过命令:

1
docker logs -f kibana

查看运行日志,当查看到下面的日志,说明成功:

image-20220110161241042

此时,在浏览器输入地址访问:http://XXXXXXXXXX:5601,即可看到结果

image-20220110161300702

  1. kibana中提供了一个DevTools界面:

image-20220110161342935

image-20220110161407875

这个界面中可以编写DSL来操作elasticsearch。并且对DSL语句有自动补全功能。

3) 部署IK分词器

测试ES默认分词器

es在创建倒排索引时需要对文档分词;在搜索时,需要对用户输入内容分词。但默认的分词规则对中文处理并不友好。

我们在kibana的DevTools中测试:

1
2
3
4
5
POST /_analyze
{
  "analyzer""standard",
  "text""我来自黑龙江省哈尔滨市!!"
}

语法说明:

  • POST:请求方式

  • /_analyze:请求路径,这里省略了http://XXXXXXX:9200,由kibana帮我们补充

  • 请求参数,json风格:

    • analyzer:分词器类型,这里是默认的standard分词器
    • text:要分词的内容

执行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
{
"tokens" : [
{
"token" : "我",
"start_offset" : 0,
"end_offset" : 1,
"type" : "<IDEOGRAPHIC>",
"position" : 0
},
{
"token" : "来",
"start_offset" : 1,
"end_offset" : 2,
"type" : "<IDEOGRAPHIC>",
"position" : 1
},
{
"token" : "自",
"start_offset" : 2,
"end_offset" : 3,
"type" : "<IDEOGRAPHIC>",
"position" : 2
},
{
"token" : "黑",
"start_offset" : 3,
"end_offset" : 4,
"type" : "<IDEOGRAPHIC>",
"position" : 3
},
{
"token" : "龙",
"start_offset" : 4,
"end_offset" : 5,
"type" : "<IDEOGRAPHIC>",
"position" : 4
},
{
"token" : "江",
"start_offset" : 5,
"end_offset" : 6,
"type" : "<IDEOGRAPHIC>",
"position" : 5
},
{
"token" : "省",
"start_offset" : 6,
"end_offset" : 7,
"type" : "<IDEOGRAPHIC>",
"position" : 6
},
{
"token" : "哈",
"start_offset" : 7,
"end_offset" : 8,
"type" : "<IDEOGRAPHIC>",
"position" : 7
},
{
"token" : "尔",
"start_offset" : 8,
"end_offset" : 9,
"type" : "<IDEOGRAPHIC>",
"position" : 8
},
{
"token" : "滨",
"start_offset" : 9,
"end_offset" : 10,
"type" : "<IDEOGRAPHIC>",
"position" : 9
},
{
"token" : "市",
"start_offset" : 10,
"end_offset" : 11,
"type" : "<IDEOGRAPHIC>",
"position" : 10
}
]
}

推荐使用离线部署

处理中文分词,一般会使用IK分词器。https://github.com/medcl/elasticsearch-analysis-ik

  1. 查看数据卷目录

安装插件需要知道elasticsearch的plugins目录位置,而我们用了数据卷挂载,因此需要查看elasticsearch的数据卷目录,通过下面命令查看:

1
docker volume inspect es-plugins
1
2
3
4
5
6
7
8
9
10
11
[
{
"CreatedAt": "2022-01-10T16:04:08+08:00",
"Driver": "local",
"Labels": null,
"Mountpoint": "/var/lib/docker/volumes/es-plugins/_data",
"Name": "es-plugins",
"Options": null,
"Scope": "local"
}
]

说明plugins目录被挂载到了:/var/lib/docker/volumes/es-plugins/_data这个目录中。

  1. 上传到es容器的插件数据卷中

需要把课前资料中的ik分词器解压缩,重命名为ik:

image-20220110163151579

image-20220110163342937

  1. 重启容器
1
2
# 重启容器
docker restart es
1
2
# 查看es日志
docker logs -f es
  1. 测试

IK分词器包含两种模式:

  • ik_smart:最少切分
  • ik_max_word:最细切分
1
2
3
4
5
GET /_analyze
{
"analyzer": "ik_smart",
"text": "我来自黑龙江省哈尔滨市!!"
}

执行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
{
"tokens" : [
{
"token" : "我",
"start_offset" : 0,
"end_offset" : 1,
"type" : "CN_CHAR",
"position" : 0
},
{
"token" : "来自",
"start_offset" : 1,
"end_offset" : 3,
"type" : "CN_WORD",
"position" : 1
},
{
"token" : "黑龙江省",
"start_offset" : 3,
"end_offset" : 7,
"type" : "CN_WORD",
"position" : 2
},
{
"token" : "哈尔滨市",
"start_offset" : 7,
"end_offset" : 11,
"type" : "CN_WORD",
"position" : 3
}
]
}

扩展词词典

随着互联网的发展,“造词运动”也越发的频繁。出现了很多新的词语,在原有的词汇列表中并不存在。比如:“奥力给”,“传智播客” 等。

所以我们的词汇也需要不断的更新,IK分词器提供了扩展词汇的功能。

  1. 打开IK分词器config目录:

image-20220110164446806

  1. 在IKAnalyzer.cfg.xml配置文件内容添加:
1
2
3
4
5
6
7
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE properties SYSTEM "http://java.sun.com/dtd/properties.dtd">
<properties>
<comment>IK Analyzer 扩展配置</comment>
<!--用户可以在这里配置自己的扩展字典 *** 添加扩展词典-->
<entry key="ext_dict">ext.dic</entry>
</properties>
  1. 新建一个 ext.dic,可以参考config目录下复制一个配置文件进行修改
1
2
传智播客
奥力给
  1. 重启elasticsearch
1
docker restart es
  1. 测试效果:
1
2
3
4
5
GET /_analyze
{
"analyzer": "ik_max_word",
"text": "传智播客Java就业超过90%,奥力给!"
}

注意:当前文件的编码必须是 UTF-8 格式,严禁使用Windows记事本编辑

停用词词典

在互联网项目中,在网络间传输的速度很快,所以很多语言是不允许在网络上传递的,如:关于宗教、政治等敏感词语,那么我们在搜索时也应该忽略当前词汇。

IK分词器也提供了强大的停用词功能,让我们在索引时就直接忽略当前的停用词汇表中的内容。

  1. IKAnalyzer.cfg.xml配置文件内容添加:
1
2
3
4
5
6
7
8
9
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE properties SYSTEM "http://java.sun.com/dtd/properties.dtd">
<properties>
<comment>IK Analyzer 扩展配置</comment>
<!--用户可以在这里配置自己的扩展字典-->
<entry key="ext_dict">ext.dic</entry>
<!--用户可以在这里配置自己的扩展停止词字典 *** 添加停用词词典-->
<entry key="ext_stopwords">stopword.dic</entry>
</properties>
  1. 在 stopword.dic 添加停用词
1
基督教
  1. 重启elasticsearch,测试效果…

10.2 索引库操作

索引库就类似数据库表,mapping映射就类似表的结构。

我们要向es中存储数据,必须先创建“库”和“表”。

10.2.1 mapping映射属性

mapping是对索引库中文档的约束,常见的mapping属性包括:

  • type:字段数据类型,常见的简单类型有:
    • 字符串:text(可分词的文本)、keyword(精确值,例如:品牌、国家、ip地址)
    • 数值:long、integer、short、byte、double、float、
    • 布尔:boolean
    • 日期:date
    • 对象:object
  • index:是否创建索引,默认为true
  • analyzer:使用哪种分词器
  • properties:该字段的子字段

例如下面的json文档:

1
2
3
4
5
6
7
8
9
10
11
12
{
    "age"21,
    "weight"52.1,
    "isMarried"false,
    "info""黑马程序员Java讲师",
"email""zy@itcast.cn",
"score": [99.1, 99.5, 98.9],
    "name": {
        "firstName""云",
        "lastName""赵"
    }
}

对应的每个字段映射(mapping):

  • age:类型为 integer;参与搜索,因此需要index为true;无需分词器
  • weight:类型为float;参与搜索,因此需要index为true;无需分词器
  • isMarried:类型为boolean;参与搜索,因此需要index为true;无需分词器
  • info:类型为字符串,需要分词,因此是text;参与搜索,因此需要index为true;分词器可以用ik_smart
  • email:类型为字符串,但是不需要分词,因此是keyword;不参与搜索,因此需要index为false;无需分词器
  • score:虽然是数组,但是我们只看元素的类型,类型为float;参与搜索,因此需要index为true;无需分词器
  • name:类型为object,需要定义多个子属性
    • name.firstName;类型为字符串,但是不需要分词,因此是keyword;参与搜索,因此需要index为true;无需分词器
    • name.lastName;类型为字符串,但是不需要分词,因此是keyword;参与搜索,因此需要index为true;无需分词器

10.2.2 索引库的CRUD

这里我们统一使用Kibana编写DSL的方式来演示。

1) 创建索引库和映射

基本语法

  • 请求方式:PUT
  • 请求路径:/索引库名,可以自定义
  • 请求参数:mapping映射
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
PUT /索引库名称
{
  "mappings": {
    "properties": {
      "字段名":{
        "type""text",
        "analyzer""ik_smart"
      },
      "字段名2":{
        "type""keyword",
        "index""false"
      },
      "字段名3":{
"type": "object",
        "properties": {
          "子字段": {
            "type""keyword"
          }
        }
      },
// ...略
    }
  }
}

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
PUT /hongyi
{
"mappings": {
"properties": {
"info":{
"type": "text",
"analyzer": "ik_smart"
},
"email":{
"type": "keyword",
"index": false
},
"name":{
"type": "object",
"properties": {
"firstName": {
"type": "keyword"
},
"lastName": {
"type": "keyword"
}
}
}
}
}
}

执行结果:

1
2
3
4
5
{
"acknowledged" : true,
"shards_acknowledged" : true,
"index" : "hongyi"
}
2) 查询索引库

基本语法

  • 请求方式:GET

  • 请求路径:/索引库名

  • 请求参数:无

1
GET /索引库名

示例

1
GET /hongyi

返回结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
{
"hongyi" : {
"aliases" : { },
"mappings" : {
"properties" : {
"email" : {
"type" : "keyword",
"index" : false
},
"info" : {
"type" : "text",
"analyzer" : "ik_smart"
},
"name" : {
"properties" : {
"firstName" : {
"type" : "keyword"
},
"lastName" : {
"type" : "keyword"
}
}
}
}
},
"settings" : {
"index" : {
"routing" : {
"allocation" : {
"include" : {
"_tier_preference" : "data_content"
}
}
},
"number_of_shards" : "1",
"provided_name" : "hongyi",
"creation_date" : "1641969194702",
"number_of_replicas" : "1",
"uuid" : "qDVR3_mASwyYvD2vWtChuw",
"version" : {
"created" : "7120199"
}
}
}
}
}
3) 删除索引库

语法

  • 请求方式:DELETE

  • 请求路径:/索引库名

  • 请求参数:无

示例

1
DELETE /hongyi

返回结果:

1
2
3
{
"ackonwledged": true
}
4) 修改索引库

倒排索引结构虽然不复杂,但是一旦数据结构改变(比如改变了分词器),就需要重新创建倒排索引,这简直是灾难。因此索引库一旦创建,无法修改mapping

虽然无法修改mapping中已有的字段,但是却允许添加新的字段到mapping中,因为不会对倒排索引产生影响。

语法说明

  • 请求方式:DELETE

  • 请求路径:/索引库名/_mapping

  • 请求参数:无

1
2
3
4
5
6
7
8
PUT /索引库名/_mapping
{
  "properties": {
    "新字段名":{
      "type""integer"
    }
  }
}

示例

1
2
3
4
5
6
7
8
PUT /hongyi/_mapping
{
"properties": {
"age": {
"type": "integer"
}
}
}

返回结果:

1
2
3
{
"ackonwledged": true
}

image-20220112144449137

如果此时要修改age字段的类型为long

1
2
3
4
5
6
7
8
PUT /hongyi/_mapping
{
"properties": {
"age": {
"type": "long"
}
}
}

发现报错,返回结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"error" : {
"root_cause" : [
{
"type" : "illegal_argument_exception",
"reason" : "mapper [age] cannot be changed from type [integer] to [long]"
}
],
"type" : "illegal_argument_exception",
// 不能修改字段类型
"reason" : "mapper [age] cannot be changed from type [integer] to [long]"
},
"status" : 400
}

10.3 文档操作

10.3.1 新增文档

语法

1
2
3
4
5
6
7
8
9
10
11
// 文档id需要自己指定
POST /索引库名/_doc/文档id
{
    "字段1""值1",
    "字段2""值2",
    "字段3": {
        "子属性1""值3",
        "子属性2""值4"
    },
// ...
}

示例

1
2
3
4
5
6
7
8
9
10
POST /hongyi/_doc/1
{
    "info""黑马程序员Java讲师",
"age": 30,
    "email""zy@itcast.cn",
    "name": {
        "firstName""云",
        "lastName""赵"
    }
}

执行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"_index" : "hongyi",
"_type" : "_doc",
"_id" : "1",
"_version" : 1,
"result" : "created", // 创建成功
"_shards" : {
"total" : 2,
"successful" : 1,
"failed" : 0
},
"_seq_no" : 0,
"_primary_term" : 1
}

10.3.2 查询文档

根据rest风格,新增是post,查询应该是get,不过查询一般都需要条件,这里我们把文档id带上。

语法

1
GET /索引库名称/_doc/{id}

示例

1
GET /hongyi/_doc/1

返回结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
"_index" : "hongyi",
"_type" : "_doc",
"_id" : "1",
"_version" : 1,
"_seq_no" : 0,
"_primary_term" : 1,
"found" : true,
"_source" : {
"info" : "黑马程序员Java讲师",
"age" : 30,
"email" : "zy@itcast.cn",
"name" : {
"firstName" : "云",
"lastName" : "赵"
}
}
}

10.3.3 删除文档

删除使用DELETE请求,同样,需要根据id进行删除。

语法

1
DELETE /索引库名/_doc/id值

示例

1
DELETE /hongyi/_doc/1

返回结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"_index" : "hongyi",
"_type" : "_doc",
"_id" : "1",
"_version" : 2, // kibana具有版本控制功能,这里的版本已经变为2
"result" : "deleted",
"_shards" : {
"total" : 2,
"successful" : 1,
"failed" : 0
},
"_seq_no" : 1,
"_primary_term" : 1
}

10.3.4 修改文档

修改有两种方式:

  • 全量修改:直接覆盖原来的文档
  • 增量修改:修改文档中的部分字段
1) 全量修改

全量修改是覆盖原来的文档,其本质是:

  • 根据指定的id删除文档
  • 新增一个相同id的文档

注意:如果根据id删除时,id不存在,第二步的新增也会执行,也就从修改变成了新增操作了。

语法

1
2
3
4
5
6
7
// 请求方式为PUT
PUT /索引库名/_doc/文档id
{
    "字段1""值1",
    "字段2""值2",
// ... 略
}
2) 增量修改

增量修改是只修改指定id匹配的文档中的部分字段

语法

1
2
3
4
5
6
7
// 注意参数是doc,路径为_update
POST /{索引库名}/_update/文档id
{
    "doc": {
"字段名""新的值",
}
}

示例

1
2
3
4
5
6
POST /hongyi/_update/1
{
"doc": {
"age": 50
}
}

执行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"_index" : "hongyi",
"_type" : "_doc",
"_id" : "1",
"_version" : 4,
"result" : "updated",
"_shards" : {
"total" : 2,
"successful" : 1,
"failed" : 0
},
"_seq_no" : 3,
"_primary_term" : 1
}

image-20220112150839806

10.4 RestAPI

ES官方提供了各种不同语言的客户端,用来操作ES。这些客户端的本质就是组装DSL语句,通过http请求发送给ES。官方文档地址:https://www.elastic.co/guide/en/elasticsearch/client/index.html

其中的Java Rest Client又包括两种:

  • Java Low Level Rest Client
  • Java High Level Rest Client

image-20220112151127951

我们学习的是Java HighLevel Rest Client客户端API

10.4.1 导入demo工程

1) 导入数据

首先导入课前资料提供的数据库数据:

数据结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
CREATE TABLE `tb_hotel` (
  `id` bigint(20) NOT NULL COMMENT '酒店id',
  `name` varchar(255) NOT NULL COMMENT '酒店名称;例:7天酒店',
  `address` varchar(255) NOT NULL COMMENT '酒店地址;例:航头路',
  `price` int(10) NOT NULL COMMENT '酒店价格;例:329',
  `score` int(2) NOT NULL COMMENT '酒店评分;例:45,就是4.5分',
  `brand` varchar(32) NOT NULL COMMENT '酒店品牌;例:如家',
  `city` varchar(32) NOT NULL COMMENT '所在城市;例:上海',
  `star_name` varchar(16) DEFAULT NULL COMMENT '酒店星级,从低到高分别是:1星到5星,1钻到5钻',
  `business` varchar(255) DEFAULT NULL COMMENT '商圈;例:虹桥',
  `latitude` varchar(32) NOT NULL COMMENT '纬度;例:31.2497',
  `longitude` varchar(32) NOT NULL COMMENT '经度;例:120.3925',
  `pic` varchar(255) DEFAULT NULL COMMENT '酒店图片;例:/img/1.jpg',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

执行结果:

image-20220112151559838

2) 导入hotel-demo工程

项目结构

image-20220113174207581

3) Mapping映射分析

创建索引库,最关键的是mapping映射,而mapping映射要考虑的信息包括:

  • 字段名
  • 字段数据类型
  • 是否参与搜索
  • 是否需要分词
  • 如果分词,分词器是什么?

其中:

  • 字段名、字段数据类型,可以参考数据表结构的名称和类型
  • 是否参与搜索要分析业务来判断,例如图片地址,就无需参与搜索
  • 是否分词呢要看内容,内容如果是一个整体就无需分词,反之则要分词
  • 分词器,我们可以统一使用ik_max_word

来看下酒店数据的索引库结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
PUT /hotel
{
"mappings": {
"properties": {
"id": {
"type": "keyword"
},
"name":{
"type": "text",
"analyzer": "ik_max_word",
"copy_to": "all"
},
"address":{
"type": "keyword",
"index": false
},
"price":{
"type": "integer"
},
"score":{
"type": "integer"
},
"brand":{
"type": "keyword",
"copy_to": "all"
},
"city":{
"type": "keyword",
"copy_to": "all"
},
"starName":{
"type": "keyword"
},
"business":{
"type": "keyword"
},
"location":{
"type": "geo_point"
},
"pic":{
"type": "keyword",
"index": false
},
"all":{
"type": "text",
"analyzer": "ik_max_word"
}
}
}
}

几个特殊字段说明:

  • location:地理坐标,里面包含精度、纬度。ES中支持两种地理坐标数据类型:
    • geo_point:由纬度(latitude)和经度(longitude)确定的一个点。例如:”32.8752345,120.2981576”
    • geo_shape:有多个geo_point组成的复杂几何图形。例如一条直线,”LINESTRING(-77.03653 38.897676, -77.009051 38.889939)”
  • all:一个组合字段,其目的是将多字段的值利用copy_to合并,提供给用户搜索。例如:
1
2
3
4
5
6
7
8
"all": {
  "type""text",
  "analyzer""ik_max_word"
},
"brand": {
  "type""keyword",
  "copy_to""all"
}

返回结果:

image-20220113204418919

4) 初始化RestClient

在elasticsearch提供的API中,与elasticsearch一切交互都封装在一个名为RestHighLevelClient的类中,必须先完成这个对象的初始化,建立与elasticsearch的连接。

步骤

  1. 引入es的RestHighLevelClient依赖:
1
2
3
4
5
<dependency>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-high-level-client</artifactId>
<version>7.12.1</version>
</dependency>
  1. 因为SpringBoot默认的ES版本是7.6.2,所以我们需要覆盖默认的ES版本:
1
2
3
4
<properties>
<java.version>1.8</java.version>
<elasticsearch.version>7.12.1</elasticsearch.version>
</properties>
  1. 初始化RestHighLevelClient,在测试类中:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class HotelIndexTest {

private RestHighLevelClient client;

// 打印
@Test
void testInit() {
System.out.println(client);
}

// 在单元测试前执行:初始化客户端
@BeforeEach
void setUp() {
this.client = new RestHighLevelClient(RestClient.builder(
// 建立与远端es服务器的连接
HttpHost.create("http://XXXXXXXXX:9200")
));
}

// 在单元测试结束后执行:关闭客户端
@AfterEach
void tearDown() throws IOException {
this.client.close();
}
}

image-20220113210340928

10.4.2 创建索引库

1) 代码解读

创建索引库的API如下:

image-20220113211318125

代码分为三步:

  • 创建Request对象。因为是创建索引库的操作,因此Request是CreateIndexRequest。
  • 添加请求参数,其实就是DSL的JSON参数部分。因为json字符串很长,这里是定义了静态字符串常量MAPPING_TEMPLATE,让代码看起来更加优雅。
  • 发送请求,client.indices()方法的返回值是IndicesClient类型,封装了所有与索引库操作有关的方法。
2) 完整代码
  1. 在hotel-demo的cn.itcast.hotel.constants包下,创建一个类,定义mapping映射的JSON字符串常量:

image-20220113211432564

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
public class HotelConstants {
public static final String MAPPING_TEMPLATE = "{\n" +
" \"mappings\": {\n" +
" \"properties\": {\n" +
" \"id\": {\n" +
" \"type\": \"keyword\"\n" +
" },\n" +
" \"name\":{\n" +
" \"type\": \"text\",\n" +
" \"analyzer\": \"ik_max_word\",\n" +
" \"copy_to\": \"all\"\n" +
" },\n" +
" \"address\":{\n" +
" \"type\": \"keyword\",\n" +
" \"index\": false\n" +
" },\n" +
" \"price\":{\n" +
" \"type\": \"integer\"\n" +
" },\n" +
" \"score\":{\n" +
" \"type\": \"integer\"\n" +
" },\n" +
" \"brand\":{\n" +
" \"type\": \"keyword\",\n" +
" \"copy_to\": \"all\"\n" +
" },\n" +
" \"city\":{\n" +
" \"type\": \"keyword\",\n" +
" \"copy_to\": \"all\"\n" +
" },\n" +
" \"starName\":{\n" +
" \"type\": \"keyword\"\n" +
" },\n" +
" \"business\":{\n" +
" \"type\": \"keyword\"\n" +
" },\n" +
" \"location\":{\n" +
" \"type\": \"geo_point\"\n" +
" },\n" +
" \"pic\":{\n" +
" \"type\": \"keyword\",\n" +
" \"index\": false\n" +
" },\n" +
" \"all\":{\n" +
" \"type\": \"text\",\n" +
" \"analyzer\": \"ik_max_word\"\n" +
" }\n" +
" }\n" +
" }\n" +
"}";
}
  1. 在hotel-demo中的HotelIndexTest测试类中,编写单元测试,实现创建索引:注意要删除之前建立的索引库hotel
1
2
3
4
5
6
7
8
9
@Test
void createHotelIndex() throws IOException {
// 1.创建Request对象
CreateIndexRequest request = new CreateIndexRequest("hotel");
// 2.准备请求的参数:DSL语句
request.source(MAPPING_TEMPLATE, XContentType.JSON);
// 3.发送请求,indices方法非常重要!
client.indices().create(request, RequestOptions.DEFAULT);
}

image-20220113211514110

10.4.3 删除索引库和判断索引库存在

1) 删除

删除索引库的DSL语句非常简单:

1
DELETE /hotel

与创建索引库相比:

  • 请求方式从PUT变为DELTE
  • 请求路径不变
  • 无请求参数

所以代码的差异,注意体现在Request对象上。依然是三步走:

  • 创建Request对象。这次是DeleteIndexRequest对象
  • 准备参数。这里是无参
  • 发送请求。改用delete方法

在hotel-demo中的HotelIndexTest测试类中,编写单元测试,实现删除索引:

1
2
3
4
5
6
7
@Test
void deleteHotelIndex() throws IOException {
// 1.创建Request对象
DeleteIndexRequest request = new DeleteIndexRequest("hotel");
// 2.发送请求
client.indices().delete(request, RequestOptions.DEFAULT);
}
2) 判断

判断索引库是否存在,本质就是查询,对应的DSL是:

1
GET /hotel

因此与删除的Java代码流程是类似的。依然是三步走:

  • 创建Request对象。这次是GetIndexRequest对象
  • 准备参数。这里是无参
  • 发送请求。改用exists方法
1
2
3
4
5
6
7
8
9
@Test
void existsHotelIndex() throws IOException {
// 1.创建Request对象
GetIndexRequest request = new GetIndexRequest("hotel");
// 2.发送请求
boolean exists = client.indices().exists(request, RequestOptions.DEFAULT);
// 3.输出
System.err.println(exists ? "索引库已经存在!" : "索引库不存在!");
}

image-20220113212054015

10.4.4 总结

JavaRestClient操作elasticsearch的流程基本类似。核心是client.indices()方法来获取索引库的操作对象。

索引库操作的基本步骤:

  • 初始化RestHighLevelClient
  • 创建XxxIndexRequest。XXX是Create、Get、Delete
  • 准备DSL( Create时需要,其它是无参)
  • 发送请求。调用RestHighLevelClient#indices().xxx()方法,xxx是create、exists、delete

10.5 RestClient操作文档

为了与索引库操作分离,我们再次新增一个测试类,做两件事情:

  • 初始化RestHighLevelClient
  • 我们的酒店数据在数据库,需要利用IHotelService去查询,所以注入这个接口
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 记得添加这个注解
@SpringBootTest
public class HotelDocumentTest {
// 注入接口
@Autowired
private IHotelService hotelService;

private RestHighLevelClient client;

@BeforeEach
void setUp() {
this.client = new RestHighLevelClient(RestClient.builder(
// 建立与es服务器的连接
HttpHost.create("http://XXXXXXXXXXXX:9200")
));
}

@AfterEach
void tearDown() throws IOException {
this.client.close();
}
}

10.5.1 新增文档

需求:我们要将数据库的酒店数据查询出来,写入elasticsearch中。

1) 索引库实体类

数据库查询后的结果是一个Hotel类型的对象。结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Data
@TableName("tb_hotel")
public class Hotel {
@TableId(type = IdType.INPUT)
private Long id;
private String name;
private String address;
private Integer price;
private Integer score;
private String brand;
private String city;
private String starName;
private String business;
private String longitude;
private String latitude;
private String pic;
}

与我们的索引库结构存在差异:

  • longitude和latitude需要合并为location

因此,我们需要定义一个新的类型,与索引库结构吻合:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@Data
@NoArgsConstructor
public class HotelDoc {
private Long id;
private String name;
private String address;
private Integer price;
private Integer score;
private String brand;
private String city;
private String starName;
private String business;
private String location;
private String pic;
// 进一步将hotel对象进行包装为hoteldoc
public HotelDoc(Hotel hotel) {
this.id = hotel.getId();
this.name = hotel.getName();
this.address = hotel.getAddress();
this.price = hotel.getPrice();
this.score = hotel.getScore();
this.brand = hotel.getBrand();
this.city = hotel.getCity();
this.starName = hotel.getStarName();
this.business = hotel.getBusiness();
this.location = hotel.getLatitude() + ", " + hotel.getLongitude();
this.pic = hotel.getPic();
}
}

2) 语法说明

新增文档的DSL语句如下:

1
2
3
4
5
POST /{索引库名}/_doc/1
{
"name": "Jack",
"age": 21
}

对应的java代码如图:

image-20220113213611940

可以看到与创建索引库类似,同样是三步走:

  • 创建Request对象
  • 准备请求参数,也就是DSL中的JSON文档,这里的JSON文档,由从数据库中查询出来的hotel对象序列化为JSON格式的数据后来提供
  • 发送请求

变化的地方在于,这里直接使用client.index()的API,不再需要client.indices()了。

3) 代码实现

我们导入酒店数据,基本流程一致,但是需要考虑几点变化:

  • 酒店数据来自于数据库,我们需要先查询出来,得到hotel对象
  • hotel对象需要转为HotelDoc对象
  • HotelDoc需要序列化为json格式

因此,代码整体步骤如下:

  1. 根据id查询酒店数据Hotel
  2. 将Hotel封装为HotelDoc
  3. 将HotelDoc序列化为JSON
  4. 创建IndexRequest,指定索引库名和id
  5. 准备请求参数,也就是JSON文档
  6. 发送请求

在hotel-demo的HotelDocumentTest测试类中,编写单元测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@SpringBootTest
public class HotelDocumentTest {

@Autowired
private IHotelService hotelService;

private RestHighLevelClient client;

@Test
void addDocument() throws IOException {
// 根据id查询
Hotel hotel = hotelService.getById(61083L);
// 转换为适用于es索引库mapping映射的文档类型
HotelDoc hotelDoc = new HotelDoc(hotel);
// 1.准备request对象
// 注意在es中,id是字符串类型,因此需要将整型的id转换为字符串
IndexRequest request = new IndexRequest("hotel").id(hotelDoc.getId().toString());
// 2.准备json文档
request.source(JSON.toJSONString(hotelDoc), XContentType.JSON);
// 3.发送请求 client.index()方法
client.index(request, RequestOptions.DEFAULT);
}

@BeforeEach
void setUp() {
this.client = new RestHighLevelClient(RestClient.builder(
HttpHost.create("http://XXXXXXXXX:9200")
));
}

@AfterEach
void tearDown() throws IOException {
this.client.close();
}
}

在kibana中验证增加的文档:

1
GET /hotel/_doc/61083
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
"_index" : "hotel",
"_type" : "_doc",
"_id" : "61083",
"_version" : 1,
"_seq_no" : 0,
"_primary_term" : 1,
"found" : true,
"_source" : {
"address" : "自由贸易试验区临港新片区南岛1号",
"brand" : "皇冠假日",
"business" : "滴水湖临港地区",
"city" : "上海",
"id" : 61083,
"location" : "30.890867, 121.937241",
"name" : "上海滴水湖皇冠假日酒店",
"pic" : "https://m.tuniucdn.com/fb3/s1/2n9c/312e971Rnj9qFyR3pPv4bTtpj1hX_w200_h200_c1_t0.jpg",
"price" : 971,
"score" : 44,
"starName" : "五钻"
}
}

10.5.2 查询文档

1) 语法说明

查询的DSL语句如下:

1
GET /hotel/_doc/{id}

非常简单,因此代码大概分两步:

  • 准备Request对象
  • 发送请求

不过查询的目的是得到结果,解析为HotelDoc,因此难点是结果的解析。完整代码如下:

image-20220114092151519

可以看到,结果是一个JSON,其中文档放在一个_source属性中,因此解析就是拿到_source,反序列化为Java对象即可。

与之前类似,也是三步走:

  • 准备Request对象。这次是查询,所以是GetRequest
  • 发送请求,得到结果。因为是查询,这里调用client.get()方法
  • 解析结果,就是对JSON做反序列化
2) 代码实现

在hotel-demo的HotelDocumentTest测试类中,编写单元测试:

1
2
3
4
5
6
7
8
9
10
11
12
@Test
void getDocument() throws IOException {
// 1.准备request对象
GetRequest request = new GetRequest("hotel", "61083");
// 2.发送请求,得到响应
GetResponse response = client.get(request, RequestOptions.DEFAULT);
// 3.得到响应结果
String json = response.getSourceAsString();
// 4.反序列化为HotelDoc类对象
HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class);
System.out.println(hotelDoc);
}
1
HotelDoc(id=61083, name=上海滴水湖皇冠假日酒店, address=自由贸易试验区临港新片区南岛1号, price=971, score=44, brand=皇冠假日, city=上海, starName=五钻, business=滴水湖临港地区, location=30.890867, 121.937241, pic=https://m.tuniucdn.com/fb3/s1/2n9c/312e971Rnj9qFyR3pPv4bTtpj1hX_w200_h200_c1_t0.jpg)

10.5.3 更新文档

1) 语法说明

修改我们讲过两种方式:

  • 全量修改:本质是先根据id删除,再新增
  • 增量修改:修改文档中的指定字段值

在RestClient的API中,全量修改与新增的API完全一致,判断依据是ID:

  • 如果新增时,ID已经存在,则修改
  • 如果新增时,ID不存在,则新增

这里不再赘述,我们主要关注增量修改。

代码示例如图:

image-20220114093027956

与之前类似,也是三步走:

  • 准备Request对象。这次是修改,所以是UpdateRequest
  • 准备参数。也就是JSON文档,里面包含要修改的字段
  • 更新文档。这里调用client.update()方法
2) 代码实现
1
2
3
4
5
6
7
8
9
10
11
12
@Test
void updateDocument() throws IOException {
// 1.准备request对象
UpdateRequest request = new UpdateRequest("hotel", "61083");
// 2.准备请求参数,注意参数的格式
request.doc(
"price", "952",
"starName", "四钻"
);
// 3.发送请求
client.update(request, RequestOptions.DEFAULT);
}

修改结果:

image-20220114092945865

10.5.4 删除文档

1) 语法说明

删除的DSL为是这样的:

1
DELETE /hotel/_doc/{id}

与查询相比,仅仅是请求方式从DELETE变成GET,可以想象Java代码应该依然是三步走:

  • 准备Request对象,因为是删除,这次是DeleteRequest对象。要指定索引库名和id
  • 准备参数,无参
  • 发送请求。因为是删除,所以是client.delete()方法
2) 代码实现
1
2
3
4
5
6
7
@Test
void deleteDocument() throws IOException {
// 1.准备request对象
DeleteRequest request = new DeleteRequest("hotel", "61083");
// 2.发送请求
client.delete(request, RequestOptions.DEFAULT);
}

删除后,在kibana中查询结果:

image-20220114093558889

10.5.5 批量新增文档

案例需求:利用BulkRequest批量将数据库数据导入到索引库中。

步骤如下:

  • 利用mybatis-plus查询酒店数据

  • 将查询到的酒店数据(Hotel)转换为文档类型数据(HotelDoc)

  • 利用JavaRestClient中的BulkRequest批处理,实现批量新增文档

1) 语法说明

批量处理BulkRequest,其本质就是将多个普通的CRUD请求组合在一起发送。

其中提供了一个add方法,用来添加其他请求:

image-20220114094751768

可以看到,能添加的请求包括:

  • IndexRequest,也就是新增
  • UpdateRequest,也就是修改
  • DeleteRequest,也就是删除

因此Bulk中添加了多个IndexRequest,就是批量新增功能了。示例:

image-20220114094814242

其实还是三步走:

  • 创建Request对象。这里是BulkRequest
  • 准备参数。批处理的参数,就是其它Request对象,这里就是多个IndexRequest
  • 发起请求。这里是批处理,调用的方法为client.bulk()方法

我们在导入酒店数据时,将上述代码改造成for循环处理即可。

2) 代码实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Test
void bulkRequest() throws IOException {
// 批量查询数据
List<Hotel> hotels = hotelService.list();

// 1.创建request
BulkRequest request = new BulkRequest();

// 2.准备参数,添加多个新增的request
for (Hotel hotel : hotels) {
// 转换为HotelDoc
HotelDoc hotelDoc = new HotelDoc(hotel);
// 创建新增文档的request对象
request.add(new IndexRequest("hotel")
.id(hotelDoc.getId().toString())
.source(JSON.toJSONString(hotelDoc), XContentType.JSON));
}
// 3.发送请求
client.bulk(request, RequestOptions.DEFAULT);
}

kibana端验证:下列DSL语句可以查询到所有数据

1
GET /hotel/_search

image-20220114095127855

10.6 DSL查询文档

elasticsearch的查询依然是基于JSON风格的DSL来实现的。

10.6.1 DSL查询分类

Elasticsearch提供了基于JSON的DSL(Domain Specific Language)来定义查询。

常见的查询类型包括:

  • 查询所有:查询出所有数据,一般测试用。例如:match_all
  • 全文检索(full text)查询:利用分词器对用户输入内容分词,然后去倒排索引库中匹配。例如:
    • match_query
    • multi_match_query
  • 精确查询:根据精确词条值查找数据,一般是查找keyword、数值、日期、boolean等类型字段。例如:
    • ids
    • range
    • term
  • 地理(geo)查询:根据经纬度查询。例如:
    • geo_distance
    • geo_bounding_box
  • 复合(compound)查询:复合查询可以将上述各种查询条件组合起来,合并查询条件。例如:
    • bool
    • function_score

查询的语法基本一致:

1
2
3
4
5
6
7
8
GET /indexName/_search
{
  "query": {
    "查询类型": {
      "查询条件""条件值"
    }
  }
}

我们以查询所有为例,其中:

  • 查询类型为match_all
  • 没有查询条件
1
2
3
4
5
6
7
// 查询所有
GET /hotel/_search
{
  "query": {
    "match_all": {}
  }
}

返回结果:

image-20220117152854723

其中hits就是一个个文档。

其它查询无非就是查询类型查询条件的变化。

10.6.2 全文检索查询

1) 使用场景

全文检索查询的基本流程如下:

  • 对用户搜索的内容做分词,得到词条
  • 根据词条去倒排索引库中匹配,得到文档id
  • 根据文档id找到文档,返回给用户

比较常用的场景包括:

  • 商城的输入框搜索
  • 百度输入框搜索
2) 基本语法

常见的全文检索查询包括:

  • match查询:单字段查询
  • multi_match查询:多字段查询,任意一个字段符合条件就算符合查询条件

match查询语法

1
2
3
4
5
6
7
8
GET /indexName/_search
{
  "query": {
    "match": {
      "FIELD""TEXT"
    }
  }
}

mulit_match语法

1
2
3
4
5
6
7
8
9
GET /indexName/_search
{
  "query": {
    "multi_match": {
      "query""TEXT",
      "fields": ["FIELD1"" FIELD12"]
    }
  }
}
3) 使用

match

1
2
3
4
5
6
7
8
9
# match查询
GET /hotel/_search
{
"query": {
"match": {
"all": "外滩如家" // all字段是对多个字段的拷贝:name, brand, city, business
}
}
}

image-20220117161513956

multi_match

1
2
3
4
5
6
7
8
9
10
// multi_match查询
GET /hotel/_search
{
"query": {
"multi_match": {
"query": "外滩如家",
"fields": ["brand", "name", "business"]
}
}
}

image-20220117161543840

可以看到,两种查询结果是一样的,为什么?

因为我们将brand、name、business值都利用copy_to复制到了all字段中。因此你根据三个字段搜索,和根据all字段搜索效果当然一样了。

但是,搜索字段越多,对查询性能影响越大,因此建议采用copy_to,然后单字段查询的方式。

10.6.3 精准查询

精确查询一般是查找keyword、数值、日期、boolean等类型字段。所以不会对搜索条件分词。常见的有:

  • term:根据词条精确值查询
  • range:根据值的范围查询
1) term查询

因为精确查询的字段搜是不分词的字段,因此查询的条件也必须是不分词的词条。查询时,用户输入的内容跟自动值完全匹配时才认为符合条件。如果用户输入的内容过多,反而搜索不到数据。

语法

1
2
3
4
5
6
7
8
9
10
11
// term查询
GET /indexName/_search
{
  "query": {
    "term": {
      "FIELD": {
        "value""VALUE"
      }
    }
  }
}

示例

当搜索的是精确词条时,能正确查询出结果:

1
2
3
4
5
6
7
8
9
10
GET /hotel/_search
{
"query": {
"term": {
"city": {
"value": "上海"
}
}
}
}
2) range查询

范围查询,一般应用在对数值类型做范围过滤的时候。比如做价格范围过滤。

基本语法

1
2
3
4
5
6
7
8
9
10
11
12
// range查询
GET /indexName/_search
{
  "query": {
    "range": {
      "FIELD": {
        "gte"10, // 这里的gte代表大于等于,gt则代表大于
        "lte"20 // lte代表小于等于,lt则代表小于
      }
    }
  }
}

示例

1
2
3
4
5
6
7
8
9
10
11
GET /hotel/_search
{
"query": {
"range": {
"price": {
"gte": 100,
"lte": 300
}
}
}
}

image-20220117162535809

10.6.4 地理查询

所谓的地理坐标查询,其实就是根据经纬度查询,官方文档:https://www.elastic.co/guide/en/elasticsearch/reference/current/geo-queries.html

常见的使用场景包括:

  • 携程:搜索我附近的酒店
  • 滴滴:搜索我附近的出租车
  • 微信:搜索我附近的人
1) 矩形范围查询

矩形范围查询,也就是geo_bounding_box查询,查询坐标落在某个矩形范围的所有文档。

查询时,需要指定矩形的左上右下两个点的坐标,然后画出一个矩形,落在该矩形内的都是符合条件的点。

语法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// geo_bounding_box查询
GET /indexName/_search
{
  "query": {
    "geo_bounding_box": {
      "FIELD": {
        "top_left": { // 左上点
          "lat"31.1,
          "lon"121.5
        },
        "bottom_right": { // 右下点
          "lat"30.9,
          "lon"121.7
        }
      }
    }
  }
}
2) 附近查询

附近查询,也叫做距离查询(geo_distance):查询到指定中心点小于某个距离值的所有文档。

换句话来说,在地图上找一个点作为圆心,以指定距离为半径,画一个圆,落在圆内的坐标都算符合条件。

语法

1
2
3
4
5
6
7
8
9
10
// geo_distance 查询
GET /indexName/_search
{
  "query": {
    "geo_distance": {
      "distance""15km", // 半径
      "FIELD""31.21,121.5" // 圆心
    }
  }
}

示例

我们先搜索陆家嘴附近15km的酒店:

1
2
3
4
5
6
7
8
9
GET /hotel/_search
{
"query": {
"geo_distance": {
"distance": "15km",
"location": "31.21, 121.5"
}
}
}

image-20220117163004435

可以看到搜索到了47家酒店。减小距离为3km:

image-20220117163050405

发现只命中了5家酒店。

10.6.5 复合查询

复合(compound)查询:复合查询可以将其它简单查询组合起来,实现更复杂的搜索逻辑。常见的有两种:

  • fuction score:算分函数查询,可以控制文档相关性算分,控制文档排名
  • bool query:布尔查询,利用逻辑关系组合多个其它的查询,实现复杂搜索
1) 相关性算分

当我们利用match查询时,文档结果会根据与搜索词条的关联度打分(_score),返回结果时按照分值降序排列。

例如,我们搜索 “虹桥如家”,结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
[
  {
    "_score" : 17.850193,
    "_source" : {
      "name" : "虹桥如家酒店真不错",
    }
  },
  {
    "_score" : 12.259849,
    "_source" : {
      "name" : "外滩如家酒店真不错",
    }
  },
  {
    "_score" : 11.91091,
    "_source" : {
      "name" : "迪士尼如家酒店真不错",
    }
  }
]

在elasticsearch中,早期使用的打分算法是TF-IDF算法,公式如下:

image-20220124040534316

在后来的5.1版本升级中,elasticsearch将算法改进为BM25算法,公式如下:

image-20220124040552789

TF-IDF算法有一个缺陷,就是词条频率越高,文档得分也会越高,单个词条对文档影响较大。而BM25则会让单个词条的算分有一个上限,曲线更加平滑:

image-20220124040615573

2) 算分函数查询

根据相关度打分是比较合理的需求,但合理的不一定是产品经理需要的。

以百度为例,你搜索的结果中,并不是相关度越高排名越靠前,而是谁掏的钱多排名就越靠前。如图:

image-20220124040711376

要想认为控制相关性算分,就需要利用elasticsearch中的function score查询了。

语法说明

image-20220124040743910

function score查询中包含四部分内容:

  • 原始查询条件:query部分,基于这个条件搜索文档,并且基于BM25算法给文档打分,原始算分query score)
  • 过滤条件:filter部分,符合该条件的文档才会重新算分
  • 算分函数:符合filter条件的文档要根据这个函数做运算,得到的函数算分function score),有四种函数
    • weight:函数结果是常量
    • field_value_factor:以文档中的某个字段值作为函数结果
    • random_score:以随机数作为函数结果
    • script_score:自定义算分函数算法
  • 运算模式:算分函数的结果、原始查询的相关性算分,两者之间的运算方式,包括:
    • multiply:相乘
    • replace:用function score替换query score
    • 其它,例如:sum、avg、max、min

function score的运行流程如下:

  • 1)根据原始条件查询搜索文档,并且计算相关性算分,称为原始算分(query score)
  • 2)根据过滤条件,过滤文档
  • 3)符合过滤条件的文档,基于算分函数运算,得到函数算分(function score)
  • 4)将原始算分(query score)和函数算分(function score)基于运算模式做运算,得到最终结果,作为相关性算分

因此,其中的关键点是:

  • 过滤条件:决定哪些文档的算分被修改
  • 算分函数:决定函数算分的算法
  • 运算模式:决定最终算分结果

示例

需求:给“如家”这个品牌的酒店排名靠前一些。翻译一下这个需求,转换为之前说的四个要点:

  • 原始条件:不确定,可以任意变化
  • 过滤条件:brand = “如家”
  • 算分函数:可以简单粗暴,直接给固定的算分结果,weight
  • 运算模式:比如求和

未添加算分函数时:

1
2
3
4
5
6
7
8
9
10
11
12
GET /hotel/_search
{
"query": {
"function_score": {
"query": {
"match": {
"all": "外滩"
}
}
}
}
}

image-20220124042238799

如家排在第二位。

添加算分函数后:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
GET /hotel/_search
{
"query": {
"function_score": {
"query": {
"match": {
"all": "外滩"
}
},
"functions": [ // 算分函数
{
"filter": { // 满足的条件,品牌必须是如家
"term": { // 因此需要精准查找
"brand": "如家"
}
},
"weight": 10 // 算分权重为10
}
],
"boost_mode": "sum" // 加权模式,求和
}
}
}

image-20220124042401127

发现如家排在了第一位。

3) 布尔查询

布尔查询是一个或多个查询子句的组合,每一个子句就是一个子查询。子查询的组合方式有:

  • must:必须匹配每个子查询,类似“与”
  • should:选择性匹配子查询,类似“或”
  • must_not:必须不匹配,不参与算分,类似“非”
  • filter:必须匹配,不参与算分

比如在搜索酒店时,除了关键字搜索外,我们还可能根据品牌、价格、城市等字段做过滤:

image-20220124042604371

每一个不同的字段,其查询的条件、方式都不一样,必须是多个不同的查询,而要组合这些查询,就必须用bool查询了。

需要注意的是,搜索时,参与打分的字段越多,查询的性能也越差。因此这种多条件查询时,建议这样做:

  • 搜索框的关键字搜索,是全文检索查询,使用must查询,参与算分
  • 其它过滤条件,采用filter查询。不参与算分

语法说明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
GET /hotel/_search
{
  "query": {
    "bool": {
      "must": [
        {"term": {"city""上海" }}
      ],
      "should": [
        {"term": {"brand""皇冠假日" }},
{"term": {"brand""华美达" }}
      ],
      "must_not": [
        { "range": { "price": { "lte"500 } }}
      ],
      "filter": [
        { "range": {"score": { "gte"45 } }}
      ]
    }
  }
}

示例

需求:搜索名字包含“如家”,价格不高于400,在坐标31.21,121.5周围10km范围内的酒店。

分析:

  • 名称搜索,属于全文检索查询,应该参与算分。放到must中
  • 价格不高于400,用range查询,属于过滤条件,不参与算分。放到must_not中
  • 周围10km范围内,用geo_distance查询,属于过滤条件,不参与算分。放到filter中
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
GET /hotel/_search
{
"query": {
"bool": {
"must": [
{"match": {
"name": "如家"
}}
],
"must_not": [
{"range": {
"price": {
"gt": 400
}
}}
],
"filter": [
{"geo_distance": {
"distance": "10km",
"location": {
"lat": 31.21,
"lon": 121.5
}
}}
]
}
}
}

image-20220124043516083

10.7 搜索结果处理

搜索的结果可以按照用户指定的方式去处理或展示。