Databene Benerator — генерация тестовых данных

Суть проблемы


Сейчас появляется очень много материала про юнит и нагрузочное тестирования. Все поголовно пишут тесты, код создают исключительно через TDD, используют jmeter/ab. Однако, все тестирование очень тесно связано с тестовыми данными. А их нужно генерировать/писать. Проблема не стоит остро для юнит тестирования — накидал mock, погонял его и забыл. Но как быть с нагрузочным тестированием? Когда мне нужно не 1-2-5-10 объектов, а миллионы?

imageБольшинство (php) разработчиков, которых я встречал, сталкиваясь с задачей нагрузочного тестирования своего кода, создают несколько фикстур руками и насилуют их (ab/jmeter). Полученный результат тестирования не является достоверным, но они об этом не думают. Более продвинутые пишут скрипты для генерации данных, закидывают в БД и после этого уже играются. Похвально, но таких значительно меньше, а сам способ мне не кажется идеальным — другой программист может не разобраться в говнокоде генерилки фикстур (ведь создатель писал это быстро и для утилитарных целей) и рано или поздно все либо пойдут по первому пути, либо начнут писать новую генерилку.

Ценность правильного составления фикстур сейчас недооценена, многие просто на это забивают из-за трудоемкости такой работы (представим 15-25 связанных таблиц, писать скрипт генерации фикстур будет весьма, кхм, интересно). Я прекрасно понимаю почему разработчики так поступают, и, когда появилась такая же задача, то решил не биться головой об стену, а поискать инструментарий для нормальной генерации связанных данных.

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

Databene Benerator — FTW!


Бенератор (да, смешное название) служит для 2х целей: генерация данных и их анонимизация. Последнее выходит за рамки этой статьи, но тоже очень правильное и полезное дело (модификация дампа бд с продакшена, с целью порезать пользовательские личные данные и номера их кредиток).
Тулза использует написанный вами XML сценарий для генерации CSV/XML или экспорта прямо в базу. Работает вообщем-то везде и поддерживает следующие БД:
  • Oracle
  • DB2
  • MS SQL Server
  • MySQL
  • PostgreSQL
  • HSQL
  • H2
  • Derby
  • Firebird


Сценарий представляет собою череду тэгов generate, в которых вы описываете какие сущности вы будете создавать и каким способом. Звучит просто, но есть ньюансы.

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

Сейчас я попробую пошагово описать процесс от скачивания до получения данных в постгресе. Предполагается, что у вас уже установлен постгрес :)

Распаковка/установка


Распаковываем архив продукта и добавляем в конец ~/.bash_profile:
export BENERATOR_HOME=/path/to/unpacked/benerator
export PATH=$PATH:$BENERATOR_HOME/bin

