异步处理窥探

用过微信网页版的人应该都清楚网页登陆的流程,大致描述一下这个过程:

  1. 打开网页版登陆链接
  2. 页面会显示一个二维码
  3. 用微信客户端扫描二维码,让用户确认登陆网页版
  4. 如果确认登陆,网页版会自动进入聊天界面。

这个过程的交互方式和一般的WEB应用不太一样,步骤4网页自动跳转,明显是由服务端主动推送了内容给网页端,网页端收到跳转确认后才触发的,这里就引出了今天要讨论的问题:服务端推送技术。服务端推送又称为Comet,服务端异步处理等。很早以前就出现了,但一直没有一个统一的标准,存在着不少Comet技术框架,各个Web容器也各自实现了自己的Comet支持。最近公司的产品也出现了和微信网页版登陆类似的场景,需要用到Comet技术,我简单的研究了下,写下来记录一下。

针对Comet技术的选择性蛮多,我匆匆看了一下,就有这么3个方案:

  • Tomcat 内置支持,需要实现CometProcessor接口。但是应用就依赖Tomcat容器了。

  • Servlet3 天然支持,Servlet3提供一套完整的异步处理API,包括AsyncContext,AsyncLiseter,AsyncEvent. 要求Tomcat7.0++。

  • SpringMVC3.2 在Servlet3的基础上做了进一步的封装,编码更为简单,提供Callable,WebAsyncTask,DeferredResult三种方式进行异步编程支持,非常方便。

基于Tomcat的CometProcessor依赖性过大,我基本上不予考虑了。因为时间还算充裕,所以我分别针对Servlet3 和SpringMVC3.2 都做了尝试,其实过程都比较简单,关键是要理解场景。我来介绍下我们产品的实际场景吧,我们要实现的一个功能是扫描动态二维码关注微信公众账号。基本流程是这样的:

  1. 客户端调用服务端接口获取动态二维码以及二维码内容中内置的ID。(这个时候在客户端能看到一个二维码了,等待用户扫描)
  2. 客户端马上调用服务端的一个长连接接口,与服务端建立长连接,等待服务端通知。(这个过程是在后台发生的,用户无法感知)
  3. 用户拿出微信扫描二维码,就会有一个扫描事件通知到服务端的扫描接口。(这个时候服务端接收到扫描动作,完成自己的业务操作以后,通知长连接接口,用户已经扫描了,可以返回了)。

这个流程里面有这么几个地方是需要能解决的:

  1. 步骤2里面要求客户端--服务端建立长连接,不会立即返回,客户端一直在等待状态。(Servlet3 的API可以支持,需要把Timeout时间设置长一点,一般是60S够了)
  2. 步骤3中 扫描接口要通知长连接接口,如何做到? 必须存在一个公共的容器,容器里面存着上下文信息,扫描接口把执行完毕的上下文告知长连接接口就可以了。

所以,实现代码如下:

配置部分

web.xml 启用Servlet3 的命名空间

<web-app xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns="http://java.sun.com/xml/ns/javaee"
    xmlns:web="http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd"
    xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd"
    id="WebApp_ID" version="3.0">

</web-app>

长连接Servlet要开启异步支持:

@WebServlet(value = "/scan/*",asyncSupported = true)

Tomcat server.xml要开启NIO模式

<Connector port="8080" protocol="org.apache.coyote.http11.Http11NioProtocol"
     connectionTimeout="20000" asyncTimeout="150000"  URIEncoding="utf-8"  redirectPort="8443" />

长连接Servlet实现

@WebServlet(value = "/scan/*",asyncSupported = true)
public class ScanServlet extends HttpServlet {

   // private ScanRetain retain;

    private Logger logger = Logger.getLogger(getClass());

    @Override
    public void init() throws ServletException {
       
    }

    @Override
    public void destroy() {
        ScanRetain.MAP.clear();
    }

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        doPost(req, resp);
    }

    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        logger.debug(">>>>>>>>>>>>>>>>>开始访问长连接Servlet.....");
        String pathInfo = req.getPathInfo();
        String key = null;
        if (pathInfo != null) {
            int i = pathInfo.lastIndexOf('/');
            if (i >= 0) {
                key = pathInfo.substring(i + 1);
            }
        }
        if (key == null) {
            PrintWriter writer = resp.getWriter();
            writer.write("error:not found scan key");
            writer.flush();
            return;
        }
        req.startAsync(req, resp);
        if (req.isAsyncStarted()) {
            final AsyncContext asyncContext = req.getAsyncContext();
            final String theKey = key;
            asyncContext.setTimeout(60 * 1000L);

            asyncContext.addListener(new AsyncListener() {
                @Override
                public void onComplete(AsyncEvent asyncEvent) throws IOException {
                    ScanRetain.MAP.remove(theKey);
                }

                @Override
                public void onTimeout(AsyncEvent asyncEvent) throws IOException {
                    ScanRetain.MAP.remove(theKey);
                }

                @Override
                public void onError(AsyncEvent asyncEvent) throws IOException {
                    ScanRetain.MAP.remove(theKey);
                }

                @Override
                public void onStartAsync(AsyncEvent asyncEvent) throws IOException {

                }
            });

            logger.debug(">>>>>>>>>>>>>>>>>将长连接上下文对象加入队列等待处理.........");
            ScanRetain.MAP.put(theKey, asyncContext);
        }
    }
}

