关于spring boot rabbitmq的学习使用

最近由于工作需要接触了RabbitMQ,并简单的进行了尝试。所以记录一下学习到的相关知识。

在一切开始之前,我们先看一下为什么需要使用消息队列?
https://blog.csdn.net/songfeihu0810232/article/details/78648706
http://www.cnblogs.com/xuyatao/p/6864109.html

GitHub测试代码


RabbitMQ

首先我们自然是从RabbitMQ开始入手,而rabbitmq官网给了我们很好的教程——开始6步

这里大概阐述一下这六步分别讲述了哪些内容,具体代码示例可在我上面的GitHub链接中看到,或者直接看官网中的代码,基本相同。

第一步

简单示例,大概熟悉生产者向队列发送消息,消费者从队列接收消息。

第二步

多个消费者时,使用轮询的形式消费message;
但是如果某个消费者挂掉,会丢失信息,需要通过设置ack来实现存在consumer的时候就不会丢失message;
上述是在consumer挂掉时不丢失数据,但是如果server挂掉,数据还是会丢失,通过设置server端两个位置可以保证server挂了,数据也不会丢失,分别为如下两个位置:

channel.queueDeclare(TASK_QUEUE_NAME, durable, false, false, null); //其中durable值为true;
channel.basicPublish("", TASK_QUEUE_NAME, MessageProperties.PERSISTENT_TEXT_PLAIN, message.getBytes()); // 设置MessageProperties的值;

注意:rabbitMQ不允许同一名字的队列设置不同声明,所以如果策略改了,需要声明一个新的队列

即便是上述设置,也不能完全保证不会丢失数据,因为可能rabbitMQ还没将数据存到磁盘里。如果想要一个鲁棒性强的,见这里

通过设置consumer,可以实现每个consumer至多消费一个message,也就是说如果当前consumer处于busy,则它不会再接收message,message会由server分发给not busy的consumer:

channel.basicQos(1);
第三步

Exchange,位于producer和queue之间,在rabbitMQ中,并不是producer直接发送message到queue中,而是将message发送到exchange里,再由对应type的exchange去push到queue中;
如果使用不带参数的方法queueDeclare()我们可以新建一个非持久化,独有的,可自动删除并带有随机名字的队列;
当我们创建fanout的exchange和一个queue时,现在我们需要告诉exchange发送message到我们的queue中。这个在exchange和queue之间的关系叫做binding。

总的来说,设置发布/订阅模式,通过server设置exchange

channel.exchangeDeclare(EXCHANGE_NAME, "fanout");

并且在注册的时候注意

channel.basicPublish(EXCHANGE_NAME, "", null, message.getBytes());

而consumer则通过binding exchange和queue,来实现扇出

channel.exchangeDeclare(EXCHANGE_NAME, "fanout");
String queueName = channel.queueDeclare().getQueue();
channel.queueBind(queueName, EXCHANGE_NAME, "");
第四步

通过设置exchange的type为direct,可以实现对应的路由算法——message会送到binding key完全和它的routing key匹配的queues中。message的routing key和queue的binding key互相匹配。
在这里插入图片描述

首先我们需要在producer中设置exchange的type

channel.exchangeDeclare(EXCHANGE_NAME, "direct");

然后发送准备发送消息

channel.basicPublish(EXCHANGE_NAME, severity, null, message.getBytes()); // severity就是routing key

接着需要在consumer中设置binding

for (String severity : args){
    channel.queueBind(queueName, EXCHANGE_NAME, severity);
}
第五步

通过设置exchange的type为topic,可以实现得到固定源的对应routing key 的message
相当于在direct上更进一步,可获取相应源的message了。
在这里插入图片描述
配置上和direct基本相同,可以认为direct就是特殊的topic

第六步

和RPC相关,有点复杂,这里不过多阐述了。

另外我简单的翻译了两篇官方的文档,能够更好的帮助理解rabbitmq,分别是对于确认队列阐述。


Spring boot RabbitMQ

当我们对于rabbitmq有一定了解之后,就需要实际使用它了,而spring boot刚好有关于rabbitmq的整合,那就是spring-boot-amqp包。
这里学习,我分别参考了如下几篇文章:
https://www.cnblogs.com/skychenjiajun/p/9037324.html
https://www.cnblogs.com/boshen-hzb/p/6841982.html
https://blog.csdn.net/qq_38455201/article/details/80308771

由于相关文章的内容重复性比较高,上述几篇也基本上绝大部分内容都是重叠的。

以及spring官方给的关于amqp这个包的示例程序:
https://github.com/spring-projects/spring-amqp-samples

基本上有了这些示例,大概就能对于rabbitmq的使用有一个比较初级的认识了,可以尝试写一些测试用例跑跑了。之后再根据具体的业务需求不断地学习和精进。

下面简单地写写相关内容,不写地太详细进行重复劳动了,具体可参见上面贴出来的几篇文章。

首先我们根据之前对于rabbitmq地学习,大概知道了queue、routingkey、exchange这样几个概念,所以首先我们就需要在配置中配置;当然,最必不可少地配置是如下两个配置:

@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)
    public RabbitTemplate rabbitTemplate(){
        RabbitTemplate template = new RabbitTemplate(connectionFactory());
        return template;
    }

接下来才是对于queue、binding、exchange地配置

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

     /**
     * 获取队列A
     * @return
     */
    @Bean
    public Queue queueA(){
        return new Queue(QUEUE_A, true);  // 队列持久
    }

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

对于fanouttopic的exchange配置略有不同:

/**
     * 下面是关于fanout的配置
     */
    @Bean
    FanoutExchange fanoutExchange(){
        return new FanoutExchange(RabbitConfig.FANOUT_EXCHANGE);  // RabbitConfig.FANOUT_EXCHANGE="my-mq-fanout-exchange"
    }

    @Bean
	TopicExchange exchange(){
		return new TopicExchange("exchange");
	}

当然我们也可以在这个配置类中直接配置一个相应地消费信息:

// 就是一个消费者,也接收队列中的消息
    @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;
    }

接下来我们简单配置一个producer:

package com.yubotao.rabbitmq_with_sb.producer;

import com.yubotao.rabbitmq_with_sb.config.RabbitConfig;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.amqp.rabbit.connection.CorrelationData;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import java.util.UUID;

/**
 * @Auther: yubt
 * @Description:
 * @Date: Created in 10:11 2018/11/22
 * @Modified By:
 */
@Component
public class MsgProducer implements RabbitTemplate.ConfirmCallback {

    private final Logger logger = LoggerFactory.getLogger(this.getClass());

    // 由于rabbitTemplate的scope属性设置为ConfigurableBeanFactory.SCOPE_PROTOTYPE,所以不能自动注入
    private RabbitTemplate rabbitTemplate;

    @Autowired
    public MsgProducer(RabbitTemplate rabbitTemplate){
        this.rabbitTemplate = rabbitTemplate;
        // rabbitTemplate如果为单例的话,那回调就是最后设置的内容
        rabbitTemplate.setConfirmCallback(this);
    }

    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);
    }

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

    // 回调
    @Override
    public void confirm(CorrelationData correlationData, boolean ack, String cause){
        logger.info(" 回调id:" + correlationData);
        if (ack){
            logger.info("消息成功消费");
        }else {
            logger.info("消息消费失败:" + cause);
        }
    }

}

以及一个简单的consumer:

package com.yubotao.rabbitmq_with_sb.receiver;

import com.yubotao.rabbitmq_with_sb.config.RabbitConfig;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.amqp.rabbit.annotation.RabbitHandler;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;

/**
 * @Auther: yubt
 * @Description:
 * @Date: Created in 10:26 2018/11/22
 * @Modified By:
 */
@Component
@RabbitListener(queues = {RabbitConfig.QUEUE_A, RabbitConfig.QUEUE_B, RabbitConfig.QUEUE_C})

public class MsgReceiver {

    private final Logger logger = LoggerFactory.getLogger(this.getClass());

    @RabbitHandler
    public void process(String content){
        logger.info("接收处理队列A或B或C当中的消息: " + content);
    }

}

然后写两个测试:

package com.yubotao.rabbitmq_with_sb;

import com.yubotao.rabbitmq_with_sb.producer.MsgProducer;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@SpringBootApplication
@RestController
public class RabbitmqWithSbApplication {

    @Autowired
    MsgProducer msgProducer;

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

    @RequestMapping(value = "/testMQ", method = RequestMethod.POST)
    public void test(@RequestParam("msg") String msg){
        msgProducer.sendMsg(msg);
    }

    @RequestMapping(value = "/testMQ/sendAll", method = RequestMethod.POST)
    public void sendAll(@RequestParam("msg") String msg){
        msgProducer.sendAll(msg);
    }

}

在具体一些的内容就可以看GitHub中的代码了。不过其实并没有太多的多余信息了。


学习使用中遇到的问题

首先是对于消息队列使用的思考。

我们在使用消息队列的时候,我们需要为producer提供一个接口,然后将相关的message放入队列中。即Controller中提供相关接口去调用我们写的producer类中的相关方法。

而consumer类中,主要需要配置对应的几个注解——@Component@RabbitListener@RabbitHandler
在对应方法上有相关的@RabbitListener@RabbitHandler,就会直接消费掉对应队列中存在的message。

接着是和上面贴的示例代码不同的是,当我们对于producer的方法入参封装成message时,@RabbitListener要变成方法级的,否则会报错:

Caused by: org.springframework.amqp.AmqpException: No method found for class String

对应的producer中的方法:

public void sendMsg(String content){
        CorrelationData correlationId = new CorrelationData(UUID.randomUUID().toString());
        Message message = MessageBuilder.withBody(content.getBytes())
                .andProperties(MessagePropertiesBuilder.newInstance().setContentType("application/json").build())
                .build();
        //把消息放入ROUTINGKEY对应的队列当中去,对应的是binding的相关队列
        rabbitTemplate.send(RabbitConfig.EXCHANGE, RabbitConfig.ROUTINGKEY, message, correlationId);
    }

而我们在将消息放到队列中,需要考虑很多相关问题:
我们如何保证message被消费成功?(ack)
如果ack失败,message回滚队列且无限循环这个过程怎么办?(需要设置相应的机制在适当的时候抛弃这条message)
消息队列如果挂掉怎么办?(涉及运维方面的问题,要考虑动态扩容,channel切换,选举等等一系列可能涉及的问题)

所以我们在项目中加入消息队列时,就需要考虑到消息队列可能带来的负面影响;我们需要在producer中保证message入队前的正确性,以防影响consumer端消费的业务逻辑报错对于队列的相关影响;同时我们需要在consumer端去处理message递送交付的相关问题,以及在producer中无法提前处理的相关问题,比如设置ack,在多次重试后抛弃相应的message等等。

如下是在consumer端设置ack:

// @RabbitListener放到类上会报错,但是放到方法级是没问题的。之前直接处理String的时候,放到类级上可行。
    @RabbitListener(queues = RabbitConfig.QUEUE)
    @RabbitHandler
    public void process(Message message, Channel channel) throws Exception {
        boolean flag = false;
        logger.info("接收处理队列当中的消息: " + new String(message.getBody()));
        try {
            JSONObject combinedMap = (JSONObject) JSONObject.parse(message.getBody());

            String designId = (String) combinedMap.get("xxId");
            String jsonData = (String) combinedMap.get("jsonData");

            CommonUtils.checkParamsIsEmpty(designId, jsonData);
            // 相应的业务逻辑方法
            objectService.sendObjectData(designId, jsonData);
            flag = true;
        }catch (Exception e){
            e.printStackTrace();
        }
        // 判断业务逻辑是否成功,message是否持久化, 否则回滚队列
        if (flag) {
            channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
        }else {
            channel.basicNack(message.getMessageProperties().getDeliveryTag(), false, true);
        }
    }

关于Spring boot RabbitMQ的补充知识

内容来自这里

当Rabbit基础设施存在时,任何被声明为@RabbitListener的bean都会创建一个监听者端点。如果未定义RabbitListenerContainerFactory,通常自动配置默认的SimpleRabbitListenerContainerFactory,你可以通过使用spring.rabbitmq.listener.type属性选择相应的容器。如果定义了MessageConverterMessageRecoverer的bean,它通常自动和默认工厂关联。

如果你需要更多的RabbitListenerContainerFactory实例或者你想重载默认的,spring boot提供了SimpleRabbitListenerContainerFactoryConfigurerDirectRabbitListenerContainerFactoryConfigurer,让你通过自动配置工厂使用的相同设置来初始化SimpleRabbitListenerContainerFactoryDirectRabbitListenerContainerFactory
例如,下面的配置类就使用一个特定的MessageConverter来暴露另一个工厂:

@Configuration
static class RabbitConfiguration {

	@Bean
	public SimpleRabbitListenerContainerFactory myFactory(
			SimpleRabbitListenerContainerFactoryConfigurer configurer) {
		SimpleRabbitListenerContainerFactory factory =
				new SimpleRabbitListenerContainerFactory();
		configurer.configure(factory, connectionFactory);
		factory.setMessageConverter(myMessageConverter());
		return factory;
	}

}

我们可以在任何@RabbitListener处使用这个工厂:

@Component
public class MyBean {

	@RabbitListener(queues = "someQueue", containerFactory="myFactory")
	public void processMessage(String content) {
		// ...
	}

}

你可以使用重试来处理你的监听者抛出异常这种情况。默认使用RejectAndDontRequeueRecoverer,但你可以定义你自己的MessageRecoverer。当重试次数耗尽,message被拒绝,根据broker的配置或者断开连接,或者路由到一个死信exchange。默认下不允许重试,你可以通过声明RabbitRetryTemplateCustomizer的bean以编程的方式自定义RetryTemplate

重要提示
默认情况下,如果重试被禁用,监听者抛出异常,delivery无限重复。你可以通过两种方式改变这一行为:设置defaultRequeueRejected为false,这样就不会再次delivery,或者抛出一个AmqpRejectAndDontRequeueException异常来表示message应该被拒绝。另一个机制是开启重试,并设置delivery尝试送达的最大值。


关于spring boot properties中属性配置
rabbitmq的配置是通过这个类来实现的。
以及一个简洁的中文翻译的参考版

最后,spring-amqp的官方文档,内容较多,暂时没有翻看,后续深入学习的时候就是主要参考文档了。


2019.6.6 新增动态管理队列

根据业务需求,有时候可能需要我们动态的对队列进行新增或删除,而不是项目一开始就指定队列名那种做法。当需要对队列进行管理的时候,就需要使用rabbitAdmin这个类。
同时配合上相应的方法

public String addQueue(Queue queue){
        return rabbitAdmin.declareQueue(queue);
    }

    public boolean deleteQueue(String queueName){
        return rabbitAdmin.deleteQueue(queueName);
    }

以下代码内容主要参考如下两篇文章:
即时使用工具类
略带瑕疵的配置

配置类RabbitConnectionConfig.java

package com.yubotao.dynamicQueue.config;

import org.springframework.amqp.rabbit.connection.CachingConnectionFactory;
import org.springframework.amqp.rabbit.connection.ConnectionFactory;
import org.springframework.amqp.rabbit.core.RabbitAdmin;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * @Auther: yubotao
 * @Description:
 * @Date: Created in 13:46 2019/5/31
 * @Modified By:
 */
@Configuration
public class RabbitConnectionConfig {

    @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;

    @Bean
    public ConnectionFactory mqConnectionFactory(){
        CachingConnectionFactory connectionFactory = new CachingConnectionFactory();
        connectionFactory.setUsername(username);
        connectionFactory.setPassword(password);
        connectionFactory.setVirtualHost("/");
        connectionFactory.setPublisherConfirms(true);
        // 设置通道数量
        connectionFactory.setChannelCacheSize(40);
        // 该方法配置多个host,在当前连接host down掉的时候会自动去重连后面的host
        connectionFactory.setAddresses(host);
        return connectionFactory;
    }

    @Bean
    public RabbitAdmin rabbitAdmin(){
        return new RabbitAdmin(mqConnectionFactory());
    }
}

工具类RabbitUtil.java

