Pub/Sub is a popular pattern for reducing coupling and increasing system modularity. In the Publish-Subscribe model, subscribers await for events they are interested in, and get notified of any event generated by a publisher that matches their registered interest.
To simplify, imagine a radio station (publisher). You (subscriber) tune into the wave of this radio station (subscribe) and start to sing along when you hear a Blink-192 band song (react to event).
In details
This is a very convenient pattern when you have many parts of the program that must respond differently to an event. For example, imagine a site where user is registered. In this case, an email should be sent, the service of collecting information on the user in social networks should start and many other things. If everything is done in one place, then you will get high coupling and this monster will be hard to maintain. In the pub/sub pattern, you have a service that creates a user and broadcasts a message to everyone who is interested in user creation. All other services isolated from each other will perform the necessary work as soon as they receive the event they need.
There are a lot of solutions providing Publish-Subscribe capabilities. From small libraries (One of them a wisper
gem), to huge projects like RabbitMQ or Kafka. But to understand how it works, we will build our simple personal solution and investigate.
Our demo
We are building our own Walmart. Let's start with three classes (Item
, Checkout
and Printer
). When cashier scans an item, it should be added to checkout and immediatelly printed. Read the code below carefully. Here is the classical solution. We inject Printer
as dependency to our Checkout
and call print on it every time item is added. What problem we have here? Checkout
needs to now how Printer
works, that it has print
method. When you have a lot of classes and interactions, that can be problematic.
class Item
attr_reader :code, :title
def initialize(code:, title:)
@code = code
@title = title
end
def to_s
"#{@code} #{@title}"
end
end
class Checkout
attr_reader :items
def initialize(printer: Printer.new)
@printer = printer
@items = []
end
def add(item)
@items << item
@printer.print(item)
end
end
class Printer
def print(message)
puts "[#{Time.now}] #{message}"
end
end
How we can solve this problem with pub/sub pattern. First, let's introduce Publisher
module.
module Publisher
def subscribe(subscribers)
@subscribers ||= [] # if @subscribers is nil, we initialize it as empty array, else we do nothing
@subscribers += subscribers
end
def broadcast(event, *payload)
@subscribers ||= [] # @subscribers is nil, we can't do each on it
@subscribers.each do |subscriber|
# If event is :item_added occured with payload item itself
# we send method :item_added to subscriber and bypass payload as argument if subscriber
# responds to it.
subscriber.public_send(event.to_sym, *payload) if subscriber.respond_to?(event)
end
end
end
And that's it. Let's refactor our business logic to work with Publisher module
class Checkout
include Publisher
attr_reader :items
def initialize(subscribers:)
@items = []
subscribe(subscribers)
end
def add(item)
@items << item
broadcast(:item_added, item)
end
end
class Printer
def item_added(item)
print(item)
end
private
def print(message)
puts "[#{Time.now}] #{message}"
end
end
Let's check it
item1 = Item.new(code: "DRP", title: "Dr.Pepper")
item2 = Item.new(code: "CCL", title: "Coca-Cola")
checkout = Checkout.new(subscribers: [Printer.new])
checkout.add(item1)
checkout.add(item2)
#=> [2020-07-16 12:08:49 +0600] DRP Dr.Pepper
#=> [2020-07-16 12:08:49 +0600] CCL Coca-Cola
And now our Checkout
class doesn't know anything about Printer
implementation, it just sends event :item_added to it. You can say that it is looking more complex, but you will start to collect benefits with scaling the system. Imaging we add SoundManager
class, which works with our sound card.
class SoundManager
def item_added(_)
beep!
end
def beep!
print "\a"
end
end
item = Item.new(code: "DRP", title: "Dr.Pepper")
checkout = Checkout.new(subscribers: [Printer.new, SoundManager.new])
checkout.add(item)
#=> [2020-07-16 12:08:49 +0600] DRP Dr.Pepper
# And you should hear "Beep" sound!
Again, our Checkout
class doesn't know anything about SondManager implementation and internal logic. You just send event to it and SoundManager
knows how to handle it!
You can find full final code here: gist.github.com
Pros and Cons of pub/sub
- π Less coupling between classes
- π High level of scalability
- π Compact system tests for each isolated module
π Easy to implement
π Full process logic is not visible (You wonβt be able to understand what happened with
Checkout#add
method call by simply looking at the class)π You still need full integration tests to check system as a whole
Conclusion
We built the simplest example of such an architecture. There are many more complex solutions, for example, when Publisher needs to make sure that the subscriber received an event. Or when you need to send events asynchronously or after some time. It's a huge topics covering message brokers, data bus, queues, etc. If you are interested in it, I can go further and provide deeper tutorials.
Top comments (2)
Is there a way to avoid holding a reference to the printer? For example say I wanted an unknown number of printers to receive the broadcast and independently perform a print task.
Would this new change still be considered publish-subscribe pattern?
Yes! You may need to introduce Event Bus. It's like a huge highway, where publishers post events. Subscribers listen to that Event Bus, rather than Publishers.