当前位置:   article > 正文

RocketMQ源码(十一)—Broker 消息重放服务ReputMessageService源码解析

reputmessageservice

此前我们学习了RocketMQ源码(十)—Broker 消息刷盘服务GroupCommitService、FlushRealTimeService、CommitRealTimeService源码深度解析_代码---小白的博客-CSDN博客

这篇文章将梳理的是如何构建消息问价nConsumeQueue和IndexFile。

CommitLog文件顺序存储着所有的信息,理论上来说RokerMQ只要有CommitLog文件就可以正常运行了,但是此前其他博主的文章RocketMQ的底层消息存储架构以及优化措施_刘Java的博客-CSDN博客_rocketmq 底层存储

中介绍过,RocketMQ还存在另外两个重要的文件服务:

1. ConsumeQueue文件:ConsumeQueue文件可以看作是CommitLog的消息偏移量索引文件,其存储了它所属Topic的消息在CommitLog中的偏移量。消费者在拉取消息的时候,可以从ConsumeQueue快速的根据偏移量定位消息在CommitLog中的位置。

2. IndexFile索引文件:IndexFile文件可以看作是CommitLog的消息时间范围索引文件。IndexFile(索引文件)提供了一种可以通过key或时间区间来查询消息的方法。

虽然CommiyLog文件顺序存储着所有的消息,但是并没有区分任何的topic、tag等信息,我们只能顺序遍历CommitLog文件去查找消费数据,性能非常低,因此才有了ConsumeQueue和IndexFile这两种索引文件系统,这两个文件系统的主要目的也是用于加快客户端的消费速或者是查询效率。

此前我们学习了broker的消息刷盘的源码,我们仅仅的了解了CommitLog文件刷盘的流程,心啊在我们来学习ConsumeQueue文件和IndexFile文件的。

1 ReputMessageService消息重放服务

ReputMessageService服务将会在循环中异步的每隔1ms对于写入CommitLog的消息进行重放,即将消息构建成DispatchRequest对象,然后将DispatchRequest对象分发给各个CommitLogDispatcher处理,这些CommitLogDispatcher通常会尝试构建ConsumeQueue索引,IndexFile索引以及SQL92布隆过滤器

ReputMessageService和此前介绍的刷盘服务一样,属于异步线程服务。其随着消息存储对象DefaultMessageStore的创建而创建,并且在DefaultMessageStore:start方法中被启动。我门直接看它的run方法。

  1. /**
  2. * ReputMessageService的方法
  3. * 可以看到,该服务将会在一个循环中,每隔1ms执行一次Reput方法,doReput方法也就是重放的方法
  4. */
  5. @Override
  6. public void run() {
  7. DefaultMessageStore.LOGGER.info(this.getServiceName() + " service started");
  8. /**
  9. * 运行时逻辑
  10. * 如果服务没有停止,则在死循环中执行重放的操作
  11. */
  12. while (!this.isStopped()) {
  13. try {
  14. //睡眠1ms
  15. Thread.sleep(1);
  16. //执行重放
  17. this.doReput();
  18. } catch (Exception e) {
  19. DefaultMessageStore.LOGGER.warn(this.getServiceName() + " service has exception. ", e);
  20. }
  21. }
  22. DefaultMessageStore.LOGGER.info(this.getServiceName() + " service end");
  23. }

可以看到,该服务将会在一个循环中,每隔1ms执行一次doReput方法,doReput方法也就是重放发方法。

2 doReput执行重放

该方法对于写入CommitLog的消息进行重放,所谓的重放就是完成诸如ConsumeQueue索引、IndexFile索引、布隆过滤器、唤醒长轮询线程和被hold住的请求等操作。

该方法的大概逻辑为:

1. 如果重放偏移量reputFromOffset小于commitlog的最小物理偏移量,那么设置为commitlog的最小物理偏移量,如果重放偏移量小于commitlog的最大物理偏移量,那么循环重放。

2. 调用getData方法。根据reputFromOffset的物理偏移量找到mappedFileQueue中对应的CommitLog文件的MappedFile,然后从该MappedFile中截取一段自reputFromOffset偏移量开始的ByteBuffer,这段内存存储着将要重放的消息。
3. 开始循环读取这段ByteBuffer中的消息,依次进行重放。

        3.1 如果存在消息,调用checkMessageAndReturnSize方法。检查当前消息的属性并且构建一个DispatchRequest对象返回。

        3.2 调用doDispatch方法分发重放请求,将会调用所有CommitLogDispatcher#dispatch方法。

                3.2.1 CommitLogDispatcherBuildConsumeQueue:根据DispatchRequest写ConsumeQueue文件,构建ConsumeQueue索引。

                3.2.2 CommitLogDispatcherBuildIndex:根据DispatchRequest写IndexFile文件,构建IndexFile索引。

                3.2.3 CommitLogDispatcherCalcBitMap:根据DispatchRequest构建布隆过滤器,加速SQL92过滤效率,避免每次都解析sql。

        3.3 . 如果broker角色不是SLAVE,并且支持长轮询,并且消息送达的监听器不为null,那么通过该监听器的arriving方法触发调用pullRequestHoldService的pullRequestHoldService方法,即唤醒挂起的拉取消息请求,表示有新的消息落盘,可以进行拉取了。

        3.4 如果读取到MappedFile文件尾,那么获取下一个文件的起始索引继续重放。

  1. /**
  2. * DefaultMessageStore的方法
  3. * <p>
  4. * 执行重放
  5. */
  6. private void doReput() {
  7. //如果重放偏移量reputFromOffset小于commitlog的最小物理偏移量,那么设置为commitlog的最小物理偏移量
  8. if (this.reputFromOffset < DefaultMessageStore.this.commitLog.getMinOffset()) {
  9. log.warn("The reputFromOffset={} is smaller than minPyOffset={}, this usually indicate that the dispatch behind too much and the commitlog has expired.",
  10. this.reputFromOffset, DefaultMessageStore.this.commitLog.getMinOffset());
  11. this.reputFromOffset = DefaultMessageStore.this.commitLog.getMinOffset();
  12. }
  13. /*
  14. *
  15. * 如果重放偏移量小于commitlog的最大物理偏移量,那么循环重放
  16. */
  17. for (boolean doNext = true; this.isCommitLogAvailable() && doNext; ) {
  18. //如果消息允许重复复制(默认为 false)并且reputFromOffset大于等于已确定的偏移量confirmOffset,那么结束循环
  19. if (DefaultMessageStore.this.getMessageStoreConfig().isDuplicationEnable()
  20. && this.reputFromOffset >= DefaultMessageStore.this.getConfirmOffset()) {
  21. break;
  22. }
  23. /*
  24. * 根据reputFromOffset的物理偏移量找到mappedFileQueue中对应的CommitLog文件的MappedFile
  25. * 然后从该MappedFile中截取一段自reputFromOffset偏移量开始的ByteBuffer,这段内存存储着将要重放的消息
  26. */
  27. SelectMappedBufferResult result = DefaultMessageStore.this.commitLog.getData(reputFromOffset);
  28. if (result != null) {
  29. try {
  30. //将截取的起始物理偏移量设置为重放偏起始移量
  31. this.reputFromOffset = result.getStartOffset();
  32. /*
  33. * 开始读取这段ByteBuffer中的消息,依次进行重放
  34. */
  35. for (int readSize = 0; readSize < result.getSize() && doNext; ) {
  36. //检查消息的属性并且构建一个DispatchRequest对象返回
  37. DispatchRequest dispatchRequest =
  38. DefaultMessageStore.this.commitLog.checkMessageAndReturnSize(result.getByteBuffer(), false, false);
  39. //消息大小,如果是基于Dledger技术的高可用DLedgerCommitLog则取bufferSize
  40. int size = dispatchRequest.getBufferSize() == -1 ? dispatchRequest.getMsgSize() : dispatchRequest.getBufferSize();
  41. if (dispatchRequest.isSuccess()) {
  42. //如果大小大于0,表示有消息
  43. if (size > 0) {
  44. /*
  45. * 分发请求
  46. * 1. CommitLogDispatcherBuildConsumeQueue:根据DispatchRequest写ConsumeQueue文件,构建ConsumeQueue索引。
  47. * 2. CommitLogDispatcherBuildIndex:根据DispatchRequest写IndexFile文件,构建IndexFile索引。
  48. * 3. CommitLogDispatcherCalcBitMap:根据DispatchRequest构建布隆过滤器,加速SQL92过滤效率,避免每次都解析sql。
  49. */
  50. DefaultMessageStore.this.doDispatch(dispatchRequest);
  51. //如果broker角色不是SLAVE,并且支持长轮询,并且消息送达的监听器不为null
  52. if (BrokerRole.SLAVE != DefaultMessageStore.this.getMessageStoreConfig().getBrokerRole()
  53. && DefaultMessageStore.this.brokerConfig.isLongPollingEnable()
  54. && DefaultMessageStore.this.messageArrivingListener != null) {
  55. //通过该监听器的arriving方法触发调用pullRequestHoldService的pullRequestHoldService方法
  56. //即唤醒挂起的拉取消息请求,表示有新的消息落盘,可以进行拉取了
  57. //这里涉及到RocketMQ的consumer消费push模式的实现,后面会专门讲解consumer消费
  58. DefaultMessageStore.this.messageArrivingListener.arriving(dispatchRequest.getTopic(),
  59. dispatchRequest.getQueueId(), dispatchRequest.getConsumeQueueOffset() + 1,
  60. dispatchRequest.getTagsCode(), dispatchRequest.getStoreTimestamp(),
  61. dispatchRequest.getBitMap(), dispatchRequest.getPropertiesMap());
  62. notifyMessageArrive4MultiQueue(dispatchRequest);
  63. }
  64. //设置重放偏起始移量加上当前消息大小
  65. this.reputFromOffset += size;
  66. //设置读取的大小加上当前消息大小
  67. readSize += size;
  68. //如果是SLAVE角色,那么存储数据的统计信息更新
  69. if (DefaultMessageStore.this.getMessageStoreConfig().getBrokerRole() == BrokerRole.SLAVE) {
  70. DefaultMessageStore.this.storeStatsService
  71. .getSinglePutMessageTopicTimesTotal(dispatchRequest.getTopic()).add(1);
  72. DefaultMessageStore.this.storeStatsService
  73. .getSinglePutMessageTopicSizeTotal(dispatchRequest.getTopic())
  74. .add(dispatchRequest.getMsgSize());
  75. }
  76. } else if (size == 0) {
  77. //如果等于0,表示读取到MappedFile文件尾
  78. //获取下一个文件的起始索引
  79. this.reputFromOffset = DefaultMessageStore.this.commitLog.rollNextFile(this.reputFromOffset);
  80. //设置readSize为0,将会结束循环
  81. readSize = result.getSize();
  82. }
  83. } else if (!dispatchRequest.isSuccess()) {
  84. if (size > 0) {
  85. log.error("[BUG]read total count not equals msg total size. reputFromOffset={}", reputFromOffset);
  86. this.reputFromOffset += size;
  87. } else {
  88. doNext = false;
  89. // If user open the dledger pattern or the broker is master node,
  90. // it will not ignore the exception and fix the reputFromOffset variable
  91. if (DefaultMessageStore.this.getMessageStoreConfig().isEnableDLegerCommitLog() ||
  92. DefaultMessageStore.this.brokerConfig.getBrokerId() == MixAll.MASTER_ID) {
  93. log.error("[BUG]dispatch message to consume queue error, COMMITLOG OFFSET: {}",
  94. this.reputFromOffset);
  95. this.reputFromOffset += result.getSize() - readSize;
  96. }
  97. }
  98. }
  99. }
  100. } finally {
  101. result.release();
  102. }
  103. } else {
  104. //如果重做完毕,则跳出循环
  105. doNext = false;
  106. }
  107. }
  108. }

2.1 isCommitLogAvailable是否需要重放

该方法用于判断CommitLog是否需要执行重放,如果重放偏移量小于commitlog的最大物理偏移量,那么就需要执行重放。

  1. /**
  2. * ReputMessageService的方法
  3. * CommitLog是否需要执行重放
  4. */
  5. private boolean isCommitLogAvailable() {
  6. //重放偏移量是否小于commitlog的最大物理偏移量
  7. return this.reputFromOffset < DefaultMessageStore.this.commitLog.getMaxOffset();
  8. }

2.2 getData获取重放数据

根据reputFromOffset的物理偏移量找到mappedFileQueue中对应的CommitLog文件的MappedFile,然后从该MappedFile中截取一段自reputFromOffset偏移量开始的ByteBuffer,这段内存存储着将要重放的消息。

  1. /**
  2. * CommitLog的方法
  3. *
  4. * 获取CommitLog的数据
  5. */
  6. public SelectMappedBufferResult getData(final long offset) {
  7. return this.getData(offset, offset == 0);
  8. }
  9. public SelectMappedBufferResult getData(final long offset, final boolean returnFirstOnNotFound) {
  10. //获取CommitLog文件大小,默认1G
  11. int mappedFileSize = this.defaultMessageStore.getMessageStoreConfig().getMappedFileSizeCommitLog();
  12. //根据指定的offset从mappedFileQueue中对应的CommitLog文件的MappedFile
  13. MappedFile mappedFile = this.mappedFileQueue.findMappedFileByOffset(offset, returnFirstOnNotFound);
  14. if (mappedFile != null) {
  15. //通过指定物理偏移量,除以文件大小,得到指定的相对偏移量
  16. int pos = (int) (offset % mappedFileSize);
  17. //从指定相对偏移量开始截取一段ByteBuffer,这段内存存储着将要重放的消息。
  18. SelectMappedBufferResult result = mappedFile.selectMappedBuffer(pos);
  19. return result;
  20. }
  21. return null;
  22. }

2.2.1 selectMappedBuffer截取一段内存

从指定相对偏移量开始从指定MappedFile中的mappedByteBuffer中截取一段ByteBuffer,这段内存存储着将要重放的消息。这段ByteBuffer和原mappedByteBuffer共享同一块内存,但是拥有自己的指针。

然后根据起始物理索引、截取的ByteBuffer、截取的ByteBuffer大小以及当前CommitLog对象构建一个SelectMappedBufferResult对象返回。

  1. /**
  2. * MappedFile的方法
  3. * @param pos 相对偏移量
  4. */
  5. public SelectMappedBufferResult selectMappedBuffer(int pos) {
  6. //获取写入位置,即最大偏移量
  7. int readPosition = getReadPosition();
  8. //如果指定相对偏移量小于最大偏移量并且大于等于0,那么截取内存
  9. if (pos < readPosition && pos >= 0) {
  10. if (this.hold()) {
  11. //从mappedByteBuffer截取一段内存
  12. ByteBuffer byteBuffer = this.mappedByteBuffer.slice();
  13. byteBuffer.position(pos);
  14. int size = readPosition - pos;
  15. ByteBuffer byteBufferNew = byteBuffer.slice();
  16. byteBufferNew.limit(size);
  17. //根据起始物理索引、新的ByteBuffer、ByteBuffer大小、当前CommitLog对象构建一个SelectMappedBufferResult对象返回
  18. return new SelectMappedBufferResult(this.fileFromOffset + pos, byteBufferNew, size, this);
  19. }
  20. }
  21. return null;
  22. }

2.3 checkMessageAndReturnSize检查消息并构建请求

该方法将会检查这段内存中的下一条消息,这里我们仅仅需要读取消息的各种属性即可,不需要读取具体的消息内容body。最后并且根据这些属性构建一个DispatchRequest对象返回。

