[UA] Предметно-орієнтована архітектура Rails
- Час: 20-30 хвилин
- Рівень: Середній/Просунутий
- Код: GitHub
- Ресурси:
Дана стаття описує структуру проекту написаного на Rails і в ній використані ідеї з вищезазначених статтей. Окрім того приклад містить в собі можливість досить просто використовувати автоматичні додатки для перевірки якості коду. Головними вимогами до проекту є:
- Розділення перегляду (репрезентації) та бізнес-логіки (вашого домену)
- Розділення залежностей (gems) і як результат - можливість виконувати юніт тести в ізольованому середовищі
- Рішення повинно бути простим і зрозумілим (Rails чудовий фреймворк і ми не збираємось з ним боротись)
TLDR - 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
так щоб він використовував нові специфічні для кожної областіGemfile
- Налаштуємо незалежні тестові середовища для представлення та предметної області
Додавання додаткових Gemfile
не є чимось складним - ми просто створюємо нові файли і переміщуємо
в них залежності (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 проектами - її застосування повинно бути доволі простим в обох випадках.
Посилання: