- 引入依赖
<!-- https://mvnrepository.com/artifact/io.netty/netty-all -->
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>4.1.66.Final</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
- 添加properties配置
netty.port= 10000
netty.ws= ws://192.168.1.204:${netty.port}${server.servlet.context-path}/ws
- 关闭SpringSecurity 安全检验
@Configuration
@EnableWebSecurity
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
//允许所有的请求通过
http.authorizeRequests().antMatchers("/").permitAll();
//关闭CSRF安全校验
http.cors().and().csrf().disable();
}
}
@Configuration
public class SpringWebMvcConfig implements WebMvcConfigurer {
@Resource(name = "thymeleafViewResolver")
private ThymeleafViewResolver thymeleafViewResolver;
@Value("${server.servlet.context-path}")
String contextPath;
@Override
public void configureViewResolvers(ViewResolverRegistry registry) {
if (thymeleafViewResolver != null) {
Map<String, Object> vars = new HashMap<>(1);
vars.put("contextPath", contextPath);
thymeleafViewResolver.setStaticVariables(vars);
}
WebMvcConfigurer.super.configureViewResolvers(registry);
}
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("*")
.allowedHeaders("*")
//.exposedHeaders(HttpHeaders.ACCEPT)
.allowCredentials(true)
.allowedMethods("GET", "POST", "DELETE", "PUT", "PATCH", "OPTIONS")
.maxAge(3600);
}
}
- 定义NettyInitRunner 项目启动后运行
@Component
public class NettyInitRunner implements CommandLineRunner {
@Value("${netty.port}")
Integer nettyPort;
@Value("${server.servlet.context-path}")
String contextPath;
@Override
public void run(String... args){
try {
System.out.println("nettyServer starting ..." + nettyPort);
new NettyServerConfig(nettyPort, contextPath).start();
} catch (Exception e) {
System.out.println("NettyServerError:" + e.getMessage());
}
}
}
- netty配置
import fangrong.com.cn.im.handler.MessageHandler;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelOption;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.codec.http.HttpObjectAggregator;
import io.netty.handler.codec.http.HttpServerCodec;
import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;
import io.netty.handler.stream.ChunkedWriteHandler;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class NettyServerConfig {
private final int port;
private final String contextPath;
public NettyServerConfig(int port, String contextPath) {
this.port = port;
this.contextPath = contextPath;
}
public void start() throws Exception {
EventLoopGroup bossGroup = new NioEventLoopGroup();
EventLoopGroup group = new NioEventLoopGroup();
try {
ServerBootstrap sb = new ServerBootstrap();
sb.option(ChannelOption.SO_BACKLOG, 1024);
// 绑定线程池
sb.group(group, bossGroup)
// 指定使用的channel
.channel(NioServerSocketChannel.class)
// 绑定监听端口
.localAddress(this.port)
// 绑定客户端连接时候触发操作
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) throws Exception {
log.info("收到新的客户端连接");
//websocket协议本身是基于http协议的,所以这边也要使用http解编码器
ch.pipeline().addLast(new HttpServerCodec());
//以块的方式来写的处理器(添加对于读写大数据流的支持)
ch.pipeline().addLast(new ChunkedWriteHandler());
//对httpMessage进行聚合
ch.pipeline().addLast(new HttpObjectAggregator(8192));
// ================= 上述是用于支持http协议的 =============
// 添加自己的handler 添加测试的聊天消息处理类
ch.pipeline().addLast(new MessageHandler());
/*websocket 服务器处理的协议,用于给指定的客户端进行连接访问的路由地址
* 本handler会帮你处理一些繁重的复杂的事
* 会帮你处理握手动作: handshaking(close, ping, pong) ping + pong = 心跳
* 对于websocket来讲,都是以frames进行传输的,不同的数据类型对应的frames也不同
*/
ch.pipeline().addLast(new WebSocketServerProtocolHandler(contextPath + "/ws", null, true, 65536 * 10));
}
});
// 服务器异步创建绑定
ChannelFuture cf = sb.bind().sync();
System.out.println(NettyServerConfig.class + " 启动正在监听: " + cf.channel().localAddress());
// 关闭服务器通道
cf.channel().closeFuture().sync();
} finally {
// 释放线程池资源
group.shutdownGracefully().sync();
bossGroup.shutdownGracefully().sync();
}
}
}
- 自定义MessageHandler
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import fangrong.com.cn.entity.MessageEntity;
import fangrong.com.cn.im.dto.SocketMessageDTO;
import fangrong.com.cn.service.IMessageService;
import fangrong.com.cn.utils.SpringUtil;
import io.netty.channel.Channel;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelId;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.channel.group.ChannelGroup;
import io.netty.channel.group.DefaultChannelGroup;
import io.netty.handler.codec.http.FullHttpRequest;
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
import io.netty.util.concurrent.GlobalEventExecutor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
/**
* @author 7788
* @version 1.0
* @date 2021/7/29 下午 3:27
* @location wuhan
*/
@Component
public class MessageHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {
private final Logger logger = LoggerFactory.getLogger(MessageHandler.class);
/**
* 存储已经登录用户的channel对象
*/
public static ChannelGroup channelGroup = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE);
/**
* 存储用户id和用户的channelId绑定
*/
public static ConcurrentHashMap<Integer, ChannelId> userMap = new ConcurrentHashMap<>();
/**
* 用于存储 群聊房间号 和 群聊成员的channel信息
*/
public static ConcurrentHashMap<Integer, ChannelGroup> groupMap = new ConcurrentHashMap<>();
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
logger.info("与客户端建立连接,通道开启!");
//添加到channelGroup通道组
channelGroup.add(ctx.channel());
ctx.channel().id();
}
@Override
public void channelInactive(ChannelHandlerContext ctx) throws Exception {
//移除channelGroup 通道组
for (Map.Entry<Integer, ChannelId> next : userMap.entrySet()) {
if (next.getValue().equals(ctx.channel().id())) {
logger.info(next.getKey() + " 与服务端断开连接,通道关闭!");
userMap.remove(next.getKey());
}
}
channelGroup.remove(ctx.channel());
}
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
//首次连接是FullHttpRequest,把用户id和对应的channel对象存储起来
if (msg instanceof FullHttpRequest) {
FullHttpRequest request = (FullHttpRequest) msg;
String uri = request.uri();
Integer userId = getUrlParams(uri);
userMap.put(getUrlParams(uri), ctx.channel().id());
logger.info("登录的用户id是:{}", userId);
//第1次登录,需要查询下当前用户是否加入过群,加入过群,则放入对应的群聊里
//UserGroupRepositor userGroupRepositor = (UserGroupRepositor)SpringUtil.getBean("userGroupRepositor");
//List<Integer> groupIds = userGroupRepositor.findGroupIdByUserId(userId);
List<Integer> groupIds = new ArrayList<>();
ChannelGroup cGroup = null;
//查询用户拥有的组是否已经创建了
for (Integer groupId : groupIds) {
cGroup = groupMap.get(groupId);
//如果群聊管理对象没有创建
if (cGroup == null) {
//构建一个channelGroup群聊管理对象然后放入groupMap中
cGroup = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE);
groupMap.put(groupId, cGroup);
}
//把用户放到群聊管理对象里去
cGroup.add(ctx.channel());
}
//如果url包含参数,需要处理
if (uri.contains("?")) {
String newUri = uri.substring(0, uri.indexOf("?"));
request.setUri(newUri);
}
} else if (msg instanceof TextWebSocketFrame) {
IMessageService messageService = (IMessageService) SpringUtil.getBean("messageServiceImpl");
//正常的TEXT消息类型
TextWebSocketFrame frame = (TextWebSocketFrame) msg;
SocketMessageDTO socketMessageDTO = JSON.parseObject(frame.text(), SocketMessageDTO.class);
String messageType = socketMessageDTO.getMessageType();
switch (messageType) {
//====================================处理群聊任务=============
case "group":
logger.info("客户端收到服务器群聊数据:{}", frame.text());
//推送群聊信息
groupMap.get(socketMessageDTO.getChatId()).writeAndFlush(new TextWebSocketFrame(JSONObject.toJSONString(socketMessageDTO)));
MessageEntity groupEntity = new MessageEntity(socketMessageDTO.getMessageType(), socketMessageDTO.getUserId().toString(), socketMessageDTO.getChatId().toString(), socketMessageDTO.getMessage(), 1, new Date());
//保持到数据库
messageService.add(groupEntity);
break;
//====================================处理私聊任务=============
case "chat":
//消息类 默认为已读
MessageEntity chatEntity = new MessageEntity(socketMessageDTO.getMessageType(), socketMessageDTO.getUserId().toString(), socketMessageDTO.getChatId().toString(), socketMessageDTO.getMessage(), 1, new Date());
//处理私聊的任务,如果对方也在线,则发送消息
ChannelId channelId = userMap.get(socketMessageDTO.getChatId());
if (channelId != null) {
Channel ct = channelGroup.find(channelId);
if (ct != null) {
ct.writeAndFlush(new TextWebSocketFrame(JSONObject.toJSONString(socketMessageDTO)));
logger.info("对方在线 收到私聊数据:{}", frame.text());
} else {// 将记录保存在数据库中
chatEntity.setRead(0);
logger.info(socketMessageDTO.getChatId() + " 登录信息丢失,请重新登录");
}
} else {//处理私聊的任务,如果对方不在线, 则发推送 并将记录保存在数据库中
chatEntity.setRead(0);
logger.info("对方不在线 收到私聊数据:{}", frame.text());
}
//保持到数据库
messageService.add(chatEntity);
break;
//====================================处理群发任务=============
case "all":
// 1.1 在线的直接发送消息
channelGroup.writeAndFlush(new TextWebSocketFrame(JSONObject.toJSONString(socketMessageDTO)));
// 1.2 消息保存数据库
channelGroup.forEach(channel -> {
for (Map.Entry<Integer, ChannelId> next : userMap.entrySet()) {
if (next.getValue().equals(channel.id())) {
MessageEntity onlineEntity = new MessageEntity(socketMessageDTO.getMessageType(), socketMessageDTO.getUserId().toString(), next.getKey().toString(), socketMessageDTO.getMessage(), 1, new Date());
messageService.add(onlineEntity);
}
}
});
// 2.1 TODO 不在线的 数据库保存一条未读的消息
//MessageEntity offlineEntity = new MessageEntity();
//messageService.add(offlineEntity);
break;
default:
logger.info("未知类型:{}", frame.text());
break;
}
}
super.channelRead(ctx, msg);
}
@Override
protected void channelRead0(ChannelHandlerContext channelHandlerContext, TextWebSocketFrame textWebSocketFrame) {
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
cause.printStackTrace();
// 发生异常之后关闭连接(关闭channel),随后从ChannelGroup中移除
ctx.channel().close();
for (Map.Entry<Integer, ChannelId> next : userMap.entrySet()) {
if (next.getValue().equals(ctx.channel().id())) {
logger.info(next.getKey() + " 发生异常,通道关闭!");
userMap.remove(next.getKey());
}
}
channelGroup.remove(ctx.channel());
logger.info(" netty 异常...... ");
}
private static Integer getUrlParams(String url) {
if (!url.contains("=")) {
return null;
}
String userId = url.substring(url.indexOf("=") + 1);
return Integer.parseInt(userId);
}
}
@Data
public class SocketMessageDTO {
/**
* 消息类型
*/
private String messageType;
/**
* 消息发送者id
*/
private Integer userId;
/**
* 消息接受者id或群聊id
*/
private Integer chatId;
/**
* 消息内容
*/
private String message;
}
- 页面接口跳转
@Controller
public class TestIMController {
@Value("${netty.ws}")
private String ws;
/**
* 登录页面
*/
@RequestMapping("/login")
public String login() {
return "/login";
}
/**
* 登录后跳转到测试主页
*/
@PostMapping("/login.do")
public String login(@RequestParam Integer userId, HttpSession session, Model model) {
model.addAttribute("ws", ws);
session.setAttribute("userId", userId);
model.addAttribute("groupList", new ArrayList<>());
return "/index";
}
}
- 登录和聊天首页 页面
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<form th:action= "${contextPath} + '/login.do'" method="post">
登录(默认的4个用户id:[1,2,3,4])
用户Id:<input type="number" name="userId"/>
<input type="submit" value="登录"/>
</form>
</body>
</html>
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<style type="text/css">
.flexBox {display: flex;width: 100%;}
.flexBox div {width: 50%;background-color: pink;}
#messageBox ul {border: solid 1px #ccc;width: 600px;height: 400px}
</style>
<body>
<div class="flexBox">
<div style="text-align: left;" th:text="'当前登录的用户:'+${session.userId}"></div>
</div>
<!-- 聊天息 -->
<div class="flexBox" id="messageBox">
<ul th:id="${groupId}" th:each="groupId,iterObj : ${groupList}">
<li th:text="房间号+${groupId}"></li>
</ul>
<ul id="chat">
<li style="color: #0c31ec">聊天记录</li>
</ul>
</div>
<div style="width:100%;border: solid 1px #ccc;">
<form style="width: 40%;border: solid 1px red;margin: 0px auto">
<h3>给好友发送数据</h3>
<div>
测试数据: 好友编号为 1-4<br/><br/>
请输入好友编号 <input type="number" id="chatId" value="1"><br/><br/>
<textarea id="message" style="width: 96%">呼叫呼叫</textarea>
</div>
<div>
消息类型<input name="messageType" type="radio" value="chat" checked>私聊
<a href="#" id="send">发送</a>
</div>
</form>
</div>
</body>
<!--在js脚本中获取作用域的值-->
<script th:inline="javascript">
//获取session中的user
var userId = [[${session.userId}]];
//获取ws服务地址
var ws = [[${ws}]]
</script>
<script type="text/javascript">
var websocket;
if (!window.WebSocket) {
window.WebSocket = window.MozWebSocket;
}
if (window.WebSocket) {
websocket = new WebSocket(ws + "?userId=" + userId);
websocket.onmessage = function (event) {
var json = JSON.parse(event.data);
console.log(json)
chat.onmessage(json);
};
websocket.onopen = function (event) {
console.log("Netty-WebSocket服务器。。。。。。连接");
};
websocket.onclose = function (event) {
console.log("Netty-WebSocket服务器。。。。。。关闭");
};
} else {
alert("您的浏览器不支持WebSocket协议!");
}
//监听窗口关闭事件,当窗口关闭时,主动去关闭websocket连接,防止连接还没断开就关闭窗口,server端会抛异常。
window.onbeforeunload = function () {
if (websocket != null) {
websocket.close();
}
};
</script>
<script>
/**
* sendMessage 发送消息推送给websocket对象
* onmessage 接受来自服务端推送的消息,并显示在页面
* */
var chat = {
sendMessage: function () {
var message = document.getElementById("message").value; //发送的内容
if (message == "") {
alert('不能发送空消息');
return;
}
if (!window.WebSocket) {
return;
}
var chatId = document.getElementById("chatId").value; //好友Id
var radio=document.getElementsByName("messageType");
var messageType=null; // 聊天类型
for(var i=0;i<radio.length;i++){
if(radio[i].checked==true) {
messageType=radio[i].value;
break;
}
}
if (messageType == "chat") {
if (chatId == userId) {
alert("不能给自己发私聊信息,请换个好友吧");
return;
}
var li = document.createElement("li");
li.innerHTML = "自己: " + message
var ul = document.getElementById("chat");
ul.appendChild(li);
}
if (websocket.readyState == WebSocket.OPEN) {
var data = {};
data.chatId = chatId;
data.message = message;
data.userId = userId;
data.messageType = messageType;
websocket.send(JSON.stringify(data));
} else {
alert("和服务器连接异常!" + websocket.readyState);
}
},
onmessage: function (jsonData) {
var id;
if (jsonData.messageType == "chat") {
id = "chat";
} else {
id = jsonData.chatId;
}
console.log(id);
var li = document.createElement("li");
li.innerHTML = "好友 " + jsonData.userId + " 发来消息: " + jsonData.message;
var ul = document.getElementById(id);
ul.appendChild(li);
}
}
document.onkeydown = keyDownSearch;
function keyDownSearch(e) {
// 兼容FF和IE和Opera
var theEvent = e || window.event;
var code = theEvent.keyCode || theEvent.which || theEvent.charCode;
// 13 代表 回车键
if (code == 13) {
// 要执行的函数 或者点击事件
chat.sendMessage();
return false;
}
return true;
}
document.getElementById("send").onclick = function () {
chat.sendMessage();
}
</script>
</html>