从源码分析RocketMQ系列-Producer的SendResult来自哪里?

导语
  对于消息中间件大家都应该不陌生,现在比较主流的消息中间件有Kafka、RabbitMQ、RocketMQ、ActiveMQ等等。前段时间花了很长时间分析了关于RocketMQ源码,之前也分享了关于RabbitMQ的相关知识。当然后期还会继续分享,与此同时。将个人学习RocketMQ的相关的总结与大家一起分享。这个系列就是总结一下自己之前对于RocketMQ源码的分析。


  首先博主选择的是RocketMQ比较新的版本release-4.5.2。当然现在Git上面有比这个还要新的版本。有兴趣的读者可以从GitHub上面获取最新的代码进行阅读。下面就开始进入到正题了。

  在中间件中一个最为主要的概念就是消息生产者,也就是产生需要的消息的一方,当然这个消息生产者是一个比较广泛的概念。当然所谓的消息也有很多的类型,例如序列化对象、JSON字符串、XML字符串等等。下面就来通过实例看看在RocketMQ中怎么使用消息生产者的。

实例分析

初始化消息

  打开源码之后,在org.apache.rocketmq.example.quickstart.Producer中,RocketMQ为我们提供了一个生产者的小例子,如下。

/**
 * 这个类是教如何使用 DefaultMQProducer 进行发送消息的
 */
public class Producer {
    public static void main(String[] args) throws MQClientException, InterruptedException {

        /*
         * 实例化了一个Producer对象,并且制定的响应的group name:
         * 对于这个Group Name 是干什么的,在后面的分析中会慢慢看到
         */
        DefaultMQProducer producer = new DefaultMQProducer("please_rename_unique_group_name");
        /**
        * 设置了一个NameSpace;也就是名称空间,类似于注册中心一样
        */
        producer.setNamesrvAddr("localhost:9876");
        producer.start();

        for (int i = 0; i < 10000; i++) {
        	//设置每两秒发送一个消息,并且指定了Topic、和Tag、设置了消息体,并且制定UTF8编码
            try {
                TimeUnit.SECONDS.sleep(2);
                Message msg = new Message("World" /* Topic */,
                        "TagA" /* Tag */,
                        ("Hello RocketMQ " + i).getBytes(RemotingHelper.DEFAULT_CHARSET) /* Message body */
                );
                /*
                 * 调用发送消息到Broker中
                 */
                SendResult sendResult = producer.send(msg);

                System.out.printf("%s%n", sendResult);
            } catch (Exception e) {
                e.printStackTrace();
                Thread.sleep(1000);
            }
        }

        /*
         * 以一种比较优雅的方式关闭Producer
         */
        producer.shutdown();
    }
}

  从上面的代码来看,发送消息似乎是一个很简单的东西,指定一个group name,指定一个Topic,设定好消息体之后就可以将消息发送到Broker中。实际上真的有那么简单么,首先来分析一下Message类,org.apache.rocketmq.common.message.Message,也就是说通过这个例子进行消息发送的所有的数据都是由这个类来进行封装的,并且从下面的构造函数中也可以发现一些问题


public Message(String topic, String tags, byte[] body) {
    this(topic, tags, "", 0, body, true);
}


public Message(String topic, String tags, String keys, int flag, byte[] body, boolean waitStoreMsgOK) {
    this.topic = topic;
    this.flag = flag;
    this.body = body;

    if (tags != null && tags.length() > 0)
        this.setTags(tags);
    if (keys != null && keys.length() > 0)
        this.setKeys(keys);

     this.setWaitStoreMsgOK(waitStoreMsgOK);
 }
 

  从下面两个方法中可以看到,当一个Message被创建的时候其实是对MessageConst.PROPERTY_WAIT_STORE_MSG_OK属性设置了一个值。这个属性来自于org.apache.rocketmq.common.message.MessageConst常量类,提供了很多的字符常量


public void setWaitStoreMsgOK(boolean waitStoreMsgOK) {
   this.putProperty(MessageConst.PROPERTY_WAIT_STORE_MSG_OK, Boolean.toString(waitStoreMsgOK));
 }
    
void putProperty(final String name, final String value) {
   if (null == this.properties) {
       this.properties = new HashMap<String, String>();
    }

    this.properties.put(name, value);
}

