На Ruby и многих других языках есть удобные ORM-решения для программного доступа к СУБД. Также есть фреймворки вроде RubyOnRails для простого и удобного создания web-приложений, работающих с базой данных. Простые соглашения позволяют писать мало кода и при этом создавать мощные интерфейсы.

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

Стандартные действия для получения возможности работы с сущностью в Rails — создать модель, создать контроллер, создать представление (а если у нас полный набор REST-действий, то два — на получение списка и на получение одной записи в случае работы через API). Действия простые, и файлы очень простые. И когда словарей много, то файлов получим также большое количество. Большое количество простых и однотипных файлов. И где-то среди них будут затеряны большие, нетипичные файлы. Кроме большого количества файлов, мы получаем проблемы с необходимостью прописывать маршруты. Даже тем, кто такие системы поддерживает не один год, ориентироваться в проекте будет тяжело, не то что новому разработчику.

Здесь на помощь приходит магия Rails.

Для простоты предположим, что мы строим JSON API. Хотя ничто не помешает нам добавить поддержку XML.

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

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

class UniversalApiController < ApplicationController
  
  before_action :prepare_model, only: [:index, :show, :create, :update, :destroy]
  before_action :find_record, only: [:show, :update, :destroy]
  
  def index
    @res = @model_class
    @res = @res.limit(params[:limit].to_i) if params[:limit]
    select_list = permitted_select_values
    @res = @res.select(select_list) if select_list
    @res = @res.ransack(params[:q]).result
    
    render json: @res
  end

  def show
    render json: @res
  end

  def create
    if @res = @model_class.create(permitted_params)
      render json: @res
    else
      invalid_resource!(@res)
    end
  end

  def update
    if @res.update_attributes(permitted_params)
      render json: @res
    else
      invalid_resource!(@res)
    end
  end

  def destroy
    @res.destroy
    raise @res.errors[:base].to_s unless @res.errors[:base].empty?
    render json: { success: true }, status: 204
  end
  
  protected
    def permitted_select_values
      if params[:select]
        case params[:select]
        when String
          permitted_select_value params[:select]
        when Array
          params[:select].map { |field| permitted_select_value field }.compact
        end
      end
    end
    
    def permitted_select_value field
      @select_fields ||= @model_class.column_names + extra_select_values
      (@select_fields.include? field) ? field : nil
    end
    
    def extra_select_values
      []
    end
    
    def permitted_params
      params.permit![_wrapper_options.name]
      params[_wrapper_options.name].extract! @model_class.primary_key
      params[_wrapper_options.name]
    end
    
    def get_model_name
      params[:model_name] || controller_name.classify
    end
    
    def prepare_model
      model_name = get_model_name

      raise "Model class not present" if model_name.nil? || model_name.strip == ""
      
      @model_class = model_name.constantize
      
      raise "Model class is not ActiveRecord" unless @model_class < ActiveRecord::Base
    end
    
    def find_record
      @res = @model_class.find(params[@model_class.primary_key.to_sym])
    end
end

И немного записей в config/routes.rb:

Rails.application.routes.draw do
...
  scope 'universal_api/:model_name', controller: 'universal_api' do
    get '/', action: 'index'
    get '/:id', action: 'show'
    post '/', action: 'create'
    put '/:id', action: 'update'
    delete '/:id', action: 'destroy'
  end
...
end

Теперь пойдем по порядку:

scope 'universal_api/:model_name', controller: 'universal_api'

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

Контроллер:

  before_action :prepare_model, only: [:index, :show, :create, :update, :destroy]
  before_action :find_record, only: [:show, :update, :destroy]

Перед всеми действиями нам надо подготовить модель, а перед показом, обновлением и удалением еще найти запись.

Имя модели в самом простом случае будет доступно в params[:model_name]. Однако в классе-наследнике имя модели обычно можно получить из имени контроллера. В некоторых случаях, когда Rails не сможет адекватно преобразовать имя контроллера в нужное нам имя модели, надо иметь возможность задать его явно. Поэтому создадим отдельный метод, возвращающий имя модели и отдельный метод, преобразующий его в реальный класс.

    def get_model_name
      params[:model_name] || controller_name.classify
    end
    
    def prepare_model
      model_name = get_model_name

      raise "Model class not present" if model_name.nil? || model_name.strip == ""
      
      @model_class = model_name.constantize
      
      raise "Model class is not ActiveRecord" unless @model_class < ActiveRecord::Base
    end

Все-таки Ruby — замечательный язык. А Rails — замечательный фреймворк.

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

    def find_record
      @res = @model_class.find(params[@model_class.primary_key.to_sym])
    end

Подготовка закончена, теперь перейдем к конкретным действиям.

Удаление и просмотр одной записи


Самые простые действия, даже нечего добавить.

  def show
    render json: @res
  end
...
  def destroy
    @res.destroy
    raise @res.errors[:base].to_s unless @res.errors[:base].empty?
    render json: { success: true }, status: 204
  end

Получение всех записей


  def index
    @res = @model_class
    @res = @res.limit(params[:limit].to_i) if params[:limit]
    select_list = permitted_select_values
    @res = @res.select(select_list) if select_list
    @res = @res.ransack(params[:q]).result
    
    render json: @res
  end

Часто нам хочется ограничить количество выдаваемых клиенту записей, мы это делаем с помощью params[:limit]. Также далеко не всегда клиенту нужны все поля модели. Мы ограничим доступные значения имеющимися в таблице колонками и предоставим возможность потомкам при необходимости добавить нужные значения. Ограничение нужно, потому что мы используем метод select, который позволяет использовать любые строки. То есть и вложенные запросы, и вообще любые запросы.

    def permitted_select_values
      if params[:select]
        case params[:select]
        when String
          permitted_select_value params[:select]
        when Array
          params[:select].map { |field| permitted_select_value field }.compact
        end
      end
    end
    
    def permitted_select_value field
      @select_fields ||= @model_class.column_names + extra_select_values
      (@select_fields.include? field) ? field : nil
    end

    def extra_select_values
      []
    end

Кроме простого ограничения на количество записей, очень часто хочется иметь возможность использования более интеллектуальных ограничений. А также сортировки, группировки и т.д. Это легко реализуется с помощью Ransack.

@res = @res.ransack(params[:q]).result
— эта строчка дает нам возможность осуществлять очень сложный поиск по параметрам модели, ассоциаций, а также предоставляет возможность сортировки и использования scope-ов и методов класса модели.

Создание и редактирование записи:

  def create
    if @res = @model_class.create(permitted_params)
      render json: @res
    else
      invalid_resource!(@res)
    end
  end

  def update
    if @res.update_attributes(permitted_params)
      render json: @res
    else
      invalid_resource!(@res)
    end
  end

В Rails по-умолчанию запрещено массовое заполнение атрибутов модели из параметров. Для нашего простого словаря это скорее всего неактуально. А для более сложного случая можно явно определить список доступных для массового заполнения атрибутов.

    def permitted_params
      params.permit![_wrapper_options.name]
      params[_wrapper_options.name].extract! @model_class.primary_key
      params[_wrapper_options.name]
    end

Создание и редактирование производится методами POST и PUT соответственно, данные передаются в теле запроса. Rails любезно парсит и оборачивает эти данные в отдельный параметр.

Итоги


Приведенный способ позволяет решить довольно узкий круг задач — не так и часто в проектах есть необходимость в подобном доступе к данным. Однако определенному кругу разработчиков это будет очень близко и полезно — количество однотипных файлов-пустышек сократится до 4 (!!!) раз, исходный код освободится от типовых методов. Да и вообще не надо будет лишний раз думать и изобретать велосипед для организации простой работы с простыми моделями.

