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

от автора

Привет, Хабр! Сегодня рассмотрим, как реализовать паттерн 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<Component>()     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> }  data class Data(val content: String)  class ApiLoader(private val url: String, private val client: HttpClient) : Component() {     override suspend fun execute(): Result<data> {         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<data> = 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<data> {         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<Throwable>) : 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-направлениям.


ссылка на оригинал статьи https://habr.com/ru/articles/859176/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *