写点什么

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

  • 2010-02-21
  • 本文字数:8876 字

    阅读完需:约 29 分钟

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

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

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

类加载地点转换

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

复制代码
ClassLoader appLoader = Thread.currentThread().<wbr></wbr>getContextClassLoader();
Class appClass = appLoader.loadClass("com.acme.<wbr></wbr>devices.SinisterEngine");
...
ClassLoader appLoader = ...
Class appClass = appLoader.loadClass("com.acme.<wbr></wbr>devices.SinisterEngine");

替换为:

复制代码
Bundle appBundle = ...
Class appClass = appBundle.loadClass("com.acme.<wbr></wbr>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().<wbr></wbr>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(<wbr></wbr>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);
}

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

复制代码
...
/* 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 Guice ImportProxyClassLoader.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 中文站用户讨论组中与我们的编辑和其他读者朋友交流。

2010-02-21 22:589290
用户头像

发布了 47 篇内容, 共 10.7 次阅读, 收获喜欢 3 次。

关注

评论

发布
暂无评论
发现更多内容

用例图

Eva

第四周 开启新的篇章,打磨产品的最强辅助——文档

小匚

极客时间 产品经理 产品经理训练营

Qcon现代数据架构-《万亿级数据库MongoDB集群性能数十倍提升优化实践》核心17问详细解答

杨亚洲(专注MongoDB及高性能中间件)

MySQL 数据库 mongodb 分布式 分布式数据库mongodb

羚羊行走在悬崖边:一份报告背后的移动开发者“自救计划”

脑极体

红信圈系统开发,红信圈APP开发

luluhulian

产品0期 - 第四周作业 - 附件1

曾烧麦

产品训练营

作业 - 第四周

eva

在游戏运营行业,Serverless 如何解决数据采集分析痛点?

阿里巴巴云原生

Serverless 运维 云原生 关系型数据库 消息中间件

产品经理训练营作业 03

KingSwim

交易所搭建

v16629866266

交易所开发

技术文档丨循迹搭建--车辆集成

百度开发者中心

我认为的互联网医疗场景用户及场景

卢嘉敏

需求 医疗 用户

第四次作业

Geek_79e983

流媒体传输协议之 RTP (上篇)

阿里云CloudImagine

音视频 流媒体 rtp

关于产品文档与原型的思考

作业 - 第四章 业务流程与产品文档 (一)

hao hao

妹妹10分钟就玩懂了零拷贝和NIO,也太强了

moon聊技术

Java nio 零拷贝

【新春特辑】发压岁钱、看贺岁片、AI写春联……华为云社区给大家拜年了

华为云开发者联盟

华为云

Vue开发中可以使用的ES6新特征

devpoint

Vue ES6

极客时间APP购买课程模块用例文档

夏天的风

用例图

【百度官方技术分享】中间件技术在百度云原生测试中的应用实践

百度Geek说

产品 架构 测试 中间件 技术宅

WEEK4作业

Geek_6a8931

一个只会写Bug的Coder年终总结

z小赵

程序员 互联网 职场成长

产品0期 - 第四周作业

曾烧麦

产品训练营

容器 & 服务:Jenkins构建实例

程序员架构进阶

容器 持续集成 七日更 28天写作 2月春节不断更

2021年人工智能数据采集标注行业四大趋势预测;清华提出深度对齐聚类用于新意图发现

京东科技开发者

人工智能 数字货币

【STM32】GPIO输入—按键检测

AXYZdong

硬件 stm32 2月春节不断更

京东科技集团21篇论文高票入选国际顶会AAAI 2021

京东科技开发者

机器学习 AI

惊呆,一条sql竟然让oracle奔溃了

君哥聊技术

oracle mybatis 批量操作

helm入门教学

三丰SanFeng

Kubernetes k8s Helm

话题讨论 | 你选择去一线城市还是老家的省会城市?

石云升

话题讨论 职业发展 2月春节不断更

类加载器特技:OSGi代码生成_Java_Todor Boev_InfoQ精选文章