公共Context容器存放类以及提供给扫描后对长连接响应处理的逻辑

public class ScanRetain {

    // 公共上下文容器
    public static final ConcurrentHashMap<String, AsyncContext> MAP = new ConcurrentHashMap<String, AsyncContext>();

    private Logger logger = Logger.getLogger(getClass());

    public void doReturn(String key){
        logger.debug(">>>>>>>>>>>>>>>>>长连接正在响应.....");
        AsyncContext asyncContext = MAP.get(key);
        if (asyncContext == null) {
            return;
        }
        HttpServletResponse res = (HttpServletResponse) asyncContext.getResponse();
        DBObject data = new BasicDBObject("result",1)
                .append("info","ok")
                .append("now",System.currentTimeMillis());
        String str = JSON.serialize(data);
        OutputStream os = null;
        try {
            os = res.getOutputStream();
            os.write(str.getBytes("utf-8"));
            logger.debug(">>>>>>>>>>>>>>>>>长连接响应完毕.....");
            os.flush();
            asyncContext.setTimeout(100L);// 一定要加这一句才会及时返回
        } catch (IOException e) {
            e.printStackTrace();
        }
    }


}

扫描事件触发长连接响应的逻辑

Long senceId = 0L;
if (qrSenceId != null) {
    senceId = Long.parseLong(qrSenceId);
}
scanRetain.doReturn(senceId + "");

SpringMVC3.2 的实现我也尝试了一下:
长连接接口:

// 上下文容器
  public static final ConcurrentHashMap<String, DeferredResult<String>> MAP = new ConcurrentHashMap<String, DeferredResult<String>>();

@RequestMapping("doScan/{key}")
    @ResponseBody
    public DeferredResult<String> doScan(@PathVariable("key") String key) {
        DeferredResult<String> result = new DeferredResult<String>();
        MAP.put(key, result);
        return result;
    }

通知长连接响应客户端的测试代码:

@RequestMapping(value="/newScan/{key}",produces = "text/plain;charset=utf-8;")
    @ResponseBody
    public String newScan(@PathVariable("key") String key,
            HttpServletRequest req, HttpServletResponse res) {
        DeferredResult<String> data = Scans.MAP.get(key);
        if(data!=null){
            data.setResult("this is result:"+System.currentTimeMillis());
            Scans.MAP.remove(key);
        }
        return "new scan test finished :"+key+"now is :"+System.currentTimeMillis();
    }

Spring的代码实现简单很多,但是也不那么直观,不利于理解。

同时,它还提供另外两种异步处理的方式,只是不适于这个场景,这里也罗列一下。
Callable:

 @ResponseBody
    @RequestMapping("call")
    public Callable<String> call(HttpServletRequest req, HttpServletResponse res) throws Exception {
        return new Callable<String>() {
            @Override
            public String call() throws Exception {
                TimeUnit.SECONDS.sleep(5);
                return "hello,callable";
            }
        };

    }

WebAsyncTask:

 @ResponseBody
    @RequestMapping("async")
    public WebAsyncTask<String> async(HttpServletRequest req, HttpServletResponse res) throws Exception {
        Callable<String> callable = new Callable<String>() {
            @Override
            public String call() throws Exception {
                TimeUnit.SECONDS.sleep(5);
                return "hello,WebAsyncTask";
            }
        };

        return new WebAsyncTask<String>(1000*60L,callable);
    }

上面两种方式也是用于异步操作的,它们比较适用于一些比较耗时的操作(如大数据计算,文件处理),它们的响应一般不存在其他的触发点,就是取决于Callable内部代码块的执行结束。

综上,我们大致可以总结出异步处理的两种应用场景:

1. 多点操作,单点的响应往往依赖于其他点的触发,最典型的就是微信扫描登录了。这个基本的编码思路应该是这样的:

  • 定义一个上下文存储容器,容器要支持并发,最好选用Concurrent类型。

  • 开发长连接接口,客户端请求连接后,将上下文加入存储容器。

  • 开发响应的触发逻辑代码段。

  • 触发业务完成以后,调用响应触发逻辑。

2. 单点操作,但是操作往往非常耗时,不能及时响应。这种场景一般会把耗时操作全部抽离到Callable代码段,响应的触发点就是Callable代码的结束处。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,736评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,167评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,442评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,902评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,302评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,573评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,847评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,562评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,260评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,531评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,021评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,367评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,016评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,068评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,827评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,610评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,514评论 2 269

推荐阅读更多精彩内容

  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,099评论 18 139
  • 从三月份找实习到现在,面了一些公司,挂了不少,但最终还是拿到小米、百度、阿里、京东、新浪、CVTE、乐视家的研发岗...
    时芥蓝阅读 42,014评论 11 349
  • 怒卷寒冬 春风抚弄 花繁柳细草青; 蝶影翩翩 千红万紫丛中。 小竹不喑凡人事 向天行 矢志凌空。 水波明 船橹摇来...
    秋思枫醉阅读 145评论 0 0
  • 【你自己明明刚刚还在嘲笑某人没出息只会对着你的照片舔屏呢,那么我想请问你,你现在自己这是又在干吗呢?】蔡唸看着璐璐...
    石思琳阅读 417评论 2 7
  • 这是我做的一个梦,在梦里,在梦里我曾和你一起看见了星河。 毕业已经有一年了, 这一年里我和你几乎没有任...
    球球球球oo阅读 566评论 0 1