- 学习视频链接
- 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 注册中心
Nacos 配置管理、Feign 远程调用、GetWay 服务网关
Docker 的基本操作、Dockerfile 的自定义镜像、了解 Docker-Compose、Dokcer 镜像服务
了解 MQ、快速入门 RabbitMQ、了解 SpringAMQP
ElasticSearch 基础:索引库操作、文档操作、RestAPI、RestClient 操作文档
ElasticSearch 搜索功能:DSL 查询文档、搜索结果处理、RestClient 查询文档、黑马旅游案例
ElasticSearch 深入学习:数据聚合、自动补全、数据补全、集群
SpringCloud 学习 Day01(实用篇-1)
- 认识微服务
- 分布式服务架构案例
- Eureka 注册中心
- Ribbon 负载均衡原理
- Nacos 注册中心
随着互联网行业的发展,对服务的要求也越来越高,服务架构也从单体架构逐渐演变为现在流行的微服务架构。
单体架构:将业务的所有功能集中在一个项目中开发,打成一个包部署。
分布式架构:根据业务功能对系统做拆分,每个业务功能模块作为独立项目开发,称为一个服务。
服务治理
分布式架构虽然降低了服务耦合,但是服务拆分时也有很多问题需要思考:
人们需要制定一套行之有效的标准来约束分布式架构。
微服务是一种经过良好架构设计的分布式架构方案。
微服务架构特征
微服务架构图
微服务部署(持续集成)
微服务图解(举例)
微服务的上述特性其实是在给分布式架构制定一个标准,进一步降低服务之间的耦合度,提供服务的独立性和灵活性。
从而做到高内聚,低耦合。
因此,可以认为微服务是一种经过良好架构设计的分布式架构方案 。
但方案该怎么落地?选用什么样的技术栈?全球的互联网公司都在积极尝试自己的微服务落地方案。
其中在 Java 领域最引人注目的就是 SpringCloud 提供的方案了。
微服务这种方案需要技术框架来落地,全球的互联网公司都在积极尝试自己的微服务落地技术。
在国内最知名的就是 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 |
SpringCloud 是目前国内使用最广泛的微服务框架。
官网地址:https://spring.io/projects/spring-cloud。
SpringCloud 集成了各种微服务功能组件,并基于 SpringBoot 实现了这些组件的自动装配。
SpringCloud 提供了良好的开箱即用体验。
另外,SpringCloud 底层是依赖于 SpringBoot 的,并且有版本的兼容关系。
我们这里学习的版本是 Hoxton.SR10,因此对应的 SpringBoot 版本是 2.3.x 版本。
课前资料
2022 最新版 Java 学习 路线图>第 4 阶段_中间键&服务框架>1.微服务开发框架 SpringCloud + RabbitMQ + Docker + Redis + 搜索 + 分布式 史上最全面的微服务全技术栈课程>实用篇>学习资料>day01-SpringCloud01(提取码:dor4)
导入服务拆分 Demo
cloud-order.sql
和 cloud-user.sql
导入到 MySQL 中。该项目导入的部分依赖以及所继承的工程
这里只是提一下,项目里有完整的文件。这里没打算赘述
<parent>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-parentartifactId>
<version>2.3.9.RELEASEversion>
<relativePath/>
parent>
<dependency>
<groupId>org.springframework.cloudgroupId>
<artifactId>spring-cloud-dependenciesartifactId>
<version>Hoxton.SR10version>
<type>pomtype>
<scope>importscope>
dependency>
<dependency>
<groupId>com.alibaba.cloudgroupId>
<artifactId>spring-cloud-alibaba-dependenciesartifactId>
<version>2.2.5.RELEASEversion>
<type>pomtype>
<scope>importscope>
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。
案例需求:根据订单 id 查询订单的同时,把订单所属的用户信息一起返回。
远程调用方式分析
因此,我们需要在 order-service中 向 user-service 发起一个 http 的请求,调用 http://localhost:8081/user/{userId}
这个接口。
大致步骤
在 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 模块中的 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;
}
}
程序运行结果展示
微服务拆分
微服务调用方式
在服务调用关系中,会有两个不同的角色:服务提供者、服务消费者。
服务提供者:一次业务中,被其它微服务调用的服务。(提供接口给其它微服务)
服务消费者:一次业务中,调用其它微服务的服务。(调用其它微服务提供的接口)
但是,服务提供者与服务消费者的角色并不是绝对的,而是相对于业务而言。
如果服务 A 调用了服务 B,而服务 B 又调用了服务 C,服务 B 的角色是什么?
因此,服务 B 既可以是服务提供者,也可以是服务消费者。
小结
假如我们的服务提供者 user-service 部署了多个实例。
以上的这些问题都需要利用 SpringCloud 中的注册中心来解决。
其中最广为人知的注册中心就是 Eureka。
在 Eureka 架构中,微服务角色有两类:服务端和客户端。
消费者该如何获取服务提供者具体信息?
如果有多个服务提供者,消费者该如何选择?
消费者如何感知服务提供者健康状态?
现在回答之前的各个问题
问题 1
:order-service 如何得知 user-service 实例地址?
问题 2
:order-service 如何从多个 user-service 实例中选择具体的实例?
问题 3
:order-service 如何得知某个 user-service 实例是否依然健康,是不是已经宕机?
首先大家注册中心服务端:eureka-server,这必须是一个独立的微服务。
总模块 cloud-demo 中的 pom.xml
<properties>
<project.build.sourceEncoding>UTF-8project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8project.reporting.outputEncoding>
<java.version>1.8java.version>
<spring-cloud.version>Hoxton.SR10spring-cloud.version>
<mysql.version>8.0.17mysql.version>
<mybatis.version>2.1.1mybatis.version>
properties>
eureka-server 模块中的 pom.xml
<dependencies>
<dependency>
<groupId>org.springframework.cloudgroupId>
<artifactId>spring-cloud-starter-netflix-eureka-serverartifactId>
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
在 user-service 和 order-service 的俩模块下的 pom.xml 都导入如下的依赖。
<dependency>
<groupId>org.springframework.cloudgroupId>
<artifactId>spring-cloud-netflix-eureka-clientartifactId>
dependency>
<dependency>
<groupId>org.springframework.cloudgroupId>
<artifactId>spring-cloud-starter-netflix-eureka-clientartifactId>
dependency>
根据情况来编写 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 地址后,都可以完成注册服务。
服务拉取是基于服务名称获取服务列表,然后对服务列表做负载均衡
下面,我们将对 order-service 进行逻辑修改。
向 eureka-server 拉取 user-service 的信息,实现服务发现(服务拉取)。
即 order-service 要去 eureka-server 中拉取 user-service 服务的实例列表,并且实现负载均衡。
不过这些动作不用我们去做,只需要添加一些注解即可。
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();
给 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 的控制台界面
显然,俩端口均有响应。
1.搭建 EurekaServer
2.服务注册
3.服务发现
上一节中,我们添加了 @LoadBalanced 注解,即可实现负载均衡功能,这是什么原理呢?
此处我们发出的请求明明是 http://userservice/user/1
,最终是怎么变成 http://localhost:8081
的呢?
为什么我们只输入了 service 名称就可以访问了呢?之前还要获取 ip 和端口。
显然有人帮我们根据 service 名称,获取到了服务实例的 ip 和端口。
它就是LoadBalancerInterceptor
,这个类会在对 RestTemplate 的请求进行拦截。
然后从 Eureka 中获取服务列表(根据服务 id),随后利用负载均衡算法得到真实的服务地址信息,替换服务 id。
可以看到这里的 intercept 方法,拦截了用户的 HttpRequest 请求,然后做了几件事:
request.getURI()
:获取请求 uri,本例中就是 http://user-service/user/8
originalUri.getHost()
:获取 uri 路径的主机名,其实就是服务 id,user-service
this.loadBalancer.execute()
:处理服务 id,和用户请求。这里的 this.loadBalancer
是 LoadBalancerClient
类型,我们继续跟入。
继续跟入 LoadBalancerIntercepor 中的 execute 方法,来到 LoadBalancerClient
代码是这样的:
放行后,再次访问并跟踪,发现获取的是 8081
显然实现了负载均衡。
在刚才的代码中,可以看到获取服务是通过一个 getServer
方法来做负载均衡
继续跟踪源码 chooseServer 方法,发现这么一段代码
我们看看这个 rule 是谁
这里的 rule 默认值是一个 RoundRobinRule
,根据类的介绍可知其为轮询。
到这里,整个负载均衡的流程我们就清楚了。
SpringCloudRibbon 的底层采用了一个拦截器,拦截了 RestTemplate 发出的请求,对地址做了修改。
基本流程如下:
http://user-service/user/1
localhost:8081
、localhost:8082
localhost:8081
localhost:8081
替代 userservice,得到 http://localhost:8081/user/1
,发起真实请求负载均衡的规则都定义在 IRule 接口中,而 IRule 有很多不同的实现类。
每一个子接口都是一种规则。
常见规则
内置负载均衡规则类 | 规则描述 |
---|---|
RoundRobinRule |
简单轮询服务列表来选择服务器。它是 Ribbon 默认的负载均衡规则。 |
AvailabilityFilteringRule |
对以下两种服务器进行忽略 (1)在默认情况下,这台服务器如果 3 次连接失败,这台服务器就会被设置为 “短路” 状态。 短路状态将持续 30 秒,如果再次连接失败,短路的持续时间就会几何级地增加。 (2)并发数过高的服务器。 如果一个服务器的并发连接数过高,配置了 AvailabilityFilteringRule 规则的客户端也会将其忽略。 并发连接数的上限,可以由客户端的属性进行配置。 该属性为 。 |
WeightedResponseTimeRule |
为每一个服务器赋予一个权重值。 服务器响应时间越长,这个服务器的权重就越小。 这个规则会随机选择服务器,这个权重值会影响服务器的选择。 |
ZoneAvoidanceRule |
以区域可用的服务器为基础进行服务器的选择。 使用 Zone 对服务器进行分类,这个 Zone 可以理解为一个机房、一个机架等。 而后再对 Zone 内的多个服务做轮询。 |
BestAvailableRule |
忽略那些短路的服务器,并选择并发数较低的服务器。 |
RandomRule |
随机选择一个可用的服务器。 |
RetryRule |
重试机制的选择逻辑。 |
默认的实现就是 ZoneAvoidanceRule,是一种轮询方案。
通过定义 IRule 实现可以修改负载均衡规则,有两种方式:代码的方式和配置文件的方式。
这种方案是作用于全局的。
也就是说,在 order-service 中调用任何服务时,都采用相同的负载均衡策略。
order-service 模块中的 src/main/java/cn/itcast/order/OrderApplication.java
/**
* 自定义负载均衡规则:此处设置为随机
*
* @return
*/
@Bean
public IRule randomRule() {
return new RandomRule();
}
order-service 模块中的 src/main/resources/application.yml
user-service: # 给某个微服务配置负载均衡规则
ribbon:
NFLoadBalancerRuleClassName: com.netflix.loadbalancer.RandomRule # 负载均衡规则:此处设置为随机
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
1.Ribbon负载均衡规则
2.负载均衡自定义方式
3.饥饿加载
国内公司一般都推崇阿里巴巴的技术。
比如注册中心,SpringCloudAlibaba 也推出了一个名为 Nacos 的注册中心。
Nacos 是阿里巴巴的产品,现在是 SpringCloud 中的一个组件。
相比 Eureka 功能更加丰富,在国内受欢迎程度较高。
Windows 安装
开发阶段采用单机安装即可。
在 Nacos 的 GitHub 页面,提供有下载链接,可以下载编译好的 Nacos 服务端或者源代码
GitHub 主页:https://github.com/alibaba/nacos
GitHub 的 Release 下载页:https://github.com/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
Nacos 是 SpringCloudAlibaba 的组件。
而 SpringCloudAlibaba 也遵循 SpringCloud 中定义的服务注册、服务发现规范。
因此使用 Nacos 和使用 Eureka 对于微服务来说,并没有太大区别。
Nacos 和 Eureka 的主要差异在于:依赖不同、服务地址不同
在 cloud-demo 父工程的 pom 文件中的
中引入 SpringCloudAlibaba 的依赖
<dependency>
<groupId>com.alibaba.cloudgroupId>
<artifactId>spring-cloud-alibaba-dependenciesartifactId>
<version>2.2.6.RELEASEversion>
<type>pomtype>
<scope>importscope>
dependency>
然后在 user-service 和 order-service 中的 pom 文件中引入 nacos-discovery 依赖
同时也需要注释掉 eureka 的依赖。
<dependency>
<groupId>com.alibaba.cloudgroupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discoveryartifactId>
dependency>
在 user-service 和 order-service 的 application.yml 中添加 nacos 地址
spring:
cloud:
nacos:
server-addr: localhost:8848
当然,此处也需注释掉 eureka 的地址
重启微服务后,登录 nacos 管理页面,我们在 “服务管理” 的 “服务列表” 里可以发现微服务信息
在 “详情” 则有着更为详细的数据信息。
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。
修改 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
-Dserver.port=8083 -Dspring.cloud.nacos.discovery.cluster-name=SH
重启微服务,进入 Nacos 管理界面
默认的 ZoneAvoidanceRule
并不能实现根据同集群优先来实现负载均衡。
因此 Nacos 中提供了一个 NacosRule
的实现,可以优先从同集群中挑选实例。
修改 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 优先选择本地集群,再在本地集群内的多个服务采用随机方式进行负载均衡。
Nacos 服务分级存储模型
如何设置实例的集群属性
NacosRule
负载均衡策略
实际部署中会出现这样的场景
服务器设备性能有差异,部分实例所在机器性能较好,另一些较差,我们希望性能好的机器承担更多的用户请求。
但默认情况下 NacosRule 是同集群内随机挑选,不会考虑机器的性能问题。
因此,Nacos 提供了权重配置来控制访问频率,权重越大则访问频率越高。
- 注意:如果权重修改为 0,则该实例永远不会被访问。
实际的权重控制
Nacos 提供了 namespace 来实现环境隔离功能。
默认情况下,所有 service、data、group 都在同一个 namespace,名为 public
我们可以点击页面新增按钮,添加一个 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,控制台会报错
Nacos 环境隔离
Nacos 的服务实例分为两种类型
配置一个服务实例为永久实例,比如这里可以选择 order-service 服务实例。
spring:
cloud:
nacos:
discovery:
ephemeral: false # 设置为非临时实例
Nacos 和 Eureka 整体结构类似,服务注册、服务拉取、心跳等待,但是也存在一些差异。
SpringCloud 学习 Day02(实用篇-2)
- Nacos 配置管理
- Feign 远程调用
- GetWay 服务网关
Nacos 除了可以做注册中心,同样可以做配置管理来使用。
当微服务部署的实例越来越多,达到数十、数百时,逐个修改微服务配置就会让人抓狂,而且很容易出错。
我们需要一种统一配置管理方案,可以集中管理所有实例的配置。
在 Nacos 中管理配置
在弹出表单中填写配置信息
- 注意
- 项目的核心配置,需要热更新的配置才有放到 nacos 管理的必要。
- 基本不会变更的一些配置还是保存在微服务本地比较好。
微服务要拉取 nacos 中管理的配置,并且与本地的 application.yml
配置合并,才能完成项目启动。
但如果尚未读取 application.yml
,又如何得知 nacos 地址呢?
因此 spring 引入了一种新的配置文件:bootstrap.yaml
文件,会在 application.yml
之前被读取。
在 user-service
服务中,引入 nacos-config 的客户端依赖
<dependency>
<groupId>com.alibaba.cloudgroupId>
<artifactId>spring-cloud-starter-alibaba-nacos-configartifactId>
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
在 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 工具,当然你也可以直接在页面上访问。
bootstrap.yml
,配置 nacos 地址、当前环境、服务名称、文件后缀名。
我们最终的目的,是修改 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.bootgroupId>
<artifactId>spring-boot-configuration-processorartifactId>
<optional>trueoptional>
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 读取多个配置文件,例如:
[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
中读取共享配置我们在 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_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、服务本地同时出现相同属性时,优先级有高低之分
Nacos 生产环境下一定要部署为集群状态
官方给出的 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 |
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');
nacos 在 GitHub 上有下载地址:https://github.com/alibaba/nacos/tags,可选择任意版本下载。
本例中使用的是 1.4.1 版本
将包解压到非中文目录,其中 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
将 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
,表示集群启动成功。
找到课前资料提供的 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 表了。
说明持久化已经成功了。
这是之前利用 RestTemplate 发起远程调用的代码
String url = "http://user-service/user/" + order.getUserId();
User user = restTemplate.getForObject(url, User.class);
存在下面的问题:① 代码可读性差,编程体验不统一;② 参数复杂 URL 难以维护。
Feign 是一个声明式的 http 客户端,官方地址:https://github.com/OpenFeign/feign
其作用就是帮助我们优雅的实现 http 请求的发送,解决上面提到的问题。
我们在 order-service
服务的 pom 文件中引入 feign 的依赖
<dependency>
<groupId>org.springframework.cloudgroupId>
<artifactId>spring-cloud-starter-openfeignartifactId>
dependency>
在 order-service
的启动类添加注解开启 Feign 的功能:@EnableFeignClients
在 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
/user/{id}
Long id
这样,Feign 就可以帮助我们发送 http 请求,无需自己使用 RestTemplate 来发送了。
修改 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
文件的内容
cloud.nacos.server-addr
的值改为 localhost:80
。观察 Idea 控制台可以发现,此时我们不仅实现了远程调用,还实现了负载均衡。
事实上,我们可以通过观察 order-service
依赖的树形图,
来了解到 spring-cloud-starter-openfeign
中已经集成了 spring-cloud-netflix-ribbon
,自动实现了负载均衡。
使用 Feign 的步骤
@EnableFeignClients
注解Feign 运行自定义配置来覆盖默认配置。可以修改的配置如下。
类型 | 作用 | 说明 |
---|---|---|
feign.Logger.Level |
修改日志级别 | 包含四种不同的级别:NONE、BASIC、HEADERS、FULL |
feign.codec.Decoder |
响应结果的解析器 | http 远程调用的结果做解析,例如解析 json 字符串为 java 对象 |
feign.codec.Encoder |
请求参数编码 | 将请求参数编码,便于通过 http 请求发送 |
feign. Contract |
支持的注解格式 | 默认是 SpringMVC 的注解 |
feign. Retryer |
失败重试机制 | 请求失败的重试机制,默认是没有,不过会使用 Ribbon 的重试 |
一般我们需要配置的就是日志级别
下面以日志为例来演示如何自定义配置。
基于配置文件修改 feign 的日志级别可以针对单个服务:
feign:
client:
config:
userservice: # 针对某个微服务的配置
loggerLevel: FULL # 日志级别
也可以针对所有服务:
feign:
client:
config:
default: # 这里用 default 就是全局配置,如果是写服务名称,则是针对某个微服务的配置
loggerLevel: FULL # 日志级别
而日志的级别分为四种:
也可以基于 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)
Feign 的日志配置
feign.client.config.xxx.loggerlevel
@EnableFeignClients
注解声明则代表全局@FeignClient
注解中声明则代表某服务Feign 底层发起 http 请求,依赖于其它的框架。
其底层客户端实现包括:
因此提高 Feign 的性能主要手段就是使用连接池代替默认的 URLConnection。
日志界别的话,最好用 basic 或 none。
这里我们用 Apache的HttpClient 来演示。
在 order-service
的 pom.xml
文件中引入 Apache 的 HttpClient 依赖
<dependency>
<groupId>io.github.openfeigngroupId>
<artifactId>feign-httpclientartifactId>
dependency>
在 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
Feign 的优化
最佳实践,就是使用过程中总结的经验,最好的一种使用方式。
经由观察可以发现,Feign 的客户端与服务提供者的 controller 代码非常相似
feign 客户端
UserController
有没有一种办法简化这种重复的代码编写呢?
给消费者的 FeignClient 和提供者的 controller 定义统一的父接口作为标准
一样的代码可以通过继承来共享
优点
缺点
将 Feign 的 Client 抽取为独立模块,并且把接口有关的 POJO、默认的 Feign 配置都放到这个模块中,提供给所有消费者使用。
例如,将 UserClient、User、Feign 的默认配置都抽取到一个 feign-api 包中,所有微服务引用该依赖包,即可直接使用。
实现最佳实践方式二(抽取方式)的步骤如下
feign-api
,然后引入 feign 的 starter 依赖order-service
中编写的 UserClient、User、DefaultFeignConfiguration 都复制到 feign-api 项目中order-service
中引入 feign-api 的依赖order-service
中的所有与上述三个组件有关的 import 部分,改成导入 feign-api 中的包Feign 的最佳实践
首先创建一个 module,命名为 feign-api
在 feign-api
中然后引入 feign 的 starter 依赖
<dependency>
<groupId>org.springframework.cloudgroupId>
<artifactId>spring-cloud-starter-openfeignartifactId>
dependency>
然后,order-service
中编写的 UserClient、User、DefaultFeignConfiguration 都复制到 feign-api
项目中。
下图为最终的项目结构
order-service
中使用 feign-api
首先,删除 order-service
中的 UserClient、User、DefaultFeignConfiguration 等类或接口。
在 order-service
的 pom.xml
文件中中引入 feign-api
的依赖
<dependency>
<groupId>cn.itcast.demogroupId>
<artifactId>feign-apiartifactId>
<version>1.0version>
dependency>
修改 order-service
中的所有与上述三个组件有关的导包部分,改成导入 feign-api
中的包
即 Order、OrderService、OrderApplication
重启 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
当定义的 FeignClient 不在 SpringBootApplication 的扫描包范围时,这些 FeignClient 无法使用。
我们需要导入包。
不同包的 FeignClient 的导入有两种方式
@EnableFeignClients
注解中添加 basePackages,指定 FeignClient 所在的包@EnableFeignClients
注解中添加 clients,指定具体 FeignClient 的字节码方式一
@EnableFeignClients(basePackages = "cn.itcast.feign.clients")
方式二(推荐)
@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 {
... ...
}
Spring Cloud Gateway 是 Spring Cloud 的一个全新项目。
该项目是基于 Spring 5.0,Spring Boot 2.0 和 Project Reactor 等响应式编程和事件流技术开发的网关,
它旨在为微服务架构提供一种简单有效的统一的 API 路由管理方式。
Gateway 网关是我们服务的守门神,所有微服务的统一入口。
网关的核心功能特性:请求路由、权限控制、限流
权限控制:网关作为微服务入口,需要校验用户是是否有请求资格,如果没有则进行拦截。
路由和负载均衡:
限流:当请求流量过高时,在网关中按照下流的微服务能够接受的速度来放行请求,避免服务压力过大。
在SpringCloud 中网关的实现包括两种:gateway、zuul
Zuul 是基于 Servlet 的实现,属于阻塞式编程。
而 SpringCloudGateway 则是基于 Spring5 中提供的 WebFlux,属于响应式编程的实现,具备更好的性能。
架构图
下面,我们就演示下网关的基本路由功能。
基本步骤
创建服务
引入依赖
gateway
服务下的 pom.xml
文件
<dependency>
<groupId>org.springframework.cloudgroupId>
<artifactId>spring-cloud-starter-gatewayartifactId>
dependency>
<dependency>
<groupId>com.alibaba.cloudgroupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discoveryartifactId>
dependency>
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);
}
}
创建 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)是负载均衡,根据服务名拉取服务列表,实现负载均衡。
重启网关,访问 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
,
从而确保所有服务都在一个集群环境里。
这两个坑我是一个不剩的全踩上去了。
搭建网关服务
网关搭建步骤
application.yml
,包括服务基本信息、nacos 地址、路由路由配置
接下来,就重点来学习路由断言和路由过滤器的详细知识。
路由断言工厂 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.org,**.anotherhost.org |
Method |
请求方式必须是指定方式 | - Method=GET,POST |
Path |
请求路径必须符合指定规则 | - Path=/red/{segment},/blue/** |
Query |
请求参数必须包含指定参数 | - Query=name, Jack 或者 - Query=name |
RemoteAddr |
请求者的 ip 必须是指定范围 | - RemoteAddr=192.168.1.1/24 |
Weight |
权重处理 |
我们只需要掌握 Path 这种路由工程就可以了。
详情还请见官网:https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/#gateway-request-predicates-factories
GatewayFilter 是网关中提供的一种过滤器,可以对进入网关的请求和微服务返回的响应做处理
Spring 提供了 31 种不同的路由过滤器工厂。
名称 | 说明 |
---|---|
AddRequestHeader |
给当前请求添加一个请求头 |
RemoveRequestHeader |
移除请求中的一个请求头 |
AddResponseHeader |
给响应结果中添加一个响应头 |
RemoveResponseHeader |
从响应结果中移除有一个响应头 |
RequestRateLimiter |
限制请求的流量 |
… … | … … |
具体情况还请浏览官网:https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/#gatewayfilter-factories
下面我们以 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
的结果
如果要对所有的路由都生效,则可以将过滤器工厂写到 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!
过滤器的作用是什么?
defaultFilters 的作用是什么?
上一节学习的过滤器,网关提供了 31 种,但每一种过滤器的作用都是固定的。
如果我们希望拦截请求,做自己的业务逻辑则没办法实现。
全局过滤器的作用也是处理一切进入网关的请求和微服务响应,与 GatewayFilter 的作用一样。
区别在于 GatewayFilter 通过配置定义,处理逻辑是固定的;
而 GlobalFilter 的逻辑需要自己写代码实现。
定义方式是实现 GlobalFilter 接口。
public interface GlobalFilter {
/**
* 处理当前请求,有必要的话通过 {@link GatewayFilterChain} 将请求交给下一个过滤器处理
*
* @param exchange 请求上下文,里面可以获取 Request、Response 等信息
* @param chain 用来把请求委托给下一个过滤器
* @return {@code Mono} 返回标示当前过滤器业务结束
*/
Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain);
}
在 filter 中编写自定义逻辑,可以实现下列功能:登录状态判断、权限校验、请求限流等
案例需求
实现
在 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
,成功获取到数据信息。
请求进入网关会碰到三类过滤器:当前路由的过滤器、DefaultFilter、GlobalFilter
请求路由后,会将当前路由过滤器和 DefaultFilter、GlobalFilter,合并到一个过滤器链(集合)中,排序后依次执行每个过滤器:
排序的规则是什么呢?
@Order
注解来指定 order 值,由我们自己指定defaultFilter > 路由过滤器 > GlobalFilter
的顺序执行。可以参考一下几个类源码来查看
org.springframework.cloud.gateway.route.RouteDefinitionRouteLocator#getFilters()
org.springframework.cloud.gateway.handler.FilteringWebHandler#handle()
全局过滤器的作用是什么?
实现全局过滤器的步骤
@Order
注解或实现 Ordered 接口路由过滤器、defaultFilter、全局过滤器的执行顺序?
跨域:域名不一致就是跨域
跨域主要包括:
www.taobao.com
和 www.taobao.org
;www.jd.com
和 miaosha.jd.com
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.com/blog/2016/04/cors.html
找到课前资料的页面文件
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>Documenttitle>
head>
<body>
<pre>
spring:
cloud:
gateway:
globalcors: # 全局的跨域处理
add-to-simple-url-handler-mapping: true # 解决 options 请求被拦截问题
corsConfigurations:
'[/**]':
allowedOrigins: # 允许哪些网站的跨域请求
- "http://localhost:8090"
- "http://www.leyou.com"
allowedMethods: # 允许的跨域 ajax 的请求方式
- "GET"
- "POST"
- "DELETE"
- "PUT"
- "OPTIONS"
allowedHeaders: "*" # 允许在请求中携带的头信息
allowCredentials: true # 是否允许携带 cookie
maxAge: 360000 # 这次跨域检测的有效期
pre>
body>
<script src="https://unpkg.com/axios/dist/axios.min.js">script>
<script>
axios.get("http://localhost:10010/user/1?authorization=admin")
.then(resp => console.log(resp.data))
.catch(err => console.log(err))
script>
html>
放入 tomcat 或者 nginx 这样的 web 服务器中,启动并访问。
我这里使用的是 VSCode 工具
可以在浏览器控制台看到下面的错误
从 localhost:8090
访问 localhost:10010
,端口不同,显然是跨域的请求。
在 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 # 这次跨域检测的有效期
配置完成后重启网关服务
显然,成功获取到了数据。
SpringCloud 学习 Day03(实用篇-3) Docker
- 初识 Docker
- Docker 的基本操作
- Dockerfile 自定义镜像
- Docker-Compose
- Docker 镜像服务
微服务虽然具备各种各样的优势,但服务的拆分通用给部署带来了很大的麻烦。
大型项目组件较多,运行环境也较为复杂,部署时会碰到一些问题:
例如一个项目中,部署时需要依赖于 node.js、Redis、RabbitMQ、MySQL 等,
这些服务部署时所需要的函数库、依赖项各不相同,甚至会有冲突。给部署带来了极大的困难。
Docker 为了解决依赖的兼容问题的,采用了两个手段:
这样打包好的应用包中,既包含应用本身,也保护应用所需要的 Libs、Deps,
无需再操作系统上安装这些,自然就不存在不同应用之间的兼容问题了。
虽然解决了不同应用的兼容问题,但是开发、测试等环境会存在差异,操作系统版本也会有差异,怎么解决这些问题呢?
要解决不同操作系统环境差异问题,必须先了解操作系统结构。
以一个 Ubuntu 操作系统为例,结构如下:
结构包括:
应用于计算机交互的流程如下:
Ubuntu 和 CentOS 都是基于 Linux 内核,无非是系统应用不同,提供的函数库有差异:
此时,如果将一个 Ubuntu 版本的 MySQL 应用安装到 CentOS 系统,
MySQL 在调用 Ubuntu 函数库时,会发现找不到或者不匹配,就会报错了:
Docker 如何解决不同系统环境的问题?
如图:
Docker 如何解决大型项目依赖关系复杂,不同组件依赖的兼容性问题?
Docker 如何解决开发、测试、生产环境有差异的问题?
Docker 是一个快速交付应用、运行应用的技术
Docker 可以让一个应用在任何操作系统中非常方便的运行。
而以前我们接触的虚拟机,也能在一个操作系统中,运行另外一个操作系统,保护系统中的任何应用。
两者有什么差异呢?
虚拟机(virtual machine)是在操作系统中模拟硬件设备,然后运行另一个操作系统,
比如在 Windows 系统里面运行 Ubuntu 系统,这样就可以运行任意的 Ubuntu 应用了。
Docker 仅仅是封装函数库,并没有模拟完整的操作系统。
对比来看的话
特性 | Docker |
虚拟机 |
---|---|---|
性能 | 接近原生 | 性能较差 |
硬盘占用 | 一般为 MB | 一般为 GB |
启动 | 秒级 | 分钟级 |
小结
Docker 和虚拟机的差异:
镜像(Image):Docker 将应用程序及其所需的依赖、函数库、环境、配置等文件打包在一起,称为镜像。
容器(Container):镜像中的应用程序运行后形成的进程就是容器,只是 Docker 会给容器进程做隔离,对外不可见。
一切应用最终都是代码组成,都是硬盘中的一个个的字节形成的文件。只有运行时,才会加载到内存,形成进程。
而镜像,就是把一个应用在硬盘上的文件、及其运行环境、部分系统函数库文件一起打包形成的文件包。这个文件包是只读的。
容器呢,就是将这些文件中编写的程序、函数加载到内存中允许,形成进程,只不过要隔离起来。
因此一个镜像可以启动多次,形成多个容器进程。
例如你下载了一个 QQ,如果我们将 QQ 在磁盘上的运行文件及其运行的操作系统依赖打包,形成 QQ 镜像。
然后你可以启动多次,双开、甚至三开 QQ,跟多个妹子聊天。
开源应用程序非常多,打包这些应用往往是重复的劳动。
为了避免这些重复劳动,人们就会将自己打包的应用镜像,
例如 Redis、MySQL 镜像放到网络上,共享使用,就像 GitHub 的代码共享一样。
我们一方面可以将自己的镜像共享到 DockerHub,另一方面也可以从 DockerHub 拉取镜像:
我们要使用 Docker 来操作镜像、容器,就必须要安装 Docker。
Docker 是一个 CS 架构的程序,由两部分组成:
镜像
容器
Docker 结构
DockerHub
企业部署一般都是采用 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 粗略学习】
如果之前安装过旧版本的 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
安装必需的软件包, 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.com/docker-ce/linux/centos/docker-ce.repo
sed -i 's/download.docker.com/mirrors.aliyun.com\/docker-ce/g' /etc/yum.repos.d/docker-ce.repo
将软件包信息提前在本地索引缓存(旨在可以提高搜索安装软件的速度。执行这个命令可以提升 yum 安装的速度)
yum makecache fast
yum install -y docker-ce
docker-ce 为社区免费版本。稍等片刻,docker 即可安装成功。
这里我使用的远程控制工具是 Mobaxterm,视频中使用的是 FinalShell
使用以下命令查看安装的 Docker 版本
docker -v
关闭防火墙
systemctl stop firewalld
禁止开机自启动防火墙
systemctl disable firewalld
启动 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.com/cn-hangzhou/instances/mirrors
或者使用账号登录阿里云,点击 “控制台“,然后点击 “三” 在搜索栏中输入 “容器镜像服务”。
最后点击 “镜像工具” 的 “镜像加速器” 就可以看到 “加速器地址”。
在其下方的操作文档里,有非常详细的操作文档,我们将其文档上的内容复制直接到 Linux 系统的终端即可。
我们也可以使用以下命令来查看阿里云地址是否写入文件中
cat /etc/docker/daemon.json
卸载 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
首先来看下镜像的名称组成
[repository]:[tag]
。这里的 mysql 就是 repository,5.7 就是 tag,合一起就是镜像名称,代表 5.7 版本的 MySQL 镜像。
常见的镜像操作命令如图
Docker 的命令很多,我们不需要全记住。
使用 docker --help
查看帮助文档即可。使用 docker xx --help
可以查看 xx 相关命令的帮助文档。
比如我想查看镜像的相关命令,输入 docker images --help
即可。
docker images --help
需求:从 DockerHub 中拉取一个 nginx 镜像并查看
相关链接:
https://hub.docker.com/search?q=nginx&type=image
docker pull nginx
拉取自己需要的镜像docker pull nginx
docker images
查看拉取到的镜像docker images
需求:利用 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
部分 Docker 镜像操作命令
docker images
(查看镜像)、docker rmi
(删除镜像)
docker pull
(拉取镜像)、docker push
(将本地的镜像上传到镜像仓库)
docker save
(保存镜像为一个压缩包)、docker load
(加载压缩包成为镜像)
docker search [镜像名称]
(从网络中查找需要的镜像)
练习案例-需求:去 DockerHub 搜索并拉取一个 Redis 镜像
练习案例-目标:
docker pull
命令拉取镜像docker save
命令将 redis:latest
打包为一个 redis.tar
包docker rmi
删除本地的 redis:latest
docker load
重新加载 redis.tar
文件练习案例-实际操作:
相关链接:
https://hub.docker.com/search?q=redis&type=image
相关链接:
https://hub.docker.com/_/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
容器操作的命令如图
容器保护三个状态
其中:
docker run
:创建并运行一个容器,处于运行状态docker pause
:让一个运行的容器暂停docker unpause
:让一个容器从暂停状态恢复运行docker stop
:停止一个运行的容器docker start
:让一个停止的容器再次运行docker rm
:删除一个容器去 DockerHub 上查看 Nignx 的容器的运行命令:https://hub.docker.com/search?q=nginx
在 https://hub.docker.com/_/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
需求:进入 Nginx 容器,修改 HTML 文件内容,添加 “stand power”。
提示:进入容器要用到 docker exec
命令。
操作步骤
docker exec -it containerName bash
docker exec -it mn bash
命令解读
docker exec
:进入容器内部,执行一个命令-it
: 给当前进入的容器创建一个标准输入、输出终端,允许我们与容器交互mn
:要进入的容器的名称bash
:进入容器后执行的命令,bash 是一个 linux 终端交互命令/usr/share/nginx/html
容器内部会模拟一个独立的 Linux 文件系统,看起来如同一个 linux 服务器一样
nginx 的环境、配置、运行文件全部都在这个文件系统中,包括我们要修改的 html 文件。
可以看作是 Linux 的阉割版,容器内部只有 Nginx 运行需要的东西。
前往 https://hub.docker.com/_/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###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
案例需求:
redis-cli
客户端命令,存入 num=666
操作步骤
docker run
命令运行一个 Redis 容器redis-cli
客户端命令,存入 num=666
具体操作
https://hub.docker.com/search?q=redis、https://hub.docker.com/_/redis?tab=description
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.com/uglide/RedisDesktopManager
- 安装包仓库地址:https://github.com/lework/RedisDesktopManager-Windows/releases
连接成功!
redis-cli
客户端命令,存入 num=666
进入 Redis 容器内部
docker -it mr bash
打开 redis-cli 客户端
redis-cli
设置指定 key 的值(string)
set num 666
获取指定 key 的值(string)
get num
当然,我们也可以直接进入 redis-cli 客户端
docker run
命令的常见参数有哪些?
--name
:指定容器名称-p
:指定端口映射-d
:让容器后台运行查看容器日志的命令
docker logs
-f
参数可以持续查看日志查看容器状态
docker ps
docker ps -a
查看所有容器,包括已经停止的删除容器
docker rm
-f
参数进入容器
docker exec -it [容器名] [要执行的命令]
数据卷(容器数据管理)
在之前的 nginx 案例中,修改 nginx 的 html 页面时,需要进入 nginx 内部。并且因为没有编辑器,修改文件也很麻烦。
这就是因为容器与数据(容器内文件)耦合带来的后果。
要解决这个问题,必须将数据与容器解耦,这就要用到数据卷了。
数据卷(volume)是一个虚拟目录,指向宿主机文件系统中的某个目录。
一旦完成数据卷挂载,对容器的一切操作都会作用在数据卷对应的宿主机目录了。
这样,我们操作宿主机的 /var/lib/docker/volumes/html
目录,就等于操作容器内的 /usr/share/nginx/html
目录了
数据卷操作的基本语法如下
docker volume [COMMAND]
docker volume
命令是数据卷操作,根据命令后跟随的 command
来确定下一步的操作
create
创建一个 volumeinspect
显示一个或多个 volume 的信息ls
列出所有的 volumeprune
删除未使用的 volumerm
删除一个或多个指定的 volumedokcer 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
我们在创建容器时,可以通过 -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
:镜像名称需求:创建一个 nginx 容器,修改容器内的 html
目录内的 index.html
内容
分析:
html
目录所在位置 /usr/share/nginx/html
html
这个数据卷上,方便操作其中的内容提示:运行容器时使用 -v
参数挂载数据卷
步骤:
html
目录(详情见官网:https://hub.docker.com/_/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 不存在,其也会自动被创建出来。
容器不仅仅可以挂载数据卷,也可以直接挂载到宿主机目录上。
关联关系如下:
如图:
语法:
目录挂载与数据卷挂载的语法是类似的:
-v [宿主机目录]:[容器内目录]
-v [宿主机文件]:[容器内文件]
案例需求:创建并运行一个 MySQL 容器,将宿主机目录直接挂载到容器
实现思路如下:
mysql.tar
文件上传到虚拟机,通过 load 命令加载为镜像docker pull mysql:tag
命令的话,耗时比较久,故使用资料中提供的 mysql.tar
)/tmp/mysql/data
/tmp/mysql/conf
,将课前资料提供的 hmy.cnf
文件上传到 /tmp/mysql/conf
/tmp/mysql/data
到 mysql 容器内数据存储目录/tmp/mysql/conf/hmy.cnf
到 mysql 容器的配置文件实际操作
mysql.tar
文件上传到虚拟机,通过 load 命令加载为镜像docker load -i mysql.tar
查看镜像
docker images -a
/tmp/mysql/data
mkdir -p mysql/data
/tmp/mysql/conf
,将课前资料提供的 hmy.cnf
文件上传到 /tmp/mysql/conf
mkdir -p mysql/conf
[mysqld]
skip-name-resolve
character_set_server=utf8
datadir=/var/lib/mysql
server-id=1000
/tmp/mysql/data
到 mysql 容器内数据存储目录/tmp/mysql/conf/hmy.cnf
到 mysql 容器的配置文件前往 DockerHub 页面查看使用文档:https://hub.docker.com/_/mysql
mysql
docker run --name some-mysql -e MYSQL_ROOT_PASSWORD=my-secret-pw -d mysql:tag
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
docker run \
--name c_mysql \
-e MYSQL_ROOT_PASSWORD=123 \
-p 3307:3306 \
-v /tmp/mysql/conf/hmy.cnf:/etc/mysql/conf.d/hmy.cnf \
-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/hmy.cnf:/etc/mysql/conf.d/hmy.cnf
/tmp/mysql/conf/hmy.cnf
挂载到容器的 /etc/mysql/conf.d/hmy.cnf
。-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
目录中也成功记录了文件
数据卷的作用
数据卷操作
docker volume create
:创建数据卷docker volume ls
:查看所有数据卷docker volume inspect
:查看数据卷详细信息,包括关联的宿主机目录位置docker volume rm
:删除指定数据卷docker volume prune
:删除所有未使用的数据卷数据卷挂载方式
-v volumeName: /targetContainerPath
docker run
的命令中通过 -v
参数挂载文件或目录到容器中
-v volume名称
:容器内目录-v 宿主机文件
:容器内文-v 宿主机目录
:容器内目录数据卷挂载与目录直接挂载的区别
常见的镜像在 DockerHub 就能找到,但是我们自己写的项目就必须自己构建镜像了。
而要自定义镜像,就必须先了解镜像的结构才行。
镜像是将应用程序及其需要的系统函数库、环境、配置、依赖打包而成。
我们以 MySQL 为例,来看看镜像的组成结构
镜像就是在系统函数库、运行环境基础上,添加应用程序文件、配置文件、依赖文件等组合,然后编写好启动脚本打包在一起形成的文件。
我们要构建镜像,其实就是实现上述打包的过程。
构建自定义的镜像时,并不需要一个个文件去拷贝,打包。
我们只需要告诉 Docker,我们的镜像的组成,
需要哪些 BaseImage、需要拷贝什么文件、需要安装什么依赖、启动脚本是什么,
将来 Docker 会帮助我们构建镜像。
而描述上述信息的文件就是 Dockerfile 文件。
Dockerfile 就是一个文本文件,其中包含一个个的指令(Instruction),用指令来说明要执行什么操作来构建镜像。
每一个指令都会形成一层 Layer。
指令 | 说明 | 示例 |
---|---|---|
FROM |
指定基础镜像 | FROM centos:6 |
ENV |
设置环境变量,可在后面指令使用 | ENV key value |
COPY |
拷贝本地文件到镜像的指定目录 | COPY ./mysql-5.7.rpm /tmp |
RUN |
执行 Linux 的 shell 命令,一般都是安装过程的命令 | RUN yum install gcc |
EXPOSE |
指定日期运行时监听的端口,是给镜像的使用者看的 | EXPOSE 8080 |
ENTRYPOINT |
镜像中应用的启动目录,容器运行时间调用 | ENTRYPOINT java -jar xx.jar |
更新详细语法说明,请参考官网文档: https://docs.docker.com/engine/reference/builder
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
docker-demo
将准备好的 docker-demo
上传到虚拟机任意目录,然后进入 docker-demo
目录下
docker build -t javaweb:1.0 .
docker build -t javaweb:1.0 .
其中 .
表示当前目录
docker run --name web -p 8090:8090 -d javaweb:1.0
http://192.168.150.101:8090/hello/count
,其中的 ip 改成你的虚拟机 ip有关
docker build
命令的更多操作还请查看官网文档: https://docs.docker.com/engine/reference/builder
虽然我们可以基于 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
java:8-alpine
Docker Compose 可以基于 Compose 文件帮我们快速的部署分布式应用,而无需手动一个个创建和运行容器!
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/hmy.cnf:/etc/mysql/conf.d/hmy.cnf"
web:
build: .
ports:
- "8090:8090"
上面的 Compose 文件就描述一个项目,其中包含两个容器:
mysql
:一个基于mysql:5.7.25
镜像构建的容器,并且挂载了两个目录web
:一个基于docker build
临时构建的镜像容器,映射端口时 8090因为这里旨在微服务集群部署,因为是在集群内使用,故无需对外暴露端口。
DockerCompose 的详细语法参考官网:https://docs.docker.com/compose/compose-file/
其实 DockerCompose 文件可以看做是将多个 docker run
命令写到一个文件,只是语法稍有差异。
Linux 下需要通过命令下载:
安装
curl -L https://github.com/docker/compose/releases/download/1.23.1/docker-compose-`uname -s`-`uname -m` > \
/usr/local/bin/docker-compose
如果下载速度较慢,或者下载失败,可以使用课前资料提供的 docker-compose
文件:
上传到 /usr/local/bin/
目录也可以。
chmod +x /usr/local/bin/docker-compose
补全命令
curl -L https://raw.githubusercontent.com/docker/compose/1.29.1/contrib/completion/bash/docker-compose > \
/etc/bash_completion.d/docker-compose
如果这里出现错误,需要修改自己的 hosts 文件
echo "199.232.68.133 raw.githubusercontent.com" >> /etc/hosts
问:DockerCompose 有什么作用?
答:帮助我们快速部署分布式应用,而无需一个个微服务去构建镜像和部署
吐槽:这个部署 看看就行。与其说是部署,不如说是配环境。也完全没有实际价值(因为用的例子是 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
来部署
查看课前资料提供的 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 账户的密码为 123volumes
:数据卷挂载,这里挂载了 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
因为微服务将来要部署为 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服务地址
接下来需要将我们的每个微服务都打包。
因为之前查看到 Dockerfile 中的 jar 包名称都是 app.jar
,因此我们的每个微服务都需要用这个名称。
可以通过修改 pom.xml
中的打包名称来实现,每个微服务都需要修改:
<build>
<finalName>appfinalName>
<plugins>
<plugin>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-maven-pluginartifactId>
plugin>
plugins>
build>
编译打包好的 app.jar
文件,需要放到 Dockerfile 的同级目录中。
注意:每个微服务的 app.jar
放到与服务名称对应的目录。
最后,我们需要将文件整个 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
镜像仓库(Docker Registry)有公共的和私有的两种形式。
搭建镜像仓库可以基于 Docker 官方提供的 DockerRegistry 来实现。
官网地址:https://hub.docker.com/_/registry
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
可以查看当前私有镜像服务中包含的镜像
使用 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)
我们的私服采用的是 http 协议,默认不被 Docker 信任,所以需要做一个配置:
打开要修改的文件
vi /etc/docker/daemon.json
添加内容
"insecure-registries":["http://192.168.150.101:8080"]
重加载
systemctl daemon-reload
重启 docker
systemctl restart docker
推送镜像到私有镜像服务必须先 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
docker tag
)镜像,以镜像仓库地址为前缀daemon.json
文件中,被 docker 信任docker push
命令docker pull
命令SpringCloud 学习 Day04(实用篇-4) 服务异步通讯:RabbitMQ
- 初识 MQ
- RabbitMQ 快速入门
- SpringAMQP
微服务间通讯有同步和异步两种方式:
两种方式各有优劣,打电话可以立即得到响应,但是你却不能跟多个人同时通话。
发送邮件可以同时与多个人收发邮件,但是往往响应会有延迟。
我们之前学习的 Feign 调用就属于同步方式,虽然调用可以实时得到结果,但存在下面的问题:
总结
异步调用常见实现就是事件驱动模式
异步调用则可以避免上述问题:
我们以购买商品为例,用户支付后需要调用订单服务完成订单状态修改,调用物流服务,从仓库分配响应的库存并准备发货。
在事件模式中,支付服务是事件发布者(publisher),在支付完成后只需要发布一个支付成功的事件(event),事件中带上订单 id。
订单服务和物流服务是事件订阅者(Consumer),订阅支付成功的事件,监听到事件后完成自己业务即可。
为了解除事件发布者与订阅者之间的耦合,两者并不是直接通信,而是有一个中间人(Broker)。
发布者发布事件到 Broker,不关心谁来订阅事件。订阅者从 Broker 订阅事件,不关心谁发来的消息。
Broker 是一个像数据总线一样的东西,所有的服务要接收数据和发送数据都发到这个总线上。
这个总线就像协议一样,让服务间的通讯变得标准和可控。
好处:
缺点:
好在现在开源软件或云平台上 Broker 的软件是非常成熟的,比较常见的一种就是我们今天要学习的 MQ 技术。
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
概述:RabbitMQ 是基于 Erlang 语言开发的开源消息通信中间件。
- 官网地址:https://www.rabbitmq.com/
- 如果想要深入学习 RabbitMQ,建议按照官方文档来学习:https://www.rabbitmq.com/documentation.html
RabbitMQ 中的一些角色
我们在 Centos7 虚拟机中使用 Docker 来安装。
方式一:在线拉取
docker pull rabbitmq:3-management
方式二:从本地加载
在课前资料已经提供了镜像包:
上传到虚拟机中后,使用命令加载镜像即可:
docker load -i mq.tar
执行下面的命令来运行 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.com/_/rabbitmq
访问 虚拟机地址:15672
,输入之前设置的用户名和密码,即可进入 RabbitMQ 的管理平台
RabbitMQ 的管理平台界面
以下的界面介绍参考博客:《RabbitMQ-管理界面介绍》
在我之前的博客里,有着更为详细的介绍,感兴趣的可以逛逛:《学习笔记:RabbitMQ 快速入门》
关于 RabbitMQ 控制台的一些基本操作
我们可以通过 Admin 界面的 Add users 来添加用户。
但是我们发现,所创建的用户 “lisi” 并没有服务虚拟机的权限。
这是因为 RabbitMQ 中可以创建许多用户,各个用户都可以创建队列,容易产生冲突。
为了避免这种情况的发生,通过不同的虚拟主机对用户的隔离,使得不同用户无法查看到对方的信息。
我们可以创建更多的虚拟主机,给用户 “lisi” 分配虚拟主机的访问权,来给该用户设置权限。
创建虚拟主机(MQ 中的逻辑划分)
通过给用户分配虚拟主机,来使用户获得该虚拟主机的服务权限
原则上来说,一个用户应该有自己独享的虚拟主机
在 RabbitMQ 的官方文档中,讲述了两种集群的配置方式:
首先,我们需要让 3 台 MQ 互相知道对方的存在。
分别在 3 台机器中,设置 /etc/hosts
文件,添加如下内容:
192.168.150.101 mq1
192.168.150.102 mq2
192.168.150.103 mq3
并在每台机器上测试,是否可以 ping 通对方
RabbitMQ 官方提供了 6 个不同的 Demo 示例,对应了不同的消息模型:
发布订阅,又根据交换机类型不同分为 3 种
另外还有 RPC 远程调用模式(远程调用,不太算 MQ。暂不作介绍)
访问官网模式介绍页面:https://www.rabbitmq.com/getstarted.html (访问时间:2022-07-07 )
课前资料提供了一个 Demo 工程:mq-demo
导入后可以看到结构如下
mq-demo
:父工程,管理项目依赖publisher
:消息的发送者consumer
:消息的消费者简单队列模式的模型图
官方的 HelloWorld 是基于最基础的消息队列模型来实现的,只包括三个角色:
思路:
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();
}
}
思路:
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();
基本消息队列的消息发送流程:
基本消息队列的消息接收流程:
SpringAMQP 是基于 RabbitMQ 封装的一套模板,并且还利用 SpringBoot 对其实现了自动装配,使用起来非常方便。
SpringAMQP 的官方地址:https://spring.io/projects/spring-amqp
SpringAMQP 提供了三个功能:
Basic Queue(简单队列模型)
案例流程:
spring-amqp
的依赖publisher
服务中利用 RabbitTemplate 发送消息到 simple.queue
这个队列consumer
服务中编写消费逻辑,绑定 simple.queue
这个队列在父工程 mq-demo
中引入依赖
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-amqpartifactId>
dependency>
首先配置 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 如何发送消息?
首先配置 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 如何接收消息?
@Component
注解@RabbitListener
注解,方法参数就时消息注意:消息一旦消费就会从队列删除,RabbitMQ 没有消息回溯功能
Work queues(工作队列),也被称为(Task queues),任务模型。
简单来说就是让多个消费者绑定到一个队列,共同消费队列中的消息。
当消息处理比较耗时的时候,可能生产消息的速度会远远大于消息的消费速度。
长此以往,消息就会堆积越来越多,无法及时处理。
此时就可以使用 work 模型,多个消费者共同处理消息处理,速度就能大大提高了。
案例的基本思路
publisher
服务中定义测试方法,每秒产生 50 条消息,发送到 simple.queue
consumer
服务中定义两个消息监听者,都监听 simple.queue
队列这次我们循环发送,模拟大量消息堆积现象。
在 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);
}
}
要模拟多个消费者绑定同一个队列,我们在 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 秒,模拟任务耗时。
启动 ConsumerApplication 后,在执行 publisher
服务中刚刚编写的发送测试方法 testWorkQueue。
可以看到消费者 1 很快完成了自己的 25 条消息。消费者 2 却在缓慢的处理自己的 25 条消息。
也就是说消息是平均分配给每个消费者,并没有考虑到消费者的处理能力。这样显然是有问题的。
在 spring 中有一个简单的配置,可以解决这个问题。
我们修改 consumer
服务的 application.yml
文件,添加配置:
spring:
rabbitmq:
listener:
simple:
prefetch: 1 # 每次只能获取一条消息,处理完成才能获取下一个消息
之后再重启 consumer
服务,使用测试类 SpringAmqpTest 发送单元请求
总体上的耗时时间变短了。
Work 模型的使用:
发布订阅的模型如图:
可以看到,在订阅模型中,多了一个 exchange 角色,而且过程略有变化:
注意:Exchange(交换机)只负责转发消息,不具备存储消息的能力。
因此如果没有任何队列与 Exchange 绑定,或者没有符合路由规则的队列,那么消息会丢失!
Fanout,英文翻译是扇出。
或许在 MQ 中叫广播更合适
Fanout Exchange 会将接收到的消息广播到每一个跟其绑定的 queue
在广播模式下,消息发送流程是这样的:
计划是这样的:
itcast.fanout
,类型是 Fanoutfanout.queue1
和 fanout.queue2
,绑定到交换机 itcast.fanout
案例思路
consumer
服务中,利用代码声明队列、交换机,并将两者绑定consumer
服务中,编写两个消费者方法,分别监听 fanout.queue1
和 fanout.queue2
publisher
中编写测试方法,向 itcast.fanout
发送消息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
服务后,观察控制台
在 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);
}
在 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
服务下的测试类
交换机的作用是什么?
publisher
发送的消息声明队列、交换机、绑定关系的 Bean 是什么?
在 Fanout 模式中,一条消息,会被所有订阅的队列都消费。
但是,在某些场景下,我们希望不同的消息被不同的队列消费。
这时就要用到 Direct 类型的 Exchange。
Direct Exchange 会将接收到的消息根据规则路由到指定的 Queue,因此称为路由模式(routes)
在 Direct 模型下:
RoutingKey
(路由 key)RoutingKey
。Routing Key
进行判断。
Routingkey
与消息的 Routingkey
完全一致,才会接收到消息案例需求
@RabbitListener
声明 Exchange、Queue、RoutingKeyconsumer
服务中,编写两个消费者方法,分别监听 direct.queue1
和 direct.queue2
publisher
中编写测试方法,向 itcast. direct
发送消息基于 @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,与预期一致
查看队列界面
在 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 的值来确认代码结果是否达到预期
描述下 Direct 交换机与 Fanout 交换机的差异
基于 @RabbitListener
注解声明队列和交换机有哪些常见注解?
@Queue
@Exchange
Topic
类型的 Exchange
与 Direct
相比,都是可以根据 RoutingKey
把消息路由到不同的队列。
只不过 Topic
类型 Exchange
可以让队列在绑定 Routing key
的时候使用通配符!
Routingkey
一般都是有一个或多个单词组成,多个单词之间以 “.
” 分割,例如: item.insert
通配符规则
#
:匹配一个或多个词*
:匹配不多不少恰好 1 个词举例
item.#
:能够匹配 item.spu.insert
或者 item.spu
item.*
:只能匹配 item.spu
图示:
解释:
china.#
,因此凡是以 china.
开头的 routing key
都会被匹配到。包括 china.news
和 china.weather
#.news
,因此凡是以 .news
结尾的 routing key
都会被匹配。包括 china.news
和 japan.news
案例的实现思路:
@RabbitListener
声明 Exchange、Queue、RoutingKeyconsumer
服务中,编写两个消费者方法,分别监听 topic.queue1
和 topic.queue2
publisher
中编写测试方法,向 itcast.topic
发送消息在 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);
}
在 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
服务
启动测试类中的相关方法测试
描述下 Direct 交换机与 Topic 交换机的差异?
**.**
分割#
:代表0个或多个词*
:代表1个词使用 idea 快捷键查看参数列表(视频中的为 Ctrl + K,但我的电脑上的是 Ctrl + P)
由上图可以看出:在 SpringAMQP 的发送方法中,接收消息的类型是 Object。
事实上,我们可以发送任意对象类型的消息,SpringAMQP 会帮我们序列化为字节后发送给 MQ。
接收消息的时候,还会把字节反序列化为 Java 对象。
只不过,默认情况下 Spring 采用的序列化方式是 JDK 序列化。
众所周知,JDK 序列化存在问题:数据体积过大、有安全漏洞、可读性差
我们在 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 序列化存在问题:可读性差、数据体积过大、有安全漏洞。
显然,JDK 序列化方式并不合适。
我们希望消息体的体积更小、可读性更高,因此可以使用 JSON 方式来做序列化和反序列化。
Spring 对消息对象的处理是由 org.springframework.amqp.support.converter.MessageConverter
来处理的。
而默认实现是 SimpleMessageConverter ,基于 JDK 的 ObjectOutputStream 完成序列化。
如果要修改只需要定义一个 MessageConverter 类型的 Bean 即可。推荐用 JSON 方式序列化。
在 publisher
和 consumer
两个服务中都引入依赖
pom.xml
<dependency>
<groupId>com.fasterxml.jackson.dataformatgroupId>
<artifactId>jackson-dataformat-xmlartifactId>
<version>2.9.10version>
dependency>
或者直接在父工程 mq-demo
中导入依赖
pom.xml
<dependency>
<groupId>com.fasterxml.jackson.coregroupId>
<artifactId>jackson-databindartifactId>
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 控制台的情况。
SpringAMQP 中消息的序列化和反序列化是怎么实现的?
SpringCloud 学习 Day05(实用篇-5) 分布式搜索引擎:elasticsearch 基础
- 初识 ElasticSearch
- 索引库操作
- 文档操作
- RestAPI
- RestClient 操作文档
elasticsearch 是一款非常强大的开源搜索引擎,具备非常多强大功能,可以帮助我们从海量数据中快速找到需要的内容
在 GitHub 搜索代码
在电商网站搜索商品
在浏览器搜索答案
elasticsearch 结合 kibana、Logstash、Beats,也就是 elastic stack(ELK)
被广泛应用在日志数据分析、实时监控等领域。
而 elasticsearch 是 elastic stack 的核心,负责存储、搜索、分析数据。
elasticsearch 底层是基于 lucene 来实现的。
Lucene 是一个 Java 语言的搜索引擎类库,是 Apache 公司的顶级项目,由 DougCutting 于 1999 年研发。
官网地址:https://lucene.apache.org/
elasticsearch的发展历史:
官网地址:https:// www.elastic.co/cn/
视频发布时间:2021-08-11
2022-7-9 的最新版本为 8.3.2
目前比较知名的搜索引擎技术排名(视频发布时间:2021-08-11)
Elasticsearch:开源的分布式搜索引擎
Splunk:商业项目
Solr:Apache 的开源搜索引擎
虽然在早期,Apache Solr 是最主要的搜索引擎技术,但随着发展 elasticsearch 已经渐渐超越了 Solr,独占鳌头
什么是 elasticsearch?
什么是 elastic stack(ELK)?
什么是 Lucene?
倒排索引的概念是基于 MySQL 这样的正向索引而言的。
那么什么是正向索引呢?例如给下表(tb_goods)中的 id 创建索引:
如果是根据 id 查询,那么直接走索引,查询速度非常快。
但如果是基于 title 做模糊查询,只能是逐行扫描数据,流程如下
"%手机%"
逐行扫描,也就是全表扫描,随着数据量增加,其查询效率也会越来越低。当数据量达到数百万时,就是一场灾难。
倒排索引中有两个非常重要的概念
Document
):用来搜索的数据,其中的每一条数据就是一个文档。例如一个网页、一个商品信息Term
):对文档数据或用户搜索数据,利用某种算法分词,得到的具备含义的词语就是词条。
创建倒排索引 是对正向索引的一种特殊处理,流程如下:
倒排索引的搜索流程如下(以搜索 “华为手机” 为例):
"华为手机"
进行搜索。华为
、手机
。
虽然要先查询倒排索引,再查询倒排索引,但是无论是词条、还是文档 id 都建立了索引,查询速度非常快!无需全表扫描。
什么是文档和词条?
为什么一个叫做正向索引,一个叫做倒排索引呢?
两者方式的优缺点是什么呢?
正向索引:
倒排索引:
elasticsearch 中有很多独有的概念,与 mysql 中略有差别,但也有相似之处。
elasticsearch 是面向文档(Document)存储的,可以是数据库中的一条商品数据,一个订单信息。
文档数据会被序列化为 json 格式后存储在 elasticsearch 中
而 Json 文档中往往包含很多的字段(Field),类似于数据库中的列。
索引(Index),就是相同类型的文档的集合。
例如:
因此,我们可以把索引当做是数据库中的表。
数据库的表会有约束信息,用来定义表的结构、字段的名称、类型等信息。
因此,索引库中就有映射(mapping),是索引中文档的字段约束信息,类似表的结构约束。
我们统一的把 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 了呢?
并不是如此,两者各自有自己的长处:
因此在企业中,往往是两者结合使用:
创建网络
因为我们还需要部署 kibana 容器,因此需要让 es 和 kibana 容器互联。
这里先创建一个网络
docker network create es-net
加载镜像
这里我们采用 elasticsearch 的 7.12.1 版本的镜像,这个镜像体积非常大,接近 1G。
不建议大家自己 pull
。课前资料提供了镜像的 tar 包。
- 课前资料链接:
https://pan.baidu.com/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 的响应结果:
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"
-p 5601:5601
:端口映射配置kibana 启动一般比较慢,需要多等待一会,可以通过命令:
docker logs -f kibana
查看运行日志,当查看到下面的日志,说明成功:
输入 http://虚拟机地址:5601
我这里是选择 “Explore on my own”,直接进入了下方界面。
DevTools
kibana 中提供了一个 DevTools 界面
这个界面中可以编写 DSL 来操作 elasticsearch。
并且对 DSL 语句有自动补全功能。
关于 es 集群的部署,后续会介绍,这里暂且跳过。
es 在创建倒排索引时需要对文档分词;在搜索时,需要对用户输入内容分词。但默认的分词规则对中文处理并不友好。
我们在 kibana 的 DevTools 中测试:
# 测试分词器
POST /_analyze
{
"text": "测试文本",
"analyzer": "standard"
}
语法说明
POST
:请求方式/_analyze
:请求路径,这里省略了 http://XXXXXXX:9200
,由 kibana 帮我们补充analyzer
:分词器类型,这里是默认的 standard 分词器text
:要分词的内容测试结果
处理中文分词,一般会使用 IK 分词器。
相关链接:https://github.com/medcl/elasticsearch-analysis-ik
在线安装 IK 插件(较慢)
进入容器内部
docker exec -it elasticsearch /bin/bash
在线下载并安装
./bin/elasticsearch-plugin install https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v7.12.1/elasticsearch-analysis-ik-7.12.1.zip
退出
exit
重启容器
docker restart elasticsearch
离线安装 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的中文学习笔记"
}
随着互联网的发展,“造词运动” 也越发的频繁。
出现了很多新的词语,在原有的词汇列表中并不存在。
比如:“奥力给”,“传智播客” 等。
所以我们的词汇也需要不断的更新,IK分词器提供了扩展词汇的功能。
config
目录IKAnalyzer.cfg.xml
配置文件内容添加
DOCTYPE properties SYSTEM "http://java.sun.com/dtd/properties.dtd">
<properties>
<comment>IK Analyzer 扩展配置comment>
<entry key="ext_dict">ext.dicentry>
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.com/medcl/elasticsearch-analysis-ik
在互联网项目中,在网络间传输的速度很快,所以很多语言是不允许在网络上传递的。
如:关于宗教、政治等敏感词语,那么我们在搜索时也应该忽略当前词汇。
IK 分词器也提供了强大的停用词功能,让我们在索引时就直接忽略当前的停用词汇表中的内容。
IKAnalyzer.cfg.xml
配置文件内容添加:
DOCTYPE properties SYSTEM "http://java.sun.com/dtd/properties.dtd">
<properties>
<comment>IK Analyzer 扩展配置comment>
<entry key="ext_dict">ext.dicentry>
<entry key="ext_stopwords">stopword.dicentry>
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.com/medcl/elasticsearch-analysis-ik
分词器的作用是什么?
IK 分词器有几种模式?
ik_smart
:智能切分,粗粒度ik_max_word
:最细切分,细粒度IK 分词器如何拓展词条?如何停用词条?
config
目录的 IkAnalyzer.cfg.xml
文件添加拓展词典和停用词典索引库就类似数据库表,mapping 映射就类似表的结构。
我们要向 es 中存储数据,必须先创建 “库” 和 “表”。
mapping 是对索引库中文档的约束,常见的 mapping 属性包括:
type
:字段数据类型,常见的简单类型有:
index
:是否创建索引,默认为 trueanalyzer
:使用哪种分词器properties
:该字段的子字段例如下面的 json 文档:
{
"age": 21,
"weight": 52.1,
"isMarried": false,
"info": "黑马程序员Java讲师",
"email": "[email protected]",
"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;无需分词器这里我们统一使用 Kibana 编写 DSL 的方式来演示。
ES 中通过 Restful 请求操作索引库、文档。
请求内容用 DSL 语句来表示。
基本语法
/索引库名
,可以自定义格式
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"
}
}
}
}
}
}
基本语法
/索引库名
格式
GET /索引库名
例
GET /heima
倒排索引结构虽然不复杂,但是一旦数据结构改变(比如改变了分词器),就需要重新创建倒排索引,这简直是灾难。
因此索引库一旦创建,无法修改mapping。
虽然无法修改 mapping 中已有的字段,但是却允许添加新的字段到 mapping 中,因为不会对倒排索引产生影响。
语法说明
PUT /索引库名/_mapping
{
"properties": {
"新字段名":{
"type": "integer"
}
}
}
示例
# 修改索引库(添加新字段)
PUT /heima/_mapping
{
"properties":{
"age":{
"type": "integer"
}
}
}
重复添加同一字段会报错
PUT /heima/_mapping
{
"properties":{
"age":{
"type": "long"
}
}
}
语法
格式
DELETE /索引库名
示例
在 kibana 中测试
# 删除
DELETE /heima
PUT/索引库名
GET/索引库名
DELETE/索引库名
PUT/索引库名/_mapping
语法:
POST /索引库名/_doc/文档id
{
"字段1": "值1",
"字段2": "值2",
"字段3": {
"子属性1": "值3",
"子属性2": "值4"
},
// ...
}
示例:
POST /heima/_doc/1
{
"info": "黑马程序员Java讲师",
"email": "[email protected]",
"name": {
"firstName": "云",
"lastName": "赵"
}
}
响应
根据 rest 风格,新增是 post,查询应该是 get,不过查询一般都需要条件,这里我们把文档 id 带上。
语法:
GET /{索引库名称}/_doc/{id}
通过 kibana 查看数据
GET /heima/_doc/1
查看结果
删除使用 DELETE 请求,同样,需要根据 id 进行删除:
语法:
DELETE /{索引库名}/_doc/id值
示例:
# 根据 id 删除数据
DELETE /heima/_doc/1
结果
修改有两种方式:
全量修改是覆盖原来的文档,其本质是
注意:如果根据id删除时,id不存在,第二步的新增也会执行,也就从修改变成了新增操作了。
语法
PUT /{索引库名}/_doc/文档id
{
"字段1": "值1",
"字段2": "值2",
// ... 略
}
示例
PUT /heima/_doc/1
{
"info": "黑马程序员高级Java讲师",
"email": "[email protected]",
"name": {
"firstName": "云",
"lastName": "赵"
}
}
增量修改是只修改指定 id 匹配的文档中的部分字段。
语法
POST /{索引库名}/_update/文档id
{
"doc": {
"字段名": "新的值",
}
}
示例
POST /heima/_update/1
{
"doc": {
"email": "[email protected]"
}
}
文档操作有哪些?
POST /{索引库名}/_doc/文档id { json文档 }
GET /{索引库名}/_doc/文档id
DELETE /{索引库名}/_doc/文档id
PUT /{索引库名}/_doc/文档id { json文档 }
POST /{索引库名}/_update/文档id { "doc": {字段}}
ES 官方提供了各种不同语言的客户端,用来操作 ES。
这些客户端的本质就是组装 DSL 语句,通过 http 请求发送给 ES。
官方文档地址:https://www.elastic.co/guide/en/elasticsearch/client/index.html
其中的 Java Rest Client 又包括两种:
我们学习的是 Java HighLevel Rest Client 客户端 API
案例介绍
利用 JavaRestClient 实现创建、删除索引库,判断索引库是否存在
根据课前资料提供的酒店数据创建索引库,索引库名为 hotel,mapping 属性根据数据库结构定义。
- 课前资料链接:
https://pan.baidu.com/s/169SFtYEvel44hRJhmFTRTQ
- 提取码:
1234
基本步骤
首先导入课前资料提供的数据库数据
数据结构如下
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;
然后导入课前资料提供的项目
项目结构
其中(我使用的 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
和图不一样?之前写错了,然后就是我懒得再截图了。
创建索引库,最关键的是 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
:地理坐标,里面包含精度、纬度。geo_point
:由纬度(latitude)和经度(longitude)确定的一个点。
geo_shape
:有多个 geo_point 组成的复杂几何图形。
all
:一个组合字段,其目的是将多字段的值 利用 copy_to 合并,提供给用户搜索copy_to
:字段拷贝可以使用 copy_to 属性将当前字段拷贝到指定字段。在 elasticsearch 提供的 API 中,与 elasticsearch 一切交互都封装在一个名为 RestHighLevelClient 的类中,
必须先完成这个对象的初始化,建立与 elasticsearch 的连接。
<dependency>
<groupId>org.elasticsearch.clientgroupId>
<artifactId>elasticsearch-rest-high-level-clientartifactId>
dependency>
<properties>
<java.version>1.8java.version>
<elasticsearch.version>7.12.1elasticsearch.version>
properties>
初始化的代码如下
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);
}
}
创建索引库的 API 如下
代码分为三步:
MAPPING_TEMPLATE
,让代码看起来更加优雅。client.indices()
方法的返回值是 IndicesClient 类型,封装了所有与索引库操作有关的方法。创建新目录 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);
}
删除索引库的 DSL 语句非常简单:
DELETE /hotel
与创建索引库相比:
所以代码的差异,注意体现在 Request 对象上。
依然是三步走:
在 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);
}
判断索引库是否存在,本质就是查询。对应的 DSL 是
GET /hotel
因此与删除的 Java 代码流程是类似的。依然是三步走:
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 ? "索引库已经存在!" : "索引库不存在!");
}
JavaRestClient 操作 elasticsearch 的流程基本类似。核心是 client.indices()
方法来获取索引库的操作对象。
索引库操作的基本步骤:
RestHighLevelClient#indices().xxx()
方法,xxx
是 create
、exists
、delete
案例介绍
基本步骤
为了与索引库操作分离,我们再次参加一个测试类,做两件事情:
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();
}
}
我们要将数据库的酒店数据查询出来,写入 elasticsearch 中。
先查询酒店数据,然后给这条数据创建倒排索引,即可完成添加。
数据库查询后的结果是一个 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;
}
与我们的索引库结构存在差异:
因此,我们需要定义一个新的类型,与索引库结构吻合
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();
}
}
新增文档的 DSL 语句如下:
POST /{索引库名}/_doc/1
{
"name": "Jack",
"age": 21
}
对应的 java 代码如图
可以看到与创建索引库类似,同样是三步走:
变化的地方在于,这里直接使用 client.xxx()
的 API,不再需要 client.indices()
了。
我们导入酒店数据,基本流程一致,但是需要考虑几点变化:
因此,代码整体步骤如下
在 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
根据 id 查询到的数据是 json,需要反序列化为 java 对象
查询的 DSL 语句如下:
GET /hotel/_doc/{id}
非常简单,因此代码大概分两步:
不过查询的目的是得到结果,解析为 HotelDoc,因此难点是结果的解析。
完整代码如下
可以看到,结果是一个 JSON,其中文档放在一个 _source
属性中,因此解析就是拿到 _source
,反序列化为 Java 对象即可。
与之前类似,也是三步走:
client.get()
方法在 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 控制台
删除的 DSL 是这样的
DELETE /hotel/_doc/{id}
与查询相比,仅仅是请求方式从DELETE 变成 GET,可以想象 Java 代码应该依然是三步走
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);
}
修改我们讲过两种方式:
在 RestClient 的 API 中,全量修改与新增的 API 完全一致,判断依据是 ID
这里不再赘述,我们主要关注增量修改。
代码示例如图
与之前类似,也是三步走:
client.update()
方法在 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);
}
案例:利用 JavaRestClient 批量导入酒店数据到 ES
需求:批量查询酒店数据,然后批量导入索引库中
思路:
批量处理 BulkRequest,其本质就是将多个普通的 CRUD 请求组合在一起发送。
其中提供了一个 add 方法,用来添加其他请求
可以看到,能添加的请求包括:
因此 Bulk 中添加了多个 IndexRequest,就是批量新增功能了。
示例
其实还是三步走:
client.bulk()
方法我们在导入酒店数据时,将上述代码改造成 for 循环处理即可。
在 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
文档操作的基本步骤
RestHighLevelClient#.xxx()
方法,xxx
是 index、get、update、delete、bulkSpringCloud 学习 Day06(实用篇-6) 分布式搜索引擎:elasticsearch 搜索功能
- DSL 查询文档
- 搜索结果处理
- RestClient 查询文档
- 黑马旅游案例
在 SpringCloudDay05 里,我们已经导入了大量数据到 elasticsearch 中,实现了 elasticsearch 的数据存储功能。
但 elasticsearch 最擅长的还是搜索和数据分析。
故在 SpringCloudDay06 里,我们研究下 elasticsearch 的数据搜索功能。
我们会分别使用 DSL 和 RestClient 实现搜索。
elasticsearch 的查询依然是基于 JSON 风格的 DSL 来实现的。
Elasticsearch 提供了基于 JSON 的 DSL(Domain Specific Language)来定义查询。
常见的查询类型包括:
match_all
match_query
multi_match_query
ids
range
term
geo_distance
geo_bounding_box
bool
function_score
查询的语法基本一致:
GET /indexName/_search
{
"query": {
"查询类型": {
"查询条件": "条件值"
}
}
}
我们以查询所有为例,其中:
match_all
// 查询所有
GET /indexName/_search
{
"query": {
"match_all": {
}
}
}
其它查询无非就是查询类型、查询条件的变化。
示例
小结:match 查询
查询 DSL 的基本语法是什么?
GET /索引库名/_search
{ "query": { "查询类型": { "FIELD": "TEXT"}}}
全文检索查询的基本流程如下:
比较常用的场景包括:
例如京东:
因为是拿着词条去匹配,因此参与搜索的字段也必须是可分词的 text 类型的字段。
常见的全文检索查询包括:
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"]
}
}
}
match
查询示例
multi_match
查询示例
可以看到,两种查询结果是一样的,为什么?
因为我们将 brand、name、business 值都利用 copy_to
复制到了 all 字段中。
因此你根据三个字段搜索,和根据 all 字段搜索效果当然一样了。
但是,搜索字段越多,对查询性能影响越大,因此建议采用 copy_to
,然后单字段查询的方式。
match
和 multi_match
的区别是什么?
match
:根据一个字段查询multi_match
:根据多个字段查询,参与查询字段越多,查询性能越差精确查询一般是查找 keyword、数值、日期、boolean 等类型字段。所以不会对搜索条件分词。
常见的有:
term
:根据词条精确值查询range
:根据值的范围查询因为精确查询的字段搜是不分词的字段,因此查询的条件也必须是不分词的词条。
查询时,用户输入的内容跟自动值完全匹配时才认为符合条件。如果用户输入的内容过多,反而搜索不到数据。
基本语法
// term 查询
GET /indexName/_search
{
"query": {
"term": {
"FIELD": {
"value": "VALUE"
}
}
}
}
示例
当我搜索的是精确词条时,能正确查询出结果
但是,当我搜索的内容不是词条,而是多个词语形成的短语时,反而搜索不到
范围查询,一般应用在对数值类型做范围过滤的时候。比如做价格范围过滤。
基本语法
// range 查询
GET /indexName/_search
{
"query": {
"range": {
"FIELD": {
"gte": 10, // 这里的 gte 代表大于等于,gt 则代表大于
"lte": 20 // lte 代表小于等于,lt 则代表小于
}
}
}
}
示例
精确查询常见的有哪些?
term
查询:根据词条精确匹配,一般搜索 keyword 类型、数值类型、布尔类型、日期类型字段range
查询:根据数值范围查询,可以是数值、日期的范围所谓的地理坐标查询,其实就是根据经纬度查询。
官方文档:https://www.elastic.co/guide/en/elasticsearch/reference/current/geo-queries.html
常见的使用场景包括:
例如:附近的酒店 和 附近的出租车
矩形范围查询,也就是 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
}
}
}
}
}
这种并不符合 “附近的人” 这样的需求,所以我们就不做了。
附近查询,也叫做距离查询(geo_distance
):查询到指定中心点小于某个距离值的所有文档。
换句话来说,在地图上找一个点作为圆心,以指定距离为半径,画一个圆,落在圆内的坐标都算符合条件
语法说明
// geo_distance 查询
GET /indexName/_search
{
"query": {
"geo_distance": {
"distance": "15km", // 半径
"FIELD": "31.21,121.5" // 圆心
}
}
}
示例
我们先搜索陆家嘴附近 15km 的酒店
发现共有 47 家酒店。
然后把半径缩短到 3 公里
可以发现,搜索到的酒店数量减少到了 5 家。
复合(compound
)查询:复合查询可以将其它简单查询组合起来,实现更复杂的搜索逻辑。
常见的有两种:
fuction score
:算分函数查询,可以控制文档相关性算分,控制文档排名bool query
:布尔查询,利用逻辑关系组合多个其它的查询,实现复杂搜索当我们利用 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 会根据词条和文档的相关度做打分。
算法由两种:
根据相关度打分是比较合理的需求,但合理的不一定是产品经理需要的。
以百度为例,你搜索的结果中,并不是相关度越高排名越靠前,而是谁掏的钱多排名就越靠前。
如图:
要想认为控制相关性算分,就需要利用 elasticsearch 中的 function score query 了。
function score 查询中包含四部分内容:
weight
:函数结果是常量field_value_factor
:以文档中的某个字段值作为函数结果random_score
:以随机数作为函数结果script_score
:自定义算分函数算法multiply
:相乘replace
:用 function score 替换 query scorefunction score 的运行流程如下:
因此,其中的关键点是:
需求:给 “如家” 这个品牌的酒店排名靠前一些
翻译一下这个需求,转换为之前说的四个要点:
brand = "如家"
因此最终的 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 定义的三要素是什么?
布尔查询是一个或多个查询子句的组合,每一个子句就是一个子查询。
子查询的组合方式有:
must
:必须匹配每个子查询,类似 “与”should
:选择性匹配子查询,类似 “或”must_not
:必须不匹配,不参与算分,类似 “非”filter
:必须匹配,不参与算分比如在搜索酒店时,除了关键字搜索外,我们还可能根据品牌、价格、城市等字段做过滤:
每一个不同的字段,其查询的条件、方式都不一样,必须是多个不同的查询,而要组合这些查询,就必须用 bool 查询了。
需要注意的是,搜索时,参与打分的字段越多,查询的性能也越差。因此这种多条件查询时,建议这样做:
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_not
中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
:必须匹配的条件,不参与打分搜索的结果可以按照用户指定的方式去处理或展示。
elasticsearch 默认是根据相关度算分(_score
)来排序,但是也支持自定义方式对搜索结果排序。
可以排序字段类型有:keyword 类型、数值类型、地理坐标类型、日期类型等。
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"
}
]
}
地理坐标排序略有不同。
语法说明:
GET /indexName/_search
{
"query": {
"match_all": {}
},
"sort": [
{
"_geo_distance" : {
"FIELD" : "纬度,经度", // 文档中 geo_point 类型的字段名、目标坐标点
"order" : "asc", // 排序方式
"unit" : "km" // 排序的距离单位
}
}
]
}
这个查询的含义是
geo_point
类型)的坐标 到目标点的距离是多少示例:
需求描述:实现对酒店数据按照到你的位置坐标的距离升序排序
提示:获取你的位置的经纬度的方式:https://lbs.amap.com/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"
}
}
]
}
elasticsearch 默认情况下只返回 top10 的数据。
而如果要查询更多数据就需要修改分页参数了。
elasticsearch 中通过修改 from、size 参数来控制要返回的分页结果:
from
:从第几个文档开始size
:总共查询几个文档类似于 mysql 中的 limit ?, ?
分页的基本语法
GET /hotel/_search
{
"query": {
"match_all": {}
},
"from": 0, // 分页开始的位置,默认为 0
"size": 10, // 期望获取的文档总数
"sort": [
{"price": "asc"}
]
}
现在,我要查询 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
分页查询的常见实现方案以及优缺点
from + size
:
after search
:
scroll
:
after search
方案。什么是高亮显示呢?
我们在百度,京东搜索时,关键字会变成红色,比较醒目,这叫高亮显示
高亮显示的实现分为两步:
标签
标签编写 CSS 样式高亮的语法
GET /hotel/_search
{
"query": {
"match": {
"FIELD": "TEXT" // 查询条件,高亮一定要使用全文检索查询
}
},
"highlight": {
"fields": { // 指定要高亮的字段
"FIELD": {
"pre_tags": "", // 用来标记高亮字段的前置标签
"post_tags": "" // 用来标记高亮字段的后置标签
}
}
}
}
注意
required_field_match=false
示例
# 高亮查询,默认情况下,ES 搜索字段必须与高亮字段一致
GET /hotel/_search
{
"query": {
"match": {
"all": "如家"
}
},
"highlight": {
"fields": {
"name": {
"require_field_match": "false"
}
}
}
}
查询的 DSL 是一个大的 JSON 对象。
包含下列属性:
query
:查询条件from
和 size
:分页条件sort
:排序条件highlight
:高亮条件示例
文档的查询同样适用于 RestHighLevelClient 对象
基本步骤
我们以 match_all
查询为例
我们通过 match_all
来演示下基本的 API,先看请求 DSL 的组织。
代码解读:
SearchRequest
对象,指定索引库名request.source()
构建 DSL,DSL 中可以包含查询、分页、排序、高亮等
query()
:代表查询条件,利用QueryBuilders.matchAllQuery()
构建一个 match_all
查询的DSLclient.search()
发送请求,得到响应这里关键的 API 有两个,一个是 request.source()
,其中包含了查询、排序、分页、高亮等所有功能
另一个是 QueryBuilders
,其中包含 match、term、function_score、bool 等各种查询
响应的结果的解析
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 文档数据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);
}
}
查询的基本步骤是
Request.source()
,也就是 DSL
QueryBuilders
来构建查询条件Request.source()
的 query()
方法全文检索的 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);
}
精确查询主要是两者:
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);
}
布尔查询是用 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);
}
当然,也可以使用链式编程的方式
搜索结果的排序和分页是与 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);
}
高亮的代码与之前代码差异较大,有两点:
_source
文档数据,还要解析高亮结果高亮请求的构建 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);
}
高亮的结果与查询的文档结果默认是分离的,并不在一起。
因此解析高亮的代码需要额外处理
代码解读
hit.getSourceAsString()
,这部分是非高亮结果,json 字符串。还需要反序列为 HotelDoc 对象hit.getHighlightFields()
,返回值是一个 Map,key 是高亮字段名称,值是 HighlightField 对象,代表高亮值完整代码
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);
}
}
小结
SearchRequest的source()
方法。通过该案例来实战演练下之前学习的知识。
实现四部分功能:
启动资料中提供的 hotel-demo
项目,其默认端口是 8089,访问 http://localhost:8090
,就能看到项目页面了
具体情况见博客:【学习笔记:SpringCloud 微服务技术栈 | 实用篇② | 黑马旅游案例】
SpringCloud 学习 Day07(实用篇-7) 分布式搜索引擎:深入 elasticsearch
- 数据聚合
- 自动补全
- 数据补全
- 集群
聚合(aggregations) 可以让我们极其方便的实现对数据的统计、分析、运算。
例如:
实现这些统计功能的比数据库的 sql 要方便的多,而且查询速度非常快,可以实现近实时搜索效果。
TermAggregation
:按照文档字段值分组,例如按照品牌值分组、按照国家分组Date Histogram
:按照日期阶梯分组,例如一周为一组,或者一月为一组Avg
:求平均值Max
:求最大值Min
:求最小值Stats
:同时求 max、min、avg、sum 等注意:参加聚合的字段必须是 keyword、日期、数值、布尔类型
欲知更多情况需访问官网:https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations.html
现在,我们要统计所有数据中的酒店品牌有几种,其实就是按照品牌对数据分组。
此时可以根据酒店品牌的名称做聚合,也就是 Bucket 聚合。
语法如下
GET /hotel/_search
{
"size": 0, // 设置 size 为 0,结果中不包含文档,只包含聚合结果
"aggs": { // 定义聚合
"brandAgg": { //给聚合起个名字
"terms": { // 聚合的类型,按照品牌值聚合,所以选择 term
"field": "brand", // 参与聚合的字段
"size": 20 // 希望获取的聚合结果数量
}
}
}
}
结果如图
默认情况下,Bucket 聚合会统计 Bucket 内的文档数量,记为 _count
,并且按照 _count
降序排序。
我们可以指定 order 属性,自定义聚合的排序方式
GET /hotel/_search
{
"size": 0,
"aggs": {
"brandAgg": {
"terms": {
"field": "brand",
"order": {
"_count": "asc" // 按照 _count 升序排列
},
"size": 20
}
}
}
}
默认情况下,Bucket 聚合是对索引库的所有文档做聚合。
但真实场景下,用户会输入搜索条件,因此聚合必须是对搜索结果聚合。那么聚合必须添加限定条件。
我们可以限定要聚合的文档范围,只要添加 query 条件即可
GET /hotel/_search
{
"query": {
"range": {
"price": {
"lte": 200 // 只对200元以下的文档聚合
}
}
},
"size": 0,
"aggs": {
"brandAgg": {
"terms": {
"field": "brand",
"size": 20
}
}
}
}
这次,聚合得到的品牌明显变少了
在之前的操作中,我们对酒店按照品牌分组,形成了一个个桶。
现在我们需要对桶内的酒店做运算,获取每个品牌的用户评分的 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 的聚合内部嵌套的子聚合。
因为我们需要在每个桶分别计算。
另外,我们还可以给聚合结果做个排序,例如按照每个桶的酒店平均分做排序
aggs 代表聚合,与 query 同级,此时 query 的作用是?
聚合必须的三要素:
聚合可配置属性有:
size
:指定聚合结果数量order
:指定聚合结果排序方式field
:指定聚合字段聚合条件与 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 微服务技术栈 | 实用篇② | 黑马旅游案例】
当用户在搜索框输入字符时,我们应该提示出与该字符有关的搜索项,如图
这种根据用户输入的字母,提示完整词条的功能,就是自动补全了。
因为需要根据拼音字母来推断,因此要用到拼音分词功能。
要实现根据字母做补全,就必须对文档按照拼音分词。
在 GitHub 上恰好有 elasticsearch 的拼音分词插件。
地址:https://github.com/medcl/elasticsearch-analysis-pinyin
课前资料中也提供了拼音分词器的安装包
安装方式与 IK 分词器一样,分三步
plugin
目录可以通过下面命令查看 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"
}
结果
默认的拼音分词器会将每个汉字单独分为拼音,而我们希望的是每个词条形成一组拼音,需要对拼音分词器做个性化定制,形成自定义分词器。
elasticsearch 中分词器(analyzer)的组成包含三部分:
ik_smart
文档分词时会依次由这三部分来处理文档
我们在创建索引库时,可以通过 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"
}
}
}
}
测试
总结
如何使用拼音分词器?
plugin
目录如何自定义分词器?
character filter
tokenizer
filter
拼音分词器注意事项?
elasticsearch 提供了 Completion Suggester 查询来实现自动补全功能。
这个查询会匹配以用户输入内容开头的词条并返回。为了提高补全查询的效率,对于文档中字段的类型有一些约束:
比如,一个这样的索引库
// 创建索引库
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条结果
}
}
}
}
之前我们学习了自动补全查询的 DSL,而没有学习对应的 JavaAPI,这里给出一个示例
而自动补全的结果也比较特殊,解析的代码如下
具体项目的业务实现见博客:【学习笔记:SpringCloud 微服务技术栈 | 实用篇② | 黑马旅游案例】
elasticsearch 中的酒店数据来自于 mysql 数据库,因此 mysql 数据发生改变时,elasticsearch 也必须跟着改变。
这个就是 elasticsearch 与 mysql 之间的数据同步。
常见的数据同步方案有三种
基本步骤
hotel-demo
对外提供接口,用来修改 elasticsearch 中的数据hotel-demo
提供的接口流程如下
hotel-admin
对 mysql 数据库数据完成增、删、改后,发送 MQ 消息hotel-demo
监听 MQ,接收到消息后完成 elasticsearch 数据修改流程如下:
hotel-demo
基于 canal 监听 binlog 变化,实时更新 elasticsearch 中的内容方式一:同步调用
方式二:异步通知
方式三:监听 binlog
具体项目的业务实现见博客:【学习笔记:SpringCloud 微服务技术栈 | 实用篇② | 黑马旅游案例】
单机的 elasticsearch 做数据存储,必然面临两个问题:海量数据存储问题、单点故障问题。
ES集群相关概念:
cluster name
的 节点。解决问题:数据量太大,单点存储量有限的问题。
此处,我们把数据分成 3 片:shard0、shard1、shard2
数据备份可以保证高可用,但是每个分片备份一份,所需要的节点数量就会翻一倍,成本实在是太高了!
为了在高可用和成本间寻求平衡,我们可以这样做:
这样可以大大减少所需要的服务节点数量,如图,我们以 3 分片,每个分片备份一份为例
现在,每个分片都有 1 个备份,存储在 3 个节点
首先编写一个 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
来查看节点的运行状态
kibana 可以监控 es 集群,不过新版本需要依赖 es 的 x-pack 功能,配置比较复杂。
这里推荐使用 cerebro 来监控 es 集群状态。
cerebro 的官方网址:https://github.com/lmenezes/cerebro
课前资料已经提供了安装包
解压即可使用,非常方便。
解压好的目录如下
进入对应的 bin
目录,双击其中的 cerebro.bat
文件即可启动服务。
访问 http://localhost:9000
即可进入管理界面,输入你的 elasticsearch 的任意节点的地址和端口,点击 connect 即可
绿色的条,代表集群处于绿色(健康状态)。
两种方式:利用 kibana 的 DevTools 创建索引库;利用 cerebro 创建索引库
在 DevTools 中输入指令
PUT /itcast
{
"settings": {
"number_of_shards": 3, // 分片数量
"number_of_replicas": 1 // 副本数量
},
"mappings": {
"properties": {
// mapping映射定义 ...
}
}
}
填写索引库信息
点击右下角的 create 按钮
若分片失败,那有可能是 CentOS7 空间不足的问题
若要扩容,可以参考我之前的博客:CentOS7 给 centos-root 扩容【学习记录】
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 集群职责划分如图
脑裂是因为集群中的节点失联导致的。
例如一个集群中,主节点与其它节点失联
此时,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 个主节点,没有出现脑裂。
master eligible
节点的作用是什么?
data节点的作用是什么?
coordinator 节点的作用是什么?
当新增文档时,应该保存到不同分片,保证数据均衡,那么 coordinating node
如何确定数据该存储到哪个分片呢?
以下使用的工具是 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": {}
}
}
elasticsearch 会通过 hash 算法来计算文档应该存储到哪个分片
说明
_routing
默认是文档的 id新增文档的流程如下
解读
id=1
的文档shard-2
shard-2
的主分片在 node3
节点,将数据路由到 node3
shard-2
的副本 replica-2
,在 node2
节点coordinating-node
节点elasticsearch 的查询分成两个阶段
scatter phase
:分散阶段,coordinating node
会把请求分发到每一个分片gather phase
:聚集阶段,coordinating node
汇总 data node
的搜索结果,并处理为最终结果集返回给用户集群的 master
节点会监控集群中的节点状态。
如果发现有节点宕机,会立即将宕机节点的分片数据迁移到其它节点,确保数据安全,这个叫做故障转移。
现在,node1
是主节点,其它两个节点是从节点。
node1
发生了故障宕机后的第一件事,需要重新选主,例如选中了 node2
node2 成为主节点后,会检测集群监控状态。
发现:shard-1
、shard-0
没有副本节点。
因此需要将 node1
上的数据迁移到 node2
、node3
总结:故障转移
master
宕机后,EligibleMaster
选举为新的主节点。master
节点监控分片、节点状态,将故障节点上的分片转移到正常节点,确保数据安全。本博客主要讲述了
至此,SpringCloud 微服务技术栈的实用篇 的内容结束
部分补充内容则见博客:【学习笔记:SpringCloud 微服务技术栈 | 实用篇② | 黑马旅游案例】