Refatorando Aplicações Rails - Form Objects

Não é surpresa que o Ruby on Rails revolucionou a maneira como desenvolvemos aplicações web, o que antes era uma grande mistura de primos e conceitos foi trocado pelo convention over configuration e um modelo bem simplificado de MVC.

Acredito que esse MVC simplificado foi uma das key-features para o sucesso do Rails, é muito fácil criar uma nova aplicação e colocá-la em produção num piscar de olhos. O problema é que com o tempo só a divisão entre models, views e controllers já não é suficiente, a sua aplicação deixou de ser um CRUD, ela tem muito mais responsabilidades.

Mas, então o Rails não serve pra aplicações grandes? Não é bem assim.

O que se torna problemático conforme uma aplicação ganha maturidade e complexidade são alguns dos patterns simplificados que são implementados e padronizados pelo Rails, isso não quer dizer que Rails é ruim, ele tem suas qualidades e defeitos, como qualquer outro framework. Mas o mais legal é que podemos continuar desenvolvendo com Rails e aproveitar todas as coisas boas e o conhecimento que já possuímos da plataforma, basta aplicarmos alguns patterns diferentes que possam separar melhor as responsabilidades de uma aplicação mais complexa.

Seu model se parece com isso? Não precisa ficar envergonhado :D
Todo mundo já fez ou acaba fazendo isso.

# app/models/customer.rb
class Customer < ActiveRecord::Base  
  has_many :addresses

  serialize :device_ids

  attr_accessor :validate_password, :validate_device,
    :device_id, :accept_notifications

  accepts_nested_attributes_for :addresses, allow_destroy: true,
    reject_if: ( :all_blank || proc { |address_attributes| 
    Address.already_exists?(address_attributes) } )

  validates :name, presence: true
  validates :cellphone, presence: true
  validates :email, presence: true, email: true, uniqueness: true
  validates :password, presence: true, if: :validate_password
  validates :password, 
    confirmation: { message: 'Senhas não conferem' }, 
    length: { within: 6..20, allow_blank: true }, 
    if: :validate_password
  validates :device_id, presence: true, if: :validate_device

  # and a lot more stuff over here...

end  

Veja que estamos criando algumas variáveis apenas para decidir se as validações devem ser executadas ou não. No começo parece bem inofensível, mas conforme sua aplicação cresce isso se torna um verdadeiro pé-no-saco. Outra coisa a ser notada é o uso do accepts_nested_attributes_for, em alguns casos simples ele até que pode ser útil, mas o model não deveria ser responsável em montar os campos de um :address para o :customer, estamos acoplando lógica que será utilizada em uma view diretamente num model.

Na falta de outros layers de abstração a maioria das coisas acabam sendo colocadas junto aos models. Preciso de um método para exibir nome e sobrenome juntos? Manda pro model. Mais validações? Vai pro model. Queries complexas? Model! Callbacks e notificações? Model!

E criando nossos models dessa maneira também teremos que fazer algumas doideiras nos controllers, é bem capaz de acabar com algo assim, senão for até pior:

    def create
      @customer = Customer.new(customer_params)
      @customer.validate_password = true
      @customer.validate_device = true
      @customer.save
      respond_with @customer
    end

Você sempre terá que se lembrar quais validações deseja executar ou não, e se em outro lugar eu precisar validar outra coisa? Lá vamos nós adicionar uma nova variável :validate_something ao nosso model e um par de IFs aqui e ali. Com o tempo dar manutenção e adicionar novas features numa codebase assim se torna bem complicado :(

Form Objects

Pra amenizar um pouco essa situação podemos utilizar form objects. Eles serão os responsáveis pelas lógicas de validação, agregação, formatação e tratamento de dados.

Como podemos utilizar form objects?

Uma forma simples, mas um pouco mais manual é seguir o exemplo do Bryan Helmkamp.

Particularmente, prefiro utilizar a gem reform, do Nick Sutterer, ela traz algumas coisinhas a mais, que facilitam nossa vida na hora de mover toda aquela bagunça do model para um lugar melhor.

Direto da documentação da gem:

Form objects decoupled from your models.

Reform gives you a form object with validations and nested setup of models. It is completely framework-agnostic and doesn't care about your database.

Although reform can be used in any Ruby framework, it comes with Rails support, works with simple_form and other form gems, allows nesting forms to implement has_one and has_many relationships, can compose a form from multiple objects and gives you coercion.

Mãos ao teclado

Digamos que nossa aplicação possa criar customers de duas maneiras, via API e através do portal de administração. No caso da criação de um customer via API é necessário uma senha, bem como um :device_id, e o cadastro inicial é feito sem nenhum :address. Já no portal administrativo não definimos a senha, nem o :device_id, mas já podemos adicionar um ou mais :addresses diretamente ao customer.

Api::Customer::CreateForm

Nosso form object para a criação de um customer via API vai ficar assim:

# app/forms/api/customer/create_form.rb
class Api::Customer::CreateForm < Reform::Form

  model :customer

  property :name
  property :email
  property :cellphone
  property :device_id, virtual: true
  property :accept_notifications, virtual: true
  property :password
  property :password_confirmation, virtual: true

  validates :name, presence: true
  validates :email, presence: true, 
    email: true, uniqueness: true
  validates :cellphone, presence: true, 
    uniqueness: true, phony_plausible: true
  validates :device_id, presence: true
  validates :password, presence: true, 
    confirmation: { message: 'Senhas não conferem' }, 
    length: { within: 6..20, allow_blank: true }

  # Coercion

  def cellphone=(value)
    if Phony.plausible? value
      super Phony.normalize value
    else
      super value
    end
  end

  # Display

  def cellphone
    if Phony.plausible? value
      Phony.format value
    else
      value
    end
  end

  # Persistence

  def persist(params)
    if validate(params)
      sync_models
      model.set_device(device_id, 
        accept_notifications: accept_notifications)
      save
    end
  end
end  

Definimos as propriedades do CreateForm, ou seja, os atributos que iremos utilizar e logo depois as validações necessárias para os mesmos. Uma coisa interessante que ganhamos com a utilização dessa classe com Reform é que não precisamos mais fazer um white-list de params lá no controller, pois quaisquer parâmetros diferentes daqueles definidos via property serão ignorados.

O método cellphone= é utilizado para fazer a transformação de um número, com isso podemos receber um valor como: +55 21 12345-1234 e normalizá-lo para 5521123451234 antes de fazer as validações ou mandar salvar no banco.

Já o método cellphone é utilizado para formatar o número para exibição.

E como podemos usar esse novo form object lá no nosso controller? Fácil, fácil!

# app/controllers/api/customers_controller.rb
class Api::CustomersController < Api::BaseController

  def create
    @form = Api::Customer::CreateForm.new(Customer.new)
    @form.persist(params)
    respond_with @form
  end

  # other actions
end  

E na view, utilizando jbuilder para responder com json:

# app/views/api/customers/create.json.jbuilder
json.extract! @form, :id, :name, :email, :cellphone  

Manage::Customer::CreateForm

E o nosso form object para a criação de um customer pelo painel administrativo será:

# app/forms/manage/customer/create_form.rb
class Manage::Customer::CreateForm < Reform::Form

  model :customer

  property :name
  property :email
  property :cellphone

  validates :name, presence: true
  validates :email, presence: true, 
    uniqueness: true, email: { allow_blank: true }
  validates :cellphone, presence: true, 
    uniqueness: true, phony_plausible: true

  collection :addresses, skip_if: :all_blank,
    prepopulate: ->(options) { self.addresses << Address.new },
    populate_if_empty: Address do

      property :city
      property :district
      property :number
      property :state
      property :street
      property :zip_code

      validates :city, presence: true
      validates :district, presence: true
      validates :number, presence: true,
        uniqueness: { scope: [:state, :city, :district, :street] }
      validates :state, presence: true
      validates :street, presence: true
      validates :zip_code, length: { is: 8 }, 
        numericality: { only_integer: true }, 
        allow_blank: true

      # Coercion

      def zip_code=(value)
        super value.to_s.gsub(/\D/, "")
      end
  end

  # Coercion

  def cellphone=(value)
    if Phony.plausible? value
      super Phony.normalize value
    else
      super value
    end
  end

  # Display

  def cellphone
    if Phony.plausible? value
      Phony.format value
    else
      value
    end
  end
end  

Esse form object é bem parecido com o que criamos para Api::Customer::CreateForm, vários dos atributos se repetem, mas veja que aqui não utilizamos :password nem :device_id, ao invés disso temos o collection :addresses, que informa ao reform que esse customer pode receber também parâmetros de um ou mais endereços.

Nosso controller:

# app/controllers/manage/customers_controller.rb
class Manage::CustomersController < Manage::BaseController

  def new
    @form = Manage::Customer::CreateForm.new(Customer.new)
    @form.prepopulate!
  end

  def create
    @form = Manage::Customer::CreateForm.new(Customer.new)
    if @form.validate(params)
      @form.save
      redirect_to edit_manage_customer_path(@form)
    end

    @form.prepopulate!
    flash[:alert] = 'Ops! Ocorreu um erro.'
    render action: :new
  end

  # other actions
end  

O método prepopulate! do reform permite a criação de novos endereços, seria como se fizéssemos algo assim:

@customer = Customer.new
@customer.addresses.build

Não é necessário mostrar as views aqui, elas podem ser utilizadas normalmente, como você faria quando chamava o form_for diretamente com um model, mas agora teremos: form_for @form do ... end.

Dry it Up!

Como podemos ver, tanto o Api::Customer::CreateForm quanto o Manage::Customer::CreateForm possuem algumas propriedades e validações em comum, pra deixar de ficar repetindo essas coisas vamos criar um classe base que será utilizada pelas outras duas, ou até alguma nova classe que desejamos adicionar no futuro, tal qual Customer::UpdateForm ou algo assim.

Customer::Form

# app/forms/customer/form.rb
class Customer::Form < Reform::Form

  model :customer

  property :name
  property :email
  property :cellphone

  validates :name, presence: true
  validates :email, presence: true, 
    uniqueness: true, email: { allow_blank: true }
  validates :cellphone, presence: true, 
    uniqueness: true, phony_plausible: true

  # Coercion

  def cellphone=(value)
    super normalize_as_phone(value)
  end

  # Display

  def cellphone
    format_as_phone(super)
  end

  private

    def normalize_as_phone(value)
      if Phony.plausible? value
        Phony.normalize value
      else
        value
      end
    end

    def format_as_phone(value)
      if Phony.plausible? value
        Phony.format value
      else
        value
      end
    end

Api::Customer::CreateForm.rb

# app/forms/api/customer/create_form.rb
class Api::Customer::CreateForm < Customer::Form

  property :device_id, virtual: true
  property :accept_notifications, virtual: true
  property :password
  property :password_confirmation, virtual: true

  validates :device_id, presence: true
  validates :password, presence: true, 
    confirmation: { message: 'Senhas não conferem' }, 
    length: { within: 6..20, allow_blank: true }

  # Persistence

  def persist(params)
    if validate(params)
      sync_models
      model.set_device(device_id, 
        accept_notifications: accept_notifications)
      save
    end
  end
end  

Manage::Customer::CreateForm

# app/forms/manage/customer/create_form.rb
class Manage::Customer::CreateForm < Customer::Form

  collection :addresses, skip_if: :all_blank,
    prepopulate: ->(options) { self.addresses << Address.new },
    populate_if_empty: Address do

      property :city
      property :district
      property :number
      property :state
      property :street
      property :zip_code

      validates :city, presence: true
      validates :district, presence: true
      validates :number, presence: true,
        uniqueness: { scope: [:state, :city, :district, :street] }
      validates :state, presence: true
      validates :street, presence: true
      validates :zip_code, length: { is: 8 }, 
        numericality: { only_integer: true }, 
        allow_blank: true

      # Coercion

      def zip_code=(value)
        super value.to_s.gsub(/\D/, "")
      end
  end
end  

Com isso foi possível remover um bocado de código repetitivo e desnecessário! E sabe qual a melhor parte? Olha só como ficou nosso Customer model:

# app/models/customer.rb
class Customer < ActiveRecord::Base  
  has_many :addresses

  serialize :device_ids

  attr_accessor :device_id, :accept_notifications

  # other stuff...
end  

Podemos remover todas as validações e aquele accepts_nested_attributes_for horrendo. o/ #winwin

A utilização de form objects é apenas um dos conceitos que podemos utilizar para refatorar nossas aplicações Rails, nas próximas semanas vou publicar mais artigos artigos explicando outras maneiras.

Vocês já utilizavam form objects? Como? Dúvidas? Sugestões? Vamos ampliar a discussão ai nos comentários.

Obrigado pelo seu tempo e até a próxima.

Ralf Schmitz Bongiolo

Read more posts by this author.

Amazonian Rainforest