Sunday, February 22, 2009

Немного о проектировании: паттерны из мира ORM

Наверняка, каждый программист слышал о паттернах (шаблонах) проектирования, читал классику, и с той или иной периодичностью использует паттерны в повседневной работе для того, чтобы сделать дизайн своего приложения более понятным и масштабируемым. Если вы когда-нибудь пробовали использовать Linq to SQL, Entity Framework, NHibernate или другие ORM, то вы, конечно же, заметили, что у них есть много общего. L2S и EF с некоторого расстояния вообще выглядят, как близнецы-братья, да и остальные ORM тоже недалеко ушли. Вы думаете, это совпадение? Вовсе нет. Просто все они строятся с использованием одних и тех же паттернов проектирования.

Примечание. Хотел бы отметить, что использовать мы будем терминологию небезызвестного Мартина Фаулера, который является общепризнанным авторитетом в дизайне приложений, и его каталог "архитектурных" паттернов.

Начнем с того, что каждый ORM имеет в своем составе некий "менеджер" (например, контекст в L2S и EF, или сессию в NH), который является неким логическим фасадом (Facade) к базе данных. Этот фасад, как правило, "понимает" определенный маппинг объектов приложения на таблицы базы данных, "умеет" принимать на вход простые и сложные запросы на получение объектов, знает, "какие" изменения объектов и "как" сохранить в базе данных, и многое другое. Рассмотрим, какие паттерны работают внутри и рядом с ним.

Unit of Work

Unit of Work - наверно, самый популярный паттерн модификации данных. Он описывает следующую стратегию работы с данными:

  1. Получили объекты из ORM
  2. Изменили какое угодно их количество
  3. Сказали ORM сохранить данные в базу (или откатиться)
  4. ORM сохранил все изменения одним махом (или откатился)

Даже если вы никогда в жизни в глаза не видели ORM, то знакомо, не правда, ли? Конечно, именно по такому же принципу работает старая-добрая пара DataAdapter - DataSet в plain ADO.NET. И точно так же работают как контексты L2S и EF, так и сессия NH. Unit of Work работает благодаря тому, что он хранит в себе ссылки на все объекты, которые были созданы/получены через него. Именно поэтому без дополнительных телодвижений вам не удастся заставить контекст EF сохранить объект, который был изменен в рамках другого контекста, а потом приаттачен к текущему. Контекст просто не знает, какие изменения были сделаны в объекте, т.к. это не забота объекта "знать" о своих изменениях, а забота окружения, коим и является для объекта контекст/сессия.

Паттерн Unit of Work полезно использовать и в других случаях. Например, когда вам необходимо дать пользователю возможность изменить определенные данные приложения в памяти, а потом нажатием на одну кнопку сохранить их в базу данных. Или не сохранить.

Active Record

В противовес предыдущему паттерну, паттерн Active Record определяет, что объект сам знает, как себя извлечь из базы данных и сохранить изменения туда же. То есть объекту больше не нужны внешние "менеджеры", он сам в состоянии за себя постоять.

В целом, использование этого паттерна остается достаточно спорным, т.к. объект в таком случае "знает" о способе сохранения себя в базе, и поэтому крепко связан с ней, нарушая при этом Persistence Ignorance. Кроме того, если подобный объект содержит бизнес-логику, то он еще и нарушает Single Responsibility Principle. С другой стороны, у Active Record есть свои преимущества и сторонники. Из широко распространенных ORM паттерн ActiveRecord используется как минимум в Castle ActiveRecord (кто бы мог подумать :)). Да и Ruby-on-Rails, насколько я слышал, его уважает.

Identity Map

Identity Map - это паттерн, который дает возможность загрузить лишь один экземпляр объекта в память. То есть, если при первом обращении к определенному объекту ORM "поднимает" экземпляр этого объекта из базы, но все последующие запросы, которые будут приводить к получению этого объекта (даже если этот объект будет частью списка других объектов), будут всего лишь получать ссылку на уже созданный объект, а не его копию. Работает это благодаря тому, что внутри ядра ORM есть реализация этого паттерна. Контекст/сессия, перед тем, как материализовать (создать) объект с определенным Id, сначала просматривают это хранилище на предмет существования объекта, и если он не найден, регистрируют новый материализованный объект в хранилище.

Что это нам дает, как программистам. Прежде всего, если мы работаем с одним и тем же контекстом/сессией, то мы можем легко сравнивать объекты по ссылке (==). Кроме того, и что более важно, если мы уже начали изменять объект, а потом вдруг он появился в результате какой-то вспомогательной выборки, то мы можем быть уверены, что это один и тот же измененный объект (конечно, если вы напрямую не сказали при выборке, что вам необходим "чистый" объект). Ну, и напоследок, как side effect, мы получаем небольшое кеширование в рамках контекста/сессии, что позволяет уменьшить затраты на повторную материализацию объекта (весьма немаленькие). Естесственно, объекты, полученные из разных конекстов/сессий, не обладают подобными свойствами, что также необходимо понимать и учитывать.

Identity Field

Упомянув Identity Map, нельзя обойти стороной Identity Field. Этот небольшой паттерн, по сути, всего лишь говорит о том, что каждый объект содержит в себе уникальный ключ, который позволяет ORM правильно идентифицировать объекты у себя внутри. В частности, в EF такую функцию выполняет класс EntityKey, который состоит из названия сущности и значений первичного ключа.

Как вы уже догадались, Identity Field интенсивно используется для решения задач, описанных предыдущими паттернами.

Query Object

Паттерн Query Object реализует в себе запрос к данным. У Фаулера написано, что этот объект должен сам знать, как превратить себя в SQL-запрос, но очень часто паттерн рассматривается более широко, описывая прежде всего способ составления сложных запросов к ORM.

В NH есть механизм запросов Criteria, который можно отнести к реализации этого паттерна. В L2S и EF эту функцию выполняет язык запросов LINQ, который также можно назвать его реализацией (хотя и с большой натяжкой).

Lazy Load

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

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

Repository

Я не мог пройти мимо этого паттерна, хотя на самом деле он не реализован ни в L2S, ни в EF, ни в NH, ни в AR. (Возможно, реализован в других ORM, подскажите, если знаете.) Не мог пройти, потому что паттерн Repository очень часто используется при работе с NH (1 и 2), да и примеры для других ORM уже подоспели, например, для L2S (1 и 2).

С простой точки зрения, этот паттерн определяет фасад, который предназначен для того, чтобы быть промежуточным слоем между доменной моделью (бизнес-логикой) и источником данных. Весь код приложения за пределами репозитория работает с базой данных (или другим источником данных) через репозиторий. Однако, если смотреть на него с точки зрения пуристов DDD, то репозиторий - это уже нечто другое. Всех желающих погрузиться в нирвану DDD отправляю к следующему источнику, который достаточно неплохо описывает Repository с точки зрения DDD. Не заблудитесь только там :)

Заключение

Предлагаю пока что ограничится данными паттернами, т.к. они являются основными паттернами поведения ORM. Тех же, кто хочет узнать больше, отправляю к Фаулеру, в каталоге которого описаны еще с десяток других паттернов, связанных с другими аспектами работы Data Access в целом, и ORM в частности.

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

28 comments:

  1. Занёс в рекомендуемые статьи. Спасибо, для меня бОльшую ценность оказало не столько содержимое статьи (всё это уже знаю), сколько ссылки разбросанные по тексту =)

    ReplyDelete
  2. Рад, что нашли хоть что-то полезное для себя :) Надеюсь, кому-то будет полезен и остальной материал.

    ReplyDelete
  3. Он очень интересен, я даю его почитать новичкам. Респект короче =)

    ReplyDelete
  4. В динамических языках Active Record - замечательная вещь. Persistance Ignorance в таких языках как Ruby может и не нарушаться этим паттерном. В зависимости от провайдера реализация AR меняется динамически. Например, в том же Ruby On Rails вообще не надо ни маппингов, ни сущностей писать - только новый класс объявляется автоматически (грубо говоря, его имя). После этого механизм AR динамически "подмешивает" все необходимые методы исходя из схемы базы данных, то есть DAL создается на лету. Время разработки сокращается в разы, а благодаря такой штуке как миграции, новые изменения вносить тоже легко и приятно.
    С нетерпением жду динамического C#4.0. Думаю, когда и для него сделают аналогичный динамический AR , у ORM будет достойная альтернатива, особенно при RAD.

    ReplyDelete
  5. За все нужно платить :) Если в C# 4.0 появится автоматический AR (пока что слабо представляю себе, как это будет работать), то за это мы все заплатим еще более глубокими провисаниями в области производительности. Какой бы неудобной ни был маппинг и кодогенерация (или рефлексия) в современных ORM, это по крайней мере работает за адекватное время.

    Кроме того, наличие Active Record не решает многих других проблем. Например, Identity Map реально помогает в случае "поднимания" сущности в память из нескольких запросов к базе. Объект будет один. Как в этом случае будет работать AR? Да и вообще, как подобные задачи решаются в том же Ruby on Rails?

    ReplyDelete
  6. Я могу ошибаться но у ActiveRecord есть Identity Map. Да и по перфомансу все точно также. С чего бы это у ActiveRecord были провисания по преформансу?

    ReplyDelete
  7. Про какой Active Record ты говоришь? Про паттерн или про фреймворк? В фреймворке, возможно, и есть, потому что он базируется на NH, а в NH точно есть реализация Identity Map. Если мы говорим про паттерн, то все зависит от реализации. Думаю, ты без проблем можешь реализовать свой Active Record так, что при запросе того же объекта из базы в другом запросе, он вернет тебе ссылку на существующий. Но наверно, это будет дополнительная головная боль, не знаю...

    А фраза о провисаниях в перформансе была для динамических AR-объектов, а не для паттерна в целом. Собственно, провисание будет заключаться как раз в том, что есть динамический "маппинг", о котором говорил Otozvalsa.

    ReplyDelete
  8. Я про Ruby On Rails Active Record. Хотя судя по всему там таки нема Identity Map.

    Про провисания. Я технически не вижу проблем. С точки зрения реалзиации у тебя есть результат запроса ввиде колекции ключ/значение. Какая разница есть мапинг или нема? В мапинге итерируемся по мапингам и асайним в проперти. В динамическом, просто все ключ/значение асайним в проперти.

    ReplyDelete
  9. Про RoR вообще ничего сказать не могу :(

    По поводу провисаний - похоже, что ты прав :) Более того, если маппинга нет, то не нужно делать дополнительные проверки... Так что, возможно, динамические проперти даже быстрее работать будут.

    ReplyDelete
  10. Очень нравится ваш блог, уже не первую статью читаю. Полезно, спасибо

    ReplyDelete
  11. По поводу реализации паттерна Repository с помощью LinqToSql, возможно вас заинтересует мой проект - lsda.codeplex.com. Там реализация Generic Repository под LinqToSql и LinqToObjects c поддержкой ассоциаций, шейпинга, интерсепторов и прочих плюшек.

    ReplyDelete