Understanding Monix Observable
Piotr Gawryś
Monix Observableを理解する
About me
- An open source contributor for fun
- One of the maintainers of Monix
- Kraków Scala User Group co-organizer
https://github.com/Avasil
twitter.com/p_gawrys
Monix メンテナの一人
Monix
- Scala / Scala.js library for asynchronous programming
- Multiple modules exposing Task, IO[E, A], Observable, Iterant, Coeval, Local, and many concurrency primitives
- Favors purely functional programming but provides for all
- Big focus on being both Future, and
非同期プログラミングのためのライブラリ Task、IO などの並行プリミティブを提供
Monix Observable
- Inspired by RxJava / ReactiveX
- Push-based with back-pressure
- See Alex's presentation for origins: https://monix.io/presentations/2018-tale-two-monix-streams.html
- Cold (single subscriber) streams are purely functional
RxJava に影響を受けている プッシュ・ベースかつバックプレッシャー付き
High Level Example
twitter.com/p_gawrys
val result: Task[Long] =
Observable.fromIterable(allElements)
.bufferTumbling(bufferSize)
.mapEval(seq => Task(seq.sum))
.filter(_ > 0)
.map(_.toLong)
.foldLeftL(0L)(_ + _)
Today, we're going to talk about internals!
今日は内部構造にも踏み込んでいきます
Definition
trait Observer[-A] {
def onNext(elem: A): Future[Ack]
def onError(ex: Throwable): Unit
def onComplete(): Unit
}
// Needs some kind of ExecutionContext to do
// anything with onNext (which returns Future)
trait Subscriber[-A] extends Observer[A] {
implicit def scheduler: Scheduler
}
abstract class Observable[+A] {
def unsafeSubscribeFn(subscriber: Subscriber[A]): Cancelable
}
まずは定義から
Observable
Observer
Observer
subscribe
subscribe
Observable
Observer
Observer
onNext
onNext
Observer#onNext protocol
trait Observer[-A] {
def onNext(elem: A): Future[Ack]
}
sealed abstract class Ack extends Future[Ack]
case object Continue extends Ack
case object Stop extends Ack
- Grammar: onNext CAN be called zero, one or multiple times until onComplete, or onError
- Back-pressure: each onNext call MUST wait on a Continue result
- Cancellation: after receiving Stop the data-source MUST no longer send any events
onComplete/onError が呼ばれるまでは onNext は 0~複数回呼ばれることがある
Observer protocol
trait Observer[-A] {
def onError(ex: Throwable): Unit
def onComplete(): Unit
}
- Grammar: either onComplete or onError at most one time, can't call both.
- Back-pressure: optional, not required to wait for the last onNext
onComplete もしくは onError は一度だけ呼ばれ 両方を呼ぶことは禁止
Observer protocol
trait Observer[-A] {
def onNext(elem: A): Future[Ack]
def onError(ex: Throwable): Unit
def onComplete(): Unit
}
- Ordering: all calls to onNext, onComplete, and onError MUST BE ordered and thus non-concurrent
- Exceptions: it is not allowed to throw exceptions
- https://monix.io/docs/current/reactive/observers.html#contract
onNext、onComplete、onError は逐次的に呼ぶこと 例外を投げることは禁止
Observable
- Subscriber/Observer subscribes to Observable and it starts emitting events
- subscribe returns Cancelable which allows to stop the computation from the outside
abstract class Observable[+A] {
def unsafeSubscribeFn(subscriber: Subscriber[A]): Cancelable
}
Subscriber は Observable を購読する Cancelable によってキャンセル可能
Simple Observable
twitter.com/p_gawrys
final class NowObservable[+A](elem: A) extends Observable[A] {
def unsafeSubscribeFn(subscriber: Subscriber[A]): Cancelable = {
// No need to back-pressure for onComplete
subscriber.onNext(elem)
subscriber.onComplete()
// There's no specific action needed in case the connection is canceled
Cancelable.empty
}
}
twitter.com/p_gawrys
final class PrintSubscriber[-A] extends Subscriber[A] {
override def scheduler: Scheduler = Scheduler.global
override def onNext(elem: A): Future[Ack] = {
println(s"Received $elem")
Continue
}
override def onError(ex: Throwable): Unit = {
println(s"Received error $ex")
}
override def onComplete(): Unit = {
println(s"Received final event")
}
}
Simple Subscriber
twitter.com/p_gawrys
val source: Observable[Int] = new NowObservable(10)
val cancelable: Cancelable =
source.unsafeSubscribeFn(new PrintSubscriber)
// => Received 10
// => Received final event
Running Observable
twitter.com/p_gawrys
new Observable[Int] {
def unsafeSubscribeFn(subscriber: Subscriber[Int]): Cancelable = {
subscriber.onNext(elem)
subscriber.onComplete()
Cancelable.empty
}
}.unsafeSubscribeFn(new Subscriber[Int] {
override def scheduler: Scheduler = Scheduler.global
override def onNext(elem: A): Future[Ack] = {
println(s"Received $elem")
Continue
}
override def onError(ex: Throwable): Unit = {
println(s"Received error $ex")
}
override def onComplete(): Unit = {
println(s"Received final event")
}
})
// => Received 10
// => Received final event
More complicated example
import monix.eval.Task
import monix.reactive.Observable
import scala.concurrent.duration._
import scala.util.Random
val result: Task[List[Int]] =
Observable.repeatEval(Random.nextInt(10))
.takeByTimespan(10.second)
.toListL
もう少し複雑な例
Observable.repeatEval
twitter.com/p_gawrys
object Observable {
def repeatEval[A](task: => A): Observable[A] =
new RepeatEvalObservable(task)
}
final class RepeatEvalObservable[+A](eval: => A) extends Observable[A] {
def unsafeSubscribeFn(subscriber: Subscriber[A]): Cancelable = {
val s = subscriber.scheduler
val cancelable = BooleanCancelable()
fastLoop(subscriber, cancelable, s.executionModel, 0)(s)
cancelable
}
@tailrec
def fastLoop(
o: Subscriber[A],
// We might check it periodically to
// see if the subscription is not cancelled
c: BooleanCancelable,
// Scheduler has ExecutionModel, e.g. Synchronous, Batched, AlwaysAsync
// We could add async boundaries according to it
em: ExecutionModel,
// BatchedExecution model inserts async boundary
// after N synchronous operations
syncIndex: Int
)(implicit s: Scheduler): Unit = ???
}
twitter.com/p_gawrys
@tailrec
def fastLoop(
o: Subscriber[A],
c: BooleanCancelable,
em: ExecutionModel,
syncIndex: Int
)(implicit s: Scheduler): Unit = {
val ack =
try o.onNext(eval)
catch {
case ex if NonFatal(ex) =>
Future.failed(ex)
}
val nextIndex =
if (ack == Continue) em.nextFrameIndex(syncIndex)
else if (ack == Stop) -1
else 0
if (nextIndex > 0)
fastLoop(o, c, em, nextIndex)
else if (nextIndex == 0 && !c.isCanceled)
reschedule(ack, o, c, em)
}
def reschedule(
ack: Future[Ack],
o: Subscriber[A],
c: BooleanCancelable,
em: ExecutionModel
)(implicit s: Scheduler): Unit = ???
def reschedule(
ack: Future[Ack],
o: Subscriber[A],
c: BooleanCancelable,
em: ExecutionModel
)(implicit s: Scheduler): Unit =
ack.onComplete {
case Success(success) =>
if (success == Continue) fastLoop(o, c, em, 0)
case Failure(ex) =>
s.reportFailure(ex)
case _ => () // this was a Stop, do nothing
}
twitter.com/p_gawrys
Observable#toListL
twitter.com/p_gawrys
abstract class Observable[+A] {
final def toListL: Task[List[A]] =
foldLeft(mutable.ListBuffer.empty[A])(_ += _)
// We know for sure that there will be only one element
.firstOrElseL(mutable.ListBuffer.empty[A])
.map(_.toList)
final def foldLeft[R](seed: => R)(op: (R, A) => R): Observable[R] = ???
final def firstOrElseL[B >: A](default: => B): Task[B] = ???
}
twitter.com/p_gawrys
final class FoldLeftObservable[A, R](
source: Observable[A],
initial: () => R,
f: (R, A) => R
) extends Observable[R] {
def unsafeSubscribeFn(out: Subscriber[R]): Cancelable = {
var streamErrors = true
try {
val initialState = initial()
streamErrors = false
source.unsafeSubscribeFn(new Subscriber[A] { ... })
} catch {
// If an error was thrown in source.unsafeSubscribeFn(...)
// it is a breach of the protocol and the behavior is undefined
// but we don't want to call out.onError in case it already happened there
case NonFatal(ex) if streamErrors =>
out.onError(ex)
Cancelable.empty
}
}
}
source.unsafeSubscribeFn(new Subscriber[A] {
implicit val scheduler = out.scheduler
// We might call onError in onNext so we need this
// flag to protect from potentially calling it twice
// (once from onNext, once by upstream)
private[this] var isDone = false
private[this] var state: R = initialState
def onNext(elem: A): Ack = {
try {
// User-supplied function
// could throw exception
state = f(state, elem)
Continue
} catch {
case ex if NonFatal(ex) =>
onError(ex)
Stop
}
}
def onComplete(): Unit =
if (!isDone) {
isDone = true
out.onNext(state)
out.onComplete()
}
def onError(ex: Throwable): Unit =
if (!isDone) {
isDone = true
out.onError(ex)
}
})
Are those vars thread-safe?
private[this] var isDone = false
private[this] var state: R = initialState
- They can be modified and read from a different thread, after all...
その var スレッドセーフですか? 変数は複数スレッドから書き込める状態
Are those vars thread-safe?
private[this] var isDone = false
private[this] var state: R = initialState
- They can be modified and read from a different thread, after all...
- but the protocol guarantees that and we'll see how!
プロトコルによってスレッドセーフが保証されている
Are those vars thread-safe?
out.onNext(next).flatMap(_ => out2.onNext).flatMap(_ => out3.onNext) ...
out.onNext(next).flatMap(_ => out2.onNext).flatMap(_ => Continue) ...
out.onNext(next).flatMap(_ => Continue) ...
Continue
If we follow onNext calls, it goes like that:
And then the next element is sent after Continue is received (remember onComplete in repeatEval?)
onNext の呼び出しを展開してみる
Internally, each Future might be scheduled on a potentially different Thread with ec.execute():
Which establishes a happens-before relation between writing and reading isDone from potentially different threads.
var isDone = false
ec.execute(() => {
isDone = true
// second thread
ec.execute(() => {
assert(isDone)
})
})
内部では各 Future は別スレッドに分担される 事前発生関係を保つことで安全性を担保
Observable#toListL
twitter.com/p_gawrys
abstract class Observable[+A] {
final def toListL: Task[List[A]] =
foldLeft(mutable.ListBuffer.empty[A])(_ += _)
// We know for sure that there will be only one element
.firstOrElseL(mutable.ListBuffer.empty[A])
.map(_.toList)
final def foldLeft[R](seed: => R)(op: (R, A) => R): Observable[R] =
new FoldLeftObservable(source, seed, op)
final def firstOrElseL[B >: A](default: => B): Task[B] = ???
}
Observable#firstOrElseL
final def firstOrElseL[B >: A](default: => B): Task[B] =
Task.create { (s, cb) =>
unsafeSubscribeFn(new Subscriber[A] {
implicit val scheduler: Scheduler = s
private[this] var isDone = false
def onNext(elem: A): Ack = {
cb.onSuccess(elem)
isDone = true
Stop
}
def onError(ex: Throwable): Unit =
if (!isDone) {
isDone = true
cb.onError(ex)
}
def onComplete(): Unit =
if (!isDone) {
isDone = true
cb(Try(default))
}
})
}
Observable#firstOrElseL Bonus!
final def firstOrElseLZIOOO[B >: A](default: => B): zio.Task[B] = {
ZIO.descriptorWith { desc =>
ZIO.effectAsync { cb =>
unsafeSubscribeFn(new Subscriber[A] {
implicit val scheduler: Scheduler =
Scheduler(desc.executor.asEC)
private[this] var isDone = false
def onNext(elem: A): Ack = {
cb(ZIO.succeed(elem))
isDone = true
Stop
}
def onError(ex: Throwable): Unit =
if (!isDone) {
isDone = true
cb(ZIO.fail(ex))
}
def onComplete(): Unit =
if (!isDone) {
isDone = true
cb(ZIO(default))
}
})
}
}
}
TakeLeftByTimespanObservable
abstract class Observable[+A] {
final def takeByTimespan(timespan: FiniteDuration): Observable[A] =
new TakeLeftByTimespanObservable(this, timespan)
}
- Takes the elements until timespan passes
- We could run source as usual but run it concurrently with a timeoutTask that will stop the source gracefully
一定時間が経過するまで要素を取る
final class TakeLeftByTimespanObservable[A](
source: Observable[A],
timespan: FiniteDuration
) extends Observable[A] {
def unsafeSubscribeFn(out: Subscriber[A]): Cancelable = {
source.unsafeSubscribeFn(new Subscriber[A] {
implicit val scheduler = out.scheduler
def onNext(elem: A): Future[Ack] = out.onNext(elem)
def onError(ex: Throwable): Unit = out.onError(ex)
def onComplete(): Unit = out.onComplete()
})
}
}
Let's add a concurrent task to call onComplete after timespan
一定時間後に onComplete を呼ぶタスクを追加してみる
def unsafeSubscribeFn(out: Subscriber[A]): Cancelable = {
val composite = CompositeCancelable()
composite += source.unsafeSubscribeFn(new Subscriber[A] {
implicit val scheduler = out.scheduler
private[this] val timeoutTask: Cancelable = {
val ref = scheduler.scheduleOnce(
timespan.length,
timespan.unit,
new Runnable { override def run(): Unit = onComplete() }
)
composite += ref
ref
}
def onNext(elem: A): Future[Ack] = ???
def onError(ex: Throwable): Unit = ???
def onComplete(): Unit = ???
})
}
private[this] var isActive = true
private def deactivate(): Unit = {
isActive = false
timeoutTask.cancel()
}
def onNext(elem: A): Future[Ack] = {
if (isActive) out.onNext(elem).syncOnStopOrFailure(_ => deactivate())
else Stop
}
def onError(ex: Throwable): Unit = {
if (isActive) {
deactivate()
out.onError(ex)
}
}
def onComplete(): Unit = {
if (isActive) {
deactivate()
out.onComplete()
}
}
Access to isActive needs to be synchronized because there is no happens-before relationship between onNext and timeoutTask
onNext と timeoutTask には事前発生関係が無いため isActive へのアクセスは同期化する必要がある
private[this] var isActive = true
private def deactivate(): Unit = {
isActive = false
timeoutTask.cancel()
}
def onNext(elem: A): Future[Ack] = synchronized {
if (isActive) out.onNext(elem).syncOnStopOrFailure(_ => deactivate())
else Stop
}
def onError(ex: Throwable): Unit = synchronized {
if (isActive) {
deactivate()
out.onError(ex)
}
}
def onComplete(): Unit = synchronized {
if (isActive) {
deactivate()
out.onComplete()
}
}
syncOnStopOrFailure
// F-bounded polymorphism, see
// https://github.com/ghik/opinionated-scala/blob/master/chapters/Generics-and-type-members.md#f-bounded-polymorphism
implicit class AckExtensions[Self <: Future[Ack]](val source: Self) extends AnyVal {
def syncOnStopOrFailure(
cb: Option[Throwable] => Unit
)(implicit r: UncaughtExceptionReporter): Self = {
if (source eq Stop)
try cb(None)
catch { case e if NonFatal(e) => r.reportFailure(e) }
else if (source ne Continue)
source.onComplete { ack =>
try ack match {
case Success(Stop) => cb(None)
case Failure(e) => cb(Some(e))
case _ => ()
} catch {
case e if NonFatal(e) => r.reportFailure(e)
}
}(immediate)
source
}
}
source.unsafeSubscribeFn(new Subscriber[A] {
implicit val scheduler = out.scheduler
private[this] var isActive = true
private[this] val timeoutTask: Cancelable = {
val ref = scheduler.scheduleOnce(
timespan.length,
timespan.unit,
new Runnable { override def run(): Unit = onComplete() }
)
composite += ref
ref
}
})
source.unsafeSubscribeFn(new Subscriber[A] with Runnable {
implicit val scheduler = out.scheduler
private[this] var isActive = true
private[this] val timeoutTask: Cancelable = {
val ref = scheduler.scheduleOnce(
timespan.length,
timespan.unit,
this
)
composite += ref
ref
}
def run() = onComplete()
})
final class TakeLeftByTimespanObservable[A](
source: Observable[A],
timespan: FiniteDuration
) extends Observable[A] {
def unsafeSubscribeFn(out: Subscriber[A]): Cancelable = {
val composite = CompositeCancelable()
composite += source.unsafeSubscribeFn(new Subscriber[A] with Runnable {
implicit val scheduler = out.scheduler
private[this] var isActive = true
private[this] val timeoutTask: Cancelable = {
val ref = scheduler.scheduleOnce(timespan.length, timespan.unit, this)
composite += ref
ref
}
def run(): Unit = onComplete()
private def deactivate(): Unit = synchronized {
isActive = false
timeoutTask.cancel()
}
def onNext(elem: A): Future[Ack] = synchronized {
if (isActive) out.onNext(elem).syncOnStopOrFailure(_ => deactivate())
else Stop
}
def onError(ex: Throwable): Unit = synchronized {
if (isActive) {
deactivate()
out.onError(ex)
}
}
def onComplete(): Unit = synchronized {
if (isActive) {
deactivate()
out.onComplete()
}
}
})
}
}
Complete example
val result: Task[List[Int]] =
Observable.repeatEval(Random.nextInt(10))
.takeByTimespan(10.second)
.toListL
Could be inlined to
このコードを展開してみよう
Task.create { (s, cb) =>
val source =
new FoldLeftObservable(
new TakeLeftByTimespanObservable(
new RepeatEvalObservable(Random.nextInt(10)),
10.second
),
mutable.ListBuffer.empty[Int]
)(_ += _).firstOrElse().map(_.toList)
source.unsafeSubscribeFn(new Subscriber[A] {
implicit val scheduler: Scheduler = s
private[this] var isDone = false
def onNext(elem: A): Ack = {
cb.onSuccess(elem)
isDone = true
Stop
}
def onError(ex: Throwable): Unit =
if (!isDone) {
isDone = true
cb.onError(ex)
}
def onComplete(): Unit =
if (!isDone) {
isDone = true
cb(Try(default))
}
})
.map(_.toList)
}
What we didn't cover
- Subject (both Observable and Observer)
- BufferedSubscriber
- Hot Observable (sharing one Observable between multiple Subscribers)
今回カバーできなかった事
Are dirty internals worth it?
I'm about to show few micro-benchmarks.
Please, keep in mind that the results can be misleading - it's best to measure for your specific use case.
API/Ecosystem/Familiarity is usually better criteria, as long as the library meets the minimum performance requirements.
泥臭い内部構造は割に合うのか ライブラリの選択は性能が全てでは無いが...
ChunkedMapFilterSum
def monixObservable(): Int = {
val stream = Observable
.fromIterable(allElements)
.map(_ + 1)
.filter(_ % 2 == 0)
sum(stream)
}
def akkaStream(): Long = {
val stream = AkkaSource(allElements)
.map(_ + 1)
.filter(_ % 2 == 0)
.toMat(AkkaSink.fold(0L)(_ + _))(Keep.right)
Await.result(stream.run(), Duration.Inf)
}
def whileLoop(): Int = {
val cursor = allElements.iterator
var sum = 0
while (cursor.hasNext) {
val next = cursor.next() + 1
if (next % 2 == 0) sum += next
}
sum
}
def zioStream(): Int = {
val stream = ZStream
.fromChunks(zioChunks: _*)
.map(_ + 1)
.filter(_ % 2 == 0)
.runSum
zioUntracedRuntime.unsafeRun(stream)
}
def fs2Stream(): Int = {
val stream = FS2Stream(fs2Chunks: _*)
.flatMap(FS2Stream.chunk)
.map(_ + 1)
.filter(_ % 2 == 0)
.compile
.fold(0)(_ + _)
stream
}
ChunkedMapFilterSum
[info] Benchmark (chunkCount) (chunkSize) Mode Cnt Score Error Units
[info] akka 1000 1000 thrpt 20 10.749 ± 0.082 ops/s
[info] fs2 1000 1000 thrpt 20 55.939 ± 0.497 ops/s
[info] iterator 1000 1000 thrpt 20 76.830 ± 1.182 ops/s
[info] monix 1000 1000 thrpt 20 97.942 ± 2.479 ops/s
[info] vector 1000 1000 thrpt 20 61.514 ± 0.213 ops/s
[info] whileLoop 1000 1000 thrpt 20 355.31 ± 2.803 ops/s
[info] zio 1000 1000 thrpt 20 31.971 ± 0.197 ops/s
Note that Monix and Akka process elements one-by-one, and fs2 and zio do it in batches
Monix と Akka は要素を 1つづつ処理する
MapAccumulate
twitter.com/p_gawrys
[info] Benchmark (n) Mode Cnt Score Error Units
[info] fs2 1000 thrpt 20 66490.570 ± 211.840 ops/s
[info] fs2 10000 thrpt 20 8241.498 ± 52.588 ops/s
[info] monix 1000 thrpt 20 99300.153 ± 619.293 ops/s
[info] monix 10000 thrpt 20 10539.976 ± 203.321 ops/s
[info] zio 1000 thrpt 20 1819.379 ± 16.974 ops/s
[info] zio 10000 thrpt 20 201.752 ± 2.983 ops/s
Merge
twitter.com/p_gawrys
[info] Benchmark (streams) Mode Cnt Score Error Units
[info] akka 100 thrpt 20 1048.289 ± 4.834 ops/s
[info] akka 1000 thrpt 20 118.239 ± 1.396 ops/s
[info] fs2 100 thrpt 20 347.603 ± 8.651 ops/s
[info] fs2 1000 thrpt 20 8.636 ± 0.128 ops/s
[info] monix 100 thrpt 20 15860.931 ± 122.029 ops/s
[info] monix 1000 thrpt 20 2563.933 ± 53.844 ops/s
[info] zio 100 thrpt 20 116.100 ± 0.973 ops/s
[info] zio 1000 thrpt 20 12.260 ± 0.315 ops/s
ChunkedEvalFilterMapSum
twitter.com/p_gawrys
def fs2Stream = {
val stream = FS2Stream
.apply(allElements: _*)
.chunkN(chunkSize)
.evalMap[MonixTask, Int](chunk => MonixTask(sumIntScala(chunk.iterator)))
.filter(_ > 0)
.map(_.toLong)
.compile
.fold(0L)(_ + _)
}
def fs2StreamPreChunked = {
val stream = FS2Stream(fs2Chunks: _*)
.evalMap[MonixTask, Int](chunk => MonixTask(sumIntScala(chunk.iterator)))
.filter(_ > 0)
.map(_.toLong)
.compile
.fold(0L)(_ + _)
}
ChunkedEvalFilterMapSum
[info] Benchmark (chunkCount) (chunkSize) Mode Cnt Score Error Units
[info] akka 1000 1000 thrpt 20 12.620 ± 0.418 ops/s
[info] akkaPreChunked 1000 1000 thrpt 20 193.842 ± 1.147 ops/s
[info] fs2 1000 1000 thrpt 20 61.285 ± 1.243 ops/s
[info] fs2PreChunked 1000 1000 thrpt 20 150.544 ± 1.089 ops/s
[info] monix 1000 1000 thrpt 20 80.448 ± 1.510 ops/s
[info] monixPreChunked 1000 1000 thrpt 20 280.467 ± 4.769 ops/s
[info] zioPreChunked 1000 1000 thrpt 20 121.028 ± 0.861 ops/s
ZIO will be automatically chunked with fromIterable so we don't compare buffering here
ZIO は自動的にチャンク化するので比較外
Tradeoffs
Cons:
- Pure API, Dirty Internals - individual operators are hard to reason about in comparison to higher-level implementations of fs2/zio
- Push Model - if you want to maximize throughput, you need to use buffers yourself
- Shared Data Sources are not purely functional
- Current implementation of flatMap is not stack-safe
演算子は fs2/zio に比べて分析しづらい 自分でバッファリングする必要がある
Pros:
- Pure API, Dirty Internals - nice API and best-in-class performance
- Push Model - awesome for latency and time-based operators
- Effect independent - Observable is fully capable of executing on its own, without any overhead of going through Task/IO Run-Loop, and could support all effect types natively
- ReactiveX based - tons of related resources and a perfect step into FP for people coming from Java/JS :)
ナイスな API、(他社比)最高速の性能 時間ベースの演算にも向いている
Final words
- If you have any questions or more ideas, make sure to let us know at https://github.com/monix/monix or https://gitter.im/monix/monix
- Recently, I've released https://github.com/monix/monix-bio - Cats-Effect friendly IO[E, A] implementation
- Contributions are very welcome!
- ... Thank you for being here :)
- Slides: https://slides.com/avasil/understanding-monix-observable-scalamatsuri2020
コントリ大歓迎 ご清聴ありがとうございます
Understanding Monix Observable (ScalaMatsuri 2020, Japanese subtitles)
By Piotr Gawryś
Understanding Monix Observable (ScalaMatsuri 2020, Japanese subtitles)
- 2,158