Как я оптимизировал работу своего Active Admin с помощью 4 простых приемов

Как я оптимизировал работу своего Active Admin с помощью 4 простых приемов

Содержание
  1. Представление набора данных
  2. Введение в ActiveAdmin
  3. Фильтры
    1. 1️⃣ Всегда используйте пользовательские фильтры
    2. 2️⃣ Предварительная загрузка собственной коллекции и ее кэширование
  4. Индекс
    1. 3️⃣ Предварительная загрузка данных в контроллер
    2. 4️⃣ Предварительная загрузка данных в представление
  5. Результаты
  6. Заключение

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

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

Представление набора данных

Чтобы проиллюстрировать наши примеры, давайте представим, что вам нужно интегрировать интерфейс администратора для агентства по подбору персонала.

У вас будет 4 таблицы:

Таблица для хранения клиентов, которые платят за поиск временных работников (Owner) Таблица для хранения всех доступных временных работников (User) Таблица для хранения всех миссий (Mission) Таблица для хранения всех смен миссий (MissionShift)

create_table "users" do |t|
  t.string "email"
  t.string "first_name"
  t.string "last_name"
end

create_table "owner" do |t|
  t.string "name"
end

create_table "missions" do |t|
  t.string "description"
  t.string "title"
  t.bigint "client_id", null: false
end

create_table "mission_shifts" do |t|
  t.bigint "mission_id", null: false
  t.date "day"
  t.datetime "begin_time"
  t.datetime "end_time"
  t.bigint "user_id"
end

Отношения

У пользователя есть множество MissionShift. MissionShift принадлежит миссии. Миссия принадлежит владельцу.

Итак, давайте разберемся в нашем примере!

Введение в ActiveAdmin

Наша цель - использовать ActiveAdmin для создания интерфейса администратора как можно быстрее. Вам нужна страница, на которой будут отображаться:

Названия миссий Клиент, которому принадлежит миссия Сколько смен укомплектовано пользователем

Прочитав немного документации, вы открываете для себя замечательный DSL ActiveAdmin.

Вы быстро придумали такой код:

ActiveAdmin.register Mission do
  index do
    selectable_column
    column :title
    column :owner
    column('Mission Shifts') do |mission|
      mission_shifts = mission.mission_shifts
      "#{mission_shifts.count(:user_id)}/#{mission_shifts.size}"
    end
    actions
  end
end

В результате получается следующее:

16f7rkznufs3jr99pnrn.png
16f7rkznufs3jr99pnrn.png

Вы готовы покорить мир с этой страницей администратора!

Тем не менее, вы должны знать, что эта страница содержит 3 источника N+1 запросов. Чтобы доказать это, мы воспользуемся гемом “rack-mini-profiler”. Этот гем позволяет проследить путь сервера при рендеринге страницы.

Вот скриншот того, что показывает нам rack-mini-profiler:

У нас 148 SQL-вызовов в представлении 😲. Это очень много!

Если посмотреть на серверную часть, то вот информация, которую мы получаем при загрузке страницы:

Completed 200 OK in 741ms (Views: 455.5ms | ActiveRecord: 266.5ms | Allocations: 1035126).

Страница загружается очень долго и выделяет огромное количество памяти!

Цель этой статьи - показать вам, как значительно сократить количество SQL-вызовов и выделение памяти, тем самым оптимизировав время отрисовки страницы и уменьшив утечки памяти!

Фильтры

1️⃣ Всегда используйте пользовательские фильтры

Первый совет, которым я хотел бы поделиться сегодня, хорошо известен. Он не решает проблему N+1, но значительно сокращает объем памяти, выделяемой странице:

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

На самом деле ActiveAdmin старается быть полезным и генерирует фильтры для всех атрибутов вашей таблицы.

Если у вас много атрибутов, вам будет трудно ориентироваться. Если у вас есть связи с другими моделями, ActiveAdmin будет предварительно загружать всю коллекцию ActiveRecord. В нашем случае по умолчанию включены модели Owner и MissionShift.

Всегда указывайте хотя бы один фильтр:

ActiveAdmin.register Mission do filter :title [...] end.

Вот рендеринг на стороне сервера при загрузке страницы:

До модификации: Completed 200 OK in 620ms (Views: 422.9ms | ActiveRecord: 186.0ms | Allocations: 928758).

После модификации: Completed 200 OK in 581ms (Views: 403.4ms | ActiveRecord: 168.0ms | Allocations: 703878).

Огромная разница в выделении памяти!

2️⃣ Предварительная загрузка собственной коллекции и ее кэширование

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

ActiveAdmin.register Mission do
  filter :owner, as: :select, collection: lambda { Owner.pluck(:name, :id) }
  # ...
end

Вот рендеринг на стороне сервера при загрузке страницы:

Completed 200 OK in 666ms (Views: 337.9ms | ActiveRecord: 316.7ms | Allocations: 761030).

Это все еще лучше, чем с фильтрами по умолчанию.

Если вы хотите пойти еще дальше в оптимизации, вы также можете настроитьлогику кэширования, например, так:

ActiveAdmin.register Mission do
  filter :owner, as: :select, collection: lambda {
    Rails.cache.fetch('owners_name_id', expires_in: 1.hour) do
      Owner.pluck(:name, :id)
    end
  }
  # ...
end

Это дает нам:

Completed 200 OK in 816ms (Views: 531.6ms | ActiveRecord: 248.3ms | Allocations: 711373).

С точки зрения распределения, мы проделали долгий путь!

Индекс

На нашей индексной странице у нас две проблемы:

Отображение колонки Владелец Отображение количества смен, в которых есть пользователь на миссию

Если кратко описать работу ActiveAdmin, то для каждой строки он делает 3 SQL-запроса:

Один для поиска владельца Один для поиска MissionShifts Один для подсчета MissionShifts, в которых есть пользователь

Как вы можете видеть, все наши N+1 фактически сосредоточены здесь.

Единственный способ решить наши проблемы - это предварительно загрузить как можно больше данных. Для этого у нас есть два пути:

Предварительно загрузить данные в контроллер Загрузить данные один раз и мемоизировать их в представлении

3️⃣ Предварительная загрузка данных в контроллер

ActiveAdmin позволяет нам создавать собственные методы контроллера. До сих пор мы работали только с методом #index, так что если мы хотим предварительно загрузить данные, то это самое подходящее место!

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

ActiveAdmin.register Mission do
  # ... other code ...

  index do
    # ... other code ...

    column('Owner') do |mission|
      owners.fetch(mission.owner_id)
    end

    # ... other code ...
  end

  controller do
    # перед загрузкой чего-либо, мы предварительно загружаем Owners
    def index
      @owners = Owner.pluck(:id, :name).to_h
      # вызов метода начального индекса
      super
    end
  end
end

Если мы посмотрим на rack-mini-profiler, то получим следующее:

yhojx6a25u0ajlvf1ccx.png
yhojx6a25u0ajlvf1ccx.png

Мы сократили количество обращений к базе данных на ~30. Почему именно на столько? Так как один и тот же владелец был у нас несколько раз на разных миссиях, ActiveAdmin сохранял вызов в кэше и извлекал информацию, а не делал новый запрос.

Эта техника хорошо работает, если у вас нет большого объема данных в таблице Owner. Если вы хотите оптимизировать еще немного, вы также можете использовать кэш. А если вы хотите оптимизировать еще больше, вы можете повторно использовать кэш, который мы вместе создали в фильтрах, чтобы дважды использовать одни и те же значения на странице.

4️⃣ Предварительная загрузка данных в представление

Предварительная загрузка данных в контроллере - это хорошо, но мы можем загрузить слишком много данных. Таблица Mission имеет больший объем, чем Owner, поэтому мы не можем позволить себе предварительно загрузить всю таблицу. К сожалению, мы не знаем, какие миссии будут загружены на страницу на уровне контроллера.

Но есть одно место, где мы знаем, какие миссии будут отображаться: в представлении!

Другими словами:

ActiveAdmin.register Mission do
  index do
    column('Sample Column') do
      missions.count # => 50
    end
  end

  controller do
    def index
      pp missions.count # => NameError - undefined local variable or method `missions' for #<Admin::MissionsController:0x000000000a4128>:
      super
    end
  end
end

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

Первый шаг - найти запрос для предварительной загрузки этих данных для набора миссий:

class Mission < ApplicationRecord
  # выведет миссии с "id", "total_shifts" и "shifts_with_user"
  def self.mission_shifts_with_users
    self.left_joins(:mission_shifts)
      .select(
        :id,
        'COUNT(mission_shifts.id) AS total_shifts',
        'COUNT(DISTINCT CASE WHEN mission_shifts.user_id IS NOT NULL THEN mission_shifts.id END) AS shifts_with_user'
      )
      .group('missions.id')
  end
end

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

ActiveAdmin.register Mission do
  index do
    column('Mission Shifts') do |mission|
      @missions_with_mission_shifts ||= missions.mission_shifts_with_users
      mission_shifts = @missions_with_mission_shifts.find { |m| m.id == mission.id }
      "#{миссия_смены.смены_с_пользователями}/#{миссия_смены.общие_смены}"
    end
  end
end

Используя мемоизацию, мы делаем запрос только один раз, и он используется для всех наших миссий.

Если мы выполним тур по rack-mini-profiler, то получим:

xcqrvnwbab7ss9n1gzev.png
xcqrvnwbab7ss9n1gzev.png

Мы избавились почти от 100 обращений к базе данных, предварительно загрузив данные для Owners и MissionShifts.

Результаты

Вот результаты с применением всех советов:

Рендеринг на стороне сервера:

До: Завершено 200 OK за 741 мс (Views: 455.5 мс | ActiveRecord: 266.5 мс | Allocations: 1035126).

После: Completed 200 OK in 200ms (Views: 187.3ms | ActiveRecord: 5.9ms | Allocations: 517789).

Выделение памяти сократилось вдвое.

Время отклика составляет около 200 мс (что вполне приемлемо).

Rack-mini-profiler:

p1rmxtxpayjd3ugne0ok.png
p1rmxtxpayjd3ugne0ok.png
xcqtwpjkrhfpx0jngiac.png
xcqtwpjkrhfpx0jngiac.png

Цифры говорят сами за себя: всего 6 SQL-запросов против 148 ранее. IЭто огромный зазор для производительности ваших приложений.

Важным показателем, на который следует обратить внимание, является % в sql, который мы снизили с 15,6 до 3,5%, что является огромной разницей!

Заключение

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