- Час: 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 проектами - їїзастосування повинно бути доволі простим в обох випадках.
Посилання:
Top comments (0)