实现一个在线网页的聊天室
Hello,今天给大家带来的是我的一个Web项目的开发过程的相关步骤,这个项目实现的功能是一个Web在线聊天室,简单的来说就是实现在网页版的聊天框,能够实现对于用户信息进行注册,登录,在网页上收发消息的功能。
这个项目也实现了我和别的小伙伴一起实现在线聊天的功能,这是我实现的Web聊天室网页链接地址:[http://47.100.138.17:8080/chatroom/index.html]
感兴趣的小伙伴可以注册登录呦在网上尝试一下聊天。
话不多说,我们直接开始对于开发过程进行实现吧:
第一步:首先是第一步对于需求分析创建需要的数据库表单
对于用户使用Web聊天室实现来说,需要用户用自己的账号,密码登录,同时有自己设置的昵称信息,头像信息;在登录之后有聊天室需要提供频道来使用户在其中进行交流;在交流的时候需要用户去发送消息,不同的用户会在不同的时间发不同的消息内容。
因此呢,根据这些需求就设计了
User(用户表)、channel(频道表)、message(消息表)
三个表单信息:
create table user(
id int primary key auto_increment,
username varchar(15) not null unique comment '账号',
password varchar(15) not null comment '密码',
nickname varchar(20) not null comment '昵称',
head varchar(50) comment '头像url(相对路径)',
logout_time datetime comment '退出登录的时间'
) comment '用户表';
create table channel(
id int primary key auto_increment,
name varchar(20) not null unique comment '频道名称'
)comment '频道';
create table message(
id int primary key auto_increment,
user_id int comment '消息发送方:用户id',
user_nickname varchar(20) comment '消息发送方:用户昵称(历史消息展示需要)',
channel_id int comment '消息接收方:频道id',
content varchar(255) comment '消息内容',
send_time datetime comment '消息发送时间',
foreign key (user_id) references user(id),
foreign key (channel_id) references channel(id)
) comment '发送的消息记录';
三个表单的关系在navicat的EP图中表现是如下的:
第二步:创建一个Mavaen项目,将三个表单的实体类放在Model中
根据MySQL中数据库设计的信息在实体类中实现其属性,利用@Getter、@Setter、@ToString注解快速实现对于类相关方法的生产(需要导入lombok的依赖包)。
第三步:设计关键性的工具类:数据库操作的JDBC工具类;json和java对象转换,session操作的Web工具类
(1)对于JDBC的工具类
在JDBC工具类设计中提供连接连接数据库和释放数据库资源的关键方法。同时为保证线程安全部分功能使用懒汉式的双重校验锁的形式来实现。实现代码如下:
//和数据库连接的工具类
public class DBUtil {
//定义一个单例的数据源来连接对象
private static 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/onlinechatroom");
DS.setUser("root");
DS.setPassword("123456");
DS.setUseSSL(false);
DS.setUseUnicode(true);
DS.setCharacterEncoding("utf-8");
}
}
}
return DS;
}
//数据库的连接方法实现,数据库的关闭方法实现
public static Connection getConnection(){
try {
return getDS().getConnection();
} catch (SQLException e) {
throw new RuntimeException("数据库连接异常",e);
}
}
public static void close(Connection c , Statement s){
close(c,s,null);
}
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 RuntimeException("数据库释放资源出错",e);
}
}
}
(2)对于Web工具类
在Web工具类设计中提供Java对象转为json字符串,json字符串转为Java对象,获取当前登录用户的session信息的功能。为保证线程安全,使用懒汉式的双重校验锁的写法。具体实现代码的如下:
public class WebUtil {
public static final String LOCAL_HEAD_PATH="E://TMP";
//从json中读取到java对象,则jackson库中通过ObjectMapper实现了将数据集或对象转换的实现。
private static ObjectMapper M=null;
//使用懒汉式的双重校验锁的单例模式
private static ObjectMapper getMapper(){
if (M==null){
synchronized (WebUtil.class){
if (M==null){
M=new ObjectMapper();
//SimpleDateFormat是日期工具类能够实现将文本和日期的双重转化
SimpleDateFormat df=new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
//设置自定义的时间结构
M.setDateFormat(df);
}
}
}
return M;
}
//实现将 JAVA对象————>json字符串 的方法
public static String Write(Object o){
try {
//通过
return getMapper().writeValueAsString(o);
} catch (JsonProcessingException e) {
throw new RuntimeException("将JAVA对象转为json字符串时出错",e);
}
}
//反序列化设计:将JSON字符串————>java对象
//两个重载的方法:InputStream 和 String 来进行转换
//inputStream 字节流输入 读取的数据
public static <T> T read(InputStream inputStream,Class<T> tClass){
try {
return getMapper().readValue(inputStream,tClass);
} catch (IOException e) {
throw new RuntimeException("将json字符串转化为JAVA对象时出错",e);
}
}
public static <T> T read(String string ,Class<T> tClass){
try {
return getMapper().readValue(string,tClass);
} catch (IOException e) {
throw new RuntimeException("将json字符串转化为JAVA对象时出错", e);
}
}
//对于session的操作 获取session中的用户信息
public static User getLoginUser(HttpSession session) {
if (session != null) {
//获取登录Session中的user信息 由于getAttribute 返回值是任意类型的 所以需要进行类型的强制转型
//获取的键和登录时设置的键一样
return (User) session.getAttribute("user");
}
return null;
}
}
第四步:实现用户的注册功能
(1)对于用户注册的前端处理实现
在前端中需要创建相应的标签来让用户将自己的用户名,密码,昵称等相关信息输入当中,在标签中设置required则表示必须填写的内容。
用户将需要填写的内容在浏览器上输入完毕之后由前端页面将用户信息获取保存, 将前端中标记好的相关用户信息,放在一个formdata表单中进行存储。创建好格式,调用一个ajax请求,将当前页面的信息进行上传后端,用callback函数做接收信息的处理,如果成功就返回到登录页面,如果是失败就跳提示注册失败的原因。
举例:对于头像文件信息,设置为一个event事件,将event事件传入下方中showHead中。通过获取其中的文件将其保存在vue框架中的head中,在将文件信息写入到body中发送。
实现代码如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>在线聊天室</title>
<link rel="stylesheet" href="../css/common.css">
<link rel="stylesheet" href="../css/index.css">
</head>
<body>
<div id="app" v-cloak>
<form @submit.prevent="register()">
<h3>聊天室注册</h3>
<div class="row">
<span>用户名</span>
<!-- 添加required表示为必填字段,不填写的话会发生报错 -->
<input type="text" v-model="username" required>
</div>
<div class="row">
<span>密码</span>
<input type="password" v-model="password" required>
</div>
<div class="row">
<span>昵称</span>
<input type="text" v-model="nickname" required>
</div>
<div class="row">
<span>头像</span>
<!-- 绑定文件选择文件 改变原始数据添加新数据的改变事件 $event是vue的事件 就是下方函数e 传入的事件-->
<input type="file" accept="image/*" @change="showHead($event)">
<img :src="head.src" v-if="head.src">
</div>
<!-- 后台内容显示报错信息 若果没有则显示为空 -->
<div class="error-message">{{ errorMessage }}</div>
<div class="row">
<!-- 注册按钮点击提交信息 -->
<input type="submit" value="注册">
</div>
<div class="row-right">
<a href="../index.html">返回登录</a>
</div>
</form>
</div>
</div>
</body>
<script src="../js/util.js"></script>
<script src="../js/vue.js"></script>
<script>
let app = new Vue({
el: "#app",
data: {
errorMessage: "",
username: "",
password: "",
nickname: "",
head: {
file: "", //在这里保存选择的文件
src: "", //选择好图片还没上传,客户端本地有一个的图片
},
},
methods: {
//注册选择头像,显示预览图片
//e是传入的事件对象
showHead: function (e){
//获取选择的文件: 通过e.target.file 可获取 在上面标签中的input中的 @chang
let headFile = e.target.files[0];
//保存信息 用上面的vue框架信息里面的file地址保存图片
app.head.file= headFile;
//将文件的信息转化为url 调用Url中的信息
app.head.src=URL.createObjectURL(headFile);
},
register: function (){
//注册功能的实现
//使用FormData对象作为form-data格式上传的数据
//创建FormData格式的对象来调用该形式
let formData=new FormData();
//添加数据,利用append将相关参数进行设置 使用 k v 模型 k参数和APP中设置的参数一样
formData.append("username",app.username);
formData.append("password",app.password);
formData.append("nickname",app.nickname);
//如果上传了头像的信息
if (app.head.file){
//将头像的信息传入当中
formData.append("headFile",app.head.file);
}
ajax({
method: "post",
//当前html位置是在/views/register.html
url: "../register",//当前html是在/views/register.html
//上传文件,使用form-data格式,但是不能设置这个Content-Type
//body中放置的信息 上传文件使用的form-data格式
body: formData,
//返回响应
callback: function(status, responseText){
//表示服务器返回的相应状态码出错
// console.log(responseText);//查看一下响应正文的数据是否符合业务的,可以抓包(建议)
if(status != 200){
alert("出错了,响应状态码:"+status);
return;
}
//表示正常返回200 就进行接下来的操作
//响应正文的地方
let body = JSON.parse(responseText);//响应正文
if(body.ok){
alert("注册成功");
//跳转到登陆的页面
window.location.href = "../index.html";
}else{
// //注册失败 显示错误,根据后端的reason反馈信息
app.errorMessage = body.reason;
}
}
});
}
},
});
</script>
</html>
(2)对于用户注册的后端处理实现
(tips:在写后端的响应之前做一个测试,利用抓包工具验证是否能够正常的发送请求和响应。)
接下来进行对于RegisterServlet的开发,首先设置Servlet注解获取前端传过来的formdata表单数据,在其次对于传过来的数据进行构造成一个实体类,存到数据库中。在针对于头像文件获取的时候,需要先获取存储照片的后缀名,将其保存下来创建一个时间戳相关的随机字符串构成重命名,最后形成新的文件保存在本地的文件中。
最后调用用户表的相关操作(封装在UserDao类中)将数据库的信息存储完毕之后就可以返回后续的响应,但是需要构造一个响应对象,需要设置JsonResult返回的对象类,设置好返回格式,返回信息。最后通过resp的相关API来实现对于响应数据的返回。
实现的代码如下:
//做前端注册页面的响应
@WebServlet("/register")
@MultipartConfig //FormData上传数据需要
public class RegisterServlet extends HttpServlet {
//对于前端页面发送的POST请求数据做后端解析
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
//设置请求正文的编码
req.setCharacterEncoding("UTF-8");
//获取前端传递过来的FormData表单格式数据
//在这里需要添加一个 @MultipartConfig 的注解获取form data格式的数据
//用String创建对象来接受传递过来的参数 通过请求的getParameter来获取
String username=req.getParameter("username");
String password=req.getParameter("password");
String nickname=req.getParameter("nickname");
//对于头像文件的获取,前端传递可能为空
//如果存在就从数据中获取信息
Part headFile=req.getPart("headFile");
//将接受到的数据形成一个User对象 进一步的将User对象传递到数据库中 形成注册
User user= new User();
user.setUsername(username);
user.setPassword(password);
user.setNickname(nickname);
//对于传递过来的头像文件需要进行判断是否为空才能进行存储
if (headFile!=null){
//传递头像文件的方法:将文件保存在服务端的一个路径中
//先获取上传文件的后缀名称 getSubmittedFileName() 获取其原始名称
String filename=headFile.getSubmittedFileName();
//找到最后一个点的索引位置,并返回 能够获取其中的照片格式 如JPG 或者JPEG
String suffix=filename.substring(filename.lastIndexOf('.'));
//添加一个随即字串和时间戳有关 在拼接上后续的照片格式,实现了对于文件的重命名
filename= UUID.randomUUID()+suffix;
//保存文件的路径
headFile.write(WebUtil.LOCAL_HEAD_PATH+"/"+filename);
//数据库保存头像的路径
user.setHead("/"+filename);
}
//将数据信息保存到数据库中:判断其是否存在用户名和账号重复
User exist = UserDao.checkIfExist(username,nickname);
//后续的逻辑信息,如果数据存在,就返回错误的信息, 如果不存在就 进行注册功能 并返回响应
//此时需要一个返回格式 构建model——JsonResult
//构造响应的对象
JsonResult result=new JsonResult();
if (exist!=null) {
//表示查询不为空,用户信息存在
//result.setOk(false); 初始的布尔值为false所以可以不用给设置
result.setReason("账号或者昵称已存在");
}else {
//表示查询为空,执行数据库的插入信息功能
int n= UserDao.insert(user);
result.setOk(true);
}
//接下来应该返回HTTP响应给前端数据
resp.setContentType("application/json; charset=utf-8");
//需要将java对象转为json的形式
String body=WebUtil.Write(result);
resp.getWriter().write(body);
}
}
第五步:对于数据库三个类的JDBC操作实现
(1)对于用户表的工具类实现
在实现数据存储到数据库中时需要去开发UserDao这个实体类用来存放用户信息到数据库,因此对于user用户表的查询,插入,修改操作是经常性的需要去进行完成。所以在这个user用户表的工具类中实现了对于插入、查询、修改的操作方法。
实现的代码如下:
//用户表数据库相关的操作
public class UserDao {
//注册:检查账号、昵称是否存在 实现JDBC操作
public static User checkIfExist(String username,String nickname){
Connection c=null;
PreparedStatement preparedStatement=null;
ResultSet rs=null;
try {
c= DBUtil.getConnection();
String sql="select * from user where username=?";
if (nickname!=null){
sql+="or nickname=?";
}
//将上面的预编译的的占位符进行替换数据
preparedStatement=c.prepareStatement(sql);
preparedStatement.setString(1,username);
if (nickname!=null){
preparedStatement.setString(2,nickname);
}
//执行查询操作,返回结果集进行接收
rs=preparedStatement.executeQuery();
//准备查询的User对象
User queryUser=null;
while (rs.next()){
queryUser=new User();
//将结果集的字段设置到属性中
Integer id=rs.getInt("id");
String loginNickname=rs.getString("nickname");
String password=rs.getString("password");
String head=rs.getString("head");
java.sql.Timestamp logoutTime=rs.getTimestamp("logout_time");
queryUser.setId(id);
queryUser.setUsername(username);
queryUser.setPassword(password);
queryUser.setNickname(loginNickname);
queryUser.setHead(head);
if (logoutTime!=null){
//考虑数据是不是为空的情况
long l=logoutTime.getTime();
queryUser.setLogoutTime(new java.util.Date(l));
}
}
return queryUser;
} catch (SQLException e) {
throw new RuntimeException("注册检查账号昵称是否存在JDBC出现错误",e);
}finally {
DBUtil.close(c,preparedStatement,rs);
}
}
public static int insert(User user) {
Connection c=null;
PreparedStatement ps=null;
try {
c=DBUtil.getConnection();
String sql="insert into user (username,password,nickname,head)"+" values(?,?,?,?)";
//进行预编译
ps=c.prepareStatement(sql);
//替换占位符
ps.setString(1, user.getUsername());
ps.setString(2, user.getPassword());
ps.setString(3, user.getNickname());
ps.setString(3, user.getHead());
return ps.executeUpdate();
} catch (SQLException e) {
throw new RuntimeException("插入数据时出现错误",e);
}finally {
DBUtil.close(c,ps);
}
}
//数据库修改用户的退出时间jdbc代码
public static int updateLogoutTime(User loginUser) {
Connection c=null;
PreparedStatement ps=null;
try {
c=DBUtil.getConnection();
String sql="update user set logout_time=? where id=?";
ps=c.prepareStatement(sql);
//替换时间站位符 获取用户中存储的退出时间
long currentTime=loginUser.getLogoutTime().getTime();
ps.setTimestamp(1,new Timestamp(currentTime));
ps.setInt(2,loginUser.getId());
return ps.executeUpdate();
} catch (SQLException e) {
throw new RuntimeException("更新用户上次注销时间出错", e);
} finally {
DBUtil.close(c,ps);
}
}
}
(2)对于消息表的工具类实现
对于用户的发送的消息需要存储到数据库的消息表字段中,同时上线的用户也需要获取历史的消息,因此对于消息表需要实现插入和查询的方法。实现代码如下:
//对于数据库Message的JDBC操作
public class MessageDao {
//对于Message操作有查询和插入操作
//给用户放回从退出时间开始算起的保存的历史消息
public static List<Message> query(Date logoutTime){
Connection c=null;
PreparedStatement ps=null;
ResultSet rs = null;
try {
c= DBUtil.getConnection();
String sql="select * from message";
//对于刚注册是用户,没有上次注销的时间,要进行判断一下
if (logoutTime!=null) {
//表示有用户存在
sql += " where send_time > ?";
}
ps=c.prepareStatement(sql);
if (logoutTime!=null){
//表示用户存在,将预编译的信息进行参数设置
//从退出的时间开始算起 保存的数据
ps.setTimestamp(1,new Timestamp(logoutTime.getTime()));
}
//得到执行的结果 存放在rs中
rs=ps.executeQuery();
List<Message> messages=new ArrayList<>();
while (rs.next()){
//构建Message对象来接收rs中的消息
Message getOldMessage=new Message();
getOldMessage.setId(rs.getInt("id"));
getOldMessage.setUserId(rs.getInt("user_id"));
getOldMessage.setUserNickname(rs.getString("user_nickname"));
getOldMessage.setChannelId(rs.getInt("channel_id"));
getOldMessage.setContent(rs.getString("content"));
getOldMessage.setSendTime(rs.getTimestamp("send_time"));
//将获取的历史消息对象传到消息队列中
messages.add(getOldMessage);
}
//将查询到的历史消息返回到队列中进行返回
return messages;
} catch (SQLException e) {
throw new RuntimeException("查询历史消息出错",e);
}finally {
DBUtil.close(c,ps,rs);
}
}
//插入数据操作
public static int insert(Message m){
Connection c=null;
PreparedStatement ps=null;
try {
c=DBUtil.getConnection();
//将接收到的用户发送的消息保存起来
//接收到的用户信息的包含的字段 有 内容 用户的昵称 用户的id 频道号 发送的时间
String sql="insert into message(content, user_id, user_nickname, channel_id, send_time) " +
" values(?,?,?,?,now())";
ps=c.prepareStatement(sql);
ps.setString(1,m.getContent());
ps.setInt(2,m.getId());
ps.setString(3,m.getUserNickname());
ps.setInt(4,m.getChannelId());
//设置接收到的信息发给前端
m.setSendTime(new Date());
return ps.executeUpdate();
} catch (SQLException e) {
throw new RuntimeException("保存发送的消息jdbc出错",e);
}finally {
DBUtil.close(c,ps);
}
}
}
(3)对于频道表的工具类实现
用户在进入界面的时候能够获取到所有频道的信息,因此对于频道中所有频道需要实现查询的方法。实现代码如下:
public class ChannelDao {
//实现查询返回 channels表单数据即可
public static List<Channel> selectAll() {
Connection c=null;
Statement ps=null;
ResultSet rs=null;
try {
//对于上述进行赋值
c= DBUtil.getConnection();
//展示的频道框为第一个
String sql ="select * from channel order by id";
ps=c.createStatement();
rs=ps.executeQuery(sql);
//用来接受所有的channel
List<Channel> channels=new ArrayList<>();
while (rs.next()){
//获取的数据很多个,将每一个数据转为channel对象
Channel channel=new Channel();
//Channel的关键字段为 id name
int id=rs.getInt("id");
String name=rs.getString("name");
channel.setId(id);
channel.setName(name);
//将数据添加的到List结构中的Channels里面
channels.add(channel);
}
return channels;
} catch (SQLException e) {
throw new RuntimeException("查询频道列表时出错",e);
}finally {
//释放资源
DBUtil.close(c,ps,rs);
}
}
}
第六步:对于登录功能的实现
(1)对于登录页面的前端实现
用户在登录页面登录自己的用户名和密码信息后,前端进行获取,前端获取后通过ajax发送到后端进行解析,通过回调函数来确定是否是注册账号,如果是就进行登录,如果不是就提示输入有误。
实现的代码如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>在线聊天室</title>
<link rel="stylesheet" href="css/common.css">
<link rel="stylesheet" href="css/index.css">
</head>
<body>
<div id="app" v-cloak>
<form @submit.prevent="login()">
<h3>聊天室登录</h3>
<div class="row">
<span>用户名</span>
<input type="text" v-model="username" required>
</div>
<div class="row">
<span>密码</span>
<input type="password" v-model="password" required>
</div>
<div class="error-message">{{ errorMessage }}</div>
<div class="row">
<input type="submit" value="登录">
</div>
<div class="row-right">
<a href="views/register.html">注册</a>
</div>
</form>
</div>
</body>
<script src="js/util.js"></script>
<script src="js/vue.js"></script>
<script>
let app = new Vue({
el: "#app",
data: {
errorMessage: "",
username: "",
password: "",
},
methods: {
//实现前端的登录功能
login: function (){
ajax({
method: "post",
url: "login",
contentType:"application/json",
//转化为json对象 将数据转为字符串
body:JSON.stringify({
username: app.username,
password: app.password,
}),
//回调函数
callback:function (status,responseText){
if (status!=200){
alert("登录出错了,服务器可能开小差了。" +
"返回响应状态码为:"+status);
return;
}
let body=JSON.parse(responseText);
if (body.ok){
alert("账号密码验证成功,欢迎进入在线聊天室")
//跳转到聊天框页面进行访问
window.location.href="views/message.html"
}else {
//登录失败,显示错误信息
app.errorMessage=body.reason;
}
}
});
},
},
});
</script>
</html>
(2)对于登录页面的后端实现
首先在后端是对于前端的登录页面发来的请求的进行获取信息,通过获取前端的json字符串的user信息查询数据库来实现对于用户信息的校验,如果存在就创建session保存用户信息,不存在就提示用户有错误。最后将查询的相关用户的信息返回到当前页面的响应中。实现的代码如下:
//对于后端登录页面的信息的功能实现
@WebServlet("/login")
public class loginServlet extends HttpServlet {
//对于前端发起的Post方法做一个返回响应
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
//设置一个请求正文的编码
req.setCharacterEncoding("utf-8");
//解析传递过来的json数据 调用WebUtil中的读取方法即可 将输入流中的数据转为Class对象
User input = WebUtil.read(req.getInputStream(),User.class);
//对于账号密码的检验
//先检验账号是否存在,如果不存在提示,如果存在,在校验密码 使用UserDao 中对于用户名和昵称的检验方法
User exist= UserDao.checkIfExist(input.getUsername(),null);
//准备返回的Web响应 调用JsonResult 实现即可
JsonResult result=new JsonResult();
//对于密码进行验证
if (exist==null){
result.setReason("您输入的账号不存在");
}else{
//对于密码进行判断
//exits是查询的username对应在数据库中的信息 input.getPassword是用户在页面上登录的信息
if (!exist.getPassword().equals(input.getPassword())){
//表示校验失败 登录密码错误 设置返回原因
result.setReason("您输入的密码有误,请重新输入");
}else {
//校验成功 需要给用户创建session保存信息
HttpSession session=req.getSession();
//保存数据库查询到的用户信息
session.setAttribute("user",exist);
result.setOk(true);
}
}
//返回响应数据
resp.setContentType("application/json; charset=utf-8");
String body=WebUtil.Write(result);
//把body数据写进当前页面的响应中
resp.getWriter().write(body);
}
}
第七步:对于聊天页面的功能实现
(1)对于聊天页面的前端实现
前端处理:先构建频道信息,对于每个对话框进行设计,能够实现基础的点击对话框就能跳转到非当前对话框。同时在页面加载的时候,需要对于对话框的列表进行获取和返回。根据与后端传递过来的channel参数信息来设置前端的响应,对于Channel中的属性继续的进行实现。对于消息推送功能的实现,使用WebSocket的方式实现,之所以不用Http是因为Http对于客户端和服务端之间需要一发一收后才能进行下一步操作,不能实现客户端和服务端全双工的特性,而WebSocket则实现了该特性,对于WebSocket,则是基于TCP协议,首先发送Http请求建立连接(目的是双方确定后续使用的协议和秘钥),后续使用Websocket协议(在应用层使用相同的数据格式来发送、接收数据)来实现收发数据。在前端使用socket的相关api来完成对于消息的处理。
前端处理代码实现:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>在线聊天室</title>
<link rel="stylesheet" href="../css/common.css">
<link rel="stylesheet" href="../css/message.css">
</head>
<body>
<div id="app" v-cloak>
<div id="nav">
<!-- 在这里由 current.userNickname 换为 currentUser.nickname 和下端vue 进行对齐 -->
<span>欢迎进入在线聊天室!{{ currentUser.nickname }}</span>
<!-- 注销链接的实现 实现后端注销功能 -->
<a href="../logout">注销</a>
</div>
<div id="container">
<div id="channel-list">
<!-- 这里的currentChannel.id和下面当前频道id需要对应 -->
<!-- @click="changeChannel(c) 应用的是vue框架中的点击事件 c就是传递进去的对象每一个channel的对象 然后进行遍历 -->
<div :class="c.id== currentChannel.id ? 'channel-row-checked' : 'channel-row-unchecked'" v-for="c in channels" :key="c.id" @click="changeChannel(c)" >
<!-- 显示是当前频道的昵称 -->
<span class="channel-item"> {{ c.name }}</span>
<span v-if="c.unreadCount" class="unread">{{ c.unreadCount }}</span>
</div>
</div>
<div id="dialog">
<div id="dialog-history">
<div class="dialog-row" v-for="m in currentChannel.historyMessages" :key="m.id">
<div class="dialog-date">{{ m.sendTime }}</div>
<div class="dialog-user">{{ m.userNickname }}</div>
<!-- 三目表达式来进行 判断当前的 发送的消息是否是用户自己发送的消息 -->
<div :class="m.userId==currentUser.id ? 'dialog-current-content' : 'dialog-other-content'">{{ m.content }}</div>
</div>
</div>
<!-- 展示的消息内容 字段为currentChannel.inputMessageContent-->
<!-- @keyup="checkIfSend($event)" vue绑定按键弹起事件,$event就是事件对象 -->
<textarea id="dialog-content" v-model="currentChannel.inputMessageContent" @keyup="checkIfSend($event)"></textarea>
<div id="dialog-send">
<button @click="sendMessage">发送(S)</button>
</div>
</div>
</div>
</div>
</body>
<script src="../js/util.js"></script>
<script src="../js/vue.js"></script>
<script>
let app = new Vue({
el: "#app",
data: {
websocket:null,
currentUser: {
//当前登录用户
nickname: "",
head: "",
},
//设置频道信息 写静态数据验证前端代码,后续从servlet的响应中获取数据
channels: [
{
id: 1,
name: "带刀侍卫群",
//存放历史消息的地点
historyMessage: [],
//输入框的内容
inputMessageContent: "",
unreadCount: 0,
},
{
id: 2,
name: "门前麻将群",
//存放历史消息的地点
historyMessage: [],
//输入框的内容 每个频道的输入框内容不一样
inputMessageContent: "",
unreadCount: 0,
},
],
//当前频道
currentChannel: {
id: 1,
name: "带刀侍卫群",
//存放历史消息的地点
historyMessage: [],
//输入框的内容
inputMessageContent: "",
unreadCount: 0,
},
},
methods: {
//点击切换频道的功能实现
changeChannel: function (channel) {
//先判断点击的频道是否是当前频道
if (channel.id != app.currentChannel.id) {
app.currentChannel = channel;
}
//切换到一个频道后,滚到最后,并且未读消息=0
app.scrollHistory();
},
//从后端获取频道列表 再设置到vue的变量中,页面就可以跟着去改变
getChannels: function () {
//发送AJAX请求获取数据
ajax({
method: "get",
//获取频道列表
url: "../channelList",
callback: function(status, responseText){
// console.log(responseText);//查看一下响应正文的数据是否符合业务的,可以抓包(建议)
if(status != 200){
alert("出错了,响应状态码:"+status);
return;
}
//设置响应正文
let body = JSON.parse(responseText);//响应正文
//后端ChannelListServlet中返回的是{user:{},channels:[]}
//返回的Channel是不带historyMessage(历史消息) inputMessageContent(输入框消息)
//需要给返回的数据添加上当前的消息
//当前用户
app.currentUser = body.user;
for(let i=0; i<body.channels.length; i++){
//添加历史消息 为数组的形式
body.channels[i].historyMessages = [];
//会话框的消息置为空
body.channels[i].inputMessageContent = "";
body.channels[i].unreadCount = 0;
//默认切换到第一个频道
if(i == 0){
//设置现在的界面为初始的频道值
app.currentChannel = body.channels[0];
}
}
//将数据库中的响应值传递给前端中进行实现。
app.channels = body.channels;
//接收消息
app.initWebsocket();
}
});
},
//接受消息功能
initWebsocket: function () {
//在这里面写入websocket的连接获取的消息
//创建一个websocket对象,用来创建该连接,客户端收发数据
//websocket的url格式 协议名://ip:port/contextPath/资源路径
// websocket协议名 为ws contextPath是部署的项目名/项目路径
//获取前端当前页面的url的协议名 为 http:(有:)
let protocal = location.protocol;
//获取当前地址栏的url:http://xxx.xxx.xxx.xxx:8080/chatroom/views/message.html
let url = location.href;
//url:http://xxx.xxx.xxx.xxx:8080/chatroom/views/message.html
//字符串.indexOf(str), 返回第一个匹配str的索引位置
//截取后的url为 xxx.xxx.xxx:8080/chatroom
url = url.substring((protocal + "//").length, url.indexOf("/view/message.html"))
//创建websocket的连接url
//新的url为 ws://xxx.xxx.xxx.xxx:8080/chatroom/message
let ws = new WebSocket("ws://" + url + "/message");
//此时可以进行连接
//绑定事件,事件发生的时候,由浏览器自动调用事件函数
//建立连接事件:e
ws.onopen = function (e) {
console.log("客户端连接")
}
//关闭连接 可能由服务器关闭或者先由客户端进行关闭
ws.onclose = function (e) {
let reason = e.reason;
console.log("close:" + reason)
if (reason) {
alert(reason)
}
}
//发生错误事件
ws.onerror = function (e) {
console.log("websocket出错")
}
//接收消息事件
ws.onmessage = function (e) {
//服务器推送消息给客户端执行该函数
//获取 e 中推送的消息
//消息对象
let m = JSON.parse(e.data);
//对于channels数组进行遍历
for (let channel of app.channels) {
if (channel.id == m.channelId) {
//数组放置元素的方法 将获取的消息放在历史消息中
channel.historyMessage.push(m);
//如果是当前的频道,就滚动到最后
if (m.channelId == app.currentChannel.id) {
app.scrollHistory();
} else {
//不是当前频道,未读消息数++
channel.unreadCount++;
}
}
}
}
//刷新/关闭页面,需要关闭websocket
window.onbeforeunload = function (e) {
//主动关闭websocket连接
ws.close();
}
app.websocket = ws;
},
//当前频道接收到消息后,滚动到最下面
scrollHistory: function () {
app.$nextTick(function () {
//异步操作:vue渲染元素css,数据完成后,在执行
//当前频道历史消息div
let history = document.querySelector("#dialog-history");
//scrollTop是滚动条顶部 scrollHeight是整个滚动div的高
//滚动条的设置
history.scrollTop = history.scrollHeight;
});
//将未读消息设为0
app.currentChannel.unreadCount = 0;
},
//绑定当前频道的键盘发送消息 为CTRL+ENTER
checkIfSend: function (e) {
if (e.keyCode == 13 && e.ctrlKey) {
//发送消息
app.sendMessage();
}
},
//发送消息
sendMessage: function () {
let content = app.currentChannel.inputMessageContent;
if (content) {
//表示消息不为空 利用websocket来进行发送消息
//后台需要插入数据库一条客户端发送的消息
//id,user_id, user_nick,send_time(后端可以获取到,不用发),channel_id, content...)
//注意:后端是将json字符串反序列化为message对象(驼峰式)
app.websocket.send(JSON.stringify({
//发送当前的频道 id
channelId: app.currentChannel.id,
//将当前的输入文本内容设置进去
content: content,
}));
//清空当前频道的消息
app.currentChannel.inputMessageContent = "";
}
},
},
});
//在页面初始化的时候,就需要获取频道列表
app.getChannels();
</script>
</html>
(2)对于聊天页面的后端实现
先对于ChannelList类进行设计,在后端返回响应之前需要对于用户信息的session进行确认,和查询的数据库信息进行比对,如果不同,则进行禁止访问的状态码设置。如果查询成功存在的话,查询数据库的所有信息返回到List中,再将数据写入Json字符串中,最后将数据写在响应中进行返回。
对于数据库查询所有的Channels信息用ChannelDao的一个方法来进行实现。然后对于后端messsage的功能实现,该功能是基于websocket实现所以需要导入Websocket的依赖包:
javax.websocket
javax.websocket-api
1.1
provided
接下来对于后端实现对于messageEndpoint的相关功能实现,构造能够保存在线用户信息的onlineUsers数据结构来,构造能够实现消息信息存储的数据结构,并创建线程不断的从消息队列中取出消息发给每一个用户,最后实现对于基于socket的注解的open 、close、error、message的方法实现。对于OnOpen方法需要判断用户是否登录,如果登录就需要踢掉上一个该账户的用户,来实现一个用户同时只能一个人来进行登录的情况,同时能够将之前的历史数据进行获取。对于OnClose方法在实现的时候需要去从在线用户中去除掉当前的用户信息同时,将数据库的用户退出时间做好记录,在此调用的是DBUtil中的修改用户退出时间的方法。对于OnError方法,表示出现错误,当前用户的登录信息被清除,重新进行登录。对于OnMessage方法,将用户发送的消息通过socket的连接发送过来进行接收,在这里将消息存储到消息队列中,同时将此条消息对于所有的在线用户进行推送过去。
具体的代码实现如下:
package org.example.api;
import org.example.dao.MessageDao;
import org.example.dao.UserDao;
import org.example.model.Message;
import org.example.model.User;
import org.example.util.WebUtil;
import org.example.util.WebsocketConfigurator;
import javax.servlet.http.HttpSession;
import javax.websocket.*;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.LinkedBlockingQueue;
//使用con
@ServerEndpoint(value = "/message",configurator = WebsocketConfigurator.class)
public class MessageEndpoint {
//传入的session信息
private Session session;
//当前登录的用户对象
private User loginUser;
//用数据结构保存所有客户端的websocket会话
//根据配置类的信息,则是用map结构保存所有websocket会话,判断是否相同账号重复登录,就比较方便(key: userId, value: Session)
//基于ConcurrentHashMap多线程安全的方式保存在线用户的Session
private static Map<Integer, Session> onlineUsers = new ConcurrentHashMap<>();
//创建消息队列,将用户的消息存放在队列中
//LinkedBlockingQueue是一个链表实现的,无边界阻塞队列
private static BlockingQueue<Message> messageQueue = new LinkedBlockingQueue<>();
//消费消息:创建一个或多个线程,从消息队列中一个一个拿,每个都转发到所有在线用户
static {
new Thread(new Runnable() {
@Override
public void run() {
//不断的取出消息
while (true){
try {
//取出消息队列中的消息
Message m=messageQueue.take();
for (Session session : onlineUsers.values()){
//将登录用户的Session信息保存到创建的session中
//将session数据写入到字符串中
String json= WebUtil.Write(m);
//调整为包含插入的字段
session.getBasicRemote().sendText(json);
}
}catch (InterruptedException e){
e.printStackTrace();
}catch (IOException o){
o.printStackTrace();
}
}
}
}).start();
}
@OnOpen
public void onOpen(Session session) throws IOException {
System.out.println("建立连接");
//验证一下是否登录,如果登录踢掉一个已登录的同一个用户
//获取当前用户的httpsession信息
HttpSession httpSession = (HttpSession) session.getUserProperties().get("HttpSession");
//获取当前httpsession的User对象
User user=WebUtil.getLoginUser(httpSession);
//对于user进行判断
if (user==null){
//用户没有登录:关闭连接 返回一个socket的CloseReason
CloseReason reason=new CloseReason(CloseReason.CloseCodes.NORMAL_CLOSURE,"没有登录,禁止访问");
return;
}
//对于session 进行判断
//踢掉使用相同账号登录的用户 先对于已经在线的用户进行查询操作 当前登录的用户存放在Map集合中
Session preSession =onlineUsers.get(user.getId());
if (preSession!=null){
//表示在所有的key中有用户的id
//执行踢掉之前登录的用户
CloseReason closeReason=new CloseReason(CloseReason.CloseCodes.NORMAL_CLOSURE,"账号在别处登录");
//对于前一个用户进行剔除
preSession.close(closeReason);
}
//将用户信息更新 踢出上一个用户操作完成
this.loginUser=user;
this.session=session;
//用户信息保存在 在线用户数据中
onlineUsers.put(user.getId(),session);
//到此websocket连接,客户端需要接收所有的历史消息
//调用MessageDao的查询方法,查询从上一次退出到现在的历史消息
List<Message> messages=MessageDao.query(user.getLogoutTime());
//获取历史消息
for (Message m: messages){
//变量获取到的历史消息 并发送到当前的用户账号中
//此时是websocket在接收消息,所以需要用json格式,将数据进行转化
String json =WebUtil.Write(m);
session.getBasicRemote().sendText(json);
}
}
@OnClose
public void onClose(){
System.out.println("断开连接");
//删除在线用户信息
onlineUsers.remove(loginUser.getId());
//记录下退出的时间
loginUser.setLogoutTime(new java.util.Date());
int n = UserDao.updateLogoutTime(loginUser);
}
@OnError
public void onError(Throwable t){
t.printStackTrace();
//出现异常删除当前用户登录的状态
onlineUsers.remove(loginUser);
}
@OnMessage
public void onMessage(String message) {
//首先将接收到的json消息转为一个message对象
//调用WebUtil中的读取方法
Message m=WebUtil.read(message,Message.class);
//将当前用户的信息传到构建的Message对象中
m.setUserId(loginUser.getId());
m.setUserNickname(loginUser.getNickname());
System.out.println("收到消息:"+message);
//将接收到消息保存到数据库
int n = MessageDao.insert(m);
//同时将消息放到在线的消息队列中,进行推送(这是服务端主动进行发送)
//将消息放到阻塞队列中
try {
messageQueue.put(m);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
第八步:对于注销功能的实现
在页面上有一个注销按钮,用户点击后就能实现注销功能。实现该功能是前端发送一个请求,后端执行一个servlet响应即可,在响应前需要核对用户的信息是否存在,不存在就禁止访问。如果存在就删除当前的session信息同时从当前的在线用户中退出,记录下退出的时间在数据库中进行更新,以便下次能够获取未读的历史消息,最后重定向初始的登录页面,注销功能完成。
实现的代码如下:
@WebServlet("/logout")
public class LogoutServlet extends HttpServlet {
//前端发来的是get请求
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
//获取当前用户的session信息
HttpSession session=req.getSession(false);
//从session中获取用户信息
User user= WebUtil.getLoginUser(session);
if (user==null){
//表示没有登录返回 403
resp.setStatus(403);
return;
}
//进行正常的用户退出功能
//删除session中保存的用户
session.removeAttribute("user");
//给用户创建退出的时间
user.setLogoutTime(new java.util.Date());
//将用户退出时间传递给数据库
UserDao.updateLogoutTime(user);
//返回重定向到初始界面
resp.sendRedirect("index.html");
}
}
以上就是今天的全部内容了,后续的源码连接我会发到评论区,如果需要源码的话可以进行查看。如果觉得有用的话就点个赞吧!感谢!!!