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

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

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

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


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

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

Архитектура приложения — это комбинация выбранных подходов к формированию приложения, а также результирующая структура исходного кода

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


Подходы к разделению #

Деление по функциям предметной области #

Приложение разделяется по сильно связанным функциям, для каждой функции выделяются отдельные части приложения

  • Управление комнатами и списками постояльцев
  • Управление персоналом гостиницы

Деление по техническим аспектам #

Выделение кода, описывающего предметную область #

Любое достаточно сложное приложение содержит нетривиальную логику по модификации данных, эту логику логично отделить от технической части приложения, которая содержит сложные компоненты

Выделение различных технических частей приложения #


Вариант разделения частей для веб-приложения #

Можно предложить следующий подход к разделению небольшого приложения на части:

  • Описание предментной области, пакет .domain
    • Подсистема хранения данных, пакет .domain.storage или .domain.database
    • Подсистема изменения данных, пакет .domain.operations
  • Статические данные приложения, пакет .public
  • Логика обработки HTTP-запросов, пакет .web
    • Фильтры по обработке HTTP-запросов, пакет .web.filters
    • Обработчики HTTP-запросов, пакет .web.handlers
    • Шаблоны для отображения информации, пакет .web.models

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

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

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

Выход — ответ сервера

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

Пользователь должен всегда получать корректный ответ, даже если составил некорректный запрос


Какие данные передаёт пользователь #

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

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


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

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

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

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

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

Подход к обработке #

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

Фильтры http4k #

fun interface Filter : (HttpHandler) -> HttpHandler

Фильтры позволяют выполнить действия, независимые от конкретного запроса:

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

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

Фильтр принимает в качестве аргумента HttpHandler и должен вернуть HttpHandler

val handler = { _: Request -> Response(OK) }

val myFilter = Filter {
    next: HttpHandler -> {
        request: Request ->
            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(myFilter)
val app: HttpHandler = latencyAndBasicAuth.then(handler)

Стандартные фильтры http4k #

Пакет org.http4k.filter содержит описание фильтров, поставляемых с помощью http4k

  • DebuggingFilters — фильтры, помогающие в отладке работы приложения
  • ServerFilters — основные серверные фильтры
    • BasicAuth — реализация базовой HTTP-авторизации
    • CatchAll — перехватывание всех внутренних исключений, возникающих при обработке сообщений
    • Cors — установка CORS заголовков
    • HandleRemoteRequestFailed — обработка ошибок от HTTP-запросов к внешним ресурсам
    • RequestTracing — структурированное отслеживание запросов в формате Zipkin

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

  • Маршрутизатор по умолчанию возвращает статус 404 при обращении к несуществующему маршруту
  • Стандартные обработчики тоже могут возвращать неуспешные ответы

Подход к реализации:

  • Надо выбрать уровень обработки сообщений. Либо только динамические маршруты, либо все маршруты приложения
  • Необходимо реализовать фильтр для обработки неуспешного запроса, отслеживать состояние ответа можно с помощью Status.successful

Принципиальный подход к реализации

val errorFilter: Filter = { next: HttpHandler ->
    { request: Request ->
        val response = next(request)
        if (response.successfull) {
            response
        } else {
            response.body(renderer(ErrorMessage(request)))
        }
    }
}
val system = errorFilter.then(app)

Обработка параметров #

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

  1. Переданы ли данные
  2. Являются ли они корректными

Подходы к обработке данной ситуации:

  • Вручную обработать ситуацию наличия или отсутствия данных
  • Использовать специализированный механизм основной библиотеки
  • Реализовать собственную подсистему для обработки запросов

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

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

fun handler(renderer: TemplateRenderer): HttpHandler = handler@{ request ->
    val paramemter: String? = request.path("parameter")?.toIntOrNull()
    if (parameter == null) {
        return@handler Response(UNSATISFIABLE_PARAMETERS).body(
            renderer(EmptyParameterRequestFoundVM())
        )
    }
    val entity: Entity? = getEntityByData(parameter)
    if (entity == null) {
        return@handler Response(NOT_FOUND).body(
            renderer(EntityNotFoundVM())
        )
    }
    Response(OK).body(renderer(ShowEntityVM(entity)))
}

Или с помощью составления цепочки по обработке данных, которые могут стать null:

fun handler(renderer: Templaterenderer): HttpHandler = { request ->
    request
        .path("parameter")
        ?.let { parameter ->
            getEntityByParameter(parameter)
        }?.let { entity ->
            Response(OK).body(renderer(ShowEntityVM(entity)))
        } ?: Response(NOT_FOUND).body(renderer(EntityNotFoundVM()))
}

Или путём обработки данной ситуации на уровне шаблона Pebble

Разрешаем null-состояние для модели

data class ShowEntityVM(entity: Entity?)

Передаём неопределённое состояние в модель

fun handler(renderer: Templaterenderer): HttpHandler = { request ->
    val entity request
        .path("parameter")
        ?.let { parameter ->
            getEntityByParameter(parameter)
        }
    Response(OK).body(renderer(ShowEntityVM(entity)))
}

Проверка на null в шаблоне Pebble:

{% if model.entity is null %}
   ...
{% else %}
   ...
{% endif %}

Работа со сложными структурами данных #

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

val structure = listOf(
    mapOf(
        "lang" to "kotlin",
        "name" to "Веб-программирование",
    )
)

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

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

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

Механизм линз #

Линза — специальный объект, который позволяет осуществлять операции чтения и записи для конкретного вложенного свойства

Предположим, что мы создали линзу для получения переменной из пути:

val parameterLens = Path.of("parameter")

Для получения параметра:

val parameter:String = parameterLens(request)

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

val newRequest = parameterLens("newData", request)

Но смысла в операции записи на запросе нет


Линзы http4k #

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

Настройка линзы #

При создании линзы необходимо указать:

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

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

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

Указание типа данных для преобразования #

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

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

val request = Request(GET, Uri.of("http://server.ru"))
val requestWithQuery = request.query("data", "42")
val queryDataLens = Query.int().defaulted("data", 15)
val data = queryDataLens(requestWithQuery) // 42

Указание необходимости параметра #

Уровни необходимости параметра описываются в рамках класса LensSpec

  • defaulted() — нужно указать название элемента, значение по умолчанию и описание
  • optional() — параметр является необязательным, необходимо указать название
  • required() — параметр является обязательным, необходимо указать название

Ключевое отличие — поведение при отсутствии целевого значения

  • defaulted() возвращает значение по умолчанию
  • optional() возвращает null-тип
  • required() выбрасывает исключение LensFailure

Обработка HTML-форм #

Предыдущий рассмотренный сценарий обработки форм не удовлетворяет требованиям по обработке данных:

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

Корректный подход к обработке форм #

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

Описание линз для формы #

HTML форма представляет собой набор полей, которые пользователь заполняет

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

  • FormField — описание требований к конкретному полю формы
  • Body.webForm — описание спецификации линзы для всей формы
  • WebForm — структура для хранения результатов проверки формы
val ageField = FormField.int().required("age")
val nameField = FormField.map(::Name, Name::value).optional("name")
val feedbackFormBody = Body.webForm(Validator.Feedback, nameField, ageField).toLens()
val invalidRequest = Request(GET, "/")
        .with(Header.CONTENT_TYPE of ContentType.APPLICATION_FORM_URLENCODED)
val invalidForm = feedbackFormBody(invalidRequest)
println(invalidForm.errors)
val validForm = strictFormBody(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" %}
    Поле присутствует: {{ model.form.fields["field"] | first }}
{% endif %}

Использование макросов Pebble #

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

{% macro input(type="text", name, placeholder="", form) %}
    <input
        type="{{ type }}"
        name="{{ name }}"
        placeholder="{{ placeholder }}"
        {% if form.fields contains name  %}
            value="{{ form.fields[name] | first }}"
        {% endif %}
    >
{% 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. Васильев, 2022, CC BY-SA 4.0, andrey@crafted.su