Soft delete pattern в Entity Framework — ходим по “граблям”

Alexander Solovey
ITA Labs
Published in
8 min readJan 23, 2019

В этой статье мы поговорим про особенности реализации подхода Soft delete в Entity Framework, с использованием IDbCommandTreeInterceptor. Рассмотрим проблемы, скрывающиеся за довольно прозрачным решением, которые пришлось решать в процессе работы над проектами в нашей компании. В итоге очертим круг задач, которые можно решить с помощью данного подхода, определим ограничения в использовании. Итак, по порядку.

1. Что такое «soft delete» и зачем оно нам?

«Soft delete», фантомное или мягкое удаление — это подход по работе с данными, когда данные реальное не удаляются из хранилища, а просто помечаются как удаленные. В данных, которые должны быть отмечены на удаление, как правило, добавляется поле bool IsDeleted или DateTime? DeletedDate и механизм удаления и получения данных работает с этим полем. Использование DeletedDate предпочтительней, т.к. его использование дает возможность установить не только факт удаления, но и дату удаления.

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

Кроме того, отсутствие реального физического удаления в системе защищает от случайного или не случайного удаления, в этом случае для восстановления данных можно просто сбросить флаг или дату удаления, особенно, это касается критичных данных, типа аккаунтов, пользователей, паролей. Хотя реального применения в нашем проекте это пока не нашло, но как-то на душе теплее от того, что ты можешь это сделать в будущем J.

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

2. Требования к реализации

· Работа с данными во всех наших продуктах, выполняется через Entity Framework 6, с использованием подхода Database First. Соответственно реализация должна быть интегрирована с движком EF;

· Код, который выполняет фейковое удаление данных должен быть общим;

· Под удалением должно пониматься установка даты удаления — DeletedDate.

· Код, который выполняет получение удаленных и\или не удаленных данных должен быть общий;

· Должна быть возможность настройки получения данных: только не удаленные (default), все данные (удаленные и не удаленные), только удаленные.

· Возможно на этом же решении можно сделать установку даты создания данных — CreatedDate и даты изменения данных — ModifiedDate.

3. Реализация

Так как мы работаем через EF, то особого выбора не было, тем более, что EF предоставляет механизмы, которые позволяют встраиваться в pipeline обработки LINQ запроса. На просторах интернета можно найти несколько реализаций EF Soft Delete, и все они сводятся к использованию IDbCommandTreeInterceptor. Определим какое место занимает IDbCommandTreeInterceptor конвейере обработки EF.

Вспомним немного теории, как работает EF:

Рисунок 1.

В упрощенном виде, получение\удаление\изменение данных в базе в EF выполняется в несколько этапов

1. На стороне приложения мы имеем EF контекст (DbContext), это точка через которое идет обращение ко всем сущностям системы. Опускаю здесь EDMX модель и прочее.

2. Выполняем получение данных из контекста, используя LINQ to Entity, например, var accounts = context.Accounts.Where(a => a.Enabled == true);

3. Под капотом метод расширения Where вызовет Where у EF провайдера, т.е. вызов переходит в internal область EF.

4. Задача EF выполнить преобразование Expression Tree в SQL. Самая нагруженная операция внутри EF это обход Expression Tree и построение плана выполнения EF.

5. После того, как EF выполнил преобразование он вызывает DbCommand соответствующего ADO.NET Data Provider, в нашем случае SQL Provider.

6. ADO.NET Data Provider передаст SQL в СУБД и получит результат.

EF Interceptor позволяет встроиться на шаге 4 сразу после получения в EF Expression Tree до его обхода и построениях internal структур. В частности, IDbCommandTreeInterceptor позволяет подменить Expression Tree, которое получил EF от LINQ вызова. Что собственно нам и нужно.

Исходную задачу реализации soft delete можно разделить на две:

1. Заменить удаление сущности на обновление ее свойства — установку DeleteDate;

2. Получение сущностей в зависимости от настройки: получить не удаленные, получить все, получить только удаленные, по сути наложить дополнительное условие на свойство DeletedDate, например, DeletedDate == null — для не удаленных, DeletedDate != null — для не удаленных.

Задача 1.

Решается довольно просто и не имеет побочных эффектов. Суть решения сводится к замене команды удаления на команду обновления свойства DeletedDate.

Задача 2.

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

Причем все, или часть объектов могут быть удалены, и нам для всех этих объектов нужно наложить дополнительное условие на DeletedDate. Реализация IDbCommandTreeInterceptor позволяет получить Expression Tree, которое построил провайдер по данному LINQ запросу, обойти это дерево и добавить для всех объектов, у которых есть свойство DeletedDate, дополнительное условие. Для выполнения обхода дерева используется своя реализация ExpressionVisitor

Настройка получения удаленных или не удаленных объектов задается per context. Т.е. при создании контекста мы указываем с какими объектами мы хотим работать: только с не удаленными или с удаленными и не удаленными.

Если получать ТОЛЬКО не удаленные объекты, то все прекрасно работает. НО если в бизнес логике требуется иметь возможность получать как удаленные, так и не удаленные объекты, как в нашем случае, то это решение имеет существенную проблему.

4. Проблема

Когда мы использовали решение по получению данных из Задачи 2, начали возникать очень странные проблемы: периодически начали всплывать удаленные данные в тех местах и в тех кейсах, где их в принципе быть не должно. Причем, после рестарта сервера, все работало штатно. И воспроизвести данное поведение не представлялось возможным.

Оказалось, что при вызове одного и того же LINQ запроса несколько раз подряд, Interceptor будет вызван только первый раз. Вроде бы ничего страшного, если бы не тот факт, что для получения удаленных и не удаленных сущностей используется один и тот же запрос (знание о том удаленные или не удаленные сущности мы получаем находится в контексте).

После более глубокого анализа работы EF Interceptor, выяснилось, что EF внутри использует кеширование полученного Expression Tree, где в качестве ключа используется строковое представление LINQ запроса, за это отвечает глубоко internal компонент EF — QueryCacheManager. Ключ LINQ запроса включает в себя знания о всех сущностях, используемых в запросе в том числе и подключенных по Include, количестве параметров и типе результата.

Причем кеш, реализованный в QueryCacheManager имеет интересные особенности:

· размер кеша фиксированный — 1000 записей

· кеш сам себя очищает, если размер достигает заполнения 80%, запускает процедуру выборочной очистки каждую минуту

· экземпляр QueryCacheManager создается один на домен, т.е. все DbContext работают с одним кешем запросов

· не имеет публичного API

· в Entity Framework 6.2. появилась возможность установки максимального размера кеша и частоты запуска очистки кеша

Во что это выливается:

1. Когда мы вызываем первый раз var accounts = context.AccountEntities.ToList() в контексте, настроенном на получение не удаленных сущностей происходит следующее

· LINQ запрос попадает в EF, строится ключ этого запроса

· Идет обращение в QueryCacheManager с LINQ-ключем, данные нет

· Пошло построение Expression Tree

· Expression Tree передается в Interceptor, добавляются условия DeletedDate == null

· По ExpressionTree строится план выполнения, результат вместе с LINQ-ключом записывается в QueryCacheManager

· Исполняется план –> SQL c “…where DeletedDate is null”

2. Когда мы повторно вызываем var accounts = context.AccountEntities.ToList() на этот раз в контексте, настроенном на получение в том числе удаленных сущностей происходит следующее:

· LINQ запрос попадает в EF, строится ключ этого запроса

· Идет обращение в QueryCacheManager с LINQ-ключем, данные уже есть с предыдущего раза

· По LINQ-ключу получается план выполнения

· Исполняется план –> SQL c “…where DeletedDate is null”

В итоге получаем недетерминированное поведение: какой запрос попадет в кеш первым, тот и будет использоваться в дальнейшем для выполнения в независимости от того, какой контекст мы используем для запроса, с удаленными или не удаленными сущностями. Причем, со временем, запрос из кеша может быть удален внутренним механизмом EF.

На первый взгляд решение очевидно, нужно изменить вид ExpressionTree, которое строит EF по одному и тому же LINQ запросу при получении удаленных и не удаленных сущностей. В этом случае, оба запроса выполнятся без использования кеша, т.к. LINQ-ключи в QueryCacheManager будут разными. Можно воспользоваться следующим методом расширения, который при запросе удаленных сущностей вернет DbSet, в который будет добавлено дополнительное условие d => true. Данное условие не изменит результирующий набор, но добавит один дополнительный узел в ExpressionTree, который изменит LINQ-ключ кеша.

И использовать данный метод расширения для получения данных следующим образом:

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

5. Вывод

В виду всего вышесказанного, для случая, когда система должна одновременно работать с удаленными и не удаленными данными, лучше отказаться от использования реализации soft delete c IDbCommandTreeInterceptor. Наилучшим способом является ручное управление условием выборки, т.е. явное указание условий для DeletedDate. В чем плюсы:

- детерминированность — одинаковое поведение в любой момент времени, воспроизводимый результат, тестируемость;

- читаемость — по LINQ запросу сразу можно понять, какие данные он выбирает;

- гибкость — можно выбрать любую комбинацию удаленных и\или не удаленных данных;

- легкость в сопровождении (относительно реализации c IDbCommandTreeInterceptor)

- одинаковое поведение в разных версиях EF и для разных провайдеров, в отличие от реализации с IDbCommandTreeInterceptor, т.к. нет гарантии, что в будущих версиях EF не изменится внутренний механизм кеширования.

Для систем, работающих одновременно с удаленными и с не удаленными данными, возможно использование только части реализации soft delete с IDbCommandTreeInterceptor, отвечающей за автоматическую установку DeletedDate при удалении.

Когда использование реализации soft delete с IDbCommandTreeInterceptor оправдано? Если система работает исключительно с не удаленными данными, то можно использовать interceptor как для установки DeletedDate, так и для выборки не удаленных данных.

Автор статьи: Виталий Назаров, Старший Разработчик, ITA Labs

--

--