Rails: ajax-валидация в стиле DRY

  • Tutorial
Когда я только начинал задумываться о том, чтобы приобщиться к миру веб-разработки, и выбирал язык, с которого начну, одна из википедий мне напела, что в основе философии Rails лежат 2 принципа: Convention over configuration (CoC) и Don’t Repeat Yourself (DRY). Что касается первого — я тогда вобще не понял о чём речь, а вот второй понял, принял и ожидал, что в недрах этого замечательного фреймворка, я отыщу нативный инструмент, позволяющий мне один раз написать правила валидации для атрибутов модели, и потом использовать эти правила как для front, так и для back проверок.

Как выяснилось позже — меня ждало разочарование. В рельсах из коробки подобной штуки нет, и всё, что удалось отыскать по теме в ходе обучения — это railscast про client_side_validations gem.

Я тогда о javascript знал только то, что он есть, поэтому пришлось молча прикрутить гем к рождающемуся блогу и отложить тему dry-валидаций до более близкого знакомства с js. И вот это время пришло: мне понадобился гибкий инструмент для проверки форм, и переписывать каждый validates_inclusion_of на js-манер я был не намерен. Да и гем тот больше не поддерживается.

Постановка задачи


Найти способ, который позволит:
  1. при валидации атрибутов использовать одну логику: как для бэка, так и для фронта
  2. быстро «вешать» проверки на разные формы и гибко их настраивать (как логику, так и визуал)

Решение материализовано в небольшой демке: http://sandbox.alexfedoseev.com/dry-validation/showoff

И пара поясняющих абзацев ниже.

Инструменты


Забыл упомянуть, что я в меру ленив, и писать собственный js-валидатор в мои планы изначально не входило. Из готовых решений мой выбор пал на jQuery Validation Plugin.

Его можно просто закинуть в js-ассеты или поставить как гем.
Больше ничего стороннего не потребуется.

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

Переходим к сути


Соответственно есть модель — Email
И два её атрибута:
  • email — электронный адрес
  • frequency — периодичность, с которой рассылка будет уходить на данный адрес

Какие будут ограничения:
  • наличие email обязательно
  • email должен быть уникален
  • email должен быть email (с собакой и прочими рюшечками)
  • frequency не обязателен, но если есть, то должен быть в диапазоне от 1 до 7


Воплощаем:

app/models/email.rb

class Email < ActiveRecord::Base

  before_save { self.email = email.downcase }

  VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
  validates :email, presence: true,
            uniqueness: { case_sensitive: false },
            format: { with: VALID_EMAIL_REGEX }
  validates_inclusion_of :frequency, in: 1..7, allow_blank: true

end


В контроллере и представлении всё абсолютно стандартно
app/controllers/emails_controller.rb

class EmailsController < ApplicationController

  def new
    @email = Email.new
  end

  def create
    @email = Email.new(email_params)
    if @email.save
      flash[:success] = 'Email добавлен!'
      redirect_to emails_url
    else
      render :new
    end
  end

  private

    def email_params
      params.require(:email).permit(:email, :frequency)
    end

end


app/views/emails/new.html.haml

%h1 Новая почта
= form_for @email do |f|

  = render partial: 'shared/error_messages', locals: { object: f.object }

  %p= f.text_field :email, placeholder: 'Почта'
  %p= f.text_field :frequency, placeholder: 'Периодичность рассылки'
  %p= f.submit 'Добавить!'



Следующий шаг — повесить валидатор на форму и посмотреть что к чему.
Делается это просто: $('#form').validate();
Повторю ссылку на документацию к плагину, чтобы к ней больше не возвращаться. Там со структурированностью контента небольшая проблема, но информация вся есть.

Итак, вешаем:

app/assets/javascripts/emails.js.coffee

jQuery ->
  validate_url = '/emails/validate'

  $('#new_email, [id^=edit_email_]').validate(
    debug: true
    rules:
      'email[email]':
        required: true
        remote:
          url: validate_url
          type: 'post'
      'email[frequency]':
        remote:
          url: validate_url
          type: 'post'
  )


Пройдемся по каждой строчке:
  • validate_url = '/emails/validate'
    адрес, на который будем отправлять ajax-запрос для проверки значений полей
  • $('#new_email, [id^=edit_email_]').validate
    вешаем валидатор как на форму нового email, так и на форму редактирования уже существующих адресов
  • debug: true
    метод отключает отправку формы, чтобы можно было развлекаться с настройками
  • rules:
    в методе прописываются правила проверки для полей, у плагина их много из коробки (см. доки), но нас интересуют пока только 2
  • 'email[email]':
    name–атрибут поля формы (простые имена указываются без кавычек, со специальными символами — берём в кавычки)

На следующих двух методах остановимся подробнее.

remote

remote:
  url: validate_url
  type: 'post'

Сначала поговорим о главном методе этого поста — remote. С его помощью мы можем отсылать ajax-запросы к серверу и обрабатывать возвращаемые данные.

Как оно работает: методу нужно скормить url запроса и его тип (в нашем случае отсылаем post-запрос). Этого достаточно, чтобы отправить значение поля на проверку серверу.

В ответ метод ожидает получить json:
  • ответ true — означает, что с полем всё ок
  • ответы false, undefined или null, а также любая другая string'а — расцениваются методом как сигнал провальной валидации


required

required: true

Метод «обязательных полей». Единственная проверка, которую нельзя (да и не нужно) выполнять через обращение к серверу, — это validates_presence_of (то есть валидацию наличия). Это связано с особенностями работы валидатора — он дёргает метод remote только в том случае, если в поле вводились какие-либо данные. «Запустить руками» данную проверку невозможно, поэтому валидации наличия прописываем непосредственно через данный метод. Кстати, он принимает в качестве аргумента функции, поэтому сложные логические проверки на наличие можно (и нужно) осуществлять через него.

Продолжаем


Валидатор повешен, ajax-запрос уходит к серверу, что дальше:
  • нужно создать метод в контроллере, который будет обрабатывать запрос
  • прописать роут к этому методу


app/controllers/emails_controller.rb

  def validate
    # пока пустой
  end


config/routes.rb

resources :emails
post 'emails/validate', to: 'emails#validate', as: :emails_validation


Отлично, теперь сервер может принимать post-запросы на адрес '/emails/validate'
Давате запустим сервер, откроем форму создания Email в браузере (lvh.me:3000/emails/new), наберём «что-нибудь» в поле формы и бегом в консоль — смотреть что же передаёт нам валидатор.

В общем-то, этого можно было ожидать:

Started POST "/emails/validate" for 127.0.0.1 at 2014-02-17 22:10:31 +0000
Processing by EmailsController#validate as JSON
  Parameters: {"email"=>{"frequency"=>"что-нибудь"}}

Теперь о стратегии: что мы будем делать с этим добром — как обрабатывать и что возвращать:
  • из прилетевшего в контроллер json, мы создадим в памяти новый объект Email
  • и дёрнем его валидацию через ActiveModel
  • в памяти нарисуется объект класса ActiveModel::Errors (доступный через метод errors), в котором будет хэш @messages — либо с ошибками (если атрибуты не прошли валидацию), либо пустой (если с объектом всё хорошо)
  • мы разберём этот хэш и, если он пустой — ответим браузеру true, а если в нём есть ошибки для проверяемого атрибута — ответим текстом этих ошибок, что будет расценено принимающим методом плагина как провальная валидация. И, более того, плагин использует полученную стрингу как текст сообщения об ошибке.

Шо-ко-лад! Мало того, что правила валидации прописываются один раз непосредственно в моделе, так ещё и сообщения об ошибках хранятся непосредственно в локале рельс.

Кстати, давайте их напишем.

config/locales/ru.yml

ru:
  activerecord:
    attributes:
      email:
        email: "Почта"
        frequency: "Периодичность"
    errors:
      models:
        email:
          attributes:
            email:
              blank: "обязательна"
              taken: "уже добавлена в список рассылки"
              invalid: "имеет странный формат"
            frequency:
              inclusion: "должна быть в диапазоне от 1 до 7 включительно"

О I18n читаем в гайдах Rails: http://guides.rubyonrails.org/i18n.html

Названия атрибутов и сообщения прописаны.
Теперь самое интересное — формируем ответ браузеру.

Сразу вываливаю работающий код, который будем разбирать по строчкам:

app/controllers/emails_controller.rb

def validate
  email = Email.new(email_params)
  email.valid?

  field = params[:email].first[0]
  @errors = email.errors[field]

  if @errors.empty?
    @errors = true
  else
    name = t("activerecord.attributes.email.#{field}")
    @errors.map! { |e| "#{name} #{e}<br />" }
  end

  respond_to do |format|
    format.json { render json: @errors }
  end
end


Поехали.

email = Email.new(email_params)
email.valid?

