Линзы http4k и изменение данных #
Васильев Андрей Михайлович, 2023
Версии презентации
Архитектура приложения #
- Данные системы хранятся в постоянных хранилищах:
- Файловая система
- Внутри БД под управлением СУБД
- Ключевые задачи приложения
- Обеспечение корректной записи данных в хранилище
- Быстрое и точное извлечение данных из хранилища
Обработка запроса от пользователя #
Обработку запроса от пользователя можно разделить на три логических этапа, следующих один за другим:
- Техническая обработка данных, пришедших от пользователя. Данные приходят в формате строк, необходимо их преобразовать к типам слоя предметной области
- Выполнение обработки извлечённых данных в слое предметной области: поиск значений, добавление значений, изменение значений и т.д.
- Формирование ответа пользователю в формате, который он ожидает
В рамках обработки HTTP-запроса может быть выполнено несколько обращений к каждому из слоя извлечения данных и слоя предметной области
Рекомендуется каждый этап оформлять внутри отдельного компонента, а задачей HTTP-обработчика становится их логическое объединение для достижения задачи
Работа со сложными структурами данных #
При работе со сложными вложенными структурами приходится решать вопрос по получению и изменению свойств вложенных элементов
Рассмотрим следующие структуры данных
data class Class(val id: Int, val name: String, val teacher: String)
data class Course(val id: Int, val name: String, val clasess: List<Class>)
data class Speciality(val id: Int, val courses: List<Course>)
val speciality: Speciality = ...
Модификация структур данных #
Для получения информации по предмету необходимо найти курс в списке, найти предмет, обратиться к полям класса для получения:
val class = speciality.courses[1].classes[5]
Для редактирования потребуется выполнить несколько копирований:
val newClass = speciality.courses[1].classes[5]
.copy(name = "Безопасность жизнедеятельности")
val newClasses = speciality.courses[1].classes.toMutableList()
.apply { set(5, newClass) }
val newCourse = speciality.courses[1].copy(classes = newClasses)
val newCourses = speciality.courses.toMutableList()
.apply { set(1, newCourse) }
val newSpeciality = speciality.copy(courses = newCourses)
Можно создать новую версию неизменяемых данных с нужным для нас состоянием
Сложные структуры сложны #
Сильная вложенность структур данных несёт следующие проблемы:
- Надо корректно указать путь к данным несколько раз
- Необходимо написать много кода
- Возможны технические ошибки
- Код, которому нужен доступ только к конечным данным, начинает зависеть от всех промежуточных объектов, что усложняет изменение структур данных в приложении
Решение с помощью функций #
Мы можем ввести функции, позволяющие решить данные задачи:
fun getName(speciality: Speciality, courseId: Int, classId: Int): String
fun setName(name: String, speciality: Speciality,
courseId: Int, classId: Int): Speciality
Код будет зависеть только от класса Speciality и одной из указанных функций
Механизм линз #
Линза — специальный объект, который позволяет осуществлять операции чтения и записи для конкретного вложенного свойства
class ClassNameLens(
private val courseId: Int,
private val classId: Int,
) {
fun getName(speciality: Speciality): String { ... }
fun setName(name: String, speciality: Speciality): Speciality { ... }
}
val speciality: Speciality = ...
val classNameLens = ClassNameLens(1, 5)
classNameLens.getName(speciality)
val newSpeciality = classNameLens.setName("Веб-разработка", speciality)
- Код зависит только от общего хранилища данных и линзы
- Линзы предоставляют простой и понятный интерфейс
- Линзы объединяют функции по чтению и записи
- Линзы могут выполнять преобразование данных при чтении или записи
Пример линзы http4k #
Предположим, что мы создали линзу для получения строковой переменной из пути:
val parameterLens = Path.nonEmptyString().of("parameter")
Для получения параметра необходимо применить лизу к хранилищу данных, к запросу:
val parameter: String = parameterLens(request)
Потенциально для установки значения можно было бы написать:
val newRequest = parameterLens("newData", request)
Запись данных может быть полезна в рамках модульных тестов
Шаблон проектирования строитель #
Для создания некоторых объектов пользователю необходимо указать множество аргументов конструктора, причём каждый из них тоже может быть сложным
Чтобы упросить создание конкретного компонента в строитель выделяют:
- логику по сбору всех аргументов для создания объекта
- метод для создания объекта целевого класса
При использовании строителя создание объекта выглядит следующим образом:
- Создать объект-строитель
- Вызвать несколько методов, формирующих набор данных для конструктора класса
- Вызвать метод по созданию целевого объекта
Создание линзы #
Для создания линзы необходимо указать следующие параметры:
- Объект, для которого формируется линза
- Настройка линзы, обычно состоит из определения типа данных для преобразования
- Терминатор (если есть обязательные или необязательные параметры)
Терминатор также создаёт объект линзы из соответствующего объекта-строителя
Классы-линзы описаны в пакете org.http4k.lens, строители описываются с помощью классов-спецификаций (наследников интерфейса LensSpec), сами линзы с помощью классов (наследников интерфейса Lens)
Поддерживается два типа линз:
- Однонаправленные линзы, считывающие и преобразующие данные из источника
- Двунаправленные линзы, считывающие и записывающие данные в целевой объект
Для стандартных классов http4k предлагаются двунаправленные линзы
Линзы http4k #
Объект | Начальный тип данных | Применимо для объектов | Количество | Необходимый |
---|---|---|---|---|
Параметры запроса | String | Request | Один или много | Обязательный или необязательный |
Заголовок | String | Request / Response | Один или много | Обязательный или необязательный |
Переменная пути | String | Request | Один | Обязательный |
Поле формы | String | WebForm | Один или много | Обязательный или необязательный |
Тело | String | Request / Response | Один | Обязательный |
Указание объекта #
Настройка преобразования типа данных #
Данный этап можно пропустить, если необходима строка, однако лучше всегда явно указывать тип для преобразования
http4k предлагает большой набор функций расширения, с помощью которых можно настраивать преобразование данных. Данные приходят от пользователя в строковом формате и их далее необходимо преобразовать в корректный внутренний формат
Рассмотрим список преобразований типов для объекта-запроса Query
enum()
— преобразование в перечислениеdateTime()
— преобразование переданных данных в тип LocalDateint()
— преобразование в целочисленный вариантnonEmptyString()
— проверка данных на пустотуuuid()
— преобразование в тип данных UUID
Линзы могут быть преобразованы в любой формат с помощью метода map()
Указание необходимости параметра #
Уровни необходимости параметра описываются в функциях-терминаторах в интерфейсах LensSpec и BiDiLensSpec
- defaulted() — нужно указать название элемента, значение по умолчанию и описание
- optional() — параметр является необязательным, необходимо указать название
- required() — параметр является обязательным, необходимо указать название
Ключевое отличие — поведение при отсутствии целевого значения
- defaulted() возвращает значение по умолчанию
- optional() возвращает null-тип, но выбрасывает исключение, если параметра нет
- required() выбрасывает исключение LensFailure
Терминатор создаёт на основе спецификации (строителя, LensSpec) нужную линзу
Линзы для параметров запроса #
Рассмотрим пример линзы, которая извлекает целое число из параметров запроса
val queryDataLens = Query.int().defaulted("data", 15)
- Целью выступает класс
Query
- Далее указываем преобразование параметров из строки в целое число, метод
int()
- Затем указываем, что значением по умолчанию является число
15
При наличии данных будет выполнено их преобразование и получение
val request = Request(GET, Uri.of("http://example.ru"))
val requestWithQuery = request.query("data", "42")
val data = queryDataLens(requestWithQuery) // 42
При отсутствии данных линза вернёт значение по умолчанию
val request = Request(GET, Uri.of("http://example.ru"))
val data = queryDataLens(request) // 15
Данные в некорректном формате #
При отсутствии данных будет выброшено исключение
val request = Request(GET, Uri.of("http://example.ru"))
val requestWithQuery = request.query("data", "abc")
queryDataLens(requestWithQuery)
org.http4k.lens.LensFailure: query 'data' must be integer
Перехват исключения #
С выбрасыванием исключения при использовании линз должна быть обработана:
- Локальная обработка исключения
- Обработка исключения на уровне всего приложения с помощью фильтра, http4k предлагает https://www.http4k.org/api/org.http4k.filter/-server-filters/-catch-lens-failure.html
Если пользователь самостоятельно может ввести некорректные данные, то необходимо обеспечить локальную обработку данных
Поведение разных терминаторов #
Терминатор | Данные есть | Данных нет | Некорректные данные |
---|---|---|---|
defaulted | Данные | Значение по умолчанию | Исключение |
optional | Данные | null | Исключение |
required | Данные | Исключение | Исключение |
Каждый тип терминатора продуцирует линзу, способную выбросить исключение в одной или нескольких ситуациях
Проверка данных на стороне клиента (в браузере) #
- Можно предположить, что приложение формирует корректные данные
- В полях ввода на стороне браузера поддерживается проверка данных по типу
- Поддерживается проверка необходимости указания данных
Для обработки ситуации выхода за указанные границы достаточно воспользоваться обработкой на уровне всего приложения
Обёртки для линз #
Мы можем преобразовать исключение к null
-значению в случае появления исключения LensFailure с помощью следующих самописных функций:
import org.http4k.lens.Lens
import org.http4k.lens.LensFailure
fun <IN : Any, OUT>lensOrNull(lens: Lens<IN, OUT?>, value: IN): OUT? =
try {
lens.invoke(value)
} catch (_: LensFailure) {
null
}
fun <IN : Any, OUT>lensOrDefault(lens: Lens<IN, OUT?>, value: IN, default: OUT): OUT =
try {
lens.invoke(value) ?: default
} catch (_: LensFailure) {
default
}
Вариант вызова данных функций-обёрток:
lensOrNull(fromLens, request) // Значение или null
HTML-формы #
Для передачи данных от пользователя к веб-приложению используются HTML-формы
GET и POST-запросы #
POST-запросы обычно используется для создания нового элемента в данных сервера или для изменения существующих элементов
- GET-запросы должны быть безопасными, не должны менять видимое состояние
- Безопасные запросы можно повторять несколько раз, можно кешировать
- POST-запросы не являются безопасными
- Результат работы POST-запроса не кешируется на прокси-серверах
Если POST-запрос приводит к изменению данных на стороне сервера запрещено возвращать HTML-документ пользователю, т.к. он легко сможет повторить такой запрос
Правильное решение — перенаправить пользователя на адрес, где он с помощью GET-запроса сможет просмотреть новое состояние сервера
Форма на добавление данных #
Формы на редактирование данных не отличаются от форм для поиска и фильтрации
<form method="POST">
<div class="mb-3">
<label for="email" class="form-label">Адрес электронной почты</label>
<input type="email" class="form-control" id="email" name="email" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Пароль</label>
<input type="password" class="form-control" id="password"
name="password" required>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="check" name="check">
<label class="form-check-label" for="check">Проверить меня</label>
</div>
<button type="submit" class="btn btn-primary">Отправить</button>
</form>
Единственное отличие — передача данных осуществляется POST-запросом
Кодирование данных формой #
POST-запрос может содержать тело, а HTML-формы могут туда помещать данные с использованием следующих кодировок:
application/x-www-form-urlencoded
— данные кодируются в форме данных URL-запроса, но в отличие от последнего нет ограничения на длинуmultipart/form-data
— данные передаются в бинарном виде, подходит для отправки файлов на сервер, требуют отдельной обработки в http4ktext/plain
— подходит для низкоуровневой отладки, почти не используется
Тип кодировки формы задаётся с помощью атрибута формы enctype
В рамках данной лекции рассмотрим обработку первого типа кодировок данных
Обработка данных от формы #
От клиента к серверу все данные передаются в виде пар ключ-значение, схожим образом с параметрами URI-запроса: может быть несколько одинаковых ключей
Класс Request
предоставляет следующие методы для получения данных из тела формы:
fun Request.form(name: String): String?
— получить значение поля по названию ключа, при каждом вызове происходит разбор всей строки (не эффективный)fun Request.form(): Form
— получить весь набор параметровfun Request.form(name: String, value: String): Request
— указать новое значение для поля формы внутри объекта-запроса, подходит для тестированияfun Request.formAsMap(): Map<String, List<String?>>
— получить список значений в форме словаря
Особенности указанных функций
- Тип данных
Form
является псевдонимом типаParameters
:typealias Form = Parameters
, т.е. поддерживает все соответствующие функции - Для работы со словарём предоставляется метод
getFirst(key: String)
, позволяющий извлечь первый элемент из списка значений ключа
Перенаправление HTTP-клиента #
HTML-формы зачастую направлены на изменение данных на стороне сервера:
- Добавление новых элементов
- Редактирование существующих элементов коллекции
- Удаление существующих элементов коллекций
Для этих случаев используются не-GET-запросы, GET-запрос не должен менять состояние сервера
В случае успеха POST-запроса, в случае изменения данных, HTTP-сервер должен вернуть ответ с указанием адреса, куда следует сделать GET-запрос для получения HTML-документа с результатом
Параметры ответа:
- Статус ответа — 302, FOUND
- Заголовок
Location
с ссылкой на страницу для просмотра результата
Принципиальная схема работы #
val strings = mutableListOf<String>()
val formHandler: HttpHandler = { request ->
val form = request.form()
val newString = form.findSingle("text").orEmpty()
strings.add(newString)
Response(FOUND).header("Location", "/strings")
}
Обработка HTML-форм #
Принципиальная схема не удовлетворяет требованиям по обработке данных от пользователя:
- Работает только в случае передачи корректных данных
- Падает с сообщением внутренних проблем при получении данных
- Теряет данные, которые были введены пользователем
В случае возникновения проблемы POST-запрос должен вернуть HTML-документ с формой, на которой:
- Показаны все данные, которые ввёл пользователь
- Показаны сообщения о проблемах с теми данными, которые указал пользователь
Полный сценарий обработки формы #
с данными пользователя и сообщениями об ошибках,
возвращение к пункту № 3 end
Описание линз для формы #
HTML-форма представляет собой набор полей, которые пользователь заполняет
В рамках http4k используются следующие типы данных:
- FormField — описание требований к конкретному полю формы в форме линзы
- Body.webForm — описание спецификации линзы для всей формы
- WebForm — структура для хранения результатов проверки формы
Порядок использования элементов следующий:
- Для каждого поля ввода необходимо оформить требования с использованием FormField
- Необходимо объединить поля ввода в общую линзу для работы с формой
- Выполнить обработку запроса и проверить результаты обработки
Пример формы #
Форма для отправки обратной связи
<form method="POST">
<div class="mb-3">
<label for="age" class="form-label">Возраст</label>
<input type="number" class="form-control" id="age" name="age" required>
</div>
<div class="mb-3">
<label for="name" class="form-label">Имя</label>
<input type="password" class="form-control" id="password"
name="password" required>
</div>
<div class="mb-3 form-check">
<label for="feedback" class="form-label">Комментарии</label>
<textarea id="feedback" name="feedback" rows="10"></textarea>
</div>
<button type="submit" class="btn btn-primary">Отправить</button>
</form>
Обработка формы #
val ageField = FormField.int().required("age")
val nameField = FormField.nonEmptyString().required("name")
val feedbackField = FormField.nonEmptyString().optional("feedback")
val formLens = Body.webForm(Validator.Feedback,
ageField, nameField, feedbackField).toLens()
Обработка данных с формы
val form = formLens(request)
if (form.errors.isEmpty()) {
val age = ageField(form)
val feedback = feedbackField(form)
}
Можно использовать в тестах:
// Отправка пустой формы
val invalidRequest = Request(GET, "/")
.with(Header.CONTENT_TYPE of ContentType.APPLICATION_FORM_URLENCODED)
val invalidForm = formLens(invalidRequest)
println(invalidForm.errors)
// Отправка заполненной формы
val webForm = WebForm().with(ageField of 55, nameField of "Rita"))
val validRequest = Request(GET, "/").with(formLens of webForm)
val validForm = formLens(validRequest)
val age = ageField(validForm)
Отображение результатов проверки в форме #
- При первом показе форма должна отображать пустые поля ввода
- В случае ошибки форма должна отображать данные, введённые пользователем и сообщения об ошибках
Удобно использовать объекты класса WebForm в обоих случаях
data class WebForm constructor(
val fields: Map<String, List<String>> = emptyMap(),
val errors: List<Failure> = emptyList()
)
Ограничение данного формата:
- нельзя связать ошибку с полем ввода
- сообщения об ошибках не ориентированы на конечного пользователя
Для проверки наличия данных в форме можно использовать следующие конструкции:
{% if model.form.errors is not empty %}
Присутствуют ошибки
{% endif %}
{% if model.form.fields contains "field" %}
Данные поля field: {{ model.form.fields["field"] | first }}
{% endif %}
Использование макросов Pebble #
Для уменьшения количества повторений внутри шаблонов Pebble можно воспользоваться макросами, которые можно несколько раз вызывать внутри шаблона
{% macro input(type="text", name, placeholder="", form) %}
<input
type="{{ type }}"
name="{{ name }}"
placeholder="{{ placeholder }}"
value="{{ form.fields[name] | first }}"
>
{% endmacro %}
{{
input(
name="count",
type="number",
placeholder="Число работников",
form=model.form
)
}}
Указание типа документа #
Согласно протоколу HTTP-серверу желательно сообщать клиенту тип передаваемого документа с помощью MIME типов. Также должен поступать и клиент
Для указания типа документа используется заголовок Content-Type
В http4k для решения этой задачи можно либо воспользоваться фильтрами:
val SetHtmlContentType = Filter { next ->
{ next(it).with(CONTENT_TYPE of TEXT_HTML) }
}
val app = routes(
return "/create" bind POST to SetHtmlContentType.then someHandler
)
Либо использовать линзы:
val renderer = PebbleTemplates().HotReload("src/main/resources")
val htmlView = Body.viewModel(renderer, TEXT_HTML).toLens()
val handler: HttpHandler {
Response(OK).with(htmlView of SomeViewModel(42))
}