rafaparadela / server-client-rpc-workshop

Freestyle-RPC workshop

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

My Smart Home workshop

What are we going to build?

During the course of this workshop, we are going to build a couple of purely functional microservices, which are going to interact with each other in different ways but always via the RPC protocol. One as a server will play the role of a smart home and the other will be a client, a mobile app for instance, and the interactions will be:

  • IsEmpty: Will be a unary RPC, that means that the smart home will return a single response to each request from the mobile, to let it know if there is anybody inside the home or there isn't.

  • getTemperature: Will be a unidirectional streaming service from the server, where the smart home will return a stream of temperature values in real-time after a single request from the mobile.

  • comingBackMode: Will be a unidirectional streaming service from the client, where the mobile app sends a stream of location coordinates and the smart home returns a list of operations that are being triggered. For instance:

    • If the client is about 30 minutes to come back, the home can start heating the living room and increase the power of the hot water heater.
    • If the client is only a 2-minute walk away, the home can turn some lights on and turn the irrigation system off.
    • If the client is in front of the main door, this can be unlocked and the alarms disabled.

Basic Freestyle-RPC Structure

We are going to use the rpc-server-client-pb giter8 template to create the basic project structure, which provides a good basis to build upon. In this case, the template creates a multimodule project, with:

  • The RPC protocol, which is very simple. It exposes a service to lookup a person given a name.
  • The server, which with implements an interpreter of the service defined by the protocol and it runs an RPC server.
  • The client, which consumes the RPC endpoint against the server, and it uses the protocol to know the schema.

To start:

sbt new frees-io/rpc-server-client-pb.g8
...
name [Project Name]: SmartHome
projectDescription [Project Description]: My SmartHome app
project [project-name]: smarthome
package [org.mycompany]: com.fortyseven
freesRPCVersion [0.14.0]:

Template applied in ./smarthome

How to run it

Run the server:

sbt runServer

And the log will show:

INFO  - Starting server at localhost:19683

then, run the client:

sbt runClient

The client should log:

INFO  - Created new RPC client for (localhost,19683)
INFO  - Request: foo
INFO  - Result: GetPersonResponse(Person(foo,10))
INFO  - Removed 1 RPC clients from cache.

And the server:

INFO  - PeopleService - Request: GetPersonRequest(foo)

Project structure

.
├── LICENSE
├── NOTICE.md
├── README.md
├── build.sbt
├── client
│   └── src
│       └── main
│           ├── resources
│           │   └── logback.xml
│           └── scala
│               ├── ClientApp.scala
│               ├── ClientRPC.scala
│               └── PeopleServiceClient.scala
├── project
│   ├── ProjectPlugin.scala
│   ├── build.properties
│   ├── plugins.sbt
│   └── project
├── protocol
│   └── src
│       └── main
│           └── scala
│               └── protocol.scala
├── server
│   └── src
│       └── main
│           ├── resources
│           │   └── logback.xml
│           └── scala
│               ├── PeopleServiceHandler.scala
│               ├── ServerApp.scala
│               └── ServerBoot.scala
└── version.sbt

Protocol

The protocol module includes the definition of the service and the messages that will be used both by the server and the client:

├── protocol
│   └── src
│       └── main
│           └── scala
│               └── protocol.scala

protocol.scala

We have to define the protocol. In this case is just an operation called getPerson that accepts a GetPersonRequest and returns a GetPersonResponse which are the messages that are going to "flow through the wire", and in this case we are choosing ProtoBuffer for serializing:

final case class Person(name: String, age: Int)

@message
final case class GetPersonRequest(name: String)

@message
final case class GetPersonResponse(person: Person)

@service(Protobuf) trait PeopleService[F[_]] {
  def getPerson(request: GetPersonRequest): F[GetPersonResponse]
}

Server

The server tackles mainly a couple of purposes: To run the RPC server and provide an interpreter to the service defined in the protocol.

├── server
│   └── src
│       └── main
│           ├── resources
│           │   └── logback.xml
│           └── scala
│               ├── PeopleServiceHandler.scala
│               ├── ServerApp.scala
│               └── ServerBoot.scala

PoepleServiceHandler.scala

This is the interpretation of the protocol PeopleService. In this case, the getPerson operation returns a trivial Person with the same name passed in the request.

class PeopleServiceHandler[F[_]: Sync](implicit L: Logger[F]) extends PeopleService[F] {

  override def getPerson(request: GetPersonRequest): F[GetPersonResponse] =
    L.info(s"PeopleService - Request: $request").as(GetPersonResponse(Person(request.name, 10)))
}

ServerBoot.scala

This streaming app instantiates the logger and uses them to run the RPC server.

abstract class ServerBoot[F[_]: Effect] extends StreamApp[F] {

  implicit val S: Scheduler = monix.execution.Scheduler.Implicits.global

