Главная » Хабрахабр » Bonsai: фамильный вики-движок

Bonsai: фамильный вики-движок

Лирическое вступление

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

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

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

Готовых подходящих решений не оказалось, поэтому пришлось написать собственный. Идеальным решением для хранения семейных данных мне представлялся гибрид вики-движка и фотоальбома. Дальше будет история о том, как он устроен и как им пользоваться, а также история его разработки и немного ДРАМЫ. Он называется Bonsai и доступен с открытым кодом по лицензии MIT.

Очередной велосипед?

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

Вы открываете приложение и пополняете ее в однопользовательском режиме. В случае с десктопным приложением база обычно хранится в виде файла на диске. Из тех, что я смотрел, самыми приятными в использовании показались Gramps (бесплатный) и отечественное Древо жизни (требует одноразовой покупки). При необходимости данные можно экспортировать для бэкапа или переноса в другую систему (например, в формате GEDCOM).

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

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

Если учесть недостатки обоих этих подходов, можно сформулировать список требований к «идеальному» движку:

  1. Веб-приложение, размещаемое на собственном сервере
  2. Создание статей о людях, питомцах, местах, событиях и т.д. наподобие вики
  3. Загрузка медиафайлов
  4. Отметки людей на фото и видео
  5. Автоматическое построение генеалогического дерева
  6. Календарь со всеми важными датами
  7. Инструменты для совместного редактирования и заливки

Справедливости ради, мне удалось найти несколько проектов с self-hosted-реализацией, но они были в плачевном состоянии: внешний вид застыл на уровне середины двухтысячных, полного набора нужного функционала нет, а копаться в легаси-скриптах на PHP не хотелось. Кроме того, предыдущий pet project закончился и было желание взяться за что-то новое.

Золотое правило гласит: хочешь сделать хорошо — сделай это сам!

Вот что получилось в результате: Используемые технологии выбирались по трем критериям: мой опыт работы с ними, популярность и открытость-бесплатность.

  • Рантайм: .NET Core 2.1
  • Бэкенд: ASP.NET Core MVC
  • База данных: PostgreSQL
  • Логика фронтенда: частично Vue, частично jQuery.
  • Стили фронтенда: Bootstrap + Sass

Во второстепенных ролях — Elasticsearch для полнотекстового поиска и ffmpeg для получения скриншотов из видео.

Схема данных

Основными объектами в базе Bonsai являются страница и медиа-файл. Они связаны соотношением «много ко многим» через отметки. Отметка может иметь заголовок без ссылки — например, если нужно отметить кого-то на фотографии, но информации на полноценную страницу о нем нет.

По фактам вычисляются дополнительные данные: например, если указать дату рождения человека, то она отметится в календаре, а на его странице будет указан текущий возраст (или продолжительность жизни, если дата смерти тоже указана), по полу можно определить корректное название отношений («отец» или «мать» вместо общего «родители»), и так далее. Помимо текста в свободной форме на странице могут быть указаны факты, которые вводятся в специальные поля в админке. В базе факты хранятся как JSON-документ.

От типа страницы зависит список доступных фактов: например, «образование» актуально только для человека, «дата рождения» — для человека и животного, а «адрес» — только для места. На выбор доступно пять типов страниц: человек, питомец, событие, место и «прочее».

Некоторые отношения могут быть ограничены по времени (супруг, владелец, житель), другие считаются постоянными. Страницы связаны между собой отношениями: «родитель», «супруг», «друг», «владелец», «житель» и многими другими.

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

Это позволяет хранить историю правок и при необходимости их откатывать. Редактирование страницы, медиа-файла или отношения сохраняет в базу изменение.

Отношения

Родственные отношения — одна из самых древних концепций в обществе. Уже в праиндоевропейском языке существовало множество названий для них, которые в несколько измененном виде перекочевали в современные языки самых разных групп: слово «мама» поймет и русский, и англичанин, и китаец.

Они позволяют построить из семьи направленный граф, где эти отношения являются ребрами, а люди — узлами. Вариантов родственных отношений очень много, однако базовыми из них являются три: родитель, ребенок и супруг. На таком графе можно выразить любое другое отношение, зная путь между участникам и их пол: например, чтобы определить чьего-то дедушку, нужно найти сначала его родителя (любого пола), а потом родителя этого родителя (мужского пола), и так далее.

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

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

public static RelationDefinition[] ParentRelations =
{ new RelationDefinition("Parent:m", "Отец"), new RelationDefinition("Parent:f", "Мать"), new RelationDefinition("Parent Child:m", "Брат", "Братья"), new RelationDefinition("Parent Child:f", "Сестра", "Сестры"), new RelationDefinition("Parent Parent:m", "Дедушка", "Дедушки"), new RelationDefinition("Parent Parent:f", "Бабушка", "Бабушки")
};

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

  1. Ближайшее кровное родство — семья, в которой человек вырос: мать и отец, бабушки и дедушки, братья и сестры. Если смотреть на граф, то это путь на 1-2 шага вверх и на 1 в стороны.
  2. Собственная семья: по одной группе на каждого супруга и детей от него. Сюда также попадают родственники супруга — тещи, шурины и тому подобное.
  3. Прочее: более дальние родственники (внуки, дяди-тёти) и не-родственные связи (друзья, коллеги).

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

