微服务的相关技术栈(docker+rabbitmq+elasticsearch+sentinel...)

        前言: 前段时间刚刚学完黑马的微服务课程,为了巩固记忆和方便以后查看, 所以写下此篇博客, 希望能够给予你帮助.

一、Docker的安装与使用

        介绍: docker的官网介绍: Docker使开发变得高效且可预测,Docker消除了重复、平凡的配置任务,并在整个开发生命周期中用于快速、简单和可移植的应用程序开发。简单来说,docker就是帮助我们在程序部署时简化配置的,我们的项目可能会运用到很多其他的软件,比如mysql,oracle,redis,nginx这些技术软件。而我们一般的应用都喜欢在windows上进行开发,然后部署在linux中,这就导致我们要想我们的应用能同样在linux上正常运行,就又得在linux上面下载相关的软件, 小项目还好, 但一旦遇到大的项目或者微服务,弊端就出来了, 配置起来就会相当的麻烦, 而docker就是帮助我们解决这个问题的, 正如它的官网所说"Docker消除了重复、平凡的配置任务". 有了docker, 我们想要下载配置相关的软件就会变得非常容易, 只需下载相对应的镜像文件,然后run起来就可以了,不用像以前一样逐个的去相关软件的官网下载软件包。接下来的rabbitmq,elasticsearch就会用docker容器的方式进行运行.

        安装:  我们去docker的官网下载即可,我们下载他的桌面版本, 桌面版本内置了docker的引擎和桌面UI, 并且自带了docker-compose, 推荐下载这个桌面版本(简化了我们的命令操作, 并且容器的可视化对我们很友好). 注意:要选择你系统的docker版本, 我这里下载的是docker的windows版本, 这个问题不大, 你是linux系统就下载linux版本.Docker: Accelerated, Containerized Application Developmenticon-default.png?t=N7T8https://www.docker.com/        由于我是下载的windows版本, 但由于docker的容器隔离其实是在linux内核之上发展的,所以我们还需要wsl(适用于windows的linux子系统)才能在windows上使用docker, 所以当我们准备启动DockerDesktop时可能会报错,需要我们更新或者安装wsl, 他会提供我们一个网址,我们按照网址提供的步骤去操作即可(可以去网上查, 网上有很多解决方案,这里不再详细赘述).

        当我们打开DockerDesktop时能正常启动时则说明我们的安装已经完成了(下方图片所示).

         使用: 在docker中有三个概念(引用官网的介绍):

                容器(container):  简单地说,容器是机器上的一个沙盒进程,它与主机上的所有其他进程隔离。这种隔离利用了Linux中已经存在很长时间的内核名称空间和cgroup功能。Docker致力于使这些功能变得易于使用。总之,容器:

                        1.是映像的可运行实例。您可以使用DockerAPI或CLI创建、启动、停止、移动或删除容器。

                         2.可以在本地机器、虚拟机上运行,也可以部署到云中。

                         3.是可移植的(可以在任何操作系统上运行)。

                         4.与其他容器隔离,并运行自己的软件、二进制文件和配置。

                镜像(image): 当运行容器时,它使用一个独立的文件系统。这个自定义文件系统是由一个镜像提供的。由于镜像包含容器的文件系统,因此它必须包含运行应用程序所需的一切-所有依赖项、配置、脚本、二进制文件等。镜像还包含容器的其他配置,如环境变量、要运行的默认命令和其他元数据。镜像的名称由两个部分组成: 软件名称: tag(版本号), 例如nginx: 1.0, 没有指定tag则默认为最新版本

                仓库(repository): 类似于代码仓库, 是docker存放镜像文件的地方

               在我们使用时,我们首先会从官方仓库中(dockerhub)找到想要的镜像文件(例如nginx,mysql...), 执行pull命令将镜像文件从官方仓库中拉取到本地, 然后使用该镜像构建出一个容器运行即可。

        docker的基本操作:

                1.image的相关命令:

                2. 容器的相关命令:

                3. 数据卷的操作(待会介绍): 

         数据卷(volume)的官方介绍: 数据卷提供了将容器的特定文件系统路径连接回主机的能力。如果在容器中装载一个目录,那么在主机上也可以看到该目录中的更改。如果在容器重新启动时装载相同的目录,则会看到相同的文件。简单来说: 数据卷可以将容器内的文件与主机内的文件进行一个映射,无论哪一方的文件改动都会影响另一方的文件, 这为我们提供了方便, 因为容器内只给我们提供了软件正常运行最基础的环境,当我们使用相关命令对容器内的文件进行改动时可能会发现命令不存在,例如使用vi命令, 此时我们使用数据卷将主机的文件与容器内的文件进行关联,只需改动主机的文件, 容器内的文件也会随着相应的更改. 更有用的是, 只需我们将同一个数据卷挂载到多个容器中, 即可完成多个容器实现一样的配置加载.

        两种数据卷的使用方式:

                第一种: Named Volumes(命令数据卷), 使用时先要创建数据卷: docker volume create 数据卷名称,  该数据卷在本机的地址由docker指定, 可使用docker inspect 数据卷名称查看该数据卷在本机的地址, 使用数据卷时需要在容器的创建时指定: docker run -v 数据卷名称: 容器内映射文件路径(例如:/usr/local/data) 镜像名, 使用-v参数进行数据卷的挂载.

                第二种: bind mount(官方叫法, 绑定装载), 使用时不需要先创建数据卷, 它允许您将主机文件系统中的目录共享到容器中。也是需要在容器创建时指定: docker run -v 本机文件系统目录: 容器内映射文件路径 镜像名.

        上面简单的介绍了一下docker的基础使用, 而我们下载了docker的桌面版, 其实上面的命令都可以通过简单的UI操作完成. 

        Dockerfile:  如果我们仔细观察, 上面并没有提到docker中镜像的创建, 我们都只是从仓库中pull来了镜像, 如果我们想要手动创建镜像的话, 则需要我们手动的编写Dockerfile文件完成镜像的创建, 可以去参考官方文档(本人也不太会), 这里不再赘述.

        docker-compose: Docker Compose是一个旨在帮助定义和共享多容器应用程序的工具。使用Compose,我们可以创建一个YAML文件来定义服务,只需一个命令,就可以运行所有服务或将其全部删除。可以去参考官方文档(本人也不太会), 这里不再赘述.

        推荐去docker的官网看一遍简单的使用教程, 能帮助我们搞懂上面所说的镜像,容器,数据卷, dockfile,docker-compose的作用, 附上教程地址:Overview | Docker Documentationicon-default.png?t=N7T8https://docs.docker.com/get-started/

二、RabbitMQ和SpringAMQP

       安装: 我们使用docker来下载rabbitmq的镜像, 并为它创建一个容器启动rabbitmq,

我们去到dockerhub的官网找到rabbitmq的镜像, 然后安装它的指示去pull镜像到本地然后运行命令启动它即可。

 可以看到rabbitmq的服务成功的启动起来了,  5672端口是rabbitmq的服务端口, 15672为rabbitmq的控制台端口, 在本地访问http://localhost:15672即可看到rabbitmq的控制台, 输入刚才指定的账号和密码即可.

         SpringAMQP:

                由于java原生操作rabbitmq比较复杂, 我们使用spring的springAMQP来操作rabbitmq,它使得我们操作rabbitmq非常的简单,且配置起来很简单,只需在springboot项目中加入springAMQP的依赖即可.

在application.yml文件中配置rabbitmq信息:

        下面我们将来演示用java代码的方式来操作rabbitmq来完成官网介绍的其中五种rabbitmq的使用方式.(我的项目里有两个模块,一个模块为publisher(发布者)模块用来发送信息, 另一个模块为consumer(消费者)模块用来消费信息, 两者都为springboot项目且都引入了SpringAMQP的依赖)

        第一种: 简单队列模型, 一个生产者对应一个消费者

                实现我们先使用@RabbitListener这个注解在consumer模块中来声明监听一个名为first.queue的队列, 开始接收从first.queue这个队列中传过来的信息.注意: 一定先要在rabbitmq中声明这个队列(first.queue), 此处并不会自己创建队列(后面声明交换机与队列的绑定关系时则会自己创建)。

 /**
     *  简单队列模型, 一个生产者对应一个消费者A
     * @param message
     */
    @RabbitListener(queues = "first.queue")
    public void basicQueueConsumer1(String message) {
        System.out.println("第一个消费者收到的信息" + message);
    }

  然后我们启动consumer这个模块.

 我们开始启动publisher服务, 在publisher中我们有一个接口, 只要访问了该接口就会将数据发送到first.queue这个队列中.

@Resource
private RabbitTemplate rabbitTemplate;


@GetMapping("/{id}")
    public void exchangeSendMessage (@PathVariable Long id) {
        // 直接向first.queue队列发送一条信息
        rabbitTemplate.convertAndSend("first.queue", "这是第" + id + "条信息");
}

  在这个接口中我们直接使用了SpringAMQP为我们提供好的RabbitTemplate对象对rabbitmq发送信息,  大大的简化了我们的操作。接下来访问该接口, 就会向first.queue中发送一条信息, consumer模块中的方法就会接收到这条信息并且打印出来.

可以看到, consumer模块中的方法已经接收到了该信息, 演示成功.

        第二种: 工作队列模型, 一个生产者对应多个消费者

        我们在consumer模块中再添加一个消费者监听first.queue队列, 

  /**
     *  工作队列模型, 一个生产者对应多个消费者
     * @param message
     */
    @RabbitListener(queues = "first.queue")
    public void basicQueueConsumer2(String message) {
        System.out.println("第二个消费者收到的信息" + message);
    }

 我们对publisher模块中的接口发起4次请求, 查看consumer模块的控制台

可以看到,两个消费者都消费了2条信息, 这就是一个生产者对应多个消费者的情况

         第三种:发布订阅模型: FanoutExchange(广播交换机), 该交换机会向它绑定的每个queue发送一个信息

        有时候我们想要多个消费者消费同一条信息,此时我们就需要引入交换机了,广播交换机会向它绑定的所有queue都发送信息, 只要消费者分别监听对应的queue, 就实现了多个消费者消费同一条信息。

 /**
     * 发布订阅模型: FanoutExchange(广播交换机), 该交换机会向它绑定的每个queue发送一个信息
     * @param message
     */
    @RabbitListener(bindings = @QueueBinding(
            //  绑定一个队列(会自己创建)
            value = @Queue("fanout.queue"),
            // 声明一个交换机, 并指定类型(会自己创建)
            exchange = @Exchange(type = ExchangeTypes.FANOUT, name = "fanout.exchange")
    ))
    public void fanoutExchange1(String message) {
        System.out.println("fanout.queue收到的信息" + message);
    }

    @RabbitListener(bindings = @QueueBinding(
            //  绑定一个队列
            value = @Queue("fanout.queue2"),
            // 声明一个交换机, 并指定类型
            exchange = @Exchange(type = ExchangeTypes.FANOUT, name = "fanout.exchange")
    ))
    public void fanoutExchange2(String message) {
        System.out.println("fanout.queue2收到的信息" + message);
    }

 上面的两个方法的@RabbitListener注解将fanout.queue队列和fanout.queue2队列与fanout.exchange交换机进行了绑定, 此时我们将consumer模块重新启动.

 

 

 可以看到fanout.exchange交换机与两个队列都已经创建好了,并且fanout.exchange交换机绑定了fanout.queue和fanout.queue2这两个队列.

接下来我们修改publisher中的接口, 使其向fanout.exchange发送信息.

 @GetMapping("/{id}")
    public void exchangeSendMessage (@PathVariable Long id) {
         //向fanout.exchange交换机发送一条信息, 由于该交换机绑定了fanout.queue2, fanout.queue两个队列
        // 该两个队列都会收到交换机发来的信息
        rabbitTemplate.convertAndSend("fanout.exchange", "", "这是第" + id + "条信息");
}

我们访问publisher的接口,查看consumer模块的控制台:

可以看到,两个队列都收到了 同样的信息。

        第四种:发布订阅模型: DirectExchange(路由交换机), 每一个Queue都会与Exchange设置一个BindingKey, 发布者向交换机发送信息时会带上一个 RoutingKey, Exchange会将信息路由到BindingKey与信息RoutingKey一致的队列

        在consumer模块中声明路由交换机和相应的队列以及key:

/**
     * 发布订阅模型: DirectExchange(路由交换机), 每一个Queue都会与Exchange设置一个BindingKey, 发布者向交换机发送信息时会带上一个
     * RoutingKey, Exchange会将信息路由到BindingKey与信息RoutingKey一致的队列
     * @param message
     */
    @RabbitListener(bindings = @QueueBinding(
            //  绑定一个队列
            value = @Queue("direct.queue"),
            // 声明一个交换机, 并指定类型(默认即为DIRECT类型)
            exchange = @Exchange(name = "direct.exchange"),
            // 可以指定多个key
            key = {"red", "blue"}
    ))
    public void directExchange1(String message) {
        System.out.println("direct.queue收到的信息" + message);
    }

    @RabbitListener(bindings = @QueueBinding(
            //  绑定一个队列
            value = @Queue("direct.queue2"),
            // 声明一个交换机, 并指定类型(默认即为DIRECT类型)
            exchange = @Exchange(name = "direct.exchange"),
            key = {"red", "yellow"}
    ))
    public void directExchange2(String message) {
        System.out.println("direct.queue2收到的信息" + message);
    }

重新启动consumer模块, 就会创建出对应的路由交换机和队列,(这里不再看效果截图)

再修改publisher中的代码, 

@GetMapping("/{id}")
    public void exchangeSendMessage (@PathVariable Long id) {
       
        // 向direct.exchange交换机发送一条消息, 并携带RoutingKey为"red"
        rabbitTemplate.convertSendAndReceive("direct.exchange", "red", "这是Direct.exchange发送的" +
                "RoutingKey为red的第" + id + "条信息");
        // 向direct.exchange交换机发送一条消息, 并携带RoutingKey为"yellow"
        rabbitTemplate.convertSendAndReceive("direct.exchange", "yellow", "这是Direct.exchange发送的" +
                "RoutingKey为yellow的第" + id + "条信息");
    }
}

再重新启动接口, 并向接口发起一次请求, 查看consumer的控制台

由于direct.queue2的key为"red"和"yellow", 所以收到了两条信息, 而direct.queue的key为"red"和“blue”所以只收到了routingkey为“red”的那条信息. 

         第五种:TopicExchange(主题交换机), 与DirectExchange类似, 区别在于routingkey必须是多个单词的列表, 并且 以"."分割, Queue与Exchange指定BindingKey时可以使用通配符:

         #: 代指0个或多个单词   

         *: 代指一个单词

        这里不再赘述, 与路由交换机的差别就是指定的bindingkey不同, 可以使用通配符,增加了灵活性.

信息转换器:

        从上面的案例中我们可以看到, 我们发送的信息都是String类型的数据, 但有时我们需要发送实体类或者Map,List这样类型的数据,RabbitTemplate是允许我们发送的, 但是发送的时候会对数据进行序列化, 默认是使用java内置的序列化, 而我们可能需要JSON类型的数据, 这时则需要采用JSON进行数据的序列化, 这时我们可以引入jackson的依赖来实现。

  一、引入依赖

 二、将jackson注入到IOC容器中

 到这里就OK了;

       保证RabbitMQ信息的可靠性

                上面我已经演示了RabbitMQ的基本使用, 一般来说信息的流向为: 发布者——>交换机——>队列——>消费者, 我们需要从三个方面保持数据的可靠性(数据能被消费者完整的消费掉):

                1、发布者发送信息时必须将信息完整的送达到RabbitMQ

                2、信息到达RabbitMQ后, RabbitMQ能保证数据不会丢失

                3、信息从RabbitMQ发出后能被消费者正常的消费掉

                下面我们将从上面的三个方面来保证信息的可靠性

             i: 生产者确认机制:

  

还需要在publisher的application.yml文件中添加如下配置:

1、添加publisher-return, 指定消息路由失败时的回调,每个RabbitTemplate只能配置一个ReturnCallback, 所以我们需要在项目启动时进行配置。如下:

@Configuration
@Slf4j
public class CommonConfig implements ApplicationContextAware {
    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        // 得到RabbitTemplate对象
        RabbitTemplate rabbitTemplate = applicationContext.getBean(RabbitTemplate.class);
        // 指定RabbitTemplate对象的回调函数
        rabbitTemplate.setReturnsCallback((message) -> {
            // 信息到达了交换机, 但是却没有到达队列
            log.error("信息投递到了交换机, 但是没有投递到队列, 应答码为{}, 交换机名称为{}, 回复文本为{}, 路由key为{}, 信息对象为{}",
                    message.getReplyCode(), message.getExchange(), message.getReplyText(), message.getRoutingKey(),
                    message.getMessage());
        });
    }
}

2.给信息添加publisher-confirm, 每个信息都可以指定publisher-confirm, 通过CorrelationData对象来指定信息的唯一id(区分不同信息, 避免ack冲突), 并添加信息的回调函数, 如下:

 @GetMapping("/confirm/{id}")
    public void confirmCallback(@PathVariable Long id) {
        // 指定信息
        String message = "这是发来的第" + id + "条信息";
        // 指定该信息的唯一id(封装在CorrelationData中)
        CorrelationData correlationData = new CorrelationData(UUID.randomUUID().toString());
        // 指定回调
        correlationData.getFuture().addCallback(
           // 当收到了rabbitmq的信息时触发, 可能为ack(rabbitmq成功的收到了信息)或者为nack(信息都没有传到exchange)
           success -> {
               // 信息投递到了交换机
            if (success.isAck()) {
                log.info("信息投递到交换机了");
            } else {
                // 还没传到rabbitmq的exchange, 此时可以再次发送信息
                log.error("信息还没有传到交换机");
            }
        }, fail -> {
            // 当没有收到rabbitmq的信息时触发, 可以重发
            log.error("我都没有收到rabbitmq的回调信息");
        });
        rabbitTemplate.convertAndSend("direct.exchange", "red", message, correlationData);
    }

到此就完成了生产者确认机制, 我们首先演示信息没有到达交换机的情况, 我们将convertAndSend方法中的交换机名称变为一个不存在的交换机名称, 如下:

开始运行,看控制台的打印结果, :

 

 可以看到,与我们预想的结果一致

再演示信息到达了交换机,却没有到达队列的情况, 我们将routingkey改为一个没有绑定的值, 如下:

再次发信息, 查看控制台的结果:

 可以看到, 信息投递到了交换机,但没有投递到队列中,至此生产者确认机制已经简单的讲完了,它可以保证我们的信息能够准确的投递到队列中。

                ii: 消息持久化

                        默认情况下, 在SpringAMQP中, 我们创建的交换机, 队列, 消息都是持久化的, 并不需要担心持久化的问题,下面来介绍如何指定队列, 交换机, 消息不需要持久化.

                1. 指定队列不需要持久化(通过注解的方式)

/**
     *  声明一个不持久化的队列和持久化的队列(采用注解的方式)
     */
                                       // 声明一个不持久化的队列, 指定 durable = "false"
    @RabbitListener(queuesToDeclare = { @Queue(value = "no.durable.queue", durable = "false"),
            // 声明一个持久化的队列(默认持久化)
            @Queue(value = "durable.queue") })
    public void durable(String message) {
        System.out.println(message);
    }

查看rabbimq的控制台: 

 可以看到no.durable.queue这个队列确实没有持久化

                采用注册Bean的方式来声明非持久化的队列(注意: 采用Bean的方式创建队列或者交换机需要在项目中有@RabbitListener这个注解才能成功的创建)

                第一种方式:

   @Bean
    public Queue noDurableQueue() {
        // 使用new的方式直接指定一个非持久化的队列
        return new Queue("no.durable.quque2", false);
    }

                第二种方式:

   @Bean
    public Queue noDurableQueue() {
        // 使用队列builder构建出一个非持久化的队列
        return QueueBuilder.nonDurable("no.durable.queue3").build();
    }

                2. 使用Bean构建非持久化的交换机

    @Bean
    public Exchange noDurableExchange() {
        // 声明一个非持久化的交换机, 第二个参数指定为false即可
        return new DirectExchange("no.durable.exchange", false, false);
    }

查看rabbitmq的控制台:

 可以看到, 非持久化的交换机已经创建好了.

                3. 发送非持久化的消息

 @Test
    public void test() {
        // 构建一条消息
        Message message = MessageBuilder.withBody("发送一条非持久化的消息".getBytes(StandardCharsets.UTF_8))
                // 指定消息不需要持久化
                .setDeliveryMode(MessageDeliveryMode.NON_PERSISTENT).build();
        rabbitTemplate.convertAndSend("fanout.exchange", "", message);
    }

查看控制台收到的消息:

 可以看到消息的delivery_mode为1, 说明消息没有持久化.

                iii: 消费者消息确认

看下面的图吧, 记得在consumer模块的application.yml文件中加上下面的配置

                        

 我们将acknowledge-mode改为auto, 让spring帮我们自动监测, 同时我们在消费者中监听fanout.queue中的消息, 但是在消费者中消息被接收到是无法正常被消费的, 此时由于我们将acknowledge-mode改为了auto, 消息又会被spring重新送到fanout.queue这个队列中, 然后队列又把消息发给消费者, 如此反复, 会导致消费者一直报错.演示:

    /**
     * 消费者确认机制
     */
    @RabbitListener(queues = "fanout.queue")
    public void retry(String message) {
        System.out.println("重试机制");
        int i = 1 / 0;
        System.out.println(message);
    }

我们往fanout.queue这个队列中放入一条消息, 查看控制台:

 会看到控制台一直在报错, 再查看rabbitmq的控制台

 可以看到消息又回到了rabbitmq中,但这种不断重试的机制会大大的消耗rabbitmq的资源(消息不断的在rabbitmq与程序中来回发送), 接下来我们才有本地重试的机制, 来避免这种情况。看图:

 我们在consumer的配置文件中加上上面的配置, 然后再重启consumer的服务,再查看控制台消息:

 可以看到, 本地重试三次后就会显示"Retries exhausted for message "显示重试耗尽, 说明上面的配置成功了, 这时我们去查看rabbitmq的控制台, 会发现rabbitmq中的那条信息以及被丢弃了,但有时我们并不希望该消息被丢弃, 再次看图:

 如果我们不希望重试耗尽后消息被丢弃, 我们只要将MessageRecoverer这个接口注入到spring容器中即可, 我们使用RepublisherMessageRecoverer则可以实现我们的需求: 当重试耗尽后, 消息不会被丢弃, 而是投递到一个指定的交换机中, 等待我们的下一步操作。看代码:

    @Bean MessageRecoverer republishedRecover(RabbitTemplate rabbitTemplate) {
        // 指定重试耗尽后投递到error.exchange这个交换机中, 并且指定routingKey为"error"
        return new RepublishMessageRecoverer(rabbitTemplate, "error.exchange", "error");
    }

注意: 在此之前我们需要创建好对应的交换机与绑定关系,这里不再赘述。

此时,我们再往fanout.queue这个队列中放入一条消息,观察结果

 可以看到,又重试了三次,然后再去rabbitmq的控制台看error.queue(bindingKey为error)中是否有消息

 可以看到, 重试的消息已经被SpringAMQP投递到了error.queue这个队列中。演示成功.

                死信

        介绍如下:

 TTL:

 接下来我们演示为队列设置超时时间,并为它指定死信交换机和routingKey, 采用Bean的方式来创建此队列.代码如下:

.

 @Bean
    public Queue deadLetter() {
                // 指定队列的名称
        return QueueBuilder.durable("ttl.queue")
                // 指定队列的超时时间
                .ttl(5000)
                // 指定该队列的死信交换机, 当消息在该队列中待的时间超过指定的ttl时间后, 会将消息投递给该交换机
                .deadLetterExchange("dead.exchange")
                // 指定routingKey
                .deadLetterRoutingKey("dead")
                .build();
    }

重新启动模块, 查看rabbitmq的控制台:

 可以看到, 队列已经被创建出来了.接下来我们向ttl.queue中发送消息,但是不添加消费者去接收,而添加一个消费者去消费dead.queue(与dead.exchange进行了绑定, 且bindingKey为dead)这个队列中的消息。查看ttl.queue中的消息超时后是否会将消息投递到死信交换机中.

        publisher的接口如下:

@GetMapping("/dead")
    public void deadLetter() {
        // 构造消息
        Message message = MessageBuilder.withBody("这是一条测试死信机制的消息".getBytes(StandardCharsets.UTF_8))
                // 指定消息持久化
                .setDeliveryMode(MessageDeliveryMode.PERSISTENT).build();
        rabbitTemplate.convertAndSend( "ttl.queue", message);
    }

        然后在consumer中接收dead.queue队列中的消息.

 @RabbitListener(queues = "dead.queue")
    public void retry(String message) {
        System.out.println(message);
    }

向publisher中的接口发送消息,查看consumer的控制台是否打印接收到的消息.

 可以看到, consumer成功接收到了dead.queue中的消息。

另外,我们也可以为消息设置超时时间, 当消息在队列中等待的时间超过了消息指定的超时时间时该消息也会被投递到指定的死信交换机中.代码如下

 @GetMapping("/dead")
    public void deadLetter() {
        // 构造消息
        Message message = MessageBuilder.withBody("这是一条测试死信机制的消息".getBytes(StandardCharsets.UTF_8))
                // 指定消息持久化
                .setDeliveryMode(MessageDeliveryMode.PERSISTENT)
                // 设置该消息的超时时间为3000毫秒
                .setExpiration("3000")
                .build();
        rabbitTemplate.convertAndSend( "ttl.queue", message);
    }

注意: 当同时设置了消息的超时时间和队列的超时时间时,会选择它俩之间那个最小的超时时间,

例如消息制定了expiration为3000,而队列指定了ttl为5000,则消息的真正超时时间会选择它俩之间那个较小的3000.

             惰性队列

        看图:

 而在SpringAMQP中设置一个队列为惰性队列非常简单, 代码如下:

  @Bean
    public Queue lazyQueue() {
        return QueueBuilder.durable("lazy.queue")
                // 声明该队列为惰性队列
                .lazy()
                .build();
    }

到此,一个惰性队列就声明好了。

三、ElasticSearch

        介绍: ElasticSearch是一个基于Lucene的搜索服务器。它提供了一个分布式多用户能力的全文搜索引擎,基于RESTful web接口。Elasticsearch是用Java开发的,并作为Apache许可条款下的开放源码发布,是当前流行的企业级搜索引擎。设计用于云计算中,能够达到实时搜索,稳定,可靠,快速,安装使用方便。

        安装使用: 我们同样通过docker的方式来安装运行它, 并安装ES的控制台工具Kibana,注意:

kibana与elasticsearch的版本必须一样。例如es的版本为7.17.7,kibana的版本也必须为7.17.7。我建议你们安装7.16.6的版本,因为后面的中文分词器和拼音分词器都有对应的版本,而7.17.7就没有对应的拼音分词器版本。

        倒排索引: ES搜索如此快的核心就是采用了倒排索引, 当我们在mysql等传统数据库时, 当我们想要查询一个表中的哪些行数据中的某个字段包含某些词语时,我们通常会使用"like"这个关键词进行查询, 而我们在mysql中添加数据时,mysql会以我们的唯一id作为索引, 以文档内容作为记录 。而我们往ES中添加数据时,ES会将文档内容进行分词,以分词后的内容作为索引,将包含该词的文档 ID 作为记录。(有关倒排索引的详细信息,可以去网上找找,这里只是最简单的描述).

        ES中的概念: 

                index(索引): 是文档的集合,相当于mysql中的一张表

                document(文档):就是一条条数据, 相当于mysql数据库中的行

                field(字段): 就是JSON文档中的字段, 类似于数据库中的列(column)

      在Java客户端中操作ES(7.17.7版本)

              ES的基本语法这里不再赘述,可以自己去学习学习,还是比较简单的,可以使用上面安装的kibana进行ES的语法学习,他提供了一个ES的控制台(还有很多功能,这只是其中的一个小功能,自己可以去探索探索)。由于我是学习的黑马的ES教程,他教我们在java中操作ES的方式已经被弃用了 ,所以我去看了官网,按照官网提供的官方教程来用java操作ES。官网地址如下:感(兴趣的可以去看看,讲的很好,非常容易懂,大部分操作都有讲解)

Installation | Elasticsearch Java API Client [7.17] | Elasticicon-default.png?t=N7T8https://www.elastic.co/guide/en/elasticsearch/client/java-api-client/7.17/installation.html

               一:依赖的安装

        导入下面的依赖:

  <!--    导入操作es的客户端依赖    -->
        <dependency>
            <groupId>co.elastic.clients</groupId>
            <artifactId>elasticsearch-java</artifactId>
            <version>7.17.10</version>
        </dependency>
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
            <version>2.12.3</version>
        </dependency>
        <dependency>
            <groupId>jakarta.json</groupId>
            <artifactId>jakarta.json-api</artifactId>
            <version>2.0.1</version>
        </dependency>

导入上面的依赖后,可能在连接ES时还会报错,官网中也提到了:

 大概意思就是说包管理工具比如maven,gradle为了简化我们的配置,而内置了一些知名库的版本,而它们为我们内置的依赖包版本可能会与我们刚才导入的依赖产生冲突,所以我们需要使用正确的版本。这里我们使用的springboot的版本为2.7.9,它内置的jackson-bom的版本为2.13.5,

 我们需要将它覆盖为2.12.3,如下:

 到此,配置就完成了。完整的依赖配置如下:

<dependencies>
     
        <!--    导入操作es的客户端依赖    -->
        <dependency>
            <groupId>co.elastic.clients</groupId>
            <artifactId>elasticsearch-java</artifactId>
            <version>7.17.10</version>
        </dependency>
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
            <version>2.12.3</version>
        </dependency>
        <dependency>
            <groupId>jakarta.json</groupId>
            <artifactId>jakarta.json-api</artifactId>
            <version>2.0.1</version>
        </dependency>
</dependencies>
    <!--  覆盖springboot的jackson默认版本依赖(必须覆盖)  -->
    <properties>
        <jackson-bom.version>2.12.3</jackson-bom.version>
    </properties>

        二、安装中文分词器

        由于我们平常使用的都是中文,而ES的官方内置分词器对中文的支持不是很好,所以我们需要自己安装中文分词器,这里我们选择IK分词器。

        首先由于我们安装了docker-desktop, 我们就可以直接进入运行es容器的内部

 点击运行elasticsearch的那个容器,会进入下面的页面:

 点击Terminal, 则表示我们进入了容器的内部,并可以用bash命令来进行操作

 可以看到我们现在位于容器的/usr/share/elasticsearch目录, 我们输入命令: cd /usr/share/elasticsearch/plugins/, 进入到es的插件目录, 此时我们找到ik在github的地址,查看它的安装说明:

 我们采用方式二的方式进行在线安装,在es容器内运行命令:  ./bin/elasticsearch-plugin install https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v7.17.7/elasticsearch-analysis-ik-7.17.7.zip,注意:这里的版本必须与你的es版本一致,等待下载完毕即可,再重启容器。

有些伙伴可能只是安装了docker的引擎,而没有安装桌面版本, 这时我们可以通过命令:

docker exec -it elasticsearch bash

进入到容器内部,然后依次执行上面的步骤即可。注意: 这里的elasticsearch为我es运行的容器名称,你应该替换成你自己的容器名称。

        验证: 

        我们进入到kibana为我们提供的控制台进行验证分词器是否安装成功, 打开http://localhost:5601/app/dev_tools#/console这个网址,运行下面的命令

 这里指定了分词器为ik_max_word, 查看返回结果为:

 可以看到,已经完成了分词,到此中文分词器就按照成功了。ik中文分词器有两种常用的模式: ik_smart(最小分词)和ik_max_word(最大分词), ik_max_word模式分的词会更多,细粒度更高,ik分词器还可以指定不对某些词进行分词和添加自定义词语,这都可以通过配置实现,实现的方式也很简单,感兴趣的可以去github上面查看, 附上网址:GitHub - medcl/elasticsearch-analysis-ik: The IK Analysis plugin integrates Lucene IK analyzer into elasticsearch, support customized dictionary.icon-default.png?t=N7T8https://github.com/medcl/elasticsearch-analysis-ik

        三:使用java操作ES(根据官网教程)

                一:创建连接:

 /**
     * 将操作ES的客户端类注入到IOC容器中
     * @return
     */
    @Bean
    public ElasticsearchClient operateElasticSearch() {
        RestClient restClient = RestClient.builder(
                new HttpHost("localhost", 9200)).build();
        ElasticsearchTransport transport = new RestClientTransport(
                restClient, new JacksonJsonpMapper());
        return new ElasticsearchClient(transport);
    }

第一步,我们先创建连接,并将操作ES的客户端操作交给Spring管理

        二:  开始操作:(注意: 下面的client变量即为交给spring管理的那个操作ES的对象)

                1.创建索引(使用JSON的方式):

  private void createIndex() throws IOException {
        Reader input = new StringReader(
                """
                        {
                             "mappings": {
                               "properties": {
                                 "name": {
                                   "type": "text",
                                   "analyzer": "ik_max_word",
                                   "copy_to": "all"
                                 },
                                 "id": {
                                   "type": "keyword",
                                   "index": false
                                 },
                                 "address": {
                                   "type": "text",
                                   "analyzer": "ik_max_word"
                                 },
                                 "price": {
                                   "type": "integer"
                                 },
                                 "score": {
                                   "type": "integer"
                                 },
                                 "brand": {
                                   "type": "keyword",
                                   "copy_to": "all"
                                 },
                                 "city": {
                                   "type": "keyword"
                                 },
                                 "starName": {
                                   "type": "keyword"
                                 },
                                 "business": {
                                   "type": "text",
                                   "analyzer": "ik_max_word"
                                 },
                                 "location": {
                                   "type": "geo_point"
                                 },
                                 "pic": {
                                   "type": "text",
                                   "index": false
                                 },
                                 "all": {
                                   "type": "text",
                                   "analyzer": "ik_max_word"
                                 }
                               }
                             }
                           }
                            """);
        CreateIndexResponse hotel = client.indices().create(p -> p.index("hotel").withJson(input));
        System.out.println(hotel.acknowledged());
    }

这里我们采用编写JSON字符串的方式创建索引,当然还可以采用编程的方式创建,这里不再赘述,我的建议是索引还是别使用JAVA的方式进行创建,太过于麻烦了,直接在kibana的控制台中进行创建,还有语法提示。

                2.删除索引:

 /**
     * 删除指定索引
     *
     * @throws IOException
     */
    private void deleteIndex() throws IOException {
        DeleteIndexResponse response = client.indices().delete(p -> p.index("name"));
        System.out.println(response.acknowledged());
    }

这里指我们要删除索引名称为"name"的那个索引。

        3.查看指定的索引和字段

  /**
     * 查看指定索引和索引的字段
     *
     * @throws IOException
     */
    private void getIndex() throws IOException {
        GetIndexResponse response = client.indices().get(p -> p.index("name"));
        // 查看指定字段
        GetFieldMappingResponse response1 = client.indices().getFieldMapping(p -> p.index("hotel").fields("name"));
        System.out.println(response);
        System.out.println(response1);
    }

          4. 修改指定的索引:

                ES不允许我们修改已经存在的索引字段, 但我们可以为索引添加新的字段,代码如下:

/**
     * 为指定索引增加字段
     *
     * @throws IOException
     */
    private void updateIndex() throws IOException {
        PutMappingResponse response = client.indices().putMapping(p -> p.index("name").withJson(new StringReader("""
                {
                    "properties": {
                        "age": {
                            "type": "integer"
                        }
                    }
                }
                """)));
        System.out.println(response.acknowledged());
    }

这里我们为"name"这个索引添加了一个新的age字段,这是允许的。

        5.创建文档

 /**
     * 创建文档
     *
     * @throws IOException
     */
    private void createDoc() throws IOException {
        Hotel hotel = hotelMapper.selectById(38609L);
        CreateResponse res = client.create(p -> p.index("hotel").id("111").document(new HotelDoc(hotel)));
    }

