使用AspectJ,Javassist和Java Proxy进行代码注入的实用介绍

静态地或在运行时将代码片段注入已编译的类和方法中的功能可能会很有帮助。 这尤其适用于在没有源代码的第三方库中或在无法使用调试器或探查器的环境中对问题进行故障排除。 代码注入对于处理涉及整个应用程序的问题(例如性能监视)也很有用。 以这种方式使用代码注入在面向方面编程 (AOP)的名字下变得很流行。 相反,代码注入并不是很少使用,就像相反。 每个程序员都会遇到这种能力可以避免很多痛苦和沮丧的情况。

这篇文章旨在为您提供您可能(或我宁愿说“将要”)需要的知识,并说服您学习代码注入的基础确实值得您花很少的时间。 我将介绍三种不同的现实情况,在这些情况下我需要进行代码注入,并使用不同的工具解决每个问题,最适合手头的约束。

为什么您需要它

关于AOP优势(因此有代码注入),已经有很多论述 ,因此,从故障排除的角度来看,我将只专注于一些要点。

最酷的事情是,它使您能够修改第三方封闭源类 ,甚至实际上是JVM类。 我们大多数人使用的是遗留代码和我们没有源代码的代码,因此不可避免地我们偶尔会遇到这些第三方二进制文件的局限性或错误,因此非常需要更改其中的一些小东西或深入了解代码的行为。 如果没有代码注入,则无法修改代码或添加对代码增加可观察性的支持。 同样,您通常需要在生产环境中处理问题或收集信息,在生产环境中,您不能使用调试器和类似工具,而您通常至少可以以某种方式管理应用程序的二进制文件和依赖项。 请考虑以下情况:

  • 您正在将数据集合传递到闭源库进行处理,并且库中的一个方法对其中一个元素失败,但是异常未提供有关它是哪个元素的信息。 您需要对其进行修改以记录有问题的参数或将其包括在异常中。 (并且您不能使用调试器,因为它仅在生产应用程序服务器上发生。)
  • 您需要收集应用程序中重要方法的性能统计信息,包括在典型生产负载下的某些封闭源组件。 (在生产环境中,您当然不能使用探查器,并且您希望产生最小的开销。)
  • 您使用JDBC批量发送大量数据到数据库,而其中一个批量更新失败。 您将需要一些不错的方法来找出批次和包含的数据。

实际上,我已经遇到了这三种情况(在其他情况下),稍后您将看到可能的实现。

阅读本文时,您应该牢记代码注入的以下优点:

  • 代码注入使您能够修改您没有源代码的二进制类
  • 注入的代码可用于在无法使用传统开发工具(例如探查器和调试器)的环境中收集各种运行时信息。
  • 不要重复自己:当您需要在多个地方使用相同的逻辑时,可以定义一次,然后将其注入所有这些地方。
  • 使用代码注入时,您无需修改​​原始源文件,因此非常适合仅在有限时间内进行的(可能是大规模的)更改,尤其是借助可以轻松打开和关闭代码注入的工具(例如,具有加载时编织功能的AspectJ)。 典型的情况是性能指标收集和故障排除期间增加的日志记录
  • 您可以在构建时静态或静态地注入代码,或者在JVM加载目标类时动态注入代码。

迷你词汇

您可能会遇到以下与代码注入和AOP有关的术语:

忠告

要注入的代码。 通常,我们谈论建议之前,之后和周围,这些建议是在目标方法之前,之后或代替目标方法执行的。 除了将代码注入方法之外,还可以进行其他更改,例如,向类添加字段或接口。

AOP(面向方面​​的编程)

一个编程范例声称,“跨领域关注点”(在许多地方都需要的逻辑,没有一个单独的类在哪里实现)应该实施一次,然后注入这些地方。 检查维基百科以获得更好的描述。

方面

AOP中的模块化单位大致对应于一个类–它可以包含不同的建议和切入点。

联合点

程序中可能成为代码注入目标的特定点,例如方法调用或方法条目。

切入点

粗略地说,切入点是一个表达式,它告诉代码注入工具在哪里注入特定代码段,即在哪个联合点上应用特定建议。 它只能选择一个这样的点(例如,单个方法的执行),也可以选择许多类似的点,例如,所有带有自定义注释(例如@MyBusinessMethod)的方法的执行。

织造

将代码(建议)注入目标位置(联合点)的过程。


工具

有很多非常不同的工具可以完成这项工作,因此我们将首先了解它们之间的差异,然后我们将熟悉代码注入工具的不同演化分支的三个杰出代表。

代码注入工具的基本分类

一,抽象水平

表达要注入的逻辑以及表达应在其中插入逻辑的切入点有多困难?

关于“建议”代码:

  1. 直接字节码操作(例如ASM)–要使用这些工具,您需要了解类的字节码格式,因为它们从类中提取的很少,您可以直接使用操作码,操作数堆栈和单个指令。 一个ASM示例:

    methodVisitor.visitFieldInsn(Opcodes.GETSTATIC,“ java / lang / System”,“ out”,“ Ljava / io / PrintStream;”);

    由于级别太低,因此难以使用,但功能最强大。 通常,它们用于实现更高级别的工具,实际上很少需要使用它们。

  2. 中级–字符串代码,类文件结构的抽象(Javassist)
  3. Java建议(例如AspectJ)–要注入的代码表示为语法检查和静态编译的Java

关于将代码注入到哪里的规范:

  1. 手动注入–您必须以某种方式掌握要注入代码的位置(ASM,Javassist)
  2. 原始切入点–表达特定位置,特定类,类的所有公共方法或组中所有类的公共方法(Java EE拦截器)的地方,代码的插入可能性非常有限。
  3. 模式匹配切入点表达式–强大的表达式,可基于多个条件使用通配符匹配关节点,对上下文的了解(例如“从包XY中的类调用”)等(AspectJ)

二。 当魔术发生时

可以在不同的时间点注入代码:

  • 在运行时手动–您的代码必须明确要求增强代码,例如,通过手动实例化包装目标对象的自定义代理(可以说这不是真正的代码注入)
  • 在加载时–在JVM加载目标类时执行修改
  • 在构建时–在打包和部署应用程序之前,您需要在构建过程中添加额外的步骤来修改已编译的类。

这些注射方式中的每一种都可能更适合于不同情况。

三, 它能做什么

代码注入工具可以做什么或不能做什么都存在很大差异,其中一些可能性是:

  • 在方法之前/之后/而不是方法中添加代码–仅成员级方法还是静态方法?
  • 将字段添加到班级
  • 添加新方法
  • 制作一个类以实现接口
  • 修改方法体内的指令(例如方法调用)
  • 修改泛型,注释,访问修饰符,更改常量值,…
  • 删除方法,字段等



选定的代码注入工具

最著名的代码注入工具是:

  1. 动态Java代理
  2. 字节码操作库ASM
  3. JBoss Javassist
  4. AspectJ
  5. Spring AOP /代理
  6. Java EE拦截器



Java Proxy,Javassist和AspectJ实用介绍

我选择了三种非常不同的成熟和流行的代码注入工具,并将它们呈现在我亲身经历的真实示例中。

无所不在的动态Java代理

Java.lang.reflect.Proxy使动态创建接口的代理成为可能,将所有调用转发到目标对象。 它不是代码注入工具,因为您不能在任何地方注入它,必须手动实例化并使用代理而不是原始对象,并且只能对接口执行此操作,但是如我们所见,它仍然非常有用。

优点:

  • 它是JVM的一部分,因此随处可见
  • 您可以对不兼容的对象使用相同的代理(更确切地说是InvocationHandler) ,从而比平时更多地重用代码
  • 您可以节省精力,因为您可以轻松地将所有调用转发到目标对象,而仅修改您感兴趣的那些调用。 如果要手动实现代理,则需要实现相关接口的所有方法

缺点:

  • 您只能为接口创建动态代理,如果代码需要具体的类,则不能使用它
  • 您必须实例化并手动应用它,没有神奇的自动注入功能
  • 有点太冗长
  • 它的功能非常有限,它只能在方法之前/之后/周围执行一些代码

没有代码注入步骤-您必须手动应用代理。

我正在使用JDBC PreparedStatement的批处理更新来修改数据库中的许多数据,并且由于违反完整性约束而导致其中一个批处理的处理失败。 该异常没有足够的信息来找出导致失败的数据,因此我为PreparedStatement创建了一个动态代理,该代理记住了传递给每个批处理更新的值,并且在失败的情况下会自动打印该批处理数字和数据。 有了这些信息,我就可以修复数据,并保持解决方案就位,这样,如果再次发生类似的问题,我将能够找到原因并Swift解决。

该代码的关键部分:

LoggingStatementDecorator.java –片段1

class LoggingStatementDecorator implements InvocationHandler {

   private PreparedStatement target;
   ...

   private LoggingStatementDecorator(PreparedStatement target) { this.target = target; }

   @Override
   public Object invoke(Object proxy, Method method, Object[] args)
         throws Throwable {

      try {
         Object result = method.invoke(target, args);
         updateLog(method, args); // remember data, reset upon successful execution
         return result;
      } catch (InvocationTargetException e) {
         Throwable cause = e.getTargetException();
         tryLogFailure(cause);
         throw cause;
      }

   }

   private void tryLogFailure(Throwable cause) {
      if (cause instanceof BatchUpdateException) {
         int failedBatchNr = successfulBatchCounter + 1;
         Logger.getLogger("JavaProxy").warning(
               "THE INJECTED CODE SAYS: " +
               "Batch update failed for batch# " + failedBatchNr +
               " (counting from 1) with values: [" +
               getValuesAsCsv() + "]. Cause: " + cause.getMessage());
      }
   }
...

笔记:

要创建代理,您首先需要实现一个InvocationHandler及其调用方法,只要在代理上调用任何接口的方法,就会调用该方法
您可以通过java.lang.reflect。*对象访问有关该调用的信息,例如,通过method.invoke将调用委派给代理对象
我们还有一个实用方法,用于为Prepared语句创建代理实例:

LoggingStatementDecorator.java –代码段2

public static PreparedStatement createProxy(PreparedStatement target) {
  return (PreparedStatement) Proxy.newProxyInstance(
      PreparedStatement.class.getClassLoader(),
      new Class[] { PreparedStatement.class },
      new LoggingStatementDecorator(target));
};

笔记:

  • 您可以看到newProxyInstance调用使用一个类加载器,代理应实现的接口数组以及应将调用委派给其的调用处理程序(如果需要,处理程序本身必须管理对代理对象的引用)

然后按以下方式使用:

Main.java

...
PreparedStatement rawPrepStmt = connection.prepareStatement("...");
PreparedStatement loggingPrepStmt = LoggingStatementDecorator.createProxy(rawPrepStmt);
...
loggingPrepStmt.executeBatch();
...

笔记:

  • 您会看到我们必须使用代理手动包装原始对象,并在以后继续使用代理



替代解决方案

可以通过不同的方式解决此问题,例如,通过创建一个实现PreparedStatement的非动态代理,并在记住批处理数据的同时将所有调用转发到实际语句,但是对于具有许多方法的接口,这将是很多无聊的键入。 调用方还可以手动跟踪已发送到准备好的语句的数据,但这会因无关的关注而使逻辑模糊。

使用动态Java代理,我们可以得到非常干净且易于实现的解决方案。

独立的Javassist

JBoss Javassist是一个中间代码注入工具,它提供了比字节码操作库更高级别的抽象,并且提供了有限的但仍然非常有用的操作功能。 要注入的代码以字符串表示,您必须手动进入将其注入的类方法。 它的主要优点是修改后的代码对Javassist或其他任何东西都没有新的运行时依赖项。 如果您在一家大公司工作,这可能是决定性因素,而在大公司中,由于法律和其他原因,很难部署其他开放源代码库(或几乎任何其他库),例如AspectJ。

优点:

  • Javassist修改的代码不需要任何新的运行时依赖项,注入会在构建时发生,并且注入的建议代码本身不依赖于任何Javassist API
  • 尽管比字节码操作库更高级,但注入的代码是用Java语法编写的,尽管包含在字符串中
  • 可以完成您可能需要的大多数事情,例如“建议”方法调用和方法执行
  • 您可以同时实现构建时注入(通过Java代码或定制的Ant任务来执行执行/调用建议 )和加载时注入(通过实现自己的Java 5+代理 [thx to Anton])

缺点:

  • 仍然有些太底层,因此难于使用–您必须处理一些方法的结构,并且注入的代码未经语法检查
  • Javassist没有执行注入的工具,因此您必须实现自己的注入代码-包括不支持根据模式自动注入代码

(有关没有Javassist的大多数缺点的解决方案,请参见下面的GluonJ。)

使用Javassist,您可以创建一个类,该类使用Javassist API注入int目标代码,并在编译后将其作为构建过程的一部分运行,例如,就像我曾经通过自定义Ant任务所做的那样。

我们需要在Java EE应用程序中添加一些简单的性能监控,并且不允许我们部署任何未经批准的开源库(至少在没有经过耗时的审批过程的情况下)。 因此,我们使用Javassist将性能监视代码注入到我们的重要方法中,以及将重要的外部方法调用到的地方。

代码注入器:

JavassistInstrumenter.java

public class JavassistInstrumenter {

   public void insertTimingIntoMethod(String targetClass, String targetMethod) throws NotFoundException, CannotCompileException, IOException {
      Logger logger = Logger.getLogger("Javassist");
      final String targetFolder = "./target/javassist";

      try {
         final ClassPool pool = ClassPool.getDefault();
         // Tell Javassist where to look for classes - into our ClassLoader
         pool.appendClassPath(new LoaderClassPath(getClass().getClassLoader()));
         final CtClass compiledClass = pool.get(targetClass);
         final CtMethod method = compiledClass.getDeclaredMethod(targetMethod);

         // Add something to the beginning of the method:
         method.addLocalVariable("startMs", CtClass.longType);
         method.insertBefore("startMs = System.currentTimeMillis();");
         // And also to its very end:
         method.insertAfter("{final long endMs = System.currentTimeMillis();" +
            "iterate.jz2011.codeinjection.javassist.PerformanceMonitor.logPerformance(\"" +
            targetMethod + "\",(endMs-startMs));}");

         compiledClass.writeFile(targetFolder);
         // Enjoy the new $targetFolder/iterate/jz2011/codeinjection/javassist/TargetClass.class

         logger.info(targetClass + "." + targetMethod +
               " has been modified and saved under " + targetFolder);
      } catch (NotFoundException e) {
         logger.warning("Failed to find the target class to modify, " +
               targetClass + ", verify that it ClassPool has been configured to look " +
               "into the right location");
      }
   }

   public static void main(String[] args) throws Exception {
      final String defaultTargetClass = "iterate.jz2011.codeinjection.javassist.TargetClass";
      final String defaultTargetMethod = "myMethod";
      final boolean targetProvided = args.length == 2;

      new JavassistInstrumenter().insertTimingIntoMethod(
            targetProvided? args[0] : defaultTargetClass
            , targetProvided? args[1] : defaultTargetMethod
      );
   }
}

笔记:

  • 您可以看到“底层” –您必须显式处理CtClass,CtMethod之类的对象,显式添加局部变量等。
  • Javassist在查找要修改的类方面非常灵活-它可以搜索类路径,特定文件夹,JAR文件或包含JAR文件的文件夹
  • 您将在编译过程中编译此类并运行其主要内容

类固醇的Javassist:GluonJ

GluonJ是一个基于Javassist的AOP工具。 它可以使用自定义语法或Java 5注释,并且围绕“修订器”的概念构建。 Reviser是一个类(一个方面),它可以修改(即修改)特定的目标类并覆盖其一个或多个方法(与继承相反,修订者的代码实际上被强加于目标类内部的原始代码)。

优点:

  • 如果使用构建时编织,则没有运行时依赖性(加载时编织需要GluonJ代理库或gluonj.jar)
  • 使用GlutonJ的注释的简单Java语法-尽管自定义语法也很容易理解和易于使用
  • 使用GlutonJ的JAR工具,Ant任务或在加载时动态轻松地自动织入目标类
  • 支持构建时和加载时编织

缺点:

  • 一个方面只能修改一个类,而不能将同一段代码注入多个类/方法
  • 功率有限–仅在执行任何代码时或仅在特定上下文中执行时(即从特定的类/方法中调用时),才提供字段/方法的添加和代码的执行,而不是在目标方法周围/

如果您不需要将同一段代码注入多个方法中,那么GluonJ比Javassist更加容易和更好地选择,并且如果它的简单性对您来说不是问题,那么它也比AspectJ更好的选择。简单。

全能方面

AspectJ是功能完善的AOP工具,它几乎可以完成您可能想要的任何事情,包括修改静态方法,添加新字段,在类的已实现接口列表中添加接口等。

AspectJ建议的语法有两种,一种是Java语法的超集,具有诸如Aspect和Pointcut的其他关键字,另一种称为@AspectJ –是标准Java 5,具有诸如@ Aspect,@ Pointcut,@ Around的批注。 后者也许更容易学习和使用,但功能却不那么强大,因为它不像自定义AspectJ语法那样具有表现力。

使用AspectJ,您可以定义要用非常有力的表达建议的联合点,但是学习它们并使其正确起来可能并不困难。 对于AspectJ开发,有一个有用的Eclipse插件– AspectJ开发工具 (AJDT)–但是上次尝试时,它没有我想要的那样有用。

优点:

  • 功能强大,几乎可以完成您可能需要的所有操作
  • 强大的切入点表达式,用于定义在何处注入建议以及何时激活建议(包括一些运行时检查)–完全启用DRY,即写入一次并多次注入
  • 编译时和加载时代码注入(编织)

缺点:

  • 修改后的代码取决于AspectJ运行时库
  • 切入点表达式非常强大,但是可能很难正确使用它们,尽管AJDT插件可以部分可视化它们的效果,但对“调试”它们的支持不多
  • 尽管基本用法非常简单(可能会花一些时间才能开始使用(使用@ Aspect,@ Around和一个简单的切入点表达式,如我们在示例中所见))



曾几何时,我为具有相关性的封闭式LMS J2EE应用程序编写了一个插件,以致于无法在本地运行它。 在API调用期间,应用程序内部的某个方法失败了,但该异常未包含足够的信息来跟踪问题的原因。 因此,我需要更改方法以在失败时记录其参数的值。

AspectJ代码非常简单:

LoggingAspect.java

@Aspect
public class LoggingAspect {

   @Around("execution(private void TooQuiet3rdPartyClass.failingMethod(..))")
   public Object interceptAndLog(ProceedingJoinPoint invocation) throws Throwable {
      try {
         return invocation.proceed();
      } catch (Exception e) {
         Logger.getLogger("AspectJ").warning(
            "THE INJECTED CODE SAYS: the method " +
            invocation.getSignature().getName() + " failed for the input '" +
            invocation.getArgs()[0] + "'. Original exception: " + e);
         throw e;
      }
   }
}

笔记:

  • 方面是带有@Aspect批注的普通Java类,它只是AspectJ的标记
  • @Around注释指示AspectJ执行该方法,而不是与表达式匹配的方法,即代替TooQuiet3rdPartyClass的failingMethod。
  • 周围建议方法需要是公共的,返回一个对象,并采用一个特殊的AspectJ对象作为参数,该对象携带有关调用的信息– ProceedingJoinPoint –并且可以具有任意名称(实际上,这是签名的最小形式,它可以更复杂。)
  • 我们使用ProceedingJoinPoint将调用委派给原始目标(TooQuiet3rdPartyClass的实例),并在发生异常的情况下获取参数的值
  • 我使用了@Around建议,尽管@AfterThrowing会更简单,更合适,但这可以更好地显示AspectJ的功能,并且可以与上述动态Java代理示例进行很好的比较

由于我无法控制应用程序的环境,因此无法启用加载时编织,因此不得不在构建时使用AspectJ的Ant任务来编织代码,重新打包受影响的JAR并将其重新部署到服务器。

替代解决方案

好吧,如果您不能使用调试器,那么您的选择就非常有限。 我唯一想到的替代解决方案是反编译该类(非法!),将日志记录添加到该方法中(前提是反编译成功),重新编译它,然后将原始.class替换为修改后的.class。

黑暗的一面

代码注入和面向方面的编程非常强大,有时对于故障排除和作为应用程序体系结构的常规部分来说都是必不可少的,例如我们可以看到,例如在Java EE的Enterprise Java Beans中,诸如事务管理和安全性检查等业务问题是注入到POJO中(尽管实现实际上更可能使用代理)或在Spring中。

但是,由于可能会降低可理解性,因此需要付出一定的代价,因为运行时行为和结构与您根据源代码所期望的不同(除非您知道还要检查方面的源代码,或者除非进行了注入)通过对目标类(例如Java EE的@Interceptors )的注释进行显式显示。 因此,您必须仔细权衡代码注入/ AOP的优缺点-尽管合理使用它们不会比接口,工厂等掩盖程序流。 关于掩盖代码争论可能经常被高估了

如果您想看一下AOP的例子,请查看Glassbox源代码 ,它是JavaEE性能监视工具(为此,您可能需要一张地图 ,以免丢失太多)。

花式使用代码注入和AOP

在故障排除过程中,代码注入的主要应用领域是日志记录,通过提取并以某种方式传达有关它的有趣运行时信息,可以更准确地了解应用程序正在做什么。 但是,AOP除了简单或复杂的日志记录以外,还有许多有趣的用途,例如:

  • 典型示例:Caching等人(例如: 在JBoss Cache中的AOP上 ),事务管理,日志记录,安全性实施,持久性,线程安全,错误恢复,方法的自动实现(例如toString,equals,hashCode),远程处理
  • 基于角色的编程 (例如OT / J ,使用BCEL)或数据,上下文和交互体系结构的实现
  • 测试中
    • 测试覆盖率–注入代码以记录测试运行期间是否已执行某行
    • 突变测试µJavaJumble )–向应用程序注入“随机”突变并验证测试是否失败
    • 模式测试 –通过AOP自动验证代码中正确实施了架构/设计/最佳实践建议
    • 通过注入异常来模拟硬件/外部故障
  • 帮助实现Java应用程序的零周转– JRebel对框架和服务器集成插件使用类似于AOP的方法 –即其插件 使用Javassist进行“二进制修补”
  • 解决问题并避免使用AOP模式进行猴子编码,例如Worker Object Creation(通过Runnable和ThreadPool / task队列将直接调用转变为异步调用)和Wormhole(使调用方的上下文信息对被调用方可用,而不必传递它们)遍历所有层作为参数,并且没有ThreadLocal)–在《 AspectJ in Action》一书中进行了描述
  • 处理遗留代码–覆盖对构造函数的调用实例化的类(可以使用此类和类似的类来打破紧密耦合与可行的工作量), 确保向后兼容 o, 教导组件对环境变化做出正确反应
  • 保留API的向后兼容性,同时不阻止其演化能力,例如,在缩小/扩展返回类型( Bridge Method Injector –使用ASM)时添加向后兼容方法,或者通过重新添加旧方法并按照以下方式实现它们:新的API
  • 将POJO转换为JMX bean



摘要

我们已经了解到,代码注入对于故障排除是必不可少的,尤其是在处理封闭源代码库和复杂的部署环境时。 我们已经看到了三种完全不同的代码注入工具(动态Java代理,Javassist,AspectJ)应用于实际问题,并讨论了它们的优缺点,因为不同的工具可能适用于不同的情况。 我们还提到了不应过度使用代码注入/ AOP,并查看了一些代码注入/ AOP的高级应用示例。

我希望您现在了解代码注入如何为您提供帮助,并知道如何使用这三个工具。

源代码

您可以从GitHub 获取示例的完整文档源代码,不仅包括要注入的代码,还包括目标代码和易于构建的支持。 最简单的可能是:

git clone git://github.com/jakubholynet/JavaZone-Code-Injection.git
cd JavaZone-Code-Injection/
cat README
mvn -P javaproxy test
mvn -P javassist test
mvn -P aspectj   test

(Maven可能需要花费几分钟来下载其依赖项,插件和实际项目的依赖项。)

其他资源

致谢

我要感谢所有为我提供这篇文章和演示文稿的人,包括我的大学,JRebel同学和GluonJ的合著者。 千叶繁

参考: Holy Java博客上我们JCG合作伙伴 JakubHolý撰写的有关 使用AspectJ,Javassist和Java Proxy进行代码注入的实用介绍

相关文章:


翻译自: https://www.javacodegeeks.com/2011/09/practical-introduction-into-code.html

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值