Design Patterns and Idioms in Ruby
Remarks#
Singleton
Ruby Standard Library has a Singleton module which implements the Singleton pattern. The first step in creating a Singleton class is to require and include the Singleton
module in a class:
require 'singleton'
class Logger
include Singleton
end
If you try to instantiate this class as you normally would a regular class, a NoMethodError
exception is raised. The constructor is made private to prevent other instances from being accidentally created:
Logger.new
#=> NoMethodError: private method `new' called for AppConfig:Class
To access the instance of this class, we need to use the instance()
:
first, second = Logger.instance, Logger.instance
first == second
#=> true
Logger example
require 'singleton'
class Logger
include Singleton
def initialize
@log = File.open("log.txt", "a")
end
def log(msg)
@log.puts(msg)
end
end
In order to use Logger
object:
Logger.instance.log('message 2')
Without Singleton include
The above singleton implementations can also be done without the inclusion of the Singleton module. This can be achieved with the following:
class Logger
def self.instance
@instance ||= new
end
end
which is a shorthand notation for the following:
class Logger
def self.instance
@instance = @instance || Logger.new
end
end
However, keep in mind that the Singleton module is tested and optimized, therefore being the better option to implement your singleton with.
Observer
The observer pattern is a software design pattern in which an object (called subject
) maintains a list of its dependents (called observers
), and notifies them automatically of any state changes, usually by calling one of their methods.
Ruby provides a simple mechanism to implement the Observer design pattern. The module Observable
provides the logic to notify the subscriber of any changes in the Observable object.
For this to work, the observable has to assert it has changed and notify the observers.
Objects observing have to implement an update()
method, which will be the callback for the Observer.
Let’s implement a small chat, where users can subscribe to users and when one of them write something, the subscribers get notified.
require "observer"
class Moderator
include Observable
def initialize(name)
@name = name
end
def write
message = "Computer says: No"
changed
notify_observers(message)
end
end
class Warner
def initialize(moderator, limit)
@limit = limit
moderator.add_observer(self)
end
end
class Subscriber < Warner
def update(message)
puts "#{message}"
end
end
moderator = Moderator.new("Rupert")
Subscriber.new(moderator, 1)
moderator.write
moderator.write
Producing the following output:
# Computer says: No
# Computer says: No
We’ve triggered the method write
at the Moderator class twice, notifying its subscribers, in this case just one.
The more subscribers we add the more the changes will propagate.
Decorator Pattern
Decorator pattern adds behavior to objects without affecting other objects of the same class. The decorator pattern is a useful alternative to creating sub-classes.
Create a module for each decorator. This approach is more flexible than inheritance because you can mix and match responsibilities in more combinations. Additionally, because the transparency allows decorators to be nested recursively, it allows for an unlimited number of responsibilities.
Assume the Pizza class has a cost method that returns 300:
class Pizza
def cost
300
end
end
Represent pizza with an added layer of cheese burst and the cost goes up by 50. The simplest approach is to create a PizzaWithCheese
subclass that returns 350 in the cost method.
class PizzaWithCheese < Pizza
def cost
350
end
end
Next, we need to represent a large pizza that adds 100 to the cost of a normal pizza. We can represent this using a LargePizza subclass of Pizza.
class LargePizza < Pizza
def cost
400
end
end
We could also have an ExtraLargePizza which adds a further cost of 15 to our LargePizza. If we were to consider that these pizza types could be served with cheese, we would need to add LargePizzaWithChese and ExtraLargePizzaWithCheese subclasses.we end up with a total of 6 classes.
To simplify the approach, use modules to dynamically add behavior to Pizza class:
Module + extend + super decorator:->
class Pizza
def cost
300
end
end
module CheesePizza
def cost
super + 50
end
end
module LargePizza
def cost
super + 100
end
end
pizza = Pizza.new #=> cost = 300
pizza.extend(CheesePizza) #=> cost = 350
pizza.extend(LargePizza) #=> cost = 450
pizza.cost #=> cost = 450
Proxy
Proxy object is often used to ensure guarded access to another object, which internal business logic we don’t want to pollute with safety requirements.
Suppose we’d like to guarantee that only user of specific permissions can access resource.
Proxy definition: (it ensure that only users which actually can see reservations will be able to consumer reservation_service)
class Proxy
def initialize(current_user, reservation_service)
@current_user = current_user
@reservation_service = reservation_service
end
def highest_total_price_reservations(date_from, date_to, reservations_count)
if @current_user.can_see_reservations?
@reservation_service.highest_total_price_reservations(
date_from,
date_to,
reservations_count
)
else
[]
end
end
end
Models and ReservationService:
class Reservation
attr_reader :total_price, :date
def initialize(date, total_price)
@date = date
@total_price = total_price
end
end
class ReservationService
def highest_total_price_reservations(date_from, date_to, reservations_count)
# normally it would be read from database/external service
reservations = [
Reservation.new(Date.new(2014, 5, 15), 100),
Reservation.new(Date.new(2017, 5, 15), 10),
Reservation.new(Date.new(2017, 1, 15), 50)
]
filtered_reservations = reservations.select do |reservation|
reservation.date.between?(date_from, date_to)
end
filtered_reservations.take(reservations_count)
end
end
class User
attr_reader :name
def initialize(can_see_reservations, name)
@can_see_reservations = can_see_reservations
@name = name
end
def can_see_reservations?
@can_see_reservations
end
end
Consumer service:
class StatsService
def initialize(reservation_service)
@reservation_service = reservation_service
end
def year_top_100_reservations_average_total_price(year)
reservations = @reservation_service.highest_total_price_reservations(
Date.new(year, 1, 1),
Date.new(year, 12, 31),
100
)
if reservations.length > 0
sum = reservations.reduce(0) do |memo, reservation|
memo + reservation.total_price
end
sum / reservations.length
else
0
end
end
end
Test:
def test(user, year)
reservations_service = Proxy.new(user, ReservationService.new)
stats_service = StatsService.new(reservations_service)
average_price = stats_service.year_top_100_reservations_average_total_price(year)
puts "#{user.name} will see: #{average_price}"
end
test(User.new(true, "John the Admin"), 2017)
test(User.new(false, "Guest"), 2017)
BENEFITS
- we’re avoiding any changes in
ReservationService
when access restrictions are changed. - we’re not mixing business related data (
date_from
,date_to
,reservations_count
) with domain unrelated concepts (user permissions) in service. - Consumer (
StatsService
) is free from permissions related logic as well
CAVEATS
- Proxy interface is always exactly the same as the object it hides, so that user that consumes service wrapped by proxy wasn’t even aware of proxy presence.