- What are we going to build?
- Basic Freestyle-RPC Structure
- Unary RPC service:
IsEmpty
- Server-streaming RPC service:
GetTemperature
- Client-streaming RPC service:
comingBackMode
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.
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
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)
.
├── 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
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]
}
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]
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()
}
}
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.
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]
}
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]))
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()
}
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()
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.
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]
}
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
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()
}
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)
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.
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]
}
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))
}
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()
}
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)
Freestyle-RPC is designed and developed by 47 Degrees
Copyright (C) 2017 47 Degrees. http://47deg.com