Статьи

Разработка DSL для описания архитектуры программного обеспечения, часть 1

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

Проблемы начинаются, когда дело доходит до описания вашей архитектуры в формальной и обязательной форме. Вы можете написать хорошую статью в Вики, чтобы описать архитектуру вашей системы, или описать ее на слайде Powerpoint или с набором диаграмм UML; но это было бы совершенно бесполезно, поскольку невозможно автоматически проверить, соблюдается ли ваша архитектура кодом. И каждый, кто когда-либо работал над нетривиальным проектом с более чем двумя разработчиками, знает, что правила будут нарушены. Это приводит к постоянно растущему накоплению архитектурных долгов со всевозможными нежелательными побочными эффектами для долгосрочной устойчивости программного обеспечения. Вы также можете использовать Sonargraph 7 или аналогичные инструменты для создания графического представления вашего архитектурного проекта.Это уже намного лучше, потому что вы можете применять правила в ваших автоматических сборках или даже непосредственно в IDE. Но это также означает, что всем, кто хочет понять архитектуру, понадобится инструмент, чтобы увидеть ее. Вы также не сможете изменить архитектуру, не имея доступа к инструменту.

Было бы неплохо, если бы вы могли описать свою архитектуру как код, если бы у вас был DSL (предметно-ориентированный язык), который может использоваться архитекторами программного обеспечения для описания архитектуры системы и который достаточно выразителен и читабелен, чтобы каждый разработчик мог в состоянии это понять? Что ж, нам потребовалось некоторое время , чтобы придумать с этой идеей, но теперь я считаю , что это является недостающим куском головоломки , чтобы значительно увеличить принятие формализованных и осуществимых правил архитектуры программного обеспечения. Долгосрочные выгоды от их использования просто хороши, чтобы их игнорировать.

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

  1. Должна быть возможность описать архитектуру в наборе файлов. Некоторые из них должны быть достаточно общими, чтобы их можно было использовать во многих проектах, например, общий шаблон, описывающий многоуровневую систему.
  2. Должна быть возможность описать архитектуру в форме нескольких совершенно независимых аспектов. Например, один аспект описывает разделение на слои, другой аспект описывает компоненты, а третий аспект рассматривает разделение логики клиента и сервера.
  3. С другой стороны, язык также должен быть достаточно мощным, чтобы описать всю архитектуру в одном аспекте.
  4. DSL должен быть легким для чтения и легким в освоении.

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

Архитектура как код

Основные строительные блоки: компоненты и артефакты

Чтобы описать архитектуру формально, нам сначала нужно подумать об основных строительных блоках, которые мы могли бы использовать для описания архитектуры системы. Наименьшая единица дизайна — это то, что мы называем физическим компонентом (или просто компонентом в его краткой форме). Для большинства языков, таких как Java или C #, это будет просто один исходный файл, для других языков, таких как C или C ++, компонент представляет собой комбинацию файла заголовка с соответствующими исходными файлами, которые реализуют элементы, объявленные в заголовке. Чтобы определить архитектуру, вы должны сгруппировать связанные компоненты в архитектурные артефакты. Затем вы можете сгруппировать несколько таких артефактов в артефакты более высокого уровня и так далее. Для каждого артефакта вы также определяете, какие другие артефакты могут ими использоваться.

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

"Core/com/hello2morrow/Main"                          // Main.java in package com.hello2morrow
"External [Java]/[Unknown]/java/lang/reflect/Method"  // The Method class from java.lang.reflection
"NHibernate/Action/SimpleAction"                      // SimpleAction.cs in subfolder of NHibernate
"External [C#]/System/System/Uri"                     // An external class from System.dll

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

источник-имя модуля / отн-путь к проекту-корневой директории /

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

Внешнее [language] / jar-or-dll-if-present / rel-path-or-namespace / typename

Теперь мы можем использовать шаблоны для описания групп компонентов:

"Core/**/business/**"             // All components from the Core module with "business" in their name
"External*/*/java/lang/reflect/*" // All components in java.lang.reflect

Как вы можете видеть, один ‘*’ соответствует всему, кроме косой черты, ‘**’ соответствует границам косой черты. Вы также можете использовать «?» в качестве символа подстановки для одного символа.

Теперь мы можем построить наши первые артефакты:

artifact Business
{
    include "Core/**/business/**"
}

artifact Reflection
{
    include "External*/*/java/lang/reflect/*"
}

Мы сгруппировали все компоненты из модуля «Core» с «business» в их названии в артефакт под названием «Business». Классы отражения из среды выполнения Java теперь имеют собственный артефакт, называемый «Отражение». Артефакты также могут иметь фильтры «исключать». Они помогают вам описать содержание артефакта с помощью стратегии «все, кроме». Фильтры исключения всегда будут применяться после всех фильтров включения.

Интерфейсы и разъемы

Чтобы определить допустимые отношения между артефактами, полезно использовать некоторые простые и эффективные абстракции. Предположим, что у каждого артефакта есть по крайней мере один входящий и один исходящий именованный порт. Артефакты могут подключаться к другим артефактам, соединяя исходящий порт с входящим портом другого артефакта. Мы будем называть исходящие порты «Разъемы», а входящие порты — «Интерфейсы».   По умолчанию каждый артефакт всегда имеет неявный соединитель, называемый «default», и неявный интерфейс, также называемый «default».  Эти неявные порты всегда содержат все элементы, содержащиеся в артефакте, если они не были переопределены архитектором.

Давайте теперь соединим наши артефакты:

artifact Business
{
    include "Core/**/business/**"
    connect default to Reflection.default
}

artifact Reflection
{
    include "External*/*/java/lang/reflect/*"
}

Это позволит всем элементам, содержащимся в «Business», использовать все элементы, содержащиеся в «Reflection», подключив соединитель по умолчанию «Business» с интерфейсом по умолчанию «Reflection». В нашей архитектуре DSL вы также можете написать это короче:

artifact Business
{
    // ...
    connect to Reflection
}

// ...

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

подключить разъемИмя ? to interfaceList

Список интерфейсов представляет собой разделенный запятыми список интерфейсов для подключения. Разъем может быть опущен, в этом случае будет использоваться разъем по умолчанию.

Теперь давайте предположим, что мы не хотим, чтобы кто-либо использовал класс «Метод» артефакта отражения. Это может быть достигнуто путем переопределения интерфейса по умолчанию «Отражение»:

artifact Reflection
{
    include "External*/*/java/lang/reflect/*"

    interface default
    {
        include all
        exclude "**/Method"
    }
}

Это сделает невозможным доступ к классу Method вне артефакта «Отражение», поскольку он не является частью какого-либо интерфейса. Здесь мы использовали  фильтр include all, чтобы добавить все элементы «Reflection» в интерфейс. Затем, используя фильтр исключения, мы вынули «Метод» из набора доступных элементов интерфейса.

В большинстве случаев вам не нужно определять свои собственные разъемы. Это необходимо, только если вы хотите исключить определенные элементы артефакта использования из доступа к используемому артефакту. Использование более одного интерфейса с другой стороны может быть весьма полезным. Но для полноты давайте также определим соединитель в «Бизнесе»:

artifact Business
{
    include "Core/**/business/**"

    connector CanUseReflection
    {
        // Only include the controller classes in Business
        include "**/controller/**"
    }

    connect CanUseReflection to Reflection
}

// ...

Теперь только классы, имеющие «business» и «controller» в своем имени, смогут получить доступ к «Reflection».

Давайте сделаем что-то более продвинутое и предположим, что архитектор хочет убедиться, что «Отражение» может использоваться только из элементов уровня «Бизнес». Чтобы достичь этого, мы можем просто вложить «Отражение» в артефакт «Бизнес» и спрятать его от внешнего мира:

artifact Business
{
    include "Core/**/business/**"

    hidden artifact Reflection
    {
        // Need a strong pattern to bypass patterns defined by parent artifact 
        strong include "External*/*/java/lang/reflect/*"
    }
}

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

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

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

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

artifact Business
{
    include "Core/**/business/**"

    hidden artifact Reflection
    {
        // Need a strong pattern to bypass patterns defined by parent artifact 
        strong include "External*/*/java/lang/reflect/*"
    }

    interface Refl
    {
        export Reflection
    }
}

С помощью экспорта вы можете включать в интерфейс вложенные артефакты или интерфейсы вложенных артефактов. Теперь клиенты могут подключаться к «Business.Refl». Аналогом экспорта для соединителей является ключевое слово include . Он будет содержать вложенные артефакты или соединители из вложенных артефактов в соединителе.

В этом конкретном примере мы можем раскрыть «Отражение» еще проще:

artifact Business
{
    include "Core/**/business/**"

    exposed hidden artifact Reflection
    {
        // Need a strong pattern to bypass patterns defined by parent artifact 
        strong include "External*/*/java/lang/reflect/*"
    }
}

Теперь это выглядит немного странно на первый взгляд, не так ли — открыто и скрыто одновременно? Что ж, скрытый будет исключать «Отражение» из стандартного интерфейса «Бизнес», в то время как открытый делает его видимым для клиентов «Бизнес». Теперь клиенты могут подключаться к «Business.Reflection», который является ярлыком для «Business.Reflection.default». Если бы «Отражение» имело больше интерфейсов, они могли бы также подключиться к этим другим интерфейсам.

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

В конце этого поста давайте посмотрим на общую синтаксическую структуру артефактов, интерфейсов и соединителей:

artifact name
{
    // include and exclude filters
    // nested artifacts
    // interfaces and connectors
    // connections
}

interface iname
{
    // include and exclude filter
    // exported nested interfaces
}

connector cname
{
    // include and exclude filters
    // included nested connectors
}

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

Мы реализовали этот язык в 8.6 выпуске Sonargraph-Explorer . Мы находимся в процессе развертывания этого языка для всех других вариантов продукта Sonargraph. Вы можете поэкспериментировать с языком, получив бесплатную пробную лицензию Sonargraph-Explorer.

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