- PVSM.RU - https://www.pvsm.ru -

Использование возможностей Groovy DSL для конфигурации Java-приложения

Предыстория

Всем привет! Я хотел бы рассказать историю о страшных конфигах и как их удалось причесать и сделать вменяемыми. Я работаю над довольно большим и относительно старым проектом, который постоянно допиливается и разрастается. Конфигурация задается с помощью маппинга xml-файлов на java-бины. Не самое лучшее решение, но оно имеет свои плюсы — например, при создании сервиса можно передать ему бин с конфигурацией, отвечающий за его раздел. Однако, есть и минусы. Самый существенный из них — нет нормального наследования профилей конфигурации. В какой-то момент я осознал, что для того, чтобы поменять одну настройку, я должен отредактировать около 30 xml-файлов, по одному для каждого из профилей. Так больше продолжаться не могло, и было принято волевое решение все переписать.

Требования

  • Наследование и переопределение (или fallback). Должна быть возможность задать некий базовый профиль, унаследовать от него дочерние и переопределить или добавить в них те места, которые необходимо
  • Маппинг в java-бины. Переписывать по всему проекту использование конфигурации с бинов на проперти вида mongodb.directory.host не хотелось, использовать map-ы из map-ов тоже.
  • Возможность писать в конфиге комментарии. Не критично, но удобно и приятно.

Хотелось бы, чтобы конфиг выглядел примерно так:

Типичный DSL-скрипт на groovy

name = "MyTest"
description = "Apache Tomcat"

http {
    port = 80
    secure = false
}
https {
    port = 443
    secure = true
}

mappings = [
        {
            url = "/"
            active = true
        },
        {
            url = "/login"
            active = false
        }
]

Как я этого добился — под катом.

Может, для этого уже есть библиотека?

Скорее всего, да. Однако, из тех, что я нашел и посмотрел, мне ничего не подошло. Большинство из них рассчитаны на чтение конфигов, объединение их в один большой и затем работу с полученным конфигом через отдельные проперти. Маппинг на бины почти никто не умеет, а писать несколько десятков адаптеров-конвертеров слишком долго. Самой перспективной показалась lightbend config [1], с ее симпатичным форматом HOCON и наследованием/переопределением из коробки. И она даже почти смогла заполнить java-бин, но, как оказалось, она не умеет map-ы и очень плохо расширяется. Пока я с ней экспериментировал, на получившиеся конфиги посмотрел коллега и сказал: "Чем-то это напоминает Groovy DSL". Так было принято решение использовать именно его.

Что это такое?

DSL (domain-specific language, предметно-ориентированный язык) — язык, "заточенный" под определенную область применения, в нашем случае — под конфигурацию конкретно нашего приложения. Пример можно посмотреть в спойлере перед катом.

Запускать groovy-скрипты из java-приложения легко. Нужно всего лишь добавить groovy в зависимости, например, Gradle

compile 'org.codehaus.groovy:groovy-all:2.3.11'

и использовать GroovyShell

  GroovyShell shell = new GroovyShell();
  Object value = shell.evaluate(pathToScript);

Как это работает?

Вся магия основывается на двух вещах.

Делегирование

Для начала, скрипт на groovy компилируется в байткод, для него создается свой класс, а при запуске скрипта вызывается метод run() этого класса, содержащий весь код скрипта. Если скрипт возвращает какое-то значение, то мы можем получить его как результат выполнения evaluate(). В принципе, можно было бы в скрипте создавать наши бины с конфигурацией и возвращать их, но в таком случае мы не получим красивого синтаксиса.

Вместо этого мы можем создать скрипт специального типа — DelegatingScript [2]. Его особенность в том, что ему можно передать объект-делегат, и все вызовы методов и работа с полями будут делегироваться ему. В документации по ссылке есть пример использования.
Создадим класс, который будет содержать наш конфиг

@Data
public class ServerConfig extends GroovyObjectSupport {
    private String name;
    private String description;
}

@Data — аннотация из библиотеки lombok [3]: добавляет геттеры и сеттеры к полям и реализует toString, equals и hashCode. Благодаря ей POJO превращается в бин.

GroovyObjectSupport — базовый класс для "java-объектов, которые хотят казаться groovy-объектами" (как написано в документации). Позже я покажу, для чего именно он нужен. На данном этапе можно обойтись без него, но пусть будет сразу.

Теперь создадим скрипт, который будет заполнять его поля.

name = "MyTestServer"
description = "Apache Tomcat"

Тут все очевидно. Пока, как вы видите, мы не используем каких-то фич DSL, о них я расскажу позже.

И, наконец, запустим его из джавы

CompilerConfiguration cc = new CompilerConfiguration();
cc.setScriptBaseClass(DelegatingScript.class.getName()); // благодаря этой настройке все создаваемые groovy скрипты будут наследоваться от DelegatingScript
GroovyShell sh = new GroovyShell(Main.class.getClassLoader(), new Binding(), cc);
DelegatingScript script = (DelegatingScript)sh.parse(new File("config.groovy"));
ServerConfig config = new ServerConfig(); // наш бин с конфигурацией
script.setDelegate(config);
// благодаря предыдущей строчке run() выполнится "в контексте" объекта config и присвоит ему поля name и description
script.run();
System.out.println(config.toString());

ServerConfig(name=MyTestServer, description=Apache Tomcat) — результат lombok-овской реализации toString().

Как видите, все довольно просто. Конфиг — настоящий исполняемый groovy-код, в нем можно использовать все фичи языка, например, подстановки

def postfix = "server"
name = "MyTest ${postfix}"
description = "Apache Tomcat ${postfix}"

вернет нам ServerConfig(name=MyTest server, description=Apache Tomcat server)

И в этом скрипте даже можно ставить брейкпоинты и дебажить!

Вызов методов

Теперь перейдем к собственно DSL. Допустим, мы хотим добавить в наш конфиг настройки коннекторов. Выглядят они примерно так:

@Data
public class Connector extends GroovyObjectSupport {
    private int port;
    private boolean secure;
}

Добавим поля для двух коннекторов, http и https, в наш конфиг сервера:

@Data
public class ServerConfig extends GroovyObjectSupport {
    private String name;
    private String description;

    private Connector http;
    private Connector https;
}

Мы можем задать коннекторы из скрипта с помощью вот такого groovy-кода

import org.example.Connector
//...
http = new Connector();
http.port = 80
http.secure = false

Результат выполнения

ServerConfig(name=MyTest, description=Apache Tomcat, http=Connector(port=80, secure=false), https=null)

Как видите, это сработало, но, конечно же, для конфигурации такой синтаксис совершенно не подходит. Перепишем конфиг так, как хотелось бы, чтобы он выглядел:

name = "MyTest"
description = "Apache Tomcat"

http {
    port = 80
    secure = false
}
https {
    port = 443
    secure = true
}

Результат - исключение

Exception in thread "main" groovy.lang.MissingMethodException: No signature of method: config.http() is applicable for argument types: (config$_run_closure1) values: [config$_run_closure1@780cb77].

Похоже, мы пытаемся вызвать метод http(Closure), и groovy не может найти его ни у нашего объекта-делегата, ни у скрипта. Мы могли бы, конечно, объявить его в классе ServersConfig:

 public void http(Closure closure) {
        http = new Connector();
        closure.setDelegate(http);
        closure.setResolveStrategy(Closure.DELEGATE_FIRST);
        closure.call();
    }

И аналогичный — для https. На этот раз все хорошо:

Результат выполнения

ServerConfig(name=MyTest, description=Apache Tomcat, http=Connector(port=80, secure=false), https=Connector(port=443, secure=true))

Здесь надо пояснить, что же мы сделали, потому что это первый шаг к DSL. Мы объявили метод, который принимает параметром groovy.lang.Closure, создает новый объект для поля нашего конфига, делегирует его полученному замыканию и выполняет код замыкания. Строка

closure.setResolveStrategy(Closure.DELEGATE_FIRST);

означает, что при обращении к полям или методам groovy будет сначала смотреть на делегат, и только потом, если не найдет ничего подходящего — на замыкание. Для скрипта эта стратегия используется по умолчанию, для замыкания ее надо устанавливать вручную.

Заголовок спойлера

Библиотека logback [4], имеющая возможность конфигурации через groovy, использует именно такой подход. Они явным образом реализовали все методы, которые используются в их DSL.

В принципе, у нас уже есть некий DSL, но он далек от идеального. Во-первых, хотелось бы избежать ручного написания кода для установки каждого поля, а во-вторых, хотелось бы избежать дублирования кода для всех классов бинов, которые используются у нас в конфиге. И здесь нам на помощь приходит второй компонент магии groovy DSL...

methodMissing()

Каждый раз, когда groovy встречает вызов метода, отсутствующего у объекта, он пытается вызвать methodMissing(). В качестве параметров туда передается имя метода, который попытались вызвать, и список его аргументов. Уберем из класса ServerConfig методы http и https и объявим вместо них следующее:

public void methodMissing(String name, Object args) {
    System.out.println(name + " was called with " + args.toString());
}

args на самом деле имеет тип Object[], но groovy ищет метод именно с такой сигнатурой. Проверим:

http was called with [Ljava.lang.Object;@16aa0a0a
https was called with [Ljava.lang.Object;@691a7f8f
ServerConfig(name=MyTest, description=Apache Tomcat, http=null, https=null)

То, что нужно! Осталось только развернуть аргументы и в зависимости от типа параметра устанавливать значения полей. В нашем случае туда передается массив из одного элемента класса Closure. Сделаем, например, вот так:

 public void methodMissing(String name, Object args) {
        MetaProperty metaProperty = getMetaClass().getMetaProperty(name);
        if (metaProperty != null) {
            Closure closure = (Closure) ((Object[]) args)[0];
            Object value = getProperty(name) == null ?
                    metaProperty.getType().getConstructor().newInstance() :
                    getProperty(name);
            closure.setDelegate(value);
            closure.setResolveStrategy(Closure.DELEGATE_FIRST);
            closure.call();
            setProperty(name, value);
        } else {
            throw new IllegalArgumentException("No such field: " + name);
        }
    }

проверки и исключения

Я опускаю почти все проверки и ловлю исключений, чтобы не захламлять код. В реальном проекте, естественно, прямо так делать нельзя.

Здесь мы видим сразу несколько вызовов, специфичных для groovy-объектов.

  • смотрим, что вызванный метод совпадает по имени с одним из полей с помощью обращения к метаклассу. Метакласс присутствует у каждого groovy-объекта и работает примерно как reflection, но удобнее. Метакласс, в частности, позволяет получать информацию о полях и доступ к ним через аксессоры, даже если сами поля приватные. Это нам еще пригодится позже.
  • получаем тип поля через тот же метакласс, чтобы создать новый экземпляр его. Здесь мы рассчитываем на то, что у всех классов, которые мы собираемся использовать в конфигах, задан конструктор по умолчанию, но в принципе никто не мешает сделать тут настолько сложную логику, насколько вам необходимо.
  • получаем значение поля через getProperty() и устанавливаем новое значение через setProperty(). Это методы из GroovyObjectSupport и они обращаются к полю через аксессоры, если найдет их, или напрямую. Это избавляет нас от необходимости изменять поле через reflection или еще какими-то не очень удобными способами, особенно, если это поле где-то в классе-наследнике.

Пока что мы добавили methodMissing и все dsl-плюшки только для одного класса, ServerConfig. Мы могли бы реализовать тот же метод для Connection, но зачем дублировать код? Создадим какой-нибудь базовый для всех наших конфиг-бинов класс, скажем, GroovyConfigurable, перенесем methodMissing в него, а ServerConfig и Connector унаследуем.

Как-то так

public class GroovyConfigurable extends GroovyObjectSupport {

    @SneakyThrows
    public void methodMissing(String name, Object args) {
        MetaProperty metaProperty = getMetaClass().getMetaProperty(name);
        if (metaProperty != null) {
            Closure closure = (Closure) ((Object[]) args)[0];
            Object value = getProperty(name) == null ?
                    metaProperty.getType().getConstructor().newInstance() :
                    getProperty(name);
            closure.setDelegate(value);
            closure.setResolveStrategy(Closure.DELEGATE_FIRST);
            closure.call();
            setProperty(name, value);
        } else {
            throw new IllegalArgumentException("No such field: " + name);
        }
    }
}

@Data
public class ServerConfig extends GroovyConfigurable {
    private String name;
    private String description;

    private Connector http;
    private Connector https;
}

@Data
public class Connector extends GroovyConfigurable {
    private int port;
    private boolean secure;
}

Это все работает, даже при том, что GroovyConfigurable ничего не знает о полях своих наследников!

Наследование

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

include 'parent.groovy'

name = "prod"
https {
    port = 8080
}

Groovy позволяет импортировать классы, но не скрипты. Самый простой способ — реализовать в нашем классе GroovyConfigurable метод include. Добавим туда путь к самому скрипту и пару методов:

GroovyConfigurable

    private URI scriptPath;

    @SneakyThrows
    public void include(String path) {
        // получим путь к запрашиваемому скрипту относительно текущего
        URI uri = Paths.get(scriptPath).getParent().resolve(path).toUri();
        runFrom(uri);
    }

    @SneakyThrows
    public void runFrom(URI uri) {
        this.scriptPath = uri;
        // все то, что раньше было в main-е
        CompilerConfiguration cc = new CompilerConfiguration();
        cc.setScriptBaseClass(DelegatingScript.class.getName());
        GroovyShell sh = new GroovyShell(Main.class.getClassLoader(), new Binding(), cc);
        DelegatingScript script = (DelegatingScript)sh.parse(uri);
        script.setDelegate(this);
        script.run();
    }

Сделаем конфиг parent.groovy, в котором опишем некий базовый конфиг:

name = "PARENT NAME"
description = "PARENT DESCRIPTION"

http {
    port = 80
    secure = false
}
https {
    port = 443
    secure = true
}

В config.groovy оставим только то, что мы хотим переопределить:

include "parent.groovy"

name = "MyTest"

https {
    port = 8080
}

Результат выполнения

ServerConfig(name=MyTest, description=PARENT DESCRIPTION, http=Connector(port=80, secure=false), https=Connector(port=8080, secure=true))

Как видите, name переопределилось, как и поле port в https. Поле secure в нем осталось от родительского конфига.

Можно пойти еще дальше и сделать возможность инклюдить не весь конфиг, а его отдельные части! Для этого в methodMissing надо добавить проверку на то, что устанавливаемое поле тоже GroovyConfigurable и задать ему путь к родительскому скрипту.

Заголовок спойлера

    public void methodMissing(String name, Object args) {
        MetaProperty metaProperty = getMetaClass().getMetaProperty(name);
        if (metaProperty != null) {
            Closure closure = (Closure) ((Object[]) args)[0];
            Object value = getProperty(name) == null ?
                    metaProperty.getType().getConstructor().newInstance() :
                    getProperty(name);
            if (value instanceof GroovyConfigurable) {
                ((GroovyConfigurable) value).scriptPath = scriptPath;
            }
            closure.setDelegate(value);
            closure.setResolveStrategy(Closure.DELEGATE_FIRST);
            closure.call();
            setProperty(name, value);
        } else {
            throw new IllegalArgumentException("No such field: " + name);
        }
    }

Это позволит нам инклюдить не только весь скрипт, но и его части! Например, так

http {
    include "http.groovy"
}

где http.groovy это

port = 90
secure = true

Это уже отличный результат, но есть небольшая проблема.

Generics

Скажем, мы хотим добавить в конфиг нашего сервера маппинги и их статус.

конфиг

name = "MyTest"
description = "Apache Tomcat"

http {
    port = 80
    secure = false
}
https {
    port = 443
    secure = true
}

mappings = [
        {
            url = "/"
            active = true
        },
        {
            url = "/login"
            active = false
        }
]

Mapping.java

@Data
public class Mapping extends GroovyConfigurable {
    private String url;
    private boolean active;
}

ServerConfig.java

@Data
public class ServerConfig extends GroovyConfigurable {
    private String name;
    private String description;

    private Connector http;
    private Connector https;

    private List<Mapping> mappings;
}

Запускаем...

ServerConfig(name=MyTest, description=Apache Tomcat, http=Connector(port=80, secure=false), https=Connector(port=443, secure=true), mappings=[config$_run_closure3@14ec4505, config$_run_closure4@53ca01a2])

Упс. Type erasure во всей красе. К сожалению, здесь магия кончается, и мы должны руками поправить то, что прочитали. Например, с помощью отдельного метода GroovyConfigurable#postProcess()

Код

public void postProcess() {
        for (MetaProperty metaProperty : getMetaClass().getProperties()) {
            Object value = getProperty(metaProperty.getName());
            if (Collection.class.isAssignableFrom(metaProperty.getType()) &&
                    value instanceof Collection) {
                // у коллекции тип всегда параметризован
                ParameterizedType collectionType = (ParameterizedType) getClass().getDeclaredField(metaProperty.getName()).getGenericType();
                // если в объявлении коллекции был не класс, а интерфейс, это работать не будет, и нужна более
                // сложная проверка, но для демонстрации оставим так
                Class itemClass = (Class)collectionType.getActualTypeArguments()[0];
                // развернем замыкания только в том случае, если в коллекции должны лежать объекты GroovyConfigurable
                // для других типов, возможно, понадобится другой код
                if (GroovyConfigurable.class.isAssignableFrom(itemClass)) {
                    Collection collection = (Collection) value;
                    // мы не знаем конкретный класс коллекции, поэтому создадим такой же, какой уже у этого поля
                    Collection newValue = collection.getClass().newInstance();
                    for (Object o : collection) {
                        if (o instanceof Closure) {
                            // создадим делегата и выполним код
                            Object item = itemClass.getConstructor().newInstance();
                            ((GroovyConfigurable) item).setProperty("scriptPath", scriptPath);
                            ((Closure) o).setDelegate(item);
                            ((Closure) o).setResolveStrategy(Closure.DELEGATE_FIRST);
                            ((Closure) o).call();
                            ((GroovyConfigurable) item).postProcess(); // вдруг там внутри тоже коллекции?
                            newValue.add(item);
                        } else {
                            newValue.add(o);
                        }
                    }
                    setProperty(metaProperty.getName(), newValue);
                }
            }
        }
    }

Вышло, конечно, некрасиво, но свою работу выполняет. Кроме того, мы написали это только для одного базового класса, и не нужно повторять для наследников. После вызова config.postProcess(); мы получим пригодные для использования бины.

Заключение

Конечно, приведенный здесь код — это всего лишь небольшая (самая простая) часть того, что необходимо в реальной библиотеке для конфигурирования, и чем сложнее ваш случай использования, тем больше надо добавлять ручной обработки и проверок. Например, поддержку map-ов, перечислений, вложенных generic-ов, и т.д. Список можно продолжать бесконечно, но для моих нужд хватило того, что я привел в статье. Надеюсь, вам это тоже поможет и ваши конфиги станут более красивыми и удобными!

Автор: Heliki

Источник [5]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/java/280196

Ссылки в тексте:

[1] lightbend config: https://github.com/lightbend/config

[2] DelegatingScript: http://docs.groovy-lang.org/latest/html/api/groovy/util/DelegatingScript.html

[3] lombok: https://projectlombok.org/

[4] logback: https://logback.qos.ch/

[5] Источник: https://habr.com/post/358594/?utm_source=habrahabr&utm_medium=rss&utm_campaign=358594