Предыстория
Всем привет! Я хотел бы рассказать историю о страшных конфигах и как их удалось причесать и сделать вменяемыми. Я работаю над довольно большим и относительно старым проектом, который постоянно допиливается и разрастается. Конфигурация задается с помощью маппинга 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-ов, и т.д. Список можно продолжать бесконечно, но для моих нужд хватило того, что я привел в статье. Надеюсь, вам это тоже поможет и ваши конфиги станут более красивыми и удобными!
Комментарии (16)
dokwork
15.05.2018 13:53результат очень напоминает hocon, возможно, вам не стоит городить таких огородов с груви и котлин
Heliki Автор
15.05.2018 14:09Я смотрел в его сторону и написал, почему он нам не подошел: эта библиотека плохо маппит конфиг на бины, а кроме того, не умеет работать ни с какими мапами, кроме <String, Object>. То есть, нельзя, например, использовать enum в качестве ключей.
alxt
15.05.2018 15:34Сначала написали такое же на groovy DSL. Потом переписали на kotlin DSL. Намного лучше стало.
У нас ещё была причина на груви писать- тогда котлин ещё не поддерживал DSL (появилось в 1.1.1)- но сейчас груви не нужен.Heliki Автор
15.05.2018 16:47А там нужно объявлять методы для каждого из полей, или есть какой-то аналог methodMissing или, может быть, аннотация над полем, чтобы к нему лямбда применялась?
alxt
15.05.2018 16:56Методы не нужно- паблик поля и так работают (методы будут, там сахар).
methodMissing — нет и я не понимаю зачем это надо. Мы ж хотим бины- зачем делать свалку, куда попадёт куча мусора? нужно лишь описать классы с полями- и всё.
А аннотация с лямбдой- это о чём?Heliki Автор
15.05.2018 17:02В первом комментарии приведен пример конфига. Поле jetty, например, как заполнится? Что для этого надо сделать? Достаточно просто объявить его как паблик и все?
alxt
15.05.2018 17:06Да. Именно.
dougrinch
15.05.2018 23:06А про какое вообще поле речь и где оно заполнится автоматически? Я знаю только один способ сделать такое, но тут нужно и методы писать, и их реализацию. Неужели я упустил что-то очень крутое?!..
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()
dougrinch
15.05.2018 22:35тогда котлин ещё не поддерживал DSL (появилось в 1.1.1)
А поясните пожалуйста. kotlin dsl — это экстеншн лямбды и возможность писать лямбду-последний аргумент «за пределами вызова функции» как блок кода. Обе эти штуки были с релиза.
Throwable
16.05.2018 09:37Может, для этого уже есть библиотека?
Да полно. Вот очень неплохой вариант: http://owner.aeonbits.org/.
Для себя я уже давно решил проблему конфигов: использую стандартный JAXB. Структура описывается прямо в бинах при помощи парочки аннотаций. Все сериализуется в XML. Maven автоматически генерит XSD, который понимает IDE и позволяет делать autocomplete при редактировании конфига.Heliki Автор
16.05.2018 12:40У нас как раз все через JAXB было сделано, но не хватало «наследования» конфигов, приходилось править в 30 местах примерно.
Throwable
17.05.2018 12:13Не совсем понял, что вы имеете ввиду под "наследованием" конфигов. Если это наследование бинов, то у JAXB с этим проблем нет. А если дефолтные значения для полей, то их можно указывать прямо при описании самих полей:
public class DatabaseConfig { public String url = "jdbc:h2:mem:test"; public String username = "sa"; public String password = ""; }
Heliki Автор
17.05.2018 12:58Я не знаю, как это назвать, но суть такая. У нас есть, условно, 5 конфигов для разных тестовых площадок, которые совпадают на 90%. Хотелось бы иметь некий «родительский» конфиг, в котором описан условный дефолтный тест, а в этих пяти конфигах переопределить необходимые им 10%.
Throwable
17.05.2018 13:36Дык, захардкодьте весь дефолтный конфиг значениями прямо в бинах, как я показал выше. А в xml нужно будет прописывать только элементы, значения которых отличаются. JAXB будет создавать объект с дефолтными параметрами, и перезаписывать только поля, указанные в xml. В идеале с пустым xml будет создаваться полный дефолтный конфиг.
UbuRus
Пишу конфиги на котлин скрипте, по большому счету принцип тот же что в груви, только есть автодополнение и нет methodMissing:
Можно еще вспонить проект https://github.com/gradle/kotlin-dsl для gradle который приносит те же прелести нормального тулинга