380 lines
20 KiB
Markdown
380 lines
20 KiB
Markdown
---
|
||
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)
|