• RabbitMQ(七)高级发布确认与优先级队列


    RabbitMQ(七)高级分布确认与优先级队列


    8 发布确认高级

    ​ 在生产环境中由于一些不明原因,而导致rabbitMQ重启,在RabbitMQ重启阶段生产者消息投递失败,导致消息丢失,需要手动处理和恢复,这种情况可以使用确认机制。

    8.1 交换机确认方案

    8.1.1 确认机制方案

    在这里插入图片描述

    8.1.2 代码架构图

    在这里插入图片描述

    8.1.3 配置文件

    ​ 需要在配置文件中添加

    spring:
      rabbitmq:
        # 发布消息成功到交换机后会触发回调方法
        publisher-confirm-type: correlated
    
    • 1
    • 2
    • 3
    • 4
    • NONE

      禁用发布确认模式,默认

    • CORRELATED

      发布消息成功到交换机会触发回调方法

    • SIMPLE

      经过测试有两种效果,其一效果和CORRELATED一样会调用回调方法,

      其二在发布消息成功后使用rabbitTemplate调用waitForConfirms或waitForConfirmsOrDie方法等待broker节点返回发送结果,根据返回结果来判定下一步的逻辑,需要注意的点是waitForConfirmsOrDie方法返回false则会关闭channel,则接下来无法发送消息到broker。

    8.1.4 实战代码

    1. 结构配置类代码

      /**
       * 确认高级
       */
      @Configuration
      public class ConfirmQueueConfig {
      
        //交换机名称
        public static final String CONFIRM_EXCHANGE = "confirm.exchange";
        public static final String CONFIRM_QUEUE = "confirm.queue";
        //routingKey
        public static final String CONFIRM_ROUTING_KEY = "key1";
      
      
      
        @Bean("confirmExchange")
        public DirectExchange confirmExchange() {
          return new DirectExchange(CONFIRM_EXCHANGE);
      
        }
      
        @Bean("confirmQueue")
        public Queue confirmQueue() {
          return QueueBuilder.durable(CONFIRM_QUEUE).build();
        }
      
        @Bean
        public Binding confirmQueueBindingConfirmExchange(@Qualifier("confirmQueue") Queue queue,
                                                          @Qualifier("confirmExchange") DirectExchange directExchange) {
          return BindingBuilder.bind(queue).to(directExchange).with(CONFIRM_ROUTING_KEY);
        }
      }
      
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 17
      • 18
      • 19
      • 20
      • 21
      • 22
      • 23
      • 24
      • 25
      • 26
      • 27
      • 28
      • 29
      • 30
      • 31
      • 32
    2. 消息生产者代码

    @Slf4j
    @Api(tags = "高级确认")
    @RestController
    @RequestMapping("confirm")
    public class ConfirmController {
    
      @Resource
      RabbitTemplate rabbitTemplate;
    
      @ApiOperation(value = "高级确认")
      @GetMapping("sendMsg/{message}")
      public boolean sendMsg(@PathVariable String message) {
        CorrelationData correlationData = new CorrelationData("1");
        //   发送正确消息
        rabbitTemplate.convertAndSend(ConfirmQueueConfig.CONFIRM_EXCHANGE, ConfirmQueueConfig.CONFIRM_ROUTING_KEY,
                                      message, correlationData);
        log.info("接口发出消息{}", message);
        //   发送错误key消息
        CorrelationData correlationData2 = new CorrelationData("2");
        rabbitTemplate.convertAndSend(ConfirmQueueConfig.CONFIRM_EXCHANGE+ "12", ConfirmQueueConfig.CONFIRM_ROUTING_KEY ,
                                      message, correlationData2);
        log.info("接口发出消息{}", message);
    
        return true;
      }
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    1. 回调接口

      /**
       * 确认实现回调
       */
      @Slf4j
      @Component
      public class MyCallBack implements RabbitTemplate.ConfirmCallback {
      
        @Autowired
        private RabbitTemplate rabbitTemplate;
      
        //当前接口注入到RabbitTemplate中
        @PostConstruct
        public void init() {
          //注入
          rabbitTemplate.setConfirmCallback(this);
        }
      
        /**
           * 交换机确认回调方法
           * 1. 发消息 交换机接收到(成功回调)
           * 1.1 correlationData 保存回调信息的ID以及相关信息
           * 1.2 交换机收到消息 ack = true
           * 1.3 cause null 因为成功 所以失败原因为空
           * 

      * 2. 发消息 交换机没有接收到(失败回调) * 2.1 correlationData 保存回调信息的ID以及相关信息 * 2.2 交换机接收到的消息 ack = false * 2.3 cause 里面存放的是失败的原因 * * @param correlationData * @param ack * @param cause */ @Override public void confirm(CorrelationData correlationData, boolean ack, String cause) { String id = correlationData != null ? correlationData.getId() : ""; if (ack) { log.info("交换机已经收到id为:{}的消息", id); } else { log.info("交换机还未收到id为:{}的消息,由于原因:{}", id, cause); } } }

      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 17
      • 18
      • 19
      • 20
      • 21
      • 22
      • 23
      • 24
      • 25
      • 26
      • 27
      • 28
      • 29
      • 30
      • 31
      • 32
      • 33
      • 34
      • 35
      • 36
      • 37
      • 38
      • 39
      • 40
      • 41
      • 42
      • 43
      • 44
      • 45
      • 46
    2. 消息消费者

      @Slf4j
      @Component
      public class Consumer {
      
      @RabbitListener(queues = ConfirmQueueConfig.CONFIRM_QUEUE)
      public void receiveConfirmQueue(Message message) throws UnsupportedEncodingException {
      String msg = new String(message.getBody(), "UTF-8");
      log.info("当前时间: {}.收到队列的消息: {}", new Date(), msg);
      }
      
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11

    8.1.5 结果分析

    发起请求

    http://127.0.0.1:9191/confirm/sendMsg/123

    在这里插入图片描述

    ​ 从图片中可以看到,当请求了这个接口之后,会发送两条消息,由于第一条消息里面的信息都是正确的,所以他调用了正确的回调方法,而第二条的消息的交换机名称是错误的,所以他就调用了错误的回调方法,并打印了错误的原因。

    8.2 消息确认方案(回退消息)

    8.2.1 Mandatory参数

    在仅开启了生产者确认机制的情况下,交换机接收到消息后,会直接给消息生产者发送确认消息,如果发现该消息不可以路由,那么消息就会被直接丢弃,此时生产者是不知道消息被丢弃这个事情的。所以可以通过设置mandatory参数在消息传递过程中不可达目的地时将消息返回给生产者。

    在这里插入图片描述

    8.2.2 配置文件

    添加回退消息配置

    spring:
      rabbitmq:
        # 发送消息给信道,如果没有接收则回退消息
        publisher-returns: true
    
    • 1
    • 2
    • 3
    • 4

    8.2.3 实战代码

    1. 生产者代码

      @ApiOperation(value = "高级确认")
      @GetMapping("sendMsg/{message}")
      public boolean sendMsg(@PathVariable String message) {
      CorrelationData correlationData = new CorrelationData("1");
      //   发送正确消息
      rabbitTemplate.convertAndSend(ConfirmQueueConfig.CONFIRM_EXCHANGE, ConfirmQueueConfig.CONFIRM_ROUTING_KEY,
      message, correlationData);
      log.info("接口发出消息{}", message);
      //   发送错误key消息
      CorrelationData correlationData2 = new CorrelationData("2");
      rabbitTemplate.convertAndSend(ConfirmQueueConfig.CONFIRM_EXCHANGE, ConfirmQueueConfig.CONFIRM_ROUTING_KEY + "12",
      message + "(错误交换机名称)", correlationData2);
      log.info("接口发出消息{}", message + "(错误交换机名称)");
      
      return true;
      }
      
      
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 17
      • 18
    2. 回调方法

      /**
       * 确认实现回调
       */
      @Slf4j
      @Component
      public class MyCallBack implements RabbitTemplate.ConfirmCallback, RabbitTemplate.ReturnsCallback {
        @Autowired
        private RabbitTemplate rabbitTemplate;
      
        //当前接口注入到RabbitTemplate中
        @PostConstruct
        public void init() {
          //注入
          rabbitTemplate.setConfirmCallback(this);
      
          //注入
          rabbitTemplate.setReturnsCallback(this);
        }
      
        @Override
        public void confirm(CorrelationData correlationData, boolean ack, String cause) {
          String id = correlationData != null ? correlationData.getId() : "";
          if (ack) {
            log.info("交换机已经收到id为:{}的消息", id);
          } else {
            log.info("交换机还未收到id为:{}的消息,由于原因:{}", id, cause);
          }
      
        }
      
        @SneakyThrows
        @Override
        public void returnedMessage(ReturnedMessage returnedMessage) {
          System.out.println(returnedMessage);
          log.error("消息{},被交换机{}退回,退回原因:{},路由Key:{}",
                    new String(returnedMessage.getMessage().getBody(), "UTF-8"),
                    returnedMessage.getExchange(), returnedMessage.getReplyText(), returnedMessage.getRoutingKey());
        }
      }
      
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 17
      • 18
      • 19
      • 20
      • 21
      • 22
      • 23
      • 24
      • 25
      • 26
      • 27
      • 28
      • 29
      • 30
      • 31
      • 32
      • 33
      • 34
      • 35
      • 36
      • 37
      • 38
      • 39
      • 40

    8.2.4 结果分析

    发起请求

    http://127.0.0.1:9191/confirm/sendMsg/123

    在这里插入图片描述

    ​ 从图片中可以看到,当消息被接收,我们可以在回调中知道是哪一条数据没有接收到,可以对没有接收到的数据进行保存重发等操作。

    8.3 备份交换机

    ​ 对于无法投递的消息,我们会希望有一个备份交换机,当交换机接收到一条不可路由的消息时,会将这条消息转发到备份交换机中,由备份交换机进行转发和处理,通常备份交换机的类型为Fanout,这样就能把这条消息投递到与其绑定的所有队列,当然我们还可以建立一个报警队列,通独立的消费者来进行监测和报警。

    8.3.1 代码架构图

    在这里插入图片描述

    8.3.2 实战代码

    1. 修改配置类

      @Configuration
      public class ConfirmQueueConfig {
      
        //交换机名称
        public static final String CONFIRM_EXCHANGE = "confirm.exchange";
        public static final String CONFIRM_QUEUE = "confirm.queue";
        //routingKey
        public static final String CONFIRM_ROUTING_KEY = "key1";
      
        //备份交换机
        public static final String BACKUP_EXCHANGE = "backup.exchange";
        //备份队列
        public static final String BACKUP_QUEUE = "backup.queue";
        //报警队列
        public static final String WARNING_QUEUE = "waring.queue";
      
        @Bean("confirmExchange")
        public DirectExchange confirmExchange() {
      
          HashMap<String, Object> maps = new HashMap<>();
          //指定备份交换机
          maps.put("alternate-exchange", BACKUP_EXCHANGE);
      
          //关联备份交换机
          return ExchangeBuilder.directExchange(CONFIRM_EXCHANGE).durable(true)
            .withArguments(maps).build();
        }
      
        @Bean("confirmQueue")
        public Queue confirmQueue() {
          return QueueBuilder.durable(CONFIRM_QUEUE).build();
        }
      
        //备份交换机
        @Bean("backupExchange")
        public FanoutExchange backupExchange() {
          return new FanoutExchange(BACKUP_EXCHANGE);
        }
      
        @Bean("warningQueue")
        public Queue warningQueue() {
          return QueueBuilder.durable(WARNING_QUEUE).build();
        }
      
        @Bean("backupQueue")
        public Queue backupQueue() {
          return QueueBuilder.durable(BACKUP_QUEUE).build();
        }
      
      
        @Bean
        public Binding confirmQueueBindingConfirmExchange(@Qualifier("confirmQueue") Queue queue,
                                                          @Qualifier("confirmExchange") DirectExchange directExchange) {
          return BindingBuilder.bind(queue).to(directExchange).with(CONFIRM_ROUTING_KEY);
        }
      
        //备份队列绑定备份交换机
        @Bean
        public Binding backupQueueBindingConfirmExchange(@Qualifier("backupQueue") Queue queue,
                                                         @Qualifier("backupExchange") FanoutExchange backupExchange) {
          return BindingBuilder.bind(queue).to(backupExchange);
        }
      
        //报警队列绑定备份交换机
        @Bean
        public Binding waringQueueBindingConfirmExchange(@Qualifier("warningQueue") Queue queue,
                                                         @Qualifier("backupExchange") FanoutExchange backupExchange) {
          return BindingBuilder.bind(queue).to(backupExchange);
        }
      }
      
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 17
      • 18
      • 19
      • 20
      • 21
      • 22
      • 23
      • 24
      • 25
      • 26
      • 27
      • 28
      • 29
      • 30
      • 31
      • 32
      • 33
      • 34
      • 35
      • 36
      • 37
      • 38
      • 39
      • 40
      • 41
      • 42
      • 43
      • 44
      • 45
      • 46
      • 47
      • 48
      • 49
      • 50
      • 51
      • 52
      • 53
      • 54
      • 55
      • 56
      • 57
      • 58
      • 59
      • 60
      • 61
      • 62
      • 63
      • 64
      • 65
      • 66
      • 67
      • 68
      • 69
      • 70
      • 71
    2. 报警消费者

      /**
       * 报警消费者
       */
      @Slf4j
      @Component
      public class WaringConsumer {
      
        //接收报警消息
        @RabbitListener(queues = ConfirmQueueConfig.WARNING_QUEUE)
        public void receiveWaringMsg(Message message) {
          String msg = new String(message.getBody());
          log.error("报警发现不可路由消息:{}", msg);
        }
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14

    8.3.3 结果分析

    发起请求

    http://127.0.0.1:9191/confirm/sendMsg/123

    在这里插入图片描述

    ​ 通过这个例子一个是可以看出备份交换机的作用,还有一个是可以看出mandatory参数与备份交换机一起使用的时候,备份交换机的优先级更高。

    9 RabbitMQ的其他知识点

    9.1 幂等性

    9.1.1 概念

    ​ 用户对于同一操作发起的一次请求或者多次请求的结果是一致的,不会因为多次点击而产生了副作用。 举个最简单的例子,那就是支付,用户购买商品后支付,支付扣款成功,但是返回结果的时候网络异常,此时钱已经扣了,用户再次点击按钮,此时会进行第二次扣款,返回结果成功,用户查询余额发现多扣钱了,流水记录也变成了两条。在以前的单用系统中,我们只需要把数据操作放入事务中即可,发生错误立即回滚,但是再响应客户端的时候也有可能出现网络中断或者异常等等。

    9.1.2 消息重复消费

    ​ 消费者在消费 MQ 中的消息时,MQ 已把消息发送给消费者,消费者在给 MQ 返回 ack 时网络中断,故 MQ 未收到确认信息,该条消息会重新发给其他的消费者,或者在网络重连后再次发送给该消费者,但实际上该消费者已成功消费了该条消息,造成消费者消费了重复的消息。

    9.1.3 解决思路

    ​ MQ 消费者的幂等性的解决一般使用全局 ID 或者写个唯一标识比如时间戳 或者 UUID 或者订单消费 者消费 MQ 中的消息也可利用 MQ 的该 id 来判断,或者可按自己的规则生成一个全局唯一 id,每次消费消息时用该 id 先判断该消息是否已消费过。

    9.1.4 消费端幂等性保障

    ​ 在海量订单生成的业务高峰期,生产端有可能就会重复发生了消息,这时候消费端就要实现幂等性,这就意味着我们的消息永远不会被消费多次,即使我们收到了一样的消息。业界主流的幂等性有两种操作:

    ​ a. 唯一 ID+指纹码机制,利用数据库主键去重,。

    ​ b.利用 redis 的原子性去实现。

    ​ 利用redis执行setnx命令,天然具有幂等性。从而实现不重复消费

    9.2 优先级队列

    9.2.1 使用场景

    ​ 在我们系统中有一个订单催付的场景,我们的客户在软件下的订单,软件会及时将订单推送给我们,如果在用户设定的时间内未付款那么就会给用户推送一条短信提醒,很简单的一个功能对吧,但是,商家对我们来说,肯定是要分大客户和小客户的对吧,比如像大品牌的大商家一年起码能给我们创造很大的利润,所以理应当然,他们的订单必须得到优先处理,而曾经我们的后端系统是使用 redis 来存放的定时轮询,大家都知道 redis 只能用 List 做一个简简单单的消息队列,并不能实现一个优先级的场景,所以以订单量大了后采用 RabbitMQ 进行改造和优化,如果发现是大客户的订单给一个相对比较高的优先级, 否则就是默认优先级。

    9.2.2 代码中如何添加

    // 队列中添加优先级参数
    HashMap<String, Object> maps = new HashMap<>();
    //官方允许的值为0-255,此处设置为10,允许优先级范围为0-10,不要设置过大,浪费cpu与内存
    maps.put("x-max-priority", 10);
    
    channel.queueDeclare(QueueName.Priority_Queue.getName(), true, false, false, maps);
    
    // 消息中添加优先级 priority 后面就是优先级参数,越大越优先
    AMQP.BasicProperties properties =
      new AMQP.BasicProperties().builder().priority(i).build();
    channel.basicPublish("", QueueName.Priority_Queue.getName(), properties, (message + i).getBytes());
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    ​ 注意:要让队列实现优先级需要做的事情有如下事情:队列需要设置为优先级队列,消息需要设置消息的优先级,消费者需要等待消息已经发送到队列中才去消费因为,这样才有机会对消息进行排序

    9.2.3 实战代码

    1. 消息生产者

      /**
       * 优先级队列
       */
      public class Producer {
      
        public static void main(String[] args) throws Exception {
          //创建连接
          Connection connection = RabbitMqUtil.getConnection();
      
          //获取信道
          Channel channel = connection.createChannel();
          HashMap<String, Object> maps = new HashMap<>();
          //官方允许的值为0-255,此处设置为10,允许优先级范围为0-10,不要设置过大,浪费cpu与内存
          maps.put("x-max-priority", 10);
          
      channel.queueDeclare(QueueName.Priority_Queue.getName(), true, false, false, maps);
          String message = "hello word";
      
          for (int i = 0; i < 10; i++) {
      
            switch (i) {
              case 8:
              case 5:
              case 3: {
                AMQP.BasicProperties properties =
                  new AMQP.BasicProperties().builder().priority(i).build();
                channel.basicPublish("", QueueName.Priority_Queue.getName(), properties, (message + i).getBytes());
                System.out.println("消息已经发送");
              }
                break;
              default: {
                AMQP.BasicProperties properties =
                  new AMQP.BasicProperties().builder().priority(0).build();
                channel.basicPublish("", QueueName.Priority_Queue.getName(), null, (message + i).getBytes());
                System.out.println("消息已经发送");
              }
      
            }
      
      
          }
        }
      }
      
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 17
      • 18
      • 19
      • 20
      • 21
      • 22
      • 23
      • 24
      • 25
      • 26
      • 27
      • 28
      • 29
      • 30
      • 31
      • 32
      • 33
      • 34
      • 35
      • 36
      • 37
      • 38
      • 39
      • 40
      • 41
      • 42
      • 43
      • 44
    2. 消费者代码

      /**
       * 消费者
       */
      public class Consumer {
      
        public static void main(String[] args) throws Exception {
          Connection connection = RabbitMqUtil.getConnection();
          Channel channel = connection.createChannel();
      
          // 声明接收消息
          DeliverCallback deliverCallback = (consumerTag, message) -> {
            System.out.println(new String(message.getBody()));
          };
      
          //取消消息回调
          CancelCallback cancelCallback = consumerTag -> {
            System.out.println("消息被中断");
          };
      
      channel.basicConsume(QueueName.Priority_Queue.getName(), true, deliverCallback, cancelCallback);
        }
      }
      
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 17
      • 18
      • 19
      • 20
      • 21
      • 22
      • 23

    9.2.4 结果分析

    ​ 开启生产者代码,可以在UI中看到

    在这里插入图片描述

    打开消费者

    在这里插入图片描述

    可以看到消息按照优先级顺序排列消费。

    9.3 惰性队列

    9.3.1 使用场景

    ​ RabbitMQ 从 3.6.0 版本开始引入了惰性队列的概念。惰性队列会尽可能的将消息存入磁盘中,而在消费者消费到相应的消息时才会被加载到内存中,它的一个重要的设计目标是能够支持更长的队列,即支持更多的消息存储。当消费者由于各种各样的原因(比如消费者下线、宕机亦或者是于维护而关闭等)而致使长时间内不能消费消息造成堆积时,惰性队列就很有必要了。
    ​ 默认情况下,当生产者将消息发送到 RabbitMQ 的时候,队列中的消息会尽可能的存储在内存之中, 这样可以更加快速的将消息发送给消费者。即使是持久化的消息,在被写入磁盘的同时也会在内存中驻留一份备份。当 RabbitMQ 需要释放内存的时候,会将内存中的消息换页至磁盘中,这个操作会耗费较长的时间,也会阻塞队列的操作,进而无法接收新的消息。虽然 RabbitMQ 的开发者们一直在升级相关的算法,但是效果始终不太理想,尤其是在消息量特别大的时候。

    9.3.2 两种模式

    ​ 队列具备两种模式:default 和 lazy。默认的为 default 模式,在 3.6.0 之前的版本无需做任何变更。lazy模式即为惰性队列的模式,可以通过调用 channel.queueDeclare 方法的时候在参数中设置,也可以通过Policy 的方式设置,如果一个队列同时使用这两种方式设置的话,那么 Policy 的方式具备更高的优先级。 如果要通过声明的方式改变已有队列的模式的话,那么只能先删除队列,然后再重新声明一个新的。
    ​ 在队列声明的时候可以通过“x-queue-mode”参数来设置队列的模式,取值为“default”和“lazy”。下面示例中演示了一个惰性队列的声明细节:

    Map<String, Object> args = new HashMap<String, Object>(); 
    args.put("x-queue-mode", "lazy");
    channel.queueDeclare("myqueue", false, false, false, args);
    
    • 1
    • 2
    • 3

    9.3.3 内存开销对比

    在这里插入图片描述

    ​ 在发送一百万条消息,每条消息大概占用1KB的情况下,普通队列占用内存是1.2GB,而惰性队列仅仅占用1.5M(因为惰性队列内存只存储消息的id,消息具体是存放在磁盘上的)。


                                                                 (RabbitMQ 完)
                                                                   2022.08.05
                                                             恭喜小华喜提新车车牌
    
    • 1
    • 2
    • 3

    (内心)
    请添加图片描述

  • 相关阅读:
    代码随想录训练营第42天|01背包问题、LeetCode 416. 分割等和子集
    【技术干货】如何快速创建商用照明 OEM APP?
    【金九银十】343道Java面试真题整理,将每道经典题详汇
    uView 2.0:uni-app生态的利剑出鞘,引领UI框架新纪元
    DB2分区表详解
    Hadoop运行环境搭建(开发重点三)、在hadoop102安装JDK、配置JDK环境变量、测试JDK是否安装成功
    Fiddler配置及使用
    BootLoader为什么要分阶段?
    图解 Apache SkyWalking UI 的使用
    网络探索利器:深入理解 nc、ncat 和 telnet 的作用和用法
  • 原文地址:https://blog.csdn.net/qq_27331467/article/details/126180169