发送消息

  到这里我们都不需要管上面这些对于Message的初始化操作都是什么,继续下面的逻辑就可以了,慢慢就会发现这些参数都用到了什么地方。继续往下会看到调用了如下的这样一个方法


SendResult sendResult = producer.send(msg);

System.out.printf("%s%n", sendResult);

  看上去很不起眼的一个方法,并且还有一个返回对象。首先来看看这个对象是什么通过下面这种方式打印出来的效果又是什么。
org.apache.rocketmq.client.producer.SendResult 类

public class SendResult {
    private SendStatus sendStatus; //发送状态
    private String msgId; //message id
    private MessageQueue messageQueue; //消息队列
    private long queueOffset; //队列偏移量
    private String transactionId; //追踪ID
    private String offsetMsgId; //偏移下一个消息的ID
    private String regionId; // 区域id
    private boolean traceOn = true; //标识是否可追踪
    

打印效果是由重写的这个toString方法来决定的。当然这个toString方法是可以继续扩展的。这里了解这些之后,就可以继续看看后面的逻辑了。

@Override
public String toString() {
   return "SendResult [sendStatus=" + sendStatus + ", msgId=" + msgId + ", offsetMsgId=" + offsetMsgId + ", messageQueue=" + messageQueue
            + ", queueOffset=" + queueOffset + "]";
}

消息逻辑详细分析

  看到其实他是调用了producer对象的send()方法。发送消息,我们可以继续追踪这个方法看看后续的逻辑,会看到其实这个方法是通过继承过来的,是实现了org.apache.rocketmq.client.producer.MQProducer 接口,对于接口,在Java中其实就是提供了一些规范。继承接口的类就需要实现接口里面的方法。这里先不需要管org.apache.rocketmq.client.producer.MQProducer接口提供了那些方法,先来看看下面这个方法的具体实现。

@Override
public SendResult send(Message msg) throws MQClientException, RemotingException, MQBrokerException, InterruptedException {
	//消息检车
    Validators.checkMessage(msg, this);
    //设置消息Topic
    msg.setTopic(withNamespace(msg.getTopic()));
    return this.defaultMQProducerImpl.send(msg);
}

  首先进入之后进行了一个消息的检查,第二步,设置了msg的Topic。这里为什么会有这样的一个操作呢?这就要看看withNamespace(msg.getTopic())这个方法为我们返回的返回值是什么样子的?很多人会发现这个方法似乎是从自己本身调用的,也没有this关键字作为标识。但是点击进入之后,会发现这个方法其实来自于这个类org.apache.rocketmq.client.ClientConfig,见名知意。就是客户端的一个配置类。这个方法的意思就是对于Namespace进行了封装,也就是说Namespace中需要保证在Namespace进行管理的时候,确实有需要的信息在Namespace中。而ClientConfig类就是也是作为客户端的一个基础配置类存在。

public String withNamespace(String resource) {
   return NamespaceUtil.wrapNamespace(this.getNamespace(), resource);
}


public String getNamespace() {
    if (StringUtils.isNotEmpty(namespace)) {
        return namespace;
    }
   if (StringUtils.isNotEmpty(this.namesrvAddr)) {
        if (NameServerAddressUtils.validateInstanceEndpoint(namesrvAddr)) {
            return NameServerAddressUtils.parseInstanceIdFromEndpoint(namesrvAddr);
        }
    }
    return namespace;
}
    

开始发送消息了

   做好前面的准备工作之后就进入到了最为关键的地方了,似乎这个方法才是最关键的方法,它还调用了this.defaultMQProducerImpl的方法。那么我们之前的DefaultMQProducer 对象又是什么呢?,第二行代码似乎给出了答案,并且发现 public class DefaultMQProducerImpl implements MQProducerInner 这个类居然不是 DefaultMQProducer的子类,而且还有public class DefaultMQProducer extends ClientConfig implements MQProducer,担心的事情终于出现了,原来这两个类一点关系都没有。那就继续往下看看。

return this.defaultMQProducerImpl.send(msg);
 
protected final transient DefaultMQProducerImpl defaultMQProducerImpl;

  继续往下分析,会看到如下的一段代码,更为奇特的是它上面有个注释。这个注释应该不默认,同步

/**
* DEFAULT SYNC -------------------------------------------------------
*/
public SendResult send(Message msg) throws MQClientException, RemotingException, MQBrokerException, InterruptedException {
  return send(msg, this.defaultMQProducer.getSendMsgTimeout());
}

