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

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

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

Базовый цикл работы веб-приложения #

Получение запроса, формирование ответа, отправка ответа клиенту

Вход — запрос от клиента, клиентом может выступать:

  • Веб-браузер
  • Клиентские приложения curl, wget, HTTPie
  • Скрипты и собственные приложения
  • Специализированные приложения для тестирования приложения
  • Автоматические тесты приложения

Выход — ответ серверного веб-приложения, включающий

  • Статус
  • Заголовки
  • Тело ответа

Клиентское приложение должно всегда получать корректный ответ. Если был составлен некорректный запрос, то серверное приложение должно оповещать об этом

Серверное веб-приложение никогда не должно выводить внутренние сообщения об ошибках пользователю

Данные клиентского запроса #

  • Маршрут к документу
  • HTTP-заголовки
    • Наличие: установлен или нет
    • Содержимое: корректно или нет
  • Путь к документу с переменными
    • Наличие: установлен или нет
    • Содержимое: корректно или нет
  • Параметры запроса
    • Наличие: установлен или нет
    • Содержимое: корректно или нет
  • Содержимое полей формы
    • Наличие: установлен или нет
    • Содержимое: корректно или нет

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

Неразумные стратегии обработки ошибок #

  • Обработка только успешного сценария, отказ от обработки некорректных сценариев
    • Обычно влечёт за собой показ внутренней информации о работе системы злоумышленнику
  • «Техническая обработка»:
    • возвращение исключительно HTTP-статусов
    • возвращение единого ответа на все некорректные запросы
  • Потеря некорректных данных, переданных пользователем

Обработка некорректного маршрута #

Источники некорректных маршрутов #

  • Логические ошибки внутри приложения
  • Ошибки, вызванные данными, которые ввели пользователи
  • Некорректная ссылка, сформированная вне приложения
  • Корректная ранее ссылка, которая в настоящее время не работает

Подходы к решению проблемы #

  • Показать страницу, с которой он сможет открыть другие части сайта
  • Показать информацию по пришедшему запросу, чтобы пользователь мог сообщить о проблеме
  • Если это возможно, перенаправить запрос на другую страницу с корректным содержимым

Обработка ошибок внутри обработчика #

  • После получения данных HTTP-обработчик проверяет полученные данные
  • В случае наличия проблем обработчик возвращает сообщение об ошибке
  • Если технически данные корректны, то они передаются слою предметной области для обработки
  • Запрос может завершиться успешно или с логической ошибкой
  • В случае ошибки надо оповестить о проблеме в данных запроса

diagram

Обработка переменных внутри обработчика #

Рассмотрим порядок обработки маршрута с переменной внутри. Стратегия решения проблемы — отображение страниц с деталями информации по ошибкам

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-обработчику
  • Фильтр может применяться ко всему приложению

diagram

Реализация собственного фильтра #

Интерфейс фильтра (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 — фильтры, помогающие в отладке работы приложения
  • 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-клиент, необходимо выполнить две базовые проверки:

  1. Переданы ли данные в запросе от пользователя
  2. Являются ли переданные данные технически корректными (в строке передано число)

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

  • Обработать запросы внутри обработчика, используя низкоуровневые интерфейсы
  • Реализовать собственную подсистему для обработки параметров для уменьшения дублирования между разными запросами:
    • Реализовать подсистему полностью самостоятельно
    • Интегрировать стороннюю библиотеку с примитивами 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-документа
  • Для шаблонизатора можно предоставить функцию-расширения, которая позволит программно указывать параметры в запросе

На стороне клиента доступны следующие варианты:

  • Вручную указать список параметров в адресной строке браузера
  • Воспользоваться интерактивными элементами на 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

  • Указание конкретного типа данных позволит пользователю редактировать его удобным для него способом
  • Клиент будет передавать данные на сервер в строковом виде вне зависимости от типа поля ввода

Работа с большим объёмом информации #

При работе с большим объёмом информации возникает ряд проблем:

  • Весь объём данных для отображения слишком большой
    • Сервер будет долго формировать и обрабатывать весь набор данных
      • Клиентскому приложению надо будет эти данные получить (и в плохом случае оплатить мобильный трафик)
    • Клиентскому приложению надо данные отобразить пользователю удобным образом
  • Системе в большинстве случаев не выгодно предоставлять все свои данные

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

Фильтрация и сортировка сервером #

diagram

  • Фильтрация — выбор элементов из списка согласно какому-то свойству
    • По конкретному значению (хочу 10k-телевизор)
    • По границам возможных значений (до 10 т.р.)
  • Сортировка — упорядочивание элементов в списке согласно свойству
    • По дате доставки (хочу вчера)

Фильтрация и сортировка #

diagram

Форма для фильтрации и сортировки #

Рассмотрим форму, в которой пользователь может указать максимальную стоимость товара и выбрать его категорию:

<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>

Постраничный вывод информации #

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

Общепринятое решение — передача данных блоками, страницами

  • При показе списка данных в первый раз показывается первая страница
  • На странице отображается возможность перейти к другим страницам
    • Явно путём показа списка ссылок на предыдущие и следующие страниы
    • Неявно путём показа кнопки «показать больше»

Для указания номера страницы логично использовать параметры запроса

  • Если параметр не указан, то его следует считать равным значению первой страницы
  • Обработчик запроса должен учитывать все параметры: сначала выполнить фильтрацию, затем отобразить нужную страницу

Идентификация элементов в списке #

При выполнении фильтрации и сортировки порядок элементов в массиве скорее всего не будет соответствовать изначальному:

diagram

  • Входными данными шаблонизатора будет массив из элементов с порядковыми номерами 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-запросов выполняют координационную роль по передаче данных между этими компонентами

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

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

diagram

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

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

В рамках обработки HTTP-запроса может быть выполнено несколько обращений к каждому из слоя извлечения данных и слоя предметной области

Рекомендуется каждый этап оформлять внутри отдельного компонента, а задачей HTTP-обработчика становится их логическое объединение для достижения задачи

Проблема большого класса-хранилища #

В рамках HTTP-обработчика необходимо иметь доступ к хранилищу данных, т.к. все действия приложения так или иначе сводятся к выборке или изменению набора данных

При использовании большого класса-хранилища

diagram

При написании теста обработчика ему необходимо предоставить тестовый дубль класса-хранилища, а может быть и не одного

Можно добавить промежуточный слой - классов операций

diagram

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

Пример операции #

При создании операции можно удобно описать её интерфейс:

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
}