BT

如何利用碎片时间提升技术认知与能力? 点击获取答案

类加载器特技:OSGi代码生成

| 作者 Todor Boev 关注 0 他的粉丝 ,译者 曹云飞 关注 0 他的粉丝 发布于 2010年2月23日. 估计阅读时间: 37 分钟 | GMTC大前端的下一站,PWA、Web框架、Node等最新最热的大前端话题邀你一起共同探讨。

把大型系统移植到OSGi架构上常常意味着解决复杂的类加载问题。这篇文章专门研究了面向这个领域最难问题的几个框架:有关动态代码生成的框架。这些框架也都是些超酷的框架:AOP包装器,ORM映射器以及服务代理生成器,这些仅仅是一些例子。

我们将按照复杂性增加的顺序考察一些类加载的典型问题,开发一小段代码来解决这些问题中最有趣的一个。即使你不打算马上写一个代码生成框架,这篇文章也会让你对静态定义依赖的模块运行时(如OSGi系统)的低级操作有比较深入的了解。

这篇文章还包括一个可以工作的演示项目,该项目不仅包含这里演示的代码,还有两个基于ASM的代码生成器可供实践。

类加载地点转换

把一个框架移植到OSGi系统通常需要把框架按照extender模式重构。这个模式允许框架把所有的类加载工作委托给OSGi框架,与此同时保留对应用代码的生命周期的控制。转换的目标是使用应用bundle的类加载来代替传统的繁复的类加载策略。例如我们希望这样替换代码:

ClassLoader appLoader = Thread.currentThread().getContextClassLoader();
Class appClass = appLoader.loadClass("com.acme.devices.SinisterEngine");
...
ClassLoader appLoader = ...
Class appClass = appLoader.loadClass("com.acme.devices.SinisterEngine");

替换为:

Bundle appBundle = ...
Class appClass = appBundle.loadClass("com.acme.devices.SinisterEngine");

尽管我们必须做大量的工作以便OSGi为我们加载应用代码,我们至少有一种优美而正确的方式来完成我们的工作,而且会比以前工作得更好!现在用户可以通过向OSGi容器安装/卸载bundle而增加/删除应用。用户还可以把他们的应用分解为多个bundle,在应用之间共享库并利用模块化的其他能力。

由于上下文类加载器是目前框架加载应用代码的标准方式,我们在此对其多说两句。当前OSGi没有定义设置上下文类加载器的策略。当一个框架依赖于上下文类加载器时,开发者需要预先知道这点,在每次调用进入那个框架时手工设置上下文类加载器。由于这样做易于出错而其不方便,所以在OSGi下几乎不使用上下文类加载器。在定义OSGi容器如何自动管理上下文类加载器方面,目前有些人正在进行尝试。但在一个官方的标准出现之前,最好把类加载转移到一个具体的应用bundle。

适配器类加载器

有时候我们转换的代码有外部化的类加载策略。这意味着框架的类和方法接收明确的ClassLoader 参数,允许我们来决定他们从哪里加载应用代码。在这种情况下,把系统转换到OSGi就仅仅是让Bundle对象适配ClassLoader API的问题。这是一个经典的适配器模式的应用。

public class BundleClassLoader extends ClassLoader {
  private final Bundle delegate;
 
  public BundleClassLoader(Bundle delegate) {
    this.delegate = delegate;
  }
 
  @Override
  public Class<?> loadClass(String name) throws ClassNotFoundException {
    return delegate.loadClass(name);
  }
}

现在我们可以把这个适配器传给转换的框架代码。随着新bundle的增减,我们还可以增加bundle跟踪代码来创建新的适配器 —— 例如,我们可以“在外部”把一个Java框架适配到OSGi,避免浏览该框架的代码库以及变换每个单独的类加载场所。下面是将一个框架转换到使用OSGi类加载的示意性的例子:

...
Bundle app = ...
BundleClassLoader appLoader = new BundleClassLoader(app);

DeviceSimulationFramework simfw = ...
simfw.simulate("com.acme.devices.SinisterEngine", appLoader);
...

桥接类加载器

许多有趣的Java框架的客户端代码在运行时做了很多花哨的类处理工作。其目的通常是在应用的类空间中构造本不存在的类。让我们把这些生成的类称作增强(enhancement)。通常,增强类实现了一些应用可见的接口或者继承自一个应用可见的类。有时,一些附加的接口及其实现也可以被混入。

增强类扩充了应用代码 - 应用可以直接调用生成的对象。例如,一个传递给应用代码的服务代理对象就是这种增强类对象,它使得应用代码不必去跟踪一个动态服务。简单的说,增加了一些AOP特征的包装器被作为原始对象的替代品传递给应用代码。

