admin 管理员组

文章数量: 887016


  • 若文章内容或图片失效,请留言反馈。部分素材来自网络,若不小心影响到您的利益,请联系博主删除。

前言


  • 学习视频链接
    • SpringCloud + RabbitMQ + Docker + Redis + 搜索 + 分布式,史上最全面的 SpringCloud 微服务技术栈课程 | 黑马程序员 Java 微服务
  • 学习资料链接
    • 2022 最新版 Java 学习 路线图>第 4 阶段:中间键 & 服务框架>1.微服务开发框架 SpringCloud + RabbitMQ + Docker + Redis + 搜索 + 分布式 史上最全面的微服务全技术栈课程(提取码dor4

  • 写这篇博客旨在制作笔记,巩固知识。同时方便个人在线阅览,回顾知识。
  • 博客的内容主要来自视频内容和资料中提供的学习笔记。

系列目录


SpringCloud 微服务技术栈_实用篇①_基础知识

SpringCloud 微服务技术栈_实用篇②_黑马旅游案例


SpringCloud 微服务技术栈_高级篇①_微服务保护

SpringCloud 微服务技术栈_高级篇②_分布式事务

SpringCloud 微服务技术栈_高级篇③_分布式缓存

SpringCloud 微服务技术栈_高级篇④_多级缓存

SpringCloud 微服务技术栈_高级篇⑤_可靠消息服务


本文目录


微服务远程调用、Eureka 注册中心、Ribbon 负载均衡原理、Nacos 注册中心

  • SpringCloudDay01

Nacos 配置管理、Feign 远程调用、GetWay 服务网关

  • SpringCloudDay02

Docker 的基本操作、Dockerfile 的自定义镜像、了解 Docker-ComposeDokcer 镜像服务

  • SpringCloudDay03

了解 MQ、快速入门 RabbitMQ、了解 SpringAMQP

  • SpringCloudDay04

ElasticSearch 基础:索引库操作、文档操作、RestAPIRestClient 操作文档

  • SpringCloudDay05

ElasticSearch 搜索功能:DSL 查询文档、搜索结果处理、RestClient 查询文档、黑马旅游案例

  • SpringCloudDay06

ElasticSearch 深入学习:数据聚合、自动补全、数据补全、集群

  • SpringCloudDay07

0.微服务技术栈导学


  • 以下为视频中的截图








# SpringCloudDay01


SpringCloud 学习 Day01实用篇-1

  • 认识微服务
  • 分布式服务架构案例
  • Eureka 注册中心
  • Ribbon 负载均衡原理
  • Nacos 注册中心

1.认识微服务


随着互联网行业的发展,对服务的要求也越来越高,服务架构也从单体架构逐渐演变为现在流行的微服务架构。


1.1.服务架构演变


1.1.1.单体架构


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

  • 优点:架构简单、部署成本低
  • 缺点:耦合度高(维护困难、升级困难)


1.1.2.分布式架构


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

  • 优点:降低服务耦合、有利于服务升级和拓展
  • 缺点:服务调用关系错综复杂


服务治理

分布式架构虽然降低了服务耦合,但是服务拆分时也有很多问题需要思考:

  • 服务拆分的粒度如何界定?
  • 服务之间如何调用?
  • 服务的调用关系如何管理?

人们需要制定一套行之有效的标准来约束分布式架构。


1.1.3.微服务


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


微服务架构特征

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

微服务架构图


微服务部署持续集成


微服务图解举例


微服务的上述特性其实是在给分布式架构制定一个标准,进一步降低服务之间的耦合度,提供服务的独立性和灵活性。

从而做到高内聚,低耦合。

因此,可以认为微服务是一种经过良好架构设计的分布式架构方案

但方案该怎么落地?选用什么样的技术栈?全球的互联网公司都在积极尝试自己的微服务落地方案。

其中在 Java 领域最引人注目的就是 SpringCloud 提供的方案了。


1.2.微服务技术对比


  • 微服务结构

微服务这种方案需要技术框架来落地,全球的互联网公司都在积极尝试自己的微服务落地技术。

在国内最知名的就是 SpringCloud 和阿里巴巴的 Dubbo


  • 微服务技术对比
DubboSpringCloudSpringCloudAlibaba
注册中心zookeeperRedisEurekaConsulNacosEureka
服务远程调用Dubbo 协议FeignHttp 协议)DubboFeign
配置中心SpringCloudConfigSpringCloudConfigNacos
服务网关SpringCloudGatewayZuulSpringCloudGatewayZuul
服务监控和保护dubbo-admin,功能弱HystixSentinel

  • 企业需求


1.3.SpringCloud


SpringCloud 是目前国内使用最广泛的微服务框架。

官网地址https://spring.io/projects/spring-cloud

SpringCloud 集成了各种微服务功能组件,并基于 SpringBoot 实现了这些组件的自动装配。

SpringCloud 提供了良好的开箱即用体验。


另外,SpringCloud 底层是依赖于 SpringBoot 的,并且有版本的兼容关系。

我们这里学习的版本是 Hoxton.SR10,因此对应的 SpringBoot 版本是 2.3.x 版本。


1.4.总结


  • 单体架构:简单方便,高度耦合,扩展性差,适合小型项目。
    • 例如:学生管理系统

  • 分布式架构:松耦合,扩展性好,但架构复杂,难度大。适合大型互联网项目。
    • 例如:京东、淘宝

  • 微服务:一种良好的分布式架构方案
    • 优点:拆分粒度更小、服务更独立、耦合度更低
    • 缺点:架构非常复杂,运维、监控、部署难度提高

  • SpringCloud 是微服务架构的一站式解决方案,集成了各种优秀微服务功能组件

2.分布式服务架构案例


2.1.服务拆分原则


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


2.2.服务拆分示例


课前资料

2022 最新版 Java 学习 路线图>第 4 阶段_中间键&服务框架>1.微服务开发框架 SpringCloud + RabbitMQ + Docker + Redis + 搜索 + 分布式 史上最全面的微服务全技术栈课程>实用篇>学习资料>day01-SpringCloud01(提取码:dor4


2.2.1.案例 Demo


导入服务拆分 Demo

  1. 导入课前资料提供的工程:cloud-demo
  2. 项目结构

  1. 首先,将课前资料提供的 cloud-order.sqlcloud-user.sql 导入到 MySQL 中。

该项目导入的部分依赖以及所继承的工程

这里只是提一下,项目里有完整的文件。这里没打算赘述

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.3.9.RELEASE</version>
    <relativePath/>
</parent>
<!-- springCloud -->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-dependencies</artifactId>
    <version>Hoxton.SR10</version>
    <type>pom</type>
    <scope>import</scope>
</dependency>
<!--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>

数据库 & 数据表说明

其中 cloud_ordercloud_user 数据库的字符集为 utf8、排序规则是 utf8_general_ci

数据库 cloud-order 中仅有一张表:tb_order

数据库 cloud_user 中仅有一张表:tb_user

其中 tb_order.user_id = tb_user.id

不过因为是跨库的俩表,所以无法作关联查询。


相关的项目结构 和 Idea 的窗口


order-service 服务中,有一个根据 id 查询订单的接口

order-service 模块中的 src/main/java/cn/itcast/order/web/OrderController.java

@RestController
@RequestMapping("order")
public class OrderController {

    @Autowired
    private OrderService orderService;

    @GetMapping("{orderId}")
    public Order queryOrderByUserId(@PathVariable("orderId") Long orderId) {
        // 根据id查询订单并返回
        return orderService.queryOrderById(orderId);
    }
}

user-service 中有一个根据 id 查询用户的接口

user-service 模块中的 src/main/java/cn/itcast/user/web/UserController.java

@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {
    @Autowired
    private UserService userService;

    /**
     * 路径: /user/110
     *
     * @param id 用户id
     * @return 用户
     */
    @GetMapping("/{id}")
    public User queryById(@PathVariable("id") Long id) {
        return userService.queryById(id);
    }
}

查询结果

这里使用的接口测试工具是 Postman

根据 id 查询用户,返回值是 User 对象。

根据 id 查询订单,返回值是 Order 对象,其中的 usernull


2.2.2.实现远程调用案例


案例需求:根据订单 id 查询订单的同时,把订单所属的用户信息一起返回。


远程调用方式分析

因此,我们需要在 order-service中 向 user-service 发起一个 http 的请求,调用 http://localhost:8081/user/{userId} 这个接口。


大致步骤

  • 注册一个 RestTemplate 的实例到 Spring 容器
  • 修改 order-service 服务中的 OrderService 类中的 queryOrderById 方法
    • 根据 Order 对象中的 userId 查询 User
    • 将查询的 User 填充到 Order 对象,一起返回

  • 注册 RestTemplate

order-service 服务的 OrderApplication 启动类中,注册 RestTemplate 实例

order-service 模块中的 src/main/java/cn/itcast/order/OrderApplication.java

@MapperScan("cn.itcast.order.mapper")
@SpringBootApplication
public class OrderApplication {
    public static void main(String[] args) {
        SpringApplication.run(OrderApplication.class, args);
    }

    /* ************************************* */
    /**
     * 创建 RestTemplate 并注入 Spring 容器
     *
     * @return
     */
    @Bean
    public RestTemplate restTemplate() {
        return new RestTemplate();
    }
    /* ************************************* */
}

  • 修改 order-service 服务中的的 OrderService 类中的 queryOrderById 方法

order-service 模块中的 cn/itcast/order/service/OrderService.java

@Service
public class OrderService {
    @Autowired
    private OrderMapper orderMapper;

    /* ******************************* */
    @Autowired
    private RestTemplate restTemplate;
    /* ******************************* */

    public Order queryOrderById(Long orderId) {
        // 1.查询订单
        Order order = orderMapper.findById(orderId);
        /* ***************************************************************** */
        //2. 利用 RestTemplate 发起 HTTP 请求,查询用户
        //2.1.url 路径
        String url = "http://localhost:8081/user/" + order.getUserId();
        //2.2.发送 http 请求,实现远程调用
        User user = restTemplate.getForObject(url, User.class);
        //3.封装 user 至 order
        order.setUser(user);
        /* ***************************************************************** */
        // 4.返回
        return order;
    }
}

程序运行结果展示


2.3.总结


微服务拆分

  • 微服务需要根据业务模块拆分,做到单一职责,不要重复开发相同的业务。
  • 微服务可以将业务暴露为接口,供其他服务使用。
  • 不同微服务都应该有自己独立的数据库。

微服务调用方式

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

3.eureka 注册中心


3.1.提供者与消费者


在服务调用关系中,会有两个不同的角色:服务提供者、服务消费者。

服务提供者:一次业务中,被其它微服务调用的服务。(提供接口给其它微服务)

服务消费者:一次业务中,调用其它微服务的服务。(调用其它微服务提供的接口)

但是,服务提供者与服务消费者的角色并不是绝对的,而是相对于业务而言。

如果服务 A 调用了服务 B,而服务 B 又调用了服务 C,服务 B 的角色是什么?

  • 对于 A 调用 B 的业务而言:A 是服务消费者,B 是服务提供者
  • 对于 B 调用 C 的业务而言:B 是服务消费者,C 是服务提供者

因此,服务 B 既可以是服务提供者,也可以是服务消费者。


小结

  • 服务提供者:暴露接口给其他微服务调用
  • 服务消费者:调用其他微服务提供的接口
  • 提供者与消费者角色其实是相对

3.2.远程调用出现的问题


假如我们的服务提供者 user-service 部署了多个实例。

  • order-service 在发起远程调用的时候,该如何得知 user-service 实例的 ip 地址和端口?
  • 有多个 user-service 实例地址,order-service 调用时该如何选择?
  • order-service 如何得知某个 user-service 实例是否依然健康,是不是已经宕机?


3.3.Eureka 的作用


以上的这些问题都需要利用 SpringCloud 中的注册中心来解决。

其中最广为人知的注册中心就是 Eureka

  • 注意
    • 一个微服务,既可以是服务提供者,又可以是服务消费者。
    • 因此 eureka 将服务注册、服务发现等功能统一封装到了 eureka-client

Eureka 架构中,微服务角色有两类:服务端和客户端。


  • EurekaServer:服务端,注册中心
    • 记录服务信息
    • 心跳监控

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

消费者该如何获取服务提供者具体信息?

  • 服务提供者启动时向 eureka 注册自己的信息
  • eureka 保存这些信息
  • 消费者根据服务名称向 eureka 拉取提供者信息

如果有多个服务提供者,消费者该如何选择?

  • 服务消费者利用负载均衡算法,从服务列表中挑选一个

消费者如何感知服务提供者健康状态?

  • 服务提供者会每隔 30 秒向 EurekaServer 发送心跳请求,报告健康状态
  • eureka 会更新记录服务列表信息,心跳不正常会被剔除
  • 消费者就可以拉取到最新的信息

现在回答之前的各个问题

问题 1order-service 如何得知 user-service 实例地址?

  • user-service 服务实例启动后,将自己的信息注册到 eureka-serverEureka 服务端)。即服务注册
  • eureka-server 保存服务名称到服务实例地址列表的映射关系
  • order-service 根据服务名称,拉取实例地址列表。这个叫服务发现服务拉取

问题 2order-service 如何从多个 user-service 实例中选择具体的实例?

  • order-service 从实例列表中利用负载均衡算法选中一个实例地址
  • 向该实例地址发起远程调用

问题 3order-service 如何得知某个 user-service 实例是否依然健康,是不是已经宕机?

  • user-service 会每隔一段时间(默认 30 秒)向 eureka-server 发起请求,报告自己状态,称为心跳
  • 当超过一定时间没有发送心跳时,eureka-server 会认为微服务实例故障,将该实例从服务列表中剔除
  • order-service 拉取服务时,就能将故障实例排除了

  • 接下来,就需要我们来动手实践了。


3.4.搭建 eureka-server


首先大家注册中心服务端:eureka-server,这必须是一个独立的微服务。


  1. cloud-demo 父工程下,创建一个子模块 eureka-server
  2. 引入 spring-cloud-starter-netflix-eureka-server 的依赖
  3. 编写启动类,添加 @EnableEurekaServer 注解
  4. 添加配置文件:application.yml
  5. 启动服务

  1. cloud-demo 父工程下,创建一个子模块 eureka-server


  1. 引入 spring-cloud-starter-netflix-eureka-server 的依赖

总模块 cloud-demo 中的 pom.xml

<properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
    <java.version>1.8</java.version>
    <spring-cloud.version>Hoxton.SR10</spring-cloud.version>
    <!--<mysql.version>5.1.47</mysql.version>-->
    <mysql.version>8.0.17</mysql.version>
    <mybatis.version>2.1.1</mybatis.version>
</properties>

eureka-server 模块中的 pom.xml

<dependencies>
    <!-- eureka 服务端-->
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
    </dependency>
</dependencies>

  1. 编写启动类,添加 @EnableEurekaServer 注解

eureka-server 模块中的 src/main/java/cn/itcast/eureka/EurekaApplication.java

@EnableEurekaServer
@SpringBootApplication
public class EurekaApplication {
    public static void main(String[] args) {
        SpringApplication.run(EurekaApplication.class);
    }
}

  1. 添加配置文件:application.yml

eureka-server 模块中的 src/main/resources/application.yml

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

  1. 启动服务

在浏览器访问:http://127.0.0.1:10086


3.5.服务注册


  1. 引入 eureka-client 依赖
  2. application.yml 下配置 eureka 地址
  3. 启动多个实例

  1. 引入 eureka-client 依赖

user-serviceorder-service 的俩模块下的 pom.xml 都导入如下的依赖。

<!-- eureka 客户端 -->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-netflix-eureka-client</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>

  1. application.yml 下配置 eureka 地址

根据情况来编写 user-serviceorder-service 的俩模块下的 /resources/application.yml 的信息。

server:
  port: 8080 # order-service 模块
  # port: 8081 # user-service 模块
spring:
  datasource:
    # url: jdbc:mysql://localhost:3306/cloud_order?useSSL=false # MySQL5
    url: jdbc:mysql://localhost:3306/cloud_order?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&useSSL=false&allowPublicKeyRetrieval=true # MySQL8
    username: root
    password: root
    # driver-class-name: com.mysql.jdbc.Driver # MySQL5
    driver-class-name: com.mysql.cj.jdbc.Driver # MySQL8
  #####################################################
  application:
    # eureka 的服务名称
    name: order-service # order-service 模块
    # name: user-service # user-service 模块
  #####################################################
    
mybatis:
  type-aliases-package: cn.itcast.user.pojo
  configuration:
    map-underscore-to-camel-case: true
logging:
  level:
    cn.itcast: debug
  pattern:
    dateformat: MM-dd HH:mm:ss:SSS
#####################################################
eureka:
  client:
    service-url: # eureka 的地址信息
      defaultZone: http://127.0.0.1:10086/eureka
#####################################################

  1. 启动多个实例

为了演示一个服务有多个实例的场景,我们添加一个 SpringBoot 的启动配置,再启动一个 user-service

配置成功后运行项目。

运行时 SpringBoot 窗口会出现两个 user-service 的启动配置。

eureka-server 管理页面的用例表也会显示相关信息。


显然,无论是消费者还是提供者,引入 eureka-client 依赖、知道 eureka 地址后,都可以完成注册服务。


3.6.服务发现


服务拉取是基于服务名称获取服务列表,然后对服务列表做负载均衡


下面,我们将对 order-service 进行逻辑修改。

eureka-server 拉取 user-service 的信息,实现服务发现服务拉取)。

order-service 要去 eureka-server 中拉取 user-service 服务的实例列表,并且实现负载均衡。

不过这些动作不用我们去做,只需要添加一些注解即可。


  1. 修改 OrderServicequeryOrderById 方法,修改访问的 url 路径,用服务名代替 ip、端口。

order-service 模块:src/main/java/cn/itcast/order/service/OrderService.java

//2.1.url 路径
//String url = "http://localhost:8081/user/" + order.getUserId();
String url = "http://user-service/user/" + order.getUserId();

  1. order-service 项目的启动类 OrderApplication 中的 RestTemplate 添加负载均衡注解。

RestTemplate 这个 Bean 添加一个 @LoadBalanced 注解

order-service 模块:src/main/java/cn/itcast/order/OrderApplication.java

@Bean
@LoadBalanced //负载均衡注解
public RestTemplate restTemplate() {
    return new RestTemplate();
}

之后 spring 会自动帮助我们从 eureka-server 端,根据 user-service 这个服务名称,获取实例列表,而后完成负载均衡。


  1. 结果展示

发起多次请求:localhost:8080/order/103

此时 idea 的控制台界面

显然,俩端口均有响应。


3.7.总结


1.搭建 EurekaServer

  • 引入 eureka-server 依赖
  • 添加 @EnableEurekaServer 注解
  • application.yml 中配置 eureka 地址

2.服务注册

  • 引入 eureka-client 依赖
  • application.yml 中配置 eureka 地址

3.服务发现

  • 引入 eureka-client 依赖
  • application.yml 中配置 eureka 地址
  • RestTemplate 添加 @LoadBalanced 注解
  • 用服务提供者的服务名称远程调用

4.Ribbon 负载均衡


4.1.负载均衡原理


4.1.1.提问


上一节中,我们添加了 @LoadBalanced 注解,即可实现负载均衡功能,这是什么原理呢?


此处我们发出的请求明明是 http://userservice/user/1,最终是怎么变成 http://localhost:8081 的呢?


4.1.2.源码探究


为什么我们只输入了 service 名称就可以访问了呢?之前还要获取 ip 和端口。

显然有人帮我们根据 service 名称,获取到了服务实例的 ip 和端口。

它就是LoadBalancerInterceptor,这个类会在对 RestTemplate 的请求进行拦截。

然后从 Eureka 中获取服务列表(根据服务 id),随后利用负载均衡算法得到真实的服务地址信息,替换服务 id


  1. LoadBalancerIntercepor

可以看到这里的 intercept 方法,拦截了用户的 HttpRequest 请求,然后做了几件事:

  • request.getURI():获取请求 uri,本例中就是 http://user-service/user/8
  • originalUri.getHost():获取 uri 路径的主机名,其实就是服务 iduser-service
  • this.loadBalancer.execute():处理服务 id,和用户请求。

这里的 this.loadBalancerLoadBalancerClient 类型,我们继续跟入。


  1. LoadBalancerClient

继续跟入 LoadBalancerIntercepor 中的 execute 方法,来到 LoadBalancerClient

代码是这样的:

  • getLoadBalancer(serviceId):根据服务 id 获取 ILoadBalancer,而 ILoadBalancer 会拿着服务 ideureka 中获取服务列表并保存起来。
  • getServer(loadBalancer):利用内置的负载均衡算法,从服务列表中选择一个。本例中,可以看到获取了 8082 端口的服务

放行后,再次访问并跟踪,发现获取的是 8081

显然实现了负载均衡。


  1. 负载均衡策略 IRule

在刚才的代码中,可以看到获取服务是通过一个 getServer 方法来做负载均衡

继续跟踪源码 chooseServer 方法,发现这么一段代码

我们看看这个 rule 是谁

这里的 rule 默认值是一个 RoundRobinRule,根据类的介绍可知其为轮询。

到这里,整个负载均衡的流程我们就清楚了。


4.1.3.小结


SpringCloudRibbon 的底层采用了一个拦截器,拦截了 RestTemplate 发出的请求,对地址做了修改。

基本流程如下:

  • 拦截 RestTemplate 请求 http://user-service/user/1
  • RibbonLoadBalancerClient 会从请求 url 中获取服务名称,也就是 user-service
  • DynamicServerListLoadBalancer 根据 user-serviceeureka 拉取服务列表
  • eureka 返回列表,localhost:8081localhost:8082
  • IRule 利用内置负载均衡规则,从列表中选择一个,例如 localhost:8081
  • RibbonLoadBalancerClient 修改请求地址,用 localhost:8081 替代 userservice,得到 http://localhost:8081/user/1,发起真实请求

4.2.负载均衡策略


负载均衡的规则都定义在 IRule 接口中,而 IRule 有很多不同的实现类。

每一个子接口都是一种规则。


常见规则

内置负载均衡规则类规则描述
RoundRobinRule 简单轮询服务列表来选择服务器。它是 Ribbon 默认的负载均衡规则。
AvailabilityFilteringRule对以下两种服务器进行忽略

(1)在默认情况下,这台服务器如果 3 次连接失败,这台服务器就会被设置为 “短路” 状态。
短路状态将持续 30 秒,如果再次连接失败,短路的持续时间就会几何级地增加。

(2)并发数过高的服务器。
如果一个服务器的并发连接数过高,配置了 AvailabilityFilteringRule 规则的客户端也会将其忽略。
并发连接数的上限,可以由客户端的属性进行配置。
该属性为 <clientName>.<clientConfigNameSpace>.ActiveConnectionsLimit
WeightedResponseTimeRule为每一个服务器赋予一个权重值。
服务器响应时间越长,这个服务器的权重就越小。
这个规则会随机选择服务器,这个权重值会影响服务器的选择。
ZoneAvoidanceRule以区域可用的服务器为基础进行服务器的选择。
使用 Zone 对服务器进行分类,这个 Zone 可以理解为一个机房、一个机架等。
而后再对 Zone 内的多个服务做轮询。
BestAvailableRule忽略那些短路的服务器,并选择并发数较低的服务器。
RandomRule随机选择一个可用的服务器。
RetryRule重试机制的选择逻辑。

默认的实现就是 ZoneAvoidanceRule,是一种轮询方案。


4.3.自定义负载均衡策略


通过定义 IRule 实现可以修改负载均衡规则,有两种方式:代码的方式和配置文件的方式。


  • 代码方式:在 order-service 中的 OrderApplication 类中,定义一个新的 IRule

这种方案是作用于全局的。

也就是说,在 order-service 中调用任何服务时,都采用相同的负载均衡策略。

order-service 模块中的 src/main/java/cn/itcast/order/OrderApplication.java

/**
 * 自定义负载均衡规则:此处设置为随机
 *
 * @return
 */
@Bean
public IRule randomRule() {
    return new RandomRule();
}

  • 配置文件方式:在 order-serviceapplication.yml 文件中,添加新的配置也可以修改规则。

order-service 模块中的 src/main/resources/application.yml

user-service: # 给某个微服务配置负载均衡规则
  ribbon:
    NFLoadBalancerRuleClassName: comflix.loadbalancer.RandomRule # 负载均衡规则:此处设置为随机

  • 注意:一般使用默认的负载均衡规则,不做修改。

4.4.饥饿加载


Ribbon 默认是采用懒加载,即第一次访问时才会去创建 LoadBalanceClient,请求时间会很长。

而饥饿加载则会在项目启动时创建,降低第一次访问的耗时,通过配置开启饥饿加载。

比如我们可以在 order-service 模块内的 src/main/resources/application.yml 中配置加载信息。

ribbon:
  eager-load:
    enabled: true # 开启饥饿加载
    clients: user-service # 指定对 user-service 这个服务饥饿加载
ribbon:
  eager-load:
    enabled: true # 开启饥饿加载
    clients: # 指定饥饿加载的多个服务的名称
    	- user-service
    	- xxx-service

4.5.总结


1.Ribbon负载均衡规则

  • 规则接口是 IRule
  • 默认实现是 ZoneAvoidanceRule。根据 zone 选择服务列表,然后轮询

2.负载均衡自定义方式

  • 代码方式:配置灵活,但修改时需要重新打包发布
  • 配置方式:直观,方便,无需重新打包发布,但是无法做全局配置

3.饥饿加载

  • 通过配置文件开启饥饿加载
  • 通过配置文件指定饥饿加载的微服务名称

5.Nacos 注册中心


国内公司一般都推崇阿里巴巴的技术。

比如注册中心,SpringCloudAlibaba 也推出了一个名为 Nacos 的注册中心。


5.1.认识 Nacos


Nacos 是阿里巴巴的产品,现在是 SpringCloud 中的一个组件。

相比 Eureka 功能更加丰富,在国内受欢迎程度较高。


5.2.Windows 安装启动 Nacos


Windows 安装

  • 下载安装包
  • 解压
  • 端口配置
  • 启动
  • 访问
  • 小结

  1. 下载安装包

开发阶段采用单机安装即可。

NacosGitHub 页面,提供有下载链接,可以下载编译好的 Nacos 服务端或者源代码

GitHub 主页:https://github/alibaba/nacos

GitHubRelease 下载页:https://github/alibaba/nacos/releases

本课程采用 1.4.1.X 版本的 Nacos,课前资料已经准备了安装包。

Windows 版本使用 nacos-server-1.4.1.zip 包即可。

  1. 解压

将这个包解压到任意非中文目录下

目录说明:bin(启动脚本)、conf(配置文件)。

  1. 端口配置

Nacos 的默认端口是 8848,如果你电脑上的其它进程占用了 8848 端口,请先尝试关闭该进程。

如果无法关闭占用 8848 端口的进程,也可以进入 nacosconf 目录,修改配置文件中的端口。

  1. 启动

启动非常简单,进入 bin 目录,结构如下

然后执行命令即可(windows 命令)

startup.cmd -m standalone

其中,standalone 意为单机启动模式。

执行后的效果图

  1. 访问

在浏览器输入地址:http://127.0.0.1:8848/nacos 即可

默认的用户和密码都是 nacos

成功登录后,就会进入这样的页面。


  1. 小结

Nacos服务搭建

  • 下载安装包、解压到非中文命名的目录
  • bin 目录下运行指令:startup.cmd -m standalone

5.3.服务注册到 Nacos


NacosSpringCloudAlibaba 的组件。

SpringCloudAlibaba 也遵循 SpringCloud 中定义的服务注册、服务发现规范。

因此使用 Nacos 和使用 Eureka 对于微服务来说,并没有太大区别。

NacosEureka 的主要差异在于:依赖不同、服务地址不同


5.3.1.引入依赖


cloud-demo 父工程的 pom 文件中的 <dependencyManagement> 中引入 SpringCloudAlibaba 的依赖

<!-- Nacos 的管理依赖 -->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-alibaba-dependencies</artifactId>
    <version>2.2.6.RELEASE</version>
    <type>pom</type>
    <scope>import</scope>
</dependency>

然后在 user-serviceorder-service 中的 pom 文件中引入 nacos-discovery 依赖

同时也需要注释掉 eureka 的依赖。

<!-- Nacos 的客户端依赖 -->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>

5.3.2.配置 Nacos 地址


user-serviceorder-serviceapplication.yml 中添加 nacos 地址

spring:
  cloud:
    nacos:
      server-addr: localhost:8848

当然,此处也需注释掉 eureka 的地址


重启微服务后,登录 nacos 管理页面,我们在 “服务管理”“服务列表” 里可以发现微服务信息

“详情” 则有着更为详细的数据信息。


5.3.3.小结


Nacos 服务注册或发现

  • 引入 nacos.discovery 依赖
  • 配置 nacos 地址 spring.cloud.nacos.server-addr

5.4.Nacos 服务多级存储模型


5.4.1.Nacos 概述


一个服务可以有多个实例

例如我们的 user-service,可以有:127.0.0.1:8081127.0.0.1:8082127.0.0.1:8083

假如这些实例分布于全国各地的不同机房。

例如:127.0.0.1:8081(在上海机房)、127.0.0.1:8082(在上海机房)、127.0.0.1:8083(在杭州机房)

Nacos 就将同一机房内的实例划分为一个集群

也就是说,user-service 是服务,一个服务可以包含多个集群。

如杭州、上海,每个集群下可以有多个实例,形成分级模型。


微服务互相访问时,应该尽可能访问同集群实例,因为本地访问速度更快。

当本集群内不可用时,才访问其它集群。

如上图,杭州机房内的 order-service 应该优先访问同机房的 user-service


5.4.2.给 user-service 配置集群


修改 user-serviceapplication.yml 文件,添加集群配置

spring:
  cloud:
    nacos:
      server-addr: localhost:8848 # nacos 服务地址
      discovery:
        cluster-name: HZ # 集群名称
        # cluster-name: SH # 集群名称

我们可以给 UserApplication_1 服务和 UserApplciation_2 服务设置集群名称为:HZ

UserApplication_3 服务设置集群名称为 SH

  • 可以在前两个服务启动后,更改配置文件的内容,再启动第三个服务来达到上述的目的
  • 也可以直接通过 Idea 工具来设置集群名称
-Dserver.port=8083 -Dspring.cloud.nacos.discovery.cluster-name=SH


重启微服务,进入 Nacos 管理界面

服务管理/服务列表

服务管理/服务列表/详情


5.4.3.同集群优先的负载均衡


默认的 ZoneAvoidanceRule 并不能实现根据同集群优先来实现负载均衡。

因此 Nacos 中提供了一个 NacosRule 的实现,可以优先从同集群中挑选实例。

  1. order-service 配置集群信息

修改 order-serviceapplication.yml 文件,添加集群配置。

spring:
  cloud:
    nacos:
      server-addr: localhost:8848 # nacos 的服务端地址
      discovery:
        cluster-name: HZ # 配置集群名称,即机房地址
  1. 修改负载均衡规则

修改 order-serviceapplication.yml 文件,修改负载均衡规则。

user-service:
  ribbon:
    NFLoadBalancerRuleClassName: com.alibaba.cloud.nacos.ribbon.NacosRule # 负载均衡规则 

NacosRule 优先选择本地集群,再在本地集群内的多个服务采用随机方式进行负载均衡。


5.4.4.小结


Nacos 服务分级存储模型

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

如何设置实例的集群属性

  • 修改 application.yml 文件,添加 spring.cloud.nacos.discovery.cluster-name 属性

NacosRule 负载均衡策略

  1. 优先选择同集群服务实例列表
  2. 本地集群找不到提供者,才去其它集群寻找,并且会报警告
  3. 确定了可用实例列表后,再采用随机负载均衡挑选实例

5.5.权重配置


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

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

但默认情况下 NacosRule 是同集群内随机挑选,不会考虑机器的性能问题。

因此,Nacos 提供了权重配置来控制访问频率,权重越大则访问频率越高。


  • nacos 控制台,找到 user-service 的实例列表,点击编辑,即可修改权重。

  • 在弹出的编辑窗口,修改权重

  • 注意:如果权重修改为 0,则该实例永远不会被访问。

实际的权重控制

  • Nacos 控制台可以设置实例的权重值,0~1 之间
  • 同集群内的多个实例,权重越高被访问的频率越高
  • 权重设置为 0 则完全不会被访问

5.6.环境隔离


5.6.1.简单介绍


Nacos 提供了 namespace 来实现环境隔离功能。

  • nacos 中可以有多个 namespace
  • namespace 下可以有 groupservice
  • 不同 namespace 之间相互隔离,例如不同 namespace 的服务互相不可见


5.6.2.创建 namespace


默认情况下,所有 servicedatagroup 都在同一个 namespace,名为 public

我们可以点击页面新增按钮,添加一个 namespace

然后,填写表单


5.6.3.给微服务配置 namespace


给微服务配置 namespace 只能通过修改配置来实现。

例如,修改 order-serviceapplication.yml 文件

spring:
  cloud:
    nacos:
      server-addr: localhost:8848
      discovery:
        cluster-name: HZ
        namespace: 492a7d5d-237b-46a1-a99a-fa8e98e4b0f9 # 命名空间,填 ID

重启 order-service 后,访问控制台,可以看到下面的结果

此时访问 order-service,因为 namespace 不同,会导致找不到 userservice,控制台会报错


5.6.4.小结


Nacos 环境隔离

  1. 每个 namespace 都有唯一 id
  2. 服务设置 namespace 时要写 id 而不是名称
  3. 不同 namespace 下的服务互相不可见

5.7.Nacos 与 Eureka 的区别


Nacos 的服务实例分为两种类型

  1. 临时实例:如果实例宕机超过一定时间,会从服务列表剔除,默认的类型。
  2. 非临时实例:如果实例宕机,不会从服务列表剔除,也可以叫永久实例。

配置一个服务实例为永久实例,比如这里可以选择 order-service 服务实例。

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

NacosEureka 整体结构类似,服务注册、服务拉取、心跳等待,但是也存在一些差异。


  • Nacos 与 eureka 的共同点
    • 都支持服务注册和服务拉取
    • 都支持服务提供者心跳方式做健康检测

  • Nacos 与 Eureka 的区别
    • Nacos 支持服务端主动检测提供者状态:临时实例采用心跳模式,非临时实例采用主动检测模式
    • 临时实例心跳不正常会被剔除,非临时实例则不会被剔除
    • Nacos 支持服务列表变更的消息推送模式,服务列表更新更及时
    • Nacos 集群默认采用 AP 方式;当集群中存在非临时实例时,采用 CP 模式。而 Eureka 集群采用 AP 方式

# SpringCloudDay02


SpringCloud 学习 Day02实用篇-2

  • Nacos 配置管理
  • Feign 远程调用
  • GetWay 服务网关

6.Nacos 配置管理


Nacos 除了可以做注册中心,同样可以做配置管理来使用。


6.1.统一配置管理


当微服务部署的实例越来越多,达到数十、数百时,逐个修改微服务配置就会让人抓狂,而且很容易出错。

我们需要一种统一配置管理方案,可以集中管理所有实例的配置。




6.1.1.在 Nacos 中添加配置文件


在 Nacos 中管理配置

在弹出表单中填写配置信息

  • 注意
    • 项目的核心配置,需要热更新的配置才有放到 nacos 管理的必要。
    • 基本不会变更的一些配置还是保存在微服务本地比较好。

6.1.2.微服务配置拉取


微服务要拉取 nacos 中管理的配置,并且与本地的 application.yml 配置合并,才能完成项目启动。

但如果尚未读取 application.yml,又如何得知 nacos 地址呢?

因此 spring 引入了一种新的配置文件:bootstrap.yaml 文件,会在 application.yml 之前被读取。


  1. 引入 nacos-config 依赖

user-service 服务中,引入 nacos-config 的客户端依赖

<!-- Nacos 配置管理依赖-->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
  1. 添加 bootstrap.yaml

user-service 中添加一个 bootstrap.yaml 文件

spring:
  application:
    name: userservice # 服务名称
  profiles:
    active: dev #开发环境,这里是 dev 
  cloud:
    nacos:
      server-addr: localhost:8848 # Nacos 地址
      config:
        file-extension: yaml # 文件后缀名

同时,这里需要注释掉之前在 user-serviceapplication.yml 文件中的一些重复的内容

诸如 application.namecloud.nacos.xxx 的内容。

这里会根据 spring.cloud.nacos.server-addr 获取 nacos 地址。

再根据 ${spring.application.name}-${spring.profiles.active}.${spring.cloud.nacos.config.file-extension} 作为文件 id ,来读取配置。

本例中,就是去读取 userservice-dev.yaml

  1. 读取 nacos 配置

user-service 中的 UserController 中添加业务逻辑,读取 pattern.dateformat 配置

/* 目前 UserController 需要导入的包(这里容易导错包,故直接贴上有关包的代码)*/
import cn.itcast.user.pojo.User;
import cn.itcast.user.service.UserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
@Value("${pattern.dateformat}")
//@NacosValue("${pattern.dateformat}")
private String dateformat;

@GetMapping("now")
public String now() {
    // 完成日期格式化并返回
    System.out.println(dateformat);
    return LocalDateTime.now().format(DateTimeFormatter.ofPattern(dateformat));
}

效果就是这样子。

我这里使用的是 Postman 工具,当然你也可以直接在页面上访问。


6.1.3.小结


  • Nacos 中添加配置文件
  • 在微服务中引入 nacosconfig 依赖
  • 在微服务中添加 bootstrap.yml,配置 nacos 地址、当前环境、服务名称、文件后缀名。
    • 这些决定了程序启动时去 nacos 读取哪个文件

6.2.配置热更新


我们最终的目的,是修改 nacos 中的配置后,微服务中无需重启即可让配置生效,也就是配置热更新

要实现配置热更新,有两种方式可供选择

  • @Value 注入的变量所在类上添加注解 @RefreshScope
  • 使用 @ConfigurationProperties 注解代替 @Value 注解

方式一:在 @Value 注入的变量所在类上添加注解 @RefreshScope

user-service 服务下的 src/main/java/cn/itcast/user/web/UserController.java


方式二:使用 @ConfigurationProperties 注解代替 @Value 注解。

user-service 服务中,添加一个类,读取 patterrn.dateformat属性

user-service 下的 src/main/java/cn/itcast/user/config/PatternProperties.java

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

若代码报红:Spring Boot Configuration Annotation Processor not configured,可以在 pom.xml 中添加依赖

user-service 下的 pom.xml 导入 spring-boot-configuration-processor 的相关依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-configuration-processor</artifactId>
    <optional>true</optional>
</dependency>

UserController 类中使刚刚创建的类代替 @Value,并且注释掉之前的代码

修改 user-service 下的 src/main/java/cn/itcast/user/web/UserController.java 的代码

//@RefreshScope // 需要注释的注解
//@Value("${pattern.dateformat}") // 注释掉的代码
//private String dateformat; // 注释掉的代码

@Autowired
private PatternProperties patternProperties;

//编写 Controller,通过日期格式化器现在的时间并返回
@GetMapping("now")
public String now() {
    //return LocalDateTime.now().format(DateTimeFormatter.ofPattern(dateformat));// 注释掉的代码
    return LocalDateTime.now().format(DateTimeFormatter.ofPattern(patternProperties.getDateformat()));
}

小结

Nacos 配置更改后,微服务可以实现热更新

  • 方式一:通过 @Value 注解注入,结合 @RefreshScope 来刷新
  • 方式二:通过 @ConfigurationProperties 注入,自动刷新

注意事项

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

6.3.配置共享


其实微服务启动时,会去 nacos 读取多个配置文件,例如:

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

无论 profile 如何变化,[spring.application.name].yaml 这个文件一定会加载

因此多环境共享配置可以写入这个文件。

优先级:[服务名]-[环境].yaml > [服务名].yaml > 本地配置


步骤

  1. 添加一个环境共享配置
  2. user-service 中读取共享配置
  3. 运行两个 UserApplication,使用不同的 profile
  4. 配置共享的优先级

  1. 添加一个环境共享配置

我们在 nacos 中添加一个 userservice.yaml 文件


  1. user-service 中读取共享配置

user-service 服务中,修改 PatternProperties 类,读取新添加的属性

user-service 下的 src/main/java/cn/itcast/user/config/PatternProperties.java

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

user-service 服务中,修改 UserController,添加一个方法:

@GetMapping("/prop")
public PatternProperties properties() {
    return patternProperties;
}

  1. 运行两个 UserApplication,使用不同的 profile

UserApplication_18081)使用的 profiledev

UserApplication_28082)使用的 profiletest(这里就只贴一张图了)

启动 UserApplication_1UserApplication_2,访问 http://localhost:8081/user/prophttp://localhost:8082/user/prop

可以看出来,不管是 dev,还是 test 环境,都读取到了 envSharedValue 这个属性的值。


  1. 配置共享的优先级

nacos、服务本地同时出现相同属性时,优先级有高低之分


6.4.搭建 Nacos 集群


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


6.4.1.集群架构图


官方给出的 Nacos 集群图

其中包含 3nacos 节点,然后一个负载均衡器代理 3Nacos

这里负载均衡器可以使用 nginx

我们计划的集群结构

三个 nacos 节点的地址

节点ipport
nacos1192.168.150.18845
nacos2192.168.150.18846
nacos3192.168.150.18847

6.4.2.搭建集群的基本步骤


  • 搭建 MySQL 集群并初始化数据库表
  • 下载解压 nacos
  • 修改集群配置(节点信息)、数据库配置
  • 分别启动多个 nacos 节点
  • nginx 反向代理

6.4.3.初始化数据库


Nacos 默认数据存储在内嵌数据库 Derby 中,不属于生产可用的数据库。

官方推荐的最佳实践是使用带有主从的高可用数据库集群。

这里我们以单点的数据库为例来讲解。

首先新建一个数据库,命名为 nacos,字符集是 utf8,排序规则是 utf8_general_ci

而后导入下面的 SQL

CREATE TABLE `config_info` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'id',
  `data_id` varchar(255) NOT NULL COMMENT 'data_id',
  `group_id` varchar(255) DEFAULT NULL,
  `content` longtext NOT NULL COMMENT 'content',
  `md5` varchar(32) DEFAULT NULL COMMENT 'md5',
  `gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间',
  `src_user` text COMMENT 'source user',
  `src_ip` varchar(50) DEFAULT NULL COMMENT 'source ip',
  `app_name` varchar(128) DEFAULT NULL,
  `tenant_id` varchar(128) DEFAULT '' COMMENT '租户字段',
  `c_desc` varchar(256) DEFAULT NULL,
  `c_use` varchar(64) DEFAULT NULL,
  `effect` varchar(64) DEFAULT NULL,
  `type` varchar(64) DEFAULT NULL,
  `c_schema` text,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_configinfo_datagrouptenant` (`data_id`,`group_id`,`tenant_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='config_info';

/******************************************/
/*   数据库全名 = nacos_config   */
/*   表名称 = config_info_aggr   */
/******************************************/
CREATE TABLE `config_info_aggr` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'id',
  `data_id` varchar(255) NOT NULL COMMENT 'data_id',
  `group_id` varchar(255) NOT NULL COMMENT 'group_id',
  `datum_id` varchar(255) NOT NULL COMMENT 'datum_id',
  `content` longtext NOT NULL COMMENT '内容',
  `gmt_modified` datetime NOT NULL COMMENT '修改时间',
  `app_name` varchar(128) DEFAULT NULL,
  `tenant_id` varchar(128) DEFAULT '' COMMENT '租户字段',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_configinfoaggr_datagrouptenantdatum` (`data_id`,`group_id`,`tenant_id`,`datum_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='增加租户字段';


/******************************************/
/*   数据库全名 = nacos_config   */
/*   表名称 = config_info_beta   */
/******************************************/
CREATE TABLE `config_info_beta` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'id',
  `data_id` varchar(255) NOT NULL COMMENT 'data_id',
  `group_id` varchar(128) NOT NULL COMMENT 'group_id',
  `app_name` varchar(128) DEFAULT NULL COMMENT 'app_name',
  `content` longtext NOT NULL COMMENT 'content',
  `beta_ips` varchar(1024) DEFAULT NULL COMMENT 'betaIps',
  `md5` varchar(32) DEFAULT NULL COMMENT 'md5',
  `gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间',
  `src_user` text COMMENT 'source user',
  `src_ip` varchar(50) DEFAULT NULL COMMENT 'source ip',
  `tenant_id` varchar(128) DEFAULT '' COMMENT '租户字段',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_configinfobeta_datagrouptenant` (`data_id`,`group_id`,`tenant_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='config_info_beta';

/******************************************/
/*   数据库全名 = nacos_config   */
/*   表名称 = config_info_tag   */
/******************************************/
CREATE TABLE `config_info_tag` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'id',
  `data_id` varchar(255) NOT NULL COMMENT 'data_id',
  `group_id` varchar(128) NOT NULL COMMENT 'group_id',
  `tenant_id` varchar(128) DEFAULT '' COMMENT 'tenant_id',
  `tag_id` varchar(128) NOT NULL COMMENT 'tag_id',
  `app_name` varchar(128) DEFAULT NULL COMMENT 'app_name',
  `content` longtext NOT NULL COMMENT 'content',
  `md5` varchar(32) DEFAULT NULL COMMENT 'md5',
  `gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间',
  `src_user` text COMMENT 'source user',
  `src_ip` varchar(50) DEFAULT NULL COMMENT 'source ip',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_configinfotag_datagrouptenanttag` (`data_id`,`group_id`,`tenant_id`,`tag_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='config_info_tag';

/******************************************/
/*   数据库全名 = nacos_config   */
/*   表名称 = config_tags_relation   */
/******************************************/
CREATE TABLE `config_tags_relation` (
  `id` bigint(20) NOT NULL COMMENT 'id',
  `tag_name` varchar(128) NOT NULL COMMENT 'tag_name',
  `tag_type` varchar(64) DEFAULT NULL COMMENT 'tag_type',
  `data_id` varchar(255) NOT NULL COMMENT 'data_id',
  `group_id` varchar(128) NOT NULL COMMENT 'group_id',
  `tenant_id` varchar(128) DEFAULT '' COMMENT 'tenant_id',
  `nid` bigint(20) NOT NULL AUTO_INCREMENT,
  PRIMARY KEY (`nid`),
  UNIQUE KEY `uk_configtagrelation_configidtag` (`id`,`tag_name`,`tag_type`),
  KEY `idx_tenant_id` (`tenant_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='config_tag_relation';

/******************************************/
/*   数据库全名 = nacos_config   */
/*   表名称 = group_capacity   */
/******************************************/
CREATE TABLE `group_capacity` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `group_id` varchar(128) NOT NULL DEFAULT '' COMMENT 'Group ID,空字符表示整个集群',
  `quota` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '配额,0表示使用默认值',
  `usage` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '使用量',
  `max_size` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '单个配置大小上限,单位为字节,0表示使用默认值',
  `max_aggr_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '聚合子配置最大个数,,0表示使用默认值',
  `max_aggr_size` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '单个聚合数据的子配置大小上限,单位为字节,0表示使用默认值',
  `max_history_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '最大变更历史数量',
  `gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_group_id` (`group_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='集群、各Group容量信息表';

/******************************************/
/*   数据库全名 = nacos_config   */
/*   表名称 = his_config_info   */
/******************************************/
CREATE TABLE `his_config_info` (
  `id` bigint(64) unsigned NOT NULL,
  `nid` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  `data_id` varchar(255) NOT NULL,
  `group_id` varchar(128) NOT NULL,
  `app_name` varchar(128) DEFAULT NULL COMMENT 'app_name',
  `content` longtext NOT NULL,
  `md5` varchar(32) DEFAULT NULL,
  `gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `src_user` text,
  `src_ip` varchar(50) DEFAULT NULL,
  `op_type` char(10) DEFAULT NULL,
  `tenant_id` varchar(128) DEFAULT '' COMMENT '租户字段',
  PRIMARY KEY (`nid`),
  KEY `idx_gmt_create` (`gmt_create`),
  KEY `idx_gmt_modified` (`gmt_modified`),
  KEY `idx_did` (`data_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='多租户改造';


/******************************************/
/*   数据库全名 = nacos_config   */
/*   表名称 = tenant_capacity   */
/******************************************/
CREATE TABLE `tenant_capacity` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `tenant_id` varchar(128) NOT NULL DEFAULT '' COMMENT 'Tenant ID',
  `quota` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '配额,0表示使用默认值',
  `usage` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '使用量',
  `max_size` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '单个配置大小上限,单位为字节,0表示使用默认值',
  `max_aggr_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '聚合子配置最大个数',
  `max_aggr_size` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '单个聚合数据的子配置大小上限,单位为字节,0表示使用默认值',
  `max_history_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '最大变更历史数量',
  `gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_tenant_id` (`tenant_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='租户容量信息表';


CREATE TABLE `tenant_info` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'id',
  `kp` varchar(128) NOT NULL COMMENT 'kp',
  `tenant_id` varchar(128) default '' COMMENT 'tenant_id',
  `tenant_name` varchar(128) default '' COMMENT 'tenant_name',
  `tenant_desc` varchar(256) DEFAULT NULL COMMENT 'tenant_desc',
  `create_source` varchar(32) DEFAULT NULL COMMENT 'create_source',
  `gmt_create` bigint(20) NOT NULL COMMENT '创建时间',
  `gmt_modified` bigint(20) NOT NULL COMMENT '修改时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_tenant_info_kptenantid` (`kp`,`tenant_id`),
  KEY `idx_tenant_id` (`tenant_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='tenant_info';

CREATE TABLE `users` (
	`username` varchar(50) NOT NULL PRIMARY KEY,
	`password` varchar(500) NOT NULL,
	`enabled` boolean NOT NULL
);

CREATE TABLE `roles` (
	`username` varchar(50) NOT NULL,
	`role` varchar(50) NOT NULL,
	UNIQUE INDEX `idx_user_role` (`username` ASC, `role` ASC) USING BTREE
);

CREATE TABLE `permissions` (
    `role` varchar(50) NOT NULL,
    `resource` varchar(255) NOT NULL,
    `action` varchar(8) NOT NULL,
    UNIQUE INDEX `uk_role_permission` (`role`,`resource`,`action`) USING BTREE
);

INSERT INTO users (username, password, enabled) VALUES ('nacos', '$2a$10$EuWPZHzz32dJN7jexM34MOeYirDdFAZm2kuWj7VEOJhhZkDrxfvUu', TRUE);

INSERT INTO roles (username, role) VALUES ('nacos', 'ROLE_ADMIN');

6.4.5.下载 Nacos


nacosGitHub 上有下载地址:https://github/alibaba/nacos/tags,可选择任意版本下载。

本例中使用的是 1.4.1 版本


6.4.4.配置 Nacos


将包解压到非中文目录,其中 bin 目录:启动脚本,conf 目录:配置文件

进入 nacosconf 目录,复制配置文件 cluster.conf.example,命名为 cluster.conf

之后添加内容

127.0.0.1:8845
127.0.0.1.8846
127.0.0.1.8847

修改 application.properties 文件,添加数据库配置

spring.datasource.platform=mysql

db.num=1

db.url.0=jdbc:mysql://127.0.0.1:3306/nacos?characterEncoding=utf8&connectTimeout=1000&socketTimeout=3000&autoReconnect=true&useUnicode=true&useSSL=false&serverTimezone=UTC
db.user.0=root
db.password.0=123

6.4.6.启动


nacos 文件夹复制三份,分别命名为:nacos1nacos2nacos3

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

nacos1

server.port=8845

nacos2

server.port=8846

nacos3

server.port=8847

然后分别启动三个 nacos 节点

startup.cmd

当三个终端都出现:Nacos started successfully in cluster mode. use external storage,表示集群启动成功。


6.4.7.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;
    }
}

而后在浏览器访问:http://localhost/nacos 即可。

代码中 application.yml 文件配置如下

spring:
  cloud:
    nacos:
      server-addr: localhost:80 # Nacos地址

之后我们可以启动两个 user-service 服务,并在 Nacos 界面设置一个配置。

之后我们可以在数据库中发现,相关记录已经存储到数据库中的 config_info 表了。

说明持久化已经成功了。


6.4.8.优化


  • 实际部署时,需要给做反向代理的 nginx 服务器设置一个域名,这样后续如果有服务器迁移 nacos 的客户端也无需更改配置
  • Nacos 的各个节点应该部署到多个不同服务器,做好容灾和隔离

7.Feign 远程调用


7.1.RestTemplate 存在的问题


这是之前利用 RestTemplate 发起远程调用的代码

String url = "http://user-service/user/" + order.getUserId();
User user = restTemplate.getForObject(url, User.class);

存在下面的问题: 代码可读性差,编程体验不统一; 参数复杂 URL 难以维护。


Feign 是一个声明式的 http 客户端,官方地址:https://github/OpenFeign/feign

其作用就是帮助我们优雅的实现 http 请求的发送,解决上面提到的问题。


7.2.Feign 替代 RestTemplate


7.2.1.导入依赖


我们在 order-service 服务的 pom 文件中引入 feign 的依赖

<!-- Feign 客户端依赖 -->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>

7.2.2.添加注解


order-service 的启动类添加注解开启 Feign 的功能:@EnableFeignClients


7.2.3.编写 Feign 的客户端


order-service 中新建一个接口,内容如下

order-service 服务下的 src/main/java/cn/itcast/order/clients/UserClient.java

package cn.itcast.order.clients;

import cn.itcast.order.pojo.User;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;

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

这个客户端主要是基于 SpringMVC 的注解来声明远程调用的信息

  • 服务名称:userservice
  • 请求方式:GET
  • 请求路径:/user/{id}
  • 请求参数:Long id
  • 返回值类型:User

这样,Feign 就可以帮助我们发送 http 请求,无需自己使用 RestTemplate 来发送了。


7.2.4.测试


修改 order-service 中的 OrderService 类中的 queryOrderById 方法

并且使用 Feign 客户端代替 RestTemplate

@Autowired
private UserClient userClient;

public Order queryOrderById(Long orderId) {
    // 1.查询订单
    Order order = orderMapper.findById(orderId);
    // 2.用 Feign 远程调用
    User user = userClient.findById(order.getUserId());
    // 3.封装 user 到 Order
    order.setUser(user);
    // 4.返回
    return order;
}

此外,记得修改 order-service 服务下的 application.yml 文件的内容

  1. 注释掉 namespace
  2. cloud.nacos.server-addr 的值改为 localhost:80

观察 Idea 控制台可以发现,此时我们不仅实现了远程调用,还实现了负载均衡。

事实上,我们可以通过观察 order-service 依赖的树形图,

来了解到 spring-cloud-starter-openfeign 中已经集成了 spring-cloud-netflix-ribbon,自动实现了负载均衡。


7.2.5.小结


使用 Feign 的步骤

  1. 引入依赖
  2. 添加 @EnableFeignClients 注解
  3. 编写 FeignClient 接口
  4. 使用 FeignClient 中定义的方法代替 RestTemplate

7.3.自定义配置


7.3.1.自定义配置表


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

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

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

下面以日志为例来演示如何自定义配置。


7.3.2.配置文件方式


基于配置文件修改 feign 的日志级别可以针对单个服务:

feign:  
  client:
    config: 
      userservice: # 针对某个微服务的配置
        loggerLevel: FULL #  日志级别 

也可以针对所有服务:

feign:  
  client:
    config: 
      default: # 这里用 default 就是全局配置,如果是写服务名称,则是针对某个微服务的配置
        loggerLevel: FULL #  日志级别 

而日志的级别分为四种:

  • NONE:不记录任何日志信息,这是默认值。
  • BASIC:仅记录请求的方法,URL 以及响应状态码和执行时间
  • HEADERS:在 BASIC 的基础上,额外记录了请求和响应的头信息
  • FULL:记录所有请求和响应的明细,包括头信息、请求体、元数据。

7.3.3.Java 代码方式


也可以基于 Java 代码来修改日志级别,先声明一个类,然后声明一个 Logger.Level 的对象

比如我可以在 order-service 服务下创建一个类:cn/itcast/order/config/DefaultFeignConfiguration.java

public class DefaultFeignConfiguration  {
    @Bean
    public Logger.Level feignLogLevel(){
        return Logger.Level.BASIC; // 日志级别为 BASIC
    }
}

如果要全局生效,将其放到启动类的 @EnableFeignClients 这个注解中:

比如这里就可以放在 order-service 服务下的 cn/itcast/order/OrderApplication.java

@EnableFeignClients(defaultConfiguration = DefaultFeignConfiguration .class) 

如果是局部生效,则把它放到对应的 @FeignClient 这个注解中:

比如这里就可以放在 order-service 服务下的 cn/itcast/order/clients/UserClient.java

@FeignClient(value = "userservice", configuration = DefaultFeignConfiguration .class) 

7.3.4.小结


Feign 的日志配置

  1. 方式一是配置文件,feign.client.config.xxx.loggerlevel
    1. 如果 xxx 是 default,则代表全局
    2. 如果 xxx 是服务名称(例如 userservice),那么它代表的就是某服务
  2. 方式二是 java 代码配置 Logger.Level 这个 Bean
    1. 如果在 @EnableFeignClients 注解声明则代表全局
    2. 如果在 @FeignClient 注解中声明则代表某服务

7.4.Feign 使用优化


7.4.1.分析


Feign 底层发起 http 请求,依赖于其它的框架。

其底层客户端实现包括:

  • URLConnection:默认实现,不支持连接池
  • Apache HttpClient:支持连接池
  • OKHttp:支持连接池

因此提高 Feign 的性能主要手段就是使用连接池代替默认的 URLConnection

日志界别的话,最好用 basicnone

这里我们用 Apache的HttpClient 来演示。


7.4.2.引入依赖


order-servicepom.xml 文件中引入 ApacheHttpClient 依赖

<!-- httpClient 的依赖 -->
<dependency>
    <groupId>io.github.openfeign</groupId>
    <artifactId>feign-httpclient</artifactId>
</dependency>

7.4.3.配置连接池


order-serviceapplication.yml 中添加配置

feign:
  client:
    config:
      default: # default 全局的配置
        loggerLevel: BASIC # 日志级别,BASIC 就是基本的请求和响应信息
  httpclient:
    enabled: true # 开启 feign 对 HttpClient 的支持
    max-connections: 200 # 最大的连接数
    max-connections-per-route: 50 # 每个路径的最大连接数

接下来,在 FeignClientFactoryBean 中的 loadBalance 方法中打断点:

Debug 方式启动 order-service 服务,可以看到这里的 client,底层就是 Apache HttpClient


7.4.4.总结


Feign 的优化

  1. 日志级别尽量用 basic
  2. 使用 HttpClientOKHttp 代替 URLConnection
    1. 引入 feign-httpClient 依赖
    2. 配置文件开启 httpClient 功能,设置连接池参数

7.5.最佳实践


7.5.1.分析


最佳实践,就是使用过程中总结的经验,最好的一种使用方式。

经由观察可以发现,Feign 的客户端与服务提供者的 controller 代码非常相似

feign 客户端

UserController

有没有一种办法简化这种重复的代码编写呢?


7.5.2.继承方式


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


一样的代码可以通过继承来共享

  1. 定义一个 API 接口,利用定义方法,并基于 SpringMVC 注解做声明
  2. Feign 客户端和 Controller 都集成改接口

优点

  • 简单、实现了代码共享

缺点

  • 服务提供方、服务消费方紧耦合
  • 参数列表中的注解映射并不会继承,因此 Controller 中必须再次声明方法、参数列表、注解


7.5.3.抽取方式


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

例如,将 UserClientUserFeign 的默认配置都抽取到一个 feign-api 包中,所有微服务引用该依赖包,即可直接使用。

实现最佳实践方式二(抽取方式)的步骤如下

  1. 首先创建一个 module,命名为 feign-api,然后引入 feignstarter 依赖
  2. order-service 中编写的 UserClientUserDefaultFeignConfiguration 都复制到 feign-api 项目中
  3. order-service 中引入 feign-api 的依赖
  4. 修改 order-service 中的所有与上述三个组件有关的 import 部分,改成导入 feign-api 中的包
  5. 重启测试

7.5.4.小结


Feign 的最佳实践

  • 继承方式:让 controllerFeignClient 继承同一接口
  • 抽取方式:将 FeignClientPOJOFeign 的默认配置都定义到一个项目中,供所有消费者使用

7.6.实现基于抽取的最佳实践


7.6.1.抽取


首先创建一个 module,命名为 feign-api

feign-api 中然后引入 feignstarter 依赖

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

然后,order-service 中编写的 UserClientUserDefaultFeignConfiguration 都复制到 feign-api 项目中。

下图为最终的项目结构


7.6.2.使用 feign-api


  1. order-service 中使用 feign-api

首先,删除 order-service 中的 UserClientUserDefaultFeignConfiguration 等类或接口。

order-servicepom.xml 文件中中引入 feign-api 的依赖

<dependency>
    <groupId>cn.itcast.demo</groupId>
    <artifactId>feign-api</artifactId>
    <version>1.0</version>
</dependency>

修改 order-service 中的所有与上述三个组件有关的导包部分,改成导入 feign-api 中的包

OrderOrderServiceOrderApplication


7.6.3.重启测试


重启 OrderApplicationIdea 控制台报错,启动失败。

Field userClient in cn.itcast.order.service.OrderService 
	required a bean of type 'cn.itcast.feign.clients.UserClient' 
	that could not be found.

这是因为 UserClient 现在在 cn.itcast.feign.clients 包下,

order-service@EnableFeignClients 注解是在 cn.itcast.order 包下,不在同一个包,无法扫描到 UserClient


7.6.4.解决扫描包问题


当定义的 FeignClient 不在 SpringBootApplication 的扫描包范围时,这些 FeignClient 无法使用。

我们需要导入包。

不同包的 FeignClient 的导入有两种方式

  1. @EnableFeignClients 注解中添加 basePackages,指定 FeignClient 所在的包
  2. @EnableFeignClients 注解中添加 clients,指定具体 FeignClient 的字节码

方式一

  • 指定 Feign 应该扫描的包
@EnableFeignClients(basePackages = "cn.itcast.feign.clients")

方式二推荐

  • 指定需要加载的 Client 接口:
@EnableFeignClients(clients = {UserClient.class})

例如我们可以在 order-service 服务下的 src/main/java/cn/itcast/order/OrderApplication.java 上方添加注解

@MapperScan("cn.itcast.order.mapper")
@SpringBootApplication
@EnableFeignClients(clients = UserClient.class, defaultConfiguration = DefaultFeignConfiguration.class)
public class OrderApplication {
	... ...
}

8.Gateway 服务网关


Spring Cloud GatewaySpring Cloud 的一个全新项目。

该项目是基于 Spring 5.0Spring Boot 2.0Project Reactor 等响应式编程和事件流技术开发的网关,

它旨在为微服务架构提供一种简单有效的统一的 API 路由管理方式。


8.1.为什么需要网关


Gateway 网关是我们服务的守门神,所有微服务的统一入口。

网关的核心功能特性:请求路由、权限控制、限流


权限控制:网关作为微服务入口,需要校验用户是是否有请求资格,如果没有则进行拦截。

路由和负载均衡

  • 一切请求都必须先经过 gateway,但网关不处理业务。
  • 而是根据某种规则,把请求转发到某个微服务,这个过程叫做路由。
  • 当然路由的目标服务有多个时,还需要做负载均衡。

限流:当请求流量过高时,在网关中按照下流的微服务能够接受的速度来放行请求,避免服务压力过大。


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

Zuul 是基于 Servlet 的实现,属于阻塞式编程。

SpringCloudGateway 则是基于 Spring5 中提供的 WebFlux,属于响应式编程的实现,具备更好的性能。


架构图

  • 网关功能:1.身份认证和权限校验;2.服务路由、负载均衡;3.请求限流


8.2.gateway 快速入门


下面,我们就演示下网关的基本路由功能。

基本步骤

  1. 创建 SpringBoot 工程 gateway,引入网关依赖
  2. 编写启动类
  3. 编写基础配置和路由规则
  4. 启动网关服务进行测试

8.2.1.创建 gateway 服务,引入依赖


创建服务


引入依赖

gateway 服务下的 pom.xml 文件

<!--网关 gateway 依赖-->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>

<!-- nacos 服务注册发现依赖 -->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>

8.2.2.编写启动类


gateway 服务下的 src/main/java/cn/itcast/gateway/GatewayApplication.java

package cn.itcast.gateway;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class GatewayApplication {
    public static void main(String[] args) {
        SpringApplication.run(GatewayApplication.class, args);
    }
}

8.2.3.编写基础配置和路由规则


创建 application.yml 文件

server:
  port: 10010 # 网关端口
spring:
  application:
    name: gateway # 服务名称
  cloud:
    nacos:
      # 我这里依旧使用的是 Nacos 集群
      # server-addr: localhost:8848 # nacos 地址 
      server-addr: localhost:80 # 之前设置的 nacos 的集群地址
    gateway:
      routes: # 网关路由配置
        - id: user-service # 路由 id,自定义,只要唯一即可
          # uri: http://127.0.0.1:8081 # 路由的目标地址 http 就是固定地址
          uri: lb://userservice # 路由的目标地址 lb 就是负载均衡,后面跟服务名称
          predicates: # 路由断言,也就是判断请求是否符合路由规则的条件
            - Path=/user/** # 这个是按照路径匹配,只要以 /user/ 开头就符合要求
		- id: order-service
          uri: lb://orederservice
          predicates:
            - Path=/order/**

我们将符合 Path 规则的一切请求,都代理到 uri参数指定的地址。

本例中,我们将 /user/** 开头的请求,代理到 lb://userservice

lbloadBalance)是负载均衡,根据服务名拉取服务列表,实现负载均衡。


8.2.4.重启测试


重启网关,访问 http://localhost:10010/user/1 时,符合 /user/** 规则,

请求转发到 uri:http://userservice/user/1,得到了结果

这里需要注意的地方是:

第一点

确定自己使用的是 Nacos 单例环境还是 Nacos 集群环境,不同环境的服务下的配置文件都要进行内容的更改。

spring.cloud.nacos.server-addr: localhost:80 是之前配置的集群环境;

spring.cloud.nacos.server-addr: localhost:8848Nacos 的默认地址,以单机方式启动:startup.cmd -m standalone

第二点

如果访问 http://localhost:10010/order/101 时,报 503 错误的话,

请检查 order-servie 服务下的 application.yml,注释掉 spring.cloud.nacos.discovery.cluster-name

从而确保所有服务都在一个集群环境里。

这两个坑我是一个不剩的全踩上去了。


8.2.5.网关路由的流程图


搭建网关服务


8.2.6.小结


网关搭建步骤

  1. 创建项目,引入 nacos 服务发现和 gateway 依赖
  2. 配置 application.yml,包括服务基本信息、nacos 地址、路由

路由配置

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

接下来,就重点来学习路由断言和路由过滤器的详细知识。


8.3.断言工厂


路由断言工厂 Route Predicate Factory

predicates:路由断言,判断请求是否符合要求,符合则转发到路由目的地。


我们在配置文件中写的断言规则只是字符串,这些字符串会被 Predicate Factory 读取并处理,转变为路由判断的条件。

例如 Path=/user/** 是按照路径匹配,

这个规则是由 org.springframework.cloud.gateway.handler.predicate.PathRoutePredicateFactory 类来处理的。

像这样的断言工厂在 SpringCloudGateway 还有十几个。

名称说明示例
After是某个时间点后的请求- After=2037-01-20T17:42:47.789-07:00[America/Denver]
Before是某个时间点之前的请求- Before=2031-04-13T15:14:47.433+08:00[Asia/Shanghai]
Between是某两个时间点之前的请求- Between=2037-01-20T17:42:47.789-07:00[America/Denver],
2037-01-21T17:42:47.789-07:00[America/Denver]
Cookie请求必须包含某些 cookie- Cookie=chocolate, ch.p
Header请求必须包含某些 header- Header=X-Request-Id, \d+
Host请求必须是访问某个 host(域名)- Host=**.somehost,**.anotherhost
Method请求方式必须是指定方式- Method=GET,POST
Path请求路径必须符合指定规则- Path=/red/{segment},/blue/**
Query请求参数必须包含指定参数- Query=name, Jack 或者 - Query=name
RemoteAddr请求者的 ip 必须是指定范围- RemoteAddr=192.168.1.1/24
Weight权重处理

我们只需要掌握 Path 这种路由工程就可以了。

详情还请见官网:https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/#gateway-request-predicates-factories


8.4.过滤器工厂


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


8.4.1.路由过滤器的种类


Spring 提供了 31 种不同的路由过滤器工厂。

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

具体情况还请浏览官网:https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/#gatewayfilter-factories


8.4.2.请求头过滤器


下面我们以 AddRequestHeader 为例来讲解。

  • 案例需求:给所有进入 userservice 的请求添加一个请求头:Truth=itcast is freaking awesome!

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

spring:
  cloud:
    gateway:
      routes:
      - id: user-service 
        uri: lb://userservice 
        predicates: 
        - Path=/user/** 
        filters: # 过滤器
        - AddRequestHeader=Truth, Itcast is freaking awesome! # 添加请求头

当前过滤器写在 userservice 路由下,因此仅仅对访问 userservice 的请求有效。

修改 userservice 服务下的 src/main/java/cn/itcast/user/web/UserController.java queryById 方法

@GetMapping("/{id}")
public User queryById(@PathVariable("id") Long id,
                      @RequestHeader(value = "Truth", required = false) String truth) {
    System.out.println("truth:" + truth);
    return userService.queryById(id);
}

向浏览器发送 Get 请求后:http://localhost:10010/user/3 的结果


8.4.3.默认过滤器


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

gateway 下的 application.yml

spring:
  cloud:
    gateway:
      routes:
      - id: user-service 
        uri: lb://userservice 
        predicates: 
        - Path=/user/**
      default-filters: # 默认过滤项
      - AddRequestHeader=Truth, Itcast is freaking awesome! 

8.4.4.小结


过滤器的作用是什么?

  1. 对路由的请求或响应做加工处理,比如添加请求头
  2. 配置在路由下的过滤器只对当前路由的请求生效

defaultFilters 的作用是什么?

  • 对所有路由都生效的过滤器

8.5.全局过滤器


上一节学习的过滤器,网关提供了 31 种,但每一种过滤器的作用都是固定的。

如果我们希望拦截请求,做自己的业务逻辑则没办法实现。


8.5.1.全局过滤器作用


全局过滤器的作用也是处理一切进入网关的请求和微服务响应,与 GatewayFilter 的作用一样。

区别在于 GatewayFilter 通过配置定义,处理逻辑是固定的;

GlobalFilter 的逻辑需要自己写代码实现。

定义方式是实现 GlobalFilter 接口。

public interface GlobalFilter {
    /**
     *  处理当前请求,有必要的话通过 {@link GatewayFilterChain} 将请求交给下一个过滤器处理
     *
     * @param exchange 请求上下文,里面可以获取 Request、Response 等信息
     * @param chain 用来把请求委托给下一个过滤器 
     * @return {@code Mono<Void>} 返回标示当前过滤器业务结束
     */
    Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain);
}

filter 中编写自定义逻辑,可以实现下列功能:登录状态判断、权限校验、请求限流等


8.5.2.自定义全局过滤器


案例需求

  • 定义全局过滤器,拦截请求,判断请求的参数是否满足下面条件:
  • 参数中是否有 authorizationauthorization 参数值是否为 admin
  • 如果同时满足则放行,否则拦截

实现

gateway 中定义一个过滤器

src/main/java/cn/itcast/gateway/AuthorizeFilter.java

package cn.itcast.gateway;

import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.util.MultiValueMap;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

//@Order(-1)//顺序注解
@Component
public class AuthorizeFilter implements GlobalFilter, Ordered {
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        //1.获取请求参数
        ServerHttpRequest request = exchange.getRequest();
        MultiValueMap<String, String> params = request.getQueryParams();

        //2.获取参数中的 authorization 参数
        String auth = params.getFirst("authorization");

        //3.判断参数值是否大于 admin
        if ("admin".equals(auth)) {
            //4.是。放行
            return chain.filter(exchange);
        }
        //5.否。拦截
        //5.1.设置状态码
        exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);

        //5.2.拦截请求
        return exchange.getResponse().setComplete();
    }

    @Override
    public int getOrder() {
        return -1;
    }
}

测试查看结果

访问 http://localhost:10010/user/3401 错误,获取不到数据。

访问 http://localhost:10010/user/1?authorization=admin,成功获取到数据信息。


8.5.3.过滤器执行顺序


请求进入网关会碰到三类过滤器:当前路由的过滤器、DefaultFilterGlobalFilter

请求路由后,会将当前路由过滤器和 DefaultFilterGlobalFilter,合并到一个过滤器链(集合)中,排序后依次执行每个过滤器:


排序的规则是什么呢?

  • 每一个过滤器都必须指定一个 int 类型的 order 值,order值越小,优先级越高,执行顺序越靠前
  • GlobalFilter 通过实现 Ordered 接口,或者添加 @Order 注解来指定 order 值,由我们自己指定
  • 路由过滤器和 defaultFilterorderSpring 指定,默认是按照声明顺序从 1 递增。
  • 当过滤器的 order 值一样时,会按照 defaultFilter > 路由过滤器 > GlobalFilter 的顺序执行。

可以参考一下几个类源码来查看

org.springframework.cloud.gateway.route.RouteDefinitionRouteLocator#getFilters()

  • 该方法是先加载 defaultFilters,然后再加载某个 routefilters,然后合并。

org.springframework.cloud.gateway.handler.FilteringWebHandler#handle()

  • 该方法会加载全局过滤器,与前面的过滤器合并后根据 order 排序,组织过滤器链

8.5.4.小结


全局过滤器的作用是什么?

  • 对所有路由器都生效的过滤器,并且可以自定义处理逻辑

实现全局过滤器的步骤

  1. 实现 GlobalFilter 接口
  2. 添加 @Order 注解或实现 Ordered 接口
  3. 编写处理逻辑

路由过滤器、defaultFilter、全局过滤器的执行顺序?

  1. order 值越小,优先级越高
  2. order 值一样时,顺序是 defaultFilter 最先,然后是局部的路由过滤器,最后是全局过滤器

8.6.跨域问题


8.6.1.什么是跨域问题


跨域:域名不一致就是跨域

跨域主要包括:

  • 域名不同: www.taobaowww.taobaowww.jdmiaosha.jd
  • 域名相同,端口不同:localhost:8080localhost8081

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

解决方案:CORSCross-Origin Resource Sharing

网络通信技术
CORS,全称 Cross-Origin Resource Sharing,是一种允许当前域(domain)的资源(比如 html/js/web service)被其他域(domain)的脚本请求访问的机制,通常由于同域安全策略(the same-origin security policy)浏览器会禁止这种跨域请求。(百度百科)

更多详情请见:https://www.ruanyifeng/blog/2016/04/cors.html


8.6.2.模拟跨域问题


找到课前资料的页面文件

index.html 的内容

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <meta http-equiv="X-UA-Compatible" content="ie=edge">
        <title>Document</title>
    </head>
    <body>
<pre>
spring:
  cloud:
    gateway:
      globalcors: # 全局的跨域处理
        add-to-simple-url-handler-mapping: true # 解决 options 请求被拦截问题
        corsConfigurations:
          '[/**]':
            allowedOrigins: # 允许哪些网站的跨域请求
              - "http://localhost:8090"
              - "http://www.leyou"
            allowedMethods: # 允许的跨域 ajax 的请求方式
              - "GET"
              - "POST"
              - "DELETE"
              - "PUT"
              - "OPTIONS"
            allowedHeaders: "*" # 允许在请求中携带的头信息
            allowCredentials: true # 是否允许携带 cookie
            maxAge: 360000 # 这次跨域检测的有效期
</pre>
    </body>
    <script src="https://unpkg/axios/dist/axios.min.js"></script>
    <script>
        axios.get("http://localhost:10010/user/1?authorization=admin")
            .then(resp => console.log(resp.data))
            .catch(err => console.log(err))
    </script>
</html>

放入 tomcat 或者 nginx 这样的 web 服务器中,启动并访问。

我这里使用的是 VSCode 工具

可以在浏览器控制台看到下面的错误

localhost:8090 访问 localhost:10010,端口不同,显然是跨域的请求。


8.6.3.解决跨域问题


gateway 服务的 application.yml 文件中,添加下面的配置

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

配置完成后重启网关服务

显然,成功获取到了数据。


# SpringCloudDay03


SpringCloud 学习 Day03实用篇-3 Docker

  • 初识 Docker
  • Docker 的基本操作
  • Dockerfile 自定义镜像
  • Docker-Compose
  • Docker 镜像服务

9.初始 Docker


9.1.什么是 Docker


微服务虽然具备各种各样的优势,但服务的拆分通用给部署带来了很大的麻烦。

  • 分布式系统中,依赖的组件非常多,不同组件之间部署时往往会产生一些冲突。
  • 在数百上千台服务中重复部署,环境不一定一致,会遇到各种问题

9.1.1.应用部署的环境问题


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

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

例如一个项目中,部署时需要依赖于 node.jsRedisRabbitMQMySQL 等,

这些服务部署时所需要的函数库、依赖项各不相同,甚至会有冲突。给部署带来了极大的困难。


9.1.2.Docker 解决依赖兼容问题


Docker 为了解决依赖的兼容问题的,采用了两个手段:

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

这样打包好的应用包中,既包含应用本身,也保护应用所需要的 LibsDeps

无需再操作系统上安装这些,自然就不存在不同应用之间的兼容问题了。

虽然解决了不同应用的兼容问题,但是开发、测试等环境会存在差异,操作系统版本也会有差异,怎么解决这些问题呢?


9.1.3.Docker 解决操作系统环境差异


要解决不同操作系统环境差异问题,必须先了解操作系统结构。

以一个 Ubuntu 操作系统为例,结构如下:

结构包括:

  • 计算机硬件:例如 CPU、内存、磁盘等
  • 系统内核:所有 Linux 发行版的内核都是 Linux,例如 CentOSUbuntuFedora 等。
    内核可以与计算机硬件交互,对外提供内核指令,用于操作计算机硬件。
  • 系统应用:操作系统本身提供的应用、函数库。这些函数库是对内核指令的封装,使用更加方便。

应用于计算机交互的流程如下:

  1. 应用调用操作系统应用(函数库),实现各种功能
  2. 系统函数库是对内核指令集的封装,会调用内核指令
  3. 内核指令操作计算机硬件

UbuntuCentOS 都是基于 Linux 内核,无非是系统应用不同,提供的函数库有差异:


此时,如果将一个 Ubuntu 版本的 MySQL 应用安装到 CentOS 系统,

MySQL 在调用 Ubuntu 函数库时,会发现找不到或者不匹配,就会报错了:

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

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

如图:


9.1.4.小结


Docker 如何解决大型项目依赖关系复杂,不同组件依赖的兼容性问题?

  • Docker 允许开发中将应用、依赖、函数库、配置一起打包,形成可移植镜像
  • Docker 应用运行在容器中,使用沙箱机制,相互隔离

Docker 如何解决开发、测试、生产环境有差异的问题?

  • Docker 镜像中包含完整运行环境,包括系统函数库,仅依赖系统的 Linux 内核,因此可以在任意 Linux 操作系统上运行

Docker 是一个快速交付应用、运行应用的技术

  • 可以将程序及其依赖、运行环境一起打包为一个镜像,可以迁移到任意 Linux 操作系统
  • 运行时利用沙箱机制形成隔离容器,各个应用互不干扰
  • 启动、移除都可以通过一行命令完成,方便快捷


9.2.Docker 和虚拟机的区别


Docker 可以让一个应用在任何操作系统中非常方便的运行。

而以前我们接触的虚拟机,也能在一个操作系统中,运行另外一个操作系统,保护系统中的任何应用。

两者有什么差异呢?

虚拟机virtual machine)是在操作系统中模拟硬件设备,然后运行另一个操作系统,

比如在 Windows 系统里面运行 Ubuntu 系统,这样就可以运行任意的 Ubuntu 应用了。

Docker 仅仅是封装函数库,并没有模拟完整的操作系统。

对比来看的话

特性Docker虚拟机
性能接近原生性能较差
硬盘占用一般为 MB一般为 GB
启动秒级分钟级

小结

Docker 和虚拟机的差异:

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

9.3.Docker 架构


9.3.1.镜像和容器


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

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

一切应用最终都是代码组成,都是硬盘中的一个个的字节形成的文件。只有运行时,才会加载到内存,形成进程。

镜像,就是把一个应用在硬盘上的文件、及其运行环境、部分系统函数库文件一起打包形成的文件包。这个文件包是只读的。

容器呢,就是将这些文件中编写的程序、函数加载到内存中允许,形成进程,只不过要隔离起来。

因此一个镜像可以启动多次,形成多个容器进程。

例如你下载了一个 QQ,如果我们将 QQ 在磁盘上的运行文件及其运行的操作系统依赖打包,形成 QQ 镜像。

然后你可以启动多次,双开、甚至三开 QQ,跟多个妹子聊天。


9.3.2.DockerHub


开源应用程序非常多,打包这些应用往往是重复的劳动。

为了避免这些重复劳动,人们就会将自己打包的应用镜像,

例如 RedisMySQL 镜像放到网络上,共享使用,就像 GitHub 的代码共享一样。

  • DockerHubDockerHub 是一个官方的 Docker 镜像的托管平台。这样的平台称为 Docker Registry
  • 国内也有类似于 DockerHub 的公开服务,比如 网易云镜像服务阿里云镜像库 等。

我们一方面可以将自己的镜像共享到 DockerHub,另一方面也可以从 DockerHub 拉取镜像:


9.3.3.Docker 架构


我们要使用 Docker 来操作镜像、容器,就必须要安装 Docker

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

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


9.3.4.小结


镜像

  • 将应用程序及其依赖、环境、配置打包在一起

容器

  • 镜像运行起来就是容器,一个镜像可以运行多个容器

Docker 结构

  • 服务端:接收命令或远程请求,操作镜像或容器
  • 客户端:发送命令或者请求到 Docker 服务端

DockerHub

  • 一个镜像托管的服务器,类似的还有阿里云镜像服务,统称为 DockerRegistry

9.4.安装启动配置 Docker


9.4.1.相关说明


企业部署一般都是采用 Linux 操作系统,而其中又数 CentOS 发行版占比最多,因此我们在 CentOS 下安装 Docker

  • 版本信息

Docker 分为 CEEE 两大版本。

CE 即社区版(免费,支持周期 7 个月),EE 即企业版,强调安全,付费使用,支持周期 24 个月。

Docker CE 分为 stabletestnightly 三个更新频道。

  • 硬件要求

官方网站上有各种环境下的 安装指南,这里主要介绍 Docker CECentOS 上的安装。

Docker CE 支持 64 位版本 CentOS 7,并且要求内核版本不低于 3.10

CentOS 7 满足最低内核的要求,所以我们在 CentOS 7 安装 Docker

  • 关于在虚拟机中安装 CentOS7 的操作,可以参考我之前写的博客:【Centos7 的下载安装及之后的基本操作】
  • 有关 Linux 系统的一些基础知识,则可以参考我之前写的博客:【瑞吉外卖⑩ | Linux 粗略学习 & Redis 粗略学习】

9.4.2.卸载之前安装过的 Docker


如果之前安装过旧版本的 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

9.4.3.具体的安装操作


  1. 首先要确保虚拟机可以联网,安装 yum 工具

安装必需的软件包, yum-util 提供 yum-config-manager 功能,另外两个是 devicemapper 驱动依赖的

yum install -y yum-utils \
           device-mapper-persistent-data \
           lvm2 --skip-broken
  1. 然后更新本地镜像源

设置 docker 镜像源

yum-config-manager \
    --add-repo \
    https://mirrors.aliyun/docker-ce/linux/centos/docker-ce.repo
sed -i 's/download.docker/mirrors.aliyun\/docker-ce/g' /etc/yum.repos.d/docker-ce.repo

将软件包信息提前在本地索引缓存(旨在可以提高搜索安装软件的速度。执行这个命令可以提升 yum 安装的速度)

yum makecache fast
  1. 安装 Docker
yum install -y docker-ce

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

这里我使用的远程控制工具是 Mobaxterm,视频中使用的是 FinalShell

使用以下命令查看安装的 Docker 版本

docker -v


9.5.启动 Docker


  • 关闭防火墙
  • Docker 进程相关命令
  • 配置镜像

  1. Docker 应用需要用到各种端口,逐一去修改防火墙设置。非常麻烦,因此建议直接关闭防火墙!

关闭防火墙

systemctl stop firewalld

禁止开机自启动防火墙

systemctl disable firewalld

  1. Docker 进程相关命令

启动 Docker 服务

systemctl start docker

查看 Docker 服务状态

systemctl status docker

关闭 Docker 服务

systemctl stop docker

重启 Docker 服务

systemctl restart docker

开机自启动 Docker 服务

systemctl enable docker

  1. 配置镜像

docker 官方镜像仓库网速较差,我们需要设置国内镜像服务,这里我们选用阿里云镜像服务。

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

或者使用账号登录阿里云,点击 “控制台“,然后点击 “三” 在搜索栏中输入 “容器镜像服务”

最后点击 “镜像工具”“镜像加速器” 就可以看到 “加速器地址”

在其下方的操作文档里,有非常详细的操作文档,我们将其文档上的内容复制直接到 Linux 系统的终端即可。

我们也可以使用以下命令来查看阿里云地址是否写入文件中

cat /etc/docker/daemon.json

9.6.卸载 Docker


卸载 docker 前,务必要先关掉 docker 服务

systemctl stop docker

yum remove docker-ce docker-ce-cli containerd.io

清理 docker 默认在 /var/lib 目录下配置的数据

rm -rf /var/lib/docker
rm -rf /var/lib/containerd

10.Docker 的基本操作


10.1.镜像操作


10.1.1.镜像名称


首先来看下镜像的名称组成

  • 镜名称一般分两部分组成:[repository]:[tag]
  • 在没有指定 tag 时,默认是 latest,代表最新版本的镜像

这里的 mysql 就是 repository5.7 就是 tag,合一起就是镜像名称,代表 5.7 版本的 MySQL 镜像。


10.1.2.镜像命令


常见的镜像操作命令如图

Docker 的命令很多,我们不需要全记住。

使用 docker --help 查看帮助文档即可。使用 docker xx --help 可以查看 xx 相关命令的帮助文档。

比如我想查看镜像的相关命令,输入 docker images --help 即可。

docker images --help


10.1.3.案例1(拉取、查看镜像)


需求:从 DockerHub 中拉取一个 nginx 镜像并查看


  1. 首先去镜像仓库搜索 nginx 镜像,比如 DockerHub

相关链接:https://hub.docker/search?q=nginx&type=image


  1. 根据查看到的镜像名称,通过命令 docker pull nginx 拉取自己需要的镜像
docker pull nginx


  1. 通过命令 docker images 查看拉取到的镜像
docker images


10.1.4.案例2(保存、导入镜像)


需求:利用 docker savenginx 镜像导出磁盘,然后再通过 load 加载回来


  1. 利用 docker xx --help 命令查看 docker savedocker load 的语法

例如,查看 save 命令用法,可以输入命令:docker save --help

docker save --help


  1. 使用 docker save导出镜像到磁盘

由上图易知命令 docker save 的完整格式 docker save -o [保存的目标文件名称] [镜像名称]

docker save -o nginx.tar nginx:latest


  1. 使用 docker load 加载镜像

先删除本地的 nginx 镜像

docker rmi nginx:latest

之后我们通过 docket images 发现镜像已经成功被删除。

最后再运行命令 docker load -i nginx.tar,加载本地文件

docker load -i nginx.tar


10.1.5.练习案例


部分 Docker 镜像操作命令

docker images (查看镜像)、docker rmi(删除镜像)

docker pull(拉取镜像)、docker push(将本地的镜像上传到镜像仓库)

docker save (保存镜像为一个压缩包)、docker load(加载压缩包成为镜像)

docker search [镜像名称](从网络中查找需要的镜像)


练习案例-需求:去 DockerHub 搜索并拉取一个 Redis 镜像

练习案例-目标

  1. DockerHub 搜索 Redis 镜像
  2. 查看 Redis 镜像的名称和版本
  3. 利用 docker pull 命令拉取镜像
  4. 利用 docker save 命令将 redis:latest 打包为一个 redis.tar
  5. 利用 docker rmi 删除本地的 redis:latest
  6. 利用 docker load 重新加载 redis.tar文件

练习案例-实际操作

  1. DockerHub 搜索 Redis 镜像

相关链接:https://hub.docker/search?q=redis&type=image

  1. 查看 Redis 镜像的名称和版本

相关链接:https://hub.docker/_/redis

  1. 利用 docker pull 命令拉取镜像
docker pull redis

  1. 利用 docker save 命令将 redis:latest 打包为一个 redis.tar
docker save -o redis.tar redis:latest

  1. 利用 docker rmi 删除本地的 redis:latest
docker rmi redis:latest

  1. 利用 docker load 重新加载 redis.tar 文件
docker load -i redis.tar


10.2.容器操作


10.2.1.容器相关命令


容器操作的命令如图

容器保护三个状态

  • 运行:进程正常运行
  • 暂停:进程暂停,CPU 不再运行,并不释放内存
  • 停止:进程终止,回收进程占用的内存、CPU 等资源

其中:

  • docker run:创建并运行一个容器,处于运行状态
  • docker pause:让一个运行的容器暂停
  • docker unpause:让一个容器从暂停状态恢复运行
  • docker stop:停止一个运行的容器
  • docker start:让一个停止的容器再次运行
  • docker rm:删除一个容器

10.2.2.案例1(创建并运行一个容器)


DockerHub 上查看 Nignx 的容器的运行命令:https://hub.docker/search?q=nginx

https://hub.docker/_/nginx 下的页面的 description 中发现了一个命令

创建并运行容器的命令

docker run --name containerName -p 80:80 -d nginx

命令解读:

  • docker run:创建并运行一个容器
  • --name: 给容器起一个名字,比如叫做 mn
  • -p:将宿主机端口与容器端口映射,冒号左侧是宿主机端口,右侧是容器端口
  • -d:后台运行容器
  • nginx:镜像名称,例如 nginx

这里的 -p 参数,是将容器端口映射到宿主机端口。

默认情况下,容器是隔离环境,我们直接访问宿主机的 80 端口,肯定访问不到容器中的 nginx

现在,将容器的 80 与宿主机的 80 关联起来,当我们访问宿主机的 80 端口时,就会被映射到容器的 80,这样就能访问到 nginx 了:


具体操作

创建并运行 nginx 容器。成功后,控制台显示容器唯一 id

docker run --name mn -p 80:80 -d nginx

查看所有运行的容器即状态 (因为我的 MobaXterm 的输出格式有点小问题,故这里使用 FinalShell

docker ps

输入 虚拟机的IP地址,可以访问到 Nginx 的欢迎页面(因为之前设置的宿主机映射端口都是 80

如果之前设置的宿主机端口是 8080 ,则需要输入 虚拟机IP地址:8080 才可以访问的到


查看容器日志:docker logs containerName

docker logs mn

动态跟踪日志,可以实时查看容器日志信息:docker logs -f containerName

docker logs -f mn

欲获得更多的有关容器日志的信息,输入 docker logs --help 即可

docker logs --help


10.2.3.案例2(进入容器,修改文件)


需求:进入 Nginx 容器,修改 HTML 文件内容,添加 “stand power”

提示:进入容器要用到 docker exec 命令。


操作步骤


  1. 进入容器。进入我们刚刚创建的 nginx 容器的命令为:docker exec -it containerName bash
docker exec -it mn bash

命令解读

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

  1. 进入 nginxHTML 所在目录 /usr/share/nginx/html

容器内部会模拟一个独立的 Linux 文件系统,看起来如同一个 linux 服务器一样

nginx 的环境、配置、运行文件全部都在这个文件系统中,包括我们要修改的 html 文件。

可以看作是 Linux 的阉割版,容器内部只有 Nginx 运行需要的东西。

前往 https://hub.docker/_/nginx?tab=description 页面

我们可以找到设置静态页面的方法。其中我们可以找到静态页面的位置。

当然,你也可以使用命令:find / -name containerName

find / -name nginx

找到 index.html 的位置后,就输入命令 cd /usr/local/nginx/html 进入目录。


  1. 修改 index.html 的内容

Nginx 容器内没有 vim 命令,无法直接修改(也没有 vi 命令)

我们用其他命令来修改

sed -i -e 's#Welcome to nginx#stand power#g' -e 's#<head>#<head><meta charset="utf-8">#g' index.html

sed 全名为 stream editor,流编辑器。

  • sed -i 命令的具体情况可见博客:《sed -i 命令详解

在浏览器访问自己的虚拟机地址,例如我的是:http://192.168.2.5,即可看到结果


  1. 退出并关闭容器

关闭容器的命令:docker stop containerName

docker stop mn

显示所有容器状态(包括停止运行的容器)

docker ps -a

删除容器的命令:docker rm containerName

docker rm mn


关于以上的这些命令,都可以通过 docker xxx --help 来查看。希望诸位善用 --help

查看 docker ps 的帮助文档

docker ps --help

强制删除正在运行的容器:docker rm -f containerName

dokcer rm -f 

欲知更多详情还请使用命令:docker rm --help

docker rm --help


10.2.4.练习案例


案例需求

  • 创建并允许一个 redis 容器,并且支持数据持久化
  • 进入 redis 容器,并且执行 redis-cli 客户端命令,存入 num=666

操作步骤

  • 步骤一:到 DockerHub 搜索 Redis 镜像
  • 步骤二:查看 Redis 镜像文档中的帮助信息
  • 步骤三:利用 docker run 命令运行一个 Redis 容器
  • 步骤四:进入 redis 容器,并且执行 redis-cli 客户端命令,存入 num=666

具体操作

  1. DockerHub 搜索 Redis 镜像

https://hub.docker/search?q=redis、https://hub.docker/_/redis?tab=description

  1. 查看 Redis 镜像文档中的帮助信息

docker run --name some-redis -d redis redis-server --save 60 1 --loglevel warning
  1. 利用 docker run 命令运行一个 Redis 容器

视频中给出的命令,与官网给出的命令约有不同,应该是官网命令更新过了。

使用视频中给出的命令,启动失败(视频发布时间是 2021-8-11

docker run --name mr -p 6379:6379 -d redis redis-server --apppendonly yes

使用官网给出的新命令。启动成功。(访问时间是 2022-07-05

docker run --name mr -p 6379:6379 -d redis redis-server --save 60 1 --loglevel warning

  • 我这里使用的是图形化界面工具是:Redis Desketop Manager
  • 源码仓库地址:https://github/uglide/RedisDesktopManager
  • 安装包仓库地址:https://github/lework/RedisDesktopManager-Windows/releases

连接成功!

  1. 进入 redis 容器,并且执行 redis-cli 客户端命令,存入 num=666

进入 Redis 容器内部

docker -it mr bash

打开 redis-cli 客户端

redis-cli

设置指定 key 的值(string

set num 666

获取指定 key 的值(string

get num

当然,我们也可以直接进入 redis-cli 客户端


10.2.5.小结


docker run 命令的常见参数有哪些?

  • --name:指定容器名称
  • -p:指定端口映射
  • -d:让容器后台运行

查看容器日志的命令

  • docker logs
  • 添加 -f 参数可以持续查看日志

查看容器状态

  • docker ps
  • docker ps -a 查看所有容器,包括已经停止的

删除容器

  • docker rm
  • 不能删除运行中的容器,除非添加 -f 参数

进入容器

  • 命令是 docker exec -it [容器名] [要执行的命令]
  • exec 命令可以进入容器修改文件,但是在容器内修改文件是不推荐的

10.3.数据卷操作


数据卷容器数据管理

在之前的 nginx 案例中,修改 nginxhtml 页面时,需要进入 nginx 内部。并且因为没有编辑器,修改文件也很麻烦。

这就是因为容器与数据(容器内文件)耦合带来的后果。

要解决这个问题,必须将数据与容器解耦,这就要用到数据卷了。


10.3.1.什么是数据卷


数据卷volume)是一个虚拟目录,指向宿主机文件系统中的某个目录。

一旦完成数据卷挂载,对容器的一切操作都会作用在数据卷对应的宿主机目录了。

这样,我们操作宿主机的 /var/lib/docker/volumes/html 目录,就等于操作容器内的 /usr/share/nginx/html 目录了


10.3.2.数据集操作命令


数据卷操作的基本语法如下

docker volume [COMMAND]

docker volume 命令是数据卷操作,根据命令后跟随的 command 来确定下一步的操作

  • create 创建一个 volume
  • inspect 显示一个或多个 volume 的信息
  • ls 列出所有的 volume
  • prune 删除未使用的 volume
  • rm 删除一个或多个指定的 volume

10.3.3.创建、查看、删除数据卷


  1. 创建数据卷dokcer volume create 数据卷名称
docker volume create html

  1. 查看所有数据卷
docker volume ls

  1. 查看数据卷详细信息卷docker volume inspect 数据卷名称),并查看数据卷在宿主机的目录位置
docker volume inspect html

可以看到,我们创建的 html 这个数据卷关联的宿主机目录为 /var/lib/docker/volumes/html/_data目录。

  1. 删除数据卷

删除所有未在使用的数据卷:docker volume prune(不推荐,防止误删)

或者使用命令:docker volume rm 数据卷名称 来删除指定数据卷

docker volume rm html


关于 docker volume 的相关命令可以使用 docker volume --help 来查看帮助文档

docker volume --help


10.3.4.挂载数据卷


我们在创建容器时,可以通过 -v 参数来挂载一个数据卷到某个容器内目录,命令格式如下:

docker run \
  --name mn \
  -v html:/root/html \
  -p 8080:80
  nginx \

这里的 -v 就是挂载数据卷的命令:

  • docker run:创建并运行容器
  • --name mn:给容器起个名字叫 mn
  • -v html:/root/html :把 html 数据卷挂载到容器内的 /root/html 这个目录中
  • -p 8080:80:把宿主机8080 端口映射到容器内的 80 端口
  • nginx:镜像名称

10.3.5.案例1(给 Nginx 挂载数据卷)


需求:创建一个 nginx 容器,修改容器内的 html 目录内的 index.html 内容

分析

  • 在之前的操作中,我们进入 nginx 容器内部,已经知道 nginxhtml 目录所在位置 /usr/share/nginx/html
  • 现在我们需要把这个目录挂载到 html 这个数据卷上,方便操作其中的内容

提示:运行容器时使用 -v 参数挂载数据卷


步骤

  1. 创建容器并挂载数据卷到容器内的 html 目录(详情见官网:https://hub.docker/_/nginx )
docker run --name mn -v html:/usr/share/nginx/html -p 80:80 -d nginx

  1. 进入 html 数据卷所在位置,并修改 html 内容

查看 html 数据卷的位置

docker volume inspect html

进入该目录

cd /var/lib/docker/volumes/html/_data

修改文件

vim index.html

这里我直接用 vocode 来修改 index.html 文件了。

最终的结果

修改成功!

补充:如果容器运行时 volume 不存在,其也会自动被创建出来。


10.3.6.案例2(给 MySQL 挂载本地目录)


容器不仅仅可以挂载数据卷,也可以直接挂载到宿主机目录上。

关联关系如下:

  • 带数据卷模式:宿主机目录 --> 数据卷 —> 容器内目录
  • 直接挂载模式:宿主机目录 —> 容器内目录

如图:

语法

目录挂载与数据卷挂载的语法是类似的:

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

案例需求:创建并运行一个 MySQL 容器,将宿主机目录直接挂载到容器

实现思路如下:

  1. 在将课前资料(提取码:1234)中的 mysql.tar 文件上传到虚拟机,通过 load 命令加载为镜像
    (主要是 MySQL 镜像文件比较大,使用 docker pull mysql:tag 命令的话,耗时比较久,故使用资料中提供的 mysql.tar
  2. 创建目录 /tmp/mysql/data
  3. 创建目录 /tmp/mysql/conf,将课前资料提供的 hmyf 文件上传到 /tmp/mysql/conf
  4. DockerHub 查阅资料,创建并运行 MySQL 容器,要求:
    1. 挂载 /tmp/mysql/datamysql 容器内数据存储目录
    2. 挂载 /tmp/mysql/conf/hmyfmysql 容器的配置文件
    3. 设置 MySQL 密码

实际操作

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

查看镜像

docker images -a

  1. 创建目录 /tmp/mysql/data
mkdir -p mysql/data
  1. 创建目录 /tmp/mysql/conf,将课前资料提供的 hmyf 文件上传到 /tmp/mysql/conf
mkdir -p mysql/conf


hymf 文件的内容

[mysqld]
skip-name-resolve
character_set_server=utf8
datadir=/var/lib/mysql
server-id=1000
  1. DockerHub 查阅资料,创建并运行 MySQL 容器
    1. 挂载 /tmp/mysql/datamysql 容器内数据存储目录
    2. 挂载 /tmp/mysql/conf/hmyfmysql 容器的配置文件
    3. 设置 MySQL 密码

前往 DockerHub 页面查看使用文档https://hub.docker/_/mysql

  • 启动服务器实例 mysql

docker run --name some-mysql -e MYSQL_ROOT_PASSWORD=my-secret-pw -d mysql:tag
  • 使用自定义的 MySQL 配置文件

docker run --name some-mysql -v /my/custom:/etc/mysql/conf.d -e MYSQL_ROOT_PASSWORD=my-secret-pw -d mysql:tag
  • 存储数据的位置

docker run --name some-mysql -v /my/own/datadir:/var/lib/mysql -e MYSQL_ROOT_PASSWORD=my-secret-pw -d mysql:tag
  • 使用如下的命令创建并运行一个 MySQL 容器,将宿主机目录直接挂载到容器
docker run \
	--name c_mysql \
	-e MYSQL_ROOT_PASSWORD=123 \
	-p 3307:3306 \
	-v /tmp/mysql/conf/hmyf:/etc/mysql/conf.d/hmyf \
	-v /tmp/mysql/data:/var/lib/mysql \
	-d \
	mysql:5.7.25
  • 参数说明
  • docker run:创建并运行容器
  • -e MYSQL_ROOT_PASSWORD=123:初始化 root 用户的密码。 -e 是环境变量的意思。
  • -p 3307:3306:将容器的 3306 端口映射到宿主机的 3307 端口。冒号左侧是宿主机端口,右侧是容器端口
  • v /tmp/mysql/conf/hmyf:/etc/mysql/conf.d/hmyf
    • 将主机的 /tmp/mysql/conf/hmyf 挂载到容器的 /etc/mysql/conf.d/hmyf
  • -v /tmp/mysql/data:/var/lib/mysql:将主机的 /tmp/mysql/data 挂载到容器的 /var/lib/mysql
  • -d:后台运行

执行完命令后,使用 docker ps 查看 mysql 容器是否成功启动

docker ps

使用数据库图形化界面 Navicat Premium 成功连接到 mysql 容器的客户端

运行成功后 /tmp/mysql/data 目录中也成功记录了文件


10.3.7.小结


数据卷的作用

  • 将容器与数据分离,解耦合,方便操作容器内数据,保证数据安全

数据卷操作

  • docker volume create:创建数据卷
  • docker volume ls:查看所有数据卷
  • docker volume inspect:查看数据卷详细信息,包括关联的宿主机目录位置
  • docker volume rm:删除指定数据卷
  • docker volume prune:删除所有未使用的数据卷

数据卷挂载方式

  • -v volumeName: /targetContainerPath
  • 如果容器运行时 volume 不存在,会自动被创建出来

docker run 的命令中通过 -v 参数挂载文件或目录到容器中

  • -v volume名称:容器内目录
  • -v 宿主机文件:容器内文
  • -v 宿主机目录:容器内目录

数据卷挂载与目录直接挂载的区别

  • 数据卷挂载耦合度低,由 docker 来管理目录,但是目录较深,不好找
  • 目录挂载耦合度高,需要我们自己管理目录,不过目录容易寻找查看

11.Dockerfile 自定义镜像


常见的镜像在 DockerHub 就能找到,但是我们自己写的项目就必须自己构建镜像了。

而要自定义镜像,就必须先了解镜像的结构才行。


11.1.镜像结构


镜像是将应用程序及其需要的系统函数库、环境、配置、依赖打包而成。

我们以 MySQL 为例,来看看镜像的组成结构

镜像就是在系统函数库、运行环境基础上,添加应用程序文件、配置文件、依赖文件等组合,然后编写好启动脚本打包在一起形成的文件。

我们要构建镜像,其实就是实现上述打包的过程。


11.2.Dockerfile 语法


构建自定义的镜像时,并不需要一个个文件去拷贝,打包。

我们只需要告诉 Docker,我们的镜像的组成,

需要哪些 BaseImage、需要拷贝什么文件、需要安装什么依赖、启动脚本是什么,

将来 Docker 会帮助我们构建镜像。

而描述上述信息的文件就是 Dockerfile 文件。

Dockerfile 就是一个文本文件,其中包含一个个的指令(Instruction),用指令来说明要执行什么操作来构建镜像。

每一个指令都会形成一层 Layer

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

更新详细语法说明,请参考官网文档: https://docs.docker/engine/reference/builder


11.3.基于 Ubuntu 构建 Java 项目


  • 步骤 1:新建一个空文件夹 docker-demo(我是在 /tmp 目录下创建这个文件夹的)
mkdir docker-demo
  • 步骤 2:拷贝课前资料中的 docker-demo.jar 文件到 docker-demo 这个目录

  • 步骤 3:拷贝课前资料中的 jdk8.tar.gz 文件到 docker-demo 这个目录

  • 步骤 4:拷贝课前资料提供的 Dockerfiledocker-demo 这个目录

Dockerfile 中的内容

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

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

# 安装 JDK
RUN cd $JAVA_DIR \
 && tar -xf ./jdk8.tar.gz \
 && mv ./jdk1.8.0_144 ./java8

# 配置环境变量
ENV JAVA_HOME=$JAVA_DIR/java8
ENV PATH=$PATH:$JAVA_HOME/bin

# 暴露端口
EXPOSE 8090

# 入口,java 项目的启动命令
ENTRYPOINT java -jar /tmp/app.jar
  • 步骤 5:进入 docker-demo

将准备好的 docker-demo 上传到虚拟机任意目录,然后进入 docker-demo 目录下

  • 步骤 6:运行命令:docker build -t javaweb:1.0 .
docker build -t javaweb:1.0 .

其中 . 表示当前目录

  • 步骤 7:使用命令启动镜像
docker run --name web -p 8090:8090 -d javaweb:1.0

  • 步骤 8:访问 http://192.168.150.101:8090/hello/count,其中的 ip 改成你的虚拟机 ip


有关 docker build 命令的更多操作还请查看官网文档: https://docs.docker/engine/reference/builder


11.4.基于 java8 构建 Java 项目


虽然我们可以基于 Ubuntu 基础镜像,添加任意自己需要的安装包,构建镜像,但是却比较麻烦。

所以大多数情况下,我们都可以在一些安装了部分软件的基础镜像上做改造。

例如,构建 java 项目的镜像,可以在已经准备了 JDK 的基础镜像基础上构建。

需求:基于 java:8-alpine 镜像,将一个 Java 项目构建为镜像

实现思路如下:

  • ① 新建一个空的目录,然后在目录中新建一个文件,命名为 Dockerfile

  • ② 拷贝课前资料提供的 docker-demo.jar 到这个目录中

  • ③ 编写 Dockerfile 文件:

    • a.基于 java:8-alpine 作为基础镜像

    • b.将 app.jar 拷贝到镜像中

    • c.暴露端口

    • d.编写入口 ENTRYPOINT

      内容如下:

      FROM java:8-alpine
      COPY ./app.jar /tmp/app.jar
      EXPOSE 8090
      ENTRYPOINT java -jar /tmp/app.jar
      
  • ④ 使用 docker build 命令构建镜像

    docker build -t javaweb:2.0 .
    

  • ⑤ 使用 docker run创建容器并运行

    docker run --name web2 -p 8090:8090 -d javaweb:2.0
    

    不过要注意的是,上面的进程要关掉(因为用的是同一个端口),并且启动新容器时,名字不可一样。

  • ⑥ 访问 http://192.168.150.101:8090/hello/count,其中的 ip 改成你的虚拟机 ip


11.5.小结


  1. Dockerfile 的本质是一个文件,通过指令描述镜像的构建过程
  2. Dockerfile 的第一行必须是 FROM,从一个基础镜像来构建
  3. 基础镜像可以是基本操作系统,如 Ubuntu。也可以是其他人制作好的镜像,例如:java:8-alpine

12.Docker-Compose


Docker Compose 可以基于 Compose 文件帮我们快速的部署分布式应用,而无需手动一个个创建和运行容器!


12.1.初识 DockerCompose


Compose 文件是一个文本文件,通过指令定义集群中的每个容器如何运行。格式如下:

version: "3.8"
 services:
  mysql:
    image: mysql:5.7.25
    environment:
     MYSQL_ROOT_PASSWORD: 123 
    volumes:
     - "/tmp/mysql/data:/var/lib/mysql"
     - "/tmp/mysql/conf/hmyf:/etc/mysql/conf.d/hmyf"
  web:
    build: .
    ports:
     - "8090:8090"

上面的 Compose 文件就描述一个项目,其中包含两个容器:

  • mysql:一个基于mysql:5.7.25 镜像构建的容器,并且挂载了两个目录
  • web:一个基于docker build 临时构建的镜像容器,映射端口时 8090

因为这里旨在微服务集群部署,因为是在集群内使用,故无需对外暴露端口。

DockerCompose 的详细语法参考官网:https://docs.docker/compose/compose-file/

其实 DockerCompose 文件可以看做是将多个 docker run 命令写到一个文件,只是语法稍有差异。


12.2.安装 DockerCompose


12.2.1.下载


Linux 下需要通过命令下载:

安装

curl -L https://github/docker/compose/releases/download/1.23.1/docker-compose-`uname -s`-`uname -m` > \
/usr/local/bin/docker-compose

如果下载速度较慢,或者下载失败,可以使用课前资料提供的 docker-compose 文件:

上传到 /usr/local/bin/ 目录也可以。


12.2.2.修改文件权限


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


12.2.3.Base 自动补全命令


补全命令

curl -L https://raw.githubusercontent/docker/compose/1.29.1/contrib/completion/bash/docker-compose > \
/etc/bash_completion.d/docker-compose

如果这里出现错误,需要修改自己的 hosts 文件

echo "199.232.68.133 raw.githubusercontent" >> /etc/hosts

12.2.4.小结


问:DockerCompose 有什么作用?

答:帮助我们快速部署分布式应用,而无需一个个微服务去构建镜像和部署


12.3.部署微服务集群


吐槽这个部署 看看就行与其说是部署不如说是配环境也完全没有实际价值因为用的例子是 MySQL)。

另外由于我的 mysql 密码与视频中的密码不同,就 mysql 打包那里我就过不了关了。

最后访问地址时,查看日志,也是密码不对的原因。反正这个部署我是没有弄成功。

而且很占用内存:内存使用量直接飚到 15G


需求:将之前学习的 cloud-demo 微服务集群利用 DockerCompose 部署

实现思路

① 查看课前资料提供的 cloud-demo 文件夹,里面已经编写好了 docker-compose 文件

② 修改自己的 cloud-demo 项目,将数据库、nacos 地址都命名为 docker-compose 中的服务名

③ 使用 maven 打包工具,将项目中的每个微服务都打包为 app.jar

④ 将打包好的 app.jar 拷贝到 cloud-demo 中的每一个对应的子目录中

⑤ 将 cloud-demo 上传至虚拟机,利用 docker-compose up -d 来部署


12.3.1pose 文件


查看课前资料提供的 cloud-demo 文件夹,里面已经编写好了 docker-compose 文件,而且每个微服务都准备了一个独立的目录:

内容如下

version: "3.2"

services:
  nacos:
    image: nacos/nacos-server
    environment:
      MODE: standalone
    ports:
      - "8848:8848"
  mysql:
    image: mysql:5.7.25
    environment:
      MYSQL_ROOT_PASSWORD: 123
    volumes:
      - "$PWD/mysql/data:/var/lib/mysql"
      - "$PWD/mysql/conf:/etc/mysql/conf.d/"
  userservice:
    build: ./user-service
  orderservice:
    build: ./order-service
  gateway:
    build: ./gateway
    ports:
      - "10010:10010"

可以看到,其中包含 5service 服务:

  • nacos:作为注册中心和配置中心
    • image: nacos/nacos-server: 基于 nacos/nacos-server 镜像构建
    • environment:环境变量
      • MODE: standalone:单点模式启动
    • ports:端口映射,这里暴露了 8848 端口
  • mysql:数据库
    • image: mysql:5.7.25:镜像版本是 mysql:5.7.25
    • environment:环境变量
      • MYSQL_ROOT_PASSWORD: 123:设置数据库 root 账户的密码为 123
    • volumes:数据卷挂载,这里挂载了 mysqldataconf 目录,其中有我提前准备好的数据
  • userserviceorderservicegateway:都是基于 Dockerfile 临时构建的

查看 mysql 目录,可以看到其中已经准备好了 cloud_ordercloud_user 表:

查看微服务目录,可以看到都包含 Dockerfile 文件:


内容如下:

FROM java:8-alpine
COPY ./app.jar /tmp/app.jar
ENTRYPOINT java -jar /tmp/app.jar

12.3.2.修改微服务配置


因为微服务将来要部署为 docker 容器,而容器之间互联不是通过 IP 地址,而是通过容器名。

这里我们将 order-serviceuser-servicegateway 服务的 mysqlnacos 地址都修改为基于容器名的访问。

如下所示:

spring:
  datasource:
    url: jdbc:mysql://mysql:3306/cloud_order?useSSL=false
    username: root
    password: 123
    driver-class-name: com.mysql.jdbc.Driver
  application:
    name: orderservice
  cloud:
    nacos:
      server-addr: nacos:8848 # nacos服务地址


12.3.3.打包


接下来需要将我们的每个微服务都打包。

因为之前查看到 Dockerfile 中的 jar 包名称都是 app.jar,因此我们的每个微服务都需要用这个名称。

可以通过修改 pom.xml 中的打包名称来实现,每个微服务都需要修改:

<build>
  <!-- 服务打包的最终名称 -->
  <finalName>app</finalName>
  <plugins>
    <plugin>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-maven-plugin</artifactId>
    </plugin>
  </plugins>
</build>

12.3.4.拷贝 jar 包到部署目录


编译打包好的 app.jar 文件,需要放到 Dockerfile 的同级目录中。

注意:每个微服务的 app.jar放到与服务名称对应的目录。




12.3.5.部署


最后,我们需要将文件整个 cloud-demo 文件夹上传到虚拟机中,理由 DockerCompose 部署。

上传到任意目录:

部署:

进入 cloud-demo 目录,然后运行下面的命令:

docker-compose up -d

事实上因为,因为 Nacos 启动慢的缘故,其是无法成功启动的

这里只是简单重启服务一下(因为此时 Nacos 服务已经启动成功,重启其他服务即可成功)

docker-compose restart gateway userservice orderservice

之后直接访问这俩地址就可以成功获取到数据

  • http://虚拟机IP地址:10010/order/101?authorization=admin
  • http://虚拟机IP地址:10010/user/1?authorization=admin

13.Docker 镜像服务


镜像仓库(Docker Registry)有公共的和私有的两种形式。

  • 公共仓库:例如 Docker 官方的 Docker Hub
    国内也有一些云服务商提供类似于 Docker Hub 的公开服务,比如 DaoCloud 镜像服务、网易云镜像服务、阿里云镜像库等。
  • 除了使用公开仓库外,用户还可以在本地搭建私有 Docker Registry。企业自己的镜像最好是采用私有 Docker Registry 来实现。

13.1.搭建私有镜像仓库


搭建镜像仓库可以基于 Docker 官方提供的 DockerRegistry 来实现。

官网地址:https://hub.docker/_/registry


13.1.1.简化版镜像仓库


Docker 官方的 Docker Registry 是一个基础版本的 Docker 镜像仓库,具备仓库管理的完整功能,但是没有图形化界面。

搭建方式比较简单,命令如下:

docker run -d \
    --restart=always \
    --name registry	\
    -p 5000:5000 \
    -v registry-data:/var/lib/registry \
    registry

命令中挂载了一个数据卷 registry-data 到容器内的 /var/lib/registry 目录,这是私有镜像库存放数据的目录。

访问 http://YourIp:5000/v2/_catalog 可以查看当前私有镜像服务中包含的镜像


13.1.2.带有图形化界面版本


使用 DockerCompose 部署带有图像界面的 DockerRegistry,命令如下:

version: '3.0'
services:
  registry:
    image: registry
    volumes:
      - ./registry-data:/var/lib/registry
  ui:
    image: joxit/docker-registry-ui:static
    ports:
      - 8080:80
    environment:
      - REGISTRY_TITLE=随便起的名字
      - REGISTRY_URL=http://registry:5000
    depends_on:
      - registry

把上面的内容复制到新创建的 docker-compose.yml 文件中即可。

在做下面的操作前,需要先配置 Docker 信任地址(见 13.1.3




13.1.3.配置 Docker 信任地址


我们的私服采用的是 http 协议,默认不被 Docker 信任,所以需要做一个配置:

打开要修改的文件

vi /etc/docker/daemon.json

添加内容

"insecure-registries":["http://192.168.150.101:8080"]

重加载

systemctl daemon-reload

重启 docker

systemctl restart docker

13.2.推送、拉取镜像


推送镜像到私有镜像服务必须先 tag,步骤如下:

① 重新 tag 本地镜像,名称前缀为私有仓库的地址:192.168.150.101:8080/

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

② 推送镜像

docker push 192.168.150.101:8080/nginx:1.0 

③ 拉取镜像

docker pull 192.168.150.101:8080/nginx:1.0 



13.3.小结


  1. 推送本地镜像到仓库前都必须重命名(docker tag)镜像,以镜像仓库地址为前缀
  2. 镜像仓库推送前需要把仓库地址配置到 docker 服务的 daemon.json 文件中,被 docker 信任
  3. 推送使用 docker push 命令
  4. 拉取使用 docker pull 命令

# SpringCloudDay04


SpringCloud 学习 Day04实用篇-4 服务异步通讯:RabbitMQ

  • 初识 MQ
  • RabbitMQ 快速入门
  • SpringAMQP

14.初识 MQ


14.1.同步通讯和异步通讯


微服务间通讯有同步和异步两种方式:

  • 同步通讯:就像打电话,需要实时响应。
  • 异步通讯:就像发邮件,不需要马上回复。

两种方式各有优劣,打电话可以立即得到响应,但是你却不能跟多个人同时通话。

发送邮件可以同时与多个人收发邮件,但是往往响应会有延迟。


14.1.1.同步通讯


我们之前学习的 Feign 调用就属于同步方式,虽然调用可以实时得到结果,但存在下面的问题:


总结

  1. 同步调用的优点:时效性较强,可以立即得到结果
  2. 同步调用的问题
    1. 耦合度高
    2. 性能和吞吐能力下降
    3. 有额外的资源消耗
    4. 有级联失败问题

14.1.2.异步通讯


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

  • 事件驱动优势
    • 优势一:服务解耦(耦合度低)
    • 优势二:性能提升,吞吐量提高
    • 优势三:服务没有强依赖,不担心级联失败问题(故障隔离)
    • 优势四:流量削峰

异步调用则可以避免上述问题:

我们以购买商品为例,用户支付后需要调用订单服务完成订单状态修改,调用物流服务,从仓库分配响应的库存并准备发货。

在事件模式中,支付服务是事件发布者(publisher),在支付完成后只需要发布一个支付成功的事件(event),事件中带上订单 id

订单服务和物流服务是事件订阅者(Consumer),订阅支付成功的事件,监听到事件后完成自己业务即可。

为了解除事件发布者与订阅者之间的耦合,两者并不是直接通信,而是有一个中间人(Broker)。

发布者发布事件到 Broker,不关心谁来订阅事件。订阅者从 Broker 订阅事件,不关心谁发来的消息。

Broker 是一个像数据总线一样的东西,所有的服务要接收数据和发送数据都发到这个总线上。

这个总线就像协议一样,让服务间的通讯变得标准和可控。

好处

  • 吞吐量提升:无需等待订阅者处理完成,响应更快速
  • 故障隔离:服务没有直接调用,不存在级联失败问题
  • 调用间没有阻塞,不会造成无效的资源占用
  • 耦合度极低,每个服务都可以灵活插拔,可替换
  • 流量削峰:不管发布事件的流量波动多大,都由 Broker 接收,订阅者可以按照自己的速度去处理事件

缺点

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

好在现在开源软件或云平台上 Broker 的软件是非常成熟的,比较常见的一种就是我们今天要学习的 MQ 技术。


14.2.技术对比


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

比较常见的 MQ 实现:ActiveMQRabbitMQRocketMQKafka


几种常见 MQ 的对比

RabbitMQActiveMQRocketMQKafka
公司/社区RabbitApache阿里Apache
开发语言ErlangJavaJavaScala&Java
协议支持AMQPXMPPSMTPSTOMPOpenWireSTOMPRESTXMPPAMQP自定义协议自定义协议
可用性一般
单机吞吐量一般非常高
消息延迟微秒级毫秒级毫秒级毫秒以内
消息可靠性一般一般

追求可用性:KafkaRocketMQRabbitMQ

追求可靠性:RabbitMQRocketMQ

追求吞吐能力:RocketMQKafka

追求消息低延迟:RabbitMQKafka


15.RabbitMQ 快速入门


概述:RabbitMQ 是基于 Erlang 语言开发的开源消息通信中间件。

  • 官网地址:https://www.rabbitmq/
  • 如果想要深入学习 RabbitMQ,建议按照官方文档来学习:https://www.rabbitmq/documentation.html

RabbitMQ 中的一些角色

  • publisher:生产者
  • consumer:消费者
  • channel:操作 MQ 的工具
  • exchange:交换机,负责路由消息到队列中
  • queue:队列,存储消息
  • virtualHost:虚拟主机,是对资源的逻辑分组。隔离不同用户的 exchangequeue、消息。

15.1.安装 RabbitMQ


15.1.1.单机部署


我们在 Centos7 虚拟机中使用 Docker 来安装。


  1. 下载镜像

方式一:在线拉取

docker pull rabbitmq:3-management

方式二:从本地加载

在课前资料已经提供了镜像包:

上传到虚拟机中后,使用命令加载镜像即可:

docker load -i mq.tar


  1. 安装 MQ

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

docker run \
	-e RABBITMQ_DEFAULT_USER=itcast \
	-e RABBITMQ_DEFAULT_PASS=123456 \
	--name mq \
	--hostname mq1 \
	-p 15672:15672 \
	-p 5672:5672 \
	-d \
 	rabbitmq:3-management
  • -e:给容器设置环境变量
  • --name:给容器命名
  • --hostname:配置主机名(单机部署无所谓,集群部署则必须配置主机名)
  • -p:端口映射。
    • 将容器的端口映射到宿主机的端口。冒号左侧是宿主机端口,右侧是容器端口。
    • p 15672:1567215672RabbitMQ 管理平台的窗口
    • p 5672:56725672 是消息通信的端口
  • -d:后台运行
  • rabbitmq:3-management:镜像名称

使用命令关闭之前创建过的 mq 容器:docker stop mq

使用命令开启之前创建过的 mq 容器:docker start mq

更多细节还请参考官网:https://hub.docker/_/rabbitmq

访问 虚拟机地址:15672,输入之前设置的用户名和密码,即可进入 RabbitMQ 的管理平台

RabbitMQ 的管理平台界面

以下的界面介绍参考博客:《RabbitMQ-管理界面介绍

  • Overview:概览
  • Connections:连接。
    • 无论生产者还是消费者,都需要与 RabbitMQ 建立连接后,才可以完成消息的生产和消费。
    • 在这里可以查看连接情况。
  • Channels:通道。建立连接后,会形成通道,消息的投递获取依赖的通道。
  • Exchanges:交换机。用来实现消息的路由
  • Queues:队列。就是消息队列,消息存放在队列中,等待消费,消费后会被移除队列

在我之前的博客里,有着更为详细的介绍,感兴趣的可以逛逛:《学习笔记RabbitMQ 快速入门


关于 RabbitMQ 控制台的一些基本操作

我们可以通过 Admin 界面的 Add users 来添加用户。

但是我们发现,所创建的用户 “lisi” 并没有服务虚拟机的权限。

这是因为 RabbitMQ 中可以创建许多用户,各个用户都可以创建队列,容易产生冲突。

为了避免这种情况的发生,通过不同的虚拟主机对用户的隔离,使得不同用户无法查看到对方的信息。

我们可以创建更多的虚拟主机,给用户 “lisi” 分配虚拟主机的访问权,来给该用户设置权限。

创建虚拟主机(MQ 中的逻辑划分)

通过给用户分配虚拟主机,来使用户获得该虚拟主机的服务权限

原则上来说,一个用户应该有自己独享的虚拟主机


15.1.2.集群部署


  1. 集群分类

RabbitMQ 的官方文档中,讲述了两种集群的配置方式:

  • 普通模式:普通模式集群不进行数据同步,每个 MQ 都有自己的队列、数据信息(其它元数据信息如交换机等会同步)。
    • 例如我们有 2MQmq1mq2)如果你的消息在 mq1,而你连接到了 mq2,那么 mq2 会去 mq1 拉取消息,然后返回给你。
    • 如果 mq1 宕机,消息就会丢失。
  • 镜像模式:与普通模式不同,队列会在各个 mq 的镜像节点之间同步,因此你连接到任何一个镜像节点,均可获取到消息。
    • 而且如果一个节点宕机,并不会导致数据丢失。
    • 不过,这种方式增加了数据同步的带宽消耗。

  1. 设置网络

首先,我们需要让 3MQ 互相知道对方的存在。

分别在 3 台机器中,设置 /etc/hosts 文件,添加如下内容:

192.168.150.101 mq1
192.168.150.102 mq2
192.168.150.103 mq3

并在每台机器上测试,是否可以 ping 通对方


15.2.RabbitMQ 消息模型


RabbitMQ 官方提供了 6 个不同的 Demo 示例,对应了不同的消息模型:

  • 基本消息队列(BasicQueue)、工作消息队列(WorkQueue)、发布订阅(publishsubscribe)。

发布订阅,又根据交换机类型不同分为 3

  • 广播(Fanout exchange)、路由(Direct Exchange)、主题(Topic Exchange

另外还有 RPC 远程调用模式(远程调用,不太算 MQ。暂不作介绍)

访问官网模式介绍页面:https://www.rabbitmq/getstarted.html (访问时间:2022-07-07


15.3.导入 Demo 工程


课前资料提供了一个 Demo 工程:mq-demo


导入后可以看到结构如下

  • mq-demo:父工程,管理项目依赖
  • publisher:消息的发送者
  • consumer:消息的消费者


15.4.入门案例


简单队列模式的模型图

官方的 HelloWorld 是基于最基础的消息队列模型来实现的,只包括三个角色:

  • publisher:消息发布者,将消息发送到队列 queue
  • queue:消息队列,负责接受并缓存消息
  • consumer:订阅队列,处理队列中的消息

15.4.1.publisher 实现


思路:

  • 建立连接
  • 创建 Channel
  • 声明队列
  • 发送消息
  • 关闭连接和 channel

publisher 模块下的 src/test/java/cn/itcast/mq/helloworld/PublisherTest.java

package cn.itcast.mq.helloworld;

import com.rabbitmq.client.Channel;
import com.rabbitmq.client.Connection;
import com.rabbitmq.client.ConnectionFactory;
import org.junit.Test;

import java.io.IOException;
import java.util.concurrent.TimeoutException;

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

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

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

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

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

    }
}

15.4.2.consumer 实现


思路:

  • 建立连接
  • 创建 Channel
  • 声明队列
  • 订阅消息

consumer 模块下的 src/test/java/cn/itcast/mq/helloworld/ConsumerTest.java

package cn.itcast.mq.helloworld;

import com.rabbitmq.client.*;

import java.io.IOException;
import java.util.concurrent.TimeoutException;

public class ConsumerTest {

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

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

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

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

为了不影响后续的操作,我们需要关闭该通道和该连接

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

15.5.总结


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

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

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

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

16.SpringAMQP


SpringAMQP 是基于 RabbitMQ 封装的一套模板,并且还利用 SpringBoot 对其实现了自动装配,使用起来非常方便。

SpringAMQP 的官方地址:https://spring.io/projects/spring-amqp


SpringAMQP 提供了三个功能:

  • 自动声明队列、交换机及其绑定关系
  • 基于注解的监听器模式,异步接收消息
  • 封装了 RabbitTemplate 工具,用于发送消息

16.1.Basic Queue


Basic Queue(简单队列模型)

案例流程:

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

在父工程 mq-demo 中引入依赖

<!-- AMQP 依赖,包含 RabbitMQ-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-amqp</artifactId>
</dependency>

16.1.1.消息发送


首先配置 MQ 地址,在 publisher 服务的 application.yml 中添加配置:

publisher 服务下的 src/main/resources/application.yml

spring:
  rabbitmq:
    host: 192.168.150.101 # 主机名
    port: 5672 # 端口
    virtual-host: / # 虚拟主机
    username: itcast # 用户名
    password: 123456 # 密码

然后在 publisher 服务中编写测试类 SpringAmqpTest,并利用 RabbitTemplate 实现消息发送

publisher 服务下的 src/test/java/cn/itcast/mq/spring/SpringAmqpTest.java

package cn.itcast.mq.spring;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;

@RunWith(SpringRunner.class)
@SpringBootTest
public class SpringAmqpTest {

    @Autowired
    private RabbitTemplate rabbitTemplate;

    @Test
    public void testSimpleQueue() {
        // 队列名称
        String queueName = "simple.queue";
        // 消息
        String message = "hello, spring amqp!";
        // 发送消息
        rabbitTemplate.convertAndSend(queueName, message);
    }
}

启动测试类后的情况



总结

什么是 AMQP?

  • 应用间消息通信的一种协议,与语言和平台无关。

SpringAMQP 如何发送消息?

  • 引入 amqpstarter 依赖
  • 配置 RabbitMQ 地址
  • 利用 RabbitTemplateconvertAndSend 方法

16.1.2.消息接收


首先配置 MQ 地址,在 consumer 服务的 application.yml 中添加配置:

consumer 服务下的 src/main/resources/application.yml

spring:
  rabbitmq:
    host: 192.168.150.101 # 主机名
    port: 5672 # 端口
    virtual-host: / # 虚拟主机
    username: itcast # 用户名
    password: 123456 # 密码

然后在 consumer 服务的 cn.itcast.mq.listener 包中新建一个类 SpringRabbitListener,代码如下:

consumer 服务下的 src/main/java/cn/itcast/mq/listener/SpringRabbitListener.java

package cn.itcast.mq.listener;

import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;

@Component
public class SpringRabbitListener {

    @RabbitListener(queues = "simple.queue")
    public void listenSimpleQueueMessage(String msg) throws InterruptedException {
        System.out.println("spring 消费者接收到消息:【" + msg + "】");
    }
}

启动 consumer 服务中的启动类后的情况



总结

SpringAMQP 如何接收消息?

  • 引入 amqpstarter 依赖
  • 配置 RabbitMQ地址
  • 定义类,添加 @Component 注解
  • 类中声明方法,添加 @RabbitListener 注解,方法参数就时消息

注意:消息一旦消费就会从队列删除,RabbitMQ 没有消息回溯功能


16.2.WorkQueue


Work queues(工作队列),也被称为(Task queues),任务模型。

简单来说就是让多个消费者绑定到一个队列,共同消费队列中的消息

当消息处理比较耗时的时候,可能生产消息的速度会远远大于消息的消费速度。

长此以往,消息就会堆积越来越多,无法及时处理。

此时就可以使用 work 模型,多个消费者共同处理消息处理,速度就能大大提高了。


案例的基本思路

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

16.2.1.消息发送


这次我们循环发送,模拟大量消息堆积现象。

publisher 服务中的 SpringAmqpTest 类中添加一个测试方法

src/test/java/cn/itcast/mq/spring/SpringAmqpTest.java

/**
 * workQueue
 * 向队列中不停发送消息,模拟消息堆积。
 *
 * @throws InterruptedException
 */
@Test
public void testWorkQueue() throws InterruptedException {
    // 队列名称
    String queueName = "simple.queue";
    // 消息
    String message = "hello, message_";
    for (int i = 0; i < 50; i++) {
        // 发送消息
        rabbitTemplate.convertAndSend(queueName, message + i);
        Thread.sleep(20);
    }
}

16.2.2.消息接收


要模拟多个消费者绑定同一个队列,我们在 consumer 服务的 SpringRabbitListener 中添加 2 个新的方法

此处需要注释掉之前的 ListenSimpleQueue(String msg){} 方法

src/main/java/cn/itcast/mq/listener/SpringRabbitListener.java

@RabbitListener(queues = "simple.queue")
public void listenWorkQueue1(String msg) throws InterruptedException {
    System.out.println("消费者1接收到消息:【" + msg + "】" + LocalTime.now());
    Thread.sleep(20);
}

@RabbitListener(queues = "simple.queue")
public void listenWorkQueue2(String msg) throws InterruptedException {
	//使用 error 只是为了使控制台打印出红色,方便观察
    System.err.println("消费者2........接收到消息:【" + msg + "】" + LocalTime.now());
    Thread.sleep(200);
}

注意到这个消费者 sleep1000 秒,模拟任务耗时。


16.2.3.测试


启动 ConsumerApplication 后,在执行 publisher 服务中刚刚编写的发送测试方法 testWorkQueue

可以看到消费者 1 很快完成了自己的 25 条消息。消费者 2 却在缓慢的处理自己的 25 条消息。

也就是说消息是平均分配给每个消费者,并没有考虑到消费者的处理能力。这样显然是有问题的。


16.2.4.能者多劳


spring 中有一个简单的配置,可以解决这个问题。

我们修改 consumer 服务的 application.yml 文件,添加配置:

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

之后再重启 consumer 服务,使用测试类 SpringAmqpTest 发送单元请求

总体上的耗时时间变短了。


16.2.5.总结


Work 模型的使用

  • 多个消费者绑定到一个队列,同一条消息只会被一个消费者处理
  • 通过设置 prefetch 来控制消费者预取的消息数量

16.3.发布/订阅


发布订阅的模型如图:

可以看到,在订阅模型中,多了一个 exchange 角色,而且过程略有变化:

  • Publisher:生产者,也就是要发送消息的程序,但是不再发送到队列中,而是发给 X(交换机)
  • Exchange:交换机,图中的 X
    • 一方面,接收生产者发送的消息。
    • 另一方面,知道如何处理消息,例如递交给某个特别队列、递交给所有队列、或是将消息丢弃。
    • 到底如何操作,取决于 Exchange 的类型。
    • Exchange 有以下 3 种类型:
      • Fanout:广播,将消息交给所有绑定到交换机的队列
      • Direct:定向,路由。把消息交给符合指定 routing key 的队列
      • Topic:通配符,话题。把消息交给符合 routing pattern(路由模式) 的队列
  • Consumer:消费者,与以前一样,订阅队列,没有变化
  • Queue:消息队列也与以前一样,接收消息、缓存消息。

注意Exchange交换机只负责转发消息不具备存储消息的能力

因此如果没有任何队列与 Exchange 绑定,或者没有符合路由规则的队列,那么消息会丢失!


16.4.Fanout


Fanout,英文翻译是扇出。

或许在 MQ 中叫广播更合适

Fanout Exchange 会将接收到的消息广播到每一个跟其绑定的 queue

在广播模式下,消息发送流程是这样的:

  1. 可以有多个队列
  2. 每个队列都要绑定到 Exchange(交换机)
  3. 生产者发送的消息,只能发送到交换机,交换机来决定要发给哪个队列,生产者无法决定
  4. 交换机把消息发送给绑定过的所有队列
  5. 订阅队列的消费者都能拿到消息

计划是这样的:

  • 创建一个交换机 itcast.fanout,类型是 Fanout
  • 创建两个队列 fanout.queue1fanout.queue2,绑定到交换机 itcast.fanout


案例思路

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

16.4.1.声明队列和交换机


Spring 提供了一个接口 Exchange,来表示所有不同类型的交换机:


consumer 中创建一个类,声明队列和交换机

consumer 服务下的 src/main/java/cn/itcast/mq/config/FanoutConfig.java

package cn.itcast.mq.config;

import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.FanoutExchange;
import org.springframework.amqp.core.Queue;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class FanoutConfig {
    /**
     * 声明交换机
     *
     * @return Fanout 类型交换机
     */
    @Bean
    public FanoutExchange fanoutExchange() {
        return new FanoutExchange("itcast.fanout");
    }

    /**
     * 第 1 个队列
     */
    @Bean
    public Queue fanoutQueue1() {
        return new Queue("fanout.queue1");
    }

    /**
     * 绑定队列 1 和交换机
     */
    @Bean
    public Binding bindingQueue1(Queue fanoutQueue1, FanoutExchange fanoutExchange) {
        return BindingBuilder.bind(fanoutQueue1).to(fanoutExchange);
    }

    /**
     * 第 2 个队列
     */
    @Bean
    public Queue fanoutQueue2() {
        return new Queue("fanout.queue2");
    }

    /**
     * 绑定队列 2 和交换机
     */
    @Bean
    public Binding bindingQueue2(Queue fanoutQueue2, FanoutExchange fanoutExchange) {
        return BindingBuilder.bind(fanoutQueue2).to(fanoutExchange);
    }
}

启动 consumer 服务后,观察控制台


16.4.2.消息发送


publisher 服务的 SpringAmqpTest 类中添加测试方法:

src/test/java/cn/itcast/mq/spring/SpringAmqpTest.java

@Test
public void testFanoutExchange() {
    // 交换机的名称
    String exchangeName = "itcast.fanout";
    // 消息
    String message = "hello, everyone!";
    // public void convertAndSend(String exchange, String routingKey, Object object)
    rabbitTemplate.convertAndSend(exchangeName, "", message);
}

16.4.3.消息接收


consumer 服务的 SpringRabbitListener 中添加两个方法,作为消费者:

src/main/java/cn/itcast/mq/listener/SpringRabbitListener.java

@RabbitListener(queues = "fanout.queue1")
public void listenFanoutQueue1(String msg) {
    System.out.println("消费者1接收到Fanout消息:【" + msg + "】");
}

@RabbitListener(queues = "fanout.queue2")
public void listenFanoutQueue2(String msg) {
    System.out.println("消费者2接收到Fanout消息:【" + msg + "】");
}

启动 consumer 服务和 publisher 服务下的测试类


16.4.4.总结


交换机的作用是什么?

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

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

  • Queue
  • FanoutExchange
  • Binding

16.5.Direct


Fanout 模式中,一条消息,会被所有订阅的队列都消费。

但是,在某些场景下,我们希望不同的消息被不同的队列消费。

这时就要用到 Direct 类型的 Exchange

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

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

Direct 模型下:

  • 队列与交换机的绑定,不能是任意绑定了,而是要指定一个 RoutingKey(路由 key
  • 消息的发送方在 向 Exchange 发送消息时,也必须指定消息的 RoutingKey
  • Exchange 不再把消息交给每一个绑定的队列,而是根据消息的 Routing Key进行判断。
    • 只有队列的 Routingkey 与消息的 Routingkey 完全一致,才会接收到消息

案例需求

  1. 利用 @RabbitListener 声明 ExchangeQueueRoutingKey
  2. consumer 服务中,编写两个消费者方法,分别监听 direct.queue1direct.queue2
  3. publisher 中编写测试方法,向 itcast. direct发送消息


16.5.1.基于注解声明队列和交换机


基于 @Bean 的方式声明队列和交换机比较麻烦,Spring 还提供了基于注解方式来声明。

consumerSpringRabbitListener 中添加两个消费者,同时基于注解来声明队列和交换机

src/main/java/cn/itcast/mq/listener/SpringRabbitListener.java

//基于注解,type 默认为 direct 可不写
@RabbitListener(bindings = @QueueBinding(
    value = @Queue(name = "direct.queue1"),//队列
    exchange = @Exchange(name = "itcast.direct", type = ExchangeTypes.DIRECT),//交换机
    key = {"red", "blue"}//bindingKey
))
public void listenDirectQueue1(String msg){
    System.out.println("消费者接收到direct.queue1的消息:【" + msg + "】");
}

@RabbitListener(bindings = @QueueBinding(
    value = @Queue(name = "direct.queue2"),
    exchange = @Exchange(name = "itcast.direct", type = ExchangeTypes.DIRECT),
    key = {"red", "yellow"}
))
public void listenDirectQueue2(String msg){
    System.out.println("消费者接收到direct.queue2的消息:【" + msg + "】");
}

启动 consumer 服务后,发现新生成的交换机,且相关队列绑定了 key,与预期一致


查看队列界面


16.5.2.消息发送


publisher 服务的 SpringAmqpTest 类中添加测试方法

src/test/java/cn/itcast/mq/spring/SpringAmqpTest.java

@Test
public void testSendDirectExchange() {
    // 交换机名称
    String exchangeName = "itcast.direct";
    // 消息
    String message = "红色警报!";//红黄蓝色
    // 发送消息
    rabbitTemplate.convertAndSend(exchangeName, "red", message);//routingKey(red、yellow、blue)
}

启动该测试类的相关方法后,通过更改 routingKey 的值来确认代码结果是否达到预期


16.5.3.总结


描述下 Direct 交换机与 Fanout 交换机的差异

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

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

  • @Queue
  • @Exchange

16.6.Topic


16.6.1.说明


Topic 类型的 ExchangeDirect 相比,都是可以根据 RoutingKey 把消息路由到不同的队列。

只不过 Topic 类型 Exchange 可以让队列在绑定 Routing key 的时候使用通配符

Routingkey 一般都是有一个或多个单词组成,多个单词之间以 “.” 分割,例如: item.insert

通配符规则

  • #:匹配一个或多个词
  • *:匹配不多不少恰好 1 个词

举例

  • item.#:能够匹配 item.spu.insert 或者 item.spu
  • item.*:只能匹配 item.spu

图示:

解释:

  • Queue1:绑定的是 china.# ,因此凡是以 china. 开头的 routing key 都会被匹配到。包括 china.newschina.weather
  • Queue2:绑定的是 #.news ,因此凡是以 .news 结尾的 routing key 都会被匹配。包括 china.newsjapan.news

案例的实现思路:

  1. 利用 @RabbitListener 声明 ExchangeQueueRoutingKey
  2. consumer 服务中,编写两个消费者方法,分别监听 topic.queue1topic.queue2
  3. publisher 中编写测试方法,向 itcast.topic 发送消息


16.6.2.消息发送


publisher 服务的 SpringAmqpTest 类中添加测试方法:

src/test/java/cn/itcast/mq/spring/SpringAmqpTest.java

/**
 * topicExchange
 */
@Test
public void testSendTopicExchange() {
    // 交换机名称
    String exchangeName = "itcast.topic";
    // 消息
    String message = "喜报!孙悟空大战哥斯拉,胜!";
    // 发送消息
    rabbitTemplate.convertAndSend(exchangeName, "china.news", message);
}

16.6.3.消息接收


consumer 服务的 SpringRabbitListener 中添加方法

src/main/java/cn/itcast/mq/listener/SpringRabbitListener.java

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

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

重启 consumer 服务


启动测试类中的相关方法测试


16.6.4.总结


描述下 Direct 交换机与 Topic 交换机的差异?

  • Topic 交换机接收的消息 RoutingKey 必须是多个单词,以 **.** 分割
  • Topic 交换机与队列绑定时的 bindingKey 可以指定通配符
  • #:代表0个或多个词
  • *:代表1个词

16.7.消息转换器


使用 idea 快捷键查看参数列表(视频中的为 Ctrl + K,但我的电脑上的是 Ctrl + P

由上图可以看出:在 SpringAMQP 的发送方法中,接收消息的类型是 Object

事实上,我们可以发送任意对象类型的消息,SpringAMQP 会帮我们序列化为字节后发送给 MQ

接收消息的时候,还会把字节反序列化为 Java 对象。

只不过,默认情况下 Spring 采用的序列化方式是 JDK 序列化。

众所周知,JDK 序列化存在问题:数据体积过大、有安全漏洞、可读性差


16.7.1.测试默认转换器


我们在 consumer 中利用 @Bean 声明一个队列:

src/main/java/cn/itcast/mq/config/FanoutConfig.java

@Bean
public Queue objectQueue(){
    return new Queue("object.queue");
}

重启服务后,发现 object.queue 队列成功创建


我们在 publisher 中添加有关消息发送的方法的代码,发送一个 Map 对象

src/test/java/cn/itcast/mq/spring/SpringAmqpTest.java

@Test
public void testSendMap() throws InterruptedException {
    // 准备消息
    Map<String,Object> msg = new HashMap<>();
    msg.put("name", "Jack");
    msg.put("age", 21);
    // 发送消息
    rabbitTemplate.convertAndSend("object.queue", msg);
}

测试结果

很明显,Spring 采用的序列化方式是 JDK 序列化。

JDK 序列化存在问题:可读性差、数据体积过大、有安全漏洞。


16.7.2.配置 JSON 转换器


显然,JDK 序列化方式并不合适。

我们希望消息体的体积更小、可读性更高,因此可以使用 JSON 方式来做序列化和反序列化。


Spring 对消息对象的处理是由 org.springframework.amqp.support.converter.MessageConverter 来处理的。

而默认实现是 SimpleMessageConverter ,基于 JDKObjectOutputStream 完成序列化。

如果要修改只需要定义一个 MessageConverter 类型的 Bean 即可。推荐用 JSON 方式序列化。


  1. 导入依赖

publisherconsumer 两个服务中都引入依赖

pom.xml

<dependency>
    <groupId>com.fasterxml.jackson.dataformat</groupId>
    <artifactId>jackson-dataformat-xml</artifactId>
    <version>2.9.10</version>
</dependency>

或者直接在父工程 mq-demo 中导入依赖

pom.xml

<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
</dependency>

  1. 配置消息转换器

publisherconsumer 两个服务中启动类中添加一个 Bean 即可

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

测试


  1. consumer 下的 SpringRabbitListener 添加消费方法

src/main/java/cn/itcast/mq/listener/SpringRabbitListener.java

@RabbitListener(queues = "object.queue")
public void ListenObjectQueue(Map<String, Object> msg) {
    System.out.println("收到消息:" + msg);
}

重新启动 consumer 的服务,再使用 publisher 服务中的测试类的相关方法发送消息。

查看 Idea 控制台的情况。


16.7.3.小结


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

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

# SpringCloudDay05


SpringCloud 学习 Day05实用篇-5 分布式搜索引擎:elasticsearch 基础

  • 初识 ElasticSearch
  • 索引库操作
  • 文档操作
  • RestAPI
  • RestClient 操作文档

17.初识 ElasticSearch


17.1.了解 ES


17.1.1.elasticsearch 的作用


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


GitHub 搜索代码


在电商网站搜索商品


在浏览器搜索答案

  • ELK 技术栈


17.1.2.ELK 技术栈


elasticsearch 结合 kibanaLogstashBeats,也就是 elastic stackELK

被广泛应用在日志数据分析、实时监控等领域。


elasticsearchelastic stack 的核心,负责存储、搜索、分析数据。


17.1.3.elasticsearch 和 lucene


elasticsearch 底层是基于 lucene 来实现的。

Lucene 是一个 Java 语言的搜索引擎类库,是 Apache 公司的顶级项目,由 DougCutting1999 年研发。

官网地址:https://lucene.apache/


elasticsearch的发展历史:

  • 2004Shay Banon 基于 Lucene 开发了 Compass
  • 2010Shay Banon 重写了Compass,取名为Elasticsearch

官网地址:https:// www.elastic.co/cn/

视频发布时间:2021-08-11

2022-7-9 的最新版本为 8.3.2


17.1.4.为什么不是其他搜索技术


目前比较知名的搜索引擎技术排名(视频发布时间:2021-08-11

Elasticsearch:开源的分布式搜索引擎

Splunk:商业项目

SolrApache 的开源搜索引擎


虽然在早期,Apache Solr 是最主要的搜索引擎技术,但随着发展 elasticsearch 已经渐渐超越了 Solr,独占鳌头


17.1.5.总结


什么是 elasticsearch

  • 一个开源的分布式搜索引擎,可以用来实现搜索、日志统计、分析、系统监控等功能

什么是 elastic stackELK)?

  • 是以 elasticsearch 为核心的技术栈,包括 beatsLogstashkibanaelasticsearch

什么是 Lucene

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

17.2.倒排索引


倒排索引的概念是基于 MySQL 这样的正向索引而言的。


17.2.1.正向索引


那么什么是正向索引呢?例如给下表(tb_goods)中的 id 创建索引:

如果是根据 id 查询,那么直接走索引,查询速度非常快。

但如果是基于 title 做模糊查询,只能是逐行扫描数据,流程如下

  1. 用户搜索数据,条件是 title 符合 "%手机%"
  2. 逐行获取数据,比如 id1 的数据
  3. 判断数据中的 title 是否符合用户搜索条件
  4. 如果符合则放入结果集,不符合则丢弃。回到步骤 1

逐行扫描,也就是全表扫描,随着数据量增加,其查询效率也会越来越低。当数据量达到数百万时,就是一场灾难。


17.2.2.倒排索引


倒排索引中有两个非常重要的概念

  • 文档(Document):用来搜索的数据,其中的每一条数据就是一个文档。例如一个网页、一个商品信息
  • 词条(Term):对文档数据或用户搜索数据,利用某种算法分词,得到的具备含义的词语就是词条。
    • 例如:我是中国人,就可以分为:我、是、中国人、中国、国人这样的几个词条

创建倒排索引 是对正向索引的一种特殊处理,流程如下:

  • 将每一个文档的数据利用算法分词,得到一个个词条
  • 创建表,每行数据包括词条、词条所在文档 id、位置等信息
  • 因为词条唯一性,可以给词条创建索引,例如 hash 表结构索引

倒排索引的搜索流程如下(以搜索 “华为手机” 为例):

  1. 用户输入条件"华为手机"进行搜索。
  2. 对用户输入内容分词,得到词条:华为手机
  3. 拿着词条在倒排索引中查找,可以得到包含词条的文档 id123
  4. 拿着文档 id 到正向索引中查找具体文档。


虽然要先查询倒排索引,再查询倒排索引,但是无论是词条、还是文档 id 都建立了索引,查询速度非常快!无需全表扫描。


17.2.3.总结


什么是文档和词条?

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

为什么一个叫做正向索引,一个叫做倒排索引呢?

  • 正向索引是最传统的,根据 id 索引的方式。
    • 但根据词条查询时,必须先逐条获取每个文档,然后判断文档中是否包含所需要的词条,是根据文档找词条的过程
  • 倒排索引是先找到用户要搜索的词条,根据词条得到保护词条的文档的 id,然后根据 id 获取文档。
    • 根据词条找文档的过程

两者方式的优缺点是什么呢?

正向索引

  • 优点:
    • 可以给多个字段创建索引
    • 根据索引字段搜索、排序速度非常快
  • 缺点:
    • 根据非索引字段,或者索引字段中的部分词条查找时,只能全表扫描。

倒排索引

  • 优点:
    • 根据词条搜索、模糊搜索时,速度非常快
  • 缺点:
    • 只能给词条创建索引,而不是字段
    • 无法根据字段做排序

17.3.es 的一些概念


elasticsearch 中有很多独有的概念,与 mysql 中略有差别,但也有相似之处。


17.3.1.文档和字段


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

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

Json 文档中往往包含很多的字段Field),类似于数据库中的列。


17.3.2.索引和映射


索引Index),就是相同类型的文档的集合。

例如:

  • 所有用户文档,就可以组织在一起,称为用户的索引;
  • 所有商品的文档,可以组织在一起,称为商品的索引;
  • 所有订单的文档,可以组织在一起,称为订单的索引;

因此,我们可以把索引当做是数据库中的表。

数据库的表会有约束信息,用来定义表的结构、字段的名称、类型等信息。

因此,索引库中就有映射mapping),是索引中文档的字段约束信息,类似表的结构约束。


17.3.3.mysql 与 elasticsearch


我们统一的把 mysqlelasticsearch 的概念做一下对比:

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

是不是说,我们学习了 elasticsearch 就不再需要 mysql 了呢?

并不是如此,两者各自有自己的长处:

  • MySQL:擅长事务类型操作,可以确保数据的安全和一致性
  • ElasticSearch:擅长海量数据的搜索、分析、计算

因此在企业中,往往是两者结合使用:

  • 对安全性要求较高的写操作,使用 mysql 实现
  • 对查询性能要求较高的搜索需求,使用 elasticsearch 实现
  • 两者再基于某种方式,实现数据的同步,保证一致性


17.3.4.总结


  • 文档:一条数据就是一个文档,esJson 格式
  • 字段:Json 文档中的字段
  • 索引:同类型文档的集合
  • 映射:索引中的文档的约束,比如字段名称、类型
  • ElasticSearch 与数据库的关系
    • 数据库负责事务类型的操作
    • ElasticSearch 负责海量数据的搜索、分析、计算

17.4.安装 es、kibana


17.4.1.部署单点 ES


创建网络

因为我们还需要部署 kibana 容器,因此需要让 eskibana 容器互联。

这里先创建一个网络

docker network create es-net

加载镜像

这里我们采用 elasticsearch7.12.1 版本的镜像,这个镜像体积非常大,接近 1G

不建议大家自己 pull。课前资料提供了镜像的 tar 包。

  • 课前资料链接:https://pan.baidu/s/169SFtYEvel44hRJhmFTRTQ
  • 提取码1234
  • 1.微服务开发框架 SpringCloud + RabbitMQ + Docker + Redis + 搜索 + 分布式史上最全面的微服务全技术栈课程>
    • 实用篇>学习资料>day05-Elasticsearch01>资料

大家将其上传到虚拟机中,然后运行命令加载即可

docker load -i es.tar

同理,kibanatar 包也需要这样做。


运行

运行 docker 命令,部署单点 es

docker run -d \
	--name es \
    -e "ES_JAVA_OPTS=-Xms512m -Xmx512m" \
    -e "discovery.type=single-node" \
    -v es-data:/usr/share/elasticsearch/data \
    -v es-plugins:/usr/share/elasticsearch/plugins \
    --privileged \
    --network es-net \
    -p 9200:9200 \
    -p 9300:9300 \
elasticsearch:7.12.1

命令解释:

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

9200ES 暴露的 http 协议端口(用户访问)

9300ES 暴露的 ES 容器间互连的端口


FinalShell 截图


在浏览器中输入:http://虚拟机地址:9200 即可看到 elasticsearch 的响应结果:


17.4.2.部署 kibana


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


部署

首先加载上传到 CentOS7kibana.tar 文件

docker load -i kibana.tar

运行 docker 命令,部署 kibana

docker run -d \
	--name kibana \
	-e ELASTICSEARCH_HOSTS=http://es:9200 \
	--network=es-net \
	-p 5601:5601  \
kibana:7.12.1

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

kibana 启动一般比较慢,需要多等待一会,可以通过命令:

docker logs -f kibana

查看运行日志,当查看到下面的日志,说明成功:

输入 http://虚拟机地址:5601

我这里是选择 “Explore on my own”,直接进入了下方界面。


DevTools

kibana 中提供了一个 DevTools 界面

这个界面中可以编写 DSL 来操作 elasticsearch

并且对 DSL 语句有自动补全功能。


关于 es 集群的部署,后续会介绍,这里暂且跳过。


17.5.安装 IK 分词器


17.5.1.安装理由


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

我们在 kibanaDevTools 中测试:

# 测试分词器
POST /_analyze
{
  "text": "测试文本",
  "analyzer": "standard"
}

语法说明

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

测试结果


处理中文分词,一般会使用 IK 分词器。

相关链接https://github/medcl/elasticsearch-analysis-ik


17.5.2.在线安装 IK 插件


在线安装 IK 插件较慢


进入容器内部

docker exec -it elasticsearch /bin/bash

在线下载并安装

./bin/elasticsearch-plugin  install https://github/medcl/elasticsearch-analysis-ik/releases/download/v7.12.1/elasticsearch-analysis-ik-7.12.1.zip

退出

exit

重启容器

docker restart elasticsearch

17.5.3.离线安装 IK 插件


离线安装 IK 插件推荐


查看数据卷目录

安装插件需要知道 elasticsearchplugins 目录位置。

而我们用了数据卷挂载,因此需要查看 elasticsearch 的数据卷目录。

通过下面命令查看:

docker volume inspect es-plugins

显示结果:

[
    {
        "CreatedAt": "2022-05-06T10:06:34+08:00",
        "Driver": "local",
        "Labels": null,
        "Mountpoint": "/var/lib/docker/volumes/es-plugins/_data",
        "Name": "es-plugins",
        "Options": null,
        "Scope": "local"
    }
]

说明 plugins 目录被挂载到了:/var/lib/docker/volumes/es-plugins/_data 这个目录中。


解压缩分词器安装包

下面我们需要把课前资料中的 ik 分词器解压缩,重命名为 ik


上传到 es 容器的插件数据卷中

也就是 /var/lib/docker/volumes/es-plugins/_data


重启容器

docker restart es

查看日志

docker logs -f es

测试

IK 分词器包含两种模式:

  • ik_smart:最少切分
  • ik_max_word:最细切分
  • 举例
GET /_analyze
{
  "analyzer": "ik_max_word",
  "text": "这是SpringCloud的中文学习笔记"
}


17.5.4.拓展词典


随着互联网的发展,“造词运动” 也越发的频繁。

出现了很多新的词语,在原有的词汇列表中并不存在。

比如:“奥力给”“传智播客” 等。

所以我们的词汇也需要不断的更新,IK分词器提供了扩展词汇的功能。


  1. 打开 IK 分词器 config 目录


  1. IKAnalyzer.cfg.xml 配置文件内容添加
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE properties SYSTEM "http://java.sun/dtd/properties.dtd">
<properties>
        <comment>IK Analyzer 扩展配置</comment>
        <!--用户可以在这里配置自己的扩展字典 *** 添加扩展词典-->
        <entry key="ext_dict">ext.dic</entry>
</properties>


  1. 新建一个 ext.dic,可以参考 config 目录下复制一个配置文件进行修改
传智播客
奥力给


  1. 重启 elasticsearch
docker restart es

  1. 查看日志
docker logs -f elasticsearch

日志中已经成功加载 ext.dic 配置文件


  1. 测试效果
GET /_analyze
{
  "analyzer": "ik_max_word",
  "text": "此文章摘抄自传智播客的开放视频,奥力给!"
}

注意当前文件的编码必须是 UTF-8 格式,严禁使用 Windows 记事本编辑


更多信息还请访问:https://github/medcl/elasticsearch-analysis-ik


17.5.5.停用词典


在互联网项目中,在网络间传输的速度很快,所以很多语言是不允许在网络上传递的。

如:关于宗教、政治等敏感词语,那么我们在搜索时也应该忽略当前词汇。

IK 分词器也提供了强大的停用词功能,让我们在索引时就直接忽略当前的停用词汇表中的内容。


  1. IKAnalyzer.cfg.xml 配置文件内容添加:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE properties SYSTEM "http://java.sun/dtd/properties.dtd">
<properties>
        <comment>IK Analyzer 扩展配置</comment>
        <!--用户可以在这里配置自己的扩展字典-->
        <entry key="ext_dict">ext.dic</entry>
         <!--用户可以在这里配置自己的扩展停止词字典  *** 添加停用词词典-->
        <entry key="ext_stopwords">stopword.dic</entry>
</properties>


  1. stopword.dic 添加停用词
某大大


  1. 重启 elasticsearch 、重启 kibana
docker restart elasticsearch
docker restart kibana

  1. 查看日志
docker logs -f elasticsearch

  1. 测试效果
GET /_analyze
{
  "analyzer": "ik_max_word",
  "text": "某大大的路程"
}

注意当前文件的编码必须是 UTF-8 格式,严禁使用 Windows 记事本编辑


更多信息还请访问:https://github/medcl/elasticsearch-analysis-ik


17.5.6.总结


分词器的作用是什么?

  • 创建倒排索引时对文档分词
  • 用户搜索时,对输入的内容分词

IK 分词器有几种模式?

  • ik_smart:智能切分,粗粒度
  • ik_max_word:最细切分,细粒度

IK 分词器如何拓展词条?如何停用词条?

  • 利用 config 目录的 IkAnalyzer.cfg.xml 文件添加拓展词典和停用词典
  • 在词典中添加拓展词条或者停用词条

18.索引库操作


索引库就类似数据库表,mapping 映射就类似表的结构。

我们要向 es 中存储数据,必须先创建 “库”“表”


18.1.mapping 映射属性


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

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

例如下面的 json 文档:

{
    "age": 21,
    "weight": 52.1,
    "isMarried": false,
    "info": "黑马程序员Java讲师",
    "email": "zy@itcast",
    "score": [99.1, 99.5, 98.9],
    "name": {
        "firstName": "云",
        "lastName": "赵"
    }
}

对应的每个字段映射(mapping

  • age:类型为 integer;参与搜索,因此需要 indextrue;无需分词器
  • weight:类型为 float;参与搜索,因此需要 indextrue;无需分词器
  • isMarried:类型为 boolean;参与搜索,因此需要 indextrue;无需分词器
  • info:类型为字符串,需要分词,因此是 text;参与搜索,因此需要index为true;分词器可以用 ik_smart
  • email:类型为字符串,但是不需要分词,因此是 keyword;不参与搜索,因此需要 indexfalse;无需分词器
  • score:虽然是数组,但是我们只看元素的类型,类型为 float;参与搜索,因此需要 indextrue;无需分词器
  • name:类型为 object,需要定义多个子属性
    • name.firstName:类型为字符串,但是不需要分词,因此是 keyword;参与搜索,因此需要 indextrue;无需分词器
    • name.lastName:类型为字符串,但是不需要分词,因此是 keyword;参与搜索,因此需要 indextrue;无需分词器

18.2.索引库的 CRUD


这里我们统一使用 Kibana 编写 DSL 的方式来演示。


18.2.1.创建索引库和映射


ES 中通过 Restful 请求操作索引库、文档。

请求内容用 DSL 语句来表示。

基本语法

  • 请求方式:PUT
  • 请求路径:/索引库名,可以自定义
  • 请求参数:mapping 映射


格式

PUT /索引库名称
{
  "mappings": {
    "properties": {
      "字段名":{
        "type": "text",
        "analyzer": "ik_smart"
      },
      "字段名2":{
        "type": "keyword",
        "index": "false"
      },
      "字段名3":{
        "properties": {
          "子字段": {
            "type": "keyword"
          }
        }
      },
      // ...略
    }
  }
}

示例

# 创建索引库
PUT /heima
{
  "mappings": {
    "properties": {
      "info":{
        "type":"text",
        "analyzer": "ik_smart"
      },
      "email":{
        "type":"keyword",
        "index": false
      },
      "name":{
        "type": "object",
        "properties": {
          "firstName":{
            "type": "keyword"
          },
          "lastName":{
            "type": "keyword"
          }
        }
      }
    }
  }
}


18.2.2.查询索引库


基本语法

  • 请求方式:GET
  • 请求路径:/索引库名
  • 请求参数:无

格式

GET /索引库名

GET /heima


18.2.3.修改索引库


倒排索引结构虽然不复杂,但是一旦数据结构改变(比如改变了分词器),就需要重新创建倒排索引,这简直是灾难。

因此索引库一旦创建,无法修改mapping

虽然无法修改 mapping 中已有的字段,但是却允许添加新的字段到 mapping 中,因为不会对倒排索引产生影响。

语法说明

PUT /索引库名/_mapping
{
  "properties": {
    "新字段名":{
      "type": "integer"
    }
  }
}

示例

# 修改索引库(添加新字段)
PUT /heima/_mapping
{
  "properties":{
    "age":{
      "type": "integer"
    }
  }
}

重复添加同一字段会报错

PUT /heima/_mapping
{
  "properties":{
    "age":{
      "type": "long"
    }
  }
}


18.2.4.删除索引库


语法

  • 请求方式:DELETE
  • 请求路径:/索引库名
  • 请求参数:无

格式

DELETE /索引库名

示例

kibana 中测试

# 删除
DELETE /heima


18.2.5.总结


  • 创建索引库:PUT/索引库名
  • 查询索引库:GET/索引库名
  • 删除索引库:DELETE/索引库名
  • 添加字段:PUT/索引库名/_mapping

19.文档操作


19.1.新增文档


语法:

POST /索引库名/_doc/文档id
{
    "字段1": "值1",
    "字段2": "值2",
    "字段3": {
        "子属性1": "值3",
        "子属性2": "值4"
    },
    // ...
}

示例:

POST /heima/_doc/1
{
    "info": "黑马程序员Java讲师",
    "email": "zy@itcast",
    "name": {
        "firstName": "云",
        "lastName": "赵"
    }
}

响应


19.2.查询文档


根据 rest 风格,新增是 post,查询应该是 get,不过查询一般都需要条件,这里我们把文档 id 带上。

语法:

GET /{索引库名称}/_doc/{id}

通过 kibana 查看数据

GET /heima/_doc/1

查看结果


19.3.删除文档


删除使用 DELETE 请求,同样,需要根据 id 进行删除:

语法:

DELETE /{索引库名}/_doc/id值

示例:

# 根据 id 删除数据
DELETE /heima/_doc/1

结果


19.4.修改文档


修改有两种方式:

  • 全量修改:直接覆盖原来的文档
  • 增量修改:修改文档中的部分字段

19.4.1.全量修改


全量修改是覆盖原来的文档,其本质是

  • 根据指定的 id 删除文档
  • 新增一个相同 id 的文档

注意:如果根据id删除时,id不存在,第二步的新增也会执行,也就从修改变成了新增操作了。

语法

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

示例

PUT /heima/_doc/1
{
    "info": "黑马程序员高级Java讲师",
    "email": "zy@itcast",
    "name": {
        "firstName": "云",
        "lastName": "赵"
    }
}

19.4.5.增量修改


增量修改是只修改指定 id 匹配的文档中的部分字段。

语法

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

示例

POST /heima/_update/1
{
  "doc": {
    "email": "ZhaoYun@itcast"
  }
}

19.5.总结


文档操作有哪些?

  • 创建文档:POST /{索引库名}/_doc/文档id { json文档 }
  • 查询文档:GET /{索引库名}/_doc/文档id
  • 删除文档:DELETE /{索引库名}/_doc/文档id
  • 修改文档:
    • 全量修改:PUT /{索引库名}/_doc/文档id { json文档 }
    • 增量修改:POST /{索引库名}/_update/文档id { "doc": {字段}}

20.RestAPI


ES 官方提供了各种不同语言的客户端,用来操作 ES

这些客户端的本质就是组装 DSL 语句,通过 http 请求发送给 ES

官方文档地址:https://www.elastic.co/guide/en/elasticsearch/client/index.html

其中的 Java Rest Client 又包括两种:

  • Java Low Level Rest Client
  • Java High Level Rest Client


我们学习的是 Java HighLevel Rest Client 客户端 API


案例介绍

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

根据课前资料提供的酒店数据创建索引库,索引库名为 hotelmapping 属性根据数据库结构定义。

  • 课前资料链接:https://pan.baidu/s/169SFtYEvel44hRJhmFTRTQ
  • 提取码1234

基本步骤

  • 导入课前资料 Demo
  • 分析数据结构,定义 mapping 属性
  • 初始化 JavaRestClient
  • 利用 JavaRestClient 创建索引库
  • 利用 JavaRestClient 删除索引库
  • 利用 JavaRestClient 判断索引库是否存在

20.1.导入 Demo 工程


20.1.1.导入数据


首先导入课前资料提供的数据库数据

数据结构如下

CREATE TABLE `tb_hotel` (
  `id` bigint(20) NOT NULL COMMENT '酒店id',
  `name` varchar(255) NOT NULL COMMENT '酒店名称;例:7天酒店',
  `address` varchar(255) NOT NULL COMMENT '酒店地址;例:航头路',
  `price` int(10) NOT NULL COMMENT '酒店价格;例:329',
  `score` int(2) NOT NULL COMMENT '酒店评分;例:45,就是4.5分',
  `brand` varchar(32) NOT NULL COMMENT '酒店品牌;例:如家',
  `city` varchar(32) NOT NULL COMMENT '所在城市;例:上海',
  `star_name` varchar(16) DEFAULT NULL COMMENT '酒店星级,从低到高分别是:1星到5星,1钻到5钻',
  `business` varchar(255) DEFAULT NULL COMMENT '商圈;例:虹桥',
  `latitude` varchar(32) NOT NULL COMMENT '纬度;例:31.2497',
  `longitude` varchar(32) NOT NULL COMMENT '经度;例:120.3925',
  `pic` varchar(255) DEFAULT NULL COMMENT '酒店图片;例:/img/1.jpg',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;


20.1.2.导入项目


然后导入课前资料提供的项目


项目结构

其中(我使用的 MySQL 的版本是:8.0.17

url: jdbc:mysql://localhost:3306/heima?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&useSSL=false&allowPublicKeyRetrieval=true

相关数据库还请写上自己所创建的数据库的名称。比如我这里的就是 heima

和图不一样?之前写错了,然后就是我懒得再截图了。


20.1.3.mapping 映射分析


创建索引库,最关键的是 mapping 映射,而 mapping 映射要考虑的信息包括:

  • 字段名
  • 字段数据类型
  • 是否参与搜索
  • 是否需要分词
  • 如果分词,分词器是什么?

其中:

  • 字段名、字段数据类型,可以参考数据表结构的名称和类型
  • 是否参与搜索要分析业务来判断,例如图片地址,就无需参与搜索
  • 是否分词呢要看内容,内容如果是一个整体就无需分词,反之则要分词
  • 分词器,我们可以统一使用 ik_max_word

来看下酒店数据的索引库结构

PUT /hotel
{
  "mappings": {
    "properties": {
      "id": {
        "type": "keyword"
      },
      "name":{
        "type": "text",
        "analyzer": "ik_max_word",
        "copy_to": "all"
      },
      "address":{
        "type": "keyword",
        "index": false
      },
      "price":{
        "type": "integer"
      },
      "score":{
        "type": "integer"
      },
      "brand":{
        "type": "keyword",
        "copy_to": "all"
      },
      "city":{
        "type": "keyword",
        "copy_to": "all"
      },
      "starName":{
        "type": "keyword"
      },
      "business":{
        "type": "keyword"
      },
      "location":{
        "type": "geo_point"
      },
      "pic":{
        "type": "keyword",
        "index": false
      },
      "all":{
        "type": "text",
        "analyzer": "ik_max_word"
      }
    }
  }
}

输出结果


几个特殊字段说明

  • location:地理坐标,里面包含精度、纬度。
  • ES 中支持两种地理坐标数据类型:
    • geo_point:由纬度(latitude)和经度(longitude)确定的一个点。
      • 例如:”32.8752345,120.2981576”
    • geo_shape:有多个 geo_point 组成的复杂几何图形。
      • 例如一条直线,”LINESTRING(-77.03653 38.897676, -77.009051 38.889939)”
  • all:一个组合字段,其目的是将多字段的值 利用 copy_to 合并,提供给用户搜索
  • copy_to:字段拷贝可以使用 copy_to 属性将当前字段拷贝到指定字段。


20.1.4.初始化 RestClient


elasticsearch 提供的 API 中,与 elasticsearch 一切交互都封装在一个名为 RestHighLevelClient 的类中,

必须先完成这个对象的初始化,建立与 elasticsearch 的连接。


  1. 引入 esRestHighLevelClient 依赖:
<!-- ElasticSearch -->
<dependency>
    <groupId>org.elasticsearch.client</groupId>
    <artifactId>elasticsearch-rest-high-level-client</artifactId>
</dependency>

  1. 因为 SpringBoot 默认的 ES 版本是 7.6.2,所以我们需要覆盖默认的 ES 版本:
<properties>
    <java.version>1.8</java.version>
    <elasticsearch.version>7.12.1</elasticsearch.version>
</properties>

  1. 初始化 RestHighLevelClient

初始化的代码如下

RestHighLevelClient client = new RestHighLevelClient(RestClient.builder(
        HttpHost.create("http://192.168.150.101:9200")
));

这里为了单元测试方便,我们创建一个测试类 HotelIndexTest,然后将初始化的代码编写在 @BeforeEach 方法中

src/test/java/cn/itcast/hotel/HotelIndexTest.java

package cn.itcast.hotel;

import org.apache.http.HttpHost;
import org.elasticsearch.client.RestHighLevelClient;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;

import java.io.IOException;

@SpringBootTest
public class HotelIndexTest {
    private RestHighLevelClient client;

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

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

    /**
     * 测试客户端初始化是否成功
     */
    @Test
    void testInit() {
        System.out.println(client);
    }
}

20.2.创建索引库


20.2.1.代码解读


创建索引库的 API 如下

代码分为三步:

  1. 创建 Request 对象。因为是创建索引库的操作,因此 RequestCreateIndexRequest
  2. 添加请求参数,其实就是 DSLJSON 参数部分。
    1. 因为 json 字符串很长,这里是定义了静态字符串常量 MAPPING_TEMPLATE,让代码看起来更加优雅。
  3. 发送请求,client.indices() 方法的返回值是 IndicesClient 类型,封装了所有与索引库操作有关的方法。

20.2.2.完整代码


创建新目录 constants,在该目录下创建一个类。

src/main/java/cn/itcast/hotel/constants/HotelIndexConstants.java

package cn.itcast.hotel.constants;

public class HotelIndexConstants {
    public static final String MAPPING_TEMPLATE = "{\n" +
            "  \"mappings\": {\n" +
            "    \"properties\": {\n" +
            "      \"id\": {\n" +
            "        \"type\": \"keyword\"\n" +
            "      },\n" +
            "      \"name\": {\n" +
            "        \"type\": \"text\",\n" +
            "        \"analyzer\": \"ik_max_word\",\n" +
            "        \"copy_to\": \"all\"\n" +
            "      },\n" +
            "      \"address\": {\n" +
            "        \"type\": \"keyword\",\n" +
            "        \"index\": false\n" +
            "      },\n" +
            "      \"price\": {\n" +
            "        \"type\": \"integer\"\n" +
            "      },\n" +
            "      \"score\": {\n" +
            "        \"type\": \"integer\"\n" +
            "      },\n" +
            "      \"brand\": {\n" +
            "        \"type\": \"keyword\",\n" +
            "        \"copy_to\": \"all\"\n" +
            "      },\n" +
            "      \"city\": {\n" +
            "        \"type\": \"keyword\"\n" +
            "      },\n" +
            "      \"starName\": {\n" +
            "        \"type\": \"keyword\"\n" +
            "      },\n" +
            "      \"business\": {\n" +
            "        \"type\": \"keyword\",\n" +
            "        \"copy_to\": \"all\"\n" +
            "      },\n" +
            "      \"pic\": {\n" +
            "        \"type\": \"keyword\",\n" +
            "        \"index\": false\n" +
            "      },\n" +
            "      \"location\": {\n" +
            "        \"type\": \"geo_point\"\n" +
            "      },\n" +
            "      \"all\": {\n" +
            "        \"type\": \"text\",\n" +
            "        \"analyzer\": \"ik_max_word\"\n" +
            "      }\n" +
            "    }\n" +
            "  }\n" +
            "}";
}

src/test/java/cn/itcast/hotel/HotelIndexTest.java

@Test
void createHotelIndex() throws IOException {
    // 1.创建 Request 对象
    CreateIndexRequest request = new CreateIndexRequest("hotel");
    // 2.准备请求的参数:DSL 语句
    request.source(MAPPING_TEMPLATE, XContentType.JSON);
    // 3.发送请求
    client.indices().create(request, RequestOptions.DEFAULT);
}

20.3.删除索引库


删除索引库的 DSL 语句非常简单:

DELETE /hotel

与创建索引库相比:

  • 请求方式从 PUT 变为 DELTE
  • 请求路径不变
  • 无请求参数

所以代码的差异,注意体现在 Request 对象上。

依然是三步走:

  1. 创建 Request 对象。这次是 DeleteIndexRequest 对象
  2. 准备参数。这里是无参
  3. 发送请求。改用 delete 方法

hotel-demo 中的 HotelIndexTest 测试类中,编写单元测试,实现删除索引

src/test/java/cn/itcast/hotel/HotelIndexTest.java

@Test
void testDeleteHotelIndex() throws IOException {
    // 1.创建 Request 对象
    DeleteIndexRequest request = new DeleteIndexRequest("hotel");
    // 2.发送请求
    client.indices().delete(request, RequestOptions.DEFAULT);
}

20.4.判断索引库是否存在


判断索引库是否存在,本质就是查询。对应的 DSL

GET /hotel

因此与删除的 Java 代码流程是类似的。依然是三步走:

  1. 创建 Request 对象。这次是 GetIndexRequest 对象
  2. 准备参数。这里是无参
  3. 发送请求。改用 exists 方法

src/test/java/cn/itcast/hotel/HotelIndexTest.java

@Test
void testExistsHotelIndex() throws IOException {
    // 1.创建 Request 对象
    GetIndexRequest request = new GetIndexRequest("hotel");
    // 2.发送请求
    boolean exists = client.indices().exists(request, RequestOptions.DEFAULT);
    // 3.输出
    System.out.println(exists ? "索引库已经存在!" : "索引库不存在!");
}

20.5.总结


JavaRestClient 操作 elasticsearch 的流程基本类似。核心是 client.indices() 方法来获取索引库的操作对象。

索引库操作的基本步骤:

  • 初始化 RestHighLevelClient
  • 创建 XxxIndexRequestXXXCreateGetDelete
  • 准备 DSLCreate 时需要,其它是无参)
  • 发送请求。调用 RestHighLevelClient#indices().xxx() 方法,xxxcreateexistsdelete

21.RestClient 操作文档


案例介绍

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

基本步骤

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

为了与索引库操作分离,我们再次参加一个测试类,做两件事情:

  • 初始化 RestHighLevelClient
  • 我们的酒店数据在数据库,需要利用 IHotelService 去查询,所以注入这个接口

src/test/java/cn/itcast/hotel/HotelDocumentTest.java

package cn.itcast.hotel;

import cn.itcast.hotel.pojo.Hotel;
import cn.itcast.hotel.service.IHotelService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.io.IOException;
import java.util.List;

@SpringBootTest
public class HotelDocumentTest {
    @Autowired
    private IHotelService hotelService;

    private RestHighLevelClient client;

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

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

21.1.新增文档


我们要将数据库的酒店数据查询出来,写入 elasticsearch 中。

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


21.1.1.索引库实体类


数据库查询后的结果是一个 Hotel 类型的对象。结构如下:

src/main/java/cn/itcast/hotel/pojo/Hotel.java

@Data
@TableName("tb_hotel")
public class Hotel {
    @TableId(type = IdType.INPUT)
    private Long id;
    private String name;
    private String address;
    private Integer price;
    private Integer score;
    private String brand;
    private String city;
    private String starName;
    private String business;
    private String longitude;
    private String latitude;
    private String pic;
}

与我们的索引库结构存在差异:

  • longitudelatitude 需要合并为 location

因此,我们需要定义一个新的类型,与索引库结构吻合

src/main/java/cn/itcast/hotel/pojo/HotelDoc.java

package cn.itcast.hotel.pojo;

import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
public class HotelDoc {
    private Long id;
    private String name;
    private String address;
    private Integer price;
    private Integer score;
    private String brand;
    private String city;
    private String starName;
    private String business;
    private String location;
    private String pic;

    public HotelDoc(Hotel hotel) {
        this.id = hotel.getId();
        this.name = hotel.getName();
        this.address = hotel.getAddress();
        this.price = hotel.getPrice();
        this.score = hotel.getScore();
        this.brand = hotel.getBrand();
        this.city = hotel.getCity();
        this.starName = hotel.getStarName();
        this.business = hotel.getBusiness();
        this.location = hotel.getLatitude() + ", " + hotel.getLongitude();
        this.pic = hotel.getPic();
    }
}

21.1.2.语法说明


新增文档的 DSL 语句如下:

POST /{索引库名}/_doc/1
{
    "name": "Jack",
    "age": 21
}

对应的 java 代码如图

可以看到与创建索引库类似,同样是三步走:

  1. 创建 Request 对象
  2. 准备请求参数,也就是 DSL 中的 JSON 文档
  3. 发送请求

变化的地方在于,这里直接使用 client.xxx()API,不再需要 client.indices() 了。


21.1.3.完整代码


我们导入酒店数据,基本流程一致,但是需要考虑几点变化:

  • 酒店数据来自于数据库,我们需要先查询出来,得到 hotel 对象
  • hotel 对象需要转为 HotelDoc 对象
  • HotelDoc 需要序列化为 json 格式

因此,代码整体步骤如下

  1. 根据 id 查询酒店数据 Hotel
  2. Hotel 封装为 HotelDoc
  3. HotelDoc 序列化为 JSON
  4. 创建 IndexRequest,指定索引库名和 id
  5. 准备请求参数,也就是 JSON 文档
  6. 发送请求

hotel-demoHotelDocumentTest 测试类中,编写单元测试

src/test/java/cn/itcast/hotel/HotelDocumentTest.java

@Test
void testAddDocument() throws IOException {
    // 1.根据 id 查询酒店数据
    Hotel hotel = hotelService.getById(61083L);
    // 2.转换为文档类型
    HotelDoc hotelDoc = new HotelDoc(hotel);
    // 3.将 HotelDoc 转 json
    String json = JSON.toJSONString(hotelDoc);

    // 1.准备 Request 对象
    IndexRequest request = new IndexRequest("hotel").id(hotelDoc.getId().toString());
    // 2.准备 Json 文档
    request.source(json, XContentType.JSON);
    // 3.发送请求
    client.index(request, RequestOptions.DEFAULT);
}

IDEA 控制台


kibana 界面 Dev-Tools


21.2.查询文档


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


21.2.1.语法说明


查询的 DSL 语句如下:

GET /hotel/_doc/{id}

非常简单,因此代码大概分两步:

  • 准备 Request 对象
  • 发送请求

不过查询的目的是得到结果,解析为 HotelDoc,因此难点是结果的解析。

完整代码如下

可以看到,结果是一个 JSON,其中文档放在一个 _source 属性中,因此解析就是拿到 _source,反序列化为 Java 对象即可。

与之前类似,也是三步走:

  1. 准备 Request 对象。这次是查询,所以是 GetRequest
  2. 发送请求,得到结果。因为是查询,这里调用 client.get() 方法
  3. 解析结果,就是对 JSON 做反序列化

21.2.2.完整代码


hotel-demoHotelDocumentTest 测试类中,编写单元测试

src/test/java/cn/itcast/hotel/HotelDocumentTest.java

@Test
void testGetDocumentById() throws IOException {
    // 1.准备 Request
    GetRequest request = new GetRequest("hotel", "61082");
    // 2.发送请求,得到响应
    GetResponse response = client.get(request, RequestOptions.DEFAULT);
    // 3.解析响应结果
    String json = response.getSourceAsString();
	// 4.反序列化为 Java 对象
    HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class);
    System.out.println(hotelDoc);
}

启动程序后的 IDEA 控制台


21.3.删除文档


删除的 DSL 是这样的

DELETE /hotel/_doc/{id}

与查询相比,仅仅是请求方式从DELETE 变成 GET,可以想象 Java 代码应该依然是三步走

  1. 准备 Request 对象,因为是删除,这次是 DeleteRequest 对象。要指定索引库名和 id
  2. 准备参数,无参
  3. 发送请求。因为是删除,所以是 client.delete() 方法

hotel-demoHotelDocumentTest 测试类中,编写单元测试

src/test/java/cn/itcast/hotel/HotelDocumentTest.java

@Test
void testDeleteDocument() throws IOException {
    // 1.准备 Request
    DeleteRequest request = new DeleteRequest("hotel", "61083");
    // 2.发送请求
    client.delete(request, RequestOptions.DEFAULT);
}

21.4.修改文档


21.4.1.语法说明


修改我们讲过两种方式:

  • 全量修改:本质是先根据 id 删除,再新增
  • 增量修改:修改文档中的指定字段值

RestClientAPI 中,全量修改新增API 完全一致,判断依据是 ID

  • 如果新增时,ID 已经存在,则修改
  • 如果新增时,ID 不存在,则新增

这里不再赘述,我们主要关注增量修改

代码示例如图

与之前类似,也是三步走:

  1. 准备 Request 对象。这次是修改,所以是 UpdateRequest
  2. 准备参数。也就是 JSON 文档,里面包含要修改的字段
  3. 更新文档。这里调用 client.update() 方法

21.4.2.完整代码


hotel-demoHotelDocumentTest 测试类中,编写单元测试

src/test/java/cn/itcast/hotel/HotelDocumentTest.java

@Test
void testUpdateDocument() throws IOException {
    // 1.准备 Request
    UpdateRequest request = new UpdateRequest("hotel", "61083");
    // 2.准备请求参数
    request.doc(
        "price", "952",
        "starName", "四钻"
    );
    // 3.发送请求
    client.update(request, RequestOptions.DEFAULT);
}

21.5.批量导入文档


案例:利用 JavaRestClient 批量导入酒店数据到 ES

需求:批量查询酒店数据,然后批量导入索引库中

思路:

  • 利用 mybatis-plus 查询酒店数据
  • 将查询到的酒店数据(Hotel)转换为文档类型数据(HotelDoc
  • 利用 JavaRestClient 中的 Bulk 批处理,实现批量新增文档

21.5.1.语法说明


批量处理 BulkRequest,其本质就是将多个普通的 CRUD 请求组合在一起发送。

其中提供了一个 add 方法,用来添加其他请求

可以看到,能添加的请求包括:

  • IndexRequest,也就是新增
  • UpdateRequest,也就是修改
  • DeleteRequest,也就是删除

因此 Bulk 中添加了多个 IndexRequest,就是批量新增功能了。


示例

其实还是三步走:

  1. 创建 Request 对象。这里是 BulkRequest
  2. 准备参数。批处理的参数,就是其它 Request 对象,这里就是多个 IndexRequest
  3. 发起请求。这里是批处理,调用的方法为 client.bulk()方法

我们在导入酒店数据时,将上述代码改造成 for 循环处理即可。


21.5.2.完整代码


hotel-demoHotelDocumentTest 测试类中,编写单元测试

src/test/java/cn/itcast/hotel/HotelDocumentTest.java

@Test
void testBulkRequest() throws IOException {
    // 批量查询酒店数据
    List<Hotel> hotels = hotelService.list();

    // 1.创建 Request
    BulkRequest request = new BulkRequest();
    // 2.准备参数,添加多个新增的 Request
    for (Hotel hotel : hotels) {
        // 2.1.转换为文档类型 HotelDoc
        HotelDoc hotelDoc = new HotelDoc(hotel);
        // 2.2.创建新增文档的 Request 对象
        request.add(new IndexRequest("hotel")
                    .id(hotelDoc.getId().toString())
                    .source(JSON.toJSONString(hotelDoc), XContentType.JSON));
    }
    // 3.发送请求
    client.bulk(request, RequestOptions.DEFAULT);
}

Idea 控制台


kibana 界面 Dev-Tools


21.6.小结


文档操作的基本步骤

  • 初始化 RestHighLevelClient
  • 创建 XxxRequestXXXIndexGetUpdateDeleteBulk
  • 准备参数(IndexUpdateBulk 时需要)
  • 发送请求。调用 RestHighLevelClient#.xxx() 方法,xxxindexgetupdatedeletebulk
  • 解析结果(Get 时需要)

# SpringCloudDay06


SpringCloud 学习 Day06实用篇-6 分布式搜索引擎:elasticsearch 搜索功能

  • DSL 查询文档
  • 搜索结果处理
  • RestClient 查询文档
  • 黑马旅游案例

SpringCloudDay05 里,我们已经导入了大量数据到 elasticsearch 中,实现了 elasticsearch 的数据存储功能。

elasticsearch 最擅长的还是搜索和数据分析。

故在 SpringCloudDay06 里,我们研究下 elasticsearch 的数据搜索功能。

我们会分别使用 DSLRestClient 实现搜索。


22.DSL 查询文档


elasticsearch 的查询依然是基于 JSON 风格的 DSL 来实现的。


22.1.DSL查询分类


Elasticsearch 提供了基于 JSONDSLDomain Specific Language)来定义查询。

常见的查询类型包括:

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

查询的语法基本一致:

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

我们以查询所有为例,其中:

  • 查询类型为 match_all
  • 没有查询条件
// 查询所有
GET /indexName/_search
{
  "query": {
    "match_all": {
    }
  }
}

其它查询无非就是查询类型查询条件的变化。


示例


小结:match 查询

查询 DSL 的基本语法是什么?

  • GET /索引库名/_search
  • { "query": { "查询类型": { "FIELD": "TEXT"}}}

22.2.全文检索查询


22.2.1.使用场景


全文检索查询的基本流程如下:

  • 对用户搜索的内容做分词,得到词条
  • 根据词条去倒排索引库中匹配,得到文档 id
  • 根据文档 id 找到文档,返回给用户

比较常用的场景包括:

  • 商城的输入框搜索
  • 百度输入框搜索

例如京东:

因为是拿着词条去匹配,因此参与搜索的字段也必须是可分词的 text 类型的字段。


22.2.2.基本语法


常见的全文检索查询包括:

  • match 查询:单字段查询
  • multi_match 查询:多字段查询,任意一个字段符合条件就算符合查询条件

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

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

mulit_match:与 match 查询相似,只不过允许同时查询多个字段。参与查询的字段越多,查询性能越差。

GET /indexName/_search
{
  "query": {
    "multi_match": {
      "query": "TEXT",
      "fields": ["FIELD1", " FIELD12"]
    }
  }
}

22.2.3.示例


match 查询示例


multi_match 查询示例


可以看到,两种查询结果是一样的,为什么?

因为我们将 brandnamebusiness 值都利用 copy_to 复制到了 all 字段中。

因此你根据三个字段搜索,和根据 all 字段搜索效果当然一样了。

但是,搜索字段越多,对查询性能影响越大,因此建议采用 copy_to,然后单字段查询的方式。


22.2.4.总结


matchmulti_match 的区别是什么?

  • match:根据一个字段查询
  • multi_match:根据多个字段查询,参与查询字段越多,查询性能越差

22.3.精准查询


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

常见的有:

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

22.3.1.term 查询


因为精确查询的字段搜是不分词的字段,因此查询的条件也必须是不分词的词条。

查询时,用户输入的内容跟自动值完全匹配时才认为符合条件。如果用户输入的内容过多,反而搜索不到数据。


基本语法

// term 查询
GET /indexName/_search
{
  "query": {
    "term": {
      "FIELD": {
        "value": "VALUE"
      }
    }
  }
}

示例

当我搜索的是精确词条时,能正确查询出结果

但是,当我搜索的内容不是词条,而是多个词语形成的短语时,反而搜索不到


22.3.2.range 查询


范围查询,一般应用在对数值类型做范围过滤的时候。比如做价格范围过滤。


基本语法

// range 查询
GET /indexName/_search
{
  "query": {
    "range": {
      "FIELD": {
        "gte": 10, // 这里的 gte 代表大于等于,gt 则代表大于
        "lte": 20 // lte 代表小于等于,lt 则代表小于
      }
    }
  }
}

示例


22.3.3.总结


精确查询常见的有哪些?

  • term 查询:根据词条精确匹配,一般搜索 keyword 类型、数值类型、布尔类型、日期类型字段
  • range 查询:根据数值范围查询,可以是数值、日期的范围

22.4.地理坐标查询


所谓的地理坐标查询,其实就是根据经纬度查询。

官方文档:https://www.elastic.co/guide/en/elasticsearch/reference/current/geo-queries.html

常见的使用场景包括:

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

例如:附近的酒店 和 附近的出租车


22.4.1.矩形范围查询


矩形范围查询,也就是 geo_bounding_box 查询,查询坐标落在某个矩形范围的所有文档

查询时,需要指定矩形的左上右下两个点的坐标,然后画出一个矩形,落在该矩形内的都是符合条件的点。


语法

// geo_bounding_box 查询
GET /indexName/_search
{
  "query": {
    "geo_bounding_box": {
      "FIELD": {
        "top_left": { // 左上点
          "lat": 31.1,
          "lon": 121.5
        },
        "bottom_right": { // 右下点
          "lat": 30.9,
          "lon": 121.7
        }
      }
    }
  }
}

这种并不符合 “附近的人” 这样的需求,所以我们就不做了。


22.4.2.附近查询


附近查询,也叫做距离查询(geo_distance):查询到指定中心点小于某个距离值的所有文档。

换句话来说,在地图上找一个点作为圆心,以指定距离为半径,画一个圆,落在圆内的坐标都算符合条件


语法说明

// geo_distance 查询
GET /indexName/_search
{
  "query": {
    "geo_distance": {
      "distance": "15km", // 半径
      "FIELD": "31.21,121.5" // 圆心
    }
  }
}

示例

我们先搜索陆家嘴附近 15km 的酒店

发现共有 47 家酒店。

然后把半径缩短到 3 公里

可以发现,搜索到的酒店数量减少到了 5 家。


22.5.复合查询


复合(compound)查询:复合查询可以将其它简单查询组合起来,实现更复杂的搜索逻辑。

常见的有两种:

  • fuction score:算分函数查询,可以控制文档相关性算分,控制文档排名
  • bool query:布尔查询,利用逻辑关系组合多个其它的查询,实现复杂搜索

22.5.1.相关性算分


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

例如,我们搜索 “虹桥如家”,结果如下:

[
  {
    "_score" : 17.850193,
    "_source" : {
      "name" : "虹桥如家酒店真不错",
    }
  },
  {
    "_score" : 12.259849,
    "_source" : {
      "name" : "外滩如家酒店真不错",
    }
  },
  {
    "_score" : 11.91091,
    "_source" : {
      "name" : "迪士尼如家酒店真不错",
    }
  }
]

elasticsearch 中,早期使用的打分算法是 TF-IDF 算法,公式如下:

在后来的 5.1 版本升级中,elasticsearch 将算法改进为 BM25 算法,公式如下:

TF-IDF 算法有一各缺陷,就是词条频率越高,文档得分也会越高,单个词条对文档影响较大。

BM25 则会让单个词条的算分有一个上限,曲线更加平滑:


小结elasticsearch 会根据词条和文档的相关度做打分。

算法由两种:

  • TF-IDF 算法
    • elasticsearch5.0 版本前采用的算法。会随着词频增加而越来越大。
  • BM25 算法
    • elasticsearch5.1 版本后采用的算法。会随着词频增加而增大,但增长曲线会趋于水平。

22.5.2.算分函数查询


根据相关度打分是比较合理的需求,但合理的不一定是产品经理需要的。

以百度为例,你搜索的结果中,并不是相关度越高排名越靠前,而是谁掏的钱多排名就越靠前。

如图:

要想认为控制相关性算分,就需要利用 elasticsearch 中的 function score query 了。


  1. 语法说明

function score 查询中包含四部分内容:

  • 原始查询条件:query 部分,基于这个条件搜索文档,并且基于BM25算法给文档打分,原始算分query score)
  • 过滤条件filter 部分,符合该条件的文档才会重新算分
  • 算分函数:符合 filter 条件的文档要根据这个函数做运算,得到的函数算分function score),有四种函数
    • weight:函数结果是常量
    • field_value_factor:以文档中的某个字段值作为函数结果
    • random_score:以随机数作为函数结果
    • script_score:自定义算分函数算法
  • 运算模式:算分函数的结果、原始查询的相关性算分,两者之间的运算方式,包括:
    • multiply:相乘
    • replace:用 function score 替换 query score
    • 其它,例如:sumavgmaxmin

function score 的运行流程如下:

  1. 根据原始条件查询搜索文档,并且计算相关性算分,称为原始算分query score
  2. 根据过滤条件,过滤文档
  3. 符合过滤条件的文档,基于算分函数运算,得到函数算分function score
  4. 原始算分query score)和函数算分function score)基于运算模式做运算,得到最终结果,作为相关性算分。

因此,其中的关键点是:

  • 过滤条件:决定哪些文档的算分被修改
  • 算分函数:决定函数算分的算法
  • 运算模式:决定最终算分结果

  1. 示例

需求:给 “如家” 这个品牌的酒店排名靠前一些

翻译一下这个需求,转换为之前说的四个要点:

  • 原始条件:不确定,可以任意变化
  • 过滤条件:brand = "如家"
  • 算分函数:可以简单粗暴,直接给固定的算分结果,weight
  • 运算模式:比如求和

因此最终的 DSL 语句如下:

GET /hotel/_search
{
  "query": {
    "function_score": {
      "query": {  .... }, // 原始查询,可以是任意条件
      "functions": [ // 算分函数
        {
          "filter": { // 满足的条件,品牌必须是如家
            "term": {
              "brand": "如家"
            }
          },
          "weight": 2 // 算分权重为 2
        }
      ],
      "boost_mode": "sum" // 加权模式,求和
    }
  }
}

  1. 测试

在未添加算分函数时,如家得分如下

# function score 查询
GET /hotel/_search
{
  "query": {
    "function_score": {
      "query": {
        "match": {
          "all": "外滩"
        }
      }
    }
  }
}

添加了算分函数后,如家得分就提升了

# function score 查询
GET /hotel/_search
{
  "query": {
    "function_score": {
      "query": {
        "match": {
          "all": "外滩"
        }
      },
      "functions": [
        {
          "filter": {
            "term": {
              "brand": "如家"
            }
          },
          "weight": 10
        }
      ],
      "boost_mode": "sum"
    }
  }
}


  1. 小结

function score query 定义的三要素是什么?

  • 过滤条件:哪些文档要加分
  • 算分函数:如何计算 function score
  • 加权方式:function scorequery score 如何运算

22.5.3.布尔查询


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

子查询的组合方式有:

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

比如在搜索酒店时,除了关键字搜索外,我们还可能根据品牌、价格、城市等字段做过滤:

每一个不同的字段,其查询的条件、方式都不一样,必须是多个不同的查询,而要组合这些查询,就必须用 bool 查询了。

需要注意的是,搜索时,参与打分的字段越多,查询的性能也越差。因此这种多条件查询时,建议这样做:

  • 搜索框的关键字搜索,是全文检索查询,使用 must 查询,参与算分
  • 其它过滤条件,采用 filter 查询。不参与算分

  1. 语法示例:
GET /hotel/_search
{
  "query": {
    "bool": {
      "must": [
        {"term": {"city": "上海" }}
      ],
      "should": [
        {"term": {"brand": "皇冠假日" }},
        {"term": {"brand": "华美达" }}
      ],
      "must_not": [
        { "range": { "price": { "lte": 500 } }}
      ],
      "filter": [
        { "range": {"score": { "gte": 45 } }}
      ]
    }
  }
}
  1. 示例

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

分析:

  • 名称搜索,属于全文检索查询,应该参与算分。放到 must
  • 价格不高于 400,用 range 查询,属于过滤条件,不参与算分。放到 must_not
  • 周围 10km 范围内,用 geo_distance 查询,属于过滤条件,不参与算分。放到 filter
# bool 查询
GET /hotel/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "match": {"name": "如家"}
        }
      ],
      "must_not": [
        {
          "range": {"price":{"gt": 400}}
        }
      ],
      "filter": [
        {
          "geo_distance": {
            "distance": "10km",
            "location": {
              "lat": 31.21,
              "lon": 121.5
            }
          }
        }
      ]
    }
  }
}


  1. 小结

bool 查询有几种逻辑关系?

  • must:必须匹配的条件,可以理解为“与”
  • should:选择性匹配的条件,可以理解为“或”
  • must_not:必须不匹配的条件,不参与打分
  • filter:必须匹配的条件,不参与打分

23.搜索结果处理


搜索的结果可以按照用户指定的方式去处理或展示。


23.1.排序


elasticsearch 默认是根据相关度算分(_score)来排序,但是也支持自定义方式对搜索结果排序

可以排序字段类型有:keyword 类型、数值类型、地理坐标类型、日期类型等。


23.1.1.普通字段排序


keyword、数值、日期类型排序的语法基本一致。

语法

GET /indexName/_search
{
  "query": {
    "match_all": {}
  },
  "sort": [
    {
      "FIELD": "desc"  // 排序字段、排序方式 ASC、DESC
    }
  ]
}

排序条件是一个数组,也就是可以写多个排序条件。

按照声明的顺序,当第一个条件相等时,再按照第二个条件排序,以此类推


示例

需求描述:酒店数据按照用户评价(score)降序排序,评价相同的按照价格(price)升序排序

# sort 排序
GET /hotel/_search
{
  "query": {
    "match_all": {}
  },
  "sort": [
    {
      "score": "desc"
    },
    {
      "price": "desc"
    }
  ]
}


23.1.2.地理坐标排序


地理坐标排序略有不同。

语法说明

GET /indexName/_search
{
  "query": {
    "match_all": {}
  },
  "sort": [
    {
      "_geo_distance" : {
          "FIELD" : "纬度,经度", // 文档中 geo_point 类型的字段名、目标坐标点
          "order" : "asc", // 排序方式
          "unit" : "km" // 排序的距离单位
      }
    }
  ]
}

这个查询的含义是

  • 指定一个坐标,作为目标点
  • 计算每一个文档中,指定字段(必须是 geo_point 类型)的坐标 到目标点的距离是多少
  • 根据距离排序

示例:

需求描述:实现对酒店数据按照到你的位置坐标的距离升序排序

提示:获取你的位置的经纬度的方式:https://lbs.amap/demo/jsapi-v2/example/map/click-to-get-lnglat/

假设我的位置是:31.034661121.612282,寻找我周围距离最近的酒店。

# 找到 31.034661121.612282 周围的酒店
GET /hotel/_search
{
  "query": {
    "match_all": {}
  },
  "sort": [
    {
      "_geo_distance": {
        "location": {
          "lat": 31.034661,
          "lon": 121.612282
        },
        "order": "asc",
        "unit": "km"
      }
    }
  ]
}


23.2.分页


elasticsearch 默认情况下只返回 top10 的数据。

而如果要查询更多数据就需要修改分页参数了。

elasticsearch 中通过修改 fromsize 参数来控制要返回的分页结果:

  • from:从第几个文档开始
  • size:总共查询几个文档

类似于 mysql 中的 limit ?, ?


23.2.1.基本的分页


分页的基本语法

GET /hotel/_search
{
  "query": {
    "match_all": {}
  },
  "from": 0, // 分页开始的位置,默认为 0
  "size": 10, // 期望获取的文档总数
  "sort": [
    {"price": "asc"}
  ]
}


23.2.2.深度分页问题


现在,我要查询 990 ~ 1000 的数据。

查询逻辑要这么写

GET /hotel/_search
{
  "query": {
    "match_all": {}
  },
  "from": 990, // 分页开始的位置,默认为0
  "size": 10, // 期望获取的文档总数
  "sort": [
    {"price": "asc"}
  ]
}

这里是查询 990 开始的数据,也就是 第 990 ~ 第 1000 条数据。

不过,elasticsearch 内部分页时,必须先查询 0~ 1000 条,然后截取其中的 990 ~ 1000 的这 10

查询 TOP1000,如果 es单点模式,这并无太大影响。


但是 elasticsearch 将来一定是集群

例如我集群有 5 个节点,我要查询TOP1000的数据,并不是每个节点查询 200 条就可以了。

因为节点 ATOP200,在另一个节点可能排到 10000 名以外了。

因此要想获取整个集群的 TOP1000,必须先查询出每个节点的 TOP1000,汇总结果后,重新排名,重新截取 TOP1000

那如果我要查询 9900~10000 的数据呢?是不是要先查询 TOP10000 呢?那每个节点都要查询 10000 条?汇总到内存中?

当查询分页深度较大时,汇总数据过多,对内存和 CPU 会产生非常大的压力。

因此 elasticsearch 会禁止 from+ size 超过 10000 的请求。


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

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

更多详情还请查看官方文档:https://www.elastic.co/guide/en/elasticsearch/reference/current/paginate-search-results.html


23.2.3.小结


分页查询的常见实现方案以及优缺点

  • from + size

    • 优点:支持随机翻页
    • 缺点:深度分页问题,默认查询上限(from + size)是 10000
    • 场景:百度、京东、谷歌、淘宝这样的随机翻页搜索
  • after search

    • 优点:没有查询上限(单次查询的 size 不超过 10000
    • 缺点:只能向后逐页查询,不支持随机翻页
    • 场景:没有随机翻页需求的搜索,例如手机向下滚动翻页
  • scroll

    • 优点:没有查询上限(单次查询的 size 不超过 10000
    • 缺点:会有额外内存消耗,并且搜索结果是非实时的
    • 场景:海量数据的获取和迁移。从 ES7.1 开始不推荐,建议用 after search 方案。

23.3.高亮


23.3.1.高亮原理


什么是高亮显示呢?

我们在百度,京东搜索时,关键字会变成红色,比较醒目,这叫高亮显示

高亮显示的实现分为两步:

  1. 给文档中的所有关键字都添加一个标签,例如 <em> 标签
  2. 页面给 <em> 标签编写 CSS 样式

23.3.2.实现高亮


高亮的语法

GET /hotel/_search
{
  "query": {
    "match": {
      "FIELD": "TEXT" // 查询条件,高亮一定要使用全文检索查询
    }
  },
  "highlight": {
    "fields": { // 指定要高亮的字段
      "FIELD": {
        "pre_tags": "<em>",  // 用来标记高亮字段的前置标签
        "post_tags": "</em>" // 用来标记高亮字段的后置标签
      }
    }
  }
}

注意

  • 高亮是对关键字高亮,因此搜索条件必须带有关键字,而不能是范围这样的查询。
  • 默认情况下,高亮的字段,必须与搜索指定的字段一致,否则无法高亮
  • 如果要对非搜索字段高亮,则需要添加一个属性:required_field_match=false

示例

# 高亮查询,默认情况下,ES 搜索字段必须与高亮字段一致
GET /hotel/_search
{
  "query": {
    "match": {
      "all": "如家" 
    }
  },
  "highlight": {
    "fields": { 
      "name": {
        "require_field_match": "false"
      }
    }
  }
}


23.4.小结


查询的 DSL 是一个大的 JSON 对象。

包含下列属性:

  • query:查询条件
  • fromsize:分页条件
  • sort:排序条件
  • highlight:高亮条件

示例


24.RestClient 查询文档


文档的查询同样适用于 RestHighLevelClient 对象

基本步骤

  1. 准备 Request 对象
  2. 准备请求参数
  3. 发起请求
  4. 解析响应

24.1.快速入门


我们以 match_all 查询为例


24.1.1.发起查询请求


我们通过 match_all 来演示下基本的 API,先看请求 DSL 的组织。

代码解读:

  • 第一步,创建 SearchRequest 对象,指定索引库名
  • 第二步,利用 request.source() 构建 DSLDSL 中可以包含查询、分页、排序、高亮等
    • query():代表查询条件,利用QueryBuilders.matchAllQuery()构建一个 match_all 查询的DSL
  • 第三步,利用 client.search() 发送请求,得到响应

这里关键的 API 有两个,一个是 request.source(),其中包含了查询、排序、分页、高亮等所有功能

另一个是 QueryBuilders,其中包含 matchtermfunction_scorebool 等各种查询


24.1.2.解析响应


响应的结果的解析

elasticsearch 返回的结果是一个 JSON 字符串,结构包含:

  • hits:命中的结果
    • total:总条数,其中的 value 是具体的总条数值
    • max_score:所有结果中得分最高的文档的相关性算分
    • hits:搜索结果的文档数组,其中的每个文档都是一个 json 对象
      • _source:文档中的原始数据,也是 json 对象

因此,我们解析响应结果,就是逐层解析 JSON 字符串,流程如下:

  • SearchHits:通过 response.getHits() 获取,就是 JSON 中的最外层的 hits,代表命中的结果
    • SearchHits#getTotalHits().value:获取总条数信息
    • SearchHits#getHits():获取SearchHit数组,也就是文档数组
      • SearchHit#getSourceAsString():获取文档结果中的 _source,也就是原始的 json 文档数据

24.1.3.完整代码


src/test/java/cn/itcast/hotel/HotelSearchTest.java

/**
 * match_all 查询
 *
 * @throws IOException
 */
@Test
void testMatchAll() throws IOException {
    // 1.准备 Request
    SearchRequest request = new SearchRequest("hotel");
    // 2.准备 DSL
    request.source()
        .query(QueryBuilders.matchAllQuery());
    // 3.发送请求
    SearchResponse response = client.search(request, RequestOptions.DEFAULT);

    // 4.解析响应
    handleResponse(response);
}
/**
 * 响应结果的解析
 * 
 * @param response
 */
private void handleResponse(SearchResponse response) {
    // 4.解析响应
    SearchHits searchHits = response.getHits();
    // 4.1.获取总条数
    long total = searchHits.getTotalHits().value;
    System.out.println("共搜索到" + total + "条数据");
    // 4.2.文档数组
    SearchHit[] hits = searchHits.getHits();
    // 4.3.遍历
    for (SearchHit hit : hits) {
        // 获取文档 source
        String json = hit.getSourceAsString();
        // 反序列化
        HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class);
        System.out.println("hotelDoc = " + hotelDoc);
    }
}


24.1.4.小结


查询基本步骤

  1. 创建 SearchRequest 对象
  2. 准备 Request.source(),也就是 DSL
    1. QueryBuilders 来构建查询条件
    2. 传入 Request.source()query() 方法
  3. 发送请求,得到结果
  4. 解析结果(参考 JSON 结果,从外到内,逐层解析)

24.2.match 查询


全文检索的 matchmulti_match 查询与 match_allAPI 基本一致。

差别是查询条件,也就是 query 的部分。

因此,Java 代码上的差异主要是 request.source().query() 中的参数了。

同样是利用 QueryBuilders 提供的方法

而结果解析代码则完全一致,可以抽取并共享。


完整代码

src/test/java/cn/itcast/hotel/HotelSearchTest.java

@Test
void testMatch() throws IOException {
    // 1.准备 Request
    SearchRequest request = new SearchRequest("hotel");
    // 2.准备 DSL
    request.source()
        .query(QueryBuilders.matchQuery("all", "如家"));
    // 3.发送请求
    SearchResponse response = client.search(request, RequestOptions.DEFAULT);
    // 4.解析响应
    handleResponse(response);

}


24.3.精确查询


精确查询主要是两者:

  • term:词条精确匹配
  • range:范围查询

与之前的查询相比,差异同样在查询条件,其它都一样。

查询条件构造的 API 如下


完整代码-1

src/test/java/cn/itcast/hotel/HotelSearchTest.java

@Test
void testTerm() throws IOException {
    //1.准备 request
    SearchRequest request = new SearchRequest("hotel");
    //2.准备请求参数
    request.source().
            query(QueryBuilders.termQuery("city", "上海"));
    //3.发送请求,得到响应
    SearchResponse response = client.search(request, RequestOptions.DEFAULT);
    //4.结果解析
    handleResponse(response);
}


完整代码-2

src/test/java/cn/itcast/hotel/HotelSearchTest.java

@Test
void testRange() throws IOException {
    //1.准备 request
    SearchRequest request = new SearchRequest("hotel");
    //2.准备请求参数
    request.source().
            query(QueryBuilders.rangeQuery("price").gte(100).lte(150));
    //3.发送请求,得到响应
    SearchResponse response = client.search(request, RequestOptions.DEFAULT);
    //4.结果解析
    handleResponse(response);
}


24.4.复合查询


布尔查询是用 mustmust_notfilter 等方式组合其它查询,代码示例如下

可以看到,API 与其它查询的差别同样是在查询条件的构建,QueryBuilders,结果解析等其他代码完全不变。


完整代码

src/test/java/cn/itcast/hotel/HotelSearchTest.java

@Test
void testBool() throws IOException {
    // 1.准备 Request
    SearchRequest request = new SearchRequest("hotel");
    
    // 2.准备 DSL
    // 2.1.准备 BooleanQuery
    BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
    // 2.2.添加 term
    boolQuery.must(QueryBuilders.termQuery("city", "上海"));
    // 2.3.添加 range
    boolQuery.filter(QueryBuilders.rangeQuery("price").lte(250));

    request.source().query(boolQuery);
    
    // 3.发送请求
    SearchResponse response = client.search(request, RequestOptions.DEFAULT);
    
    // 4.解析响应
    handleResponse(response);
}


当然,也可以使用链式编程的方式


  • 要构建查询条件,构建在于 QueryBuilders 这个类

24.5.排序、分页


搜索结果的排序和分页是与 query 同级的参数,因此同样是使用 request.source() 来设置。

对应的 API 如下


完整代码

src/test/java/cn/itcast/hotel/HotelSearchTest.java

@Test
void testPageAndSort() throws IOException {
    // 模拟:页码,每页大小
    int page = 1, size = 5;

    // 1.准备Request
    SearchRequest request = new SearchRequest("hotel");
    // 2.准备DSL
    // 2.1.query
    request.source().query(QueryBuilders.matchAllQuery());
    // 2.2.排序 sort
    request.source().sort("price", SortOrder.ASC);
    // 2.3.分页 from、size
    request.source().from((page - 1) * size).size(5);
    // 3.发送请求
    SearchResponse response = client.search(request, RequestOptions.DEFAULT);
    // 4.解析响应
    handleResponse(response);
}


24.6.高亮


高亮的代码与之前代码差异较大,有两点:

  • 查询的 DSL:其中除了查询条件,还需要添加高亮条件,同样是与 query 同级。
  • 结果解析:结果除了要解析 _source 文档数据,还要解析高亮结果

24.6.1.高亮请求构建


高亮请求的构建 API 如下

上述代码省略了查询条件部分。

但是大家不要忘了:高亮查询必须使用全文检索查询,并且要有搜索关键字,将来才可以对关键字高亮


完整代码

src/test/java/cn/itcast/hotel/HotelSearchTest.java

@Test
void testHighlight() throws IOException {
    // 1.准备 Request
    SearchRequest request = new SearchRequest("hotel");
    // 2.准备 DSL
    // 2.1.query
    request.source().query(QueryBuilders.matchQuery("all", "如家"));
    // 2.2.高亮
    request.source().highlighter(new HighlightBuilder().field("name").requireFieldMatch(false));
    // 3.发送请求
    SearchResponse response = client.search(request, RequestOptions.DEFAULT);
    // 4.解析响应
    handleResponse(response);
}

24.6.2.高亮结果解析


高亮的结果与查询的文档结果默认是分离的,并不在一起。

因此解析高亮的代码需要额外处理

代码解读

  • 第一步:从结果中获取 sourcehit.getSourceAsString(),这部分是非高亮结果,json 字符串。还需要反序列为 HotelDoc 对象
  • 第二步:获取高亮结果。hit.getHighlightFields(),返回值是一个 Mapkey 是高亮字段名称,值是 HighlightField 对象,代表高亮值
  • 第三步:从 map 中根据高亮字段名称,获取高亮字段值对象 HighlightField
  • 第四步:从 HighlightField 中获取 Fragments,并且转为字符串。这部分就是真正的高亮字符串了
  • 第五步:用高亮的结果替换 HotelDoc 中的非高亮结果

完整代码

src/test/java/cn/itcast/hotel/HotelSearchTest.java

private void handleResponse(SearchResponse response) {
    // 4.解析响应
    SearchHits searchHits = response.getHits();
    // 4.1.获取总条数
    long total = searchHits.getTotalHits().value;
    System.out.println("共搜索到" + total + "条数据");
    // 4.2.文档数组
    SearchHit[] hits = searchHits.getHits();
    // 4.3.遍历
    for (SearchHit hit : hits) {
        // 获取文档source
        String json = hit.getSourceAsString();
        // 反序列化
        HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class);
        // 获取高亮结果
        Map<String, HighlightField> highlightFields = hit.getHighlightFields();
        if (!CollectionUtils.isEmpty(highlightFields)) {
            // 根据字段名获取高亮结果
            HighlightField highlightField = highlightFields.get("name");
            if (highlightField != null) {
                // 获取高亮值
                String name = highlightField.getFragments()[0].string();
                // 覆盖非高亮结果
                hotelDoc.setName(name);
            }
        }
        System.out.println("hotelDoc = " + hotelDoc);
    }
}


小结

  • 所有搜索 DSL 的构建,需要记住一个 APISearchRequest的source() 方法。
  • 高亮结果解析是参考 JSON 结果,逐层解析

25.黑马旅游案例


通过该案例来实战演练下之前学习的知识。

实现四部分功能:

  • 酒店搜索和分页
  • 酒店结果过滤
  • 我周边的酒店
  • 酒店竞价排名

启动资料中提供的 hotel-demo 项目,其默认端口是 8089,访问 http://localhost:8090,就能看到项目页面了


具体情况见博客:【学习笔记SpringCloud 微服务技术栈 | 实用篇② | 黑马旅游案例


# SpringCloudDay07


SpringCloud 学习 Day07实用篇-7 分布式搜索引擎:深入 elasticsearch

  • 数据聚合
  • 自动补全
  • 数据补全
  • 集群

26.数据聚合


聚合aggregations) 可以让我们极其方便的实现对数据的统计、分析、运算。

例如:

  • 什么品牌的手机最受欢迎?
  • 这些手机的平均价格、最高价格、最低价格?
  • 这些手机每月的销售情况如何?

实现这些统计功能的比数据库的 sql 要方便的多,而且查询速度非常快,可以实现近实时搜索效果。


26.1.聚合的种类


  • Bucket)聚合:用来对文档做分组
    • TermAggregation:按照文档字段值分组,例如按照品牌值分组、按照国家分组
    • Date Histogram:按照日期阶梯分组,例如一周为一组,或者一月为一组

  • 度量Metric)聚合:用以计算一些值,比如:最大值、最小值、平均值等
    • Avg:求平均值
    • Max:求最大值
    • Min:求最小值
    • Stats:同时求 maxminavgsum

  • 管道pipeline)聚合:其它聚合的结果为基础做聚合

注意:参加聚合的字段必须是 keyword、日期、数值、布尔类型


欲知更多情况需访问官网:https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations.html


26.2.DSL 实现聚合


26.2.1.Bucket 聚合语法


现在,我们要统计所有数据中的酒店品牌有几种,其实就是按照品牌对数据分组。

此时可以根据酒店品牌的名称做聚合,也就是 Bucket 聚合。

语法如下

GET /hotel/_search
{
  "size": 0,  // 设置 size 为 0,结果中不包含文档,只包含聚合结果
  "aggs": { // 定义聚合
    "brandAgg": { //给聚合起个名字
      "terms": { // 聚合的类型,按照品牌值聚合,所以选择 term
        "field": "brand", // 参与聚合的字段
        "size": 20 // 希望获取的聚合结果数量
      }
    }
  }
}

结果如图


26.2.2.聚合结果排序


默认情况下,Bucket 聚合会统计 Bucket 内的文档数量,记为 _count,并且按照 _count 降序排序。

我们可以指定 order 属性,自定义聚合的排序方式

GET /hotel/_search
{
  "size": 0, 
  "aggs": {
    "brandAgg": {
      "terms": {
        "field": "brand",
        "order": {
          "_count": "asc" // 按照 _count 升序排列
        },
        "size": 20
      }
    }
  }
}

26.2.3.限定聚合范围


默认情况下,Bucket 聚合是对索引库的所有文档做聚合。

但真实场景下,用户会输入搜索条件,因此聚合必须是对搜索结果聚合。那么聚合必须添加限定条件。

我们可以限定要聚合的文档范围,只要添加 query 条件即可

GET /hotel/_search
{
  "query": {
    "range": {
      "price": {
        "lte": 200 // 只对200元以下的文档聚合
      }
    }
  }, 
  "size": 0, 
  "aggs": {
    "brandAgg": {
      "terms": {
        "field": "brand",
        "size": 20
      }
    }
  }
}

这次,聚合得到的品牌明显变少了


26.2.4.Metric 聚合语法


在之前的操作中,我们对酒店按照品牌分组,形成了一个个桶。

现在我们需要对桶内的酒店做运算,获取每个品牌的用户评分的 minmaxavg 等值。

这就要用到 Metric 聚合了,例如 stat 聚合:就可以获取minmaxavg 等结果。

语法如下:

GET /hotel/_search
{
  "size": 0, 
  "aggs": {
    "brandAgg": { 
      "terms": { 
        "field": "brand", 
        "size": 20
      },
      "aggs": { // 是 brands 聚合的子聚合,也就是分组后对每组分别计算
        "score_stats": { // 聚合名称
          "stats": { // 聚合类型,这里 stats 可以计算 min、max、avg 等
            "field": "score" // 聚合字段,这里是 score
          }
        }
      }
    }
  }
}

这次的 score_stats 聚合是在 brandAgg 的聚合内部嵌套的子聚合。

因为我们需要在每个桶分别计算。

另外,我们还可以给聚合结果做个排序,例如按照每个桶的酒店平均分做排序


26.2.5.小结


aggs 代表聚合,与 query 同级,此时 query 的作用是?

  • 限定聚合的的文档范围

聚合必须的三要素:

  • 聚合名称
  • 聚合类型
  • 聚合字段

聚合可配置属性有:

  • size:指定聚合结果数量
  • order:指定聚合结果排序方式
  • field:指定聚合字段

26.3.RestAPI


聚合条件与 query 条件同级别,因此需要使用 request.source() 来指定聚合条件。

聚合条件的语法:


聚合的结果也与查询结果不同,API 也比较特殊。不过同样是 JSON 逐层解析


代码部分

src/test/java/cn/itcast/hotel/HotelSearchTest.java

聚合条件

@Test
void testAggregation() throws IOException {
    //1.准备 Request
    SearchRequest request = new SearchRequest("hotel");

    //2.准备 DSL
    //2.1.设置 size
    request.source().size(0);
    //2.2.聚合
    request.source().aggregation(AggregationBuilders
            .terms("brandAgg")
            .field("brand")
            .size(10)
    );

    //3.发出请求
    SearchResponse response = client.search(request, RequestOptions.DEFAULT);

    //4.解析结果
    //System.out.println(response);
    handleAggregationResponse(response);
}

聚合结果

private void handleAggregationResponse(SearchResponse response) {
    //4.解析结果
    Aggregations aggregations = response.getAggregations();
    //4.1.根据聚合名称获取聚合结果
    Terms brandTerms = aggregations.get("brandAgg");
    //4.2.获取 buckets
    List<? extends Terms.Bucket> buckets = brandTerms.getBuckets();
    //4.3.遍历
    for (Terms.Bucket bucket : buckets) {
        //4.4.获取 key
        String key = bucket.getKeyAsString();
        System.out.println(key);
    }
}


具体项目的业务实现见博客:【学习笔记SpringCloud 微服务技术栈 | 实用篇② | 黑马旅游案例


27.自动补全


当用户在搜索框输入字符时,我们应该提示出与该字符有关的搜索项,如图

这种根据用户输入的字母,提示完整词条的功能,就是自动补全了。

因为需要根据拼音字母来推断,因此要用到拼音分词功能。


27.1.拼音分词器


要实现根据字母做补全,就必须对文档按照拼音分词。

GitHub 上恰好有 elasticsearch 的拼音分词插件。

地址:https://github/medcl/elasticsearch-analysis-pinyin

课前资料中也提供了拼音分词器的安装包

安装方式与 IK 分词器一样,分三步

  1. 解压
  2. 上传到虚拟机中,elasticsearchplugin 目录
  3. 重启 elasticsearch
  4. 测试

可以通过下面命令查看 elasticsearchplugins 目录位置。

docker volume inspect es-plugins

显示结果:

[
    {
        "CreatedAt": "2022-05-06T10:06:34+08:00",
        "Driver": "local",
        "Labels": null,
        "Mountpoint": "/var/lib/docker/volumes/es-plugins/_data",
        "Name": "es-plugins",
        "Options": null,
        "Scope": "local"
    }
]

说明 plugins 目录被挂载到了:/var/lib/docker/volumes/es-plugins/_data 这个目录中。


上传到指定目录后,后重启 es 即可

docker restart es

我的 IK 分词器和拼音分词器都是安装在了 /var/lib/docker/volumes/es-plugins/_data 目录下


测试用法

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

结果


27.2.自定义分词器


默认的拼音分词器会将每个汉字单独分为拼音,而我们希望的是每个词条形成一组拼音,需要对拼音分词器做个性化定制,形成自定义分词器。

elasticsearch 中分词器(analyzer)的组成包含三部分:

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

文档分词时会依次由这三部分来处理文档


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


拼音分词器适合在创建倒排索引的时候使用,但不能在搜索的时候使用。


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

字段在搜索时应该使用 ik_smart 分词器


声明自定义分词器的语法如下

PUT /test
{
  "settings": {
    "analysis": {
      "analyzer": { // 自定义分词器
        "my_analyzer": {  // 分词器名称
          "tokenizer": "ik_max_word",
          "filter": "py"
        }
      },
      "filter": { // 自定义 tokenizer filter
        "py": { // 过滤器名称
          "type": "pinyin", // 过滤器类型,这里是 pinyin
		  "keep_full_pinyin": false,
          "keep_joined_full_pinyin": true,
          "keep_original": true,
          "limit_first_letter_length": 16,
          "remove_duplicated_term": true,
          "none_chinese_pinyin_tokenize": false
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "name": {
        "type": "text",
        "analyzer": "my_analyzer",
        "search_analyzer": "ik_smart"
      }
    }
  }
}

测试


总结

如何使用拼音分词器?

  1. 下载 pinyin 分词器
  2. 解压并放到 elasticsearchplugin 目录
  3. 重启即可

如何自定义分词器?

  • 创建索引库时,在 settings 中配置,可以包含三部分
    1. character filter
    2. tokenizer
    3. filter

拼音分词器注意事项?

  • 为了避免搜索到同音字,搜索时不要使用拼音分词器

27.3.自动补全查询


elasticsearch 提供了 Completion Suggester 查询来实现自动补全功能。

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

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

比如,一个这样的索引库

// 创建索引库
PUT test2
{
  "mappings": {
    "properties": {
      "title":{
        "type": "completion"
      }
    }
  }
}

然后插入下面的数据

// 示例数据
POST test2/_doc
{
  "title": ["Sony", "WH-1000XM3"]
}
POST test2/_doc
{
  "title": ["SK-II", "PITERA"]
}
POST test2/_doc
{
  "title": ["Nintendo", "switch"]
}

查询的 DSL 语句如下

// 自动补全查询
GET /test2/_search
{
  "suggest": {
    "title_suggest": {
      "text": "s", // 关键字
      "completion": {
        "field": "title", // 补全查询的字段
        "skip_duplicates": true, // 跳过重复的
        "size": 10 // 获取前10条结果
      }
    }
  }
}

27.4.自动补全查询的 JavaAPI


之前我们学习了自动补全查询的 DSL,而没有学习对应的 JavaAPI,这里给出一个示例

而自动补全的结果也比较特殊,解析的代码如下


具体项目的业务实现见博客:【学习笔记SpringCloud 微服务技术栈 | 实用篇② | 黑马旅游案例


28.数据同步


elasticsearch 中的酒店数据来自于 mysql 数据库,因此 mysql 数据发生改变时,elasticsearch 也必须跟着改变。

这个就是 elasticsearchmysql 之间的数据同步

常见的数据同步方案有三种

  • 同步调用
  • 异步通知
  • 监听 binlog

28.1.同步调用


基本步骤

  • hotel-demo对外提供接口,用来修改 elasticsearch 中的数据
  • 酒店管理服务在完成数据库操作后,直接调用 hotel-demo 提供的接口

28.2.异步通知


流程如下

  • hotel-adminmysql 数据库数据完成增、删、改后,发送 MQ 消息
  • hotel-demo 监听 MQ,接收到消息后完成 elasticsearch 数据修改

28.3.监听 binlog


流程如下:

  • mysql 开启 binlog 功能
  • mysql 完成增、删、改操作都会记录在 binlog
  • hotel-demo 基于 canal 监听 binlog 变化,实时更新 elasticsearch 中的内容

28.4.选择


方式一:同步调用

  • 优点:实现简单,粗暴
  • 缺点:业务耦合度高

方式二:异步通知

  • 优点:低耦合,实现难度一般
  • 缺点:依赖 mq 的可靠性

方式三:监听 binlog

  • 优点:完全解除服务间耦合
  • 缺点:开启 binlog 增加数据库负担、实现复杂度高

具体项目的业务实现见博客:【学习笔记SpringCloud 微服务技术栈 | 实用篇② | 黑马旅游案例


29.集群


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

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


ES集群相关概念:

  • 集群(cluster):一组拥有共同的 cluster name 的 节点。
  • 节点(node) :集群中的一个 Elasticearch 实例
  • 分片(shard):索引可以被拆分为不同的部分进行存储,称为分片。
    • 在集群环境下,一个索引的不同分片可以拆分到不同的节点中

解决问题:数据量太大,单点存储量有限的问题。

此处,我们把数据分成 3 片:shard0shard1shard2

  • 主分片(Primary shard):相对于副本分片的定义。
  • 副本分片(Replica shard)每个主分片可以有一个或者多个副本,数据和主分片一样。

数据备份可以保证高可用,但是每个分片备份一份,所需要的节点数量就会翻一倍,成本实在是太高了!

为了在高可用和成本间寻求平衡,我们可以这样做:

  • 首先对数据分片,存储到不同节点
  • 然后对每个分片进行备份,放到对方节点,完成互相备份

这样可以大大减少所需要的服务节点数量,如图,我们以 3 分片,每个分片备份一份为例

现在,每个分片都有 1 个备份,存储在 3 个节点

  • node0:保存了分片 01
  • node1:保存了分片 02
  • node2:保存了分片 12

29.1.部署 es 集群


29.1.1.搭建 ES 集群


首先编写一个 docker-compose 文件,内容如下

version: '2.2'
services:
  es01:
    image: elasticsearch:7.12.1
    container_name: es01
    environment:
      - node.name=es01
      - cluster.name=es-docker-cluster
      - discovery.seed_hosts=es02,es03
      - cluster.initial_master_nodes=es01,es02,es03
      - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
    volumes:
      - data01:/usr/share/elasticsearch/data
    ports:
      - 9200:9200
    networks:
      - elastic
  es02:
    image: elasticsearch:7.12.1
    container_name: es02
    environment:
      - node.name=es02
      - cluster.name=es-docker-cluster
      - discovery.seed_hosts=es01,es03
      - cluster.initial_master_nodes=es01,es02,es03
      - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
    volumes:
      - data02:/usr/share/elasticsearch/data
    ports:
      - 9201:9200
    networks:
      - elastic
  es03:
    image: elasticsearch:7.12.1
    container_name: es03
    environment:
      - node.name=es03
      - cluster.name=es-docker-cluster
      - discovery.seed_hosts=es01,es02
      - cluster.initial_master_nodes=es01,es02,es03
      - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
    volumes:
      - data03:/usr/share/elasticsearch/data
    networks:
      - elastic
    ports:
      - 9202:9200
volumes:
  data01:
    driver: local
  data02:
    driver: local
  data03:
    driver: local

networks:
  elastic:
    driver: bridge

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

vi /etc/sysctl.conf

/etc/sysctl.conf 文件中添加下面的内容

vm.max_map_count=262144


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

sysctl -p


出现 vm.max_map_count=262144 时,说明配置生效。


通过 docker-compose 启动集群

docker-compose up -d


可以使用 docker ps 来查看创建的情况

也可以使用 dokcer logs -f es01 来查看节点的运行状态


29.1.2.集群状态监控


kibana 可以监控 es 集群,不过新版本需要依赖 esx-pack 功能,配置比较复杂。

这里推荐使用 cerebro 来监控 es 集群状态。

cerebro 的官方网址:https://github/lmenezes/cerebro

课前资料已经提供了安装包

解压即可使用,非常方便。

解压好的目录如下

进入对应的 bin 目录,双击其中的 cerebro.bat 文件即可启动服务。

访问 http://localhost:9000 即可进入管理界面,输入你的 elasticsearch 的任意节点的地址和端口,点击 connect 即可

绿色的条,代表集群处于绿色(健康状态)。


29.1.3.创建索引库


两种方式:利用 kibanaDevTools 创建索引库;利用 cerebro 创建索引库


  1. 利用 kibanaDevTools 创建索引库

DevTools 中输入指令

PUT /itcast
{
  "settings": {
    "number_of_shards": 3, // 分片数量
    "number_of_replicas": 1 // 副本数量
  },
  "mappings": {
    "properties": {
      // mapping映射定义 ...
    }
  }
}

  1. 利用 cerebro 创建索引库

填写索引库信息

点击右下角的 create 按钮


29.1.4.查看分片效果


若分片失败,那有可能是 CentOS7 空间不足的问题

若要扩容,可以参考我之前的博客:CentOS7 给 centos-root 扩容【学习记录】


29.2.集群脑裂问题


29.2.1.集群职责划分


elasticsearch 中集群节点有不同的职责划分

节点参数配置参数默认值节点职责
master eligiblenode.mastertrue备选主节点
主节点可以管理和记录集群状态,
决定分片在哪个节点,
处理创建和删除索引库的请求。
datanode.datatrue数据节点:存储数据、搜索、聚合、CRUD
ingestnode.ingestture数据存储之前的预处理``
coordinating上面的三个参数都是 false
则为 coordinating 节点
路由请求到其他节点
合并其它节点处理的结果,返回给用户

默认情况下,集群中的任何一个节点都同时具备上述四种角色。


但是真实的集群一定要将集群职责分离

  • master 节点:对 CPU 要求高,但是内存要求第
  • data 节点:对 CPU 和内存要求都高
  • coordinating 节点:对网络带宽、CPU 要求高

职责分离可以让我们根据不同节点的需求分配不同的硬件去部署。而且避免业务之间的互相干扰。

一个典型的 es 集群职责划分如图


29.2.2.脑裂问题


脑裂是因为集群中的节点失联导致的。

例如一个集群中,主节点与其它节点失联

此时,node2node3 认为 node1 宕机,就会重新选主

node3 当选后,集群继续对外提供服务,node2node3 自成集群,node1 自成集群,两个集群数据不同步,出现数据差异。

当网络恢复后,因为集群中有两个 master 节点,集群状态的不一致,出现脑裂的情况

解决脑裂的方案是,要求选票超过 ( eligible 节点数量 + 1 )/ 2 才能当选为主,因此 eligible 节点数量最好是奇数。

对应配置项是 discovery.zen.minimum_master_nodes,在 es7.0 以后,已经成为默认配置,因此一般不会发生脑裂问题


例如3 个节点形成的集群,选票必须超过 (3 + 1) / 2 ,也就是 2 票。

node3 得到 node2node3 的选票,当选为主。

node1 只有自己 1 票,没有当选。

集群中依然只有 1 个主节点,没有出现脑裂。


29.2.3.小结


master eligible 节点的作用是什么?

  • 参与集群选主
  • 主节点可以管理集群状态、管理分片信息、处理创建和删除索引库的请求

data节点的作用是什么?

  • 数据的 CRUD

coordinator 节点的作用是什么?

  • 路由请求到其它节点
  • 合并查询到的结果,返回给用户

29.3.集群分布式存储


当新增文档时,应该保存到不同分片,保证数据均衡,那么 coordinating node 如何确定数据该存储到哪个分片呢?


29.3.1.分片存储测试


以下使用的工具是 Postman


  1. 随意插入三条数据

使用 POST 方式插入数据 http://虚拟机IP地址:920X/itcast/_doc/5

{
    "title": "尝试插入一条 id = X"
}


  1. 使用不同端口查询数据

使用 GET 方式来查看数据 http://虚拟机IP地址:920X/itcast/_search

{
    "query": {
        "match_all": {}
    }
}


  1. 查看数据存储的具体分片

使用 GET 方式来查看数据 http://虚拟机IP地址:920X/itcast/_search

{
    "explain": true,
    "query": {
        "match_all": {}
    }
}


29.3.2.分片存储原理


elasticsearch 会通过 hash 算法来计算文档应该存储到哪个分片

说明

  • _routing 默认是文档的 id
  • 算法与分片数量有关,因此索引库一旦创建,分片数量不能修改!

新增文档的流程如下

解读

  1. 新增一个 id=1 的文档
  2. idhash 运算,假如得到的是 2,则应该存储到 shard-2
  3. shard-2 的主分片在 node3 节点,将数据路由到 node3
  4. 保存文档
  5. 同步给 shard-2 的副本 replica-2,在 node2 节点
  6. 返回结果给 coordinating-node 节点

29.4.集群分布式查询


elasticsearch 的查询分成两个阶段

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


29.5.集群故障转移


集群的 master 节点会监控集群中的节点状态。

如果发现有节点宕机,会立即将宕机节点的分片数据迁移到其它节点,确保数据安全,这个叫做故障转移


  1. 例如一个集群结构如图

现在,node1 是主节点,其它两个节点是从节点。


  1. 突然 node1 发生了故障

宕机后的第一件事,需要重新选主,例如选中了 node2

node2 成为主节点后,会检测集群监控状态。

发现:shard-1shard-0 没有副本节点。

因此需要将 node1 上的数据迁移到 node2node3


总结:故障转移

  • master 宕机后,EligibleMaster 选举为新的主节点。
  • master 节点监控分片、节点状态,将故障节点上的分片转移到正常节点,确保数据安全。

30.实用篇总结


本博客主要讲述了

  • 微服务的远程调用的简单案例
  • Eureka 注册中心、Nacos 注册中心、Fegin 的远程调用、统一 GateWay
  • DockerMQ 消息队列、ElasticSearch 分布式搜索的基础知识。

至此,SpringCloud 微服务技术栈的实用篇 的内容结束

部分补充内容则见博客:【学习笔记SpringCloud 微服务技术栈 | 实用篇② | 黑马旅游案例


本文标签: 基础知识 学习笔记 技术 SpringCloud