Сергей Кошель, ведущий разработчик, опубликовал в нашем корпоративном блоге на «Хабрахабре» статью «Статически проверяемые ссылки на свойства Java-бинов». В ней он рассказал о недостатках синтаксиса Java, связанных с невозможностью сослаться на свойство бина, о конкретных рабочих задачах, при работе над которыми необходимо было устранить данные недостатки, а также представил разработанное в компании решение и аспекты его использования.

Когда долго и серьезно используешь какой-либо инструмент, неминуемо возникают претензии к нему — неудобства, с которыми сперва миришься, но в какой-то момент понимаешь, что проще один раз исправить, чем все время страдать. Хорош тот инструмент, который позволяет допилить сам себя.

Java — хороший инструмент, поэтому об одном таком неудобстве и о том, как мы его исправляли, и пойдет речь.

Итак, неудобство

В Java нет синтаксиса, позволяющего сослаться на свойство бина. Проще пояснить на примере.

Допустим, есть Account, у которого есть свойство customer типа Customer, у которого есть свойство name. Иными словами:

public class Account {
   public Customer getCustomer() { ... }
}
 
public class Customer {
   public String getName() { ... }
}

И есть TableBuilder, который умеет создавать таблички на интерфейсе для показа списка бинов, нужно лишь сообщить ему, какие их свойства (возможно, вложенные) мы хотим вывести, а он уже сделает всю рутинную работу.

Как сказать, что мы хотим показать name customer’а Account’а? Обычно используют строковые литералы:

TableBuilder<Account> tableBuilder = TableBuilder.of(Account.class);
 
…
 
tableBuilder.addColumn(«customer.name»);

Недостаток этого способа в том, что компилятор не знает, что это не просто строка и не может проверить ее корректность, а, значит, все опечатки обернутся ошибками только во время исполнения. По той же причине среда разработки не сможет подсказать нам, какие свойства есть у Customer’а. И даже если мы все проверим и отладим, первый же рефакторинг разрушит наши старания.

Есть здесь еще одно, менее очевидное, неудобство: типизация addColumn(String) никак не подсказывает нам, что этот метод ожидает не абы какую строку, а цепочку свойств.

Хочется, чтобы компилятор всё проверил, среда подсказала, а рефакторинг не сломал. Не так уж это и много, учитывая, что вся необходимая для этого информация уже есть.

Казалось бы, задача — нерешаемая: в Java действительно нет синтаксической конструкции, позволяющей сослаться на член класса без обращения к нему. Однако, это уже давно не мешает mock-фрэймворкам изящно и строго выражать «когда будет вызван метод…», как например умеет Mockito:

Account account = mock(Account.class);
 
when(account.getCustomer()).thenReturn();

Метод mock() создает и возвращает прокси, который выглядит как Account, но ведет себя совсем по-другому: запоминает информацию о вызванном методе в ThreadLocal-переменной, которую потом извлекает и использует when().

Можно использовать такой же трюк для решения нашей задачи:

Account account = root(Account.class);
 
tableBuilder.addColumn($(account.gertCustomer().getName()));

root() возвращает прокси, который запоминает вызванные методы в ThreadLocal-переменную и возвращает следующий прокси, позволяя писать цепочки вызовов, которые превратятся в цепочку свойств.

$() возвращает не строку, а объект типа BeanPath, который представляет цепочку свойств в объектно-ориентированном виде. Можно перемещаться по отдельным элементам этой цепочки (для каждого элемента сохраняется имя и тип) или преобразовать в уже знакомую нам строку:

$(account.gertCustomer().getName()).toDotDelimitedString()
 
     => «customer.name»

$(), помимо основной своей функции, захватывает тип цепочки (последнего свойства в цепочке), а, значит, позволяет добавить еще капельку типизации в TableBuilder:

public <T> ColumnBuilder<T> addColumn(BaenPath<T> path) {}

Вот такой небольшой фрэймворк мы написали в CUSTIS, немного попользовались им сами, а теперь выложили на GitHub.

Аспекты использования

Реализация через динамическое проксирование налагает следующие ограничения.

Во-первых, «корень» и незамыкающие свойства в цепочке не могут быть final-классами (в том числе enum’ом, строкой, j.l.Integer и т. д.). Фрэймворк не может проксировать их и возвращает null:

$(account.getCustomer().getName().length()) // => NPX!

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

Во-вторых, геттеры должны быть видимы для фрэймворка, то есть не должны быть private или package-local.

А вот конструктора по умолчанию и вообще публичного конструктора может и не быть — прокси инстанцируется в обход конструктора. Поскольку, законным способом это сделать нельзя, используется проприетарный для HotSpot JVM интринзик sun.misc.Unsafe.allocateObject(), что делает фрэймворк непереносимым на другие JVM.

«Руты» можно и нужно переиспользовать, они не содержат состояния:

Account account = root(Account.class);
 
tableBuilder.addColumn($(account.getCustomer().getName()));
 
tableBuilder.addColumn($(account.getNumber()));
 
tableBuilder.addColumn($(account.getOpenDate()));

Методы root() и $() можно алиасить, так как это просто статические методы:

public class BeanPathMagicAlias {
 
   public static <T> BeanPath<T> path(T callChain) {
 
       return BeanPathMagic.$(callChain);
 
   }
 
}

Можно это использовать для переименования методов в угоду вкусу или чтобы создать полезный шорткат. В частности, один такой уже объявлен в beanpath:

public static String $$(Object callChain)
 
{ return $(callChain).toDotDelimitedString(); }

Пригодится для использования beanpath в коде, который ожидает строковые литералы.

Инстанс BeanPath можно сконструировать и вручную — его поведение полностью определяется состоянием, которое задается при конструировании. Так:

BeanPath<String> bp1 = $(account.getCustomer().getName());
 
BeanPath<String> bp2 = BaenPath.root(Account.class)
 
 .append("customer", Customer.class)
 
 .append("name", String.class);
 
bp1.equals(bp2) // => true

Это может пригодиться, чтобы обойти упомянутые выше ограничения (если в цепочке оказался final-класс или нет публичных геттеров). При этом корректность цепочки остается на совести разработчика.

Планы на будущее

Сейчас beanpath доступен только в исходных кодах. Поэтому прежде всего хочется наладить его полноценную сборку и деплой в Maven Central.

Потом заменить использование sun.misc.Unsafe на Objnesis, чтобы сделать beanpath переносимым.

Ну и совсем на перспективу, подойти к решению задачи с другого края: использовать статическую кодогенерацию, а-ля JPA static metamodel. Такой вариант имеет ряд плюсов: