当前位置:   article > 正文

基于 ThreadLocal 实现一个上下文管理组件(附源码)

java 每一次请求的上下文管理器

点击上方“Java基基”,选择“设为星标”

做积极的人,而不是积极废人!

每天 14:00 更新文章,每天掉亿点点头发...

源码精品专栏

 

来源:juejin.cn/post/

7153287656624324638


本文基于ThreadLocal原理,实现了一个上下文状态管理组件Scope,通过开启一个自定义的Scope,在Scope范围内,可以通过Scope各个方法读写数据;

通过自定义线程池实现上下文状态数据的线程间传递;

提出了一种基于FilterScopeRequest粒度的上下文管理方案。

1 ThreadLocal原理

ThreadLocal主要作用就是实现线程间变量隔离,对于一个变量,每个线程维护一个自己的实例,防止多线程环境下的资源竞争,那ThreadLocal是如何实现这一特性的呢?

cc825b123f8c7e942a8e0487ed814550.jpeg

从上图可知:

  1. 每个Thread对象中都包含一个ThreadLocal.ThreadLocalMap类型的threadlocals成员变量;

  2. 该map对应的每个元素Entry对象中:key是ThreadLocal对象的弱引用,value是该threadlocal变量在当前线程中的对应的变量实体;

  3. 当某一线程执行获取该ThreadLocal对象对应的变量时,首先从当前线程对象中获取对应的threadlocals哈希表,再以该ThreadLocal对象为key查询哈希表中对应的value;

  4. 由于每个线程独占一个threadlocals哈希表,因此线程间ThreadLocal对象对应的变量实体也是独占的,不存在竞争问题,也就避免了多线程问题。

有人可能会问:ThreadLocalMapThread成员变量(非public,只有包访问权限,Thread和Threadlocal都在java.lang 包下,Thread可以访问ThreadLocal.ThreadLocalMap),定义却在ThreadLocal中,为什么要这么设计?

源码的注释给出了解释:ThreadLocalMap就是维护线程本地变量设计的,就是让使用者知道ThreadLocalMap就只做保存线程局部变量这一件事。

4531013f83f2c8f889f967a4545843ae.jpeg
set() 方法
  1. public void set(T value) {
  2.     Thread t = Thread.currentThread(); //获取当前线程
  3.     ThreadLocalMap map = getMap(t); //从当前线程对象中获取threadlocals,该map保存了所用的变量实例
  4.     if (map != null) {
  5.         map.set(this, value);
  6.     } else {
  7.         createMap(t, value); //初始threadlocals,并设置当前变量
  8.     }
  9. }
  10. ThreadLocalMap getMap(Thread t) {
  11.     return t.threadLocals;
  12. }
  13. void createMap(Thread t, T firstValue) {
  14.     t.threadLocals = new ThreadLocalMap(this, firstValue);
  15. }
get() 方法
  1. public T get() {
  2.     Thread t = Thread.currentThread();
  3.     ThreadLocalMap map = getMap(t); //从当前线程对象中获取threadlocals,该map保存了所用的变量实体
  4.     if (map != null) {
  5.         // 获取当前threadlocal对象对应的变量实体
  6.         ThreadLocalMap.Entry e = map.getEntry(this);
  7.         if (e != null) {
  8.             @SuppressWarnings("unchecked")
  9.             T result = (T)e.value;
  10.             return result;
  11.         }
  12.     }
  13.     // 如果map没有初始化,那么在这里初始化一下
  14.     return setInitialValue();
  15. }
withInitial()方法

由于通过 ThreadLocalset() 设置的值,只会设置当前线程对应变量实体,无法实现统一初始化所有线程的ThreadLocal的值。ThreadLocal提供了一个 withInitial() 方法实现这一功能:

  1. ThreadLocal<String> initValue = ThreadLocal.withInitial(() -> "initValue");
  2. public static <S> ThreadLocal<S> withInitial(Supplier<? extends S> supplier) {
  3.     // 返回SuppliedThreadLocal类型对象
  4.     return new SuppliedThreadLocal<>(supplier);
  5. }
  6. static final class SuppliedThreadLocal<T> extends ThreadLocal<T> {
  7.     private final Supplier<? extends T> supplier;
  8.     SuppliedThreadLocal(Supplier<? extends T> supplier) {
  9.         this.supplier = Objects.requireNonNull(supplier);
  10.     }
  11.     @Override
  12.     protected T initialValue() {
  13.         // 获取初始化值
  14.         return supplier.get();
  15.     }
  16. }