需要注意这里有个对于延迟消息的特殊处理,即tagCode属性,对于普通消息就是tags的hashCode值,对于延迟消息则是消息将来投递的时间戳,用于用于后续判断消息是否到期。
 

  1. /**
  2. * CommitLog的方法
  3. *
  4. * @param byteBuffer 一段内存
  5. * @param checkCRC 是否校验CRC
  6. * @param readBody 是否读取消息体
  7. */
  8. public DispatchRequest checkMessageAndReturnSize(java.nio.ByteBuffer byteBuffer, final boolean checkCRC,
  9. final boolean readBody) {
  10. try {
  11. // 1 TOTAL SIZE
  12. //消息条目总长度
  13. int totalSize = byteBuffer.getInt();
  14. // 2 MAGIC CODE
  15. //消息的magicCode属性,魔数,用来判断消息是正常消息还是空消息
  16. int magicCode = byteBuffer.getInt();
  17. switch (magicCode) {
  18. case MESSAGE_MAGIC_CODE:
  19. break;
  20. case BLANK_MAGIC_CODE:
  21. //读取到文件末尾
  22. return new DispatchRequest(0, true /* success */);
  23. default:
  24. log.warn("found a illegal magic code 0x" + Integer.toHexString(magicCode));
  25. return new DispatchRequest(-1, false /* success */);
  26. }
  27. byte[] bytesContent = new byte[totalSize];
  28. //消息体CRC校验码
  29. int bodyCRC = byteBuffer.getInt();
  30. //消息消费队列id
  31. int queueId = byteBuffer.getInt();
  32. //消息flag
  33. int flag = byteBuffer.getInt();
  34. //消息在消息消费队列的偏移量
  35. long queueOffset = byteBuffer.getLong();
  36. //消息在commitlog中的偏移量
  37. long physicOffset = byteBuffer.getLong();
  38. //消息系统flag,例如是否压缩、是否是事务消息
  39. int sysFlag = byteBuffer.getInt();
  40. //消息生产者调用消息发送API的时间戳
  41. long bornTimeStamp = byteBuffer.getLong();
  42. //消息发送者的IP和端口号
  43. ByteBuffer byteBuffer1;
  44. if ((sysFlag & MessageSysFlag.BORNHOST_V6_FLAG) == 0) {
  45. byteBuffer1 = byteBuffer.get(bytesContent, 0, 4 + 4);
  46. } else {
  47. byteBuffer1 = byteBuffer.get(bytesContent, 0, 16 + 4);
  48. }
  49. //消息存储时间
  50. long storeTimestamp = byteBuffer.getLong();
  51. //broker的IP和端口号
  52. ByteBuffer byteBuffer2;
  53. if ((sysFlag & MessageSysFlag.STOREHOSTADDRESS_V6_FLAG) == 0) {
  54. byteBuffer2 = byteBuffer.get(bytesContent, 0, 4 + 4);
  55. } else {
  56. byteBuffer2 = byteBuffer.get(bytesContent, 0, 16 + 4);
  57. }
  58. //消息重试次数
  59. int reconsumeTimes = byteBuffer.getInt();
  60. //事务消息物理偏移量
  61. long preparedTransactionOffset = byteBuffer.getLong();
  62. //消息体长度
  63. int bodyLen = byteBuffer.getInt();
  64. if (bodyLen > 0) {
  65. //读取消息体
  66. if (readBody) {
  67. byteBuffer.get(bytesContent, 0, bodyLen);
  68. if (checkCRC) {
  69. int crc = UtilAll.crc32(bytesContent, 0, bodyLen);
  70. if (crc != bodyCRC) {
  71. log.warn("CRC check failed. bodyCRC={}, currentCRC={}", crc, bodyCRC);
  72. return new DispatchRequest(-1, false/* success */);
  73. }
  74. }
  75. } else {
  76. //不需要读取消息体,那么跳过这段内存
  77. byteBuffer.position(byteBuffer.position() + bodyLen);
  78. }
  79. }
  80. //Topic名称内容大小
  81. byte topicLen = byteBuffer.get();
  82. byteBuffer.get(bytesContent, 0, topicLen);
  83. //topic的值
  84. String topic = new String(bytesContent, 0, topicLen, MessageDecoder.CHARSET_UTF8);
  85. long tagsCode = 0;
  86. String keys = "";
  87. String uniqKey = null;
  88. //消息属性大小
  89. short propertiesLength = byteBuffer.getShort();
  90. Map<String, String> propertiesMap = null;
  91. if (propertiesLength > 0) {
  92. byteBuffer.get(bytesContent, 0, propertiesLength);
  93. //消息属性
  94. String properties = new String(bytesContent, 0, propertiesLength, MessageDecoder.CHARSET_UTF8);
  95. propertiesMap = MessageDecoder.string2messageProperties(properties);
  96. keys = propertiesMap.get(MessageConst.PROPERTY_KEYS);
  97. //客户端生成的uniqId,也被称为msgId,从逻辑上代表客户端生成的唯一一条消息
  98. uniqKey = propertiesMap.get(MessageConst.PROPERTY_UNIQ_CLIENT_MESSAGE_ID_KEYIDX);
  99. //tag
  100. String tags = propertiesMap.get(MessageConst.PROPERTY_TAGS);
  101. //普通消息的tagsCode被设置为tag的hashCode
  102. if (tags != null && tags.length() > 0) {
  103. tagsCode = MessageExtBrokerInner.tagsString2tagsCode(MessageExt.parseTopicFilterType(sysFlag), tags);
  104. }
  105. /*
  106. * 延迟消息处理
  107. * 对于延迟消息,tagsCode被替换为延迟消息的发送时间,主要用于后续判断消息是否到期
  108. */
  109. {
  110. //消息属性中获取延迟级别DELAY字段,如果是延迟消息则生产者会在构建消息的时候设置进去
  111. String t = propertiesMap.get(MessageConst.PROPERTY_DELAY_TIME_LEVEL);
  112. //如果topic是SCHEDULE_TOPIC_XXXX,即延迟消息的topic
  113. if (TopicValidator.RMQ_SYS_SCHEDULE_TOPIC.equals(topic) && t != null) {
  114. int delayLevel = Integer.parseInt(t);
  115. if (delayLevel > this.defaultMessageStore.getScheduleMessageService().getMaxDelayLevel()) {
  116. delayLevel = this.defaultMessageStore.getScheduleMessageService().getMaxDelayLevel();
  117. }
  118. if (delayLevel > 0) {
  119. //tagsCode被替换为延迟消息的发送时间,即真正投递时间
  120. tagsCode = this.defaultMessageStore.getScheduleMessageService().computeDeliverTimestamp(delayLevel,
  121. storeTimestamp);
  122. }
  123. }
  124. }
  125. }
  126. //读取的当前消息的大小
  127. int readLength = calMsgLength(sysFlag, bodyLen, topicLen, propertiesLength);
  128. //不相等则记录BUG
  129. if (totalSize != readLength) {
  130. doNothingForDeadCode(reconsumeTimes);
  131. doNothingForDeadCode(flag);
  132. doNothingForDeadCode(bornTimeStamp);
  133. doNothingForDeadCode(byteBuffer1);
  134. doNothingForDeadCode(byteBuffer2);
  135. log.error(
  136. "[BUG]read total count not equals msg total size. totalSize={}, readTotalCount={}, bodyLen={}, topicLen={}, propertiesLength={}",
  137. totalSize, readLength, bodyLen, topicLen, propertiesLength);
  138. return new DispatchRequest(totalSize, false/* success */);
  139. }
  140. //根据读取的消息属性内容,构建为一个DispatchRequest对象并返回
  141. return new DispatchRequest(
  142. topic,
  143. queueId,
  144. physicOffset,
  145. totalSize,
  146. tagsCode,
  147. storeTimestamp,
  148. queueOffset,
  149. keys,
  150. uniqKey,
  151. sysFlag,
  152. preparedTransactionOffset,
  153. propertiesMap
  154. );
  155. } catch (Exception e) {
  156. }
  157. //读取异常
  158. return new DispatchRequest(-1, false /* success */);
  159. }

