• RocketMQ源码分析——消费端


    长轮询 (RocketMQ使用)

    Consumer -> Broker RocketMQ采用的长轮询建立连接

    • consumer的处理能力Broker不知道
    • 直接推送消息 broker端压力较大
    • 采用长连接有可能consumer不能及时处理推送过来的数据
    • pull主动权在consumer手里

    短轮询

    client不断发送请求到server,每次都需要重新连接

    长轮询

    client发送请求到server,server有数据返回,没有数据请求挂起不断开连接

    长连接

    连接一旦建立,永远不断开,push方式推送

    消费端

    主要从以下 5步操作 进行源码跟踪

    1. new出 DefaultMQPushConsumer
    DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("consumer");
    
    1. 设置Namesrv地址
    consumer.setNamesrvAddr("192.168.88.134:9876");
    
    1. 订阅topic,并进行过滤。( pullMessageService 启动后 ,会看到内部如何操作)
    • DefaultMQPushConsumer的方法由 defaultMQPushConsumerImpl类进行真正实现
    • 返回subscriptionData ,subExpression要么指定,要么为*
    • mQClientFactory会在 消费客户端启动后,向broker发送心跳包
    consumer.subscribe("tagTopic", "TAG-A");
    
    -----------------------------------------------------------------------------------------
    
    public void subscribe(String topic, String subExpression) throws MQClientException {
    	this.defaultMQPushConsumerImpl.subscribe(this.withNamespace(topic), subExpression);
        }
        
    -----------------------------------------------------------------------------------------
        
    this.rebalanceImpl.getSubscriptionInner().put(topic, subscriptionData);
        
    -----------------------------------------------------------------------------------------
    
    if (this.mQClientFactory != null) {
                    this.mQClientFactory.sendHeartbeatToAllBrokerWithLock();
                }
    
    1. 设置消息监听,并回调
    consumer.registerMessageListener
    
    默认情况下 这条消息只会被 一个consumer 消费到 点对点
    message 状态修改 ( 由broker进行维护 )
    
    ACK (重新投递) 返回消费状态--->CONSUME_SUCCESS 消费成功 || RECONSUME_LATER 消费失败,重新消费
    
    • 返回 Broker RECONSUME_LATER状态时
    • RocketMQ会把这批消息重发回Broker。(topic不是原topic而是这个消费租的RETRY topic 重发topic)
    • 在延迟的某个时间点(默认是10秒,业务可设置)后,再次投递到这个ConsumerGroup的另一个消费者。
    • 如果一直这样重复消费都持续失败到一定次数(默认16次),就会投递到DLQ死信队列。应用可以监控死信队列来做人工干预。
    1. 启动 消费客户端 ( 开启 traceDispatcher 追踪调度 )
    consumer.start();
    this.defaultMQPushConsumerImpl.start();
    
    • 针对 ServiceState 状态进行操作
        刚刚创建	CREATE_JUST,
        正在运行	RUNNING,
        已经关闭	SHUTDOWN_ALREADY,
        开启失败	START_FAILED;
    
    • 检查配置,获取订阅列表 SubscriptionData
    this.checkConfig();
    this.copySubscription();
    
    • 获取MQClient实例
    this.mQClientFactory = MQClientManager.getInstance().getOrCreateMQClientInstance(this.defaultMQPushConsumer, this.rpcHook);
    
    • 注册消费者,并开启客户端
    boolean registerOK = this.mQClientFactory.registerConsumer(this.defaultMQPushConsumer.getConsumerGroup(), this);
    ------------------------------------------------------------------------
    this.mQClientFactory.start();
    

    consumeMessageService启动

    this.consumeMessageService.start();
    

    MQClientInstance启动流程

    this.mQClientAPIImpl.start();
    this.startScheduledTask();
    this.pullMessageService.start();
    this.rebalanceService.start();
    this.defaultMQProducer.getDefaultMQProducerImpl().start(false);
    

    NettyRemotingClient启动

    • 启动Netty远程调用Client (4个工作线程),创建事件执行组并放入Netty管道中
    • NettyRemotingClient定时扫描 ResponseTable
    • 遍历responseTable ,ConcurrentHashMap类型 ,初始容量256
    • 对于超时请求 进行删除操作
    				this.mQClientAPIImpl.start();
    				private int clientWorkerThreads = 4;
    				
    ----------------------------------------------------------------------------------------
     pipeline.addFirst(NettyRemotingClient.this.defaultEventExecutorGroup, "sslHandler", 					NettyRemotingClient.this.sslContext.newHandler(ch.alloc()));
                    NettyRemotingClient.log.info("Prepend SSL handler");
    -----------------------------------------------------------------------------------------
    
    this.timer.scheduleAtFixedRate(new TimerTask() {
                public void run() {
                    try {
                        NettyRemotingClient.this.scanResponseTable();
                    } catch (Throwable var2) {
                        NettyRemotingClient.log.error("scanResponseTable exception", var2);
                    }
    
                }
            }, 3000L, 1000L);
            
            protected final ConcurrentMap<Integer, ResponseFuture> responseTable = new ConcurrentHashMap(256);
            
            if (rf.getBeginTimestamp() + rf.getTimeoutMillis() + 1000L <= System.currentTimeMillis()) {
                    rf.release();
                    it.remove();
                    rfList.add(rf);
                    log.warn("remove timeout request, " + rf);
                }
    
    • channelEventListener 不为空, 开启nettyEventExecutor 事件执行器( 启动ServiceThread线程 )

    • org.apache.rocketmq.remoting.common 属于Netty的ServiceThread
      
    if (this.channelEventListener != null) {
                this.nettyEventExecutor.start();
            }
    

    startScheduledTask启动

    • 每120s 判断 NamesrvAddr地址,若为空,便去获取新的地址
     private void startScheduledTask() {
            if (null == this.clientConfig.getNamesrvAddr()) {
                this.scheduledExecutorService.scheduleAtFixedRate(new Runnable() {
                    public void run() {
                        try {
                            MQClientInstance.this.mQClientAPIImpl.fetchNameServerAddr();
                        } catch (Exception var2) {
                            MQClientInstance.this.log.error("ScheduledTask fetchNameServerAddr exception", var2);
                        }
    
                    }
                }, 10000L, 120000L, TimeUnit.MILLISECONDS);
            }
    

    pullMessageService启动 、 实现消息消费 ( 重点 )

    • org.apache.rocketmq.common 属于Rocketmq的ServiceThread
      
    public void start() {
            log.info("Try to start service thread:{} started:{} lastThread:{}", new Object[]{this.getServiceName(), this.started.get(), this.thread});
            if (this.started.compareAndSet(false, true)) {
                this.stopped = false;
                this.thread = new Thread(this, this.getServiceName());
                this.thread.setDaemon(this.isDaemon);
                this.thread.start();
            }
        }
    
    final LinkedBlockingQueue<PullRequest> pullRequestQueue = new LinkedBlockingQueue();
    
    • 拉取消息服务
    public void run() {
            this.log.info(this.getServiceName() + " service started");
    
            while(!this.isStopped()) {
                try {
                    PullRequest pullRequest = (PullRequest)this.pullRequestQueue.take();
                    
    -----------------------------------------------------------------------------------------
                    this.pullMessage(pullRequest);
    -----------------------------------------------------------------------------------------
    
                } catch (InterruptedException var2) {
                    ;
                } catch (Exception var3) {
                    this.log.error("Pull Message Service Run Method exception", var3);
                }
            }
    
            this.log.info(this.getServiceName() + " service end");
        }
    
    • LinkedBlockingQueue pullRequestQueue 拉取队列中取出一个拉取请求
    • 获取AtomicInteger、可中断的ReentrantLock重入锁
    • lockInterruptibly(); 可中断重入锁 (一旦检测到中断请求,方法返回不再参与锁竞争,直接抛出中断异常)
    public E take() throws InterruptedException {
            E x;
            int c = -1;
            final AtomicInteger count = this.count;
            final ReentrantLock takeLock = this.takeLock;
            takeLock.lockInterruptibly();
            try {
                while (count.get() == 0) {
                    notEmpty.await();
                }
                x = dequeue();
                c = count.getAndDecrement();
                if (c > 1)
                    notEmpty.signal();
            } finally {
                takeLock.unlock();
            }
            if (c == capacity)
                signalNotFull();
            return x;
        }
    
    • dequeue 出队列,出一个PullRequest拉取请求

    • PullRequest拉取请求包括:消费组,messageQueue(元消息队列包括:topic、brokerName、queueId )

    • processQueue 处理队列主要包括:(TreeMap<Long, MessageExt> 存放消息)

    • private String consumerGroup;
      private MessageQueue messageQueue;
      private ProcessQueue processQueue;
      private long nextOffset;
      
    private E dequeue() {
            // assert takeLock.isHeldByCurrentThread();
            // assert head.item == null;
            Node<E> h = head;
            Node<E> first = h.next;
            h.next = h; // help GC
            head = first;
            E x = first.item;
            first.item = null;
            return x;
        }
    
    • 拉取消息服务中 this.pullMessage(pullRequest);
    • 获取消费者,准备进行processQueue 消费
     private void pullMessage(PullRequest pullRequest) {
     
    -----------------------------------------------------------------------------------------
            MQConsumerInner consumer = this.mQClientFactory.selectConsumer(pullRequest.getConsumerGroup());
    -----------------------------------------------------------------------------------------
    
            if (consumer != null) {
                DefaultMQPushConsumerImpl impl = (DefaultMQPushConsumerImpl)consumer;
                
    -----------------------------------------------------------------------------------------
                impl.pullMessage(pullRequest);
    -----------------------------------------------------------------------------------------
            } else {
                this.log.warn("No matched consumer for the PullRequest {}, drop it", pullRequest);
            }
        }
    
    • 拉取采用异步回调方法,onSuccess( PullResult pullResult )

    • pullResult.getMsgFoundList() 结果为 List<MessageExt> msgFoundList
      
    • submitConsumeRequest 两个实现 ConcurrentlyService 和 OrderlyService 多线程消费和顺序消费

    • executePullRequestImmediately ,将pullRequest put () pullRequestQueue 中

    PullCallback pullCallback = new PullCallback()
    public void onSuccess(PullResult pullResult) {
    switch(pullResult.getPullStatus()) {
    	case FOUND:
    
    pullRequest.setNextOffset(pullResult.getNextBeginOffset());
    
    DefaultMQPushConsumerImpl.this.consumeMessageService.submitConsumeRequest(pullResult.getMsgFoundList(), processQueue, pullRequest.getMessageQueue(), dispatchToConsume);
    
    -----------------------------------------------------------------------------------------
    if (DefaultMQPushConsumerImpl.this.defaultMQPushConsumer.getPullInterval() > 0L) {        DefaultMQPushConsumerImpl.this.executePullRequestLater(pullRequest, DefaultMQPushConsumerImpl.this.defaultMQPushConsumer.getPullInterval());
                                                } else {
     DefaultMQPushConsumerImpl.this.executePullRequestImmediately(pullRequest);
                                                }
    -----------------------------------------------------------------------------------------                               
     this.pullAPIWrapper.pullKernelImpl(pullRequest.getMessageQueue(), subExpression, subscriptionData.getExpressionType(), subscriptionData.getSubVersion(), pullRequest.getNextOffset(), this.defaultMQPushConsumer.getPullBatchSize(), sysFlag, commitOffsetValue, 15000L, 30000L, CommunicationMode.ASYNC, pullCallback);
     	}
     }
    
    • this.pullAPIWrapper.pullKernelImpl + pullCallback 回调方法 处理拉取到消息PullResult
    public class PullResult {
        private final PullStatus pullStatus;
        private final long nextBeginOffset;
        private final long minOffset;
        private final long maxOffset;
        private List<MessageExt> msgFoundList;
    
    • ConcurrentlyService 并发消费服务

    • 并发消费 和 顺序消费 run执行体 大体相同,

    • 主要区别在于:生产者向指定queue队列发送消息,跟普通消息相比,顺序消息的使用需要在producer的send()方法中添加MessageQueueSelector接口的实现类,并重写select选择使用的队列,因为顺序消息局部顺序,需要将所有消息指定发送到同一队列中。

    • 消费者 设置最大最小线程数为1,并实现MessageListenerOrderly 接口进行消息消费


    • msgs 小于等于 consumeMessageBatchMaxSize ,new出 consumeRequest , 在线程池消费

    • if (msgs.size() <= consumeBatchSize) 
      
    • 若大于consumeMessageBatchMaxSize ,每次只能消费consumeMessageBatchMaxSize 数量的消息

    • private String consumerGroup;
      private List&lt;MessageExt&gt; msgList;
      private MessageQueue mq;
      private boolean success;
      private String status;
      private Object mqTraceContext;
      private Map&lt;String, String&gt; props;
      private String namespace;
      
    • 并发消费线程池

    this.consumeExecutor = new ThreadPoolExecutor(this.defaultMQPushConsumer.getConsumeThreadMin(), this.defaultMQPushConsumer.getConsumeThreadMax(), 60000L, TimeUnit.MILLISECONDS, this.consumeRequestQueue, new ThreadFactoryImpl("ConsumeMessageThread_"));
    
    • MessageListenerConcurrently 并发消费监听接口
    • public interface MessageListenerConcurrently extends MessageListener {
          ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> var1, ConsumeConcurrentlyContext var2);
      }
      
    MessageListenerConcurrently listener = ConsumeMessageConcurrentlyService.this.messageListener;
                    ConsumeConcurrentlyContext context = new  
                    ConsumeConcurrentlyContext(this.messageQueue);
                    ConsumeConcurrentlyStatus status = null;
                    ConsumeMessageConcurrentlyService.this.defaultMQPushConsumerImpl.resetRetryAndNamespace(this.msgs,ConsumeMessageConcurrentlyService.this.defaultMQPushConsumer.getConsumerGroup());
                    ConsumeMessageContext consumeMessageContext = null;
         if (ConsumeMessageConcurrentlyService.this.defaultMQPushConsumerImpl.hasHook()) {
                        consumeMessageContext = new ConsumeMessageContext();
                        consumeMessageContext.setNamespace(ConsumeMessageConcurrentlyService.this.defaultMQPushConsumer.getNamespace());
                        consumeMessageContext.setConsumerGroup(ConsumeMessageConcurrentlyService.this.defaultMQPushConsumer.getConsumerGroup());
                        consumeMessageContext.setProps(new HashMap());
                        consumeMessageContext.setMq(this.messageQueue);
                        consumeMessageContext.setMsgList(this.msgs);
                        consumeMessageContext.setSuccess(false);
                        ConsumeMessageConcurrentlyService.this.defaultMQPushConsumerImpl.executeHookBefore(consumeMessageContext);
                    }
                    long beginTimestamp = System.currentTimeMillis();
                    boolean hasException = false;
                    ConsumeReturnType returnType = ConsumeReturnType.SUCCESS;
    
    • OrderlyService 顺序消费服务

    • new出 consumeRequest , 在线程池消费

    • ConsumeMessageOrderlyService.ConsumeRequest consumeRequest = new ConsumeMessageOrderlyService.ConsumeRequest(processQueue, messageQueue);
      
    • consumeExecutor 顺序消费线程池 执行 consumeRequest

    • this.consumeExecutor.submit(consumeRequest);
      
    • this.consumeExecutor = new ThreadPoolExecutor(this.defaultMQPushConsumer.getConsumeThreadMin(), this.defaultMQPushConsumer.getConsumeThreadMax(), 60000L, TimeUnit.MILLISECONDS, this.consumeRequestQueue, new ThreadFactoryImpl("ConsumeMessageThread_"));
      
    • consumeRequest实现Runnable接口,下面是它的run()方法

    • class ConsumeRequest implements Runnable {
          private final ProcessQueue processQueue;
          private final MessageQueue messageQueue;
      
    • 取出List 消息集合

    List<MessageExt> msgs = this.processQueue.takeMessags(consumeBatchSize);
    
    • resetRetryAndNamespace 过滤重投消息
    ConsumeMessageOrderlyService.this.defaultMQPushConsumerImpl.resetRetryAndNamespace(msgs, ConsumeMessageOrderlyService.this.defaultMQPushConsumer.getConsumerGroup());
    
    • 遍历 消息集合, 找出属性为RETRY_TOPIC 重投的消息 , 设置该消息的topic

    • String retryTopic = msg.getProperty("RETRY_TOPIC");
      if (retryTopic != null && groupTopic.equals(msg.getTopic())) {
          msg.setTopic(retryTopic);
      }
      
      if (StringUtils.isNotEmpty(this.defaultMQPushConsumer.getNamespace())) {
          msg.setTopic(NamespaceUtil.withoutNamespace(msg.getTopic(), this.defaultMQPushConsumer.getNamespace()));
      }
      
    if (!msgs.isEmpty()) {
              ConsumeOrderlyContext context = new ConsumeOrderlyContext(this.messageQueue);
              ConsumeOrderlyStatus status = null;
              ConsumeMessageContext consumeMessageContext = null;
           if (ConsumeMessageOrderlyService.this.defaultMQPushConsumerImpl.hasHook()) {
                    consumeMessageContext = new ConsumeMessageContext();
                                            consumeMessageContext.setConsumerGroup(ConsumeMessageOrderlyService.this.defaultMQPushConsumer.getConsumerGroup());
                                            consumeMessageContext.setNamespace(ConsumeMessageOrderlyService.this.defaultMQPushConsumer.getNamespace());
                    consumeMessageContext.setMq(this.messageQueue);
                    consumeMessageContext.setMsgList(msgs);
                    consumeMessageContext.setSuccess(false);
                    consumeMessageContext.setProps(new HashMap());
                                            ConsumeMessageOrderlyService.this.defaultMQPushConsumerImpl.executeHookBefore(consumeMessageContext);
                             }
    
                  long beginTimestamp = System.currentTimeMillis();
                  ConsumeReturnType returnType = ConsumeReturnType.SUCCESS;
    
    • MessageListenerOrderly 顺序消费监听接口 继承了 messageListener 接口

    • MessageListenerOrderly 就是 我们在设置 监听订阅时 回调用的接口,重写此方法进行消息消费

    • public interface MessageListenerOrderly extends MessageListener {
          ConsumeOrderlyStatus consumeMessage(List<MessageExt> var1, ConsumeOrderlyContext var2);
      }
      
    • 就是这一行,如果重写乐监听接口,就能消费消息

    -----------------------------------------------------------------------------------------
    status = ConsumeMessageOrderlyService.this.messageListener.consumeMessage(
        Collections.unmodifiableList(msgs), context  );
    -----------------------------------------------------------------------------------------
    
    • 消息在消费前后 executeHookBefore,executeHookAfter ( Hook进行调用 )
        if (ConsumeMessageOrderlyService.this.defaultMQPushConsumerImpl.hasHook()) {
                                            consumeMessageContext.setStatus(status.toString());
                                            consumeMessageContext.setSuccess(ConsumeOrderlyStatus.SUCCESS == status || ConsumeOrderlyStatus.COMMIT == status);
                                            ConsumeMessageOrderlyService.this.defaultMQPushConsumerImpl.executeHookAfter(consumeMessageContext);
                                        }
    

    rebalanceService启动

    • 和pullMessageService一样启动 Rocketmq的ServiceThread

    • 同一个抽象类 rebalanceService 和 pullMessageService 为具体实现

    • public abstract class ServiceThread implements Runnable

    • 等待间隔

    private static long waitInterval = Long.parseLong(System.getProperty("rocketmq.client.rebalance.waitInterval", "20000"));
    
      public void run() {
            this.log.info(this.getServiceName() + " service started");
    
            while(!this.isStopped()) {
                this.waitForRunning(waitInterval);
                this.mqClientFactory.doRebalance();
            }
        
            this.log.info(this.getServiceName() + " service end");
        }
    
    • 进行负载
    public void doRebalance() {
            Iterator var1 = this.consumerTable.entrySet().iterator();
    
            while(var1.hasNext()) {
                Entry<String, MQConsumerInner> entry = (Entry)var1.next();
                MQConsumerInner impl = (MQConsumerInner)entry.getValue();
                if (impl != null) {
                    try {
                        impl.doRebalance();
                    } catch (Throwable var5) {
                        this.log.error("doRebalance exception", var5);
                    }
                }
            }
    
     public void doRebalance() {
            if (!this.pause) {
                this.rebalanceImpl.doRebalance(this.isConsumeOrderly());
            }
    
        }
    
    • 获取订阅列表
    • 广播和集群两种模式 BROADCASTING: CLUSTERING:
    private void rebalanceByTopic(String topic, boolean isOrder) {
            Set mqSet;
    
    -----------------------------------------------------------------------------------------
    广播模式::::
            switch(this.messageModel) {
            case BROADCASTING:
                mqSet = (Set)this.topicSubscribeInfoTable.get(topic);
                if (mqSet != null) {
    -----------------------------------------------------------------------------------------
    
    清理不重要的消息 ( 同一个topic下,清理没有在topicSubscribeInfoTable订阅列表中的MessageQueue )  
    boolean changed = this.updateProcessQueueTableInRebalance(topic, mqSet, isOrder);
    
    -----------------------------------------------------------------------------------------
       if (changed) {
         this.messageQueueChanged(topic, mqSet, mqSet);
         log.info("messageQueueChanged {} {} {} {}", new Object[]{this.consumerGroup, topic, mqSet, mqSet});
                }
                } else {
         log.warn("doRebalance, {}, but the topic[{}] not exist.", this.consumerGroup, topic);
                }
                break;
                
    
    • 集群模式下:::
    • 获取同一个 消费组中 ,订阅同一个topic的 消费者列表
    集群模式::::
          case CLUSTERING:
          mqSet = (Set)this.topicSubscribeInfoTable.get(topic);
      List<String> cidAll = this.mQClientFactory.findConsumerIdList(topic, this.consumerGroup);
                if (null == mqSet && !topic.startsWith("%RETRY%")) {
          log.warn("doRebalance, {}, but the topic[{}] not exist.", this.consumerGroup, topic);
                }
    
                if (null == cidAll) {
         log.warn("doRebalance, {} {}, get consumer id list failed", this.consumerGroup, topic);
                }
    
                if (mqSet != null && cidAll != null) {
                    List<MessageQueue> mqAll = new ArrayList();
                    mqAll.addAll(mqSet);
                    Collections.sort(mqAll);
                    Collections.sort(cidAll);
      AllocateMessageQueueStrategy strategy = this.allocateMessageQueueStrategy;
                    List allocateResult = null;
    
    • allocateMessageQueueStrategy 分配消息队列策略
    • 为当前消费端 分配消息队列MessageQueue
                    try {
             allocateResult = strategy.allocate(this.consumerGroup, this.mQClientFactory.getClientId(), mqAll, cidAll);
                    } catch (Throwable var10) {
                        log.error("AllocateMessageQueueStrategy.allocate Exception. allocateMessageQueueStrategyName={}", strategy.getName(), var10);
                        return;
                    }
    
                    Set<MessageQueue> allocateResultSet = new HashSet();
                    if (allocateResult != null) {
                        allocateResultSet.addAll(allocateResult);
                    }
    
               boolean changed = this.updateProcessQueueTableInRebalance(topic, allocateResultSet, isOrder);
                    if (changed) {
                        log.info("rebalanced result changed. allocateMessageQueueStrategyName={}, group={}, topic={}, clientId={}, mqAllSize={}, cidAllSize={}, rebalanceResultSize={}, rebalanceResultSet={}", new Object[]{strategy.getName(), this.consumerGroup, topic, this.mQClientFactory.getClientId(), mqSet.size(), cidAll.size(), allocateResultSet.size(), allocateResultSet});
                        this.messageQueueChanged(topic, mqSet, allocateResultSet);
                    }
                }
            }
    
        }
    

    DefaultMQProducerImpl (消费端默认false,不启动)

    • true的话,传入生产者相关配置 class DefaultMQProducer extends ClientConfig 创建生产者实例
    • 在producerTable生产者列表中 , 进行生产者客户端注册( 本质ConcurrentMap )
      this.mQClientFactory = MQClientManager.getInstance().getOrCreateMQClientInstance(this.defaultMQProducer, this.rpcHook);
      boolean registerOK = this.mQClientFactory.registerProducer(this.defaultMQProducer.getProducerGroup(), this);
    
    
    ConcurrentMap<String, MQProducerInner> producerTable;
    
  • 相关阅读:
    Eventbus的功能
    Linux下xz与tar的区别
    IntelliJ IDEA出现:This file is indented with tabs instead of 4 spaces的问题解决
    IntelliJ IDEA设置properties文件显示中文
    oh-my-zsh官方教程
    Vim出现:_arguments:450: _vim_files: function definition file not found的问题解决
    Ubuntu 16.04下安装zsh和oh-my-zsh
    zsh与oh-my-zsh是什么
    Mac安装IntelliJ IDEA时快捷键冲突设置
    IntelliJ IDEA删除项目
  • 原文地址:https://www.cnblogs.com/JMrLi/p/12792974.html
Copyright © 2020-2023  润新知