В статье рассматривается декларативный скриптовый язык JavaPath как альтернатива использованию Java Reflection и способ избежать "сервисного ада" в обособленных приложениях использующих сложные структуры данных.


Описание проблемы


Рассмотрим глубоко вложенную структуру


class A{
   B b;
}
class B{
   C c;
}
class C{
   String name;
}

Если нам нужно присвоить значение полю name класса C, не имея при этом непосредственного доступа к экземпляру A, то на помощь, обычно, приходит промежуточный слой сервисного API.


private A a;
public void setNameService(String name) {
    a.b.c.name = name;
}

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


private A a;
public void setNameService(String name) {
    if(a == null) {
        a = new A();
    }
    if(a.b == null) {
        a.b = new B();
    }
    if(a.b.c == null) {
        a.b.c = new C();
    }
    a.b.c.name = name;
}

Если данный упрощенный пример кажется вам избыточным, то представьте, что setNameService(String name) не является единственным сервисом в вашей программе, и есть другие независимые сервисы, управляющие содержимым полей a, b и c, а сама структура имеет намного больше полей и методов, которые здесь просто не представлены для краткости.


В слабо связанном коде разные части приложения имеют доступ только к публичным интерфейсам API, а значит, каждый раз когда нам потребуется доступ к определенной ветви сложного дерева структуры данных, мы должны оформить это в виде соответствующего метода в сервисном слое API. Количество методов быстро разрастается и поддерживать их становится все сложнее и сложнее. Даже в таком простом примере, существует множество вариантов как клиент может установить значение нужного поля. Можно присвоить значение полю, как в данном примере, можно заменить целиком объект 'C' с предустановленным полем name, можно заменить 'B' или 'A' соответственно.


Проблема еще более усложняется если код клиента физически отделен от соответствующего сервиса, например в случае микро-сервисов или архитектуре построенной на сообщениях. Наконец, в предельном случае может оказаться так, что только клиент обладает полной информацией о конкретных деталях структуры данных, которую он собирается изменить. Такое случается в сервисах работающих с ключ-значение или документ-ориентированными базами данных. Сервис хранит и обеспечивает доступ к данным, но имеет слабое представление о том какие конкретно данные в нем хранятся. Закачивать весь блок данных на сторону клиента, а потом обратно, ради небольшого изменения может быть слишком дорого, опасно, да и не удобно.


На помощь может прийти небольшой проект JavaPath с открытым кодом, который я и хочу здесь представить.


JavaPath и его отличие от других шаблонов инициализации


Проще всего начать с простого примера решения вышеописанной проблемы с помощью JavaPath.


private A a = new A();
private final JavaPath javaPath = new JavaPath(A.class);

public void setNameService(String name) {
    javaPath.evalPath("b.c.name", a, name);
}

Данный пример функционально полностью эквивалентен предыдущей реализации setNameService.


Таким образом, JavaPath — это скриптовый язык и тулкит для глубокого доступа, инициализации и исполнения произвольных цепочек вызовов полей и методов в Java. В языке JavaPath отсутствуют управляющие конструкции. Нет ни циклов, ни ветвлений. JavaPath позволяет декларативно описать доступ к полям и методам в заданной иерархии классов. Поля и методы могут быть не-public. Это особенно полезно для тестирования. JavaPath существенно облегчает нициализацию сложных объектов с private или final полями.


Пример: Строки — мутируемые объекты для JavaPath!


final String str = "VALUE";
JavaPath javaPath = new JavaPath(String.class);
assertEquals("VALUE",str);
//Доступ к private final byte[] value; класса String 
javaPath.evalPath("value",str,"THE HACK".getBytes());
assertEquals("THE HACK",str);

Даже если ваша структура данных проста и не требует какого-то особого доступа к компонентам, но вам необходимо обеспечить произвольный доступ контролируемый на стороне клиента, JavaPath избавит вас от необходимости работать непосредственно с Java Reflection или скриптовыми языками с избыточной для данной задачи сложностью.


Как видно из примеров, парсер JavaPath требует указать корневой класс. Все возможные пути вычисляются относительно него. Результаты вычисления иерархического дерева для класса кешируются и могут быть использованы в дальнейшем.


Основной метод для работы с JavaPath


public Object evalPath(String path, Object root, Object... values);

Метод evalPath получает три параметра


  • сам путь — строка максимально близкая к эквивалентному Java коду
  • корневой объект класса A — именно в нем мы ожидаем найти поле b
  • значения, которые хотим присвоить элементам пути по ходу инициализации. Здесь — последнему элементу указанному в пути — полю name

Элементами пути могут быть не только поля, но и методы. Слегка изменим декларацию класса C. Добавим к нему сеттер.


class C{
    String name;
    public void setName(String name) {
        this.name = name;
    }   
}

Пример использования сеттера очень мало отличается от прямого обращения к полю. Разве что имя поменялось.


A a = new A();
JavaPath javaPath = new JavaPath(A.class);
public void setNameService(String name) {
    javaPath.evalPath("b.c.setName", a, name);
}

JavaPath не требует указывать скобки и имя параметра для сеттера, если это единственный параметр, но, для большей выразительности можно записать и так:


    javaPath.evalPath("b.c.setName($)", a, name);

где $ или $0 — ссылка на первый из списка передаваемых параметров инициализации.


В цепочке вызова


a0.a1.… .an


все элементы


a0.a1.… .an-1


должны быть полями или геттерами, то есть, иметь/возвращать значения не примитивных типов и не null. Последний элемент an это всегда сеттер или поле. Само значение возвращаемое сеттером, если оно есть, или поле, будет возвращено методом evalPath. Не вдаваясь в подробности, можно рассматривать поле как геттер и сеттер самого себя. Синтаксически поля и методы в JavaPath эквивалентны.


Основы синтаксиса JavaPath


Строка JavaPath представляет собой набор элементов пути, разделенных точками. Каждый элемент пути является полем или методом соответствующего класса. Для каждого элемента неявно подразумевается тип и набор параметров. Как правило, парсер сам способен сделать заключение о типе элемента и типах параметров, но, в отдельных случаях потребуется явное указание нужного типа. Вы, как пользователь, заинтересованны в том, чтобы строка пути была
как можно более простой и визуально максимально напоминала не осложненный вызов цепочки эквивалентного Java кода. Но, иногда это невозможно, как правило, если вам необходим доступ к сторонним классам и их API.


Синтаксическая диаграмма


Синтаксическая Диаграмма


Параметры


Элементы JavaPath могут получать параметры. Параметры заключаются в скобки и разделяются запятыми. Если параметры достаточно просты, для того чтобы быть выражены простой строкой, то они могут указываться тут же, в строке. Строка параметра без пробелов и спец символов не требует кавычек. Если вам нужно включить в строку пробел, точку, запятую, и т.п. — заключайте параметр в одинарные или двойные кавычки.


Прямые и обратные ссылки $ и #


Специальные параметры


Прямые Ссылки


На любое значение из массива значений, который получает метод evalPath можно ссылаться с помощью $[:digit:]* Нумерация начинается с ноля. $ без числа означает ссылку на параметр с тем же индексом, что и индекс текущего пути. См. Выполнение нескольких путей


Можно также ссылаться на поля и методы внутри ссылочных значений.


JavaPath Комментарий
"a($)" ссылка на значение с тем же индексом, что и индекс пути
"a($0)" ссылка на первое значение из списка значений
"a($1.name)" ссылка на поле или метод name, который ожидается во втором значении

Обратные ссылки


Каждый последующий элемент пути может ссылаться в качестве параметра на предыдущий или на корневой объект. Ссылка выглядит так: #[:digit:]* Нумерация начинается с корневого объекта и он имеет значение # или #0


Можно также ссылаться на поля и методы внутри обратных ссылок.


JavaPath Комментарий
"a(#)" ссылка на корневой объект
"a(#0.name)" ссылка на поле name в корневом объекте
"a.b(#1)" ссылка на 'a'

Пример инициализации цепочки, имеющей зависимость родитель-потомок.


public class A {
    A parent;
    A child;
    String name;
    public A(A parent) {
        this.parent = parent;
    }
}

A a = new A(null);
JavaPath javaPath = new JavaPath(A.class);
javaPath.evalPath("name",a,"PARENT");
javaPath.evalPath("child(#0).name",a,"CHILD");
javaPath.evalPath("child(#0).child(#1).name",a,"GRAND-CHILD");
assertEquals("PARENT",a.name);
assertEquals("CHILD",a.child.name);
assertEquals("GRAND-CHILD",a.child.child.name);
assertEquals("CHILD",a.child.child.parent.name);
assertEquals("PARENT",a.child.child.parent.parent.name);

Если нужно передать сроку совпадающую с прямой или обратной ссылкой — заключите ее в кавычки.


"a.set('$0')" // строка $0

InLine параметры:


Строки, а также объекты которые естественным образом можно воссоздать из их строкового представления можно передавать непосредственно в строке JavaPath. Если тип не указан, то подразумевается, что метод или поле ожидают строковый параметр. Во всех остальных случаях перед самим значением нужно указать тип.


JavaPath Комментарий
"a.b.c" Не параметризованный вызов цепочки. Неявно предполагается, что если методу evalPath передан хотя бы один параметр, то он будет присвоен последнему элементу в цепи.
"a.b.c($)" Эквивалентный пример, но ссылка на первое значение передана явно.
"a.b.c($0)" $0 эквивалентно $ для единственного пути.
"a.b.c('THE VALUE')" полю c будет присвоено значение 'THE VALUE' если метод evalPath не получил вообще никаких параметров. В противном случае явно переданное значение из массива значений получит приоритет.
"a().b().c" При отсутствии параметров скобки не обязательны, но могут быть полезны для визуального отделения методов от полей
"a(ПРИМЕР).b.c" Инлайн подстановка строкового параметра. Указание типа не требуется для строк
"a.b(int 1024).c" Инлайн подстановка примитивного целого типа
"a.b(Int 1024).c" Инлайн подстановка типа Integer
"a.b(int 1024,'БУФФЕР ОБМЕНА').c" Инлайн подстановка двух параметров, один из которых содержит пробел
a.setX(PhoneType CELL) Ожидается значение пользовательского типа enum PhoneType{HOME,CELL,WORK}, который может быть представлен строкой "CELL"

Для классов имеющих конструктор, принимающий единственную строку, либо имеющих статическую фабрику valueOf (в частности, все enum классы и все примитивные типы) конвертация произойдет без каких либо дополнительных усилий.


Для пользовательских классов, если вариант с конструктором или статическим методом valueOf по каким-либо причинам не подходит, можно создать собственный StringConverter, который обеспечит необходимые преобразования.


Инициализация полей


Если поле-геттер имеет значение null и класс имеет дефолтный конструктор, то никаких действий не требуется. Инициализация произойдет автоматически и только один раз.


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


"(T a).b"

Пример: поле map не может быть создано без указания конкретной имплементации.


public class A {
    Map<String,String> map;
}

A a = new A();
JavaPath javaPath = new JavaPath(A.class);
//Первый вызов должен знать конкретную имплементация поля map
javaPath.evalPath("(HashMap map).put(firstName,$)", a, "John");
//Второму вызову тип не требуется, так как поле уже существует.
javaPath.evalPath("map.put(lastName,$)", a, "Silver");

Вызов конструктора может быть параметризован.


//Вариант инициализации HashMap с начальной емкостью и фактором загрузки.
//Обратите внимание на кавычки в которые помещено значение 0.8 
javaPath.evalPath("(HashMap map(int 100,float '0.8')).put(firstName,$)", a, "John");

Хотя это и выглядит как вызов метода с параметрами, инициализация поля происходит только если поле имеет значение null. Последующие вызовы путей с участием поля map будут использовать уже созданный экземпляр.


Пример использования пользовательских типов


enum PhoneType{HOME,CELL,WORK}
public static class A {
    String firstName;
    String lastName;
    Map<PhoneType, Set<String>> phones;
    Map<String, PhoneType> reversedPhones;
}

A a = new A();
ClassRegistry  classRegistry = new ClassRegistry();
classRegistry.registerClass(PhoneType.class,PhoneType.class.getSimpleName());
JavaPath javaPath = new JavaPath(A.class,classRegistry);

