Как подружился Ebean с Gradle и помирился с IntelliJ Idea

в 10:38, , рубрики: class-файл, github, gradle, groovy, intellij idea, java, orm, plugin

Наконец-то я созрел, чтобы начать свой веб-проект. Очередной todo-менеджер, который агрегирует задачи с нужных мне источников. Планировался как проект для души, такой чистый и правильный. Никаких компромиссов в архитектуре и технологиях. Только best-practices, только хардкор. И, конечно же, кнопать это все собрался в любимой Intellij IDEA.

После 7 лет Java, последних двух вперемешку с Scala, захотелось попробовать Groovy. Для сборки, конечно же Gradle — популярно и удобно. Рельсы показались слишком «заезженные», так что решил использовать Spring для веб, причем по современному, через Spring Boot. И все было просто замечательно, только с ORM не сложилось. На работе мы Hibernate выпилили, заказчик лично невзлюбил (не смейтесь и такое бывает — отдельная история) и заменили своим велосипедом. Негативный опыт и нежелание тянуть монстра ради пары сущностей сделали свое — хибернейту твердое нет! Захотелось попробовать что-то совсем другое. По воле случая наткнулся на Ebean, который и был выбран.

После окончательной подборки стека начала кипеть работа. Но вот незадача, воз с функционалом пока не сдвинулся с места. Под катом искреннее оправдание почему.

Ebean

Это opensource ORM фреймворк, по-моему, создан хейтерами классических реализаций JPA, как раз чтобы понравиться хипстерам. Из ключевых особенностей:

  • привычный маппинг (использует аннотации java.persistence);
  • простое API;
  • легок в настройке;
  • гибкий fetching связанных сущностей;
  • partial-выборки;
  • трекинг изменений;
  • отсутствие сессий;
  • собственная поддержка транзакций;
  • асинхронная загрузка;
  • и даже автотюнинг!

То есть все, что нужно для обычного, не enterprise веб-приложения. Авторы фреймворка проделали просто колоссальную работу. Думаю не даром его добавили как один из штатных ORM в Play! Сперва пугает, что сайт как-то плохо обновляется, и разработка замерла. Но нет, на GitHub есть очень даже свежие версии avaje-ebeanorm 4.х. А благодаря комьюнити и популярности Play развитие проекта продолжается. Как доказательство активность на GitHub:

Как подружился Ebean с Gradle и помирился с IntelliJ Idea

Вот примеры того, как выглядят базовые запросы в Ebean:

// find an order by its id  
Order order = Ebean.find(Order.class, 12); 

// find all the orders  
List<Order> list = Ebean.find(Order.class).findList();  

// find all the orders shipped since a week ago  
List<Order> list = Ebean.find(Order.class)  
    .where()  
      .eq("status", Order.Status.SHIPPED)  
      .gt("shipDate", lastWeek)  
    .findList();

Создание, сохранение и обновление сущностей:

Order order = new Order();  
order.setOrderDate(new Date());  
...  
// insert the order  
Ebean.save(order);  

//If the bean was fetched it will be updated
Order order = Ebean.find(Order.class, 12);  
order.setStatus("SHIPPED");  
...  
// update the order  
Ebean.save(order); 

Работа с partial-объектами:

// find order 12  
// ... fetching the order id, orderDate and version property  
// .... nb: the Id and version property are always fetched  
  
Order order = Ebean.find(Order.class)  
        .select("orderDate")  
        .where().idEq(12)  
        .findUnique();  
  
// shipDate is not in the partially populated order  
// ... so it will lazy load all the missing properties  
Date shipDate = order.getShipDate();  
  
// similarly if we where to set the shipDate   
// ... that would also trigger a lazy load  
order.setShipDate(new Date());

Вдохновившись примерами, было принято окончательное решение, использовать последнюю, активно поддерживаемую версию 4.1.8. Настрой боевой. В build.gradle добавлена зависимость:

compile('org.avaje.ebeanorm:avaje-ebeanorm:4.1.8')

Создана конфигурация:

@Configuration
@PropertySource("config/db.properties")
class EbeanConfig {
    @Autowired DbConfig dbConfig

    @Bean
    EbeanServer ebeanServer() {
        EbeanServerFactory.create(serverConfig())
    }

    ServerConfig serverConfig() {
        new ServerConfig(
            dataSourceConfig: dataSourceConfig(), name: "main",
            ddlGenerate: false, ddlRun: false, defaultServer: true, register: true,
            namingConvention: new MatchingNamingConvention()
        )
    }

    DataSourceConfig dataSourceConfig() {
        new DataSourceConfig(driver: dbConfig.driver, username: dbConfig.username, password: dbConfig.password, url: dbConfig.url)
    }
}

И тестовая сущность:

@Entity
class TestEntity {
    @Id UUID id
    Integer value
}

Я уже потирал руки в предвкушении всеми любимого profit'а. Но… сказок не бывает, и конечно же, все свалилось еще на старте с java.lang.IllegalStateException: Bean class x.x.x.TestEntity is not enhanced?

Обычно люди читают документацию, только когда что-то не получается. И это даже хорошо. Оказывается, для нормальной работы Ebean требуется расширять байт-код .class файлов еще на этапе сборки, т.е. сразу после компиляции. Зачем это нужно? Почти все ORM встраиваются в классы, просто большинство выкручиваются по-другому. Hibernate, например, создает runtime прокси через cglib, чтобы перехватывать доступ к Lazy-коллекциям. Прозрачно честное lazy без таких хаков просто не сделать. Ebean, наряду со всеми, поддерживает ленивую загрузку, плюс частичные объекты, а также отслеживает изменения в каждом поле, дабы не отправлять на сервер лишнее при операции save.

Ранние версии библиотеки поддерживали два подхода к проксированию: патчинг .class-файла и инструментация кода класса при загрузке через ClassLoader (требовалось подключать агент на старте JVM). Со временем для простоты поддержки оставили только первый вариант.

Хм… Сложно, но… Люди как-то же живут с Ebean в Play Framework? Оказывается к самой ORM поставляется отдельная библиотека ebeanorm-agent, которая умеет расширяет байт код скомпилированного .class-файла, а SBT, на котором держится Play ее успешно использует. Так как в Play сборка кода только внутренняя, то все работает как часы. И никто, наверное, не догадывается, что происходит за кулисами.

Gradle

Но вот вопрос, есть ли такая фишка для Gradle? Для Maven точно существует плагин. А вот для Gradle я нашел ровным счетом ничего (возможно плохо искал). Сначала расстроился, и даже думал бросить эту затею… Но в последний момент собрался и решил, во что бы не стало довести дело до конца.

Итак, делаем недостающий плагин!

Удобнейший путь добавить собственные инструменты сборки в Gradle — это создание модуля buildSrc в корневой директории проекта. Код с этого модуля будет автоматически доступен во всех остальныъ build-скриптах (все варианты описаны тут).

Дальше создаем build.gradle внутри директории buildSrc:

apply plugin: 'groovy'

repositories {
    mavenCentral()
}

dependencies {
    //зависимость от самого свежего агента расширения
    compile 'org.avaje.ebeanorm:avaje-ebeanorm-agent:4.1.5'
}

Хотя подход с buildSrc и не требует создания именно плагина (можно просто писать и вызывать код с Groovy-скрипта), мы пойдем по правильному пути, расширяя Gradle API. Ведь наверняка, позже, захочется оформить это все как готовый продукт и выложить где-то для всобщего пользования.

Главная идея плагина — после каждого этапа компиляции Java, Groovy или Scala найти и обработать созданные компилятором .class файлы, которые будут использоваться Ebean. Эта задача решается примерно так:

class EbeanPlugin implements Plugin<Project> {
    //известные задачи компиляции - хардкод!
    private static def supportedCompilerTasks = ['compileJava', 'compileGroovy', 'compileScala']

    //это точка связки плагина с проектом, который собирается
    void apply(Project project) {
        //указываем имя и тип контейнера для настроек плагина
        def params = project.extensions.create('ebean', EbeanPluginParams)
        //вытягиваем все задачи, которые есть в проекте...
        def tasks = project.tasks

        //... и пытаемся навесить хук на каждую возможную задачу 
        supportedCompilerTasks.each { compileTask ->
            tryHookCompilerTask(tasks, compileTask, params)
        }
    }

    private static void tryHookCompilerTask(TaskContainer tasks, String taskName, EbeanPluginParams params) {
        try {
            def task = tasks.getByName(taskName)

            //хук вешаем на событие после выполнения задачи, т.е. компиляции
            task.doLast({ completedTask ->
                //делаем полезную работу в контексте корневой папки вывода           
                enhanceTaskOutput(completedTask.outputs, params)
            })
        } catch (UnknownTaskException _) {
            ; //просто плагин не активирован
        }
    }

