Результаты обучения
- Что такое композит?
- Применимость композита.
- Случаи использования композита.
- Подходы к композиции объектов.
- За и против.
Проблема
Как вы составляете объекты, соответствующие общему интерфейсу? Возможно, мы хотим отделить логику, создав новые компоненты, которые имеют тот же интерфейс, что и логика, которую мы хотим.
Решение
Composite позволяет объединять объекты в структурированное дерево для представления части или всей иерархии, а затем работать с этими структурами, как если бы они были отдельными объектами. Например:
Даже клиенты должны иметь возможность единообразно обрабатывать отдельные объекты и композиции объектов через общий единый интерфейс. Например:
Составные объекты на графике выше могут масштабироваться бесконечно, поскольку все участники композиции используют один и тот же интерфейс. Это позволяет составным объектам ссылаться на два типа зависимостей:
- Листовой объект → Не расширяет композицию.
- Объект композиции → Расширяет композицию.
Я покажу вам, как использовать шаблон проектирования Composite, применяемый в нашем приложении. Например, я создал приложение с механизмом кэширования. Цель:
Подходы к композиции объектов
- Состав с бетонными видами
/** * Domain Module * */ interface PondFeedLoader { fun load(callback: (List<String>) -> Unit) } /** * Http Module * */ class LoadPondFeedRemoteUseCase : PondFeedLoader { override fun load(callback: (List<String>) -> Unit) { // Simulate insert to local callback( listOf( "Pond A from remote data", "Pond B from remote data" ) ) } } /** * Cache Module * */ class LoadPondFeedLocalUseCase : PondFeedLoader { override fun load(callback: (List<String>) -> Unit) { callback( listOf( "Pond A from local data", "Pond B from local data" ) ) } } /** * Presentation Module * */ class PondFeedViewModel( private val pondFeedLoader: PondFeedLoader ) : ViewModel() { fun loadPondFeed() { pondFeedLoader.load { pondFeed -> println(pondFeed) } } } /** * Factory Method * */ fun createRemoteWithLocalCompositeFactory( remote: LoadPondFeedRemoteUseCase, local: LoadPondFeedLocalUseCase, ): PondFeedLoader { return RemoteWithLocalComposite( remote = remote, local = local ) } /** * Composite * */ fun isSuccess(): Boolean { return true } class RemoteWithLocalComposite( private val remote: LoadPondFeedRemoteUseCase, private val local: LoadPondFeedLocalUseCase ) : PondFeedLoader { override fun load(callback: (List<String>) -> Unit) { if (isSuccess()) { remote.load { callback(it) } } else { local.load { callback(it) } } } } // Output -> [Pond A from remote data, Pond B from remote data] // If you change return value of this function to false fun isSuccess(): Boolean { return false } /** * Main Module * */ fun main() { val pondFeedViewModel = PondFeedViewModel( pondFeedLoader = createRemoteWithLocalCompositeFactory( remote = LoadPondFeedRemoteUseCase(), local = LoadPondFeedLocalUseCase() ) ) pondFeedViewModel.loadPondFeed() } // The output would be -> [Pond A from local data, Pond B from local data]
Код может выглядеть следующим образом:
За и против:
✅ Принудительная проверка типов во время компиляции гарантирует, что мы составляем правильные объекты.
✅ Работа с конкретными классами в качестве зависимостей не отличается гибкостью. Что, если наши требования изменятся? Если требования диктуют сначала попытаться загрузить данные из локального хранилища, а если оно пусто или загрузка не удалась, попытаться загрузить из удаленного хранилища — вы не сможете выполнить это с помощью этого подхода. Вот почему нам нужно рассмотреть второй подход с использованием абстрактных типов.
❌ Изолирующее тестирование композита является сложной задачей. Поскольку композит опирается на конкретные типы в качестве зависимостей, становится трудно тестировать его изолированно. Любое изменение конкретных типов, которые сами имеют свои собственные зависимости, потенциально может нарушить тесты. В то время как интеграционное тестирование возможно, модульное тестирование композита становится проблематичным в этом конкретном сценарии.
- Композиция с абстрактными типами
/** * Domain Module * */ interface PondFeedLoader { fun load(callback: (List<String>) -> Unit) } /** * Http Module * */ class LoadPondFeedRemoteUseCase : PondFeedLoader { override fun load(callback: (List<String>) -> Unit) { // Simulate insert to local callback( listOf( "Pond A from remote data", "Pond B from remote data" ) ) } } /** * Cache Module * */ class LoadPondFeedLocalUseCase : PondFeedLoader { override fun load(callback: (List<String>) -> Unit) { callback( listOf( "Pond A from local data", "Pond B from local data" ), ) } } /** * Presentation Module * */ class PondFeedViewModel( private val pondFeedLoader: PondFeedLoader ) : ViewModel() { fun loadPondFeed(callback: (List<String>) -> Unit) { pondFeedLoader.load { pondFeed -> callback(pondFeed) } } } /** * Composite * */ fun isSuccess(): Boolean { return true } class RemoteWithLocalComposite( private val remote: PondFeedLoader, private val local: PondFeedLoader ) : PondFeedLoader { override fun load(callback: (List<String>) -> Unit) { if (isSuccess()) { remote.load { callback(it) } } else { local.load { callback(it) } } } } /** * Factory Method * */ fun createLoadPondFeedRemoteUseCaseFactory(): PondFeedLoader { return LoadPondFeedRemoteUseCase() } fun createLoadPondFeedLocalUseCaseFactory(): PondFeedLoader { return LoadPondFeedLocalUseCase() } fun createRemoteWithLocalCompositeFactory( remote: PondFeedLoader, local: PondFeedLoader, ): PondFeedLoader { return RemoteWithLocalComposite( remote = remote, local = local ) } /** * Main Module * */ fun main() { /** * If requirements change and we want load from local first * you just flip the params of remote and local * */ val pondFeedViewModel = PondFeedViewModel( pondFeedLoader = createRemoteWithLocalCompositeFactory( remote = createLoadPondFeedRemoteUseCaseFactory(), local = createLoadPondFeedLocalUseCaseFactory() ) ) pondFeedViewModel.loadPondFeed { pondFeed -> println(pondFeed) // Output -> [Pond A from remote data, Pond B from remote data] // Output if you flip the params -> [Pond A from local data, Pond B from local data] // But you need change contex name of params // from remote and local to primary and fallback like this val pondFeedViewModel = PondFeedViewModel( pondFeedLoader = createRemoteWithLocalCompositeFactory( primary = createLoadPondFeedLocalUseCaseFactory(), fallback = createLoadPondFeedRemoteUseCaseFactory() ) ) } }
За и против:
✅ Использование абстракций в качестве зависимостей повышает гибкость. Как упоминалось ранее, если требования меняются и требуется установить приоритет локальной загрузки, вы можете просто переключить параметры для удаленной и локальной загрузки.
✅ Композит можно тестировать изолированно, а не только путем интеграции.
❌ Проверки типов во время компиляции не могут гарантировать, что мы компонуем правильные объекты. Написание тестов остается единственным способом обеспечить правильный порядок композиции.
С абстрактным типом вы можете использовать композицию с пользовательским интерфейсом. Например:
/** * Domain Module * */ interface PondFeedLoader { fun load(callback: (List<String>) -> Unit) } /** * Http Module * */ class LoadPondFeedRemoteUseCase : RemotePondFeedLoader { override fun load(callback: (List<String>) -> Unit) { // Simulate insert to local callback( listOf( "Pond A from remote data", "Pond B from remote data" ) ) } } /** * Cache Module * */ class LoadPondFeedLocalUseCase : LocalPondFeedLoader { override fun load(callback: (List<String>) -> Unit) { callback( listOf( "Pond A from local data", "Pond B from local data" ), ) } } /** * Presentation Module * */ class PondFeedViewModel( private val pondFeedLoader: PondFeedLoader ) : ViewModel() { fun loadPondFeed(callback: (List<String>) -> Unit) { pondFeedLoader.load { pondFeed -> callback(pondFeed) } } } /** * Composite * */ interface RemotePondFeedLoader : PondFeedLoader interface LocalPondFeedLoader : PondFeedLoader fun isSuccess(): Boolean { return true } class RemoteWithLocalComposite( private val remote: RemotePondFeedLoader, private val local: LocalPondFeedLoader ) : PondFeedLoader { override fun load(callback: (List<String>) -> Unit) { if (isSuccess()) { remote.load { callback(it) } } else { local.load { callback(it) } } } } /** * Factory Method * */ fun createLoadPondFeedRemoteUseCaseFactory(): RemotePondFeedLoader { return LoadPondFeedRemoteUseCase() } fun createLoadPondFeedLocalUseCaseFactory(): LocalPondFeedLoader { return LoadPondFeedLocalUseCase() } fun createRemoteWithLocalCompositeFactory( remote: RemotePondFeedLoader, local: LocalPondFeedLoader, ): PondFeedLoader { return RemoteWithLocalComposite( remote = remote, local = local ) } /** * Main Module * */ fun main() { /** * If requirements change and we want load from local first you just flip the params of remote and local * */ val pondFeedViewModel = PondFeedViewModel( pondFeedLoader = createRemoteWithLocalCompositeFactory( remote = createLoadPondFeedRemoteUseCaseFactory(), local = createLoadPondFeedLocalUseCaseFactory() ) ) pondFeedViewModel.loadPondFeed { pondFeed -> println(pondFeed) } }
За и против:
✅ Проверка типов во время компиляции обеспечивает точную композицию объектов.
✅ Использование абстракций в качестве зависимостей повышает гибкость.
✅ Композит можно тестировать отдельно, а не только через интеграцию.
❌ Приводит к увеличению типов и сложности.
С абстрактным типом у вас также есть гибкая стратегия, не ограниченная удаленной или локальной парадигмой. Например:
/** * Domain Module * */ interface PondFeedLoader { fun load(callback: (List<String>) -> Unit) } /** * Http Module * */ class LoadPondFeedRemoteUseCase : PondFeedLoader { override fun load(callback: (List<String>) -> Unit) { callback( listOf( "Pond A from remote data", "Pond B from remote data" ) ) // Simulate insert to local } } /** * Cache Module * */ class LoadPondFeedLocalUseCase : PondFeedLoader { override fun load(callback: (List<String>) -> Unit) { callback( listOf( "Pond A from local data", "Pond B from local data" ), ) } } /** * Presentation Module * */ class PondFeedViewModel( private val pondFeedLoader: PondFeedLoader ) : ViewModel() { fun loadPondFeed(callback: (List<String>) -> Unit) { pondFeedLoader.load { pondFeed -> callback(pondFeed) } } } /** * Composite * Simulate if load from remote success * */ fun isSuccess(): Boolean { return true } class PondFeedLoaderWithFallbackComposite( private val primary: PondFeedLoader, private val fallback: PondFeedLoader ) : PondFeedLoader { override fun load(callback: (List<String>) -> Unit) { if (isSuccess()) { primary.load { callback(it) } } else { fallback.load { callback(it) } } } } /** * Factory Method * */ fun createLoadPondFeedRemoteUseCaseFactory(): PondFeedLoader { return LoadPondFeedRemoteUseCase() } fun createLoadPondFeedLocalUseCaseFactory(): PondFeedLoader { return LoadPondFeedLocalUseCase() } fun createPondFeedLoaderWithFallbackCompositeFactory( primary: PondFeedLoader, fallback: PondFeedLoader, ): PondFeedLoader { return PondFeedLoaderWithFallbackComposite( primary = primary, fallback = fallback ) } /** * Main Module * */ fun main() { /** * You have more flexibility, we can setup primary as remote or local, local or local and so on * */ val pondFeedViewModel = PondFeedViewModel( pondFeedLoader = createPondFeedLoaderWithFallbackCompositeFactory( primary = createLoadPondFeedRemoteUseCaseFactory(), fallback = createPondFeedLoaderWithFallbackCompositeFactory( primary = createLoadPondFeedRemoteUseCaseFactory(), fallback = createLoadPondFeedLocalUseCaseFactory() ) ) ) pondFeedViewModel.loadPondFeed { pondFeed -> println(pondFeed) } }
За и против
✅ Использование абстракций в качестве зависимостей упрощает и повышает гибкость, позволяя нам создавать бесконечно сложные древовидные структуры объектов посредством композиции.
✅ Композит можно тестировать изолированно, а не полагаться исключительно на интеграционное тестирование.
❌ Проверки типов во время компиляции не могут гарантировать, что мы компонуем правильные объекты. Единственный способ гарантировать правильный порядок композиции — написать тест.
За и против
✅ Шаблон Composite упрощает клиентский код, устраняя необходимость в условных операторах над определяющими классами композиции.
// From this class PondFeedViewModel( private val remote: LoadPondFeedRemoteUseCase, private val local: LoadPondFeedLocalUseCase ) : ViewModel() { fun loadPondFeed() { if (isSuccess()) { remote.load { println(it) } } else { local.load { println(it) } } } } // To this class RemoteWithLocalComposite( private val primary: PondFeedLoader, private val fallback: PondFeedLoader ) : PondFeedLoader { override fun load(callback: (List<String>) -> Unit) { if (isSuccess()) { primary.load { callback(it) } } else { fallback.load { callback(it) } } } }
✅ Клиенту не нужно знать, и ему все равно, имеют ли они дело с листовым или составным компонентом (если мы используем общий интерфейс, который является листом).
✅ Не нарушая принцип открытости/закрытости. Вы можете добавить в приложение новый компонент, не нарушая существующий код, который теперь работает с деревом объектов.
❌ Выявление вариантов использования, в которых классы имеют одинаковую функциональность и интерфейс, может оказаться сложной задачей.
❌ Обеспечение общего интерфейса для классов с совершенно разными функциями может оказаться сложной задачей. Чрезмерное обобщение интерфейса компонента в таких случаях может привести к снижению ясности.
Позвольте мне открыть вам секрет. Секрет в том, что вы можете применять Composite Design Pattern не только для Android, например, Flutter, KMP, iOS, React Native, Web и Backend.
Сделано с ❤️ автором Fiqri Hafzain Islamici.