Остаточний посібник з подолання технічного боргу в проєкті на Rails

pic

Як вижити в старій монолітній Ruby on Rails програмі за допомогою Rails Engines. Для початку, ця стаття не про витягування модулів з основного додатку в Rails Engine, а про використання потужності Rails Engine для того, щоб дозволити вам переосмислити і переписати кодову базу, яка потребує цього.

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

Припустимо, що стартап тільки що отримав фінансування і починає наймати нових співробітників, вам належить організувати процеси, наприклад, налаштувати Rubocop, Yard Doc, чистий набір тестів, прискорити доставку і так далі.

Rubocop — це чудовий інструмент, але він погано працює в існуючому проекті і сильно зашкодить вашій git історії, яку вам дійсно потрібно зберігати, щоб зрозуміти і знайти, чому деякі частини коду були додані.

Додавати документацію до коду в існуючому проекті — це болісно, та й часу на це у вас все одно немає.

Тож давайте почнемо з нового свіжого проекту! Як вам така ідея?

Щоб ця стаття була справді корисною для тих, хто хоче застосувати її в своєму проекті, давайте розглянемо конкретний приклад.

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

{{domain}}/organizations/{{organization_id}}/projects/{{project_id}}/reports/{{report_id}}/notes

Представлення може виглядати так: Tesla > Model 3 > 2019 Q4 Shanghai Report, щоб мати загальне уявлення.

Окрім старого наївного та жорсткого підходу цього CRUD REST API, ви доходите висновку, що проект не повинен бути обов'язковим, або скажімо, що це вимога бізнесу: клієнти скаржаться, що вони хочуть додавати звіти безпосередньо до організацій, оскільки це спрощує весь робочий процес. Вони можуть відсортувати їх пізніше, або навіть не сортувати взагалі. Вони можуть захотіти додати один і той самий звіт до різних розділів проекту і так далі... Це перша функція, яку вас просять реалізувати.

Згодом ви вважаєте, що моделі проект і нота використовуються як фільтри/теги і їх можна легко переробити в поліморфну модель, більше чи менше, але поки що ви не хочете торкатися їх.

Отже, ми переміщаємо моделі користувач, організація та звіт до Rails Engine і залишаємо моделі проект та нота в основному додатку Rails для подальшого видалення. Таким чином, ми досліджуємо, як змусити це працювати разом.

Ви думаєте, хороший момент для рефакторингу цього коду і поведінки, хочете ввести JSON API і мати можливість запитувати звіти ось так:

{{domain}}/v1/organizations/{{organization_id}}/reports?include=project&filter[project_name_in]=Annual-Report-2017,Annual-Report-2018&fields[project]=name

Це дійсно виглядає значно краще, правда? І додає багато гнучкості до API.

Основний додаток

Ця частина є опційною. Ви можете пропустити цей розділ, якщо не хочете слідувати покроково.
Весь код знаходиться тут, ви можете переглянути коміти, які покажуть детальні кроки, які я зробив, та/або слідувати README.

Версія Ruby 2.6.5

Я пропускаю все, що мені не потрібно.

rails _6.0.1_ new annotable_app --api \  
 --database=postgresql \  
 --skip-action-mailer \  
 --skip  
 --skip-javascript \  
 --skip-turbolinks \  
 --skip-test \  
 --skip-webpack-install

Мені також подобається завантажувати лише ті залежності, які я збираюсь використовувати, замість завантаження всього стека Rails.

# Gemfile# gem 'rails', '~> 6.0.1'gem 'actionpack', '~> 6.0.1'  
gem 'activemodel', '~> 6.0.1'  
gem 'activerecord', '~> 6.0.1'  
gem 'activesupport', '~> 6.0.1'  
gem 'railties', '~> 6.0.1'

Ви також можете переглянути свій стек проміжного програмного забезпечення (middleware) і видалити те, що ви не використовуєте, завжди корисно повторно перевірити стек проміжного програмного забезпечення, щоб тримати це в голові.

bin/rails middleware

І видаліть все, чим не будете користуватися

# config/application.rbconfig.middleware.delete ActionDispatch::Cookies  
config.middleware.delete Rack::Sendfile  
config.middleware.delete ActionDispatch::Static  
config.middleware.delete ActiveSupport::Cache::Strategy::LocalCache::Middleware  
config.middleware.delete ActionDispatch::ActionableExceptions  
config.middleware.delete ActionDispatch::Callbacks

Zeitwerk пропонує вести журнал своєї діяльності, оскільки ми будемо покращувати функціональність Engine через перевизначення моделей та контролерів у основному додатку, було б непогано слідкувати за тим, що робить autoload, ми зрозуміємо чому пізніше.

# config/initializers/autoloader.rb  
Rails.autoloaders.logger = Rails.logger

Оскільки ми збираємось генерувати деякі ресурси, зробіть собі велику послугу і перевизначте всі шаблони, які вам потрібні, стандартні шаблони Rails, шаблони RSpec, якщо ви плануєте використовувати RSpec.

Це не зовсім очевидно, тому якщо у вас виникнуть труднощі, не соромтесь звернутись до моєї відповіді щодо цієї теми на stackoverflow.com.

Або створіть свій власний генератор, якщо це необхідно

bin/rails generate generator — help

ПРИМІТКА: Якщо ви використовуєте RSpec, генератор не працює, перегляньте моє PR https://github.com/rspec/rspec-rails/pull/2217

Як тільки ваш додаток налаштований правильно, ви можете швидко створити його за допомогою наступних команд:

bin/rails generate scaffold organization name:string  
bin/rails g scaffold user name:string email:string organization:references  
bin/rails g scaffold project name:string organization:references  
bin/rails g scaffold report name:string content:text project:references  
bin/rails g scaffold note title:string content:text report:references

Rails Engine

Ця частина також є опційною. Ви можете пропустити цей розділ, якщо не хочете слідувати покроково. Весь код знаходиться тут, ви можете переглянути коміти, які покажуть детальні кроки, які я зробив, та/або слідувати README.

Насамперед, потрібно знайти підходящу назву. Якщо назва Rails App не так важлива, оскільки вона майже не впливає на код, то це зовсім інша історія, коли йдеться про назву Rails Engine. Це дуже важливе питання, оскільки воно буде присутнє по всьому коду. Тому обирайте коротку та значущу назву. Для нашого прикладу ми обрали Annotable, оскільки додаток призначений для анотацій звітів.

Ми зацікавлені тільки в Rails API-Only.
Отже, я видалю непотрібні залежності.

rails _6.0.1_ plugin new annotable --mountable --api \  
 --database=postgresql \  
 --skip  
 --skip-active-storage \  
 --skip-puma \  
 --skip-action-cable \  
 --skip-sprockets \  
 --skip-javascript \  
 --skip-turbolinks \  
 --skip-test \  
 --dummy-path=spec/dummy

У файлі annotable.gemspec ми можемо змінити відповідно

# annotable.gemspec# spec.add_dependency "rails", "~> 6.0.1"# Завантажуйте тільки те, що вам потрібно  
spec.add_dependency "actionpack", "~> 6.0.1"  
spec.add_dependency "activemodel", "~> 6.0.1"  
spec.add_dependency "activerecord", "~> 6.0.1"  
spec.add_dependency "activesupport", "~> 6.0.1"  
spec.add_dependency "activejob", "~> 6.0.1"  
spec.add_dependency "railties", "~> 6.0.1"

Якщо, як і я, ви віддаєте перевагу Rspec замість Testunit, встановіть його, але майте на увазі, що rspec-rails не змінює конфігурацію генератора в Rails Engine так, як це відбувається в Rails App, вам потрібно явно їх визначити.

# lib/annotable/engine.rbconfig.generators do |g|  
 g.test_framework :rspec, fixture: true  
 g.fixture_replacement :fabrication  
 g.api_only = true  
 g.orm :active_record, primary_key_type: :uuid  
 g.templates << File.expand_path('../templates', __dir__)  
end

Тут ми визначаємо всі генератори, які нам потрібні. Зверніть увагу, що потрібно явно додати директорію templates для перевизначення шаблонів, оскільки Rails App шукає в цій директорії автоматично, але не в Rails Engine.

