Bootstrap

SpringCloud学习笔记【黑马课程】

SpringCloud

微服务

什么是微服务?

微服务不等于springcloud,微服务是一种经过良好架构设计的分布架构方案,微服务架构特征:

  1. 单一职责:微服务拆分粒度更小。每一个服务都对应唯一的业务能力,做到职责单一,避免业务重复开发
  2. 面向服务:微服务对外暴露业务接口
  3. 自治:团队独立,技术独立,数据独立,部署独立
  4. 隔离性强:服务调用做好隔离,容错,降级,避免出现级联问题

微服务技术栈

微服务做的第一件事就是拆分,因为传统单体架构,所有的业务功能全部写在一起,随着业务越来越复杂,代码也耦合的越来越多,将来升级维护就会变得很困难,所以大型的互联网服务都会进行拆分。微服务再做拆分的时候,会根据业务功能模块把一个单体的项目拆分成许多的独立的项目,每个项目完成一部分业务功能,将来独立开发或部署,我们把这一个独立的项目称为服务,一个大型的互联网项目往往会包含数百上千的服务最终形成一个服务集群,而一个业务往往就需要多个服务共同完成。比如说一个请求来了,他可能先去调用服务A,而服务A可能又调用了服务B,而后又去调用了服务C。当业务越来越多也来越复杂的时候,这些服务的调用关系就会越来越复杂,这么复杂的调用关系要让人去记录和维护是不可能的。所以再微服务里会又一个组件叫注册中心,它可以去记录微服务里每个服务的ip、端口,以及他们的功能。当有一个服务需要调用另一个服务时它不需要自己去记录对方的ip,只需要去找注册中心就可以,从注册中心去拉取对应的服务信息。同时随着服务越来越多每个服务都有自己的配置文件,将来要更改配置我们要逐一去修改这样就太麻烦了,所以我们还会有一个配置中心。它可以去统一的管理整个服务群里成千上百个配置,如果以后又配置需要变更只需要去 找配置中心,他会通知相关的微服务实现配置的热更新当我们的微服务运行起来之后,用户就可以来访问我们了,这时候就还需要一个网关服务。那你这里有这么多微服务,用户怎么知道你要访问哪一个呢?而且也不是随便什么人都可以访问我们的服务,这就像是我们的小区,小区里往往有一个看门的大爷,不能什么人来了都可以进入。所以服务网关一部分是对用户身份进行校验,另一方面可以把用户的请求路由到具体的服务,当然再路由过程中也可以去做一些负载均衡。而这时候服务集群根据你的请求去处理业务,该访问数据库就访问数据库并把查询到的数据返回给用户。数据库肯定也是集群,不过集群在庞大也不会有用户数量多,所以数据库将来肯定无法抗住高并发,因此我们还会加入分布式缓存, 但是简单查询可以走缓存,一些海量数据的复杂的搜索统计和分析缓存也做不了,这时候就还需要用到分布式搜索功能。数据库将来的功能就只需要做一些写操作和一些事务类型的对数据安全较高的一些数据存储。最后再微服务里面还需要一种异步通讯的消息队列组件。为什么呢?其实对于分布式的服务或再微服务里面,他的业务往往会跨越多个服务,所以总时长就会等于每个服务执行时长之和,性能就会下降,而异步通讯的意思就是请求来了我调用服务A,而服务A不是去调用服务B和C而是通知他们,B和C去执行之后A就直接结束了,那么通信链路就变短了执行时长也就短了 。所以异步通讯可以大大提高我们的并发,再一些秒杀等高并发场景下就可以去利用了。当然我们如此庞大复杂的一个服务在运行的过程中如果出现什么问题也不好排查。所以在微服务运行中还会引入两个组件来解决这种异常定位。第一个是分布式日志服务,它可以去统计整个集群中成千上百个服务他们的运行日志,统一的去做一个存储,统计,分析。将来出现问题就比较好定位了。第二个叫做系统监控链路追踪,他可以去实时监控我们整个集群中每个服务节点的运行状态,Cpu的负载、内存的占用等等情况,一旦出现问题直接可以定位到具体的某一个方法和栈信息。那这么大的服务怎么部署呢?未来我们会做一些自动化的部署,持续集成:利用Jenkins这样的工具,它可以帮助我们对这些微服务项目进行自动化的编译,而基于Docker来进行一些打包形成镜像,再基于kubernetesrancher这样的技术实现自动化的部署,这一套我们就称为叫持续集成。以上全部才叫做微服务技术栈

认识微服务

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

优点:

  1. 架构简单
  2. 部署成本低

缺点:

  1. 耦合度高

分布式架构:

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

  1. 降低服务耦合
  2. 有利于服务升级拓展

分布式架构要考虑的问题:

  • 服务拆分粒度如何?
  • 服务集群地址如何维护?
  • 服务之间如何实现远程调用?
  • 服务健康状态如何感知?

微服务远程调用

微服务调用方式

  1. 基于RestTemplate发起的http请求实现远程调用
  2. http请求做远程调用是 与语言无关的调用,只要知道对方的ip、端口、接口路径、请求参数即可

提供者与消费者

  • 服务提供者:一次业务中,被其他微服务调用的服务(提供接口给其他微服务)
  • 服务消费者:一次业务中,调用其他微服务的服务(调用其他微服务提供的接口)

​ 如果服务A调用服务B,服务B调用了服务C,那么服务B是什么角色?

​ 一个服务既可以是提供者也可以是消费者

EureKa注册中心

服务调用出现的问题

  • 服务消费者该如何获取服务提供者的地址信息?
  • 如果有多个服务提供者,消费者该如何选择?
  • 消费者如何得知服务提供者的健康状态?

EureKa的作用

在Eureka架构中,微服务角色有两类:

  1. EurekaServer:服务端,注册中心
    • 记录服务信息
    • 心跳监控
  2. EurekaClient:客户端
    • Provider:服务提供者,例如案例中的user-service
      1. 注册自己的信息到EurekaServer
      2. 每隔30秒向EurekaServer发送心跳
    • consumer:服务消费者,例如案例中的order-service
      1. 根据服务名称从EurekaServer拉取服务列表
      2. 基于服务列表做负载均衡,选中一个微服务后发起远程调用

具体问题:

  • 消费者该如何获取服务提供者具体信息?
    1. 服务提供者启动时向eureka注册自己的信息
    2. eureka保存这些信息
    3. 消费者根据服务名称向eureka拉取提供者信息
  • 如果有多个服务提供者,消费者该如何选择?
    1. 服务消费者利用负载均衡算法,从服务列表中挑选一个
  • 消费者如何感知服务提供者的健康状态?
    1. 服务提供者会每隔30秒向eurekaserver发送心跳请求,报告健康状态
    2. eureka会更新记录服务器列表信息,心跳不正常会被剔除
    3. 消费者就可以拉取到最新的消息

搭建EurekaServer

搭建EurekaServer服务步骤如下:

  1. 创建项目,引入spring-cloud-starter-netflix-eureka-server的依赖

    <dependency>
    	<groupId>org,springframework.cloud</groupId>
        <artifacrId>spring-cloud-starter-netflix-eureka-server</artifacrId>
    </dependency>
        
    
  2. 编写启动类,添加@EnableEurekaServer注解

  3. 添加application.yml文件,编写下面的配置:

    server: #端口
      port: 8084
    spring:
      application: #服务名称
        name: eurekaServer
    eureka:
      client:
        service-url: #eureka的地址信息
          defaultZone: http://127.0.0.1:8084/eureka
    

服务注册

服务注册步骤

  1. 在要注册的服务下引入spring-cloud-starter-netflix-eureka-client

    <dependency>
    	<groupId>org,springframework.cloud</groupId>
        <artifacrId>spring-cloud-starter-netflix-eureka-client</artifacrId>
    </dependency>
    
  2. 在application.yml文件添加

    server: #端口
      port: 8081
    spring:
      application: #服务名称
        name: UserService
    eureka:
      client:
        service-url: #eureka的地址信息
          defaultZone: http://127.0.0.1:8084/eureka
    

服务发现

代码请求地址中将ip和端口号改为eureka的服务名,并给restTemplate添加@loadBanlanced开启负载均衡

负载均衡

Ribbon负载均衡
负载均衡原理

ribbon通过获取主机名来对eureka进行获取服务地址,然后通过轮询或者随机来完成负载均衡

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

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

1、代码方式在配置类中定义一个新的IRule:

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

1、配置文件方式:yml文件中添加新的配置也可以修改规则:

userservice:
	ribbon:
		NFLoadBalancerRuleClassName:com.netflix.loadbalancer.RandomRule  #负载均衡规则
饥饿加载

Ribbon默认是采用懒加载,即第一次访问时才会去创建LoadBalanceClient,请求时间会很长。而饥饿加载则会在项目启动时创建,降低第一次访问的耗时,通过下面配置开启饥饿加载:

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

Nacos注册中心

认识和安装Nacos

Nacos是阿里巴巴的产品,现在是SpringCloud中的一个组件。相比Eureka功能更加丰富,在国内受欢迎程度较高。

安装步骤
  1. 安装nacos
  2. nacos默认端口:8848,修改端口在application.properties
  3. nacos启动命令:startup.cmd -m standalone
  4. 登录名和密码都为nacos

服务注册到Nacos

  1. 在cloud-demo父工程中添加spring-cloud-alibaba的管理依赖:

    <dependency>
    	<groupId>com.alibaba.cloud</groupId>
        <artfactId>spring-cloud-alibaba-dependencies</artfactId>
        <version>2.2.5RELEASE</version>
        <type>pom</type>
        <scope>import</scope>
    </dependency>
    
  2. 注释掉order-service和user-service中原有的eureka依赖

  3. 添加nacos的客户端依赖:

    <dependecy>
    	<groupId>com.alibaba.cloud</groupId>
        <artfactId>spring-cloud-starter-alibaba-nacos-discovery</artfactId>
    </dependecy>
    
  4. 修改user-service和order-service中的application.yml文件,注释eureka地址,添加nacos地址:

    spring:
    	cloud:
    		nacos:
    			server-addr: localhost:8848 #nacos  服务端地址
    
  5. 启动并测试

nacos服务分级存储模型

一个服务的多个实例分开放

  1. 修改application.yml文件,添加以下内容

    spring:
    	cloud:
    		nacos:
    			server-addr: #nacos  服务端地址
    			discovery:
    				cluster-name: HZ #配置集群名称,也就是机房位置,例如:HZ,杭州
    

1、Nacos服务分级存储模型:

  • 一级是服务,例如userservice
  • 二级是集群,例如杭州或上海
  • 三级是实例,例如杭州机房的某台部署了userservice的服务器

2、如何设置实例的集群属性

  • 修改application.yml文件,添加spring.cloud.nacos.discovery.cluster-name属性即可
根据集群负载均衡
  1. 修改order-service中的application.yml,设置集群为HZ

    spring:
    	cloud:
    		nacos:
    			server-addr: localhost:8848  #nacos服务器地址
    			discovery:
    				cluster-name: HZ #配置集群名称,也就是机房位置
    
  2. 然后再order-service中设置负载均衡的Rule为NacosRule,这个规则会优先寻找与自己同集群的服务:

    userservice:
    	ribbon:
    		NFLoadBalancerRuleClassName: com.alibaba.cloud.nacos.ribbon.NacosRule #负载均衡策略
    
  3. 注意将user-service的权重都设置为1

根据权重负载均衡

实际部署中会出现这样的场景:

  • 服务器设备性能有差异,部分实例所在及其性能较好,另一些较差,我们希望性能好的机器承担更多的用户请求

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

  1. 在nacos控制台可以设置实例的权重值,首先选中实例后面的编辑按钮
  2. 将权重设置为0.1,则该实例被访问到的概率将大大降低

总结:

  1. nacos控制台可以设置实例的权重值,0-1之间
  2. 同集群内的多个实例,权重越高被访问的频率越高
  3. 权重设置为0则完全不会被访问(可以做项目的平滑升级)

环境隔离-namespace

nacos中服务存储和数据存储的最外层都是一个名为namespace的东西,用来做最外层隔离

步骤:

  1. 在nacos控制台中的命名空间添加命名空间

  2. 在要隔离的yml文件中添加namespace

    spring:
    	cloud:
    		nacos:
    			server-addr: localhost:8848
    			discovery:
    				cluster-name: SH
    				namespace: 命名空间id
    

总结:

  1. namespzce用来做环境隔离
  2. 每个namespace都有唯一id
  3. 不同namespace下的服务不可见

Nacos与eureka的区别

  1. Nacos与eureka的共同点
    • 都支持服务注册和服务拉取
    • 都支持服务提供者心跳方式做健康检测
  2. Nacos与Eureka的区别
    • nacos支持服务端主动检测提供者状态:临时实例采用心跳模式,非临时实例采用主动检测模式
    • 临时实例心跳不正常会被剔除,非临时实例则不会被剔除
    • nacos支持服务列表变更的消息推送模式,服务列表更新更及时
    • nacos集群默认采用ap方式,当集群中存在非临时实例时,采用cp模式;eureka采用ap方式

服务注册到nacos时,可以选择注册为临时或非临时实例,通过下面的配置来设置

spring:
	cloud:
		nacos:
			discovery:
				ephemeral: false  #设置是否为临时实例

Nacos配置管理

统一配置管理

  1. 在nacos中添加配置信息
  2. 在弹出的表单中填写配置信息
    • 配置文件的id:[服务名称]-[profile].后缀名
    • 分组 默认即可
    • 格式 目前支持yaml 和properties
    • 完成
  3. 最好只配置需要热更新的配置

微服务配置拉取

  1. 在nacos中添加配置文件

  2. 在微服务中引入nacos的config依赖

    <!--导入nacos配置依赖-->
            <dependency>
                <groupId>com.alibaba.cloud</groupId>
                <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
            </dependency>
    
  3. 在微服务中添加bootstrap.yml(引导文件,优先级高于application.yml),配置nacos地址、当前环境、服务名称、文件后缀名。这些决定了程序启动时去nacos读取那个文件

    spring:
      application:
        name: userservice #服务名称
      profiles:
        active: dev #开发环境
      cloud:
        nacos:
          server-addr: localhost:8848
          config:
            file-extension: yaml #文件后缀名
    
  4. @refreshScope注解 热更新自动刷新注解 在需要自动注入的地方使用

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

  1. 通过@value注解注入,结合@refreshScope来刷新
  2. 通过@ConfigurationProperties注入,自动刷新

注意事项:

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

多环境配置共享

微服务启动时会从nacos读取多个配置文件:

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

无论profile如何变化,[spring.application.name.yaml].yaml这个文件一定会加载,因此多环境共享配置可以写入这个文件

多种配置的优先级:

  • 服务名-profile.yaml > 服务名称.yaml > 本地配置

Nacos集群搭建

nacos生产环境下一定要部署为集群状态

  1. 配置nacos

    配置三份nacos节点

    • 进入nacos的conf目录,修改配制文件cluster.conf.example,重命名为cluster.conf

    • 添加内容:

      127.0.0.1:8845

      127.0.0.1:8846

      127.0.0.1:8847

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

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

      server.port=8845

      server.port=8846

      server.port=8847

  3. 分别启动三个nacos节点

  4. nginx反向代理

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

      upstream nacos-cluster {
      	server 127.0.0.1:8845;
      	server 127.0.0.1:8846;
      	server 127.0.0.1:8847;
      }
      
      server {
      	listen	80;
      	server_name	localhost;
      	
      	location /nacos {
      		proxy_pass http://nacos-cluster;
      	}
      }
      
  5. 将nacos中的连接数据库打开

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

http客户端Feign

RestTemplate方式调用存在的问题

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

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

​ 存在下面的问题:

  • 代码可读性差,编程体验不统一
  • 参数复杂URL难以维护

Feign的介绍

Feign是一个声明式的http客户端,官方地址:http://github.com/OpenFeign/feign 其作用就是帮助我们优雅的实现http请求的发送,解决上面提到的问题

使用步骤:

  1. 引入依赖

    <!--导入feign依赖-->
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-starter-openfeign</artifactId>
            </dependency>
    
  2. 在order-service的启动类添加注解开启Feign的功能

    @MapperScan("cn.itcast.order.mapper")
    @SpringBootApplication
    @EnableFeignClients
    public class OrderApplication {
        public static void main(String[] args) {    SpringApplication.run(OrderApplication.class, args);
        }
    }
    
  3. 编写Feign客户端

    @FeignClient("userservice")
    public interface UserClient {
        @GetMapping("/user/{id}")
        User findUserById(@PathVariable("id") Long id);
    }
    

自定义Feign的配置

Feign运行自定义配置来覆盖默认配置,可以修改的配置如下

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

一般我们需要配置的就是日志级别

自定义Feign的配置有两种方式:

方式一:配置文件方式

feign:
	client:
		config:
			default:  #这里用default就是全局配置  
				loggerLevel: Full  #日志级别
feign:
	client:
		config:
			userservice:  #这里用userservice就是只有某个微服务配置
				loggerLevel: Full  #日志级别

方式二:Java代码方式,需要先声明一个bean

public class FeignClientConfiguration{
	@Bean
	public Logger.Level feignLogLevel(){
		return Logger.Level.BASIC;
	}

}
  1. 而后如果是全局配置,则把它放到@EnableFeignClient这个注解中

    @EnableFeignClients(defaultConfiguration = FeignClientConfiguration.class)
    
  2. 如果是局部配置,则把他放到@FeignClient这个注解中

    @feignClient(value = "userservice",configuration=FeignClientConfiguration.class)
    

Feign的性能优化

Feign底层的客户端实现:

  • URLConnection:默认实现,不支持连接池
  • Apache Httpclient:支持连接池
  • OKHTTP:支持连接池

因此优化Feign的性能主要包括:

  1. 使用连接池代替默认的URLConnection
  2. 日志级别,最好用basic或none

步骤:

  1. 引入依赖:

    <!--引入HttpClient依赖-->
            <dependency>
                <groupId>io.github.openfeign</groupId>
                <artifactId>feign-httpclient</artifactId>
            </dependency>
    

Feign的最佳实践

方式一(继承):

给消费者的FeignClient和提供者的controller定义统一的父接口作为标准

因为消费者的feignClient和提供者的controller的方法名和参数以及请求方式都相似,所以可以把他们抽取出来定义一个父类 让他们来继承 ,这样 他们就都不用写了。但这样他们的耦合性就高了

方式二(抽取):

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

因为服务的消费者用的userCilent有很多消费者在用,我们把userclient抽取出来(包括实体类,默认配置等),让消费者来医用依赖,然后远程调用服务的提供者。

实现最佳实现方式二
  1. 首先创建一个module,命名为feign-api,然后引入feign的starter依赖
  2. 将order-service中编写的UserClient、User、DefaultFeignConfiguration都复制到feign-api项目中
  3. 在order-service中引入feign-api的依赖
  4. 修改order-service中的所有与上述三个组件有关的import部分,改成导入feign-api中的包

当定义FeignClient不在SpringBootApplication的扫描包范围时,这些FeignClient无法使用。有两种解决方案:

  1. 方式一:指定FeignClient所在包

    @EnableFeignClients(basePackages="cn.itcast.feign.clients")
    
  2. 方式二:指定FeignClient字节码

    @EnableFeignClients(clients = {UserClient.class})
    

GateWay网关

为什么需要网关?

微服务先注册到nacos,微服务与微服务之间通过feign来调用,用户需要操作时直接发请求到微服务,但这时候就存在一个问题,我们的微服务就在那里让所有人访问这是不安全的,因为有些业务属于公司内部的不需要面向大众,所以就需要对用户的身份进行验证。所以gateway网关就是专门做这个的。验证完后是不是需要放行到微服务去那?所以网关还需要将请求转发到微服务,也就是服务路由,并且网关还做到了负载均衡,因为一个查询请求对应的微服务可能偶多个实例。网关还可以请求限流

网关的功能:

  • 身份验证和权限校验。
  • 服务路由和负载均衡
  • 请求限流

网关的技术实现

  • gateway
  • zuul

zuul是基于servlet的实现,属于阻塞式编程。而SpringcloudGateway是基于Spring5中提供的webFlux,属于响应式编程的实现,具备更好的性能。

搭建网关服务