  到这里会发现,我们得调用终于随着正主了。到了最为关键的发送代码了,结果你点击进去之后又到了另外一个方法中

public SendResult send(Message msg,long timeout) throws MQClientException, RemotingException, MQBrokerException, InterruptedException {
    return this.sendDefaultImpl(msg, CommunicationMode.SYNC, null, timeout);
}
       

 &emsp到这里才算是真的找到归宿了,这里似乎是找到真正需要的代码,下面就来进一步的分析this.sendDefaultImpl(msg, CommunicationMode.SYNC, null, timeout) 方法中的代码

 private SendResult sendDefaultImpl(
        Message msg,
        final CommunicationMode communicationMode,
        final SendCallback sendCallback,
        final long timeout
    ) throws MQClientException, RemotingException, MQBrokerException, InterruptedException
    

  首先会看到这个方法是使用了this关键字,所以说明这个方法其实就是上面这个类对象所属的方法,也就是说他是org.apache.rocketmq.client.impl.producer.DefaultMQProducerImpl 这类中的一个方法,在看看传入的参数

  • Message 对象,这个对象之前说过的就是封装了传递过程中需要传递什么样的参数来实现的。
  • CommunicationMode 交换模式,这里RocketMQ提供了三种模式,SYNC,ASYNC,ONEWAY。同步、异步、单向。对于这三种模式,在后面的分享中还会详细说到这里只是简单的提出一个概念。
  • SendCallback:发送回调接口,这个接口提供了两个方法,一个是发送成功之后回调,一个是发送有异常回调。
  • timeout:超时时间,在调用过程中需要设置一个调用超时时间。

  看完传递了那些参数之后接下来就需要进入关键点了,但是在发送消息之前做了一些准备性的工作这里来看看这些准备性的工作有那些。

 Validators.checkMessage(msg, this.defaultMQProducer);

final long invokeID = random.nextLong();
long beginTimestampFirst = System.currentTimeMillis();
long beginTimestampPrev = beginTimestampFirst;
long endTimestamp = beginTimestampFirst;
TopicPublishInfo topicPublishInfo = this.tryToFindTopicPublishInfo(msg.getTopic());

  从上面代码中会看到其实也没有做太多的准备工作,就是先准备了一些初始化计算的时间,并且准备了一个TopicPublishInfo 对象,而且这个对象还是通过msg中的Topic进行获取的。获取完成之后也就是做了一切开始准备操作的工作,就进入到了正确的流程中

if (topicPublishInfo != null && topicPublishInfo.ok()) {
	代码暂时省略           
}

  if条件判断里面的中的代码如下所示,开始的时候就准备了是否超时回调、消息队列、异常信息、返回结果。四个对象。其实这里最值的注意的就是timesTotal变量,这个变量表示,重试次数,在前面的提到过这里使用的同步模式,如果使用的是同步模式this.defaultMQProducer.getRetryTimesWhenSendFailed()这个函数返回的属性值是2,前面还有一个加 1 的操作,所以说timesTotal变量最后的值是3,也就是说在同步模式下,需要发送三次消息。那么是怎么发送的呢。

 &emps;继续往下会看到String[] brokersSent = new String[timesTotal]; 创建了一个长度为timesTotal的数组,并且后续看到传入的参数是,获取到的broker列表,在一般使用配置的时候broker都是以字符串的形式在配置文件中利用逗号分隔的,所以说这里要将获取的brokerSent 设置为一个字符串类型的数组。

boolean callTimeout = false;
MessageQueue mq = null;
Exception exception = null;
SendResult sendResult = null;

int timesTotal = communicationMode == CommunicationMode.SYNC ? 1 + this.defaultMQProducer.getRetryTimesWhenSendFailed() : 1;

int times = 0;
String[] brokersSent = new String[timesTotal];
for (; times < timesTotal; times++) {
     String lastBrokerName = null == mq ? null : mq.getBrokerName();
     MessageQueue mqSelected = this.selectOneMessageQueue(topicPublishInfo, lastBrokerName);
     if (mqSelected != null) {
         mq = mqSelected;
         brokersSent[times] = mq.getBrokerName();

		//后续代码待接入

  String lastBrokerName = null == mq ? null : mq.getBrokerName();这个是一个关键性的代码,为什么说它关键呢?首先第一次进入到这个循环的时候mq确实是一个null,也就是说条件满足,对于三元运算符来说条件满足的时候应该返回的是第一个值,也就是最后lastBrokerName的值是null。进入到后续逻辑中也就是selectOneMessageQueue(topicPublishInfo, lastBrokerName) 方法,那么下面就来看看这个方法当lastBrokerName为null的时候是如何操作的。

public MessageQueue selectOneMessageQueue(final TopicPublishInfo tpInfo, final String lastBrokerName) {
    return this.mqFaultStrategy.selectOneMessageQueue(tpInfo, lastBrokerName);
}

  最终进入到了如下的一个方法中,第一个行代码上来就是分析是否是发送失误的策略,可想,当sendLatencyFaultEnable参数为true的时候这个if判断才会执行,否则是不会执行的。直接就 return tpInfo.selectOneMessageQueue(lastBrokerName); 可见,第一次进来之后策略是如何配置的就是如何操作的。这里先来看看默认为false的情况。
在这里插入图片描述
  this.sendLatencyFaultEnable这个值默认是false,进入之后进入的是,下面return语句,而return语句的逻辑如下图所示
在这里插入图片描述
  方法中调用的index的计算策略如下图所示,对于ThreadLocal有了解的人都知道,这是属于线程独有的东西。也是一个随机产生的随机数。并且找到它的绝对值。当然对于这些算法先不进行深入研究这里主要是关注,上面这段代码中其实默认返回的是一个MessageQueue。而这个MessageQueue是从MessageQueueList通过某种算法进行查找的。如果lastBrokerName为null的时候是直接进行查找,如果不是,则通过算法避免了一些冲突。
在这里插入图片描述

  到这里就真正了解了这个MessageQueue是怎么选入的,选入之后就进入了一个trycatch语句中。由于代码格式的关系,后续的代码都是以图片的形式为大家展示,
在这里插入图片描述
  会看到在进入之前先来判断了一下是否有选中的MessageQueue,并且获取到了第一次发送的BrokerName。调用了msg.setTopic(this.defaultMQProducer.withNamespace(msg.getTopic())); 方法,这里会看到有一个判断的条件是times大于0,那么是否还存在这个times小于零的时候呢?这里有一点需要注意就是,在进入第一次循环的时候times初始化是0,在编程中也是数组的下标也是从0 开始,那么在第一次进行调用的时候并没有执行上面这条语句,而是在后续重试的机制中才会调用对应的setTopic代码。

  在前面分析准备工作的时候提到了一个sendResult ,从代码中可以看到,这个参数的值是由this.sendKernelImpl(msg, mq, communicationMode, sendCallback, topicPublishInfo, timeout - costTime);方法提供的,也就是这个方法才是整个发送过程中最为核心的代码。有了这个结果之后,就需要找在什么时候进行返回了,什么时候进行返回操作,才是真正程序所需要的。继续往下,会经历一个this.updateFaultItem(mq.getBrokerName(), endTimestamp - beginTimestampPrev, false);更新失败项的操作。执行完这一步之后,就要开始真正的返回工作了。

数据返回工作

  到这里假设所有的工作都完成了,那么将会有一个数据返回的操作,返回我们需要的SendResult。会发现在在下面代码中,有一个模式选择,如果没有找到对应的模式则返回为空,如果找到对应的模式则返回一个sendResult的对象。否则就是break;这里需要注意,到目前为止上面所有的分析逻辑都还在第一次的for循环中。也就是说在判断模式的时候是最为重要的。那么按照代码逻辑分析来看,会从异步模式、单向模式、一直判断到同步模式,在同步模式的时候进入到了一个sendResult的状态判断中,判断之后进入到了第二判断中。那么这两次判断分别是什么呢?
在这里插入图片描述
  从代码的层面来理解,发送是否成功,当不成功的时候是否使用其他的Broker尝试发送。如果两个条件都满足,这进入到了continue中,也就是继续进行下次循环。这个下次循环就是前面提到的for循环,那么只需要一个第一次发送成功之后就不需要进行后续循环了么?从代码的意思是是直接就return了。那就继续往下看。
  除了上面的代码中有return语句之外还有其他的地方有return语句,在Catch MQBrokerException异常时候会返回一个sendResult。
在这里插入图片描述
  在所有的三次尝试都完成之后如果没有对应的操作,就返回一个。也许有人会问,之前那个地方不是已经判断过一次了么,既然三次都重试失败了,那就应该没有返回结果呀!仔细分析会知道有异常并不代表消息没有发送,状态是失败,说明的是消息没有被正常发送,并不是说没有消息。这些都是在集群场景下才会出现的问题。
在这里插入图片描述

  下面是触发异常场景之后得到的异常信息,而这个异常信息的触发来自与下面这样一段代码
在这里插入图片描述

异常代码逻辑,会看到当执行完所有的发送语句之后,如果还没有收到sendResult,或者是这Result是空的时候,就会触发下面这段异常。最终返回就是这段异常。
在这里插入图片描述

核心发送逻辑

  到这里对于整体的接收消息和返回逻辑都已经有了大致的了解,但是有一个方法始终没有提到。那就是核心发送逻辑this.sendKernelImpl(msg, mq, communicationMode, sendCallback, topicPublishInfo, timeout - costTime);,这个方法才是真正返回我们所需要的sendResult的方法,那么这个方法到底长什么样子呢?下面就来一起分析分析。

private SendResult sendKernelImpl(final Message msg,
                                 final MessageQueue mq,
                                 final CommunicationMode communicationMode,
                                 final SendCallback sendCallback,
                                 final TopicPublishInfo topicPublishInfo,
                                 final long timeout) throws MQClientException, RemotingException, MQBrokerException, InterruptedException
                                 

  也看到了这个方法也指定了一些参数,在使用的时候也指定了一些调用的逻辑以及初始化的数据。下面就来介绍一下参数

  • Message : 也就是之前提到的封装好的数据信息
  • MessageQueue: 通过上面的代码分析可以知道是通过一种简单的选择算法进行选中的一个消息队列
  • CommunicationMode:消息发送模式,也就是之前提到的同步、异步、单向
  • SendCallBack: 回调对象,其中有两个方法一个是成功回调,一个异常回调
  • TopicPublishInfo:发布Topic信息对象
  • timeout:超时时间。

  简单说明:在之前的调用中Debug的时候发现一个问题,RockMQ之所以可以保证高效的消息发送机制,其实在里面有一点点的小小的技巧在其中,这个小技巧就是这个超时时间。这个超时时间在一些场景下可以保证消息的高效传递。

准备工作

  在介绍完了上面的方法参数之后下面就来说说关于,实际操作前的准备工作,会发现还是离不开时间的调用。首先需要获取到的就是进行Broker地址的获取,如果没有获取到则尝试重新拉取然后再次获取这种方式,最终目的就是为了获取到这Broker地址

long beginStartTime = System.currentTimeMillis();
String brokerAddr = this.mQClientFactory.findBrokerAddressInPublish(mq.getBrokerName());
if (null == brokerAddr) {
    tryToFindTopicPublishInfo(mq.getTopic());
    brokerAddr = this.mQClientFactory.findBrokerAddressInPublish(mq.getBrokerName());
}

正常流程

  由于后续代码太多还是采用分段截图的方式进行说明。上内容强调了对于Broker地址的获取,到这里是在是太重要了,发现后续逻辑代码第一步就是对这个Broker地址进行了检查。对于这个检查逻辑就先不展示了,就是用来某种系统属性的方式对IP列表进行操作,看看是否是属性标识的。默认是false。
在这里插入图片描述
  检查完成之后就进入到了一个大的try cache语句中,在这之前还将Message变成了一个二进制的数组。这个也就是在日志中经常见到的消息体变成了ASCII码。
在这里插入图片描述
  会看到上来就先进行判断是否是批处理消息,如果是批处理消息则设置一个UniqID,也就是在消息中看到UNIQ_KEY,那么问题来了MessageBatch东西到底是什么呢?为什么客户端的消息中会有这个东西。这里由于是分析消息流向,这里就先不说明,到后面的内容中再进行说明;第二个判断是,获取到配置中的Namespace,通过获取到的客户端配置将这个配置作为 InstanceID 设置到Message中,设置topic标识;第三个判断,调用压缩消息的方法,并且这里有个标识,就是是否压缩标识,并且这里有个一个需要注意的操作 sysFlag |= MessageSysFlag.COMPRESSED_FLAG; 为什么这里要做这个操作呢?这里简单的说说,这个操作符的意思是按位或,什么是按位或呢?就是说将所表示的数据按照二进制展开,相同位置进行或运算。有1 则就是1 没有1 就是0。而MessageSysFlag.COMPRESSED_FLAG 是 0x1 也就是说十六进制的1。如果进行了压缩之后sysFlag的值就是1 了。第四个判断, 这里会看到首先获取了一个TRAN_MSG 属性,当不为空的情况下对sysFlag进行了新的规则校验,这个校验 0x1 << 2 表示什么意思呢,<< 表示左移,也就是说原来的数据乘以2的几次方。这里就变成了4,由于原来的数据是按位或。所以这个时候sysFlag的值就会变成5。这个值在后续的分析中会有用到。
  分析完上面四个判断之后简单的总结就是判断是否是指定的Topic,判断数据是否压缩,判断tranMsg并且修改了一个sysFlag,似乎也没有与到其他的问题。继续往下看就知道这些判断都是什么意思了
在这里插入图片描述
  从图中的代码可以看到,做完上面四个判断之后,第五个判断上来就先来了检查,那么这个检查到底是什么呢?看到下面代码就会知道就是到禁用的Hook列表中进行了一个判断,如果这个结果返回为True。就会进入到这个代码中,那么下面来分析一下进入的这段代码都干了些什么事情?

 public boolean hasCheckForbiddenHook() {
     return !checkForbiddenHookList.isEmpty();
 }
 
 

 &emps; 创建了一个CheckForbiddenContext 上下文的对象,并且完成之后会将这个上下文传入到一个Hook中进行执行。接下来会看到另一个判断这个判断引入的是this.hasSendMessageHook(),也就是说这个方法一定是对象本身提供的,也就是这个对象org.apache.rocketmq.client.impl.producer.DefaultMQProducerImpl 。

public boolean hasSendMessageHook() {
    return !this.sendMessageHookList.isEmpty();
}

在这里插入图片描述
  这个也是判断了需要操作的HookList中是否有对应的Hook。从上面的两个Hook中可以看到,似乎提供的是一个动态Hook的机制,有多少以父类为接口的Hook只要被加载到列表中就会进行Hook的操作。这里的这些Hook就是为了追踪Message的流向,RocketMQ有一个admin模块还commond命令模块,就是提供这里使用的。
在这里插入图片描述
  封装完成Hook之后就开始进入到正常的Message的封装。上面代码封装了一个SendMessageRequestHeader,从字面的意思是发送消息的请求头。要支持一个协议,通常在请求头中规定了一些公共的约定,这些约的对于所有的消息发送接收都是适用的。而这其中需要重点注意的就是一个之前说过的标识sysFlag,这个标识会随着操作进行改变,还有这里需要注意另外的一个标识UnitMode单元模式,这个需要注意一下,后续的分享中都会遇到。这里还需要注意的一点,就是对于这些配置属性的操作。
在这里插入图片描述
  封装完请求头之后,发现还是没有知道到真正调用发送的方法在什么地方。继续往后会看到就是对SendResult的封装了。接下来就来分析一下。
  在上面的分析中提到了一个交流模式,在发送的时候第一步就是对着模式的选择,在外部方法中判断交流模式是同步模式,异步模式和单向模式都是为null的操作。也就是说能进入到核心发送方法的请求都是以同步模式进入的。但是分析上面的代码会看到,其实在异步和同步case中都有很多的处理逻辑。而且最终都调用了一个方法 this.mQClientFactory.getMQClientAPIImpl().sendMessage(),这个方法对于SendResult进行了封装响应。
  到这里终于找到了sendResult是怎么被响应回去的,看上去在Broker中流转的Message,要经过很长时间的发展分装进化,才可以真正被送到Broker中进行流转,有了这些标识之后才能更好的跟踪消息。

总结

  到这里,并没有真正找到SendResult是怎么被创建的,但是至少到这一步,找到了需要获取SendResult需要哪些准备工作,下一篇博客继续来分析来寻找SendResult到底从什么地方被进行了封装。

  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 3
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

nihui123

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值