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

  • Tutorial

Предыстория


Всем привет! Я хотел бы рассказать историю о страшных конфигах и как их удалось причесать и сделать вменяемыми. Я работаю над довольно большим и относительно старым проектом, который постоянно допиливается и разрастается. Конфигурация задается с помощью маппинга 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, с ее симпатичным форматом 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. Его особенность в том, что ему можно передать объект-делегат, и все вызовы методов и работа с полями будут делегироваться ему. В документации по ссылке есть пример использования.
Создадим класс, который будет содержать наш конфиг


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

@Data — аннотация из библиотеки lombok: добавляет геттеры и сеттеры к полям и реализует 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, имеющая возможность конфигурации через 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-ов, и т.д. Список можно продолжать бесконечно, но для моих нужд хватило того, что я привел в статье. Надеюсь, вам это тоже поможет и ваши конфиги станут более красивыми и удобными!

Share post
AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 18

    +2

    Пишу конфиги на котлин скрипте, по большому счету принцип тот же что в груви, только есть автодополнение и нет methodMissing:


    config {
        jetty {
            httpConnector {
                port = 4242
                host = "0.0.0.0"
            }
        }
    }

    Можно еще вспонить проект https://github.com/gradle/kotlin-dsl для gradle который приносит те же прелести нормального тулинга

      0
      результат очень напоминает hocon, возможно, вам не стоит городить таких огородов с груви и котлин
        0
        Я смотрел в его сторону и написал, почему он нам не подошел: эта библиотека плохо маппит конфиг на бины, а кроме того, не умеет работать ни с какими мапами, кроме <String, Object>. То есть, нельзя, например, использовать enum в качестве ключей.
          0
          да, прошу прощения, пропустил абзац с объяснением
        +2
        Сначала написали такое же на groovy DSL. Потом переписали на kotlin DSL. Намного лучше стало.
        У нас ещё была причина на груви писать- тогда котлин ещё не поддерживал DSL (появилось в 1.1.1)- но сейчас груви не нужен.
          0
          А там нужно объявлять методы для каждого из полей, или есть какой-то аналог methodMissing или, может быть, аннотация над полем, чтобы к нему лямбда применялась?
            0
            Методы не нужно- паблик поля и так работают (методы будут, там сахар).
            methodMissing — нет и я не понимаю зачем это надо. Мы ж хотим бины- зачем делать свалку, куда попадёт куча мусора? нужно лишь описать классы с полями- и всё.
            А аннотация с лямбдой- это о чём?
              0
              В первом комментарии приведен пример конфига. Поле jetty, например, как заполнится? Что для этого надо сделать? Достаточно просто объявить его как паблик и все?
                0
                Да. Именно.
                  0
                  А про какое вообще поле речь и где оно заполнится автоматически? Я знаю только один способ сделать такое, но тут нужно и методы писать, и их реализацию. Неужели я упустил что-то очень крутое?!..
                  class HttpConnector(var port: Int? = null, var host: String? = null)
                  class JettyBuilder {
                      fun httpConnector(f: HttpConnector.() -> Unit): Unit = TODO()
                  }
                  class ConfigBuilder {
                      fun jetty(f: JettyBuilder.() -> Unit): Unit = TODO()
                  }
                  fun config(f: ConfigBuilder.() -> Unit): Unit = TODO()
                  
            0
            тогда котлин ещё не поддерживал DSL (появилось в 1.1.1)

            А поясните пожалуйста. kotlin dsl — это экстеншн лямбды и возможность писать лямбду-последний аргумент «за пределами вызова функции» как блок кода. Обе эти штуки были с релиза.
            0
            Может, для этого уже есть библиотека?

            Да полно. Вот очень неплохой вариант: http://owner.aeonbits.org/.
            Для себя я уже давно решил проблему конфигов: использую стандартный JAXB. Структура описывается прямо в бинах при помощи парочки аннотаций. Все сериализуется в XML. Maven автоматически генерит XSD, который понимает IDE и позволяет делать autocomplete при редактировании конфига.

              0
              У нас как раз все через JAXB было сделано, но не хватало «наследования» конфигов, приходилось править в 30 местах примерно.
                0

                Не совсем понял, что вы имеете ввиду под "наследованием" конфигов. Если это наследование бинов, то у JAXB с этим проблем нет. А если дефолтные значения для полей, то их можно указывать прямо при описании самих полей:


                public class DatabaseConfig {
                    public String url = "jdbc:h2:mem:test";
                    public String username = "sa";
                    public String password = "";
                }
                  0
                  Я не знаю, как это назвать, но суть такая. У нас есть, условно, 5 конфигов для разных тестовых площадок, которые совпадают на 90%. Хотелось бы иметь некий «родительский» конфиг, в котором описан условный дефолтный тест, а в этих пяти конфигах переопределить необходимые им 10%.
                    0

                    Дык, захардкодьте весь дефолтный конфиг значениями прямо в бинах, как я показал выше. А в xml нужно будет прописывать только элементы, значения которых отличаются. JAXB будет создавать объект с дефолтными параметрами, и перезаписывать только поля, указанные в xml. В идеале с пустым xml будет создаваться полный дефолтный конфиг.

              0
              Можно ли потом готовый бин изменить программно и записать обратно в такой файл, в таком же формате? Мне бы это пригодилось для хранения настроек моих парсеров. Настройки пользователи меняют в процессе работы.
                0
                Нет, какого-то простого способа для этого нет, надо делать какой-то свой сериализатор, перечисляя поля через reflection, например.

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