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

380 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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<JvmTestSuite>("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:<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](../Взаимодействие%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<Test> {
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)