Это продолжение статьи «Проблемы пакетной обработки запросов и их решения». Рекомендуется сначала ознакомиться с первой частью, так как в ней подробно описана суть задачи и некоторые подходы к ее решению. Здесь же мы рассмотрим другие методы.
Краткое повторение задачи
Есть чат для согласования документа с предопределенным набором участников. Сообщения содержат текст и файлы. И, как в обычных чатах, сообщения могут быть ответами (reply) и пересылками (forward).
Модель сообщения чата:
data class ChatMessage(
// nullable так как появляется только после persist
val id: Long? = null,
/** Ссылка на автора */
val author: UserReference,
/** Сообщение */
val message: String,
/** Ссылки на аттачи */
// из-за особенностей связки JPA+СУБД проще поддерживать и null, и пустые списки
val files: List<FileReference>? = null,
/** Если является ответом, то здесь будет оригинал */
val replyTo: ChatMessage? = null,
/** Если является пересылкой, то здесь будет оригинал */
val forwardFrom: ChatMessage? = null
)
Через Dependency Injection нам доступны реализации следующих внешних сервисов:
interface ChatMessageRepository {
fun findLast(n: Int): List<ChatMessage>
}
data class FileHeadRemote(
val id: FileReference,
val name: String
)
interface FileRemoteApi {
fun getHeadById(id: FileReference): FileHeadRemote
fun getHeadsByIds(id: Set<FileReference>): Set<FileHeadRemote>
fun getHeadsByIds(id: List<FileReference>): List<FileHeadRemote>
}
data class UserRemote(
val id: UserReference,
val name: String
)
interface UserRemoteApi {
fun getUserById(id: UserReference): UserRemote
fun getUsersByIds(id: Set<UserReference>): Set<UserRemote>
fun getUsersByIds(id: List<UserReference>): List<UserRemote>
}
Нам нужно реализовать REST-контроллер:
interface ChatRestApi {
fun getLast(n: Int): List<ChatMessageUI>
}
Где:
/** В таком виде отдаются ссылки на сущности для фронта */
data class ReferenceUI(
/** Идентификатор для url */
val ref: String,
/** Видимое пользователю название ссылки */
val name: String
)
data class ChatMessageUI(
val id: Long,
/** Ссылка на автора */
val author: ReferenceUI,
/** Сообщение */
val message: String,
/** Ссылки на аттачи */
val files: List<ReferenceUI>,
/** Если являтся ответом, то здесь будет оригинал */
val replyTo: ChatMessageUI? = null,
/** Если являтся пересылкой, то здесь будет оригинал */
val forwardFrom: ChatMessageUI? = null
)
В предыдущей части мы рассмотрели наивную реализацию сервиса, использующего пакетную обработку, и несколько способов ее ускорить. Эти способы очень просты, но их применение не обеспечивает достаточно хорошую производительность.
Увеличение пакетов
Главной проблемой наивных решений стал маленький размер пакетов.
Для того чтобы вызовы группировать в пакеты большего размера, нужно как-то накапливать запросы. Вот эта строка не подразумевает накопления запросов:
author = userRepository.getUserById(author).toFrontReference(),
Сейчас у нас в рантайме нет специального места для хранения перечня пользователей — он формируется постепенно. Это придется менять.
Для начала нужно отделить логику получения данных от маппинга в методе:
ChatMessage.toFrontModel:
private fun ChatMessage.toFrontModel(
getUser: (UserReference) -> UserRemote,
getFile: (FileReference) -> FileHeadRemote,
serializeMessage: (ChatMessage) -> ChatMessageUI
): ChatMessageUI =
ChatMessageUI(
id = id ?: throw IllegalStateException("$this must be persisted"),
author = getUser(author).toFrontReference(),
message = message,
files = files?.let {
it.map(getFile).map { it.toFrontReference() }
} ?: listOf(),
forwardFrom = forwardFrom?.let(serializeMessage),
replyTo = replyTo?.let(serializeMessage)
)
Получается, что эта функция зависит только от трех внешних функций (а не от целых классов, как было вначале).
После такой переделки тело функции не стало менее понятным, а контракт стал жестче (в этом есть как плюсы, так и минусы).
В действительности можно и не делать такое сужение контракта и оставить зависимости от интерфейсов. Главное, чтобы в них точно не было ничего лишнего, так как нам понадобится делать альтернативные реализации.
Поскольку функция serializeMessage похожа на рекурсивную, на первом шаге рефакторинга это и можно сделать в виде явной рекурсии:
class ChatRestController(
private val messageRepository: ChatMessageRepository,
private val userRepository: UserRemoteApi,
private val fileRepository: FileRemoteApi
) : ChatRestApi {
override fun getLast(n: Int) =
messageRepository.findLast(n)
.map { it.toFrontModel() }
Я сделал заглушку для метода toFrontModel, которая пока работает ровно так же, как в нашей первой наивной реализации (реализация всех трех внешних функций осталась прежней).
private fun ChatMessage.toFrontModel(): ChatMessageUI =
toFrontModel(
getUser = userRepository::getUserById,
getFile = fileRepository::getHeadById,
serializeMessage = {it.toFrontModel()}
)
Но нам нужно сделать так, чтобы функции getUser, getFile и serializeMessage работали эффективно, то есть слали запросы соответствующим сервисам пакетами нужных размеров (для каждого сервиса теоретически этот размер может быть разным) или вообще по одному запросу в каждый сервис, если разрешены безлимитные запросы.
Проще всего добиться такой группировки, если до начала обработки у нас будут на руках все нужные запросы. Для этого нужно перед вызовом toFrontModel собрать все необходимые ссылки, сделать пакетную обработку и потом уже использовать результат.
Еще можно попробовать схемы с накоплением запросов и постепенным их исполнением. Однако такие схемы потребуют асинхронного выполнения, а мы пока остановимся на синхронных.
Итак, чтобы начать использовать пакетную обработку, так или иначе придется узнать заранее как можно больше запросов (желательно все), которые нам придется сделать. Если речь про REST-контроллер, было бы прекрасно объединить запросы к каждому сервису по всей сессии.
Группировка всех вызовов
В некоторых ситуациях все данные, которые необходимы в рамках сессии, могут быть получены сразу и не вызовут проблем с ресурсами ни у инициатора запроса, ни у исполнителя. В таком случае мы можем не ограничивать размер пакета для вызова сервиса и получать сразу вообще все данные.
Еще одно допущение, которое сильно облегчает жизнь, — считать, что у инициатора хватит ресурсов на обработку всех данных. Запросы к внешним сервисам можно сл��ть и ограниченными пакетами, если они этого требуют.
Упрощение логики в этом случае касается того, как будут сопоставляться места, где данные нужны, с результатами вызовов. Если считать, что ресурсы инициатора сильно ограничены, и при этом пытаться минимизировать количество внешних вызовов, получится довольно сложная задача на оптимальное разрезание графа. Скорее всего, придется просто пожертвовать производительностью ради уменьшения потребления ресурсов.
Будем считать, что конкретно в нашем демо-проекте инициатор не особо ограничен в ресурсах, может получать все необходимые данные и хранить их до окончания сессии. Если будут проблемы с ресурсами, мы просто сделаем пагинацию поменьше.
Поскольку в моей практике именно такой подход наиболее востребован, дальнейшие примеры будут касаться этого варианта.
Можно выделить такие способы получения больших наборов запросов:
- реверс-инжиниринг;
- бизнес-эвристики;
- агрегаты в стиле DDD;
- проксирование и двойной вызов.
Пройдемся по всем вариантам на примере нашего проекта.
Реверс-инжиниринг
Сбор всех запросов
Поскольку у нас есть код реализации всех функций, участвующих в сборе информации и ее преобразовании для фронтенда, можно сделать реверс-инжиниринг и из этого кода понять, какие будут запросы:
class ChatRestController(
private val messageRepository: ChatMessageRepository,
private val userRepository: UserRemoteApi,
private val fileRepository: FileRemoteApi
) : ChatRestApi {
override fun getLast(n: Int) =
messageRepository.findLast(n)
.let { messages ->
// получаем полный список сообщений, включая forward и reply
val allMessages = messages.asSequence().flatMap {
sequenceOf(it, it.forwardFrom, it.replyTo).filterNotNull()
}.toSet()
val allUserReq = allMessages.map { it.author }
val allFileReq = allMessages.flatMap { it.files ?: listOf() }.toSet()
Все запросы собраны, теперь нужно сделать собственно пакетную обработку.
Для allUserReq и allFileReq делаем внешние запросы и группируем их по id. Если ограничений на размер пакета нет, то это будет выглядеть примерно так:
userRepository.getUsersByIds(allMessages.map { it.author }.toSet())
.associateBy { it.id }::get
fileRepository.getHeadsByIds(allMessages.flatMap { it.files ?: listOf() }.toSet())
.associateBy { it.id }::get
Если ограничение есть, то код примет такой вид:
val userApiChunkLimit = 100
allMessages.map { it.author }.asSequence().distinct()
.chunked(userApiChunkLimit, userRepository::getUsersByIds)
.flatten()
.associateBy { it.id }::get
К сожалению, в отличие от Stream, в Sequence нельзя легко перейти на параллельный запрос пакетов.
Если вы считаете параллельный запрос допустимым и нужным, можно сделать, например, так:
allMessages.map { it.author }.parallelStream().distinct()
.chunked(userApiChunkLimit, userRepository::getUsersByIds)
.flatten()
.associateBy { it.id }::get
Видно, что особо ничего не изменилось. В этом нам помогло использование некоторого количества Kotlin-магии:
fun <T, R> Stream<out T>.chunked(size: Int, transform: (List<T>) -> R): Stream<out R> =
batches(this, size).map(transform)
fun <T> Stream<out Collection<T>>.flatten(): Stream<T> =
flatMap { it.stream() }
fun <T, K> Stream<T>.associateBy(keySelector: (T) -> K): Map<K, T> =
collect(Collectors.toMap(keySelector, { it }))
Теперь осталось собрать все вместе:
override fun getLast(n: Int) =
messageRepository.findLast(n)
.let { messages ->
// получаем полный список сообщений, включая forward и reply
val allMessages = messages.asSequence().flatMap { message ->
sequenceOf(message, message.forwardFrom, message.replyTo)
.filterNotNull()
}.toSet()
messages.map(ValueHolder<(ChatMessage) -> ChatMessageUI>().apply {
value = memoize { message: ChatMessage ->
message.toFrontModel(
// для этого сервиса есть ограничение размера пакета, но возможны параллельные запросы
getUser = allMessages.map { it.author }.parallelStream().distinct()
.chunked(userApiChunkLimit, userRepository::getUsersByIds)
.flatten()
.associateBy { it.id }::get.orThrow { IllegalArgumentException("User $it") },
// для этого сервиса нет ограничений на размер пакета
getFile = fileRepository.getHeadsByIds(allMessages.flatMap { it.files ?: listOf() }.toSet())
.associateBy { it.id }::get.orThrow { IllegalArgumentException("File $it") },
// рекурсивный вызов этой же функции с мемоизацией
serializeMessage = value
)
}
}.value)
}
Пояснения и упрощения
Первое, что наверняка бросится в глаза, — это функция memoize. Дело в том, что функция serializeMessage почти наверняка будет вызываться для одних и тех же сообщений несколько раз (из-за reply и forward). Непонятно, зачем нам делать toFrontModel отдельно для каждого такого сообщения (в некоторых случаях это может быть нужно, но не в нашем). Поэтому можно сделать мемоизацию для функции serializeMessage. Реализуется это, например, так:
fun <A, R> memoize(func: (A) -> R) = func as? Memoize2 ?: Memoize2(func)
class Memoize2<A, R>(val func: (A) -> R) : (A) -> R, java.util.function.Function<A, R> {
private val cache = hashMapOf<A, R>()
override fun invoke(p1: A) = cache.getOrPut(p1, { func(p1) })
override fun apply(t: A): R = invoke(t)
}
Далее нам нужно сконструировать мемоизированную функцию serializeMessage, но при этом у нее внутри будет использоваться она же. Здесь важно использовать внутри именно тот же экземпляр функции, иначе вся мемоизация пойдет псу под хвост. Для разрешения этой коллизии используем класс ValueHolder, который просто хранит ссылку на значение (можно взять вместо него что-то стандартное, например AtomicReference). Чтобы сократить запись для рекурсии, можно сделать так:
inline fun <A, R> recursiveMemoize(crossinline func: (A, (A) -> R) -> R): (A) -> R =
ValueHolder<(A) -> R>().apply {
value = memoize { a -> func(a, value) }
}.value
Если вы смогли понять этот стрелочный силлогизм с первого раза — поздравляю, вы функциональный программист :-)
Теперь код будет выглядеть следующим образом:
override fun getLast(n: Int) =
messageRepository.findLast(n)
.let { messages ->
// получаем полный список сообщений, включая forward и reply
val allMessages = messages.asSequence().flatMap { message ->
sequenceOf(message, message.forwardFrom, message.replyTo)
.filterNotNull()
}.toSet()
// для этого сервиса есть ограничение размера пакета, но возможны параллельные запросы
val getUser = allMessages.map { it.author }.parallelStream().distinct()
.chunked(userApiChunkLimit, userRepository::getUsersByIds)
.flatten()
.associateBy { it.id }::get.orThrow { IllegalArgumentException("User $it") }
// для этого сервиса нет ограничений на размер пакета
val getFile = fileRepository.getHeadsByIds(allMessages.flatMap { it.files ?: listOf() }.toSet())
.associateBy { it.id }::get.orThrow { IllegalArgumentException("File $it") }
messages.map(recursiveMemoize { message, memoized: (ChatMessage) -> ChatMessageUI ->
message.toFrontModel(
getUser = getUser,
getFile = getFile,
// рекурсивный вызов этой же функции с мемоизацией
serializeMessage = memoized
)
})
Можно заметить еще orThrow, который определяется так:
/** Бросает указанный [exception], если функция возвращает null */
fun <P, R> ((P) -> R?).orThrow(exception: (P) -> Exception): (P) -> R =
{ p -> invoke(p).let { it ?: throw exception(p) } }
Если во внешних сервисах отсутствуют данные по нашим id и это считается легальной ситуацией, нужно обрабатывать ее как-то по-другому.
После этого исправления ожидается, что время выполнения getLast будет в районе 300 мс. Причем это время вырастет несильно, даже если запросы перестанут укладываться в ограничения на размер пакета (так как пакеты запрашиваются параллельно). Напомню, что наша цель-минимум — 500 мс, а нормальной работой можно будет считать 250 мс.
Параллельность
Но нужно двигаться дальше. Обращения к userRepository и fileRepository являются полностью независимыми, и их можно легко распараллелить, в теории приблизившись к 200 мс.
Например, через нашу функцию join:
override fun getLast(n: Int) =
messageRepository.findLast(n)
.let { messages ->
// получаем полный список сообщений, включая forward и reply
val allMessages = messages.asSequence().flatMap { message ->
sequenceOf(message, message.forwardFrom, message.replyTo)
.filterNotNull()
}.toSet()
join({
// для этого сервиса есть ограничение размера пакета, но возможны параллельные запросы
allMessages.map { it.author }.parallelStream().distinct()
.chunked(userApiChunkLimit, userRepository::getUsersByIds)
.flatten()
.associateBy { it.id }
}, {
// для этого сервиса нет ограничений на размер пакета
fileRepository.getHeadsByIds(allMessages.flatMap { it.files ?: listOf() }.toSet())
.associateBy { it.id }
}).let { (users, files) ->
messages.map(recursiveMemoize { message, memoized: (ChatMessage) -> ChatMessageUI ->
message.toFrontModel(
getUser = users::get.orThrow { IllegalArgumentException("User $it") },
getFile = files::get.orThrow { IllegalArgumentException("File $it") },
// рекурсивный вызов этой же функции с мемоизацией
serializeMessage = memoized
)
})
}
}
Как показывает практика, на исполнение затрачивается в районе 200 мс, и очень важно, что с увеличением количества сообщений время особо не растет.
Проблемы
В целом код стал, конечно, менее читабельным, чем наша наивная первая версия, но хорошо, что сама сериализация (реализация toFrontModel) почти не изменилась и осталась вполне читабельной. Вся логика хитрой работы с внешними сервисами живет в одном месте.
Минус этого подхода в том, что наша абстракция протекает.
Если нам понадобится внести изменения в toFrontModel, почти наверняка придется вносить изменения и в функцию getLast, что нарушает принцип подстановки Барбары Лисков (Liskov Substitution Principle).
Например, мы договорились расшифровывать приложенные файлы только в основных сообщениях, но не в ответах и пересылках (reply/forward), или только в ответах и пересылках первого уровня. В таком случае после внесения изменений в код toFrontModel придется сделать соответствующие исправления и в коде сбора запросов для файлов. Причем исправление будет нетривиальным:
fileRepository.getHeadsByIds(
allMessages.flatMap { it.files ?: listOf() }.toSet()
)
И здесь мы плавно подходим к еще одной проблеме, тесно связанной с предыдущей: правильность работы кода в целом зависит от грамотности проведенного реверс-инжиниринга. В некоторых сложных случаях код может сработать неправильно именно из-за некорректной работы сбора запросов. Нет никакой гарантии, что у вас получится быстро придумать юнит-тесты, которые покроют все такие хитрые ситуации.
Выводы
Плюсы:
- Очевидный способ предварительного получения запросов, который легко отделяется от основного кода.
- Почти полное отсутствие накладных расходов памяти и времени, связанное с использованием только тех данных, которые все равно были бы получены.
- Хорошее масштабирование и возможность построить сервис, который в теории будет отвечать за предсказуемое время вне зависимости от размера запроса извне.
Минусы:
- Довольно сложный код самой пакетной обработки.
- Большая и ответственная работа по анализу запросов в существующей реализации.
- Протекающая абстракция и, как следствие, хрупкость всей схемы по отношению к изменениям в реализации.
- Сложности в поддержке: ошибки в блоке предсказания запросов трудно отличить от ошибок в основном коде. В идеале нужно применять в два раза больше юнит-тестов, поэтому и разбирательства с ошибками в продакшене будут в два раза сложнее.
- Соблюдение принципов SOLID при написании кода: код должен быть готов к отчуждению логики пакетной обработки. Само по себе внедрение этих принципов даст некоторые преимущества, так что данный минус самый несущественный.
Важно отметить, что использовать этот метод можно и не делая реверс-инжиниринга как такового. Нам нужно получить контракт getLast, от которого зависит контракт предварительного расчета запросов (далее — prefetch). В данном случае мы сделали это, рассмотрев реализацию getLast (реверс-инжиниринг). Однако при таком подходе возникают сложности: правка этих двух кусков кода всегда должна быть синхронной, а обеспечить это никак невозможно (вспомните hashCode и equals, там ровно то же самое). Следующий подход, который я хотел бы показать, как раз призван решить эту проблему (или хотя бы смягчить).
Бизнес-эвристики
Решение проблемы контракта
Что если оперировать не точным контрактом и, следовательно, точным набором запросов, а примерным? Причем мы построим примерный набор так, что он будет строго включать точный и базироваться на особенностях предметной области.
Таким образом, вместо зависимости контракта prefetch от getLast установим зависимость их обоих от какого-то общего контракта, который будет диктоваться пользователем. Основная сложность будет в том, чтобы как-то овеществить этот общий контракт в виде кода.
Поиск полезных ограничений
Давайте попробуем сделать это на нашем примере.
В нашем случае есть следующие бизнес-особенности:
- список участников чата предопределен;
- чаты абсолютно изолированы друг от друга;
- вложенность цепочек reply/forward небольшая (~2–3 сообщения).
Из первого ограничения следует, что не нужно бегать по сообщениям, смотреть, какие там есть пользователи, выбирать уникальных и по ним делать запрос. Можно просто сделать запрос по предопределенному списку. Если вы согласились с этим утверждением, значит, я вас поймал.
На самом деле все не так просто. Список может быть предопределен, но в нем могут быть тысячи пользователей. Такие вещи необходимо уточнять. В нашем случае участников чата, как правило, будет два — три, редко больше. Так что вполне допустимо получать данные по ним всем.
Далее, если список пользователей чата предопределен, но этой информации нет в сервисе пользователей (что очень вероятно), то толку от такой информации тоже не будет. Мы сделаем лишний запрос списка пользователей чата, а потом все равно придется делать запрос(-ы) к сервису пользователей.
Допустим, что информация о связи пользователей и чата хранится в сервисе пользователей. В нашем случае это так, поскольку связь определяется правами пользователя. Тогда для пользователей получится такой prefetch-код:
Здесь может показаться удивительным то, что мы не передаем никакого идентификатора чата. Я сделал это намеренно, чтобы не загромождать код примеров.
Из второго ограничения, на первый взгляд, ничего не следует. Во всяком случае, у меня так и не получилось вывести из него что-то полезное.
Третье ограничение мы уже использовали ранее. Оно может оказать существенное влияние на то, как мы будем хранить и получать цепочки сообщений. Не станем развивать эту тему, так как к REST-контроллеру и пакетной обработке это не имеет отношения.
Что же делать с файлами? Очень хочется получить список всех файлов чата одним простым запросом. По условиям API нам нужны только заголовки файлов, без тел, так что это не выглядит ресурсоемкой и опасной задачей для вызывающей стороны.
С другой стороны, нужно помнить, что мы получаем не все сообщения чата, а только последние N, и легко может оказаться, что они не содержат вообще никаких файлов.
Здесь не может быть универсального ответа: все сильно зависит от бизнес-специфики и вариантов использования. При создании продуктового решения можно попасть впросак, если заложить эвристику под один вариант использования, а потом пользователи будут работать с функционалом другим способом. Для демонстраций и пресейлов это хороший вариант, но сейчас мы пытаемся написать честный продакшен-сервис.
Так что, увы, делать для файлов бизнес-эвристику здесь можно будет только по итогам эксплуатации и сбора статистики (либо после экспертной оценки).
Поскольку хочется все-таки как-то применить наш метод, допустим, что статистика показала следующее:
- Типичная цепочка начинается с сообщения, включающего один или несколько файлов, за которым следуют ответные сообщения (reply) без файлов.
- Почти все сообщения входят в типичные цепочки.
- Ожидаемое количество уникальных файлов в рамках одного чата ~20.
Отсюда следует, что для отображения почти всех сообщений понадобится получить заголовки каких-то файлов (потому что так устроен ChatMessageUI) и что общее количество файлов невелико. В таком случае разумным выглядит получение всех файлов чата одним запросом. Для этого в наш API для файлов придется добавить следующее:
fun getHeadsByChat(): List<FileHeadRemote>
Метод getHeadsByChat не выглядит надуманным и сделанным чисто из-за нашего желания оптимизировать производительность (хотя это тоже вполне себе обоснование). Довольно часто в чатах с файлами пользователи хотят увидеть все использованные файлы, причем в порядке их добавления (поэтому используем List).
Реализация такой явной связи потребует хранения дополнительной информации в файловом сервисе или в нашем приложении. Все зависит от того, в чьей зоне ответственности, как мы считаем, должна храниться эта избыточная информация о связи файла с чатом. Избыточная она потому, что с файлом уже связано сообщение, а оно в свою очередь связано с чатом. Можно не использовать денормализацию, а извлекать эту информацию на лету из сообщений, то есть внутри SQL получать сразу все файлы по всему чату (это в нашем приложении) и запрашивать их все сразу у файлового сервиса. Такой вариант будет работать похуже, если сообщений в чате окажется много, но зато нам не понадобится денормализация. Я бы оба варианта скрыл за getHeadsByChat.
Код получился такой:
override fun getLast(n: Int) =
messageRepository.findLast(n)
.let { messages ->
join(
{ userRepository.getUsersByChat().associateBy { it.id } },
{ fileRepository.getHeadsByChat().associateBy { it.id } }
)
.let { // здесь ничего не изменилось
}
}
Видно, что по сравнению с предыдущим вариантом изменилось очень мало и изменения коснулись только части с prefetch, что замечательно.
Код prefetch стал намного короче и понятнее.
Время исполнения не изменилось, что логично, так как количество запросов осталось прежним. Теоретически возможны случаи, когда масштабирование будет лучше, чем у честного реверс-инжиниринга (только за счет убирания звена сложного расчета). Однако в равной степени вероятны противоположные ситуации: эвристики гребут слишком много лишнего. Как показывает практика, если удается придумать адекватные эвристики, то особых перемен во времени исполнения быть не должно.
Однако это еще не все. Мы не учли, что теперь получение детальных данных по пользователям и файлам не связано с получением сообщений и запросы можно запустить параллельно:
Такой вариант дает стабильные 100 мс на запрос.
Ошибки эвристик
Что если при использовании эвристик набор запросов окажется не больше, а чуть меньше, чем должен быть? Для большинства вариантов такие эвристики подойдут, но будут исключения, ради которых придется делать отдельный запрос. В моей практике такого рода решения оказывались неудачными, так как каждое исключение сильно сказывалось на производительности, и в конце концов какой-то пользователь делал запрос, полностью состоящий из исключений. Я бы сказал, что в таких ситуациях лучше использовать реверс-инжиниринг, даже если алгоритм сбора запросов получается жуткий и нечитаемый, но, конечно, все зависит от критичности сервиса.
Выводы
Плюсы:
- Логика бизнес-эвристик легко читается и обычно тривиальна. Это хорошо для того, чтобы понять границы применимости, верифицировать и изменять контракт prefetch.
- Масштабируемость такая же хорошая, как у реверс-инжиниринга.
- Уменьшается связность кода по данным, что может привести к лучшей параллелизации кода.
- Логика prefetch, как и основная логика REST-контроллера, базируется на требованиях. Это слабый плюс, если требования часто меняются.
Минусы:
- Из требований не так легко вывести эвристики для предсказаний запросов. Могут понадобиться уточнения требований, причем до такой степени, которая плохо совместима с agile.
- Можно получить лишние данные.
- Для обеспечения эффективной работы контракта prefetch, вероятно, понадобится денормализация хранения данных. Это слабый минус, так как эти оптимизации следуют из бизнес-логики и потому, скорее всего, будут востребованы разными процессами.
Из нашего примера можно сделать вывод, что применить данный подход очень сложно и овчинка не стоит выделки. На самом деле в реальных бизнес-проектах количество ограничений огромно и из этой кучи часто удается достать что-то полезное, что позволяет партиционировать данные или предсказывать статистику. Главный плюс этого подхода в том, что используемые ограничения трактуются бизнесом, поэтому они легко понимаются и валидируются.
Обычно самой большой проблемой при попытке использовать этот подход оказывается разделение деятельности. Разработчик должен хорошо погрузиться в бизнес-логику и задавать уточняющие вопросы аналитику, что требует определенного уровня инициативности.
Агрегаты в стиле DDD
В больших проектах часто можно увидеть использование практик DDD, поскольку они позволяют эффективно структурировать код. Не обязательно использовать в проекте все шаблоны DDD — иногда можно получить хорошую отдачу даже от внедрения одного. Рассмотрим такое понятие DDD, как агрегат. Агрегатом называют объединение логически связанных сущностей, работа с которыми осуществляется только через корень агрегата (обычно это сущность, которая является вершиной графа связности сущностей).
С точки зрения получения данных главное в агрегате то, что вся логика работы со списками сущностей находится в одном месте — агрегате. Есть два подхода к тому, что следует передавать в агрегат при его конструировании:
Передаем в агрегат функции для получения внешних данных. Логика определения необходимых данных живет внутри агрегата.
Передаем все необходимые данные. Логика определения необходимых данных живет вне агрегата.
Выбор подхода во многом зависит от того, насколько легко можно вынести prefetch за рамки агрегата. Если логика prefetch базируется на бизнес-эвристиках, то обычно ее несложно отделить от агрегата. Выносить за рамки агрегата логику, основанную на анализе его использования (реверс-инжиниринг), может оказаться опасным, так как мы разносим логически связанный код по разным классам.
Логика укрупнения запросов внутри агрегата
Попробуем набросать агрегат, который бы соответствовал понятию «чат». Наши классы ChatMessage, UserReference, FileReference соответствуют модели хранения, поэтому их можно было бы переименовать с каким-то соответствующим префиксом, но у нас проект маленький, поэтому оставим как есть. Агрегат назовем Chat, а его составляющие — ChatPage и ChatPageMessage:
interface Chat {
fun getLastPage(n: Int): ChatPage
}
interface ChatPage {
val messages: List<ChatPageMessage>
}
data class ChatPageMessage(
val id: Long,
val author: UserRemote,
val message: String,
val files: List<FileHeadRemote>,
val replyTo: ChatPageMessage?,
val forwardFrom: ChatPageMessage?
)
Пока что получается довольно много бессмысленного дублирования. Это связано с тем, что наша предметная модель похожа на модель хранения и они обе похожи на модель для фронтенда.
Я использую классы FileHeadRemote и UserRemote напрямую, чтобы не писать лишнего кода, хотя обычно в домене стоит избегать прямого использования таких классов.
Если использовать такой агрегат, наш REST-контроллер можно переписать так:
class ChatRestController(
private val chat: Chat
) : ChatRestApi {
override fun getLast(n: Int) =
chat.getLastPage(n).toFrontModel()
private fun ChatPage.toFrontModel() =
messages.map { it.toFrontModel() }
private fun ChatPageMessage.toFrontModel(): ChatMessageUI =
ChatMessageUI(
id = id,
author = author.toFrontReference(),
message = message,
files = files.toFrontReference(),
forwardFrom = forwardFrom?.toFrontModel(),
replyTo = replyTo?.toFrontModel()
)
}
Этот вариант во многом напоминает нашу первую наивную реализацию, но при этом имеет важное преимущество: контроллер больше не занимается получением данных напрямую и не зависит от классов, связанных с хранением данных, а зависит только от агрегата, который задан через интерфейсы. Таким образом, и логики prefetch больше нет в контроллере. Контроллер занимается только преобразованием агрегата в модель фронтенда, что дает нам соблюдение принципа единственной ответственности (Single Responsibility Principle, SRP).
К сожалению, для всех описанных в агрегате методов придется написать реализацию.
Попробуем просто сохранить логику контроллера, реализованную при использовании бизнес-эвристик.
class ChatImpl(
private val messageRepository: ChatMessageRepository,
private val userRepository: UserRemoteApi,
private val fileRepository: FileRemoteApi
) : Chat {
override fun getLastPage(n: Int) = object : ChatPage {
override val messages: List<ChatPageMessage>
get() =
runBlocking(IO) {
val prefetch = async(
{ userRepository.getUsersByChat().associateBy { it.id } },
{ fileRepository.getHeadsByChat().associateBy { it.id } }
)
withContext(IO) { messageRepository.findLast(n) }
.map(
prefetch.await().let { (users, files) ->
recursiveMemoize { message, memoized: (ChatMessage) -> ChatPageMessage ->
message.toDomainModel(
getUser = users::get.orThrow { IllegalArgumentException("User $it") },
getFile = files::get.orThrow { IllegalArgumentException("File $it") },
// рекурсивный вызов этой же функции с мемоизацией
serializeMessage = memoized
)
}
}
)
}
}
}
private fun ChatMessage.toDomainModel(
getUser: (UserReference) -> UserRemote,
getFile: (FileReference) -> FileHeadRemote,
serializeMessage: (ChatMessage) -> ChatPageMessage
) = ChatPageMessage(
id = id ?: throw IllegalStateException("$this must be persisted"),
author = getUser(author),
message = message,
files = files?.map(getFile) ?: listOf(),
forwardFrom = forwardFrom?.let(serializeMessage),
replyTo = replyTo?.let(serializeMessage)
)
Здесь получилось, что в самой функции getLastPage живет стратегия получения данных, включая prefetch, а функция toDomainModel чисто техническая и отвечает за преобразование хранимых моделей в модель предметной области.
Параллельные вызовы userRepository, fileRepository и messageRepository я переписал в более привычном для Kotlin виде. Надеюсь, что понятность кода из-за этого не пострадала.
В целом такой метод уже вполне работоспособен, производительность при его применении будет такой же, как при простом использовании реверс-инжиниринга или бизнес-эвристик.
Логика укрупнения запросов вне агрегата
В процессе создания агрегата мы сразу же столкнемся с проблемой: для конструирования ChatPage размер страницы нужно будет задавать как константу при создании Chat, а не передавать его в getLast(), как обычно. Придется поменять сам интерфейс агрегата:
interface Chat {
fun getPage(): ChatPage
}
Поскольку у нас есть дочитка остальных сообщений и мы твердо хотим получать все данные за рамками агрегата, нам придется вообще отказаться от агрегата уровня Chat и сделать корнем ChatPage:
class ChatPageImpl(
private val messageData: List<ChatMessage>,
private val userData: List<UserRemote>,
private val fileData: List<FileHeadRemote>
) : ChatPage {
override val messages: List<ChatPageMessage>
get() =
messageData.map(
(userData.associateBy { it.id } to fileData.associateBy { it.id })
.let { (users, files) ->
recursiveMemoize { message, self: (ChatMessage) -> ChatPageMessage ->
message.toDomainModel(
getUser = users::get.orThrow(),
getFile = files::get.orThrow(),
// рекурсивный вызов этой же функции с мемоизацией
serializeMessage = self
)
}
}
)
}
Далее необходимо создать код prefetch, отдельный от агрегата:
fun chatPagePrefetch(
pageSize: Int,
messageRepository: ChatMessageRepository,
userRepository: UserRemoteApi,
fileRepository: FileRemoteApi
) =
runBlocking(IO) {
async(
{ userRepository.getUsersByChat() },
{ fileRepository.getHeadsByChat() },
{ messageRepository.findLast(pageSize) }
)
}
Теперь для того, чтобы создать агрегат, нужно его состыковать с prefetch. В DDD такого рода оркестрациями занимаются Application Services.
class ChatService(
private val messageRepository: ChatMessageRepository,
private val userRepository: UserRemoteApi,
private val fileRepository: FileRemoteApi
) {
private fun chatPagePrefetch(pageSize: Int) =
runBlocking(IO) {
async(
{ messageRepository.findLast(pageSize) },
{ userRepository.getUsersByChat() },
{ fileRepository.getHeadsByChat() }
).await()
}
fun getLastPage(n: Int): ChatPage =
chatPagePrefetch(n)
.let { (messageData, userData, fileData) ->
ChatPageImpl(messageData, userData, fileData)
}
}
Ну а контроллер особо не изменится, нужно только вместо Chat::getLastPage использовать ChatService::getLastPage. То есть код изменится так:
class ChatRestController(
private val chat: ChatService
) : ChatRestApi
Выводы:
- Логику prefetch можно поместить как внутрь агрегата, так и в отдельное место.
- Если логика prefetch сильно связана с внутренней логикой агрегата, лучше не выносить ее наружу, так как это может нарушить инкапсуляцию. Я лично не вижу особого смысла выносить prefetch за пределы агрегата, поскольку это сильно ограничивает возможности и увеличивает неявную связность кода.
- На производительность пакетной обработки сама по себе организация агрегатов влияет положительно, так как контроля над тяжелыми запросами становится больше и место для логики prefetch становится вполне определенным.
В следующей главе мы рассмотрим такой вариант реализации prefetch, который невозможно реализовать в отрыве от основной функции.
Проксирование и двойной вызов
Решение проблемы контракта
Как мы уже разобрались в предыдущих частях, основная проблема контракта prefetch в том, что он сильно связан с контрактом функции, для которой он должен подготовить данные. Если быть более точным, то он зависит от того, какие данные могут понадобиться основной функции. Что если мы не будем пытаться предсказывать, а попробуем сделать реверс-инжиниринг средствами самого кода? В простых ситуациях нам может помочь подход проксирования, широко используемый в тестировании. Такие библиотеки, как Mockito, генерируют классы с имплементаций интерфейсов, которые могут в том числе накапливать информацию о вызовах. Похожий подход используется в нашей библиотеке.
Если вызвать основную функцию с проксированными репозиториями и собрать информацию о необходимых данных, то потом можно будет эти данные получить в виде пакета и повторно вызвать основную функцию для получения финального результата.
Основное условие следующее: запрашиваемые данные не должны влиять на последующие запросы. Прокси будет возвращать не реальные данные, а только какие-то заглушки, поэтому все ветвления и получения связанных данных отпадают.
В нашем случае это означает, что бесполезно проксировать messageRepository, поскольку по результатам запроса сообщений и делаются дальнейшие запросы. Это не проблема, так как к messageRepository у нас всего один запрос, так что никакой пакетной обработки здесь и не требуется.
Поскольку проксировать мы будем простые функции UserReference->UserRemote и FileReference->FileHeadRemote, то накапливать нужно просто два списка аргументов.
В итоге получаем следующее:
class ChatRestController(
private val messageRepository: ChatMessageRepository,
private val userRepository: UserRemoteApi,
private val fileRepository: FileRemoteApi
) : ChatRestApi {
override fun getLast(n: Int): List<ChatMessageUI> {
val messages = messageRepository.findLast(n)
// функция получения списка сообщений
fun transform(
getUser: (UserReference) -> UserRemote,
getFile: (FileReference) -> FileHeadRemote
): List<ChatMessageUI> =
messages.map(
recursiveMemoize { message, self ->
message.toFrontModel(getUser, getFile, self)
}
)
// накапливаем запросы
val userIds = mutableSetOf<UserReference>()
val fileIds = mutableSetOf<FileReference>()
transform(
{ userIds += it; UserRemote(0L, "") },
{ fileIds += it; FileHeadRemote(0L, "") }
)
return runBlocking(IO) {
// получаем данные по всем запросам сразу
async(
{ userRepository.getUsersByIds(userIds).associateBy { it.id }::get.orThrow() },
{ fileRepository.getHeadsByIds(fileIds).associateBy { it.id }::get.orThrow() }
).await().let { (getUser, getFile) ->
transform(getUser, getFile)
}
}
}
}
Если измерить производительность, получится, что при данном подходе она не хуже, чем при использовании методов реверс-инжиниринга, хотя мы вызываем функцию два раза. Это связано с тем, что по сравнению со временем выполнения внешних запросов, временем выполнения функции преобразования можно пренебречь (в нашем случае).
Если сравнивать с производительностью при использовании бизнес-эвристик, то в нашем случае накопление запросов окажется менее эффективным. Но нужно учитывать, что не всегда удается найти такие хорошие эвристики. Например, если количество пользователей в чате будет большим, как и количество файлов, и при этом файлы будут прикрепляться к сообщениям редко, то наш алгоритм на бизнес-эвристиках сразу же начнет проигрывать честному получению списку запросов.
Выводы
Плюсы:
- Для реализации prefetch не нужен реверс-инжиниринг кода получения данных.
- В большинстве случаев изменения основной функции не повлияют на prefetch.
- Масштабируемость такая же хорошая, как у реверс-инжиниринга.
Минусы:
- Требуется независимость следующих запросов от результатов предыдущих.
- Редко используется, поэтому логика не так очевидна.
Несмотря на кажущуюся экзотичность, накопление запросов через проксирование и повторный вызов вполне применимо в ситуациях, когда логика основной функции не завязана на получаемые данные. Основная сложность здесь такая же, как при реверс-инжиниринге: мы закладываемся на текущую реализацию функции, хотя и в гораздо меньшей степени (только на тот факт, что следующие запросы не зависят от результатов предыдущих запросов).
Производительность упадет незначительно, зато в коде prefetch не нужно будет учитывать всех нюансов реализации основной функции.
Можно использовать такой подход, когда не получается построить хороших бизнес-эвристик для предсказания запросов, а связность prefetch и функции хочется уменьшить.
Заключение
Использование пакетной обработки не так просто, как кажется на первый взгляд. Думаю, все шаблоны проектирования обладают этим свойством (вспомните кэширование).
Для эффективной пакетной обработки запросов вызывающей стороне важно собрать вместе как можно больше запросов, что часто затрудняется структурой приложения. Выхода два: либо проектировать приложение в расчете на эффективную работу с данными (очень может быть, что это приведет к реактивному устройству приложения), либо, как это часто бывает, пытаться внедрить пакетную обработку в существующее приложение без значительной его перестройки.
Самый очевидный способ собрать запросы в кучу — это реверс-инжиниринг существующего кода в поисках тяжелых запросов. Главным недостатком этого подхода будет увеличение неявной связности кода. Альтернатива — задействовать информацию о бизнес-особенностях для того, чтобы разделить данные на порции, которые часто используются совместно и целиком. Иногда для такого эффективного разделения придется денормализовать хранение, но зато, если такое получится осуществить, логика пакетной обработки будет определятся предметной областью, что хорошо.
Менее очевидный способ получить все запросы — реализовать два прохода. На первом этапе собираем все необходимые запросы, на втором работаем с уже полученными данными. Применимость такого подхода ограничена требованием независимости запросов друг от друга.
<!doctype html>
<html lang="ru" class="no-js no-touch ">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,minimum-scale=1,initial-scale=1">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta name="yandex-verification" content="3019a35aeda6b45d" />
<meta name="robots" content="noindex"/>
<script type="text/javascript">
var html = document.getElementsByTagName('html')[0];
html.className = html.className.replace('no-js', '');
window.React = {};
window.metrics = [];
window.TEST_ENV = false;
window.isSuperuser = false;
window.isStaff = false;
window.isHeadTeacher = false;
</script>
<script type="text/javascript">
window.DEBUG_COUNTERS = true;
var yaParams = {
'course_title': 'b',
'ab': 'b',
'features': JSON.parse('{"category-catalog-redirect": true, "landing-price-mode-switcher": true, "register-instead-of-start-test": true, "main-page-redesign": true, "subscription_genus_basic": true, "adblender": true, "greenlight": true, "phone": true, "professions": true, "course_enrol": true, "new_lessons_page": true, "jivosite": false, "course-page-single-screen": true, "right-price": false, "course_page_header_footer_new": true, "submit-application": false, "prof_dev_certificate": false, "assessment-react": true, "new-events-calendar": true, "new-pre-assessment-screen": true, "recommended_courses": true, "tinkoff_payment": true, "payment-page-3-front-refactor": true, "installment-calculator": true, "boomstream-player": true, "finsystems": true, "new-reviews": true}')
};
</script>
<!-- MindBox JavaScript SDK --->
<script>
mindbox = window.mindbox || function() { mindbox.queue.push(arguments); };
mindbox.queue = mindbox.queue || [];
mindbox('create');
</script>
<script src="https://api.mindbox.ru/scripts/v1/tracker.js" async></script>
<!-- End MindBox JavaScript SDK --->
<!-- Yandex.Metrika counter -->
<script type="text/javascript">
(function (ids) {
function yamInit(d, w, c, id) {
(w[c] = w[c] || []).push(function () {
try {
const metrika = new Ya.Metrika2({
id: id,
params: window.yaParams || {},
clickmap: true,
trackLinks: true,
accurateTrackBounce: true,
webvisor: true
});
w.yaMetriks.push(metrika);
w['yaCounter' + id] = metrika;
} catch (e) {
}
});
var n = d.getElementsByTagName('script')[0],
s = d.createElement('script'),
f = function () {
n.parentNode.insertBefore(s, n);
};
s.type = 'text/javascript';
s.async = true;
s.src = 'https://mc.yandex.ru/metrika/tag.js';
if (w.opera == '[object Opera]') {
d.addEventListener('DOMContentLoaded', f, false);
} else {
f();
}
}
window['yaMetriks'] = [];
(Array.isArray(ids) ? ids : [ids]).forEach(id => {
if (id) {
yamInit(document, window, 'yandex_metrika_callbacks2', id)
}
})
})([34531570, 82755226, 93715742])
</script>
<!-- /Yandex.Metrika counter -->
<script type="text/javascript" id="advcakeAsync">
(function (a) {
var b = a.createElement('script');
b.async = 1;
b.src = '//0gs25f.ru/';
a = a.getElementsByTagName('script')[0];
a.parentNode.insertBefore(b, a)
})(document);
window.advcake_data = window.advcake_data || [];
</script>
<script>
!(function (w, d, t) {
w.TiktokAnalyticsObject = t;
var ttq = (w[t] = w[t] || []);
(ttq.methods = [
'page',
'track',
'identify',
'instances',
'debug',
'on',
'off',
'once',
'ready',
'alias',
'group',
'enableCookie',
'disableCookie'
]),
(ttq.setAndDefer = function (t, e) {
t[e] = function () {
t.push([e].concat(Array.prototype.slice.call(arguments, 0)));
};
});
for (var i = 0; i < ttq.methods.length; i++)
ttq.setAndDefer(ttq, ttq.methods[i]);
(ttq.instance = function (t) {
for (var e = ttq._i[t] || [], n = 0; n < ttq.methods.length; n++)
ttq.setAndDefer(e, ttq.methods[n]);
return e;
}),
(ttq.load = function (e, n) {
var i = 'https://analytics.tiktok.com/i18n/pixel/events.js';
(ttq._i = ttq._i || {}),
(ttq._i[e] = []),
(ttq._i[e]._u = i),
(ttq._t = ttq._t || {}),
(ttq._t[e] = +new Date()),
(ttq._o = ttq._o || {}),
(ttq._o[e] = n || {});
var o = document.createElement('script');
(o.type = 'text/javascript'),
(o.async = !0),
(o.src = i + '?sdkid=' + e + '&lib=' + t);
var a = document.getElementsByTagName('script')[0];
a.parentNode.insertBefore(o, a);
});
ttq.load("C4IDL5C17T561FR1EMKG");
ttq.page();
})(window, document, 'ttq');
</script>
<script>
window.vkAsyncInit = function () {
VK.Retargeting.Init("VK-RTRG-410987-bLXUv");
VK.Retargeting.Hit();
}
</script>
<script src="//vk.com/js/api/openapi.js?159" async></script>
<noscript>
<img src="https://vk.com/rtrg?p=VK-RTRG-410987-bLXUv"
style="position:fixed; left:-999px;" alt="" />
</noscript>
<!-- rick.ai/q -->
<script type="text/javascript">
(function(e) {
var t = e.createElement("script");
t.src = "https://store-b2b.ru/tag.js?id=wsse7xcbtr07r1";
t.type = "module";
t.async = true;
t.crossorigin = "anonymous";
e.head.appendChild(t)
})(document)
</script>
<script type="text/javascript" nomodule src="https://store-b2b.ru/tag.js?id=wsse7xcbtr07r1&nomodule"></script>
<!-- end rick.ai/q -->
<script type="text/javascript">
!(function (n, e, t, r, a, s) {
function i(n, r) {
const a = e.createElement(t),
s = e.getElementsByTagName(t)[0];
(a.async = 1),
(a.src = n),
(a.onerror = r),
s.parentNode.insertBefore(a, s);
}
(n.SalesNinja = ['init', 'start', 'onPersonalization', 'reachGoal'].reduce(
(e, t) => {
return (
(e[t] = function () {
const e = Array.prototype.slice.call(arguments);
e.unshift(t), n[r].apply(0, e);
}),
e
);
},
{ k: r, ready: !1 }
)),
(n[r] = function () {
let e,
t,
a = new Promise((n, r) => {
(e = n), (t = r);
});
return (
(n[r].r = n[r].r || []).push({ s: e, f: t }),
(n[r].c = n[r].c || []).push(arguments),
a
);
}),
i(a, () => {
i(s);
});
})(
window,
document,
'script',
'ninja',
'https://cdn.sales-ninja.me/userBundle.js',
'https://bundle.sales-ninja.me/userBundle.js'
);
ninja('init', 'c20cc0ff-6d2f-42ac-9b66-1103c735a13a');
ninja('start');
</script>
<script type="text/javascript">
window.TMR_PIXEL_ID = 3316675;
var _tmr = window._tmr || (window._tmr = []);
_tmr.push({id: "3316675", type: "pageView", start: (new Date()).getTime()});
(function (d, w, id) {
if (d.getElementById(id)) return;
var ts = d.createElement("script"); ts.type = "text/javascript"; ts.async = true; ts.id = id;
ts.src = "https://top-fwz1.mail.ru/js/code.js";
var f = function () {var s = d.getElementsByTagName("script")[0]; s.parentNode.insertBefore(ts, s);};
if (w.opera == "[object Opera]") { d.addEventListener("DOMContentLoaded", f, false); } else { f(); }
})(document, window, "tmr-code");
</script>
<noscript>
<div>
<img src="https://top-fwz1.mail.ru/counter?id=3316675;js=na"
style="position:absolute;left:-9999px;" alt="Top.Mail.Ru" />
</div>
</noscript>
<!-- Pixel Tag Code -->
<script type="text/javascript">
(function (t, l, g, r, m) {
t[g] ||
((g = t[g] =
function () {
g.run ? g.run.apply(g, arguments) : g.queue.push(arguments);
}),
(g.queue = []),
(t = l.createElement(r)),
(t.async = !0),
(t.src = m),
(l = l.getElementsByTagName(r)[0]),
l.parentNode.insertBefore(t, l));
})(window, document, 'tgp', 'script', 'https://telegram.org/js/pixel.js');
tgp('init', '4bxSybss');
</script>
<!-- End Pixel Tag Code -->
<!-- GTM is no more -->
<link rel="canonical" href="https://otus.ru/nest/post/2362/"/>
<link rel="amphtml" href="https://otus.ru/nest/post/2362/?amp"/>
<title>Проблемы пакетной обработки запросов и их решения. Часть 2 | OTUS</title>
<meta property="og:type" content="website"/>
<meta property="og:url" content="https://otus.ru/nest/post/2362/"/>
<meta property="og:title" content="Проблемы пакетной обработки запросов и их решения. Часть 2 | OTUS">
<meta property="og:description" content="Проблемы пакетной обработки запросов и их решения. Часть 2 в OTUS, только интересные посты!">
<meta name="description" content="Проблемы пакетной обработки запросов и их решения. Часть 2 в OTUS, только интересные посты!">
<meta property="og:site_name" content="Otus">
<meta property="fb:app_id" content="486413851704844"/>
<meta property="og:image" content="/static/img/favicons/android-chrome-537x240.jpg?nocache">
<meta property="og:image:width" content="537">
<meta property="og:image:height" content="240">
<link href="/static/img/favicons/android-chrome-537x240.jpg" rel="image_src"/>
<meta property="vk:image" content="/static/img/favicons/android-chrome-537x240.jpg">
<link href="/static/img/favicons/apple-touch-icon.png" rel="apple-touch-icon"/>
<link href="/static/img/favicons/apple-touch-icon-57x57.png" sizes="57x57" rel="apple-touch-icon"/>
<link href="/static/img/favicons/apple-touch-icon-60x60.png" sizes="60x60" rel="apple-touch-icon"/>
<link href="/static/img/favicons/apple-touch-icon-72x72.png" sizes="72x72" rel="apple-touch-icon"/>
<link href="/static/img/favicons/apple-touch-icon-76x76.png" sizes="76x76" rel="apple-touch-icon"/>
<link href="/static/img/favicons/apple-touch-icon-114x114.png" sizes="114x114" rel="apple-touch-icon"/>
<link href="/static/img/favicons/apple-touch-icon-120x120.png" sizes="120x120" rel="apple-touch-icon"/>
<link href="/static/img/favicons/apple-touch-icon-144x144.png" sizes="144x144" rel="apple-touch-icon"/>
<link href="/static/img/favicons/apple-touch-icon-152x152.png" sizes="152x152" rel="apple-touch-icon"/>
<link href="/static/img/favicons/apple-touch-icon-180x180.png" sizes="180x180" rel="apple-touch-icon"/>
<link type="image/png" href="/static/img/favicons/favicon-32x32.png" sizes="32x32" rel="icon"/>
<link type="image/png" href="/static/img/favicons/favicon-16x16.png" sizes="16x16" rel="icon"/>
<link type="image/x-icon" href="/static/img/favicons/favicon.ico" rel="shortcut icon"/>
<link rel="mask-icon" href="/static/img/favicons/safari-pinned-tab.svg" color="#000000"/>
<meta name="msapplication-TileColor" content="#ffffff">
<meta name="msapplication-TileImage" content="/static/img/favicons/mstile-144x144.png">
<meta name="theme-color" content="#FFFFFF"/>
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials">
<meta name="sw" content="https://otus.ru/static/js/service-worker.8ed2b.js"/>
<meta name="csrf" id="meta-csrf" content="8eZQM3z0y8HpLoch3fzj3WuWLiVUFOIrREwp6Uid3rSfzToS6uusTSjBoOv0Q2ii"/>
<meta name="auth" content="false"/>
<meta name="phone_confirmed" content="false"/>
<meta name="next" content='{"value": "/nest/post/2362/"}'/>
<link href="https://otus.ru/static/css/vendor.react.dd7f4.css" rel="stylesheet" />
<link href="https://otus.ru/static/css/vendor.common.5ac2f.css" rel="stylesheet" />
<link href="https://otus.ru/static/css/otus-react:header-search.37c7a.css" rel="stylesheet" />
<link href="https://otus.ru/static/css/fonts.211eb.css" rel="stylesheet" />
<link href="https://otus.ru/static/css/otus-icons.e3e2d.css" rel="stylesheet" />
<link href="https://otus.ru/static/css/vendor.common.5ac2f.css" rel="stylesheet" />
<link href="https://otus.ru/static/css/vendor.otus-scss.59b5e.css" rel="stylesheet" />
<link href="https://otus.ru/static/css/otus-scss.2de41.css" rel="stylesheet" />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Organization",
"name": "Otus",
"url": "https://otus.ru",
"logo": "https://otus.ru/__new_static__/img/meta-image.png",
"sameAs": [
"https://vk.com/otusru",
"https://t.me/Otusjava"
]
}
</script>
</head>
<body class=" body-header3">
<script type="application/ld+json">
{
"@context" : "http://schema.org",
"@type" : "Organization",
"name" : "OTUS",
"url" : "https://otus.ru",
"logo": "https://otus.ru/static/img/favicons/apple-touch-icon-180x180.png",
"sameAs": [
"",
"https://www.youtube.com/channel/UCetgtvy93o3i3CvyGXKFU3g",
"https://www.instagram.com/otus.ru/"
],
"contactPoint": [
{
"@type": "ContactPoint",
"telephone": "+7-499-938-92-02",
"contactType": "customer service",
"areaServed": "RU"
}
]
}
</script>
<!-- TODO FIXME DRY -->>
<script
src="https://smartcaptcha.yandexcloud.net/captcha.js?render=onload&onload=smartCaptchaInit"
defer
></script>
<script>
function smartCaptchaInit() {
const sitekey = 'ysc1_cM9ClhSx0kwuG9QxSMfFmxHnC1gsW7Axbyddkmzref6982c0';
const test = false;
if (!window.smartCaptcha || !sitekey) {
return;
}
const widgetId = window.smartCaptcha.render('captcha-container', {
sitekey,
invisible: true,
test,
hideShield: true,
});
}
</script>
<div id="captcha-container"></div>
<script src="https://otus.ru/js-18n"></script>
<script>
window.texts = {
companyEmail: "help@otus.ru",
separateQuestionCount: " из "
}
window.language = 'ru-ru'
window.config = {
isEnablePhoneConfirm: true
}
</script>
<script src="https://otus.ru/js-18n"></script>
<script>
window.texts = {
companyEmail: "help@otus.ru",
separateQuestionCount: " из "
}
window.language = 'ru-ru'
</script>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BlogPosting",
"mainEntityOfPage": {
"@type": "WebPage",
"@id": "https://otus.ru/nest/post/2362/"
},
"headline": "Проблемы пакетной обработки запросов и их решения. Часть 2",
"image": "",
"author": {
"@type": "Person",
"name": "Зинченко Максим",
"url": "https://otus.ru/profile/226076/"
},
"publisher": {
"@type": "Organization",
"name": "OTUS",
"logo": {
"@type": "ImageObject",
"url": "https://otus.ru/static/img/favicons/apple-touch-icon-180x180.png"
}
},
"datePublished": "2021-11-18T22:48:09.940650+03:00",
"dateModified": ""
}
</script>
<div class="body-wrapper">
<div class="body body_header3 drawer body_not-subscribed drawer--right blog-drawer ">
<div class="before-header-ui">
<div class="before-header-ui__ellipse1"></div>
<div class="before-header-ui__ellipse2"></div>
<div class="before-header-ui__container">
<div class="before-header-ui__img before-header-ui__img_sales"></div>
<div class="before-header-ui__content">
<div class="before-header-ui__title hide-phone">Курсы по нейросетям со скидкой до 30%</div>
<div class="before-header-ui__title show-phone">Курсы по нейросетям со скидкой до 30%</div>
</div>
<a href="https://otus.ru/catalog/courses?categories=neural_networks&utm_source=internal&utm_medium=free&utm_campaign=otus&utm_term=chank&utm_content=sla_sale_20-02-2026-10-04-2026" rel="nofollow noreferrer noopener" target="_blank" class="before-header-ui__button">Выбрать курс</a>
</div>
</div>
<header class="header3 js-header3">
<div class="header3__container">
<a class="header3__logo" href="/">
<img
class="header3__logo-img"
src="/static/img/logos/logo-2022-without-text.svg"
width="82"
height="42"
alt="Logo"
/>
</a>
<nav class="header3__nav">
<div id="headerSearch" class="header3__nav-item header3__nav-item-search">
<div class="header-search-icon">
<svg
class="header-search-icon__icon"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="11.767"
cy="11.767"
r="8.989"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
></circle>
<path
d="M18.018 18.485 21.542 22"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
</div>
</div>
<div data-name="learning" class="header3__nav-item js-header3-popup-trigger header3__nav-item_only-desktop header3__nav-item_with-hover " >
<span title="Обучение" class="header3__nav-item-arrow-title">Обучение</span>
<div class="header3__nav-item-arrow-container js-header3-popup-arrow">
<svg
width="10"
height="5"
viewBox="0 0 10 5"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="header3__nav-item-arrow"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M1.10067 0.378818C1.29593 0.183556 1.61251 0.183555 1.80778 0.378818L5.00023 3.57127L8.19272 0.378777C8.38798 0.183515 8.70457 0.183515 8.89983 0.378777C9.09509 0.574039 9.09509 0.890622 8.89983 1.08588L5.3643 4.62142C5.26426 4.72146 5.13237 4.77024 5.00127 4.76777C4.8695 4.77079 4.73676 4.72202 4.6362 4.62146L1.10067 1.08592C0.905408 0.890663 0.905408 0.57408 1.10067 0.378818Z"
fill="currentColor"
></path>
</svg>
</div>
</div>
<div class="header3__nav-item-popup-wrapper js-header3-popup" data-name="learning" style="display: none;">
<div class="header3__nav-item-popup-container js-header3-popup-container">
<div class="header3__nav-item-popup-content">
<div class="header3__nav-column">
<div>
<p class="header3__nav-section-title ">Направления</p>
<div class="header3__nav-section-items header3__nav-section-items_learning header3__nav-section-items_learning_rows-8">
<a
class="header3__nav-section-item"
href="/categories/programming/"
>
Программирование (117)
</a>
<a
class="header3__nav-section-item"
href="/categories/architecture/"
>
Архитектура (17)
</a>
<a
class="header3__nav-section-item"
href="/categories/data-science/"
>
Data Science (27)
</a>
<a
class="header3__nav-section-item"
href="/categories/operations/"
>
Инфраструктура (58)
</a>
<a
class="header3__nav-section-item"
href="/categories/gamedev/"
>
GameDev (10)
</a>
<a
class="header3__nav-section-item"
href="/categories/information-security-courses/"
>
Безопасность (15)
</a>
<a
class="header3__nav-section-item"
href="/categories/marketing-business/"
>
Управление (46)
</a>
<a
class="header3__nav-section-item"
href="/categories/analytics/"
>
Аналитика и анализ (25)
</a>
<a
class="header3__nav-section-item"
href="/categories/business-product/"
>
Бизнес и продукт в IT (26)
</a>
<a
class="header3__nav-section-item"
href="/categories/import-substitution/"
>
Импортозамещение (15)
</a>
<a
class="header3__nav-section-item"
href="/categories/testing/"
>
Тестирование (12)
</a>
<a
class="header3__nav-section-item"
href="/categories/neural_networks/"
>
Нейросети (9)
</a>
<a
class="header3__nav-section-item"
href="/categories/it-bez-programmirovanija/"
>
IT без программирования (19)
</a>
<a
class="header3__nav-section-item"
href="/categories/corporate/"
>
Корпоративные курсы (27)
</a>
</div>
</div>
</div>
<div class="header3__nav-column">
<div>
<p class="header3__nav-section-title ">События</p>
<div class="header3__nav-section-items ">
<a
class="header3__nav-section-item"
href="/lessons/calendar/2026/"
>
Календарь запуска курсов
</a>
<a
class="header3__nav-section-item"
href="/events/near/"
>
Календарь мероприятий
</a>
</div>
</div>
<div>
<p class="header3__nav-section-title ">Другое</p>
<div class="header3__nav-section-items ">
<a
class="header3__nav-section-item"
href="/categories/spec/"
>
Специализации (13)
</a>
<a
class="header3__nav-section-item"
href="/categories/online/"
>
Подготовительные курсы (14)
</a>
<a
class="header3__nav-section-item header3__nav-section-item_bold"
href="/subscription"
>
Подписка на курсы
</a>
<a
class="header3__nav-section-item"
href="/tests"
>
Проверьте свои знания
</a>
</div>
</div>
</div>
<div class="header3__nav-column">
<div>
<p class="header3__nav-section-title header3__nav-section-items-recommendation-title">OTUS рекомендует</p>
<div class="header3__nav-section-items header3__nav-section-items_not-items header3__nav-section-items-recommendation">
<a
href="/lessons/ai-dlya-analitiki-i-raboty-s-dannymi/"
class="header3__card-recommendation"
>
<div
class="header3__card-recommendation-background"
style="background: linear-gradient( 90deg,#0A4489, #00316B);"
></div>
<div class="header3__card-recommendation-header">
<div class="header3__card-recommendation-header-photo-wrapper" style="background: linear-gradient( 90deg,#0A4489, #00316B);">
<img
src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODAiIGhlaWdodD0iODAiIHZpZXdCb3g9IjAgMCA4MCA4MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICAgIDxwYXRoCiAgICAgICAgZD0iTTM1LjAwNDcgNDUuODcyNUMzNS4wMDQ3IDQ2LjkzMDYgMzYuMTA5IDQ3LjU3NTcgMzYuOTYwMyA0Ny4wMTQ5TDQ0LjYyOTIgNDEuOTYzMUM0NS4wMDI5IDQxLjcxNjkgNDUuMjI5OSA0MS4yODUyIDQ1LjIyOTkgNDAuODIwN1YyNi43NDg5QzQ1LjIyOTkgMjYuNzQ4OSA0Ny43ODYyIDI2Ljc0ODkgNTQuMTc3IDIyLjcwNzRDNTcuMzAxMiAyMC43MzE4IDU4Ljg5OCAxOC43NTYxIDU5LjcxNDMgMTcuMjUyNUM2MC4yNDY1IDE2LjI3MjEgNTkuMjUxMSAxNS41NTg4IDU4LjI5NjUgMTYuMDcyM0M1NS4zMjkyIDE3LjY2ODQgNDkuMzkxOCAyMC4wMTMyIDQwLjExNzMgMjAuMDEzMkMzMC44NDI4IDIwLjAxMzIgMjQuOTA1NCAxNy42Njg0IDIxLjkzODEgMTYuMDcyM0MyMC45ODM1IDE1LjU1ODggMTkuOTg4MSAxNi4yNzIxIDIwLjUyMDMgMTcuMjUyNUMyMS4zMzY2IDE4Ljc1NjEgMjIuOTMzNCAyMC43MzE4IDI2LjA1NzcgMjIuNzA3NEMzMi40NDg0IDI2Ljc0ODkgMzUuMDA0NyAyNi43NDg5IDM1LjAwNDcgMjYuNzQ4OVY0NS44NzI1WiIKICAgICAgICBmaWxsPSJ3aGl0ZSIgLz4KICAgIDxlbGxpcHNlIGN4PSIyNi45NTM1IiBjeT0iMzUuNTUyNiIgcng9IjMuOTQ5MTQiIHJ5PSI0LjA4NTMyIiBmaWxsPSJ3aGl0ZSIgLz4KICAgIDxlbGxpcHNlIGN4PSI1My4yODEyIiBjeT0iMzUuNTUyNiIgcng9IjMuOTQ5MTQiIHJ5PSI0LjA4NTMyIiBmaWxsPSJ3aGl0ZSIgLz4KICAgIDxwYXRoCiAgICAgICAgZD0iTTI1LjYzNzIgNTkuNjM1QzI1LjYzNzIgNTguNzk2MSAyNi4yMjY2IDU4LjExNiAyNi45NTM2IDU4LjExNkMyNy42ODA2IDU4LjExNiAyOC4yNyA1OC43OTYxIDI4LjI3IDU5LjYzNVY2Mi4wNDRDMjguMjcgNjIuNDQ2OSAyOC4xMzEzIDYyLjgzMzMgMjcuODg0NCA2My4xMTgxQzI3LjM3MDMgNjMuNzExNCAyNi41MzY4IDYzLjcxMTQgMjYuMDIyOCA2My4xMTgxQzI1Ljc3NTkgNjIuODMzMyAyNS42MzcyIDYyLjQ0NjkgMjUuNjM3MiA2Mi4wNDRWNTkuNjM1WiIKICAgICAgICBmaWxsPSJ3aGl0ZSIgLz4KICAgIDxwYXRoCiAgICAgICAgZD0iTTQ0LjA2NjUgNTkuNjM1QzQ0LjA2NjUgNTguNzk2MSA0NC42NTU5IDU4LjExNiA0NS4zODI5IDU4LjExNkM0Ni4xMDk5IDU4LjExNiA0Ni42OTkzIDU4Ljc5NjEgNDYuNjk5MyA1OS42MzVWNjIuMDQ0QzQ2LjY5OTMgNjIuNDQ2OSA0Ni41NjA2IDYyLjgzMzMgNDYuMzEzOCA2My4xMTgxQzQ1Ljc5OTcgNjMuNzExNCA0NC45NjYyIDYzLjcxMTQgNDQuNDUyMSA2My4xMTgxQzQ0LjIwNTIgNjIuODMzMyA0NC4wNjY1IDYyLjQ0NjkgNDQuMDY2NSA2Mi4wNDRWNTkuNjM1WiIKICAgICAgICBmaWxsPSJ3aGl0ZSIgLz4KICAgIDxwYXRoCiAgICAgICAgZD0iTTI5LjU4NjQgNTkuNjM1QzI5LjU4NjQgNTguNzk2MSAzMC4xNzU3IDU4LjExNiAzMC45MDI3IDU4LjExNkMzMS42Mjk4IDU4LjExNiAzMi4yMTkxIDU4Ljc5NjEgMzIuMjE5MSA1OS42MzVWNjIuMDQ0QzMyLjIxOTEgNjIuNDQ2OSAzMi4wODA0IDYyLjgzMzMgMzEuODMzNiA2My4xMTgxQzMxLjMxOTUgNjMuNzExNCAzMC40ODYgNjMuNzExNCAyOS45NzE5IDYzLjExODFDMjkuNzI1IDYyLjgzMzMgMjkuNTg2NCA2Mi40NDY5IDI5LjU4NjQgNjIuMDQ0VjU5LjYzNVoiCiAgICAgICAgZmlsbD0id2hpdGUiIC8+CiAgICA8cGF0aAogICAgICAgIGQ9Ik00OC4wMTU3IDU5LjYzNUM0OC4wMTU3IDU4Ljc5NjEgNDguNjA1MSA1OC4xMTYgNDkuMzMyMSA1OC4xMTZDNTAuMDU5MSA1OC4xMTYgNTAuNjQ4NSA1OC43OTYxIDUwLjY0ODUgNTkuNjM1VjYyLjA0NEM1MC42NDg1IDYyLjQ0NjkgNTAuNTA5OCA2Mi44MzMzIDUwLjI2MjkgNjMuMTE4MUM0OS43NDg4IDYzLjcxMTQgNDguOTE1MyA2My43MTE0IDQ4LjQwMTIgNjMuMTE4MUM0OC4xNTQ0IDYyLjgzMzMgNDguMDE1NyA2Mi40NDY5IDQ4LjAxNTcgNjIuMDQ0VjU5LjYzNVoiCiAgICAgICAgZmlsbD0id2hpdGUiIC8+CiAgICA8cGF0aAogICAgICAgIGQ9Ik0zMy41MzU1IDU5LjYzNUMzMy41MzU1IDU4Ljc5NjEgMzQuMTI0OSA1OC4xMTYgMzQuODUxOSA1OC4xMTZDMzUuNTc4OSA1OC4xMTYgMzYuMTY4MyA1OC43OTYxIDM2LjE2ODMgNTkuNjM1VjYyLjA0NEMzNi4xNjgzIDYyLjQ0NjkgMzYuMDI5NiA2Mi44MzMzIDM1Ljc4MjcgNjMuMTE4MUMzNS4yNjg2IDYzLjcxMTQgMzQuNDM1MSA2My43MTE0IDMzLjkyMTEgNjMuMTE4MUMzMy42NzQyIDYyLjgzMzMgMzMuNTM1NSA2Mi40NDY5IDMzLjUzNTUgNjIuMDQ0VjU5LjYzNVoiCiAgICAgICAgZmlsbD0id2hpdGUiIC8+CiAgICA8cGF0aAogICAgICAgIGQ9Ik01MS45NjQ4IDU5LjYzNUM1MS45NjQ4IDU4Ljc5NjEgNTIuNTU0MiA1OC4xMTYgNTMuMjgxMiA1OC4xMTZDNTQuMDA4MiA1OC4xMTYgNTQuNTk3NiA1OC43OTYxIDU0LjU5NzYgNTkuNjM1VjYyLjA0NEM1NC41OTc2IDYyLjQ0NjkgNTQuNDU4OSA2Mi44MzMzIDU0LjIxMiA2My4xMTgxQzUzLjY5OCA2My43MTE0IDUyLjg2NDUgNjMuNzExNCA1Mi4zNTA0IDYzLjExODFDNTIuMTAzNSA2Mi44MzMzIDUxLjk2NDggNjIuNDQ2OSA1MS45NjQ4IDYyLjA0NFY1OS42MzVaIgogICAgICAgIGZpbGw9IndoaXRlIiAvPgo8L3N2Zz4="
class="header3__card-recommendation-header-photo header3__card-recommendation-header-photo_default"
/>
<img
src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODAiIGhlaWdodD0iODAiIHZpZXdCb3g9IjAgMCA4MCA4MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICAgIDxtYXNrIGlkPSJhIiBmaWxsPSIjZmZmIj4KICAgICAgICA8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIKICAgICAgICAgICAgZD0iTTAgMTJDMCA1LjM3MyA1LjM3MyAwIDEyIDBoNTZjNi42MjcgMCAxMiA1LjM3MyAxMiAxMnY1NmMwIDYuNjI3LTUuMzczIDEyLTEyIDEySDEyQzUuMzczIDgwIDAgNzQuNjI3IDAgNjhWMTJabTM1LjAwNSAzMy44NzNjMCAxLjA1OCAxLjEwNCAxLjcwMyAxLjk1NSAxLjE0Mmw3LjY3LTUuMDUyYy4zNzMtLjI0Ni42LS42NzguNi0xLjE0MlYyNi43NDlzMi41NTYgMCA4Ljk0Ny00LjA0MmMzLjEyNC0xLjk3NSA0LjcyMS0zLjk1MSA1LjUzNy01LjQ1NC41MzMtLjk4MS0uNDYzLTEuNjk0LTEuNDE3LTEuMTgtMi45NjggMS41OTUtOC45MDUgMy45NC0xOC4xOCAzLjk0LTkuMjc0IDAtMTUuMjEyLTIuMzQ1LTE4LjE3OS0zLjk0LS45NTQtLjUxNC0xLjk1LjE5OS0xLjQxOCAxLjE4LjgxNyAxLjUwMyAyLjQxMyAzLjQ3OSA1LjUzOCA1LjQ1NCA2LjM5IDQuMDQyIDguOTQ3IDQuMDQyIDguOTQ3IDQuMDQydjE5LjEyNFptLTguMDUxLTYuMjM1YzIuMTggMCAzLjk0OS0xLjgzIDMuOTQ5LTQuMDg1IDAtMi4yNTctMS43NjgtNC4wODYtMy45NS00LjA4Ni0yLjE4IDAtMy45NDkgMS44My0zLjk0OSA0LjA4NiAwIDIuMjU2IDEuNzY4IDQuMDg1IDMuOTUgNC4wODVabTMwLjI3Ni00LjA4NWMwIDIuMjU2LTEuNzY4IDQuMDg1LTMuOTQ5IDQuMDg1LTIuMTggMC0zLjk0OS0xLjgzLTMuOTQ5LTQuMDg1IDAtMi4yNTcgMS43NjgtNC4wODYgMy45NS00LjA4NiAyLjE4IDAgMy45NDggMS44MyAzLjk0OCA0LjA4NlpNMjYuOTU0IDU4LjExNmMtLjcyNyAwLTEuMzE3LjY4LTEuMzE3IDEuNTE5djIuNDA5YzAgLjQwMy4xMzkuNzkuMzg2IDEuMDc0LjUxNC41OTMgMS4zNDcuNTkzIDEuODYxIDAgLjI0Ny0uMjg1LjM4Ni0uNjcxLjM4Ni0xLjA3NHYtMi40MDljMC0uODM5LS41OS0xLjUxOS0xLjMxNy0xLjUxOVptMTguNDI5IDBjLS43MjcgMC0xLjMxNy42OC0xLjMxNyAxLjUxOXYyLjQwOWMwIC40MDMuMTQuNzkuMzg2IDEuMDc0LjUxNC41OTMgMS4zNDguNTkzIDEuODYyIDAgLjI0Ny0uMjg1LjM4NS0uNjcxLjM4NS0xLjA3NHYtMi40MDljMC0uODM5LS41OS0xLjUxOS0xLjMxNi0xLjUxOVptLTE1Ljc5NyAxLjUxOWMwLS44MzkuNTktMS41MTkgMS4zMTctMS41MTlzMS4zMTYuNjggMS4zMTYgMS41MTl2Mi40MDljMCAuNDAzLS4xMzkuNzktLjM4NiAxLjA3NC0uNTE0LjU5My0xLjM0Ny41OTMtMS44NjEgMGExLjY0NSAxLjY0NSAwIDAgMS0uMzg2LTEuMDc0di0yLjQwOVptMTkuNzQ2LTEuNTE5Yy0uNzI3IDAtMS4zMTYuNjgtMS4zMTYgMS41MTl2Mi40MDljMCAuNDAzLjEzOC43OS4zODUgMS4wNzQuNTE0LjU5MyAxLjM0OC41OTMgMS44NjIgMCAuMjQ3LS4yODUuMzg1LS42NzEuMzg1LTEuMDc0di0yLjQwOWMwLS44MzktLjU4OS0xLjUxOS0xLjMxNi0xLjUxOVptLTE1Ljc5NyAxLjUxOWMwLS44MzkuNTktMS41MTkgMS4zMTctMS41MTlzMS4zMTYuNjggMS4zMTYgMS41MTl2Mi40MDljMCAuNDAzLS4xMzkuNzktLjM4NSAxLjA3NC0uNTE0LjU5My0xLjM0OC41OTMtMS44NjIgMGExLjY0NSAxLjY0NSAwIDAgMS0uMzg2LTEuMDc0di0yLjQwOVptMTkuNzQ2LTEuNTE5Yy0uNzI3IDAtMS4zMTYuNjgtMS4zMTYgMS41MTl2Mi40MDljMCAuNDAzLjEzOC43OS4zODUgMS4wNzQuNTE0LjU5MyAxLjM0OC41OTMgMS44NjIgMCAuMjQ3LS4yODUuMzg2LS42NzEuMzg2LTEuMDc0di0yLjQwOWMwLS44MzktLjU5LTEuNTE5LTEuMzE3LTEuNTE5WiIgLz4KICAgIDwvbWFzaz4KICAgIDxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIgogICAgICAgIGQ9Ik0wIDEyQzAgNS4zNzMgNS4zNzMgMCAxMiAwaDU2YzYuNjI3IDAgMTIgNS4zNzMgMTIgMTJ2NTZjMCA2LjYyNy01LjM3MyAxMi0xMiAxMkgxMkM1LjM3MyA4MCAwIDc0LjYyNyAwIDY4VjEyWm0zNS4wMDUgMzMuODczYzAgMS4wNTggMS4xMDQgMS43MDMgMS45NTUgMS4xNDJsNy42Ny01LjA1MmMuMzczLS4yNDYuNi0uNjc4LjYtMS4xNDJWMjYuNzQ5czIuNTU2IDAgOC45NDctNC4wNDJjMy4xMjQtMS45NzUgNC43MjEtMy45NTEgNS41MzctNS40NTQuNTMzLS45ODEtLjQ2My0xLjY5NC0xLjQxNy0xLjE4LTIuOTY4IDEuNTk1LTguOTA1IDMuOTQtMTguMTggMy45NC05LjI3NCAwLTE1LjIxMi0yLjM0NS0xOC4xNzktMy45NC0uOTU0LS41MTQtMS45NS4xOTktMS40MTggMS4xOC44MTcgMS41MDMgMi40MTMgMy40NzkgNS41MzggNS40NTQgNi4zOSA0LjA0MiA4Ljk0NyA0LjA0MiA4Ljk0NyA0LjA0MnYxOS4xMjRabS04LjA1MS02LjIzNWMyLjE4IDAgMy45NDktMS44MyAzLjk0OS00LjA4NSAwLTIuMjU3LTEuNzY4LTQuMDg2LTMuOTUtNC4wODYtMi4xOCAwLTMuOTQ5IDEuODMtMy45NDkgNC4wODYgMCAyLjI1NiAxLjc2OCA0LjA4NSAzLjk1IDQuMDg1Wm0zMC4yNzYtNC4wODVjMCAyLjI1Ni0xLjc2OCA0LjA4NS0zLjk0OSA0LjA4NS0yLjE4IDAtMy45NDktMS44My0zLjk0OS00LjA4NSAwLTIuMjU3IDEuNzY4LTQuMDg2IDMuOTUtNC4wODYgMi4xOCAwIDMuOTQ4IDEuODMgMy45NDggNC4wODZaTTI2Ljk1NCA1OC4xMTZjLS43MjcgMC0xLjMxNy42OC0xLjMxNyAxLjUxOXYyLjQwOWMwIC40MDMuMTM5Ljc5LjM4NiAxLjA3NC41MTQuNTkzIDEuMzQ3LjU5MyAxLjg2MSAwIC4yNDctLjI4NS4zODYtLjY3MS4zODYtMS4wNzR2LTIuNDA5YzAtLjgzOS0uNTktMS41MTktMS4zMTctMS41MTlabTE4LjQyOSAwYy0uNzI3IDAtMS4zMTcuNjgtMS4zMTcgMS41MTl2Mi40MDljMCAuNDAzLjE0Ljc5LjM4NiAxLjA3NC41MTQuNTkzIDEuMzQ4LjU5MyAxLjg2MiAwIC4yNDctLjI4NS4zODUtLjY3MS4zODUtMS4wNzR2LTIuNDA5YzAtLjgzOS0uNTktMS41MTktMS4zMTYtMS41MTlabS0xNS43OTcgMS41MTljMC0uODM5LjU5LTEuNTE5IDEuMzE3LTEuNTE5czEuMzE2LjY4IDEuMzE2IDEuNTE5djIuNDA5YzAgLjQwMy0uMTM5Ljc5LS4zODYgMS4wNzQtLjUxNC41OTMtMS4zNDcuNTkzLTEuODYxIDBhMS42NDUgMS42NDUgMCAwIDEtLjM4Ni0xLjA3NHYtMi40MDlabTE5Ljc0Ni0xLjUxOWMtLjcyNyAwLTEuMzE2LjY4LTEuMzE2IDEuNTE5djIuNDA5YzAgLjQwMy4xMzguNzkuMzg1IDEuMDc0LjUxNC41OTMgMS4zNDguNTkzIDEuODYyIDAgLjI0Ny0uMjg1LjM4NS0uNjcxLjM4NS0xLjA3NHYtMi40MDljMC0uODM5LS41ODktMS41MTktMS4zMTYtMS41MTlabS0xNS43OTcgMS41MTljMC0uODM5LjU5LTEuNTE5IDEuMzE3LTEuNTE5czEuMzE2LjY4IDEuMzE2IDEuNTE5djIuNDA5YzAgLjQwMy0uMTM5Ljc5LS4zODUgMS4wNzQtLjUxNC41OTMtMS4zNDguNTkzLTEuODYyIDBhMS42NDUgMS42NDUgMCAwIDEtLjM4Ni0xLjA3NHYtMi40MDlabTE5Ljc0Ni0xLjUxOWMtLjcyNyAwLTEuMzE2LjY4LTEuMzE2IDEuNTE5djIuNDA5YzAgLjQwMy4xMzguNzkuMzg1IDEuMDc0LjUxNC41OTMgMS4zNDguNTkzIDEuODYyIDAgLjI0Ny0uMjg1LjM4Ni0uNjcxLjM4Ni0xLjA3NHYtMi40MDljMC0uODM5LS41OS0xLjUxOS0xLjMxNy0xLjUxOVoiCiAgICAgICAgZmlsbD0iI2ZmZiIgLz4KICAgIDxwYXRoCiAgICAgICAgZD0ibTM2Ljk2IDQ3LjAxNSAxLjEgMS42Ny0xLjEtMS42N1ptNy42Ny01LjA1MiAxLjEgMS42Ny0xLjEtMS42N1ptLjYtMTUuMjE0di0yaC0ydjJoMlptOC45NDctNC4wNDIgMS4wNjkgMS42OS0xLjA2OS0xLjY5Wm01LjUzNy01LjQ1NCAxLjc1OC45NTQtMS43NTgtLjk1NFptLTEuNDE3LTEuMTguOTQ3IDEuNzYtLjk0Ny0xLjc2Wm0tMzYuMzU5IDAtLjk0NyAxLjc2Ljk0Ny0xLjc2Wm0tMS40MTggMS4xOCAxLjc1OC0uOTU1LTEuNzU4Ljk1NVptNS41MzggNS40NTQtMS4wNyAxLjY5IDEuMDctMS42OVptOC45NDcgNC4wNDJoMnYtMmgtMnYyWm0tOC45ODIgMzYuMzcgMS41MTEtMS4zMS0xLjUxMSAxLjMxWm0xLjg2MSAwIDEuNTEyIDEuMzA5LTEuNTEyLTEuMzFabTE2LjU2OCAwLTEuNTExIDEuMzA5IDEuNTExLTEuMzFabTEuODYyIDAgMS41MTEgMS4zMDktMS41MTEtMS4zMVptLTE0LjQ4IDAtMS41MTItMS4zMSAxLjUxMiAxLjMxWm0tMS44NjIgMCAxLjUxMS0xLjMxLTEuNTExIDEuMzFabTE4LjQzIDAtMS41MTIgMS4zMDkgMS41MTEtMS4zMVptMS44NiAwIDEuNTEyIDEuMzA5LTEuNTExLTEuMzFabS0xNC40OCAwIDEuNTEyIDEuMzA5LTEuNTExLTEuMzFabS0xLjg2MSAwLTEuNTExIDEuMzA5IDEuNTExLTEuMzFabTE4LjQzIDAgMS41MS0xLjMxLTEuNTEgMS4zMVptMS44NjEgMCAxLjUxMSAxLjMwOS0xLjUxMS0xLjMxWk0xMi0yQzQuMjY4LTItMiA0LjI2OC0yIDEyaDRDMiA2LjQ3NyA2LjQ3NyAyIDEyIDJ2LTRabTU2IDBIMTJ2NGg1NnYtNFptMTQgMTRjMC03LjczMi02LjI2OC0xNC0xNC0xNHY0YzUuNTIzIDAgMTAgNC40NzcgMTAgMTBoNFptMCA1NlYxMmgtNHY1Nmg0Wk02OCA4MmM3LjczMiAwIDE0LTYuMjY4IDE0LTE0aC00YzAgNS41MjMtNC40NzcgMTAtMTAgMTB2NFptLTU2IDBoNTZ2LTRIMTJ2NFpNLTIgNjhjMCA3LjczMiA2LjI2OCAxNCAxNCAxNHYtNEM2LjQ3NyA3OCAyIDczLjUyMyAyIDY4aC00Wm0wLTU2djU2aDRWMTJoLTRabTM3Ljg2IDMzLjM0NWEuNzkxLjc5MSAwIDAgMSAuODEyLS4wMjEuNjU2LjY1NiAwIDAgMSAuMzMzLjU0OWgtNGMwIDIuNDU0IDIuNzE0IDQuMzU1IDUuMDU1IDIuODEybC0yLjItMy4zNFptNy42NjktNS4wNTItNy42NjkgNS4wNTIgMi4yIDMuMzQgNy42Ny01LjA1Mi0yLjIwMS0zLjM0Wm0tLjI5OS41MjhjMC0uMTc4LjA4Ni0uMzg4LjI5OS0uNTI4bDIuMiAzLjM0YTMuMzY1IDMuMzY1IDAgMCAwIDEuNTAxLTIuODEyaC00Wm0wLTE0LjA3MlY0MC44Mmg0VjI2Ljc0OWgtNFptOS44NzgtNS43MzJjLTMuMDk3IDEuOTU5LTUuMTk3IDIuODk1LTYuNDY0IDMuMzRhOC44NDUgOC44NDUgMCAwIDEtMS4yOC4zNjIgMi40MiAyLjQyIDAgMCAxLS4yMDMuMDMxSDQ1LjE5bC4wMTgtLjAwMWguMDJjLjAwMSAwIC4wMDMgMCAuMDAzIDJzLjAwMiAyIC4wMDMgMmguMDU0YTMuMTk2IDMuMTk2IDAgMCAwIC4yNTYtLjAxN2MuMTQ0LS4wMTQuMzI3LS4wMzguNTUyLS4wOC40NS0uMDg0IDEuMDY5LS4yMzggMS44NzUtLjUyMSAxLjYxLS41NjYgMy45ODItMS42NSA3LjI3Ni0zLjczM2wtMi4xMzgtMy4zODFabTQuODQ5LTQuNzE5Yy0uNjM3IDEuMTczLTEuOTgzIDIuOTA2LTQuODQ5IDQuNzE5bDIuMTM4IDMuMzhjMy4zODItMi4xMzggNS4yMy00LjM1NiA2LjIyNi02LjE5bC0zLjUxNS0xLjkwOVptMS4yODcgMS41MzZjLS4xMDYuMDU2LS40OTQuMTczLS45MTYtLjE1OGExLjIzMyAxLjIzMyAwIDAgMS0uNDQ4LS43Ni45ODUuOTg1IDAgMCAxIC4wNzctLjYxOGwzLjUxNSAxLjkwOWMuMzA4LS41NjguNDcyLTEuMjQ4LjM1MS0xLjk2YTIuNzY4IDIuNzY4IDAgMCAwLTEuMDI1LTEuNzE4Yy0xLjAyNi0uODA1LTIuMzg4LS43ODgtMy40NDktLjIxOGwxLjg5NSAzLjUyM1ptLTE5LjEyNyA0LjE4YzkuNjI4IDAgMTUuODg5LTIuNDM4IDE5LjEyNy00LjE4bC0xLjg5NS0zLjUyM2MtMi42OTYgMS40NS04LjMxIDMuNzAyLTE3LjIzMiAzLjcwMnY0Wm0tMTkuMTI2LTQuMThjMy4yMzggMS43NDIgOS40OTkgNC4xOCAxOS4xMjYgNC4xOHYtNGMtOC45MjEgMC0xNC41MzUtMi4yNTMtMTcuMjMxLTMuNzAzbC0xLjg5NSAzLjUyM1ptMS4yODctMS41MzZjLjA0Mi4wNzguMTMxLjMuMDc3LjYxOWExLjIzMiAxLjIzMiAwIDAgMS0uNDQ5Ljc1OWMtLjQyMi4zMy0uODEuMjE1LS45MTUuMTU4bDEuODk1LTMuNTIzYy0xLjA2LS41Ny0yLjQyNC0uNTg3LTMuNDQ5LjIxOGEyLjc2OCAyLjc2OCAwIDAgMC0xLjAyNiAxLjcxOWMtLjEyLjcxMS4wNDMgMS4zOTEuMzUyIDEuOTU5bDMuNTE1LTEuOTA5Wm00Ljg0OSA0LjcxOWMtMi44NjctMS44MTMtNC4yMTItMy41NDYtNC44NDktNC43MTlsLTMuNTE1IDEuOTA5Yy45OTUgMS44MzQgMi44NDQgNC4wNTIgNi4yMjYgNi4xOWwyLjEzOC0zLjM4Wm03Ljg3OCA1LjczMmMwLTIgLjAwMS0yIC4wMDMtMkgzNS4wNDdsLjAyNS4wMDFoLjAwMWEyLjQyIDIuNDIgMCAwIDEtLjIwMi0uMDMgOC44NTIgOC44NTIgMCAwIDEtMS4yOC0uMzYzYy0xLjI2Ny0uNDQ1LTMuMzY3LTEuMzgxLTYuNDY0LTMuMzRsLTIuMTM4IDMuMzhjMy4yOTMgMi4wODQgNS42NjYgMy4xNjggNy4yNzYgMy43MzQuODA2LjI4MyAxLjQyNS40MzcgMS44NzUuNTJhNi4zNTYgNi4zNTYgMCAwIDAgLjczNy4wOTVsLjA3MS4wMDJoLjA1NGMuMDAxIDAgLjAwMyAwIC4wMDMtMlptMiAxOS4xMjRWMjYuNzQ5aC00djE5LjEyNGg0Wm0tOC4xMDItMTAuMzJjMCAxLjIxNi0uOTM2IDIuMDg1LTEuOTUgMi4wODV2NGMzLjM1IDAgNS45NS0yLjc4OSA1Ljk1LTYuMDg1aC00Wm0tMS45NS0yLjA4NmMxLjAxNCAwIDEuOTUuODcgMS45NSAyLjA4Nmg0YzAtMy4yOTctMi42LTYuMDg2LTUuOTUtNi4wODZ2NFptLTEuOTQ5IDIuMDg2YzAtMS4yMTYuOTM2LTIuMDg2IDEuOTUtMi4wODZ2LTRjLTMuMzUgMC01Ljk1IDIuNzktNS45NSA2LjA4Nmg0Wm0xLjk1IDIuMDg1Yy0xLjAxNCAwLTEuOTUtLjg3LTEuOTUtMi4wODVoLTRjMCAzLjI5NiAyLjYgNi4wODUgNS45NSA2LjA4NXYtNFptMjYuMzI3IDRjMy4zNSAwIDUuOTUtMi43ODkgNS45NS02LjA4NWgtNGMwIDEuMjE2LS45MzcgMi4wODUtMS45NSAyLjA4NXY0Wm0tNS45NDktNi4wODVjMCAzLjI5NiAyLjYgNi4wODUgNS45NSA2LjA4NXYtNGMtMS4wMTQgMC0xLjk1LS44Ny0xLjk1LTIuMDg1aC00Wm01Ljk1LTYuMDg2Yy0zLjM1IDAtNS45NSAyLjc5LTUuOTUgNi4wODZoNGMwLTEuMjE2LjkzNi0yLjA4NiAxLjk1LTIuMDg2di00Wm01Ljk0OCA2LjA4NmMwLTMuMjk3LTIuNi02LjA4Ni01Ljk0OS02LjA4NnY0YzEuMDEzIDAgMS45NS44NyAxLjk1IDIuMDg2aDRaTTI3LjYzNyA1OS42MzVhLjM3LjM3IDAgMCAxLS4xMDMuMjM2Ljc3NC43NzQgMCAwIDEtLjU4LjI0NXYtNGMtMi4wOTEgMC0zLjMxNyAxLjg1NC0zLjMxNyAzLjUxOWg0Wm0wIDIuNDA5di0yLjQwOWgtNHYyLjQwOWg0Wm0tLjEwMy0uMjM2YS40NS40NSAwIDAgMSAuMDg2LjE0Mi4yNzcuMjc3IDAgMCAxIC4wMTcuMDk0aC00YzAgLjg0OC4yOSAxLjcxLjg3NCAyLjM4NGwzLjAyMy0yLjYyWm0tMS4xNjEgMGEuNzc2Ljc3NiAwIDAgMSAuNTgtLjI0NWMuMjcgMCAuNDc2LjEyNC41ODEuMjQ1bC0zLjAyMyAyLjYyYzEuMzEyIDEuNTEzIDMuNTczIDEuNTEzIDQuODg1IDBsLTMuMDIzLTIuNjJabS0uMTAzLjIzNmMwLS4wMjcuMDA0LS4wNTguMDE3LS4wOTRhLjQ1LjQ1IDAgMCAxIC4wODYtLjE0MmwzLjAyMyAyLjYyYTMuNjQ0IDMuNjQ0IDAgMCAwIC44NzQtMi4zODRoLTRabTAtMi40MDl2Mi40MDloNHYtMi40MDloLTRabS42ODMuNDgxYy0uMjkgMC0uNDktLjE0LS41OC0uMjQ1YS4zNy4zNyAwIDAgMS0uMTAzLS4yMzZoNGMwLTEuNjY1LTEuMjI2LTMuNTE5LTMuMzE3LTMuNTE5djRabTE5LjExMy0uNDgxYS4zNy4zNyAwIDAgMS0uMTAyLjIzNi43NzQuNzc0IDAgMCAxLS41ODEuMjQ1di00Yy0yLjA5MSAwLTMuMzE3IDEuODU0LTMuMzE3IDMuNTE5aDRabTAgMi40MDl2LTIuNDA5aC00djIuNDA5aDRabS0uMTAyLS4yMzZhLjQ1LjQ1IDAgMCAxIC4wODUuMTQyLjI3Ny4yNzcgMCAwIDEgLjAxNy4wOTRoLTRjMCAuODQ4LjI5IDEuNzEuODc1IDIuMzg0bDMuMDIzLTIuNjJabS0xLjE2MiAwYS43NzUuNzc1IDAgMCAxIC41OC0uMjQ1Yy4yNzEgMCAuNDc3LjEyNC41ODIuMjQ1bC0zLjAyMyAyLjYyYzEuMzExIDEuNTEzIDMuNTczIDEuNTEzIDQuODg0IDBsLTMuMDIzLTIuNjJabS0uMTAzLjIzNmMwLS4wMjcuMDA1LS4wNTguMDE4LS4wOTRhLjQ1LjQ1IDAgMCAxIC4wODUtLjE0MmwzLjAyMyAyLjYyYTMuNjQ1IDMuNjQ1IDAgMCAwIC44NzQtMi4zODRoLTRabTAtMi40MDl2Mi40MDloNHYtMi40MDloLTRabS42ODQuNDgxYy0uMjkgMC0uNDktLjE0LS41OC0uMjQ1YS4zNjkuMzY5IDAgMCAxLS4xMDQtLjIzNmg0YzAtMS42NjUtMS4yMjUtMy41MTktMy4zMTYtMy41MTl2NFptLTE0LjQ4LTRjLTIuMDkxIDAtMy4zMTcgMS44NTQtMy4zMTcgMy41MTloNGEuMzcuMzcgMCAwIDEtLjEwMy4yMzYuNzc0Ljc3NCAwIDAgMS0uNTguMjQ1di00Wm0zLjMxNiAzLjUxOWMwLTEuNjY1LTEuMjI1LTMuNTE5LTMuMzE2LTMuNTE5djRjLS4yOSAwLS40OS0uMTQtLjU4LS4yNDVhLjM3LjM3IDAgMCAxLS4xMDQtLjIzNmg0Wm0wIDIuNDA5di0yLjQwOWgtNHYyLjQwOWg0Wm0tLjg3NCAyLjM4NGEzLjY0NCAzLjY0NCAwIDAgMCAuODc0LTIuMzg0aC00YzAtLjAyNy4wMDUtLjA1OC4wMTctLjA5NGEuNDUuNDUgMCAwIDEgLjA4Ni0uMTQybDMuMDIzIDIuNjJabS00Ljg4NSAwYzEuMzEyIDEuNTEzIDMuNTczIDEuNTEzIDQuODg1IDBsLTMuMDIzLTIuNjJhLjc3NS43NzUgMCAwIDEgLjU4LS4yNDVjLjI3IDAgLjQ3Ni4xMjQuNTgxLjI0NWwtMy4wMjMgMi42MlptLS44NzQtMi4zODRjMCAuODQ4LjI5IDEuNzEuODc0IDIuMzg0bDMuMDIzLTIuNjJhLjQ1LjQ1IDAgMCAxIC4wODYuMTQyLjI3Ny4yNzcgMCAwIDEgLjAxNy4wOTRoLTRabTAtMi40MDl2Mi40MDloNHYtMi40MDloLTRabTIyLjQzIDBhLjM3LjM3IDAgMCAxLS4xMDMuMjM2Ljc3NC43NzQgMCAwIDEtLjU4MS4yNDV2LTRjLTIuMDkgMC0zLjMxNiAxLjg1NC0zLjMxNiAzLjUxOWg0Wm0wIDIuNDA5di0yLjQwOWgtNHYyLjQwOWg0Wm0tLjEwMy0uMjM2YS40NTMuNDUzIDAgMCAxIC4wODUuMTQyLjI3Ny4yNzcgMCAwIDEgLjAxOC4wOTRoLTRjMCAuODQ4LjI5IDEuNzEuODc0IDIuMzg0bDMuMDIzLTIuNjJabS0xLjE2MiAwYS43NzYuNzc2IDAgMCAxIC41ODEtLjI0NWMuMjcgMCAuNDc2LjEyNC41OC4yNDVsLTMuMDIyIDIuNjJjMS4zMTEgMS41MTMgMy41NzMgMS41MTMgNC44ODQgMGwtMy4wMjMtMi42MlptLS4xMDMuMjM2YzAtLjAyNy4wMDUtLjA1OC4wMTgtLjA5NGEuNDUuNDUgMCAwIDEgLjA4NS0uMTQybDMuMDIzIDIuNjJhMy42NDQgMy42NDQgMCAwIDAgLjg3NC0yLjM4NGgtNFptMC0yLjQwOXYyLjQwOWg0di0yLjQwOWgtNFptLjY4NC40ODFjLS4yOSAwLS40OS0uMTQtLjU4LS4yNDVhLjM3LjM3IDAgMCAxLS4xMDQtLjIzNmg0YzAtMS42NjUtMS4yMjUtMy41MTktMy4zMTYtMy41MTl2NFptLTE0LjQ4LTRjLTIuMDkxIDAtMy4zMTYgMS44NTQtMy4zMTYgMy41MTloNGEuMzcuMzcgMCAwIDEtLjEwNC4yMzYuNzc0Ljc3NCAwIDAgMS0uNTguMjQ1di00Wm0zLjMxNiAzLjUxOWMwLTEuNjY1LTEuMjI1LTMuNTE5LTMuMzE2LTMuNTE5djRjLS4yOSAwLS40OS0uMTQtLjU4LS4yNDVhLjM3LjM3IDAgMCAxLS4xMDQtLjIzNmg0Wm0wIDIuNDA5di0yLjQwOWgtNHYyLjQwOWg0Wm0tLjg3NCAyLjM4NGEzLjY0NCAzLjY0NCAwIDAgMCAuODc0LTIuMzg0aC00YzAtLjAyNy4wMDUtLjA1OC4wMTgtLjA5NGEuNDUuNDUgMCAwIDEgLjA4NS0uMTQybDMuMDIzIDIuNjJabS00Ljg4NCAwYzEuMzExIDEuNTEzIDMuNTczIDEuNTEzIDQuODg0IDBsLTMuMDIzLTIuNjJhLjc3Ni43NzYgMCAwIDEgLjU4LS4yNDVjLjI3MSAwIC40NzcuMTI0LjU4MS4yNDVsLTMuMDIyIDIuNjJabS0uODc0LTIuMzg0YzAgLjg0OC4yODkgMS43MS44NzQgMi4zODRsMy4wMjMtMi42MmEuNDUzLjQ1MyAwIDAgMSAuMDg1LjE0Mi4yNzcuMjc3IDAgMCAxIC4wMTcuMDk0aC00Wm0wLTIuNDA5djIuNDA5aDR2LTIuNDA5aC00Wm0yMi40MjkgMGEuMzcuMzcgMCAwIDEtLjEwMy4yMzYuNzc0Ljc3NCAwIDAgMS0uNTguMjQ1di00Yy0yLjA5MiAwLTMuMzE3IDEuODU0LTMuMzE3IDMuNTE5aDRabTAgMi40MDl2LTIuNDA5aC00djIuNDA5aDRabS0uMTAzLS4yMzZhLjQ1LjQ1IDAgMCAxIC4wODUuMTQyLjI3Ny4yNzcgMCAwIDEgLjAxOC4wOTRoLTRjMCAuODQ4LjI5IDEuNzEuODc0IDIuMzg0bDMuMDIzLTIuNjJabS0xLjE2MiAwYS43NzYuNzc2IDAgMCAxIC41ODEtLjI0NWMuMjcgMCAuNDc2LjEyNC41OC4yNDVsLTMuMDIyIDIuNjJjMS4zMTIgMS41MTMgMy41NzMgMS41MTMgNC44ODUgMGwtMy4wMjMtMi42MlptLS4xMDIuMjM2YzAtLjAyNy4wMDQtLjA1OC4wMTctLjA5NGEuNDU1LjQ1NSAwIDAgMSAuMDg1LS4xNDJsMy4wMjMgMi42MmEzLjY0NSAzLjY0NSAwIDAgMCAuODc1LTIuMzg0aC00Wm0wLTIuNDA5djIuNDA5aDR2LTIuNDA5aC00Wm0uNjgzLjQ4MWMtLjI5IDAtLjQ5LS4xNC0uNTgtLjI0NWEuMzcuMzcgMCAwIDEtLjEwMy0uMjM2aDRjMC0xLjY2NS0xLjIyNi0zLjUxOS0zLjMxNy0zLjUxOXY0WiIKICAgICAgICBmaWxsPSIjZmZmIiBtYXNrPSJ1cmwoI2EpIiAvPgo8L3N2Zz4="
class="header3__card-recommendation-header-photo header3__card-recommendation-header-photo_default_inverted"
/>
</div>
<div class="header3__card-recommendation-header-content">
<div class="header3__card-recommendation-header-content-tag">
Курс
</div>
<div class="header3__card-recommendation-header-sub">
<span
class="header3__card-recommendation-header-sub-chunk header3__card-recommendation-header-sub-chunk_green"
>
Скидка 20000 ₽
</span>
</div>
</div>
</div>
<h6 class="header3__card-recommendation-title">
ИИ для аналитики и работы с данными
</h6>
<div class="header3__card-recommendation-footer">
12 марта
, 2026
· 2 месяца
</div>
</a>
</div>
</div>
</div>
</div>
<svg
class="header3__nav-item-popup-figure"
viewBox="0 0 600 600"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M125.573 140.619C131.659 71.6017 210.245 34.9562 267.027 74.6573L553.942 275.262C610.723 314.962 603.117 401.233 540.247 430.55L222.58 578.681C159.71 607.997 88.7344 558.37 94.8204 489.355L125.573 140.619Z"
stroke="#eaeaea"
class="header3__nav-item-popup-figure-spinner"
></path>
<path
d="M148.472 246.647C133.624 191.005 184.615 140.013 240.257 154.862L519.856 229.476C575.498 244.325 594.059 313.877 553.266 354.67L348.281 559.656C307.488 600.449 237.935 581.888 223.087 526.246L148.472 246.647Z"
fill="url(#paint0_linear-learning)"
></path>
<defs>
<linearGradient
id="paint0_linear-learning"
x1="128.696"
y1="395.739"
x2="443.538"
y2="180.173"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#f9f9f9"></stop>
<stop offset="1" stop-color="#EBEBEB"></stop>
</linearGradient>
</defs>
</svg>
</div>
</div>
<div data-name="info" class="header3__nav-item js-header3-popup-trigger header3__nav-item_only-desktop header3__nav-item_with-hover " >
<span title="Информация" class="header3__nav-item-arrow-title">Информация</span>
<div class="header3__nav-item-arrow-container js-header3-popup-arrow">
<svg
width="10"
height="5"
viewBox="0 0 10 5"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="header3__nav-item-arrow"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M1.10067 0.378818C1.29593 0.183556 1.61251 0.183555 1.80778 0.378818L5.00023 3.57127L8.19272 0.378777C8.38798 0.183515 8.70457 0.183515 8.89983 0.378777C9.09509 0.574039 9.09509 0.890622 8.89983 1.08588L5.3643 4.62142C5.26426 4.72146 5.13237 4.77024 5.00127 4.76777C4.8695 4.77079 4.73676 4.72202 4.6362 4.62146L1.10067 1.08592C0.905408 0.890663 0.905408 0.57408 1.10067 0.378818Z"
fill="currentColor"
></path>
</svg>
</div>
</div>
<div class="header3__nav-item-popup-wrapper js-header3-popup" data-name="info" style="display: none;">
<div class="header3__nav-item-popup-container js-header3-popup-container">
<div class="header3__nav-item-popup-content">
<div class="header3__nav-column">
<div>
<p class="header3__nav-section-title ">OTUS</p>
<div class="header3__nav-section-items ">
<a
class="header3__nav-section-item"
href="/about"
>
О компании
</a>
<a
class="header3__nav-section-item"
href="/smi/"
>
СМИ о нас
</a>
<a
class="header3__nav-section-item js-stats"
href="/journal/"
target="_blank"
rel="noreferrer nofollow"
data-event="header;click_otus_journal"
data-goal="click_otus_journal"
>
OTUS Журнал
</a>
<a
class="header3__nav-section-item"
href="https://direct.otus.ru/"
target="_blank"
rel="noreferrer nofollow"
>
OTUS Директ
</a>
<a
class="header3__nav-section-item"
href="/legal/common/"
>
Сведения об образовательной организации
</a>
<a
class="header3__nav-section-item"
href="/contacts/"
>
Контактная информация
</a>
</div>
</div>
</div>
<div class="header3__nav-column">
<div>
<p class="header3__nav-section-title ">Студентам</p>
<div class="header3__nav-section-items ">
<a
class="header3__nav-section-item"
href="/reviews"
>
Отзывы
</a>
<a
class="header3__nav-section-item"
target="_blank"
href="https://landing.otus.ru/about-otus"
>
Как выбрать курс
</a>
<a
class="header3__nav-section-item"
target="_blank"
href="https://landing.otus.ru/gallery"
>
Истории выпускников
</a>
<a
class="header3__nav-section-item"
href="/instructors/"
>
Наши преподаватели
</a>
<a
class="header3__nav-section-item"
href="/employers/all/"
>
Наши партнеры
</a>
<a
class="header3__nav-section-item"
href="/about/loyalty/"
>
Программа лояльности
</a>
<a
class="header3__nav-section-item"
href="/faq/"
>
Вопросы и ответы
</a>
</div>
</div>
</div>
<div class="header3__nav-column">
<div>
<p class="header3__nav-section-title ">Преподавателям</p>
<div class="header3__nav-section-items ">
<a
class="header3__nav-section-item"
href="/teach/"
>
Стать преподавателем
</a>
<a
class="header3__nav-section-item"
href="/instructors/"
>
Наши преподаватели
</a>
<a
class="header3__nav-section-item"
href="/nest/dlja-prepodavatelej/"
>
База знаний
</a>
</div>
</div>
</div>
<div class="header3__nav-column header3__nav-column_space-between">
<div>
<p class="header3__nav-section-title header3__nav-section-items-recommendation-title">OTUS рекомендует</p>
<div class="header3__nav-section-items header3__nav-section-items_not-items header3__nav-section-items-recommendation">
<a
href="/lessons/ai-dlya-analitiki-i-raboty-s-dannymi/"
class="header3__card-recommendation"
>
<div
class="header3__card-recommendation-background"
style="background: linear-gradient( 90deg,#0A4489, #00316B);"
></div>
<div class="header3__card-recommendation-header">
<div class="header3__card-recommendation-header-photo-wrapper" style="background: linear-gradient( 90deg,#0A4489, #00316B);">
<img
src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODAiIGhlaWdodD0iODAiIHZpZXdCb3g9IjAgMCA4MCA4MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICAgIDxwYXRoCiAgICAgICAgZD0iTTM1LjAwNDcgNDUuODcyNUMzNS4wMDQ3IDQ2LjkzMDYgMzYuMTA5IDQ3LjU3NTcgMzYuOTYwMyA0Ny4wMTQ5TDQ0LjYyOTIgNDEuOTYzMUM0NS4wMDI5IDQxLjcxNjkgNDUuMjI5OSA0MS4yODUyIDQ1LjIyOTkgNDAuODIwN1YyNi43NDg5QzQ1LjIyOTkgMjYuNzQ4OSA0Ny43ODYyIDI2Ljc0ODkgNTQuMTc3IDIyLjcwNzRDNTcuMzAxMiAyMC43MzE4IDU4Ljg5OCAxOC43NTYxIDU5LjcxNDMgMTcuMjUyNUM2MC4yNDY1IDE2LjI3MjEgNTkuMjUxMSAxNS41NTg4IDU4LjI5NjUgMTYuMDcyM0M1NS4zMjkyIDE3LjY2ODQgNDkuMzkxOCAyMC4wMTMyIDQwLjExNzMgMjAuMDEzMkMzMC44NDI4IDIwLjAxMzIgMjQuOTA1NCAxNy42Njg0IDIxLjkzODEgMTYuMDcyM0MyMC45ODM1IDE1LjU1ODggMTkuOTg4MSAxNi4yNzIxIDIwLjUyMDMgMTcuMjUyNUMyMS4zMzY2IDE4Ljc1NjEgMjIuOTMzNCAyMC43MzE4IDI2LjA1NzcgMjIuNzA3NEMzMi40NDg0IDI2Ljc0ODkgMzUuMDA0NyAyNi43NDg5IDM1LjAwNDcgMjYuNzQ4OVY0NS44NzI1WiIKICAgICAgICBmaWxsPSJ3aGl0ZSIgLz4KICAgIDxlbGxpcHNlIGN4PSIyNi45NTM1IiBjeT0iMzUuNTUyNiIgcng9IjMuOTQ5MTQiIHJ5PSI0LjA4NTMyIiBmaWxsPSJ3aGl0ZSIgLz4KICAgIDxlbGxpcHNlIGN4PSI1My4yODEyIiBjeT0iMzUuNTUyNiIgcng9IjMuOTQ5MTQiIHJ5PSI0LjA4NTMyIiBmaWxsPSJ3aGl0ZSIgLz4KICAgIDxwYXRoCiAgICAgICAgZD0iTTI1LjYzNzIgNTkuNjM1QzI1LjYzNzIgNTguNzk2MSAyNi4yMjY2IDU4LjExNiAyNi45NTM2IDU4LjExNkMyNy42ODA2IDU4LjExNiAyOC4yNyA1OC43OTYxIDI4LjI3IDU5LjYzNVY2Mi4wNDRDMjguMjcgNjIuNDQ2OSAyOC4xMzEzIDYyLjgzMzMgMjcuODg0NCA2My4xMTgxQzI3LjM3MDMgNjMuNzExNCAyNi41MzY4IDYzLjcxMTQgMjYuMDIyOCA2My4xMTgxQzI1Ljc3NTkgNjIuODMzMyAyNS42MzcyIDYyLjQ0NjkgMjUuNjM3MiA2Mi4wNDRWNTkuNjM1WiIKICAgICAgICBmaWxsPSJ3aGl0ZSIgLz4KICAgIDxwYXRoCiAgICAgICAgZD0iTTQ0LjA2NjUgNTkuNjM1QzQ0LjA2NjUgNTguNzk2MSA0NC42NTU5IDU4LjExNiA0NS4zODI5IDU4LjExNkM0Ni4xMDk5IDU4LjExNiA0Ni42OTkzIDU4Ljc5NjEgNDYuNjk5MyA1OS42MzVWNjIuMDQ0QzQ2LjY5OTMgNjIuNDQ2OSA0Ni41NjA2IDYyLjgzMzMgNDYuMzEzOCA2My4xMTgxQzQ1Ljc5OTcgNjMuNzExNCA0NC45NjYyIDYzLjcxMTQgNDQuNDUyMSA2My4xMTgxQzQ0LjIwNTIgNjIuODMzMyA0NC4wNjY1IDYyLjQ0NjkgNDQuMDY2NSA2Mi4wNDRWNTkuNjM1WiIKICAgICAgICBmaWxsPSJ3aGl0ZSIgLz4KICAgIDxwYXRoCiAgICAgICAgZD0iTTI5LjU4NjQgNTkuNjM1QzI5LjU4NjQgNTguNzk2MSAzMC4xNzU3IDU4LjExNiAzMC45MDI3IDU4LjExNkMzMS42Mjk4IDU4LjExNiAzMi4yMTkxIDU4Ljc5NjEgMzIuMjE5MSA1OS42MzVWNjIuMDQ0QzMyLjIxOTEgNjIuNDQ2OSAzMi4wODA0IDYyLjgzMzMgMzEuODMzNiA2My4xMTgxQzMxLjMxOTUgNjMuNzExNCAzMC40ODYgNjMuNzExNCAyOS45NzE5IDYzLjExODFDMjkuNzI1IDYyLjgzMzMgMjkuNTg2NCA2Mi40NDY5IDI5LjU4NjQgNjIuMDQ0VjU5LjYzNVoiCiAgICAgICAgZmlsbD0id2hpdGUiIC8+CiAgICA8cGF0aAogICAgICAgIGQ9Ik00OC4wMTU3IDU5LjYzNUM0OC4wMTU3IDU4Ljc5NjEgNDguNjA1MSA1OC4xMTYgNDkuMzMyMSA1OC4xMTZDNTAuMDU5MSA1OC4xMTYgNTAuNjQ4NSA1OC43OTYxIDUwLjY0ODUgNTkuNjM1VjYyLjA0NEM1MC42NDg1IDYyLjQ0NjkgNTAuNTA5OCA2Mi44MzMzIDUwLjI2MjkgNjMuMTE4MUM0OS43NDg4IDYzLjcxMTQgNDguOTE1MyA2My43MTE0IDQ4LjQwMTIgNjMuMTE4MUM0OC4xNTQ0IDYyLjgzMzMgNDguMDE1NyA2Mi40NDY5IDQ4LjAxNTcgNjIuMDQ0VjU5LjYzNVoiCiAgICAgICAgZmlsbD0id2hpdGUiIC8+CiAgICA8cGF0aAogICAgICAgIGQ9Ik0zMy41MzU1IDU5LjYzNUMzMy41MzU1IDU4Ljc5NjEgMzQuMTI0OSA1OC4xMTYgMzQuODUxOSA1OC4xMTZDMzUuNTc4OSA1OC4xMTYgMzYuMTY4MyA1OC43OTYxIDM2LjE2ODMgNTkuNjM1VjYyLjA0NEMzNi4xNjgzIDYyLjQ0NjkgMzYuMDI5NiA2Mi44MzMzIDM1Ljc4MjcgNjMuMTE4MUMzNS4yNjg2IDYzLjcxMTQgMzQuNDM1MSA2My43MTE0IDMzLjkyMTEgNjMuMTE4MUMzMy42NzQyIDYyLjgzMzMgMzMuNTM1NSA2Mi40NDY5IDMzLjUzNTUgNjIuMDQ0VjU5LjYzNVoiCiAgICAgICAgZmlsbD0id2hpdGUiIC8+CiAgICA8cGF0aAogICAgICAgIGQ9Ik01MS45NjQ4IDU5LjYzNUM1MS45NjQ4IDU4Ljc5NjEgNTIuNTU0MiA1OC4xMTYgNTMuMjgxMiA1OC4xMTZDNTQuMDA4MiA1OC4xMTYgNTQuNTk3NiA1OC43OTYxIDU0LjU5NzYgNTkuNjM1VjYyLjA0NEM1NC41OTc2IDYyLjQ0NjkgNTQuNDU4OSA2Mi44MzMzIDU0LjIxMiA2My4xMTgxQzUzLjY5OCA2My43MTE0IDUyLjg2NDUgNjMuNzExNCA1Mi4zNTA0IDYzLjExODFDNTIuMTAzNSA2Mi44MzMzIDUxLjk2NDggNjIuNDQ2OSA1MS45NjQ4IDYyLjA0NFY1OS42MzVaIgogICAgICAgIGZpbGw9IndoaXRlIiAvPgo8L3N2Zz4="
class="header3__card-recommendation-header-photo header3__card-recommendation-header-photo_default"
/>
<img
src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODAiIGhlaWdodD0iODAiIHZpZXdCb3g9IjAgMCA4MCA4MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICAgIDxtYXNrIGlkPSJhIiBmaWxsPSIjZmZmIj4KICAgICAgICA8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIKICAgICAgICAgICAgZD0iTTAgMTJDMCA1LjM3MyA1LjM3MyAwIDEyIDBoNTZjNi42MjcgMCAxMiA1LjM3MyAxMiAxMnY1NmMwIDYuNjI3LTUuMzczIDEyLTEyIDEySDEyQzUuMzczIDgwIDAgNzQuNjI3IDAgNjhWMTJabTM1LjAwNSAzMy44NzNjMCAxLjA1OCAxLjEwNCAxLjcwMyAxLjk1NSAxLjE0Mmw3LjY3LTUuMDUyYy4zNzMtLjI0Ni42LS42NzguNi0xLjE0MlYyNi43NDlzMi41NTYgMCA4Ljk0Ny00LjA0MmMzLjEyNC0xLjk3NSA0LjcyMS0zLjk1MSA1LjUzNy01LjQ1NC41MzMtLjk4MS0uNDYzLTEuNjk0LTEuNDE3LTEuMTgtMi45NjggMS41OTUtOC45MDUgMy45NC0xOC4xOCAzLjk0LTkuMjc0IDAtMTUuMjEyLTIuMzQ1LTE4LjE3OS0zLjk0LS45NTQtLjUxNC0xLjk1LjE5OS0xLjQxOCAxLjE4LjgxNyAxLjUwMyAyLjQxMyAzLjQ3OSA1LjUzOCA1LjQ1NCA2LjM5IDQuMDQyIDguOTQ3IDQuMDQyIDguOTQ3IDQuMDQydjE5LjEyNFptLTguMDUxLTYuMjM1YzIuMTggMCAzLjk0OS0xLjgzIDMuOTQ5LTQuMDg1IDAtMi4yNTctMS43NjgtNC4wODYtMy45NS00LjA4Ni0yLjE4IDAtMy45NDkgMS44My0zLjk0OSA0LjA4NiAwIDIuMjU2IDEuNzY4IDQuMDg1IDMuOTUgNC4wODVabTMwLjI3Ni00LjA4NWMwIDIuMjU2LTEuNzY4IDQuMDg1LTMuOTQ5IDQuMDg1LTIuMTggMC0zLjk0OS0xLjgzLTMuOTQ5LTQuMDg1IDAtMi4yNTcgMS43NjgtNC4wODYgMy45NS00LjA4NiAyLjE4IDAgMy45NDggMS44MyAzLjk0OCA0LjA4NlpNMjYuOTU0IDU4LjExNmMtLjcyNyAwLTEuMzE3LjY4LTEuMzE3IDEuNTE5djIuNDA5YzAgLjQwMy4xMzkuNzkuMzg2IDEuMDc0LjUxNC41OTMgMS4zNDcuNTkzIDEuODYxIDAgLjI0Ny0uMjg1LjM4Ni0uNjcxLjM4Ni0xLjA3NHYtMi40MDljMC0uODM5LS41OS0xLjUxOS0xLjMxNy0xLjUxOVptMTguNDI5IDBjLS43MjcgMC0xLjMxNy42OC0xLjMxNyAxLjUxOXYyLjQwOWMwIC40MDMuMTQuNzkuMzg2IDEuMDc0LjUxNC41OTMgMS4zNDguNTkzIDEuODYyIDAgLjI0Ny0uMjg1LjM4NS0uNjcxLjM4NS0xLjA3NHYtMi40MDljMC0uODM5LS41OS0xLjUxOS0xLjMxNi0xLjUxOVptLTE1Ljc5NyAxLjUxOWMwLS44MzkuNTktMS41MTkgMS4zMTctMS41MTlzMS4zMTYuNjggMS4zMTYgMS41MTl2Mi40MDljMCAuNDAzLS4xMzkuNzktLjM4NiAxLjA3NC0uNTE0LjU5My0xLjM0Ny41OTMtMS44NjEgMGExLjY0NSAxLjY0NSAwIDAgMS0uMzg2LTEuMDc0di0yLjQwOVptMTkuNzQ2LTEuNTE5Yy0uNzI3IDAtMS4zMTYuNjgtMS4zMTYgMS41MTl2Mi40MDljMCAuNDAzLjEzOC43OS4zODUgMS4wNzQuNTE0LjU5MyAxLjM0OC41OTMgMS44NjIgMCAuMjQ3LS4yODUuMzg1LS42NzEuMzg1LTEuMDc0di0yLjQwOWMwLS44MzktLjU4OS0xLjUxOS0xLjMxNi0xLjUxOVptLTE1Ljc5NyAxLjUxOWMwLS44MzkuNTktMS41MTkgMS4zMTctMS41MTlzMS4zMTYuNjggMS4zMTYgMS41MTl2Mi40MDljMCAuNDAzLS4xMzkuNzktLjM4NSAxLjA3NC0uNTE0LjU5My0xLjM0OC41OTMtMS44NjIgMGExLjY0NSAxLjY0NSAwIDAgMS0uMzg2LTEuMDc0di0yLjQwOVptMTkuNzQ2LTEuNTE5Yy0uNzI3IDAtMS4zMTYuNjgtMS4zMTYgMS41MTl2Mi40MDljMCAuNDAzLjEzOC43OS4zODUgMS4wNzQuNTE0LjU5MyAxLjM0OC41OTMgMS44NjIgMCAuMjQ3LS4yODUuMzg2LS42NzEuMzg2LTEuMDc0di0yLjQwOWMwLS44MzktLjU5LTEuNTE5LTEuMzE3LTEuNTE5WiIgLz4KICAgIDwvbWFzaz4KICAgIDxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIgogICAgICAgIGQ9Ik0wIDEyQzAgNS4zNzMgNS4zNzMgMCAxMiAwaDU2YzYuNjI3IDAgMTIgNS4zNzMgMTIgMTJ2NTZjMCA2LjYyNy01LjM3MyAxMi0xMiAxMkgxMkM1LjM3MyA4MCAwIDc0LjYyNyAwIDY4VjEyWm0zNS4wMDUgMzMuODczYzAgMS4wNTggMS4xMDQgMS43MDMgMS45NTUgMS4xNDJsNy42Ny01LjA1MmMuMzczLS4yNDYuNi0uNjc4LjYtMS4xNDJWMjYuNzQ5czIuNTU2IDAgOC45NDctNC4wNDJjMy4xMjQtMS45NzUgNC43MjEtMy45NTEgNS41MzctNS40NTQuNTMzLS45ODEtLjQ2My0xLjY5NC0xLjQxNy0xLjE4LTIuOTY4IDEuNTk1LTguOTA1IDMuOTQtMTguMTggMy45NC05LjI3NCAwLTE1LjIxMi0yLjM0NS0xOC4xNzktMy45NC0uOTU0LS41MTQtMS45NS4xOTktMS40MTggMS4xOC44MTcgMS41MDMgMi40MTMgMy40NzkgNS41MzggNS40NTQgNi4zOSA0LjA0MiA4Ljk0NyA0LjA0MiA4Ljk0NyA0LjA0MnYxOS4xMjRabS04LjA1MS02LjIzNWMyLjE4IDAgMy45NDktMS44MyAzLjk0OS00LjA4NSAwLTIuMjU3LTEuNzY4LTQuMDg2LTMuOTUtNC4wODYtMi4xOCAwLTMuOTQ5IDEuODMtMy45NDkgNC4wODYgMCAyLjI1NiAxLjc2OCA0LjA4NSAzLjk1IDQuMDg1Wm0zMC4yNzYtNC4wODVjMCAyLjI1Ni0xLjc2OCA0LjA4NS0zLjk0OSA0LjA4NS0yLjE4IDAtMy45NDktMS44My0zLjk0OS00LjA4NSAwLTIuMjU3IDEuNzY4LTQuMDg2IDMuOTUtNC4wODYgMi4xOCAwIDMuOTQ4IDEuODMgMy45NDggNC4wODZaTTI2Ljk1NCA1OC4xMTZjLS43MjcgMC0xLjMxNy42OC0xLjMxNyAxLjUxOXYyLjQwOWMwIC40MDMuMTM5Ljc5LjM4NiAxLjA3NC41MTQuNTkzIDEuMzQ3LjU5MyAxLjg2MSAwIC4yNDctLjI4NS4zODYtLjY3MS4zODYtMS4wNzR2LTIuNDA5YzAtLjgzOS0uNTktMS41MTktMS4zMTctMS41MTlabTE4LjQyOSAwYy0uNzI3IDAtMS4zMTcuNjgtMS4zMTcgMS41MTl2Mi40MDljMCAuNDAzLjE0Ljc5LjM4NiAxLjA3NC41MTQuNTkzIDEuMzQ4LjU5MyAxLjg2MiAwIC4yNDctLjI4NS4zODUtLjY3MS4zODUtMS4wNzR2LTIuNDA5YzAtLjgzOS0uNTktMS41MTktMS4zMTYtMS41MTlabS0xNS43OTcgMS41MTljMC0uODM5LjU5LTEuNTE5IDEuMzE3LTEuNTE5czEuMzE2LjY4IDEuMzE2IDEuNTE5djIuNDA5YzAgLjQwMy0uMTM5Ljc5LS4zODYgMS4wNzQtLjUxNC41OTMtMS4zNDcuNTkzLTEuODYxIDBhMS42NDUgMS42NDUgMCAwIDEtLjM4Ni0xLjA3NHYtMi40MDlabTE5Ljc0Ni0xLjUxOWMtLjcyNyAwLTEuMzE2LjY4LTEuMzE2IDEuNTE5djIuNDA5YzAgLjQwMy4xMzguNzkuMzg1IDEuMDc0LjUxNC41OTMgMS4zNDguNTkzIDEuODYyIDAgLjI0Ny0uMjg1LjM4NS0uNjcxLjM4NS0xLjA3NHYtMi40MDljMC0uODM5LS41ODktMS41MTktMS4zMTYtMS41MTlabS0xNS43OTcgMS41MTljMC0uODM5LjU5LTEuNTE5IDEuMzE3LTEuNTE5czEuMzE2LjY4IDEuMzE2IDEuNTE5djIuNDA5YzAgLjQwMy0uMTM5Ljc5LS4zODUgMS4wNzQtLjUxNC41OTMtMS4zNDguNTkzLTEuODYyIDBhMS42NDUgMS42NDUgMCAwIDEtLjM4Ni0xLjA3NHYtMi40MDlabTE5Ljc0Ni0xLjUxOWMtLjcyNyAwLTEuMzE2LjY4LTEuMzE2IDEuNTE5djIuNDA5YzAgLjQwMy4xMzguNzkuMzg1IDEuMDc0LjUxNC41OTMgMS4zNDguNTkzIDEuODYyIDAgLjI0Ny0uMjg1LjM4Ni0uNjcxLjM4Ni0xLjA3NHYtMi40MDljMC0uODM5LS41OS0xLjUxOS0xLjMxNy0xLjUxOVoiCiAgICAgICAgZmlsbD0iI2ZmZiIgLz4KICAgIDxwYXRoCiAgICAgICAgZD0ibTM2Ljk2IDQ3LjAxNSAxLjEgMS42Ny0xLjEtMS42N1ptNy42Ny01LjA1MiAxLjEgMS42Ny0xLjEtMS42N1ptLjYtMTUuMjE0di0yaC0ydjJoMlptOC45NDctNC4wNDIgMS4wNjkgMS42OS0xLjA2OS0xLjY5Wm01LjUzNy01LjQ1NCAxLjc1OC45NTQtMS43NTgtLjk1NFptLTEuNDE3LTEuMTguOTQ3IDEuNzYtLjk0Ny0xLjc2Wm0tMzYuMzU5IDAtLjk0NyAxLjc2Ljk0Ny0xLjc2Wm0tMS40MTggMS4xOCAxLjc1OC0uOTU1LTEuNzU4Ljk1NVptNS41MzggNS40NTQtMS4wNyAxLjY5IDEuMDctMS42OVptOC45NDcgNC4wNDJoMnYtMmgtMnYyWm0tOC45ODIgMzYuMzcgMS41MTEtMS4zMS0xLjUxMSAxLjMxWm0xLjg2MSAwIDEuNTEyIDEuMzA5LTEuNTEyLTEuMzFabTE2LjU2OCAwLTEuNTExIDEuMzA5IDEuNTExLTEuMzFabTEuODYyIDAgMS41MTEgMS4zMDktMS41MTEtMS4zMVptLTE0LjQ4IDAtMS41MTItMS4zMSAxLjUxMiAxLjMxWm0tMS44NjIgMCAxLjUxMS0xLjMxLTEuNTExIDEuMzFabTE4LjQzIDAtMS41MTIgMS4zMDkgMS41MTEtMS4zMVptMS44NiAwIDEuNTEyIDEuMzA5LTEuNTExLTEuMzFabS0xNC40OCAwIDEuNTEyIDEuMzA5LTEuNTExLTEuMzFabS0xLjg2MSAwLTEuNTExIDEuMzA5IDEuNTExLTEuMzFabTE4LjQzIDAgMS41MS0xLjMxLTEuNTEgMS4zMVptMS44NjEgMCAxLjUxMSAxLjMwOS0xLjUxMS0xLjMxWk0xMi0yQzQuMjY4LTItMiA0LjI2OC0yIDEyaDRDMiA2LjQ3NyA2LjQ3NyAyIDEyIDJ2LTRabTU2IDBIMTJ2NGg1NnYtNFptMTQgMTRjMC03LjczMi02LjI2OC0xNC0xNC0xNHY0YzUuNTIzIDAgMTAgNC40NzcgMTAgMTBoNFptMCA1NlYxMmgtNHY1Nmg0Wk02OCA4MmM3LjczMiAwIDE0LTYuMjY4IDE0LTE0aC00YzAgNS41MjMtNC40NzcgMTAtMTAgMTB2NFptLTU2IDBoNTZ2LTRIMTJ2NFpNLTIgNjhjMCA3LjczMiA2LjI2OCAxNCAxNCAxNHYtNEM2LjQ3NyA3OCAyIDczLjUyMyAyIDY4aC00Wm0wLTU2djU2aDRWMTJoLTRabTM3Ljg2IDMzLjM0NWEuNzkxLjc5MSAwIDAgMSAuODEyLS4wMjEuNjU2LjY1NiAwIDAgMSAuMzMzLjU0OWgtNGMwIDIuNDU0IDIuNzE0IDQuMzU1IDUuMDU1IDIuODEybC0yLjItMy4zNFptNy42NjktNS4wNTItNy42NjkgNS4wNTIgMi4yIDMuMzQgNy42Ny01LjA1Mi0yLjIwMS0zLjM0Wm0tLjI5OS41MjhjMC0uMTc4LjA4Ni0uMzg4LjI5OS0uNTI4bDIuMiAzLjM0YTMuMzY1IDMuMzY1IDAgMCAwIDEuNTAxLTIuODEyaC00Wm0wLTE0LjA3MlY0MC44Mmg0VjI2Ljc0OWgtNFptOS44NzgtNS43MzJjLTMuMDk3IDEuOTU5LTUuMTk3IDIuODk1LTYuNDY0IDMuMzRhOC44NDUgOC44NDUgMCAwIDEtMS4yOC4zNjIgMi40MiAyLjQyIDAgMCAxLS4yMDMuMDMxSDQ1LjE5bC4wMTgtLjAwMWguMDJjLjAwMSAwIC4wMDMgMCAuMDAzIDJzLjAwMiAyIC4wMDMgMmguMDU0YTMuMTk2IDMuMTk2IDAgMCAwIC4yNTYtLjAxN2MuMTQ0LS4wMTQuMzI3LS4wMzguNTUyLS4wOC40NS0uMDg0IDEuMDY5LS4yMzggMS44NzUtLjUyMSAxLjYxLS41NjYgMy45ODItMS42NSA3LjI3Ni0zLjczM2wtMi4xMzgtMy4zODFabTQuODQ5LTQuNzE5Yy0uNjM3IDEuMTczLTEuOTgzIDIuOTA2LTQuODQ5IDQuNzE5bDIuMTM4IDMuMzhjMy4zODItMi4xMzggNS4yMy00LjM1NiA2LjIyNi02LjE5bC0zLjUxNS0xLjkwOVptMS4yODcgMS41MzZjLS4xMDYuMDU2LS40OTQuMTczLS45MTYtLjE1OGExLjIzMyAxLjIzMyAwIDAgMS0uNDQ4LS43Ni45ODUuOTg1IDAgMCAxIC4wNzctLjYxOGwzLjUxNSAxLjkwOWMuMzA4LS41NjguNDcyLTEuMjQ4LjM1MS0xLjk2YTIuNzY4IDIuNzY4IDAgMCAwLTEuMDI1LTEuNzE4Yy0xLjAyNi0uODA1LTIuMzg4LS43ODgtMy40NDktLjIxOGwxLjg5NSAzLjUyM1ptLTE5LjEyNyA0LjE4YzkuNjI4IDAgMTUuODg5LTIuNDM4IDE5LjEyNy00LjE4bC0xLjg5NS0zLjUyM2MtMi42OTYgMS40NS04LjMxIDMuNzAyLTE3LjIzMiAzLjcwMnY0Wm0tMTkuMTI2LTQuMThjMy4yMzggMS43NDIgOS40OTkgNC4xOCAxOS4xMjYgNC4xOHYtNGMtOC45MjEgMC0xNC41MzUtMi4yNTMtMTcuMjMxLTMuNzAzbC0xLjg5NSAzLjUyM1ptMS4yODctMS41MzZjLjA0Mi4wNzguMTMxLjMuMDc3LjYxOWExLjIzMiAxLjIzMiAwIDAgMS0uNDQ5Ljc1OWMtLjQyMi4zMy0uODEuMjE1LS45MTUuMTU4bDEuODk1LTMuNTIzYy0xLjA2LS41Ny0yLjQyNC0uNTg3LTMuNDQ5LjIxOGEyLjc2OCAyLjc2OCAwIDAgMC0xLjAyNiAxLjcxOWMtLjEyLjcxMS4wNDMgMS4zOTEuMzUyIDEuOTU5bDMuNTE1LTEuOTA5Wm00Ljg0OSA0LjcxOWMtMi44NjctMS44MTMtNC4yMTItMy41NDYtNC44NDktNC43MTlsLTMuNTE1IDEuOTA5Yy45OTUgMS44MzQgMi44NDQgNC4wNTIgNi4yMjYgNi4xOWwyLjEzOC0zLjM4Wm03Ljg3OCA1LjczMmMwLTIgLjAwMS0yIC4wMDMtMkgzNS4wNDdsLjAyNS4wMDFoLjAwMWEyLjQyIDIuNDIgMCAwIDEtLjIwMi0uMDMgOC44NTIgOC44NTIgMCAwIDEtMS4yOC0uMzYzYy0xLjI2Ny0uNDQ1LTMuMzY3LTEuMzgxLTYuNDY0LTMuMzRsLTIuMTM4IDMuMzhjMy4yOTMgMi4wODQgNS42NjYgMy4xNjggNy4yNzYgMy43MzQuODA2LjI4MyAxLjQyNS40MzcgMS44NzUuNTJhNi4zNTYgNi4zNTYgMCAwIDAgLjczNy4wOTVsLjA3MS4wMDJoLjA1NGMuMDAxIDAgLjAwMyAwIC4wMDMtMlptMiAxOS4xMjRWMjYuNzQ5aC00djE5LjEyNGg0Wm0tOC4xMDItMTAuMzJjMCAxLjIxNi0uOTM2IDIuMDg1LTEuOTUgMi4wODV2NGMzLjM1IDAgNS45NS0yLjc4OSA1Ljk1LTYuMDg1aC00Wm0tMS45NS0yLjA4NmMxLjAxNCAwIDEuOTUuODcgMS45NSAyLjA4Nmg0YzAtMy4yOTctMi42LTYuMDg2LTUuOTUtNi4wODZ2NFptLTEuOTQ5IDIuMDg2YzAtMS4yMTYuOTM2LTIuMDg2IDEuOTUtMi4wODZ2LTRjLTMuMzUgMC01Ljk1IDIuNzktNS45NSA2LjA4Nmg0Wm0xLjk1IDIuMDg1Yy0xLjAxNCAwLTEuOTUtLjg3LTEuOTUtMi4wODVoLTRjMCAzLjI5NiAyLjYgNi4wODUgNS45NSA2LjA4NXYtNFptMjYuMzI3IDRjMy4zNSAwIDUuOTUtMi43ODkgNS45NS02LjA4NWgtNGMwIDEuMjE2LS45MzcgMi4wODUtMS45NSAyLjA4NXY0Wm0tNS45NDktNi4wODVjMCAzLjI5NiAyLjYgNi4wODUgNS45NSA2LjA4NXYtNGMtMS4wMTQgMC0xLjk1LS44Ny0xLjk1LTIuMDg1aC00Wm01Ljk1LTYuMDg2Yy0zLjM1IDAtNS45NSAyLjc5LTUuOTUgNi4wODZoNGMwLTEuMjE2LjkzNi0yLjA4NiAxLjk1LTIuMDg2di00Wm01Ljk0OCA2LjA4NmMwLTMuMjk3LTIuNi02LjA4Ni01Ljk0OS02LjA4NnY0YzEuMDEzIDAgMS45NS44NyAxLjk1IDIuMDg2aDRaTTI3LjYzNyA1OS42MzVhLjM3LjM3IDAgMCAxLS4xMDMuMjM2Ljc3NC43NzQgMCAwIDEtLjU4LjI0NXYtNGMtMi4wOTEgMC0zLjMxNyAxLjg1NC0zLjMxNyAzLjUxOWg0Wm0wIDIuNDA5di0yLjQwOWgtNHYyLjQwOWg0Wm0tLjEwMy0uMjM2YS40NS40NSAwIDAgMSAuMDg2LjE0Mi4yNzcuMjc3IDAgMCAxIC4wMTcuMDk0aC00YzAgLjg0OC4yOSAxLjcxLjg3NCAyLjM4NGwzLjAyMy0yLjYyWm0tMS4xNjEgMGEuNzc2Ljc3NiAwIDAgMSAuNTgtLjI0NWMuMjcgMCAuNDc2LjEyNC41ODEuMjQ1bC0zLjAyMyAyLjYyYzEuMzEyIDEuNTEzIDMuNTczIDEuNTEzIDQuODg1IDBsLTMuMDIzLTIuNjJabS0uMTAzLjIzNmMwLS4wMjcuMDA0LS4wNTguMDE3LS4wOTRhLjQ1LjQ1IDAgMCAxIC4wODYtLjE0MmwzLjAyMyAyLjYyYTMuNjQ0IDMuNjQ0IDAgMCAwIC44NzQtMi4zODRoLTRabTAtMi40MDl2Mi40MDloNHYtMi40MDloLTRabS42ODMuNDgxYy0uMjkgMC0uNDktLjE0LS41OC0uMjQ1YS4zNy4zNyAwIDAgMS0uMTAzLS4yMzZoNGMwLTEuNjY1LTEuMjI2LTMuNTE5LTMuMzE3LTMuNTE5djRabTE5LjExMy0uNDgxYS4zNy4zNyAwIDAgMS0uMTAyLjIzNi43NzQuNzc0IDAgMCAxLS41ODEuMjQ1di00Yy0yLjA5MSAwLTMuMzE3IDEuODU0LTMuMzE3IDMuNTE5aDRabTAgMi40MDl2LTIuNDA5aC00djIuNDA5aDRabS0uMTAyLS4yMzZhLjQ1LjQ1IDAgMCAxIC4wODUuMTQyLjI3Ny4yNzcgMCAwIDEgLjAxNy4wOTRoLTRjMCAuODQ4LjI5IDEuNzEuODc1IDIuMzg0bDMuMDIzLTIuNjJabS0xLjE2MiAwYS43NzUuNzc1IDAgMCAxIC41OC0uMjQ1Yy4yNzEgMCAuNDc3LjEyNC41ODIuMjQ1bC0zLjAyMyAyLjYyYzEuMzExIDEuNTEzIDMuNTczIDEuNTEzIDQuODg0IDBsLTMuMDIzLTIuNjJabS0uMTAzLjIzNmMwLS4wMjcuMDA1LS4wNTguMDE4LS4wOTRhLjQ1LjQ1IDAgMCAxIC4wODUtLjE0MmwzLjAyMyAyLjYyYTMuNjQ1IDMuNjQ1IDAgMCAwIC44NzQtMi4zODRoLTRabTAtMi40MDl2Mi40MDloNHYtMi40MDloLTRabS42ODQuNDgxYy0uMjkgMC0uNDktLjE0LS41OC0uMjQ1YS4zNjkuMzY5IDAgMCAxLS4xMDQtLjIzNmg0YzAtMS42NjUtMS4yMjUtMy41MTktMy4zMTYtMy41MTl2NFptLTE0LjQ4LTRjLTIuMDkxIDAtMy4zMTcgMS44NTQtMy4zMTcgMy41MTloNGEuMzcuMzcgMCAwIDEtLjEwMy4yMzYuNzc0Ljc3NCAwIDAgMS0uNTguMjQ1di00Wm0zLjMxNiAzLjUxOWMwLTEuNjY1LTEuMjI1LTMuNTE5LTMuMzE2LTMuNTE5djRjLS4yOSAwLS40OS0uMTQtLjU4LS4yNDVhLjM3LjM3IDAgMCAxLS4xMDQtLjIzNmg0Wm0wIDIuNDA5di0yLjQwOWgtNHYyLjQwOWg0Wm0tLjg3NCAyLjM4NGEzLjY0NCAzLjY0NCAwIDAgMCAuODc0LTIuMzg0aC00YzAtLjAyNy4wMDUtLjA1OC4wMTctLjA5NGEuNDUuNDUgMCAwIDEgLjA4Ni0uMTQybDMuMDIzIDIuNjJabS00Ljg4NSAwYzEuMzEyIDEuNTEzIDMuNTczIDEuNTEzIDQuODg1IDBsLTMuMDIzLTIuNjJhLjc3NS43NzUgMCAwIDEgLjU4LS4yNDVjLjI3IDAgLjQ3Ni4xMjQuNTgxLjI0NWwtMy4wMjMgMi42MlptLS44NzQtMi4zODRjMCAuODQ4LjI5IDEuNzEuODc0IDIuMzg0bDMuMDIzLTIuNjJhLjQ1LjQ1IDAgMCAxIC4wODYuMTQyLjI3Ny4yNzcgMCAwIDEgLjAxNy4wOTRoLTRabTAtMi40MDl2Mi40MDloNHYtMi40MDloLTRabTIyLjQzIDBhLjM3LjM3IDAgMCAxLS4xMDMuMjM2Ljc3NC43NzQgMCAwIDEtLjU4MS4yNDV2LTRjLTIuMDkgMC0zLjMxNiAxLjg1NC0zLjMxNiAzLjUxOWg0Wm0wIDIuNDA5di0yLjQwOWgtNHYyLjQwOWg0Wm0tLjEwMy0uMjM2YS40NTMuNDUzIDAgMCAxIC4wODUuMTQyLjI3Ny4yNzcgMCAwIDEgLjAxOC4wOTRoLTRjMCAuODQ4LjI5IDEuNzEuODc0IDIuMzg0bDMuMDIzLTIuNjJabS0xLjE2MiAwYS43NzYuNzc2IDAgMCAxIC41ODEtLjI0NWMuMjcgMCAuNDc2LjEyNC41OC4yNDVsLTMuMDIyIDIuNjJjMS4zMTEgMS41MTMgMy41NzMgMS41MTMgNC44ODQgMGwtMy4wMjMtMi42MlptLS4xMDMuMjM2YzAtLjAyNy4wMDUtLjA1OC4wMTgtLjA5NGEuNDUuNDUgMCAwIDEgLjA4NS0uMTQybDMuMDIzIDIuNjJhMy42NDQgMy42NDQgMCAwIDAgLjg3NC0yLjM4NGgtNFptMC0yLjQwOXYyLjQwOWg0di0yLjQwOWgtNFptLjY4NC40ODFjLS4yOSAwLS40OS0uMTQtLjU4LS4yNDVhLjM3LjM3IDAgMCAxLS4xMDQtLjIzNmg0YzAtMS42NjUtMS4yMjUtMy41MTktMy4zMTYtMy41MTl2NFptLTE0LjQ4LTRjLTIuMDkxIDAtMy4zMTYgMS44NTQtMy4zMTYgMy41MTloNGEuMzcuMzcgMCAwIDEtLjEwNC4yMzYuNzc0Ljc3NCAwIDAgMS0uNTguMjQ1di00Wm0zLjMxNiAzLjUxOWMwLTEuNjY1LTEuMjI1LTMuNTE5LTMuMzE2LTMuNTE5djRjLS4yOSAwLS40OS0uMTQtLjU4LS4yNDVhLjM3LjM3IDAgMCAxLS4xMDQtLjIzNmg0Wm0wIDIuNDA5di0yLjQwOWgtNHYyLjQwOWg0Wm0tLjg3NCAyLjM4NGEzLjY0NCAzLjY0NCAwIDAgMCAuODc0LTIuMzg0aC00YzAtLjAyNy4wMDUtLjA1OC4wMTgtLjA5NGEuNDUuNDUgMCAwIDEgLjA4NS0uMTQybDMuMDIzIDIuNjJabS00Ljg4NCAwYzEuMzExIDEuNTEzIDMuNTczIDEuNTEzIDQuODg0IDBsLTMuMDIzLTIuNjJhLjc3Ni43NzYgMCAwIDEgLjU4LS4yNDVjLjI3MSAwIC40NzcuMTI0LjU4MS4yNDVsLTMuMDIyIDIuNjJabS0uODc0LTIuMzg0YzAgLjg0OC4yODkgMS43MS44NzQgMi4zODRsMy4wMjMtMi42MmEuNDUzLjQ1MyAwIDAgMSAuMDg1LjE0Mi4yNzcuMjc3IDAgMCAxIC4wMTcuMDk0aC00Wm0wLTIuNDA5djIuNDA5aDR2LTIuNDA5aC00Wm0yMi40MjkgMGEuMzcuMzcgMCAwIDEtLjEwMy4yMzYuNzc0Ljc3NCAwIDAgMS0uNTguMjQ1di00Yy0yLjA5MiAwLTMuMzE3IDEuODU0LTMuMzE3IDMuNTE5aDRabTAgMi40MDl2LTIuNDA5aC00djIuNDA5aDRabS0uMTAzLS4yMzZhLjQ1LjQ1IDAgMCAxIC4wODUuMTQyLjI3Ny4yNzcgMCAwIDEgLjAxOC4wOTRoLTRjMCAuODQ4LjI5IDEuNzEuODc0IDIuMzg0bDMuMDIzLTIuNjJabS0xLjE2MiAwYS43NzYuNzc2IDAgMCAxIC41ODEtLjI0NWMuMjcgMCAuNDc2LjEyNC41OC4yNDVsLTMuMDIyIDIuNjJjMS4zMTIgMS41MTMgMy41NzMgMS41MTMgNC44ODUgMGwtMy4wMjMtMi42MlptLS4xMDIuMjM2YzAtLjAyNy4wMDQtLjA1OC4wMTctLjA5NGEuNDU1LjQ1NSAwIDAgMSAuMDg1LS4xNDJsMy4wMjMgMi42MmEzLjY0NSAzLjY0NSAwIDAgMCAuODc1LTIuMzg0aC00Wm0wLTIuNDA5djIuNDA5aDR2LTIuNDA5aC00Wm0uNjgzLjQ4MWMtLjI5IDAtLjQ5LS4xNC0uNTgtLjI0NWEuMzcuMzcgMCAwIDEtLjEwMy0uMjM2aDRjMC0xLjY2NS0xLjIyNi0zLjUxOS0zLjMxNy0zLjUxOXY0WiIKICAgICAgICBmaWxsPSIjZmZmIiBtYXNrPSJ1cmwoI2EpIiAvPgo8L3N2Zz4="
class="header3__card-recommendation-header-photo header3__card-recommendation-header-photo_default_inverted"
/>
</div>
<div class="header3__card-recommendation-header-content">
<div class="header3__card-recommendation-header-content-tag">
Курс
</div>
<div class="header3__card-recommendation-header-sub">
<span
class="header3__card-recommendation-header-sub-chunk header3__card-recommendation-header-sub-chunk_green"
>
Скидка 20000 ₽
</span>
</div>
</div>
</div>
<h6 class="header3__card-recommendation-title">
ИИ для аналитики и работы с данными
</h6>
<div class="header3__card-recommendation-footer">
12 марта
, 2026
· 2 месяца
</div>
</a>
</div>
</div>
<div>
<p class="header3__nav-section-title ">Ответим на ваши вопросы</p>
<div class="header3__nav-section-items ">
<a
class="header3__nav-section-item header3__nav-section-item_phone"
rel="noopener noreferrer"
href="tel:+7 499 938-92-02"
>
<svg
class="header3__phone-icon"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14.352 2.75a7.971 7.971 0 0 1 7.041 7.032M14.352 6.293a4.426 4.426 0 0 1 3.5 3.5"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
></path>
<path
clip-rule="evenodd"
d="M7.7 16.299C.803 9.4 1.783 6.241 2.51 5.223c.094-.164 2.396-3.611 4.865-1.589 6.126 5.045-1.63 4.332 3.514 9.477 5.146 5.144 4.431-2.611 9.477 3.514 2.022 2.469-1.425 4.771-1.588 4.864-1.018.728-4.178 1.709-11.078-5.19Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
+7 499 938-92-02
</a>
</div>
</div>
</div>
</div>
<svg
class="header3__nav-item-popup-figure"
viewBox="0 0 600 600"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M125.573 140.619C131.659 71.6017 210.245 34.9562 267.027 74.6573L553.942 275.262C610.723 314.962 603.117 401.233 540.247 430.55L222.58 578.681C159.71 607.997 88.7344 558.37 94.8204 489.355L125.573 140.619Z"
stroke="#eaeaea"
class="header3__nav-item-popup-figure-spinner"
></path>
<path
d="M148.472 246.647C133.624 191.005 184.615 140.013 240.257 154.862L519.856 229.476C575.498 244.325 594.059 313.877 553.266 354.67L348.281 559.656C307.488 600.449 237.935 581.888 223.087 526.246L148.472 246.647Z"
fill="url(#paint0_linear-info)"
></path>
<defs>
<linearGradient
id="paint0_linear-info"
x1="128.696"
y1="395.739"
x2="443.538"
y2="180.173"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#f9f9f9"></stop>
<stop offset="1" stop-color="#EBEBEB"></stop>
</linearGradient>
</defs>
</svg>
</div>
</div>
<a class="header3__nav-item header3__nav-item-b2b header3__nav-item_with-hover" href="/b2b">
Компаниям
</a>
</nav>
<div class="header3__nav header3__nav_right">
<div data-name="" class="header3__nav-item js-header3-popup-trigger js-open-modal-reg header3__button-sign-up-container" >
<button class="header3__button-sign-up">
<svg class="header3__button-sign-up-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
clip-rule="evenodd"
d="M9.922 21.808c-3.814 0-7.072-.577-7.072-2.887s3.237-4.41 7.072-4.41c3.814 0 7.072 2.08 7.072 4.39 0 2.308-3.237 2.907-7.072 2.907ZM9.922 11.216A4.534 4.534 0 1 0 5.39 6.683a4.518 4.518 0 0 0 4.501 4.533h.032Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
></path>
<path
d="M19.131 8.13v4.01M21.178 10.134h-4.09"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
Зарегистрироваться
</button>
</div>
<div data-name="" class="header3__nav-item js-header3-popup-trigger js-open-modal-login header3__button-sign-in-container" data-modal-id="new-log-reg">
<button class="header3__button-sign-in">
Войти
</button>
</div>
<div class="header3__hamburger">
<button data-name="hamburger" class="header3__hamburger-button js-header3-popup-trigger">
<svg
class="header3__hamburger-icon js-header3-popup-trigger-icon-default"
width="28"
height="22"
viewBox="0 0 28 22"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2.15999 0.119995C1.09961 0.119995 0.23999 0.979576 0.23999 2.04C0.23999 3.10035 1.09961 3.96 2.15999 3.96H25.84C26.9004 3.96 27.76 3.10035 27.76 2.04C27.76 0.979576 26.9004 0.119995 25.84 0.119995H2.15999ZM2.15999 9.08C1.09961 9.08 0.23999 9.93957 0.23999 11C0.23999 12.0604 1.09961 12.92 2.15999 12.92H25.84C26.9004 12.92 27.76 12.0604 27.76 11C27.76 9.93957 26.9004 9.08 25.84 9.08H2.15999ZM2.15999 18.04C1.09961 18.04 0.23999 18.8996 0.23999 19.96C0.23999 21.0204 1.09961 21.88 2.15999 21.88H25.84C26.9004 21.88 27.76 21.0204 27.76 19.96C27.76 18.8996 26.9004 18.04 25.84 18.04H2.15999Z"
fill="currentColor"
></path>
</svg>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="header3__hamburger-icon-close js-header3-popup-trigger-icon-close"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M12 0.75C11.3096 0.75 10.75 1.30964 10.75 2V10.75L2 10.75C1.30964 10.75 0.75 11.3096 0.75 12C0.75 12.6904 1.30964 13.25 2 13.25L10.75 13.25L10.75 22C10.75 22.6904 11.3096 23.25 12 23.25C12.6904 23.25 13.25 22.6904 13.25 22L13.25 13.25L22 13.25C22.6904 13.25 23.25 12.6904 23.25 12C23.25 11.3096 22.6904 10.75 22 10.75L13.25 10.75V2C13.25 1.30964 12.6904 0.75 12 0.75Z"
fill="currentColor"
></path>
</svg>
</button>
<div
data-name="hamburger"
data-only-click="true"
class="header3__nav-item-popup-wrapper js-header3-popup"
style="display: none;"
>
<div
class="header3__nav-item-popup-container js-header3-popup-container"
>
<svg
class="header3__nav-item-popup-figure"
viewBox="0 0 600 600"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M125.573 140.619C131.659 71.6017 210.245 34.9562 267.027 74.6573L553.942 275.262C610.723 314.962 603.117 401.233 540.247 430.55L222.58 578.681C159.71 607.997 88.7344 558.37 94.8204 489.355L125.573 140.619Z"
stroke="#eaeaea"
class="header3__nav-item-popup-figure-spinner"
></path>
<path
d="M148.472 246.647C133.624 191.005 184.615 140.013 240.257 154.862L519.856 229.476C575.498 244.325 594.059 313.877 553.266 354.67L348.281 559.656C307.488 600.449 237.935 581.888 223.087 526.246L148.472 246.647Z"
fill="url(#paint0_linear-hamburger)"
></path>
<defs>
<linearGradient
id="paint0_linear-hamburger"
x1="128.696"
y1="395.739"
x2="443.538"
y2="180.173"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#f9f9f9"></stop>
<stop offset="1" stop-color="#EBEBEB"></stop>
</linearGradient>
</defs>
</svg>
<div class="header3__nav-item-popup-content header3__hamburger-tabs">
<ul>
<li
class="header3__hamburger-tabs-tab header3__hamburger-tabs-tab_active"
>
Обучение
</li>
<li class="header3__hamburger-tabs-tab">
Информация
</li>
<li data-url="/b2b" class="header3__hamburger-tabs-tab header3__hamburger-tabs-tab_link">
Компаниям
</li>
</ul>
<div class="header3__hamburger-tabs-tab-content-wrapper">
<div
class="header3__hamburger-tabs-tab-content header3__hamburger-tabs-tab-learning header3__hamburger-tabs-tab-content_active"
>
<div class="header3__nav-column">
<div>
<p class="header3__nav-section-title ">Направления</p>
<div class="header3__nav-section-items header3__nav-section-items_learning header3__nav-section-items_learning_rows-8">
<a
class="header3__nav-section-item"
href="/categories/programming/"
>
Программирование (117)
</a>
<a
class="header3__nav-section-item"
href="/categories/architecture/"
>
Архитектура (17)
</a>
<a
class="header3__nav-section-item"
href="/categories/data-science/"
>
Data Science (27)
</a>
<a
class="header3__nav-section-item"
href="/categories/operations/"
>
Инфраструктура (58)
</a>
<a
class="header3__nav-section-item"
href="/categories/gamedev/"
>
GameDev (10)
</a>
<a
class="header3__nav-section-item"
href="/categories/information-security-courses/"
>
Безопасность (15)
</a>
<a
class="header3__nav-section-item"
href="/categories/marketing-business/"
>
Управление (46)
</a>
<a
class="header3__nav-section-item"
href="/categories/analytics/"
>
Аналитика и анализ (25)
</a>
<a
class="header3__nav-section-item"
href="/categories/business-product/"
>
Бизнес и продукт в IT (26)
</a>
<a
class="header3__nav-section-item"
href="/categories/import-substitution/"
>
Импортозамещение (15)
</a>
<a
class="header3__nav-section-item"
href="/categories/testing/"
>
Тестирование (12)
</a>
<a
class="header3__nav-section-item"
href="/categories/neural_networks/"
>
Нейросети (9)
</a>
<a
class="header3__nav-section-item"
href="/categories/it-bez-programmirovanija/"
>
IT без программирования (19)
</a>
<a
class="header3__nav-section-item"
href="/categories/corporate/"
>
Корпоративные курсы (27)
</a>
</div>
</div>
</div>
<div class="header3__nav-column">
<div>
<p class="header3__nav-section-title ">События</p>
<div class="header3__nav-section-items ">
<a
class="header3__nav-section-item"
href="/lessons/calendar/2026/"
>
Календарь запуска курсов
</a>
<a
class="header3__nav-section-item"
href="/events/near/"
>
Календарь мероприятий
</a>
</div>
</div>
<div>
<p class="header3__nav-section-title ">Другое</p>
<div class="header3__nav-section-items ">
<a
class="header3__nav-section-item"
href="/categories/spec/"
>
Специализации (13)
</a>
<a
class="header3__nav-section-item"
href="/categories/online/"
>
Подготовительные курсы (14)
</a>
<a
class="header3__nav-section-item header3__nav-section-item_bold"
href="/subscription"
>
Подписка на курсы
</a>
<a
class="header3__nav-section-item"
href="/tests"
>
Проверьте свои знания
</a>
</div>
</div>
</div>
<div class="header3__nav-column">
<div>
<p class="header3__nav-section-title header3__nav-section-items-recommendation-title">OTUS рекомендует</p>
<div class="header3__nav-section-items header3__nav-section-items_not-items header3__nav-section-items-recommendation">
<a
href="/lessons/ai-dlya-analitiki-i-raboty-s-dannymi/"
class="header3__card-recommendation"
>
<div
class="header3__card-recommendation-background"
style="background: linear-gradient( 90deg,#0A4489, #00316B);"
></div>
<div class="header3__card-recommendation-header">
<div class="header3__card-recommendation-header-photo-wrapper" style="background: linear-gradient( 90deg,#0A4489, #00316B);">
<img
src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODAiIGhlaWdodD0iODAiIHZpZXdCb3g9IjAgMCA4MCA4MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICAgIDxwYXRoCiAgICAgICAgZD0iTTM1LjAwNDcgNDUuODcyNUMzNS4wMDQ3IDQ2LjkzMDYgMzYuMTA5IDQ3LjU3NTcgMzYuOTYwMyA0Ny4wMTQ5TDQ0LjYyOTIgNDEuOTYzMUM0NS4wMDI5IDQxLjcxNjkgNDUuMjI5OSA0MS4yODUyIDQ1LjIyOTkgNDAuODIwN1YyNi43NDg5QzQ1LjIyOTkgMjYuNzQ4OSA0Ny43ODYyIDI2Ljc0ODkgNTQuMTc3IDIyLjcwNzRDNTcuMzAxMiAyMC43MzE4IDU4Ljg5OCAxOC43NTYxIDU5LjcxNDMgMTcuMjUyNUM2MC4yNDY1IDE2LjI3MjEgNTkuMjUxMSAxNS41NTg4IDU4LjI5NjUgMTYuMDcyM0M1NS4zMjkyIDE3LjY2ODQgNDkuMzkxOCAyMC4wMTMyIDQwLjExNzMgMjAuMDEzMkMzMC44NDI4IDIwLjAxMzIgMjQuOTA1NCAxNy42Njg0IDIxLjkzODEgMTYuMDcyM0MyMC45ODM1IDE1LjU1ODggMTkuOTg4MSAxNi4yNzIxIDIwLjUyMDMgMTcuMjUyNUMyMS4zMzY2IDE4Ljc1NjEgMjIuOTMzNCAyMC43MzE4IDI2LjA1NzcgMjIuNzA3NEMzMi40NDg0IDI2Ljc0ODkgMzUuMDA0NyAyNi43NDg5IDM1LjAwNDcgMjYuNzQ4OVY0NS44NzI1WiIKICAgICAgICBmaWxsPSJ3aGl0ZSIgLz4KICAgIDxlbGxpcHNlIGN4PSIyNi45NTM1IiBjeT0iMzUuNTUyNiIgcng9IjMuOTQ5MTQiIHJ5PSI0LjA4NTMyIiBmaWxsPSJ3aGl0ZSIgLz4KICAgIDxlbGxpcHNlIGN4PSI1My4yODEyIiBjeT0iMzUuNTUyNiIgcng9IjMuOTQ5MTQiIHJ5PSI0LjA4NTMyIiBmaWxsPSJ3aGl0ZSIgLz4KICAgIDxwYXRoCiAgICAgICAgZD0iTTI1LjYzNzIgNTkuNjM1QzI1LjYzNzIgNTguNzk2MSAyNi4yMjY2IDU4LjExNiAyNi45NTM2IDU4LjExNkMyNy42ODA2IDU4LjExNiAyOC4yNyA1OC43OTYxIDI4LjI3IDU5LjYzNVY2Mi4wNDRDMjguMjcgNjIuNDQ2OSAyOC4xMzEzIDYyLjgzMzMgMjcuODg0NCA2My4xMTgxQzI3LjM3MDMgNjMuNzExNCAyNi41MzY4IDYzLjcxMTQgMjYuMDIyOCA2My4xMTgxQzI1Ljc3NTkgNjIuODMzMyAyNS42MzcyIDYyLjQ0NjkgMjUuNjM3MiA2Mi4wNDRWNTkuNjM1WiIKICAgICAgICBmaWxsPSJ3aGl0ZSIgLz4KICAgIDxwYXRoCiAgICAgICAgZD0iTTQ0LjA2NjUgNTkuNjM1QzQ0LjA2NjUgNTguNzk2MSA0NC42NTU5IDU4LjExNiA0NS4zODI5IDU4LjExNkM0Ni4xMDk5IDU4LjExNiA0Ni42OTkzIDU4Ljc5NjEgNDYuNjk5MyA1OS42MzVWNjIuMDQ0QzQ2LjY5OTMgNjIuNDQ2OSA0Ni41NjA2IDYyLjgzMzMgNDYuMzEzOCA2My4xMTgxQzQ1Ljc5OTcgNjMuNzExNCA0NC45NjYyIDYzLjcxMTQgNDQuNDUyMSA2My4xMTgxQzQ0LjIwNTIgNjIuODMzMyA0NC4wNjY1IDYyLjQ0NjkgNDQuMDY2NSA2Mi4wNDRWNTkuNjM1WiIKICAgICAgICBmaWxsPSJ3aGl0ZSIgLz4KICAgIDxwYXRoCiAgICAgICAgZD0iTTI5LjU4NjQgNTkuNjM1QzI5LjU4NjQgNTguNzk2MSAzMC4xNzU3IDU4LjExNiAzMC45MDI3IDU4LjExNkMzMS42Mjk4IDU4LjExNiAzMi4yMTkxIDU4Ljc5NjEgMzIuMjE5MSA1OS42MzVWNjIuMDQ0QzMyLjIxOTEgNjIuNDQ2OSAzMi4wODA0IDYyLjgzMzMgMzEuODMzNiA2My4xMTgxQzMxLjMxOTUgNjMuNzExNCAzMC40ODYgNjMuNzExNCAyOS45NzE5IDYzLjExODFDMjkuNzI1IDYyLjgzMzMgMjkuNTg2NCA2Mi40NDY5IDI5LjU4NjQgNjIuMDQ0VjU5LjYzNVoiCiAgICAgICAgZmlsbD0id2hpdGUiIC8+CiAgICA8cGF0aAogICAgICAgIGQ9Ik00OC4wMTU3IDU5LjYzNUM0OC4wMTU3IDU4Ljc5NjEgNDguNjA1MSA1OC4xMTYgNDkuMzMyMSA1OC4xMTZDNTAuMDU5MSA1OC4xMTYgNTAuNjQ4NSA1OC43OTYxIDUwLjY0ODUgNTkuNjM1VjYyLjA0NEM1MC42NDg1IDYyLjQ0NjkgNTAuNTA5OCA2Mi44MzMzIDUwLjI2MjkgNjMuMTE4MUM0OS43NDg4IDYzLjcxMTQgNDguOTE1MyA2My43MTE0IDQ4LjQwMTIgNjMuMTE4MUM0OC4xNTQ0IDYyLjgzMzMgNDguMDE1NyA2Mi40NDY5IDQ4LjAxNTcgNjIuMDQ0VjU5LjYzNVoiCiAgICAgICAgZmlsbD0id2hpdGUiIC8+CiAgICA8cGF0aAogICAgICAgIGQ9Ik0zMy41MzU1IDU5LjYzNUMzMy41MzU1IDU4Ljc5NjEgMzQuMTI0OSA1OC4xMTYgMzQuODUxOSA1OC4xMTZDMzUuNTc4OSA1OC4xMTYgMzYuMTY4MyA1OC43OTYxIDM2LjE2ODMgNTkuNjM1VjYyLjA0NEMzNi4xNjgzIDYyLjQ0NjkgMzYuMDI5NiA2Mi44MzMzIDM1Ljc4MjcgNjMuMTE4MUMzNS4yNjg2IDYzLjcxMTQgMzQuNDM1MSA2My43MTE0IDMzLjkyMTEgNjMuMTE4MUMzMy42NzQyIDYyLjgzMzMgMzMuNTM1NSA2Mi40NDY5IDMzLjUzNTUgNjIuMDQ0VjU5LjYzNVoiCiAgICAgICAgZmlsbD0id2hpdGUiIC8+CiAgICA8cGF0aAogICAgICAgIGQ9Ik01MS45NjQ4IDU5LjYzNUM1MS45NjQ4IDU4Ljc5NjEgNTIuNTU0MiA1OC4xMTYgNTMuMjgxMiA1OC4xMTZDNTQuMDA4MiA1OC4xMTYgNTQuNTk3NiA1OC43OTYxIDU0LjU5NzYgNTkuNjM1VjYyLjA0NEM1NC41OTc2IDYyLjQ0NjkgNTQuNDU4OSA2Mi44MzMzIDU0LjIxMiA2My4xMTgxQzUzLjY5OCA2My43MTE0IDUyLjg2NDUgNjMuNzExNCA1Mi4zNTA0IDYzLjExODFDNTIuMTAzNSA2Mi44MzMzIDUxLjk2NDggNjIuNDQ2OSA1MS45NjQ4IDYyLjA0NFY1OS42MzVaIgogICAgICAgIGZpbGw9IndoaXRlIiAvPgo8L3N2Zz4="
class="header3__card-recommendation-header-photo header3__card-recommendation-header-photo_default"
/>
<img
src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODAiIGhlaWdodD0iODAiIHZpZXdCb3g9IjAgMCA4MCA4MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICAgIDxtYXNrIGlkPSJhIiBmaWxsPSIjZmZmIj4KICAgICAgICA8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIKICAgICAgICAgICAgZD0iTTAgMTJDMCA1LjM3MyA1LjM3MyAwIDEyIDBoNTZjNi42MjcgMCAxMiA1LjM3MyAxMiAxMnY1NmMwIDYuNjI3LTUuMzczIDEyLTEyIDEySDEyQzUuMzczIDgwIDAgNzQuNjI3IDAgNjhWMTJabTM1LjAwNSAzMy44NzNjMCAxLjA1OCAxLjEwNCAxLjcwMyAxLjk1NSAxLjE0Mmw3LjY3LTUuMDUyYy4zNzMtLjI0Ni42LS42NzguNi0xLjE0MlYyNi43NDlzMi41NTYgMCA4Ljk0Ny00LjA0MmMzLjEyNC0xLjk3NSA0LjcyMS0zLjk1MSA1LjUzNy01LjQ1NC41MzMtLjk4MS0uNDYzLTEuNjk0LTEuNDE3LTEuMTgtMi45NjggMS41OTUtOC45MDUgMy45NC0xOC4xOCAzLjk0LTkuMjc0IDAtMTUuMjEyLTIuMzQ1LTE4LjE3OS0zLjk0LS45NTQtLjUxNC0xLjk1LjE5OS0xLjQxOCAxLjE4LjgxNyAxLjUwMyAyLjQxMyAzLjQ3OSA1LjUzOCA1LjQ1NCA2LjM5IDQuMDQyIDguOTQ3IDQuMDQyIDguOTQ3IDQuMDQydjE5LjEyNFptLTguMDUxLTYuMjM1YzIuMTggMCAzLjk0OS0xLjgzIDMuOTQ5LTQuMDg1IDAtMi4yNTctMS43NjgtNC4wODYtMy45NS00LjA4Ni0yLjE4IDAtMy45NDkgMS44My0zLjk0OSA0LjA4NiAwIDIuMjU2IDEuNzY4IDQuMDg1IDMuOTUgNC4wODVabTMwLjI3Ni00LjA4NWMwIDIuMjU2LTEuNzY4IDQuMDg1LTMuOTQ5IDQuMDg1LTIuMTggMC0zLjk0OS0xLjgzLTMuOTQ5LTQuMDg1IDAtMi4yNTcgMS43NjgtNC4wODYgMy45NS00LjA4NiAyLjE4IDAgMy45NDggMS44MyAzLjk0OCA0LjA4NlpNMjYuOTU0IDU4LjExNmMtLjcyNyAwLTEuMzE3LjY4LTEuMzE3IDEuNTE5djIuNDA5YzAgLjQwMy4xMzkuNzkuMzg2IDEuMDc0LjUxNC41OTMgMS4zNDcuNTkzIDEuODYxIDAgLjI0Ny0uMjg1LjM4Ni0uNjcxLjM4Ni0xLjA3NHYtMi40MDljMC0uODM5LS41OS0xLjUxOS0xLjMxNy0xLjUxOVptMTguNDI5IDBjLS43MjcgMC0xLjMxNy42OC0xLjMxNyAxLjUxOXYyLjQwOWMwIC40MDMuMTQuNzkuMzg2IDEuMDc0LjUxNC41OTMgMS4zNDguNTkzIDEuODYyIDAgLjI0Ny0uMjg1LjM4NS0uNjcxLjM4NS0xLjA3NHYtMi40MDljMC0uODM5LS41OS0xLjUxOS0xLjMxNi0xLjUxOVptLTE1Ljc5NyAxLjUxOWMwLS44MzkuNTktMS41MTkgMS4zMTctMS41MTlzMS4zMTYuNjggMS4zMTYgMS41MTl2Mi40MDljMCAuNDAzLS4xMzkuNzktLjM4NiAxLjA3NC0uNTE0LjU5My0xLjM0Ny41OTMtMS44NjEgMGExLjY0NSAxLjY0NSAwIDAgMS0uMzg2LTEuMDc0di0yLjQwOVptMTkuNzQ2LTEuNTE5Yy0uNzI3IDAtMS4zMTYuNjgtMS4zMTYgMS41MTl2Mi40MDljMCAuNDAzLjEzOC43OS4zODUgMS4wNzQuNTE0LjU5MyAxLjM0OC41OTMgMS44NjIgMCAuMjQ3LS4yODUuMzg1LS42NzEuMzg1LTEuMDc0di0yLjQwOWMwLS44MzktLjU4OS0xLjUxOS0xLjMxNi0xLjUxOVptLTE1Ljc5NyAxLjUxOWMwLS44MzkuNTktMS41MTkgMS4zMTctMS41MTlzMS4zMTYuNjggMS4zMTYgMS41MTl2Mi40MDljMCAuNDAzLS4xMzkuNzktLjM4NSAxLjA3NC0uNTE0LjU5My0xLjM0OC41OTMtMS44NjIgMGExLjY0NSAxLjY0NSAwIDAgMS0uMzg2LTEuMDc0di0yLjQwOVptMTkuNzQ2LTEuNTE5Yy0uNzI3IDAtMS4zMTYuNjgtMS4zMTYgMS41MTl2Mi40MDljMCAuNDAzLjEzOC43OS4zODUgMS4wNzQuNTE0LjU5MyAxLjM0OC41OTMgMS44NjIgMCAuMjQ3LS4yODUuMzg2LS42NzEuMzg2LTEuMDc0di0yLjQwOWMwLS44MzktLjU5LTEuNTE5LTEuMzE3LTEuNTE5WiIgLz4KICAgIDwvbWFzaz4KICAgIDxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIgogICAgICAgIGQ9Ik0wIDEyQzAgNS4zNzMgNS4zNzMgMCAxMiAwaDU2YzYuNjI3IDAgMTIgNS4zNzMgMTIgMTJ2NTZjMCA2LjYyNy01LjM3MyAxMi0xMiAxMkgxMkM1LjM3MyA4MCAwIDc0LjYyNyAwIDY4VjEyWm0zNS4wMDUgMzMuODczYzAgMS4wNTggMS4xMDQgMS43MDMgMS45NTUgMS4xNDJsNy42Ny01LjA1MmMuMzczLS4yNDYuNi0uNjc4LjYtMS4xNDJWMjYuNzQ5czIuNTU2IDAgOC45NDctNC4wNDJjMy4xMjQtMS45NzUgNC43MjEtMy45NTEgNS41MzctNS40NTQuNTMzLS45ODEtLjQ2My0xLjY5NC0xLjQxNy0xLjE4LTIuOTY4IDEuNTk1LTguOTA1IDMuOTQtMTguMTggMy45NC05LjI3NCAwLTE1LjIxMi0yLjM0NS0xOC4xNzktMy45NC0uOTU0LS41MTQtMS45NS4xOTktMS40MTggMS4xOC44MTcgMS41MDMgMi40MTMgMy40NzkgNS41MzggNS40NTQgNi4zOSA0LjA0MiA4Ljk0NyA0LjA0MiA4Ljk0NyA0LjA0MnYxOS4xMjRabS04LjA1MS02LjIzNWMyLjE4IDAgMy45NDktMS44MyAzLjk0OS00LjA4NSAwLTIuMjU3LTEuNzY4LTQuMDg2LTMuOTUtNC4wODYtMi4xOCAwLTMuOTQ5IDEuODMtMy45NDkgNC4wODYgMCAyLjI1NiAxLjc2OCA0LjA4NSAzLjk1IDQuMDg1Wm0zMC4yNzYtNC4wODVjMCAyLjI1Ni0xLjc2OCA0LjA4NS0zLjk0OSA0LjA4NS0yLjE4IDAtMy45NDktMS44My0zLjk0OS00LjA4NSAwLTIuMjU3IDEuNzY4LTQuMDg2IDMuOTUtNC4wODYgMi4xOCAwIDMuOTQ4IDEuODMgMy45NDggNC4wODZaTTI2Ljk1NCA1OC4xMTZjLS43MjcgMC0xLjMxNy42OC0xLjMxNyAxLjUxOXYyLjQwOWMwIC40MDMuMTM5Ljc5LjM4NiAxLjA3NC41MTQuNTkzIDEuMzQ3LjU5MyAxLjg2MSAwIC4yNDctLjI4NS4zODYtLjY3MS4zODYtMS4wNzR2LTIuNDA5YzAtLjgzOS0uNTktMS41MTktMS4zMTctMS41MTlabTE4LjQyOSAwYy0uNzI3IDAtMS4zMTcuNjgtMS4zMTcgMS41MTl2Mi40MDljMCAuNDAzLjE0Ljc5LjM4NiAxLjA3NC41MTQuNTkzIDEuMzQ4LjU5MyAxLjg2MiAwIC4yNDctLjI4NS4zODUtLjY3MS4zODUtMS4wNzR2LTIuNDA5YzAtLjgzOS0uNTktMS41MTktMS4zMTYtMS41MTlabS0xNS43OTcgMS41MTljMC0uODM5LjU5LTEuNTE5IDEuMzE3LTEuNTE5czEuMzE2LjY4IDEuMzE2IDEuNTE5djIuNDA5YzAgLjQwMy0uMTM5Ljc5LS4zODYgMS4wNzQtLjUxNC41OTMtMS4zNDcuNTkzLTEuODYxIDBhMS42NDUgMS42NDUgMCAwIDEtLjM4Ni0xLjA3NHYtMi40MDlabTE5Ljc0Ni0xLjUxOWMtLjcyNyAwLTEuMzE2LjY4LTEuMzE2IDEuNTE5djIuNDA5YzAgLjQwMy4xMzguNzkuMzg1IDEuMDc0LjUxNC41OTMgMS4zNDguNTkzIDEuODYyIDAgLjI0Ny0uMjg1LjM4NS0uNjcxLjM4NS0xLjA3NHYtMi40MDljMC0uODM5LS41ODktMS41MTktMS4zMTYtMS41MTlabS0xNS43OTcgMS41MTljMC0uODM5LjU5LTEuNTE5IDEuMzE3LTEuNTE5czEuMzE2LjY4IDEuMzE2IDEuNTE5djIuNDA5YzAgLjQwMy0uMTM5Ljc5LS4zODUgMS4wNzQtLjUxNC41OTMtMS4zNDguNTkzLTEuODYyIDBhMS42NDUgMS42NDUgMCAwIDEtLjM4Ni0xLjA3NHYtMi40MDlabTE5Ljc0Ni0xLjUxOWMtLjcyNyAwLTEuMzE2LjY4LTEuMzE2IDEuNTE5djIuNDA5YzAgLjQwMy4xMzguNzkuMzg1IDEuMDc0LjUxNC41OTMgMS4zNDguNTkzIDEuODYyIDAgLjI0Ny0uMjg1LjM4Ni0uNjcxLjM4Ni0xLjA3NHYtMi40MDljMC0uODM5LS41OS0xLjUxOS0xLjMxNy0xLjUxOVoiCiAgICAgICAgZmlsbD0iI2ZmZiIgLz4KICAgIDxwYXRoCiAgICAgICAgZD0ibTM2Ljk2IDQ3LjAxNSAxLjEgMS42Ny0xLjEtMS42N1ptNy42Ny01LjA1MiAxLjEgMS42Ny0xLjEtMS42N1ptLjYtMTUuMjE0di0yaC0ydjJoMlptOC45NDctNC4wNDIgMS4wNjkgMS42OS0xLjA2OS0xLjY5Wm01LjUzNy01LjQ1NCAxLjc1OC45NTQtMS43NTgtLjk1NFptLTEuNDE3LTEuMTguOTQ3IDEuNzYtLjk0Ny0xLjc2Wm0tMzYuMzU5IDAtLjk0NyAxLjc2Ljk0Ny0xLjc2Wm0tMS40MTggMS4xOCAxLjc1OC0uOTU1LTEuNzU4Ljk1NVptNS41MzggNS40NTQtMS4wNyAxLjY5IDEuMDctMS42OVptOC45NDcgNC4wNDJoMnYtMmgtMnYyWm0tOC45ODIgMzYuMzcgMS41MTEtMS4zMS0xLjUxMSAxLjMxWm0xLjg2MSAwIDEuNTEyIDEuMzA5LTEuNTEyLTEuMzFabTE2LjU2OCAwLTEuNTExIDEuMzA5IDEuNTExLTEuMzFabTEuODYyIDAgMS41MTEgMS4zMDktMS41MTEtMS4zMVptLTE0LjQ4IDAtMS41MTItMS4zMSAxLjUxMiAxLjMxWm0tMS44NjIgMCAxLjUxMS0xLjMxLTEuNTExIDEuMzFabTE4LjQzIDAtMS41MTIgMS4zMDkgMS41MTEtMS4zMVptMS44NiAwIDEuNTEyIDEuMzA5LTEuNTExLTEuMzFabS0xNC40OCAwIDEuNTEyIDEuMzA5LTEuNTExLTEuMzFabS0xLjg2MSAwLTEuNTExIDEuMzA5IDEuNTExLTEuMzFabTE4LjQzIDAgMS41MS0xLjMxLTEuNTEgMS4zMVptMS44NjEgMCAxLjUxMSAxLjMwOS0xLjUxMS0xLjMxWk0xMi0yQzQuMjY4LTItMiA0LjI2OC0yIDEyaDRDMiA2LjQ3NyA2LjQ3NyAyIDEyIDJ2LTRabTU2IDBIMTJ2NGg1NnYtNFptMTQgMTRjMC03LjczMi02LjI2OC0xNC0xNC0xNHY0YzUuNTIzIDAgMTAgNC40NzcgMTAgMTBoNFptMCA1NlYxMmgtNHY1Nmg0Wk02OCA4MmM3LjczMiAwIDE0LTYuMjY4IDE0LTE0aC00YzAgNS41MjMtNC40NzcgMTAtMTAgMTB2NFptLTU2IDBoNTZ2LTRIMTJ2NFpNLTIgNjhjMCA3LjczMiA2LjI2OCAxNCAxNCAxNHYtNEM2LjQ3NyA3OCAyIDczLjUyMyAyIDY4aC00Wm0wLTU2djU2aDRWMTJoLTRabTM3Ljg2IDMzLjM0NWEuNzkxLjc5MSAwIDAgMSAuODEyLS4wMjEuNjU2LjY1NiAwIDAgMSAuMzMzLjU0OWgtNGMwIDIuNDU0IDIuNzE0IDQuMzU1IDUuMDU1IDIuODEybC0yLjItMy4zNFptNy42NjktNS4wNTItNy42NjkgNS4wNTIgMi4yIDMuMzQgNy42Ny01LjA1Mi0yLjIwMS0zLjM0Wm0tLjI5OS41MjhjMC0uMTc4LjA4Ni0uMzg4LjI5OS0uNTI4bDIuMiAzLjM0YTMuMzY1IDMuMzY1IDAgMCAwIDEuNTAxLTIuODEyaC00Wm0wLTE0LjA3MlY0MC44Mmg0VjI2Ljc0OWgtNFptOS44NzgtNS43MzJjLTMuMDk3IDEuOTU5LTUuMTk3IDIuODk1LTYuNDY0IDMuMzRhOC44NDUgOC44NDUgMCAwIDEtMS4yOC4zNjIgMi40MiAyLjQyIDAgMCAxLS4yMDMuMDMxSDQ1LjE5bC4wMTgtLjAwMWguMDJjLjAwMSAwIC4wMDMgMCAuMDAzIDJzLjAwMiAyIC4wMDMgMmguMDU0YTMuMTk2IDMuMTk2IDAgMCAwIC4yNTYtLjAxN2MuMTQ0LS4wMTQuMzI3LS4wMzguNTUyLS4wOC40NS0uMDg0IDEuMDY5LS4yMzggMS44NzUtLjUyMSAxLjYxLS41NjYgMy45ODItMS42NSA3LjI3Ni0zLjczM2wtMi4xMzgtMy4zODFabTQuODQ5LTQuNzE5Yy0uNjM3IDEuMTczLTEuOTgzIDIuOTA2LTQuODQ5IDQuNzE5bDIuMTM4IDMuMzhjMy4zODItMi4xMzggNS4yMy00LjM1NiA2LjIyNi02LjE5bC0zLjUxNS0xLjkwOVptMS4yODcgMS41MzZjLS4xMDYuMDU2LS40OTQuMTczLS45MTYtLjE1OGExLjIzMyAxLjIzMyAwIDAgMS0uNDQ4LS43Ni45ODUuOTg1IDAgMCAxIC4wNzctLjYxOGwzLjUxNSAxLjkwOWMuMzA4LS41NjguNDcyLTEuMjQ4LjM1MS0xLjk2YTIuNzY4IDIuNzY4IDAgMCAwLTEuMDI1LTEuNzE4Yy0xLjAyNi0uODA1LTIuMzg4LS43ODgtMy40NDktLjIxOGwxLjg5NSAzLjUyM1ptLTE5LjEyNyA0LjE4YzkuNjI4IDAgMTUuODg5LTIuNDM4IDE5LjEyNy00LjE4bC0xLjg5NS0zLjUyM2MtMi42OTYgMS40NS04LjMxIDMuNzAyLTE3LjIzMiAzLjcwMnY0Wm0tMTkuMTI2LTQuMThjMy4yMzggMS43NDIgOS40OTkgNC4xOCAxOS4xMjYgNC4xOHYtNGMtOC45MjEgMC0xNC41MzUtMi4yNTMtMTcuMjMxLTMuNzAzbC0xLjg5NSAzLjUyM1ptMS4yODctMS41MzZjLjA0Mi4wNzguMTMxLjMuMDc3LjYxOWExLjIzMiAxLjIzMiAwIDAgMS0uNDQ5Ljc1OWMtLjQyMi4zMy0uODEuMjE1LS45MTUuMTU4bDEuODk1LTMuNTIzYy0xLjA2LS41Ny0yLjQyNC0uNTg3LTMuNDQ5LjIxOGEyLjc2OCAyLjc2OCAwIDAgMC0xLjAyNiAxLjcxOWMtLjEyLjcxMS4wNDMgMS4zOTEuMzUyIDEuOTU5bDMuNTE1LTEuOTA5Wm00Ljg0OSA0LjcxOWMtMi44NjctMS44MTMtNC4yMTItMy41NDYtNC44NDktNC43MTlsLTMuNTE1IDEuOTA5Yy45OTUgMS44MzQgMi44NDQgNC4wNTIgNi4yMjYgNi4xOWwyLjEzOC0zLjM4Wm03Ljg3OCA1LjczMmMwLTIgLjAwMS0yIC4wMDMtMkgzNS4wNDdsLjAyNS4wMDFoLjAwMWEyLjQyIDIuNDIgMCAwIDEtLjIwMi0uMDMgOC44NTIgOC44NTIgMCAwIDEtMS4yOC0uMzYzYy0xLjI2Ny0uNDQ1LTMuMzY3LTEuMzgxLTYuNDY0LTMuMzRsLTIuMTM4IDMuMzhjMy4yOTMgMi4wODQgNS42NjYgMy4xNjggNy4yNzYgMy43MzQuODA2LjI4MyAxLjQyNS40MzcgMS44NzUuNTJhNi4zNTYgNi4zNTYgMCAwIDAgLjczNy4wOTVsLjA3MS4wMDJoLjA1NGMuMDAxIDAgLjAwMyAwIC4wMDMtMlptMiAxOS4xMjRWMjYuNzQ5aC00djE5LjEyNGg0Wm0tOC4xMDItMTAuMzJjMCAxLjIxNi0uOTM2IDIuMDg1LTEuOTUgMi4wODV2NGMzLjM1IDAgNS45NS0yLjc4OSA1Ljk1LTYuMDg1aC00Wm0tMS45NS0yLjA4NmMxLjAxNCAwIDEuOTUuODcgMS45NSAyLjA4Nmg0YzAtMy4yOTctMi42LTYuMDg2LTUuOTUtNi4wODZ2NFptLTEuOTQ5IDIuMDg2YzAtMS4yMTYuOTM2LTIuMDg2IDEuOTUtMi4wODZ2LTRjLTMuMzUgMC01Ljk1IDIuNzktNS45NSA2LjA4Nmg0Wm0xLjk1IDIuMDg1Yy0xLjAxNCAwLTEuOTUtLjg3LTEuOTUtMi4wODVoLTRjMCAzLjI5NiAyLjYgNi4wODUgNS45NSA2LjA4NXYtNFptMjYuMzI3IDRjMy4zNSAwIDUuOTUtMi43ODkgNS45NS02LjA4NWgtNGMwIDEuMjE2LS45MzcgMi4wODUtMS45NSAyLjA4NXY0Wm0tNS45NDktNi4wODVjMCAzLjI5NiAyLjYgNi4wODUgNS45NSA2LjA4NXYtNGMtMS4wMTQgMC0xLjk1LS44Ny0xLjk1LTIuMDg1aC00Wm01Ljk1LTYuMDg2Yy0zLjM1IDAtNS45NSAyLjc5LTUuOTUgNi4wODZoNGMwLTEuMjE2LjkzNi0yLjA4NiAxLjk1LTIuMDg2di00Wm01Ljk0OCA2LjA4NmMwLTMuMjk3LTIuNi02LjA4Ni01Ljk0OS02LjA4NnY0YzEuMDEzIDAgMS45NS44NyAxLjk1IDIuMDg2aDRaTTI3LjYzNyA1OS42MzVhLjM3LjM3IDAgMCAxLS4xMDMuMjM2Ljc3NC43NzQgMCAwIDEtLjU4LjI0NXYtNGMtMi4wOTEgMC0zLjMxNyAxLjg1NC0zLjMxNyAzLjUxOWg0Wm0wIDIuNDA5di0yLjQwOWgtNHYyLjQwOWg0Wm0tLjEwMy0uMjM2YS40NS40NSAwIDAgMSAuMDg2LjE0Mi4yNzcuMjc3IDAgMCAxIC4wMTcuMDk0aC00YzAgLjg0OC4yOSAxLjcxLjg3NCAyLjM4NGwzLjAyMy0yLjYyWm0tMS4xNjEgMGEuNzc2Ljc3NiAwIDAgMSAuNTgtLjI0NWMuMjcgMCAuNDc2LjEyNC41ODEuMjQ1bC0zLjAyMyAyLjYyYzEuMzEyIDEuNTEzIDMuNTczIDEuNTEzIDQuODg1IDBsLTMuMDIzLTIuNjJabS0uMTAzLjIzNmMwLS4wMjcuMDA0LS4wNTguMDE3LS4wOTRhLjQ1LjQ1IDAgMCAxIC4wODYtLjE0MmwzLjAyMyAyLjYyYTMuNjQ0IDMuNjQ0IDAgMCAwIC44NzQtMi4zODRoLTRabTAtMi40MDl2Mi40MDloNHYtMi40MDloLTRabS42ODMuNDgxYy0uMjkgMC0uNDktLjE0LS41OC0uMjQ1YS4zNy4zNyAwIDAgMS0uMTAzLS4yMzZoNGMwLTEuNjY1LTEuMjI2LTMuNTE5LTMuMzE3LTMuNTE5djRabTE5LjExMy0uNDgxYS4zNy4zNyAwIDAgMS0uMTAyLjIzNi43NzQuNzc0IDAgMCAxLS41ODEuMjQ1di00Yy0yLjA5MSAwLTMuMzE3IDEuODU0LTMuMzE3IDMuNTE5aDRabTAgMi40MDl2LTIuNDA5aC00djIuNDA5aDRabS0uMTAyLS4yMzZhLjQ1LjQ1IDAgMCAxIC4wODUuMTQyLjI3Ny4yNzcgMCAwIDEgLjAxNy4wOTRoLTRjMCAuODQ4LjI5IDEuNzEuODc1IDIuMzg0bDMuMDIzLTIuNjJabS0xLjE2MiAwYS43NzUuNzc1IDAgMCAxIC41OC0uMjQ1Yy4yNzEgMCAuNDc3LjEyNC41ODIuMjQ1bC0zLjAyMyAyLjYyYzEuMzExIDEuNTEzIDMuNTczIDEuNTEzIDQuODg0IDBsLTMuMDIzLTIuNjJabS0uMTAzLjIzNmMwLS4wMjcuMDA1LS4wNTguMDE4LS4wOTRhLjQ1LjQ1IDAgMCAxIC4wODUtLjE0MmwzLjAyMyAyLjYyYTMuNjQ1IDMuNjQ1IDAgMCAwIC44NzQtMi4zODRoLTRabTAtMi40MDl2Mi40MDloNHYtMi40MDloLTRabS42ODQuNDgxYy0uMjkgMC0uNDktLjE0LS41OC0uMjQ1YS4zNjkuMzY5IDAgMCAxLS4xMDQtLjIzNmg0YzAtMS42NjUtMS4yMjUtMy41MTktMy4zMTYtMy41MTl2NFptLTE0LjQ4LTRjLTIuMDkxIDAtMy4zMTcgMS44NTQtMy4zMTcgMy41MTloNGEuMzcuMzcgMCAwIDEtLjEwMy4yMzYuNzc0Ljc3NCAwIDAgMS0uNTguMjQ1di00Wm0zLjMxNiAzLjUxOWMwLTEuNjY1LTEuMjI1LTMuNTE5LTMuMzE2LTMuNTE5djRjLS4yOSAwLS40OS0uMTQtLjU4LS4yNDVhLjM3LjM3IDAgMCAxLS4xMDQtLjIzNmg0Wm0wIDIuNDA5di0yLjQwOWgtNHYyLjQwOWg0Wm0tLjg3NCAyLjM4NGEzLjY0NCAzLjY0NCAwIDAgMCAuODc0LTIuMzg0aC00YzAtLjAyNy4wMDUtLjA1OC4wMTctLjA5NGEuNDUuNDUgMCAwIDEgLjA4Ni0uMTQybDMuMDIzIDIuNjJabS00Ljg4NSAwYzEuMzEyIDEuNTEzIDMuNTczIDEuNTEzIDQuODg1IDBsLTMuMDIzLTIuNjJhLjc3NS43NzUgMCAwIDEgLjU4LS4yNDVjLjI3IDAgLjQ3Ni4xMjQuNTgxLjI0NWwtMy4wMjMgMi42MlptLS44NzQtMi4zODRjMCAuODQ4LjI5IDEuNzEuODc0IDIuMzg0bDMuMDIzLTIuNjJhLjQ1LjQ1IDAgMCAxIC4wODYuMTQyLjI3Ny4yNzcgMCAwIDEgLjAxNy4wOTRoLTRabTAtMi40MDl2Mi40MDloNHYtMi40MDloLTRabTIyLjQzIDBhLjM3LjM3IDAgMCAxLS4xMDMuMjM2Ljc3NC43NzQgMCAwIDEtLjU4MS4yNDV2LTRjLTIuMDkgMC0zLjMxNiAxLjg1NC0zLjMxNiAzLjUxOWg0Wm0wIDIuNDA5di0yLjQwOWgtNHYyLjQwOWg0Wm0tLjEwMy0uMjM2YS40NTMuNDUzIDAgMCAxIC4wODUuMTQyLjI3Ny4yNzcgMCAwIDEgLjAxOC4wOTRoLTRjMCAuODQ4LjI5IDEuNzEuODc0IDIuMzg0bDMuMDIzLTIuNjJabS0xLjE2MiAwYS43NzYuNzc2IDAgMCAxIC41ODEtLjI0NWMuMjcgMCAuNDc2LjEyNC41OC4yNDVsLTMuMDIyIDIuNjJjMS4zMTEgMS41MTMgMy41NzMgMS41MTMgNC44ODQgMGwtMy4wMjMtMi42MlptLS4xMDMuMjM2YzAtLjAyNy4wMDUtLjA1OC4wMTgtLjA5NGEuNDUuNDUgMCAwIDEgLjA4NS0uMTQybDMuMDIzIDIuNjJhMy42NDQgMy42NDQgMCAwIDAgLjg3NC0yLjM4NGgtNFptMC0yLjQwOXYyLjQwOWg0di0yLjQwOWgtNFptLjY4NC40ODFjLS4yOSAwLS40OS0uMTQtLjU4LS4yNDVhLjM3LjM3IDAgMCAxLS4xMDQtLjIzNmg0YzAtMS42NjUtMS4yMjUtMy41MTktMy4zMTYtMy41MTl2NFptLTE0LjQ4LTRjLTIuMDkxIDAtMy4zMTYgMS44NTQtMy4zMTYgMy41MTloNGEuMzcuMzcgMCAwIDEtLjEwNC4yMzYuNzc0Ljc3NCAwIDAgMS0uNTguMjQ1di00Wm0zLjMxNiAzLjUxOWMwLTEuNjY1LTEuMjI1LTMuNTE5LTMuMzE2LTMuNTE5djRjLS4yOSAwLS40OS0uMTQtLjU4LS4yNDVhLjM3LjM3IDAgMCAxLS4xMDQtLjIzNmg0Wm0wIDIuNDA5di0yLjQwOWgtNHYyLjQwOWg0Wm0tLjg3NCAyLjM4NGEzLjY0NCAzLjY0NCAwIDAgMCAuODc0LTIuMzg0aC00YzAtLjAyNy4wMDUtLjA1OC4wMTgtLjA5NGEuNDUuNDUgMCAwIDEgLjA4NS0uMTQybDMuMDIzIDIuNjJabS00Ljg4NCAwYzEuMzExIDEuNTEzIDMuNTczIDEuNTEzIDQuODg0IDBsLTMuMDIzLTIuNjJhLjc3Ni43NzYgMCAwIDEgLjU4LS4yNDVjLjI3MSAwIC40NzcuMTI0LjU4MS4yNDVsLTMuMDIyIDIuNjJabS0uODc0LTIuMzg0YzAgLjg0OC4yODkgMS43MS44NzQgMi4zODRsMy4wMjMtMi42MmEuNDUzLjQ1MyAwIDAgMSAuMDg1LjE0Mi4yNzcuMjc3IDAgMCAxIC4wMTcuMDk0aC00Wm0wLTIuNDA5djIuNDA5aDR2LTIuNDA5aC00Wm0yMi40MjkgMGEuMzcuMzcgMCAwIDEtLjEwMy4yMzYuNzc0Ljc3NCAwIDAgMS0uNTguMjQ1di00Yy0yLjA5MiAwLTMuMzE3IDEuODU0LTMuMzE3IDMuNTE5aDRabTAgMi40MDl2LTIuNDA5aC00djIuNDA5aDRabS0uMTAzLS4yMzZhLjQ1LjQ1IDAgMCAxIC4wODUuMTQyLjI3Ny4yNzcgMCAwIDEgLjAxOC4wOTRoLTRjMCAuODQ4LjI5IDEuNzEuODc0IDIuMzg0bDMuMDIzLTIuNjJabS0xLjE2MiAwYS43NzYuNzc2IDAgMCAxIC41ODEtLjI0NWMuMjcgMCAuNDc2LjEyNC41OC4yNDVsLTMuMDIyIDIuNjJjMS4zMTIgMS41MTMgMy41NzMgMS41MTMgNC44ODUgMGwtMy4wMjMtMi42MlptLS4xMDIuMjM2YzAtLjAyNy4wMDQtLjA1OC4wMTctLjA5NGEuNDU1LjQ1NSAwIDAgMSAuMDg1LS4xNDJsMy4wMjMgMi42MmEzLjY0NSAzLjY0NSAwIDAgMCAuODc1LTIuMzg0aC00Wm0wLTIuNDA5djIuNDA5aDR2LTIuNDA5aC00Wm0uNjgzLjQ4MWMtLjI5IDAtLjQ5LS4xNC0uNTgtLjI0NWEuMzcuMzcgMCAwIDEtLjEwMy0uMjM2aDRjMC0xLjY2NS0xLjIyNi0zLjUxOS0zLjMxNy0zLjUxOXY0WiIKICAgICAgICBmaWxsPSIjZmZmIiBtYXNrPSJ1cmwoI2EpIiAvPgo8L3N2Zz4="
class="header3__card-recommendation-header-photo header3__card-recommendation-header-photo_default_inverted"
/>
</div>
<div class="header3__card-recommendation-header-content">
<div class="header3__card-recommendation-header-content-tag">
Курс
</div>
<div class="header3__card-recommendation-header-sub">
<span
class="header3__card-recommendation-header-sub-chunk header3__card-recommendation-header-sub-chunk_green"
>
Скидка 20000 ₽
</span>
</div>
</div>
</div>
<h6 class="header3__card-recommendation-title">
ИИ для аналитики и работы с данными
</h6>
<div class="header3__card-recommendation-footer">
12 марта
, 2026
· 2 месяца
</div>
</a>
</div>
</div>
</div>
</div>
<div class="header3__hamburger-tabs-tab-content">
<div class="header3__nav-column">
<div>
<p class="header3__nav-section-title ">OTUS</p>
<div class="header3__nav-section-items ">
<a
class="header3__nav-section-item"
href="/about"
>
О компании
</a>
<a
class="header3__nav-section-item"
href="/smi/"
>
СМИ о нас
</a>
<a
class="header3__nav-section-item js-stats"
href="/journal/"
target="_blank"
rel="noreferrer nofollow"
data-event="header;click_otus_journal"
data-goal="click_otus_journal"
>
OTUS Журнал
</a>
<a
class="header3__nav-section-item"
href="https://direct.otus.ru/"
target="_blank"
rel="noreferrer nofollow"
>
OTUS Директ
</a>
<a
class="header3__nav-section-item"
href="/legal/common/"
>
Сведения об образовательной организации
</a>
<a
class="header3__nav-section-item"
href="/contacts/"
>
Контактная информация
</a>
</div>
</div>
</div>
<div class="header3__nav-column">
<div>
<p class="header3__nav-section-title ">Студентам</p>
<div class="header3__nav-section-items ">
<a
class="header3__nav-section-item"
href="/reviews"
>
Отзывы
</a>
<a
class="header3__nav-section-item"
target="_blank"
href="https://landing.otus.ru/about-otus"
>
Как выбрать курс
</a>
<a
class="header3__nav-section-item"
target="_blank"
href="https://landing.otus.ru/gallery"
>
Истории выпускников
</a>
<a
class="header3__nav-section-item"
href="/instructors/"
>
Наши преподаватели
</a>
<a
class="header3__nav-section-item"
href="/employers/all/"
>
Наши партнеры
</a>
<a
class="header3__nav-section-item"
href="/about/loyalty/"
>
Программа лояльности
</a>
<a
class="header3__nav-section-item"
href="/faq/"
>
Вопросы и ответы
</a>
</div>
</div>
</div>
<div class="header3__nav-column">
<div>
<p class="header3__nav-section-title ">Преподавателям</p>
<div class="header3__nav-section-items ">
<a
class="header3__nav-section-item"
href="/teach/"
>
Стать преподавателем
</a>
<a
class="header3__nav-section-item"
href="/instructors/"
>
Наши преподаватели
</a>
<a
class="header3__nav-section-item"
href="/nest/dlja-prepodavatelej/"
>
База знаний
</a>
</div>
</div>
</div>
<div class="header3__nav-column header3__nav-column_space-between">
<div>
<p class="header3__nav-section-title header3__nav-section-items-recommendation-title">OTUS рекомендует</p>
<div class="header3__nav-section-items header3__nav-section-items_not-items header3__nav-section-items-recommendation">
<a
href="/lessons/ai-dlya-analitiki-i-raboty-s-dannymi/"
class="header3__card-recommendation"
>
<div
class="header3__card-recommendation-background"
style="background: linear-gradient( 90deg,#0A4489, #00316B);"
></div>
<div class="header3__card-recommendation-header">
<div class="header3__card-recommendation-header-photo-wrapper" style="background: linear-gradient( 90deg,#0A4489, #00316B);">
<img
src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODAiIGhlaWdodD0iODAiIHZpZXdCb3g9IjAgMCA4MCA4MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICAgIDxwYXRoCiAgICAgICAgZD0iTTM1LjAwNDcgNDUuODcyNUMzNS4wMDQ3IDQ2LjkzMDYgMzYuMTA5IDQ3LjU3NTcgMzYuOTYwMyA0Ny4wMTQ5TDQ0LjYyOTIgNDEuOTYzMUM0NS4wMDI5IDQxLjcxNjkgNDUuMjI5OSA0MS4yODUyIDQ1LjIyOTkgNDAuODIwN1YyNi43NDg5QzQ1LjIyOTkgMjYuNzQ4OSA0Ny43ODYyIDI2Ljc0ODkgNTQuMTc3IDIyLjcwNzRDNTcuMzAxMiAyMC43MzE4IDU4Ljg5OCAxOC43NTYxIDU5LjcxNDMgMTcuMjUyNUM2MC4yNDY1IDE2LjI3MjEgNTkuMjUxMSAxNS41NTg4IDU4LjI5NjUgMTYuMDcyM0M1NS4zMjkyIDE3LjY2ODQgNDkuMzkxOCAyMC4wMTMyIDQwLjExNzMgMjAuMDEzMkMzMC44NDI4IDIwLjAxMzIgMjQuOTA1NCAxNy42Njg0IDIxLjkzODEgMTYuMDcyM0MyMC45ODM1IDE1LjU1ODggMTkuOTg4MSAxNi4yNzIxIDIwLjUyMDMgMTcuMjUyNUMyMS4zMzY2IDE4Ljc1NjEgMjIuOTMzNCAyMC43MzE4IDI2LjA1NzcgMjIuNzA3NEMzMi40NDg0IDI2Ljc0ODkgMzUuMDA0NyAyNi43NDg5IDM1LjAwNDcgMjYuNzQ4OVY0NS44NzI1WiIKICAgICAgICBmaWxsPSJ3aGl0ZSIgLz4KICAgIDxlbGxpcHNlIGN4PSIyNi45NTM1IiBjeT0iMzUuNTUyNiIgcng9IjMuOTQ5MTQiIHJ5PSI0LjA4NTMyIiBmaWxsPSJ3aGl0ZSIgLz4KICAgIDxlbGxpcHNlIGN4PSI1My4yODEyIiBjeT0iMzUuNTUyNiIgcng9IjMuOTQ5MTQiIHJ5PSI0LjA4NTMyIiBmaWxsPSJ3aGl0ZSIgLz4KICAgIDxwYXRoCiAgICAgICAgZD0iTTI1LjYzNzIgNTkuNjM1QzI1LjYzNzIgNTguNzk2MSAyNi4yMjY2IDU4LjExNiAyNi45NTM2IDU4LjExNkMyNy42ODA2IDU4LjExNiAyOC4yNyA1OC43OTYxIDI4LjI3IDU5LjYzNVY2Mi4wNDRDMjguMjcgNjIuNDQ2OSAyOC4xMzEzIDYyLjgzMzMgMjcuODg0NCA2My4xMTgxQzI3LjM3MDMgNjMuNzExNCAyNi41MzY4IDYzLjcxMTQgMjYuMDIyOCA2My4xMTgxQzI1Ljc3NTkgNjIuODMzMyAyNS42MzcyIDYyLjQ0NjkgMjUuNjM3MiA2Mi4wNDRWNTkuNjM1WiIKICAgICAgICBmaWxsPSJ3aGl0ZSIgLz4KICAgIDxwYXRoCiAgICAgICAgZD0iTTQ0LjA2NjUgNTkuNjM1QzQ0LjA2NjUgNTguNzk2MSA0NC42NTU5IDU4LjExNiA0NS4zODI5IDU4LjExNkM0Ni4xMDk5IDU4LjExNiA0Ni42OTkzIDU4Ljc5NjEgNDYuNjk5MyA1OS42MzVWNjIuMDQ0QzQ2LjY5OTMgNjIuNDQ2OSA0Ni41NjA2IDYyLjgzMzMgNDYuMzEzOCA2My4xMTgxQzQ1Ljc5OTcgNjMuNzExNCA0NC45NjYyIDYzLjcxMTQgNDQuNDUyMSA2My4xMTgxQzQ0LjIwNTIgNjIuODMzMyA0NC4wNjY1IDYyLjQ0NjkgNDQuMDY2NSA2Mi4wNDRWNTkuNjM1WiIKICAgICAgICBmaWxsPSJ3aGl0ZSIgLz4KICAgIDxwYXRoCiAgICAgICAgZD0iTTI5LjU4NjQgNTkuNjM1QzI5LjU4NjQgNTguNzk2MSAzMC4xNzU3IDU4LjExNiAzMC45MDI3IDU4LjExNkMzMS42Mjk4IDU4LjExNiAzMi4yMTkxIDU4Ljc5NjEgMzIuMjE5MSA1OS42MzVWNjIuMDQ0QzMyLjIxOTEgNjIuNDQ2OSAzMi4wODA0IDYyLjgzMzMgMzEuODMzNiA2My4xMTgxQzMxLjMxOTUgNjMuNzExNCAzMC40ODYgNjMuNzExNCAyOS45NzE5IDYzLjExODFDMjkuNzI1IDYyLjgzMzMgMjkuNTg2NCA2Mi40NDY5IDI5LjU4NjQgNjIuMDQ0VjU5LjYzNVoiCiAgICAgICAgZmlsbD0id2hpdGUiIC8+CiAgICA8cGF0aAogICAgICAgIGQ9Ik00OC4wMTU3IDU5LjYzNUM0OC4wMTU3IDU4Ljc5NjEgNDguNjA1MSA1OC4xMTYgNDkuMzMyMSA1OC4xMTZDNTAuMDU5MSA1OC4xMTYgNTAuNjQ4NSA1OC43OTYxIDUwLjY0ODUgNTkuNjM1VjYyLjA0NEM1MC42NDg1IDYyLjQ0NjkgNTAuNTA5OCA2Mi44MzMzIDUwLjI2MjkgNjMuMTE4MUM0OS43NDg4IDYzLjcxMTQgNDguOTE1MyA2My43MTE0IDQ4LjQwMTIgNjMuMTE4MUM0OC4xNTQ0IDYyLjgzMzMgNDguMDE1NyA2Mi40NDY5IDQ4LjAxNTcgNjIuMDQ0VjU5LjYzNVoiCiAgICAgICAgZmlsbD0id2hpdGUiIC8+CiAgICA8cGF0aAogICAgICAgIGQ9Ik0zMy41MzU1IDU5LjYzNUMzMy41MzU1IDU4Ljc5NjEgMzQuMTI0OSA1OC4xMTYgMzQuODUxOSA1OC4xMTZDMzUuNTc4OSA1OC4xMTYgMzYuMTY4MyA1OC43OTYxIDM2LjE2ODMgNTkuNjM1VjYyLjA0NEMzNi4xNjgzIDYyLjQ0NjkgMzYuMDI5NiA2Mi44MzMzIDM1Ljc4MjcgNjMuMTE4MUMzNS4yNjg2IDYzLjcxMTQgMzQuNDM1MSA2My43MTE0IDMzLjkyMTEgNjMuMTE4MUMzMy42NzQyIDYyLjgzMzMgMzMuNTM1NSA2Mi40NDY5IDMzLjUzNTUgNjIuMDQ0VjU5LjYzNVoiCiAgICAgICAgZmlsbD0id2hpdGUiIC8+CiAgICA8cGF0aAogICAgICAgIGQ9Ik01MS45NjQ4IDU5LjYzNUM1MS45NjQ4IDU4Ljc5NjEgNTIuNTU0MiA1OC4xMTYgNTMuMjgxMiA1OC4xMTZDNTQuMDA4MiA1OC4xMTYgNTQuNTk3NiA1OC43OTYxIDU0LjU5NzYgNTkuNjM1VjYyLjA0NEM1NC41OTc2IDYyLjQ0NjkgNTQuNDU4OSA2Mi44MzMzIDU0LjIxMiA2My4xMTgxQzUzLjY5OCA2My43MTE0IDUyLjg2NDUgNjMuNzExNCA1Mi4zNTA0IDYzLjExODFDNTIuMTAzNSA2Mi44MzMzIDUxLjk2NDggNjIuNDQ2OSA1MS45NjQ4IDYyLjA0NFY1OS42MzVaIgogICAgICAgIGZpbGw9IndoaXRlIiAvPgo8L3N2Zz4="
class="header3__card-recommendation-header-photo header3__card-recommendation-header-photo_default"
/>
<img
src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODAiIGhlaWdodD0iODAiIHZpZXdCb3g9IjAgMCA4MCA4MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICAgIDxtYXNrIGlkPSJhIiBmaWxsPSIjZmZmIj4KICAgICAgICA8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIKICAgICAgICAgICAgZD0iTTAgMTJDMCA1LjM3MyA1LjM3MyAwIDEyIDBoNTZjNi42MjcgMCAxMiA1LjM3MyAxMiAxMnY1NmMwIDYuNjI3LTUuMzczIDEyLTEyIDEySDEyQzUuMzczIDgwIDAgNzQuNjI3IDAgNjhWMTJabTM1LjAwNSAzMy44NzNjMCAxLjA1OCAxLjEwNCAxLjcwMyAxLjk1NSAxLjE0Mmw3LjY3LTUuMDUyYy4zNzMtLjI0Ni42LS42NzguNi0xLjE0MlYyNi43NDlzMi41NTYgMCA4Ljk0Ny00LjA0MmMzLjEyNC0xLjk3NSA0LjcyMS0zLjk1MSA1LjUzNy01LjQ1NC41MzMtLjk4MS0uNDYzLTEuNjk0LTEuNDE3LTEuMTgtMi45NjggMS41OTUtOC45MDUgMy45NC0xOC4xOCAzLjk0LTkuMjc0IDAtMTUuMjEyLTIuMzQ1LTE4LjE3OS0zLjk0LS45NTQtLjUxNC0xLjk1LjE5OS0xLjQxOCAxLjE4LjgxNyAxLjUwMyAyLjQxMyAzLjQ3OSA1LjUzOCA1LjQ1NCA2LjM5IDQuMDQyIDguOTQ3IDQuMDQyIDguOTQ3IDQuMDQydjE5LjEyNFptLTguMDUxLTYuMjM1YzIuMTggMCAzLjk0OS0xLjgzIDMuOTQ5LTQuMDg1IDAtMi4yNTctMS43NjgtNC4wODYtMy45NS00LjA4Ni0yLjE4IDAtMy45NDkgMS44My0zLjk0OSA0LjA4NiAwIDIuMjU2IDEuNzY4IDQuMDg1IDMuOTUgNC4wODVabTMwLjI3Ni00LjA4NWMwIDIuMjU2LTEuNzY4IDQuMDg1LTMuOTQ5IDQuMDg1LTIuMTggMC0zLjk0OS0xLjgzLTMuOTQ5LTQuMDg1IDAtMi4yNTcgMS43NjgtNC4wODYgMy45NS00LjA4NiAyLjE4IDAgMy45NDggMS44MyAzLjk0OCA0LjA4NlpNMjYuOTU0IDU4LjExNmMtLjcyNyAwLTEuMzE3LjY4LTEuMzE3IDEuNTE5djIuNDA5YzAgLjQwMy4xMzkuNzkuMzg2IDEuMDc0LjUxNC41OTMgMS4zNDcuNTkzIDEuODYxIDAgLjI0Ny0uMjg1LjM4Ni0uNjcxLjM4Ni0xLjA3NHYtMi40MDljMC0uODM5LS41OS0xLjUxOS0xLjMxNy0xLjUxOVptMTguNDI5IDBjLS43MjcgMC0xLjMxNy42OC0xLjMxNyAxLjUxOXYyLjQwOWMwIC40MDMuMTQuNzkuMzg2IDEuMDc0LjUxNC41OTMgMS4zNDguNTkzIDEuODYyIDAgLjI0Ny0uMjg1LjM4NS0uNjcxLjM4NS0xLjA3NHYtMi40MDljMC0uODM5LS41OS0xLjUxOS0xLjMxNi0xLjUxOVptLTE1Ljc5NyAxLjUxOWMwLS44MzkuNTktMS41MTkgMS4zMTctMS41MTlzMS4zMTYuNjggMS4zMTYgMS41MTl2Mi40MDljMCAuNDAzLS4xMzkuNzktLjM4NiAxLjA3NC0uNTE0LjU5My0xLjM0Ny41OTMtMS44NjEgMGExLjY0NSAxLjY0NSAwIDAgMS0uMzg2LTEuMDc0di0yLjQwOVptMTkuNzQ2LTEuNTE5Yy0uNzI3IDAtMS4zMTYuNjgtMS4zMTYgMS41MTl2Mi40MDljMCAuNDAzLjEzOC43OS4zODUgMS4wNzQuNTE0LjU5MyAxLjM0OC41OTMgMS44NjIgMCAuMjQ3LS4yODUuMzg1LS42NzEuMzg1LTEuMDc0di0yLjQwOWMwLS44MzktLjU4OS0xLjUxOS0xLjMxNi0xLjUxOVptLTE1Ljc5NyAxLjUxOWMwLS44MzkuNTktMS41MTkgMS4zMTctMS41MTlzMS4zMTYuNjggMS4zMTYgMS41MTl2Mi40MDljMCAuNDAzLS4xMzkuNzktLjM4NSAxLjA3NC0uNTE0LjU5My0xLjM0OC41OTMtMS44NjIgMGExLjY0NSAxLjY0NSAwIDAgMS0uMzg2LTEuMDc0di0yLjQwOVptMTkuNzQ2LTEuNTE5Yy0uNzI3IDAtMS4zMTYuNjgtMS4zMTYgMS41MTl2Mi40MDljMCAuNDAzLjEzOC43OS4zODUgMS4wNzQuNTE0LjU5MyAxLjM0OC41OTMgMS44NjIgMCAuMjQ3LS4yODUuMzg2LS42NzEuMzg2LTEuMDc0di0yLjQwOWMwLS44MzktLjU5LTEuNTE5LTEuMzE3LTEuNTE5WiIgLz4KICAgIDwvbWFzaz4KICAgIDxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIgogICAgICAgIGQ9Ik0wIDEyQzAgNS4zNzMgNS4zNzMgMCAxMiAwaDU2YzYuNjI3IDAgMTIgNS4zNzMgMTIgMTJ2NTZjMCA2LjYyNy01LjM3MyAxMi0xMiAxMkgxMkM1LjM3MyA4MCAwIDc0LjYyNyAwIDY4VjEyWm0zNS4wMDUgMzMuODczYzAgMS4wNTggMS4xMDQgMS43MDMgMS45NTUgMS4xNDJsNy42Ny01LjA1MmMuMzczLS4yNDYuNi0uNjc4LjYtMS4xNDJWMjYuNzQ5czIuNTU2IDAgOC45NDctNC4wNDJjMy4xMjQtMS45NzUgNC43MjEtMy45NTEgNS41MzctNS40NTQuNTMzLS45ODEtLjQ2My0xLjY5NC0xLjQxNy0xLjE4LTIuOTY4IDEuNTk1LTguOTA1IDMuOTQtMTguMTggMy45NC05LjI3NCAwLTE1LjIxMi0yLjM0NS0xOC4xNzktMy45NC0uOTU0LS41MTQtMS45NS4xOTktMS40MTggMS4xOC44MTcgMS41MDMgMi40MTMgMy40NzkgNS41MzggNS40NTQgNi4zOSA0LjA0MiA4Ljk0NyA0LjA0MiA4Ljk0NyA0LjA0MnYxOS4xMjRabS04LjA1MS02LjIzNWMyLjE4IDAgMy45NDktMS44MyAzLjk0OS00LjA4NSAwLTIuMjU3LTEuNzY4LTQuMDg2LTMuOTUtNC4wODYtMi4xOCAwLTMuOTQ5IDEuODMtMy45NDkgNC4wODYgMCAyLjI1NiAxLjc2OCA0LjA4NSAzLjk1IDQuMDg1Wm0zMC4yNzYtNC4wODVjMCAyLjI1Ni0xLjc2OCA0LjA4NS0zLjk0OSA0LjA4NS0yLjE4IDAtMy45NDktMS44My0zLjk0OS00LjA4NSAwLTIuMjU3IDEuNzY4LTQuMDg2IDMuOTUtNC4wODYgMi4xOCAwIDMuOTQ4IDEuODMgMy45NDggNC4wODZaTTI2Ljk1NCA1OC4xMTZjLS43MjcgMC0xLjMxNy42OC0xLjMxNyAxLjUxOXYyLjQwOWMwIC40MDMuMTM5Ljc5LjM4NiAxLjA3NC41MTQuNTkzIDEuMzQ3LjU5MyAxLjg2MSAwIC4yNDctLjI4NS4zODYtLjY3MS4zODYtMS4wNzR2LTIuNDA5YzAtLjgzOS0uNTktMS41MTktMS4zMTctMS41MTlabTE4LjQyOSAwYy0uNzI3IDAtMS4zMTcuNjgtMS4zMTcgMS41MTl2Mi40MDljMCAuNDAzLjE0Ljc5LjM4NiAxLjA3NC41MTQuNTkzIDEuMzQ4LjU5MyAxLjg2MiAwIC4yNDctLjI4NS4zODUtLjY3MS4zODUtMS4wNzR2LTIuNDA5YzAtLjgzOS0uNTktMS41MTktMS4zMTYtMS41MTlabS0xNS43OTcgMS41MTljMC0uODM5LjU5LTEuNTE5IDEuMzE3LTEuNTE5czEuMzE2LjY4IDEuMzE2IDEuNTE5djIuNDA5YzAgLjQwMy0uMTM5Ljc5LS4zODYgMS4wNzQtLjUxNC41OTMtMS4zNDcuNTkzLTEuODYxIDBhMS42NDUgMS42NDUgMCAwIDEtLjM4Ni0xLjA3NHYtMi40MDlabTE5Ljc0Ni0xLjUxOWMtLjcyNyAwLTEuMzE2LjY4LTEuMzE2IDEuNTE5djIuNDA5YzAgLjQwMy4xMzguNzkuMzg1IDEuMDc0LjUxNC41OTMgMS4zNDguNTkzIDEuODYyIDAgLjI0Ny0uMjg1LjM4NS0uNjcxLjM4NS0xLjA3NHYtMi40MDljMC0uODM5LS41ODktMS41MTktMS4zMTYtMS41MTlabS0xNS43OTcgMS41MTljMC0uODM5LjU5LTEuNTE5IDEuMzE3LTEuNTE5czEuMzE2LjY4IDEuMzE2IDEuNTE5djIuNDA5YzAgLjQwMy0uMTM5Ljc5LS4zODUgMS4wNzQtLjUxNC41OTMtMS4zNDguNTkzLTEuODYyIDBhMS42NDUgMS42NDUgMCAwIDEtLjM4Ni0xLjA3NHYtMi40MDlabTE5Ljc0Ni0xLjUxOWMtLjcyNyAwLTEuMzE2LjY4LTEuMzE2IDEuNTE5djIuNDA5YzAgLjQwMy4xMzguNzkuMzg1IDEuMDc0LjUxNC41OTMgMS4zNDguNTkzIDEuODYyIDAgLjI0Ny0uMjg1LjM4Ni0uNjcxLjM4Ni0xLjA3NHYtMi40MDljMC0uODM5LS41OS0xLjUxOS0xLjMxNy0xLjUxOVoiCiAgICAgICAgZmlsbD0iI2ZmZiIgLz4KICAgIDxwYXRoCiAgICAgICAgZD0ibTM2Ljk2IDQ3LjAxNSAxLjEgMS42Ny0xLjEtMS42N1ptNy42Ny01LjA1MiAxLjEgMS42Ny0xLjEtMS42N1ptLjYtMTUuMjE0di0yaC0ydjJoMlptOC45NDctNC4wNDIgMS4wNjkgMS42OS0xLjA2OS0xLjY5Wm01LjUzNy01LjQ1NCAxLjc1OC45NTQtMS43NTgtLjk1NFptLTEuNDE3LTEuMTguOTQ3IDEuNzYtLjk0Ny0xLjc2Wm0tMzYuMzU5IDAtLjk0NyAxLjc2Ljk0Ny0xLjc2Wm0tMS40MTggMS4xOCAxLjc1OC0uOTU1LTEuNzU4Ljk1NVptNS41MzggNS40NTQtMS4wNyAxLjY5IDEuMDctMS42OVptOC45NDcgNC4wNDJoMnYtMmgtMnYyWm0tOC45ODIgMzYuMzcgMS41MTEtMS4zMS0xLjUxMSAxLjMxWm0xLjg2MSAwIDEuNTEyIDEuMzA5LTEuNTEyLTEuMzFabTE2LjU2OCAwLTEuNTExIDEuMzA5IDEuNTExLTEuMzFabTEuODYyIDAgMS41MTEgMS4zMDktMS41MTEtMS4zMVptLTE0LjQ4IDAtMS41MTItMS4zMSAxLjUxMiAxLjMxWm0tMS44NjIgMCAxLjUxMS0xLjMxLTEuNTExIDEuMzFabTE4LjQzIDAtMS41MTIgMS4zMDkgMS41MTEtMS4zMVptMS44NiAwIDEuNTEyIDEuMzA5LTEuNTExLTEuMzFabS0xNC40OCAwIDEuNTEyIDEuMzA5LTEuNTExLTEuMzFabS0xLjg2MSAwLTEuNTExIDEuMzA5IDEuNTExLTEuMzFabTE4LjQzIDAgMS41MS0xLjMxLTEuNTEgMS4zMVptMS44NjEgMCAxLjUxMSAxLjMwOS0xLjUxMS0xLjMxWk0xMi0yQzQuMjY4LTItMiA0LjI2OC0yIDEyaDRDMiA2LjQ3NyA2LjQ3NyAyIDEyIDJ2LTRabTU2IDBIMTJ2NGg1NnYtNFptMTQgMTRjMC03LjczMi02LjI2OC0xNC0xNC0xNHY0YzUuNTIzIDAgMTAgNC40NzcgMTAgMTBoNFptMCA1NlYxMmgtNHY1Nmg0Wk02OCA4MmM3LjczMiAwIDE0LTYuMjY4IDE0LTE0aC00YzAgNS41MjMtNC40NzcgMTAtMTAgMTB2NFptLTU2IDBoNTZ2LTRIMTJ2NFpNLTIgNjhjMCA3LjczMiA2LjI2OCAxNCAxNCAxNHYtNEM2LjQ3NyA3OCAyIDczLjUyMyAyIDY4aC00Wm0wLTU2djU2aDRWMTJoLTRabTM3Ljg2IDMzLjM0NWEuNzkxLjc5MSAwIDAgMSAuODEyLS4wMjEuNjU2LjY1NiAwIDAgMSAuMzMzLjU0OWgtNGMwIDIuNDU0IDIuNzE0IDQuMzU1IDUuMDU1IDIuODEybC0yLjItMy4zNFptNy42NjktNS4wNTItNy42NjkgNS4wNTIgMi4yIDMuMzQgNy42Ny01LjA1Mi0yLjIwMS0zLjM0Wm0tLjI5OS41MjhjMC0uMTc4LjA4Ni0uMzg4LjI5OS0uNTI4bDIuMiAzLjM0YTMuMzY1IDMuMzY1IDAgMCAwIDEuNTAxLTIuODEyaC00Wm0wLTE0LjA3MlY0MC44Mmg0VjI2Ljc0OWgtNFptOS44NzgtNS43MzJjLTMuMDk3IDEuOTU5LTUuMTk3IDIuODk1LTYuNDY0IDMuMzRhOC44NDUgOC44NDUgMCAwIDEtMS4yOC4zNjIgMi40MiAyLjQyIDAgMCAxLS4yMDMuMDMxSDQ1LjE5bC4wMTgtLjAwMWguMDJjLjAwMSAwIC4wMDMgMCAuMDAzIDJzLjAwMiAyIC4wMDMgMmguMDU0YTMuMTk2IDMuMTk2IDAgMCAwIC4yNTYtLjAxN2MuMTQ0LS4wMTQuMzI3LS4wMzguNTUyLS4wOC40NS0uMDg0IDEuMDY5LS4yMzggMS44NzUtLjUyMSAxLjYxLS41NjYgMy45ODItMS42NSA3LjI3Ni0zLjczM2wtMi4xMzgtMy4zODFabTQuODQ5LTQuNzE5Yy0uNjM3IDEuMTczLTEuOTgzIDIuOTA2LTQuODQ5IDQuNzE5bDIuMTM4IDMuMzhjMy4zODItMi4xMzggNS4yMy00LjM1NiA2LjIyNi02LjE5bC0zLjUxNS0xLjkwOVptMS4yODcgMS41MzZjLS4xMDYuMDU2LS40OTQuMTczLS45MTYtLjE1OGExLjIzMyAxLjIzMyAwIDAgMS0uNDQ4LS43Ni45ODUuOTg1IDAgMCAxIC4wNzctLjYxOGwzLjUxNSAxLjkwOWMuMzA4LS41NjguNDcyLTEuMjQ4LjM1MS0xLjk2YTIuNzY4IDIuNzY4IDAgMCAwLTEuMDI1LTEuNzE4Yy0xLjAyNi0uODA1LTIuMzg4LS43ODgtMy40NDktLjIxOGwxLjg5NSAzLjUyM1ptLTE5LjEyNyA0LjE4YzkuNjI4IDAgMTUuODg5LTIuNDM4IDE5LjEyNy00LjE4bC0xLjg5NS0zLjUyM2MtMi42OTYgMS40NS04LjMxIDMuNzAyLTE3LjIzMiAzLjcwMnY0Wm0tMTkuMTI2LTQuMThjMy4yMzggMS43NDIgOS40OTkgNC4xOCAxOS4xMjYgNC4xOHYtNGMtOC45MjEgMC0xNC41MzUtMi4yNTMtMTcuMjMxLTMuNzAzbC0xLjg5NSAzLjUyM1ptMS4yODctMS41MzZjLjA0Mi4wNzguMTMxLjMuMDc3LjYxOWExLjIzMiAxLjIzMiAwIDAgMS0uNDQ5Ljc1OWMtLjQyMi4zMy0uODEuMjE1LS45MTUuMTU4bDEuODk1LTMuNTIzYy0xLjA2LS41Ny0yLjQyNC0uNTg3LTMuNDQ5LjIxOGEyLjc2OCAyLjc2OCAwIDAgMC0xLjAyNiAxLjcxOWMtLjEyLjcxMS4wNDMgMS4zOTEuMzUyIDEuOTU5bDMuNTE1LTEuOTA5Wm00Ljg0OSA0LjcxOWMtMi44NjctMS44MTMtNC4yMTItMy41NDYtNC44NDktNC43MTlsLTMuNTE1IDEuOTA5Yy45OTUgMS44MzQgMi44NDQgNC4wNTIgNi4yMjYgNi4xOWwyLjEzOC0zLjM4Wm03Ljg3OCA1LjczMmMwLTIgLjAwMS0yIC4wMDMtMkgzNS4wNDdsLjAyNS4wMDFoLjAwMWEyLjQyIDIuNDIgMCAwIDEtLjIwMi0uMDMgOC44NTIgOC44NTIgMCAwIDEtMS4yOC0uMzYzYy0xLjI2Ny0uNDQ1LTMuMzY3LTEuMzgxLTYuNDY0LTMuMzRsLTIuMTM4IDMuMzhjMy4yOTMgMi4wODQgNS42NjYgMy4xNjggNy4yNzYgMy43MzQuODA2LjI4MyAxLjQyNS40MzcgMS44NzUuNTJhNi4zNTYgNi4zNTYgMCAwIDAgLjczNy4wOTVsLjA3MS4wMDJoLjA1NGMuMDAxIDAgLjAwMyAwIC4wMDMtMlptMiAxOS4xMjRWMjYuNzQ5aC00djE5LjEyNGg0Wm0tOC4xMDItMTAuMzJjMCAxLjIxNi0uOTM2IDIuMDg1LTEuOTUgMi4wODV2NGMzLjM1IDAgNS45NS0yLjc4OSA1Ljk1LTYuMDg1aC00Wm0tMS45NS0yLjA4NmMxLjAxNCAwIDEuOTUuODcgMS45NSAyLjA4Nmg0YzAtMy4yOTctMi42LTYuMDg2LTUuOTUtNi4wODZ2NFptLTEuOTQ5IDIuMDg2YzAtMS4yMTYuOTM2LTIuMDg2IDEuOTUtMi4wODZ2LTRjLTMuMzUgMC01Ljk1IDIuNzktNS45NSA2LjA4Nmg0Wm0xLjk1IDIuMDg1Yy0xLjAxNCAwLTEuOTUtLjg3LTEuOTUtMi4wODVoLTRjMCAzLjI5NiAyLjYgNi4wODUgNS45NSA2LjA4NXYtNFptMjYuMzI3IDRjMy4zNSAwIDUuOTUtMi43ODkgNS45NS02LjA4NWgtNGMwIDEuMjE2LS45MzcgMi4wODUtMS45NSAyLjA4NXY0Wm0tNS45NDktNi4wODVjMCAzLjI5NiAyLjYgNi4wODUgNS45NSA2LjA4NXYtNGMtMS4wMTQgMC0xLjk1LS44Ny0xLjk1LTIuMDg1aC00Wm01Ljk1LTYuMDg2Yy0zLjM1IDAtNS45NSAyLjc5LTUuOTUgNi4wODZoNGMwLTEuMjE2LjkzNi0yLjA4NiAxLjk1LTIuMDg2di00Wm01Ljk0OCA2LjA4NmMwLTMuMjk3LTIuNi02LjA4Ni01Ljk0OS02LjA4NnY0YzEuMDEzIDAgMS45NS44NyAxLjk1IDIuMDg2aDRaTTI3LjYzNyA1OS42MzVhLjM3LjM3IDAgMCAxLS4xMDMuMjM2Ljc3NC43NzQgMCAwIDEtLjU4LjI0NXYtNGMtMi4wOTEgMC0zLjMxNyAxLjg1NC0zLjMxNyAzLjUxOWg0Wm0wIDIuNDA5di0yLjQwOWgtNHYyLjQwOWg0Wm0tLjEwMy0uMjM2YS40NS40NSAwIDAgMSAuMDg2LjE0Mi4yNzcuMjc3IDAgMCAxIC4wMTcuMDk0aC00YzAgLjg0OC4yOSAxLjcxLjg3NCAyLjM4NGwzLjAyMy0yLjYyWm0tMS4xNjEgMGEuNzc2Ljc3NiAwIDAgMSAuNTgtLjI0NWMuMjcgMCAuNDc2LjEyNC41ODEuMjQ1bC0zLjAyMyAyLjYyYzEuMzEyIDEuNTEzIDMuNTczIDEuNTEzIDQuODg1IDBsLTMuMDIzLTIuNjJabS0uMTAzLjIzNmMwLS4wMjcuMDA0LS4wNTguMDE3LS4wOTRhLjQ1LjQ1IDAgMCAxIC4wODYtLjE0MmwzLjAyMyAyLjYyYTMuNjQ0IDMuNjQ0IDAgMCAwIC44NzQtMi4zODRoLTRabTAtMi40MDl2Mi40MDloNHYtMi40MDloLTRabS42ODMuNDgxYy0uMjkgMC0uNDktLjE0LS41OC0uMjQ1YS4zNy4zNyAwIDAgMS0uMTAzLS4yMzZoNGMwLTEuNjY1LTEuMjI2LTMuNTE5LTMuMzE3LTMuNTE5djRabTE5LjExMy0uNDgxYS4zNy4zNyAwIDAgMS0uMTAyLjIzNi43NzQuNzc0IDAgMCAxLS41ODEuMjQ1di00Yy0yLjA5MSAwLTMuMzE3IDEuODU0LTMuMzE3IDMuNTE5aDRabTAgMi40MDl2LTIuNDA5aC00djIuNDA5aDRabS0uMTAyLS4yMzZhLjQ1LjQ1IDAgMCAxIC4wODUuMTQyLjI3Ny4yNzcgMCAwIDEgLjAxNy4wOTRoLTRjMCAuODQ4LjI5IDEuNzEuODc1IDIuMzg0bDMuMDIzLTIuNjJabS0xLjE2MiAwYS43NzUuNzc1IDAgMCAxIC41OC0uMjQ1Yy4yNzEgMCAuNDc3LjEyNC41ODIuMjQ1bC0zLjAyMyAyLjYyYzEuMzExIDEuNTEzIDMuNTczIDEuNTEzIDQuODg0IDBsLTMuMDIzLTIuNjJabS0uMTAzLjIzNmMwLS4wMjcuMDA1LS4wNTguMDE4LS4wOTRhLjQ1LjQ1IDAgMCAxIC4wODUtLjE0MmwzLjAyMyAyLjYyYTMuNjQ1IDMuNjQ1IDAgMCAwIC44NzQtMi4zODRoLTRabTAtMi40MDl2Mi40MDloNHYtMi40MDloLTRabS42ODQuNDgxYy0uMjkgMC0uNDktLjE0LS41OC0uMjQ1YS4zNjkuMzY5IDAgMCAxLS4xMDQtLjIzNmg0YzAtMS42NjUtMS4yMjUtMy41MTktMy4zMTYtMy41MTl2NFptLTE0LjQ4LTRjLTIuMDkxIDAtMy4zMTcgMS44NTQtMy4zMTcgMy41MTloNGEuMzcuMzcgMCAwIDEtLjEwMy4yMzYuNzc0Ljc3NCAwIDAgMS0uNTguMjQ1di00Wm0zLjMxNiAzLjUxOWMwLTEuNjY1LTEuMjI1LTMuNTE5LTMuMzE2LTMuNTE5djRjLS4yOSAwLS40OS0uMTQtLjU4LS4yNDVhLjM3LjM3IDAgMCAxLS4xMDQtLjIzNmg0Wm0wIDIuNDA5di0yLjQwOWgtNHYyLjQwOWg0Wm0tLjg3NCAyLjM4NGEzLjY0NCAzLjY0NCAwIDAgMCAuODc0LTIuMzg0aC00YzAtLjAyNy4wMDUtLjA1OC4wMTctLjA5NGEuNDUuNDUgMCAwIDEgLjA4Ni0uMTQybDMuMDIzIDIuNjJabS00Ljg4NSAwYzEuMzEyIDEuNTEzIDMuNTczIDEuNTEzIDQuODg1IDBsLTMuMDIzLTIuNjJhLjc3NS43NzUgMCAwIDEgLjU4LS4yNDVjLjI3IDAgLjQ3Ni4xMjQuNTgxLjI0NWwtMy4wMjMgMi42MlptLS44NzQtMi4zODRjMCAuODQ4LjI5IDEuNzEuODc0IDIuMzg0bDMuMDIzLTIuNjJhLjQ1LjQ1IDAgMCAxIC4wODYuMTQyLjI3Ny4yNzcgMCAwIDEgLjAxNy4wOTRoLTRabTAtMi40MDl2Mi40MDloNHYtMi40MDloLTRabTIyLjQzIDBhLjM3LjM3IDAgMCAxLS4xMDMuMjM2Ljc3NC43NzQgMCAwIDEtLjU4MS4yNDV2LTRjLTIuMDkgMC0zLjMxNiAxLjg1NC0zLjMxNiAzLjUxOWg0Wm0wIDIuNDA5di0yLjQwOWgtNHYyLjQwOWg0Wm0tLjEwMy0uMjM2YS40NTMuNDUzIDAgMCAxIC4wODUuMTQyLjI3Ny4yNzcgMCAwIDEgLjAxOC4wOTRoLTRjMCAuODQ4LjI5IDEuNzEuODc0IDIuMzg0bDMuMDIzLTIuNjJabS0xLjE2MiAwYS43NzYuNzc2IDAgMCAxIC41ODEtLjI0NWMuMjcgMCAuNDc2LjEyNC41OC4yNDVsLTMuMDIyIDIuNjJjMS4zMTEgMS41MTMgMy41NzMgMS41MTMgNC44ODQgMGwtMy4wMjMtMi42MlptLS4xMDMuMjM2YzAtLjAyNy4wMDUtLjA1OC4wMTgtLjA5NGEuNDUuNDUgMCAwIDEgLjA4NS0uMTQybDMuMDIzIDIuNjJhMy42NDQgMy42NDQgMCAwIDAgLjg3NC0yLjM4NGgtNFptMC0yLjQwOXYyLjQwOWg0di0yLjQwOWgtNFptLjY4NC40ODFjLS4yOSAwLS40OS0uMTQtLjU4LS4yNDVhLjM3LjM3IDAgMCAxLS4xMDQtLjIzNmg0YzAtMS42NjUtMS4yMjUtMy41MTktMy4zMTYtMy41MTl2NFptLTE0LjQ4LTRjLTIuMDkxIDAtMy4zMTYgMS44NTQtMy4zMTYgMy41MTloNGEuMzcuMzcgMCAwIDEtLjEwNC4yMzYuNzc0Ljc3NCAwIDAgMS0uNTguMjQ1di00Wm0zLjMxNiAzLjUxOWMwLTEuNjY1LTEuMjI1LTMuNTE5LTMuMzE2LTMuNTE5djRjLS4yOSAwLS40OS0uMTQtLjU4LS4yNDVhLjM3LjM3IDAgMCAxLS4xMDQtLjIzNmg0Wm0wIDIuNDA5di0yLjQwOWgtNHYyLjQwOWg0Wm0tLjg3NCAyLjM4NGEzLjY0NCAzLjY0NCAwIDAgMCAuODc0LTIuMzg0aC00YzAtLjAyNy4wMDUtLjA1OC4wMTgtLjA5NGEuNDUuNDUgMCAwIDEgLjA4NS0uMTQybDMuMDIzIDIuNjJabS00Ljg4NCAwYzEuMzExIDEuNTEzIDMuNTczIDEuNTEzIDQuODg0IDBsLTMuMDIzLTIuNjJhLjc3Ni43NzYgMCAwIDEgLjU4LS4yNDVjLjI3MSAwIC40NzcuMTI0LjU4MS4yNDVsLTMuMDIyIDIuNjJabS0uODc0LTIuMzg0YzAgLjg0OC4yODkgMS43MS44NzQgMi4zODRsMy4wMjMtMi42MmEuNDUzLjQ1MyAwIDAgMSAuMDg1LjE0Mi4yNzcuMjc3IDAgMCAxIC4wMTcuMDk0aC00Wm0wLTIuNDA5djIuNDA5aDR2LTIuNDA5aC00Wm0yMi40MjkgMGEuMzcuMzcgMCAwIDEtLjEwMy4yMzYuNzc0Ljc3NCAwIDAgMS0uNTguMjQ1di00Yy0yLjA5MiAwLTMuMzE3IDEuODU0LTMuMzE3IDMuNTE5aDRabTAgMi40MDl2LTIuNDA5aC00djIuNDA5aDRabS0uMTAzLS4yMzZhLjQ1LjQ1IDAgMCAxIC4wODUuMTQyLjI3Ny4yNzcgMCAwIDEgLjAxOC4wOTRoLTRjMCAuODQ4LjI5IDEuNzEuODc0IDIuMzg0bDMuMDIzLTIuNjJabS0xLjE2MiAwYS43NzYuNzc2IDAgMCAxIC41ODEtLjI0NWMuMjcgMCAuNDc2LjEyNC41OC4yNDVsLTMuMDIyIDIuNjJjMS4zMTIgMS41MTMgMy41NzMgMS41MTMgNC44ODUgMGwtMy4wMjMtMi42MlptLS4xMDIuMjM2YzAtLjAyNy4wMDQtLjA1OC4wMTctLjA5NGEuNDU1LjQ1NSAwIDAgMSAuMDg1LS4xNDJsMy4wMjMgMi42MmEzLjY0NSAzLjY0NSAwIDAgMCAuODc1LTIuMzg0aC00Wm0wLTIuNDA5djIuNDA5aDR2LTIuNDA5aC00Wm0uNjgzLjQ4MWMtLjI5IDAtLjQ5LS4xNC0uNTgtLjI0NWEuMzcuMzcgMCAwIDEtLjEwMy0uMjM2aDRjMC0xLjY2NS0xLjIyNi0zLjUxOS0zLjMxNy0zLjUxOXY0WiIKICAgICAgICBmaWxsPSIjZmZmIiBtYXNrPSJ1cmwoI2EpIiAvPgo8L3N2Zz4="
class="header3__card-recommendation-header-photo header3__card-recommendation-header-photo_default_inverted"
/>
</div>
<div class="header3__card-recommendation-header-content">
<div class="header3__card-recommendation-header-content-tag">
Курс
</div>
<div class="header3__card-recommendation-header-sub">
<span
class="header3__card-recommendation-header-sub-chunk header3__card-recommendation-header-sub-chunk_green"
>
Скидка 20000 ₽
</span>
</div>
</div>
</div>
<h6 class="header3__card-recommendation-title">
ИИ для аналитики и работы с данными
</h6>
<div class="header3__card-recommendation-footer">
12 марта
, 2026
· 2 месяца
</div>
</a>
</div>
</div>
<div>
<p class="header3__nav-section-title ">Ответим на ваши вопросы</p>
<div class="header3__nav-section-items ">
<a
class="header3__nav-section-item header3__nav-section-item_phone"
rel="noopener noreferrer"
href="tel:+7 499 938-92-02"
>
<svg
class="header3__phone-icon"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14.352 2.75a7.971 7.971 0 0 1 7.041 7.032M14.352 6.293a4.426 4.426 0 0 1 3.5 3.5"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
></path>
<path
clip-rule="evenodd"
d="M7.7 16.299C.803 9.4 1.783 6.241 2.51 5.223c.094-.164 2.396-3.611 4.865-1.589 6.126 5.045-1.63 4.332 3.514 9.477 5.146 5.144 4.431-2.611 9.477 3.514 2.022 2.469-1.425 4.771-1.588 4.864-1.018.728-4.178 1.709-11.078-5.19Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
+7 499 938-92-02
</a>
</div>
</div>
</div>
</div>
</div>
<div class="header3__hamburger-pagination">
<div
class="header3__hamburger-pagination-button header3__hamburger-pagination-button_prev"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19.75 11.726h-15M13.7 5.701l6.05 6.024-6.05 6.025"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
</div>
<div class="header3__hamburger-pagination-items"></div>
<div
class="header3__hamburger-pagination-button header3__hamburger-pagination-button_next"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19.75 11.726h-15M13.7 5.701l6.05 6.024-6.05 6.025"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</header>
<div class="blog js-blog">
<div class="blog__nav-wrapper">
<div class="nav nav_blue nav_mobile-fix">
<div class="nav__scroll">
<div class="container container-relative container-overflow-auto">
<div class="nav__items nav__float-left">
<a href="/nest/" class="nav__item" title="Блоги">Блоги</a>
<a href="/nest/posts/" class="nav__item nav__item_divider nav__item_divider-pad" title="Посты">
Посты
</a>
<a href="/nest/best/" class="nav__item nav__item_divider"
title="Лучшие">
Лучшие
</a>
<a href="/nest/users/" class="nav__item" title="Участники">Участники</a>
<div class="nav__item show-md">
<form action="/nest/search/" method="get" class="inline-block search js-search">
<div class="search__box js-search-box search__box_close">
<input autocomplete="off"
name="q"
placeholder="Поиск блогов, постов, текста в постах"
class="search__input input"
/>
<i class="ic ic-close search__box-close js-cancel"></i>
<button class="button button_gray2 search__box-button">
<i class="ic ic-search-black search__icon"></i>Найти
</button>
</div>
<div class="search__button js-open-search">
<i class="ic ic-search search__icon"></i>Поиск
</div>
</form>
<form action="/nest/post/add/" method="get" class="js-form-need-auth inline-block">
<input type="hidden" name="blog" value="120"/>
</form>
</div>
</div>
<div class="nav__items nav__float-right hide-md">
<div class="nav__item vertical-middle">
<form action="/nest/search/" method="get" class="inline-block search js-search">
<div class="search__box js-search-box search__box_close">
<input autocomplete="off"
name="q"
placeholder="Поиск блогов, постов, текста в постах"
class="search__input input"
/>
<i class="ic ic-close search__box-close js-cancel"></i>
<button class="button button_gray2 search__box-button">
<i class="ic ic-search-black search__icon"></i>Найти
</button>
</div>
<div class="search__button js-open-search">
<i class="ic ic-search search__icon"></i>Поиск
</div>
</form>
<form action="/nest/post/add/" method="get" class="js-form-need-auth inline-block">
<input type="hidden" name="blog" value="120"/>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="blog__content">
<div class="container">
<div class="container__row container__row_gutter-24">
<div class="container__col container__col_8 container__col_md-12">
<div class="blog-post">
<div class="blog__tile blog-post__tile">
<article class="blog-post-content">
<div class="post-info">
<a href="/profile/226076/">
<div class="post-info__avatar ic ic-blog-default-avatar" style="background-image: url(https://cdn.otus.ru/media/public/79/39/avatar-1801-7939a1.png);"></div>
<div class="post-info__author">Зинченко Максим</div>
</a>
<div class="post-info__time">18.11.21 в 19:48</div>
</div>
<h1 class="blog__h1">Проблемы пакетной обработки запросов и их решения. Часть 2</h1>
<div class="post-info">
<div class="post-info__blogs">
<a href="/nest/architec/" class="post-info__blog" title="Архитектура и шаблоны проектирования" >Архитектура и шаблоны проектирования</a> →
<a href="/nest/architec-art/" class="post-info__blog" title="Полезные материалы по архитектуре ПО" >Полезные материалы по архитектуре ПО</a>
</div>
<div class="post-info__tags">
Теги: java, kotlin, производительность, highload, батчинг
</div>
</div>
<div class="blog-post-text blog-post-text_markdown markdown">
<p>Это продолжение статьи «<a href="https://otus.ru/nest/post/2355/">Проблемы пакетной обработки запросов и их решения</a>». Рекомендуется сначала ознакомиться с первой частью, так как в ней подробно описана суть задачи и некоторые подходы к ее решению. Здесь же мы рассмотрим другие методы. <cut></cut></p>
<h2>Краткое повторение задачи</h2>
<p>Есть чат для согласования документа с предопределенным набором участников. Сообщения содержат текст и файлы. И, как в обычных чатах, сообщения могут быть ответами (reply) и пересылками (forward).</p>
<p>Модель сообщения чата:</p>
<pre><div class="codehilite"><pre><span></span><span class="n">data</span> <span class="kd">class</span> <span class="nf">ChatMessage</span><span class="o">(</span>
<span class="c1">// nullable так как появляется только после persist</span>
<span class="n">val</span> <span class="n">id</span><span class="o">:</span> <span class="n">Long</span><span class="o">?</span> <span class="o">=</span> <span class="kc">null</span><span class="o">,</span>
<span class="cm">/** Ссылка на автора */</span>
<span class="n">val</span> <span class="n">author</span><span class="o">:</span> <span class="n">UserReference</span><span class="o">,</span>
<span class="cm">/** Сообщение */</span>
<span class="n">val</span> <span class="n">message</span><span class="o">:</span> <span class="n">String</span><span class="o">,</span>
<span class="cm">/** Ссылки на аттачи */</span>
<span class="c1">// из-за особенностей связки JPA+СУБД проще поддерживать и null, и пустые списки</span>
<span class="n">val</span> <span class="n">files</span><span class="o">:</span> <span class="n">List</span><span class="o"><</span><span class="n">FileReference</span><span class="o">>?</span> <span class="o">=</span> <span class="kc">null</span><span class="o">,</span>
<span class="cm">/** Если является ответом, то здесь будет оригинал */</span>
<span class="n">val</span> <span class="n">replyTo</span><span class="o">:</span> <span class="n">ChatMessage</span><span class="o">?</span> <span class="o">=</span> <span class="kc">null</span><span class="o">,</span>
<span class="cm">/** Если является пересылкой, то здесь будет оригинал */</span>
<span class="n">val</span> <span class="n">forwardFrom</span><span class="o">:</span> <span class="n">ChatMessage</span><span class="o">?</span> <span class="o">=</span> <span class="kc">null</span>
<span class="o">)</span>
</pre></div>
</pre>
<p>Через Dependency Injection нам доступны реализации следующих внешних сервисов:</p>
<pre><div class="codehilite"><pre><span></span><span class="kd">interface</span> <span class="nc">ChatMessageRepository</span> <span class="o">{</span>
<span class="n">fun</span> <span class="nf">findLast</span><span class="o">(</span><span class="n">n</span><span class="o">:</span> <span class="n">Int</span><span class="o">):</span> <span class="n">List</span><span class="o"><</span><span class="n">ChatMessage</span><span class="o">></span>
<span class="o">}</span>
<span class="n">data</span> <span class="kd">class</span> <span class="nf">FileHeadRemote</span><span class="o">(</span>
<span class="n">val</span> <span class="n">id</span><span class="o">:</span> <span class="n">FileReference</span><span class="o">,</span>
<span class="n">val</span> <span class="n">name</span><span class="o">:</span> <span class="n">String</span>
<span class="o">)</span>
<span class="kd">interface</span> <span class="nc">FileRemoteApi</span> <span class="o">{</span>
<span class="n">fun</span> <span class="nf">getHeadById</span><span class="o">(</span><span class="n">id</span><span class="o">:</span> <span class="n">FileReference</span><span class="o">):</span> <span class="n">FileHeadRemote</span>
<span class="n">fun</span> <span class="nf">getHeadsByIds</span><span class="o">(</span><span class="n">id</span><span class="o">:</span> <span class="n">Set</span><span class="o"><</span><span class="n">FileReference</span><span class="o">>):</span> <span class="n">Set</span><span class="o"><</span><span class="n">FileHeadRemote</span><span class="o">></span>
<span class="n">fun</span> <span class="nf">getHeadsByIds</span><span class="o">(</span><span class="n">id</span><span class="o">:</span> <span class="n">List</span><span class="o"><</span><span class="n">FileReference</span><span class="o">>):</span> <span class="n">List</span><span class="o"><</span><span class="n">FileHeadRemote</span><span class="o">></span>
<span class="o">}</span>
<span class="n">data</span> <span class="kd">class</span> <span class="nf">UserRemote</span><span class="o">(</span>
<span class="n">val</span> <span class="n">id</span><span class="o">:</span> <span class="n">UserReference</span><span class="o">,</span>
<span class="n">val</span> <span class="n">name</span><span class="o">:</span> <span class="n">String</span>
<span class="o">)</span>
<span class="kd">interface</span> <span class="nc">UserRemoteApi</span> <span class="o">{</span>
<span class="n">fun</span> <span class="nf">getUserById</span><span class="o">(</span><span class="n">id</span><span class="o">:</span> <span class="n">UserReference</span><span class="o">):</span> <span class="n">UserRemote</span>
<span class="n">fun</span> <span class="nf">getUsersByIds</span><span class="o">(</span><span class="n">id</span><span class="o">:</span> <span class="n">Set</span><span class="o"><</span><span class="n">UserReference</span><span class="o">>):</span> <span class="n">Set</span><span class="o"><</span><span class="n">UserRemote</span><span class="o">></span>
<span class="n">fun</span> <span class="nf">getUsersByIds</span><span class="o">(</span><span class="n">id</span><span class="o">:</span> <span class="n">List</span><span class="o"><</span><span class="n">UserReference</span><span class="o">>):</span> <span class="n">List</span><span class="o"><</span><span class="n">UserRemote</span><span class="o">></span>
<span class="o">}</span>
</pre></div>
</pre>
<p>Нам нужно реализовать REST-контроллер:</p>
<pre><div class="codehilite"><pre><span></span><span class="kd">interface</span> <span class="nc">ChatRestApi</span> <span class="o">{</span>
<span class="n">fun</span> <span class="nf">getLast</span><span class="o">(</span><span class="n">n</span><span class="o">:</span> <span class="n">Int</span><span class="o">):</span> <span class="n">List</span><span class="o"><</span><span class="n">ChatMessageUI</span><span class="o">></span>
<span class="o">}</span>
</pre></div>
</pre>
<p>Где:</p>
<pre><div class="codehilite"><pre><span></span><span class="cm">/** В таком виде отдаются ссылки на сущности для фронта */</span>
<span class="n">data</span> <span class="kd">class</span> <span class="nf">ReferenceUI</span><span class="o">(</span>
<span class="cm">/** Идентификатор для url */</span>
<span class="n">val</span> <span class="n">ref</span><span class="o">:</span> <span class="n">String</span><span class="o">,</span>
<span class="cm">/** Видимое пользователю название ссылки */</span>
<span class="n">val</span> <span class="n">name</span><span class="o">:</span> <span class="n">String</span>
<span class="o">)</span>
<span class="n">data</span> <span class="kd">class</span> <span class="nf">ChatMessageUI</span><span class="o">(</span>
<span class="n">val</span> <span class="n">id</span><span class="o">:</span> <span class="n">Long</span><span class="o">,</span>
<span class="cm">/** Ссылка на автора */</span>
<span class="n">val</span> <span class="n">author</span><span class="o">:</span> <span class="n">ReferenceUI</span><span class="o">,</span>
<span class="cm">/** Сообщение */</span>
<span class="n">val</span> <span class="n">message</span><span class="o">:</span> <span class="n">String</span><span class="o">,</span>
<span class="cm">/** Ссылки на аттачи */</span>
<span class="n">val</span> <span class="n">files</span><span class="o">:</span> <span class="n">List</span><span class="o"><</span><span class="n">ReferenceUI</span><span class="o">>,</span>
<span class="cm">/** Если являтся ответом, то здесь будет оригинал */</span>
<span class="n">val</span> <span class="n">replyTo</span><span class="o">:</span> <span class="n">ChatMessageUI</span><span class="o">?</span> <span class="o">=</span> <span class="kc">null</span><span class="o">,</span>
<span class="cm">/** Если являтся пересылкой, то здесь будет оригинал */</span>
<span class="n">val</span> <span class="n">forwardFrom</span><span class="o">:</span> <span class="n">ChatMessageUI</span><span class="o">?</span> <span class="o">=</span> <span class="kc">null</span>
<span class="o">)</span>
</pre></div>
</pre>
<p>В предыдущей части мы рассмотрели наивную реализацию сервиса, использующего пакетную обработку, и несколько способов ее ускорить. Эти способы очень просты, но их применение не обеспечивает достаточно хорошую производительность.</p>
<h2>Увеличение пакетов</h2>
<p>Главной проблемой наивных решений стал маленький размер пакетов.</p>
<p>Для того чтобы вызовы группировать в пакеты большего размера, нужно как-то накапливать запросы. Вот эта строка не подразумевает накопления запросов:</p>
<pre><div class="codehilite"><pre><span></span><span class="n">author</span> <span class="o">=</span> <span class="n">userRepository</span><span class="o">.</span><span class="na">getUserById</span><span class="o">(</span><span class="n">author</span><span class="o">).</span><span class="na">toFrontReference</span><span class="o">(),</span>
</pre></div>
</pre>
<p>Сейчас у нас в рантайме нет специального места для хранения перечня пользователей — он формируется постепенно. Это придется менять.</p>
<p>Для начала нужно отделить логику получения данных от маппинга в методе:</p>
<pre><div class="codehilite"><pre><span></span><span class="n">ChatMessage</span><span class="o">.</span><span class="na">toFrontModel</span><span class="o">:</span>
<span class="kd">private</span> <span class="n">fun</span> <span class="n">ChatMessage</span><span class="o">.</span><span class="na">toFrontModel</span><span class="o">(</span>
<span class="n">getUser</span><span class="o">:</span> <span class="o">(</span><span class="n">UserReference</span><span class="o">)</span> <span class="o">-></span> <span class="n">UserRemote</span><span class="o">,</span>
<span class="n">getFile</span><span class="o">:</span> <span class="o">(</span><span class="n">FileReference</span><span class="o">)</span> <span class="o">-></span> <span class="n">FileHeadRemote</span><span class="o">,</span>
<span class="n">serializeMessage</span><span class="o">:</span> <span class="o">(</span><span class="n">ChatMessage</span><span class="o">)</span> <span class="o">-></span> <span class="n">ChatMessageUI</span>
<span class="o">):</span> <span class="n">ChatMessageUI</span> <span class="o">=</span>
<span class="n">ChatMessageUI</span><span class="o">(</span>
<span class="n">id</span> <span class="o">=</span> <span class="n">id</span> <span class="o">?:</span> <span class="k">throw</span> <span class="n">IllegalStateException</span><span class="o">(</span><span class="s">"$this must be persisted"</span><span class="o">),</span>
<span class="n">author</span> <span class="o">=</span> <span class="n">getUser</span><span class="o">(</span><span class="n">author</span><span class="o">).</span><span class="na">toFrontReference</span><span class="o">(),</span>
<span class="n">message</span> <span class="o">=</span> <span class="n">message</span><span class="o">,</span>
<span class="n">files</span> <span class="o">=</span> <span class="n">files</span><span class="o">?.</span><span class="na">let</span> <span class="o">{</span>
<span class="n">it</span><span class="o">.</span><span class="na">map</span><span class="o">(</span><span class="n">getFile</span><span class="o">).</span><span class="na">map</span> <span class="o">{</span> <span class="n">it</span><span class="o">.</span><span class="na">toFrontReference</span><span class="o">()</span> <span class="o">}</span>
<span class="o">}</span> <span class="o">?:</span> <span class="n">listOf</span><span class="o">(),</span>
<span class="n">forwardFrom</span> <span class="o">=</span> <span class="n">forwardFrom</span><span class="o">?.</span><span class="na">let</span><span class="o">(</span><span class="n">serializeMessage</span><span class="o">),</span>
<span class="n">replyTo</span> <span class="o">=</span> <span class="n">replyTo</span><span class="o">?.</span><span class="na">let</span><span class="o">(</span><span class="n">serializeMessage</span><span class="o">)</span>
<span class="o">)</span>
</pre></div>
</pre>
<p>Получается, что эта функция зависит только от трех внешних функций (а не от целых классов, как было вначале).</p>
<p>После такой переделки тело функции не стало менее понятным, а контракт стал жестче (в этом есть как плюсы, так и минусы).</p>
<p>В действительности можно и не делать такое сужение контракта и оставить зависимости от интерфейсов. Главное, чтобы в них точно не было ничего лишнего, так как нам понадобится делать альтернативные реализации.</p>
<p>Поскольку функция serializeMessage похожа на рекурсивную, на первом шаге рефакторинга это и можно сделать в виде явной рекурсии:</p>
<pre><div class="codehilite"><pre><span></span><span class="kd">class</span> <span class="nf">ChatRestController</span><span class="o">(</span>
<span class="kd">private</span> <span class="n">val</span> <span class="n">messageRepository</span><span class="o">:</span> <span class="n">ChatMessageRepository</span><span class="o">,</span>
<span class="kd">private</span> <span class="n">val</span> <span class="n">userRepository</span><span class="o">:</span> <span class="n">UserRemoteApi</span><span class="o">,</span>
<span class="kd">private</span> <span class="n">val</span> <span class="n">fileRepository</span><span class="o">:</span> <span class="n">FileRemoteApi</span>
<span class="o">)</span> <span class="o">:</span> <span class="n">ChatRestApi</span> <span class="o">{</span>
<span class="n">override</span> <span class="n">fun</span> <span class="nf">getLast</span><span class="o">(</span><span class="n">n</span><span class="o">:</span> <span class="n">Int</span><span class="o">)</span> <span class="o">=</span>
<span class="n">messageRepository</span><span class="o">.</span><span class="na">findLast</span><span class="o">(</span><span class="n">n</span><span class="o">)</span>
<span class="o">.</span><span class="na">map</span> <span class="o">{</span> <span class="n">it</span><span class="o">.</span><span class="na">toFrontModel</span><span class="o">()</span> <span class="o">}</span>
</pre></div>
</pre>
<p>Я сделал заглушку для метода toFrontModel, которая пока работает ровно так же, как в нашей первой наивной реализации (реализация всех трех внешних функций осталась прежней).</p>
<pre><div class="codehilite"><pre><span></span><span class="kd">private</span> <span class="n">fun</span> <span class="n">ChatMessage</span><span class="o">.</span><span class="na">toFrontModel</span><span class="o">():</span> <span class="n">ChatMessageUI</span> <span class="o">=</span>
<span class="n">toFrontModel</span><span class="o">(</span>
<span class="n">getUser</span> <span class="o">=</span> <span class="n">userRepository</span><span class="o">::</span><span class="n">getUserById</span><span class="o">,</span>
<span class="n">getFile</span> <span class="o">=</span> <span class="n">fileRepository</span><span class="o">::</span><span class="n">getHeadById</span><span class="o">,</span>
<span class="n">serializeMessage</span> <span class="o">=</span> <span class="o">{</span><span class="n">it</span><span class="o">.</span><span class="na">toFrontModel</span><span class="o">()}</span>
<span class="o">)</span>
</pre></div>
</pre>
<p>Но нам нужно сделать так, чтобы функции getUser, getFile и serializeMessage работали эффективно, то есть слали запросы соответствующим сервисам пакетами нужных размеров (для каждого сервиса теоретически этот размер может быть разным) или вообще по одному запросу в каждый сервис, если разрешены безлимитные запросы.</p>
<p>Проще всего добиться такой группировки, если до начала обработки у нас будут на руках все нужные запросы. Для этого нужно перед вызовом toFrontModel собрать все необходимые ссылки, сделать пакетную обработку и потом уже использовать результат.</p>
<p>Еще можно попробовать схемы с накоплением запросов и постепенным их исполнением. Однако такие схемы потребуют асинхронного выполнения, а мы пока остановимся на синхронных.</p>
<p>Итак, чтобы начать использовать пакетную обработку, так или иначе придется узнать заранее как можно больше запросов (желательно все), которые нам придется сделать. Если речь про REST-контроллер, было бы прекрасно объединить запросы к каждому сервису по всей сессии.</p>
<h2>Группировка всех вызовов</h2>
<p>В некоторых ситуациях все данные, которые необходимы в рамках сессии, могут быть получены сразу и не вызовут проблем с ресурсами ни у инициатора запроса, ни у исполнителя. В таком случае мы можем не ограничивать размер пакета для вызова сервиса и получать сразу вообще все данные.</p>
<p>Еще одно допущение, которое сильно облегчает жизнь, — считать, что у инициатора хватит ресурсов на обработку всех данных. Запросы к внешним сервисам можно слать и ограниченными пакетами, если они этого требуют.</p>
<p>Упрощение логики в этом случае касается того, как будут сопоставляться места, где данные нужны, с результатами вызовов. Если считать, что ресурсы инициатора сильно ограничены, и при этом пытаться минимизировать количество внешних вызовов, получится довольно сложная задача на оптимальное разрезание графа. Скорее всего, придется просто пожертвовать производительностью ради уменьшения потребления ресурсов.</p>
<p>Будем считать, что конкретно в нашем демо-проекте инициатор не особо ограничен в ресурсах, может получать все необходимые данные и хранить их до окончания сессии. Если будут проблемы с ресурсами, мы просто сделаем пагинацию поменьше.</p>
<p>Поскольку в моей практике именно такой подход наиболее востребован, дальнейшие примеры будут касаться этого варианта.</p>
<p>Можно выделить такие способы получения больших наборов запросов:</p>
<ul>
<li>реверс-инжиниринг;</li>
<li>бизнес-эвристики;</li>
<li>агрегаты в стиле DDD;</li>
<li>проксирование и двойной вызов.</li>
</ul>
<p>Пройдемся по всем вариантам на примере нашего проекта.</p>
<h2>Реверс-инжиниринг</h2>
<h3>Сбор всех запросов</h3>
<p>Поскольку у нас есть код реализации всех функций, участвующих в сборе информации и ее преобразовании для фронтенда, можно сделать реверс-инжиниринг и из этого кода понять, какие будут запросы:</p>
<pre><div class="codehilite"><pre><span></span><span class="kd">class</span> <span class="nf">ChatRestController</span><span class="o">(</span>
<span class="kd">private</span> <span class="n">val</span> <span class="n">messageRepository</span><span class="o">:</span> <span class="n">ChatMessageRepository</span><span class="o">,</span>
<span class="kd">private</span> <span class="n">val</span> <span class="n">userRepository</span><span class="o">:</span> <span class="n">UserRemoteApi</span><span class="o">,</span>
<span class="kd">private</span> <span class="n">val</span> <span class="n">fileRepository</span><span class="o">:</span> <span class="n">FileRemoteApi</span>
<span class="o">)</span> <span class="o">:</span> <span class="n">ChatRestApi</span> <span class="o">{</span>
<span class="n">override</span> <span class="n">fun</span> <span class="nf">getLast</span><span class="o">(</span><span class="n">n</span><span class="o">:</span> <span class="n">Int</span><span class="o">)</span> <span class="o">=</span>
<span class="n">messageRepository</span><span class="o">.</span><span class="na">findLast</span><span class="o">(</span><span class="n">n</span><span class="o">)</span>
<span class="o">.</span><span class="na">let</span> <span class="o">{</span> <span class="n">messages</span> <span class="o">-></span>
<span class="c1">// получаем полный список сообщений, включая forward и reply</span>
<span class="n">val</span> <span class="n">allMessages</span> <span class="o">=</span> <span class="n">messages</span><span class="o">.</span><span class="na">asSequence</span><span class="o">().</span><span class="na">flatMap</span> <span class="o">{</span>
<span class="n">sequenceOf</span><span class="o">(</span><span class="n">it</span><span class="o">,</span> <span class="n">it</span><span class="o">.</span><span class="na">forwardFrom</span><span class="o">,</span> <span class="n">it</span><span class="o">.</span><span class="na">replyTo</span><span class="o">).</span><span class="na">filterNotNull</span><span class="o">()</span>
<span class="o">}.</span><span class="na">toSet</span><span class="o">()</span>
<span class="n">val</span> <span class="n">allUserReq</span> <span class="o">=</span> <span class="n">allMessages</span><span class="o">.</span><span class="na">map</span> <span class="o">{</span> <span class="n">it</span><span class="o">.</span><span class="na">author</span> <span class="o">}</span>
<span class="n">val</span> <span class="n">allFileReq</span> <span class="o">=</span> <span class="n">allMessages</span><span class="o">.</span><span class="na">flatMap</span> <span class="o">{</span> <span class="n">it</span><span class="o">.</span><span class="na">files</span> <span class="o">?:</span> <span class="n">listOf</span><span class="o">()</span> <span class="o">}.</span><span class="na">toSet</span><span class="o">()</span>
</pre></div>
</pre>
<p>Все запросы собраны, теперь нужно сделать собственно пакетную обработку.</p>
<p>Для allUserReq и allFileReq делаем внешние запросы и группируем их по id. Если ограничений на размер пакета нет, то это будет выглядеть примерно так:</p>
<pre><div class="codehilite"><pre><span></span><span class="n">userRepository</span><span class="o">.</span><span class="na">getUsersByIds</span><span class="o">(</span><span class="n">allMessages</span><span class="o">.</span><span class="na">map</span> <span class="o">{</span> <span class="n">it</span><span class="o">.</span><span class="na">author</span> <span class="o">}.</span><span class="na">toSet</span><span class="o">())</span>
<span class="o">.</span><span class="na">associateBy</span> <span class="o">{</span> <span class="n">it</span><span class="o">.</span><span class="na">id</span> <span class="o">}::</span><span class="n">get</span>
<span class="n">fileRepository</span><span class="o">.</span><span class="na">getHeadsByIds</span><span class="o">(</span><span class="n">allMessages</span><span class="o">.</span><span class="na">flatMap</span> <span class="o">{</span> <span class="n">it</span><span class="o">.</span><span class="na">files</span> <span class="o">?:</span> <span class="n">listOf</span><span class="o">()</span> <span class="o">}.</span><span class="na">toSet</span><span class="o">())</span>
<span class="o">.</span><span class="na">associateBy</span> <span class="o">{</span> <span class="n">it</span><span class="o">.</span><span class="na">id</span> <span class="o">}::</span><span class="n">get</span>
</pre></div>
</pre>
<p>Если ограничение есть, то код примет такой вид:</p>
<pre><div class="codehilite"><pre><span></span><span class="n">val</span> <span class="n">userApiChunkLimit</span> <span class="o">=</span> <span class="mi">100</span>
<span class="n">allMessages</span><span class="o">.</span><span class="na">map</span> <span class="o">{</span> <span class="n">it</span><span class="o">.</span><span class="na">author</span> <span class="o">}.</span><span class="na">asSequence</span><span class="o">().</span><span class="na">distinct</span><span class="o">()</span>
<span class="o">.</span><span class="na">chunked</span><span class="o">(</span><span class="n">userApiChunkLimit</span><span class="o">,</span> <span class="n">userRepository</span><span class="o">::</span><span class="n">getUsersByIds</span><span class="o">)</span>
<span class="o">.</span><span class="na">flatten</span><span class="o">()</span>
<span class="o">.</span><span class="na">associateBy</span> <span class="o">{</span> <span class="n">it</span><span class="o">.</span><span class="na">id</span> <span class="o">}::</span><span class="n">get</span>
</pre></div>
</pre>
<p>К сожалению, в отличие от Stream, в Sequence нельзя легко перейти на параллельный запрос пакетов.</p>
<p>Если вы считаете параллельный запрос допустимым и нужным, можно сделать, например, так:</p>
<pre><div class="codehilite"><pre><span></span><span class="n">allMessages</span><span class="o">.</span><span class="na">map</span> <span class="o">{</span> <span class="n">it</span><span class="o">.</span><span class="na">author</span> <span class="o">}.</span><span class="na">parallelStream</span><span class="o">().</span><span class="na">distinct</span><span class="o">()</span>
<span class="o">.</span><span class="na">chunked</span><span class="o">(</span><span class="n">userApiChunkLimit</span><span class="o">,</span> <span class="n">userRepository</span><span class="o">::</span><span class="n">getUsersByIds</span><span class="o">)</span>
<span class="o">.</span><span class="na">flatten</span><span class="o">()</span>
<span class="o">.</span><span class="na">associateBy</span> <span class="o">{</span> <span class="n">it</span><span class="o">.</span><span class="na">id</span> <span class="o">}::</span><span class="n">get</span>
</pre></div>
</pre>
<p>Видно, что особо ничего не изменилось. В этом нам помогло использование некоторого количества Kotlin-магии:</p>
<pre><div class="codehilite"><pre><span></span><span class="n">fun</span> <span class="o"><</span><span class="n">T</span><span class="o">,</span> <span class="n">R</span><span class="o">></span> <span class="n">Stream</span><span class="o"><</span><span class="n">out</span> <span class="n">T</span><span class="o">>.</span><span class="na">chunked</span><span class="o">(</span><span class="n">size</span><span class="o">:</span> <span class="n">Int</span><span class="o">,</span> <span class="n">transform</span><span class="o">:</span> <span class="o">(</span><span class="n">List</span><span class="o"><</span><span class="n">T</span><span class="o">>)</span> <span class="o">-></span> <span class="n">R</span><span class="o">):</span> <span class="n">Stream</span><span class="o"><</span><span class="n">out</span> <span class="n">R</span><span class="o">></span> <span class="o">=</span>
<span class="n">batches</span><span class="o">(</span><span class="k">this</span><span class="o">,</span> <span class="n">size</span><span class="o">).</span><span class="na">map</span><span class="o">(</span><span class="n">transform</span><span class="o">)</span>
<span class="n">fun</span> <span class="o"><</span><span class="n">T</span><span class="o">></span> <span class="n">Stream</span><span class="o"><</span><span class="n">out</span> <span class="n">Collection</span><span class="o"><</span><span class="n">T</span><span class="o">>>.</span><span class="na">flatten</span><span class="o">():</span> <span class="n">Stream</span><span class="o"><</span><span class="n">T</span><span class="o">></span> <span class="o">=</span>
<span class="n">flatMap</span> <span class="o">{</span> <span class="n">it</span><span class="o">.</span><span class="na">stream</span><span class="o">()</span> <span class="o">}</span>
<span class="n">fun</span> <span class="o"><</span><span class="n">T</span><span class="o">,</span> <span class="n">K</span><span class="o">></span> <span class="n">Stream</span><span class="o"><</span><span class="n">T</span><span class="o">>.</span><span class="na">associateBy</span><span class="o">(</span><span class="n">keySelector</span><span class="o">:</span> <span class="o">(</span><span class="n">T</span><span class="o">)</span> <span class="o">-></span> <span class="n">K</span><span class="o">):</span> <span class="n">Map</span><span class="o"><</span><span class="n">K</span><span class="o">,</span> <span class="n">T</span><span class="o">></span> <span class="o">=</span>
<span class="n">collect</span><span class="o">(</span><span class="n">Collectors</span><span class="o">.</span><span class="na">toMap</span><span class="o">(</span><span class="n">keySelector</span><span class="o">,</span> <span class="o">{</span> <span class="n">it</span> <span class="o">}))</span>
</pre></div>
</pre>
<p>Теперь осталось собрать все вместе:</p>
<pre><div class="codehilite"><pre><span></span><span class="n">override</span> <span class="n">fun</span> <span class="nf">getLast</span><span class="o">(</span><span class="n">n</span><span class="o">:</span> <span class="n">Int</span><span class="o">)</span> <span class="o">=</span>
<span class="n">messageRepository</span><span class="o">.</span><span class="na">findLast</span><span class="o">(</span><span class="n">n</span><span class="o">)</span>
<span class="o">.</span><span class="na">let</span> <span class="o">{</span> <span class="n">messages</span> <span class="o">-></span>
<span class="c1">// получаем полный список сообщений, включая forward и reply</span>
<span class="n">val</span> <span class="n">allMessages</span> <span class="o">=</span> <span class="n">messages</span><span class="o">.</span><span class="na">asSequence</span><span class="o">().</span><span class="na">flatMap</span> <span class="o">{</span> <span class="n">message</span> <span class="o">-></span>
<span class="n">sequenceOf</span><span class="o">(</span><span class="n">message</span><span class="o">,</span> <span class="n">message</span><span class="o">.</span><span class="na">forwardFrom</span><span class="o">,</span> <span class="n">message</span><span class="o">.</span><span class="na">replyTo</span><span class="o">)</span>
<span class="o">.</span><span class="na">filterNotNull</span><span class="o">()</span>
<span class="o">}.</span><span class="na">toSet</span><span class="o">()</span>
<span class="n">messages</span><span class="o">.</span><span class="na">map</span><span class="o">(</span><span class="n">ValueHolder</span><span class="o"><(</span><span class="n">ChatMessage</span><span class="o">)</span> <span class="o">-></span> <span class="n">ChatMessageUI</span><span class="o">>().</span><span class="na">apply</span> <span class="o">{</span>
<span class="n">value</span> <span class="o">=</span> <span class="n">memoize</span> <span class="o">{</span> <span class="n">message</span><span class="o">:</span> <span class="n">ChatMessage</span> <span class="o">-></span>
<span class="n">message</span><span class="o">.</span><span class="na">toFrontModel</span><span class="o">(</span>
<span class="c1">// для этого сервиса есть ограничение размера пакета, но возможны параллельные запросы</span>
<span class="n">getUser</span> <span class="o">=</span> <span class="n">allMessages</span><span class="o">.</span><span class="na">map</span> <span class="o">{</span> <span class="n">it</span><span class="o">.</span><span class="na">author</span> <span class="o">}.</span><span class="na">parallelStream</span><span class="o">().</span><span class="na">distinct</span><span class="o">()</span>
<span class="o">.</span><span class="na">chunked</span><span class="o">(</span><span class="n">userApiChunkLimit</span><span class="o">,</span> <span class="n">userRepository</span><span class="o">::</span><span class="n">getUsersByIds</span><span class="o">)</span>
<span class="o">.</span><span class="na">flatten</span><span class="o">()</span>
<span class="o">.</span><span class="na">associateBy</span> <span class="o">{</span> <span class="n">it</span><span class="o">.</span><span class="na">id</span> <span class="o">}::</span><span class="n">get</span><span class="o">.</span><span class="na">orThrow</span> <span class="o">{</span> <span class="n">IllegalArgumentException</span><span class="o">(</span><span class="s">"User $it"</span><span class="o">)</span> <span class="o">},</span>
<span class="c1">// для этого сервиса нет ограничений на размер пакета</span>
<span class="n">getFile</span> <span class="o">=</span> <span class="n">fileRepository</span><span class="o">.</span><span class="na">getHeadsByIds</span><span class="o">(</span><span class="n">allMessages</span><span class="o">.</span><span class="na">flatMap</span> <span class="o">{</span> <span class="n">it</span><span class="o">.</span><span class="na">files</span> <span class="o">?:</span> <span class="n">listOf</span><span class="o">()</span> <span class="o">}.</span><span class="na">toSet</span><span class="o">())</span>
<span class="o">.</span><span class="na">associateBy</span> <span class="o">{</span> <span class="n">it</span><span class="o">.</span><span class="na">id</span> <span class="o">}::</span><span class="n">get</span><span class="o">.</span><span class="na">orThrow</span> <span class="o">{</span> <span class="n">IllegalArgumentException</span><span class="o">(</span><span class="s">"File $it"</span><span class="o">)</span> <span class="o">},</span>
<span class="c1">// рекурсивный вызов этой же функции с мемоизацией</span>
<span class="n">serializeMessage</span> <span class="o">=</span> <span class="n">value</span>
<span class="o">)</span>
<span class="o">}</span>
<span class="o">}.</span><span class="na">value</span><span class="o">)</span>
<span class="o">}</span>
</pre></div>
</pre>
<h3>Пояснения и упрощения</h3>
<p>Первое, что наверняка бросится в глаза, — это функция memoize. Дело в том, что функция serializeMessage почти наверняка будет вызываться для одних и тех же сообщений несколько раз (из-за reply и forward). Непонятно, зачем нам делать toFrontModel отдельно для каждого такого сообщения (в некоторых случаях это может быть нужно, но не в нашем). Поэтому можно сделать мемоизацию для функции serializeMessage. Реализуется это, например, так:</p>
<pre><div class="codehilite"><pre><span></span><span class="n">fun</span> <span class="o"><</span><span class="n">A</span><span class="o">,</span> <span class="n">R</span><span class="o">></span> <span class="nf">memoize</span><span class="o">(</span><span class="n">func</span><span class="o">:</span> <span class="o">(</span><span class="n">A</span><span class="o">)</span> <span class="o">-></span> <span class="n">R</span><span class="o">)</span> <span class="o">=</span> <span class="n">func</span> <span class="n">as</span><span class="o">?</span> <span class="n">Memoize2</span> <span class="o">?:</span> <span class="n">Memoize2</span><span class="o">(</span><span class="n">func</span><span class="o">)</span>
<span class="kd">class</span> <span class="nc">Memoize2</span><span class="o"><</span><span class="n">A</span><span class="o">,</span> <span class="n">R</span><span class="o">>(</span><span class="n">val</span> <span class="n">func</span><span class="o">:</span> <span class="o">(</span><span class="n">A</span><span class="o">)</span> <span class="o">-></span> <span class="n">R</span><span class="o">)</span> <span class="o">:</span> <span class="o">(</span><span class="n">A</span><span class="o">)</span> <span class="o">-></span> <span class="n">R</span><span class="o">,</span> <span class="n">java</span><span class="o">.</span><span class="na">util</span><span class="o">.</span><span class="na">function</span><span class="o">.</span><span class="na">Function</span><span class="o"><</span><span class="n">A</span><span class="o">,</span> <span class="n">R</span><span class="o">></span> <span class="o">{</span>
<span class="kd">private</span> <span class="n">val</span> <span class="n">cache</span> <span class="o">=</span> <span class="n">hashMapOf</span><span class="o"><</span><span class="n">A</span><span class="o">,</span> <span class="n">R</span><span class="o">>()</span>
<span class="n">override</span> <span class="n">fun</span> <span class="nf">invoke</span><span class="o">(</span><span class="n">p1</span><span class="o">:</span> <span class="n">A</span><span class="o">)</span> <span class="o">=</span> <span class="n">cache</span><span class="o">.</span><span class="na">getOrPut</span><span class="o">(</span><span class="n">p1</span><span class="o">,</span> <span class="o">{</span> <span class="n">func</span><span class="o">(</span><span class="n">p1</span><span class="o">)</span> <span class="o">})</span>
<span class="n">override</span> <span class="n">fun</span> <span class="nf">apply</span><span class="o">(</span><span class="n">t</span><span class="o">:</span> <span class="n">A</span><span class="o">):</span> <span class="n">R</span> <span class="o">=</span> <span class="n">invoke</span><span class="o">(</span><span class="n">t</span><span class="o">)</span>
<span class="o">}</span>
</pre></div>
</pre>
<p>Далее нам нужно сконструировать мемоизированную функцию serializeMessage, но при этом у нее внутри будет использоваться она же. Здесь важно использовать внутри именно тот же экземпляр функции, иначе вся мемоизация пойдет псу под хвост. Для разрешения этой коллизии используем класс ValueHolder, который просто хранит ссылку на значение (можно взять вместо него что-то стандартное, например AtomicReference). Чтобы сократить запись для рекурсии, можно сделать так:</p>
<pre><div class="codehilite"><pre><span></span><span class="n">inline</span> <span class="n">fun</span> <span class="o"><</span><span class="n">A</span><span class="o">,</span> <span class="n">R</span><span class="o">></span> <span class="nf">recursiveMemoize</span><span class="o">(</span><span class="n">crossinline</span> <span class="n">func</span><span class="o">:</span> <span class="o">(</span><span class="n">A</span><span class="o">,</span> <span class="o">(</span><span class="n">A</span><span class="o">)</span> <span class="o">-></span> <span class="n">R</span><span class="o">)</span> <span class="o">-></span> <span class="n">R</span><span class="o">):</span> <span class="o">(</span><span class="n">A</span><span class="o">)</span> <span class="o">-></span> <span class="n">R</span> <span class="o">=</span>
<span class="n">ValueHolder</span><span class="o"><(</span><span class="n">A</span><span class="o">)</span> <span class="o">-></span> <span class="n">R</span><span class="o">>().</span><span class="na">apply</span> <span class="o">{</span>
<span class="n">value</span> <span class="o">=</span> <span class="n">memoize</span> <span class="o">{</span> <span class="n">a</span> <span class="o">-></span> <span class="n">func</span><span class="o">(</span><span class="n">a</span><span class="o">,</span> <span class="n">value</span><span class="o">)</span> <span class="o">}</span>
<span class="o">}.</span><span class="na">value</span>
</pre></div>
</pre>
<p>Если вы смогли понять этот стрелочный силлогизм с первого раза — поздравляю, вы функциональный программист :-)</p>
<p>Теперь код будет выглядеть следующим образом:</p>
<pre><div class="codehilite"><pre><span></span><span class="n">override</span> <span class="n">fun</span> <span class="nf">getLast</span><span class="o">(</span><span class="n">n</span><span class="o">:</span> <span class="n">Int</span><span class="o">)</span> <span class="o">=</span>
<span class="n">messageRepository</span><span class="o">.</span><span class="na">findLast</span><span class="o">(</span><span class="n">n</span><span class="o">)</span>
<span class="o">.</span><span class="na">let</span> <span class="o">{</span> <span class="n">messages</span> <span class="o">-></span>
<span class="c1">// получаем полный список сообщений, включая forward и reply</span>
<span class="n">val</span> <span class="n">allMessages</span> <span class="o">=</span> <span class="n">messages</span><span class="o">.</span><span class="na">asSequence</span><span class="o">().</span><span class="na">flatMap</span> <span class="o">{</span> <span class="n">message</span> <span class="o">-></span>
<span class="n">sequenceOf</span><span class="o">(</span><span class="n">message</span><span class="o">,</span> <span class="n">message</span><span class="o">.</span><span class="na">forwardFrom</span><span class="o">,</span> <span class="n">message</span><span class="o">.</span><span class="na">replyTo</span><span class="o">)</span>
<span class="o">.</span><span class="na">filterNotNull</span><span class="o">()</span>
<span class="o">}.</span><span class="na">toSet</span><span class="o">()</span>
<span class="c1">// для этого сервиса есть ограничение размера пакета, но возможны параллельные запросы</span>
<span class="n">val</span> <span class="n">getUser</span> <span class="o">=</span> <span class="n">allMessages</span><span class="o">.</span><span class="na">map</span> <span class="o">{</span> <span class="n">it</span><span class="o">.</span><span class="na">author</span> <span class="o">}.</span><span class="na">parallelStream</span><span class="o">().</span><span class="na">distinct</span><span class="o">()</span>
<span class="o">.</span><span class="na">chunked</span><span class="o">(</span><span class="n">userApiChunkLimit</span><span class="o">,</span> <span class="n">userRepository</span><span class="o">::</span><span class="n">getUsersByIds</span><span class="o">)</span>
<span class="o">.</span><span class="na">flatten</span><span class="o">()</span>
<span class="o">.</span><span class="na">associateBy</span> <span class="o">{</span> <span class="n">it</span><span class="o">.</span><span class="na">id</span> <span class="o">}::</span><span class="n">get</span><span class="o">.</span><span class="na">orThrow</span> <span class="o">{</span> <span class="n">IllegalArgumentException</span><span class="o">(</span><span class="s">"User $it"</span><span class="o">)</span> <span class="o">}</span>
<span class="c1">// для этого сервиса нет ограничений на размер пакета</span>
<span class="n">val</span> <span class="n">getFile</span> <span class="o">=</span> <span class="n">fileRepository</span><span class="o">.</span><span class="na">getHeadsByIds</span><span class="o">(</span><span class="n">allMessages</span><span class="o">.</span><span class="na">flatMap</span> <span class="o">{</span> <span class="n">it</span><span class="o">.</span><span class="na">files</span> <span class="o">?:</span> <span class="n">listOf</span><span class="o">()</span> <span class="o">}.</span><span class="na">toSet</span><span class="o">())</span>
<span class="o">.</span><span class="na">associateBy</span> <span class="o">{</span> <span class="n">it</span><span class="o">.</span><span class="na">id</span> <span class="o">}::</span><span class="n">get</span><span class="o">.</span><span class="na">orThrow</span> <span class="o">{</span> <span class="n">IllegalArgumentException</span><span class="o">(</span><span class="s">"File $it"</span><span class="o">)</span> <span class="o">}</span>
<span class="n">messages</span><span class="o">.</span><span class="na">map</span><span class="o">(</span><span class="n">recursiveMemoize</span> <span class="o">{</span> <span class="n">message</span><span class="o">,</span> <span class="n">memoized</span><span class="o">:</span> <span class="o">(</span><span class="n">ChatMessage</span><span class="o">)</span> <span class="o">-></span> <span class="n">ChatMessageUI</span> <span class="o">-></span>
<span class="n">message</span><span class="o">.</span><span class="na">toFrontModel</span><span class="o">(</span>
<span class="n">getUser</span> <span class="o">=</span> <span class="n">getUser</span><span class="o">,</span>
<span class="n">getFile</span> <span class="o">=</span> <span class="n">getFile</span><span class="o">,</span>
<span class="c1">// рекурсивный вызов этой же функции с мемоизацией</span>
<span class="n">serializeMessage</span> <span class="o">=</span> <span class="n">memoized</span>
<span class="o">)</span>
<span class="o">})</span>
</pre></div>
</pre>
<p>Можно заметить еще orThrow, который определяется так:</p>
<pre><div class="codehilite"><pre><span></span><span class="cm">/** Бросает указанный [exception], если функция возвращает null */</span>
<span class="n">fun</span> <span class="o"><</span><span class="n">P</span><span class="o">,</span> <span class="n">R</span><span class="o">></span> <span class="o">((</span><span class="n">P</span><span class="o">)</span> <span class="o">-></span> <span class="n">R</span><span class="o">?).</span><span class="na">orThrow</span><span class="o">(</span><span class="n">exception</span><span class="o">:</span> <span class="o">(</span><span class="n">P</span><span class="o">)</span> <span class="o">-></span> <span class="n">Exception</span><span class="o">):</span> <span class="o">(</span><span class="n">P</span><span class="o">)</span> <span class="o">-></span> <span class="n">R</span> <span class="o">=</span>
<span class="o">{</span> <span class="n">p</span> <span class="o">-></span> <span class="n">invoke</span><span class="o">(</span><span class="n">p</span><span class="o">).</span><span class="na">let</span> <span class="o">{</span> <span class="n">it</span> <span class="o">?:</span> <span class="k">throw</span> <span class="n">exception</span><span class="o">(</span><span class="n">p</span><span class="o">)</span> <span class="o">}</span> <span class="o">}</span>
</pre></div>
</pre>
<p>Если во внешних сервисах отсутствуют данные по нашим id и это считается легальной ситуацией, нужно обрабатывать ее как-то по-другому.</p>
<p>После этого исправления ожидается, что время выполнения getLast будет в районе 300 мс. Причем это время вырастет несильно, даже если запросы перестанут укладываться в ограничения на размер пакета (так как пакеты запрашиваются параллельно). Напомню, что наша цель-минимум — 500 мс, а нормальной работой можно будет считать 250 мс.</p>
<h3>Параллельность</h3>
<p>Но нужно двигаться дальше. Обращения к userRepository и fileRepository являются полностью независимыми, и их можно легко распараллелить, в теории приблизившись к 200 мс.</p>
<pre><div class="codehilite"><pre><span></span><span class="n">Например</span><span class="o">,</span> <span class="n">через</span> <span class="n">нашу</span> <span class="n">функцию</span> <span class="n">join</span><span class="o">:</span>
<span class="n">override</span> <span class="n">fun</span> <span class="nf">getLast</span><span class="o">(</span><span class="n">n</span><span class="o">:</span> <span class="n">Int</span><span class="o">)</span> <span class="o">=</span>
<span class="n">messageRepository</span><span class="o">.</span><span class="na">findLast</span><span class="o">(</span><span class="n">n</span><span class="o">)</span>
<span class="o">.</span><span class="na">let</span> <span class="o">{</span> <span class="n">messages</span> <span class="o">-></span>
<span class="c1">// получаем полный список сообщений, включая forward и reply</span>
<span class="n">val</span> <span class="n">allMessages</span> <span class="o">=</span> <span class="n">messages</span><span class="o">.</span><span class="na">asSequence</span><span class="o">().</span><span class="na">flatMap</span> <span class="o">{</span> <span class="n">message</span> <span class="o">-></span>
<span class="n">sequenceOf</span><span class="o">(</span><span class="n">message</span><span class="o">,</span> <span class="n">message</span><span class="o">.</span><span class="na">forwardFrom</span><span class="o">,</span> <span class="n">message</span><span class="o">.</span><span class="na">replyTo</span><span class="o">)</span>
<span class="o">.</span><span class="na">filterNotNull</span><span class="o">()</span>
<span class="o">}.</span><span class="na">toSet</span><span class="o">()</span>
<span class="n">join</span><span class="o">({</span>
<span class="c1">// для этого сервиса есть ограничение размера пакета, но возможны параллельные запросы</span>
<span class="n">allMessages</span><span class="o">.</span><span class="na">map</span> <span class="o">{</span> <span class="n">it</span><span class="o">.</span><span class="na">author</span> <span class="o">}.</span><span class="na">parallelStream</span><span class="o">().</span><span class="na">distinct</span><span class="o">()</span>
<span class="o">.</span><span class="na">chunked</span><span class="o">(</span><span class="n">userApiChunkLimit</span><span class="o">,</span> <span class="n">userRepository</span><span class="o">::</span><span class="n">getUsersByIds</span><span class="o">)</span>
<span class="o">.</span><span class="na">flatten</span><span class="o">()</span>
<span class="o">.</span><span class="na">associateBy</span> <span class="o">{</span> <span class="n">it</span><span class="o">.</span><span class="na">id</span> <span class="o">}</span>
<span class="o">},</span> <span class="o">{</span>
<span class="c1">// для этого сервиса нет ограничений на размер пакета</span>
<span class="n">fileRepository</span><span class="o">.</span><span class="na">getHeadsByIds</span><span class="o">(</span><span class="n">allMessages</span><span class="o">.</span><span class="na">flatMap</span> <span class="o">{</span> <span class="n">it</span><span class="o">.</span><span class="na">files</span> <span class="o">?:</span> <span class="n">listOf</span><span class="o">()</span> <span class="o">}.</span><span class="na">toSet</span><span class="o">())</span>
<span class="o">.</span><span class="na">associateBy</span> <span class="o">{</span> <span class="n">it</span><span class="o">.</span><span class="na">id</span> <span class="o">}</span>
<span class="o">}).</span><span class="na">let</span> <span class="o">{</span> <span class="o">(</span><span class="n">users</span><span class="o">,</span> <span class="n">files</span><span class="o">)</span> <span class="o">-></span>
<span class="n">messages</span><span class="o">.</span><span class="na">map</span><span class="o">(</span><span class="n">recursiveMemoize</span> <span class="o">{</span> <span class="n">message</span><span class="o">,</span> <span class="n">memoized</span><span class="o">:</span> <span class="o">(</span><span class="n">ChatMessage</span><span class="o">)</span> <span class="o">-></span> <span class="n">ChatMessageUI</span> <span class="o">-></span>
<span class="n">message</span><span class="o">.</span><span class="na">toFrontModel</span><span class="o">(</span>
<span class="n">getUser</span> <span class="o">=</span> <span class="n">users</span><span class="o">::</span><span class="n">get</span><span class="o">.</span><span class="na">orThrow</span> <span class="o">{</span> <span class="n">IllegalArgumentException</span><span class="o">(</span><span class="s">"User $it"</span><span class="o">)</span> <span class="o">},</span>
<span class="n">getFile</span> <span class="o">=</span> <span class="n">files</span><span class="o">::</span><span class="n">get</span><span class="o">.</span><span class="na">orThrow</span> <span class="o">{</span> <span class="n">IllegalArgumentException</span><span class="o">(</span><span class="s">"File $it"</span><span class="o">)</span> <span class="o">},</span>
<span class="c1">// рекурсивный вызов этой же функции с мемоизацией</span>
<span class="n">serializeMessage</span> <span class="o">=</span> <span class="n">memoized</span>
<span class="o">)</span>
<span class="o">})</span>
<span class="o">}</span>
<span class="o">}</span>
</pre></div>
</pre>
<p>Как показывает практика, на исполнение затрачивается в районе 200 мс, и очень важно, что с увеличением количества сообщений время особо не растет.</p>
<h3>Проблемы</h3>
<p>В целом код стал, конечно, менее читабельным, чем наша наивная первая версия, но хорошо, что сама сериализация (реализация toFrontModel) почти не изменилась и осталась вполне читабельной. Вся логика хитрой работы с внешними сервисами живет в одном месте.</p>
<p>Минус этого подхода в том, что наша абстракция протекает.</p>
<p>Если нам понадобится внести изменения в toFrontModel, почти наверняка придется вносить изменения и в функцию getLast, что нарушает принцип подстановки Барбары Лисков (Liskov Substitution Principle).</p>
<p>Например, мы договорились расшифровывать приложенные файлы только в основных сообщениях, но не в ответах и пересылках (reply/forward), или только в ответах и пересылках первого уровня. В таком случае после внесения изменений в код toFrontModel придется сделать соответствующие исправления и в коде сбора запросов для файлов. Причем исправление будет нетривиальным:</p>
<pre><div class="codehilite"><pre><span></span><span class="n">fileRepository</span><span class="o">.</span><span class="na">getHeadsByIds</span><span class="o">(</span>
<span class="n">allMessages</span><span class="o">.</span><span class="na">flatMap</span> <span class="o">{</span> <span class="n">it</span><span class="o">.</span><span class="na">files</span> <span class="o">?:</span> <span class="n">listOf</span><span class="o">()</span> <span class="o">}.</span><span class="na">toSet</span><span class="o">()</span>
<span class="o">)</span>
</pre></div>
</pre>
<p>И здесь мы плавно подходим к еще одной проблеме, тесно связанной с предыдущей: правильность работы кода в целом зависит от грамотности проведенного реверс-инжиниринга. В некоторых сложных случаях код может сработать неправильно именно из-за некорректной работы сбора запросов. Нет никакой гарантии, что у вас получится быстро придумать юнит-тесты, которые покроют все такие хитрые ситуации.</p>
<h2>Выводы</h2>
<h3>Плюсы:</h3>
<ol>
<li>Очевидный способ предварительного получения запросов, который легко отделяется от основного кода.</li>
<li>Почти полное отсутствие накладных расходов памяти и времени, связанное с использованием только тех данных, которые все равно были бы получены.</li>
<li>Хорошее масштабирование и возможность построить сервис, который в теории будет отвечать за предсказуемое время вне зависимости от размера запроса извне.</li>
</ol>
<h3>Минусы:</h3>
<ol>
<li>Довольно сложный код самой пакетной обработки.</li>
<li>Большая и ответственная работа по анализу запросов в существующей реализации.</li>
<li>Протекающая абстракция и, как следствие, хрупкость всей схемы по отношению к изменениям в реализации.</li>
<li>Сложности в поддержке: ошибки в блоке предсказания запросов трудно отличить от ошибок в основном коде. В идеале нужно применять в два раза больше юнит-тестов, поэтому и разбирательства с ошибками в продакшене будут в два раза сложнее.</li>
<li>Соблюдение принципов SOLID при написании кода: код должен быть готов к отчуждению логики пакетной обработки. Само по себе внедрение этих принципов даст некоторые преимущества, так что данный минус самый несущественный.</li>
</ol>
<p>Важно отметить, что использовать этот метод можно и не делая реверс-инжиниринга как такового. Нам нужно получить контракт getLast, от которого зависит контракт предварительного расчета запросов (далее — prefetch). В данном случае мы сделали это, рассмотрев реализацию getLast (реверс-инжиниринг). Однако при таком подходе возникают сложности: правка этих двух кусков кода всегда должна быть синхронной, а обеспечить это никак невозможно (вспомните hashCode и equals, там ровно то же самое). Следующий подход, который я хотел бы показать, как раз призван решить эту проблему (или хотя бы смягчить).</p>
<h2>Бизнес-эвристики</h2>
<h3>Решение проблемы контракта</h3>
<p>Что если оперировать не точным контрактом и, следовательно, точным набором запросов, а примерным? Причем мы построим примерный набор так, что он будет строго включать точный и базироваться на особенностях предметной области.</p>
<p>Таким образом, вместо зависимости контракта prefetch от getLast установим зависимость их обоих от какого-то общего контракта, который будет диктоваться пользователем. Основная сложность будет в том, чтобы как-то овеществить этот общий контракт в виде кода.</p>
<h3>Поиск полезных ограничений</h3>
<p>Давайте попробуем сделать это на нашем примере.
В нашем случае есть следующие бизнес-особенности:</p>
<ul>
<li>список участников чата предопределен;</li>
<li>чаты абсолютно изолированы друг от друга;</li>
<li>вложенность цепочек reply/forward небольшая (~2–3 сообщения).</li>
</ul>
<p>Из первого ограничения следует, что не нужно бегать по сообщениям, смотреть, какие там есть пользователи, выбирать уникальных и по ним делать запрос. Можно просто сделать запрос по предопределенному списку. Если вы согласились с этим утверждением, значит, я вас поймал.</p>
<p>На самом деле все не так просто. Список может быть предопределен, но в нем могут быть тысячи пользователей. Такие вещи необходимо уточнять. В нашем случае участников чата, как правило, будет два — три, редко больше. Так что вполне допустимо получать данные по ним всем.</p>
<p>Далее, если список пользователей чата предопределен, но этой информации нет в сервисе пользователей (что очень вероятно), то толку от такой информации тоже не будет. Мы сделаем лишний запрос списка пользователей чата, а потом все равно придется делать запрос(-ы) к сервису пользователей.</p>
<p>Допустим, что информация о связи пользователей и чата хранится в сервисе пользователей. В нашем случае это так, поскольку связь определяется правами пользователя. Тогда для пользователей получится такой prefetch-код:</p>
<p>Здесь может показаться удивительным то, что мы не передаем никакого идентификатора чата. Я сделал это намеренно, чтобы не загромождать код примеров.</p>
<p>Из второго ограничения, на первый взгляд, ничего не следует. Во всяком случае, у меня так и не получилось вывести из него что-то полезное.</p>
<p>Третье ограничение мы уже использовали ранее. Оно может оказать существенное влияние на то, как мы будем хранить и получать цепочки сообщений. Не станем развивать эту тему, так как к REST-контроллеру и пакетной обработке это не имеет отношения.</p>
<p>Что же делать с файлами? Очень хочется получить список всех файлов чата одним простым запросом. По условиям API нам нужны только заголовки файлов, без тел, так что это не выглядит ресурсоемкой и опасной задачей для вызывающей стороны.</p>
<p>С другой стороны, нужно помнить, что мы получаем не все сообщения чата, а только последние N, и легко может оказаться, что они не содержат вообще никаких файлов.</p>
<p>Здесь не может быть универсального ответа: все сильно зависит от бизнес-специфики и вариантов использования. При создании продуктового решения можно попасть впросак, если заложить эвристику под один вариант использования, а потом пользователи будут работать с функционалом другим способом. Для демонстраций и пресейлов это хороший вариант, но сейчас мы пытаемся написать честный продакшен-сервис.</p>
<p>Так что, увы, делать для файлов бизнес-эвристику здесь можно будет только по итогам эксплуатации и сбора статистики (либо после экспертной оценки).</p>
<p>Поскольку хочется все-таки как-то применить наш метод, допустим, что статистика показала следующее:</p>
<ol>
<li>Типичная цепочка начинается с сообщения, включающего один или несколько файлов, за которым следуют ответные сообщения (reply) без файлов.</li>
<li>Почти все сообщения входят в типичные цепочки.</li>
<li>Ожидаемое количество уникальных файлов в рамках одного чата ~20.</li>
</ol>
<p>Отсюда следует, что для отображения почти всех сообщений понадобится получить заголовки каких-то файлов (потому что так устроен ChatMessageUI) и что общее количество файлов невелико. В таком случае разумным выглядит получение всех файлов чата одним запросом. Для этого в наш API для файлов придется добавить следующее:</p>
<pre><div class="codehilite"><pre><span></span><span class="n">fun</span> <span class="nf">getHeadsByChat</span><span class="o">():</span> <span class="n">List</span><span class="o"><</span><span class="n">FileHeadRemote</span><span class="o">></span>
</pre></div>
</pre>
<p>Метод getHeadsByChat не выглядит надуманным и сделанным чисто из-за нашего желания оптимизировать производительность (хотя это тоже вполне себе обоснование). Довольно часто в чатах с файлами пользователи хотят увидеть все использованные файлы, причем в порядке их добавления (поэтому используем List).</p>
<p>Реализация такой явной связи потребует хранения дополнительной информации в файловом сервисе или в нашем приложении. Все зависит от того, в чьей зоне ответственности, как мы считаем, должна храниться эта избыточная информация о связи файла с чатом. Избыточная она потому, что с файлом уже связано сообщение, а оно в свою очередь связано с чатом. Можно не использовать денормализацию, а извлекать эту информацию на лету из сообщений, то есть внутри SQL получать сразу все файлы по всему чату (это в нашем приложении) и запрашивать их все сразу у файлового сервиса. Такой вариант будет работать похуже, если сообщений в чате окажется много, но зато нам не понадобится денормализация. Я бы оба варианта скрыл за getHeadsByChat.</p>
<p>Код получился такой:</p>
<pre><div class="codehilite"><pre><span></span><span class="n">override</span> <span class="n">fun</span> <span class="nf">getLast</span><span class="o">(</span><span class="n">n</span><span class="o">:</span> <span class="n">Int</span><span class="o">)</span> <span class="o">=</span>
<span class="n">messageRepository</span><span class="o">.</span><span class="na">findLast</span><span class="o">(</span><span class="n">n</span><span class="o">)</span>
<span class="o">.</span><span class="na">let</span> <span class="o">{</span> <span class="n">messages</span> <span class="o">-></span>
<span class="n">join</span><span class="o">(</span>
<span class="o">{</span> <span class="n">userRepository</span><span class="o">.</span><span class="na">getUsersByChat</span><span class="o">().</span><span class="na">associateBy</span> <span class="o">{</span> <span class="n">it</span><span class="o">.</span><span class="na">id</span> <span class="o">}</span> <span class="o">},</span>
<span class="o">{</span> <span class="n">fileRepository</span><span class="o">.</span><span class="na">getHeadsByChat</span><span class="o">().</span><span class="na">associateBy</span> <span class="o">{</span> <span class="n">it</span><span class="o">.</span><span class="na">id</span> <span class="o">}</span> <span class="o">}</span>
<span class="o">)</span>
<span class="o">.</span><span class="na">let</span> <span class="o">{</span> <span class="c1">// здесь ничего не изменилось</span>
<span class="o">}</span>
<span class="o">}</span>
</pre></div>
</pre>
<p>Видно, что по сравнению с предыдущим вариантом изменилось очень мало и изменения коснулись только части с prefetch, что замечательно.</p>
<p>Код prefetch стал намного короче и понятнее.</p>
<p>Время исполнения не изменилось, что логично, так как количество запросов осталось прежним. Теоретически возможны случаи, когда масштабирование будет лучше, чем у честного реверс-инжиниринга (только за счет убирания звена сложного расчета). Однако в равной степени вероятны противоположные ситуации: эвристики гребут слишком много лишнего. Как показывает практика, если удается придумать адекватные эвристики, то особых перемен во времени исполнения быть не должно.</p>
<p>Однако это еще не все. Мы не учли, что теперь получение детальных данных по пользователям и файлам не связано с получением сообщений и запросы можно запустить параллельно:</p>
<p>Такой вариант дает стабильные 100 мс на запрос.</p>
<h3>Ошибки эвристик</h3>
<p>Что если при использовании эвристик набор запросов окажется не больше, а чуть меньше, чем должен быть? Для большинства вариантов такие эвристики подойдут, но будут исключения, ради которых придется делать отдельный запрос. В моей практике такого рода решения оказывались неудачными, так как каждое исключение сильно сказывалось на производительности, и в конце концов какой-то пользователь делал запрос, полностью состоящий из исключений. Я бы сказал, что в таких ситуациях лучше использовать реверс-инжиниринг, даже если алгоритм сбора запросов получается жуткий и нечитаемый, но, конечно, все зависит от критичности сервиса.</p>
<h2>Выводы</h2>
<h3>Плюсы:</h3>
<ol>
<li>Логика бизнес-эвристик легко читается и обычно тривиальна. Это хорошо для того, чтобы понять границы применимости, верифицировать и изменять контракт prefetch.</li>
<li>Масштабируемость такая же хорошая, как у реверс-инжиниринга.</li>
<li>Уменьшается связность кода по данным, что может привести к лучшей параллелизации кода.</li>
<li>Логика prefetch, как и основная логика REST-контроллера, базируется на требованиях. Это слабый плюс, если требования часто меняются.</li>
</ol>
<h3>Минусы:</h3>
<ol>
<li>Из требований не так легко вывести эвристики для предсказаний запросов. Могут понадобиться уточнения требований, причем до такой степени, которая плохо совместима с agile.</li>
<li>Можно получить лишние данные.</li>
<li>Для обеспечения эффективной работы контракта prefetch, вероятно, понадобится денормализация хранения данных. Это слабый минус, так как эти оптимизации следуют из бизнес-логики и потому, скорее всего, будут востребованы разными процессами.</li>
</ol>
<p>Из нашего примера можно сделать вывод, что применить данный подход очень сложно и овчинка не стоит выделки. На самом деле в реальных бизнес-проектах количество ограничений огромно и из этой кучи часто удается достать что-то полезное, что позволяет партиционировать данные или предсказывать статистику. Главный плюс этого подхода в том, что используемые ограничения трактуются бизнесом, поэтому они легко понимаются и валидируются.</p>
<p>Обычно самой большой проблемой при попытке использовать этот подход оказывается разделение деятельности. Разработчик должен хорошо погрузиться в бизнес-логику и задавать уточняющие вопросы аналитику, что требует определенного уровня инициативности.</p>
<h3>Агрегаты в стиле DDD</h3>
<p>В больших проектах часто можно увидеть использование практик DDD, поскольку они позволяют эффективно структурировать код. Не обязательно использовать в проекте все шаблоны DDD — иногда можно получить хорошую отдачу даже от внедрения одного. Рассмотрим такое понятие DDD, как агрегат. Агрегатом называют объединение логически связанных сущностей, работа с которыми осуществляется только через корень агрегата (обычно это сущность, которая является вершиной графа связности сущностей).</p>
<p>С точки зрения получения данных главное в агрегате то, что вся логика работы со списками сущностей находится в одном месте — агрегате. Есть два подхода к тому, что следует передавать в агрегат при его конструировании:</p>
<p>Передаем в агрегат функции для получения внешних данных. Логика определения необходимых данных живет внутри агрегата.
Передаем все необходимые данные. Логика определения необходимых данных живет вне агрегата.</p>
<p>Выбор подхода во многом зависит от того, насколько легко можно вынести prefetch за рамки агрегата. Если логика prefetch базируется на бизнес-эвристиках, то обычно ее несложно отделить от агрегата. Выносить за рамки агрегата логику, основанную на анализе его использования (реверс-инжиниринг), может оказаться опасным, так как мы разносим логически связанный код по разным классам.</p>
<h3>Логика укрупнения запросов внутри агрегата</h3>
<p>Попробуем набросать агрегат, который бы соответствовал понятию «чат». Наши классы ChatMessage, UserReference, FileReference соответствуют модели хранения, поэтому их можно было бы переименовать с каким-то соответствующим префиксом, но у нас проект маленький, поэтому оставим как есть. Агрегат назовем Chat, а его составляющие — ChatPage и ChatPageMessage:</p>
<pre><div class="codehilite"><pre><span></span><span class="kd">interface</span> <span class="nc">Chat</span> <span class="o">{</span>
<span class="n">fun</span> <span class="nf">getLastPage</span><span class="o">(</span><span class="n">n</span><span class="o">:</span> <span class="n">Int</span><span class="o">):</span> <span class="n">ChatPage</span>
<span class="o">}</span>
<span class="kd">interface</span> <span class="nc">ChatPage</span> <span class="o">{</span>
<span class="n">val</span> <span class="n">messages</span><span class="o">:</span> <span class="n">List</span><span class="o"><</span><span class="n">ChatPageMessage</span><span class="o">></span>
<span class="o">}</span>
<span class="n">data</span> <span class="kd">class</span> <span class="nf">ChatPageMessage</span><span class="o">(</span>
<span class="n">val</span> <span class="n">id</span><span class="o">:</span> <span class="n">Long</span><span class="o">,</span>
<span class="n">val</span> <span class="n">author</span><span class="o">:</span> <span class="n">UserRemote</span><span class="o">,</span>
<span class="n">val</span> <span class="n">message</span><span class="o">:</span> <span class="n">String</span><span class="o">,</span>
<span class="n">val</span> <span class="n">files</span><span class="o">:</span> <span class="n">List</span><span class="o"><</span><span class="n">FileHeadRemote</span><span class="o">>,</span>
<span class="n">val</span> <span class="n">replyTo</span><span class="o">:</span> <span class="n">ChatPageMessage</span><span class="o">?,</span>
<span class="n">val</span> <span class="n">forwardFrom</span><span class="o">:</span> <span class="n">ChatPageMessage</span><span class="o">?</span>
<span class="o">)</span>
</pre></div>
</pre>
<p>Пока что получается довольно много бессмысленного дублирования. Это связано с тем, что наша предметная модель похожа на модель хранения и они обе похожи на модель для фронтенда.</p>
<p>Я использую классы FileHeadRemote и UserRemote напрямую, чтобы не писать лишнего кода, хотя обычно в домене стоит избегать прямого использования таких классов.</p>
<p>Если использовать такой агрегат, наш REST-контроллер можно переписать так:</p>
<pre><div class="codehilite"><pre><span></span><span class="kd">class</span> <span class="nf">ChatRestController</span><span class="o">(</span>
<span class="kd">private</span> <span class="n">val</span> <span class="n">chat</span><span class="o">:</span> <span class="n">Chat</span>
<span class="o">)</span> <span class="o">:</span> <span class="n">ChatRestApi</span> <span class="o">{</span>
<span class="n">override</span> <span class="n">fun</span> <span class="nf">getLast</span><span class="o">(</span><span class="n">n</span><span class="o">:</span> <span class="n">Int</span><span class="o">)</span> <span class="o">=</span>
<span class="n">chat</span><span class="o">.</span><span class="na">getLastPage</span><span class="o">(</span><span class="n">n</span><span class="o">).</span><span class="na">toFrontModel</span><span class="o">()</span>
<span class="kd">private</span> <span class="n">fun</span> <span class="n">ChatPage</span><span class="o">.</span><span class="na">toFrontModel</span><span class="o">()</span> <span class="o">=</span>
<span class="n">messages</span><span class="o">.</span><span class="na">map</span> <span class="o">{</span> <span class="n">it</span><span class="o">.</span><span class="na">toFrontModel</span><span class="o">()</span> <span class="o">}</span>
<span class="kd">private</span> <span class="n">fun</span> <span class="n">ChatPageMessage</span><span class="o">.</span><span class="na">toFrontModel</span><span class="o">():</span> <span class="n">ChatMessageUI</span> <span class="o">=</span>
<span class="n">ChatMessageUI</span><span class="o">(</span>
<span class="n">id</span> <span class="o">=</span> <span class="n">id</span><span class="o">,</span>
<span class="n">author</span> <span class="o">=</span> <span class="n">author</span><span class="o">.</span><span class="na">toFrontReference</span><span class="o">(),</span>
<span class="n">message</span> <span class="o">=</span> <span class="n">message</span><span class="o">,</span>
<span class="n">files</span> <span class="o">=</span> <span class="n">files</span><span class="o">.</span><span class="na">toFrontReference</span><span class="o">(),</span>
<span class="n">forwardFrom</span> <span class="o">=</span> <span class="n">forwardFrom</span><span class="o">?.</span><span class="na">toFrontModel</span><span class="o">(),</span>
<span class="n">replyTo</span> <span class="o">=</span> <span class="n">replyTo</span><span class="o">?.</span><span class="na">toFrontModel</span><span class="o">()</span>
<span class="o">)</span>
<span class="o">}</span>
</pre></div>
</pre>
<p>Этот вариант во многом напоминает нашу первую наивную реализацию, но при этом имеет важное преимущество: контроллер больше не занимается получением данных напрямую и не зависит от классов, связанных с хранением данных, а зависит только от агрегата, который задан через интерфейсы. Таким образом, и логики prefetch больше нет в контроллере. Контроллер занимается только преобразованием агрегата в модель фронтенда, что дает нам соблюдение принципа единственной ответственности (Single Responsibility Principle, SRP).</p>
<p>К сожалению, для всех описанных в агрегате методов придется написать реализацию.</p>
<p>Попробуем просто сохранить логику контроллера, реализованную при использовании бизнес-эвристик.</p>
<pre><div class="codehilite"><pre><span></span><span class="kd">class</span> <span class="nf">ChatImpl</span><span class="o">(</span>
<span class="kd">private</span> <span class="n">val</span> <span class="n">messageRepository</span><span class="o">:</span> <span class="n">ChatMessageRepository</span><span class="o">,</span>
<span class="kd">private</span> <span class="n">val</span> <span class="n">userRepository</span><span class="o">:</span> <span class="n">UserRemoteApi</span><span class="o">,</span>
<span class="kd">private</span> <span class="n">val</span> <span class="n">fileRepository</span><span class="o">:</span> <span class="n">FileRemoteApi</span>
<span class="o">)</span> <span class="o">:</span> <span class="n">Chat</span> <span class="o">{</span>
<span class="n">override</span> <span class="n">fun</span> <span class="nf">getLastPage</span><span class="o">(</span><span class="n">n</span><span class="o">:</span> <span class="n">Int</span><span class="o">)</span> <span class="o">=</span> <span class="n">object</span> <span class="o">:</span> <span class="n">ChatPage</span> <span class="o">{</span>
<span class="n">override</span> <span class="n">val</span> <span class="n">messages</span><span class="o">:</span> <span class="n">List</span><span class="o"><</span><span class="n">ChatPageMessage</span><span class="o">></span>
<span class="nf">get</span><span class="o">()</span> <span class="o">=</span>
<span class="n">runBlocking</span><span class="o">(</span><span class="n">IO</span><span class="o">)</span> <span class="o">{</span>
<span class="n">val</span> <span class="n">prefetch</span> <span class="o">=</span> <span class="n">async</span><span class="o">(</span>
<span class="o">{</span> <span class="n">userRepository</span><span class="o">.</span><span class="na">getUsersByChat</span><span class="o">().</span><span class="na">associateBy</span> <span class="o">{</span> <span class="n">it</span><span class="o">.</span><span class="na">id</span> <span class="o">}</span> <span class="o">},</span>
<span class="o">{</span> <span class="n">fileRepository</span><span class="o">.</span><span class="na">getHeadsByChat</span><span class="o">().</span><span class="na">associateBy</span> <span class="o">{</span> <span class="n">it</span><span class="o">.</span><span class="na">id</span> <span class="o">}</span> <span class="o">}</span>
<span class="o">)</span>
<span class="n">withContext</span><span class="o">(</span><span class="n">IO</span><span class="o">)</span> <span class="o">{</span> <span class="n">messageRepository</span><span class="o">.</span><span class="na">findLast</span><span class="o">(</span><span class="n">n</span><span class="o">)</span> <span class="o">}</span>
<span class="o">.</span><span class="na">map</span><span class="o">(</span>
<span class="n">prefetch</span><span class="o">.</span><span class="na">await</span><span class="o">().</span><span class="na">let</span> <span class="o">{</span> <span class="o">(</span><span class="n">users</span><span class="o">,</span> <span class="n">files</span><span class="o">)</span> <span class="o">-></span>
<span class="n">recursiveMemoize</span> <span class="o">{</span> <span class="n">message</span><span class="o">,</span> <span class="n">memoized</span><span class="o">:</span> <span class="o">(</span><span class="n">ChatMessage</span><span class="o">)</span> <span class="o">-></span> <span class="n">ChatPageMessage</span> <span class="o">-></span>
<span class="n">message</span><span class="o">.</span><span class="na">toDomainModel</span><span class="o">(</span>
<span class="n">getUser</span> <span class="o">=</span> <span class="n">users</span><span class="o">::</span><span class="n">get</span><span class="o">.</span><span class="na">orThrow</span> <span class="o">{</span> <span class="n">IllegalArgumentException</span><span class="o">(</span><span class="s">"User $it"</span><span class="o">)</span> <span class="o">},</span>
<span class="n">getFile</span> <span class="o">=</span> <span class="n">files</span><span class="o">::</span><span class="n">get</span><span class="o">.</span><span class="na">orThrow</span> <span class="o">{</span> <span class="n">IllegalArgumentException</span><span class="o">(</span><span class="s">"File $it"</span><span class="o">)</span> <span class="o">},</span>
<span class="c1">// рекурсивный вызов этой же функции с мемоизацией</span>
<span class="n">serializeMessage</span> <span class="o">=</span> <span class="n">memoized</span>
<span class="o">)</span>
<span class="o">}</span>
<span class="o">}</span>
<span class="o">)</span>
<span class="o">}</span>
<span class="o">}</span>
<span class="o">}</span>
<span class="kd">private</span> <span class="n">fun</span> <span class="n">ChatMessage</span><span class="o">.</span><span class="na">toDomainModel</span><span class="o">(</span>
<span class="n">getUser</span><span class="o">:</span> <span class="o">(</span><span class="n">UserReference</span><span class="o">)</span> <span class="o">-></span> <span class="n">UserRemote</span><span class="o">,</span>
<span class="n">getFile</span><span class="o">:</span> <span class="o">(</span><span class="n">FileReference</span><span class="o">)</span> <span class="o">-></span> <span class="n">FileHeadRemote</span><span class="o">,</span>
<span class="n">serializeMessage</span><span class="o">:</span> <span class="o">(</span><span class="n">ChatMessage</span><span class="o">)</span> <span class="o">-></span> <span class="n">ChatPageMessage</span>
<span class="o">)</span> <span class="o">=</span> <span class="n">ChatPageMessage</span><span class="o">(</span>
<span class="n">id</span> <span class="o">=</span> <span class="n">id</span> <span class="o">?:</span> <span class="k">throw</span> <span class="n">IllegalStateException</span><span class="o">(</span><span class="s">"$this must be persisted"</span><span class="o">),</span>
<span class="n">author</span> <span class="o">=</span> <span class="n">getUser</span><span class="o">(</span><span class="n">author</span><span class="o">),</span>
<span class="n">message</span> <span class="o">=</span> <span class="n">message</span><span class="o">,</span>
<span class="n">files</span> <span class="o">=</span> <span class="n">files</span><span class="o">?.</span><span class="na">map</span><span class="o">(</span><span class="n">getFile</span><span class="o">)</span> <span class="o">?:</span> <span class="n">listOf</span><span class="o">(),</span>
<span class="n">forwardFrom</span> <span class="o">=</span> <span class="n">forwardFrom</span><span class="o">?.</span><span class="na">let</span><span class="o">(</span><span class="n">serializeMessage</span><span class="o">),</span>
<span class="n">replyTo</span> <span class="o">=</span> <span class="n">replyTo</span><span class="o">?.</span><span class="na">let</span><span class="o">(</span><span class="n">serializeMessage</span><span class="o">)</span>
<span class="o">)</span>
</pre></div>
</pre>
<p>Здесь получилось, что в самой функции getLastPage живет стратегия получения данных, включая prefetch, а функция toDomainModel чисто техническая и отвечает за преобразование хранимых моделей в модель предметной области.</p>
<p>Параллельные вызовы userRepository, fileRepository и messageRepository я переписал в более привычном для Kotlin виде. Надеюсь, что понятность кода из-за этого не пострадала.</p>
<p>В целом такой метод уже вполне работоспособен, производительность при его применении будет такой же, как при простом использовании реверс-инжиниринга или бизнес-эвристик.</p>
<h3>Логика укрупнения запросов вне агрегата</h3>
<p>В процессе создания агрегата мы сразу же столкнемся с проблемой: для конструирования ChatPage размер страницы нужно будет задавать как константу при создании Chat, а не передавать его в getLast(), как обычно. Придется поменять сам интерфейс агрегата:</p>
<pre><div class="codehilite"><pre><span></span><span class="kd">interface</span> <span class="nc">Chat</span> <span class="o">{</span>
<span class="n">fun</span> <span class="nf">getPage</span><span class="o">():</span> <span class="n">ChatPage</span>
<span class="o">}</span>
</pre></div>
</pre>
<p>Поскольку у нас есть дочитка остальных сообщений и мы твердо хотим получать все данные за рамками агрегата, нам придется вообще отказаться от агрегата уровня Chat и сделать корнем ChatPage:</p>
<pre><div class="codehilite"><pre><span></span><span class="kd">class</span> <span class="nf">ChatPageImpl</span><span class="o">(</span>
<span class="kd">private</span> <span class="n">val</span> <span class="n">messageData</span><span class="o">:</span> <span class="n">List</span><span class="o"><</span><span class="n">ChatMessage</span><span class="o">>,</span>
<span class="kd">private</span> <span class="n">val</span> <span class="n">userData</span><span class="o">:</span> <span class="n">List</span><span class="o"><</span><span class="n">UserRemote</span><span class="o">>,</span>
<span class="kd">private</span> <span class="n">val</span> <span class="n">fileData</span><span class="o">:</span> <span class="n">List</span><span class="o"><</span><span class="n">FileHeadRemote</span><span class="o">></span>
<span class="o">)</span> <span class="o">:</span> <span class="n">ChatPage</span> <span class="o">{</span>
<span class="n">override</span> <span class="n">val</span> <span class="n">messages</span><span class="o">:</span> <span class="n">List</span><span class="o"><</span><span class="n">ChatPageMessage</span><span class="o">></span>
<span class="nf">get</span><span class="o">()</span> <span class="o">=</span>
<span class="n">messageData</span><span class="o">.</span><span class="na">map</span><span class="o">(</span>
<span class="o">(</span><span class="n">userData</span><span class="o">.</span><span class="na">associateBy</span> <span class="o">{</span> <span class="n">it</span><span class="o">.</span><span class="na">id</span> <span class="o">}</span> <span class="n">to</span> <span class="n">fileData</span><span class="o">.</span><span class="na">associateBy</span> <span class="o">{</span> <span class="n">it</span><span class="o">.</span><span class="na">id</span> <span class="o">})</span>
<span class="o">.</span><span class="na">let</span> <span class="o">{</span> <span class="o">(</span><span class="n">users</span><span class="o">,</span> <span class="n">files</span><span class="o">)</span> <span class="o">-></span>
<span class="n">recursiveMemoize</span> <span class="o">{</span> <span class="n">message</span><span class="o">,</span> <span class="n">self</span><span class="o">:</span> <span class="o">(</span><span class="n">ChatMessage</span><span class="o">)</span> <span class="o">-></span> <span class="n">ChatPageMessage</span> <span class="o">-></span>
<span class="n">message</span><span class="o">.</span><span class="na">toDomainModel</span><span class="o">(</span>
<span class="n">getUser</span> <span class="o">=</span> <span class="n">users</span><span class="o">::</span><span class="n">get</span><span class="o">.</span><span class="na">orThrow</span><span class="o">(),</span>
<span class="n">getFile</span> <span class="o">=</span> <span class="n">files</span><span class="o">::</span><span class="n">get</span><span class="o">.</span><span class="na">orThrow</span><span class="o">(),</span>
<span class="c1">// рекурсивный вызов этой же функции с мемоизацией</span>
<span class="n">serializeMessage</span> <span class="o">=</span> <span class="n">self</span>
<span class="o">)</span>
<span class="o">}</span>
<span class="o">}</span>
<span class="o">)</span>
<span class="o">}</span>
</pre></div>
</pre>
<p>Далее необходимо создать код prefetch, отдельный от агрегата:</p>
<pre><div class="codehilite"><pre><span></span><span class="n">fun</span> <span class="nf">chatPagePrefetch</span><span class="o">(</span>
<span class="n">pageSize</span><span class="o">:</span> <span class="n">Int</span><span class="o">,</span>
<span class="n">messageRepository</span><span class="o">:</span> <span class="n">ChatMessageRepository</span><span class="o">,</span>
<span class="n">userRepository</span><span class="o">:</span> <span class="n">UserRemoteApi</span><span class="o">,</span>
<span class="n">fileRepository</span><span class="o">:</span> <span class="n">FileRemoteApi</span>
<span class="o">)</span> <span class="o">=</span>
<span class="n">runBlocking</span><span class="o">(</span><span class="n">IO</span><span class="o">)</span> <span class="o">{</span>
<span class="n">async</span><span class="o">(</span>
<span class="o">{</span> <span class="n">userRepository</span><span class="o">.</span><span class="na">getUsersByChat</span><span class="o">()</span> <span class="o">},</span>
<span class="o">{</span> <span class="n">fileRepository</span><span class="o">.</span><span class="na">getHeadsByChat</span><span class="o">()</span> <span class="o">},</span>
<span class="o">{</span> <span class="n">messageRepository</span><span class="o">.</span><span class="na">findLast</span><span class="o">(</span><span class="n">pageSize</span><span class="o">)</span> <span class="o">}</span>
<span class="o">)</span>
<span class="o">}</span>
</pre></div>
</pre>
<p>Теперь для того, чтобы создать агрегат, нужно его состыковать с prefetch. В DDD такого рода оркестрациями занимаются Application Services.</p>
<pre><div class="codehilite"><pre><span></span><span class="kd">class</span> <span class="nf">ChatService</span><span class="o">(</span>
<span class="kd">private</span> <span class="n">val</span> <span class="n">messageRepository</span><span class="o">:</span> <span class="n">ChatMessageRepository</span><span class="o">,</span>
<span class="kd">private</span> <span class="n">val</span> <span class="n">userRepository</span><span class="o">:</span> <span class="n">UserRemoteApi</span><span class="o">,</span>
<span class="kd">private</span> <span class="n">val</span> <span class="n">fileRepository</span><span class="o">:</span> <span class="n">FileRemoteApi</span>
<span class="o">)</span> <span class="o">{</span>
<span class="kd">private</span> <span class="n">fun</span> <span class="nf">chatPagePrefetch</span><span class="o">(</span><span class="n">pageSize</span><span class="o">:</span> <span class="n">Int</span><span class="o">)</span> <span class="o">=</span>
<span class="n">runBlocking</span><span class="o">(</span><span class="n">IO</span><span class="o">)</span> <span class="o">{</span>
<span class="n">async</span><span class="o">(</span>
<span class="o">{</span> <span class="n">messageRepository</span><span class="o">.</span><span class="na">findLast</span><span class="o">(</span><span class="n">pageSize</span><span class="o">)</span> <span class="o">},</span>
<span class="o">{</span> <span class="n">userRepository</span><span class="o">.</span><span class="na">getUsersByChat</span><span class="o">()</span> <span class="o">},</span>
<span class="o">{</span> <span class="n">fileRepository</span><span class="o">.</span><span class="na">getHeadsByChat</span><span class="o">()</span> <span class="o">}</span>
<span class="o">).</span><span class="na">await</span><span class="o">()</span>
<span class="o">}</span>
<span class="n">fun</span> <span class="nf">getLastPage</span><span class="o">(</span><span class="n">n</span><span class="o">:</span> <span class="n">Int</span><span class="o">):</span> <span class="n">ChatPage</span> <span class="o">=</span>
<span class="n">chatPagePrefetch</span><span class="o">(</span><span class="n">n</span><span class="o">)</span>
<span class="o">.</span><span class="na">let</span> <span class="o">{</span> <span class="o">(</span><span class="n">messageData</span><span class="o">,</span> <span class="n">userData</span><span class="o">,</span> <span class="n">fileData</span><span class="o">)</span> <span class="o">-></span>
<span class="n">ChatPageImpl</span><span class="o">(</span><span class="n">messageData</span><span class="o">,</span> <span class="n">userData</span><span class="o">,</span> <span class="n">fileData</span><span class="o">)</span>
<span class="o">}</span>
<span class="o">}</span>
</pre></div>
</pre>
<p>Ну а контроллер особо не изменится, нужно только вместо Chat::getLastPage использовать ChatService::getLastPage. То есть код изменится так:</p>
<pre><div class="codehilite"><pre><span></span><span class="kd">class</span> <span class="nf">ChatRestController</span><span class="o">(</span>
<span class="kd">private</span> <span class="n">val</span> <span class="n">chat</span><span class="o">:</span> <span class="n">ChatService</span>
<span class="o">)</span> <span class="o">:</span> <span class="n">ChatRestApi</span>
</pre></div>
</pre>
<h2>Выводы:</h2>
<ol>
<li>Логику prefetch можно поместить как внутрь агрегата, так и в отдельное место.</li>
<li>Если логика prefetch сильно связана с внутренней логикой агрегата, лучше не выносить ее наружу, так как это может нарушить инкапсуляцию. Я лично не вижу особого смысла выносить prefetch за пределы агрегата, поскольку это сильно ограничивает возможности и увеличивает неявную связность кода.</li>
<li>На производительность пакетной обработки сама по себе организация агрегатов влияет положительно, так как контроля над тяжелыми запросами становится больше и место для логики prefetch становится вполне определенным.</li>
</ol>
<p>В следующей главе мы рассмотрим такой вариант реализации prefetch, который невозможно реализовать в отрыве от основной функции.</p>
<h2>Проксирование и двойной вызов</h2>
<h3>Решение проблемы контракта</h3>
<p>Как мы уже разобрались в предыдущих частях, основная проблема контракта prefetch в том, что он сильно связан с контрактом функции, для которой он должен подготовить данные. Если быть более точным, то он зависит от того, какие данные могут понадобиться основной функции. Что если мы не будем пытаться предсказывать, а попробуем сделать реверс-инжиниринг средствами самого кода? В простых ситуациях нам может помочь подход проксирования, широко используемый в тестировании. Такие библиотеки, как Mockito, генерируют классы с имплементаций интерфейсов, которые могут в том числе накапливать информацию о вызовах. Похожий подход используется в нашей библиотеке.</p>
<p>Если вызвать основную функцию с проксированными репозиториями и собрать информацию о необходимых данных, то потом можно будет эти данные получить в виде пакета и повторно вызвать основную функцию для получения финального результата.</p>
<p>Основное условие следующее: запрашиваемые данные не должны влиять на последующие запросы. Прокси будет возвращать не реальные данные, а только какие-то заглушки, поэтому все ветвления и получения связанных данных отпадают.</p>
<p>В нашем случае это означает, что бесполезно проксировать messageRepository, поскольку по результатам запроса сообщений и делаются дальнейшие запросы. Это не проблема, так как к messageRepository у нас всего один запрос, так что никакой пакетной обработки здесь и не требуется.</p>
<p>Поскольку проксировать мы будем простые функции UserReference->UserRemote и FileReference->FileHeadRemote, то накапливать нужно просто два списка аргументов.</p>
<p>В итоге получаем следующее:</p>
<pre><div class="codehilite"><pre><span></span><span class="kd">class</span> <span class="nf">ChatRestController</span><span class="o">(</span>
<span class="kd">private</span> <span class="n">val</span> <span class="n">messageRepository</span><span class="o">:</span> <span class="n">ChatMessageRepository</span><span class="o">,</span>
<span class="kd">private</span> <span class="n">val</span> <span class="n">userRepository</span><span class="o">:</span> <span class="n">UserRemoteApi</span><span class="o">,</span>
<span class="kd">private</span> <span class="n">val</span> <span class="n">fileRepository</span><span class="o">:</span> <span class="n">FileRemoteApi</span>
<span class="o">)</span> <span class="o">:</span> <span class="n">ChatRestApi</span> <span class="o">{</span>
<span class="n">override</span> <span class="n">fun</span> <span class="nf">getLast</span><span class="o">(</span><span class="n">n</span><span class="o">:</span> <span class="n">Int</span><span class="o">):</span> <span class="n">List</span><span class="o"><</span><span class="n">ChatMessageUI</span><span class="o">></span> <span class="o">{</span>
<span class="n">val</span> <span class="n">messages</span> <span class="o">=</span> <span class="n">messageRepository</span><span class="o">.</span><span class="na">findLast</span><span class="o">(</span><span class="n">n</span><span class="o">)</span>
<span class="c1">// функция получения списка сообщений</span>
<span class="n">fun</span> <span class="nf">transform</span><span class="o">(</span>
<span class="n">getUser</span><span class="o">:</span> <span class="o">(</span><span class="n">UserReference</span><span class="o">)</span> <span class="o">-></span> <span class="n">UserRemote</span><span class="o">,</span>
<span class="n">getFile</span><span class="o">:</span> <span class="o">(</span><span class="n">FileReference</span><span class="o">)</span> <span class="o">-></span> <span class="n">FileHeadRemote</span>
<span class="o">):</span> <span class="n">List</span><span class="o"><</span><span class="n">ChatMessageUI</span><span class="o">></span> <span class="o">=</span>
<span class="n">messages</span><span class="o">.</span><span class="na">map</span><span class="o">(</span>
<span class="n">recursiveMemoize</span> <span class="o">{</span> <span class="n">message</span><span class="o">,</span> <span class="n">self</span> <span class="o">-></span>
<span class="n">message</span><span class="o">.</span><span class="na">toFrontModel</span><span class="o">(</span><span class="n">getUser</span><span class="o">,</span> <span class="n">getFile</span><span class="o">,</span> <span class="n">self</span><span class="o">)</span>
<span class="o">}</span>
<span class="o">)</span>
<span class="c1">// накапливаем запросы</span>
<span class="n">val</span> <span class="n">userIds</span> <span class="o">=</span> <span class="n">mutableSetOf</span><span class="o"><</span><span class="n">UserReference</span><span class="o">>()</span>
<span class="n">val</span> <span class="n">fileIds</span> <span class="o">=</span> <span class="n">mutableSetOf</span><span class="o"><</span><span class="n">FileReference</span><span class="o">>()</span>
<span class="n">transform</span><span class="o">(</span>
<span class="o">{</span> <span class="n">userIds</span> <span class="o">+=</span> <span class="n">it</span><span class="o">;</span> <span class="n">UserRemote</span><span class="o">(</span><span class="mi">0</span><span class="n">L</span><span class="o">,</span> <span class="s">""</span><span class="o">)</span> <span class="o">},</span>
<span class="o">{</span> <span class="n">fileIds</span> <span class="o">+=</span> <span class="n">it</span><span class="o">;</span> <span class="n">FileHeadRemote</span><span class="o">(</span><span class="mi">0</span><span class="n">L</span><span class="o">,</span> <span class="s">""</span><span class="o">)</span> <span class="o">}</span>
<span class="o">)</span>
<span class="k">return</span> <span class="n">runBlocking</span><span class="o">(</span><span class="n">IO</span><span class="o">)</span> <span class="o">{</span>
<span class="c1">// получаем данные по всем запросам сразу</span>
<span class="n">async</span><span class="o">(</span>
<span class="o">{</span> <span class="n">userRepository</span><span class="o">.</span><span class="na">getUsersByIds</span><span class="o">(</span><span class="n">userIds</span><span class="o">).</span><span class="na">associateBy</span> <span class="o">{</span> <span class="n">it</span><span class="o">.</span><span class="na">id</span> <span class="o">}::</span><span class="n">get</span><span class="o">.</span><span class="na">orThrow</span><span class="o">()</span> <span class="o">},</span>
<span class="o">{</span> <span class="n">fileRepository</span><span class="o">.</span><span class="na">getHeadsByIds</span><span class="o">(</span><span class="n">fileIds</span><span class="o">).</span><span class="na">associateBy</span> <span class="o">{</span> <span class="n">it</span><span class="o">.</span><span class="na">id</span> <span class="o">}::</span><span class="n">get</span><span class="o">.</span><span class="na">orThrow</span><span class="o">()</span> <span class="o">}</span>
<span class="o">).</span><span class="na">await</span><span class="o">().</span><span class="na">let</span> <span class="o">{</span> <span class="o">(</span><span class="n">getUser</span><span class="o">,</span> <span class="n">getFile</span><span class="o">)</span> <span class="o">-></span>
<span class="n">transform</span><span class="o">(</span><span class="n">getUser</span><span class="o">,</span> <span class="n">getFile</span><span class="o">)</span>
<span class="o">}</span>
<span class="o">}</span>
<span class="o">}</span>
<span class="o">}</span>
</pre></div>
</pre>
<p>Если измерить производительность, получится, что при данном подходе она не хуже, чем при использовании методов реверс-инжиниринга, хотя мы вызываем функцию два раза. Это связано с тем, что по сравнению со временем выполнения внешних запросов, временем выполнения функции преобразования можно пренебречь (в нашем случае).</p>
<p>Если сравнивать с производительностью при использовании бизнес-эвристик, то в нашем случае накопление запросов окажется менее эффективным. Но нужно учитывать, что не всегда удается найти такие хорошие эвристики. Например, если количество пользователей в чате будет большим, как и количество файлов, и при этом файлы будут прикрепляться к сообщениям редко, то наш алгоритм на бизнес-эвристиках сразу же начнет проигрывать честному получению списку запросов.</p>
<h2>Выводы</h2>
<h3>Плюсы:</h3>
<ol>
<li>Для реализации prefetch не нужен реверс-инжиниринг кода получения данных.</li>
<li>В большинстве случаев изменения основной функции не повлияют на prefetch.</li>
<li>Масштабируемость такая же хорошая, как у реверс-инжиниринга.</li>
</ol>
<h3>Минусы:</h3>
<ol>
<li>Требуется независимость следующих запросов от результатов предыдущих.</li>
<li>Редко используется, поэтому логика не так очевидна.</li>
</ol>
<p>Несмотря на кажущуюся экзотичность, накопление запросов через проксирование и повторный вызов вполне применимо в ситуациях, когда логика основной функции не завязана на получаемые данные. Основная сложность здесь такая же, как при реверс-инжиниринге: мы закладываемся на текущую реализацию функции, хотя и в гораздо меньшей степени (только на тот факт, что следующие запросы не зависят от результатов предыдущих запросов).</p>
<p>Производительность упадет незначительно, зато в коде prefetch не нужно будет учитывать всех нюансов реализации основной функции.</p>
<p>Можно использовать такой подход, когда не получается построить хороших бизнес-эвристик для предсказания запросов, а связность prefetch и функции хочется уменьшить.</p>
<h2>Заключение</h2>
<p>Использование пакетной обработки не так просто, как кажется на первый взгляд. Думаю, все шаблоны проектирования обладают этим свойством (вспомните кэширование).</p>
<p>Для эффективной пакетной обработки запросов вызывающей стороне важно собрать вместе как можно больше запросов, что часто затрудняется структурой приложения. Выхода два: либо проектировать приложение в расчете на эффективную работу с данными (очень может быть, что это приведет к реактивному устройству приложения), либо, как это часто бывает, пытаться внедрить пакетную обработку в существующее приложение без значительной его перестройки.</p>
<p>Самый очевидный способ собрать запросы в кучу — это реверс-инжиниринг существующего кода в поисках тяжелых запросов. Главным недостатком этого подхода будет увеличение неявной связности кода. Альтернатива — задействовать информацию о бизнес-особенностях для того, чтобы разделить данные на порции, которые часто используются совместно и целиком. Иногда для такого эффективного разделения придется денормализовать хранение, но зато, если такое получится осуществить, логика пакетной обработки будет определятся предметной областью, что хорошо.</p>
<p>Менее очевидный способ получить все запросы — реализовать два прохода. На первом этапе собираем все необходимые запросы, на втором работаем с уже полученными данными. Применимость такого подхода ограничена требованием независимости запросов друг от друга.</p>
</div>
</article>
</div>
<div class="blog__tile blog__tile_slim">
<div class="blog-post__footer">
<div class="blog-post__footer-item blog-post__footer-item_right">
<div class="inline-block">
<div class="blog-post__footer-text blog-post__footer-text_share">Поделиться</div>
<div class="blog-post__footer-icons">
<div class="blog-post__footer-icon-box hover-ic js-share" data-href="https://otus.ru/nest/post/2362/" data-type="tg">
<div class="blog-post__footer-icon ic ic-tg-square ic-tg-square-hover"></div>
</div>
<div class="blog-post__footer-icon-box hover-ic js-share" data-href="https://otus.ru/nest/post/2362/" data-type="tw">
<div class="blog-post__footer-icon ic ic-twitter-square ic-twitter-square-hover"></div>
</div>
<div class="blog-post__footer-icon-box hover-ic js-share" data-href="https://otus.ru/nest/post/2362/" data-type="vk">
<div class="blog-post__footer-icon ic ic-vk-square ic-vk-square-hover"></div>
</div>
</div>
</div>
<div class="inline-block float-right_ssm">
<div class="blog__counters-item blog__counters-item_bookmark">
<a href="#"
class="js-blog-post-mark blog-post__footer-icon blog-post__footer-icon_star"
post_id=2362></a>
</div>
</div>
</div>
<div class="blog-post__footer-item">
<div class="blog-vote">
<div class="blog-vote__item blog-vote__item_border blog-vote__item_left js-blog-post-dislike"
data-url="/nest/vote/post/" data-id=2362></div>
<div class="blog-vote__item blog-vote__item_border blog-vote__item_center blog-vote__item_pos js-post-votes"
data-url="/nest/vote/post/" data-id=2362>
1
</div>
<div class="blog-vote__item blog-vote__item_border blog-vote__item_right js-blog-post-like"
data-url="/nest/vote/post/" data-id=2362></div>
</div>
</div>
<div class="inline-block float-right_ssm">
<a class="blog-post__footer-item" href="/nest/post/2362/#comments">
<div class="blog-post__footer-icon blog-post__footer-icon_comments"></div>
<div class="blog-post__footer-text js-blog-post-comments-counter">0</div>
</a>
<div class="blog-post__footer-item">
<div class="blog-post__footer-icon blog-post__footer-icon_views"></div>
<div class="blog-post__footer-text">5</div>
</div>
</div>
</div>
</div>
</div>
<div
id="formSubscribe"
class="blog-subscribe__container js-subscribe-container"
>
<form
method="post"
novalidate
action="/nest/blog/subscribe/"
class="js-ajax-form js-new-validation blog-subscribe blog__external"
data-before-send='[
{"action": "addClass", "el": "#formSubscribe .js-content", "class": "hidden"},
{"action": "show", "el": "#formSubscribe .js-loader"}
]'
data-after-complete='[
{"action": "hide", "el": "#formSubscribe .js-loader"}
]'
data-after-error='[{"action": "removeClass", "el": "#formSubscribe .js-content", "class": "hidden"}]'
data-after-success='[
{"action": "addClass", "el": "#formSubscribe .js-block-common", "class": "hidden"},
{"action": "show", "el": "#formSubscribe .js-block-success"},
{"action": "addClass", "el": "#formSubscribe.js-subscribe-container", "class": "blog-subscribe__container_finish"}
]'
data-success-msg="false"
>
<div class="js-block-common">
<p class="blog-subscribe__title">Не пропустите новые полезные статьи!</p>
<div class="blog-subscribe__fields">
<input type="hidden" name="csrfmiddlewaretoken" value="8eZQM3z0y8HpLoch3fzj3WuWLiVUFOIrREwp6Uid3rSfzToS6uusTSjBoOv0Q2ii">
<input type="hidden" name="object_id" value="120"/>
<div class="js-content">
<div class="new-input-line blog-subscribe__input-line">
<div class="new-input-group new-input-group_right blog-subscribe__group">
<div class="new-input new-input_fake new-input_full">
<input
type="email"
class="new-input new-input_full js-placeholder new-input_border-no blog-subscribe__input"
data-title="email"
autocomplete="email"
name="email"
required
placeholder="Введите ваш email"
/>
<div class="new-input__error-sign new-ic new-ic-warning js-new-input-error">
<div class="new-input__error-text-container">
<p class="new-input__error-text js-new-input-error-text"></p>
</div>
</div>
</div>
<button
type="submit"
class="new-input-group__addon new-input-group__addon_button new-button new-button_blue blog-subscribe__button"
>
Подписаться
</button>
</div>
</div>
<div class="new-input-line new-input-line_last">
<label class="checkbox checkbox_new">
<input required type="checkbox" checked name="subscription_agree" value="true">
<div class="checkbox__label blog-subscribe__checkbox-text">
Соглашаюсь получать полезные новости, статьи,
приглашения на мастер-классы и специальные предложения OTUS
</div>
</label>
<div class="new-input__error-sign new-ic new-ic-warning js-new-input-error">
<div class="new-input__error-text-container">
<p class="new-input__error-text js-new-input-error-text"></p>
</div>
</div>
</div>
</div>
<div class="hide js-loader">
<i class="ic loader loader_md loader_absolute-center ic-loader"></i>
</div>
</div>
</div>
<div class="js-block-success blog-subscribe__success hide">
<p class="blog-subscribe__title">Спасибо за подписку!</p>
<p class="blog-subscribe__text text-center">
Мы отправили вам письмо для подтверждения вашего email.
<br/>
С уважением, OTUS!
</p>
</div>
</form>
</div>
<div class="blog__tile" id="author_block">
<div class="blog__h2">Автор</div>
<div class="post-info">
<a href="/profile/226076/">
<div class="post-info__avatar post-info__avatar_big ic ic-blog-default-avatar" style="background-image: url(https://cdn.otus.ru/media/public/79/39/avatar-1801-7939a1.png);"></div>
<div class="post-info__author">Зинченко Максим</div>
</a>
<div class="post-info__text">
Рейтинг:
<div class="post-info__text-ratingprofile__rating_pos">
+4
</div>
</div>
<div class="post-info__text">
1651 день
</div>
<div class="post-info__button">
</div>
</div>
</div>
<div class="blog-tile-wrapper">
<div class="blog-tile">
<div class="blog-tile__item blog-tile__item_no-padding-bottom blog-tile__item_last">
<div class="blog__h2 blog__h2_slim">Похожие посты</div>
</div>
<div class="blog-tile__item">
<a class="blog-tile__item-title" href="/nest/post/151/" title="Варианты DI в Spring">
Варианты DI в Spring
</a>
<a class="blog-post__footer-item blog-post__footer-item_slim" href="/nest/post/151/#comments">
<div class="blog-post__footer-icon blog-post__footer-icon_comments"></div>
<div class="blog-post__footer-text">0</div>
</a>
<a class="blog-post__footer-item blog-post__footer-item_slim" href="/nest/post/151/#author_block">
<div class="blog-post__footer-icon ic ic-blog-rating"></div>
<div class="blog-post__footer-text">+28</div>
</a>
</div>
<div class="blog-tile__item">
<a class="blog-tile__item-title" href="/nest/post/168/" title="Theories в JUnit">
Theories в JUnit
</a>
<a class="blog-post__footer-item blog-post__footer-item_slim" href="/nest/post/168/#comments">
<div class="blog-post__footer-icon blog-post__footer-icon_comments"></div>
<div class="blog-post__footer-text">0</div>
</a>
<a class="blog-post__footer-item blog-post__footer-item_slim" href="/nest/post/168/#author_block">
<div class="blog-post__footer-icon ic ic-blog-rating"></div>
<div class="blog-post__footer-text">+14</div>
</a>
</div>
<div class="blog-tile__item">
<a class="blog-tile__item-title" href="/nest/post/169/" title="Spring Context">
Spring Context
</a>
<a class="blog-post__footer-item blog-post__footer-item_slim" href="/nest/post/169/#comments">
<div class="blog-post__footer-icon blog-post__footer-icon_comments"></div>
<div class="blog-post__footer-text">0</div>
</a>
<a class="blog-post__footer-item blog-post__footer-item_slim" href="/nest/post/169/#author_block">
<div class="blog-post__footer-icon ic ic-blog-rating"></div>
<div class="blog-post__footer-text">+16</div>
</a>
</div>
<div class="blog-tile__item">
<a class="blog-tile__item-title" href="/nest/post/176/" title="Давно ли вы работали с числами?">
Давно ли вы работали с числами?
</a>
<a class="blog-post__footer-item blog-post__footer-item_slim" href="/nest/post/176/#comments">
<div class="blog-post__footer-icon blog-post__footer-icon_comments"></div>
<div class="blog-post__footer-text">0</div>
</a>
<a class="blog-post__footer-item blog-post__footer-item_slim" href="/nest/post/176/#author_block">
<div class="blog-post__footer-icon ic ic-blog-rating"></div>
<div class="blog-post__footer-text">+15</div>
</a>
</div>
<div class="blog-tile__item">
<a class="blog-tile__item-title" href="/nest/post/180/" title="Комментарии в коде: почему, зачем и как?">
Комментарии в коде: почему, зачем и как?
</a>
<a class="blog-post__footer-item blog-post__footer-item_slim" href="/nest/post/180/#comments">
<div class="blog-post__footer-icon blog-post__footer-icon_comments"></div>
<div class="blog-post__footer-text">1</div>
</a>
<a class="blog-post__footer-item blog-post__footer-item_slim" href="/nest/post/180/#author_block">
<div class="blog-post__footer-icon ic ic-blog-rating"></div>
<div class="blog-post__footer-text">+10</div>
</a>
</div>
</div>
</div>
<div class="blog__comments blog__comments-blog-default">
<div data-comments>
<div class="blog__h2">
<div class="text text_inline text_default js-blog-comments-counter">
0 комментариев
</div>
</div>
<div class="js-comments">
</div>
<div class="blog-comment-deny">
Для комментирования необходимо <a href="/login/?next=https%3A%2F%2Fotus.ru%2Fnest%2Fpost%2F2362%2F">авторизоваться</a>
</div>
</div>
</div>
</div>
<div class="container__col container__col_4 container__col_md-0">
<div class="blog-tile-wrapper">
<div class="blog-tile">
<div class="blog-tile__title">Популярное</div>
<div class="blog-tile__item blog-tile__item_last">Сегодня тут пусто</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="blog-footer">
<div class="container cookies__container">
<div class="cookies__margin-block cookies_hide js-cookie">
<div class="cookies">
<div class="cookies__title">
Посещая наш сайт, вы принимаете <a class="cookies__link" href="/legal/cookie/" target="_blank">политику использования cookie-файлов</a>
</div>
<button class="js-cookie-accept cookies__button">ОК</button>
</div>
</div>
</div>
<footer class="footer2 footer2_header3 footer2_desktop footer2_not-subscribed no-print ">
<div class="footer2__container container">
<div class="footer2__container-box">
<div class="footer2__content">
<div class="container__row">
<div class="container__col container__col_4">
<div class="container__row">
<div class="container__col container__col_6 container__col_md-5">
<div class="footer2__links">
<div class="footer2__links-row">
<a href="/about" class="footer2__link" title="О нас">О нас</a>
</div>
<div class="footer2__links-row">
<a href="/smi/" class="footer2__link" title="СМИ о нас">СМИ о нас</a>
</div>
<div class="footer2__links-row">
<a href="/reviews" class="footer2__link" title="Отзывы">Отзывы</a>
</div>
<div class="footer2__links-row">
<a href="/contacts/" class="footer2__link" title="Контакты">Контакты</a>
</div>
<div class="footer2__links-row">
<a href="/journal/" class="footer2__link" title="Блог">Блог</a>
</div>
<div class="footer2__links-row">
<a href="/faq/" class="footer2__link" title="FAQ">FAQ</a>
</div>
</div>
</div>
<div class="container__col container__col_6 container__col_md-7">
<div class="footer2__icons">
<div class="footer2__social">
<a href="https://vk.com/club145052891" target="_blank" rel="noreferrer nofollow" class="footer2__icon ic ic-vk-footer2 ic-vk-footer2-hover"></a>
<a href="https://zen.yandex.ru/id/5bbcbc1ba5bd5400a990e7d9" target="_blank" rel="noreferrer nofollow" class="footer2__icon ic ic-zen ic-zen-hover"></a>
<a href="https://www.youtube.com/channel/UCetgtvy93o3i3CvyGXKFU3g" target="_blank" rel="noreferrer nofollow" class="footer2__icon ic ic-yt-footer2 ic-yt-footer2-hover"></a>
</div>
<a href="https://ttttt.me/Otusjava" target="_blank" rel="noreferrer nofollow" class="footer2__link-extended hover-ic">
<div class="footer2__icon ic ic-inline ic-tlgrm-footer2 ic-tlgrm-footer2-hover"></div>
<p class="footer2__link-extended-text">Канал в Telegram</p>
</a>
<a href="https://ttttt.me/joinchat/JMakp0NXc-L8nNneHCtx7A" target="_blank" rel="noreferrer nofollow" class="footer2__link-extended hover-ic" >
<div class="footer2__icon ic ic-inline ic-tlgrm-footer2 ic-tlgrm-footer2-hover"></div>
<p class="footer2__link-extended-text">Группа в Telegram</p>
</a>
</div>
</div>
</div>
</div>
<div class="container__col container__col_4">
<div class="footer2__links footer2__links_center">
<div class="footer2__links-row">
<a href="/b2b" class="footer2__link" rel="nofollow" title="Корпоративное обучение">
Корпоративное обучение
</a>
</div>
<div class="footer2__links-row">
<a href="/lessons/" class="footer2__link" title="Каталог курсов">
Каталог курсов
</a>
</div>
<div class="footer2__links-row">
<a href="/about/loyalty/" class="footer2__link" title="Программы лояльности">Программы лояльности</a>
</div>
<div class="footer2__links-row">
<a href="/professions/" class="footer2__link" title="Каталог профессий">Каталог профессий</a>
</div>
<div class="footer2__links-row">
<a href="/employers/all/" class="footer2__link" title="Наши партнеры">Наши партнеры</a>
</div>
<div class="footer2__links-row">
<a href="/teach/" class="footer2__link" title="Стать преподавателем">
Стать преподавателем
</a>
</div>
</div>
</div>
<div class="container__col container__col_4">
<p class="footer2__text footer2__text_margin-bot">Подписка на новости IT, анонсы открытых уроков, спец. предложения</p>
<form method="post" class="footer2__subscribe js-subscribe" action="/lessons/subscribe/">
<input type="hidden" name="csrfmiddlewaretoken" value="8eZQM3z0y8HpLoch3fzj3WuWLiVUFOIrREwp6Uid3rSfzToS6uusTSjBoOv0Q2ii">
<input
required
type="email"
name="email"
class="input footer2__subscribe-input"
placeholder="Электронная почта"
value=""
/>
<button
class="footer2__subscribe-button button button_blue button_as-input"
type="submit"
disabled
>
Подписаться
</button>
<div class="new-input-line new-input-line_relative new-input-line_triple footer2__subscribe-policy">
<label class="new-checkbox new-checkbox_vertical-center new-log-reg__checkbox">
<input type="checkbox" checked name="terms_agree" value="true"
class="js-remove-field-error">
<div class="new-checkbox__label">
Я принимаю условия
<a
class="new-link-dotted-blue"
target="_blank"
href="/legal/privacy/"
>
Политики обработки персональных данных
</a>
и
<a
class="new-link-dotted-blue"
target="_blank"
href="/legal/terms/"
>
Пользовательского соглашения
</a>
и даю
<a target="_blank" class="new-link-dotted-blue" href="
/legal/lead_privacy_agree/"
">
свое согласие на обработку персональных данных
</a>
</div>
</label>
</div>
</form>
<p class="footer2__text footer2__text_margin-bot">
По всем вопросам пишите на
<a class="footer2__link" href="mailto:help@otus.ru" target="_blank" rel="nofollow noreferer" title="help@otus.ru">help@otus.ru</a>
</p>
<p class="footer2__text footer2__text_margin-bot">
<a
class="footer2__link"
href="/legal/common/"
target="_blank"
rel="nofollow noreferer"
title="Сведения об образовательной организации"
>
Сведения об образовательной организации
</a>
</p>
<p class="footer2__text footer2__text_margin-bot">
<a
class="footer2__link"
href="/legal/it_company_accreditation/"
target="_blank"
rel="nofollow noreferer"
title="OTUS является аккредитованной IT-компанией"
>
OTUS является аккредитованной IT-компанией
</a>
</p>
<p class="footer2__text footer2__text_margin-bot">
<a
class="footer2__link"
href="/legal/recommendations/"
target="_blank"
rel="nofollow noreferer"
title="Сведения о рекомендательных технологиях"
>
Сведения о рекомендательных технологиях
</a>
</p>
<p class="footer2__text footer2__text_margin-bot">
<a
class="footer2__link"
href="https://reestr.digital.gov.ru/reestr/2704482/"
target="_blank"
rel="nofollow noreferer"
title="В реестре отечественного ПО №24216"
>
В реестре отечественного ПО №24216
</a>
</p>
</div>
</div>
</div>
<div class="footer2__info">
<div class="container__row">
<div class="container__col container__col_bottom container__col_8">
<div class="container__row">
<div class="container__col container__col_3 container__col_md-7">
<p
class="footer2__text footer2__text_nowrap footer2__text_margin-right "
>
© 2015-2026 OTUS
</p>
</div>
<div class="container__col container__col_3 container__col_md-5">
<a
class="footer2__link footer2__link-white-space-normal"
href="/legal/terms/"
title="Условия использования сервиса"
>
Условия использования сервиса
</a>
</div>
</div>
</div>
<div class="container__col container__col_middle container__col_4">
<div class="footer2__info-logos footer2__info-logos_desktop">
<div class="footer2__info-logos-row">
<div class="footer2__info-logo-box">
<div class="runet__wrapper">
<div class="runet">
<div class="runet__text">Премия Рунета <br>2018</div>
</div>
</div>
</div>
<div class="footer2__info-logo-box">
<a class="footer2__info-logo footer2__info-logo_skolkovo" href="http://sk.ru/?utm_source=otus" target="_blank" rel="noreferrer nofollow"></a>
</div>
</div>
<div class="footer2__info-logos-row">
<div class="footer2__info-logo-box footer2__info-logo-box_not-first footer2__info-logo-box_kts">
<a class="footer2__info-logo footer2__info-logo_kts" href="https://ktsstudio.com/?utm_source=otus" target="_blank" rel="noreferrer nofollow"></a>
</div>
<div class="footer2__info-logo-box footer2__info-logo-box_not-first">
<a class="footer2__info-logo footer2__info-logo_uno" href="http://goodlookin.ru/?utm_source=otus" target="_blank" rel="noreferrer nofollow"></a>
</div>
</div>
<div class="footer2__info-logos-row">
<div class="footer2__info-logo-box"></div>
<div class="footer2__info-logo-box">
<div class="runet-mobile">
<div class="runet-mobile__text">Премия Рунета <br>2018</div>
</div>
</div>
<div class="footer2__info-logo-box"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</footer>
<div class="footer2 footer2_mobile no-print ">
<div class="container footer2-mobile__container">
<div class="footer2-mobile__wrapper">
<div class="footer2-mobile__row">
<div class="container__row">
<div class="container__col container__col_12">
<p class="footer2__text footer2__text_margin-bot">Подписка на новости IT, анонсы открытых уроков, спец. предложения</p>
<form method="post" class="footer2__subscribe js-subscribe" action="/lessons/subscribe/">
<input type="hidden" name="csrfmiddlewaretoken" value="8eZQM3z0y8HpLoch3fzj3WuWLiVUFOIrREwp6Uid3rSfzToS6uusTSjBoOv0Q2ii">
<input
required
type="email"
name="email"
class="input footer2__subscribe-input"
placeholder="Электронная почта"
value=""
/>
<button
class="footer2__subscribe-button button button_blue button_as-input"
type="submit"
disabled
>
Подписаться
</button>
<div class="new-input-line new-input-line_relative new-input-line_triple footer2__subscribe-policy">
<label class="new-checkbox new-checkbox_vertical-center new-log-reg__checkbox">
<input type="checkbox" checked name="terms_agree" value="true"
class="js-remove-field-error">
<div class="new-checkbox__label">
Я принимаю условия
<a
class="new-link-dotted-blue"
target="_blank"
href="/legal/privacy/"
>
Политики обработки персональных данных
</a>
и
<a
class="new-link-dotted-blue"
target="_blank"
href="/legal/terms/"
>
Пользовательского соглашения
</a>
и даю
<a target="_blank" class="new-link-dotted-blue" href="
/legal/lead_privacy_agree/"
">
свое согласие на обработку персональных данных
</a>
</div>
</label>
</div>
</form>
<p class="footer2__text footer2__text_margin-bot">
По всем вопросам пишите на
<a class="footer2__link"
href="mailto:help@otus.ru"
target="_blank" rel="nofollow noreferer"
title="help@otus.ru">help@otus.ru</a>
</p>
<p class="footer2__text footer2__text_margin-bot">
<a class="footer2__link"
href="/legal/common/"
target="_blank" rel="nofollow noreferer"
title="Сведения об образовательной организации"
>
Сведения об образовательной организации
</a>
</p>
<p class="footer2__text footer2__text_margin-bot">
<a
class="footer2__link"
href="/legal/it_company_accreditation/"
target="_blank"
rel="nofollow noreferer"
title="OTUS является аккредитованной IT-компанией"
>
OTUS является аккредитованной IT-компанией
</a>
</p>
<p class="footer2__text footer2__text_margin-bot">
<a
class="footer2__link"
href="/legal/recommendations/"
target="_blank"
rel="nofollow noreferer"
title="Сведения о рекомендательных технологиях"
>
Сведения о рекомендательных технологиях
</a>
</p>
<p class="footer2__text footer2__text_margin-bot">
<a
class="footer2__link"
href="https://reestr.digital.gov.ru/reestr/2704482/"
target="_blank"
rel="nofollow noreferer"
title="В реестре отечественного ПО №24216"
>
В реестре отечественного ПО №24216
</a>
</p>
</div>
</div>
</div>
<div class="footer2-mobile__row footer2-mobile__row_social">
<div class="container__row">
<div class="container__col container__col_7 footer2-mobile__col_tg">
<a href="https://ttttt.me/Otusjava" target="_blank" rel="noreferrer nofollow" class="footer2__link-extended hover-ic">
<div class="footer2__icon ic ic-inline ic-tlgrm-footer2 ic-tlgrm-footer2-hover"></div>
<p class="footer2__link-extended-text">Канал в Telegram</p>
</a>
<a href="https://ttttt.me/joinchat/JMakp0NXc-L8nNneHCtx7A" target="_blank" rel="noreferrer nofollow" class="footer2__link-extended hover-ic" >
<div class="footer2__icon ic ic-inline ic-tlgrm-footer2 ic-tlgrm-footer2-hover"></div>
<p class="footer2__link-extended-text">Группа в Telegram</p>
</a>
</div>
<div class="container__col container__col_5 footer2-mobile__col_social">
<div class="footer2__social footer2__social_mobile">
<a href="https://vk.com/club145052891" target="_blank" rel="noreferrer nofollow" class="footer2__icon ic ic-vk-footer2 ic-vk-footer2-hover"></a>
<a href="https://zen.yandex.ru/id/5bbcbc1ba5bd5400a990e7d9" target="_blank" rel="noreferrer nofollow" class="footer2__icon ic ic-zen ic-zen-hover"></a>
<a href="https://www.youtube.com/channel/UCetgtvy93o3i3CvyGXKFU3g" target="_blank" rel="noreferrer nofollow" class="footer2__icon ic ic-yt-footer2 ic-yt-footer2-hover"></a>
</div>
</div>
</div>
</div>
<div class="footer2-mobile__row">
<div class="container__row">
<div class="container__col container__col_6 container__col_xs375-8">
<div class="footer2__links ">
<div class="footer2__links-row">
<a href="/b2b" class="footer2__link" rel="nofollow" title="Корпоративное обучение">
Корпоративное обучение
</a>
</div>
<div class="footer2__links-row">
<a href="/lessons/" class="footer2__link" rel=nofollow title="Каталог курсов">
Каталог курсов
</a>
</div>
<div class="footer2__links-row">
<a href="/about/loyalty/" class="footer2__link" rel=nofollow title="Программы лояльности">Программы лояльности</a>
</div>
<div class="footer2__links-row">
<a href="/professions/" class="footer2__link" rel=nofollow title="Каталог профессий">Каталог профессий</a>
</div>
<div class="footer2__links-row">
<a href="/employers/all/" class="footer2__link" rel=nofollow title="Наши партнеры">Наши партнеры</a>
</div>
<div class="footer2__links-row">
<a href="/teach/" class="footer2__link" rel=nofollow title="Стать преподавателем">
Стать преподавателем
</a>
</div>
</div>
</div>
<div class="container__col container__col_6 container__col_xs375-4">
<div class="footer2__links">
<div class="footer2__links-row">
<a href="/about" class="footer2__link" rel=nofollow title="О нас">О нас</a>
</div>
<div class="footer2__links-row">
<a href="/smi/" class="footer2__link" rel=nofollow title="СМИ о нас">СМИ о нас</a>
</div>
<div class="footer2__links-row">
<a href="/reviews" class="footer2__link" rel=nofollow title="Отзывы">Отзывы</a>
</div>
<div class="footer2__links-row">
<a href="/contacts/" class="footer2__link" rel=nofollow title="Контакты">Контакты</a>
</div>
<div class="footer2__links-row">
<a href="/journal/" class="footer2__link" rel=nofollow title="Блог">Блог</a>
</div>
<div class="footer2__links-row">
<a href="/faq/" class="footer2__link" rel=nofollow title="FAQ">FAQ</a>
</div>
</div>
</div>
</div>
</div>
<div class="footer2-mobile__row footer2-mobile__row_mark">
<div class="footer2-mobile__mark">
<p
class="footer2__text footer2__text_nowrap footer2__text_margin-right footer2__text_margin-right"
>
© 2015-2026 OTUS
</p>
<a
class="footer2__link"
href="/legal/terms/"
title="Пользовательское соглашение"
>
Пользовательское соглашение
</a>
</div>
<div class="footer2-mobile__logos">
<div class="footer2__info-logos footer2__info-logos_tablet">
<div class="footer2__info-logos-row_mobile">
<div class="footer2__info-logo-box">
<a class="footer2__info-logo footer2__info-logo_skolkovo" href="http://sk.ru/?utm_source=otus" target="_blank" rel="noreferrer nofollow"></a>
</div>
<div class="footer2__info-logo-box">
<div class="runet-mobile">
<div class="runet-mobile__text">Премия Рунета <br>2018</div>
</div>
</div>
<div class="footer2__info-logo-box footer2__info-logo-box_not-first footer2__info-logo-box_kts">
<a class="footer2__info-logo footer2__info-logo_kts" href="https://ktsstudio.com/?utm_source=otus" target="_blank" rel="noreferrer nofollow"></a>
</div>
<div class="footer2__info-logo-box footer2__info-logo-box_not-first">
<a class="footer2__info-logo footer2__info-logo_uno" href="http://goodlookin.ru/?utm_source=otus" target="_blank" rel="noreferrer nofollow"></a>
</div>
</div>
</div>
<div class="footer2__info-logos footer2__info-logos_mobile">
<div class="footer2__info-logos-row">
<div class="footer2__info-logo-box">
<div class="runet-mobile">
<div class="runet-mobile__text">Премия Рунета <br>2018</div>
</div>
</div>
<div class="footer2__info-logo-box">
<a class="footer2__info-logo footer2__info-logo_skolkovo" href="http://sk.ru/?utm_source=otus" target="_blank" rel="noreferrer nofollow"></a>
</div>
</div>
<div class="footer2__info-logos-row">
<div class="footer2__info-logo-box footer2__info-logo-box_not-first footer2__info-logo-box_kts">
<a class="footer2__info-logo footer2__info-logo_kts" href="https://ktsstudio.com/?utm_source=otus" target="_blank" rel="noreferrer nofollow"></a>
</div>
<div class="footer2__info-logo-box footer2__info-logo-box_not-first">
<a class="footer2__info-logo footer2__info-logo_uno" href="http://goodlookin.ru/?utm_source=otus" target="_blank" rel="noreferrer nofollow"></a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="modal-container hide-top hide-transparent new-log-reg-container"
data-query='{"next": "/nest/post/2362/"}'
data-modal-ajax="/login-registration/"
data-modal-ajax-once="true"
data-modal-id="new-log-reg"
data-modal-hide-remove-class="hide-top,hide-transparent"
data-modal-hide-add-class="hide-transparent"
data-modal-loading="true"
data-modal-hide-add-delay-class="hide-top">
<div class="new-log-reg-loader">
<div class="new-log-reg__login">
<div class="loader loader_md loader_absolute-center ic-loader ic"></div>
</div>
</div>
<div class="new-log-reg-wrapper js-modal-content">
<div class="new-log-reg__login"></div>
</div>
</div>
<div class="modal-container hide-top hide-transparent new-log-reg-container"
data-query='{"next": "/nest/post/2362/"}'
data-modal-ajax="/login-registration/"
data-modal-id="new-log-reg-event"
data-modal-hide-remove-class="hide-top,hide-transparent"
data-modal-hide-add-class="hide-transparent"
data-modal-loading="true"
data-modal-hide-add-delay-class="hide-top">
<div class="new-log-reg-loader">
<div class="new-log-reg__login">
<div class="loader loader_md loader_absolute-center ic-loader ic"></div>
</div>
</div>
<div class="new-log-reg-wrapper js-modal-content">
<div class="new-log-reg__login"></div>
</div>
</div>
<div class="modal-container hide" data-modal-id="restore-password">
<div class="modal-wrapper modal-wrapper_dark js-modal-wrapper">
<div class="modal new-log-reg__popup">
<div class="new-log-reg__popup-body">
<div class="modal__close new-ic new-ic-close-inverse new-log-reg__popup-close js-close-modal"></div>
<p class="new-log-reg__popup-title">Восстановление пароля</p>
<form method="post" class="js-restore-password" action="/api/restore_password.send_email">
<input type="hidden" name="csrfmiddlewaretoken" value="8eZQM3z0y8HpLoch3fzj3WuWLiVUFOIrREwp6Uid3rSfzToS6uusTSjBoOv0Q2ii">
<div class="new-log-reg__popup-text new-log-reg__popup-text_slim">
Введите электронную почту для восстановления пароля
</div>
<div class="new-input-line new-input-line_slim new-input-line_relative">
<input type="email"
class="new-input new-input_full js-placeholder js-input
js-required
"
maxlength=""
name="email"
autocomplete="off"
required
placeholder="Электронная почта"
/>
<div class="new-input-error new-input-error_bottom js-validation-error hide "></div>
<div class="new-input-error new-input-error_info new-input-error_bottom hide"></div>
</div>
<div class="new-input-line js-error hide">
<span class="new-input-line__error new-log-reg__popup-error">
Пользователь с таким email не найден
</span>
</div>
<div class="new-input-line">
<button class="new-button new-button_md new-button_full new-button_blue"
type="submit">Восстановить
</button>
</div>
</form>
</div>
</div>
</div>
</div>
<div class="modal-container hide" data-modal-id="phone_duplicate">
<div class="modal-wrapper modal-wrapper_dark js-modal-wrapper" data-no-wrapper-close="true">
<div class="modal new-log-reg__popup new-log-reg__popup-body">
<button class="new-log-reg__popup-close new-ic-close-inverse js-close-modal js-stats"
data-event="Register;double_tel_bad"
style=""></button>
<form>
<p class="new-log-reg__popup-title">
Ваш телефон уже привязан к<br/>другой учетной записи
</p>
<p class="new-log-reg__popup-text">
Ваш телефон <b><nobr class="js-phone-duplicate-phone"></nobr></b> уже
привязан к учетной записи <b><nobr class="js-phone-duplicate-acc"></nobr></b>.
Выберите учетную запись, с которой желаете продолжить работу.
Мы привяжем к ней телефон.
</p>
<input class="js-dod-phone-dup-modal-phone" type="hidden" value="" name="phone">
<div class="dod-phone-dup-modal__loader-container">
<div class="js-loader-content">
<div class="new-input-line">
<button
class="new-button new-button_md new-button_full new-button_one-line
new-button_blue-inverse js-stats js-dod-phone-dup-modal-submit"
type="submit"
data-value="1"
data-event="Register;double_tel_ok"
>
Текущий аккаунт <nobr class="js-phone-duplicate-self-acc"></nobr>
</button>
</div>
<div class="new-input-line">
<button
class="new-button new-button_md new-button_full new-button_one-line
new-button_blue js-stats js-dod-phone-dup-modal-submit"
type="submit"
data-value="0"
data-event="Register;double_tel_ok"
>
Войти в аккаунт <nobr class="js-phone-duplicate-acc"></nobr>
</button>
</div>
<div class="new-input-line js-error hide">
<span class="new-input-line__error new-log-reg__popup-error js-error-text"></span>
</div>
</div>
<div
class="js-loader loader loader_absolute-center loader_md ic ic-loader"
style="display: none;"
></div>
</div>
</form>
</div>
</div>
</div>
<div class="modal-container hide" data-modal-id="teacher-enrollment">
<div class="modal-wrapper modal-wrapper_dark js-modal-wrapper" data-no-wrapper-close="true">
<div class="modal new-log-reg__popup">
<form class="new-log-reg__popup-body js-email-terms-update js-form-in-modal"
action="/teacher/request/"
data-modal="teacher-enrollment">
<div class="modal__close new-ic new-ic-close-inverse new-log-reg__popup-close js-close-modal"></div>
<div class="new-log-reg__popup-title">
Заполните номер телефона
</div>
<div class="new-log-reg__popup-text new-log-reg__popup-text_slim">
Для отправки заявки в преподаватели заполните номер телефона
</div>
<div class="new-input-line new-input-line_slim new-input-line_relative">
<input type="text"
class="new-input new-input_full js-placeholder js-input
js-required
"
maxlength="255"
name="phone"
autocomplete="off"
data-js-mask="phone"
required
placeholder="Телефон"
/>
<div class="new-input-error new-input-error_bottom js-validation-error hide "></div>
<div class="new-input-error new-input-error_info new-input-error_bottom hide"></div>
</div>
<div class="new-input-line js-error hide">
<span class="new-input-line__error new-log-reg__popup-error js-text"></span>
</div>
<div class="new-input-line">
<button class="new-button new-button_md new-button_full new-button_blue"
type="submit">Отправить
</button>
</div>
</form>
</div>
</div>
</div>
<script src="https://otus.ru/static/js/common.792bd.js" ></script>
<script src="https://otus.ru/static/js/vendor.common.2ae83.js" ></script>
<script src="https://otus.ru/static/js/vendor.otus.90c85.js" ></script>
<script src="https://otus.ru/static/js/otus.714df.js" ></script>
<script src="https://otus.ru/static/js/vendor.react.3b8bf.js" ></script>
<script src="https://otus.ru/static/js/vendor.common.2ae83.js" ></script>
<script src="https://otus.ru/static/js/vendor.otus-react:header-search.706a9.js" ></script>
<script src="https://otus.ru/static/js/otus-react:header-search.34d9b.js" ></script>
<script type="text/javascript">(window.Image ? (new Image()) : document.createElement('img')).src = 'https://vk.com/rtrg?r=w1EsimsoVZRm*rb04TRjaay1IST5bHFNpthXLyHrq1GCPGzgOmMRcY3mDDdxA17TQOlx6ykxwlrtBtW7sMri/2f364oz1QGDuVvxHFqMqER5NT9mlhp1lYbEMKPIB7NgDRQQp5s2IxBuu*caPsHzTtfILgZJsV5bbkH0m8*GleA-&pixel_id=1000094479';</script>
<script type="text/javascript">(window.Image ? (new Image()) : document.createElement('img')).src = 'https://vk.com/rtrg?p=VK-RTRG-182645-Hs1B';</script>
<script type="text/javascript">(window.Image ? (new Image()) : document.createElement('img')).src = 'https://vk.com/rtrg?p=VK-RTRG-110005-dVnpE';</script>
<script type="text/javascript">(window.Image ? (new Image()) : document.createElement('img')).src = 'https://vk.com/rtrg?p=VK-RTRG-226047-a8wzo';</script>
<noscript>
<div>
<img src="https://mc.yandex.ru/watch/34531570" style="position:absolute; left:-9999px; top: 0;" alt=""/>
<img src="https://mc.yandex.ru/watch/82755226" style="position:absolute; left:-9999px; top: 0;" alt=""/>
<img src="https://mc.yandex.ru/watch/93715742" style="position:absolute; left:-9999px; top: 0;" alt=""/>
</div>
</noscript>
<img height="1" width="1" src="https://happy.otus.ru/pixel/otus.gif?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyZWZlcmVyIjoiaHR0cHM6Ly9vdHVzLnJ1L25lc3QvcG9zdC8yMzYyLz91dG1fc291cmNlPXR5cGVpbiZ1dG1fbWVkaXVtPWRpcmVjdCZ1dG1fY2FtcGFpZ249Tm9uZSJ9.F5QA17iIQR7aULLb9_sl53uajvmLqMkGxoRf68Hdv0c" style="position:absolute; left:-9999px; top: 0;" alt="" />
</body>
</html>