Search
Write a publication
Pull to refresh

Comments 8

В очередной раз спасибо за статью) Это отличное освещение функциональной теории, того как работать с композицией, как подставлять параметры и делать каррирование на практике.

Однако, у меня все еще остаются вопросы касаемо DI.

fun buildReservationContext(...)

Чтобы написать эту функцию, мы должны знать о реализациях, чтобы подставить дефолтные значения.

В модуле :app создадим реализации интерфейсов

Мы разместили реализации в app для примера, и по хорошему, чтобы app не содержал все реализации существующие в проекте. Но даже так app не может протолкнуть эти реализации в другие модули, вниз по зависимостям. (конечно есть вариант в модулях сделать функции принимающие интерфейсы, но так app станет глобальным дирижёром зависимостей, которого мы сами должны написать)

Соответственно buildReservationContext должен находиться не ниже app(или модуля с реализациями) чтобы иметь к ним доступ.

В свою очередь ReservationScreen так же должен находиться в app, чтобы иметь доступ к buildReservationContext. И как в таком случае мы сможем выделить этот Screen полностью в отдельный модуль с ui, чтобы при этом зависеть только от интерфейсов и не превратить app в глобального дирижёра зависимостями?

И второй вопрос, имеется ли в таком подходе возможность разделения модулей на api impl? Или может такое разделение считается избыточным и ненужным в таком подходе работы с зависимостями?

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

Написать работающую программу не сложно, сложно сделать ее масштабируемой. А пока что, я в этой схеме только нахожу вопросы масштабируемости и не хочу пытаться самостоятельно докручивать ее, т.к. со своим опытом просто придумаю очередной ооп di, а хочется все же понять возможности функционального di. Так что надеюсь ты поможешь мне докрутить эту схему правильно и ответить на вопросы масштабируемости)

Классные вопросы!
По первому вопросу, в данный момент ReservationScreen ни от чего не зависит это функция с пустым параметров. Поэтому при выносе UI части в отдельный фичевый модуль вместе с ним может уехать и buildReservationContext(). Такое решение будет прекрасно работать, если для его работы нужны зависимости из модулей нижних уровней, которые будут доступны в фичевом модуле. Но если попробовать подхватить твою мысль. И рассмотреть ситуации, когда нужен объект с ЖЦ приложения или его можно создать только в :app и нигде больше, в такой модели его создают в :app и прокидывают как параметр в функцию.
По поводу app = дирижёр оркестратор зависимостей. Не особо понимаю, что в этом плохого. Во-первых кому-то в лубом случае придется этим заниматься:) Во-вторых, я бы скорее назвал это точка входа в приложение (на уровне app ты создаешь зависимости, которые нельзя создать в другом месте и передаешь их на нижние уровни. Если для нижнего уровня такие зависимости не нужны занимаешься этим на нижнем уровне). В-третьих в привычных DI-фреймворках, так же есть верхнеуровневый элемент в случае Dagger это какой-нибудь AppComponent. С Koin давненько не работал + там breaking changes версии выходили, но на сколько помню там тоже у него что-то типа startApplication и родительская функциях с зависимостями на app уровне. Да есть Hilt со своими обертками и аннотациями, который по сути за нас некоторые вещи делаеть, но глобально он эту задачу не решает. Опиши пожалуйста почему ты хочешь избавиться от дирижера зависимостями. И еще можно быть я упустил какой-то момент из ООП и DI-фреймворков и есть какой-то особый способ избавиться от этого их силами. На моем текущем рабочем проекте, функцию дирижера выполняет AppComponent дагера.
Основная задумка следующая :app модуль может инициализировать только те объекты, которые мы не можем опустить вниз. Функции по типу buildReservationContext по возможности опускаем вниз, а на уровне UI выполняем ленивое обращение. Т.е. App по сути дирижирует только тем, что ты осознано решил оставить на этому уровне.

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

  • :core-reservation-api

    • Интерфейсы (ReservationContext, TableRepository и т.д.)

    • DTO (ReservationRequest)

  • :core-reservation-impl

    • Реализации (ReservationContextImpl, NetworkTableRepository)

    • Зависит от :core-reservation-api

  • :feature-reservation-ui

    • ReservationScreen

    • Зависит только от :core-reservation-api

  • :app

    • Зависит от :core-reservation-impl и :feature-reservation-ui

    • Собирает реализации и передаёт в UI

Напиши, если где-то не совсем понял твою мысль, попробуем дальше разобраться

Разделение на api/impl часто встречается в более крупных и долгоживущих проектах, ты прав. Оно дает как минимум 2 важные вещи.
1. Ускорение сборки (при правильном проектировании зависимостей), что критически важно для больших проектов
2. Возможность подмены реализации при сборке, что так же часто встречается в проектах, пусть и в нескольких ключевых модулях, а не во всех подряд

У меня в проекте Hilt и он позволяет создавать di-модули в impl gradle-модулях, тем самым полностью избавляя от необходимости самостоятельно дирижировать зависимостями. А так же дает возможность создать bind gradle-модуль, который бы собирал группы impl модулей, избавляя от необходимости app модулю зависеть от них всех, достаточно только прописать зависимость на bind модуль.
Конечно, это кодогенерация, которая сильно тормозит сборку, но это все еще просто код, который так же можно написать руками, сделав свой manualDI. И да, в таком случае app модуль так же станет глобальным дирижером. Я не против этого как концепции, скорее пытаюсь понять зачем это делать вручную, если можно этого не делать с помощью ооп либ. (Конечно если не стоит острого вопроса времени сборки)

Но вот что еще дает избавление от ручных пробросов зависимостей, так это навигация без использования mediator или app модуля, для разруливания переходов.
В presentation-api модуле отписывается объект Screen, который выступает в роли ключа экрана и публичный интерфейс этого экрана, у которого есть метод для вызова ui.
В presentation-impl делается фабрика создания этого экрана с параметрами из Screen и биндится в di с ключом Screen::class.
И тогда навигатор, сможет при вызове open(Screen) получить из di нужную фабрику, создать экран и получить его ui.
Для этого достаточно, чтобы любой presentation-impl зависел от нашего presentation-api, чтобы иметь доступ к ключу Screen.
И получаем, что любой экран может открывать любой другой экран на прямую, зная только его минимальный публичный интерфейс, а app ничего не знает про навигацию и переходы.


Но мне все же интересно разобраться в данном подходе и кажется я что-то в нем понял, но потом сломался)
Основная идея проброса зависимостей, это композиция контекста через делегаты?
В api модуле будет интерфейс контекста
В impl его реализация с зависимостями на другие api модули
В app собираем нужный контекст через делегаты и передаем на вход функции.

Но я так и не понял как отвязать зависимости друг от друга.
В твоем примере через ReservationContext мы получаем доступ к интерфейсу TableRepository и вызываем его. Но NetworkTableRepository должен же еще иметь зависимости на сеть или базу.
Для аналитики, нужны зависимости на конкретные сервисы аналитики.

// Тут всё осталось без изменений
  CoroutineScope(Dispatchers.IO).launch {
    isLoading = true
    delay(1000) //Имитация загрузки
    reservationContext.makeReservation(request)
    isLoading = false
  }

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

Короче я так и не смог понять как это завести с разбиением логики по слоям, на подобии клина.

Или может в этом подходе подразумевается, что по слоям не нужно бить, типа это история из ооп?

Но как передать зависимость в NetworkTableRepository , чтобы к ней не было доступа у вызывающего makeReservation?

То, что код вызова makeReservation не должен находится на UI слое это тема отдельного разговора, в этом месте должен быть другой элемент архитектуры viewModel, MVI прослойка или что-то иное.
Можно передавать зависимости в NetworkTableRepository точно таким же способом, через делегаты и интерфейс. Ты же можешь создавать таким макаром не только ReservationContext но и вложенные в него параметры у которых есть свои зависимости. А вообще мне кажется более подходящее наименование тут не Context, а какой-нибудь Capability нужны зависимости для репозитория - создаешь для него свои капабилити и также наделяешь функцию делегатами только того, что тебе нужны. Т.е. приводишь функцию репозитория к такому же виду как и сама функция makeReservation. Если сейчас посмотреть на функцию makeReservation она ничего не знает о зависимостях TableRepository для нее это интерфейс же и его реализация скрыта. Т.е если ты хочешь прокинуть зависимость в виде базенки или сети не нарушив чистоту функции побочным эффектом, тебе и вложеную функцию необходимо привести к такому виду. Желательно при этом обернув это в монаду, тем самым моделируя возможные поведения этого обращения к базе в рамках изолированного конеткста. В котлин это потребует немного boilerplate кода. Я думаю тут DI фреймворки со своим кодгеном конечно могут чуть облегчить жизнь, но с данным подходом в виде кложур мы получаем контроль над этим местом программы, а не сгружаем ответственность на магический кодген и тяжеловесный DI фреймворк.

Подход с closure, как мне кажется это максимально компромиссное решение между ФП и JVM наследием Kotlin.

На вопрос нужно ли бить по слоям или нет, я отвечу, что тема которую ты поднял - следствие того, что в Kotlin нет более подходящего инструмента из мира ФП. Если бы он был такой проблемы бы не было. Я пометил в статье, что в Котлин я не нашел более лаконичного способа.

Вообще для мира ФП вопрос DI-зависимостей и микросервисов которые мы с тобой сейчас обсуждаем стараются решить при помощи эффектов или рассматривать как алгебру. Есть один шаблон в Scala (Tagless final), аля DI для android ООП. Если тебе интересно я могу попробовать привести ФПшный пример как бы это выглядело там. Единственное этот пример будет на Scala или Haskell, но я максимально попробую расписать, что там происходит и провести какие-то аналогии, если тебя это устроит?) Вообщем тут стоит попробовать смотреть на эту тему максимально без призмы ООП, как говорится.

Можешь, пожалуйста, сделать пример на котлин, с прокидкой 3 влоденных зависимостей?

Типа вызвать useCase, тот repository, тот dataSource, тот саму базу. Просто посмотреть как это в коде выглядит, а то я пытался пример сделать и в чем-то запутался.

Прошу прощения за долгий ответ. Было много работы :(
Я немного переписал код под ФП стиль, который до этого был упрощен, так что если нужны будут дополнительные разъяснения по ФП цепочкам - спрашивай. Вообщем вложенные зависимости могут выглядеть как-то так:

interface DatabaseContext {
  val databaseClient: DatabaseClient
}

interface DatabaseClient {
  suspend fun query(sql: String): Either<Throwable, QueryResult>
  suspend fun executeUpdate(sql: String): Either<Throwable, Boolean>
}

class RealDatabaseClient : DatabaseClient {
  override suspend fun query(sql: String): Either<Throwable, QueryResult> =
    Either.catch {
      // Реальная реализация
      QueryResult(emptyList())
    }

  override suspend fun executeUpdate(sql: String): Either<Throwable, Boolean> =
    Either.catch {
      // Реальная реализация
      true
    }
}
fun buildReservationContext(
  logger: Logger = Logger.getLogger("Reservation"),
  tableRepository: TableRepository = NetworkTableRepository(),
  databaseClient: DatabaseClient = RealDatabaseClient(),
  tracker: AnalyticsTracker = RealAnalyticsTracker()
): ReservationContextImpl {
  return ReservationContextImpl(
    logger = logger,
    tableRepository = tableRepository,
    databaseClient = databaseClient,
    tracker = tracker,
  )
}
class ReservationContextImpl(
  override val logger: Logger,
  override val tableRepository: TableRepository,
  override val databaseClient: DatabaseClient,
  override val tracker: AnalyticsTracker
) : ReservationContext, AnalyticsContext, DatabaseContext
class NetworkTableRepository : TableRepository {
  override suspend fun <Ctx> Ctx.checkTableAvailability(
    date: String,
    guests: Int
  ): Either<ReservationError, Boolean> where Ctx: DatabaseContext = either {
    databaseClient.query("SELECT * FROM tables WHERE date = '$date'")
      .mapLeft { ReservationError.DatabaseError(it) }
      .bind()
      .let { true } // Упрощенная логика
  }

  override suspend fun <Ctx> Ctx.reserveTable(
    tableId: Int,
    customerName: String
  ): Either<ReservationError, Boolean> where Ctx: DatabaseContext = either {
    databaseClient.executeUpdate("INSERT INTO reservations VALUES (...)")
      .mapLeft { ReservationError.DatabaseError(it) }
      .bind()
  }
}

Теперь нужно адаптировать под эти изменения функцию makeReservation

suspend fun <Ctx> Ctx.makeReservation(request: ReservationRequest): Either<ReservationError, ReservationSuccess>
        where Ctx : ReservationContext, Ctx : AnalyticsContext, Ctx : DatabaseContext = either {
  logger.info("Starting reservation for ${request.customerName}")

  val isAvailable = tableRepository
    .run { this@makeReservation.checkTableAvailability(request.date, request.guests) }
    .bind()

  ensure(isAvailable) {
    logger.warning("Table unavailable for ${request.date}")
    ReservationError.TableUnavailable(request.date)
  }

  // Бронирование
  tableRepository
    .run { this@makeReservation.reserveTable(request.tableId, request.customerName) }
    .bind()
    .also {
      tracker.track(Event(mapOf(
        "event" to "reservation_success",
        "table_id" to request.tableId
      )))
      logger.info("Table #${request.tableId} reserved!")
    }

  ReservationSuccess(request.tableId, request.date)
}

sealed interface ReservationError {
  data class TableUnavailable(val date: String) : ReservationError
  data class DatabaseError(val cause: Throwable) : ReservationError
}

data class ReservationSuccess(val tableId: Int, val date: String)

Так же я залил отдельный коммит в ветку для ответа на твои вопросы, при желании можешь стянуть к себе и проверить, что все работает.

Вот я тоже самое сделал, когда пытался. Но меня не устраивает, что makeReservation имеет прямой доступ к DatabaseContext и может с ним взаимодействовать, хотя его использование, это часть реализации tableRepository о которой нам знать совершенно не нужно.  

Изменение реализации для tableRepository приведет к тому, что нам понадобится пробрасывать дополнительные контексты через всю цепочку до места использования. А если зависимостей будет много, то with в сигнатуре функции станет огромным.

Пока я склоняюсь к тому, что лучшим решением остается использование классов и интерфейсов, чисто для разруливания зависимостей через ооп di. При этом чтобы внутри классов сами функции так же оставались чистыми и без изменяемого состояния. Тогда мы получим и преимущества ФП в устройстве самих функций, и преимущества ООП в удобном разруливании зависимостей для этих функций.

Вообще да, Hilt + какой-нибудь MVVM, а ФП уже в других участках приложения, тоже не плохо смотрится. Тут хотелось показать альтернативный путь, кто знает, может быть с развитием библиотеки Arrow появится более интересный механизм из ФП, о которых я писал в статье.

Sign up to leave a comment.