Architecture & dependency management in Ruby/Rails – v2.0 – part 2



Architecture & dependency management in Ruby/Rails – v2.0 – part 2

0 0


architecture-and-dependency-management-presentation

Архитектура и управление зависимостями в Ruby/Rails приложениях

On Github fxposter / architecture-and-dependency-management-presentation

Architecture & dependency management in Ruby/Rails

v2.0

part 2

Forkert Pavel

Rubyist for over than 4 years

Has committed to various opensource projects

DevOps at Wix.com

In the previous episodes...

OOP, FP & Dependency management in Ruby/Rails http://fxposter.github.io/dependency-management-presentation/
Я сейчас хочу поговорить о нескольких вещах, которые мне кажутся сейчас очень важными, но в тех книгах и статьях, которые я читаю, о них либо вообще не говорят, либо говорят, как мне кажется, недостаточно.
class MediaService::PictureDatasource
  def initialize(cdn)
    @cdn = cdn
  end

  def delete(path)
    # delete ...
    cdn.delete(path)
  end
end
class MediaService::PictureDatasource
  def delete(path)
    # delete ...
    MediaService.cdn.delete(path)
  end
end
class MediaService::PictureDatasource
  def delete(path)
    # delete ...
    Akamai.new(:username => 'A',
               :password => 'B',
               :hostname => 'C').delete(path)
  end
end

Начнем с кода:

Допустим, вы пишете сервис по работе с картинками, и при удалении картинок из базы нам нужно удалять их из какого-нибудь CDN. Какой из классов вы хотели бы видеть в своем проекте?

Для тех, кто "проникся TDD и жить без него не может" - протестировать в руби можно все три варианта (вопрос в том, насколько это будет удобно, но сейчас я не об этом).

Допустим, третий вариант вы откидываем. Во втором варианте мы вполне можем динамически менять cdn и сам PictureDataSource зависеть от него не будет.

Are those options interchangeable?

class MediaService::PictureDatasource
  def initialize(cdn)
    @cdn = cdn
  end

  def delete(path)
    # delete ...
    cdn.delete(path)
  end
end
class MediaService::PictureDatasource
  def delete(path)
    # delete ...
    MediaService.cdn.delete(path)
  end
end
MediaService.with_cdn(akamai) do
  datasource.delete(path)
end

Являются ли эти варианты взаимозаменяемыми?

Теоретически в самом приложении так же можно "подменить" MediaService.cdn, но вы не сможете создать 2 разных инстанса MediaService с разными CDN. Представьте, что у вас есть 2 storage-а с одинаковыми интерфейсами работы с ними, но перед каждым из них стоят разные CDN.

Точнее это возможно, но с БОЛЬШИМИ извращениями.

Если кому интересно дальше поразрабатывать этот вариант - подумайте еще над тем, как сделать так, чтобы это все работало в многопоточном режиме.

Чтобы быть до конца честным - если просто передавать сюда инстанс CDN, то мы теряем одну маленькую возможность - иметь РАЗНЫЕ CDN при вызовах datasource.delete у одного и того же datasource. Но это тоже может решаться локально. Опять же, можно самим додумать как. :)

Dependency Inversion?

Yes, but that's not what I'd like to point out

Locality of the objects

В общем случае - это dependency inversion и все. Но в данном случае я хочу обратить внимание не на сам принцип, а на то, ЧЕМ именно отличаются эти 2 варианта - локальностью. По крайней мере я буду называть ЭТО локальностью.

Я имею ввиду то, что нам не нужно никуда ходить для того, чтобы узнать CDN - он уже у нас есть, это наша локальная переменная.

Почему я считаю, что это важно - локальное лучше глобального. Всегда. Потому что с помощью локального всегда можно эмулировать глобальное. Обратное сделать иногда возможно, но не всегда. и практически НИКОГДА это нельзя сделать удобно.

Минус "локального" - приходится таскать с собой дополнительные параметры. Но как правило подобные сервисы с зависимостями создаются где-нибудь в одном месте и "таскать" приходится не так уж и много.

MyGem.configure do |config|
end

MyGem.call_method
config = MyGem::Config.new(:a => 'b')
client = MyGem::Client.new(config)
client.call_method

Если вы разрабатываете гем - попытайтесь в первую очередь дать возможность пользователю самому решать, как у него будет называться та или иная глобальная переменная. Старайтесь как можно меньше влиять на архитектуру приложения. Но если уж совсем хочется сделать удобно и глобально - сделайте сначала локально, а потом поверх этого в 10 строк сделайте глобальную надстройку:

