ضمان سهولة الصيانة في تطبيقات Rails الكبيرة: الـ Fat Controllers بالطريقة الصحيحة
كان مجتمع Ruby on Rails محقًا في الابتعاد عن الـ fat controllers، لكننا فعلنا ذلك بالطريقة الخاطئة. أريد أن أعيد النظر في معمارية الـ fat controller بخبرة أكبر، وأن أحل المشكلة بطريقة أفضل

مررنا بعدة حقب في مجتمع Rails.
البداية: Fat Controllers
في البداية، كان معظم الكود يُكتب داخل الـ controllers. وتضخمت الـ controllers إلى درجة أصبح معها التنقل في الكود مستحيلًا. فبدأنا ننقل ذلك المنطق إلى الـ models لنبقي الـ controllers أصغر.
Skinny Controllers وFat Models
انتقلنا بعدها إلى حقبة الـ skinny controllers. وترك لنا ذلك fat models، فتكررت المشكلة نفسها لكن بصورة أسوأ: لم يعد الكود معزولًا لكل request على حدة كما كان مع الـ fat controllers، فاضطررنا إلى الحذر في إدارة الآثار الجانبية (side-effects) بشكل صحيح. فقد يكون model callback مرغوبًا في request ما وغير مرغوب في آخر، والأمر نفسه ينطبق على الـ validations وغيرها. كما بدأت الـ models تتضخم بدورها، مليئة بـ methods لا تخص إلا جزءًا من الـ requests التي يتعامل معها ذلك الـ model.
Service Objects
في مرحلة ما بعد التحوّل إلى الـ fat models، بدأنا نبني service objects، أي أصناف PORO وmodules لتشغيل المنطق المجرّد. ساعدنا ذلك على تقليص حجم الـ models إلى حجم يمكن إدارته، لكنه لم يحل المشكلة الأساسية فعلًا. وبالنظر إلى الوراء، أعتقد أنه لو بدأ المجتمع باستخدام service objects في وقت أبكر، لما انتقلنا إلى الـ fat models أصلًا. ففي ذلك الوقت، كان المجتمع يحاول جاهدًا الالتزام بعقلية controller أو model أو view، وكان أي شيء خارج ذلك أشبه بالمحظور.
مستقبل أفضل
هناك حلول معمارية متاحة، لكنها غالبًا أعقد بكثير من معمارية Rails التقليدية. وفي رأيي، ظهرت المشكلة الأساسية حين ابتعدنا عن الـ fat controllers. فقد أتاحت لنا الـ fat controllers منطقًا معزولًا خاصًا بكل request، أي بنقطة الدخول إلى تطبيقاتنا. المستخدم النهائي يبدأ دائمًا من controller action، فلماذا نكتب الكود وكأنه يبدأ من الـ model؟ لماذا لا نبدأ من الـ controller ونبني إلى الخارج؟ هذا ما بدأت أفعله.
Form Objects وService Objects
الـ form objects موجودة منذ وجود ActiveModel. وعندما بدأ الناس يروّجون لفكرة الـ form objects، كان الحديث عنها أساسًا بوصفها حالة استخدام للـ models الافتراضية، مثل نماذج التواصل أو مصادر البيانات الأخرى التي لا تُحفظ.
class ContactForm
include ActiveModel::Model
attr_accessor :name, :email, :message
validates :name, presence: true
validates :email, presence: true
validates :message, presence: true
def send_mail
return false unless valid?
ContactFormMailer.incoming_message(
name: name, email: email, message: message
).deliver_later
end
end
<em>language-ruby</em>
يمكننا استخدام التصميم نفسه لتمرير الـ params إلى الـ models لحفظها. ويتيح لنا ذلك القيام بأمور مثيرة للاهتمام كانت ستكون أعقد بكثير بطريقة أخرى. فربما لديكم صفحة تحفظ البيانات في أكثر من model:
class UpdateProfileForm
include ActiveModel::Model
attr_accessor :user_id, :name, :contact_number
def save
user = User.find(user_id)
user.update(name: name)
user.device.update(contact_number: contact_number)
end
end
<em>language-ruby</em>
والأهم من ذلك أننا نستطيع نقل كل تلك الـ validations والـ callbacks من الـ models إلى أماكنها الصحيحة. والسبب الرئيسي الذي دفعني إلى البحث عن حل لهذه المشكلة هو طبيعة تصميم تجربة المستخدم (UX) في التطبيقات الحديثة: فالتسجيل غالبًا ليس نموذجًا بسيطًا في صفحة واحدة، بل مجموعة من الخطوات. والـ validations لا تنطبق دائمًا على كل خطوة منها. على سبيل المثال، لنتخيل حالة لدينا فيها إجراء KYC يضم 50 حقلًا. الصفحة الأولى فيها 10 حقول، منها أداة لرفع صورة بطاقة الهوية وصورة سيلفي مع حمل البطاقة. وهذا شائع إلى حد ما في إجراءات KYC (اعرف عميلك)، ومن خبرتي غالبًا ما لا يُسمح بتخزين هذه الملفات لفترة طويلة. وفي أكثر من تطبيق عملت عليه، كان يجب التحقق من هذه الملفات في الخطوة الأولى، ثم تُرسل للمعالجة قبل أن نتحقق من الخطوة الثانية. ومع معمارية Rails MVC التقليدية، يصبح هذا فوضى حقيقية. وينتهي بكم الأمر إلى شيء كهذا
class User
attr_accessor :kyc_step, :updating_profile
validates :name, on: :create
validates :id_image, if: -> { kyc_step == '1' }
validates :document, if: -> { kyc_step == '2' }
validates :avatar, if: -> { kyc_step == '3' || updating_profile }
end
<em>language-ruby</em>
مع الـ form objects، يكون لكل action مجموعته الخاصة من الـ validations، فلا حاجة إلى هذه الـ attributes الافتراضية والشروط المعقدة. ويصبح الكود أسهل بكثير في القراءة والفهم. فكل منطق الـ controller action موجود داخل class واحد (باستثناء استدعاءات الـ service objects المجرّدة)، وهو معزول عن بقية قاعدة الكود. حالفني الحظ بكتابة تطبيقين من الصفر باستخدام الـ form objects، وفي المرتين شعرت بأن ذلك أفاد تصميم التطبيق، وسيثبت أنه نظام أبسط في الصيانة على المدى الطويل. كما طوّرت مكتبة صغيرة لهذا الغرض اسمها ActionForm، وسأتناولها بتفصيل أكبر في مقال آخر. تعود الـ models لتكون ORM models من جديد. تعرّف الـ associations والـ scopes، وهذا كل شيء. ويُنقل المنطق إلى أماكنه الصحيحة. لكل controller action نموذج (form) خاص به، وهو سهل جدًا في القراءة والـ debugging. بل يمكنكم حتى عرضه في الـ views.
النتيجة
class User < ApplicationRecord
belongs_to :account
has_many :devices
scope :active, -> { where.not(:activated_at: nil) }
end
# app/forms/user/registration_form.rb
class User < ApplicationRecord
class RegistrationForm
# more about this in another post,
# basically a collection of active support
# modules and some helpers
include ActiveForm::Form
attr_accessor :user
attribute :email
attribute :password
with_options presence: true do
validates :email, format: { with: Devise.EMAIL_REGEX }
validates :password, length: { minimum: 8, maximum: 128 }
end
def save
return false unless valid?
self.user = User.create(email: email, password: password)
end
end
end
# app/controllers/registrations_controller.rb
class RegistrationsController < ApplicationController
def new
@form = User::RegistrationForm.new
end
def create
@form = User::RegistrationForm.new(params: params[:form])
if @form.save
redirect_to welcome_path
else
render :new
end
end
end
# app/views/registrations/new.erb.html
<%= simple_form_for @form, url: registrations_path, method: :post do |f| %>
<%= f.input :email %>
<%= f.input :password %>
<%= f.submit %>
<% end %>


