О Persistence ignorance и проблеме контроля изменений в слое работы с базой.
В огромном количестве умных книжек по доменным моделям постоянно твердят о т.н. “Persistence ignorance” - утверждении о том, что доменная модель должна строиться без оглядки на базу данных. Мысль конечно правильая, вот только в качестве реализации почему-то всегда предлагают сделать ORM маппинги для доменной модели и делать вид что она при этом никак с персистентностью не связана, “оно само” работает с помощью ORM.
Однако даже в простейших “книжных” примерах или в демонстрационных видео видно что уже сразу начинаются всяческие уступки для того чтобы ORM заработала - virtual свойства, proteсted конструкторы без параметров (для того чтобы была возможность заполнить поля при вычитке из базы), использование обратных ссылок на родителей в дочерних объектах (public ParentClass Parent {get; set;}) для того чтобы включить инверсное сохранение дочерних объектов так как оно работает в СУБД (проблема one-to-many) и т.д. и т.п. Т.е. делается множество относительно мелких, но всё же вполне заметных допущений и “подпиливаний напильником” для того чтобы суметь втиснуть доменную модель в рамки ORM и работы с базой. Причем допущения эти в целом довольно неявные и неочевидные, но без них персистентность перестаёт работать.
И это еще в хорошем случае если доменная модель и структура базы совпадают между собой. Когда же начинают натягивать что-то не совпадающее 1 в 1, то в ход идёт тяжелая артиллерия - всяческие ухищрения со стороны маппингов и настроек ORM. В этом случае персистентность становится неимоверно хрупкой. И без знания этих деталей сделать изменения в базе или доменной модели становится очень опасно - могут возникать ошибки в абсолютно неочевидных местах.
Поэтому, лично моё мнение что в таком виде Persistence ignorance - это миф, слишком уж разные миры реляционных баз данных и объектов (и коллекций) в памяти.
Что же делать в этом случае? Перестать притворяться!
Т.е. разделить условный слой персистентности на две части:
Часть работы напрямую с базой: объекты-модели максимально точно воспроизводящие строение базы, их маппинги (или напрямую аннотации в классах), контекст работы с базой. Всё это имеет модификаторы internal и снаружи не доступно.
Часть, свободная от персистентности - уже “чистые” классы, которые будут нашим контрактом во внешний мир + публичные статические методы, которые создают, удаляют и обновляют записи в СУБД. Именно они манипулируют нашей первой частью в том виде, в котором нам это нужно.
Т.е. всё это выглядит примерно таким образом:
Сперва мы создаём “образ” нашей СУБД в виде всем понятных и известных классов и маппингов, не пытаясь притворяться что “ложки-базы нет” - это именно что модель сугубо для общения с базой, с публичными сеттерами и прочими вещами не свойственными для хорошей доменной модели. Там же мы создаём internal перегрузку контекста работы с базой (сессия в терминах NHibernate), чем сразу говорим “работа с базой дальше этой сборки не пойдёт”, заодно избавимся от соблазна протянуть Entity/NHibernate через всё приложение.
После этого можно приступать к созданию классов доступных уже за пределами проекта, не завязанного напрямую с базой, не имеющих маппингов, конструкторов без параметров (а то и вовсе не имеющих публичных конструкторов, только private/internal, если нам не надо создавать объекты без сохранения их в базу).
Лично я создавал вообще read-only объекты, модификация которых была возможна только “через кассу базу” .
Т.е. выглядело это приблизительно так:
При этом внутри этих статических методов может быть любая логика по работе с базой (но желательно только с ней, не стоит домешивать туда доменную) - транзакции, создание/удаление/обновление сопутствующих записей. Т.е. именно то что должно быть на уровне персистентности.
Отдельно стоит упомянуть проблему поддержания всех моделей (при подходе по типу CQRS, где модель для чтения и модель для записи - это две разных модели) в консистентном состоянии. Думаю, для многих не редкость ситуация, когда для того чтобы добавить колонку в таблицу, необходимо не только добавить колонку в таблицу и поле в модель, но еще и добавить её в несколько view и хранимых процедур в базе. Причем узнать какие именно это хранимые процедуры или вьюшки довольно непросто - в Sql Management Studio есть конечно view dependencies, но работает оно мягко говоря совсем не так удобно как Find Usages в студии, не говоря уже о том что это модальное окошко. В итоге, вносить изменения становится намного труднее, а отлавливать баги - куда чаще.
В случае с вышеприведённым подходом, есть возможность использовать одну и ту же инфраструктуру для работы с базой как для чтения, так и для записи, ведь всё равно она лишь повторяет схему базы.
К тому же, по моему опыту - большая часть view - моделей обычно состоят из плоских доменных моделей + каких-либо дополнительных данных (агрегатные данные типа сумм, количества и т.д. + какие-либо дочерние коллекции). Соответственно, никто особо не запрещает использовать либо наследование (хотя его не очень люблю и на свежую голову решил что необходимости в нём тут абсолютно нет) либо композицию и использовать те же самые CreateFromEntity. Что во-первых даёт полный контроль с т.з. системы типов и конструкторов, а во-вторых уменьшает количество точек где надо производить изменения до минимума - в таком случае для того чтобы добавить колонку в базе необходимо будет: добавить собственно колонку, добавить поле в entity для работы с базой, добавить поле во внешний объект и его конструктор, а также в CreateFromEntity (который, кстати, тоже может быть private/internal конструктором).
Т.е. если нам понадобился документ вместе с его содержимым, то мы создаём
Т.е. если вдруг понадобится добавить поле в Document, то сперва добавляем его в Document и соответственно его конструктор (ведь поле у нас readonly, иного способа его инициализировать нет). А дальше - у нас перестаёт билдиться ConvertFromEntity, т.к. внутри в вызове конструктора не хватает параметра, вслед за ним тянется Entities.Document (надо же откуда-то брать данные?) и всё! Останется лишь добавить колонку в базу, что в общем-то довольно сложно забыть. Всякие DocumentWithEntries, DocumentWithStatistics и всё что нам еще придёт в голову будут автоматически иметь нужное нам поле. При этом Entity позволяет получать довольно удобные агрегирующие функции прямо из Linq интерфейса путём трансляции этих запросов в базу. Т.е. в 90% случаев скорее всего вполне можно обойтись без Views/Stored Procedures и ручного написания SQL кода.
В качестве вывода хотел бы еще раз подчеркнуть что не стоит пытаться прятать голову в песок и утверждать что мы имеем Persistence Ignorance, потому что мы сумели кое-как натянуть ORM поверх доменных объектов. На мой взгляд лучше не пытаться притворяться и разделить эту часть на две - проекцию базы данных и нормальные доменные сущности, которые уже будут действительно POCO, без прикрученных сбоку маппингов, конструкторов без параметров и т.д. Заодно, поверх этой же проекции можно сделать и View - модели для чтения. Либо смешав их с доменными (т.к. они практически всегда строятся поверх уже имеющихся там данных), либо вынеся в отдельную friendly сборку. Это поможет иметь куда более удобный контроль над изменениями в структуре базы, практически ничего не останется на “это надо запомнить”, всё будет контроллироваться с помощью параметризированных конструкторов и системы типов.
UPD. Тут мне заняли хороший вопрос - а что происходит с этой моделью в дальнейшем и как будет выглядеть модификация данных?
В общем, первоначальным соблазном было сказать что на выходе мы и имеем доменную модель. Но тут возникает вопрос - а что мы хотим получить от доменной модели и что в таком случае будет ею являться?
Если она должна "делать зашибись", включая обновления базы, то наверное можно назвать её и таким образом. Но мне не нравится эта идея, потому как тогда эта доменная модель будет намертво привязана к персистентности и зачастую смешивать собственную логику с логикой персистентности (во всяком случае соблазн будет крайне велик).
Так что я всё же склоняюсь к тому что это никакая не доменная модель, а просто довольно четко оформленная модель персистентности. А уже потом из неё можно сделать что угодно - и доменную модель и модель для отображения данных и чёрта в ступе. Т.е. опять приходится не лениться и делать разделение между моделями :(
Соответственно, изменения для такой модели тоже могут быть минималистичными с учетом необходимых юзкейсов. Например, если необходимо изменить текст в элементе документа, то вовсе не надо принимать в качестве модели весь элемент документа, достаточно просто получить его Id и новый текст.
В огромном количестве умных книжек по доменным моделям постоянно твердят о т.н. “Persistence ignorance” - утверждении о том, что доменная модель должна строиться без оглядки на базу данных. Мысль конечно правильая, вот только в качестве реализации почему-то всегда предлагают сделать ORM маппинги для доменной модели и делать вид что она при этом никак с персистентностью не связана, “оно само” работает с помощью ORM.
Однако даже в простейших “книжных” примерах или в демонстрационных видео видно что уже сразу начинаются всяческие уступки для того чтобы ORM заработала - virtual свойства, proteсted конструкторы без параметров (для того чтобы была возможность заполнить поля при вычитке из базы), использование обратных ссылок на родителей в дочерних объектах (public ParentClass Parent {get; set;}) для того чтобы включить инверсное сохранение дочерних объектов так как оно работает в СУБД (проблема one-to-many) и т.д. и т.п. Т.е. делается множество относительно мелких, но всё же вполне заметных допущений и “подпиливаний напильником” для того чтобы суметь втиснуть доменную модель в рамки ORM и работы с базой. Причем допущения эти в целом довольно неявные и неочевидные, но без них персистентность перестаёт работать.
И это еще в хорошем случае если доменная модель и структура базы совпадают между собой. Когда же начинают натягивать что-то не совпадающее 1 в 1, то в ход идёт тяжелая артиллерия - всяческие ухищрения со стороны маппингов и настроек ORM. В этом случае персистентность становится неимоверно хрупкой. И без знания этих деталей сделать изменения в базе или доменной модели становится очень опасно - могут возникать ошибки в абсолютно неочевидных местах.
Поэтому, лично моё мнение что в таком виде Persistence ignorance - это миф, слишком уж разные миры реляционных баз данных и объектов (и коллекций) в памяти.
Что же делать в этом случае? Перестать притворяться!
Т.е. разделить условный слой персистентности на две части:
Часть работы напрямую с базой: объекты-модели максимально точно воспроизводящие строение базы, их маппинги (или напрямую аннотации в классах), контекст работы с базой. Всё это имеет модификаторы internal и снаружи не доступно.
Часть, свободная от персистентности - уже “чистые” классы, которые будут нашим контрактом во внешний мир + публичные статические методы, которые создают, удаляют и обновляют записи в СУБД. Именно они манипулируют нашей первой частью в том виде, в котором нам это нужно.
Т.е. всё это выглядит примерно таким образом:
Сперва мы создаём “образ” нашей СУБД в виде всем понятных и известных классов и маппингов, не пытаясь притворяться что “ложки-базы нет” - это именно что модель сугубо для общения с базой, с публичными сеттерами и прочими вещами не свойственными для хорошей доменной модели. Там же мы создаём internal перегрузку контекста работы с базой (сессия в терминах NHibernate), чем сразу говорим “работа с базой дальше этой сборки не пойдёт”, заодно избавимся от соблазна протянуть Entity/NHibernate через всё приложение.
После этого можно приступать к созданию классов доступных уже за пределами проекта, не завязанного напрямую с базой, не имеющих маппингов, конструкторов без параметров (а то и вовсе не имеющих публичных конструкторов, только private/internal, если нам не надо создавать объекты без сохранения их в базу).
Лично я создавал вообще read-only объекты, модификация которых была возможна только “через кассу базу” .
Т.е. выглядело это приблизительно так:
public class Document { private readonly Guid id; private readonly string title; private readonly string fileName; internal Document(Guid id, string title, string fileName) { this.id = id; this.title = title; this.fileName = fileName; } public Guid Id {get { return id; } } public string Title {get { return title; } } public string FileName {get { return fileName; } } public static Document GetDocument(Guid id) { using (var context = DbContext.CreateInternal()) { var entity = context.Documents.Find(id); return ConvertFromEntity(entity); } } internal static Document ConvertFromEntity(Entities.Document entity) { return new Document(entity.Id, entity.Title, entity.FileName); } }
При этом внутри этих статических методов может быть любая логика по работе с базой (но желательно только с ней, не стоит домешивать туда доменную) - транзакции, создание/удаление/обновление сопутствующих записей. Т.е. именно то что должно быть на уровне персистентности.
Отдельно стоит упомянуть проблему поддержания всех моделей (при подходе по типу CQRS, где модель для чтения и модель для записи - это две разных модели) в консистентном состоянии. Думаю, для многих не редкость ситуация, когда для того чтобы добавить колонку в таблицу, необходимо не только добавить колонку в таблицу и поле в модель, но еще и добавить её в несколько view и хранимых процедур в базе. Причем узнать какие именно это хранимые процедуры или вьюшки довольно непросто - в Sql Management Studio есть конечно view dependencies, но работает оно мягко говоря совсем не так удобно как Find Usages в студии, не говоря уже о том что это модальное окошко. В итоге, вносить изменения становится намного труднее, а отлавливать баги - куда чаще.
В случае с вышеприведённым подходом, есть возможность использовать одну и ту же инфраструктуру для работы с базой как для чтения, так и для записи, ведь всё равно она лишь повторяет схему базы.
К тому же, по моему опыту - большая часть view - моделей обычно состоят из плоских доменных моделей + каких-либо дополнительных данных (агрегатные данные типа сумм, количества и т.д. + какие-либо дочерние коллекции). Соответственно, никто особо не запрещает использовать либо наследование (хотя его не очень люблю и на свежую голову решил что необходимости в нём тут абсолютно нет) либо композицию и использовать те же самые CreateFromEntity. Что во-первых даёт полный контроль с т.з. системы типов и конструкторов, а во-вторых уменьшает количество точек где надо производить изменения до минимума - в таком случае для того чтобы добавить колонку в базе необходимо будет: добавить собственно колонку, добавить поле в entity для работы с базой, добавить поле во внешний объект и его конструктор, а также в CreateFromEntity (который, кстати, тоже может быть private/internal конструктором).
Т.е. если нам понадобился документ вместе с его содержимым, то мы создаём
public class DocumentWithEntries { private readonly Document document; private readonly IEnumerable<DocumentEntry> entries; private static DocumentWithEntries(Document document, IEnumerable<DocumentEntry> entries) { this.document = document; this.entries = entries; } public Document Document { get { return document; } } public IEnumerable<DocumentEntry> {get { return entries; } } public static DocumentWithEntries GetDocumentWithEntries(guid id) { using(var context = DbContext.CreateInternal()) { var documentEntity = context .Documents .Include(x => x.Entries) .FirstOrDefault(x => x.Id == id); return ConvertFromEntity(documentEntity, documentEntity.Entries); } } private static DocumentWithEntries ConvertFromEntity(Entities.Document document,
IEnumerable<Entities.DocumentEntry> entryEntities) { var document = Document.ConvertFromEntity(document); var entries = entryEntities.Select(x => DocumentEntry.ConvertFromEntity(x)); return new DocumentWithEntries(document, entries); } }
Т.е. если вдруг понадобится добавить поле в Document, то сперва добавляем его в Document и соответственно его конструктор (ведь поле у нас readonly, иного способа его инициализировать нет). А дальше - у нас перестаёт билдиться ConvertFromEntity, т.к. внутри в вызове конструктора не хватает параметра, вслед за ним тянется Entities.Document (надо же откуда-то брать данные?) и всё! Останется лишь добавить колонку в базу, что в общем-то довольно сложно забыть. Всякие DocumentWithEntries, DocumentWithStatistics и всё что нам еще придёт в голову будут автоматически иметь нужное нам поле. При этом Entity позволяет получать довольно удобные агрегирующие функции прямо из Linq интерфейса путём трансляции этих запросов в базу. Т.е. в 90% случаев скорее всего вполне можно обойтись без Views/Stored Procedures и ручного написания SQL кода.
В качестве вывода хотел бы еще раз подчеркнуть что не стоит пытаться прятать голову в песок и утверждать что мы имеем Persistence Ignorance, потому что мы сумели кое-как натянуть ORM поверх доменных объектов. На мой взгляд лучше не пытаться притворяться и разделить эту часть на две - проекцию базы данных и нормальные доменные сущности, которые уже будут действительно POCO, без прикрученных сбоку маппингов, конструкторов без параметров и т.д. Заодно, поверх этой же проекции можно сделать и View - модели для чтения. Либо смешав их с доменными (т.к. они практически всегда строятся поверх уже имеющихся там данных), либо вынеся в отдельную friendly сборку. Это поможет иметь куда более удобный контроль над изменениями в структуре базы, практически ничего не останется на “это надо запомнить”, всё будет контроллироваться с помощью параметризированных конструкторов и системы типов.
UPD. Тут мне заняли хороший вопрос - а что происходит с этой моделью в дальнейшем и как будет выглядеть модификация данных?
В общем, первоначальным соблазном было сказать что на выходе мы и имеем доменную модель. Но тут возникает вопрос - а что мы хотим получить от доменной модели и что в таком случае будет ею являться?
Если она должна "делать зашибись", включая обновления базы, то наверное можно назвать её и таким образом. Но мне не нравится эта идея, потому как тогда эта доменная модель будет намертво привязана к персистентности и зачастую смешивать собственную логику с логикой персистентности (во всяком случае соблазн будет крайне велик).
Так что я всё же склоняюсь к тому что это никакая не доменная модель, а просто довольно четко оформленная модель персистентности. А уже потом из неё можно сделать что угодно - и доменную модель и модель для отображения данных и чёрта в ступе. Т.е. опять приходится не лениться и делать разделение между моделями :(
Соответственно, изменения для такой модели тоже могут быть минималистичными с учетом необходимых юзкейсов. Например, если необходимо изменить текст в элементе документа, то вовсе не надо принимать в качестве модели весь элемент документа, достаточно просто получить его Id и новый текст.
public static DocumentEntry UpdateEntryText(Guid id, string newText) { using(var context = DbContext.CreateInternal()) { var entryEntity = context.DocumentEntries.First(x => x.Id == id); entryEntity.Text = newText; context.Save(); return ConvertFromEntity(entryEntity); } }
Кстати, если вдруг будет необходимость комбинировать разные операции внутри одного более глобального метода, то можно создавать 2 версии метода - internal перегрузку, которая будет принимать контекст и выполнять все действия в нём и public версию, которая будет создавать контекст и вызывать internal часть, которая всё и сделает. Соответственно, если нужно скомбинировать какие-то действия, то будут вызываться internal методы получающие контекст извне. И только внешний public метод будет создавать транзакции и управлять ими.
Комментариев нет:
Отправить комментарий