Просто щоб побачити, як далеко можна змішувати все між Rails Engine та Rails App, я вибрав інший генератор Fixture, пізніше ми побачимо, як це організувати в основному додатку.

Оскільки план полягає в повному переписуванні додатку, ми не хочемо залишатись на класичному ID як послідовності цілих чисел, а використовувати UUID, додаючи Rubocop і YardStick на самому початку, що робить все набагато зручнішим, я вже пробував це на існуючому проекті, і це дійсно не здійсненно.

Для Rails JSON API існує кілька різних реалізацій, я використаю цю jsonapi.rb, яка є простою та досить потужною завдяки Ransack.

{{domain}}/annotable/organizations/{{organization_id}}/reports?filter[name_cont]=Annual-Report

Перегляньте деталі комітів, щоб побачити, як все налаштовано, але з уже встановленими шаблонами це в основному зводиться до виклику генераторів для трьох моделей, які нам потрібні.
Організація, Користувач, Звіт.

Підключення Rails Engine

Тепер ми дійшли до цікавої частини — як підключити наш Rails Engine до Rails додатку та зробити так, щоб усе працювало плавно.

Тепер повернемося до нашого Rails додатку.

Ми підключаємо Rails Engine, це досить просто і безпосередньо.

Додайте гем у ваш Gemfile

# Gemfile  
gem ‘annotable’, path: '../annotable' # локальна розробка

Ми монтуємо API як V1, щоб відокремити нову поведінку від старої.

# config/routes.rbRails.application.routes.draw do  
 resources :users  
 resources :organizations do  
 resources :projects do  
 resources :reports do  
 resources :notes  
 end  
 end  
 end mount Annotable::Engine, at: 'v1'  
end

Ми копіюємо міграції Rails Engine до нашого Rails додатку

bin/rails railties:install:migrations

На цьому етапі ми можемо запустити сервер і побачити правильну відповідь з endpoint

curl -X GET \  
 '[http://localhost:3000/v1/users](http://localhost:3000/v1/users){  
 "links": {  
 "self": "[http://localhost:3000/v1/users?include=organization](http://localhost:3000/v1/users?include=organization)",  
 "current": "[http://localhost:3000/v1/users?include=organization&page[number]=1](http://localhost:3000/v1/users?include=organization&page%5Bnumber%5D=1)"  
 },  
 "data": []  
}

Добре, тепер ми хочемо побачити наші дані, не порушуючи роботу старого API.

Існує кілька технік, як використовувати код Rails Engine у вашому додатку. Я рекомендую вам ознайомитись з розділом Покращення функціональності engine в посібнику Rails Engine, але для наших цілей ми використаємо техніку переписування класу, оскільки наша мета — повністю замінити наш Rails додаток, а не додавати функціональність. Ми дійсно хочемо повністю замінити стару систему.

Ми хочемо використовувати клас Annotable::Organization замість Organization.

Але пам’ятайте, модель Annotable::Organization не має жодних знань про проект і не має асоціації projects, тому ми збираємося додати це.

# app/models/organization.rbAnnotable::Organization.class_eval do  
 has_many :projects, class_name: 'Project', primary_key: :legacy_id  
end  
Organization = Annotable::Organization

Як ви, напевно, вже помітили, ми оголошуємо інший primary_key як посилання. Насправді, ми перш за все хочемо використовувати UUID в нашій системі, однак стара система використовує звичайний ID.
Ми хочемо, щоб кожна модель нашого Rails додатку продовжувала працювати з класичним ID, але нова модель використовувала UUID замість цього.

Для цього ми додаємо нове поле до моделі Annotable::Organization.

add_column :annotable_organizations, :legacy_id, :bigint

Ми відстежуємо значення послідовності ID так, як воно є.

organizations_id_seq_value = select_value("SELECT NEXTVAL('organizations_id_seq')")

Ми створюємо послідовність для нашого нового атрибута legacyid_.

sql = <<-SQL.squish  
 CREATE SEQUENCE public.annotable_organizations_legacy_id_seq  
 START WITH #{organizations_id_seq_value}  
 INCREMENT BY 1  
 NO MINVALUE  
 NO MAXVALUE  
 CACHE 1  
SQL  
execute(sql)

Нам потрібно змінити обмеження зовнішнього ключа.

sql = <<-SQL.squish  
 ALTER TABLE annotable_organizations ADD CONSTRAINT annotable_organizations_legacy_id_uniq UNIQUE (legacy_id)  
SQL  
execute(sql)remove_foreign_key :projects, :organizations

Дивіться коміти або код для всіх деталей.

Ми говоримо моделі project як знаходити нову модель Annotable::Organization.

class Project < ApplicationRecord  
 belongs_to(  
 :organization,  
 class_name: 'Annotable::Organization',  
 primary_key: :legacy_id,  
 required: true  
 )  
end

Тепер старий проект і нова організація, надана нашим Engine, працюють добре.

Перед тим, як ми зможемо запустити наш додаток, нам потрібно розширити код engine, щоб він дізнався про project.

# config/initializers/annotable.rbrequire 'annotable'require Annotable::Engine.root  
 .join('app/controllers/annotable/organizations_controller.rb')module OrganizationControllerCustomFields  
 module ClassMethods  
 def allowed_includes  
 (super.dup + [:projects]).freeze  
 end  
 def allowed_filterables  
 (super.dup + ['projects_name']).freeze  
 end  
 end def self.prepended(base)  
 class << base  
 prepend(ClassMethods)  
 end  
 end  
end  
Annotable::OrganizationsController  
 .prepend(OrganizationControllerCustomFields)

Пам'ятайте, що project не існує для нашого Engine, тому тепер ми можемо робити такого роду запити через V1 API.

curl -X GET \  
 '[http://localhost:3000/v1/organizations?include=users,projects&filter[name_eq]=Big](http://localhost:3000/v1/organizations?include=users%2Cprojects&filter%5Bname_eq%5D=Big) Corp&fields[organization]=name&fields[user]=email&fields[project]=name

Є ще один останній трюк: у режимі розробки autoload буде перезавантажувати все в директорії app/, як і ваш Engine, але нічого в config/. Тому, щоб уникнути наступної помилки в режимі розробки

NoMethodError (undefined method `projects' for #)

нам потрібно сказати autoload не перезавантажувати ані контролер організації, ані модель організації.

# config/initializers/zeitwerk.rbrequire 'annotable'require Annotable::Engine.root  
 .join('app/models/annotable/organization.rb')  
require Annotable::Engine.root  
 .join('app/serializers/annotable/organization_serializer.rb')  
require Annotable::Engine.root  
 .join('app/controllers/annotable/organizations_controller.rb')Rails.autoloaders.main.ignore(  
 Annotable::Engine.root.join('app/models/annotable/organization.rb')  
)  
Rails.autoloaders.main.ignore(  
 Annotable::Engine.root  
 .join('app/serializers/annotable/organization_serializer.rb')  
)  
Rails.autoloaders.main.ignore(  
 Annotable::Engine.root  
 .join('app/controllers/annotable/organizations_controller.rb')  
)require_dependency   
 Rails.root.join('app/models/organization.rb')require_dependency  
 Rails.root.join('config/initializers/annotable.rb')

Тепер ми можемо отримувати доступ як через старий API, так і через новий V1 API.

Наступним кроком буде позбутися від project і note для поліморфної моделі тегів, оскільки саме так ці моделі використовуються тут.
Якщо все буде готово, і команда фронтенду синхронізується з новим API, ви зможете прибрати весь старий код і працювати лише з новим кодом, і ви завершили!

Підсумок

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

Висновок

Це цікавий спосіб використання Rails Engine, однак я б рекомендував чітко визначити розклад для повного переписування, я наполегливо раджу не залишатись у такому змішаному стані назавжди, оскільки, безумовно, з'являться нові краєві випадки. Але це буде чудовий спосіб впоратися з поганою базою коду в більш безпечний спосіб.

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

Перекладено з: The definitive guide to tackle technical deb in the Rails project