1-й в Украине сертификационный курс по UX от UXQB — крупнейшего в мире комьюнити UX специалистов
×Закрыть

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

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

  1. Розділення перегляду (репрезентації) та бізнес-логіки (вашого домену).
  2. Розділення залежностей (gems) і як результат — можливість виконувати юніт-тести в ізольованому середовищі.
  3. Рішення має бути простим і зрозумілим (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

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

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

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

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

  • завантажуємо 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

Rails Parts — Tom Rothe

Scaling Teams using Tests for Productivity and Education — Julian Nadeau

LinkedIn

Нет комментариев

Подписаться на комментарииОтписаться от комментариев Комментарии могут оставлять только пользователи с подтвержденными аккаунтами.

Подписаться на комментарии