Нагрузи меня, Gatling

Про что будет идти речь

Привет. Это статья-туториал про выбор технологии и реализацию проекта нагрузочных тестов для API REST микросервисов. Про себя и специфику продукта, над которым работаю, я подробно описывал тут, когда рассказывал о интеграционных тестах. Здесь этому уделять внимание не буду. Если решитесь продолжать, то Вас ждет длинное чтиво. Результатом потраченного времени и внимания будет понимание того, зачем нужно нагрузочное тестирование, с чего начать, куда двигаться дальше и шаблонный проект нагрузочных тестов, который Вы сможете адаптировать под себя. Все используемые мной технологии в этой статье несут печать Java экосистемы. Это тоже может повлиять на то, решитесь ли Вы продолжать. Поехали …

Используемые технологии

Технологический стек, на котором будет основано повествование:

  • Java 17;

  • Gatling;

  • Gitlab;

  • Maven;

Какую пользу принесет эта статья?

Что?

Тестирование, процесс, направленный на раннее и комплексное выявление проблем. Чем раньше, большее количество проблем будет выявлено, тем выше шанс создать код, который будет выполнять свое назначение. Существует понятная теоретическая классификация видов тестирования, которая помогает разобраться в том, какие тесты и для чего нужны. В этой статье мы будем говорить про нагрузочные тесты. Они позволяют сымитировать динамику функционирования под нагрузкой (экстремальное количество обращений, «объемные» запросы и т.д.). Нагрузочные тесты помогают обнаружить компоненты, которые будут сильнее, по сравнению с остальными, подвержены сбоям. Это даст понимание, в каких условиях приложение будет успешно работать, а при каких потребуется дополнительные ресурсы (инстанс/под/озу/цпу и т.д.). 

Где?

Функция нагрузочного тестирования должна быть в команде разработки. Доступность, простота внедрения, сопровождения и использования нагрузочных тестов определяет успешность их применения в процессах разработки. Разработчику должно быть просто запустить нагрузочный тест, чтобы подтвердить штатные параметры функционирования конкретного кода в любой момент времени. Отделение кода от нагрузочных тестов приведет к нормальному, природному игнорированию проверяемых аспектов функционирования. Идеальная реализация нагрузочных тестов — проект, с которым будет просто работать любой роли в команде, который можно будет запустить в любой удобный момент. 

Когда?

Позднее выявление ошибок обходится дорого. Нагрузочное тестирование направлено на работу с проблемами, которые проявляются по ходу функционирования системы. 

Стимулы изменений

Приложения находятся в конкретном ландшафте. Они получают, преобразуют и передают данные. Источники данных — интеграции, базы данных, специфические репозитории. Ландшафт меняется во времени. Одни источники становятся производительнее, другие меняют технологии, обновляются. Это факторы неопределенности для ландшафта и приложений. Привели ли изменения к желаемым результатам? Стало ли производительнее? Для клиентов важно поддерживать заданное время отклика и функциональные параметры контрактов взаимодействия. Потребителей, со временем, становится больше. Размышления привели к мысли о том, что вслед за интеграционными тестами нужен инструмент, подтверждающий нефункциональные характеристики функционирования приложений. 

Обоснование выбора

Мы подошли к выбору конкретного инструмента. Голова закружилась и кровь пошла носом от многообразия. Есть из чего выбрать, но процесс определения оптимального инструмента не был долгим и сложным. Не прокрастинируя, выделим важное. Этим атрибутам должен соответствовать желаемый инструментарий:

  • Реализация тестов в виде проекта на Java:

    • Для разработчика важно быстро переключать контекст между задачами. Разные технологии вносят разнообразие, но сбивают фокус и требует дополнительных ресурсов на переключение. Реализация нагрузочных тестов в виде проекта с web интерфейсом или на языке программирования  отличном от Java была для меня привлекательна, удалось бы поразбираться в чем-то новом, но это — интерес ради интереса. Нужен надежный инструмент, который будет просто поддерживать, обновлять, сопровождать;

  • Реализация тестов в виде простых DSL выражений:

    • Я смотрел в сторону Jmeter и Gatling web. По этим технологиям была экспертиза, но смущало то, что надо что-то дополнительное устанавливать, адаптировать для автоматического выполнения в pipeline, держать рабочие запросы где-то рядом и таскать их за инструментом. В конечном итоге эти работы так же сбивает фокус и способствуют тому, чтобы реализованный с их помощью инструмент отдалялся от самого приложения;

  • Отчетность о тестировании «из коробки»:

    • Тестовый отчет должен формироваться без каких-то затрат. Идеальный сценарий работы с результатами в моем случае должен был выглядеть так: Запуск тестов → Выполнение тестов → Формирование отчета в чем-то web, что можно быстро прикрутить к gitlab pages. Хотелось быстро получать полноценную картину, которая продемонстрирует аспекты выполнения нагрузочных тестов. Отчеты я покажу дальше. Отдельный абзац на эту тему впереди;

  • Возможность гибко балансировать в выборе тестовой стратегии:

    • Есть разные виды нагрузки — постоянно-устойчивая, постоянно-увеличивающаяся, увеличивающаяся-снижающаяся. Мне хотелось иметь возможность гибко, с помощью минимальных усилий, реализовать разные виды нагрузки и балансировать между ними для оценки состояния приложения. Про стратегии нагрузки и балансировку между ними мы поговорим дальше;

  • Поддержка протоколов HTTP, HTTPS, JMS, JDBC:

В совокупности все привело к конкретному решению — gatling. Что-то из существующих инструментов реализовано на python (lokust), что-то можно реализовать только в виде настроенного инструмента с UI (Jmeter, Gatling, Яндекс Танк). В каких-то доработки можно делать только на JavaScript (k6). Что интересно — возможность написания проектов на Java в gatling добавили не так давно. До этого была возможность Использовать UI и писать тесты на Scala. Ну что же, для меня все сложилось крайне удачно.

Метрики работы под нагрузкой

Нагрузочное тестирование — тестирование поведения приложения под нагрузкой. Оно состоит из:  

  • Тестирование нагрузки (load testing);

  • Стресс тестирование (stress testing);

  • Тестирование на выдержку (soak testing);

  • Пиковое тестирование (spike testing);

  • Тестирование масштабируемости (scalability testing);

Данные стратегии тестирования должны оцениваться по следующим показателям:

  • Время ответа на вызов;

  • Количество вызовов, завершившихся за период;

  • Количество ответов, поступивших за период;

  • Количество пользователей, запросы которых обрабатываются;

  • Ошибки при вызове;

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

  • Что находится за точкой взаимодействия?

  • Выполняется ли кэширование данных запросов?

  • Стенд для тестирования адекватен/линейнокоррелирует с характеристиками продуктивной системы?

  • Канал взаимодействия?

Во времени значения показателей будут меняться в зависимости от того, насколько нагружен источник данных, поэтому важно выбрать достаточную продолжительность для проведения нагрузочного тестирования. Если Вы не знаете с чего начать, то load testing будет оптимальной стратегией для старта.

Gatling

Gatling поддерживает параллелизм и высокую скорость обработки запросов. Это реализуется за счет асинхронной и неблокирующей архитектуры. Хотел сюда добавить ссылку на документацию gatling, но внезапно обнаружил для себя, что ее уже нет. Обидно :-(. Gatling реализован на Scala и использует библиотеку Akka для управления запросами/активными объектами/акторами. Вот тут подробное описание того, как это работает. Суть — акторная модель позволяет обработать множество одновременных запросов без блокировки потоков. Gatling использует неблокирующие операции ввода-вывода (NIO). Это позволяет обрабатывать большое количество запросов без создания дополнительных потоков. Gatling предстает быстрым, масштабируемым инструментом для тестирования производительности.

Абстракции Gatling

Для работы с Gatling нужно иметь представление о его основных абстракциях:

  • Chain — конкретное действие, запрос;

  • Scenario — последовательность действий (chain) для воспроизведения процесса или поведения пользователя;

  • Feeders — механизмы, которые позволяют вводить данные из внешних источников (файлы, JSON и т.д.), при выполнении последовательности действий (scenario);

  • Simulation — транзакция, процесс выполнения сценария или сценариев (scenario), определенным количеством пользователей. Они запускают сценарии в течении определенного периода времени;

  • Session — взаимодействия пользователя с системой во время выполнения сценария;

  • Recorder — интерфейс Gatling, который генерирует scenario и simulation;

Зависимости проекта

Приступаем к сборке проекта. Будем использовать maven. Нам понадобятся следующие зависимости:

Библиотека с реализацией основных gatling абстракций:

     
    io.gatling
    gatling-app
    ${берите последнюю из возможных}

Библиотека с реализацией gatling отчетов:


    io.gatling.highcharts
    gatling-charts-highcharts
    ${берите последнюю из возможных}

Кроме этого добавим компоненты автогенерации кода (lombok), логирование (sl4j) и сериализации/десериализации Json (jackson). Эти зависимости я добавил, потому что мне привычно/комфортно их использовать и дальше по ходу ссылок на код они будут присутствовать. Вы для себя можете выбрать что-то более подходящее:


    org.projectlombok
    lombok
    ${берите последнюю из возможных}
    provided
    true


    org.slf4j
    slf4j-api
    ${берите последнюю из возможных}


    com.fasterxml.jackson.core  
    jackson-databind
    ${берите последнюю из возможных}

Для исполнение симуляции требуется maven-compiler-plugin, который будет запускать тесты и специальный gatling-maven-plugin, для которого нужно указать конкретный класс, который содержит точку входа в проект. Такой себе аналог public static void main. Настройка обоих плагинов в блоке pom.xml будет выглядеть так:


    
        
            org.apache.maven.plugins
            maven-compiler-plugin
            ${берите последнюю из возможных}
            
                ${java.version}
                ${java.version}
            
        
        
            io.gatling
            gatling-maven-plugin
            ${берите последнюю из возможных}
            
                simulation.Simulation
            
        
    

Настроечные компоненты для исполнения тестов

Сами тесты должны находиться в директории — src/test. Директория src/main/java нужна для классов, которые будут обрабатывать конфигурации тестов и параметры их запуска. В папку src/main/resources добавим файл config.properties. Этот файл будет содержать url сервиса, который будем тестировать. Сейчас ограничимся одним url:

#ServiceUnderPerformance
##Test
serviceUrl=http://localhost:8090/api

Для обработки конфигурационных параметров сделаем отдельный класс. Пополнение параметров конфигурации должно увеличивать количество параметров в этом классе. Сейчас у нас будет только один. Используем для этого record.

/**
 * Объект для работы с:
 * конфигурационными переменными - ConfigProperty (config.properties)
 */
public record ConfigProperty(
        String serviceUrl
) {
}

Сделаем custom exception, который нам понадобится по ходу обработки параметров:

/**
 * Custom exception
 * */
public class ProcessingException extends RuntimeException {
    public ProcessingException(String message) {
        super(message);
    }
}

Следом обработаем параметры, полученные из конфигурации, чтобы они были доступны в тестах:

/**
 * Component processing property from config.properties
 */
@UtilityClass
@Slf4j
public class ProcessingProperty {
    private static final ObjectMapper objectMapper =
            new ObjectMapper();
     
    /**
     * Получить значения из config.properties
     *
     * @return Property объект
     */
    public static ConfigProperty getConfigProperty() {
        log.info("Load properties file config.properties");
        return objectMapper.convertValue(
                loadPropertiesFromFile("config.properties"),
                ConfigProperty.class);
    }
 
    /**
     * Загрузить содержимое файла
     *
     * @param fileProperties - название файла с конфигурационными переменными
     * @return содержимое файла
     */
    private static Properties loadPropertiesFromFile(String fileProperties) {
        Properties properties = new Properties();
        try (InputStream inputStream =
                     ProcessingProperty.class.getClassLoader()
                             .getResourceAsStream(fileProperties)) {
 
            properties.load(inputStream);
        } catch (IOException exception) {
            throw new ProcessingException(
                    String.format(
                            "Ошибка при обработке конфигурационных переменных : %s", 
                exception.getMessage()));
        }
        return properties;
    }
 
}

В завершение типизируем тестовые стратегии, которые собираемся использовать. Сделаем это через enum. Под наши потребности подходят 2 стратегии:

  • CONSTANT- приложение сразу получает запросы от заданного количества пользователей;  

    • Стратегия, которая предполагает сразу установить предельное значение пользователей, который будут отправлять запросы;

  • RAMP — увеличение пользователей от начального до заданного значения в течении определенного интервала времени;

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

@Getter
@AllArgsConstructor
public enum TestMode {
    CONSTANT,
    RAMP
}

На это все с настроечными классами. Переходим непосредственно к тестам.

Реализация тестов

Папка с тестами будет содержать две подпапки. В одной — «src/test/java» у нас будут шаги, сценарии, стратегии и симуляции, в другой — «src/test/resources/json», файлы тестируемых сервисов.

Шаги

Это вызовы конкретных сервисов. При реализации конкретного шага (chain) задается название теста, используем ранее определенный url, определяем метод вызова, если требуется задаем тело запроса, так же можно задать специфический вид (urlencoded), заголовки, таймауты, и много, много, много настроек конкретного шага. Есть возможность параметризировать работу с помощью feeders. Суть этого функционала → можно получать значения для выполнения запросов из сторонних файлов, генераторов данных, по интеграции, и т.д. Мне это не понадобилось. Хватило подготовленных запросов. Вам может понадобиться. Тут оставлю ссылку на документацию. Для выполнения набора тестов может понадобиться сохранять значения из полученных ответов и подставлять в передаваемые запросы, чтобы воспроизвести цепочку бизнес процесса. В gatling есть функционал языка выражений (epression language). Мне из этого функционала понадобились только функции преобразования json файлов в запросы. Другими функциями  не было необходимости пользоваться. Моя цель — разработать набор нагрузочных тестов для того, чтобы запустить нагрузочное тестирование. Усложнить будет возможность. Описание языка выражений — тут. Еще в шаге есть возможность проверить атрибуты ответа. Мне было важно получить статус 200. Типичный шаг в моем случае выглядит так:

public static final ChainBuilder stepSomeService =
            exec(http("Name")
                    .post(ProcessingProperty.getConfigProperty().serviceUrl())
                    .body(ElFileBody("json/service/success/request.json"))
                       .asJson()
                    .check(status().is(200)));

Подобных шагов набралось 32.

Сценарии

После того, как шаги подготовлены, можно собирать их в сценарии. Сценарий — реализован в виде DSL компонента. DSL помогает сосредоточиться на том, что должно выполняться в ходе сценария. Удобно. Для сценария есть возможность:

  • Поэтапно выполнить шаги;

  • Задать между шагами паузу;

  • Использовать feeders для передачи общих параметров между шагами;

  • Задать/остановить стратегию нагрузки сервиса для отдельных шагов;

  • Задать/остановить условие продолжения шагов;

Я выбрал поэтапное выполнение подготовленных шагов. Для меня этого достаточно. В сценариях нагрузочных тестов нет возможных вариантов работы системы. Если потребуется — добавим. Сейчас достаточно задать базовый сценарий, который определяет поэтапный опрос всех точек доступа конкретного приложения. В моем случае сценарий такой:

public class ServiceScenario {
 
    public static ScenarioBuilder scenarioUniversalCheck() {
        return CoreDsl.scenario("Performance Test Service")
                .exec(
                        List.of(
                                Chain.stepSomeService,
                                ...
                        ));
    }
 
}

Шагов может быть сколько угодно.

Профили и стратегии (инъекции) нагрузки приложения

Gatling поддерживает 2 основных вида профиля нагрузки:

  • Открытый:

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

    • В этом типе систем пользователи прибывают даже если системы испытывают трудности с обработкой запросов от текущего количества пользователей;

    • Этот тип нагрузки подходит для WEB UI приложений, которые направлены на работу с широкой пользовательской аудиторией.

      • Это тип приложений, который должен поддерживать интерактивный пользовательский режим работы. Чем больше пользователей, тем продукт более востребован и ценен для своей аудитории;

  • Закрытый:

    • Подходит для систем, где нужно контролировать количество одновременно работающих пользователей;

    • Можно провести аналогию с открытым пулом соединений, то есть мы точно знаем сколько к нам потенциально может прийти пользователей;

    • Именно этот профиль нагрузки подходит для тестирования моего приложения;

Про методы тестирования разных профилей в документации gatling хорошо описано тут. Основательная фундаментальная статья, которая поможет разобраться в нюансах работы профилей тут. Я решил реализовать 2 стратегии (инъекции), которые поддерживает закрытый профиль:

  • Константное значение пользователей — задаем количество пользователей и время работы под нагрузкой;

  • Пороговое значение пользователей —  задаем начальное, конечное значение пользователей и время под нагрузкой. В этом случае реализуется постепенное добавление пользователей до порогового значения;

Используя подготовленную типизацию стратегий вышло так:

public class InjectionMode {
 
    public static ClosedInjectionStep chooseInjectionStrategy(TestMode testMode) {
        switch (testMode) {
            case RAMP -> {
                return injectionRampRateProfile();
            }
            default -> {
                return injectionConstantRateProfile();
            }
        }
    }
 
    // Схема нагрузки -> Заданное количество пользователей сразу
    private static ClosedInjectionStep injectionConstantRateProfile() {
        return constantConcurrentUsers(END_USER_COUNT)
                .during(Duration.ofSeconds(WORK_UNDER_PRESSURE));
    }
 
    // Схема нагрузки -> Увеличение количества пользователей 
    //от стартового значения
    // до конечного значений
    private static ClosedInjectionStep injectionRampRateProfile() {
        return rampConcurrentUsers(START_USER_COUNT)
                .to(END_USER_COUNT)
                .during(Duration.ofSeconds(WORK_UNDER_PRESSURE));
    }
 
}

Можно задать профили тестирования используя DSL выражения gatling, задать параллельный или последовательные сценарии выполнения стратегий. Об этом подробно описано тут.

Наш проект будет запускаться с помощью maven. Определять конкретную стратегию запуска будет удобно, задав его в виде конкретного системного параметра. Зададим значения для выполнения нагрузочных тестов «по-умолчанию». Их можно будет переопределить переменными при запуске скрипта. Про запуск приложения будет ниже. Еще зададим параметры стартового числа пользователей и конечного числа пользователей в виде системных переменных:

@UtilityClass
public class PerformanceParameters {
		 
public static final int START_USER_COUNT
    = Integer.parseInt(System.getProperty("START_USER_COUNT", "1"));
 
public static final int END_USER_COUNT
    = Integer.parseInt(System.getProperty("START_USER_COUNT", "10"));
 
public static final int WORK_UNDER_PRESSURE
    = Integer.parseInt(System.getProperty("WORK_UNDER_PRESSURE", "120"));
 
public static final TestMode TEST_MODE
    = TestMode.valueOf(System.getProperty("TEST_MODE", TestMode
                                                        .CONSTANT.toString()));

}

Симуляция

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

Нашу симуляцию необходимо реализовать в виде класса, который должен быть унаследован от корневого gatling компонента симуляции «io.gatling.javaapi.core.Simulation». Наш класс в конечном виде будет выглядеть так:

public class Simulation extends Simulation {
 
    // Симуляция
    public Simulation() {
        setUp(
                scenarioUniversalCheck()
                        .injectClosed(
                            InjectionMode.chooseInjectionStrategy(TEST_MODE)
                        )
 
        )
                .protocols(setupHttpForSimulation())
                .assertions(
                        global().responseTime()
                                  .max().lte(GATE_FOR_RESPONSE_MILISECONDS),
                        global().successfulRequests()
                                    .percent().gt(PERCENT_SUCCESS_RESPONSE)
                );
    }
 
    // Протокол подключения
    private static HttpProtocolBuilder setupHttpForSimulation() {
 
        return HttpDsl.http
                .acceptHeader(JSON_TYPE_HEADER)
                .contentTypeHeader(JSON_TYPE_HEADER)
                .maxConnectionsPerHost(CONNECTIONS_PER_HOST)
                ;
    }
 
}

Запуск и встраивание в процесс разработки

Для разработчика важно иметь возможность запуска проекта при локальной разработке. Локальный запуск поможет проверять сделанные доработки. Прогон в тестовой среде будет частью quality gate и даст уверенности в том, что каждый mr с кодом или настройкой не нарушает метрик производительности. Для запуска локально с параметрами по-умолчанию достаточно выполнить команду → `mvn gatling: test` и проект будет исполняться. В консоли мы будем видеть лог запуска каждого прогона сценария. Он будет исполняться заданное нами на нагрузку время. В итоге получим отчет в логе (Рис. 1) и сгенерированную страницу с отчетом о выполнении скрипта (Рис. 2).

Рис.1

Рис. 1

Рис.2

Рис. 2

Выше мы определили параметры. При необходимости выполнить тест в параметрами, отличными от параметров «по-умолчанию» нужно выполнить запуск скрипта с параметрами — `mvn -D[наименование параметра]=[значение параметра] ga.tling: test`. Встраивать наш проект мы будем в pipeline на gitlab CI, отчетность будем публиковать на страничке gitlab pages. Страничка будет динамически обновляться после каждого прогона нагрузочных тестов. Так мы будем постоянно иметь актуальное значение по производительным метрикам. Так как тестовая и продуктивная среда отличаются только количеством инстансов, на которых развернуты приложения, то мы имеем вполне себе актуальную картину о состоянии продуктивной среды, разделенное на количество подов. Это упрощение. Мы не учли ряд параметров — разные типы запросов, разный размер запросов, кэширование со стороны сервисов и много чего еще. Но мы сделали первый шаг, разобрались с инструментом. Дальше его нужно развивать.

Отчеты

Отдельного упоминания заслуживают отчеты, которые генерируются по результатам выполнения нагрузочных тестов. Это качественно и продуманно сверстанный html. У меня есть опыт работы с разными системами генерации отчетов. Например allure, о котором я упоминал ранее тут. Отчетность на gatling субъективно мне понравилось больше. Она позволяет оценить, как чувствовала себя система под нагрузкой. У нас есть состояние симуляции в целом (Рис. 3), которое дает представление о временных интервалах, за которые отработали шаги нашей симуляции, количестве запросов и времени выполнения нагрузочного тестирования:

Рис.3

Рис. 3

Блок результатов с проверками проведенной симуляции (Рис. 4). Тут мы видим итоги, были ли соблюдены заданные нами параметры.

Рис.4

Рис. 4

Блок, в котором показаны результаты выполнения каждого шага (Рис. 5) и 4 распределения под нагрузкой работы симуляции. Стоит добавить, что на отчете есть дополнительные вкладки, которые демонстрируют подобную статистику по каждому выполненному шагу (вкладка Details).

Рис.5

Рис. 5

Количество активных виртуальных пользователей во время симуляции (Рис. 6). То есть виртуальные подключения, запросы от которых обрабатывались.

Рис.6

Рис. 6

Распределение времени отклика по группам, перцентилям (рис. 7). Так мы получаем близкие по значениям группы времени выполнения запросов.

Рис.7

Рис. 7

Распределение по времени ответа на запросы (Рис. 8.). Встав на график мышью можно увидеть распределение по запросам на конкретной временной точке.

Рис.8

Рис. 8

Количество обрабатываемых запросов на конкретной временной точке (Рис. 9).

Рис.9

Рис. 9

Если какой-то шаг будет выполнен с ошибками, то параметры выполнения сценария в целом сразу сигнализируют об этом (Рис. 10).

Рис.10

Рис. 10

В сводном отчете сразу можно будет выявить конкретный шаг (Рис. 11), отсортировать данные на общем представлении и на соседней вкладке с детализацией данных проанализировать, что не так.

Рис.11

Рис. 11

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

Вместо завершения и ссылка на репозиторий

Проект нагрузочных тестов готов. Идей про его развитии и возможностей использования — куча. Я покрыл только одно приложение. Нужно еще. В ходе этой работы станет понятны, упущения, что стоит добавить, а от чего отказаться.

Благодарности

Спасибо моей команде за помощь, поддержку и мотивацию Да выстоим мы все под любой нагрузкой)

Ссылка на репозиторий

Репозиторий с обезличенным проектом — тут. Пусть он принесет Вам пользу.

© Habrahabr.ru