Статьи

Улучшите свой Ruby с помощью шаблона проектирования адаптера

Представьте, что у нас есть какой-то код, в котором мы хотим добиться чего-то разными способами. Один из способов сделать это с условным ветвлением:

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) 

Это работает, но что, если разработчик хочет добавить новый способ? При условном ветвлении весь метод должен быть перезаписан. Вместо этого мы можем разделить реализации на модули:

 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 

Это намного больше кода! Однако, если мы хотим добавить другой модуль, он не так уж плох и намного более гибок:

 class Animal module Adapter module Owl def self.speak puts "hoo!" end end end end animal.adapter = :owl animal.speak 

Этот новый модуль может даже войти в отдельный гем — и со своими собственными зависимостями! Организация вещей таким способом называется шаблоном проектирования адаптера . Давайте посмотрим на несколько примеров этой модели в дикой природе.

multi_json

Хорошим примером является гем multi_json, который анализирует JSON с самым быстрым из доступных бэкэндов. В multi_json каждый бэкэнд содержится в классе, который происходит от Adapter . Вот 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 

Здесь load выполняет метод каждой библиотеки для преобразования строки JSON в объект, а dump выполняет метод для преобразования объекта в строку.

ActiveRecord

ActiveRecord — библиотека ORM Rails для взаимодействия с реляционными базами данных. Он опирается на шаблон адаптера, чтобы позволить разработчику взаимодействовать с любой поддерживаемой базой данных, используя те же методы. Мы можем найти этот шаблон в ActiveRecord 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 включает в себя множество адаптеров, в том числе MySQL и PostgreSQL здесь . Просмотрите пару из них, чтобы увидеть отличные примеры этого паттерна.

Moneta

Одна из моих любимых жемчужин — это Moneta, который представляет собой унифицированный интерфейс для хранилищ ключевых значений, таких как Redis. Вот пример использования файла в качестве хранилища значения ключа:

 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 

С точки зрения пользователя, получить доступ к redis и redis так же просто, как прочитать или изменить хэш. Вот как выглядит адаптер для daybreak (комментарии удалены для экономии места):

 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 

Создание адаптера Gem

Давайте сделаем драгоценный камень, который позволит пользователю выбрать адаптер для элементарного анализатора CSV. Вот как будет выглядеть наша структура папок:

 ├── 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 

зависимости

Добавьте minitest и ruby "2.3.0" в Gemfile :

 # Gemfile source "https://rubygems.org" ruby "2.3.0" gem "minitest", "5.8.3" 

В Ruby 2.3 добавлен новый волнообразный синтаксис heredoc, который будет полезен в этом случае, поскольку он предотвращает ненужные начальные пробелы. Добавление его в Gemfile не установит его. Он должен быть установлен отдельно с помощью команды вроде (если вы используете RVM):

 $ rvm install 2.3.0 

Поддержка тестирования

Добавьте Rakefile, который позволяет использовать rake для запуска всех наших тестов:

 # 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' добавляет папку test в наш $LOAD_PATH при запуске задачи. Папка lib включена по умолчанию.

Основной модуль

lib / table_parser.rb будет реализовывать то, что пользователь получает, когда он использует гем:

 # 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 устанавливает ::adapter по умолчанию при первом вызове. Обратите внимание, что адаптеры не загружаются, пока они не установлены . Это позволяет избежать ошибок разработчиков в неиспользуемых адаптерах и позволяет адаптерам в одном проекте использовать свои собственные зависимости, не требуя всех зависимостей для всех адаптеров заранее.

Адаптеры

Первый адаптер анализирует с помощью метода scan с соответствующим регулярным выражением. Разделитель регулярных выражений ищет либо все, что не является запятой, либо две последовательные запятые:

 # 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 

Второй адаптер анализирует с помощью метода split другое регулярное выражение:

 # 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

Главное, что здесь необходимо сделать, — это установить зависимости minitest , и мы можем также загрузить код проекта. Это не совсем необходимый файл, но он распространен в больших проектах.

 # test/test_helper.rb require "minitest/autorun" require "table_parser" 

Общие тестовые примеры

Мы должны избегать дублирования всех тестов для каждого адаптера. Вместо этого мы напишем общие примеры, которые будут извлечены из более простых тестовых файлов адаптера:

 # 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 

Тестовые файлы адаптера

Далее, для каждого адаптера нам нужен тест, который будет запускать на нем общие примеры. Поскольку тестовые примеры находятся в отдельном файле и являются общими, они довольно компактны.

Во-первых, один для адаптера сканирования:

 # test/scan_adapter_test.rb require "table_parser_test" class TableParser::ScanAdapterTest < Minitest::Test include TableParserTest def setup TableParser.adapter = :scan end end 

Далее для разделительного адаптера:

 # test/split_adapter_test.rb require "table_parser_test" class TableParser::SplitAdapterTest < Minitest::Test include TableParserTest def setup TableParser.adapter = :split end end 

Запуск набора тестов

Благодаря Rakefile проверить, что оба адаптера работают легко;

 $ 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 

Вывод

Адаптеры — это отличный способ объединить несколько способов достижения чего-либо, не прибегая к горам условного ветвления. Они также позволяют разбивать подходы на отдельные библиотеки, которые могут иметь свои собственные зависимости. Если адаптеры загружаются лениво, сломанные адаптеры не повлияют на проект, если они не используются.

Оставайтесь с нами, чтобы увидеть больше отличных дизайнерских статей!