May 10

Недавно писал проект на Rails 2.3 + MongoDB + MongoMapper. Не могу сказать, что все было хорошо - для того, чтобы существующие плагины для рельсов заработали с MongoMapper-ом пришлось немного повозиться, но в итоге все закончилось хорошо. :)

А мой сегодняшний рассказ пойдет о некоторых особенностях MongoDB и MongoMapper-а, с которыми вы, скорее всего столкнетесь, если будете использовать эти библиотеки.

Для начала поговорим немного о MongoDB. Что это такое?

MongoDB — документо-ориентированная система управления базами данных (СУБД) с открытым исходным кодом, не требующая описания схемы таблиц. Написана на языке C++. СУБД управляет наборами JSON-подобных документов, хранимых в двоичном виде в формате BSON.

Если попробовать вкратце охарактеризовать эту базу данных, то получится что-то вроде этого: аналог реляционной СУБД без join-ов и транзакций, зато с поддержкой структур данных (массивов, хешей).

MongoMapper - это “ORM” для MongoDB, написанный на руби. “ORM” в кавычках потому что сложно себе представить ORM для нереляционной базы данных. Я бы скорее назвал это высокоуровневой оберткой над API, которое предоставляет MongoDB, с поддержкой ассоциаций между записями и много чем еще.

Теперь, собственно, о “особенностях” MongoDB и MongoMapper-а.

Вложенные документы

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

class User
  include MongoMapper::Document

  key :login
  key :password
  key :salt

  many :posts
  many :addresses
end

class Post
  include MongoMapper::Document
  key :title, String
  key :body, String
  timestamps!
end

class Address
  include MongoMapper::EmbeddedDocument
  key :type, String
  key :country_id, ObjectId
  key :city_id, ObjectId
  key :city_address, String
end

Чтобы понимать, что тут происходит нужно, во-первых знать Ruby :) и во вторых - прочесть пост John Nunemaker (даже не знаю, как правильно перевести :)) о MongoMapper-е. Вот еще один, кстати, тоже интересный.

Таким образом имеем модель пользователя, которая хранить набор постов, написанных этим пользователем и набор адресов пользователя (домашний, рабочий, etc).

Особенности работы со встроенными документами легче показать на примере:

user = User.first
user.posts

Последняя комманда вернет “scope”/”relation”, а не просто массив элементов (люди, знакомые с named_scope в ActiveRecord версии < 3 поймут).
Соответственно, можно дродолжить эту комманду, например, так:

user.posts.all(:conditions => { :created_at => { "$gt" => (Date.today - 10.days) } })

В то время как

user.addresses

вернет массив и всю дополнительную фильтрацию прийдется производить с помощью Ruby.

Манипуляции со вложенными документами

У EmbeddedDocument нет понятия “id”, т.е. отличать обьекты Address один от другого можно только по их индексу в массиве user.addresses либо вводя “свои” идентификаторы. Но даже в этом случае выбирать соответствующий обьект прийдется “вручную”:

user.addresses.detect { |address| address.identifier == params[:address_identifier] }

PS. Для тех, кто в танке: detect - это аналог select { |address| … }.first.

Соответственно - вложенные обьекты легко добавлять, выбирать всю связь полностью, но с ними становится “сложно” работать, как с отдельными обьектами. В частности - неудобно обновлять из-за проблем с выборкой отдельного элемента и неудобно удалять.

С удалением вообще интересная история. Удалить обьект имея только ссылку на него - нельзя, нужна еще и ссылка на массив, в котором он хранится:

user.addresses.first.delete/destroy

не работает, т.к. таких методов у вложенных обьектов нет. Нужно делать так:

user.addresses.delete_at(address_index)

если вы знаете индекс в массиве адресов, либо

user.addresses.delete_if { |address| address.identifier == address_identifier }

если знаете идентификатор или удаляете по какому-либо другому полю.

Несложно, но проблемы на первых порах с этим бывают.

“идентификаторы” обьектов MongoDB

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

Например, вместо такой модели:

class Tag
  include MongoMapper::Document
  key :name, String
end

иметь такую

class Tag
  include MongoMapper::Document
  # id-шник явно никогда не прописывается
end

И создавать обьект так:

Tag.create(:_id => "ruby on rails")

MongoDB это позволяет делать. Более того - драйвер Ruby для MongoDB это тоже может делать. Проблема в том, что это не умеет делать MongoMapper. Совершенно. Как обойти эту проблему я на данный момент не знаю (я в итоге сделал модель первого типа и забил), есди кто знает решение - напишите, будет интересно.

И напоследок поговорим о

Has And Belongs To Many в MongoDB

… или habtm, знакомый многим по ActiveRecord.

Приведу пример из моего проекта: есть домены, есть их модераторы, связть many-to-many. Для каждого домена хочется видеть список модераторов и для каждого модератора хочется видеть список доменов, которые он модерирует.

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

Первое решение, которое мне пришло в голову - хранить массив id-шников доменов для каждого пользователя и хранить массив id-шников пользователей для каждого домена и синхронизировать их.

Последние два слова очень смущали - синхронизация вносила излишнюю сложность. Т.е. реализовать-то её можно, но очень не хотелось.

Собственно, обратился за помощью к знакомым и вышел на одного человека (ник - Necromant, сайта его я не знаю :) ), который помог решить мне эту проблему.

Итак, решение - хранить только один список id-шников (или в юзерах или в доменах) и сделать по нему индекс, соответственно, вывод как доменов по юзеру так и юзеров по домену будет достаточно быстрый.

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

class Domain
  include MongoMapper::Document
  # ...
  key :moderator_ids, Array, :index => true
  def moderators
    User.find moderator_ids
  end
end

class User
  include MongoMapper::Document
  # ...
  def moderator_of?(domain)
    domain.moderator_ids.include? id.to_s
  end

  def moderated_domains
    Domain.all :moderator_ids => id.to_s
  end
end

Еще хочу рассказать о том, как подружить MongoMapper и Clearance, но пост вроде и так не маленький получился, так что ждите еще один пост о MongoMapper-е в ближайшие дни.

PS. Совсем писать разучился… :(

PPS. Если вам понравился этот пост, проголосуйте за него на Habrahabr-е. :)

written by fxposter \\ tags: , , ,