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
Я сейчас хочу поговорить о нескольких вещах, которые мне кажутся сейчас очень важными, но в тех книгах и статьях, которые я читаю, о них либо вообще не говорят, либо говорят, как мне кажется, недостаточно.
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"
конечные приложения могут сами решать, как им управлять глобальным состоянием, но это нужно делать аккуратно
думайте об интерфейсах зависимостей не с точки зрения того, ЧТО У ВАС УЖЕ ЕСТЬ, а с точки зрения того, ЧТО ВАМ ДЕЙСТВИТЕЛЬНО НУЖНО
если вы точно не знаете, как будет менятся функциональность в дальнейшем - абстрагируйтесь от решений так, чтобы у вас было максимум возможностей для дальнейшего развития. обычные методы с минимумом аргументов на первое время будут прослойкой между вашей системой, к которой у них будет доступ и полностью абстрагированной от внешнего мира функциональностью
Анализируйте ваши архитектурные решения, особенно через время после их принятия.