W trakcie pisania pracy inżynierskiej trafiłem na ciekawy problem z użyciem ZLayers, czyli warstw pozwalających na dostarczanie zależności do naszych efektów. Oficjalna dokumentacja ZIO określa je jako konstruktory na sterydach. Podobnie jak konstruktor, warstwa wymaga zasobów na wejściu by stworzyć produkt, czyli typ wyjściowy. Przy czym warstwy dużo łatwiej komponować, czy odpalać asynchronicznie.
Zarówno w dokumentacji, jak i na blogach, można znaleźć masę przykładów jak rozbijać aplikację na poszczególne warstwy.
Brakowało mi tylko jednego przykładu- tego, który sam miałem zastosować. Otóż moja praca zakłada, że aplikacja będzie łącyzć się z bazą danych- to akurat nie jest jakieś szczególne novum.
Ale typ bazy poznam dopiero w run-time. Na potrzeby pracy dyplomowej ograniczam wybór do trzech: Postgres, Mongo i Snowflake, ale to i tak skutkuje znacznymi różnicami w połączeniu i prowadzeniu transakcji.
Pojawia się zatem pytanie- w jaki sposób uwzględnić te zmienne zależności w kodzie.
Najgłębszą warstwą mojej aplikacji jest Config- czyli plik konfiguracyjny przełożony na case class. To tam określam, jaki typ bazy będzie wykorzystany.
Warstwę wyżej mam ConnectionPool, która jak łatwo zgadnąć, zależy od tego co znajdzie się w configu - w zależności od typu bazy szukamy różnych danych do połączenia.
I wreszcie jest sama baza - która zależy od ConnectionPool i configu.
Jak widać, zarówno dla ConnectionPool, jak i bazy, typ tworzonej warstwy zależeć będzie od zawartości configu. Jednak nie potrafiłem znaleźć odpowiedzi na pytanie, kiedy powinienem to różnicować.
Czy stworzyć jedną warstwę dla bazy danych, a wewnątrz jej konstruktora wykorzystać pattern matching, by zwrócić właściwą bazę?
A może stworzyć pełen serwis - wraz z warstwą dla każdej z możliwych baz, i dopiero przy komponowaniu finalnej aplikacji podstawić właściwą warstwę?
Nie mogąc samemu znaleźć odpowiedzi, udałem się do źródła, czyli na oficjalny Discord ZIO. Zadałem pytanie, i okazało się, że najbardziej idiomatycznie będzie pójść w drugą stronę.
Oto odpowiedź, jaką dostałem od użytkownika cal
:
Załóżmy, że mamy taką implementację dostępnych baz:
trait Persistence:
def record(eventId: EventId, message: Message): Task[Unit]
def retrieve(eventId: EventId): Stream[Throwable, Message]
final class PostgresStorage(pool: JDBCPool) extends Persistence:
def record(eventId: EventId, message: Message): Task[Unit] = ...
def retrieve(eventId: EventId): Stream[Throwable, Message] = ...
object PostgresStorage:
// typically defined, we won't use these since your use case is more complex
val layer: ZLayer[JDBCPool, Nothing, Persistence] = ZLayer.fromFunction(PostgresStorage.apply)
final class MongoDbStorage(pool: MongoPool) extends Persistence:
def record(eventId: EventId, message: Message): Task[Unit] = ...
def retrieve(eventId: EventId): Stream[Throwable, Message] = ...
object MongoDbStorage:
// typically defined, we won't use these since your use case is more complex
val layer: ZLayer[MongoPool, Nothing, Persistence] = ZLayer.fromFunction(MongoDbStorage.apply)
Mamy też serwis, który będzie korzystał z bazy:
final class HydrationUseCase(persistence: Persistence, eventCollector: EventCollector):
val hydrate: ZStream[Any, Throwable, Message] =
eventCollector.stream.mapZIO: event =>
val (eventId, message) = extract(event)
persistence.record(eventId, message)
.as(message)
private def extract(event: Event): (EventId, Message) = ...
object HydrationUseCase:
val layer: ZLayer[Persistence & EventCollector, Nothing, HydrationUseCase] = ZLayer.fromFunction(HydrationUseCase.apply)
I tu następuje odpowiedź na moje pytanie- wyboru dokonujemy już na etapie składania do kupy configu:
final case class AppConfig(
databaseConfig: DatabaseConfig,
...
)
object AppConfig:
private val config: Config[AppConfig] =
DatabaseConfig.config.map(AppConfig.apply)
val layer: TaskLayer[AppConfig] =
ZLayer.fromZIO(
read[AppConfig](config.from(ConfigProvider.fromEnv()))
)
val persistenceLayer: ZLayer[AppConfig, Throwable, Persistence] =
ZLayer.fromZIO: // this may become .scoped instead as your pools are usually resources (Scope)
ZIO.serviceWithZIO[AppConfig]: config =>
config.databaseConfig match
case DatabaseConfig.Mongo(user, pass, collection, url) =>
val mongoPool: MongoPool = ...
MongoDbStorage(mongoPool)
case DatabaseConfig.Postgres(user, pass, host, port, database, searchPath) =>
val jdbcPool: JdbcPool = ...
PostgresStorage(jdbcPool)
enum DatabaseConfig:
case Mongo(user: String, pass: String, collection: String, url: String)
case Postgres(user: String, pass: String, host: String, port: Int, database: String, searchPath: String)
object DatabaseConfig:
private val mongoConfig: Config[DatabaseConfig.Mongo] =
(
Config.string("MONGO_USER") ++
Config.string("MONGO_PASS") ++
Config.string("MONGO_COLLECTION") ++
Config.string("MONGO_URL")
)
.map(DatabaseConfig.Mongo.apply)
private val postgresConfig: Config[DatabaseConfig.Postgres] =
(
Config.string("POSTGRES_USER") ++
Config.string("POSTGRES_PASS") ++
Config.string("POSTGRES_HOST") ++
Config.int("POSTGRES_PORT") ++
Config.string("POSTGRES_DATABASE") ++
Config.string("POSTGRES_SEARCH_PATH")
)
.map(DatabaseConfig.Postgres.apply)
private val databaseType: Config[String] =
Config.string("DATABASE_TYPE")
val config: Config[DatabaseConfig] =
(databaseType ++ mongoConfig.optional ++ postgresConfig.optional).mapOrFail:
case ("mongo", Some(mongo), _) => Right(mongo)
case ("postgres", _, Some(postgres)) => Right(postgres)
case (dbType, _, _) => Left(Config.Error.Unsupported(message = s"Unsupported database type: $dbType"))
I wreszcie sama aplikacja może wyglądać tak:
object ExampleApp extends ZIOAppDefault:
val run =
ZStream.serviceWithStream[HydrationUseCase](_.hydrate).runDrain
.provide(AppConfig.layer, AppConfig.persistenceLayer, HydrationUseCase.layer, EventCollector.layer)
Muszę przyznać, że tego się nie spodziewałem.