Spec API 模块化模式

发布者:    |       讨论

虽然 Java 9 本周已经达到其生命周期的结束,但 Java 平台模块系统(JPMS,JSR 376)将持续存在。这意味着像 Java 持久化 APIBean 验证 这样的规范最终也必须进行调整,以支持并利用模块系统。

这篇博客文章是关于探索 JPMS 规范 API 模块化模式的一系列文章中的第一篇。在本部分中,我们将探讨如何以可移植的方式引导规范 API 的实现,以及这些实现如何访问 API 使用者模块的私有状态。正如之前所讨论的的那样,后一种是一个常见需求;例如,JPA 提供商必须这样做以读取和写入实体状态。

以一个例子来说明,让我们考虑三个 JPMS 模块

  • fieldreader.spec:一个虚构的“规范”,它定义了访问用户类私有字段的合同

  • acme.fieldreader.impl:ACME Corp. 对字段读取规范的实现

  • com.example.beans:一个包含用户类和一些使用字段读取规范 API 访问该用户类私有状态的代码的应用程序代码的模块

以下图片显示了这些模块之间的关系

Module Relationships, align=

 
ACME 规范实现和用户模块都依赖于规范 API 模块进行编译和运行时。但 com.example.beans 模块不应依赖于 ACME 规范实现的任何具体细节,即编译时它只依赖于 API 模块,而 ACME 实现模块仅是运行时依赖。

有了这个结构,让我们来探讨如何设计 API、实现和使用了 API 的代码。

规范 API

字段读取规范定义了一个单独的接口,FieldValueReader

fieldreader.spec/src/main/java/fieldreader/spec/FieldValueReader.java
/**
 * Retrieves the value of the specified (private) field from the given object.
 */
public interface FieldValueReader {
    public Object getFieldValue(Object o, String fieldName);
}

传递一个对象和字段名称,API 应返回给定字段的值。显然,这不是一个非常有用的规范,但在这里作为一个示例非常合适,因为它的实现带来了一些有趣的挑战,类似于在实际规范(如 JPA)中遇到的挑战。

我们还需要一种方法来允许其他模块中的代码启动字段读取器规范的实现。类似于现有的规范(想想 JPA 的 Persistence 或 Bean Validation 的 Validation 类),这可以通过在 API 入口点类的静态方法中实现。

fieldreader.spec/src/main/java/fieldreader/spec/FieldReaderApi.java
public class FieldReaderApi {

    public static FieldValueReader getFieldValueReader() {
        // ...
    }
}

除了任何异常和枚举类型之外,这个入口点类通常是 API 模块中唯一的非接口类,它提供了实际的方法实现。

启动 API 实现

入口点类的实现可以基于服务加载器机制。自 Java 6 版本以来就是 Java 平台的一个官方部分,当 JPMS 引入时,服务加载器获得了更多的关注。

在 JPMS 中,服务是一级构建块,是将一个模块中的 API 合同绑定到位于其他模块中的实现(们)的手段。为了使用此机制,需要一个服务合同,它作为 API 模块和实现模块(们)之间的集成点。

fieldreader.spec/src/main/java/fieldreader/spec/bootstrap/FieldReaderProvider.java
/**
 * Contract between the field reader API bootstrap mechanism and spec implementations.
 */
public interface FieldReaderProvider {

    FieldValueReader provideFieldValueReader();
}

在 API 的模块描述符中,我们声明了对此服务的使用,并导出两个 API 包

fieldreader.spec/src/main/java/module-info.java
module fieldreader.spec {
    // public API
    exports fieldreader.spec;
    exports fieldreader.spec.bootstrap;

    // using the FieldReaderProvider service
    uses fieldreader.spec.bootstrap.FieldReaderProvider;
}

现在,FieldReaderApigetFieldValueReader() 方法可以通过几行代码实现。

fieldreader.spec/src/main/java/fieldreader/spec/FieldReaderApi.java
public class FieldReaderApi {

    public static FieldValueReader getFieldValueReader() {
        ServiceLoader<FieldReaderProvider> loader = ServiceLoader.load( FieldReaderProvider.class );

        return loader.findFirst()
            .orElseThrow( () -> new IllegalStateException(
                        "No provider of " + FieldReaderProvider.class.getName() + " available" )
                    )
            .provideFieldValueReader();
    }
}

提供规范实现

现在是时候看看 ACME 规范的实现。我们首先需要创建一个 FieldReaderProvider 合同的实现(这将返回 ACME 的 FieldValueReader 实现)

