✨Kafka✨生产者消息发送源码解析✨

                                                           为心爱的人努力是一种幸福


前言

本文将对Kafka生产者消息发送的源码进行分析,帮助了解Kafka生产者发送消息时,回调函数如何执行Kafka消息发送的异步和同步原理以及Kafka消息发送的机制

Kafka版本:3.1.1

正文

一. 认识生产者消息ProducerRecord

Kafka客户端的生产者对应的类是KafkaProducer,其有两个发送方法,签名如下。

 

java

代码解读

复制代码

// 没有回调函数的消息发送 public Future<RecordMetadata> send(ProducerRecord<K, V> record) // 有回调函数的消息发送 public Future<RecordMetadata> send(ProducerRecord<K, V> record, Callback callback)

回调函数我们后面再讨论,但是可以先了解一下生产者消息ProducerRecord,其有如下字段。

 

java

代码解读

复制代码

public class ProducerRecord<K, V> { // 消息主题 private final String topic; // 分区 private final Integer partition; // 消息头 private final Headers headers; // 消息键 private final K key; // 消息值 private final V value; // 消息时间戳 private final Long timestamp; ...... }

如果通过KafkaProducer来发送消息,我们可以先将ProducerRecord创建出来,然后通过KafkaProducersend() 方法完成发送,而在创建ProducerRecord时,我们可以通过构造函数指定消息主题分区消息头消息键��息值消息时间戳

二. 应用生产者拦截器

可以为Kafka生产者添加拦截器ProducerInterceptor,其作用时机如下图所示。

三. 拉取集群元数据信息

这是Kafka生产者发送消息时唯一会阻塞的地方。

KafkaProducerdoSend() 方法中,会调用到waitOnMetadata() 方法,该方法实现如下。

 

java

代码解读

复制代码

private ClusterAndWaitTime waitOnMetadata(String topic, Integer partition, long nowMs, long maxWaitMs) throws InterruptedException { Cluster cluster = metadata.fetch(); if (cluster.invalidTopics().contains(topic)) throw new InvalidTopicException(topic); metadata.add(topic, nowMs); Integer partitionsCount = cluster.partitionCountForTopic(topic); if (partitionsCount != null && (partition == null || partition < partitionsCount)) return new ClusterAndWaitTime(cluster, 0); long remainingWaitMs = maxWaitMs; long elapsed = 0; do { if (partition != null) { log.trace("Requesting metadata update for partition {} of topic {}.", partition, topic); } else { log.trace("Requesting metadata update for topic {}.", topic); } metadata.add(topic, nowMs + elapsed); int version = metadata.requestUpdateForTopic(topic); // 唤醒Sender去拉取元数据 sender.wakeup(); try { // 阻塞在这里等待元数据拉取完成 metadata.awaitUpdate(version, remainingWaitMs); } catch (TimeoutException ex) { // 阻塞超时则返回TimeoutException throw new TimeoutException( String.format("Topic %s not present in metadata after %d ms.", topic, maxWaitMs)); } cluster = metadata.fetch(); elapsed = time.milliseconds() - nowMs; if (elapsed >= maxWaitMs) { throw new TimeoutException(partitionsCount == null ? String.format("Topic %s not present in metadata after %d ms.", topic, maxWaitMs) : String.format("Partition %d of topic %s with partition count %d is not present in metadata after %d ms.", partition, topic, partitionsCount, maxWaitMs)); } metadata.maybeThrowExceptionForTopic(topic); remainingWaitMs = maxWaitMs - elapsed; partitionsCount = cluster.partitionCountForTopic(topic); } while (partitionsCount == null || (partition != null && partition >= partitionsCount)); return new ClusterAndWaitTime(cluster, elapsed); }

四. 序列化消息的键和值

消息的keyvalue在发送前需要序列化,对应代码片段在KafkaProducerdoSend() 方法中,代码片段如下。

 

java

代码解读

复制代码

byte[] serializedKey; try { serializedKey = keySerializer.serialize(record.topic(), record.headers(), record.key()); } catch (ClassCastException cce) { throw new SerializationException("Can't convert key of class " + record.key().getClass().getName() + " to class " + producerConfig.getClass(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG).getName() + " specified in key.serializer", cce); } byte[] serializedValue; try { serializedValue = valueSerializer.serialize(record.topic(), record.headers(), record.value()); } catch (ClassCastException cce) { throw new SerializationException("Can't convert value of class " + record.value().getClass().getName() + " to class " + producerConfig.getClass(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG).getName() + " specified in value.serializer", cce); }

五. 计算消息所属分区

使用自定义或默认的分区器获取消息所属分区,对应代码片段在KafkaProducerdoSend() 方法中,代码片段如下。

 

java

代码解读

复制代码

int partition = partition(record, serializedKey, serializedValue, cluster);

partition() 方法如下所示。

整理了一份面试笔记包括了:Java面试、Spring、JVM、MyBatis、Redis、MySQL、并发编程、微服务、Linux、Springboot、SpringCloud、MQ、Kafka 面试专题

需要全套面试笔记的【点击此处即可】即可免费获取

java

代码解读

复制代码

private int partition(ProducerRecord<K, V> record, byte[] serializedKey, byte[] serializedValue, Cluster cluster) { Integer partition = record.partition(); return partition != null ? partition : partitioner.partition( record.topic(), record.key(), serializedKey, record.value(), serializedValue, cluster); }

分区计算策略总结如下。

  1. 消息中指定了分区。此时使用指定的分区;
  2. 消息中未指定分区但有自定义分区器。此时使用自定义分区器计算分区;
  3. 消息中未指定分区也没有自定义分区器但消息键不为空。此时对键求哈希值,并用求得的哈希值对Topic的分区数取模得到分区;
  4. 如果前面都不满足。此时根据Topic取一个递增整数并对Topic分区数求模得到分区。

六. 将消息添加到消息累加器

消息累加器RecordAccumulator,在RecordAccumulator中有一个字段叫做batches,其签名如下。

 

java

代码解读

复制代码

ConcurrentMap<TopicPartition, Deque<ProducerBatch>> batches

也就是根据Topic和分区可以唯一确定一个ProducerBatch的双端队列Deque,新添加的消息会被添加到队列最后一个节点上的ProducerBatch中,具体就是将消息写入到ProducerBatchMemoryRecordsBuilder中,而如果ProducerBatch写满了,则创建新的ProducerBatch然后往里面写。

消息添加到消息累加器后,,会得到一个RecordAppendResult,该对象包含如下信息。

 

java

代码解读

复制代码

public final static class RecordAppendResult { public final FutureRecordMetadata future; public final boolean batchIsFull; public final boolean newBatchCreated; public final boolean abortForNewBatch; public RecordAppendResult(FutureRecordMetadata future, boolean batchIsFull, boolean newBatchCreated, boolean abortForNewBatch) { this.future = future; this.batchIsFull = batchIsFull; this.newBatchCreated = newBatchCreated; this.abortForNewBatch = abortForNewBatch; } }

关键的是RecordAppendResult中包含的FutureRecordMetadata,这就是发送的一个结果future,通过这个结果future可以拿到发送后的消息的元数据RecordMetadata,或者得到消息发送的异常,如果业务代码中是通过KafkaProducersend() 方法来发送消息,那么业务代码调用send() 方法持有的结果就是这个future

七. 唤醒Sender

当将消息添加到消息累加器后,我们会拿到RecordAppendResult,该对象的batchIsFull表示ProducerBatch满了)或newBatchCreated表示新创建了ProducerBatch)为true时,就会唤醒Sender来发送消息,对应代码片段在KafkaProducerdoSend() 方法中,如下所示。

 

java

代码解读

复制代码

if (result.batchIsFull || result.newBatchCreated) { log.trace("Waking up the sender since topic {} partition {} is either full or getting a new batch", record.topic(), partition); this.sender.wakeup(); }

Sender是一个Runnable,所以直接看Senderrun() 方法,如下所示。

 

java

代码解读

复制代码

@Override public void run() { log.debug("Starting Kafka producer I/O thread."); // 主要在这里进行循环 while (running) { try { runOnce(); } catch (Exception e) { log.error("Uncaught error in kafka producer I/O thread: ", e); } } ...... }

runOnce() 方法会调用到sendProducerData() 方法,在该方法中会从RecordAccumulator里将所有可发送的ProducerBatch获取出来并按照Broker进行分组,然后调用sendProduceRequests() 方法进行发送,sendProduceRequests() 方法实现如下。

 

java

代码解读

复制代码

private void sendProduceRequests(Map<Integer, List<ProducerBatch>> collated, long now) { // 按Broker对应的ProducerBatch集合来发送 for (Map.Entry<Integer, List<ProducerBatch>> entry : collated.entrySet()) sendProduceRequest(now, entry.getKey(), acks, requestTimeoutMs, entry.getValue()); }

实际就是将一个Broker的所有可发送的ProducerBatch合并到一个Request中进行发送,继续跟进一下sendProduceRequest() 方法,如下所示。

 

java

代码解读

复制代码

private void sendProduceRequest(long now, int destination, short acks, int timeout, List<ProducerBatch> batches) { ...... ClientRequest clientRequest = client.newClientRequest(nodeId, requestBuilder, now, acks != 0, requestTimeoutMs, callback); // 这里使用NetworkClient来发送Request到对应Broker client.send(clientRequest, now); log.trace("Sent produce request to {}: {}", nodeId, requestBuilder); }

使用NetworkClient发送RequestBroker时,最终会调用到NetworkClientdoSend() 方法,如下所示。

 

java

代码解读

复制代码

private void doSend(ClientRequest clientRequest, boolean isInternalRequest, long now, AbstractRequest request) { String destination = clientRequest.destination(); RequestHeader header = clientRequest.makeHeader(request.version()); if (log.isDebugEnabled()) { log.debug("Sending {} request with header {} and timeout {} to node {}: {}", clientRequest.apiKey(), header, clientRequest.requestTimeoutMs(), destination, request); } Send send = request.toSend(header); // 将Request封装成InFlightRequest InFlightRequest inFlightRequest = new InFlightRequest( clientRequest, header, isInternalRequest, request, send, now); // 将Request对应的InFlightRequest添加到inFlightRequests缓冲区 // InFlightRequest表示已发送且待确认的请求 this.inFlightRequests.add(inFlightRequest); selector.send(new NetworkSend(clientRequest.destination(), send)); }

请求Request会被封装成InFlightRequest然后添加到inFlightRequests缓冲区,InFlightRequest表示已发送且待确认的请求,当RequestSelector完成发送并收到服务端的ACK后,客户端这边就会将Request对应的InFlightRequestinFlightRequests缓冲区移除。

八. 消息发送后的回调

我们通过KafkaProducer的如下send() 方法发送消息时,可以传入回调函数。

 

java

代码解读

复制代码

@Override public Future<RecordMetadata> send(ProducerRecord<K, V> record, Callback callback) { ProducerRecord<K, V> interceptedRecord = this.interceptors.onSend(record); return doSend(interceptedRecord, callback); }

KafkaProducerdoSend() 方法中将消息添加到RecordAccumulator时会一并添加回调函数,代码片段如下。

 

java

代码解读

复制代码

// callback就是调用KafkaProducer的doSend()方法时传入的回调函数 Callback interceptCallback = new InterceptorCallback<>(callback, this.interceptors, tp); // 将消息添加到RecordAccumulator时会一并将回调函数也添加进去 RecordAccumulator.RecordAppendResult result = accumulator.append(tp, timestamp, serializedKey, serializedValue, headers, interceptCallback, remainingWaitMs, true, nowMs);

而将消息添加到RecordAccumulator时其实就是将消息写入到一个ProducerBatch中,此时interceptCallback也会一并添加,如下所示。

 

java

代码解读

复制代码

private RecordAppendResult tryAppend(long timestamp, byte[] key, byte[] value, Header[] headers, Callback callback, Deque<ProducerBatch> deque, long nowMs) { // 拿到待写入的ProducerBatch ProducerBatch last = deque.peekLast(); if (last != null) { // 写入消息时也会一并添加回调函数 FutureRecordMetadata future = last.tryAppend(timestamp, key, value, headers, callback, nowMs); if (future == null) last.closeForRecordAppends(); else return new RecordAppendResult(future, deque.size() > 1 || last.isFull(), false, false); } return null; }

所以继续跟进ProducerBatchtryAppend() 方法,看一下做了什么事情。

 

java

代码解读

复制代码

public FutureRecordMetadata tryAppend(long timestamp, byte[] key, byte[] value, Header[] headers, Callback callback, long now) { if (!recordsBuilder.hasRoomFor(timestamp, key, value, headers)) { return null; } else { this.recordsBuilder.append(timestamp, key, value, headers); this.maxRecordSize = Math.max(this.maxRecordSize, AbstractRecords.estimateSizeInBytesUpperBound(magic(), recordsBuilder.compressionType(), key, value, headers)); this.lastAppendTime = now; // 在这里创建FutureRecordMetadata FutureRecordMetadata future = new FutureRecordMetadata(this.produceFuture, this.recordCount, timestamp, key == null ? -1 : key.length, value == null ? -1 : value.length, Time.SYSTEM); // 基于回调函数和FutureRecordMetadata创建一个Thunk // 然后将创建出来的Thunk添加到当前ProducerBatch的thunks集合中 thunks.add(new Thunk(callback, future)); this.recordCount++; return future; } }

ProducerBatchtryAppend() 方法中,做了如下两件我们关心的事情。

  1. 创建了FutureRecordMetadata。通过该future可以拿到消息发送后的元数据信息RecordMetadata或发送时的异常信息;
  2. 创建了Thunk并添加到ProducerBatchthunks集合。将回调函数和FutureRecordMetadata创建了一个Thunk并添加到当前ProducerBatchthunks集合中。

Thunk其实就是将回调函数和FutureRecordMetadata做了一下关联,那么肯定在某一刻,会使用到Thunk,然后通过FutureRecordMetadata拿到消息发送元数据metadata或者异常exception,再然后就将metadataexception传入回调函数。

所以现在问题转移到了Thunk在哪里被使用的。回顾一下在Sender中,其sendProduceRequest() 方法有如下代码片段。

 

java

代码解读

复制代码

// RequestCompletionHandler顾名思义就是请求完毕后的处理器 RequestCompletionHandler callback = response -> handleProduceResponse(response, recordsByPartition, time.milliseconds()); String nodeId = Integer.toString(destination); ClientRequest clientRequest = client.newClientRequest(nodeId, requestBuilder, now, acks != 0, requestTimeoutMs, callback); client.send(clientRequest, now);

我们在发送ProducerBatch时,会将同一个Broker上的可以发送的ProducerBatch合并到一个Request中进行发送,这里的Request就是ClientRequest,在创建ClientRequest时会传入一个回调函数,这个回调函数在请求结束时会被调用,也就是请求结束时会调用到SenderhandleProduceResponse() 方法,此时当前请求对应的所有ProducerBatch已经被发送,因此就要调用SendercompleteBatch() 方法来将发送的每个ProducerBatch关闭,SendercompleteBatch() 方法实现如下。

 

java

代码解读

复制代码

private void completeBatch(ProducerBatch batch, ProduceResponse.PartitionResponse response, long correlationId, long now) { Errors error = response.error; if (error == Errors.MESSAGE_TOO_LARGE && batch.recordCount > 1 && !batch.isDone() && (batch.magic() >= RecordBatch.MAGIC_VALUE_V2 || batch.isCompressed())) { // 如果当前ProducerBatch太长则分割后重新发送 log.warn( "Got error produce response in correlation id {} on topic-partition {}, splitting and retrying ({} attempts left). Error: {}", correlationId, batch.topicPartition, this.retries - batch.attempts(), formatErrMsg(response)); if (transactionManager != null) transactionManager.removeInFlightBatch(batch); this.accumulator.splitAndReenqueue(batch); maybeRemoveAndDeallocateBatch(batch); this.sensors.recordBatchSplit(); } else if (error != Errors.NONE) { // 如果发生了异常 if (canRetry(batch, response, now)) { // 若ProducerBatch没有超时 // 且没有达到最大重试次数 // 且ProducerBatch没有进入终态即成功或异常 // 且当前发生的异常是可以重试的异常 // 此时ProducerBatch需要重新发送 log.warn("Got error produce response with correlation id {} on topic-partition {}, retrying ({} attempts left). Error: {}", correlationId, batch.topicPartition, this.retries - batch.attempts() - 1, formatErrMsg(response)); reenqueueBatch(batch, now); } else if (error == Errors.DUPLICATE_SEQUENCE_NUMBER) { // 如果是重复序列号异常 // 此时返回消息发送成功 // 但是不会返回消息偏移和消息时间戳 completeBatch(batch, response); } else { // 以消息发送失败的方式来关闭ProducerBatch failBatch(batch, response, batch.attempts() < this.retries); } if (error.exception() instanceof InvalidMetadataException) { if (error.exception() instanceof UnknownTopicOrPartitionException) { log.warn("Received unknown topic or partition error in produce request on partition {}. The " + "topic-partition may not exist or the user may not have Describe access to it", batch.topicPartition); } else { log.warn("Received invalid metadata error in produce request on partition {} due to {}. Going " + "to request metadata update now", batch.topicPartition, error.exception(response.errorMessage).toString()); } metadata.requestUpdate(); } } else { // 以消息发送成功的方式来关闭ProducerBatch completeBatch(batch, response); } if (guaranteeMessageOrder) this.accumulator.unmutePartition(batch.topicPartition); }

上述方法做的事情概括如下。

  1. 如果ProducerBatch发送失败且允许重试则重新发送ProducerBatch。允许重试的条件有:ProducerBatch没有超时没有达到最大重试次数ProducerBatch没有进入终态当前发生的异常是可以重试的异常
  2. 如果ProducerBatch发送失败且不允许重试则执行failBatch()。此时就是以消息发送失败的方式来关闭ProducerBatch
  3. 如果ProducerBatch发送成功则执行completeBatch()。此时就是以消息发送成功的方式来关闭ProducerBatch

因此关闭ProducerBatch要么调用到failBatch() 方法,要么调用到completeBatch() 方法,但无论哪个方法,最终会调用到ProducerBatchdone() 方法,在done() 方法最终会调用到completeFutureAndFireCallbacks() 方法,该方法实现如下。

 

java

代码解读

复制代码

private void completeFutureAndFireCallbacks( long baseOffset, long logAppendTime, Function<Integer, RuntimeException> recordExceptions ) { produceFuture.set(baseOffset, logAppendTime, recordExceptions); for (int i = 0; i < thunks.size(); i++) { try { Thunk thunk = thunks.get(i); if (thunk.callback != null) { if (recordExceptions == null) { // 没有异常时就通过FutureRecordMetadata拿到发送消息的元数据RecordMetadata // 调用FutureRecordMetadata的value()方法是不阻塞的 RecordMetadata metadata = thunk.future.value(); // 然后将RecordMetadata送入回调函数 thunk.callback.onCompletion(metadata, null); } else { // 有异常就拿到这个异常 RuntimeException exception = recordExceptions.apply(i); // 然后将异常送入回调函数 thunk.callback.onCompletion(null, exception); } } } catch (Exception e) { log.error("Error executing user-provided callback on message for topic-partition '{}'", topicPartition, e); } } produceFuture.done(); }

至此消息发送后,回调函数就被执行了。

九. 同步和异步

使用KafkaProducersend() 方法发送消息时,对于KafkaProducer来说,整个的发送其实是完全异步的(除了拉取集群元数据信息)。

在业务代码中,调用了KafkaProducersend() 方法后,会得到FutureRecordMetadata,业务代码中可以通过决定是否调用FutureRecordMetadataget() 方法来实现同步或异步消息发送。

  1. 业务代码调用了FutureRecordMetadataget() 方法就是同步发送;
  2. 业务代码不调用FutureRecordMetadataget() 方法就是异步发送。

十. KafkaTemplate的消息发送模式

Spring中,提供了KafkaTemplate作为Kafka消息发送的客户端,其本质就是对KafkaProducer做了一层封装。KafkaTemplate提供了很多重载的send() 方法,这些方法最终会调用到KafkaTemplatedoSend() 方法,所以KafkaTemplate的消息发送,关键就在于KafkaTemplatedoSend() 方法的逻辑,代码实现如下。

 

java

代码解读

复制代码

protected ListenableFuture<SendResult<K, V>> doSend(final ProducerRecord<K, V> producerRecord) { final Producer<K, V> producer = getTheProducer(producerRecord.topic()); this.logger.trace(() -> "Sending: " + KafkaUtils.format(producerRecord)); // 这里的future会返回给业务代码 final SettableListenableFuture<SendResult<K, V>> future = new SettableListenableFuture<>(); Object sample = null; if (this.micrometerEnabled && this.micrometerHolder == null) { this.micrometerHolder = obtainMicrometerHolder(); } if (this.micrometerHolder != null) { sample = this.micrometerHolder.start(); } // 调用KafkaProducer来发送消息 // 这里固定会传入一个回调函数 // 这个回调函数很关键 Future<RecordMetadata> sendFuture = producer.send(producerRecord, buildCallback(producerRecord, producer, future, sample)); if (sendFuture.isDone()) { try { // 如果上面的isDone()方法立即就返回了true // 通常表明是消息发送快速失败了 // 此时调用sendFuture的get()方法可以抛出异常信息 // 从而业务代码可以感知到消息发送的异常 sendFuture.get(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new KafkaException("Interrupted", e); } catch (ExecutionException e) { throw new KafkaException("Send failed", e.getCause()); } } if (this.autoFlush) { flush(); } this.logger.trace(() -> "Sent: " + KafkaUtils.format(producerRecord)); // 这里返回的是在当前方法中创建出来的SettableListenableFuture // 而不是KafkaProducer返回的FutureRecordMetadata // 这里需要结合上面提到的回调函数一起理解 return future; }

KafkaTemplatedoSend() 方法实际就是调用到KafkaProducersend() 方法完成消息发送,但是区别在于,KafkaTemplatedoSend() 方法返回的是SettableListenableFuture,而KafkaProducersend() 方法返回的是FutureRecordMetadata,之所以KafkaTemplate这么干,是因为KafkaTemplate在调用KafkaProducersend() 方法时传入了一个回调函数,在这个回调函数中,会将消息元数据和异常写到SettableListenableFuture中,从而业务代码就可以通过SettableListenableFuture获取到消息元数据和异常,所以现在看一下buildCallback() 方法构建出了一个什么样的回调函数,如下所示。

 

java

代码解读

复制代码

// 这里的future就是业务代码调用KafkaTemplate的send()方法得到的future // 这里的回调函数干的事情就是: // 1. 如果消息发送没有异常则将消息元数据RecordMetadata写入future // 2. 如果消息发送有异常则将异常信息写入future // 这样业务代码中通过future就可以得到消息发送结果 private Callback buildCallback(final ProducerRecord<K, V> producerRecord, final Producer<K, V> producer, final SettableListenableFuture<SendResult<K, V>> future, @Nullable Object sample) { // 这里的metadata就是RecordMetadata // 这里exception就是发送消息时的异常 return (metadata, exception) -> { try { if (exception == null) { if (sample != null) { this.micrometerHolder.success(sample); } // 没有异常则将消息元数据写入到future // 业务代码通过future就能拿到消息元数据 future.set(new SendResult<>(producerRecord, metadata)); if (KafkaTemplate.this.producerListener != null) { // 执行生产者监听器的onSuccess()逻辑 KafkaTemplate.this.producerListener.onSuccess(producerRecord, metadata); } KafkaTemplate.this.logger.trace(() -> "Sent ok: " + KafkaUtils.format(producerRecord) + ", metadata: " + metadata); } else { if (sample != null) { this.micrometerHolder.failure(sample, exception.getClass().getSimpleName()); } // 有异常则将异常写入到future // 业务代码通过future就能拿到异常 future.setException(new KafkaProducerException(producerRecord, "Failed to send", exception)); if (KafkaTemplate.this.producerListener != null) { // 执行生产者监听器的onError()逻辑 KafkaTemplate.this.producerListener.onError(producerRecord, metadata, exception); } KafkaTemplate.this.logger.debug(exception, () -> "Failed to send: " + KafkaUtils.format(producerRecord)); } } finally { if (!KafkaTemplate.this.transactional) { closeProducer(producer, false); } } }; }

也就是在ProducerBatch完成发送时,上述回调函数会执行,从而在回调函数中会将消息元数据或者异常写到SettableListenableFuture中,从而业务代码可以通过SettableListenableFuture拿到消息元数据或异常。

总结

Kafka生产者消息发送流程用下图进行总结。


总结不易,如果本文对你有帮助,烦请点赞,收藏加关注,谢谢帅气漂亮的你。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值