Files
obsidian/Kotlin/Инструментарий/Структура проекта.md
T

20 KiB
Raw Blame History

title, description, tags
title description tags
Структура проекта Каталоги исходников, пакеты, многомодульность, смешанный Kotlin+Java код и convention plugins
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/
// 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/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, jar

[!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