步骤:

  1. 创建新的mouble,引入SpringCloudGateWay的依赖和nacos的服务发现依赖

    <!--nacos的服务发现依赖-->
            <dependency>
                <groupId>com.alibaba.cloud</groupId>
                <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
            </dependency>
            <!--网关依赖-->
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-starter-gateway</artifactId>
            </dependency>
    
  2. 编写路由配置及nacos地址

    spring:
      application:
        name: gateway  #服务名称
      cloud:
        nacos:
          server-addr: localhost:8848  #nacos地址
        gateway:
          routes: #网关路由配置
            - id: user-service  #路由id,自定义,只要唯一即可
              uri: lb://userservice  #路由的目标地址,lb就是负载均衡  后面跟着服务名
              predicates: #路由断言,也就是判断请求是否符合路由规则的条件
                - Path=/user/** #这是按照路径匹配 也就是/user/开头就符合要求
            - id: order-service
              uri: lb://orderservice
              predicates:
                - Path=/order/**
    server:
      port: 10010 #网关端口
    

总结:

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

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

    路由配置包括:

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

路由断言工厂RoutePredicateFactory

spring提供了11种基本的predicate工厂:

名称说明示例
After是某个时间点之后的请求-Afer=2023-01-20T17:42:47.789-07:00[America/Denver]
Before是某个时间点之前的请求-Afer=2023-01-20T17:42:47.789-07:00[America/Denver]
Between是某两个之间点之间的请求-Between=2023-01-20T17:42:47.789-07:00[America/Denver],023-01-20T17:42:47.789-07:00[America/Denver]
Cookid请求必须包含某些cookie-Cookid=chocolata,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必须是指定范围-ReomteAdr=192.168.1.1/24
Weight权重处理

路由过滤器GatewayFilter

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

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

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

给所有进入userservice的请求添加一个请求头:Truth=rosevvi fucking awesome!

实现方式:在gateway中修改application.yml文件,给userserivice的路由添加过滤器:

routes: #网关路由配置
        - id: user-service  #路由id,自定义,只要唯一即可
          uri: lb://userservice  #路由的目标地址,lb就是负载均衡  后面跟着服务名
          predicates: #路由断言,也就是判断请求是否符合路由规则的条件
            - Path=/user/** #这是按照路径匹配 也就是/user/开头就符合要求
          filters:
            - AddRequestHeader=Truth,rosevvi is fucking rwesome!!!

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

      routes: #网关路由配置
        - id: user-service  #路由id,自定义,只要唯一即可
          uri: lb://userservice  #路由的目标地址,lb就是负载均衡  后面跟着服务名
          predicates: #路由断言,也就是判断请求是否符合路由规则的条件
            - Path=/user/** #这是按照路径匹配 也就是/user/开头就符合要求
#          filters:
#            - AddRequestHeader=Truth,rosevvi is fucking rwesome!!!
        - id: order-service
          uri: lb://orderservice
          predicates:
            - Path=/order/**
      default-filters:
        - AddRequestHeader=Truth,rosevvi is fucking rwesome!!!

全局过滤器GlobleFilter

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

定义方式是实现FlobalFilter接口

//设置优先级  或者实现Ordered接口
@Order(-1)
@Component
public class AuthFilter implements GlobalFilter {

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        //1、获取请求参数
        ServerHttpRequest request = exchange.getRequest();
        //2、获取参数中的Authorization参数
        String auth = request.getQueryParams().getFirst("Authorization");
        //3、判断参数值是否为admin
        if ("admin".equals(auth)){
            return chain.filter(exchange);
        }
        //4、拦截  设置状态码
        exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
        //5、拦截请求
        return  exchange.getResponse().setComplete();
    }
}
过滤器的执行顺序
  • 每一个过滤器都必须指定一个int类型的order值,order值越小,优先级越高,执行顺序越靠前。
  • GlobalFilter通过实现Order接口,或者添加@Order注解来指定order值,有我们自己指定
  • 路由过滤器和defaultFilter的order由Spring指定,默认是按照声明顺序从1递增。
  • 当过滤器的order值一样时,会按照defaultFilter>路由过滤器>GlobalFilter的顺序执行。

网关跨域问题处理

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

  • 域名不同:www.taobao.com和www.taobao.org和www.jd.com和miaosha.jd.com
  • 域名相同,端口不容:localhost:8080和localhost:8081

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

解决方案:CORS

网关处理跨域采用的同样是CORS方案,并且只需要简单配置即可实现:

gateway:
      globalcors: #全局的跨域处理
        add-to-simple-url-handler-mapping: true  #解决options请求被拦截
        cors-configurations:
          '[/**]': #拦截那些请求
            allowedOrigins:  #允许那些网站的跨域请求
              - "http://localhost:8090"
              - "http://www.leyou.com"
            allowedMethods:  #允许的跨域ajax的请求方式
              - "GET"
              - "POST"
              - "DELETE"
              - "PUT"
              - "OPTIONS"
            allowedHeaders: "*" #允许在请求头中携带的头信息
            allowCredentials: true #是否允许携带cookid
            maxAge: 360000 #这次跨域检测的有效期

Docker

项目部署的问题

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

  • 依赖关系复杂,容易出现兼容性问题
  • 开发、测试、生产环境有差异

Docker如何解决依赖的兼容问题的?

  • 将应用的libs(函数库)、Deps(依赖)、配置与应用一起打包
  • 将每个应用放到一个隔离容器去运行,避免互相干扰

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

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

Docker和虚拟机的差异:

  • docker是一个系统进程;虚拟机是在操作系统中的操作系统
  • docker体积小、启动速度快、性能好;虚拟机体积大、启动速度慢、性能一般

Docker架构

镜像(Image):Docker将应用程序以及其所需要的依赖、环境、配置等文件打包在一起,称为镜像。

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

DockerHub:DockerHub是一个Docker镜像的托管平台。这样的怕平台称为DockerRegistry。

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

  • 服务端(server):Docker守护进程,负责处理Docker指令,管理镜像、容器等
  • 客户端(client):通过命令或RestApi向Docker服务端发送指令。可以在本地或远程向服务器发送指令

安装Docker

常用命令:
#镜像命令
# 关闭
systemctl stop firewalld
# 禁止开机启动防火墙
systemctl disable firewalld
# 查看防火墙状态
systemctl status firewalld
# 启动Docker
systemctl start docker
# 查看docker版本
docker -v
# 停止docker 服务
systemctl stop docker
# 重启docker 服务
systemctl restart docker
# 查看本地镜像
docker images
# 拉取镜像 没有版本 默认最新版
docker pull nginx:latest
# 推送镜像 
docker push
# 删除一个本地镜像
docker rmi nginx:latest
# 查看帮助
docker save --help
# 利用dockers save将nginx镜像导出到磁盘,然后再通过load加载回来  docker save [options] image [image]...
docker save -o nginx.tar nginx:latest
# 利用load加载镜像
docker load -i nginx.tar
# 构建镜像  自定义镜像  -t指定dockerfile所在目录 空格点.
docker build -t
# 容器常用命令
# 运行  --name:给容器起名字   -p:将宿主机端口与容器端口映射,冒号左边是宿主机端口,右侧是容器端口  80:80  -d:后台运行容器 nginx:基于nginx镜像创建的
docker run  --name containerName -p 80:80 -d nginx
# 暂停
docker pause
# 开始
docker unpause
# 停止
docker stop
# 开始
docker start
# 查看所有运行的容器及状态  -a查看所有状态的容器
docker ps -a
# 查看容器运行日志 docker logs [name] -f   -f:持续输出日志
docker logs mn
# 进入容器执行命令   要执行的命令通常为bash
docker exec -it [容器名][要执行的命令]
# 删除指定容器 -f强制删除运行中的容器
docker rm -f

#数据卷命令
#数据卷操作命令的基本语法
docker volume [COMMAND]
#创建一个volume
docker volume create
#显示一个或多个volume的信息
docker volume inspect
#列出所有的volume
docker volume ls
#删除未使用的volume
docker volume prune
#删除一个或多个指定的volume
docker volume rm
#数据卷挂载
docker run --name mn -v html:/root/html -p 8080:80 nginx
1.CentOs安装Docker

DockerCE支持64位版本的Centos7,并且要求内核版本不低于3.10,Centos7满足最低内核要求,所以我们在Centos7上安装Docker

1.1卸载(可选)

如果之间安装过旧版本的Docker,可以使用下面命令卸载:

yum  remove docker \
    docker-client \
    docker-client-latest \
    docker-common \
    docker-latest \
    docker-latest=logrotate \
    docker-logroate \
    docker-selinux \
    docker-engine-selinux \
    docker-enfine \
    docker -ce 
1.2 安装Docker

首先要联网虚拟机,安装yum工具

yum install -y yum-utils \
    device=mapper-persistent-data \
    lvn2 --skip-broken\

更新本地镜像源:

#设置Docker镜像源
yum-config-manger \
    --add-repo \
    https://mirrors.aliyun.com/docker-ce/linux/centos/docker-ce.repo
sed -i 's/download.docker.com/mirrors.aliyun.com\/docker-ce/g'
/etc/yum.repos.d/docker-ce.repo
yum makecache fast

然后输入命令:

yum install -y docker-ce

稍等就安装完成了

1.3 启动docker

Docker应用需要用到各种端口,逐一去修改防护墙设置非常麻烦,建议关闭防火墙

# 关闭
systemctl stop firewalld
# 禁止开机启动防火墙
systemctl disable firewalld
# 查看防火墙状态
systemctl status firewalld
# 启动Docker
systemctl start docker
# 查看docker版本
docker -v
# 停止docker 服务
systemctl stop docker
# 重启docker 服务
systemctl restart docker

1.4 配置镜像

docker官方镜像仓库网速较差,我们需要设置国内镜像

参考阿里云的镜像加速文档:https://cr.console.aliyun.com/cn-hangzhou/instances/mirrors

sudo mkdir -p /etc/docker
sudo tee /etc/docker/daemon.json <<-'EOF'
{
  "registry-mirrors": ["https://u2fv9qvc.mirror.aliyuncs.com"]
}
EOF
sudo systemctl daemon-reload
sudo systemctl restart docker

容器练习

进入nginx容器,修改html文件内容,添加“船只教育观影你”

  1. 进入容器。进入我们刚创建的nginx的容器命令为

    docker exec -it mn bash
    
    

    命令解读:

    • docker exec:进入容器内部,执行一个命令
    • -it:给当前进入的容器创建一个标准输入、输出终端,允许我们与容器交互
    • mn:要进入的容器的名称
    • bash:进入容器后执行的命令,bash是一个linux终端交互命令

数据卷

数据卷(volume)是一个虚拟目录,指向宿主机文件系统中的某个目录(/var/lib/docker/volumes),创建一个数据卷html那么他就会创建/var/lib/docker/volumes/html

数据卷操作命令:

#数据卷操作命令的基本语法
docker volume [COMMAND]
#创建一个volume
docker volume create [name]
#显示一个或多个volume的信息
docker volume inspect
#列出所有的volume
docker volume ls
#删除未使用的volume
docker volume prune
#删除一个或多个指定的volume
docker volume rm

数据卷挂载

#-v 前半部分是数据卷,后半部分是容器内目录
docker run --name mn -v html:/root/html -p 8080:80 nginx

案例:

需求说明:上个案例中,我们进入nginx容器内部,已经知道nginx的html目录所在位置/usr/share/nginx/html,我们需要把这个目录挂载到html这个数据卷上,方便操作其中的内容。提示:运行容器时使用-v参数挂载数据卷

步骤:

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

    #创建名为html的数据卷
    docker valume html
    #创建容器并挂载数据卷到容器内的html目录
    docker run --name mn -p 80:80 -v 
    html:/usr/share/nginx/html -d nginx
    
  2. 进入html数据卷所在位置,并修改HTML内容

    #查看数据卷的位置
    docker volume inspect html
    #计入该目录 
    cd 
    #修改文件
    vi
    
宿主机目录挂载

宿主机目录可以直接挂载到容器

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

实现思路如下:

  1. 在将课前资料中的mysql.tar文件上传到虚拟机,通过load命令加载为镜像

  2. 创建目录/tmp/myql/data,

  3. 创建目录/tmp/myql/conf,将课前资料提供的hmy.cnf文件上传到/tmp/myql/conf4.

  4. 去DockerHub查阅资料,创建并运行MySQL容器,要求:

    • 挂载/tmp/myql/data到mysql容器内数据存储目录
    • 挂载/tmp/myql/conf/hmy.cnf到mysql容器的配置文件
    • 设置MySQL密码
    docker run \
    	--name mysql \
    	-e MYSAL_ROOT_PASSWORD=123 \
    	-p 3306:3306 \
    	-v /tmp/mysql/conf/hmy.cnf:/etc/mysql/conf.d/hmy.cnf \
    	-v /tmp/mysql/data:/var/lib/musql
    	-d \
    	mysql:5.7.25
    

总结:

  1. docker run的命令中通过-v参数挂载文件或目录到容器中:
    • -v volume 名称:容器内目录
    • -v 宿主机文件:容器内文件
    • -v 宿主机目录:容器内目录
  2. 数据卷挂载与目录直接挂载的区别:
    • 数据卷挂载耦合度低,由docker来管理,但是目录较深,不好找
    • 目录挂载耦合度高,需要我们自己管理目录,不过目录容易寻找查看

DockerFile自定义镜像

镜像结构:

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

  • BaseImage层:包含基本的系统函数库、环境变量、文件系统
  • Entrypoint:入口,是镜像中应用启动的命令
  • 其他:在BaseImage基础上添加依赖、安装程序、完成整个应用的安装和配置
DockerFile

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

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

案例:

基于Ubuntu镜像构建一个新镜像,运行一个java项目
步骤1∶新建一个空文件夹docker-demo
步骤2:拷贝课前资料中的docker-demo.jar文件到docker-demo这个目录
步骤3:拷贝课前资料中的jdk8.tar.gz文件到docker-demo这个目录
步骤4:拷贝课前资料提供的Dockerfile到docker-demo这个目录
步骤5:进入docker-demo
步骤6:运行命令:

DokerFile示例

#指定基础镜像
FROM centos:6
# 配置环境变量,JDK的安装目录
ENV JAVA_DIR=/usr/local

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

# 安装JDK
Run cd $JAVA_DIR \
	&& tar -xf ./jdk8.tar.gz \
	&& mv ./jdk1.8.0_144 ./java8
	
# 配置环境变量
ENV JAVA_HOME=$JAVA_DIR/java8
ENV PATH=$PATH:$JAVA_DIR/bin

#暴露端口
EXPOSE 8090

# 入口 Java项目的启动命令
ENTRYPOINT java -jar .tmp/app.jar

现在有人把安装jdk的步骤构建成了镜像,我们可以利用java:8-alpine镜像来构建自己的镜像

# 指定基础镜像
FROM java:8-alpine
COPY ./docker-demo.jar /tmp/app.jar
#暴露端口
EXPOSE 8090
# 入口 Java项目的启动命令
ENTRYPOINT java -jar .tmp/app.jar
DockerCompose
  • DockerCompose可以基于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

MQ

同步调用:你个妹子打视频电话

异步调用:你和19个妹子发微信消息

同步调用的问题:微服务之间基于Feign的调用就属于同步方式,存在一些问题

同步调用的优点:

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

同步调用的问题:

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

异步调用:异步调用常见实现就是事件驱动模式

异步通讯优势:

  • 服务解耦
  • 吞吐量提升
  • 故障隔离
  • 流量削峰

异步通信的缺点:

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

什么是MQ

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

RabbitMQACtiveMQRocketMQKafka
公司/社区RabbitApache阿里Apache
开发语言ErlangJavaJavaScala&Java
协议支持AMQP、XMPP、SMTP、STOMPOpenWire,STOMP,REST,XNPP,AMQP自定义协议自定义协议
可用性一般
单机吞吐量一般非常高
消息延迟微秒级毫秒级毫秒级毫秒以内
消息可靠性一般一般

RabbitMQ

单机部署

下载镜像:

docker pull rabbitmq:3-management

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

docker run \
	-e RABBITMQ_DEFAULT_USER=rosevvi \ #设置环境变量
	-e RABBITMQ_DEFAULT_PASS=123321 \
	--name mq \  #起名
	--hostname mq1 \ #配置主机名
	-p 15672:15672 \ #端口映射 管理平台的端口
	-p 5672:5672 \ #消息通信的端口
	-d \
	rabbitmq:3-management

RabbitMQ的几个概念:

  • channel:操作MQ的工具
  • exchange:路有消息到队列中
  • queue:缓存消息
  • virtual host:虚拟主机,是对queue、exchange等资源的逻辑分组
消息模型

MQ的官方文档中给出了5个MQ的demo示例,对应了集中不同的用法:

  • 基本消息队列(BasicQueue)
  • 工作消息队列(WorkQueue)
  • 发布订阅(Publish、Subscribe),有根据交换机类型不同分为三种:
    • FanoutExChange:广播
    • DirectExchange:路由
    • TopicExchange:主题

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

  1. 建立连接connection
  2. 创建channel
  3. 利用channel声明队列
  4. 利用channel向队列发送消息

基本消息队列的消息接收流程:

  1. 建立connection
  2. 创建channel
  3. 利用channel声明队列
  4. 定义consumer的消费行为handleDelivery()
  5. 利用channel将消费者与队列绑定

SpringAMQP

Advanced Message Queuing Protocol,是用于在应用程序之间传递业务消息的开放标准。该协议与语言和平台无关,更符合微服务中独立性的要求。

SpringAMQP 是基于AMQP协议定义的一套API规范,提供了模板来发送和接收消息。包含两部分,其中spring-amop是基础抽象,spring-rabbit是底层的默认实现。

案例:利用SpringAMQP实现helloworld中的基础消息队列功能

流程:

  1. 在父工程中引入spring-amqp的依赖
  2. 在publisher服务中利用RabbitTemplate发送消息到simple.queue这个队列
  3. 在consumer服务中编写消费逻辑,绑定simple.queue 这个队列

步骤一:

  1. 引入AMQP依赖

            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-amqp</artifactId>
            </dependency>
    

步骤二:

  1. 在publisher服务中编写application.yml,添加mq连接信息

    spring:
      rabbitmq:
        host: 43.143.237.123 #rabbitMQ的ip地址
        port: 5672 #端口
        virtual-host: /  #虚拟主机
        username: rosevvi
        password: 123321
    
  2. 在publisher服务中新建一个测试类,编写测试方法:

    @RunWith(SpringRunner.class)
    @SpringBootTest
    public class PublisherTest {
    @Autowired
    private RabbitTemplate rabbitTemplatel;
    
    @Test
    public void setRabbitTemplatel(){
        String queuename = "simple.queue";
        String message = "hello rosevvi";
        rabbitTemplatel.convertAndSend(queuename,message);
    }
    }
    
    
    

步骤三:在consumer中编写消费逻辑,监听simple.queue

  1. 在consumer服务中编写application.yml,添加mq连接信息

    spring:
      rabbitmq:
        host: 43.143.237.123 #rabbitMQ的ip地址
        port: 5672 #端口
        virtual-host: /  #虚拟主机
        username: rosevvi
        password: 123321
    
  2. 在consumer服务中新建一个类,编写消费逻辑

    
    @Component
    public class RabbitListener {
    
        @org.springframework.amqp.rabbit.annotation.RabbitListener(queues = "simple.queue")
        public void listenerSimpleQueueMessage(String  msg){
            System.out.println("消费者收到消息:{"+msg+"}");
        }
    }
    

WorkQueue工作队列

Workqueue,工作队列,可以提高消息处理速度,避免队列消息堆积

案例:模拟WorkQueue,实现一个队列绑定多个消费者

基本思路:

  1. 在publisher服务中定义测试方法,每秒产生50条消息,发送到simple.queue
  2. 在consumer服务中定义两个消息监听者,都监听simple.queue
  3. 消费者1每秒处理50条消息,消费者2每秒处理10条消息。

消费预取限制:修改application.yml文件,设置preFetch这个值,可以控制预取消息的上限.

spring:
  rabbitmq:
    host: 43.143.237.123 #rabbitMQ的ip地址
    port: 5672 #端口
    virtual-host: /  #虚拟主机
    username: rosevvi
    password: 123321
    listener:
      simple:
        prefetch: 1

发布、订阅

发布订阅模式与之前案例的区别就是允许将同一消息发送给多个消费者。实现方式是加入了exchange(交换机)

常见exchange类型包括:

  • Fanout:广播
  • Direct:路由
  • Topic:话题

发布订阅-FanoutExchange

FanoutExchange会将收到的消息路由到每一个跟其绑定的queue

案例:

  1. 在consumer服务中,利用代码声明队列、交换机,并将两者绑定。
  2. 在consumer服务中,编写两个消费者方法,分别监听fanout.queue1和fanout.queue2
  3. 在publisher中编写测试方法,向rosevvi.fanout发送消息

步骤:

  1. 在consumer服务声明Exchange、Queue、Binding

    @Configuration
    public class FanoutConfig {
        //定义交换机
        @Bean
        public FanoutExchange fanoutExchange(){
            return new FanoutExchange("rosevvi.fanout");
        }
        //定义队列1
        @Bean
        public Queue queue1(){
            return new Queue("rosevvi.queue1");
        }
        //绑定队列1和交换机
        @Bean
        public Binding bindingQueue1(Queue queue1,FanoutExchange fanoutExchange){
            return BindingBuilder.bind(queue1).to(fanoutExchange);
        }
        //定义队列2
        @Bean
        public Queue queue2(){
            return new Queue("rosevvi.queue2");
        }
        //绑定队列2和交换机
        @Bean
        public Binding bindingQueue2(Queue queue2,FanoutExchange fanoutExchange){
            return BindingBuilder.bind(queue2).to(fanoutExchange);
        }
    
    
    }
    
    
  2. 在consumer服务声明两个消费者

        @org.springframework.amqp.rabbit.annotation.RabbitListener(queues = "rosevvi.queue1")
        public void listenerFanoutMessage1(String  msg) throws InterruptedException {
            System.out.println("消费者1收到Fanout消息:{"+msg+"}"+ LocalDateTime.now());
        }
        @org.springframework.amqp.rabbit.annotation.RabbitListener(queues = "rosevvi.queue2")
        public void listenerFanoutMessage2(String  msg) throws InterruptedException {
            System.err.println("消费者2收到Fanout消息:{"+msg+"}"+LocalDateTime.now());
        }
    
  3. 在publisher服务发送消息到FanoutExchange

        @Test
        public void fanOutExchangeTest(){
            //交换机名字
            String name= "rosevvi.fanout";
            //消息
            String message = "rosevvi,Hello";
    
            rabbitTemplatel.convertAndSend(name,"",message);
        }
    

交换机的作用是什么:

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

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

  1. Queue
  2. FanoutExchange
  3. Binding

发布订阅-DirectExchange

DirectExchange会将接收到的消息根据规则路由到指定的Queue,因此称为路由模式(routes)

  • 每一个Queue都与Exchange设置一个BindingKey
  • 发布者发送消息时,指定消息的RoutingKey
  • Exchange将消息路由到BindingKey与消息RoutingKey一致的队列

案例:利用SpringAMQP演示DirectExchange的使用

实现思路:

  1. 利用@RabbitListener声明Exchange、Queue、RountingKey
  2. 在Consumer服务中,编写两个消费者方法,分别监听direct.queue1和direct.queue2
  3. 在publisher中编写测试方法,向rosevvi.direct发送消息

步骤1:在consumer服务声明Exchange、Queue

    @RabbitListener(bindings = @QueueBinding(
            value = @Queue("rosevvi.direct.queue2"),
            exchange = @Exchange(value = "rosevvi.direct",type = ExchangeTypes.DIRECT),
            key = {"red","yellow"}
    ))
    public void listenerDirectMessage2(String msg){
        System.err.println("消费者2收到Direct消息:{"+msg+"}"+LocalDateTime.now());
    }

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

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

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

  • @Queue
  • @Exchange

发布订阅-TopicExchange

TopicExchange与DirectExchange类似,区别在于routingKey必须是多个单词的列表,并且以 . 分割

Queue与Exchange指定BindingKey时可以使用通配符:

  • #:代指0个或多个单词
  • *:代指一个单词

案例:利用SpringAMQP演示TopicExchange的使用

实现思路如下:

  1. 并利用@RabbitListener声明Exchange、Queue、RoutingKey
  2. 在consumer服务中,编写两个消费者方法,分别监听topic.queue1和topic.queue2
  3. 在publisher中编写测试方法,向itcast.topic发送消息

步骤1:在xonsumer服务声明Exchange、Queue

    @RabbitListener(bindings = @QueueBinding(
            value = @Queue(name = "rosevvi.topic.queue2"),
            exchange = @Exchange(name = "rosevvi.topic",type = ExchangeTypes.TOPIC),
            key = "#.news"
    ))
    public void listenerTopicMessage2(String msg){
        System.err.println("消费者2收到Topic消息:{"+msg+"}"+LocalDateTime.now());
    }

消息转换器

案例:测试发送Object类型消息

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

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

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

  1. 在publisher服务引入依赖:

            <dependency>
                <groupId>com.fasterxml.jackson.core</groupId>
                <artifactId>jackson-databind</artifactId>
            </dependency>
    
  2. 在publisher服务声明MessageConverter

        @Bean
        public MessageConverter messageConverter(){
            return new Jackson2JsonMessageConverter();
        }
    

SpringAMQP中消息的序列化和反序列化是怎么实现的?

  • 利用MessageConverter实现的,默认是JDK的序列化
  • 注意发送方与接收方必须使用相同的MessageConverter

分布式搜索ElasticSearch

什么是elasticsearch

  • elasticsearch是一款非常强大的开源搜索引擎,可以帮助我们从海量数据中快速找到需要的内容。可以用来实现搜索、日志统计、分析、系统监控等功能。

什么是Elastic Stack(ELK)?

  1. 是以Elasticsearch为核心的技术栈,包括beats、logstash、kibana、elasticsearch

什么是Lucene?

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

正向索引和倒排索引

传统数据库(Mysql)采用正向索引,例如id自增利用id创建索引。

elasticsearch采用倒排索引:

  • 文档(document):每条数据就是一个文档
  • 词条(term):文档按照语义分成的词语

比如:

idtitleprice
1小米手机3499
2华为手机4999
3华为小米充电器49
4小米手环299
词条(term)文档id
小米1,3,4
手机1,2
华为2,3
充电器3
手环4

搜索时“华为手机”,会将华为手机进行分词,得到“华为”,“手机”两个词条,再利用词条去搜索得当文档id

总结:

什么是文档和词条?

  • 每一条数据就是一个文档
  • 对文档中的内容分析,得到的词语就是词条

什么是正向索引?

  • 基于文档id创建索引。查询词条时必须先找到文档,而后判断是否包含词条

什么是倒排索引?

  • 对文档内容进行分词,对词条创建索引,并记录词条所在文档的信息。查询时现根据词条查询到文档id,而后获取到文档。

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

索引(index):相同类型的文档的集合

映射(mapping):索引中文档的字段约束信息,类似表的结构约束

概念对比:

mysqlelasticsearch说明
TableIndex索引(index),就是文档的集合,类似数据库的表(table)
RowDocument文档(Document),就是一条条的数据,类似数据库中的行(Row),文档都是json格式
ColumnField字段(Field),就是json文档中的字段,类似数据库中的列(Column)
SchemaMappingMapping(映射)是索引中文档的约束,例如字段类型约束。类似数据库的表结构(Schema)
SqlDslDsl是一个elasticsearch提供的json风格的请求语句,用来操作elasticsearch,实现FRUD

架构

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

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

安装ElasticSearch

单点部署

创建网络

docker network create es-net

加载镜像

这里采用elasticsearch的7.12.1版本的镜像,这个镜像体积非常大,接近1G。不建议自己pull,建议上传到虚拟机后,自行加载

docker load -i es.tar

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

运行:运行docker命令,部署单点es:

docker run -d \
	--name es \
	-e "ES_$JAVA_OPTS=-Xms512m -Xms512m" \
	-e "discovery.type=single-node" \
	-v es-data:/usr/share/elasticsearch/date \
	-v es-plugins:/usr/share/elasticsearch/plugins \
	--privileged \
	--network es-net \
	-p 9200:9200 \ #暴露再http协议的端口
	-p 9300:9300 \ #es容器各个节点之间互联的端口
elasticsearch:7.12.1

命令解释:

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

部署kibana

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

docker run -d \
	--name kibana \
	-e ELASTICSEARCH_HOSTS=http://es:9200 \
	--network=es-net \
	-p 5601:5601 \
kibana:7.12.1
  • –network es-net:加入一个名为es-net的网络中,与elasticsearch在同一个网络中
  • -e ELASTICSEARCH_HOSTS=http://es:9200:设置elasticsearch的地址,因为kibana已经与elasticsearch在一个网络,因此可以用容器名直接访问elasticsearch
  • -p 5601:5601:端口映射配置

安装IK分词器

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

POST /_analyze
{
	"analyzer":"standard",
	"text":"黑马程序员学习java太棒了"
}

语法说明:

  • POST:请求方式
  • / _analyze:请求路径,这里省略了http://192.168.150.101:9200,有kibana帮我们补充
  • 请求参数,json风格:
    • analyzer:分词器类型,这里是默认的standard分词器
    • text:要分词的内容
安装

在线:

# 进入容器内部
docker exec -it elasticsearch /bin/bash
# 在线下载并安装
./bin/elasticsearch-plugin install
https://github.com/medcl/elasticsearch-analysis-ik/release/download/v7.12.1/elasticsearch-analysis-ik-7.12.1.zip
# 退出
exit
# 重启容器
docker restart elasticsearch

离线:

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

  1. docker volume inspect es-plugins
    
  2. 解压分词器安装包,重命名为ik

  3. 上传到es容器的插件数据卷中,也就是/var/lib/docker/volumes/es-plugins/_data;

  4. 重启容器

    #重启容器
    docker restart es
    
    #查看es日志
    docker logs -f es
    
  5. 测试:ik分词器包含两种模式

    • ik_smart:最少切分
    • ik_max_word:最细切分

IK分词器

扩展词库-停用词库

要扩展分词器的词库,只需要修改一个ik分词器目录中的config目录中的IKAnalyzer.cfg.xml文件:

<?xml version="1.0" encoding="UTF_8"?>
<! DOCTYPE properties SYSTEM "http://java.sun.com/dtd/properties.dtd">
<properties>
	<commemt>Ik analyzer 扩展配置 </comment>
	<!--用户可以在这里配置自己的扩展字典***添加扩展字典-->
	<entry key="ext_dict">ext.did</entry>
    <!--用户可以在这里配置自己的扩展停用字典***添加停用词字典-->
    <entry key="ext_stopwords">stopwords.dic</entry>
</properties>

索引库

mapping属性

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

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

es中通过restful请求操作索引库、文档。请求内容用DSL语句来表示。创建索引库和mapping的DSL语法如下:

PUT /rose
{
	"mapping":{
		"properties":{
			"info":{
				"tupe":"text",
				"analyzer":"ik_smart"
			},
			"email":{
				"type":"keyword",
				"index":"false"
			},
			"name":{
				"properties":{
					"firstName":{
						"type":"keyword"	
					}
				}
			}
		}
	}
}
索引库操作

查看索引库语法:

GET /索引库名

示例:

GET /rose

删除索引库的语法:

DELETE /索引库名

示例:

DELETE /rose

修改索引库:

索引库和mapping一旦创建无法修改,但是可以添加新的字段,语法如下:

PUT /索引库名/_mapping
{
	"properties":{
		"新的字段名":{
			"type":"integer"
		}
	}
}
#注意新的字段一定是 原本不存在的
文档操作

添加文档: 新增文档的DSL语法如下:

POST
{
	"字段1":"值1""字段2":"值2""字段3":{
		"子属性1":"值3",
		"子属性2":"值4",
	}
}

查询文档语法:

GET /索引库名/_doc/文档id

示例:

GET /rose/_doc/1

删除索引库的语法:

DELETE /索引库名/_doc/文档id

示例:

DELETE /rose/——doc/

修改文档:

方式一:全量修改,回删除旧文档,添加新文档。 既能更新也能添加

PUT /索引库名/_doc/文档id
{
	"字段1":"值1",
	"字段2":"值2"
	//。。。略
}

方式二:增量修改,修改指定字段值

POST /索引库名/_update/文档id
{
	"doc":{
    	"字段名":"新的值"
	}
}

RestClient操作索引库

什么是RestClient?

ES官方提供了各种不同语言的客户端,用来操作ES。这些客户端的本质就是组装DSL语句,通过gttp请求发送给es。

案例:利用JavaRestClient实现创建、删除索引库,判断索引库是否存在

根据课前资料提供的九点数据创建索引库,索引库名为hotel,mapping属性根据数据库结构定义。

基本步骤如下:

  1. 导入课前资料Demo
  2. 分析数据结构,定义mapping属性
  3. 初始化JavaRestClient
  4. 利用JavaRestClient创建索引库
  5. 利用JavaRestClient删除索引库
  6. 利用JavaRestClient判断索引库是否存在
步骤二:数据分析

Es中支持两种地理坐标数据类型:

  • geo_point:由维度和经度确定的一个点:“32.8752345,120.2981576”
  • geo_shape:有多个geo_point组成的复杂几何图形。例如一条直线"LINESTRING(-77.03653 38.897676,-77.009051 38.889939)"

字段拷贝可以使用copy_to属性将当前字段拷贝到指定字段。示例:

"all":{
	"type":"text",
	"analyzer":"ik_max_word"
},
"brand""{
	"type":"keyword",
	"copy_to":"all"
}
步骤三:初始化RestClient
  1. 引入es的RestHighClient依赖:

    <dependency>
    	<groupId>org.elasticsearch.client</groupId>
        <artifactId>elaeticsearch-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:

    RestHighLevelClient client =new RestHighLevelClient(RestClient.builder(
    	this.client = new RestHighLevelClient(RestClient.builder(HttpHost.create("http:43.143.237.123:9200")));
    ))
    
步骤四:创建索引库
@Test
    void testInit() throws IOException {
        //1、创建Request对象
        CreateIndexRequest request = new CreateIndexRequest("hotel");
        //2、请求参数,MAPPING_TEMPLATE是静态常量字符串,内容是创建索引可的DSL语句
        request.source(MAAPING_TEMPLATE, XContentType.JSON);
        //3、发起请求
        client.indices().create(request, RequestOptions.DEFAULT);
    }
步骤五:删除索引库、判断索引库是否存在
    @Test
    void testDelete() throws IOException {
        //1、创建Request对象
        DeleteIndexRequest request = new DeleteIndexRequest("hotel");
        //3、发起请求
        client.indices().delete(request, RequestOptions.DEFAULT);
    }

    @Test
    void testExits() throws IOException {
        //1、创建Request对象
        GetIndexRequest request = new GetIndexRequest("hotel");
        //3、发起请求
        boolean exists = client.indices().exists(request, RequestOptions.DEFAULT);
        System.err.println(exists?"索引库存在":"索引库不存在");
    }

RestClient操作文档

案例:利用JavaRestClient实现文档的CRUD

去数据库查询酒店数据,导入到hotel索引库,实现酒店数据的CRUD。

基本步骤如下:

  1. 初始化JavaRestClient
  2. 利用JavaRestClient新增酒店数据
  3. 利用JavaRestClient根据id查询酒店数据
  4. 利用JavaRestClient删除酒店数据
  5. 利用JavaRestClient修改酒店数据
步骤一:初始化

新建一个测试类,实现文档相关操作,并且完成javarestClient的初始化

@SpringBootTest
public class HotelDocumentTest {

    private RestHighLevelClient client;

    @BeforeEach
    void setUp() {
        this.client = new RestHighLevelClient(RestClient.builder(HttpHost.create("http://43.143.237.123:9200")));
    }

    @AfterEach
    void tearDown() throws IOException {
        client.close();
    }
}

步骤2:添加酒店数据到索引库

先查询酒店数据,然后给这条数据创建倒排索引,即可完成添加:

    @Autowired
    private IHotelService iHotelService;

    @Test
    void testAddDocument() throws IOException {
        //查询数据
        Hotel hotel = iHotelService.getById(36934L);
        //转换为文档类型
        HotelDoc hotelDoc=new HotelDoc(hotel);
        //1、准备request对象
        IndexRequest indexRequest = new IndexRequest("hotel").id(hotel.getId().toString());
        //2、准备json文档
        indexRequest.source(JSON.toJSONString(hotelDoc), XContentType.JSON);
        //3、发送请求
        client.index(indexRequest, RequestOptions.DEFAULT);
    }
步骤三:根据id查询酒店数据

根据id查询到的文档数据是json,需要反序列化为java对象

    @Test
    void testGetDocument() throws IOException {
        GetRequest request = new GetRequest("hotel").id("36934");
        GetResponse response = client.get(request, RequestOptions.DEFAULT);
        String json = response.getSourceAsString();
        HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class);
        System.out.println(hotelDoc);
    }
步骤四:根据id修改酒店数据
    @Test
    void testUpdateDocument() throws IOException {
        //1、创建request对象
        UpdateRequest request =new UpdateRequest("hotel","36934");
        //2、准备请求参数
        request.doc(
                "price","380",
                "starName","新二钻"
        );
        //3、发送请求
        client.update(request,RequestOptions.DEFAULT);
    }
步骤五:根据id删除酒店数据
    @Test
    void testDeleteDocument() throws IOException {
        //1、创建request对象
        DeleteRequest request =new DeleteRequest("hotel","36934");
        //3、发送请求
        client.delete(request,RequestOptions.DEFAULT);
    }
tip:批处理
    @Test
    void testBulkDocument() throws IOException {
        //批量查询酒店数据
        List<Hotel> list = iHotelService.list();
        //1、创建request对象
        BulkRequest request =new BulkRequest();
        //2、准备参数
        for (Hotel hotel : list) {
            HotelDoc hotelDoc=new HotelDoc(hotel);
            request.add(new IndexRequest("hotel").id(
                    hotelDoc.getId().toString())
                    .source(JSON.toJSONString(hotelDoc),XContentType.JSON)
            );
        }
        //3、发送请求
        client.bulk(request,RequestOptions.DEFAULT);
    }

DSL查询语法

DSLQuery的分类

Elasticsearch提供了基于json的dsl来定义查询。常见的查询类型包括:

  • 查询所有:查询出所有数据,一般测试用。例如:match_all
  • 全文检索(full text)查询:利用分词器对用户输入内容分词,然后去倒排索引库中匹配。例如:
    • match_query
    • multi_match_query
  • 精确查询:根据精确词条值查找数据,一般是查找keyword、数值、日期、boolean等类型的字段。例如:
    • ids
    • range
    • term
  • 地理(geo)查询:根据经纬度查询。例如:
    • geo_distance
    • geo_bounding_box
  • 复合(compound)查询:复合查询可以将上述各种查询条件组合起来,合并查询条件。例如:
    • bool
    • function_score
DSL Query基本语法

​ 查询的基本语法如下:

GET /indexName/_search
{
	"query":{
		"查询类型":{
			"查询条件":"条件值"
		}
	}
}

查询所有

GET /indexName/_search
{
	"query":{
		"match_all":{
		
		}
	}
}
全文检索查询

全文检索查询,会对用户输入内容分词,常用于搜索框搜索:

matcha查询:全文检索查询的一种,会对用户输入的内容进行分词,然后去倒排索引库检索,语法:

GET /indexName/_search
{
	"query":{
		"match":{
			"FIELD":"TEXT"
		}
	}
}

multi_match:与match查询相似,只不过允许同时查询多个字段,语法:

GET /indexName/_search
{
	"query":{
		"multi_match":{
			"query":"TEXT",
			"fields":["Field1","Field2"]
		}
	}
}
精确查询

精确查询一般是查找keyword、数值、日期、boolean等类型字段。所以不会对搜索条件分词。常见的有:

  • term:根据词条精确值查询
  • range:根据值的范围查询

语法如下:

term:

GET /hotel/_search
{
  "query": {
    "term": {
      "city": {
        "value": "上海"
      }
    }
  }
}

range:

#精确查询 range
GET /hotel/_search
{
  "query": {
    "range": {
      "price": {
        "gte": 335,
        "lte": 337
      }
    }
  }
}
地理查询

根据经纬度查询。常见的使用场景:

  • 携程:搜索我附近的酒店
  • 滴滴:搜索我附近的出租车

根据经纬度查询,官方文档。例如:

  • geo_bounding_box:查询geo_point值落在某个举行范围的所有文档

    GET /indexName/_search
    {
    	"query":{
    		"geo_bounding_box":{
    			"FIELD":{
    				"top_left":{
                    	"lat":31.1,
                    	"lon":121.5
    				},
    				"bottom_right":{
    					"lat":30.9,
    					"lon":121.7
    				}
    			}
    		}
    	}
    }
    
  • geo_distance:查询到指定中心点小于莫格距离值的所有文档

    GET /indexName/_search
    {
    	"query":{
    		"geo_distance":{
    			"distance":"15km",
    			"FIELD":"31.21,121.5"
    		}
    	}
    }
    
复合查询

复合查询:复合查询可以将其他简单查询组合起来,实现更复杂的搜索逻辑,例如:

  • fuction score:算分函数查询,可以控制文档相关性算分,控制文档排名。例如百度竞价

相关性算分

当我们利用match查询时,文档结果回根据与搜索词条的关联度打分,返回结果时按照分值降序排列。

  • TF(词条频率)=词条出现次数 / 文档中词条总数
  • TF—IDF算法:
    • IDF(逆文档频率)=log(文档总数 / 包含词条的文档总数)
    • score=∑TF词条频率 * IDF(逆文档频率)
  • BM25算法(主流)
FunctionScoreQuery

使用FunctionScoreQuery,可以修改文档的相关性算分,根据新的到的算分排序。

GET /hotel/_search
{
  "query": {
    "function_score": {
      "query": {
        "match": {
          "all": "上海"
        }
      },
      "functions": [
        {
          "filter": {
            "term": {
              "id": "5873072"
            }
          },
          "weight": 10
        }
      ],
      "boost_mode": "multiply"
    }
  }
}

  • “query”:{“match”:{“all”:“外滩”}}:原始查询条件,搜索文档并根据相关性打分(query score)
  • “filter”:{“term”:{“id”:“1”}} 过滤条件,符合条件的文档才会背重新算分
  • “weight”:10 算分函数,算分函数结果称为function score ,将来回与query score运算得到新算分,常见算分函数有:
    • weight:给一个常量值,作为函数结果
    • field_value_factor:用文档中的某个字段值作为函数结果
    • random_score:随机生成一个值,作为函数结果
    • script_score:自定义计算公式,公式结果作为函数结果
  • “boost_mode”:“multiply” 加权模式,定义function score与query score的运算方式,包括:
    • multiply:两者相乘。默认就是这个
    • replace:用function score 替换query score
    • 其他:sum、avg、max、min
BooleanQuery

布尔查询是一个或多个查询字句的组合,子查询的组合方式有:

  • must:必须匹配每个子查询,类似"与"
  • should:选择性匹配子查询,类似"或"
  • must_not:必须不匹配,不参与算分,类似"非"
  • filter:必须匹配,不参与算分

案例:利用bool查询实现功能:

需求:搜索名字包含如家,价格不高于400,在坐标31.21,121.5周围10km范围内的酒店。

#复合查询  BooleanQuery
GET /hotel/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "match": {
            "name": "如家"
          }
        }
      ],
      "must_not": [
        {
          "range": {
            "score": {
              "gt": 400
            }
          }
        }
      ],
      "filter": [
        {
          "geo_distance": {
            "distance": "10km",
            "location": {
              "lat": 31.21,
              "lon": 121.5
            }
          }
        }
      ]
    }
  }
}

搜索结果处理

排序

elasticsearch支持对搜索结果排序,默认是根据相关度算分来排序。可以排序的字段类型有:keyword类型、数值类型、地理坐标类型、日期类型等。

# 排序  标准
GET /hotel/_search
{
  "query": {
    "match_all": {}
  },
  "sort": [
    {
      "score": {
        "order": "desc"
      },
      "price": {
        "order": "asc"
      }
    }
  ]
}
# 排序 地理坐标
GET /hotel/_search
{
  "query": {
    "match_all": {}
  },
  "sort": [
    {
      "_geo_distance": {
        "location": {
          "lat": 31.034661,
          "lon": 121.612282
        },
        "order": "asc",
        "unit": "km"
      }
    }
  ]
}

分页

elasticsearch默认情况下只返回top10的数据。而如果要查询更多数据就需要修改分页参数了。

elastic search中通过修改from、size参数来控制要返回的分页结果:

#分页
GET /hotel/_search
{
  "query": {
    "match_all": {}
  },
  "from": 0,
  "size": 5
}

深度分页问题

ES是分布式的,所以回面临深度分页问题。例如按照price排序后,获取from=990,size=10的数据:

  1. 首先在每个数据片上都排序并查询前1000条文档
  2. 然后将所有节点的结果聚合,在内存中重新排序选出前1000条文档
  3. 最后从这10000条中,选取从990开始的10条文档

如果搜索页数过深,或者结果集(from+size)越大,对内存和cpu的消耗越高。因此es色号顶结果集查询上线是10000

深度分页解决方案

针对深度分页,es提供了两种解决方案

  • search after:分页时需要排序,原理是从上一次的排序值开始,查询下一页数据。官方推荐
  • scroll:原理将排序数据形成快照,保存在内存。官方不推荐
高亮

高亮:就是在搜索结果中把搜索关键字突出显示

原理:

  • 将搜索结果中的关键字用标签标记出来
  • 在页面中给标签添加css样式
#高亮
GET /hotel/_search
{
  "query": {
    "match": {
      "all": "如家"
    }
  },
  "highlight": {
    "fields": {
      "name": {
        "require_field_match": "false"
      }
    }
  }
}

RestClient查询文档

快速入门

我们通过match_all来演示基本的api

RestAPI中其中构建DSL是通过HighLevelRestClient中的resource来实现的,其中包含了查询、排序、分页、高亮等所有功能。

RestAPI中其中构建条件的核心部分是由一个名为QueryBuilders的工具类提供的,其中包含了各种查询方法

    @Test
    void testMatchAll() throws IOException {
        //1、获取SearchRequest对象
        SearchRequest request=new SearchRequest("hotel");
        //2、设置参数
        request.source().query(QueryBuilders.matchAllQuery());
        //3、发请求
        SearchResponse response = client.search(request, RequestOptions.DEFAULT);
        //4、处理结果集
        SearchHits hits = response.getHits();
        TotalHits totalHits = hits.getTotalHits();
        System.out.println(totalHits);
        SearchHit[] searchHits = hits.getHits();
        //5.遍历
        for (SearchHit searchHit : searchHits) {
            String source = searchHit.getSourceAsString();
            HotelDoc hotelDoc = JSON.parseObject(source, HotelDoc.class);
            System.out.println(hotelDoc);
        }
    }
全文检索查询

match

    @Test
    void testMatch() throws IOException {
        //1、获取SearchRequest对象
        SearchRequest request=new SearchRequest("hotel");
        //2、设置参数
        request.source().query(QueryBuilders.matchQuery("name","如家"));
        //3、发请求
        SearchResponse response = client.search(request, RequestOptions.DEFAULT);
        handlerResponse(response);
    }

boolean: term、range

    @Test
    void testBool() throws IOException {
        //1、获取SearchRequest对象
        SearchRequest request=new SearchRequest("hotel");
        BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
        //2、设置dsl传语句
        boolQuery.must(QueryBuilders.termQuery("city","北京"));
        boolQuery.mustNot(QueryBuilders.rangeQuery("price").gt(500));
        //2、设置参数
        request.source().query(boolQuery);
        //3、发请求
        SearchResponse response = client.search(request, RequestOptions.DEFAULT);
        handlerResponse(response);
    }
排序和分页
    @Test
    void testPageAndSort() throws IOException {
        int page = 2,size = 5;
        //1、获取SearchRequest对象
        SearchRequest request=new SearchRequest("hotel");
        //2、设置参数
        request.source().query(QueryBuilders.matchAllQuery());
        request.source().from((page-1) * size).size(size);
        request.source().sort("price", SortOrder.ASC);
        //3、发请求
        SearchResponse response = client.search(request, RequestOptions.DEFAULT);
        handlerResponse(response);
    }
高亮
 @Test
    void testHighLight() throws IOException {
        //1、获取SearchRequest对象
        SearchRequest request=new SearchRequest("hotel");
        //2、设置参数
        request.source().query(QueryBuilders.matchQuery("name","北京"));
        request.source().highlighter(new HighlightBuilder().field("name").requireFieldMatch(false));
        //3、发请求
        SearchResponse response = client.search(request, RequestOptions.DEFAULT);
        handlerResponse(response);
    }

    private void handlerResponse(SearchResponse response) {
        //4、处理结果集
        SearchHits hits = response.getHits();
        TotalHits totalHits = hits.getTotalHits();
        System.out.println(totalHits);
        SearchHit[] searchHits = hits.getHits();
        //5.遍历
        for (SearchHit searchHit : searchHits) {
            String source = searchHit.getSourceAsString();
            HotelDoc hotelDoc = JSON.parseObject(source, HotelDoc.class);
            Map<String, HighlightField> map = searchHit.getHighlightFields();
            if (!CollectionUtils.isEmpty(map)){
                HighlightField highlightField = map.get("name");
                if (highlightField!= null){
                    String string = highlightField.getFragments()[0].string();
                    hotelDoc.setName(string);
                }
            }
            System.out.println(hotelDoc);
        }
    }

数据聚合

聚合的分类

聚合可以实现对文档数据的统计、分析、运算。聚合常见的有三大类:

  • 桶(Bucket)聚合:用来对文档做分组
    • TermAggregation:按照文档字段值分组
    • DateHistogram:按照容器阶梯分组,例如一周为一组,或一月为一组
  • 度量(Metric)聚合:用以计算一些值,比如:最大值、最小值、平均值等
    • AVG:求平均值
    • Max:求最大值
    • Min:求最小值
    • Stats:同时求max、min、avg、sum等
  • 管道(pipeline)聚合:其他聚合的结果为基础做聚合

聚合字段类型必须是:keyword、数值、日期、布尔。

DSL实现Bucket聚合

现在我们要统计所有数据中的酒店品牌有几种,此时可以根据酒店品牌的名称做聚合。类型为term


# 聚合查询
GET /hotel/_search
{
  "query": {
    "range": {
      "price": {
        "lte": 500
      }
    }
  },
  "size": 0, 
  "aggs": {
    "brandAgg": {
      "terms": {
        "field": "brand",
        "size": 20
      }
    }
  }
}

聚合结果排序

默认情况下,bucket聚合会统计Bucket内的文档数量,记为_count,并且按照 _count降序来排序,我们可以修改结果排序方式


# 聚合查询
GET /hotel/_search
{
  "query": {
    "range": {
      "price": {
        "lte": 500
      }
    }
  },
  "size": 0, 
  "aggs": {
    "brandAgg": {
      "terms": {
        "field": "brand",
        "size": 20,
        "order": {
          "_count": "desc"
        }
      }
    }
  }
}

默认情况下,Bucket聚合是对索引库的所有文档做聚合,我们可以限定要做聚合的文档范围,只要添加query条件即可

DSL实现Metrics聚合

例如我们要求获取每个品牌的用户评分的min、max、avg等值


# 数据聚合stats
GET /hotel/_search
{
  "size": 0, 
  "query": {
    "range": {
      "price": {
        "lte": 1000
      }
    }
  },
  "aggs": {
    "brandAgg": {
      "terms": {
        "field": "brand",
        "size": 10,
        "order": {
          "scoreAgg.avg": "desc"
        }
      },
      "aggs": {
        "scoreAgg": {
          "stats": {
            "field": "score"
          }
        }
      }
    }
  }
}


RestClient实现聚合
    @Test
    void testAggregation() throws IOException {
        //1、获取SearchRequest对象
        SearchRequest request = new SearchRequest("hotel");
        //2、设置参数
        request.source().size(0);
        request.source().aggregation(AggregationBuilders.terms("branAgg").field("brand").size(5));
        //3、发请求
        SearchResponse response = client.search(request, RequestOptions.DEFAULT);
        Aggregations aggregations = response.getAggregations();
        Terms brandTerms = aggregations.get("branAgg");
        List<? extends Terms.Bucket> buckets = brandTerms.getBuckets();
        for (Terms.Bucket bucket : buckets) {
            String key = bucket.getKeyAsString();
            System.out.println(key);
        }
    }

自动补全

拼音分词器

要实现根据字母做补全,就必须对文档按照拼音分词。在github上恰好有elasticsearch的拼音分词插件。

elasticsearch中分词器的组成包含三个部分:

  • character filters:在tokenizer之前对文本进行处理。例如删除字符、替换字符
  • tokenizer:将文本按照一定的规则切割成词条,例如keyword、就是不分词;还有ik_smart
  • tokenizer filter:将tokenizer输出的词条做进一步处理。例如大小写转换、同义词处理、拼音处理等
自定义分词器

我们可以在创建索引库时,通过settings来配置自定义的analyzer(分词器):

put /test
{
	"settings":{
		"analysis":{
			"analyzer":{#自定义分词器
				"my_analyzer":{ #自定义的分词器名
					"tokenizer":"ik_max_word",
					"filter":"pinyin"
				}
			}
		}
	}
}
// 自定义拼音分词器
PUT /test
{
  "settings": {
    "analysis": {
      "analyzer": { 
        "my_analyzer": { 
          "tokenizer": "ik_max_word",
          "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
        }
      }
    }
  }
}

自定义分词器适合在创建倒排索引的时候使用,但不能再搜索的时候使用。

因此字段在创建倒排索引时应该用my_analyzer分词器;在搜索时应使用ik_smart分词器


// 自定义拼音分词器
PUT /test
{
  "settings": {
    "analysis": {
      "analyzer": { 
        "my_analyzer": { 
          "tokenizer": "ik_max_word",
          "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
        }
      },
      "mappngs":{
      	"properties":{
      		"name":{
      			"type":"text",
      			"analyzer":"my_analyzer",
      			"search_analyzer":"ik_smart"
      		}
      	}
      }
    }
  }
}
completion suggester查询

elasticsearch提供了Completion suggester查询来实现自动补全功能。这个查询会匹配以用户输入内容开头的词条并返回。为了提高补全查询效率,对于文档中字段的类型有一些约束:

  • 参与补全查询的字段必须是completion类型

    put test
    {
    	"mappings":{
    		"properties":{
    			"title":{
    				"type":"completion"
    			}
    		}
    	}
    }
    
    
  • 字段的内容一般是用来补全的多个词条形成的数组

查询语法如下:

GET /test/_search
{
	"suggest":{
		"title_seggest":{
			"text":"s", #关键字
			"completion":{
				"field":"title", #补全查询的字段
				"skip_duplicates":"true", #跳过重复的
				"size":10 #获取前十条数据
			}
		}
	}
}
RestApi实现自动补全
    @Test
    void testSuggest() throws IOException {
        //1、获取SearchRequest对象
        SearchRequest request = new SearchRequest("hotel");
        //2、设置参数
        request.source().suggest(
                new SuggestBuilder().addSuggestion(
                        "my_suggest",
                        SuggestBuilders.completionSuggestion("suggestion")
                                .prefix("s")
                                .skipDuplicates(true)
                                .size(10)
                )
        );
        //3、发请求
        SearchResponse response = client.search(request, RequestOptions.DEFAULT);
        Suggest suggest = response.getSuggest();
        CompletionSuggestion my_suggest = suggest.getSuggestion("my_suggest");
        List<CompletionSuggestion.Entry.Option> options = my_suggest.getOptions();
        for (CompletionSuggestion.Entry.Option option : options) {
            String text = option.getText().string();
            System.out.println(text);
        }
    }


数据同步(重要)

Elasticsearch中的酒店数据来自于mysql数据库,因此,mysql数据库发生改变时,elasticsearch也必须跟着改变,这个就时elasticsearch与mysql之间的数据同步。

同步调用

当酒店管理服务操作mysql数据库时,调用酒店搜索服务暴露的接口来修改Es

异步调用

使用mq中间件来实现,当酒店管理服务操作mysql数据库时,利用MQ通知酒店搜索服务,让酒店搜索服务更新ES

监听binlog

使用mysql的binlog来实现,当酒店管理服务操作mysql数据库时,利用canal来监听mysql的binlog,当binlog改变就通知酒店搜索服务数据变更,酒店搜索服务来更新ES

总结:

  1. 同步调用
    • 优点:实现简单,粗暴
    • 缺点:业务耦合度高
  2. 异步通知
    • 优点:低耦合,实现难度一般
    • 缺点:依赖mq的高可靠性
  3. 监听binlog
    • 优点:完全解除服务间耦合
    • 缺点:开启binlog增加数据库负担、实现复杂度高
案例

利用MQ实现mysql与es数据同步

步骤:

  • 导入课前资料提供的hotel-admin项目,启动并测试酒店数据的CRUD
  • 声明exchange、queue、RoutingKey
  • 在hote-admin中的增、删、改业务中完成消息发送
  • 在hotel-demo中完成消息监听,并更新elasticsearch中数据
  • 启动并测试数据同步功能
步骤二

声明exchange、queue、RoutingKey

@Configuration
public class MQConfig {
    /**
     * 交换机
     * @return
     */
    @Bean
    public TopicExchange topicExchange(){
        return new TopicExchange(HotelMqConstants.EXCHANGE_NAME,true,false);
    }

    /**
     * 插入和修改队列
     * @return
     */
    @Bean
    public Queue insertQueue(){
        return new Queue(HotelMqConstants.INSERT_QUEUE_NAME,true);
    }

    /**
     * 插入和修改队列
     * @return
     */
    @Bean
    public Queue deleteQueue(){
        return new Queue(HotelMqConstants.DELETE_QUEUE_NAME,true);
    }

    /**
     * 插入与修改绑定关系
     * @return
     */
    @Bean
    public Binding insertBinding(){
        return BindingBuilder.bind(insertQueue()).to(topicExchange()).with(HotelMqConstants.INSERT_KEY);
    }

    /**
     * 删除绑定关系
     * @return
     */
    @Bean
    public Binding deleteBinding(){
        return BindingBuilder.bind(deleteQueue()).to(topicExchange()).with(HotelMqConstants.DELETE_KEY);
    }
}

步骤三
    @PostMapping
    public void saveHotel(@RequestBody Hotel hotel){
        // 新增酒店
        hotelService.save(hotel);
        // 发送MQ消息
        rabbitTemplate.convertAndSend(HotelMqConstants.EXCHANGE_NAME, HotelMqConstants.INSERT_KEY, hotel.getId());
    }
步骤四

    /**
     * 监听插入和修改
     * @param id
     */
    @RabbitListener(queues = HotelMqConstants.INSERT_QUEUE_NAME)
    public void listenHotelInsertOrUpdate(Long id){
        iHotelService.insertById(id);
    }

    /**
     * 监听删除
     * @param id
     */
    @RabbitListener(queues = HotelMqConstants.DELETE_QUEUE_NAME)
    public void listenHotelDelete(Long id){
        iHotelService.deleteById(id);
    }

ES集群

单机的es做数据存储,必然面临两个问题:海量数据存储问题、单点故障问题。

  • 海量数据存储问题:将索引库从逻辑上拆分为N个分片,存储到多个节点
  • 单点故障问题:将分片数据在不同节点做备份
搭建集群

docker-compose

version: '2.2'
services:
  es01:
    image: elasticsearch:7.12.1
    container_name: es01 #容器名
    environment:
      - node.name=es01
      - cluster.name=es-docker-cluster
      - discovery.seed_hosts=es02,es03  #另外两个ip  docker内可以用名
      - cluster.initial_master_nodes=es01,es02,es03 #主节点选举 
      - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
    volumes:
      - data01:/usr/share/elasticsearch/data
    ports:
      - 9200:9200
    networks:
      - elastic
  es02:
    image: elasticsearch:7.12.1
    container_name: es02
    environment:
      - node.name=es02
      - cluster.name=es-docker-cluster
      - discovery.seed_hosts=es01,es03
      - cluster.initial_master_nodes=es01,es02,es03
      - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
    volumes:
      - data02:/usr/share/elasticsearch/data
    ports:
      - 9201:9200
    networks:
      - elastic
  es03:
    image: elasticsearch:7.12.1
    container_name: es03
    environment:
      - node.name=es03
      - cluster.name=es-docker-cluster
      - discovery.seed_hosts=es01,es02
      - cluster.initial_master_nodes=es01,es02,es03
      - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
    volumes:
      - data03:/usr/share/elasticsearch/data
    networks:
      - elastic
    ports:
      - 9202:9200
