写点什么

模块化 Java:动态模块化

  • 2010 年 1 月 28 日
  • 本文字数:8724 字

    阅读完需:约 29 分钟

本文是“模块化 Java”系列文章的第三篇,我们将讨论动态模块化。内容涉及如何解析 bundle 类、bundle 如何变化、以及 bundle 之间如何通信。

在前一篇文章《模块化Java:静态模块化》中,我们讨论了如何构建Java 模块并将其作为一个单独的JAR 进行部署。文中的例子给出了一个client 和一个server bundle(两者在同一个VM 中),client 通过工厂方法找到server。在该例子中,工厂实例化了一个已知类,当然也可以使用反射来获取一个服 务实现;Spring 就是大量运用这种技术把spring 对象绑定在一起的。

在我们讨论动态服务之前,有必要回顾一下类路径,因为标准Java 代码和模块化Java 代码的区别之一就是依赖在运行时是如何绑定的。在此之后,我们还将简单讨论一下类的垃圾回收;如果你对此已非常熟悉,则可以跳过这部分内容

Bundle ClassPath

对于一个普通 Java 程序,只有一个 classpath——启动应用程序所使用的那个。该路径通常是在命令行中用 -classpath 选项指定的,或者通 过 CLASSPATH 环境变量来设定。Java 类装载器在运行时解析类的时候会扫描此路径,无论这一过程是静态地(已编译进代码)还是动态地(使用反射及 class.forName())。然而,在运行时也可以使用多个类加载器;像 Jetty Tomcat 这样的 Web 应用引擎都是使用多个类加载器,以便支持应用热部署。

在 OSGi 中,每个 bundle 都有其自己的类加载器。需要被其他 bundle 访问的类则被委派(delegated)给这些其他 bundle 的类装载器。因此,尽管在传统应用中,来自 logging 类库、client 和 server JAR 中的类都是由同一个类加载器加载的,但在 OSGi 模块系统中,他们都是由自己的类加载器加载的。

结果是,一个 VM 中有可能有多个类加载器,其中可能存在名字相同的不同 Class 的对象。也就是说,在同一个 VM 中,一个叫做 com.infoq.example.App 的类,其不同版本可以由 com.infoq.example bundle 的第 1 版和第 2 版同时输出。Client bundle 版本 1 使用该类的第 1 版,而 client 版本 2 使用该类的第 2 版。这在模块化系统中相当普遍;在同一个 VM 中,有些代码可能需要装载一个类库 的老版本,同时更新点的代码(在另一个 bundle 中)却需要该类库的新版本。好在 OSGi 为你管理起这种依赖传递,确保不再出现不兼容类引发的问题。

类的垃圾回收

每个类都有一个对其类装载器的引用。因此如果想要从不同的 bundle 访问这些类,不但要有对该类实例的引用,而且还要有对该类的类装载器的引用。当一个 bundle 持有另一个 bundle 的类时,它也会将该 bundle固定在内存中。在前篇文章的例子中,client 被固定到该 server 上。

在静态世界里,无论你是否把自己的类固定到其他类(或类库)都无所谓;因为不会有什么变化。可是,在动态世界里,在运行时将类库或工具替换成新版本就有可 能了。这听起来可能有点复杂,但是在可热部署应用的 Web 应用引擎早期就出现了(如 Tomcat,最早发布于 1999 年)。每个 Web 应用程序都绑定到 Servlet API 的某个版本上,当其停止时,装载该 Web 应用的类加载器也就废弃掉了。当 Web 应用重新被部署时,又创建了一个新的类加载器,新版类就由其装载。只 要 servlet 引擎没有保持对老版应用的引用,这些类就像其他 Java 对象一样被垃圾回收器回收了。

并不是所有的类库都能意识到 Java 代码中可能存在类泄漏的问题,就像是内存泄漏。一个典型的例子就是 Log4J 的 addAppender() 调用,一旦其执行了,将会把你的类绑定在 Log4J bundle 的生命周期上。即使你的 bundle 停止了,Log4J 仍将维对 appender 的引用,并继续发送日志事件(除非该 bundle 在停止时恰当地调用了 removeAppender() 方法)。

查找和绑定

为了成为动态,我们需要有一个能查找服务的机制,而不是持久持有他们(以免 bundle 停止)。这是通过使用简单 Java 接口和 POJO 来实现的,也就是大家所熟知的services(注意他们与 WS-DeathStar 或其他任何 XML 底层架构都没有关系;他们就是普通 Java 对象——Plain Old Java Objects)。

典型工厂实现方式是使用从 properties 文件中获取的某种形式的类名,然后用 Class.forName() 来实例化相应的类,OSGi 则不同,它 维护了一个‘服务注册器’,其实这是一个包含了类名和服务的映射列表。这样,OSGi 系统就可以使用 context.getService(getServiceReference(“java.sql.Driver”)),而不是 class.forName(“com.example.JDBCDriver”) 来获取一个 JDBC 驱动器。这就把 client 代码解放出来了,它不需 知道任何特定客户端实现;相反,它可以在运行时绑定任何可用驱动程序。移植到不同的数据库服务器也就非常简单了,只需停止一个模块并启动一个新模 块;client 甚至不需要重新启动,也不需要改变任何配置。

这样做是因为 client 只需知道其所需的服务的 API(基本上都是接口,尽管 OSGi 规范允许使用其他类)。在上述情况中,接口名是 java.sql.Driver;返回的接口实例是具体的数据库实现(不必了解是哪些类,编码在那里)。此外,如果服务不可用(数据库不存在,或数据库临 时停掉了),那么这个方法会返回 null 以说明该服务不可用。

为了完全动态,返回结果不应被缓存。换句话说,每当需要服务的时候,需要重新调用 getService。框架会在底层执行缓存操作,因此不存在太大的性能 问题。但重要的是,它允许数据库服务在线被替换成新的服务,如果没有缓存代码,那么下次调用时,client 将透明地绑定到新服务上。

付诸实施

为了证明这一点,我们将创建一个用于缩写 URL 的 OSGi 服务。其思路是服务接收一个长 URL,如 http://www.infoq.com/articles/modular-java-what-is-it ,将其转换为短点的 URL,如 http://tr.im/EyH1 。该服务也可以被广泛应用在 Twitter 这样的站点上,还可以用它来把长 URL 转成短的这样便签背后也写得下。甚至像《新科学家》和《Macworld》这样的杂志也是用这些短 URL 来印刷媒体链接的。

为了实现该服务,我们需要:

  • 一个缩写服务的接口
  • 一个注册为缩写实现的 bundle
  • 一个验证用 client

尽管并没有禁止把这些东西都放在同一个 bundle 中,但是我们还是把他们分别放在不同的 bundle 里。(即便他们在一个 bundle 中,最好也让 bundle 通过服务来通讯,就好像他们处于不同的 bundle 一样;这样他们就可以方便地与其他服务提供者进行集成。

把缩写服务接口与其实现(或 client)分开放在单独 bundle 中是很重要的。该接口代表了 client 和 server 之间的‘共享代码’,这样,该 接口在每个 bundle 中都会加载。正因如此,每个 bundle 实际上都被固定到了该接口特定版本上,所有服务都有共同的生命周期,将接口放在单独 bundle 中(在整个 OSGi VM 生命周期中都在运行),我们的 client 就可以自由变化。如果我们把该接口放在某个服务实现的 bundle 中,那么该服务发生变化后我们就不能重新 连接到 client 上了。

shorten 接口的 manifest 和实现如下:

复制代码
Bundle-ManifestVersion: 2
Bundle-Name: Shorten
Bundle-SymbolicName: com.infoq.shorten
Bundle-Version: 1.0.0
Export-Package: com.infoq.shorten
---
package com.infoq.shorten;
public interface IShorten {
public String shorten(String url) throws IOException;
}

上面的例子建立了一个拥有单一接口(com.infoq.shorten.IShorten)的 bundle( com.infoq.shorten ),并将其输出给 client。参数是一个 URL,返回一个唯一的压缩版 URL。

和接口定义相比,实现就相对有趣一些了。尽管最近缩写名称的应用开始多起来了,但是所有这些应用的祖师爷都是 TinyURL.com 。(具有讽刺意味的是, http://tinyurl.com 实际上可以被压缩的更短 http://ow.ly/AvnC )。如今比较流行有: ow.ly bit.ly tr.im 等等。这里并不是对这些服务全面介绍,也不是为其背书,我们的实现也可以使用其他同类服务。本文之所以使用 TinyURL 和 Tr.im,是由于他们都可以匿名基于 GET 提交,易于实现,除此之外没有其他原因。

每种实现实际上都非常小;都以 URL 为参数(要缩写的东西)并返回新的压缩过的文本:

复制代码
package com.infoq.shorten.tinyurl;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.URL;
import com.infoq.shorten.IShorten;
public class TinyURL implements IShorten {
private static final String lookup =
"http://tinyurl.com/api-create.php?url=";
public String shorten(String url) throws IOException {
String line = new BufferedReader(
new InputStreamReader(
new URL(lookup + url).openStream())).readLine();
if(line == null)
throw new IllegalArgumentException(
"Could not shorten " + url);
return line;
}
}

Tr.im 的实现类似,只需用 http://api.tr.im/v1/trim_simple?url= 替代 lookup 的值即可。这两种实现的源代码分别在 com.infoq.shorten.tinyurl com.infoq.shorten.trim bundle 里。

那么,完成缩写服务的实现后,我们如何让其他程序访问它呢?为此,我们需要把实现注册为 OSGi 框架的服务。BundleContext 类的 registerService(class,instance,properties) 方法可以让我们定义一个服务以供后用,该方法通常在 bundle 的 start() 调用期间被调用。如上篇文章所讲,我们必须定义一个 BundleActivator。实现该类后,我们还要把 Bundle-Activator 放在 MANIFEST.MF 里以便找到该实现。代码如下:

复制代码
Manifest-Version: 1.0
Bundle-ManifestVersion: 2
Bundle-Name: TinyURL
Bundle-SymbolicName: com.infoq.shorten.tinyurl
Bundle-Version: 1.0.0
Import-Package: com.infoq.shorten,org.osgi.framework
Bundle-Activator: com.infoq.shorten.tinyurl.Activator
---
package com.infoq.shorten.tinyurl;
import org.osgi.framework.BundleActivator;
import org.osgi.framework.BundleContext;
import com.infoq.shorten.IShorten;
public class Activator implements BundleActivator {
public void start(BundleContext context) {
context.registerService(IShorten.class.getName(),
new TinyURL(),null);
}
public void stop(BundleContext context) {
}
}

尽管 registerService() 方法接收一个字符串作为其第一个参数,而且用"com.infoq.shorten.IShorten"也是可以的,但是最好还是用 _class_.class.getName() 这种形式,这样如果你重构了包或改变了类名,在编译时就可发现问题。如果用字符串,进行了错误的重构,那么只有在运行时你才能知道问题所在。

registerService() 的第二个参数是实例本身。之所以将其与第一个参数分开,是因为你可以将同一个服务实例输出给多个服务接口(如果需要带有版本的 API,这就有用了,你可以进化接口了)。另外,一个 bundle 输出同一类型的多个服务也是有可能的。

最后一个参数是服务属性(service properties)。允许你给服务加上额外元数据注解,比如标注优先级以表明该服务相对于其他服务的重要性,或者调用者关心的其他信息(比如功能描述和厂商)。

只要该 bundle 一启动,缩写服务就可用了。当 bundle 停止,框架将自动取消服务注册。如果我们想要自己取消注册(比方说,对错误代码和网络接口不可用所作出的响应)也很容易(用 context.unregisterService())。

使用服务

一旦服务起来并运行之后,我们就可以用 client 访问它了。如果运行的是 Equinox,你可以用 services 命令罗列所有已安装的服务,以及它们是由谁注册的:

复制代码
{com.infoq.shorten.IShorten}={service.id=27}
Registered by bundle: com.infoq.shorten.trim-1.0.0 [1]
No bundles using service.
{com.infoq.shorten.IShorten}={service.id=28}
Registered by bundle: com.infoq.shorten.tinyurl-1.0.0 [2]
No bundles using service.

在调用服务处理 URL 之前,client 需要解析服务。我们需要获得一个服务引用,它可以让我们查看服务自身内部的属性,然后利用其来获得我们感兴趣的服务。可是,我们需要能够重复处理相同及不同的 URL,以便我们可以把它集成到 Equinox 或 Felix 的 shell 里。实现如下:

复制代码
package com.infoq.shorten.command;
import org.osgi.framework.BundleContext;
import org.osgi.framework.ServiceReference;
import com.infoq.shorten.IShorten;
public class ShortenCommand {
protected BundleContext context;
public ShortenCommand(BundleContext context) {
this.context = context;
}
protected String shorten(String url) throws IllegalArgumentException, IOException {
ServiceReference ref =
context.getServiceReference(IShorten.class.getName());
if(ref == null)
return null;
IShorten shorten = (IShorten) context.getService(ref);
if(shorten == null)
return null;
return shorten.shorten(url);
}
}

当 shorten 方法被调用时,上面这段程序将查找服务引用并获得服务对象。然后我们可以把服务对象赋值给一个 IShorten 对象,并使用它与前面讲到 的已注册服务进行交互。注意这些都是在同一个 VM 中发生的;没有远程调用,没有强制异常,没有参数被序列化;只是一个 POJO 与另一个 POJO 对话。实际 上,这里与最开始 class.forName() 例子的唯一区别是:我们如何获得 shorten POJO。

为了在 Equinox 和 Felix 里面使用这一服务,我们需要放一些样板代码进去。必须提一下,当我们定义 manifest 时,我们可以在 Felix 和 Equinox 命令行上声明可选依赖,这样,当我们两者中任何一个安装之后,我们就可以运行了。(一个更好的解决方案是将其部署为单独的 bundles, 这样我们可以去掉选项;但是如果 bundle 不存在,activator 将会失败,因此无法启动)。Equinox 和 Felix 特定命令的源代码在 com.infoq.shorten.command bundle 中。

如果我们安装了命令 client bundle,我们将得到一个新命令,shorten,通过 OSGi shell 可以调用它。要运行该命令,需要先执行 java -jar equinox.jar -console -noExit 或 java -jar bin/felix.jar,然后安装 bundle,之后你就可以使用该命令了:

复制代码
java -jar org.eclipse.osgi_* -console -noExit
osgi> install <a href="com.infoq.shorten-1.0.0.jar">file:///tmp/com.infoq.shorten-1.0.0.jar</a>
Bundle id is 1
osgi> install <a href="com.infoq.shorten.command-1.0.0.jar">file:///tmp/com.infoq.shorten.command-1.0.0.jar</a>
Bundle id is 2
osgi> install <a href="com.infoq.shorten.tinyurl-1.0.0.jar">file:///tmp/com.infoq.shorten.tinyurl-1.0.0.jar</a>
Bundle id is 3
osgi> install <a href="com.infoq.shorten.trim-1.0.0.jar">file:///tmp/com.infoq.shorten.trim-1.0.0.jar</a>
Bundle id is 4
osgi> start 1 2 3 4
osgi> shorten <a href="http://www.infoq.com">http://www.infoq.com</a>
http://tinyurl.com/yr2jrn
osgi> stop 3
osgi> shorten <a href="http://www.infoq.com">http://www.infoq.com</a>
http://tr.im/Eza8

注意,在运行时 TinyURL 和 Tr.im 服务都是可用的,但是一次只能使用一种服务。可以设置一个服务级别(service ranking), 这是一个整数,取值范围在 Integer.MIN_VALUE 和 Integer.MAX_VALUE 之间,当服务最初注册时给 Constants.SERVICE_RANKING 赋予相应值。值越大表示级别越高,当需要服务时,会返回最高级别的服务。如果没有服务级别(默认值为 0),或者多个服务的服务级别相同,那么就使用自动分配的 Constants.SERVICE_PID,可能是任意顺序的一个服务。

另一个需注意的问题是:当我们停止一个服务时,client 会自动失败转移到列表中的下一个服务。每当该命令执行时,它会获取(当前)服务来处理 URL 压 缩需求。如果在运行期间服务提供程序发生了变化,不会影响命令的使用,只要有此需求时有服务在就成。(如果你停止了所有服务提供程序,服务查找将返回 null,这将会打印出相应的错误信息——好的代码应该确保程序能够预防返回服务引用为 null 的情况发生。)

服务跟踪

除过每次查询服务外,还可以用 ServiceTracker 来代替做这一工作。这就跳过了中间获得 ServiceReference 的几步,但是要求你在构造之后调用 open,以便开始跟踪服务。

对于 ServiceReference,可以调用 getService() 获得服务实例。而 waitForService() 则在服务不可用时阻塞一段时间(根据指定的 timeout。如果 timeout 为 0,则永远阻塞)。我们可以如下重新实现 shorten 命令:

复制代码
package com.infoq.shorten.command;
import java.io.IOException;
import org.osgi.framework.BundleContext;
import org.osgi.util.tracker.ServiceTracker;
import com.infoq.shorten.IShorten;
public class ShortenCommand {
protected ServiceTracker tracker;
public ShortenCommand(BundleContext context) {
this.tracker = new ServiceTracker(context,
IShorten.class.getName(),null);
this.tracker.open();
}
protected String shorten(String url) throws IllegalArgumentException,
IOException {
try {
IShorten shorten = (IShorten)
tracker.waitForService(1000);
if (shorten == null)
return null;
return shorten.shorten(url);
} catch (InterruptedException e) {
return null;
}
}
}

使用 Service Tracker 的常见问题是在构造后忘记了调用 open()。除此之外,还必须在 MANIFEST.MF 内部引入 org.osgi.util.tracker 包。

使用 ServiceTracker 来管理服务依赖通常被认为是管理关系的好方法。在没有使用服务的情况下,查找已输出的服务稍微有点复杂:比 如,ServiceReference 在其被解析为一个服务之前突然变得不可用了。存在一个 ServiceReference 的原因是,相同实例能够在多 个 bundle 间共享,而且它可以被用来基于某些标准(手工)过滤服务。而且,它还可以使用过滤器来限制可用服务的集合。

服务属性和过滤器

当一个服务注册时,可以将服务属性一起注册。大多情况下属性可以为 null,但是也可以提供 OSGi 特定或关于 URL 的通用属性。例如,我们想给服务分级 以便区分优先级。我们可以注册 Constants.SERVICE_RANKING(代表优先级的数值),作为最初注册过程的一部分。我们可能还想放一些 client 想知道的元数据,比如服务的主页在哪儿,该站点的条款链接。为达此目的,我们需要修改 activator:

复制代码
public class Activator implements BundleActivator {
public void start(BundleContext context) {
Hashtable properties = new Hashtable();
properties.put(Constants.SERVICE_RANKING, 10);
properties.put(Constants.SERVICE_VENDOR, "http://tr.im");
properties.put("home.page", "http://tr.im");
properties.put("FAQ", "http://tr.im/website/faqs");
context.registerService(IShorten.class.getName(),
new Trim(), properties);
}
...
}

服务级别自动由 ServiceTracker 及其他对象来管理,但也可以用特定条件来过滤。Filter 是由 LDAP 风格的过滤器改编而来的,其使用了一种前缀表示法(prefix notation)来 执行多个过滤。虽然多数情况下你想提供类的名字(Constants.OBJECTCLASS),但你也可以对值进行检验(包括限制连续变量的取值范 围)。Filter 是通过 BundleContext 创建的;如果你想跟踪实现了 IShorten 接口的服务,并且定义一个 FAQ,我们可以这样做:

复制代码
...
public class ShortenCommand
public ShortenCommand(BundleContext context) {
Filter filter = context.createFilter("(&" +
"(objectClass=com.infoq.shorten.IShorten)" +
"(FAQ=*))");
this.tracker = new ServiceTracker(context,filter,null);
this.tracker.open();
}
...
}

在定义服务时可以被过滤或可以设置的标准属性包括:

  • service.ranking (Constants.SERVICE_RANKING) - 整数,可以区分服务优先级
  • service.id (Constants.SERVICE_ID) - 整数,在服务被注册时由框架自动设置
  • service.vendor (Constants.SERVICE_VENDOR) - 字符串,表明服务出自谁手
  • service.pid (Constants.SERVICE_PID) - 字符串,代表服务的 PID(persistent identifier)
  • service.description (Constants.SERVICE_DESCRIPTION) - 服务的描述
  • objectClass (Constants.OBJECTCLASS) - 接口列表,服务被注册在哪些接口下

过滤器语法在 OSGi 核心规范的 3.2.7 节 “Filter syntax”中有定义。最基本的,它允许如等于(=)、约等于(~=)、大于等于、小于等于以及子字符串比较等操作符。括号将过流器分组,并且可以结合 使用“&”、“|” 或“!”分别代表 and、or 和 not。属性名不是大小写敏感的,值可能是(如果不用~= 作比的话)。“*”是通配符,可用来支持子字符串匹配,比如 com.infoq.*.*。

总结

本文中,我们介绍了如何使用服务进行 bundle 间通信,以替代直接类引用的方法。服务可以让模块系统动态化,这样就能应对在运行时服务的变化问题。我们 还接触到了服务级别、属性及过滤器,并使用标准服务跟踪器来更容易的访问服务并跟踪变化的服务。我们将在下一部分介绍如何用声明式服务使得服务的编写更加容易。

本文所讲例子的可安装 bundle 罗列如下(包含源代码):

查看英文原文 Modular Java: Dynamic Modularity


感谢曹云飞对本文的审校。

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

2010 年 1 月 28 日 02:4910628
用户头像

发布了 150 篇内容, 共 38.8 次阅读, 收获喜欢 8 次。

关注

评论

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

渣本全力以赴33天,四面阿里妈妈(淘宝联盟),拿下实习岗offer

小Q

Java 学习 编程 架构 面试

【应用运维】公司业务迭代迅速,运维如何高效进行应用发布?

嘉为蓝鲸

可视化 PaaS 运维自动化 部署与维护 发布

微服务通信之feign的配置隔离

编程 微服务 计算机

区块链币支付系统开发搭建,USDT支付平台源码

13530558032

史上最通俗Netty入门长文:基本介绍、环境搭建、动手实战

JackJiang

网络编程 Netty nio 即时通讯 IM

C++调用Go方法的字符串传递问题及解决方案

华为云开发者社区

c++ 内存 代码

爆火的低代码,到底是真需求还是伪风口?

ToB行业头条

低代码

区块链IM即时社交通讯系统开发,区块链社交平台源码搭建

13530558032

K3d vs Kind 谁更适合本地研发

郭旭东

Kubernetes k3s kind

备战2021年金三银四,阿里P8面试官梳理的2020年999道大厂高频Java面试题(附答案)

Java架构之路

Java 编程 程序员 架构 面试

懵了!一口气问了我18个JVM问题!

yes

面试 JVM

Android热修复之DexPatch介绍

阿里云金融线TAM SRE专家服务团队

android

这才是图文并茂:我写了1万多字,就是为了让你了解AQS是怎么运行的

鄙人薛某

Java 并发编程 AQS 并发 ReentrantLock

阿里P8大牛整理的300页图解网络知识+计算机底层操作系统

Java架构之路

Java 编程 程序员 架构 面试

《深入理解java虚拟机》- java内存区域

never say never

JVM笔记 堆内存

架构师训练营 -week09-作业

大刘

极客大学架构师训练营

7面阿里,最后一面居然挂在了JVM上面!狠下决心恶补JVM知识

Java架构之路

Java 程序员 架构 面试 编程语言

适用初学者的5种Python数据输入技术

华为云开发者社区

Python 数据 函数

来不及解释了,快上车!LR.NET开发平台助力企业信息互联

雯雯写代码

Java读取Excel/CSV格式的科学计数法问题

团子粑粑

Java Excel csv

nginx 平滑升级、以及导入第三方模块

sinsy

nginx 升级

区块链交易所软件,数字货币场外交易系统搭建

13530558032

奋力准备一个月成功进字节,来看一下我都看了哪些资料做了哪些准备

小Q

学习 编程 程序员 架构 面试

这4个Java异常框架,很多人竟然还不知道

比伯

Java 编程 架构 面试 计算机

vscode + vim : vscode 全键盘使用方案

lmymirror

vim vscode Spacemacs

为什么有的专科程序员比本科程序员薪资高?他们之间有多大的区别?

Java架构师迁哥

学习笔记:架构师训练营-第八周

四夕晖

go-zero 如何扛住流量冲击(一)

万俊峰Kevin

microservice go-zero goctl Go 语言

接口测试并不只是测试参数和返回值

测试人生路

接口测试

深圳区块链钱包系统开发,区块链钱包app源码

13530558032

模块化Java:动态模块化_Java_Alex Blewitt_InfoQ精选文章