如何在长时间运行的服务器操作期间与用户交互(例如确认对话框)?

Posted

技术标签:

【中文标题】如何在长时间运行的服务器操作期间与用户交互(例如确认对话框)?【英文标题】:How to interact with user (for example a confirm dialog) during a long running server action? 【发布时间】:2015-12-18 09:56:07 【问题描述】:

我有一个 MVC5 应用程序。有一个处理上传的大型 CSV 文件的特定操作,有时在此任务期间它需要用户提供其他信息。例如在第 5 行,软件需要向用户显示他真的想用它做某事等的确认。在 Winforms 环境中这很容易,但是我不知道如何在网络上实现相同的功能.

我更喜欢同步方式,这样服务器线程将被阻塞,直到确认。否则我觉得我必须完全重写逻辑。

让事情变得更加困难的是,我不仅需要简单的确认,而且有时用户可以有更复杂的选择,这不能在客户端同步实现(只有原生简单 confirm 是同步 AFAIK)。

任何建议或提示都将不胜感激,完整的简短指南甚至更多。

示例

在这个例子中,客户端调用了一个返回数字0, 1, 2, ..., 99, 100的方法。假设我们的用户可能讨厌可被 5 整除的数字。我们需要实现一个功能,允许用户排除这些数字,如果他们愿意的话。用户不喜欢为未来做计划,所以他们希望在处理过程中实时选择是否喜欢这样的数字。

[Controller]

public enum ConfirmResult 
  Yes = 0,
  No = 1,
  YesToAll = 2,
  NoToAll = 3


...

