20 KiB
title, description, tags
| title | description | tags | |||
|---|---|---|---|---|---|
| Структура проекта | Каталоги исходников, пакеты, многомодульность, смешанный Kotlin+Java код и convention plugins |
|
Структура 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/
// src/main/kotlin/com/example/Main.kt
package com.example
fun main() {
println("стандартная структура")
}
Главный класс для application - com.example.MainKt: имя файла с суффиксом Kt. Подробнее: Kotlin в байт-коде
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:
// 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:
// build.gradle.kts
testing {
suites {
val test by getting(JvmTestSuite::class) {
useKotlinTest()
}
register<JvmTestSuite>("integrationTest") {
useKotlinTest()
dependencies {
implementation(project())
}
}
}
}
Пакеты и каталоги
В Kotlin объявленный пакет не обязан совпадать с путём каталога:
// файл src/main/kotlin/Utils.kt — лежит в корне
package com.example.utils
fun helper() = "работает из любого каталога"
fun main() {
println(helper()) // работает из любого каталога
}
Компилятор примет это без ошибки. Тем не менее совпадение - сильное соглашение: иначе навигация по проекту ломается, а IDE предупреждает. Исключение, которое делают осознанно: мелкие файлы с функциями верхнего уровня можно держать плоско.
Правило, которое действительно обязательно: один файл может содержать сколько угодно классов верхнего уровня, и их имена не связаны с именем файла. Это заметно отличается от Java. Подробнее: Пакеты и импорты
Как раскладывать файлы
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. Подробнее: Функции-расширения
Смешанный Kotlin + Java
Один модуль может содержать оба языка, причём зависимости допускаются в обе стороны одновременно:
src/main/
├── kotlin/demo/KotlinImpl.kt
└── java/demo/
├── JavaBase.java
└── UsesKotlin.java
// src/main/java/demo/JavaBase.java
package demo;
public abstract class JavaBase {
public abstract String name();
public String describe() { return "Java-база: " + name(); }
}
// src/main/kotlin/demo/KotlinImpl.kt
// Kotlin наследуется от Java-класса
// class KotlinImpl : JavaBase() {
// override fun name() = "из Kotlin"
// }
fun main() {
println("Java-база: из Kotlin") // Java-база: из Kotlin
}
// src/main/java/demo/UsesKotlin.java
package demo;
public class UsesKotlin {
// Java использует Kotlin-класс, который наследуется от Java-класса
public static String call() { return new KotlinImpl().describe(); }
}
Это работает, хотя выглядит как циклическая зависимость. Порядок компиляции:
graph LR
A["Java-исходники"] -->|"читаются как стабы"| B["kotlinc:<br/>компиляция Kotlin"]
B --> C["Kotlin .class"]
C -->|"в classpath"| D["javac:<br/>компиляция Java"]
D --> E["Java .class"]
kotlinc получает .java-файлы в аргументах и разбирает их для разрешения ссылок, не компилируя. Затем javac компилирует Java, уже видя готовые Kotlin-классы. Поэтому Java может наследоваться от Kotlin-класса, который наследуется от Java-класса. Подробнее: Взаимодействие с Java
Ограничение: эта схема работает в пределах одного модуля. Между модулями циклов быть не может.
Многомодульный проект
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
// settings.gradle.kts
rootProject.name = "myapp"
include("domain", "data", "service", "app")
// корневой 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"))
}
}
// 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
// build-logic/build.gradle.kts
plugins {
`kotlin-dsl`
}
repositories {
mavenCentral()
gradlePluginPortal()
}
dependencies {
implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:2.4.0")
}
// 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<Test> {
useJUnitPlatform()
}
// settings.gradle.kts
pluginManagement {
includeBuild("build-logic")
}
rootProject.name = "myapp"
include("domain", "data", "service", "app")
// domain/build.gradle.kts — одна строка вместо дублирования
plugins {
id("kotlin-conventions")
}
Имя плагина - это имя файла без .gradle.kts. Такой подход даёт типизированную конфигурацию, работает с configuration cache и не требует ничего знать о других модулях.
Исторически ту же роль играл каталог buildSrc/. Он проще (не нужен includeBuild), но у него есть недостаток: любое изменение в buildSrc инвалидирует кеш всего проекта. Для больших проектов предпочтителен build-logic как included build.
Ресурсы
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/javasrc/main/kotlinодин публичный класс на файл сколько угодно имя файла = имя публичного класса не связаны пакет обязан совпадать с каталогом не обязан утилитный класс StringUtilsфайл Strings.ktс расширениямиMain.classMainKt.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-плагин, лежащий в проекте: типизированный, кешируемый, переиспользуемый.
[!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