Невозможное — возможно. Stateful поведение в Stateless приложении!

в 7:46, , рубрики: .net, ERP-системы, Oreodor, взаимодействие, интерфейсы, метки: , , ,

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

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

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

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

В соответствии с требованием простоты бизнес-кода, нам бы хотелось, чтобы прикладной код выглядел как-то так (внимание, псевдокод!):

var данные = СложнаяПроверкаРасчетДанных();
если (данные.НеСовсемВерны())
{
   СпроситьПользователя(“У вас дебет с кредитом не сходится,  продолжить?”);
}
ПродолжаемОбработкуДанных(данные);

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

  1. Код приостановил работу.
  2. В неподтвержденную форму клиента пришел запрос Да/Нет.
  3. Если он нажмет “Да” — продолжаем работу.
  4. Если “Нет” — дать ему повторно ввести данные формы и еще раз отправить.

Предлагаю на ваш суд свое “магическое” решение, которое уже опробовано и успешно работает в моих проектах.

Попробуем решить задачу выдачи типичного диалога “Да/нет”.

Главная проблема — мы не можем приостановить выполнение операции на сервере на время ожидания ответа от пользователя по нескольким причинам:

  1. все общение клиент-сервер происходит асинхронно и по stateless протоколу.
  2. мы не можем занять ресурсы на все время ожидания ответа от пользователя

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

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

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

Вот как, примерно, выглядит эта схема:
image

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

Рассмотрим, как эта затея может выглядеть для программиста.

public class HelloNewWorldOrder
{
   // собственно, идентификатор действия
   Guid guid = new Guid("5FFD6DB4-1201-44BF-9DE0-DC199AC004D9");

   public void KillAllHumans(Human[] humans)
   {
       foreach (var human in humans)
       {
           if (human.Name == Context.Current.User.Name)
           {
             // указание выбросить исключение с запросом
               ExceptionHelper.Interactive(guid, "Вы были обнаружены в списке человеков. Все равно убить?");
           }

           human.Kill();
       }
   }
}

image
По-моему, выглядит довольно дружелюбно :)

Сам же ExceptionHelper.Interactive выглядит примерно так:

public static void Interactive(Guid id, string message)
{
   // получает идентификатор запроса   
   var key = RequestHelper.GetRequestId();
   var exists = Query.All<InteractiveException>()
       .Any(r => r.RequestId == key && r.ExceptionId == id);
   if (exists)
   {
       return;
   }

   throw new InteractiveException(message, id, key);
}

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

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

Опробовать данную систему в действии вы можете по адресу http://demo.oreodor.com/Parts/Main.aspx#Order:Regular. Описанный в статье подход там используется при оформлении заказа.

Исходный код действия, проверяющего валидность, выглядит так:

/// <summary>
/// Оформить заказ
/// </summary>
[Icon(ExtIcon.Accept)]
public class OrderCompleteAction : IAction<IFormContext<Order>>
{
    /// <summary>
    /// Ошибка 1
    /// </summary>
    private Guid e1 = new Guid("84099696-2225-41F9-AF54-0BE66367CEAA");

    /// <summary>
    /// Ошибка 1
    /// </summary>
    private Guid e2 = new Guid("26142EDB-3DC8-4B00-920F-FA33FC3ADF40");

    /// <summary>
    /// Выполнение действия
    /// </summary>
    /// <param name="context">Контекст действия</param>
    public void Execute(IFormContext<Order> context)
    {
         Assert.That(context.Item, Is.Not.Null, "Сохраните заказ перед оформлением, пожалуйста!");
         var cpus = context.Item.Items.Select(m => m.Linked)
                .OfType<Cpu>().Select(c => c.SocketType.SysName).ToHashSet();
         var mbs = context.Item.Items.Select(m => m.Linked)
                .OfType<MotherBoard>().Select(c => c.SocketType.SysName).ToHashSet();
         var coolers = context.Item.Items.Select(m => m.Linked)
                .OfType<Cooler>().SelectMany(c => c.Sockets.Select(m => m.Linked.SysName)).ToHashSet();

       if (!cpus.IsSubsetOf(coolers))
       {
           ExceptionHelper.Interactive(e1, "В списке товаров есть процессоры без подходящих кулеров.");
       }

       if (!cpus.IsSubsetOf(mbs))
       {
           ExceptionHelper.Interactive(e2, "В списке товаров есть процессоры без подходящих мат. плат.");
       }

       context.Item.Status = Status.BuiltInStatuses.Work.ToEntity<Status>();

       context.ShowMsgBox("Заказ принят в обработку.");
    }
}

Автор: XuMiX

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js