大家好,我是半夏之沫 😁😁 一名金融科技领域的JAVA系统研发😊😊
我希望将自己工作和学习中的经验以最朴实,最严谨的方式分享给大家,共同进步👉💓👈
👉👉👉👉👉👉👉👉💓写作不易,期待大家的关注和点赞💓👈👈👈👈👈👈👈👈
👉👉👉👉👉👉👉👉💓关注微信公众号【技术探界】 💓👈👈👈👈👈👈👈👈
前言
本文将对Kafka生产者消息发送的源码进行分析,帮助了解Kafka生产者发送消息时,回调函数如何执行,Kafka消息发送的异步和同步原理以及Kafka消息发送的机制。
Kafka版本:3.1.1
正文
一. 认识生产者消息ProducerRecord
Kafka客户端的生产者对应的类是KafkaProducer,其有两个发送方法,签名如下。
// 没有回调函数的消息发送
public Future<RecordMetadata> send(ProducerRecord<K, V> record)
// 有回调函数的消息发送
public Future<RecordMetadata> send(ProducerRecord<K, V> record, Callback callback)
回调函数我们后面再讨论,但是可以先了解一下生产者消息ProducerRecord,其有如下字段。
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创建出来,然后通过KafkaProducer的send() 方法完成发送,而在创建ProducerRecord时,我们可以通过构造函数指定消息主题,分区,消息头,消息键,消息值和消息时间戳。
二. 应用生产者拦截器
可以为Kafka生产者添加拦截器ProducerInterceptor,其作用时机如下图所示。
三. 拉取集群元数据信息
这是Kafka生产者发送消息时唯一会阻塞的地方。
在KafkaProducer的doSend() 方法中,会调用到waitOnMetadata() 方法,该方法实现如下。
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);
}
四. 序列化消息的键和值
消息的key和value在发送前需要序列化,对应代码片段在KafkaProducer的doSend() 方法中,代码片段如下。
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);
}
五. 计算消息所属分区
使用自定义或默认的分区器获取消息所属分区,对应代码片段在KafkaProducer的doSend() 方法中,代码片段如下。
int partition = partition(record, serializedKey, serializedValue, cluster);
partition() 方法如下所示。
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);
}
分区计算策略总结如下。
- 消息中指定了分区。此时使用指定的分区;
- 消息中未指定分区但有自定义分区器。此时使用自定义分区器计算分区;
- 消息中未指定分区也没有自定义分区器但消息键不为空。此时对键求哈希值,并用求得的哈希值对Topic的分区数取模得到分区;
- 如果前面都不满足。此时根据Topic取一个递增整数并对Topic分区数求模得到分区。
六. 将消息添加到消息累加器
消息累加器即RecordAccumulator,在RecordAccumulator中有一个字段叫做batches,其签名如下。
ConcurrentMap<TopicPartition, Deque<ProducerBatch>> batches
也就是根据Topic和分区可以唯一确定一个ProducerBatch的双端队列Deque,新添加的消息会被添加到队列最后一个节点上的ProducerBatch中,具体就是将消息写入到ProducerBatch的MemoryRecordsBuilder中,而如果ProducerBatch写满了,则创建新的ProducerBatch然后往里面写。
消息添加到消息累加器后,,会得到一个RecordAppendResult,该对象包含如下信息。
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,或者得到消息发送的异常,如果业务代码中是通过KafkaProducer的send() 方法来发送消息,那么业务代码调用send() 方法持有的结果就是这个future。
七. 唤醒Sender
当将消息添加到消息累加器后,我们会拿到RecordAppendResult,该对象的batchIsFull(表示ProducerBatch满了)或newBatchCreated(表示新创建了ProducerBatch)为true时,就会唤醒Sender来发送消息,对应代码片段在KafkaProducer的doSend() 方法中,如下所示。
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,所以直接看Sender的run() 方法,如下所示。
@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() 方法实现如下。
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() 方法,如下所示。
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发送Request到Broker时,最终会调用到NetworkClient的doSend() 方法,如下所示。
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表示已发送且待确认的请求,当Request由Selector完成发送并收到服务端的ACK后,客户端这边就会将Request对应的InFlightRequest从inFlightRequests缓冲区移除。
八. 消息发送后的回调
我们通过KafkaProducer的如下send() 方法发送消息时,可以传入回调函数。
@Override
public Future<RecordMetadata> send(ProducerRecord<K, V> record, Callback callback) {
ProducerRecord<K, V> interceptedRecord = this.interceptors.onSend(record);
return doSend(interceptedRecord, callback);
}
在KafkaProducer的doSend() 方法中将消息添加到RecordAccumulator时会一并添加回调函数,代码片段如下。
// 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也会一并添加,如下所示。
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;
}
所以继续跟进ProducerBatch的tryAppend() 方法,看一下做了什么事情。
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;
}
}
在ProducerBatch的tryAppend() 方法中,做了如下两件我们关心的事情。
- 创建了FutureRecordMetadata。通过该future可以拿到消息发送后的元数据信息RecordMetadata或发送时的异常信息;
- 创建了Thunk并添加到ProducerBatch的thunks集合。将回调函数和FutureRecordMetadata创建了一个Thunk并添加到当前ProducerBatch的thunks集合中。
Thunk其实就是将回调函数和FutureRecordMetadata做了一下关联,那么肯定在某一刻,会使用到Thunk,然后通过FutureRecordMetadata拿到消息发送元数据metadata或者异常exception,再然后就将metadata和exception传入回调函数。
所以现在问题转移到了Thunk在哪里被使用的。回顾一下在Sender中,其sendProduceRequest() 方法有如下代码片段。
// 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时会传入一个回调函数,这个回调函数在请求结束时会被调用,也就是请求结束时会调用到Sender的handleProduceResponse() 方法,此时当前请求对应的所有ProducerBatch已经被发送,因此就要调用Sender的completeBatch() 方法来将发送的每个ProducerBatch关闭,Sender的completeBatch() 方法实现如下。
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);
}
上述方法做的事情概括如下。
- 如果ProducerBatch发送失败且允许重试则重新发送ProducerBatch。允许重试的条件有:ProducerBatch没有超时,没有达到最大重试次数,ProducerBatch没有进入终态,当前发生的异常是可以重试的异常;
- 如果ProducerBatch发送失败且不允许重试则执行failBatch()。此时就是以消息发送失败的方式来关闭ProducerBatch;
- 如果ProducerBatch发送成功则执行completeBatch()。此时就是以消息发送成功的方式来关闭ProducerBatch。
因此关闭ProducerBatch要么调用到failBatch() 方法,要么调用到completeBatch() 方法,但无论哪个方法,最终会调用到ProducerBatch的done() 方法,在done() 方法最终会调用到completeFutureAndFireCallbacks() 方法,该方法实现如下。
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();
}
至此消息发送后,回调函数就被执行了。
九. 同步和异步
使用KafkaProducer的send() 方法发送消息时,对于KafkaProducer来说,整个的发送其实是完全异步的(除了拉取集群元数据信息)。
在业务代码中,调用了KafkaProducer的send() 方法后,会得到FutureRecordMetadata,业务代码中可以通过决定是否调用FutureRecordMetadata的get() 方法来实现同步或异步消息发送。
- 业务代码调用了FutureRecordMetadata的get() 方法就是同步发送;
- 业务代码不调用FutureRecordMetadata的get() 方法就是异步发送。
十. KafkaTemplate的消息发送模式
在Spring中,提供了KafkaTemplate作为Kafka消息发送的客户端,其本质就是对KafkaProducer做了一层封装。KafkaTemplate提供了很多重载的send() 方法,这些方法最终会调用到KafkaTemplate的doSend() 方法,所以KafkaTemplate的消息发送,关键就在于KafkaTemplate的doSend() 方法的逻辑,代码实现如下。
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;
}
KafkaTemplate的doSend() 方法实际就是调用到KafkaProducer的send() 方法完成消息发送,但是区别在于,KafkaTemplate的doSend() 方法返回的是SettableListenableFuture,而KafkaProducer的send() 方法返回的是FutureRecordMetadata,之所以KafkaTemplate这么干,是因为KafkaTemplate在调用KafkaProducer的send() 方法时传入了一个回调函数,在这个回调函数中,会将消息元数据和异常写到SettableListenableFuture中,从而业务代码就可以通过SettableListenableFuture获取到消息元数据和异常,所以现在看一下buildCallback() 方法构建出了一个什么样的回调函数,如下所示。
// 这里的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生产者消息发送流程用下图进行总结。
大家好,我是半夏之沫 😁😁 一名金融科技领域的JAVA系统研发😊😊
我希望将自己工作和学习中的经验以最朴实,最严谨的方式分享给大家,共同进步👉💓👈
👉👉👉👉👉👉👉👉💓写作不易,期待大家的关注和点赞💓👈👈👈👈👈👈👈👈
👉👉👉👉👉👉👉👉💓关注微信公众号【技术探界】 💓👈👈👈👈👈👈👈👈