- PVSM.RU - https://www.pvsm.ru -
Всем привет! Я хотел бы рассказать историю о страшных конфигах и как их удалось причесать и сделать вменяемыми. Я работаю над довольно большим и относительно старым проектом, который постоянно допиливается и разрастается. Конфигурация задается с помощью маппинга xml-файлов на java-бины. Не самое лучшее решение, но оно имеет свои плюсы — например, при создании сервиса можно передать ему бин с конфигурацией, отвечающий за его раздел. Однако, есть и минусы. Самый существенный из них — нет нормального наследования профилей конфигурации. В какой-то момент я осознал, что для того, чтобы поменять одну настройку, я должен отредактировать около 30 xml-файлов, по одному для каждого из профилей. Так больше продолжаться не могло, и было принято волевое решение все переписать.
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 [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...
Каждый раз, когда 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-объектов.
Пока что мы добавили 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
Это уже отличный результат, но есть небольшая проблема.
Скажем, мы хотим добавить в конфиг нашего сервера маппинги и их статус.
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-ов, и т.д. Список можно продолжать бесконечно, но для моих нужд хватило того, что я привел в статье. Надеюсь, вам это тоже поможет и ваши конфиги станут более красивыми и удобными!
Автор: 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
Нажмите здесь для печати.