Чжан Цзяньфэй

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

Мы использовали конечный автомат для отслеживания частых переходов в недавнем проекте, потому что выразительность DSL конечного автомата способствует лучшему пониманию, чем операторы if-else. Более того, конечные автоматы легче внедрить и использовать, и они менее сложны, чем механизмы обработки.

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

Этот конечный автомат (cola-statemachine) был добавлен в Чистую объектно-ориентированную и многоуровневую архитектуру (COLA) и теперь имеет открытый исходный код:

  • Пока я работал над конечным автоматом, я прочитал книгу Мартина Фаулера Domain-Specific Languages, которая полностью изменила мое понимание DSL.
  • Именно поэтому я написал эту статью, чтобы предоставить вам новый угол обзора DSL и применения DSL и конечных автоматов.

DSL

Книга Domain-Specific Languages начинается с обсуждения конечных автоматов и постепенно переходит к более глубокому пониманию DSL. Я рекомендую эту книгу всем, кто интересуется DSL и конечными автоматами. В следующих разделах кратко излагаются основные положения книги.

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

Что такое ДСЛ?

"DSL – это инструмент, передовое преимущество которого заключается в его способности предоставлять средства для более четкой передачи информации о назначении части системы".

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

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

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

Рассмотрим это регулярное выражение:

/\d{3}-\d{3}-\d{4}/

Это типичный DSL, решающий проблемы сопоставления строк.

Категории DSL:

DSL можно разделить на три основные категории: внешние DSL, внутренние DSL и языковые рабочие места. Ниже приведены определения Мартина Фаулера:

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

builder.externalTransition()
  .from(States.STATE1)
  .to(States.STATE2)
  .on(Events.EVENT1)
  .when(checkCondition())
  .perform(doAction());

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

Language Workbench — это специализированная среда разработки для определения и создания DSL. Проще говоря, это продукт и визуализация DSL.

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

Как выбрать между DSL

Вы можете получить более четкое представление о том, какой тип DSL использовать, узнав, как они используются по-разному:

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

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

Сложности лучше избегать, но она также распространена на практике. Разработчики, работающие в крупных компаниях, не только пишут код, но также должны улучшать, расширять и внедрять инновации, чтобы предлагать самые современные технологии. Как сказал Нассим Николас Талеб в «Антихрупкости»:

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

Свободные интерфейсы

При написании программных библиотек мы сталкиваемся с двумя вариантами: один — предоставить API-интерфейсы команд-запросов, а другой — предоставить плавные интерфейсы. Например, API Мокито:

when(mockedList.get(anyInt())).thenReturn("element")

Это демонстрирует типичное использование плавного интерфейса.

Свободные интерфейсы являются важным средством реализации внутренних DSL.

Плавные интерфейсы улучшают читаемость и понятность кода, делая их внутренними DSL, которые выходят за рамки предоставления API.

Например, API Мокито:

when(mockedList.get(anyInt())).thenReturn("element")

Mockito образует отличную комбинацию с плавными интерфейсами, а также является DSL, используемым для модульного тестирования плавных интерфейсов. Если мы заменим свободный интерфейс API-интерфейсом команд-запросов, область среды тестирования будет представлена ​​не так четко.

String element = mockedList.get(anyInt());
boolean isExpected = "element".equals(element);

Примечание. Интерфейсы Fluent можно использовать в каскадных вызовах, таких как цепочка методов или шаблон построителя, например OkHttpClient.Builder():

OkHttpClient.Builder builder=new OkHttpClient.Builder();
  OkHttpClient okHttpClient=builder
    .readTimeout(5*1000, TimeUnit.SECONDS)
    .writeTimeout(5*1000, TimeUnit.SECONDS)
    .connectTimeout(5*1000, TimeUnit.SECONDS)

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

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

Конечные автоматы

В следующих разделах описывается, как реализовать внутренний конечный автомат DSL.

Выбор конечного автомата

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

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

Конечные автоматы с открытым исходным кодом слишком сложны