这个document()方法可以接收任何一个实体类对象,将其转换为ES中的一条文档存储起来,如果你熟悉mybatis-plus的话,应该会感到很熟悉。

        6.根据文档的id来删除文档

   /**
     * 根据文档的id删除指定的文档
     *
     * @throws IOException
     */
    private void deleteDoc() throws IOException {
        DeleteResponse response = client.delete(p -> p.index("hotel").id("111"));
        System.out.println(response.index());
    }

        7.根据文档的id来得到数据

/**
     * 根据文档的id得到数据
     */
    private void getDoc() throws IOException {
        GetRequest.Builder builder = new GetRequest.Builder();
        builder.index("hotel");
        builder.id("1");
        GetResponse<HotelDoc> hotelDocGetResponse = client.get(builder.build(), HotelDoc.class);
        System.out.println(hotelDocGetResponse.source());
    }

你可以看到,这次的代码与上面的不同:先创建了GetRequest的Builder对象。但实际与上面的代码是一样的,上面的代码使用了lambda表达式,使得代码更加简洁,省去了我们新建对象的步骤,

如果你觉得lambda表达式的可阅读性差的话完全可以使用一步步创建对象的步骤(但代码量会显得比较庞大,特别是后面的查询)来进行查询。

        8.根据文档的id来进行更新


    /**
     * 根据文档id进行更新
     */
    private void updateDoc() throws IOException {
        client.update(u -> u.index("hotel").id("111").withJson(new StringReader("""
                                {
                                    "doc": {
                                        "city": "北京",
                                        "score": 45
                                    }
                                }
                """)), HotelDoc.class);
    }

这里依旧采用书写JSON字符串命令的方式来进行文档的更新操作。

        9.使用Bulk来进行文档的批处理操作

    /**
     * 使用Bulk来操作大量的文档(document)
     *
     * @throws IOException
     */
    private void useBulk() throws IOException {
        List<BulkOperation> hotelList = new ArrayList<>();
        List<Hotel> hotels = hotelMapper.selectList(null);
        // 得到BulkRequest的Builder
        BulkRequest.Builder builder = new BulkRequest.Builder();
        // 通过Builder得到BulkOperation对象(在Builder中指定要进行的操作)
        for (Hotel hotel : hotels) {
            HotelDoc doc = new HotelDoc(hotel);
            // 得到BulkOperation的Builder
            BulkOperation.Builder bulkOperationBuilder = new BulkOperation.Builder();
                                                // 除了index方法,还有create(),update(),delete()方法
            hotelList.add(bulkOperationBuilder.index(p -> p.index("hotel").id(doc.getId() + "").document(doc)).build());
        }
        // 将BulkOperation传给BulkRequest的Builder从而完成BulkRequest的创建
        BulkRequest bulkRequest = builder.operations(hotelList).build();
        // 发起请求
        BulkResponse response = client.bulk(bulkRequest);
    }

这里的案例我首先将数据库中hotel表的全部数据取了出来,然后利用Bulk批量的将这些数据发送给了ES的服务端,从而完成了数据从mysql向ES的批量转移操作。

既然Bulk是进行批量操作的,那么就会有增删改这三种操作, 而ES为我们提供了四个对象来完成这三种操作,分别是:

                IndexOperation,

                CreateOperation,

                UpdateOperation,

                DeleteOperation,

而它们的作用官网详细的进行了说明:

 唯一要注意的就是create和index的区别,create就是当进行添加操作时,如果要添加的文档的id在索引中已经存在时,则不进行操作,而index则是进行替换操作。

        10.使用模板语言

        刚才我们看到了使用JSON字符串的时候,插入变量很不方便,只能进行拼接操作,而使用模板语言则可以优雅的在JSON字符串中插入变量。

 /**
     * 使用模板语言来进行查询
     *
     * @throws IOException
     */
    private void useTemplate() throws IOException {
        client.putScript(r -> r
                // 指定模板语言的唯一id
                .id("query-script")
                .script(s -> s
                        // 指定模板语言的类型为mustache
                        .lang("mustache")
                        // 进行的操作
                        .source("""
                                {
                                  "query": {
                                    "match": {
                                      "{{field}}": "{{value}}"
                                    }
                                  }
                                }
                                """)
                ));
        SearchTemplateResponse<HotelDoc> response = client.searchTemplate(r -> r
                        .index("hotel")
                        // 根据模板语言的id找到相对应的模板
                        .id("query-script") // <1>
                        // 替换模板语言中的占位符
                        .params("field", JsonData.of("name"))
                        .params("value", JsonData.of("连锁")),
                HotelDoc.class
        );

        List<Hit<HotelDoc>> hits = response.hits().hits();
        for (Hit<HotelDoc> hit : hits) {
            HotelDoc product = hit.source();
            System.out.println(product);
        }
    }

        11.进行查询:

我们在学习ES的查询语法时, 我们会发现它提供了好多种查询的方式,比如:term,range,match,matchAll等等,而在ES的java客户端中,他为每一种查询都提供了一个查询对象,例如:term查询对应着TermQuery这个对象。。在ES中每个查询方式有的操作,所对应的java对象都进行了封装。这边使用条件查询的方式来演示Java中这些查询对象的使用,代码如下:

  /**
     * 使用条件查询
     *
     * @throws IOException
     */
    private void useBool() throws IOException {
        // 使用TermQuery对象构建一个term查询
        Query query = TermQuery.of(p -> p.field("brand").value("速8"))._toQuery();
        // 使用RangeQuery对象构建一个range查询
        Query range = RangeQuery.of(q -> q.field("score").lte(JsonData.of(35)))._toQuery();
        // 将已经构建好的Query对象传给BoolQuery来构建出条件查询对象
        BoolQuery bool = BoolQuery.of(q -> q.must(query).filter(range));
                                                                                     // 使用条件查询对象作为条件进行查询   
        SearchResponse<HotelDoc> response = client.search(p -> p.index("hotel").query(q -> q.bool(bool)), HotelDoc.class);
        List<Hit<HotelDoc>> hits = response.hits().hits();
        for (Hit<HotelDoc> hit : hits) {
            System.out.println(hit.source());
        }
    }

这里只演示了其中的几种查询对象,感兴趣的话可以自己去查看文档。在查询时,我推荐使用这种分布构建查询对象的方式来进行查询。代码比较优雅,阅读性较强。

        下面再来演示一下function_score查询:

