handbookНаписать

patterns/behavioral/memento/practice/README.md · практика · 4 языка · 35 тестов

база: Снимок

Практика: черновики анкеты (Form drafts) ​

Длинная заявка сохраняется сама: снимки по таймеру с лимитом, именованные точки для отката и черновик, который переживает перезагрузку страницы и узнаёт чужой формат.

Базовый пример: Снимок. Там история хранит снимки без счёта, снимает их по команде и живёт только в памяти. Здесь появляются вопросы из жизни: как часто снимать, сколько хранить, какие снимки выбрасывать нельзя и что делать со снимком, который пролежал в браузере неделю и был записан прошлой версией приложения.

Задача ​

Заявка на кредит — шесть экранов полей: паспорт, адрес, работа, доходы. Пользователь заполняет её полчаса, отвлекается, закрывает вкладку. Первая версия сохраняла всю форму в localStorage на каждое нажатие клавиши: интерфейс подтормаживал, а кнопка «Вернуться к сохранённому» откатывала к последнему символу, то есть никуда. Потом поля формы переименовали, и у части пользователей старый черновик молча восстанавливался в пустую форму.

Нужно: автосохранение не чаще раза в 30 секунд, несколько последних автосохранений, явные точки «Сохранить черновик» с именем, откат к такой точке и черновик-строка, которая честно отказывается восстанавливаться, если записана в другом формате.

Что урезано ​

  • Форма — словарь «поле -> строка», без экранов, валидации и вложенных групп.
  • localStorage — строка в тесте: Draft кодируется в JSON и декодируется обратно, куда положить строку, решает вызывающий код.
  • Таймер автосохранения не запускается сам: autosave() вызывают по событию ввода, а частоту ограничивает история по часам.
  • Миграции старых форматов нет: черновик другой версии отклоняется, а не переводится.

Решение ​

Роли те же, что в базовом примере. ApplicationForm — создатель, Draft — снимок, DraftHistory — опекун. Новое в каждой роли:

  • снимок умеет превратиться в строку и обратно, и строка несёт номер формата v. Упаковку знает сам снимок, поэтому опекун и хранилище по-прежнему не видят полей формы;
  • опекун держит два вида снимков: автосохранения в ограниченной очереди и именованные точки, которые очередь не вытесняет;
  • время опекун берёт из часов, переданных в конструктор: в бою системные, в тестах подделка.

Структура ​

creates, reads

keeps

ApplicationForm

-fields: map

+fill(field, value)

+value(field) : string

+save(label) : Draft

+restore(draft)

Draft

+label: string

-fields: map

+encode() : string

+decode(stored) : Draft

DraftHistory

-autosaves: Draft[]

-checkpoints: map

+latest: Draft

+autosave() : bool

+checkpoint(name)

+rollbackTo(name)

+autosaves() : string[]

«interface»

Clock

+now()

ApplicationForm — та же форма создателя, что и холст в базовом примере: save() копирует поля в снимок, restore() копирует их обратно. Ни одного геттера «отдай все поля» у формы нет: история и хранилище работают с черновиком целиком.

Черновик в строке ​

Draft — единственный, кто знает, как он выглядит в JSON: {"v":2,"label":"auto-1","fields":{...}}. decode() проверяет три вещи по порядку: строка вообще JSON, версия формата совпадает с текущей, есть подпись и поля. Каждая ошибка — DraftFormatError с текстом, по которому видно, что сломано: «broken draft» или «draft format v1 is not supported, expected v2».

Версия формата — главное, что отличает снимок в хранилище от снимка в памяти. Снимок в памяти живёт столько же, сколько код, который его создал. Снимок в браузере переживает выкладку новой версии, и без номера формата приложение не отличит старый черновик от испорченного. Отказ — честное поведение по умолчанию; миграция v1 -> v2 добавляется рядом с проверкой, когда она нужна.

Часы вместо sleep ​

«Не чаще раза в 30 секунд» — правило про время, и проверяется оно управлением временем, а не ожиданием. Clock передаётся в конструктор истории. Тест ставит часы, вызывает autosave(), сдвигает часы на 29 секунд — отказ, ещё на одну — сохранение. Граница точная: прошло ровно 30 секунд — уже можно.

Как это собирается ​

Форму, историю и часы собирает страница анкеты при загрузке: в приложении часы системные, хранилище - localStorage браузера. Первым делом страница пробует поднять черновик из хранилища. В примере там лежит строка от прошлого релиза с форматом v1: Draft::decode бросает DraftFormatError, и страница начинает с пустой формы вместо того, чтобы упасть или подставить чужие поля.

Дальше таймер зовёт autosave, но второй вызов через 10 секунд пропускается: интервал 30. Перед шагом оплаты страница ставит точку before-payment, и кнопка "Назад" откатывает форму к ней, так что номер карты исчезает. Последний снимок уходит строкой в хранилище, а после перезагрузки новая форма поднимает его обратно: имя на месте, карты нет.

Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.

Реализации ​

DraftHistory решает три вещи. autosave() пропускает вызов, если с прошлого автосохранения прошло меньше интервала, иначе снимает черновик auto-N и дописывает его в очередь, выбрасывая самые старые сверх лимита. checkpoint(name) снимает черновик с именем без всяких ограничений, а повторное имя перезаписывает точку. rollbackTo(name) возвращает форму к точке или падает, если такой нет. latest — самый свежий черновик любого вида, его и кладут в localStorage.

ЯзыкОсобенность
PHPЧасы — интерфейс Clock с секундами; Draft — readonly с private полями, форма читает их через Closure::bind; очередь — array_slice по лимиту
GoЧасы — func() time.Time, в тест идёт метод-значение clock.Now; JSON через encoding/json и отдельную структуру wireDraft с тегами, сам Draft остаётся с закрытыми полями
PythonЧасы — Callable[[], float] с time.monotonic; очередь — deque(maxlen=...) выбрасывает старые сама; поля снимка — MappingProxyType, только для чтения
TypeScriptЧасы — () => number с Date.now; поля # и читатель модуля, как в базовом примере; JSON.parse возвращает any, поэтому каждое поле проверяется явно

Запуск: make test T=patterns/behavioral/memento/practice

Что проверяют тесты ​

Тесты одинаковы во всех языках: интервал 30 секунд, лимит 3. Автосохранение в момент 0 проходит, через 29 секунд пропускается, ещё через одну — проходит: в очереди auto-1 и auto-2. Четыре автосохранения с шагом 30 секунд оставляют auto-2, auto-3, auto-4. Главный тест — откат: точка personal с именем Ann, потом имя меняется на Bob, появляется доход, проходят четыре автосохранения и вытесняют друг друга, а откат к personal всё равно возвращает Ann и пустой доход. Неизвестная точка — ошибка с её именем. Черновик, закодированный в строку, восстанавливает новую форму целиком. Таблица чужих строк: формат v1, обрезанный JSON и черновик без полей отклоняются с понятным текстом.

Где граница ​

  • Команда вместо снимка. Снимок хранит состояние целиком, и это дёшево, пока форма маленькая. В текстовом редакторе или на холсте с тысячами объектов полная копия на каждый шаг слишком дорога; тогда историю строят на Команде: каждая операция знает, как отменить себя обратным действием, а хранятся только операции. Снимок при этом остаётся для редких точек («сохранить версию»), а шаги между ними — командами. Команда хрупче: ошибка в одном обратном действии портит всё, что после неё, тогда как снимок восстанавливает состояние ровно.
  • Хранилище. localStorage ограничен несколькими мегабайтами и синхронный. Большие черновики и файлы уходят в IndexedDB или на сервер, и тогда к формату добавляются идентификатор пользователя и время записи.
  • Несколько вкладок. Две вкладки с одной заявкой перезаписывают черновик друг друга. Нужна метка версии черновика и правило «кто новее», это уже синхронизация, а не снимок.
  • Избыточность. Короткая форма на один экран не требует ни истории, ни точек: хватит одного черновика в хранилище без лимита и таймера.

Отличия между языками ​

  • PHP — сериализацию делает сам Draft, он читает свои поля, поэтому PHPStan доволен; закрытость для формы по-прежнему обходится Closure::bind. json_decode с JSON_THROW_ON_ERROR превращает битый JSON в исключение, которое оборачивается в DraftFormatError.
  • Go — граница пакета даёт и закрытость, и «дружбу» бесплатно. encoding/json не видит неэкспортируемые поля, поэтому формат описан отдельной структурой wireDraft; это заодно отделяет формат хранения от внутреннего устройства. Карта полей клонируется maps.Clone в обе стороны.
  • Python — deque(maxlen=limit) делает лимит одной строкой. MappingProxyType — представление словаря только для чтения: снимок нельзя поменять даже случайно, хотя прочитать поле с подчёркиванием по-прежнему можно.
  • TypeScript — тот же приём с static-блоком, что в базовом примере. Тип any из JSON.parse — главная дыра: без явной проверки v, label и fields черновик чужого формата прошёл бы компиляцию и упал бы где-то в форме.

Связанные темы ​

© 2014-2026 Тимур ТурдыевСайт и справочник работают на VPS от AdminVPS