Предыстория
Всем привет! Я хотел бы рассказать историю о страшных конфигах и как их удалось причесать и сделать вменяемыми. Я работаю над довольно большим и относительно старым проектом, который постоянно допиливается и разрастается. Конфигурация задается с помощью маппинга xml-файлов на java-бины. Не самое лучшее решение, но оно имеет свои плюсы — например, при создании сервиса можно передать ему бин с конфигурацией, отвечающий за его раздел. Однако, есть и минусы. Самый существенный из них — нет нормального наследования профилей конфигурации. В какой-то момент я осознал, что для того, чтобы поменять одну настройку, я должен отредактировать около 30 xml-файлов, по одному для каждого из профилей. Так больше продолжаться не могло, и было принято волевое решение все переписать.
Требования
- Наследование и переопределение (или fallback). Должна быть возможность задать некий базовый профиль, унаследовать от него дочерние и переопределить или добавить в них те места, которые необходимо
- Маппинг в java-бины. Переписывать по всему проекту использование конфигурации с бинов на проперти вида
mongodb.directory.host
не хотелось, использовать map-ы из map-ов тоже. - Возможность писать в конфиге комментарии. Не критично, но удобно и приятно.
Хотелось бы, чтобы конфиг выглядел примерно так:
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. Добавим туда путь к самому скрипту и пару методов:
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
}
]
@Data
public class Mapping extends GroovyConfigurable {
private String url;
private boolean active;
}
@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-ов, и т.д. Список можно продолжать бесконечно, но для моих нужд хватило того, что я привел в статье. Надеюсь, вам это тоже поможет и ваши конфиги станут более красивыми и удобными!