目录
一、概述
二、DWR
三、STOMP
四、小结
五、原生websocket
六、Netty实现WebSocket
七、小结
1. 本篇文章以聊天室程序为例,介绍实现消息推送给的几种方式。
2. 基于Spring Boot,其他框架或语言也类似,项目地址:https://github.com/yeta666/chat
3. 文中只提及主要代码,详细内容看项目。
1. 引入依赖
org.directwebremoting
dwr
3.0.2-RELEASE
2. 创建提供服务的类
@RestController
@RemoteProxy
public class DWRController {
/**
* 根据请求参数type处理不同类型请求
* @param reqMessage
*/
@RemoteMethod
public void chat(String reqMessage) {
//请求
CommonRequest request = JSON.parseObject(reqMessage, CommonRequest.class);
Integer type = request.getType();
//返回
CommonResponse response;
//所有会话
WebContext webContext = WebContextFactory.get();
Collection sessions = webContext.getAllScriptSessions();
//构建发送所需的JS脚本
ScriptBuffer scriptBuffer = new ScriptBuffer();
if (type == 1) { //群聊消息
//调用客户端的JS脚本函数
scriptBuffer.appendScript("chat(");
//这个message可以被过滤处理一下,或者做其他的处理操作。这视需求而定。
response = new CommonResponse(1000, 1, request.getMessage());
scriptBuffer.appendData(JSON.toJSONString(response));
scriptBuffer.appendScript(")");
Util util = new Util(sessions); //sessions,群发
util.addScript(scriptBuffer);
} else if (type == 2) { //私聊消息
for (ScriptSession session : sessions) {
if (session.getId().equals(request.getTarget())) {
//调用客户端的JS脚本函数
scriptBuffer.appendScript("chat(");
//这个message可以被过滤处理一下,或者做其他的处理操作。这视需求而定。
response = new CommonResponse(1000, 2, request.getMessage());
scriptBuffer.appendData(JSON.toJSONString(response));
scriptBuffer.appendScript(")");
Util util = new Util(session); //session,单独发
util.addScript(scriptBuffer);
}
}
} else if (type == 3) { //返回所有会话id,用于私聊
List ids = new ArrayList();
for (ScriptSession session : sessions) {
ids.add(session.getId());
}
//调用客户端的JS脚本函数
scriptBuffer.appendScript("addTarget(");
//这个message可以被过滤处理一下,或者做其他的处理操作。这视需求而定。
response = new CommonResponse(1000, 4, ids);
scriptBuffer.appendData(JSON.toJSONString(response));
scriptBuffer.appendScript(")");
Util util = new Util(sessions); //sessions,群发
util.addScript(scriptBuffer);
}
}
}
3. 创建dwr配置文件
4. 配置dwrServlet
/**
* 配置dwr Servlet
* 重点注意取名
* @return
*/
@Bean
public ServletRegistrationBean dwrServlet() {
DwrSpringServlet servlet = new DwrSpringServlet();
ServletRegistrationBean registrationBean = new ServletRegistrationBean(servlet, "/dwr/*");
registrationBean.addInitParameter("debug", "true");
//使用服务器反转Ajax
registrationBean.addInitParameter("activeReverseAjaxEnabled", "true");
//能够从其他域请求true:开启; false:关闭
registrationBean.addInitParameter("crossDomainSessionSecurity", "false");
//允许远程调用JS
registrationBean.addInitParameter("allowScriptTagRemoting", "true");
return registrationBean;
}
5. 导入dwr的配置文件
@ImportResource(locations = "classpath:config/dwrConfig.xml")
6. 引入dwr相关js,注意路径,文件由dwr自动生成,不会在项目中出现
7. 主要js代码
$(function() {
//页面加载时进行反转的激活
dwr.engine.setActiveReverseAjax(true);
//点击建立连接按钮,加载发送消息目标
$("#connectBtn").click(function() {
$("#chatroomBox").css("display", "block");
sendMessage(3);
});
//点击群聊按钮
$("#topicBtn").click(function() {
sendMessage(1);
});
//点击私聊按钮
$("#queueBtn").click(function() {
$target = $("#target").val().trim();
sendMessage(2, $target);
});
})
//发送消息方法
function sendMessage(type, target) {
var request = {
type: type,
message: $('#message').val().trim(),
target: target
};
DWRController.chat(JSON.stringify(request));
$('#message').val("");
}
//后台回调方法,加载发送消息目标
function addTarget(data) {
var message = JSON.parse(data).message;
//清空
$("#target").html("");
//加载
for(var i = 0; i < message.length; i++) {
$("#target").append($(''));
}
}
//后台回调
function chat(data) {
var message = JSON.parse(data).message;
$("#messages").val($("#messages").val() + "\n" + message);
}
1. 点对点推送需要指定用户名,这里引入spring security
org.springframework.boot
spring-boot-starter-websocket
org.springframework.boot
spring-boot-starter-security
2. 创建spring security配置类
@Configuration
@EnableWebSecurity
public class CommonWebSecurityConfigurerAdapter extends WebSecurityConfigurerAdapter {
@Autowired
private CommonUserDetailsService commonUserDetailsService;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
//内存中分配用户,spring security自己验证
/*auth.inMemoryAuthentication()
.passwordEncoder(new BCryptPasswordEncoder())
.withUser("yeta1").password(new BCryptPasswordEncoder().encode("yeta1")).roles("USER")
.and()
.withUser("yeta2").password(new BCryptPasswordEncoder().encode("yeta2")).roles("USER");*/
//自定义登陆验证,可以通过数据库来验证
auth.userDetailsService(commonUserDetailsService).passwordEncoder(new BCryptPasswordEncoder());
}
@Override
public void configure(WebSecurity web) throws Exception {
//设置/resources/static目录下的静态资源不拦截
web.ignoring().antMatchers("/resources/static/**");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest()
.authenticated() //所有请求需要认证
.and()
.formLogin()
.defaultSuccessUrl("/slogin", true) //登陆验证成功路径
.failureUrl("/flogin") //登陆验证失败路径
.permitAll();
http.csrf()
.disable();
}
}
3. 由于需要在内存中保存登陆的所有用户信息,以便实现点对点推送,这里采用自定义登陆验证方式
@Service
public class CommonUserDetailsService implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user;
if (username.equals("yeta1")) {
user = new User("A01", "yeta1", new BCryptPasswordEncoder().encode("yeta1"), "USER");
} else if (username.equals("yeta2")) {
user = new User("A02", "yeta2", new BCryptPasswordEncoder().encode("yeta2"), "USER");
} else {
user = new User();
}
return user;
}
}
4. 创建WebSocket配置类
@Configuration
@EnableWebSocketMessageBroker //开启STOMP协议来传输基于代理(message broker)的消息,这时控制器支持@MessageMapping
public class CommonWebSocketMessageBrokerConfigurer implements WebSocketMessageBrokerConfigurer {
/**
* 注册STOMP协议的节点(endpoint),并映射指定的URL
* @param registry
*/
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
//指定使用SocketJS协议
registry.addEndpoint("/endpoint").withSockJS();
}
/**
* 配置消息代理
* @param registry
*/
@Override
public void configureMessageBroker(MessageBrokerRegistry registry){
//广播式应配置一个/topic消息代理
//点对点式应配置一个/queue消息代理
registry.enableSimpleBroker("/topic", "/queue");
}
}
5. 创建推送业务处理类
@RestController
public class PushController {
//保存所有登陆的用户
public static ConcurrentSkipListSet users = new ConcurrentSkipListSet();
@Autowired
private SimpMessagingTemplate smt; //通过该对象向浏览器发送消息
/**
* 群发方法
* @param request
* @return
*/
@MessageMapping(value = "/topic/pushAll/receive") //服务器接收浏览器消息的地址
@SendTo(value = "/topic/pushAll") //浏览器订阅服务器消息的地址,服务器将会把消息推送到订阅了该地址的浏览器
public CommonResponse pushAll(CommonRequest request) {
return new CommonResponse(1000, 1, request.getMessage());
}
/**
* 群发方法,同上面的方法效果一样
* @param request
* @return
*/
@MessageMapping(value = "/topic/pushAll/receive1")
public void pushAll1(CommonRequest request) {
smt.convertAndSend("/topic/pushAll1", new CommonResponse(1000, 1, request.getMessage()));
}
/**
* 推送在线用户
* @return
*/
@MessageMapping(value = "/topic/pushUsers")
@SendTo(value = "/topic/pushAll")
public CommonResponse pushUsers(Principal principal, CommonRequest request) {
if (request.getType() == 5) {
users.add(principal.getName());
} else if (request.getType() == 6) {
users.remove(principal.getName());
}
return new CommonResponse(1000, 4, users);
}
/**
* 只推送自己方法
* @param request
* @return
*/
@MessageMapping(value = "/queue/pushMyself/receive")
@SendToUser(value = "/queue/pushMyself", broadcast = false) //只推送给自己,broadcast=false表示如果一个账户登陆多个浏览器,只将消息推送给发出请求的浏览器
public CommonResponse pushMyself(CommonRequest request) {
return new CommonResponse(1000, 1, request.getMessage());
}
/**
* 私发方法
* @param principal
* @param request
* @return
*/
@MessageMapping(value = "/queue/pushSomeone/receive")
public CommonResponse pushSomeone(Principal principal, CommonRequest request) {
String target = request.getTarget();
for (String username : users) {
if (username.equals(target)) {
CommonResponse response = new CommonResponse(1000, 1, principal.getName() + " say: " + request.getMessage());
smt.convertAndSendToUser(target, "/queue/pushSomeone", response);
}
}
return new CommonResponse(1001, 2, "error");
}
}
6. 主要js代码
//连接SockJS
var socket = new SockJS(SOCKJS_URI);
//使用STOMP子协议的WebSocket客户端
var stompClient = Stomp.over(socket);
//连接WebSocket客户端
stompClient.connect({}, function(frame) {
//订阅服务器群发消息
stompClient.subscribe(PUSH_ALL_URL, function(data) {
var response = JSON.parse(data.body);
var message = response.message;
if(response.type == 4) {
$("#target").html("");
for(var i = 0; i < message.length; i++) {
$("#target").append($(''));
}
} else {
$("#messages").val($("#messages").val() + "\n" + message);
}
});
//订阅服务器私发自己消息
stompClient.subscribe(PUSH_MYSELF_URL, function(data) {
var response = JSON.parse(data.body);
var message = response.message;
$("#messages").val($("#messages").val() + "\n" + message);
});
//订阅服务器私发某人消息
stompClient.subscribe(PUSH_SOMEONE_URL, function(data) {
var response = JSON.parse(data.body);
var message = response.message;
$("#messages").val($("#messages").val() + "\n" + message);
});
//显示聊天室
$("#chatroomBox").css("display", "block");
//断开连接按钮可用
$("#disconnectBtn").attr("disabled", false);
//更新在线用户
sendMessage("/topic/pushUsers", 5);
});
//点击群发按钮
$("#topicBtn").click(function() {
sendMessage(PUSH_ALL_RECEIVE_URL, 1);
});
//点击点对点发按钮
$("#queueBtn").click(function() {
sendMessage(PUSH_SOMEONE_RECEIVE_URL, 2, $("#target").val());
});
//发送消息方法
function sendMessage(url, type, target) {
var request = {
type: type,
target: target,
message: $("#message").val().trim()
};
stompClient.send(url, {}, JSON.stringify(request));
$('#message').val("");
}
//监听窗口关闭
window.onbeforeunload = function() {
//更新在线用户
sendMessage("/topic/pushUsers", 6);
//客户端断开连接
stompClient.disconnect();
socket.close();
};
在dwr和stomp的使用过程中,我发现在服务器端不能监听它们的连接建立和关闭,实现不了如下场景:当有一个新连接建立,服务器主动推送给所有连接当前在线的用户,当有一个连接断开,服务器也主动推送给所有连接当前在线的用户。
当然,也可能只是我没有研究到位。
其实stomp可以这样实现:
1. stompClient.connect回调方法中,stompClient.subscribe订阅之后,浏览器主动向服务器发送一个消息,表示自己要和服务器建立连接;
2. 服务器将该连接加入在线用户列表,并向所有连接推送最新的在线用户;
3. 浏览器监听用户准备关闭连接的时候(比如说监听关闭浏览器或者有一个主动断开连接的按钮),先向服务器发送一个消息,表示自己要和服务器断开连接;
4. 服务器将该连接移除在线用户列表,并向所有连接推送最新的在线用户;
1. 引入WebSocket依赖
org.springframework.boot
spring-boot-starter-websocket
2. 创建WebSocket配置类
@Configuration
public class WebSocketConfig {
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}
3. 创建WebSocket服务类
@ServerEndpoint("/webSocket")
@Component
public class WebSocketServer {
//保存所有的会话
private static ConcurrentHashMap sessions = new ConcurrentHashMap();
/**
* 建立连接
* @param session
* @throws IOException
*/
@OnOpen
public void onOpen(Session session) throws IOException {
//保存会话
if (sessions.get(session.getId()) == null) {
sessions.put(session.getId(), session);
}
//服务器主动推送所有会话id
for (Session s : sessions.values()) {
String ids = sessions.keySet().toString();
CommonResponse response = new CommonResponse(1000, 4, ids);
s.getBasicRemote().sendText(JSON.toJSONString(response));
}
}
/**
* 关闭连接
* @param session
* @throws IOException
*/
@OnClose
public void onClose(Session session) throws IOException {
sessions.remove(session.getId(), session);
//服务器主动推送所有会话id
for (Session s : sessions.values()) {
String ids = sessions.keySet().toString();
CommonResponse response = new CommonResponse(1000, 4, ids);
s.getBasicRemote().sendText(JSON.toJSONString(response));
}
}
/**
* 收到消息
* @param session
* @param sRequest
* @throws IOException
*/
@OnMessage
public void onMessage(Session session, String sRequest) throws IOException {
CommonRequest request = JSON.parseObject(sRequest, CommonRequest.class);
String message = request.getMessage();
CommonResponse response;
if (request.getType() == 1) { //群聊消息
for (Session s : sessions.values()) {
response = new CommonResponse(1000, 1, message);
s.getBasicRemote().sendText(JSON.toJSONString(response));
}
} else if (request.getType() == 2) { //私聊消息
String target = request.getTarget();
for (Session s : sessions.values()) {
if (s.getId().equals(target)) {
response = new CommonResponse(1000, 1, message);
s.getBasicRemote().sendText(JSON.toJSONString(response));
}
}
} else if (request.getType() == 3) { //浏览器自己请求资源
response = new CommonResponse(1000, 3, message);
session.getBasicRemote().sendText(JSON.toJSONString(response));
}
}
/**
* 出现错误
* @param session
* @param e
* @throws IOException
*/
@OnError
public void onError(Session session, Throwable e) throws IOException {
CommonResponse response = new CommonResponse(1001, 3, e.getMessage());
session.getBasicRemote().sendText(JSON.toJSONString(response));
e.printStackTrace();
}
}
4. 主要js代码
//创建WebSocket对象
var webSocket = new WebSocket("ws://localhost:8080/chat/webSocket");
//连接成功调用方法
webSocket.onopen = function(e) {
console.log("onopen");
//显示聊天室
$("#chatroomBox").css("display", "block");
//断开连接按钮可用
$("#disconnectBtn").attr("disabled", false);
};
//浏览器收到服务器消息调用方法
webSocket.onmessage = function(e) {
console.log("onmessage");
var response = JSON.parse(e.data);
if(response.code == 1000) {
if(response.type == 4) { //服务器主动推送消息
//清空
$("#target").html("");
//加载
var message = response.message.substring(1, response.message.length - 1).split(",");
for(var i = 0; i < message.length; i++) {
$("#target").append($(''));
}
} else { //群聊消息或私聊消息
$("#messages").val($("#messages").val() + "\n" + response.message);
}
}
};
//出现错误调用方法
webSocket.onerror = function(e) {
alert(e.type);
};
//连接关闭调用方法
webSocket.onclose = function(e) {
alert(e.type);
};
//点击群聊按钮
$("#topicBtn").click(function() {
sendMessage(1);
});
//点击私聊按钮
$("#queueBtn").click(function() {
//判断目标
var $target = $("#target").val().trim();
sendMessage(2, $target);
});
//发送消息方法
function sendMessage(type, target) {
if(webSocket.readyState == WebSocket.OPEN) {
var request = {
type: type,
message: $('#message').val().trim(),
target: target
};
webSocket.send(JSON.stringify(request));
$('#message').val("");
}
}
//监听窗口关闭
window.onbeforeunload = function() {
webSocket.close();
};
1. 引入Netty依赖
io.netty
netty-all
5.0.0.Alpha1
2. 创建Netty配置类
public class NettyConfig {
//存储每一个浏览器接入进来时的channel对象
public static ChannelGroup channels = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE);
//IP地址
public static final String IP = "localhost";
//端口号
public static final int PORT = 8081;
//路径
public static final String URL = "/webSocket";
}
3. 创建业务处理类
public class CommonSimpleChannelInboundHandler extends SimpleChannelInboundHandler
3. 创建初始化类
public class CommonChannelInitializer extends ChannelInitializer {
@Override
protected void initChannel(SocketChannel socketChannel) throws Exception {
//HTTP消息编码解码
socketChannel.pipeline().addLast("http-codec", new HttpServerCodec());
//HTTP消息组装
socketChannel.pipeline().addLast("aggregator", new HttpObjectAggregator(65536));
//WebSocket通信支持
socketChannel.pipeline().addLast("http-chunked", new ChunkedWriteHandler());
//
socketChannel.pipeline().addLast("handler", new CommonSimpleChannelInboundHandler());
}
}
4. 创建Netty启动类
@Configuration
public class NettyService implements CommandLineRunner {
//日志
private static final Logger LOG = LoggerFactory.getLogger(NettyConfig.class);
@Override
public void run(String... args) throws Exception {
EventLoopGroup bossGroup = new NioEventLoopGroup();
EventLoopGroup workGroup = new NioEventLoopGroup();
try {
ServerBootstrap sb = new ServerBootstrap();
sb.group(bossGroup, workGroup);
sb.channel(NioServerSocketChannel.class);
sb.childHandler(new CommonChannelInitializer());
LOG.info("等待连接...");
Channel channel = sb.bind(NettyConfig.PORT).sync().channel();
channel.closeFuture().sync();
} catch (Exception e) {
e.printStackTrace();
} finally {
bossGroup.shutdownGracefully();
workGroup.shutdownGracefully();
}
}
}
原生websocket和netty实现websocket都可以监听到连接建立和关闭,所以可以轻易实现上面说到的功能。