用websocket做心跳检测——解决微信iOS端网页无法监听浏览时间问题

一、问题解决的背景和意义

在很多购物网站中,特别是在一些产品购买浏览页面中,我们需要抓取用户浏览的一些行为。比如说某个页面的用户浏览时间,分享的次数,以便能给销售团队提供用户行为的信息,方便以后的销售策略的调整。在普通的网页中,其实是个很容易实现的需求——监听popstate,visualbilitychange等浏览器全局事件的回调即可。但是在微信内部访问的网页中,特别是微信iOS版本中,问题就变得非常复杂了,这些全局事件的表现变得捉摸不定,在2017年3月微信iOS客户端换为wkwebview内核以后,单纯靠前端的逻辑,基本上没有办法获取到前端的用户浏览网页时间了,于是我们就准备用websocket来解决这个问题。


二、websocket的实现方法的简述和遇到的问题

在实现尝试实现的过程中,观察和遇到了这样几个问题。第一个问题是基于jsr-356标准的websocket实现与spring整合的问题,如果不解决这个问题就会导致在websocket的接口里无法自动组装(@Autowired)在spring context里的Bean。第二个问题是当用户离开当前页面时,普通浏览器会立即断开当前页发起的websocket链接,而在微信中的网页不会。在微信中websocket长连接会一直保持链接,直至用户锁屏,或再次发起另一个websocket握手请求时,上一个连接才会断开。这样的现象导致我们不得不在服务端实现心跳检测来侦测用户是否真的离开了目标页面。


OK, SHOW ME THE CODE!


三、心跳检测在tomcat下基于JSR-356实现的大致步骤

tips: demo地址websocket-heartbreak.git

(1)websocket服务端的实现

/**
 * 
 * websocket heart break endpoint
 * client side viewing-time count
 * 
 * @author zhiheng
 *
 */

@ServerEndpoint(value = "/websocket/count")
public class HeartBreakEndpoint {
	
	private Timer timer;
	private volatile boolean isPong;
	private long startTime;
	
	@OnOpen
	public void start(final Session localSession) {
		this.startTime = System.currentTimeMillis();
		
		timer.schedule(new TimerTask() {

			@Override
			public void run() {
				try {
					if(isPong) {
						System.out.println("Timer - Say hi");
						localSession.getBasicRemote().sendText("hi");
						isPong = false;
					} else {
						System.out.println("INFO - Check the time.");
						try {
							localSession.close();
						} catch (IOException e) {
							e.printStackTrace();
						}
						
						this.cancel();
					}
					
				} catch (IOException e) {
					e.printStackTrace();
					this.cancel();
				}
			}
			
		}, 2000, 2000);
		
	}
	
	@OnClose
	public void onClose() {
		long currTime = System.currentTimeMillis();
		System.out.println("OnClose - view time " + (currTime - this.startTime)/1000 + " sec.");
	}
	
	@OnMessage
	public void onMessage(String msg) {
		System.out.println("OnMessage - " + msg);
		if(msg.equals("yes")) {
			this.isPong = true;
		}
	}
	
	@OnError
	public void onError(Throwable t) throws Throwable {
		
	}
	
	public HeartBreakEndpoint() {
		this.timer = new Timer();
		this.isPong = true;
	}

}
首先需要明确的一点是,如果在不加其他的配置的前提下,websocket的服务端endpoint默认是多例模式——一个握手连接就会
有一个HeartBreakEndpoint类的实例与其对应。因此,我们就可以把每个链接需要保存的数据保存在这个类当中:
1.timer,用来给每个链接发起定时ping-pong操作的对象
2.isPong,用来标记客户端是否反馈了Pong
3.startTime,连接建立时刻的时间戳
根据JSR-356标准,在连接建立时,会执行@OnOpen注解标记的方法。之后在注解后的方法里实现以下逻辑:
1.记录当前系统毫秒数
2.往timer 里注册一个每两秒执行一次的task
3.定时任务会每两秒会执行以下操作:
① 去判断客户端是否有给服务端Pong(判断isPong 是否为true)
② 如果客户端有Pong,则往服务端发Ping信息
③ 否则认为客户端已经没有响应了,关闭当前websocket连接,并取消定时任务
在@OnClose注解的方法里实现计算时长逻辑。@OnMessage方法里实现,当客户端发来Pong信息时,把isPong修改为true