Для таких случаев может быть указано несколько путей, которые должны существовать или не существовать, и они помечаются знаками + и - соответственно: Как мы видим, для Александра указаны две жены (Вера и Галина) и сын (Борис), но мы не знаем, кто из жен является матерью ребенка — может быть, это вообще какая-то третья женщина, но она пока не добавлена.

new RelationDefinition("Spouse Child+Child", "Сын|Дочь|Ребенок", "Дети")
new RelationDefinition("Spouse Child-Child:m", "Пасынок")
new RelationDefinition("Spouse Child-Child:f", "Падчерица")

Генеалогическое древо

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

Вот несколько примеров того, как могут выглядеть генеалогические деревья:

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

Графическое представление генерируется из специального markdown-синтаксиса, в котором все равно нужно вручную расставить все блоки и прочертить связи между ними. Греческие боги. Немного похоже на ASCII-art.

Легко генерируется автоматически, но учитывает только прямых предков. Представление дерева в виде полукруговой диаграммы.

Самый эстетически приятный был на сайте MyHeritage: Я просмотрел множество вариантов.

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

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

Пожалуй, это первое, что приходит в голову при необходимости нарисовать график или диаграмму на веб-странице. Больше всего надежд я возлагал на библиотеку D3.js. Увы, среди более трех сотен (!) примеров на wiki не оказалось ни одного более-менее похожего на дерево с MyHeritage.

Большинство из них предлагают так называемый Force layout. Следующим шагом было погружение в библиотеки, занимающиеся не отрисовкой, а именно вычислением оптимального расположения элементов в графе. Его можно легко узнать по характерной анимации — граф как бы «расправляется» на ходу, и это не дополнительная фича, а неизбежное следствие симуляционной природы алгоритма. Это очень простой подход, который основан на физических формулах: узлы графа представляются упругими телами, а соединительные линии — пружинами. Подход force-layout хорош для визуализации данных без четкой иерархи (например, связей в соцсетях), но фамильное древо в таком виде выглядит ущербно.

Результат ее работы можно легко узнать по характерным стрелкам. Еще один рассмотренный вариант — библиотека Graphviz. Тестовые примеры выглядят еще более-менее, а вот с реальными данными возникают проблемы: стрелки «заламываются» и соединяются под странными углами, граф расползается, и это уже никак не настроишь и не обойдешь. Для описания графа используется специальный язык DOT.

Не найдя подходящего решения самостоятельно, я решил заказать его на фрилансе, и тут началась та самая ДРАМА.

Одного из откликнувшихся звали Владислав; он прислал пример аналогичного решения и пообещал сделать задачу за один день. Заказ был размещен утром 22 октября и уже за час на него поступило несколько откликов. Первые пару дней Владислав задавал дополнительные вопросы, не переставая приятно удивлять глубокой погруженностью в проект и внимательным отношением к деталям, а потом пропал. Такая скорость показалась мне подозрительной, но я понадеялся на его опыт и про себя дал парню погрешность в неделю. Очнулся он 1 ноября, извинился за вынужденное исчезновение по семейным обстоятельствам и прислал ссылку с бета-версией, которая выглядела довольно похоже на желаемое, если бы не узел в соединительных линиях в центре:

Пусть продолжает! Исчезновение исполнителя — всегда тревожный звонок, но мало ли, всякое бывает, ведь сделал же что-то. Через пару дней Владислав написал, что исправить проблему он не может, а потом пропал снова — на этот раз на три недели. Я отправил предоплату и стал ждать доработок. После пары уточняющих вопросов горе-делегатор перестал пытаться отмазываться и просто замолчал. За это время он ничего не сделал и предоплату возвращать отказался, потому что «задачу на самом деле делал глупый бывший друг, который подвел его и деньги не отдает». Владиславу я желаю успехов в бизнесе и побыстрее разбогатеть! Так теперь и живем — периодически я напоминаю ему про долг, а он в ответ присылает скриншот из банковского приложения — дескать, «денег нет, но как только — так сразу».

Кинул пацана — минус в карму на!

В первую очередь я был зол на самого себя: пошел по пути наименьшего сопротивления, нарушил золотое правило — и вот результат. Потерять деньги было не так досадно, но прошел целый месяц, а задача не сдвинулась с мертвой точки, и ждать помощи теперь было неоткуда. — внезапно нашел именно то, что нужно. Исполнившись праведным гневом, я повторно засел за изучение библиотек для отрисовки графов и — о чудо!

Как можно догадаться, она применяется для отображения диаграмм в IDE Eclipse, но может быть использована и автономно. Библиотека называлась Eclipse Layout Kernel, сокращенно ELK. Да, ее код кошмарен и весит полтора мегабайта, но эти недостатки можно простить за то, что она просто работает и делает именно то и именно так, как нужно. Вообще она написана на Java, но существует версия, транслированная в JS. Отрисовать дерево по ним можно любым удобным способом: я выбрал SVG для соединительных линий и div'ы с абсолютным позиционированием для блоков. Интерфейс элементарный: на вход передаются узлы, ребра и настройки, а на выходе получаем координаты.

Это, конечно, не «одни сутки», как обещал мой незадачливый и самонадеянный фрилансер, но довольно близко. Интеграция библиотеки и подбор оптимальных настроек занял от силы два вечера. В результате Bonsai смог отображать дерево примерно в таком виде:

ELK использует итеративный алгоритм: можно получить более близкое к оптимальному размещение, потратив дополнительное время. Теперь осталась единственная проблема — время обработки. Из-за этого страница с деревом каждый раз открывается долго, и это быстро начинает раздражать. На дереве в 20-30 элементов хороший результат требует порядка 5 секунд. К следующей версии вычисление будет перенесено на бэкенд, чтобы его можно было сделать один раз при изменении страницы и закешировать.

Полнотекстовый поиск

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

SELECT to_tsvector('Проверки') @@ to_tsquery('Проверка'), -- true to_tsvector('Иванов') @@ to_tsquery('Иван'), -- false to_tsvector('Иванова') @@ to_tsquery('Иван'), -- false to_tsvector('Иванова') @@ to_tsquery('Иванов'), -- false to_tsvector('Иванов Иван Иванович') @@ to_tsquery('Иванова') -- false

Триграммы тоже не дали ничего хорошего. В итоге я остановился на довольно ожидаемом варианте: ElasticSearch + Russian Morphology. Работать с ним из .NET оказалось очень неудобно, однако с поиском по ФИО он справляется на твердую пятерку.

Сознательное несовершенство

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

Вот например, что происходит, когда мы открываем любую страницу?

  1. Текст страницы компилируется из Markdown в HTML. Если в тексте есть ссылки на другие страницы и медиафайлы, придется сходить за дополнительной информацией в базу.
  2. Факты десериализуются из JSON, в котором они хранятся в базе, во вьюмодели.
  3. Определяются родственные связи. Для этого из многострадальной базы необходимо достать весь граф связей и найти в нем узлы по заранее известному списку путей.

На первый взгляд кажется, что это ужасно тяжелая операция, но на самом деле это не так из-за относительно малого объема данных. Сколько родственников вы можете вспомнить и захотите записать? Попробуйте ради интереса их пересчитать и обнаружите, что набрать хотя бы сотню будет очень сложно. А скольким людям захотите дать доступ? Даже астрономически большое для семьи число — тысяча человек! — по меркам современных баз данных остается смехотворным.

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

Все это можно было бы реализовать или улучшить, но оно не решило бы никакой проблемы, а следовательно время было бы потрачено зря. Проект не поддерживает локализацию и смену внешнего вида, авторизация работает по OAuth на Facebook\Google, а админка сделана на обычных формах, а не каком-нибудь SPA-фреймворке по последнему писку моды.

С надеждой в будущее

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

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

От данной идеи пришлось отказаться, потому что она потребовала бы вместо обычного текста заносить всю информацию в неком формализованном виде, и только описание этого вида заняло бы годы. На самых первых этапах проектирования мне хотелось встроить в движок некий SQL-подобный язык, с помощью которого можно было бы получать ответы на конкретные вопросы: «какой процент моих предков с голубыми глазами», «когда Иван купил первый автомобиль» и так далее. Не удивлюсь, если через десяток-другой лет можно будет попросить Siri прочитать текст за тебя, походить по ссылкам и в итоге представить выжимку из фактов. С другой стороны, Natural Language Understanding сейчас набирает обороты. Ребята, поднажмите!

Как попробовать?

К сожалению, я не могу предоставить ссылку на готовую демку: нет сервера, который бы выдержал хабраэффект. Зато есть несколько наглядных скриншотов (картинки кликабельны).

Если Bonsai показался вам полезным и вы захотите запустить его самостоятельно, исходный код можно скачать с Гитхаба:

https://github.com/impworks/bonsai

Вам понадобится вот что: Подробная инструкция по установке указана в Readme.

  1. .NET Core 2.1+
  2. PostgreSQL 10+
  3. ElasticSearch 5.x и плагин Russian Morphology
  4. Приложение в Facebook или Google для авторизации по oAuth

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

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


Оставить комментарий

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

Интересные факты об истории Китайской лунной программы и космической миссии «Чанъэ-4»

Многое скрыто за заборами полигонов и стенами лабораторий Китайской академии космических технологий при реализации лунных научно-исследовательских космических программ, но часть информации потом все равно любезно предоставляется в открытый доступ.В продолжении этой публикации. Ранее опубликованные материалы о «Чанъэ-4»: Краткая Китайская история ...

Путеводитель по программе JPoint 2019

Благо, всего месяц остался до JPoint 2019 — международной Java-конференции, которая пройдёт в начале апреля в Москве. Последний месяц зимы подходит к концу, и просыпается здоровое желание сходить на какую-нибудь большую Java-конференцию. Программа почти стабилизировалась, и настало время раскрыть все ...