Мои критерии к ORM

Скорость
ORM должен быть быстрым. ORM должен иметь Identity Map для предотвращения запросов в БД, если объект уже был загружен. Этот момент особенно важен, когда различные изолированные компоненты приложения пытаются загрузить один и тот же объект из БД в собственную область видимости. Кроме того, я считаю, что Identity Map должен конфигурироваться под уровни изоляции транзакции, чтобы, например, не повторять запросы в БД, если такого объекта не существует.
Простота
ORM не должен пугать своим видом в отладчике, а понять что он делает можно было не более чем из просмотра исходников. Любой продукт рано или поздно умирает, или хотя бы теряет интерес к себе со стороны автора, поэтому всегда нужно быть готовым взять сопровождение выбранного продукта на себя. Новые люди должны легко осваивать продукт. А единственным источником истины о коде служит сам код. Комментарии, документирование, конечно, облегчают осваивание продукта, но часто они освещают далеко не все, и нередко отстают от реального кода. И я не встречал в своей практике ни одной библиотеки, которую не было бы необходимости адаптировать или расширять. А в таких вопросах простота выходит на первый план.
Архитектура

Грамотное разделение уровней абстракции, соблюдение базовых принципов архитектуры, таких как SOLID.

Если Вы не можете использовать отдельно взятый компонент ORM, например SQLBuilder, изолированно от всей системы, то, наверное, такой ORM лучше вообще не использовать (в пользу “низкоуровневых” паттернов обработки данных). Хорошо спроектированный ORM позволяет использовать свои компоненты по отдельности, Query Object (SQLBuilder), Connection, DataMapper, Identity Map, Unit of Work, Repository. Можете ли Вы в своем ORM использовать Raw-SQL (полностью или частично)? Можете ли Вы изолированно использовать только Data Mapper? Можете ли Вы подменить Data Mapper, например, на фиктивную службу, которая не будет осуществлять запросы в БД?

Возможности любого ORM приходится расширять. Насколько легко расширить Ваш ORM без форков, патчей, манкипатчей? Соблюдается ли в нем Open/Closed Principle (OCP)?

“The primary occasion for using Data Mapper is when you want the database schema and the object model to evolve independently. The most common case for this is with a Domain Model (116). Data Mapper’s primary benefit is that when working on the domain model you can ignore the database, both in design and in the build and testing process. The domain objects have no idea what the database structure is, because all the correspondence is done by the mappers.” («Patterns of Enterprise Application Architecture» [3])
ACID и Two-phase transaction
Хорошая система следит за соответствием объекта в памяти его записи в БД. Представьте, что Вы загрузили объект, и затем сделали коммит. На этот объект уже ссылаются десятки других объектов, но он был изменен в БД другим потоком. Если после этого Вы приступите к изменению этого объекта, - то изменения, внесенные другим потоком будут утрачены. В момент коммита Вам необходимо согласовать состояние объектов в памяти с данными на диске, и при этом сохранить все ссылки на них. Смотрите также эту статью и презентацию. Для гарантирования целостности данных, одной только подержки транзакций приложением недостаточно. Разумеется, это не критическое требование, однако без его выполнения невозможно полностью сокрыть источник данных в коде.
Сокрытие источника

Хороший ORM позволяет Вам позабыть о своем существовании, и обращаться с объектами моделей так, будто это обычные объекты. Он не будет раскрывать источник данных, требуя от Вас явного вызова метода для сохранения объектов. Не будет вынуждать Вас “перезагружать” объекты. Позволит легко подменить маппер, даже если Вы смените реляционную БД на нереляционную.

Представьте, что Вы создали два новых объекта, из которых один ссылается на другой по внешнему ключу. Можете ли Вы создать между ними связь до того, как хотя бы один из них будет сохранен в БД и получит первичный ключ? Обновится ли значение внешнего ключа связанного объекта в тот момент когда первый объект будет сохранен и получит первичный ключ?

Хороший ORM предотвращает дедлоки, потому что сохраняет все объекты непосредственно перед коммитом, минимизируя интервал времени от первого сохранения до коммита. Кроме того, он позволит Вам влиять на порядок сохранения объектов, например, используя топологическую сортировку для предотвращения дедлоков.

О достоинствах

Несмотря на номер релиза, - код достаточно стабилен. Удачная архитектура в сочетании с принципом KISS создает ложную иллюзию, что Storm ORM якобы не развивается. Это не так. На самом деле, там просто нечего развивать. За три года копания в исходниках Storm ORM я не нашел ничего, что можно было бы улучшить. Расширить - да, можно. Но не изменить. Коммиты происходят регулярно. Но их можно охарактеризовать как “вылизывание”, или “полирование”.

Storm ORM поддерживает композитные ключи и связи (после Django Models я облегченно вздохнул).

Позволяет выражать SQL-запросы практически любой сложности (по крайней мере, конструктивно).

Работает с любым количеством БД.

Реализует DataMapper pattern, а значит классы моделей свободны от метаданных и логики доступа к БД, как это свойственно для ActiveRecord. Классы моделей могут наследоваться от простого класса object.

Storm ORM реализует топологическую сортировку (в виде push модели данных).

Благодаря Identity Map, Storm ORM очень быстр. На странице одного из проектов, после внедрения Storm ORM (вместо Django ORM), затраты на работу ORM упали с 0.21 сек до 0.014 сек, т.е. в 15 раз, а совокупное время генерации страницы сократилось в два раза, с 0.48 сек до 0.24 сек. Количество запросов в БД сократилось с 88 до 7. Identity Map также делает ненужными утилиты типа prefetch_related(), правда только для внешних ключей ссылающихся на первичный ключ.

Работать с кодом Storm ORM приятно. Можно найти много интересных методик по оптимизации кода. Нужно отдать должное разработчикам Storm ORM, - они совершили настоящий умственный подвиг. Весь код тщательно продуман. Любые попытки его улучшить обычно только убеждают в правильности существующего решения.

Storm ORM очень грамотно обрабатывает транзакции. Здесь нельзя встретить бездумный реконнект в случае обрыва соединения во время незавершенной транзакции. Соединение восстановится только в том случае, если это не может отразиться на целостности данных. Сами транзакции сделаны двухуровневыми. В случае rollback откатывается также состояние объектов в памяти.

Благодаря наличию у Storm ORM возможности скомпилировать критерии выборки в коллекцию фильтров Python-кода, применимых к любой коллекции объектов в памяти, Storm ORM предоставляет неплохие возможности для создания фиктивного маппера для тестов. А для выборки объектов из Store() по первичному ключу (в том числе и посредством вызова Foreign Key) и создавать вообще ничего не нужно, так как благодаря паттерну Identity Map можно просто не посылать объекты в БД, и использовать реальный маппер как фиктивный.

Storm ORM не производит конвертации значений сразу, в момент загрузки объекта. Вместо этого он просто заворачивает значение во враппер (адаптер) - класс Variable.

Это позволяет:

  • Контролировать политику присваивания и доступа.
  • Оптимизировать ресурсы (конвертация не производится до фактической востребованности).
  • Сохранять первоначальное значение атрибута, следить за его изменениями, реализовывать откат (rollback) на уровне объектов языка программирования.
  • Наблюдать за изменениями значения (обсервер) и обновлять связанные объекты.
  • Синхронизировать значение объекта со значением на диске.
  • Предотвратить присваивание невалидного значения. Что, кроме следования базовым принципам ООП, также устраняет проблему “G22: Make Logical Dependencies Physical” [1] и “G31: Hidden Temporal Couplings” [1], которая обычно заключается в сохранении объекта с забыванием его провалидировать.
  • Валидировать значение только при присваивании его извне, но не из БД. Это исключает проблему невозможности пересохранения данных в случае изменения правил валидации.
  • Конвертировать значение в нужное представление в зависимости от контекста использования (Python или DB).

С последним, правда, тоже есть некоторые нюансы.

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

(GeoObjectModel.point == author_instance.location)

Конвертор какого атрибута здесь должен работать, GeoObjectModel.point или AuthorModel.location? Очевидно что AuthorModel.location, так как именно он предоставляет значение. Но работать будет GeoObjectModel.point. Что если эти конверторы имеют различное поведение? И что произойдет если мы передадим такой критерий: Func('SOME_FUNCTION_NAME', AuthorModel.location)?

Справедливости ради нужно сказать, что Storm ORM сделал большой прорыв по упорядочиванию данного аспекта, по сравнению большинством других ORM, и заложил правильный фундамент для построения идеальной конвертации. При соблюдении несложных правил конверторы будут работать идеально правильно (для этого в критерии выборки нужно передавать инстанцию Variable(), т.е. “завернутое” значение). В то время как во многих других ORM такая возможность технически отсутствует из-за того, что конвертации делаются в момент создания объекта. Иными словами, там конверторы фактически привязываются к типу значения а не к конкретному атрибуту (как это декларируется), что делает их практически бесполезными, учитывая что эти функции итак возложены на коннектор.

Storm ORM не навязывает способ получения коннекта. Вы легко можете расшарить коннект между двумя ORM или использовать какой-то особый способ получения коннекта.

Storm ORM не обязывает декларировать схему БД в коде. Это соответствует принципу DRY, - схема уже есть в БД. Кроме того, полный контроль над схемой БД легче всего достигнуть средствами самой БД. Обычно в крупных проектах, использующих репликацию и шардинг, используются собственные инструменты для контроля за схемой. Как вариант, можно воспользоваться поставляемым вместе со Storm ORM пакетом storm.schema. В отличии от SQLAlchemy, в Storm ORM не предусмотрена и автоматическая подгрузка незадекларированных свойств модели из БД. При желании это несложно реализовать, но обращаться к БД придется на стадии инициализации кода, а неявность кода затруднит его визуальное восприятие (просмотра исходников будет недостаточно для получения представления о моделях). Кроме того, различные типы данных в Python могут иметь один и тот же тип данных в БД, а значит, данных БД для полноценной декларации классов недостаточно.

Другие достоинства хорошо отражены в Tutorial и в Manual

По поводу SQLAlchemy

В общем-то любой ORM хорош, если он реализует принципы нашумевшей книги «Patterns of Enterprise Application Architecture» [3]. Storm ORM контрастирует своей простотой на фоне SQLAlchemy также, как VIM на фоне Emacs, или jQuery на фоне Dojo. Идеологически между ними много общего, я бы даже сказал, что Storm ORM - это упрощенная версия SQLAlchemy. Исходники Storm ORM изучаются быстрее, нежели вводный tutorial SQLAlchemy. Раширяется и адаптируется Storm ORM быстрее, чем приходит понимание того, как это можно сделать под SQLAlchemy.

Но существует грань, которая делает SQLAlchemy более предпочтительной, чем Storm ORM. Если функционал Storm ORM Вас устраивает, Вы “владеете пером”, и располагаете временем на адаптацию библиотеки под свои нужды, то Storm ORM выглядит привлекательней. В противном случае, SQLAlchemy становится предпочтительней, даже невзирая на уровень ее сложности, поскольку многие решения предоставляет “из коробки”.

О недостатках

В моей практике было три случая, когда в Storm ORM требовалось “допиливать” то, что SQLAlchemy (или ее сообщество) предоставляет в готовом виде.

  1. Массовая вставка объектов, причем, с условием ON DUPLICATE KEY UPDATE.
  2. Адаптация SQL Builder под интерфейс django.db.models.query.QuerySet.
  3. Поддержка паттерна Concrete Table Inheritance

В Storm ORM нет блокировки потоков при ленивой модификации критически важных глобальных метаданных. Это не проблема, и легко решается (достаточно исполнить их сразу, под блокировкой). Но об этом нужно знать, иначе в условиях высоко-конкурентных потоков можно завалить прод.

