Improve Your Ruby with the Adapter Design Pattern

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!

Video Improve Your Ruby with the Adapter Design Pattern

Improve Your Ruby with the Adapter Design Pattern


Source: www.bing.com
Images credited to www.bing.com and www.ladailypost.com


Related Posts To Improve Your Ruby with the Adapter Design Pattern


Improve Your Ruby with the Adapter Design Pattern Rating: 4.5 Posted by: Brot Trune

Search Here

Popular Posts

Total Pageviews

Recent Posts