Статически проверяемые ссылки на свойства 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(BeanPath<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 = BeanPath.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.
    Такой вариант имеет ряд плюсов:

    1. Нулевые накладные расходы в рантайме.
    2. Возможность захватить типизацию «корня» цепочки.
    3. В API сгенеренных классов можно отфильтровать лишние методы (которые не относятся к свойствам).

    P. S. Похожий функционал есть в Querydsl, и реализован он так же, но сильно завязан на инфраструктуру Querydsl.
    CUSTIS
    65.45
    Company
    Share post

    Comments 16

      0
      Интересно. Я уже предлагал похожий способ решения этой проблемы. Ваш способ синтаксически более лаконичен.
        0
        Да, использование замыкания здесь напраашивается, оно позволяет:
        1. Отказаться от ThreadLocal (а это, на минуточку, глобальное состояние)
        2. Сократить API, выкинув метод root()

        Но, к сожалению, громоздкий синтаксис (prior to Java 8) губит всю идею.

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

        Сравните:

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

        и

        tableBuilder.addColumn( $(Account.class, (account) -> account.getCustomer().getName() ) );
        

        Все равно громоздко. К тому же, у нас была задача использовать это в уже существующих проектах (не Java 8).

        Правда у лямбд здесь есть неоспоримое преимущество, они позволяют захватить типизацию «рута»:

        public static <R, T> BeanPath<R, T> $(Class<R> root, CallChain<R, T> callChain) (...)
        

        А это уже повод задуматься о том чтобы реализовать такой синтаксис в следующей версии.
      • UFO just landed and posted this here
          +1
          Скорее, позволяют решить проблему более изящно.
            0
            Не решают.

            Здесь, видимо, имеется ввиду возможность C# (я — нуб в C# :-) получать из лямбды Expression trees, а ходить по ним в рантайме. Но лямбды Java так не работают, если не углубятся в тонкости, это скорее лаконичный синтаксис создания анонимных классов (и да простят меня за эти слова создатели лямбд Java).

            А на счет изящности — это спорный вопрос (смотри коммент выше).
              0
              По мне, отказаться от глобального состояния и укоротить синтаксис — это и значит, сделать более изящно.
            0
            Интересно решение, спасибо за статью. Где же вы были раньше, пока не было java8)
              0
              Я решал эту проблему немножко по-другому. Каждый класс, к которому шло обращение через названия свойств, объявлял строковые константы вида _PROPETY_NAME для свойства propertyName. И был юнит-тест, который пробегался по всем классам и проверял соответствие объявленных констант этого вида реальности.

              // простите, промахнулся
                0
                Но это же поддерживать надо и тяжело, да и код лишний. Автогенерация, как мне кажется, решает проблему изящнее.
                  0
                  Поддерживать особенно нечего, тяжелого тоже ничего нет, немножко нудно, это да. Подход из статьи выглядит симпатичней, но у него лишние расходы в рантайме (хоть и небольшие, но копейка рубль бережёт, если мы говорим про библиотеный код).
                    0
                    Вообще-то я не автор. Но в принципе ровно так же можно генерировать в компайл-тайме константа, которые генерируете вы.
                0
                Либо я чего-то сильно не понимаю, либо Java 8 принципиально здесь ничего не меняет. Другой синтаксис но от динамических прокси, чтобы вытащить имена свойств, никуда не уйти.
                0
                Мы тоже в свое время использовали доллар в качестве имени метода для своих целей. Но кажется, что это не совсем правильная практика, т.к. Java использует тот же символ для работы с внутренними классами и другими сущностями. stackoverflow.com/questions/7484210/what-is-the-meaning-of-in-a-variable-name Не наблюдали ли вы с этим проблем?
                  0
                  С проблемами не сталкивался.

                  Действительно, хотя доллар вполне законный символ в идентификаторе, по соглашению, он зарезервирован для сгенерированого кода (в частности, самим javac), поэтому стоит избегать его использования в «ваших» именах. Чтобы не поймать конфликт имен. Но это не строгий запрет.

                  Некоторые кодогенераторные фрэймворки используют $ для своих «искусственных» переменных/методов. Например, так делает Lombok.

                  Не используйте $, если у вас нет веской на то причины.

                  Здесь причина: 1) максимально сократить код и 2) ввести как будто бы новую синтаксическую конструкцию.
                  0
                  Напишите пожалуйста в ридми на гитхабе эти примерчики, чтобы дока была рядом с кодом — ну и там же можно и эту статейку линкануть для двусторонней связи
                    0
                    Ага, написал. Спасибо что не поленились сделать pull request.

                  Only users with full accounts can post comments. Log in, please.