В этой серии статей я соберу бОльшую часть своего опыта разработки на Ruby on Rails. Эти методики позволяют контролировать сложность и облегчают сопровождение проекта. Большинство из них придумал не я, и, по возможности, буду указывать источник.

Основная проблема проектов на RoR в том, что, как правило, всю логику пытаются уместить в модели, контроллеры и представления. Т.е. код находится только в моделях(ActiveRecord::Base), контроллерах, хэлперах и шаблонах. Такой подход приводит к печальным последствиям: код становится запутанным, долго делаются фичи, появляются регрессии, у разработчиков пропадает мотивация. В качестве примера можно посмотреть на исходники redmine.

Выход из данной ситуации довольно-таки очевидный. Будем делать проекты не на ruby on rails, а с использованием ruby on rails. Как это будет выглядеть: мы никуда не уходим от MVC и Rails, просто пересмотрим Model, View, Controller. Для начала расширим понятие модели. Модель — это не просто класс-наследник ORM. Модель — это вся бизнес логика приложения. Модель включает в себя: модели, сервисы, политики, репозитории, формы и другие элементы, которые я опишу далее. Так же расширим представления. Представления — это шаблоны, презентеры, хелперы, билдеры форм. Контроллеры — это все то, что связано с обработкой запросов: контроллеры, responders.

Кроме этих методик пригодятся знания по SOLID, ruby style guide, rails conventions, ruby object model, ruby metaprogramming, основным паттернам.

Helpers


Самый простой совет — используйте хэлперы. С помощью них удобно описывать частые операции:

module ApplicationHelper
  def menu_item(model, action, name, url, link_options = {})
    return unless policy(model).send "#{action}?"
    content_tag :li do
      link_to name, url, link_options
    end
  end
end

# _nav.haml
= menu_item current_user, :show, t(:show_profile), user_path(current_user)
= menu_item current_user, :edit, t(:edit_profile), edit_user_path(current_user)


Хэлпер menu_item отображает элемент меню в зависимости от политик. Можно расширить этот хэлпер, и он будет выделять активный элемент меню.

module ApplicationHelper
  def han(model, attribute)
    model.to_s.classify.constantize.human_attribute_name(attribute)
  end

  def show_attribute(model, attribute)
    value = model.send(attribute)
    return if value.blank?
    [
        content_tag(:dt, han(model.model_name, attribute)),
        content_tag(:dd, value)
    ].join.html_safe
  end
end

# show.haml
 = show_attribute user_presenter, :name
 = show_attribute user_presenter, :role_text
 = show_attribute user_presenter, :profile_image


Хэлпер show_attribute печатает название атрибута и его значение, если значение есть.

Form templates


= simple_form_for @user, builder: PunditFormBuilder do |f|
  = f.input :name
  = f.input :contacts, as: :big_textarea
  # some other inputs
  = f.button :submit


Я использую gem simple_form для рендеринга форм. Этот гем берет на себя всю работу по отображению форм. Понятно, что в случае нестандартных дизайнерских форм этот гем не сработает, но для стандартных форм он подходит отлично.

При построении формы я указываю только необходимое: список полей и их тип. Тексты для labels, placeholders, submit подставляются автоматически — достаточно прописать в файле перевода правильные ключи:

ru:
  attributes:
    created_at: Создано
  activerecord:
    attributes:
      user:
        name: Имя
  helpers:
    submit:
      create: Сохранить


Теперь подробнее про свои inputs.
Например, все текстовые формы должны содержать минимум 10 строк:

class BigTextareaInput < SimpleForm::Inputs::TextInput
  def input_html_options
    { rows: 10 }
  end
end


Это очень простой пример, инпуты могут быть гораздо сложнее. Например, выбор, в какое состояние можно перевести модель (gem state_machines).

Так же SimpleForm позволяет подключать свои билдеры форм:

class PunditFormBuilder < SimpleForm::FormBuilder
  def input(attribute_name, options = {}, &block)
    return unless show_attribute? attribute_name
    super(attribute_name, options, &block)
  end

  def show_attribute?(attr_name)
    # some code
  end
end

= simple_form_for @user, builder: PunditFormBuilder do |f|


PunditFormBuilder отвечает за отображение только тех полей, к которым имеет доступ текущий пользователь приложения. Более подробно я расскажу об этом в главе про ACL.

Serializers


Давайте теперь рассмотрим более специфическую задачу, а именно проектирование http json api. Вот наиболее простые способы:
  • метод Model#to_json
  • метод конроллера serialize_model


Все эти способы противоречат принципу единственной ответственности и паттерну MVC. Модель и конроллер не должны заниматься отображением — это обязанность представлений.

Я вижу 2 способа решения:
  • шаблоны jbuilder
  • serializers, причем как одноименный gem, так и просто объекты-сериализаторы (сериалайзеры?)


class CommentSerializer < ActiveModel::Serializer
  attributes :name, :body

  belongs_to :post
end


Т.е. представления — это не только шаблоны и хэлперы, но и другие объекты, занимающиеся представлением данных, например сериалайзеры.

Presenters


Так мы плавно подошли к следующему подходу: использование презентеров. В rails они используются как дополнение хэлперов.

gem drapper внес путаницу: его разработчики назвали презентеры декораторами. Хотя эти паттерны похожи, они имеют значительное различие: декораторы не изменяют интерфейс. Так же с этим гемом есть много проблем (можно посмотреть на список issues).

Я нашел простой, элегантный и понятный способ, как реализовать презентеры. Ниже я опишу свою реализацию.

# app/presenters/base_presenter.rb
class BasePresenter < Delegator
  attr_reader :model, :h
  alias_method :__getobj__, :model

  def initialize(model, view_context)
    @model = model
    @h = view_context
  end

  def inspect
    "#<#{self.class} model: #{model.inspect}>"
  end
end


Презентер представляет собой объект, который оборачивает модель и делегирует ей методы. В качестве модели может быть любой объект, даже другой декоратор. Базовый класс Delegator включен в стандартную библиотеку.

Кроме модели презентер содежит view_context, который для удобства назван 'h'.
Это self, доступный в helpers и views. Соответственно, в презентерах можно использовать все хэлперы.

# app/presenters/task_presenter.rb
class TaskPresenter < BasePresenter
  def to_link
    h.link_to model.to_s, model
  end

  def description
    h.markdown model.description
  end

  # оборачиваем связь
  def users
    model.users.map { |user| h.present user }
  end
end


# app/helpers/application_helper.rb
def present(model)
  return if model.blank?
  klass = "#{model.class}Presenter".constantize
  presenter = klass.new(model, self)
  yield(presenter) if block_given?
  presenter
end


Хэлпер present передает объект-презентер в блок или как результат.
Передачу через блок удобно использовать в шаблонах:

# app/views/web/tasks/index.haml
- @tasks.each do |task|
  %tr
    - present task do |task_presenter|
      %td= task_presenter.id
      %td= task_presenter.to_link
      %td= task_presenter.project


Похожий подход можно использовать, если у вас очень сложная логика отображения и хэлпереры не помогают. Или отсутствует объект для отображения. Например, вывод сложных меню или расписаний событий.

class MenuRenderer
  attr_reader :h

  def initialize(view_context)
    @h = view_context
  end

  def render
    some_hard_logic
  end

  private
  def some_hard_logic
    h.link_to '', ''
  end
end


В этой части я рассмотрел способы организации логики представлений. В следующей я покажу, как можно организовать логику контроллеров. В последующих — расскажу про модели. А именно: form-objects, services, ACL, query-objects, взаимодействие с различными хранилищами.