Создаём в памяти объект из прилетевших от формы параметров и дёргаем проверку на валидность, чтобы в памяти появился объект ActiveModel::Errors. В хэше @messages с ошибками, помимо нужных нам для проверяемого атрибута, будут лежать и сообщения для всех остальных атрибутов (т.к. значения всех остальных — nil, прилетело же только значение проверяемого атрибута).

Давайте посмотрим как выглядит объект, чтобы понять как его разобрать:

(rdb:938) email.errors
#=> #<ActiveModel::Errors:0x007fbbe378dfb0 @base=#<Email id: nil, email: nil, frequency: "что-нибудь", created_at: nil, updated_at: nil>, @messages={:email=>["обязательна", "имеет странный формат"], :frequency=>["должна быть в диапазоне от 1 до 7 включительно"]}>

Мы видим хэш с сообщениями об ошибках, и доки нам подсказывают как их достать:

(rdb:938) email.errors['frequency']
#=> ["должна быть в диапазоне от 1 до 7 включительно"]

То есть для того, чтобы достать ошибки для атрибута, нам прежде всего нужно достать имя этого атрибута.
Это мы вытянем из хэша params:

# так выглядит хэш
(rdb:938) params
#=> {"email"=>{"frequency"=>"что-нибудь"}, "controller"=>"emails", "action"=>"validate"}

# нам известно название модели, поэтому достаём атрибуты
(rdb:938) params[:email]
#=> {"frequency"=>"что-нибудь"}

# поскольку за запрос улетает всегда один атрибут, то забираем первый
(rdb:938) params[:email].first
#=> ["frequency", "что-нибудь"]

# на первом месте всегда будет ключ, то есть имя атрибута -> забираем
(rdb:938) params[:email].first[0]
#=> "frequency"

Возвращаемся к функции валидации в контроллере:

field = params[:email].first[0]
@errors = email.errors[field]

Сначала мы достали название проверяемого атрибута модели, потом вытащили массив с сообщениями об ошибках.

После этого сформируем ответ браузеру:

if @errors.empty?
  @errors = true
else
  name = t("activerecord.attributes.email.#{field}")
  @errors.map! { |e| "#{name} #{e}<br />" }
end

Если массив с ошибками пуст, то переменная @errors — это true (именно этот ответ ожидает плагин, если ошибок нет).

Если же в массиве есть ошибки, то:
  • если мы отдадим его как просто @errors, то получим сообщение «должна быть в диапазоне от 1 до 7 включительно» (а если их будет несколько, то они вобще «слипнутся» при выводе)

Поэтому мы:
  • вытаскиваем имя атрибута модели из файла локализации рельс:
    name = t("activerecord.attributes.email.#{field}")
  • и в каждый элемент массива с ошибками добавляем это имя в начало с пробелом:
    @errors.map! { |e| "#{name} #{e}
    " }
  • можно ещё и br в конце каждой ошибки прилепить, зависит от вёрстки, короче добавлять по вкусу

Получаем в итоге массив с сообщениями в формате:
«Периодичность должна быть в диапазоне от 1 до 7 включительно».

И последний штрих — пакуем всё это в json:

respond_to do |format|
  format.json { render json: @errors }
end

Rails отдаёт ответ браузеру.

Рефакторим


Для одной модели это работает, но у нас в приложении будет много моделей. Для того, чтобы не-повторять-себя, можно переписать роутинг и метод валидации в контроллере.

Роутинг

config/routes.rb

# было
post 'emails/validate', to: 'emails#validate', as: :emails_validation

# чтобы не переписывать маршрут для каждого контроллера
post ':controller/validate', action: 'validate', as: :validate_form


Метод валидации

Вынесем логику валидации в application_controller.rb, чтобы его могли использовать любые контроллеры приложения.

app/controllers/application_controller.rb

def validator(object)
  object.valid?
  model = object.class.name.underscore.to_sym
  field = params[model].first[0]
  @errors = object.errors[field]

  if @errors.empty?
    @errors = true
  else
    name = t("activerecord.attributes.#{model}.#{field}")
    @errors.map! { |e| "#{name} #{e}<br />" }
  end
end


app/controllers/emails_controller.rb

def validate
  email = Email.new(email_params)
  validator(email)
  respond_to do |format|
    format.json { render json: @errors }
  end
end

P.S. Чтобы не дёргать сервер при каждом введённом пользователем символе в полях формы, установите значение метода onkeyup: false

jQuery Validation Plugin:
http://jqueryvalidation.org

Демо с бантиками:
http://sandbox.alexfedoseev.com/dry-validation/showoff

UPDATE: Отредактировал заголовок
Share post

Similar posts

AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 22

    +4
    Собственно, это server-side валидация через ajax, а не frontend-валидация. Валидация на фронте — это проверка на стороне клиента без запроса на сервер, но там DRY-ем даже не пахнет.
      0
      Извиняюсь, ошибся формой. Ответил ниже.
      0
      С терминологией спорить не буду, всё так и есть. Я хотел подчеркнуть, что валидация осуществляется средствами js ( + server-side), но без отправки формы с перезагрузкой страницы. Пользователь сразу получает информацию о правильности заполнения полей, как будто это делает чистый js, но без дублирования логики валидации.
        +2
        Мне кажется тут надо писать гем, который бы из модели генерировал front-end валидатор — тогда бы было все по-честному.
          0
          Это далеко не всегда возможно. Из простого — проверка уникальности. Из более сложного — всякие кастомные проверки, коллбэки и прочее. На фронте можно проверить максимум заполнение поля, длину и формат (интервал для чисел).
            0
            И этот gem уже вполне существует, о чём я писал ниже. Для rails3 — client_side_validations, для rails4 — rails4_client_side_validations.
              0
              Я в курсе, я его пользую. Но на фронте, как я и писал, он выполняет только самые простые валидации.
                0
                Очевидно, что часть валидаций придется делать на бэке. Уникальность, например. И этот gem их прозрачно прокидывает через ajax.
              0
              Если говорить о «стандартной» валидации: проверка на существование, длину, тип, регулярка — все это можно перенести в JS.

              Частные случаи можно либо дописывать в JS(на то они и частные), либо (наверное это будет ужасно) писать валидатор в принципе на JS и из руби выполнять JS.

              Первый способ более правильный, но, увы, не вписывается в концепцию «единый валидатор». У меня самого такая проблема — двойная логика в RoR и Knockout. Я почему и говорю про генерацию JS валидаторов — я бы написал генератор JS кода на основе RoR моделей (все валидаторы, связи и прочее). Причем эта генерация нужна «единожды». Налету ее генерировать смысла нет.
                0
                Это, на мой взгляд, ненужные сложности. В большинстве случаев нам нужно не только проверить модель, но и сохранить ее в фоне, а тут совсем проблем нет, и все эти извращения с валидацией на фронте уходят сами собой.
                Ну а валидация чисто на фронте при необходимости доступа к БД невозможна в принципе. А коль скоро мы все равно будем дергать сервер, то какого черта, ajax-валидация — наше все.
                  0
                  а если к примеру нужна валидация не только полей, но и связей? понятно что ajax наше все и это упрощает. Но если мы берем Knockout, Angular, Backbone? у них своя логика и, зачастую, она может дублировать backend.

                  Например. У нас есть Item для продажи. Надо посчитать его стоимость. Формула стоимости (по причине кучи связей и условностей) большая. Эту стоимость надо в итоге считать и в backend (суммировать заказ) и, самое простое, в корзине.

                  По сути в мелких масштабах ничего страшного, но если фурмула усложняется и добавляются условия? В итоге у вас over 100 строк для подсчета на backend и столько же в front-end.

                  Понятно, что частный случай, Но на каждый клик ajax валидацию делать нельзя
                  0
                  Частные случаи можно либо дописывать в JS(на то они и частные), либо (наверное это будет ужасно) писать валидатор в принципе на JS и из руби выполнять JS.
                  Так недолго и до node.js докатиться =)
                    0
                    Я об этом задумывался уже, что проще написать продукт на node.js. Но проект уже написан на RoR и деваться некуда :-)
              –1
              Без холивара, но в PHP Yii — это из коробки. Плюс можно включать/отключать фронтенд валидацию как для всей формы, так и для некоторых элементов(например, капчи).
                0
                Скорее всего не фронтенд-валидацию, а таки ajax-валидацию.

                Кроме того, это «из коробки» судя по куче постов в блогах требует изменения каждого контроллера, где это необходимо, что не совсем DRY, мягко выражаясь. Если у вас есть актуальные статьи, которые это опровергают — пруф в студию.

                Пока я видел что-то в стиле
                осторожно, PHP
                В контроллере:
                if(isset($_POST['ajax'])) {
                  if ($_POST['ajax']=='form') {
                    echo CActiveForm::validate($model);
                  }
                  Yii::app()->end();
                }
                

                Во вью:
                <?php
                  $form = $this->beginWidget('CActiveForm', array(
                    'id'=>'form',  //form-id
                    'enableAjaxValidation'=>true,
                    'clientOptions'=>array(
                      'validateOnSubmit'=>true,
                     ),
                 ));
                ?>
                

                knackforge.com/blog/vishnu/yii-how-enable-ajax-form-validation


                Аналогичные вещи пишут в документации: www.yiiframework.com/doc/api/1.1/CActiveForm#enableAjaxValidation-detail.

                Судя по фрагментам кода и подходам, Yii пытается копировать RoR, но синтаксис PHP не располагает. Что ещё ждать от вторичных имитаторов?
                  0
                  У меня не статьи — у меня код.
                  Нет. Там есть фронтенд без аякса, в том числе и для капчи. Тоесть можно включить аякс, но можно и только в браузере.

                  Что за быдлокод вы прислали? Там просто $model->save(). Если ошибка — оно не сохранит.

                  Я же сказал, без холивара, потому «Что ещё ждать от вторичных имитаторов?» просто проигнорую. Хотя удивлен что в таком фремворке как RoR нет таких базовых вещей.
                    0
                    Код, который приведен в спойлере практически совпадает с быдлокодом, как вы выразились, из документации:
                    public function actionCreate()
                    {
                        $model=new User;
                        if(isset($_POST['ajax']) && $_POST['ajax']==='user-form')
                        {
                            echo CActiveForm::validate($model);
                            Yii::app()->end();
                        }
                        ......
                    }
                    
                    www.yiiframework.com/doc/api/1.1/CActiveForm#enableAjaxValidation-detail

                    Я вижу наличие параметра enableClientValidation (который выключен в примере с SO, на который вы сослались ниже). Несмотря на скудную документацию, похоже, что оно должно работать в простейших случаях (как, например, gem client_side_validations для rails «из коробки»).

                    Что такое фронтенд-валидация для капчи — мне совсем не очевидно. Вы не указали, генерируете ли капчу самостоятельно или нет, что валидируете (например, соответствие множеству допустимых символов), что передаете на клиент для валидации капчи.

                    Из rails очень много лишнего повыкидывали в процессе его развития. При этом есть огромное количество библиотек. Из интересных для валидации видел html5_validations и rails4_client_side_validation (ранее просто client_side_validations).

                    Можно посмотреть, как это делается на rails: railscasts.com/episodes/263-client-side-validations?view=asciicast. Подход смешанный: некоторые проверки выполняются на клиенте и на сервере (но описываются в коде единожды), другие — с ajax-запросом, т. к., например, требуют обращения к db для выполнения (уникальность email/username).
                      0
                      Вся суть в том, что я ни о чем не забочусь.
                      Ничего не передаю. Это все — забота фреймворка. Я, конечно, посмотрел как там сделано — передается хеш капчи.
                      Генерирую не самостоятельно, а просто во вью пишу «вывести капчу».

                      Я, кажется, потерял нить спора. Я говорю, что в php фреймворке yii фронтенд валидация из коробки. Правила описываются единожды, а фреймворк генерирует js код. Причем для этого controller не надо менять, если это не серверная валидация.

                      А вы говорите, что php отстой. Ну… я это знаю, потому давно уже не пэхэпэшник.
                        0
                        То, что описывается в этом посте — это ajax-валидация. Как способ, описываемый в посте, так и имеющийся в Yii требуют дополнительных телодвижений (костыли, описываемые в посте даже больших).

                        Валидация на клиенте, которая есть в Yii «из коробки» принципиально не отличается от той, что предоставляется gem'ом client_side_validations (т. е. клиентский код для стандартных валидаций генерируется фреймворком). Т. е. с тем же успехом можно сказать, что в Rails это «из коробки» (достаточно поставить gem, выполнить 1 команду и добавить 1 строчку в layout). Ajax-валидация с этим gem'ом проще чем в Yii, т. к. не требует вообще никаких дополнительных телодвижений.

                        Стоит сказать, что с PHP я сталкивался во времена 3 версии, что, в общем-то, было давно. Тогда это было более ужасно. И то, что там появляются нормальные фреймворки может улучшить средний уровень поделок в интернетах.
                    0
                    Я уже очень долго не пишу на php. Сейчас с рабочего компьютера нет доступа к старым проектам. Но там как-то так.
                      0
                      Наверное я не полностью откровенен.
                      Да, для аякс валидации нужно иметь метод на сервере, который бы обрабатывал эту самую валидацию. Не обязательно что-либо изменять, можно его создать отдельно. Но мы же говорим о клиентской валидиции без какого-либо общения с сервером. В таком случае контроллер изменяить не надо.
                    +2
                    и вот еще насчет валидаций: Прекратите использовать regexp для email!

                    Only users with full accounts can post comments. Log in, please.