学习笔记—RabbitMQ

RabbitMQ中间件

0. 安装RabbitMQ

CentOS7 安装单机版 RabbitMQ
1、上传3个rpm包
socat-1.7.3.2-2.el7.x86_64.rpm
esl-erlang_23.2.3-1_centos_7_amd64.rpm
rabbitmq-server-3.8.22-1.el7.noarch.rpm

2、安装socat
yum localinstall -y socat-1.7.3.2-2.el7.x86_64.rpm

3、安装erlang
yum localinstall -y esl-erlang_23.2.3-1_centos_7_amd64.rpm

4、安装RabbitMQ
yum localinstall -y rabbitmq-server-3.8.22-1.el7.noarch.rpm

5、启动RabbitMQ
systemctl start rabbitmq-server

6、启动web控制台
rabbitmq-plugins enable rabbitmq_management

7、添加用户和密码(用户名et, 密码也是et)
rabbitmqctl add_user et et

8、设置角色(administrator)
rabbitmqctl set_user_tags et administrator

9、设置权限
rabbitmqctl set_permissions et -p / ".*" ".*" ".*"

1. 消息中间件

  1. 中间件概念:

    中间件是一种独立的系统软件服务程序,位于客户机服务器的操作系统之上,管理着计算资源和网络通信

    分布式应用系统可以借助这种软件在不同的技术之间共享资源。

  2. 消息中间件:

    支持在分布式系统之间发送和接收消息的软件。

2. 消息中间件使用场景

  1. 应用解耦
  2. 异步消息通信(重点)
  3. 流量削峰
  4. 日志处理(Kafka)

3. 消息中间件的发展

在这里插入图片描述

4. AMQP协议(Advanced Message Queue Protocol)

  • AMQP协议概念

    概念描述
    Broker接收和分发消息的应用,RabbitMQ Server就是Message Broker。
    Virtual Host为了在一个单独的代理上实现多个隔离的环境(用户、用户组、交换机、队列 等),AMQP 提供了一个虚拟主机(virtual hosts)的概念,当多个不同的用户使用同一个RabbitMQ时,可以划分出多个vhost,每个用户在自己的vhost创建exchange、queue等。
    Connection生产者、消费者和Broker之间的TCP连接。
    Channel管道,是在Connection内部建立的逻辑连接,它作为轻量级的Connection,极大减少了操作系统建立TCP连接的开销。
    Exchange交换机,用于接收生产者消息,根据分发规则,匹配绑定(Binding)的Routing Key,分发消息到队列(Queue)中去。
    Queue队列,消息最终被送到这里等待消费者取走。
    Binding用于描述消息队列与交换机之间的关系。一个绑定就是基于路由键(RoutingKey)将交换机(Exange)和消息队列(Queue)连接起来的路由规则。因此可以将交换器看成一个由绑定构成的路由表
    Routing Key路由规则,可用来确定如何路由一个特定消息。
    Message消息
    Publisher消息发布者
    Consumer消费者

在这里插入图片描述

5. RabbitMQ

5.1 RabbitMQ概述

​ RabbitMQ拥有成千上万的用户,是最受欢迎的开源消息代理服务器之一;

​ RabbitMQ是一个开源的AMQP(高级消息队列协议)实现,服务器端用Erlang语言编写,支持多种客户端,如:Python、Ruby、.NET、Java、JMS、C、PHP、ActionScript、XMPP、STOMP等。用于在分布式系统中存储、转发消息。

5.2 RabbitMQ支持的模式

5.3 RabbitMQ工作原理图


在这里插入图片描述

6. RabbitMQ简单模式(Hello World)

  • P是生产者。
  • C是消费者。
  • 中间的红色区域是一个队列,表示消费者保留的消息缓冲区。

6.1 RabbitMQ-API

6.1.1 发布者Publisher
package com.etoak.hello;

import com.rabbitmq.client.Channel;
import com.rabbitmq.client.Connection;
import com.rabbitmq.client.ConnectionFactory;

import java.io.IOException;
import java.util.concurrent.TimeoutException;

public class HelloPublisher {

