D:\sideБлогRails и ActiveRecord

⚠️ Обращайте внимание на даты.
Этот блог больше не ведётся с 17 января 2023, и на тот момент с написания этой страницы (24.05.2014) прошло 8 лет.

Мы посовещались и решили, что люди хотят знать больше о принципах работы с базами данных. Я уже немножко о них рассказывал ранее, в контексте Rails, но сейчас расскажу немножко больше. Говорить мы будем об ActiveRecord, об “SQL с человеческим лицом”. Эта система служит “мостиком” между объектами Ruby и реляционной базой данных (РСУБД). Для таких штук даже придуман специальный термин — ORM.

Реляционные базы данных? Зачем?

Чем вообще круты такие базы? В них достаточно легко производить поиск и хранить огромные объёмы данных. И поиск по ним идёт очень быстро, если речь идёт об отдельном сервере с базой, который выделяет на данные существенный объём оперативной памяти. Запросы по выводу полезного набора данных мне на работе удаётся умещать в 2 миллисекунды на страницу при использовании PostgreSQL. Но это сейчас неважно.

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

Задачи на такие данные часто возникают на динамических сайтах во Всемирной Паутине. Форумы, социальные сети, блоги &mdsh; все они часто реализованы поверх такой базы. И это обычно разумно, да. Хотя вот в случае со своим блогом я решил, что это неразумно и заставил его собираться просто из набора файлов, а комментарии вставил со сторонней службы — великолепно, мне не нужно париться о том, как они работают!

Что такое SQL?

SQL обычно используется как язык запросов к реляционной базе данных. Запрос, в переводе на русский язык, часто выглядит примерно так: Дай мне из таблицы x всё, где y равен 3. Это довольно примитивный случай, конечно, но о более продвинутых мы поговорим дальше.

Вы могли слышать об SQL в возможных способах взлома сайтов: SQL-инъекции. Это подача в запрос таких данных, которые при вшивании в него могут заставить запрос делать совсем другое. Скажем, в пример выше вместо 3 можно подставить 3 а потом отдай мне все записи из таблицы admin (XKCD знает). Как правило, это следствие неопытности или недосмотра разработчика — в таких вопросах разумнее воспользоваться существующей системой, которая обработает данные от пользователя каким-нибудь ядом, чтобы самые идиотские и непрактивные значения либо успешно сохранялись прямо в таком же виде, либо вызывали ошибку (осторожно, возможен неправильный мёд).

ORM

ORM это преобразователь между строками базы данных и объектами в языке. В обе стороны. Потребность в таких вещах пришла вместе с распространением объектно-ориентированного программирования. Когда стало интересно решать задачи с использованием РСУБД, стала очень частой задача преобразования данных объекта между форматом для баз и форматом объекта в языке. К примеру, если мы работаем в Ruby и подключили базу SQLite, мы должны иметь возможность получить из базы SQLite массив Ruby-объектов с известными нам заранее заданными свойствами.

Почему? Потому что в языках с сильным ООП-шным уклоном гораздо удобнее работать с объектами, чем получать некий абстрактный “курсор” из базы и разбирать записи по одной, по-разному обрабатывая попадающиеся данные в зависимости от их типа. Устрашающе? Когда ООП не был распространён, это было обычной практикой. Впрочем, тогда и компьютеры были не сильны, нагружать их лишним преобразованием данных было неправильно.

Неоднозначность

Сразу оговорюсь — Active Record как таковой не связан с Ruby. Это распространённый способ реализации ORM: каждый объект умещается в одну строку таблицы, все операции с объектом происходят и с этой строкой. На практике соблюсти это требование буквально довольно сложно, это чревато сильными потерями производительности или дырами в логике, поскольку изменение в программировании обычно затрагивает всего одно поле, и попытка переписать объект целиком (а такое нужно довольно часто) может сделать… 3, 4 запроса? В общем, больше одного. Альтернатива — сконструировать новый объект и присвоить его в существующий, или воспользоваться хэшмапом, где перечислить все изменения. Оба таких подхода нуждаются в дополнительной памяти, и это не всегда хорошо. Но всем же пофиг. Правда. В Rails метод update_attributes принимает хэшмап всех параметров.

Так вот, я здесь буду говорить об ActiveRecord из Ruby.

К делу!

Наш ActiveRecord это ORM между РСУБД разных видов и языком Ruby. И он является стандартным средством работы с базами данных в Rails. Какими базами? MySQL, SQLite, PostgreSQL, это то, что я нашёл в исходниках. Говорят, можно добиться работы и с другими, но я слабо представляю, когда это может быть настолько надо, чтобы терпеть связанные с этим лишения при обновлениях. Хотя тут можно положиться на зависимости — несовместимые версии автоматика обычно отказывается ставить.

Я какой урок по Rails не посмотрю, там собирают клона какого-нибудь урезанного Twitter. Что ж, не буду нарушать традицию и возьму упрощённую структуру данных для него. У нас будут пользователи, у каждого из них посты, каждый может подписаться на других пользователей, итоговая лента составляется из последних N постов пользователя и тех, на кого он подписан.

Начнём с азов. У нас есть модель пользователя. У неё есть имя (на пароль забьём, сейчас это технические детали). Поехали в командную строку ОС:

rails generate model User name:string
rake db:migrate

Это сделает несколько вещей. Во-первых — это соберёт набор изменений в структуру базы данных (миграцию): создание новой таблицы с указанным полем name и несколькими другими (они сейчас не важны). Во-вторых, это сделает класс нашего пользователя где-то в недрах Rails — если вы действительно это делаете вживую, вы сами увидите, где; а если нет, не беспокойтесь, это неважно. Вторая строчка сделает миграцию на самой базе данных.

Состояние после этих действий отражено вот здесь.

class User < ActiveRecord::Base
end

…немного, прямо скажем. Большую часть работы тут сделает ActiveRecord, что отражено в заголовке: пользователь является наследником класса “запись ActiveRecord”, ActiveRecord::Base. Пошли в консоль рельсов, выжимать из неё пользу:

u = User.new
u.name = "D-side" #Это я, это я!

Вводим:

User.all

К базе Rails самостоятельно совершит запрос:

SELECT "users".* FROM "users"

В переводе:

Берём все (*) поля таблицы "users" из таблицы "users"

…а в код при этом вернётся массив из всех пользователей. Круто!

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

u.save

Поторяем запрос на всех пользователей (User.all), я есть в базе. Ура! Всё выглядит довольно просто, не нужно отрываться от языка Ruby на изучение SQL. Рано или поздно придётся, но потом, когда вас начнёт волновать производительность.

А пока давайте глянем на более короткий способ создать пользователя в базе:

User.create(name: "Side")

One shot, one kill. Оу, о чём это я… Хотя вообще показать работу ActiveRecord на списке жертв и убийц было бы забавно…

Попробуем чуть вмешаться в запрос:

User.all.order(name: :asc)
SELECT "users".* FROM "users" ORDER BY "users"."name" ASC

Читать это следует так: из пользователей (User, класс) получить всех (all), отсортированных по имени (name) в порядке возрастания (:asc, ascending)

Занимательно, но пока не сильно-то интересно, даже в SQL запрос более-менее понятен. Другими методами по этой же схеме (что.как) строятся операции поиска (find, find_by) и предварительной загрузки… стоп, я забегаю вперёд. В общем, такие запросы ещё можно без особенных проблем осилить и вручную.

Перейдём к чему-нибудь посложнее, например — связанным данным. Именно здесь AR достаёт напалм и начинает жарить код. Давайте позволим пользователям писать свои твиты и видеть их. Всё более-менее просто: у каждого твита может быть лишь один автор, поэтому автора можно спокойно вписать прямо в твит. Твиту логично иметь содержимое и автора. Поехали в консоль ОС:

rails generate model Tweet user:references content:string
rake db:migrate

Отлично, есть новая модель, у неё поля user (автор) и content (содержимое). Осталось указать, какая у неё связь с пользователем. Полезли обратно в класс пользователя, добавим строчку.

class User < ActiveRecord::Base
  has_many :tweets
end

has_many — часть синтаксиса “ассоциаций” ActiveRecord. В таком виде она обозначает “у одного пользователя много постов”. Неплохо бы оформить и обратную связь, пост должен знать, что у него, вообще-то, автор есть!

class Tweet < ActiveRecord::Base
  belongs_to: :user
end

Зачем это надо? Затем, что в схеме “у одного есть много” принадлежность обозначается у той модели, которая принадлежит. К тому же, это “семантически правильно”, то есть, осмысленно с точки зрения здравого смысла.

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

Итак, мы что-то собрали. Давайте пойдём в рельсовую консоль и посмотрим, что нам это дало:

User.first.posts
SELECT "tweets".* FROM "tweets"  WHERE "tweets"."user_id" = ?  [["user_id", 1]]

Список твитов пустой, и это понятно, но нас интересует получившийся запрос. Давайте его прочитаем по-русски:

Получить все поля твитов из таблицы твитов, у которых id пользователя равен (X) [[X = 1]]

Миленько. Но всё ещё не сильно впечатляет, такие запросы всё ещё легко писать руками, мало-мальски разбираясь с SQL… но насколько короче это делается с использованием AR! К тому же, опять же, почти не требует дополнительных навыков.

Давайте теперь сделаем совсем хардкорную вещь, чтобы AR нам показал, насколько он крут. Сделаем “сообщества”, в которых может быть много людей, причём дадим возможность каждому человеку вступать во много сообществ.

Но есть проблемка — в какой модели хранить, у кого какие сообщества? Если исключить хранение (потенциально) огромных значений в каждой модели, то нужно завести пары “человек-сообщество”. Давайте их и сделаем. Назовём “участие”, или “participation”, а точнее: Participation. Ну и сообщество надо бы сделать, для начала. Идём в командную строку:

rails g model Community name:string
rails g model Participation user:references:index community:references:index
rake db:migrate

