使用Java 8方法引用模拟属性字面量

发布者    |       讨论

库开发人员经常在Java中错过的一件事就是属性字面量。在这篇文章中,我将展示如何通过一些字节码生成,利用Java 8方法引用创造性地模拟属性字面量。

类似于类字面量(例如 Customer.class),属性字面量将允许以类型安全的方式引用bean类中的属性。这对于设计执行特定bean属性的操作或对其应用某些配置方式的API非常有用。例如,考虑Hibernate Search中对索引映射进行程序性配置的API

new SearchMapping().entity( Address.class )
    .indexed()
    .property( "city", ElementType.METHOD )
        .field();

或者Bean Validation API中的validateValue()方法,用于验证单个属性的约束条件

Set<ConstraintViolation<Address>> violations = validator.validateValue( Address.class, "city", "Purbeck" );

在这两种情况下,都使用一个字符串来表示Address类的city属性。

这在多个层面上都是容易出错的

  • Address类可能根本不存在一个名为city的属性。或者,在重命名属性时,可能会忘记更新这个字符串引用。

  • validateValue()的情况下,没有方法可以确保传递的值实际上满足city属性的类型。

API的用户只能在应用程序实际运行时发现这些问题。如果编译器和语言的类型系统从一开始就阻止了这种错误的使用,那岂不是更好?如果Java有属性字面量,那就正是你将得到的结果(发明语法,这不会编译!)

mapping.entity( Address.class )
    .indexed()
    .property( Address::city, ElementType.METHOD )
        .field();

并且

validator.validateValue( Address.class, Address::city, "Purbeck" );

上述提到的问题可以避免:在属性字面量中有一个拼写错误会导致编译错误,你会在你的IDE中立即注意到。这将允许以只接受配置Address实体的属性的方式设计Hibernate Search中的配置API。在Bean Validation的validateValue()中,字面量可以帮助确保只能传递可以分配给所讨论属性类型的值。

Java 8方法引用

尽管Java 8没有真正的属性字面量(而且它们的引入也没有计划在Java 9中进行),但它提供了一种有趣的方法来在一定程度上模拟它们:[方法引用](https://docs.oracle.com/javase/tutorial/java/javaOO/methodreferences.html)。在引入以提高使用Lambda表达式时的开发者体验后,方法引用也可以作为穷人的属性字面量。

将getter方法的引用视为属性字面量

validator.validateValue( Address.class, Address::getCity, "Purbeck" );

显然,这只有在实际存在getter的情况下才会工作。但如果你的类遵循JavaBeans约定——这通常是情况——那就没问题。

现在,validateValue()方法的定义是什么样的?关键是使用新的Function类型

public <T, P> Set<ConstraintViolation<T>> validateValue(Class<T> type, Function<? super T, P> property, P value);

通过使用两个类型参数,我们确保bean类型、属性以及传递给属性的值都正确匹配。从API的角度来看,我们得到了我们需要的:它是安全的,IDE甚至会在开始编写Address::后自动完成方法名称。但在validateValue()的实现中,我们如何从Function中推导出属性名称呢?

这很有趣,因为Function接口仅定义了一个方法,apply(),它将函数应用于T的给定实例。这似乎并不特别有帮助,对吗?

ByteBuddy来救命

实际上,应用这个函数是有效的!通过创建一个T类型的代理实例,我们有一个可以调用方法的目标,并且可以在代理的方法调用处理器中获取其名称。

Java自带对动态代理的支持,但这仅限于代理接口。由于我们的API应该与任何类型的bean一起工作,包括实际类,我将使用一个叫做ByteBuddy的巧妙工具。ByteBuddy提供了一个易于使用的DSL,用于动态创建类,这正是我们所需要的。

让我们首先定义一个接口,它只允许存储和获取从方法引用获得的方法的属性名称

public interface PropertyNameCapturer {

    String getPropertyName();

    void setPropertyName(String propertyName);
}

现在让我们使用ByteBuddy来程序化地创建一个代理类,它可以分配给感兴趣的类型的(例如Address)和PropertyNameCapturer

public <T> T /* & PropertyNameCapturer */ getPropertyNameCapturer(Class<T> type) {
    DynamicType.Builder<?> builder = new ByteBuddy()                                       (1)
            .subclass( type.isInterface() ? Object.class : type );

    if ( type.isInterface() ) {                                                            (2)
        builder = builder.implement( type );
    }

    Class<?> proxyType = builder
        .implement( PropertyNameCapturer.class )                                           (3)
        .defineField( "propertyName", String.class, Visibility.PRIVATE )
        .method( ElementMatchers.any() )                                                   (4)
            .intercept( MethodDelegation.to( PropertyNameCapturingInterceptor.class ) )
        .method( named( "setPropertyName" ).or( named( "getPropertyName" ) ) )             (5)
            .intercept( FieldAccessor.ofBeanProperty() )
        .make()
        .load(                                                                             (6)
             PropertyNameCapturer.class.getClassLoader(),
             ClassLoadingStrategy.Default.WRAPPER
        )
        .getLoaded();

    try {
        @SuppressWarnings("unchecked")
        Class<T> typed = (Class<T>) proxyType;
        return typed.newInstance();                                                        (7)
    }
    catch (InstantiationException | IllegalAccessException e) {
        throw new HibernateException(
            "Couldn't instantiate proxy for method name retrieval", e
        );
    }
}

代码可能看起来有点密集,让我为您解释一下。首先,我们获得一个新的ByteBuddy实例(1),这是DSL的入口点。它用于创建一个新动态类型,该类型要么扩展给定的类型(如果它是一个类),要么扩展Object并实现给定的类型,如果它是一个接口(2)

接下来,我们让该类型实现PropertyNameCapturer接口,并添加一个用于存储指定属性名称的字段(3)。然后我们说所有方法的调用都应该被拦截PropertyNameCapturingInterceptor(我们稍后会提到)(4)。只有setPropertyName()getPropertyName()(如PropertyNameCapturer接口中声明的)应该路由到之前创建的字段的写入和读取访问(5)。最后,构建、加载(6)和实例化(7)类。

这就是创建代理类型所需的全部内容;多亏了ByteBuddy,这只需几行代码就能完成。现在让我们看看我们之前配置的拦截器

public class PropertyNameCapturingInterceptor {

    @RuntimeType
    public static Object intercept(@This PropertyNameCapturer capturer, @Origin Method method) {         (1)
        capturer.setPropertyName( getPropertyName( method ) );                                           (2)

        if ( method.getReturnType() == byte.class ) {                                                    (3)
            return (byte) 0;
        }
        else if ( ... ) { } // ... handle all primitve types
            // ...
        }
        else {
            return null;
        }
    }

    private static String getPropertyName(Method method) {                                               (4)
        final boolean hasGetterSignature = method.getParameterTypes().length == 0
                && method.getReturnType() != null;

        String name = method.getName();
        String propName = null;

        if ( hasGetterSignature ) {
            if ( name.startsWith( "get" ) && hasGetterSignature ) {
                propName = name.substring( 3, 4 ).toLowerCase() + name.substring( 4 );
            }
            else if ( name.startsWith( "is" ) && hasGetterSignature ) {
                propName = name.substring( 2, 3 ).toLowerCase() + name.substring( 3 );
            }
        }
        else {
            throw new HibernateException( "Only property getter methods are expected to be passed" );    (5)
        }

        return propName;
    }
}

intercept() 函数接受被调用的 Method 以及调用的目标 (1)。使用 @Origin@This 注解来指定相应的参数,以便 ByteBuddy 能够在动态代理类型中生成正确的 intercept() 调用。

请注意,此拦截器与任何类型的 ByteBuddy 没有强依赖关系,这意味着 ByteBuddy 只在创建动态代理类型时需要,而在此之后使用时则不需要。

然后,通过 getPropertyName() (4) 获取由传入的方法对象表示的属性名称,并将其存储在 PropertyNameCapturer (2) 中。如果给定方法不表示 getter 方法,则会引发异常 (5)。调用的 getter 的返回值无关紧要,因此我们只需确保返回一个与属性类型匹配的合理的“null值” (3)

有了这些,我们就已经准备好了获取传递给 validateValue() 的方法引用所表示的属性。

public <T, P> Set<ConstraintViolation<T>> validateValue(Class<T> type, Function<? super T, P> property, P value) {
    T capturer = getPropertyNameCapturer( type );
    property.apply( capturer );
    String propertyName = ( (PropertyLiteralCapturer) capturer ).getPropertyName();

    // perform validation of the property value...
}

当将函数应用于属性名称捕获代理时,拦截器会启动,从 Method 对象中获取属性名称并将其存储在捕获器实例中,最终可以从该实例中检索到。

就这样,一些字节码魔术让我们能够创造性地使用 Java 8 方法引用来模拟属性文字。

尽管如此,将真正的属性文字作为语言的一部分(让我们幻想一下,也许是在 Java 10?)仍然非常有好处。它将允许处理私有属性,并且有望从注解中引用属性文字。真正的属性文字也将更加简洁(没有“get”前缀),整体上感觉不那么像黑客行为 ;)


回到顶部