OOP, FP & Dependency management in Ruby/Rails

Управление зависимостями и сложностью

Почему мне должно быть не все равно?

Ка OOP/FP могут помочь?

Ну и чуток о Ruby/Rails :)

Павел Форкерт

Пишу на руби уже больше 4х лет

Когда-то коммитил в Travis CI

много чего еще

Зачем?

Вижу много кода, который мог бы быть лучше

Поделиться опытом

Получить фидбек

...

Проблема

Со временем изменения вносить все тяжелее и тяжелее

Любое изменение требует все больше и больше времени

Причины

  • Жесткость Необходимость изменений в одном месте тянет за собой изменения в других местах
  • Хрупкость Изменения в одной части программы непонятно почему приводят к ошибкам в других частях системы
  • Неразделяемость Невозможно использовать одну часть функционала без того, чтобы тебе навязали еще кучу всего

Управление зависимостями

class Nameserver < ActiveRecord::Base
  def self.import
    nameservers = fetch_nameservers

    geoip = GeoIP.new(Rails.root.join('db', 'GeoLiteCity.dat'))
    nameservers.each do |nameserver|
      Rails.logger.info "Processing nameserver: #{nameserver[:ip]}"
      nameserver = nameserver.slice(:country_id, :city, :state, :ip)
      if valid_for_import?(nameserver)
        fill_nameserver_with_additional_attributes(geoip, nameserver)
        create!(nameserver)
      end
    end
...
...
    def self.fill_nameserver_with_additional_attributes(nameserver)
      nameserver[:city] = nameserver[:city].presence

      city = geoip.city(nameserver[:ip])
      nameserver[:latitude] = city.latitude
      nameserver[:longitude] = city.longitude
    end

    def self.fetch_nameservers
      open('http://public-dns.tk/nameservers.json') { |f|
        JSON.parse(f.read).map(&:symbolize_keys)
      }
    end
...
...
    def self.valid_for_import?(nameserver)
      valid_country?(nameserver) &&
        valid_state?(nameserver) &&
        valid_ip_address?(nameserver) &&
        exists?(:ip => nameserver[:ip]) &&
        is_able_to_resolve?(nameserver)
    end

    private
      # def self.valid_country?(nameserver)
      # def self.valid_state?(nameserver)
      # def self.valid_ip_address?(nameserver)
      # def self.is_able_to_resolve?(nameserver)
...
...
    # Еще 10 страниц кода в том же классе
    # И этот код уже не связан с импортом
  end
end

Кто виноват и что делать?

Литературы очень много

  • Design Patters
  • GRASP
  • PoEAA
  • DDD
  • Hexagonal Architecture (aka "Ports and Adapters")
  • SOLID

Принципы

Low coupling

High cohesion

Small classes

Composition over inheritance

...

class Nameservers::Importer
  def initialize(options)
    @logger = options.fetch(:logger) {
      Rails.logger
    }
    @repository = options.fetch(:repository) {
      Nameservers::Nameserver
    }
    @validator = options.fetch(:validator) {
      Nameservers::Validator.new(:repository => @repository)
    }

    @fetches_namespace_data = options.fetch(:fetches_namespace_data) {
      Nameservers::DataLoader(:geoip =>
        GeoIP.new(Rails.root.join('db', 'GeoLiteCity.dat')))
    }
  end
class Nameservers::Importer
  def call(nameservers)
    nameservers.each do |nameserver|
      @logger.info "Processing nameserver: #{nameserver[:ip]}"

      nameserver = nameserver.slice(:country_id, :city, :state, :ip)
      if @validator.valid?(nameserver)
        nameserver = @fetches_namespace_data.call(nameserver)
        @repository.create!(nameserver)
      end
    end
  end
end
  • Single responsibility principle
  • Open closed principle
  • Liskov substitution principle
  • Interface segregation principle
  • Dependency inversion principle

Создавайте больше классов.

При создании задумывайтесь, что может меняться.

Выделяйте зависимости (еще больше классов).

Изолируйте изменения.

jsonrpc-client

https://github.com/fxposter/jsonrpc-client/pull/2

Практика!

  • Form object (ActiveModel::Model)
  • Decorators (draper, display-case)
  • Uploaders (carrierwave)
  • Inputs (simple_form)
  • Validators
class Invoice < ActiveRecord::Base
  attr_accessible :title

  def as_json(options = {})
    super(methods: [:user_name], only: [:id, :date_range, :price])
  end

  def report(filename)
    CSV.generate(filename)
    # ...
  end

  def background_color
    settings.background_color || 'rgba(0, 0, 255, 0.5)'
  end

  def schedule_post_upload_processing
    InvoiceProcessor.delay(priority: 15).after_create(id)
  end
end
class Invoice < ActiveRecord::Base # persistence
  attr_accessible :title # fixed with strong params, do you agree?

  def as_json(options = {}) # ActiveModel::Serializers, CustomSerializer
    super(methods: [:user_name], only: [:id, :date_range, :price])
  end

  def report(filename) # InvoiceReportGenerator, ReportGenerator?
    CSV.generate(filename)
    # ...
  end

  def background_color # really? background color?
    settings.background_color || 'rgba(0, 0, 255, 0.5)'
  end

  def schedule_post_upload_processing # definitely persistence
    InvoiceProcessor.delay(priority: 15).after_create(id)
  end
end

Services

Идея - по возможности stateless классы подобного вида:

class Service
  def initialize(*dependencies)
    # @dependencies = dependencies
  end

  # methods that use dependencies
end

Тем, кто писал на Angular.js будет знакома концепция

class Nameservers::Importer
  def initialize(logger, repository, geoip, validator)
  end
end
class Nameservers::Validator
  def initialize(logger, repository)
  end
end
class Nameservers::EventLogger
  def initialize(logger_file_path)
  end
end
module Nameservers::Registry
  def self.repository
    Nameservers::Nameserver # < ActiveRecord::Base
  end

  def self.importer
    @importer ||= Nameservers::Importer.new(:validator => validator,
                                            :repository => repository,
                                            :geoip => Geoip.new(root + '/db/geoipcitylite.dat'))
  end
end
module Nameservers::Registry
  def self.validator
    Nameservers::Validator.new(:repository => repository)
  end

  def self.serializer
    Oj
  end

  private
  def self.root
    File.expand_path('../../', __FILE__)
  end
end
container.register :root_path, '/tmp'
container.register :repository, Nameserver

container.register :validator do
  NameserverValidator.new(:repository => repository)
end

container.register :importer do |validator, repository, root_path|
  NameserverImporter.new(:validator => validator,
                         :repository => repository,
                         :geoip => Geoip.new(root_path + '/db/geoipcitylite.dat'))
end

Можно использовать вот такие "контейнеры" как для всего приложения, так и для конкретного запроса.

Их даже можно обьединить.

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

class ImportController
  def import_nameservers(params, importer, nameserver_data_source)
    importer.call(nameserver_data_source.all(params[:data_source_url]))
  end
end

Hint: poniard

Можно сделать проще:

class ApplicationController
  def container
    # setup and cache container
  end
end

class ImportController
  def import_nameservers
    nameservers = container[:nameserver_data_source].all(params[:data_source_url])
    container[:importer].call(nameservers)
  end
end

События

class Nameserver < ActiveRecord::Base
  def on(event, &callback)
    (@events[event] ||= []) << callback
  end

  def trigger(event, *args)
    @events[event].each do |callback|
      callback.call(*args)
    end
  end
end
class Nameserver < ActiveRecord::Base
  around_save :notify_on_save
  private
  def notify_on_save
    if new_record?
      # ...
    end

    yield

    if connection_address_changed?
      trigger(:connection_address_changed, new_connection_address, old_connection_address)
    end

    # ...
  end
end
class NameserversController
  def update
    @nameserver = Nameserver.find(params[:ip])
    @nameserver.on :become_available do
      schedule_background_job_for(@nameserver)
    end

    @nameserver.on :connection_address_changed do |new_connection_address, old_connection_address|
      notify_via_websockets(:create_connection, new_connection_address)
      notify_via_websockets(:delete_connection, old_connection_address)
    end

    @nameserver.save
  end
end

Вместо коллбеков можно использовать сам контроллер:

class Nameserver < ActiveRecord::Base
  def on(event, observer)
    (@observers[event] ||= []) << observer
  end

  def trigger(event, *args)
    @observers[event].each do |observer|
      if observer.respond_to?(event)
        observer.send(event, self, *args)
      end
    end
  end
