Сучасна диджитал-освіта для дітей — безоплатне заняття в GoITeens ×
Mazda CX 30
×

Надійна міграція застосунків за допомогою OpenRewrite

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті

Всім привіт. Я Сергій Моренець, розробник, викладач, спікер і технічний письменник, хочу поділитися з вами своїм досвідом роботи з такою цікавою темою, як автоматична міграція коду в проєктах. Міграція і рефакторинг постійно супроводжує розробників у їх роботі, а оскільки це в основному рутинна робота, то постійно виникає бажання її автоматизувати. Знайомство з бібліотекою OpenRewrite та її використання у наших проєктах спонукало мене написати цю статтю, щоб ви теж могли її застосовувати та економити свій час для більш творчих завдань.

Спочатку ми використовували цю технологію для міграції на Spring Boot 3, але потім виявилося, що в неї маса інших застосувань. Сподіваюся, що ця стаття виявиться корисною для читачів, і ви зможете при необхідності писати і свої «рецепти».

Про проєкт

OpenRewrite — це порівняно новий проєкт, який стартував у 2020 році і позиціонує себе як бібліотека для повномасштабного рефакторингу та виправлення (remediation) коду та конфігурації. Її автор — Джонатан Шнайдер, який на той час працював у Netflix та автоматизував деякі завдання за допомогою внутрішнього Gradle плагіна. Пізніше він створив окремий open-source проєкт та компанію Moderne.

Moderne — це SAAS-платформа, яка під капотом використовує OpenRewrite і пропонує enterprise фітчі, такі як консоль управління, пошук вразливостей у безпеці ваших застосунків, візуалізацію та автоматизацію всіх завдань. Шнайдер — один із небагатьох відомих розробників, який довго служив в армії (8 років) і навіть двічі проходив службу на Близькому Сході.

OpenRewrite написаний для Java, але з коробки підтримує Python, Kotlin, SQL і навіть Terraform. При цьому не потрібно помилятися щодо рефакторингу. OpenRewrite зовсім не призначений для перейменування класів і методів, він дозволяє здійснювати безпечну міграцію на нові версії ПЗ, проводити статичний аналіз коду, оновлювати ваш API і багато іншого. Сама бібліотека надає базові можливості, а різні готові розширення вже адаптовані для прикладних рецептів:

  1. Міграція на JDK 17.
  2. Перехід із Junit 4 на Junit 5.
  3. Міграція з Log4j на Slf4j.
  4. Перехід із Micronaut 2 на Micronaut 3.

Кожен «рецепт» може включати інші рецепти і складається з двох операцій: пошук і трансформацію коду (конфігурації). При цьому реалізовано це досить гнучко. Сам рецепт — це Java-клас, але який можна додатково налаштувати у YAML-файлі. Для Java-проєктів є дві головні бібліотеки — rewrite-migrate-java та rewrite-spring.

Може здатися надмірним використовувати окрему бібліотеку, якщо потрібно просто поміняти версію JDK на 17 для проєкту, але список рецептів тут досить великий:

  • org.openrewrite.java.migrate.Java8toJava11;
  • org.openrewrite.java.migrate.JavaVersion17;
  • org.openrewrite.java.migrate.lang.StringFormatted;
  • org.openrewrite.java.migrate.lombok.UpdateLombokToJava17;
  • org.openrewrite.github.SetupJavaUpgradeJavaVersion;
  • org.openrewrite.java.cleanup.InstanceOfPatternMatch;
  • org.openrewrite.java.migrate.lang.UseTextBlocks.

І він включає, наприклад, явне застосування тих фітч, які з’явилися з JDK 9 JDK 17 — текстові блоки, pattern matching, заміна Collections.singletonList() на List.of() і т.д. Є й складніші рецепти — наприклад, заміна типів з Google Guava на стандартні колекції/Optional JDK.

По суті, такий підхід заощаджує ваш час і позбавляє рутинних помилок. Загалом у OpenRewrite понад 600(!) рецептів на всі випадки життя.

Використовувати OpenRewrite дуже просто. Достатньо лише додати OpenRewrite плагін і вказати потрібні назви рецептів. Вся трансформація буде проведена під час мануального запуску (активації) відповідного плагіна, причому OpenRewrite гарантує збереження оригінального форматування коду. Також OpenRewrite підтримує холостий (dry) режим, коли він не вносить зміни на диску, а генерує патч-файл для аналізу та мануального застосування.

