Стрункі резолвери GraphQL з багатими моделями в Ruby: підхід до чистої архітектури

Відомим антипаттерном для контролерів Ruby on Rails є надмірне використання логіки в контролерах. Тонкі контролери, багаті моделі (Lean Controllers, Rich Models) — це принцип проектування, що вказує на те, що більшість бізнес-логіки програми повинна бути реалізована в моделі, а контролери повинні бути якомога більш простими. Це робить код програми модульним, а бізнес-логіку — зручною для обслуговування та повторного використання.

Забруднені контролери створюють чотири основні проблеми для розробки програмного забезпечення:

  1. Надмірні обов'язки: Забруднені контролери часто виконують кілька ролей, порушуючи Принцип єдиної відповідальності.
  2. Обмежене повторне використання: Код стає менш придатним для повторного використання іншими API, що призводить до потенційної надмірності або повторення коду.
  3. Ускладнення тестування та відлагодження: Через переплетеність коду стає дедалі важче ізолювати окремі компоненти для тестування або відлагодження.
  4. Проблеми з обслуговуванням: Складна логіка в забруднених контролерах ускладнює додавання або зміну існуючого коду, іноді вимагаючи значних зусиль.

Принцип Тонкі контролери, багаті моделі застосовується також до резолверів GraphQL. Давайте розглянемо приклад.

pic

Забруднені резолвери мутацій 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

Завдання: Спробуйте проаналізувати та рефакторити цей код перед тим, як продовжити.

Давайте проаналізуємо код!

Мутація виконує три основні дії:

  1. Валідація: Вона здійснює запит до Active Record для перевірки наявності продукту. Валідує, що або SRC, або URL присутні. Також викликається кілька rescues, якщо вставка до бази даних не вдалася.
  2. Збереження в базі даних: Під час цього кроку виконується кілька операцій: отримання блокування для бази даних, створення запису за допомогою Active Record і виконання операції update через product.touch.
    Усе це обгорнуте в блок begin/rescue.
  3. Управління помилками бази даних: Кожен блок 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 містить менше бізнес-логіки, і турботи чітко розділені. Усе це зроблено в три прості кроки:

  1. Розбір і валідація введених даних
  2. Виклик сервісу для зміни даних
  3. Мапування результату від сервісу в API відповідь

Інші міркування

Сервіс не повинен знати про API рівень, оскільки він має належати до рівня бізнес-логіки. Наприклад, у нас може бути більше ніж один API. Може бути REST API, який виконує подібні дії, або інші мутації, які потребують прикріплення зображення до продукту.
Витягнення нашої бізнес-логіки в сервіс дозволяє повторно використовувати ту саму основну логіку.

Ми можемо використовувати ін'єкцію залежностей (Dependency Injection), щоб взаємодіяти один з одним. Наприклад, уявімо, що повідомлення про помилки в REST і GraphQL API обробляються по-різному. В такому разі ми все одно можемо використовувати спільний клас сервісу, але за допомогою ін'єкції залежностей передавати об'єкт для обробки помилок у сервіс. Цей об'єкт буде відображати помилки, які виникають у сервісі, на ті, що повинні повернутись в API. Ми можемо побачити це на наступній ілюстрації:

pic

Використовуйте ін'єкцію залежностей (Dependency Injection), щоб обробляти помилки, які викликає сервіс.

Перезавантажені резолвери GraphQL так само погані, як і перезавантажені контролери. Стрункі резолвери GraphQL та багаті моделі роблять код більш масштабованим і дозволяють розділяти турботи. Шаблон сервісу та ін'єкція залежностей (Dependency Injection) — це чудові рішення для досягнення цієї мети.

Дякую за прочитання! Дайте знати, що ви думаєте, на X ignaciochiazzo

Перекладено з: Lean GraphQL Resolvers with Rich Models in Ruby: A Clean Architecture Approach

Leave a Reply

Your email address will not be published. Required fields are marked *