Васильев Андрей Михайлович, 2024
Версии презентации
Получение запроса, формирование ответа, отправка ответа клиенту
Вход — запрос от клиента, клиентом может выступать:
Выход — ответ серверного веб-приложения, включающий
Клиентское приложение должно всегда получать корректный ответ. Если был составлен некорректный запрос, то серверное приложение должно оповещать об этом
Серверное веб-приложение никогда не должно выводить внутренние сообщения об ошибках пользователю
Для каждого случая необходимо подготовить разумную стратегию обработки данных с предоставлением информации об ошибках
Рассмотрим порядок обработки маршрута с переменной внутри. Стратегия решения проблемы — отображение страниц с деталями информации по ошибкам
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()))
}
fun interface Filter : (HttpHandler) -> HttpHandler
Фильтры позволяют выполнить действия, общие для ряда запросов от пользователя:
Интерфейс фильтра (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
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
}
}
Пакет org.http4k.filter содержит описание фильтров, поставляемых с помощью http4k
Статус NOT_FOUND используется для оповещения пользователя о том, что по указанному запросу данные не были найдены
На уровне фильтра при возникновении ошибки 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) // Делегируем фильтру показ страницы
}
Страница с информацией об ошибке должна быть информативной:
Обработку ошибки получения данных потенциально можно делегировать слою представления, шаблонизатору 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
[ схема ":" ] [ // источник ] путь [ "?" запрос ] [ "#" фрагмент ]
=
&
http://some.domain/some/path?key1=value1&key2=value2&key3=value3
На первый взгляд параметры очень напоминают структуру данных словарь, т.к. состоят из пар ключ-значение
Однако есть важное отличие от словаря: клиент может передать несколько параметров с одинаковыми ключами и сервер должен обработать их все
Для всех данных, которые передаёт HTTP-клиент, необходимо выполнить две базовые проверки:
Подходы к обработке параметров, приходящих в запросах от пользователя:
uri
, содержащее объект класса Uriquery
содержит строку запроса полностьюfun query(query: String): Uri
позволяет создать новый объект Uri с новым значением запросаfun queries(): Parameters
возвращает набор пар ключ-значенияfun Uri.query(name: String, value: String?): Uri
позволяет добавить новый параметр к новому Uri-объектуfun Uri.removeQuery(name: String): Uri
позволяет удалить все параметры с указанным ключом, новое состояние сохраняется в возвращаемом объекте типа Urifun Parameters.findSingle(name: String): String?
позволяет найти первый параметр с указанным именемfun Parameters.findMultiple(name: String): List<String?>
позволяет найти все значения для параметра с указанным именем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, для их описания используются следующие элементы:
<form method="GET">
<input type="text" name="start">
<textarea name="description">
<input type="submit" value="Отправить">
</form>
Элемент ввода Input поддерживает множество типов данных, которые может указать пользователь: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#input_types
При работе с большим объёмом информации возникает ряд проблем:
Для обеспечения удобного доступа к большому объёму данных пользователю предоставляются инструменты для выполнения фильтрации, сортировки и постраничного ввода информации
Рассмотрим форму, в которой пользователь может указать максимальную стоимость товара и выбрать его категорию:
<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>
?maxCost=100&category=15
Заполненные поля формы одновременно сообщают о текущем фильтре и позволяют его изменить в будущем при необходимости
В рамках соответствующих HTTP-обработчиков необходимо параметры от пользователя передать шаблонизатору
Внутри элемента 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>
Даже после выполнения фильтрации объём данных скорее всего слишком большой для передачи пользователю, да и сервер не хочет отдавать все свои данные
Общепринятое решение — передача данных блоками, страницами
Для указания номера страницы логично использовать параметры запроса
При выполнении фильтрации и сортировки порядок элементов в массиве скорее всего не будет соответствовать изначальному:
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-обработчика необходимо иметь доступ к хранилищу данных, т.к. все действия приложения так или иначе сводятся к выборке или изменению набора данных
При использовании большого класса-хранилища
При написании теста обработчика ему необходимо предоставить тестовый дубль класса-хранилища, а может быть и не одного
Можно добавить промежуточный слой - классов операций
Если классы-операции предоставляют только один публичный метод, то в рамках теста их легко заместить тестовым дублем
При создании операции можно удобно описать её интерфейс:
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
}