volumes:
  data01:
    driver: local
  data02:
    driver: local
  data03:
    driver: local

networks:
  elastic:
    driver: bridge

es运行还需要修改一些linux的系统权限,修改/etc/sysctl.conf文件

vi /etc/sysctl.conf
#添加下面内容
vm.max_map_count=262144
#然后执行命令,让配置生效
sysctl -p
集群状态监控

kibana监控不好用,用cerebro来监控集群状态,解压后直接用

创建索引库

  1. 利用kibana的devtools创建索引库

    put /rose
    {
    	"settings":{
    		"number_of_shards":3, #分片数量
    		"number_of_replicas":1  #副本数量
    	},
    	"mappings":{
    	
    	}
    }
    
ES集群职责和脑裂
节点类型配置参数默认值节点职责
master eligiblenode.mastertrue备选主节点:主节点可以管理和记录集群状态、决定分片在那个节点、处理创建和删除索引库的请求
datanode.datatrue数据节点:存储数据、搜索、聚合、CRUD
ingestnode.ingesttrue数据存储之前的预处理
coordingting上面三个都为false,则为coordingnating节点路由请求到其他节点,合并其他节点处理的结果,返回给用户

es中的每个节点角色都有自己不同的职责,因此建议集群部署时,每个节点都有独立的角色。