Выполняем:
chmod a+x $BENERATOR_HOME/bin/*.sh

Первый сценарий


Сперва нам необходимо познакомиться с базовыми конструкциями сценария, то, как он строится и из чего состоит. Давайте сгенерируем 5 пользователей с несколькими полями и отдадим их в консоль.

Создаем произвольную папку, сохраняем в нее benerator.xml со следующим содержимым:
benerator.xml
<?xml version="1.0" encoding="UTF-8"?>
<setup xmlns="http://databene.org/benerator/0.7.6"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://databene.org/benerator/0.7.6 benerator-0.7.6.xsd"
        defaultEncoding="UTF-8"
        defaultDataset="US"
        defaultLocale="us"
        defaultLineSeparator="\n">

	<bean id="dtGen" class="DateTimeGenerator">
		<property name='minDate'          value='2013-01-01'/>
		<property name='maxDate'          value='2013-01-31'/>
		<property name='dateGranularity'  value='00-00-02'  />
		<property name='dateDistribution' value='random'    />
		<property name='minTime'          value='08:00:00'  />
		<property name='maxTime'          value='17:00:00'  />
		<property name='timeGranularity'  value='00:00:01'  />
		<property name='timeDistribution' value='random'    />
	</bean>

	<import domains="person"/>

	<generate type="user" count="5" consumer="ConsoleExporter">
		<variable name="person" generator="PersonGenerator"/>
		<attribute name="first_name" script="person.givenName"/>
    	<attribute name="last_name" script="person.familyName"/>
    	<attribute name="birthdate" script="person.birthDate"/>
    	<attribute name="email" script="person.email"/>
    	<attribute name="gender" script="person.gender" map="'MALE'->'true','FEMALE'->'false'"/>
    	<attribute name="created_at" type="timestamp" generator="dtGen"/>
	</generate>
</setup>


Запустив в этой папке benerator.sh ./benerator.xml вы должны увидеть выдачу полученных объектов в консоль. А теперь давайте внимательно изучим benerator.xml и разберемся как это произошло.
  • - аттрибуты тэга несут настройки локали и т.д. Не интересно.

    - создает генератор класса DateTimeGenerator и идентификатором dtGen. По этому идентификатору мы можем далее использовать созданный генератор. Обилие вложенных тэгов property выставляют настройки генератора, названия их вполне говорящие.

    - подгружает один из встроенных генераторов. Я не могу объяснить, почему PersonGenerator нужно подгружать через import, а DateTimeGenerator не требует ничего.

    - создает цикл объектов user с выдачей через потребителя ConsoleExporter (потребителем можно указать подключение к БД, или CSVExporter). Цикл содержит 5 итераций.

    - создает переменную равную экземплятру генератора, все просто. Ньюанс в том, что аттрибуты тэга могут выступать в качестве конфигурации генератора. Обратите внимание, что область видимости этой переменной ограничена нашим циклом generate. Переменную нельзя объявить вне цикла.

    - заполнение свойств объекта. Как видите, в аттрибуте script используется переменная person, вызываются различные свойства специфичные для PersonGenerator. Названия этих свойств, равно как и сами классы описаны в документации. В одном из случаев используется еще один аттрибут map. Т.к. я решил хранить пол пользователя в булине, мне нужно "объяснить" бенератору этот момент, чтобы не получилось ситуации, что я пытаюсь в булинь запихнуть строку.


    Усложнение сценария


    То что вы увидили выше, это все конечно замечательно и красиво, но давайте задумаем себе дополнительные приключения. Скажем, у нас, помимо users должна быть таблица tags, а так же user_refs_tag. Соответственно, между сущностями users и tags будет связь n к n.
    Нам необходимо связать каждого пользователя с произвольным (но управляемым!) количеством тэгов. Сами тэги подготовили нам менеджеры и прислали csv, нам нужно заполнить таблицу из этого файла.

    Сперва, созданим наши таблички в БД:
    psql
    create table users (id serial primary key, first_name varchar not null, last_name varchar not null, birthdate date not null, email varchar not null, gender boolean not null, created_at timestamp not null);
    create table tags (id serial primary key, name varchar not null, weight numeric not null, active boolean not null);
    create table user_refs_tag (user_id integer not null references users (id), tag_id integer not null references tags (id), primary key (user_id, tag_id));
    


    Подготовим tags.ent.csv, который, якобы прислали нам менеджеры:
    tags.ent.csv
    name,weight,active
    Tag 1,1.0,true
    Tag 2,1.05,true
    Tag 3,0.95,true
    Tag 4,1.0,true
    Tag 5,1.06,true
    Tag 6,1.04,true
    Tag 7,1.05,true
    Tag 8,1.1,true
    Tag 9,1.01,true
    Tag 10,0.8,true


    Обновляем benerator.xml:
    benerator.xml
    <?xml version="1.0" encoding="UTF-8"?>
    <setup xmlns="http://databene.org/benerator/0.7.6"
            xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
            xsi:schemaLocation="http://databene.org/benerator/0.7.6 benerator-0.7.6.xsd"
            defaultEncoding="UTF-8"
            defaultDataset="US"
            defaultLocale="us"
            defaultLineSeparator="\n">
    
    	<import domains="person"/>
    	<import platforms="db" />
    	
    	<database id="db" url="jdbc:postgresql://127.0.0.1:6432/benerator" driver="org.postgresql.Driver" user="benerator" password="123" schema="public" catalog="benerator" />
    
    	<memstore id="memstore"/>
    
    	<setting name="min_tags_per_user" value="1"/>
    	<setting name="users_count" value="5"/>
    
    	<execute target="db" type="sql" onError="warn">
    		truncate users cascade;
    		truncate tags cascade;
    	</execute>
    
    	<bean id="dtGen" class="DateTimeGenerator">
    		<property name='minDate'          value='2013-01-01'/>
    		<property name='maxDate'          value='2013-01-31'/>
    		<property name='dateGranularity'  value='00-00-02'  />
    		<property name='dateDistribution' value='random'    />
    		<property name='minTime'          value='08:00:00'  />
    		<property name='maxTime'          value='17:00:00'  />
    		<property name='timeGranularity'  value='00:00:01'  />
    		<property name='timeDistribution' value='random'    />
    	</bean>
    
    	<bean id="tags_seq" spec="new DBSequenceGenerator('tags_id_seq', db)" />
    	<bean id="users_seq" spec="new DBSequenceGenerator('users_id_seq', db)" />
    
    	<bean id="tags_counter" spec="new IncrementalIdGenerator(1)" />
    	<iterate type="tags" source="tags.ent.csv" consumer="db,memstore,ConsoleExporter">
    		<id name="id" type="long" generator="tags_seq" />
    		<variable name="tags_count" generator="tags_counter" />
    		<setting name="max_tags_per_user" value="{tags_count}"/>
    	</iterate>
    
    	<echo>{ftl:Total tags count: ${max_tags_per_user}}</echo>
    
    	<generate type="users" count="{users_count}" consumer="db,ConsoleExporter">
    		<variable name="person" generator="PersonGenerator"/>
    		<id name="id" type="long" generator="users_seq" />
    		<attribute name="first_name" script="person.givenName"/>
        	<attribute name="last_name" script="person.familyName"/>
        	<attribute name="birthdate" script="person.birthDate"/>
        	<attribute name="email" script="person.email"/>
        	<attribute name="gender" script="person.gender" map="'MALE'->'true','FEMALE'->'false'"/>
        	<attribute name="created_at" type="timestamp" generator="dtGen"/>
    
        	<variable name="tags_per_user_count" type="int" min="{min_tags_per_user}" max="{max_tags_per_user}" distribution="random" />
     		<generate type="user_refs_tag" count="{tags_per_user_count}" consumer="db,ConsoleExporter">
     			<variable name="tag" source="memstore" type="tags" distribution="random" unique="true" />
    			<attribute name="tag_id" script="tag.id"/>
    			<attribute name="user_id" script="{users.id}"/>
     		</generate>
    	</generate>
    </setup>
    


    После запуска сценария вы получите выдачу в консоль и вместе с этим экспорт в БД. Рассмотрим, что случилось.
    • - Создание коннекшена к БД. Есть ньюанс, что вам необходимо указать аттрибут catalog, равный названию БД.
      /> — Создает пул в памяти, куда мы будем складывать некие промежуточные данные, увидите позже. Пул доступен по идентификатору memstore

      - Это просто переменные, объявленные вне контекста циклов и доступные глобально.
      Несколько новых с классом DBSequenceGenerator. Этому классу передается название секвенса из базы и коннекшен, класс читает последнее значение секвенса и позволяет вам использовать его.
      Так же, вы видите класс IncrementalIdGenerator - из названия, думаю, понятно, чем он занимается. 1 в аргументе класса - стартовое число.



      - А вот это уже новый тип циклов. Проходится по всему csv, создает сущности tags, создает свойства каждой сущности, согласно загаловку csv и наполняет эти самые свойства. Наполнение каждого из свойств можно переопределять.

      Обратите внимание на consumer - там и db для экспорта в БД и memstore для сохранения в оперативной памяти. Это пригодиться мне позже.


      Внутри цикла я указываю получение id через секвенс. Далее, создаю переменную tags_count, которая наполняется через генератор IncrementalIdGenerator. После каждого наполнения, IncrementalIdGenerator увеличивает счетчик внутри себя на единицу. Таким образом, я всегда владею актуальной информацией о том, сколько же тэгов было импортировано через csv. Вы увидите позже, для чего это надо. Проблема с этой переменной tags_count в том, что она локальная, поэтому я приравниваю глобальную max_tags_per_user к локальной tags_count. Теперь количество тэгов доступно всем остальным.


      - Вывод строки в консоль. Ньюанс в том, что мне нужно вывести строку с переменной, поэтому необходимо заключить всю строку в {ftl:}. Подробнее об этом уже в документации, я не разобрался до конца.


      Внутри для users я добавил новую локальную переменную, которая рандомно получается (distribution) в соответствии с аттрибутами min и max. А вот в этих аттрибутах используются ранее созданные мною глобальные переменные. Таким образом, в локальной переменной tags_per_user_count будет лежать рандомное число, которое я использую в качестве count для нового цикла внутри текущего.

      Как вы видите вложенный цикл создает объекты user_refs_tag. Чтобы не было ситуации, когда у всех пользователей одинаковое количество тэгов, я использую рандомное значение tags_per_user_count. Но рандом должен иметь некоторые рамки, низшая граница задается в начале сценария, верхняя равна количеству уникальных тэгов, (вы ведь еще не забыли про IncrementalIdGenerator в итераторе по csv?) чтобы не произошло ошибки констрайнтов (пользователь связан с 11 тэгами, когда уникальных всего 10, на 11 будет ошибка от БД).

      Внутри вложенного цикла для user_refs_tag я вытаскиваю сущность tags из memstore в переменную tag. Обратите внимание, что во время каждой итерации цикла будет вытаскиваться произвольная сущность (distribution), но не повторяющаяся (unique). Опять же, важный момент, чтобы добиться реалистичности данных и не нарушить констрайнты БД.
      Наполнение сущности user_refs_tag происходит довольно очевидным способом, ньюанс только в user_id - я обращаюсь к переменной текущего контекста users. Нужны скобочки :)

      На выходе мы получаем следующую картину в БД:
      psql
      benerator=> select * from tags;
       id |  name  | weight | active 
      ----+--------+--------+--------
       1  | Tag 1  |      1 | t
       2  | Tag 2  |   1.05 | t
       3  | Tag 3  |   0.95 | t
       4  | Tag 4  |      1 | t
       5  | Tag 5  |   1.06 | t
       6  | Tag 6  |   1.04 | t
       6  | Tag 7  |   1.05 | t
       8  | Tag 8  |    1.1 | t
       9  | Tag 9  |   1.01 | t
       10 | Tag 10 |    0.8 | t
      (10 rows)
      

      benerator=> select * from users;
      id | first_name | last_name | birthdate  |               email                | gender |     created_at      
      ---+------------+-----------+------------+------------------------------------+--------+---------------------
       1 | Francis    | Gardner   | 1946-08-22 | francis_gardner@hotmail.com        | t      | 2013-01-01 09:46:57
       2 | Todd       | Robinson  | 1911-07-24 | todd_robinson@william-thompson.org | t      | 2013-01-21 14:42:54
       3 | Jamie      | Lyons     | 1933-08-14 | jamielyons@owwybni.net             | f      | 2013-01-29 11:23:07
       4 | Ronald     | West      | 1989-03-24 | ronald_west@yahoo.com              | t      | 2013-01-11 15:43:42
       5 | Vanessa    | Pope      | 1942-05-27 | vanessapope@apc.de                 | f      | 2013-01-05 12:28:43
      (5 rows)
      

      benerator=> select * from user_refs_tag;
       user_id | tag_id 
      ---------+--------
           1 |   4
           1 |   10
           1 |   6
           1 |   7
           1 |   5
           1 |   2
           1 |   3
           1 |   1
           1 |   9
           1 |   8
           2 |   5
           2 |   8
           3 |   7
           3 |   10
           3 |   3
           3 |   2
           3 |   4
           3 |   1
           3 |   5
           3 |   8
           3 |   6
           4 |   1
           4 |   9
           4 |   4
           5 |   6
      (25 rows)
      


      Дополнительные возможности


      Надеюсь, этот пример был показателен и не требуют дополнительного описания. Сейчас вы увидели лишь малую часть функционала бенератора, приведу несколько примеров из документации:
      • Свои генераторы
        <bean id="special" class="com.my.SpecialGenerator" />
        
      • Получение значения через запрос к БД с зависимостью от переменной:
        <attribute name="ean_code" source="db" selector="{{ftl:select ean_code from db_product where country='${shop.country}'}}"/>
        
      • Более простой способ наполнения внешних ключей:
        <generate type="db_role" count="10" consumer="db" />
        <generate type="db_user" count="100" consumer="db">
        	<reference name="role_fk" targetType="db_role" source="db" distribution="random"/>
        </generate>
        
      • Использование весов для кастомного распределения данных:
        cities.ent.csv:
        name,population
        New York,8274527
        Los Angeles,3834340
        San Francisco,764976
        

        <generate type="address" count="100" consumer="ConsoleExporter">
        	<variable name="city_data" source="cities.ent.csv" distribution="weighted[population]"/>
        	<id name="id" type="long" />
        	<attribute name="city" script="city_data.name"/>
        </generate>
        


      Все это весьма детально расписано в официальном мануале.
      Так же, существует форум, на случай, если вы зашли в тупик со своей проблемой.

      Заключение


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

      Плюсы:

      • Вся генерация лежит в одном месте, в едином формате, в едином стиле. Запускается через одну операцию. Весь процесс стандартизирован и документирован. Придя на новое место работы, увидя benerator.xml, вы сразу снимаете с себя задачу изучать еще один велосипед.
      • Вам не нужно знать язык программирования вообще. Это позволяет использовать бенератор для DBA. Да и область применения не ограничена работой с БД, вы можете генерировать xml и csv.
      • Скорость написания сценариев + гибкость + скорость изменения. Обилие встроенных классов для генерации пользовательских данных или дат. Я уверен, что используя 20% фич бенератора вы сможете покрыть 80% кейсов, причем сделать это быстрее, чем писать голые скрипты.

      Минусы:

      • Новый синтаксис. Если вы не сталкивались с сабжем ранее, вам придется потратить время на изучение.
      • Слабая поддержка комьюнити. В рунете вообще не сталкивался с описанием бенератора, на stackoverflow тоже очень мало.
      • Оверхэд. Будьте к нему готовы. Есть опасность выстрелить себе в ногу.
Поделиться публикацией
Ой, у вас баннер убежал!

Ну. И что?
Реклама
Комментарии 11
  • 0
    Хорошая вещь. Есть ли что-либо похожее для MongoDB?
    • 0
      Вам уже влом даже на сайт софтины зайти?
      databene.org/mongo4ben.html

      Так как софтина на java добавить туда БД не такая уж большая проблема.
      • +1
        Мне достаточно было прочесть в статье:
        • Oracle
        • DB2
        • MS SQL Server
        • MySQL
        • PostgreSQL
        • HSQL
        • H2
        • Derby
        • Firebird

    • +2
      9 Using mongoDB
      Из мануала.

      Для монги нужно проинсталлить их специальный плагин. Из коробки, бенератор монгу не держит.
    • 0
      Не знаю, как с jmeter, а с помощью ab делать именно нагрузочное тестирование не слишком хорошо — цифры запросов в секунду в результате очень сильно зависят от latency и параметров запуска ab.
      • +1
        Интересный инструмент, но непонятно причем тут хаб PHP? Только потому что вы с ним работаете? По-моему, этот инструмент может пригодиться любым разработчикам.
        • 0
          Чтобы попиарить пост :)
          • +2
            Согласен, чуть его не пропустил по этой причине — думал что-то про пхп.
          • 0
            Хотел бы добавить ещё один минус: хотя инструмент и позиционирует себя как очень быстрый путь сгенерировать данные, но в особо сложных случаях (которых впринципе много для нормализованных реляционных данных) его производительность много меньше, чем использование нативных средств СУБД (PL/SQL, T-SQL, pgPL/SQL) даже с использованием различных предлагаемых «костылей» типа memstore, различных настроек транзакционности и проч.

            Приходилось использовать этот инструмент для генерации чуть более чем десятка сущностей общим объёмом около 100 миллионов записей. Из-за того что сущности сильно взаимосвязаны на генерацию уходило около 6 часов, что не очень хорошо. Это всё, что можно было вытянуть из бенератора с учётом его тюнинга и преднастройки БД (отключения триггеров, констрейнтов и проч.). На PL/SQL задача заняла по-моему в 10 раз меньше времени, но т.к. нужен был инструмент, который работал бы с разными БД, то остановились на бенераторе. В целом действительно не плохо, но требует некоторого времени на изучение всех возможностей.
            • 0
              Глубокая настройка бенератора — отдельная песня. В целом, соглашусь, т.к. оверхэд неизбежен.

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

            Самое читаемое