在我们进行数据的查询时,ES会给返回回来的每条文档计算得分,得分越高则代表该条文档与查询条件的相关性更高,而有时我们需要改变某些文档的得分,以将他的排名提高。而这时我们可以使用function_score来进行查询, 演示代码如下:

  /**
     * 使用function_score
     *
     * @throws IOException
     */
    private void useFunctionScore() throws IOException {
        // 构建查询对象
        Query query = TermQuery.of(p -> p.field("brand").value("速8"))._toQuery();
        Query range = RangeQuery.of(q -> q.field("score").lte(JsonData.of(35)))._toQuery();
                                                                            // 查询的条件
        FunctionScoreQuery functionScoreQuery = FunctionScoreQuery.of(f -> f.query(query).
                // 指定要重新计算得分的数据的过滤条件, 并给这些筛选出来的数据指定权重为10(这里只是一种最简单的处理方法, 还有很多种, 感兴趣的可以
                // 查看相关资料)
                functions(v -> v.filter(range).weight(10D)).
                // 重新计分的方式, 这里Multiply表示将原始分与权重10做一个乘积, 将计算后的分数作为新的得分
                boostMode(FunctionBoostMode.Multiply));
        // 指定索引开始进行查询
        SearchResponse<HotelDoc> response = client.search(s -> s.index("hotel").
                query(q -> q.functionScore(functionScoreQuery)), HotelDoc.class);
        
        // 处理返回的数据
        List<Hit<HotelDoc>> hits = response.hits().hits();
        for (Hit<HotelDoc> hit : hits) {
            System.out.println("得分为:" + hit.score() + ";");
            System.out.println(hit.source());
        }
    }

通过function_score这种查询,就可以方便我们自由的改变文档的得分。

        再来演示分页和排序:

  /**
     * 使用sort和分页
     */
    private void useSortAndPage() throws IOException{
        // 构建查询对象
        MatchQuery matchQuery = MatchQuery.of(m -> m.field("all").query("速8"));
        // 构建排序的规则
        SortOptions options = SortOptions.of(m -> m.field(j -> j.order(SortOrder.Desc).field("score")));
        SortOptions options1 = SortOptions.of(m -> m.field(j -> j.order(SortOrder.Desc).field("price")));
        SearchResponse<HotelDoc> response = client.search(s -> s.index("hotel")
                // 根据查询对象进行查询
                .query(q -> q.match(matchQuery))
                // 根据排序对象进行排序
                .sort(options, options1)
                // 开始分页
                .from(0)
                .size(6), HotelDoc.class);
        // 对返回的数据进行处理
        List<Hit<HotelDoc>> hits = response.hits().hits();
        for (Hit<HotelDoc> hit : hits) {
            System.out.println(hit.source().getScore());
            System.out.println(hit.source().getPrice());
            System.out.println("----------------------------");
        }
    }

可以看到, 排序和分页还是很简单的。

        再来演示一下高亮处理:

我们平时在浏览器进行搜索时,如果我们仔细观察的话,查询条件会在查询到的数据中进行高亮显示,而ES也为我们提供了此功能,且非常容易实现。


    /**
     * 使用高亮
     * @throws IOException
     */
    private void useHighlight() throws IOException {
        // 构建查询对象
        MatchQuery matchQuery = MatchQuery.of(m -> m.field("all").query("速8"));
        SearchResponse<HotelDoc> response = client.search(s -> s.index("hotel").query(q -> q.match(matchQuery)).
                // 进行高亮处理                             指定在查询到的目标词语前加一个"<li>", 在后面加一个"</li>"
                highlight(h -> h.fields("name", f -> f.preTags("<li>").postTags("</li>").requireFieldMatch(false))), HotelDoc.class);
        List<Hit<HotelDoc>> res = response.hits().hits();
        for (Hit<HotelDoc> hit : res) {
            System.out.println(hit.source().getScore());
            System.out.println(hit.source().getPrice());
            System.out.println(hit.highlight());
        }
    }

可以看到,我们将文档中的目标词语包含在了一个<li>标签中,后面我们只需为这个<li>元素添加样式即可做到高亮显示了,非常方便.

        最后演示一下聚合的操作:

 /**
     * 使用聚合函数
     *
     * @throws IOException
     */
    private void useAggregations() throws IOException {
        // 得到全部的数据
        Query query = MatchAllQuery.of(p -> p.queryName("hotel"))._toQuery();
        // 开始进行聚合
        SearchResponse<Void> response = client.search(p -> p.index("hotel").query(query).
                // 聚合的名称为first
                aggregations("first", a -> a.terms(t -> t.field("brand").size(5).order(new NamedValue<>("second.avg", SortOrder.Desc)))
                        // 再次聚合, 在第一次聚合的基础上面再聚合        
                        .aggregations("second", s -> s.stats(v -> v.field("score")))), Void.class);
        // 得到聚合的结果
        List<StringTermsBucket> first = response.aggregations().get("first").sterms().buckets().array();
        for (StringTermsBucket bucket : first) {
            System.out.println(bucket.docCount() + ":" + bucket.key().stringValue());
            // 得到二次聚合的结果
            System.out.println(bucket.aggregations().get("second").stats());
        }
        System.out.println(first);
    }

这里只演示了最简单的聚合操作,ES官方为我们提供了非常多的聚合操作,感兴趣的可以去查看官方文档,链接如下:

Aggregations | Elasticsearch Guide [7.17] | Elasticicon-default.png?t=N7T8https://www.elastic.co/guide/en/elasticsearch/reference/7.17/search-aggregations.html至此我们使用java演示完了对ES的简单操作。下面再介绍一下ES的高级用法,使用ES的补全功能,如果我们经常购物的话,我们在一个搜索栏中输入一两个字符,下面就会给我们推荐一系列相关的产品。这就是文字补全的作用了,就比如我们输入一个"手", 他就能给我们补全成"手机", "手帕"等一系列词语。而有些补全则更为强大,他可以根据我们输入的拼音来进行补全,例如输入"shui", 他就能给我们补全为"水瓶", "水壶"等一系列成语。下面,我将来完成这个功能。

未完待续。。。。。。。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值