![image](https://habrastorage.org/webt/oe/bg/db/oebgdbwbph1kzflig9jyrabyozo.gif)
Чтобы пользователь не чувствовал боли от безвозвратно утерянных данных, стоит задуматься о мягком удалении. При мягком удалении запись не удаляется из базы физически, а лишь помечается как удалённая. Это позволяет легко восстановить данные путём сброса флага.
Недавно я реализовал мягкое удаление в одном из наших REST-сервисов. Тех, кому интересно, что у меня получилось, приглашаю под кат.
Необходимое вступление
Споры по поводу того, надо ли использовать мягкое удаление, очень стары. Достаточно взглянуть на длительные холивары тут и тут.
Наиболее разумной кажется позиция, согласно которой всё зависит от ситуации. Есть случаи, когда мягкое удаление удобно или даже необходимо, есть случаи, когда аргументы противников мягкого удаления заслуживают внимания. Кстати говоря, важным аргументов против мягкого удаления является ответ, пришедший из 2018 года: если речь идёт об учётных записях пользователей, тогда мягкое удаление противоречит GDPR.
Мы решили, что в нашем сервисе для хранения документов мягкое удаление необходимо.
RESTful подход
Если мы хотим реализовать мягкое удаление в сервисе, надо понять, как оно должно выглядеть с точки зрения интерфейса. Поиск по интернету показал, что типичный вопрос, который возникает у людей, это надо ли по-прежнему использовать DELETE {resource}, или лучше воспользоваться вместо этого методом PATCH с телом, включающим в себя что-то вроде {status: 'deleted'}.
Тут мнение народа однозначно: использовать надо по-прежнему DELETE. С точки зрения клиента, удаление – оно и в Африке удаление. Ничего меняться не должно: если ресурс удалён, он становится недоступным; если клиент хочет удалить ресурс, он знает, что для этого предназначен HTTP-метод DELETE. Посвящать клиента в детали того, как именно сервис реализует удаление, не нужно.
Но кроме этого, меня волновал вопрос, как восстанавливать удалённые ресурсы. Конечно, эта проблема решается путём администрирования базы. Однако, хотелось бы иметь возможность сделать это и через REST API. А тут мы вступаем в противоречие. Получается, клиент всё-таки должен быть посвящён в детали реализации?
Поиск долго не давал результатов, пока я не наткнулся на хорошую статью Дэна Йодера. В статье разбирается семантика разных HTTP-запросов и предлагается вместо физического удаления перемещать удалённые ресурсы в архив. Кроме того будет неплохо, если DELETE будет возвращать ссылку на архивированный ресурс. Пользователь всегда может восстановить удалённый ресурс, послав запрос POST к архиву.
Дизайн
Наш REST-сервис построен на ASP.NET Web API с использованием Entity Framework. Как я уже говорил, мягкое удаление я делаю для ресурса, который называется document.
Итак, сначала надо добавить столбцы в соответствующую таблицу. В качестве флага я использую временную метку, которая называется Deleted. Если значение не NULL, ресурс считается удалённым. Кроме того, полезно иметь информацию о том, кто удалил ресурс.
ALTER TABLE Documents ADD
Deleted datetime NULL,
DeletedBy int NULL
GO
Действие DELETE в контроллере теперь будет просто устанавливать значения этих полей вместо физического удаления записи. Кроме этого, DELETE будет возвращать тело со стандартной ссылкой на архивируемый документ:
{
"links": {
"archive": "documents/{id}/deleted"
}
}
На самом деле, это важный момент: ссылка помогает клиенту понять, что документ не удаляется, а перемещается.
Новый контроллер для архивированных документов должен обеспечивать следующие методы:
GET documents/deleted | Возвращает коллекцию всех удалённых документов |
GET documents/{id}/deleted | Возвращает удалённый документ |
POST documents/{id}/deleted | Восстанавливает удалённый документ; не требует тела; возвращает 201 Created |
DELETE documents/{id}/deleted | Физически удаляет документ |
Реализация
Вначале я планировал добавить два представления в свою базу:
CREATE VIEW DeletedDocuments
AS
SELECT *
FROM Documents
WHERE Deleted IS NOT NULL
GO
CREATE VIEW AvailableDocuments
AS
SELECT *
FROM Documents
WHERE Deleted IS NULL
GO
Мне показалось, что так будет меньше мороки: вместо того, чтобы расставлять условия в коде, я просто заведу два разных свойства DbSet в своём DB-контексте. Придётся, правда, иметь две одинаковые сущности в модели, но таково уж свойство POCO-объектов в контексте EF – каждой таблице соответствует ровно одна сущность.
Кстати говоря, представления в SQL могут быть полезными для Entity Framework и в других отношениях: с их помощью, например, можно сослаться на таблицы из другой базы, если вы не хотите заводить несколько DB-контекстов.
Однако, в моём случае номер с представлениями не прошёл. Во время авторизации нужно работать со всеми документами, ведь у пользователей действуют те же права на удалённые документы, что и на существующие.
Поэтому я решил иметь только один DbSet Documents в DbContext, а в коде каждый раз разбираться, что именно нужно в данный момент:
var availableDocuments = DbContext.Documents.Where(d => d.Deleted == null);
var deletedDocuments = DbContext.Documents.Where(d => d.Deleted != null);
var allDocuments = DbContext.Documents;
Связанные ресурсы
Документ – это ресурс, с которым связаны другие ресурсы. Например, у нас есть псевдоним документа. То есть получить документ можно не только по пути documents/{id}, но и по пути documents/{alias}, где alias – это уникальная строка.
После удаления документа все связанные с ним псевдонимы должны стать “невидимыми”: если раньше клиент получал список всех псевдонимов, используя GET documents/aliases, то после удаления документа его псевдонимы из списка пропадут.
Но в базе-то они остались! Мы ведь хотим предоставить возможность восстановления документа в том состоянии, в котором он был удалён. Это может вызвать недоумение у клиента: он пытается добавить новый псевдоним для другого документа, список, возвращаемый из GET documents/aliases, не содержит такой строки, а сервис тем не менее отказывает в добавлении.
Не думаю, что это серьёзная проблема. Тем не менее, если нужно её решать, то можно добавить эндпоинт GET documents/deleted/aliases. Тогда всё становится на свои места: сервис не может добавить псевдоним, поскольку такое значение уже используется удалённым документом.
Может возникнуть вопрос: а стоит ли выбрасывать псевдоним из списка, возвращаемого из documents/aliases? Пусть остаются! Не думаю, что такое решение будет правильным. Тогда, получается, список псевдонимов будет содержать битые ссылки, ведь сервис вернёт 404 Not Found, если клиент попытается получить удалённый документ по псевдониму. Если дело касается дочерних ресурсов, ассоциированных с документом, то поведение должно быть точно таким же, как если бы мы удаляли документ физически.
Очистка архива
Мягкое удаление, помимо возможности легко восстанавливать данные, обладает ещё несколькими преимуществами. Операция удаления в реляционных базах – это операция дорогостоящая. А если ещё удаление одной записи ведёт к каскадному удалению записей в других таблицах, то это чревато дедлоками. Поэтому мягкое удаление быстрее и надёжнее физического удаления.
Но есть один существенный недостаток. База начинает расти.
Поэтому на финальной стадии следует позаботиться об автоматической очистке архива. Можно, конечно, очищать базу и вручную, но лучше автоматизировать этот процесс. Если мы директивно установим срок годности удалённого объекта, скажем, в 30 дней, то клиент может отображать страницу архива, на которой будут выделены элементы, время жизни которых подходит к концу.
До этой задачи руки у меня пока не дошли. Мы планируем добавить в нашу таск-систему задачу, которая раз в сутки будет запускать простой SQL запрос, удаляющий всё протухшие объекты из архива. В качестве параметра задача должна принимать срок годности. Надо будет позаботиться о том, чтобы текущее значение этого параметра хранилось где-то в одном месте. Тогда можно будет реализовать в сервисе метод, возвращающий это значение клиенту.
Комментарии (7)
Dansoid
21.02.2019 22:36Извините, но это какой-то детский сад.
Все правильно на такую мелочь не надо генерировать вьюшку.
Тот же EF имеет Global Filters. С моей точки зрения полезность их сомнительна, но вам подойдет.
Также кто вам мешает понаписывать экстеншины или хелпер методы
public static IQueryable<Document> ValidDocuments(this DbContext ctx) => ctx.Documents.Where(d => d.Deleted != null);
Из минусов вижу что инклюдо-писание пострадает (Include/ThenInclude)
Если история очень важна, то можете хранить в таблице EffectiveStart, EffectiveEnd и считать что записи нет если EffectiveEnd установлена в дату меньше необходимой
public static IQueryable<Document> ValidDocuments(this DbContext ctx, DateTime onDate) => ctx.Documents.Where(d => d.EffectiveStart <= onDate && (d.EffectiveEnd == null || d.EffectiveEnd > onDate));
Но опять же страдает инклюдописание, что-то тут недодумали. Так бывает когда продукт пишут те кто его полноценно не использует (sarcasm)AndreyRodin Автор
21.02.2019 23:27public static IQueryable<Document> ValidDocuments(this DbContext ctx) => ctx.Documents.Where(d => d.Deleted != null);
Эмм… Так я же так и делаю! Вьюшка или глобальный фильтр мне не подошли.
Но насчёт детского сада вы, может быть, и правы.
Разумеется, ничего принципиально нового в этой статье нет. Просто я считаю, что моя цель достигнута, если хотя бы паре человек это пригодится.
Главная идея здесь — интерпретация метода DELETE как перемещения ресурса в архив. В начале работы это было совсем неочевидно.
mflash123
21.02.2019 22:47Мне кажется, что есть тонкая нить связи между трендом прошлого года-криптой. И похоже, трендом этого года-восстановлением данных=)
trawl
Но ведь метод
PUT
должен принимать весь объект целиком? Для частичного изменения предусмотрен методPATCH
, или я ошибаюсь?GHostly_FOX
Все правильно
PATCH — частичное обновление записи
PUT — полное обновление объекта
AndreyRodin Автор
Да, вы правы.
Правда, в контексте статьи неважно о каком методе шла речь.
На всякий случай, переправил PUT на PATCH, чтобы это не вызывало у людей недоумения.
AndreyRodin Автор
И кстати: речь шла о переводе всего объекта в новое состояние, так что метод PUT вполне мог рассматриваться в качестве альтернативы DELETE.
Хотя, стандарт определяет, что сохранять надо именно под URL запроса:
Так что ещё раз: вы правы.