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

Комментарии (7)


  1. trawl
    21.02.2019 06:31

    лучше воспользоваться вместо этого методом PUT с телом, включающим в себя что-то вроде {status: 'deleted'}.

    Но ведь метод PUT должен принимать весь объект целиком? Для частичного изменения предусмотрен метод PATCH, или я ошибаюсь?


    1. GHostly_FOX
      21.02.2019 10:08

      Все правильно
      PATCH — частичное обновление записи
      PUT — полное обновление объекта


    1. AndreyRodin Автор
      21.02.2019 10:55

      Да, вы правы.
      Правда, в контексте статьи неважно о каком методе шла речь.
      На всякий случай, переправил PUT на PATCH, чтобы это не вызывало у людей недоумения.


    1. AndreyRodin Автор
      21.02.2019 11:44

      И кстати: речь шла о переводе всего объекта в новое состояние, так что метод PUT вполне мог рассматриваться в качестве альтернативы DELETE.

      Хотя, стандарт определяет, что сохранять надо именно под URL запроса:

      The PUT method requests that the enclosed entity be stored under the supplied Request-URI. If the Request-URI refers to an already existing resource, the enclosed entity SHOULD be considered as a modified version of the one residing on the origin server.

      Так что ещё раз: вы правы.


  1. 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)


    1. AndreyRodin Автор
      21.02.2019 23:27

      public static IQueryable<Document> ValidDocuments(this DbContext ctx)
             => ctx.Documents.Where(d => d.Deleted != null);

      Эмм… Так я же так и делаю! Вьюшка или глобальный фильтр мне не подошли.

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

      Главная идея здесь — интерпретация метода DELETE как перемещения ресурса в архив. В начале работы это было совсем неочевидно.


  1. mflash123
    21.02.2019 22:47

    Мне кажется, что есть тонкая нить связи между трендом прошлого года-криптой. И похоже, трендом этого года-восстановлением данных=)