mirror of
https://codeberg.org/JasterV/jaster.xyz.git
synced 2026-04-26 18:10:01 +00:00
Merge pull request #4 from JasterV/about-interfaces
[Post] Decoupling Elixir GenServers with Phoenix PubSub
This commit is contained in:
commit
45af154c14
16 changed files with 597 additions and 48 deletions
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
|
|
@ -13,6 +13,11 @@ jobs:
|
||||||
- name: 🚚 Get latest code
|
- name: 🚚 Get latest code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# https://d2lang.com/tour/install/
|
||||||
|
- name: Install D2
|
||||||
|
run: |
|
||||||
|
curl -fsSL https://d2lang.com/install.sh | sh -s --
|
||||||
|
|
||||||
- name: Install bun
|
- name: Install bun
|
||||||
uses: oven-sh/setup-bun@v1
|
uses: oven-sh/setup-bun@v1
|
||||||
with:
|
with:
|
||||||
|
|
|
||||||
5
.github/workflows/deploy.yml
vendored
5
.github/workflows/deploy.yml
vendored
|
|
@ -21,6 +21,11 @@ jobs:
|
||||||
- name: 🚚 Get latest code
|
- name: 🚚 Get latest code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# https://d2lang.com/tour/install/
|
||||||
|
- name: Install D2
|
||||||
|
run: |
|
||||||
|
curl -fsSL https://d2lang.com/install.sh | sh -s --
|
||||||
|
|
||||||
- name: Install bun
|
- name: Install bun
|
||||||
uses: oven-sh/setup-bun@v1
|
uses: oven-sh/setup-bun@v1
|
||||||
with:
|
with:
|
||||||
|
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -19,3 +19,6 @@ pnpm-debug.log*
|
||||||
.env
|
.env
|
||||||
.env.production
|
.env.production
|
||||||
|
|
||||||
|
# Ignore automatic generated diagrams from D2
|
||||||
|
public/assets/d2/
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,38 @@
|
||||||
import { defineConfig } from "astro/config";
|
import { defineConfig } from "astro/config";
|
||||||
import icon from "astro-icon";
|
import icon from "astro-icon";
|
||||||
|
|
||||||
import sitemap from "@astrojs/sitemap";
|
import sitemap from "@astrojs/sitemap";
|
||||||
|
import d2 from "astro-d2";
|
||||||
|
|
||||||
// https://astro.build/config
|
// https://astro.build/config
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
site: "https://jaster.xyz",
|
site: "https://jaster.xyz",
|
||||||
|
markdown: {
|
||||||
|
shikiConfig: {
|
||||||
|
theme: "dracula",
|
||||||
|
wrap: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
integrations: [
|
integrations: [
|
||||||
icon({
|
icon({
|
||||||
include: {
|
include: {
|
||||||
// Include only three `mdi` icons in the bundle
|
// Include only three `mdi` icons in the bundle
|
||||||
// Otherwise, Astro Icons could include every single icon in the mdi package and result in a huge bundle size
|
// Otherwise, Astro Icons could include every single icon in the mdi package and result in a huge bundle size
|
||||||
mdi: ["github", "linkedin"],
|
mdi: ["github", "linkedin", "alternate-email"],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
sitemap(),
|
sitemap(),
|
||||||
|
// Refer to: https://astro-d2.vercel.app/configuration/
|
||||||
|
d2({
|
||||||
|
// Outputs to `public/assets/d2`
|
||||||
|
output: "assets/d2",
|
||||||
|
sketch: true,
|
||||||
|
pad: 50,
|
||||||
|
layout: "dagre",
|
||||||
|
// https://d2lang.com/tour/themes/
|
||||||
|
theme: {
|
||||||
|
default: "3",
|
||||||
|
dark: "200",
|
||||||
|
},
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
|
||||||
BIN
bun.lockb
BIN
bun.lockb
Binary file not shown.
6
codebook.toml
Normal file
6
codebook.toml
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
words = [
|
||||||
|
"astro",
|
||||||
|
"craftzdog",
|
||||||
|
"martínez",
|
||||||
|
"neovim",
|
||||||
|
]
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
"@astrojs/check": "^0.9.4",
|
"@astrojs/check": "^0.9.4",
|
||||||
"@astrojs/sitemap": "^3.4.0",
|
"@astrojs/sitemap": "^3.4.0",
|
||||||
"astro": "^5.8.1",
|
"astro": "^5.8.1",
|
||||||
|
"astro-d2": "^0.8.0",
|
||||||
"astro-icon": "^1.1.5",
|
"astro-icon": "^1.1.5",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@ import profileImage from "@assets/profile.jpeg";
|
||||||
<a href="https://linkedin.com/in/jaster-victor/">
|
<a href="https://linkedin.com/in/jaster-victor/">
|
||||||
<Icon size="1.5rem" name="mdi:linkedin" />
|
<Icon size="1.5rem" name="mdi:linkedin" />
|
||||||
</a>
|
</a>
|
||||||
|
<a href="mailto:jaster.victor@gmail.com">
|
||||||
|
<Icon size="1.5rem" name="mdi:alternate-email" />
|
||||||
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="profile">
|
<div class="profile">
|
||||||
|
|
@ -70,7 +73,7 @@ import profileImage from "@assets/profile.jpeg";
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-image-wrapper:hover {
|
.profile-image-wrapper:hover {
|
||||||
border-color: var(--violet300);
|
border-color: var(--link-color);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ const blog = defineCollection({
|
||||||
schema: ({ image }) =>
|
schema: ({ image }) =>
|
||||||
z.object({
|
z.object({
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
|
description: z.string().optional(),
|
||||||
pubDate: z.coerce.date(),
|
pubDate: z.coerce.date(),
|
||||||
image: image(),
|
image: image(),
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
466
src/content/blog/DecouplingElixirGenServers.md
Normal file
466
src/content/blog/DecouplingElixirGenServers.md
Normal file
|
|
@ -0,0 +1,466 @@
|
||||||
|
---
|
||||||
|
title: "Decoupling Elixir GenServers with Phoenix PubSub"
|
||||||
|
description: "A way to make our GenServers more easy to test and maintain by using PubSub"
|
||||||
|
pubDate: 2025-06-05
|
||||||
|
image: "./assets/elixir.svg"
|
||||||
|
---
|
||||||
|
|
||||||
|
In this post I'd like to talk about an interesting way to decouple GenServers from the rest of the system by using a PubSub library.
|
||||||
|
|
||||||
|
We will do it by going through actual code. We will take a look at a regular Elixir application and we will discuss possible problems in it.
|
||||||
|
|
||||||
|
We will then use PubSub to refactor the application and we'll discuss the final result, its benefits but also possible downsides.
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
|
||||||
|
The project we will look at is very simple.
|
||||||
|
|
||||||
|
It consists of an IoT application that automates locking and unlocking a door.
|
||||||
|
|
||||||
|
We can imagine a user interacting with our application using some sort of remote control.
|
||||||
|
|
||||||
|
Then, when the door gets locked, we want a light to become red and a notification to be sent to us.
|
||||||
|
Finally, when the door gets unlocked, we want a light to become green and get notified too.
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
The application consists of:
|
||||||
|
|
||||||
|
- A GenServer that manages the state of the Door and is responsible for locking or unlocking it.
|
||||||
|
- A module that manages the state of the lights and is responsible for changing colors.
|
||||||
|
- A module that manages notifications.
|
||||||
|
|
||||||
|
Let's look at a diagram of the whole system first:
|
||||||
|
|
||||||
|
<div class="diagram" align="center">
|
||||||
|
|
||||||
|
```d2 width="500" theme=303 title="Lock door flow"
|
||||||
|
direction: down
|
||||||
|
|
||||||
|
user {
|
||||||
|
shape: c4-person
|
||||||
|
}
|
||||||
|
|
||||||
|
firmware {
|
||||||
|
door_server: DoorServer
|
||||||
|
lights: Lights
|
||||||
|
notifications: Notifications
|
||||||
|
|
||||||
|
label.near: bottom-left
|
||||||
|
}
|
||||||
|
|
||||||
|
third_party_notifications_service: 3rd party notifications service {
|
||||||
|
shape: cloud
|
||||||
|
}
|
||||||
|
|
||||||
|
hardware: {
|
||||||
|
shape: rectangle
|
||||||
|
}
|
||||||
|
|
||||||
|
user -> firmware.door_server: lock door {style.animated: true}
|
||||||
|
firmware.door_server -> firmware.lights: set_lights_red {style.animated: true}
|
||||||
|
firmware.door_server -> firmware.notifications: send_notification {style.animated: true}
|
||||||
|
firmware.door_server -> hardware: lock {style.animated: true}
|
||||||
|
firmware.lights -> hardware: set_lights_red {style.animated: true}
|
||||||
|
firmware.notifications -> third_party_notifications_service: send_notification {style.animated: true}
|
||||||
|
```
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
In this post we will focus on the firmware layer, more specifically in the Door server, its implementation and tests.
|
||||||
|
|
||||||
|
## Let's get into code
|
||||||
|
|
||||||
|
Let's go directly to the point, this is how we could implement a basic version of a DoorServer
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
defmodule DoorAutomation.DoorServer do
|
||||||
|
use GenServer
|
||||||
|
|
||||||
|
@locked_state :locked
|
||||||
|
@unlocked_state :unlocked
|
||||||
|
|
||||||
|
alias DoorAutomation.Lights
|
||||||
|
alias DoorAutomation.Notifications
|
||||||
|
|
||||||
|
# Client API
|
||||||
|
|
||||||
|
def start_link(initial_state \\ :locked) do
|
||||||
|
GenServer.start_link(__MODULE__, initial_state, name: __MODULE__)
|
||||||
|
end
|
||||||
|
|
||||||
|
def lock do
|
||||||
|
GenServer.call(__MODULE__, :lock)
|
||||||
|
end
|
||||||
|
|
||||||
|
def unlock do
|
||||||
|
GenServer.call(__MODULE__, :unlock)
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_state do
|
||||||
|
GenServer.call(__MODULE__, :get_state)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Server API
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def init(initial_state), do: {:ok, initial_state}
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_call(:get_state, _from, current_state) do
|
||||||
|
{:reply, current_state, current_state}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_call(:lock, _from, _current_state) do
|
||||||
|
# Call the underlying hardware
|
||||||
|
# Hardware.unlock_door()
|
||||||
|
Lights.set_red()
|
||||||
|
Notifications.send_notification("Door has been Locked.")
|
||||||
|
{:reply, :locked, @locked_state}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_call(:unlock, _from, _current_state) do
|
||||||
|
# Call the underlying hardware
|
||||||
|
# Hardware.unlock_door()
|
||||||
|
Lights.set_green()
|
||||||
|
Notifications.send_notification("Door has been Unlocked.")
|
||||||
|
{:reply, :unlocked, @unlocked_state}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
For the purpose of this article, the underlying calls that the Door Server would need to make to the Hardware are left out of the code.
|
||||||
|
|
||||||
|
### Let's write some tests
|
||||||
|
|
||||||
|
Let's now see a basic test suite for the DoorServer:
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
defmodule DoorAutomation.DoorServerTest do
|
||||||
|
use ExUnit.Case
|
||||||
|
|
||||||
|
alias DoorAutomation.DoorServer
|
||||||
|
alias DoorAutomation.Lights
|
||||||
|
alias DoorAutomation.Notifications
|
||||||
|
|
||||||
|
setup do
|
||||||
|
# Here we'd add whatever setup is needed
|
||||||
|
# for the lights and notifications module
|
||||||
|
{:ok, _} = start_supervised({DoorServer, :unlocked})
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
test "locking the door changes state, light, and sends notification" do
|
||||||
|
assert DoorServer.get_state() == :unlocked
|
||||||
|
assert :ok = DoorServer.lock()
|
||||||
|
assert DoorServer.get_state() == :locked
|
||||||
|
assert Lights.get_color() == "red"
|
||||||
|
assert Notifications.get_notifications() == ["Door has been Locked."]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "unlocking the door changes state, light, and sends notification" do
|
||||||
|
assert :ok = DoorServer.lock()
|
||||||
|
assert DoorServer.get_state() == :locked
|
||||||
|
|
||||||
|
Notifications.reset_notifications()
|
||||||
|
|
||||||
|
assert :ok = DoorServer.unlock()
|
||||||
|
assert DoorServer.get_state() == :unlocked
|
||||||
|
assert Lights.get_color() == "green"
|
||||||
|
assert Notifications.get_notifications() == ["Door has been Unlocked."]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## What is wrong here?
|
||||||
|
|
||||||
|
Well, first of all I'd like to make it clear that there is actually nothing "wrong". The code we see above is completely valid.
|
||||||
|
|
||||||
|
It compiles and it could be enough for someone's needs.
|
||||||
|
|
||||||
|
With that said, let's talk about some possible issues we could run into.
|
||||||
|
|
||||||
|
### Coupling!
|
||||||
|
|
||||||
|
Following the previous design, we are coupling the DoorServer implementation with the lights and notifications modules.
|
||||||
|
|
||||||
|
The lights module might rely hardware which could fail at any time (perhaps the circuits break) and the notifications module might rely on a network to send messages.
|
||||||
|
|
||||||
|
If the lights are faulty or the network goes down, the door could potentially stop working and the user wouldn't be able to lock or unlock it anymore.
|
||||||
|
|
||||||
|
In this case, if a user locks the door but the lights don't work or the notifications service is off, we want the door to lock anyway.
|
||||||
|
|
||||||
|
Ideally, any issues that might occur related to the lights or notifications system should be handled in their own modules.
|
||||||
|
|
||||||
|
Our door server should simply worry about locking or unlocking a door.
|
||||||
|
|
||||||
|
Coupling these modules spreads complexity that should be isolated.
|
||||||
|
|
||||||
|
### Coupling in tests
|
||||||
|
|
||||||
|
Coupling doesn't stop in the DoorServer module, it gets propagated into our tests.
|
||||||
|
|
||||||
|
Because our `lock` and `unlock` functions cause a series of side effects, we find ourselves having to add extra setup to test them.
|
||||||
|
|
||||||
|
Now, what was supposed to be a unit test suite for our DoorServer ends up looking more like an integration test suite.
|
||||||
|
|
||||||
|
Each time we add a new side effect that doesn't necessarily have to do with locking or unlocking a door, these tests have to be updated.
|
||||||
|
|
||||||
|
## So, what can we do?
|
||||||
|
|
||||||
|
We want to find a way to decouple our door server from the rest of the system and protect it from unrelated issues.
|
||||||
|
|
||||||
|
I'm sure there are many ways you could do this, but in this article I want to talk about a specific one I recently deployed to production with success.
|
||||||
|
|
||||||
|
Let's make our Elixir application more event-driven!
|
||||||
|
|
||||||
|
We can make the DoorServer publish an event whenever the doors are successfully locked or unlocked.
|
||||||
|
|
||||||
|
Imagine if we had some sort of dedicated mailbox for our door events that any processes can subscribe to.
|
||||||
|
|
||||||
|
Then, if a module that is interested on those events can simply subscribe to them and implement their own handlers.
|
||||||
|
|
||||||
|
This automatically makes our application more flexible, testable and maintainable.
|
||||||
|
|
||||||
|
## Introducing Phoenix PubSub
|
||||||
|
|
||||||
|
[Phoenix PubSub](https://github.com/phoenixframework/phoenix_pubsub) is a known Elixir library that comes with the Phoenix framework.
|
||||||
|
|
||||||
|
It is a generic PubSub library which enables us to implement a publish-subscribe pattern in our Elixir applications.
|
||||||
|
|
||||||
|
It supports distribution by default (publishing and subscribing between erlang nodes) and multiple backends including Redis.
|
||||||
|
|
||||||
|
Even-though it explicitly says it is made for the Phoenix framework, it doesn't have any phoenix-related dependencies and can be used on its own.
|
||||||
|
|
||||||
|
Also we know Phoenix has been battle tested, so we can be sure Phoenix PubSub too!
|
||||||
|
|
||||||
|
## Rewriting the DoorServer
|
||||||
|
|
||||||
|
As mentioned, we'll rewrite the DoorServer by making it publish events and remove any coupling with the other modules:
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
defmodule DoorAutomation.DoorServer do
|
||||||
|
use GenServer
|
||||||
|
alias Phoenix.PubSub
|
||||||
|
|
||||||
|
@locked_state :locked
|
||||||
|
@unlocked_state :unlocked
|
||||||
|
|
||||||
|
@door_topic "door_events"
|
||||||
|
|
||||||
|
# Client API
|
||||||
|
|
||||||
|
def start_link(initial_state \\ :locked) do
|
||||||
|
GenServer.start_link(__MODULE__, initial_state, name: __MODULE__)
|
||||||
|
end
|
||||||
|
|
||||||
|
def lock do
|
||||||
|
GenServer.call(__MODULE__, :lock)
|
||||||
|
end
|
||||||
|
|
||||||
|
def unlock do
|
||||||
|
GenServer.call(__MODULE__, :unlock)
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_state do
|
||||||
|
GenServer.call(__MODULE__, :get_state)
|
||||||
|
end
|
||||||
|
|
||||||
|
def events_topic(), do: @door_topic
|
||||||
|
|
||||||
|
# Server API
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def init(initial_state) do
|
||||||
|
{:ok, initial_state}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_call(:get_state, _from, current_state) do
|
||||||
|
{:reply, current_state, current_state}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_call(:lock, _from, _current_state) do
|
||||||
|
# Hardware.lock_door()
|
||||||
|
|
||||||
|
Phoenix.PubSub.broadcast(DoorAutomation.PubSub, @door_topic, :door_locked)
|
||||||
|
|
||||||
|
{:reply, :locked, @locked_state}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_call(:unlock, _from, _current_state) do
|
||||||
|
# Hardware.unlock_door()
|
||||||
|
|
||||||
|
Phoenix.PubSub.broadcast(DoorAutomation.PubSub, @door_topic, :door_unlocked)
|
||||||
|
|
||||||
|
{:reply, :unlocked, @unlocked_state}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
I'd like to also show how to write a subscriber for the lights module to react to pub sub events.
|
||||||
|
|
||||||
|
Also, to be consistent with our new design, we'll make it publish events when the lights are updated.
|
||||||
|
|
||||||
|
This way testing that the lights were updated after firing a door event becomes simpler as we'll see later.
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
defmodule DoorAutomation.LightsSubscriber do
|
||||||
|
use GenServer
|
||||||
|
alias Phoenix.PubSub
|
||||||
|
|
||||||
|
@lights_topic "lights_events"
|
||||||
|
|
||||||
|
# Client API
|
||||||
|
|
||||||
|
def start_link do
|
||||||
|
GenServer.start_link(__MODULE__, [], name: __MODULE__)
|
||||||
|
end
|
||||||
|
|
||||||
|
def events_topic(), do: @lights_topic
|
||||||
|
|
||||||
|
# Server API
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def init(_args) do
|
||||||
|
# Subscribe to the topic when the GenServer starts
|
||||||
|
:ok = Phoenix.PubSub.subscribe(DoorAutomation.PubSub, DoorAutomation.DoorServer.events_topic())
|
||||||
|
{:ok, %{}}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_info(:door_locked, state) do
|
||||||
|
:ok = DoorAutomation.Lights.set_red()
|
||||||
|
Phoenix.PubSub.broadcast(DoorAutomation.PubSub, @lights_topic, :lights_set_red)
|
||||||
|
{:noreply, state}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_info(:door_unlocked, state) do
|
||||||
|
:ok = DoorAutomation.Lights.set_green()
|
||||||
|
Phoenix.PubSub.broadcast(DoorAutomation.PubSub, @lights_topic, :lights_set_green)
|
||||||
|
{:noreply, state}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
As a note, some people might prefer publishing the light events from inside the Lights module,
|
||||||
|
I don't have a strong opinion here so for simplicity I'll leave that logic in the LightsSubscriber.
|
||||||
|
|
||||||
|
## Rewriting our tests
|
||||||
|
|
||||||
|
Now we can rewrite our tests in a more event-driven way:
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
defmodule DoorAutomation.DoorServerTest do
|
||||||
|
use ExUnit.Case
|
||||||
|
|
||||||
|
alias DoorAutomation.DoorServer
|
||||||
|
alias Phoenix.PubSub
|
||||||
|
|
||||||
|
setup do
|
||||||
|
# Subscribe the current test process to the door events topic
|
||||||
|
# This makes events published by DoorServer arrive in the test process's mailbox.
|
||||||
|
:ok = PubSub.subscribe(DoorAutomation.PubSub, DoorAutomation.DoorServer.events_topic())
|
||||||
|
|
||||||
|
{:ok, _} = start_supervised({DoorServer, :unlocked})
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
test "locking the door changes state and publishes :door_locked event" do
|
||||||
|
assert :ok = DoorServer.lock()
|
||||||
|
|
||||||
|
assert DoorServer.get_state() == :locked
|
||||||
|
assert_receive :door_locked
|
||||||
|
refute_receive :door_unlocked
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
test "unlocking the door changes state and publishes :door_unlocked event" do
|
||||||
|
assert :ok = DoorServer.unlock()
|
||||||
|
|
||||||
|
assert DoorServer.get_state() == :unlocked
|
||||||
|
assert_receive :door_unlocked
|
||||||
|
refute_receive :door_locked
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
Now to write tests for the subscriber we can do as follows:
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
defmodule DoorAutomation.LightsSubscriberTest do
|
||||||
|
use ExUnit.Case
|
||||||
|
|
||||||
|
alias DoorAutomation.LightsSubscriber
|
||||||
|
alias DoorAutomation.DoorServer
|
||||||
|
alias Phoenix.PubSub
|
||||||
|
|
||||||
|
# Setup block that runs before each test
|
||||||
|
setup do
|
||||||
|
:ok = PubSub.subscribe(DoorAutomation.PubSub, LightsSubscriber.events_topic())
|
||||||
|
|
||||||
|
{:ok, _subscriber_pid} = start_supervised(LightsSubscriber)
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
test "LightsSubscriber subscribes sets lights red on :door_locked" do
|
||||||
|
PubSub.broadcast(DoorAutomation.PubSub, DoorServer.events_topic(), :door_locked)
|
||||||
|
|
||||||
|
assert_receive :lights_set_red
|
||||||
|
refute_receive :lights_set_green
|
||||||
|
end
|
||||||
|
|
||||||
|
test "LightsSubscriber sets lights green on :door_unlocked" do
|
||||||
|
PubSub.broadcast(DoorAutomation.PubSub, DoorServer.events_topic(), :door_unlocked)
|
||||||
|
|
||||||
|
assert_receive :lights_set_green
|
||||||
|
refute_receive :lights_set_red
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## What has improved?
|
||||||
|
|
||||||
|
By refactoring our DoorServer this way we only have to worry about maintaining and testing logic that concerns exclusively the door and the events it is meant to publish.
|
||||||
|
|
||||||
|
On the other hand, other modules can be tested very easily if we also refactor them to work in a more event-driven way.
|
||||||
|
|
||||||
|
For instance, as we just saw, to test if the lights change when a door change occurs we can use entirely our PubSub library to simulate door events happening and to assert that new events are published.
|
||||||
|
|
||||||
|
## Conclusions
|
||||||
|
|
||||||
|
Decoupling your elixir application components by making them more event-driven can make them more maintainable,
|
||||||
|
allowing them to grow independently of the rest of the system being sure you won't be breaking other modules as long as you don't break your events contract.
|
||||||
|
|
||||||
|
We've also seen how tests become simpler due to the fact that each test suite becomes more narrowed in scope.
|
||||||
|
|
||||||
|
There are some drawbacks though:
|
||||||
|
|
||||||
|
- Debugging becomes harder.
|
||||||
|
|
||||||
|
- You are introducing an extra of asynchronous broadcast messaging and therefore your elixir processes become harder to debug.
|
||||||
|
|
||||||
|
- Event delivery is not ensured.
|
||||||
|
|
||||||
|
- If a subscriber goes down, Phoenix PubSub won't try to re-deliver lost events once the process restarts.
|
||||||
|
- Depending on your situation you might want to implement a more complex pub-sub system that does its best to ensure event delivery.
|
||||||
|
|
||||||
|
- You loose orchestration
|
||||||
|
- You can't enforce the order each subscriber will consume the same event.
|
||||||
|
If your case requires one side effect to happen before another
|
||||||
|
(For example, the lights have to turn red before sending a notification) you will need to setup a single subscriber that executes them in order.
|
||||||
|
|
||||||
|
So as with every software pattern, depending on the requirements of your system this might or might not work for you.
|
||||||
|
|
||||||
|
In my case, my application didn't require side effects to be performed in order nor it was a problem if an event was lost because a process was down.
|
||||||
|
|
||||||
|
This is the first ever post that I publish online, I hope someone finds it useful.
|
||||||
|
If you have any doubts or feedback you can contact me via email, you can find it at the top of the page :)
|
||||||
|
|
||||||
|
Thank you for your time!
|
||||||
|
|
@ -4,10 +4,8 @@ pubDate: 2024-01-20
|
||||||
image: "./assets/me.png"
|
image: "./assets/me.png"
|
||||||
---
|
---
|
||||||
|
|
||||||
So this is my first post in here, I'm quite excited :D
|
|
||||||
|
|
||||||
This is just an introductory post where I will explain a little bit more
|
This is just an introductory post where I will explain a little bit more
|
||||||
what is this and the purpose of it.
|
what is this place and the purpose of it.
|
||||||
|
|
||||||
## What is this about?
|
## What is this about?
|
||||||
|
|
||||||
|
|
@ -19,28 +17,19 @@ It will be mainly focused in programming and software development,
|
||||||
but I don't want to constrain myself to just that
|
but I don't want to constrain myself to just that
|
||||||
so let's see where all of this goes.
|
so let's see where all of this goes.
|
||||||
|
|
||||||
Also you can notice how my goal is not to make this a space to sell myself as a developer.
|
My main purpose is to enjoy the process, have fun while
|
||||||
|
writing posts and most important to feel comfortable with what I build.
|
||||||
My main purpose is to enjoy the process, to have fun while
|
|
||||||
building this website and writing posts and most important to feel
|
|
||||||
comfortable and represented by what I build.
|
|
||||||
|
|
||||||
## A few more notes
|
## A few more notes
|
||||||
|
|
||||||
The fact that the website is pixel art themed and contains purple-ish colors
|
|
||||||
is merely because I love pixel art and because that's my favourite color :)
|
|
||||||
|
|
||||||
The color palette comes from the same theme I use in my Neovim setup.
|
The color palette comes from the same theme I use in my Neovim setup.
|
||||||
It was developed by [craftzdog](https://www.craftz.dog/),
|
It was developed by [craftzdog](https://www.craftz.dog/),
|
||||||
I am a big fan of his work. Go check out his [Youtube channel](https://www.youtube.com/devaslife)!
|
I am a big fan of his work. Go check out his [Youtube channel](https://www.youtube.com/devaslife)!
|
||||||
|
|
||||||
The whole website was built using [Astro](https://astro.build/).
|
The whole website was built using [Astro](https://astro.build/).
|
||||||
I've had a blast of a time with this framework,
|
I've had great time with this framework,
|
||||||
it is definitely the right tool if you are looking to build a blog!
|
it is definitely the right tool if you are looking to build a blog!
|
||||||
|
|
||||||
And that's it for today folks, I hope to be here soon with new content!
|
And that's it for today, I hope to be here soon with new content!
|
||||||
|
|
||||||
One thing I can already tell is that there will
|
|
||||||
be a lot of content related to Rust programming 🦀.
|
|
||||||
|
|
||||||
**Stay tuned!**
|
**Stay tuned!**
|
||||||
|
|
|
||||||
54
src/content/blog/assets/elixir.svg
Normal file
54
src/content/blog/assets/elixir.svg
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Generator: Adobe Illustrator 27.5.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||||
|
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||||
|
viewBox="0 0 2122 2122" style="enable-background:new 0 0 2122 2122;" xml:space="preserve">
|
||||||
|
<g>
|
||||||
|
<path style="fill:#D1E1EB;" d="M1276.382,844.885V584.528h25.309c43.313,0,78.75-35.438,78.75-78.751
|
||||||
|
c0-43.313-35.437-78.751-78.75-78.751H819.63c-43.313,0-78.751,35.438-78.751,78.751c0,43.313,35.439,78.751,78.751,78.751h25.309
|
||||||
|
v260.357c-224.536,86.624-383.844,304.465-383.844,559.545c0,331.13,268.434,599.564,599.564,599.564
|
||||||
|
s599.564-268.434,599.564-599.564C1660.224,1149.35,1500.917,931.509,1276.382,844.885z"/>
|
||||||
|
<path style="fill:#9ECDE6;" d="M971.72,521.138h-25.311c-43.313,0-78.75-35.438-78.75-78.751c0-5.255,0.542-10.387,1.537-15.361
|
||||||
|
H819.63c-43.313,0-78.751,35.438-78.751,78.751c0,43.313,35.439,78.751,78.751,78.751h25.309v260.357
|
||||||
|
c-224.536,86.624-383.844,304.465-383.844,559.545c0,331.13,268.434,599.564,599.564,599.564
|
||||||
|
c63.676,0,125.017-9.962,182.593-28.352c-32.428,3.225-64.961,5.283-97.547,5.865c-86.115,1.539-173.238-7.17-255.647-33.108
|
||||||
|
c-81.667-25.705-157.182-68.89-218.663-128.716c-70.345-68.449-119.891-154.769-146.345-249.128
|
||||||
|
c-47.507-169.461-7.832-363.453,93.331-505.861c0,0,110.151-162.614,351.532-183.751c0.3-0.026,2.935-253.568,2.935-253.568
|
||||||
|
l161.067-42.847h142.466h25.309c38.058,0,70.015-27.367,77.213-63.39H971.72z"/>
|
||||||
|
<path style="fill:#FFFFFF;" d="M1319.442,485.334h-257.555c-9.684,0-17.608-7.924-17.608-17.608l0,0
|
||||||
|
c0-9.685,7.924-17.608,17.608-17.608h257.555c9.684,0,17.608,7.924,17.608,17.608l0,0
|
||||||
|
C1337.05,477.411,1329.126,485.334,1319.442,485.334z"/>
|
||||||
|
<path style="fill:#A75A97;" d="M1550.618,1278.821c-10.375,30.318-226.09,54.53-490.727,54.53
|
||||||
|
c-258.464,0-470.295-23.094-489.703-52.417c-9.925,39.532-15.256,80.886-15.256,123.495c0,279.306,226.423,505.728,505.728,505.728
|
||||||
|
c279.305,0,505.729-226.422,505.729-505.728C1566.389,1361.063,1560.885,1318.989,1550.618,1278.821z"/>
|
||||||
|
<path style="fill:#65488F;" d="M1062.017,1786.685c-15.778-25.921-31.536-51.848-47.568-77.609
|
||||||
|
c-13.992-22.482-27.311-46.133-34.015-71.944c-6.415-24.704-5.727-51.056,3.993-74.834c7.885-19.29,17.571-37.801,28.077-55.782
|
||||||
|
c24.306-41.601,52.85-80.709,82.751-118.432c14.948-18.858,30.363-37.36,46.173-55.515c-26.522,0.513-53.754,0.783-81.536,0.783
|
||||||
|
c-258.464,0-470.295-23.094-489.703-52.417c-9.925,39.532-15.256,80.886-15.256,123.495c0,279.306,226.423,505.728,505.728,505.728
|
||||||
|
c24.893,0,49.354-1.831,73.285-5.305C1109.969,1865.463,1085.993,1826.074,1062.017,1786.685z"/>
|
||||||
|
<path style="fill:#CC7AA8;" d="M1059.892,1333.351c264.637,0,480.351-24.212,490.727-54.53c0.258-0.758,0.429-1.52,0.429-2.286
|
||||||
|
c0-31.378-219.898-56.816-491.155-56.816c-271.258,0-491.155,25.438-491.155,56.816c0,1.48,0.492,2.947,1.452,4.398
|
||||||
|
C589.596,1310.257,801.428,1333.351,1059.892,1333.351z"/>
|
||||||
|
|
||||||
|
<ellipse transform="matrix(0.5 -0.866 0.866 0.5 -274.6768 1609.1263)" style="fill:#FFFFFF;" cx="1256.206" cy="1042.44" rx="105.729" ry="210.461"/>
|
||||||
|
<path style="fill:#FFFFFF;" d="M1526.553,1228.9c-13.704,15.096-41.065,12.58-61.112-5.619
|
||||||
|
c-20.047-18.199-25.188-45.19-11.484-60.286c13.705-15.096,41.066-12.58,61.112,5.619
|
||||||
|
C1535.116,1186.813,1540.258,1213.804,1526.553,1228.9z"/>
|
||||||
|
<path style="fill:#F39452;" d="M1065.225,174.248c-111.51,13.509-203.883,15.245-212.964,4.374l10.552,248.403h390.743
|
||||||
|
l19.214-298.554l-0.184,0.021C1262.894,141.262,1174.265,161.038,1065.225,174.248z"/>
|
||||||
|
<path style="fill:#EF7747;" d="M1164.241,384.442c-32.827-4.501-67.072-11.337-96.265-27.733
|
||||||
|
c-11.758-6.602-22.574-15.075-30.575-26.016c-10.341-14.138-13.722-32.001-15.545-49.074c-3.667-34.332,0.149-68.572,2.427-102.845
|
||||||
|
c-92.436,9.219-164.08,9.355-172.021-0.152l10.552,248.403h390.743l2.296-35.681
|
||||||
|
C1225.225,390.47,1194.604,388.605,1164.241,384.442z"/>
|
||||||
|
<path style="fill:#F8B88A;" d="M1065.225,174.248c109.04-13.21,197.668-32.986,207.362-45.755c0.665-0.877,0.972-1.721,0.875-2.527
|
||||||
|
c-1.52-12.548-97.216-11.277-213.742,2.84c-116.526,14.117-209.756,35.733-208.236,48.282c0.066,0.54,0.368,1.045,0.777,1.535
|
||||||
|
C861.343,189.493,953.715,187.757,1065.225,174.248z"/>
|
||||||
|
<path style="fill:#FFFFFF;" d="M1213.605,343.089L1213.605,343.089c-15.049-1.088-26.472-14.291-25.384-29.341l7.406-102.438
|
||||||
|
c1.088-15.049,14.291-26.472,29.34-25.384l0,0c15.049,1.088,26.472,14.291,25.384,29.34l-7.406,102.438
|
||||||
|
C1241.857,332.754,1228.654,344.177,1213.605,343.089z"/>
|
||||||
|
|
||||||
|
<ellipse transform="matrix(0.3838 -0.9234 0.9234 0.3838 -503.6756 2242.0808)" style="fill:#CC6098;" cx="1428.062" cy="1498.424" rx="133.823" ry="74.835"/>
|
||||||
|
<path style="fill:#CC6098;" d="M1361.324,1698.587c-11.376,22.513-34.099,33.941-50.753,25.525
|
||||||
|
c-16.654-8.416-20.933-33.488-9.557-56.001c11.376-22.513,34.099-33.941,50.753-25.525
|
||||||
|
C1368.422,1651.001,1372.7,1676.074,1361.324,1698.587z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.8 KiB |
|
|
@ -22,7 +22,7 @@ const backgroundImageUrl = `url('${optimizedBgImage.src}')`;
|
||||||
<meta name="generator" content={Astro.generator} />
|
<meta name="generator" content={Astro.generator} />
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="My personal blog where I write about my learnings on software development & coding.
|
content="My personal blog where I write about my learning on software development & coding.
|
||||||
Learn with my about best coding practices and new tools to add to your developer toolbox."
|
Learn with my about best coding practices and new tools to add to your developer toolbox."
|
||||||
/>
|
/>
|
||||||
<link rel="sitemap" href="/sitemap-index.xml" />
|
<link rel="sitemap" href="/sitemap-index.xml" />
|
||||||
|
|
@ -65,7 +65,7 @@ const backgroundImageUrl = `url('${optimizedBgImage.src}')`;
|
||||||
.wrapper {
|
.wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: 80ch;
|
width: 90ch;
|
||||||
min-height: calc(100vh - 1rem);
|
min-height: calc(100vh - 1rem);
|
||||||
border: 2px solid white;
|
border: 2px solid white;
|
||||||
background: rgba(29, 31, 33, 0.95);
|
background: rgba(29, 31, 33, 0.95);
|
||||||
|
|
|
||||||
|
|
@ -46,24 +46,12 @@ const { title, pubDate, image } = Astro.props;
|
||||||
color: var(--text-color-light);
|
color: var(--text-color-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Revert the changes made by the global styles */
|
||||||
|
/* So markdown can be spaced as the markdown processor says */
|
||||||
.post-content {
|
.post-content {
|
||||||
h1,
|
* {
|
||||||
h2,
|
margin: revert;
|
||||||
h3,
|
padding: revert;
|
||||||
h4 {
|
|
||||||
margin-top: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
li {
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
li > p {
|
|
||||||
display: contents;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ const sortedPosts = posts.sort(
|
||||||
{post.data.title}
|
{post.data.title}
|
||||||
</a>
|
</a>
|
||||||
</h2>
|
</h2>
|
||||||
|
{post.data.description && <p>{post.data.description}</p>}
|
||||||
</article>
|
</article>
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -139,11 +139,10 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--base-color: #1d1f21;
|
|
||||||
--text-color: white;
|
--text-color: white;
|
||||||
--text-color-light: #657b83;
|
--text-color-light: #657b83;
|
||||||
--violet300: #9ca0ed;
|
--link-color: #9ca0ed;
|
||||||
--violet100: #cccfff;
|
--link-hover-color: #cccfff;
|
||||||
--font-size-m: 18px;
|
--font-size-m: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -165,19 +164,28 @@ a {
|
||||||
}
|
}
|
||||||
|
|
||||||
ul {
|
ul {
|
||||||
list-style: square inside none;
|
list-style: square;
|
||||||
}
|
|
||||||
|
|
||||||
ol {
|
|
||||||
list-style-position: inside;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: var(--violet300);
|
color: var(--link-color);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
a:hover {
|
a:hover {
|
||||||
color: var(--violet100);
|
color: var(--link-hover-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fix responsiveness issues with code blocks */
|
||||||
|
.astro-code code {
|
||||||
|
display: block;
|
||||||
|
width: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fix responsiveness issues with generated diagrams */
|
||||||
|
.diagram img {
|
||||||
|
max-width: 100% !important;
|
||||||
|
height: auto !important;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue