JavaAgent初探

简介

在JDK1.5以后,javaagent是一种能够在不影响正常编译的情况下,修改字节码。

java作为一种强类型的语言,不通过编译就不能够进行jar包的生成。而有了javaagent技术,就可以在字节码这个层面对类和方法进行修改。同时,也可以把javaagent理解成一种代码注入的方式。但是这种注入比起spring的aop更加的优美。

静态加载

简单使用

其实就是在JVM启动前加载。

有两种实现方式,带Instrumentation的优先级高

<code> public static void premain(String agentArgs, Instrumentation inst);</code><code> public static void premain(String agentArgs);</code>

实现:建一个类,编写premain的逻辑即可

<code> import java.lang.instrument.Instrumentation;</code><code> </code><code> public class Agent {</code><code> </code><code>     public static void premain(String agentArgs, Instrumentation inst){</code><code>         System.out.println("agent premain!!");</code><code>     }</code><code> }</code>

还有个要点:MAINFEST.MF配置agent

<code> Can-Redefine-Classes: true   <em>// 允许agent程序修改java应用的代码</em></code><code> Can-Retransform-Classes: true  <em>// 允许agent程序修改java应用的代码</em></code><code> Premain-Class: com.example.Agent  <em>// 声明了这个jar的premain函数所在的类</em></code>

配置agent:-javaagent:agent.jar即可

图片

刚启动就执行了

图片

进阶使用

函数简介

关于Instrumentation,agent这种动态特性,肯定不止这么简单的使用的啦,主要是用的也就是这种了,现在就来了解下这个是干嘛的呢,还能干嘛呢?

我们先来解释下参数agentArgs

 -javaagent:D:codegithubagenttargetagent-1.0-SNAPSHOT.jar=123

等号后面的数据就会传入

图片

Instrumentation inst是什么呢??看看Java Doc怎么讲

<code> This class provides services needed to instrument Java programming language code. Instrumentation is the addition of byte-codes to methods for the purpose of gathering data to be utilized by tools. Since the changes are purely additive, these tools do not modify application state or behavior. Examples of such benign tools include monitoring agents, profilers, coverage analyzers, and event loggers.</code><code> There are two ways to obtain an instance of the Instrumentation interface:</code><code> When a JVM is launched in a way that indicates an agent class. In that case an Instrumentation instance is passed to the premain method of the agent class.</code><code> When a JVM provides a mechanism to start agents sometime after the JVM is launched. In that case an Instrumentation instance is passed to the agentmain method of the agent code.</code><code> These mechanisms are described in the package specification.</code><code> Once an agent acquires an Instrumentation instance, the agent may call methods on the instance at any time.</code><code> Since:1.5</code>

看不懂??我也是,简单地说就是可以修改字节码的功能,以实现一些功能,如:监控代理、分析器、覆盖分析仪和事件记录器

我们看下关键函数:

  • addTransformer:注册一个Transformer,后面加载的所有class都会经过他,字节修改的主要操作就是在这里了
  • isRetransformClassesSupported:是否支持重新加载class,通过设置MAINFEST.INF里的CAN-Retransform-classess为true
  • isRedefineClassesSupported:是否支持重新定义类,通过设置MAINFEST.INF里的CAN-Redefine-Classes为true
  • retransformClasses:重新加载指定类
  • redefineClasses:重新定义类
  • getAllLoadedClasses:获取当前JVM的所有class

我们来试下getAllLoadedClasses

<code> public static void premain(String agentArgs, Instrumentation inst) throws IOException {</code><code>         System.out.println("agent premain!!");</code><code>         Class[] classes = inst.getAllLoadedClasses();</code><code>         FileOutputStream fileOutputStream = new FileOutputStream(new File("classesInfo"));</code><code>         for (Class aClass : classes) {</code><code>             String result = "class ==&gt; " + aClass.getName() + "n";</code><code>             fileOutputStream.write(result.getBytes());</code><code>         }</code><code>         fileOutputStream.close();</code><code>     }</code>

获取的当前JVM的所有class,将近500个class

<code> class ==&gt; com.example.Agent</code><code> class ==&gt; sun.reflect.DelegatingMethodAccessorImpl</code><code> class ==&gt; sun.reflect.NativeMethodAccessorImpl</code><code> class ==&gt; sun.instrument.InstrumentationImpl$1</code><code> class ==&gt; [Ljava.lang.reflect.Method;</code><code> class ==&gt; java.security.BasicPermissionCollection</code><code> class ==&gt; java.security.UnresolvedPermission</code><code> class ==&gt; java.security.AllPermission</code><code> class ==&gt; java.io.FilePermissionCollection</code><code> class ==&gt; java.io.FilePermission$1</code><code> class ==&gt; java.io.FilePermission</code><code> class ==&gt; sun.net.www.MessageHeader</code><code> class ==&gt; sun.net.www.protocol.file.FileURLConnection</code><code> class ==&gt; sun.net.www.URLConnection</code><code> class ==&gt; java.net.URLConnection</code><code> class ==&gt; [J</code><code> class ==&gt; [I</code><code> class ==&gt; [S</code><code> class ==&gt; [B</code><code> class ==&gt; [D</code><code> class ==&gt; [F</code><code> class ==&gt; [C</code><code> class ==&gt; [Z</code><code> ......</code>

首个是agent的类,其他都是基础库的class(而我这个demo还有很多第三方组件的,为什么没加载??有可能是因为是实现的premain,后面试试agentmain是不是会获取的更多class)

ClassFileTransformer

我们再来看下ClassFileTransformer,这个核心操作类,作用就是转换class字节码,

这个是唯一接口函数

<code> byte[] transform(  ClassLoader         loader,</code><code>                 String              className,</code><code>                 Class&lt;?&gt;            classBeingRedefined,</code><code>                 ProtectionDomain    protectionDomain,</code><code>                 byte[]              classfileBuffer)</code><code>         throws IllegalClassFormatException;</code>

接口文档

 此方法的实现可以转换提供的类文件并返回新的替换类文件。 有两种transformers,由Instrumentation.addTransformer(ClassFileTransformer transformer,boolean canRetransform)的canRetransform参数确定:  重新转换使用Canretransform添加的功能变换器为True  重新发送无法使用Canretransform添加的无法变压器,或者使用Instrumentation.AddTransformer(ClassFileTransformer)  一旦在addTransformer中注册了转换器后,将为每个新的类定义和每个类重新定义调用该转换器。具有重转换功能的转换器也将在每个类的重转换上被调用。使用ClassLoader.defineClass或其本机等效项来请求新的类定义。使用Instrumentation.redefineClasses或其本机等效项进行类重新定义的请求。使用Instrumentation.retransformClasses或其本机等效项进行类重新转换的请求。在验证或应用类文件字节之前,将在处理请求期间调用转换器。如果有多个转换器,则通过链接转换调用来构成转换。也就是说,一次转换所返回的字节数组成为转换的输入(通过classfileBuffer参数)。

简单的总结

1、Java虚拟机加载class时才会调用transform这个方法的处理

2、返回null则不做修改

修改字节码例子

例子Agent.java

<code> import java.io.IOException;</code><code> import java.lang.instrument.Instrumentation;</code><code> import java.lang.instrument.UnmodifiableClassException;</code><code> </code><code> public class Agent {</code><code> </code><code>     public static void premain(String agentArgs, Instrumentation inst) throws IOException, UnmodifiableClassException {</code><code>         System.out.println("agent premain!!");</code><code>         inst.addTransformer(new MyTramsformet());</code><code> </code><code>     }</code><code> </code><code>     public static void agentmain(String agentArgs, Instrumentation inst){</code><code>         System.out.println("agent agentmain!!");</code><code>     }</code><code> }</code>

ClassFileTransformer子类代码,新增了个sex的参数,并重写print方法

<code> import javassist.*;</code><code> </code><code> import java.io.ByteArrayInputStream;</code><code> import java.lang.instrument.ClassFileTransformer;</code><code> import java.lang.instrument.IllegalClassFormatException;</code><code> import java.security.ProtectionDomain;</code><code> </code><code> public class MyTramsformet implements ClassFileTransformer {</code><code>     @Override</code><code>     public byte[] transform(ClassLoader loader, String className, Class&lt;?&gt; classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {</code><code> <em>//        System.out.println(className);</em></code><code>         if (className.replace("/", ".").equalsIgnoreCase("agent.Agent")) {</code><code>             try {</code><code>                 ClassPool classPool = ClassPool.getDefault();</code><code>                 classPool.appendClassPath(new LoaderClassPath(loader));</code><code>                 CtClass clazz = classPool.makeClass(new ByteArrayInputStream(classfileBuffer), false);</code><code>                 <em>//定义一个String类型的sex属性</em></code><code>                 CtField param = new CtField(classPool.get("java.lang.String"), "sex", clazz);</code><code>                 <em>//设置属性为private</em></code><code>                 param.setModifiers(Modifier.PRIVATE);</code><code>                 <em>//将属性加到类中,并设置属性的默认值为male</em></code><code>                 clazz.addField(param, CtField.Initializer.constant("male"));</code><code>                 <em>//为刚才的sex属性添加GET SET 方法</em></code><code>                 clazz.addMethod(CtNewMethod.setter("setSex", param));</code><code>                 clazz.addMethod(CtNewMethod.getter("getSex", param));</code><code>                 <em>//重写toString方法,将sex属性加入返回结果中。</em></code><code>                 CtMethod method = clazz.getDeclaredMethod("print");</code><code>                 method.setBody("System.out.println("name: " + name + " sex: " + sex);");</code><code>                 return clazz.toBytecode();</code><code>             }catch (Exception e){</code><code>                 e.printStackTrace();</code><code>             }</code><code>         }</code><code>         return null;</code><code>     }</code><code> }</code>

agent.Agent代码

<code> public class AgentTest {</code><code>     public static void main(String[] args){</code><code>         Agent agent = new Agent();</code><code>         agent.print();</code><code>     }</code><code> }</code><code> </code><code> class Agent {</code><code>     private String name = "agent";</code><code> </code><code>     public void print(){</code><code>         System.out.println(name);</code><code>     }</code><code> }</code>

添加VM参数,如果没有VM参数的填写处,可以点击Modify options添加

图片

运行结果

<code> agent premain!!</code><code> name: agent sex: male</code>

premain总结

1、premain函数仅添加addTransformer即可

<code> public static void premain(String agentArgs, Instrumentation inst) {</code><code>     System.out.println("agent premain!!");</code><code>     inst.addTransformer(new MyTramsformet());</code><code> }</code>

2、核心操作类在ClassFileTransformer的实现类,结合javassist进行字节码修改

3、MANIFEST.MF文件需要添加Premain-Class,maven插件添加

<code> &lt;build&gt;</code><code>         &lt;plugins&gt;</code><code>             &lt;plugin&gt;</code><code>                 &lt;groupId&gt;org.apache.maven.plugins&lt;/groupId&gt;</code><code>                 &lt;artifactId&gt;maven-assembly-plugin&lt;/artifactId&gt;</code><code>                 &lt;version&gt;2.6&lt;/version&gt;</code><code>                 &lt;configuration&gt;</code><code>                     &lt;appendAssemblyId&gt;false&lt;/appendAssemblyId&gt;</code><code>                     &lt;descriptorRefs&gt;</code><code>                         <em>&lt;!-- 将依赖包也打包进jar --&gt;</em></code><code>                         &lt;descriptorRef&gt;jar-with-dependencies&lt;/descriptorRef&gt;</code><code>                     &lt;/descriptorRefs&gt;</code><code>                     &lt;archive&gt;</code><code>                         &lt;manifest&gt;</code><code>                             &lt;addClasspath&gt;true&lt;/addClasspath&gt;</code><code>                         &lt;/manifest&gt;</code><code>                         &lt;manifestEntries&gt;</code><code>                             &lt;Premain-Class&gt;com.example.Agent&lt;/Premain-Class&gt;</code><code>                             &lt;Can-Redefine-Classes&gt;true&lt;/Can-Redefine-Classes&gt;</code><code>                             &lt;Can-Retransform-Classes&gt;true&lt;/Can-Retransform-Classes&gt;</code><code>                         &lt;/manifestEntries&gt;</code><code>                     &lt;/archive&gt;</code><code>                 &lt;/configuration&gt;</code><code>                 &lt;executions&gt;</code><code>                     &lt;execution&gt;</code><code>                         &lt;id&gt;assemble-all&lt;/id&gt;</code><code>                         &lt;phase&gt;package&lt;/phase&gt;</code><code>                         &lt;goals&gt;</code><code>                             &lt;goal&gt;single&lt;/goal&gt;</code><code>                         &lt;/goals&gt;</code><code>                     &lt;/execution&gt;</code><code>                 &lt;/executions&gt;</code><code>             &lt;/plugin&gt;</code><code>         &lt;/plugins&gt;</code><code>     &lt;/build&gt;</code>

注意:premain因为是在JVM加载前起作用,所以JVM加载的所有类都会经过transform处理,所以我们需要添加条件指定修改某类

动态加载

其实就是在JVM启动后加载,也就是类都加载好了,我还咋动态修改?

整体流程跟premain差不多,但有点区别

1、agentmain函数addTransformer添加ClassFileTransformer(带Instrumentation的优先级高)

<code> public static void agentmain(String agentArgs, Instrumentation inst);{</code><code>     System.out.println("agent agentmain!!");</code><code>     <em>//必须传入canRetransform参数为true,不然不允许retransformClasses</em></code><code>     inst.addTransformer(new MyTramsformet(), true);  </code><code>     inst.retransformClasses(clazz);  <em>// 区别</em></code><code> }</code><code> public static void agentmain(String agentArgs);{</code><code>     <em>//......</em></code><code> }</code>

函数里添加ClassFileTransformer的实现类,但是这里会有个区别,就是新增了

 inst.retransformClasses(clazz);  // 区别

retransformClasses是重新加载某类

静态加载的时候提到过: transform是只在JVM加载类的时候调用的,那我们监控的类可能就已经加载完了(不是所有类都是启动加载),所以如果我们要修改,那就必须重新加载,这样才会走transform

2、核心操作类在ClassFileTransformer的实现类,结合javassist进行字节码修改

3、MANIFEST.MF文件也有区别,需要添加Agent-Class,maven插件添加

 <Agent-Class>com.example.Agent</Agent-Class>

4、最重要的一点区别,直接按上面的写法,是无法起作用的,他不是通过JVM options指定加载的,而是通过virtualMachine.loadAgent(agentjar)触发agentmain

在Java6 以后实现启动后加载的新实现是Attach api,只有 2 个主要的类,都在 com.sun.tools.attach 包里,即JDK/lib/tools.jar,如果没有就自己添加下库

图片

demo代码

agent代码

<code> public static void agentmain(String agentArgs, Instrumentation inst) throws UnmodifiableClassException {</code><code>         System.out.println("[agentmain] agent agentmain!!");</code><code>         <em>// 必须传入canRetransform参数为true,不然不允许retransformClasses</em></code><code>         inst.addTransformer(new MyTramsformet(), true);</code><code>         Class[] classs = inst.getAllLoadedClasses();</code><code>         for (Class clazz: classs){</code><code> <em>//            System.out.println(clazz.getName());</em></code><code>             if (clazz.getName().replace("/", ".").equalsIgnoreCase("agent.Agent")){</code><code>                 System.out.println("[agentmain] " + clazz.getName() +</code><code>                         " isRetransformClassesSupported: " + inst.isRetransformClassesSupported() +</code><code>                         ", isRedefineClassesSupported: " + inst.isRedefineClassesSupported());</code><code>                 inst.retransformClasses(new Class[]{clazz});</code><code>             }</code><code>         }</code><code>     }</code>

ClassFileTransformer实现类代码

<code> import javassist.*;</code><code> </code><code> import java.io.ByteArrayInputStream;</code><code> import java.lang.instrument.ClassFileTransformer;</code><code> import java.lang.instrument.IllegalClassFormatException;</code><code> import java.security.ProtectionDomain;</code><code> </code><code> public class MyTramsformet implements ClassFileTransformer {</code><code>     @Override</code><code>     public byte[] transform(ClassLoader loader, String className, Class&lt;?&gt; classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {</code><code> <em>//        System.out.println("[transform] " + className);</em></code><code>         if (className.replace("/", ".").equalsIgnoreCase("agent.Agent")) {</code><code>             System.out.println("[transform] start trandform " + className);</code><code>             try {</code><code>                 ClassPool classPool = ClassPool.getDefault();</code><code>                 classPool.appendClassPath(new LoaderClassPath(loader));</code><code>                 CtClass clazz = classPool.makeClass(new ByteArrayInputStream(classfileBuffer), false);</code><code>                 <em>//定义一个String类型的sex属性</em></code><code>                 CtField param = new CtField(classPool.get("java.lang.String"), "sex", clazz);</code><code>                 <em>//设置属性为private</em></code><code>                 param.setModifiers(Modifier.PRIVATE);</code><code>                 <em>//将属性加到类中,并设置属性的默认值为male</em></code><code>                 clazz.addField(param, CtField.Initializer.constant("male"));</code><code>                 <em>//为刚才的sex属性添加GET SET 方法</em></code><code>                 clazz.addMethod(CtNewMethod.setter("setSex", param));</code><code>                 clazz.addMethod(CtNewMethod.getter("getSex", param));</code><code>                 <em>//重写toString方法,将sex属性加入返回结果中。</em></code><code>                 CtMethod method = clazz.getDeclaredMethod("print");</code><code>                 method.setBody("System.out.println("name: " + name + " sex: " + sex);");</code><code>                 return clazz.toBytecode();</code><code>             }catch (Exception e){</code><code>                 e.printStackTrace();</code><code>             }</code><code>         }</code><code>         return null;</code><code>     }</code><code> }</code><code> </code>

attach代码,就是触发agentmain的代码

<code> import com.sun.tools.attach.*;</code><code> </code><code> import java.io.IOException;</code><code> import java.util.List;</code><code> </code><code> public class AgentMain {</code><code>     public static void main(String[] args) throws IOException, AttachNotSupportedException, AgentLoadException, AgentInitializationException {</code><code>         Agent agent = new Agent();</code><code>         agent.print();</code><code>         System.out.println("start attach 'agent.Agent'");</code><code>         <em>// 获取当前正在运行的所有JVM</em></code><code>         List&lt;VirtualMachineDescriptor&gt; vmlist = VirtualMachine.list();</code><code>         for (VirtualMachineDescriptor vmd :</code><code>                 vmlist) {</code><code> <em>//            System.out.println(vmd.displayName());</em></code><code>             <em>// agent.AgentMain是当前类运行的jvm的名字</em></code><code>             if ("agent.AgentMain".equalsIgnoreCase(vmd.displayName())){</code><code>                 VirtualMachine virtualMachine = VirtualMachine.attach(vmd);</code><code>                 <em>// 加载我写得agent</em></code><code>                 virtualMachine.loadAgent("D:\code\github\agent\target\agent-1.0-SNAPSHOT.jar");</code><code>                 virtualMachine.detach();</code><code>             }</code><code>         }</code><code>         System.out.println("end attach 'agent.Agent'");</code><code>         agent.print();</code><code>     }</code><code> }</code>

agentmain已经触发了,但是修改字节码报错

<code> agent</code><code> start attach 'agent.Agent'</code><code> [agentmain] agent agentmain!!</code><code> [agentmain] agent.Agent isRetransformClassesSupported: true isRedefineClassesSupported: true</code><code> [transform] start trandform agent/Agent</code><code> java.lang.reflect.InvocationTargetException</code><code> at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)</code><code> at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)</code><code> at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)</code><code> at java.lang.reflect.Method.invoke(Method.java:498)</code><code> at sun.instrument.InstrumentationImpl.loadClassAndStartAgent(InstrumentationImpl.java:386)</code><code> at sun.instrument.InstrumentationImpl.loadClassAndCallAgentmain(InstrumentationImpl.java:411)</code><code> Caused by: java.lang.UnsupportedOperationException: class redefinition failed: attempted to change the schema (add/remove fields)</code><code> at sun.instrument.InstrumentationImpl.retransformClasses0(Native Method)</code><code> at sun.instrument.InstrumentationImpl.retransformClasses(InstrumentationImpl.java:144)</code><code> at com.example.Agent.agentmain(Agent.java:28)</code><code> ... 6 more</code><code> Exception in thread "main" com.sun.tools.attach.AgentInitializationException: Agent JAR loaded but agent failed to initialize</code><code> at sun.tools.attach.HotSpotVirtualMachine.loadAgent(HotSpotVirtualMachine.java:121)</code><code> at com.sun.tools.attach.VirtualMachine.loadAgent(VirtualMachine.java:540)</code><code> at agent.AgentMain.main(AgentMain.java:22)</code><code> Agent failed to start!</code><code> Exception in thread "Attach Listener" </code><code> </code>

搜了很久终于发现为什么了

https://stackoverflow.com/questions/18605025/failed-to-redefine-class-when-i-try-to-retransform-class

当一个类第一次被加载时,除了存储类的字节码之外,JVM 还具有一些表格,用于跟踪每个类中的字段类型和方法签名。

所以如果像我上面代码新增了字段,就会报错

<code> package com.example;</code><code> </code><code> import javassist.*;</code><code> </code><code> import java.io.ByteArrayInputStream;</code><code> import java.lang.instrument.ClassFileTransformer;</code><code> import java.lang.instrument.IllegalClassFormatException;</code><code> import java.security.ProtectionDomain;</code><code> </code><code> public class MyTramsformet implements ClassFileTransformer {</code><code>     @Override</code><code>     public byte[] transform(ClassLoader loader, String className, Class&lt;?&gt; classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {</code><code> <em>//        System.out.println("[transform] " + className);</em></code><code>         if (className.replace("/", ".").equalsIgnoreCase("agent.Agent")) {</code><code>             System.out.println("[transform] start trandform " + className);</code><code>             try {</code><code>                 ClassPool classPool = ClassPool.getDefault();</code><code>                 classPool.appendClassPath(new LoaderClassPath(loader));</code><code>                 CtClass clazz = classPool.makeClass(new ByteArrayInputStream(classfileBuffer), false);</code><code>                 CtMethod method = clazz.getDeclaredMethod("print");</code><code>                 <em>// 只是修改了打印内容</em></code><code>                 method.setBody("System.out.println("name: " + name + " sex: ");");</code><code>                 return clazz.toBytecode();</code><code>             }catch (Exception e){</code><code>                 e.printStackTrace();</code><code>             }</code><code>         }</code><code>         return null;</code><code>     }</code><code> }</code><code> </code>

这样就可以了

<code> agent</code><code> start attach 'agent.Agent'</code><code> [agentmain] agent agentmain!!</code><code> [agentmain] agent.Agent isRetransformClassesSupported: true, isRedefineClassesSupported: true</code><code> [transform] start trandform agent/Agent</code><code> end attach 'agent.Agent'</code><code> name: agent sex:</code>

那既然不能修改已加载的,那我能不能重新定义一个??

本来想着不是有个redefineClasses,但是发现并不是一回事

  • retransformClasses:是重新加载,可修改其字节码
  • redefineClasses:是重新定义,根据自定义的字节码覆盖原类,但是有限制,当然redefine时,并不是随心所欲,我们可以重新定义方法体、常量池、属性、但是不可以添加、移除、重命名方法和方法和入参,不能更改方法签名或更改继承

JavaAgent运用

上面学完了怎么用了,那javaagent的主要的运用有哪些呢

  • 可以在加载java文件之前做拦截把字节码做修改
  • 可以在运行期将已经加载的类的字节码做变更,但是这种情况下会有很多的限制,后面会详细说
  • 还有其他的一些小众的功能
    • 获取所有已经被加载过的类
    • 获取所有已经被初始化过了的类(执行过了clinit方法,是上面的一个子集)
    • 获取某个对象的大小
    • 将某个jar加入到bootstrapclasspath里作为高优先级被bootstrapClassloader加载
    • 将某个jar加入到classpath里供AppClassloard去加载
    • 设置某些native方法的前缀,主要在查找native方法的时候做规则匹配

当然安全研究者的就是内存马

总结

整体原理就是实现代理功能

premain:可以做的更多,字节码修改幅度更大

agentmain:仅能修改原class的内容,不能新增修改元数据,如新增字段和方法

参考资料:

https://bbs.huaweicloud.com/blogs/detail/260776

https://xz.aliyun.com/t/9450#toc-1

https://www.infoq.cn/article/fh69pypqzpf6cj1ujy7x

https://developer.aliyun.com/article/2946

发表评论

登录后才能评论
网站客服
网站客服
申请收录 侵权处理
分享本页
返回顶部