最终整个项目的完整代码我会放在文末
整体流程总结:
1、新建项目准备
(1)创建maven项目;选择maven-archetype-webapp
(2)修改WEB-INF下的web.xml;配置pom.xml下的依赖包
(3)在Setting的Plugins中安装Lombok(这个在本项目中的作用是不用再生成private的Get和Set的方法了)
(4)还需要准备一些工具包放在webapp下,“css”、“fonts”和“js”的一些包,这些主要是构成前段页面的(我代码中有了)
2、需求分析
(1)打开主页,见到登陆页面
(2)登陆成功,进入主页面
(3)主页面中可以看到当前的频道(房间)列表
(4)点击某个频道,可以看到频道(房间)中的消息
(5)点击某个频道,可以发送消息,此时其他用户也都能看到该消息
3、前后端API设计
在本次项目中,前端页面已给出来,我们需要根据开发文档返回给前端所要求的内容,再写这些接口之前,可以把前端页面先运行起来看一下,讲我这里提供的index.html放在webapp文件下;然后配置一下tomcat启动一下,可以看到界面,可以尝试点击以下登陆或者注册,肯定是没反应,在开发者工具(谷歌浏览器按F12)里面我们可以看到登陆端口是404的状态。接下来我们来写后端代码部分
ps:这里附上数据库的代码,在进行以下项目之前,先用这些sql语句将数据库创建了
drop database if exists java_chatroom;
create database java_chatroom character set utf8mb4;
use java_chatroom;
create table user (userId int primary key auto_increment,
name varchar(50) unique,
password varchar(50),
nickName varchar(50), -- 昵称
iconPath varchar(2048), -- 头像路径
signature varchar(100),
lastLogout DateTime -- 上次登录时间
); -- 个性签名
insert into user values(null, 'test', '123', '周', '', '一起来打游戏呀', now());
insert into user values(null, 'test2', '123', '周2', '', '有没有怪猎来联机', now());
insert into user values(null, 'test3', '123', '周3', '', '我Rise200小时萌新', now());
insert into user values(null, 'test4', '123', '周4', '', '或者一起玩原神呀', now());
create table channel (channelId int primary key auto_increment,
channelName varchar(50)
);
insert into channel values(null, '体坛赛事');
insert into channel values(null, '娱乐八卦');
insert into channel values(null, '时事新闻');
insert into channel values(null, '午夜情感');
create table message (messageId int primary key auto_increment,
userId int, -- 谁发的
channelId int, -- 发到哪个频道中
content text, -- 消息内容是啥
sendTime DateTime default now() -- 发送时间
);
insert into message values (null, 1, 1, 'hehe1', now());
insert into message values (null, 1, 1, 'hehe2', now());
insert into message values (null, 1, 1, 'hehe3', now());
(1)首先写一个工具类
- ①与数据库建立连接的方法
- ②对json字符串进行序列化与反序列化;稍后我们会看到前后端的API接口中,后端返回的对象是有固定格式的,所以我们要进行序列化
这部分代码我也贴在这里(详细的代码说明我写在代码的注释里)
package example.util;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mysql.jdbc.jdbc2.optional.MysqlDataSource;
import example.exception.AppException;
import java.io.IOException;
import java.io.InputStream;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
public class Util {
private static final ObjectMapper M = new ObjectMapper(); // 一种数据模型转换框架
// 方便将模型对象转换为JSON
private static final MysqlDataSource DS = new MysqlDataSource(); // 用来数据库连接的对象
// 设置静态变量
static {
DateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); // 设置时间的标准格式
M.setDateFormat(df);
DS.setURL("jdbc:mysql://localhost:3306/java_chatroom"); // 数据库名字设置成自己的数据库(我提供的数据库名字叫做java_chatroom)
DS.setUser("root"); // 设置mysql的用户名
DS.setPassword("123456"); // 设置mysql的密码(用户名和密码设置自己本机的)
DS.setUseSSL(false); // 当JDBC 比 mysql 版本不兼容(JDBC版本高于mysql兼容版本)设置为true
DS.setCharacterEncoding("UTF-8"); // 防止中文乱码
}
/***
* json序列化:java对象转化为json字符串
*
* json字符串就理解为前后端沟通常用的一种字符串格式
*/
public static String serialize(Object o){
try{
return M.writeValueAsString(o);
}catch (JsonProcessingException e){ // 注意异常的种类不要写错了
throw new AppException("json序列化失败" + o, e);
}
}
/***
* json反序列化:json字符串转换为java对象
*/
public static <T> T deserialize(String s, Class<T> c){
// 这里我们用到了泛型,因为我们要转换成为的java对象并不固定
// 比如我们要把json中的信息转换成用户对象;把另一个json中的信息转换成发送的消息对象
// 所以这里用泛型来定义反序列化
try{
return M.readValue(s, c); // 注意这里不是readValues,我当时就没注意被这个s折磨了老久
}catch (JsonProcessingException e){
throw new AppException("json反序列化失败", e);
}
}
// 为了满足输入是InputStream对象,我们重载(同一个类下,方法名一样,参数和返回值不一样)反序列方法
public static <T> T deserialize(InputStream is, Class<T> c){
try {
return M.readValue(is, c);
}catch (IOException e){
throw new AppException("json反序列化失败", e);
}
}
/**
* 获取数据库链接
* */
public static Connection getConnection(){
try{
return DS.getConnection();
}catch (SQLException e){
throw new AppException("获取数据库连接失败", e);
}
}
/**
* 释放jdbc资源
*/
public static void close(Connection c, Statement s, ResultSet r){
try{
if(r != null) r.close();
if(s != null) s.close();
if(c != null) c.close();
}catch (SQLException e){
throw new AppException("释放数据资源出错", e);
}
}
public static void close(Connection c, Statement s){
close(c, s, null);
}
// 以上我们就把一些常用工具写完了,这里可以写一个主函数测试一下
// public static void main(String[] args){
// // 测试一下json序列化
// Map<String, Object> map = new HashMap<>();
// map.put("ok", true);
// map.put("d", new Date());
//
// System.out.println(serialize(map));
// // 运行后就可以看到,这里将使用map存放的键和值转化成了一个JSON字符串(用map的原因应该是有键值对儿的原因吧)
//
// // 测试数据库链接,执行这步前,先把我提供的初始化数据库代码在cmd的mysql里面运行一下,保证自己本机有这个数据库
// System.out.println(getConnection());
// }
}
(2)实现登陆功能
根据开发文档中对登陆功能的请求与响应的描述如下:
请求:
POST /login
{
name: xxx,
password: xxx
}
响应:
HTTP/1.1 200 OK
{
ok: true,
reason: xxx,
userId: xxx,
name: xxx,
nickName: xxx,
signature: xxx
}
在java文件夹下创建一个servlet类专门存放与负责实现传递前后端信息的类
创建一个LoginServlet类:
- ① 惯例,继承HttpServlet,写WebServlet()的路由地址,根据开发文档知道路由地址为@WebServlet("/login")
- ② 然后生成doGet和doPost方法;
此处要对这个登录页面的功能进行一个说明:在我们一进入到主页面时,此时应该检测我们有没有登陆,所以在实现登陆功能时,也要实现一个检测登陆状态的功能;因为用户在登陆以后这个信息是保存在浏览器的Session中的,为了方便用户不用没刷新一次浏览器就要重新登陆信息,我们要确认浏览器的Session有没有保存已经登陆的用户信息,如果有这个信息,就直接进入到屏道页面,如果没有这个信息,那就显示的是最开始的”请先登录页面“ - ③ 我们在doPost中实现登陆的接口;在doGet中实现检测登陆状态的接口:惯例主要注释我写在代码中贴在下面
注意: 这部分代码实现需要以下面4,5,6部分为前提,其中一些功能的定义和实现在下面,可以先往后看
package example.servlet;
import example.dao.UserDAO;
import example.exception.AppException;
import example.model.User;
import example.util.Util;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
@WebServlet("/login")
public class LoginServlet extends HttpServlet {
// 第二步编写:检测登陆状态接口,主要是在页面初始化时执行
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
req.setCharacterEncoding("UTF-8"); // 请求编码格式
resp.setCharacterEncoding("UTF-8"); // 响应编码格式
resp.setContentType("application/json"); // 前后端是以json字符串的格式传递的
// 返回给前端的还是user对象的用户信息
User user = new User();
// 获取当前请求的Session,并从中获取用户信息,如果获取不到,返回 ok 为false
HttpSession session = req.getSession(false);// false的意思就是如果没有获取到session信息就不创建新的session
if (session != null) {
User get = (User) session.getAttribute("user"); //这里的"user"和登陆下面的setAttribute是对应的
if (get != null) {
// 说明已经登陆了
// 设置返回个前端的参数
user = get;
user.setOk(true);
resp.getWriter().println(Util.serialize(user)); // 返回响应数据
return;
}
}
// 没有获取到session或者用户信息
user.setOk(false); // 其实默认就是false
user.setReason("用户未登录");
// 返回响应数据:从响应对象获取数据流,打印输出响应体body
resp.getWriter().println(Util.serialize(user));
}
// 第一步编写:登陆接口
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
req.setCharacterEncoding("UTF-8"); // 设置请求的编码格式
resp.setCharacterEncoding("UTF-8"); // 设置响应的编码格式
resp.setContentType("application/json"); // 前后端是以json字符串的格式传递的
// 根据前端的请求(输入的账号密码)要去数据库中查询是否有与之匹配的信息
User user = new User(); // 用于返回响应给前端
try{
// 1、解析请求数据,根据接口文档,需要使用反序列化操作
// 将前端请求信息反序列化为用户对象
User input = Util.deserialize(req.getInputStream(), User.class); // 反序列化为用户类对象
// 2、业务处理:去数据库验证账号密码,如果验证通过,保存用户信息于Session中
// 首先根据账号查询是否有此用户
User query = UserDAO.queryByName(input.getName());
if(query == null){
// 没有在用户表中查询到
throw new AppException("用户不存在");
}
if(!query.getPassword().equals(input.getPassword())){
// 从数据库中拿到该用户名的密码和前端输入的密码不一致
throw new AppException("账号或密码错误");
}
// 能执行到这里,说明验证通过了
// 在session中保存用户信息
HttpSession session = req.getSession(); // 根据请求拿到session,如果没有,就创建一个session,默认是true
session.setAttribute("user", query); // 以"user"作为名字,保存用户信息
user = query; //将查询到的用户信息传给要返回给前端的user
// 设置返回参数,成功返回了,就设置ok为true
user.setOk(true);
}catch (Exception e){
e.printStackTrace();
// 构造返回给前端响应失败了,就设置ok为false
user.setOk(false);
// 这里就体现出了自定义异常的好处,我们给前端返回我们想要返回的内容
if(e instanceof AppException){
user.setReason(e.getMessage());
}else{
// 如果出现了非自定义异常的情况,可以不报英文,报我们给定的内容
user.setReason("未知错误,请联系管理员");
}
}
// 3、返回相应数据:从响应对象中获取输出流(序列化为JSON字符串),打印输出到响应体body
resp.getWriter().println(Util.serialize(user));
}
}
- ④ 在实现doPost和doGet功能之前,还有一点就是,我们既然要从Session中得到用户的信息,那应该有一个用户的对象,并且可以从开发文档中看到,请求和响应都是一个对象,我们之前不是写了一个json序列化和反序列化么,在登陆时,就需要将输入反序列化为一个用户对象(这个对象我们需要新建);检测是否登陆时,也是将Session中的信息转换成用户对象。所以,这步要新建一个模板类文件夹,在下面新建一个用户类对象的模板,代码在这里
package example.model;
// 因为我们这里在File -> Settings -> Plugins -> 中安装了Lombok(没有安装的,在里面搜索Lombok然后点击install,安装完重启idea就行)
// 所以可以直接简写私有变量的get,set和toString方法
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import java.io.Serializable;
@Getter
@Setter
@ToString
public class User extends Response implements Serializable {
// 因为数据库使用、前后端ajax、Session保存是基于对象和二进制的数据转换,所以要实现串行化接口
private static final Long serialVersionUID = 1L;
// serialVersionUID 的作用主要是验证传来的字节流中seriaVersionUID与本地响应实体类的serialVersionUID进行比较
// 如果相同说明是一致,就可以进行反序列化(这个概念我也不是很理解,大概就是个安全验证的意思吧)
// 1L就是默认生成 serialVersionUID 的方式
// 这里要根据数据库中来定义
// 数据库中 用户表 有多少个 属性
// 在 用户的模板类中 就有多少个 成员变量
// 要一一对应起来
private Integer userId; // 用户Id
private String name; // 用户名(账号)
private String password; // 用户密码
private String nickName; // 昵称
private String iconPath; // 头像路径(这个属性本项目不用)
private String signature; // 个性签名
private java.util.Date lastLogout; // 用户最后一次登陆的时间(记录的是用户下线的时间点)
}
- ⑤ 在我这里定义模板类时,我把响应也单独作为了一个模板类,这个模板类里面放着通用的响应信息,也是为了前后端接口字段的统一
package example.model;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
/**
* 前后端接口需要的统一字段
*/
// 因为我们这里在File -> Settings -> Plugins -> 中安装了Lombok(没有安装的,在里面搜索Lombok然后点击install,安装完重启idea就行)
// 所以可以直接简写私有变量的get,set和toString方法
@Getter
@Setter
@ToString
public class Response {
// 当前借口响应是否操作成功
private boolean ok; // 默认为false
// 操作失败是,前端要展示的错误信息
private String reason;
// 保存要返回给前端的业务数据
private Object data;
}
- ⑥ 在具体与数据库进行增删查改时,还需要创建一个具体操作的类文件夹,在该文件夹下创建具体的操作方法,此处登陆时,我们需要在数据库中查询是否有此用户。所以,先创建一个dao类,并在该类文件夹下创建一个UserDAO类,专门用改针对用户表进行操作的具体操作类。具体注释也在代码中体现
package example.dao;
import example.exception.AppException;
import example.model.User;
import example.util.Util;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.Timestamp;
import java.util.Date;
public class UserDAO {
/**
* 根据用户名查询数据表中的用户
* */
public static User queryByName(String name) {
// 先定义我们肯定要用的对象
Connection connection = null; // 用于连接数据库
PreparedStatement preparedStatement = null; // 用户sql注入,就是在写sql语句是可以使用占位符
ResultSet resultSet = null; // 查询的结果集
// 先定义返回数据,根据技术文档要返回给前端的是一个用户类型的对象
User user = null;
try{
// 1、获取数据库连接
connection = Util.getConnection(); // 调用先前在工具类中写的连接数据库地方法
// 2、通过Connection + sql 创建操作命令对象Statement
String sql = "select * from user where name=?";
preparedStatement = connection.prepareStatement(sql);
// 3、执行sql:执行前替换占位符
preparedStatement.setString(1, name);
resultSet = preparedStatement.executeQuery(); // 存放查询后的结果集
// 4、如果是查询操作,处理结果集
while(resultSet.next()){
user = new User();
// 设置结果集字段到用户对象的属性中
user.setUserId(resultSet.getInt("userId")); // 注意这个“userId”要和表中的属性名相同
user.setName(name);
user.setPassword(resultSet.getString("password"));
user.setNickName(resultSet.getString("nickName"));
user.setIconPath(resultSet.getString("iconPath"));
user.setSignature(resultSet.getString("signature"));
java.sql.Timestamp lastLogout = resultSet.getTimestamp("lastLogout"); // 得到从1970年到今的毫秒数
user.setLastLogout(new Date(lastLogout.getTime())); // 根据毫秒数拿到年月日时分秒
}
return user;
}catch (Exception e){
throw new AppException("查询用户账号出错", e);
}finally {
// 5、无论如何都要释放资源
Util.close(connection, preparedStatement, resultSet);
}
}
public static int updateLastLogout(Integer userId) {
Connection c = null;
PreparedStatement ps = null;
try{
c = Util.getConnection();
String sql = "update user set lastLogout=? where userId=?";
ps = c.prepareStatement(sql);
ps.setTimestamp(1, new Timestamp(System.currentTimeMillis()));
ps.setInt(2, userId);
return ps.executeUpdate();
}catch (Exception e){
throw new AppException("修改用户上次登录时间出错", e);
}finally {
Util.close(c, ps);
}
}
}
-
⑦ 实现完登录功能后,实现检测登陆状态的接口
-
⑧ 实现这部分代码后,可以尝试启动一下项目,可以发现,现在可以登录了,并且登陆之后刷新页面也依旧会保持登陆状态。此时运行按F12可以看到出现两个两个报错,一个是无法获取频道列表,另一个是收发消息的WebSocket功能无法实现。下一步我们先实现频道列表的获取。
(3)实现注销功能(退出功能)
根据开发文档中对注销功能的请求与响应的描述如下:
请求:
GET /logout
响应:
HTTP/1.1 200 OK
{
ok: true,
reason: xxx
}
创建一个LogoutServlet类
- ① 同样,先继承HttpServle,再写@WebServlet的路由地址:@WebServlet("/logout")
- ② 生成doGet()与doPost()方法,这里只需doGet()方法就好,可以用doPost()也调用doGet()
主要的实现逻辑为:从浏览器保存的Session中获取用户信息,如果获取到了就将用户信息删除,如果没获取到,就返回“用户未登录”信息
具体流程写在代码中, ------------这里贴上LogoutServlet.java代码---------------
package example.servlet;
import example.model.Response;
import example.model.User;
import example.util.Util;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
@WebServlet("/logout")
public class LogoutServlet extends HttpServlet {
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
this.doGet(req, resp);
}
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
// 惯例开头先设置格式
req.setCharacterEncoding("UTF-8");
resp.setCharacterEncoding("UTF-8");
resp.setContentType("application/json");
// 从浏览器中获取Session信息
HttpSession session = req.getSession(false); // false 表示,如果没有获取到Session 则不创建新的
// 默认为true 表示,没有获取到就创建新的Session
if(session != null) {
// 如果session不为空
// 根据session中的信息,user是我们在登陆的时候设置到session中的字段,根据它中的信息,以User为模板创建user对象
User user = (User) session.getAttribute("user");
if (user != null) {
// 用户已登陆,要实现注销,就要删除session中保存的用户信息
session.removeAttribute("user");
// 注销成功,根据开发手册,返回OK为true
Response r = new Response();
r.setOk(true);
// 返回相应数据:从响应对象中获取输出流(序列化为JSON字符串),打印输出到响应体body
resp.getWriter().println(Util.serialize(r));
return;
}
}
// 用户未登录
Response r = new Response();
r.setReason("用户未登录,不允许访问");
resp.getWriter().println(Util.serialize(r));
}
}
(3)实现频道查询
该功能必须在用户登陆后才可以使用,将查询到的频道信息显示在用户登陆后的页面上
根据开发文档中对频道查询的请求与响应的描述如下:
请求:
GET /channel
响应:
HTTP/1.1 200 OK
[
{
channelId: 1,
channelName: xxx
},
{
channelId: 2,
channelName: xxx
}
]
我们还没有创建有关频道信息的模板,所以先在model下创建一个Channel类,来记录基本的有关频道的信息
—这里贴上Channel.java代码—
创建一个ChannelServlet类:
- ① 继承HttpServlet,写WebServlet地址:@WebServlet("/channel")
- ② 生成doGet()与doPost()方法,这里只需doGet()方法就好,可以用doPost()也调用doGet()
主要实现逻辑为:老三样
(1)设置请求响应格式 (2)实现业务逻辑 (3)返回相应数据
需要在第二部分创建具体查询频道数据表信息的方法,即在dao文件下创建ChannelDAO类,在该类中实现此处要用的查询方法query()
----这里贴上ChannelDAO.java与ChannelServlet.java—
package example.dao;
import example.exception.AppException;
import example.model.Channel;
import example.model.Response;
import example.util.Util;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.ArrayList;
import java.util.List;
public class ChannelDAO {
public static List<Channel> query() {
// 定义查询数据库要用的对象
Connection c = null;
PreparedStatement ps = null;
ResultSet rs = null;
// 定义存放最终放回的数据的列表
List<Channel> list = new ArrayList<>(); // 返回的对象都是频道对象
try {
// 1、获取数据库连接Connection
c = Util.getConnection();
// 2、通过Connection + sql 操作命令对象Statement
String sql = "select * from channel";
ps = c.prepareStatement(sql);
// 3、执行sql:执行前 替换占位符
rs = ps.executeQuery();
// 4、如果是查询操作,需要处理结果集
while(rs.next()){ // 移动到下一行,有数据返回true
Channel channel = new Channel(); // 在返回的结果List中是一个个的Channel对象
// 设置属性
channel.setChannelId(rs.getInt("channelId")); // 要与数据表中的属性对应
channel.setChannelName(rs.getString("channelName"));
list.add(channel); // 将频道信息添加进list中
}
return list;
}catch (Exception e){
throw new AppException("查询频道出错", e); //我们自定义的异常输出方法
}finally {
// 5、释放资源
Util.close(c,ps,rs);
}
}
}
package example.servlet;
import example.dao.ChannelDAO;
import example.model.Channel;
import example.model.Response;
import example.util.Util;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.List;
@WebServlet("/channel")
public class ChannelServlet extends HttpServlet {
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
this.doGet(req, resp);
}
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
// 1、设置请求响应格式
req.setCharacterEncoding("UTF-8");
resp.setCharacterEncoding("UTF-8");
resp.setContentType("application/json");
// 2、实现业务逻辑
// 目标是从数据库中查询到频道信息,将频道信息返回给前端
Response response = new Response(); // 将内容以我们定义的响应格式传输
try {
// 查询所有频道,以列表的形式返回
List<Channel> List = ChannelDAO.query();
response.setOk(true); // 查询成功,设置输出给前端的ok为true
response.setData(List); // 将查询到的频道信息列表返回给前端
}catch (Exception e){
e.printStackTrace();
response.setReason(e.getMessage()); // 查询失败,返回错误信息
}
// 3、返回相应数据:从响应对象中获取输出流(序列化为JSON字符串),打印输出到响应体body
resp.getWriter().println(Util.serialize(response));
}
}
(4)使用WebSocket实现发送和接收消息
需要注意的是,在websocket中的session和Http协议中的session是不一样的
建立连接
请求:
ws://[ip]:[port]/message/{userId}
只要登陆成功就会出发建立连接操作,发送/接收消息格式如下:
{
"userId": 1,
"nickName": "蔡徐坤",
"channelId": 1,
"content": "这是消息正文"
}
这里需要用到一个新的东西,WebSocket,简单看看概念:
WebSocket是一种在单个TCP连接上进行全双工(客户端和服务器端均既可接受也可发送)通讯的协议,在WebSocket的API中,客户端和服务器端只需要完成一次握手,两者之间就可以创建持久性的链接,并进行双向数据传输
接下来,我们进行代码部分,在代码中边写,边分析
首先,创建MessageWebsocket类
- ① 与原先不同的是,此时不是@WebSocket获取路由了,而是使用@ServerEndPoint()建立连接,@ServerEndpoint("/message/{userId}"),是根据URL地址获取到userId
- ② 在实现WebSocket功能中,我们要重写一些它自身的方法
需要重写的方法有以下几种:
@OnOpen // 成功建立连接
@OnClose // 关闭连接
@OnMessage // 收到消息
@OnError // 连接出错 - ③ 在重写之前需要创建一个关于消息的模板类MessageCenter类来保存所有客户端的session
在这个类中,我们实现一些有关在聊天室中发送消息的基本方法:- 这个类是叫做 MessageCenter 顾名思义,是存放消息的地方,客户端发送的消息都要先到达服务器这里,然后再通过服务器转发给另一个客户端
- addMessage() 方法,是将从客户端接收到的消息在服务器端先存放在一个阻塞队列中,经由另外的线程去发送
- addOnLinUser() 方法,是将在WebSocket建立后,将用户的id和客户端的session信息保存起来,保存在 ConcurrentHashMap中(一种支持线程安全的map结构,并且满足高并发(读写,读读并发,写写互斥))
- delOnlinUser() 方法,是当关闭websocket链接,或是程序出错时,删除保存的客户端session信息
- sendMessage() 方法,当接收到每个客户端发送来的消息时,将该消息转发到所有的客户端
package example.model;
import javax.websocket.Session;
import java.io.IOException;
import java.util.Enumeration;
import java.util.concurrent.BlockingDeque;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.LinkedBlockingDeque;
public class MessageCenter {
/**
* ConcurrentHashMap:支持线程安全的map结构,并且满足高并发(读写,读读并发,写写互斥)
* **/
private static final ConcurrentHashMap<Integer, Session> clients = new ConcurrentHashMap<>();
/**
* 阻塞队列,用来存放消息,接受客户端的消息放进队列;
*
* 再启动一个线程,不停的拉去队列中的消息,发送
* **/
private static BlockingDeque<String> queue = new LinkedBlockingDeque<>();
// 定义类
private static MessageCenter center;
// 构造方法
private MessageCenter(){}
/**
* 不直接发送消息,先将消息存放在队列中,由另一个线程去发送消息
* **/
public void addMessage(String message){
queue.add(message);
}
/**
* WebSocket建立连接时,添加用户id和客户端session,并保存起来
* **/
public static void addOnLinUser(Integer userId, Session session){
clients.put(userId, session);
}
/**
* 关闭websocket连接、或出错时,删除客户端的session
* **/
public static void delOnlinUser(Integer userId){
clients.remove(userId);
}
/**
* 接收到某用户的消息时,转发到所有客户端:
* **/
public static void sendMessage(String message){
try{
Enumeration<Session> e = clients.elements();
while(e.hasMoreElements()){ // 遍历每个用户的session
Session session = e.nextElement();
session.getBasicRemote().sendText(message);
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
- ④ 从客户端发送到服务器端的消息,我们要把它保存起来,就需要一个消息模板,来保存有关这条消息的各种信息
在model下新建一个Message类,里面包含了:这条消息的id,发送这条消息的用户的id,发送这条消息所在频道的id,消息的内容,消息所发送的时间,发送这条消息的用户的昵称
package example.model;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
@Getter
@Setter
@ToString
public class Message {
private Integer messageId;
private Integer userId;
private Integer channelId;
private String content;
private java.util.Date sendTime;
//接收客户端发送的消息,转发到所有客户端的消息,需要昵称
private String nickName;
}
- ⑤ 当服务器接收到客户端法莱的消息时,是把这条消息储存在 消息数据库 中,所以我们要写一个具体的操作消息数据库的方法类
先创建一个名为MessageDAO的类,具体操作方法,在用到时再写。
为了不放排版看起来特别乱,我这里将本来是写在下面的部分代码,拿到这这里
package example.dao;
import example.model.Message;
import example.exception.AppException;
import example.util.Util;
import java.sql.*;
import java.util.ArrayList;
import java.util.List;
public class MessageDAO {
// 根据用户id查询该用户最后一次下线之后,服务器数据库中接收到的消息(当前用户应该接受到的消息)
public static List<Message> queryByLastLogout(Integer userId) {
Connection c = null;
PreparedStatement ps = null;
ResultSet rs = null;
// 定义返回数据
List<Message> list = new ArrayList<>(); // 返回的是一个消息列表,包含了用户下线这个时间段内所有的消息
try{
// 1、获取数据库链接
c = Util.getConnection();
// 2、通过Connection + sql 创建操作命令对象 Statement
String sql = "select m.*,u.nickName from message m join user u on u.userId=m.userId where m.sendTime>(select lastLogout from user where userId=?)";
// 该sql语句的意思为:以message表中发送时间大于use表中该用户下线时间为条件,联合用户表和消息表查询message表的所有信息与user表中的用户昵称
ps = c.prepareStatement(sql);
// 3、执行sql,执行前替换占位符
ps.setInt(1,userId);
rs = ps.executeQuery();
// 4、对于查询操作,需要处理结果及
while(rs.next()){ // 看下一行是否有数据,有数据则为true,进入循环
// 获取结果集字段,设置所需要的对象属性
Message m = new Message();
m.setUserId(userId);
m.setNickName(rs.getString("nickName"));
m.setContent(rs.getString("content"));
m.setChannelId(rs.getInt("channelId"));
list.add(m);
}
return list;
} catch (Exception e) {
throw new AppException("查询用户[" + userId + "]的消息出错");
} finally {
Util.close(c, ps, rs);
}
}
public static int insert(Message msg) {
Connection c = null;
PreparedStatement ps = null;
try{
c = Util.getConnection();
String sql = "insert into message values(null, ?, ?, ?, ?)";
ps = c.prepareStatement(sql);
ps.setInt(1,msg.getUserId());
ps.setInt(2,msg.getChannelId());
ps.setString(3, msg.getContent());
ps.setTimestamp(4,new Timestamp(System.currentTimeMillis()));
return ps.executeUpdate();
}catch (Exception e){
throw new AppException("保存消息出错", e);
}finally {
Util.close(c, ps);
}
}
}
-
⑥ 接下来我们开始重写websocket中的方法
(1)重写OnOpen —— 建立连接(建立连接就表示登陆,试想,当我们使用微信QQ,重新登录上去后,在我们没有登录的这一段时间内,别人发给我们的消息全都显示了出来,所以我们在建立连接时,要同时判断一下,在该用户下线的这个时间段里,都有哪些发送给该用户的消息)- 1、将每个客户端的session都保存起来,有了这些session信息,服务器端就可以将后续的消息转发到这些客户端
- 2、查询本用户(本客户端)在上次登录之后,其他用户在某一频道里发送的消息(这些消息储存在数据库中,根据时间戳,拿到这些消息)
此处就要在MessageDAO类下写一个方法:queryByLastLogout(userid),查询最后一次登录后数据库收到的消息(根据用户id查询)
------这里贴上MessageDAO中的queryByLastLogout方法代码------- - 3、将这些消息发送给当前用户
(2)重写OnMessage —— 将A用户发送来的消息转发给其他用户并且服务器保存接收到用户A发送来的消息
- 1、遍历所有的session,对每个session都发送消息
- 2、服务器将这条消息反序列化为Message类型的对象后,插入到服务器的 消息数据库 中
此处要在MessageDAO类下写一个方法:insert(msg),将就受到的消息插入到数据库中
------这里贴上MessageDAO中的insert方法代码-------
(3)重写OnClose —— 关闭连接,将某一客户端断开连接
- 1、该客户端断开连接,要将在MessageCenter中保存的该客户端的session信息删除
此处用到了前面在MessageCenter中写的delOnlineUser方法 - 2、下次该用户如果建立连接时,需要收到在该用户下线的这段时间有其他客户端发送给该客户端的消息,所以要更新该用户最后下线时刻的时间
此处要在MessageDAO类下写一个方法:updateLastLogout(userId),更新当前这个断开链接的用户最后的在线时间
(4)重写OnError —— 出现错误的时候,同样关闭连接,写法和关闭连接一样
package example.servlet;
import example.dao.MessageDAO;
import example.dao.UserDAO;
import example.model.Message;
import example.model.MessageCenter;
import example.util.Util;
import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.util.List;
@ServerEndpoint("/message/{userId}")
public class MessageWebsocket {
// 建立连接
@OnOpen
public void onOpen(@PathParam("userId") Integer userId,
Session session) throws IOException {
// 1、把每个客户端的session都保存起来,之后将消息转发到所有的客户端时要使用
MessageCenter.addOnLinUser(userId, session);
// 2、查询本客户端(用户)在上次登录之后,别人发送到服务器的消息(在数据库查)
List<Message> list = MessageDAO.queryByLastLogout(userId);
// 3、将这些查询到的消息发送给当前用户
for (Message m: list) {
// 将消息序列化为JSON字符串后发送
session.getBasicRemote().sendText(Util.serialize(m));
}
System.out.println("建立连接" + userId);
}
// 服务器转发消息,并将消息存储在数据库中
@OnMessage
public void onMessage(Session session, String message){
// 1、遍历所保存的所有session信息,对每个都发送消息
MessageCenter.sendMessage(message);
// 2、将消息保存在数据库中
// (1) 反序列化json字符串为message对象
Message msg = Util.deserialize(message, Message.class);
// (2)插入数据库
int n = MessageDAO.insert(msg);
// 服务器显示一下接收到的消息
System.out.printf("接收到消息:%s\n", message);
}
// 断开连接
@OnClose
public void onClose(@PathParam("userId") Integer userId){
// 1、该客户端断开连接,要将在MessageCenter中保存的该客户端的session信息删除
MessageCenter.delOnlinUser(userId);
// 2、下次该用户如果建立连接时,需要收到在该用户下线的这段时间有其他客户端发送给该客户端的消息,
// 所以要更新该用户最后下线时刻的时间
int n = UserDAO.updateLastLogout(userId);
System.out.println("关闭连接");
}
@OnError
public void onError(@PathParam("userId") Integer userId, Throwable t){
System.out.println("出错了");
MessageCenter.delOnlinUser(userId);
t.printStackTrace();
//和关闭连接的操作一样
}
}
这样聊天室的功能就实现了,整个项目工程这里:
https://download.youkuaiyun.com/download/MercuryG/19356248?spm=1001.2014.3001.5501
链接:https://pan.baidu.com/s/1P1hza2CXZiMlFxwp5pagYQ
提取码:51i7