Васильев Андрей Михайлович, 2024
Версии презентации
Обработку запроса от пользователя можно разделить на три логических этапа, следующих один за другим:
В рамках обработки 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 = ...
Для получения информации по предмету необходимо найти курс в списке, найти предмет, обратиться к полям класса для получения:
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)
Можно создать новую версию неизменяемых данных с нужным для нас состоянием
Сильная вложенность структур данных несёт следующие проблемы:
val class = speciality.courses[1].classes[5]
Мы можем ввести функции, позволяющие решить данные задачи:
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 линзы применяются для решения различных задач по взаимодействию со структурами данных:
Линзы библиотеки http4k определены в пакете org.http4k.lens
В рамках пакета определены два основных интерфейса:
Однако ввиду сложности линзы для их создания используются отдельные классы-строители
Для создания некоторых объектов пользователю необходимо указать множество аргументов конструктора, причём каждый из них тоже может быть сложным
Чтобы упросить создание конкретного компонента в строитель выделяют:
При использовании строителя создание объекта выглядит следующим образом:
Строители в библиотеке http4k тоже являются неизменямыми, поэтому при вызове методов создаются новые строители с изменённым состоянием
Для создания линзы необходимо указать следующие параметры:
Терминатор также создаёт объект линзы из соответствующего объекта-строителя
Классы-линзы описаны в пакете org.http4k.lens, строители описываются с помощью классов-спецификаций (наследников интерфейса LensSpec), сами линзы с помощью классов (наследников интерфейса Lens)
Поддерживается два типа линз:
Для стандартных классов http4k предлагаются двунаправленные линзы
Предположим, что мы создали линзу для получения непустой строковой переменной из пути:
val parameterLens = Path.nonBlankString().of("parameter")
Для получения параметра необходимо применить лизу к хранилищу данных, к запросу:
val parameter: String = parameterLens(request)
Потенциально для установки значения можно было бы написать:
val newRequest = parameterLens("newData", request)
Запись данных может быть полезна в рамках модульных тестов
Объект | Внутренний тип данных | Применимо для объектов | Количество | Необходимый |
---|---|---|---|---|
Параметры запроса | String | Request | Один или много | Обязательный или необязательный |
Заголовок | String | Request / Response | Один или много | Обязательный или необязательный |
Переменная пути | String | Request | Один | Обязательный |
Поле формы | String | WebForm | Один или много | Обязательный или необязательный |
Тело | String | Request / Response | Один | Обязательный |
Для каждого целевого элемента предоставляется объект-строитель, настроенный на взаимодействие с данным целевым элементом
Данный этап можно пропустить, если необходима строка, однако лучше всегда явно указывать тип для преобразования
http4k предлагает большой набор функций расширения, с помощью которых можно настраивать преобразование данных. Данные приходят от пользователя в строковом формате и их далее необходимо преобразовать в корректный внутренний формат
Рассмотрим список преобразований типов для объекта-запроса Query
enum()
— преобразование в перечислениеdateTime()
— преобразование переданных данных в тип LocalDateint()
— преобразование в целочисленный вариантnonEmptyString()
— проверка строки на существованиеnonBlankString()
— проверка строки на наличие печатных символовuuid()
— преобразование в тип данных UUIDДанные могут быть преобразованы в любой формат с помощью метода map()
, которому надо будет предоставить два метода: для преобразования из строки и преобразования в строку
Уровни необходимости параметра описываются в функциях-терминаторах в интерфейсах LensSpec и BiDiLensSpec
Ключевое отличие — поведение при отсутствии целевого значения
Терминатор создаёт на основе спецификации (строителя, 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-форма представляет собой набор полей, которые пользователь заполняет
В рамках http4k используются следующие типы данных:
Порядок использования элементов следующий:
Форма для отправки обратной связи
<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 можно воспользоваться макросами, которые можно несколько раз вызывать внутри шаблона
{% 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))
}
Можно выделить следующие категории файлов:
Неизменяемые файлы могут быть сделаны изменяемыми, но это может стать причиной проблем с безопасностью
Веб-сервер постоянно формирует новые документы и возвращает их клиенту по запросу
Иногда приложению требуется избыточное время для формирования документа:
Сигналом для формирования таких документов может быть:
Такие документы разумно сначала выгрузить на файловую систему, а затем предложить клиенту ссылку, по которой можно скачать документ
Для предоставления файлов с файловой системы можно воспользоваться стандартным механизмом раздачи документов с файловой системы
В некоторых сценариях взаимодействия между клиентом и веб-сервером необходимо загружать данные на веб-сервер:
В рамках протокола HTTP для отправки данных на сервер необходимо выполнить POST-запрос, в теле которого передать большой объём данных
Помимо самого тела запроса в заголовке желательно указать тип передаваемых данных с помощью заголовка Content-Type
Стандартным способом передачи файлов на сервер является их отправка с использованием HTML-форм:
file
multipart/form-data
Сформируем простейшую форму, содержащую:
<form action="post" enctype="multipart/form-data">
<div class="mb-3">
<label for="fileName" class="form-label">Название файла</label>
<input type="text" class="form-control" id="fileName" name="fileName">
</div>
<div class="mb-3">
<label for="file" class="form-label">Файл</label>
<input type="file" class="form-control" id="file" name="file">
</div>
<button type="submit" class="btn btn-primary">Отправить</button>
</form>
Поддержка кодировки форм поставляется в отдельной библиотеки, которую необходимо подключить к проекту в файле build.gradle
:
dependencies {
implementation("org.http4k:http4k-multipart")
}
Затем на уровне HTTP-обработчика можно извлечь данные на низком уровне с помощью класса org.http4k.core.MultipartFormBody
from(httpMessage: HttpMessage
)`fun field(name: String)
— получение значения поля по его названиюfun fields(name: String)
— получение всех значений поля по названиюfun fieldValue(name: String)
— получение значения поля в строковом видеfun fieldValues(name: String)
— получение всех значений в списке строкfun file(name: String)
— получение данных из файла по названию поляfun files(name: String)
— получение списка файлов, связанных с полемСформируем обработчик для простой формы, который будет извлекать данные из формы-примера
class MultiPartFormHandler() : HttpHandler {
override fun invoke(request: Request): Response {
val formData = MultipartFormBody.from(request)
val name: String? = formData.field("fileName")
val contents: MultipartFormFile? = formData.file("file")
return Response(FOUND).header("Location", "/done")
}
}
Очевидно, что данный обработчик не отвечает базовым требованиям:
Стоит учесть, что обработку содержимого файла (если она предполагается) следует выполнять в отдельном процессе, чтобы пользователь долго не ждал формирования ответа от сервера
Для работы с данными формами применяется другой набор классов:
Body.multipartForm()
Сформируем линзы для обработки формы:
val fileNameField = MultipartFormField.nonBlankString().required("fileName")
val fileField = MultipartFormFile.required("file")
val formLens = Body
.multipartForm(Validator.Feedback, fileNameField, fileField)
.toLens()
Далее внутри HTTP-обработчика данные линцы можно будет использовать аналогично линзам для обыкновенной формы
Серверное приложение обычно запускается на выделенном компьютере и функционирует постоянно (или согласно графику обслуживания пользователей)
Во время работы приложения могут возникнуть разные ситуации:
Обычно при продуктовом запуске у разработчиков нет возможности физически подключиться к серверу, чтобы изучить все детали некорректного поведения приложения
Выполним классификацию возможных причин критических ситуаций:
Задача отслеживания состояния серверов в целом решена: создан ряд продуктов, которые позволяют собрать информацию о состоянии вычислительной системы:
Данные собираются в единую систему, которая:
На каждом компьютере, за которым необходимо следить, запускается агент системы мониторинга, который собирает информацию и отправляет на центральный узел
Некоторые системы мониторинга позволяют отправлять метрики не только от собственных агентов, но также и от собственных приложений пользователя
Каждое приложение будет обладать важными характеристиками относительно задачи, которую оно решает, разработчикам стоит осмысленно подходить к метрикам
Общие метрики веб-сервера, запущенного на JVM:
При отсутствии системы монитроринга можно воспользоваться локальной альтернативой — журналированием работы приложения в файлы-журналы
Журналы в данном случае являются файлами, расположенными на файловой системе
Необходимо выполнять ротацию журналов — удаление излишних записей из журнала, когда всё хранилище заполнено или старые данные не нужны
Ввиду длинной жизни платформы JVM было предложено много решений для выполнения журналирования внутри приложения:
И каждый разработчик библиотеки может выбирать любое решение для записи своих журналов
Разработчики http4k решили, что они не будут использовать никакой фреймворк, но вот разработчики шаблонизатора Pebble решили использовать slf4j
Данная библиотека является достаточно гибкой и она используется при разработке Android-приложений, для неё существуют обёртки на языке Kotlin
В файле build.gradle
необходимо добавить зависимость от API
dependencies {
implementation("org.slf4j:slf4j-api:2.0.12")
}
class SomeHandler() : HttpHandler {
private val logger = LoggerFactory.getLogger(SomeHandler::class.java)
override fun invoke(request: Request): Response {
logger.atInfo().log("Обрабатываем очень важный запрос")
}
}
Журналирование общей информации по запросам можно доверить фильтру на уровне всего приложения, т.к. он имеет всю необходимую информацию
val logger = LoggerFactory.getLogger("ru.yarsu.WebApplication")
val loggingFilter = Filter { next: HttpHandler ->
{ request: Request ->
// Собрать данные о запросе
// Вычислить время обработки запроса
val result = next(request)
// Собрать данные об ответе
// Выполнить журналирование информации
logger.atInfo().setMessage("Request").addKeyValue("URI", request.uri).log()
result
}
}
Необходимо добавить зависимость от logback-classic в build.gradle:
dependencies {
implementation("ch.qos.logback:logback-classic:1.5.4")
}
В момент запуска приложения Logback пытается выполнить конфигурацию автоматически:
logback-test.scmo
из ресурсов приложенияlogback.xml
из ресурсов приложенияSCMO-файлы являются бинарным представлением конфигурации, оптимизированы для ускорения запуска
logs
, в файл app.log
<configuration>
<property name="HOME_LOG" value="logs/app.log"/>
<appender name="FILE-ROLLING" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${HOME_LOG}</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>logs/app.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
<!-- Максимальный размер архива журнала 10 МБ -->
<maxFileSize>10MB</maxFileSize>
<!-- Максимальный общий объём архива 100 МБ -->
<totalSizeCap>100MB</totalSizeCap>
<!-- Хранить не более 60 дней -->
<maxHistory>60</maxHistory>
</rollingPolicy>
<encoder class="ch.qos.logback.classic.encoder.JsonEncoder"/>
</appender>
<root level="debug">
<appender-ref ref="FILE-ROLLING"/>
</root>
</configuration>