Сплютирование конструкций проекта Kotlin с рецептами сканирования Openrewrite

Сплютирование конструкций проекта Kotlin с рецептами сканирования Openrewrite

20 июня 2025 г.

Я начал открывать Openrewrite на прошлой неделе, написав котлинрецептЭто перемещает файлы Kotlin в соответствии с официальной рекомендацией структуры каталогов. Я упомянул некоторые будущие работы, и вот они. В этом посте я хочу описать, как вычислить корневой пакет вместо того, чтобы позволить пользователю установить его.

Напоминание

На прошлой неделе я разработал рецепт, чтобы следовать рекомендации Котлина относительно структуры каталогов:

В проектах Pure Kotlin рекомендуемая структура каталогов следует за структурой пакета с общим корневым пакетом опущены. Например, если весь код в проекте находится вorg.example.kotlinпакет и его подпакинги, файлы сorg.example.kotlinпакет должен быть размещен непосредственно под корнем исходного и файла вorg.example.kotlin.network.socketДолжен быть в подкаталоге сети/сокета исходного корня.

-Структура каталога

В проекте Java, если у вас есть пакетыch.frankel.fooВch.frankel.bar, иch.frankel.baz, вы получите следующую структуру:

src
 |__ main
    |__ java
       |__ ch
          |__ frankel
             |__ foo
             |__ bar
             |__ baz

В проектах Kotlin многие разработчики следуют той же структуре, что и выше, но его можно сгладить, как:

src
 |__ main
    |__ kotlin
       |__ foo
       |__ bar
       |__ baz

Работа

Оригинальная версия моего рецепта поручена самостоятельно настроить корневую пакет,напримерВch.frankelДля приведенного выше примера. Тем не менее, должно быть возможно автоматически вычислять, от просмотра исходных файлов. Он добавляет дополнительный шаг к процессу: перед перемещением файла в корень рецепт должен смотреть на каждый исходный файл, получить пакет, вычислить самый длинный префикс с существующим корнем, сделать его корнем и перейти к следующему исходному файлу. ОбычныйRecipeне работает в этом случае. Нам нужно переключиться наScanningRecipe:

Если рецепт должен генерировать новые исходные файлы или необходимо просмотреть все исходные файлы перед внесением изменений, он должен бытьScanningRecipeПолем АScanningRecipeрасширяет нормальноеRecipeи добавляет два ключевых объекта:accumulatorиscannerПолем АaccumulatorОбъект - это пользовательская структура данных, определяемая самим рецептом для хранения любой информации, которую должен функционировать рецепт. Аscannerобъект - это посетитель, который населяетaccumulatorс данными.

-Сканирование рецептов

Рецепты сканирования предлагают два шага: первые, кто собирает данные, второй, кто выполняет работу.

Мы должны спроектировать наш алгоритм в пределах ограничений Openrewrite, и они являются следующими: на первом этапе для каждого исходного файла Openrewrite вызоветgetScanner()Метод, который возвращает посетителя по нашему выбору. В свою очередь, Openrewrite называет методы посетителя, которые могут получить доступ к аккумулятору.

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

  • Если значениеnull, что происходит в первом посетителе, установите корень пакета в пакет исходного файла.
  • Если значение является пустой строкой, пропустите - посмотрите ниже.
  • В любом другом случае вычислите новый пакет root, найдя самый длинный префикс между существующим корнем пакета и пакетом исходного файла.

Это может привести к пустой строке, указывая на то, что у пакетов нет общего корня,напримерВch.frankel.fooиorg.frankel.fooПолем

Вот обновленный код:

class FlattenStructure(private val rootPackage: String?) : ScanningRecipe<AtomicReference<String?>>() { //1-2

  constructor() : this(null)                                                   //3

  override fun getDisplayName(): String = "Flatten Kotlin package directory structure"
  override fun getDescription(): String =
    "Move Kotlin files to match idiomatic layout by omitting the root package according to the official recommendation."

  override fun getInitialValue(ctx: ExecutionContext) = AtomicReference<String?>(null) //4

  override fun getScanner(acc: AtomicReference<String?>): TreeVisitor<*, ExecutionContext> {
    if (rootPackage != null) return TreeVisitor.noop<Tree, ExecutionContext>() //5
    return object : KotlinIsoVisitor<ExecutionContext>() {
      override fun visitCompilationUnit(cu: K.CompilationUnit, ctx: ExecutionContext): K.CompilationUnit {
        val packageName = cu.packageDeclaration?.packageName ?: return cu
        val computedPackage = acc.get()
        when (computedPackage) {
          null -> acc.set(packageName)                                         //6
          "" -> {}                                                             //7
          else -> {
            val commonPrefix = packageName.commonPrefixWith(computedPackage).removeSuffix(".") //8
            acc.set(commonPrefix)
          }
        }
        return cu
      }
    }
  }

  override fun getVisitor(acc: AtomicReference<String?>): TreeVisitor<*, ExecutionContext> {
    return object : KotlinIsoVisitor<ExecutionContext>() {
      override fun visitCompilationUnit(cu: K.CompilationUnit, ctx: ExecutionContext): K.CompilationUnit {
        val packageName = cu.packageDeclaration?.packageName ?: return cu
        val packageToSet: String? = rootPackage ?: acc.get()                   //9
        if (packageToSet == null || packageToSet.isEmpty()) return cu
        val relativePath = packageName.removePrefix(packageToSet).removePrefix(".")
          .replace('.', '/')
        val filename = cu.sourcePath.fileName.toString()
        val newPath: Path = Paths.get("src/main/kotlin")
          .resolve(relativePath)
          .resolve(filename)
        return cu.withSourcePath(newPath)
      }
    }
  }
}
  1. Наследуют отScanningRecipeвместо прямо изRecipe
  2. Установить тип аккумулятора какAtomicReference<String?>
  3. Упростить конфигурацию, когда вы не переопределяете корень пакета
  4. Начальный корень ненициализирован
  5. Пропустите вычисление, если корневой пакет установлен вручную
  6. Если это первый посещенный файл, аккумулятор удерживаетnullи мы можем установить (временный) корень в качестве текущего пакета
  7. Одно из предыдущих вычислений не возвращалось общего корня - ничего
  8. Найдите самый длинный общий префикс между корнем удерживаемого пакета и текущим пакетом
  9. Единственная разница с исходным кодом: мы проверяем, был ли корневой пакет установлен вручную иначе, мы используем тот, который вычислен в первом проходе

Оптимизация рецепта

Возможно, вы заметили, что когда нет общего корня,напримерВch.frankel.fooиorg.frankel.foo, мы все равно сканируем все файлы. В небольшой кодовой базе это не большая проблема, но при сканировании миллионов исходных файлов это огромная трата циклов процессора и времени. Если вы запускаете рецепт в облаке, он напрямую переводится на деньги. Мы должны прекратить сканирование, как только мы обнаружим, что вычислимый корень пакета является пустой строкой для оптимизации рецепта.

Вот обновленный код:

override fun getScanner(acc: AtomicReference<String?>): TreeVisitor<*, ExecutionContext> {
  if (rootPackage != null) return TreeVisitor.noop<Tree, ExecutionContext>()   //1
  val currentPackage = acc.get()
  if (currentPackage == "") return TreeVisitor.noop<Tree, ExecutionContext>()  //2
  return object : KotlinIsoVisitor<ExecutionContext>() {
    override fun visitCompilationUnit(cu: K.CompilationUnit, ctx: ExecutionContext): K.CompilationUnit {
      val packageName = cu.packageDeclaration?.packageName ?: return cu
      // Different call than the one above!
      val currentPackage = acc.get()
      // First scanned file
      if (currentPackage == null) acc.set(packageName)                         //3
      else {
        // Find the longest common prefix between the stored package and the current one
        val commonPrefix = packageName.commonPrefixWith(currentPackage).removeSuffix(".")
        acc.set(commonPrefix)
      }
      return cu
    }
  }
}
  1. Если корневой пакет был установлен, пропустите посещение
  2. Если одно из предыдущих вычислений устанавливает пустую строку, нет никакого общего корневого пакета: пропустите посещение
  3. Упростите посетителя, удалив предложение, когда аккумулятор является пустой строкой, так как это больше не может случиться

Обратите внимание, что Openrewrite все еще сканирует каждый файл, но, по крайней мере, не посещает его благодаря посетителю NO-OP.

Подсчет посещений

Я сделал пару попыток, прежде чем найти правильный подход к вышесказанному. Чтобы убедиться, что я понял это правильно, я хотел отобразить количество посещений сканера. Мы можем использовать аккумулятор для увеличения количества посещений. Вот изменения, которые я сделал:

  • Мигрировал изAtomicReference<String?>кAtomicReference<Pair<Int, String?>>
  • АgetInitialValue()функция возвращаетAtomicReference<Pair<Int, String?>>(0 to null)
  • Во время каждого посещения:
    • Получить количество посещений от аккумулятора
    • Увеличить это
    • Распечатать его
    • Хранить его в аккумуляторе
  • Обновить тесты соответственно

С упаковкамиch.frankel.blog.fooВorg.frankel.blog.bar, иorg.frankel.blog.baz, журнал показывает:

[INFO] [stdout] FlattenStructure: 1 file visited
[INFO] [stdout] FlattenStructure: 2 files visited

Изменяясьch.frankel.blog.fooкorg.frankel.blog.foo, журнал меняется на:

[INFO] [stdout] FlattenStructure: 1 files visited
[INFO] [stdout] FlattenStructure: 2 files visited
[INFO] [stdout] FlattenStructure: 3 files visited

Поскольку я внес эти изменения просто для того, чтобы подтвердить мое понимание того, как работает Openrewrite, я положил их вvisits_countветвь на GitHub. Чтобы увидеть различия, выполнитьgit diff master visits_countПолем

Заключение

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

Рецепт все еще не сериализуется, хотя это рекомендация. Я также заметил, что мои тесты не использовали тестирование API Openrewrite. Есть еще много работы!

Полный исходный код для этого поста можно найти на GitHub:

https://github.com/ajavageek/flatten-kotlin-recipe?embedable=true

Идти дальше:

  • Сканирование рецептов
  • Пример кода существующего рецепта сканирования
  • Существующие рецепты сканирования

Первоначально опубликовано вJava Geek15 июня 2025 года


Оригинал
PREVIOUS ARTICLE
NEXT ARTICLE