    public static void main(String[] args) throws IOException, TimeoutException, InterruptedException {
        ConnectionFactory factory = new ConnectionFactory();
        factory.setHost("192.168.136.139");
        // 5672   15672-web控制台
        factory.setPort(5672);
        factory.setVirtualHost("/");
        factory.setUsername("et");
        factory.setPassword("et");
        Connection connection = factory.newConnection();
        Channel channel = connection.createChannel();

        // 声明队列(Broker上没有这个队列, 就创建; Broker上有这个队列, 就不重复创建)
        /*
            参数1: 队列名称
            参数2: 是否持久化队列
                   true: 持久化队列(Broker重启之后队列仍然存在)
                   false: 不持久化队列(Broker重启之后队列就删除了)
            参数3: 是否是排他队列(是否是独占队列)
                  true: 排他队列、独占队列(只有创建这个队列的Connection可以访问这个队列)
                        区分Connection, 不区分同一个Connection创建的Channel
                  false: 不排他, 不独占队列
            参数4: 是否自动删除队列
                  true: 自动删除(前提: 队列的消息被消费了, 并且所有的消费者都关闭了与这个队列的连接)
                  false: 不自动删除
            参数5: 消息队列参数 Map<String, Object>
                  队列上消息的过期时间: x-message-ttl
                  消息队列的长度: x-max-length
                  消息队列的过期时间: x-expires
                  ...
         */
        channel.queueDeclare("hello",
                false,
                false,
                false,
                null);

        /*
           发送消息
           参数1: 交换机: 空字符串表示使用"默认交换机"(AMQP default)
           参数2: routing key - 交换机与队列绑定时使用的routing key(默认交换机使用队列名称与队列进行绑定)
           参数3: 消息参数
           参数4: 消息体 字节数组
         */
        channel.basicPublish("",
                "hello",
                null,
                "Hello RabbitMQ".getBytes());

        channel.close();
        connection.close();
        System.out.println("消息发送结束!");
    }

}

6.1.2 消费者Consumer
package com.etoak.hello;

import com.rabbitmq.client.Channel;
import com.rabbitmq.client.Connection;
import com.rabbitmq.client.ConnectionFactory;

import java.io.IOException;
import java.util.concurrent.TimeoutException;

public class HelloConsumer {

    public static void main(String[] args) throws IOException, TimeoutException {
        ConnectionFactory factory = new ConnectionFactory();
        factory.setHost("192.168.136.139");
        factory.setPort(5672);
        factory.setVirtualHost("/");
        factory.setUsername("et");
        factory.setPassword("et");

        Connection connection = factory.newConnection();
        Channel channel = connection.createChannel();

        /**
         * 参数1: 队列
         * 参数2: 消息确认方式, true表示自动确认
         * 参数3: 消息送达之后的回调
         * 参数4: 取消消费后的回调
         */
        channel.basicConsume("hello", true, (consumerTag, message) -> {
            String msg = new String(message.getBody());
            System.out.println("收到消息:" + msg);
        }, consumerTag -> {
        });
    }
}

6.1.3 RabbitUtil
package com.etoak.util;

import com.rabbitmq.client.Channel;
import com.rabbitmq.client.ConnectionFactory;

public class RabbitUtil {

    private static final ConnectionFactory factory = new ConnectionFactory();
    static {
        factory.setHost("192.168.136.139");
        factory.setPort(5672);
        factory.setVirtualHost("/");
        factory.setUsername("et");
        factory.setPassword("et");
    }

    public static Channel createChannel() throws Exception{
        return factory.newConnection().createChannel();
    }

}
6.1.3 轮询分发消费者
package com.etoak.work01;

import com.etoak.util.RabbitUtil;
import com.rabbitmq.client.Channel;

public class Consumer02 {