Как и в случае с механизмами обработки, существует довольно много конечных автоматов с открытым исходным кодом. Я проверил проекты двух лучших реализаций конечного автомата на GitHub, а именно Spring Statemachine и Squirrel State Machine. Обе они являются очень мощными фреймворками, но это также может быть и недостатком.

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

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

Конечные автоматы с открытым исходным кодом имеют низкую производительность

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

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

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

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

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

Реализовать конечный автомат

Модель предметной области конечного автомата

Как показано на следующей диаграмме, основные концепции нашего облегченного конечного автомата включают в себя:

  • Штат:штат
  • Событие: сущность, вызывающая изменение состояния.
  • Переход: переход от одного состояния к другому.
  • Внешний переход: переход, при котором происходит выход из исходного состояния и вход в целевое состояние.
  • Внутренний переход: переход, который выполняется без выхода или повторного входа в состояние, в котором он определен.
  • Условие: условие, которое разрешает или останавливает переход в определенное состояние.
  • Действие: поведение, выполняемое во время срабатывания перехода.
  • StateMachine: конечный автомат.

Следующая диаграмма иллюстрирует семантическую модель конечного автомата:

Примечание. Термин «семантическая модель» взят из книги Domain-Specific Languages. Вы можете думать об этом как о модели предметной области. Мартин использовал «семантический», чтобы указать, что сценарии DSL обозначают синтаксис, а модель — семантический. Я думаю, что это хороший выбор слова.

Ниже приведен основной код семантической модели конечного автомата:

//StateMachine
public class StateMachineImpl<S,E,C> implements StateMachine<S, E, C> {
  private String machineId;
  private final Map<S, State<S,E,C>> stateMap;
  ...
}
  //State
  public class StateImpl<S,E,C> implements State<S,E,C> {
    protected final S stateId;
    private Map<E, Transition<S, E,C>> transitions = new HashMap<>();
  ...
}
  //Transition
  public class TransitionImpl<S,E,C> implements Transition<S,E,C> {
    private State<S, E, C> source;
    private State<S, E, C> target;
    private E event;
    private Condition<C> condition;
    private Action<S,E,C> action;
    ...
}

Fluent API для создания конечного автомата

Я написал больше строк для компоновщика и плавного интерфейса, чем для основного кода. Ниже приведен код для TransitionBuilder:

class  TransitionBuilderImpl<S,E,C> implements ExternalTransitionBuilder<S,E,C>, InternalTransitionBuilder<S,E,C>, From<S,E,C>, On<S,E,C>, To<S,E,C> {    
  ...    
  @Override    
  public From<S, E, C> from(S stateId) {        
    source = StateHelper.getState(stateMap,stateId);        
    return this;    
  }
  @Override    
   public To<S, E, C> to(S stateId) {        
     target = StateHelper.getState(stateMap, stateId);        
     return this;    
  }   
  ...
}

Интерфейс Fluent обеспечивает последовательность вызовов, как показано на следующем рисунке, в которой после externalTransition можно вызывать только from(), а после from() можно вызывать только to(). Таким образом, семантическая правильность и непротиворечивость конечного автомата могут быть гарантированы.

Безгосударственный дизайн государственной машины

В этом разделе представлено решение проблемы с производительностью: сделать конечный автомат без состояния.

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

Можем ли мы обойтись без этих двух состояний? Конечно, можем.

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

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

Используйте конечный автомат

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

StateMachineBuilder<States, Events, Context> builder = StateMachineBuilderFactory.create();
  //external transition  
  builder.externalTransition()    
    .from(States.STATE1)    
    .to(States.STATE2)    
    .on(Events.EVENT1)    
     .when(checkCondition())    
     .perform(doAction());
   //internal transition  
  builder.internalTransition()    
     .within(States.STATE2)    
    .on(Events.INTERNAL_EVENT)    
    .when(checkCondition())    
    .perform(doAction());
  //external transitions  
  builder.externalTransitions()    
    .fromAmong(States.STATE1, States.STATE2, States.STATE3)    
    .to(States.STATE4)    
    .on(Events.EVENT4)    
    .when(checkCondition())    
     .perform(doAction());
    
  builder.build(machineId);

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

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

Оригинальный источник: