当前位置:   article > 正文

(三)Logback-slf4j日志之常用两种方式打印实现原理_slf4j打印出上一个调用

slf4j打印出上一个调用

目录

一、常用类配置项

二、实现原理

1.UML图

2.实现流程图

三、源码分析

1.Logger

2.AppenderAttachableImpl

3.UnsynchronizedAppenderBase

4.OutputStreamAppender

5.ConsoleAppender

6.RollingFileAppender

7.LayoutWrappingEncoder

8.PatternLayout

9.PatternLayoutBase


如果对Logback的初始化以及和Springboot的集成感兴趣也可以跳转至下列博客:

  1. (一)Logback-slf4j日志原理及源码启动分析
  2. (二)Logback-slf4j日志和Springboot集成配置原理

一、常用类配置项

下列是常用的配置,接下来将会基于下面这些配置来分析Logback的打印实现原理:

  1. <?xml version="1.0" encoding="UTF-8"?>
  2. <configuration>
  3. <contextName>logback</contextName>
  4. <property name="logging.path" value="F:/springboot-demo/logs"/>
  5. <property name="pattern" value="[%d{yyyy-MM-dd HH:mm:ss:SSS}] [%t] [%level] [%logger{80}] - %m%n"/>
  6. <appender name="console" class="ch.qos.logback.core.ConsoleAppender">
  7. <encoder>
  8. <pattern>${pattern}</pattern>
  9. <charset>UTF-8</charset>
  10. </encoder>
  11. </appender>
  12. <appender name="app" class="ch.qos.logback.core.rolling.RollingFileAppender">
  13. <file>${logging.path}/app/log-app.log</file>
  14. <encoder>
  15. <pattern>${pattern}</pattern>
  16. <charset>UTF-8</charset>
  17. </encoder>
  18. <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
  19. <fileNamePattern>${logging.path}/app/log-app-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
  20. <TimeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
  21. <maxFileSize>5MB</maxFileSize>
  22. </TimeBasedFileNamingAndTriggeringPolicy>
  23. </rollingPolicy>
  24. </appender>
  25. <root level="info">
  26. <appender-ref ref="console"/>
  27. <appender-ref ref="app"/>
  28. </root>
  29. </configuration>

二、实现原理

1.UML图

首先看到其主要类的UML图再次了解一下大致结构:

其主要类和第一篇说的差不多,只是在这里贴出了Appender以及搭配使用的Encoder、Layout和Policy等具体实现类。LoggingEvent对象是在Appender以及相关搭配类互相传递的参数,每当调用一次打印日志的方法,都会创建一个LoggingEvent打印事件,具体的打印流程面向的便是这个打印事件。Appender和相关搭配类其具体作用分别为:

  1. ILoggingEvent:日志事件接口,其实现类将会承担Logback实现日志打印的数据载体,在不同的组件中传递,并最终交由Converter处理;
  2. Appender:所有日志打印和Logger交互的入口,可以理解成日志打印流程的载体,所有的流程都会在这里面实现;
  3. Encoder:负责将传入的Log事件转化为一个字节数组;
  4. Layout:将会预先解析用户规定的日志格式,并将其转换成一个个Converter,在最终打印时再使用Converter链将日志时间转换成对应的日志内容字符串;
  5. Converter:具体实现日志事件转换成字符串的类,一般会组成链式的转换器链,将日志事件转换并写到StringBuilder对象中;
  6. TriggeringPolicy:控制了发生日志文件滚动的条件,即滚动触发器。这些条件包括一天中的时间、文件大小、外部事件、日志请求或其组合;
  7. RollingPolicy:具体实现日志文件滚动的接口类,当发生滚动时其还会提供活动日志文件(即当前使用的实时日志文件)。

2.实现流程图

Logback的Logger构成类似于一个树状结构,而Logback在实现打印逻辑时也是根据树状结构的特点去实现的。接下来就分析一下ConsoleAppender和RollingFileAppender这两个最常用Appender大致运行流程,其大致流程图如下:

可以从大致流程图看到,Appender和Converter是以链式存在的。Appender的链式表现为从子节点指向父节点最终到ROOT节点,利用了树状结构;而Converter则是普通的链式,以head开头,使用next指向下一个节点,这个链式则是PatternLayout调用start()方法初始化的,具体实现逻辑便不做过多分析。其具体流程分析图如下:

具体的流程便不介绍,看图片即可。

三、源码分析

在第一和第二篇中说明了Logback的关键组成以及对于配置是如何加载的,在本篇中则说明一下平时使用Logback最常用的两类Appender及其实现原理流程。大致的流程分析可看前面的流程图。

1.Logger

实现SLF4J的Logger接口,开发者使用Logback打印日志的交互类。接下来我们以调用info()和error()两个打印日志方法来一探究竟:

  1. public final class Logger implements org.slf4j.Logger, LocationAwareLogger,
  2. AppenderAttachable<ILoggingEvent>, Serializable {
  3. // Logger的名字
  4. private String name;
  5. // 当前的Logger日志级别Level对象,不能为空
  6. transient private Level level;
  7. // 当前Logger的Level int值,可为空,如果为空则取父类的值
  8. transient private int effectiveLevelInt;
  9. // 父节点Logger
  10. transient private Logger parent;
  11. // 子节点系列Logger
  12. transient private List<Logger> childrenList;
  13. // 处理当前Logger日志处理器Appender集合
  14. transient private AppenderAttachableImpl<ILoggingEvent> aai;
  15. // 当从子节点开始往父节点遍历时,如果additive为false则代表遍历到此结束
  16. transient private boolean additive = true;
  17. // 接下来只贴出info()和error()方法,且参数都是3或以上的方法
  18. // 如果参数是1或者2,最终还是会转换成3或以上的处理方式,因此直接贴3或以上的
  19. public void info(String format, Object... argArray) {
  20. // 如果是打印info级别日志,Level对象直接传Level.INFO,如果是打印warn
  21. // 或者其它级别的日志则方法里面会传对应的级别,调用的其实都是同样的方法
  22. filterAndLog_0_Or3Plus(FQCN, null, Level.INFO, format, argArray,
  23. null);
  24. }
  25. public void error(String format, Object... argArray) {
  26. // 如果是打印error级别日志,Level对象直接传Level.ERROR
  27. filterAndLog_0_Or3Plus(FQCN, null, Level.ERROR, format, argArray,
  28. null);
  29. }
  30. private void filterAndLog_0_Or3Plus(final String localFQCN,
  31. final Marker marker, final Level level, final String msg,
  32. final Object[] params,
  33. final Throwable t) {
  34. // 3或者三个以上的参数最终都会调用到这个方法中,只是Level对象会变成
  35. // 对应的日志级别对象
  36. // 调用TurboFilterList判断,一般用不到,因此暂不分析,等改日有时间再
  37. // 对其进行研究
  38. final FilterReply decision = loggerContext
  39. .getTurboFilterChainDecision_0_3OrMore(marker, this,
  40. level, msg, params, t);
  41. // FilterReply分为三种:NEUTRAL、DENY和ACCEPT
  42. if (decision == FilterReply.NEUTRAL) {
  43. if (effectiveLevelInt > level.levelInt) {
  44. return;
  45. }
  46. } else if (decision == FilterReply.DENY) {
  47. return;
  48. }
  49. // 如果没使用特殊的TurboFilter处理最终会调用到这里
  50. buildLoggingEventAndAppend(localFQCN, marker, level, msg, params,
  51. t);
  52. }
  53. private void buildLoggingEventAndAppend(final String localFQCN,
  54. final Marker marker, final Level level, final String msg,
  55. final Object[] params,
  56. final Throwable t) {
  57. // 不管是什么级别的日志打印方法,最终都会调用到这里来,在这里根据传进来的
  58. // 参数创建LoggingEvent日志事件
  59. LoggingEvent le = new LoggingEvent(localFQCN, this, level, msg, t,
  60. params);
  61. le.setMarker(marker);
  62. // 开始调用本Logger中的Appender集合处理日志事件
  63. callAppenders(le);
  64. }
  65. public void callAppenders(ILoggingEvent event) {
  66. // 当有一个Appender成功处理日志事件时,writes将会+1
  67. int writes = 0;
  68. // 从当前节点往父节点依次遍历,直到ROOT节点
  69. for (Logger l = this; l != null; l = l.parent) {
  70. // 一个Logger可能会有多个Appender处理日志事件,对应的操作便是一个
  71. // Logger可能会有ConsoleAppender也会有FileAppender
  72. writes += l.appendLoopOnAppenders(event);
  73. // 为false代表遍历到此结束,不再往上遍历
  74. if (!l.additive) {
  75. break;
  76. }
  77. }
  78. // 如果没有Appender处理当前日志事件
  79. if (writes == 0) {
  80. // 这个方法实际上会打印"No appenders present in context []
  81. // for logger [].",最常见的便是Zookeeper在log4j中的报错也是
  82. // 类似于这个原因
  83. loggerContext.noAppenderDefinedWarning(this);
  84. }
  85. }
  86. private int appendLoopOnAppenders(ILoggingEvent event) {
  87. if (aai != null) {
  88. // 如果aai对象Appender集合不为空则调用
  89. return aai.appendLoopOnAppenders(event);
  90. } else {
  91. return 0;
  92. }
  93. }
  94. }

跟踪主要代码看起来其实打印日志在Logger中没有经理很多流程,只是大致封装了一下日志事件来调用Appender实现打印日志到控制台或者文件或者其它的地方。

2.AppenderAttachableImpl

用于将Appender和对象绑定起来的实现类,实现的功能便是将Appender和具体的Logger关联起来,其在流程中的主要代码如下:

  1. public class AppenderAttachableImpl<E> implements AppenderAttachable<E> {
  2. // 包含封装的Appender对象
  3. final private COWArrayList<Appender<E>> appenderList =
  4. new COWArrayList<Appender<E>>(new Appender[0]);
  5. public void addAppender(Appender<E> newAppender) {
  6. // 在AppenderAttachableImpl被创建时需要添加Appender到appenderList中
  7. // 便是通过调用这个方法完成的,如果是XML配置方式可看到AppenderRefAction
  8. if (newAppender == null) {
  9. throw new IllegalArgumentException("Null argument disallowed");
  10. }
  11. appenderList.addIfAbsent(newAppender);
  12. }
  13. public int appendLoopOnAppenders(E e) {
  14. int size = 0;
  15. final Appender<E>[] appenderArray = appenderList.asTypedArray();
  16. final int len = appenderArray.length;
  17. // 转换成数组依次遍历,调用Appender的doAppend()方法处理,处理成功则
  18. // size+1,标示着有多少个Appender处理了日志事件
  19. for (int i = 0; i < len; i++) {
  20. appenderArray[i].doAppend(e);
  21. size++;
  22. }
  23. return size;
  24. }
  25. }

从官方对这个对象的定位就可以知道,这个类只是一个中介类,为了实现Appender和Logger关联而存在的,因此这个类中没有很多实际的逻辑。

3.UnsynchronizedAppenderBase

这个Appender抽象类是接下来要分析的ConsoleAppender和RollingFileAppender的共同父类,子类实现逻辑的调用入口也是从这个类调用进去的,其源码如下:

  1. abstract public class UnsynchronizedAppenderBase<E>
  2. extends ContextAwareBase implements Appender<E> {
  3. // 是否调用start()方法成功
  4. protected boolean started = false;
  5. // 守卫,用来保证一个线程只会有一个请求进来
  6. private ThreadLocal<Boolean> guard = new ThreadLocal<Boolean>();
  7. public void doAppend(E eventObject) {
  8. // 如果为TRUE则代表守卫开始工作,不会允许同一个线程同时有两个进来
  9. if (Boolean.TRUE.equals(guard.get())) {
  10. return;
  11. }
  12. try {
  13. // 守卫开始工作
  14. guard.set(Boolean.TRUE);
  15. if (!this.started) {
  16. if (statusRepeatCount++ < ALLOWED_REPEATS) {
  17. // 没有启动成功
  18. }
  19. return;
  20. }
  21. // 再次判断tFilter,一般用不到,暂不分析
  22. if (getFilterChainDecision(eventObject) == FilterReply.DENY) {
  23. return;
  24. }
  25. // 开始调用子类的append方法进行日志处理
  26. this.append(eventObject);
  27. } catch (Exception e) {
  28. if (exceptionCount++ < ALLOWED_REPEATS) {
  29. addError("Appender [" + name + "] failed to append.", e);
  30. }
  31. } finally {
  32. // 守卫工作完了,结束工作
  33. guard.set(Boolean.FALSE);
  34. }
  35. }
  36. abstract protected void append(E eventObject);
  37. }

相当于使用模板方法把处理日志的方法进行了改变。

4.OutputStreamAppender

面向流的父类,是ConsoleAppender、FileAppender和RollingFileAppender这三个需要使用到流的共同父类。其源码如下:

  1. public class OutputStreamAppender<E> extends UnsynchronizedAppenderBase<E>{
  2. // 最终负责将日志事件转变成流所需要的byte[]数组对象
  3. protected Encoder<E> encoder;
  4. protected final ReentrantLock lock = new ReentrantLock(false);
  5. // 管理维护的流对象
  6. private OutputStream outputStream;
  7. // 是否立即将流的数据刷到文件中
  8. boolean immediateFlush = true;
  9. @Override
  10. protected void append(E eventObject) {
  11. if (!isStarted()) {
  12. return;
  13. }
  14. // 这个方法子类可以重写
  15. subAppend(eventObject);
  16. }
  17. protected void subAppend(E event) {
  18. // 就我们使用的ConsoleAppender、FileAppender和RollingFileAppender
  19. // 三个Appender而言,最终都会调用到这个方法中来
  20. if (!isStarted()) {
  21. return;
  22. }
  23. try {
  24. // 调用该方法来防止日志事件没有初始化的问题
  25. // prepareForDeferredProcessing()将会将需要打印日志的格式填充
  26. // 并获取线程名称和MDC属性集合
  27. if (event instanceof DeferredProcessingAware) {
  28. ((DeferredProcessingAware) event)
  29. .prepareForDeferredProcessing();
  30. }
  31. // 调用encoder的encode()方法将日志事件转换成byte[]数组,Converter
  32. // 链也是在这个方法中完成的,稍后分析
  33. byte[] byteArray = this.encoder.encode(event);
  34. // 将字节数组写入到流对象中
  35. writeBytes(byteArray);
  36. } catch (IOException ioe) {
  37. this.started = false;
  38. addStatus(new ErrorStatus("IO failure in appender",this,ioe));
  39. }
  40. }
  41. private void writeBytes(byte[] byteArray) throws IOException {
  42. // 写入的对象为空则直接返回
  43. if(byteArray == null || byteArray.length == 0)
  44. return;
  45. lock.lock();
  46. try {
  47. // 将数据写入到流中
  48. this.outputStream.write(byteArray);
  49. if (immediateFlush) {
  50. // 立刻刷新到流中
  51. this.outputStream.flush();
  52. }
  53. } finally {
  54. lock.unlock();
  55. }
  56. }
  57. }

通过这个类先了解写入流中的最终操作,其它三个实现类的具体处理都是在子类通过增加额外的功能完成的,因此这个可以看成是公共基础部分。

5.ConsoleAppender

这个Appender将会使用用户指定的日志格式将日志事件附加在System.out或者Sytem.err对象流中,默认的流是System.out。代码如下:

  1. public class ConsoleAppender<E> extends OutputStreamAppender<E> {
  2. // 默认的流对象,实际上指向的是System.out
  3. protected ConsoleTarget target = ConsoleTarget.SystemOut;
  4. @Override
  5. public void start() {
  6. // 通过调用start()方法来设置父类的流对象
  7. OutputStream targetStream = target.getStream();
  8. // enable jansi only on Windows and only if withJansi set to true
  9. if (EnvUtil.isWindows() && withJansi) {
  10. targetStream = getTargetStreamForWindows(targetStream);
  11. }
  12. // 设置父类的新流对象,老的流将会关闭
  13. setOutputStream(targetStream);
  14. super.start();
  15. }
  16. }

该类的作用基本上就是将System.out和System.err控制台的流替换到父类的流对象中。

6.RollingFileAppender

这是FileAppender的子类,集成FileAppender功能的同时,提供可滚动文件的额外功能。其代码如下:

  1. public class RollingFileAppender<E> extends FileAppender<E> {
  2. // 当前活动的文件
  3. File currentlyActiveFile;
  4. // 触发滚动条件的判断类
  5. TriggeringPolicy<E> triggeringPolicy;
  6. // 实现具体滚动逻辑的滚动类
  7. RollingPolicy rollingPolicy;
  8. @Override
  9. protected void subAppend(E event) {
  10. // 判断当前的日志对象是否满足滚动条件,如果满足则调用rollover()
  11. // 方法进行文件滚动
  12. synchronized (triggeringPolicy) {
  13. if (triggeringPolicy.isTriggeringEvent(currentlyActiveFile,
  14. event)){
  15. rollover();
  16. }
  17. }
  18. // 调用父类的方法,实际上是OutputStreamAppender的方法
  19. super.subAppend(event);
  20. }
  21. public void rollover() {
  22. lock.lock();
  23. try {
  24. // 关闭当前流对象
  25. this.closeOutputStream();
  26. // 尝试使用rollingPolicy对象滚动文件
  27. attemptRollover();
  28. // 尝试打开滚动后文件的流
  29. attemptOpenFile();
  30. } finally {
  31. lock.unlock();
  32. }
  33. }
  34. private void attemptRollover() {
  35. try {
  36. // 滚动文件
  37. rollingPolicy.rollover();
  38. } catch (RolloverFailure rf) {
  39. this.append = true;
  40. }
  41. }
  42. private void attemptOpenFile() {
  43. try {
  44. // 获取rollingPolicy的活动文件,并设置给currentlyActiveFile对象
  45. currentlyActiveFile = new File(
  46. rollingPolicy.getActiveFileName());
  47. // 尝试打开文件并获取文件流设置给outputStream对象
  48. this.openFile(rollingPolicy.getActiveFileName());
  49. } catch (IOException e) {
  50. addError("setFile(" + fileName + ", false) call failed.", e);
  51. }
  52. }
  53. }

对于具体rollingPolicy和triggeringPolicy的实现方式又有很多方式,具体的实现逻辑暂不分析,但是根据平时使用的结果来看无非是根据命名规则创建一个新的文件,并将老的日志内容移到新的文件中,又或者是更改命名字节创建新的日志文件,大同小异。

7.LayoutWrappingEncoder

听名字就可以看出来这是一个封装类,实际上这里面封装了一个Layout实现类,具体的转换是在Layout实现类完成的。其代码如下:

  1. public class LayoutWrappingEncoder<E> extends EncoderBase<E> {
  2. protected Layout<E> layout;
  3. public byte[] encode(E event) {
  4. // 直接调用layout对象的doLayout()方法对日志时间进行转换
  5. String txt = layout.doLayout(event);
  6. // 再将String转换成byte[]数组
  7. return convertToBytes(txt);
  8. }
  9. private byte[] convertToBytes(String s) {
  10. if (charset == null) {
  11. return s.getBytes();
  12. } else {
  13. // 如果有规定charset则使用charset转换
  14. return s.getBytes(charset);
  15. }
  16. }
  17. }

8.PatternLayout

对日志样式进行转换的类,实际上可以看成是对Converter的管理类,当有日志事件进来时直接调用Converter链来进行转换。其代码如下:

  1. public class PatternLayout extends PatternLayoutBase<ILoggingEvent> {
  2. // 用来存储当前日志样式对照表
  3. public static final Map<String, String> defaultConverterMap =
  4. new HashMap<String, String>();
  5. static {
  6. // 这里显示了当前样式所支持的一些配置,包括缩写和全称,这也是为什么
  7. // 我们的Pattern要按照这些来配
  8. defaultConverterMap.putAll(Parser.DEFAULT_COMPOSITE_CONVERTER_MAP);
  9. defaultConverterMap.put("d", DateConverter.class.getName());
  10. defaultConverterMap.put("date", DateConverter.class.getName());
  11. defaultConverterMap.put("r",
  12. RelativeTimeConverter.class.getName());
  13. defaultConverterMap.put("relative",
  14. RelativeTimeConverter.class.getName());
  15. defaultConverterMap.put("level", LevelConverter.class.getName());
  16. defaultConverterMap.put("le", LevelConverter.class.getName());
  17. defaultConverterMap.put("p", LevelConverter.class.getName());
  18. defaultConverterMap.put("t", ThreadConverter.class.getName());
  19. defaultConverterMap.put("thread", ThreadConverter.class.getName());
  20. defaultConverterMap.put("lo", LoggerConverter.class.getName());
  21. defaultConverterMap.put("logger", LoggerConverter.class.getName());
  22. defaultConverterMap.put("c", LoggerConverter.class.getName());
  23. defaultConverterMap.put("m", MessageConverter.class.getName());
  24. defaultConverterMap.put("msg", MessageConverter.class.getName());
  25. defaultConverterMap.put("message",
  26. MessageConverter.class.getName());
  27. defaultConverterMap.put("C",
  28. ClassOfCallerConverter.class.getName());
  29. defaultConverterMap.put("class",
  30. ClassOfCallerConverter.class.getName());
  31. defaultConverterMap.put("M",
  32. MethodOfCallerConverter.class.getName());
  33. defaultConverterMap.put("method",
  34. MethodOfCallerConverter.class.getName());
  35. defaultConverterMap.put("L",
  36. LineOfCallerConverter.class.getName());
  37. defaultConverterMap.put("line",
  38. LineOfCallerConverter.class.getName());
  39. defaultConverterMap.put("F",
  40. FileOfCallerConverter.class.getName());
  41. defaultConverterMap.put("file",
  42. FileOfCallerConverter.class.getName());
  43. defaultConverterMap.put("X", MDCConverter.class.getName());
  44. defaultConverterMap.put("mdc",
  45. MDCConverter.class.getName());
  46. defaultConverterMap.put("n",
  47. LineSeparatorConverter.class.getName());
  48. }
  49. public String doLayout(ILoggingEvent event) {
  50. if (!isStarted()) {
  51. return CoreConstants.EMPTY_STRING;
  52. }
  53. // 这个方法是父类的,可直接看到父类实现
  54. return writeLoopOnConverters(event);
  55. }
  56. }

PatternLayout从方法调用层面来看是没什么作用的,但其主要作用并不是用来处理运行时的逻辑,而是提供Pattern样式格式对应的Converter处理器。我们平时使用的各种格式符号规则便来源于此。

9.PatternLayoutBase

刚刚所说的PatternLayout只是为样式分析提供对应的Converter,而PatternLayoutBase则是真正调用处理样式的地方。代码如下:

  1. abstract public class PatternLayoutBase<E> extends LayoutBase<E> {
  2. // Converter调用链的头节点
  3. Converter<E> head;
  4. public void start() {
  5. // 这个start()方法的作用便是解析一开始的Pattern日志格式
  6. if (pattern == null || pattern.length() == 0) {
  7. addError("Empty or null pattern.");
  8. return;
  9. }
  10. try {
  11. // 创建解析器
  12. Parser<E> p = new Parser<E>(pattern);
  13. if (getContext() != null) {
  14. p.setContext(getContext());
  15. }
  16. Node t = p.parse();
  17. // getEffectiveConverterMap()方法最终会调用子类的Converter
  18. // 对照表,从而根据编译出Pattern一串Converter链表
  19. this.head = p.compile(t, getEffectiveConverterMap());
  20. if (postCompileProcessor != null) {
  21. postCompileProcessor.process(context, head);
  22. }
  23. // 对Converter进行初始化等操作
  24. ConverterUtil.setContextForConverters(getContext(), head);
  25. ConverterUtil.startConverters(this.head);
  26. super.start();
  27. } catch (ScanException sce) {
  28. StatusManager sm = getContext().getStatusManager();
  29. sm.add(new ErrorStatus("Failed to parse pattern \"" +
  30. getPattern() + "\".", this, sce));
  31. }
  32. }
  33. protected String writeLoopOnConverters(E event) {
  34. // Converter处理结果写入对象,看方法也可以知道这个方法的作用就是
  35. // 循环遍历Converter进行写入操作
  36. StringBuilder strBuilder =
  37. new StringBuilder(INTIAL_STRING_BUILDER_SIZE);
  38. Converter<E> c = head;
  39. // 开始遍历Converter链来处理当前日志对象
  40. while (c != null) {
  41. c.write(strBuilder, event);
  42. c = c.getNext();
  43. }
  44. // 返回处理结果
  45. return strBuilder.toString();
  46. }
  47. }

至此,Logback的常用两种打印方式便分析完了,其它的Appender功能以后有机会再来分析分析。

 

 

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/木道寻08/article/detail/963223
推荐阅读
  

闽ICP备14008679号