当前位置: 首页 > news >正文

毕业设计——基于springboot的聊天系统设计与实现(服务端 + 客户端 + web端)

整个工程包含三个部分:

1、聊天服务器

聊天服务器的职责一句话解释:负责接收所有用户发送的消息,并将消息转发给目标用户。

聊天服务器没有任何界面,但是却是IM中最重要的角色,为表达敬意,必须要给它放个效果图:
2021-05-11 10:41:40.037 INFO 9392 — [ntLoopGroup-3-1] c.e.o.s.netty.handler.HeartBeatHandler : server收到心跳包:{“time”:1620700900029,“messageType”:“99”}
2021-05-11 10:41:50.049 INFO 9392 — [ntLoopGroup-3-1] c.e.o.s.n.handler.BussMessageHandler : 收到消息:{“time”:1620700910045,“messageType”:“14”,“sendUserName”:“guodegang”,“recvUserName”:“yuqian”,“sendMessage”:“于老师你好”}
2021-05-11 10:41:50.055 INFO 9392 — [ntLoopGroup-3-2] c.e.o.s.netty.executor.SendMsgExecutor : 消息转发成功:{“time”:1620700910052,“messageType”:“14”,“sendUserName”:“guodegang”,“recvUserName”:“yuqian”,“sendMessage”:“于老师你好”}
2021-05-11 10:41:54.068 INFO 9392 — [ntLoopGroup-3-2] c.e.o.s.netty.handler.HeartBeatHandler : server收到心跳包:{“time”:1620700914064,“messageType”:“99”}
2021-05-11 10:41:57.302 INFO 9392 — [ntLoopGroup-3-2] c.e.o.s.n.handler.BussMessageHandler : 收到消息:{“time”:1620700917301,“messageType”:“14”,“sendUserName”:“yuqian”,“recvUserName”:“guodegang”,“sendMessage”:“郭老师你好”}
2021-05-11 10:41:57.304 INFO 9392 — [ntLoopGroup-3-1] c.e.o.s.netty.executor.SendMsgExecutor : 消息转发成功:{“time”:1620700917303,“messageType”:“14”,“sendUserName”:“yuqian”,“recvUserName”:“guodegang”,“sendMessage”:“郭老师你好”}
2021-05-11 10:42:05.050 INFO 9392 — [ntLoopGroup-3-1] c.e.o.s.netty.handler.HeartBeatHandler : server收到心跳包:{“time”:1620700925049,“messageType”:“99”}
2021-05-11 10:42:12.309 INFO 9392 — [ntLoopGroup-3-2] c.e.o.s.netty.handler.HeartBeatHandler : server收到心跳包:{“time”:1620700932304,“messageType”:“99”}
2021-05-11 10:42:20.066 INFO 9392 — [ntLoopGroup-3-1] c.e.o.s.netty.handler.HeartBeatHandler : server收到心跳包:{“time”:1620700940050,“messageType”:“99”}
2021-05-11 10:42:27.311 INFO 9392 — [ntLoopGroup-3-2] c.e.o.s.netty.handler.HeartBeatHandler : server收到心跳包:{“time”:1620700947309,“messageType”:“99”}
2021-05-11 10:42:35.070 INFO 9392 — [ntLoopGroup-3-1] c.e.o.s.netty.handler.HeartBeatHandler : server收到心跳包:{“time”:1620700955068,“messageType”:“99”}
2021-05-11 10:42:42.316 INFO 9392 — [ntLoopGroup-3-2] c.e.o.s.netty.handler.HeartBeatHandler : server收到心跳包:{“time”:1620700962312,“messageType”:“99”}
2021-05-11 10:42:50.072 INFO 9392 — [ntLoopGroup-3-1] c.e.o.s.netty.handler.HeartBeatHandler : server收到心跳包:{“time”:1620700970071,“messageType”:“99”}
2021-05-11 10:42:57.316 INFO 9392 — [ntLoopGroup-3-2] c.e.o.s.netty.handler.HeartBeatHandler : server收到心跳包:{“time”:1620700977315,“messageType”:“99”}

从效果图我们看到了一些内容:收到心跳包、收到消息,转发消息,这些内容后面会详细讲解。

2、聊天客户端

聊天客户端的职责一句话解释:登陆,给别人发聊天内容,收其它人发给自己的聊天内容。

下面为方便演示,我会打开两个客户端,用两个不同用户登陆,然后发消息。

3、Web管理控制台

目前只做了一个账户管理,具体看图吧:

概要设计
1、技术选型

1)聊天服务端

聊天服务器与客户端通过TCP协议进行通信,使用长连接、全双工通信模式,基于经典通信框架Netty实现。

那么什么是长连接?顾名思义,客户端和服务器连上后,会在这条连接上面反复收发消息,连接不会断开。与长连接对应的当然就是短连接了,短连接每次发消息之前都需要先建立连接,然后发消息,最后断开连接。显然,即时聊天适合使用长连接。

那么什么又是全双工?当长连接建立起来后,在这条连接上既有上行的数据,又有下行的数据,这就叫全双工。那么对应的半双工、单工,大家自行百度吧。

2)Web管理控制台

Web管理端使用SpringBoot脚手架,前端使用Layuimini(一个基于Layui前端框架封装的前端框架),后端使用SpringMVC+Jpa+Shiro。

3)聊天客户端

使用SpringBoot+JavaFX,做了一个极其简陋的客户端,JavaFX是一个开发Java桌面程序的框架,本人也是第一次使用,代码中的写法都是网上查的,这并不是本文的重点,有兴趣的仔细百度吧。

4)SpringBoot

以上三个组件,全部以SpringBoot做为脚手架开发。

5)代码构建

Maven。

2、数据库设计

我们只简单用到一张用户表,比较简单直接贴脚本:
CREATE TABLE sys_user (
id bigint(20) NOT NULL AUTO_INCREMENT COMMENT ‘主键’,
user_name varchar(64) DEFAULT NULL COMMENT ‘用户名:登陆账号’,
pass_word varchar(128) DEFAULT NULL COMMENT ‘密码’,
name varchar(16) DEFAULT NULL COMMENT ‘昵称’,
sex char(1) DEFAULT NULL COMMENT ‘性别:1-男,2女’,
status bit(1) DEFAULT NULL COMMENT ‘用户状态:1-有效,0-无效’,
online bit(1) DEFAULT NULL COMMENT ‘在线状态:1-在线,0-离线’,
salt varchar(128) DEFAULT NULL COMMENT ‘密码盐值’,
admin bit(1) DEFAULT NULL COMMENT ‘是否管理员(只有管理员才能登录Web端):1-是,0-否’,
PRIMARY KEY (id)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;

这张表都在什么时候用到?

1)Web管理端登陆的时候;
2)聊天客户端将登陆请求发送到聊天服务端时,聊天服务端进行用户认证;
3)聊天客户端的好友列表加载。

3、通信设计

本节将会是本文的核心内容之一,主要描述通信报文协议格式、以及通信报文的交互场景。

1)报文协议格式

下面这张图应该能说明99%了:
图片
剩下的1%在这里说:

a)粘包问题,TCP长连接中,粘包是第一个需要解决的问题。通俗的讲,粘包的意思是消息接收方往往收到的不是“整个”报文,有时候比“整个”多一点,有时候比“整个”少一点,这样就导致接收方无法解析这个报文。那么上图中的头8个字节就为了解决这个问题,接收方根据头8个字节标识的长度来获取到“整个”报文,从而进行正常的业务处理;

b)2字节报文类型,为了方便解析报文而设计。根据这两个字节将后面的json转成相应的实体以便进行后续处理;

c)变长报文体实际上就是json格式的串,当然,你可以自己设计报文格式,我这里为了方便处理就直接放json了;

d)当然,你可以把报文设计的更复杂、更专业,比如加密、加签名等。

2)报文交互场景

a)登陆
图片

b)发送消息-成功
图片

c)发送消息-目标客户端不在线
图片

d)发送消息-目标客户端在线,但消息转发失败
图片

编码实现
前面说了那么多,现在总得说点有用的。

1、先说说Netty

Netty是一个相当优秀的通信框架,大多数的顶级开源框架中都有Netty的身影。具体它有多么优秀,建议大家自行百度,我不如百度说的好。我只从应用方面说说Netty。

应用过程中,它最核心的东西叫handler,我们可以简单理解它为消息处理器。收到的消息和出去的消息都会经过一系列的handler加工处理。收到的消息我们叫它入站消息,发出去的消息我们叫它出站消息,因此handler又分为出站handler和入站handler。收到的消息只会被入站handler处理,发出去的消息只会被出站handler处理。

举个例子,我们从网络上收到的消息是二进制的字节码,我们的目标是将消息转换成java bean,这样方便我们程序处理,针对这个场景我设计这么几个入handler:

1)将字节转换成String的handler;
2)将String转成java bean的handler;
3)对java bean进行业务处理的handler。

发出去的消息呢,我设计这么几个出站handler:

1)java bean 转成String的handler;
2)String转成byte的handler。

以上是关于handler的说明。

接下来再说一下Netty的异步。异步的意思是当你做完一个操作后,不会立马得到操作结果,而是有结果后Netty会通知你。通过下面的一段代码来说明:
channel.writeAndFlush(sendMsgRequest).addListener(new GenericFutureListener<Future<? super Void>>() {
@Override
public void operationComplete(Future<? super Void> future) throws Exception {
if (future.isSuccess()){
logger.info(“消息发送成功:{}”,sendMsgRequest);
}else {
logger.info(“消息发送失败:{}”,sendMsgRequest);
}
}
});

上面的writeAndFlush操作无法立即返回结果,如果你关注结果,那么为他添加一个listener,有结果后在listener中响应。

到这里,百度上搜到的Netty相关的代码你基本就能看懂了。

2、聊天服务端

首先看主入口的代码
public void start(){
EventLoopGroup boss = new NioEventLoopGroup();
EventLoopGroup worker = new NioEventLoopGroup();
ServerBootstrap serverBootstrap = new ServerBootstrap();
serverBootstrap.group(boss, worker)
.channel(NioServerSocketChannel.class)
.option(ChannelOption.SO_BACKLOG, 1024)
.handler(new LoggingHandler(LogLevel.INFO))
.childHandler(new ChannelInitializer() {
@Override
protected void initChannel(SocketChannel ch) throws Exception {
//心跳
ch.pipeline().addLast(new IdleStateHandler(25, 20, 0, TimeUnit.SECONDS));
//收整包
ch.pipeline().addLast(new StringLengthFieldDecoder());
//转字符串
ch.pipeline().addLast(new StringDecoder(Charset.forName(“UTF-8”)));
//json转对象
ch.pipeline().addLast(new JsonDecoder());
//心跳
ch.pipeline().addLast(new HeartBeatHandler());
//实体转json
ch.pipeline().addLast(new JsonEncoder());
//消息处理
ch.pipeline().addLast(bussMessageHandler);
}
});
try {
ChannelFuture f = serverBootstrap.bind(port).sync();
f.channel().closeFuture().sync();
}catch (InterruptedException e) {
logger.error(“服务启动失败:{}”, ExceptionUtils.getStackTrace(e));
}finally {
worker.shutdownGracefully();
boss.shutdownGracefully();
}
}

代码中除了initChannel方法中的代码,其他代码都是固定写法。那么什么叫固定写法呢?通俗来讲就是可以Ctrl+c、Ctrl+v。

下面我们着重看initChannel方法里面的代码。这里面就是上面讲到的各种handler,我们下面挨个讲这些handler都是干啥的。

1)IdleStateHandler。这个是Netty内置的一个handler,既是出站handler又是入站handler。它的作用一般是用来实现心跳监测。所谓心跳,就是客户端和服务端建立连接后,服务端要实时监控客户端的健康状态,如果客户端挂了或者hung住了,服务端及时释放相应的资源,以及做出其他处理比如通知运维。所以在我们的场景中,客户端需要定时上报自己的心跳,如果服务端检测到一段时间内没收到客户端上报的心跳,那么及时做出处理,我们这里就是简单的将其连接断开,并修改数据库中相应账户的在线状态。

现在开始说IdleStateHandler,第一个参数叫读超时时间,第二个参数叫写超时时间,第三个参数叫读写超时时间,第四个参数时时间单位秒。这个handler表达的意思是当25秒内没读到客户端的消息,或者20秒内没往客户端发消息,就会产生一个超时事件。那么这个超时事件我们该对他做什么处理呢,请看下一条。

2)HeartBeatHandler。结合a)一起看,当发生超时事件时,HeartBeatHandler会收到这个事件,并对它做出处理:第一将链接断开;第二讲数据库中相应的账户更新为不在线状态。
public class HeartBeatHandler extends ChannelInboundHandlerAdapter {
private static Logger logger = LoggerFactory.getLogger(HeartBeatHandler.class);

@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
if (evt instanceof IdleStateEvent){
IdleStateEvent event = (IdleStateEvent)evt;
if (event.state() == IdleState.READER_IDLE) {
//读超时,应将连接断掉
InetSocketAddress socketAddress = (InetSocketAddress)ctx.channel().remoteAddress();
String ip = socketAddress.getAddress().getHostAddress();
ctx.channel().disconnect();
logger.info(“【{}】连接超时,断开”,ip);
String userName = SessionManager.removeSession(ctx.channel());
SpringContextUtil.getBean(UserService.class).updateOnlineStatus(userName,Boolean.FALSE);
}else {
super.userEventTriggered(ctx, evt);
}
}else {
super.userEventTriggered(ctx, evt);
}

}

@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
if (msg instanceof HeartBeat){
//收到心跳包,不处理
logger.info(“server收到心跳包:{}”,msg);
return;
}
super.channelRead(ctx, msg);
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
}

3)StringLengthFieldDecoder。这是个入站handler,他的作用就是解决上面提到的粘包问题:
public class StringLengthFieldDecoder extends LengthFieldBasedFrameDecoder {
public StringLengthFieldDecoder() {
super(1010241024,0,8,0,8);
}

@Override
protected long getUnadjustedFrameLength(ByteBuf buf, int offset, int length, ByteOrder order) {
buf = buf.order(order);
byte[] lenByte = new byte[length];
buf.getBytes(offset, lenByte);
String lenStr = new String(lenByte);
Long len = Long.valueOf(lenStr);
return len;
}
1
2
3
4
5
6
7
8
9
}

只需要集成Netty提供的LengthFieldBasedFrameDecoder 类,并重写getUnadjustedFrameLength方法即可。

首先看构造方法中的5个参数。第一个表示能处理的包的最大长度;第二三个参数应该结合起来理解,表示长度字段从第几位开始,长度的长度是多少,也就是上面报文格式协议中的头8个字节;第四个参数表示长度是否需要校正,举例理解,比如头8个字节解析出来的长度=包体长度+头8个字节的长度,那么这里就需要校正8个字节,我们的协议中长度只包含报文体,因此这个参数填0;最后一个参数,表示接收到的报文是否要跳过一些字节,本例中设置为8,表示跳过头8个字节,因此经过这个handler后,我们收到的数据就只有报文本身了,不再包含8个长度字节了。

再看getUnadjustedFrameLength方法,其实就是将头8个字符串型的长度为转换成long型。重写完这个方法后,Netty就知道如何收一个“完整”的数据包了。

4)StringDecoder。这个是Netty自带的入站handler,会将字节流以指定的编码解析成String。

5)JsonDecoder。是我们自定义的一个入站handler,目的是将json String转换成java bean,以方便后续处理:
public class JsonDecoder extends MessageToMessageDecoder {
@Override
protected void decode(ChannelHandlerContext channelHandlerContext, String o, List list) throws Exception {
Message msg = MessageEnDeCoder.decode(o);
list.add(msg);
}

}

这里会调用我们自定义的一个编解码帮助类进行转换:
public static Message decode(String message){
if (StringUtils.isEmpty(message) || message.length() < 2){
return null;
}
String type = message.substring(0,2);
message = message.substring(2);
if (type.equals(LoginRequest)){
return JsonUtil.jsonToObject(message,LoginRequest.class);
}else if (type.equals(LoginResponse)){
return JsonUtil.jsonToObject(message,LoginResponse.class);
}else if (type.equals(LogoutRequest)){
return JsonUtil.jsonToObject(message,LogoutRequest.class);
}else if (type.equals(LogoutResponse)){
return JsonUtil.jsonToObject(message,LogoutResponse.class);
}else if (type.equals(SendMsgRequest)){
return JsonUtil.jsonToObject(message,SendMsgRequest.class);
}else if (type.equals(SendMsgResponse)){
return JsonUtil.jsonToObject(message,SendMsgResponse.class);
}else if (type.equals(HeartBeat)){
return JsonUtil.jsonToObject(message,HeartBeat.class);
}
return null;
}

6)BussMessageHandler。先看这个入站handler,是我们的一个业务处理主入口,他的主要工作就是将消息分发给线程池去处理,另外还负载一个小场景,当客户端主动断开时,需要将相应的账户数据库中状态更新为不在线。
public class BussMessageHandler extends ChannelInboundHandlerAdapter {
private static Logger logger = LoggerFactory.getLogger(BussMessageHandler.class);

@Autowired
private TaskDispatcher taskDispatcher;

@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
logger.info(“收到消息:{}”,msg);
if (msg instanceof Message){
taskDispatcher.submit(ctx.channel(),(Message)msg);
}
}

@Override
public void channelInactive(ChannelHandlerContext ctx) throws Exception {
//客户端连接断开
InetSocketAddress socketAddress = (InetSocketAddress)ctx.channel().remoteAddress();
String ip = socketAddress.getAddress().getHostAddress();
logger.info(“客户端断开:{}”,ip);
String userName = SessionManager.removeSession(ctx.channel());
SpringContextUtil.getBean(UserService.class).updateOnlineStatus(userName,Boolean.FALSE);
super.channelInactive(ctx);
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
}

接下来还差线程池的处理逻辑,也非常简单,就是将任务封装成executor然后交给线程池处理:
public class TaskDispatcher {
private ThreadPoolExecutor threadPool;

public TaskDispatcher(){
int corePoolSize = 15;
int maxPoolSize = 50;
int keepAliveSeconds = 30;
int queueCapacity = 1024;
BlockingQueue queue = new LinkedBlockingQueue<>(queueCapacity);
this.threadPool = new ThreadPoolExecutor(
corePoolSize, maxPoolSize, keepAliveSeconds, TimeUnit.SECONDS,
queue);
}

public void submit(Channel channel, Message msg){
ExecutorBase executor = null;
String messageType = msg.getMessageType();
if (messageType.equals(MessageEnDeCoder.LoginRequest)){
executor = new LoginExecutor(channel,msg);
}
if (messageType.equalsIgnoreCase(MessageEnDeCoder.SendMsgRequest)){
executor = new SendMsgExecutor(channel,msg);
}
if (executor != null){
this.threadPool.submit(executor);
}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
}

接下来看一下消息转发executor是怎么做的:
public class SendMsgExecutor extends ExecutorBase {
private static Logger logger = LoggerFactory.getLogger(SendMsgExecutor.class);

public SendMsgExecutor(Channel channel, Message message) {
super(channel, message);
}

@Override
public void run() {
SendMsgResponse response = new SendMsgResponse();
response.setMessageType(MessageEnDeCoder.SendMsgResponse);
response.setTime(new Date());
SendMsgRequest request = (SendMsgRequest)message;
String recvUserName = request.getRecvUserName();
String sendContent = request.getSendMessage();
Channel recvChannel = SessionManager.getSession(recvUserName);
if (recvChannel != null){
SendMsgRequest sendMsgRequest = new SendMsgRequest();
sendMsgRequest.setTime(new Date());
sendMsgRequest.setMessageType(MessageEnDeCoder.SendMsgRequest);
sendMsgRequest.setRecvUserName(recvUserName);
sendMsgRequest.setSendMessage(sendContent);
sendMsgRequest.setSendUserName(request.getSendUserName());
recvChannel.writeAndFlush(sendMsgRequest).addListener(new GenericFutureListener<Future<? super Void>>() {
@Override
public void operationComplete(Future<? super Void> future) throws Exception {
if (future.isSuccess()){
logger.info(“消息转发成功:{}”,sendMsgRequest);
response.setResultCode(“0000”);
response.setResultMessage(String.format(“发给用户[%s]消息成功”,recvUserName));
channel.writeAndFlush(response);
}else {
logger.error(ExceptionUtils.getStackTrace(future.cause()));
logger.info(“消息转发失败:{}”,sendMsgRequest);
response.setResultCode(“9999”);
response.setResultMessage(String.format(“发给用户[%s]消息失败”,recvUserName));
channel.writeAndFlush(response);
}
}
});
}else {
logger.info(“用户{}不在线,消息转发失败”,recvUserName);
response.setResultCode(“9999”);
response.setResultMessage(String.format(“用户[%s]不在线”,recvUserName));
channel.writeAndFlush(response);
}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
}

整体逻辑:一获取要把消息发给那个账号;二获取该账号对应的连接;三在此连接上发送消息;四获取消息发送结果,将结果发给消息“发起者”。

下面是登陆处理的executor:
public class LoginExecutor extends ExecutorBase {
private static Logger logger = LoggerFactory.getLogger(LoginExecutor.class);

public LoginExecutor(Channel channel, Message message) {
super(channel, message);
}
@Override
public void run() {
LoginRequest request = (LoginRequest)message;
String userName = request.getUserName();
String password = request.getPassword();
UserService userService = SpringContextUtil.getBean(UserService.class);
boolean check = userService.checkLogin(userName,password);
LoginResponse response = new LoginResponse();
response.setUserName(userName);
response.setMessageType(MessageEnDeCoder.LoginResponse);
response.setTime(new Date());
response.setResultCode(check?“0000”:“9999”);
response.setResultMessage(check?“登陆成功”:“登陆失败,用户名或密码错”);
if (check){
userService.updateOnlineStatus(userName,Boolean.TRUE);
SessionManager.addSession(userName,channel);
}
channel.writeAndFlush(response).addListener(new GenericFutureListener<Future<? super Void>>() {
@Override
public void operationComplete(Future<? super Void> future) throws Exception {
//登陆失败,断开连接
if (!check){
logger.info(“用户{}登陆失败,断开连接”,((LoginRequest) message).getUserName());
channel.disconnect();
}
}
});
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
}

登陆逻辑也不复杂,登陆成功则更新用户在线状态,并且无论登陆成功还是失败,都会返一个登陆应答。同时,如果登陆校验失败,在返回应答成功后,需要将链接断开。

7)JsonEncoder。最后看这个唯一的出站handler,服务端发出去的消息都会被出站handler处理,他的职责就是将java bean转成我们之前定义的报文协议格式:
public class JsonEncoder extends MessageToByteEncoder {
@Override
protected void encode(ChannelHandlerContext channelHandlerContext, Message message, ByteBuf byteBuf) throws Exception {
String msgStr = MessageEnDeCoder.encode(message);
int length = msgStr.getBytes(Charset.forName(“UTF-8”)).length;
String str = String.valueOf(length);
String lenStr = StringUtils.leftPad(str,8,‘0’);
msgStr = lenStr + msgStr;
byteBuf.writeBytes(msgStr.getBytes(“UTF-8”));
}
}

8)SessionManager。剩下最后一个东西没说,这个是用来保存每个登陆成功账户的链接的,底层是个map,key为用户账户,value为链接:
public class SessionManager {
private static ConcurrentHashMap<String,Channel> sessionMap = new ConcurrentHashMap<>();

public static void addSession(String userName,Channel channel){
sessionMap.put(userName,channel);
}

public static String removeSession(String userName){
sessionMap.remove(userName);
return userName;
}

public static String removeSession(Channel channel){
for (String key:sessionMap.keySet()){
if (channel.id().asLongText().equalsIgnoreCase(sessionMap.get(key).id().asLongText())){
sessionMap.remove(key);
return key;
}
}
return null;
}

public static Channel getSession(String userName){
return sessionMap.get(userName);
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
}

到这里,整个服务端的逻辑就走完了,是不是,很简单呢!

3、聊天客户端

客户端中界面相关的东西是基于JavaFX框架做的,这个我是第一次用,所以不打算讲这块,怕误导大家。主要还是讲Netty作为客户端是如何跟服务端通信的。
按照惯例,还是先贴出主入口:
public void login(String userName,String password) throws Exception {
Bootstrap clientBootstrap = new Bootstrap();
EventLoopGroup clientGroup = new NioEventLoopGroup();
try {
clientBootstrap.group(clientGroup)
.channel(NioSocketChannel.class)
.option(ChannelOption.TCP_NODELAY, true)
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS,10000);
clientBootstrap.handler(new ChannelInitializer() {
@Override
protected void initChannel(SocketChannel ch) throws Exception {
ch.pipeline().addLast(new IdleStateHandler(20, 15, 0, TimeUnit.SECONDS));
ch.pipeline().addLast(new StringLengthFieldDecoder());
ch.pipeline().addLast(new StringDecoder(Charset.forName(“UTF-8”)));
ch.pipeline().addLast(new JsonDecoder());
ch.pipeline().addLast(new JsonEncoder());
ch.pipeline().addLast(bussMessageHandler);
ch.pipeline().addLast(new HeartBeatHandler());
}
});
ChannelFuture future = clientBootstrap.connect(server,port).sync();
if (future.isSuccess()){
channel = (SocketChannel)future.channel();
LoginRequest request = new LoginRequest();
request.setTime(new Date());
request.setUserName(userName);
request.setPassword(password);
request.setMessageType(MessageEnDeCoder.LoginRequest);
channel.writeAndFlush(request).addListener(new GenericFutureListener<Future<? super Void>>() {
@Override
public void operationComplete(Future<? super Void> future) throws Exception {
if (future.isSuccess()){
logger.info(“登陆消息发送成功”);
}else {
logger.info(“登陆消息发送失败:{}”, ExceptionUtils.getStackTrace(future.cause()));
Platform.runLater(new Runnable() {
@Override
public void run() {
LoginController.setLoginResult(“网络错误,登陆消息发送失败”);
}
});
}
}
});
}else {
clientGroup.shutdownGracefully();
throw new RuntimeException(“网络错误”);
}
}catch (Exception e){
clientGroup.shutdownGracefully();
throw new RuntimeException(“网络错误”);
}
}

对这段代码,我们主要关注这几点:一所有handler的初始化;二connect服务端。

所有handler中,除了bussMessageHandler是客户端特有的外,其他的handler在服务端章节已经讲过了,不再赘述。

1)先看连接服务端的操作。首先发起连接,连接成功后发送登陆报文。发起连接需要对成功和失败进行处理。发送登陆报文也需要对成功和失败进行处理。注意,这里的成功失败只是代表当前操作的网络层面的成功失败,这时候并不能获取服务端返回的应答中的业务层面的成功失败,如果不理解这句话,可以翻看前面讲过的“异步”相关内容。

