服务器推送Comet技术

135 阅读4分钟

Comet概念

首先我们看维基百科的定义: Comet是一种用于web的推送技术,能使服务器实时地将更新的信息传送到客户端,而无须客户端发出请求,目前有两种实现方式,长轮询和iframe流。在H5的标准中定义了WebSocket方式,在得到浏览器支持之后,WebSocket将会取代Comet成为服务器推送的方法。 根据这个定义,短轮询其实不算是一种Comet,因为短轮询是依赖于客户端发起请求的。

长轮询

原理与概念

长轮询的原理就是客户端发起HTTP请求,请求的超时时间设置的长一些。当服务端收到请求时,如果此时没有需要发送给客户端的消息,则暂时不返回响应。当有数据需要推送给客户端时,拿到正在等待的连接,将数据通过此连接发送。当客户端收到请求、或者即将超时服务端主动返回空结果、服务端完成一次数据返回后,一次长轮询结束。客户端再发起请求。

长轮询和短轮询的区别是:短轮询中服务器对请求立即响应。而长轮询中服务器等待新的数据到来才响应。因此长轮询需要的网络更少。

示例

需求: 提供一个发送消息的接口。如果收信人在线则立即发送。否则将消息保存,等收信人上线后再发送。收信人使用长轮询接口来拉去自己信箱中的消息。

@RestController  
public class LongPollController {  
    private final long timeOutInMilliSec = 100000L;  
  
    @Autowired  
    private MsgDist dist;  
  
    /**  
     * 长轮询接口。  
     * 客户端的超时时间需要比服务器的timeOutInMilliSec设置的长一些  
     *  
     * @param uid 消息所属用户id  
     * @return  
     */  
    @GetMapping("/longpoll")  
    public DeferredResult<String> poll(@RequestParam("uid") Integer uid) {  
        // 当我们返回DeferredResult时,请求线程将被释放,并将其交给worker线程处理  
        DeferredResult<String> result = new DeferredResult<>(timeOutInMilliSec, "");  
        dist.pollMsgs(uid, result);  
        return result;  
    }  
  
    /**  
     * 消息发送接口  
     *  
     * @param toUid 收信息人用户id  
     * @param msg   需要发送的消息  
     * @return  
     */  
    @PostMapping("/send")  
    public String sendMsg(@RequestParam("to_uid") Integer toUid, @RequestParam("msg") String msg) {  
        dist.sendMsgToUserBox(toUid, msg);  
        return "ok";  
    }  
}
@Component  
public class MsgDist {  
  
    private ConcurrentHashMap<Integer, DeferredResult<String>> uid2deferredResult;  
    private ConcurrentHashMap<Integer, LinkedList<String>> uid2box;  
  
    public MsgDist() {  
        this.uid2deferredResult = new ConcurrentHashMap<>();  
        this.uid2box = new ConcurrentHashMap<>();  
        Timer timer = new Timer();  
        MsgSender sender = new MsgSender();  
        timer.schedule(sender, 1, 1);  
    }  
  
    /**  
     * 将消息放到用户信箱,交给定时任务发送  
     *  
     * @param uid  
     * @param msg  
     */  
	public void sendMsgToUserBox(int uid, String msg) {  
	    synchronized (this){  
	        LinkedList<String> box = this.uid2box.getOrDefault(uid, new LinkedList<String>());  
	        box.add(msg);  
	        this.uid2box.put(uid, box);  
	    }  
	}
  
    /**  
     * 拉取消息  
     *  
     * @param uid  
     * @param deferredResult  
     */  
    public void pollMsgs(int uid, DeferredResult<String> deferredResult) {  
        this.uid2deferredResult.put(uid, deferredResult);  
    }  
  
    /**  
     * 定时尝试将用户信息中的数据发送给用户  
     */  
    class MsgSender extends TimerTask {  
  
        @Override  
        public void run() {  
            Iterator<Map.Entry<Integer, LinkedList<String>>> iterator = uid2box.entrySet().iterator();  
            while (iterator.hasNext()) {  
                Map.Entry<Integer, LinkedList<String>> entry = iterator.next();  
                LinkedList<String> box = entry.getValue();  
                // 信箱无数据  
                if (box == null || box.size() == 0) {  
                    continue;  
                }  
                Integer uid = entry.getKey();  
                // 用户没有长轮询请求  
                DeferredResult<String> deferredResult = uid2deferredResult.get(uid);  
                if (deferredResult == null) {  
                    continue;  
                }  
                // 将多条消息拼接后发送  
                StringBuffer willSendMsg = new StringBuffer("");  
                box.forEach(str -> {  
                    willSendMsg.append(str);  
                    willSendMsg.append(";");  
                });  
                deferredResult.setResult(willSendMsg.toString());  
                uid2box.remove(uid);  
            }  
        }  
    }  
}