脑裂

默认情况下,每个节点都是master eligible节点,因此一旦master节点宕机,其他候选节点会选举一个称为新的主节点。当主节点与其他节点网络故障时,可能发生脑裂问题。

为了避免脑裂问题,需要要求选票超过(eligible节点数量+1)/2才能当选为主节点,因此eligible节点数量最好是奇数。对应配置项时discover.zen.minimum_master_nodes,在es7之后,已经成为默认配置,因此一般不会发生脑裂问题。

ES集群的分布式存储

当心怎文档时,应该保存到不同分片,保证数据均衡,那么coordingnating node如何确定数据改存储到那个分片呢?

Es会通过hash算法来计算文档应该存储到那个分片:

​ shard = hash(_routing) % number_of_shards

说明:

  • _routing默认是文档的id
  • number_of_shards分片数量

算法与分片数量有关,因此索引库一旦创建,分片数量不能修改

ES的查询分成两个阶段:

  • scatter phase:分散阶段,coordinating node会把请求分到每一个分片
  • gather phase:聚集阶段,coordinating node汇总data node的搜索结果,并处理为最终结果集返回给用户
故障转移

集群的master节点回监控集群中节点状态,如果发现有节点宕机,回立刻将宕机节点的分片数据迁移到其他节点,确保数据安全,这个叫做故障转移

Sentinel

雪崩问题

微服务调用链路中的某个服务故障,引起整个链路中的所有微服务都不可用,这就是雪崩。

解决雪崩问题的常见方式有四种:

  • 超时处理:设定超时时间,请求超时一定时间没有响应就返回错误消息,不会无休止等待。
  • 舱壁模式:限定每个业务能使用的线程数,避免耗尽整个tomcat的资源,因此也叫线程隔离。
  • 熔断降级:由断路器统计业务执行的异常比例,如果超出阈值则会熔断该业务,来接访问该业务的一切请求。
  • 流量控制:限制业务访问的QPS,避免服务因流量的徒增而故障
;