image

В Тинькофф для разработки систем автоматизации бизнес-процессов мы используем фреймворк Camunda + Spring. Сами бизнес-процессы описываем с помощью BPMN (Business Process Management Notation) в виде блок-схем.

Наиболее часто используемый элемент на наших схемах — service tasks (прямоугольник с шестеренкой). Camunda поддерживает два способа выполнения service tasks:

  1. С помощью синхронного вызова java-кода.
  2. Создание external task.

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

image
Пример BPMN-схемы

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

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

External task in BPMN


External task подразумевает создание задачи, которая может быть выполнена внешним обработчиком. Суть паттерна external task заключается в том, что:

  1. Процесс, который «заказывает» выполнение задачи, просто добавляет в БД свой «заказ».
  2. Некий абстрактный обработчик запрашивает у camunda задачи на обработку, при этом закрепляя задачу за собой так, чтобы ее не мог выполнить другой обработчик.
  3. После выполнения задачи обработчик сообщает camunda результат выполнения (успешный/неуспешный).

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

Я хочу, чтобы мое приложение на camunda отвечало только за бизнес-процессы, а email-рассылками занималось любое другое приложение. В таком случае мне отлично подходит паттерн external task. В своем процессе я просто создам задачу на имейл-рассылку и буду ждать, когда ее выполнит какой-нибудь внешний обработчик.

Чтобы на схеме создать external task, необходимо:

  1. Создать обычный task.
  2. Поменять его тип на service task.
  3. Установить implementation на external.
  4. Указать значение поля Topic.

image

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

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

External tasks worker


Паттерн external task хорош тем, что он позволяет реализовывать обработку задач на любом языке, с помощью любых инструментов, которые могут выполнять HTTP-запросы.

Ниже приведен пример из блога camunda. В примере реализован внешний обработчик на javascript, который раз в 20 секунд запрашивает у camunda список задач на обработку. Если есть задачи, то выполняет по ним рассылку и уведомляет camunda о завершении задачи.

const baseUrl = 'http://localhost:8080/my-app/rest';
const workerSettings = {
 workerId: 'worker01', // some unique name for the current worker instance
 maxTasks: 5,
 topics: [
   {
     topicName: 'sendEmail',
     lockDuration: 10000, // How much time the worker thinks he needs to process the task
     variables: ['video'] // Which variables should be returned in the response (to avoid additional REST calls to read data)
   }]};
const requestParams = {method: 'POST', headers: {contentType: 'application/json'}};

function pollExternalTasks() {
 return fetch(`${baseUrl}/external-task/fetchAndLock`, {
   ...requestParams,
   body: JSON.stringify(workerSettings)
 })
}

function processExternalTask(result = []) {
 return Promise.all(result.map(externalTask => {
   sendEmail(externalTask); // Here the actual work would be done

   return fetch(`${baseUrl}/external-task/${externalTask.id}/complete`, {
     ...requestParams,
     body: JSON.stringify({workerId: workerSettings.workerId}),
   })
 }));
}

setInterval(() => {
 pollExternalTasks().then(processExternalTask)
}, 20000);

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

Camunda external task client


image

Для реализации обработки external tasks camunda предоставила клиенты на Javascript и Java, которые позволяют создавать обработчики внешних задач буквально в несколько строк. Еще есть подробный гайд, в котором описаны основные принципы обработки внешних задач — опять-таки с примерами на Javascript и Java.

Пример реализации внешнего обработчика с помощью ExternalTaskClient:

public class App {
   public static void main(String... args) {
       // bootstrap the client
       ExternalTaskClient client = ExternalTaskClient.create()
           .baseUrl("http://localhost:8080/engine-rest")
           .asyncResponseTimeout(1000)
           .build();

       // subscribe to the topic
       client.subscribe("sendEmail").handler((externalTask, externalTaskService) -> {
           try {
               String result = sendEmail(externalTask)
               Map<String, Object> variables = new HashMap<>();

               variables.put("result", result);
               externalTaskService.complete(externalTask, variables);
               System.out.println("The External Task " + externalTask.getId() + " has been completed!");
           } catch (e: Exception) {
               externalTaskService.handleFailure(externalTask, e.message, e.stackTrace.toString())
           }
       }).open();
   }
}

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

@Service
class EmailWorker(
   private val runtimeService: RuntimeService
) {
   val builder = ExternalTaskClientBuilderImpl().baseUrl("http://localhost:8080").workerId("myWorker")
   val taskClient = builder.build()
   val engineClient = (builder as ExternalTaskClientBuilderImpl).engineClient

   @PostConstruct
   fun init() {
       taskClient
           .subscribe("sendEmail")
           .lockDuration(10000)
           .handler { externalTask, externalService ->
               runtimeService.startProcessInstanceByKey(
                   "SendEmailProcess",
                   externalTask.getVariable("emailId"),
                   mapOf(
                       "text" to externalTask.getVariable("text"),
                       "email" to externalTask.getVariable("email")
                   )
               )
           }
           .open()
   }


   @PreDestroy
   fun destroy() {
       taskClient.stop()
   }
}

// Delegate from SendEmailProcess process
@Component
class EmailResultDelegate(private val emailWorker: EmailWorker) {
   fun doExecute(execution: DelegateExecution) {
       emailWorker.engineClient.complete(
           execution.readVar(EXTERNAL_TASK_ID),
           mapOf("result" to "Success")
       )
   }
}

В этом примере обработчик external tasks (EmailWorker) при получении задачи запускает процесс SendEmailProcess.

Представим, что этот процесс выполняет какие-то действия, необходимые для отправки рассылки, и в конце вызывает EmailResultDelegate, который, в свою очередь, завершает выполнение external task.

Архитектурные преимущества external task


Стоит отметить, что есть способ запускать процесс в другом приложении camunda более простым способом: POST: /rest/process-definition/key/${id}/start

Когда вы используете REST, у вас нет никаких транзакционных гарантий. Но ведь с external task мы тоже работаем посредством REST, в чем тогда разница?

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

image

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

Теперь давайте рассмотрим случай, когда внешний обработчик получил задачу, выполнил ее и вызвал метод complete, который завершился ошибкой из-за проблем сети. Теперь вы не сможете понять, была ли задача успешно завершена в camunda или нет. Вы можете попробовать снова, но есть вероятность, что проблемы с сетью будут продолжаться.

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

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

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

Подробнее про external task:
https://docs.camunda.org/manual/latest/user-guide/process-engine/external-tasks/
https://docs.camunda.org/manual/latest/reference/rest/external-task/
https://docs.camunda.org/manual/latest/user-guide/ext-client/