Движения остатка и транзакция
Редактирование карточки уже защищено проверкой версии. Теперь добавим движение остатка: приход или расход выражается целой дельтой, а рядом сохраняется запись журнала. Для клавиатуры приход пяти единиц означает увеличение количества на пять; расход трёх означает отрицательную дельту. Главное требование — количество и журнал изменяются совместно. Если записать их двумя независимыми командами, база может показать состояние, которое невозможно объяснить историей.
Используем новую таблицу movements с ID, ссылкой на карточку, дельтой, идентификатором операции и временем создания. Это журнал учебной операции, а не полноценная бухгалтерия: он пока не хранит документ поставки, ответственное лицо или валютную стоимость. Также прежняя форма корректировки количества остаётся доступной и не создаёт движение. Значит, журнал нельзя объявлять исчерпывающим аудитом всех изменений остатка; граница модели описана явно.
Дельта вместо нового итога
Форма движения размещена на странице редактирования. Она отправляет delta, текущую version и случайное operation. Сервер требует ненулевую дельту в заданном диапазоне и проверяет положительные ID и версию. Пользователь предлагает изменение относительно состояния, которое видел. Простая отправка нового итога скрыла бы смысл операции: число 17 само не показывает, было ли добавлено пять или исправлена ошибка предыдущего ввода.
Рассмотрим расход 20 клавиатур при остатке 12. SQL должен отказать, потому что новое количество оказалось бы отрицательным. Условие включено в WHERE вместе с версией и ID. Даже если браузер получил остаток 12, за время заполнения другая операция могла изменить его. Поэтому отдельная предварительная проверка в Go не обеспечивает предметной границы. Само изменение должно учитывать допустимый результат в момент выполнения.
func (a *App) move(w http.ResponseWriter,r *http.Request){
r.Body=http.MaxBytesReader(w,r.Body,8192)
if err:=r.ParseForm();err!=nil{http.Error(w,"Неверная форма",400);return}
id,e1:=strconv.ParseInt(r.PathValue("id"),10,64);delta,e2:=strconv.Atoi(r.PostForm.Get("delta"));version,e3:=strconv.Atoi(r.PostForm.Get("version"));operation:=r.PostForm.Get("operation")
if e1!=nil||e2!=nil||e3!=nil||id<1||version<1||delta==0||delta>1000000||delta< -1000000||len(operation)!=64{http.Error(w,"Неверное движение",400);return}
tx,err:=a.db.BeginTx(r.Context(),nil);if err!=nil{http.Error(w,"Транзакция недоступна",503);return};defer tx.Rollback()
result,err:=tx.ExecContext(r.Context(),"UPDATE items SET quantity=quantity+?,version=version+1 WHERE id=? AND version=? AND quantity+? BETWEEN 0 AND 1000000",delta,id,version,delta)
if err!=nil{http.Error(w,"Движение недоступно",500);return}
count,err:=result.RowsAffected();if err!=nil{http.Error(w,"Неизвестный результат",500);return};if count!=1{http.Error(w,"Конфликт версии или недостаточный остаток",409);return}
if _,err:=tx.ExecContext(r.Context(),"INSERT INTO movements(item_id,delta,operation) VALUES(?,?,?)",id,delta,operation);err!=nil{http.Error(w,"Движение не записано: возможно, операция уже применена",409);return}
if err:=tx.Commit();err!=nil{http.Error(w,"Подтверждение движения недоступно",500);return}
if r.Header.Get("HX-Request")=="true" {r.URL.RawQuery="";a.list(w,r);return};http.Redirect(w,r,"/",http.StatusSeeOther)
}
Листинг относится к move в состоянии lesson-11. В полном файле дополнительно выполняются разбор формы, проверка количества изменённых строк, INSERT журнала и Commit. Все обращения после BeginTx идут через tx, а не через a.db. Если часть команд случайно выполнить через общий пул, они окажутся вне выбранной транзакции или могут ждать соединения, которое уже удерживается её объектом. В нашем пуле это особенно заметно.
Общая граница подтверждения
BeginTx создаёт транзакцию с контекстом входящего запроса. Сразу планируется Rollback через defer. При любой ранней ошибке эта ветка пытается отменить незавершённые изменения. После успешного Commit дополнительный отложенный Rollback не возвращает старое состояние: транзакция уже завершена. Такой шаблон облегчает чтение нескольких ветвей отказа, потому что не приходится вручную повторять отмену после каждого нового условия.
Сначала UPDATE увеличивает количество и версию. Затем INSERT записывает движение с той же карточкой и дельтой. Если журнал не удалось сохранить, ответ не должен сообщать успешный приход. Незавершённая транзакция отменяет и изменение количества. Если подтверждение Commit не удалось, сервер также не показывает обычный успешный список. Обработка неопределённого результата требует внимательности: отказ подтверждения нельзя автоматически считать доказательством, что никакой записи не было.
Внешний ключ item_id и включённый foreign_keys связывают движение с карточкой. Само объявление ссылки в схеме недостаточно, если режим её проверки выключен для соединения. Поэтому параметр подключения задан заранее. В нашем приложении карточки не удаляются, но связь всё равно формулирует смысл журнала и защищает прямой путь записи. Будущая операция удаления потребовала бы отдельного решения о сохранении исторических объектов.
Повторная отправка
operation создаётся из криптографически случайных байтов и имеет уникальность в журнале. Если одно и то же предложение отправлено повторно, второй INSERT не должен создать ещё одно движение с тем же идентификатором. Транзакция тогда отменяет своё изменение количества. Это предотвращает двойное применение, но текущий обработчик возвращает конфликт, а не восстанавливает прежний успешный ответ. Значит, здесь есть защита повторной записи, но нет полного договора повторяемого успешного результата.
Для полноценной идемпотентности нужно хранить параметры операции и её итог, сравнивать повторное тело с первоначальным и возвращать согласованный сохранённый результат. Если один идентификатор пришёл с другой дельтой, такой запрос должен стать ошибкой, а не новым действием. В учебной версии уникальная запись является нижней границей; мы не выдаём её за законченную систему повторных попыток при сетевой неопределённости.
Версия даёт дополнительную защиту повторной формы. После первого движения карточка получает новый счётчик, поэтому повтор с прежней версией часто откажет уже на UPDATE. Это не делает operation лишним: пользователь может повторить идентификатор с другим свежим основанием, а будущая версия API может иначе разрешать движение. Разные ограничения охраняют разные свойства. Версия проверяет состояние карточки, идентификатор — уникальность конкретного намерения записи.
Ожидаемый результат движения
Для прихода пяти клавиатур версия 1 должна стать версией 2, количество 12 — количеством 17, а журнал получить строку с дельтой 5. Для расхода 20 от исходных 12 ожидается 409 без новой строки и без изменения остатка. При устаревшей версии также не должно появиться движение. Эти сценарии позволяют предметно прочитать код, однако при подготовке урока программа, SQL и проверки не запускались.
Форма движения использует обычный POST, поэтому текстовый конфликт не вставляется htmx в main. Это намеренный текущий договор: интерактивные HTML-формы карточки и текстовый отказ операции имеют разные представления. Если нужна встроенная форма движения с сохранённым вводом, обработчик должен подготовить соответствующий HTML и согласовать его с событием замены. Одного добавления hx-post недостаточно, чтобы сделать все отказные ветви удобными.
SQLite подходит выбранному небольшому локальному процессу, но журнал и транзакция сами не определяют масштабирование между несколькими серверами. Понадобятся стратегия постоянного хранения, блокировки, резервные копии и единые правила повторных попыток. На следующем шаге добавим локальную демонстрационную сессию, чтобы отделить предложенные поля формы от информации о текущем субъекте. После этого станет понятнее, где сервер принимает решение о правах на движение.
Время журнала создаётся базой и относится к сохранённой записи. Оно не доказывает фактическое время физической выдачи оборудования: сотрудник мог провести документ позже. Если нужно учитывать обе даты, следует хранить их отдельными полями с разными смыслами, а не заменять серверное время непроверенной строкой формы.
Исходники урока
Оглавление серии · Скачать исходники. Полное самостоятельное состояние находится в lesson-11. Учебные приложения, зависимости, база и проверки при подготовке не запускались; описанный результат ожидается по исходникам. Материалы пока находятся в локальной редакционной версии.
Первичные источники: документация 1.