ThreadLocal中的内存泄漏问题

由图1可知,ThreadLocal.ThreadLocalMap 对应的Entry中,key为ThreadLocal对象的弱引用,方法执行对应栈帧中的ThreadLocal引用为强引用。当方法执行过程中,由于栈帧销毁或者主动释放等原因,释放了ThreadLocal对象的强引用,即表示该ThreadLocal对象可以被回收了。又因为Entry中key为ThreadLocal对象的弱引用,所以当jvm执行GC操作时是能够回收该ThreadLocal对象的。

Entry中value对应的是变量实体对象的强引用,因此释放一个ThreadLocal对象,是无法释放ThreadLocal.ThreadLocalMap中对应的value对象的,也就造成了内存泄漏。除非释放当前线程对象,这样整个threadlocals都被回收了。但是日常开发中会经常使用线程池等线程池化技术,释放线程对象的条件往往无法达到。

JDK处理的方法是,在ThreadLocalMap进行set()get()remove()的时候,都会进行清理:

  1. private Entry getEntry(ThreadLocal<?> key) {
  2.     int i = key.threadLocalHashCode & (table.length - 1);
  3.     Entry e = table[i];
  4.     if (e != null && e.get() == key)
  5.         return e;
  6.     else
  7.         return getEntryAfterMiss(key, i, e);
  8. }
  9. private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
  10.     Entry[] tab = table;
  11.     int len = tab.length;
  12.     while (e != null) {
  13.         ThreadLocal<?> k = e.get();
  14.         if (k == key)
  15.             return e;
  16.         if (k == null)
  17.             //如果key为null,对应的threadlocal对象已经被回收,清理该Entry
  18.             expungeStaleEntry(i);
  19.         else
  20.             i = nextIndex(i, len);
  21.         e = tab[i];
  22.     }
  23.     return null;
  24. }

基于 Spring Boot + MyBatis Plus + Vue & Element 实现的后台管理系统 + 用户小程序,支持 RBAC 动态权限、多租户、数据权限、工作流、三方登录、支付、短信、商城等功能

  • 项目地址:https://github.com/YunaiV/ruoyi-vue-pro

  • 视频教程:https://doc.iocoder.cn/video/

2 自定义上下文Scope

在工作中,我们经常需要维护一些上下文,这样可以避免在方法调用过程中传入过多的参数,需要查询/修改一些数据的时候,直接在当前上下文中操作就行了。举个具体点的例子:当web服务器收到一个请求时,需要解析当前登录态的用户,在后续的业务执行流程中都需要这个用户名。

如果只需要维护一个上下文状态数据还比较好处理,可以通过方法传参的形式,执行每个业务方法的时候都通过添加一个表示用户名方法参数传递进去,但是如果需要维护上下文状态数据比较多的话,这个方式就不太优雅了。

一个可行的方案是通过Threadlocal实现请求线程的上下文,只要是同一线程的执行过程,不同方法间不传递上下文状态变量,直接操作ThreadLocal对象实现状态数据的读写。当存在多个上下文状态的话,则需要维护多个ThreadLocal,似乎也可以勉强接受。但是当遇到业务流程中使用线程池的情况下,从Tomcat传递这些ThreadLocal到线程池中的线程里就变的比较麻烦了。

基于以上考虑,下面介绍一种基于Threadlocal实现的上下文管理组件Scope

Scope.java

  1. public class Scope {
  2.     // 静态变量,维护不同线程的上下文Scope
  3.     private static final ThreadLocal<Scope> SCOPE_THREAD_LOCAL = new ThreadLocal<>();
  4.     // 实例变量,维护每个上下文中所有的状态数据,为了区分不同的状态数据,使用ScopeKey类型的实例作为key
  5.     private final ConcurrentMap<ScopeKey<?>, Object> values = new ConcurrentHashMap<>();
  6.     // 获取当前上下文
  7.     public static Scope getCurrentScope() {
  8.         return SCOPE_THREAD_LOCAL.get();
  9.     }
  10.     // 在当前上下文设置一个状态数据
  11.     public <T> void set(ScopeKey<T> key, T value) {
  12.         if (value != null) {
  13.             values.put(key, value);
  14.         } else {
  15.             values.remove(key);
  16.         }
  17.     }
  18.     // 在当前上下文读取一个状态数据
  19.     public <T> T get(ScopeKey<T> key) {
  20.         T value = (T) values.get(key);
  21.         if (value == null && key.initializer() != null) {
  22.             value = key.initializer().get();
  23.         }
  24.         return value;
  25.     }
  26.     // 开启一个上下文
  27.     public static Scope beginScope() {
  28.         Scope scope = SCOPE_THREAD_LOCAL.get();
  29.         if (scope != null) {
  30.             throw new IllegalStateException("start a scope in an exist scope.");
  31.         }
  32.         scope = new Scope();
  33.         SCOPE_THREAD_LOCAL.set(scope);
  34.         return scope;
  35.     }
  36.     // 关闭当前上下文
  37.     public static void endScope() {
  38.         SCOPE_THREAD_LOCAL.remove();
  39.     }
  40. }

ScopeKey.java

  1. public final class ScopeKey<T> {
  2.     // 初始化器,参考 ThreadLocal 的 withInitial()
  3.     private final Supplier<T> initializer;
  4.     public ScopeKey() {
  5.         this(null);
  6.     }
  7.     public ScopeKey(Supplier<T> initializer) {
  8.         this.initializer = initializer;
  9.     }
  10.     // 统一初始化所有线程的 ScopeKey 对应的值,参考 ThreadLocal 的 withInitial()
  11.     public static <T> ScopeKey<T> withInitial(Supplier<T> initializer) {
  12.         return new ScopeKey<>(initializer);
  13.     }
  14.     public Supplier<T> initializer() {
  15.         return this.initializer;
  16.     }
  17.     // 获取当前上下文中 ScopeKey 对应的变量
  18.     public T get() {
  19.         Scope currentScope = getCurrentScope();
  20.         return currentScope.get(this);
  21.     }
  22.     // 设置当前上下文中 ScopeKey 对应的变量
  23.     public boolean set(T value) {
  24.         Scope currentScope = getCurrentScope();
  25.         if (currentScope != null) {
  26.             currentScope.set(this, value);
  27.             return true;
  28.         } else {
  29.             return false;
  30.         }
  31.     }
  32. }

使用方式

  1. @Test
  2. public void testScopeKey() {
  3.     ScopeKey<String> localThreadName = new ScopeKey<>();
  4.     // 不同线程中执行时,开启独占的 Scope
  5.     Runnable r = () -> {
  6.         // 开启 Scope
  7.         Scope.beginScope();
  8.         try {
  9.             String currentThreadName = Thread.currentThread().getName();
  10.             localThreadName.set(currentThreadName);
  11.             log.info("currentThread: {}", localThreadName.get());
  12.         } finally {
  13.             // 关闭 Scope
  14.             Scope.endScope();
  15.         }
  16.     };
  17.     new Thread(r, "thread-1").start();
  18.     new Thread(r, "thread-2").start();
  19.     /** 执行结果
  20.      * [thread-1] INFO com.example.demo.testscope.TestScope - currentThread: thread-1
  21.      * [thread-2] INFO com.example.demo.testscope.TestScope - currentThread: thread-2
  22.      */
  23. }
  24. @Test
  25. public void testWithInitial() {
  26.     ScopeKey<String> initValue = ScopeKey.withInitial(() -> "initVal");
  27.     Runnable r = () -> {
  28.         Scope.beginScope();
  29.         try {
  30.             log.info("initValue: {}", initValue.get());
  31.         } finally {
  32.             Scope.endScope();
  33.         }
  34.     };
  35.     new Thread(r, "thread-1").start();
  36.     new Thread(r, "thread-2").start();
  37.     /** 执行结果
  38.      * [thread-1] INFO com.example.demo.testscope.TestScope - initValue: initVal
  39.      * [thread-2] INFO com.example.demo.testscope.TestScope - initValue: initVal
  40.      */
  41. }

上面的测试用例中在代码中手动开启和关闭Scope不太优雅,可以在Scope中添加两个个静态方法包装下RunnableSupplier接口:

  1. public static <X extends Throwable> void runWithNewScope(@Nonnull ThrowableRunnable<X> runnable)
  2.         throws X {
  3.     supplyWithNewScope(() -> {
  4.         runnable.run();
  5.         return null;
  6.     });
  7. }
  8. public static <T, X extends Throwable> T
  9.         supplyWithNewScope(@Nonnull ThrowableSupplier<T, X> supplier) throws X {
  10.     beginScope();
  11.     try {
  12.         return supplier.get();
  13.     } finally {
  14.         endScope();
  15.     }
  16. }
  17. @FunctionalInterface
  18. public interface ThrowableRunnable<X extends Throwable> {
  19.     void run() throws X;
  20. }
  21. public interface ThrowableSupplier<T, X extends Throwable> {
  22.     T get() throws X;
  23. }

以新的Scope执行,可以这样写:

  1. @Test
  2. public void testRunWithNewScope() {
  3.     ScopeKey<String> localThreadName = new ScopeKey<>();
  4.     ThrowableRunnable r = () -> {
  5.         String currentThreadName = Thread.currentThread().getName();
  6.         localThreadName.set(currentThreadName);
  7.         log.info("currentThread: {}", localThreadName.get());
  8.     };
  9.     // 不同线程中执行时,开启独占的 Scope
  10.     new Thread(() -> Scope.runWithNewScope(r), "thread-1").start();
  11.     new Thread(() -> Scope.runWithNewScope(r), "thread-2").start();
  12.     /** 执行结果
  13.      * [thread-2] INFO com.example.demo.TestScope.testscope - currentThread: thread-2
  14.      * [thread-1] INFO com.example.demo.TestScope.testscope - currentThread: thread-1
  15.      */
  16. }

基于 Spring Cloud Alibaba + Gateway + Nacos + RocketMQ + Vue & Element 实现的后台管理系统 + 用户小程序,支持 RBAC 动态权限、多租户、数据权限、工作流、三方登录、支付、短信、商城等功能

  • 项目地址:https://github.com/YunaiV/yudao-cloud

  • 视频教程:https://doc.iocoder.cn/video/

3 在线程池中传递Scope

在上一节中实现的Scope,通过ThreadLocal实现了了一个自定义的上下文组件,在同一个线程中通过ScopeKey.set() / ScopeKey.get()读写同一个上下文中的状态数据。

现在需要实现这样一个功能,在一个线程执行过程中开启了一个Scope,随后使用线程池执行任务,要求在线程池中也能获取当前Scope中的状态数据。典型的使用场景是:服务收到一个用户请求,通过Scope将登陆态数据存到当前线程的上下文中,随后使用线程池执行一些耗时的操作,希望线程池中的线程也能拿到Scope中的登陆态数据。

由于线程池中的线程和请求线程不是一个线程,按照目前的实现,线程池中的线程是无法拿到请求线程上下文中的数据的。

解决方法是,在提交runnable时,将当前的Scope引用存到runnable对象中,当获得线程执行时,将Scope替换到执行线程中,执行完成后,再恢复现场。在Scope中新增如下静态方法:

  1. // 以给定的上下文执行 Runnable
  2. public static <X extends Throwable> void runWithExistScope(Scope scope, ThrowableRunnable<X> runnable) throws X {
  3.     supplyWithExistScope(scope, () -> {
  4.         runnable.run();
  5.         return null;
  6.     });
  7. }
  8. // 以给定的上下文执行 Supplier
  9. public static <T, X extends Throwable> T supplyWithExistScope(Scope scope, ThrowableSupplier<T, X> supplier) throws X {
  10.     // 保留现场
  11.     Scope oldScope = SCOPE_THREAD_LOCAL.get();
  12.     // 替换成外部传入的 Scope
  13.     SCOPE_THREAD_LOCAL.set(scope);
  14.     try {
  15.         return supplier.get();
  16.     } finally {
  17.         if (oldScope != null) {
  18.             // 恢复线程
  19.             SCOPE_THREAD_LOCAL.set(oldScope);
  20.         } else {
  21.             SCOPE_THREAD_LOCAL.remove();
  22.         }
  23.     }
  24. }

实现支持Scope切换的自定义线程池ScopeThreadPoolExecutor

  1. public class ScopeThreadPoolExecutor extends ThreadPoolExecutor {
  2.     ScopeThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime,
  3.                             TimeUnit unit, BlockingQueue<Runnable> workQueue) {
  4.         super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue);
  5.     }
  6.     public static ScopeThreadPoolExecutor newFixedThreadPool(int nThreads) {
  7.         return new ScopeThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS,
  8.                 new LinkedBlockingQueue<Runnable>());
  9.     }
  10.     /**
  11.      * 只要override这一个方法就可以
  12.      * 所有submit, invokeAll等方法都会代理到这里来
  13.      */
  14.     @Override
  15.     public void execute(Runnable command) {
  16.         Scope scope = getCurrentScope();
  17.         // 提交任务时,把执行 execute 方法的线程中的 Scope 传进去
  18.         super.execute(() -> runWithExistScope(scope, command::run));
  19.     }
  20. }

测试下ScopeThreadPoolExecutor是否生效:

  1. @Test
  2. public void testScopeThreadPoolExecutor() {
  3.     ScopeKey<String> localVariable = new ScopeKey<>();
  4.     Scope.beginScope();
  5.     try {
  6.         localVariable.set("value out of thread pool");
  7.         Runnable r = () -> log.info("localVariable in thread pool: {}", localVariable.get());
  8.         // 使用线程池执行,能获取到外部Scope中的数据
  9.         ExecutorService executor = ScopeThreadPoolExecutor.newFixedThreadPool(10);
  10.         executor.execute(r);
  11.         executor.submit(r);
  12.     } finally {
  13.         Scope.endScope();
  14.     }
  15.     /** 执行结果
  16.      * [pool-1-thread-1] INFO com.example.demo.testscope.TestScope - localVariable in thread pool: value out of thread pool
  17.      * [pool-1-thread-2] INFO com.example.demo.testscope.TestScope - localVariable in thread pool: value out of thread pool
  18.      */
  19. }
  20. @Test
  21. public void testScopeThreadPoolExecutor2() {
  22.     ScopeKey<String> localVariable = new ScopeKey<>();
  23.     Scope.runWithNewScope(() -> {
  24.         localVariable.set("value out of thread pool");
  25.         Runnable r = () -> log.info("localVariable in thread pool: {}", localVariable.get());
  26.         // 使用线程池执行,能获取到外部Scope中的数据
  27.         ExecutorService executor = ScopeThreadPoolExecutor.newFixedThreadPool(10);
  28.         executor.execute(r);
  29.         executor.submit(r);
  30.     });
  31.     /** 执行结果
  32.      * [pool-1-thread-2] INFO com.example.demo.testscope.TestScope - localVariable in thread pool: value out of thread pool
  33.      * [pool-1-thread-1] INFO com.example.demo.testscope.TestScope - localVariable in thread pool: value out of thread pool
  34.      */
  35. }

以上两个测试用例,分别通过手动开启Scope、借助runWithNewScope工具方法自动开启Scope两种方式验证了自定义线程池ScopeThreadPoolExecutorScope可传递性。

4 通过Filter、Scope实现Request上下文

接下来介绍如何通过FilterScope实现Request粒度的Scope上下文。思路是:通过注入一个拦截器,在进入拦截器后开启Scope作为一个请求的上下文,解析Request对象获取获取相关状态信息(如登陆用户),并在Scope中设置,在离开拦截器时关闭Scope

AuthScope.java

  1. // 获取登录态的工具类
  2. public class AuthScope {
  3.     private static final ScopeKey<String> LOGIN_USER = new ScopeKey<>();
  4.     public static String getLoginUser() {
  5.         return LOGIN_USER.get();
  6.     }
  7.     public static void setLoginUser(String loginUser) {
  8.         if (loginUser == null) {
  9.             loginUser = "unknownUser";
  10.         }
  11.         LOGIN_USER.set(loginUser);
  12.     }
  13. }

ScopeFilter.java

  1. @Lazy
  2. @Order(0)
  3. @Service("scopeFilter")
  4. public class ScopeFilter extends OncePerRequestFilter {
  5.     @Override
  6.     protected String getAlreadyFilteredAttributeName() {
  7.         return this.getClass().getName();
  8.     }
  9.     @Override
  10.     protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
  11.                                     FilterChain filterChain) throws ServletException, IOException {
  12.         // 开启Scope
  13.         beginScope();
  14.         try {
  15.             Cookie[] cookies = request.getCookies();
  16.             String loginUser = "unknownUser";
  17.             if (cookies != null) {
  18.                 for (Cookie cookie : cookies) {
  19.                     if (cookie.getName().equals("login_user")) {
  20.                         loginUser = cookie.getValue();
  21.                         break;
  22.                     }
  23.                 }
  24.             }
  25.             // 设置该 Request 上下文对用的登陆用户
  26.             AuthScope.setLoginUser(loginUser);
  27.             filterChain.doFilter(request, response);
  28.         } finally {
  29.             // 关闭Scope
  30.             endScope();
  31.         }
  32.     }
  33. }

注入Filter

  1. @Slf4j
  2. @Configuration
  3. public class FilterConfig {
  4.     @Bean
  5.     public FilterRegistrationBean<ScopeFilter> scopeFilterRegistration() {
  6.         FilterRegistrationBean<ScopeFilter> registration = new FilterRegistrationBean<>();
  7.         registration.setFilter(new ScopeFilter());
  8.         registration.addUrlPatterns("/rest/*");
  9.         registration.setOrder(0);
  10.         log.info("scope filter registered");
  11.         return registration;
  12.     }
  13. }

UserController.java

  1. @Slf4j
  2. @RestController
  3. @RequestMapping("/rest")
  4. public class UserController {
  5.     // curl --location --request GET 'localhost:8080/rest/getLoginUser' --header 'Cookie: login_user=zhangsan'
  6.     @GetMapping("/getLoginUser")
  7.     public String getLoginUser() {
  8.         return AuthScope.getLoginUser();
  9.     }
  10.     // curl --location --request GET 'localhost:8080/rest/getLoginUserInThreadPool' --header 'Cookie: login_user=zhangsan'
  11.     @GetMapping("/getLoginUserInThreadPool")
  12.     public String getLoginUserInThreadPool() {
  13.         ScopeThreadPoolExecutor executor = ScopeThreadPoolExecutor.newFixedThreadPool(4);
  14.         executor.execute(() -> {
  15.             log.info("get login user in thread pool: {}", AuthScope.getLoginUser());
  16.         });
  17.         return AuthScope.getLoginUser();
  18.     }
  19. }

通过以下请求验证,请求线程和线程池线程是否能获取登录态,其中登录态通过Cookie模拟:

  1. curl --location --request GET 'localhost:8080/rest/getLoginUser' --header 'Cookie: login_user=zhangsan'
  2. curl --location --request GET 'localhost:8080/rest/getLoginUserInThreadPool' --header 'Cookie: login_user=zhangsan'

5 总结

源代码

github:

  • https://github.com/pengchengSU/demo-request-scope.git



欢迎加入我的知识星球,一起探讨架构,交流源码。加入方式,长按下方二维码噢

7a4ea5d475ba74455e095c94901d9a7e.png

已在知识星球更新源码解析如下:

b3eeecca8d66b60cf9dcc84e492dbd5e.jpeg

a7434f94878821fe115b086e2ee7ac9a.jpeg

4342cb081e0bb5a969880a089abc4386.jpeg

74984d7a23aa14a3002ef234740efaf0.jpeg

最近更新《芋道 SpringBoot 2.X 入门》系列,已经 101 余篇,覆盖了 MyBatis、Redis、MongoDB、ES、分库分表、读写分离、SpringMVC、Webflux、权限、WebSocket、Dubbo、RabbitMQ、RocketMQ、Kafka、性能测试等等内容。

提供近 3W 行代码的 SpringBoot 示例,以及超 6W 行代码的电商微服务项目。

获取方式:点“在看”,关注公众号并回复 666 领取,更多内容陆续奉上。

  1. 文章有帮助的话,在看,转发吧。
  2. 谢谢支持哟 (*^__^*)
声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/繁依Fanyi0/article/detail/949067
推荐阅读
相关标签
  

闽ICP备14008679号