Комментарии (17)


  1. CuamckuyKot
    13.09.2015 15:07
    -5

    Есть такой волшебный тег — habracut.


    1. mkuzmin
      13.09.2015 15:25

      спасибо, исправил


  1. goooseman
    13.09.2015 19:01

    а зачем делать меню в виде хэлпера, если можно сделать partial, который на вход принимает Hash с меню?


    1. Fedcomp
      14.09.2015 06:36
      +1

      Партиалы вроде как гораздо медленнее хелперов (хотя с одним наверное не критично). Ради интереса, а где бы вы хранили хэш?

      По посту: для подсвечивания активных элементов удобно использовать github.com/comfy/active_link_to
      В том же геме есть хелпер если нужна сложная логика подсветки.


      1. mkuzmin
        14.09.2015 10:02
        -3

        Я сам использую много гемов, но не все они одинаково полезны. Иногда их приходится допиливать и делать monkey-patch.
        Проще написать 10-100 строчек, которые в любой момент можно изменить, что бы они соответствовали новым требованиям.


    1. mkuzmin
      14.09.2015 09:50

      Это от ситуации зависит.

      Как в твоем случае сделать обработку прав доступа?
      А если меню древовидное?
      Удобнее иметь функцию, которая рендерит один элемент меню. А функция может быть любой: helper или partial.


  1. j_wayne
    14.09.2015 07:35
    +3

    Странно вы как то, с хелперов начали)
    ИМХО самое главное — это как раз организация бизнес-логики по уму.
    За начинание спасибо, чем больше таких статей, тем меньше rails проектов в виде неуправляемого мессива.
    Ждем продолжения.
    Про бизнес-логику в коллбэках что-нибудь будет?


    1. mkuzmin
      14.09.2015 09:52
      +1

      Я начал с самого простого. Бизнес-логику оставил на десерт. Про коллбэки тоже напишу.


  1. Cim
    14.09.2015 10:23
    +1

    Поделюсь и я тем, как мы работаем с бизнес-логикой в нашем очень большом проекте.
    Один из наших инструментов — это дополнительная прослойка между моделями и контроллерами, которую мы в силу исторических причин называем Менеджерами.
    Дело в том, что иногда, а на самом деле почти всегда, создание/удаление/изменение какой-либо сущности требует использования нескольких моделей. Мы ввели прослойку, которую назвали менеджерами. Их задача: принять какие-то данные и выполнить кучу операций:

    class ProductManager < Manager
      def initialize(product, performer)...end
      def sell
        validate @product.count > 0, message: 'no products in the stock'
        @product.count -= 1
        @performer.account -= @product.price
        @product.save 
        @performer.save
        log_action :product_sold, @product, @performer
      end
    end
    
    

    ну как-то примерно так. Менеджеры можно назвать Экшенами, и договориться, что один класс описывает одно Действие, но это частные случаи реализации. Главное — наличие данной прослойки для бизнес-логики.
    Задача контроллера: ProductManager.new(..).sale и вернуть на фронт ошибку валидации или сказать, что всё окей.
    В моделях же у нас представлены исключительно связи, валидаторы на целостность данных и всякие методы, которые, как бы сказать, ну методы которые не занимаются бизнес-логикой — это тонкий момент и правил что считать логикой, а что нет — особо-то и нет. Комплексные же валидаторы у нас вынесены в менеджеры. Т.к. для сохранения целостности данных, модели не нужно знать, что нельзя создавать Продукт, если сегодня пятница, пользователь карлик и у него уже было создано тридцать семь продуктов за предыдущие семь нечетных недель.

    Подобная организация кода накладывает определенные требования к разработке, но выглядит как меньшее зло. Есть еще несколько интересностей, часть из которых здесь описана, а часть — исключительно «флора и фауна» конкретно нашего проекта.


    1. Metus
      14.09.2015 11:18

      Тоже сталкиваемся с подобными ситуациями.
      Отличное название. Как мы их только не именовали.


  1. printercu
    14.09.2015 12:18

    > model.model_name… .to_s.classify.constantize.human_attribute_name(attribute)

    Зачем так усложняете? Вы видели реализацию .constantize? :) Это метод для стадии инициализации, его не надо в рантайме использовать везде. const_get в большинстве случаях подойдет всем. Но тут я уверен, что model.class должно хватить (или model.model.class для презентеров).

    Мы еще делали show_attributes resource, :attr1, attr2,…


  1. fzn7
    14.09.2015 13:09

    Постоянно тема всплывает. Вот например http://habrahabr.ru/post/261475/

    Используйте паттерн "Command" и забудьте про вопрос где хранить логику приложения. Можете хоть сразу выполнять, хоть в очереди ставить, как угодно. А хэлперы пускай там дату форматируют, как изначально и задумывалось

    Вот вам гем даже, что-бы не гуглить гем нормальный https://github.com/karmajunkie/imperator

    Enjoy


  1. Loriowar
    14.09.2015 13:38
    +1

    Для всех любителей переосмыслить кронцепцию RoR существует Trailblazer. Лучшего переосмысления и более фундаментальной базы пока не предвидится. Там вам и сериализация через Roar и презентеры из коробки и много много чего ещё.


    1. XimikS
      14.09.2015 13:52
      +1

      Вы его у себя в проектах используете? Было бы интересно почитать о опыте использования.


      1. Loriowar
        14.09.2015 15:08
        +3

        Мы его пользуем пока эксперементально в некоторых частях проекта. Коллеги активно Cells используют там где это целесообразно. Я только Roar'ом ограничивался. Было это полгода назад и без вдумчивого чтения исходников онного было очень тяжко. В итоге, реализованный функционал работает и по сей день. Из личных ощущений: на момент использования было сыровато, без напильника и умения читать исходники можно даже не соваться, документашка хромала как только дело доходило до нестандартных/продвинутых способов использования, но сама идея красивая. Можно попытаться засамонить Tab10id и спросить у него про Cells и прочие приблуды Trailblazer'а.


        1. Dreyk
          15.09.2015 22:05
          +1

          Да, я вот тоже с Cells начал, очень нравится. Остальной Trailblazer пока ждет своей очереди)


          1. XimikS
            16.09.2015 00:27

            Я пока только reform пользуюсь, но посмотрю в сторону cells, вдруг пригодится, спасибо)