Bring Your Own Effect

Chris Birchall

The good old days

interface Cache<K, V> {

  public Optional<V> get(K key);

  public void put(K key, V value);

}
trait Cache[K, V] {

  def get(key: K): Future[Option[V]]

  def put(key: K, value: V): Future[Unit]

}
trait AsyncCache[K, V] {

  def get(key: K): Future[Option[V]]

  def put(key: K, value: V): Future[Unit]

}

trait SyncCache[K, V] {

  def get(key: K): Option[V]

  def put(key: K, value: V): Unit

}

class RedisCache extends AsyncCache { ...}

class CaffeineCache extends SyncCache { ... }
trait Cache[K, V] {

  def get(key: K): Future[Option[V]]

  def put(key: K, value: V): Future[Unit]

}

def getSync[K, V](cache: Cache[K, V])
                 (key: K): Option[V] =
  Await.result(cache.get(key), Duration.Inf)

def putSync[K, V](cache: Cache[K, V])
                 (key: K, value: V): Option[V] =
  Await.result(cache.put(key, value), Duration.Inf)

Source: Wikipedia

Source: pxhere.com

trait Cache[K, V] {

  def get[F[_]](key: K): F[Option[V]]

  def put[F[_]](key: K, value: V): F[Unit]

}
val cache: Cache[String, Int] = ...

val future: Future[Option[Int]] = cache.get[Future]("foo")

val task: Task[Option[Int]] = cache.get[Task]("foo")
val plainOldValue: Option[Int] = cache.get[???]("foo")
val plainOldValue: Option[Int] = cache.get[Id]("foo")
type Id[A] = A
class MemcachedCache[K, V] extends Cache[K, V] {

  val memcachedClient = ...

  def get[F[_]](key: K): F[Option[V]] = ???
 
  def put[F[_]](key: K, value: V): F[Unit] = ???

}
class MemcachedCache[K, V] extends Cache[K, V] {

  val memcachedClient = ...

  def get[F[_]](key: K)
               (implicit tc: TypeClass[F]): F[Option[V]] = {
    // TODO cache lookup!
    tc.pure(None)
  }
 
  def put[F[_]](key: K, value: V)
               (implicit tc: TypeClass[F]): F[Unit] = {
    // TODO cache write!
    tc.pure(())
  }
}

What typeclass do I need?

  • Depends on what your code needs to do
  • Use the least powerful tool for the job

Functor

Applicative

Monad

MonadError

Sync

Async

def map[A,B](fa: F[A])(f: A => B): F[B]
def pure[A](a: A): F[A]
def flatMap[A,B](fa: F[A])(f: A => F[B]): F[B]
def raiseError[A](e: Throwable): F[A]
def handleError[A](fa: F[A])(f: Throwable => A): F[A]
def delay[A](thunk: => A): F[A]
def async[A](
  register: (Either[Throwable, A] => Unit) => Unit): F[A]

Aside: "non-blocking"/"async" I/O

Blocking the current thread:

val x: Result = makeRemoteApiCall()
val x: Future[Result] = Future { makeRemoteApiCall() }

Still blocking, but on a different thread:

val x: Unit = makeRemoteApiCall(callback = {
  case Left(err) => println("Oh no!")
  case Right(result) => println("Yay!")
})

Callback-based async I/O:

val x: Future[Result] = {
  val p = Promise[Result]()
  makeRemoteApiCall(callback = {
    case Left(err) => p.failure(err)
    case Right(result) => p.success(result)
  })
  p.future
}

Aside: "non-blocking"/"async" I/O

Callback-based async I/O

Future

Blocking the current thread

val x = Await.result(future, Duration.Inf)

ScalaCache's Async typeclass

trait Async[F[_]] {

  def pure[A](a: A): F[A]

  def map[A, B](fa: F[A])(f: A => B): F[B]

  def flatMap[A, B](fa: F[A])(f: A => F[B]): F[B]

  def raiseError[A](t: Throwable): F[A]

  def handleNonFatal[A](fa: => F[A])(f: Throwable => A): F[A]

  def delay[A](thunk: => A): F[A]

  def suspend[A](thunk: => F[A]): F[A]

  def async[A](register: (Either[Throwable, A] => Unit) => Unit): F[A]

}
class AsyncForFuture(implicit ec: ExecutionContext) extends Async[Future] {

  def pure[A](a: A): Future[A] = Future.successful(a)

  def map[A, B](fa: Future[A])(f: A => B): Future[B] = fa.map(f)

  def flatMap[A, B](fa: Future[A])(f: A => Future[B]): Future[B] = fa.flatMap(f)

  def raiseError[A](t: Throwable): Future[A] = Future.failed(t)

  def handleNonFatal[A](fa: => Future[A])(f: Throwable => A): Future[A] = {
    fa.recover {
      case NonFatal(e) => f(e)
    }
  }

  def delay[A](thunk: => A): Future[A] = Future(thunk)

  def suspend[A](thunk: => Future[A]): Future[A] = thunk

  def async[A](register: (Either[Throwable, A] => Unit) => Unit): Future[A] = {
    val promise = Promise[A]()
    register {
      case Left(e) => promise.failure(e)
      case Right(x) => promise.success(x)
    }
    promise.future
  }

}
class CaffeineCache[V](...) {

  def doGet[F[_]](key: String)(implicit mode: Mode[F]): F[Option[V]] = {
    mode.M.delay {
      val baseValue = underlying.getIfPresent(key)
      val result = {
        if (baseValue != null) {
          val entry = baseValue.asInstanceOf[Entry[V]]
          if (entry.isExpired) None else Some(entry.value)
        } else None
      }
      logCacheHitOrMiss(key, result)
      result
    }
  }

  ...

}
class MemcachedCache[V](...) {

  def doGet[F[_]](key: String)(implicit mode: Mode[F]): F[Option[V]] = {
    mode.M.async { cb =>
      val f = client.asyncGet(keySanitizer.toValidMemcachedKey(key))
      f.addListener(new GetCompletionListener {
        def onComplete(g: GetFuture[_]): Unit = {
          if (g.getStatus.isSuccess) {
            try {
              val bytes = g.get()
              val value = codec.decode(bytes.asInstanceOf[Array[Byte]]).map(Some(_))
              cb(value)
            } catch {
              case NonFatal(e) => cb(Left(e))
            }
          } else {
            g.getStatus.getStatusCode match {
              case StatusCode.ERR_NOT_FOUND => cb(Right(None))
              case _ => cb(Left(new MemcachedException(g.getStatus.getMessage)))
            }

          }
        }
      })
    }
  }

  ...

}

Why should I do this?

In a library:

  • More flexibility - allow users to choose the F[_] that is most appropriate to their application

 

In an application:

  • Extra dimension of abstraction
  • Simplify testing

Extra dimension of abstraction

Decouple:

  • Composition of operations (business logic)
  • Choice of monad (error handling, context passing)
  • Concrete implementation details

Composition of operations

trait Persistence[F[_]] {

  def saveNewUser(details: UserDetails): F[UserId]

  def findUserById(userId: UserId): F[User]

}

trait Events[F[_]] {

  def sendCreatedUserEvent(user: User): F[Unit]

}

trait UserOps[F[_]] extends Persistence[F] with Events[F] {

  implicit def M: Monad[F]

  final def createUser(details: UserDetails): F[User] =
    for {
      userId <- saveNewUser(details)
      user <- findUserById(userId)
      _ <- sendCreatedUserEvent(user)
    } yield user

}

Choice of monad

sealed trait UserServiceFailure
case object NotFound extends UserServiceFailure
case class InternalError(e: Throwable) extends UserServiceFailure

type UserServiceResult[A] = Either[UserServiceFailure, A]

case class Context(traceToken: String)
type UserServiceOp[A] = Kleisli[UserServiceResult, Context, A]

trait UserService extends UserOps[UserServiceOp]

Concrete impl details

class MyLovelyUserService(db: Database, eventPublisher: EventPublisher)
                         (implicit def M: Monad[UserUserviceOp])
                         extends UserService {

  def saveNewUser(userDetails: UserDetails): UserServiceOp[UserId] = {
    ...
  }

  def findUserById(userId: UserId): UserServiceOp[User] = {
    ...
  }

  def sendCreatedUserEvent(user: User): UserServiceOp[Unit] = {
    ...
  }

}

Conclusion

Abstract early, commit late

 

Questions?

 

https://slides.com/cb372/bring-your-own-effect

Bring Your Own Effect

By Chris Birchall

Bring Your Own Effect

  • 3,218