Управление вёрсткой в PlantUML

Вступление

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

Как следствие, иногда получаются не самые эстетически привлекательные диаграммы, а поиск ответа на вопрос, как повлиять на автоматическую расстановку элементов, заводит в тупик. Это происходит из-за того, что доступной информации об управлении вёрсткой в PlantUML практически нет, а эксперименты с расстановкой не всегда дают устойчивый эффект.

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

1. Базовые сведения

Начнём с общей информации и базовых подходов к управлению расположением элементов на диаграмме.

1.1. Направление и длина стрелок

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

  • вправо: -r->, -right-> или просто ->

  • вниз: -d->, -down-> или просто -->

  • влево: -l->, -left->

  • вверх: -u->, -up->

Соответственно, когда вы применяете этот синтаксис, то ожидаете, что верх будет верхом, низ низом и т.д.

@startuml
"Актор" -left-> (лево)
"Актор" -right-> (право)
"Актор" -up-> (верх)
"Актор" -down-> (низ)
@enduml

Ожидаемое направление это, когда право справа

Ожидаемое направление это, когда право справа

Однако ожидания иногда не оправдываются. Если вы указали инструкцию left to right direction, рассматриваемую в следующих разделах более детально, то происходит следующее: смысл подсказок «верх» и «лево» меняется местами, «низ» и «право», в свою очередь, также меняются местами. Соответственно, все приведённые выше рассуждения надо как бы перевернуть (к примеру, --> станет указывать вправо, а не вниз).

@startuml
left to right direction
"Актор" -left-> (лево)
"Актор" -right-> (право)
"Актор" -up-> (верх)
"Актор" -down-> (низ)
@enduml

Иллюстрация изменения направлений при left to right direction

Иллюстрация изменения направлений при left to right direction

Это поведение трудно назвать очевидным для начинающего пользователя PlantUML, но далее будет дано объяснение произошедшему. Пока же можно использовать следующую аналогию: ориентация листа формата A4 может быть книжной (короткая сторона в верхней части) или альбомной (длинная сторона в верхней части), и если лист повернуть на 90°, то ранее использованные направления на изображении изменяются.

Другой важный момент, о котором стоит сказать, состоит в том, что PlantUML позволяет задавать длину стрелки. Это делается с помощью добавления дополнительных дефисов. Однако, если посмотреть на приведённое в самом начале описание, то можно увидеть, что 1 дефис используется для направления вправо, а 2 дефиса — для направления вниз. Как же тогда это работает? Ответ в том, что удлинение стрелки возможно только в одном направлении. Если стрелка имеет 2 дефиса, то добавление третьего и последующих не изменит её направление, а только длину. Таким образом, вместо --> можно поставить --->, ---->, ------> и т.д.).

1.2. Направление вёрстки

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

@startuml
"Актор" -left-> (лево)
"Актор" -right-> (право)
"Актор" -up-> (верх)
"Актор" -down-> (низ)
@enduml

Однако если попробовать воспользоваться инструкцией left to right direction, то будет включена горизонтальная вёрстка. Зачем это может быть полезно? Затем что результат для некоторых видов диаграмм (например, для диаграмм вариантов использования) так будет выглядеть лучше и/или привычней. Вот сравните.

До:

@startuml
"Актор 1" --> (ВИ 1)
"Актор 2" --> (ВИ 2)
@enduml

После:

@startuml
left to right direction
"Актор 1" --> (ВИ 1)
"Актор 2" --> (ВИ 2)
@enduml

До и после: вертикальная и горизонтальная вёрстка

До и после: вертикальная и горизонтальная вёрстка

Что ещё. Как уже отмечалось выше, изменение направления вёрстки приводит к тому, что меняются местами понятия «верх» и «лево», а также «низ» и «право». Но это не единственное, о чём следует знать.

Существует также инструкция top to bottom direction (сверху вниз), и обычно её использовать не приходится, т.к. это и так значение по умолчанию для большинства поддерживаемых PlantUML диаграмм. Хотя для ментальных карт (Mind Map) умолчания другие, и в этом случае инструкция сработает, направив диаграмму по вертикали вниз вместо горизонтальной ориентации.

@startmindmap
skin rose
top to bottom direction
* Транспортное средство
** Автомобиль
***_ Легковой
***_ Грузовой
** Автобус
** Троллейбус
@endmindmap

До и после: вертикальная и горизонтальная вёрстка

Вертикальная вёрстка для Mind Map: непривычно, но можно

Иногда в коде примеров можно встретить skin rose. Для рассматриваемой темы это не существенно, это всего лишь способ добавить красок к иллюстрациям.

1.3. Порядок элементов

Если ранее мы в большей степени рассматривали вопрос задания направлений, то сейчас плавно переходим к тому, как повлиять на PlantUML, чтобы он отображал элементы в нужном нам виде. Ведь не секрет, что по мере добавления элементов они иногда «расползаются» по экрану. Самое просто в этом случае это попробовать изменить порядок объявления объектов в коде.

Так, в рассмотренном выше примере с вариантами использования второй вариант с горизонтальной ориентацией выглядит точно лучше, чем первый вариант, но в нём непривычно то, что Актор 2 и ВИ 2 «забрались» наверх. Это можно исправить, если в коде поменять местами строчки 3 и 4. Должно выйти так.

@startuml
left to right direction
"Актор 2" --> (ВИ 2)
"Актор 1" --> (ВИ 1)
@enduml

При горизонтальной вёрстке пришлось переставить местами 2 строки

При горизонтальной вёрстке пришлось переставить местами 2 строки

Из этого следует вывод: порядок объявления объектов имеет значение. И этим можно пользоваться.

1.4. Группировка элементов

Идея, заложенная в этом подходе, проста: если объекты на диаграмме сгруппировать, то движок PlantUML будет стараться их расположить на экране ближе друг к другу.

Группировать объекты можно разными способами. Например, можно использовать:

  • пакеты — ключевое слово package. Так поступают обычно в PDF-версии документации к PlantUML, но с точки зрения синтаксиса языка UML это не всегда уместно;

  • прямоугольники — ключевое слово rectangle. Этот способ выглядит как самый подходящий для группировки вариантов использования («эллипсов») в диаграммах вариантов использования;

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

Рассмотрим один из примеров из стандартной документации по PlantUML.

@startuml
skin rose
left to right direction
actor "Food Critic" as fc
package Restaurant {
  usecase "Eat Food" as UC1
  usecase "Pay for Food" as UC2
  usecase "Drink" as UC3
}
fc --> UC1
fc --> UC2
fc --> UC3
@enduml

Выполнение группировки с помощью графического изображения пакета

Выполнение группировки с помощью графического изображения пакета

Если в приведённый пример добавить инструкцию skinparam packageStyle rectangle, то вместо символа пакета получим рамку системы, как того и требует спецификация UML. Но я лично предпочитаю другой подход: вместо добавления skinparam packageStyle rectangle достаточно заменить ключевое слово package на rectangle. Внешне результат получится таким же, но без дополнительных ухищрений. Да и вообще, рамка, группирующая варианты использования, в UML символизирует систему, а не пакет.

@startuml
skin rose
left to right direction
actor "Food Critic" as fc
rectangle Restaurant {
  usecase "Eat Food" as UC1
  usecase "Pay for Food" as UC2
  usecase "Drink" as UC3
}
fc --> UC1
fc --> UC2
fc --> UC3
@enduml

Выполнение группировки с помощью прямоугольника или пакета в стиле прямоугольника

Выполнение группировки с помощью прямоугольника или пакета в стиле прямоугольника

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

@startuml
skin rose
left to right direction
actor "Food Critic" as fc
together {
  usecase "Eat Food" as UC1
  usecase "Pay for Food" as UC2
  usecase "Drink" as UC3
}
fc --> UC1
fc --> UC2
fc --> UC3
@enduml

Выполнение группировки без дополнительного визуального выделения

Выполнение группировки без дополнительного визуального выделения

При рассмотрении первых двух способов всегда использовалось название группирующего элемента (package Restaurant и rectangle Restaurant) для обозначения имени моделируемой системы, а в третьем способе его не было (было просто together). В действительности же PlantUML позволяет убрать название и в первых двух случаях (указав просто package или rectangle), а вот добавить название при третьем способе уже не получится.

2. Погружение в механику

По умолчанию движок PlantUML пытается расположить все объекты на минимальном пространстве с минимальным числом пересечений линий. Но жизнь сильно разнообразней, чем самый изощрённый алгоритм, поэтому иногда результат расстановки может не отвечать нашим ожиданиям.

Чтобы победить проблему, важно понимать внутреннюю механику, а значит и первопричины того, почему что-то работает так, а не иначе. И тогда уже добиться результата будет проще. Начнём издалека, с математики.

2.1. Теория графов

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

  • Граф — это совокупность вершин (узлов) и рёбер (дуг), соединяющих эти вершины.

  • Диграф (ориентированный граф, или попросту орграф) — это граф, состоящий из множества вершин, соединённых направленными рёбрами.

  • Взвешенный граф — граф, каждому ребру которого поставлено в соответствие некое значение (вес ребра).

Пример диграфа с тремя вершинами

Пример диграфа с тремя вершинами

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

Это обстоятельство позволило разработчикам PlantUML задействовать «под капотом» графическую библиотеку GraphViz для отрисовки объектов/элементов своих диаграмм.

2.2. Библиотека GraphViz

GraphViz является opensource-решением, позволяющим создавать различные графы с возможностью влиять на расположение вершин и рёбер (более подробно здесь). В GraphViz по умолчанию используется вертикальная вёрстка, но возможно использование и горизонтальной (rankdir=LR). По своему смыслу оба варианта вёрстки являются взаимоисключающими.

Одним из центральных понятий реализованного в библиотеке подхода является понятие ранга (rank). Ранг присваивается каждой вершине и в зависимости от его величины осуществляется упорядочение этих вершин на экране (условной координатной плоскости). Механика назначения рангов сложна: ранг вершины рассчитывается на основе рангов соседних вершин и весов рёбер, входящих в эту вершину. Детально с алгоритмом можно познакомиться в публикации, но для пояснения логики представленных соображений уже будет достаточно.

Одинаковый ранг у вершин в практическом плане означает, что эти вершины будут расположены на одной линии — на одной горизонтальной линии (условной координате y) при вертикальной вёрстке или на одной вертикальной линии (условной координате x) при горизонтальной вёрстке.

Если ранги вершин различаются, то логика следующая. При вертикальной вёрстке вершины с более высоким рангом располагаются ниже, чем вершины с более низким рангом (т.е. у них значение координаты y больше); при горизонтальной вёрстке вершины с более высоким рангом располагаются правее, чем вершины с более низким рангом (т.е. у них значение координаты x больше).

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

Принцип увеличения ранга при движении по условной координатной плоскости

Принцип увеличения ранга при движении по условной координатной плоскости

В типовой ситуации вершине присваивается более высокий ранг, чем у всех вершин, указывающих рёбрами на него. Пример: если на вершину A3 указывают рёбра, исходящие из вершин A1 и A2, причём у вершины A1 ранг=2, а у вершины A2 ранг=10, то в общем случае вершине A3 будет присвоен ранг больший, чем max {rank (A1), rank (A2)} = max {2, 10} = 10. Это может быть 11, 12 и т.д. Конкретная величина будет определяться ещё рядом других факторов, в частности весом рёбер: чем больший вес присвоен рёбрам, входящим в вершину, тем больший ранг будет присвоен вершине A3.

В соответствии с правилом выше можно утверждать, что самой близкой к началу вершиной будет A1, а самой дальней — A3.

Но здесь есть 2 нюанса:

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

  • если несколько вершин имеет одинаковый ранг, то, как отмечалось выше, они обычно выстраиваются в одну строку (или столбец — в зависимости от направления вёрстки). Но это не всё. Вершины по умолчанию выстраиваются в порядке объявления их в коде, при этом GraphViz позволяет вручную переопределить взаимное расположение вершин одного ранга.

2.3. А что с PlantUML?

Приведённые выше сведения позволяют по-новому взглянуть на поведение PlantUML и привычные нам операции. Имеющиеся у PlantUML возможности и ограничения часто проистекают из использования GraphViz. К примеру, при рассмотрении управления длиной стрелок говорилось, что:

  • этот механизм работает только по вертикали или горизонтали (в зависимости от направления вёрстки). А это всё потому, что только одна из двух координат на плоскости в GraphViz используется для ранжирования узлов. Вторая координата содержит объекты одного ранга;

  • количество дефисов (или точек, если мы хотим сделать пунктирную линию вместо сплошной) в стрелке/линии позволяет задать длину. Это оттого, что каждый дефис, начиная со второго, на самом деле увеличивает вес ребра графа (стрелки или линии) на единицу (см. здесь). Но это только если он соответствует направлению вёрстки. Вес ребра, в свою очередь, влияет на то, какой ранг присвоить вершине графа (прямоугольнику класса на диаграмме классов, эллипсу варианта использования на диаграмме ВИ и пр.), на которую указывает ребро. А чем ранг выше, тем дальше на диаграмме эта вершина будет расположена.

Иными словами, удалённость объектов (вершин) это величина производная от весов. Веса, при этом, позволяют располагать элементы более предсказуемо.

Важно также отметить, что согласно документации не все диаграммы в PlantUML строятся на основе GraphViz, поэтому в ряде случаев приведённые соображения не будут работать. Так для каких видом диаграмм это точно работает? Список следующий (взято отсюда):

Означает ли это, что для других диаграмм приведённые соображения вообще не актуальны? Видится, что нет. Причина в том, что мы как потребители оперируем инструкциями PlantUML, которые уже в том или ином случае транслируются в вызовы GraphViz. Но иногда эти инструкции обеспечивают тот же результат, но без обращения к GraphViz. Можно сказать, что мы оперируем контрактом PlantUML, а реализация в каждом конкретном случае может варьироваться, приводя, при этом, к ожидаемому эффекту.

Напомню, ранее в п.1.2 был рассмотрен пример, когда направление вёрстки изменялось в Mind Map, притом что в этом виде диаграмм GraphViz не используется.

2.4. Разбор на примере

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

@startuml
class Первый
class Второй
@enduml

Поэтому, когда вы добавляете class Третий расположение классов претерпевает изменение. И так постепенно доходим до 6 классов.

@startuml
class Первый
class Второй
class Третий
class Четвёртый
class Пятый
class Шестой
@enduml

Автоматическое расположение классов в зависимости от их числа

Автоматическое расположение классов в зависимости от их числа

Появление второй строки с классами говорит о том, что PlantUML «жонглирует» рангами классов (вершинами графа), чтобы разместить их более компактно на экране. Но мы из своих соображений можем хотеть другое расположение.

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

Если мы хотим, чтобы «Пятый» был ниже «Второго», да ещё и на 2 строки ниже, то надо не просто добавить связь с направлением вниз (-down-), но и увеличить вес этой связи (ребра графа) на дополнительную единицу (т.е. нужно обеспечить +2 ранга). Это делается через дополнительный дефис: Второй --down- Пятый. Так как для направлений вниз и вправо есть сокращённая запись, можно было бы написать и так: Второй --- Пятый.

Начинаем расставлять классы

Начинаем расставлять классы

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

Класс можно «втиснуть» между двумя другими

Класс можно «втиснуть» между двумя другими

Далее мы можем решить разместить «Шестой» над «Третьим» и связать их отношением зависимости. Для этого понадобится пунктирная линия со стрелкой. Это делается простым добавлением инструкции: Третий .up.> Шестой. После всех этих действий «Шестой» и «Четвёртый» находятся в самом верху, а «Пятый» в самом низу диаграммы.

Допустим мы хотим сделать связь «Четвёртого» и «Пятого». Если мы напишем так: Четвёртый --> Пятый, то это укажет, что «Пятый» должен находиться на 1 строку ниже относительно «Четвёртого» (разница в 1 ранг обеспечивается вторым дефисом). Но у нас «Четвёртый» явно не был ни с кем связан (т.е. его расположение никак не фиксировалось), а «Пятый» ранее был намеренно смещён на 2 строки ниже основной группы классов. В совокупности это приведёт к тому, что «Четвёртый» изменит своё положение, сместившись вниз на 2 строки, дабы оказаться на расстоянии 1 строки от «Пятого». Но если мы не хотим изменения его положения, то достаточно указать опцию[norank], которую можно интерпретировать как «исключи эту связь из расчёта рангов, оставь эти объекты в покое». Получаем: Четвёртый --[norank]> Пятый.

В результате всех манипуляций должно получиться так.

@startuml
class Первый
class Второй
class Третий
class Четвёртый
class Пятый
class Шестой
Первый -left- Второй
Второй --down- Пятый
Второй - Третий
Третий .up.> Шестой
Четвёртый --[norank]> Пятый
@enduml

А теперь задумаемся. Мы ранее управляли взаимным расположением классов, прибегая к указанию связей (линий, стрелок, пунктиров и пр. доступных вариантов). Связи получились искусственными, они могут не отражать тех логических связей, которые мы бы реально хотели представить на диаграмме. В этом случае нам нужно скрыть лишние связи. Для простоты допустим, что лишняя у нас только одна связь, это связь между «Первым» и «Вторым» классами. Значит добавляем к ней опцию [hidden] и получаем: Первый -left[hidden]- Второй. После этого линия исчезнет, но расположение классов никак не изменится, чего и требовалось.

До и после добавления [hidden]

До и после добавления [hidden]

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

@startuml
class Первый
class Второй
class Третий
class Четвёртый
class Пятый
class Шестой
Первый -left[hidden]- Второй
Второй "+Владелец \t1" --down- "+Вещь \t*" Пятый : "Владеет >"
Второй "Конец1\n0..1" x-> "+Конец2\n2..*" Третий : "\t\t\t\t"
Третий .up.> Шестой
Четвёртый --[norank]> Пятый
@enduml

Демонстрация дополнительных элементов на диаграмме классов

Демонстрация дополнительных элементов на диаграмме классов


Вдаваться в подробности того, что означает тот или иной символ здесь не буду (лучше обратиться к описанию языка UML), но на что хочу обратить внимание. При горизонтальных связях пришлось прибегнуть к символу перевода строки \n, а при вертикальных связях — к символу табуляции \t. За счёт этого получилось разрядить представленную информацию, чтобы она не сливалась друг с другом и была визуально различима. Можете попробовать убрать эти символы и оценить изменения.

Подход можно ещё немного улучшить, если использовать инструкции !skinparam nodesep X и !skinparam ranksep Y, где X и Y — целые числа. Первая инструкция устанавливает расстояние между вершинами одного ранга, вторая — между вершинами разных рангов. При вертикальной вёрстке (умолчательное значение для диаграммы классов) эти параметры определяют расстояние между графическими изображениями классов по горизонтали (ось x) и вертикали (ось y) соответственно. Эти инструкции оказываются особенно полезными, когда текст связей на диаграмме наезжает друг на друга (с этим порой автоматика не справляется) либо расположенные близко элементы смотрятся не очень опрятно.

Резюме

PlantUML для рендеринга ряда диаграмм использует GraphViz. В таких ситуациях для управления расположением объектов (классов, пакетов и др.) надо придерживаться правил.

  • Объявляйте объекты в том порядке, в каком нужно, чтобы они отрисовывались. Но, если порядок не устраивает, можно переставить строки местами.

  • Используйте направление связей/стрелок для позиционирования объекта относительно других.

  • Помните, что направление вёрстки влияет на то, будет ли возможность задавать степень отдаления объектов (за счёт добавления дефисов/точек), и куда на самом деле указывают стрелки.

  • Прячьте с помощью [hidden] те связи, которые не несут семантики, а лишь вводились на диаграмму для расстановки объектов. При этой учитывайте, что такие связи будут участвовать в расчёте рангов.

  • Используйте [norank] для связей, которые не должны участвовать в расчёте ранга.

  • Добавляйте пространство с помощью инструкций !skinparam nodesep X и !skinparam ranksep Y.

Представленные статья основана на 4-м разделе моей большой работы «Неочевидный PlantUML», которую я ранее опубликовал для своего телеграм-канала. Если интересуют другие полезные приёмы, рекомендую обратиться к первоисточнику.

© Habrahabr.ru