--- title: Структура проекта description: Каталоги исходников, пакеты, многомодульность, смешанный Kotlin+Java код и convention plugins tags: - kotlin - tooling - gradle --- Структура Kotlin-проекта наследует соглашения Maven, но с двумя отличиями: каталог называется `kotlin`, а требование «путь пакета = путь каталога» необязательно. ``` myapp/ ├── settings.gradle.kts ├── build.gradle.kts ├── gradle.properties ├── gradle/ │ ├── libs.versions.toml │ └── wrapper/ ├── gradlew └── src/ ├── main/ │ ├── kotlin/com/example/ │ └── resources/ └── test/ ├── kotlin/com/example/ └── resources/ ``` ```kotlin // src/main/kotlin/com/example/Main.kt package com.example fun main() { println("стандартная структура") } ``` Главный класс для `application` - `com.example.MainKt`: имя файла с суффиксом `Kt`. Подробнее: [Kotlin в байт-коде](../Взаимодействие%20с%20Java/Kotlin%20в%20байт-коде.md) ## Source sets | Source set | Каталоги | Зависимости | |---|---|---| | `main` | `src/main/kotlin`, `src/main/java`, `src/main/resources` | `implementation`, `api` | | `test` | `src/test/kotlin`, `src/test/java`, `src/test/resources` | `testImplementation` + всё из `main` | Gradle-плагин Kotlin добавляет `src/main/java` в Kotlin source set, поэтому `.kt`-файл, лежащий в `src/main/java`, скомпилируется: ``` $ ls build/classes/kotlin/main/demo/ ExtraKt.class # из src/main/java/demo/Extra.kt KotlinImpl.class # из src/main/kotlin/demo/KotlinImpl.kt ``` Работает, но вводит в заблуждение - держите Kotlin в `src/main/kotlin`. Добавить свой source set: ```kotlin // build.gradle.kts sourceSets { val integrationTest by creating { kotlin.srcDir("src/integrationTest/kotlin") resources.srcDir("src/integrationTest/resources") compileClasspath += sourceSets["main"].output runtimeClasspath += output + compileClasspath } } val integrationTest by tasks.registering(Test::class) { testClassesDirs = sourceSets["integrationTest"].output.classesDirs classpath = sourceSets["integrationTest"].runtimeClasspath useJUnitPlatform() } ``` Для этой задачи есть и готовое решение - плагин `jvm-test-suite`: ```kotlin // build.gradle.kts testing { suites { val test by getting(JvmTestSuite::class) { useKotlinTest() } register("integrationTest") { useKotlinTest() dependencies { implementation(project()) } } } } ``` ## Пакеты и каталоги В Kotlin объявленный пакет не обязан совпадать с путём каталога: ```kotlin // файл src/main/kotlin/Utils.kt — лежит в корне package com.example.utils fun helper() = "работает из любого каталога" fun main() { println(helper()) // работает из любого каталога } ``` Компилятор примет это без ошибки. Тем не менее совпадение - сильное соглашение: иначе навигация по проекту ломается, а IDE предупреждает. Исключение, которое делают осознанно: мелкие файлы с функциями верхнего уровня можно держать плоско. Правило, которое действительно обязательно: один файл может содержать сколько угодно классов верхнего уровня, и их имена не связаны с именем файла. Это заметно отличается от Java. Подробнее: [Пакеты и импорты](../Синтаксис%20и%20типы/Базовый%20синтаксис/Пакеты%20и%20импорты.md) ## Как раскладывать файлы ``` com/example/ ├── Main.kt # точка входа ├── domain/ │ ├── User.kt # data class User + связанные типы │ ├── Order.kt │ └── OrderStatus.kt # enum или sealed ├── repository/ │ ├── UserRepository.kt # interface │ └── UserRepositoryImpl.kt ├── service/ │ └── OrderService.kt ├── api/ │ ├── Routes.kt # функции верхнего уровня │ └── Dto.kt # несколько data class в одном файле └── util/ ├── Strings.kt # функции-расширения └── Dates.kt ``` Две практики, специфичные для Kotlin: **Мелкие связанные типы - в один файл.** `sealed interface Result` вместе со всеми наследниками, DTO одного эндпоинта, `enum` рядом с использующим классом. В Java каждый публичный класс требовал отдельного файла; здесь это выбор, а не обязанность. **Расширения группируются по типу-получателю, а не по назначению.** `Strings.kt` со всеми расширениями `String`, `Collections.kt` - для коллекций. Это заменяет утилитные классы вроде `StringUtils`. Подробнее: [Функции-расширения](../Функциональный%20Kotlin/Функции-расширения.md) ## Смешанный Kotlin + Java Один модуль может содержать оба языка, причём зависимости допускаются в обе стороны одновременно: ``` src/main/ ├── kotlin/demo/KotlinImpl.kt └── java/demo/ ├── JavaBase.java └── UsesKotlin.java ``` ```java // src/main/java/demo/JavaBase.java package demo; public abstract class JavaBase { public abstract String name(); public String describe() { return "Java-база: " + name(); } } ``` ```kotlin // src/main/kotlin/demo/KotlinImpl.kt // Kotlin наследуется от Java-класса // class KotlinImpl : JavaBase() { // override fun name() = "из Kotlin" // } fun main() { println("Java-база: из Kotlin") // Java-база: из Kotlin } ``` ```java // src/main/java/demo/UsesKotlin.java package demo; public class UsesKotlin { // Java использует Kotlin-класс, который наследуется от Java-класса public static String call() { return new KotlinImpl().describe(); } } ``` Это работает, хотя выглядит как циклическая зависимость. Порядок компиляции: ```mermaid graph LR A["Java-исходники"] -->|"читаются как стабы"| B["kotlinc:
компиляция Kotlin"] B --> C["Kotlin .class"] C -->|"в classpath"| D["javac:
компиляция Java"] D --> E["Java .class"] ``` `kotlinc` получает `.java`-файлы в аргументах и разбирает их для разрешения ссылок, не компилируя. Затем `javac` компилирует Java, уже видя готовые Kotlin-классы. Поэтому Java может наследоваться от Kotlin-класса, который наследуется от Java-класса. Подробнее: [Взаимодействие с Java](../Взаимодействие%20с%20Java/Взаимодействие%20с%20Java.md) Ограничение: эта схема работает в пределах одного модуля. Между модулями циклов быть не может. ## Многомодульный проект ``` myapp/ ├── settings.gradle.kts ├── build.gradle.kts ├── gradle/libs.versions.toml ├── build-logic/ # convention plugins │ ├── settings.gradle.kts │ └── src/main/kotlin/ │ └── kotlin-conventions.gradle.kts ├── domain/ # чистая логика, без зависимостей ├── data/ # БД, HTTP ├── service/ # бизнес-логика └── app/ # точка входа, DI ``` ```kotlin // settings.gradle.kts rootProject.name = "myapp" include("domain", "data", "service", "app") ``` ```kotlin // корневой build.gradle.kts plugins { kotlin("jvm") version "2.4.0" apply false } subprojects { apply(plugin = "org.jetbrains.kotlin.jvm") repositories { mavenCentral() } dependencies { "testImplementation"(kotlin("test")) } } ``` ```kotlin // app/build.gradle.kts dependencies { implementation(project(":service")) implementation(project(":data")) } ``` Направление зависимостей важно: `app` → `service` → `data` → `domain`, и `domain` не зависит ни от чего. Gradle обнаружит цикл между модулями и откажется собирать. ## Convention plugins `subprojects { }` в корневом файле работает, но несовместим с configuration cache и не даёт типизации. Правильное решение - convention plugin: ``` build-logic/ ├── settings.gradle.kts ├── build.gradle.kts └── src/main/kotlin/kotlin-conventions.gradle.kts ``` ```kotlin // build-logic/build.gradle.kts plugins { `kotlin-dsl` } repositories { mavenCentral() gradlePluginPortal() } dependencies { implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:2.4.0") } ``` ```kotlin // build-logic/src/main/kotlin/kotlin-conventions.gradle.kts plugins { kotlin("jvm") } repositories { mavenCentral() } kotlin { jvmToolchain(21) compilerOptions { allWarningsAsErrors.set(true) } } dependencies { "testImplementation"(kotlin("test")) } tasks.withType { useJUnitPlatform() } ``` ```kotlin // settings.gradle.kts pluginManagement { includeBuild("build-logic") } rootProject.name = "myapp" include("domain", "data", "service", "app") ``` ```kotlin // domain/build.gradle.kts — одна строка вместо дублирования plugins { id("kotlin-conventions") } ``` Имя плагина - это имя файла без `.gradle.kts`. Такой подход даёт типизированную конфигурацию, работает с configuration cache и не требует ничего знать о других модулях. Исторически ту же роль играл каталог `buildSrc/`. Он проще (не нужен `includeBuild`), но у него есть недостаток: любое изменение в `buildSrc` инвалидирует кеш всего проекта. Для больших проектов предпочтителен `build-logic` как included build. ## Ресурсы ```kotlin fun main() { // чтение из src/main/resources val stream = object {}.javaClass.getResourceAsStream("/config.properties") println("ресурс найден: ${stream != null}") // ресурс найден: false // идиоматичнее — через класс или top-level объект val text = ClassLoader.getSystemResourceAsStream("missing.txt") ?.bufferedReader()?.readText() ?: "ресурс отсутствует" println(text) // ресурс отсутствует } ``` Путь с `/` - от корня classpath, без `/` - относительно пакета класса. Файлы из `src/main/resources` попадают в корень JAR. Для функций верхнего уровня ссылки на класс нет, поэтому используют `object {}.javaClass` или `ClassLoader`. ## Файлы, которые нужны в репозитории | Путь | Коммитить | |---|---| | `gradlew`, `gradlew.bat` | да | | `gradle/wrapper/gradle-wrapper.jar` | да | | `gradle/wrapper/gradle-wrapper.properties` | да | | `gradle/libs.versions.toml` | да | | `gradle.properties` | да | | `.gradle/`, `build/` | нет | | `local.properties` | нет | | `.idea/` | частично (`codeStyles`, `inspectionProfiles`) | | `*.iml` | нет | ``` # .gitignore build/ .gradle/ local.properties *.iml .idea/* !.idea/codeStyles/ !.idea/inspectionProfiles/ .kotlin/ ``` Каталог `.kotlin/` появился в Kotlin 2.x - это служебные данные компилятора, их не коммитят. > [!info] Отличия от Java > | Java | Kotlin | > |---|---| > | `src/main/java` | `src/main/kotlin` | > | один публичный класс на файл | сколько угодно | > | имя файла = имя публичного класса | не связаны | > | пакет обязан совпадать с каталогом | не обязан | > | утилитный класс `StringUtils` | файл `Strings.kt` с расширениями | > | `Main.class` | `MainKt.class` | > > **Освобождение от «один класс на файл» меняет раскладку кода.** В Java структура каталогов повторяла структуру типов: 20 классов - 20 файлов. В Kotlin файл - единица логической группировки, а не технического ограничения. `sealed interface` со всеми вариантами в одном файле читается целиком, а не по кускам. Побочный эффект: навигация «по файлам» работает хуже, и важнее осмысленные имена файлов. > > **Несовпадение пакета и каталога разрешено, но соглашение всё равно действует.** Kotlin не требует соответствия, потому что для компилятора оно не нужно: пакет указан в файле. Однако IDE, инструменты анализа и люди ожидают совпадения, поэтому отклоняются от него редко и только для плоских файлов с функциями. > > **Смешанная компиляция в одном модуле - то, чего нет в однодходовом `javac`.** Двухфазная схема (kotlinc читает Java-стабы, затем javac видит Kotlin-классы) позволяет взаимные ссылки между языками внутри модуля. Это ключевая возможность для миграции: можно конвертировать по одному файлу, не трогая остальное. Ограничение - только в пределах модуля. > > **Convention plugins - ответ Gradle на отсутствие наследования `pom.xml`.** В Maven общая конфигурация выносится в parent POM, и это декларативно. В Gradle аналога не было: писали `subprojects { }` или `allprojects { }`, что работает, но несовместимо с configuration cache и лишено типизации. Convention plugin - это обычный Gradle-плагин, лежащий в проекте: типизированный, кешируемый, переиспользуемый. > > Сравнить: [javac](../../Программирование%20на%20Java/JDK/javac.md), [jar](../../Программирование%20на%20Java/JDK/jar.md) > [!warning] Подводные камни > **Главный класс для `application` - не имя файла.** Для `src/main/kotlin/com/example/Main.kt` это `com.example.MainKt`, а не `com.example.Main`. Ошибка проявляется как `ClassNotFoundException` при `./gradlew run`. Если в файле стоит `@file:JvmName("App")`, то класс - `com.example.App`. > > **Kotlin-файлы в `src/main/java` компилируются молча.** Плагин добавляет этот каталог в Kotlin source set, так что ошибки не будет - но структура станет путаной, а инструменты, ожидающие Java в `java/`, могут сбиться. > > **`buildSrc` инвалидирует кеш всего проекта.** Любая правка в `buildSrc` заставляет Gradle пересобрать все модули. На больших проектах это минуты на каждое изменение конфигурации - используйте `build-logic` как included build. > > **`subprojects { }` несовместим с configuration cache.** Он обращается к другим проектам в фазе конфигурации, что кеш запрещает. При включении `org.gradle.configuration-cache=true` появятся ошибки «invocation of 'Project.subprojects'». Решение - convention plugins. > > **Циклы между модулями запрещены.** Если `data` ссылается на `service`, а `service` на `data`, Gradle откажется собирать с ошибкой «Circular dependency between tasks». Внутри одного модуля цикл Kotlin↔Java допустим, между модулями - нет. Общие типы выносят в третий модуль. > > **Ресурс не найден из функции верхнего уровня.** `javaClass` недоступен вне класса; `this::class.java` в top-level функции не скомпилируется. Используйте `object {}.javaClass.getResourceAsStream(...)` или `ClassLoader.getSystemResourceAsStream(...)`. > > **Путь к ресурсу чувствителен к ведущему слэшу.** `getResourceAsStream("config.txt")` ищет относительно пакета класса, `getResourceAsStream("/config.txt")` - от корня classpath. Первый вариант неожиданно возвращает `null`, если класс лежит в пакете. > > **`gradle-wrapper.jar` обязателен в репозитории.** Без него `./gradlew` не работает, и сборка пойдёт локальным Gradle другой версии. Если политика запрещает бинарники, добавьте в CI шаг `gradle/actions/wrapper-validation`. > > **Переименование файла меняет имя класса.** `Utils.kt` даёт `UtilsKt`, после переименования в `Helpers.kt` - `HelpersKt`. Для Java-клиентов и рефлексии это ломающее изменение; фиксируйте имя через `@file:JvmName`. > > **Тесты не видят `internal` из другого модуля.** `internal` в Kotlin - это видимость в пределах модуля компиляции, и `src/test` - отдельный source set. Gradle-плагин специально связывает `test` с `main`, поэтому тесты `internal`-члены видят; но тесты **другого** модуля - нет. > > **Файл без `package` попадает в корневой пакет.** Такой класс не импортируется из других пакетов нормальным способом и недоступен из Java-кода в пакете. Всегда объявляйте пакет, кроме скриптов. Далее: [Компилятор K2](Компилятор%20K2.md)