Improve Your Ruby with the Adapter Design Pattern.
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!
Source: www.bing.com
Images credited to www.bing.com and www.ladailypost.com