今天看到jdeferred文档中一个关于Asynchronous Servlet例子,如下

@WebServlet(value = "/AsyncServlet", asyncSupported = true)
public class AsyncServlet extends HttpServlet {
  private static final long serialVersionUID = 1L;
  private ExecutorService executorService = Executors.newCachedThreadPool();
  private DeferredManager dm = new DefaultDeferredManager(executorService);

  protected void doGet(HttpServletRequest request,
                       HttpServletResponse response) throws ServletException, IOException {
    final AsyncContext actx = request.startAsync(request, response);

    dm.when(new Callable<String>() {
      @Override
      public String call() throws Exception {
        if (actx.getRequest().getParameter("fail") != null) {
          throw new Exception("oops!");
        }
        Thread.sleep(2000);
        return "Hello World!";
      }
    }).then(new DoneCallback<String>() {
      @Override
      public void onDone(String result) {
        actx.getRequest().setAttribute("message", result);
        actx.dispatch("/hello.jsp");
      }
    }).fail(new FailCallback<Throwable>() {
      @Override
      public void onFail(Throwable exception) {
        actx.getRequest().setAttribute("exception", exception);
        actx.dispatch("/error.jsp");
      }
    });
  }
}

突然想到在以前工作中经常前端向后端提交了一个长时间任务,为了良好的用户体验,前端还需要定时获取该任务的进度信息。之前的方案如下:

  1. 前端提交任务创建需要的信息至后台,后台为该任务创建对应Task,仅将该Task的ID返回至前端
  2. 后端向线程池提交该任务对应的Task Runnable,该Runnable的执行体里以任务的进度信息更新该Task的progress字段
  3. 前端定时发AJAX请求凭借Task的ID取进度

以前我一直有个疑问:就为了更新进度信息,浏览器要不停地向后端发请求,是不是代价太大了。曾经也尝试过以一个WebSocket请求代替轮寻询AJAX请求,但还是觉得比较麻烦。

今天看到异步Servlet,又想起以前看过的监控AJAX下载进度的例子,感觉可以有另一种解决方案。直接粘代码吧。

首先是获取任务进度的后端代码

package personal.xxj.servlet;

import org.jdeferred.DeferredManager;
import org.jdeferred.DoneCallback;
import org.jdeferred.FailCallback;
import org.jdeferred.impl.DefaultDeferredManager;

import javax.servlet.AsyncContext;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Random;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
 * Created by jeremy on 16/5/15.
 */
public class GetTaskProgressServlet extends HttpServlet {
    private ExecutorService executorService = Executors.newCachedThreadPool();
    private DeferredManager dm = new DefaultDeferredManager(executorService);
    private Random random = new Random();

    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        final AsyncContext actx = request.startAsync(request, response);
        actx.setTimeout(Long.MAX_VALUE);
        dm.when(new Callable<Void>() {
            @Override
            public Void call() throws Exception {
                HttpServletResponse resp = (HttpServletResponse) actx.getResponse();
                resp.setContentType("text/html");
                resp.setCharacterEncoding("UTF-8");
                resp.setContentLength(100);
                try {
                    for (int i = 0; i < 100; i++) {
                        Thread.sleep(random.nextInt(10) * 10);
                        resp.getWriter().write("*");
                        resp.getWriter().flush();
                    }
                } catch (Throwable e){
                    e.printStackTrace();
                } finally {
                    actx.complete();
                }
                return null;
            }
        });
    }
}
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee
		 http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd"
         version="3.1">

  <display-name>Java Web Demo</display-name>
  <servlet>
    <servlet-name>GetTaskProgressServlet</servlet-name>
    <servlet-class>personal.xxj.servlet.GetTaskProgressServlet</servlet-class>
    <async-supported>true</async-supported>
  </servlet>
  <servlet-mapping>
    <servlet-name>GetTaskProgressServlet</servlet-name>
    <url-pattern>/api/getTaskProgress</url-pattern>
  </servlet-mapping>
</web-app>

可以看到这里用到了jdeferredAsynchronous Servlet,工作逻辑就是模拟一个任务在慢慢地执行,每执行1%则向response里打印一个*

为啥一定要用Asynchronous Servlet?最大的原因是不想这些长时间运行的任务占用http线程,但又想持有请求响应上下文,可以在任务运行过程中输出合理的响应。

这里有几点要注意:

  • actx.setTimeout(Long.MAX_VALUE)这样根据实际场景设置超时时间,默认好像才30秒,对于一个长时间任务来说太短了
  • resp.setContentTyperesp.setCharacterEncodingresp.setContentLength最后都调用一遍,以免前端由于收到不这样响应头,非得接收完整的响应内容后才触发XMLHttpRequestprogress事件。(唉,入坑数小时,说多都是泪)
  • 每向response里打印一个*后需要调用resp.getWriter().flush();,尽快将响应刷回客户端。
  • 任务完成后要保证actx.complete();得到调用。
  • 注册异步servlet时,在web.xml里需要<async-supported>true</async-supported>

然后是前端代码

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Task Progress Demo</title>
</head>
<body>
<h1 id="output"></h1>
<script type="text/javascript">
    var xhr = new XMLHttpRequest();
    xhr.timeout = Number.MAX_VALUE;
    xhr.open('GET', '/javawebdemo/api/getTaskProgress');
    xhr.onprogress = function(event){
        if (event.lengthComputable) {
            var percentComplete = parseInt((100 * event.loaded) / event.total);
            document.getElementById("output").innerHTML = "Task's progress is " + percentComplete + "%";
        }
    };
    xhr.onerror = function(){
        document.getElementById("output").innerHTML = "Task's execution is failed";
    };
    xhr.send();
</script>
</body>
</html>

前端代码倒没有太多要注意的地方,只有一点要注意设置xhr.timeout

本例使用了Servlet 3.0 APIHTML5中的XMLHttpRequest 2XMLHttpRequest 2现在较新的主流浏览器都支持。

另外我查阅XMLHttpRequest 2的文档时还发现在XMLHttpRequest 2里不仅可以监控下载的进度,也可以监控上传的进度,参见XMLHttpRequest.upload的progress事件。

XMLHttpRequest 2还可以上传文件,接收二进制数据,参见这里,真是强大地不要不要的。