Spring Boot集成RabbitMQ以及RabbitMQ的工作原理

RabbitMQ的工作原理

RabbitMQ介绍 :

MQ全称为Message Queue,即消息队列;RabbitMQ由erlang语言开发,基于AMQP协议实现的消息队列; :RabbitMQ的官网
常见的其它消息队列 : ActiveMQ,ZeroMQ,Kafka,MetaMQ,RocketMQ、Redis(也可做消息队列)

RabbitMQ优点介绍 :

  • 使用简单,功能强大(SpringBoot默认集成)
  • 基于AMQP协议
  • 社区活跃,文档完善
  • 基于erlang语言,高并发性能好

RabbitMQ工作原理 :

在这里插入图片描述
组成部分说明 :
Producter : 消息生产者,负责将消息发送到MQ
Consumer : 消息消费者,接收MQ转发的消息
Connection : 连接通道,包含信道channel
Broker : 消息队列服务进程,包含Exchange和Queue
Exchange : 消息队列交换机,按一定的规则将消息路由转发到某个队列,对消息进行过滤
Queue : 消息队列;存储消息的队列,消息到达队列并转发给指定的消费方

消息发布流程 :
发布消息

  • 生产者和Broker建立TCP连接
    2. 生产者和Broker建立通道
    3. 生产者通过通道将消息发送给Broker,由Exchange将消息进行转发
    4. Exchange将消息转发到指定Queue

接收消息

  • 消费者和Broker建立TCP连接
    2. 消费者和Broker建立通道
    3. 消费者监听指定的Queue
    4. 当有消息到达Queue时Broker默认将消息推送给消费者。
    5. 消费者接收到消息。

消息队列的应用场景 :

  • 异步处理任务
  • 应用程序解耦
  • 流量削峰

RabbitMQ的工作模式 :

常用的工作模式 :

  • Work Queue(工作队列模式)
  • Publish/Subscribe(发布订阅模式)
  • Routing(路由模式)
  • Topics(通配符模式)
  • Header(头交换机模式)
  • RPC(远程调用模式)

1. Work Queues(工作模式) :
模型图:
在这里插入图片描述
工作队列模式特点 :

  1. 一条消息只会被一个消费者接收
  2. 采用轮询的方式将消息发送给消费者
  3. 消费者在处理完当前消息时,才会收到下一条消息

2. Publish/Subscribe(发布订阅模式) :
模型图 :
在这里插入图片描述
发布订阅模式特点 :
4. 每个消费者监听自己的队列
5. 生产者将消息发送给Broker,由交换机将消息发送到每个绑定此交换机的队列
6.
Work Queues与Publish/Subscribe的区别 :
不同点 :

  1. Work Queues不需要定义交换机,而Publish/Subscribe需要定义
  2. Publish/Subscribe生产方面向交换机发送消息,Work Queues面向队列(底层使用默认交换机)
  3. Publish/Subscribe需要设置队列和交换机的绑定,Work Queues不需要(队列绑定默认交换机)

相同点 :
两者实现的发布/订阅的效果是一样的,多个消费端监听同一个队列不会重复消费消息

3. Routing(路由模式) :
模型图 :
多重绑定 (一个key绑定多个队列)
在这里插入图片描述
direct:
在这里插入图片描述
特点 :

  1. 生产者将消息发送到broker,交换机根据routingKey将消息发送到指定队列
  2. 消费者根据binding关键字(routingkey)监控相应的队列

Routing模式和Publish/Subscribe模式区别 :
Routing模式需要在生产者发送消息的时候设置routingKey,交换机会根据routingKey将消息发送到指定的队列

4. Topics(通配符模式):
模型图 :
在这里插入图片描述
Topic模式特点 :

  1. 生产者将消息发送到broker,交换机根据routingKey将消息发送到指定队列
  2. 消费者监控带有通配符routingKey的相应队列(通配符指的是消费者 如 P :log.sms C :log.*)

通配符规则 :

  1. 符号“ * ” :只能匹配一个单词; 如 lazy. * 可以匹配lazy.info或者lazy.irs
  2. 符号“ # ”: 匹配一个或者多个词lazy.# 可以匹配lazy.irs或者lazy.irs.cor

Topic模式的功能更加强大,可是实现Routing、publish/subscirbe模式的功能

5. Headers(头交换机模式) :
模型图 :
在这里插入图片描述
特点 :
生产者将消息发送到broker,交换机根据request message中的header进行匹配,将消息发送到指定队列

匹配规则(x-match) :
all : 默认规则;一个传送消息header里的键值对和exchange里的键值对全部匹配才可以路由到对应交换机
any : 一个传送消息header里的键值对和exchange里的键值对有一对匹配,就可以路由到对应交换机

headers模式和topics模式的区别:

  1. topic模式的路由值基于routingKey,headers模式的路由值基于消息的header数据
  2. topic交换机路由键只有是字符串,headers可以是多种类型

关于全链路消息不丢失 :

从生产者和消费者两个方面来考虑:
生产者 :队列,消息都进行持久化操作

  1. 队列持久化:在声明队列的时候,将第二个参数设置完true,则可以完成队列持久化的设置,队列的信息会保存到磁盘中。当rabbitMQ重启,则会恢复之前的存在的队列。
/**
 * queue : 当前操作的队列. 设置队列名称即可
 * durable: 当前队列是否开启持久化. 如果为true.当前mq服务重启之后,队列仍然存在
 * exclusive: 当前队列是否独占此连接
 * autoDelete: 当前队列是否自动删除
 * arguments: 队列参数
 */
channel.queueDeclare(QUEUE,true,false,false,null);

  1. 消息持久化:在生产者发送消息时,可以通过第三个参数设置消息属性,将消息声明为持久化。则可以完成将消息写入磁盘。当rabbitmq重启,在队列恢复的同时也会一并恢复该队列中设置持久化属性且未被消费的消息。
/**
 * exchange: 交换机. 对于当前操作使用默认交换机 ""
 * routingKey: 路由key. 如果当前使用默认交换机, routingKey的值就是当前队列的名称
 * props: 参数
 * body: 消息体
 */
String message = "hello RabbitMQ";
channel.basicPublish("",QUEUE, MessageProperties.PERSISTENT_TEXT_PLAIN,message.getBytes());

  1. RabbitMq的数据保护机制 :
    针对生产者投递数据丢失,有两种解决机制,事务机制和confirm机制
    事务机制 :
String message = "Hello RabbitMQ";
try {
    //txSelect():开启事务
    channel.txSelect();
    for (int i = 0; i < 5; i++) {
        channel.basicPublish(EXCHANGE_NAME, ROUTING_KEY, MessageProperties.PERSISTENT_TEXT_PLAIN, (message + i).getBytes("UTF-8"));
    }
    //txCommit():提交事务
    channel.txCommit();
} catch (Exception e) {
    //txRollback():事务回滚
    channel.txRollback();
}

事务会造成性能的急剧下降,不建议使用;

confirm机制 :
confirm模式需要基于channel进行设置, 一旦某条消息被投递到队列之后,消息队列就会发送一个确认信息给生产者,如果队列与消息是可持久化的, 那么确认消息会等到消息成功写入到磁盘之后发出.
confirm的性能高,主要得益于它是异步的.生产者在将第一条消息发出之后等待确认消息的同时也可以继续发送后续的消息.当确认消息到达之后,就可以通过回调方法处理这条确认消息. 如果MQ服务宕机了,则会返回nack消息. 生产者同样在回调方法中进行后续处理。

public static void main(String[] args)throws Exception {
        ConnectionFactory factory = new ConnectionFactory();
        factory.setUsername("guest");
        factory.setPassword("guest");
        factory.setVirtualHost("/");
        factory.setHost("localhost");
        factory.setPort(5672);
 
        Connection conn = factory.newConnection();
        Channel channel = conn.createChannel();
        String exchangeName = "exchangeName";
        String routingKey = "routingKey";
        String queueName = "queueName";
        channel.exchangeDeclare(exchangeName,"direct",true);
        channel.queueDeclare(queueName,true,false,false,null);
        channel.queueBind(queueName,exchangeName,routingKey);
        byte [] messageBodyBytes = "hello confirm" .getBytes();
        //发送之前
        //将消息写入到某一个存储空间,用来防止发送消息失败
        try{
            channel.confirmSelect();    // 开启confirm模式
            long start  = System.currentTimeMillis();
            //设置监听器
            channel.addConfirmListener(new ConfirmListener() {
                public void handleAck(long deliveryTag, boolean multiple) throws IOException {
                    //删除之前临时存储空间中的消息
                    System.out.println("ack:deliveryTag:"+deliveryTag+",multiple:"+multiple);
                }
                public void handleNack(long deliveryTag, boolean multiple) throws IOException {
                    //从临时存储空间中拿出刚才的消息,并重新发送
                    System.out.println("nack:deliveryTag:"+deliveryTag+",multiple:"+multiple);
                }
            });
            
            for(int i = 0;i<100;i++) {   //循环发消息
                channel.basicPublish(exchangeName, routingKey, MessageProperties.PERSISTENT_TEXT_PLAIN, messageBodyBytes);
            }
        }catch (Exception e){
            e.printStackTrace();
        }finally {
            channel.close();
            conn.close();
        }
    }

