微服务技术栈学习笔记
微服务技术栈学习笔记
学习时间:2021年12月8日
学习内容:SpringCloud,RabbitMQ,Docker,Redis,搜索,分布式
视频来源:SpringCloud+RabbitMQ+Docker+Redis+搜索+分布式,史上最全面的springcloud微服务技术栈课程|黑马程序员Java微服务
1 认识微服务
随着互联网行业的发展,对服务的要求也越来越高,服务架构也从单体架构逐渐演变为现在流行的微服务架构。这些架构之间有怎样的差别呢?
1.1 单体架构
单体架构:将业务的所有功能集中在一个项目中开发,打成一个包部署。
优点:
- 架构简单
- 部署成本低
缺点:
- 耦合度高(维护困难、升级困难)
1.2 分布式架构
分布式架构:根据业务功能对系统做拆分,每个业务功能模块作为独立项目开发,称为一个服务。
优点:
- 降低服务耦合
- 有利于服务升级和拓展
缺点:
- 服务调用关系错综复杂
分布式架构虽然降低了服务耦合,但是服务拆分时也有很多问题需要思考:
- 服务拆分的粒度如何界定?
- 服务之间如何调用?
- 服务的调用关系如何管理?
人们需要制定一套行之有效的标准来约束分布式架构。
1.3 微服务
1.3.1 服务架构演变
微服务是一种经过良好架构设计的分布式架构方案。
微服务的架构特征:
- 单一职责:微服务拆分粒度更小,每一个服务都对应唯一的业务能力,做到单一职责
- 自治:团队独立、技术独立、数据独立,独立部署和交付
- 面向服务:服务提供统一标准的接口,与语言和技术无关
- 隔离性强:服务调用做好隔离、容错、降级,避免出现级联问题
微服务架构图
微服务部署(持续集成)
微服务图解
微服务的上述特性其实是在给分布式架构制定一个标准,进一步降低服务之间的耦合度,提供服务的独立性和灵活性。做到高内聚,低耦合。
因此,可以认为微服务是一种经过良好架构设计的分布式架构方案 。
但方案该怎么落地?选用什么样的技术栈?全球的互联网公司都在积极尝试自己的微服务落地方案。
其中在Java领域最引人注目的就是SpringCloud提供的方案了。
微服务技术栈
1.3.2 微服务框架对比
微服务这种方案需要技术框架来落地,全球的互联网公司都在积极尝试自己的微服务落地技术。在国内最知名的就是SpringCloud和阿里巴巴的Dubbo。
技术对比
Dubbo | SpringCloud | SpringCloudAlibaba | |
---|---|---|---|
注册中心 | zookeeper、Redis | Eureka、Consul | Nacos、Eureka |
服务远程调用 | Dubbo协议 | Feign(http协议) | Dubbo、Feign |
配置中心 | 无 | SpringCloudConfig | SpringCloudConfig、Nacos |
服务网关 | 无 | SpringCloudGateway、Zuul | SpringCloudGateway、Zuul |
服务监控和保护 | dubbo-admin,功能弱 | Hystix | Sentinel |
企业需求
1.4 SpringCloud
- SpringCloud是目前国内使用最广泛的微服务框架。官网地址:https://spring.io/projects/spring-cloud。
- SpringCloud集成了各种微服务功能组件,并基于SpringBoot实现了这些组件的自动装配,从而提供了良好的开箱即用体验:
另外,SpringCloud底层是依赖于SpringBoot的,并且有版本的兼容关系。
1.5 总结
单体架构:简单方便,高度耦合,扩展性差,适合小型项目。例如:学生管理系统
分布式架构:松耦合,扩展性好,但架构复杂,难度大。适合大型互联网项目,例如:京东、淘宝
微服务:一种良好的分布式架构方案
①优点:拆分粒度更小、服务更独立、耦合度更低
②缺点:架构非常复杂,运维、监控、部署难度提高
SpringCloud是微服务架构的一站式解决方案,集成了各种优秀微服务功能组件
2 服务拆分和远程调用
任何分布式架构都离不开服务的拆分,微服务也是一样。
2.1 服务拆分原则
- 不同微服务,不要重复开发相同业务
- 微服务数据独立,不要访问其它微服务的数据库
- 微服务可以将自己的业务暴露为接口,供其它微服务调用
2.2 服务拆分示例
2.2.1 项目结构
cloud-demo:父工程,管理依赖
- order-service:订单微服务,负责订单相关业务
- user-service:用户微服务,负责用户相关业务
要求:
- 订单微服务和用户微服务都必须有各自的数据库,相互独立
- 订单服务和用户服务都对外暴露Restful的接口
- 订单服务如果需要查询用户信息,只能调用用户服务的Restful接口,不能查询用户数据库
2.2.2 导入sql语句
首先,将课前资料提供的cloud-order.sql
和cloud-user.sql
导入到mysql中。
cloud-user表中初始数据如下:
cloud-order表中初始数据如下:
cloud-order表中持有cloud-user表中的id字段。
2.2.3 导入demo工程
项目结构
部分代码
OrderController.java
1 |
|
UserController.java
1 |
|
查询结果
2.3 实现远程调用案例
2.3.1 案例需求
修改order-service中的根据id查询订单业务,要求在查询订单的同时,根据订单中包含的userId查询出用户信息,一起返回。
因此,我们需要在order-service中向user-service发起一个http的请求,调用http://localhost:8081/user/{userId}这个接口。
步骤
- 注册一个
RestTemplate
的实例到Spring容器 - 修改order-service服务中的OrderService类中的queryOrderById方法,根据Order对象中的userId查询User
- 将查询的User填充到Order对象,一起返回
2.3.2 注册RestTemplate并远程调用
首先,我们在order-service服务中的OrderApplication启动类中,注册RestTemplate实例:
1 |
|
其次,修改order-service服务中的cn.itcast.order.service包下的OrderService类中的queryOrderById方法:
1 |
|
程序结果
2.4 提供者与消费者
在服务调用关系中,会有两个不同的角色:
服务提供者:一次业务中,被其它微服务调用的服务。(提供接口给其它微服务)
服务消费者:一次业务中,调用其它微服务的服务。(调用其它微服务提供的接口)
但是,服务提供者与服务消费者的角色并不是绝对的,而是相对于业务而言。
如果服务A调用了服务B,而服务B又调用了服务C,服务B的角色是什么?
- 对于A调用B的业务而言:A是服务消费者,B是服务提供者
- 对于B调用C的业务而言:B是服务消费者,C是服务提供者
因此,服务B既可以是服务提供者,也可以是服务消费者。
3 Eureka注册中心
问题提出
假如我们的服务提供者user-service部署了多个实例,如图:
- order-service在发起远程调用的时候,该如何得知user-service实例的ip地址和端口?
- 有多个user-service实例地址,order-service调用时该如何选择?
- order-service如何得知某个user-service实例是否依然健康,是不是已经宕机?
3.1 Eureka的结构和作用
这些问题都需要利用SpringCloud中的注册中心来解决,其中最广为人知的注册中心就是Eureka,其结构如下:
问题解决
order-service如何得知user-service实例地址?
- user-service服务实例启动后,将自己的信息注册到eureka-server(Eureka服务端)。这个叫服务注册
- eureka-server保存服务名称到服务实例地址列表的映射关系
- order-service根据服务名称,拉取实例地址列表。这个叫服务发现或服务拉取
order-service如何从多个user-service实例中选择具体的实例?
- order-service从实例列表中利用负载均衡算法选中一个实例地址
- 向该实例地址发起远程调用
order-service如何得知某个user-service实例是否依然健康,是不是已经宕机?
- user-service会每隔一段时间(默认30秒)向eureka-server发起请求,报告自己状态,称为心跳
- 当超过一定时间没有发送心跳时,eureka-server会认为微服务实例故障,将该实例从服务列表中剔除
- order-service拉取服务时,就能将故障实例排除了
注意:一个微服务,既可以是服务提供者,又可以是服务消费者,因此eureka将服务注册、服务发现等功能统一封装到了eureka-client端。
实现步骤
3.2 搭建eureka-server
- 创建eureka-server服务
首先注册中心服务端:eureka-server,这必须是一个独立的微服务。
在cloud-demo
父工程下,创建一个子模块eureka-server:
- 引入eureka依赖
在子模块的pom中引入eureka的依赖,注意版本信息已经在父工程中定义,故在子模块中无需填写。
1 | <dependencies> |
上图是父工程的pom文件,其中红线部分是对springcloud各组件版本的定义。
- 编写启动类
给eureka-server服务编写一个启动类,一定要添加一个@EnableEurekaServer
注解,开启eureka的注册中心功能:
1 |
|
- 编写配置文件
编写一个application.yml
文件,内容如下:
1 | server: |
- 启动服务
启动微服务,然后在浏览器访问:http://127.0.0.1:10086
看到下面结果应该是成功了:
3.3 服务注册
下面,我们将user-service和order-service注册到eureka-server中去。以user-service为例:
- 引入依赖
在user-service的pom文件中,引入下面的eureka-client依赖:
1 | <!--eureka客户端依赖--> |
- 配置文件
在user-service中,修改application.yml文件,添加服务名称、eureka地址:
1 | server: |
- 启动多个user-service实例
为了演示一个服务有多个实例的场景,我们添加一个SpringBoot的启动配置,再启动一个user-service。
首先,复制原来的user-service启动配置:
在弹出的窗口中填写名称和jvm参数:
现在,SpringBoot窗口会出现两个user-service启动配置:
不过,第一个是8081端口,第二个是8082端口。
查看eureka-server管理页面:
3.4 服务发现
下面,我们将order-service的逻辑修改:向eureka-server拉取user-service的信息,实现服务发现(服务拉取)。即order-service要去eureka-server中拉取user-service服务的实例列表,并且实现负载均衡。
不过这些动作不用我们去做,只需要添加一些注解即可。
- 在order-service的OrderApplication中,给RestTemplate这个Bean添加一个
@LoadBalanced
注解:
1 |
|
- 修改order-service服务中的cn.itcast.order.service包下的OrderService类中的queryOrderById方法。修改访问的url路径,用服务名代替ip、端口:
spring会自动帮助我们从eureka-server端,根据userservice这个服务名称,获取实例列表,而后完成负载均衡。
验证
发起多次请求:localhost:8080/order/101
两个端口都有响应
总结
- 搭建EurekaServer:引入eureka-server依赖;添加@EnableEurekaServer注解;在application.yml中配置eureka地址
- 服务注册:引入eureka-client依赖;在application.yml中配置eureka地址
- 服务发现:引入eureka-client依赖;在application.yml中配置eureka地址;给RestTemplate添加@LoadBalanced注解;用服务提供者的服务名称远程调用
4 Ribbon负载均衡
4.1 负载均衡原理
4.1.1 问题的提出
SpringCloud底层其实是利用了一个名为Ribbon
的组件,来实现负载均衡功能的。
那么我们发出的请求明明是http://userservice/user/1,怎么变成了http://localhost:8081的呢?
4.1.2 源码跟踪
为什么我们只输入了service名称就可以访问了呢?之前还要获取ip和端口。显然有人帮我们根据service名称,获取到了服务实例的ip和端口。它就是LoadBalancerInterceptor
,这个类会在对RestTemplate的请求进行拦截,然后从Eureka根据服务id获取服务列表,随后利用负载均衡算法得到真实的服务地址信息,替换服务id。
LoadBalancerInterceptor
可以看到这里的intercept
方法,拦截了用户的HttpRequest请求,然后做了几件事:
request.getURI()
:获取请求uri,本例中就是 http://user-service/user/8originalUri.getHost()
:获取uri路径的主机名,其实就是服务id,user-service
this.loadBalancer.execute()
:处理服务id,和用户请求。
这里的this.loadBalancer
是LoadBalancerClient
类型,我们继续跟入。
LoadBalancerClient
getLoadBalancer(serviceId)
:根据服务id获取ILoadBalancer,而ILoadBalancer会拿着服务id去eureka中获取服务列表并保存起来。getServer(loadBalancer)
:利用内置的负载均衡算法,从服务列表中选择一个。本例中,可以看到获取了8082端口的服务
放行后,再次访问并跟踪,发现获取的是8081:
负载均衡策略
IRule
在刚才的代码中,可以看到获取服务使通过一个getServer
方法来做负载均衡,我们继续跟入:
继续跟踪源码chooseServer
方法,发现这么一段代码:
我们看看这个rule是谁:
这里的rule默认值是一个RoundRobinRule
,看类的介绍:
即轮询算法。到这里,整个负载均衡的流程我们就清楚了。
4.1.3 原理总结
SpringCloudRibbon的底层采用了一个拦截器,拦截了RestTemplate发出的请求,对地址做了修改。用一幅图来总结一下:
基本流程如下:
- 拦截我们的RestTemplate请求http://userservice/user/1
RibbonLoadBalancerClient
会从请求url中获取服务名称,也就是user-serviceDynamicServerListLoadBalancer
根据user-service到eureka拉取服务列表- eureka返回列表,localhost:8081、localhost:8082
IRule
利用内置负载均衡规则,从列表中选择一个,例如localhost:8081RibbonLoadBalancerClient
修改请求地址,用localhost:8081替代userservice,得到http://localhost:8081/user/1,发起**真实请求**
4.2 负载均衡策略
负载均衡的规则都定义在IRule接口中,而IRule有很多不同的实现类。Ribbon的负载均衡规则是一个叫做IRule
的接口来定义的,每一个子接口都是一种规则:
内置负载均衡规则类 | 规则描述 |
---|---|
RoundRobinRule | 简单轮询服务列表来选择服务器。它是Ribbon默认的负载均衡规则。 |
AvailabilityFilteringRule | 对以下两种服务器进行忽略: (1)在默认情况下,这台服务器如果3次连接失败,这台服务器就会被设置为“短路”状态。短路状态将持续30秒,如果再次连接失败,短路的持续时间就会几何级地增加。(2)并发数过高的服务器。如果一个服务器的并发连接数过高,配置了AvailabilityFilteringRule规则的客户端也会将其忽略。并发连接数的上限,可以由客户端的 |
WeightedResponseTimeRule | 为每一个服务器赋予一个权重值。服务器响应时间越长,这个服务器的权重就越小。这个规则会随机选择服务器,这个权重值会影响服务器的选择。 |
ZoneAvoidanceRule | 以区域可用的服务器为基础进行服务器的选择。使用Zone对服务器进行分类,这个Zone可以理解为一个机房、一个机架等。而后再对Zone内的多个服务做轮询。 |
BestAvailableRule | 忽略那些短路的服务器,并选择并发数较低的服务器。 |
RandomRule | 随机选择一个可用的服务器。 |
RetryRule | 重试机制的选择逻辑 |
默认的实现就是ZoneAvoidanceRule
,是一种轮询方案
自定义负载均衡策略
通过定义IRule实现可以修改负载均衡规则,有两种方式:
- 代码方式:在order-service中的OrderApplication类中,定义一个新的IRule:这种方式是全局性的,对调用任何服务时,都采用相同的负载均衡策略
1 |
|
- 配置文件方式:在order-service的application.yml文件中,添加新的配置也可以修改规则:这种是针对性的。
1 | userservice: # 给某个微服务配置负载均衡规则,这里是userservice服务 |
4.3 饥饿加载
Ribbon默认是采用懒加载,即第一次访问时才会去创建LoadBalanceClient,请求时间会很长。
而饥饿加载则会在项目启动时创建,降低第一次访问的耗时,通过下面配置开启饥饿加载:
1 | ribbon: |
总结
- Ribbon负载均衡规则:
- 规则接口是IRule
- 默认实现是ZoneAvoidanceRule,根据zone选择服务列表,然后轮询
- 负载均衡自定义方式:
- 代码方式:配置灵活,但修改时需要重新打包发布
- 配置方式:直观,方便,无需重新打包发布,但是无法做全局配置
- 饥饿加载:
- 开启饥饿加载
- 指定饥饿加载的微服务名称
5 Nacos注册中心
国内公司一般都推崇阿里巴巴的技术,比如注册中心,SpringCloudAlibaba也推出了一个名为Nacos的注册中心。Nacos是阿里巴巴的产品,现在是SpringCloud中的一个组件。相比Eureka功能更加丰富,在国内受欢迎程度较高。
5.1 安装及启动
启动命令:
1 | startup.cmd -m standalone |
其中,standalone意为单机启动模式。
5.2 服务注册
Nacos是SpringCloudAlibaba的组件,而SpringCloudAlibaba也遵循SpringCloud中定义的服务注册、服务发现规范。因此使用Nacos和使用Eureka对于微服务来说,并没有太大区别。
主要差异在于:
- 依赖不同
- 服务地址不同
5.3.1 引入依赖
在cloud-demo父工程的pom文件中的<dependencyManagement>
中引入SpringCloudAlibaba的依赖:
1 | <!--nacos依赖--> |
然后在user-service和order-service中的pom文件中引入nacos-discovery依赖:
1 | <!--nacos客户端依赖--> |
5.3.2 配置nacos地址
在user-service和order-service的application.yml中添加nacos地址:
1 | cloud: |
重启微服务后,登录nacos管理页面,可以看到微服务信息:
5.3 服务分级存储模型
5.3.1 概述
一个服务可以有多个实例,例如我们的user-service,可以有:
- 127.0.0.1:8081
- 127.0.0.1:8082
- 127.0.0.1:8083
假如这些实例分布于全国各地的不同机房,例如:
- 127.0.0.1:8081,在上海机房
- 127.0.0.1:8082,在上海机房
- 127.0.0.1:8083,在杭州机房
Nacos就将同一机房内的实例划分为一个集群。
也就是说,user-service是服务,一个服务可以包含多个集群,如杭州、上海,每个集群下可以有多个实例,形成分级模型,如图:
微服务互相访问时,应该尽可能访问同集群实例,因为本地访问速度更快。当本集群内不可用时,才访问其它集群。例如:
杭州机房内的order-service应该优先访问同机房的user-service。
5.3.2 给user-service配置集群
修改user-service的application.yml文件,添加集群配置:
1 | cloud: |
我们再次复制一个user-service启动配置,添加属性:
1 | -Dserver.port=8083 -Dspring.cloud.nacos.discovery.cluster-name=SH |
启动UserApplication3后再次查看nacos控制台:
点击详情:
5.3.3 同集群优先的负载均衡
为使得同集群的微服务优先相互访问,需要配置同集群优先的负载均衡。默认的ZoneAvoidanceRule
并不能实现根据同集群优先来实现负载均衡。因此Nacos中提供了一个NacosRule
的实现,可以优先从同集群中挑选实例。
- 给order-service配置集群信息:修改order-service的application.yml文件,添加集群配置。
1 | spring: |
- 修改负载均衡规则:修改order-service的application.yml文件,修改负载均衡规则:
1 | userservice: # 要做配置的微服务名称 |
NacosRule负载均衡策略
优先选择同集群服务实例列表
本地集群找不到提供者,才去其它集群寻找,并且会报警告
确定了可用实例列表后,再采用随机负载均衡挑选实例
5.3.4 根据权重的负载均衡
实际部署中会出现这样的场景:服务器设备性能有差异,部分实例所在机器性能较好,另一些较差,我们希望性能好的机器承担更多的用户请求。
Nacos提供了权重配置来控制访问频率,权重越大则访问频率越高。
- 点击编辑:
- 修改结果:
实例的权重控制
Nacos控制台可以设置实例的权重值,0~1之间
同集群内的多个实例,权重越高被访问的频率越高
权重设置为0则完全不会被访问
5.4 环境隔离
Nacos提供了namespace
(命名空间)来实现环境隔离功能。
- nacos中可以有多个namespace
- namespace下可以有group、service等
- 不同namespace之间相互隔离,例如不同namespace的服务互相不可见
- 默认的命名空间为public
5.4.1 创建namespace
- 默认情况下,所有service、data、group都在同一个namespace,名为public,我们可以点击页面新增按钮,添加一个namespace:
- 然后,填写表单:
- 就能在页面看到一个新的namespace:
5.4.2 给微服务配置namespace
- 给微服务配置namespace只能通过修改配置来实现。例如,修改order-service的application.yml文件:
1 | cloud: |
- 重启order-service后,访问控制台,可以看到下面的结果:
- 此时访问order-service,因为namespace不同,会导致找不到userservice,控制台会报错:
5.5 Nacos和Eureka的区别
5.5.1 服务实例
Nacos的服务实例分为两种类型:
临时实例:如果实例宕机超过一定时间,会从服务列表剔除,默认的类型。
非临时实例:如果实例宕机,不会从服务列表剔除,也可以叫永久实例。
配置一个服务实例为永久实例
1 | cloud: |
5.5.2 二者区别
Nacos和Eureka整体结构类似,服务注册、服务拉取、心跳等待,但是也存在一些差异:
Nacos与Eureka的共同点
- 都支持服务注册和服务拉取
- 都支持服务提供者心跳方式做健康检测
Nacos与Eureka的区别
- Nacos支持服务端主动检测提供者状态:临时实例采用心跳模式,非临时实例采用主动检测模式
- 临时实例心跳不正常会被剔除,非临时实例则不会被剔除
- Nacos支持服务列表变更的消息推送模式,服务列表更新更及时
- Nacos集群默认采用AP方式,当集群中存在非临时实例时,采用CP模式;Eureka采用AP方式
5.6 Nacos配置管理
5.6.1 统一配置管理
当微服务部署的实例越来越多,达到数十、数百时,逐个修改微服务配置就会让人抓狂,而且很容易出错。我们需要一种统一配置管理方案,可以集中管理所有实例的配置。
Nacos一方面可以将配置集中管理,另一方可以在配置变更时,及时通知微服务,实现配置的热更新。
1) 在nacos中添加配置文件
如何在nacos中管理配置呢?
然后在弹出的表单中,填写配置信息:
注意:项目的核心配置,需要热更新的配置才有放到nacos管理的必要。基本不会变更的一些配置还是保存在微服务本地比较好。
应用结果
2) 微服务拉取配置
微服务要拉取nacos中管理的配置,并且与本地的application.yml配置合并,才能完成项目启动。
但如果尚未读取application.yml,又如何得知nacos地址呢?
因此spring引入了一种新的配置文件:bootstrap.yaml
文件,会在application.yml之前被读取,流程如下:
- 引入nacos-config依赖
首先,在user-service服务中,引入nacos-config的客户端依赖:
1 | <!--nacos配置管理依赖--> |
- 添加bootstrap.yaml
然后,在user-service中添加一个bootstrap.yaml文件,内容如下:
1 | spring: |
这里会根据spring.cloud.nacos.server-addr
获取nacos地址,再根据
${spring.application.name}-${spring.profiles.active}.${spring.cloud.nacos.config.file-extension}
作为文件id,来读取配置。
本例中,就是去读取userservice-dev.yaml
:
- 读取nacos配置
在user-service中的UserController中添加业务逻辑,读取pattern.dateformat
配置:
1 |
|
3) 总结
- 在Nacos中添加配置文件
- 在微服务中引入nacos的config依赖
- 在微服务中添加bootstrap.yml,配置nacos地址、当前环境、服务名称、文件后缀名。这些决定了程序启动时去nacos读取哪个文件
5.6.2 配置热更新
我们最终的目的,是修改nacos中的配置后,微服务中无需重启即可让配置生效,也就是配置热更新。
要实现配置热更新,可以使用两种方式:
方式1
在@Value注入的变量所在类上添加注解@RefreshScope:
方式2
使用@ConfigurationProperties注解代替@Value注解。
- 在user-service服务中,添加一个类,读取patterrn.dateformat属性:
1 |
|
注:我此处的程序报了一个错误:Spring Boot Configuration Annotation Processor not found in claspath
解决方法:把spring-boot-configuration-processor依赖加入到pom.xml文件中
1 | <dependency> |
- 在UserController中使用这个类代替@Value:
总结
Nacos配置更改后,微服务可以实现热更新,方式:
- 通过@Value注解注入,结合@RefreshScope来刷新
- 通过@ConfigurationProperties注入,自动刷新
注意事项:
- 不是所有的配置都适合放到配置中心,维护起来比较麻烦
- 建议将一些关键参数,需要运行时调整的参数放到nacos配置中心,一般都是自定义配置
5.6.3 配置共享
其实微服务启动时,会去nacos读取多个配置文件,例如:
[spring.application.name]-[spring.profiles.active].yaml
,例如:userservice-dev.yaml[spring.application.name].yaml
,例如:userservice.yaml
而[spring.application.name].yaml
不包含环境,因此可以被多个环境共享。
1) 添加一个环境共享配置
我们在nacos中添加一个userservice.yaml文件:
结果:
2) 在user-service中读取共享配置
- 在user-service服务中,修改PatternProperties类,读取新添加的属性:
1 |
|
- 在user-service服务中,修改UserController,添加一个方法:
1 |
|
3) 运行两个UserApplication,使用不同的profile
例如userservice运行dev环境不变,userservice1使用test(active)环境运行:
这样,UserApplication(8081)使用的profile是dev,UserApplication2(8082)使用的profile是test。
启动UserApplication和UserApplication2,访问http://localhost:8081/user/prop,结果:
访问http://localhost:8082/user/prop,结果:
可以看出来,不管是dev,还是test环境,都读取到了envSharedValue这个属性的值。
4) 配置共享的优先级
当nacos、服务本地同时出现相同属性时,优先级有高低之分:
即nacos的配置优先级大于本地配置的优先级。
5.7 搭建Nacos集群
集群搭建步骤
搭建MySQL集群并初始化数据库表
下载解压nacos
修改集群配置(节点信息)、数据库配置
分别启动多个nacos节点
nginx反向代理
5.7.1 集群结构图
官方给出的Nacos集群图:
其中包含3个nacos节点,然后一个负载均衡器代理3个Nacos。这里负载均衡器可以使用nginx。
我们计划的集群结构:
三个nacos节点的地址:
节点 | ip | port |
---|---|---|
nacos1 | 192.168.150.1 | 8845 |
nacos2 | 192.168.150.1 | 8846 |
nacos3 | 192.168.150.1 | 8847 |
5.7.2 搭建集群
1) 初始化数据库
Nacos默认数据存储在内嵌数据库Derby中,不属于生产可用的数据库。
官方推荐的最佳实践是使用带有主从的高可用数据库集群,主从模式的高可用数据库可以参考传智教育的后续高手课程。
这里我们以单点的数据库为例来讲解。
首先新建一个数据库,命名为nacos,而后导入下面的SQL:
1 | CREATE TABLE `config_info` ( |
2) 配置Nacos
将这个包解压到任意非中文目录下,进入nacos的conf目录,修改配置文件cluster.conf.example,重命名为cluster.conf:
然后添加内容:
1 | 127.0.0.1:8845 |
然后修改application.properties文件,添加数据库配置:
1 | mysql = |
3) 启动
将nacos文件夹复制三份,分别命名为:nacos1、nacos2、nacos3
然后分别修改三个文件夹中的application.properties,
nacos1:
1 | 8845 = |
nacos2:
1 | 8846 = |
nacos3:
1 | 8847 = |
然后分别启动三个nacos节点:
1 | startup.cmd |
4) nginx反向代理
修改conf/nginx.conf文件,配置如下:
1 | upstream nacos-cluster { |
而后在浏览器访问:http://localhost/nacos即可。
代码中application.yml文件配置如下:
1 | spring: |
5) 优化
实际部署时,需要给做反向代理的nginx服务器设置一个域名,这样后续如果有服务器迁移nacos的客户端也无需更改配置.
Nacos的各个节点应该部署到多个不同服务器,做好容灾和隔离
6 Feign远程调用
RestTemplate方式调用存在的问题
先来看我们以前利用RestTemplate发起远程调用的代码:
1 | String url = "http://userservice/user/" + order.getUserId(); |
存在下面的问题:
代码可读性差,编程体验不统一
参数复杂URL难以维护
解决
Feign是一个声明式的http客户端,官方地址:https://github.com/OpenFeign/feign
其作用就是帮助我们优雅的实现http请求的发送,解决上面提到的问题。
6.1 Feign代替RestTemplate
- 引入依赖:
我们在order-service服务的pom文件中引入feign的依赖:
1 | <!--feign客户端依赖--> |
- 添加注解:
在order-service的启动类添加注解开启Feign的功能:
1 |
- 编写Feign的客户端
在order-service中新建一个接口,内容如下:
1 |
|
这个客户端主要是基于SpringMVC的注解来声明远程调用的信息,比如:
- 服务名称:userservice
- 请求方式:GET
- 请求路径:/user/{id}
- 请求参数:Long id
- 返回值类型:User
这样,Feign就可以帮助我们发送http请求,无需自己使用RestTemplate来发送了。
- 用Feign客户端代替RestTemplate:
1 |
|
总结——Feign的使用步骤
引入依赖(微服务pom.xml)
添加@EnableFeignClients注解(启动类)
编写FeignClient接口
使用FeignClient中定义的方法代替RestTemplate(业务层)
6.2 自定义配置
Feign可以支持很多的自定义配置,如下表所示:
类型 | 作用 | 说明 |
---|---|---|
feign.Logger.Level | 修改日志级别 | 包含四种不同的级别:NONE、BASIC、HEADERS、FULL |
feign.codec.Decoder | 响应结果的解析器 | http远程调用的结果做解析,例如解析json字符串为java对象 |
feign.codec.Encoder | 请求参数编码 | 将请求参数编码,便于通过http请求发送 |
feign. Contract | 支持的注解格式 | 默认是SpringMVC的注解 |
feign. Retryer | 失败重试机制 | 请求失败的重试机制,默认是没有,不过会使用Ribbon的重试 |
一般情况下,默认值就能满足我们使用,如果要自定义时,只需要创建自定义的@Bean覆盖默认Bean即可。
日志的级别分为四种:
- NONE:不记录任何日志信息,这是默认值。
- BASIC:仅记录请求的方法,URL以及响应状态码和执行时间
- HEADERS:在BASIC的基础上,额外记录了请求和响应的头信息
- FULL:记录所有请求和响应的明细,包括头信息、请求体、元数据。
6.2.1 配置文件方式
基于配置文件修改feign的日志级别可以针对单个服务:
1 | feign: |
也可以针对所有服务:
1 | feign: |
6.2.2 Java代码方式
也可以基于Java代码来修改日志级别,先声明一个类,然后声明一个Logger.Level的对象:
1 | public class DefaultFeignConfiguration { |
- 如果要全局生效,将其放到启动类的@EnableFeignClients这个注解中:
1 |
- 如果是局部生效,则把它放到对应的@FeignClient这个注解中:
1 |
6.3 Feign优化
优化Feign的性能
使用连接池代替默认的URLConnection
日志级别,最好用basic或none
Feign底层发起http请求,依赖于其它的框架。其底层客户端实现包括:
URLConnection:默认实现,不支持连接池
Apache HttpClient :支持连接池
OKHttp:支持连接池
因此提高Feign的性能主要手段就是使用连接池代替默认的URLConnection。这里我们用Apache的HttpClient来演示。
- 引入依赖:在order-service的pom文件中引入Apache的HttpClient依赖:
1 | <!--Apache的HttpClient依赖--> |
- 配置连接池
在order-service的application.yml中添加配置:
1 | feign: |
此外的可配置的选项:
6.4 Feign最佳实践
观察可以发现,Feign的客户端(UserClient)与服务提供者的controller代码非常相似:
有没有一种办法简化这种重复的代码编写呢?
6.4.1 继承方式
一样的代码可以通过继承来共享:
定义一个API接口,利用定义方法,并基于SpringMVC注解做声明。
Feign客户端和Controller都继承该接口
优点
- 实现简单
- 实现了代码共享
缺点
服务提供方、服务消费方紧耦合
参数列表中的注解映射并不会继承,因此Controller中必须再次声明方法、参数列表、注解
6.4.2 抽取方式
将Feign的Client抽取为独立模块,并且把接口有关的POJO、默认的Feign配置都放到这个模块中,提供给所有消费者使用。
例如,将UserClient、User、Feign的默认配置都抽取到一个feign-api包中,所有微服务引用该依赖包,即可直接使用。
6.4.3 抽取方式的实现
1) 抽取
首先创建一个module,命名为feign-api
,在feign-api中引入feign的starter依赖。
1 | <dependency> |
然后,order-service中编写的UserClient、User、DefaultFeignConfiguration都复制到feign-api项目中,最后的项目结构:
2) 在order-service中使用feign-api
首先,删除order-service中的UserClient、User、DefaultFeignConfiguration等类或接口。
在order-service的pom文件中中引入feign-api的依赖:
1 | <!--引入feign的统一api--> |
修改order-service中的所有与上述三个组件有关的导包部分,改成导入feign-api中的包。
3) 解决扫描包问题
重启后,发现服务报错了:
这是因为UserClient现在在cn.itcast.feign.clients包下,
而order-service的@EnableFeignClients注解是在cn.itcast.order包下,不在同一个包,无法扫描到UserClient。
以下两种方式都是在orderservice的启动类上添加注解。
方式一
指定Feign应该扫描的包:
1 |
方式二(推荐)
指定需要加载的Client接口:
1 |
1 |
|
7 Gateway服务网关
Spring Cloud Gateway 是 Spring Cloud 的一个全新项目,该项目是基于 Spring 5.0,Spring Boot 2.0 和 Project Reactor 等响应式编程和事件流技术开发的网关,它旨在为微服务架构提供一种简单有效的统一的 API 路由管理方式。
7.1 为什么需要网关
Gateway网关是我们服务的守门神,所有微服务的统一入口。
网关的核心功能特性:
对用户请求做身份认证、权限校验:网关作为微服务入口,需要校验用户是是否有请求资格,如果没有则进行拦截。
将用户请求路由到微服务,并实现负载均衡:一切请求都必须先经过gateway,但网关不处理业务,而是根据某种规则,把请求转发到某个微服务,这个过程叫做路由。当然路由的目标服务有多个时,还需要做负载均衡。
对用户请求做限流:当请求流量过高时,在网关中按照下流的微服务能够接受的速度来放行请求,避免服务压力过大。
在SpringCloud中网关的实现包括两种:
gateway
zuul
Zuul是基于Servlet的实现,属于阻塞式编程。而SpringCloudGateway则是基于Spring5中提供的WebFlux,属于响应式编程的实现,具备更好的性能。
7.2 快速入门
7.2.1 项目搭建及测试
下面,我们就演示下网关的基本路由功能。基本步骤如下:
- 创建SpringBoot工程gateway,引入网关依赖
- 编写启动类
- 编写基础配置和路由规则
- 启动网关服务进行测试
创建SpringBoot工程gateway,引入网关依赖
引入依赖:
1 | <!--网关gateway依赖--> |
编写启动类
1 |
|
编写基础配置和路由规则
1 | server: |
我们将符合Path
规则的一切请求,都代理到 uri
参数指定的地址。
本例中,我们将 /user/**
开头的请求,代理到lb://userservice
,lb是负载均衡,根据服务名拉取服务列表,实现负载均衡。
测试
重启网关,访问http://localhost:10010/user/1时,符合`/user/**`规则,请求转发到uri:http://userservice/user/1,得到了结果:
7.2.2 网关路由流程图
整个访问的流程如下:
总结
网关搭建步骤:
创建项目,引入nacos服务发现和gateway依赖
配置application.yml,包括服务基本信息、nacos地址、路由
路由配置包括:
- 路由id:路由的唯一标示
- 路由目标(uri):路由的目标地址,http代表固定地址,lb代表根据服务名负载均衡
- 路由断言(predicates):判断路由的规则,
- 路由过滤器(filters):对请求或响应做处理
7.3 断言工厂 Predicate Factory
predicates:路由断言,判断请求是否符合要求,符合则转发到路由目的地
我们在配置文件中写的断言规则只是字符串,这些字符串会被Predicate Factory读取并处理,转变为路由判断的条件。例如Path=/user/**
是按照路径匹配,这个规则是由org.springframework.cloud.gateway.handler.predicate.PathRoutePredicateFactory
类来处理的,像这样的断言工厂在SpringCloudGateway还有十几个:
名称 | 说明 | 示例 |
---|---|---|
After | 是某个时间点后的请求 | - After=2037-01-20T17:42:47.789-07:00[America/Denver] |
Before | 是某个时间点之前的请求 | - Before=2031-04-13T15:14:47.433+08:00[Asia/Shanghai] |
Between | 是某两个时间点之前的请求 | - Between=2037-01-20T17:42:47.789-07:00[America/Denver], 2037-01-21T17:42:47.789-07:00[America/Denver] |
Cookie | 请求必须包含某些cookie | - Cookie=chocolate, ch.p |
Header | 请求必须包含某些header | - Header=X-Request-Id, \d+ |
Host | 请求必须是访问某个host(域名) | - Host=.somehost.org,.anotherhost.org |
Method | 请求方式必须是指定方式 | - Method=GET,POST |
Path | 请求路径必须符合指定规则 | - Path=/red/{segment},/blue/** |
Query | 请求参数必须包含指定参数 | - Query=name, Jack或者- Query=name |
RemoteAddr | 请求者的ip必须是指定范围 | - RemoteAddr=192.168.1.1/24 |
Weight | 权重处理 |
我们只需要掌握Path这种路由工程就可以了。
7.4 过滤器工厂 Gateway Filter
GatewayFilter是网关中提供的一种过滤器,可以对进入网关的请求和微服务返回的响应做处理:
7.4.1 路由过滤器的种类
Spring提供了31种不同的路由过滤器工厂。例如:
名称 | 说明 |
---|---|
AddRequestHeader | 给当前请求添加一个请求头 |
RemoveRequestHeader | 移除请求中的一个请求头 |
AddResponseHeader | 给响应结果中添加一个响应头 |
RemoveResponseHeader | 从响应结果中移除有一个响应头 |
RequestRateLimiter | 限制请求的流量 |
… |
7.4.2 请求头过滤器
以AddRequestHeader 为例来讲解。
需求:给所有进入userservice的请求添加一个请求头:Truth=you are good!
解决
只需要修改gateway服务的application.yml文件,添加路由过滤即可:
1 | spring: |
当前过滤器写在userservice路由下,因此仅仅对访问userservice的请求有效。
同时修改userservice业务层代码,接收这一参数:
1 |
|
执行结果:
7.4.3 默认过滤器
如果要对所有的路由都生效,则可以将过滤器工厂写到routes同级下。格式如下:
1 | spring: |
7.4.4 全局过滤器
上一节学习的过滤器,网关提供了31种,但每一种过滤器的作用都是固定的。如果我们希望拦截请求,做自己的业务逻辑则没办法实现。
1) 全局过滤器作用
全局过滤器的作用也是处理一切进入网关的请求和微服务响应,与GatewayFilter的作用一样。区别在于GatewayFilter通过配置定义,处理逻辑是固定的;而GlobalFilter的逻辑需要自己写代码实现。
定义方式是实现GlobalFilter接口:
1 | public interface GlobalFilter { |
在filter中编写自定义逻辑,可以实现下列功能:
- 登录状态判断
- 权限校验
- 请求限流等
2) 自定义全局过滤器
需求:定义全局过滤器,拦截请求,判断请求的参数是否满足下面条件:
参数中是否有authorization,
authorization参数值是否为admin
如果同时满足则放行,否则拦截。
实现
在gateway中定义一个过滤器:
1 |
|
测试结果
访问http://localhost:10010/user/1
访问http://localhost:10010/user/1?authorization=admin
7.4.5 过滤器执行顺序
请求进入网关会碰到三类过滤器:当前路由的过滤器、DefaultFilter、GlobalFilter
请求路由后,会将当前路由过滤器和DefaultFilter、GlobalFilter,合并到一个过滤器链(集合)中,排序后依次执行每个过滤器:
排序的规则
- 每一个过滤器都必须指定一个int类型的order值,order值越小,优先级越高,执行顺序越靠前。
- GlobalFilter通过实现Ordered接口,或者添加@Order注解来指定order值,由我们自己指定
- 路由过滤器和defaultFilter的order由Spring指定,默认是按照声明顺序从1递增。
- 当过滤器的order值一样时,会按照 defaultFilter > 路由过滤器 > GlobalFilter的顺序执行。
详细内容,可以查看源码:
org.springframework.cloud.gateway.route.RouteDefinitionRouteLocator#getFilters()
方法是先加载defaultFilters,然后再加载某个route的filters,然后合并。
org.springframework.cloud.gateway.handler.FilteringWebHandler#handle()
方法会加载全局过滤器,与前面的过滤器合并后根据order排序,组织过滤器链
7.5 跨域问题
跨域:域名不一致就是跨域,主要包括:
域名不同: www.taobao.com 和 www.taobao.org 和 www.jd.com 和 miaosha.jd.com
域名相同,端口不同:localhost:8080和localhost8081
跨域问题:浏览器禁止请求的发起者与服务端发生跨域ajax请求,请求被浏览器拦截的问题。
解决方案:CORS。
7.5.1 模拟跨域问题
index.html
1 |
|
放入tomcat或者nginx这样的web服务器中,启动并访问。
可以在浏览器控制台看到下面的错误:
从localhost:8090访问localhost:10010,端口不同,显然是跨域的请求。
7.5.2 解决跨域问题
在gateway服务的application.yml文件中,添加下面的配置:
1 | spring: |
8 Docker应用部署
8.1 初识Docker
8.1.1 应用部署的环境问题
微服务虽然具备各种各样的优势,但服务的拆分通用给部署带来了很大的麻烦。
- 分布式系统中,依赖的组件非常多,不同组件之间部署时往往会产生一些冲突。
- 在数百上千台服务中重复部署,环境不一定一致,会遇到各种问题
大型项目组件较多,运行环境也较为复杂,部署时会碰到一些问题:
依赖关系复杂,容易出现兼容性问题
开发、测试、生产环境有差异
例如一个项目中,部署时需要依赖于node.js、Redis、RabbitMQ、MySQL等,这些服务部署时所需要的函数库、依赖项各不相同,甚至会有冲突。给部署带来了极大的困难。
Docker解决依赖兼容问题
Docker为了解决依赖的兼容问题的,采用了两个手段:
将应用的Libs(函数库)、Deps(依赖)、配置与应用一起打包
将每个应用放到一个隔离容器去运行,避免互相干扰
这样打包好的应用包中,既包含应用本身,也保护应用所需要的Libs、Deps,无需再操作系统上安装这些,自然就不存在不同应用之间的兼容问题了。
Docker解决操作系统环境差异
要解决不同操作系统环境差异问题,必须先了解操作系统结构。以一个Ubuntu操作系统为例,结构如下:
结构包括:
- 计算机硬件:例如CPU、内存、磁盘等
- 系统内核:所有Linux发行版的内核都是Linux,例如CentOS、Ubuntu、Fedora等。内核可以与计算机硬件交互,对外提供内核指令,用于操作计算机硬件。
- 系统应用:操作系统本身提供的应用、函数库。这些函数库是对内核指令的封装,使用更加方便。
应用于计算机交互的流程如下:
1)应用调用操作系统应用(函数库),实现各种功能
2)系统函数库是对内核指令集的封装,会调用内核指令
3)内核指令操作计算机硬件
Ubuntu和CentOS都是基于Linux内核,无非是系统应用不同,提供的函数库有差异:
此时,如果将一个Ubuntu版本的MySQL应用安装到CentOS系统,MySQL在调用Ubuntu函数库时,会发现找不到或者不匹配,就会报错了:
Docker如何解决不同系统环境的问题?
- Docker将用户程序与所需要调用的系统(比如Ubuntu)函数库一起打包
- Docker运行到不同操作系统时,直接基于打包的函数库,借助于操作系统的Linux内核来运行
如图:
小结
- Docker如何解决大型项目依赖关系复杂,不同组件依赖的兼容性问题?
- Docker允许开发中将应用、依赖、函数库、配置一起打包,形成可移植镜像
- Docker应用运行在容器中,使用沙箱机制,相互隔离
- Docker如何解决开发、测试、生产环境有差异的问题?
- Docker镜像中包含完整运行环境,包括系统函数库,仅依赖系统的Linux内核,因此可以在任意Linux操作系统上运行
- Docker是一个快速交付应用、运行应用的技术,具备下列优势:
- 可以将程序及其依赖、运行环境一起打包为一个镜像,可以迁移到任意Linux操作系统
- 运行时利用沙箱机制形成隔离容器,各个应用互不干扰
- 启动、移除都可以通过一行命令完成,方便快捷
8.1.2 Docker和虚拟机的区别
Docker可以让一个应用在任何操作系统中非常方便的运行。而以前我们接触的虚拟机,也能在一个操作系统中,运行另外一个操作系统,保护系统中的任何应用。
两者有什么差异呢?
虚拟机(virtual machine)是在操作系统中模拟硬件设备,然后运行另一个操作系统,比如在 Windows 系统里面运行 Ubuntu 系统,这样就可以运行任意的Ubuntu应用了。
Docker仅仅是封装函数库,并没有模拟完整的操作系统,如图:
对比来看:
特性 | Docker | 虚拟机 |
---|---|---|
性能 | 接近原生 | 性能较差 |
硬盘占用 | 一般为 MB | 一般为GB |
启动 | 秒级 | 分钟级 |
小结——Docker和虚拟机的差异
docker是一个系统进程;虚拟机是在操作系统中的操作系统
docker体积小、启动速度快、性能好;虚拟机体积大、启动速度慢、性能一般
8.1.3 Docker架构
1) 镜像和容器
镜像(Image):Docker将应用程序及其所需的依赖、函数库、环境、配置等文件打包在一起,称为镜像。
容器(Container):镜像中的应用程序运行后形成的进程就是容器,只是Docker会给容器进程做隔离,对外不可见。
一切应用最终都是代码组成,都是硬盘中的一个个的字节形成的文件。只有运行时,才会加载到内存,形成进程。而镜像,就是把一个应用在硬盘上的文件、及其运行环境、部分系统函数库文件一起打包形成的文件包。这个文件包是只读的。容器,就是将这些文件中编写的程序、函数加载到内存中允许,形成进程,只不过要隔离起来。因此一个镜像可以启动多次,形成多个容器进程。
例如你下载了一个QQ,如果我们将QQ在磁盘上的运行文件及其运行的操作系统依赖打包,形成QQ镜像。然后你可以启动多次,双开、甚至三开QQ,跟多个妹子聊天。
2) DockerHub
开源应用程序非常多,打包这些应用往往是重复的劳动。为了避免这些重复劳动,人们就会将自己打包的应用镜像,例如Redis、MySQL镜像放到网络上,共享使用,就像GitHub的代码共享一样。
DockerHub
:DockerHub是一个官方的Docker镜像的托管平台。这样的平台称为Docker Registry
。
我们一方面可以将自己的镜像共享到DockerHub,另一方面也可以从DockerHub拉取镜像:
3) Docker架构
我们要使用Docker来操作镜像、容器,就必须要安装Docker。
Docker是一个CS架构的程序,由两部分组成:
服务端(server):Docker守护进程,负责处理Docker指令,管理镜像、容器等
客户端(client):通过命令或RestAPI向Docker服务端发送指令。可以在本地或远程向服务端发送指令。
如图:
8.1.4 安装Docker
企业部署一般都是采用Linux操作系统,而其中又数CentOS发行版占比最多,因此我们在CentOS下安装Docker。
常用命令
1 | systemctl start docker # 启动docker服务 |
部分截图
8.2 Docker基本操作
8.2.1 镜像操作
1) 镜像名称
镜像名称一般分两部分组成:[repository]:[tag]
。
在没有指定tag时,默认是latest
,代表最新版本的镜像
这里的mysql就是repository,5.7就是tag,合一起就是镜像名称,代表5.7版本的MySQL镜像。
2) 镜像命令
常见的镜像操作命令如图:
3) 拉取和查看镜像
- 首先去镜像仓库搜索nginx镜像,比如DockerHub:
- 根据查看到的镜像名称,拉取自己需要的镜像,通过命令
1 | docker pull nginx |
- 查看拉取到的镜像,通过命令:
1 | docker images |
4) 保存和导入镜像
- 利用
docker xx --help
命令可以查看docker save和docker load的语法:
1 | docker save --help |
可以知道命令格式为:
1 | docker save -o nginx.tar nginx:latest |
- 保存镜像到本地,通过命令:
1 | docker save -o nginx.tar nginx:latest |
- 删除镜像,通过命令:
1 | docker rmi nginx:latest |
- 导入本地镜像,通过命令:
1 | docker load -i nginx.tar |
8.2.2 容器操作
1) 概述
容器操作的命令如图:
容器保护三个状态:
- 运行:进程正常运行
- 暂停:进程暂停,CPU不再运行,并不释放内存
- 停止:进程终止,回收进程占用的内存、CPU等资源
其中的命令含义为:
- docker run:创建并运行一个容器,处于运行状态
- docker pause:让一个运行的容器暂停
- docker unpause:让一个容器从暂停状态恢复运行
- docker stop:停止一个运行的容器
docker start:让一个停止的容器再次运行
docker rm:删除一个容器
2) 创建并运行一个容器
去docker hub查看Nginx的容器运行命令:
1 | docker run --name some-nginx -d -p 8080:80 some-content-nginx |
命令解读:
- docker run :创建并运行一个容器
- —name : 给容器起一个名字,比如叫做serv
- -p :将宿主机端口与容器端口映射,冒号左侧是宿主机端口,右侧是容器端口
- -d:后台运行容器
- nginx:镜像名称,例如nginx
这里的-p
参数,是将容器端口映射到宿主机端口。
默认情况下,容器是隔离环境,我们直接访问宿主机的80端口,肯定访问不到容器中的nginx。
现在,将容器的80与宿主机的80关联起来,当我们访问宿主机的80端口时,就会被映射到容器的80,这样就能访问到nginx了:
创建并运行
1 | docker ps # 查看正在运行的容器 |
访问页面:注意是8080端口
日志跟踪
1 | docker logs serv # 打印全部日志,参数是容器名称 |
3) 进入容器和修改文件
需求:进入Nginx容器,修改HTML文件内容,添加“Hello World”
提示:进入容器要用到docker exec
命令。
- 进入容器。进入我们刚刚创建的nginx容器的命令为:
1 | docker exec -it serv bash |
- docker exec :进入容器内部,执行一个命令。exec命令可以进入容器修改文件,但是在容器内修改文件是不推荐的
- -it : 给当前进入的容器创建一个标准输入、输出终端,允许我们与容器交互
- serv:要进入的容器的名称
- bash:进入容器后执行的命令,bash是一个linux终端交互命令
- 进入nginx的HTML所在目录
/usr/share/nginx/html
容器内部会模拟一个独立的Linux文件系统,看起来如同一个linux服务器一样:
nginx的环境、配置、运行文件全部都在这个文件系统中,包括我们要修改的html文件。
查看DockerHub网站中的nginx页面,可以知道nginx的html目录位置在/usr/share/nginx/html
我们执行命令,进入该目录:
1 | cd /usr/share/nginx/html |
查看目录下文件:
- 修改index.html的内容
容器内没有vi命令(nginx容器),无法直接修改,我们用下面的命令来修改:
1 | sed -i -e 's#Welcome to nginx#Hello World#g' -e 's#<head>#<head><meta charset="utf-8">#g' index.html |
再次访问页面:
- 停止容器,并显示所有容器的状态(包括停止运行的容器):
1 | docker stop serv # 停止运行serv容器 |
- 删除容器:
1 | docker rm -f serv # 强制删除正在运行的容器 |
8.2.3 容器数据管理
在之前的nginx案例中,修改nginx的html页面时,需要进入nginx内部。并且因为没有编辑器,修改文件也很麻烦。
这就是因为容器与数据(容器内文件)耦合带来的后果。要解决这个问题,必须将数据与容器解耦,这就要用到数据卷了。
1) 数据卷
数据卷(volume)是一个虚拟目录,指向宿主机文件系统中的某个目录。
数据卷的作用:将容器与数据分离,解耦合,方便操作容器内数据,保证数据安全。
一旦完成数据卷挂载,对容器的一切操作都会作用在数据卷对应的宿主机目录了。
这样,我们操作宿主机的/var/lib/docker/volumes/html
目录,就等于操作容器内的/usr/share/nginx/html
目录了
2) 操作命令
数据卷操作的基本语法如下:
1 | docker volume [COMMAND] |
docker volume命令是数据卷操作,根据命令后跟随的command来确定下一步的操作:
create 创建一个volume
inspect 显示一个或多个volume的信息
ls 列出所有的volume
- ```sh
docker volume ls1
2
3
4
5
- prune 删除未使用的volume
- ```sh
docker volume prumn
- ```sh
rm 删除一个或多个指定的volume
- ```sh
docker volume rm html1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
> 实操
![image-20211230170840728](https://kisugitakumi.oss-cn-chengdu.aliyuncs.com/img5/image-20211230170840728.png)
##### 3) 挂载数据卷案例1
**需求**:创建一个nginx容器,修改容器内的html目录内的index.html内容
**分析**:上个案例中,我们进入nginx容器内部,已经知道nginx的html目录所在位置/usr/share/nginx/html ,我们需要把这个目录挂载到html这个数据卷上,方便操作其中的内容。
**提示**:运行容器时使用 -v 参数挂载数据卷
> 步骤
1. 创建容器并挂载数据卷到容器内的HTML目录
```sh
docker run --name serv -v html:/usr/share/nginx/html -p 8080:80 -d nginx
- ```sh
-v html:/usr/share/nginx/html
:把html数据卷挂载到容器内的/usr/share/nginx/html这个目录中
- 进入html数据卷所在位置,并修改HTML内容
1 | # 查看html数据卷的位置 |
修改index.html文件:
访问页面:
4) 挂载数据卷案例2
容器不仅仅可以挂载数据卷,也可以直接挂载到宿主机目录或者文件上。关联关系如下:
- 带数据卷模式:宿主机目录 —> 数据卷 —-> 容器内目录
- 直接挂载模式:宿主机目录/文件 —-> 容器内目录/文件
语法:目录挂载与数据卷挂载的语法是类似的:
- -v [宿主机目录]:[容器内目录]
- -v [宿主机文件]:[容器内文件]
需求:创建并运行一个MySQL容器,将宿主机目录直接挂载到容器
实现步骤
- 在mysql.tar文件上传到虚拟机,通过load命令加载为镜像
1 | docker load -i mysql.tar |
- 创建目录/tmp/mysql/data
1 | mkdir -p mysql/data |
- 创建目录/tmp/mysql/conf,将hmy.cnf文件上传到/tmp/mysql/conf
1 | mkdir -p mysql/conf |
去DockerHub查阅资料,创建并运行MySQL容器,要求:
挂载/tmp/mysql/data到mysql容器内数据存储目录
挂载/tmp/mysql/conf/hmy.cnf到mysql容器的配置文件
设置MySQL密码
1 | docker run \ |
执行结果
测试连接数据库:
总结
数据卷挂载与目录直接挂载的比较
- 数据卷挂载耦合度低,由docker来管理目录,但是目录较深,不好找
- 目录挂载耦合度高,需要我们自己管理目录,不过目录容易寻找查看
8.3 Dockerfile自定义镜像
常见的镜像在DockerHub就能找到,但是我们自己写的项目就必须自己构建镜像了。
而要自定义镜像,就必须先了解镜像的结构才行。
8.3.1 镜像结构
镜像是将应用程序及其需要的系统函数库、环境、配置、依赖打包而成。
我们以MySQL为例,来看看镜像的组成结构:
镜像是分层结构,每一层称为一个Layer:
BaseImage层:包含基本的系统函数库、环境变量、文件系统
Entrypoint:入口,是镜像中应用启动的命令
其它:在BaseImage基础上添加依赖、安装程序、完成整个应用的安装和配置
简单来说,镜像就是在系统函数库、运行环境基础上,添加应用程序文件、配置文件、依赖文件等组合,然后编写好启动脚本打包在一起形成的文件。
我们要构建镜像,其实就是实现上述打包的过程。
8.3.2 Dockerfile
1) 概述
构建自定义的镜像时,并不需要一个个文件去拷贝,打包。我们只需要告诉Docker,我们的镜像的组成,需要哪些BaseImage、需要拷贝什么文件、需要安装什么依赖、启动脚本是什么,将来Docker会帮助我们构建镜像。
而描述上述信息的文件就是Dockerfile文件。
Dockerfile就是一个文本文件,其中包含一个个的指令(Instruction),用指令来说明要执行什么操作来构建镜像。每一个指令都会形成一层Layer。
指令 | 说明 | 示例 |
---|---|---|
FROM | 指定基础镜像 | FROM centos:6 |
ENV | 设置环境变量,可在后面指令使用 | ENV key value |
COPY | 拷贝本地文件到镜像的指定目录 | COPY ./mysql-5.7.rpm /tmp |
RUN | 执行Linux的shell命令,一般是安装过程的命令 | RUN yum install gcc |
EXPOSE | 指定容器运行时监听的端口,是给镜像使用者看的 | EXPOSE 8080 |
ENTRYPOINT | 镜像中应用的启动命令,容器运行时调用 | ENTRYPOINT java -jar xx.jar |
更新详细语法说明,请参考官网文档: https://docs.docker.com/engine/reference/builder
2) 构建一个Java项目
需求:基于Centos镜像构建一个新镜像,运行一个java项目
步骤
新建一个空文件夹docker-demo
拷贝课前资料中的
docker-demo.jar
,jdk8.tar.gz
,Dockerfile
文件到docker-demo这个目录,其中Dockerfile文件的内容如下:
1 | # 指定基础镜像 |
- 进入docker-demo,将准备好的docker-demo上传到虚拟机任意目录,然后进入docker-demo目录下
- 运行命令:构建镜像
1 | docker build -t javaweb:1.0 . |
- 查看镜像:
- 启动该镜像为容器:
- 访问启动的项目
XXXX:8090/hello/count
:
8.4 Docker-Compose
8.4.1 概述
Docker Compose可以基于Compose文件帮我们快速的部署分布式应用,而无需手动一个个创建和运行容器。
Compose文件是一个文本文件,通过指令定义集群中的每个容器如何运行。格式如下:
1 | version: "3.8" |
上面的Compose文件就描述一个项目,其中包含两个容器:
- mysql:一个基于
mysql:5.7.25
镜像构建的容器,并且挂载了两个目录 - web:一个基于
docker build
临时构建的镜像容器,映射端口是8090
DockerCompose的详细语法参考官网:https://docs.docker.com/compose/compose-file/
其实DockerCompose文件可以看做是将多个docker run命令写到一个文件,只是语法稍有差异。
8.4.2 安装DockerCompose
略
8.4.3 部署微服务集群 (暂时未实践)
需求:将之前学习的cloud-demo微服务集群利用DockerCompose部署
实现步骤:
查看课前资料提供的cloud-demo文件夹,里面已经编写好了docker-compose文件
修改自己的cloud-demo项目,将数据库、nacos地址都命名为docker-compose中的服务名
使用maven打包工具,将项目中的每个微服务都打包为app.jar
将打包好的app.jar拷贝到cloud-demo中的每一个对应的子目录中
将cloud-demo上传至虚拟机,利用 docker-compose up -d 来部署
1) Compose文件
- 查看课前资料提供的cloud-demo文件夹,里面已经编写好了docker-compose文件,而且每个微服务都准备了一个独立的目录:
docker-compose.yml文件内容如下:
1 | version: "3.2" |
可以看到,其中包含5个service服务:
nacos
:作为注册中心和配置中心image: nacos/nacos-server
: 基于nacos/nacos-server镜像构建environment
:环境变量MODE: standalone
:单点模式启动
ports
:端口映射,这里暴露了8848端口
mysql
:数据库image: mysql:5.7.25
:镜像版本是mysql:5.7.25environment
:环境变量MYSQL_ROOT_PASSWORD: 123
:设置数据库root账户的密码为123
volumes
:数据卷挂载,这里挂载了mysql的data、conf目录,其中有提前准备好的数据
userservice
、orderservice
、gateway
:都是基于Dockerfile临时构建的
- 查看mysql目录,可以看到其中已经准备好了cloud_order、cloud_user表:
- 查看微服务目录,可以看到都包含Dockerfile文件,内容如下:
1 | FROM java:8-alpine |
2) 修改微服务配置
因为微服务将来要部署为docker容器,而容器之间互联不是通过IP地址,而是通过容器名。这里我们将order-service、user-service、gateway服务的mysql、nacos地址都修改为基于容器名的访问。
如下所示:
1 | spring: |
3) 打包
接下来需要将我们的每个微服务都打包。因为之前查看到Dockerfile中的jar包名称都是app.jar,因此我们的每个微服务都需要用这个名称。
可以通过修改pom.xml中的打包名称来实现,每个微服务都需要修改:
1 | <build> |
4) 拷贝jar包到部署目录
编译打包好的app.jar文件,需要放到Dockerfile的同级目录中。注意:每个微服务的app.jar放到与服务名称对应的目录,别搞错了。
5) 部署
最后,我们需要将文件整个cloud-demo文件夹上传到远端服务器中,由DockerCompose部署。
然后进入cloud-demo目录,然后运行下面的命令:
1 | docker-compose up -d |
8.5 Docker镜像仓库
镜像仓库( Docker Registry )有公共的和私有的两种形式:
公共仓库:例如Docker官方的 Docker Hub,国内也有一些云服务商提供类似于 Docker Hub 的公开服务,比如 网易云镜像服务、DaoCloud 镜像服务、阿里云镜像服务等。
除了使用公开仓库外,用户还可以在本地搭建私有 Docker Registry。企业自己的镜像最好是采用私有Docker Registry来实现。
9 RabbitMQ消息队列
9.1 初识 MQ
9.1.1 同步通讯和异步通讯
微服务间通讯有同步和异步两种方式:
同步通讯:就像打电话,需要实时响应。
异步通讯:就像发邮件,不需要马上回复。
两种方式各有优劣,打电话可以立即得到响应,但是你却不能跟多个人同时通话。发送邮件可以同时与多个人收发邮件,但是往往响应会有延迟。
1) 同步通讯
我们之前学习的Feign调用就属于同步方式,虽然调用可以实时得到结果,但存在下面的问题:
总结
同步调用的优点:
- 时效性较强,可以立即得到结果
同步调用的问题:
- 耦合度高
- 性能和吞吐能力下降
- 有额外的资源消耗
- 有级联失败问题
2) 异步通讯
异步调用则可以避免上述问题:
我们以购买商品为例,用户支付后需要调用订单服务完成订单状态修改,调用物流服务,从仓库分配响应的库存并准备发货。
在事件模式中,支付服务是事件发布者(publisher),在支付完成后只需要发布一个支付成功的事件(event),事件中带上订单id。
订单服务和物流服务是事件订阅者(Consumer),订阅支付成功的事件,监听到事件后完成自己业务即可。
为了解除事件发布者与订阅者之间的耦合,两者并不是直接通信,而是有一个中间人(Broker)。发布者发布事件到Broker,不关心谁来订阅事件。订阅者从Broker订阅事件,不关心谁发来的消息。
Broker 是一个像数据总线一样的东西,所有的服务要接收数据和发送数据都发到这个总线上,这个总线就像协议一样,让服务间的通讯变得标准和可控。
好处
吞吐量提升:无需等待订阅者处理完成,响应更快速
故障隔离:服务没有直接调用,不存在级联失败问题
- 调用间没有阻塞,不会造成无效的资源占用
- 耦合度极低,每个服务都可以灵活插拔,可替换
- 流量削峰:不管发布事件的流量波动多大,都由Broker接收,订阅者可以按照自己的速度去处理事件
缺点
- 架构复杂了,业务没有明显的流程线,不好管理
- 需要依赖于Broker的可靠、安全、性能
现在开源软件或云平台上 Broker 的软件是非常成熟的,比较常见的一种就是我们今天要学习的MQ技术。
9.1.2 MQ技术对比
MQ,中文是消息队列(Message Queue),字面来看就是存放消息的队列。也就是事件驱动架构中的Broker。
比较常见的MQ实现:
- ActiveMQ
- RabbitMQ
- RocketMQ
- Kafka
技术对比
RabbitMQ | ActiveMQ | RocketMQ | Kafka | |
---|---|---|---|---|
公司/社区 | Rabbit | Apache | 阿里 | Apache |
开发语言 | Erlang | Java | Java | Scala&Java |
协议支持 | AMQP,XMPP,SMTP,STOMP | OpenWire,STOMP,REST,XMPP,AMQP | 自定义协议 | 自定义协议 |
可用性 | 高 | 一般 | 高 | 高 |
单机吞吐量 | 一般 | 差 | 高 | 非常高 |
消息延迟 | 微秒级 | 毫秒级 | 毫秒级 | 毫秒以内 |
消息可靠性 | 高 | 一般 | 高 | 一般 |
追求可用性:Kafka、 RocketMQ 、RabbitMQ
追求可靠性:RabbitMQ、RocketMQ
追求吞吐能力:RocketMQ、Kafka
追求消息低延迟:RabbitMQ、Kafka
9.2 部署和概述
9.2.1 RabbitMQ部署
RabbitMQ是基于Erlang语言开发的开源消息通信中间件,官网地址:https://www.rabbitmq.com/
1) 单机部署
我们在Centos7虚拟机中使用Docker来安装。
下载镜像
- 方法1:在线拉取镜像
1 | docker pull rabbitmq:3-management |
- 从本地加载
1 | docker load -i mq.tar |
安装MQ
执行下面的命令来运行MQ容器:
1 | docker run \ |
进入可视化管理界面:
2) 集群部署
在RabbitMQ的官方文档中,讲述了两种集群的配置方式:
- 普通模式:普通模式集群不进行数据同步,每个MQ都有自己的队列、数据信息(其它元数据信息如交换机等会同步)。例如我们有2个MQ:mq1,和mq2,如果你的消息在mq1,而你连接到了mq2,那么mq2会去mq1拉取消息,然后返回给你。如果mq1宕机,消息就会丢失。
- 镜像模式:与普通模式不同,队列会在各个mq的镜像节点之间同步,因此你连接到任何一个镜像节点,均可获取到消息。而且如果一个节点宕机,并不会导致数据丢失。不过,这种方式增加了数据同步的带宽消耗。
步骤
首先,我们需要让3台MQ互相知道对方的存在。
分别在3台机器中,设置 /etc/hosts文件,添加如下内容:
1 | 192.168.150.101 mq1 |
并在每台机器上测试,是否可以ping通对方即可。
3) MQ基本结构
RabbitMQ中的一些角色:
- publisher:生产者
- consumer:消费者
- exchange:交换机,负责消息路由
- queue:队列,存储消息
- virtualHost:虚拟主机(逻辑分组),隔离不同租户的exchange、queue、消息的隔离
9.2.2 RabbitMQ消息模型
RabbitMQ官方提供了6个不同的Demo示例,对应了不同的消息模型。
- 基本消息队列(BasicQueue)
- 工作消息队列(WorkQueue)
发布订阅(publish,subscribe),又根据交换机类型不同分为4种:
广播Fanout exchange:
路由Direct Exchange:
主题Topic Exchange:
RPC
9.3 入门案例
官方的HelloWorld是基于最基础的消息队列模型来实现的,只包括三个角色:
publisher:消息发布者,将消息发送到队列queue
queue:消息队列,负责接受并缓存消息
consumer:订阅队列,处理队列中的消息
9.3.1 导入demo工程
课前资料提供了一个Demo工程,mq-demo,导入后可以看到结构如下:
包括三部分:
- mq-demo:父工程,管理项目依赖
- publisher:消息的发送者
- consumer:消息的消费者
9.3.2 发布者流程
- 建立连接
- 创建Channel
- 声明队列
- 发送消息
- 关闭连接和channel
代码
1 | public class PublisherTest { |
执行流程
- 建立连接
- 创建通道Channel
- 创建队列simple.queue
- 发送消息和在可视化界面查看消息
- 控制台结果:
9.3.3 接收者流程
- 建立连接
- 创建Channel
- 声明队列(保险措施)
- 订阅消息
代码
1 | public class ConsumerTest { |
执行流程
- 建立连接
- 创建通道channel
- 声明队列(保险措施)
订阅消息
控制台执行结果:
9.3.4 流程总结
基本消息队列的消息发送流程:
- 建立connection
- 创建channel
- 利用channel声明队列
- 利用channel向队列发送消息
基本消息队列的消息接收流程:
- 建立connection
- 创建channel
- 利用channel声明队列
- 定义consumer的消费行为handleDelivery()
- 利用channel将消费者与队列绑定
9.4 SpringAMQP
9.4.1 概述
SpringAMQP是基于RabbitMQ封装的一套模板,并且还利用SpringBoot对其实现了自动装配,使用起来非常方便。
SpringAMQP的官方地址:https://spring.io/projects/spring-amqp
SpringAMQP提供了三个功能:
- 自动声明队列、交换机及其绑定关系
- 基于注解的监听器模式,异步接收消息
- 封装了RabbitTemplate工具,用于发送消息
9.4.2 Basic Queue 简单队列模型
在父工程mq-demo中引入SpringAMQP依赖:
1 | <!--AMQP依赖,包含RabbitMQ--> |
1) 消息发送
- 首先配置MQ地址,在publisher服务的application.yml中添加配置:
1 | spring: |
- 在publisher服务中编写测试类SpringAmqpTest,并利用RabbitTemplate实现消息发送:
1 |
|
执行结果:
2) 接收消息
首先配置MQ地址,在consumer服务的application.yml中添加配置(同publisher)。
在consumer服务的
cn.itcast.mq.listener
包中新建一个类SpringRabbitListener,代码如下:
1 | public class SpringRabbitListener { |
- 启动consumer服务,即可接收到消息
simple.queue的状态:
可见消息已被取走,消息队列为空。
9.4.3 WorkQueue 工作队列模型
Work queues,也被称为(Task queues),任务模型。简单来说就是让多个消费者绑定到一个队列,共同消费队列中的消息。
当消息处理比较耗时的时候,可能生产消息的速度会远远大于消息的消费速度。长此以往,消息就会堆积越来越多,无法及时处理。
此时就可以使用work 模型,多个消费者共同处理消息处理,速度就能大大提高了。
1) 发送和接收
- 发送
这次我们循环发送,模拟大量消息堆积现象。
在publisher服务中的SpringAmqpTest类中添加一个测试方法:
1 | /** |
- 接收
要模拟多个消费者绑定同一个队列,我们在consumer服务的SpringRabbitListener中添加2个新的方法:
1 |
|
- 测试:打印结果
总耗时:5s
启动ConsumerApplication后,在执行publisher服务中刚刚编写的发送测试方法testWorkQueue。
可以看到消费者1很快完成了自己的25条消息。消费者2却在缓慢的处理自己的25条消息。也就是说消息是平均分配给每个消费者,并没有考虑到消费者的处理能力。这样显然是有问题的。
2) 能者多劳
消费预取限制:在spring中有一个简单的配置,可以解决这个问题。我们修改consumer服务的application.yml文件,添加配置,设置preFetch这个值,可以控制预取消息的上限:
1 | spring: |
- 测试
总耗时:3s,这样处理使得消息大部分交由处理得快的消费者consumer1来消费。
9.4.4 发布/订阅模型
发布订阅的模型如图:
可以看到,在订阅模型中,多了一个exchange角色,而且过程略有变化:
- Publisher:生产者,也就是要发送消息的程序,但是不再发送到队列中,而是发给exchange(交换机)
- Exchange:交换机。一方面,接收生产者发送的消息。另一方面,知道如何处理消息,例如递交给某个特别队列、递交给所有队列、或是将消息丢弃。到底如何操作,取决于Exchange的类型。Exchange有以下3种类型:
- Fanout:广播,将消息交给所有绑定到交换机的队列
- Direct:定向,把消息交给符合指定routing key 的队列
- Topic:通配符,把消息交给符合routing pattern(路由模式) 的队列
- Consumer:消费者,与以前一样,订阅队列,没有变化
- Queue:消息队列也与以前一样,接收消息、缓存消息。
Exchange(交换机)只负责转发消息,不具备存储消息的能力,因此如果没有任何队列与Exchange绑定,或者没有符合路由规则的队列,那么消息会丢失!
9.4.5 Fanout
Fanout,英文翻译是扇出,我觉得在MQ中叫广播更合适。
在广播模式下,消息发送流程是这样的:
- 可以有多个队列queue
- 每个队列都要绑定到Exchange(交换机)
- 生产者发送的消息,只能发送到交换机,交换机来决定要发给哪个队列,生产者无法决定
- 交换机把消息发送给绑定过的所有队列
- 订阅队列的消费者都能拿到消息
我们的计划是这样的:
- 创建一个交换机 itcast.fanout,类型是Fanout
- 创建两个队列fanout.queue1和fanout.queue2,绑定到交换机itcast.fanout
1) 声明队列和交换机
Spring提供了一个接口Exchange,来表示所有不同类型的交换机:
在consumer中创建一个类,声明队列和交换机:
1 |
|
启动结果:
- 新增了一个交换机
itcast.fanout
- 新增了两个队列
- 查看绑定关系
2) 消息发送
在publisher服务的SpringAmqpTest类中添加测试方法:
1 |
|
3) 消息接收
在consumer服务的SpringRabbitListener中添加两个方法,作为消费者:
1 | // 监听两个队列 |
执行结果:
小结
交换机的作用是什么?
- 接收publisher发送的消息
- 将消息按照规则路由到与之绑定的队列
- 不能缓存消息,路由失败,消息丢失
- FanoutExchange的会将消息路由到每个绑定的队列
声明队列、交换机、绑定关系的Bean是什么?
- Queue
- FanoutExchange
- Binding
9.4.6 Direct
在Fanout模式中,一条消息,会被所有订阅的队列都消费。但是,在某些场景下,我们希望不同的消息被不同的队列消费。这时就要用到Direct类型的Exchange。
在Direct模型下:
- 队列与交换机的绑定,不能是任意绑定了,而是要指定一个
RoutingKey
(路由key) - 消息的发送方在 向 Exchange发送消息时,也必须指定消息的
RoutingKey
。 - Exchange不再把消息交给每一个绑定的队列,而是根据消息的
Routing Key
进行判断,只有队列的Routingkey
与消息的Routing key
完全一致,才会接收到消息
案例需求如下:
利用@RabbitListener声明Exchange、Queue、RoutingKey
在consumer服务中,编写两个消费者方法,分别监听direct.queue1和direct.queue2
在publisher中编写测试方法,向itcast. direct发送消息
1) 基于注解声明队列和交换机
基于@Bean的方式声明队列和交换机比较麻烦,Spring还提供了基于注解方式来声明。
在consumer的SpringRabbitListener中添加两个消费者,同时基于注解来声明队列和交换机:
1 | // 基于注解,type默认为direct可不写 |
执行结果:
- 查看交换机
- 查看队列
- 绑定关系和routing key
2) 消息发送
在publisher服务的SpringAmqpTest类中添加测试方法:
1 |
|
执行结果:
3) 总结
描述下Direct交换机与Fanout交换机的差异?
- Fanout交换机将消息路由给每一个与之绑定的队列
- Direct交换机根据RoutingKey判断路由给哪个队列
- 如果多个队列具有相同的RoutingKey,则与Fanout功能类似
基于@RabbitListener注解声明队列和交换机有哪些常见注解?
- @Queue
- @Exchange
9.4.7 Topic
Topic
类型的Exchange
与Direct
相比,都是可以根据RoutingKey
把消息路由到不同的队列。只不过Topic
类型Exchange
可以让队列在绑定Routing key
的时候使用通配符!
Routingkey
一般都是有一个或多个单词组成,多个单词之间以”.”分割,例如: item.insert
通配符规则
#
:匹配一个或多个词*
:匹配不多不少恰好1个词
举例:
item.#
:能够匹配item.spu.insert
或者item.spu
item.*
:只能匹配item.spu
图示
案例需求
并利用@RabbitListener声明Exchange、Queue、RoutingKey
在consumer服务中,编写两个消费者方法,分别监听topic.queue1和topic.queue2
在publisher中编写测试方法,向itcast. topic发送消息
1) 消息接收
在consumer服务的SpringRabbitListener中添加方法:
1 | // 接收关于中国的消息 |
- 查看交换机
- 查看队列
2) 发送消息
1 |
|
1 |
|
9.4.8 消息转换器
在SpringAMQP的发送方法中,接收消息的类型是Object,也就是说我们可以发送任意对象类型的消息,SpringAMQP会帮我们序列化为字节后发送。
只不过,默认情况下Spring采用的序列化方式是JDK序列化。众所周知,JDK序列化存在下列问题:
- 数据体积过大
- 有安全漏洞
- 可读性差
1) 测试默认转换器
- 通过@Bean方式声明一个队列object.queue:
1 |
|
- 修改消息发送的代码,发送一个Map对象:
1 |
|
- 发送结果:
2) 配置JSON转换器
Spring对消息对象的处理是由org.springframework.amqp.support.converter.MessageConverter
来处理的。而默认实现是SimpleMessageConverter
,基于JDK的ObjectOutputStream完成序列化。
如果要修改只需要定义一个MessageConverter 类型的Bean即可。推荐用JSON方式序列化,步骤如下:
- 在publisher和consumer两个服务中都引入依赖:
1 | <!--jackson依赖--> |
- 配置消息转换器MessageConverter:在各自的启动类中添加一个Bean即可:
1 |
|
- 发送方发送到队列:
- 接收方接收到的消息:
小结
SpringAMQP中消息的序列化和反序列化利用MessageConverter实现的,默认是JDK的序列化
注意发送方与接收方必须使用相同的MessageConverter
10 ElasticSearch分布式搜索
10.1 了解ES
10.1.1 概述
1) ES的作用
elasticsearch是一款非常强大的开源搜索引擎,具备非常多强大功能,可以帮助我们从海量数据中快速找到需要的内容。
例如:
- 在GitHub搜索代码:
- 在电商网站搜索商品:
- 在谷歌搜索答案
2) ELK技术栈
elasticsearch结合kibana、Logstash、Beats,也就是elastic stack(ELK)。被广泛应用在日志数据分析、实时监控等领域。
而elasticsearch是elastic stack的核心,负责存储、搜索、分析数据。
3) elasticsearch和lucene
elasticsearch底层是基于lucene来实现的。
Lucene是一个Java语言的搜索引擎类库,是Apache公司的顶级项目,由DougCutting于1999年研发。官网地址:https://lucene.apache.org/ 。
elasticsearch的发展历史:
- 2004年Shay Banon基于Lucene开发了Compass
- 2010年Shay Banon 重写了Compass,取名为Elasticsearch。
官网地址: https://www.elastic.co/cn/。目前最新的版本是:7.12.1
相比与lucene,elasticsearch具备下列优势:
支持分布式,可水平扩展
提供Restful接口,可被任何语言调用
4) 为什么不是其他搜索技术?
目前比较知名的搜索引擎技术排名:
虽然在早期,Apache Solr是最主要的搜索引擎技术,但随着发展elasticsearch已经渐渐超越了Solr,独占鳌头:
总结
- 什么是elasticsearch?
一个开源的分布式搜索引擎,可以用来实现搜索、日志统计、分析、系统监控等功能
- 什么是elastic stack(ELK)?
是以elasticsearch为核心的技术栈,包括beats、Logstash、kibana、elasticsearch
- 什么是Lucene?
是Apache的开源搜索引擎类库,提供了搜索引擎的核心API
10.1.2 倒排索引
倒排索引的概念是基于MySQL这样的正向索引而言的。
1)正向索引
例如给下表(tb_goods)中的id创建索引:
如果是根据id查询,那么直接走索引,查询速度非常快。
但如果是基于title做模糊查询,只能是逐行扫描数据,流程如下:
用户搜索数据,条件是title符合
"%手机%"
逐行获取数据,比如id为1的数据
判断数据中的title是否符合用户搜索条件
如果符合则放入结果集,不符合则丢弃。回到步骤1
逐行扫描,也就是全表扫描,随着数据量增加,其查询效率也会越来越低。当数据量达到数百万时,就是一场灾难。
2) 倒排索引
倒排索引中有两个非常重要的概念:
- 文档(
Document
):用来搜索的数据,其中的每一条数据就是一个文档。例如一个网页、一个商品信息 - 词条(
Term
):对文档数据或用户搜索数据,利用某种算法分词,得到的具备含义的词语就是词条。例如:我是中国人,就可以分为:我、是、中国人、中国、国人这样的几个词条
创建倒排索引是对正向索引的一种特殊处理,流程如下:
- 将每一个文档的数据利用算法分词,得到一个个词条
- 创建表,每行数据包括词条、词条所在文档id、位置等信息
- 因为词条唯一性,可以给词条创建索引,例如hash表结构索引
如图:
倒排索引的搜索流程如下(以搜索”华为手机”为例):
用户输入条件
"华为手机"
进行搜索。对用户输入内容分词,得到词条:
华为
、手机
。拿着词条在倒排索引中查找,可以得到包含词条的文档id:1、2、3。
拿着文档id到正向索引中查找具体文档。
虽然要先查询倒排索引,再查询倒排索引,但是无论是词条、还是文档id都建立了索引,查询速度非常快!无需全表扫描。
3) 总结
那么为什么一个叫做正向索引,一个叫做倒排索引呢?
正向索引是最传统的,根据id索引的方式。但根据词条查询时,必须先逐条获取每个文档,然后判断文档中是否包含所需要的词条,是根据文档找词条的过程。
而倒排索引则相反,是先找到用户要搜索的词条,根据词条得到保护词条的文档的id,然后根据id获取文档。是根据词条找文档的过程。
是不是恰好反过来了?
优缺点
正向索引:
- 优点:
- 可以给多个字段创建索引
- 根据索引字段搜索、排序速度非常快
- 缺点:
- 根据非索引字段,或者索引字段中的部分词条查找时,只能全表扫描。
倒排索引:
- 优点:
- 根据词条搜索、模糊搜索时,速度非常快
- 缺点:
- 只能给词条创建索引,而不是字段
- 无法根据字段做排序
10.1.3 es的一些概念
1) 文档和字段
elasticsearch是面向文档(Document)存储的,可以是数据库中的一条商品数据,一个订单信息。文档数据会被序列化为json格式后存储在elasticsearch中:
而Json文档中往往包含很多的字段(Field)(id,title,price…),类似于数据库中的列。
2) 索引和映射
索引(Index),就是相同类型的文档的集合。
例如:
- 所有用户文档,就可以组织在一起,称为用户的索引;
- 所有商品的文档,可以组织在一起,称为商品的索引;
- 所有订单的文档,可以组织在一起,称为订单的索引;
因此,我们可以把索引当做是数据库中的表。
数据库的表会有约束信息,用来定义表的结构、字段的名称、类型等信息。因此,索引库中就有映射(mapping),是索引中文档的字段约束信息,类似表的结构约束。
3) MySQL和ES
我们统一的把mysql与elasticsearch的概念做一下对比:
MySQL | Elasticsearch | 说明 |
---|---|---|
Table | Index | 索引(index),就是文档的集合,类似数据库的表(table) |
Row | Document | 文档(Document),就是一条条的数据,类似数据库中的行(Row),文档都是JSON格式 |
Column | Field | 字段(Field),就是JSON文档中的字段,类似数据库中的列(Column) |
Schema | Mapping | Mapping(映射)是索引中文档的约束,例如字段类型约束。类似数据库的表结构(Schema) |
SQL | DSL | DSL是elasticsearch提供的JSON风格的请求语句,用来操作elasticsearch,实现CRUD |
是不是说,我们学习了elasticsearch就不再需要mysql了呢?并不是如此,两者各自有自己的擅长之处:
Mysql:擅长事务类型操作,可以确保数据的安全和一致性
Elasticsearch:擅长海量数据的搜索、分析、计算
因此在企业中,往往是两者结合使用:
- 对安全性要求较高的写操作,使用mysql实现
- 对查询性能要求较高的搜索需求,使用elasticsearch实现
- 两者再基于某种方式,实现数据的同步,保证一致性
10.1.4 安装ES和Kibana
1) 部署ES
部署单点ES
- 创建网络
因为我们还需要部署kibana容器,因此需要让es和kibana容器互联。这里先创建一个网络:
1 | docker network create es-net |
- 加载镜像
1 | # 导入数据 |
同理还有kibana
的tar包也需要这样做。
- 运行:运行docker命令,部署单点es:
1 | docker run -d \ |
命令解释:
-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
:端口映射配置
- 在浏览器中输入:http://XXXXXXX:9200 即可看到elasticsearch的响应结果:
部署集群ES
部署es集群可以直接使用docker-compose来完成,不过要求你的Linux虚拟机至少有4G的内存空间
首先编写一个docker-compose文件,内容如下:
1 | version: '2.2' |
执行:
1 | docker-compose up |
2) 部署kibana
kibana可以给我们提供一个elasticsearch的可视化界面,便于我们学习。
- 加载镜像
- 运行docker命令,部署kibana
1 | docker run -d \ |
--network es-net
:加入一个名为es-net的网络中,与elasticsearch在同一个网络中-e ELASTICSEARCH_HOSTS=http://es:9200"
:设置elasticsearch的地址,因为kibana已经与elasticsearch在一个网络,因此可以用容器名直接访问elasticsearch-p 5601:5601
:端口映射配置
kibana启动一般比较慢,需要多等待一会,可以通过命令:
1 | docker logs -f kibana |
查看运行日志,当查看到下面的日志,说明成功:
此时,在浏览器输入地址访问:http://XXXXXXXXXX:5601,即可看到结果
- kibana中提供了一个DevTools界面:
这个界面中可以编写DSL来操作elasticsearch。并且对DSL语句有自动补全功能。
3) 部署IK分词器
测试ES默认分词器
es在创建倒排索引时需要对文档分词;在搜索时,需要对用户输入内容分词。但默认的分词规则对中文处理并不友好。
我们在kibana的DevTools中测试:
1 | POST /_analyze |
语法说明:
POST:请求方式
/_analyze:请求路径,这里省略了http://XXXXXXX:9200,由kibana帮我们补充
请求参数,json风格:
- analyzer:分词器类型,这里是默认的standard分词器
- text:要分词的内容
执行结果:
1 | { |
推荐使用离线部署
处理中文分词,一般会使用IK分词器。https://github.com/medcl/elasticsearch-analysis-ik
- 查看数据卷目录
安装插件需要知道elasticsearch的plugins目录位置,而我们用了数据卷挂载,因此需要查看elasticsearch的数据卷目录,通过下面命令查看:
1 | docker volume inspect es-plugins |
1 | [ |
说明plugins目录被挂载到了:/var/lib/docker/volumes/es-plugins/_data
这个目录中。
- 上传到es容器的插件数据卷中
需要把课前资料中的ik分词器解压缩,重命名为ik:
- 重启容器
1 | # 重启容器 |
1 | # 查看es日志 |
- 测试
IK分词器包含两种模式:
ik_smart
:最少切分ik_max_word
:最细切分
1 | GET /_analyze |
执行结果:
1 | { |
扩展词词典
随着互联网的发展,“造词运动”也越发的频繁。出现了很多新的词语,在原有的词汇列表中并不存在。比如:“奥力给”,“传智播客” 等。
所以我们的词汇也需要不断的更新,IK分词器提供了扩展词汇的功能。
- 打开IK分词器config目录:
- 在IKAnalyzer.cfg.xml配置文件内容添加:
1 |
|
- 新建一个 ext.dic,可以参考config目录下复制一个配置文件进行修改
1 | 传智播客 |
- 重启elasticsearch
1 | docker restart es |
- 测试效果:
1 | GET /_analyze |
注意:当前文件的编码必须是 UTF-8 格式,严禁使用Windows记事本编辑
停用词词典
在互联网项目中,在网络间传输的速度很快,所以很多语言是不允许在网络上传递的,如:关于宗教、政治等敏感词语,那么我们在搜索时也应该忽略当前词汇。
IK分词器也提供了强大的停用词功能,让我们在索引时就直接忽略当前的停用词汇表中的内容。
- IKAnalyzer.cfg.xml配置文件内容添加:
1 |
|
- 在 stopword.dic 添加停用词
1 | 基督教 |
- 重启elasticsearch,测试效果…
10.2 索引库操作
索引库就类似数据库表,mapping映射就类似表的结构。
我们要向es中存储数据,必须先创建“库”和“表”。
10.2.1 mapping映射属性
mapping是对索引库中文档的约束,常见的mapping属性包括:
type
:字段数据类型,常见的简单类型有:- 字符串:text(可分词的文本)、keyword(精确值,例如:品牌、国家、ip地址)
- 数值:long、integer、short、byte、double、float、
- 布尔:boolean
- 日期:date
- 对象:object
index
:是否创建索引,默认为trueanalyzer
:使用哪种分词器properties
:该字段的子字段
例如下面的json文档:
1 | { |
对应的每个字段映射(mapping):
- age:类型为 integer;参与搜索,因此需要index为true;无需分词器
- weight:类型为float;参与搜索,因此需要index为true;无需分词器
- isMarried:类型为boolean;参与搜索,因此需要index为true;无需分词器
- info:类型为字符串,需要分词,因此是text;参与搜索,因此需要index为true;分词器可以用ik_smart
- email:类型为字符串,但是不需要分词,因此是keyword;不参与搜索,因此需要index为false;无需分词器
- score:虽然是数组,但是我们只看元素的类型,类型为float;参与搜索,因此需要index为true;无需分词器
- name:类型为object,需要定义多个子属性
- name.firstName;类型为字符串,但是不需要分词,因此是keyword;参与搜索,因此需要index为true;无需分词器
- name.lastName;类型为字符串,但是不需要分词,因此是keyword;参与搜索,因此需要index为true;无需分词器
10.2.2 索引库的CRUD
这里我们统一使用Kibana编写DSL的方式来演示。
1) 创建索引库和映射
基本语法
- 请求方式:PUT
- 请求路径:/索引库名,可以自定义
- 请求参数:
mapping映射
1 | PUT /索引库名称 |
示例
1 | PUT /hongyi |
执行结果:
1 | { |
2) 查询索引库
基本语法
请求方式:GET
请求路径:/索引库名
请求参数:无
1 | GET /索引库名 |
示例
1 | GET /hongyi |
返回结果:
1 | { |
3) 删除索引库
语法
请求方式:DELETE
请求路径:/索引库名
请求参数:无
示例
1 | DELETE /hongyi |
返回结果:
1 | { |
4) 修改索引库
倒排索引结构虽然不复杂,但是一旦数据结构改变(比如改变了分词器),就需要重新创建倒排索引,这简直是灾难。因此索引库一旦创建,无法修改mapping。
虽然无法修改mapping中已有的字段,但是却允许添加新的字段到mapping中,因为不会对倒排索引产生影响。
语法说明
请求方式:DELETE
请求路径:/索引库名/_mapping
请求参数:无
1 | PUT /索引库名/_mapping |
示例
1 | PUT /hongyi/_mapping |
返回结果:
1 | { |
如果此时要修改age字段的类型为long
1 | PUT /hongyi/_mapping |
发现报错,返回结果:
1 | { |
10.3 文档操作
10.3.1 新增文档
语法
1 | // 文档id需要自己指定 |
示例
1 | POST /hongyi/_doc/1 |
执行结果:
1 | { |
10.3.2 查询文档
根据rest风格,新增是post,查询应该是get,不过查询一般都需要条件,这里我们把文档id带上。
语法
1 | GET /索引库名称/_doc/{id} |
示例
1 | GET /hongyi/_doc/1 |
返回结果:
1 | { |
10.3.3 删除文档
删除使用DELETE请求,同样,需要根据id进行删除。
语法
1 | DELETE /索引库名/_doc/id值 |
示例
1 | DELETE /hongyi/_doc/1 |
返回结果:
1 | { |
10.3.4 修改文档
修改有两种方式:
- 全量修改:直接覆盖原来的文档
- 增量修改:修改文档中的部分字段
1) 全量修改
全量修改是覆盖原来的文档,其本质是:
- 根据指定的id删除文档
- 新增一个相同id的文档
注意:如果根据id删除时,id不存在,第二步的新增也会执行,也就从修改变成了新增操作了。
语法
1 | // 请求方式为PUT |
2) 增量修改
增量修改是只修改指定id匹配的文档中的部分字段。
语法
1 | // 注意参数是doc,路径为_update |
示例
1 | POST /hongyi/_update/1 |
执行结果:
1 | { |
10.4 RestAPI
ES官方提供了各种不同语言的客户端,用来操作ES。这些客户端的本质就是组装DSL语句,通过http请求发送给ES。官方文档地址:https://www.elastic.co/guide/en/elasticsearch/client/index.html
其中的Java Rest Client又包括两种:
- Java Low Level Rest Client
- Java High Level Rest Client
我们学习的是Java HighLevel Rest Client客户端API
10.4.1 导入demo工程
1) 导入数据
首先导入课前资料提供的数据库数据:
数据结构如下:
1 | CREATE TABLE `tb_hotel` ( |
执行结果:
2) 导入hotel-demo工程
项目结构
3) Mapping映射分析
创建索引库,最关键的是mapping映射,而mapping映射要考虑的信息包括:
- 字段名
- 字段数据类型
- 是否参与搜索
- 是否需要分词
- 如果分词,分词器是什么?
其中:
- 字段名、字段数据类型,可以参考数据表结构的名称和类型
- 是否参与搜索要分析业务来判断,例如图片地址,就无需参与搜索
- 是否分词呢要看内容,内容如果是一个整体就无需分词,反之则要分词
- 分词器,我们可以统一使用ik_max_word
来看下酒店数据的索引库结构:
1 | PUT /hotel |
几个特殊字段说明:
- location:地理坐标,里面包含精度、纬度。ES中支持两种地理坐标数据类型:
- geo_point:由纬度(latitude)和经度(longitude)确定的一个点。例如:”32.8752345,120.2981576”
- geo_shape:有多个geo_point组成的复杂几何图形。例如一条直线,”LINESTRING(-77.03653 38.897676, -77.009051 38.889939)”
- all:一个组合字段,其目的是将多字段的值利用copy_to合并,提供给用户搜索。例如:
1 | "all": { |
返回结果:
4) 初始化RestClient
在elasticsearch提供的API中,与elasticsearch一切交互都封装在一个名为RestHighLevelClient
的类中,必须先完成这个对象的初始化,建立与elasticsearch的连接。
步骤
- 引入es的RestHighLevelClient依赖:
1 | <dependency> |
- 因为SpringBoot默认的ES版本是7.6.2,所以我们需要覆盖默认的ES版本:
1 | <properties> |
- 初始化RestHighLevelClient,在测试类中:
1 | public class HotelIndexTest { |
10.4.2 创建索引库
1) 代码解读
创建索引库的API如下:
代码分为三步:
- 创建Request对象。因为是创建索引库的操作,因此Request是CreateIndexRequest。
- 添加请求参数,其实就是DSL的JSON参数部分。因为json字符串很长,这里是定义了静态字符串常量MAPPING_TEMPLATE,让代码看起来更加优雅。
- 发送请求,client.indices()方法的返回值是IndicesClient类型,封装了所有与索引库操作有关的方法。
2) 完整代码
- 在hotel-demo的cn.itcast.hotel.constants包下,创建一个类,定义mapping映射的JSON字符串常量:
1 | public class HotelConstants { |
- 在hotel-demo中的HotelIndexTest测试类中,编写单元测试,实现创建索引:注意要删除之前建立的索引库hotel
1 |
|
10.4.3 删除索引库和判断索引库存在
1) 删除
删除索引库的DSL语句非常简单:
1 | DELETE /hotel |
与创建索引库相比:
- 请求方式从PUT变为DELTE
- 请求路径不变
- 无请求参数
所以代码的差异,注意体现在Request对象上。依然是三步走:
- 创建Request对象。这次是DeleteIndexRequest对象
- 准备参数。这里是无参
- 发送请求。改用delete方法
在hotel-demo中的HotelIndexTest测试类中,编写单元测试,实现删除索引:
1 |
|
2) 判断
判断索引库是否存在,本质就是查询,对应的DSL是:
1 | GET /hotel |
因此与删除的Java代码流程是类似的。依然是三步走:
- 创建Request对象。这次是GetIndexRequest对象
- 准备参数。这里是无参
- 发送请求。改用exists方法
1 |
|
10.4.4 总结
JavaRestClient操作elasticsearch的流程基本类似。核心是client.indices()方法来获取索引库的操作对象。
索引库操作的基本步骤:
- 初始化RestHighLevelClient
- 创建XxxIndexRequest。XXX是Create、Get、Delete
- 准备DSL( Create时需要,其它是无参)
- 发送请求。调用RestHighLevelClient#indices().xxx()方法,xxx是create、exists、delete
10.5 RestClient操作文档
为了与索引库操作分离,我们再次新增一个测试类,做两件事情:
- 初始化RestHighLevelClient
- 我们的酒店数据在数据库,需要利用IHotelService去查询,所以注入这个接口
1 | // 记得添加这个注解 |
10.5.1 新增文档
需求:我们要将数据库的酒店数据查询出来,写入elasticsearch中。
1) 索引库实体类
数据库查询后的结果是一个Hotel类型的对象。结构如下:
1 |
|
与我们的索引库结构存在差异:
- longitude和latitude需要合并为location
因此,我们需要定义一个新的类型,与索引库结构吻合:
1 |
|
2) 语法说明
新增文档的DSL语句如下:
1 | POST /{索引库名}/_doc/1 |
对应的java代码如图:
可以看到与创建索引库类似,同样是三步走:
- 创建Request对象
- 准备请求参数,也就是DSL中的JSON文档,这里的JSON文档,由从数据库中查询出来的hotel对象序列化为JSON格式的数据后来提供
- 发送请求
变化的地方在于,这里直接使用client.index()
的API,不再需要client.indices()
了。
3) 代码实现
我们导入酒店数据,基本流程一致,但是需要考虑几点变化:
- 酒店数据来自于数据库,我们需要先查询出来,得到hotel对象
- hotel对象需要转为HotelDoc对象
- HotelDoc需要序列化为json格式
因此,代码整体步骤如下:
- 根据id查询酒店数据Hotel
- 将Hotel封装为HotelDoc
- 将HotelDoc序列化为JSON
- 创建IndexRequest,指定索引库名和id
- 准备请求参数,也就是JSON文档
- 发送请求
在hotel-demo的HotelDocumentTest测试类中,编写单元测试:
1 |
|
在kibana中验证增加的文档:
1 | GET /hotel/_doc/61083 |
1 | { |
10.5.2 查询文档
1) 语法说明
查询的DSL语句如下:
1 | GET /hotel/_doc/{id} |
非常简单,因此代码大概分两步:
- 准备Request对象
- 发送请求
不过查询的目的是得到结果,解析为HotelDoc,因此难点是结果的解析。完整代码如下:
可以看到,结果是一个JSON,其中文档放在一个_source
属性中,因此解析就是拿到_source
,反序列化为Java对象即可。
与之前类似,也是三步走:
- 准备Request对象。这次是查询,所以是GetRequest
- 发送请求,得到结果。因为是查询,这里调用client.get()方法
- 解析结果,就是对JSON做反序列化
2) 代码实现
在hotel-demo的HotelDocumentTest测试类中,编写单元测试:
1 |
|
1 | HotelDoc(id=61083, name=上海滴水湖皇冠假日酒店, address=自由贸易试验区临港新片区南岛1号, price=971, score=44, brand=皇冠假日, city=上海, starName=五钻, business=滴水湖临港地区, location=30.890867, 121.937241, pic=https://m.tuniucdn.com/fb3/s1/2n9c/312e971Rnj9qFyR3pPv4bTtpj1hX_w200_h200_c1_t0.jpg) |
10.5.3 更新文档
1) 语法说明
修改我们讲过两种方式:
- 全量修改:本质是先根据id删除,再新增
- 增量修改:修改文档中的指定字段值
在RestClient的API中,全量修改与新增的API完全一致,判断依据是ID:
- 如果新增时,ID已经存在,则修改
- 如果新增时,ID不存在,则新增
这里不再赘述,我们主要关注增量修改。
代码示例如图:
与之前类似,也是三步走:
- 准备Request对象。这次是修改,所以是UpdateRequest
- 准备参数。也就是JSON文档,里面包含要修改的字段
- 更新文档。这里调用client.update()方法
2) 代码实现
1 |
|
修改结果:
10.5.4 删除文档
1) 语法说明
删除的DSL为是这样的:
1 | DELETE /hotel/_doc/{id} |
与查询相比,仅仅是请求方式从DELETE变成GET,可以想象Java代码应该依然是三步走:
- 准备Request对象,因为是删除,这次是DeleteRequest对象。要指定索引库名和id
- 准备参数,无参
- 发送请求。因为是删除,所以是client.delete()方法
2) 代码实现
1 |
|
删除后,在kibana中查询结果:
10.5.5 批量新增文档
案例需求:利用BulkRequest批量将数据库数据导入到索引库中。
步骤如下:
利用mybatis-plus查询酒店数据
将查询到的酒店数据(Hotel)转换为文档类型数据(HotelDoc)
利用JavaRestClient中的BulkRequest批处理,实现批量新增文档
1) 语法说明
批量处理BulkRequest,其本质就是将多个普通的CRUD请求组合在一起发送。
其中提供了一个add方法,用来添加其他请求:
可以看到,能添加的请求包括:
- IndexRequest,也就是新增
- UpdateRequest,也就是修改
- DeleteRequest,也就是删除
因此Bulk中添加了多个IndexRequest,就是批量新增功能了。示例:
其实还是三步走:
- 创建Request对象。这里是BulkRequest
- 准备参数。批处理的参数,就是其它Request对象,这里就是多个IndexRequest
- 发起请求。这里是批处理,调用的方法为client.bulk()方法
我们在导入酒店数据时,将上述代码改造成for循环处理即可。
2) 代码实现
1 |
|
kibana端验证:下列DSL语句可以查询到所有数据
1 | GET /hotel/_search |
10.6 DSL查询文档
elasticsearch的查询依然是基于JSON风格的DSL来实现的。
10.6.1 DSL查询分类
Elasticsearch提供了基于JSON的DSL(Domain Specific Language)来定义查询。
常见的查询类型包括:
- 查询所有:查询出所有数据,一般测试用。例如:match_all
- 全文检索(full text)查询:利用分词器对用户输入内容分词,然后去倒排索引库中匹配。例如:
- match_query
- multi_match_query
- 精确查询:根据精确词条值查找数据,一般是查找keyword、数值、日期、boolean等类型字段。例如:
- ids
- range
- term
- 地理(geo)查询:根据经纬度查询。例如:
- geo_distance
- geo_bounding_box
- 复合(compound)查询:复合查询可以将上述各种查询条件组合起来,合并查询条件。例如:
- bool
- function_score
查询的语法基本一致:
1 | GET /indexName/_search |
我们以查询所有为例,其中:
- 查询类型为match_all
- 没有查询条件
1 | // 查询所有 |
返回结果:
其中hits
就是一个个文档。
其它查询无非就是查询类型、查询条件的变化。
10.6.2 全文检索查询
1) 使用场景
全文检索查询的基本流程如下:
- 对用户搜索的内容做分词,得到词条
- 根据词条去倒排索引库中匹配,得到文档id
- 根据文档id找到文档,返回给用户
比较常用的场景包括:
- 商城的输入框搜索
- 百度输入框搜索
2) 基本语法
常见的全文检索查询包括:
match
查询:单字段查询multi_match
查询:多字段查询,任意一个字段符合条件就算符合查询条件
match查询语法
1 | GET /indexName/_search |
mulit_match语法
1 | GET /indexName/_search |
3) 使用
match
1 | # match查询 |
multi_match
1 | // multi_match查询 |
可以看到,两种查询结果是一样的,为什么?
因为我们将brand、name、business值都利用copy_to复制到了all字段中。因此你根据三个字段搜索,和根据all字段搜索效果当然一样了。
但是,搜索字段越多,对查询性能影响越大,因此建议采用copy_to,然后单字段查询的方式。
10.6.3 精准查询
精确查询一般是查找keyword、数值、日期、boolean等类型字段。所以不会对搜索条件分词。常见的有:
- term:根据词条精确值查询
- range:根据值的范围查询
1) term查询
因为精确查询的字段搜是不分词的字段,因此查询的条件也必须是不分词的词条。查询时,用户输入的内容跟自动值完全匹配时才认为符合条件。如果用户输入的内容过多,反而搜索不到数据。
语法
1 | // term查询 |
示例
当搜索的是精确词条时,能正确查询出结果:
1 | GET /hotel/_search |
2) range查询
范围查询,一般应用在对数值类型做范围过滤的时候。比如做价格范围过滤。
基本语法
1 | // range查询 |
示例
1 | GET /hotel/_search |
10.6.4 地理查询
所谓的地理坐标查询,其实就是根据经纬度查询,官方文档:https://www.elastic.co/guide/en/elasticsearch/reference/current/geo-queries.html
常见的使用场景包括:
- 携程:搜索我附近的酒店
- 滴滴:搜索我附近的出租车
- 微信:搜索我附近的人
1) 矩形范围查询
矩形范围查询,也就是geo_bounding_box查询,查询坐标落在某个矩形范围的所有文档。
查询时,需要指定矩形的左上、右下两个点的坐标,然后画出一个矩形,落在该矩形内的都是符合条件的点。
语法
1 | // geo_bounding_box查询 |
2) 附近查询
附近查询,也叫做距离查询(geo_distance):查询到指定中心点小于某个距离值的所有文档。
换句话来说,在地图上找一个点作为圆心,以指定距离为半径,画一个圆,落在圆内的坐标都算符合条件。
语法
1 | // geo_distance 查询 |
示例
我们先搜索陆家嘴附近15km的酒店:
1 | GET /hotel/_search |
可以看到搜索到了47家酒店。减小距离为3km:
发现只命中了5家酒店。
10.6.5 复合查询
复合(compound)查询:复合查询可以将其它简单查询组合起来,实现更复杂的搜索逻辑。常见的有两种:
fuction score
:算分函数查询,可以控制文档相关性算分,控制文档排名bool query
:布尔查询,利用逻辑关系组合多个其它的查询,实现复杂搜索
1) 相关性算分
当我们利用match查询时,文档结果会根据与搜索词条的关联度打分(_score),返回结果时按照分值降序排列。
例如,我们搜索 “虹桥如家”,结果如下:
1 | [ |
在elasticsearch中,早期使用的打分算法是TF-IDF算法,公式如下:
在后来的5.1版本升级中,elasticsearch将算法改进为BM25算法,公式如下:
TF-IDF算法有一个缺陷,就是词条频率越高,文档得分也会越高,单个词条对文档影响较大。而BM25则会让单个词条的算分有一个上限,曲线更加平滑:
2) 算分函数查询
根据相关度打分是比较合理的需求,但合理的不一定是产品经理需要的。
以百度为例,你搜索的结果中,并不是相关度越高排名越靠前,而是谁掏的钱多排名就越靠前。如图:
要想认为控制相关性算分,就需要利用elasticsearch中的function score
查询了。
语法说明
function score
查询中包含四部分内容:
- 原始查询条件:query部分,基于这个条件搜索文档,并且基于BM25算法给文档打分,原始算分(
query score
) - 过滤条件:filter部分,符合该条件的文档才会重新算分
- 算分函数:符合filter条件的文档要根据这个函数做运算,得到的函数算分(
function score
),有四种函数- weight:函数结果是常量
- field_value_factor:以文档中的某个字段值作为函数结果
- random_score:以随机数作为函数结果
- script_score:自定义算分函数算法
- 运算模式:算分函数的结果、原始查询的相关性算分,两者之间的运算方式,包括:
- multiply:相乘
- replace:用function score替换query score
- 其它,例如:sum、avg、max、min
function score
的运行流程如下:
- 1)根据原始条件查询搜索文档,并且计算相关性算分,称为原始算分(query score)
- 2)根据过滤条件,过滤文档
- 3)符合过滤条件的文档,基于算分函数运算,得到函数算分(function score)
- 4)将原始算分(query score)和函数算分(function score)基于运算模式做运算,得到最终结果,作为相关性算分。
因此,其中的关键点是:
- 过滤条件:决定哪些文档的算分被修改
- 算分函数:决定函数算分的算法
- 运算模式:决定最终算分结果
示例
需求:给“如家”这个品牌的酒店排名靠前一些。翻译一下这个需求,转换为之前说的四个要点:
- 原始条件:不确定,可以任意变化
- 过滤条件:brand = “如家”
- 算分函数:可以简单粗暴,直接给固定的算分结果,weight
- 运算模式:比如求和
未添加算分函数时:
1 | GET /hotel/_search |
如家排在第二位。
添加算分函数后:
1 | GET /hotel/_search |
发现如家排在了第一位。
3) 布尔查询
布尔查询是一个或多个查询子句的组合,每一个子句就是一个子查询。子查询的组合方式有:
must
:必须匹配每个子查询,类似“与”should
:选择性匹配子查询,类似“或”must_not
:必须不匹配,不参与算分,类似“非”filter
:必须匹配,不参与算分
比如在搜索酒店时,除了关键字搜索外,我们还可能根据品牌、价格、城市等字段做过滤:
每一个不同的字段,其查询的条件、方式都不一样,必须是多个不同的查询,而要组合这些查询,就必须用bool查询了。
需要注意的是,搜索时,参与打分的字段越多,查询的性能也越差。因此这种多条件查询时,建议这样做:
- 搜索框的关键字搜索,是全文检索查询,使用must查询,参与算分
- 其它过滤条件,采用filter查询。不参与算分
语法说明
1 | GET /hotel/_search |
示例
需求:搜索名字包含“如家”,价格不高于400,在坐标31.21,121.5周围10km范围内的酒店。
分析:
- 名称搜索,属于全文检索查询,应该参与算分。放到must中
- 价格不高于400,用range查询,属于过滤条件,不参与算分。放到must_not中
- 周围10km范围内,用geo_distance查询,属于过滤条件,不参与算分。放到filter中
1 | GET /hotel/_search |
10.7 搜索结果处理
搜索的结果可以按照用户指定的方式去处理或展示。