-
Notifications
You must be signed in to change notification settings - Fork 141
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Implement 'produce' aliases in the Producer trait, making producer co… #1048
Conversation
…mposition simpler
44dc032
to
95b921c
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How does this make composition easier?
Consider a simple producer: trait Producer:
def produce[K, V](record: ProducerRecord[K, V]): UIO[UIO[Unit]] I want to create a producer that, for each record, applies a function that modifies the record, and may tack on an effect, like logging each trait Producer:
def produce[K, V](record: ProducerRecord[K, V]): UIO[UIO[Unit]]
def modifyHeadersAndLoggingProducer(producer: Producer): Producer =
new Producer:
def produce[K, V](record: ProducerRecord[K, V]): UIO[UIO[Unit]] =
producer.produce(record.addHeader("foo", "bar"))
.map(_ *> ZIO.logDebug("ack received")) *> ZIO.logDebug("record sent")
) Without this MR, to compose producers, you'd have to implement all the Even without this use case, all the methods other than |
Here is an example of how painful it is without these changes: https://github.com/kaizen-solutions/trace4cats-zio-extras/blob/main/zio-kafka/src/main/scala/io/kaizensolutions/trace4cats/zio/extras/ziokafka/KafkaProducerTracer.scala |
@yisraelU fyi |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I like it. Thanks!
I don't think that's how you should do that Here's how I'd do it: package my.example
import zio.kafka.Producer as ZioProducer
trait MyProducer {
def produce[K, V](record: ProducerRecord[K, V]): UIO[UIO[Unit]]
}
object MyProducer {
val live: URLayer[ZioProducer, MyProducer] =
ZLayer {
for {
zioProducer <- ZIO.service[ZioProducer]
} yield new MyProducerLive(zioProducer)
}
}
final class MyProducerLive(zioProducer: ZioProducer) extends MyProducer {
override def produce[K, V](record: ProducerRecord[K, V]): UIO[UIO[Unit]] = {
zioProducer
.produce(record.addHeader("foo", "bar"))
.map(_ *> ZIO.logDebug("ack received")) *> ZIO.logDebug("record sent"))
}
} You should prefer composition and should not depend on the interface that we're exposing. |
Co-authored-by: Jules Ivanic <[email protected]>
@guizmaii this way of composition makes sense, but has some shortcomings:
|
package my example
import zio.kafka.Producer as ZioProducer
// Because you extends the zio-kafka Producer, your implementation will have to implement all the zio-kafka Producer interface
trait MyProducer extends ZioProducer
object MyProducer {
val live: URLayer[ZioProducer, ZioProducer] =
ZLayer {
for {
zioProducer <- ZIO.service[ZioProducer]
} yield new MyProducerLive(zioProducer)
}
}
final class MyProducerLive(zioProducer: ZioProducer) extends MyProducer {
// example of methods where you do something around the producing
override def produce[K, V](record: ProducerRecord[K, V]): UIO[UIO[Unit]] =
zioProducer
.produce(record.addHeader("foo", "bar"))
.map(_ *> ZIO.logDebug("ack received")) *> ZIO.logDebug("record sent"))
// example of methods where you do nothing around the producing, just call the zioProducer method
override def produceAsync[K, V](record: ProducerRecord[K, V]): UIO[UIO[Unit]] =
zioProducer.produceAsync(record)
... // all the other methods of the interface
} If we merge your PR, you'll not be able to do this anymore or you'll have to copy the code you moved from the
package my.example
import zio.telemetry.opentelemetry.Tracing
trait MyService {
def myUsefulMethod(...): Task[Unit]
}
object MyService {
val live: URLayer[... & Tracing, MyService] =
for {
... <- ...
tracing <- ZIO.environment[Tracing]
live = new MyServiceLive(...)
traced = new MyServiceTraced(tracing)(live)
} yield traced
}
final class MyServiceLive(...) extends MyService {
override def myUsefulMethod(...): Task[Unit] = ...
}
// Copilot is very good at writing this boring code automatically BTW
final class MyServiceTraced(tracing: ZEnvironment[Tracing])(delegator: MyService) extends MyService {
import zio.telemetry.opentelemetry.TracingSyntax.*
override def myUsefulMethod(...): Task[Unit] =
delegator
.myUsefulMethod(...)
.span("MyService::myUsefulMethod")
.provideEnv(tracing)
} With your Producer needs, that'd give: package my.example
import zio.kafka.Producer as ZioProducer
import zio.telemetry.opentelemetry.Tracing
object MyTunedProducer {
val live: URLayer[Tracing & ZioProducer, ZioProducer] =
ZLayer {
for {
zioProducer <- ZIO.service[ZioProducer]
tracing <- ZIO.environment[Tracing]
headers = new HeaderAddingProducer(producer)
logged = new LoggedProducer(headers)
traced = new MyTracedProducerLive(tracing)(logged)
} yield traced
}
}
final class HeaderAddingProducer(delegator: ZioProducer) extends ZioProducer {
private def addHeaders([re](record: ProducerRecord[K, V])) = record.addHeader("foo", "bar")
override def produce[K, V](record: ProducerRecord[K, V]): UIO[UIO[Unit]] =
delegator
.produce(addHeaders(record))
override def produceAsync[K, V](record: ProducerRecord[K, V]): UIO[UIO[Unit]] =
delegator
.produceAsync(addHeaders(record))
...
}
final class LoggedProducer(delegator: ZioProducer) extends ZioProducer {
override def produce[K, V](record: ProducerRecord[K, V]): UIO[UIO[Unit]] =
delegator
.produce(record)
.map(_ *> ZIO.logDebug("ack received")) *> ZIO.logDebug("record sent"))
// not logged
override def produceAsync[K, V](record: ProducerRecord[K, V]): UIO[UIO[Unit]] =
delegator.produceAsync(record)
...
}
final class MyTracedProducerLive(tracing: ZEnvironment[Tracing])(delegator: ZioProducer) extends ZioProducer {
import zio.telemetry.opentelemetry.TracingSyntax.*
override def produce[K, V](record: ProducerRecord[K, V]): UIO[UIO[Unit]] =
delegator
.produce(record)
.span("Producer::produce")
.provideEnv(produce)
override def produceAsync[K, V](record: ProducerRecord[K, V]): UIO[UIO[Unit]] =
delegator
.produceAsync(record)
.span("Producer::produceAsync")
.provideEnv(produce)
...
} |
Here is how my current code looks like: object MyTunedProducer {
val live: URLayer[Tracing & ZioProducer, ZioProducer] =
ZLayer {
for {
zioProducer <- ZIO.service[ZioProducer]
tracing <- ZIO.environment[Tracing]
headers = new HeaderAddingProducer(producer)
logged = new LoggedProducer(headers)
traced = new MyTracedProducerLive(tracing)(logged)
} yield traced
}
}
trait MyService:
def foo: UIO[Unit]
case class MyServiceLive(producer: Producer) extends MyService:
def foo: UIO[Unit] = producer.produce(...)
object MyServiceLive:
val live: ZLayer[Producer, MyService] = ZLayer.fromFunction(MyServiceLive(_)) Only in object Main extends ZIOAppDefault:
def run = app.provide(
MyTunedProducer.live,
MyServiceLive.live
) |
Besides composition: What is the value of letting users composing with In your example, both |
Why not? All I'm doing is removing the need to reimplement aliases, which (arguably) have no other useful implementations // example of methods where you do nothing around the producing, just call the zioProducer method
override def produceAsync[K, V](record: ProducerRecord[K, V]): UIO[UIO[Unit]] =
zioProducer.produceAsync(record)
... // all the other methods of the interface During the implementation of the traced producer, I had to carefully check and make sure if any of the 11 aliases actually did something other than call If they did, I'd have to instrument them as well. Since they don't, I can now confidently instrument one method and know all other methods are instrumented as well. |
Look how clean the FS2 The interface has 1 |
Your service doesn't know if the passed Producer instance is traced or not: // my/package/MyService.scala
trait MyService {
...
}
object MyService {
val live: URLayer[ZioProducer, MyService] = ...
}
final class MyServiceLive(producer: ZioProducer) extends MyService {
...
}
// my/package/Main.scala
object Main extends zio.App {
val kafkaProducer: ULayer[ZioProducer] =
ZLayer.make[ZioProducer](
Tracing.live,
MyTunedProducer.live
)
def run =
( ... ).provide(kafkaProducer, MyService.live)
} |
Ok, we agree that instrumentation should be transparent 👍 To the matter at hand, and the reason I submitted this PR:
|
@soujiro32167 I agree with you. I find this solution more elegant. |
@guizmaii i cannot merge this MR with your objection |
Even with the default implementations of the aliases in But because of the specifics of our |
Sorry @soujiro32167, the other maintainers convinced me that this is not a good idea. @svroonland explained it well in the previous comment. To support your use case, perhaps it is better to introduce something like the |
Absolutely @svroonland, the idea is to create better defaults. If an implementation needs different behaviors for |
@erikvanoosten could you say more about the
|
Implement 'produce' aliases in the Producer trait, making producer composition easier