  override def stream(args: List[String], requestShutdown: F[Unit]): Stream[F, StreamApp.ExitCode] =
    for {
      logger <- Stream.eval(Slf4jLogger.fromName[F]("Server"))
      exitCode <- serverStream(logger)
    } yield exitCode

  def serverStream(implicit L: Logger[F]): Stream[F, StreamApp.ExitCode]
}

ServerApp.scala

The implementation of the serverStream leverages the features of GrpcServer to deal with servers.

class ServerProgram[F[_]: Effect] extends ServerBoot[F] {

  override def serverStream(implicit L: Logger[F]): Stream[F, StreamApp.ExitCode] = {
    implicit val PS: PeopleService[F] = new PeopleServiceHandler[F]
    val port = 19683
    val grpcConfigs: List[GrpcConfig] = List(AddService(PeopleService.bindService[F]))
    Stream.eval(for {
      server <- GrpcServer.default[F](port, grpcConfigs)
      _ <- L.info(s"Starting server at localhost:$port")
      exitCode <- GrpcServer.server(server).as(StreamApp.ExitCode.Success)
    } yield exitCode)

  }
}

object ServerApp extends ServerProgram[IO]

Client

In this initial version of the client, it just runs a client for the PeopleService and it injects it in the streaming flow of the app.

├── client
│   └── src
│       └── main
│           ├── resources
│           │   └── logback.xml
│           └── scala
│               ├── ClientApp.scala
│               ├── ClientRPC.scala
│               └── PeopleServiceClient.scala

PeopleServiceClient.scala

This algebra is the via to connect to the server through the RPC client, using some Freestyle-RPC magic.

trait PeopleServiceClient[F[_]] {
  def getPerson(name: String): F[Person]
}

object PeopleServiceClient {

  def apply[F[_]: Effect](clientF: F[PeopleService.Client[F]])
  (implicit L: Logger[F]): PeopleServiceClient[F] = new PeopleServiceClient[F] {
      def getPerson(name: String): F[Person] = ???
    }

  def createClient[F[_]](hostname: String,
                         port: Int,
                         sslEnabled: Boolean = true,
                         tryToRemoveUnusedEvery: FiniteDuration,
                         removeUnusedAfter: FiniteDuration)(
      implicit F: Effect[F],
      L: Logger[F],
      TM: Timer[F],
      S: Scheduler): fs2.Stream[F, PeopleServiceClient[F]] =  ???

}

ClientRPC.scala

This object provides an RPC client for a given tuple of host and port. It's used in PeopleServiceClient.

ClientApp.scala

Similar to ServerBoot, this app instantiates the logger, the RPC client and it calls to getPerson as soon as it starts running.

object ClientApp {

  implicit val S: Scheduler = monix.execution.Scheduler.Implicits.global

  implicit val TM = Timer.derive(Effect[IO], IO.timer(S))

  def main(args: Array[String]): Unit = {
    (for {
      logger <- Stream.eval(Slf4jLogger.fromName[IO]("Client"))
      client <- {
        implicit val l = logger
        PeopleServiceClient.createClient[IO]("localhost", 19683)
      }
      getPersonResponse <- Stream.eval(client.getPerson("foo")).as(println)
    } yield (getPersonResponse)).compile.toVector.unsafeRunSync()

  }

}

Unary RPC service: IsEmpty

As we said above, we want also to build a unary RPC service to let clients know if there is somebody in the home or there is not. Basically is the same we used to have behind the getPerson operation. So, we only need to replace some pieces.

Protocol

Of course we describe a new protocol for this operation, with new messages:

@message final case class IsEmptyRequest()

@message final case class IsEmptyResponse(result: Boolean)

@service(Protobuf) trait SmartHomeService[F[_]] {
  def isEmpty(request: IsEmptyRequest): F[IsEmptyResponse]
}

Server

Now, we have to implement an interpreter for the new service SmartHomeService:

class SmartHomeServiceHandler[F[_]: Sync](implicit L: Logger[F]) extends SmartHomeService[F] {

  override def isEmpty(request: IsEmptyRequest): F[IsEmptyResponse] =
    L.info(s"SmartHomeService - Request: $request").as(IsEmptyResponse(true))

}

And bind it to the gRPC server:

val grpcConfigs: List[GrpcConfig] = List(AddService(SmartHomeService.bindService[F]))

Client

And the client, of course, needs an algebra to describe the same operation:

trait SmartHomeServiceClient[F[_]] {
  def isEmpty(): F[Boolean]
}

That will be called when the app is running

def main(args: Array[String]): Unit = {
  (for {
    logger <- Stream.eval(Slf4jLogger.fromName[IO]("Client"))
    client <- {
      implicit val l = logger
      SmartHomeServiceClient.createClient[IO]("localhost", 19683)
    }
    isEmptyResponse <- Stream.eval(client.isEmpty()).as(println)
  } yield (isEmptyResponse)).compile.toVector.unsafeRunSync()
}

Result

When we run the client now with sbt runClient we get:

INFO  - Created new RPC client for (localhost,19683)
INFO  - Result: IsEmptyResponse(true)
INFO  - Removed 1 RPC clients from cache.

And the server log the request as expected:

INFO  - SmartHomeService - Request: IsEmptyRequest()

Server-streaming RPC service: GetTemperature

Following the established plan, the next step is building the service that returns a stream of temperature values, to let clients subscribe to collect real-time info.

Protocol

As usual we should add this operation in the protocol

case class Temperature(value: Double, unit: String)

...

@service(Protobuf) trait SmartHomeService[F[_]] {

  def isEmpty(request: IsEmptyRequest): F[IsEmptyResponse]

  def getTemperature(empty: Empty.type): Stream[F, Temperature]

}

Server

If we want to emit a stream of Temperature values, we would be well advised to develop a producer of Temperature in the server side. For instance:

object TemperaturesGenerators {

  val seed = Temperature(25d, "Celsius")

  def get[F[_]: Sync]: Stream[F, Temperature] = {
    Stream.iterateEval(seed) { t =>
      println(s"* New Temperature 👍  --> $t")
      nextTemperature(t)
    }
  }

  def nextTemperature[F[_]](current: Temperature)(implicit F: Sync[F]): F[Temperature] = F.delay {
    Thread.sleep(2000)
    val increment: Double = Random.nextDouble() / 2d
    val signal = if (Random.nextBoolean()) 1 else -1
    val currentValue = current.value
    val nextValue = currentValue + (signal * increment)
    current.copy(value = (math rint nextValue * 100) / 100)
  }
}

And this can be returned as response of the new service, in the interpreter.

override def getTemperature(empty: Empty.type): Stream[F, Temperature] =
  for {
    _ <- Stream.eval(L.info(s"SmartHomeService - getTemperature Request"))
    temperatures <- TemperaturesGenerators.get[F]
  } yield temperatures

Client

We have nothing less than adapt the client to consume the new service when it starting up. To this, a couple of changes are needed:

Firstly we should enrich the algebra

trait SmartHomeServiceClient[F[_]] {

  def isEmpty(): F[Boolean]

  def getTemperature(): Stream[F, Temperature]

}

Whose interpretation could be:

def getTemperature(): Stream[F, Temperature] =
  for {
    client <- Stream.eval(clientF)
    response <- client.getTemperature(Empty)
    _ <- Stream.eval(L.info(s"Result: $response"))
  } yield response

And finally, to call it:

def main(args: Array[String]): Unit = {
  (for {
    logger <- Stream.eval(Slf4jLogger.fromName[IO]("Client"))
    client <- {
      implicit val l = logger
      SmartHomeServiceClient.createClient[IO]("localhost", 19683)
    }
    isEmptyResponse <- Stream.eval(client.isEmpty()).as(println)
    temperatureResponse <- client.getTemperature()
  } yield
    (isEmptyResponse, temperatureResponse)).compile.toVector.unsafeRunSync()

}

Result

When we run the client now with sbt runClient we get:

INFO  - Created new RPC client for (localhost,19683)
INFO  - Result: IsEmptyResponse(true)
INFO  - Result: Temperature(25.0,Celsius)
INFO  - Result: Temperature(25.22,Celsius)
INFO  - Result: Temperature(24.76,Celsius)
INFO  - Result: Temperature(24.99,Celsius)
INFO  - Result: Temperature(25.16,Celsius)
INFO  - Result: Temperature(25.64,Celsius)
INFO  - Result: Temperature(26.11,Celsius)
INFO  - Result: Temperature(25.98,Celsius)
INFO  - Result: Temperature(25.98,Celsius)
INFO  - Result: Temperature(25.7,Celsius)

And the server log the request as expected:

INFO  - Starting server at localhost:19683
INFO  - SmartHomeService - Request: IsEmptyRequest()
INFO  - SmartHomeService - getTemperature Request
* New Temperature 👍  --> Temperature(25.0,Celsius)
* New Temperature 👍  --> Temperature(25.22,Celsius)
* New Temperature 👍  --> Temperature(24.76,Celsius)
* New Temperature 👍  --> Temperature(24.99,Celsius)
* New Temperature 👍  --> Temperature(25.16,Celsius)
* New Temperature 👍  --> Temperature(25.64,Celsius)
* New Temperature 👍  --> Temperature(26.11,Celsius)
* New Temperature 👍  --> Temperature(25.98,Celsius)
* New Temperature 👍  --> Temperature(25.98,Celsius)
* New Temperature 👍  --> Temperature(25.7,Celsius)

Client-streaming RPC service: comingBackMode

To illustrate the client streaming, we are going to build a new service that makes the server react to real-time info provided by the client. In this case, as we said above, the client (the mobile app) will emit a stream of coordinates (latitude and longitude), and the server (the smart home) will trigger some actions according to the distance.

Protocol

So let's add this service to the protocol.

case class Location(lat: Double, long: Double)

...

@message final case class ComingBackModeResponse(result: Boolean)

@service(Protobuf) trait SmartHomeService[F[_]] {
  def isEmpty(request: IsEmptyRequest): F[IsEmptyResponse]
  def getTemperature(empty: Empty.type): Stream[F, Temperature]
  def comingBackMode(request: Stream[F, Location]): F[ComingBackModeResponse]
}

Server

So there is a new function to be implemented in the interpreter:

override def comingBackMode(
    request: Stream[F, Location]): F[ComingBackModeResponse] = {
  (for {
    _ <- Stream.eval(L.info(s"SmartHomeService - comingBackMode Request"))
    _ <- request.attempt.map { l =>
      println(getActions)
    }
  } yield ()).compile.drain.map(_ => ComingBackModeResponse(true))
}

Where getActions is a sample piece that takes some actions randomly:

private def getActions: List[String] = {
  val ops = Seq(
    List("👮 - Enable Security cameras", "💡 - Turn off Lights"),
    List("😎 - Low the blinds", "📺 - Turn on TV"),
    List("🔥 - Increase thermostat power", "💦 -  Disable irrigation system"),
    List("🚪 - Unlock doors", "👩 - Connect Alexa")
  )
  ops(Random.nextInt(ops.length))
}

Client

Again, if the client will emit a stream of locations, we should develop a producer:

object LocationGenerators {

  val seed = Location(47d, 47d)

  def get[F[_]: Sync]: Stream[F, Location] = {
    Stream.iterateEval(seed) { l =>
      println(s"* New Location 👍  --> $l")
      nextLocation(l)
    }
  }

  def nextLocation[F[_]](current: Location)(implicit F: Sync[F]): F[Location] =
    F.delay {
      Thread.sleep(3000)
      Location(closeLocation(current.lat), closeLocation(current.long))
    }

  def closeLocation(d: Double): Double = {
    val increment: Double = Random.nextDouble() / 10d
    val signal = if (Random.nextBoolean()) 1 else -1
    d + (signal * increment)
  }
}

No big deal, so far. But we have to add this operation in the SmartHomeServiceClient:

trait SmartHomeServiceClient[F[_]] {
  def isEmpty(): F[Boolean]
  def getTemperature(): Stream[F, Temperature]
  def comingBackMode(locations: Stream[F, Location]): F[Boolean]
}

Whose interpretation could be:

def comingBackMode(locations: Stream[F, Location]): F[Boolean] =
  for {
    client <- clientF
    response <- client.comingBackMode(locations)
    _ <- L.info(s"Result: $response")
  } yield response.result

Now, we have all the ingredients to proceed in the ClientApp:

def main(args: Array[String]): Unit = {
  (for {
    logger <- Stream.eval(Slf4jLogger.fromName[IO]("Client"))
    client <- {
      implicit val l = logger
      SmartHomeServiceClient.createClient[IO]("localhost", 19683)
    }
    isEmptyResponse <- Stream.eval(client.isEmpty()).as(println)
    //temperatureResponse <- client.getTemperature()
    comingBackModeResponse <- Stream.eval(client.comingBackMode(LocationGenerators.get[IO])).as(println)
  } yield (isEmptyResponse, comingBackModeResponse)).compile.toVector.unsafeRunSync()
}

Result

When we run the client now with sbt runClient we get:

INFO  - Created new RPC client for (localhost,19683)
INFO  - Result: IsEmptyResponse(true)
* New Location 👍  --> Location(47.0,47.0)
* New Location 👍  --> Location(47.06799068842323,46.98929181922271)
* New Location 👍  --> Location(47.14182165355257,47.01686282210311)
* New Location 👍  --> Location(47.227313869966196,47.044541473406426)
* New Location 👍  --> Location(47.221235857794206,47.08981180627368)

And the server log the request as expected:

INFO  - Starting server at localhost:19683
INFO  - SmartHomeService - Request: IsEmptyRequest()
INFO  - SmartHomeService - comingBackMode Request
List(🔥 - Increase thermostat power, 💦 -  Disable irrigation system)
List(🚪 - Unlock doors, 👩 - Connect Alexa)
List(👮 - Enable Security cameras, 💡 - Turn off Lights)
List(😎 - Low the blinds, 📺 - Turn on TV)

Copyright

Freestyle-RPC is designed and developed by 47 Degrees

Copyright (C) 2017 47 Degrees. http://47deg.com

About

Freestyle-RPC workshop

License:Apache License 2.0


Languages

Language:Scala 100.0%