Прим. переводчика: автор статьи рассказывает, как его команде удалось запустить два клиента Elasticsearch разных версий на одной JVM путем написания специальной библиотеки-обертки для работы с нужной версией.

Это пятая часть серии статей об обновлении кластера Elasticsearch без простоев и с минимальным воздействием на пользователей.

Глобальный характер обновления с самого начала намекал, что оно займет минимум год (а то и больше). В этой части пойдет речь об изменении подхода к разработке и о том, как удалось поддерживать параллельную работу нескольких клиентских библиотек Elasticsearch в кодовых базах Java в течение длительного времени.

Работаем сразу с несколькими клиентами Elasticsearch

У нас есть несколько компонентов, которые напрямую взаимодействуют с Elasticsearch. Один из них, например, отвечает за обработку поиска, другой — за индексирование. Практически все развертываемые компоненты — это контейнеры Docker, реализованные на Java, Kotlin и Spring Boot.

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

Проще всего было, конечно, забросить старую версию, «заморозив» ее функционал, и полностью переключиться на новую. Увы, переезд мог занять не один год, поэтому такой вариант не подходил. Пришлось искать другой способ.

Формулируем базовые принципы разработки

Чтобы команда могла продолжить нормальную поставку функций в существующую систему, мы решили сформулировать парочку руководящих принципов и неукоснительно соблюдать их во время обновления. Вот они:

Всегда развертывайте главную ветку. Было решено отказаться от версий — они осложнили бы реализацию новых функций. Кроме того, хотелось избежать долгоживущих форков и фич-веток, чтобы не поддерживать обе кодовые базы в актуальном состоянии для каждого изменения.

Для каждого компонента должен быть только один развертываемый артефакт. Feature-флаг определял бы, какую версию Elasticsearch использовать, а переключать его можно было бы с помощью переменной окружения.

Одинаковый набор интеграционных тестов для обеих версий Elasticsearch. Это позволило бы обеспечить обратную совместимость. Для небольшого числа случаев, когда сознательно менялся API, добавлялись специализированные тесты, учитывающие различия между версиями.

Увы, перейти от слов к делу всегда непросто. Вот некоторые из причин, по которым придерживаться этих принципов оказалось гораздо труднее, чем предполагалось:

  • API Elasticsearch для двух версий (старой и новой) были несовместимы.

  • Старый, кастомный Elasticsearch был совместим только с Java 8 — задействовать Java-модули не было возможности.

  • API Java Transport Client, используемый в ранних версиях Elasticsearch, требует, чтобы все дерево зависимостей Elasticsearch присутствовало в classpath Java. Это означает, что одновременный запуск двух версий клиента в одной JVM приводит к classpath-конфликтам.

Устраняем classpath-конфликты

Поскольку API разных версий Elasticsearch были несовместимы, приходилось загружать оба набора API-библиотек в среду исполнения всех компонентов, работавших с Elasticsearch.

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

После длительных исследований было решено остановиться на плагине Shadow для Gradle. Среди прочего, этот плагин умеет переводить классы на использование пакетов с другими именами. Например, класс TermQueryBuilder из пакета org.elasticsearch.index.query существует в обеих версиях Elasticsearch, но его реализация и API отличаются. Плагин Shadow позволяет изменить имя пакета на v1.org.elasticsearch.index.query для старой версии класса, а также обновить импорты и ссылки во всех других классах, которые его используют. В результате получаем два варианта «одного и того же» класса, загруженного в один и тот же classpath JVM, но с разными именами пакетов.

Используем разные версии

Как только было найдено решение для размещения обоих клиентских API Elasticsearch в одном classpath, сразу возник соблазн использовать их бесшовно в остальной части кода.

Для этого была создана отдельная библиотека-обертка и клиентский интерфейс поверх Elasticsearch API. Тут мы поступили прагматично, обернув только те части API, которые фактически использовались в компонентах. Были созданы две реализации клиентского интерфейса, по одной для каждой версии Elasticsearch. Схематично это выглядело следующим образом:

interface ESClient {
    fun search(request: ESSearchRequest): ESSearchResult
    // … index, scroll, etc
}

class ESClient1: ESClient {
    override fun search(request: ESSearchRequest): ESSearchResult {
        // Implementation of the old version using the shadowed packages
    }
}

class ESClient7: ESClient {
    override fun search(request: ESSearchRequest): ESSearchResult {
        // Implementation of the new version
    }
}

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

Подобная абстракция позволяет во все компоненты встроить одинаковый код для выполнения вызовов Elasticsearch, который не будет зависеть от версии. Конкретная реализация API-обертки определяется переменной окружения, устанавливаемой при запуске компонента. Соответствующая логика работы одного из компонентов показана на рисунке 1.

Рисунок 1. Логика работы компонента, позволяющая использовать оба клиентских API Elasticsearch
Рисунок 1. Логика работы компонента, позволяющая использовать оба клиентских API Elasticsearch

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

Итоги

У нашего подхода были свои сложности. Прежде всего потребовалось изменить большой объем кода в нескольких репозиториях, чтобы вписать в него клиентскую обертку, что заняло много времени. Также пришлось потратить немало времени на то, чтобы заставить плагин Shadow делать именно то, что было нужно.

Но как только настройка была завершена, пришло понимание, что описанный подход работает шикарно. Можно было разрабатывать новые функции, централизованно внося все изменения. Кроме того, после завершения миграции было очень легко избавиться от старого кода — достаточно было удалить обертку для старой версии, только и всего!

На этом заканчивается пятая часть серии публикаций об обновлении кластера Elasticsearch. Следите за обновлениями: очередная статья будет опубликована на следующей неделе.

Чтобы оставаться в курсе событий, подписывайтесь на нас в Twitter или Instagram.

P.S.

Читайте также в нашем блоге:

Комментарии (0)