Відомим антипаттерном для контролерів Ruby on Rails є надмірне використання логіки в контролерах. Тонкі контролери, багаті моделі (Lean Controllers, Rich Models) — це принцип проектування, що вказує на те, що більшість бізнес-логіки програми повинна бути реалізована в моделі, а контролери повинні бути якомога більш простими. Це робить код програми модульним, а бізнес-логіку — зручною для обслуговування та повторного використання.
Забруднені контролери створюють чотири основні проблеми для розробки програмного забезпечення:
- Надмірні обов'язки: Забруднені контролери часто виконують кілька ролей, порушуючи Принцип єдиної відповідальності.
- Обмежене повторне використання: Код стає менш придатним для повторного використання іншими API, що призводить до потенційної надмірності або повторення коду.
- Ускладнення тестування та відлагодження: Через переплетеність коду стає дедалі важче ізолювати окремі компоненти для тестування або відлагодження.
- Проблеми з обслуговуванням: Складна логіка в забруднених контролерах ускладнює додавання або зміну існуючого коду, іноді вимагаючи значних зусиль.
Принцип Тонкі контролери, багаті моделі застосовується також до резолверів GraphQL. Давайте розглянемо приклад.
Забруднені резолвери мутацій GraphQL у Ruby on Rails.
Розглянемо наступний приклад. Ми рефакторимо мутацію GraphQL, яка створює нове зображення та прикріплює його до продукту.
Мутація отримує product_id
та image
як вхідні дані та повертає нове image
, яке було створене, якщо мутація успішна. В іншому випадку вона повертає поле errors
.
Мутація може викликати помилки з різних причин. Потрібно виконати певні валідації перед вставкою даних у базу, а також провести інші операції, які вимагають взаємодії з базою даних. У наведеному нижче прикладі ми перевіряємо наявність продукту перед його вставкою і одночасно ловимо будь-які помилки таймауту при додаванні нового запису.
class ProductCreateImage < GraphQL::Schema::Mutation
argument(:product_id, ID, loads: Product, required: true)
argument(:image, CreateImageInput, required: true)
field(:image, Image, null: true, description: 'The newly created image.')
field(:errors, [Errors], null: false)
def resolve(product_id:, image:)
unless image[:src_image] || image[:url]
return {
errors: { image: I18n.t('src_image_present'), code: Image::ErrorCodes::SrcImageErrorCode }
}
end
product = Product.find_by(id: product_id)
unless product
return { errors:
{ product_id: I18n.t('product_does_not_exist'), code: Image::ErrorCodes::PRODUCT_DOES_NOT_EXIST } }
end
begin
ImagesLock.acquire(product_id: product.id) do
new_image = Image.create(new_image, product.id, image)
if new_image
image.create_default_record
product.touch
else
errors = new_image.errors
end
end
rescue Image::PerProductLimitEnforcer::ProductLimitExceeded => e
errors = GraphApi::UserError.errors_from_hash(image: e.message)
rescue ProductLock::LockAcquireTimeoutError
errors = {
base: I18n.t('image_cannot_be_modified'), code: Image::ErrorCodes::MEDIA_CANNOT_BE_MODIFIED
}
rescue ImageThrottle::DailyLimitExceeded
errors = {
image: I18n.t('image_throttle_error'), code: Media::ErrorCodes::IMAGE_THROTTLE_EXCEEDED
}
end
end
end
Завдання: Спробуйте проаналізувати та рефакторити цей код перед тим, як продовжити.
Давайте проаналізуємо код!
Мутація виконує три основні дії:
- Валідація: Вона здійснює запит до Active Record для перевірки наявності продукту. Валідує, що або
SRC
, абоURL
присутні. Також викликається кількаrescues
, якщо вставка до бази даних не вдалася. - Збереження в базі даних: Під час цього кроку виконується кілька операцій: отримання блокування для бази даних, створення запису за допомогою Active Record і виконання операції
update
черезproduct.touch
.
Усе це обгорнуте в блок begin/rescue. - Управління помилками бази даних: Кожен блок rescue повертає помилку з її GraphQL кодом помилки.
Давайте рефакторимо код!
Перший крок — винести логіку збереження та валідації в окремі методи, що робить код більш інкапсульованим, дотримуючись принципу Єдиної відповідальності.
class ProductCreateImage < GraphQL::Schema::Mutation
argument :product_id, ID, loads: Product, required: true
argument :image, CreateImageInput, required: true
field :image, Image, null: true, description: 'The newly created image.'
field :errors, [Errors], null: false
def resolve(product_id:, image:)
product = Product.find_by(id: product_id)
errors = errors_from_input(image, product)
return { errors: } if errors
image_result = create_image(image, product)
if image_result.errors
{ errors: image_result.errors }
else
{ image: image_result.image }
end
end
# Нове!
def create_image(image_input, product)
begin
ImagesLock.acquire(product_id: product.id) do
new_image = Image.create(new_image, product.id, image_input)
if new_image
image.create_default_record
product.touch
else
errors = new_image.errors
end
end
rescue Image::PerProductLimitEnforcer::ProductLimitExceeded => e
errors = GraphApi::UserError.errors_from_hash(image: e.message)
rescue ProductLock::LockAcquireTimeoutError
errors = {
base: I18n.t('image_cannot_be_modified'), code: Image::ErrorCodes::MEDIA_CANNOT_BE_MODIFIED
}
rescue ImageThrottle::DailyLimitExceeded
errors = {
image: I18n.t('image_throttle_error'), code: Image::ErrorCodes::IMAGE_THROTTLE_EXCEEDED
}
end
{ errors:, image: new_image }
end
# Нове!
def errors_from_input(image_input, product)
unless image_input[:src_image] || image_input[:url]
return {
errors: {
image: I18n.t('src_image_present'), code: Image::ErrorCodes::SrcImageErrorCode
}
}
end
if product
{}
else
{
errors: { product_id: I18n.t('product_does_not_exist'), code: Image::ErrorCodes::PRODUCT_DOES_NOT_EXIST }
}
end
end
end
Чудово! Тепер метод resolve став значно простішим і компактнішим!
Тепер… чи повинна логіка бази даних бути всередині контролера? Чи має контролер знати, що потрібно блокувати доступ до бази даних і про всі помилки, які можуть виникнути? Ні! Давайте перемістимо цю логіку до сервісу ProductImageCreateService
:
class ProductCreateImage < GraphQL::Schema::Mutation
argument :product_id, ID, loads: Product, required: true
argument :image, CreateImageInput, required: true
field :image, Image, null: true, description: 'The newly created image.'
field :errors, [Errors], null: false
def resolve(product_id:, image:)
# Валідація введених даних
product = Product.find_by(id: product_id)
errors = errors_from_input(image, product)
return { errors: } if errors
# Виклик сервісу для зміни даних
image_result = ProductImageCreateService.call(image, product)
# Обробка результату від сервісу та мапування його в API відповідь
if image_result.errors
{ errors: image_result.errors }
else
{ image: image_result.image }
end
end
def errors_from_input(image_input, product)
# ...
end
end
Здається набагато краще! Тепер код більше не зв'язаний між собою, і інші частини системи можуть повторно використовувати цей код. Резолвер GraphQL містить менше бізнес-логіки, і турботи чітко розділені. Усе це зроблено в три прості кроки:
- Розбір і валідація введених даних
- Виклик сервісу для зміни даних
- Мапування результату від сервісу в API відповідь
Інші міркування
Сервіс не повинен знати про API рівень, оскільки він має належати до рівня бізнес-логіки. Наприклад, у нас може бути більше ніж один API. Може бути REST API, який виконує подібні дії, або інші мутації, які потребують прикріплення зображення до продукту.
Витягнення нашої бізнес-логіки в сервіс дозволяє повторно використовувати ту саму основну логіку.
Ми можемо використовувати ін'єкцію залежностей (Dependency Injection), щоб взаємодіяти один з одним. Наприклад, уявімо, що повідомлення про помилки в REST і GraphQL API обробляються по-різному. В такому разі ми все одно можемо використовувати спільний клас сервісу, але за допомогою ін'єкції залежностей передавати об'єкт для обробки помилок у сервіс. Цей об'єкт буде відображати помилки, які виникають у сервісі, на ті, що повинні повернутись в API. Ми можемо побачити це на наступній ілюстрації:
Використовуйте ін'єкцію залежностей (Dependency Injection), щоб обробляти помилки, які викликає сервіс.
Перезавантажені резолвери GraphQL так само погані, як і перезавантажені контролери. Стрункі резолвери GraphQL та багаті моделі роблять код більш масштабованим і дозволяють розділяти турботи. Шаблон сервісу та ін'єкція залежностей (Dependency Injection) — це чудові рішення для досягнення цієї мети.
Дякую за прочитання! Дайте знати, що ви думаєте, на X ignaciochiazzo
Перекладено з: Lean GraphQL Resolvers with Rich Models in Ruby: A Clean Architecture Approach