
Сплютирование конструкций проекта 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)
}
}
}
}
- Наследуют от
ScanningRecipe
вместо прямо изRecipe
- Установить тип аккумулятора как
AtomicReference<String?>
- Упростить конфигурацию, когда вы не переопределяете корень пакета
- Начальный корень ненициализирован
- Пропустите вычисление, если корневой пакет установлен вручную
- Если это первый посещенный файл, аккумулятор удерживает
null
и мы можем установить (временный) корень в качестве текущего пакета - Одно из предыдущих вычислений не возвращалось общего корня - ничего
- Найдите самый длинный общий префикс между корнем удерживаемого пакета и текущим пакетом
- Единственная разница с исходным кодом: мы проверяем, был ли корневой пакет установлен вручную иначе, мы используем тот, который вычислен в первом проходе
Оптимизация рецепта
Возможно, вы заметили, что когда нет общего корня,напримерВ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
}
}
}
- Если корневой пакет был установлен, пропустите посещение
- Если одно из предыдущих вычислений устанавливает пустую строку, нет никакого общего корневого пакета: пропустите посещение
- Упростите посетителя, удалив предложение, когда аккумулятор является пустой строкой, так как это больше не может случиться
Обратите внимание, что 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 года
Оригинал