DEV Community

Bohdan Pohorilets
Bohdan Pohorilets

Posted on • Originally published at bpohoriletz.github.io on

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

Дана стаття описує структуру проекту написаного на Rails і в нійвикористані ідеї з вищезазначених статтей. Окрім того приклад містить всобі можливість досить просто використовувати автоматичні додатки дляперевірки якості коду. Головними вимогами до проекту є:

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

Після цієї зміни 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
Enter fullscreen mode Exit fullscreen mode

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

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

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

Додавання додаткових Gemfile не є чимось складним - ми просто створюємо нові файли і переміщуємов них залежності (gem) з головного.

Налаштувати головний Gemfile для роботи з розподіленими залежностями є також доволі простим - bundlerвже має метод для завантаження додаткових файлів, якщо виникнуть проблеми при завантаженні розподіленихзалежностей ви побачите ті самі помилки що і при завантаженні звичайного Gemfile

 # Gemfile

 54 %w[representations/Gemfile domain/Gemfile].each do |custom_gemfile|
 55 eval_gemfile custom_gemfile
 56 end
Enter fullscreen mode Exit fullscreen mode

Налаштування незалежних тестових середовищ є найскладнішою частиною (і найімовірніше саме тут виникнутьдодаткові проблеми при рості проекту). Перший крок - запустити команду 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'
Enter fullscreen mode Exit fullscreen mode
  • завантажуємо залежності тестового середовища
  7 ENV['RAILS_ENV'] ||= 'test'
  8 require 'spec_helper'
  9 require 'database_cleaner'
 10 require 'factory_bot'
 11 require 'pry-byebug'
Enter fullscreen mode Exit fullscreen mode
  • створюємо Application для роботи з rspec-rails (нажаль найбільшслабка частина)
 13 ContextsTestApplication = Class.new( ::Rails::Application )
 14 ::Rails.application = ContextsTestApplication.new
Enter fullscreen mode Exit fullscreen mode
  • під’єднуємось до бази даних
 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
Enter fullscreen mode Exit fullscreen mode
  • завантажуємо предметну область (спільні concerns в першу чергу оскільки немаємеханізму автозавантаження)
 24 %w[concerns contexts].each do |folder|
 25 Dir[File.expand_path( "../#{folder}/**/*.rb", __dir__ )].each { |f| require f }
 26 end
Enter fullscreen mode Exit fullscreen mode
  • завантаження файлів initializer/support
 28 Dir['./spec/support/*.rb'].each { |f| require f }
 29
 30 RSpec.configure do |config|
Enter fullscreen mode Exit fullscreen mode

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

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

  • завантажуємо 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'
Enter fullscreen mode Exit fullscreen mode
  • створюємо Application для rspec-rails та завантажуємо routes
  8 RepresentationsTestApplication = Class.new( ::Rails::Application )
  9 ::Rails.application = RepresentationsTestApplication.new
 10 require_relative '../routes'
Enter fullscreen mode Exit fullscreen mode
  • завантажуємо залежності
 12 require 'pry-byebug'
 13 require 'uuid'
Enter fullscreen mode Exit fullscreen mode
  • завантажуємо код представлення (спільні concerns в першу чергу оскільки немаємеханізму автозавантаження)
 15 %w[concerns controllers decorators].each do |folder|
 16 Dir[File.expand_path( "../#{folder}/**/*.rb", __dir__ )].each { |f| require f }
 17 end
Enter fullscreen mode Exit fullscreen mode

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

  • тести можуть включати лише юніт-тести
  • ми змушені залишатися всередині контексту при написанні тесту
  • завантаження/перезавантаження середовища є швидким (завантаження файлів тривало 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
Enter fullscreen mode Exit fullscreen mode

Приклад проекту також включає Git hooks які будуть встановленіна ваш проект якщо ви запустите ./bin/setup і будуть автоматично виконані передта після того як ви зробити commit. pre-commit hook запускає rubocop для перевіркивсіх змін які будуть включені в commit, post-commit hook надає вам можливість запускатиrails_best_practices, reek, brakeman і mutant для вашого коду.

Підсумок

Мені дуже подобається гнучкість даної архітектури - при потребі можна заізолювати будь-якучастину коду і ставитись до неї як до незалежного unit. В той же час вона, здебільшого,використовує Rails API - тож ми не боремося з Rails, скоріше це ще один спосіб для організаціїкоду. Мені кортить випробувати дану архітектуру з більш складними та legacy проектами - їїзастосування повинно бути доволі простим в обох випадках.

Посилання:

Top comments (0)