javaPath.evalPath("(map phones).put(PhoneType WORK)", a, new HashSet<>());
javaPath.evalPath("phones.computeIfAbsent(PhoneType HOME,key->new HashSet).@", a);
javaPath.evalPath("phones.computeIfAbsent(PhoneType CELL,key->new HashSet).@", a);
javaPath.evalPath("(map reversedPhones).@", a);

javaPath.evalPath("firstName", a, "John");
javaPath.evalPath("lastName", a, "Smith");

javaPath.evalPath("phones.get(PhoneType CELL).add", a, "1-101-111-2233");
javaPath.evalPath("phones.get(PhoneType HOME).add", a, "1-101-111-7865");
javaPath.evalPath("phones.get(PhoneType WORK).add", a, "1-105-333-1100");
javaPath.evalPath("phones.get(PhoneType WORK).add($)", a, "1-105-333-1104");

javaPath.evalPath("reversedPhones.put($,PhoneType CELL)", a, "1-101-111-2233");
javaPath.evalPath("reversedPhones.put($,PhoneType HOME)", a, "1-101-111-7865");
javaPath.evalPath("reversedPhones.put($,PhoneType WORK)", a, "1-105-333-1100");
javaPath.evalPath("reversedPhones.put($,PhoneType WORK)", a, "1-105-333-1104");

Префикс @


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


Оператор альтернативной инициализации ||


Если геттер в цепочке может вернуть null, то JavaPath предлагает возможность сделать попытку вызвать соответствующий сеттер или иной метод инициализации объекта. После этого геттер будет вызван еще один раз.


JavaPath Комментарий
"getA??setA($1).name($0)" Если getA вернет null будет вызван метод setA, затем снова getA
"getA??init.name($0)" вызов абстрактного метода инициализации без параметров

Оператор явного указания фабрики экземпляров ::


Если для создания объекта используется не конструктор, а статический метод-фабрика, то он может быть указан явно с помощью оператора ::


JavaPath Комментарий
"(UserInfo::newInstance userInfo).phone.ext" Экземпляр UserInfo создается с помощью фабрики UserInfo.newInstance()
"(UserInfo::newInstance userInfo(John,Smith)).phone.ext" Экземпляр UserInfo создается с помощью параметризованной фабрики UserInfo.newInstance(String a, String b)
"a.b(Integer::valueOf 100).c" Метод конвертации строки в целое. Приведено для иллюстрации. Фабрики для базовых типов не требуют явного указания
"(HashMap::new map).get" псевдо-метод new описывает вызов конструктора. Не требует явного указания.

ClassRegistry


Вспомогательный класс для регистрации алиасов для пользовательских классов, фабрик конвертации строк параметров в произвольные типы, и т.п. Время действия объекта ClassRegistry в основном ограниченно временем построения дерева зависимостей классов, поэтому все изменения в него должны вноситься до создания экземпляра JavaPath.


ClassRegistry позволяет вносить изменения глобально или локально. Глобально устанавливаемые изменения не влияют на уже имеющиеся экземпляры ClassRegistry, и соответственно JavaPath.


Пример локальной регистрации коротких имен для пользовательского класс PhoneType


ClassRegistry  classRegistry = new ClassRegistry();
classRegistry.registerClass(PhoneType.class,PhoneType.class.getSimpleName(),"Phone");
JavaPath javaPath = new JavaPath(A.class,classRegistry);

После регистрации PhoneType доступен не только по полному, но и по короткому имени, а также по имени Phone.


ClassRegistry так же позволяет регистрировать конвертеры строк в произвольные типы. Если конвертер зарегистрирован явно, то во многих случаях не потребуется использования оператора :: и даже явного указания типа.


Пример глобальной регистрации конвертора строки в пользовательский тип А. Все последующие экземпляры ClassRegistry будут иметь этот конвертер. Обратите внимание, что StringConverter — это функциональный интерфейс.


public class A {
...
static {
    ClassRegistry.registerGlobalStringConverter(A.class,A::stringToA); 
}
public static A stringToA(String str) {
       A a = new A("{"+str+"}"); //Делаем что-то необычное
       return a;
    }
}

public class B {
    A a;
}

JavaPath javaPath = new JavaPath(B.class); //имеет конвертер A::stringToA

Аннотации @PathElement и @NoPathElement


@PathElement устанавливает альтернативные имена для поля или метода.


public class A {
    String name; //доступ к полю маскируется сеттерм с аннотацией "name"
    @PathElement({"name","first_name","firstName"})
    public void setName(String name) {
        this.name = name;
    }
}

Обратите внимание, что метод setName теперь доступен под именем "name", совпадающем с именем поля. В этом случае сеттеру будет отдан приоритет перед полем с тем же именем.


@NoPathElement исключает метод или поле из области видимости JavaPath.


public class A {
    StringBuilder stringBuilder = new StringBuilder();

    @NoPathElement
    private final String protectedField = "IMMUTABLE BY JAVA PATH!";

    public void add(String str) {
        stringBuilder.append(str == null ? "N/A" : str);
    }

    @NoPathElement
    public void add(Object val) {
        stringBuilder.append(val);
    }
}

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




Выполнение нескольких путей


Строка JavaPath может включать в себя несколько путей. Каждый путь отделяется от предыдущего точкой с запятой или новой строкой.


public class A {
    String firstName;
    String lastName;
    int age;
}

A a = new A();
JavaPath javaPath = new JavaPath(A.class);
javaPath.evalPath("firstName; lastName; age", a, "John","Smith",55);
// $ ссылается на значение с тем же индексом, что и индекс пути
javaPath.evalPath("firstName($); lastName($); age($)", a, "John","Smith",55);
// Во избежание путаницы в сложных скриптах используйте явную нумерацию.
javaPath.evalPath("firstName($0); lastName($1); age($2)", a, "John","Smith",55);

Параметрическая ссылка без номера '$', а так же отсутствие ссылки в последнем элементе для множественных путей означает номер параметра с тем же индексом, что и путь. Так, в примере firstName($) будет присвоено значение "John" (первый путь, первое значение), а age($) — целое число 55 (третий путь, третье значение). Во избежание путаницы в сложных скриптах используйте явную нумерацию.


Обратные ссылки, кроме корневого объекта, не наследуются и вычисляются заново для каждого пути.


В случае множественных путей возвращается значение полученное для последнего из них. Остальные значения будут утеряны.




Инициализация корневых объектов из JavaPath


Все предыдущие примеры исходили из предположения, что корневой объект так или иначе доступен. Но что если он отсутствует, и должен быть создан до того как его параметры будут проинициализированы? Семейство методов initPath позволяет решить
проблему.


Строка JavaPath для инициализации немного отличается от предыдущих примеров. Она начинается с дополнительного элемента пути, символизирующего корневой объект. Поскольку данного поля или метода просто физически не существует ни в одном классе, то это может быть любое имя, например root. Так же можно использовать символы обратных ссылок на корневой элемент # и #0


Методы initPath возвращают экземпляр созданного корневого объекта.


Примеры:


// Тип указан непосредственно в строке JavaPath. Потребуется кастинг
Object instanceOfA = javaPath.initPath("(com.my.project.A #0).b", "test");
// Тип передан как параметр метода initPath
A instanceOfA = javaPath.initPath(A.class, "#.b", "test");
// Имя корневого объекта может быть любым идентификатором, # или #0
A instanceOfA = javaPath.initPath(A.class, "root.b", "test");



Кеширование путей


Метод setEnablePathCaching(boolean enableCaching) класса JavaPath позволяет сохранять результат работы парсера в кеше. Не следует путать с неотключаемым кешированием иерархии полей и методов классов. Кеш путей отключен по умолчанию, потому что может привести к неконтролируемому расходу памяти, если пути вычисляются динамически.


Пример — в кеше окажется три разных пути:


evalPath("user.name('John'));"
evalPath("user.name('Peter'));"
evalPath("user.name('Mike'));"

Вместо этого стоит использовать явную передачу изменяемых значений. Нижний пример сохранит один путь.


evalPath("user.name($0)","John");
evalPath("user.name($0)","Peter");
evalPath("user.name($0)","Mike");

Зависимости


Java8 и старше


Maven Repository


<dependency>
    <groupId>com.aegisql</groupId>
    <artifactId>java-path</artifactId>
    <version>0.2.0</version>
</dependency>