В настоящее время я разрабатываю редактор фотокниг — многофункциональное веб-приложение с Kotlin/Spring в качестве серверной части. При прототипировании я переключился с MongoDB на MySQL и наоборот и в итоге остановился на Mongo. Было нелегко принять это решение, поэтому давайте обоснуем, почему это хороший случай.

Фотокнига – это документ

Фотокнига — это самодостаточный объект, состоящий из листов, на которых размещены различные элементы — в основном фотографии и текстовые поля. Это древовидная структура:

book
- sheet 1
- - photo 1.1
- - text 1.2
- sheet 2
- - photo 2.1

Этот тип объектов хорошо подходит для хранилищ документов, таких как MongoDB, потому что вложенные элементы не ссылаются явно друг на друга и на своих родителей.

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

Когда я попытался описать свою модель в терминах сущностей JPA, она вскоре стала очень сложной, и я застрял, пытаясь поддерживать вложенные упорядоченные списки (тяжелый случай в Hibernate). С Mongo я просто работаю с альбомом как с одним документом.

Совершенно другой подход

Редактор фотокниг позволяет мне изменять все — добавлять фотографии, перемещать их, редактировать тексты, добавлять листы, применять эффекты и т. д.

Если я выберу реляционный путь, мне понадобится такой сложный API

  1. Добавить фото: POST "/api/album/{albumId}/sheet/{sheetId}/photo/{photoId}/add" {photo}
  2. Обновить фото: POST "/api/album/{albumId}/sheet/{sheetId}/photo/{photoId}/update" {photo}
  3. Удалить фото: POST "/api/album/{albumId}/sheet/{sheetId}/photo/{photoId}/delete"
  4. Добавить текст: POST "/api/album/{albumId}/sheet/{sheetId}/text/{textId}/add" {text}
  5. Текст обновления: POST "/api/album/{albumId}/sheet/{sheetId}/text/{textId}/update" {text}

Но с MongoDB мне нужен только один метод:

POST "/api/album/{albumId}/update" {album}

Я что-то переделываю, а потом просто отправляю весь альбом в базу данных как есть.

Что действительно здорово, так это то, что сущность альбома везде одинакова. На клиенте, в сети, на сервере, в базе данных — это один и тот же простой объект JSON. Не нужно сложное преобразование из JSON в дерево ORM и в несколько записей в разных таблицах.

Отменить повторить

Преимущества документно-ориентированного подхода становятся очевидными, если мы попытаемся реализовать функциональность отмены/возврата.

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

class UpdatePhotoAction: Action {
  redo() {
    ...
  }
  undo() {
    ...
  }
}
class DeletePhotoAction: Action {
  redo() {
    ...
  }
  undo() {
    ...
  }
}

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

С документным подходом очень легко переключиться на шаблон отмены/повторения Memento. Мы просто делаем копию всего альбома перед каждой операцией и сохраняем эти копии последовательно. Чтобы отменить операцию, нам просто нужно восстановить альбом из копии. Опять же, альбом — это просто JSON, поэтому его очень легко клонировать.

Работа с вложенными объектами

Фотокнига — это документ, поэтому мы обычно работаем с ним как единое целое. Что, если мне нужно иметь дело исключительно с вложенным элементом, например, сделать предварительный просмотр одного листа? Я пришел к выводу, что в любом случае лучше ссылаться на вложенный элемент вместе с его родителем.

Пример кода выглядит следующим образом:

fun getSheetImage(albumId: String, sheetId: String): BufferedImage {
  val album = repository.findById(albumId).get()
  val sheet = album.sheets.find { it.id == sheetId }
  ...
}

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

Если мне нужно обновить вложенный объект, я сохраняю весь документ.

Отношения с другими объектами

Конечно, в моем хранилище данных есть не только книги. Есть также пользователи, и каждая книга связана с каким-то пользователем. Чтобы иметь возможность получить пользователя альбома, мы сохраняем идентификатор пользователя в сущности альбома:

@Document
data class Album(
        @Id
        val id: String,
        val userId: String,
        val sheets: MutableList<Sheet> = mutableListOf()
)

Поскольку нам редко требуется это отношение и никогда не нужны сложные объединенные запросы, вполне нормально иметь эту обратную ссылку в документе Mongo.

параллелизм

Когда я еще не добрался до документно-ориентированного мира, я пытался обращаться с MongoDB как с RDB. Например, я попытался обновить отдельные свойства с помощью сложных запросов $set:

db.albums.update(
   { id: ALBUM_ID },
   { $set: { "sheets.2.items.3.x": "200" } }
)

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

Проблема с параллелизмом возникает, если вы обновляете документы рекомендуемым способом:

fun uploadPhoto() {
  val album = repository.findById(albumId).get()
  val sheet = album.sheets.find { it.id == sheetId }
  val photo = ... // upload from request
  sheet.add(photo)
  repository.save(album)
}

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

Сначала я был разочарован, но потом обнаружил, что гораздо проще правильно синхронизировать эти вызовы, чем переключаться на RDB. Читайте об этом здесь: