专栏首页Java Studyjava agent 及字节码技术得到 DNS 时间流程尝试(如何对jdk 代码进行AOP操作)
原创

java agent 及字节码技术得到 DNS 时间流程尝试(如何对jdk 代码进行AOP操作)

想说一下这个意义吧,单纯的只是为了得到dns 的时间 好像并没有很大的意义或者值得研究的只是一小部分,但不只是对InetAddress.getByName 方法的aop ,而是对大部分 bootstrap 类加载器加载的 jdk 类都可以采用以下方法,来得到答案,就像我在这个过程中对 final 类型的 String 类的 toString ()方法进行了aop 切入,还有对 HashMap 的put () 方法进行 aop 你尝试了一种方法后会对大部分的 这样都会有思路,我们在web 项目中大部分都是对项目里编写的service controller 进行 aop 记录 ,而那些都是有我们自己编写的代码,是webApplicaiton classloader 来进行加载的,但对这些jdk 的代码,使用spring aop 的方式来进行切入是否还起作用呢?如果没有实现我们的作用那怎么进行切入呢。

问题描述

如何得到一次请求http中的dns 时间(域名转ip地址),那为什么要计算这个时间呢?

一次http 请求的经历的过程:

  1. 客户端 点击页面,向服务端发送一个http 请求。
  2. 客户端首先得到的请求域名的主机ip 这就需要用到 dns 服务。 (根据本机设置的dns 服务商  时间长短会有波动)。  1
  3. 客户端经过dns 服务得到 ip 地址,会以一个随机端口(1024 < 端口 < 65535)再去向服务端的web 端口默认80 发出tcp 建立连接的请求,客户端经过三次握手后,建立连接。 2
  4. 客户端建立连接后会向服务端发送http 请求。
  5. 服务端响应,客户端(浏览器)解析响应数据进行html 对应位置的显示。3
  6. 服务端与客户端断开连接。

当我们在用日志统计时,当我们用 httpclient 去构造一个 get 请求,统计这个get的时间区间是:

请求发送request  到收到 respone 的时间  主要是 1 3 的时间,正常情况下 1的时间会在 30ms 内 baidu  腾讯 正常的都在 10ms 左右而且dns 会有缓存会更快,基本可以忽略dns 时间 ,但会有特殊的情况,

会混淆是dns 的问题,还是我们编写的服务端的响应速度问题 ?正常我们都会认为 请求到响应的时间长 是我们的服务端的问题,但会有dns 解析的时间过长导致的问题,这就需要,如果我们能更细度的能够统计到dns 的时间,这对观察日志时也是一件值得做的事。

开始

首先从Java 层面上 是否有能够直接得到请求域名时 ,返回dns 时间的方法?

经过一些的网上搜索资料,确认Java 层面没有比较方法,但是Java .net 包下 有一个方法,根据域名而得到 ip 的方法 方法如下:

public static void main(String[] args) throws UnknownHostException {
                                            
    InetAddress ip = InetAddress.getByName("www.changyou.com");  // 你可以试一下baidu www.baidu.com
    InetAddress[] ips = InetAddress.getAllByName("www.changyou.com");
 
    System.out.println(ip);
    System.out.println();
    for (InetAddress ip1:ips) {
        System.out.println(ip1);
    }
 
}
  
sout :    
  
www.changyou.com/211.93.241.55
 
www.changyou.com/211.93.241.55
www.changyou.com/60.28.100.37

1.可以看到 输入域名  www.changyou.com 返回了 IP地址。  看到这些,我们是否可以对这个方法或者这个 方法调用内部的方法进行aop 切面编程 来得到 这个请求响应的时间。(或者尝试使用百度 www.baidu.com)

2.经过 对 InetAddress.getByName 下层方法打断点 debug,及 对URLconcention  HttpClient  debug 的方法追踪,发现这些工具类 同样是调用了这个 getByName 的方法来获取 ip地址的,因此对这个方法进行切入是很有意义的。

尝试

我们知道 对一个类一个方法进行aop 操作,最能想到的就是 spring 集成的Aop 特性。提前说: 如果想看到最后的实现过程直接跳到,下面的Javassist  部分。

Spring Aop 的尝试

首先知道,spring实现的aop 必须是已经在spring ioc 容器中的bean ,因此需要将 这个inetAddress 类注册到ioc 容器中,采用的方式是在启动类上加 import(@Import(value = java.lang.Object.class) )和使用xml 注入的方式 然后对inetAddress 进行环绕增强


@Pointcut("execution(* java.net.InetAddress.getByName(String))") // java.net. InetAddress.getByname(String)
public void pointcut2() {
}

@Around("pointcut2()")
public Object around(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
long startTime = System.currentTimeMillis();

 Object result = proceedingJoinPoint.proceed();

long endTime = System.currentTimeMillis();
 System.out.println(endTime-startTime);

return result;
}

 

报错为: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'InetAddress' defined inclasspath resource [application-bean.xml]: Initialization of bean failed; nested exception is org.springframework.aop.framework.AopConfigException: Could not generate CGLIB subclass ofclassjava.net.InetAddress: Common causes ofthis problem include using a final classor non-visible class;nested exception is java.lang.IllegalArgumentException: No visible constructors inclassjava.net.InetAddress

报错日志写的相当的详细, No visible constructors in class java.net.InetAddress  因为查看 这个InetAddress()构造函数的范围为 default ,没有一个可用的构造器,我又换了一个object 结果也不行,真的object类就没有构造器,  那我们面试总是被问到的而且有构造器的就是 就是 hashmap了

默认容量我为16大小的 ,因次我又尝试了一下,配置文件 配置bean 修改aop 的配置,这次是对 hashmap 的put() 方法为切点进行了aop 操作, 因此然后 在验证是否aop成功时,ioc bean容器 注入Hashmap类型的map 并调用了一个controller 对他put了一下,结果还真的可以注入进去,打印了方法执行的时间,那正也说明了 一个类注入spring 容器进去后,是可以进行操作的。

思考,方法是一个静态的方法,方法可以被子类重写吗?(cglib 是通过继承要需要被aop的类生成代理类,而进行操作的) 不可以被重写,但可以被继承 ,new 子类会调用自己的方法 ,但是(父类)People  man1 =  new  man()(子类) 在向上转型时。

还是会调用父类people的方法。那对我们 springaop cglib 对静态方法 的形式会有影响吗?网上搜到的答案是spring aop 是不可以对静态方法进行aop 的 ,原因 ,因此我们用了spring aop 除了构造器的作用范围, 还要不能对静态方法进行改造。

对 Hashmap 的 put 方法 aop 操作:代码

@Pointcut("execution(* java.util.HashMap.put(..))") // java.net.. InetAddress.getByname(String)
public void pointcut() {
}

@Around("pointcut2()")
public Object around(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
long startTime = System.currentTimeMillis();

 Object result = proceedingJoinPoint.proceed();
 

while (System.currentTimeMillis()-startTime<2000){
// 为了结果明显,在这里加了一个停止2 s的操作
}

long endTime = System.currentTimeMillis();
 System.out.println(endTime-startTime);

return result;
}

 

Controller中的执行putMap 操作

@GetMapping("/putMap")
@ResponseBody
public String putMap () throws Exception {

UUID temp = UUID.randomUUID();
 hashMap1.put(temp, UUID.randomUUID());

return temp.toString();
}

在每次请求 putMap 的url 时 会停止两秒中 再会显示结果。控制台输出时间为2000ms
2000
2000
2000
2000
不暂停2s 的话 记录的时间为 :侧面说明如果我aop 成功的话,那hashmap 的put 操作速度是真的快啊,只有第一次put时因为要进行 初始化map 时间为 5ms 其余的操作都是0ms,,,,
5
0
0
0
0
0
0
0

最终

 Java agent +javassist字节码 

使用了 javassist字节码 技术来实现了对  InetAddress 类的的getByAddress()方法的改造。

同样利用 Java agent 技术,自定义实现了一个 实现 ClassFileTransformer 接口的   PerformMonitorTransformer 方法。

对是  java.net.InetAddress 的类获取 方法集 ,并对方法名为 ” getAllByName “ 的方法进行了增强。

代码实现

public class PerformMonitorTransformer implements ClassFileTransformer {
 
    private static final String PACKAGE_PREFIX = "java.net.InetAddress";  //要增强的前缀
 
