Эмуляция литералов свойств с Java 8 Method Reference

в 13:56, , рубрики: java, metaprogramming, nameof, ненормальное программирование
Эмуляция литералов свойств с Java 8 Method Reference - 1

От переводчика: к переводу этой статьи меня подтолкнула обида от отсутствия оператора nameOf в языке Java. Для нетерпеливых — в конце статьи есть готовая реализация в исходниках и бинарниках.

Одна из вещей, которой часто не хватает разработчикам библиотек в Java, — литералы свойств. В этом посте я покажу, как можно креативно воспользоваться Method Reference из Java 8 для эмуляции литералов свойств с помощью генерации байт-кода.

Сродни литералам классов (например, Customer.class), литералы свойств позволили бы ссылаться на свойства классов-бинов типобезопасно. Это было бы полезно для дизайна API, где есть необходимость выполнять действия над свойствами или каким-то образом конфигурировать их.

От переводчика: Под катом разбираем как из подручных средств это реализовать.

Например, рассмотрим API конфигурации маппинга индекса в Hibernate Search:

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

Или же метод validateValue() из Bean Validation API, позволяющий проверить значение по ограничениям на свойстве:

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

В обоих случаях чтобы сослаться на свойство city объекта Address используется тип String.

Это может приводить к ошибкам:

  • класс Address может вообще не иметь свойства city. Или кто-то может забыть обновить строковое имя свойства после переименования get/set методов при рефакторинге.
  • в случае validateValue() у нас нет возможности убедиться, что тип передаваемого значения соответствует типу свойства.

Пользователи этого API могут узнать об этих проблемах только запустив приложение. Разве не круто было бы, если бы компилятор и система типов предотвращали такое использование с самого начала? Если бы в Java были литералы свойств, то мы бы могли делать так (этот код не компилируется):

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

И:

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

Мы бы могли избежать проблем, упомянутых выше: любая описка в имени свойства привела бы к ошибке компиляции, которую можно заметить прямо в вашей IDE. Это позволило бы разработать API конфигурации Hibernate Search так, чтобы он принимал только свойства класса Address, когда мы конфигурируем сущность Address. И в случае c Bean Validation validateValue() литералы свойств помогли бы убедиться, что мы передаём значение верного типа.

Java 8 Method Reference

Java 8 не поддерживает литералы свойств (и их не планируется поддержать в Java 11), но в то же время она предоставляет интересный способ для их эмуляции: Method Reference (ссылка на метод). Изначально, Method Reference были добавлены для упрощения работы с лямбда-выражениями, но их можно использовать как литералы свойств для бедных.

Рассмотрим идею использования ссылки на геттер метод в качестве литерала свойства:

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

Очевидно, это будет работать, только если у вас есть геттер. Но если ваши классы уже следуют конвенции JavaBeans, что чаще всего так, — это нормально.

Как выглядело бы объявление метода validateValue()? Ключевой момент — использование нового типа Function:

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

Используя два параметра типизации мы можем убедиться, что тип бина, свойства и переданного значения корректны. С точки зрения API мы получили то, что нужно: его безопасно использовать и IDE будет даже автоматически дополнять имена методов начинающиеся с Address::. Но как же вывести имя свойства из объекта Function в реализации метода validateValue()?

И тут то начинается веселье, поскольку функциональный интерфейс Function всего лишь объявляет один метод — apply(), который исполняет код функции для переданного экземпляра T. Это похоже не то, что нам было нужно.

ByteBuddy во спасение

Как выясняется, в применении функции и состоит трюк! Создавая прокси-экземпляр типа T, мы имеем цель для вызова метода и получения его имени в обработчике вызовов Proxy. (От переводчика: здесь и далее идёт речь о динамических прокси Java — java.lang.reflect.Proxy).

Java поддерживает динамические прокси из коробки, но эта поддежка ограничивается только интерфейсами. Поскольку наш API должен работать с любыми бинами, в том числе с реальными классами, я собираюсь использовать вместо Proxy отличный инструмент — ByteBuddy. ByteBuddy предоставляет простой DSL для создания классов на лету, то что нам и нужно.

Давайте начнём с определения интерфейса, который бы позволил хранить и получать имя свойства, извлечённое из Method Reference.

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) мы можем получить имя свойства, соответствующее переданному Method Reference, и сохранить его в PropertyNameCapturer (2). Если метод не является геттером, то код выбрасывает исключение (5). Возвращаемый тип геттера не имеет значения, так что мы возвращаем 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();

    // здесь запускам саму валидацию значения
}

После применения функции к созданному прокси, мы приводим тип к PropertyNameCapturer и получаем имя из Method.

Вот так используя немного магии генерации байт-кода, мы применили Method Reference из Java 8 для эмуляции литералов свойств.

Конечно же, будь у нас реальные литералы свойств в языке, нам всем было бы лучше. Я бы разрешил даже работать с приватными свойствами и, наверное, на свойства можно было бы ссылаться из аннотаций. Реальные литералы свойств были бы более аккуратными (без префикса «get») и не выглядели бы как хак.

От переводчика

Тут стоит отметить, что другие хорошие языки уже поддерживают (или почти) подобный механизм:

Если вы вдруг используете c Java проект Lombok, то для него написан байткод генератор времени компиляции.

Вдохновившись описанным в статье подходом, ваш покорный слуга собрал небольшую библиотеку, которая реализует nameOfProperty() для Java 8:

Исходники
Бинарники

Автор: Юрий Артамонов

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js