(2) websocket 客户端实现



Hello See heartBreak


客户端的逻辑则比较简单,页面一加载完成就发起websocket握手请求。之后当接到服务端的Ping,就立即返回Pong,如此往
复。所以在微信中打开网页时,即使离开网页页面,而websocket连接没有断开,服务端也会立即发现收不到客户端的Pong。从而
可以侦测到用户离开当前页面了。

四、与spring等框架的整合

spring是很多java服务端程序的首选框架,因此如何让websocket与spring整合,是我们需要解决的问题。通常遇到的问题就
是:websocket的endpoint建立成功了,但是一些想要自动组装的Bean的实例却无法自动装配(@Autowired),从而导致程序报
NullPointerException。

(1)与spring框架整合

import org.springframework.web.socket.server.endpoint.SpringConfigurator;


@ServerEndpoint(value = "/websocket/count", configurator = SpringConfigurator.class)
在endpoint类上导入这个包,并改一下annotation就可以了。

(2)与spring-boot项目整合

如果你的项目是spring-boot项目,那我的建议就是:不要用JSR-356标准实现websocket了,因为似乎spring-boot的
Context 似乎和Servlet本身的Context有很大的冲突。因此最好使用spring-boot提供的websocket实现:
(handler)类的实现:
package com.zhheng.nosencebase.wshandler;

import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;

import com.zhheng.nosencebase.dao.CitiesDao;


public class GreetHandler extends TextWebSocketHandler {
	
	private final CitiesDao citiesDao;
	
	@Override
	protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
		String msg = citiesDao.selectCitiesList().get(0).getCity();
		session.sendMessage(new TextMessage(msg));
	}

	@Override
	public void afterConnectionEstablished(WebSocketSession session) throws Exception {
		
	}

	@Override
	public void handleMessage(WebSocketSession session, WebSocketMessage message) throws Exception {
		super.handleMessage(session, message);
	}

	@Override
	public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
		super.handleTransportError(session, exception);
	}

	@Override
	public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
		super.afterConnectionClosed(session, status);
	}
	
	public GreetHandler(CitiesDao citiesDao) {
		this.citiesDao = citiesDao;
	}

}

其实就是继承Text'WebSocketHandler,看起来每个声明周期的重写方法其实和JSR-356差不多。
在configuration类中注册handler,并给其绑定一个路由:
package com.zhheng.nosencebase;


import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;

import com.zhheng.nosencebase.dao.CitiesDao;
import com.zhheng.nosencebase.wshandler.GreetHandler;

@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
	
	@Autowired
	private CitiesDao citiesDao;
	
	public WebSocketConfig() {
		
	}

	@Override
	public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
		registry.addHandler(greetingHandler(), "/wstest");
	}
	
	@Bean
	public WebSocketHandler greetingHandler() {
		return new GreetHandler(citiesDao);
	}
	
	
}

至此,我们就可以通过路由“/wstest”,在spring-boot项目中使用websocket了。


五、反向代理/网关服务器对websocket连接的影响

都说线上环境是最恶劣的环境,有websocket连接的实例在部署到nginx,apache等服务器时,经常会遇到握手失败的问题。这
和websocket的通信机制有关。因为websocket的握手连接是个http(s)连接,握手的验证信息会写进这个http请求的请求头里。之
后服务器,才会升级为websocket连接,所以这些头信息如果不经过nginx转发,握手就会不成功。因此这些请求头的转发我们要在
nginx的配置文件里配置好。在这里我想多说一点的是:特别是一些大公司,他们经常会使用一些类似于NetScaler这样的企业级反向
代理/ 透明代理/网关服务器,这些服务器对websocket连接通常默认也是配置被禁用的,所以排查问题的时候也要考虑到这一点。

六、参考资料

(1)    Oracle JSR-356     http://www.oracle.com/technetwork/articles/java/jsr356-1937161.html
(2)    Spring JSR-356 整合   https://spring.io/blog/2013/05/23/spring-framework-4-0-m1-websocket-support
(3)    nginx websocket proxy  http://nginx.org/en/docs/http/websocket.html
(4)    spring-boot websocket implement  https://stackoverflow.com/questions/30483094/springboot-serverendpoint-failed-to-find-the-root-webapplicationcontext

你可能感兴趣的:(用websocket做心跳检测——解决微信iOS端网页无法监听浏览时间问题)