    // 通过 transform 来修改类文件
    @Override
    public byte[] transform(ClassLoader loader,
                            String className,
                            Class<?> classBeingRedefined,
                            ProtectionDomain protectionDomain,
                            byte[] classfileBuffer) throws IllegalClassFormatException {
        try {
            if (className == null){
                return classfileBuffer;
            }
            String currentClassName = className.replaceAll("/", ".");
            // 仅仅提升这个包中的类
            if (!currentClassName.equals(PACKAGE_PREFIX)) {
                return classfileBuffer;
            }
            System.out.println("now transform: [" + currentClassName + "]");
 
            // 得到类  -》》   决定对方法的 是否要增强。
            CtClass ctClass = ClassPool.getDefault().get(currentClassName);
            CtBehavior[] methods = ctClass.getDeclaredBehaviors();
         
            for (CtBehavior method : methods) {
                if (method.getName().equals("getAllByName"))//getByName
                    enhanceMethod(method); // 提升方法
            }
 
            return ctClass.toBytecode();  //类的字节数组
        } catch (Exception e) {
            e.printStackTrace();
        }
        return classfileBuffer;
 
    }
 
 
  
 /* 提升方法    javassist */
    private void enhanceMethod(CtBehavior method) throws Exception {
        if (method.isEmpty()) {
            return;
        }
        final String methodName = method.getName();
 
        ExprEditor editor = new ExprEditor() {
            @Override
            public void edit(MethodCall methodCall) throws CannotCompileException {
                //
                try {
                    methodCall.replace(genSource(methodName));
                } catch (ClassNotFoundException e) {
                    e.printStackTrace();
                }
            }
        };
        method.instrument(editor);
    }
  
     
/*  具体增强的实现 打入新的代码    关键实现逻辑 */
private String genSource(String methodName) throws ClassNotFoundException {
    StringBuilder source = new StringBuilder();
    source.append("{")
            .append("long start = System.currentTimeMillis();\n") // 前置增强: 打入时间戳
            .append("$_ = $proceed($$);\n") // 保留原有的代码处理逻辑
            .append("long spendTime =System.currentTimeMillis() -start;")
           // .append("this.getClass().getClassLoader().loadClass(\"instrument.HttpContext\");")     本来想采用 threadlocal 来存放记录的值 结果无法使用
            .append("if (spendTime!=0){")
            .append("System.setProperty(\"spendTime\", String.valueOf(spendTime));")  // 通过向系统变量中设置 dns 花费的时间来解决这问题,通过取出 System.getProperty("spendTime");         
           // .append("HttpContext.get().put(\"spendTime\",spendTime);")
            .append("System.out.print(\"method:[" + methodName + "]\");").append("\n")
            .append("System.out.println(\" cost:[\" +(spendTime)+ \"ms]\");}") // 后置增强
            .append("}");
    return source.toString();
 
}

要记得日志每次进行 

System.getProperty("spendTime"); // 完后要  清除掉设置的这个时间
System.clearProperty("spendTime"); 

将自己的 agent 程序进行打包后, 测试 用一个web 程序 使用 -javaagent :E:\xxx\demo-agent-master\first-instrument\target\my-agent.jar=first  的方式来 启动

Controller 层RequestMapping方法


@GetMapping("/getDnsTime3")
@ResponseBody
public String getDnsTime3 () throws Exception {
InetAddress inetAddress = InetAddress.getByName("www.nowcoder.com");  // 对牛客网
 String spendTime = System.getProperty("spendTime");     //可取到这个值

 

System.out.println(spendTime);

 System.clearProperty("spendTime");                                    // 清理这个key



return inetAddress.toString()+" "+spendTime;    // 响应体 
}

运行效果

对牛客网的域名测试
method:[getAllByName] cost:[17ms]
method:[getAllByName] cost:[17ms]
对b站的 测试时间
method:[getAllByName] cost:[2ms]
查看响应体 
www.nowcoder.com/114.55.207.244 17 
www.bilibili.com/103.41.165.46 2

修正

Agent 中的 ByteBuddy实现部分尝试(也已经成功)

ByteBuddy 自己有自己包装的Java agnet 方法利用 AgentBuilder来构建并实现 关键的transform 方法

实现代码如下:

已采坑说明:ByteBuddy 号称可以创建和增强所以的类,

作者也谈到:在处理 bootStrap类加载器加载的类时,bytebuddy 注意默认是绕过这些类的,如果我们想要去改变这些类要

1 // 此时要 注意添加把忽略增强的 设为(none),也就是说默认是对jdk 的类进行过滤的不增强,设置为none 会都可以进行增强

AgentBuilder agentBuilder = new AgentBuilder.Default().ignore(none());

2 尽量使用Advice 去进行增强,而不使用委托类进行增强。

更正后的代码为:

public static void premain (String arg , Instrumentation instrumentation) {

new AgentBuilder.Default()
.with(AgentBuilder.Listener.StreamWriting.toSystemError())
.ignore(none()) // 设置忽略
.disableClassFormatChanges()
.with(AgentBuilder.RedefinitionStrategy.REDEFINITION)
.with(AgentBuilder.TypeStrategy.Default.REDEFINE)
.type(named("java.lang.String"))
.transform((builder, typeDescription, classLoader) -> builder
.visit(Advice
.to(GetTimeAdvice.class) // 交给 addvice 类进行增强
.on(nameEndsWith("toString")) //.and(takesArguments(String.class)) 对toString()方法进行增强
 )
)
.installOn(instrumentation);
}

修改使用简单的 Advice 类

 

public class GetTimeAdvice {

