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.

要查看或添加评论,请登录

Bradly Ovitt的更多文章

  • Concerns of Domain Driven Design

    Concerns of Domain Driven Design

    Let's talk about what problems domain driven design solves. Domain driven design is a methodology to model several…

  • The Actor Model & Reactive Systems

    The Actor Model & Reactive Systems

    Let's talk about the actor model and building reactive systems. The actor model is a programming paradigm for building…

    1 条评论
  • Concerns of Reactive Systems

    Concerns of Reactive Systems

    Let's talk about why we build reactive systems, as software engineers/developers. As professionals, we have to balance…

    1 条评论
  • Software Transactional Memory

    Software Transactional Memory

    Software transactional memory (STM) is a programming paradigm that allows multiple threads to share access to shared…

  • Understanding the Elm Architecture

    Understanding the Elm Architecture

    Elm is a functional programming language that is well-known for its robust architecture and its ability to build highly…

  • Binding Propagation in Prolog

    Binding Propagation in Prolog

    Prolog is a programming language that is well known for its ability to perform logical reasoning and search through…

  • Effect Rotation in ZIO

    Effect Rotation in ZIO

    ZIO is a popular functional programming library in Scala that has gained a lot of traction in recent years. One of the…

  • What Scala Can't Do

    What Scala Can't Do

    Today, Scala is a programming language that can use any Javascript library, any Java library, and any C/C++ library…

  • Transparency in Distributed Systems

    Transparency in Distributed Systems

    One of the most important theoretical goals of distributed systems is hiding the fact that resources and processes are…

  • The Secret to a Perfect Steak: The Nature of Abstraction

    The Secret to a Perfect Steak: The Nature of Abstraction

    I promise this is about more than cooking. I've been known to save MOST of my money and usually spend on a very tight…

    1 条评论

社区洞察

其他会员也浏览了