Расширять функциональность Storm ORM все-таки придется. Возможности SQL-билдера нужно расширять. Утилита prefetch_related() для OneToMany() тоже не помешала бы. Возможно, понадобится реализовать каскадное удаление средствами ORM, а не базы данных. И добавить сериализатор объектов.

То что класс Store (по сути паттерн Repository) совмещает в себе обязанности маппера, не очень удобно. Например, это создает проблему в реализации паттерна Class Table Inheritance. Сами разработчики Storm ORM советуют заменить наследование композицией (впрочем, postgresql сам поддерживает наследование (DDL)). Отсутствие выделенного класса для маппера вынуждает также загромождать доменную модель служебной логикой.

О неоднозначном

Поддержка ACID и двухфазных транзакций привела к тому, что доменная модель на самом деле не является чистой. Тем не менее, она имеет чистый интерфейс, и ведет себя как обычный чистый объект. На самом деле инстанция модели не содержит данных, а ссылается на структуру данных посредством дескрипторов. Реализовать все это (тем более в стиле KISS), является титаническим трудом. Хотя я не уверен, что сама реализация такого сложного механизма соответствует принципу KISS. Быть может, простота реализации здесь была бы предпочтительней, нежели простота интерфейса. И тем не менее, это делает одним аргументом против ORM меньше.

Кроме того, это решение не обеспечивает полной согласованности поведения доступного для использования. Предположим, вы создали два новых объекта, первый из которых ссылается на второй по внешнему ключу. Вы связали эти два объекта посредством дескриптора. В момент (отложенного) сохранения, когда второму объекту будет присвоен первичный ключ, значение внешнего ключа первого объекта автоматически примет нужное значение. Но еще до момента сохранения Вы можете получить доступ к второму объекту посредством обращения к связи первого объекта (благодаря дескрипторам объект будет доступен, см. пример). Тем не менее, вы не имеете возможности запросить напрямую второй объект у хранилища Store() до момента сохранения.

FAQ

q: Storm ORM не поддерживает Python3.

a: Если Вы мигрировали хотя бы одну библиотеку на Python3, то понимаете, что этот процесс больших трудностей не вызывает. 95% работы делает команда 2to3. Единственный вопрос, который может иметь значение, - это миграция Си-расширения. Впрочем, даже без него Storm ORM работает достаточно быстро, и не сильно теряет в производительности. Найти Си-расширение под Python3 можно здесь (diff). Есть еще один py3 branch.

q: Как использовать Storm ORM с фрагментами Raw-SQL

a: Вообще-то так лучше не делать. Лучше расширить SQL-builder. Но если очень надо:

>>> from storm.expr import SQL
>>> from authors.models import Author
>>> store = get_my_store()
>>> list(store.find(Author, SQL("auth_user.id = %s", (1,), Author)))
[<authors.models.Author object at 0x7fcd64cea750>]

q: Как использовать Storm ORM с полностью чистым SQL, чтобы результат запроса содержал инстанции моделей?

a: Поскольку Storm ORM использует паттерны Data Mapper, Identity Map и Unit of Work, мы должны указать в выборке все поля модели, и использовать для загрузки метод Store._load_object():

>>> store = get_my_store()
>>> from storm.info import get_cls_info
>>> from authors.models import Author

>>> author_info = get_cls_info(Author)

>>> # Load single object
>>> result = store.execute("SELECT " + store._connection.compile(author_info.columns) + " FROM author where id = %s", (1,))
>>> store._load_object(author_info, result, result.get_one())
<authors.models.Author at 0x7fcc76a85090>

>>> # Load collection of objects
>>> result = store.execute("SELECT " + store._connection.compile(author_info.columns) + " FROM author where id IN (%s, %s)", (1, 2))
>>> [store._load_object(author_info, result, row) for row in result.get_all()]
[<authors.models.Author at 0x7fcc76a85090>,
 <authors.models.Author at 0x7fcc76a854d0>]

А нужен ли вообще ORM?

Честно говоря, нет необходимости использовать ОРМ всегда и везде. Во многих случаях (например, если от приложения требуется просто выдать список JSON значений) вполне достаточно простейшего Table Data Gateway, который будет возвращать простейшие значения Data Transfer Object. Тут уже дело личных предпочтений.

Нужен ли Query Object?

Единственное в чем я убежден твердо, - это в том, что без паттерна Query Object (часто именуемом как SQLBuilder) обойтись довольно трудно, если не невозможно.

1. Даже самые стойкие сторонники концепции “чистого SQL” достаточно быстро сталкиваются с невозможностью выразить SQL-запрос в чистом виде, и вынуждены его динамически составлять в зависимости от условий. А это уже разновидность концепции SQLBuilder, пусть и в примитивном виде, и реализованном в частном порядке. А решения частного порядка всегда занимают много места, так как отступают от принципа DRY.

Проиллюстрирую это примером. Имеем запрос на выборку объявлений из БД по 5-ти критериям. Нужно позволить пользователям выбирать объявления по совокупности любого количества из перечисленных критериев:

  1. Без критериев.
  2. Типу объявления.
  3. Стране, области, городу.
  4. По категориям, включая вложенные категории.
  5. По пользователям (все объявления одного пользователя)
  6. По поисковым словам.

Итого, пришлось бы заготовить 2^5 = 32 фиксированных SQL-запроса, и это если не учитывать вложенностей древовидных структур (иначе п.3 пришлось бы разнести на еще 3 пункта, так как нередко эти данные хранятся денормализованно).

Список возможных комбинаций критериев:

0
1
1,2
1,2,3
1,2,3,4
1,2,3,4,5
1,2,4
1,2,4,5
1,2,5
1,3
1,3,4
1,3,4,5
1,3,5
1,4
1,4,5
1,5
2,
2,3
2,3,4
2,3,4,5
2,3,5
2,4
2,4,5
2,5
3
3,4
3,4,5
3,5
4
4,5
5

А если добавить еще один критерий, - это будет 2^6=64 комбинации, т.е. в 2 раза больше. Еще один, - это будет 2^7=128 комбинаций.

128 фиксированных запросов вынуждают отказаться от концепции “чистого SQL” в пользу концепции “динамического построения SQL-запроса”. Метод, создающий такой запрос, будет принимать много аргументов, что отразится на чистоте кода. Можно разделить ответственности, чтобы каждый метод строил свою часть запроса. Но во-первых, такой подход создаст SQL-билдер в частном порядке (отступление от принципа DRY). А во-вторых, если продолжить полученные методы “вычищать”, освобождать от зависимостей и повышать связанность классов, - то мы в конечном итоге прийдем к классам Criteria и реализуем паттерн Query Object. Повторюсь, попытки разбить этот метод приведут к падению связанности класса. Восстановление связанности выделит классы Criteria.

Т.е. фактически создадим SQL-билдер, который может быть выделен в отдельную утилиту, которая сможет развиваться отдельно.

А если же мы не будем “вычищать” полученные методы, освобождать от зависимостей и повышать связанность классов, то получим нечитаемое мессиво с кучей SQL-кусочков разбросанных по разным методам. Иногда такие “кусочки” оформляют в виде статических методов класса, что обретает признаки “G18: Inappropriate Static” [1], и в полном соответствии с рекомендациями Robert C. Martin напрашивается полиморфный объект Criteria. В любом случае, читаемость “чистого SQL” (а это один из самых весомых аргументов в его пользу) будет утрачена (она будет даже ниже, чем читаемость SQL-билдера).

Иными словами, SQL-билдеры потому и существуют, что они являются вершиной реализации Single responsibility principle (SRP) в данном случае. В главе “Chapter 10: Classes. Organizing for Change” известной книги «Clean Code: A Handbook of Agile Software Craftsmanship» [1], C.Martin демонстрирует достижение принципа SRP именно на примере SQL-билдера.

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

Также концепция “чистого SQL” практически неосуществима в реализации следующих паттернов и подходов:

2. Такие запросы невозможно наследовать без синтаксического анализа (например, чтобы просто изменить сортировку), что обычно влечет за собой их полное копирование. А каждую копию приходится сопровождать отдельно, что усложняет сопровождение такого кода. Впрочем, на досуге я написал простейший mini-builder, который представляет SQL-запрос в виде многоуровневого списка с фрагментами Raw-SQL, что позволяет полноценно выстраивать условно-составные SQL-запросы и при этом практически полностью сохраняет читаемость Raw-SQL.

3. Мне нередко приходилось видеть среди файлов с Raw-SQL диффы на несколько сотен строк только потому, что в модель был добавлен новый атрибут, что имеет признаки “Divergent Change” [2] и “Shotgun Surgery” [2]. Это потому, что SQL-запросы содержат много дубликатов выражений. SQL-код, даже если он в Python-файлах, все равно остается кодом. И к нему также справедливо правило “G5: Duplication” [1] (“Duplicated Code” [2]). В случае использования SQLBuilder таких проблем не возникает, так как необходимые метаданные для построения запроса (в частности, список выбираемых полей) хранятся в едином месте.

4. При использовании концепции “чистого SQL”, критерии выборки обычно передаются в методы выборки в виде аргументов, из-за чего нередко приходится изменять их интерфейсы (а также добавлять новые методы), когда добавляются новые поля данных и критерии выборки к ним, что нарушает Open/Closed Principle и имеет признаки “Divergent Change” [2] и “Shotgun Surgery” [2].

Напрашивается “Introduce Parameter Object[2] с выделением класса Criteria паттерна Query Object. Этот подход исключит подобные проблемы, поскольку все критерии выборки инкапсулированы в единственном объекте (Composite pattern), а также освободит методы выборки от условных операторов “Replace Conditional with Polymorphism[2].

В своем воображении (и в программном коде) человек оперирует объектами. Способ сортировки и ее направление - характеризуют состояние объекта. Критерии выборки - это тоже объекты, от которых мы ожидаем определенного поведения (образовывать композиции, влиять на выборку БД). Когда объекты есть, но они не выражены в коде, программа теряет способность выражать замысел разработчика (“G16: Obscured Intent” [1]).

5. Если какое-то значение объекта требует особой конвертации в DB представление, - нам придется загромождать код явным вызовом этих конвертаций.

6. Существует тенденция (которая мне регулярно встречается) использования паттерна Repository в сочетании с Raw-SQL. Поскольку сам Repository предназначен для сокрытия источника данных, то непонятно, как передавать в Repository критерии выборки, чтобы они были полностью абстрактны от источника данных, т.е. абстрактны от Raw-SQL.

В примитивных случаях, это, конечно, не проблема (можно передавать их именованными аргументами функции, хотя это, в свою очередь, вызывает проблемы описанные в п.4).

Но если требуется хотя бы пять нефиксированных, взаимозависимых или составных критериев (сочетающих вложенные приоритизированные операции “OR”, “AND”, логический “XOR” и др.), то это уже проблема, решение которой и входит в обязанности паттерна Query Object. Передача же фрагментов SQL строк в качестве аргументов функции имеет признаки “G6: Code at Wrong Level of Abstraction” [1] и “G34: Functions Should Descend Only One Level of Abstraction” [1].

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

Чтобы этого избежать, обычно объект, форматирующий запрос, наделяется методами, которые модифицируют запрос под потребности использующих его объектов. Но объект не должен делать предположений о своих клиентах! Иначе получится Божественный Объект, который должен знать о потребностях всех клиентов, которые потенциально могут его использовать.

Это нарушает OCP и приводит к “Divergent Change” [2] и “Shotgun Surgery” [2]. Нередко остается мусор в виде невостребованных методов, после удаления использующих их объектов. Очень большие классы обычно разбиваются наследованием или композицией. Это приводит к тому, что получить целостное представление о том, что делает метод, невозможно без неоднократного прерывания взгляда на изучение содержимого различных методов, классов, а то и файлов.

Паттерн Query Object предоставляет унифицированный интерфейс модификации запроса, освобождая объект запроса от необходимости знать о потребностях использующих его клиентов.

8. Отдельно хочу затронуть вопрос использования синтаксических конструкций языка для построения SQL-запросов в стиле LINQ.

Вот несколько примеров:

Я скажу субъективно, мне больше нравится использовать для этого объекты. Более того, мне нравится когда сами синтаксические конструкции языка представлены объектами, как в Smalltalk.

Простейшей формой реализации паттерна Query Object может быть обычная структура данных, как это сделано в MongoDB Query. Именно такой вариант я выбрал при разработке библиотеки Store.js.

Нужен ли сам Data Mapper?

Что же касается самого маппера, то тут следует решить, нужна ли приложению Domain Model, или вполне устроит паттерн Transaction Script. Я не буду останавливаться на этом выборе, так как он хорошо освещен в «Patterns of Enterprise Application Architecture» [3]. Но если нуждам приложения больше соответствует Domain Model, то без полноценного ORM (пусть и самодельного) обойтись будет непросто, по крайней мере, для качественной, удобной и быстрой работы.

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

Во-первых, поддерживают (DDL).

Во-вторых, наследование можно заменить композицией. Кстати, полезность наследования в ООП до сих пор является обсуждаемым вопросом. В Go-lang наследование отсутствует в пользу композиции. Сами языки программирования реализуют наследование посредством композиции.

В-третьих, сегодня только ленивый не знает о паттернах Single Table Inheritance, Concrete Table Inheritance, Class Table Inheritance и Entity Attribute Value.

Поэтому я затрону только два существенных на мой взгляд вопроса:

  1. Представлять данные в памяти объектами, или структурами данных?
  2. ACID и Two-phase transaction, согласованность объекта в памяти и его данными на диске.

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

В чем отличие между структурой данных и объектом? В Python это отличие сугубо условное. Объекты используют представление данных на абстрактном уровне.

“Объекты скрывают свои данные за абстракциями и предоставляют функции, работающие с этими данными. Структуры данных раскрывают свои данные и не имеют осмысленных функций.” “Objects hide their data behind abstractions and expose functions that operate on that data. Data structure expose their data and have no meaningful functions.” («Clean Code: A Handbook of Agile Software Craftsmanship» [1])

Тут мы снова упираемся в вопрос Domain Model vs Transaction Script, поскольку доменная модель по своему определению охватывает поведение (функции) и свойства (данные).

Но есть еще один немаловажный момент. Допустим, мы храним в БД две колонки - цена и валюта. Или, например, данные полиморфной связи - тип объекта и его идентификатор. Или координаты - x и y. Или путь древовидной структуры - страна, область, город, улица. Т.е. несколько данных образуют единую сущность, и изменение части этих данных не имеет никакого смысла. Как задать политику доступа данных и гарантировать атомарность их изменения (кроме как использованием объектов или неизменяемых типов)?

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

“Весь смысл объектов в том, что они позволяют хранить данные вместе с процедурами их обработки. Классический пример дурного запаха – метод, который больше интересуется не тем классом, в котором он находится, а каким то другим. Чаще всего предметом зависти являются данные.”

“The whole point of objects is that they are a technique to package data with the processes used on that data. A classic smell is a method that seems more interested in a class other than the one it actually is in. The most common focus of the envy is the data.” («Refactoring: Improving the Design of Existing Code» [2])

“Now this design has some problems. Most important, the details of the table structure have leaked into the DOMAIN LAYER ; they should be isolated in a mapping layer that relates the domain objects to the relational tables. Implicitly duplicating that information here could hurt the modifiability and maintainability of the Invoice and Customer objects, because any change to their mappings now have to be tracked in more than one place. But this example is a simple illustration of how to keep the rule in just one place. Some object-relational mapping frameworks provide the means to express such a query in terms of the model objects and attributes, generating the actual SQL in the infrastructure layer. This would let us have our cake and eat it too.” («Domain-Driven Design: Tackling Complexity in the Heart of Software» [4])
The greatest value I’ve seen delivered has been when a narrowly scoped framework automates a particularly tedious and error-prone aspect of the design, such as persistence and object-relational mapping. The best of these unburden developers of drudge work while leaving them complete freedom to design. («Domain-Driven Design: Tackling Complexity in the Heart of Software» [4])

Одним из главных принципов объектно ориентированного программирования является инкапсуляция. Принцип единой обязанности гласит, что каждый объект должен иметь одну обязанность и эта обязанность должна быть полностью инкапсулирована в класс. Лишая объект поведения, мы возлагаем его поведение на другой объект, который должен обслуживать первый. Вопрос в том, оправдано ли это? Если в разделении Active Record на Data Mapper и Domain Model это очевидно, и направлено именно на соблюдение принципа единой обязанности, то в данном случае ответ не так очевиден. Объект поведения начинает “завидовать” объекту данных “G14: Feature Envy” [1], (“Feature Envy” [2]), обретая признаки “F2: Output Arguments” [1], “Convert Procedural Design to Objects” [2], “Primitive Obsession” [2] и “Data Class” [2].

Рассуждения M.Fowler по этому поводу в статье “Anemic Domain Model”. Смотрите так же статью “Про Anemic Domain Model”.

“Многочисленность классов и методов иногда является результатом бессмысленного догматизма. В качестве примера можно привести стандарт кодирования, который требует создания интерфейса для каждого без исключения класса. Или разработчиков, настаивающих, что поля данных и поведение всегда должны быть разделены на классы данных и классы поведения. Избегайте подобных догм, а в своей работе руководствуйтесь более прагматичным подходом.”

“High class and method counts are sometimes the result of pointless dogmatism. Consider, for example, a coding standard that insists on creating an interface for each and every class. Or consider developers who insist that fields and behavior must always be separated into data classes and behavior classes. Such dogma should be resisted and a more pragmatic approach adopted.” («Clean Code: A Handbook of Agile Software Craftsmanship» [1])

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

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

“If the framework’s partitioning conventions pull apart the elements implementing the conceptual objects, the code no longer reveals the model.

There is only so much partitioning a mind can stitch back together, and if the framework uses it all up, the domain developers lose their ability to chunk the model into meaningful pieces.” («Domain-Driven Design: Tackling Complexity in the Heart of Software» [4])

По поводу второго вопроса. Из всех ORM, что я встречал в своей практике (не только на Python), поддержка ACID и Two-phase transaction в Storm ORM и SQLAlchemy реализована наилучшим образом. Надо сказать, в подавляющем большинстве существующих ORM такие попытки даже не предпринимаются.

Рассуждения Мартина Фаулера на этот счет в статье “Orm Hate”.

Статья Роберта Мартина “Dance you Imps!”.

В целом у меня отношение к ORM неоднозначное. Я часто использую в сыром виде паттерн DataMapper для сложных запросов с аннотациями и агрегациями (особенно в Django-приложениях), но также часто использую ORM. Слишком много существующих ORM создает больше “запахов” в коде, чем устраняет. Но Storm ORM к ним не относится.

Интервью с Gustavo Niemeyer, ведущим разработчиком проекта Storm компании Canonical “Storm: An ORM for Python”.

Послесловие

Storm ORM - это инструмент для высококвалифицированных специалистов которые понимают его превосходства и не боятся сопровождать 300 Кб высококачественного кода самостоятельно.

This article in English “Why I prefer Storm ORM for Python”.

Footnotes

[1](1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12) «Clean Code: A Handbook of Agile Software Craftsmanship» Robert C. Martin
[2](1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14) «Refactoring: Improving the Design of Existing Code» by Martin Fowler, Kent Beck, John Brant, William Opdyke, Don Roberts
[3](1, 2, 3) «Patterns of Enterprise Application Architecture» by Martin Fowler, David Rice, Matthew Foemmel, Edward Hieatt, Robert Mee, Randy Stafford
[4](1, 2, 3) «Domain-Driven Design: Tackling Complexity in the Heart of Software» by Eric Evans

Updated on Jul 31, 2017

Comments

comments powered by Disqus