acme.fieldreader.impl/src/main/java/acme/fieldreader/impl/AcmeFieldReaderProvider.java
public class AcmeFieldReaderProvider implements FieldReaderProvider {

    @Override
    public FieldValueReader provideFieldValueReader() {
        // ...
    }
}

然后我们需要使用 provides 指令在模块描述符中注册该服务实现

acme.fieldreader.impl/src/main/java/module-info.java
module acme.fieldreader.impl {
    requires fieldreader.spec;
    provides fieldreader.spec.bootstrap.FieldReaderProvider
        with acme.fieldreader.impl.AcmeFieldReaderProvider;
}

我们还需要 requires 指令来声明对 API 模块的依赖,因为这个模块实现了规范接口。

有了这些,FieldReaderApi 中的代码将能够启动实现。

FieldValueReader 实现迈进

到目前为止,一切看起来与 JPMS 之前的时间几乎相同。毕竟,以可移植的方式使用服务加载器启动 API 实现是一个已建立的模式,被广泛使用的 Java 规范所采用。只有在使用 JPMS 时,在模块描述符中注册提供和使用的服务是一个新特点。

当涉及到 ACME 的 FieldValueReader 合同实现时,事情变得更加有趣。JPMS 的一个目标就是封装,即默认情况下,非导出包及其内容对于其他模块中的代码不可访问。这意味着,与过去不同,ACME 模块不能简单地使用反射来获取一个 java.lang.reflect.Field 实例,对其调用 setAccessible(true),然后使用 Field#get() 从给定实例中获取字段值。

相反,为了使这可行,必须将用于从字段获取值的类型所在的包 打开 给 ACME 实现模块。只有在这种情况下,我们才能使用反射,或者更好的是,使用新的 VarHandle API 从用户模块的私有字段中获取值。有多种方法可以打开一个包,每种方法提供不同程度的粒度。

最简单的方法是将 com.example.beans 模块完全作为一个 开放模块。这意味着任何其他模块中的代码都可以对 com.example.beans 模块中的任何类型应用深度反射。不言而喻,这在封装方面并不是非常可取。

仅仅通过打开一个单独的包,我们就获得了一些更多的控制。

com.example.beans/src/main/java/module-info.java
module com.example.beans {
    requires fieldreader.spec;
    opens com.example.beans;
}

这允许其他模块对com.example.beans包中的类型应用深度反射,但不能对那个模块的其他包应用。这已经更好了,但如果我们能特别控制并限制哪些其他模块可以这样做,那就更好了。这可以通过指定opens指令来实现。

opens com.example.beans to acme.fieldreader.impl;

现在,我们可以在ACME模块中创建FieldValueReader接口的实现。但是,如果我们仔细想想,我们现在与上面设置的原设计产生了冲突:用户模块不应该依赖于字段读取规范的具体实现,但现在正是这种情况。通过在我们的模块描述符中使用模块名称acme.fieldreader.impl,我们降低了字段读取规范的其他实现的可移植性。

与其将我们的包向特定实现开放,不如将其向API模块本身开放。

opens com.example.beans to fieldreader.spec;

从用户的角度来看,这是理想的,但问题是,如何实现这一点?毕竟,执行私有字段访问的代码将位于实现模块,而不是规范模块本身。

传播已开放的包

现在,一个模块可以将其已经开放的包再向其他模块开放,这很有用。这意味着如果com.example.beans包向fieldreader.spec模块开放,这个规范模块也可以将该包向实现模块开放。

为了做到这一点,让我们稍微改变一下FieldReaderProvider接口

fieldreader.spec/src/main/java/fieldreader/spec/bootstrap/FieldReaderProvider.java
public interface FieldReaderProvider {

    FieldValueReader provideFieldValueReader(PackageOpener opener);

    public interface PackageOpener {
        void openPackageIfNeeded(Module targetModule, String targetPackage, Module specImplModule);
    }
}

provideFieldValueReader()现在有一个PackageOpener参数。这个对象将后来在FieldValueReader#getFieldValue()的实现中使用,以请求从用户的模块中打开给定的包到实现模块。

但首先让我们看看需要修改的FieldReaderApi

fieldreader.spec/src/main/java/fieldreader/spec/FieldReaderApi.java
public class FieldReaderApi {

    private static final PackageOpener PACKAGE_OPENER = new PackageOpenerImpl();

    public static FieldValueReader getFieldValueReader() {
        ServiceLoader<FieldReaderProvider> loader = ServiceLoader.load( FieldReaderProvider.class );

        return loader.findFirst()
                .orElseThrow( () -> new IllegalStateException(
                    "No provider of " + FieldReaderProvider.class.getName() + " available" ) )
                .provideFieldValueReader( PACKAGE_OPENER );
    }

    private static class PackageOpenerImpl implements FieldReaderProvider.PackageOpener {

        @Override
        public void openPackageIfNeeded(Module targetModule, String targetPackage,
                Module specImplModule) {

            if ( !targetModule.isOpen( targetPackage, specImplModule ) ) {
                targetModule.addOpens( targetPackage, specImplModule );
            }
        }
    }
}

getFieldValueReader()方法基本上和以前一样,它仍然使用服务加载器来查找FieldReaderProvider实现。不同的地方在于它现在传递了一个PackageOpener实现。

这个类只是将用户的模块的给定包打开到指定的规范实现模块,如果还不是这样的话(即如果用户的模块是一个开放模块或者已经向实现模块打开了给定的包,则不需要做任何事情)。

实现FieldValueReader接口

在这些更改到位后,我们终于可以看看ACME模块中的FieldReaderProviderFieldValueReader实现。前者很简单,它只是实例化字段读取器,传递给定的打开对象。

acme.fieldreader.impl/src/main/java/acme/fieldreader/impl/AcmeFieldReaderProvider.java
public class AcmeFieldReaderProvider implements FieldReaderProvider {

    @Override
    public FieldValueReader provideFieldValueReader(PackageOpener opener) {
        return new FieldValueReaderImpl( opener );
    }
}

FieldValueReader实现稍微复杂一些

acme.fieldreader.impl/src/main/java/acme/fieldreader/impl/AcmeFieldReaderProvider.java
public class FieldValueReaderImpl implements FieldValueReader {

    private final ClassValue<Lookup> lookups;

    public FieldValueReaderImpl(PackageOpener packageOpener) {
        this.lookups = new ClassValue<Lookup>() {

            @Override
            protected Lookup computeValue(Class<?> type) {
                if ( !getClass().getModule().canRead( type.getModule() ) ) {
                    getClass().getModule().addReads( type.getModule() );
                }

                packageOpener.openPackageIfNeeded(
                        type.getModule(),
                        type.getPackageName(),
                        FieldValueReaderImpl.class.getModule()
                );

                try {
                    return MethodHandles.privateLookupIn( type, MethodHandles.lookup() );
                }
                catch (IllegalAccessException e) {
                    throw new RuntimeException( e );
                }
            }
        };
    }

    @Override
    public Object getFieldValue(Object o, String fieldName) {
        try {
            VarHandle varHandle = lookups.get( o.getClass() )
                    .unreflectVarHandle( o.getClass().getDeclaredField( fieldName ) );

            return varHandle.get( o );
        }
        catch (Exception e) {
            throw new RuntimeException( e );
        }
    }
}

getFieldValue()中,使用VarHandle从指定的实例的字段中获取值。简单来说,var handles(及其兄弟,method handles)可以用作访问Java字段和方法的经典反射API的替代品。

Var handles是从MethodHandles.Lookup类中获得的。对我们来说,重要的是查找具有“私有访问”给定对象类型的权限。引用文档,“我们说一个查找具有私有访问权限,如果它的查找模式包括访问私有成员的可能性”。

要获取具有私有访问权限的查找,可以使用MethodHandles#privateLookupIn()。为了成功执行此调用,必须从已打开给定类型包的模块(或类型的自身模块)内部进行。由于此调用是从ACME实现模块进行的,因此如果需要,将使用由规范模块提供的PackageOpener来建立此打开关系。这反过来又要求应打开的模块必须读取声明该包的模块。当然,ACME实现模块不能在其模块描述符中声明指向用户模块的requires指令。这就是为什么在请求打开包之前通过调用Module#addReads()动态建立读取关系的原因。

FieldValueReaderImpl利用了非常有用的ClassValue类,该类充当为每个类型从查找对象中获取字段值的懒惰填充缓存。如果尚未为给定类型存储任何Lookup,则将调用computeValue()方法来检索此类查找。对于同一类型的所有后续对getFieldValue()的调用将重用存储在ClassValue实例中的查找实例。当然,更复杂的实现也可以缓存给定字段的var handle并可能应用其他优化。

测试

这完成了实现工作,现在我们可以在用户模块内测试字段读取规范API和ACME实现。

com.example.beans/src/main/java/com/example/beans/MyEntity.java
public class MyEntity {

    private String name;

    public MyEntity(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }
}
com.example.beans/src/main/java/com/example/main/FieldReaderTest.java
public class FieldReaderTest {

    public static void main(String[] args) {
        FieldValueReader fieldValueReader = FieldReaderApi.getFieldValueReader();
        Object value = fieldValueReader.getFieldValue( new MyEntity( "bob" ), "name" );
        assert "bob".equals( value );
    }
}

请注意,用户模块的模块描述符没有声明对ACME实现的任何依赖。

com.example.beans/src/main/java/module-info.java
module com.example.beans {
    requires fieldreader.spec;
    opens com.example.beans to fieldreader.spec;
}

相反,当执行应用程序时,只需将此模块添加到模块路径即可,API模块将引导实现,并将稍后打开用户包所需的PackageOpener对象传递给ACME实现模块。

作为一个实验,尝试在模块描述符中省略opens指令。这将阻止API模块打开com.example.beans包以供实现模块使用,您应该看到以下异常

Exception in thread "main" java.lang.RuntimeException:
java.lang.IllegalCallerException: com.example.beans is not open to module fieldreader.spec
    at acme.fieldreader.impl/acme.fieldreader.impl.FieldValueReaderImpl.getFieldValue(FieldValueReaderImpl.java:44)
    at com.example.beans/com.example.main.FieldReaderTest.main(FieldReaderTest.java:12)
Caused by: java.lang.IllegalCallerException: com.example.beans is not open to module fieldreader.spec
    at java.base/java.lang.Module.addOpens(Module.java:751)
    at fieldreader.spec/fieldreader.spec.FieldReaderApi$PackageOpenerImpl.openPackageIfNeeded(FieldReaderApi.java:25)

摘要

在这篇博文中,我们展示了Java规范如何利用JPMS引导实现,以及实现模块如何从用户模块获取对类的私有访问权限。

这通常是必需的;例如,JPA提供者必须访问实体(如果它们强制字段访问)的私有状态。同样,Bean Validation提供者必须访问字段值以验证字段级约束。正如我们所见,用户模块必须打开受影响类型的包才能使此功能正常工作。然而,为了可移植性,不应将包打开给特定实现,而应打开给API模块。然后,API模块可以将用户模块的包也打开给规范实现模块。这种方法实现了规范之间的可移植性目标,同时使规范实现能够执行所需的私有访问操作。

请注意,这需要Java规范定义一个要由所有为该规范创建的API模块使用的模块名称(例如,此示例中的fieldreader.spec)。或者,更好的办法是只提供一个由所有实现共享的单个API模块。例如,从版本2.2开始,JPA就是这样做的,Hibernate ORM也依赖于javax.persistence:javax.persistence-api:2.2工件,而不是提供其自己的带有规范包的工件。

一种替代方案可能是让用户自己启动具有私有访问权限的查找,并在启动过程中将其传递给API。然而,由于以下原因,这并不是最佳选择:`Lookup` API相对较底层,用户代码可能无法正确实现私有访问权限的查找。此外,在容器环境中,用户代码无法控制JPA或Bean Validation等启动API,因此无法传递查找对象。最后,如果用户类分布在多个模块中,从这些用户模块中收集所需的查找将是一项具有挑战性的任务。

相比之下,将用户包对规范API模块开放,并让这些模块进一步将用户包对实现模块开放,这种解决方案更容易操作。

为此,需要对规范API模块进行一些调整。当受影响的规范开始探索所需的更改并作为JPMS感知模块发布其API的更新版本时,这将非常有趣。随着Java EE最近迁移到Eclipse Foundation并将其重新命名为Jakarta EE,社区——即您——有机会帮助实现这一点!

您可以在我们的示例存储库中找到本博客的完整源代码,包括我们讨论的三个模块。

在后续的博文中,我们将探讨规范实现如何处理应用模块中提供的资源,例如JPA的`persistence.xml`文件等XML描述符。如果您有任何反馈或对其他相关话题的建议,请在下面的评论中告诉我们。非常期待您关于这个话题的想法、观点和问题。

感谢Alex Buckley、Alan Bateman、Guillaume Smet、Sanne Grinovero和Yoann Rodiere在撰写本文时提供的宝贵反馈!


返回顶部