Гайд по реализации паттерна Composite в Kotlin с sealed-классами и корутинами

1d05e8b9e1a72a8ba3670d5ec2a65894.png

Привет, Хабр! Сегодня рассмотрим, как реализовать паттерн Composite в Kotlin с помощью sealed-классов и корутин. Если у вас есть сложная система с кучей объектов — простых и составных — и вы хотите управлять ими, не теряя асинхронности, то этот гайд для вас.

Немного теории

Представьте, у вас есть дерево объектов. Не реальное дерево, конечно, а структурное: корень, ветки, листья. Причём некоторые листья могут быть такими же деревьями. Вот в этом-то хаосе вам и нужно как-то работать — скажем, взять и применить операцию ко всем элементам, не заботясь, кто там ветка, кто лист. Composite — это как универсальный интерфейс, который позволяет обращаться с составными и одиночными объектами одинаково. Вместо тысячи if-else можно получить довольно стройную иерархию, где всё просто: композиты содержат другие компоненты, а листья выполняют конкретную работу. Лепота!

Но на практике есть нюанс. Дерево-то может быть не просто деревом, а лесом, где операции происходят асинхронно. Пока один компонент зависает на своей долгой задаче, другой уже завершил работу. Тут Composite начинает выглядеть немного иначе: он всё ещё объединяет объекты, но теперь работает с учётом параллельных процессов. Поэтому-то мы и берём в руки корутины — чтобы каждая ветка делала своё дело, а результат собирался сам собой.

Реализуем базовый компонент

Начнём с создания базового абстрактного класса Component, который будет sealed-классом:

sealed class Component {
    abstract suspend fun execute(): Result
}

Обратите внимание на suspend в функции execute() — это значит, что операции могут быть асинхронными. Также возвращаем Result, чтобы аккуратно работать с ошибками.

Создаём листья дерева

Листья — это конечные объекты, которые не содержат других компонентов. Реализуем их:

class Leaf(private val name: String) : Component() {
    override suspend fun execute(): Result {
        return try {
            println("Лист [$name]: начало выполнения")
            delay(1000) // Эмулируем долгую операцию
            println("Лист [$name]: выполнение завершено")
            Result.success(Unit)
        } catch (e: Exception) {
            println("Лист [$name]: ошибка — ${e.message}")
            Result.failure(e)
        }
    }
}

Тут всё просто: имитируем работу с помощью delay, обрабатываем возможные исключения и возвращаем результат.

Создаём композитные узлы

Композитные узлы могут содержать другие компоненты, будь то листья или другие композиты:

class Composite(private val name: String) : Component() {
    private val children = mutableListOf()

    fun add(component: Component) = children.add(component)

    fun remove(component: Component) = children.remove(component)

    override suspend fun execute(): Result = coroutineScope {
        println("Композит [$name]: начало выполнения")
        val results = children.map { child ->
            async {
                child.execute()
            }
        }.awaitAll()

        val failures = results.filterIsInstance<>()
        if (failures.isNotEmpty()) {
            println("Композит [$name]: обнаружены ошибки")
            Result.failure(Exception("Ошибки в дочерних компонентах"))
        } else {
            println("Композит [$name]: выполнение успешно завершено")
            Result.success(Unit)
        }
    }
}

Здесь используем coroutineScope и async для параллельного выполнения операций в дочерних компонентах. Это значит, что все компоненты будут выполняться одновременно, а мы дождёмся их завершения с помощью awaitAll().

Собираем всё вместе

Теперь создадим дерево компонентов и запустим выполнение:

suspend fun main() = coroutineScope {
    val root = Composite("Root")

    val branch1 = Composite("Branch1")
    val branch2 = Composite("Branch2")

    val leaf1 = Leaf("Leaf1")
    val leaf2 = Leaf("Leaf2")
    val leaf3 = Leaf("Leaf3")
    val leaf4 = Leaf("Leaf4")

    branch1.add(leaf1)
    branch1.add(leaf2)

    branch2.add(leaf3)
    branch2.add(leaf4)

    root.add(branch1)
    root.add(branch2)

    val result = root.execute()
    if (result.isSuccess) {
        println("Все операции успешно завершены!")
    } else {
        println("При выполнении произошли ошибки: ${result.exceptionOrNull()?.message}")
    }
}

Ожидаемый вывод:

Композит [Root]: начало выполнения
Композит [Branch1]: начало выполнения
Композит [Branch2]: начало выполнения
Лист [Leaf1]: начало выполнения
Лист [Leaf2]: начало выполнения
Лист [Leaf3]: начало выполнения
Лист [Leaf4]: начало выполнения
Лист [Leaf1]: выполнение завершено
Лист [Leaf2]: выполнение завершено
Композит [Branch1]: выполнение успешно завершено
Лист [Leaf3]: выполнение завершено
Лист [Leaf4]: выполнение завершено
Композит [Branch2]: выполнение успешно завершено
Композит [Root]: выполнение успешно завершено
Все операции успешно завершены!

Добавляем ложку дёгтя: обработка ошибок

Но жизнь не всегда так идеальна. Представим, что один из листов выбрасывает исключение:

class Leaf(private val name: String) : Component() {
    override suspend fun execute(): Result {
        return try {
            println("Лист [$name]: начало выполнения")
            if (name == "Leaf3") {
                throw RuntimeException("Что-то пошло не так в $name")
            }
            delay(1000)
            println("Лист [$name]: выполнение завершено")
            Result.success(Unit)
        } catch (e: Exception) {
            println("Лист [$name]: ошибка — ${e.message}")
            Result.failure(e)
        }
    }
}

Теперь Leaf3 всегда будет выбрасывать исключение. Запустим код снова.

Композит [Root]: начало выполнения
Композит [Branch1]: начало выполнения
Композит [Branch2]: начало выполнения
Лист [Leaf1]: начало выполнения
Лист [Leaf2]: начало выполнения
Лист [Leaf3]: начало выполнения
Лист [Leaf4]: начало выполнения
Лист [Leaf3]: ошибка — Что-то пошло не так в Leaf3
Лист [Leaf1]: выполнение завершено
Лист [Leaf2]: выполнение завершено
Композит [Branch1]: выполнение успешно завершено
Лист [Leaf4]: выполнение завершено
Композит [Branch2]: обнаружены ошибки
Композит [Root]: обнаружены ошибки
При выполнении произошли ошибки: Ошибки в дочерних компонентах

Ошибка в Leaf3 корректно обрабатывается и приводит к неуспешному завершению родительских композитов.

Ограничение параллелизма

Допустим, у нас тысячи компонентов. Запускать тысячи корутин одновременно — не лучшая идея. Добавим ограничение параллелизма с помощью Semaphore:

class Composite(private val name: String, private val concurrency: Int = 4) : Component() {
    private val children = mutableListOf()
    private val semaphore = Semaphore(concurrency)

    // Методы add и remove остаются прежними

    override suspend fun execute(): Result = coroutineScope {
        println("Композит [$name]: начало выполнения")
        val results = children.map { child ->
            async {
                semaphore.withPermit {
                    child.execute()
                }
            }
        }.awaitAll()

        // Обработка результатов остаётся прежней
    }
}

Теперь мы ограничили количество одновременно выполняющихся корутин до concurrency.

Загрузка данных из сети

Допустим, у нас есть приложение. Оно, бедняга, должно общаться с кучей API, собирать данные и всё это максимально быстро. Одни запросы можно отправлять параллельно, другие — строго по порядку.

Каждый загрузчик выполняет свою задачу: стучится к API, получает ответ, проверяет, не подкинули ли ему 500-й статус, и возвращает результат. Бдуем юзать Ktor Client:

import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.request.*

sealed class Component {
    abstract suspend fun execute(): Result
}

data class Data(val content: String)

class ApiLoader(private val url: String, private val client: HttpClient) : Component() {
    override suspend fun execute(): Result {
        return try {
            println("Загружаем данные с $url...")
            val response: String = client.get(url)
            println("Данные с $url успешно получены.")
            Result.success(Data(response))
        } catch (e: Exception) {
            println("Ошибка при загрузке $url: ${e.message}")
            Result.failure(e)
        }
    }
}

Мы явно передаём клиент через конструктор. Это удобно: можно переиспользовать его, добавить тайм-ауты или мокнуть в тестах.

Далее уже нужны два типа композитов: один для параллельной загрузки, другой для последовательной. Первый отправляет всех своих загрузчиков работать одновременно. Второй в роли некого охранника заставляет выполнять работу по очереди.

Параллельный композит:

class ParallelComposite(private val name: String) : Component() {
    private val children = mutableListOf()

    fun add(component: Component) = children.add(component)

    override suspend fun execute(): Result = coroutineScope {
        println("Композит [$name]: параллельное выполнение начато.")
        val results = children.map { child ->
            async {
                child.execute()
            }
        }.awaitAll()

        val failures = results.filter { it.isFailure }
        if (failures.isNotEmpty()) {
            val exceptions = failures.mapNotNull { it.exceptionOrNull() }
            println("Композит [$name]: обнаружены ошибки.")
            Result.failure(CompositeException(exceptions))
        } else {
            val combinedData = results.map { it.getOrThrow().content }.joinToString("\n")
            println("Композит [$name]: все операции успешно завершены.")
            Result.success(Data(combinedData))
        }
    }
}

Последовательный композит:

class SequentialComposite(private val name: String) : Component() {
    private val children = mutableListOf()

    fun add(component: Component) = children.add(component)

    override suspend fun execute(): Result {
        println("Композит [$name]: последовательное выполнение начато.")
        val combinedContent = StringBuilder()
          for (child in children) {
            val result = child.execute()
            if (result.isFailure) {
              println("Композит [$name]: ошибка при выполнении.")
              return result
            }
            combinedContent.append(result.getOrThrow().content).append("\n")
}

        println("Композит [$name]: успешно завершено.")
        return Result.success(Data(combinedContent.toString()))
    }
}

Все готово.

Далее добавим обработку ошибок:

class CompositeException(val errors: List) : Exception(
    "Ошибки в композите: ${errors.joinToString { it.message ?: "Неизвестная ошибка" }}"
)

Когда один из загрузчиков облажается (а это может случится), мы соберём все исключения в одном месте и вернём их как результат композита.

Соберём параллельный и последовательный загрузчики в общий композит:

suspend fun main() {
    val client = HttpClient(CIO)

    // Загрузчики
    val loader1 = ApiLoader("https://api.example.com/data1", client)
    val loader2 = ApiLoader("https://api.example.com/data2", client)
    val loaderError = ApiLoader("https://api.example.com/error", client)

    // Параллельный композит
    val parallelComposite = ParallelComposite("ParallelGroup")
    parallelComposite.add(loader1)
    parallelComposite.add(loader2)

    // Последовательный композит
    val sequentialComposite = SequentialComposite("SequentialGroup")
    sequentialComposite.add(parallelComposite)
    sequentialComposite.add(loaderError)

    // Общий корневой композит
    val rootComposite = ParallelComposite("RootGroup")
    rootComposite.add(sequentialComposite)

    // Выполняем
    val result = rootComposite.execute()
    if (result.isSuccess) {
        println("Все данные успешно загружены:")
        println(result.getOrThrow().content)
    } else {
        println("Ошибки при выполнении:")
        val exception = result.exceptionOrNull()
        if (exception is CompositeException) {
            exception.errors.forEach { println("- ${it.message}") }
        } else {
            println("- ${exception?.message}")
        }
    }

    client.close()
}

26 ноября в Otus пройдет открытый урок на тему «Макросы и другие помощники Dart/Flutter». На нем участники научатся создавать и использовать макросы, разберут принципы генерации кода через source_gen и build_runner и упростят себе жизнь с помощью mason bricks.

Если тема показалась для вас актуальной, записаться можно на странице курса «Flutter Mobile Developer». А в календаре мероприятий можно посмотреть список всех открытых уроков по другим IT-направлениям.

© Habrahabr.ru