一、netty整合websockte
1.1、配置 NettyServer
创建NettyServer:定义两个EventLoopGroup,bossGroup辅助客户端的tcp连接请求,workGroup负责与客户端之间的读写操作。注意
:需要开启一个新的线程来执行netty server, 要不然会阻塞主线程,到时候就无法调用项目的其他controller接口了。
@Component
public class NettyServer {
@Autowired
private MyWebSocketHandler webSocketHandler;
private static final Logger log = LoggerFactory.getLogger(NettyServer.class);
public int port = 6655;
public void start() throws Exception{
// 主线程组,用于接受客户端的连接,但是不做任何处理,跟老板一样
EventLoopGroup bossGroup = new NioEventLoopGroup();
// 从线程组,主线程组会把任务丢给它,让从线程组去做相应的处理
EventLoopGroup workGroup = new NioEventLoopGroup();
try {
ServerBootstrap bootstrap = new ServerBootstrap();
//boss 等待连接的 队列长度
bootstrap.option(ChannelOption.SO_BACKLOG,1024);
// bossGroup辅助客户端的tcp连接请求, workGroup负责与客户端之前的读写操作
bootstrap.group(workGroup,bossGroup);//绑定线程池
//指定使用的channel 设置NIO类型的channel
bootstrap.channel(NioServerSocketChannel.class);
//绑定监听端口
bootstrap.localAddress(port);
// 连接到达时会创建一个通道
bootstrap.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());
//http数据在传输过程中是分段的,HttpObjectAggregator可以将多个段聚合
ch.pipeline().addLast(new HttpObjectAggregator(8192));
// 自定义的handler,处理业务逻辑
ch.pipeline().addLast(webSocketHandler);
// 1、对应webSocket,它的数据是以帧(frame)的形式传递
// 2、浏览器请求时 ws://localhost:58080/xxx 表示请求的uri
// 3、核心功能是将http协议升级为ws协议,保持长连接/
ch.pipeline().addLast(new WebSocketServerProtocolHandler("/ws",null,true,65536*10));
}
});
// 配置完成,绑定server,通过调用sync同步方法阻塞直到绑定成功
ChannelFuture cf = bootstrap.bind().sync(); // 服务器异步创建绑定
log.info("启动监听:{}",cf.channel().localAddress());
// 关闭服务器通道
cf.channel().closeFuture().sync();
} finally {//释放资源
if(bossGroup != null){
bossGroup.shutdownGracefully().sync();
}
if(workGroup != null){
workGroup.shutdownGracefully().sync();
}
}
}
}
1.2、创建通道连接池
@Component
public class MyChannelHandlerPool {
public MyChannelHandlerPool(){}
/**
* 管理全局的 channel
* GlobalEventExecutor.INSTANCE 全局事件监听器
* 一旦将channel 加入 ChannelGroup 就不要用手动去管理channel的连接失效后移除操作,他会自己移除
*/
public static ChannelGroup channelGroup = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE);
/**
* 存放用户与Chanel的对应信息,用于给指定用户发送消息
* 是一个支持高并发更新与查询的哈希表
*/
private static ConcurrentHashMap<String, Channel> userChannelMap = new ConcurrentHashMap<>();
/**
* 存放用户群组对应的用户
*/
private static ConcurrentHashMap<String, List<String>> chatGroup = new ConcurrentHashMap<>();
/**
* 获取用户 channel map
* @return
*/
public static ConcurrentHashMap<String, Channel> getUserChannelMap(){
return userChannelMap;
}
/**
* 获取用户 channel map
* @return
*/
public static ConcurrentHashMap<String, List<String>> getChatGroupMap(){
return chatGroup;
}
}
1.3、业务逻辑和通道管理
@Component
@ChannelHandler.Sharable
public class MyWebSocketHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {
private static final Logger log = LoggerFactory.getLogger(MyWebSocketHandler.class);
/**
* channel连接状态就绪以后调用
*/
@Override
public void channelActive(ChannelHandlerContext ctx) {
Channel channel = ctx.channel();
MyChannelHandlerPool.channelGroup.add(channel);
log.info("新设备上线了~");
}
/**
* channel连接状态断开后触发
* 同时当前channel会自动从ChannelGroup中被移除
* @param ctx
*/
@Override
public void channelInactive(ChannelHandlerContext ctx) {
Channel channel = ctx.channel();
AttributeKey<String> key = AttributeKey.valueOf("userId");
Attribute<String> userId = channel.attr(key);
//这里 ChannelGroup 底层封装会遍历给所有的channel发送消息
//MyChannelHandlerPool.channelGroup.writeAndFlush(new TextWebSocketFrame("【用户"+userId.get()+"】离开聊天!==> "+new Date()));
MyChannelHandlerPool.getUserChannelMap().remove(userId.get());
//打印 ChannelGroup中的人数
log.info("【{}】连接关闭!",userId.get());
log.info("当前在线数为: {}", MyChannelHandlerPool.channelGroup.size());
}
/**
* 连接发生异常时触发
*/
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
log.error("连接发生异常!");
ctx.close();
log.error(cause.toString());
}
/**
* 通道数据读取
* @param ctx
* @param msg
* @throws Exception
*/
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
//首次连接是FullHttpRequest
//以http请求形式接入,但是走的是websocket
if (null != msg && msg instanceof FullHttpRequest) {
FullHttpRequest request = (FullHttpRequest) msg;
String url = request.uri();
Map paramMap = getUrlParams(url);
log.info("接收到的参数是:{}", JSONObject.toJSONString(paramMap));
// 将用户ID作为自定义属性加入到channel中,方便随时channel中获取用户ID
AttributeKey<String> key = AttributeKey.valueOf("userId");
ctx.channel().attr(key).setIfAbsent((String) paramMap.get("id"));
//用户id与连接channel绑定
MyChannelHandlerPool.getUserChannelMap().put((String) paramMap.get("id"), ctx.channel());
//如果url包含参数,需要处理
if(url.contains("?")){
String newUri=url.substring(0,url.indexOf("?"));
request.setUri(newUri);
}
}else if(msg instanceof TextWebSocketFrame){
//正常的TEXT消息类型
TextWebSocketFrame frame=(TextWebSocketFrame)msg;
String data = frame.text();
log.info("客户端收到服务器数据:{}",data);
JSONObject jsonObject = (JSONObject) JSONObject.parse(data);
String uid = jsonObject.getString("uid");
String targetId = jsonObject.getString("targetId");
jsonObject.remove("targetId");
jsonObject.put("source",uid);
jsonObject.remove("uid");
sendTargetUserMessage(jsonObject,targetId,uid);
}
super.channelRead(ctx, msg);
}
@Override
protected void channelRead0(ChannelHandlerContext channelHandlerContext, TextWebSocketFrame textWebSocketFrame) throws Exception {
}
/**
* 收到信息后,发给指定用户所在的channel
* @param message
*/
private void sendTargetUserMessage(JSONObject message, String targetId,String sourceId){
JSONObject newMsg = new JSONObject();
Channel sourceChannel = MyChannelHandlerPool.getUserChannelMap().get(sourceId);
Channel channel2 = MyChannelHandlerPool.getUserChannelMap().get(targetId);
if (null != channel2) {
channel2.writeAndFlush(new TextWebSocketFrame(JSONObject.toJSONString(message)));
if("printTask".equals(message.getString("msgType"))){
newMsg.put("msgType","havePrinter");
sourceChannel.writeAndFlush(new TextWebSocketFrame(JSONObject.toJSONString(newMsg)));
}
}else {
newMsg.put("msgType","noPrinter");
sourceChannel.writeAndFlush(new TextWebSocketFrame(JSONObject.toJSONString(newMsg)));
log.error("目标{}不存在",targetId);
}
}
/**
* 获取路径中的参数值
* @param url
*/
private static Map getUrlParams(String url){
Map<String,String> map = new HashMap<>();
url = url.replace("?",";");
if (!url.contains(";")){
return map;
}
if (url.split(";").length > 0){
String[] arr = url.split(";")[1].split("&");
for (String s : arr){
String key = s.split("=")[0];
String value = s.split("=")[1];
map.put(key,value);
}
return map;
}else{
return map;
}
}
}
1.4、服务启动
实现CommandLineRunner 接口,在容器启动后执行如下操作。
@Service
public class NettyStart implements CommandLineRunner {
@Autowired
private NettyServer nettyServer;
@Override
public void run(String... args) {
new Thread(()->{
try {
nettyServer.start();
} catch (Exception e) {
e.printStackTrace();
}
}).start();
}
}
1.5、浏览器页面连接websocket
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>Netty-Websocket</title>
</head>
<body>
<form onSubmit="return false;">
<label>当前用户ID</label><input type="text" id="uid" value="1" readonly="true"/> <br />
<label>目标用户ID</label><input type="text" id="targetId" placeholder="这里输入目标用户ID"/>
<br/>
<label>发送的消息</label><input type="text" id="message" placeholder="这里输入消息"/> <br />
<input type="button" value="连接webSocket" onClick="connect(this)" />
<input type="button" value="断开连接" onClick="closeConnect()" />
<input type="button" value="发送ws消息" onClick="send()" />
<hr color="black" />
<h3>服务端返回的应答消息</h3>
<textarea id="responseText" style="width: 1024px;height: 300px;"></textarea>
</form>
</body>
<script type="text/javascript">
let socket;
if(!window.WebSocket){
window.WebSocket = window.MozWebSocket;
}
function connect() {
if(window.WebSocket){
var uid = document.getElementById('uid').value;
socket = new WebSocket("ws://127.0.0.1:6655/ws?id="+uid);
//有消息时触发
socket.onmessage = function(event){
var ta = document.getElementById('responseText');
ta.value += event.data+"\r\n";
};
//socket打开时触发
socket.onopen = function(event){
var ta = document.getElementById('responseText');
ta.value = "欢迎您上线! \r\n";
};
//socket关闭时触发
socket.onclose = function(event){
var ta = document.getElementById('responseText');
ta.value += "下线成功,欢迎再次上线!\r\n";
};
}else{
alert("您的浏览器不支持WebSocket协议!");
}
}
//发送消息
function send(message){
if(!window.WebSocket){return;}
// WebSocket 是否建立连接
if(socket.readyState == WebSocket.OPEN){
var msgData = {
uid:document.getElementById('uid').value,
targetId:document.getElementById('targetId').value,
msg:document.getElementById('message').value,
};
socket.send(JSON.stringify(msgData));
document.getElementsByName('message').value = "";
}else{
alert("WebSocket 连接没有建立成功!");
}
}
function closeConnect() {
socket.close()
}
</script>
</html>
二、ws升级wss
ws升级wss需要使用ssL证书,ssL证书是为网络通信提供安全及数据完整性的一种安全协议。TLS与SSL在传输层与应用层之间对网络连接进行加密。
2.1、自签证书
注意:自签的证书有且之能在本机使用,如将A机生成的证书拷贝B机使用也会出现同样的错误
1.1、win+r输入cmd打开命令窗口
1.2、生成自己jks文件,指定文件保存位置
注意:自签的证书有且之能在本机使用,如将A机生成的证书拷贝B机使用也会出现同样的错误
jks文件地址:C:\Users\admin\Desktop\aa\mystore.jks
dns解析的域名:www.wu.com
keytool -genkey -alias server -keyalg RSA -validity 3650 -keystore C:\Users\admin\Desktop\aa\mystore.jks -ext san=dns:www.wu.com -storepass 1234567
# keytool -genkey -alias server -keyalg RSA -validity 3650 -keystore jks文件地址 -ext san=dns:解析域名 -storepass 密码
查看信息jks文件信息
# keytool -list -keystore jks地址 -v
keytool -list -keystore mystore.jks -v
1.3、生成cer证书
# keytool -alias server -exportcert -keystore jks文件地址 -file 生成证书的位置 -storepass jks密码
keytool -alias server -exportcert -keystore C:\Users\admin\Desktop\aa\mystore.jks -file C:\Users\admin\Desktop\aa\mystore.cer -storepass 1234567
成功生成的证书:
1.4、NettyServer配置改动
1.4.1、NettyServer配置改动
新建一个ssl工具类
public class SslUtil {
/**
* @param type 证书类型
* @param path 证书路径
* @param password 证书密码
* @throws Exception
*/
public static SSLContext createSSLContext(String type , String path , String password) throws Exception {
InputStream inputStream = new FileInputStream(path);
char[] passArray = password.toCharArray();
SSLContext sslContext = SSLContext.getInstance("SSLv3");
KeyStore ks = KeyStore.getInstance(type);
//加载keytool 生成的文件
ks.load(inputStream, passArray);
KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
kmf.init(ks, passArray);
sslContext.init(kmf.getKeyManagers(), null, null);
inputStream.close();
return sslContext;
}
}
在NettyServer中添加如下代码
SSLContext sslContext = SslUtil.createSSLContext("JKS","C:\\Users\\admin\\Desktop\\aa\\mystore.jks","1234567");
//SSLEngine 此类允许使用ssl安全套接层协议进行安全通信
SSLEngine engine = sslContext.createSSLEngine();
engine.setUseClientMode(false); //服务器端模式
engine.setNeedClientAuth(false); //不需要验证客户端
ch.pipeline().addFirst("ssl", new SslHandler(engine));
1.4.2、前端连接改动,将ws改为wss,并将127.0.0.1改成www.wu.com(生成jks文件时指定dns解析为www.wu.com),hosts文件添加域名映射
修改hosts文件
Windows系统中hosts文件路径:C:\Windows\System32\drivers\etc\hosts
在末尾添加域名ip映射,修改hosts后需要刷新DNS缓存使之生效
在cmd命令行中执行命令:ipconfig/flushdns
1.5、服务启动,前端页面访问
后端报错 io.netty.handler.codec.DecoderException: javax.net.ssl.SSLException: Received fatal alert: certificate_unknown,
这是因为jdk认为我们的证书不可信,我们需要将证书变成jdk信任的证书。
1.6、对证书添加信任
- 双击cer证书,安装证书
- 导入证书到信任库中
密钥库默认密码:changeit
keytool -import -alias mycacert -file C:\Users\admin\Desktop\aa\mystore.cer -storepass changeit
- 查看密钥库中的证书
keytool -v -list -keystore .keystore
- 查看密钥库中指定的证书
# keytool -list -keystore .keystore -alias 证书别名
keytool -list -keystore .keystore -alias foo
检查密钥库,当证书成功导入后们就可以重新启动服务,连接websocket
2.2、权威机构签发的证书
1. 下载证书jks文件,文件密钥
2. 获取网站证书
3. 安装证书、导入证书到信任库中和上述相同,不在赘述。
三、证书失效
当证书过了有效期限,证书则需要更换,在更换新证书前。务必将旧证书卸载并将证书移除密钥库。
3.1、 证书卸载
Win+R ==> certmgr.msc ==> 找到对应的证书,点击上面的删除
注意:这里的“颁发给“名称,我们点击要安装的cer时,看到的名称,可能并不是我们的XXX.cer这个文件名
3.2、将证书从密钥库中移除
# keytool -delete -alias 证书别名 -storepass 密钥库密码
keytool -delete -alias mycacert -storepass changeit
3.3、重新获取证书,按上述步骤操作即可