2)BussMessageHandler。整体流程还是跟服务端一样,将受到的消息扔给线程池处理,我们直接看处理消息的各个executor。

先看客户端发出登陆请求后,收到登陆应答消息后是怎么处理的(这段代码可以结合1)的内容一起理解):
public class LoginRespExecutor extends ExecutorBase {
private static Logger logger = LoggerFactory.getLogger(LoginRespExecutor.class);

public LoginRespExecutor(Channel channel, Message message) {
super(channel, message);
}

@Override
public void run() {
LoginResponse response = (LoginResponse)message;
logger.info(“登陆结果:{}->{}”,response.getResultCode(),response.getResultMessage());
if (!response.getResultCode().equals(“0000”)){
Platform.runLater(new Runnable() {
@Override
public void run() {
LoginController.setLoginResult(“登陆失败,用户名或密码错误”);
}
});
}else {
LoginController.setCurUserName(response.getUserName());
ClientApplication.getScene().setRoot(SpringContextUtil.getBean(MainView.class).getView());
}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
}

接下来看客户端是怎么发聊天信息的:
public void sendMessage(Message message) {
channel.writeAndFlush(message).addListener(new GenericFutureListener<Future<? super Void>>() {
@Override
public void operationComplete(Future<? super Void> future) throws Exception {
SendMsgRequest send = (SendMsgRequest)message;
if (future.isSuccess()){
Platform.runLater(new Runnable() {
@Override
public void run() {
MainController.setMessageHistory(String.format(“[我]在[%s]发给[%s]的消息[%s],发送成功”,
DateFormatUtils.format(send.getTime(),“yyyy-MM-dd HH:mm:ss”),send.getRecvUserName(),send.getSendMessage()));
}
});
}else {
Platform.runLater(new Runnable() {
@Override
public void run() {
MainController.setMessageHistory(String.format(“[我]在[%s]发给[%s]的消息[%s],发送失败”,
DateFormatUtils.format(send.getTime(),“yyyy-MM-dd HH:mm:ss”),send.getRecvUserName(),send.getSendMessage()));
}
});
}
}
});
}

实际上,到这里通信相关的代码已经贴完了。剩下的都是界面处理相关的代码,不再贴了。

客户端,是不是,非常简单!

4、Web管理端

Web管理端可以说是更没任何技术含量,就是Shiro登陆认证、列表增删改查。增删改没什么好说的,下面重点说一下Shiro登陆和列表查询。

1)Shiro登陆

首先定义一个Realm,至于这是什么概念,自行百度吧,这里并不是本文重点:
public class UserDbRealm extends AuthorizingRealm {
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
return null;
}

@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
RequestAttributes attributes = RequestContextHolder.getRequestAttributes();

UsernamePasswordToken upToken = (UsernamePasswordToken) authenticationToken;
String username = upToken.getUsername();
String password = "";
if (upToken.getPassword() != null)
{password = new String(upToken.getPassword());
}
// TODO: 2021/5/13 校验用户名密码,不通过则抛认证异常即可 
ShiroUser user = new ShiroUser();
SimpleAuthenticationInfo info = new SimpleAuthenticationInfo(user, password, getName());
return info;

}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
}

接下来把这个Realm注册成Spring Bean,同时定义过滤链:
@Bean
public Realm realm() {
UserDbRealm realm = new UserDbRealm();
realm.setAuthorizationCachingEnabled(true);
realm.setCacheManager(cacheManager());
return realm;
}

@Bean
public ShiroFilterChainDefinition shiroFilterChainDefinition() {
DefaultShiroFilterChainDefinition chainDefinition = new DefaultShiroFilterChainDefinition();
chainDefinition.addPathDefinition(“/css/“, “anon”);
chainDefinition.addPathDefinition(”/img/
”, “anon”);
chainDefinition.addPathDefinition(“/js/“, “anon”);
chainDefinition.addPathDefinition(”/logout", “logout”);
chainDefinition.addPathDefinition(“/login”, “anon”);
chainDefinition.addPathDefinition(“/captchaImage”, “anon”);
chainDefinition.addPathDefinition("/
”, “authc”);
return chainDefinition;
}
1
2
3
4
5
6
7
8
9
10
11
12
到现在为止,Shiro配置好了,下面看如何调起登陆:
@PostMapping(“/login”)
@ResponseBody
public Result login(String username, String password, Boolean rememberMe)
{
Result ret = new Result<>();
UsernamePasswordToken token = new UsernamePasswordToken(username, password);
Subject subject = SecurityUtils.getSubject();
try
{
subject.login(token);
return ret;
}
catch (AuthenticationException e)
{
String msg = “用户或密码错误”;
if (StringUtils.isNotEmpty(e.getMessage()))
{
msg = e.getMessage();
}
ret.setCode(Result.FAIL);
ret.setMessage(msg);
return ret;
}
}

登陆代码就这么愉快的完成了。

2)列表查询

查是个很简单的操作,但是却是所有web系统中使用最频繁的操作。因此,做一个通用性的封装,非常有必要。以下代码不做过多讲解,初级工程师到高级工程师,就差这段代码了(手动捂脸):

a)Controller
@RequestMapping(“/query”)
@ResponseBody
public Result query(@RequestParam Map<String,Object> params, String sort, String order, Integer pageIndex, Integer pageSize){
Page page = userService.query(params,sort,order,pageIndex,pageSize);
Result ret = new Result<>();
ret.setData(page);
return ret;
}

b)Service
@Autowired
private UserDao userDao;
@Autowired
private QueryService queryService;

public Page query(Map<String,Object> params, String sort, String order, Integer pageIndex, Integer pageSize){
return queryService.query(userDao,params,sort,order,pageIndex,pageSize);
}
1
2
3
public class QueryService {
public com.easy.okim.common.model.Page query(JpaSpecificationExecutor dao, Map<String,Object> filters, String sort, String order, Integer pageIndex, Integer pageSize){
com.easy.okim.common.model.Page ret = new com.easy.okim.common.model.Page();
Map<String,Object> params = new HashMap<>();
if (filters != null){
filters.remove(“sort”);
filters.remove(“order”);
filters.remove(“pageIndex”);
filters.remove(“pageSize”);
for (String key:filters.keySet()){
Object value = filters.get(key);
if (value != null && StringUtils.isNotEmpty(value.toString())){
params.put(key,value);
}
}
}
Pageable pageable = null;
pageIndex = pageIndex - 1;
if (StringUtils.isEmpty(sort)){
pageable = PageRequest.of(pageIndex,pageSize);
}else {
Sort s = Sort.by(Sort.Direction.ASC,sort);
if (StringUtils.isNotEmpty(order) && order.equalsIgnoreCase(“desc”)){
s = Sort.by(Sort.Direction.DESC,sort);
}
pageable = PageRequest.of(pageIndex,pageSize,s);
}
Page page = null;
if (params.size() ==0){
page = dao.findAll(null,pageable);
}else {
Specification specification = new Specification() {
@Override
public Predicate toPredicate(Root root, CriteriaQuery<?> criteriaQuery, CriteriaBuilder builder) {
List predicates = new ArrayList<>();
for (String filter : params.keySet()) {
Object value = params.get(filter);
if (value == null || StringUtils.isEmpty(value.toString())) {
continue;
}
String field = filter;
String operator = “=”;
String[] arr = filter.split(“|”);
if (arr.length == 2) {
field = arr[0];
operator = arr[1];
}
if (arr.length == 3) {
field = arr[0];
operator = arr[1];
String type = arr[2];
if (type.equalsIgnoreCase(“boolean”)){
value = Boolean.parseBoolean(value.toString());
}else if (type.equalsIgnoreCase(“integer”)){
value = Integer.parseInt(value.toString());
}else if (type.equalsIgnoreCase(“long”)){
value = Long.parseLong(value.toString());
}
}
String[] names = StringUtils.split(field, “.”);
Path expression = root.get(names[0]);
for (int i = 1; i < names.length; i++) {
expression = expression.get(names[i]);
}
// logic operator
switch (operator) {
case “=”:
predicates.add(builder.equal(expression, value));
break;
case “!=”:
predicates.add(builder.notEqual(expression, value));
break;
case “like”:
predicates.add(builder.like(expression, “%” + value + “%”));
break;
case “>”:
predicates.add(builder.greaterThan(expression, (Comparable) value));
break;
case “<”:
predicates.add(builder.lessThan(expression, (Comparable) value));
break;
case “>=”:
predicates.add(builder.greaterThanOrEqualTo(expression, (Comparable) value));
break;
case “<=”:
predicates.add(builder.lessThanOrEqualTo(expression, (Comparable) value));
break;
case “isnull”:
predicates.add(builder.isNull(expression));
break;
case “isnotnull”:
predicates.add(builder.isNotNull(expression));
break;
case “in”:
CriteriaBuilder.In in = builder.in(expression);
String[] arr1 = StringUtils.split(filter.toString(), “,”);
for (String e : arr1) {
in.value(e);
}
predicates.add(in);
break;
}
}

            // 将所有条件用 and 联合起来if (!predicates.isEmpty()) {return builder.and(predicates.toArray(new Predicate[predicates.size()]));}return builder.conjunction();}};page = dao.findAll(specification,pageable);
}
ret.setTotal(page.getTotalElements());
ret.setRows(page.getContent());
return ret;

}
1
2
3
4
5
6
7
8
9
10
11
12
13
}

c)Dao
public interface UserDao extends JpaRepository<User,Long>,JpaSpecificationExecutor {
//啥都不用写,继承Spring Data Jpa提供的类就行了
}

相关文章:

毕业设计——基于springboot的聊天系统设计与实现(服务端 + 客户端 + web端)

整个工程包含三个部分&#xff1a; 1、聊天服务器 聊天服务器的职责一句话解释&#xff1a;负责接收所有用户发送的消息&#xff0c;并将消息转发给目标用户。 聊天服务器没有任何界面&#xff0c;但是却是IM中最重要的角色&#xff0c;为表达敬意&#xff0c;必须要给它放个…...

公告栏功能:自动弹出提醒,重要通知不再错过

发布查询时&#xff0c;您是否遇到这样的困扰&#xff1a; 1、查询发布时间未到&#xff0c;学生进入查询主页后发现未发布任何查询&#xff0c;不断进行咨询。 2、有些重要事项需要进入查询主页就进行强提醒&#xff0c;确保人人可见&#xff0c;用户需要反馈“我知道了”才能…...

网络编程学习

思维导图 代码练习 TCP实现通信 服务器端代码 #include <myhead.h> #define SER_IP "192.168.152.135" #define SER_PORT 8910 int main(int argc, const char *argv[]) {//&#xff11;创建用于监听的套接字int sfd -1;sfd socket(AF_INET,SOCK_STREAM,0)…...

centos物理电脑安装过程(2024年1月)

开机时&#xff1a;CtrlAltDelete键重启电脑 重启开始时&#xff1a;按F11&#xff0c;桌面弹出蓝色框&#xff0c;选择第二个SSK SFD142 1.00&#xff0c;回车 选择install centos7安装 选择后弹出选择安装选项&#xff0c;选择语言 连接无线网络 安装设置&#xff0c;选择磁…...

Web自动化测试平台开发---Automated_platform

一、项目简介 历时一个假期&#xff0c;Automated_platform 第一版完工&#xff0c;是一款基于po模式的自动化测试平台,采用后端技术为DjangoceleryRabbitMQmysql 配置mysql数据库&#xff0c;进行数据迁移后&#xff0c;运行项目后&#xff0c;即可成功访问http://127.0.0.1:8…...

mybatis-plus: 多租户隔离机制

文章目录 一、TenantLineHandler1、介绍2、包含的方法 二、简单实例三、实践1、实现TenantLineHandler接口 一、TenantLineHandler 1、介绍 TenantLineHandler 是 Mybatis-Plus 中用于处理多租户的接口&#xff0c;用于实现多租户数据隔离的具体逻辑。通过实现这个接口&#…...

用Socks5代理游戏,绕过“网络海关”去探险

1. 出海大冒险的开始 在游戏世界&#xff0c;就像在现实生活中一样&#xff0c;有时我们需要越过海洋去探索未知的世界。但是&#xff0c;网络上也有一些“海关”&#xff0c;限制我们访问某些网站或游戏服务器。这就是我们今天要克服的挑战&#xff01; 2. Socks5代理&#xf…...

SpringBoot整合rabbitmq-直连队列,没有交换机(一)

说明&#xff1a;本文章只是springboot和rabbitmq的直连整合&#xff0c;只使用队列生产和消费消息&#xff0c;最简单整合&#xff01; 工程图&#xff1a; A.总体pom.xml <?xml version"1.0" encoding"UTF-8"?><project xmlns"http://…...

CUDA C:查看GPU设备信息

相关阅读 CUDA Chttps://blog.csdn.net/weixin_45791458/category_12530616.html?spm1001.2014.3001.5482 了解自己设备的性能是很有必要的&#xff0c;为此CUDA 运行时(runtime)API给用户也提供了一些查询设备信息的函数&#xff0c;下面的函数用于查看GPU设备的一切信息。 …...

深度学习如何入门?——从“小白”到“大牛”的深度学习之旅

大家好&#xff0c;今天我要和大家分享的主题是“深度学习如何入门”。深度学习作为人工智能领域的重要分支&#xff0c;已经取得了许多令人瞩目的成果。然而&#xff0c;对于初学者来说&#xff0c;深度学习可能显得有些神秘和难以入手。那么&#xff0c;如何才能快速入门深度…...

编译 qsqlmysql.dll QMYSQL driver not loaded

Qt 连接MySQL数据库&#xff0c;没有匹配的qsqlmysql.dll, 需要我们跟进自己Mysql 以及QT版本自行编译的。异常如下图&#xff1a; 安装环境为 VS2019 Qt5.12.12&#xff08;msvc2017_64、以及源码&#xff09; 我的安装地址&#xff1a;D:\Qt\Qt5.12.12 Mysql 8.1.0 默认安…...

Android日历提醒增删改查事件、添加天数不对问题

Android日历提醒是非常好的提醒功能&#xff0c;笔者在做的过程中&#xff0c;遇到的一些问题&#xff0c;现整理出来&#xff0c;以供参考。 一、申请日历的读写权限 <uses-permission android:name"android.permission.WRITE_CALENDAR" /> <uses-permiss…...

【力扣hot100】刷题笔记Day15

前言 今天要刷的是图论&#xff0c;还没学过&#xff0c;先看看《代码随想录》这部分的基础 深搜DFS理论基础 深搜三部曲 确认递归函数、参数确认终止条件处理目前搜索节点出发的路径 代码框架 void dfs(参数) {if (终止条件) {存放结果;return;}for (选择&#xff1a;本节点…...

vue-显示数据

​ v-text和v-html专门用来展示数据, 其作用和插值表达式类似。v-text和v-html可以避免插值闪烁问题. ​ 当网速比较慢时, 使用{{}}来展示数据, 有可能会产生插值闪烁问题。 ​ 插值闪烁: 在数据未加载完成时&#xff0c;页面会显示出原始的{{}}, 过一会才会展示正常数据.语法…...

kali linux常用命令

1. 网络扫描 功能&#xff1a;网络扫描是用来发现网络中的设备、服务和开放端口的过程。 命令&#xff1a;nmap 例子&#xff1a;nmap -sP 192.168.1.0/24 这个命令使用 Nmap 进行网络扫描&#xff0c;列出 192.168.1.0/24 网段中的所有活跃主机。 2. 密码破解 功能&#xf…...

HTML5:七天学会基础动画网页4

backgorund-size 值与说明 length(单位像素):设置背景图片高度和宽度&#xff0c;第一个值设置宽度&#xff0c;第二个值设置高度&#xff0c;如果只给出一个值&#xff0c;第二个是设置为auto。 percentage(百分比):以父元素的百分比来设置背景图像的宽度和高度&#xff0c…...

Web安全之接口鉴权

目录 接口鉴权定义 为什么会有cookie还有session还有token这种技术的存在?...

shardingsphere 集成springboot【水平分表】

创建sharding_sphere数据库 在数据库中创建两张表&#xff0c;t_order_1和t_order_2 分片规则&#xff1a;如果订单编号是偶数添加到t_order_1,如果是奇数添加到t_order_2 创建实体类 public class Order { private Integer id; private Integer orderType; private Int…...

GO 的 Web 开发系列(六)—— 遍历路径下的文件

文件 IO 处理是程序的基础功能&#xff0c;WEB 程序中通过文件 IO 实现附件的上传与下载。在 GO 中&#xff0c;有多种方式可以遍历文件目录&#xff0c;获取文件路径&#xff0c;本文从使用层面上论述这些函数。 预先准备一个包含子目录的目录&#xff0c;用于遍历测试&#…...

Flutter 处理异步操作并根据异步操作状态动态构建界面的方法FutureBuilder

概述 当界面的内容需要依靠网络请求的数据&#xff0c;就需要处理苦恼的&#xff0c;状态是空&#xff0c;非空的逻辑了&#xff0c;不然页面构建可能会报错&#xff0c;而FutureBuilder提供了一个非常好的解决方法&#xff0c;直接看代码 代码 异步操作函数 即网络请求函数…...

蓝桥杯 2024 15届国赛 A组 儿童节快乐

P10576 [蓝桥杯 2024 国 A] 儿童节快乐 题目描述 五彩斑斓的气球在蓝天下悠然飘荡&#xff0c;轻快的音乐在耳边持续回荡&#xff0c;小朋友们手牵着手一同畅快欢笑。在这样一片安乐祥和的氛围下&#xff0c;六一来了。 今天是六一儿童节&#xff0c;小蓝老师为了让大家在节…...

Java - Mysql数据类型对应

Mysql数据类型java数据类型备注整型INT/INTEGERint / java.lang.Integer–BIGINTlong/java.lang.Long–––浮点型FLOATfloat/java.lang.FloatDOUBLEdouble/java.lang.Double–DECIMAL/NUMERICjava.math.BigDecimal字符串型CHARjava.lang.String固定长度字符串VARCHARjava.lang…...

spring:实例工厂方法获取bean

spring处理使用静态工厂方法获取bean实例&#xff0c;也可以通过实例工厂方法获取bean实例。 实例工厂方法步骤如下&#xff1a; 定义实例工厂类&#xff08;Java代码&#xff09;&#xff0c;定义实例工厂&#xff08;xml&#xff09;&#xff0c;定义调用实例工厂&#xff…...

成都鼎讯硬核科技!雷达目标与干扰模拟器,以卓越性能制胜电磁频谱战

在现代战争中&#xff0c;电磁频谱已成为继陆、海、空、天之后的 “第五维战场”&#xff0c;雷达作为电磁频谱领域的关键装备&#xff0c;其干扰与抗干扰能力的较量&#xff0c;直接影响着战争的胜负走向。由成都鼎讯科技匠心打造的雷达目标与干扰模拟器&#xff0c;凭借数字射…...

大数据学习(132)-HIve数据分析

​​​​&#x1f34b;&#x1f34b;大数据学习&#x1f34b;&#x1f34b; &#x1f525;系列专栏&#xff1a; &#x1f451;哲学语录: 用力所能及&#xff0c;改变世界。 &#x1f496;如果觉得博主的文章还不错的话&#xff0c;请点赞&#x1f44d;收藏⭐️留言&#x1f4…...

2023赣州旅游投资集团

单选题 1.“不登高山&#xff0c;不知天之高也&#xff1b;不临深溪&#xff0c;不知地之厚也。”这句话说明_____。 A、人的意识具有创造性 B、人的认识是独立于实践之外的 C、实践在认识过程中具有决定作用 D、人的一切知识都是从直接经验中获得的 参考答案: C 本题解…...

在QWebEngineView上实现鼠标、触摸等事件捕获的解决方案

这个问题我看其他博主也写了&#xff0c;要么要会员、要么写的乱七八糟。这里我整理一下&#xff0c;把问题说清楚并且给出代码&#xff0c;拿去用就行&#xff0c;照着葫芦画瓢。 问题 在继承QWebEngineView后&#xff0c;重写mousePressEvent或event函数无法捕获鼠标按下事…...

CVE-2020-17519源码分析与漏洞复现(Flink 任意文件读取)

漏洞概览 漏洞名称&#xff1a;Apache Flink REST API 任意文件读取漏洞CVE编号&#xff1a;CVE-2020-17519CVSS评分&#xff1a;7.5影响版本&#xff1a;Apache Flink 1.11.0、1.11.1、1.11.2修复版本&#xff1a;≥ 1.11.3 或 ≥ 1.12.0漏洞类型&#xff1a;路径遍历&#x…...

面向无人机海岸带生态系统监测的语义分割基准数据集

描述&#xff1a;海岸带生态系统的监测是维护生态平衡和可持续发展的重要任务。语义分割技术在遥感影像中的应用为海岸带生态系统的精准监测提供了有效手段。然而&#xff0c;目前该领域仍面临一个挑战&#xff0c;即缺乏公开的专门面向海岸带生态系统的语义分割基准数据集。受…...

Python Einops库:深度学习中的张量操作革命

Einops&#xff08;爱因斯坦操作库&#xff09;就像给张量操作戴上了一副"语义眼镜"——让你用人类能理解的方式告诉计算机如何操作多维数组。这个基于爱因斯坦求和约定的库&#xff0c;用类似自然语言的表达式替代了晦涩的API调用&#xff0c;彻底改变了深度学习工程…...