增强类的生命始于字节数组byte[],由你喜爱的类工程库(ASM,BCEL,CGLIB)产生。一旦我们生成了我们的类,必须把这些原始的字节转换为一个Class对象,换言之,我们必须让某个类加载器对我们的字节调用它的defineClass()方法。我们有三个独立的问题要解决:

  • 类空间完整性 - 首先我们必须决定可以定义我们增强类的类空间。该类空间必须“看到”足够多的类以便让增强类能够被完全链接。
  • 可见性 - ClassLoader.defineClass()是一个受保护的方法。我们必须找到一个好方法来调用它。
  • 类空间一致性 - 增强类从框架和应用bundle混入类,这种加载类的方式对于OSGi容器来说是“不可见的”。作为结果,增强类可能被暴露给相同类的不兼容的版本。

类空间完整性

增强类的背后支持代码对于生成它们的Java框架来说是未公开的 - 这意味着该框架应该会把该新类加入到其类空间。另一方面,增强类实现的接口或者扩展的类在应用的类空间是可见,这意味着我们应该在这里定义增强类。我们不能同时在两个类空间定义一个类,所以我们有个麻烦。

因为没有类空间能够看到所有需要的类,我们别无选择,只能创建一个新的类空间。一个类空间等同于一个类加载器实例,所以我们的第一个工作就是在所有的应用bundle之上维持一个专有的类加载器。这些叫做桥接类加载器,因为他们通过链接加载器合并了两个类空间:

public class BridgeClassLoader extends ClassLoader {
  private final ClassLoader secondary;
 
  public BridgeClassLoader(ClassLoader primary, ClassLoader secondary) {
    super(primary);
  }
 
  @Override
  protected Class<?> findClass(String name) throws ClassNotFoundException {
    return secondary.loadClass(name);
  }
}

现在我们可以使用前面开发的BundleClassLoader:

  /* Application space */
  Bundle app = ...
  ClassLoader appSpace = new BundleClassLoader(app);
 
  /*
   * Framework space
   *
   * We assume this code is executed inside the framework
   */
  ClassLoader fwSpace = this.getClass().getClassLoader();
 
  /* Bridge */
  ClassLoader bridge = new BridgeClassLoader(appSpace, fwSpace);

这个加载器首先从应用空间为请求提供服务 - 如果失败,它将尝试框架空间。请注意我们仍然让OSGi为我们做很多重量工作。当我们委托给任何一个类空间时,我们实际上委托给了一个基于OSGi的类加载器 - 本质上,primary和secondary加载器可以根据他们各自bundle的导入/导出(import/export)元数据将加载工作委托给其他bundle加载器。

此刻我们也许会对自己满意。然而,真相是苦涩的,合并的框架和应用类空间也许并不够!这一切的关键是JVM链接类(也叫解析类)的特殊方式。对于JVM链接类的工作有很多解释:

简短的回答:JVM以一种细粒度(一次一个符号)的方式来做解析工作的

冗长的回答:当JVM链接一个类时,它不需要被连接类的所有引用类的完整的描述。它只需要被链接类真正使用的个别的方法、字段和类型的信息。我们直觉上认为对于JVM来说,其全部是一个类名称,加上一个超类,加上一套实现的接口,加上一套方法签名,加上一套字段签名。所有这些符号是被独立且延迟解析的。例如,要链接一个方法调用,调用者的类空间只需要给类对象提供目标类和方法签名中用到的所有类型。目标类中的其他许多定义是不需要的,调用者的类加载器永远不会收到加载它们(那些不需要的定义)的请求。

正式的答案:类空间SpaceA的类A必须被类空间SpaceB的相同类对象所代表,当且仅当:

  • SpaceB存在一个类B,在它的符号表(也叫做常量池)中引用着A。
  • OSGi容器已经将SpaceA作为类A的提供者(provider)提供给SpaceB。该联系是建立在容器所有bundle的静态元数据之上的。

例如:假设我们有一个bundle BndA导出一个类A。类A有3个方法,分布于3个接口中:

  • IX.methodX(String)
  • IY.methodY(String)
  • IZ.methodZ(String)

还假设我们有一个bundle BndB,其有一个类B。类B中有一个引用 A a = ……和一个方法调用a.methodY("Hello!")。为了能够解析类B,我们需要为BndB的类空间引入类A和类String。这就够了!我们不需要导入IX或者IZ。我们甚至不需要导入IY,因为类B没有用IY - 它只用了A。在另一方面,bundle BndA导出时会解析类A,必须提供IX,IY,IZ,因为他们作为被实现的接口直接被引用。最终,BndA也不需要提供IX,IY,IZ的任何父接口,因为他们也没有被直接引用。

现在假设我们希望给类空间BndB的类B呈现类空间BndA的类A的一个增强版本。该增强类需要继承类A并覆盖它的一些或全部方法。因此,该增强类需要看到在所有覆盖的方法签名中使用的类。然而,BndB仅当调用了所有被覆盖的方法时才会导入所有这些类。BndB恰好调用了我们的增强覆盖的所有的A的方法的可能性非常小。因此,BndB很可能在他的类空间中不会看到足够的类来定义增强类。实际上完整的类集合只有BndA能够提供。我们有麻烦了!

结果是我们必须桥接的不是框架和应用空间,而是框架空间和增强类的空间 - 所以,我们必须把策略从“每个应用空间一个桥”变为“每个增强类空间一个桥”。我们需要从应用空间到一些第三方bundle的类空间做过渡跳接,在那里,应用导入其想让我们增强的类。但是我们如何做过渡跳接呢?很简单!如我们所知,每个类对象可以告诉我们它第一次被定义的类空间是什么。例如,我们要得到A的类加载器,所需要做的就是调用A.class.getClassLoader()。在很多情况下我们没有一个类对象,只有类的名字,那么我们如何从一开始就得到A.class?也很简单!我们可以让应用bundle给我们它所看到的名称“A”对应的类对象。然后我们就可以桥接那个类的空间与框架的空间。这是很关键的一步,因为我们需要增强类和原始类在应用内是可以互换的。在类A可能的许多版本中,我们需要挑选被应用所使用的那个类的类空间。下面是框架如何保持类加载器桥缓存的示意性例子:

...
/* Ask the app to resolve the target class */
Bundle app = ...
Class target = app.loadClass("com.acme.devices.SinisterEngine");
 
/* Get the defining classloader of the target */
ClassLoader targetSpace = target.getClassLoader();
 
/* Get the bridge for the class space of the target */
BridgeClassLoaderCache cache = ...
ClassLoader bridge = cache.resolveBridge(targetSpace);

桥缓存看起来会是这样:

public class BridgeClassLoaderCache {
  private final ClassLoader primary;
  private final Map<ClassLoader, WeakReference<ClassLoader>> cache;
 
  public BridgeClassLoaderCache(ClassLoader primary) {
    this.primary = primary;
    this.cache = new WeakHashMap<ClassLoader, WeakReference<ClassLoader>>();
  }
 
  public synchronized ClassLoader resolveBridge(ClassLoader secondary) {
    ClassLoader bridge = null;
 
    WeakReference<ClassLoader> ref = cache.get(secondary);
    if (ref != null) {
      bridge = ref.get();
    }
 
    if (bridge == null) {
      bridge = new BridgeClassLoader(primary, secondary);
      cache.put(secondary, new WeakReference<ClassLoader>(bridge));
    }
 
    return bridge;
  }
}

为了防止保留类加载器带来的内存泄露,我们必须使用弱键和弱值。目标是不在内存中保持一个已卸载的bundle的类空间。我们必须使用弱值,因为每个映射项目的值(BridgeClassLoader)都强引用着键(ClassLoader),于是以此方式否定它的“弱点”。这是WeakHashMap javadoc规定的标准建议。通过使用一个弱缓存我们避免了跟踪所有的bundle,而且不必对他们的生命周期做出反应。

可见性

好的,我们终于有了自己的外来的桥接类空间。现在我们如何在其中定义我们的增强类?如前所述问题,defineClass()是BridgeClassLoader的一个受保护的方法。我们可以用一个公有方法来覆盖它,但这是粗野的做法。如果做覆盖,我们还需要自己编码来检查所请求的增强类是否已经被定义。更好的办法是遵循类加载器设计的意图。该设计告诉我们应该覆盖findClass(),当findClass()认为它可以由任意二进制源提供所请求类时会调用defineClass()方法。在findClass()中我们只依赖所请求的类的名称来做决定。所以我们的BridgeClassLoade必须自己拿主意:

这是一个对“A$Enhanced”类的请求,所以我必须调用一个叫做"A"的类的增强类生成器!然后我在生成的字节数组上调用defineClass()方法。然后我返回一个新的类对象。

这段话中有两个值得注意的地方。

  • 我们为增强类的名称引入了一个文本协议 - 我们可以给我们的类加载器传入数据的单独一项 - 所请求的类的名称的字符串。同时我们需要传入数据中的两项 - 原始类的名称和一个标志,将其(原始类)标志为增强类的主语。我们将这两项打包为一个字符串,形式为[目标类的名称]"$Enhanced"。现在findClass()可以寻找增强类的标志$Enhanced,如果存在,则提取出目标类的名称。这样我们引入了我们增强类的命名约定。无论何时,当我们在堆栈中看到一个类名以$Enhanced结尾,我们知道这是一个动态生成的类。为了减少与正常类名称冲突的风险,我们将增强类标志做得尽可能特殊(例如:$__service_proxy__)
  • 增强是按需生成的 - 我们永远不会把一个增强类生成两次。我们继承的loadClass()方法首先会调用findLoadedClass(),如果失败会调用parent.loadClass(),只有失败的时候它才会调用 findClass()。由于我们为名称用了一个严格的协议,保证findLoadedClass()在第二次请求相同类的增强类时候不会失败。这与桥接类加载器缓存相结合,我们得到了一个非常有效的方案,我们不会桥接同样的bundle空间两次,或者生产冗余的增强类。

这里我们必须强调通过反射调用defineClass()的选项。cglib使用这种方法。当我们希望用户给我们传递一个可用的类加载器时这是一种可行的方案。通过使用反射我们避免了在类加载器之上创建另一个类加载器的需要,只要调用它的defineClass()方法即可。

类空间一致性

到了最后,我们所做的是使用OSGi的模块层合并两个不同的、未关联的类空间。我们还引入了在这些空间中一种搜索顺序,其与邪恶的Java类路径搜索顺序相似。实际上,我们破坏了OSGi容器的类空间一致性。这里是糟糕的事情发生的一个场景:

  1. 框架使用包com.acme.devices,需要的是1.0版本。
  2. 应用使用包com.acme.devices,需要的是2.0版本。
  3. 类A直接饮用com.acme.devices.SinisterDevice
  4. A$Enhanced在他自己的实现中使用了com.acme.devices.SinisterDevice
  5. 因为我们搜索应用空间,首先A$Enhanced会被链接到com.acme.devices.SinisterDevice 2.0版,而他的内部代码是基于com.acme.devices.SinisterDevice 1.0编译的。

结果应用将会看到诡异的LinkageErrors或者ClassCastExceptions。不用说,这是个问题。

唉,自动处理这个问题的方式还不存在。我们必须简单的确保增强类的内部代码直接引用的是“非常私有的”类实现,不会被其他类使用。我们甚至可以为任何我们可能希望使用的外部API定义私有的适配器,然后在增强类代码中引用这些适配器。一旦我们有了一个良好定义的实现子空间,我们可以用这个知识来限制类泄露。现在我们仅仅向框架空间委托特殊的私有实现类的请求。这还会限定搜索顺序问题,使得应用优先搜索还是框架优先搜索对结果没有影响。让所有的事情都可控的一个好策略是有一个专有的包来包含所有增强类实现代码。那么桥接加载器就可以检查以那个包开头的类的名称并将它们委托给框架加载器做加载。最终,我们有时候可以对特定的单实例(singleton)包放宽这个隔离策略,例如org.osgi.framework - 我们可以安全的直接基于org.osgi.framework编译我们的增强类代码,因为在运行时所有在OSGi容器中的代码都会看到相同的org.osgi.framework - 这是由OSGi核心保证的。

把事情放到一起

所有关于这个类加载的传说可以被浓缩为下面的100行代码:

public class Enhancer {
  private final ClassLoader privateSpace;
  private final Namer namer;
  private final Generator generator;
  private final Map<ClassLoader , WeakReference<ClassLoader>> cache;

  public Enhancer(ClassLoader privateSpace, Namer namer, Generator generator) {
    this.privateSpace = privateSpace;
    this.namer = namer;
    this.generator = generator;
    this.cache = new WeakHashMap<ClassLoader , WeakReference<ClassLoader>>();
  }

  @SuppressWarnings("unchecked")
  public <T> Class<T> enhance(Class<T> target) throws ClassNotFoundException {
    ClassLoader context = resolveBridge(target.getClassLoader());
    String name = namer.map(target.getName());
    return (Class<T>) context.loadClass(name);
  }

  private synchronized ClassLoader resolveBridge(ClassLoader targetSpace) {
    ClassLoader bridge = null;

    WeakReference<ClassLoader> ref = cache.get(targetSpace);
    if (ref != null) {
      bridge = ref.get();
    }

    if (bridge == null) {
      bridge = makeBridge(targetSpace);
      cache.put(appSpace, new WeakReference<ClassLoader>(bridge));
    }

    return bridge;
  }

  private ClassLoader makeBridge(ClassLoader targetSpace) {
    /* Use the target space as a parent to be searched first */ 
    return new ClassLoader(targetSpace) {
      @Override
      protected Class<?> findClass(String name) throws ClassNotFoundException {
        /* Is this used privately by the enhancements? */
        if (generator.isInternal(name)) {
          return privateSpace.loadClass(name);
        }

        /* Is this a request for enhancement? */
        String unpacked = namer.unmap(name);
        if (unpacked != null) {
          byte[] raw = generator.generate(unpacked, name, this);
          return defineClass(name, raw, 0, raw.length);
        }

        /* Ask someone else */
        throw new ClassNotFoundException(name);
      }
    };
  }
}

public interface Namer {
  /** Map a target class name to an enhancement class name. */
  String map(String targetClassName);

  /** Try to extract a target class name or return null. */
  String unmap(String className);
}

public interface Generator {
  /** Test if this is a private implementation class. */
  boolean isInternal(String className);

  /** Generate enhancement bytes */
  byte[] generate(String inputClassName, String outputClassName, ClassLoader context);
}

Enhancer仅仅针对桥接模式。代码生成逻辑被具体化到一个可插拔的Generator中。该Generator接收一个上下文类加载器,从中可以得到类,使用反射来驱动代码生成。增强类名称的文本协议也可以通过Name接口插拔。这里是一个最终的示意性代码,展示这么一个增强类框架是如何使用的:

...
/* Setup the Enhancer on top of the framework class space */
ClassLoader privateSpace = getClass().getClassLoader();
Namer namer = ...;
Generator generator = ...;
Enhancer enhancer = new Enhancer(privateSpace, namer, generator);
...

/* Enhance some class the app sees */
Bundle app = ...
Class target = app.loadClass("com.acme.devices.SinisterEngine");
Class<SinisterDevice> enhanced = enhancer.enhance(target);
...

这里展示的Enhance框架不仅是伪代码。实际上,在撰写这篇文章期间,这个框架被真正构建出来并用两个在同一OSGi容器中同时运行的样例代码生成器进行了测试。结果是类加载正常,现在代码在Google Code上,所有人都可以拿下来研究。

对于类生成过程本身感兴趣的人可以研究这两个基于ASM的生成器样例。那些在service dynamics上阅读文章的人也许注意到proxy generator使用ServiceHolder代码作为一个私有实现。

结论

这里展现的类加载特技在许多OSGi之外的基础框架中使用。例如桥接类加载器被用在Guice,Peaberry中,Spring Dynamic Modules则用桥接类加载器来使他们的AOP包装器和服务代理得以工作。当我们听说Spring的伙计们在将Tomcat适配到OSGi方面做了大量工作时,我们可以推断他们还得做类加载位置转换或者更大量的重构来外化Tomcat的servlet加载。

感谢

这篇文章中的许多例子都是摘自Stuart McCulloch为Google Guice和Peaberry所写的出色代码。工业强度的类桥接的例子请看BytecodeGen.java from Google GuiceImportProxyClassLoader.java from Peaberry。在那里你会看到如何处理其他方面的问题,例如安全,系统类加载器,更好的延迟缓存和并发。谢谢Stuart!

作者还要感谢Peter Kriens的Classy Solutions to Tricky Proxies。希望在本文中的对JVM链接的解释对于Peter的工作有用。谢谢你Peter!

关于作者

Todor Boev作为ProSyst一名雇员已经在OSGi方面工作了8年。他热衷于将OSGi发展为JVM的一个通用编程环境。目前他既在专职研究这一领域,又是Peaberry项目的一名贡献者。他在rinswind.blogspot.com维护着一个博客。

查看英文原文Classloader Acrobatics: Code Generation with OSGi


感谢宋玮对本文的审校。

给InfoQ中文站投稿或者参与内容翻译工作,请邮件至editors@cn.infoq.com。也欢迎大家加入到InfoQ中文站用户讨论组中与我们的编辑和其他读者朋友交流。

评价本文

专业度
风格

您好,朋友!

您需要 注册一个InfoQ账号 或者 才能进行评论。在您完成注册后还需要进行一些设置。

获得来自InfoQ的更多体验。

告诉我们您的想法

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

OSGI增加了代码的复杂性! by guo jinfei

OSGI适合基础系统的构建,不适合旧系统的改造升级!否则,痛苦会接重而至!

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

1 讨论

登陆InfoQ,与你最关心的话题互动。


找回密码....

Follow

关注你最喜爱的话题和作者

快速浏览网站内你所感兴趣话题的精选内容。

Like

内容自由定制

选择想要阅读的主题和喜爱的作者定制自己的新闻源。

Notifications

获取更新

设置通知机制以获取内容更新对您而言是否重要

BT