消费者 :

  1. 自动ack
    当消费者成功接收到消息之后,可以自动的向rabbitMQ返回一个应答信息,通知rabbitMQ此条消息已经被成功的接收,当rabbitMQ接收到这条消息之后,则会将此条消息删除。这叫做自动ACK(自动应答)
/**
 * queue : 队列名称
 * autoAck: 是否自动应答
 *callback: 消费者
 */
channel.basicConsume(QUEUE_INFORM_EMAIL,true,consumer);

  1. 手动ack
    当消费者接收到消息,不会马上自动向消息队列发送应答消息,而是需要开发人员手动编码发送应答消息, 从而保证消息队列不会自动删除这条消息,而是等到消费者返回ACK确认消息才会进行删除
DefaultConsumer consumer = new DefaultConsumer(finalChannel){
    @Override
    public void handleDelivery(String consumerTag, Envelope envelope, AMQP.BasicProperties properties, byte[] body) throws IOException {

        String value = new String(body,"utf-8");
        try{
            //仓储服务业务
            //调用物流系统
            if(调用成功)//通知消息队列删除此消息
            finalChannel.basicAck(envelope.getDeliveryTag(),false);else{
                //重新调用
            }
        }catch(Exception e){
            //特定异常特定处理
        }
    }
};
//将第二个参数设置为false,则表明开启手动应答
channel.basicConsume(QUEUE,false,consumer);

ACK工作原理 :

当定义好一个消费者实例,并且让它监听消息队列之后, 消费者实例就会将自身注册到rabbitMQ.这样可以保证消息队列自身是知道当前有多少消费者实例存在。
在这里插入图片描述
rabbitMQ会通过自身内部的delivery方法将生产者发送的消息投递到相对应的消费者实例上. 在投递的时候,会给当前的消息加上一个消息的唯一标识(deliveryTag). 换句话说,就是可以通过这个唯一标识确定到具体的某一个消息. 消费者实例会通过connection对象中的channel进行与rabbitMQ的通信.
在这里插入图片描述
此时,当消费者实例成功使用完消息之后,就会通过手动ack方式先将这条消息的唯一标识返回到通道, 然后通道再把这条这个标识信息返回给rabbitMQ, 当rabbitMQ接到这个标识信息之后,则可以将相对应的消息进行删除.
这里需要注意一个问题: delivery tag仅仅在一个channel中是唯一的,换句话说,不同的channel中可以存在相同的delivery tag值. 所以在进行收到ack消息的时候, 务必保证接收消息的channel与返回ack消息的channel是同一个。
在这里插入图片描述

RabbitMQ消息中间件的应用场景

1. 异步处理:

场景说明:用户注册后,需要发注册邮件和注册短信,传统的做法有两种1.串行的方式;2.并行的方式
(1)串行方式:将注册信息写入数据库后,发送注册邮件,再发送注册短信,以上三个任务全部完成后才返回给客户端。 这有一个问题是,邮件,短信并不是必须的,它只是一个通知,而这种做法让客户端等待没有必要等待的东西.
在这里插入图片描述
(2)并行方式:将注册信息写入数据库后,发送邮件的同时,发送短信,以上三个任务完成后,返回给客户端,并行的方式能提高处理的时间。
在这里插入图片描述
(3)消息队列 :
引入消息队列后,把发送邮件,短信不是必须的业务逻辑异步处理
在这里插入图片描述
由此可以看出,引入消息队列后,用户的响应时间就等于写入数据库的时间+写入消息队列的时间(可以忽略不计),引入消息队列后处理后,响应时间是串行的3倍,是并行的2倍。

2. 应用解耦:
场景:双11是购物狂节,用户下单后,订单系统需要通知库存系统,传统的做法就是订单系统调用库存系统的接口.
在这里插入图片描述
这种做法有一个缺点:

  1. 当库存系统出现故障时,订单就会失败。
  2. 订单系统和库存系统高耦合.

引入消息队列:
在这里插入图片描述
3. 订单系统:用户下单后,订单系统完成持久化处理,将消息写入消息队列,返回用户订单下单成功。
4. 库存系统:订阅下单的消息,获取下单消息,进行库操作。
就算库存系统出现故障,消息队列也能保证消息的可靠投递,不会导致消息丢失。

3. 流量削峰:
流量削峰一般在秒杀活动中应用广泛
场景:秒杀活动,一般会因为流量过大,导致应用挂掉,为了解决这个问题,一般在应用前端加入消息队列。
作用:
5. 可以控制活动人数,超过此一定阀值的订单直接丢弃(我为什么秒杀一次都没有成功过呢^^)
6. 可以缓解短时间的高流量压垮应用(应用程序按自己的最大处理能力获取订单)
7. 在这里插入图片描述

  1. 用户的请求,服务器收到之后,首先写入消息队列,加入消息队列长度超过最大值,则直接抛弃用户请求或跳转到错误页面.
  2. 秒杀业务根据消息队列中的请求信息,再做后续处理.

各种消息中间件性能的比较:

  1. TPS比较 一ZeroMq 最好,RabbitMq 次之, ActiveMq 最差。
  2. 持久化消息比较—zeroMq不支持,activeMq和rabbitMq都支持。持久化消息主要是指:MQ down或者MQ所在的服务器down了,消息不会丢失的机制。
  3. 可靠性、灵活的路由、集群、事务、高可用的队列、消息排序、问题追踪、可视化管理工具、插件系统、社区—RabbitMq最好,ActiveMq次之,ZeroMq最差。
  4. 高并发—从实现语言来看,RabbitMQ最高,原因是它的实现语言是天生具备高并发高可用的erlang语言。

综上所述:RabbitMQ的性能相对来说更好更全面,是消息中间件的首选。

Spring Boot整合RabbitMQ实现

第一步:导入maven依赖

<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-amqp</artifactId>
	<version>1.5.2.RELEASE</version>
</dependency>

第二步:在application.properties文件当中引入RabbitMQ基本的配置信息

#对于rabbitMQ的支持
spring.rabbitmq.host=127.0.0.1
spring.rabbitmq.port=5672
spring.rabbitmq.username=guest
spring.rabbitmq.password=guest

第三步:编写RabbitConfig类,类里面设置很多个EXCHANGE,QUEUE,ROUTINGKEY,是为了接下来的不同使用场景。

/**
Broker:它提供一种传输服务,它的角色就是维护一条从生产者到消费者的路线,保证数据能按照指定的方式进行传输, 
Exchange:消息交换机,它指定消息按什么规则,路由到哪个队列。 
Queue:消息的载体,每个消息都会被投到一个或多个队列。 
Binding:绑定,它的作用就是把exchange和queue按照路由规则绑定起来. 
Routing Key:路由关键字,exchange根据这个关键字进行消息投递。 
vhost:虚拟主机,一个broker里可以有多个vhost,用作不同用户的权限分离。 
Producer:消息生产者,就是投递消息的程序. 
Consumer:消息消费者,就是接受消息的程序. 
Channel:消息通道,在客户端的每个连接里,可建立多个channel.
*/
@Configuration
public class RabbitConfig {
 
    private final Logger logger = LoggerFactory.getLogger(this.getClass());
 
    @Value("${spring.rabbitmq.host}")
    private String host;
 
    @Value("${spring.rabbitmq.port}")
    private int port;
 
    @Value("${spring.rabbitmq.username}")
    private String username;
 
