При разработке веб приложений часто необходимо интерактивное общение с пользователем в процессе выполнения каких-то действий. Веб 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("Заказ принят в обработку.");
}
}
Автор: XuMiX