Java微服务

Java微服务笔记(基础篇完结,高级篇持续更新中)

🌟该文档用于学习Java微服务全套技术栈,参考2021黑马程序员微服务技术栈课程

古人云:面试造火箭,工作拧螺丝

呦西,就这样顺理成章随随便便把java微服务给学完吧~

微服务导学

什么是微服务

微服务 != SpringCloud

微服务本质是拆分,根据业务功能将单体项目拆分成许多个独立的项目独立部署开发,称作一个服务

微服务技术栈 = 微服务架构 + 持续集成

image-20240122221320668

微服务架构

包括拆分好的服务集群,用于统一注册服务和统一配置的注册中心配置中心,用于处理用户请求分发到不同服务与负载均衡的服务网关,用于异步通信提高服务之间通信效率的消息队列,用于提高查询效率减轻数据库负担的分布式缓存分布式搜索

image-20240122221625714

持续集成

用于自动化快速部署微服务

学习计划

image-20240122222934173

其实也没多少东西,怀着轻松愉悦的心情一口气全都学完吧(笑)

image-20240122222437644

Day01 2024.01.22 微服务入门与注册中心

认识微服务

架构演变

单体架构

将业务所有功能集中在一个项目中开发,打包成一个包直接部署

优点:架构简单,部署成本低

缺点:耦合度高

image-20240122224156512

分布式架构

根据业务功能对系统进行拆分,每个业务模块作为独立项目开发

优点:降低服务耦合,有利于服务升级拓展

需要考虑的问题:

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

image-20240122224928430

微服务

是一种经过良好架构设计的分布式架构方案

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

image-20240122225108001

结论

image-20240122225312284

微服务技术方案

微服务方案需要技术框架来落地,国内最知名的是SpringCloud和阿里巴巴的Dubbo

微服务技术对比

Dubbo SpringCloud SrpingCloudAlibaba
注册中心 zookeeper/Redis Eureka/Consul Nacos/Eureka
服务远程调用 Dubbo协议 Feign(http协议) Dubbo/Feign
配置中心 SpringCloudConfig SpringCloudConfig/Nacos
服务网关 SpringCloudGateway/Zuul SpringCloudGateway/Zuul
服务监控和保护 dubbo-admin,功能弱 Hystrix Sentinel

image-20240123150729554

SpringCloud

image-20240123151122525

SpringBoot实现组件的自动装配,因此成为最受欢迎的微服务框架

服务拆分及远程调用

服务拆分注意事项

  1. 不同微服务,不要重复开发相同业务
  2. 微服务数据独立,不要访问其他微服务的数据库
  3. 微服务可以将自己的业务暴露为接口,供其他微服务使用

image-20240123153244871

项目案例

导入demo项目初始工程

项目工程和建表sql在教程资料中获取(注意工程代码在资料文件中的才是初始工程,外面的是已经完成的代码)

分别创建cloud_user和cloud_order两个数据库,里面分别存放使用sql脚本创建好的tb_user和tb_order

image-20240123182901095

记得在application.yml中修改数据库账号密码

调取接口测试成功

image-20240123182637088

服务远程调用

image-20240124111448066

因为微服务不允许开发重复业务,订单模块需要远程调用获取用户信息

  1. 注册RestTemplate(在order启动类里面)

    //OrderApplication    
    /**
         * 注入RestTemplate
         * @return
         */
        @Bean
        public RestTemplate restTemplate() {
            return new RestTemplate();
        }
    
  2. 在orderController中利用RestTemplate发起http请求查询并封装

       @Autowired
       private RestTemplate restTemplate;
    
        @GetMapping("{orderId}")
        public Order queryOrderByUserId(@PathVariable("orderId") Long orderId) {
            // 根据id查询订单
            Order order = orderService.queryOrderById(orderId);
            // 利用RestTemplate发起http请求,查询用户
            order.setUser(restTemplate.getForObject("http://localhost:8081/user/" + order.getUserId(), User.class));
            return order;
        }
    

服务远程调用相关概念

提供者:提供接口供其他微服务使用

消费者:调用其他微服务接口的服务

抛开业务不谈,一个服务既可以是消费者,也可以是提供者,只有相对某个服务而言才有消费提供的概念

eureka注册中心

Eureka原理

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

image-20240124115931506

搭建EurekaServer

  1. 新建模块并引入依赖(虽然别人说选择Spring Initializr更方便一些,不过这里就和老师一样来了)

    image-20240124121409076

    pom文件导入依赖坐标(父工程starter已经指定了版本信息,不必再次指定)

      <dependencies>
        <dependency>
          <groupId>org.springframework.cloud</groupId>
          <artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
        </dependency>
        <dependency>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-autoconfigure</artifactId>
        </dependency>
      </dependencies>
    

    这里笔者发现maven加载依赖十分迟缓,连vpn都没有用,因此更换了maven镜像,选择国内的阿里云镜像站,效果十分显著,更改教程

  2. 编写启动类并添加@EnableEurekaServer注解

    @EnableEurekaServer
    @SpringBootApplication
    public class EurekaApplication{
        public static void main(String[] args) {
            SpringApplication.run(EurekaApplication.class, args);
        }
    }
    
  3. 编写配置文件并在其中编写如下配置

    server:
      port: 10086
    spring:
      application:
        name: eureka-server
    eureka:
      client:
        service-url:
          defaultZone: http://localhost:10086/eureka/
    

    之所以要配置url是因为eureka自己本身也是微服务,需要完成自身注册,而且之后可能会部署多个eureka互相通信

  4. 功能测试

    可以通过底部工具栏里的服务选项卡查看spring项目相关的所有启动类(如果没有,点击左上角+号添加SpringBoot类型服务,然后把相关启动类都开启一遍,就会在这里显示了,可以多选创建一个分组)

    利用快捷键ctrl+shift+F10可以实现一键启动所有微服务

    参考教程

    image-20240124131154562

    😔 如果服务一直转圈,并且右边并没有显示可供跳转的端口,我目前也没有什么好的解决办法,不过不影响功能

    跳转到eureka服务端口页面,了可以看到eureka-server自身已经注册了

    image-20240124133013456

服务注册

注册user-service

  1. 在user-service引入eureka-client依赖

    <!--        eureka-client-->
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
            </dependency>
    
  2. 在配置文件编写如下配置

    spring:
        application:
          name: user-service # 指定注册的服务名字
    eureka:
        client:
          service-url:
            defaultZone: http://localhost:10086/eureka/ #eureka地址信息
    

order-service同理,这里就不赘述了

可以看到成功注册

image-20240124153849911

如果想要启动同一个服务的多个实例,可以复制服务配置(运行在不同的端口)

image-20240124170509384

调出VM选项来修改端口避免冲突

image-20240124170826459

启动后可以发现注册了两个实例

image-20240124170930076

服务发现

**在order-service完成服务拉取 **

  1. 修改原来controller的代码,将ip地址用服务名称代替

        @GetMapping("{orderId}")
        public Order queryOrderByUserId(@PathVariable("orderId") Long orderId) {
            // 根据id查询订单
            Order order = orderService.queryOrderById(orderId);
             // 利用RestTemplate发起http请求,查询用户
            order.setUser(restTemplate.getForObject("http://user-service/user/" + order.getUserId(), User.class));
            return order;
        }
    
  2. 在RestTemplate上面添加@LoadBalanced注解进行负载均衡设置

        /**
         * 注入RestTemplate
         * @return
         */
        @Bean
        @LoadBalanced
        public RestTemplate restTemplate() {
            return new RestTemplate();
        }
    
  3. 功能测试

    要保证服务选项中数据库保持开启状态才能正常查询(跳转到对应mapper上对标红的字段alt+enter选择合适的数据库就能够加载出来了)

    image-20240124171843849

    笔者在测试的时候发现数据库一直报连接超时,之后一通操作后突然发现mysql登不进去了,于是免密码登入再修改密码等等一系列操作,参考这个教程mysql8密码重置

    笔者用的是mysql8.0.34,不支持通过修改my.ini来免密登入,只能通过cmd通过命令设置选项开启服务

    😔笔者查了好长时间资料也没有想明白为什么调用接口的时候会连接超时,所以为了不浪费过多时间,这个问题先暂时搁置在一边

总结

image-20240125140012051

Ribbon负载均衡原理

eureka服务发现时候的负载均衡由ribbon来进行实现

负载均衡原理

image-20240125140300250

源码细节

启动的时候会通过spring的负载均衡拦截器

image-20240125140613743

实现的ClientHttpRequestInterceptor接口的intercept方法会拦截http请求

image-20240125201406111

其中loadBalancer即RibbonLoadBalancer的一个实例,执行execute方法,根据服务名称向eureka拉取服务列表(即图中allServerList)

getServer方法通过负载均衡策略获取其中的一个服务

image-20240125202742860

getServer会跳转到chooseServer方法,其中的rule.choose(key)便指定了选择的负载均衡策略

image-20240125203540429

rule实现的接口IRule就包含了七种负载均衡策略,包含轮询,随机等等

默认规则是ZoneAvoidanceRule

image-20240125203631114

总结

image-20240125204151889

负载均衡策略

image-20240125214942310

image-20240125215047101

调整负载均衡策略

方案1(代码方式,针对一个service中的所有服务调用)

在启动类定义一个实现Irule接口的新的策略Bean即可

//OrderApplication   
/**
     * 注入随机负载均衡策略
     * @return
     */
    @Bean
    public IRule randomRule() {
        return new RandomRule();
    }

方案2(配置文件方式,针对某个微服务单独配置)

在配置文件指定仅在调用user-service微服务的时候使用随机策略

user-service:
  ribbon:
    NFLoadBalancerRuleClassName: com.netflix.loadbalancer.RandomRule

加载策略

懒加载:只有在第一次使用的时候才会加载资源

饥饿加载:一开始启动项目的时候就会加载资源

ribbon默认使用懒加载

类似java基础里面讲到的饿汉式,懒汉式加载

切换加载策略

在消费者配置文件中进行如下配置,切换成饥饿加载

ribbon:
  eager-load:
    enabled: true #饥饿加载
    clients: #指定调用什么服务的时候采用该种加载策略
      - user-service 

其中clients指定应用这一策略的提供者集合(- item是yaml文件中集合的写法)

nacos注册中心

Nacos是阿里巴巴的一个产品,现在已加入SpirngCloud全家桶,功能更加丰富,除了服务注册和发现,还有分布式配置,中国国内比较受欢迎

Nacos入门

认识和安装Nacos

解压老师资料提供的1.4.1版本nacos,进入bin目录打开cmd窗口,输入

startup.cmd -m standalone

启动,默认在8848端口开启(如果直接双击cmd启动可能会报错)

image-20240125230349938

跳转显示的console地址,进入登录界面(账号密码默认都是nacos)

image-20240125230747549

快速入门
  1. 导入nacos依赖,并注释掉原来的eureka依赖

    cloud-demo模块管理依赖配置(不要放错位置了)

    <!-- pom.xml(cloud-demo)
    <!--            nacos管理依赖-->
                <dependency>
                    <groupId>com.alibaba.cloud</groupId>
                    <artifactId>spring-cloud-alibaba-dependencies</artifactId>
                    <version>2.2.5.RELEASE</version>
                    <type>pom</type>
                    <scope>import</scope>
                </dependency>
    

    在user-service和order-service引入nacos客户端依赖,并将eureka依赖注释掉

    <!--        eureka-client-->
    <!--        <dependency>-->
    <!--            <groupId>org.springframework.cloud</groupId>-->
    <!--            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>-->
    <!--        </dependency>-->
    
    <!--        nacos-client-->
            <dependency>
                <groupId>com.alibaba.cloud</groupId>
                <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
            </dependency>
    
  2. 修改相关配置(注释掉eureka配置)

    #eureka:
    #  client:
    #    service-url:
    #      defaultZone: http://localhost:10086/eureka
    
    spring:
      cloud:
        nacos:
            server-addr: localhost:8848 #nacos服务地址
    

    user-service和order-service操作相同

  3. 功能测试

    启动order-service和user-service之后,在nacos发现成功注册

    image-20240126004237681

    输入localhost:9090/order/101地址测试成功返回son数据

    image-20240126004412519

    果然是eureka本身有问题啊,这次就成功远程调用了,枉费我连续两天调试源码找bug!

Nacos服务分级存储模型

一个服务分成多个服务集群,提高容灾性

image-20240126222927381

服务集群调用问题

服务调用尽可能选择本地集群的服务,跨集群调用延迟较高,只有当本地集群不可用的时候才会跨集群调用

配置服务集群
  1. 修改application.yml.添加如下内容即可(user-service和order-service均配置一个集群名称)

    spring:
    	cloud:
            nacos:
                discovery:
                    cluster-name: HZ # 指定集群在杭州
    
  2. 修改负载均衡设置,保证优先选择同一个地域(集群)的服务

    # user-service配置文件
    user-service:
      ribbon:
        NFLoadBalancerRuleClassName: com.alibaba.cloud.nacos.ribbon.NacosRule
    
  3. 功能测试

    复制一共三个user-service,前两个设置集群名为HZ(杭州),然后再修改user-service的集群名称为SH(上海),启动第三个user-service

    记得先启动一下nacos
    其实idea可以配置nacos启动服务,不必通过资源管理器找到文件打开,此处教程

    按照笔者这样添加运行配置即可

    image-20240126231042101

    idea终端一定要设置成cmd而不是powershell,切换终端路径需要如下配置

    image-20240126231334791

    运行之后服务成功注册,并且user-service分成了两个不同集群

    image-20240126231647421

    image-20240126231709712

    调用http://localhost:8080/order/101 请求接口多次之后,可以看到确实第三个user-service没有处理请求
    如果把前两个user-service停掉,那么就跨集群访问第三个集群,并产生警告信息

    image-20240126232311635

服务实例权重配置

现实生活中,服务器设备性能往往有差异,一部分服务器性能比较好,应该承担更多用户请求

通过修改权重来控制访问频率

通过nacos界面进行修改即可

image-20240128205051569

如果将权重调成0,就不会有服务来访问,这个操作往往用来对服务器进行维护,同时不影响用户的使用
如果想对维护后的服务器进行测试,可以调很小的一个权重,让少量用户访问,平稳升级过渡,被称作灰度发布

Nacos环境隔离

nacos采用namespace基于同一个实例不同环境进行数据隔离

image-20240128205914640

在nacos操作页面即可新建命名空间,这里新建dev开发环境

image-20240128210142195

命名空间会生成对应id(如果没有指定的话)

image-20240128210301105

将id配置在代码中

# order service配置文件
spring:  
  cloud:
    nacos:
      discovery:
        namespace: e0445b23-956f-4b98-90a8-bdf65afb07ce

如果配置好之后再启动,order-service将无法调用user-service,因为两个服务不在一个命名空间里面

nacos和eureka对比

image-20240128211559580

nacos和eureka都会使用如上流程进行注册,且将服务列表进行缓存以供消费者查询

差别在于健康监测上面,nacos会区分临时实例非临时实例,对于临时实例,nacos采用心跳检测,如果心跳异常会直接从服务列表中移除,这一点和eureka是一样的

但是对于非临时实例nacos会主动询问(比心跳检测更加敏感,但性能消耗更多),如果不健康,会将其标记,不会从列表移除,只会等待其恢复正常

在心跳检测机制上,nacos相对于eureka,除了被动接受心跳信号以外,如果发现有服务挂掉的话会主动向消费者进行消息推送,让更新更加及时

配置非临时实例
# order service配置文件
spring:  
  cloud:
    nacos:
      discovery:
      	ephemeral: false
总结

image-20240128212516968

关于集群采用AP或CP方式会在后续章节学习

Day02 2024.01.28 配置管理,远程调用和服务网关

Nacos配置管理

统一配置管理

需求

  • 配置更改实现热更新,避免不必要的重启

image-20240128224410155

新建配置

在public命名环境下面配置统一配置

在nacos的配置管理选项卡中选择添加配置进入页面

data id即配置名称,一般用服务名+命名空间的方式命名(用yaml格式的话推荐拓展名写全(.yaml))

配置内容应填写那些需要经常改变的开关类配置,例如日期格式切换,启用某个服务的开关等等

image-20240128224936587

配置内容

pattern:
    dateformat: yyyy-MM-dd HH:mm:ss
配置拉取

如果nacos统一配置需要较读取本地配置文件提前读取,不能还像原来一样将从配置文件获取nacos地址,因此可以选择在bootstrap这个启动优先级更高的地方进行配置image-20240128225523390

  1. 在user-service引入nacos配置管理依赖

    <!--        nacos配置管理-->
            <dependency>
                <groupId>com.alibaba.cloud</groupId>
                <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
            </dependency>
    
  2. 在user-service中的resource目录添加一个bootstrap.yml文件,该文件是引导文件,优先级高于application.yml

    spring:
      application:
        name: user-service # 服务名称
      profiles:
        active: dev # 配置作用环境,这里是dev
      cloud:
        nacos:
          server-addr: localhost:8848 # nacos服务地址
          config:
            file-extension: yaml # 指定yaml格式
    

    将application.yml中重复的配置去掉

    #      server-addr: localhost:8848 #nacos服务地址
    #  application:
    #    name: user-service
    
  3. 在user-service获取添加的配置并编写功能接口测试

    //user-service Controller
    @Value("${pattern.dateformat}")
        private String dateformat;
    
        /**
         * 根据指定的日期格式返回当前日期
         * @return
         */
        @GetMapping("/now")
        public String now() {
            return LocalDateTime.now().format(DateTimeFormatter.ofPattern(dateformat));
        }
    

    功能测试

    啊?为什么之前一直没解决的问题突然电脑自己就给解决了,离谱...(之前服务启动之后一直没能成功暴露端口,并且一直图标一直转圈)

    image-20240129210140417

    调用对应接口后成功返回指定格式的当前日期

    image-20240129212454926

总结

image-20240129212737669

配置热更新

实现配置热更新

要想实现配置的热更新,还需要下面两种方式实现

  1. 在@value注入变量的类上面添加@RefreshScope注解

    @RefreshScope
    public class UserController{
        ...
    }
    

    确实实现了热更新呢

    image-20240129213828770

  2. 使用@ConfigurationProperties注解

    创建一个配置类用于获取配置

    //config.PatternProperties
    @Data
    @Component
    @ConfigurationProperties(prefix = "pattern")
    public class PatternProperties {
        private String dateformat;
    }
    

    注入配置类来获取对应配置,不必添加上述@RefreshScope注解

        @Autowired
        private PatternProperties patternProperties;
        /**
         * 根据指定的日期格式返回当前日期
         * @return
         */
        @GetMapping("/now")
        public String now() {
            return LocalDateTime.now().format(DateTimeFormatter.ofPattern(patternProperties.getDateformat()));
        }
    

因为第二种方法将配置进行了封装,而且不必添加@RefreshScope注解,更加优雅方便,因此推荐使用第二种方法

总结

image-20240129225524763

配置共享

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

  • 特定环境的yaml配置,如user-service.yaml
  • 没有环境限制的配置,如user-servie.yaml

因此只需要在nacos创建没有环境限制的配置即可

环境共享配置

命名上去掉命名空间,只保留服务名即为多环境共享配置

image-20240129230152844

功能测试

修改原来的配置类读取新增属性

@Data
@Component
@ConfigurationProperties(prefix = "pattern")
public class PatternProperties {
    private String dateformat;
    private String envSharedValue;
}

添加新的功能接口用来读取获得的属性

    /**
     * 测试获取到的配置信息
     * @return
     */
    @GetMapping("/prop")
    public PatternProperties prop() {
        return patternProperties;
    }

右键服务编辑运行配置,在有效配置文件(active profile)那里填写命名空间(这里是将8081端口user服务环境设置成'test')即可更改运行的环境而不用修改本身代码

image-20240129230639687

可以看到test环境的user-service(端口8081)只能读取到envSharedValue

image-20240129231927263

而其他两个dev环境的可以将本环境的和环境共享配置全部读取

image-20240129231941518

配置优先级

nacos当前指定环境配置 > 多环境共享配置 > 本地配置

image-20240129232431220

搭建Nacos集群

image-20240131212151734

配置多个nacos服务器,在同一个mysql服务集群读取数据实现数据共享

搭建集群操作
  • 搭建数据库,初始化数据库表结构

    创建名为nacos的数据库,然后在其中导入下面的建表sql语句(可以在资料对应文档中找到)

    CREATE TABLE `config_info` (
      `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'id',
      `data_id` varchar(255) NOT NULL COMMENT 'data_id',
      `group_id` varchar(255) DEFAULT NULL,
      `content` longtext NOT NULL COMMENT 'content',
      `md5` varchar(32) DEFAULT NULL COMMENT 'md5',
      `gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
      `gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间',
      `src_user` text COMMENT 'source user',
      `src_ip` varchar(50) DEFAULT NULL COMMENT 'source ip',
      `app_name` varchar(128) DEFAULT NULL,
      `tenant_id` varchar(128) DEFAULT '' COMMENT '租户字段',
      `c_desc` varchar(256) DEFAULT NULL,
      `c_use` varchar(64) DEFAULT NULL,
      `effect` varchar(64) DEFAULT NULL,
      `type` varchar(64) DEFAULT NULL,
      `c_schema` text,
      PRIMARY KEY (`id`),
      UNIQUE KEY `uk_configinfo_datagrouptenant` (`data_id`,`group_id`,`tenant_id`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='config_info';
    
    /******************************************/
    /*   数据库全名 = nacos_config   */
    /*   表名称 = config_info_aggr   */
    /******************************************/
    CREATE TABLE `config_info_aggr` (
      `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'id',
      `data_id` varchar(255) NOT NULL COMMENT 'data_id',
      `group_id` varchar(255) NOT NULL COMMENT 'group_id',
      `datum_id` varchar(255) NOT NULL COMMENT 'datum_id',
      `content` longtext NOT NULL COMMENT '内容',
      `gmt_modified` datetime NOT NULL COMMENT '修改时间',
      `app_name` varchar(128) DEFAULT NULL,
      `tenant_id` varchar(128) DEFAULT '' COMMENT '租户字段',
      PRIMARY KEY (`id`),
      UNIQUE KEY `uk_configinfoaggr_datagrouptenantdatum` (`data_id`,`group_id`,`tenant_id`,`datum_id`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='增加租户字段';
    
    
    /******************************************/
    /*   数据库全名 = nacos_config   */
    /*   表名称 = config_info_beta   */
    /******************************************/
    CREATE TABLE `config_info_beta` (
      `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'id',
      `data_id` varchar(255) NOT NULL COMMENT 'data_id',
      `group_id` varchar(128) NOT NULL COMMENT 'group_id',
      `app_name` varchar(128) DEFAULT NULL COMMENT 'app_name',
      `content` longtext NOT NULL COMMENT 'content',
      `beta_ips` varchar(1024) DEFAULT NULL COMMENT 'betaIps',
      `md5` varchar(32) DEFAULT NULL COMMENT 'md5',
      `gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
      `gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间',
      `src_user` text COMMENT 'source user',
      `src_ip` varchar(50) DEFAULT NULL COMMENT 'source ip',
      `tenant_id` varchar(128) DEFAULT '' COMMENT '租户字段',
      PRIMARY KEY (`id`),
      UNIQUE KEY `uk_configinfobeta_datagrouptenant` (`data_id`,`group_id`,`tenant_id`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='config_info_beta';
    
    /******************************************/
    /*   数据库全名 = nacos_config   */
    /*   表名称 = config_info_tag   */
    /******************************************/
    CREATE TABLE `config_info_tag` (
      `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'id',
      `data_id` varchar(255) NOT NULL COMMENT 'data_id',
      `group_id` varchar(128) NOT NULL COMMENT 'group_id',
      `tenant_id` varchar(128) DEFAULT '' COMMENT 'tenant_id',
      `tag_id` varchar(128) NOT NULL COMMENT 'tag_id',
      `app_name` varchar(128) DEFAULT NULL COMMENT 'app_name',
      `content` longtext NOT NULL COMMENT 'content',
      `md5` varchar(32) DEFAULT NULL COMMENT 'md5',
      `gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
      `gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间',
      `src_user` text COMMENT 'source user',
      `src_ip` varchar(50) DEFAULT NULL COMMENT 'source ip',
      PRIMARY KEY (`id`),
      UNIQUE KEY `uk_configinfotag_datagrouptenanttag` (`data_id`,`group_id`,`tenant_id`,`tag_id`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='config_info_tag';
    
    /******************************************/
    /*   数据库全名 = nacos_config   */
    /*   表名称 = config_tags_relation   */
    /******************************************/
    CREATE TABLE `config_tags_relation` (
      `id` bigint(20) NOT NULL COMMENT 'id',
      `tag_name` varchar(128) NOT NULL COMMENT 'tag_name',
      `tag_type` varchar(64) DEFAULT NULL COMMENT 'tag_type',
      `data_id` varchar(255) NOT NULL COMMENT 'data_id',
      `group_id` varchar(128) NOT NULL COMMENT 'group_id',
      `tenant_id` varchar(128) DEFAULT '' COMMENT 'tenant_id',
      `nid` bigint(20) NOT NULL AUTO_INCREMENT,
      PRIMARY KEY (`nid`),
      UNIQUE KEY `uk_configtagrelation_configidtag` (`id`,`tag_name`,`tag_type`),
      KEY `idx_tenant_id` (`tenant_id`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='config_tag_relation';
    
    /******************************************/
    /*   数据库全名 = nacos_config   */
    /*   表名称 = group_capacity   */
    /******************************************/
    CREATE TABLE `group_capacity` (
      `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
      `group_id` varchar(128) NOT NULL DEFAULT '' COMMENT 'Group ID,空字符表示整个集群',
      `quota` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '配额,0表示使用默认值',
      `usage` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '使用量',
      `max_size` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '单个配置大小上限,单位为字节,0表示使用默认值',
      `max_aggr_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '聚合子配置最大个数,,0表示使用默认值',
      `max_aggr_size` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '单个聚合数据的子配置大小上限,单位为字节,0表示使用默认值',
      `max_history_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '最大变更历史数量',
      `gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
      `gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间',
      PRIMARY KEY (`id`),
      UNIQUE KEY `uk_group_id` (`group_id`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='集群、各Group容量信息表';
    
    /******************************************/
    /*   数据库全名 = nacos_config   */
    /*   表名称 = his_config_info   */
    /******************************************/
    CREATE TABLE `his_config_info` (
      `id` bigint(64) unsigned NOT NULL,
      `nid` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
      `data_id` varchar(255) NOT NULL,
      `group_id` varchar(128) NOT NULL,
      `app_name` varchar(128) DEFAULT NULL COMMENT 'app_name',
      `content` longtext NOT NULL,
      `md5` varchar(32) DEFAULT NULL,
      `gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
      `gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
      `src_user` text,
      `src_ip` varchar(50) DEFAULT NULL,
      `op_type` char(10) DEFAULT NULL,
      `tenant_id` varchar(128) DEFAULT '' COMMENT '租户字段',
      PRIMARY KEY (`nid`),
      KEY `idx_gmt_create` (`gmt_create`),
      KEY `idx_gmt_modified` (`gmt_modified`),
      KEY `idx_did` (`data_id`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='多租户改造';
    
    
    /******************************************/
    /*   数据库全名 = nacos_config   */
    /*   表名称 = tenant_capacity   */
    /******************************************/
    CREATE TABLE `tenant_capacity` (
      `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
      `tenant_id` varchar(128) NOT NULL DEFAULT '' COMMENT 'Tenant ID',
      `quota` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '配额,0表示使用默认值',
      `usage` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '使用量',
      `max_size` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '单个配置大小上限,单位为字节,0表示使用默认值',
      `max_aggr_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '聚合子配置最大个数',
      `max_aggr_size` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '单个聚合数据的子配置大小上限,单位为字节,0表示使用默认值',
      `max_history_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '最大变更历史数量',
      `gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
      `gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间',
      PRIMARY KEY (`id`),
      UNIQUE KEY `uk_tenant_id` (`tenant_id`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='租户容量信息表';
    
    
    CREATE TABLE `tenant_info` (
      `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'id',
      `kp` varchar(128) NOT NULL COMMENT 'kp',
      `tenant_id` varchar(128) default '' COMMENT 'tenant_id',
      `tenant_name` varchar(128) default '' COMMENT 'tenant_name',
      `tenant_desc` varchar(256) DEFAULT NULL COMMENT 'tenant_desc',
      `create_source` varchar(32) DEFAULT NULL COMMENT 'create_source',
      `gmt_create` bigint(20) NOT NULL COMMENT '创建时间',
      `gmt_modified` bigint(20) NOT NULL COMMENT '修改时间',
      PRIMARY KEY (`id`),
      UNIQUE KEY `uk_tenant_info_kptenantid` (`kp`,`tenant_id`),
      KEY `idx_tenant_id` (`tenant_id`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='tenant_info';
    
    CREATE TABLE `users` (
    	`username` varchar(50) NOT NULL PRIMARY KEY,
    	`password` varchar(500) NOT NULL,
    	`enabled` boolean NOT NULL
    );
    
    CREATE TABLE `roles` (
    	`username` varchar(50) NOT NULL,
    	`role` varchar(50) NOT NULL,
    	UNIQUE INDEX `idx_user_role` (`username` ASC, `role` ASC) USING BTREE
    );
    
    CREATE TABLE `permissions` (
        `role` varchar(50) NOT NULL,
        `resource` varchar(255) NOT NULL,
        `action` varchar(8) NOT NULL,
        UNIQUE INDEX `uk_role_permission` (`role`,`resource`,`action`) USING BTREE
    );
    
    INSERT INTO users (username, password, enabled) VALUES ('nacos', '$2a$10$EuWPZHzz32dJN7jexM34MOeYirDdFAZm2kuWj7VEOJhhZkDrxfvUu', TRUE);
    
    INSERT INTO roles (username, role) VALUES ('nacos', 'ROLE_ADMIN');
    
配置nacos

由于配置nacos集群,因此不能像往常一样直接在代码中配置,可以在nacos修改配置文件,然后复制多份模拟多个nacos

在nacos/conf中将clustter.conf.example文件更名为cluster.conf,将里面的ip地址用以下来替换

127.0.0.1:8845
127.0.0.1.8846
127.0.0.1.8847

image-20240131213730933

然后进入application.properties将对应内容按照如下修改(数据库用户名和密码按照自己实际来修改)

#*************** Config Module Related Configurations ***************#
### If use MySQL as datasource:
 spring.datasource.platform=mysql

### Count of DB:
 db.num=1

### Connect URL of DB:
 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=123456

将nacos复制三份,分别命名成1,2,3

image-20240131215035517

进入三个nacos的application.properties将启动端口分别改为8845,8846,8847

然后直接分别启动上述三个nacos(进入bin目录双击startup.cmd,不必添加-m参数了,因为默认是按照集群启动)

可以配置服务方便以后一键开启

image-20240131215905247

内存已经占用88%了...,不知道自己的电脑配置能不能撑到微服务结束学习...(慌)

配置nginx

如果之前没有nginx的话可以找到资料中的安装包进行安装

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

upstream nacos-cluster {
    server 127.0.0.1:8845;
	server 127.0.0.1:8846;
	server 127.0.0.1:8847;
}

server {
    listen       80;
    server_name  localhost;

    location /nacos {
        proxy_pass http://nacos-cluster;
    }
}

然后双击.exe启动nginx,浏览器输入localhost/nacos访问成功,nginx会通过80端口反向代理,在上述3个nacos服务之间进行负载均衡选择其中的一个

image-20240131221026518

功能测试

java代码要想使用nacos,只需要将bootstap配置中nacos端口改成80即可

image-20240131221236295

进入nacos添加一个环境配置

image-20240131221726037

可以在数据库中看到配置数据已经进行了持久化存储

image-20240131221834369

用完赶快关掉nacos吧,一个nacos就占了一个多G的内存...

Feign远程调用

Feign替代RestTemplate

RestTemplate调用存在的问题

image-20240131222700567

对于上述代码存在以下问题

  • 可读性差,变成体验不统一
  • 参数复杂,url难以维护
配置Feign

Feign是一个声明式http客户端,通过它可以优雅实现发送http请求,解决上面提到的问题

  1. 引入依赖

    //orderservice pom.xml
    <!--        Feign-->
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-starter-openfeign</artifactId>
            </dependency>
    
  2. 在order-service启动类添加注解开启Feign功能

    @EnableFeignClients

  3. 编写Feign客户端

    所谓声明式,指的是只要提供发送请求所需要的信息就能封装一个规范完整的http请求,例如服务名称,请求方式,请求路径,请求参数,返回值类型等

    //clients.UserClient
    @FeignClient("user-service")
    public interface UserClient {
        @GetMapping("/user/{id}")
        User findById(@PathVariable Long id);
    }
    

    注释掉原来controller中使用RestTemplate编写的方法,采用feign来发送请求

    @RestController
    @RequestMapping("order")
    public class OrderController {
    
       @Autowired
       private OrderService orderService;
       @Autowired
       private UserClient userClient;
    //   @Autowired
    //   private RestTemplate restTemplate;
    
        @GetMapping("{orderId}")
        public Order queryOrderByUserId(@PathVariable("orderId") Long orderId) {
            // 根据id查询订单
            Order order = orderService.queryOrderById(orderId);
    //         // 利用RestTemplate发起http请求,查询用户
    //        order.setUser(restTemplate.getForObject("http://user-service/user/" + order.getUserId(), User.class));
            // 利用Feign查询用户
            User user = userClient.findById(order.getUserId());
            order.setUser(user);
            return order;
        }
    
    }
    
    
功能测试

❗因为nacos集群太消耗电脑性能了,这里还采用原来的单个nacos服务启动

这里笔者忘记持久化保存单机nacos配置了,因此没有之前创建的命名空间信息,如果发现不了服务直接将user-service的配置文件中nacos.namespace配置项注释掉即可

访问order-service接口可以看到成功返回数据,而且Feign自动实现了负载均衡

image-20240131234538460

自定义配置

Feign通过自定义配置来覆盖原来的默认配置

类型(feign.开头) 作用 说明
Logger.Level 修改日志级别 四种不同的级别:NONE(默认),BASIC(请求开始结束时间和耗时),HEADERS(再加上请求头信息),FULL(将请求体和响应体也包括进去)
codec.Decoder 响应结果的解析器 http远程调用结果解析,例如将json字符串解析为java对象
codec.Encoder 请求参数编码 将请求参数编码,便于通过http请求发送,例如将传送参数转换成请求体
Contract 支持的注解格式 默认使用SpringMVC的注解格式
Retryer 失败重试 默认没有重试,不过由于底层使用ribbon,可以使用ribbon的重试
代码配置
配置文件方式
  1. 全局生效

    feign:
    	client:
    		config:
    			default: #全局默认配置
    				loggerLevel: FULL #日志级别
    
  2. 局部生效

    feign:
    	client:
    		config:
    			user-service: #针对指定服务的配置
    				loggerLevel: FULL 
    

功能测试

# order-service 配置文件
feign:
  client:
    config:
      default:
        logger-level: FULL

可以看到返回了日志

image-20240201172828277

java代码配置方式
  1. 声明一个配置Bean
public class FeignClientConfiguration {
    @Bean
    public Logger.Level feignLogLevel(){
        return Logger.Level.BASIC;
    }
}
  1. 在注解上添加对应选项

    • 全局配置,在启动类对应注解添加配置

      @EnableFeignClients(defaultConfiguration = FeignClientConfiguration.class)
      
    • 局部配置,在对应clients注解上添加配置

      @FeignClient(value='user-service',configuration = FeignClientConfiguration.class)
      

个人肯定是推荐用配置文件的方式啦

测试完之后记得把feign日志配置改成NONE或者BASIC,毕竟日志还是消耗一定性能的

Feign性能优化

Feign底层客户端实现

  • URLConnection:默认实现,性能一般,并且不支持连接池
  • Apache HttpClient" 支持连接池
  • OKHttp: 支持用basic或none

因此优化性能包括

  • 使用连接池替换默认底层实现客户端(这里使用httpClient)
  • 日志级别采用NONE或者BASIC
优化配置
  1. 引入httpClient依赖

    <!-- order-service pom.xml -->
    <!--        Feign HttpClient-->
            <dependency>
                <groupId>io.github.openfeign</groupId>
                <artifactId>feign-httpclient</artifactId>
            </dependency>
    
  2. 配置连接池

    # order-service
    feign:
      httpclient:
        enabled: true #启用httpclient作为feign的http请求客户端
        max-connections: 200 #连接池最大连接数,根据业务具体需求来确定
        max-connections-per-route: 50 #单个服务的最大连接数,根据业务具体需求来确定
    

企业最佳实践

继承方案

因为对于原功能接口和需要远程调用的接口的请求方法一定相同(例如根据id获取order信息的时候同时远程调用根据id查找用户信息的接口,可以共同用userAPI来约束,保证请求方法相同)

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

image-20240201200934829

弊端

  • 服务紧耦合,如果父接口变化了的话,继承的子类都要跟着改变
  • 父接口参数列表中的映射不会被继承(springMVC本身并不支持如@PathVariable这样的注解的继承)
抽取方案

将FeignClient抽取为独立的模块,将接口有关的pojo,默认的feign配置都放到这个模块中,提供给所有的消费者使用

避免了不同业务调用相同的一个服务重复造方法,后端使用服务的时候只需要调用api即可

image-20240201203602849

弊端:

  • 需要将一整套实现好的方法搬进来,项目可能会比较臃肿

个人比较倾向于方案2,本次项目也将采取方案2进行改造

抽取FeignClient
  1. 创建feign-api这个模块,引入feign依赖,将order-servcie中编写的UserClient,User,DefaultFeignConfiguration都复制到feign-api项目中(由于之前自己采用的是配置文件方式配置feign,因此没有复制配置类)

    <!--    feign-->
        <dependency>
          <groupId>org.springframework.cloud</groupId>
          <artifactId>spring-cloud-starter-openfeign</artifactId>
        </dependency>
    

    image-20240201221613704

    将原来order-service里面的feign配置转移到feign-api模块的application.yml文件中(自行创建)

    feign:
      client:
        config:
          default:
            logger-level: NONE
      httpclient:
        enabled: true #启用httpclient作为feign的http请求客户端
        max-connections: 200 #连接池最大连接数,根据业务具体需求来确定
        max-connections-per-route: 50 #单个服务的最大连接数,根据业务具体需求来确定
    
  2. 在order-service中引入feign-api本地模块的依赖

    <!--        feign-api-->
            <dependency>
                <groupId>cn.itcast.eureka</groupId>
                <artifactId>feign-api</artifactId>
                <version>1.0</version>
            </dependency>
    
  3. 修改order-service中的所有与上述三个组件有关的import部分改成导入feign-api中的包

    首先在order-service启动类中添加@EnableFeignClients(clients = {UserClient.class})指定UserClient作为客户端进入IOC管理
    之后检查每个文件的类的依赖是否配置正确(可以直接构建更快发现需要改动的地方)

  4. 启动测试,正常返回结果

    image-20240201224935248

找不到client类的解决方案
  1. 启动类feign注解指定Feign的最佳实践@EnableFeignClients(basePackages = "cn.itcast.feign.clients"),不推荐,扫描包浪费性能
  2. 精准定位指定的FeignClient@EnableFeignClients(clients={UserClient.class}),即上面采用的方法,推荐,有多少用多少,节约性能

Gateway服务网关

想起「502 Bad Gateway」

究竟何年何月学校的教务系统不会在选课的时候炸掉呢

网关功能:

  1. 身份认证和权限校验
  2. 服务路由/负载均衡
  3. 请求限流

image-20240201231209950

gateway快速入门

在SpringCloud中网关的实现包括两种:

  • gateway(较新的技术,基于Spring5中提供的WebFlux,属于响应式编程,具备更好的性能)
  • zuul(较早的技术,基于Servlet的实现,属于阻塞式编程)
搭建网关服务
  1. 创建新的module,引入SpringCloudGateWay依赖和nacos服务发现依赖,创建启动类

    image-20240202223751015

    //pom.xml
    <dependencies>
    <!--    nacos服务注册发现-->
        <dependency>
          <groupId>com.alibaba.cloud</groupId>
          <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
        </dependency>
    <!--    gateway网关-->
        <dependency>
          <groupId>org.springframework.cloud</groupId>
          <artifactId>spring-cloud-starter-gateway</artifactId>
        </dependency>
        <dependency>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-autoconfigure</artifactId>
        </dependency>
      </dependencies>
    
    //GatewayApplication
    @SpringBootApplication
    public class GatewayApplication {
        public static void main(String[] args) {
            SpringApplication.run(GatewayApplication.class, args);
        }
    
    }
    
  2. 在网关实现请求路由

    编写路由配置和nacos地址

    # application.yml
    server:
      port: 10010 #网关端口
    spring:
      application:
        name: gateway #网关服务名称
      cloud:
        nacos:
          server-addr: localhost:8848 #nacos服务地址
        gateway:
          routes: #网关路由配置
            - id: user-service #路由id,一般以定位到的服务名称命名
              uri: lb://user-service #路由目标地址(lb代表负载均衡)
              predicates: #路由断言,也就是判断条件是否符合要求,如果符合才能进行路由
                - Path=/user/** #按照路径匹配,只要路径符合以/user/开头的都可以进行路由
            - id: order-service
              uri: lb://order-service
              predicates:
                - Path=/order/**
    
  3. 启动网关和其他相关服务进行功能测试

    image-20240202225006769

    网关流程

    image-20240202225315571

总结

image-20240202225603323

断言工厂

断言工厂负责将配置文件中的断言规则字符串读取并解析,转换为路由判断条件

image-20240202225728110

例如添加After谓词限制只能在指定时间段之后访问

predicates:
	- After=2031-01-01T00:00:00+08:00[Asia/Shanghai] #时间断言,只有在指定时间之后才能进行路由

可以看到由于请求时间不符合要求,会报404

image-20240202230156440

过滤器工厂

GatewayFilter对进入网关的请求和微服务返回的响应做处理,多个过滤器组成一套链

image-20240202230513677

入门案例

      routes: #网关路由配置
        - id: order-service
          
          filters:
            - AddRequestHeader=Truth,Itcast is freaking awesome #添加请求头,在请求头中添加XTruth: Itcast is freaking awesome
      default-filters:
        - AddRequestHeader=Itcast,Itcast is freaking awesome #默认过滤器,在请求头中添加XItcast: Itcast is freaking awesome

全局过滤器

全局过滤器和上述的default filter功能相同,但与后者处理逻辑被gateway写死相比,可以自定义逻辑,实现更复杂的过滤功能

定义方式是实现GlobalFilter接口

入门案例

定义全局过滤器,拦截并判断用户身份

  • 参数中含有authorization,且值为admin即可
@Order(-1) //设置过滤器的优先级,值越小,优先级越高
@Component //注册进Spring容器
public class AuthorizationFilter implements GlobalFilter {

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        //1.获取请求参数
        String authorization = exchange.getRequest().getQueryParams().getFirst("authorization");//getFirst()获取匹配上的第一个参数
        //2.判断authorization是否为admin,符合则放行
        if ("admin".equals(authorization)) {
            //交给下一个过滤器处理,即放行
            return chain.filter(exchange);
        }
        //3.不符合则拦截,设置响应状态码为401
        exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
        return exchange.getResponse().setComplete();
    }
}
过滤器执行顺序

请求进入网关会遇到三类过滤器:当前路由的过滤器,默认过滤器和全局过滤器

请求路由之后,会将当前三类过滤器合并到一个过滤器链(集合)中,排序后一次执行每个过滤器(全局过滤器可以通过适配器适配成GatewayFilter,因此可以这三类可以放在一个集合里面)

image-20240203212751600

过滤器执行顺序

  1. 每个过滤器指定一个int类型的order值,值越小,顺序越靠前(可以通过注解或者实现接口的方式指定,对于配置声明的过滤器,由Spring根据顺序从1开始递增指定)
  2. 当过滤器order值一样的时候,会按照default > 路由过滤器 > 全局过滤器的顺序执行

跨域问题

跨域:

  • 域名不同:如www.taobao.comwww.jd.commiaosha.jd.com
  • 域名相同,但端口不同

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

解决方案:CORS(自己查了一下,大概就是修改响应头允许跨域或者使用其他方法(jsonp)代替ajax请求,了解视频)

网关处理跨域采用的同样是CORS方案,只需要如下简单配置即可

入门案例

在指定端口开启前端页面(这里我是用的是vscode的live server插件)

image-20240203223033748

可以看到前端报了CORS错误(跨域请求错误)image-20240203223241738

然后在gateway模块添加以下配置(允许跨域的端口号根据前端页面开启的端口号而定)

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

重启gateway之后再重新再浏览器访问index.html,成功返回数据(如果没成功请检查是否允许了前端开启的端口,并将访问地址127.0.0.1改成localhost(因为浏览器并不知道你的主机localhost对应的就是127.0.0.1))

image-20240203224207103

Day03 2024.02.03 Docker

最喜欢的一集,很早就已经听闻了Docker的nb之处,今日终于有机会接触了!

初识Docker

项目部署的问题

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

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

什么是Docker

Docker

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

image-20240203225415307

❓ 不同环境的操作系统不同,DOcker如何解决呢?

基于linux内核的系统使用了不同的函数库来封装内核指令,因此一个系统上的软件可能无法运行在另一个系统上面

image-20240203225615596

image-20240203225720765

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

  • Docker将用户程序所需要调用的系统(比如Ubuntu)函数库一同打包
  • Docker运行到不同操作系统的时候,直接基于打包的库函数运行在系统内核(即linux内核)上

image-20240203230203360

总结

image-20240203230407619

Docker和虚拟机的区别

  • Docker是直接调用操作系统内核,相比虚拟机性能更好,占用更少
  • 虚拟机是基于本地模拟一个硬件环境,调用这个虚拟硬件环境搭建的操作系统内核,进而调用本地内核

image-20240203230915281

总结

image-20240203230942546

Docker架构

镜像和容器

镜像 : Docker将应用程序及其所需依赖,函数库,环境,配置等文件打包在一起的一个文件

容器 : 镜像中的应用程序运行后形成的进程就是容器,只是Docker会给容器进行隔离,对外不可见,一个镜像可以运行多个容器

image-20240205201940872

Docker和DockerHub
  • DockerHub : 是Docker镜像托管平台,这样的平台成为Docker Registry
Docker架构

Docker是一个CS架构的程序

  • 服务端:Docekr守护进程,负责处理Docker指令,管理镜像和容器
  • 客户端:通过命令或RestA向Docker服务端发送指令

image-20240205202517104

安装Docker

这里选择在CentOS7上安装docker,因为笔者之前学习linux的时候已经装过这个系统了,就不演示如何安装linux,可自行上网搜索

  1. 先检查卸载之前残留的docker

    yum remove docker \
                      docker-client \
                      docker-client-latest \
                      docker-common \
                      docker-latest \
                      docker-latest-logrotate \
                      docker-logrotate \
                      docker-selinux \
                      docker-engine-selinux \
                      docker-engine \
                      docker-ce
    

    image-20240205204047098

  2. 安装yum工具并更新镜像源

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

    然后更新本地镜像源:

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

    image-20240205204517436

    image-20240205205039251

  3. 安装Docker

yum install -y docker-ce

docker-ce为社区免费版本。稍等片刻,docker即可安装成功。

image-20240205205218800

  1. 启动Docker

    由于Docker需要用到许多端口,需要逐一设置防火墙,建议直接将防火墙关闭

    启动Docekr前一定要关闭防火墙

    # 关闭
    systemctl stop firewalld
    # 禁止开机启动防火墙
    systemctl disable firewalld
    

    通过命令启动docker:

    systemctl start docker  # 启动docker服务
    
    systemctl stop docker  # 停止docker服务
    
    systemctl restart docker  # 重启docker服务
    

    然后输入命令,可以查看docker版本:

    docker -v
    

    如图:

    image-20240205210011662

  2. 配置镜像

    参考文档

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

    image-20240205210542665

Docker基本操作

镜像操作

image-20240205211028679

镜像命名规范
  • 命名分成两部分:[repository]:[tag],在没有指定tag的时候,默认是latest,即最新版镜像

    image-20240205210831543

案例:拉取nginx镜像并查看
  1. Dockerhub网站查找镜像(进入网站貌似得FQ)

    image-20240205211738411

  2. 安装镜像

    docker pull nginx
    

    image-20240205212027705

    原来nginx已经两年没更新了吗

案例:镜像导出磁盘再重新加载
  1. docker save来保存镜像为压缩包

    # -o表示输出到一个文件 nginx.tar是输出的文件名 nginx:latest是指定要导出的镜像
    docker save -o nginx.tar nginx:latest
    
  2. 删除并重新加载镜像

    #删除镜像
    docker rmi nginx:latest
    #加载镜像 -i表示将指定文件作为输入文件
    docker load -i nginx.tar
    

    image-20240205213721220

案例:自行拉取Redis镜像

image-20240205213924686

很简单,因此我就不练习了,只拉取镜像即可

docker pull redis

容器操作

image-20240205214659543

案例:创建运行nginx容器
  1. 运行nginx容器

    docker run --name containerName -p 80:80 -d nginx
    
    • --name 后面接指定的容器进程名称

    • -p 将宿主主机端口与容器端口映射,左侧是宿主机端口,右侧是容器端口

      image-20240205215445848

    • -d 后台运行容器

    • nginx 是指定运行的镜像名称

    image-20240205220126297

    其中启动容器后返回的是进程的唯一id

    然后在主机浏览器中访问

    image-20240205220432417

    其中虚拟机的内网ip地址是通过ifconfig查询获取的

    image-20240205220529642

    查看日志

    #查看指定容器(进程)的日志
    docker logs containerName
    
    #持续跟踪日志
    docker logs -f containerName
    
案例:进入容器修改文件内容
docker exec -it containerName bash
  • docker exec 进入容器内部执行命令(bash 即为执行的命令,打开bash终端)
  • -it 给进入的容器创建一个标准输入输出终端,允许我们与容器交互

在dockerhub上查看nginx html文件所在位置

image-20240205223945556

修改容器的html文件内容

# 进入html所在目录
cd /usr/share/nginx/html

# 修改html内容
# 由于容器相当于一个阉割版的系统,只包含运行容器必要的依赖环境,没有vim等工具,因此使用最原始的sed指令进行修改
#其中-i表示直接对原文件内容进行替换,`#`表示分割符,s表示替换指令,第一个分隔符后面是要替换的字符串,第二个分隔符后面是替换成的内容,分隔符也可以用`/`,`|`等来表示
sed -i 's#Welcome to nginx#传智教育欢迎你#g' index.html
#让html页面支持显示中文
sed -i 's#<head>#<head><meta charset="utf-8">#g' index.html

可以看到成功替换了页面内容

image-20240205230448828

杀死nginx容器进程并查看进程状态

# 杀掉进程
docker stop containerName

#查看docker容器状况,如果不加`-a`,则默认只显示运行中的容器
docker ps -a

#重启进程
docker start containerName

#删除容器,-f可以强制删除运行中的容器
docker rm -f containerName

❗ exec命令进入容器内修改文件并不推荐,因为不方便,而且没有修改记录,正确修改文件的方式将在下面讲解

案例:创建运行redis容器并实现持久化
 docker pull redis
 
 docker run --name mr -p 6379:6379 -d redis redis-server --appendonly yes

在主机上连接redis容器,个人觉得直接用idea自带的来连接redis就行了,不必再安装一个专门的软件

初始账号是redis,没有密码,连接的主机即为虚拟机的地址,这里笔者是配置了hosts文件将fishlulu01和虚拟机地址映射了image-20240208211840806

可以在linux终端对redis进行操作

# 进入容器内部
docker exec -it mr bash
# 进入redis客户端
redis-cli 
# 上述两步操作也可以合并为一个操作
docker exec -it mr redis-cli

# redis命令操作
keys *
set num 666

image-20240208212510119

ℹ️ 关于虚拟机挂起再恢复后主机无法在浏览器访问容器的问题

解决方案 简单来说就是开启IPv4连接,这个小小问题没想到花费了我一晚上时间去琢磨啊

数据卷(容器数据管理)

容器与数据耦合的问题

  • 不便于修改,需要进入容器修改
  • 数据不可复用.容器内部修改对外不可见
  • 升级维护困难,如果升级容器必须连带数据和旧的容器一起删除

📑数据卷:是一个虚拟目录,指向宿主机文件系统中的某个目录,可以理解为容器使用宿主机指定的目录来存储数据,容器本身不存储数据(并不是数据备份)

image-20240208213728859

数据卷操作
  • create 创建
  • inspect 显示一个或者数据卷信息
  • ls 列出所有的volume
  • prune 删除未使用的数据卷(prune 意为修剪)
  • rm 删除指定的volume

image-20240208221729202

(貌似prune命令并没有将新建的数据卷删除,不过不是什么重要的事情)

挂载数据卷

可以通过-v参数来挂载数据卷到某个容器目录

docker run \               
--name mn \
-v html:/root/html \         #将html数据卷挂载到容器内的/root/html目录中(如果之前没有创建html数据卷也没关系,docker会自动创建)
-p 8080:80 \
nginx\
案例:通过数据卷挂载niginx容器的html文件并修改内容
#启动nginx容器服务并挂载指定数据卷
docker run --name mn -p 80:80 -v html:/usr/share/nginx/html -d nginx

image-20240208224822747

进入指定数据卷的_data目录找到对应关联的文件进行修改(通过vscode远程连接虚拟机,通过高级编辑工具来进行修改)

image-20240208225714446

可以看到成功修改

image-20240208230107469

案例:直接挂载mysql容器指定目录到宿主机目录上面
  1. 将课前资料中的mysql.tar文件上传到虚拟机,通过load命令加载为镜像(当然可以网上拉取,不过这里就和课程保持一致了)

    这里使用xftp上传tar包到/root/Downloads目录下面

    image-20240208231945500

    终端跳转到tar包目录,加载镜像

    docker load -i mysql.tar
    

    image-20240208232409572

  2. 创建目录/tmp/mysql/conf,创建目录/tmp/mysql/data,将课前资料中提供的hmy.cnf文件上传到conf目录

    这里创建目录和上传cnf文件都直接用xftp了

    image-20240208232813277

  3. 创建并运行mysql容器

    docker run \
    --name mysql \
    -e MYSQL_ROOT_PASSWORD=123 \		#指定环境变量,这里直接指定root用户密码
    -p 3306:3306 \
    -v /tmp/mysql/conf/hmy.cnf:/etc/mysql/conf.d/hmy.cnf \			#挂载配置文件,容器对应目录可在dockerhub查阅(my.cnf是官方的默认配置,因此额外配置挂载到conf目录下面而非my.cnf上,其中`.d`代表是目录的含义)
    -v /tmp/mysql/data:/var/lib/mysql \					#挂载数据目录
    -d \
    mysql:5.7.25
    

    如何使用vscode终端运行命令的话,由于windows换行符和linux不同,无法识别上面多行文本为一行指令,因此直接拼接成一行执行即可

    docker run --name mysql -e MYSQL_ROOT_PASSWORD=123 -p 3306:3306 -v /tmp/mysql/conf/hmy.cnf:/etc/mysql/conf.d/hmy.cnf -v /tmp/mysql/data:/var/lib/mysql -d mysql:5.7.25
    
    docker exec -it mysql bash # 在终端打开mysql容器
    
    mysql -uroot -p # 密码登入mysql(密码为运行时环境变量指定的123)
    

    成功进入mysql

    image-20240208234620110

数据卷挂载和目录挂载对比
  • 数据卷挂载耦合度更低,由docker来管理目录,但是目录比较深,不好找(/var/lib/docker/volumes/..)
    • 数据卷不存在的时候会自动创建
    • 数据卷中没有文件的时候会将容器目录中的文件自动挂载(转移)到数据卷中
    • 数据卷中有文件的时候,数据卷中的文件会覆盖掉原来容器中的对应文件
  • 目录直接挂载耦合度更高(因为需要我们自己指定目录),不过目录比较容易寻找和查看

image-20240208235324553

Dockerfile自定义镜像

镜像结构

image-20240218165209828

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

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

自定义镜像

image-20240218165610046

案例:构建镜像运行java项目

  1. 创建/tmp/docker-demo文件夹,将docker-demo.jar,Dockerfile,jdk8.tar.gz三个文件上传到文件夹里面

    #Dockerfile 内容
    
    # 指定基础镜像
    FROM ubuntu:16.04
    # 配置环境变量,JDK的安装目录
    ENV JAVA_DIR=/usr/local
    
    # 拷贝jdk和java项目的包
    COPY ./jdk8.tar.gz $JAVA_DIR/
    COPY ./docker-demo.jar /tmp/app.jar
    
    # 安装JDK
    RUN cd $JAVA_DIR \
     && tar -xf ./jdk8.tar.gz \
     && mv ./jdk1.8.0_144 ./java8
    
    # 配置环境变量
    ENV JAVA_HOME=$JAVA_DIR/java8
    ENV PATH=$PATH:$JAVA_HOME/bin
    
    # 暴露端口
    EXPOSE 8090
    # 入口,java项目的启动命令
    ENTRYPOINT java -jar /tmp/app.jar
    

    image-20240218171243185

  2. 创建容器并运行

    # 创建容器 其中-t指定容器的标签(即1.0)
    docker build -t javaweb:1.0 /tmp/docker-demo
    # 运行容器
    docker run --name web -p 8090:8090 -d javaweb:1.0
    

    image-20240218171924189

​ 成功构建部署image-20240218172551740

ℹ️ 在先前的dockerfile文件中,存在许多可以复用的指令(层),可以统一构建一个通用的镜像再在此基础上进行改造,这里可以基于java:8-alpine这个已经准备好jdk环境的镜像来部署项目,只需要改变dockerfie即可(需要联网拉取一下基础镜像)

💡如果没有镜像,就改成openjdk:8-alpine,因为原来的镜像已经弃用了

#指定基础镜像
FROM java:8-alpine

COPY ./docker-demo.jar /tmp/app.jar
# 暴露端口
EXPOSE 8090
# 入口,java项目的启动命令
ENTRYPOINT java -jar /tmp/app.jar

Docker-Compose

入门

  • Docker Compose可以基于Compose文件快速部署分布式应用,而不用手动一个个创建和运行容器

  • Compose文件是一个yaml文本文件,通过指令定义集群中的每个如何运行(本质上就是把dockerfile指令转化成配置文件来批量运行)

    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
    			
    

安装Docker-Compose

这里直接使用课程资料提供的docker-compose文件上传到/usr/local/bin目录中

image-20240218174518077

修改文件权限给予执行权

chmod +x /usr/local/bin/docker-compose

配置自动补全

# 补全命令
curl -L https://raw.githubusercontent.com/docker/compose/1.29.1/contrib/completion/bash/docker-compose > /etc/bash_completion.d/docker-compose

#如果出错了需要修改一下hosts文件
echo "199.232.68.133 raw.githubusercontent.com" >> /etc/hosts

image-20240218175505070

部署cloud-demo项目

  1. 查看docker-compose文件,将自己项目中的nacos和mysql地址从原来的主机名改成对应的服务名

    ❓为什么在docker中只要对应服务名就可以访问不同服务?

    因为docker底层实现了自动构建虚拟网络,可以根据对应服务名定位到指定ip,比如后面在部署nacos服务的时候nacos控制台172开头的ip地址就是docker生成的内网ip

​ docker-compose文件(记得把里面的数据库密码改成自己的)

#docker-compose文件
version: "3.2"

services:
  nacos:
    image: nacos/nacos-server
    environment:
      MODE: standalone
    ports:
      - "8848:8848"
  mysql:
    image: mysql:5.7.25
    environment:
      MYSQL_ROOT_PASSWORD: 123456
    volumes:
    	#$PWD表示相对当前目录
      - "$PWD/mysql/data:/var/lib/mysql"
      - "$PWD/mysql/conf:/etc/mysql/conf.d/"
  userservice:
  # 服务是提供给内部调用的,因此不需要暴露端口
    build: ./user-service
  orderservice:
    build: ./order-service
  gateway:
    build: ./gateway
    ports:
      - "10010:10010"
  1. 修改项目中服务的对应地址(网关,user和order微服务都需要修改)

image-20240218213914238

  1. 使用maven打包成jar包(app.jar)

    在每个要打包的服务的pom文件build标签中设置打包名称为app.jar(因为dockerfile文件配置是复制app.jar到指定目录)

    image-20240218214536179

    先后执行父模块maven声明周期的clean和package操作,将target目录生成好的app.jar包分别放到资料cloud-demo文件的指定子文件夹中( 这里推荐不要用老师现成给的,不知道为什么运行后服务注册不到nacos上面,用自己的就可以)

    image-20240218215301890

  2. 将整理好的cloud-demo文件夹上传到/tmp目录下

    image-20240218215922067

  3. 构建镜像并运行

    cd /tmp/cloud-demo
    docker-compose up -d # up指令表示构建并运行
    

    image-20240218223815339

    这里可能需要先拉取nacos镜像,因此会慢一些

    docker-compose logs -f #查看运行日志
    

    会看到nacos报错连接失败

    image-20240218224447507

    原因貌似是nacos本身代码问题,nacos在较后面的位置启动,因此前面先启动的服务找不到nacos来注册

    因此建议在生产环境先部署nacos,然后再部署其他服务,这里直接重启gateway,user-service和order-service服务即可

    docker-compose restart orderservice userservice gateway #服务名是docker-compose.yml文件配置的,与服务本身spring的应用名和nacos服务注册名无关
    

    通过内网ip访问nacos控制台192.168.181:8848/nacos(把ip地址换成自己虚拟机对应的内网ip即可)

    image-20240219124846289

    可以看到服务注册成功

    image-20240219132452462

    然后通过网关来访问服务测试192.168.181.100:10010/order/101?authorization=admin(之前网关配置了拦截器,只有添加admin请求参数才能访问)

    成功返回

    image-20240219133049737

    🐛 这里刚开始可能会报500错误,查看日志发现是请求超时导致的,进一步发现报错的是user-service

    java.net.UnknownHostException: nacos

    过了一段时间之后可以正常访问了,暂时自己还不清楚原因

Docker镜像仓库

搭建私有镜像仓库

  • 公共镜像仓库: Docker官方的DockerHub以及网易云,阿里云镜像服务等
  • 用户在本地搭建私有Docker Registry
搭建简化版镜像仓库

具备仓库管理的完整功能,只是没有图形化界面

docker run -d \ 
--restart=always \ 
--name registry \ 
-p 5000:5000 \ 
-v registry-data:/var/lib/registry \ 
registry
搭建带有图形化界面仓库

由于图形化界面不是官方开发,这里使用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

之前提到的nacos没有先于其他服务配置导致其他服务注册失败也可以通过在docker-compose.yml文件中配置depends_on选项来指定其他服务在nacos之后注册

配置Docker信任地址

由于私服默认采用http协议,不被Docker信任,因此需要额外配置

# 打开要修改的文件
vim /etc/docker/daemon.json
#添加内容:
"insecure-registries":["http://192.168.181.100:8080"] #换成自己对应的虚拟机内网ip
#重新加载
systemctl daemon-reload
#重启docker

记得要在上一行末尾添加逗号分隔

案例:搭建本地图形化仓库
  1. 按照上述配置Docker信任地址

  2. 创建/tmp/registry-ui文件夹,里面新建docker-compose.yml文件,并将上述搭建图形化仓库的命令内容粘贴进里面

  3. 创建并运行容器

    cd /tmp/registry-ui
    docker-compose up -d
    

    image-20240219144305685

    在浏览器访问虚拟机8080端口

    image-20240219144459847

向镜像仓库推送镜像

将镜像tag重命名(比如要推送nginx镜像)

docker tag nginx:latest 192.168.181.100:8080/nginx:1.0

此时会发现两个镜像id一样,其实本质上都是一个镜像

image-20240219160926735

将镜像推送

docker push 192.168.181.100:8080/nginx:1.0

image-20240219161137510

此时在浏览器查看仓库,可以发现镜像

镜像仓库拉取镜像

可以直接通过页面复制拉取指令

image-20240219161244769

先将原来的镜像删除,然后执行拉取

docker rmi 192.168.181.100:8080/nginx:1.0
docker rmi nginx:latest
docker pull 192.168.181.100:8080/nginx@sha256:ee89b00528ff4f02f2405e4ee221743ebc3f8e8dd0bfd5c4c20a2fa2aaa7ede3

image-20240219163442591

总结

image-20240219163532482

Day04 2024.02.19 服务异步通讯(消息队列)

初识MQ

同步通讯

同步通讯好比打电话,保证消息能立刻传达,但只能同时和一个连接

异步通讯好比微信聊天,发过去的消息别人可能不会立刻回复,但可以同时和多个连接image-20240219164105750

同步调用的问题

image-20240219200548051

  1. 耦合度高,每次想新添功能都要改动现有代码
  2. 性能问题,必须等待一个完成才能进行下一个
  3. 系统资源浪费,等待期间持续占用内存和CPU消耗
  4. 级联失败,如果支付服务调用的一个服务挂了的话,支付服务也会阻塞

异步通讯

异步调用方案

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

image-20240219201451688

服务发布时间,由broker进行代理转发,其他服务通过Broker获取消息

事件驱动优势
  1. 服务解耦,支付服务只负责发送事件,而不负责调用其他服务,因此不必对其进行改动,只要订阅事件即可
  2. 性能提升,吞吐量提高,支付服务只需要发送事件耗时
  3. 故障隔离,某一个调用的服务挂了的话不会影响上游服务
  4. 流量削峰,broker起到一个缓冲的作用,来不及处理的事件会暂存到Broker中
异步通信缺点
  1. 依赖于Broker的可靠性,安全性和吞吐能力
  2. 架构复杂了,并不清楚是具体哪一个业务处理事件,业务调用链不清晰,出了问题不好追踪排查

MQ常见框架

什么是MQ

MQ,即Message Queue 消息队列,也就是事件驱动中的Broker

RabbitMQ ActiveMQ RocketMQ Kafka
公司 Rabbit Apache 阿里 Apache
开发语言 Erlang Java Java Scala&Java
协议支持 AMQP,XMPP,SMTP,STOMP OpenWire,STOMP,REST,XMPP,AMQP 自定义协议 自定义协议
可用性 一般
单机吞吐量 一般 非常高
消息延迟 微妙级 毫秒级 毫秒级 毫秒以内
消息可靠性 一般 一般

这里使用RabbitMQ,更稳定,社区活跃,并且没有定制需求,如果追求海量数据高吞吐的话可以选用Kafka

RabbitMQ快速入门

RabbitMQ概述和安装

Erlang语言是一个面向并发的编程语言,编写的RabbitMQ具备高稳定,低延迟和不错的吞吐量

安装

这里在centos7虚拟机中使用Docker来安装

  1. 将资料提供的mq.tar包上传到/tmp目录

  2. 加载镜像

    docker load -i mq.tar
    
  3. 运行容器

    docker run \
    -e RABBITMQ_DEFAULT_USER=fishlulu \ #配置用户名和密码
    -e RABBITMQ_DEFAULT_PASS=123456 \
    --name mq \
    --hostname mq1 \
    -p 15672:15672 \ #管理平台的端口,用来统一管理消息
    -p 5672:5672 \ #用来进行收发信息通讯的端口
    -d \ # 后台运行
    rabbitmq:3-management
    

    image-20240219211546044

  4. 访问mq管理端端口192.168.181.100:15672

    image-20240219211726424

结构概述

image-20240219222414404

rabbitMQ设置多个虚拟主机实现业务的隔离,通过接受事件分发到不同队列上实现消息队列,最终通知给调用的服务

  • channel:操作MQ的通道
  • exchange:将消息路由到队列中
  • queue:用于缓存消息的队列
  • virtual host:虚拟主机,是对queue,exchange等资源的逻辑分组

常见消息模型

  • 基本消息队列(BasicQueue)

    image-20240219222958823

  • 工作消息队列(WorkQueue)

    image-20240219223009297

  • 发布订阅(Publish,Subscribe)(包含交换机部分,根据交换机类型不同分为三种)

    • 广播 Fanout Exchange

      image-20240219223112810

    • 路由 Direct Exchange

      image-20240219223120553

    • 主题 Topic Exchange

      image-20240219223136378

快速入门

基于官方HelloWorld案例,只包括三个角色

  • publisher:消息发布者
  • queue:消息队列
  • consumer:订阅队列

image-20240219223322120

由于代码比较复杂,这里导入课程资料提供的mq-demo项目工程调试跟进代码学习即可

调试publisher端

调试publisher的测试程序(记得把里面的用户密码和主机改成自己的)

public class PublisherTest {
    @Test
    public void testSendMessage() throws IOException, TimeoutException {
        // 1.建立连接
        ConnectionFactory factory = new ConnectionFactory();
        // 1.1.设置连接参数,分别是:主机名、端口号、vhost、用户名、密码
        factory.setHost("192.168.181.100");
        factory.setPort(5672);
        factory.setVirtualHost("/");
        factory.setUsername("fishlulu");
        factory.setPassword("123456");
        // 1.2.建立连接
        Connection connection = factory.newConnection();

        // 2.创建通道Channel
        Channel channel = connection.createChannel();

        // 3.创建队列
        String queueName = "simple.queue";
        channel.queueDeclare(queueName, false, false, false, null);

        // 4.发送消息
        String message = "hello, rabbitmq!";
        channel.basicPublish("", queueName, null, message.getBytes());
        System.out.println("发送消息成功:【" + message + "】");

        // 5.关闭通道和连接
        channel.close();
        connection.close();

    }
}

  1. 代码通过connnectionFactory创建与消息队列的连接

image-20240219224755943

  1. 然后创建相应通道

image-20240219225042822

  1. 创建消息队列

image-20240219225150886

  1. 发布者向通道发送消息,此时可以在管理端的消息队列中查看到

    image-20240219225359595

调试consumer端
public class ConsumerTest {

    public static void main(String[] args) throws IOException, TimeoutException {
        // 1.建立连接
        ConnectionFactory factory = new ConnectionFactory();
        // 1.1.设置连接参数,分别是:主机名、端口号、vhost、用户名、密码
        factory.setHost("192.168.181.100");
        factory.setPort(5672);
        factory.setVirtualHost("/");
        factory.setUsername("fishlulu");
        factory.setPassword("123456");
        // 1.2.建立连接
        Connection connection = factory.newConnection();

        // 2.创建通道Channel
        Channel channel = connection.createChannel();

        // 3.创建队列
        String queueName = "simple.queue";
        channel.queueDeclare(queueName, false, false, false, null);

        // 4.订阅消息
        channel.basicConsume(queueName, true, new DefaultConsumer(channel){
            @Override
            public void handleDelivery(String consumerTag, Envelope envelope,
                                       AMQP.BasicProperties properties, byte[] body) throws IOException {
                // 5.处理消息
                String message = new String(body);
                System.out.println("接收到消息:【" + message + "】");
            }
        });
        System.out.println("等待接收消息。。。。");
    }
}

❓ 为什么consumer端要重复创建一次队列

因为真正启动的时候不清楚先启动consumer端还是publisher端,防止还没有创建好队列就接受或者发送,因此两边都进行创建,然而rabbitMQ识别到同名队列并不会重复创建,因此可以放心使用

channel的basicConsume方法会提取队列中的消息并按照传入的实现类中重写的方法来处理消息(这里只是将如何处理消息告诉给队列,并没有真正执行处理,因此大概率会先打印等待接受消息,而后才打印接受的消息)(这就是异步通信的体现)

image-20240219230620576

处理完消息后再来管理端会发现消息队列清空了,这就是RabbitMQ的阅后即焚机制

image-20240219230753452

总结

image-20240219230909741

SpringAMQP

AMQP是一个传递业务消息的规范

image-20240220164330829

Basic Queue简单队列模型

hello world案例

  1. 父工程引入spring-amqp依赖

    1. 在publisher服务中利用RabbitTemplate发送消息到simple.queue这个队列(初始工程引完了)

    2. 在publisher编写测试方法发送消息

      1. 在publisher服务中application.yml 添加如下配置信息

        spring:
          rabbitmq:
            host: 192.168.181.100
            port: 5672
            virtual-host: /
            username: fishlulu
            password: 123456
        
      2. 在测试类编写如下方法

        @RunWith(SpringRunner.class)
        @SpringBootTest
        public class SpringAmqpTest {
            @Autowired
            private RabbitTemplate rabbitTemplate;
            @Test
            public void testSimpleQueue(){
                String queueName = "simple.queue";
                String message = "hello, rabbitmq!";
                rabbitTemplate.convertAndSend(queueName, message);
            }
        }
        

        Runwith注解是为了提供Spring运行环境支持,其实不配这个注解应该也没问题

        如果之前关闭了队列记得重新新建一个名为simple.queue的队列,否则接收不到信息

        image-20240220201354457

    3. 在consumer编写消费逻辑,监听simple.queue

      1. 配置yml

        spring:
          rabbitmq:
            host: 192.168.181.100
            port: 5672
            virtual-host: /
            username: fishlulu
            password: 123456
        
      2. 编写监听类

        // cn/itcast/mq/listener/SpringRabbitListener.java
        @Component
        public class SpringRabbitListener {
            @RabbitListener(queues = "simple.queue")
            public void listenSimpleQueue(String message){
                System.out.println("接收到消息:【" + message + "】");
            }
        }
        

        运行启动类

    image-20240220201531722

Work Queue工作队列模型

多个消费者来合作处理一个消息队列,可以提高消息处理速度,避免队列消息堆积

image-20240220202329792

这里案例就不跟着做了

实现起来很简单,消费者监听类上面设置两个以上监听方法监听同一个队列即可

消息预取机制

消费者在处理信息之前,会先将信息暂时取出,rabbitmq默认为每一个消费者预先分配同等个数的消息进行处理,如果两个消费者消费信息的能力不同,则会造成性能上的浪费

因此可以在yml配置消息预取上限为1

spring:
	rabbitmq:
		listener:
			simple:
				prefetch: 1 # 每次只能获取一条消息,处理完成才能获取下一个消息

发布订阅模型

之前学到的模型消息在被一个消费者处理之后会直接销毁(阅后即焚),而发布订阅模型允许将同一个消息发送给多个消费者,实现方式是加入了exchanger(交换机)

exchanger负责消息路由,而非存储,如果路由失败,消息丢失

image-20240220203844992

Fanout广播模式

会将消息发送给所有的绑定的队列

案例:实现FanoutExchange

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

    SpringAMQP提供了声明交换机,队列,绑定关系的API

image-20240220204856155

@Configuration
public class FanoutConfig {
    //声明交换机
    @Bean
    public FanoutExchange fanoutExchange(){
        return new FanoutExchange("fanout.exchange");
    }
    @Bean
    //声明队列
    public Queue fanoutQueue1(){
        return new Queue("fanout.queue1");
    }
    @Bean
    public Queue fanoutQueue2(){
        return new Queue("fanout.queue2");
    }
    //绑定队列到交换机
    @Bean
    public Binding fanoutBinding1(FanoutExchange fanoutExchange, Queue fanoutQueue1){
        return BindingBuilder.bind(fanoutQueue1).to(fanoutExchange);
    }
    //绑定队列到交换机
    @Bean
    public Binding fanoutBinding2(FanoutExchange fanoutExchange, Queue fanoutQueue2){
        return BindingBuilder.bind(fanoutQueue2).to(fanoutExchange);
    }
}

这么麻烦,后续肯定会用注解之类的更简便的方式啊

可以在管理端查看到交换机的状况

image-20240220205915880

  1. 发送和接受信息

    1. publisher测试类发送信息

      @Test
      public void testSendFanoutExchange(){
          //交换机名称	
          String exchangeName = "fanout.exchange";
          //消息
          String message = "hello,every one!";
          //发送消息
          rabbitTemplate.convertAndSend(exchangeName,"",message)//第一个参数指定交换机名称,第二个参数是routing key,后续会学习
      }
      
    2. consumer编写两个监听类来监听fanout.queue1和fanout.queue2

      @Component
      public class SpringRabbitListener {
          @RabbitListener(queues = "fanout.queue1")
          public void listenSimpleQueue(String message){
              System.out.println("接收到消息:【" + message + "】");
          }
          @RabbitListener(queues = "fanout.queue2")
          public void listenSimpleQueue(String message){
              System.out.println("接收到消息:【" + message + "】");
          }
      }
      
Direct路由模式

Direct Exchange会将接受到的消息根据规则路由到指定的Queue

  • 每一个Queue斗鱼Exchange设置一个bindingKey
  • 发布者发送消息的时候指定消息的bindingkey
  • exchange将消息路由到与消息bindingKey一致的队列
  • 一个队列也可以指定多个bindingkey,且不同队列key可以相同,因此路由模式也可以模拟广播模式

image-20240220215234693

案例

消费者监听类方法

	
@Component
public class SpringRabbitListener {
    @RabbitListener(
        bindings = @QueueBinding(
            value = @Queue(name = "direct.queue1"),
            exchange = @Exchange(
                name = "direct.exchange",
                type = ExchangeTypes.DIRECT), //exchange默认类型就是direct,因此可以不用指明
            key = {"red","blue"}
        ))
    public void listenDirectQueue(String message){
        System.out.println("direct.queue1接收到消息:【" + message + "】");
    }
}

发布者测试方法

    @Test
    public void testDirectQueue(){
        String exchangeName = "direct.exchange";
        String routingKey = "red";
        String message = "hello, direct exchange!";
        rabbitTemplate.convertAndSend(exchangeName, routingKey, message);
    }
Topic主题模式

与DirectExchange类似,但routingKey必须是多个单词的列表,并且以.分隔

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

:代指0或多个单词

*:代指一个单词

image-20240220222208666

案例

消费者

    @RabbitListener(
        bindings = @QueueBinding(
            value = @Queue(name = "topic.queue1"),
            exchange = @Exchange(
                name = "topic.exchange",
                type = ExchangeTypes.TOPIC),
            key = {"china.*"}
        ))
    public void listenTopicQueue1(String message){
        System.out.println("topic.queue1接收到消息:【" + message + "】");
    }
    @RabbitListener(
        bindings = @QueueBinding(
            value = @Queue(name = "topic.queue2"),
            exchange = @Exchange(
                name = "topic.exchange",
                type = ExchangeTypes.TOPIC),
            key = {"#.news"}
        ))
    public void listenTopicQueue2(String message){
        System.out.println("topic.queue2接收到消息:【" + message + "】");
    }

发布者

    @Test
    public void testTopicQueue(){
        String exchangeName = "topic.exchange";
        String routingKey = "china.news";
        String message = "hello, topic exchange!";
        rabbitTemplate.convertAndSend(exchangeName, routingKey, message);
    }

消息转换器

案例

新建队列用于测试

//FanoutConfig.java

	//objectQueue用于测试消息转换器
    @Bean
    public Queue objectQueue(){
        return new Queue("object.queue");
    }

发送消息

    //测试消息转换器
    @Test
    public void testSendObjectQueue(){
        Map<String, Object> map = new HashMap<>();
        map.put("name", "张三");
        map.put("age", 23);
        rabbitTemplate.convertAndSend("object.queue", map);
    }

发送的消息在管理端查看是序列化后的java对象

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

然而默认方式存在序列化后长度过长,不安全存在注入风险,传输速度慢等缺点

因此这里推荐使用JSON方式序列化

首先在发布者那边修改

  1. 在publisher引入依赖

            <dependency>
                <groupId>com.fasterxml.jackson.core</groupId>
                <artifactId>jackson-databind</artifactId>
            </dependency>
    
  2. 启动类设置消息转换器

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

消费者那边同理

注意发布者和消费者双方要使用相同的Converter

Day05 2024.02.21 分布式搜索

初识elasticsearch(ES)

了解ES

elasticsearch是一款非常强大的开源搜索引擎,可以帮助我们从海量数据中快速找到需要的内容

image-20240221155333645

此外elasticsearch结合kibana,logstash,beats,也就是elastic stack(ELK),被广泛应用在日志数据分析,实时监控等领域

image-20240221155653829

elasticsearch是elastic stack的核心,负责存储,搜索和分析数据

image-20240221155734361

elasticsearch发展

elasticsearch的核心是lucene,lucene是一个java语言的搜索引擎类库,是Apache公司的顶级项目

Lucene优势

  • 易扩展
  • 高性能(基于倒排索引)

Lucene缺点

  • 只限于Java语言开发
  • 学习曲线陡峭
  • 不支持水平扩展

而后出现的es,相对lucene具备如下优势

  • 支持分布式,可水平扩展
  • 提供Restful接口,可被任意语言调用

当然不止有elasticsearch这一个搜索引擎

  • Elasticsearch:开源的分布式搜索引擎
  • Splunk:商业项目
  • Solr:Apache开源搜索引擎

倒排索引

传统数据库(如MySQL)采用正向索引,例如给下表(tb_goods)中的id创建索引

如果用id查询速度会很快,但如果想要模糊查找包含的数据,只能逐条搜索,这样创建的索引就失去了意义,效率低下

image-20240221162617604

而elasticsearch采用倒排索引

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

所谓倒排索引,就是先对文档内容分词,对词条创建索引,并记录词条所在文档的信息,查询的时候先根据词条查询到文档id,然后根据id获取文档

虽然查询了两遍数据库,但每次都是走的索引查询,因此效率会更高

image-20240221163536327

es一些概念

文档

elasticsearch是面向文档存储的,可以是数据库中的一条商品数据,一个订单信息等

文档数据会被序列化为json格式存储在es中

索引
  • 索引(index):相同类型的文档的集合
  • 映射(mapping):索引中文档的字段约束信息,类似表的结构约束

image-20240221164625686

mysql与elasticsearch对比
概念对比

image-20240221170329110

DSL相对于SQL语句通过http请求就可以发送,实现了不同语言通用

架构对比
  • Mysql:擅长时务类型操作,可以确保数据的安全和一致性
  • ElasticSearch:擅长海量数据的搜索,分析,计算

因此两个数据库是互补的关系,将来设计系统架构的时候两个数据库都会用到

image-20240221171057676

安装es和kibana

  1. 创建网络

    因为还需要部署kibana容器,因此需要让es和kibana容器互联,这里先创建一个网络

    docker network create es-net
    
  2. 加载镜像

    由于镜像有1个G大小,这里使用课程资料提供的tar包加载

    #加载镜像
    cd /tmp
    docker load -i es.tar
    

    kibana包同理

  3. 运行容器

    1. 部署单点es
    docker run -d \
    	--name es \
    	-e "ES_JAVA_OPTS=-Xms512m -Xmx512m" \ #配置运行内存大小,由于es对内存消耗比较大,这里配置512MB
    	-e "discovery.type=single-node" #单点配置
    	-v es-data:/usr/share/elasticsearch/data \ #挂载数据目录
    	-v es-plugins:/usr/share/elasticsearch/plugins \ #挂载插件目录
    	--privileged \ #给予逻辑卷(容器内数据卷挂载的位置)访问权
    	--network es-net \ #加入到之前创建的网络
        -p 9200:9200 \ #http端口,供用户访问
        -p 9300:9300 \ #es各个节点互联的端口,暂时用不到
    elasticsearch:7.12.1 
    

    image-20240221213302293

    访问9200端口返回下面json信息即为成功

    image-20240221221331391

    1. 部署kibana
    docker run -d \
    	--name kibana \
    	-e ELASTICSEARCH_HOSTS=http://es:9200 \
    	--network es-net \
    	-p 5601:5601 \
    kibana:7.12.1 #kibana版本一定要和es保持一致
    

    image-20240221221829588

    image-20240221221859447

    进入dev tools可以编辑DSL语句并发送给es

    这里是查询所有记录(没想到只用了不到100ms,明明自己就是个轻薄本来着,不过虚拟机2g内存已经占用将近80%了)

    image-20240221222204965

分词器

默认分词器

es在创建倒排索引时需要对文档分词,搜索时需要对用户输入分词,但默认分词规则对中文处理并不友好

例如用中文测试

POST /_analyze # 对分词结果进行分析
{
	"analyzer": "standard", #es默认分词器		
	"text": "趁现在红利学java,狠狠赚一笔"
}

image-20240221223809447

如果用英文分词器的方法中文汉字只能一个一个分隔

即便使用"chinese"分词也是一样的结果

如果想要分词中文,需要使用第三方分词器

这里使用IK分词器(IKUN分词器)

安装ik分词器插件

这里直接用教程资料离线安装

  1. 查看插件数据卷所在位置

    docker volume inspect es-plugins
    

    image-20240221224402431

  2. 将资料中的ik分词器文件上传到对应目录

    image-20240221224840631

  3. 重启es

    docker restart es
    

    查看日志可以看到成功启用ik分词器插件

    image-20240221225034330

  4. 测试

    ik分词器包括两种模式

    • ik_smart:最少切分,分词较少,词汇长度较长

      占用内存更少,查询效率更高,但被搜索到的概率较低(比如只搜程序的话搜不到这条文档)

      image-20240221225321434

    • ik_max_word:最细切分

      相较上一个,会将每个词再细分一遍,比如分出程序员以外,还分出了程序两个词汇

      占用内存更多,但被搜索到的概率也更高

      image-20240221225400431

分词器拓展与字典
分词器的局限

分词器是如何进行分词的?我们推测应该是内部有一个字典,如果词汇与字典中的匹配,则分出成为一个词

但这样就必定会存在时效性问题,比如近年来网络热词奥利给就分不出来了

还有分词器会将这样没有实际意义的介词分出来,空占用存储空间

还有一些敏感词汇过滤等...

这些更细致的需求可以通过拓展实现

image-20240221230556449

高级配置

可以修改一个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>
</properties>

名为ext.dic的拓展词典

传智教育
白嫖
奥利给

stopword.dic

的
了
哦
啊
嘤嘤嘤
*** (敏感词汇)

当然ik分词器还有更多高级用法,比如远程热更新字典等,自行探索吧

索引库制作

mapping映射属性

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

  • type:字段数据类型

    • 字符串:text(可分词文本),keyword(精确值,例如品牌,国家,ip地址等不可拆分的词)
    • 数值:long,integer,short,byte,double,float
    • 布尔:boolean
    • 日期:date
    • 对象:object

    数组不是单独一个数据类型,es允许多个字段放在一起,也就是数组了

  • index: 事后创建索引,默认为true(如果是url地址之类的没有必要参与搜索,就把index设置为false了)

  • analyzer:使用哪种分词器(只有text类型才会用到)

  • properties:该字段的子字段,附加的属性

索引库crud

创建索引库
PUT /heima  								# 索引库名称
{
	"mappings":{
		"properties":{
			"info":{						# 字段名1
				"type":"text",
				"analyzer":"ik_smart"
			},
			"email":{						# 字段名2
				"type":"keyword",
				"index":"false"				#email作为一个整体没有必要分词,而且也没有必要参与搜索 
			},
			"name":{
				"properties":{
					"firstName":{			# 子字段	
						"type":"keyword"
					}
				}
			},
		}
	}
}

在浏览器实际操作一下

image-20240223153012559

查看删除修改索引库

查看

GET /heima #索引库名

删除

DELETE /heima

修改

索引库和mapping一旦创建就无法修改,因为会破坏原有索引,性能大打折扣,但是可以添加新的字段

PUT /数据库名/_mapping
{
	"properties":{
		"新字段名":{
			"type":"integer"
		}
	}
}

image-20240223153858682

文档操作

添加文档

POST /heima/_doc/1    #1代表文档id,不指定的话会自动生成
{
	"info":"黑马程序员Java讲师",			#指定mapping配置好的每个字段的值
	"email":"zt@itcast.cn",
	"name":{
	"firstName":"云",
	"lastName":"赵"
	}
}

image-20240223154842363

查询文档

GET /heima/_doc/1

image-20240223155936539

其中version=1表示修改次数(是根据id来统计修改次数的,所以就算删除了,version值还是会正常加1)

删除文档

DELETE /heima/_doc/1

修改文档

  1. 方式1:全量修改,会删除旧的文档,添加新文档

    其实和增加写法一样,如果id不存在就是新增了

PUT /heima/_doc/1
  1. 方式2:局部修改,修改指定字段名
POST /heima/_update/1
{
	"doc":{
		"email":"zy@itcast.cn"
	}
}

RestAPI

什么是RestClient

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

我们用的事Java REST Client,可以将java代码转成dsl发送

IDEA连接操作es数据源(可选)

image-20240229222015524

与连接mysql操作方法一样

需要注意es默认用户名和密码分别是elasticchangeme

然后需要将驱动配置成和使用的elastic-search一样的版本(例如这里是7.12.1)

如果没有找到对应版本驱动,需要到maven仓库下载到本地,然后手动在idea导入

es7.12.1版本驱动下载地址

image-20240229235143705

如果连接仍然有问题,可以和这个教程仔细比对

在idea连接es(其中安装es插件和设置证书个人感觉是没有必要的,用正常账号密码的方式也能登录,关键是驱动版本要对应的上)

(自己折腾了好久,最后发现没有仔细看教程...)

image-20240301001307666

没错,可以用sql的方式来操作es

不过sql功能貌似并不完善,比如直接双击索引库显示数据会报指令错误,意义不大

案例:利用javaRestClient实现索引库操作

导入环境

在idea打开课程资料的hotel-demo项目,导入sql建表,这里推荐新建一个hotel_demo的数据库

image-20240223163508990

创建索引库
  1. 分析数据结构

    mapping需要考虑的问题:

    字段名/数据类型/是否参与搜索/是否分词/如果分词分词器如何选择...

  2. 编写mapping映射

    PUT /hotel
    {
      "mappings": {
        "properties": {
          "id": {
            "type": "keyword" # 在es中id都是以字符串形式存储的,因为不参与分词,所以类型是keyword
          },
          "name": {
            "type": "text",
            "analyzer": "ik_max_word"
            }
          },
          "address": {
            "type": "keyword",  #因为一般搜酒店没有直接根据地址去搜的,因此不参与索引  
            "index":false
          },
          "price": {
            "type": "integer"
          },
          "score": {
            "type": "integer"
          },
          "brand": {
            "type": "keyword"
          },
          "city": {
            "type": "keyword"
          },
          "starName": {
            "type": "keyword"
          },
          "business": {
            "type": "keyword"
          },
          "location":{
            "type":"geo_point" #经纬度坐标在es有特殊存法,详见下面
          }
          "pic": {
            "type": "keyword",
            "index":false
          }
        }
      }
    }
    
    

    ℹ️ es中支持两种地理坐标数据类型

    • geo_point:由经纬度确定的一个点
    • geo_shape,由多个geo_point组成的复杂几何图形

    ❓es搜索的时候往往需要查询多个字段,性能比较浪费,如何仅通过查找一个字段实现同时查找所有字段?

    -> 可以使用字段拷贝,使用copy_to属性将当前字段拷贝到指定字段,示例

    "all":{
        "type":"text",
        "analyzer":"ik_max_word"
    },
    "brand":{
        "type":"keyword",
        "copy_to":"all"
    }
    

    这种拷贝并不是真正将内容拷贝进去,而是基于拷贝字段整体创建一个新的倒排索引(可以理解成一个视图)

    改造后的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"
          }
        }
      }
    }
    
配置javaRestClient
  1. 引入依赖

    // hotel-demo的pom文件
    <!--        elastic search-->
            <dependency>
                <groupId>org.elasticsearch.client</groupId>
                <artifactId>elasticsearch-rest-high-level-client</artifactId>
            </dependency>
    

    ❗ 这里引入 javarestClient依赖之后相关依赖版本由spring-boot-starter管理,而这里父工程定义的es版本是7.6.2而不是我们装在虚拟机上面的7.12.1,因此我们需要在自己的父工程上定义es版本来覆盖spring父工程的配置(即和上面同一个pom文件)

        <properties>	
            <elasticsearch.version>7.12.1</elasticsearch.version>
        </properties>
    

    image-20240225142020232

  2. 功能测试

新建一个测试类

// cn/itcast/hotel/HotelIndexTest.java
@SpringBootTest
public class HotelIndexTest {
    private RestHighLevelClient client;
    //客户端初始化
    @BeforeEach
    void setUp(){
        this.client = new RestHighLevelClient(RestClient.builder(
            //也可以指定多个地址集群使用
            HttpHost.create("http://192.168.181.100:9200")
        ));
    }
    //测试完成后统一客户端销毁
    @AfterEach
    void tearDown() throws Exception{
        this.client.close();
    }
    @Test
    public void testInit(){
        System.out.println(client);
    }
}
f

运行测试(记得开启虚拟机里面的es容器)(如果虚拟机挂起后再恢复,无法访问容器,记得看第三天docker部分关于这个问题的解决方案(配置ipv4链接))

image-20240225143105447

利用restclient实现索引库操作
创建索引库

image-20240225143617485

     @Test
    public void testCreateHotelIndex() throws IOException {
        //1.创建索引请求
        CreateIndexRequest request = new CreateIndexRequest("hotel");
        //2.准备请求参数
        request.source(HotelConstants.MAPPING_TEMPLATE, XContentType.JSON);
        //3.客户端发送请求
        //indices方法返回索引库操作对象,里面包括了索引库的所有操作方法
        client.indices().create(request, RequestOptions.DEFAULT);
    }
//将DSL封装成一个常量统一存放
//constants.HotelConstants.java
public class HotelConstants {
    public static final String MAPPING_TEMPLATE =
        "{\n" +
            "  \"mappings\": {\n" +
            "    \"properties\": {\n" +
            "      \"id\": {\n" +
            "        \"type\": \"keyword\"\n" +
            "      },\n" +
            "      \"name\": {\n" +
            "        \"type\": \"text\",\n" +
            "        \"analyzer\": \"ik_max_word\"\n" +
            "      },\n" +
            "      \"address\": {\n" +
            "        \"type\": \"keyword\",\n" +
            "        \"index\": false\n" +
            "      },\n" +
            "      \"price\": {\n" +
            "        \"type\": \"integer\"\n" +
            "      },\n" +
            "      \"score\": {\n" +
            "        \"type\": \"integer\"\n" +
            "      },\n" +
            "      \"brand\": {\n" +
            "        \"type\": \"keyword\"\n" +
            "      },\n" +
            "      \"city\": {\n" +
            "        \"type\": \"keyword\"\n" +
            "      },\n" +
            "      \"starName\": {\n" +
            "        \"type\": \"keyword\"\n" +
            "      },\n" +
            "      \"business\": {\n" +
            "        \"type\": \"keyword\"\n" +
            "      },\n" +
            "      \"location\": {\n" +
            "        \"type\": \"geo_point\"\n" +
            "      },\n" +
            "      \"pic\": {\n" +
            "        \"type\": \"keyword\",\n" +
            "        \"index\": false\n" +
            "      }\n" +
            "    }\n" +
            "  }\n" +
            "}";
}

❗ 上面的createIndexRequest方法导入的是org.elasticsearch.client.indices.CreateIndexRequest这个类

在kibana上查看创建的索引库,测试成功

image-20240225145854510

删除索引库,判断索引库是否存在
    @Test
    public void testDeleteHotelIndex() throws IOException {
        //1.创建删除索引请求
        DeleteIndexRequest request = new DeleteIndexRequest("hotel");
        //2.客户端发送请求
        client.indices().delete(request, RequestOptions.DEFAULT);
    }
    @Test
    public void testIfExistHotelIndex() throws IOException {
        //1.创建删除索引请求
        GetIndexRequest request = new GetIndexRequest("hotel");
        //2.客户端发送请求
        boolean isExists = client.indices().exists(request, RequestOptions.DEFAULT);
        System.out.println(isExists);
    }
利用restclient实现文档crud
新增酒店数据

由于创建索引库的时候字段名和数据库中的并不完全一致(比如经纬度),因此需要另创建一个实体类用来表示文档对象(可以理解为一个DTO)

初始工程已经提供好了名为HotelDoc的实体类

    @Test
    public void testIndexDocument() throws IOException {
        //准备实体类数据(从数据库获取)
        Hotel hotel = hotelService.getById(36934);
        //1.创建索引请求
        IndexRequest request = new IndexRequest("hotel").id(hotel.getId().toString());
        //2.准备Json文档
        HotelDoc hotelDoc = new HotelDoc(hotel);//这里是老师自定义的一个构造方法,其中将经纬度转换成location
        request.source(JSON.toJSONString(hotelDoc),XContentType.JSON);
        //3.客户端发送请求
        client.index(request,RequestOptions.DEFAULT);
    }

image-20240227223117771

可以看到在es成功获取到了添加的数据

image-20240227223336656

根据id查询酒店数据
    @Test
    public void testGetDocById() throws IOException {
        //1.创建索引请求
        GetRequest request = new GetRequest("hotel", "36934");
        //2.客户端发送请求
        GetResponse response = client.get(request, RequestOptions.DEFAULT);
        //3.解析响应
        System.out.println(response.getSourceAsString());
    }

image-20240227224032522

根据id修改和删除酒店数据
//修改(局部修改)
@Test
    public void testUpdatePartlyById() throws IOException {
        //局部更新操作
        UpdateRequest request = new UpdateRequest("hotel", "36934");
        //准备修改的参数
        request.doc("price", 1000,
                            "score", 5);
        //客户端发送请求
        client.update(request, RequestOptions.DEFAULT);
    }

❗注意doc传入键值对都是用逗号隔开的

//删除
    @Test
    public void testDeleteRequest() throws IOException {
        DeleteRequest request = new DeleteRequest("hotel", "36934");
        client.delete(request,RequestOptions.DEFAULT);
    }
批量导入酒店数据到es

利用javaRestClient的bulk批处理可以实现批量新增文档,也即将多个request合并成一个request请求一次性提交

    @Test
    public void testBulkRequest(){
        //从数据库批量查询酒店数据
        List<Hotel> hotels = hotelService.list();
        //批量操作请求
        BulkRequest request = new BulkRequest();
        //利用lambda表达式实现批量转doc对象并添加到request中
        hotels.stream().map(HotelDoc::new).forEach(hotelDoc -> {
            request.add(new IndexRequest("hotel").id(hotelDoc.getId().toString()).source(JSON.toJSONString(hotelDoc),XContentType.JSON));
        });
        //客户端发送请求
        try {
            client.bulk(request, RequestOptions.DEFAULT);
        } catch (IOException e) {
            e.printStackTrace();
        }	
    }

可以看到201条数据都被添加进去了

image-20240227233840500

Day06 2024.02.27 分布式搜索引擎

DSL查询文档

es提供了基于json的dsl来定义查询,常用类型包括

类型 作用 示例
查询所有 查询出所有数据 match_all
全文检索 利用分词器对用户输入内容粉刺,倒排索引匹配 match_query/multi_match_query
精确查询 根据词条值查找数据,一般是查找keyword,数值,日期等不需要分词字段 ids,range,term
地理查询 根据经纬度查询 geo_distance/geo_bounding_box
符合查询 将上述查询条件合并组合 bool/function_score

例如match_all查询

GET /hotel/_search
{
  "query": {
    "match_all": {}
  }
}	

全文检索查询

  • match查询,填写要查询的字段和匹配的内容

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

    老师演示的all字段是之前有一次提到过的copy_to方法,不过自己可以确定老师当时创建索引库的时候并没有设置all字段,因此如果没有设置,可以自行删除索引库再重新构建和导入

  • multi_match,可以匹配多个字段

    GET /hotel/_search
    {
      "query": {
        "multi_match": {
          "query": "外滩如家",
          "fields": ["brand","name","business"]
        }
      }
    }
    

    由于多个字段查询相对一个字段查询效率较低,因此推荐将所有字段拷贝到一个all字段上统一查询

精确查询

  • term:根据词条精确查询

    
    

    因为查询的是keyword,因此不会对输入的内容进行分词

    GET /hotel/_search
    {
      "query": {
        "term": {
          "city": {
            "value": "上海"
          }
        }
      }
    }
    
  • range:根据值的范围查询

    GET /hotel/_search
    {
      "query": {
        "range": {
          "price": {
            "gte": 1000,
            "lte": 2000
          }
        }
      }
    }
    
  • 地理查询

    按照范围查询

    GET /hotel/_search
    {
      "query": {
        "geo_distance":{
          "distance":"15km",
          "location":"31.21,121.5"
        }
      }
    }
    

    image-20240228092956776

复合查询

算分函数查询

function score 可以控制文档相关性算分,控制文档排名,例如百度竞价

  • 相关性算分

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

image-20240228121951883

通过BM25计算分数可以保证词频增加的时候分数不会无限增大,而是趋于稳定

image-20240228122019256

image-20240228121449360

  • function score query

使用这种查询可以人为修改相关性算分,得到新的算分排序,记得将索引库改成带有all字段的版本

GET /hotel/_search
{
  "query": {
    "function_score": {
      "query": {"match": {
        "all": "外滩"
      }},
      "functions": [
        {
          "filter": {"term":{"id":"1"}},
          "weight":10
        }
      ],
      "boost_mode": "multiply"
    }
  }
}

image-20240228123310597

例如给如家品牌酒店排名靠前一些

可以考虑function score的三要素

  1. 需要加权的文档:品牌 = 如家的
  2. 算分函数:简单的weight即可
  3. 加权模式:求和即可

先来看看正常排名

GET /hotel/_search
{
  "query": {
    "match": {
      "all": "上海外滩"
    }
  }
}

image-20240228124317346

然后来看看加权后的排名

GET /hotel/_search
{
  "query":{
    "function_score": {
      "query": {
        "match": {
          "all": "上海外滩"
        }
      },
      "functions": [
        {
          "filter": {
            "term": {
              "brand": "如家"
            }
          }
          , "weight": 10 
        }
      ],
      "boost_mode": "sum"
    }
    
  }
}

可以看到原来排名第一的君悦酒店已经排到第三了

image-20240228124427274

布尔查询

布尔查询是一个或多个查询子句的组合

语句 作用
must 必须匹配每个子查询
should 匹配其中一个
must_not 每一个都不匹配,不参与算分
filter 必须匹配,不参与算分

🔔因为must_not和filter不参与算分,因此查询效率会有所提升,因此除了需要进行分词匹配算分排序的字段必须用must和should以外,其余关键字字段都应该使用不参与算分的语句

需求:搜索名字包含"如家",价格不高于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": "31.21,121.5"
          }
        }
      ]
    }
  }
}

可以看到返回了三个结果

image-20240228130854050

搜索结果处理

排序

es默认按照相关度算分(_score)来排序,可以根据各种类型来排序

案例:对酒店数据按照用户评价降序排序,评价相同按照价格升序,如果价格相同按照距离自己位置远近排序

#sort排序
GET /hotel/_search
{
  "query": {
    "match_all": {}
  },
  "sort": [
    {
      "score": {
        "order": "desc"
      },
      "price": {
        "order": "asc"
      },
      "_geo_distance":{
      	"location":{
      		"lat":31,
      		"lon":121
      	},
      	örder:"asc",
      	"unit":"km"
      }
    }
  ]
}

当使用排序之后,查询显示的_score项就会为null,因为不再需要相对算分来排序词条相关度了,只根据_sort得分来排序就可以了

分页

es默认情况下只返回top10的数据,如果要查询更多参数需要修改分页参数

#分页查询
GET /hotel/_search
{
  "query": {
    "match_all": {}
  },
  "sort": [
    {
      "price": "asc"
    }
  ],
    "from":0,
    "size":10
}

由于es底层采用倒排索引,因此并不能像mysql一样方便分页,如果要分页查询比如第500到1000条数据,只能先查询1000条数据再从中截取

深度分页问题:es是分布式idea,例如按price排序获取从990到1000的数据,es由于集群部署,并不知道总排序前1000条是多少,只能从每台es上分别获取前1000条数据,然后整合在一起选出前1000条数据

image-20240228164550824

如果搜索的页数过深,或者结果集(from+size)过大,对内存和CPU的消耗也越高,因此es设定结果集查询的上限是10000

而比如知名搜索引擎在业务上就对查询结果数量做了限制,比如百度限制用户一次查询最多76页数据

深度分页的解决方案

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

  1. search after : 分页的时候需要排序,原理是从上一次排序值开始,查询下一页数据,官方推荐使用,比如根据价格排序,先查出前十条,再查第二页的时候会记住第十条数据的价格值,作为筛选条件查出这个价格之后的另10条数据,但是只能向后翻页,不支持随机翻页
  2. scroll,原理是将排序数据全存在内存里面形成快照,分页的时候从内存里面取,内存占用大,而且如果数据更新的话还要同步更新快照,官方不推荐使用

image-20240228171002942

高亮

就是把搜索中的关键字突出出来

从源码中可以看到高亮的java都用em标签包裹起来,藉由前端的em类型选择器来实现样式的高亮,也即

  1. 将搜索结果中的关键字用标签标记出来
  2. 再页面中给标签添加css样式

image-20240228171100048

案例:搜索与如家有关的酒店,然后将名字中的部分高亮

默认es搜索字段和高亮字段必须保持一致,而我们这里用的是all字段搜索,因此可以指定require_field_match属性为false来单独高亮某一个字段

image-20240228171847738

RestClient查询文档

快速入门

    @Test
    public void testMatchAll() throws IOException {
        //创建查询请求
        SearchRequest request = new SearchRequest("hotel");
        //准备请求DSL
        request.source().query(QueryBuilders.matchAllQuery());
        //客户端发送请求
        SearchResponse response = client.search(request, RequestOptions.DEFAULT);
        System.out.println(response);
        //解析结果
        SearchHits searchHits = response.getHits();
        //查询总条数
        long totalHits = searchHits.getTotalHits().value;
        //查询结果数组
        SearchHit[] hits = searchHits.getHits();
        for (SearchHit hit : hits) {
            System.out.println(hit.getSourceAsString());
            HotelDoc hotelDoc = JSON.parseObject(hit.getSourceAsString(), HotelDoc.class);
            System.out.println(hotelDoc);
        }
    }

❗ 注意query传入的参数要用org.elasticsearch.index.query.QueryBuilders这个构造器

打印出来的response内容就是返回的json数据,通过json解析工具即可处理

match查询

全文检索查询

全文检索match和multi_match查询的api基本一致,差别是查询条件,也就是query部分

只需要在上述代码基础上做出下面调整即可

        //准备请求DSL
        request.source().query(QueryBuilders.matchQuery("all","如家"));

可以使用ctrl+alt+m抽取解析reponse部分的代码

精确查询

QueryBuilers.termQuery("city","杭州")

复合查询

QueryBuilders.rangeQuery("price".gte(100).lte(150)

排序分页高亮

    @Test
    public void testPageAndSort() throws IOException {
        //创建查询请求
        SearchRequest request = new SearchRequest("hotel");
        //准备请求DSL
        request.source().query(QueryBuilders.matchAllQuery());
        //分页
        request.source().from(0).size(2);
        //排序
        request.source().sort("price", SortOrder.ASC);
        //客户端发送请求
        SearchResponse response = client.search(request, RequestOptions.DEFAULT);
        System.out.println(response);
        handleResponse(response);
    }

其中因为高亮后的结果被单独存放在了"highlight"字段上,因此不能使用原来的结果解析方法

image-20240228235927358

因此按照如下改造handleResponse代码

    private static void handleResponse(SearchResponse response) {
        //解析结果
        SearchHits searchHits = response.getHits();
        //查询总条数
        long totalHits = searchHits.getTotalHits().value;
        //查询结果数组
        SearchHit[] hits = searchHits.getHits();
        for (SearchHit hit : hits) {
            System.out.println(hit.getSourceAsString());
            HotelDoc hotelDoc = JSON.parseObject(hit.getSourceAsString(), HotelDoc.class);
            //获取高亮字段
            Map<String, HighlightField> highlightFields = hit.getHighlightFields();
            if (!CollectionUtils.isEmpty(highlightFields)){
                HighlightField highlightField = highlightFields.get("name");
                if (highlightField != null) {
                    String name = highlightField.getFragments()[0].string();
                    hotelDoc.setName(name);
                }
            }
        }
    }

黑马旅游案例

实现酒店搜索功能呢,完成关键字搜索和分页

  1. 定义实体类,接受前端请求
//pojo.RequestParams.java
@Data
public class RequestParams {
    private String key;
    private Integer page;
    private Integer size;
    private String sortBy;

}
  1. 定义controller接口,接受页面请求,调用IHotelService的search方法

    • 请求方式:post

    • 请求路径/hotel/list

    • 请求参数:对象,类型为RequestParam

    • 返回值:PageResult,包含总条数和酒店数据List

      //定义pageResult类存储分页数据
      @Data
      public class PageResult {
          private Long total;
          private List<HotelDoc> hotels;
      
      }
      
      //在启动类上注入restclient
          //配置es客户端
          @Bean
          public RestHighLevelClient restHighLevelClient(){
              return new RestHighLevelClient(RestClient.builder(
                  HttpHost.create("http://fishlulu01:9200")
              ));
          }
      
      //web.HotelController
      @RestController
      @RequestMapping("/hotel")
      public class HotelController {
      
          @Autowired
          private IHotelService hotelService;
          @PostMapping("/list")
          public PageResult list(@RequestBody RequestParams requestParams){
              //调用service查询
              return hotelService.search(requestParams);
          }
      }
      
      //IHotelService
      public interface IHotelService extends IService<Hotel> {
      
          PageResult search(RequestParams requestParams);
      }
      
      //HotelService(实现类)
      @Service
      public class HotelService extends ServiceImpl<HotelMapper, Hotel> implements IHotelService {
          @Autowired
          private RestHighLevelClient client;
      
          /**
           * 通过ES查询酒店数据
           * @param requestParams
           * @return
           */
          @Override
          public PageResult search(RequestParams requestParams){
              try {
                  //创建查询请求
                  SearchRequest request = new SearchRequest("hotel");
                  //准备请求DSL
                  //全文关键字检索
                  String key = requestParams.getKey();
                  if (key.isEmpty())
                  {
                      request.source().query(QueryBuilders.matchAllQuery());
                  }
                  else
                  {
                      request.source().query(QueryBuilders.matchQuery("all", key));
                  }
                  //分页
                  int page = requestParams.getPage();
                  int size = requestParams.getSize();
                  request.source().from((page - 1) * size).size(size);
                  //客户端发送请求
                  SearchResponse response = client.search(request, RequestOptions.DEFAULT);
                  //解析相应
                  return handleResponse(response);
      
              } catch (IOException e) {
                  throw new RuntimeException(e);
              }
          }
          private static PageResult handleResponse(SearchResponse response) {
              //解析结果
              SearchHits searchHits = response.getHits();
              //查询总条数
              long totalHits = searchHits.getTotalHits().value;
              //将查询结果封装到PageResult
              return PageResult.builder().total(totalHits).hotels(Arrays.stream(searchHits.getHits()).map(
                  hit ->{
                      //将查询结果转换为HotelDoc对象
                      return JSON.parseObject(hit.getSourceAsString(), HotelDoc.class);
                  }
              ).collect(Collectors.toList())).build();
          }
      }
      

      测试成功

      image-20240229005343376

  2. 定义IhotelService中的search方法,利用match查询实现根据关键字搜索酒店信息

添加品牌,城市,星级,价格等过滤功能

  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 city;
        private String starName;
        private Integer maxPrice;
        private Integer minPrice;
    }
    
  2. 修改search方法实现,在关键字搜索时,如果brand等参数存在,进行过滤

    多个过滤条件是and关系,因此要用booleanQuery

    参数存在才能过滤,做好非空判断

    @Service
    public class HotelService extends ServiceImpl<HotelMapper, Hotel> implements IHotelService {
        @Autowired
        private RestHighLevelClient client;
    
        /**
         * 通过ES查询酒店数据
         * @param requestParams
         * @return
         */
        @Override
        public PageResult search(RequestParams requestParams){
            try {
                //创建查询请求
                SearchRequest request = new SearchRequest("hotel");
                //准备请求DSL
                BoolQueryBuilder boolQuery = buildBasicQuery(requestParams);
                request.source().query(boolQuery);
                //分页
                int page = requestParams.getPage();
                int size = requestParams.getSize();
                request.source().from((page - 1) * size).size(size);
                //客户端发送请求
                SearchResponse response = client.search(request, RequestOptions.DEFAULT);
                //解析相应
                return handleResponse(response);
    
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }
    
        private static BoolQueryBuilder buildBasicQuery(RequestParams requestParams) {
            //构建查询条件
            BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
            //关键字检索(must部分)
            String key = requestParams.getKey();
            if (StringUtils.isEmpty(key))
            {
                boolQuery.must(QueryBuilders.matchAllQuery());
            }
            else
            {
                boolQuery.must(QueryBuilders.matchQuery("all", key));
            }
            //过滤条件(filter部分)
            //城市
            String city = requestParams.getCity();
            if (!StringUtils.isEmpty(city))
            {
                boolQuery.filter(QueryBuilders.termQuery("city", city));
            }
            //品牌
            String brand = requestParams.getBrand();
            if (!StringUtils.isEmpty(brand))
            {
                boolQuery.filter(QueryBuilders.termQuery("brand", brand));
            }
            //星级
            String star = requestParams.getStarName();
            if (!StringUtils.isEmpty(star))
            {
                boolQuery.filter(QueryBuilders.termQuery("starName", star));
            }
            //价格
            Integer minPrice = requestParams.getMinPrice();
            Integer maxPrice = requestParams.getMaxPrice();
            if(minPrice != null && maxPrice != null)
            {
                boolQuery.filter(QueryBuilders.rangeQuery("price").gte(minPrice).lte(maxPrice));
            }
            return boolQuery;
        }
    
        private static PageResult handleResponse(SearchResponse response) {
            //解析结果
            SearchHits searchHits = response.getHits();
            //查询总条数
            long totalHits = searchHits.getTotalHits().value;
            //将查询结果封装到PageResult
            return PageResult.builder().total(totalHits).hotels(Arrays.stream(searchHits.getHits()).map(
                hit ->{
                    //将查询结果转换为HotelDoc对象
                    return JSON.parseObject(hit.getSourceAsString(), HotelDoc.class);
                }
            ).collect(Collectors.toList())).build();
        }
    }
    

    🔔老师前端在过滤四星和五星酒店的时候返回为空,是因为初始工程传入的数据是五星级四星级,而前端在发送请求的时候筛选的是四星五星,应该是老师那边的疏忽,这里就不用在意了

我附近的酒店

image-20240301002745055

  1. requestParam对象添加字段

        private String location;
    
  2. 在上述分页功能代码下面添加如下代码

                //添加地理排序
                String location = requestParams.getLocation();
                if (!StringUtils.isEmpty(location))
                {
                    request.source().sort(SortBuilders.geoDistanceSort("location",new GeoPoint(location)).order(SortOrder.ASC).unit(
                        DistanceUnit.KILOMETERS));
                }
    

    image-20240301003930210

可以看到前端的确传过来坐标数据了

不过奇怪的是,显示离我最近的酒店竟然在深圳(明明上海离北京比深圳更近的说),而且地图也没显示出我的位置,这个问题先放在一边

image-20240301004148293

  1. 解析结果返回距离数据

    按照如下改造handleResponse方法

        private static PageResult handleResponse(SearchResponse response) {
            //解析结果
            SearchHits searchHits = response.getHits();
            //查询总条数
            long totalHits = searchHits.getTotalHits().value;
            //将查询结果封装到PageResult
            return PageResult.builder().total(totalHits).hotels(Arrays.stream(searchHits.getHits()).map(
                hit ->{
                    //将查询结果反序列化为HotelDoc对象
                    HotelDoc hotelDoc = JSON.parseObject(hit.getSourceAsString(), HotelDoc.class);
                    //获取排序值
                    Object[] sortValues = hit.getSortValues();
                    if (sortValues != null && sortValues.length > 0)
                    {
                        hotelDoc.setDistance(sortValues[0]);
                    }
                    return hotelDoc;
                }
            ).collect(Collectors.toList())).build();
        }
    

    相应的,在hotelDoc对象添加字段

    private Object distance;
    

    如果调试的时候报错空指针异常无法调用hotel.getId(),因为hotel为null,记得在hoteldoc对象上添加@AllArgsConstructor注解,因为不添加这个注解的话解析json的时候默认用了自定义的构造器(里面需要传入hotel对象,显然为空)

    看到成功显示了距离

    image-20240301011558447

不过我明明在北京,为什么显示距离4000多公里啊,这缺德地图也太离谱了吧!

让指定酒店在搜索结果中排名置顶

可以给置顶的酒店添加标记,然后利用function score给带有标记的文档增加权重

  1. hoteldoc添加标记字段

        private boolean isAD;
    
  2. 挑选几个酒店设置字段为true

    POST /hotel/_update/1714520967
    {
      "doc": {
        "isAD":true
      }
    }
    
    POST /hotel/_update/396506
    {
      "doc": {
        "isAD":true
      }
    }
    
    
    POST /hotel/_update/2031683181
    {
      "doc": {
        "isAD":true
      }
    }
    
    POST /hotel/_update/396471
    {
      "doc": {
        "isAD":true
      }
    }
    
  3. 修改search方法,添加function score功能,给标记的酒店添加权重

    java代码示例

image-20240301230913969

​ 业务代码

@Service
public class HotelService extends ServiceImpl<HotelMapper, Hotel> implements IHotelService {
    @Autowired
    private RestHighLevelClient client;

    /**
     * 通过ES查询酒店数据
     * @param requestParams
     * @return
     */
    @Override
    public PageResult search(RequestParams requestParams){
        try {
            //创建查询请求
            SearchRequest request = new SearchRequest("hotel");
            //准备请求DSL
            buildBasicQuery(request,requestParams);
            //分页
            int page = requestParams.getPage();
            int size = requestParams.getSize();
            request.source().from((page - 1) * size).size(size);
            //添加地理排序
            String location = requestParams.getLocation();
            if (!StringUtils.isEmpty(location))
            {
                request.source().sort(SortBuilders.geoDistanceSort("location",new GeoPoint(location)).order(SortOrder.ASC).unit(
                    DistanceUnit.KILOMETERS));
            }
            //客户端发送请求
            SearchResponse response = client.search(request, RequestOptions.DEFAULT);
            //解析相应
            return handleResponse(response);

        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    private static void buildBasicQuery(SearchRequest request,RequestParams requestParams) {
        //构建查询条件
        BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
        //关键字检索(must部分)
        String key = requestParams.getKey();
        if (StringUtils.isEmpty(key))
        {
            boolQuery.must(QueryBuilders.matchAllQuery());
        }
        else
        {
            boolQuery.must(QueryBuilders.matchQuery("all", key));
        }
        //过滤条件(filter部分)
        //城市
        String city = requestParams.getCity();
        if (!StringUtils.isEmpty(city))
        {
            boolQuery.filter(QueryBuilders.termQuery("city", city));
        }
        //品牌
        String brand = requestParams.getBrand();
        if (!StringUtils.isEmpty(brand))
        {
            boolQuery.filter(QueryBuilders.termQuery("brand", brand));
        }
        //星级
        String star = requestParams.getStarName();
        if (!StringUtils.isEmpty(star))
        {
            boolQuery.filter(QueryBuilders.termQuery("starName", star));
        }
        //价格
        Integer minPrice = requestParams.getMinPrice();
        Integer maxPrice = requestParams.getMaxPrice();
        if(minPrice != null && maxPrice != null)
        {
            boolQuery.filter(QueryBuilders.rangeQuery("price").gte(minPrice).lte(maxPrice));
        }
        //算分控制
        //如果有isAD广告标记,算分加权
        FunctionScoreQueryBuilder functionScoreQuery = QueryBuilders.functionScoreQuery(
            //原始查询
            boolQuery,
            //function score查询数组
            new FilterFunctionBuilder[]{
                //一个算分函数,包括过滤条件和算分加权
                new FunctionScoreQueryBuilder.FilterFunctionBuilder(QueryBuilders.termQuery("isAD", true),
                    ScoreFunctionBuilders.weightFactorFunction(10))
            });
        request.source().query(functionScoreQuery);
    }

    private static PageResult handleResponse(SearchResponse response) {
        //解析结果
        SearchHits searchHits = response.getHits();
        //查询总条数
        long totalHits = searchHits.getTotalHits().value;
        if (totalHits == 0)
        {
            return PageResult.builder().total(0L).build();
        }
        //将查询结果封装到PageResult
        return PageResult.builder().total(totalHits).hotels(Arrays.stream(searchHits.getHits()).map(
            hit ->{
                //将查询结果反序列化为HotelDoc对象
                HotelDoc hotelDoc = JSON.parseObject(hit.getSourceAsString(), HotelDoc.class);
                //获取排序值
                Object[] sortValues = hit.getSortValues();
                if (sortValues != null && sortValues.length > 0)
                {
                    hotelDoc.setDistance(sortValues[0]);
                }
                return hotelDoc;
            }
        ).collect(Collectors.toList())).build();
    }
}

这里将构造查询的函数返回类型修改重构了,推荐将代码重新复制粘贴一下

image-20240301234124636

测试结果被广告标记的酒店的确置顶了,均分都在40多,而且返回ad字段为true,前端没有显示广告字眼是前端代码问题,不用担心

Day07 2024.03.01 深入es

数据聚合

📑聚合aggregations可以实现对文档数据的统计,分析,运算,类似mysql中求和,计数等聚合函数,聚合常见有三类

  • 桶(bucket):用来对文档进行分组

    image-20240301234735389

    • TermAggregation:按照文档字段值来分组
    • Date Histogram:文昭日期阶梯分组,例如一周为一组或一月为一组
  • 度量(Metric)聚合:用来计算一些值,如平均值,最大最小值,stats(同时求上述三个值)

  • 管道(pipeline)聚合:其他聚合结果基础上再作聚合,例如在对酒店进行分组后分别求每个组评分最大值

DSL实现聚合

实现桶聚合

image-20240301235800567

GET /hotel/_search
{
  "size": 0,
  "aggs": {
    "brandAgg": {
      "terms": {
        "field": "brand",
        "size": 20
      }
    }
  }
}

可以看到aggregations标签下显示了聚合分组情况

image-20240302000158639

可以指定order,来根据每个组的数据个数来排序,并且添加查询语句来限定聚合的范围

GET /hotel/_search
{
  "query":{
  	"range":{
  		"price":{
  			"lte":200
  		}
  	}
  }
  "size": 0,
  "aggs": {
    "brandAgg": {
      "terms": {
        "field": "brand",
        "size": 20,
        "order":{
        	"_count":"asc"
        }
      }
    }
  }
}
实现度量聚合

可以和上面的桶聚合实现嵌套聚合

# 嵌套聚合matric
GET /hotel/_search
{
  "size":0,
  "aggs":{
    "brandAgg":{
      "terms": {
        "field": "brand",
        "size": 20,
        "order": {
          "scoreAgg.avg": "desc"
         }
      },
      "aggs": {
        "scoreAgg": {
          "stats": {
            "field": "score"
          }
        }
      }
    }
  }
}

上述查询表示对数据按照品牌分组,计算各组的评分最值和平均值,并按照计算的各组平均分对组间进行排序

image-20240302002310118

RestAPI实现聚合

根据json的格式来构造java代码,很好理解

image-20240302002440515

     @Test
    public void testAggregation() throws IOException {
        //准备request
        SearchRequest request = new SearchRequest("hotel");
        //准备DSL
        request.source().size(0)
            .aggregation(AggregationBuilders.terms("brandAgg")
                .field("brand")
                .size(10));
        //发出请求
        SearchResponse response = client.search(request, RequestOptions.DEFAULT);
        //解析聚合结果
        Aggregation brandAgg = response.getAggregations().get("brandAgg");//获取指定名称的聚合
        List<? extends Terms.Bucket> buckets = ((Terms)brandAgg).getBuckets();
        //遍历buckets
        for (Terms.Bucket bucket : buckets) {
            //获取key(品牌名称)
            String key = bucket.getKeyAsString();
            System.out.println(key);
        }

    }

案例:在HotelService定义方法,实现对品牌,城市,星级的聚合

实现多条件聚合

在搜索页面的品牌城市等信息不应该是在页面上写死,而是通过聚合索引库中的酒店数据得到

image-20240302135721020

//在IHotelService定义方法,实现对品牌,城市,星级的聚合,方法声明如下
    /**
     * 查询城市,星级,品牌的聚合结果
     * return 聚合结果,格式:{"城市":["北京","上海"],"星级":["五星级","四星级"],"品牌":["如家","汉庭"]}
     */
    Map<String, List<String>> filters();
//实现类
    /**
     * 查询城市,星级,品牌的聚合结果 return 聚合结果,格式:{"城市":["北京","上海"],"星级":["五星级","四星级"],"品牌":["如家","汉庭"]}
     */
    @Override
    public Map<String, List<String>> filters() {
        Map<String, List<String>> resultMap = new HashMap<>();
        try {
            //准备request
            SearchRequest request = new SearchRequest("hotel");
            buildAggregation(request);

            //发出请求
            SearchResponse response = client.search(request, RequestOptions.DEFAULT);
            //解析聚合结果
            response.getAggregations().asMap().forEach((k,v) -> {
                Terms terms = (Terms) v;
                List<String> list = terms.getBuckets().stream().map(Bucket::getKeyAsString).collect(Collectors.toList());
                resultMap.put(HotelConstants.AGG_FIELDS_MAP.get(k), list);
            });
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
        return resultMap;
    }
    private static void buildAggregation(SearchRequest request) {
        //准备DSL,根据城市,星级,品牌进行聚合
        request.source().size(0)
            .aggregation(AggregationBuilders.terms("cityAgg").field("city").size(10))
            .aggregation(AggregationBuilders.terms("starAgg").field("starName").size(10))
            .aggregation(AggregationBuilders.terms("brandAgg").field("brand").size(10));
    }

这里我采用了lambda流式编程,代码相较老师的应该是简洁了不少,不过需要通过额外的一个map来根据聚合名称获取显示的字段名,这里配置在了HotelConstants中

    /**
     * 聚合查找时,每个聚合搜索分组对应的字段,展示给前端
     */
    public static final Map<String,String> AGG_FIELDS_MAP = new HashMap<String,String>(){{
        put("cityAgg","城市");
        put("starAgg","星级");
        put("brandAgg","品牌");
    }};

显示成功

image-20240302185438022

对聚合进行过滤限制

前端在发起fliters请求的时候,可以看到和之前的list传递的参数都是RequestParams,这是因为要对聚合搜索进行范围限制

例如用户搜索"外滩",价格区间在300-600,则聚合必须要在这一限定条件执行(毕竟如果不限制聚合结果范围的话会出现按条件查询时并根据过滤器过滤,发现有许多查询结果为空白的情况)

  1. 在controller编写接口
    /**
     * 获取用于展示在前端的过滤条件
     * @param requestParams
     * @return
     */
    @PostMapping("/filters")
    public Map<String, List<String>> filters(@RequestBody RequestParams requestParams){
        return hotelService.filters(requestParams);
    }
  1. 修改IUserService的filters()方法,添加根据条件查询

非常简单,只需要用原来写好的buildBasicQuery对request进行改造即可

    /**
     * 查询城市,星级,品牌的聚合结果 return 聚合结果,格式:{"城市":["北京","上海"],"星级":["五星级","四星级"],"品牌":["如家","汉庭"]}
     */
    @Override
    public Map<String, List<String>> filters(RequestParams requestParams){
        Map<String, List<String>> resultMap = new HashMap<>();
        try {
            //准备request
            SearchRequest request = new SearchRequest("hotel");
            //查询条件
            buildBasicQuery(request,requestParams);
            //设置size
            request.source().size(0);
            //设置聚合
            buildAggregation(request);

            //发出请求
            SearchResponse response = client.search(request, RequestOptions.DEFAULT);
            //解析聚合结果
            response.getAggregations().asMap().forEach((k,v) -> {
                Terms terms = (Terms) v;
                List<String> list = terms.getBuckets().stream().map(Bucket::getKeyAsString).collect(Collectors.toList());
                resultMap.put(HotelConstants.AGG_FIELDS_MAP.get(k), list);
            });
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
        return resultMap;
    }

    private static void buildAggregation(SearchRequest request) {
        //准备DSL,根据城市,星级,品牌进行聚合
        request.source()
            .aggregation(AggregationBuilders.terms("cityAgg").field("city").size(10))
            .aggregation(AggregationBuilders.terms("starAgg").field("starName").size(10))
            .aggregation(AggregationBuilders.terms("brandAgg").field("brand").size(10));
    }

这里后来觉得buildAggregation方法里面设置size参数不太妥当,因此搬到了外面

  1. 功能测试

image-20240302194157710

可以看到标签名称没有显示正常,经过视频弹幕提醒,把聚合结果返回的map中key值从中文改成英文即可,我这里直接修改常量类就行了,非常方便

//HotelConstants.java
    /**
     * 聚合查找时,每个聚合搜索分组对应的字段,展示给前端
     */
    public static final Map<String,String> AGG_FIELDS_MAP = new HashMap<String,String>(){{
        put("cityAgg","city");
        put("starAgg","starName");
        put("brandAgg","brand");
    }};

ok,这回正常了

image-20240302194441827

并且在进行条件检索的时候过滤器只给出限定范围内的选项

image-20240302194814008

自动补全

安装拼音分词器

要实现子啊搜索框仅输入字母就能根据拼音提示到对应数据,需要对文档按照拼音进行分词,在github上有es拼音分词插件

github仓库地址,注意一定要下载和自己es版本一致的压缩包,这里下载releasev7.12.1版本压缩包

将解压好的文件夹放在es容器在虚拟机上挂载的对应插件目录下面,一般这里设置的是/var/lib/docker/volumes/es-plugins/_data这个位置,可以通过docker inspect es来查看数据卷具体挂载情况

然后重启es容器加载插件

测试拼音分词器功能成功

POST /_analyze
{
  "text": ["如家酒店还不错"],
  "analyzer": "pinyin"
}

image-20240302223545697

自定义分词器

配置自定义分词器

es中分词器包含是那个部分

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

image-20240302225045375

因此我们可以在创建索引库的时候通过settings来配置自定义的分词器

#创建测试库,设置自定义分词器,由tokenizer:ik将文本分词,并由filter:py(自定义的拼音分词器)将词条转换为拼音
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
        }
      }
    }
  }
}

传入两个测试数据,拼音相同,但汉字含义不同

POST /test/_doc/1
{
  "id":1,
  "title":"狮子"
}

POST /test/_doc/2
{
  "id":2,
  "title":"虱子"
}

此时如果搜索有关狮子的内容,会发现狮子虱子都被搜索出来了,因为自定义的分词器将汉语分词后全部转成拼音来存储,并不会区分这两个词语

(注意如果直接用拼音搜的的话可能会没有结果,个人猜测因为自定义分词器先是进行汉字分词处理,破坏了原有的拼音结构)

image-20240302235245352

由上述可知,拼音分词器适合在创建倒排索引的时候使用,但不能再搜索的时候使用

image-20240303000854515

因此创建倒排索引的时候应该用上述的my_analyzer分词器,而咋已进行字段搜索时应该使用ik_smart分词器

#重新创建测试库,用之前的拼音分词器来创建倒排索引,用ik分词器来进行文本检索
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
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "name":{
        "type":"text",
        "analyzer": "my_analyzer",
        "search_analyzer": "ik_smart"
      }
    }
  }
}

可以看到这次确实只返回了狮子

image-20240303001745811

总结

image-20240303001905905

自动补全查询

completion suggester查询

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

  • 参与补全查询的字段必须是completion类型
  • 字段的内容一般是用来补全的多个词条形成的数组

image-20240303132718138

查询语法如下

image-20240303132845734

#用于测试自动补全的测试库
PUT /test2
{
  "mappings": {
    "properties":{
      "title":{
        "type":"completion"
      }
    }
  }
}
#插入示例数据
POST /test2/_doc
{
  "title":["Sony","WH-1000XM3"]
}

POST /test2/_doc
{
  "title":["SK-II","PITERA"]
}

POST /test2/_doc
{
  "title":["Nintendo","switch"]
}


#自动补全查询 
GET /test2/_search
{
  "suggest": {
    "titleSuggest": {
      "text": "s",
      "completion":{
        "field":"title",
        "skip_duplicates":true, 
        "size":10 
      }
    }
  }
}

成功返回自动补全的text

image-20240303142429104

实现酒店搜索框自动补全和拼音搜索功能

修改hotel库设置自定义分词器

将原来的hotel库删掉,重新创建

// 酒店数据索引库
PUT /hotel
{
  "settings": {
    "analysis": {	
      "analyzer": {
        //定义倒排索引采用的自定义分词器
        "text_anlyzer": {
          "tokenizer": "ik_max_word",
          "filter": "py"
        },
        //定义自动补全采用的分词器,之所以tokenizer采用keyword不分词,是因为completion字段本身就是分好的词条组成的数组,只需要之后进行拼音分词即可
        "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",
        "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"
      }
    }
  }
}

新增hotelDoc字段

private List<String> suggestion; //用来进行搜索提示自动补全的字段

在hotel自定义的构造函数上设置suggestion字段的值,只需要用原有的字段构造即可

this.suggestion = Arrays.asList(this.brand,this.business);

重新运行单元测试中的导入全部数据testBulkRequest测试方法

成功导入数据,并生成了对应的suggestion字段

image-20240303150439189

不过上述存在一个细节问题,有的酒店business字段含有两个以上的地点,如江湾/五角场商业区,可以在上述hoteldoc构造函数中根据/将地点进行分割之后存入suggestion数组

//对this.suggestion初始化进行进一步改进       
if (this.business.contains("/") || this.business.contains("、")){
            this.suggestion = new ArrayList<>();
            this.suggestion.add(this.brand);
            Collections.addAll(this.suggestion, this.business.split("[/、]"));
        }else {
            this.suggestion = Arrays.asList(this.brand, this.business);
        }

❗ 由于实际数据中包含用/进行分割的,因此将两者都添加上,split方法采用了正则语法进行匹配

🔔感觉有时候不能过于追求代码的简洁和优雅,最重要的是可读性和性能,自己想要用lambda函数的方法用一两行代码解决对suggestion的初始化,结果性能慢的要命,还费老大劲去想要怎么写

简单单元测试发现确实成功分割了

image-20240303153957636

然后将数据重新导入一遍(不必删库,重新运行下批量添加的测试方法就行)

按照拼音搜索测试成功

image-20240303154610545

RestAPI实现自动补全

就是按照json格式来一步步构建,很好理解

image-20240303154908989

@Test
public void testSuggest() throws IOException {
    //创建请求
    SearchRequest request = new SearchRequest("hotel");
    //准备DSL
    request.source().suggest(new SuggestBuilder().addSuggestion(
        "suggestions",
        SuggestBuilders.completionSuggestion("suggestion")
            .prefix("sh")
            .skipDuplicates(true)
            .size(10)
    ));
    //发起请求
    SearchResponse response = client.search(request, RequestOptions.DEFAULT);

    //解析结果
    response.getSuggest().getSuggestion("suggestions") // 获取指定名称的补全查询结果(上面在查询的时候指定了名称)
        .getEntries().get(0).getOptions() // 获取补全结果
        .forEach(option -> System.out.println(option.getText().string()));
}

image-20240303165418932

成功获取了sh开头的所有suggestions

image-20240303170024942

实现酒店搜索页面输入框的自动补全
//Controller
    /**
     * 实现搜索提示自动补全
     * @param prefix
     * @return
     */
    @GetMapping("/suggestion")
    public List<String> suggestion(@RequestParam("key") String prefix){
        return hotelService.getSuggestion(prefix);
    }
//ServiceImpl
    /**
     * 实现搜索提示自动补全
     *
     * @param prefix
     * @return
     */
    @Override
    public List<String> getSuggestion(String prefix) {
        ArrayList<String> resList = new ArrayList<>();
        try {
            //创建请求
            SearchRequest request = new SearchRequest("hotel");
            //准备DSL
            request.source().suggest(new SuggestBuilder().addSuggestion(
                "suggestions",
                SuggestBuilders.completionSuggestion("suggestion")
                    .prefix(prefix)
                    .skipDuplicates(true)
                    .size(10)
            ));
            //发起请求
            SearchResponse response = client.search(request, RequestOptions.DEFAULT);

            //解析结果
            response.getSuggest().getSuggestion("suggestions") // 获取指定名称的补全查询结果(上面在查询的时候指定了名称)
                .getEntries().get(0).getOptions() // 获取补全结果
                .forEach(option -> resList.add(option.getText().toString()));
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
        return resList;
    }

可以看到前端输入拼音的时候返回了提示

image-20240303171247493

不过输入中文的时候貌似并不能给提示,听说是前端的问题,不过无关紧要

数据同步

问题分析

es中的酒店数据来自于mysql数据库,因此mysql数据发生改变时,elasticsearch也必须要跟着改变,这就是不同微服务之间的数据同步

方案一:同步调用

image-20240303175319212

先写完数据库,然后调用接口让搜索服务对es进行更新,操作是依次进行的

问题

  1. 数据耦合,需要在业务操作mysql的代码上添加调用接口数据同步的代码
  2. 写数据库还得等待es同步完成,性能浪费
  3. 级联失败,如果恰巧es更新出现异常,整个业务都会出现问题
方案二:异步通知

image-20240303190240422

推荐方案,不过会依赖mq的可靠性,而且实现复杂度有所上升

监听binlog

image-20240303190343422

mysql在进行操作的时候binlog会发生变化(需要事先开启),canal中间件会对binlog进行监听,通知微服务及时更新

完全解除了和服务间的耦合,但是因为要开启binlog,对mysql服务器的压力增加了

案例:利用MQ实现mysql和es数据同步

导入hotel-admin微服务

在资料中找到hotel-admin文件夹然后用idea打开即可,运行时记得修改配置连接到自己的数据库上

image-20240304113019521

配置mq

当酒店数据发生增删改的时候,要求对elasticsearch数据完成同样的操作(由于es中增和改都是put操作,因此可以看做同一种操作),可以声明两个队列

image-20240304121053508

  1. hotel-demo引入SpringAMQPy依赖

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

    在配置文件中对mq连接进行相关配置

    spring:
      rabbitmq:
        host: fishlulu01
        port: 5672
        username: fishlulu
        password: 123456
        virtual-host: /
    
  2. 在配置文件中配置连接mq(之前在虚拟机创建过mq容器)

​ 首先设置mq相关常量,新建一个常量类

package cn.itcast.hotel.constants;

public class MqConstants {

    /**
     * 交换机名称
     */
    public final static String HOTEL_EXCHANGE = "hotel.topic";
    /**
     * 监听新增和修改的队列
     */
    public final static String HOTEL_INSERT_QUEUE = "hotel.insert.queue";
    /**
     * 监听删除的队列
     */
    public final static String HOTEL_DELETE_QUEUE = "hotel.delete.queue";
    /**
     * 新增和修改的路由键
     */
    public final static String HOTEL_INSERT_KEY = "hotel.insert";
    /**
     * 删除的路由键
     */
    public final static String HOTEL_DELETE_KEY = "hotel.delete";

}

新建一个配置类用来声明交换机,队列和绑定关系

package cn.itcast.hotel.config;
@Configuration
public class MqConfig {
    /**
     * 定义交换机
     */
    @Bean
    public TopicExchange topicExchange(){
        return new TopicExchange(MqConstants.HOTEL_EXCHANGE, true, false);
    }
    /**
     * 定义新增和修改的队列
     */
    @Bean
    public Queue insertQueue(){
        return new Queue(MqConstants.HOTEL_INSERT_QUEUE, true);
    }
    /**
     * 定义删除的队列
     */
    @Bean
    public Queue deleteQueue(){
        return new Queue(MqConstants.HOTEL_DELETE_QUEUE, true);
    }
    /**
     * 定义绑定关系
     */
    @Bean
    public Binding insertQueueBinding(){
        return BindingBuilder
            .bind(insertQueue())
            .to(topicExchange())
            .with(MqConstants.HOTEL_INSERT_KEY);
    }
    @Bean
    public Binding deleteQueueBinding(){
        return BindingBuilder
            .bind(deleteQueue())
            .to(topicExchange())
            .with(MqConstants.HOTEL_DELETE_KEY);
    }
}
  1. hotel-admin配置数据同步操作

hotel-demo项目中将mq常量类和mq相关配置复制粘贴到admin项目下

其实个人认为这种重复的配置应该交给nacos进行配置统一管理,不过由于这个demo项目体量较小,因此暂时用复制粘贴的方式配置了

然后在controller下面配置发送到消息队列的操作

个人觉得这个后续可以自定义一个注解,使用AOP方法来统一添加数据同步操作

	@Autowired
	private RabbitTemplate rabbitTemplate;

@PostMapping
    public void saveHotel(@RequestBody Hotel hotel){
        hotelService.save(hotel);
        rabbitTemplate.convertAndSend(MqConstants.HOTEL_EXCHANGE, MqConstants.HOTEL_INSERT_KEY, 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, hotel.getId());
    }

    @DeleteMapping("/{id}")
    public void deleteById(@PathVariable("id") Long id) {
        hotelService.removeById(id);
        rabbitTemplate.convertAndSend(MqConstants.HOTEL_EXCHANGE, MqConstants.HOTEL_DELETE_KEY, id);
    }

由于rabbitmq占用的是内存资源,因此发送消息不建议过大,这里就选择发送酒店数据ID过去,然后被通知的服务再根据id去查询

  1. hotel-demo编写监听类
@Component
public class HotelListener {
    @Autowired
    private IHotelService hotelService;
    /**
     * 监听酒店数据新增和修改
     * @param id
     */
    @RabbitListener(queues = MqConstants.HOTEL_INSERT_QUEUE)
    public void listenHotelInsertOrUpdate(Long id){
        hotelService.insertById(id);

    }
    /**
     * 监听酒店数据删除
     */
    @RabbitListener(queues = MqConstants.HOTEL_DELETE_QUEUE)
    public void listenHotelDelete(Long id){
        hotelService.deleteById(id);
    }
}

在HotelService编写监听后要实现的操作

    /**
     * 通过id删除酒店es索引库数据
     *
     * @param id
     */
    @Override
    public void insertById(Long id) {
        try {
            //准备新增request
            IndexRequest request = new IndexRequest("hotel").id(id.toString());
            //准备dsl
            //获取酒店数据
            Hotel hotel = getById(id);
            HotelDoc hotelDoc = new HotelDoc(hotel);
            request.source(JSON.toJSONString(hotelDoc), XContentType.JSON);
            //发出请求
            client.index(request, RequestOptions.DEFAULT);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    /**
     * 通过id删除酒店es索引库数据
     *
     * @param id
     */
    @Override
    public void deleteById(Long id) {
        try {
            //准备request
            DeleteRequest request = new DeleteRequest("hotel", id.toString());
            client.delete(request, RequestOptions.DEFAULT);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
功能测试

开启虚拟机中的mq容器,然后启动两个微服务

可以看到队列和交换机声明成功

image-20240306143503526

找到一个酒店id进行演示,这里用id为60223的上海希尔顿酒店

image-20240306144136583

老师视频演示控制台查看vue元素是通过一个叫vue.js devtools的浏览器插件实现的,可以自行魔法上网到谷歌插件商店下载

去酒店管理页面进行数据修改(端口8099),把60223酒店改成2686元

image-20240306145352182

mq收到了消息转发

image-20240306145745267

es数据成功被修改

image-20240306145651560

集群

搭建ES集群

ES集群结构

单机es进行数据存储存在以下问题

  • 海量数据存储问题

    • 对策: 将索引库从逻辑上拆分为n个分片,存储到多个节点
  • 单点故障问题

    • 将芬片数据在不同节点备份(replica)

image-20240306151030609

搭建ES集群

这里我们使用3个docker容器来模拟三个节点

首先编写docker-compose文件配置集群部署,将文件

version: '2.2'
services:
  es01:
    image: elasticsearch:7.12.1
    container_name: es01
    environment:
      - node.name=es01
      - cluster.name=es-docker-cluster #es天生支持分布式,因此只要设置es的cluster name保持一致,es会自动将其部署到一个集群上面
      - discovery.seed_hosts=es02,es03 #指定集群另外两个机器的ip地址(docker构建网络的时候ip地址默认是容器名)
      - 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

vm.max_map_count 是一个 Linux 内核参数,它决定了一个进程可以拥有的虚拟内存区域(或称为内存映射)的最大数量。这些内存区域通常用于映射文件、共享内存、动态链接库等。总的来说就是防止内存分配不够

然后执行命令,让配置生效:

sysctl -p

通过docker-compose后台启动集群:

记得先把之前的es容器关掉防止冲突,mq和kibana最好也关了,现在本人分配的4g内存已经占用84%了

docker-compose up -d
集群状态监控
docker部署es集群

kibana虽然可以监控es集群,不过新版需要配置x-pack功能,配置比较复杂

这里推荐使用cerebro来监控es集群状态,直接解压资料安装包在本机上运行即可

但是这里自己使用jdk17版本发现本机运行cerebro会发生闪退,于是决定采用容器部署的方式,这里我参考了这篇文章重新配置docker集群部署(将cerebro和kibana也加入到集群搭建中,并且为es配置了用于集群间通信的端口),参考文章

用这个docker-compose.yml文件来替换原来的重新部署

version: '2.2'

services:
  cerebro:
    image: lmenezes/cerebro:0.9.4
    container_name: cerebro
    ports:
      - "9000:9000"
    command:
      - -Dhosts.0.host=http://es01:9200 #告诉 Cerebro 连接到指定的 Elasticsearch 实例,以便监控和管理 Elasticsearch 集群
    networks:
      - elastic
  kibana:
    image: kibana:7.12.1
    container_name: kibana_cluster
    environment:
      # 配置kibana图形化集群监控
      - I18N_LOCALE=zh-CN #将 Kibana 的界面语言设置为中文
      - XPACK_GRAPH_ENABLED=true #启用图形插件
      - TIMELION_ENABLED=true #该插件允许用户执行时间序列数据的查询和可视化
      - XPACK_MONITORING_COLLECTION_ENABLED="true" #启用监控数据收集,这样可以在 Kibana 中查看和分析 Elasticsearch 集群的监控数据
      - ELASTICSEARCH_HOSTS=http://es01:9200 #配置 Kibana 连接到 Elasticsearch 集群的地址,只写主节点即可
    ports:
      - "5601:5601"
    networks:
      - elastic
  es01:
    image: elasticsearch:7.12.1
    container_name: es01
    environment:
      - node.name=es01
      - cluster.name=es-docker-cluster #es天生支持分布式,因此只要设置es的cluster name保持一致,es会自动将其部署到一个集群上面
      - discovery.seed_hosts=es02,es03 #指定集群另外两个机器的ip地址(docker构建网络的时候ip地址默认是容器名)
      - cluster.initial_master_nodes=es01,es02,es03 #配置节点的主从关系
      - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
    volumes:
      - data01:/usr/share/elasticsearch/data
    ports:
      - 9200:9200
      - 9300:9300
    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
      - 9301:9300
    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
      - 9302:9300
volumes:
  data01:
    driver: local
  data02:
    driver: local
  data03:
    driver: local

networks:
  elastic:
    driver: bridge

如果拉取的时候报错Get "https://registry-1.docker.io/v2/": dial tcp: lookup registry-1.docker.io on [::1]:53: read udp [::1]:32897->[::1]:53: read: connection refused

可以参考这篇文章解决(很简单的方法)

访问虚拟机9000端口,并输入es01:9200地址进入cerebro集群管理界面

image-20240306184534768

也可以通过在kibana上进行查看(在搜索栏里搜堆栈监测,然后开启即可(选择内部收集就行))

kibana加载可能会很慢,稍微有点耐心

image-20240306194703631

创建索引库

可以创建集群索引库,指定分片数量和每个片需要备份的份数

image-20240306185025623

回到主页可以看到创建成功,其中实线框是主分片(真正被调用数据的),虚线框是副本分片,存放的是其他分片的备份

image-20240306185122911

es集群分布式查询

es集群中的角色
节点类型 配置参数 默认值 节点职责
master eligible node.master true 备选主节点:主节点可以管理和记录集群状态,决定分片在哪一个节点,处理创建和删除索引库的请求
data node.data true 数据节点:存储数据,搜索树,聚合,crud
ingest node.ingest true 数据存储之前的预处理
coordinating 上面三个参数都为false则为这类节点 负责路由请求到其他节点,合并其他节点处理的结果返回给用户

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

image-20240306195832873

集群脑裂问题

默认情况下,每个节点都是master eligible节点,因此一旦master节点宕机,其他候选节点会选举一个成为主节点,而当主节点只是与其他节点发生网络故障而非真正坏掉的时候,其他节点会误以为主节点挂掉了,会再选出一个主节点,当网络恢复的时候,就会出现两个主节点控制请求,会出现数据不一致的问题,这就是脑裂问题

image-20240306200255761

而在es中为了避免脑裂问题,需要选票不小于(eligible节点数量+1)/2才能当选主节点(例如node3有自己和node2两张选票,不小于(3+1)/2,而node1只有自己一票,因此node3会当选新的主节点),因此eligible节点数量最好是奇数,7.0之后版本默认配置了这个选项,因此一般不会发生脑裂问题

es集群分布式存储

当新增文档时,应该保存到不同分片,保证数据均衡,coordinating node就是负责这个的

先在kibana上添加三条数据到itcast索引库上(这里id=1,3,5)

image-20240306222655113

通过explain:true选项可以看数据存放在哪一个节点上,这里看到都是从主节点插入的数据,但三条数据分散在三个节点上image-20240306223135250

事实上,es会通过hash算法计算文档应该存储到哪一个分片上(就是最简单的取余方法)

image-20240306223236232

其中_routing默认是文档id

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

分布式查询

image-20240306223641723

上述只是通过id查询,但实际情况更多是通过字段检索来查询,一开始并不知道数据在哪一个分片上

因es分布查询分为两个阶段

  • scatter phase 分散阶段,协调节点会把请求发到每个分片去查
  • gather phase 聚集阶段,协调节点汇总data node的搜索结果,并将最终结果返回用户

集群故障转移

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

image-20240306224533112

我们现在就来模拟一下故障场景

docker-compose stop es01停掉es01节点

可以看到触发了黄色告警

image-20240306224832092

数据最终完成了迁移,恢复了绿色

image-20240306225001355

数据可以正常查询

这里本来想用kibana来试验的,结果发现之前自己因为只设置了es01作为入口,挂掉以后kibana直接进不去了,目前来看还是cerebro方便一些啊

然后我们再重启一下es01

可以看到之前额外备份的数据重新回到es01上了,不过理所当然不能让es01这个不靠谱的东西当老大了(笑)

image-20240306225323992

🌟可喜可贺,你已经完成了基础篇,是时候去做一个微服务入门项目巩固巩固了,接下来是高级篇,笔者也会在后面进行更新,敬请期待~

禄禄鱼在某个平常的春夜里有感而发并自我感动着

posted @ 2024-01-26 01:03  禄禄鱼Fish_lulu  阅读(268)  评论(0)    收藏  举报