При разработке веб приложений часто необходимо интерактивное общение с пользователем в процессе выполнения каких-то действий. Веб ERP-системы, в свою очередь, накладывают на такое общение довольно специфичные требования. После ввода в эксплуатацию нескольких вариантов таких систем, я нашел способ, который показался мне наиболее приемлемым. Теперь же хочу поделиться своим решением задачи интерактивной работы с пользователем при выполнении действий на сервере.Итак, при разработке сложного web-приложения нужно совместить несколько противоречивых требований:
- прикладной код бизнес-решения должен содержать минимум артефактов, не относящихся к бизнес-задаче
- выполнение бизнес-операции должно происходить в одной неделимой транзакции
- в процессе выполнения кода необходимо интерактивное общение с пользователем
- ожидание ответа от пользователя не должно занимать ресурсы сервера и блокировать работу других пользователей
- прикладной код выполняется на сервере, общение с клиентом идет через браузер
Типичная бизнес-задача: оформление заказа, в процессе заказа на сервере производятся проверки, требующие от оператора интерактивного ввода. Например, мы хотим попросить у пользователя подтверждение его действий при выполнении каких-то условий.
В соответствии с требованием простоты бизнес-кода, нам бы хотелось, чтобы прикладной код выглядел как-то так (внимание, псевдокод!):
var данные = СложнаяПроверкаРасчетДанных(); если (данные.НеСовсемВерны()) { СпроситьПользователя(“У вас дебет с кредитом не сходится, продолжить?”); } ПродолжаемОбработкуДанных(данные);
При этом в строке подтверждения нам бы хотелось, чтобы произошла магия:
- Код приостановил работу.
- В неподтвержденную форму клиента пришел запрос Да/Нет.
- Если он нажмет “Да” — продолжаем работу.
- Если “Нет” — дать ему повторно ввести данные формы и еще раз отправить.
Предлагаю на ваш суд свое “магическое” решение, которое уже опробовано и успешно работает в моих проектах.
Попробуем решить задачу выдачи типичного диалога “Да/нет”.
Главная проблема — мы не можем приостановить выполнение операции на сервере на время ожидания ответа от пользователя по нескольким причинам:
- все общение клиент-сервер происходит асинхронно и по stateless протоколу.
- мы не можем занять ресурсы на все время ожидания ответа от пользователя
Однако, у пользователя должно создаваться впечатление непрерывности действия. Так же и программист не должен задумываться над тем, что выполнение его кода может прерываться. Поэтому, придется использовать некоторые хитрости.
Для начала введем понятия идентификаторов запроса и действия.
Идентификатор запроса — уникальный идентификатор запроса, который генерируется на клиенте. Для каждого запроса к серверу идентификатор свой, за исключением того случая, когда запрашиваем пользовательский ввод.
Идентификатор действия — уникальный идентификатор действия. Если быть точнее, уникальный идентификатор места в коде, в котором запрашивается ответ пользователя.
Эти два идентификатора позволяют организовать схему работы клиент-сервер таким образом, что можно точно определить когда и что запрашивалось у пользователя и какой ответ он дал.
Вот как, примерно, выглядит эта схема:

Таким образом, у разработчика создается впечатление, что его метод выполняется всего 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(); } } }

По-моему, выглядит довольно дружелюбно :)
Сам же 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("Заказ принят в обработку."); } }