public JsonResult SomeProcessingAction() 
  var result = new List<int>();
  for (int i = 0; i <= 100; i++) 
    if (i%5==0) 
      // sketch implementation for example purposes
      if (Confirm(string.Format("The number 0 is dividable by 5. Are you sure you want to include it?", i) == ConfirmResult.No)
        continue;
    
    result.Add(i);
  
  return Json(result);


public ConfirmResult Confirm(string message) 
  // ... show confirm message on client-side and block until the response comes back... or anything else 



[javascript]
// sketch...
$.post('mycontroller/someprocessing', function(result) 
  $('#results').text("Your final numbers: " + result.join(', '));
);

【问题讨论】:

我知道谷歌电子表格可以做到这一点,但我对其他解决方案一无所知 我会看看 SignalR,如果您发布一些视图代码和控制器代码,我可以发布一个示例 我同意@JamieRees,避免同步方式,使用signalR 感谢 cmets,我添加了一个非常简单的示例,但对于演示来说可能已经足够了。 您几乎肯定会想要构建该功能,以便以后可以描述、存储和恢复任务的状态。例如,如果用户启动长时间运行的任务并离开,然后收到输入提示,则您可能会被无限期地锁定资源。 【参考方案1】:

我整理了一个例子,放在github上供大家看。 MVC5 long running input-required example.

请注意,这不一定是最好的设计方式。这是我没有经过深思熟虑的初步方法。可能有更灵活或更复杂,或更推荐的模式。

基本上它只是将 Job 的状态存储在数据库中(在示例中使用实体框架),只要它发生变化。 与某些类型的长时间运行的“同步”方法相比,持久化到磁盘或数据库具有明显的优势。

等待输入时不会锁定资源 在崩溃或服务器超时的情况下防止数据丢失 如果您想在横向扩展的环境或完全不同的服务器(例如非前置式虚拟机)上运行或恢复,它可以提供灵活性。 它可以更好地管理当前运行的作业。

对于这个例子,我选择不使用 Signalr,因为它不会增加显着的价值。对于长时间运行的作业(例如 5 分钟以上),亚秒级响应不会增加用户体验。我建议每 1-2 秒从 javascript 轮询一次。简单得多。

请注意,有些代码很老套;例如,在 ResumableJobState 表中重复输入字段。

流程可能看起来像这样,

上传文件 > 返回文件名 // 在我的示例中没有实现 调用 StartJob(filename) > 返回 (Json)Job 轮询 GetJobState(jobId) > 返回 (Json)Job 如果 (Json)Job.RequiredInputType 已填充,则向用户显示适当的表单以将输入返回 使用适当形式的正确输入类型调用 PostInput 工作将恢复

这是主 JobController 的转储。

public class JobController : Controller

    private Context _context;
    private JobinatorService _jobinatorService;
    public JobController()
    
        _context = new Context();
        _jobinatorService = new JobinatorService(_context);
    

    public ActionResult Index()
    
        ViewBag.ActiveJobs = _context.LongRunningJobs.Where(t => t.State != "Completed").ToList();//TODO, filter by logged in User
        return View();
    

    [HttpPost]
    public JsonResult StartJob(string filename)//or maybe you've already uploaded and have a fileId instead
    
        var jobState = new ResumableJobState
        
            CurrentIteration = 0,
            InputFile = filename,
            OutputFile = filename + "_output.csv"
        ;

        var job = new LongRunningJob
        
            State = "Running",
            ResumableJobState = jobState
        ;

        _context.ResumableJobStates.Add(jobState);
        _context.LongRunningJobs.Add(job);
        var result = _context.SaveChanges();
        if (result == 0) throw new Exception("Error saving to database");

        _jobinatorService.StartOrResume(job);

        return Json(job);
    

    [HttpGet]
    public JsonResult GetJobState(int jobId)
    
        var job = _context.LongRunningJobs.Include("ResumableJobState.RequiredInputType").FirstOrDefault(t => t.Id == jobId);
        if (job == null)
            throw new HttpException(404, "No job found with that Id");
        return Json(job, JsonRequestBehavior.AllowGet);
    

    [HttpPost]
    public JsonResult PostInput(int jobId, RequiredInputType userInput)
    
        if (!ModelState.IsValid)
            throw new HttpException(500, "Bad input");

        var job = _context.LongRunningJobs.Include("ResumableJobState.RequiredInputType").FirstOrDefault(t => t.Id == jobId);
        job.ResumableJobState.BoolInput = userInput.BoolValue;
        job.ResumableJobState.IntInput = userInput.IntValue;
        job.ResumableJobState.FloatInput = userInput.FloatValue;
        job.ResumableJobState.StringInput = userInput.StringValue;
        _context.SaveChanges();

        if (job == null)
            throw new HttpException(404, "No job found with that Id");

        if (userInput.InputName == job.ResumableJobState.RequiredInputType.InputName)//Do some checks to see if they provided input matching the requirements
            _jobinatorService.StartOrResume(job);
        //TODO have the jobinator return the State after it's resumed, otherwise we need another Get to check the state. 
        return Json(job);
    

    /// <summary>
    /// Stuff this in it's own service.  This way, you could use it in other places; for example starting scheduled jobs from a cron job
    /// </summary>
    public class JobinatorService//Ideally use Dependency Injection, or something good practicey to get an instance of this
    
        private Context _context = new Context();
        private string _filePath = "";
        public JobinatorService(Context context)
        
            _context = context;
            _filePath = AppDomain.CurrentDomain.GetData("DataDirectory").ToString() + "/";
        

        public void StartOrResume(LongRunningJob job)
        
            Task.Run(() =>
            
                using (var inputFile = System.IO.File.OpenRead(_filePath + job.ResumableJobState.InputFile))
                using (var outputFile = System.IO.File.OpenWrite(_filePath + job.ResumableJobState.OutputFile))
                
                    inputFile.Position = job.ResumableJobState.CurrentIteration;
                    for (int i = (int)inputFile.Position; i < inputFile.Length; i++)//casting long to int, what could possibly go wrong?
                    

                        if (job.State == "Input Required" && job.ResumableJobState.RequiredInputType != null)
                        //We needed input and received it
                            //You might want to do a switch..case on the various inputs, and branch into different functions

                            if (job.ResumableJobState.RequiredInputType.InputName == "6*7")
                                if (job.ResumableJobState.RequiredInputType.IntValue.Value == 42)
                                    break;//Pass Go, collect 42 dollars;
                        
                        outputFile.WriteByte((byte)inputFile.ReadByte());//Don't try this at home!

                        job.ResumableJobState.CurrentIteration = i;//or row, or line, or however you delimit processing
                        job.ResumableJobState.InputFileBufferReadPosition = inputFile.Position;//or something

                        if (i % 7 == 0)
                            job.ResumableJobState.RequiredInputType = _context.RequiredInputTypes.First(t => t.InputName == "Row 7 Input");
                        if (i % 42 == 0)
                            job.ResumableJobState.RequiredInputType = _context.RequiredInputTypes.First(t => t.InputName == "6*7");

                        if (job.ResumableJobState.RequiredInputType != null)
                            job.State = "Input Required";
                        _context.SaveChanges();
                        if (job.State != "Running")
                            return;
                    
                    job.State = "Completed";
                    _context.SaveChanges();
                
            );
            return;
        
    

【讨论】:

很好的答案,但这里有很多不好的做法,例如在您的控制器中拥有您的 Db 上下文。要求似乎非常复杂,可能值得关注Hangfire.io。但这是一个很好的答案,所以你值得一票。 感谢您提供详细的示例。我正在考虑这样的解决方案,但是虽然我喜欢你的想法,但这意味着我必须完全重写已经存在的处理代码(WinForms 应用程序有这个模块,其中交互被抽象为一个简单的界面,但期望得到同步回复)。然而,这似乎是值得的,因为在这种特殊情况下,网络与桌面有很大不同。 @JamieRees,确实有很多不好的做法,因为它并不是为了展示好的一般做法,而是核心暂停/恢复的想法。关于复杂性,我不同意。网络只是 不是同步的。试图强迫它如此,或让它看起来如此,不仅仅是逆流而上,而​​是顺着瀑布游。在大多数 Web 场景中,在等待用户时保留/阻止资源的解决方案根本不可行

以上是关于如何在长时间运行的服务器操作期间与用户交互(例如确认对话框)?的主要内容,如果未能解决你的问题,请参考以下文章

使用 ReSharper,如何在长时间运行的单元测试期间显示调试输出?

session过期怎么恢复

在长时间运行期间泵送 Windows 消息?

C# 如何防止在长时间运行的查询期间因崩溃而丢失数据?

如何取消 AJAX 长时间运行的 MVC 动作客户端(在 javascript 中)?

在应用程序关闭期间正确关闭一个可能运行很长时间循环的线程[重复]