package com.yubotao.dynamicQueue.config;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.amqp.core.*;
import org.springframework.amqp.rabbit.core.RabbitAdmin;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.context.annotation.Bean;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;

/**
 * @Auther: yubotao
 * @Description:
 * @Date: Created in 13:54 2019/5/31
 * @Modified By:
 */
@Component
public class RabbitUtil {

    private Logger logger = LoggerFactory.getLogger(this.getClass());

    private static final String DIRECT_EXCHANGE_NAME = "test-dynamic";

    @Resource
    private RabbitAdmin rabbitAdmin;

    @Resource
    private RabbitTemplate rabbitTemplate;

    // 配置发送格式
    @Bean
    public AmqpTemplate amqpTemplate(){
        // 使用jackson 消息转换器
        rabbitTemplate.setMessageConverter(new Jackson2JsonMessageConverter());
        rabbitTemplate.setEncoding("UTF-8");
        // 开启return callback
        rabbitTemplate.setMandatory(true);
        rabbitTemplate.setReturnCallback((message, replyCode, replyText, exchange, routingKey) -> {
            String correlationId = message.getMessageProperties().getCorrelationId();
            logger.info("消息:{} 发送失败, 应答码:{} 原因:{} 交换机: {}  路由键: {}", correlationId,replyCode,replyText,exchange,routingKey);
        });
        // 消息确认
        rabbitTemplate.setConfirmCallback((correlationData, ack, cause) -> {
            if (ack){
                logger.info("消息发送到exchange成功");
            }else {
                logger.info("消息发送到exchange失败,原因: {}", cause);
            }
        });
        return rabbitTemplate;
    }

    public Message getMessage(String messageType, Object msg){
        MessageProperties messageProperties = new MessageProperties();
        messageProperties.setContentType(messageType);
        Message message = new Message(msg.toString().getBytes(), messageProperties);
        return message;
    }

    public void sendToQueue(String queueName, String message){
        DirectExchange exchange = createExchange(DIRECT_EXCHANGE_NAME);
        addExchange(exchange);
        Queue queue = createQueue(queueName);
        addQueue(queue);
        addBinding(queue, exchange, queueName);
        rabbitTemplate.convertAndSend(exchange.getName(), queueName, message);
    }

    public String receiveFromQueue(String queueName){
        String message = (String)rabbitTemplate.receiveAndConvert(queueName);
        System.out.println("Receive: " + message);
        return message;
    }

    public DirectExchange createExchange(String exchangeName){
        return new DirectExchange(exchangeName, true, false);
    }

    public Queue createQueue(String queueName){
        return QueueBuilder.durable(queueName).build();
    }

    /**
     * 创建时限队列,可自动删除
     * @param queueName
     * @return
     */
    public Queue createExpiresQueue(String queueName){
        return QueueBuilder.durable(queueName)
//                .withArgument("x-message-ttl", delayMillis) // 死信时间
                .withArgument("x-expires", 3000) // 设置队列自动删除时间
//                .withArgument("x-dead-letter-exchange", rabbitConfig.getExchange()) // 死信重新投递的交换机
//                .withArgument("x-dead-letter-routing-key", rabbitConfig.getQueue()) // 路由到队列的routingKey
                .build();
    }

    /**
     * 使用一个routingKey绑定一个队列到一个匹配型交换器
     * @param queue
     * @param exchange
     * @param routingKey
     */
    public void addBinding(Queue queue, DirectExchange exchange, String routingKey){
        Binding binding = BindingBuilder.bind(queue).to(exchange).with(routingKey);
        rabbitAdmin.declareBinding(binding);
    }

    /**
     * 创建一个指定的Queue
     * @param queue
     * @return queueName
     */
    public String addQueue(Queue queue){
        return rabbitAdmin.declareQueue(queue);
    }

    public boolean deleteQueue(String queueName){
        return rabbitAdmin.deleteQueue(queueName);
    }

    /**
     * 创建Exchange
     * @param exchange
     */
    public void addExchange(AbstractExchange exchange){
        rabbitAdmin.declareExchange(exchange);
    }

    public boolean deleteExchange(String exchangeName){
        return rabbitAdmin.deleteExchange(exchangeName);
    }

}

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值