Программирование и тестирование обычно принято относить к разным профессиональным сферам. Скотт Оукс — признанный эксперт по языку Java — уверен, что если вы хотите работать с этим языком, то обязаны понимать, как выполняется код в виртуальной машине Java, и знать, какие настройки влияют на производительность.
Вы сможете разобраться в производительности приложений Java в контексте как JVM, так и платформы Java, освоите средства, функции и процессы, которые могут повысить производительность в LTS-версиях Java, и познакомитесь с новыми возможностями (такими как предварительная компиляция и экспериментальные уборщики мусора).
В этой книге вы:
- Узнаете, как платформы и компиляторы Java влияют на производительность.
- Разберетесь c механизмом уборки мусора.
- Освоите четыре принципа получения наилучших результатов при тестировании производительности.
- Научитесь пользоваться JDK и другими инструментами оценки производительности.
- Узнаете как настройка и приемы программирования позволяют минимизировать последствия уборки мусора.
- Научитесь решать проблемы производительности средствами Java API.
- Поймете, как улучшить производительность приложений баз данных Java.
Реактивщина стала поддерживаться Спрингом с 2017 года. Но через 6 лет многие так и не осознали, где её применять и зачем она нужна. А ведь для Спринга это стало целой новой эпохой.
Реактивнища это не изобретение Спринга.
Первое что нужно знать — реактивщина и реактивный подход не являются изобретением Спринга. Наоборот спринг как обычно поглотил очередную технологию, в данном случае Project Reactor
Реактивный дух времени.
Понятие реактивного подхода размыто но в общем подразумевает что приложение будет скорее событийно ориентировано. Давайте рассмотрим простой пример ниже, в котором сравним старый синхроный подход и рективный.
Синхронный блокирующий подход:
Предположим что на мнужно выполнить 3 задачи. В старом синхронном подходе мы получим код который будет запущенным одним потоком поочередно и блокирующе.
синхронный подход
Реактивный подход
Теперь перепишем ту же задачу используя реактивщину, встроенную в JDK еще с Java 9:
Создаем паблишер и отправляем ему 3 задачи.
Различия. Небольшая большая разница.
Как можно было понять что сам подход к написанию кода от «естественного» процедурного, где каждый этап движется сверху вниз теряется в реактивщине (хотя и в функциональности он тоже теряется). Реактивный код выглядит скорее как декларативный набор реакций на события и поэтому менее прозрачен и читаем. К сожалению, такой стиль написания кода усложняет процессы разработки, тестирования и поддержки кода.
Но почему реактивщина стала с каждым днем становится все более популярней?
Причин довольно много, но я упомяну лишь два наиболее важных (по моему мнению):
Эффективное использование ресурсов (это утверждение истинно только если вы используете неблокирующий код).
Отзывчивость системы (Responsive)
Реактивный + неблокирующий подходы = нефть, золото, греча и, конечно же, акции эппл.
Отгадайте, кто сожрет все потоковые ресурсы?
Если проанализировать среднестатистический цикл жизни потока то можно заметить что большую часть времени (иногда по 99% времени)
находится либо:
В состоянии ожидании доступа к локу те в состоянии ожидании монитора BLOCKED или WAITING
В "логическом ожидании" какого то ресурса как то ответа базы, сервиса или просто базы.
И это очень очень плохо. В текущих реалиях даже среднестатистический процессор обладает огромными мощностями. Большинство операций занимают нано и микросекунд, но ожидания и блокировки руинят всю производительность.
Решением является совместное использования реактивщины и неблокировщины. Первая организует процесс в котором пул потоков ожидает задачи под выполнение а неблокирующий подход не заставляет ждать потока в рамках одной из задач.
Отзывчивость системы. (Responsiveness)
Один из весомых плюсов реактивщины - возможность получения данных по мере их появления. А как следствие любые решения поверх реактивщины отзывчивы для пользователя и не висят тысячами лет пока все данные не подгрузятся.
Давайте уже, что нибудь попишем.
Как я уже упомянул есть несколько реактивных решений, но в рамках статьи мы напишем Спринговые классические решения.
Перед тем как начать. Если выше на путь реактивнищы - пути обратно нет. Все слои внутри приложений надо стараться писать именно в этом стиле. И конечно не забывать про неблокируйщий подход. Если следовать блокирующему то потоки будут стопорится именно у такой блокируюей воронки.
Что это значит на практике?
А на практике это значит что старые библиотеки вроде rest template (общение по http), jdbc template (связь с базой) должны быть выпилены и заменены не реактивные и неблокирующие:
Пример ниже не является самым сложным, но скорее показательным. Напишем простую локику:
К нам приходит http запрос
Мы перенаправляем запрос в базу
Как база дает первый ответ мы кидаем данные на фронт
Данные на фронте парсятся по мере появления (а не лишь когда все будут доступны)
Если говорить упрощенно то весь код по сути будет сводится к тому что мы будем прокладывать трубы в виде Flux или Mono объектов.
Стоит помнить что вся реактивная магия Спринга возможно лишь когда при его старте используется WebFlux'овый фреймворк. Старый вариант запуска Спринга с каким нибудь томкатом под капотом не сработает. Поэтому зависимости ниже обязательны:
Начнем с базы, используем
Пробросим Flux используя DatabaseClient:
Теперь полученный Flux выставим на сторону фронта:
Наверно вы заметили что возвращаемый тип не application/json. Да тип x-ndjson. Если использовать стандартный application/json то данные которые будут улетать на фронт будут неполный и конвертировать их в целые объекты будет головной болью. Это недостаток потокового подхода (хотя по сути это 1 http запрос, просто растянутый). x-ndjson формат позволяет кидать на фронт кидать объекты
Читаем на фронте. Код неидеален, скорее служит в качестве простого примера.
Код выше делает следующее:
Делает http запрос
Ожидает ответа
Как только ответ прилетает начинает читать его по частям и писать в конcоль
Приведенный пример является довольно простым, возможности WebFlux гораздо богаче и позволяют манипулировать данными в асинхронном, реактивном подходе. Но код становится сложнее для написания, чтения и поддержки.
Еще раз про эффективный расход процессорных ресурсов.
Я еще раз хочу упомянуть что реактивные, неблокирующие решение написанные прямыми руками позволяют выжать максимум из предоставленных ресурсов. Особенно это актуально во времена микросервисов. Все чаще на проектах под небольшой микросервис могут выделить не более чем 1/0.5/0.1 CPU и я в общем поддерживаю такой подход.
Виртуальные потоки VS Реактивщина.
Эта тема заслуживает отдельного поста. И он будет следующим если эта статья зайдет. Дайте знать в комментах если интересно.
В данной статье мы разберем, что такое GraphQL, и построим приложение с использованием Java и Spring.
Атмосферный логотип GraphQL
GraphQL стал опенсорсным в 2015 году, однако за десять лет так и не достиг такой же популярности, как REST-архитектура (на графике ниже видно, что нет явной тенденции к большему росту). Нельзя прямо сравнивать GraphQL с REST, поскольку первый представляет собой язык запросов, а второй – архитектурный стиль. Тем не менее, оба имеют реализации в промышленной разработке.
GraphQL все еще остается аутсайдером.
GraphQL в двух словах это гибкий контракт.
GraphQL создает канал коммуникации, где клиент может указывать серверу, какие именно данные ему нужны. Благодаря такому подходу, имея всего лишь один гибкий канал, можно снизить нагрузку на потребление трафика и упростить процесс разработки.
Spring и GraphQL
Довольно ожидаемо что Spring поглотил стандарное решение GraphQL и, конечно же, добавил несколько новых аннотаций. Spring хлебом не корми - дай новых аннотаций наклепать. Давайте напишем своё первое - апи сотрудники.
Для работы со спрингом нужно добавить зависимости.
Для интеграции добавьте в зависимости org.springframework.boot:spring-boot-starter-graphql а также установите переменную в application.properties: spring.graphql.graphiql.enabled: true
Все начинается с GraphQL схемы.
При проектировке контракта, используемого внутри канала, нам необходимо предварительно задекларировать структуру данных. Наша схема будет выглядеть вот так:
Пример так себе, но для нашего примера сгодится.
Суть апи которое мы сделаем - предоставлять сотрудников. У каждого сотрудника есть ссылка на департамент, который в свою очередь имеет ссылку на локацию и руководителей этого отдела. Основной посыл такой - скорее всего многим потребителям нашего апи понадобятся данные о сотрудниках и их отделах. Но также некоторым из них все же могут пригодится все данные.
Составляем схему.
Делаем схему, которая будет соответствовать нашим классам. Слева я расположил знакомые нам Java классы, а справа схему которая будет сохранена в schema.graphqls файле и затем использована для построения соединения.
Язык graphql (справа) довольно интуитивен
Из структуры довольно понятно, что типы имеют либо примитивный тип - (строка, число) либо ссылки на другие объекты. Также в квадратных скобках отмечаются массивы данных.
Последний шаг - декларация запроса. В нашем случае allEmployees.
Создадим метод который будет возвращать данные - он должен быть помечен аннотацией @QueryMapping. Также зарегистрируем его в schema файле:
Возвращаемый тип и название метода должны совпадать.
На этом все. Мы закончили разработку. Пишем первые запросы.
Довольно просто получилось интегрировать GraphQL, осталось научиться им пользоваться. Для этого мы воспользуемся встроенной веб интерфейсом который поставляется из коробки. После старта приложения откройте: http://localhost:8080/graphiql?path=/graphql
Вот какую красоту мы увидим.
Что это такое? Более детально:
Слева у нас есть конструктор который помогает кликами создавать запрос
По центру мы готовим сам контракт для запроса - его можно править в ручную
Справа результат работы апи для выбранного контракта
В примере выше мы выбрали лишь два поля которые нужно вернуть для каждого сотрудника - имя и айдишник. Давайте добавим туда и департамент с локациями:
Лишь измения в контракте дали нам новый результат. На бэкэнде мы не сделали изменений.
Как можно заметить, добавив необходимые поля, мы изменили контракт. Теперь данные о сотрудниках, департаментах и их локациях возвращаются с бэкенда.
Это не все возможности GraphQL.
Данная статья не включает все возможности GraphQL такие как:
Мутации - возможность делать гибкие запросы на изменение данных
Подписка - возможность подписываться на изменения
Более гибкие возможности работы с GraphQL - например возможность читать данные из контекста GraphQL
Генерация документации
DataLoaders - возможность борьбы с N+1 проблемой при работе с базой данных
Моё скромное мнение.
По моему технология недооценена на рынке и достойна куда большего внимания. Текущая имплементация далека от идеала но и относительно проста. Проблема снижения нагрузки и создания универсального апи которое можно динамически менять без изменений со стороны сервера - встречается часто, особенно в больших компаниях (а чаще в больших компания java и используется). Поэтому я всем советую поиграться с технологией и применять в нужных случаях.
Среди множества способов и решений для анализа виртуальной машины Java есть одно хорошее решение — Flight Recorder. Благодаря этой утилите работающая Java-машина может записывать все происходящее внутри.
Достоинства Flight Recorder
Если сравнивать Flight Recorder с другими похожими решениями то можно отметить
Минимальную нагрузку на само приложение (пишут, что она составляет около 1% производительности).
Наличие большого количества метрик и возможность интегрировать свои кастомные метрики.
Простоту интеграции и поддержки.
Отсутствие необходимости перезагрузки приложения. Утилита работает с "горячей виртуальной машиной"
Возможность запускать аналитику на фиксированное количество секунд/минут.
Запуск Flight Recorder.
Предположим, вы уже создали свое приложение и готовы запустить его с помощью JAR. Если вы хотите при старте приложения запустить процесс записи Flight Recorder, то достаточно выполнить следующую команду
В данном случае, в течение 10 секунд после старта Flight Recorder будет записывать все происходящее в виртуальной машине.
Используем JCMD для динамического подключения записи.
Как альтернативу можно использовать утилиту входящую в jdk - JCMD которая позволит подключиться уже к рабочей JVM.
Чтобы узнать, какие виртуальные машины работают в данный момент, достаточно просто написать JCMD и получить что-то вроде:
Слева находятся номера процессов которые можно использовать для подключения.
Как только мы определили, к какому процессу (42800) нужно подключиться, вводим команду для записи отчета на 30 секунд. Файл с записанной аналитикой будет назван myreport.fjr. Итоговая команда будет следующей:
JCMD 42800 JFR.start durati filename=myreport.jfr
Анализируем отчет
Самый просто способ прочитать myreport.jfr - установить программу JMC (Java Mission Control). Скачать её можно вот тут.
После запуска программы можно выбрать существущий myreport.jfr либо подключиться к рабочей виртуальной машине и создать его внутри программы.
Анализируем File I/O - работу с файловой системой.
На вкладке "OUTLINE" необходимо выбрать "File I/O", и тогда можно получить аналитику работы программы с файловой системой. Я намеренно написал код, который создавал файлы размером 20 МБ. Вот что получилось в итоге:
Выглядит довольно точно. Где то 6 файлов было записано каждый по 20 мб.
Socket IO - работа с сетью.
Чтобы просмотреть работу и трафик, необходимо выбрать "Socket I/O". В моем случае, взаимодействия с сетью было крайне мало. Вот результат:
Картина совпала - по сети передавались лишь байты данных. Это примерно ничего.
Аналитика памяти - Ключевая вещь для анализа работы виртуальной машины.
Чтобы оценить затраты памяти, необходимо выбрать "Memory" внутри вкладки "Outline":
Как можно заметить, в памяти было выделено до 80мб, но затем собрано сборщиком мусора. Судя по всему, утечек нет. Но для хорошей аналитики утечек минуты работы профайлера недостаточно.
Аналитика Сборщикам Мусора.
Тут несколько вариантов. Есть Grabage Collectons, GC Configuration, GC Summary. Давайте рассмотрим первый и последний:
Итак, на вкладке видна детализированная аналитика времени работы сборщика мусора. Также можно просмотреть общую статистику:
Суммарное время сборщика сборщика мусора составило 56 мс, и при этом было сделано 13 остановок.
Аналитика потоков. Thread Dumps
Также важной частью отчета является информация о состоянии потоков. Благодаря этим данным можно выявить взаимные блокировки и подобные проблемы. Вкладка "Thread Dumps":
Анализ событий.
Flight Recorder также позволяет записывать происходящие события внутри JVM (и даже создавать свои!). Затем все эти процессы можно удобно просмотреть в Event Browser:
В качестве заключения.
Flight Recorder очень удобная в использовании утилита для аналитики виртуальной машины, которая делает внутренние процессы даже сложных Java программ значительно более читаемыми. Меня лично радует сам факт разивития подобных утилит. Кому интересна Java а также мир разработки приглашаю в мой телеграм канал.
Спасибо за внимание, надеюсь вы узнали что то новое.
❓Вы когда-нибудь ощущали разочарование, работая с чужим кодом? Сегодня трудности сопровождения исходного кода представляют важную проблему разработки программного обеспечения, приводящую к дорогостоящим срывам сроков и ошибкам. Подключайтесь к ее решению. Данное практическое руководство познакомит вас с 10 простыми рекомендациями, помогающими писать программное обеспечение, которое легко поддерживать и адаптировать. Эти тезисы сформулированы на основании анализа сотен реальных систем.
Написанная консультантами компании Software Improvement Group (SIG), книга содержит ясные и краткие советы по применению рекомендаций на практике. Примеры для этого издания написаны на языке Java, но существует аналогичная книга с примерами на языке C#.
Java - это язык строгой типизации. Но это далеко не всегда дает гарантию безопасности во время выполнения программы (т.е. в Runtime).
В отличие от интерпретируемых языков, строгая типизация в Java позволяет избегать миллиарда ошибок, проверяя типы на момент компиляции. Вы не можете назначить строку целому числу (про приведение типов речи не идет). В статье мы рассмотрим наиболее частые ошибки, которые компилятор не может отловить.
1. Null Pointer Exception - топ-1 проблема в Java.
К сожалению, в Java до сих пор нет null-safe типов. А это значит, что любой объект потенциально может быть не инициализирован и указывать на null. Рассмотрим очевидный случай:"
К сожалению компилятор такое допускает
Решением данной проблемы будет введение null-safe типов, которые будут позволять объекту быть null только если это явно указано.
2.Удаление из коллекции во время итерирования.
Классическая ошибка при работе с коллекциями - удаление записей из коллекции без использования итератора. Рассмотрим пример ниже:
Итерируемся и удаляем одновременно.
При попытке запуска такого кода в рантайме мы получим:
Итератор жалуется что кто то модифицировал коллекцию пока он по ней ходил.
Если присмотреться поглубже, можно разглядеть, что внутри итератора это вызывает ошибку при попытке обратиться к следующему элементу:
3. Немодифицируемые коллекции в Java реализуют интерфейсы, предназначенные для модифицируемых коллекций. Аналогичная ситуация существует и для коллекций фиксированного размера.
На мой взгляд, это представляет собой определенный недостаток в дизайне Java. В языке присутствует понятный интерфейс Collection, который устанавливает требования для всех коллекций, включая возможность удаления, добавления и других модификаций данных. Однако существуют реализации, такие как:
Все эти реализации реализованы через интерфейсы, предполагающие наличие функциональности для модификации данных, но на практике они этого не делают. Об этом становится известно только во время выполнения программы. По вопросу модификаций коллекций, Дуг Ли (Doug Lea) высказывал следующее(см. первый параграф).
Кратко говоря, он не стал разделять существующие коллекции на обычные и немодифицируемые, так как это привело бы к увеличению числа интерфейсов и итераторов: "Now we're up to twenty or so interfaces and five iterators." Создание минимум 20 новых интерфейсов и 5 новых итераторов. Более того это все равно не помогло избежать всех потенциальных Runtime исключений.
Я не могу судить человека, кто написал java.util.concurrent. Но я знаю, что в том же Kotlin'e были созданных Mutable/Immutable коллекции, которые позволяют избежать подобных проблем. Мне не важно, добавит ли Java 25 или 2500 новых классов и надеюсь, что в будущем будут добавлены интерфейсы и реализации, предназначенные исключительно для немодифицируемых коллекций без методов типа get/remove/add и так далее. Все текущие недоразумения могут быть отмечены как устаревшие (deprecated).
4. Перезапуск ранее запущенного потока.
В случае работы с многопоточностью можно допустить ряд ошибок, которые не будут отловлены на моменте компиляции. Например, попытка "переиспользовать" один и тот же поток, запустив его дважды:
Запущенный ранее поток запустить еще раз не выйдет.
5.Некорректная работа с монитором.
Также при работе с монитором нужно соблюдать ряд правил. Я не буду вдаваться в подробности, просто приведу пример неправильной попытки работы с локом:
6.Бесконечная рекурсия.
Компилятор не может отличить рекурсию, которая будет работать с условием прерывания, или без. Поэтому бесконечная рекурсия вполне может быть скомпилирована:
Большинство всех показанных выше проблем отлавливаются утилитами которые анализируют код на ошибки вроде SonarQube или ему подобных, об этом я писал в этой статье.
В этой книге представлены практические методики исследования и улучшения незнакомого кода. Вы узнаете о том, как определять скрытые зависимости, выявлять главные причины критических сбоев и аварийных завершений приложений, а также интерпретировать неожиданные результаты. Осваивайте профилирование и отладку и начинайте исследовать, как в действительности работают Java-приложения.
Издание подойдет для Java-разработчиков средней квалификации.
🤔«Наконец-то вышла книга, которая демонстрирует, как устранять проблемы и исследовать Java-приложения в производственной среде! Она определенно может спасти положение!»
Примитивы и обертки являются базовыми элементами в Java. Тем не менее многие, даже опытные специалисты, имеют пробелы в этой теме. В этой статье мы рассмотрим самые важные аспекты примитивов и их оберток.
Примитивы в Java включают в себя 8 типов, из которых 6 относятся к численным.
Целочисленные:
BYTE: тип с диапазоном от -128 до 127
SHORT: тип с диапазоном -32768 до 32767
INT: один из самых популярных типов, диапазон от -2^31 до 2^31-1
LONG: тип с диапазоном значений от -2^63 до 2^63-1
Числа с плавающей точкой.
Куда хитрее стоит дело с числами с плавающей точкой. Тут пользователю дают 32 бита (float) или 64 бита (double), и он может тратить их на целую часть или на часть после плавающей точки. Оценить масштабность этих числе мы можем следующим образом:
Минималный "шаг" между двумя такими числами будет авен:
Float.MIN_VALUE ~ 1.40239846e-45
Double.MIN_VALUE ~ 4.9e-324
Максимальное число, если после запятой не будет дробных частей (те мы словно станем целочисленной переменной):
Float.MAX_VALUE ~ 3.4e+38
Double.MAX_VALUE ~ 1.79e+308
Резюмируя: чем более точно наше число (то есть, чем больше чисел после плавающей точки), тем меньше его абсолютное значение. Поскольку числа "до" запятой, то есть целочисленная часть, уменьшаются, увеличивается точность представления дробной части.
Булевы и Символные примитивы.
Ну и на последок - логические и символьные типы:
boolean - имеет значение true/false
char - символьный примитив может представлять максимум 65536 символов.
Что нужно знать о примитивах?
Основные характеристики примитивов:
Быстры в работе
Легковестны
Переполнение нам не друг. В крайнем случае, мы спасаемся через BigInteger/BigDecimal.
Когда переоценил длину примитива
Но нужно помнить, что примитивы (и их обертки, кстати) могут легко выйти за свои границы. Например, предположим, у нас есть код, в котором происходит умножение типа на самого себя, тогда:
Если умножить два Short, каждый из которых равен 200, то мы выйдем за границу 32768, и вместо 40000 мы получим -25536.
Если мы умножим два Integer (которые могут покрывать 2 миллиарда), каждый из которых равен 50000, то мы получим -1794967296 вместо 2.5 миллиарда.
Вывод - заранее подумай о границах значений. Если данные не влазят ни в один из типов то BigDecimal или BigInteger придут на помощь.
Обертки примитивов. Вроде и жирненькие но необходимые.
Для каждого примитива в Java существует его обертка. Например:
int обернут в Integer
double в Double
и так далее для каждого из 8 типов
Основная необходимость наличия оберток - использование их в коллекциях. Коллекции в Java, по дизайну, работают только с объектами. Такой код даже не скомпилируется:
Так, к сожалению, делать нельзя.
Что дает класс обертка? Посмотрим на примере Integer
Классы-обертки в Java, такие как Integer, предоставляют ряд преимуществ и дополнительных функциональностей:
Конвертация типов: Методы, такие как Integer.valueOf(), предоставляют возможность конвертировать значения одного типа в другой.
Autoboxing/Unboxing: Обертки поддерживают автоматическую упаковку (autoboxing) и распаковку (unboxing) значений, обеспечивая плавную конвертацию между примитивами и объектами.
Реализация equals/hashcode: Классы-обертки переопределяют методы equals() и hashCode(), что позволяет использовать их в качестве ключей в коллекциях, таких как HashMap, где хранение и поиск элементов основаны на хэш-функции.
Имплементация Comparable: Классы-обертки реализуют интерфейс Comparable, что делает их подходящими для использования в упорядоченных коллекциях, таких как TreeMap.
Дополнительные методы: Классы-обертки предоставляют различные вспомогательные методы, такие как min(), max(), bitCount() и другие.
И так один из самых важных моментов - обертки работают из коробки для коллекций. Но есть ли минусы у них?
Недостатки оберток. Жирнота, медлительность.
Обертки инкапсулируют в себя примитивы это мы уже знаем. И как следствие мы:
Тратим память на саму обертку, а учитывая минимальный размер слова в 64-битных операционных системах (32-битные не берем в расчет, они уходят в прошлое) - обертка просто непростительно разрастается.
Время на доступ к примитиву возрастает, так как добавляется лишний прыжок по ссылке обертки.
Время на autoboxing/unboxing
Давайте оценим размер примитива и размер обертки для 64х битных систем:
Потеря минимум 3 раза.
Итак, потеря в памяти довольно высока. Минимум, мы теряем от 3 до 16 раз. И, возможно, здесь стоит отметить, что, на практике, большинство программ не постоянно хранят большие объемы данных, и в большинстве случаев созданные объекты удаляются. Кроме того, современная память относительно дешева.
Что делать, если размер памяти критичен?
Однако, если вы храните большие объемы числовых данных и эффективное использование памяти критично, у вас есть два варианта:
Вернуться к использованию старых добрых массивов, которые поддерживают примитивы.
Использовать библиотеки вроде FastUtil или Trove, которые предоставляют специализированные коллекции для работы с примитивами и оптимизированы для минимизации потребления памяти.
Производительность.
Вопрос о скорости работы коллекций по сравнению с массивами довольно тонкий. Однозначно можно сказать, что коллекции обычно медленнее. Вот тут есть несколько бенчмарков. Проседание в среднем не так заметно. Но вот та гибкость и возможности которые дают коллекции перевешивают их недостатки (хотя если вы пишите что нибудь low latency то с коллекциями вам, наверно не по пути).