Почему я вообще затронул эту тему - потому что я как правило не пишу обычные CRUD приложения с базой данных. Мне периодически приходится сталкиваться с гемами, которые делают "MyGem.configure" и не дают сделать несколько инстансов с разными параметрами (API chef-а можно этим "похвастаться" :().

Dependency Inversion -> Interface Ownership

But... Ruby does not have interfaces...

Does it need interface ownership?

Продолжим про dependency inversion. Для тех, кто не в курсе - это последний из SOLID принципов и он говорит нам, что мы должны зависеть от абстракций, а не реализаций. В коде это в итоге выражается в том, что в статически-типизированных языках мы говорим, что мы получаем интерфейсы, а нам в итоге заходят какие-то реализации. В динамически-типизированных языках ситуация "даже проще" - интерфейсов нет, просто получаем сразу список любых обьектов и все хорошо.

К сожалению в Руби нет такого понятия как интерфейс и, соответственно, одна из важных, опять же - по моему мнению, частей принципа теряется - теряется "interface ownership", т.е. кто "владеет" интерфейсом, который описывает то, что должен получить обьект.

В чем, собственно проблема - у кого было хоть раз, что вы изменяете какой-нибудь метод (добавляете параметр, переименовываете его, удаляете вообще, потому что он вроде как не нужен, изменяете возвращаемое значение), а потом вы следом за этим меняете и кучу других обьектов, которые юзают изменившийся обьект?

Это следствие того, что вашего интерфейса либо в принципе не существует (типа, "а, что прийдет, то и буду юзать"), либо этим интерфейсом "владеет" кто-то еще.

class AddsProductToCart
  def initialize(user)
    @user = user
  end

  def call(product)
    @user.cart << product
  end
end
class AddsProductToCart
  def call(product)
    if product.published? && @user.is_customer_of?(product.vendor)
      @user.cart << product
    end
  end
end
class User
  def cart
  end
end
class User
  def profile
  end
end

class Profile
  def cart
  end
end
user.cart -> user.profile.cart
class AddsProductToCart
  def call(product)
    if product.published? && @user.is_customer_of?(product.vendor)
      @user.cart << product
    end
  end
end

Use adapters!

Как избегать подобных ситуаций? Забирать интерфейс в свои руки. Если обьект, который передается в ваш класс меняется - это значит, что должна быть создана (или изменена, если она уже есть) прослойка (adapter), которая должна приводить чужой обьект, интерфейс которого изменился к интерфейсу, который нужен вам.

Don't mock what you don't own

Кстати, наверняка многие слышали фразу "don't mock what you don't own", так вот "own" в ней как раз относится к interface ownership. Потому что если этим интерфейсом владеет какой-то класс, то при тестировании этого класса можно смело его мокать, потому что он "ВАШ".

"Interface ownership" how to

When you create a new object and see that it needs some dependency, then define dependency's interface in terms of what you need from it, not in terms of what objects/classes you actually have in the application already

Okay, now what? т.е. что нужно реально делать. Когда вы строите какой-то обьект и видите, что ему нужна зависимость (а они, если мы помним, должны быть локальные), то определяйте интерфейс этой зависимости исходя из того, что нужно этому классу, А НЕ ИЗ ТОГО, ЧТО УЖЕ ЕСТЬ ПОД РУКОЙ.

На практике лично у меня эти правила, иногда не работают (иногда все-таки приходится изменять интерфейс потому что очень проблематично писать адаптеры - как правило это связано с производительностью), но в целом подобный подход дает большую уверенность в коде.

"Interface ownership" and external gems

What if this class is in some gem?

class AddsProductToCart
  def initialize(user)
    @user = user
  end

  def call(product)
    if product.published? && @user.is_customer_of?(product.vendor)
      @user.cart << product
    end
  end
end

Check out rails_admin

Что примечательно, тот же interface ownership как ни странно соблюдается при работе с гемами, но напрочь игнорируется при работе с собственным кодом:

Если подобные классы находятся снаружи приложения, то мы начинаем в первую очередь искать варианты которые позволяют нам работать не изменяя внешний класс. Но если он находится у нас под рукой - он сразу же попадает под нож.

Это ведет к довольно нелогичным и печальным последствиям. В пример хочется привести админки, типа rails_admin, которые как правило внутри значительно более адекватные и красивые чем "доморощенные админки", при том что доморощенные еще и беднее по функционалу.

Treat and design your objects so that you don't need to change them every time something else is changed. Let something else change the way the object works.

Фиксируйте АПИ своих обьектов и старайтесь сделать добавлять функционал не меняя существующих обьектов, даже если кажется что "нужно всего одну строку поменять". Если можно с обьектом провести какую-то операцию с использованием существующего интерфейса - лучше это сделать именно так, а не вносить в АПИ дополнительные методы.

Dependency inversion in Rails apps?

Wat?

И напоследок о том, откуда, собственно, эти все обьекты с зависимостями будут появляться в приложениях на Rails и как далеко стоит с этим заходить. В предыдущей презентации я говорил о том, как можно в методы контроллера инжектить зависимости и вообще все приложение строить вокруг DI-фреймворка, как уже давно делают джависты, теперь скалисты и многие другие.

Rails is OMAKASE!

http://david.heinemeierhansson.com/2012/rails-is-omakase.html

У рельс есть свое мнение относительно того, как строить приложения и ради того, чтобы все было красиво и "ООП" от этого отказываться не стоит. Я пока что не видел реальную и полноценно удобную замену activerecord-у. Да, Sequel удобный, но заменять AR на него я бы не стал. Использовать "репозитории" вместо того, что уже есть я тоже не вижу смысла.
class ExportController
  def export
    MyApp::Zookeeper.export_current_data_to_production
    MyApp::Zookeeper.export_current_data_to_staging
    MyApp::Zookeeper.export_current_data_to_ci
  end
end

vs

class ExportController
  def export
    zk = MyApp::Zookeeper.new(production_config)
    zk.export(SomeModel.current_data_for(SomeModel.env(:production)))
  end
end

Представьте что у вас задача текущую базу данных экспортировать в каком-нибудь виде во внешнее хранилище. Это обычное Rails-приложение с AR-слоем для работы с бд.

Кто считает что подобный подход будет лучше?

Довольно проблематично "инжектить в существующие контроллеры и модели" с которыми все привыкли работать что-то "из другого мира". Да и представьте, что вот это будет написано у вас в контроллере. Я считаю, что второй вариант открывает чересчур много информации о всем происходящем. Это все тому же контроллеру не нужно.

Поэтому я предпочитаю, особенно на начальных этапах, выносить все в статические методы каких-нибудь классов, чтобы внутри этих методов иметь возможность КАК УГОДНО менять реализацию. А уже после вынесения начинать дробить все на обьекты и их зависимости.

Another layer

MyApp::Zookeeper.export_current_data_to_production knows about your application, but the parts it is using does not know anything.

It is the starting point for the "whole new world"

module MyApp::Zookeeper
  def self.export_current_data_to_production
    dumper = ProductionDataDumper.new(database_records,
                                      config[:production_prefix])
    export(client_for(config[:production]), dumper.dump)
  end
end

export_current_data_to_production is not reusable and swappable by itself, but the export method and ProductionDataDumper are.

Я делаю для себя ограду, за которой я могу творить все что угодно. Я могу тут писать любой плохой и некрасивый код, но в последствии у меня будет возможность это легко рефакторить все что скрывает этот вызов.
class ApplicationController
  def zookeeper
    @zookeeper ||= MyApp::Zookeeper
    # MyApp::Zookeeper.new(Rails.config(:zookeeper))
  end
end

class SomeController < ApplicationController
  def production_export
    zookeeper.export(Model.export_records)
  end
end
Когда классы и интерфейсы, лежащие за export_current_data_to_production стабилизируются - можно начинать искать выносить их на уровень выше.

To sum up

  • Minimize global state in gems
  • Applications is less restrictive in terms of global state, but still "local" stuff is much more manageable
  • Dependencies' interfaces should actually be "object which needs the dependency"-centric, not the "use what you already have"-centric
  • Abstract stuff and try not to break the abstractions every time you need something
  • Analyze your solutions, think what are the pros and cons
  • Happy hacking!
  • стройте свои библиотеки так, чтобы глобальное состояние у них было минимально, потом всегда можно добавить "opinionated global interface"
  • конечные приложения могут сами решать, как им управлять глобальным состоянием, но это нужно делать аккуратно
  • думайте об интерфейсах зависимостей не с точки зрения того, ЧТО У ВАС УЖЕ ЕСТЬ, а с точки зрения того, ЧТО ВАМ ДЕЙСТВИТЕЛЬНО НУЖНО
  • если вы точно не знаете, как будет менятся функциональность в дальнейшем - абстрагируйтесь от решений так, чтобы у вас было максимум возможностей для дальнейшего развития. обычные методы с минимумом аргументов на первое время будут прослойкой между вашей системой, к которой у них будет доступ и полностью абстрагированной от внешнего мира функциональностью
  • Анализируйте ваши архитектурные решения, особенно через время после их принятия.

Thanks!