[译] Java 模块化系统中反射和封装的对比

1,723 阅读14分钟
原文链接: coyee.com

过去,反射功能可以破坏任何运行在同一JVM中的代码的封装。随着Java 9的来临,这一现状将被改变。新模块化系统有两个主要目标,其中之一就是强封装,给各个模块一个安全的,无法入侵的运行空间。这两种技术是如此的的水火难容,那么如何解决这个问题呢?经过相当多的讨论之后,最近提出的开放化模块系统也许会是一个方向。

如果你已经全部了解的模块化系统的概念和反射的功能,那么你可以直接跳过后面的故事,直接进入章节:对质

设置场景

让我来设置一些场景,解释关于模块化系统如何实现的强封装,以及如何与反射相矛盾

模块速成

Java平台模块化平台 (JPMS)介绍了“模块”的概念, 这种“模块”最后也只是一个带着模块描述的JAR包. 这个描述是从module-info.java这个文件编译而来,文件里面定义了模块的名字,模块依赖的其他模块,以及模块提供的可用包 , 

module some.module {

    requires some.other.module;
    requires yet.another.module;

    exports some.module.package;
    exports some.module.other.package;

}

在封装的代码上下文中有两点值得注意:

  • 在要导出(exports)的包中,只有声明为public的类型,方法和字段可以访问.
  • 这些导出(exports)的包,只会被在依赖(requires)他们的模块访问.

在这里,“允许访问”意味着可以针对包括的元素进行编译并且JVM可以在运行时访问它们. 所以,如果我们有一个模块 user 依赖模块 owner, 需要我们做的只是在 user 模块中依赖(require) owner 和在 owner 模块中导出(export) 包含着user模块依赖的类型的包 :

module user {
    requires owner;
}

module owner {
    exports owner.api.package;
}

除了写依赖和显示API,在这些我们之前使用的已知模块化系统中,这些都是很常见的情况

目前为止,大伙都很开心,然后,反射登场… 从人声鼎沸到万籁俱寂

反射

在Java 9之前, 反射可以破坏任意代码的封装性. 除了对 setAccessible有些烦人的调用外,任何类中的每个类型,方法,字段都可以使用,都可以调用,都可以改变 ——甚至定义为final的字段都TMD的变得不安全!

Integer two = 2;

Field value = Integer.class.getDeclaredField("value ");
value.setAccessible(true);
value.set(two, 3);

if (1 + 1 != two)
    System.out.println("Math died! ");

这种技术被使用到各种框架中 – 从JPA实现的提供者(例如:hibernate), 到测试库(例如:JUnit 和 TestNG), 再到依赖注入工具(例如:Guice), 最后到类的路径扫描工具(例如:Spring) – 这些或者完全的覆盖了我们的应用,或者通过代码测试来展示他们的魔法。也有的时候,我们有一些库可能需要一些JDK的东西,但是这些东西并没有开源 (是不是有人说了 sun.misc.Unsafe?). 这时候,反射就是你要的答案。

所以这家伙,经常被用来获得他想要的东西,但是现在,我们走进模块化系统中,看起来这次并不会如他所愿

更多该作者文章

对质

在模块系统中(让我们忘了那可笑的Saloon英文名吧,这里不让发车)反射只可以访问当导出包(exports)中的代码。模块中的包被限制访问,这已经引起了一些骚动。但是我们依然可以通过反射访问到导出包(exports)中的其他内容,像是包可见类或者private声明的字段和方法。这被称为深反射。在九月这种规则被定义的更加严格。现在深反射也被禁止了,反射不会再比静态类型的代码更强力,相反的现在只有public声明的类型,方法和字段在导出包中可以被访问。

 

当然如果这导致了很多的讨论,不管是激烈的还是有好的,都具有极其重要的意义。

一些人(包括我)赞成强封装, 他们认为在模块内部需要一块安全的空间,在这块空间中他们可以放心的组织模块的内部信息,而不用担心会被其他的代码轻易依赖的风险。举个例子的话我很喜欢说JUnit 4,重写它的一个重要原因就是JUnit使用的一些工具依赖了具体的实现,这些工具使用了反射来访问到私有的属性;强封装的不安全,是给很多移除依赖的工作增加了很大的压力。

另外一些人则认为,反射提供的灵活性不仅为许多框架的高度的可用性,例如在实体上加上注解,并将hibernate.jar放到classpath中就能够让一切正常运作。 它同样给库的使用者提供了自由,他们可以用他们想要的方式使用依赖的对象,尽管这种使用方式可能不是开发者的本意。反射这里,不安全的例子来自另外一面:当前Java生态系统中很多的重要的库和框架也仅仅是可用,因为一些骇客很有可能没有JDK团队的许可(感觉没读懂作者的原意,求大神们抬一手- -)

虽然我更倾向于封装,但是我也清楚其他观点的正确性。 所以该怎么做呢?除了封装内部和放弃反射之外,开发者还能怎么选择?

武器的选择

假设我们需要做一个可以通过反射使用的内部模块。也许将它暴露于一个库或框架或我们做的其他模块并且想在第一个模块中调用它们。 在剩下的文章中,我们将探索所有可用的选择,寻找这些问题的答案:

  • 我们需要采用什么样的访问权限呢?
  • 谁可以访问内部?
  • 他们也可以在编译时访问吗?

我们将创建两个模块来进行这次的探索。 其中一个模块名为 owner 包括一个简单地类 Owner (在包 owner 中) ,这个类有一个所有人可见的方法,方法内部没做任何事情。 另一个模块 intruder , 包括了一个类 Intruder ,在编译时没有依赖 Owner ,但是在运行时会调用Owner类的方法。代码如下:

Class<?> owner = Class.forName("owner.Owner");
Method owned = owner.getDeclaredMethod(methodName);
owned.setAccessible(true);
owned.invoke(null);

关键的部分就是setAccessible这里,成功与否取决于我们创建和执行模块的方式。最后,我们得到的输出如下:

public: ✓   protected: ✗   default: ✗   private: ✗   

(这里只有public声明的方法可以访问)

所有的代码都可以在GitHub上找到, 包括linux的运行脚本。

常规导出(Regular Exports)

这是暴露API的方式(好比普通的香草):  owner 模块仅仅导出(exports)了包 owner. 当然要做到这一点,我们需要能够改变拥有模块的描述符。

module owner {
    exports owner;
}

我们得到了一下的结果:

public: ✓   protected: ✗   default: ✗   private: ✗   

目前为止, 呃。。并不是很好。首先,我们只达到了了我们预期目标的一部分,因为入侵的模块只能访问public声明的元素。 并且如果我们这样做了,所有依赖着onwer的模块都可以编译代码,并且所有模块都可以反射内部元素。 实际上从我们导出了(Exports)它们开始,它们就不再是内部元素了 – package包中的public类型元素被嵌入到module模块的API中。

合格导出(Qualified Exports)

如果常规导出是香草,这种导出是蔓越莓香草 – 一种默认的选择,它提供了一种有趣的特性. owner module可以导出包到特定的模块,这种模块被称为合格导出(qualified export):

module owner {
    exports owner to intruder;
}

但是结果和常规导出相同,入侵的模块只能访问到public声明的元素:

public: ✓   protected: ✗   default: ✗   private: ✗   

同样,我们只达到了我们目标的一部分,并且在编译时以及运行时暴露了元素。情况有所改善,在这种情况下,只有命名的模块,在这种情况下intruder被授予访问权限,但是我们接受了在编译时实际知道模块名称的必要性。

知道入侵(Intruder)模块可能适用于像Guice这样的框架,但是一旦在API之后有隐藏的实现类(JDK团队称之为抽象反射框架;想想JPA和Hibernate),这种方法就会失败。先不看这种方式是否可以成功,在拥有(owner)模块的描述符中明确写明入侵(Intruder)模块的名字已经可以看出来是有问题的了。另一方面,好的地方是拥有(owner)模块已经依赖于入侵(Intruder)的模块,因为它需要一些注解或其他东西,在这种情况下,我们不会使事情更糟。

开放包(Open Packages)

现在事情变得有趣了。模块系统最近的一个新增功能是模块仅在运行时打开包。

module owner {
    opens owner;
}

输出结果:

public: ✓   protected: ✓   default: ✓   private: ✓

莱斯!这种方式一石二鸟:

  • 入侵(Intruder)包有了更深的权限,允许可以访问整个包,甚至private声明的元素
  • 这种暴露只是在运行时间,所以代码不能针对包中的内容进行编译.

虽然还有一个缺点:这种方式所有的模块都可以反射开放包。不过,这一切依然远远好于导出(Exports)方式。

合格开放包(Qualified Open Packages)

像导出和合格导出一样, 开放包也存在一个合格版本的变体:

module owner {
    opens owner to intruder;
}

我们得到了相同的结果,但是这一次只有Intruder可以实现这个效果:

public: ✓   protected: ✓   default: ✓   private: ✓

这为我们提供了与导出和合格导出一样的可选项,但是也同样不适用于API和实现类之间的分离。但有希望!

11月,Mark Reinhold提出了一种机制,允许打开包的模块中的代码将该访问权转移到第三个模块。回到JPA和Hibernate,这杨就完全解决了问题。假设所有者的以下模块描述符:

module owner {
    // the JPA module is called java.persistence
    // JAP模块名称叫java.persistence
    opens owner to java.persistence;
}

在这种情况下,可以采用以下机制(从原文引用):

JPA实体管理器是通过Persistence :: createEntityManagerFactory方法之一创建的,它定位并初始化一个合适的持久化功能提供者,如Hibernate。作为该过程的一部分,他们可以使用客户端模块owner上的addOpens方法向Hibernate模块开放的owner包。在owner模块向java.persistence模块开放包的时候,这将生效

还有一种容器的变体,用于将包中的内容向实现类开放。在当前的EA构建(b146)这个功能似乎还没有实现,虽然,我不能尝试一下。但它肯定前途无量!

开放模块(Open Modules)

如果开放包好比是手术刀一搬精致,那开放模块就像菜刀一样。在这种情况下,模块将放弃控制运行时的访问权限,并且将包中的内容开放给所有模块,就像给它们每个写了一个opens定义一样。

open module owner { }

这将导致与单独开放包具有相同的访问权限:

public: ✓   protected: ✓   default: ✓   private: ✓

开放模块可以被认为是从类路径上的JAR到成熟的强封装模块的迁移路径上的中间步骤。

类路径技巧(Class Path Trickery)

现在我们进入较少的模块化区域。或许你知道java和javac需要模块在模块路径上,模块路径就像类路径(class path)之于类。但类路径不会消失,JAR包也不会。如果我们可以访问启动命令行并且可以推送生成包(这对JDK模块不起作用),我们可以使用两个技巧。

匿名模块(Unnamed Module)

首先, 我们可以把包含的模块放到类路径(classpath)上.

模块系统将对此如何反应?因为所有的东西都需要模块,所以模块系统就会简单的创建一个,被称为未命名的模块,并把所有东西放在里面,使它能在类路径上被发现。在无名的模块内部,一切都像现在一样,JAR冲突会继续存在。因为未命名的模块是合成的,JPMS不知道它会导出什么东西来,所以它就简单的导出所有的东西,包括编译和运行时。

如果类路径上的任何JAR不小心包含了一个模块描述符,则该机制将忽略它。因此,拥有它的模块将会被降级,普通JAR及其编码会在一个能导出一切东西的模块里结束:

public: ✓   protected: ✓   default: ✓   private: ✓

RUA! 我们对没有访问权限的模块使用这种方式,并且不用接触到包含的模块。小小的警告: 我们不能依赖匿名模块,所以并没有什么好方法能够在其他模块中,针对包含的模块进行编译。好吧,这也许并不是一个小小的警告。。

自动模块(Automatic Module)

第二种方式是在描述中去掉包含的模块,但是依然把他们放到模块路径上。对模块路径上的每个常规JAR,JPMS都会根据文件名自动创建一个模块,并且会导出(exports)所有内容。因为所有的东西都导出了,我们得到了和匿名模块一样的结果:

public: ✓   protected: ✓   default: ✓   private: ✓

Nice。相比匿名模块自动模块最大的好处是其他模块可以对它写依赖声明(require),所以应用中的其他部分依然可以针对它进行依赖和编译,同时,入侵(intruder)模块也可以通过反射获得访问内部代码的权限。.

这种方式有一个缺点,就是在运行时,该模块内部的东西会向系统中的其他所有模块开放。,不幸的是,编译时也是如此,除非在编写完成之后我们适当的对包含的包进行管理,之后删掉其中的依赖描述。 这样会变得复杂而且容易出错,并且这种方式看起来也没啥前途

命令行逃生舱

不管我们用什么方式摆弄命令行,最简单的方法(或许我应该在之前告诉你)包括 javac 和 java 命令行都支持一个新的参数 --add-opens 用来打开额外的包。

java \
    --module-path mods \
    --add-modules owner \
    --add-opens owner/owner=intruder \
    --module intruder

这个可以让你在不修改 JDK 模块的情况下工作。这比无命名和自动模块发掘的方法要好得多。

总结

好了,还记得我们刚才做了什么吗?下面是一个概要总结:

机制 access compile access reflection access 描述
export descriptor all code > public all code > public makes API public
qualified export descriptor specified modules > public specified modules > public 需要知道要包含的模块
open package descriptor none all code > private  
qualified open package descriptor none specified modules > private 可以转换成实现模块
open module descriptor none all code > private 一个用来打开所有包的关键字
unnamed module command line all non-modules > public all code > private  
automatic module command line and artifact all code > public all code > private requires fiddling with the artifact
command line flag command line none all code > private  

哇,我们真的经历了很多选择! 但现在如果你要使用反射将任务分解为模块你就知道该怎么做了。 总之,我认为绝大多数的用例可以通过回答一个问题来解决:

这是你自己的模块吗?

  • Yes ⇝ 开放包(也可能是合格开放包) 或者,如果有太多包,则开放整个模块。
  • No ⇝ 使用命令行标志 --add-opens.