end
class NameserversController
  def update
    @nameserver = Nameserver.find(params[:ip])
    @nameserver.add_listener self
    @nameserver.save
  end

  def connection_address_changed(nameserver, new_connection_address, old_connection_address)
    notify_via_websockets(:create_connection, new_connection_address)
    notify_via_websockets(:delete_connection, old_connection_address)
  end
end

... или другие обьекты

class NameserversController
  def update
    @nameserver = Nameserver.find(params[:ip])
    @nameserver.add_listener WebsocketNotifier.new(websocket_server_url)
    @nameserver.save
  end
end

class WebsocketNotifier
  def initialize(websocket_server_url)
    @websocket_server_url = websocket_server_url
  end

  def connection_address_changed(nameserver, new_connection_address, old_connection_address)
    # NOTIFY
  end
end
class EventSet
  def on(event, observer)
    @observers ||= {}
    @observers[event] ||= []
    @observers[event] << observer
  end

  def off(event, observer)
    return unless @observers
    return unless @observers[event]
    @observers[event].delete(observer)
  end

  def trigger(event, *args)
    if @observers && @observers[event]
      @observers[event].each do |observer|
        if observer.respond_to?(event)
          observer.send(event, *args)
        end
      end
    end
  end
end

Use Cases

Идея - отделить реакцию на запрос от фреймворка

class CreatesUserFile # or UserFileCreator
  def initialize(repository, uploader)
    @repository = repository
    @uploader = uploader
  end

  def call(params, request_body, listener)
    attributes = extract_attributes(params)
    attributes[:file_url] = @uploader.upload(request_body)
    file = @repository.create(attributes)
    listener.creation_succeeded(file)
  rescue => e
    listener.creation_failed(e)
  end
end

Чистые функции

Приняли аргументы - возвратите значение

Не изменяйте переданные аргументы

def fill_nameserver_attributes(nameserver)
  city = $geoip.city nameserver[:ip].to_s
  nameserver[:latitude] = city.latitude
  nameserver[:longitude] = city.longitude
end

nameserver = {:ip => IPAddr.new('8.8.8.8') }
fill_nameserver_attributes(nameserver)
nameserver[:latitude]
=> 32.2
def fill_nameserver_attributes(nameserver)
  city = $geoip.city nameserver[:ip].to_s
  nameserver.merge :latitude => city.latitude,
                   :longitude => city.longitude
end

Если производительность вам в данном куске кода ОЧЕНЬ важна (что маловероятно):

def fill_nameserver_attributes(nameserver)
  city = $geoip.city nameserver[:ip].to_s
  nameserver[:latitude] = city.latitude
  nameserver[:longitude] = city.longitude
  nameserver
end

API должно показывать что клиент должен рассчитывать только на возвращаемое значение.

class JSONLoggerFormatter
  def call(message)
    message.to_json << "\n"
  end
end
  • Обьект сам знает как сделать из себя JSON
  • Если нужна кастомизация - приходится переопределять #to_json
  • Весь контроль по генерации json-а выносится на вызывающий уровень
  • Если не хочется смешивать бизнес логику с кастомизацией json-представления - приходится на каждый вызов делать wrapper-ы
class JSONLoggerFormatter
  def call(message)
    JSON.dump(message) << "\n"
  end
end
  • Явно видим кто будет сериализировать
  • Можем использовать только те обьекты, которые известны в JSON: Hash, Array, String, etc
class JSONLoggerFormatter
  def initialize(json)
    @json = json
  end

  def call(message)
    @json.dump(message) << "\n"
  end
end
  • Явно видим кто будет сериализировать
  • Можем локально подменять реализации JSON-а
  • Можем обрабатывать как дампить кастомные обьекты
class JSONLoggerFormatter
  def initialize(json)
    @json = json
  end

  def call(message)
    @json.dump(message.to_value) << "\n"
  end
end
  • Явно видим кто будет сериализировать
  • Можем локально подменять реализации JSON-а
  • Можем обрабатывать как дампить кастомные обьекты на уровне самих обьектов

Зачем ограничиваться JSON-ом?

class SerializedLoggerFormatter
  def initialize(serializer)
    @serializer = serializer
  end

  def call(message)
    @serializer.dump(message.to_value) << "\n"
  end
end

"Мы стремимся к hexagonal!"

To be done

  • autoload vs require
  • Namespaced everything
  • Gemified everything
  • Enginied everything
  • Microservice everything?

Спасибо!