Линзы http4k и изменение данных

Линзы http4k и изменение данных #

Васильев Андрей Михайлович, 2023

Версии презентации


Архитектура приложения #

flowchart LR classDef process fill:#c6e9afff,stroke:#5aa02cff subgraph Компьютер клиента browser-1[Веб-браузер] end subgraph Компьютер клиента browser-2[Веб-браузер] end subgraph Сервер приложения subgraph http-server-1[HTTP-сервер приложения] web-app-process-1["Процесс 1"] web-app-process-2["Процесс 2"] resources-1>Ресурсы\nприложения] in-memory-data[["Данные\nв оперативной\nпамяти"]] end http-server-1:::process filesystem>Файловая\nсистема] end subgraph Сервер Икс rest-data-provider[Внутренний\nHTTP-сервер] end subgraph Сервер СУБД database[СУБД] end browser-1 --> http-server-1 browser-2 --> http-server-1 web-app-process-1 -.-> resources-1 web-app-process-1 -.-> filesystem web-app-process-1 -.-> in-memory-data web-app-process-2 -.-> resources-1 web-app-process-2 -.-> filesystem web-app-process-2 -.-> in-memory-data http-server-1 ---> rest-data-provider http-server-1 ---> database

  • Данные системы хранятся в постоянных хранилищах:
    • Файловая система
    • Внутри БД под управлением СУБД
  • Ключевые задачи приложения
    • Обеспечение корректной записи данных в хранилище
    • Быстрое и точное извлечение данных из хранилища

Обработка запроса от пользователя #

flowchart TB input[[Запрос]] data_extraction["Проверка и извлечение данных"] user_data[[Извлечённые данные]] error_data[[Техническая ошибка в запросе]] domain_processing["Обработка данных в слое предметной области"] result_data[[Данные для отображения]] logical_error[[Логическая ошибка в запросе]] presentation["Преобразование данных в пользовательский формат"] output[[Ответ]] input --> data_extraction --> user_data data_extraction --> error_data error_data --> presentation user_data --> domain_processing --> result_data domain_processing --> logical_error logical_error --> presentation result_data --> presentation --> output

Обработку запроса от пользователя можно разделить на три логических этапа, следующих один за другим:

  1. Техническая обработка данных, пришедших от пользователя. Данные приходят в формате строк, необходимо их преобразовать к типам слоя предметной области
  2. Выполнение обработки извлечённых данных в слое предметной области: поиск значений, добавление значений, изменение значений и т.д.
  3. Формирование ответа пользователю в формате, который он ожидает

В рамках обработки 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 = ...
flowchart TB speciality[["Speciality"]] course_1[["Course"]] course_2[[". . ."]] course_n[["Course"]] class_1[["Class"]] class_2[["Class"]] class_3[[". . ."]] class_n[["Class"]] speciality --> course_1 speciality --> course_2 speciality --> course_n course_1 --> class_1 course_n --> class_2 course_n --> class_3 course_n --> class_n

Модификация структур данных #

Для получения информации по предмету необходимо найти курс в списке, найти предмет, обратиться к полям класса для получения:

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)

Запись данных может быть полезна в рамках модульных тестов


Шаблон проектирования строитель #

Для создания некоторых объектов пользователю необходимо указать множество аргументов конструктора, причём каждый из них тоже может быть сложным

Чтобы упросить создание конкретного компонента в строитель выделяют:

  • логику по сбору всех аргументов для создания объекта
  • метод для создания объекта целевого класса

При использовании строителя создание объекта выглядит следующим образом:

  1. Создать объект-строитель
  2. Вызвать несколько методов, формирующих набор данных для конструктора класса
  3. Вызвать метод по созданию целевого объекта

Создание линзы #

Для создания линзы необходимо указать следующие параметры:

  • Объект, для которого формируется линза
  • Настройка линзы, обычно состоит из определения типа данных для преобразования
  • Терминатор (если есть обязательные или необязательные параметры)

Терминатор также создаёт объект линзы из соответствующего объекта-строителя

Классы-линзы описаны в пакете org.http4k.lens, строители описываются с помощью классов-спецификаций (наследников интерфейса LensSpec), сами линзы с помощью классов (наследников интерфейса Lens)

Поддерживается два типа линз:

  • Однонаправленные линзы, считывающие и преобразующие данные из источника
  • Двунаправленные линзы, считывающие и записывающие данные в целевой объект

Для стандартных классов http4k предлагаются двунаправленные линзы


Линзы http4k #

Объект Начальный тип данных Применимо для объектов Количество Необходимый
Параметры запроса String Request Один или много Обязательный или необязательный
Заголовок String Request / Response Один или много Обязательный или необязательный
Переменная пути String Request Один Обязательный
Поле формы String WebForm Один или много Обязательный или необязательный
Тело String Request / Response Один Обязательный

Указание объекта #

  • Параметры запроса: Query
  • Заголовок: Header
  • Переменные пути: Path
  • Поле формы: FormField
  • Тело: Body

Настройка преобразования типа данных #

Данный этап можно пропустить, если необходима строка, однако лучше всегда явно указывать тип для преобразования

http4k предлагает большой набор функций расширения, с помощью которых можно настраивать преобразование данных. Данные приходят от пользователя в строковом формате и их далее необходимо преобразовать в корректный внутренний формат

Рассмотрим список преобразований типов для объекта-запроса Query

  • enum() — преобразование в перечисление
  • dateTime() — преобразование переданных данных в тип LocalDate
  • int() — преобразование в целочисленный вариант
  • 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

Перехват исключения #

С выбрасыванием исключения при использовании линз должна быть обработана:

Если пользователь самостоятельно может ввести некорректные данные, то необходимо обеспечить локальную обработку данных


Поведение разных терминаторов #

Терминатор Данные есть Данных нет Некорректные данные
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-формы

sequenceDiagram Клиент ->> Сервер: GET-запрос страницы с формой Сервер ->> Клиент: HTML-документ с формой alt POST-запрос, обычно изменение данных Клиент ->> Сервер: POST-запрос с данными формы в виде данных документа Сервер ->> Клиент: Перенаправление на страницу с результатами Клиент ->> Сервер: GET-запрос на страницу с результатами else GET-запрос, запрос данных Клиент ->> Сервер: GET-запрос с данными формы в виде параметров запроса end

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 — данные передаются в бинарном виде, подходит для отправки файлов на сервер, требуют отдельной обработки в http4k
  • text/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-документ с формой, на которой:

  • Показаны все данные, которые ввёл пользователь
  • Показаны сообщения о проблемах с теми данными, которые указал пользователь

Полный сценарий обработки формы #

sequenceDiagram autonumber actor Пользователь participant Браузер participant Сервер Пользователь ->> Браузер: Нажимает на ссылку к странице формы Браузер ->> Сервер: Отправляет GET-запрос по адресу документа Сервер ->> Браузер: HTML-документ, содержащий форму Браузер ->> Пользователь: Показывает документ с формой Пользователь ->> Браузер: Заполняет интерактивные элементы на форме Пользователь ->> Браузер: Нажимает на кнопку отправки запроса Браузер ->> Сервер: POST-запрос с содержимым формы alt Форма не содержит ошибок Сервер ->> Браузер: Ответ со статусом 302, FOUND Браузер ->> Сервер: GET-запрос для просмотра результата else Сервер ->> Браузер: 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))
}

© A. M. Васильев, 2023, CC BY-SA 4.0, andrey@crafted.su