От переводчика: к переводу этой статьи меня подтолкнула обида от отсутствия оператора 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# — nameOf оператор
- Groovy и Scala — есть известные хаки с метапрограммированием и макросами
- Kotlin — есть нормальный синтаксис
User::login.name
Если вы вдруг используете c Java проект Lombok, то для него написан байткод генератор времени компиляции.
Вдохновившись описанным в статье подходом, ваш покорный слуга собрал небольшую библиотеку, которая реализует nameOfProperty() для Java 8:
Исходники
Бинарники
Комментарии (12)
Lure_of_Chaos
18.08.2018 20:51+1А еще для этих целей используется метамодель, и пишется проще: users.get(User_.username). Особенно если сборку настроить для автоматической генерации метамодели
jreznot Автор
18.08.2018 20:53А о какой метамодели речь? Нужно ли повторять код классов? В Kotlin вот вообще круто, прямо в компилятор встроено.
Lure_of_Chaos
18.08.2018 20:57Речь о canonical metamodel в JPA
jreznot Автор
18.08.2018 21:26Да, это тоже нормальное решение, но получается, что мы генерируем ещё код по классам сущностей, дополнительный boilerplate. Это всё мог бы давать и компилятор, как в случае с C#.
Borz
18.08.2018 22:00мышки плакали и кололись, но продолжали. Чем JPA MetaModel не устраивает? в том же, упомянутом в статье, Hibernate всё уже давно есть: https://docs.jboss.org/hibernate/orm/5.0/topical/html/metamodelgen/MetamodelGenerator.html
jreznot Автор
18.08.2018 22:23Не все любят генерировать код дополнительный, когда все эти сведения и так есть в коде сущностей. Согласитесь, если бы была поддержка компилятором — было бы всем лучше.
Foror
19.08.2018 11:56Я для этого создаю в классе специальный enum, тело которого автоматически генерируется. Дальше делаю так: User.field.password Но конечно на уровне ЯП было бы лучше.
sshikov
Поздравляю, автор оригинала изобрел то, что в разных видах существует уже лет 10 :) Причем было сделано задолго до Java 8.
Этим пользуются очень и очень многие широко известные продукты. Ну хоть Lambdaj например. Или Guava. Или Hamcrest.
В сущности, достаточно статического метода, которому вы передаете Address.class, чтобы на выходе вернуть обертку, которая позволяет типобезопасно работать со свойствами и методами этого класса, и без всяких там city в виде строкового литерала. Тот факт, что конкретный Bean Validation API этого не умеет, говорит вероятно лишь о том, что его создавали во времена, когда еще не было Generics. Или он просто плохо написан.
jreznot Автор
Найдите, пожалуйста, как это сделано в Guava для свойств. Статья ничего не говорит про класс литералы. Тут речь о том, чтобы получить доступ к аннотациям над полями, при этом типобезопасно. BeanValidation как раз нуждается в аннотациях, а не только в значении поля.
sshikov
Не исключаю, что не так что-то понял, но где у вас про аннотации? Где-то ближе к концу?
jreznot Автор
Тут действительно автор этот момент не поясняет. Например, в C# nameOf используется для биндинга данных с учетом атрибутов свойства, а для их получения нужна рефлексия.
yarosroman
В WPF для биндинга используется такая вещь как DependencyProperty, да и биндинги существовали задолго до nameOf