1. 项目简介
本项目的名称为Web聊天室,即类QQ群组聊天,多个用户可以在同一个群组收发消息进行聊天
项目实现的业务
注册功能:用户输入账号,密码,昵称,图像点击即可注册用户(账号和昵称不能重复)
登陆功能:用户输入账号,密码即可进行登陆(如果登陆的账号已在别处登陆,系统会自动踢掉之前登陆的账号)
获取群组列表:登陆成功后,会展示所有的频道列表
展示历史消息:群组列表会展示当前登陆用户上次退出的时间到当前登陆的时间,这个范围内的所有消息
发送消息:在输入框输入消息点击发送后,其他的用户可以接收到这条消息
注销功能:点击注销按钮退出账号,跳转到登陆页面
项目用到的技术
前端页面使用html,css,js,Vue.js实现
客户端使用ajax技术向后端发送http请求
后端使用Servlet对请求进行解析并返回响应
基于WebSocket,客户端与服务端建立长连接,完成服务端主动的向客户端推送消息
数据库使用MySQL数据库对数据进行存储
使用Java的JDBC操作对数据库表进行增删查改
使用jackson框架来完成java对象与json字符串的相互转化(序列化与反序列化)
使用junit框架对模块进行单元测试
使用lombok简化开发,在编译期间动态的织入需要的代码
使用双重校验锁的模式创建线程安全的单例对象,如数据库连接池对象
使用阻塞队列保存客户端发送的消息,以多线程的方式转发这些消息到所有在线的用户
这些技术会结合业务功能实现在后面具体介绍
项目功能展示
项目已经部署到服务器,感兴趣的同学可以点击访问:Web聊天室
注册页面:注册成功后跳转到登陆页面
登陆页面:登陆成功后跳转到频道列表页面
频道列表页面:包含消息展示及发送消息
使用新的无痕模式登陆另一个账号即可看到未读消息
2. 数据库表的设计
分析业务功能可知需要三张表,用户user表,频道channel表,消息message表
用户user表的设计
用户user表的字段有主键id,用户名username,密码password,昵称nickname,头像head,退出登陆的时间logout_time,因为历史消息只展示用户退出到本次登陆之间的消息,所以需要有logout_time来保存用户的注销时间
id为自增的主键
username设置为非空且唯一,长度为20
password设置为非空,长度为20
nickname设置为非空且唯一,长度为20
head保存图像的相对路径,长度为50,不做具体限制,用户注册时可以上传图像,也可以不上传
logout_time保存用户退出登陆的时间
频道channel表的设计
频道channel表的字段有主键id,频道名称name
id设置为自增主键
name设置为非空且唯一,长度为20
消息message表的设计
观察下面的消息设计message表的字段
从上面的一条消息可以看出一条消息包含了频道列表,消息时间,消息内容,发送消息的用户昵称,故消息message表的字段有主键id,用户user_id,用户昵称user_nickname,频道channel_id,消息内容content,消息发送时间send_time
id为主键
user_id标识消息发送方用户id,外键
user_nickname标识消息发送方用户昵称
channel_id标识消息接收方频道id,外键
content为消息内容,长度为255
send_time,为消息发送时间
3. 实体类以及工具类的设计
3.1 实体类model
创建一个model包,里面放实体类,数据库有三张表,对应后端创建三个实体类User,Channel,Message
3.1.1 lombok的使用
lombok主要是为了简化开发,在实体类上标注@Getter,@Setter,@ToString三个注解,可以提供对应的方法
lombok的原理:在编译期间(javac+lombok的代理类)进行编译,动态的往class文件织入需要的代码
lombok的使用,在pom.xml中添加依赖: <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.18.20</version> <optional>true</optional> </dependency>
类上加注解:
@Getter @Setter @ToString public class User { private Integer id; private String username; private String password; private String nickname; private Date logoutTime; private String head; }
3.2 工具类util
创建一个util包,里面存放后端需要用的工具类
3.2.1 DBUtil
该类用于创建数据库连接池对象,获取数据库连接和释放统一资源
双重校验锁的方式定义一个单例的数据源:
private static volatile MysqlDataSource DS = null; private static MysqlDataSource getDS(){ if(DS == null){ synchronized (DBUtil.class){ if(DS == null){ DS = new MysqlDataSource(); DS.setURL("jdbc:mysql://127.0.0.1:3306/chatroom"); DS.setUser("root"); DS.setPassword("xiaobai520..@@@"); DS.setUseSSL(false); DS.setUseUnicode(true); DS.setCharacterEncoding("UTF-8"); } } } return DS; }
获取数据库连接:
public static void close(Connection c, Statement s, ResultSet rs){ try { if(rs != null) rs.close(); if(s != null) s.close(); if(c != null) c.close(); } catch (SQLException e) { throw new RuntimeException("释放数据库资源出错",e); } }
使用方法重载对有结果集和无结果集的jdbc操作释放资源:
public static void close(Connection c, Statement s, ResultSet rs){ try { if(rs != null) rs.close(); if(s != null) s.close(); if(c != null) c.close(); } catch (SQLException e) { throw new RuntimeException("释放数据库资源出错",e); } } public static void close(Connection c, Statement s){ close(c,s,null); }
3.2.2 WebUtil
提供序列化和反序列化操作,使用jackson框架:可以将json字符串和Java对象相互转换(序列化与反序列化)
在pom.xml中添加依赖:
<dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.12.3</version> </dependency>
ObjectMapper的实例只需要一个,使用双重校验锁的单例模式,提供一个方法返回ObjectMapper实例
需要把消息中的日期转换为年-月-日 时:分:秒的形式
private static volatile ObjectMapper mapper = null; private static ObjectMapper getMapper(){ if(mapper == null){ synchronized (WebUtil.class){ if(mapper == null){ mapper = new ObjectMapper(); DateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); mapper.setDateFormat(format); } } } return mapper; }
序列化:将java对象转化为json字符串
public static String write(Object o){ try { return getMapper().writeValueAsString(o); } catch (JsonProcessingException e) { throw new RuntimeException("序列化为json字符串出错",e); } }
反序列化:将json字符串转化为java对象,这里使用重载提供两种反序列化操作,一是将json字符串反序列化为java对象,二是将输入流中json字符串反序列化为java对象
public static <T> T read(InputStream is,Class<T> clazz){ try { return getMapper().readValue(is,clazz); } catch (IOException e) { throw new RuntimeException("反序列化为java对象出错",e); } } public static <T> T read(String json,Class<T> clazz){ try { return getMapper().readValue(json,clazz); } catch (IOException e) { throw new RuntimeException("反序列化为java对象出错",e); } }
4. 注册功能
4.1 前端设计
给表单绑定点击提交事件,点击注册发送ajax请求,因为有上传图像文件,所以请求body的类型为form-data格式,待后端解析完请求并返回响应后,前端解析响应,如果注册成功跳转到频道列表页面,如果注册失败,提示错误信息
4.2 关于Ajax技术的介绍
Ajax是一种客户端异步回调的技术
异步回调:发送一个http请求距离收到响应有一段时间,后续的代码不会等待响应而是继续往下执行,待收到返回的响应后由浏览器自动执行回调函数解析响应内容来动态的填充网页
使用Ajax的优点:
不用刷新网页就可以发送http请求,用户体验好
不需要返回全部的动态网页,只需返回动态变化的数据,效率高
4.3 后端设计
创建对应的Servlet类RegisterServlet来解析请求并返回响应
解析请求
因为请求中有文件的上传,所以Servlet类必须加一个@MultipartConfig注解表示有文件上传
请求中的简单类型使用getParameter解析,解析后设置到user对应的属性中,文件类型使用getPart解析,因为用户上传的文件可能为null,所以要先进行判空操作,当不为空时将后端生成的图像路径保存到user对应的属性中
校验账号和昵称
因为user表要求账号和昵称不能重复,故先校验账号和昵称是否存在,创建一个dao包,主要进行数据库增删查改操作,在dao包中创建UserDao类表示是对user这张表进行的jdbc操作,在UserDao中创建checkIfExist方法,参数为请求中的username和nickname,通过该jdbc操作在user表中看能否查询到相关信息,如果能查到则说明账号或昵称有重复,查不到说明可以注册
构造响应对象
返回给前端的响应也是一个json字符串,所以需要先构造一个响应对象,在model包中创建JsonResult类
@Getter @Setter @ToString public class JsonResult { private boolean ok; private String reason; //ok==false,返回给前端的错误信息 private Object data; //ok==true,返回给前端的数据 }
如果要注册的用户已存在,将ok设置为false,设置错误信息,如果要注册的用户不存在,将ok设置为true,并将该用户插入到用户表user中,在UserDao中创建insertOne方法,参数传入解析请求并创建的user对象
返回响应
设置响应的body及编码格式,调用WebUtil中的序列化方法将响应对象序列化为json字符串返回给前端
resp.setContentType("application/json; charset=utf-8"); String body = WebUtil.write(json); //将结果集对象序列化为json字符串 resp.getWriter().write(body);
5. 登陆功能
5.1 前端设计
给表单绑定点击提交事件,点击登陆发送ajax请求,请求body类型为application/json格式,请求body为包含username和password的json字符串,待后端解析完请求并返回响应后,前端解析响应,如果登陆成功跳转到频道列表页面,如果登陆失败提示错误信息
5.2 后端设计
创建对Servlet类LoginServlet来解析请求并返回响应
解析请求
前端的请求数据为json字符串,所以后端解析时要用getInputStream获取输入流来解析,将输入流转化为一个User对象
InputStream is = req.getInputStream(); User get = WebUtil.read(is,User.class);
校验账号和密码
我们这里先校验账号是否存在,然后校验密码是否正确
可以用之前UserDao中的chackIfExist方法,参数传入解析请求获得的User对象的username,另一个参数传入null,判断通过该方法查询到的User对象是否为null,如果为null说明账号不存在,如果不为null,则继续校验密码,如果从请求获得的User对象的password和从数据库查询到的User对象的password不相等,说明密码错误,如果相等则说明账号和密码正确,创建session保存用户信息
登陆成功,创建session保存用户信息
校验账号密码成功后,创建session保存用户信息
HttpSession session = req.getSession(true); session.setAttribute("user",user);
构造响应对象并返回给前端
首先创建一个响应对象
JsonResult json = new JsonResult();
如果账号不存在,或者密码错误,将json.ok=false,并设置原因,如果校验成功,创建完session后将json.ok=true,构建完响应对象后,将该对象序列化为json字符串返回给前端
resp.setContentType("application/json; charset=utf-8"); resp.getWriter().write(WebUtil.write(json));
6. 获取频道列表
6.1 前端设计
获取频道页面是在登陆成功后跳转到频道页面就立即发送请求获取频道列表,只是获取频道,不带任何请求数据,所以发送的ajax请求方法为get,待后端返回响应后,前端解析响应将获取的频道列表设置在页面中,此页面还需要设置当前登陆的用户信息,所以后端返回的响应中还包含当前登陆用户信息,将此信息也设置到页面中
6.2 后端设计
频道列表页面未登录不允许访问,后面进行接收消息和发送消息时也需要验证用户是否登陆,所以这里将验证用户是否登陆的方法放在WebUtil中实现代码的复用
public static User getLoginUser(HttpSession session){ if(session != null){ return (User)session.getAttribute("user"); } return null; }
如果用户登陆了,就存在保存的session会话,所以通过session获取之前保存的user,如果获取到的user为null说明未登录,设置响应状态码为403直接返回,如果user不为null,说明用户登陆了,接着执行后续逻辑
登陆成功后,获取频道列表,创建一个ChannelDao类,专门做channel表的jdbc操作,在该类中创建selectAll方法查询所有的频道并返回
构造一个Map数据结构,键为String类型,值为Object类型,将用户和查询的所有频道放到Map中,将Map对象序列化为json字符串返回给前端
//登陆成功,获取频道列表数据 List<Channel> channels = ChannelDao.selectAll(); Map<String,Object> map = new HashMap<>(); map.put("user",user); map.put("channels",channels); //返回响应数据 resp.setContentType("application/json; charset=utf-8"); resp.getWriter().write(WebUtil.write(map));
7. 接收和发送消息
7.1 WebSocket技术
WebSocket是基于tcp协议,为客户端与服务端之间提供了一种全双工的通信机制,即客户端与服务端建立长连接,都可以主动的进行收发消息,即服务端可以主动的向客户端推送消息
7.1.1 WebSocket实现原理
握手阶段:客户端向服务端发送一个http请求,目的是沟通好后续使用的协议和钥匙,即升级协议为WebSocket协议,通过该钥匙就可以知道对方的身份,服务端接收到客户端的握手请求后,也采用http协议回馈数据
发送数据阶段:在握手阶段完后,后续使用的协议就是WebSocket协议,基于WebSocket协议来收发数据
7.1.2 WebSocket的使用
客户端WebSocket的使用
先创建一个WebSocket的对象,ws为WebSocket的协议名,传入要访问的路径
let ws = new WebSocket("ws://127.0.0.1:8080/chatroom/message");
1
为WebSocket对象绑定事件,在事件发生的时候,由浏览器自动调用事件函数,e为事件对象
ws.onopen = function (e) { console.log("建立连接") } ws.onclose = function (e) { console.log("关闭连接") } ws.onerror = function (e) { console.log("出错了") } //接收到服务端发的消息时,执行函数,e.data获取服务端推动的消息 ws.onmessage = function (e) { console.log(e.data) }
服务端WebSocket的使用
服务端要引用websocket依赖包
<dependency> <groupId>javax.websocket</groupId> <artifactId>javax.websocket-api</artifactId> <version>1.1</version> <scope>provided</scope> </dependency>
创建一个类,类上加@ServerEndpoint(“/message”)注解(message为路径,要与前端路径匹配),然后创建@OnOpen,@OnClose,@OnError,@OnMessage方法
@ServerEndpoint("/message") public class TestWebSocket { //这个session,是建立连接客户端的websocket会话 //建立连接调用 @OnOpen public void onOpen(Session session){ } //关闭连接调用 @OnClose public void onClose(){ } //服务端抛出异常调用 @OnError public void onError(Throwable t){ t.printStackTrace(); } //服务端收到消息调用 @OnMessage public void onMessage(String message){ //message为服务端收到的消息 System.out.println("服务端接收到消息:"+message); } }
7.2 前端设计
消息的获取是在频道列表页面加载完后就要立即获取到消息并展示出来
写一个函数用于获取服务端推动的消息,在展示完频道列表,调用该函数,此处WebSocket对象的url设置为动态的,因为后续如果部署到服务器上,ip地址会变,而且不同的contextPath,路径也不一样
let protocol = location.protocol; //获取协议名 let url = location.href; //获取当前地址栏的url //截取url,获取:IP地址/contextPath url = url.substring((protocol+"//").length,url.indexOf("/views/message.html")); //web-chat为contextPath let ws = new WebSocket("ws://"+url+"/message");
关闭连接事件:通过事件对象.reason获取到关闭连接的原因,主要是提示用户未登录和账号在别处登陆,这些原因在后端进行设置
ws.onclose = function (e) { let reason = e.reason; if(reason){ alert(reason) } console.log("关闭连接") }
接收到消息时事件:事件对象e.data就可以获取到服务端推送的消息,遍历频道列表,将该消息设置到页面中
客户端主动发送消息:给发送按钮绑定点击事件,将用户所在的频道id和输入框要发送的内容组装为一个json对象,在将json对象转化为json字符串发送给服务端,发送完消息将输入框置空
7.3 后端设计
WebSocket的配置类
Session对象用于保存建立连接的客户端websocket对话,与登陆时HttpSession对象不同,所以使用一个WebSocket的配置类来获取到登陆时创建的HttpSession对象,将该对象保存在WebSocket的Session中,创建一个配置类放到WebUtil中,该类需要继承ServerEndpointConfig.Configurator,并且重写modifyHandshake方法
说明: 该类是在客户端与服务端握手阶段的一些配置,在websocket建立连接时,服务端就可以使用这个配置类来完成一些初始化工作
//websocket的配置类,在建立连接之前,定义一个配置 public class WebSocketConfigurator extends ServerEndpointConfig.Configurator { //握手阶段的配置 @Override public void modifyHandshake(ServerEndpointConfig sec, HandshakeRequest request, HandshakeResponse response) { HttpSession httpSession = (HttpSession) request.getHttpSession(); if(httpSession != null){ sec.getUserProperties().put("httpSession",httpSession); } } }
MessageEndpoint类
创建MessageEndpoint类,服务端用来接收客户端发的消息并且将消息推送到所有在线用户,该类添加@ServerEpoint注解
@ServerEndpoint(value = "/message", configurator = WebSocketConfigurator.class)
1
OnPen方法
首先要判断是否登陆:
通过WebSocket的session获取到配置类保存的HttpSession,通过httpSession获取创建时保存的user,如果user为null,说明用户未登陆,关闭连接,添加关闭连接原因返回给前端
if(user == null){ CloseReason closeReason = new CloseReason(CloseReason.CloseCodes.NORMAL_CLOSURE,"没有登陆,不允许发送消息"); session.close(closeReason); return; }
再踢掉在别处登陆的用户:
我们可以用线程安全的HashMap也就是ConcurrentHashMap来保存在线用户,键为用户id,值为websocket保存的session会话
private static Map onlineUsers = new ConcurrentHashMap<>();
1
判断该map中是否包含当前登陆用户的session,如果包含,说明该用户在别处登陆,则关闭它的连接,如果不包含将该用户添加到onlineUsers中
Session preSession = onlineUsers.get(user.getId()); if(onlineUsers.containsKey(user.getId())){ CloseReason closeReason = new CloseReason(CloseReason.CloseCodes.NORMAL_CLOSURE,"账号在别处登陆"); preSession.close(closeReason); }
接收历史消息:
在建立连接后,需要接收所有的历史消息,并将消息发送到客户端,创建一个List保存从数据库查询到的所有历史消息,遍历该消息,将消息序列化为json字符串发送到客户端,创建一个MessageDao类专门做消息的jdbc操作,在此类中创建query方法,参数传入当前用户的上次注销时间,查询到的历史消息为当前用户上次退出登陆到这次登陆的所有历史消息
List<Message> messages = MessageDao.query(user.getLogoutTime()); for(Message message : messages){ String json = WebUtil.write(message); session.getBasicRemote().sendText(json); }
OnClose方法
关闭连接时,需要将onlineUsers中保存的会话删掉,并记录该用户此次的注销时间,设置到该用户中,然后更新数据库中该用户的上次注销时间,在UserDao中创建一个updateLogoutTime方法,参数传入关闭连接的用户
public void onClose(){ System.out.println("关闭连接"); //关闭连接:删除map中当前会话,记录当前用户的上次注销时间 onlineUsers.remove(loginUser.getId()); loginUser.setLogoutTime(new java.util.Date()); int n = UserDao.updateLogoutTime(loginUser); }
OnError方法
发生错误时,这里简单做,只需将错误信息打印,然后删除onlineUsers中的会话
public void onError(Throwable t){ t.printStackTrace(); //出现异常,删除map中当前会话 onlineUsers.remove(loginUser.getId()); }
OnMessage方法
服务端接收到客户端发送的消息时,执行此方法,前端发送过来的消息为json字符串,json字符串中包含消息内容与频道id,首先将json字符串转化为Message对象,将当前登陆用户的id与nickname设置到Message对象中,消息的时间在插入消息时实时获取,所以此处不用获取消息的发送时间,在MessageDao中创建一个insert方法做消息插入操作,传入的参数为Message对象,在将该消息推送到客户端
在服务端收到客户端发送的消息时,将该消息推送到所有在线用户,此处需要考虑:
使用一个数据结构来保存消息,需要考虑线程安全
启用一个或多个线程来转发保存的所有消息到所有的在线用户
使用LinkedBlockingQueue无边界的阻塞队列来保存服务端接收到客户端发送的消息
private static BlockingDeque<Message> messageQueue = new LinkedBlockingDeque<>(); 1 public void onMessage(String message) throws IOException { //将接收到的json字符串转换为message对象 Message m = WebUtil.read(message,Message.class); m.setUserId(loginUser.getId()); m.setUserNickname(loginUser.getNickname()); int n = MessageDao.insert(m); try { messageQueue.put(m); } catch (InterruptedException e) { e.printStackTrace(); } }
消费消息:创建一个或多个线程,从消息队列中一个一个拿,每个都转发到所有在线用户,线程只能创建一次,所以使用一个静态代码块来执行此逻辑,让其在类加载的时候就开始工作
static { new Thread(new Runnable() { @Override public void run() { while(true){ try { Message m = messageQueue.take(); for(Session session : onlineUsers.values()){ String json = WebUtil.write(m); session.getBasicRemote().sendText(json); } } catch (InterruptedException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } } } }).start(); }
8. 注销功能
创建LogoutServlet类,用来做注销功能
未登录不允许访问,校验是否登陆,若没有登陆设置响应状态码为403直接返回,如果登陆了则需要删除保存的httpSession,更新用户的上次注销时间,重定向到登陆页面
@WebServlet("/logout") public class LogoutServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { HttpSession session = req.getSession(false); User user = WebUtil.getLoginUser(session); if(user == null){ resp.setStatus(403); return; } session.removeAttribute("user"); //更新用户上次注销时间 user.setLogoutTime(new java.util.Date()); UserDao.updateLogoutTime(user); resp.sendRedirect("index.html"); } }