    @Advice.OnMethodEnter
 public static long enter(@Advice.Origin Method method , @Advice.This Object thiz){


        long l = System.currentTimeMillis();
  System.out.println("进入的时间为 "+ l );
 return l;
 }


 @Advice.OnMethodExit
 public static void out( @Advice.Origin java.lang.reflect.Method method,
 @Advice.Enter long start
                             ){
        System.out.println("开始的时间为 "+start);

  System.out.println(" 花费的时间为: "+ String.valueOf(System.currentTimeMillis() - start)  );

 }


}

首先是对 toString 方法进行的增强 日志打印 TRANSFORM java.lang.String 说明 byte buddy 对string 类进行了增强

[Byte Buddy] TRANSFORM java.lang.String [null, null] [Byte Buddy] COMPLETE java.lang.String [null, null]

调用 toString 方法的结果会打印很多,自己在测试时只调用了一处,但打印了多次 , 猜想是String 类型的 toString()方法 byte buddy 中也有被调用过,好多方法都进行了使用。

进入的时间为 1601346851430
开始的时间为 1601346851430
花费的时间为:0

进入的时间为 1601346851434
开始的时间为 1601346851434
花费的时间为: 0
进入的时间为 1601346851436
开始的时间为 1601346851436
花费的时间为:0
 
进入的时间为 1601346851437
 开始的时间为 1601346851437
花费的时间为:0
尝试对 String 的mathes() 进行增强,只有一处进行了输出,说明增强成功
进入的时间为 1601347498553
开始的时间为 1601347498553
花费的时间为: 1
正式对 java.net.InetAddress 的增强
对www.newcoder.com 进行解析
[Byte Buddy] TRANSFORM java.net.InetAddress [null, null, loaded=true]
public static java.net.InetAddress[] java.net.InetAddress.getAllByName(java.lang.String) throws java.net.UnknownHostException took 72 ms
www.newcoder.com/121.199.77.57
对www.changyou.com/ 进行解析
[Byte Buddy] TRANSFORM java.net.InetAddress [null, null, loaded=true]
public static java.net.InetAddress[] java.net.InetAddress.getAllByName(java.lang.String) throws java.net.UnknownHostException took 8 ms
www.changyou.com/125.39.1.138

开始尝试时的代码(未成功)

public static void premain(String arguments, Instrumentation instrumentation) {


new AgentBuilder.Default()
.type(nameMatches("java.net.InetAddress")) // 指定类
.transform(new AgentBuilder.Transformer() {
@Override
 public DynamicType.Builder transform(DynamicType.Builder builder,
 TypeDescription typeDescription,
 ClassLoader classloader) {
return builder.method(named("getByName")) // 指定方法
.intercept(MethodDelegation.to(TimeInterceptor.class)); //增加委派方法

  }
}).installOn(instrumentation); //加入到 instrumenation中




}

 

对 hashmap 的改造

public static void premain(String argument, Instrumentation inst) {

System.out.println("start premain)");
 // HashMap<Object, Object> map = new HashMap<>();
 new AgentBuilder.Default()// bytebuddy.demo
 .type(named("java.util.HashMap").and(not(isInterface())).and(not(isStatic())))
.transform((builder, typeDescription, classLoader) -> builder
.method(ElementMatchers.named("put"))
.intercept(MethodDelegation.to(TimeInterceptor.class)
)
).with(new AgentBuilder.Listener() {
@Override
 public void onTransformation(TypeDescription typeDescription, ClassLoader classLoader, JavaModule javaModule, DynamicType dynamicType) {

}

@Override
 public void onIgnored(TypeDescription typeDescription, ClassLoader classLoader, JavaModule javaModule) {

}

@Override
 public void onError(String s, ClassLoader classLoader, JavaModule javaModule, Throwable throwable) {
throwable.printStackTrace();
 }

@Override
 public void onComplete(String s, ClassLoader classLoader, JavaModule javaModule) {

}
})
.installOn(inst);
}

 

 

//TimeInterceptor.class 的方法:

@RuntimeType
public static Object intercept(@Origin Method method, @SuperCall Callable<?> callable)
throws Exception {
// aop
 long start = System.currentTimeMillis();
 // 原有函数执行
 Object call = callable.call();

 System.out.println(method + ": took " + (System.currentTimeMillis() - start) + "ms");

return call;

}

经过对这两种方式的改造 ,但是都没有生效。

如何直接使用它

可以用 -Javaagent:xxxx.jar 的方式来 直接看到效果。

my-agent.jar (上文中的 java ssist 的实现方式)下载这个 jar 然后 在启动这块加上本机jar 的位置

编写一个类 我的为 InstrumentTest 和上面的Main Class 对上

写一个main 方法

public static void main(String[] args) throws UnknownHostException {


      //比如说 调用这个方法 ,或者你可以写一个 httpclient 的get请求
     InetAddress byName2 = InetAddress.getByName("www.changyou.com");

 // 可以在System.getProperty("spendTime")  取出这个值 ,用到比如说 打日志的地方 
  System.out.println("dns的时间"+ System.getProperty("spendTime"));

}

查看控制台打印:深色部分为 agent 打印的输出作用

this is an perform monitor agent.
now transform: [java.net.InetAddress]
method:[getAllByName] cost:[1ms]
method:[getAllByName] cost:[1ms]
method:[getAllByName] cost:[39ms]
method:[getAllByName] cost:[40ms]
dns的时间40

my-agent-byteBuddy.jar byte buddy 版的 原理相同

打印5 次 间隔2 s 打印结果如下[Byte Buddy] TRANSFORM java.net.InetAddress [null, null, loaded=false]private static java.net.InetAddress[] java.net.InetAddress.getAllByName(java.lang.String,java.net.InetAddress) throws java.net.UnknownHostException took 3 msprivate static java.net.InetAddress[] java.net.InetAddress.getAllByName(java.lang.String,java.net.InetAddress) throws java.net.UnknownHostException took 14 mswww.newcoder.com/121.199.77.57private static java.net.InetAddress[] java.net.InetAddress.getAllByName(java.lang.String,java.net.InetAddress) throws java.net.UnknownHostException took 0 mswww.newcoder.com/121.199.77.57private static java.net.InetAddress[] java.net.InetAddress.getAllByName(java.lang.String,java.net.InetAddress) throws java.net.UnknownHostException took 0 mswww.newcoder.com/121.199.77.57private static java.net.InetAddress[] java.net.InetAddress.getAllByName(java.lang.String,java.net.InetAddress) throws java.net.UnknownHostException took 0 mswww.newcoder.com/121.199.77.57private static java.net.InetAddress[] java.net.InetAddress.getAllByName(java.lang.String,java.net.InetAddress) throws java.net.UnknownHostException took 0 mswww.newcoder.com/121.199.77.57 后面4次 时间都为 0ms ,将记录时间的单位换为 nanoTime 再打印5次 发现是 dns有缓存 速度很快的原因。 private static java.net.InetAddress[] java.net.InetAddress.getAllByName(java.lang.String,java.net.InetAddress) throws java.net.UnknownHostException took 148488614 mswww.newcoder.com/121.199.77.57private static java.net.InetAddress[] java.net.InetAddress.getAllByName(java.lang.String,java.net.InetAddress) throws java.net.UnknownHostException took 149198590 msprivate static java.net.InetAddress[] java.net.InetAddress.getAllByName(java.lang.String,java.net.InetAddress) throws java.net.UnknownHostException took 115914 mswww.newcoder.com/121.199.77.57private static java.net.InetAddress[] java.net.InetAddress.getAllByName(java.lang.String,java.net.InetAddress) throws java.net.UnknownHostException took 121951 mswww.newcoder.com/121.199.77.57private static java.net.InetAddress[] java.net.InetAddress.getAllByName(java.lang.String,java.net.InetAddress) throws java.net.UnknownHostException took 90860 mswww.newcoder.com/121.199.77.57private static java.net.InetAddress[] java.net.InetAddress.getAllByName(java.lang.String,java.net.InetAddress) throws java.net.UnknownHostException took 119537 mswww.newcoder.com/121.199.77.57

流程图简单如下:

收获

debug 能力: 在找getByName()时,之前的idea 的debug 其实玩的不是很清楚。

spring aop 中的 反射和cglib 的两种动态代理模式 实现实际的代码认识:自己手写了两种实现。

spring aop 的实际横切:之前spring aop 太长时间不自己手写用了。

bytebuddy 和 Java agent 的实际使用

下次应对 要用agent 横切一种 类型时会有经验。

解决关键问题的:byte buddy作者 与问题提问者的对话 链接:

https://github.com/raphw/byte-buddy/issues/276

https://stackoverflow.com/questions/40433232/redefine-java-lang-classes-with-bytebuddy

对String类 进行增强的 代码示例:

https://github.com/kriegaex/ByteBuddyAgent

原创声明,本文系作者授权云+社区发表,未经许可,不得转载。

如有侵权,请联系 yunjia_community@tencent.com 删除。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • Java并发机制的底层实现原理--Java并发编程的艺术

    当volatile 修饰的共享变量时,在进行写操作时,查看Java程序经 编译 解释为机器语言,汇编语言时,发现多了一个lock 的前缀。

    猎户星座1
  • 并发编程的挑战及解决方案--Java并发编程的艺术

    并发编程是为了让程序运行的更快,相比但单线程,使用多个线程处理一项任务,明显具有优越性。但在使用多线程时要注意,比如进程之间的通信和同步问题。

    猎户星座1
  • jvm -垃圾收集算法与经典垃圾收集器

    标记清除算法,首先对需要收集的对进行标记,或者对不需要回收的对象进行标记,然后统一回收掉被标记的对象。

    猎户星座1
  • Java 学习笔记(1)——java基础语法

    最近抽时间在学习Java,目前有了一点心得,在此记录下来。 由于我自己之前学过C/C++,而Java的语法与C/C++基本类似,所以这一系列文章我并不想从基础...

    Masimaro
  • 搜索引擎排名技术,引爆网站流量,你也可以做到 第一课

    对于进行关键词排名,没有固定的模式,仅仅是基于传统经验之上慢慢摸索出来的一条道路,通过网站的一些设置让搜索引擎觉得网站更友好,提升搜索引擎蜘蛛停留时间,增加收录...

    做全栈攻城狮
  • error occurred during initializaton of VM

    error occurred during initializaton of VM java.lang.error:properties init:Could...

    MickyInvQ
  • Java中的时间和日期(一):有关java时间的哪些坑

    从一开始学习java到现在,我们都一直在使用java.util.Date这个对象来表示时间和日期。使用也很方便:

    冬天里的懒猫
  • linux下安装部署jenkins

    环境搭建(linuxs版本) 一、安装包下载地址 注:笔者以64位为例 1、JDK安装包: http://www.oracle.com/technetwork/...

    苦叶子
  • RMI

    RMI定义:     RMI即远程方法调用(Remote Method Invocation)。能够让在某个java虚拟机上的对象像调用本地对象一样调用另一个j...

    用户1215919
  • 深入理解 Java 反射:Field (成员变量)

    深入理解 Java 反射系列: 深入理解 Java 反射:Class (反射的入口) 深入理解 Java 反射:Field (成员变量) 深入理解 Java ...

    张拭心 shixinzhang

扫码关注云+社区

领取腾讯云代金券