    public static void main(String[] args) throws Exception {
        Channel channel = RabbitUtil.createChannel();
        // true代表自动确认
        channel.basicConsume("work01", true, (consumerTag, message) -> {
            System.out.println("Consumer02==>" + new String(message.getBody()));
            try {
                Thread.sleep(5000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }, consumerTag -> {
        });
    }
}

6.1.4 公平分发消费者
package com.etoak.work02;

import com.etoak.util.RabbitUtil;
import com.rabbitmq.client.Channel;

public class Consumer04 {

    public static void main(String[] args) throws Exception {
        Channel channel = RabbitUtil.createChannel();

        // 取一条消息
        // channel.basicQos(1) 可以让消费者按照公平分发机制处理消息,不设置则默认采用轮询分发机制。
        channel.basicQos(1);
		// false代表手动确认信息
        channel.basicConsume("work02", false, (consumerTag, message) -> {
            System.out.println("Consumer04==>" + new String(message.getBody()));
            try {
                Thread.sleep(5000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            // 手动确认消息
            /*
            * false 参数表示是否拒绝将该消息重新放回队列中。
            * 当 false 参数为 false 时,表示告知 RabbitMQ 无需将消息重新放回队列,
            * 即消息被正确处理并应被认为是已消费的。
            */
            channel.basicAck(message.getEnvelope().getDeliveryTag(), false);
        }, consumerTag -> {});
    }
}

7. RabbitMQ工作队列模式(Work Queue)

​ 在使用消息系统时,一般情况下生产者往队列里插入数据的速度是比较快的,但是消费者消费数据往往涉及到一些业务逻辑处理导致速度跟不上生产者生产数据。因此,如果一个队列只有一个消费者的话,很容易导致大量的消息堆积在队列里,这时,就可以使用工作队列,这样一个队列可以有多个消费者同时消费数据。

​ 当队列有多个消费者时,消息会被哪个消费者消费呢?这里主要有两种模式:

  1. 轮询分发:一个消费者消费一条,按均分配;
  2. 公平分发:根据消费者的消费能力进行公平分发,处理快的处理的多,处理慢的处理的少;按劳分配;

7.1 轮询分发

​ 一个消费者消费一条,平均分配;

7.2 公平分发

​ 根据消费者的消费能力进行公平分发,处理快的处理的多,处理慢的处理的少;

  1. 消费者需要手动确认消息(basicAck)
  2. 设置消费者每次取一条消息,channel.basicQos(1),这也是在不使用注解的情况下两者的区别

7.3 两者差异

公平分发和轮询分发是两种不同的消息分发机制,它们在代码上的区别主要体现在消息监听的配置上。下面是它们在代码中的区别:

  1. 公平分发(Fair Dispatch):

在使用 @RabbitListener 注解时,默认采用的是公平分发机制。具体体现在:

  • 单个消费者处理一条消息完毕后,才会接收到下一条消息。
  • 默认情况下,不需要额外的配置代码,即可实现公平分发。

示例代码:

@Component
public class RabbitConsumer {
    
    @RabbitListener(queues = "my-queue")
    public void consumeMessage(String message) {
        // 处理消息
    }
}
  1. 轮询分发(Round Robin):

要使用轮询分发机制,需要进行额外的配置。具体体现在:

  • 创建自定义的消息监听容器工厂,并进行相应的配置,如设置 AcknowledgeMode.AUTO(自动确认消息)和 setDefaultRequeueRejected(false)(禁用消息拒绝重入队列)。
  • 在使用 @RabbitListener 注解时,指定使用自定义的消息监听容器工厂。

示例代码:

@Configuration
public class RabbitConfig {
    
    @Bean
    @Primary
    public SimpleRabbitListenerContainerFactory roundRobinContainerFactory(ConnectionFactory connectionFactory) {
        SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory();
        factory.setConnectionFactory(connectionFactory);
        factory.setAcknowledgeMode(AcknowledgeMode.AUTO);
        factory.setDefaultRequeueRejected(false);
        return factory;
    }
    
}

@Component
public class RabbitConsumer {
    
    @RabbitListener(queues = "my-queue", containerFactory = "roundRobinContainerFactory")
    public void consumeMessage(String message) {
        // 处理消息
    }
}

总结:

  • 公平分发不需要额外的配置,使用默认的 @RabbitListener 注解即可。
  • 轮询分发需要创建自定义的消息监听容器工厂,并通过 containerFactory 属性指定使用该容器工厂的 @RabbitListener 注解。

8. 消费者确认模式(ACK 机制)

  1. 什么是消费者消息确认?

    为了确保消息不会丢失,RabbitMQ 支持 消息确认,消费者发回确认消息,告诉 RabbitMQ 特定消息已被接收、处理,并且 RabbitMQ可以自由删除它。

  2. RabbitMQ提供了两种确认模式

    • 自动确认

    • 手动确认

8.1 自动确认

​ 自动确认表示消息发送给消费者后立即确认,但存在丢失消息的可能,如果消费端消费逻辑抛出异常,也就是消费端没有成功处理这条消息,那么就相当于丢失了消息;

8.2 手动确认

​ 如果一个消费者在处理消息出现了网络不稳定、服务器异常等现象,那么就不会有ACK反馈,RabbitMQ会认为这个消息没有正常消费,此时,RabbitMQ会将消息重新放入队列中。

​ 开启手动确认后,如果队列有多个消费者,当出现异常情况后,RabbitMQ会立即将这个消息推送给另一个在线的消费者,这种机制保证了在消费方不会丢失消息。

8.3 忘记确认消息

​ 忘记进行消息确认是一个简单的错误,但后果很严重,当客户端(消费者)退出时,消息将被重新传送(这可能看起来像随机重新传送)到RabbitMQ队列,RabbitMQ会消耗越来越多的内存,因为它无法释放任何未确认的消息,此时就会造成内存泄漏。

9. RabbitMQ交换机

​ RabbitMQ消息传递模型的核心思想是:生产者从不直接向队列发送任何消息。实际上,生产者甚至根本不知道消息是否会被传送到队列,相反,生产者只能将消息发送到交换机。交换机一方面接收来自生产者的消息,另一方面将它们推送到队列中。

​ 交换机必须确切地知道如何处理它收到的消息,它应该发送到特定队列中?还是应该发送到许多队列中?或者它应该被丢弃?这由交换机类型决定。

9.1 交换机类型

​ 交换机类型:fanoutdirecttopicheaders

9.2 fanout交换机(发布-订阅模式)---- 广播

教程地址

​ fanout交换非常简单,它仅将收到的所有消息广播到所有队列

​ fanout交换机不设置路由键,我们只需要将队列绑定到交换机上,生产者发送到fanout交换机的消息都会被转发到与该交换机绑定的所有队列上,很像子网广播,每台子网内的主机都能获得了一份消息

9.2.1 fanout交换机实例

9.3 direct交换机(路由模式)

​ 交换机和队列之间产生关联需要使用Binding(Routing key),可理解为交换机只向与其绑定的队列中发送消息,这里的绑定关系需要使用Routing Key;

​ 相对比fanout交换机,direct交换机在其基础上,多加了一层秘钥(Routing Key)。

9.4 死信交换机

  1. 死信交换机官网

  2. 按照前边讲的内容:生产者(Producer)会将消息投递到交换机(Exchange),消费者(Consumer)从队列(Queue)中取出消息进行消费;

    但有些时候由于特定的原因导致队列中的消息无法被消费,这样的消息如果没有后续的处理,就变成了"死信"。RabbitMQ可以配置死信队列接收这些死信消息,如果不配置死信队列,那么这条消息将会被丢弃。

  3. 死信出现的原因:

    1. 消息被消费者拒绝(basicReject或basicNack,并且没有重新入队)

    2. 消息过期(The message expires due to per-message TTL

    3. 由于队列达到最大长度,<队首>的消息被丢弃

      注意:队列满了之后,如果继续向队列中发送消息,那么队首的消息就会成为死信

1.1 消息的TTL(Time To Live) 和 队列的TTL
  1. 消息的TTL 和 队列的TTL官网

  2. 队列中消息的TTL:创建队列时统一设置队列上所有消息的过期时间(x-message-ttl)

    // 原生API设置队列上消息的过期时间
    Map<String, Object> args = new HashMap<String, Object>();
    args.put("x-message-ttl", 60000);
    channel.queueDeclare("myqueue", false, false, false, args);
    
    // Spring Boot中设置队列上消息的过期时间
    @Bean
    public Queue normalQueue() {
      // 设置死信交换机和死信routing key
      Map<String, Object> args = new HashMap<>();
      // 队列上的消息的过期时间
      args.put("x-message-ttl", 1000 * 10);
      return new Queue(NORMAL_QUEUE, true, false, false, args);
    }
    
  3. 队列的过期时间:创建队列时设置(x-expires)

    Map<String, Object> args = new HashMap<String, Object>();
    // 80秒后队列直接被Broker删除, 队列中的消息也会被删除
    args.put("x-expires", 1000 * 80);
    channel.queueDeclare("myqueue", false, false, false, args);
    
  4. 消息的TTL:发送消息时设置消息过期时间(message.getMessageProties().setExpiration())

    byte[] messageBody = "Hello, world!".getBytes();
    AMQP.BasicProperties properties = new AMQP.BasicProperties()
      											.builder()
                                      .expiration("60000")
                                      .build();
    channel.basicPublish("my-exchange", "routing-key", properties, messageBody);
    
    rabbitTemplate.convertAndSend(DeadLetterConfig.NORMAL_EXCHANGE,
            DeadLetterConfig.NORMAL_KEY,
            msg,
            message -> {
              // 设置消息的过期时间  单位是ms
              String expire = String.valueOf(1000 * second);
              message.getMessageProperties().setExpiration(expire);
              return message;
            }
          );
    
1.2 死信队列 - 测试消息过期(消息的TTL到期)

package com.etoak.config;

import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.DirectExchange;
import org.springframework.amqp.core.Queue;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.HashMap;

@Configuration
public class DeadLetterConfig {
    // 交换机
    public static final String NORMAL_EXCHANGE = "x.normal";
    // 队列
    public static final String NORMAL_QUEUE = "queue.normal";
    // 密钥
    public static final String NORMAL_KEY = "normal";
    // 死信交换机
    public static final String DEAD_EXCHANGE = "x.dead";
    // 死信队列
    public static final String DEAD_QUEUE = "queue.dead";
    // 死信密钥
    public static final String DEAD_KEY = "dead";

    @Bean
    public DirectExchange normalExchange() {
        return new DirectExchange(NORMAL_EXCHANGE);
    }

    @Bean	
    public Queue normalQueue() {
        // 设置队列参数
        HashMap<String, Object> args = new HashMap<>();
        // 设置消息过期时间 | 队列最大长度 | 拒绝消息 
        // args.put("x-max-length", 2);
        args.put("x-message-ttl", 1000 * 10);
        // 设置死信交换器
        args.put("x-dead-letter-exchange", DEAD_EXCHANGE);
        // 设置死信路由key
        args.put("x-dead-letter-routing-key", DEAD_KEY);
        return new Queue(NORMAL_QUEUE, true, false, false, args);
    }

    @Bean
    public Binding normalBinding(DirectExchange normalExchange, Queue normalQueue) {
        return BindingBuilder.bind(normalQueue).to(normalExchange).with(NORMAL_KEY);
    }

    @Bean
    public DirectExchange deadExchange() {
        return new DirectExchange(DEAD_EXCHANGE);
    }

    @Bean
    public Queue deadQueue() {
        return new Queue(DEAD_QUEUE);
    }

    @Bean
    public Binding deadBinding(DirectExchange deadExchange, Queue deadQueue) {
        return BindingBuilder.bind(deadQueue).to(deadExchange).with(DEAD_KEY);
    }
}

2. 延迟交换机

​ 延迟队列存储的消息一般都是延时消息,所谓延时消息是指当消息被发送以后,并不想让消费者立即消费消息,而是等待指定时间后,才允许消费者来消费这条消息;

2.1 应用场景
  1. 用户下单后30分钟未支付,使用延迟队列功能取消超时的订单
  2. 注册一个网站,24小时(几天)内未登录,发送邮件或短信通知
2.2 安装延迟交换机插件
  1. 插件安装目录:/usr/lib/rabbitmq/lib/rabbitmq_server-3.8.22/plugins

  2. rabbitmq_delayed_message_exchange-3.8.0.ez插件上传到插件安装目录

  3. 安装命令,在plugins目录下执行:

    [root@192 plugins]# rabbitmq-plugins enable rabbitmq_delayed_message_exchange
    
  4. 安装完成之后,RabbitMQ的Web控制台Exchanges选项卡下会出现一个交换机 ,如下图所示

2.3 配置延迟队列
@Configuration
public class DelayedConfig {

  public static final String EXCHANGE = "x.delayed";

  public static final String QUEUE = "q.delayed";

  public static final String KEY = "delay";

  @Bean
  public CustomExchange customExchange() {
    Map<String, Object> args = new HashMap<>();
    args.put("x-delayed-type", "direct");
      // 交换机名称  Routing key  是否持久化  是否自动删除  参数
    return new CustomExchange(EXCHANGE, "x-delayed-message", true, false, args);
  }

  @Bean
  public Queue queue() {
    return new Queue(QUEUE);
  }

  @Bean
  public Binding binding(Queue queue, CustomExchange customExchange) {
    return BindingBuilder.bind(queue).to(customExchange).with(KEY).noargs();
  }
}

10. Topic交换机

​ topic交换机是在direct交换机的基础上,支持了对routing key的通配符匹配(*号和#号),以满足更加复杂的消息分发场景。

*:精确匹配一个词(一个字母也行、一个单词也行)

#:匹配零个或多个词(多个字母也行、多个单词也行,都以**“点”**分割)

注:

  • 如果队列绑定的路由键是#,那么这个队列可以接收所有数据,就类似于fanout(广播)交换机了
  • 如果队列绑定的路由键键当中没有#*,那么是direct(路由模式)交换机了

10.1 topic交换机实例

  • TopicConfig
package com.etoak.config;

import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.Queue;
import org.springframework.amqp.core.TopicExchange;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class TopicConfig {

    public static final String EXCHANGE = "x.topic";

    public static final String QUEUE_3 = "Q3";

    public static final String QUEUE_4 = "Q4";

    public static final String ORANGE = "*.orange.*";

    public static final String RABBIT = "*.*.rabbit";

    public static final String LAZY = "lazy.#";

    @Bean
    public TopicExchange topicExchange() {
        return new TopicExchange(EXCHANGE);
    }

    @Bean
    public Queue queue3() {
        return new Queue(QUEUE_3);
    }

    @Bean
    public Queue queue4() {
        return new Queue(QUEUE_4);
    }

    @Bean
    public Binding queue3Binding(Queue queue3, TopicExchange topicExchange) {
        return BindingBuilder.bind(queue3).to(topicExchange)
            .with(ORANGE);
    }

    @Bean
    public Binding rabbitBinding(Queue queue4, TopicExchange topicExchange) {
        return BindingBuilder.bind(queue4).to(topicExchange)
            .with(RABBIT);
    }

    @Bean
    public Binding lazyBinding(Queue queue4, TopicExchange topicExchange) {
        return BindingBuilder.bind(queue4).to(topicExchange)
            .with(LAZY);
    }

}

  • 测试类
package com.etoak;

import com.etoak.config.FanoutConfig;
import com.etoak.config.TopicConfig;
import org.junit.jupiter.api.Test;
import org.springframework.amqp.core.TopicExchange;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
public class RabbitTest {

    @Autowired
    RabbitTemplate rabbitTemplate;

    @Test
    public void testFanout() {
        rabbitTemplate.convertAndSend(FanoutConfig.EXCHANGE, "", "这是一条广播消息!");
    }

    @Test
    public void testTopic() {
        // 同时匹配 *.orange.* 和 *.*.rabbit
        rabbitTemplate.convertAndSend(TopicConfig.EXCHANGE,
            "an.orange.rabbit",
            "一只橙色的兔子");

        // 仅匹配 *.*.rabbit
        rabbitTemplate.convertAndSend(TopicConfig.EXCHANGE,
            "big.white.rabbit",
            "大白兔");

        // 同时匹配 *.orange.*、  *.*.rabbit、  lazy.#
        rabbitTemplate.convertAndSend(TopicConfig.EXCHANGE,
            "lazy.orange.rabbit",
            "懒兔子");
    }

}

11. headers交换机

​ headers交换机与fanout、direct、topic交换机不同,它时通过匹配AMQP消息的header,而不是路由键。

​ 在实际开发中这种方式很少使用。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值