Часть 11. Cats Effect & Http4s

Страничка курса: https://maxcom.github.io/scala-course-2022/

План

  1. Эффекты
  2. IO vs Future
  3. Trampolining
  4. IO API
  5. Resource
  6. Thread Model: Fibers
  7. Http4s

Чистая функция

  • Является детерминированной
  • Не обладает побочными эффектами (side-effect)

Побочный эффект

  • Создание или изменение файла
  • Запись данных в базу
  • Изменение глобальной переменной (увеличение счётчика)
  • Модификация переданной в функцию переменной
  • Изменение своего дальнейшего поведения
  • Вызов внешней функции, имеющей любой из перечисленных выше эффектов

Чистые функции - это хорошо?

Чистые функции

  • Делают код более предсказуемым
  • Решают проблемы многопоточности
  • Позволяют кешировать результат
  • Можно менять местами последовательность вызова двух чистых функций

Resource

Внешний относительно функции объект,
который может меняться со временем

(не обязательно побочными эффектами
данной функции)

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

Cats Effect

https://typelevel.org/cats-effect

Cats Effect

"Высокопроизводительная асинхронная компонуемая платформа для создания приложений в чистом функциональном стиле"

IO monad

  • Безопасное использование и управление ресурсами
  • Типизированность
  • Параллельность (Fiber - легковесные потоки, управляемые средой выполнения)
  • Асинхронность (callback-driven) или синхронность
  • Конечное или бесконечное время выполнения

IO vs Future

 object Future {
   def apply[A](body: => A): Future[A]
 }
 object IO {
   def apply[A](body: => A): IO[A]
 }
Eager
with Memo
Lazy
with Memo
Lazy
without Memo
Sync val
A
lazy val
() => A
def
() => A
Async Future[A]
(A => Unit) => Unit
IO[A]
() => (A => Unit) => Unit
IO evaluated at the "end of the world"
 val addToGauge = IO {
   ???
   println("Added!")
 }

 val program: IO[Unit] =
   for {
      _ <- addToGauge
      _ <- addToGauge
   } yield ()

 program.unsafeRunSync()
 // Added!
 // Added!
Stack Safety
 def fib(n: Int, a: Long = 0, b: Long = 1): IO[Long] =
   IO(a + b).flatMap { b2 =>
     if (n > 0) 
       fib(n - 1, b, b2)
     else 
       IO.pure(a)
 }
IO is trampolined in its flatMap evaluation

Trampolining

Основная идея – сделать, чтобы функция возвращала continuation

sealed abstract class IO[A]

case class Pure[A](a: A) extends IO[A]
case class Suspend[A](thunk: () => A) extends IO[A]
case class FlatMap[A, B](io: IO[B], f: B => IO[A]) extends IO[A]
 sealed abstract class IO[A] {
   def flatMap[B](f: A => IO[B]): IO[B] = FlatMap(this, f)

   def unsafeRun(): A = this match {
     case Pure(a) => a
     case Suspend(thunk) => thunk()
     case FlatMap(io, f) => f(io.unsafeRun()).unsafeRun()
   }
 }
 def unsafeRun(): A = this match {
   case Pure(a) => a
   case Suspend(thunk) => thunk()
   case FlatMap(ioA, f) => ioA match {
     case Pure(a) =>
       f(a).unsafeRun()
     case Suspend(thunk) =>
       thunk().flatMap(f).unsafeRun()
     case FlatMap(ioB, g) =>
       ioB.flatMap(g(_) flatMap f).unsafeRun()
   }
 }

Получаем хвостовую рекурсию!

IO API

 object IO {
   //side effect is not thread-blocking:
   def apply[A](thunk: => A): IO[A] //alias for delay
   def delay[A](thunk: => A): IO[A]

   //side effect is thread-blocking:
   def blocking[A](thunk: => A): IO[A]      //uncancelable
   def interruptible[A](thunk: => A): IO[A] //cancelable
   def interruptibleMany[A](thunk: => A): IO[A]
 }
 object IO {
   //was `async` in Cats Effect 2.x
   def async_[A](
     k: ((Either[Throwable, A]) => Unit) => Unit
   ): IO[A]

   //generalized version for `cancelable` in Cats Effect 2.x
   def async[A](
     k: ((Either[Throwable, A]) => Unit) => IO[Option[IO[Unit]]]
   ): IO[A]
 }
def fromCompletableFuture[A](f: IO[CompletableFuture[A]]):IO[A]=
  f.flatMap { cf =>
    IO.async { cb =>
      IO {
        //Invoke the callback with the result
        //of the completable future
        val stage = cf.handle[Unit] {
          case (a, null) => cb(Right(a))
          case (_, e) => cb(Left(e))
        }

        //Cancel the completable future if the fiber is canceled
        Some(IO(stage.cancel(false)).void)
  }}}
object IO {
  def pure[A](value: A): IO[A]           //already evaluated
  def canceled: IO[Unit]                 //already cancelled
  def raiseError[A](t: Throwable): IO[A] //already throwed

  def stub: IO[Nothing]
  def unit: IO[Unit]                     //alias for IO.pure(())
  def none[A]: IO[Option[A]]             //contains None
  def some[A](a: A): IO[Option[A]]       //contains Some(a)

  def raiseUnless(cond: Boolean)(e: => Throwable): IO[Unit]
  def raiseWhen(cond: Boolean)(e: => Throwable): IO[Unit]

  def never[A]: IO[A]                 //alias for async(_ => ())
}
 object IO {
   def fromEither[A](e: Either[Throwable, A]): IO[A]
   def fromFuture[A](fut: IO[Future[A]]): IO[A]
   def fromOption[A](o: Option[A])(orElse: => Throwable): IO[A]
   def fromTry[A](t: Try[A]): IO[A]
 }
class IO[A] {
  def map[B](f: A => B): IO[B]
  def flatMap[B](f: A => IO[B]): IO[B]

  def redeem[B](recover: Throwable => B, map: A => B): IO[B]
  def redeemWith[B](r: Throwable => IO[B], b: A => IO[B]): IO[B]

  def as[B](newValue: => B): IO[B] = map(_ => newValue)
  def void: IO[Unit] = map(_ => ())
}
 object IO {

   def race[A, B](left: IO[A], right: IO[B]): IO[Either[A, B]]

   def racePair[A, B](left: IO[A], right: IO[B]):
                                IO[Either[
                                    (OutcomeIO[A], FiberIO[B]),
                                    (FiberIO[A], OutcomeIO[B])
                                ]]
 }
 val ioA: IO[A] = ???
 val ioB: IO[String] = IO.sleep(10.seconds).as("Timeout")

 IO.racePair(ioB, ioA).flatMap {
   case Left((err, fiberA)) =>
     fiberA.cancel.as(err)
   case Right((_, a)) =>
     IO.pure(a)
 }
 object IO {

   def both[A, B](left: IO[A], right: IO[B]): IO[(A, B)]

   def bothOutcome[A, B](left: IO[A], right: IO[B]):
                                IO[(OutcomeIO[A], OutcomeIO[B])]
 }

Outcome

 sealed trait Outcome[F[_], E, A]

 case class Succeeded[F[_],E,A](s: F[A]) extends Outcome[F,E,A]
 case class Errored  [F[_],E,A](e: E)    extends Outcome[F,E,A]
 case class Canceled [F[_],E,A]()        extends Outcome[F,E,A]
 sealed trait Outcome[F[_], E, A] {
   def isCanceled: Boolean
   def isError: Boolean
   def isSuccess: Boolean

   def fold[B](onCancel: => B,
               onError: (E) => B,
               onComplete: (F[A]) => B
              ): B
 }

Resource

 def bracket[A, B](acquire: F[A])
                  (use: A => F[B])
                  (release: A => F[Unit]): F[B]

   //acquire & release - uncancelable
   //use - cancelable, but could be masked

 IO.bracket(openFile("file1")) { file1 =>
   IO.bracket(openFile("file2")) { file2 =>
     IO.bracket(openFile("file3")) { file3 =>
       for {
         bytes1 <- read(file1)
         bytes2 <- read(file2)
         _ <- write(file3, bytes1 ++ bytes2)
       } yield ()
     }(file3 => close(file3))
   }(file2 => close(file2))
 }(file1 => close(file1))
 
 object Resource {
   def make[F[_], A](acquire: F[A])
                    (release: A => F[Unit]): Resource[F, A]

   def eval[F[_], A](fa: F[A]): Resource[F, A]
 }
 abstract class Resource[F, A] {
   def use[B](f: A => F[B]): F[B]
 }
 def file(name: String): Resource[IO, File] =
     Resource.make(openFile(name)))(file => close(file))

 ( for { in1 <- file("file1")
         in2 <- file("file2")
         out <- file("file3")
   } yield (in1, in2, out)
 ).use { case (file1, file2, file3) =>
   for { bytes1 <- read(file1)
         bytes2 <- read(file2)
         _ <- write(file3, bytes1 ++ bytes2)
   } yield ()
 }

 open(file1).use(IO.pure).flatMap(readFile)
 // ОШИБКА: файл уже закрыт
 

 file.use(read) >> file.use(read)
 // дважды открыли и закрыли

 file.use { file => read(file) >> read(file) }
 // один раз открыли и закрыли
 

Thread Model

Fibers

Логический поток

Асинхронный процесс

Перемешивание

M:N Threading

Логический поток предоставляет
синхронный интерфейс
к асинхронному процессу

Уровни

1. Процессы ОС
M:N с процессорами.
Собственное состояние выполнения, собственное пространство памяти
2. ОС/JVM Threads
M:N с процессами.
Собственное состояние выполнения, разделяемое пространство памяти
3. Fibers
M:N c потоками.
Разделяемое состояние выполнение, разделяемое пространство памяти

Кооперативное планирование

Fiber


trait Fiber[F[_], E, A] {
  def join: F[Outcome[F, E, A]]
  def cancel: F[Unit]
}

abstract class IO[+A] {
  def start[A](): IO[Fiber[IO, Throwable, A]]
  def startOn(ec: ExecutionContext): IO[Fiber[IO, Throwable, A]]
  def blocking[A](thunk: => A): IO[A]
}
 

 IO.println("current pool") >>
   IO.blocking(println("blocking pool")) >>
   IO.println("current pool")
 

Выполняем действие в блокирующем пуле и возвращаеся обратно

Так было в Cats Effect 2.x


 trait Blocker {
   def blockOn[F[_], A](fa: F[A])
                       (implicit cs: ContextShift[F]): F[A]
 }

 blocker.blockOn(IO(readFile)) >>
   IO(println("Shifted back to the pool that CS represents"))
 

Blocker убрали в Cats Effect 3.x

Так было в Cats Effect 2.x


 trait ContextShift[F[_]] {
   def evalOn[A](ec: ExecutionContext)(fa: F[A]): F[A]
   def shift: F[Unit]
 }
 

ContextShift убрали в Cats Effect 3.x

Пример (Cats Effect 2.x)

 CS.evalOn(blockingPool)(
   IO(println("I run on the blocking pool"))
 ) >>
   IO(println("I am shifted onto the pool that CS represents"))
 IO(println("I run on some pool")) >>
   CS.shift >>                 // можно использовать IO.shift
   IO(println("I run on the pool that CS represents"))

IO.shift

  1. Переключение обратно из пула, не управляемого системой (например, при срабатывании callback handler в клиенте Java HTTP)

    Автоматизировано Cats Effect 3.x

  2. Перепланировать Fiber в том же ExecutionContext (дать другим Fiber-ам процессорное время)

    IO.shift(implicit e: ExecutionContext) заменили на IO.cede

Пример

 def fib(n: Int, a: Long = 0, b: Long = 1): IO[Long] =
   IO(a + b).flatMap { b2 =>
     val next =
       if (n > 0) fib(n - 1, b, b2)
       else IO.pure(a)

     // Triggering a logical fork every 100 iterations
     if (n % 100 == 0)
       IO.cede >> next
     else
       next
   }

IOApp

 trait IOApp {

     def run(args: List[String]): IO[ExitCode]

     final def main(args: Array[String]): Unit = {
         //получает IORuntimeConfig
         //создаёт IORuntime
         //запускает выполнение run в "main fiber"
     }

 }

Fiber может

  • Начать выполнение другого
  • Инициировать отмену дочернего (метод cancel)
  • Отслеживать результат дочернего (Outcome)
  • Продолжать выполнение после завершения дочернего

НО !

Родительский может завершиться раньше

Могут отслеживать собственную отмену,
но не могут восстанавливаться
или продолжать выполнение после неё

Пример

 object Main extends IOApp.Simple {
   val run = IO.println("Hello") >> IO.println("World")
 }
 trait Simple extends IOApp {
   def run: IO[Unit]
   final def run(args: List[String]): IO[ExitCode] =
                                        run.as(ExitCode.Success)
 }

Http4s

https://http4s.org
import cats.effect._

object Main extends IOApp.Simple {
  val server: ResourceIO[org.http4s.server.Server] = ???

  val run = server.use(_ => IO.never)
}
 import com.comcast.ip4s._
 import org.http4s.ember.server.EmberServerBuilder
 import org.http4s._
 val httpApp: HttpApp[IO] = ???

 val server =
   EmberServerBuilder
     .default[IO]
     .withHost(ipv4"0.0.0.0")
     .withPort(port"8080")
     .withHttpApp(httpApp)
     .build

 type HttpApp[F[_]] = Kleisli[F, Request[F], Response[F]]

 // Kleisli[F[_], A, B]
 // is just a wrapper around the function
 // A => F[B]
 
 import org.http4s.HttpRoutes
 import org.http4s.dsl.io._
 import org.http4s.implicits._
 // Request[IO] => OptionT[IO, Response[IO]]
 val routes: HttpRoutes[IO] =
   HttpRoutes.of[IO] {
     case GET -> Root / "hello" / name =>
       Ok(s"Hello, $name.")
   }

 val app: HttpApp[IO] = httpRoutes.orNotFound

 val helloWorldService: HttpRoutes[IO] = ???

 val whatsUpService: HttpRoutes[IO] = ???
 val telegramService: HttpRoutes[IO] = ???

 val messengers = whatsUpService <+> telegramService

 val httpApp = Router(
   "/" -> helloWorldService,
   "/mes" -> messengers
 ).orNotFound
					

Страничка курса:
https://maxcom.github.io/scala-course-2022/