admin 管理员组文章数量: 887016
- 若文章内容或图片失效,请留言反馈。部分素材来自网络,若不小心影响到您的利益,请联系博主删除。
前言
- 学习视频链接
- SpringCloud + RabbitMQ + Docker + Redis + 搜索 + 分布式,史上最全面的 SpringCloud 微服务技术栈课程 | 黑马程序员 Java 微服务
- 学习资料链接
- 2022 最新版 Java 学习 路线图>第 4 阶段:中间键 & 服务框架>1.微服务开发框架 SpringCloud + RabbitMQ + Docker + Redis + 搜索 + 分布式 史上最全面的微服务全技术栈课程(
提取码
:dor4
)
- 写这篇博客旨在制作笔记,巩固知识。同时方便个人在线阅览,回顾知识。
- 博客的内容主要来自视频内容和资料中提供的学习笔记。
系列目录
SpringCloud 微服务技术栈_实用篇①_基础知识
SpringCloud 微服务技术栈_实用篇②_黑马旅游案例
SpringCloud 微服务技术栈_高级篇①_微服务保护
SpringCloud 微服务技术栈_高级篇②_分布式事务
SpringCloud 微服务技术栈_高级篇③_分布式缓存
SpringCloud 微服务技术栈_高级篇④_多级缓存
SpringCloud 微服务技术栈_高级篇⑤_可靠消息服务
本文目录
微服务远程调用、Eureka 注册中心、Ribbon 负载均衡原理、Nacos 注册中心
- SpringCloudDay01
Nacos 配置管理、Feign 远程调用、GetWay 服务网关
- SpringCloudDay02
Docker 的基本操作、Dockerfile 的自定义镜像、了解 Docker-Compose、Dokcer 镜像服务
- SpringCloudDay03
了解 MQ、快速入门 RabbitMQ、了解 SpringAMQP
- SpringCloudDay04
ElasticSearch 基础:索引库操作、文档操作、RestAPI、RestClient 操作文档
- SpringCloudDay05
ElasticSearch 搜索功能:DSL 查询文档、搜索结果处理、RestClient 查询文档、黑马旅游案例
- SpringCloudDay06
ElasticSearch 深入学习:数据聚合、自动补全、数据补全、集群
- SpringCloudDay07
0.微服务技术栈导学
- 以下为视频中的截图
# SpringCloudDay01
SpringCloud 学习 Day01(实用篇-1)
- 认识微服务
- 分布式服务架构案例
- Eureka 注册中心
- Ribbon 负载均衡原理
- Nacos 注册中心
1.认识微服务
随着互联网行业的发展,对服务的要求也越来越高,服务架构也从单体架构逐渐演变为现在流行的微服务架构。
1.1.服务架构演变
1.1.1.单体架构
单体架构:将业务的所有功能集中在一个项目中开发,打成一个包部署。
- 优点:架构简单、部署成本低
- 缺点:耦合度高(维护困难、升级困难)
1.1.2.分布式架构
分布式架构:根据业务功能对系统做拆分,每个业务功能模块作为独立项目开发,称为一个服务。
- 优点:降低服务耦合、有利于服务升级和拓展
- 缺点:服务调用关系错综复杂
服务治理
分布式架构虽然降低了服务耦合,但是服务拆分时也有很多问题需要思考:
- 服务拆分的粒度如何界定?
- 服务之间如何调用?
- 服务的调用关系如何管理?
人们需要制定一套行之有效的标准来约束分布式架构。
1.1.3.微服务
微服务是一种经过良好架构设计的分布式架构方案。
微服务架构特征
- 单一职责:微服务拆分粒度更小,每一个服务都对应唯一的业务能力,做到单一职责,避免重复业务开发
- 面向服务:微服务对外暴露业务接口
- 自治:团队独立、技术独立、数据独立、部署独立
- 隔离性强:服务调用做好隔离、容错、降级,避免出现级联问题
微服务架构图
微服务部署(持续集成)
微服务图解(举例)
微服务的上述特性其实是在给分布式架构制定一个标准,进一步降低服务之间的耦合度,提供服务的独立性和灵活性。
从而做到高内聚,低耦合。
因此,可以认为微服务是一种经过良好架构设计的分布式架构方案 。
但方案该怎么落地?选用什么样的技术栈?全球的互联网公司都在积极尝试自己的微服务落地方案。
其中在 Java 领域最引人注目的就是 SpringCloud 提供的方案了。
1.2.微服务技术对比
- 微服务结构
微服务这种方案需要技术框架来落地,全球的互联网公司都在积极尝试自己的微服务落地技术。
在国内最知名的就是 SpringCloud 和阿里巴巴的 Dubbo。
- 微服务技术对比
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 |
- 企业需求
1.3.SpringCloud
SpringCloud 是目前国内使用最广泛的微服务框架。
官网地址:https://spring.io/projects/spring-cloud。
SpringCloud 集成了各种微服务功能组件,并基于 SpringBoot 实现了这些组件的自动装配。
SpringCloud 提供了良好的开箱即用体验。
另外,SpringCloud 底层是依赖于 SpringBoot 的,并且有版本的兼容关系。
我们这里学习的版本是 Hoxton.SR10,因此对应的 SpringBoot 版本是 2.3.x 版本。
1.4.总结
- 单体架构:简单方便,高度耦合,扩展性差,适合小型项目。
- 例如:学生管理系统
- 分布式架构:松耦合,扩展性好,但架构复杂,难度大。适合大型互联网项目。
- 例如:京东、淘宝
- 微服务:一种良好的分布式架构方案
- 优点:拆分粒度更小、服务更独立、耦合度更低
- 缺点:架构非常复杂,运维、监控、部署难度提高
- SpringCloud 是微服务架构的一站式解决方案,集成了各种优秀微服务功能组件
2.分布式服务架构案例
2.1.服务拆分原则
- 不同微服务,不要重复开发相同业务
- 微服务数据独立,不要访问其它微服务的数据库
- 微服务可以将自己的业务暴露为接口,供其它微服务调用
2.2.服务拆分示例
课前资料
2022 最新版 Java 学习 路线图>第 4 阶段_中间键&服务框架>1.微服务开发框架 SpringCloud + RabbitMQ + Docker + Redis + 搜索 + 分布式 史上最全面的微服务全技术栈课程>实用篇>学习资料>day01-SpringCloud01(提取码:dor4)
2.2.1.案例 Demo
导入服务拆分 Demo
- 导入课前资料提供的工程:cloud-demo
- 项目结构
- 首先,将课前资料提供的
cloud-order.sql
和cloud-user.sql
导入到 MySQL 中。
该项目导入的部分依赖以及所继承的工程
这里只是提一下,项目里有完整的文件。这里没打算赘述
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.3.9.RELEASE</version>
<relativePath/>
</parent>
<!-- springCloud -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>Hoxton.SR10</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!--nacos 的管理依赖-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>2.2.5.RELEASE</version>
<type>pom</type>
<scope>import</scope>
</dependency>
数据库 & 数据表说明
其中 cloud_order 和 cloud_user 数据库的字符集为 utf8
、排序规则是 utf8_general_ci
。
数据库 cloud-order 中仅有一张表:tb_order
数据库 cloud_user 中仅有一张表:tb_user
其中 tb_order.user_id = tb_user.id。
不过因为是跨库的俩表,所以无法作关联查询。
相关的项目结构 和 Idea 的窗口
在 order-service 服务中,有一个根据 id 查询订单的接口
order-service 模块中的 src/main/java/cn/itcast/order/web/OrderController.java
@RestController
@RequestMapping("order")
public class OrderController {
@Autowired
private OrderService orderService;
@GetMapping("{orderId}")
public Order queryOrderByUserId(@PathVariable("orderId") Long orderId) {
// 根据id查询订单并返回
return orderService.queryOrderById(orderId);
}
}
在 user-service 中有一个根据 id 查询用户的接口
user-service 模块中的 src/main/java/cn/itcast/user/web/UserController.java
@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);
}
}
查询结果
这里使用的接口测试工具是 Postman
根据 id 查询用户,返回值是 User 对象。
根据 id 查询订单,返回值是 Order 对象,其中的 user 为 null。
2.2.2.实现远程调用案例
案例需求:根据订单 id 查询订单的同时,把订单所属的用户信息一起返回。
远程调用方式分析
因此,我们需要在 order-service中 向 user-service 发起一个 http 的请求,调用 http://localhost:8081/user/{userId}
这个接口。
大致步骤
- 注册一个 RestTemplate 的实例到 Spring 容器
- 修改 order-service 服务中的 OrderService 类中的 queryOrderById 方法
- 根据 Order 对象中的 userId 查询 User
- 将查询的 User 填充到 Order 对象,一起返回
- 注册 RestTemplate
在 order-service 服务的 OrderApplication 启动类中,注册 RestTemplate 实例
order-service 模块中的 src/main/java/cn/itcast/order/OrderApplication.java
@MapperScan("cn.itcast.order.mapper")
@SpringBootApplication
public class OrderApplication {
public static void main(String[] args) {
SpringApplication.run(OrderApplication.class, args);
}
/* ************************************* */
/**
* 创建 RestTemplate 并注入 Spring 容器
*
* @return
*/
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}
/* ************************************* */
}
- 修改 order-service 服务中的的 OrderService 类中的 queryOrderById 方法
order-service 模块中的 cn/itcast/order/service/OrderService.java
@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;
}
}
程序运行结果展示
2.3.总结
微服务拆分
- 微服务需要根据业务模块拆分,做到单一职责,不要重复开发相同的业务。
- 微服务可以将业务暴露为接口,供其他服务使用。
- 不同微服务都应该有自己独立的数据库。
微服务调用方式
- 基于 RestTemplate 发起的 http 请求实现远程调用
- http 请求做远程调用是与语言无关的调用,只要知道对方的 ip、端口、接口路径、请求参数即可。
3.eureka 注册中心
3.1.提供者与消费者
在服务调用关系中,会有两个不同的角色:服务提供者、服务消费者。
服务提供者:一次业务中,被其它微服务调用的服务。(提供接口给其它微服务)
服务消费者:一次业务中,调用其它微服务的服务。(调用其它微服务提供的接口)
但是,服务提供者与服务消费者的角色并不是绝对的,而是相对于业务而言。
如果服务 A 调用了服务 B,而服务 B 又调用了服务 C,服务 B 的角色是什么?
- 对于 A 调用 B 的业务而言:A 是服务消费者,B 是服务提供者
- 对于 B 调用 C 的业务而言:B 是服务消费者,C 是服务提供者
因此,服务 B 既可以是服务提供者,也可以是服务消费者。
小结
- 服务提供者:暴露接口给其他微服务调用
- 服务消费者:调用其他微服务提供的接口
- 提供者与消费者角色其实是相对的
3.2.远程调用出现的问题
假如我们的服务提供者 user-service 部署了多个实例。
- order-service 在发起远程调用的时候,该如何得知 user-service 实例的 ip 地址和端口?
- 有多个 user-service 实例地址,order-service 调用时该如何选择?
- order-service 如何得知某个 user-service 实例是否依然健康,是不是已经宕机?
3.3.Eureka 的作用
以上的这些问题都需要利用 SpringCloud 中的注册中心来解决。
其中最广为人知的注册中心就是 Eureka。
- 注意
- 一个微服务,既可以是服务提供者,又可以是服务消费者。
- 因此 eureka 将服务注册、服务发现等功能统一封装到了 eureka-client 端
在 Eureka 架构中,微服务角色有两类:服务端和客户端。
- EurekaServer:服务端,注册中心
- 记录服务信息
- 心跳监控
- EurekaClient:客户端
- Provider:服务提供者。例如案例中的 user-service
- 注册自己的信息到 EurekaServer
- 每隔 30 秒向 EurekaServer 发送心跳
- consumer:服务消费者。例如案例中的 order-service
- 根据服务名称从 EurekaServer 拉取服务列表
- 基于服务列表做负载均衡,选中一个微服务后发起远程调用
- Provider:服务提供者。例如案例中的 user-service
消费者该如何获取服务提供者具体信息?
- 服务提供者启动时向 eureka 注册自己的信息
- eureka 保存这些信息
- 消费者根据服务名称向 eureka 拉取提供者信息
如果有多个服务提供者,消费者该如何选择?
- 服务消费者利用负载均衡算法,从服务列表中挑选一个
消费者如何感知服务提供者健康状态?
- 服务提供者会每隔 30 秒向 EurekaServer 发送心跳请求,报告健康状态
- eureka 会更新记录服务列表信息,心跳不正常会被剔除
- 消费者就可以拉取到最新的信息
现在回答之前的各个问题
问题 1
:order-service 如何得知 user-service 实例地址?
- user-service 服务实例启动后,将自己的信息注册到 eureka-server(Eureka 服务端)。即服务注册
- eureka-server 保存服务名称到服务实例地址列表的映射关系
- order-service 根据服务名称,拉取实例地址列表。这个叫服务发现或服务拉取。
问题 2
:order-service 如何从多个 user-service 实例中选择具体的实例?
- order-service 从实例列表中利用负载均衡算法选中一个实例地址
- 向该实例地址发起远程调用
问题 3
:order-service 如何得知某个 user-service 实例是否依然健康,是不是已经宕机?
- user-service 会每隔一段时间(默认 30 秒)向 eureka-server 发起请求,报告自己状态,称为心跳
- 当超过一定时间没有发送心跳时,eureka-server 会认为微服务实例故障,将该实例从服务列表中剔除
- order-service 拉取服务时,就能将故障实例排除了
- 接下来,就需要我们来动手实践了。
3.4.搭建 eureka-server
首先大家注册中心服务端:eureka-server,这必须是一个独立的微服务。
- 在 cloud-demo 父工程下,创建一个子模块 eureka-server
- 引入 spring-cloud-starter-netflix-eureka-server 的依赖
- 编写启动类,添加 @EnableEurekaServer 注解
- 添加配置文件:application.yml
- 启动服务
- 在 cloud-demo 父工程下,创建一个子模块 eureka-server
- 引入 spring-cloud-starter-netflix-eureka-server 的依赖
总模块 cloud-demo 中的 pom.xml
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>1.8</java.version>
<spring-cloud.version>Hoxton.SR10</spring-cloud.version>
<!--<mysql.version>5.1.47</mysql.version>-->
<mysql.version>8.0.17</mysql.version>
<mybatis.version>2.1.1</mybatis.version>
</properties>
eureka-server 模块中的 pom.xml
<dependencies>
<!-- eureka 服务端-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
</dependency>
</dependencies>
- 编写启动类,添加
@EnableEurekaServer
注解
eureka-server 模块中的 src/main/java/cn/itcast/eureka/EurekaApplication.java
@EnableEurekaServer
@SpringBootApplication
public class EurekaApplication {
public static void main(String[] args) {
SpringApplication.run(EurekaApplication.class);
}
}
- 添加配置文件:
application.yml
eureka-server 模块中的 src/main/resources/application.yml
server:
port: 10086 # 服务端口
spring:
application:
name: eurekaserver # eureka 的服务名称
eureka:
client:
service-url: # eureka 的地址信息
defaultZone: http://127.0.0.1:10086/eureka
- 启动服务
在浏览器访问:http://127.0.0.1:10086
3.5.服务注册
- 引入 eureka-client 依赖
- 在 application.yml 下配置 eureka 地址
- 启动多个实例
- 引入 eureka-client 依赖
在 user-service 和 order-service 的俩模块下的 pom.xml 都导入如下的依赖。
<!-- 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>
- 在 application.yml 下配置 eureka 地址
根据情况来编写 user-service 和 order-service 的俩模块下的 /resources/application.yml
的信息。
server:
port: 8080 # order-service 模块
# port: 8081 # user-service 模块
spring:
datasource:
# url: jdbc:mysql://localhost:3306/cloud_order?useSSL=false # MySQL5
url: jdbc:mysql://localhost:3306/cloud_order?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&useSSL=false&allowPublicKeyRetrieval=true # MySQL8
username: root
password: root
# driver-class-name: com.mysql.jdbc.Driver # MySQL5
driver-class-name: com.mysql.cj.jdbc.Driver # MySQL8
#####################################################
application:
# eureka 的服务名称
name: order-service # order-service 模块
# name: user-service # 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://127.0.0.1:10086/eureka
#####################################################
- 启动多个实例
为了演示一个服务有多个实例的场景,我们添加一个 SpringBoot 的启动配置,再启动一个 user-service。
配置成功后运行项目。
运行时 SpringBoot 窗口会出现两个 user-service 的启动配置。
eureka-server 管理页面的用例表也会显示相关信息。
显然,无论是消费者还是提供者,引入 eureka-client 依赖、知道 eureka 地址后,都可以完成注册服务。
3.6.服务发现
服务拉取是基于服务名称获取服务列表,然后对服务列表做负载均衡
下面,我们将对 order-service 进行逻辑修改。
向 eureka-server 拉取 user-service 的信息,实现服务发现(服务拉取)。
即 order-service 要去 eureka-server 中拉取 user-service 服务的实例列表,并且实现负载均衡。
不过这些动作不用我们去做,只需要添加一些注解即可。
- 修改 OrderService 的 queryOrderById 方法,修改访问的 url 路径,用服务名代替 ip、端口。
order-service 模块:src/main/java/cn/itcast/order/service/OrderService.java
//2.1.url 路径
//String url = "http://localhost:8081/user/" + order.getUserId();
String url = "http://user-service/user/" + order.getUserId();
- 在 order-service 项目的启动类 OrderApplication 中的 RestTemplate 添加负载均衡注解。
给 RestTemplate 这个 Bean 添加一个 @LoadBalanced
注解
order-service 模块:src/main/java/cn/itcast/order/OrderApplication.java
@Bean
@LoadBalanced //负载均衡注解
public RestTemplate restTemplate() {
return new RestTemplate();
}
之后 spring 会自动帮助我们从 eureka-server 端,根据 user-service 这个服务名称,获取实例列表,而后完成负载均衡。
- 结果展示
发起多次请求:localhost:8080/order/103
此时 idea 的控制台界面
显然,俩端口均有响应。
3.7.总结
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.提问
上一节中,我们添加了 @LoadBalanced 注解,即可实现负载均衡功能,这是什么原理呢?
此处我们发出的请求明明是 http://userservice/user/1
,最终是怎么变成 http://localhost:8081
的呢?
4.1.2.源码探究
为什么我们只输入了 service 名称就可以访问了呢?之前还要获取 ip 和端口。
显然有人帮我们根据 service 名称,获取到了服务实例的 ip 和端口。
它就是LoadBalancerInterceptor
,这个类会在对 RestTemplate 的请求进行拦截。
然后从 Eureka 中获取服务列表(根据服务 id),随后利用负载均衡算法得到真实的服务地址信息,替换服务 id。
- LoadBalancerIntercepor
可以看到这里的 intercept 方法,拦截了用户的 HttpRequest 请求,然后做了几件事:
request.getURI()
:获取请求 uri,本例中就是http://user-service/user/8
originalUri.getHost()
:获取 uri 路径的主机名,其实就是服务 id,user-service
this.loadBalancer.execute()
:处理服务 id,和用户请求。
这里的 this.loadBalancer
是 LoadBalancerClient
类型,我们继续跟入。
- LoadBalancerClient
继续跟入 LoadBalancerIntercepor 中的 execute 方法,来到 LoadBalancerClient
代码是这样的:
- getLoadBalancer(serviceId):根据服务 id 获取 ILoadBalancer,而 ILoadBalancer 会拿着服务 id 去 eureka 中获取服务列表并保存起来。
- getServer(loadBalancer):利用内置的负载均衡算法,从服务列表中选择一个。本例中,可以看到获取了 8082 端口的服务
放行后,再次访问并跟踪,发现获取的是 8081
显然实现了负载均衡。
- 负载均衡策略 IRule
在刚才的代码中,可以看到获取服务是通过一个 getServer
方法来做负载均衡
继续跟踪源码 chooseServer 方法,发现这么一段代码
我们看看这个 rule 是谁
这里的 rule 默认值是一个 RoundRobinRule
,根据类的介绍可知其为轮询。
到这里,整个负载均衡的流程我们就清楚了。
4.1.3.小结
SpringCloudRibbon 的底层采用了一个拦截器,拦截了 RestTemplate 发出的请求,对地址做了修改。
基本流程如下:
- 拦截 RestTemplate 请求
http://user-service/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 有很多不同的实现类。
每一个子接口都是一种规则。
常见规则
内置负载均衡规则类 | 规则描述 |
---|---|
RoundRobinRule | 简单轮询服务列表来选择服务器。它是 Ribbon 默认的负载均衡规则。 |
AvailabilityFilteringRule | 对以下两种服务器进行忽略 (1)在默认情况下,这台服务器如果 3 次连接失败,这台服务器就会被设置为 “短路” 状态。 短路状态将持续 30 秒,如果再次连接失败,短路的持续时间就会几何级地增加。 (2)并发数过高的服务器。 如果一个服务器的并发连接数过高,配置了 AvailabilityFilteringRule 规则的客户端也会将其忽略。 并发连接数的上限,可以由客户端的属性进行配置。 该属性为 <clientName>.<clientConfigNameSpace>.ActiveConnectionsLimit 。 |
WeightedResponseTimeRule | 为每一个服务器赋予一个权重值。 服务器响应时间越长,这个服务器的权重就越小。 这个规则会随机选择服务器,这个权重值会影响服务器的选择。 |
ZoneAvoidanceRule | 以区域可用的服务器为基础进行服务器的选择。 使用 Zone 对服务器进行分类,这个 Zone 可以理解为一个机房、一个机架等。 而后再对 Zone 内的多个服务做轮询。 |
BestAvailableRule | 忽略那些短路的服务器,并选择并发数较低的服务器。 |
RandomRule | 随机选择一个可用的服务器。 |
RetryRule | 重试机制的选择逻辑。 |
默认的实现就是 ZoneAvoidanceRule,是一种轮询方案。
4.3.自定义负载均衡策略
通过定义 IRule 实现可以修改负载均衡规则,有两种方式:代码的方式和配置文件的方式。
- 代码方式:在 order-service 中的 OrderApplication 类中,定义一个新的 IRule。
这种方案是作用于全局的。
也就是说,在 order-service 中调用任何服务时,都采用相同的负载均衡策略。
order-service 模块中的 src/main/java/cn/itcast/order/OrderApplication.java
/**
* 自定义负载均衡规则:此处设置为随机
*
* @return
*/
@Bean
public IRule randomRule() {
return new RandomRule();
}
- 配置文件方式:在 order-service 的 application.yml 文件中,添加新的配置也可以修改规则。
order-service 模块中的 src/main/resources/application.yml
user-service: # 给某个微服务配置负载均衡规则
ribbon:
NFLoadBalancerRuleClassName: comflix.loadbalancer.RandomRule # 负载均衡规则:此处设置为随机
- 注意:一般使用默认的负载均衡规则,不做修改。
4.4.饥饿加载
Ribbon 默认是采用懒加载,即第一次访问时才会去创建 LoadBalanceClient,请求时间会很长。
而饥饿加载则会在项目启动时创建,降低第一次访问的耗时,通过配置开启饥饿加载。
比如我们可以在 order-service 模块内的 src/main/resources/application.yml
中配置加载信息。
ribbon:
eager-load:
enabled: true # 开启饥饿加载
clients: user-service # 指定对 user-service 这个服务饥饿加载
ribbon:
eager-load:
enabled: true # 开启饥饿加载
clients: # 指定饥饿加载的多个服务的名称
- user-service
- xxx-service
4.5.总结
1.Ribbon负载均衡规则
- 规则接口是 IRule
- 默认实现是 ZoneAvoidanceRule。根据 zone 选择服务列表,然后轮询
2.负载均衡自定义方式
- 代码方式:配置灵活,但修改时需要重新打包发布
- 配置方式:直观,方便,无需重新打包发布,但是无法做全局配置
3.饥饿加载
- 通过配置文件开启饥饿加载
- 通过配置文件指定饥饿加载的微服务名称
5.Nacos 注册中心
国内公司一般都推崇阿里巴巴的技术。
比如注册中心,SpringCloudAlibaba 也推出了一个名为 Nacos 的注册中心。
5.1.认识 Nacos
Nacos 是阿里巴巴的产品,现在是 SpringCloud 中的一个组件。
相比 Eureka 功能更加丰富,在国内受欢迎程度较高。
5.2.Windows 安装启动 Nacos
Windows 安装
- 下载安装包
- 解压
- 端口配置
- 启动
- 访问
- 小结
- 下载安装包
开发阶段采用单机安装即可。
在 Nacos 的 GitHub 页面,提供有下载链接,可以下载编译好的 Nacos 服务端或者源代码
GitHub 主页:https://github/alibaba/nacos
GitHub 的 Release 下载页:https://github/alibaba/nacos/releases
本课程采用 1.4.1.X 版本的 Nacos,课前资料已经准备了安装包。
Windows 版本使用 nacos-server-1.4.1.zip
包即可。
- 解压
将这个包解压到任意非中文目录下
目录说明:bin(启动脚本)、conf(配置文件)。
- 端口配置
Nacos 的默认端口是 8848,如果你电脑上的其它进程占用了 8848 端口,请先尝试关闭该进程。
如果无法关闭占用 8848 端口的进程,也可以进入 nacos 的 conf 目录,修改配置文件中的端口。
- 启动
启动非常简单,进入 bin 目录,结构如下
然后执行命令即可(windows 命令)
startup.cmd -m standalone
其中,standalone 意为单机启动模式。
执行后的效果图
- 访问
在浏览器输入地址:http://127.0.0.1:8848/nacos
即可
默认的用户和密码都是 nacos。
成功登录后,就会进入这样的页面。
- 小结
Nacos服务搭建
- 下载安装包、解压到非中文命名的目录
- 在
bin
目录下运行指令:startup.cmd -m standalone
5.3.服务注册到 Nacos
Nacos 是 SpringCloudAlibaba 的组件。
而 SpringCloudAlibaba 也遵循 SpringCloud 中定义的服务注册、服务发现规范。
因此使用 Nacos 和使用 Eureka 对于微服务来说,并没有太大区别。
Nacos 和 Eureka 的主要差异在于:依赖不同、服务地址不同
5.3.1.引入依赖
在 cloud-demo 父工程的 pom 文件中的 <dependencyManagement>
中引入 SpringCloudAlibaba 的依赖
<!-- 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 依赖
同时也需要注释掉 eureka 的依赖。
<!-- 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 地址
spring:
cloud:
nacos:
server-addr: localhost:8848
当然,此处也需注释掉 eureka 的地址
重启微服务后,登录 nacos 管理页面,我们在 “服务管理” 的 “服务列表” 里可以发现微服务信息
在 “详情” 则有着更为详细的数据信息。
5.3.3.小结
Nacos 服务注册或发现
- 引入 nacos.discovery 依赖
- 配置 nacos 地址 spring.cloud.nacos.server-addr
5.4.Nacos 服务多级存储模型
5.4.1.Nacos 概述
一个服务可以有多个实例。
例如我们的 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 是服务,一个服务可以包含多个集群。
如杭州、上海,每个集群下可以有多个实例,形成分级模型。
微服务互相访问时,应该尽可能访问同集群实例,因为本地访问速度更快。
当本集群内不可用时,才访问其它集群。
如上图,杭州机房内的 order-service 应该优先访问同机房的 user-service。
5.4.2.给 user-service 配置集群
修改 user-service 的 application.yml
文件,添加集群配置
spring:
cloud:
nacos:
server-addr: localhost:8848 # nacos 服务地址
discovery:
cluster-name: HZ # 集群名称
# cluster-name: SH # 集群名称
我们可以给 UserApplication_1 服务和 UserApplciation_2 服务设置集群名称为:HZ
给 UserApplication_3 服务设置集群名称为 SH
- 可以在前两个服务启动后,更改配置文件的内容,再启动第三个服务来达到上述的目的
- 也可以直接通过 Idea 工具来设置集群名称
-Dserver.port=8083 -Dspring.cloud.nacos.discovery.cluster-name=SH
重启微服务,进入 Nacos 管理界面
服务管理/服务列表
服务管理/服务列表/详情
5.4.3.同集群优先的负载均衡
默认的 ZoneAvoidanceRule
并不能实现根据同集群优先来实现负载均衡。
因此 Nacos 中提供了一个 NacosRule
的实现,可以优先从同集群中挑选实例。
- 给 order-service 配置集群信息
修改 order-service 的 application.yml 文件,添加集群配置。
spring:
cloud:
nacos:
server-addr: localhost:8848 # nacos 的服务端地址
discovery:
cluster-name: HZ # 配置集群名称,即机房地址
- 修改负载均衡规则
修改 order-service 的 application.yml 文件,修改负载均衡规则。
user-service:
ribbon:
NFLoadBalancerRuleClassName: com.alibaba.cloud.nacos.ribbon.NacosRule # 负载均衡规则
NacosRule 优先选择本地集群,再在本地集群内的多个服务采用随机方式进行负载均衡。
5.4.4.小结
Nacos 服务分级存储模型
- 一级是服务。例如 user-service
- 二级是集群。例如杭州或上海
- 三级是实例。例如杭州机房的某台部署了 user-service 的服务器
如何设置实例的集群属性
- 修改 application.yml 文件,添加 spring.cloud.nacos.discovery.cluster-name 属性
NacosRule
负载均衡策略
- 优先选择同集群服务实例列表
- 本地集群找不到提供者,才去其它集群寻找,并且会报警告
- 确定了可用实例列表后,再采用随机负载均衡挑选实例
5.5.权重配置
实际部署中会出现这样的场景
服务器设备性能有差异,部分实例所在机器性能较好,另一些较差,我们希望性能好的机器承担更多的用户请求。
但默认情况下 NacosRule 是同集群内随机挑选,不会考虑机器的性能问题。
因此,Nacos 提供了权重配置来控制访问频率,权重越大则访问频率越高。
- 在 nacos 控制台,找到 user-service 的实例列表,点击编辑,即可修改权重。
- 在弹出的编辑窗口,修改权重
- 注意:如果权重修改为 0,则该实例永远不会被访问。
实际的权重控制
- Nacos 控制台可以设置实例的权重值,0~1 之间
- 同集群内的多个实例,权重越高被访问的频率越高
- 权重设置为 0 则完全不会被访问
5.6.环境隔离
5.6.1.简单介绍
Nacos 提供了 namespace 来实现环境隔离功能。
- nacos 中可以有多个 namespace
- namespace 下可以有 group、service 等
- 不同 namespace 之间相互隔离,例如不同 namespace 的服务互相不可见
5.6.2.创建 namespace
默认情况下,所有 service、data、group 都在同一个 namespace,名为 public
我们可以点击页面新增按钮,添加一个 namespace。
然后,填写表单
5.6.3.给微服务配置 namespace
给微服务配置 namespace 只能通过修改配置来实现。
例如,修改 order-service 的 application.yml 文件
spring:
cloud:
nacos:
server-addr: localhost:8848
discovery:
cluster-name: HZ
namespace: 492a7d5d-237b-46a1-a99a-fa8e98e4b0f9 # 命名空间,填 ID
重启 order-service 后,访问控制台,可以看到下面的结果
此时访问 order-service,因为 namespace 不同,会导致找不到 userservice,控制台会报错
5.6.4.小结
Nacos 环境隔离
- 每个 namespace 都有唯一 id
- 服务设置 namespace 时要写 id 而不是名称
- 不同 namespace 下的服务互相不可见
5.7.Nacos 与 Eureka 的区别
Nacos 的服务实例分为两种类型
- 临时实例:如果实例宕机超过一定时间,会从服务列表剔除,默认的类型。
- 非临时实例:如果实例宕机,不会从服务列表剔除,也可以叫永久实例。
配置一个服务实例为永久实例,比如这里可以选择 order-service 服务实例。
spring:
cloud:
nacos:
discovery:
ephemeral: false # 设置为非临时实例
Nacos 和 Eureka 整体结构类似,服务注册、服务拉取、心跳等待,但是也存在一些差异。
- Nacos 与 eureka 的共同点
- 都支持服务注册和服务拉取
- 都支持服务提供者心跳方式做健康检测
- Nacos 与 Eureka 的区别
- Nacos 支持服务端主动检测提供者状态:临时实例采用心跳模式,非临时实例采用主动检测模式
- 临时实例心跳不正常会被剔除,非临时实例则不会被剔除
- Nacos 支持服务列表变更的消息推送模式,服务列表更新更及时
- Nacos 集群默认采用 AP 方式;当集群中存在非临时实例时,采用 CP 模式。而 Eureka 集群采用 AP 方式
# SpringCloudDay02
SpringCloud 学习 Day02(实用篇-2)
- Nacos 配置管理
- Feign 远程调用
- GetWay 服务网关
6.Nacos 配置管理
Nacos 除了可以做注册中心,同样可以做配置管理来使用。
6.1.统一配置管理
当微服务部署的实例越来越多,达到数十、数百时,逐个修改微服务配置就会让人抓狂,而且很容易出错。
我们需要一种统一配置管理方案,可以集中管理所有实例的配置。
6.1.1.在 Nacos 中添加配置文件
在 Nacos 中管理配置
在弹出表单中填写配置信息
- 注意
- 项目的核心配置,需要热更新的配置才有放到 nacos 管理的必要。
- 基本不会变更的一些配置还是保存在微服务本地比较好。
6.1.2.微服务配置拉取
微服务要拉取 nacos 中管理的配置,并且与本地的 application.yml
配置合并,才能完成项目启动。
但如果尚未读取 application.yml
,又如何得知 nacos 地址呢?
因此 spring 引入了一种新的配置文件:bootstrap.yaml
文件,会在 application.yml
之前被读取。
- 引入 nacos-config 依赖
在 user-service
服务中,引入 nacos-config 的客户端依赖
<!-- Nacos 配置管理依赖-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
- 添加
bootstrap.yaml
在 user-service
中添加一个 bootstrap.yaml
文件
spring:
application:
name: userservice # 服务名称
profiles:
active: dev #开发环境,这里是 dev
cloud:
nacos:
server-addr: localhost:8848 # Nacos 地址
config:
file-extension: yaml # 文件后缀名
同时,这里需要注释掉之前在 user-service
的 application.yml
文件中的一些重复的内容
诸如 application.name
、cloud.nacos.xxx
的内容。
这里会根据 spring.cloud.nacos.server-addr 获取 nacos 地址。
再根据 ${spring.application.name}-${spring.profiles.active}.${spring.cloud.nacos.config.file-extension}
作为文件 id ,来读取配置。
本例中,就是去读取 userservice-dev.yaml
- 读取 nacos 配置
在 user-service
中的 UserController 中添加业务逻辑,读取 pattern.dateformat
配置
/* 目前 UserController 需要导入的包(这里容易导错包,故直接贴上有关包的代码)*/
import cn.itcast.user.pojo.User;
import cn.itcast.user.service.UserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
@Value("${pattern.dateformat}")
//@NacosValue("${pattern.dateformat}")
private String dateformat;
@GetMapping("now")
public String now() {
// 完成日期格式化并返回
System.out.println(dateformat);
return LocalDateTime.now().format(DateTimeFormatter.ofPattern(dateformat));
}
效果就是这样子。
我这里使用的是 Postman 工具,当然你也可以直接在页面上访问。
6.1.3.小结
- 在 Nacos 中添加配置文件
- 在微服务中引入 nacos 的 config 依赖
- 在微服务中添加
bootstrap.yml
,配置 nacos 地址、当前环境、服务名称、文件后缀名。- 这些决定了程序启动时去 nacos 读取哪个文件
6.2.配置热更新
我们最终的目的,是修改 nacos 中的配置后,微服务中无需重启即可让配置生效,也就是配置热更新。
要实现配置热更新,有两种方式可供选择
- 在
@Value
注入的变量所在类上添加注解@RefreshScope
- 使用
@ConfigurationProperties
注解代替@Value
注解
方式一:在 @Value
注入的变量所在类上添加注解 @RefreshScope
。
user-service
服务下的 src/main/java/cn/itcast/user/web/UserController.java
方式二:使用 @ConfigurationProperties
注解代替 @Value
注解。
在 user-service
服务中,添加一个类,读取 patterrn.dateformat
属性
user-service
下的 src/main/java/cn/itcast/user/config/PatternProperties.java
@Component
@Data
@ConfigurationProperties(prefix = "pattern")
public class PatternProperties {
private String dateformat;
}
若代码报红:Spring Boot Configuration Annotation Processor not configured
,可以在 pom.xml
中添加依赖
在 user-service
下的 pom.xml
导入 spring-boot-configuration-processor
的相关依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
在 UserController 类中使刚刚创建的类代替 @Value
,并且注释掉之前的代码
修改 user-service
下的 src/main/java/cn/itcast/user/web/UserController.java
的代码
//@RefreshScope // 需要注释的注解
//@Value("${pattern.dateformat}") // 注释掉的代码
//private String dateformat; // 注释掉的代码
@Autowired
private PatternProperties patternProperties;
//编写 Controller,通过日期格式化器现在的时间并返回
@GetMapping("now")
public String now() {
//return LocalDateTime.now().format(DateTimeFormatter.ofPattern(dateformat));// 注释掉的代码
return LocalDateTime.now().format(DateTimeFormatter.ofPattern(patternProperties.getDateformat()));
}
小结
Nacos 配置更改后,微服务可以实现热更新
- 方式一:通过
@Value
注解注入,结合@RefreshScope
来刷新 - 方式二:通过
@ConfigurationProperties
注入,自动刷新
注意事项
- 不是所有的配置都适合放到配置中心。都放到配置中心的话,维护起来就比较麻烦
- 建议将一些关键参数,需要运行时调整的参数放到 nacos 配置中心,一般都是自定义配置
6.3.配置共享
其实微服务启动时,会去 nacos 读取多个配置文件,例如:
[spring.application.name]-[spring.profiles.active].yaml
,例如:userservice-dev.yaml
[spring.application.name].yaml
,例如:userservice.yaml
无论 profile 如何变化,[spring.application.name].yaml
这个文件一定会加载
因此多环境共享配置可以写入这个文件。
优先级:[服务名]-[环境].yaml
> [服务名].yaml
> 本地配置
步骤
- 添加一个环境共享配置
- 在
user-service
中读取共享配置 - 运行两个 UserApplication,使用不同的 profile
- 配置共享的优先级
- 添加一个环境共享配置
我们在 nacos 中添加一个 userservice.yaml
文件
- 在
user-service
中读取共享配置
在 user-service
服务中,修改 PatternProperties 类,读取新添加的属性
user-service
下的 src/main/java/cn/itcast/user/config/PatternProperties.java
@Data
@Component
@ConfigurationProperties(prefix = "pattern")
public class PatternProperties {
private String dataformat;
/* ***************************** */
private String envSharedValue;
/* ***************************** */
}
在 user-service
服务中,修改 UserController,添加一个方法:
@GetMapping("/prop")
public PatternProperties properties() {
return patternProperties;
}
- 运行两个 UserApplication,使用不同的 profile
UserApplication_1(8081)使用的 profile 是 dev
UserApplication_2(8082)使用的 profile 是 test(这里就只贴一张图了)
启动 UserApplication_1 和 UserApplication_2,访问 http://localhost:8081/user/prop
和 http://localhost:8082/user/prop
可以看出来,不管是 dev,还是 test 环境,都读取到了 envSharedValue 这个属性的值。
- 配置共享的优先级
当 nacos、服务本地同时出现相同属性时,优先级有高低之分
6.4.搭建 Nacos 集群
Nacos 生产环境下一定要部署为集群状态
6.4.1.集群架构图
官方给出的 Nacos 集群图
其中包含 3 个 nacos 节点,然后一个负载均衡器代理 3 个 Nacos。
这里负载均衡器可以使用 nginx。
我们计划的集群结构
三个 nacos 节点的地址
节点 | ip | port |
---|---|---|
nacos1 | 192.168.150.1 | 8845 |
nacos2 | 192.168.150.1 | 8846 |
nacos3 | 192.168.150.1 | 8847 |
6.4.2.搭建集群的基本步骤
- 搭建 MySQL 集群并初始化数据库表
- 下载解压 nacos
- 修改集群配置(节点信息)、数据库配置
- 分别启动多个 nacos 节点
- nginx 反向代理
6.4.3.初始化数据库
Nacos 默认数据存储在内嵌数据库 Derby 中,不属于生产可用的数据库。
官方推荐的最佳实践是使用带有主从的高可用数据库集群。
这里我们以单点的数据库为例来讲解。
首先新建一个数据库,命名为 nacos,字符集是 utf8,排序规则是 utf8_general_ci。
而后导入下面的 SQL。
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');
6.4.5.下载 Nacos
nacos 在 GitHub 上有下载地址:https://github/alibaba/nacos/tags,可选择任意版本下载。
本例中使用的是 1.4.1 版本
6.4.4.配置 Nacos
将包解压到非中文目录,其中 bin
目录:启动脚本,conf
目录:配置文件
进入 nacos 的 conf
目录,复制配置文件 cluster.conf.example
,命名为 cluster.conf
之后添加内容
127.0.0.1:8845
127.0.0.1.8846
127.0.0.1.8847
修改 application.properties
文件,添加数据库配置
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
6.4.6.启动
将 nacos 文件夹复制三份,分别命名为:nacos1、nacos2、nacos3
然后分别修改三个文件夹中的 application.properties
nacos1
server.port=8845
nacos2
server.port=8846
nacos3
server.port=8847
然后分别启动三个 nacos 节点
startup.cmd
当三个终端都出现:Nacos started successfully in cluster mode. use external storage
,表示集群启动成功。
6.4.7.Nginx 反向代理
找到课前资料提供的 nginx 安装包,解压到任意非中文目录下。
修改 conf/nginx.conf
文件,配置如下
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
文件配置如下
spring:
cloud:
nacos:
server-addr: localhost:80 # Nacos地址
之后我们可以启动两个 user-service 服务,并在 Nacos 界面设置一个配置。
之后我们可以在数据库中发现,相关记录已经存储到数据库中的 config_info 表了。
说明持久化已经成功了。
6.4.8.优化
- 实际部署时,需要给做反向代理的 nginx 服务器设置一个域名,这样后续如果有服务器迁移 nacos 的客户端也无需更改配置
- Nacos 的各个节点应该部署到多个不同服务器,做好容灾和隔离
7.Feign 远程调用
7.1.RestTemplate 存在的问题
这是之前利用 RestTemplate 发起远程调用的代码
String url = "http://user-service/user/" + order.getUserId();
User user = restTemplate.getForObject(url, User.class);
存在下面的问题:① 代码可读性差,编程体验不统一;② 参数复杂 URL 难以维护。
Feign 是一个声明式的 http 客户端,官方地址:https://github/OpenFeign/feign
其作用就是帮助我们优雅的实现 http 请求的发送,解决上面提到的问题。
7.2.Feign 替代 RestTemplate
7.2.1.导入依赖
我们在 order-service
服务的 pom 文件中引入 feign 的依赖
<!-- Feign 客户端依赖 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
7.2.2.添加注解
在 order-service
的启动类添加注解开启 Feign 的功能:@EnableFeignClients
7.2.3.编写 Feign 的客户端
在 order-service
中新建一个接口,内容如下
order-service
服务下的 src/main/java/cn/itcast/order/clients/UserClient.java
package cn.itcast.order.clients;
import cn.itcast.order.pojo.User;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@FeignClient("userservice")
public interface UserClient {
@GetMapping("/user/{id}")
User findById(@PathVariable("id") Long id);
}
这个客户端主要是基于 SpringMVC 的注解来声明远程调用的信息
- 服务名称:
userservice
- 请求方式:GET
- 请求路径:
/user/{id}
- 请求参数:
Long id
- 返回值类型:User
这样,Feign 就可以帮助我们发送 http 请求,无需自己使用 RestTemplate 来发送了。
7.2.4.测试
修改 order-service
中的 OrderService 类中的 queryOrderById 方法
并且使用 Feign 客户端代替 RestTemplate
@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;
}
此外,记得修改 order-service
服务下的 application.yml
文件的内容
- 注释掉 namespace
- 将
cloud.nacos.server-addr
的值改为localhost:80
。
观察 Idea 控制台可以发现,此时我们不仅实现了远程调用,还实现了负载均衡。
事实上,我们可以通过观察 order-service
依赖的树形图,
来了解到 spring-cloud-starter-openfeign
中已经集成了 spring-cloud-netflix-ribbon
,自动实现了负载均衡。
7.2.5.小结
使用 Feign 的步骤
- 引入依赖
- 添加
@EnableFeignClients
注解 - 编写 FeignClient 接口
- 使用 FeignClient 中定义的方法代替 RestTemplate
7.3.自定义配置
7.3.1.自定义配置表
Feign 运行自定义配置来覆盖默认配置。可以修改的配置如下。
类型 | 作用 | 说明 |
---|---|---|
feign.Logger.Level | 修改日志级别 | 包含四种不同的级别:NONE、BASIC、HEADERS、FULL |
feign.codec.Decoder | 响应结果的解析器 | http 远程调用的结果做解析,例如解析 json 字符串为 java 对象 |
feign.codec.Encoder | 请求参数编码 | 将请求参数编码,便于通过 http 请求发送 |
feign. Contract | 支持的注解格式 | 默认是 SpringMVC 的注解 |
feign. Retryer | 失败重试机制 | 请求失败的重试机制,默认是没有,不过会使用 Ribbon 的重试 |
一般我们需要配置的就是日志级别
下面以日志为例来演示如何自定义配置。
7.3.2.配置文件方式
基于配置文件修改 feign 的日志级别可以针对单个服务:
feign:
client:
config:
userservice: # 针对某个微服务的配置
loggerLevel: FULL # 日志级别
也可以针对所有服务:
feign:
client:
config:
default: # 这里用 default 就是全局配置,如果是写服务名称,则是针对某个微服务的配置
loggerLevel: FULL # 日志级别
而日志的级别分为四种:
- NONE:不记录任何日志信息,这是默认值。
- BASIC:仅记录请求的方法,URL 以及响应状态码和执行时间
- HEADERS:在 BASIC 的基础上,额外记录了请求和响应的头信息
- FULL:记录所有请求和响应的明细,包括头信息、请求体、元数据。
7.3.3.Java 代码方式
也可以基于 Java 代码来修改日志级别,先声明一个类,然后声明一个 Logger.Level
的对象
比如我可以在 order-service
服务下创建一个类:cn/itcast/order/config/DefaultFeignConfiguration.java
public class DefaultFeignConfiguration {
@Bean
public Logger.Level feignLogLevel(){
return Logger.Level.BASIC; // 日志级别为 BASIC
}
}
如果要全局生效,将其放到启动类的 @EnableFeignClients
这个注解中:
比如这里就可以放在 order-service
服务下的 cn/itcast/order/OrderApplication.java
@EnableFeignClients(defaultConfiguration = DefaultFeignConfiguration .class)
如果是局部生效,则把它放到对应的 @FeignClient
这个注解中:
比如这里就可以放在 order-service
服务下的 cn/itcast/order/clients/UserClient.java
@FeignClient(value = "userservice", configuration = DefaultFeignConfiguration .class)
7.3.4.小结
Feign 的日志配置
- 方式一是配置文件,
feign.client.config.xxx.loggerlevel
- 如果 xxx 是 default,则代表全局
- 如果 xxx 是服务名称(例如 userservice),那么它代表的就是某服务
- 方式二是 java 代码配置 Logger.Level 这个 Bean
- 如果在
@EnableFeignClients
注解声明则代表全局 - 如果在
@FeignClient
注解中声明则代表某服务
- 如果在
7.4.Feign 使用优化
7.4.1.分析
Feign 底层发起 http 请求,依赖于其它的框架。
其底层客户端实现包括:
- URLConnection:默认实现,不支持连接池
- Apache HttpClient:支持连接池
- OKHttp:支持连接池
因此提高 Feign 的性能主要手段就是使用连接池代替默认的 URLConnection。
日志界别的话,最好用 basic 或 none。
这里我们用 Apache的HttpClient 来演示。
7.4.2.引入依赖
在 order-service
的 pom.xml
文件中引入 Apache 的 HttpClient 依赖
<!-- httpClient 的依赖 -->
<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-httpclient</artifactId>
</dependency>
7.4.3.配置连接池
在 order-service
的 application.yml
中添加配置
feign:
client:
config:
default: # default 全局的配置
loggerLevel: BASIC # 日志级别,BASIC 就是基本的请求和响应信息
httpclient:
enabled: true # 开启 feign 对 HttpClient 的支持
max-connections: 200 # 最大的连接数
max-connections-per-route: 50 # 每个路径的最大连接数
接下来,在 FeignClientFactoryBean 中的 loadBalance 方法中打断点:
Debug 方式启动 order-service
服务,可以看到这里的 client,底层就是 Apache HttpClient
7.4.4.总结
Feign 的优化
- 日志级别尽量用 basic
- 使用 HttpClient 或 OKHttp 代替 URLConnection
- 引入 feign-httpClient 依赖
- 配置文件开启 httpClient 功能,设置连接池参数
7.5.最佳实践
7.5.1.分析
最佳实践,就是使用过程中总结的经验,最好的一种使用方式。
经由观察可以发现,Feign 的客户端与服务提供者的 controller 代码非常相似
feign 客户端
UserController
有没有一种办法简化这种重复的代码编写呢?
7.5.2.继承方式
给消费者的 FeignClient 和提供者的 controller 定义统一的父接口作为标准
一样的代码可以通过继承来共享
- 定义一个 API 接口,利用定义方法,并基于 SpringMVC 注解做声明
- Feign 客户端和 Controller 都集成改接口
优点
- 简单、实现了代码共享
缺点
- 服务提供方、服务消费方紧耦合
- 参数列表中的注解映射并不会继承,因此 Controller 中必须再次声明方法、参数列表、注解
7.5.3.抽取方式
将 Feign 的 Client 抽取为独立模块,并且把接口有关的 POJO、默认的 Feign 配置都放到这个模块中,提供给所有消费者使用。
例如,将 UserClient、User、Feign 的默认配置都抽取到一个 feign-api 包中,所有微服务引用该依赖包,即可直接使用。
实现最佳实践方式二(抽取方式)的步骤如下
- 首先创建一个 module,命名为
feign-api
,然后引入 feign 的 starter 依赖 - 将
order-service
中编写的 UserClient、User、DefaultFeignConfiguration 都复制到 feign-api 项目中 - 在
order-service
中引入 feign-api 的依赖 - 修改
order-service
中的所有与上述三个组件有关的 import 部分,改成导入 feign-api 中的包 - 重启测试
7.5.4.小结
Feign 的最佳实践
- 继承方式:让 controller 和 FeignClient 继承同一接口
- 抽取方式:将 FeignClient、POJO、Feign 的默认配置都定义到一个项目中,供所有消费者使用
7.6.实现基于抽取的最佳实践
7.6.1.抽取
首先创建一个 module,命名为 feign-api
在 feign-api
中然后引入 feign 的 starter 依赖
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
然后,order-service
中编写的 UserClient、User、DefaultFeignConfiguration 都复制到 feign-api
项目中。
下图为最终的项目结构
7.6.2.使用 feign-api
- 在
order-service
中使用feign-api
首先,删除 order-service
中的 UserClient、User、DefaultFeignConfiguration 等类或接口。
在 order-service
的 pom.xml
文件中中引入 feign-api
的依赖
<dependency>
<groupId>cn.itcast.demo</groupId>
<artifactId>feign-api</artifactId>
<version>1.0</version>
</dependency>
修改 order-service
中的所有与上述三个组件有关的导包部分,改成导入 feign-api
中的包
即 Order、OrderService、OrderApplication
7.6.3.重启测试
重启 OrderApplication ,Idea 控制台报错,启动失败。
Field userClient in cn.itcast.order.service.OrderService
required a bean of type 'cn.itcast.feign.clients.UserClient'
that could not be found.
这是因为 UserClient 现在在 cn.itcast.feign.clients
包下,
而 order-service
的 @EnableFeignClients
注解是在 cn.itcast.order
包下,不在同一个包,无法扫描到 UserClient
7.6.4.解决扫描包问题
当定义的 FeignClient 不在 SpringBootApplication 的扫描包范围时,这些 FeignClient 无法使用。
我们需要导入包。
不同包的 FeignClient 的导入有两种方式
- 在
@EnableFeignClients
注解中添加 basePackages,指定 FeignClient 所在的包 - 在
@EnableFeignClients
注解中添加 clients,指定具体 FeignClient 的字节码
方式一
- 指定 Feign 应该扫描的包
@EnableFeignClients(basePackages = "cn.itcast.feign.clients")
方式二(推荐)
- 指定需要加载的 Client 接口:
@EnableFeignClients(clients = {UserClient.class})
例如我们可以在 order-service
服务下的 src/main/java/cn/itcast/order/OrderApplication.java
上方添加注解
@MapperScan("cn.itcast.order.mapper")
@SpringBootApplication
@EnableFeignClients(clients = UserClient.class, defaultConfiguration = DefaultFeignConfiguration.class)
public class OrderApplication {
... ...
}
8.Gateway 服务网关
Spring Cloud Gateway 是 Spring Cloud 的一个全新项目。
该项目是基于 Spring 5.0,Spring Boot 2.0 和 Project Reactor 等响应式编程和事件流技术开发的网关,
它旨在为微服务架构提供一种简单有效的统一的 API 路由管理方式。
8.1.为什么需要网关
Gateway 网关是我们服务的守门神,所有微服务的统一入口。
网关的核心功能特性:请求路由、权限控制、限流
权限控制:网关作为微服务入口,需要校验用户是是否有请求资格,如果没有则进行拦截。
路由和负载均衡:
- 一切请求都必须先经过 gateway,但网关不处理业务。
- 而是根据某种规则,把请求转发到某个微服务,这个过程叫做路由。
- 当然路由的目标服务有多个时,还需要做负载均衡。
限流:当请求流量过高时,在网关中按照下流的微服务能够接受的速度来放行请求,避免服务压力过大。
在SpringCloud 中网关的实现包括两种:gateway、zuul
Zuul 是基于 Servlet 的实现,属于阻塞式编程。
而 SpringCloudGateway 则是基于 Spring5 中提供的 WebFlux,属于响应式编程的实现,具备更好的性能。
架构图
- 网关功能:1.身份认证和权限校验;2.服务路由、负载均衡;3.请求限流
8.2.gateway 快速入门
下面,我们就演示下网关的基本路由功能。
基本步骤
- 创建 SpringBoot 工程 gateway,引入网关依赖
- 编写启动类
- 编写基础配置和路由规则
- 启动网关服务进行测试
8.2.1.创建 gateway 服务,引入依赖
创建服务
引入依赖
gateway
服务下的 pom.xml
文件
<!--网关 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>
8.2.2.编写启动类
gateway
服务下的 src/main/java/cn/itcast/gateway/GatewayApplication.java
package cn.itcast.gateway;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class GatewayApplication {
public static void main(String[] args) {
SpringApplication.run(GatewayApplication.class, args);
}
}
8.2.3.编写基础配置和路由规则
创建 application.yml
文件
server:
port: 10010 # 网关端口
spring:
application:
name: gateway # 服务名称
cloud:
nacos:
# 我这里依旧使用的是 Nacos 集群
# server-addr: localhost:8848 # nacos 地址
server-addr: localhost:80 # 之前设置的 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://orederservice
predicates:
- Path=/order/**
我们将符合 Path
规则的一切请求,都代理到 uri
参数指定的地址。
本例中,我们将 /user/**
开头的请求,代理到 lb://userservice
,
lb(loadBalance)是负载均衡,根据服务名拉取服务列表,实现负载均衡。
8.2.4.重启测试
重启网关,访问 http://localhost:10010/user/1
时,符合 /user/**
规则,
请求转发到 uri:http://userservice/user/1
,得到了结果
这里需要注意的地方是:
第一点
确定自己使用的是 Nacos 单例环境还是 Nacos 集群环境,不同环境的服务下的配置文件都要进行内容的更改。
spring.cloud.nacos.server-addr: localhost:80
是之前配置的集群环境;
spring.cloud.nacos.server-addr: localhost:8848
是 Nacos 的默认地址,以单机方式启动:startup.cmd -m standalone
。
第二点
如果访问 http://localhost:10010/order/101
时,报 503 错误的话,
请检查 order-servie
服务下的 application.yml
,注释掉 spring.cloud.nacos.discovery.cluster-name
,
从而确保所有服务都在一个集群环境里。
这两个坑我是一个不剩的全踩上去了。
8.2.5.网关路由的流程图
搭建网关服务
8.2.6.小结
网关搭建步骤
- 创建项目,引入 nacos 服务发现和 gateway 依赖
- 配置
application.yml
,包括服务基本信息、nacos 地址、路由
路由配置
- 路由 id:路由的唯一标示
- 路由目标(uri):路由的目标地址,http 代表固定地址,lb 代表根据服务名负载均衡
- 路由断言(predicates):判断路由的规则,
- 路由过滤器(filters):对请求或响应做处理
接下来,就重点来学习路由断言和路由过滤器的详细知识。
8.3.断言工厂
路由断言工厂 Route 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,**.anotherhost |
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 这种路由工程就可以了。
详情还请见官网:https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/#gateway-request-predicates-factories
8.4.过滤器工厂
GatewayFilter 是网关中提供的一种过滤器,可以对进入网关的请求和微服务返回的响应做处理
8.4.1.路由过滤器的种类
Spring 提供了 31 种不同的路由过滤器工厂。
名称 | 说明 |
---|---|
AddRequestHeader | 给当前请求添加一个请求头 |
RemoveRequestHeader | 移除请求中的一个请求头 |
AddResponseHeader | 给响应结果中添加一个响应头 |
RemoveResponseHeader | 从响应结果中移除有一个响应头 |
RequestRateLimiter | 限制请求的流量 |
… … | … … |
具体情况还请浏览官网:https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/#gatewayfilter-factories
8.4.2.请求头过滤器
下面我们以 AddRequestHeader 为例来讲解。
- 案例需求:给所有进入
userservice
的请求添加一个请求头:Truth=itcast is freaking awesome!
实现方式:在 gateway
服务修改的 application.yml
文件,给 userservice
的路由添加过滤器
spring:
cloud:
gateway:
routes:
- id: user-service
uri: lb://userservice
predicates:
- Path=/user/**
filters: # 过滤器
- AddRequestHeader=Truth, Itcast is freaking awesome! # 添加请求头
当前过滤器写在 userservice 路由下,因此仅仅对访问 userservice 的请求有效。
修改 userservice 服务下的 src/main/java/cn/itcast/user/web/UserController.java
queryById 方法
@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);
}
向浏览器发送 Get 请求后:http://localhost:10010/user/3
的结果
8.4.3.默认过滤器
如果要对所有的路由都生效,则可以将过滤器工厂写到 default 下。格式如下:
gateway
下的 application.yml
spring:
cloud:
gateway:
routes:
- id: user-service
uri: lb://userservice
predicates:
- Path=/user/**
default-filters: # 默认过滤项
- AddRequestHeader=Truth, Itcast is freaking awesome!
8.4.4.小结
过滤器的作用是什么?
- 对路由的请求或响应做加工处理,比如添加请求头
- 配置在路由下的过滤器只对当前路由的请求生效
defaultFilters 的作用是什么?
- 对所有路由都生效的过滤器
8.5.全局过滤器
上一节学习的过滤器,网关提供了 31 种,但每一种过滤器的作用都是固定的。
如果我们希望拦截请求,做自己的业务逻辑则没办法实现。
8.5.1.全局过滤器作用
全局过滤器的作用也是处理一切进入网关的请求和微服务响应,与 GatewayFilter 的作用一样。
区别在于 GatewayFilter 通过配置定义,处理逻辑是固定的;
而 GlobalFilter 的逻辑需要自己写代码实现。
定义方式是实现 GlobalFilter 接口。
public interface GlobalFilter {
/**
* 处理当前请求,有必要的话通过 {@link GatewayFilterChain} 将请求交给下一个过滤器处理
*
* @param exchange 请求上下文,里面可以获取 Request、Response 等信息
* @param chain 用来把请求委托给下一个过滤器
* @return {@code Mono<Void>} 返回标示当前过滤器业务结束
*/
Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain);
}
在 filter 中编写自定义逻辑,可以实现下列功能:登录状态判断、权限校验、请求限流等
8.5.2.自定义全局过滤器
案例需求
- 定义全局过滤器,拦截请求,判断请求的参数是否满足下面条件:
- 参数中是否有 authorization,authorization 参数值是否为 admin
- 如果同时满足则放行,否则拦截
实现
在 gateway
中定义一个过滤器
src/main/java/cn/itcast/gateway/AuthorizeFilter.java
package cn.itcast.gateway;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.util.MultiValueMap;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
//@Order(-1)//顺序注解
@Component
public class AuthorizeFilter implements GlobalFilter, Ordered {
@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(HttpStatus.UNAUTHORIZED);
//5.2.拦截请求
return exchange.getResponse().setComplete();
}
@Override
public int getOrder() {
return -1;
}
}
测试,查看结果
访问 http://localhost:10010/user/3
,401 错误,获取不到数据。
访问 http://localhost:10010/user/1?authorization=admin
,成功获取到数据信息。
8.5.3.过滤器执行顺序
请求进入网关会碰到三类过滤器:当前路由的过滤器、DefaultFilter、GlobalFilter
请求路由后,会将当前路由过滤器和 DefaultFilter、GlobalFilter,合并到一个过滤器链(集合)中,排序后依次执行每个过滤器:
排序的规则是什么呢?
- 每一个过滤器都必须指定一个 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 排序,组织过滤器链
8.5.4.小结
全局过滤器的作用是什么?
- 对所有路由器都生效的过滤器,并且可以自定义处理逻辑
实现全局过滤器的步骤
- 实现 GlobalFilter 接口
- 添加
@Order
注解或实现 Ordered 接口 - 编写处理逻辑
路由过滤器、defaultFilter、全局过滤器的执行顺序?
- order 值越小,优先级越高
- 当 order 值一样时,顺序是 defaultFilter 最先,然后是局部的路由过滤器,最后是全局过滤器
8.6.跨域问题
8.6.1.什么是跨域问题
跨域:域名不一致就是跨域
跨域主要包括:
- 域名不同:
www.taobao
和www.taobao
;www.jd
和miaosha.jd
- 域名相同,端口不同:
localhost:8080
和localhost8081
跨域问题:浏览器禁止请求的发起者与服务端发生跨域 ajax 请求,请求被浏览器拦截的问题
解决方案:CORS(Cross-Origin Resource Sharing)
网络通信技术
CORS,全称 Cross-Origin Resource Sharing,是一种允许当前域(domain)的资源(比如 html/js/web service)被其他域(domain)的脚本请求访问的机制,通常由于同域安全策略(the same-origin security policy)浏览器会禁止这种跨域请求。(百度百科)
更多详情请见:https://www.ruanyifeng/blog/2016/04/cors.html
8.6.2.模拟跨域问题
找到课前资料的页面文件
index.html 的内容
<!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"
allowedMethods: # 允许的跨域 ajax 的请求方式
- "GET"
- "POST"
- "DELETE"
- "PUT"
- "OPTIONS"
allowedHeaders: "*" # 允许在请求中携带的头信息
allowCredentials: true # 是否允许携带 cookie
maxAge: 360000 # 这次跨域检测的有效期
</pre>
</body>
<script src="https://unpkg/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 服务器中,启动并访问。
我这里使用的是 VSCode 工具
可以在浏览器控制台看到下面的错误
从 localhost:8090
访问 localhost:10010
,端口不同,显然是跨域的请求。
8.6.3.解决跨域问题
在 gateway
服务的 application.yml
文件中,添加下面的配置
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 # 这次跨域检测的有效期
配置完成后重启网关服务
显然,成功获取到了数据。
# SpringCloudDay03
SpringCloud 学习 Day03(实用篇-3) Docker
- 初识 Docker
- Docker 的基本操作
- Dockerfile 自定义镜像
- Docker-Compose
- Docker 镜像服务
9.初始 Docker
9.1.什么是 Docker
微服务虽然具备各种各样的优势,但服务的拆分通用给部署带来了很大的麻烦。
- 分布式系统中,依赖的组件非常多,不同组件之间部署时往往会产生一些冲突。
- 在数百上千台服务中重复部署,环境不一定一致,会遇到各种问题
9.1.1.应用部署的环境问题
大型项目组件较多,运行环境也较为复杂,部署时会碰到一些问题:
- 依赖关系复杂,容易出现兼容性问题
- 开发、测试、生产环境有差异
例如一个项目中,部署时需要依赖于 node.js、Redis、RabbitMQ、MySQL 等,
这些服务部署时所需要的函数库、依赖项各不相同,甚至会有冲突。给部署带来了极大的困难。
9.1.2.Docker 解决依赖兼容问题
Docker 为了解决依赖的兼容问题的,采用了两个手段:
- 将应用的 Libs(函数库)、Deps(依赖)、配置与应用一起打包
- 将每个应用放到一个隔离容器去运行,避免互相干扰
这样打包好的应用包中,既包含应用本身,也保护应用所需要的 Libs、Deps,
无需再操作系统上安装这些,自然就不存在不同应用之间的兼容问题了。
虽然解决了不同应用的兼容问题,但是开发、测试等环境会存在差异,操作系统版本也会有差异,怎么解决这些问题呢?
9.1.3.Docker 解决操作系统环境差异
要解决不同操作系统环境差异问题,必须先了解操作系统结构。
以一个 Ubuntu 操作系统为例,结构如下:
结构包括:
- 计算机硬件:例如 CPU、内存、磁盘等
- 系统内核:所有 Linux 发行版的内核都是 Linux,例如 CentOS、Ubuntu、Fedora 等。
内核可以与计算机硬件交互,对外提供内核指令,用于操作计算机硬件。 - 系统应用:操作系统本身提供的应用、函数库。这些函数库是对内核指令的封装,使用更加方便。
应用于计算机交互的流程如下:
- 应用调用操作系统应用(函数库),实现各种功能
- 系统函数库是对内核指令集的封装,会调用内核指令
- 内核指令操作计算机硬件
Ubuntu 和 CentOS 都是基于 Linux 内核,无非是系统应用不同,提供的函数库有差异:
此时,如果将一个 Ubuntu 版本的 MySQL 应用安装到 CentOS 系统,
MySQL 在调用 Ubuntu 函数库时,会发现找不到或者不匹配,就会报错了:
Docker 如何解决不同系统环境的问题?
- Docker 将用户程序与所需要调用的系统(比如 Ubuntu)函数库一起打包
- Docker 运行到不同操作系统时,直接基于打包的函数库,借助于操作系统的 Linux 内核来运行
如图:
9.1.4.小结
Docker 如何解决大型项目依赖关系复杂,不同组件依赖的兼容性问题?
- Docker 允许开发中将应用、依赖、函数库、配置一起打包,形成可移植镜像
- Docker 应用运行在容器中,使用沙箱机制,相互隔离
Docker 如何解决开发、测试、生产环境有差异的问题?
- Docker 镜像中包含完整运行环境,包括系统函数库,仅依赖系统的 Linux 内核,因此可以在任意 Linux 操作系统上运行
Docker 是一个快速交付应用、运行应用的技术
- 可以将程序及其依赖、运行环境一起打包为一个镜像,可以迁移到任意 Linux 操作系统
- 运行时利用沙箱机制形成隔离容器,各个应用互不干扰
- 启动、移除都可以通过一行命令完成,方便快捷
9.2.Docker 和虚拟机的区别
Docker 可以让一个应用在任何操作系统中非常方便的运行。
而以前我们接触的虚拟机,也能在一个操作系统中,运行另外一个操作系统,保护系统中的任何应用。
两者有什么差异呢?
虚拟机(virtual machine)是在操作系统中模拟硬件设备,然后运行另一个操作系统,
比如在 Windows 系统里面运行 Ubuntu 系统,这样就可以运行任意的 Ubuntu 应用了。
Docker 仅仅是封装函数库,并没有模拟完整的操作系统。
对比来看的话
特性 | Docker | 虚拟机 |
---|---|---|
性能 | 接近原生 | 性能较差 |
硬盘占用 | 一般为 MB | 一般为 GB |
启动 | 秒级 | 分钟级 |
小结
Docker 和虚拟机的差异:
- docker 是一个 系统进程;虚拟机是在操作系统中的操作系统
- docker 体积小、启动速度快、性能好;虚拟机体积大、启动速度慢、性能一般
9.3.Docker 架构
9.3.1.镜像和容器
镜像(Image):Docker 将应用程序及其所需的依赖、函数库、环境、配置等文件打包在一起,称为镜像。
容器(Container):镜像中的应用程序运行后形成的进程就是容器,只是 Docker 会给容器进程做隔离,对外不可见。
一切应用最终都是代码组成,都是硬盘中的一个个的字节形成的文件。只有运行时,才会加载到内存,形成进程。
而镜像,就是把一个应用在硬盘上的文件、及其运行环境、部分系统函数库文件一起打包形成的文件包。这个文件包是只读的。
容器呢,就是将这些文件中编写的程序、函数加载到内存中允许,形成进程,只不过要隔离起来。
因此一个镜像可以启动多次,形成多个容器进程。
例如你下载了一个 QQ,如果我们将 QQ 在磁盘上的运行文件及其运行的操作系统依赖打包,形成 QQ 镜像。
然后你可以启动多次,双开、甚至三开 QQ,跟多个妹子聊天。
9.3.2.DockerHub
开源应用程序非常多,打包这些应用往往是重复的劳动。
为了避免这些重复劳动,人们就会将自己打包的应用镜像,
例如 Redis、MySQL 镜像放到网络上,共享使用,就像 GitHub 的代码共享一样。
- DockerHub:DockerHub 是一个官方的 Docker 镜像的托管平台。这样的平台称为 Docker Registry。
- 国内也有类似于 DockerHub 的公开服务,比如 网易云镜像服务、阿里云镜像库 等。
我们一方面可以将自己的镜像共享到 DockerHub,另一方面也可以从 DockerHub 拉取镜像:
9.3.3.Docker 架构
我们要使用 Docker 来操作镜像、容器,就必须要安装 Docker。
Docker 是一个 CS 架构的程序,由两部分组成:
- 服务端(server):Docker 守护进程,负责处理 Docker 指令,管理镜像、容器等
- 客户端(client):通过命令或 RestAPI 向 Docker 服务端发送指令。可以在本地或远程向服务端发送指令。
9.3.4.小结
镜像
- 将应用程序及其依赖、环境、配置打包在一起
容器
- 镜像运行起来就是容器,一个镜像可以运行多个容器
Docker 结构
- 服务端:接收命令或远程请求,操作镜像或容器
- 客户端:发送命令或者请求到 Docker 服务端
DockerHub
- 一个镜像托管的服务器,类似的还有阿里云镜像服务,统称为 DockerRegistry
9.4.安装启动配置 Docker
9.4.1.相关说明
企业部署一般都是采用 Linux 操作系统,而其中又数 CentOS 发行版占比最多,因此我们在 CentOS 下安装 Docker。
- 版本信息
Docker 分为 CE 和 EE 两大版本。
CE 即社区版(免费,支持周期 7 个月),EE 即企业版,强调安全,付费使用,支持周期 24 个月。
Docker CE 分为 stable
、test
和 nightly
三个更新频道。
- 硬件要求
官方网站上有各种环境下的 安装指南,这里主要介绍 Docker CE 在 CentOS 上的安装。
Docker CE 支持 64 位版本 CentOS 7,并且要求内核版本不低于 3.10。
CentOS 7 满足最低内核的要求,所以我们在 CentOS 7 安装 Docker。
- 关于在虚拟机中安装 CentOS7 的操作,可以参考我之前写的博客:【Centos7 的下载安装及之后的基本操作】
- 有关 Linux 系统的一些基础知识,则可以参考我之前写的博客:【瑞吉外卖⑩ | Linux 粗略学习 & Redis 粗略学习】
9.4.2.卸载之前安装过的 Docker
如果之前安装过旧版本的 Docker,可以使用下面命令卸载
yum remove docker \
docker-client \
docker-client-latest \
docker-common \
docker-latest \
docker-latest-logrotate \
docker-logrotate \
docker-selinux \
docker-engine-selinux \
docker-engine \
docker-ce
9.4.3.具体的安装操作
- 首先要确保虚拟机可以联网,安装 yum 工具
安装必需的软件包, yum-util 提供 yum-config-manager 功能,另外两个是 devicemapper 驱动依赖的
yum install -y yum-utils \
device-mapper-persistent-data \
lvm2 --skip-broken
- 然后更新本地镜像源
设置 docker 镜像源
yum-config-manager \
--add-repo \
https://mirrors.aliyun/docker-ce/linux/centos/docker-ce.repo
sed -i 's/download.docker/mirrors.aliyun\/docker-ce/g' /etc/yum.repos.d/docker-ce.repo
将软件包信息提前在本地索引缓存(旨在可以提高搜索安装软件的速度。执行这个命令可以提升 yum 安装的速度)
yum makecache fast
- 安装 Docker
yum install -y docker-ce
docker-ce 为社区免费版本。稍等片刻,docker 即可安装成功。
这里我使用的远程控制工具是 Mobaxterm,视频中使用的是 FinalShell
使用以下命令查看安装的 Docker 版本
docker -v
9.5.启动 Docker
- 关闭防火墙
- Docker 进程相关命令
- 配置镜像
- Docker 应用需要用到各种端口,逐一去修改防火墙设置。非常麻烦,因此建议直接关闭防火墙!
关闭防火墙
systemctl stop firewalld
禁止开机自启动防火墙
systemctl disable firewalld
- Docker 进程相关命令
启动 Docker 服务
systemctl start docker
查看 Docker 服务状态
systemctl status docker
关闭 Docker 服务
systemctl stop docker
重启 Docker 服务
systemctl restart docker
开机自启动 Docker 服务
systemctl enable docker
- 配置镜像
docker 官方镜像仓库网速较差,我们需要设置国内镜像服务,这里我们选用阿里云镜像服务。
可以直接参考阿里云的镜像加速文档:https://cr.console.aliyun/cn-hangzhou/instances/mirrors
或者使用账号登录阿里云,点击 “控制台“,然后点击 “三” 在搜索栏中输入 “容器镜像服务”。
最后点击 “镜像工具” 的 “镜像加速器” 就可以看到 “加速器地址”。
在其下方的操作文档里,有非常详细的操作文档,我们将其文档上的内容复制直接到 Linux 系统的终端即可。
我们也可以使用以下命令来查看阿里云地址是否写入文件中
cat /etc/docker/daemon.json
9.6.卸载 Docker
卸载 docker 前,务必要先关掉 docker 服务
systemctl stop docker
yum remove docker-ce docker-ce-cli containerd.io
清理 docker 默认在 /var/lib
目录下配置的数据
rm -rf /var/lib/docker
rm -rf /var/lib/containerd
10.Docker 的基本操作
10.1.镜像操作
10.1.1.镜像名称
首先来看下镜像的名称组成
- 镜名称一般分两部分组成:
[repository]:[tag]
。 - 在没有指定 tag 时,默认是 latest,代表最新版本的镜像
这里的 mysql 就是 repository,5.7 就是 tag,合一起就是镜像名称,代表 5.7 版本的 MySQL 镜像。
10.1.2.镜像命令
常见的镜像操作命令如图
Docker 的命令很多,我们不需要全记住。
使用 docker --help
查看帮助文档即可。使用 docker xx --help
可以查看 xx 相关命令的帮助文档。
比如我想查看镜像的相关命令,输入 docker images --help
即可。
docker images --help
10.1.3.案例1(拉取、查看镜像)
需求:从 DockerHub 中拉取一个 nginx 镜像并查看
- 首先去镜像仓库搜索 nginx 镜像,比如 DockerHub。
相关链接:
https://hub.docker/search?q=nginx&type=image
- 根据查看到的镜像名称,通过命令
docker pull nginx
拉取自己需要的镜像
docker pull nginx
- 通过命令
docker images
查看拉取到的镜像
docker images
10.1.4.案例2(保存、导入镜像)
需求:利用 docker save
将 nginx 镜像导出磁盘,然后再通过 load 加载回来
- 利用
docker xx --help
命令查看docker save
和docker load
的语法
例如,查看 save 命令用法,可以输入命令:docker save --help
docker save --help
- 使用
docker save
导出镜像到磁盘
由上图易知命令 docker save
的完整格式 docker save -o [保存的目标文件名称] [镜像名称]
docker save -o nginx.tar nginx:latest
- 使用
docker load
加载镜像
先删除本地的 nginx 镜像
docker rmi nginx:latest
之后我们通过 docket images
发现镜像已经成功被删除。
最后再运行命令 docker load -i nginx.tar
,加载本地文件
docker load -i nginx.tar
10.1.5.练习案例
部分 Docker 镜像操作命令
docker images
(查看镜像)、docker rmi
(删除镜像)
docker pull
(拉取镜像)、docker push
(将本地的镜像上传到镜像仓库)
docker save
(保存镜像为一个压缩包)、docker load
(加载压缩包成为镜像)
docker search [镜像名称]
(从网络中查找需要的镜像)
练习案例-需求:去 DockerHub 搜索并拉取一个 Redis 镜像
练习案例-目标:
- 去 DockerHub 搜索 Redis 镜像
- 查看 Redis 镜像的名称和版本
- 利用
docker pull
命令拉取镜像 - 利用
docker save
命令将redis:latest
打包为一个redis.tar
包 - 利用
docker rmi
删除本地的redis:latest
- 利用
docker load
重新加载redis.tar
文件
练习案例-实际操作:
- 去 DockerHub 搜索 Redis 镜像
相关链接:
https://hub.docker/search?q=redis&type=image
- 查看 Redis 镜像的名称和版本
相关链接:
https://hub.docker/_/redis
- 利用
docker pull
命令拉取镜像
docker pull redis
- 利用
docker save
命令将redis:latest
打包为一个redis.tar
包
docker save -o redis.tar redis:latest
- 利用
docker rmi
删除本地的redis:latest
docker rmi redis:latest
- 利用
docker load
重新加载redis.tar
文件
docker load -i redis.tar
10.2.容器操作
10.2.1.容器相关命令
容器操作的命令如图
容器保护三个状态
- 运行:进程正常运行
- 暂停:进程暂停,CPU 不再运行,并不释放内存
- 停止:进程终止,回收进程占用的内存、CPU 等资源
其中:
docker run
:创建并运行一个容器,处于运行状态docker pause
:让一个运行的容器暂停docker unpause
:让一个容器从暂停状态恢复运行docker stop
:停止一个运行的容器docker start
:让一个停止的容器再次运行docker rm
:删除一个容器
10.2.2.案例1(创建并运行一个容器)
去 DockerHub 上查看 Nignx 的容器的运行命令:https://hub.docker/search?q=nginx
在 https://hub.docker/_/nginx 下的页面的 description 中发现了一个命令
创建并运行容器的命令
docker run --name containerName -p 80:80 -d nginx
命令解读:
docker run
:创建并运行一个容器--name
: 给容器起一个名字,比如叫做 mn-p
:将宿主机端口与容器端口映射,冒号左侧是宿主机端口,右侧是容器端口-d
:后台运行容器nginx
:镜像名称,例如 nginx
这里的 -p
参数,是将容器端口映射到宿主机端口。
默认情况下,容器是隔离环境,我们直接访问宿主机的 80 端口,肯定访问不到容器中的 nginx。
现在,将容器的 80 与宿主机的 80 关联起来,当我们访问宿主机的 80 端口时,就会被映射到容器的 80,这样就能访问到 nginx 了:
具体操作
创建并运行 nginx 容器。成功后,控制台显示容器唯一 id
docker run --name mn -p 80:80 -d nginx
查看所有运行的容器即状态 (因为我的 MobaXterm 的输出格式有点小问题,故这里使用 FinalShell)
docker ps
输入 虚拟机的IP地址
,可以访问到 Nginx 的欢迎页面(因为之前设置的宿主机映射端口都是 80)
如果之前设置的宿主机端口是 8080 ,则需要输入 虚拟机IP地址:8080
才可以访问的到
查看容器日志:docker logs containerName
docker logs mn
动态跟踪日志,可以实时查看容器日志信息:docker logs -f containerName
docker logs -f mn
欲获得更多的有关容器日志的信息,输入 docker logs --help
即可
docker logs --help
10.2.3.案例2(进入容器,修改文件)
需求:进入 Nginx 容器,修改 HTML 文件内容,添加 “stand power”。
提示:进入容器要用到 docker exec
命令。
操作步骤
- 进入容器。进入我们刚刚创建的 nginx 容器的命令为:
docker exec -it containerName bash
docker exec -it mn bash
命令解读
docker exec
:进入容器内部,执行一个命令-it
: 给当前进入的容器创建一个标准输入、输出终端,允许我们与容器交互mn
:要进入的容器的名称bash
:进入容器后执行的命令,bash 是一个 linux 终端交互命令
- 进入 nginx 的 HTML 所在目录
/usr/share/nginx/html
容器内部会模拟一个独立的 Linux 文件系统,看起来如同一个 linux 服务器一样
nginx 的环境、配置、运行文件全部都在这个文件系统中,包括我们要修改的 html 文件。
可以看作是 Linux 的阉割版,容器内部只有 Nginx 运行需要的东西。
前往 https://hub.docker/_/nginx?tab=description 页面
我们可以找到设置静态页面的方法。其中我们可以找到静态页面的位置。
当然,你也可以使用命令:find / -name containerName
find / -name nginx
找到 index.html
的位置后,就输入命令 cd /usr/local/nginx/html
进入目录。
- 修改
index.html
的内容
Nginx 容器内没有 vim
命令,无法直接修改(也没有 vi
命令)
我们用其他命令来修改
sed -i -e 's#Welcome to nginx#stand power#g' -e 's#<head>#<head><meta charset="utf-8">#g' index.html
sed
全名为 stream editor
,流编辑器。
sed -i
命令的具体情况可见博客:《sed -i
命令详解》
在浏览器访问自己的虚拟机地址,例如我的是:http://192.168.2.5
,即可看到结果
- 退出并关闭容器
关闭容器的命令:docker stop containerName
docker stop mn
显示所有容器状态(包括停止运行的容器)
docker ps -a
删除容器的命令:docker rm containerName
docker rm mn
关于以上的这些命令,都可以通过 docker xxx --help
来查看。希望诸位善用 --help
查看 docker ps
的帮助文档
docker ps --help
强制删除正在运行的容器:docker rm -f containerName
dokcer rm -f
欲知更多详情还请使用命令:docker rm --help
docker rm --help
10.2.4.练习案例
案例需求:
- 创建并允许一个 redis 容器,并且支持数据持久化
- 进入 redis 容器,并且执行
redis-cli
客户端命令,存入num=666
操作步骤
- 步骤一:到 DockerHub 搜索 Redis 镜像
- 步骤二:查看 Redis 镜像文档中的帮助信息
- 步骤三:利用
docker run
命令运行一个 Redis 容器 - 步骤四:进入 redis 容器,并且执行
redis-cli
客户端命令,存入num=666
具体操作
- 到 DockerHub 搜索 Redis 镜像
https://hub.docker/search?q=redis、https://hub.docker/_/redis?tab=description
- 查看 Redis 镜像文档中的帮助信息
docker run --name some-redis -d redis redis-server --save 60 1 --loglevel warning
- 利用
docker run
命令运行一个 Redis 容器
视频中给出的命令,与官网给出的命令约有不同,应该是官网命令更新过了。
使用视频中给出的命令,启动失败(视频发布时间是 2021-8-11)
docker run --name mr -p 6379:6379 -d redis redis-server --apppendonly yes
使用官网给出的新命令。启动成功。(访问时间是 2022-07-05)
docker run --name mr -p 6379:6379 -d redis redis-server --save 60 1 --loglevel warning
- 我这里使用的是图形化界面工具是:
Redis Desketop Manager
- 源码仓库地址:https://github/uglide/RedisDesktopManager
- 安装包仓库地址:https://github/lework/RedisDesktopManager-Windows/releases
连接成功!
- 进入 redis 容器,并且执行
redis-cli
客户端命令,存入num=666
进入 Redis 容器内部
docker -it mr bash
打开 redis-cli 客户端
redis-cli
设置指定 key 的值(string)
set num 666
获取指定 key 的值(string)
get num
当然,我们也可以直接进入 redis-cli 客户端
10.2.5.小结
docker run
命令的常见参数有哪些?
--name
:指定容器名称-p
:指定端口映射-d
:让容器后台运行
查看容器日志的命令
docker logs
- 添加
-f
参数可以持续查看日志
查看容器状态
docker ps
docker ps -a
查看所有容器,包括已经停止的
删除容器
docker rm
- 不能删除运行中的容器,除非添加
-f
参数
进入容器
- 命令是
docker exec -it [容器名] [要执行的命令]
- exec 命令可以进入容器修改文件,但是在容器内修改文件是不推荐的
10.3.数据卷操作
数据卷(容器数据管理)
在之前的 nginx 案例中,修改 nginx 的 html 页面时,需要进入 nginx 内部。并且因为没有编辑器,修改文件也很麻烦。
这就是因为容器与数据(容器内文件)耦合带来的后果。
要解决这个问题,必须将数据与容器解耦,这就要用到数据卷了。
10.3.1.什么是数据卷
数据卷(volume)是一个虚拟目录,指向宿主机文件系统中的某个目录。
一旦完成数据卷挂载,对容器的一切操作都会作用在数据卷对应的宿主机目录了。
这样,我们操作宿主机的 /var/lib/docker/volumes/html
目录,就等于操作容器内的 /usr/share/nginx/html
目录了
10.3.2.数据集操作命令
数据卷操作的基本语法如下
docker volume [COMMAND]
docker volume
命令是数据卷操作,根据命令后跟随的 command
来确定下一步的操作
create
创建一个 volumeinspect
显示一个或多个 volume 的信息ls
列出所有的 volumeprune
删除未使用的 volumerm
删除一个或多个指定的 volume
10.3.3.创建、查看、删除数据卷
- 创建数据卷:
dokcer volume create 数据卷名称
docker volume create html
- 查看所有数据卷
docker volume ls
- 查看数据卷详细信息卷(
docker volume inspect 数据卷名称
),并查看数据卷在宿主机的目录位置
docker volume inspect html
可以看到,我们创建的 html 这个数据卷关联的宿主机目录为 /var/lib/docker/volumes/html/_data
目录。
- 删除数据卷
删除所有未在使用的数据卷:docker volume prune
(不推荐,防止误删)
或者使用命令:docker volume rm 数据卷名称
来删除指定数据卷
docker volume rm html
关于 docker volume
的相关命令可以使用 docker volume --help
来查看帮助文档
docker volume --help
10.3.4.挂载数据卷
我们在创建容器时,可以通过 -v
参数来挂载一个数据卷到某个容器内目录,命令格式如下:
docker run \
--name mn \
-v html:/root/html \
-p 8080:80
nginx \
这里的 -v
就是挂载数据卷的命令:
docker run
:创建并运行容器--name mn
:给容器起个名字叫 mn-v html:/root/html
:把html
数据卷挂载到容器内的/root/html
这个目录中-p 8080:80
:把宿主机的 8080 端口映射到容器内的 80 端口nginx
:镜像名称
10.3.5.案例1(给 Nginx 挂载数据卷)
需求:创建一个 nginx 容器,修改容器内的 html
目录内的 index.html
内容
分析:
- 在之前的操作中,我们进入 nginx 容器内部,已经知道 nginx 的
html
目录所在位置/usr/share/nginx/html
- 现在我们需要把这个目录挂载到
html
这个数据卷上,方便操作其中的内容
提示:运行容器时使用 -v
参数挂载数据卷
步骤:
- 创建容器并挂载数据卷到容器内的
html
目录(详情见官网:https://hub.docker/_/nginx )
docker run --name mn -v html:/usr/share/nginx/html -p 80:80 -d nginx
- 进入
html
数据卷所在位置,并修改html
内容
查看 html
数据卷的位置
docker volume inspect html
进入该目录
cd /var/lib/docker/volumes/html/_data
修改文件
vim index.html
这里我直接用 vocode 来修改 index.html
文件了。
最终的结果
修改成功!
补充:如果容器运行时 volume 不存在,其也会自动被创建出来。
10.3.6.案例2(给 MySQL 挂载本地目录)
容器不仅仅可以挂载数据卷,也可以直接挂载到宿主机目录上。
关联关系如下:
- 带数据卷模式:宿主机目录 --> 数据卷 —> 容器内目录
- 直接挂载模式:宿主机目录 —> 容器内目录
如图:
语法:
目录挂载与数据卷挂载的语法是类似的:
-v [宿主机目录]:[容器内目录]
-v [宿主机文件]:[容器内文件]
案例需求:创建并运行一个 MySQL 容器,将宿主机目录直接挂载到容器
实现思路如下:
- 在将课前资料(提取码:1234)中的
mysql.tar
文件上传到虚拟机,通过 load 命令加载为镜像
(主要是 MySQL 镜像文件比较大,使用docker pull mysql:tag
命令的话,耗时比较久,故使用资料中提供的mysql.tar
) - 创建目录
/tmp/mysql/data
- 创建目录
/tmp/mysql/conf
,将课前资料提供的hmyf
文件上传到/tmp/mysql/conf
- 去 DockerHub 查阅资料,创建并运行 MySQL 容器,要求:
- 挂载
/tmp/mysql/data
到 mysql 容器内数据存储目录 - 挂载
/tmp/mysql/conf/hmyf
到 mysql 容器的配置文件 - 设置 MySQL 密码
- 挂载
实际操作
- 在将课前资料中的
mysql.tar
文件上传到虚拟机,通过 load 命令加载为镜像
docker load -i mysql.tar
查看镜像
docker images -a
- 创建目录
/tmp/mysql/data
mkdir -p mysql/data
- 创建目录
/tmp/mysql/conf
,将课前资料提供的hmyf
文件上传到/tmp/mysql/conf
mkdir -p mysql/conf
hymf
文件的内容
[mysqld]
skip-name-resolve
character_set_server=utf8
datadir=/var/lib/mysql
server-id=1000
- 去 DockerHub 查阅资料,创建并运行 MySQL 容器
- 挂载
/tmp/mysql/data
到 mysql 容器内数据存储目录 - 挂载
/tmp/mysql/conf/hmyf
到 mysql 容器的配置文件 - 设置 MySQL 密码
- 挂载
前往 DockerHub 页面查看使用文档:https://hub.docker/_/mysql
- 启动服务器实例
mysql
docker run --name some-mysql -e MYSQL_ROOT_PASSWORD=my-secret-pw -d mysql:tag
- 使用自定义的 MySQL 配置文件
docker run --name some-mysql -v /my/custom:/etc/mysql/conf.d -e MYSQL_ROOT_PASSWORD=my-secret-pw -d mysql:tag
- 存储数据的位置
docker run --name some-mysql -v /my/own/datadir:/var/lib/mysql -e MYSQL_ROOT_PASSWORD=my-secret-pw -d mysql:tag
- 使用如下的命令创建并运行一个 MySQL 容器,将宿主机目录直接挂载到容器
docker run \
--name c_mysql \
-e MYSQL_ROOT_PASSWORD=123 \
-p 3307:3306 \
-v /tmp/mysql/conf/hmyf:/etc/mysql/conf.d/hmyf \
-v /tmp/mysql/data:/var/lib/mysql \
-d \
mysql:5.7.25
- 参数说明
docker run
:创建并运行容器-e MYSQL_ROOT_PASSWORD=123
:初始化 root 用户的密码。-e
是环境变量的意思。-p 3307:3306
:将容器的 3306 端口映射到宿主机的 3307 端口。冒号左侧是宿主机端口,右侧是容器端口v /tmp/mysql/conf/hmyf:/etc/mysql/conf.d/hmyf
- 将主机的
/tmp/mysql/conf/hmyf
挂载到容器的/etc/mysql/conf.d/hmyf
。
- 将主机的
-v /tmp/mysql/data:/var/lib/mysql
:将主机的/tmp/mysql/data
挂载到容器的/var/lib/mysql
。-d
:后台运行
执行完命令后,使用 docker ps
查看 mysql 容器是否成功启动
docker ps
使用数据库图形化界面 Navicat Premium 成功连接到 mysql 容器的客户端
运行成功后 /tmp/mysql/data
目录中也成功记录了文件
10.3.7.小结
数据卷的作用
- 将容器与数据分离,解耦合,方便操作容器内数据,保证数据安全
数据卷操作
docker volume create
:创建数据卷docker volume ls
:查看所有数据卷docker volume inspect
:查看数据卷详细信息,包括关联的宿主机目录位置docker volume rm
:删除指定数据卷docker volume prune
:删除所有未使用的数据卷
数据卷挂载方式
-v volumeName: /targetContainerPath
- 如果容器运行时 volume 不存在,会自动被创建出来
docker run
的命令中通过 -v
参数挂载文件或目录到容器中
-v volume名称
:容器内目录-v 宿主机文件
:容器内文-v 宿主机目录
:容器内目录
数据卷挂载与目录直接挂载的区别
- 数据卷挂载耦合度低,由 docker 来管理目录,但是目录较深,不好找
- 目录挂载耦合度高,需要我们自己管理目录,不过目录容易寻找查看
11.Dockerfile 自定义镜像
常见的镜像在 DockerHub 就能找到,但是我们自己写的项目就必须自己构建镜像了。
而要自定义镜像,就必须先了解镜像的结构才行。
11.1.镜像结构
镜像是将应用程序及其需要的系统函数库、环境、配置、依赖打包而成。
我们以 MySQL 为例,来看看镜像的组成结构
镜像就是在系统函数库、运行环境基础上,添加应用程序文件、配置文件、依赖文件等组合,然后编写好启动脚本打包在一起形成的文件。
我们要构建镜像,其实就是实现上述打包的过程。
11.2.Dockerfile 语法
构建自定义的镜像时,并不需要一个个文件去拷贝,打包。
我们只需要告诉 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/engine/reference/builder
11.3.基于 Ubuntu 构建 Java 项目
- 步骤 1:新建一个空文件夹
docker-demo
(我是在/tmp
目录下创建这个文件夹的)
mkdir docker-demo
-
步骤 2:拷贝课前资料中的
docker-demo.jar
文件到docker-demo
这个目录 -
步骤 3:拷贝课前资料中的
jdk8.tar.gz
文件到docker-demo
这个目录 -
步骤 4:拷贝课前资料提供的 Dockerfile 到
docker-demo
这个目录
Dockerfile 中的内容
# 指定基础镜像
FROM ubuntu:16.04
# 配置环境变量,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
- 步骤 5:进入
docker-demo
将准备好的 docker-demo
上传到虚拟机任意目录,然后进入 docker-demo
目录下
- 步骤 6:运行命令:
docker build -t javaweb:1.0 .
docker build -t javaweb:1.0 .
其中 .
表示当前目录
- 步骤 7:使用命令启动镜像
docker run --name web -p 8090:8090 -d javaweb:1.0
- 步骤 8:访问
http://192.168.150.101:8090/hello/count
,其中的 ip 改成你的虚拟机 ip
有关
docker build
命令的更多操作还请查看官网文档: https://docs.docker/engine/reference/builder
11.4.基于 java8 构建 Java 项目
虽然我们可以基于 Ubuntu 基础镜像,添加任意自己需要的安装包,构建镜像,但是却比较麻烦。
所以大多数情况下,我们都可以在一些安装了部分软件的基础镜像上做改造。
例如,构建 java 项目的镜像,可以在已经准备了 JDK 的基础镜像基础上构建。
需求:基于 java:8-alpine
镜像,将一个 Java 项目构建为镜像
实现思路如下:
-
① 新建一个空的目录,然后在目录中新建一个文件,命名为 Dockerfile
-
② 拷贝课前资料提供的
docker-demo.jar
到这个目录中 -
③ 编写 Dockerfile 文件:
-
a.基于
java:8-alpine
作为基础镜像 -
b.将 app.jar 拷贝到镜像中
-
c.暴露端口
-
d.编写入口 ENTRYPOINT
内容如下:
FROM java:8-alpine COPY ./app.jar /tmp/app.jar EXPOSE 8090 ENTRYPOINT java -jar /tmp/app.jar
-
-
④ 使用
docker build
命令构建镜像docker build -t javaweb:2.0 .
-
⑤ 使用
docker run
创建容器并运行docker run --name web2 -p 8090:8090 -d javaweb:2.0
不过要注意的是,上面的进程要关掉(因为用的是同一个端口),并且启动新容器时,名字不可一样。
-
⑥ 访问
http://192.168.150.101:8090/hello/count
,其中的 ip 改成你的虚拟机 ip
11.5.小结
- Dockerfile 的本质是一个文件,通过指令描述镜像的构建过程
- Dockerfile 的第一行必须是 FROM,从一个基础镜像来构建
- 基础镜像可以是基本操作系统,如 Ubuntu。也可以是其他人制作好的镜像,例如:
java:8-alpine
12.Docker-Compose
Docker Compose 可以基于 Compose 文件帮我们快速的部署分布式应用,而无需手动一个个创建和运行容器!
12.1.初识 DockerCompose
Compose 文件是一个文本文件,通过指令定义集群中的每个容器如何运行。格式如下:
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/hmyf:/etc/mysql/conf.d/hmyf"
web:
build: .
ports:
- "8090:8090"
上面的 Compose 文件就描述一个项目,其中包含两个容器:
mysql
:一个基于mysql:5.7.25
镜像构建的容器,并且挂载了两个目录web
:一个基于docker build
临时构建的镜像容器,映射端口时 8090
因为这里旨在微服务集群部署,因为是在集群内使用,故无需对外暴露端口。
DockerCompose 的详细语法参考官网:https://docs.docker/compose/compose-file/
其实 DockerCompose 文件可以看做是将多个 docker run
命令写到一个文件,只是语法稍有差异。
12.2.安装 DockerCompose
12.2.1.下载
Linux 下需要通过命令下载:
安装
curl -L https://github/docker/compose/releases/download/1.23.1/docker-compose-`uname -s`-`uname -m` > \
/usr/local/bin/docker-compose
如果下载速度较慢,或者下载失败,可以使用课前资料提供的 docker-compose
文件:
上传到 /usr/local/bin/
目录也可以。
12.2.2.修改文件权限
chmod +x /usr/local/bin/docker-compose
12.2.3.Base 自动补全命令
补全命令
curl -L https://raw.githubusercontent/docker/compose/1.29.1/contrib/completion/bash/docker-compose > \
/etc/bash_completion.d/docker-compose
如果这里出现错误,需要修改自己的 hosts 文件
echo "199.232.68.133 raw.githubusercontent" >> /etc/hosts
12.2.4.小结
问:DockerCompose 有什么作用?
答:帮助我们快速部署分布式应用,而无需一个个微服务去构建镜像和部署
12.3.部署微服务集群
吐槽:这个部署 看看就行。与其说是部署,不如说是配环境。也完全没有实际价值(因为用的例子是 MySQL)。
另外由于我的 mysql 密码与视频中的密码不同,就 mysql 打包那里我就过不了关了。
最后访问地址时,查看日志,也是密码不对的原因。反正这个部署我是没有弄成功。
而且很占用内存:内存使用量直接飚到 15G。
需求:将之前学习的 cloud-demo
微服务集群利用 DockerCompose 部署
实现思路:
① 查看课前资料提供的 cloud-demo
文件夹,里面已经编写好了 docker-compose
文件
② 修改自己的 cloud-demo
项目,将数据库、nacos 地址都命名为 docker-compose
中的服务名
③ 使用 maven 打包工具,将项目中的每个微服务都打包为 app.jar
④ 将打包好的 app.jar
拷贝到 cloud-demo
中的每一个对应的子目录中
⑤ 将 cloud-demo
上传至虚拟机,利用 docker-compose up -d
来部署
12.3.1pose 文件
查看课前资料提供的 cloud-demo
文件夹,里面已经编写好了 docker-compose
文件,而且每个微服务都准备了一个独立的目录:
内容如下
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
目录,其中有我提前准备好的数据
userservice
、orderservice
、gateway
:都是基于 Dockerfile 临时构建的
查看 mysql
目录,可以看到其中已经准备好了 cloud_order
、cloud_user
表:
查看微服务目录,可以看到都包含 Dockerfile 文件:
内容如下:
FROM java:8-alpine
COPY ./app.jar /tmp/app.jar
ENTRYPOINT java -jar /tmp/app.jar
12.3.2.修改微服务配置
因为微服务将来要部署为 docker 容器,而容器之间互联不是通过 IP 地址,而是通过容器名。
这里我们将 order-service
、user-service
、gateway
服务的 mysql 、nacos 地址都修改为基于容器名的访问。
如下所示:
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服务地址
12.3.3.打包
接下来需要将我们的每个微服务都打包。
因为之前查看到 Dockerfile 中的 jar 包名称都是 app.jar
,因此我们的每个微服务都需要用这个名称。
可以通过修改 pom.xml
中的打包名称来实现,每个微服务都需要修改:
<build>
<!-- 服务打包的最终名称 -->
<finalName>app</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
12.3.4.拷贝 jar 包到部署目录
编译打包好的 app.jar
文件,需要放到 Dockerfile 的同级目录中。
注意:每个微服务的 app.jar
放到与服务名称对应的目录。
12.3.5.部署
最后,我们需要将文件整个 cloud-demo
文件夹上传到虚拟机中,理由 DockerCompose 部署。
上传到任意目录:
部署:
进入 cloud-demo
目录,然后运行下面的命令:
docker-compose up -d
事实上因为,因为 Nacos 启动慢的缘故,其是无法成功启动的
这里只是简单重启服务一下(因为此时 Nacos 服务已经启动成功,重启其他服务即可成功)
docker-compose restart gateway userservice orderservice
之后直接访问这俩地址就可以成功获取到数据
http://虚拟机IP地址:10010/order/101?authorization=admin
http://虚拟机IP地址:10010/user/1?authorization=admin
13.Docker 镜像服务
镜像仓库(Docker Registry)有公共的和私有的两种形式。
- 公共仓库:例如 Docker 官方的 Docker Hub。
国内也有一些云服务商提供类似于 Docker Hub 的公开服务,比如 DaoCloud 镜像服务、网易云镜像服务、阿里云镜像库等。 - 除了使用公开仓库外,用户还可以在本地搭建私有 Docker Registry。企业自己的镜像最好是采用私有 Docker Registry 来实现。
13.1.搭建私有镜像仓库
搭建镜像仓库可以基于 Docker 官方提供的 DockerRegistry 来实现。
官网地址:https://hub.docker/_/registry
13.1.1.简化版镜像仓库
Docker 官方的 Docker Registry 是一个基础版本的 Docker 镜像仓库,具备仓库管理的完整功能,但是没有图形化界面。
搭建方式比较简单,命令如下:
docker run -d \
--restart=always \
--name registry \
-p 5000:5000 \
-v registry-data:/var/lib/registry \
registry
命令中挂载了一个数据卷 registry-data
到容器内的 /var/lib/registry
目录,这是私有镜像库存放数据的目录。
访问 http://YourIp:5000/v2/_catalog
可以查看当前私有镜像服务中包含的镜像
13.1.2.带有图形化界面版本
使用 DockerCompose 部署带有图像界面的 DockerRegistry,命令如下:
version: '3.0'
services:
registry:
image: registry
volumes:
- ./registry-data:/var/lib/registry
ui:
image: joxit/docker-registry-ui:static
ports:
- 8080:80
environment:
- REGISTRY_TITLE=随便起的名字
- REGISTRY_URL=http://registry:5000
depends_on:
- registry
把上面的内容复制到新创建的 docker-compose.yml
文件中即可。
在做下面的操作前,需要先配置 Docker 信任地址(见 13.1.3)
13.1.3.配置 Docker 信任地址
我们的私服采用的是 http 协议,默认不被 Docker 信任,所以需要做一个配置:
打开要修改的文件
vi /etc/docker/daemon.json
添加内容
"insecure-registries":["http://192.168.150.101:8080"]
重加载
systemctl daemon-reload
重启 docker
systemctl restart docker
13.2.推送、拉取镜像
推送镜像到私有镜像服务必须先 tag,步骤如下:
① 重新 tag 本地镜像,名称前缀为私有仓库的地址:192.168.150.101:8080/
docker tag nginx:latest 192.168.150.101:8080/nginx:1.0
② 推送镜像
docker push 192.168.150.101:8080/nginx:1.0
③ 拉取镜像
docker pull 192.168.150.101:8080/nginx:1.0
13.3.小结
- 推送本地镜像到仓库前都必须重命名(
docker tag
)镜像,以镜像仓库地址为前缀 - 镜像仓库推送前需要把仓库地址配置到 docker 服务的
daemon.json
文件中,被 docker 信任 - 推送使用
docker push
命令 - 拉取使用
docker pull
命令
# SpringCloudDay04
SpringCloud 学习 Day04(实用篇-4) 服务异步通讯:RabbitMQ
- 初识 MQ
- RabbitMQ 快速入门
- SpringAMQP
14.初识 MQ
14.1.同步通讯和异步通讯
微服务间通讯有同步和异步两种方式:
- 同步通讯:就像打电话,需要实时响应。
- 异步通讯:就像发邮件,不需要马上回复。
两种方式各有优劣,打电话可以立即得到响应,但是你却不能跟多个人同时通话。
发送邮件可以同时与多个人收发邮件,但是往往响应会有延迟。
14.1.1.同步通讯
我们之前学习的 Feign 调用就属于同步方式,虽然调用可以实时得到结果,但存在下面的问题:
总结
- 同步调用的优点:时效性较强,可以立即得到结果
- 同步调用的问题:
- 耦合度高
- 性能和吞吐能力下降
- 有额外的资源消耗
- 有级联失败问题
14.1.2.异步通讯
异步调用常见实现就是事件驱动模式
- 事件驱动优势
- 优势一:服务解耦(耦合度低)
- 优势二:性能提升,吞吐量提高
- 优势三:服务没有强依赖,不担心级联失败问题(故障隔离)
- 优势四:流量削峰
异步调用则可以避免上述问题:
我们以购买商品为例,用户支付后需要调用订单服务完成订单状态修改,调用物流服务,从仓库分配响应的库存并准备发货。
在事件模式中,支付服务是事件发布者(publisher),在支付完成后只需要发布一个支付成功的事件(event),事件中带上订单 id。
订单服务和物流服务是事件订阅者(Consumer),订阅支付成功的事件,监听到事件后完成自己业务即可。
为了解除事件发布者与订阅者之间的耦合,两者并不是直接通信,而是有一个中间人(Broker)。
发布者发布事件到 Broker,不关心谁来订阅事件。订阅者从 Broker 订阅事件,不关心谁发来的消息。
Broker 是一个像数据总线一样的东西,所有的服务要接收数据和发送数据都发到这个总线上。
这个总线就像协议一样,让服务间的通讯变得标准和可控。
好处:
- 吞吐量提升:无需等待订阅者处理完成,响应更快速
- 故障隔离:服务没有直接调用,不存在级联失败问题
- 调用间没有阻塞,不会造成无效的资源占用
- 耦合度极低,每个服务都可以灵活插拔,可替换
- 流量削峰:不管发布事件的流量波动多大,都由 Broker 接收,订阅者可以按照自己的速度去处理事件
缺点:
- 架构复杂了,业务没有明显的流程线,不好管理
- 需要依赖于 Broker 的可靠性、安全性、吞吐能力
好在现在开源软件或云平台上 Broker 的软件是非常成熟的,比较常见的一种就是我们今天要学习的 MQ 技术。
14.2.技术对比
MQ,中文是消息队列(MessageQueue),字面来看就是存放消息的队列。也就是事件驱动架构中的 Broker。
比较常见的 MQ 实现:ActiveMQ、RabbitMQ、RocketMQ、Kafka
几种常见 MQ 的对比
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
15.RabbitMQ 快速入门
概述:RabbitMQ 是基于 Erlang 语言开发的开源消息通信中间件。
- 官网地址:https://www.rabbitmq/
- 如果想要深入学习 RabbitMQ,建议按照官方文档来学习:https://www.rabbitmq/documentation.html
RabbitMQ 中的一些角色
- publisher:生产者
- consumer:消费者
- channel:操作 MQ 的工具
- exchange:交换机,负责路由消息到队列中
- queue:队列,存储消息
- virtualHost:虚拟主机,是对资源的逻辑分组。隔离不同用户的 exchange、queue、消息。
15.1.安装 RabbitMQ
15.1.1.单机部署
我们在 Centos7 虚拟机中使用 Docker 来安装。
- 下载镜像
方式一:在线拉取
docker pull rabbitmq:3-management
方式二:从本地加载
在课前资料已经提供了镜像包:
上传到虚拟机中后,使用命令加载镜像即可:
docker load -i mq.tar
- 安装 MQ
执行下面的命令来运行 MQ 容器:
docker run \
-e RABBITMQ_DEFAULT_USER=itcast \
-e RABBITMQ_DEFAULT_PASS=123456 \
--name mq \
--hostname mq1 \
-p 15672:15672 \
-p 5672:5672 \
-d \
rabbitmq:3-management
-e
:给容器设置环境变量--name
:给容器命名--hostname
:配置主机名(单机部署无所谓,集群部署则必须配置主机名)-p
:端口映射。- 将容器的端口映射到宿主机的端口。冒号左侧是宿主机端口,右侧是容器端口。
p 15672:15672
:15672 是 RabbitMQ 管理平台的窗口p 5672:5672
:5672 是消息通信的端口
-d
:后台运行rabbitmq:3-management
:镜像名称
使用命令关闭之前创建过的 mq
容器:docker stop mq
使用命令开启之前创建过的 mq
容器:docker start mq
更多细节还请参考官网:https://hub.docker/_/rabbitmq
访问 虚拟机地址:15672
,输入之前设置的用户名和密码,即可进入 RabbitMQ 的管理平台
RabbitMQ 的管理平台界面
以下的界面介绍参考博客:《RabbitMQ-管理界面介绍》
- Overview:概览
- Connections:连接。
- 无论生产者还是消费者,都需要与 RabbitMQ 建立连接后,才可以完成消息的生产和消费。
- 在这里可以查看连接情况。
- Channels:通道。建立连接后,会形成通道,消息的投递获取依赖的通道。
- Exchanges:交换机。用来实现消息的路由
- Queues:队列。就是消息队列,消息存放在队列中,等待消费,消费后会被移除队列
在我之前的博客里,有着更为详细的介绍,感兴趣的可以逛逛:《学习笔记:RabbitMQ 快速入门》
关于 RabbitMQ 控制台的一些基本操作
我们可以通过 Admin 界面的 Add users 来添加用户。
但是我们发现,所创建的用户 “lisi” 并没有服务虚拟机的权限。
这是因为 RabbitMQ 中可以创建许多用户,各个用户都可以创建队列,容易产生冲突。
为了避免这种情况的发生,通过不同的虚拟主机对用户的隔离,使得不同用户无法查看到对方的信息。
我们可以创建更多的虚拟主机,给用户 “lisi” 分配虚拟主机的访问权,来给该用户设置权限。
创建虚拟主机(MQ 中的逻辑划分)
通过给用户分配虚拟主机,来使用户获得该虚拟主机的服务权限
原则上来说,一个用户应该有自己独享的虚拟主机
15.1.2.集群部署
- 集群分类
在 RabbitMQ 的官方文档中,讲述了两种集群的配置方式:
- 普通模式:普通模式集群不进行数据同步,每个 MQ 都有自己的队列、数据信息(其它元数据信息如交换机等会同步)。
- 例如我们有 2 个 MQ(mq1、mq2)如果你的消息在 mq1,而你连接到了 mq2,那么 mq2 会去 mq1 拉取消息,然后返回给你。
- 如果 mq1 宕机,消息就会丢失。
- 镜像模式:与普通模式不同,队列会在各个 mq 的镜像节点之间同步,因此你连接到任何一个镜像节点,均可获取到消息。
- 而且如果一个节点宕机,并不会导致数据丢失。
- 不过,这种方式增加了数据同步的带宽消耗。
- 设置网络
首先,我们需要让 3 台 MQ 互相知道对方的存在。
分别在 3 台机器中,设置 /etc/hosts
文件,添加如下内容:
192.168.150.101 mq1
192.168.150.102 mq2
192.168.150.103 mq3
并在每台机器上测试,是否可以 ping 通对方
15.2.RabbitMQ 消息模型
RabbitMQ 官方提供了 6 个不同的 Demo 示例,对应了不同的消息模型:
- 基本消息队列(BasicQueue)、工作消息队列(WorkQueue)、发布订阅(publish,subscribe)。
发布订阅,又根据交换机类型不同分为 3 种
- 广播(Fanout exchange)、路由(Direct Exchange)、主题(Topic Exchange)
另外还有 RPC 远程调用模式(远程调用,不太算 MQ。暂不作介绍)
访问官网模式介绍页面:https://www.rabbitmq/getstarted.html (访问时间:2022-07-07 )
15.3.导入 Demo 工程
课前资料提供了一个 Demo 工程:mq-demo
导入后可以看到结构如下
mq-demo
:父工程,管理项目依赖publisher
:消息的发送者consumer
:消息的消费者
15.4.入门案例
简单队列模式的模型图
官方的 HelloWorld 是基于最基础的消息队列模型来实现的,只包括三个角色:
- publisher:消息发布者,将消息发送到队列 queue
- queue:消息队列,负责接受并缓存消息
- consumer:订阅队列,处理队列中的消息
15.4.1.publisher 实现
思路:
- 建立连接
- 创建 Channel
- 声明队列
- 发送消息
- 关闭连接和 channel
publisher
模块下的 src/test/java/cn/itcast/mq/helloworld/PublisherTest.java
package cn.itcast.mq.helloworld;
import com.rabbitmq.client.Channel;
import com.rabbitmq.client.Connection;
import com.rabbitmq.client.ConnectionFactory;
import org.junit.Test;
import java.io.IOException;
import java.util.concurrent.TimeoutException;
public class PublisherTest {
@Test
public void testSendMessage() throws IOException, TimeoutException {
// 1.建立连接
ConnectionFactory factory = new ConnectionFactory();
// 1.1.设置连接参数,分别是:主机名、端口号、vhost、用户名、密码
factory.setHost("192.168.150.101");
factory.setPort(5672);
factory.setVirtualHost("/");
factory.setUsername("itcast");
factory.setPassword("123456");
// 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();
}
}
15.4.2.consumer 实现
思路:
- 建立连接
- 创建 Channel
- 声明队列
- 订阅消息
consumer
模块下的 src/test/java/cn/itcast/mq/helloworld/ConsumerTest.java
package cn.itcast.mq.helloworld;
import com.rabbitmq.client.*;
import java.io.IOException;
import java.util.concurrent.TimeoutException;
public class ConsumerTest {
public static void main(String[] args) throws IOException, TimeoutException {
// 1.建立连接
ConnectionFactory factory = new ConnectionFactory();
// 1.1.设置连接参数,分别是:主机名、端口号、vhost、用户名、密码
factory.setHost("192.168.150.101");
factory.setPort(5672);
factory.setVirtualHost("/");
factory.setUsername("itcast");
factory.setPassword("123456");
// 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("等待接收消息。。。。");
}
}
为了不影响后续的操作,我们需要关闭该通道和该连接
//关闭通道和连接
channel.close();
connection.close();
15.5.总结
基本消息队列的消息发送流程:
- 建立 connection
- 创建 channel
- 利用 channel 声明队列
- 利用 channel 向队列发送消息
基本消息队列的消息接收流程:
- 建立 connection
- 创建 channel
- 利用 channel 声明队列
- 定义 consumer 的消费行为 handleDelivery()(回调函数)
- 利用 channel 将消费者与队列绑定
16.SpringAMQP
SpringAMQP 是基于 RabbitMQ 封装的一套模板,并且还利用 SpringBoot 对其实现了自动装配,使用起来非常方便。
SpringAMQP 的官方地址:https://spring.io/projects/spring-amqp
SpringAMQP 提供了三个功能:
- 自动声明队列、交换机及其绑定关系
- 基于注解的监听器模式,异步接收消息
- 封装了 RabbitTemplate 工具,用于发送消息
16.1.Basic Queue
Basic Queue(简单队列模型)
案例流程:
- 在父工程中引入
spring-amqp
的依赖 - 在
publisher
服务中利用 RabbitTemplate 发送消息到simple.queue
这个队列 - 在
consumer
服务中编写消费逻辑,绑定simple.queue
这个队列
在父工程 mq-demo
中引入依赖
<!-- AMQP 依赖,包含 RabbitMQ-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
16.1.1.消息发送
首先配置 MQ 地址,在 publisher 服务的 application.yml
中添加配置:
publisher
服务下的 src/main/resources/application.yml
spring:
rabbitmq:
host: 192.168.150.101 # 主机名
port: 5672 # 端口
virtual-host: / # 虚拟主机
username: itcast # 用户名
password: 123456 # 密码
然后在 publisher
服务中编写测试类 SpringAmqpTest,并利用 RabbitTemplate 实现消息发送
publisher
服务下的 src/test/java/cn/itcast/mq/spring/SpringAmqpTest.java
package cn.itcast.mq.spring;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
@RunWith(SpringRunner.class)
@SpringBootTest
public class SpringAmqpTest {
@Autowired
private RabbitTemplate rabbitTemplate;
@Test
public void testSimpleQueue() {
// 队列名称
String queueName = "simple.queue";
// 消息
String message = "hello, spring amqp!";
// 发送消息
rabbitTemplate.convertAndSend(queueName, message);
}
}
启动测试类后的情况
总结
什么是 AMQP?
- 应用间消息通信的一种协议,与语言和平台无关。
SpringAMQP 如何发送消息?
- 引入 amqp 的 starter 依赖
- 配置 RabbitMQ 地址
- 利用 RabbitTemplate 的 convertAndSend 方法
16.1.2.消息接收
首先配置 MQ 地址,在 consumer
服务的 application.yml
中添加配置:
consumer
服务下的 src/main/resources/application.yml
spring:
rabbitmq:
host: 192.168.150.101 # 主机名
port: 5672 # 端口
virtual-host: / # 虚拟主机
username: itcast # 用户名
password: 123456 # 密码
然后在 consumer
服务的 cn.itcast.mq.listener
包中新建一个类 SpringRabbitListener,代码如下:
consumer
服务下的 src/main/java/cn/itcast/mq/listener/SpringRabbitListener.java
package cn.itcast.mq.listener;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;
@Component
public class SpringRabbitListener {
@RabbitListener(queues = "simple.queue")
public void listenSimpleQueueMessage(String msg) throws InterruptedException {
System.out.println("spring 消费者接收到消息:【" + msg + "】");
}
}
启动 consumer
服务中的启动类后的情况
总结
SpringAMQP 如何接收消息?
- 引入 amqp 的 starter 依赖
- 配置 RabbitMQ地址
- 定义类,添加
@Component
注解 - 类中声明方法,添加
@RabbitListener
注解,方法参数就时消息
注意:消息一旦消费就会从队列删除,RabbitMQ 没有消息回溯功能
16.2.WorkQueue
Work queues(工作队列),也被称为(Task queues),任务模型。
简单来说就是让多个消费者绑定到一个队列,共同消费队列中的消息。
当消息处理比较耗时的时候,可能生产消息的速度会远远大于消息的消费速度。
长此以往,消息就会堆积越来越多,无法及时处理。
此时就可以使用 work 模型,多个消费者共同处理消息处理,速度就能大大提高了。
案例的基本思路
- 在
publisher
服务中定义测试方法,每秒产生 50 条消息,发送到simple.queue
- 在
consumer
服务中定义两个消息监听者,都监听simple.queue
队列 - 消费者 1 每秒处理 50 条消息,消费者 2 每秒处理 10 条消息
16.2.1.消息发送
这次我们循环发送,模拟大量消息堆积现象。
在 publisher
服务中的 SpringAmqpTest 类中添加一个测试方法
src/test/java/cn/itcast/mq/spring/SpringAmqpTest.java
/**
* workQueue
* 向队列中不停发送消息,模拟消息堆积。
*
* @throws InterruptedException
*/
@Test
public void testWorkQueue() throws InterruptedException {
// 队列名称
String queueName = "simple.queue";
// 消息
String message = "hello, message_";
for (int i = 0; i < 50; i++) {
// 发送消息
rabbitTemplate.convertAndSend(queueName, message + i);
Thread.sleep(20);
}
}
16.2.2.消息接收
要模拟多个消费者绑定同一个队列,我们在 consumer
服务的 SpringRabbitListener 中添加 2 个新的方法
此处需要注释掉之前的 ListenSimpleQueue(String msg){} 方法
src/main/java/cn/itcast/mq/listener/SpringRabbitListener.java
@RabbitListener(queues = "simple.queue")
public void listenWorkQueue1(String msg) throws InterruptedException {
System.out.println("消费者1接收到消息:【" + msg + "】" + LocalTime.now());
Thread.sleep(20);
}
@RabbitListener(queues = "simple.queue")
public void listenWorkQueue2(String msg) throws InterruptedException {
//使用 error 只是为了使控制台打印出红色,方便观察
System.err.println("消费者2........接收到消息:【" + msg + "】" + LocalTime.now());
Thread.sleep(200);
}
注意到这个消费者 sleep 了 1000 秒,模拟任务耗时。
16.2.3.测试
启动 ConsumerApplication 后,在执行 publisher
服务中刚刚编写的发送测试方法 testWorkQueue。
可以看到消费者 1 很快完成了自己的 25 条消息。消费者 2 却在缓慢的处理自己的 25 条消息。
也就是说消息是平均分配给每个消费者,并没有考虑到消费者的处理能力。这样显然是有问题的。
16.2.4.能者多劳
在 spring 中有一个简单的配置,可以解决这个问题。
我们修改 consumer
服务的 application.yml
文件,添加配置:
spring:
rabbitmq:
listener:
simple:
prefetch: 1 # 每次只能获取一条消息,处理完成才能获取下一个消息
之后再重启 consumer
服务,使用测试类 SpringAmqpTest 发送单元请求
总体上的耗时时间变短了。
16.2.5.总结
Work 模型的使用:
- 多个消费者绑定到一个队列,同一条消息只会被一个消费者处理
- 通过设置 prefetch 来控制消费者预取的消息数量
16.3.发布/订阅
发布订阅的模型如图:
可以看到,在订阅模型中,多了一个 exchange 角色,而且过程略有变化:
- Publisher:生产者,也就是要发送消息的程序,但是不再发送到队列中,而是发给 X(交换机)
- Exchange:交换机,图中的 X。
- 一方面,接收生产者发送的消息。
- 另一方面,知道如何处理消息,例如递交给某个特别队列、递交给所有队列、或是将消息丢弃。
- 到底如何操作,取决于 Exchange 的类型。
- Exchange 有以下 3 种类型:
- Fanout:广播,将消息交给所有绑定到交换机的队列
- Direct:定向,路由。把消息交给符合指定 routing key 的队列
- Topic:通配符,话题。把消息交给符合 routing pattern(路由模式) 的队列
- Consumer:消费者,与以前一样,订阅队列,没有变化
- Queue:消息队列也与以前一样,接收消息、缓存消息。
注意:Exchange(交换机)只负责转发消息,不具备存储消息的能力。
因此如果没有任何队列与 Exchange 绑定,或者没有符合路由规则的队列,那么消息会丢失!
16.4.Fanout
Fanout,英文翻译是扇出。
或许在 MQ 中叫广播更合适
Fanout Exchange 会将接收到的消息广播到每一个跟其绑定的 queue
在广播模式下,消息发送流程是这样的:
- 可以有多个队列
- 每个队列都要绑定到 Exchange(交换机)
- 生产者发送的消息,只能发送到交换机,交换机来决定要发给哪个队列,生产者无法决定
- 交换机把消息发送给绑定过的所有队列
- 订阅队列的消费者都能拿到消息
计划是这样的:
- 创建一个交换机
itcast.fanout
,类型是 Fanout - 创建两个队列
fanout.queue1
和fanout.queue2
,绑定到交换机itcast.fanout
案例思路
- 在
consumer
服务中,利用代码声明队列、交换机,并将两者绑定 - 在
consumer
服务中,编写两个消费者方法,分别监听fanout.queue1
和fanout.queue2
- 在
publisher
中编写测试方法,向itcast.fanout
发送消息
16.4.1.声明队列和交换机
Spring 提供了一个接口 Exchange,来表示所有不同类型的交换机:
在 consumer
中创建一个类,声明队列和交换机
consumer
服务下的 src/main/java/cn/itcast/mq/config/FanoutConfig.java
package cn.itcast.mq.config;
import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.FanoutExchange;
import org.springframework.amqp.core.Queue;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class FanoutConfig {
/**
* 声明交换机
*
* @return Fanout 类型交换机
*/
@Bean
public FanoutExchange fanoutExchange() {
return new FanoutExchange("itcast.fanout");
}
/**
* 第 1 个队列
*/
@Bean
public Queue fanoutQueue1() {
return new Queue("fanout.queue1");
}
/**
* 绑定队列 1 和交换机
*/
@Bean
public Binding bindingQueue1(Queue fanoutQueue1, FanoutExchange fanoutExchange) {
return BindingBuilder.bind(fanoutQueue1).to(fanoutExchange);
}
/**
* 第 2 个队列
*/
@Bean
public Queue fanoutQueue2() {
return new Queue("fanout.queue2");
}
/**
* 绑定队列 2 和交换机
*/
@Bean
public Binding bindingQueue2(Queue fanoutQueue2, FanoutExchange fanoutExchange) {
return BindingBuilder.bind(fanoutQueue2).to(fanoutExchange);
}
}
启动 consumer
服务后,观察控制台
16.4.2.消息发送
在 publisher
服务的 SpringAmqpTest 类中添加测试方法:
src/test/java/cn/itcast/mq/spring/SpringAmqpTest.java
@Test
public void testFanoutExchange() {
// 交换机的名称
String exchangeName = "itcast.fanout";
// 消息
String message = "hello, everyone!";
// public void convertAndSend(String exchange, String routingKey, Object object)
rabbitTemplate.convertAndSend(exchangeName, "", message);
}
16.4.3.消息接收
在 consumer
服务的 SpringRabbitListener 中添加两个方法,作为消费者:
src/main/java/cn/itcast/mq/listener/SpringRabbitListener.java
@RabbitListener(queues = "fanout.queue1")
public void listenFanoutQueue1(String msg) {
System.out.println("消费者1接收到Fanout消息:【" + msg + "】");
}
@RabbitListener(queues = "fanout.queue2")
public void listenFanoutQueue2(String msg) {
System.out.println("消费者2接收到Fanout消息:【" + msg + "】");
}
启动 consumer
服务和 publisher
服务下的测试类
16.4.4.总结
交换机的作用是什么?
- 接收
publisher
发送的消息 - 将消息按照规则路由到与之绑定的队列
- 不能缓存消息,路由失败,消息丢失
- FanoutExchange 的会将消息路由到每个绑定的队列
声明队列、交换机、绑定关系的 Bean 是什么?
- Queue
- FanoutExchange
- Binding
16.5.Direct
在 Fanout 模式中,一条消息,会被所有订阅的队列都消费。
但是,在某些场景下,我们希望不同的消息被不同的队列消费。
这时就要用到 Direct 类型的 Exchange。
Direct Exchange 会将接收到的消息根据规则路由到指定的 Queue,因此称为路由模式(routes)
- 每一个 Queue 都与 Exchange设置一个 BindingKey
- 发布者发送消息时,指定消息的 RoutingKey
- Exchange 将消息路由到 BindingKey 与消息 RoutingKey 一致的队列
在 Direct 模型下:
- 队列与交换机的绑定,不能是任意绑定了,而是要指定一个
RoutingKey
(路由 key) - 消息的发送方在 向 Exchange 发送消息时,也必须指定消息的
RoutingKey
。 - Exchange 不再把消息交给每一个绑定的队列,而是根据消息的
Routing Key
进行判断。- 只有队列的
Routingkey
与消息的Routingkey
完全一致,才会接收到消息
- 只有队列的
案例需求
- 利用
@RabbitListener
声明 Exchange、Queue、RoutingKey - 在
consumer
服务中,编写两个消费者方法,分别监听direct.queue1
和direct.queue2
- 在
publisher
中编写测试方法,向itcast. direct
发送消息
16.5.1.基于注解声明队列和交换机
基于 @Bean
的方式声明队列和交换机比较麻烦,Spring 还提供了基于注解方式来声明。
在 consumer
的 SpringRabbitListener 中添加两个消费者,同时基于注解来声明队列和交换机
src/main/java/cn/itcast/mq/listener/SpringRabbitListener.java
//基于注解,type 默认为 direct 可不写
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "direct.queue1"),//队列
exchange = @Exchange(name = "itcast.direct", type = ExchangeTypes.DIRECT),//交换机
key = {"red", "blue"}//bindingKey
))
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 + "】");
}
启动 consumer
服务后,发现新生成的交换机,且相关队列绑定了 key,与预期一致
查看队列界面
16.5.2.消息发送
在 publisher
服务的 SpringAmqpTest 类中添加测试方法
src/test/java/cn/itcast/mq/spring/SpringAmqpTest.java
@Test
public void testSendDirectExchange() {
// 交换机名称
String exchangeName = "itcast.direct";
// 消息
String message = "红色警报!";//红黄蓝色
// 发送消息
rabbitTemplate.convertAndSend(exchangeName, "red", message);//routingKey(red、yellow、blue)
}
启动该测试类的相关方法后,通过更改 routingKey 的值来确认代码结果是否达到预期
16.5.3.总结
描述下 Direct 交换机与 Fanout 交换机的差异
- Fanout 交换机将消息路由给每一个与之绑定的队列
- Direct 交换机根据 RoutingKey 判断路由给哪个队列
- 如果多个队列具有相同的 RoutingKey,则与 Fanout 功能类似
基于 @RabbitListener
注解声明队列和交换机有哪些常见注解?
@Queue
@Exchange
16.6.Topic
16.6.1.说明
Topic
类型的 Exchange
与 Direct
相比,都是可以根据 RoutingKey
把消息路由到不同的队列。
只不过 Topic
类型 Exchange
可以让队列在绑定 Routing key
的时候使用通配符!
Routingkey
一般都是有一个或多个单词组成,多个单词之间以 “.
” 分割,例如: item.insert
通配符规则
#
:匹配一个或多个词*
:匹配不多不少恰好 1 个词
举例
item.#
:能够匹配item.spu.insert
或者item.spu
item.*
:只能匹配item.spu
图示:
解释:
- Queue1:绑定的是
china.#
,因此凡是以china.
开头的routing key
都会被匹配到。包括china.news
和china.weather
- Queue2:绑定的是
#.news
,因此凡是以.news
结尾的routing key
都会被匹配。包括china.news
和japan.news
案例的实现思路:
- 利用
@RabbitListener
声明 Exchange、Queue、RoutingKey - 在
consumer
服务中,编写两个消费者方法,分别监听topic.queue1
和topic.queue2
- 在
publisher
中编写测试方法,向itcast.topic
发送消息
16.6.2.消息发送
在 publisher
服务的 SpringAmqpTest 类中添加测试方法:
src/test/java/cn/itcast/mq/spring/SpringAmqpTest.java
/**
* topicExchange
*/
@Test
public void testSendTopicExchange() {
// 交换机名称
String exchangeName = "itcast.topic";
// 消息
String message = "喜报!孙悟空大战哥斯拉,胜!";
// 发送消息
rabbitTemplate.convertAndSend(exchangeName, "china.news", message);
}
16.6.3.消息接收
在 consumer
服务的 SpringRabbitListener 中添加方法
src/main/java/cn/itcast/mq/listener/SpringRabbitListener.java
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "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(name = "topic.queue2"),
exchange = @Exchange(name = "itcast.topic", type = ExchangeTypes.TOPIC),
key = "#.news"
))
public void listenTopicQueue2(String msg) {
System.out.println("消费者接收到topic.queue2的消息:【" + msg + "】");
}
重启 consumer
服务
启动测试类中的相关方法测试
16.6.4.总结
描述下 Direct 交换机与 Topic 交换机的差异?
- Topic 交换机接收的消息 RoutingKey 必须是多个单词,以
**.**
分割 - Topic 交换机与队列绑定时的 bindingKey 可以指定通配符
#
:代表0个或多个词*
:代表1个词
16.7.消息转换器
使用 idea 快捷键查看参数列表(视频中的为 Ctrl + K,但我的电脑上的是 Ctrl + P)
由上图可以看出:在 SpringAMQP 的发送方法中,接收消息的类型是 Object。
事实上,我们可以发送任意对象类型的消息,SpringAMQP 会帮我们序列化为字节后发送给 MQ。
接收消息的时候,还会把字节反序列化为 Java 对象。
只不过,默认情况下 Spring 采用的序列化方式是 JDK 序列化。
众所周知,JDK 序列化存在问题:数据体积过大、有安全漏洞、可读性差
16.7.1.测试默认转换器
我们在 consumer
中利用 @Bean
声明一个队列:
src/main/java/cn/itcast/mq/config/FanoutConfig.java
@Bean
public Queue objectQueue(){
return new Queue("object.queue");
}
重启服务后,发现 object.queue
队列成功创建
我们在 publisher
中添加有关消息发送的方法的代码,发送一个 Map 对象
src/test/java/cn/itcast/mq/spring/SpringAmqpTest.java
@Test
public void testSendMap() throws InterruptedException {
// 准备消息
Map<String,Object> msg = new HashMap<>();
msg.put("name", "Jack");
msg.put("age", 21);
// 发送消息
rabbitTemplate.convertAndSend("object.queue", msg);
}
测试结果
很明显,Spring 采用的序列化方式是 JDK 序列化。
JDK 序列化存在问题:可读性差、数据体积过大、有安全漏洞。
16.7.2.配置 JSON 转换器
显然,JDK 序列化方式并不合适。
我们希望消息体的体积更小、可读性更高,因此可以使用 JSON 方式来做序列化和反序列化。
Spring 对消息对象的处理是由 org.springframework.amqp.support.converter.MessageConverter
来处理的。
而默认实现是 SimpleMessageConverter ,基于 JDK 的 ObjectOutputStream 完成序列化。
如果要修改只需要定义一个 MessageConverter 类型的 Bean 即可。推荐用 JSON 方式序列化。
- 导入依赖
在 publisher
和 consumer
两个服务中都引入依赖
pom.xml
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-xml</artifactId>
<version>2.9.10</version>
</dependency>
或者直接在父工程 mq-demo
中导入依赖
pom.xml
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
- 配置消息转换器
在 publisher
和 consumer
两个服务中启动类中添加一个 Bean 即可
@Bean
public MessageConverter jsonMessageConverter(){
return new Jackson2JsonMessageConverter();
}
测试
- 在
consumer
下的 SpringRabbitListener 添加消费方法
src/main/java/cn/itcast/mq/listener/SpringRabbitListener.java
@RabbitListener(queues = "object.queue")
public void ListenObjectQueue(Map<String, Object> msg) {
System.out.println("收到消息:" + msg);
}
重新启动 consumer
的服务,再使用 publisher
服务中的测试类的相关方法发送消息。
查看 Idea 控制台的情况。
16.7.3.小结
SpringAMQP 中消息的序列化和反序列化是怎么实现的?
- SpringAMQP 中消息的序列化和反序列化利用 MessageConverter 实现的,默认是 JDK 的序列化
- 注意发送方与接收方必须使用相同的 MessageConverter
# SpringCloudDay05
SpringCloud 学习 Day05(实用篇-5) 分布式搜索引擎:elasticsearch 基础
- 初识 ElasticSearch
- 索引库操作
- 文档操作
- RestAPI
- RestClient 操作文档
17.初识 ElasticSearch
17.1.了解 ES
17.1.1.elasticsearch 的作用
elasticsearch 是一款非常强大的开源搜索引擎,具备非常多强大功能,可以帮助我们从海量数据中快速找到需要的内容
在 GitHub 搜索代码
在电商网站搜索商品
在浏览器搜索答案
- ELK 技术栈
17.1.2.ELK 技术栈
elasticsearch 结合 kibana、Logstash、Beats,也就是 elastic stack(ELK)
被广泛应用在日志数据分析、实时监控等领域。
而 elasticsearch 是 elastic stack 的核心,负责存储、搜索、分析数据。
17.1.3.elasticsearch 和 lucene
elasticsearch 底层是基于 lucene 来实现的。
Lucene 是一个 Java 语言的搜索引擎类库,是 Apache 公司的顶级项目,由 DougCutting 于 1999 年研发。
官网地址:https://lucene.apache/
elasticsearch的发展历史:
- 2004 年 Shay Banon 基于 Lucene 开发了 Compass
- 2010 年 Shay Banon 重写了Compass,取名为Elasticsearch
官网地址:https:// www.elastic.co/cn/
视频发布时间:2021-08-11
2022-7-9 的最新版本为 8.3.2
17.1.4.为什么不是其他搜索技术
目前比较知名的搜索引擎技术排名(视频发布时间:2021-08-11)
Elasticsearch:开源的分布式搜索引擎
Splunk:商业项目
Solr:Apache 的开源搜索引擎
虽然在早期,Apache Solr 是最主要的搜索引擎技术,但随着发展 elasticsearch 已经渐渐超越了 Solr,独占鳌头
17.1.5.总结
什么是 elasticsearch?
- 一个开源的分布式搜索引擎,可以用来实现搜索、日志统计、分析、系统监控等功能
什么是 elastic stack(ELK)?
- 是以 elasticsearch 为核心的技术栈,包括 beats、Logstash、kibana、elasticsearch
什么是 Lucene?
- 是 Apache 的开源搜索引擎类库,提供了搜索引擎的核心 API
17.2.倒排索引
倒排索引的概念是基于 MySQL 这样的正向索引而言的。
17.2.1.正向索引
那么什么是正向索引呢?例如给下表(tb_goods)中的 id 创建索引:
如果是根据 id 查询,那么直接走索引,查询速度非常快。
但如果是基于 title 做模糊查询,只能是逐行扫描数据,流程如下
- 用户搜索数据,条件是 title 符合
"%手机%"
- 逐行获取数据,比如 id 为 1 的数据
- 判断数据中的 title 是否符合用户搜索条件
- 如果符合则放入结果集,不符合则丢弃。回到步骤 1
逐行扫描,也就是全表扫描,随着数据量增加,其查询效率也会越来越低。当数据量达到数百万时,就是一场灾难。
17.2.2.倒排索引
倒排索引中有两个非常重要的概念
- 文档(
Document
):用来搜索的数据,其中的每一条数据就是一个文档。例如一个网页、一个商品信息 - 词条(
Term
):对文档数据或用户搜索数据,利用某种算法分词,得到的具备含义的词语就是词条。- 例如:我是中国人,就可以分为:我、是、中国人、中国、国人这样的几个词条
创建倒排索引 是对正向索引的一种特殊处理,流程如下:
- 将每一个文档的数据利用算法分词,得到一个个词条
- 创建表,每行数据包括词条、词条所在文档 id、位置等信息
- 因为词条唯一性,可以给词条创建索引,例如 hash 表结构索引
倒排索引的搜索流程如下(以搜索 “华为手机” 为例):
- 用户输入条件
"华为手机"
进行搜索。 - 对用户输入内容分词,得到词条:
华为
、手机
。 - 拿着词条在倒排索引中查找,可以得到包含词条的文档 id:1、2、3。
- 拿着文档 id 到正向索引中查找具体文档。
虽然要先查询倒排索引,再查询倒排索引,但是无论是词条、还是文档 id 都建立了索引,查询速度非常快!无需全表扫描。
17.2.3.总结
什么是文档和词条?
- 每一条数据就是一个文档
- 对文档中的内容分词,得到的词语就是词条
为什么一个叫做正向索引,一个叫做倒排索引呢?
- 正向索引是最传统的,根据 id 索引的方式。
- 但根据词条查询时,必须先逐条获取每个文档,然后判断文档中是否包含所需要的词条,是根据文档找词条的过程。
- 倒排索引是先找到用户要搜索的词条,根据词条得到保护词条的文档的 id,然后根据 id 获取文档。
- 是根据词条找文档的过程。
两者方式的优缺点是什么呢?
正向索引:
- 优点:
- 可以给多个字段创建索引
- 根据索引字段搜索、排序速度非常快
- 缺点:
- 根据非索引字段,或者索引字段中的部分词条查找时,只能全表扫描。
倒排索引:
- 优点:
- 根据词条搜索、模糊搜索时,速度非常快
- 缺点:
- 只能给词条创建索引,而不是字段
- 无法根据字段做排序
17.3.es 的一些概念
elasticsearch 中有很多独有的概念,与 mysql 中略有差别,但也有相似之处。
17.3.1.文档和字段
elasticsearch 是面向文档(Document)存储的,可以是数据库中的一条商品数据,一个订单信息。
文档数据会被序列化为 json 格式后存储在 elasticsearch 中
而 Json 文档中往往包含很多的字段(Field),类似于数据库中的列。
17.3.2.索引和映射
索引(Index),就是相同类型的文档的集合。
例如:
- 所有用户文档,就可以组织在一起,称为用户的索引;
- 所有商品的文档,可以组织在一起,称为商品的索引;
- 所有订单的文档,可以组织在一起,称为订单的索引;
因此,我们可以把索引当做是数据库中的表。
数据库的表会有约束信息,用来定义表的结构、字段的名称、类型等信息。
因此,索引库中就有映射(mapping),是索引中文档的字段约束信息,类似表的结构约束。
17.3.3.mysql 与 elasticsearch
我们统一的把 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 实现
- 两者再基于某种方式,实现数据的同步,保证一致性
17.3.4.总结
- 文档:一条数据就是一个文档,es 是 Json 格式
- 字段:Json 文档中的字段
- 索引:同类型文档的集合
- 映射:索引中的文档的约束,比如字段名称、类型
- ElasticSearch 与数据库的关系
- 数据库负责事务类型的操作
- ElasticSearch 负责海量数据的搜索、分析、计算
17.4.安装 es、kibana
17.4.1.部署单点 ES
创建网络
因为我们还需要部署 kibana 容器,因此需要让 es 和 kibana 容器互联。
这里先创建一个网络
docker network create es-net
加载镜像
这里我们采用 elasticsearch 的 7.12.1 版本的镜像,这个镜像体积非常大,接近 1G。
不建议大家自己 pull
。课前资料提供了镜像的 tar 包。
- 课前资料链接:
https://pan.baidu/s/169SFtYEvel44hRJhmFTRTQ
- 提取码:
1234
1.微服务开发框架 SpringCloud + RabbitMQ + Docker + Redis + 搜索 + 分布式史上最全面的微服务全技术栈课程>
实用篇>学习资料>day05-Elasticsearch01>资料
大家将其上传到虚拟机中,然后运行命令加载即可
docker load -i es.tar
同理,kibana
的 tar 包也需要这样做。
运行
运行 docker 命令,部署单点 es:
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
:端口映射配置
9200 是 ES 暴露的 http 协议端口(用户访问)
9300 是 ES 暴露的 ES 容器间互连的端口
FinalShell 截图
在浏览器中输入:http://虚拟机地址:9200
即可看到 elasticsearch 的响应结果:
17.4.2.部署 kibana
kibana 可以给我们提供一个 elasticsearch 的可视化界面,便于我们学习。
部署
首先加载上传到 CentOS7 的 kibana.tar
文件
docker load -i kibana.tar
运行 docker 命令,部署 kibana
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
:端口映射配置
kibana 启动一般比较慢,需要多等待一会,可以通过命令:
docker logs -f kibana
查看运行日志,当查看到下面的日志,说明成功:
输入 http://虚拟机地址:5601
我这里是选择 “Explore on my own”,直接进入了下方界面。
DevTools
kibana 中提供了一个 DevTools 界面
这个界面中可以编写 DSL 来操作 elasticsearch。
并且对 DSL 语句有自动补全功能。
关于 es 集群的部署,后续会介绍,这里暂且跳过。
17.5.安装 IK 分词器
17.5.1.安装理由
es 在创建倒排索引时需要对文档分词;在搜索时,需要对用户输入内容分词。但默认的分词规则对中文处理并不友好。
我们在 kibana 的 DevTools 中测试:
# 测试分词器
POST /_analyze
{
"text": "测试文本",
"analyzer": "standard"
}
语法说明
POST
:请求方式/_analyze
:请求路径,这里省略了http://XXXXXXX:9200
,由 kibana 帮我们补充- 请求参数(json 风格):
analyzer
:分词器类型,这里是默认的 standard 分词器text
:要分词的内容
测试结果
处理中文分词,一般会使用 IK 分词器。
相关链接:https://github/medcl/elasticsearch-analysis-ik
17.5.2.在线安装 IK 插件
在线安装 IK 插件(较慢)
进入容器内部
docker exec -it elasticsearch /bin/bash
在线下载并安装
./bin/elasticsearch-plugin install https://github/medcl/elasticsearch-analysis-ik/releases/download/v7.12.1/elasticsearch-analysis-ik-7.12.1.zip
退出
exit
重启容器
docker restart elasticsearch
17.5.3.离线安装 IK 插件
离线安装 IK 插件(推荐)
查看数据卷目录
安装插件需要知道 elasticsearch 的 plugins 目录位置。
而我们用了数据卷挂载,因此需要查看 elasticsearch 的数据卷目录。
通过下面命令查看:
docker volume inspect es-plugins
显示结果:
[
{
"CreatedAt": "2022-05-06T10:06:34+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
这个目录中。
解压缩分词器安装包
下面我们需要把课前资料中的 ik 分词器解压缩,重命名为 ik
上传到 es 容器的插件数据卷中
也就是 /var/lib/docker/volumes/es-plugins/_data
重启容器
docker restart es
查看日志
docker logs -f es
测试
IK 分词器包含两种模式:
ik_smart
:最少切分ik_max_word
:最细切分
- 举例
GET /_analyze
{
"analyzer": "ik_max_word",
"text": "这是SpringCloud的中文学习笔记"
}
17.5.4.拓展词典
随着互联网的发展,“造词运动” 也越发的频繁。
出现了很多新的词语,在原有的词汇列表中并不存在。
比如:“奥力给”,“传智播客” 等。
所以我们的词汇也需要不断的更新,IK分词器提供了扩展词汇的功能。
- 打开 IK 分词器
config
目录
- 在
IKAnalyzer.cfg.xml
配置文件内容添加
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE properties SYSTEM "http://java.sun/dtd/properties.dtd">
<properties>
<comment>IK Analyzer 扩展配置</comment>
<!--用户可以在这里配置自己的扩展字典 *** 添加扩展词典-->
<entry key="ext_dict">ext.dic</entry>
</properties>
- 新建一个
ext.dic
,可以参考config
目录下复制一个配置文件进行修改
传智播客
奥力给
- 重启
elasticsearch
docker restart es
- 查看日志
docker logs -f elasticsearch
日志中已经成功加载 ext.dic
配置文件
- 测试效果
GET /_analyze
{
"analyzer": "ik_max_word",
"text": "此文章摘抄自传智播客的开放视频,奥力给!"
}
注意当前文件的编码必须是 UTF-8 格式,严禁使用 Windows 记事本编辑
更多信息还请访问:https://github/medcl/elasticsearch-analysis-ik
17.5.5.停用词典
在互联网项目中,在网络间传输的速度很快,所以很多语言是不允许在网络上传递的。
如:关于宗教、政治等敏感词语,那么我们在搜索时也应该忽略当前词汇。
IK 分词器也提供了强大的停用词功能,让我们在索引时就直接忽略当前的停用词汇表中的内容。
IKAnalyzer.cfg.xml
配置文件内容添加:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE properties SYSTEM "http://java.sun/dtd/properties.dtd">
<properties>
<comment>IK Analyzer 扩展配置</comment>
<!--用户可以在这里配置自己的扩展字典-->
<entry key="ext_dict">ext.dic</entry>
<!--用户可以在这里配置自己的扩展停止词字典 *** 添加停用词词典-->
<entry key="ext_stopwords">stopword.dic</entry>
</properties>
- 在
stopword.dic
添加停用词
某大大
- 重启
elasticsearch
、重启kibana
docker restart elasticsearch
docker restart kibana
- 查看日志
docker logs -f elasticsearch
- 测试效果
GET /_analyze
{
"analyzer": "ik_max_word",
"text": "某大大的路程"
}
注意当前文件的编码必须是 UTF-8 格式,严禁使用 Windows 记事本编辑
更多信息还请访问:https://github/medcl/elasticsearch-analysis-ik
17.5.6.总结
分词器的作用是什么?
- 创建倒排索引时对文档分词
- 用户搜索时,对输入的内容分词
IK 分词器有几种模式?
ik_smart
:智能切分,粗粒度ik_max_word
:最细切分,细粒度
IK 分词器如何拓展词条?如何停用词条?
- 利用
config
目录的IkAnalyzer.cfg.xml
文件添加拓展词典和停用词典 - 在词典中添加拓展词条或者停用词条
18.索引库操作
索引库就类似数据库表,mapping 映射就类似表的结构。
我们要向 es 中存储数据,必须先创建 “库” 和 “表”。
18.1.mapping 映射属性
mapping 是对索引库中文档的约束,常见的 mapping 属性包括:
type
:字段数据类型,常见的简单类型有:- 字符串:text(可分词的文本)、keyword(精确值。例如:品牌、国家、ip地址)
- 数值:long、integer、short、byte、double、float
- 布尔:boolean
- 日期:date
- 对象:object
index
:是否创建索引,默认为 trueanalyzer
:使用哪种分词器properties
:该字段的子字段
例如下面的 json 文档:
{
"age": 21,
"weight": 52.1,
"isMarried": false,
"info": "黑马程序员Java讲师",
"email": "zy@itcast",
"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_smartemail
:类型为字符串,但是不需要分词,因此是 keyword;不参与搜索,因此需要 index 为 false;无需分词器score
:虽然是数组,但是我们只看元素的类型,类型为 float;参与搜索,因此需要 index 为 true;无需分词器name
:类型为 object,需要定义多个子属性name.firstName
:类型为字符串,但是不需要分词,因此是 keyword;参与搜索,因此需要 index 为 true;无需分词器name.lastName
:类型为字符串,但是不需要分词,因此是 keyword;参与搜索,因此需要 index 为 true;无需分词器
18.2.索引库的 CRUD
这里我们统一使用 Kibana 编写 DSL 的方式来演示。
18.2.1.创建索引库和映射
ES 中通过 Restful 请求操作索引库、文档。
请求内容用 DSL 语句来表示。
基本语法
- 请求方式:PUT
- 请求路径:
/索引库名
,可以自定义 - 请求参数:mapping 映射
格式
PUT /索引库名称
{
"mappings": {
"properties": {
"字段名":{
"type": "text",
"analyzer": "ik_smart"
},
"字段名2":{
"type": "keyword",
"index": "false"
},
"字段名3":{
"properties": {
"子字段": {
"type": "keyword"
}
}
},
// ...略
}
}
}
示例
# 创建索引库
PUT /heima
{
"mappings": {
"properties": {
"info":{
"type":"text",
"analyzer": "ik_smart"
},
"email":{
"type":"keyword",
"index": false
},
"name":{
"type": "object",
"properties": {
"firstName":{
"type": "keyword"
},
"lastName":{
"type": "keyword"
}
}
}
}
}
}
18.2.2.查询索引库
基本语法
- 请求方式:GET
- 请求路径:
/索引库名
- 请求参数:无
格式
GET /索引库名
例
GET /heima
18.2.3.修改索引库
倒排索引结构虽然不复杂,但是一旦数据结构改变(比如改变了分词器),就需要重新创建倒排索引,这简直是灾难。
因此索引库一旦创建,无法修改mapping。
虽然无法修改 mapping 中已有的字段,但是却允许添加新的字段到 mapping 中,因为不会对倒排索引产生影响。
语法说明
PUT /索引库名/_mapping
{
"properties": {
"新字段名":{
"type": "integer"
}
}
}
示例
# 修改索引库(添加新字段)
PUT /heima/_mapping
{
"properties":{
"age":{
"type": "integer"
}
}
}
重复添加同一字段会报错
PUT /heima/_mapping
{
"properties":{
"age":{
"type": "long"
}
}
}
18.2.4.删除索引库
语法
- 请求方式:DELETE
- 请求路径:/索引库名
- 请求参数:无
格式
DELETE /索引库名
示例
在 kibana 中测试
# 删除
DELETE /heima
18.2.5.总结
- 创建索引库:
PUT/索引库名
- 查询索引库:
GET/索引库名
- 删除索引库:
DELETE/索引库名
- 添加字段:
PUT/索引库名/_mapping
19.文档操作
19.1.新增文档
语法:
POST /索引库名/_doc/文档id
{
"字段1": "值1",
"字段2": "值2",
"字段3": {
"子属性1": "值3",
"子属性2": "值4"
},
// ...
}
示例:
POST /heima/_doc/1
{
"info": "黑马程序员Java讲师",
"email": "zy@itcast",
"name": {
"firstName": "云",
"lastName": "赵"
}
}
响应
19.2.查询文档
根据 rest 风格,新增是 post,查询应该是 get,不过查询一般都需要条件,这里我们把文档 id 带上。
语法:
GET /{索引库名称}/_doc/{id}
通过 kibana 查看数据
GET /heima/_doc/1
查看结果
19.3.删除文档
删除使用 DELETE 请求,同样,需要根据 id 进行删除:
语法:
DELETE /{索引库名}/_doc/id值
示例:
# 根据 id 删除数据
DELETE /heima/_doc/1
结果
19.4.修改文档
修改有两种方式:
- 全量修改:直接覆盖原来的文档
- 增量修改:修改文档中的部分字段
19.4.1.全量修改
全量修改是覆盖原来的文档,其本质是
- 根据指定的 id 删除文档
- 新增一个相同 id 的文档
注意:如果根据id删除时,id不存在,第二步的新增也会执行,也就从修改变成了新增操作了。
语法
PUT /{索引库名}/_doc/文档id
{
"字段1": "值1",
"字段2": "值2",
// ... 略
}
示例
PUT /heima/_doc/1
{
"info": "黑马程序员高级Java讲师",
"email": "zy@itcast",
"name": {
"firstName": "云",
"lastName": "赵"
}
}
19.4.5.增量修改
增量修改是只修改指定 id 匹配的文档中的部分字段。
语法
POST /{索引库名}/_update/文档id
{
"doc": {
"字段名": "新的值",
}
}
示例
POST /heima/_update/1
{
"doc": {
"email": "ZhaoYun@itcast"
}
}
19.5.总结
文档操作有哪些?
- 创建文档:
POST /{索引库名}/_doc/文档id { json文档 }
- 查询文档:
GET /{索引库名}/_doc/文档id
- 删除文档:
DELETE /{索引库名}/_doc/文档id
- 修改文档:
- 全量修改:
PUT /{索引库名}/_doc/文档id { json文档 }
- 增量修改:
POST /{索引库名}/_update/文档id { "doc": {字段}}
- 全量修改:
20.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
我们学习的是 Java HighLevel Rest Client 客户端 API
案例介绍
利用 JavaRestClient 实现创建、删除索引库,判断索引库是否存在
根据课前资料提供的酒店数据创建索引库,索引库名为 hotel,mapping 属性根据数据库结构定义。
- 课前资料链接:
https://pan.baidu/s/169SFtYEvel44hRJhmFTRTQ
- 提取码:
1234
基本步骤
- 导入课前资料 Demo
- 分析数据结构,定义 mapping 属性
- 初始化 JavaRestClient
- 利用 JavaRestClient 创建索引库
- 利用 JavaRestClient 删除索引库
- 利用 JavaRestClient 判断索引库是否存在
20.1.导入 Demo 工程
20.1.1.导入数据
首先导入课前资料提供的数据库数据
数据结构如下
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;
20.1.2.导入项目
然后导入课前资料提供的项目
项目结构
其中(我使用的 MySQL 的版本是:8.0.17)
url: jdbc:mysql://localhost:3306/heima?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&useSSL=false&allowPublicKeyRetrieval=true
相关数据库还请写上自己所创建的数据库的名称。比如我这里的就是 heima
和图不一样?之前写错了,然后就是我懒得再截图了。
20.1.3.mapping 映射分析
创建索引库,最关键的是 mapping 映射,而 mapping 映射要考虑的信息包括:
- 字段名
- 字段数据类型
- 是否参与搜索
- 是否需要分词
- 如果分词,分词器是什么?
其中:
- 字段名、字段数据类型,可以参考数据表结构的名称和类型
- 是否参与搜索要分析业务来判断,例如图片地址,就无需参与搜索
- 是否分词呢要看内容,内容如果是一个整体就无需分词,反之则要分词
- 分词器,我们可以统一使用
ik_max_word
来看下酒店数据的索引库结构
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 合并,提供给用户搜索copy_to
:字段拷贝可以使用 copy_to 属性将当前字段拷贝到指定字段。
20.1.4.初始化 RestClient
在 elasticsearch 提供的 API 中,与 elasticsearch 一切交互都封装在一个名为 RestHighLevelClient 的类中,
必须先完成这个对象的初始化,建立与 elasticsearch 的连接。
- 引入 es 的 RestHighLevelClient 依赖:
<!-- ElasticSearch -->
<dependency>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-high-level-client</artifactId>
</dependency>
- 因为 SpringBoot 默认的 ES 版本是 7.6.2,所以我们需要覆盖默认的 ES 版本:
<properties>
<java.version>1.8</java.version>
<elasticsearch.version>7.12.1</elasticsearch.version>
</properties>
- 初始化 RestHighLevelClient
初始化的代码如下
RestHighLevelClient client = new RestHighLevelClient(RestClient.builder(
HttpHost.create("http://192.168.150.101:9200")
));
这里为了单元测试方便,我们创建一个测试类 HotelIndexTest,然后将初始化的代码编写在 @BeforeEach
方法中
src/test/java/cn/itcast/hotel/HotelIndexTest.java
package cn.itcast.hotel;
import org.apache.http.HttpHost;
import org.elasticsearch.client.RestHighLevelClient;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import java.io.IOException;
@SpringBootTest
public class HotelIndexTest {
private RestHighLevelClient client;
@BeforeEach
void setUp() {
this.client = new RestHighLevelClient(RestClient.builder(
HttpHost.create("http://192.168.150.101:9200")
));
}
@AfterEach
void tearDown() throws IOException {
this.client.close();
}
/**
* 测试客户端初始化是否成功
*/
@Test
void testInit() {
System.out.println(client);
}
}
20.2.创建索引库
20.2.1.代码解读
创建索引库的 API 如下
代码分为三步:
- 创建 Request 对象。因为是创建索引库的操作,因此 Request 是 CreateIndexRequest。
- 添加请求参数,其实就是 DSL 的 JSON 参数部分。
- 因为 json 字符串很长,这里是定义了静态字符串常量
MAPPING_TEMPLATE
,让代码看起来更加优雅。
- 因为 json 字符串很长,这里是定义了静态字符串常量
- 发送请求,
client.indices()
方法的返回值是 IndicesClient 类型,封装了所有与索引库操作有关的方法。
20.2.2.完整代码
创建新目录 constants
,在该目录下创建一个类。
src/main/java/cn/itcast/hotel/constants/HotelIndexConstants.java
package cn.itcast.hotel.constants;
public class HotelIndexConstants {
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" +
" },\n" +
" \"starName\": {\n" +
" \"type\": \"keyword\"\n" +
" },\n" +
" \"business\": {\n" +
" \"type\": \"keyword\",\n" +
" \"copy_to\": \"all\"\n" +
" },\n" +
" \"pic\": {\n" +
" \"type\": \"keyword\",\n" +
" \"index\": false\n" +
" },\n" +
" \"location\": {\n" +
" \"type\": \"geo_point\"\n" +
" },\n" +
" \"all\": {\n" +
" \"type\": \"text\",\n" +
" \"analyzer\": \"ik_max_word\"\n" +
" }\n" +
" }\n" +
" }\n" +
"}";
}
src/test/java/cn/itcast/hotel/HotelIndexTest.java
@Test
void createHotelIndex() throws IOException {
// 1.创建 Request 对象
CreateIndexRequest request = new CreateIndexRequest("hotel");
// 2.准备请求的参数:DSL 语句
request.source(MAPPING_TEMPLATE, XContentType.JSON);
// 3.发送请求
client.indices().create(request, RequestOptions.DEFAULT);
}
20.3.删除索引库
删除索引库的 DSL 语句非常简单:
DELETE /hotel
与创建索引库相比:
- 请求方式从 PUT 变为 DELTE
- 请求路径不变
- 无请求参数
所以代码的差异,注意体现在 Request 对象上。
依然是三步走:
- 创建 Request 对象。这次是 DeleteIndexRequest 对象
- 准备参数。这里是无参
- 发送请求。改用 delete 方法
在 hotel-demo
中的 HotelIndexTest 测试类中,编写单元测试,实现删除索引
src/test/java/cn/itcast/hotel/HotelIndexTest.java
@Test
void testDeleteHotelIndex() throws IOException {
// 1.创建 Request 对象
DeleteIndexRequest request = new DeleteIndexRequest("hotel");
// 2.发送请求
client.indices().delete(request, RequestOptions.DEFAULT);
}
20.4.判断索引库是否存在
判断索引库是否存在,本质就是查询。对应的 DSL 是
GET /hotel
因此与删除的 Java 代码流程是类似的。依然是三步走:
- 创建 Request 对象。这次是 GetIndexRequest 对象
- 准备参数。这里是无参
- 发送请求。改用 exists 方法
src/test/java/cn/itcast/hotel/HotelIndexTest.java
@Test
void testExistsHotelIndex() throws IOException {
// 1.创建 Request 对象
GetIndexRequest request = new GetIndexRequest("hotel");
// 2.发送请求
boolean exists = client.indices().exists(request, RequestOptions.DEFAULT);
// 3.输出
System.out.println(exists ? "索引库已经存在!" : "索引库不存在!");
}
20.5.总结
JavaRestClient 操作 elasticsearch 的流程基本类似。核心是 client.indices()
方法来获取索引库的操作对象。
索引库操作的基本步骤:
- 初始化 RestHighLevelClient
- 创建 XxxIndexRequest。XXX 是 Create、Get、Delete
- 准备 DSL( Create 时需要,其它是无参)
- 发送请求。调用
RestHighLevelClient#indices().xxx()
方法,xxx
是create
、exists
、delete
21.RestClient 操作文档
案例介绍
- 去数据库查询酒店数据,导入到 hotel 索引库,实现酒店数据的 CRUD。
基本步骤
- 初始化 JavaRestClient
- 利用 JavaRestClient 新增酒店数据
- 利用 JavaRestClient 根据 id 查询酒店数据
- 利用 JavaRestClient 删除酒店数据
- 利用 JavaRestClient 修改酒店数据
为了与索引库操作分离,我们再次参加一个测试类,做两件事情:
- 初始化 RestHighLevelClient
- 我们的酒店数据在数据库,需要利用 IHotelService 去查询,所以注入这个接口
src/test/java/cn/itcast/hotel/HotelDocumentTest.java
package cn.itcast.hotel;
import cn.itcast.hotel.pojo.Hotel;
import cn.itcast.hotel.service.IHotelService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.io.IOException;
import java.util.List;
@SpringBootTest
public class HotelDocumentTest {
@Autowired
private IHotelService hotelService;
private RestHighLevelClient client;
@BeforeEach
void setUp() {
this.client = new RestHighLevelClient(RestClient.builder(
HttpHost.create("http://192.168.150.101:9200")
));
}
@AfterEach
void tearDown() throws IOException {
this.client.close();
}
}
21.1.新增文档
我们要将数据库的酒店数据查询出来,写入 elasticsearch 中。
先查询酒店数据,然后给这条数据创建倒排索引,即可完成添加。
21.1.1.索引库实体类
数据库查询后的结果是一个 Hotel 类型的对象。结构如下:
src/main/java/cn/itcast/hotel/pojo/Hotel.java
@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
因此,我们需要定义一个新的类型,与索引库结构吻合
src/main/java/cn/itcast/hotel/pojo/HotelDoc.java
package cn.itcast.hotel.pojo;
import lombok.Data;
import lombok.NoArgsConstructor;
@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;
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();
}
}
21.1.2.语法说明
新增文档的 DSL 语句如下:
POST /{索引库名}/_doc/1
{
"name": "Jack",
"age": 21
}
对应的 java 代码如图
可以看到与创建索引库类似,同样是三步走:
- 创建 Request 对象
- 准备请求参数,也就是 DSL 中的 JSON 文档
- 发送请求
变化的地方在于,这里直接使用 client.xxx()
的 API,不再需要 client.indices()
了。
21.1.3.完整代码
我们导入酒店数据,基本流程一致,但是需要考虑几点变化:
- 酒店数据来自于数据库,我们需要先查询出来,得到 hotel 对象
- hotel 对象需要转为 HotelDoc 对象
- HotelDoc 需要序列化为 json 格式
因此,代码整体步骤如下
- 根据 id 查询酒店数据 Hotel
- 将 Hotel 封装为 HotelDoc
- 将 HotelDoc 序列化为 JSON
- 创建 IndexRequest,指定索引库名和 id
- 准备请求参数,也就是 JSON 文档
- 发送请求
在 hotel-demo
的 HotelDocumentTest 测试类中,编写单元测试
src/test/java/cn/itcast/hotel/HotelDocumentTest.java
@Test
void testAddDocument() throws IOException {
// 1.根据 id 查询酒店数据
Hotel hotel = hotelService.getById(61083L);
// 2.转换为文档类型
HotelDoc hotelDoc = new HotelDoc(hotel);
// 3.将 HotelDoc 转 json
String json = JSON.toJSONString(hotelDoc);
// 1.准备 Request 对象
IndexRequest request = new IndexRequest("hotel").id(hotelDoc.getId().toString());
// 2.准备 Json 文档
request.source(json, XContentType.JSON);
// 3.发送请求
client.index(request, RequestOptions.DEFAULT);
}
IDEA 控制台
kibana 界面 Dev-Tools
21.2.查询文档
根据 id 查询到的数据是 json,需要反序列化为 java 对象
21.2.1.语法说明
查询的 DSL 语句如下:
GET /hotel/_doc/{id}
非常简单,因此代码大概分两步:
- 准备 Request 对象
- 发送请求
不过查询的目的是得到结果,解析为 HotelDoc,因此难点是结果的解析。
完整代码如下
可以看到,结果是一个 JSON,其中文档放在一个 _source
属性中,因此解析就是拿到 _source
,反序列化为 Java 对象即可。
与之前类似,也是三步走:
- 准备 Request 对象。这次是查询,所以是 GetRequest
- 发送请求,得到结果。因为是查询,这里调用
client.get()
方法 - 解析结果,就是对 JSON 做反序列化
21.2.2.完整代码
在 hotel-demo
的 HotelDocumentTest 测试类中,编写单元测试
src/test/java/cn/itcast/hotel/HotelDocumentTest.java
@Test
void testGetDocumentById() throws IOException {
// 1.准备 Request
GetRequest request = new GetRequest("hotel", "61082");
// 2.发送请求,得到响应
GetResponse response = client.get(request, RequestOptions.DEFAULT);
// 3.解析响应结果
String json = response.getSourceAsString();
// 4.反序列化为 Java 对象
HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class);
System.out.println(hotelDoc);
}
启动程序后的 IDEA 控制台
21.3.删除文档
删除的 DSL 是这样的
DELETE /hotel/_doc/{id}
与查询相比,仅仅是请求方式从DELETE 变成 GET,可以想象 Java 代码应该依然是三步走
- 准备 Request 对象,因为是删除,这次是 DeleteRequest 对象。要指定索引库名和 id
- 准备参数,无参
- 发送请求。因为是删除,所以是
client.delete()
方法
在 hotel-demo
的 HotelDocumentTest 测试类中,编写单元测试
src/test/java/cn/itcast/hotel/HotelDocumentTest.java
@Test
void testDeleteDocument() throws IOException {
// 1.准备 Request
DeleteRequest request = new DeleteRequest("hotel", "61083");
// 2.发送请求
client.delete(request, RequestOptions.DEFAULT);
}
21.4.修改文档
21.4.1.语法说明
修改我们讲过两种方式:
- 全量修改:本质是先根据 id 删除,再新增
- 增量修改:修改文档中的指定字段值
在 RestClient 的 API 中,全量修改与新增的 API 完全一致,判断依据是 ID
- 如果新增时,ID 已经存在,则修改
- 如果新增时,ID 不存在,则新增
这里不再赘述,我们主要关注增量修改。
代码示例如图
与之前类似,也是三步走:
- 准备 Request 对象。这次是修改,所以是 UpdateRequest
- 准备参数。也就是 JSON 文档,里面包含要修改的字段
- 更新文档。这里调用
client.update()
方法
21.4.2.完整代码
在 hotel-demo
的 HotelDocumentTest 测试类中,编写单元测试
src/test/java/cn/itcast/hotel/HotelDocumentTest.java
@Test
void testUpdateDocument() throws IOException {
// 1.准备 Request
UpdateRequest request = new UpdateRequest("hotel", "61083");
// 2.准备请求参数
request.doc(
"price", "952",
"starName", "四钻"
);
// 3.发送请求
client.update(request, RequestOptions.DEFAULT);
}
21.5.批量导入文档
案例:利用 JavaRestClient 批量导入酒店数据到 ES
需求:批量查询酒店数据,然后批量导入索引库中
思路:
- 利用 mybatis-plus 查询酒店数据
- 将查询到的酒店数据(Hotel)转换为文档类型数据(HotelDoc)
- 利用 JavaRestClient 中的 Bulk 批处理,实现批量新增文档
21.5.1.语法说明
批量处理 BulkRequest,其本质就是将多个普通的 CRUD 请求组合在一起发送。
其中提供了一个 add 方法,用来添加其他请求
可以看到,能添加的请求包括:
- IndexRequest,也就是新增
- UpdateRequest,也就是修改
- DeleteRequest,也就是删除
因此 Bulk 中添加了多个 IndexRequest,就是批量新增功能了。
示例
其实还是三步走:
- 创建 Request 对象。这里是 BulkRequest
- 准备参数。批处理的参数,就是其它 Request 对象,这里就是多个 IndexRequest
- 发起请求。这里是批处理,调用的方法为
client.bulk()
方法
我们在导入酒店数据时,将上述代码改造成 for 循环处理即可。
21.5.2.完整代码
在 hotel-demo
的 HotelDocumentTest 测试类中,编写单元测试
src/test/java/cn/itcast/hotel/HotelDocumentTest.java
@Test
void testBulkRequest() throws IOException {
// 批量查询酒店数据
List<Hotel> hotels = hotelService.list();
// 1.创建 Request
BulkRequest request = new BulkRequest();
// 2.准备参数,添加多个新增的 Request
for (Hotel hotel : hotels) {
// 2.1.转换为文档类型 HotelDoc
HotelDoc hotelDoc = new HotelDoc(hotel);
// 2.2.创建新增文档的 Request 对象
request.add(new IndexRequest("hotel")
.id(hotelDoc.getId().toString())
.source(JSON.toJSONString(hotelDoc), XContentType.JSON));
}
// 3.发送请求
client.bulk(request, RequestOptions.DEFAULT);
}
Idea 控制台
kibana 界面 Dev-Tools
21.6.小结
文档操作的基本步骤
- 初始化 RestHighLevelClient
- 创建 XxxRequest。XXX 是 Index、Get、Update、Delete、Bulk
- 准备参数(Index、Update、Bulk 时需要)
- 发送请求。调用
RestHighLevelClient#.xxx()
方法,xxx
是 index、get、update、delete、bulk - 解析结果(Get 时需要)
# SpringCloudDay06
SpringCloud 学习 Day06(实用篇-6) 分布式搜索引擎:elasticsearch 搜索功能
- DSL 查询文档
- 搜索结果处理
- RestClient 查询文档
- 黑马旅游案例
在 SpringCloudDay05 里,我们已经导入了大量数据到 elasticsearch 中,实现了 elasticsearch 的数据存储功能。
但 elasticsearch 最擅长的还是搜索和数据分析。
故在 SpringCloudDay06 里,我们研究下 elasticsearch 的数据搜索功能。
我们会分别使用 DSL 和 RestClient 实现搜索。
22.DSL 查询文档
elasticsearch 的查询依然是基于 JSON 风格的 DSL 来实现的。
22.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
查询的语法基本一致:
GET /indexName/_search
{
"query": {
"查询类型": {
"查询条件": "条件值"
}
}
}
我们以查询所有为例,其中:
- 查询类型为
match_all
- 没有查询条件
// 查询所有
GET /indexName/_search
{
"query": {
"match_all": {
}
}
}
其它查询无非就是查询类型、查询条件的变化。
示例
小结:match 查询
查询 DSL 的基本语法是什么?
GET /索引库名/_search
{ "query": { "查询类型": { "FIELD": "TEXT"}}}
22.2.全文检索查询
22.2.1.使用场景
全文检索查询的基本流程如下:
- 对用户搜索的内容做分词,得到词条
- 根据词条去倒排索引库中匹配,得到文档 id
- 根据文档 id 找到文档,返回给用户
比较常用的场景包括:
- 商城的输入框搜索
- 百度输入框搜索
例如京东:
因为是拿着词条去匹配,因此参与搜索的字段也必须是可分词的 text 类型的字段。
22.2.2.基本语法
常见的全文检索查询包括:
match
查询:单字段查询multi_match
查询:多字段查询,任意一个字段符合条件就算符合查询条件
match
查询:全文检索查询的一种,会对用户输入内容分词,然后去倒排索引库检索。
GET /indexName/_search
{
"query": {
"match": {
"FIELD": "TEXT"
}
}
}
mulit_match
:与 match
查询相似,只不过允许同时查询多个字段。参与查询的字段越多,查询性能越差。
GET /indexName/_search
{
"query": {
"multi_match": {
"query": "TEXT",
"fields": ["FIELD1", " FIELD12"]
}
}
}
22.2.3.示例
match
查询示例
multi_match
查询示例
可以看到,两种查询结果是一样的,为什么?
因为我们将 brand、name、business 值都利用 copy_to
复制到了 all 字段中。
因此你根据三个字段搜索,和根据 all 字段搜索效果当然一样了。
但是,搜索字段越多,对查询性能影响越大,因此建议采用 copy_to
,然后单字段查询的方式。
22.2.4.总结
match
和 multi_match
的区别是什么?
match
:根据一个字段查询multi_match
:根据多个字段查询,参与查询字段越多,查询性能越差
22.3.精准查询
精确查询一般是查找 keyword、数值、日期、boolean 等类型字段。所以不会对搜索条件分词。
常见的有:
term
:根据词条精确值查询range
:根据值的范围查询
22.3.1.term 查询
因为精确查询的字段搜是不分词的字段,因此查询的条件也必须是不分词的词条。
查询时,用户输入的内容跟自动值完全匹配时才认为符合条件。如果用户输入的内容过多,反而搜索不到数据。
基本语法
// term 查询
GET /indexName/_search
{
"query": {
"term": {
"FIELD": {
"value": "VALUE"
}
}
}
}
示例
当我搜索的是精确词条时,能正确查询出结果
但是,当我搜索的内容不是词条,而是多个词语形成的短语时,反而搜索不到
22.3.2.range 查询
范围查询,一般应用在对数值类型做范围过滤的时候。比如做价格范围过滤。
基本语法
// range 查询
GET /indexName/_search
{
"query": {
"range": {
"FIELD": {
"gte": 10, // 这里的 gte 代表大于等于,gt 则代表大于
"lte": 20 // lte 代表小于等于,lt 则代表小于
}
}
}
}
示例
22.3.3.总结
精确查询常见的有哪些?
term
查询:根据词条精确匹配,一般搜索 keyword 类型、数值类型、布尔类型、日期类型字段range
查询:根据数值范围查询,可以是数值、日期的范围
22.4.地理坐标查询
所谓的地理坐标查询,其实就是根据经纬度查询。
官方文档:https://www.elastic.co/guide/en/elasticsearch/reference/current/geo-queries.html
常见的使用场景包括:
- 携程:搜索我附近的酒店
- 滴滴:搜索我附近的出租车
- 微信:搜索我附近的人
例如:附近的酒店 和 附近的出租车
22.4.1.矩形范围查询
矩形范围查询,也就是 geo_bounding_box
查询,查询坐标落在某个矩形范围的所有文档
查询时,需要指定矩形的左上、右下两个点的坐标,然后画出一个矩形,落在该矩形内的都是符合条件的点。
语法
// 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
}
}
}
}
}
这种并不符合 “附近的人” 这样的需求,所以我们就不做了。
22.4.2.附近查询
附近查询,也叫做距离查询(geo_distance
):查询到指定中心点小于某个距离值的所有文档。
换句话来说,在地图上找一个点作为圆心,以指定距离为半径,画一个圆,落在圆内的坐标都算符合条件
语法说明
// geo_distance 查询
GET /indexName/_search
{
"query": {
"geo_distance": {
"distance": "15km", // 半径
"FIELD": "31.21,121.5" // 圆心
}
}
}
示例
我们先搜索陆家嘴附近 15km 的酒店
发现共有 47 家酒店。
然后把半径缩短到 3 公里
可以发现,搜索到的酒店数量减少到了 5 家。
22.5.复合查询
复合(compound
)查询:复合查询可以将其它简单查询组合起来,实现更复杂的搜索逻辑。
常见的有两种:
fuction score
:算分函数查询,可以控制文档相关性算分,控制文档排名bool query
:布尔查询,利用逻辑关系组合多个其它的查询,实现复杂搜索
22.5.1.相关性算分
当我们利用 match
查询时,文档结果会根据与搜索词条的关联度打分(_score
),返回结果时按照分值降序排列。
例如,我们搜索 “虹桥如家”,结果如下:
[
{
"_score" : 17.850193,
"_source" : {
"name" : "虹桥如家酒店真不错",
}
},
{
"_score" : 12.259849,
"_source" : {
"name" : "外滩如家酒店真不错",
}
},
{
"_score" : 11.91091,
"_source" : {
"name" : "迪士尼如家酒店真不错",
}
}
]
在 elasticsearch 中,早期使用的打分算法是 TF-IDF 算法,公式如下:
在后来的 5.1 版本升级中,elasticsearch 将算法改进为 BM25 算法,公式如下:
TF-IDF 算法有一各缺陷,就是词条频率越高,文档得分也会越高,单个词条对文档影响较大。
而 BM25 则会让单个词条的算分有一个上限,曲线更加平滑:
小结:elasticsearch 会根据词条和文档的相关度做打分。
算法由两种:
- TF-IDF 算法
- 在 elasticsearch5.0 版本前采用的算法。会随着词频增加而越来越大。
- BM25 算法
- 在 elasticsearch5.1 版本后采用的算法。会随着词频增加而增大,但增长曲线会趋于水平。
22.5.2.算分函数查询
根据相关度打分是比较合理的需求,但合理的不一定是产品经理需要的。
以百度为例,你搜索的结果中,并不是相关度越高排名越靠前,而是谁掏的钱多排名就越靠前。
如图:
要想认为控制相关性算分,就需要利用 elasticsearch 中的 function score query 了。
- 语法说明
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 的运行流程如下:
- 根据原始条件查询搜索文档,并且计算相关性算分,称为原始算分(query score)
- 根据过滤条件,过滤文档
- 符合过滤条件的文档,基于算分函数运算,得到函数算分(function score)
- 将原始算分(query score)和函数算分(function score)基于运算模式做运算,得到最终结果,作为相关性算分。
因此,其中的关键点是:
- 过滤条件:决定哪些文档的算分被修改
- 算分函数:决定函数算分的算法
- 运算模式:决定最终算分结果
- 示例
需求:给 “如家” 这个品牌的酒店排名靠前一些
翻译一下这个需求,转换为之前说的四个要点:
- 原始条件:不确定,可以任意变化
- 过滤条件:
brand = "如家"
- 算分函数:可以简单粗暴,直接给固定的算分结果,weight
- 运算模式:比如求和
因此最终的 DSL 语句如下:
GET /hotel/_search
{
"query": {
"function_score": {
"query": { .... }, // 原始查询,可以是任意条件
"functions": [ // 算分函数
{
"filter": { // 满足的条件,品牌必须是如家
"term": {
"brand": "如家"
}
},
"weight": 2 // 算分权重为 2
}
],
"boost_mode": "sum" // 加权模式,求和
}
}
}
- 测试
在未添加算分函数时,如家得分如下
# function score 查询
GET /hotel/_search
{
"query": {
"function_score": {
"query": {
"match": {
"all": "外滩"
}
}
}
}
}
添加了算分函数后,如家得分就提升了
# function score 查询
GET /hotel/_search
{
"query": {
"function_score": {
"query": {
"match": {
"all": "外滩"
}
},
"functions": [
{
"filter": {
"term": {
"brand": "如家"
}
},
"weight": 10
}
],
"boost_mode": "sum"
}
}
}
- 小结
function score query 定义的三要素是什么?
- 过滤条件:哪些文档要加分
- 算分函数:如何计算 function score
- 加权方式:function score 与 query score 如何运算
22.5.3.布尔查询
布尔查询是一个或多个查询子句的组合,每一个子句就是一个子查询。
子查询的组合方式有:
must
:必须匹配每个子查询,类似 “与”should
:选择性匹配子查询,类似 “或”must_not
:必须不匹配,不参与算分,类似 “非”filter
:必须匹配,不参与算分
比如在搜索酒店时,除了关键字搜索外,我们还可能根据品牌、价格、城市等字段做过滤:
每一个不同的字段,其查询的条件、方式都不一样,必须是多个不同的查询,而要组合这些查询,就必须用 bool 查询了。
需要注意的是,搜索时,参与打分的字段越多,查询的性能也越差。因此这种多条件查询时,建议这样做:
- 搜索框的关键字搜索,是全文检索查询,使用 must 查询,参与算分
- 其它过滤条件,采用 filter 查询。不参与算分
- 语法示例:
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 中
# bool 查询
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
}
}
}
]
}
}
}
- 小结
bool 查询有几种逻辑关系?
must
:必须匹配的条件,可以理解为“与”should
:选择性匹配的条件,可以理解为“或”must_not
:必须不匹配的条件,不参与打分filter
:必须匹配的条件,不参与打分
23.搜索结果处理
搜索的结果可以按照用户指定的方式去处理或展示。
23.1.排序
elasticsearch 默认是根据相关度算分(_score
)来排序,但是也支持自定义方式对搜索结果排序。
可以排序字段类型有:keyword 类型、数值类型、地理坐标类型、日期类型等。
23.1.1.普通字段排序
keyword、数值、日期类型排序的语法基本一致。
语法:
GET /indexName/_search
{
"query": {
"match_all": {}
},
"sort": [
{
"FIELD": "desc" // 排序字段、排序方式 ASC、DESC
}
]
}
排序条件是一个数组,也就是可以写多个排序条件。
按照声明的顺序,当第一个条件相等时,再按照第二个条件排序,以此类推
示例
需求描述:酒店数据按照用户评价(score)降序排序,评价相同的按照价格(price)升序排序
# sort 排序
GET /hotel/_search
{
"query": {
"match_all": {}
},
"sort": [
{
"score": "desc"
},
{
"price": "desc"
}
]
}
23.1.2.地理坐标排序
地理坐标排序略有不同。
语法说明:
GET /indexName/_search
{
"query": {
"match_all": {}
},
"sort": [
{
"_geo_distance" : {
"FIELD" : "纬度,经度", // 文档中 geo_point 类型的字段名、目标坐标点
"order" : "asc", // 排序方式
"unit" : "km" // 排序的距离单位
}
}
]
}
这个查询的含义是
- 指定一个坐标,作为目标点
- 计算每一个文档中,指定字段(必须是
geo_point
类型)的坐标 到目标点的距离是多少 - 根据距离排序
示例:
需求描述:实现对酒店数据按照到你的位置坐标的距离升序排序
提示:获取你的位置的经纬度的方式:https://lbs.amap/demo/jsapi-v2/example/map/click-to-get-lnglat/
假设我的位置是:31.034661,121.612282,寻找我周围距离最近的酒店。
# 找到 31.034661,121.612282 周围的酒店
GET /hotel/_search
{
"query": {
"match_all": {}
},
"sort": [
{
"_geo_distance": {
"location": {
"lat": 31.034661,
"lon": 121.612282
},
"order": "asc",
"unit": "km"
}
}
]
}
23.2.分页
elasticsearch 默认情况下只返回 top10 的数据。
而如果要查询更多数据就需要修改分页参数了。
elasticsearch 中通过修改 from、size 参数来控制要返回的分页结果:
from
:从第几个文档开始size
:总共查询几个文档
类似于 mysql 中的 limit ?, ?
23.2.1.基本的分页
分页的基本语法
GET /hotel/_search
{
"query": {
"match_all": {}
},
"from": 0, // 分页开始的位置,默认为 0
"size": 10, // 期望获取的文档总数
"sort": [
{"price": "asc"}
]
}
23.2.2.深度分页问题
现在,我要查询 990 ~ 1000 的数据。
查询逻辑要这么写
GET /hotel/_search
{
"query": {
"match_all": {}
},
"from": 990, // 分页开始的位置,默认为0
"size": 10, // 期望获取的文档总数
"sort": [
{"price": "asc"}
]
}
这里是查询 990 开始的数据,也就是 第 990 ~ 第 1000 条数据。
不过,elasticsearch 内部分页时,必须先查询 0~ 1000 条,然后截取其中的 990 ~ 1000 的这 10 条
查询 TOP1000,如果 es 是单点模式,这并无太大影响。
但是 elasticsearch 将来一定是集群。
例如我集群有 5 个节点,我要查询TOP1000的数据,并不是每个节点查询 200 条就可以了。
因为节点 A 的 TOP200,在另一个节点可能排到 10000 名以外了。
因此要想获取整个集群的 TOP1000,必须先查询出每个节点的 TOP1000,汇总结果后,重新排名,重新截取 TOP1000。
那如果我要查询 9900~10000 的数据呢?是不是要先查询 TOP10000 呢?那每个节点都要查询 10000 条?汇总到内存中?
当查询分页深度较大时,汇总数据过多,对内存和 CPU 会产生非常大的压力。
因此 elasticsearch 会禁止 from+ size 超过 10000 的请求。
针对深度分页,ES 提供了两种解决方案,
search after
:分页时需要排序,原理是从上一次的排序值开始,查询下一页数据。官方推荐使用的方式。scroll
:原理将排序后的文档 id 形成快照,保存在内存。官方已经不推荐使用。
更多详情还请查看官方文档:https://www.elastic.co/guide/en/elasticsearch/reference/current/paginate-search-results.html
23.2.3.小结
分页查询的常见实现方案以及优缺点
-
from + size
:- 优点:支持随机翻页
- 缺点:深度分页问题,默认查询上限(from + size)是 10000
- 场景:百度、京东、谷歌、淘宝这样的随机翻页搜索
-
after search
:- 优点:没有查询上限(单次查询的 size 不超过 10000)
- 缺点:只能向后逐页查询,不支持随机翻页
- 场景:没有随机翻页需求的搜索,例如手机向下滚动翻页
-
scroll
:- 优点:没有查询上限(单次查询的 size 不超过 10000)
- 缺点:会有额外内存消耗,并且搜索结果是非实时的
- 场景:海量数据的获取和迁移。从 ES7.1 开始不推荐,建议用
after search
方案。
23.3.高亮
23.3.1.高亮原理
什么是高亮显示呢?
我们在百度,京东搜索时,关键字会变成红色,比较醒目,这叫高亮显示
高亮显示的实现分为两步:
- 给文档中的所有关键字都添加一个标签,例如
<em>
标签 - 页面给
<em>
标签编写 CSS 样式
23.3.2.实现高亮
高亮的语法
GET /hotel/_search
{
"query": {
"match": {
"FIELD": "TEXT" // 查询条件,高亮一定要使用全文检索查询
}
},
"highlight": {
"fields": { // 指定要高亮的字段
"FIELD": {
"pre_tags": "<em>", // 用来标记高亮字段的前置标签
"post_tags": "</em>" // 用来标记高亮字段的后置标签
}
}
}
}
注意
- 高亮是对关键字高亮,因此搜索条件必须带有关键字,而不能是范围这样的查询。
- 默认情况下,高亮的字段,必须与搜索指定的字段一致,否则无法高亮
- 如果要对非搜索字段高亮,则需要添加一个属性:
required_field_match=false
示例
# 高亮查询,默认情况下,ES 搜索字段必须与高亮字段一致
GET /hotel/_search
{
"query": {
"match": {
"all": "如家"
}
},
"highlight": {
"fields": {
"name": {
"require_field_match": "false"
}
}
}
}
23.4.小结
查询的 DSL 是一个大的 JSON 对象。
包含下列属性:
query
:查询条件from
和size
:分页条件sort
:排序条件highlight
:高亮条件
示例
24.RestClient 查询文档
文档的查询同样适用于 RestHighLevelClient 对象
基本步骤
- 准备 Request 对象
- 准备请求参数
- 发起请求
- 解析响应
24.1.快速入门
我们以 match_all
查询为例
24.1.1.发起查询请求
我们通过 match_all
来演示下基本的 API,先看请求 DSL 的组织。
代码解读:
- 第一步,创建
SearchRequest
对象,指定索引库名 - 第二步,利用
request.source()
构建 DSL,DSL 中可以包含查询、分页、排序、高亮等query()
:代表查询条件,利用QueryBuilders.matchAllQuery()
构建一个match_all
查询的DSL
- 第三步,利用
client.search()
发送请求,得到响应
这里关键的 API 有两个,一个是 request.source()
,其中包含了查询、排序、分页、高亮等所有功能
另一个是 QueryBuilders
,其中包含 match、term、function_score、bool 等各种查询
24.1.2.解析响应
响应的结果的解析
elasticsearch 返回的结果是一个 JSON 字符串,结构包含:
hits
:命中的结果total
:总条数,其中的 value 是具体的总条数值max_score
:所有结果中得分最高的文档的相关性算分hits
:搜索结果的文档数组,其中的每个文档都是一个 json 对象_source
:文档中的原始数据,也是 json 对象
因此,我们解析响应结果,就是逐层解析 JSON 字符串,流程如下:
SearchHits
:通过response.getHits()
获取,就是 JSON 中的最外层的 hits,代表命中的结果SearchHits#getTotalHits().value
:获取总条数信息SearchHits#getHits()
:获取SearchHit数组,也就是文档数组SearchHit#getSourceAsString()
:获取文档结果中的_source
,也就是原始的 json 文档数据
24.1.3.完整代码
src/test/java/cn/itcast/hotel/HotelSearchTest.java
/**
* match_all 查询
*
* @throws IOException
*/
@Test
void testMatchAll() throws IOException {
// 1.准备 Request
SearchRequest request = new SearchRequest("hotel");
// 2.准备 DSL
request.source()
.query(QueryBuilders.matchAllQuery());
// 3.发送请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4.解析响应
handleResponse(response);
}
/**
* 响应结果的解析
*
* @param response
*/
private void handleResponse(SearchResponse response) {
// 4.解析响应
SearchHits searchHits = response.getHits();
// 4.1.获取总条数
long total = searchHits.getTotalHits().value;
System.out.println("共搜索到" + total + "条数据");
// 4.2.文档数组
SearchHit[] hits = searchHits.getHits();
// 4.3.遍历
for (SearchHit hit : hits) {
// 获取文档 source
String json = hit.getSourceAsString();
// 反序列化
HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class);
System.out.println("hotelDoc = " + hotelDoc);
}
}
24.1.4.小结
查询的基本步骤是
- 创建 SearchRequest 对象
- 准备
Request.source()
,也就是 DSLQueryBuilders
来构建查询条件- 传入
Request.source()
的query()
方法
- 发送请求,得到结果
- 解析结果(参考 JSON 结果,从外到内,逐层解析)
24.2.match 查询
全文检索的 match
和 multi_match
查询与 match_all
的 API 基本一致。
差别是查询条件,也就是 query
的部分。
因此,Java 代码上的差异主要是 request.source().query()
中的参数了。
同样是利用 QueryBuilders 提供的方法
而结果解析代码则完全一致,可以抽取并共享。
完整代码
src/test/java/cn/itcast/hotel/HotelSearchTest.java
@Test
void testMatch() throws IOException {
// 1.准备 Request
SearchRequest request = new SearchRequest("hotel");
// 2.准备 DSL
request.source()
.query(QueryBuilders.matchQuery("all", "如家"));
// 3.发送请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4.解析响应
handleResponse(response);
}
24.3.精确查询
精确查询主要是两者:
term
:词条精确匹配range
:范围查询
与之前的查询相比,差异同样在查询条件,其它都一样。
查询条件构造的 API 如下
完整代码-1
src/test/java/cn/itcast/hotel/HotelSearchTest.java
@Test
void testTerm() throws IOException {
//1.准备 request
SearchRequest request = new SearchRequest("hotel");
//2.准备请求参数
request.source().
query(QueryBuilders.termQuery("city", "上海"));
//3.发送请求,得到响应
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
//4.结果解析
handleResponse(response);
}
完整代码-2
src/test/java/cn/itcast/hotel/HotelSearchTest.java
@Test
void testRange() throws IOException {
//1.准备 request
SearchRequest request = new SearchRequest("hotel");
//2.准备请求参数
request.source().
query(QueryBuilders.rangeQuery("price").gte(100).lte(150));
//3.发送请求,得到响应
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
//4.结果解析
handleResponse(response);
}
24.4.复合查询
布尔查询是用 must
、must_not
、filter
等方式组合其它查询,代码示例如下
可以看到,API 与其它查询的差别同样是在查询条件的构建,QueryBuilders,结果解析等其他代码完全不变。
完整代码
src/test/java/cn/itcast/hotel/HotelSearchTest.java
@Test
void testBool() throws IOException {
// 1.准备 Request
SearchRequest request = new SearchRequest("hotel");
// 2.准备 DSL
// 2.1.准备 BooleanQuery
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
// 2.2.添加 term
boolQuery.must(QueryBuilders.termQuery("city", "上海"));
// 2.3.添加 range
boolQuery.filter(QueryBuilders.rangeQuery("price").lte(250));
request.source().query(boolQuery);
// 3.发送请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4.解析响应
handleResponse(response);
}
当然,也可以使用链式编程的方式
- 要构建查询条件,构建在于 QueryBuilders 这个类
24.5.排序、分页
搜索结果的排序和分页是与 query 同级的参数,因此同样是使用 request.source()
来设置。
对应的 API 如下
完整代码
src/test/java/cn/itcast/hotel/HotelSearchTest.java
@Test
void testPageAndSort() throws IOException {
// 模拟:页码,每页大小
int page = 1, size = 5;
// 1.准备Request
SearchRequest request = new SearchRequest("hotel");
// 2.准备DSL
// 2.1.query
request.source().query(QueryBuilders.matchAllQuery());
// 2.2.排序 sort
request.source().sort("price", SortOrder.ASC);
// 2.3.分页 from、size
request.source().from((page - 1) * size).size(5);
// 3.发送请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4.解析响应
handleResponse(response);
}
24.6.高亮
高亮的代码与之前代码差异较大,有两点:
- 查询的 DSL:其中除了查询条件,还需要添加高亮条件,同样是与 query 同级。
- 结果解析:结果除了要解析
_source
文档数据,还要解析高亮结果
24.6.1.高亮请求构建
高亮请求的构建 API 如下
上述代码省略了查询条件部分。
但是大家不要忘了:高亮查询必须使用全文检索查询,并且要有搜索关键字,将来才可以对关键字高亮。
完整代码
src/test/java/cn/itcast/hotel/HotelSearchTest.java
@Test
void testHighlight() throws IOException {
// 1.准备 Request
SearchRequest request = new SearchRequest("hotel");
// 2.准备 DSL
// 2.1.query
request.source().query(QueryBuilders.matchQuery("all", "如家"));
// 2.2.高亮
request.source().highlighter(new HighlightBuilder().field("name").requireFieldMatch(false));
// 3.发送请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4.解析响应
handleResponse(response);
}
24.6.2.高亮结果解析
高亮的结果与查询的文档结果默认是分离的,并不在一起。
因此解析高亮的代码需要额外处理
代码解读
- 第一步:从结果中获取 source。
hit.getSourceAsString()
,这部分是非高亮结果,json 字符串。还需要反序列为 HotelDoc 对象 - 第二步:获取高亮结果。
hit.getHighlightFields()
,返回值是一个 Map,key 是高亮字段名称,值是 HighlightField 对象,代表高亮值 - 第三步:从 map 中根据高亮字段名称,获取高亮字段值对象 HighlightField
- 第四步:从 HighlightField 中获取 Fragments,并且转为字符串。这部分就是真正的高亮字符串了
- 第五步:用高亮的结果替换 HotelDoc 中的非高亮结果
完整代码
src/test/java/cn/itcast/hotel/HotelSearchTest.java
private void handleResponse(SearchResponse response) {
// 4.解析响应
SearchHits searchHits = response.getHits();
// 4.1.获取总条数
long total = searchHits.getTotalHits().value;
System.out.println("共搜索到" + total + "条数据");
// 4.2.文档数组
SearchHit[] hits = searchHits.getHits();
// 4.3.遍历
for (SearchHit hit : hits) {
// 获取文档source
String json = hit.getSourceAsString();
// 反序列化
HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class);
// 获取高亮结果
Map<String, HighlightField> highlightFields = hit.getHighlightFields();
if (!CollectionUtils.isEmpty(highlightFields)) {
// 根据字段名获取高亮结果
HighlightField highlightField = highlightFields.get("name");
if (highlightField != null) {
// 获取高亮值
String name = highlightField.getFragments()[0].string();
// 覆盖非高亮结果
hotelDoc.setName(name);
}
}
System.out.println("hotelDoc = " + hotelDoc);
}
}
小结
- 所有搜索 DSL 的构建,需要记住一个 API:
SearchRequest的source()
方法。 - 高亮结果解析是参考 JSON 结果,逐层解析
25.黑马旅游案例
通过该案例来实战演练下之前学习的知识。
实现四部分功能:
- 酒店搜索和分页
- 酒店结果过滤
- 我周边的酒店
- 酒店竞价排名
启动资料中提供的 hotel-demo
项目,其默认端口是 8089,访问 http://localhost:8090
,就能看到项目页面了
具体情况见博客:【学习笔记:SpringCloud 微服务技术栈 | 实用篇② | 黑马旅游案例】
# SpringCloudDay07
SpringCloud 学习 Day07(实用篇-7) 分布式搜索引擎:深入 elasticsearch
- 数据聚合
- 自动补全
- 数据补全
- 集群
26.数据聚合
聚合(aggregations) 可以让我们极其方便的实现对数据的统计、分析、运算。
例如:
- 什么品牌的手机最受欢迎?
- 这些手机的平均价格、最高价格、最低价格?
- 这些手机每月的销售情况如何?
实现这些统计功能的比数据库的 sql 要方便的多,而且查询速度非常快,可以实现近实时搜索效果。
26.1.聚合的种类
- 桶(Bucket)聚合:用来对文档做分组
TermAggregation
:按照文档字段值分组,例如按照品牌值分组、按照国家分组Date Histogram
:按照日期阶梯分组,例如一周为一组,或者一月为一组
- 度量(Metric)聚合:用以计算一些值,比如:最大值、最小值、平均值等
Avg
:求平均值Max
:求最大值Min
:求最小值Stats
:同时求 max、min、avg、sum 等
- 管道(pipeline)聚合:其它聚合的结果为基础做聚合
注意:参加聚合的字段必须是 keyword、日期、数值、布尔类型
欲知更多情况需访问官网:https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations.html
26.2.DSL 实现聚合
26.2.1.Bucket 聚合语法
现在,我们要统计所有数据中的酒店品牌有几种,其实就是按照品牌对数据分组。
此时可以根据酒店品牌的名称做聚合,也就是 Bucket 聚合。
语法如下
GET /hotel/_search
{
"size": 0, // 设置 size 为 0,结果中不包含文档,只包含聚合结果
"aggs": { // 定义聚合
"brandAgg": { //给聚合起个名字
"terms": { // 聚合的类型,按照品牌值聚合,所以选择 term
"field": "brand", // 参与聚合的字段
"size": 20 // 希望获取的聚合结果数量
}
}
}
}
结果如图
26.2.2.聚合结果排序
默认情况下,Bucket 聚合会统计 Bucket 内的文档数量,记为 _count
,并且按照 _count
降序排序。
我们可以指定 order 属性,自定义聚合的排序方式
GET /hotel/_search
{
"size": 0,
"aggs": {
"brandAgg": {
"terms": {
"field": "brand",
"order": {
"_count": "asc" // 按照 _count 升序排列
},
"size": 20
}
}
}
}
26.2.3.限定聚合范围
默认情况下,Bucket 聚合是对索引库的所有文档做聚合。
但真实场景下,用户会输入搜索条件,因此聚合必须是对搜索结果聚合。那么聚合必须添加限定条件。
我们可以限定要聚合的文档范围,只要添加 query 条件即可
GET /hotel/_search
{
"query": {
"range": {
"price": {
"lte": 200 // 只对200元以下的文档聚合
}
}
},
"size": 0,
"aggs": {
"brandAgg": {
"terms": {
"field": "brand",
"size": 20
}
}
}
}
这次,聚合得到的品牌明显变少了
26.2.4.Metric 聚合语法
在之前的操作中,我们对酒店按照品牌分组,形成了一个个桶。
现在我们需要对桶内的酒店做运算,获取每个品牌的用户评分的 min、max、avg 等值。
这就要用到 Metric 聚合了,例如 stat 聚合:就可以获取min、max、avg 等结果。
语法如下:
GET /hotel/_search
{
"size": 0,
"aggs": {
"brandAgg": {
"terms": {
"field": "brand",
"size": 20
},
"aggs": { // 是 brands 聚合的子聚合,也就是分组后对每组分别计算
"score_stats": { // 聚合名称
"stats": { // 聚合类型,这里 stats 可以计算 min、max、avg 等
"field": "score" // 聚合字段,这里是 score
}
}
}
}
}
}
这次的 score_stats
聚合是在 brandAgg 的聚合内部嵌套的子聚合。
因为我们需要在每个桶分别计算。
另外,我们还可以给聚合结果做个排序,例如按照每个桶的酒店平均分做排序
26.2.5.小结
aggs 代表聚合,与 query 同级,此时 query 的作用是?
- 限定聚合的的文档范围
聚合必须的三要素:
- 聚合名称
- 聚合类型
- 聚合字段
聚合可配置属性有:
size
:指定聚合结果数量order
:指定聚合结果排序方式field
:指定聚合字段
26.3.RestAPI
聚合条件与 query 条件同级别,因此需要使用 request.source()
来指定聚合条件。
聚合条件的语法:
聚合的结果也与查询结果不同,API 也比较特殊。不过同样是 JSON 逐层解析
代码部分
src/test/java/cn/itcast/hotel/HotelSearchTest.java
聚合条件
@Test
void testAggregation() throws IOException {
//1.准备 Request
SearchRequest request = new SearchRequest("hotel");
//2.准备 DSL
//2.1.设置 size
request.source().size(0);
//2.2.聚合
request.source().aggregation(AggregationBuilders
.terms("brandAgg")
.field("brand")
.size(10)
);
//3.发出请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
//4.解析结果
//System.out.println(response);
handleAggregationResponse(response);
}
聚合结果
private void handleAggregationResponse(SearchResponse response) {
//4.解析结果
Aggregations aggregations = response.getAggregations();
//4.1.根据聚合名称获取聚合结果
Terms brandTerms = aggregations.get("brandAgg");
//4.2.获取 buckets
List<? extends Terms.Bucket> buckets = brandTerms.getBuckets();
//4.3.遍历
for (Terms.Bucket bucket : buckets) {
//4.4.获取 key
String key = bucket.getKeyAsString();
System.out.println(key);
}
}
具体项目的业务实现见博客:【学习笔记:SpringCloud 微服务技术栈 | 实用篇② | 黑马旅游案例】
27.自动补全
当用户在搜索框输入字符时,我们应该提示出与该字符有关的搜索项,如图
这种根据用户输入的字母,提示完整词条的功能,就是自动补全了。
因为需要根据拼音字母来推断,因此要用到拼音分词功能。
27.1.拼音分词器
要实现根据字母做补全,就必须对文档按照拼音分词。
在 GitHub 上恰好有 elasticsearch 的拼音分词插件。
地址:https://github/medcl/elasticsearch-analysis-pinyin
课前资料中也提供了拼音分词器的安装包
安装方式与 IK 分词器一样,分三步
- 解压
- 上传到虚拟机中,elasticsearch 的
plugin
目录 - 重启 elasticsearch
- 测试
可以通过下面命令查看 elasticsearch 的 plugins 目录位置。
docker volume inspect es-plugins
显示结果:
[
{
"CreatedAt": "2022-05-06T10:06:34+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
这个目录中。
上传到指定目录后,后重启 es 即可
docker restart es
我的 IK 分词器和拼音分词器都是安装在了 /var/lib/docker/volumes/es-plugins/_data
目录下
测试用法
POST /_analyze
{
"text": "如家酒店还不错",
"analyzer": "pinyin"
}
结果
27.2.自定义分词器
默认的拼音分词器会将每个汉字单独分为拼音,而我们希望的是每个词条形成一组拼音,需要对拼音分词器做个性化定制,形成自定义分词器。
elasticsearch 中分词器(analyzer)的组成包含三部分:
- character filters:在 tokenizer 之前对文本进行处理。例如删除字符、替换字符
- tokenizer:将文本按照一定的规则切割成词条(term)。例如 keyword,就是不分词;还有
ik_smart
- tokenizer filter:将 tokenizer 输出的词条做进一步处理。例如大小写转换、同义词处理、拼音处理等
文档分词时会依次由这三部分来处理文档
我们在创建索引库时,可以通过 settings 来配置自定义的 analyzer(分词器)
拼音分词器适合在创建倒排索引的时候使用,但不能在搜索的时候使用。
因此字段在创建倒排索引时应该用 my_analyzer
分词器;
字段在搜索时应该使用 ik_smart
分词器
声明自定义分词器的语法如下
PUT /test
{
"settings": {
"analysis": {
"analyzer": { // 自定义分词器
"my_analyzer": { // 分词器名称
"tokenizer": "ik_max_word",
"filter": "py"
}
},
"filter": { // 自定义 tokenizer filter
"py": { // 过滤器名称
"type": "pinyin", // 过滤器类型,这里是 pinyin
"keep_full_pinyin": false,
"keep_joined_full_pinyin": true,
"keep_original": true,
"limit_first_letter_length": 16,
"remove_duplicated_term": true,
"none_chinese_pinyin_tokenize": false
}
}
}
},
"mappings": {
"properties": {
"name": {
"type": "text",
"analyzer": "my_analyzer",
"search_analyzer": "ik_smart"
}
}
}
}
测试
总结
如何使用拼音分词器?
- 下载 pinyin 分词器
- 解压并放到 elasticsearch 的
plugin
目录 - 重启即可
如何自定义分词器?
- 创建索引库时,在 settings 中配置,可以包含三部分
character filter
tokenizer
filter
拼音分词器注意事项?
- 为了避免搜索到同音字,搜索时不要使用拼音分词器
27.3.自动补全查询
elasticsearch 提供了 Completion Suggester 查询来实现自动补全功能。
这个查询会匹配以用户输入内容开头的词条并返回。为了提高补全查询的效率,对于文档中字段的类型有一些约束:
- 参与补全查询的字段必须是 completion 类型。
- 字段的内容一般是用来补全的多个词条形成的数组。
比如,一个这样的索引库
// 创建索引库
PUT test2
{
"mappings": {
"properties": {
"title":{
"type": "completion"
}
}
}
}
然后插入下面的数据
// 示例数据
POST test2/_doc
{
"title": ["Sony", "WH-1000XM3"]
}
POST test2/_doc
{
"title": ["SK-II", "PITERA"]
}
POST test2/_doc
{
"title": ["Nintendo", "switch"]
}
查询的 DSL 语句如下
// 自动补全查询
GET /test2/_search
{
"suggest": {
"title_suggest": {
"text": "s", // 关键字
"completion": {
"field": "title", // 补全查询的字段
"skip_duplicates": true, // 跳过重复的
"size": 10 // 获取前10条结果
}
}
}
}
27.4.自动补全查询的 JavaAPI
之前我们学习了自动补全查询的 DSL,而没有学习对应的 JavaAPI,这里给出一个示例
而自动补全的结果也比较特殊,解析的代码如下
具体项目的业务实现见博客:【学习笔记:SpringCloud 微服务技术栈 | 实用篇② | 黑马旅游案例】
28.数据同步
elasticsearch 中的酒店数据来自于 mysql 数据库,因此 mysql 数据发生改变时,elasticsearch 也必须跟着改变。
这个就是 elasticsearch 与 mysql 之间的数据同步。
常见的数据同步方案有三种
- 同步调用
- 异步通知
- 监听 binlog
28.1.同步调用
基本步骤
hotel-demo
对外提供接口,用来修改 elasticsearch 中的数据- 酒店管理服务在完成数据库操作后,直接调用
hotel-demo
提供的接口
28.2.异步通知
流程如下
hotel-admin
对 mysql 数据库数据完成增、删、改后,发送 MQ 消息hotel-demo
监听 MQ,接收到消息后完成 elasticsearch 数据修改
28.3.监听 binlog
流程如下:
- 给 mysql 开启 binlog 功能
- mysql 完成增、删、改操作都会记录在 binlog 中
hotel-demo
基于 canal 监听 binlog 变化,实时更新 elasticsearch 中的内容
28.4.选择
方式一:同步调用
- 优点:实现简单,粗暴
- 缺点:业务耦合度高
方式二:异步通知
- 优点:低耦合,实现难度一般
- 缺点:依赖 mq 的可靠性
方式三:监听 binlog
- 优点:完全解除服务间耦合
- 缺点:开启 binlog 增加数据库负担、实现复杂度高
具体项目的业务实现见博客:【学习笔记:SpringCloud 微服务技术栈 | 实用篇② | 黑马旅游案例】
29.集群
单机的 elasticsearch 做数据存储,必然面临两个问题:海量数据存储问题、单点故障问题。
- 海量数据存储问题:将索引库从逻辑上拆分为 N 个分片(shard),存储到多个节点
- 单点故障问题:将分片数据在不同节点备份(replica )
ES集群相关概念:
- 集群(cluster):一组拥有共同的
cluster name
的 节点。 - 节点(node) :集群中的一个 Elasticearch 实例
- 分片(shard):索引可以被拆分为不同的部分进行存储,称为分片。
- 在集群环境下,一个索引的不同分片可以拆分到不同的节点中
解决问题:数据量太大,单点存储量有限的问题。
此处,我们把数据分成 3 片:shard0、shard1、shard2
- 主分片(Primary shard):相对于副本分片的定义。
- 副本分片(Replica shard)每个主分片可以有一个或者多个副本,数据和主分片一样。
数据备份可以保证高可用,但是每个分片备份一份,所需要的节点数量就会翻一倍,成本实在是太高了!
为了在高可用和成本间寻求平衡,我们可以这样做:
- 首先对数据分片,存储到不同节点
- 然后对每个分片进行备份,放到对方节点,完成互相备份
这样可以大大减少所需要的服务节点数量,如图,我们以 3 分片,每个分片备份一份为例
现在,每个分片都有 1 个备份,存储在 3 个节点
- node0:保存了分片 0 和 1
- node1:保存了分片 0 和 2
- node2:保存了分片 1 和 2
29.1.部署 es 集群
29.1.1.搭建 ES 集群
首先编写一个 docker-compose 文件,内容如下
version: '2.2'
services:
es01:
image: 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
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
volumes:
- data01:/usr/share/elasticsearch/data
ports:
- 9200:9200
networks:
- elastic
es02:
image: 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
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
volumes:
- data02:/usr/share/elasticsearch/data
ports:
- 9201:9200
networks:
- elastic
es03:
image: 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
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
volumes:
- data03:/usr/share/elasticsearch/data
networks:
- elastic
ports:
- 9202:9200
volumes:
data01:
driver: local
data02:
driver: local
data03:
driver: local
networks:
elastic:
driver: bridge
es 运行需要修改一些 linux 系统权限,修改 /etc/sysctl.conf
文件
vi /etc/sysctl.conf
在 /etc/sysctl.conf
文件中添加下面的内容
vm.max_map_count=262144
然后执行命令,让配置生效
sysctl -p
出现 vm.max_map_count=262144
时,说明配置生效。
通过 docker-compose 启动集群
docker-compose up -d
可以使用 docker ps
来查看创建的情况
也可以使用 dokcer logs -f es01
来查看节点的运行状态
29.1.2.集群状态监控
kibana 可以监控 es 集群,不过新版本需要依赖 es 的 x-pack 功能,配置比较复杂。
这里推荐使用 cerebro 来监控 es 集群状态。
cerebro 的官方网址:https://github/lmenezes/cerebro
课前资料已经提供了安装包
解压即可使用,非常方便。
解压好的目录如下
进入对应的 bin
目录,双击其中的 cerebro.bat
文件即可启动服务。
访问 http://localhost:9000
即可进入管理界面,输入你的 elasticsearch 的任意节点的地址和端口,点击 connect 即可
绿色的条,代表集群处于绿色(健康状态)。
29.1.3.创建索引库
两种方式:利用 kibana 的 DevTools 创建索引库;利用 cerebro 创建索引库
- 利用 kibana 的 DevTools 创建索引库
在 DevTools 中输入指令
PUT /itcast
{
"settings": {
"number_of_shards": 3, // 分片数量
"number_of_replicas": 1 // 副本数量
},
"mappings": {
"properties": {
// mapping映射定义 ...
}
}
}
- 利用 cerebro 创建索引库
填写索引库信息
点击右下角的 create 按钮
29.1.4.查看分片效果
若分片失败,那有可能是 CentOS7 空间不足的问题
若要扩容,可以参考我之前的博客:CentOS7 给 centos-root 扩容【学习记录】
29.2.集群脑裂问题
29.2.1.集群职责划分
elasticsearch 中集群节点有不同的职责划分
节点参数 | 配置参数 | 默认值 | 节点职责 |
---|---|---|---|
master eligible | node.master | true | 备选主节点; 主节点可以管理和记录集群状态, 决定分片在哪个节点, 处理创建和删除索引库的请求。 |
data | node.data | true | 数据节点:存储数据、搜索、聚合、CRUD |
ingest | node.ingest | ture | 数据存储之前的预处理`` |
coordinating | 上面的三个参数都是 false 时 则为 coordinating 节点 | 无 | 路由请求到其他节点 合并其它节点处理的结果,返回给用户 |
默认情况下,集群中的任何一个节点都同时具备上述四种角色。
但是真实的集群一定要将集群职责分离
master
节点:对 CPU 要求高,但是内存要求第data
节点:对 CPU 和内存要求都高coordinating
节点:对网络带宽、CPU 要求高
职责分离可以让我们根据不同节点的需求分配不同的硬件去部署。而且避免业务之间的互相干扰。
一个典型的 es 集群职责划分如图
29.2.2.脑裂问题
脑裂是因为集群中的节点失联导致的。
例如一个集群中,主节点与其它节点失联
此时,node2 和 node3 认为 node1 宕机,就会重新选主
当 node3 当选后,集群继续对外提供服务,node2 和 node3 自成集群,node1 自成集群,两个集群数据不同步,出现数据差异。
当网络恢复后,因为集群中有两个 master 节点,集群状态的不一致,出现脑裂的情况
解决脑裂的方案是,要求选票超过 ( eligible 节点数量 + 1 )/ 2
才能当选为主,因此 eligible 节点数量最好是奇数。
对应配置项是 discovery.zen.minimum_master_nodes
,在 es7.0 以后,已经成为默认配置,因此一般不会发生脑裂问题
例如:3 个节点形成的集群,选票必须超过 (3 + 1) / 2
,也就是 2 票。
node3 得到 node2 和 node3 的选票,当选为主。
node1 只有自己 1 票,没有当选。
集群中依然只有 1 个主节点,没有出现脑裂。
29.2.3.小结
master eligible
节点的作用是什么?
- 参与集群选主
- 主节点可以管理集群状态、管理分片信息、处理创建和删除索引库的请求
data节点的作用是什么?
- 数据的 CRUD
coordinator 节点的作用是什么?
- 路由请求到其它节点
- 合并查询到的结果,返回给用户
29.3.集群分布式存储
当新增文档时,应该保存到不同分片,保证数据均衡,那么 coordinating node
如何确定数据该存储到哪个分片呢?
29.3.1.分片存储测试
以下使用的工具是 Postman
- 随意插入三条数据
使用 POST 方式插入数据 http://虚拟机IP地址:920X/itcast/_doc/5
{
"title": "尝试插入一条 id = X"
}
- 使用不同端口查询数据
使用 GET 方式来查看数据 http://虚拟机IP地址:920X/itcast/_search
{
"query": {
"match_all": {}
}
}
- 查看数据存储的具体分片
使用 GET 方式来查看数据 http://虚拟机IP地址:920X/itcast/_search
{
"explain": true,
"query": {
"match_all": {}
}
}
29.3.2.分片存储原理
elasticsearch 会通过 hash 算法来计算文档应该存储到哪个分片
说明
_routing
默认是文档的 id- 算法与分片数量有关,因此索引库一旦创建,分片数量不能修改!
新增文档的流程如下
解读
- 新增一个
id=1
的文档 - 对 id 做 hash 运算,假如得到的是 2,则应该存储到
shard-2
shard-2
的主分片在node3
节点,将数据路由到node3
- 保存文档
- 同步给
shard-2
的副本replica-2
,在node2
节点 - 返回结果给
coordinating-node
节点
29.4.集群分布式查询
elasticsearch 的查询分成两个阶段
scatter phase
:分散阶段,coordinating node
会把请求分发到每一个分片gather phase
:聚集阶段,coordinating node
汇总data node
的搜索结果,并处理为最终结果集返回给用户
29.5.集群故障转移
集群的 master
节点会监控集群中的节点状态。
如果发现有节点宕机,会立即将宕机节点的分片数据迁移到其它节点,确保数据安全,这个叫做故障转移。
- 例如一个集群结构如图
现在,node1
是主节点,其它两个节点是从节点。
- 突然
node1
发生了故障
宕机后的第一件事,需要重新选主,例如选中了 node2
node2 成为主节点后,会检测集群监控状态。
发现:shard-1
、shard-0
没有副本节点。
因此需要将 node1
上的数据迁移到 node2
、node3
总结:故障转移
master
宕机后,EligibleMaster
选举为新的主节点。master
节点监控分片、节点状态,将故障节点上的分片转移到正常节点,确保数据安全。
30.实用篇总结
本博客主要讲述了
- 微服务的远程调用的简单案例
- Eureka 注册中心、Nacos 注册中心、Fegin 的远程调用、统一 GateWay
- Docker、MQ 消息队列、ElasticSearch 分布式搜索的基础知识。
至此,SpringCloud 微服务技术栈的实用篇 的内容结束
部分补充内容则见博客:【学习笔记:SpringCloud 微服务技术栈 | 实用篇② | 黑马旅游案例】
本文标签: 基础知识 学习笔记 技术 SpringCloud
版权声明:本文标题:学习笔记:SpringCloud 微服务技术栈_实用篇①_基础知识 内容由网友自发贡献,该文观点仅代表作者本人, 转载请联系作者并注明出处:http://www.freenas.com.cn/jishu/1726361678h944779.html, 本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容,一经查实,本站将立刻删除。
发表评论