Что такое :index у полей? Добавочка, означающая, что для новой таблицы нужно поддерживать индексы для поиска по указанному полю. Нам понадобится быстрый поиск по двум стандартным случаям: получить всех участников сообщества и получить все сообщества пользователя. С индексами поиск будет значительно быстрее, чем без них.

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

class User < ActiveRecord::Base
	has_many :tweets

	# Сначала указываем, через какие модели идёт связь
	has_many :participations
	# Затем указываем, какие, и через какие
	has_many :communities, through: :participations
end

Это всё краткие формы записи, показывающие во всей красе “convention over configuration”, один из принципов Rails. Если вы знаете, как “принято делать”, то Rails всё поймёт без всяких настроек. Сколько здесь Rails пришлось угадывать? Давайте посчитаем. Отметим только, что приходится активно использовать pluralizer, умеющий искать для английских существительных форму единственного и множественного числа.

  1. Если у пользователя много :participations, то это явно класс Participation.
  2. Мы - User, поэтому принадлежность описывается полем user_id. Мы его ранее не упоминали? Хм, не совсем: user:references:index это целое число с окончанием названия *_id, это символизирует ссылку на какую-то другую модель.
  3. :communities указывает на модель Community. Наверное.
  4. Если пользователь имеет Community через модель Participation, то в Participation должно быть поле community_id. Кстати, да, оно там есть.
  5. through. Получив список всех Participations, мы можем взять все их значения comminity_id, и это будут те самые сообщества, в которых состоит пользователь.

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

Но поскольку Rails не может угадать всё, изучить явное указание рано или поздно придётся. Скажем, в учебнике по Rails Майкла Хартла приводится код, с помощью которого пользователи могут подписываться друг на друга. Там очень похожая структура, только вместо модели Community у нас тоже модель User. Получается User\(\leftrightarrow\)Sub\(\leftrightarrow\)User. То есть, грубо говоря, у пользователя есть Sub‘ы, целых два набора: где он подписчик и где на него подписываются. Можете посмотреть сами (строки 5-10), как это выглядит. Ужас-ужас :D

Но мы отвлеклись. Аналогичную запись нужно разместить и в сообществах:

class Community < ActiveRecord::Base
	has_many :participations
	has_many :users, through: :participations
end

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

Пошли в консоль Rails (я не говорил, что она открывается командой rails c?) и введём запросик, посмотреть на то, что получается:

u = User.first

Это просто чтобы отфильтровать “осмысленный запрос”. Это у нас уже работало раньше, нового SQL мы не увидим. Нас интересует немножко другое:

u.communities

Здесь будет SQL-бум:

SELECT "communities".* FROM "communities" INNER JOIN "participations" ON "communities"."id" = "participations"."community_id" WHERE "participations"."user_id" = ?  [["user_id", 1]]

Воу-воу-воу, Ruby, полегче! Тут произошло что-то сложное. Давайте разбирать.

Сначала указываем, что нас интересуют все поля таблицы communities ("communities".*), из таблицы… получаемой склеиванием (INNER JOIN) таблицы participations с таблицей communities. То есть, берём каждую запись Participation и приклеиваем к ней соответствующую запись Community. Но нужно указать, что значит “соответствующую”: значится, мы говорим, что соответствие устанавливается равенством полей id у Community и community_id у Participation. После этого вполне известная нам штука — фильтруем только те Community, у которых user_id равен нужному нам, а именно 1 (это уже зависит от того, что в u, я туда ранее записал первого попавшегося пользователя, но его id может быть и не 1).

Что-то я устал :| Вот что получилось, на гитхабе, можете сами скачать, и если у вас есть Rails, поковырять. Установить его сравнительно несложно, но на Windows с этим свои заморочки в довольно неожиданных местах: например, в моём случае сервер Rails не смог достать информацию о том, в каком часовом поясе он запущен, и… конечно, упал! Хотя это вроде и не очень критично.

Куда дальше? Можно пошариться по способам явного указания того, что Rails приходится угадывать. Потом можно поучиться делать “усердную загрузку” (eager loading), хотя правильнее было бы сказать “предзагрузку” связанных объектов (подсказка: User.includes(:tweets).first загрузит в один объект пользователя и все его твиты в два запроса). Затем можно поучиться конструировать сложные запросы с помощью arel, на котором работает ActiveRecord, если вам нужны какие-то сложные условия — это, в какой-то мере, прямое написание SQL-кода в привычном синтаксисе Ruby. Надо ли это, если можно писать сам SQL? Хороший вопрос. Я вот не знаю SQL, и мне немного некомфортно лазить по коду, который я не очень-то понимаю. Я сейчас на том уровне, когда примерно представляю, что он делает, но написать такой сходу не смогу.

Вообще кому-то интересны материалы по Rails? Может, описать способ их установки? А то читать о чём-то отдалённом, будто с другой планеты, не очень интересно =) Обычно тянет пощупать, поковырять собственными руками.

Ну и да, бесстыдная, но добровольная реклама — пост написан в купленном буквально позавчера Sublime Text 3. Он офигенен, я вам скажу :3