Kotlin guide done. (20.10.26)
This commit is contained in:
@@ -0,0 +1,379 @@
|
||||
---
|
||||
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)
|
||||
Reference in New Issue
Block a user