Предметно-орієнтована архітектура 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/javascripts' ] = 'representations/public/javascripts' 34 paths[ 'public/stylesheets' ] = 'representations/public/stylesheets' 35 paths[ 'vendor' ] = 'representations/vendor' 36 paths[ 'vendor/assets' ] = 'representations/vendor/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
покращує гнучкість коду, тому в цьому прикладі вони представлені як concerns
, і є додатковий файл 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
- завантажуємо предметну область (спільні
concerns
у першу чергу, оскільки немає механізму автозавантаження):
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 'uuid'
- завантажуємо код представлення (спільні concerns в першу чергу, оскільки немає механізму автозавантаження):
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/
, ці тeки не можуть бути всередині 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