При необходимости можно немного продолжить мысль и реализовать возможность рендеринга с помощью отдельных представлений.

В данной статье не затрагивались вопросы разграничения доступа. Обычно за управление доступом отвечает отдельная подсистема. Также отдельно хотелось бы отметить, что из всего стека Rails здесь используется не так и много технологий — ActiveRecord, ActiveSupport и отдельно стоящий Ransack. Можно с небольшими усилиями реализовать подобное в Sinatra или другом Rails фреймворке.

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


  1. rinat_crone
    19.11.2015 15:37

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

    герб_веб_разработчиков.jpg

    Новички, прошу вас, не берите это за основу для своих проектов, не учитесь плохому.


    1. sov-87
      19.11.2015 16:47

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

      Для каждого шаблона проектирования есть своя область применения. Приведенный выше шаблон не претендует на большой охват, область его применения очень специфична. И никто не призывает использовать его в тех местах, где он будет плох.
      Скорее всего у вас другие проекты и другие задачи, если он вам кажется ужасным. Хотя если вы предложите что-то более подходящее, будет очень здорово. С большим удовольствием ознакомлюсь с вашей заметкой.


      1. Renius
        20.11.2015 06:19

        Да вы верно шутите?! Шаблон очень распространенный. Так или иначе, полностью или частично, я почти уверен, что каждый rails разработчик сталкивается с вашей проблемой. Но это не повод писать велосипед.
        Возьмите inherited_resources, и можно вообще ничего не городить.


        1. faost
          20.11.2015 10:57

          github.com/josevalim/inherited_resources

          Inherited Resources is no longer actively maintained… I suggest developers to make use of Rails' respond_with feature alongside the responders gem as a replacement to Inherited Resources.


          1. Renius
            20.11.2015 15:49

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


  1. Renius
    19.11.2015 16:42

    Index должен возвращать respond_with collection, все остальное должно быть в сервисах и концернах. Логика в контроллерах плохой тон, уже много лет.


    1. sov-87
      19.11.2015 17:34

      respond_with — да, пропустил.

      все остальное должно быть в сервисах и концернах. Логика в контроллерах плохой тон, уже много лет.
      — если наш контроллер достаточно тонкий и нацелен на решение ограниченного круга задач, то почему бы и не написать в нем логику? Это же не гем, который призван заткнуть собой все возможные щели и позволить дальнейшую модификацию под любой возможный и невозможный случай.


      1. Renius
        19.11.2015 23:12

        Ваш контроллер не достаточно тонкий — натравите на него мутанта и вы увидите 1000+ мутаций.


  1. matiouchkine
    19.11.2015 20:53

    Скажите, когда вы пишете `select { |s| s }` вас самого-то ничего не беспокоит? Прям фортраном повеяло.


    1. sov-87
      19.11.2015 22:51

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


      1. printercu
        19.11.2015 23:57

        Enumerable#compact


  1. printercu
    19.11.2015 23:56

    На POST /universal_api/kernel ответ будет, видимо, 500 и ошибка в логах `undefined method name 'column_names' for Kernel`.


    1. sov-87
      20.11.2015 10:42

      Спасибо за замечание. Более правильно будет давать доступ только к классам-потомкам ActiveRecord.


  1. Anstak
    20.11.2015 09:42

    Для меня как фронтэндщика, этот код кажется вполне достойным.Я в бэке не профессионал, но часто пишу код в рельсах, и ваш код сохраню себе в проект, т.к. раньше думал об этом, но продолжал делать разные модели.

    Вообще сообщения выше меня немного удивляют, этот код не рассчитан на сложный проект, он рассчитан на взятие определенной строки/удаления ее по параметрам, добавление новой строки и т.п. Главное не забыть про авторизацию).

    Спасибо, мне код понравился.


  1. sl_bug
    20.11.2015 13:40

    А чем rails_admin плох?