Введение


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


  • Создание простейшего приложения Erlang (эта статья)
    ??? Использование docker для изоляции рабочей среды
    ??? Управление зависимостями и сборка Erlang проекта
    ??? Борьба за качество результата
  • Кластерные и распределенные системы. Виртуализация рабочего окружения
    ??? Использование docker-compose для разработки и тестирования на примере приложения, использующего внешние хранилища данных (riak, tarantool).
    ??? Service Discovering используя Consul
    ??? Мониторинг среды разработки.
  • Комплексное тестирование: от unit-тестов до UI через интеграционное тестирование не прерывая логического контекста.
    ??? Продолжение обзора Common Test
    ??? Введение в Selenium и Selenium Hub
    ??? Интеграция Selenium Python и Common Test в рамках созданной рабочей среды.

После прочтения данного цикла у читателя должно сложиться представление о подходах, применяемых в современной разработке и тестировании проектов различного уровня, от утилит до распределенных кластерных систем. В данной статье разбирается инструментарий и простейшая песочница. Если тема будет интересна, продолжу цикл по кластерным системам на Erlang, Golang, Elixir, Rust.


Замечание: Для успешной работы с данной статьей на вашей машине должен быть установлен docker, docker-compose и GNU Make. Установка docker не занимает много времени, необходимо лишь помнить, что ваш пользователь должен быть добавлен в группу docker.
Данный код проверен только на debian-like дистрибутивах.


Пример приложения


Итак, давайте попробуем создать атомарный счетчик (atomic counter). Приложение должно выполнять следующие функции:


  • increment
  • decrement
  • reset

И соответствовать требованиям:


  • целевая система: ubuntu 16.04 LTS
  • иметь простейшее HTTP API
  • работать на Erlang/OTP >=19.0
  • стабильно выдавать больше 1k RPS при смешанной нагрузке на запись и чтение в конкурентной среде от 1 до 100 клиентских потоков.
  • иметь начальный интерфейс мониторинга внутренних процессов приложения

В тексте статьи изложены принципы и мотивация принятия тех или иных решений, но нет примеров или цитат кода. Весь код доступен в репозитории: https://github.com/Vonmo/acounter


Изоляция и виртуализация инфраструктуры


В настоящее время существует множество путей решения задачи виртуализации окружения. Все они обладают своими плюсами и минусами, и часто выбрать довольно сложно. Для разработки серверного и распределенного программного обеспечения я предлагаю использовать Docker контейнеризацию, так как docker гибкий и современный инструмент, который позволяет снизить расходы на оборудование на этапе разработки, улучшить процессы тестирования и в некоторых случаях упростить доставку приложений конечным пользователям. Например, нет проблемы запустить на среднем ноутбуке кластер из 12-15 контейнеров с разнообразными сервисами, смоделировать взаимодействие этих сервисов и написать интеграционные тесты в среде приближенной к боевой, а также проверить масштабирование ваших сервисов или обработать и оттестировать отказы, в том числе крупные аварии и восстановление после них.


Замечание: docker и docker-compose предлагаются как решение для этапа разработки: рабочее окружение программистов, staging для тестировщиков. Обоснование основы для боевого окружения выходит за рамки данной статьи.


Поскольку наша среда разделена на 2 уровня, хост и контейнеры, нам необходимо два makefile:


  • Makefile отвечает за внешнее управление виртуальной средой: создание, запуск, остановка необходимого набора контейнеров, ручки для запуска задач компиляции и анализа кода
  • docker.mk отвечает за все, что происходит с кодом внутри контейнеров.

В docker-compose.yml находится описание всех контейнеров нашего кластера:


  • Base. Для выполнения п.2. требований создаем базовый образ. Упаковываем в него Erlang/OTP 19.3 и весь необходимый софт.
  • Test. Данный контейнер унаследован от Base и в него прозрачно смонтирован код.

Замечание: если необходимо гарантировать работу вашего приложения на других версиях erlang, базовый образ дополняется этими версиями. В базовом образе уже установлен kerl и все что нам остается сделать, это добавить в базовый образ необходимую версию erlang, а в makefile дополнительные строки для всех версий erlang и запуска тестов на них.


Для управления виртуальной средой в makefile предопределены следующие цели:


  • $ make build_imgs – создает необходимые образы docker
  • $ make up – запускает и настраивает контейнеры
  • $ make down – очищает тестовую среду

Управление зависимостями и сборка Erlang проекта


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


Зависимости от утилит и их версий, а также от бинарных библиотек, мы уже решили в прошлом пункте. Теперь кратко рассмотрим процесс управления зависимостями и сборки в Erlang. Наиболее популярные из де-факто существующих в эрланге способов – это erlang.mk и rebar. Поскольку я в повседневной практике использую rebar, на нем и остановимся.


Основные функции rebar:


  • Rebar предлагает решение проблемы зависимостей, сборки релизов, настройки компилятора и окружений. В rebar3 появился lock-файл с версиями всех зависимостей и vendor-плагин. Мы можем выбрать стратегию работы с зависимостями, либо мы при каждой новой сборке (новая сборка – сборка с нуля, т.е зависимости не скачаны) скачиваем все зависимости и раскладываем их в директории сборки, либо мы, используя vendor-плагин, замораживаем все зависимости в локальной директории и держим их в репозитории приложения.
  • Также rebar3 предлагает способ упаковки релиза с помощью relx. Сборка релизов в erlang тоже обширная тема и заслуживает отдельной статьи. В данном примере реализован простейший релиз c продакшен окружением, т.е исходные коды не включены, отладочная информация убрана, и релиз готов к запуску на целевой системе без дополнительных манипуляций, т.е Erlang VM и обвязка скриптов для управления сервисом включены в поставку.
  • Расширения позволяют запускать различные полезные утилиты (об этом ниже).

Для сборки и тестирования в makefile определены следующие цели:


  • $ make tests – собирает тестовый профиль приложения и запускает все тесты.
  • $ make rel – собирает итоговый релиз

Борьба за качество результата


Пару слов о Common Test Framework


Стандартным подходом в инженерной практике является тестирование. Практически все предметы, окружающие нас, были разработаны с применением тестов в том или ином виде. В мире erlang существует два базовых фреймворка для тестирования: eunit и common test (далее CT). Оба эти инструмента позволяют оттестировать практически все аспекты работы проектируемой системы, вопрос только в сложности самого инструмента и подготовительных манипуляций перед непосредственным запуском тестов. Eunit предлагает путь модульного тестирования, а common test – это более гибкий и разноплановый инструмент с уклоном в интеграционное тестирование.


В CT есть четкая иерархия процесса тестирования. Спецификации позволяют настроить все аспекты запуска тестов. За ними следуют наборы, в которых группы тест-кейсов объединяются в логически завершенный блок. В рамках тестовой группы мы также можем настраивать порядок запуска тестов и параллельность, гибко конфигурировать тестовое окружение.


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


  1. init_per_suite/end_per_suite – вызывается один раз при запуске конкретного набора
  2. init_per_group/end_per_group – вызывается один раз для заданной группы
  3. init_per_testcase/end_per_testcase – вызывается перед каждым тестом в группе.

Наверняка каждый, кто разрабатывал через тесты и применял eunit, сталкивался с ситуацией, когда плавающий тест отвалился, и из-за этого в тестовой среде остались, например, загруженные приложения, которые ломают инициализацию следующих тестов. Благодаря гибкости CT можно корректно обрабатывать подобные ситуации наряду с многими другими, а также сократить время прогонов всех тестов за счёт продуманной инициализации окружения.


Интеграция Xref


Итак, зачем нам может понадобиться xref? Если отвечать коротко, то для выявления зависимостей между функциями, приложениями и релизами, а также для обнаружения мертвых участков кода.


В крупных проектах часто случается так, что какой-то код становится мертвым. Причин масса: например, мы написали функцию А в модуле X, потом она переехала в модуль Z под названием A2, при этом все тесты успешно прошли, и разработчик забыл об X:A. Так как функция A была экспортирована, то компилятор не подсказал нам о том, что X:A не используется. Конечно, чем раньше мы уберем мертвый код, тем меньше будет кодовая база и, соответственно, затраты на ее поддержание.


Как работает Xref? Он проверяет все вызовы и сопоставляет их с определенными функциями в модулях. Если функция определена, но нигде не задействована, будет предупреждение. Также существует сценарий использования, когда нам нужно узнать все места, где используется тот или иной метод.


Для использования xref в рабочей среде предопределена цель:


  • $ make xref

Используем Dialyzer


В прошлом пункте мы разобрались, как выявить зависимости и неиспользуемые функции. А что если функция есть, она используется, но арность (количество аргументов) или сами аргументы не соответствуют определению? Или например случаи никогда не исполняемых ветвей в case и if операторах, лишние проверки в охранных выражениях или несоответствие декларации типов. Именно для поиска подобных расхождений служит dialyzer.


Для использования dialyzer в рабочей среде предопределена цель:


  • $ make dialyzer

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


Каждая команда решает для себя, каким стандартам оформления следовать, и следовать ли вообще. Большинство крупных проектов стараются придерживаться стандартов оформления, потому что данная практика снимает ряд проблем при поддержке кодовой базы.


Ввиду того, что для Erlang нет одной универсальной IDE, так как кто-то любит emacs, кто-то vim или sublime, то возникает проблема автоматической проверки. К счастью, существует интересный проект elvis, позволяющий без войн внутри команды следовать стандартам оформления.


Например, мы договорились, что перед пушем в репозиторий прогоняем проверку на соответствие стилям.


Для использования elvis предопределена цель:


  • $ make lint

Разработка приложения-счетчика


  1. Клонируем репозиторий
    $ git clone https://github.com/Vonmo/acounter.git
  2. Запускаем песочницу
    $ make build_imgs
    $ make up
  3. Итерационно разрабатываем и тестируем основной функционал приложения. После каждой итерации запускаем тесты:
    $ make tests
  4. Когда все тесты заработали и код логически завершен, необходимо провести нагрузочное тестирование, чтобы проверить нашу программу на соответствие пункту 4 требований.
    В текущей реализации нагрузочное тестирование выполнено не совсем корректно, так как генератор нагрузки и исследуемая система работают на одной машине. Но даже такой код позволяет понять возможности нашей реализации.
    Генератор перед запуском основных тестов прогревает систему, а затем выполняет ступенчатое увеличение нагрузки.
  5. На данном этапе мы имеем полностью работоспособное приложение. Его можно упаковать в релиз и доставить конечным пользователям:
    $ make rel
  6. Для проверки работоспособности релиза можно запустить его в режиме консоли
    $ ./_build/prod/rel/acounter/bin/acounter console
    И зайти на http://localhost:18085/. Если вы видите текст “The little engine that could.”, то релиз запустился и функционирует штатно.

Итоги


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


Erlang не самый популярный язык, но он отлично подходит для серверного ПО и soft realtime систем. И мне хотелось бы хоть немного оживить данную тему на хабре.

Комментарии (12)


  1. ElMaxo
    09.01.2018 14:37

    Спасибо за Ваши статьи. С нетерпением жду следующих


    1. mr_elzor Автор
      09.01.2018 14:47

      Благодарю за приятный комментарий. Следующая статья уже в работе


  1. ElMaxo
    09.01.2018 14:45

    Скажите пожалуйста, зачем для поднятия ETS из файла и сохранения в файл Вы используете gen_server? Не проще ли это сделать в application в start и stop коллбэках?


    1. mr_elzor Автор
      09.01.2018 14:55

      Изначально код взят из metrix (доступно в релизе), там обновления идут через вызовы gen_server. Gen_server решил оставить для явного указания owner у ETS таблички. Плюс наглядно показана механика trap_exit.
      Но реализовать на уровне application:start и application:stop тоже можно.


    1. seriyPS
      09.01.2018 17:40

      Ну проще оно может и проще, но правильнее — вряд-ли по множеству причин. Например если понадобится эту ets-ку привязать к супервизору второго уровня типа


      top_level_sup
      -- some_worker
      -- sub_sup + ets
      ---- some_sub_workers

      То сделав потом во время работы приложения supervisor:terminate_child(top_level_sup, sub_sup) мы грохнем ets-ку не сделав дамп.


      1. ElMaxo
        10.01.2018 19:22

        Не понял к чему Вы. В примере gen_server не делает ничего, кроме как создает ets при старте и дампит ее в файл при завершении. Как по мне — ген_сервер для этого совсем не нужен, для примера достаточно использовать коллбэки application. Если и использовать gen_server — то работать с ets полностью через него, об этом как раз в коментариях ниже


        1. seriyPS
          10.01.2018 19:43

          Я почему-то подумал, что вы предлагали создавать ETS не в процессе application controller, а в процессе корневого супервизора приложения. Ну тогда этот аргумент, пожалуй, не сработает.
          Но я бы в любом случае 10 раз подумал бы прежде чем пристёгивать ETS к "системному" процессу. Часто потом возникает необходимость вокруг этой ETS наворачивать дополнительную логику, периодические всякие таски…
          Опять же, всегда имеет смысл заворачивать ETS в модуль с API функциями вместо прямого обращения через ets:*, а для этого имеет смысл создать отдельный модуль. А если уже модуль создал, то конвертнуть его в gen_server это 10 строк.


          1. ElMaxo
            11.01.2018 14:53

            Нет, из супервизора создавать ets я не предлагал:) Я начал смотреть код, когда увидел gen_server — сразу подумал, что работа со счетчиком идет через него (gen_server:call), и удивился, что по факту сервер ничего не делает. Для примера я предложил поместить создание и удаление таблицы в application controller. В этом случае пришлось бы сделать таблицу named public. Как такового паттерна здесь скорее нет. Я на практике использовал оба варианта: публичная таблица, создаваемая при старте приложения — как правило это какой-то кэш. Либо второй вариант — private таблица, вся работа идет через api родительского процесса. В примере получился микс, который новичка может сбить с толку.


        1. seriyPS
          10.01.2018 19:46

          работать с ets полностью через него

          Имеете в виду работать с приватной таблицей через gen_server:call или работать с публичной таблицей через ets:*, но завернуть их в API-функции?


          1. ElMaxo
            11.01.2018 14:56

            Имел ввиду работу с приватной таблицей через gen_server:call. В этом случае, кстати, в зависимости от задачи, можно вобще не использовать ets, а хранить счетчики в state ген-сервера.


  1. seriyPS
    09.01.2018 17:31
    +1

    Спасибо за статью.
    Небольшой код-ревью сделаю, если уместно:



    Насчет того что вам советуют создавать ETS прямо из супервизора — плохой совет. Для быстрых хаков ещё куда ни шло, но для реальных проектов и обучающих статей — плохая практика. Во первых ваши dump/restore не получилось бы тогда реализовать корректно, во вторых если захочется добавить какие-то периодические операции типа "удалять устаревшие ключи раз в 10 минут", то всё равно аккуратнее всего это будет смотреться внутри одного модуля с gen_server.


    1. mr_elzor Автор
      09.01.2018 18:43

      Спасибо за конструктивный ревью!
      1) Метрики в таком виде были написаны в далеком 2011 году. Сначала просто, как индикатор, т.е открываем tail -f metrics_file и смотрим счетчики, а потом в некоторых проектах эти файлы обрабатывались на узлах агентами и отрпавлялись в zabbix. В статью импортировал этот код для того, чтобы показать, как работает метапроект из нескольких приложений.
      Если выбирать из существующих библиотек, то folsom или exometer вполне решают данную задачу.
      Так же хочется отметить, что в примере приложения нет логгера. Про подходы к логгированию и анализу логов тоже можно написать статью. В качестве практики для интересующихся, можно попробовать прикрутить lager (c вынесением в заголовочный файл макросов с уровнями от DEBUG до FATAL).
      2) Убрал повторяющийся паттерн. Только добавил Req и State к Status, Body. Так как в обработчике могут поставить например cookie. Ну и State необходим, например для долгоживущих обработчиков (websocket, http-streaming)
      3) Согласен, как раз в статье про внешние хранилища данных этот код модернизируется, и детали реализации хранилища спрятаны в общем интерфейсе: сначала добавляется класс обслуживания основанный на tarantool, а затем класс обслуживания на riak. Поправил интерфейс.
      4) Catch убрал из реализации. Действительно за ним не было видно проблемы. Catch в erlang не стоит использовать, только совсем уж в крайних случаях.
      Исходную проблему можно было отловить с помощью дополнительного теста проверяющего, что данные восстановились.