前言
本笔记参考SpringCloud+RabbitMQ+Docker+Redis+搜索+分布式,史上最全面的springcloud微服务技术栈课程|黑马程序员Java微服务_哔哩哔哩_bilibili,感谢黑马!
视频资源教程下载:
https://pan.baidu.com/s/169SFtYEvel44hRJhmFTRTQ
提取码:1234
微服务的概念
定义
维基百科对它的解释:
一种软件开发技术- 面向服务的体系结构(SOA)架构样式的一种变体,它提倡将单一应用程序划分成一组小的服务,服务之间互相协调、互相配合,为用户提供最终价值。每个服务运行在其独立的进程中,服务与服务间采用轻量级的通信机制互相沟通(通常是基于HTTP的RESTful API)。每个服务都围绕着具体业务进行构建,并且能够独立地部署到生产环境、类生产环境等。另外,应尽量避免统一的、集中式的服务管理机制,对具体的一个服务而言,应根据上下文,选择合适的语言、工具对其进行构建。
整个微服务的框架可以概括如下:
特征
微服务有如下架构特征:
1.单一职责:微服务拆分粒度更小,每一个服务都对应唯一的业务能力,做到单一职责。
2.自治:团队独立、技术独立、数据独立,独立部署和交付。
3.面向服务:服务提供统一标准的接口,与语言和技术无关。
4.隔离性强:服务调用做好隔离、容错、降级,避免出现级联问题。
总结下来就是,一个整体的系统中的各个模块可以做拆分,分成一个一个小的微服务模块每个微服务都是独立存在的,数据库也是按照微服务的划分独立存在的,最重要的是可以独立的部署到生产环境上。请求服务的方式可以通过http的形式进行请求。比如下图所示:
SpringCloud
Spring Cloud是一系列框架的有序集合。它利用Spring Boot的开发便利性巧妙地简化了分布式系统基础设施的开发,如服务发现注册、配置中心、消息总线、负载均衡、断路器、数据监控等,都可以用Spring Boot的开发风格做到一键启动和部署。Spring Cloud并没有重复制造轮子,它只是将各家公司开发的比较成熟、经得起实际考验的服务框架组合起来,通过Spring Boot风格进行再封装屏蔽掉了复杂的配置和实现原理,最终给开发者留出了一套简单易懂、易部署和易维护的分布式系统开发工具包。
SpringCloud是目前国内使用最广泛的微服务框架。常用的微服务组件如下所示:
我认为,最便捷的部分就是SpringCloud无缝衔接了当前主流方便的web框架Springboot,非常简单地就可以在Springboot的基础上开发SpringCloud微服务。Springboot和SpringCloud的兼容性如下所示:
Cloud版本 | SpringBoot版本 |
---|---|
2020.0.x aka llford | 2.4.x |
Hoxton | 2.2.x,2.3.x(Starting with SR5) |
Greenwich | 2.1.x |
Finchley | 2.0.x |
Edgware | 1.5.x |
Dalston | 1.5.x |
本笔记使用的版本遵循黑马程序员娇嗔,使用的是Hoxton.SR10,SpringBoot版本是2.3.x版本。
服务拆分的例子
原则
既然微服务是将单体架构拆分成一个一个微服务,那么在服务拆分的时候需要遵循一些原则,原则如下:
1.不同微服务,不要重复开发相同业务。(不要重复造轮子)
2.微服务数据独立,不要访问其它微服务的数据库。
3.微服务可以将自己的业务暴露为接口,供其它微服务调用。
比如一个简单的商城案例,可以简单的分为如下的几个模块:
订单、用户例子
教程中的例子有两个微服务,分别为订单的微服务和用户的微服务:order-service和user-service。他们对应的数据库是分别两个不同的数据库:cloud-user和cloud-order,结构和数据如下:
在idea中的结构如下所示:
其中两个服务如下所示:
开启之后可以通过localhost:8080/order/101来请求订单号id为101的订单的详细信息,localhost:8081/user/1来请求查询用户id为1的用户的详细信息。不过Order的pojo类里还包含了User类,但是Order表里只存了User的id,我们就需要通过跨服务来请求User信息。
两个pojo类如下所示:
用户查询的有关代码和查询结果如下所示:
跨服务请求
要求在查询订单的同时,根据订单中包含的userId查询出用户信息,一起返回。如下图所示
因此,我们需要在order-service中 向user-service发起一个http的请求,调用http://localhost:8081/user/{userId}这个接口。
步骤如下所示:
1.注册一个RestTemplate的实例到Spring容器(不要忘记了!)
2.修改order-service服务中的OrderService类中的queryOrderById方法,根据Order对象中的userId查询User
3.将查询的User填充到Order对象,一起返回
Eureka注册中心
在上面的例子中我们在OrderService当中将跨请求的操作写死成localhost:8081端口,若我们的User的微服务有多个实例,且地址会发生改变的话,这么操作就不利于后续的维护拓展。
order-service在发起远程调用的时候,该如何得知user-service实例的ip地址和端口?
有多个user-service实例地址,order-service调用时该如何选择?
order-service如何得知某个user-service实例是否依然健康,是不是已经宕机?
这个时候注册中心的作用就体现出来了!
基本介绍
Eureka是Netflix开发的服务发现框架,本身是一个基于REST的服务,主要用于定位运行在AWS域中的中间层服务,以达到负载均衡和中间层服务故障转移的目的。
SpringCloud将它集成在其子项目spring-cloud-netflix中,以实现SpringCloud的服务发现功能。Eureka包含两个组件:Eureka Server和Eureka Client。
作用
回答之前的各个问题。
一、order-service如何得知user-service实例地址?
获取地址信息的流程如下:
1.服务注册:user-service服务实例启动后,将自己的信息注册到eureka-server(Eureka服务端)。
2.eureka-server保存服务名称到服务实例地址列表的映射关系。
3.服务发现:order-service根据服务名称,拉取实例地址列表。这个叫服务发现或服务拉取。
二、order-service如何从多个user-service实例中选择具体的实例?
order-service从实例列表中利用负载均衡算法选中一个实例地址,向该实例地址发起远程调用。
三、order-service如何得知某个user-service实例是否依然健康,是不是已经宕机?
1.心跳:user-service会每隔一段时间(默认30秒)向eureka-server发起请求,报告自己状态。
2.当超过一定时间没有发送心跳时,eureka-server会认为微服务实例故障,将该实例从服务列表中剔除
3.order-service拉取服务时,就能将故障实例排除了
注意:一个微服务,既可以是服务提供者,又可以是服务消费者,因此eureka将服务注册、服务发现等功能统一封装到了eureka-client端
实践
把之前的例子应用到Eureka注册中心上,步骤如下所示:
搭建Eureka服务端
Eureka服务端必须是一个独立的微服务,所以我们现在cloud-demo父工程下创建一个子模块,选择maven工程。
1.在依赖中导入eureka相关依赖:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
</dependency>
2.随后编写启动类,一定要添加一个@EnableEurekaServer注解,开启eureka的注册中心功能,并编写配置文件。
3.启动服务之后访问该端口得到以下画面:
可以看到eureka已经显示在其中了
注册服务
我们将user-service注册到eureka-server中。步骤如下
1.引入依赖:在user-service的pom文件中,引入下面的eureka-client依赖
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
2.配置文件:在user-service中,修改application.yml文件,添加服务名称、eureka地址
spring:
application:
name: userservice
eureka:
client:
service-url:
defaultZone: http://127.0.0.1:10086/eureka
注册完毕之后,再访问eureka界面就可以看到已注册的服务了:
若想实现一个服务有多个机子呈现的话,可以使用idea里的copy configuration选项,再启动一个userservice。
设置参数,将server.port改成8082
之后就会发现Services工具栏有一栏多的Not Started,其下正是UserApplication在8082端口的实例,启动它即可。
启动之后再查看eureka界面就可以发现8082端口实例:
发现服务
在eureka注册服务之后,这时请求服务就不用把主机地址写死了,只需要服务名称,就可以从eureka里拉取对应的服务。
将OrderService里跨服务请求改成服务拉取
步骤:
1.修改OrderService的代码,将url和端口改成付服务名
//原来的版本是
//String url="http://127.0.0.1:8081/user/"+order.getUserId();
String url="http://userservice/user/"+order.getUserId();
2.在order-service项目的启动类OrderApplication中的RestTemplate添加负载均衡注解:
这样就可以使用各种负载均衡策略,例如轮询、随机等等。
Ribbon负载均衡
eureka的负载均衡是通过Ribbon来实现的
负载均衡原理
如图所示:
修改负载均衡的策略
原理
IRule决定了负载均衡的策略,如果我们想修改负载均衡的策略,就可以通过IRule入手。
默认的规则实现的是ZoneAvoidanceRule根据zone选择服务列表然后轮询。
其中各个负载均衡类对应的规则描述如下:
步骤
方式一:
比如我们想修改order-service请求user-service时的负载均衡的策略,那么我就可以在order-service的OrderAppliction中重新创建一个IRule类,返回一个内置的负载均衡规则类。
这样设置的话,order-service不管调用什么微服务,都是采用随机负载均衡规则。
方式二:
在配置文件方式修改,可以指定请求某个微服务时采用什么规则,如下图所示:
饥饿加载
顾名思义:饥不择食,上来就先全部加载了。
Ribbon默认是采用懒加载,即第一次访问时才会去创建LoadBalanceClient,请求时间会很长。
而饥饿加载则会在项目启动时创建,降低第一次访问的耗时,通过下面配置开启饥饿加载:
Nacos注册中心
与Eureka类似,但是比Eureka功能更加丰富,在国内受欢迎度较高,Nacos是阿里巴巴的产品,现在是SpringCloud里的组件了。
nacos安装
去github上下载压缩包之后解压缩,解压路径不要有中文,之后通过在bin目录下cmd,命令输入:startup.cmd -m standalone。
默认登录用户名和密码是:
nacos
nacos
Nacos服务分级存储模型
一个服务可以有多个实例,每个实例部署在不同的机房or地域,就形成了集群。
所以在服务调用的时候,尽可能地选择本地集群的服务,因为跨集群调用延迟比较高。
环境隔离-namespace
使用方法:
总结如下:
1.每个namespace都有唯一id
2.服务设置namespace时要写id而不是名称
3.不同namespace下的服务互相不可见
使用
服务注册到Nacos
配置集群
在服务的实例的application.yml里添加discovery下的cluster-name信息
再在nacos里查看
如果想三个实例在不同的集群,那么8081和8082再HZ集群上启动起来之后,修改cluster-name为SH,再启动8083即可,也可以通过修改参数来进行。
可以看到有两个集群,HZ和SH:
NacosRule负载均衡
将OrderService配置到HZ集群中,测试优先调用本地集群的负载均衡规则。默认的规则没有选择同集群的即HZ的userservice。
步骤:
第2步修改规则是在order-service下的application.yml里进行修改。注意还需要把之前在OrderAppliction.java里写的IRule给注释掉!
如果HZ集群的所有实例都停止工作了,nacos就会跨集群调用,这时会弹出警告信息,如下所示:
根据权重负载均衡
实际部署中会出现这样的场景:
服务器设备性能有差异,部分实例所在机器性能较好,另一些较差,我们希望性能好的机器承担更多的用户请求
Nacos提供了权重配置来控制访问频率,权重越大则访问频率越高。
方法:
直接在Nacos控制台设置实例的权重值:
若权重调整为0,则该实例就不会被访问到。
创建非临时实例
将配置交给nacos来管理
有些关键配置我们可以放在nacos中,在nacos里管理和修改后可以实现热更新,可以比较方便快捷。
注意:
1.不是所有的配置都适合放到配置中心,维护起来比较麻烦。
2.建议将一些关键参数,需要运行时调整的参数放到nacos配置中心,一般都是自定义配置。
在nacos中添加配置
step1:
step2:在弹出的表单中填写配置信息,注意Data ID的写法要规范!
点击发布之后就可以看到自己新添加的配置文件了:
统一配置管理
微服务启动流程中读取配置的过程如下图所示:
先读取bootstrap.yml再读取本地的配置文件application.yml
首先需要引入nacos的配置管理客户端依赖,在userservice下的pom.xml里添加如下依赖:
<!--nacos配置管理依赖-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
所以我们在resource文件夹下新创建一个bootstrap.yml,并写好相关配置:
测试是否读取能读取到nacos的配置文件中的pattern.dateformat,通过Value注解进行测试,在UserController下进行修改:
访问结果如下:
实现热更新
假如我们想在nacos里修改了配置之后,修改的内容可以自动作用于微服务(userservice),那么可以热更新来实现,一共有两种方式实现:
第一种方式:
第二种方式:
新创建一个专门用于获取配置的类,然后在这个类上使用@ConfigurationProperties注解
若有多个前缀,测试如下:
若有多个前缀,则在prefix下依次用符号.来表示上下级关系即可。
这两种方式可以混用,比如需要多级访问的时候,可以直接使用@Value注解来实现。
多环境配置共享
微服务启动时会从nacos读取多个配置文件:
1.[spring.application.name]-[spring.profiles.active].yaml,例如:userservice-dev.yaml
2.[spring.application.name].yaml,例如:userservice.yaml
无论profile如何变化,[spring.application.name].yaml这个文件一定会加载,因此多环境共享配置可以写入这个文件
不同微服务也可以共享配置
集群搭建
具体看链接内的压缩包:nacos集群搭建.zip - 蓝奏云 (lanzouj.com)
上面打不开可以打开这个链接:Gofile - Share file links quickly and easily
密码是nacos123
总结
原理细节
与eureka比较
共同点:
1.都支持服务注册和服务拉取
2.都支持服务提供者心跳方式做健康检测
不同点:
1.Nacos支持服务端主动检测提供者状态:临时实例采用心跳模式,非临时实例采用主动检测模式。
2.临时实例心跳不正常会被剔除,非临时实例则不会被剔除
3.Nacos支持服务列表变更的消息推送模式,服务列表更新更及时
4.Nacos集群默认采用AP方式(强调数据服务的可用性),当集群中存在非临时实例时,采用CP模式(强调数据的可靠性和一致性);Eureka采用AP方式。
http客户端Feign
可以通过Feign来实现优雅地通过http调用其他微服务。原先使用的RestTemplate发起远程调用会存在代码可读性差,参数复杂UR难以维护等缺点。
使用步骤
step1:
引入依赖
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
step2:
在order-service的启动类添加注解开启Feign的功能,因为是orderservice跨服务调用userservice里的方法,请求user数据。
step3:(非最佳实践方法)
直接编写Feign客户端,创建cn.itcast.order.clients.UserClinet接口
(上图的接口名应该为UserClient,打错了...orz)
step4:
在OrderService代码里,将RestTemplate代码替换成Feign客户端代码
Feign内部使用了Ribbon,实现了负载均衡
自定义配置
可配置项
Feign里有一些配置可以自定义,如下表所示:
类型 | 作用 | 说明 |
---|---|---|
feign.Logger.Level | 修改日志级别 | 包含四种不同的级别:NONE、BASIC、HEADERS、FULL(用的最多的) |
feign.codec.Decoder | 响应结果的解析器 | http远程调用的结果做解析,例如解析json字符串为java对象 |
feign.codec.Encoder | 请求参数编码 | 将请求参数编码,便于通过http请求发送 |
feign. Contract | 支持的注解格式 | 默认是SpringMVC的注解 |
feign. Retryer | 失败重试机制 | 请求失败的重试机制,默认是没有,不过会使用Ribbon的重试 |
配置方法
方法一:在配置文件中修改
在order-service的application.yml文件中增加如下配置:
feign:
client:
config:
default: #这里用default就是全局配置,如果写某个服务名称,则是针对某个微服务的配置
loggerLevel: FULL #日志级别
方法二:java代码方式,需要先声明一个Bean
public class FeignClientConfiguration{
@Bean
public Logger.Level feignLongLevel(){
return Logger.Level.BASIC;
}
}
(1)如果是全局配置,则把它放到@EnableFeignClients这个注解中:
@EnableFeignClients(defaultConfiguration = FeignClientConfiguration.class)
(2)如果是局部配置,则把它放到@FeignClient这个注解中:
@FeignClient(value = "userservice", configuration = FeignClientConfiguration.class)
BASIC级别的日志显示如下:
性能优化
Feign底层的客户端实现
Feign底层默认使用的是URLConnection,它不支持连接池,影响性能与效率。而Apache HttpClient和OKHttp是支持连接池的。、
两个方向
1.日志级别最好使用basic或者none
2.使用连接池来代替默认的URLConnection
(1)引入依赖
<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-httpclient</artifactId>
</dependency>
(2)配置application.yml文件
feign: #通过配置文件来自定义Feign的配置
client: # 用于配置Feign的日志的
config:
default: #这里用default就是全局配置,如果写某个服务名称,则是针对某个微服务的配置
loggerLevel: NONE #日志级别
httpclient: #httpclient的配置
enabled: true
max-connections: 200 # 最大连接数
max-connections-per-route: 50 # 单个路径的最大连接数
最佳实践
最佳实践指的是企业在实际使用过程中总结出来的相对好用的Feign的使用方式。
继承方式
user-service里的controller中查询用户方法和order-service里的UserClient接口里的方法是一致的。
所以考虑给消费者的FeignClient和提供者的controller定义统一的父接口作为标准
抽取方式
将FeignClient抽取为独立模块,并且把接口有关的POJO、默认的Feign配置都放到这个模块中,提供给所有消费者使用
实现步骤:
1.首先创建一个module,命名为feign-api,然后引入feign的starter依赖
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
2.将order-service中编写的UserClient、User、DefaultFeignConfiguration都复制到feign-api项目中
3.在order-service中删去之前的clients,config和pojo包下的User之后引入自己写的feign-api的依赖。
<dependency>
<groupId>cn.itcast.demo</groupId>
<artifactId>feign-api</artifactId>
<version>1.0</version>
</dependency>
4.修改order-service中的所有与上述三个组件有关的import部分,改成导入feign-api中的包
5.还需要修改启动类,得让启动类能扫描到UserClinet,这有两种方式
(1)指定FeignClient所在包(批量引入)
@EnableFeignClients(basePackages = "cn.itcast.feign.clients")
(2)指定FeignClient字节码(精准引入),可以是个数组,精准导入多个类
@EnableFeignClients(clients = {UserClient.class})
统一网关Gateway
作用
我们的微服务在nacos里注册并且发现,用户如果直接能调取微服务的接口的话,存在安全隐患和权限问题,使用一个网关来对用户的请求做处理,用户先访问网关,又网关来处理微服务的调用问题,这样微服务的接口就不会暴露出来,只会在内部可见。
总结下来网关有这么几个作用:1.身份认证和权限校验2.服务路由、负载均衡3.请求限流
在SpringCloud中网关的实现主要包括两种:gateway和zuul,但是Zuul是基于Servlet的实现,属于阻塞式编程。而SpringCloudGateway则是基于Spring5中提供的WebFlux,属于响应式编程的实现,具备更好的性能。
使用
网关的搭建
1.创建新的module,引入SpringCloudGateway的依赖和nacos的服务发现依赖:
<!--网关依赖-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<!--nacos服务发现依赖-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
2.随后写好gateway的启动类
编写路由配置及nacos地址
在resource文件夹下新建application.yml文件,填写以下配置:
server:
port: 10010 # 网关端口
spring:
application:
name: gateway # 服务名称
cloud:
nacos:
server-addr: localhost:8848 # nacos地址
gateway:
routes: # 网关路由配置
- id: user-service # 路由id,自定义,只要唯一即可
# uri: http://127.0.0.1:8081 # 路由的目标地址 http就是固定地址
uri: lb://userservice # 路由的目标地址 lb就是loadbalance(负载均衡),后面跟服务名称
predicates: # 路由断言,也就是判断请求是否符合路由规则的条件
- Path=/user/** # 这个是按照路径匹配,只要以/user/开头就符合要求
- id: order-service
uri: lb://orderservice
predicates:
- Path=/order/**
配置完成后启动,访问网关端口10010,然后按照访问路径请求,发现请求的内容一致。
网关通过路由表来想nacos注册中心拉取服务列表,进行负载均衡后发送具体的请求
断言工厂
我们在配置文件中写的断言规则只是字符串,这些字符串会被Predicate Factory读取并处理,转变为路由判断的条件。
例如Path=/user/** 是按照路径匹配,这个规则是由org.springframework.cloud.gateway.handler.predicate.PathRoutePredicateFactory类来处理的。像这样的断言工厂在SpringCloudGateway还有十几个。
要满足所用断言条件的时候,gateway才会进行相应的跳转。
具体如下:
具体用法可以参照spring的官方文档:Spring Cloud Gateway
比如我们想测试一下After工厂
路由过滤器
工作流程
GatewayFilter是网关中提供的一种过滤器,可以对进入网关的请求和微服务返回的响应做处理:
过滤工厂
Spring提供了31个过滤工厂,Spring Cloud Gateway。
使用
给所有进入userservice的请求添加一个请求头:Truth=itcast is freaking awesome!
实现方式:
在gateway中修改application.yml文件,给userservice的路由添加过滤器
随后在UserController中获取请求头并打印:
请求/user/1,可以发现请求到了,不过访问/order/101的话打印出来的就是null
默认过滤器
如果要对所有的路由都生效,则可以将过滤器工厂写到default下
全局过滤器
全局过滤器的作用也是处理一切进入网关的请求和微服务响应,与GatewayFilter的作用一样。
区别在于GatewayFilter通过配置定义,处理逻辑是固定的。而GlobalFilter的逻辑需要自己写代码实现。
定义方式是实现GlobalFilter接口。
public interface GlobalFilter {
/**
* 处理当前请求,有必要的话通过{@link GatewayFilterChain}将请求交给下一个过滤器处理
*
* @param exchange 请求上下文,里面可以获取Request、Response等信息
* @param chain 用来把请求委托给下一个过滤器
* @return {@code Mono<Void>} 返回标示当前过滤器业务结束
*/
Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain);
}
使用:
定义全局过滤器,拦截并判断用户身份
需求:定义全局过滤器,拦截请求,判断请求的参数是否满足下面条件:
1.参数中是否有authorization,
2.authorization参数值是否为admin
如果同时满足则放行,否则拦截
步骤:
在gateway包下创建AuthorizeFilter类,继承GlobalFilter
package cn.itcast.gateway;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
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 {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
//1.获取请求参数
MultiValueMap<String, String> params = exchange.getRequest().getQueryParams();
//2.获取参数中的authorization参数
String auth = params.getFirst("authorization");
//3.判断参数值是否等于admin
if ("admin".equals(auth)) {
//4.是、放行
return chain.filter(exchange);
//5.否,拦截
}
exchange.getResponse().setStatusCode(HttpStatus.FORBIDDEN);
return exchange.getResponse().setComplete();
}
}
随后测试一下,发现直接访问并且没有带相应的参数时,会直接拒绝访问
带上authorization参数之后,就可以正常访问了:
过滤器执行顺序
请求进入网关会碰到三类过滤器:当前路由的过滤器、DefaultFilter、GlobalFilter
请求路由后,会将当前路由过滤器和DefaultFilter、GlobalFilter,合并到一个过滤器链(集合)中,排序后依次执行每个过滤器,因为所有过滤器都可以转换为GatewayFilter,所以可以进行排序操作。
每一个过滤器都必须指定一个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排序,组织过滤器链
跨域问题解决
跨域,域名不一致就是跨域,主要包括:
1.域名不同: www.taobao.com 和 www.taobao.org 和 www.jd.com 和 miaosha.jd.com
2.域名相同,端口不同:localhost:8080和localhost8081
产生的原因:浏览器禁止请求的发起者与服务端发生跨域ajax请求,请求被浏览器拦截的问题
解决方案:CORS
Docker
认识Docker
Docker出现的原因
在项目部署时,大型项目组件较多,运行环境也较为复杂,部署时会碰到一些问题,比如依赖关系复杂,容易出现兼容性问题;开发、测试、生产环境有差异。
Docker解决依赖兼容性的办法
那么Docker如何解决依赖的兼容问题呢?
1.Docker会将应用的Libs(函数库)、Deps(依赖)、配置与应用一起打包
2.将每个应用放到一个隔离容器去运行,避免互相干扰
Docker的工作原理
首先先介绍系统内核,比如我们熟知的linux就是一种系统内核,但是像centOS和Ubuntu还有fedora,这些都是linux系统内核基础上开发的系统应用,针对项目部署上来,项目部署时先经过系统应用再到系统内核,再由系统内核操作计算机硬件。
不同系统应用提供的不同函数接口就会导致一些兼容性问题。
Docker解决这个问题的方法如下:
1.Docker将用户程序与所需要调用的系统(比如Ubuntu)函数库一起打包
2.Docker运行到不同操作系统时,直接基于打包的库函数,借助于操作系统的Linux内核来运行
总结如下:
1.Docker如何解决大型项目依赖关系复杂,不同组件依赖的兼容性问题?
答:Docker允许开发中将应用、依赖、函数库、配置一起打包,形成可移植镜像。Docker应用运行在容器中,使用沙箱机制,相互隔离。
2.Docker如何解决开发、测试、生产环境有差异的问题
Docker镜像中包含完整运行环境,包括系统函数库,仅依赖系统的Linux内核,因此可以在任意Linux操作系统上运行。
与虚拟机的区别
虚拟机(virtual machine)是在操作系统中模拟硬件设备,然后运行另一个操作系统,比如在 Windows 系统里面运行 Ubuntu 系统,这样就可以运行任意的Ubuntu应用了。
具体区别在于:Docker作为一个进程,而虚拟机是在操作系统中的一个操作系统,docker体积比虚拟机小,速度也比虚拟机快,所需要的资源也比虚拟机要少。
Docker架构
1.镜像(Image):Docker将应用程序及其所需的依赖、函数库、环境、配置等文件打包在一起,称为镜像。
2.容器(Container):镜像中的应用程序运行后形成的进程就是容器,只是Docker会给容器做隔离,对外不可见。每个容器都对互相不可见,运行在容器里的进程会认为只有它一个进程在运行。
3.DockerHub:类似于github(代码托管平台),DockerHub是一个Docker镜像的托管平台。这样的平台称为Docker Registry。
4.Docker是一个CS架构的程序,具体看下图所示:
Docker安装
Docker安装于CentOS7 64 以上的系统,具体可以参照这篇文档:https://wwz.lanzouj.com/iXsz102qaotc,密码是c7jg,若打不开的话可以访问这个阿里云链接:https://www.aliyundrive.com/s/SGakPPLYizn
使用Docker
镜像相关命令
镜像名称一般分两部分组成:[repository]:[tag]。在没有指定tag时,默认是latest,代表最新版本的镜像。比如mysql:5.7和mysql:5.8是两个不同的镜像。
命令有:docker build,用于构建镜像,一般是针对本地的dockerfile镜像文件。docker pull表示从镜像服务器上拉取镜像,docker images查看镜像,docker rmi删除镜像,docker push推送镜像到服务器,docker saveb保存镜像为一个压缩包,docker load加载压缩包为镜像。
可以使用docker --help查看帮助,比如想查看关于images命令具体的详细操作,可以使用docker images --help命令查看帮助。
使用案例:
1.pull命令
2.将刚刚pull下来的nginx镜像save出来
3.删除pull下来的nginx镜像,然后load刚刚save的nginx镜像。
容器相关命令
其中docker ps默认查看的是正在运行的容器,如果带上参数a,就表示也查看停止的容器即docker ps -a
案例一:创建运行一个Nginx容器
步骤一:去docker hub查看Nginx的容器运行命令
docker run --name containerName -p 80:80 -d nginx
命令解读:
(1)docker run:创建并运行一个容器
(2)--name:给容器起一个名字
(3)-p:将宿主机端口与容器端口映射,冒号左侧是宿主机端口,右侧是容器端口
(4)-d:后台运行容器
(5)nginx:镜像名称,例如nginx
运行结果,返回的字符串是全局唯一的用于标识容器的:
随后使用docker ps查看容器信息:
随后访问相应端口查看nginx页面,我的虚拟机的地址是192.168.10.130,直接浏览器访问该地址,得到以下结果:
查看日志命令,带上-f参数就会实时显示更新日志数据:
案例二:进入Nginx容器,修改HTML文件内容
步骤一:进入容器。进入我们刚刚创建的nginx容器的命令为:
docker exec -it mn bash
命令解读:
(1)docker exec:进入容器内部,执行一个命令
(2)-it:给当前进入的容器创建一个标准输入、输出终端,允许我们与容器交互
(3)mn:要进入的容器的名称
(4)bash:进入容器后执行的命令,bash是一个linux终端交互命令
步骤二:进入nginx的HTML所在目录 /usr/share/nginx/html
cd /usr/share/nginx/html
根据dockerhub官方文档可以得知nginx静态页面所在的目录为/usr/share/nginx/html,所以我们进入该目录,可看到有两个页面:
步骤三:修改index.html
我们直接通过vi命令修改内容的话会报错,因为容器里不会默认安装vi命令,所以我们使用替换命令来进行修改。
sed -i 's#Welcome to nginx#思全全全#g' index.html
sed -i 's#<head>#<head><meta charset="utf-8">#g' index.html
可以看到修改成功:
注意:exec命令可以进入容器修改文件,但是在容器内修改文件是不推荐的。
数据卷相关命令
在上述案例二中,我们需要进入容器内部通过bash命令对html文件进行修改,比较麻烦,而且会有些问题,就是关于容器与数据耦合的问题:
1.不便于修改:当我们要修改Nginx的html内容时,需要进入容器内部修改,很不方便。
2.数据不可复用:在容器内的修改对外是不可见的。所有修改对新创建的容器是不可复用的。
3.升级维护困难:数据在容器内,如果要升级容器必然删除旧容器,所有数据都跟着删除了。即容器和数据深度绑定,容器没了那数据也会没有。
对上述问题,docker提出数据卷的概念,数据卷(volume)是一个虚拟目录,指向宿主机文件系统中的某个目录。容器的某个目录直接是一个数据卷,与宿主机上的某个目录互相绑定,当在宿主机相应目录下修改文件时,容器里的对应文件也会修改,同时当容器消失时,宿主机上的数据会被保留下来。
数据卷操作的基本语法如下:
一、创建数据卷
docker volume [COMMAND]
docker volume命令是数据卷操作,根据命令后跟随的command来确定下一步的操作:
1.create 创建一个volume
2.inspect 显示一个或多个volume的信息
3.ls 列出所有的volume
4.prune 删除未使用的volume
5.rm 删除一个或多个指定的volume
二、挂载数据卷
运行容器时使用-v参数挂载数据卷。假设现在docker内部没有nginx容器,现在运行一个挂载数据卷的nginx容器。
docker run --name mn -p 80:80 -v html:/usr/share/nginx/html -d nginx
接着进入宿主机的数据卷目录
直接修改再访问即可。
注意:
1.如果docker run命令进行数据卷挂载时数据卷不存在,那么docker会自动的创建对应的数据卷出来。
2.可以直接基于目录挂载,当目录不存在时,docker会报错
可以具体参考:docker数据卷(数据挂载) - 知乎 (zhihu.com)
镜像使用
目前我们都是使用的别人的镜像文件,那么我们如何自己打包一个镜像文件出来呢?我们先复习一下镜像的相关概念,接着会介绍有关镜像的相关结构。
一、镜像结构
镜像是将应用程序及其需要的系统函数库、环境、配置、依赖打包而成。
二、自定义镜像
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
案例一:基于Ubuntu镜像构建一个新镜像,运行一个java项目
SpringCloud+RabbitMQ+Docker+Redis+搜索+分布式,史上最全面的springcloud微服务技术栈课程|黑马程序员Java微服务_哔哩哔哩_bilibili,P57有详细介绍
其中dockerfile文本文件里包含了相关的命令,具体如下所示:
我们观察这个构建命令,可以观察得出大多数命令都是在配置jdk环境,那么如果我们在已经配置好jdk环境的镜像的基础上再构建,那么就会省事许多了。事实上就有,该镜像为java:8-alpine。
案例二:基于java:8-alpine镜像,将一个Java项目构建为镜像
在案例一的基础上,我们只需要将dockerfile修改成以下内容即可:
注意:指定基础镜像也会被安装到docker中:
DockerCompose
定义
Docker Compose可以基于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"
DockerCompose的详细语法参考官网:https://docs.docker.com/compose/compose-file/
安装DockerCompose
还可以参考这篇文章(4条消息) docker-compose安装教程_w1990end的博客-CSDN博客_docker-compose 安装
将之前写的cloud-demo利用DockerCompose部署
实现步骤:
1.查看课前资料提供的cloud-demo文件夹,里面已经编写好了docker-compose文件
2.修改docker-compose.yml,将数据库,nacos地址都改为本地的一些配置,比如版本号和密码。同时把idea客户端里写的项目的服务名都改成对应的(就是不要再用localhost加端口的形式了)
最外部的docker-compose.yml,内容如下:
version: "3.2"
services:
nacos: #nacos的具体配置
image: nacos/nacos-server
environment:
MODE: standalone
ports:
- "8848:8848"
mysql: #mysql所依赖的镜像的版本号
image: mysql:8.0.11
environment:
MYSQL_ROOT_PASSWORD: root #mysql的root账户的密码
volumes:
- "$PWD/mysql/data:/var/lib/mysql"
- "$PWD/mysql/conf:/etc/mysql/conf.d/"
userservice:
build: ./user-service #表示build的jar包在当前文件夹的user-service夏
orderservice: #并不是每个微服务都对外暴露端口
build: ./order-service
gateway: #网关微服务应对外暴露端口
build: ./gateway
ports:
- "10010:10010"
将user-service里的yml进行修改,其他微服务都做相应的修改
3.使用maven打包工具,将项目中的每个微服务都打包成为app.jar。
在pom.xml中最后一块build中的finalName玉树了最后打包出的jar包的名称:
我们在每个需要打包的微服务的pom.xml中都填上这段代码。
随后使用maven工具对user-service微服务模块进行打包。打包出来的文件就在target目录下
打包完成:
在打包order-service之前要先对父工程即cloud-demo进行clean和install才行,不然直接对order-service进行clean和package的话会报错!
4.将cloud-demo上传至虚拟机,利用docker-compose up -d来部署
部署结果:
直接启动的话,nacos可能启动的慢从而导致依赖它的微服务报错,所以我们可以启动之后再用该命令restart一下:
docker-compose restart gateway userservice orderservice
如果想看对应微服务的相应日志,使用以下命令:
docker-compose logs -f xxx
xxx表示微服务名称
注意:这其中mysql版本还是指定用5.7.25,如果出现数据库拒绝访问,则可以参考下图里的操作:
私有镜像仓库
搭建镜像仓库可以基于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://localhost: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信任地址,因为我们私服采用的是http协议,默认不被Docker信任,所以需要做一个配置:
# 打开要修改的文件
vi /etc/docker/daemon.json
# 添加内容:
"insecure-registries":["http://192.168.150.101:8080"]
# 重加载
systemctl daemon-reload
# 重启docker
systemctl restart docker
图形化界面的私有仓库搭建成功:
推送镜像到私有镜像服务必须先tag,步骤如下:
1.重新tag本地镜像,名称前缀为私有仓库的地址:192.168.10.130:8080/
docker tag nginx:latest 192.168.10.130:8080/nginx:1.0
2.推送镜像
docker push 192.168.10.130:8080/nginx:1.0
3.拉取镜像
docker pull 192.168.10.130:8080/nginx:1.0
推送镜像成功:
MQ
同步通讯和异步通讯
一、同步通讯的优缺点:
1.优点:同步通讯可以实时获得结果
2.缺点:
(1)耦合度高:每次加入新的需求,都要修改原来的代码
(2)性能下降:调用者需要等待服务提供者响应,如果调用链过长则响应时间等于每次调用的时间之和。
(3)资源浪费:调用链中的每个服务在等待响应过程中,不能释放请求占用的资源,高并发场景下会极度浪费系统资源。
(4)级联失败:如果服务提供者出现问题,所有调用方都会跟着出问题,如同多米诺骨牌一样,迅速导致整个微服务群故障。可以想象成一个线上的蚂蚱。
二、异步通讯
异步调用常见实现就是事件驱动模式。
各个微服务订阅事件,当事件发生之后,再进行处理。
1.优点:
(1)服务解耦:因为是事件驱动的,所以当我想再加功能的时候,就直接加就可以了。
(2)性能提升,吞吐量提高:因为是异步的,不需要等结果出来再返回。
(3)服务没有强依赖,不担心级联失败问题:比如当订单服务出现错误的时候,不用担心仓储服务(假设仓储服务和订单服务没有强依赖关系)也不会正常工作。
(4)流量削峰:当流量非常大,事件发生的特别多的时候,事件可以在Broker内排队,因为各个服务是事件驱动的。
2.缺点:
(1)依赖于Broker的可靠性、安全性、吞吐能力,如果Broker出现故障,则会导致问题。
(2)架构复杂了,业务没有明显的流程线,不好追踪管理,因为并不是线性流程,每个微服务的调用顺序可能并不唯一。
MQ含义以及常用的MQ
MQ (MessageQueue),中文是消息队列,字面来看就是存放消息的队列。也就是事件驱动架构中的Broker。
RabbitMQ
RabbitMQ是实现了高级消息队列协议(AMQP)的开源消息代理软件(亦称面向消息的中间件)。RabbitMQ服务器是用Erlang语言编写的,而集群和故障转移是构建在开放电信平台框架上的。所有主要的编程语言均有与代理接口通讯的客户端库。
安装
可以参考这篇pdf,链接如下:https://wwz.lanzouj.com/igS2Q02u39fa 密码:8uv5
结构与概念
1.channel:操作MQ的工具
2.exchange:路由消息到队列中
3.queue:缓存消息
4.virtual host:虚拟主机,是对queue,exchange等资源的逻辑分组
实践
RabbitMQ官方给了几个案例:RabbitMQ Tutorials — RabbitMQ
目前有7个Demo。对应了几种不同的用法:
我们先从最简单的HelloWorld案例开始,官方的HelloWorld是基于最基础的消息队列模型来实现的,只包括三个角色:
1.publisher:消息发布者,将消息发送到队列queue
2.queue:消息队列,负责接受并缓存消息
3.consumer:订阅队列,处理队列中的消息
案例实现:
有关资料:mq-demo.zip在这个链接下https://wwz.lanzouj.com/i8zHo02u9nvg,如果打不开的话可以用这个链接:http://gofile.me/6TtvQ/VLN9PYGmG
1.consumer的代码
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.10.130");//这里要改成虚拟机的ip地址
factory.setPort(5672);//图形化界面的端口是15672,真正服务的端口是5672
factory.setVirtualHost("/");
factory.setUsername("itcast");
factory.setPassword("123321");
// 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("等待接收消息。。。。");
}
}
consumer要设置接收到异步消息时的回调函数
2.publisher的代码:
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.10.130");
factory.setPort(5672);
factory.setVirtualHost("/");
factory.setUsername("itcast");
factory.setPassword("123321");
// 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();
}
}
接收和发送消息的流程如下图所示:
SpringAMQP
在RabbitMQ基础上简化了操作,设计了一些模板类
概念
1.AMQP:
AMQP(高级消息队列协议),即Advanced Message Queuing Protocol,一个提供统一消息服务的应用层标准高级消息队列协议,是应用层协议的一个开放标准,为面向消息的中间件设计。基于此协议的客户端与消息中间件可传递消息,并不受客户端/中间件不同产品,不同的开发语言等条件的限制。
2.Spring AMQP是基于AMQP协议定义的一套API规范,提供了模板来发送和接收消息。包含两部分,其中spring-amqp是基础抽象,spring-rabbit是底层的默认实现。
SpringAmqp的官方地址:https://spring.io/projects/spring-amqp
实践
简单队列
使用Spring AMQP来实现刚刚的HelloWorld的案例。
1.安装依赖,这里把依赖直接放到父工程mq-demo中
<!--AMQP依赖,包含RabbitMQ-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
2.在publisher中编写测试方法实现发送消息逻辑,向simple.queue发送消息
(1)在publisher服务中编写application.yml,添加mq连接信息:
spring:
rabbitmq:
host: 192.168.10.130 # 主机名
port: 5672 # 端口
virtual-host: / # 虚拟主机
username: itcast # 用户名
password: 123321 # 密码
(2)在publisher服务中新建一个测试类,编写测试方法
@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);
}
}
注意:这段代码不包括创建队列,所以如果没有运行第一个实现helloworld案例的话,需要手动在RabbitMQ里先加一个队列,随后运行publisher测试类就可以看到消息了。
3.在consumer中编写消费逻辑,监听simple.queue
(1)在consumer服务中编写application.yml,添加mq连接信息:
spring:
rabbitmq:
host: 192.168.10.130 # 主机名
port: 5672 # 端口
virtual-host: / # 虚拟主机
username: itcast # 用户名
password: 123321 # 密码
(2)在consumer服务中新建一个类,编写消费逻辑:
@Component
public class SpringRabbitListener {
@RabbitListener(queues = "simple.queue")
public void listenSimpleQueueMessage(String msg) throws InterruptedException {
System.out.println("spring 消费者接收到消息 :【" + msg + "】");
}
}
这个SpringRabbitListener是Spring中的一个bean,所以我们吧consumer的spring启动类运行起来就可以了
注意:在RabbitMQ中消息一旦消费了,就消失了,即 阅后即焚,无法回溯
工作队列
原理概念图,有多个消费者,可以提高消息处理速度,避免队列消息堆积。
案例:模拟WorkQueue,实现一个队列绑定多个消费者
基本思路如下:
1.在publisher服务中定义测试方法,每秒产生50条消息,发送到simple.queue
@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);
}
}
2.在consumer服务中定义两个消息监听者,都监听simple.queue队列。消费者1每秒处理50条消息,消费者2每秒处理10条消息
@RabbitListener(queues = "simple.queue")
public void listenSimpleQueueMessage(String msg) throws InterruptedException {
System.out.println("spring 消费者1接收到消息:【" + msg + "】");
Thread.sleep(25);
}
@RabbitListener(queues = "simple.queue")
public void listenSimpleQueueMessage2(String msg) throws InterruptedException {
System.err.println("spring 消费者2接收到消息:【" + msg + "】");
Thread.sleep(100);
}
理论上来说,1秒内就可以处理完所有消息,但是实际运行之后才发现用了5s左右,而且消费者1处理的是偶数消息,消费者2处理的是奇数消息。这是因为RabbitMQ有预取机制,会先分配下去拿过来发下去,分配时是平均分配的,所以会造成这种情况。
可以通过修改application.yml文件,设置preFetch这个值,可以控制预取消息的上限:
spring:
rabbitmq:
host: 192.168.10.130 # 主机名
port: 5672 # 端口
virtual-host: / # 虚拟主机
username: itcast # 用户名
password: 123321 # 密码
listener:
simple:
prefetch: 1 # 每次只能获取一条消息,处理完成才能获取下一个消息
发布订阅模型
发布订阅模式与之前案例的区别就是允许将同一消息发送给多个消费者。实现方式是加入了exchange(交换机)。常见的exchange类型有:①Fanout:广播②Direct:路由③Topic:话题
注意:exchange负责消息路由,而不是存储,路由失败则消息丢失。
一、Fanout交换机
Fanout Exchange 会将接收到的消息广播到每一个跟其绑定的queue
实现如下:利用SpringAMQP演示FanoutExchange的使用
1.在consumer服务中,利用代码声明队列、交换机,并将两者绑定,这里声明时使用的是Bean
@Configuration
public class FanoutConfig {
@Bean // 声明FanoutExchange交换机
public FanoutExchange fanoutExchange() {
return new FanoutExchange("itcast.fanout");
}
@Bean// 声明第1个队列
public Queue fanoutQueue1() {
return new Queue("fanout.queue1");
}
@Bean//绑定队列1到交换机
public Binding fanountBinding1(Queue fanoutQueue1,FanoutExchange fanoutExchange){
return BindingBuilder
.bind(fanoutQueue1)
.to(fanoutExchange);
}
@Bean// 声明第2个队列
public Queue fanoutQueue2() {
return new Queue("fanout.queue2");
}
@Bean//绑定队列1到交换机
public Binding fanountBinding2(Queue fanoutQueue2,FanoutExchange fanoutExchange){
return BindingBuilder
.bind(fanoutQueue2)
.to(fanoutExchange);
}
}
2.在consumer服务的SpringRabbitListener类中,添加两个方法,分别监听fanout.queue1和fanout.queue2:
@RabbitListener(queues = "fanout.queue1")
public void listenFanoutQueue1(String msg){
System.out.println("spring 消费者接收到fanout.queue1的消息 :【" + msg + "】");
}
@RabbitListener(queues = "fanout.queue2")
public void listenFanoutQueue2(String msg){
System.out.println("spring 消费者接收到fanout.queue1的消息 :【" + msg + "】");
}
3.在publisher服务的SpringAmqpTest类中添加测试方法
@Test
public void testSendFanoutExchange(){
String exchangeName="itcast.fanout";//交换机名称
String message="hello,world";//消息
//发送消息
rabbitTemplate.convertAndSend(exchangeName,"",message);
}
总结:
二、DirectExchange交换机
Direct Exchange 会将接收到的消息根据规则路由到指定的Queue,因此称为路由模式(routes)。
1.消息交换的步骤:
(1)每一个Queue都与Exchange设置一个BindingKey,每个queue都可以设置多个bindingKey
(2)发布者发送消息时,指定消息的RoutingKey
(3)Exchange将消息路由到BindingKey与消息RoutingKey一致的队列
2.案例:利用SpringAMQP演示DirectExchange的使用
(1)利用@RabbitListener声明Exchange、Queue、RoutingKey,使用注解的方法的话,直接在SpringRabbitListener类里写如下两个方法即可,不需要再在config类里绑定了。
在consumer服务中,编写两个消费者方法,分别监听direct.queue1和direct.queue2
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "direct.queue1"),
exchange = @Exchange(name = "itcast.direct",type = ExchangeTypes.DIRECT),
key = {"red","blue"}
))
public void listenDirectQueue1(String msg){
System.out.println("spring 消费者接收到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("spring 消费者接收到direct.queue2的消息 :【" + msg + "】");
}
(2)在publisher中编写测试方法,向itcast. direct发送消息
@Test
public void testSendDirectExchange(){
String exchangeName="itcast.direct";//交换机名称
String message="hello,blue";//消息
//发送消息
rabbitTemplate.convertAndSend(exchangeName,"blue",message);
}
总结:
三、TopicExchange交换机
TopicExchange与DirectExchange类似,区别在于routingKey必须是多个单词的列表,并且以 . 分割。
Queue与Exchange指定BindingKey时可以使用通配符:
#:代指0个或多个单词
*:代指一个单词
案例:
步骤1:在consumer服务声明Exchange、Queue
步骤2:在publisher服务的SpringAmqpTest类中添加测试方法:
消息转换器
直接发送object类型会被RabbitMQ通过jdk序列化进行传输,Spring的对消息对象的处理是由org.springframework.amqp.support.converter.MessageConverter来处理的。而默认实现是SimpleMessageConverter,基于JDK的ObjectOutputStream完成序列化。
但是默认的jdk序列化有一些缺点,消耗大,而且可能有注入风险。
如果要修改只需要定义一个MessageConverter 类型的Bean即可。推荐用JSON方式序列化,步骤如下:
1.我们在publisher服务引入依赖
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
2.我们在publisher服务声明MessageConverter:
@Bean
public MessageConverter jsonMessageConverter(){
return new Jackson2JsonMessageConverter();
}
3.我们在consumer服务引入Jackson依赖(类似第一步)
4.我们在consumer服务定义MessageConverter:
@Bean
public MessageConverter jsonMessageConverter(){
return new Jackson2JsonMessageConverter();
}
4.然后定义一个消费者,监听object.queue队列并消费消息:
@RabbitListener(queues = "object.queue")
public void listenObjectQueue(Map<String, Object> msg) {
System.out.println("收到消息:【" + msg + "】");
}
运行结果,以json格式的话,可读性就比较高了。
总结:
Elasticsearch
介绍
Elasticsearch是一个基于Lucene的搜索服务器。它提供了一个分布式多用户能力的全文搜索引擎,基于RESTful web接口。Elasticsearch是用Java语言开发的,并作为Apache许可条款下的开放源码发布,是一种流行的企业级搜索引擎。
Elasticsearch用于云计算中,能够达到实时搜索,稳定,可靠,快速,安装使用方便。官方客户端在Java、.NET(C#)、PHP、Python、Apache Groovy、Ruby和许多其他语言中都是可用的。根据DB-Engines的排名显示,Elasticsearch是最受欢迎的企业搜索引擎,其次是Apache Solr,也是基于Lucene。
Elasticsearch结合kibana、Logstash、Beats,也就是elastic stack(ELK)。被广泛应用在日志数据分析、实时监控等领域。而Elasticsearch是elastic stack(elastic技术栈)的核心,负责存储、搜索、分析数据。
发展历程:
ElasticSearch是基于Lucene的,那么Lucene是什么呢?
Lucene是一个Java语言的搜索引擎类库,是Apache公司的顶级项目,由DougCutting于1999年研发。
官网地址:https://lucene.apache.org/ 。
Lucene的优势:①易拓展 ②高性能(基于倒排索引)
劣势:①只限于Java语言开发 ②学习曲线陡峭 ③不支持水平拓展
那么相对于lucene,elasticsearch具备的优势有:①支持分布式,可水平拓展 ②支持Restful接口,可被任何语言调用
正向索引与倒排索引
我们之前在mysql中,用的就是正向索引(可以简称为索引)。正向索引是以关键字为主码,查询时需要遍历每一个文件。每个文件都对应一个文件ID,文件内容被表示为一串关键词的集合。实际上在搜索引擎索引库中,关键词也已经转换为关键词ID。这样的数据结构就称为正向索引。
假如我们搜索手机,那么在正向索引技术里面,就会逐条扫描记录,把title中包含手机的记录存到结果集中。
一、词条、文档、词条字典以及倒排列表
倒排索引使用了文档和词条的概念,文档(document):每条数据就是一个文档;词条(term):文档按照语义分成的词语。
elasticsearch是面向文档存储的,可以是数据库中的一条商品数据,一个订单信息。文档数据会被序列化为json格式后存储在elasticsearch中。
还有词条词典和倒排列表的概念如下:
二、索引与映射
索引(index):相同类型的文档的集合;映射(mapping):索引中文档的字段约束信息,类似表的结构约束。
与Mysql的对比
一、概念对比
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 |
SQL通过connection发送,而DSL只需要通过http请求发送即可,所以DSL不受语言的限制。
二、架构
Mysql擅长事务类(事务的ACID原则)型操作,可以确保数据的安全和一致性;
Elasticsearch擅长海量数据的搜索、分析、计算。
使用
安装Elasticsearch
参考这篇pdf:https://wwz.lanzouj.com/ieCAK02vrnsh 密码是:hsn1;若打不开则点击这个链接:http://gofile.me/6TtvQ/xA39x1Lkr
es7.12.1的镜像:http://gofile.me/6TtvQ/09DpNsxtK
kibana的镜像:http://gofile.me/6TtvQ/0YDiefAd7
分词器
es在创建倒排索引时需要对文档分词;在搜索时,需要对用户输入内容分词。但默认的分词规则对中文处理并不友好。比如对“我是一个大帅哥”进行分词,我们可以在kibana的DevTools中提交POST请求进行测试:
POST /_analyze
{
"analyzer": "standard",
"text": "我是一个大帅哥"
}
这里的/_analyze是请求路径,省略了本机的ip地址(因为有kibana帮我们补充)。请求参数是json风格的:analyzer是分词器的类型,text是要分词的内容。
默认的分词器把这句话里的每个字都分出来了,可见不是很合理。
那么我们处理对中文进行分词操作的时候,一般会使用IK分词器。https://github.com/medcl/elasticsearch-analysis-ik ,安装教程参考《安装elasticsearch.md》。其中所需的压缩包可以见:https://wwz.lanzouj.com/iziBH02wkszi或者http://gofile.me/6TtvQ/INfQAifON
如果查看日志发现有这个情况,我查了一下好像没大碍,似乎是es在恢复数据,我自己提交ik分词器请求也正常返回。
IK分词器有两种模式:①ik_smart:最少切分,粗粒度 ②ik_max_word:最细切分,细粒度(简而言之就是会分更多的词出来);那么我们使用ik_smart对刚刚的话进行切分。
拓展词典
有些词语可能不存在于原有的词汇列表。比如“奥利给”。所以我们的词汇也需要不断的更新,IK分词器提供了扩展词汇的功能。
要拓展ik分词器的词库,只需要修改一个ik分词器目录中的config目录中的IkAnalyzer.cfg.xml文件:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE properties SYSTEM "http://java.sun.com/dtd/properties.dtd">
<properties>
<comment>IK Analyzer 扩展配置</comment>
<!--用户可以在这里配置自己的扩展字典 -->
<entry key="ext_dict">ext.dic</entry>
<!--用户可以在这里配置自己的扩展停止词字典-->
<entry key="ext_stopwords">stopword.dic</entry>
<!--用户可以在这里配置远程扩展字典 -->
<!-- <entry key="remote_ext_dict">words_location</entry> -->
<!--用户可以在这里配置远程扩展停止词字典-->
<!-- <entry key="remote_ext_stopwords">words_location</entry> -->
</properties>
然后新建ext.dic和stopword.dic,并且编辑
重启es后进行测试:
索引库操作:DSL语法
mapping属性
mapping是对索引库中文档的约束,常见的mapping属性包括:
1.type:字段数据类型,常见的简单类型有:
(1)字符串:text(可分词的文本)、keyword(精确值,即不可分的,例如:品牌、国家、ip地址),比如ip地址就不用再继续分词了
(2)数值:long、integer、short、byte、double、float
(3)布尔:boolean
(4)日期:date
(5)对象:object
2.analyzer:使用哪种分词器
3.properties:该字段的子字段
4.index:是否创建倒排索引,默认为true
注意:在ES中没有数组的概念,但是一个字段可以有多个值,比如下图中的score
创建索引库
若要创建上图的索引,命令如下所示:创建一个名为heima的索引表
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"
}
}
}
}
}
}
结果:
查看、删除索引库
1.查看
GET /索引库名称
比如:GET /heima 得到的结果如下:
2.删除
DELETE /索引库名
修改索引库
索引库和mapping一旦创建无法修改,但是可以添加新的字段,语法如下:
PUT /索引库名/_mapping
{
"properties": {
"新字段名":{
"type": "integer"
}
}
}
比如:
PUT /heima/_mapping
{
"properties":{
"age":{
"type":"integer"
}
}
}
文档操作:DSL语法
文档可以简单理解为sql中的一条记录。
新增文档
POST /索引库名/_doc/文档id
{
"字段1": "值1",
"字段2": "值2",
"字段3": {
"子属性1": "值3",
"子属性2": "值4"
},
// ...
}
示例:
POST /heima/_doc/1
{
"info": "黑马程序员Java讲师",
"email": "zy@itcast.cn",
"name": {
"firstName": "云",
"lastName": "赵"
}
}
查看文档
GET /索引库名/_doc/文档id
示例:GET /heima/_doc/1
删除文档
DELETE /索引库名/_doc/文档id
示例: DELETE /heima/_doc/1
修改文档
我又新增了一个id为1的文档为:
POST /heima/_doc/1
{
"info": "黑马程序员Java讲师",
"email": "zy@itcast.cn",
"name": {
"firstName":"云",
"lastName":"陈"
}
}
修改文档有两种方式:
1.全量修改,会删除旧文档,添加新文档
PUT /索引库名/_doc/文档id
{
"字段1": "值1",
"字段2": "值2",
// ... 略
}
实例:
PUT /heima/_doc/1
{
"info": "黑马程序员高级Java讲师",
"email": "zy@itcast.cn",
"name": {
"firstName": "云",
"lastName": "赵"
}
}
注意:如果要修改的文档不存在,那么PUT命令就会新增一个文档
2.增量修改,修改指定字段值
POST /索引库名/_update/文档id
{
"doc": {
"字段名": "新的值",
}
}
实例:
POST /heima/_update/1
{
"doc": {
"email": "ZhaoYun@qq.com"
}
}
注意点:如果新增文档结构与mapping结果不一致,会报如下错:
RestClient
ES官方提供了各种不同语言的客户端,用来操作ES。这些客户端的本质就是组装DSL语句,通过http请求发送给ES。官方文档地址:https://www.elastic.co/guide/en/elasticsearch/client/index.html
操作索引库
案例:利用JavaRestClient实现创建、删除索引库,判断索引库是否存在
根据课前资料提供的酒店数据创建索引库,索引库名为hotel,mapping属性根据数据库结构定义。
资料地址:https://wwz.lanzouj.com/iTKkC02yo87g 或者 http://gofile.me/6TtvQ/yP7pExd2U
基本步骤如下:
1.导入课前资料Demo
(1)先导入sql文件,新创一个数据库名叫heima,接着在heima数据库下执行tb_hotel.sql,数据库的结构如下:
(2)用idea打开hotel-demo项目文件夹
2.分析数据结构,定义mapping属性
(1)需要考虑字段名、数据类型、是否参与搜索、是否分词、如果分词,分词器是什么?
#酒店的mapping
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"
},
"starName":{
"type": "keyword"
},
"business":{
"type": "keyword",
"copy_to": "all"
},
"location":{
"type": "geo_point"
},
"pic":{
"type": "keyword",
"index": false
},
"all":{
"type": "text",
"analyzer": "ik_max_word"
}
}
}
}
这里把经纬度合在一起成为一个整体,使用了es中的geo_point类型(ES中支持两种地理坐标数据类型:①•geo_point:由纬度(latitude)和经度(longitude)确定的一个点。例如:"32.8752345,120.2981576"②有多个geo_point组成的复杂几何图形。例如一条直线,"LINESTRING (-77.03653 38.897676, -77.009051 38.889939)")。
使用copy_to函数生成all字段:
同时还使用了copy_to,将多个字段拷贝到指定字段,然后对指定字段创建倒排索引,这样在多条件查询下就会快一些,可以想成复合索引。这个all字段直接查是查不出来的,但是它是存在的,这个all字段在后面全文检索查询时有用。
3.初始化JavaRestClient
(1)引入es的RestHighLevelClient依赖:
<dependency>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-high-level-client</artifactId>
</dependency>
(2)因为SpringBoot默认的ES版本是7.6.2,所以我们需要覆盖默认的ES版本:
<properties>
<java.version>1.8</java.version>
<elasticsearch.version>7.12.1</elasticsearch.version>
</properties>
(3)初始化RestHighLevelClient:
可以在test文件夹下,新创建一个HotelIndexTest的测试类
public class HotelIndexTest {
private RestHighLevelClient client;
@Test
void testInit(){System.out.println(client);}
@BeforeEach//在一开始就完成对象的初始化
void setUp(){
this.client = new RestHighLevelClient(RestClient.builder(
HttpHost.create("http://192.168.10.130:9200")
));
}
@AfterEach//销毁对象
void tearDown() throws IOException {this.client.close();}
}
打印的RestHighLevelClient如下:
4.利用JavaRestClient创建索引库
@Test
void createHotelIndex() throws IOException {
//1.创建Request对象
CreateIndexRequest request = new CreateIndexRequest("hotel");
//2.准备请求的参数(即DSL语句)
request.source(MAPPING_TEMPLATE, XContentType.JSON);//这里我把json语句存到常量里了
//3.发送请求
client.indices().create(request, RequestOptions.DEFAULT);
}
5.利用JavaRestClient删除索引库
@Test
void testDeleteHotelIndex() throws IOException {
//1.创建Request对象
DeleteIndexRequest request=new DeleteIndexRequest("hotel");
//2.发起请求
client.indices().delete(request,RequestOptions.DEFAULT);
}
6.利用JavaRestClient判断索引库是否存在
@Test
void testExistsHotelIndex() throws IOException {
//1.创建Request对象
GetIndexRequest request = new GetIndexRequest("hotel");
//2.发起请求
boolean exists = client.indices().exists(request, RequestOptions.DEFAULT);
System.out.println(exists);
}
操作文档
利用JavaRestClient实现文档的CRUD,去数据库查询酒店数据,导入到hotel索引库,实现酒店数据的CRUD。基本步骤如下:
1.初始化JavaRestClient,与操作索引库类似,略
2.利用JavaRestClient新增酒店数据到索引库,与DSL语法对应,很好理解
@Test
void testIndexDocument() throws IOException {
// 1.创建request对象
IndexRequest request = new IndexRequest("indexName").id("1");
// 2.准备JSON文档
request.source("{\"name\": \"Jack\", \"age\": 21}", XContentType.JSON);
// 3.发送请求
client.index(request, RequestOptions.DEFAULT);
}
在本案例中使用如下:
@Test
void testIndexDocument() throws IOException {
Hotel hotel = hotelService.getById(61083L); //根据id查询酒店数据
HotelDoc hotelDoc=new HotelDoc(hotel);//转换为文档类型
// 1.创建request对象
IndexRequest request = new IndexRequest("hotel").id(hotel.getId().toString());
// 2.准备JSON文档
request.source(JSON.toJSONString(hotelDoc), XContentType.JSON);
// 3.发送请求
client.index(request,RequestOptions.DEFAULT);
}
3.利用JavaRestClient根据id查询酒店数据
@Test
void testGetDocumentById() throws IOException {
// 1.创建request对象
GetRequest request = new GetRequest("hotel", "61083");
// 2.发送请求,得到结果
GetResponse response = client.get(request, RequestOptions.DEFAULT);
// 3.解析结果
String json = response.getSourceAsString();
System.out.println(json);
}
4.利用JavaRestClient删除酒店数据
@Test
void testDeleteDocumentById() throws IOException {
// 1.创建request对象
DeleteRequest request = new DeleteRequest("hotel", "61083");
// 2.删除文档
client.delete(request, RequestOptions.DEFAULT);
}
5.利用JavaRestClient修改酒店数据
(1)全量更新。再次写入id一样的文档,就会删除旧文档,添加新文档
(2)局部更新。只更新部分字段,我们演示方式二
@Test
void testUpdateDocumentById() throws IOException {
// 1.创建request对象
UpdateRequest request = new UpdateRequest("hotel", "61083");
// 2.准备参数,每2个参数为一对 key value
request.doc(
"price", "666 "
);
// 3.更新文档
client.update(request, RequestOptions.DEFAULT);
}
批量导入酒店数据到ES
需求:批量查询酒店数据,然后批量导入索引库中
思路:
1.利用mybatis-plus查询酒店数据
2.将查询到的酒店数据(Hotel)转换为文档类型数据(HotelDoc)
3.利用JavaRestClient中的Bulk批处理,实现批量新增文档,示例代码如下
@Test
void testBulkRequest() throws IOException {
//1.创建Request
BulkRequest request=new BulkRequest();
//批量查询酒店数据
List<Hotel> hotels = hotelService.list();
//2.准备参数,准备多个新增的Request
for(Hotel hotel:hotels){
//转换为文档类HotelDoc
HotelDoc hotelDoc=new HotelDoc(hotel);
//创建新增文档的Request对象
request.add(new IndexRequest("hotel")
.id(hotelDoc.getId().toString())
.source(JSON.toJSONString(hotelDoc),XContentType.JSON));
}
//3.最后集体发送Bulk的Request请求
client.bulk(request,RequestOptions.DEFAULT);
}
然后在浏览器使用批量查询(GET /hotel/_search),结果如下:
DSL查询文档
DSL Query的分类
Elasticsearch提供了基于JSON的DSL(Domain Specific Language)来定义查询。常见的查询类型包括:
1.查询所有:查询出所有数据,一般测试用。例如:match_all。一般不会查所有出来,可能会给你分页,一次查20个出来
2.全文检索(full text)查询:利用分词器对用户输入内容分词,然后去倒排索引库中匹配。例如:(1)match_query (2)multi_match_query
3.精确查询:根据精确词条值查找数据,一般是查找keyword、数值、日期、boolean等类型字段。例如:(1)ids,根据id精确匹配 (2)range,在数值一定范围内查询(3)term,按照数据的值查询
4.地理(geo)查询:根据经纬度查询。例如:(1)geo_distance (2)geo_bounding_box
5.复合(compound)查询:复合查询可以将上述各种查询条件组合起来,合并查询条件。例如:(1)bool (2)function_score
语法
查询的基本语法如下:
GET /indexName/_search
{
"query": {
"查询类型": {
"查询条件": "条件值"
}
}
}
查询所有,默认一次返回十条数据:
全文检索查询
1.match查询:全文检索查询的一种,会对用户输入内容分词(比如我输入“如家外滩”,es就会分词为如家和外滩两个关键词),然后去倒排索引库检索,语法:
PS:左上角是语法,左下角是实际用法,右半边是实际用法的查询结果
2.multi_match:与match查询类似,只不过允许同时查询多个字段,语法:
其实这俩搜索结果是一致的,因为我在match里搜索的字段是all,all字段是由多个子字段copy进来从而复合而成的一个字段,all的子字段就包含brand,name,business。不过参与搜索的字段越多,搜索效率就越低,在有all字段的情况下,推荐使用第一种mathc的方式进行搜索。
精确查询
精确查询一般是查找keyword、数值、日期、boolean等类型字段。所以不会对搜索条件分词。常见的有:(1)term:根据词条精确值查询(2)range:根据值的范围查询
1.term查询
2.range查询
地理查询
官方文档:https://www.elastic.co/guide/en/elasticsearch/reference/current/geo-queries.html
根据经纬度查询,常见的使用场景包括:携程里搜索我附近的酒店;滴滴搜索我附近的出租车等等
1.geo_bounding_box:查询geo_point值落在某个矩形范围的所有文档
比如在酒店的案例中,field就是location字段
2.geo_distance:查询到指定中心点小于某个距离值的所有文档
运用到酒店案例中,搜索结果如下:
复合查询
复合查询可以将其它简单查询组合起来,实现更复杂的搜索逻辑
算分函数
fuction score:算分函数查询,可以控制文档相关性算分,控制文档排名。例如百度竞价。
在elasticsearch5.0之前,采用的算分算法是TF-IDF,其中的特点是会随着词频增加而越来越大。在elasticsearch5.0之后,采用的BM25算法,会随着词频增加而增大,但增长曲线会趋于水平。
如果想人工干预算分过程,则可以使用使用 function score query,可以修改文档的相关性算分(query score),根据新得到的算分排序。
案例:给“如家”这个品牌的酒店排名靠前一些
把这个问题翻译一下,function score需要的三要素:
1.哪些文档需要算分加权?答:品牌为如家的酒店
2.算分函数是什么?答:weight就可以
3.加权模式是什么?答:求和或者相乘(默认的加权模式就是相乘)
布尔查询
一、介绍
布尔查询是一个或多个查询子句的组合。子查询的组合方式有:
(1)must:必须匹配每个子查询,类似“与”。
(2)should:选择性匹配子查询,类似“或”。
(3)must_not:必须不匹配,不参与算分,类似“非”。
(4)filter:必须匹配,不参与算分。
除了关键字需要参与算分,其他的字段应该尽量放在must_not或者filter中,尽量减少算分提高效率
二、用法
三、案例:
搜索名字包含“如家”,价格不高于400,在坐标31.21,121.5周围10km范围内的酒店。
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}
}
}
]
}
}
}
排序查询
elasticsearch支持对搜索结果排序,默认是根据相关度算分(_score)来排序。可以排序字段类型有:keyword类型、数值类型、地理坐标类型、日期类型等。注意一旦使用了排序查询,原来的打分score就没有意义了,es就不会对查询进行算分操作了。
常见的两种查询格式如下:
1.sort数组里可以有多个排序字段
GET /indexName/_search
{
"query": {
"match_all": {}
},
"sort": [
{
"FIELD": "desc" // 排序字段和排序方式ASC、DESC
}
]
}
2.按照地理位置的经纬度来排序查询
GET /indexName/_search
{
"query": {
"match_all": {}
},
"sort": [
{
"_geo_distance" : {
"FIELD" : "纬度,经度",
"order" : "asc",
"unit" : "km"
}
}
]
}
案例一:对酒店数据按照用户评价降序排序,评价相同的按照价格升序排序。
分析:评价是score字段,价格是price字段,按照顺序添加两个排序规则即可。
案例二:实现对酒店数据按照到你的位置坐标的距离升序排序(我们假设坐标是121,31)
分页操作
elasticsearch 默认情况下只返回top10的数据。而如果要查询更多数据就需要修改分页参数了。elasticsearch中通过修改from、size参数来控制要返回的分页结果:
GET /hotel/_search
{
"query": {
"match_all": {}
},
"from": 990, // 分页开始的位置,默认为0
"size": 10, // 期望获取的文档总数
"sort": [
{"price": "asc"}
]
}
es分页的底层原理是,排序、获取前1000条数据,截取990~1000条文档数据。
不过我们在实际工作生产环境中,会把es部署到集群上以存储更多的数据。ES是分布式的,所以会面临深度分页问题。例如按price排序后,获取from = 990,size =10的数据,步骤如下:
(1)首先在每个数据分片上都排序并查询前1000条文档。
(2)然后将所有节点的结果聚合,在内存中重新排序选出前1000条文档
(3)最后从这1000条中,选取从990开始的10条文档
如果搜索页数过深,或者结果集(from + size)越大,对内存和CPU的消耗也越高。因此ES设定结果集查询的上限是10000。
但是如果要深度分页,有两种解决方案:官方文档
(1)search after:分页时需要排序,原理是从上一次的排序值开始,查询下一页数据。官方推荐使用的方式。
(2)scroll:原理将排序数据形成快照,保存在内存。官方已经不推荐使用。
优点 | 缺点 | 场景 | |
---|---|---|---|
from + size | 支持随机翻页 | 深度分页问题,默认查询上限(from + size)是10000 | 百度、京东、谷歌、淘宝这样的随机翻页搜索 |
after search | 没有查询上限(单次查询的size不超过10000) | 只能向后逐页查询,不支持随机翻页 | 没有随机翻页需求的搜索,例如手机向下滚动翻页 |
scroll | 没有查询上限(单次查询的size不超过10000) | 会有额外内存消耗,并且搜索结果是非实时的 | 海量数据的获取和迁移。从ES7.1开始不推荐,建议用 after search方案。 |
高亮
就是在搜索结果中把搜索关键字突出显示。高亮的例子如下:
原理:(1)将搜索结果中的关键字用标签标记出来 (2)在页面中给标签添加css样式
GET /hotel/_search
{
"query": {
"match": {
"FIELD": "TEXT"
}
},
"highlight": {
"fields": { // 指定要高亮的字段
"FIELD": {
"pre_tags": "<em>", // 用来标记高亮字段的前置标签
"post_tags": "</em>" // 用来标记高亮字段的后置标签
}
}
}
}
因为是要关键字高亮,所以要用match查询而不是match_all查询。默认只有高亮的字段与查询字段一致时才会高亮,但是我们想查all字段,而想对name字段进行高亮时(即查询字段和高亮字段不匹配时),可以使用"require_field_match"="false"来设置。
整体语法的一个汇总:
GET /hotel/_search
{
"query": {
"match": {
"name": "如家"
}
},
"from": 0, // 分页开始的位置
"size": 20, // 期望获取的文档总数
"sort": [
{ "price": "asc" }, // 普通排序
{
"_geo_distance" : { // 距离排序
"location" : "31.040699,121.618075",
"order" : "asc",
"unit" : "km"
}
}
],
"highlight": {
"fields": { // 高亮字段
"name": {
"pre_tags": "<em>", // 用来标记高亮字段的前置标签
"post_tags": "</em>" // 用来标记高亮字段的后置标签
}
}
}
}
使用RestClient查询文档
快速入门
使用match_all查询语句来观察RestClient请求DSL的命令结构:
并且对结果进行解析:
在idea中进行测试:
在RestClient里需要特别注意的两个aip:
一、HighLevelRestClient.source()
其中包含了查询、排序、分页、高亮等所有功能,RestAPI中其中构建DSL就是通过它来完成的。
二、QueryBuilders
RestAPI中其中构建查询条件的核心部分是由一个名为QueryBuilders的工具类提供的,其中包含了各种查询方法:
查询的基本步骤为:
1.创建SearchRequest对象
2.准备Request.source(),也就是DSL:
(1)QueryBuilders来构建查询条件
(2)传入Request.source() 的 query() 方法
3.发送请求,得到结果
4.解析结果(参考JSON结果,从外到内,逐层解析)
全文检索查询
全文检索的match和multi_match查询与match_all的API基本一致。差别是查询条件,也就是query的部分。
同样是利用QueryBuilders提供的方法:
// 单字段查询
QueryBuilders.matchQuery("all", "如家");
// 多字段查询
QueryBuilders.multiMatchQuery("如家", "name", "business");
完整代码如下:
@Test
void testSearch() 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);//将结果解析的代码抽取出来
}
其中的handleResponse是抽取了解析结果的代码,在idea中可以使用快捷键ctrl+alt+m来进行方法抽取。
精确查询
精确查询常见的有term查询和range查询,同样利用QueryBuilders实现:
// 词条查询
QueryBuilders.termQuery("city", "杭州");
// 范围查询
QueryBuilders.rangeQuery("price").gte(100).lte(150);
符合查询
精确查询常见的有term查询和range查询,同样利用QueryBuilders实现:
@Test
void testSearch() throws IOException {
//1.准备Request
SearchRequest request = new SearchRequest("hotel");
//2.准备DSL
//2.1 创建布尔查询
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
//2.2 添加must条件
boolQuery.must(QueryBuilders.termQuery("city","上海"));
boolQuery.filter(QueryBuilders.rangeQuery("price").lte(250).gte(150));//价格小于250大于150的
request.source().query(boolQuery);
//3.发送请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
handleResponse(response);//将结果解析的代码抽取出来
}
排序和分页
排序和分页本质上是对查出来的结果进行一些处理,request.source对应的DSL语句中最外围最大的大括号。而在大括号内部"query"和“sort”或者"highlight"都是平级的,所以分页还有排序都是对source里的api进行调用。
request.source().query(QueryBuilders.matchAllQuery());// 查询
request.source().from(0).size(5);// 分页,调用时支持链式编程
request.source().sort("price", SortOrder.ASC);// 价格排序
高亮
与排序和分页用法类似,具体如下
这里标注高亮之后还需要做结果解析,处理较麻烦,我们获得的是source,而highlight是与source同级的:
代码如下:
@Test
void testHighlight() throws IOException {
int page = 1 , size = 5;//可以定义页码和每页容纳的文档数目,可以方便之后做分页需求
SearchRequest request = new SearchRequest("hotel");//1.准备Request
//2.准备DSL
//2.1 query
request.source().query(QueryBuilders.matchQuery("all","如家"));
// 2.2 排序sort
request.source().sort("price", SortOrder.ASC);
//2.3分页
request.source().from((page-1)*size).size(size);
//2.4高亮
request.source().highlighter(new HighlightBuilder()
.field("name")
.requireFieldMatch(false));
//3.发送请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
handleResponse_highlight(response);//将结果解析的代码抽取出来
}
其中解析结果的代码如下:
private void handleResponse_highlight(SearchResponse response) {
SearchHits searchHits = response.getHits();//4.结果解析
long total = searchHits.getTotalHits().value;//4.1 查询的总条数
System.out.println("共搜索到"+total+"条数据");
SearchHit[] hits = searchHits.getHits();//4.2 查询的结果数组
for (SearchHit hit:hits){
String json = hit.getSourceAsString();//4.3 得到source
HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class);//反序列化
Map<String, HighlightField> highlightFields = hit.getHighlightFields();//获取高亮
if(!CollectionUtils.isEmpty(highlightFields)){ //使用Spring自带的工具类进行集合判空的操作
HighlightField highlightField = highlightFields.get("name");//根据字段名获取高亮结果
if(highlightField != null){
String name = highlightField.getFragments()[0].string();//获取高亮结果
hotelDoc.setName(name);//覆盖非高亮结果
System.out.println("hotelDoc = "+ hotelDoc);//4.4 打印
}
}
}
}
运行结果如下:
总结:对结果处理时,记住一个API,即SearchRequest的source()方法,从该方法入手进行构建结果的操作。
黑马旅游案例
完成关键字搜索和分页
在课前提供的hotel-demo项目中,自带了前端页面,启动之后就可以看到,在HotelDemoApplication将启动类启动,随后访问8089即可。
先实现其中的关键字搜索功能,实现步骤如下:
1.定义实体类,接收前端请求,定义类如下:
@Data
public class RequestParams {
private String key;
private Integer page;
private Integer size;
private String sortBy;
}
2.定义controller接口,接收页面请求,调用IHotelService的search方法
(1)定义一个HotelController,声明查询接口,满足下列要求:
①请求方式:POST
②请求路径:/hotel/list
③请求参数:对象,类型为RequestParam
④返回值:PageResult类,包含两个属性:Long total:总条数;List
@Data
public class PageResult {
private Long total;
private List<HotelDoc> hotels;
}
controller类(可以自己创建一个名为web的包,把controller放在其下)定义如下:
@RestController
@RequestMapping("/hotel")
public class HotelController {
@Autowired
private IHotelService hotelService;
@PostMapping("/list")
public PageResult search(@RequestBody RequestParams params) throws IOException {
return hotelService.search(params);
}
}
注意:我们在test中每次进行RestClient操作时都创建连接,通过@BeforeEach注释来完成的,在实际开发中,我们可以将RestHighLevelClient作为Bean注入Spring中,之后就可以在需要用到的地方使用@Autowired进行注入操作。在HotelDemoApplication启动类中添加如下代码即可:
@Bean
public RestHighLevelClient client(){
return new RestHighLevelClient(RestClient.builder(
HttpHost.create("http://192.168.10.130:9200")
));
}
3.定义IHotelService中的search方法,利用match查询实现根据关键字搜索酒店信息
(1)接口:
public interface IHotelService extends IService<Hotel> {
PageResult search(RequestParams params) throws IOException;
}
(2)实现类:
@Service
public class HotelService extends ServiceImpl<HotelMapper, Hotel> implements IHotelService {
@Autowired
private RestHighLevelClient client;
@Override
public PageResult search(RequestParams params) throws IOException {
//1.准备Request
SearchRequest request = new SearchRequest("hotel");
//2.准备DSL
//2.1query
String key = params.getKey();
if(key==null||"".equals(key)){//考虑健壮性
request.source().query(QueryBuilders.matchAllQuery());
}else{
request.source().query(QueryBuilders.matchQuery("all",key));
}
//2.2分页
int page = params.getPage();
int size = params.getSize();
request.source().from((page - 1) * size).size(size);
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
return handleResponse(response);//将结果解析的代码抽取出来
}
private PageResult 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();
List<HotelDoc> hotels = new ArrayList<>();
for (SearchHit hit:hits){
//4.3 得到source
String json = hit.getSourceAsString();
//反序列化
HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class);
hotels.add(hotelDoc);
}
//4.4封装返回
return new PageResult(total,hotels);
}
}
注意前端写的发送的请求格式是这样的:
所以我们才能知道params里get的是什么。
添加品牌、城市、星级、价格等过滤功能
步骤:
1.修改RequestParams类,添加brand、city、starName、minPrice、maxPrice等参数
@Data
public class RequestParams {
private String key;
private Integer page;
private Integer size;
private String sortBy;
private String brand;
private String starName;
private Integer minPrice;
private Integer maxPrice;
}
2.修改search方法的实现,在关键字搜索时,如果brand等参数存在,对其做过滤。
(1)过滤条件包括:
①city精确匹配②brand精确匹配③starName精确匹配④price范围过滤
(2)注意事项:
①多个条件之间是AND关系,组合多条件用BooleanQuery
②参数存在才需要过滤,做好非空判断
@Service
public class HotelService extends ServiceImpl<HotelMapper, Hotel> implements IHotelService {
@Autowired
private RestHighLevelClient client;
@Override
public PageResult search(RequestParams params) throws IOException {
//1.准备Request
SearchRequest request = new SearchRequest("hotel");
//2.准备DSL
//2.1query
//构建BooleanQuery
buildBasicQuery(params, request);
//2.2分页
int page = params.getPage();
int size = params.getSize();
request.source().from((page - 1) * size).size(size);
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
return handleResponse(response);//将结果解析的代码抽取出来
}
private void buildBasicQuery(RequestParams params, SearchRequest request) {
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
// 关键字搜索
String key = params.getKey();
if(key==null||"".equals(key)){//考虑健壮性
boolQuery.must(QueryBuilders.matchAllQuery());
}else{
boolQuery.must(QueryBuilders.matchQuery("all",key));
}
if (params.getCity() != null && !params.getCity().equals("")){//城市条件
//因为不参加算分,所以放到filter里
boolQuery.filter(QueryBuilders.termQuery("city", params.getCity()));
}
if (params.getBrand() != null && !params.getBrand().equals("")){//品牌条件
//因为不参加算分,所以放到filter里
boolQuery.filter(QueryBuilders.termQuery("brand", params.getBrand()));
}
if (params.getStarName() != null && !params.getStarName().equals("")){//星级条件
//因为不参加算分,所以放到filter里
boolQuery.filter(QueryBuilders.termQuery("starName", params.getStarName()));
}
if(params.getMinPrice() != null&& params.getMaxPrice()!=null){ //价格
boolQuery.filter(QueryBuilders.rangeQuery("price")
.gte(params.getMinPrice())
.lte(params.getMaxPrice()));
}
request.source().query(boolQuery);
}
private PageResult 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();
List<HotelDoc> hotels = new ArrayList<>();
for (SearchHit hit:hits){
//4.3 得到source
String json = hit.getSourceAsString();
//反序列化
HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class);
hotels.add(hotelDoc);
}
//4.4封装返回
return new PageResult(total,hotels);
}
}
查找附近酒店
1.修改RequestParams参数,接收location字段
略
2.修改search方法业务逻辑,如果location有值,添加根据geo_distance排序的功能,在个案例中的service代码里添加如下代码即可:
//2.3 排序
String location = params.getLocation();
if (location != null && !"".equals(location)){
request.source().sort(SortBuilders
.geoDistanceSort("location", new GeoPoint(location))
.order(SortOrder.ASC)
.unit(DistanceUnit.KILOMETERS));
}
并且如果需要显示距离,首先要在HotelDoc这个pojo类中添加类型为Object的distance对象,随后在处理结果的代码中,将for循环中add list的部分改为如下:
for (SearchHit hit:hits){
//4.3 得到source
String json = hit.getSourceAsString();
//反序列化
HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class);
//获取排序结果
Object[] sortValues = hit.getSortValues();
if(sortValues.length > 0){
Object sortValue = sortValues[0];
hotelDoc.setDistance(sortValue);
}
hotels.add(hotelDoc);
}
演示结果如下所示,因为人在成都,所以离我最近的酒店在深圳:
距离排序和普通排序略有不同:
广告置顶
让指定的酒店在搜索结果中排名置顶。我们给需要置顶的酒店文档添加一个标记。然后利用function score给带有标记的文档增加权重。
步骤如下:
1.给HotelDoc类添加isAD字段,Boolean类型。在HotelDoc这个pojo类中添加如下属性:
private Boolean isAD;
2.挑选几个你喜欢的酒店,给它的文档数据添加isAD字段,值为true
POST /hotel/_update/2056126831
{
"doc":{
"isAD": true
}
}
3.修改search方法,添加function score功能,给isAD值为true的酒店增加权重
只需在buildBasicQuery中添加以下代码:
//2.算分控制
FunctionScoreQueryBuilder functionScoreQueryBuilder =
QueryBuilders.functionScoreQuery(
boolQuery,
new FunctionScoreQueryBuilder.FilterFunctionBuilder[]{
new FunctionScoreQueryBuilder.FilterFunctionBuilder(
QueryBuilders.termQuery("isAD",true),//当isAD为true时才加权重
ScoreFunctionBuilders.weightFactorFunction(10) //增加权重10
)
});
request.source().query(functionScoreQueryBuilder);//注意提交的query必须是添加了算分控制后的query
结果如下:
实现高亮
1.在HotelService类中的search方法内,多加一步:
//2.4 高亮
request.source().highlighter(new HighlightBuilder()
.field("name")
.requireFieldMatch(false));
2.接着处理结果的函数handleResponse中,将高亮的结果替换原来的name结果,其中的for循环改为如下:
for (SearchHit hit:hits){
//4.3 得到source
String json = hit.getSourceAsString();
//反序列化
HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class);
//获取高亮
Map<String, HighlightField> highlightFields = hit.getHighlightFields();
if(!CollectionUtils.isEmpty(highlightFields)){ //使用Spring自带的工具类进行集合判空的操作
//根据字段名获取高亮结果
HighlightField highlightField = highlightFields.get("name");
if(highlightField != null){
//获取高亮结果
String name = highlightField.getFragments()[0].string();
//覆盖非高亮结果
hotelDoc.setName(name);
}
}
//获取排序结果
Object[] sortValues = hit.getSortValues();
if(sortValues.length > 0){
Object sortValue = sortValues[0];
hotelDoc.setDistance(sortValue);
}
hotels.add(hotelDoc);
}
实现效果如下:
数据聚合
聚合(aggregations)可以实现对文档数据的统计、分析、运算。聚合常见的有三类:
1.桶(Bucket)聚合:用来对文档做分组
(1)TermAggregation:按照文档字段值分组
(2)Date Histogram:按照日期阶梯分组,例如一周为一组,或者一月为一组
2.度量(Metric)聚合:用以计算一些值,比如:最大值、最小值、平均值等
(1)Avg:求平均值
(2)Max:求最大值
(3)Min:求最小值
(4)Stats:同时求max、min、avg、sum等
3.管道(pipeline)聚合:其它聚合的结果为基础再做聚合
注意:参与聚合的字段类型必须是keyword、数值、日期或者布尔。
Bucket(桶)聚合
基本用法
现在,我们要统计所有数据中的酒店品牌有几种,此时可以根据酒店品牌的名称做聚合。
类型为term类型,DSL示例:
结果:
修改排序规则
如果改成20,那酒店品牌显示的就会多一些,我们可以看到他是按照聚合字段文档出现的次数倒序排序的,如果我们想让它是升序排序,只需加个order属性即可:
限定聚合范围
默认情况下,Bucket聚合是对索引库的所有文档做聚合,我们可以限定要聚合的文档范围,只要添加query条件即可:
总结
一、aggs代表聚合,与query同级,此时query的作用是?答:限定聚合的的文档范围
二、聚合必须的三要素:1.聚合名称 2.聚合类型 3.聚合字段
三、聚合可配置属性有:
1.size:指定聚合结果数量
2.order:指定聚合结果排序方式
3.field:指定聚合字段
Metrics聚合
例如,我们要求获取每个品牌的用户评分的min、max、avg等值.我们可以利用stats聚合:
对brandAgg做子聚合。
若我们想对评价的平均值做一个排序,只需在brandAgg中添加一个order即可:
RestAPI实现数据聚合
RestAPI和DSL的对应情况,即请求的组装情况:
我们可以很容易的观察出链式编程运用的广泛性。
对聚合结果的解析如下:
代码如下:
@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")//这个terms里面的name可以任意,表示聚合名
.field("brand")//对brand字段做聚合
.size(10));//聚合的结果取10个
//3.发出请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
//4.解析结果
Aggregations aggregations = response.getAggregations();
//4.1 根据聚合名称获取聚合结果
Terms brandTerms = aggregations.get("brandAgg");//注意要用Terms来接收
//4.2 获取buckets
List<? extends Terms.Bucket> buckets = brandTerms.getBuckets();
//buckets该list只能容纳Terms.Bucket类型及Terms.Bucket类型的子类。
//由于只能从List<? extends T>中获取元素,而不能向它添加元素,所以称之为生产者。
//4.3遍历
for (Terms.Bucket bucket : buckets){
//4.4获取key
String key = bucket.getKeyAsString();//即品牌的名称
long frequency = bucket.getDocCount();//出现的次数
System.out.println(key+" "+frequency+"次");
}
}
结果:
案例
在IUserService中定义方法,实现对品牌、城市、星级的聚合。
需求:搜索页面的品牌、城市等信息不应该是在页面写死,而是通过聚合索引库中的酒店数据得来的
实现下面的方法:
/**
* 查询城市、星级、品牌的聚合结果
* @return 聚合结果,格式:{"城市": ["上海", "北京"], "品牌": ["如家", "希尔顿"]}
*/
Map<String, List<String>> filters();
报这个错是因为我们一开始创建索引表的时候,starName写成了star_name,但是在java里导入数据库时,使用的字段名是starName,所以star_name是keyword类型,但是starName由于是插入时ES发现没有这个字段,于是ES自动添加的字段,所以这个字段并不是keyword类型,只是普通的text,所以不能在自动创建的这个starName字段上进行聚合操作。
ElasticsearchStatusException[Elasticsearch exception [type=search_phase_execution_exception, reason=all shards failed]
]; nested: ElasticsearchException[Elasticsearch exception [type=illegal_argument_exception, reason=Text fields are not optimised for operations that require per-document field data like aggregations and sorting, so these operations are disabled by default. Please use a keyword field instead. Alternatively, set fielddata=true on [starName] in order to load field data by uninverting the inverted index. Note that this can use significant memory.]]; nested: ElasticsearchException[Elasticsearch exception [type=illegal_argument_exception, reason=Text fields are not optimised for operations that require per-document field data like aggregations and sorting, so these operations are disabled by default. Please use a keyword field instead. Alternatively, set fielddata=true on [starName] in order to load field data by uninverting the inverted index. Note that this can use significant memory.]];
at org.elasticsearch.rest.BytesRestResponse.errorFromXContent(BytesRestResponse.java:176)
at org.elasticsearch.client.RestHighLevelClient.parseEntity(RestHighLevelClient.java:1933)
at org.elasticsearch.client.RestHighLevelClient.parseResponseException(RestHighLevelClient.java:1910)
at org.elasticsearch.client.RestHighLevelClient.internalPerformRequest(RestHighLevelClient.java:1667)
at org.elasticsearch.client.RestHighLevelClient.performRequest(RestHighLevelClient.java:1624)
at org.elasticsearch.client.RestHighLevelClient.performRequestAndParseEntity(RestHighLevelClient.java:1594)
at org.elasticsearch.client.RestHighLevelClient.search(RestHighLevelClient.java:1110)
at cn.itcast.hotel.service.impl.HotelService.filters(HotelService.java:85)
at cn.itcast.hotel.service.impl.HotelService$$FastClassBySpringCGLIB$$e419b4bb.invoke(<generated>)
at org.springframework.cglib.proxy.MethodProxy.invoke(MethodProxy.java:218)
at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:687)
at cn.itcast.hotel.service.impl.HotelService$$EnhancerBySpringCGLIB$$d551329a.filters(<generated>)
at cn.itcast.hotel.HotelDemoApplicationTests.contextLoads(HotelDemoApplicationTests.java:20)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke(Method.java:498)
at org.junit.platform.commons.util.ReflectionUtils.invokeMethod(ReflectionUtils.java:686)
at org.junit.jupiter.engine.execution.MethodInvocation.proceed(MethodInvocation.java:60)
at
我们只需要删除原来的索引表,将PUT里的star_name改为正确的starName即可,之后再批量导入一下。filter方法实现如下:
@Override
public Map<String, List<String>> filters() throws IOException {
//1.准备Request
SearchRequest request = new SearchRequest("hotel");
//2.准备DSL
//2.1设置size
request.source().size(0);//不需要文档,只需要聚合结果即可
//2.2聚合
buildAggregation(request);
//3.发出请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
System.out.println(response);
//4.解析结果
Aggregations aggregations = response.getAggregations();
//4.1 根据聚合名称获取聚合结果
Map<String,List<String>> listMap=new HashMap<>();
listMap.put("品牌",buildList(aggregations,"brandAgg"));
listMap.put("城市",buildList(aggregations,"cityAgg"));
listMap.put("星级",buildList(aggregations,"starAgg"));
return listMap;
}
private List<String> buildList(Aggregations aggregations,String listName) {
Terms brandTerms = aggregations.get(listName);//注意要用Terms来接收
System.out.println(brandTerms);
//4.2 获取buckets
List<? extends Terms.Bucket> buckets = brandTerms.getBuckets();
//buckets该list只能容纳Terms.Bucket类型及Terms.Bucket类型的子类。
//由于只能从List<? extends T>中获取元素,而不能向它添加元素,所以称之为生产者。
//4.3遍历
List<String> name_list = new ArrayList<>();
for (Terms.Bucket bucket : buckets){
//4.4获取key
String key = bucket.getKeyAsString();//即品牌的名称
name_list.add(key);
}
return name_list;
}
private void buildAggregation(SearchRequest request) {
request.source().
aggregation(AggregationBuilders.
terms("brandAgg")//这个terms里面的name可以任意,表示聚合名
.field("brand")//对brand字段做聚合
.size(10));//聚合的结果取10个
request.source().
aggregation(AggregationBuilders.
terms("cityAgg")//这个terms里面的name可以任意,表示聚合名
.field("city")//对city字段做聚合
.size(10));//聚合的结果取10个
request.source().
aggregation(AggregationBuilders.
terms("starAgg")//这个terms里面的name可以任意,表示聚合名
.field("starName")//对starName字段做聚合
.size(10));//聚合的结果取10个
}
在HotelDemoApplicationTest里测试方法如下所示:
注意:当用户搜索“外滩”时,我们的各个字段的聚合就必须在关键字为“外滩”的酒店里进行聚合,这样得到的聚合结果才更有意义,那不然我搜外滩,聚合还是聚合所有数据,得到有地区为北京,那我点北京,发现没有地区为北京,name里包含“外滩”的酒店。
1.编写controller接口,接收该请求
就是写一个postmapping,接收传来的RequestParams params。
2.修改IUserService#getFilters()方法,添加RequestParam参数
从params提取搜索字段和各种条件,在该字段的基础上进行数据聚合。
3.修改getFilters方法的业务,聚合时添加query条件,使用之前写的buildBasicQuery
这样修改之后的结果就是,当限定的条件越多,所聚合出来的选项就越少(因为符合条件的酒店随着指定条件的增多而变少,从而聚合的数据也变少了)
在上海五钻以上的价格大于1500元的酒店品牌就四个了。
自动补全
当用户在搜索框输入字符时,我们应该提示出与该字符有关的搜索项,如图:
安装
要实现根据字母做补全,就必须对文档按照拼音分词。在GitHub上恰好有elasticsearch的拼音分词插件。地址:https://github.com/medcl/elasticsearch-analysis-pinyin
安装方式与IK分词器一样,分三步:
①解压
②上传到虚拟机中,elasticsearch的plugin目录(可能是/var/lib/docker/volumes/es-plugins/_data)
③重启elasticsearch
④测试
仅靠拼音分词器的功能还不够,比如拼音分词器没有先对原来的text进行分词,而且把每个字的拼音都分出来了,而没有把分词的拼音分出来比如"zhongguo",所以我们需要自定义分词器来满足我们的需要:
自定义分词器
elasticsearch中分词器(analyzer)的组成包含三部分:
1.lcharacter filters:在tokenizer之前对文本进行处理。例如删除字符、替换字符
2.ltokenizer:将文本按照一定的规则切割成词条(term)。例如keyword,就是不分词;还有ik_smart
3.ltokenizer filter:将tokenizer输出的词条做进一步处理。例如大小写转换、同义词处理、拼音处理等
执行流程如下图所示:
使用DSL命令来初始化自定义分词器:
创建自定义分词器之后,使用分词器对“中国崛起”进行分词操作可以得到如下不同的结果:
我们添加“狮子”和“虱子”两个文档进去,搜索“shizi”可以得到两个文档,这个没问题。如果搜索“ 狮子笼咋办,在线等,很急”,也会出现“虱子”,这就不符合搜索本意了,就像下面图示的那样:
因此字段在创建倒排索引时应该用my_analyzer分词器;字段在搜索时应该使用ik_smart分词器;
PUT /test
{
"settings": {
"analysis": {
"analyzer": {
"my_analyzer": {
"tokenizer": "ik_max_word", "filter": "py"
}
},
"filter": {
"py": { ... }
}
}
},
"mappings": {
"properties": {
"name": {
"type": "text",
"analyzer": "my_analyzer",
"search_analyzer": "ik_smart"
}
}
}
}
实现自动补全
elasticsearch提供了Completion Suggester查询来实现自动补全功能。这个查询会匹配以用户输入内容开头的词条并返回。为了提高补全查询的效率,对于文档中字段的类型有一些约束:(1)参与补全查询的字段必须是completion类型。(2)字段的内容一般是用来补全的多个词条形成的数组。
// 创建索引库
PUT test
{
"mappings": {
"properties": {
"title":{
"type": "completion"
}
}
}
}
添加示例数据:
// 示例数据
POST test/_doc
{
"title": ["Sony", "WH-1000XM3"]
}
POST test/_doc
{
"title": ["SK-II", "PITERA"]
}
POST test/_doc
{
"title": ["Nintendo", "switch"]
}
自动补全查询语法如下:
因为我实际操作时,创建的索引表是test2,所以下面是test2
案例一
实现hotel索引库的自动补全、拼音搜索功能
实现思路如下:
1.修改hotel索引库结构,设置自定义拼音分词器
2.修改索引库的name、all字段,使用自定义分词器
PUT /hotel
{
"settings": {
"analysis": {
"analyzer": {
"text_anlyzer": {
"tokenizer": "ik_max_word",
"filter": "py"
},
"completion_analyzer": {
"tokenizer": "keyword",
"filter": "py"
}
},
"filter": {
"py": {
"type": "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": {
"id":{
"type": "keyword"
},
"name":{
"type": "text",
"analyzer": "text_anlyzer",
"search_analyzer": "ik_smart",# 搜索时使用ik_smart分词器
"copy_to": "all"
},
"address":{
"type": "keyword",
"index": false
},
"price":{
"type": "integer"
},
"score":{
"type": "integer"
},
"brand":{
"type": "keyword",
"copy_to": "all"
},
"city":{
"type": "keyword"
},
"starName":{
"type": "keyword"
},
"business":{
"type": "keyword",
"copy_to": "all"
},
"location":{
"type": "geo_point"
},
"pic":{
"type": "keyword",
"index": false
},
"all":{
"type": "text",
"analyzer": "text_anlyzer",
"search_analyzer": "ik_smart" # 同理
},
"suggestion":{
"type": "completion",
"analyzer": "completion_analyzer"# 使用自己定义的分词器
}
}
}
}
3.索引库添加一个新字段suggestion,类型为completion类型,使用自定义的分词器
4.给HotelDoc类添加suggestion字段,内容包含brand、business,即suggestion由现有的信息生成,并不是自己现编
5.重新导入数据到hotel库,要将商圈的“/”或者“、”分隔出来,使用分隔后的结果作为分词结果
代码:
@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;
private Object distance;
private Boolean isAD;
private List<String> suggestioon;
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();
//若商圈以/分隔,则我们需要将商圈都分出来,填进去
if(this.business.contains("、")){
String[] arr = this.business.split("、");
this.suggestioon = new ArrayList<>();
this.suggestioon.add(this.brand);
//利用Collections工具类将数组里的元素批量批量添加到suggestion中
Collections.addAll(this.suggestioon,arr);
}else{
this.suggestioon = Arrays.asList(this.brand,this.business);
}
}
}
注意:name、all是可分词的,自动补全的brand、business是不可分词的,要使用不同的分词器组合
随后我们测试一下自动补全功能:
案例二
使用RestAPI实现自动补全
@Test
void testSuggest() throws IOException {
//1.准备Request对象
SearchRequest request = new SearchRequest("hotel");
//2.准备DSL
request.source().suggest(new SuggestBuilder().addSuggestion(
"my_suggestions",
SuggestBuilders.completionSuggestion("suggestion")
.prefix("h")
.skipDuplicates(true)
.size(10)
));
//3.发起请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
//4.解析结果
Suggest suggest = response.getSuggest();
//4.1根据名称获取补全结果
CompletionSuggestion suggestion = suggest.getSuggestion("my_suggestions");
//4.2获取options并遍历
for (CompletionSuggestion.Entry.Option option : suggestion.getOptions()){
//4.3 获取一个option中的text,也就是补全的词条
String text = option.getText().string();
System.out.println(text);
}
}
实现酒店搜索页面输入框的自动补全:
1.在HotelController类中增加如下方法:
@GetMapping("suggestion")
public List<String> getSuggestions(@RequestParam("key") String prefix) throws IOException {
return hotelService.getSuggestions(prefix);
}
2.在接口类(HotelService)中实现该方法:
@Override
public List<String> getSuggestions(String prefix) throws IOException {
SearchRequest request = new SearchRequest("hotel");
request.source().suggest(new SuggestBuilder().addSuggestion(
"my_suggestions",
SuggestBuilders.completionSuggestion("suggestion")
.prefix(prefix)
.skipDuplicates(true)
.size(10)
));
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
Suggest suggest = response.getSuggest();
CompletionSuggestion suggestion = suggest.getSuggestion("my_suggestions");
List<String> list = new ArrayList<>();
for (CompletionSuggestion.Entry.Option option : suggestion.getOptions()) {
String text = option.getText().string();
list.add(text);
}
return list;
}
实现了自动补全功能:
数据同步
elasticsearch中的酒店数据来自于mysql数据库,因此mysql数据发生改变时,elasticsearch也必须跟着改变,这个就是elasticsearch与mysql之间的数据同步。
方案一:同步调用
该方案实现较为简单,但是同步调用会导致数据和业务耦合,本来对mysql操作只需要一步,现在变成了三步,会导致系统的性能下降。
方案二:异步通知
这种方案可以避免方案一的缺点,因为写完mysql之后通知MQ就完事儿了,后面的步骤与自己无关。但是这个方案非常依赖MQ的可靠性,并且业务的复杂度提升了。
方案三:监听binlog
mysql中的binlog是默认关闭的,但是开启,每次修改数据库里的记录都会在binlog中有记录,这时我们可以使用canal对mysql进行监听,一旦监听到binlog的变化是,就通知es相应地也修改数据。但这个方案会对mysql数据库带来较大的压力,并且实现复杂度也比较高。
使用MQ实现数据同步
相关资料链接:hotel-admin.zip - 蓝奏云 (lanzouj.com) or Gofile - Share file links quickly and easily
利用课前资料提供的hotel-admin项目作为酒店管理的微服务。当酒店数据发生增、删、改时,要求对elasticsearch中数据也要完成相同操作。
步骤:
1.导入课前资料提供的hotel-admin项目,启动并测试酒店数据的CRUD。注意导入之后要查看resources下的application.yaml中连接数据库的各种配置是否正确,比如用户名或者密码。注意数据库链接地址中要把mysql:3306改成localhost:3306。项目展示:
2.声明exchange、queue、RoutingKey。
(1)流程结构图如下:可见与DirectExchange交换机一致
(2)在hotel-demo里引入amqp依赖,然后在application.yaml里配置rabbitmq的各种配置,参照之前rabbitmq
pom.xml里新增的:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
application中新增的
spring:
rabbitmq:
host: 192.168.10.130 # 主机名
port: 5672 # 端口
virtual-host: / # 虚拟主机
username: itcast # 用户名
password: 123321 # 密码
3.在hotel-admin中的增、删、改业务中完成消息发送
注意要在hotel-admin中的pojo类中的Hotel类的id上面的注解改为:@TableId(type = IdType.AUTO),不然不会自增,会报id为空的错误。
在HotelController中写MQ发送消息的逻辑:
@PostMapping
public void saveHotel(@RequestBody Hotel hotel){
hotelService.save(hotel);
System.out.println(hotel);
rabbitTemplate.convertAndSend(
MqConstants.HOTEL_EXCHANGE,//交换机的名称
MqConstants.HOTEL_INSERT_KEY,//BindingKey,用于路由到相应的队列中
hotel.getId());
}
@PutMapping()
public void updateById(@RequestBody Hotel hotel){
if (hotel.getId() == null) {
throw new InvalidParameterException("id不能为空");
}
hotelService.updateById(hotel);
rabbitTemplate.convertAndSend(
MqConstants.HOTEL_EXCHANGE,//交换机的名称
MqConstants.HOTEL_INSERT_KEY,//BindingKey,用于路由到相应的队列中
hotel.getId());
}
@DeleteMapping("/{id}")
public void deleteById(@PathVariable("id") Long id) {
hotelService.removeById(id);
rabbitTemplate.convertAndSend(
MqConstants.HOTEL_EXCHANGE,//交换机的名称
MqConstants.HOTEL_DELETE_KEY,//BindingKey,用于路由到相应的队列中
id);
}
4.在hotel-demo中完成消息监听,并更新elasticsearch中数据,创建一个mq包,包下创建HotelListener类用于监听消息:
@Component
public class HotelListener {
@Autowired
private IHotelService hotelService;
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = MqConstants.HOTEL_INSERT_QUEUE),
exchange = @Exchange(name = MqConstants.HOTEL_EXCHANGE,type = ExchangeTypes.TOPIC),
key = MqConstants.HOTEL_INSERT_KEY
))
public void listenHotelInsertOrUpdate(Long id) throws IOException {
hotelService.insertById(id);
}
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = MqConstants.HOTEL_DELETE_QUEUE),
exchange = @Exchange(name = MqConstants.HOTEL_EXCHANGE,type = ExchangeTypes.TOPIC),
key = MqConstants.HOTEL_DELETE_KEY
))
public void listenHotelDelete(Long id) throws IOException {
hotelService.deleteById(id);
}
}
5.启动并测试数据同步功能
elasticsearch集群
单机的elasticsearch做数据存储,必然面临两个问题:海量数据存储问题、单点故障问题。对于这两个问题的结局方案分别如下:
对于海量数据存储问题:将索引库从逻辑上拆分为N个分片(shard),存储到多个节点。对于单点故障问题:将分片数据在不同节点备份(replica )
这样存储,哪怕node2挂了shard-0,shard-1,shard-2的数据都可以从node1和node3中找到。
搭建步骤
具体可以参照这个pdf:https://wwz.lanzouj.com/ieCAK02vrnsh 密码:hsn1
es集群中的节点角色
elasticsearch中集群节点有不同的职责划分:
节点类型 | 配置参数 | 默认值 | 节点职责 |
---|---|---|---|
master eligible | node.master | true | 备选主节点:主节点可以管理和记录集群状态、决定分片在哪个节点、处理创建和删除索引库的请求 |
data | node.data | true | 数据节点:存储数据、搜索、聚合、CRUD |
ingest | node.ingest | true | 数据存储之前的预处理 |
coordinating | 上面三个参数都为false则为coordinating节点 | 无 | 路由请求到其它节点,合并其它节点处理的结果,返回给用户 |
默认创建集群中的新节点时,新节点时身兼数职的,但是这样不太好。elasticsearch中的每个节点角色都有自己不同的职责,因此建议集群部署时,每个节点都有独立的角色。
下面是一个典型的ES集群的结构分布图:
其中LB指的是负载均衡器。
脑裂问题
默认情况下,每个节点都是master eligible节点,因此一旦master节点宕机,其它候选节点会选举一个成为主节点。若原来的master又恢复了,那就出现了两个master角色,会导致数据不一致和冲突。当主节点与其他节点网络故障时,可能发生脑裂问题。
为了避免脑裂,需要要求选票超过 ( eligible节点数量 + 1 )/ 2 才能当选为主,因此eligible节点数量最好是奇数。对应配置项是discovery.zen.minimum_master_nodes,在es7.0以后,已经成为默认配置,因此一般不会发生脑裂问题
ES集群的分布式存储
当新增文档时,应该保存到不同分片,保证数据均衡,那么coordinating node如何确定数据该存储到哪个分片呢?
答:elasticsearch会通过hash算法来计算文档应该存储到哪个分片:
shard = hash(_routing) % number_of_shards
其中,_routing默认是文档的id,而且算法与分片数量有关,因此索引库一旦创建,分片数量不能修改!
新增文档的流程如下图所示:
假如我们添加了三个文档进去,然后我们的集群里三个es对应的端口是9200,9201,9202,那么我们无论查哪个端口都可以查出这三个文档出来,如果我们想知道文档具体存在哪个es上,则需要在请求语句里加上"explain":true,es集群返回的结果里就会表明每个文档存在哪个es上。
分布式查询的过程如下,查询过程可以分为两个阶段:
1.scatter phase:分散阶段,coordinating node会把请求分发到每一个分片。
2.gather phase:聚集阶段,coordinating node汇总data node的搜索结果,并处理为最终结果集返回给用户。
两个阶段中的coordinating node可以是集群中的任何一个节点,所以刚刚所说的查任意一个端口都可以查出所有的数据。
故障转移
集群的master节点会监控集群中的节点状态,如果发现有节点宕机,会立即将宕机节点的分片数据迁移到其它节点,确保数据安全,这个叫做故障转移。master宕机后,EligibleMaster选举为新的主节点。