关于DeferredResult的拓展阅读 Guide to DeferredResult in Spring

基于iframe的长连接流模式

前端在页面中嵌入一个inframe并甚至其src,src指向服务端的接口。服务端通过此接口不断的返回数据。

Server-Send Events

前端通过标准的EventSource API来和后端建立连接。后端可以通过这个连接发送任意的字符串数据。SSE的MIME请求类型是text/event-stream. SSE通信是单向的。相较于长连接,它可以分多次将数据返回。

示例

@RestController  
public class SseController {  
  
    private final long timeOutInMilliSec = 100000L;  
  
    @Autowired  
    private SseServer sseServer;  
  
    /**  
     * 建立连接  
     *  
     * @param uid  
     * @return  
     */  
    @GetMapping("/sse_connect")  
    public SseEmitter connect(Integer uid) {  
        SseEmitter emitter = new SseEmitter(timeOutInMilliSec);  
        sseServer.subscribe(uid, emitter);  
        return emitter;  
    }  
  
    /**  
     * 发送消息  
     *  
     * @param toUid  
     * @param msg  
     * @return  
     */  
    @PostMapping("/sse_send")  
    public String sendMsg(@RequestParam("to_uid") Integer toUid, @RequestParam("msg") String msg) {  
        sseServer.sendMsgToUser(toUid, msg);  
        return "ok";  
    }  
}
@Component  
public class SseServer {  
    private ConcurrentHashMap<Integer, SseEmitter> uid2sseEmitter;  
  
    public SseServer() {  
        this.uid2sseEmitter = new ConcurrentHashMap<>();  
    }  
  
    public void sendMsgToUser(Integer uid, String msg) {  
        SseEmitter emitter = this.uid2sseEmitter.get(uid);  
        if (emitter != null) {  
            try {  
                emitter.send(msg);  
            } catch (IOException e) {  
                throw new RuntimeException(e);  
            }  
        }  
    }  
  
    public void subscribe(Integer uid, SseEmitter emitter) {  
        this.uid2sseEmitter.put(uid, emitter);  
        emitter.onTimeout(()->{  
            this.uid2sseEmitter.remove(uid);  
        });  
        emitter.onError((throwable)->{  
            this.uid2sseEmitter.remove(uid);  
        });  
    }  
  
}

websocket

websocket支持双向通信,后续数据通信无需再传递冗余的http头部信息。

示例

  1. 引入websocket依赖
<dependency>  
    <groupId>org.springframework.boot</groupId>  
    <artifactId>spring-boot-starter-web</artifactId>  
</dependency>  
  
<dependency>  
    <groupId>org.springframework.boot</groupId>  
    <artifactId>spring-boot-starter-websocket</artifactId>  
</dependency>
  1. 编写endpoint
@ServerEndpoint("/ws")  
public class WsChannel {  
  
    private Session session;  
  
  
    @OnMessage  
    public void onMessage(String msg) {  
        System.out.println("recv:" + msg);  
        this.session.getAsyncRemote().sendText("hello");  
    }  
  
    @OnOpen  
    public void onOpen(Session session, EndpointConfig endpointConfig) {  
        this.session = session;  
        System.out.println("new connect");  
    }  
  
    @OnClose  
    public void onClose(CloseReason closeReason) {  
        System.out.println("close,close code:" + closeReason.getCloseCode());  
    }  
  
    @OnError  
    public void onError(Throwable throwable) throws IOException {  
        System.out.println("on error");  
        this.session.close(new CloseReason(CloseReason.CloseCodes.UNEXPECTED_CONDITION, throwable.getMessage()));  
    }   
}
  1. 导出endpoint
@Configuration  
public class WebSocketConfiguration {  
    @Bean  
    public ServerEndpointExporter serverEndpointExporter() {  
        ServerEndpointExporter exporter = new ServerEndpointExporter();  
        exporter.setAnnotatedEndpointClasses(WsChannel.class);  
        return exporter;  
    }  
  
}

注意:这是一个很简单例子,仅演示websocket库如何使用。实际工程中还需要处理Session管理,自定义编解码,握手处理器等逻辑