Пишемо рецепти на OpenRewrite

У нашому проєкті був наступний код::

       protected byte[] extractBody(final HttpServletRequest req) {
              try {
                     if (req.getInputStream().available() == 0) {
                           return null;
                     }
 
                     return ByteStreams.toByteArray(req.getInputStream());
              } catch (IOException e) {
                     throw new RouteException(e);
              }
       }

Тут використовується бібліотека Google Guava та її клас ByteStreams. Взагалі у Google Guava славна історія і дуже широка популярність, в результаті чого багато її компонентів (Optional, immutable колекції та інші утилітні класи) плавно перекочували в JDK. Не став винятком і клас ByteStreams. Якщо раніше для читання даних з InputStream доводилося самому писати код або використовувати Google Guava, Spring Framework (StreamUtils.copyToByteArray) або Apache Commons IO (IOUtils.toByteArray), то Java 9 в класі InputStream з’явився метод readAllBytes:

    public byte[] readAllBytes() throws IOException {
        return readNBytes(Integer.MAX_VALUE);
    }

Тому після міграції на Java 9 можна остаточно позбутися такого зоопарку в реалізаціях і використовувати стандартне рішення. Ми вирішили використовувати для міграції OpenRewrite, щоб більше дізнатися про його можливості та недоліки. Сам перехід включатиме кілька кроків:

  1. Перевірка версії JDK (9+).
  2. Пошук виклику потрібного методу ByteStreams.toByteArray.
  3. Заміна на виклик readAllBytes().
  4. Видалення непотрібних імпортів (якщо вони є).

Чим добре OpenRewrite — він не тільки містить безліч рецептів, але й дозволяє вам легко додавати свої. Додамо для них залежність:

       <dependency>
              <groupId>org.openrewrite</groupId>
              <artifactId>rewrite-java</artifactId>
              <version>7.40.3</version>
       </dependency>      

Також потрібно додати залежність до поточної LTS версії JDK (у нашому випадку 17):

       <dependency>
             <groupId>org.openrewrite</groupId>
             <artifactId>rewrite-java-17</artifactId>
              <version>7.40.3</version>              
             <scope>runtime</scope>
       </dependency>      

А також бібліотеку Java Objects Diff, яка використовується для порівняння об’єктів Java:

       <dependency>
              <groupId>de.danielbechler</groupId>
              <artifactId>java-object-diff</artifactId>
              <version>0.95</version>
       </dependency>

Створимо новий клас NoByteStreamsToByteArray, який має розширювати стандартний клас Recipe:

public class NoByteStreamsToByteArray extends Recipe {
 
       @Override
       public String getDisplayName() {
              return "Prefer `InputStream.readAllBytes()` in Java 9 or higher";
       }
 
}

Тут OpenRewrite вимагає перевизначити лише один стандартний метод getDisplayName, який повертає опис вашого рецепту. Але це мінімально допустима кількість методів, насправді їх, звичайно, буде набагато більше. Насамперед це getDescription, де ми детально пояснюємо зміст рецепту та можливі проблеми чи відмінності у реалізації:

       @Override
       public String getDescription() {
              return """                       
                           Replaces `ByteStreams.toByteArray(..)` .\
                Java 9 introduced `InputStream.readAllBytes() which is similar to `ByteStreams.toByteArray()`
                and uses the same approach, buffer size and maximum length of returned array.""";
       }

OpenRewrite пропонує певний не інтуїтивний набір обмежень для опису (має закінчуватися крапкою) і displayName (не повинно закінчуватися крапкою). Далі йде метод getTags, який повертає список тегів для категоризації рецептів за групами:

       @Override
       public Set<String> getTags() {
              return Set.of("guava");
       }

getApplicableTest додає вхідні фільтри для вихідних файлу вашого проєкту:

  1. Використання JDK9+.
  2. Наявність імпорту для ByteStreams.
       @Override
       protected @Nullable TreeVisitor<?, ExecutionContext> getApplicableTest() {
              return Applicability.and(new UsesJavaVersion<>(9),
                new UsesType<>("com.google.common.io.ByteStreams", false));
       }

