An Introduction to Shardcake
I recently found myself developing backend microservices with Scala and ZIO. I'm using ZIO-gRPC to implement the microservices and I'm using Neo4j as the database. My codebase architecture is a monolithic project with subprojects for each microservice and core subproject that each microservice inherits.
What I wanted was location transparency for each of these microservices and I didn't want to use Akka Cluster Sharding or ideally, have to use any Java libraries. My perfect solution was a ZIO native solution that could integrate with my existing ZIO-gRPC services and support Scala 3. This is Shardcake.
Shardcake enables you to build a distributed system with location transparency, using an ID to identify microservices. You can also spin up several instances of the same service for horizontal scaling, while ensuring the same behavior.
What we're going to do in this tutorial is build out the infrastructure for a todo app. We want to be able to manage adding things to do, deleting things to do, editing things to do, and finally, we want to be able to say we've done something, so we can focus only on what needs to be done.
I recommend you read the Shardcake documentation a bit to familiarize yourself with the kind of architecture we're implementing. Note this tutorial also uses ZIO-Optics.
Shard Manager:
The first things we need to do is implement our shard manager. A shard manager's job is to map shards to pods.
The simplest way to implement the shard manager is it's default instances, created like this:
import com.devsisters.shardcake.*
import com.devsisters.shardcake.interfaces.*
import zio.*
object ShardManagerApp extends ZIOAppDefault:
def run: Task[Nothing] =
Server.run.provide(
ZLayer.succeed(ManagerConfig.default),
ZLayer.succeed(GrpcConfig.default),
PodsHealth.local, // just ping a pod to see if it's alive
GrpcPods.live, // use gRPC protocol
Storage.memory, // store data in memory
ShardManager.live // shard manager logic
)
end ShardManagerApp
here, we're using the default in every aspect of our shard manager. We're saying we want a shard manager that stores everything in memory, uses gRPC (ZIO-gRPC under the hood), and implements the ping protocol to check if the shardmanager is still running.
Entities:
Next we want to implement our entities. Entities live on our application servers, receiving messages and processing them. All Shardcake does it start and entities which only process messages. Persistence and message processing logic is entirely up to the developer.
Let's try implementing our todolist entity. We can create an entity that accepts all necessary actions for our todolist, but we'll create a new instance for every user of our application. This is how shardcake scales. Entity instances are spread across a distributed network of computers.
First we want to define our messages, and we'll do so with an enum:
/** Enumeration of all modifications to a todo list in message form.
*/
enum TodoListMessage:
/** Adds a new item to the todo list.
*
* @param description
* Description of the todo list item.
* @param replier
* Replies with the id of the created item.
*/
case AddItem(description: String, replier: Replier[Int])
extends TodoListMessage
/** Removes an item from the todo list.
*
* @param id
* Unique id for the todo list item to be removed.
* @param replier
* Replies with if the item was removed.
*/
case RemoveItem(id: Int, replier: Replier[Boolean]) extends TodoListMessage
/** Changes the status of a todo list item to completed.
*
* @param id
* The id of the item to be modified.
* @param replier
* Replies with if the item was modified.
*/
case Completed(id: Int, replier: Replier[Boolean]) extends TodoListMessage
/** Changes the status of a todo list item to incomplete.
*
* @param id
* The id of the item to be modified.
* @param replier
* Replies with if the item was modified.
*/
case Incompleted(id: Int, replier: Replier[Boolean]) extends TodoListMessage
/** Changes the description of a todo list item.
*
* @param id
* The id of the item to be modified.
* @param description
* The new description for the item.
* @param replier
* Replies with if the item was modified.
*/
case EditDescription(id: Int, description: String, replier: Replier[Boolean])
extends TodoListMessage
end TodoListMessage
Here we have cases to perform all actions to modify our list and individual list items. Next we create the entity as an object, and we tell it to only accept our special message type.
领英推荐
object TodoListEntity extends EntityType[TodoListMessage]("todo_list"):
// Our entity definition goes here
end TodoListEntity
We put in our imports:
/** Todo list entity.
*/
object TodoListEntity extends EntityType[TodoListMessage]("todo_list"):
// ZIO Imports:
import zio.optics.*
import zio.*
end TodoListEntity
Then we define our TodoListItem case class:
/** Todo list entity.
*/
object TodoListEntity extends EntityType[TodoListMessage]("todo_list"):
// ZIO Imports:
import zio.optics.*
import zio.*
/** Class representation of a todo list item.
*
* @param description
* Description of the todo list item.
* @param completed
* Whether the todo list item has been completed.
*/
final case class TodoListItem(description: String, completed: Boolean)
end TodoListEntity
Next, we define optics on our TodoListItem so we can easily modify it.
/** Optics for the TodoListItem class.
*/
object TodoListItem:
/** Lens optic to modify the description of a todo list item.
*/
lazy val description: Lens[TodoListItem, String] = Lens(
item => Right(item.description),
newDescription => item => Right(item.copy(description = newDescription))
)
/** Lens optic to modify the completion state of a todo list item.
*/
lazy val completed: Lens[TodoListItem, Boolean] = Lens(
item => Right(item.completed),
newCompleted => item => Right(item.copy(completed = newCompleted))
)
end TodoListItem
Building Message Logic:
Alright, here's where the boilerplate ends. We want to define this function:
/** Defines the behavior of the todo_list entity.
*
* @param entityId
* Entity id.
*
* @param messages
* Queue of messages to the entity.
*/
def behavior(
entityId: String,
messages: Dequeue[TodoListMessage]
): RIO[Sharding, Nothing] =
Ref
.make(Chunk.empty[TodoListItem])
.flatMap(state =>
messages.take.flatMap {
case message: TodoListMessage.AddItem =>
handleAddItem(state, message)
case message: TodoListMessage.RemoveItem =>
handleRemoveItem(state, message)
case message: TodoListMessage.Completed =>
handleCompleted(state, message)
case message: TodoListMessage.Incompleted =>
handleIncompleted(state, message)
case message: TodoListMessage.EditDescription =>
handleEditDescription(state, message)
}.forever
)
This function does all the processing of every kind of message. The parameter is queue of messages and the function processes them forever. We need to define each function to specifically process each type of message. Here's an example without optic usage:
/** Entity handler to add an item to the todo list.
*
* @param state
* State of the current todo list.
* @param message
* Todo list AddItem message.
*/
def handleAddItem(
state: Ref[Chunk[TodoListItem]],
message: TodoListMessage.AddItem
): RIO[Sharding, Unit] =
state
.updateAndGet(_ :+ TodoListItem(message.description, false))
.flatMap(items => message.replier.reply(items.length))
Our function updates the state by adding a new todo list item and replying with the id of the item, in our case, its index in the list of items. What about an example with optics? How can we write simple and concise code to change the completion status of a list item. Check this out:
/** Entity handler to complete an item in the todo list.
*
* @param state
* State of the current todo list.
* @param message
* Todo list Completed message.
*/
def handleCompleted(
state: Ref[Chunk[TodoListItem]],
message: TodoListMessage.Completed
): RIO[Sharding, Unit] =
state
.updateAndGet(items =>
(Optional.at(message.id)(items) >>> TodoListItem.completed)
.set(true) getOrElse Chunk.empty
)
.flatMap(items => message.replier.reply(items.length > 0))
Here, the optics in question, find the item in the list of items then zooms in the completion state of that item. It then sets it to true and replies with a boolean telling us if the id provided existed in the list of items. Here's the full definition of our entity:
// SHARDCAKE Imports:
import com.devsisters.shardcake.*
/** Enumeration of all modifications to a todo list in message form.
*/
enum TodoListMessage:
/** Adds a new item to the todo list.
*
* @param description
* Description of the todo list item.
* @param replier
* Replies with the id of the created item.
*/
case AddItem(description: String, replier: Replier[Int])
extends TodoListMessage
/** Removes an item from the todo list.
*
* @param id
* Unique id for the todo list item to be removed.
* @param replier
* Replies with if the item was removed.
*/
case RemoveItem(id: Int, replier: Replier[Boolean]) extends TodoListMessage
/** Changes the status of a todo list item to completed.
*
* @param id
* The id of the item to be modified.
* @param replier
* Replies with if the item was modified.
*/
case Completed(id: Int, replier: Replier[Boolean]) extends TodoListMessage
/** Changes the status of a todo list item to incomplete.
*
* @param id
* The id of the item to be modified.
* @param replier
* Replies with if the item was modified.
*/
case Incompleted(id: Int, replier: Replier[Boolean]) extends TodoListMessage
/** Changes the description of a todo list item.
*
* @param id
* The id of the item to be modified.
* @param description
* The new description for the item.
* @param replier
* Replies with if the item was modified.
*/
case EditDescription(id: Int, description: String, replier: Replier[Boolean])
extends TodoListMessage
end TodoListMessage
/** Todo list entity.
*/
object TodoListEntity extends EntityType[TodoListMessage]("todo_list"):
// ZIO Imports:
import zio.optics.*
import zio.*
/** Class representation of a todo list item.
*
* @param description
* Description of the todo list item.
* @param completed
* Whether the todo list item has been completed.
*/
final case class TodoListItem(description: String, completed: Boolean)
/** Optics for the TodoListItem class.
*/
object TodoListItem:
/** Lens optic to modify the description of a todo list item.
*/
lazy val description: Lens[TodoListItem, String] = Lens(
item => Right(item.description),
newDescription => item => Right(item.copy(description = newDescription))
)
/** Lens optic to modify the completion state of a todo list item.
*/
lazy val completed: Lens[TodoListItem, Boolean] = Lens(
item => Right(item.completed),
newCompleted => item => Right(item.copy(completed = newCompleted))
)
end TodoListItem
/** Entity handler to add an item to the todo list.
*
* @param state
* State of the current todo list.
* @param message
* Todo list AddItem message.
*/
def handleAddItem(
state: Ref[Chunk[TodoListItem]],
message: TodoListMessage.AddItem
): RIO[Sharding, Unit] =
state
.updateAndGet(_ :+ TodoListItem(message.description, false))
.flatMap(items => message.replier.reply(items.length))
/** Entity handler to remove an item to the todo list.
*
* @param state
* State of the current todo list.
* @param message
* Todo list RemoveItem message.
*/
def handleRemoveItem(
state: Ref[Chunk[TodoListItem]],
message: TodoListMessage.RemoveItem
): RIO[Sharding, Unit] =
state
.updateAndGet(_.patch(message.id, Nil, 1))
.flatMap(items => message.replier.reply(items.length + 1 >= message.id))
/** Entity handler to complete an item in the todo list.
*
* @param state
* State of the current todo list.
* @param message
* Todo list Completed message.
*/
def handleCompleted(
state: Ref[Chunk[TodoListItem]],
message: TodoListMessage.Completed
): RIO[Sharding, Unit] =
state
.updateAndGet(items =>
(Optional.at(message.id)(items) >>> TodoListItem.completed)
.set(true) getOrElse Chunk.empty
)
.flatMap(items => message.replier.reply(items.length > 0))
/** Entity handler to incomplete an item in the todo list.
*
* @param state
* State of the current todo list.
* @param message
* Todo list Incompleted message.
*/
def handleIncompleted(
state: Ref[Chunk[TodoListItem]],
message: TodoListMessage.Incompleted
): RIO[Sharding, Unit] =
state
.updateAndGet(items =>
(Optional.at(message.id)(items) >>> TodoListItem.completed)
.set(false) getOrElse Chunk.empty
)
.flatMap(items => message.replier.reply(items.length > 0))
/** Entity handler to change the description of an item in the todo list.
*
* @param state
* State of the current todo list.
* @param message
* Todo list EditDescription message.
*/
def handleEditDescription(
state: Ref[Chunk[TodoListItem]],
message: TodoListMessage.EditDescription
): RIO[Sharding, Unit] =
state
.updateAndGet(items =>
(Optional.at(message.id)(items) >>> TodoListItem.description)
.set(message.description) getOrElse Chunk.empty
)
.flatMap(items => message.replier.reply(items.length > 0))
/** Defines the behavior of the todo_list entity.
*
* @param entityId
* Entity id.
*
* @param messages
* Queue of messages to the entity.
*/
def behavior(
entityId: String,
messages: Dequeue[TodoListMessage]
): RIO[Sharding, Nothing] =
Ref
.make(Chunk.empty[TodoListItem])
.flatMap(state =>
messages.take.flatMap {
case message: TodoListMessage.AddItem =>
handleAddItem(state, message)
case message: TodoListMessage.RemoveItem =>
handleRemoveItem(state, message)
case message: TodoListMessage.Completed =>
handleCompleted(state, message)
case message: TodoListMessage.Incompleted =>
handleIncompleted(state, message)
case message: TodoListMessage.EditDescription =>
handleEditDescription(state, message)
}.forever
)
end TodoListEntity
This is it, ig. You should be able to figure it out from here.