Persistence

Есть разные способы работы с базой данных.

Active Record

Примеры для ruby и ActiveRecord из Ruby on Rails.

Есть проблема с отслеживанием изменений.

user = User.first
user.skills << "codding"
user.save

В ruby массивы мутабельны, соответственно ORM не может отследить добавление нового навыка и не сохранит это изменение. Можно конечно сразу после загрузки делать deep copy, и при сохранении сравнивать текущее состояние с изначальным, но не всегда это возможно и приемлемо.

Доступна загрузка ассоциаций по требованию:

user.posts

Однако вполне возможна рассинхронизация состояния базы данных и программы:

user.posts.length #=> 2
Post.create user: user, other_attr: ""
user.posts.length #=> 2

Вы можете загрузить одну и ту же сущность в разные объекты:

o1 = User.first
o2 = User.find(o1.id)

o1.object_id != o2.object_id

Ваши сущности зависят от фреймворка (см. Dependency Inversion Principle)

class User < ActiveRecord::Base
  has_many :posts
end

Разумеется есть и другие особенности, но нам достаточно приведенных.

В целом, для своей ниши это отличная ORM, но в сложных проектах она начинает откровенно вредить.

Datomic / Datascript

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

(swap! world #(-> (update % save-person (build-person {:name "Alice"}))
                  (update % save-person (build-person {:name "Bob"}))
                  (update % delete-last-person)))

Очевидно, что загружать все содержимое базы данных в память для любой операции это плохая идея при больших объемах. Проект Datascript - in-memory база, и проектировался для использования в браузере. Datomic использует ленивую загрузку данных.

https://docs.datomic.com/cloud/whatis/data-model.html

Commands & Queries

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

(defn perform [params]
  ...
  (let [user (queries/get-user-by-id some-id)
        post (post/build params)
        post (assoc post :author-id (:id user))]
    (commands/put-post post)
    ...))

Тут уже нет изменяемых объектов, user и post - просто структуры данных вроде map или record. Таким образом бизнес логка не зависит от деталей реализации команд или запросов, а сами они могут быть легко подменены в тестах.

Естественно, не получится ходить про связям user.posts.

Вы по прежнему можете отобразить одну сущность в несколько объектов в памяти:

(let [user (queries/get-user-by-id 1)
      user (update user :achievements conj :fishing)
      ...
      author (queries/get-user-by-id 1)
      author (update author :achievements conj :writing)]
  (commands/put-user user)
  ...
  (commands/put-user author))

В данном примере мы теряем часть изменений, а именно изменения «автора» перетрут изменения пользователя.

Если используются транзакции, и эти транзакции занимают некоторое время, то при большом потоке изменений будут возникать дедлоки и придется вручную расставлять блокировки.

Этот подход хорошо работает в функциональных языках и просто языках без развитой инфраструктуры ORM.

Data Mapper & Identity map & Unit of Work

Мы хотим:

  • отделить логику сохранения сущности от бизнес-логики. Data Mapper
  • получать тот же объект сущности при повторном извлечении из хранилища. Identity Map
  • автоматически отслеживать изменения и сохранять только разницу. Unit of Work

Clojure разделяет неизменяемое состояние и идентичность. Это дает тривиальную реализацию указанных паттернов.

Мы можем моделировать наши сущности используя Record и Ref. Record отвечает за состояние, а Ref - за идентичность.

(defrecord User [id login friends])

(let [alice (ref (->User 1 "alice" []))
      bob   (ref (->User 2 "bob" []))]
  (dosync
    (alter alice update :friends conj (:id bob))
    (alter bob   update :friends conj (:id alice))))

Можно было бы вместо Ref использовать Atom, но атом поддерживает только нескоординированное изменение. В примере выше установка отношения между Alice и Bob семантически атомарна, поэтому даже если мы в принципе не будем работать с alice и bob в несколько потоков, оправдано использование Ref, а не Atom.

При этом становится тривиальной реализация Identity map:

(storage/with-tx t
  (let [e  (storage/get-one t 1)
        e' (storage/get-one t 1)]
    (identical? e e')))

get-one принимает транзакцию t и идентификатор сущности и возвращает Ref. t внутри себя хранит отображение идентификаторов сущностей на идентичность (объекты ref в памяти). При повторном запросе будет возвращен тот же объект(идентичность), что и в первый раз.

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

(storage/with-tx t
  (let [alice (storage/get-one t 1)
        bob   (storage/create (user/->User 2 "bob"))]
    (dosync
     (alter alice update :friends conj (:id bob))
     (alter bob   update :friends conj (:id alice)))))

create - создает Ref с переданным состоянием и регистрирует ее в Identity Map. Не имеет значения была ли фиксация, (get-one t 2) вернет bob.

Мы можем использовать 2 стратегии фиксации изменений: оптимистическую и пессимистическую.

В случае оптимистической стратегии мы можем воспользоваться паттерном Единица работы(Unit of Work). Идентичность создается с помощью функций create или get-one. При этом сохраняется отображение идентификатора на начальное состояние сущности. При первом извлечении с помощью get-one мы так же запоминаем версию сущности. При фиксации происходит сравнение начального состояния идентичностей с их текущим состоянием. Открывается транзакция и если версии сущностей не изменились, то происходит фиксация в хранилище. Отмечу, что при таком подходе мы на очень короткое время забираем соединение из пула.

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

Если мы используем оптимистическую стратегию, то в рамках бизнес-транзакции мы не можем делать выборки по произвольному ключу. Допустим, мы выбираем пользователя по его идентификатору . Оказалось, что ему 42 года. Установим его возраст равным 43. В этой же бизнес-транзакции выберем всех пользователей с возрастом равным 42. Очевидно, что перед выборкой необходимо сохранить «грязные» сущности в БД, чтобы запрос вернул то, что мы ожидаем. Но у нас нет открытой транзакции уровня базы данных и мы не можем обеспечить изоляцию.

Мы можем воспользоваться Запросами(Query) и извлекать любые данные(состояние) вне транзакции и перечитать данные находясь в транзакции:

(let [moderators-ids (queries/fetch-moderators)]
  (storage/with-tx t
    (let [moderators (storage/get-many moderators-ids)]
      ...)))

При этом происходит разделение ответственности. Для запросов мы можем использовать поисковые движки, масштабировать чтение с помощью реплик. А API storage всегда работает с основным хранилищем(мастер). Естественно, что реплики будут содержать устаревшие данные, перечитывание данных в транзакции решает эту проблему.

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

Отмечу, что подобным образом можно работать не только с SQL базами данных. Например, Redis также поддерживает транзакции и оптимистические блокировки. Но придется вручную поддерживать вторичные индексы для произвольных выборок.

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