Обработка запросов от пользователя #
Васильев Андрей Михайлович, 2024
Версии презентации
Базовый цикл работы веб-приложения #
Получение запроса, формирование ответа, отправка ответа клиенту
Вход — запрос от клиента, клиентом может выступать:
- Веб-браузер
- Клиентские приложения curl, wget, HTTPie
- Скрипты и собственные приложения
- Специализированные приложения для тестирования приложения
- Автоматические тесты приложения
Выход — ответ серверного веб-приложения, включающий
- Статус
- Заголовки
- Тело ответа
Клиентское приложение должно всегда получать корректный ответ. Если был составлен некорректный запрос, то серверное приложение должно оповещать об этом
Серверное веб-приложение никогда не должно выводить внутренние сообщения об ошибках пользователю
Данные клиентского запроса #
- Маршрут к документу
- HTTP-заголовки
- Наличие: установлен или нет
- Содержимое: корректно или нет
- Путь к документу с переменными
- Наличие: установлен или нет
- Содержимое: корректно или нет
- Параметры запроса
- Наличие: установлен или нет
- Содержимое: корректно или нет
- Содержимое полей формы
- Наличие: установлен или нет
- Содержимое: корректно или нет
Для каждого случая необходимо подготовить разумную стратегию обработки данных с предоставлением информации об ошибках
Неразумные стратегии обработки ошибок #
- Обработка только успешного сценария, отказ от обработки некорректных сценариев
- Обычно влечёт за собой показ внутренней информации о работе системы злоумышленнику
- «Техническая обработка»:
- возвращение исключительно HTTP-статусов
- возвращение единого ответа на все некорректные запросы
- Потеря некорректных данных, переданных пользователем
Обработка некорректного маршрута #
Источники некорректных маршрутов #
- Логические ошибки внутри приложения
- Ошибки, вызванные данными, которые ввели пользователи
- Некорректная ссылка, сформированная вне приложения
- Корректная ранее ссылка, которая в настоящее время не работает
Подходы к решению проблемы #
- Показать страницу, с которой он сможет открыть другие части сайта
- Показать информацию по пришедшему запросу, чтобы пользователь мог сообщить о проблеме
- Если это возможно, перенаправить запрос на другую страницу с корректным содержимым
Обработка ошибок внутри обработчика #
- После получения данных HTTP-обработчик проверяет полученные данные
- В случае наличия проблем обработчик возвращает сообщение об ошибке
- Если технически данные корректны, то они передаются слою предметной области для обработки
- Запрос может завершиться успешно или с логической ошибкой
- В случае ошибки надо оповестить о проблеме в данных запроса
Обработка переменных внутри обработчика #
Рассмотрим порядок обработки маршрута с переменной внутри. Стратегия решения проблемы — отображение страниц с деталями информации по ошибкам
class Handler(renderer: TemplateRenderer,
getEntityByParameter: (Int) -> Entity?) : HttpHandler {
override fun invoke(request: Request) {
val paramemter: String? = request.path("parameter")?.toIntOrNull()
if (parameter == null) {
return Response(UNSATISFIABLE_PARAMETERS).body(
renderer(EmptyParameterRequestFoundVM())
)
}
val entity: Entity? = getEntityByParameter(parameter)
if (entity == null) {
return Response(NOT_FOUND).body(
renderer(EntityNotFoundVM())
)
}
Response(OK).body(renderer(ShowEntityVM(entity)))
}
}
Выдача одного сообщения об ошибке #
Упростим обработчик, чтобы он выдавал один вид ошибки при разных конкретных источниках ошибки:
class Handler(renderer: TemplateRenderer,
getEntityByParameter: (Int) -> Entity?) : HttpHandler {
override fun invoke(request: Request) =
request
.path("parameter")
?.toIntOrNull()
?.let { parameter ->
getEntityByParameter(parameter)
}?.let { entity ->
Response(OK).body(renderer(ShowEntityVM(entity)))
} ?: Response(NOT_FOUND).body(renderer(EntityNotFoundVM()))
}
- Приложение не должно предоставлять пользователю ошибочные ссылки
- Конкретные детали обработки данных могут и будут использованы против системы
- Почти в каждом разработчике нужно будет как минимум один раз вызывать создание класса-модели EntityNotFoundVM
Фильтры http4k #
fun interface Filter : (HttpHandler) -> HttpHandler
Фильтры позволяют выполнить действия, общие для ряда запросов от пользователя:
- Проверить авторизацию пользователя
- Выполнить обработку внутренних ошибок сервера
- Выполнить обработку ошибочных запросов от пользователя
- Выполнить журналирование запросов
- Настроить общие политики для предоставления содержимого, установка CORS-заголовков
- Выполнить кеширование ответов на запросы
Процесс обработки запроса с фильтрами #
- С помощью фильтров определяются действия, которые могут выполняться до и после работы HTTP-обработчика
- В любой стадии фильтр может самостоятельно сформировать ответ пользователю
- Фильтр может применяться к одиночному HTTP-обработчику
- Фильтр может применяться ко всему приложению
Реализация собственного фильтра #
Интерфейс фильтра (Filter) принимает в качестве аргумента объект HttpHandler и должен вернуть объект HttpHandler: interface Filter : (HttpHandler) -> HttpHandler
val appHandler: HttpHandler = ... // HTTP-обработчик, который надо обернуть
val timingFilter = Filter { // Создаём фильтр
next: HttpHandler -> { // HTTP-обработчик, который оборачиваем
request: Request -> // Код нового HTTP-обработчика
val start = System.currentTimeMillis() // Код до
val response = next(request) // Вызов обработчика
val latency = System.currentTimeMillis() - start // Код после
println("I took $latency ms")
response // Возврат ответа клиенту
}
}
// Создаём цепочку из стандартного фильтра
val latencyAndBasicAuth: Filter = ServerFilters.BasicAuth(
"my realm", "user", "password")
.then(timingFilter) // А после добавляем наш фильтр
val app: HttpHandler = latencyAndBasicAuth.then(appHandler)
Фильтр в примере выполняет измерение скорости выполнения кода HTTP-обработчиков
Реализация фильтров с помощью классов #
При реализации функциональных интерфейсов с помощью классов потребуется создать два класса:
- Один необходимо унаследовать от интерфейса
org.http4k.core.Filter
- Второй класс будет реализовывать интерфейс HttpHandler, его конструктор будет принимать ссылку на оборачиваемый HTTP-обработчик
class CounterFilter : Filter {
override fun invoke(next: HttpHandler): HttpHandler =
CounterHandler(next)
}
class CounterHandler(private val next: HttpHandler) : HttpHandler {
override fun invoke(request: Request): Response {
val start = System.currentTimeMillis()
val response = next(request)
val latency = System.currentTimeMillis() - start
println("I took $latency ms")
return response
}
}
Стандартные фильтры http4k #
Пакет org.http4k.filter содержит описание фильтров, поставляемых с помощью http4k
- DebuggingFilters — фильтры, помогающие в отладке работы приложения
- PrintRequest — отображать запрос
- PrintRequest — отображать ответ
- PrintRequestAndResponse — отображать и запрос и ответ
- ServerFilters — основные серверные фильтры
- BasicAuth — реализация базовой HTTP-авторизации
- CatchAll — перехватывание всех внутренних исключений, возникающих при обработке сообщений
- Cors — установка CORS-заголовков
- HandleRemoteRequestFailed — обработка ошибок от HTTP-запросов к внешним ресурсам
- RequestTracing — структурированное отслеживание запросов в формате Zipkin
Обработка статуса 404 #
Статус NOT_FOUND используется для оповещения пользователя о том, что по указанному запросу данные не были найдены
- Маршрутизатор по умолчанию возвращает ответ со статусом 404 при обращении к несуществующему маршруту
- Стандартные обработчики тоже могут возвращать ответы со статусом 404, например если параметр в пути был передан некорректно или нет объекта по указанному пути
На уровне фильтра при возникновении ошибки 404 можно сформировать страницу с информацией для пользователя
Подход к реализации:
- Надо выбрать уровень обработки сообщений:
- Либо только динамические маршруты
- Либо все маршруты приложения
- Необходимо реализовать фильтр для обработки неуспешного запроса, отслеживать состояние ответа можно с помощью статуса ответа оборачиваемого обработчика
Реализация фильтра «в лоб» #
Рассмотрим наивный подход к реализации такого фильтра
val errorFilter = Filter { next: HttpHandler ->
{ request: Request ->
val response = next(request)
if (response.status == Status.NOT_FOUND) {
response.body(renderer(ErrorMessageVM(request)))
} else {
response
}
}
}
val application = errorFilter.then(router)
Если обёрнутый HTTP-обработчик вернёт сообщение со статусом NOT_FOUND
, то будет сформирована общая страница с сообщением об ошибке
Использование фильтра ошибочного кода #
Выполним следующее упрощение в обработчике HTTP-запроса: будем возвращать только код 404, а обработку ошибочной ситуации доверим общему фильтру приложения
class Handler(renderer: TemplateRenderer,
getEntityByParameter: (Int) -> Entity?) : HttpHandler {
override fun invoke(request: Request) =
request
.path("parameter")
?.toIntOrNull()
?.let { parameter ->
getEntityByParameter(parameter)
}?.let { entity ->
Response(OK).body(renderer(ShowEntityVM(entity)))
} ?: Response(NOT_FOUND) // Делегируем фильтру показ страницы
}
Отображение страницы с ошибкой #
Страница с информацией об ошибке должна быть информативной:
- Сообщать пользователю, что запрашиваемый документ не может быть найден
- Если необходимо предоставлять инструкцию по составлению сообщения об ошибке
- Можно предоставить QR-код, в котором закодировать нужную информацию
- Можно предоставить пошаговую инструкцию по описанию проблемной ситуации и шагов по её воспроизведению
- Можно явно указать контактную информацию, куда можно сообщить о проблеме
- Дать возможность пользователю удобным образом вернуться на рабочие части веб-сайта, например предоставив соответствующую ссылку, а также оставив навигационную панель
Обработка ошибки в слое представления #
Обработку ошибки получения данных потенциально можно делегировать слою представления, шаблонизатору Pebble
Разрешаем null-состояние для данных модели
class ShowEntityVM(entity: Entity?) : ViewModel
Передаём неопределённое состояние в модель
class Handler(renderer: TemplateRenderer,
getEntityByParameter: (Int) -> Entity?) : HttpHandler {
override fun invoke(request: Request) {
val entity: Entity? = request // Данные либо есть, либо их нет
.path("parameter")
?.toIntOrNull()
?.let { number ->
getEntityByParameter(parameter)
}
return Response(OK).body(renderer(ShowEntityVM(entity)))
}
}
Обработка ошибки в слое представления #
Pebble поддерживает проверку на null внутри шаблона:
{% if model.entity is null %}
...
{% else %}
...
{% endif %}
Однако такой подход не следует активно использовать:
- Шаблонизатор исполняет код медленнее компилированного кода приложения
- Шаблонизатору приходится работать в рамках неопределённого окружения, что значительно повышает сложность восприятия, следовательно значительно повышается риск ошибок
- Шаблоны начинают разрастаться дублирующей логикой обработки ошибок
В рамках курса такой подход запрещён, вместо этого решайте проблему на уровне фильтров или HTTP-обработчиков
Параметры в URI-запросах #
Структура URI
[ схема ":" ] [ // источник ] путь [ "?" запрос ] [ "#" фрагмент ]
- Запрос отделяется от пути знаком вопроса
- В запросе данные могут быть отформатированы любым образом, однако обычно применяется схема с передачей набора параметры
- параметр — это пара ключ-значение, разделённые знаком
=
- параметры отделяются друг от друга знаком
&
- параметр — это пара ключ-значение, разделённые знаком
http://some.domain/some/path?key1=value1&key2=value2&key3=value3
- Передаётся 3 пары параметров с именами key1, key2 и key3
- Значения параметра key1 — это value1, key2 — это value2, key3 — value3
Особенности параметров #
На первый взгляд параметры очень напоминают структуру данных словарь, т.к. состоят из пар ключ-значение
Однако есть важное отличие от словаря: клиент может передать несколько параметров с одинаковыми ключами и сервер должен обработать их все
- Параметры представляют собой набор пар
- Нет никаких ограничений между параметрами нет
- Ключи и значения могут быть не только латиницей, для передачи таких данных используется процентная кодировка, сервер её автоматически декодирует
- Порядок параметров в большинстве случаев не должен быть важен, но может учитываться кодом на стороне сервера
Обработка параметров #
Для всех данных, которые передаёт HTTP-клиент, необходимо выполнить две базовые проверки:
- Переданы ли данные в запросе от пользователя
- Являются ли переданные данные технически корректными (в строке передано число)
Подходы к обработке параметров, приходящих в запросах от пользователя:
- Обработать запросы внутри обработчика, используя низкоуровневые интерфейсы
- Реализовать собственную подсистему для обработки параметров для уменьшения дублирования между разными запросами:
- Реализовать подсистему полностью самостоятельно
- Интегрировать стороннюю библиотеку с примитивами http4k
- Использовать механизмы проверки данных, предоставляемые библиотекой http4k
Низкоуровневый доступ к параметрам #
- В объекте запроса (Request) предоставляется поле
uri
, содержащее объект класса Uri - Класс Uri предоставляет следующие методы для работы с параметрами:
- Свойство
query
содержит строку запроса полностью - Функция
fun query(query: String): Uri
позволяет создать новый объект Uri с новым значением запроса - Функция
fun queries(): Parameters
возвращает набор пар ключ-значения - Функция-расширение
fun Uri.query(name: String, value: String?): Uri
позволяет добавить новый параметр к новому Uri-объекту - Функция-расширение
fun Uri.removeQuery(name: String): Uri
позволяет удалить все параметры с указанным ключом, новое состояние сохраняется в возвращаемом объекте типа Uri
- Свойство
- Класс Parameters представляет собой список параметров ключ-значение
- Функция-расширение
fun Parameters.findSingle(name: String): String?
позволяет найти первый параметр с указанным именем - Функция-расширение
fun Parameters.findMultiple(name: String): List<String?>
позволяет найти все значения для параметра с указанным именем
- Функция-расширение
Работа с классом URI #
Получение параметров из запроса #
val queryParameters: Parameters = request.uri.queries()
val maxPrise: String? = queryParameters.findSingle("maxPrise")
val page: String? = request.uri.queries().findSingle("maxPrice") // Заново разбирает строку запроса
Данные приходят (или не приходят) в строком виде. Из переданной строки надо извлечь данные
Установление новых значений параметр запроса #
Для установки нового значения для параметра надо сначала удалить старые, а после добавить один новый
val newUri = request.uri.removeQuery("data").query("data", "value")
Формирование параметров на стороне клиента #
На стороне сервера доступны следующие варианты:
- Синтаксис для формирования параметров достаточно простой, можно оформить руками внутри текста HTML-документа
- Для шаблонизатора можно предоставить функцию-расширения, которая позволит программно указывать параметры в запросе
- Для расширения возможностей Pebble необходимо сформировать класс
- Шаблонизатору Pebble можно передать функцию-инициализатор, в рамках которой можно указать список классов-расширений
На стороне клиента доступны следующие варианты:
- Вручную указать список параметров в адресной строке браузера
- Воспользоваться интерактивными элементами на HTML-странице, формами
Отображение HTML-форм #
Формы являются частью языка HTML, для их описания используются следующие элементы:
- Form — базовый контейнер для всех полей ввода, расположенных на форме
- Input — элемент для описания интерактивных элементов ввода данных. Он может принимать множество форм: кнопка, поле ввода текста, даты, числа, выбора файла
- Textarea — элемент для ввода многострочного текста
- Select — элемент для выбора элемента из списка
<form method="GET">
<input type="text" name="start">
<textarea name="description">
<input type="submit" value="Отправить">
</form>
- Формы могут выполнять как GET, так и POST-запросы
- При выполнении GET-запроса данные передаются на сервер в параметрах запроса ключом является имя поля ввода
Типизированные поля ввода #
Элемент ввода Input поддерживает множество типов данных, которые может указать пользователь: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#input_types
- Указание конкретного типа данных позволит пользователю редактировать его удобным для него способом
- Клиент будет передавать данные на сервер в строковом виде вне зависимости от типа поля ввода
Работа с большим объёмом информации #
При работе с большим объёмом информации возникает ряд проблем:
- Весь объём данных для отображения слишком большой
- Сервер будет долго формировать и обрабатывать весь набор данных
- Клиентскому приложению надо будет эти данные получить (и в плохом случае оплатить мобильный трафик)
- Клиентскому приложению надо данные отобразить пользователю удобным образом
- Сервер будет долго формировать и обрабатывать весь набор данных
- Системе в большинстве случаев не выгодно предоставлять все свои данные
Для обеспечения удобного доступа к большому объёму данных пользователю предоставляются инструменты для выполнения фильтрации, сортировки и постраничного ввода информации
Фильтрация и сортировка сервером #
- Фильтрация — выбор элементов из списка согласно какому-то свойству
- По конкретному значению (хочу 10k-телевизор)
- По границам возможных значений (до 10 т.р.)
- Сортировка — упорядочивание элементов в списке согласно свойству
- По дате доставки (хочу вчера)
Фильтрация и сортировка #
Форма для фильтрации и сортировки #
Рассмотрим форму, в которой пользователь может указать максимальную стоимость товара и выбрать его категорию:
<form method="GET">
<input type="number" name="maxCost">
<select name="category">
<option value="">--Категория не выбрана--</option>
<option value="10">Волейбольные мячи</option>
<option value="15">Тенисные мячи</option>
</select>
<button type="submit">Отправить</button>
</form>
- В поле ввода максимальной цены пользователь указывает число 100
- В категории выбирает «Тенисные мячи»
- Нажимает на кнопку «Отправить»
- Веб-браузер отправляет запрос с параметрами:
?maxCost=100&category=15
Отображение параметров фильтрации и сортировки #
- При отображении отфильтрованной информации пользователю необходимо сообщить, что данные соответствуют какой-то выборке
- Пользователь может захотеть исправить параметры фильтрации. Если текущие данные введены, то это сделать будет проще
Заполненные поля формы одновременно сообщают о текущем фильтре и позволяют его изменить в будущем при необходимости
В рамках соответствующих HTTP-обработчиков необходимо параметры от пользователя передать шаблонизатору
Заполнение интерактивных элементов HTML-документа #
Внутри элемента Input можно определить начальное значение в атрибуте value
<input name="startDate" type="date" value="2013-10-05">
Внутри элемента Select необходимо указать выбранный элемент с помощью атрибута selected вложенного элемента Option
<select name="pets">
<option value="">--Please choose an option--</option>
<option value="dog">Dog</option>
<option value="cat" selected>Cat</option>
</select>
Содержимое элемента Textarea показывается как содержимое поля ввода
<textarea name="option">
Это был очень важный опрос, спасибо
</textarea>
Постраничный вывод информации #
Даже после выполнения фильтрации объём данных скорее всего слишком большой для передачи пользователю, да и сервер не хочет отдавать все свои данные
Общепринятое решение — передача данных блоками, страницами
- При показе списка данных в первый раз показывается первая страница
- На странице отображается возможность перейти к другим страницам
- Явно путём показа списка ссылок на предыдущие и следующие страниы
- Неявно путём показа кнопки «показать больше»
Для указания номера страницы логично использовать параметры запроса
- Если параметр не указан, то его следует считать равным значению первой страницы
- Обработчик запроса должен учитывать все параметры: сначала выполнить фильтрацию, затем отобразить нужную страницу
Идентификация элементов в списке #
При выполнении фильтрации и сортировки порядок элементов в массиве скорее всего не будет соответствовать изначальному:
- Входными данными шаблонизатора будет массив из элементов с порядковыми номерами 64, 44 и 8 в оригинальном массиве
- На странице-списке необходимо сформировать ссылки на элементы
- Порядковый номер в массиве не может являться надёжным средством идентификации
- Уникальный идентификационный номер должен стать частью элемента
Использование индивидуального номера #
- При формировании ссылки на страницу элемента необходимо использовать его внутренний номер, а не порядковый номер в элементе массива
- В приложении появляется потребность в классе-хранилище, элементов, который
- Обеспечит быстрый доступ к элементу по его внутреннему номеру
- Обеспечит уникальность внутренних идентификаторов элементов в системе
class TriangleStorage() {
private val triangles: ...
fun add(triangle: Triangle): Int { ... }
fun get(id: Int): Triangle? { ... }
}
При добавлении нового элемента в хранилище либо
- У него устанавливается новое значение уникального идентификатора
- Запрещается добавление элемента, если у него не уникальный номер
Хранилище может предоставлять функции для получения всех элементов в виде списка, для выполнения операций фильтрации и т.д.
Классы данных #
Для установления нового значения идентификатора у треугольника нельзя изменять объект, который был передан в качестве аргумента
Вместо этого необходимо создавать копию переданного объекта, а в копию уже записывать новое значение
Записывать это решение вручную достаточно трудозатратно, но Kotlin предоставляет классы данных, которые предоставляют:
- Реализацию функций
equals()
иhashCode()
- Реализацию функции
toString()
- Специализированную функцию
copy()
для создания копий
Последняя функция позволяет быстро и удобно создавать объекты нужного состояния
Копирование классов данных #
Рассмотрим класс данных
data class Rectangle(val width: Double, val height: Double)
Для данного класса функция копирования выглядит следующим образом:
fun copy(width: Double = this.width, height: Double = this.height) =
Rectangle(width, height)
Данную функцию не надо писать, её автоматически сформирует компилятор языка Kotlin, т.к. класс был объявлен как класс данных
С использованием данной функции легко создать объект, описывающий новое состояние:
val firstRectangle = Rectangle(10.0, 15.0)
val square = firstRectangle.copy(width = 15.0)
Первый объект не изменился, а новый захватил новое состояние
Для неизменяемых объектов легко и удобно писать модульные тесты
Написание модульных тестов #
Архитектурный подход позволит выполнять модульное тестирование компонентов
- Элементы извлечения данных из запроса зависят только от параметров, полученных от пользователя из HTTP-запросами
- Объекты слоя представления данных зависят только от хранилища данных
- Шаблонизаторы зависят от данных, которые им были переданы
- Обработчики HTTP-запросов выполняют координационную роль по передаче данных между этими компонентами
Для облегчения тестирования каждого из компонентов все зависимости рекомендую передавать в конструкторе
Обработка запроса от пользователя #
Обработку запроса от пользователя можно разделить на три логических этапа, следующих один за другим:
- Техническая обработка данных, пришедших от пользователя. Данные приходят в формате строк, необходимо их преобразовать к типам слоя предметной области
- Выполнение обработки извлечённых данных в слое предметной области: поиск значений, добавление значений, изменение значений и т.д.
- Формирование ответа пользователю в формате, который он ожидает
В рамках обработки HTTP-запроса может быть выполнено несколько обращений к каждому из слоя извлечения данных и слоя предметной области
Рекомендуется каждый этап оформлять внутри отдельного компонента, а задачей HTTP-обработчика становится их логическое объединение для достижения задачи
Проблема большого класса-хранилища #
В рамках HTTP-обработчика необходимо иметь доступ к хранилищу данных, т.к. все действия приложения так или иначе сводятся к выборке или изменению набора данных
При использовании большого класса-хранилища
При написании теста обработчика ему необходимо предоставить тестовый дубль класса-хранилища, а может быть и не одного
Можно добавить промежуточный слой - классов операций
Если классы-операции предоставляют только один публичный метод, то в рамках теста их легко заместить тестовым дублем
Пример операции #
При создании операции можно удобно описать её интерфейс:
interface GetTriangleOperation {
fun get(id: Int): Triangle?
}
class GetTriangleOperationImpl(
private val storage: TriangleStorage
) : GetTriangleOperation {
override fun get(id: Int) = storage.get(id)
}
Внутри HTTP-обработчика реализовать получение объекта, реализующего интерфейс:
class ShowTriangleHandler(
getTriangleOperation: GetTriangleOperation
) : HttpHandler { ... }
При написании теста создаём тестовый дубль для тестирования обработчика:
class ReturnTriangle(private val triangle: Triangle?)
: GetTriangleOperation {
override fun get(id: Int) = triangle
}