Правильная работа с ActiveRecord

июля 21, 2012  |  Published in Model, Ruby on Rails, Ruby on Rails 3  |  13 Comments

Статья будет иметь несколько хаотичной. В ней я приведу несколько примеров того, что я считаю неправильной работой с ActiveRecord. Что-то в статье — истина последней инстанции, а что-то — субъективное и, возможно, ошибочное мнение автора. Статья ориентирована на тех, кто хоть немного знаком с Ruby on Rails.

Очень популярная ошибка — не использование limit(1) для выборки одиночной записи с использованием where и других методов Quering API. Суть ошибки в том, что Rails не может за вас подумать о том, что вам необходима одна единственная запись, а не все удовлетворяющие условию. В случае, когда вы не используете limit(1) будет выполняться поиск по всей таблице, а не только до первой соответствующей условию записи.

Word.where(word: 'cat')
# Word Load (0.9ms) SELECT "words".* FROM "words" WHERE "words"."word" = 'cat'
# => [records]

Word.where(word: 'cat').limit(1)
# Word Load (0.7ms) SELECT "words".* FROM "words" WHERE "words"."word" = 'cat' LIMIT 1
# => [record]

UPD Вообще не пользуйтесь .limit(1), вместо .limit(1) используйте first, если вам действительно необходим лишь один элемент. Дело в том, что limit(1) возвращает массив с одним единственным элементом, чтобы получить этот элемент нам необходимо вызывать еще один метод — first (Array#first), или обращаться к элементу через индекс — Model.limit(1)[0].

Tag.limit(1).first
# Tag Load (1.6ms)  SELECT "tags".* FROM "tags" LIMIT 1

Tag.first
# Tag Load (0.8ms)  SELECT "tags".* FROM "tags" LIMIT 1

Следующей популярной ошибкой является использование методов QueringAPI в контроллерах. Вы должны постараться вынести максимум ваших запросов в то, что называется scope’ами. В контроллерах же должны использоваться уже scope, а не «классические» методы QueringAPI. Это не строгое правило, но желательно чтобы было именно так. Глядя в код модели вы сразу понимаете где и как она используется благодаря объявлениям scope’ов.

Вместо этого:

def top_100
  @popular_words = Word.order('popularity asc').limit(100)
end

Вы должны объявить в модели Word соответствующий scope:

scope :top_100, order('popularity asc').limit(100)

или

scope :top, lambda { |num| order('popularity asc').limit(num) }

А в экшене top_100 (top) уже использовать:

def top_100
  @popular_words = Word.top_100
end

Еще лучше так:

def top
  limit = params[:limit] || 100
  @popular_words = Word.top(limit)
end

Scope позволяют давать запросам «говорящие» имена и хранить их все в одном месте. Также скоупы помогают вести поиск по коду, но это уже т.с. побочный эффект.

Следующая ошибка невероятно популярна. Суть ее заключается в том, что благодаря LazyLoading многие, если не большинство, запросов осуществляются уже при рендеринге. Глядя в код контроллера мы не видим всей картины того, что происходит в нашем приложении, для этого нам необходимо открывать все файлы представлений, которые соответствуют тому или иному экшену. Например в экшене Forums#show мы делаем такой запрос:

def show
  @forum = Forum.find(params[:id])
end

В коде же представления мы имеем следующий код:

<% @forum.topics.each do |topics| %>
  <div><%= topic.title %></div>
<% end %>

Глядя на код экшена мы можем только предполагать, но не знать то, что показывает пользователю связанное с ним представление.

Правильным решением будет следующий экшен:

def show
  @forum = Forum.find(params[:id])
  @topics = @forum.topics
end

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

Делаем правило. Максимум запросов должно выполняться в контроллере или, если вы используете дополнительную прослойку — Presenter, то в этой прослойке. Чем меньше вы размажите логику запросов данных по приложению — тем лучше.

Следующее не является ошибкой, а приводится для информирования новичков о том, как оптимизировать потребление памяти. Запрашивая определенные записи из БД мы получаем полные записи если не используем метод select. В случае, когда мы хотим вывести ссылки на 10 последних статей нам необходимы лишь их заголовки и id, но не полное их содержимое. select — это ограничитель по столбцам, он позволяет выбрать только необходимые данные присутствующие в записи тем самым сэкономив RAM. При размере основного текста статьи 20кб, количестве статей 20 штук мы экономим только на одном запросе минимум 400 кб. Что если в секунду таких запросов сотня или тысяча? Чаще всего select не используют сразу, а только по мере возникновения необходимости в оптимизации, то есть при реальном столкновении с проблемой.

Article.select([:id, :title]).all

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

В одном проекте над которым я работаю имеются категории фотографий. Сами категории не могут иметь (has_many) фотографии, но могут иметь вложенные в них категории (тот же класс PhotoCategory). Имеется только один уровень вложенности категорий фотографий Категория -> Подкатегории (main_category -> subcategories). Очевидно, что нужно проследить за тем, чтобы в категорию первого уровня нельзя было добавить фотографии. Для подобных целей часто используют проверки, которые имеют следующий вид:

if !@category.main_category_id.present? || @category.is_default?
  # do something
end

#is_default? вернет true, если категория дефолтная, дефолтная — это такая одна категория, в которую складываются фотки до того, как будут распределены по другим категориям (на то есть причины почему они сразу не распределяются).

Такая проверка безусловно правильная, но гораздо лучше использовать специальные хелперы модели. Они сделают код гораздо понятнее и лаконичнее. Пример хелперов из PhotoCategory:

def is_main_category?
  main_category_id.peresent?
end

def can_contain_photos?
  is_main_category || is_default?
end

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

if @category.can_contain_photos?
# do something
end

Сомнительной «фичей» ActiveRecord является default_scope. Я рекомендовал бы вам отказаться от его использования или очень хорошо задуматься о том, стоит ли. default_scope позволяет определить scope по умолчанию, который незаметно используется при каждой выборке. Иногда можно забыть о нем и написать неправильный код (особенно если не применяется тестирование) или, в случаях, когда он не нужен — необходимо его явно отключать. Еще одной проблемой с его использованием является то, что читая код разработчик может заблуждаться в отношении того как код работает глядя в запрос размещенный в каком-нибудь экшене. Необходимо постоянно держать в уме то, что в моделях может наличествовать default_scope и проверять их. Чтобы отключить default_scope для некоторых запросов необходимо использовать в них метод unscoped:

Post.unscoped.my_scope

Не все знают, что некоторые методы из QueringAPI возвращают не просто результат в виде массива записей, а ActiveRecord::Relation объект. Именно благодаря ActiveRecord::Relation и реализуется LazyLoading. Зная об этом можно вместо такого кода:

def index
  if params[:some_attr]
    @articles = Article.scope_1.scope_2.scope_3(params[:some_attr])
  else
    @articles = Article.scope_1.scope_2
  end
end

Писать такой:

def index
  @articles = Article.scope_1.scope_2
  @articles = @articles.scope_3(params[:some_attr]) if params[:some_attr]
end

У новичком имеется также несколько ошибок в работе с миграциями, вот они:

1. Непонятные имена миграций. Некоторые считают, что это не важно как ты назовешь миграцию и дают названия типа «Ffff» или что-то типа того. На самом деле это очень важно, это позволяет облегчить поиск по коду. Если миграция создает таблицу articles, то правильно ее назвать CreateArticles, если добавляет новое поле в таблицу, то AddNewColumnToArticles и т.д.

2. Часто забывают определять default значение для столбцов. Если столбец имеет тип integer, то чаще всего необходимо прописывать default: 0, если boolean, то default: false (или true), если string или text, то иногда следует использовать default: «»

3. Максимальному количеству полей необходимо присваивать атрибут null: false. Это ускорит выборку из базы данных (по крайней мере это так для MySQL 4 и 5). А еще, в случае когда дефолтного значения нет и пользователь пытается добавить запись с некоторым пустым значением, null: false послужит дополнительной валидацией. Вообще в миграциях старайтесь максимально подробно описать поля таблицы — это позволит вам быть уверенным в том, что не валидная запись не попадет в БД, а еще добиться некой оптимизации. Об этом очень часто забывают или пренебрегают.

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

scope :not_deleted, where(is_deleted: false)
scope :best, not_deleted.where('rank >= 4.5')

Тогда вы должны создать два индекса, один для столбца is_deleted, а второй для пары [:is_deleted, :rank]

add_index :articles, :is_deleted
add_index :articles, [:is_deleted, :rank]

Будьте аккуратны с добавлением индексов! Чем больше индексов — тем медленнее вставка из-за добавления индекса и обновление записи из-за обновления индекса. Используйте индексы только там, где это действительно необходимо, то есть там, где это значительно повысит продуктивность, в противном случае они наоборот могут ее ухудшить.

Частыми ошибками является не использование таких методов, как minimum, maximum, count, sum, calculate и average. Начинающие разработчики пишут свои велосипеды на Ruby для этих функций, которые есть в SQL при том, что: 1. Мы не делаем выборку из БД всех записей, 2. SQL быстрее чем выполнение Ruby кода, 3. Меньше потребление RAM.

Можно часто встретить что-то вроде этих примеров:

Order.current.products.map(&:price).inject(0){|sum, price| sum += price}
Product.order("price desc").first

Приведенные выше примеры являются примерами плохого кода. Хороший код выглядит так:

Order.current.products.sum(:price)
Product.maximum(:price)

Большой проблемой являются небезопасные запросы. Чаще всего это проявляется в непосредственной вставке какого-нибудь параметра в SQL выражение. Например:

Word.where("word = #{params[:word]}")

Чем плох этот код? — Тем, что он не защищен от SQL-injection (SQL иньекций). Злоумышленник может в качестве значения params[:word] передать что-то вроде этого: «‘ololo’ OR 1 = 1″. Таким образом будут выбраны все записи. SQL иньекция может быть и более интересной и опасной.

Вместо приведенного выше кода следует использовать следующие варианты:

Word.where(word: params[:word])
Word.where('word = ?', params[:word])
Word.where('word = :word', word: params[:word])

Следующая ошибка, которую мы рассмотрим несколько связана с предыдущей. В Rails 3 была добавлена такая штука как ARel (relation algebra). Если просто изьясняться, то ARel — это фреймворк для создания SQL запросов. Многие разработчики по инерции продолжают использовать старый стиль запросов — использование SQL строк в where, например. Синтаксис Arel-style запросов может быть несколько менее лаконичен, зато он — это Rails way. Приведу пример:

Word.where "word LIKE :word", word: "%#{params[:word]}%"
Word Load (1.0ms) SELECT "words".* FROM "words" WHERE (word LIKE '%ruby%')

Это пример не Arel-style запроса. А вот пример такового:

table = Word.arel_table
Word.where(table[:word].matches("%#{params[:word]}%"))

На этом мы наверное и закончим. Если что-то новое вспомнится или узнается — допишу статью, а пока добавьте в избранное на будущее.

Tags: , , ,

Responses

  1. says:

    июля 21, 2012 at 12:13 (#)

    Несколько замечаний

    Word.where(word: 'cat').limit(1) 
    

    - будет тоже возвращен массив, но с одной записью, для возврата одиночной записи нужно использовать

    Word.where(word: 'cat').first
    
    @topics = @forum.topics
    

    В этом случае запрос все же не будет выполнен в контроллере, в переменной @topics будет сохранен relation, а запрос будет выполнен все же во вьюхе при первом обращении к этой переменной

  2. admin says:

    июля 21, 2012 at 13:33 (#)

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

  3. Max says:

    июля 21, 2012 at 13:45 (#)

    Ни слова про pluck?

  4. admin says:

    июля 21, 2012 at 14:08 (#)

    О pluck и gem’е Valium я хочу отдельную статью в ближайшее время написать. Тогда и добавлю новый раздел сюда т.с. что бы в новой статье не повторяться.

  5. says:

    июля 21, 2012 at 14:50 (#)

    Про pluck тут достаточно полно написано:

  6. Павел says:

    июля 21, 2012 at 16:09 (#)

    По-моему, default_scope есть смысл использовать только в случае когда модель при удаление не удаляет данные, а только выставляет флаг что она удаленна, тогда вот удобно сделать чтобы по умолчанию выбирались все «не удаленные» записи. А в других ситуациях это зло.

    def show
    @forum = Forum.find(params[:id])
    @topics = @forum.topics
    end
    Интересное мнение. А как быть в случае если надо отображать forums и для каждого topics?

  7. admin says:

    июля 21, 2012 at 18:20 (#)

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

  8. rubytor says:

    июля 21, 2012 at 19:45 (#)

    >>> Тогда вы должны создать два индекса, один для столбца is_deleted, а второй для пары [:is_deleted, :rank]
    add_index :articles, :is_deleted
    add_index :articles, [:is_deleted, :rank]

    При такой организации первый индекс не нужен, так как второй его дублирует и он можт быть использован в обоих scope.

  9. rubytor says:

    июля 21, 2012 at 19:48 (#)

    Кстати, раскажи, пожалуйста, как по rails way сделать в миграциях поле int unsigned? И в довесок, как сделать поле id unsigned? Ведь знак ему в принципе не нужен.

  10. arni says:

    июля 21, 2012 at 21:38 (#)

    >Тогда вы должны создать два индекса, один для столбца is_deleted …
    Я не знаток многих СУБД, но с теми, где работал, за индексирование булевых полей били по рукам, ибо толку от индекса с такой хилой селективностью нет никакого.

  11. admin says:

    июля 22, 2012 at 09:46 (#)

    arni, это зависит от распределения true и false, если их примерно поровну, то да, а если миллион false и несколько true, то тогда индексы нужны + если поиск по нескольким булевым полям.

  12. admin says:

    июля 22, 2012 at 09:49 (#)

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

  13. arni says:

    июля 22, 2012 at 10:54 (#)

    >а если миллион false и несколько true, то тогда индексы нужны

    Этот «вырожденный» пример не исправит селективность у индекса, а значит оптимизаторы СУБД, не имеющих гистограм, будут отцеплять такой индекс и для true и для false. Т.е. он останется абсолютно бесполезен, или даже вреден, учитывая тормоза на вставку и на усложнение поиска оптимального плана select.

    Считаю, упоминание одиночного (не композитного вкупе с др. полем) индекса на булевское поле нужно убирать из текста статьи, т.к. он бессмысленен и для нормального распределения значений и для специфического случая 1:1000000

Leave a Response

Для подсветки кода используйте BB - коды: [language]...[/language], где language может быть: ruby, javascript, css, html.