    private static void enhanceTaskOutput(TaskOutputs taskOutputs, EbeanPluginParams params) {
        //у задачи может быть несколько точек вывода, хотя такие пока не попадались
        taskOutputs.files.each { outputDir ->
            if (outputDir.isDirectory()) {
                def classPath = outputDir.toPath()
                //создаем фильтр, который на всякий случай сужать объем работы
                def fileFilter = new EbeanFileFilter(classPath, params.include, params.exclude)
                //и делаем непосредственно само расширение
                new EBeanEnhancer(classPath, fileFilter).enhance()
            }
        }
    }
}

//это модель свойств плагина, которые можно задавать в клиентском build.gradle
class EbeanPluginParams {
    String[] include = []
    String[] exclude = []
}

Дальше, дело за самим расширителем. Алгоритм очень простой: сначала рекурсивно собираем все .class-файлы внутри базовой директории, подходящие под фильтр, а потом пропускаем их через «агент». Сама обработка незамысловата: есть сущность — трансформер, а также враппер-помощник для обработки с потока ввода. Создав и связав оба нам остается только открыть файл и вызвать transform(...), попутно расставляя кучу catch для возможных ошибок. Все в сборе выглядит так:

class EBeanEnhancer {
    private final Path classPath
    private final FileFilter fileFilter

    EBeanEnhancer(Path classPath) {
        this(classPath, { file -> true })
    }

    EBeanEnhancer(Path classPath, FileFilter fileFilter) {
        this.classPath = classPath
        this.fileFilter = fileFilter
    }

    void enhance() {
        collectClassFiles(classPath.toFile()).each { classFile ->
            if (fileFilter.accept(classFile)) {
                enhanceClassFile(classFile);
            }
        }
    }

    private void enhanceClassFile(File classFile) {
        def transformer = new Transformer(new FileSystemClassBytesReader(classPath), "debug=" + 1);//0-9 -> none - all
        def streamTransform = new InputStreamTransform(transformer, getClass().getClassLoader())

        def className = ClassUtils.makeClassName(classPath, classFile);

        try {
            classFile.withInputStream { classInputStream ->
                def enhancedClassData = streamTransform.transform(className, classInputStream)

                if (null != enhancedClassData) { //transformer returns null when nothing was transformed
                    try {
                        classFile.withOutputStream { classOutputStream ->
                            classOutputStream << enhancedClassData
                        }
                    } catch (IOException e) {
                        throw new EbeanEnhancementException("Unable to store an enhanced class data back to file $classFile.name", e);
                    }
                }
            }
        } catch (IOException e) {
            throw new EbeanEnhancementException("Unable to read a class file $classFile.name for enhancement", e);
        } catch (IllegalClassFormatException e) {
            throw new EbeanEnhancementException("Unable to parse a class file $classFile.name while enhance", e);
        }
    }

    private static List<File> collectClassFiles(File dir) {
        List<File> classFiles = new ArrayList<>();

        dir.listFiles().each { file ->
            if (file.directory) {
                classFiles.addAll(collectClassFiles(file));
            } else {
                if (file.name.endsWith(".class")) {
                    classFiles.add(file);
                }
            }
        }

        classFiles
    }
}

Как сделаны фильтры показывать смысла нет (или стыдно). Это может быть любая реализация интерфейса java.io.FileFilter. И на самом деле, этот функционал не обязательный.

Другое дело FileSystemClassBytesReader. Это очень важный элемент процесса. Он вычитывает связанные .class-файлы, если такие понадобились трансформеру. Например, когда анализируется субкласс, то ebean-agent запрашивает через ClassBytesReader суперклас, дабы проверить его на наличие аннотации @MappedSuperclass. Без этой штуки java.lang.IllegalStateException: Bean class x.x.x.SubClassEntity is not enhanced? вылетает без раздумий.

class FileSystemClassBytesReader implements ClassBytesReader {
    private final Path basePath;

    FileSystemClassBytesReader(Path basePath) {
        this.basePath = basePath;
    }

    @Override
    byte[] getClassBytes(String className, ClassLoader classLoader) {
        def classFilePath = basePath.resolve(className.replace(".", "/") + ".class");
        def file = classFilePath.toFile()
        def buffer = new byte[file.length()]

        try {
            file.withInputStream { classFileStream -> classFileStream.read(buffer) }
        } catch (IOException e) {
            throw new EbeanEnhancementException("Failed to load class '$className' at base path '$basePath'", e);
        }

        buffer
    }
}

Для того, чтобы вызывать плагин по красивому идентификатору 'ebean', необходимо добавить файл ebean.properties в папку buildSrc/resources/META-INF:

implementation-class=com.avaje.ebean.gradle.EbeanPlugin

Все. Плагин готов.

Ну и напоследок, в build.gradle основного проекта добавляем замечательные строчки:

apply plugin: 'ebean' //имя property-файла добавленного в предыдущем шаге

ebean { //имя контейнера настроек плагина
    include = ["com.vendor.product"]
    exclude = ["SomeWeirdClass"]
}

Вот такая история удачного знакомства Ebean с Gradle. Все собирается и работает как нужно.

Скачать плагин можно на GitHub gradle-ebean-enhancer. К сожалению, пока-что все сыро и код нужно копировать в buildSrc. В ближайших планах допилить и обязательно отправить в Maven Central и репозиторий Gradle.

IntelliJ Idea

Хорошая новость: для Idea 13 есть плагин от Yevgeny Krasik, за что ему огромное спасибо! Плагин «слушает» процесс сборки и по горячим следам за компилятором расширяет .class-файлы. Мне это нужно, что-бы запускать и отлаживать приложение Spring Boot с самой Idea, ведь так намного удобней и привычней.

Плохая новость: плагин работает со старой версией библиотеки агента 3.2.2. Продукт ее деятельности не совместим с Ebean 4.x и приводит к странным эксепшенам на старте.

Решение: делаем fork на github и пересобираем плагин для нужной версии.

Все прошло как по маслу. Приложение запустилось. Тестовая сущность смогла сохраниться и загрузиться.

На самом деле можно было и не писать об этом… однако есть одно «но». Как только я начал строить иерархию сущностей и появилась BaseEntity с @MappedSuperclass, java.lang.IllegalStateException: Bean class x.x.x.SubClassEntity is not enhanced? снова тут как тут.

javap показал, что все субкласы почему-то не расширенны. Why-y-y-y-y? Почему?

Оказалось, что в IDE-плагин закралась досадная ошибка. При процессе расширения текущего класса трансформер как всегда пытается проанализировать еще и субкласс. Для этого, напомню, ему нужно предоставить реализацию ClassBytesReader. Только вот имплементация IDE-плагина по какой-то причине вместо бинарных данных «скармливала» трансформеру исходный код на Groovy, которым тот и «давился».

Так-что форк оказался очень даже кстати. Было:

//virtualFile оказался ссылкой на исходный код на Groovy
if (virtualFile == null) {
	return null;
}

try {
	return virtualFile.contentsToByteArray(); // o_O ?
} catch (IOException e) {
	throw new RuntimeException(e);
}

Стало:

if (virtualFile == null) {
	compileContext.addMessage(CompilerMessageCategory.ERROR, "Unable to detect source file '" + className + "'. Not found in output directory", null, -1, -1);
	return null;
}

final Module fileModule = compileContext.getModuleByFile(virtualFile);
final VirtualFile outputDirectory = compileContext.getModuleOutputDirectory(fileModule);
//ищем нужный файл в директории вывода
final VirtualFile compiledRequestedFile = outputDirectory.findFileByRelativePath(classNamePath + ".class");

if (null == compiledRequestedFile) {
	compileContext.addMessage(CompilerMessageCategory.ERROR, "Class file for '" + className + "' is not found in output directory", null, -1, -1);
	return null;
}

try {
	return compiledRequestedFile.contentsToByteArray();
} catch (IOException e) {
	throw new RuntimeException(e);
}

Profit! Допускаю, что автор плагина не сильно пользовался самим ORM-фреймворком. Хотя, единственное на что могу посетовать, так это на отсутствие вменяемых сообщений об ошибке. Ведь как-то печально наблюдать тихо не работающий продукт.

Полный, исправленный код плагина можно найти в единственном на момент написания форке на GitHub idea-ebean-enhancer. Там же есть ссылка на готовый к установке zip.

Итоги

Пользователи IntelliJ Idea наконец-то получили полностью работающий плагин, с поддержкой самой свежей версии Ebean.

На ряду с Maven, Gradle также обзавелся расширением для поддержки этого ORM-фреймворка.

Надеюсь, что проделанный мной путь поможет читателям отважиться попробовать, что за зверь такой этот Ebean. Ведь похоже, что все существенные преграды на этом пути преодалены. Ну, или хотя бы вдохновит пройти похожий путь и с удовольствием покопаться во внутренностях какой-то малознакомой библиотеки.

Так же мне показалось довольно забавным, что написание кода заняло существенно меньше времени нежели подготовка этой публикации.

Автор: khomich

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js