2.4 doDispatch分发请求

该方法将构建的DispatchRequest分发出去,即循环调用DefaultMessageStore内部的dispatcherList中的CommitLogDispatcher的dispatch方法,取处理这个请求。

这个方法可以说是ReputMessageService服务的核心代码了,表面面上看仅仅是分发请求。实际上,ConsumeQueue索引、IndexFile索引等操作都是由对应的CommitLogDispatcher来负责实现的。
DefaultMessageStore内部的dispatcherList默认有三个CommitLogDispatcher:

1. CommitLogDispatcherBuildConsumeQueue根据DispatchRequest写ConsumeQueue文件,构建ConsumeQueue索引。

2. CommitLogDispatcherBuildIndex:根据DispatchRequest写IndexFile文件,构建IndexFile索引。

3. CommitLogDispatcherCalcBitMap:根据DispatchRequest构建布隆过滤器,加速SQL92过滤效率,避免每次都解析sql。

  1. /**
  2. * DefaultMessageStore的方法
  3. *
  4. * @param req 分发请求
  5. */
  6. public void doDispatch(DispatchRequest req) {
  7. //循环调用CommitLogDispatcher#dispatch处理
  8. for (CommitLogDispatcher dispatcher : this.dispatcherList) {
  9. dispatcher.dispatch(req);
  10. }
  11. }

3 总结

本次我们学习了ReputMessageService消息重放服务的总体流程,下一篇文章我们将深入学习CommitLogDispatcherBuildConsumeQueue、CommitLogDispatcherBuildIndex到底是如何构建异步构建ConsumeQueue和IndexFile索引文件的。
 

声明:本文内容由网友自发贡献,转载请注明出处:【wpsshop】
推荐阅读
相关标签
  

闽ICP备14008679号