    @Value("${spring.rabbitmq.password}")
    private String password;
 
 
    public static final String EXCHANGE_A = "my-mq-exchange_A";
    public static final String EXCHANGE_B = "my-mq-exchange_B";
    public static final String EXCHANGE_C = "my-mq-exchange_C";
 
 
    public static final String QUEUE_A = "QUEUE_A";
    public static final String QUEUE_B = "QUEUE_B";
    public static final String QUEUE_C = "QUEUE_C";
 
    public static final String ROUTINGKEY_A = "spring-boot-routingKey_A";
    public static final String ROUTINGKEY_B = "spring-boot-routingKey_B";
    public static final String ROUTINGKEY_C = "spring-boot-routingKey_C";
 
    @Bean
    public ConnectionFactory connectionFactory() {
        CachingConnectionFactory connectionFactory = new CachingConnectionFactory(host,port);
        connectionFactory.setUsername(username);
        connectionFactory.setPassword(password);
        connectionFactory.setVirtualHost("/");
        connectionFactory.setPublisherConfirms(true);
        return connectionFactory;
    }
 
    @Bean
    @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
    //必须是prototype类型
    public RabbitTemplate rabbitTemplate() {
        RabbitTemplate template = new RabbitTemplate(connectionFactory());
        return template;
    }
}

第四步:编写消息的生产者

@Component
public class MsgProducer implements RabbitTemplate.ConfirmCallback {
 
    private final Logger logger = LoggerFactory.getLogger(this.getClass());
 
    //由于rabbitTemplate的scope属性设置为ConfigurableBeanFactory.SCOPE_PROTOTYPE,所以不能自动注入
    private RabbitTemplate rabbitTemplate;
    /**
     * 构造方法注入rabbitTemplate
     */
    @Autowired
    public MsgProducer(RabbitTemplate rabbitTemplate) {
        this.rabbitTemplate = rabbitTemplate;
        rabbitTemplate.setConfirmCallback(this); //rabbitTemplate如果为单例的话,那回调就是最后设置的内容
    }
 
    public void sendMsg(String content) {
        CorrelationData correlationId = new CorrelationData(UUID.randomUUID().toString());
        //把消息放入ROUTINGKEY_A对应的队列当中去,对应的是队列A
        rabbitTemplate.convertAndSend(RabbitConfig.EXCHANGE_A, RabbitConfig.ROUTINGKEY_A, content, correlationId);
    }
    /**
     * 回调
     */
    @Override
    public void confirm(CorrelationData correlationData, boolean ack, String cause) {
        logger.info(" 回调id:" + correlationData);
        if (ack) {
            logger.info("消息成功消费");
        } else {
            logger.info("消息消费失败:" + cause);
        }
    }
}

第五步:把交换机,队列,通过路由关键字进行绑定,写在RabbitConfig类当中

 /**
     * 针对消费者配置
     * 1. 设置交换机类型
     * 2. 将队列绑定到交换机
     FanoutExchange: 将消息分发到所有的绑定队列,无routingkey的概念
     HeadersExchange :通过添加属性key-value匹配
     DirectExchange:按照routingkey分发到指定队列
     TopicExchange:多关键字匹配
     */
    @Bean
    public DirectExchange defaultExchange() {
        return new DirectExchange(EXCHANGE_A);
    }
 /**
     * 针对消费者配置
     * 1. 设置交换机类型
     * 2. 将队列绑定到交换机
     FanoutExchange: 将消息分发到所有的绑定队列,无routingkey的概念
     HeadersExchange :通过添加属性key-value匹配
     DirectExchange:按照routingkey分发到指定队列
     TopicExchange:多关键字匹配
     */
    @Bean
    public DirectExchange defaultExchange() {
        return new DirectExchange(EXCHANGE_A);
    }
    @Bean
    public Binding binding() {
 
        return BindingBuilder.bind(queueA()).to(defaultExchange()).with(RabbitConfig.ROUTINGKEY_A);
    }

一个交换机可以绑定多个消息队列,也就是消息通过一个交换机,可以分发到不同的队列当中去。

    @Bean
    public Binding binding() {
        return BindingBuilder.bind(queueA()).to(defaultExchange()).with(RabbitConfig.ROUTINGKEY_A);
    }
    @Bean
    public Binding bindingB(){
        return BindingBuilder.bind(queueB()).to(defaultExchange()).with(RabbitConfig.ROUTINGKEY_B);
    }

第六步:编写消息的消费者,这一步也是最复杂的,因为可以编写出很多不同的需求出来,写法也有很多的不同。

比如一个生产者,一个消费者:

@Component
@RabbitListener(queues = RabbitConfig.QUEUE_A)
public class MsgReceiver {
 
    private final Logger logger = LoggerFactory.getLogger(this.getClass());
 
    @RabbitHandler
    public void process(String content) {
        logger.info("接收处理队列A当中的消息: " + content);
    }
 
}

在这里插入图片描述
比如一个生产者,多个消费者,可以写多个消费者,并且他们的分发是负载均衡的:

@Component
@RabbitListener(queues = RabbitConfig.QUEUE_A)
public class MsgReceiverC_one {
    private final Logger logger = LoggerFactory.getLogger(this.getClass());
 
    @RabbitHandler
    public void process(String content) {
            logger.info("处理器one接收处理队列A当中的消息: " + content);
    }
}
@Component
@RabbitListener(queues = RabbitConfig.QUEUE_A)
public class MsgReceiverC_two {
    private final Logger logger = LoggerFactory.getLogger(this.getClass());
    @RabbitHandler
    public void process(String content) {    
            logger.info("处理器two接收处理队列A当中的消息: " + content);
    }
 
}

在这里插入图片描述
另外一种消息处理机制的写法如下,在RabbitMQConfig类里面增加bean:

    @Bean
    public SimpleMessageListenerContainer messageContainer() {
        //加载处理消息A的队列
        SimpleMessageListenerContainer container = new SimpleMessageListenerContainer(connectionFactory());
        //设置接收多个队列里面的消息,这里设置接收队列A
        //假如想一个消费者处理多个队列里面的信息可以如下设置:
        //container.setQueues(queueA(),queueB(),queueC());
        container.setQueues(queueA());
        container.setExposeListenerChannel(true);
        //设置最大的并发的消费者数量
        container.setMaxConcurrentConsumers(10);
        //最小的并发消费者的数量
        container.setConcurrentConsumers(1);
        //设置确认模式手工确认
        container.setAcknowledgeMode(AcknowledgeMode.MANUAL);
        container.setMessageListener(new ChannelAwareMessageListener() {
            @Override
            public void onMessage(Message message, Channel channel) throws Exception {
                /**通过basic.qos方法设置prefetch_count=1,这样RabbitMQ就会使得每个Consumer在同一个时间点最多处理一个Message,
                 换句话说,在接收到该Consumer的ack前,它不会将新的Message分发给它 */
                channel.basicQos(1);
                byte[] body = message.getBody();
                logger.info("接收处理队列A当中的消息:" + new String(body));
                /**为了保证永远不会丢失消息,RabbitMQ支持消息应答机制。
                 当消费者接收到消息并完成任务后会往RabbitMQ服务器发送一条确认的命令,然后RabbitMQ才会将消息删除。*/
                channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
            }
        });
        return container;
    }

下面是当一个消费者,处理多个队列里面的信息打印的log:
在这里插入图片描述

Fanout Exchange

Fanout 就是我们熟悉的广播模式,给Fanout交换机发送消息,绑定了这个交换机的所有队列都收到这个消息。

    //配置fanout_exchange
    @Bean
    FanoutExchange fanoutExchange() {
        return new FanoutExchange(RabbitConfig.FANOUT_EXCHANGE);
    }
 
    //把所有的队列都绑定到这个交换机上去
    @Bean
    Binding bindingExchangeA(Queue queueA,FanoutExchange fanoutExchange) {
        return BindingBuilder.bind(queueA).to(fanoutExchange);
    }
    @Bean
    Binding bindingExchangeB(Queue queueB, FanoutExchange fanoutExchange) {
        return BindingBuilder.bind(queueB).to(fanoutExchange);
    }
    @Bean
    Binding bindingExchangeC(Queue queueC, FanoutExchange fanoutExchange) {
        return BindingBuilder.bind(queueC).to(fanoutExchange);
    }

消息发送,这里不设置routing_key,因为设置了也无效,发送端的routing_key写任何字符都会被忽略。

public void sendAll(String content) {
        rabbitTemplate.convertAndSend("fanoutExchange","", content);
    }

消息处理的结果如下所示:
在这里插入图片描述

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

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值