Якщо якийсь вхідний файл не відповідає хоча б одній з цих умов, то для нього не буде запущений ваш рецепт. Тепер йде найголовніша частина рецепту — це пошук та трансформація. Чи можна використовувати для пошуку методу регулярні вирази? В принципі так, але це легко для найпростішого випадку:

       return ByteStreams.toByteArray(req.getInputStream());

що якщо вираз буде на кількох рядках:

       return ByteStreams
              .toByteArray(req.getInputStream());

Або використовуватиметься статичний імпорт:

return toByteArray(req.getInputStream());

Тоді треба перевірити, а що це за метод і взагалі якому класу належить. Або навіть такий варіант::

       return ByteStreams
              /* TODO check performance of toByteArray */
              .toByteArray(req.getInputStream());

У загальному випадку потрібно буде написати свій парсер Java-коду. OpenRewrite позбавляє цієї рутини, так як він сам вміє парсить Java (і не тільки) файли і представляти їх у зручній формі у вигляді LST (lossless semantic tree) дерева (схожого на DOM, але що зберігає форматування). Вам лише потрібно написати код, який аналізуватиме елементи цього дерева.

Насамперед додамо константу — сигнатуру методу, який ми шукаємо:

    private static final MethodMatcher BYTE_STREAMS_MATCHER =
              new MethodMatcher("com.google.common.io.ByteStreams toByteArray(java.io.InputStream)");

Тепер слід написати так званий visitor. Visitor — це патерн, який дозволяє обходити деревоподібні структури. В OpenRewrite для Java використовується клас JavaVisitor, а так як нам потрібно перевіряти вміст методів, то слід перевизначити метод visitMethodInvocation:

@Override
protected TreeVisitor<?, ExecutionContext> getVisitor() {
       return new JavaVisitor<>() {
              @Override
              public J visitMethodInvocation(MethodInvocation method, ExecutionContext p) {
                     return super.visitMethodInvocation(method, p);
              }
       };
}

У цьому методі потрібно спочатку перевірити, а чи є там той виклик, який нас цікавить, і далі виконувати основну роботу:

       if (BYTE_STREAMS_MATCHER.matches(method)) {
              maybeRemoveImport("com.google.common.io.ByteStreams");
       }

Тут буде зайвим видалити імпорт, але тільки якщо він потрібніший. Адже ми можемо використовувати й інші методи класу ByteStreams. Тепер нам потрібний код для наступної трансформації:

ByteStreams.toByteArray(expression) -> expression.getAllBytes();

А це означає, що нам потрібно якось знайти або змінну, або вираз, що йде аргументом при виклику методу toByteArray. У базовому класі Recipe є зручний метод getCursor(), який повертає покажчик на поточне положення парсера у вихідному файлі, тобто прямо на методі toByteArray. Нам лише потрібно перевірити, що це дійсно метод, взяти його перший аргумент (це буде InputStream), додати .readAllBytes() і замінити ним оригінальний виклик toByteArray (для цього є клас JavaTemplate):

J element = getCursor().getValue();
if (element instanceof MethodInvocation invocation) {
       List<Expression> args = invocation.getArguments();
Expression expression = args.get(0);
       if (expression instanceof Identifier identifier) {
              String name = identifier.getSimpleName();
              String template = name + ".readAllBytes()";
 
              return method.withTemplate(JavaTemplate.builder(this::getCursor, template).build(),
              method.getCoordinates().replace());
       }
}

Не факт, що наш код працюватиме правильно, тому додамо якнайбільше тестів. А для цього потрібна нова залежність:

        <dependency>
              <groupId>org.openrewrite</groupId>
              <artifactId>rewrite-test</artifactId>
              <version>7.40.3</version>              
        </dependency>            

Перший тест перевіряє класичний варіант — у нас Java 9 та використовується ByteStreams.toArray:

       @Test
       void change_ToByteArray_replace() {
              rewriteRun(version(java("""
                               package demo;
                               import com.google.common.io.ByteStreams;
                               import java.io.InputStream;
 
                               class Sample {
                                    void execute(InputStream is) {
                                         byte[] array = ByteStreams.toByteArray(is);
                                    }
                               }
                           """, """
                               package demo;
                               import java.io.InputStream;
 
                               class Sample {
                                    void execute(InputStream is) {
                                         byte[] array = is.readAllBytes();
                                    }
                               }
                           """), 9));
       }

Потім перевіримо те саме, але вже зі статичним імпортом:

       @Test
       void change_ToByteArrayWithStaticImport_replace() {
              rewriteRun(version(java("""
                               package demo;
                               import static com.google.common.io.ByteStreams.toByteArray;
                               import java.io.InputStream;
 
                               class Sample {
                                    void execute(InputStream is) {
                                         byte[] array = toByteArray(is);
                                    }
                               }
                           """, """
                               package demo;
                               import java.io.InputStream;
 
                               class Sample {
                                    void execute(InputStream is) {
                                          byte[] array = is.readAllBytes();
                                    }
                               }
                           """), 9));
       }

Якщо ми використовуємо наш власний метод toByteArray, то заміна не повинна відбуватися. У такому випадку в тесті можна вказати лише аргумент «before»:

        @Test
       void change_CustomToByteArray_noReplace() {
              rewriteRun(version(java("""
                               package demo;
                               import java.io.InputStream;
 
                               class Sample {
                                    void execute(InputStream is) {
                                         byte[] array = toByteArray(is);
                                    }
 
                                    void toByteArray(InputStream is) {
                                        return null;
                                    }
                               }
                           """), 9));
       }

Якщо ми використовуємо Java 8, заміна також не передбачена:

       @Test
       void change_ToByteArrayWithJava8_noReplace() {
              rewriteRun(version(java("""
                               package demo;
                               import static com.google.common.io.ByteStreams.toByteArray;
                               import java.io.InputStream;
 
                               class Sample {
                                    void execute(InputStream is) {
                                         byte[] array = toByteArray(is);
                                    }
                               }
                           """), 8));
       }

При тестуванні з’ясовується, що можна трохи переробити вхідні умови. Зараз ми перевіряємо наявність типу ByteStreams, а нам потрібно перевірити лише присутність одного методу — toByteArray. Якщо використовуються інші методи цього класу, то такий Java-файл повинен залишитися без змін:

@Override
protected @Nullable TreeVisitor<?, ExecutionContext> getApplicableTest() {
       return Applicability.and(new UsesJavaVersion<>(9), Applicability.or(new UsesMethod<>(BYTE_STREAMS_MATCHER)));
       }

Запускаємо тести, усі тести пройшли успішно. Таким чином ми написали свій перший рецепт, який дозволяє використовувати нові фітчі від JDK. Залишилося перевірити його роботу в режимі користувача (за допомогою плагіна). Чи може OpenRewrite автоматично знайти наш рецепт за його класом?

Запустимо OpenRewrite плагін та вкажемо наш клас-рецепт:

mvn -U org.openrewrite.maven:rewrite-maven-plugin:dryRun -Drewrite.recipeArtifactCoordinates=org.openrewrite.recipe:rewrite-spring:LATEST -Drewrite.activeRecipes=org.itsimulator.germes.common.infra.migration.NoByteStreamsToByteArray

При цьому тут goal dryRun, а не run для того, щоб OpenRewrite не вносив зміни на диск, а згенерував patch-файл для аналізу. Після запуску отримуємо помилку:

Recipes not found: org.itsimulator.germes.common.infra.migration.NoByteStreamsToByteArray

Якщо згадати Spring фреймворк, то там у кожного бина є як мінімум дві властивості: ідентифікатор та повний шлях до класу. Причому реєструвати бін можна у різний спосіб: як декларативно (XML, Groovy, Java анотації), так і імперативно. Щодо цього OpenRewrite чимось схожий на Spring. Нам потрібно обов’язково зареєструвати рецепт, причому в YAML-файлі, який має перебувати в папці src/main/resources/META-INF/rewrite. Додамо файл byteStreams.yml:

---
type: specs.openrewrite.org/v1beta/recipe
name: it-simulator.NoByteStreamsToByteArray
recipeList:
  - org.itsimulator.germes.common.infra.migration.NoByteStreamsToByteArray

Тут властивість name — унікальний ідентифікатор рецепту, а recipeList містить посилання на вкладені рецепти, але ми вкажемо повний шлях до нашого класу-рецепту.

Особливість OpenRewrite — він шукає YAML-файли лише у JAR-файлах. Якщо просто розмістити його в папці src/main/resources вашого поточного проєкту, він його швидше за все не знайде. Тому перемістимо рецепт в окремий проєкт і вкажемо його у конфігурації Maven OpenRewrite плагіна:

<build>
    <plugins>
        <plugin>
            <groupId>org.openrewrite.maven</groupId>
            <artifactId>rewrite-maven-plugin</artifactId>
            <version>4.45.0</version>
            <configuration>
                <activeRecipes>
                    <recipe>it-simulator.NoByteStreamsToByteArray</recipe>
                </activeRecipes>
            </configuration>
            <dependencies>
                <dependency>
                    <groupId>demo</groupId>
                    <artifactId>rewrite</artifactId>
                    <version>4.0.6</version>
                </dependency>
            </dependencies>
        </plugin>
    </plugins>
</build>

Запускаємо плагін:

mvn -U org.openrewrite.maven:rewrite-maven-plugin:dryRun

І після його роботи в папці target/rewrite буде знаходиться патч rewrite.patch зі змінами:

--- a/src/main/java/demo/Sample.java
+++ b/src/main/java/demo/Sample.java
@@ -1,7 +1,5 @@ org.itsimulator.germes.common.infra.migration.NoByteStreamsToByteArray
 package demo;
 
-import com.google.common.io.ByteStreams;
-
 import java.io.ByteArrayInputStream;
 import java.io.IOException;
 import java.io.InputStream;
@@ -9,7 +7,7 @@
 public class Sample {
     public static void main(String[] args) throws IOException {
         InputStream is = new ByteArrayInputStream(new byte[] {});
-        ByteStreams.toByteArray(is);
+        is.readAllBytes();
 
     }
 }

Цікава деталь. У роботі над OpenRewrite, загалом невеличкою бібліотекою, беруть участь понад 80 розробників. І якщо ви напишете рецепт, який буде корисним для Java-спільноти в цілому, то можете додати його до загального каталогу.

Обмеження

Чи є ця бібліотека всесильною та панацею від усіх бід? По-перше, вона не застрахована від помилок. По-друге, вона працює тільки з вихідними файлами всередині вашого проєкту. Якщо у вас конфігурація зберігається в *.properties/YAML файлах, то вона буде успішно оброблена рецептами OpenRewrite. Але якщо конфігурація вже зберігається в Consul або GitHub на production? На жаль, але тут OpenRewrite буде безсилою. Вам доведеться робити міграцію вручну.

Є й інші випадки. Наприклад, в Java 16 в інтерфейсі Stream з’явився зручний метод toList:

    default List<T> toList() {
        return (List<T>) Collections.unmodifiableList(new ArrayList<>(Arrays.asList(this.toArray())));
    }

Він повертає immutable список, а immutable типи завжди краще mutable. І може виникнути ідея переписати у нашому коді всі виклики типу:

.collect(Collectors.toList()))

на

.toList();

Але тут виникнуть дві перешкоди:

  1. Потрібно перевірити source compatibility (не нижче JDK 16) — це легко перевірити.
  2. Потрібно перевірити, що повернута колекція не змінюватиметься.

Другий пункт виконати набагато складніше. Доведеться перевірити, що у колекції не викликаються mutable методи (add/remove/clear), а це ускладнить рецепт на кілька порядків. А для цього потрібно простежити весь стек викликів і те, куди ця колекція передається. В результаті ваш рецепт загрожує перетворитися на величезний аналізатор коду з елементами компілятора.

Висновки

OpenRewrite — унікальний продукт, який практично не має конкурентів. Завдяки його величезній базі (понад 600 рецептів) ви легко знайдете рецепти для своїх потреб. А якщо ні, то можна легко написати власні рецепти. Про його простоту і велику документацію свідчить те, що на Stackoverflow всього 19 питань з тегом OpenRewrite.

Ми у своїй роботі використовували OpenRewrite для міграції проєктів на Spring Boot 3, і це спростило наше завдання і заощадило час.

👍ПодобаєтьсяСподобалось3
До обраногоВ обраному0
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Я звичийно скептично ставлюсь до ChatGPT але це стаття приблизно такого ж рівня

Підписатись на коментарі