Imagine we have some code where we want to accomplish things in a variety of ways. One way to do this is with conditional branching:
class Animal
def speak(kind)
puts case kind
when :dog then "woof!"
when :cat then "meow!"
when :owl then "hoo!"
end
end
end
Animal.new.speak(:dog)
This works, but what if a developer wants to add a new way? With conditional branching, the entire method would need to be overwritten. Instead, we can separate the implementations into modules:
class Animal
module Adapter
module Dog
def self.speak
puts "woof!"
end
end
module Cat
def self.speak
puts "meow!"
end
end
end
def speak
self.adapter.speak
end
def adapter
return @adapter if @adapter
self.adapter = :dog
@adapter
end
def adapter=(adapter)
@adapter = Animal::Adapter.const_get(adapter.to_s.capitalize)
end
end
animal = Animal.new
animal.speak
animal.adapter = :cat
aanimal.speak
This is a lot more code! However, if we want to add another module, it’s not too bad and a lot more flexible:
class Animal
module Adapter
module Owl
def self.speak
puts "hoo!"
end
end
end
end
animal.adapter = :owl
animal.speak
This new module could even go in a separate gem – and with its own dependencies! Organizing things this way is called the adapter design pattern. Let’s look at a few examples of this pattern in the wild.
multi_json
A good example is the multi_json gem which parses JSON with the fastest available backend. In multi_json
, each backend is contained in an class that descends from Adapter
. Here’s multi_json/lib/multi_json/adapters/gson.rb
.
require 'gson'
require 'stringio'
require 'multi_json/adapter'
module MultiJson
module Adapters
# Use the gson.rb library to dump/load.
class Gson < Adapter
ParseError = ::Gson::DecodeError
def load(string, options = {})
::Gson::Decoder.new(options).decode(string)
end
def dump(object, options = {})
::Gson::Encoder.new(options).encode(object)
end
end
end
end
Here, load
executes each library’s method for turning a JSON string into an object, and dump
executes the method for turning an object into a string.
ActiveRecord
ActiveRecord is Rails’ ORM library for interacting with relational databases. It relies on the adapter pattern to allow the developer to interact with any supported database using the same methods. We can find this pattern in ActiveRecord
‘s connection_adapters.
module ActiveRecord
module ConnectionAdapters # :nodoc:
extend ActiveSupport::Autoload
autoload :Column
autoload :ConnectionSpecification
autoload_at 'active_record/connection_adapters/abstract/schema_definitions' do
autoload :IndexDefinition
autoload :ColumnDefinition
autoload :ChangeColumnDefinition
autoload :ForeignKeyDefinition
autoload :TableDefinition
autoload :Table
autoload :AlterTable
autoload :ReferenceDefinition
end
autoload_at 'active_record/connection_adapters/abstract/connection_pool' do
autoload :ConnectionHandler
autoload :ConnectionManagement
end
autoload_under 'abstract' do
autoload :SchemaStatements
autoload :DatabaseStatements
autoload :DatabaseLimits
autoload :Quoting
autoload :ConnectionPool
autoload :QueryCache
autoload :Savepoints
end
...
class AbstractAdapter
ADAPTER_NAME = 'Abstract'.freeze
include Quoting, DatabaseStatements, SchemaStatements
include DatabaseLimits
include QueryCache
include ActiveSupport::Callbacks
include ColumnDumper
SIMPLE_INT = /\A\d+\z/
define_callbacks :checkout, :checkin
attr_accessor :visitor, :pool
attr_reader :schema_cache, :owner, :logger
alias :in_use? :owner
...
attr_reader :prepared_statements
def initialize(connection, logger = nil, config = {}) # :nodoc:
super()
@connection = connection
@owner = nil
@instrumenter = ActiveSupport::Notifications.instrumenter
@logger = logger
@config = config
@pool = nil
@schema_cache = SchemaCache.new self
@visitor = nil
@prepared_statements = false
end
...
ActiveRecord
includes many adapters, including MySQL and PostgreSQL here. Look through a couple of those to see great examples of this pattern.
Moneta
One of my favorite gems is moneta which is a unified interface to key-value stores, such as Redis. Here is an example of using a file as a key-value store:
require 'moneta'
# Create a simple file store
store = Moneta.new(:File, dir: 'moneta')
# Store some entries
store['key'] = 'value'
# Read entry
store.key?('key') # returns true
store['key'] # returns 'value'
store.close
From the user’s perspective, accessing both redis
and daybreak
is as simple as reading or modifying a hash. Here’s what the daybreak
adapter looks like (comments removed to save space):
require 'daybreak'
module Moneta
module Adapters
class Daybreak < Memory
def initialize(options = {})
@backend = options[:backend] ||
begin
raise ArgumentError, 'Option :file is required' unless
options[:file]
::Daybreak::DB.new(options[:file], serializer:
::Daybreak::Serializer::None)
end
end
def load(key, options = {})
@backend.load if options[:sync]
@backend[key]
end
def store(key, value, options = {})
@backend[key] = value
@backend.flush if options[:sync]
value
end
def increment(key, amount = 1, options = {})
@backend.lock { super }
end
def create(key, value, options = {})
@backend.lock { super }
end
def close
@backend.close
end
end
end
end
Creating an Adapter Gem
Let’s make a gem that allows the user to choose an adapter for a rudimentary CSV parser. Here’s what our folder structure will look like:
├── Gemfile
├── Rakefile
├── lib
│ ├── table_parser
│ │ └── adapters
│ │ ├── scan.rb
│ │ └── split.rb
│ └── table_parser.rb
└── test
├── helper.rb
├── scan_adapter_test.rb
├── split_adapter_test.rb
└── table_parser_test.rb
Dependencies
Add minitest
and ruby "2.3.0"
to the Gemfile:
# Gemfile
source "https://rubygems.org"
ruby "2.3.0"
gem "minitest", "5.8.3"
Ruby 2.3 adds the new squiggly heredoc syntax which will be useful in this case as it prevents unnecessary leading whitespace. Adding it to the Gemfile will not install it. It will need to be installed separately with a command like (if you use RVM):
$ rvm install 2.3.0
Test Support
Add a Rakefile that lets use rake
to run all of our tests:
# Rakefile
require "rake/testtask"
Rake::TestTask.new do |t|
t.pattern = "test/*_test.rb"
t.warning = true
t.libs << 'test'
end
task default: :test
t.libs << 'test'
adds the test folder to our $LOAD_PATH
when running the task. The lib folder is included by default.
The Main Module
lib/table_parser.rb will implement what the user accesses when they use the gem:
# lib/table_parser.rb
module TableParser
extend self
def parse(text)
self.adapter.parse(text)
end
def adapter
return @adapter if @adapter
self.adapter = :split
@adapter
end
def adapter=(adapter)
require "table_parser/adapters/#{adapter}"
@adapter = TableParser::Adapter.const_get(adapter.to_s.capitalize)
end
end
::adapter
sets a default adapter the first time it is called. Notice that adapters are not loaded until they are set. This avoids exposing developers to bugs in unused adapters and allows adapters in the same project to use their own dependencies without requiring all dependencies for all adapters up front.
The Adapters
The first adapter parses using the scan
method with an appropriate regular expression. The regex delimiter searches for either anything that is not a comma or two consecutive commas:
# lib/table_parser/adapters/scan.rb
module TableParser
module Adapter
module Scan
extend self
def parse(text)
delimiter = /[^,]+|,,/
lines = text.split(/\n/)
keys = lines.shift.scan(delimiter).map { |key| key.strip }
rows = lines.map do |line|
row = {}
fields = line.scan(delimiter)
keys.each do |key|
row[key] = fields.shift.strip
row[key] = "" if row[key] == ",,"
end
row
end
return rows
end
end
end
end
The second adapter parses using the split
method with another regular expression:
# lib/table_parser/adapters/split.rb
module TableParser
module Adapter
module Split
extend self
def parse(text)
delimiter = / *, */
lines = text.split(/\n/)
keys = lines.shift.split(delimiter, -1)
rows = lines.map do |line|
row = {}
fields = line.split(delimiter, -1)
keys.each { |key| row[key] = fields.shift }
row
end
return rows
end
end
end
end
Test Helper
The main thing that needs to be done here is setting up the minitest
dependencies, and we can go ahead and load the project code as well. This is not really a necessary file here, but it’s common in larger projects.
# test/test_helper.rb
require "minitest/autorun"
require "table_parser"
Shared Test Examples
We should avoid duplicating all tests for each adapter. Instead, we will write shared examples that will be pulled in from simpler adapter test files:
# test/table_parser_test.rb
require "test_helper"
module TableParserTest
def test_parse_columns_and_rows
text = <<~TEXT
Name,LastName
John,Doe
Jane,Doe
TEXT
john, jane = TableParser.parse(text)
assert_equal "John", john["Name"]
assert_equal "Jane", jane["Name"]
assert_equal "Doe", john["LastName"]
assert_equal "Doe", jane["LastName"]
end
def test_empty
text = <<~TEXT
Name,LastName
TEXT
result = TableParser.parse(text)
assert_equal [], result
end
def test_removes_leading_and_trailing_whitespace
text = <<~TEXT
, Name,LastName
,John , Doe
, Jane, Doe
TEXT
john, jane = TableParser.parse(text)
assert_equal "John", john["Name"]
assert_equal "Jane", jane["Name"]
assert_equal "Doe", john["LastName"]
assert_equal "Doe", jane["LastName"]
end
end
Adapter Test Files
Next, for each adapter we need a test that will run the shared examples on it. Since the test examples are in a separate file and shared, these are pretty compact.
First, one for the scanning adapter:
# test/scan_adapter_test.rb
require "table_parser_test"
class TableParser::ScanAdapterTest < Minitest::Test
include TableParserTest
def setup
TableParser.adapter = :scan
end
end
Next, for the splitting adapter:
# test/split_adapter_test.rb
require "table_parser_test"
class TableParser::SplitAdapterTest < Minitest::Test
include TableParserTest
def setup
TableParser.adapter = :split
end
end
Running the Test Suite
Thanks to the Rakefile, verifying that both of the adapters work is easy;
$ rake
Run options: --seed 26993
# Running:
......
Fabulous run in 0.001997s, 3004.7896 runs/s, 9014.3689 assertions/s.
6 runs, 18 assertions, 0 failures, 0 errors, 0 skips
Conclusion
Adapters are great ways to incorporate multiple ways of accomplishing something without resorting to mountains of conditional branching. They also let you split approaches into separate libraries that can have their own dependencies. If adapters are loaded in a lazy manner, broken adapters will not affect a project unless they are used.
Stay tuned for more great design pattern articles!
Frequently Asked Questions (FAQs) about the Adapter Design Pattern in Ruby
What is the Adapter Design Pattern in Ruby?
The Adapter Design Pattern in Ruby is a structural design pattern that allows objects with incompatible interfaces to work together. This pattern involves a single class, known as the adapter, which is responsible for communication between the two different interfaces. The adapter wraps the object that needs to be adapted and presents a new interface for it to the outside world.
How does the Adapter Design Pattern work?
The Adapter Design Pattern works by encapsulating an existing class within a new class and exposing a consistent interface. The new class, or adapter, can then be used in place of the original class. This allows the client code to interact with the adapter in a uniform way, regardless of the underlying class or interface.
When should I use the Adapter Design Pattern?
The Adapter Design Pattern is particularly useful when you want to use a class that doesn’t meet the exact requirements of your interfaces. It’s also beneficial when you want to create a reusable class that cooperates with unrelated or unforeseen classes, that is, classes that don’t necessarily have compatible interfaces.
What are the benefits of using the Adapter Design Pattern?
The Adapter Design Pattern promotes code reusability and flexibility. It allows developers to use existing classes even if their interfaces don’t match the ones they need. It also enables classes to work together that couldn’t otherwise because of incompatible interfaces.
Can you provide an example of the Adapter Design Pattern in Ruby?
Sure, let’s consider a simple example. Suppose we have a Printer class that expects to print an array of strings. However, we have a Text class that only provides a single string. We can create an Adapter class that takes an instance of Text and adapts its interface to match what Printer expects.class Text
def initialize(text)
@text = text
end
def content
@text
end
end
class Printer
def print(texts)
texts.each do |text|
puts text
end
end
end
class TextAdapter
def initialize(text)
@text = text
end
def to_a
[@text.content]
end
end
text = Text.new("Hello, World!")
adapter = TextAdapter.new(text)
Printer.new.print(adapter.to_a)
Are there any drawbacks to using the Adapter Design Pattern?
While the Adapter Design Pattern is useful, it can introduce extra complexity into your code, as you need to create new adapter classes for each class or interface you want to adapt. This can make the code harder to understand and maintain.
How does the Adapter Design Pattern differ from other design patterns?
The Adapter Design Pattern is a structural design pattern, which means it’s concerned with how classes and objects are composed to form larger structures. This differs from creational patterns, which deal with object creation mechanisms, and behavioral patterns, which are concerned with communication between objects.
Can the Adapter Design Pattern be used with other design patterns?
Yes, the Adapter Design Pattern can be used in conjunction with other design patterns. For example, it can be used with the Factory Pattern to create the appropriate adapter at runtime, based on some condition.
Is the Adapter Design Pattern specific to Ruby?
No, the Adapter Design Pattern is a general design pattern that can be implemented in any object-oriented programming language. The implementation details may vary from language to language, but the underlying concept remains the same.
Where can I learn more about the Adapter Design Pattern and other design patterns in Ruby?
There are many resources available online to learn about design patterns in Ruby. Some popular options include online tutorials, blogs, and video courses. You can also refer to books on the subject, such as “Design Patterns in Ruby” by Russ Olsen.
Robert is a voracious reader, Ruby aficionado, and other big words. He is currently looking for interesting projects to work on and can be found at his website.