image

Недавно мы опубликовали гем RailsStuff. Это коллекция небольших модулей и утилит для выполнения самых разных частых задач: от организации контроллеров и генерации уникальных случайных значений до парсера параметров и хэлперов переводов. В этом посте я расскажу про некоторые из них:

  • ResourcesController — облегчённая и современная версия InheritedResources;
  • Трекер типов;
  • Генератор уникальных случайных значений;
  • Хэлперы переводов и основных ссылок.

Установка


Большинство модулей слишком маленькие, чтобы быть отдельным гемом, и для удобства было решено объединить их в один гем. При этом используется ленивая загрузка: если вы не используете какой-то модуль, он и загружен не будет. Часть модулей используются напрямую через include/extend, другие добавляют методы класса для активации и настройки. Для вторых в геме есть Rails::Engine, который автоматически загрузит их в базовые классы моделей и контроллеров (ActiveRecord::Base и ActionController::Base). Выбрать отдельные модули или отключить автоматическую загрузку полностью можно, добавив initializer:

# Не использовать автоматическую загрузку:
RailsStuff.load_modules = []

# Загрузить только определенные модули:
RailsStuff.load_modules = %i(sort_scope statusable)

Гем не имеет зависимостей, но для некоторых модулей (или даже отдельных методов) надо будет установить что-нибудь ещё. Для таких модулей зависимости указаны в документации.

ResourcesController


Это один из основных модулей в RailsStuff. Фактически, он является упрощенной и обновлённой версией InheritedResources.

InheritedResources показал мне отличный подход к написанию контроллеров. Он реализовал многие функции, которые я искал или делал сам, например:

  • Доступ к ресурсам в разных контроллерах по одному имени. Хорошо, когда у тебя переменная @user, но уже хуже, если @payment_transaction. Ещё хуже, если @recurring_payment_transaction. Кто-то в таком случае начинает сокращать имена, но от этого обычно становится ещё хуже.
  • Отсутствие повторяющихся before_action :set_user / before_action :find_manager. С длинными списками only или except, которые нужно не забывать поддерживать в актуальном состоянии.
  • И, конечно, меньше дублирования кода экшенов и файндеров. Да, рельсы генерируют его за вас, но он всё равно остаётся кодом в вашем репозитории, и он повторяется. При редактировании приходится читать весь код: сразу сложно сказать, что перед тобой — оригинальный код из генератора, или он уже был редактирован.

InheritedResources позволяет избежать этих проблем и сосредоточиться на бизнес-логике приложения. Ho библиотека слишком перегружена, и достаточно часто приходится искать подходящие комбинации настроек или лезть в исходные коды за названием метода, который надо переопределить, или смотреть, как именно что-то реализовано. К тому же библиотека находится в статусе deprecated уже около года. Поэтому новые проекты было решено начинать без использования InheritedResources.

Так, после использования своих модулей в нескольких новых приложениях, мы выделили их в ResourcesController, который является основной частью RailsStuff. Это облегчённая версия InheritedResources, предоставляющая базовые возможности, и в ней отсутствуют некоторые сложные функции. Но это позволяет сделать код проще и яснее как в библиотеке, так и в приложениях. Все методы в библиотеке максимально просто выполняют отдельные функции. Основной принцип — не предоставить множество настроек на все возможные случаи, а обеспечить простым, расширяемым базовым функционалом. Если требуется особенное поведение, просто напишите метод так, как вы делали бы это без ResourcesController.

Вот основные отличия:

  • Одинаковые названия переменных для всех контроллеров: @_resource и @_collection. Нет необходимости в громоздких конструкциях из set_collection_ivar и get_resource_ivar.
  • Отсутствуют belongs_to и цепочки отношений. Но есть resource_helper и быстрый способ установить source_relation:

    resources_controller source_relation: -> { manager.projects }
    # Это добавит метод #manager, который находит менеджера по params[:manager_id]:
    resource_helper :manager
    

  • Улучшенная интеграция со StrongParameters (хэлпер вместо странного def permited_params):

    permit_attrs :name, project_attributes: [:id, :_destroy, :name]
    

  • respond_with используется только в create, update, destroy. В целом, это не должно быть проблемой: переопределение .to_json — не очень хорошая идея для форматирования ответов для API Но если это действительно необходимо, то можно допатчить модуль и добавить нужные экшены.
  • Хэлперы для STI-моделей: белый список типов, доступных для создания, автоматическое определение класса по названию из параметров и отдельные разрешенные параметры для каждого типа.

Примеры контроллеров из спеков RailsStuff
module Site
  class UsersController < SiteController
    resources_controller
    permit_attrs :name, :email
  end

  class ProjectsController < SiteController
    resources_controller sti: true
    resource_helper :user
    permit_attrs :name
    permit_attrs_for Project::External, :company
    permit_attrs_for Project::Internal, :department

    def create
      super(action: :index)
    end

    protected

    def after_save_url
      url_for action: :index, user_id: resource.user_id
    end

    def source_relation
      params.key?(:user_id) ? user.projects : self.class.resource_class
    end
  end
end


Трекер типов


Часто при использовании STI-моделей в интерфейсе надо вывести список всех возможных типов. В таких ситуациях ленивая загрузка мешает, и нужно явно вызвать require_dependency/eager_load для всех наследующих моделей. Да и DescendantsTracker для получения списка всех унаследованных классов в этом случае — не самое лучшее решение. Для таких задач мы используем TypesTracker. Он содержит хэлпер для загрузки всех типов для модели и хранит отдельный массив со всеми унаследованными классами: список готов и доступен в любой момент. При этом выборочно можно убрать некоторые классы из этого списка.

class Project
  extend RailsStuff::TypesTracker

  # ...

  eager_load_types! # загрузит все .rb в app/models/project
  # или указать директорию явно:
  eager_load_types! 'lib/path/to/projects'
end

class Project::Big < Project
  unregister_type # Убираем этот класс из списка
end

class Project::Internal < Project::Big; end
class Project::External < Project::Big; end
class Project::Small < Project; end

Project.types_list # [Internal, External, Small]

Если массив заменить кастомным хранилищем, то можно задавать флаги для каждого класса и фильтровать/сортировать по ним при выводе:

class Project
  extend RailsStuff::TypesTracker
  # MyTaggedArray должен предоставлять #add(klass, *args).
  # В примере *args - фактически *tags.
  self.types_list_class = MyTaggedArray
end

class Project::Internal < Project::Big
  # Вызывает types_list.add Project::Internal, :tag_1
  register_type :tag_1
end

class Project::External < Project::Big
  register_type :tag_2
end

Project.types_list.with(:tag_1)

Генератор случайных уникальных значений


Иногда требуется для создаваемой записи генерировать уникальное значение. Теоретически, UUID предоставляет такую возможность, но иногда значение надо генерировать в соответствии с шаблоном, и возможны коллизии. RandomUniqAttr использует ограничения БД и автоматически перегенерирует значение, если запись не удалась. Это позволяет использовать такой генератор даже тогда, когда коллизии довольно вероятны. Вот как он работает:

  1. Запись сохраняется как обычно.
  2. Если поле уже имеет значение, то ничего не происходит.
  3. Генерирует значение и пробует сохранить запись.
  4. Если произошла ошибка RecordNotUnique, повторяет предыдущий шаг.

У такого подхода есть условие: поле должно быть объявлено nullable. Несмотря на это, поле не будет иметь NULL после завершения транзакции.

# По-умолчанию используется SecureRandom.hex(32)
random_uniq_attr :token

# С кастомным генератором:
random_uniq_attr(:code) { |instance| my_random(instance) }

Хэлперы


Для мультиязычных интерфейсов в RailsStuff есть хэлперы для переводов названий действий и подтверждений. Эти методы кэшируют переводы на время запроса, и для больших списков и таблиц поиск перевода будет выполнен только один раз. Перед использованием нужно добавить в файлы переводов секции helpers.actions и/или helpers.confirmations:

ru:
  helpers:
    actions:
      edit: Редактировать
      delete: Удалить
    confirm: Точно?
    confirmations:
      delete: Не пожалеете?

Теперь во всех шаблонах, можно начать переводить действия одинаково:

# в хэлпер добавить модуль:
# include RailsStuff::Helpers::Translation

= translate_action(:edit) or translate_action(:delete)

- collection.each do |resource|
  tr
    td= resource.name
    td= link_to 'x', url_for(resource),
      method: :delete, data: {confirm:  translate_confirmation(:delete)}

= translate_confirmation(:purge_all) # Фолбэк: 'Точно?'

Для того, чтобы в приложении все ссылки на основные действия были оформлены одинаково, в RailsStuff есть хэлперы для них:

# Настройки в хэлпере.
# Подробности можно посмотреть в модуле rails_stuff/helpers/links
include RailsStuff::Helpers::Links

ICONS = {
  destroy:  '<span class="glyphicon glyphicon-trash"></span>'.html_safe,
  edit:     'Редактировать',
  new:      -> { translate_action(:new) },
}

def basic_link_icons
  ICONS
end

# Использование:
link_to_edit([:edit, :scope, resource]) or link_to_edit(edit_path)
link_to_edit('url', class: 'text-info')

# Если url не указан, используется url_for(action: edit).
# Так на странице /users/1
link_to_edit # будет вести на '/users/1/edit'

# По такому же принципу работают:
link_to_destroy or link_to_new

По умолчанию в качестве текста для ссылок используются переводы экшенов. Для пользователей Bootstrap или Glyphicon есть хэлпер RailsStuff::Helpers::Bootstrap, который использует иконки для этих ссылок.

Что ещё можно найти?


  • «nullify blanks»;
  • модуль для работы со статусами/перечислениями (как AR::Enum, только лучше);
  • обработку параметров для сортировки;
  • хэлпер response.json_body для тестирования API;
  • даже хэлпер для дебага Net::HTTP;
  • и другое.

Попробовать гем можно, добавив gem 'rails_stuff', '~> 0.4.0' в Gemfile. Больше подробностей, документация на английском и исходные коды — в репозитории на гитхабе.

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


  1. Metus
    07.10.2015 13:33
    +6

    Здравствуйте.

    Скажите пожалуйста, а зачем разработчику ставить этот гем и писать

    translate_action(:edit)

    вместо того, чтобы сделать так:
    t('actions.edit')

    или вообще создать свой хелпер или расширить i18n?

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


    1. printercu
      07.10.2015 14:33

      Здравствуйте!

      Компоненты действительно не связаны друг с другом. Почему они оказались вместе, я упомянул в начале поста. При этом в RailsStuff собраны, конечно, не все модули, которые мы когда-либо использовали. Только те, которые мы использовали во многих проектах, которые часто добавлялись в первых комитах. Чтобы не копить дубликаты файлов, вынесли их все в гем.

      Относительно хэлпера перевода, я вижу несколько плюсов:
      — Скорость работы вкупе с удобством использования. Для многих производительность в приложениях на рельсах — спорный вопрос, а жаль. Если это не аргумент, просто пропустите, пожалуйста. I18n.t — довольно сложный вызов и не самый быстрый. Время работы его растет, если используются фолбэки. Хранить перевод в локальных переменных, чтобы не переводить в циклах одно и то же — эффективнее, конечно, но больше писать надо, да и не все хотят.
      — Один общий вариант использования. Не надо вспоминать, как надо переводить, нет длинных ключей, в которых можно ошибиться.
      — Один общий способ организации переводов. Нет выбора, куда бы ещё положить перевод. Позволяет избежать повторений.

      Свой хэлпер — это тоже хорошо. Просто тут он уже готовый. Только патчить I18n для этого не надо, имо :)


  1. Vetal4eg
    07.10.2015 21:08
    -1

    Дожили…


    1. printercu
      08.10.2015 10:05

      Ну с вами всё понятно.


  1. phoenixweiss
    08.10.2015 01:11
    +2

    Начинание неплохое, но по-факту сборная солянка. У нас нечто подобное по гистам на гитхабе раскидано. Нет, безусловно есть неплохие удобные вещи, но я соглашусь с первым комментатором что это разнобой. В качестве статьи на хабре — интересно посмотреть как что использует ваша команда, но в реальной жизни и проектах использовать все же мало кто будет.

    P.S. вы бы идею ResourceController развили в отдельный гем. Я думаю это было бы значительно полезнее для сообщества, тем более в качестве альтернативы полумертвого InheritedResources. А остальное не нужно, имхо.


    1. printercu
      08.10.2015 10:20

      Попробую на статью набрать чего-нибудь интересного. Но сейчас всё кажется очень банальным.

      Если ResourcesController будет быстро развиваться или окажется, что его лучше вынести отдельно — так и сделаем. То же самое и про все остальные части. Пока что, по-моему, это было бы не рационально — несколько гемов поддерживать требует больше времени, чем один. Гисты сложнее поддерживать в актуальном состоянии, и тесты к ним писать.

      Скажите, что вас останавливало бы от использования одного только ResourcesController, установив весь набор? Все сделано на autoload, остальные файлы даже прочитаны не будут.


      1. phoenixweiss
        08.10.2015 11:54
        +1

        Узкопрофильные гемы как правило стабильнее, они лучше развиваются и у них значительно меньше мусора. Текущий подход приведет к тому что придется следить по change-логу о тех изменениях которые не нужны, а это информационный шум, да и понимание концепции развития тоже немаловажно — пока это некоторый toolbox отдельно взятой команды, в нем завтра может появиться еще mp3-плеер и генератор QR-кодов, а также хэлперы со ссылками на амиго или еще что, и даже если это все будет на автолоаде, тащить гору мусора тоже не хочется. По предпочтениям, я даже стараюсь ставить гемы с --no-ri --no-rdoc дабы не тащить лишнего.


        1. printercu
          08.10.2015 13:01

          Про ченджлог согласен. Но зачем так про амиго сразу :) Выше я писал:

          При этом в RailsStuff собраны, конечно, не все модули, которые мы когда-либо использовали. Только те, которые мы использовали во многих проектах, которые часто добавлялись в первых комитах.

          Всё, что вы перечислили, не вписывается в имеющийся набор, т.к. очень специализированное.

          > тащить гору мусора тоже не хочется

          Какие объективные причины для такой точки зрения? Я уверен, что и в рельсах и во многих других гемах мало кто использует абсолютно все функции/файлы, которые едут в геме.


          1. phoenixweiss
            08.10.2015 13:10
            +1

            Степень специализированности определяется той же командой которая использует этот тулсет. То есть если например завтра вы будете во всех проектах использовать feature_X, то велика вероятность что в тулсете это появится вне зависимости от того, нужно это или нет остальному сообществу. Подозреваю что в перспективе это может разрастись еще больше. Нет, я ни в коем случае не против такого подхода. Я просто говорю что наработки конкретной команды в их сыром виде не всегда удобно использовать остальному миру.

            Что касается объективных причин такой точки зрения — её нет, чистый субъективизм. Но программисты — народ капризный и весьма принципиальный. Я частенько вычищаю из проектов лишние не используемые гемы и код. Иначе какое-то чувство дискомфорта остается. Это как спать с горой грязной посуды в раковине — кому-то всё равно, а кого-то это раздражает и он не может спокойно уснуть просто зная что всё не на своих местах. По этой же причине если мне нужно пройти 100 метров, я иду пешком а не вызываю такси. Возможно исходя из подобных суъбективных предубеждений, я не буду ставить большой мультифункциональный гем если мне из него нужен один единственный хэлпер.


      1. Metus
        08.10.2015 13:54
        +1

        Не имею ничего против предоставленных инструментов. Некоторые из них действительно занятны.

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

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