От переводчика: к переводу этой статьи меня подтолкнула обида от отсутствия оператора 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:

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

Комментарии (12)


  1. sshikov
    18.08.2018 18:37
    -1

    Поздравляю, автор оригинала изобрел то, что в разных видах существует уже лет 10 :) Причем было сделано задолго до Java 8.

    Этим пользуются очень и очень многие широко известные продукты. Ну хоть Lambdaj например. Или Guava. Или Hamcrest.

    В сущности, достаточно статического метода, которому вы передаете Address.class, чтобы на выходе вернуть обертку, которая позволяет типобезопасно работать со свойствами и методами этого класса, и без всяких там city в виде строкового литерала. Тот факт, что конкретный Bean Validation API этого не умеет, говорит вероятно лишь о том, что его создавали во времена, когда еще не было Generics. Или он просто плохо написан.


    1. jreznot Автор
      18.08.2018 18:39

      Найдите, пожалуйста, как это сделано в Guava для свойств. Статья ничего не говорит про класс литералы. Тут речь о том, чтобы получить доступ к аннотациям над полями, при этом типобезопасно. BeanValidation как раз нуждается в аннотациях, а не только в значении поля.


      1. sshikov
        18.08.2018 18:54
        +1

        Не исключаю, что не так что-то понял, но где у вас про аннотации? Где-то ближе к концу?


        1. jreznot Автор
          18.08.2018 20:23

          Тут действительно автор этот момент не поясняет. Например, в C# nameOf используется для биндинга данных с учетом атрибутов свойства, а для их получения нужна рефлексия.


          1. yarosroman
            19.08.2018 10:20

            В WPF для биндинга используется такая вещь как DependencyProperty, да и биндинги существовали задолго до nameOf


  1. Lure_of_Chaos
    18.08.2018 20:51
    +1

    А еще для этих целей используется метамодель, и пишется проще: users.get(User_.username). Особенно если сборку настроить для автоматической генерации метамодели


    1. jreznot Автор
      18.08.2018 20:53

      А о какой метамодели речь? Нужно ли повторять код классов? В Kotlin вот вообще круто, прямо в компилятор встроено.


      1. Lure_of_Chaos
        18.08.2018 20:57

        Речь о canonical metamodel в JPA


        1. jreznot Автор
          18.08.2018 21:26

          Да, это тоже нормальное решение, но получается, что мы генерируем ещё код по классам сущностей, дополнительный boilerplate. Это всё мог бы давать и компилятор, как в случае с C#.


  1. Borz
    18.08.2018 22:00

    мышки плакали и кололись, но продолжали. Чем JPA MetaModel не устраивает? в том же, упомянутом в статье, Hibernate всё уже давно есть: https://docs.jboss.org/hibernate/orm/5.0/topical/html/metamodelgen/MetamodelGenerator.html


    1. jreznot Автор
      18.08.2018 22:23

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


  1. Foror
    19.08.2018 11:56

    Я для этого создаю в классе специальный enum, тело которого автоматически генерируется. Дальше делаю так: User.field.password Но конечно на уровне ЯП было бы лучше.