Перейти к содержанию

Go и htmx: создание карточки через форму

Постоянное хранилище уже отдаёт список оборудования. Теперь добавим действие, ради которого учёт обычно и нужен: создание карточки из браузера. Рассмотрим обычную HTML-форму без htmx. Сначала разберём полный цикл запроса, потому что последующая частичная выдача не меняет правила записи. Если интерфейс выглядит современно, но повторная перезагрузка создаёт дубликат, проблема находится в договоре операции, а не в оформлении кнопки.

Новая карточка содержит артикул, название, место и исходный остаток. Внутренний ID выдаёт база. Человек не обязан знать, какой номер свободен, и не должен выбирать его вручную. Версия при создании начинается с единицы. Эта граница позволяет серверу контролировать технические поля и принимать от формы только предметные значения, которые действительно должен задавать пользователь.

Просмотр формы и отправка данных

Маршрут GET /items/new показывает форму, а POST /items принимает её содержимое. Разделение нужно не только для аккуратной организации файла. GET считается операцией чтения: открытие ссылки, восстановление вкладки или обход адреса не должно записывать оборудование. Форма использует обычный метод POST, поэтому браузер может выполнить её даже без JavaScript. В архиве lesson-05 весь сервер и шаблон доступны как полное состояние.

Поля name в HTML определяют ключи отправляемых значений. Подпись «Остаток» не является таким ключом, а идентификатор DOM вообще может не участвовать в отправке. Если назвать поле count, но на сервере читать quantity, строка окажется пустой. Это ошибка договора между представлением и обработчиком. Мы сохраняем одни имена по всему проекту, чтобы последующее добавление валидации не требовало угадывать, откуда пришли значения.

В обработчике сначала ограничиваем размер тела через http.MaxBytesReader. Для небольшой карточки достаточно 8192 байт. Ограничение устанавливается до разбора формы: если читать тело полностью, а затем проверять его длину, ресурсы уже потрачены. Это не проверка максимальной длины названия, а общий предел входящего обращения. В следующем уроке добавим более содержательные ограничения отдельных полей и объясним человеку причину отказа.

func (a *App) create(w http.ResponseWriter,r *http.Request){
 r.Body=http.MaxBytesReader(w,r.Body,8192)
 
 p,msg:=parseItem(r)
 if msg!="" {a.render(w,r,Page{Form:p,Error:msg},422);return}
 var existing int
 if err:=a.db.QueryRowContext(r.Context(),"SELECT count(*) FROM items WHERE sku=?",p.SKU).Scan(&existing);err!=nil{http.Error(w,"Чтение недоступно",500);return}
 if existing>0{a.render(w,r,Page{Form:p,Error:"Артикул уже существует"},422);return}
 if _,err:=a.db.ExecContext(r.Context(),"INSERT INTO items(sku,name,location,quantity) VALUES(?,?,?,?)",p.SKU,p.Name,p.Location,p.Quantity);err!=nil{log.Print(err);http.Error(w,"Запись недоступна",500);return}
 http.Redirect(w,r,"/",http.StatusSeeOther)
}

ParseForm разбирает вход в структуру запроса, после чего читаем PostForm. В отличие от объединённого набора Form, он подчёркивает, что значения записи пришли из тела POST, а не случайно из параметров адреса. Если одно поле можно передать несколькими способами, нужно явно определить, какой источник имеет приоритет. В нашем учебном обработчике договор проще: параметры формы находятся в теле, а путь задаёт операцию.

Строка превращается в количество

Браузер передаёт значение поля number строкой. Сервер не получает автоматически целочисленный Go-объект только потому, что интерфейс показал стрелки увеличения числа. strconv.Atoi выполняет преобразование, а код отдельно проверяет допустимый диапазон. Любой клиент может отправить другое тело, поэтому ограничения HTML помогают человеку, но не становятся гарантией для хранилища. Количество от нуля до миллиона — выбранная граница учебного проекта, а не предел SQLite.

Строковые поля очищаются от внешних пробелов. Мы не удаляем все пробелы из названия, потому что «Механическая клавиатура» состоит из двух слов. Нормализация должна быть конкретной. Необдуманная попытка «очистить всё» часто превращает нормальные данные в другое значение. Для SKU будущая проверка выберет отдельный алфавит, а для названия останется возможность русских слов и обычной пунктуации.

Перед INSERT проверяем, есть ли уже такой артикул. Это даёт понятный ответ для обычного сценария. Однако предварительный SELECT не является защитой от одновременных запросов: между чтением и записью другая операция может создать такой же SKU. Настоящая уникальность обеспечивается ограничением таблицы. На данном шаге неожиданный конфликт INSERT получает общий ответ об ошибке записи; превращение конкретного кода базы в предметную ошибку можно добавить отдельно, сохранив серверный договор.

Параметры SQL передаются после текста запроса. Название оборудования не соединяется со строкой SQL через форматирование. Поэтому апостроф в названии должен быть обычной частью данных, а не концом SQL-литерала. Этот принцип останется тем же для поиска, редактирования и движений. Обратите внимание: шаблон защищает HTML-вывод, а параметры защищают SQL-интерпретацию. Одна мера не заменяет другую, поскольку они действуют в разных языковых контекстах.

Почему после записи нужен другой запрос

После успешного INSERT сервер возвращает перенаправление со статусом 303 на корневой список. Браузер выполняет новый GET и показывает состояние после записи. Этот приём называют Post/Redirect/Get. Пользователь видит таблицу, а обычная перезагрузка повторяет чтение. Если сразу вернуть HTML из POST, браузер может предложить повторную отправку формы, и человек легко создаст ещё одну операцию в других вариантах предметной модели.

Перенаправление не является механизмом идемпотентности. Повторная отправка самого POST до получения ответа всё ещё возможна. У создания карточки уникальный SKU ограничивает дубликаты предметно, но не позволяет считать каждую повторную попытку доказанным успехом. Для движения остатка позже введём отдельный идентификатор операции. Здесь полезно сохранить различие: управление навигацией после ответа и защита повторного изменения решают связанные, однако разные задачи.

Построим ожидаемый сценарий на новой позиции: MOUSE-001, «Мышь», «офис», количество 8. После самостоятельной отправки должно появиться новое оборудование с выданным ID. Исходные клавиатура и монитор не меняются. Перезапуск сервера должен прочитать новую строку из той же базы. В статье это ожидаемое поведение по исходникам; учебный процесс, SQL и формы при подготовке не выполнялись.

Если артикул уже существует или поле количества не удалось преобразовать, код не должен выполнять INSERT. Для ошибочного ввода возвращаем форму с сообщением. Этот переход важен для следующего шага: отказ операции не требует уничтожать введённые строки. Человеку нужно исправить конкретную причину, а не заново заполнить всё. Валидация будет развиваться на том же маршруте, поэтому успешный сценарий и перенаправление останутся знакомыми.

Запись пока доступна в локальном стенде без входа. Это сознательная учебная граница: приложение слушает только loopback, а настоящая идентификация ещё не реализована. Не переносите такой обработчик на открытый адрес как готовую корпоративную систему. Позже добавим демонстрационную сессию и права, но даже она будет учить границе решения на сервере, а не заменять полноценный вход сотрудника.

Исходники урока

Оглавление серии · Скачать исходники. Полное самостоятельное состояние находится в lesson-05. Учебные приложения, зависимости, база и проверки при подготовке не запускались; описанный результат ожидается по исходникам. Материалы пока находятся в локальной редакционной версии.

Первичные источники: документация 1.