写点什么

JavaAgent 原理与实践

  • 2019-09-24
  • 本文字数:9109 字

    阅读完需:约 30 分钟

JavaAgent原理与实践

1JavaAgent

启动时加载的 JavaAgent 是 JDK1.5 之后引入的新特性,此特性为用户提供了在 JVM 将字节码文件读入内存之后,JVM 使用对应的字节流在 Java 堆中生成一个 Class 对象之前,用户可以对其字节码进行修改的能力,从而 JVM 也将会使用用户修改过之后的字节码进行 Class 对象的创建。

1.1 JVM Tool Interface

JVMTI 是 JVM 暴露出来的一些供用户进行自定义扩展的接口集合,每当 jvm 执行到一些特定的逻辑的时间,就会去进行触发这些回调接口,用户就恰好可以在此回调接口之中做一些自定义逻辑。


而对于此次所要描述的 JavaAgent 也恰恰是基于 JVMTI 的,JPLISAgent 就是用作实现 javaagent 功能的动态库。

1.2 JPLISAgent

JPLISAgent 实现了 Agent_OnLoad 方法,Agent_OnLoad 方法也就是整个启动时加载的 JavaAgent 的入口方法,后续也会说明整个运行流程。

2 如何使用

虽然大多数同学可能已经使用过 JavaAgent 了,但是为了下面原理的平滑过渡,我这里还是大概写一下使用:

2.1 编写 premain 启动类

编写一个含有以下 premain 函数的类


1[1]public static void premain(String agentArgs, Instrumentation instrumentation);2[2]public static void premain(String agentArgs); 
复制代码


上面的两个方法只需要实现一个即可,且[1]的优先级是高于[2]的,即如果上面的两个方法同时出现,则只会执行[1]方法


agentArgs 是跟随 javaagent:xx.jar=yyy 传入的 yyy 字符串


instrumentation 是一个 java.lang.instrument.Instrumentation 实例,由本地方法实例化并由 jvm 自动传入。此类是 JavaAgent 的核心类。


1public class Agent {  2 public static void premain(String args, Instrumentation inst){  3        System.out.println("Hi, This is a agent!");  4        inst.addTransformer(new TestTransformer()); //将类转换器添加到此`agent`的`instrumentation`实例之中5    }  6}
复制代码

2.2 类转换器

类转换器的作用主要是在某个类的字节码被 JVM 读入之后,在 Java 堆上创建 Class 对象之前,JVM 会遍历所有的 instrumentation 实例并执行其中的所有的 ClassFileTransformer 的 transform 方法,其中关于启动时加载的 javaAgent 重点需要关注的入参:


className:当前类的限定类名


classfileBuffer:当前类的以 byte 数组呈现的字节码数据(可能跟 class 文件的数据不一致,因为此处的 byte 数据是此类最新的字节码数据,即此数据可能是原始字节码数据被其他增强方法增强之后的自己买数据)


1public class TestTransformer implements ClassFileTransformer {  2  @Override  3  public byte[] transform(ClassLoader classLoader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) {  4        //进行对应类字节码的操作,并返回新字节码数据的byte数组,如果返回null,则代码不对此字节码作任何操作5        return null;6   } 7} 
复制代码

2.3 MAINIFEST.MF

1Manifest-Version: 1.0  2Premain-Class: test.Agent  
复制代码


然后将上述的代码打成 jar 包并在 jvm 启动时增加-javaagent:xx.jar 即可以完成 javaAgent 的生效。

3 实现原理

上面关于 JavaAgent 的使用有涉及到这么几个关键字:


permain,Instrumentation,ClassFileTransformer,MAINIFEST.MF 中的 Premain-Class


其实对于上面这些关键字就恰好组成了 JavaAgent 的实现原理


对于在启动命令添加的-javaagent=xx.jar 如果有多个,加载顺序就是从前往后,且每一个-javaagent 都是独立的。


以下的加载流程仅仅只是针对其中的一个 javaagent 描述的。


于 javaAgent 的入口方法就是 InvocationAdapter.c 中的 Agent_OnLoad 方法。经过查看 openjdk 源码,发现如下注释


1/*2 *  This will be called once for every -javaagent on the command line.3 *  Each call to Agent_OnLoad will create its own agent and agent data.4 *
复制代码


由注释我们可以指定,每一个-javaagent 都会有其自己的 agent 和 agent 数据,且每一个 javaagent 都会调用一次 Agent_OnLoad 方法就会被调用一次,且每一次的调用都是独立的。


在 Agent_OnLoad 方法中主要做的事情有下面三个:


1)初始化一个 JPLISAgent 对象,并给此对象设置 VMInit 事件的回调函数 eventHandlerVMInit.


2)找到 jvm 启动参数中-javaagent:xx.jar=yyy 中的 xx.jar 文件添加到 classpath 之中,并获取 yyy


3)找到 xx.jar 包中的 MAINIFEST.MF 中定义的 premainClass 并作为此 Agent 的入口类


4)并将 premainClass 和 yyy 设置到步骤 1 初始化的 JPLISAgent 对象之中。


当 VMInit 事件完成以后,会回调 InvocationAdapter.c 中的 eventHandlerVMInit 方法,eventHandlerVMInit 方法主要做的事情有下面:


1)实例化一个 InstrumentationImpl 对象,jvm 并依借此对象与 java 代码进行交互。


2)通过 JNI 执行 MAINIFEST.MF 中定义的类中的 premain 方法(我们上面的例子之中在 premain 方法中给 Instrumentation 对象添加了一个 ClassFileTransformer)


3)去除 JPLISAgent 对象中的 VMInit 回调函数,转而设置一个 ClassFileLoadHook 事件的回调函数。


当 ClassFileLoadHook 事件(在字节码文件被 jvm 读入之后,在 Class 对象创建之前)完成后,进行触发 eventHandlerClassFileLoadHook,此方法主要做的事情有下面几件:


1)进行调用 InstrumentationImpl 对象中的 mTransform 方法,而对于 mTransform 方法,最终会调用到我们在 Agent 的 premain 方法中给 Instrumentation 增加的 ClassFileTransformer。


此时,JVM 会通过 JNI 调用 java 代码,对应的类就是 sun.instrument.InstrumentationImpl 类之中,而对应于 mTransform 的方法就是 byte[] transform(ClassLoader var1, String var2, Class<?> var3, ProtectionDomain var4, byte[] var5, boolean var6)


方法是上述中 c 代码调用的地方,其中的 var5 是对应当前文件的字节码数据,如果此接口返回的数据为 null,则认为 transform 方法并未对此 class 文件有过修改,如果返回的数据不为 null,则会使用返回的新字节码作为 jvm 中此类最新的字节码并进行下一个 Javaagent 的处理或者创建 Class 对象进行链接以及初始化。


其中对于 c 代码通过 JNI 反射调用 java 代码的方法声明都在 JPLISAgent.h 类中可以看见


 1struct  _JPLISAgent; 2 3typedef struct _JPLISAgent        JPLISAgent; 4typedef struct _JPLISEnvironment  JPLISEnvironment; 5 6 7/* constants for class names and methods names and such 8    these all must stay in sync with Java code & interfaces 9*/10#define JPLIS_INSTRUMENTIMPL_CLASSNAME                      "sun/instrument/InstrumentationImpl"11#define JPLIS_INSTRUMENTIMPL_CONSTRUCTOR_METHODNAME         "<init>"12#define JPLIS_INSTRUMENTIMPL_CONSTRUCTOR_METHODSIGNATURE    "(JZZ)V"13#define JPLIS_INSTRUMENTIMPL_PREMAININVOKER_METHODNAME      "loadClassAndCallPremain"14#define JPLIS_INSTRUMENTIMPL_PREMAININVOKER_METHODSIGNATURE "(Ljava/lang/String;Ljava/lang/String;)V"15#define JPLIS_INSTRUMENTIMPL_AGENTMAININVOKER_METHODNAME      "loadClassAndCallAgentmain"16#define JPLIS_INSTRUMENTIMPL_AGENTMAININVOKER_METHODSIGNATURE "(Ljava/lang/String;Ljava/lang/String;)V"17#define JPLIS_INSTRUMENTIMPL_TRANSFORM_METHODNAME           "transform"18#define JPLIS_INSTRUMENTIMPL_TRANSFORM_METHODSIGNATURE      \19    "(Ljava/lang/ClassLoader;Ljava/lang/String;Ljava/lang/Class;Ljava/security/ProtectionDomain;[BZ)[B"
复制代码


对于启动时加载的 JavaAgent 涉及到的 c 代码主要为:


其中涉及到的 c 代码在/src/share/instrument 目录下的 JPLISAgent.c,PLISAgent.h,InvocationAdapter.c。


涉及到的 java 代码为 sun.instrument.InstrumentationImpl。

4 字节码工具

上面我们说过,javaAgent 提供了一些接口可以让我们在某些特定的时机进行对于 java 字节码的操作,在不改变代码的前提下,修改最终载入内存的字节码。但是由于直接操作字节码需要对 java 的字节码底层有较深入的研究。所以一些能帮助我们不需要了解字节码底层也能修改字节码的工具就诞生了。其中较为流行的字节码操作工具有 byteBuddy 和 javassist 等等。

4.1 Javassist 冲突

使用了两种 JavaAgent(一种 JavaAgent 是基于 ByteBuddy,另外一种 JavaAgent 是基于 Javassist)同时去增强同一个类的同一个方法:


  • 当两个 JavaAgent 的载入顺序为:Javassist-ByteBuddy,这两个 JavaAgent 对于同一个类的同一个方法的增强都生效了

  • 当两个 JavaAgent 的载入顺序为:ByteBuddy-Javassist,Javassist 对应的增强生效了,而 ByteBuddy 对应的增强却没生效

  • 当使用三个 JavaAgent(两个 Javassist,一个 ByteBuddy)的载入顺序为:Javassist-ByteBuddy-Javassist,两个 Javassist 的增强都生效了,而 ByteBuddy 对应的增强却没生效

4.2 冲突根因

针对上面结果的不同,我们来开始分析不同的 JavaAgent 的实现在不同加载顺序下为什么会造成如此大的差异呢?


使用 Javassist 创建 JavaAgent


 1ClassPool classPool = ClassPool.getDefault(); 2CtClass ctClass = classPool.getCtClass(className); 3CtMethod[] ctMethods = ctClass.getDeclaredMethods(); 4for (int i = 0; i < ctMethods.length; i++) { 5     CtMethod ctMethod = ctMethods[i]; 6     if (!ctMethod.getName().equalsIgnoreCase("main")) { 7          continue 8     } 9    ctMethod.insertBefore("System.out.println(\"这是第2个 javassist Agent Before~~~~~\");");10    ctMethod.insertAfter("System.out.println(\"这是第2个 javassist Agent After~~~~~\");");11}12return ctClass.toBytecode();
复制代码


其中对于 Javassist 是使用 ClassPool 对象来存储对应的 class 对象,对于默认的 defaultPool 是 static 属性,故如果有多个基于 Javassist 实现的 JavaAgent,那么它们使用的 ClassPool 是同一个对象。


当使用 classPool.getCtClass(className)方法获取一个 CtClass 对象,其中 Javassist 都做了哪些事情呢?


让我们进去看看


 1 protected synchronized CtClass get0(String classname, boolean useCache) throws NotFoundException { 2        CtClass clazz = null; 3        if (useCache) { 4            clazz = this.getCached(classname); 5            if (clazz != null) { 6                return clazz; 7            } 8        } 910        if (!this.childFirstLookup && this.parent != null) {11            clazz = this.parent.get0(classname, useCache);12            if (clazz != null) {13                return clazz;14            }15        }1617        clazz = this.createCtClass(classname, useCache);18        if (clazz != null) {19            if (useCache) {20                this.cacheCtClass(clazz.getName(), clazz, false);21            }2223            return clazz;24        } else {25            if (this.childFirstLookup && this.parent != null) {26                clazz = this.parent.get0(classname, useCache);27            }2829            return clazz;30        }31    }
复制代码


由上面源码可以当 ClassPool 的 Cache 中不存在对应的 class 的时候,javassist 会调用 this.createCtClass(classname, useCache)来实例化一个 CtClass 对象,那它是如何创建的呢?


 1 protected CtClass createCtClass(String classname, boolean useCache) { 2        if (classname.charAt(0) == '[') { 3            classname = Descriptor.toClassName(classname); 4        } 5 6        if (!classname.endsWith("[]")) { 7            return this.find(classname) == null ? null : new CtClassType(classname, this); 8        } else { 9            String base = classname.substring(0, classname.indexOf(91));10            return (!useCache || this.getCached(base) == null) && this.find(base) == null ? null : new CtArray(classname, this);11        }12    }
复制代码


可以看出,对于此次,javassist 仅仅只是使用 className 用实例化了个 CtClassType 对象。


当获取了 CtClass 对象之后,我们想知道其中都有哪些方法,接下来使用 ctClass.getDeclaredMethods()来获取其中的方法,我们看看他是如何获取的呢?


getDeclaredMethods()–>getMembers()–>makeBehaviorCache(cache)–>getClassFile2()–>openClassfile(classname)


 1 public InputStream openClassfile(String classname) { 2        try { 3            char sep = File.separatorChar; 4            String filename = this.directory + sep + classname.replace('.', sep) + ".class"; 5            return new FileInputStream(filename.toString()); 6        } catch (FileNotFoundException var4) { 7            ; 8        } catch (SecurityException var5) { 9            ;10        }1112        return null;13    }
复制代码


可以看出 javassist 获取到对应类的 class 文件,并以文件流的形式读入以获取到其 class 中所有的方法和属性并缓存在 CtClassType 对象之中(由此,我们可以明确一个问题,第一个 javassist 对于字节码的修改都是基于对应类的源字节码文件)


当 javasist 使用了 ctMethod.insertBefore 方法对某方法进行增强的之后,会刷新其对应 ClassPool 之中缓存的 CtClassType 的属性。


当第二个基于 Javassist 实现的 JavaAgent 对相同的类进行增强的时候,其也会去 ClassPool 中获取 CtClass 对象,而这个 CtClass 恰恰就是上一个 JavaAgent 对原始字节码增强之后刷新的结果,这样可以说明第二个 JavaAgent 在增强的时候使用的字节码的源文件跟第一个 JavaAgent 使用的字节码的源文件的获取方式不同。


这时候,问题已经有一点眉目了。


我们可以明确这么几个事情:


1)非 Javassist 的字节码工具中一定中不存在与 Javassist 相同的 ClassPool 对象


2)两个基于 Javassist 产生的 JavaAgent 增强的字节码的获取来源不同


这时候,就需要上 Instrument 的源码了!


下面代码对应于 jdk8 的 openJdk 源码的/src/share/instrumentJPLISAgent.c


 1void 2transformClassFile(             JPLISAgent *            agent, 3                                JNIEnv *                jnienv, 4                                jobject                 loaderObject, 5                                const char*             name, 6                                jclass                  classBeingRedefined, 7                                jobject                 protectionDomain, 8                                jint                    class_data_len, 9                                const unsigned char*    class_data,10                                jint*                   new_class_data_len,11                                unsigned char**         new_class_data,12                                jboolean                is_retransformer) {13    jboolean        errorOutstanding        = JNI_FALSE;14    jstring         classNameStringObject   = NULL;15    jarray          classFileBufferObject   = NULL;16    jarray          transformedBufferObject = NULL;17    jsize           transformedBufferSize   = 0;18    unsigned char * resultBuffer            = NULL;19    jboolean        shouldRun               = JNI_FALSE;2021    /* only do this if we aren't already in the middle of processing a class on this thread */22    shouldRun = tryToAcquireReentrancyToken(23                                jvmti(agent),24                                NULL);  /* this thread */2526    if ( shouldRun ) {27       .................2829        /*  now call the JPL agents to do the transforming */30        /*  potential future optimization: may want to skip this if there are none */31        if ( !errorOutstanding ) {32            jplis_assert(agent->mInstrumentationImpl != NULL);33            jplis_assert(agent->mTransform != NULL);34            transformedBufferObject = (*jnienv)->CallObjectMethod(35                                                jnienv,36                                                agent->mInstrumentationImpl,37                                                agent->mTransform,38                                                loaderObject,39                                                classNameStringObject,40                                                classBeingRedefined,41                                                protectionDomain,42                                                classFileBufferObject,43                                                is_retransformer);44            errorOutstanding = checkForAndClearThrowable(jnienv);45            jplis_assert_msg(!errorOutstanding, "transform method call failed");46        }47        ..................48        ..........49    }50    return;51}
复制代码


我们可以看到 CallObjectMethod()方法的后六个入参,这六个入参直接对应于 sun.instrument.InstrumentationImpl.byte[] transform(ClassLoader var1, String var2, Class<?> var3, ProtectionDomain var4, byte[] var5, boolean var6),其中 byte[] var5 对应于上面的 classFileBufferObject,classFileBufferObject 对象是对应类的字节码数据,方法 transform 的返回值是对应类改变之后的字节码数据,而改变之后的值也会将 classFileBufferObject 覆盖,然后进行下一个 JavaAgent 的增强或则进行载入,链接和初始化。


看完 Instrument 的源码,我们回忆一下 Javassist 的增强过程:Javassist 使用的是对应类的 class 文件进行读入并进行增强。


如果某一时刻,Javassist 的 JavaAgent 接收到的某个类的字节码数据(原始字节码已经被其他非 Javassist 的方式增强之后的字节码)和此类对应的 class 文件的数据不一致,这时候 Javassist 就会废弃掉接收到的已经被改变的字节码数据,转而使用此类最原始的 class 文件进行增强。这种情况就是先使用 ByteBuddy,后使用 Javassist 的情况。


当两个 JavaAgent 的载入顺序为:Javassist-ByteBuddy,这时候 Javassist 将此类进行增强之后,新的字节码数据传递给 ByteBuddy,ByteBuddy 会基于最新的字节码数据进行新的增强,这时候两个 JavaAgent 都可以正常增强。


当使用三个 JavaAgent(两个 Javassist,一个 ByteBuddy)的载入顺序为:Javassist-ByteBuddy-Javassist


第一个 Javassist 读取对应类的 class 文件并存储在 ClassPool 之中,并进行增强并刷新,ByteBuddy 获取到最新的字节码数据,也进行增强,第二个 Javassist 获取到第一个 Javassist 和 ByteBuddy 增强的数据之后,舍弃掉了,转而使用 ClassPool 只会中的数据,因为 ClassPool 之中的数据是第一个 Javassist 增强之后的字节码;所以结果之中保留着第一个 Javassist 的改动,但是对于 ByteBuddy 的改动就废弃掉了。


由上面的分析我们可以明确出两种 JavaAgent 顺序不同所导致的结果不同是由于 javassist 的内部实现机制所导致的,但是还是有个疑问?那么对于 byteBuddy 的 agent,它操作的字节码数据是从哪里来的呢?


下面展示的方法是 ByteBuddy 的 Agent 之中对于 transform 方法的实现,binaryRepresentation 此参数就是针对于当前类最新的字节码数据,ByteBuddy 使用其生成了一个 ClassFileLocator 对象,然后经查看 ClassFileLocator 类的注释,可以得知,ByteBuddy 则是使用最新的字节码数据进行自己其他的增强操作,这刚好也能印证了上面的结果。


以下代码对应的是


net.bytebuddy.agent.builder.AgentBuilder.java


 1 private byte[] transform(JavaModule module, 2                          ClassLoader classLoader, 3                          String internalTypeName, 4                          Class<?> classBeingRedefined, 5                          ProtectionDomain protectionDomain, 6                          byte[] binaryRepresentation) { 7     if (internalTypeName == null || !lambdaInstrumentationStrategy.isInstrumented(classBeingRedefined)) { 8         return NO_TRANSFORMATION; 9     }10     String typeName = internalTypeName.replace('/', '.');11     try {12         ClassFileLocator classFileLocator = ClassFileLocator.Simple.of(typeName,13                 binaryRepresentation,14                 locationStrategy.classFileLocator(classLoader, module));15         ......16         ......17     } catch (Throwable throwable) {18         listener.onError(typeName, classLoader, module, throwable);19         return NO_TRANSFORMATION;20     } finally {21         listener.onComplete(typeName, classLoader, module);22     }1/**2 * Locates a class file or its byte array representation when it is given its type description.3 */4public interface ClassFileLocator extends Closeable {
复制代码

4.3 冲突结论

根据上述现象与原理的分析,我们可以得出:


如果同时使用基于 Javassist 和基于其他字节码工具的 JavaAgent 去增强同一个类,Javassist 的加载顺序一定要在其他字节码的 JavaAgent 之前,这样才能保证两个字节码工具都可以进行完整的增强。如果基于 Javassist 的 JavaAgent 最后增强,那么之前的非 Javassist 的 JavaAgent 对于字节码的增强都会被丢弃掉,这也能会带来不小的麻烦。


作者介绍:


作者跳跳虎(企业代号名),目前负责贝壳找房 JAVA 服务端研发工作。


本文转载自公众号贝壳产品技术(ID:gh_9afeb423f390)。


原文链接:


https://mp.weixin.qq.com/s/3hXyFCgclsuoznNQ2ulC4g


2019-09-24 15:429280

评论 2 条评论

发布
用户头像
比如:Java代码覆盖率工具Jacoco的on-the-fly模式基于javaagent实现,和基于Javassist的mock框架powermock的冲突,在运行JUnit单元测试时导致Jacoco无法统计到代码覆盖率。
2021-02-06 23:39
回复
用户头像
MAINIFEST.MF 拼错啦,害我弄了半天。正确的是MANIFEST.MF
2020-08-13 12:01
回复
没有更多了
发现更多内容

如何保证高可用?搞定三种集群模式,Redis还不是手到擒来

Java 程序员 后端

模块二作业

doublechun

「架构实战营」

如何理解互斥锁、条件锁、读写锁以及自旋锁(1),mysql入门到精通电子书

Java 程序员 后端

天才第一步!Java架构速成笔记必备精品成就年薪百万,掌门一对一java面试流程

Java 程序员 后端

太为难我了,阿里面试了7轮(5年经验,java百度图像识别接口

Java 程序员 后端

如何在今年难找工作的大环境下成功入职阿里?Java架构师面试高频300题:集合

Java 程序员 后端

如何让自己像打王者荣耀一样发了疯、拼了命、石乐志的学习

Java 程序员 后端

如何设计一个百万级用户的抽奖系统?,三面蚂蚁核心金融部

Java 程序员 后端

如何阅读一本书-读书笔记,java二到三年经验面试题

Java 程序员 后端

太难了,救救孩子吧,到现在还搞不懂TCP的三次握手四次挥手

Java 程序员 后端

如何使用Spring Cloud Consul的其他配置和发现功能,不会来学

Java 程序员 后端

如何正确使用Spring Cloud Zookeeper,不懂来学,java教程下载网盘

Java 程序员 后端

太难了,面试官不讲武德!来骗来偷袭,分层架构图案例

Java 程序员 后端

如何在Spring Boot应用中优雅的使用Date和LocalDateTime

Java 程序员 后端

太狠了!阿里技术官熬夜半年肝出来的Spring Boot巅峰之作,爱了

Java 程序员 后端

字节跳动Java开放岗面经:14天快速面试,已拿offer,Java全套百度云

Java 程序员 后端

如何保证Redis与数据库的双写一致性?,十分钟带你看懂Netty如何实现C-S

Java 程序员 后端

如果当时这15道题能答好,现在应该已经被录取了(记一次面试的亲身经历 2020-7-20

Java 程序员 后端

太牛了,Github上标星30K+的SpringBoot实战电商项目mall

Java 程序员 后端

架构实战营 - 毕业总结

雪中亮

架构实战营 #架构实战营

太厉害了,终于有人把TCP-IP协议整合成864页学习笔记了

Java 程序员 后端

太强了!阿里大神亲码“SpringCloud核心手册,2021Java常见笔试题

Java 程序员 后端

太狠了,Spring全家桶笔记,一站式通关全攻略,已入职某厂涨薪18K

Java 程序员 后端

如何快速成长为技术大牛?阿里资深技术专家的总结亮了!

Java 程序员 后端

天真,居然还有人认为java的参数传递方式是引用传递,互联网java工程师面试突击训练网盘

Java 程序员 后端

太全了吧!阿里面试官纯手打:金三银四跳槽必会Java核心知识点笔记整理

Java 程序员 后端

如何在分布式系统中正确的使用缓存?别给你的项目引入定时炸弹!

Java 程序员 后端

如何让阿三 Windows 10、11 的恢复分区(Recovery Partition

Java 程序员 后端

如果当时这16道题能答好,现在应该已经被录取了(记一次面试的亲身经历 2020-9-9

Java 程序员 后端

天真,居然还有人认为java的参数传递方式是引用传递(1)

Java 程序员 后端

头条「2020最新」Spring最易学习教程,百度java面试经验

Java 程序员 后端

JavaAgent原理与实践_文化 & 方法_跳跳虎_InfoQ精选文章