Pull to refresh

Мягкое удаление в REST API

Reading time5 min
Views12K
image

Чтобы пользователь не чувствовал боли от безвозвратно утерянных данных, стоит задуматься о мягком удалении. При мягком удалении запись не удаляется из базы физически, а лишь помечается как удалённая. Это позволяет легко восстановить данные путём сброса флага.

Недавно я реализовал мягкое удаление в одном из наших 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 запрос, удаляющий всё протухшие объекты из архива. В качестве параметра задача должна принимать срок годности. Надо будет позаботиться о том, чтобы текущее значение этого параметра хранилось где-то в одном месте. Тогда можно будет реализовать в сервисе метод, возвращающий это значение клиенту.
Tags:
Hubs:
Total votes 9: ↑0 and ↓9-9
Comments9

Articles