Предметно-орієнтована архітектура Rails
У цій статті описано DDD-підхід для побудови Ruby on Rails проекту. Окрім того, надано приклад використання автоматичних додатків для перевірки якості вище написане кодом. Головними вимогами до проекту є:
- Розділення перегляду (репрезентації) та бізнес-логіки (вашого домену).
- Розділення залежностей (gems) і як результат — можливість виконувати юніт-тести в ізольованому середовищі.
- Рішення має бути вибачимо і зрозумілим (Rails — чудовий фреймворк, і ми не збираємося з ним боротись).
TL;DR: GitHub repo та commit з усіма змінами, застосованими до нового проекту на Rails.
Розділення перегляду та бізнес-логіки
Першим кроком є чітке розділення перегляду та бізнес-логіки в структурі проекту (та у вашій голові). Для досягнення цього результату ми створимо нову папку representations/ і перемістимо в неї все, що нам потрібно для того, щоб показати суб'єкти домену. У прикладі ними є:
- representations/
- assets/
- controllers/
- decorators/
- public/
- views/
- vendor/
- routes.rb
Я надаю перевагу використанню декораторів замість helper, тому тут немає папки helpers/.
Далі нам потрібно побудувати структуру тек для суб'єктів та логіки домену. Жодне з цих двох зрозуміти не повинно бути присутнім у частині проекту, що відповідає за представлення. Отож, давайте створимо нову теку domain/ і перемістимо в неї моделі та налаштування для бази даних:
- domain/
- contexts/
- database.yml
Назва contexts/ тут є посиланням на шаблон Bounded Context в теорії предметно-орієнтованого програмування. Ви можете назвати їх по-іншому і мати будь-яку структуру тек усередині.
Тепер нам потрібно налаштувати Rails для роботи з новою структурою тек. Дані налаштування знаходяться всередині файлу config/application.rb і використовують API Rails::Application .
# config/application.rb 28 paths[ 'app/assets' ] = 'representations/assets' 29 paths[ 'app/views' ] = 'representations/views' 30 paths[ 'config/routes.rb' ] = 'representations/routes.rb' 31 paths[ 'config/database' ] = 'domain/database.yml' 32 paths[ 'public' ] = 'representations/public' 33 paths[ 'public/javascript' ] = 'representations/public/javascript' 34 paths[ 'public/stylesheets' ] = 'representations/public/stylesheets' 35 paths[ 'vendor' ] = 'representations/vendor' 36 paths[ 'vendor/assets' ] = 'representations/виробника/assets' 37 # impacts where Rails will look for an ApplicationController and ApplicationRecord 38 paths[ 'app/controllers' ] = 'representations/controllers' 39 paths[ 'app/models' ] = 'domain/contexts' 40 41 %W[ 42 #{ File.expand_path( '../representations/concerns', __dir__ ) } 43 #{ File.expand_path( '../representations/controllers', __dir__ ) } 44 #{ File.expand_path( '../domain/concerns', __dir__ ) } 45 #{ File.expand_path( '../domain/contexts', __dir__ ) } 46 ].each do |path| 47 config.autoload_paths << path 48 config.eager_load_paths << path 49 end
Після цієї зміни Rails буде працювати з новою структурою тек так, ніби оригінальна ніколи не змінювалась. Autoloading, eager loading, asset compilation — усі ці процеси будуть повністю функціональні.
На мою особисту думку, представлення ApplicationController та ApplicationRecord як concern покращує гнучкість кодом, тому в цьому прикладі вони представлені як проблеми, і є додатковий файл config/initializers/draper.rb для того, щоб 'Draper' зміг з ними працювати.
# config/initializers/draper.rb 3 DraperBaseController = Class.new( ActionController::Base ) 4 DraperBaseController.include( ApplicationController ) 5 6 Draper.configure do |config| 7 config.default_controller = DraperBaseController 8 end
Розділення середовищ та побудова незалежних тестів
Ми вже розділили представлення і предметну область, тепер окремі тести для кожної частини будуть великим плюсом для проекту. Правильно написані тести будуть швидші, ізольовані та незалежні. Спершу підготуємо середовище для них:
- Створимо окремі Gemfile та Gemfile.lock для представлення та предметної області.
- Налаштовуємо головний Gemfile так, щоб він використовував нові специфічні для кожної області Gemfiles.
- Налаштуємо незалежні тестові середовища для представлення та предметної області.
Додавання додаткових Gemfiles не є чимось складним — ми просто створюємо нові файли і переміщуємо в них залежності (gem) з головного файлу.
Налаштувати головний Gemfile для роботи з розподіленими залежностями також доволі просто. Bundler вже має метод для завантаження додаткових файлів. Якщо виникнуть проблеми при завантаженні розподілених залежностей, ви побачите ті самі помилки, що і при завантаженні звичайного Gemfile.
# Gemfile 54 %w[ representations/Gemfile domain/Gemfile ].each do |custom_gemfile| 55 eval_gemfile custom_gemfile 56 end
Налаштування незалежних тестових середовищ є найскладнішою частиною (і найімовірніше саме тут виникнуть додаткові проблеми під час росту проекту).
Перший крок — запустити команду rspec —init у теках representations/ та domain/. В результаті нові течи representations/spec та domain/spec будуть додані.
spec/spec_helper.rb також буде додано автоматично, проте spec/rails_helper.rb автоматично створен не буде. Нам доведеться додати і налаштувати його вручну.
Налаштування тестового середовища предметної області
Для початку ми копіюємо файл spec/rails_helper.rb у domain/spec/rails_helper.rb і видаляємо з нього все до лінії RSpec.configure do |config|. Це робиться для того, щоб не завантажувати жодних залежностей — ми їх завантажимо вручну пізніше. Після цього у нас не буде можливості запустити програму, проте це лише перший крок.
Далі ми завантажуємо всі необхідні залежності:
- завантажуємо active_record та rspec-rails:
# domain/spec/rails_helper.rb 3 require 'active_record/railtie' 4 require 'active_support' 5 require 'rspec/rails'
- завантажуємо залежності тестового середовища:
7 ENV['RAILS_ENV'] ||= 'test' 8 require 'spec_helper' 9 require 'database_cleaner' 10 require 'factory_bot' 11 require 'pry-byebug
- створюємо Application для роботи з rspec-rails (найімовірніше саме з них виникатимуть проблеми в майбутньому):
13 ContextsTestApplication = Class.new( ::Rails::Application ) 14 ::Rails.application = ContextsTestApplication.new
- під'єднуємось до бази даних:
16 database_configurations = YAML.load( 17 ERB.new( 18 File.read( File.expand_path( '../database.yml', __dir__ ) ) 19 ).result 20 ) 21 22 ActiveRecord::Base.establish_connection( database_configurations[ 'test' ] ) 23
- завантажуємо предметну область (спільні проблеми, у першу чергу, оскільки немає механізму автозавантаження):
24 %w[ concerns contexts ].each do |folder| 25 Dir[ File.expand_path( "../#{folder}/**/*.rb", __dir__ ) ].each { |f| require f } 26 end
- завантаження файлів initializer/support:
28 Dir[ './spec/support/*.rb' ].each { |f| require f } 29 30 RSpec.configure do |config|
Налаштування тестового середовища для представлення
Налаштування тестового середовища для представлення є досить схожим. Єдина різниця — це залежності, які ми завантажуємо:
- завантажуємо action_controller та rspec-rails:
# representations/spec/rails_helper.rb 3 require 'action_controller/railtie' 4 require 'active_support' 5 require 'rspec/rails' 6 require 'spec_helper'
- створюємо Application для rspec-rails та завантажуємо routes:
8 RepresentationsTestApplication = Class.new( ::Rails::Application ) 9 ::Rails.application = RepresentationsTestApplication.new 10 require_relative '../routes'
- завантажуємо залежності:
12 require 'pry-byebug' 13 require 'job'
- завантажуємо код представлення (спільні проблеми, в першу чергу, оскільки немає механізму автозавантаження):
15 %w[ concerns controllers decorators ].each do |folder| 16 Dir[ File.expand_path( "../#{folder}/**/*.rb", __dir__ ) ].each { |f| require f } 17 end
Тепер у нас є змога запускати різні тести залежно від контексту. І для кожного контексту:
- тести можуть включати лише юніт-тести;
- ми змушені залишатися всередині контексту при написанні тесту;
- завантаження/перезавантаження середовища є швидким (завантаження файлів тривало 2.65 секунди, коли тести запускалися з головного проектом і лише 0.9 секунд, якщо запускалися незалежно).
Оскільки файли налаштування тестового середовища знаходяться всередині тек representations/ та domain/, ці теки не можуть бути всередині app/, тому що Rails спробує скачати ці файли у production.
Фінальні частини
Як я вже згадував у попередній статті, я вважаю що тести, що знаходяться в головній теці spec/ test/ не повинні бути юніт-тестами і завжди мають тестувати декілька компонентів проекту. Протилежне твердження є істинним для тестів, що знаходяться у теках representations/spec/ та domain/spec. Вони завжди повинні бути юніт-тестами.
Єдина проблема з цим налаштуванням: для того, щоб запускати тести всередині ізольованого середовища, ви повинні мати окремі Gemfile.lock. Це може спричинити різницю у версіях gem, які використовується для тестів, що запускаються в ізоляції, і тестів, що допускаються як частина глобальної тестової системи. Давайте напишемо тест, який бі надсилав нам повідомлення, якщо така ситуація станеться:
# spec/sanity/gemfile_spec.rb 5 RSpec.describe 'Gemfile' do 6 context 'Domain Gemfile' do 7 it 'have gems locked at the same version as a global Gemfile' do 8 global_environment = Bundler::Dsl.evaluate( 'Gemfile', 'Gemfile.lock', {} ) 9 .resolve 10 .to_hash 11 local_environment = Bundler::Dsl.evaluate( 'domain/Gemfile', 'domain/Gemfile.lock', {} ) 12 .resolve 13 .to_hash 14 15 diff = local_environment.reject do |gem, specifications| 16 global_environment[ gem ].map( &:version ).uniq == specifications.map( &:version ).uniq 17 end 18 19 expect( diff.keys ).to eq( [] ) 20 end 21 end
Приклад проекту також включає Git hooks, які будуть встановлені на ваш проект, якщо ви запустите ./bin/setup і будуть автоматично виконані перед тім та після того, як ви зробите commit. Pre-commit hook запускає rubocop для перевірки всіх змін, які будуть включені в commit. Post-commit hook надає вам можливість запускати rails_best_practices, reek, brakeman і mutant для вашого коду.
Підсумок
Мені дуже подобається гнучкість цієї архітектури. За потреби можна ізолювати будь-яку частину коду і ставитись до неї як до незалежного unit. Водночас вона здебільшого використовує Rails API — тож ми не боремося з Rails. Скоріше, це ще один спосіб для організації кодом. Мені кортить випробувати цю архітектуру з більш складними проектами та legacy. Її застосування має бути доволі пробачимо в обох випадках.
Використані ресурси:
The Modular Monolith: Rails Architecture — Dan Manges
Counterintuitive Rails — Ivan Nemytchenko
Scaling Teams using Tests for Productivity and Education — Julian Nadeau
Опубліковано: 07/11/18 @ 08:00
Розділ Різне
Рекомендуємо:
«У 2016-му моя зарплата з двох шкіл становила 2400 гривень». Як я пройшов шлях від сільського вчителя до програміста
Як українські IT-компанії святкували Halloween 2018
Ruby/Rails дайджест #23: реліз Ruby 2.5.3, оновлення Hanami до версії 1.3.0, фреймворк Action Text для Ruby on Rails 6
Туторіал з налаштування Rails-додатків на Amazon EC2 з Chef. Частина 3
Як валідувати продуктові гіпотези. Досвід Google, MacPaw і SendPulse