On Github fxposter / architecture-and-dependency-management-presentation
Rubyist for over than 4 years
Has committed to various opensource projects
DevOps at Wix.com
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 зависеть от него не будет.
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. Но это тоже может решаться локально. Опять же, можно самим додумать как. :)
Yes, but that's not what I'd like to point out
В общем случае - это 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-а можно этим "похвастаться" :().
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
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? т.е. что нужно реально делать. Когда вы строите какой-то обьект и видите, что ему нужна зависимость (а они, если мы помним, должны быть локальные), то определяйте интерфейс этой зависимости исходя из того, что нужно этому классу, А НЕ ИЗ ТОГО, ЧТО УЖЕ ЕСТЬ ПОД РУКОЙ.
На практике лично у меня эти правила, иногда не работают (иногда все-таки приходится изменять интерфейс потому что очень проблематично писать адаптеры - как правило это связано с производительностью), но в целом подобный подход дает большую уверенность в коде.
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, которые как правило внутри значительно более адекватные и красивые чем "доморощенные админки", при том что доморощенные еще и беднее по функционалу.
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-слоем для работы с бд.
Кто считает что подобный подход будет лучше?
Довольно проблематично "инжектить в существующие контроллеры и модели" с которыми все привыкли работать что-то "из другого мира". Да и представьте, что вот это будет написано у вас в контроллере. Я считаю, что второй вариант открывает чересчур много информации о всем происходящем. Это все тому же контроллеру не нужно.
Поэтому я предпочитаю, особенно на начальных этапах, выносить все в статические методы каких-нибудь классов, чтобы внутри этих методов иметь возможность КАК УГОДНО менять реализацию. А уже после вынесения начинать дробить все на обьекты и их зависимости.
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 стабилизируются - можно начинать искать выносить их на уровень выше.