Практика: черновики анкеты (Form drafts)
Длинная заявка сохраняется сама: снимки по таймеру с лимитом, именованные точки для отката и черновик, который переживает перезагрузку страницы и узнаёт чужой формат.
Базовый пример: Снимок. Там история хранит снимки без счёта, снимает их по команде и живёт только в памяти. Здесь появляются вопросы из жизни: как часто снимать, сколько хранить, какие снимки выбрасывать нельзя и что делать со снимком, который пролежал в браузере неделю и был записан прошлой версией приложения.
Задача
Заявка на кредит — шесть экранов полей: паспорт, адрес, работа, доходы. Пользователь заполняет её полчаса, отвлекается, закрывает вкладку. Первая версия сохраняла всю форму в localStorage на каждое нажатие клавиши: интерфейс подтормаживал, а кнопка «Вернуться к сохранённому» откатывала к последнему символу, то есть никуда. Потом поля формы переименовали, и у части пользователей старый черновик молча восстанавливался в пустую форму.
Нужно: автосохранение не чаще раза в 30 секунд, несколько последних автосохранений, явные точки «Сохранить черновик» с именем, откат к такой точке и черновик-строка, которая честно отказывается восстанавливаться, если записана в другом формате.
Что урезано
- Форма — словарь «поле -> строка», без экранов, валидации и вложенных групп.
localStorage— строка в тесте:Draftкодируется в JSON и декодируется обратно, куда положить строку, решает вызывающий код.- Таймер автосохранения не запускается сам:
autosave()вызывают по событию ввода, а частоту ограничивает история по часам. - Миграции старых форматов нет: черновик другой версии отклоняется, а не переводится.
Решение
Роли те же, что в базовом примере. ApplicationForm — создатель, Draft — снимок, DraftHistory — опекун. Новое в каждой роли:
- снимок умеет превратиться в строку и обратно, и строка несёт номер формата
v. Упаковку знает сам снимок, поэтому опекун и хранилище по-прежнему не видят полей формы; - опекун держит два вида снимков: автосохранения в ограниченной очереди и именованные точки, которые очередь не вытесняет;
- время опекун берёт из часов, переданных в конструктор: в бою системные, в тестах подделка.
Структура
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черновик чужого формата прошёл бы компиляцию и упал бы где-то в форме.
Связанные темы
- Снимок (базовый пример)
- Практика Заместителя: кэш репозитория товаров — те же внедряемые часы вместо
sleepдля правил про время