Предметно-орієнтована архітектура Rails

У цій статті описано DDD-підхід для побудови Ruby on Rails проекту. Окрім того, надано приклад використання автоматичних додатків для перевірки якості вище написане кодом. Головними вимогами до проекту є:

  1. Розділення перегляду (репрезентації) та бізнес-логіки (вашого домену).
  2. Розділення залежностей (gems) і як результат — можливість виконувати юніт-тести в ізольованому середовищі.
  3. Рішення має бути вибачимо і зрозумілим (Rails — чудовий фреймворк, і ми не збираємося з ним боротись).

TL;DR: GitHub repo та commit з усіма змінами, застосованими до нового проекту на Rails.

Розділення перегляду та бізнес-логіки

Першим кроком є чітке розділення перегляду та бізнес-логіки в структурі проекту (та у вашій голові). Для досягнення цього результату ми створимо нову папку representations/ і перемістимо в неї все, що нам потрібно для того, щоб показати суб'єкти домену. У прикладі ними є:

Я надаю перевагу використанню декораторів замість helper, тому тут немає папки helpers/.

Далі нам потрібно побудувати структуру тек для суб'єктів та логіки домену. Жодне з цих двох зрозуміти не повинно бути присутнім у частині проекту, що відповідає за представлення. Отож, давайте створимо нову теку domain/ і перемістимо в неї моделі та налаштування для бази даних:

Назва 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

Розділення середовищ та побудова незалежних тестів

Ми вже розділили представлення і предметну область, тепер окремі тести для кожної частини будуть великим плюсом для проекту. Правильно написані тести будуть швидші, ізольовані та незалежні. Спершу підготуємо середовище для них:

  1. Створимо окремі Gemfile та Gemfile.lock для представлення та предметної області.
  2. Налаштовуємо головний Gemfile так, щоб він використовував нові специфічні для кожної області Gemfiles.
  3. Налаштуємо незалежні тестові середовища для представлення та предметної області.

Додавання додаткових 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|. Це робиться для того, щоб не завантажувати жодних залежностей — ми їх завантажимо вручну пізніше. Після цього у нас не буде можливості запустити програму, проте це лише перший крок.

Далі ми завантажуємо всі необхідні залежності:

# 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
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
28 Dir[ './spec/support/*.rb' ].each { |f| require f }
29
 30 RSpec.configure do |config|

Налаштування тестового середовища для представлення

Налаштування тестового середовища для представлення є досить схожим. Єдина різниця — це залежності, які ми завантажуємо:

# representations/spec/rails_helper.rb
 3 require 'action_controller/railtie'
 4 require 'active_support'
 5 require 'rspec/rails'
 6 require 'spec_helper'
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

Тепер у нас є змога запускати різні тести залежно від контексту. І для кожного контексту:

Оскільки файли налаштування тестового середовища знаходяться всередині тек 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

Rails Parts — Tom Rothe

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