聊天室项目
开发环境与技术栈
- Windows/Mac/Linux
- WebSocket
- Maven
- Servlet
- MySQL
- Gson
项目功能
主要业务:实现多人群聊功能,记录并管理所有聊天消息。
- 用户注册(打开主页,可以看到注册按钮);
- 用户登录(打开主页,可以看到登录界面);
- 登录成功后可以进入主界面,显示当前所有的聊天频道列表;
- 点击某个频道后,可以看到该频道的消息;
- 每个用户都可以在频道发信息,并且该频道的所有人都能接收到他的消息;
- 历史消息功能。用户登陆成功之后,可以看到从上次自己下线到这次上线这段时间中错过的历史消息。
核心技术要点:利用websocket,实现消息推送机制。
项目演示
基于Java的WebSocket聊天室项目功能演示
开发准备
1.IDEA编码配置:
2.自动编码设置:
3.配置好pom.xml文件,指定打包方式,引入相关依赖等等;
//比如引入websocket的依赖
<dependency>
<groupId>javax.websocket</groupId>
<artifactId>javax.websocket-api</artifactId>
<version>1.1</version>
<scope>provided</scope>
</dependency>
4.生成项目的web.xml文件:
5.在IDEA中设置好tomcat部署项目自动发布 功能:
6.准备好前端资源
数据库设计
1.数据库关系说明
根据需求可以确定,项目大致涉及三个实体,分别是用户User、频道Channel和消息Message。
- 首先是用户和频道,按理说应该是多对多的关系。但是为了代码实现方便,默认让所有用户关注了所有频道。所以,用户和频道可以简化地看作为没关系;
- 其实是用户和消息,为了实现历史消息功能,一个用户可能有多条未获取的消息,所以用户和消息是一对多的关系;
- 最后是频道和消息,按理说应该是一对多的关系,但是这个过程我准备直接拿websocket代码处理。不需要从数据中拿,所以此处也可以看作是没关系。
2.创建数据库及数据库表
create database chatroom;
use chatroom;
-- 创建用户表
drop table if exists user;
create table user (
userId int primary key auto_increment, -- 用户id
name varchar(50) unique, -- 用户名
password varchar(50), -- 用户密码
nickName varchar(50), -- 昵称(聊天时显示的名字)
lastLogout datetime -- lastLogout 表示上次退出的时间. 用来实现历史记录功能.
);
-- 创建频道表
drop table if exists channel;
create table channel (
channelId int primary key auto_increment,
channelName varchar(50) -- 频道名
);
-- 创建消息表
drop table if exists message;
create table message (
messageId int primary key auto_increment,
userId int, -- 谁发送的消息.
channelId int, -- 频道id
content text, -- 消息正文
sendTime datetime -- 消息的发送时间.
);
前后端接口约定
要实现功能,需要先明确前后端需要约定好的接口。接口的定义一般是前后端约定好的,所以也和前端代码息息相关,前端需要什么数据,后端需要什么数据,数据的格式等等,也都会在接口中体现。
1.用户注册
(1)请求
POST /register //请求方法和路径
{
name:xxx,
password:xxx,
nickName:xxx
}
(2)响应
{
ok:1, //约定1表示成功,0表示失败
reason:xxx //成功返回"",失败返回一个具体的原因
}
2.用户登录
(1)请求
POST /login
{
name:xxx,
password:xxx
}
(2)响应
{
ok:1,
reason:xxx,
userId:xxx,
name:xxx,
nickName:xxx //后三条主要是为了前端页面实现方便
}
3.检测登录状态
(1)请求
GET /login
(2)响应
{
ok:1, //ok为1表示已登录,ok为2表示未登录
reason:xxx,
userId:xxx;
name:xxx,
nickName:xxx
}
4.新增频道
(1)请求
POST /channel
{
channelName:xxx
}
(2)响应
{
ok:1,
reason:xxx
}
5.获取频道列表
(1)请求
GET /channel
(2)响应
[
{
channelId:xxx,
channelName:xxx
},
{
channelId:xxx,
channelName:xxx
}
]
6.删除频道
(1)请求
DELETE /channel?channelId=?
(2)响应
{
ok:1,
reason:xxx
}
7.建立websocket连接
此时就不是http协议了,请求响应格式也跟上面不太一样。
//每个登录中的用户,都需要有自己的连接,连接之间用各自唯一标识userId来区分
ws://[ip]:[port]/message/{userId}
8.发送/接收消息
为了方便,规定发送和接收的格式都为JSON格式。
当前这个message是通过WebSocket来传输,而不是走HTTP协议了。
{
userId:xxx, //消息是谁发送的
nickName:xxx, //消息发送者的昵称
channelId:xxx, //消息是往哪个频道发送的
content:xxx //消息内容
}
有了上述的约定,后面不管是后端代码,还是前端代码都需要严格遵守上述约定。否则,就无法完成交互。
代码设计
1.设计数据库实体类
(1)User实体类
package model;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import java.sql.Timestamp;
//一个 User 对象就用来表示一条数据库的记录.
//对象的属性基本和数据库的表结构一致. (大部分是一致的, 可能有细节上的差异)
@Getter
@Setter
@ToString
public class User {
private int userId;
private String name;
private String password;
private String nickName;
private Timestamp lastLogout;
}
(2)Channel实体类
package model;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
@Getter
@Setter
@ToString
public class Channel {
private int channelId;
private String channelName;
}
(3)Message实体类
package model;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import java.sql.Timestamp;
@Getter
@Setter
@ToString
public class Message {
private int messageId;
private int userId;
private int channelId;
private String content;
private Timestamp sendTime;
// nickName 在 message 表中并不存在. 但可以根据 userId 在 user 表中查到
// 直接把用户的昵称放到这里, 方便后面的界面显示.
private String nickName;
}
2.设计工具类
(1)DBUtil工具类。(对JDBC操作进行简单的封装)
package util;
import com.mysql.jdbc.jdbc2.optional.MysqlDataSource;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
// 功能: 帮助我们管理连接. DBUtil 本质上是实现了 DataSource 类的单例版本.
// 对于一个应用程序来说, DataSource 只需要有一个实例就可以了.
public class DBUtil {
private static final String URL = "jdbc:mysql://127.0.0.1:3306/chatroom?character=utf-8&useSSL=true";
private static final String USERNAME = "用户";
private static final String PASSWORD = "数据库密码";
private static volatile DataSource dataSource = null;
public static DataSource getDataSource() {
// 获取到这个单例的 DataSource 实例.
if (dataSource == null) {
synchronized (DBUtil.class) {
if (dataSource == null) {
dataSource = new MysqlDataSource();
// 必须要告诉代码, 数据库是谁, 以啥样的方式登陆上去.
((MysqlDataSource)dataSource).setURL(URL);
((MysqlDataSource)dataSource).setUser(USERNAME);
((MysqlDataSource)dataSource).setPassword(PASSWORD);
}
}
}
return dataSource;
}
//获取连接
public static Connection getConnection () {
try {
return getDataSource().getConnection();
} catch (SQLException e) {
e.printStackTrace();
}
return null;
}
//释放连接
public static void close(Connection connection, PreparedStatement statement, ResultSet resultSet) {
try {
if (resultSet != null) {
resultSet.close();
}
if (statement != null) {
statement.close();
}
if (connection != null) {
connection.close();
}
} catch (SQLException e) {
e.printStackTrace();
}
}
}
(2)自定义异常类。(抛出一些自定义异常)
package util;
public class ChatroomException extends RuntimeException{
public ChatroomException(String message) {
super(message);
}
}
(3)JSONUtil类。(读取body中JSON格式数据,并转为字符串)
package util;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
public class JSONUtil {
public static String readBody(HttpServletRequest req) throws UnsupportedEncodingException {
//ContentLength()是请求body的长度。单位是字节。
int contentLength = req.getContentLength();
//buffer来保存读到的body内容.
byte[] buffer = new byte[contentLength];
try (InputStream inputStream = req.getInputStream()) {
inputStream.read(buffer, 0, contentLength);
} catch (IOException e) {
e.printStackTrace();
}
return new String(buffer,0,contentLength,"UTF-8");
}
}
3.设计dao层
dao层是数据访问层,这里设计几个dao类,来封装对数据库表进行增删查改等基础操作的功能。
(1)UserDao。(封装针对user表的基本操作)
package dao;
import model.User;
import util.ChatroomException;
import util.DBUtil;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
// 通过UserDao这个类来完成针对User表的基本操作。
public class UserDao {
// 方法一:新增一个用户(具体就是用户注册功能)
public void add(User user) {
// 1. 获取数据库连接.
Connection connection = DBUtil.getConnection();
// 2. 拼装 SQL 语句
String sql = "insert into user values(null, ?, ?, ?, now())";
PreparedStatement statement = null;
try {
statement = connection.prepareStatement(sql);
statement.setString(1, user.getName());
statement.setString(2, user.getPassword());
statement.setString(3, user.getNickName());
// 3. 执行 SQL 语句
int ret = statement.executeUpdate();
if (ret != 1) {
//抛出一个自定义的异常.
throw new ChatroomException("插入用户失败");
}
System.out.println("插入用户成功");
} catch (SQLException e) {
e.printStackTrace();
throw new ChatroomException("插入用户失败");
} finally {
// 4. 释放连接.
DBUtil.close(connection, statement, null);
}
}
//方法二:按用户名查找用户信息(具体就是登陆功能)
public User selectByName(String name) {
// 1. 获取到连接
Connection connection = DBUtil.getConnection();
// 2. 拼装 SQL
String sql = "select * from user where name = ?";
PreparedStatement statement = null;
ResultSet resultSet = null;
try {
statement = connection.prepareStatement(sql);
statement.setString(1, name);
// 3. 执行 SQL
resultSet = statement.executeQuery();
// 4. 遍历结果集合。
// 由于查找结果预期最多只有一条记录, 所以这里直接使用 if 判定
if (resultSet.next()) {
User user = new User();
user.setUserId(resultSet.getInt("userId"));
user.setName(resultSet.getString("name"));
user.setPassword(resultSet.getString("password"));
user.setNickName(resultSet.getString("nickName"));
user.setLastLogout(resultSet.getTimestamp("lastLogout"));
return user;
}
} catch (SQLException e) {
e.printStackTrace();
throw new ChatroomException("按用户名查找用户失败");
} finally {
// 5. 释放连接.
DBUtil.close(connection, statement, resultSet);
}
return null;
}
//方法三:按用户id查找用户信息(具体就是把userId转换成昵称的时候)
public User selectById(int userId) {
// 1. 获取连接
Connection connection = DBUtil.getConnection();
// 2. 拼装 SQL
String sql = "select * from user where userId = ?";
PreparedStatement statement = null;
ResultSet resultSet = null;
try {
statement = connection.prepareStatement(sql);
statement.setInt(1, userId);
// 3. 执行 SQL
resultSet = statement.executeQuery();
// 4. 遍历结果集
if (resultSet.next()) {
User user = new User();
user.setUserId(resultSet.getInt("userId"));
user.setName(resultSet.getString("name"));
user.setPassword(resultSet.getString("password"));
user.setNickName(resultSet.getString("nickName"));
user.setLastLogout(resultSet.getTimestamp("lastLogout"));
return user;
}
} catch (SQLException e) {
e.printStackTrace();
throw new ChatroomException("按 id 查找用户失败");
} finally {
// 5. 释放连接.
DBUtil.close(connection, statement, resultSet);
}
return null;
}
//方法四:更新用户的 lastLogout 时间. (用户下线时更新,具体就是 为了实现历史记录功能)
public void updateLogout(int userId) {
// 哪个用户下线了, 就更新哪个.
// 1. 获取连接.
Connection connection = DBUtil.getConnection();
// 2. 拼装 SQL
String sql = "update user set lastLogout = now() where userId = ?";
PreparedStatement statement = null;
try {
statement = connection.prepareStatement(sql);
statement.setInt(1, userId);
// 3. 执行 SQL
int ret = statement.executeUpdate();
if (ret != 1) {
throw new ChatroomException("更新退出时间失败");
}
System.out.println("更新退出时间成功");
} catch (SQLException e) {
e.printStackTrace();
throw new ChatroomException("更新退出时间失败");
} finally {
// 4. 释放连接.
DBUtil.close(connection, statement, null);
}
}
//写完后,可以用这里的代码对上面的功能简单的测试一下
public static void main(String[] args) {
UserDao userDao = new UserDao();
// 针对上面的代码进行简单验证.
// 1.验证add方法
/*User user = new User();
user.setName("akl");
user.setPassword("123");
user.setNickName("阿卡丽");
userDao.add(user);*/
// 2. 按名字查找用户信息
/*User user = userDao.selectByName("akl");
System.out.println(user);*/
//3.按用户id查找用户信息
/*User user = userDao.selectById(1);
System.out.println(user);*/
// 4. 更新用户的退出时间。
//userDao.updateLogout(1);
}
}
(2)ChannelDao。(封装针对channel表的基本操作)
package dao;
import model.Channel;
import util.ChatroomException;
import util.DBUtil;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
//对频道表进行基本的操作
public class ChannelDao {
//方法一:新增频道
public void add(Channel channel) {
// 1. 获取数据库连接
Connection connection = DBUtil.getConnection();
// 2. 拼装 SQL
String sql = "insert into channel values(null, ?)";
PreparedStatement statement = null;
try {
statement = connection.prepareStatement(sql);
statement.setString(1, channel.getChannelName());
// 3. 执行 SQL
int ret = statement.executeUpdate();
if (ret != 1) {
throw new ChatroomException("插入频道失败");
}
System.out.println("插入频道成功");
} catch (SQLException e) {
e.printStackTrace();
throw new ChatroomException("插入频道失败");
} finally {
// 4. 断开连接
DBUtil.close(connection, statement, null);
}
}
//方法二:删除频道
public void delete(int channelId) {
// 1. 获取数据库连接
Connection connection = DBUtil.getConnection();
// 2. 拼装 SQL
String sql = "delete from channel where channelId = ?";
PreparedStatement statement = null;
try {
statement = connection.prepareStatement(sql);
statement.setInt(1, channelId);
// 3. 执行 SQL
int ret = statement.executeUpdate();
if (ret != 1) {
throw new ChatroomException("删除频道失败");
}
System.out.println("删除频道成功");
} catch (SQLException e) {
e.printStackTrace();
throw new ChatroomException("删除频道失败");
} finally {
// 4. 断开连接
DBUtil.close(connection, statement, null);
}
}
//方法三:查看频道列表
public List<Channel> selectAll() {
List<Channel> channels = new ArrayList<>();
// 1. 建立连接
Connection connection = DBUtil.getConnection();
// 2. 拼装 SQL
String sql = "select * from channel";
PreparedStatement statement = null;
ResultSet resultSet = null;
try {
statement = connection.prepareStatement(sql);
// 3. 执行 SQL
resultSet = statement.executeQuery();
// 4. 遍历结果集合
while (resultSet.next()) {
Channel channel = new Channel();
channel.setChannelId(resultSet.getInt("channelId"));
channel.setChannelName(resultSet.getString("channelName"));
channels.add(channel);
}
return channels;
} catch (SQLException e) {
e.printStackTrace();
} finally {
DBUtil.close(connection, statement, resultSet);
}
return null;
}
// 针对这个类也可以进行简单的单元测试.
public static void main(String[] args) {
// 创建一个 ChannelDao 实例
ChannelDao channelDao = new ChannelDao();
// 1. 验证 add 操作
/*Channel channel = new Channel();
channel.setChannelName("午夜情感");
channelDao.add(channel);*/
// 2. 验证 selectAll 操作
/*List<Channel> channels = channelDao.selectAll();
System.out.println(channels);*/
// 3. 验证 delete 操作
channelDao.delete(4);
}
}
(3)MessageDao。(封装针对message表的基本操作)
package dao;
import model.Message;
import util.ChatroomException;
import util.DBUtil;
import java.sql.*;
import java.util.ArrayList;
import java.util.List;
public class MessageDao {
// 方法一:新增消息
public void add(Message message) {
// 1. 获取数据库连接
Connection connection = DBUtil.getConnection();
// 2. 拼装 SQL
String sql = "insert into message values (null, ?, ?, ?, ?)";
PreparedStatement statement = null;
try {
statement = connection.prepareStatement(sql);
statement.setInt(1, message.getUserId());
statement.setInt(2, message.getChannelId());
statement.setString(3, message.getContent());
statement.setTimestamp(4, message.getSendTime());
// 3. 开始执行 SQL
int ret = statement.executeUpdate();
if (ret != 1) {
throw new ChatroomException("插入消息失败");
}
System.out.println("插入消息成功");
} catch (SQLException e) {
e.printStackTrace();
throw new ChatroomException("插入消息失败");
} finally {
// 4. 释放连接
DBUtil.close(connection, statement, null);
}
}
// 方法二:按时间段查询消息
public List<Message> selectByTimeStamp(Timestamp from, Timestamp to) {
List<Message> messages = new ArrayList<>();
// 1. 获取到连接
Connection connection = DBUtil.getConnection();
// 2. 拼装 SQL
// MySQL 中的 datetime 类型是可以直接比较大小的.
String sql = "select * from message where sendTime >= ? and sendTime <= ?";
PreparedStatement statement = null;
ResultSet resultSet = null;
try {
statement = connection.prepareStatement(sql);
statement.setTimestamp(1, from);
statement.setTimestamp(2, to);
System.out.println("selectByTimeStamp: " + statement);
// 3. 执行 SQL
resultSet = statement.executeQuery();
// 4. 遍历结果集合
while (resultSet.next()) {
Message message = new Message();
message.setMessageId(resultSet.getInt("messageId"));
message.setUserId(resultSet.getInt("userId"));
message.setChannelId(resultSet.getInt("channelId"));
message.setContent(resultSet.getString("content"));
message.setSendTime(resultSet.getTimestamp("sendTime"));
messages.add(message);
}
return messages;
} catch (SQLException e) {
e.printStackTrace();
} finally {
DBUtil.close(connection, statement, resultSet);
}
return null;
}
//简单的测试一下代码
public static void main(String[] args) {
MessageDao messageDao = new MessageDao();
// 1. 测试新增消息
// Message message = new Message();
// message.setUserId(1);
// message.setChannelId(1);
// message.setContext("你好哇");
// message.setSendTime(new Timestamp(System.currentTimeMillis()));
// messageDao.add(message);
// 2. 获取指定时间段的消息
List<Message> messages = messageDao.selectByTimeStamp(
new Timestamp(1595503500000L), new Timestamp(1595503800000L)
);
System.out.println(messages);
}
}
4.设计API接口
(1)创建一个RegisterServlet类来实现注册功能。
实现思路大致可以分为以下几步:
- a).读取 body 中的信息(一个JSON格式的字符串);
- b).把 json 数据转成 Java 中的对象;
- c).在数据库中查一下, 看看用户名是否已经存在了,如果存在了就认为注册失败;
- d).把新的用户名和密码构造User对象并插入数据库;
- e).返回一个注册成功的响应结果。
package api;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonSyntaxException;
import dao.UserDao;
import model.User;
import util.ChatroomException;
import util.JSONUtil;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
public class RegisterServlet extends HttpServlet {
private Gson gson = new GsonBuilder().create();
//这个类以内部类的方式来组织。这个 Request 类只是针对 RegisterServlet来使用的.
//其他的Servlet对应的Request类可能结构不同.
//从body的JSON中转换过来的。
static class Request {
public String name;
public String password;
public String nickName;
@Override
public String toString() {
return "Request{" +
"name='" + name + '\'' +
", password='" + password + '\'' +
", nickName='" + nickName + '\'' +
'}';
}
}
// 响应的数据内容
// 要把这个对象再转回JSON字符串, 并写回给客户端.
static class Response {
public int ok;
public String reason;
}
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
req.setCharacterEncoding("utf-8");
//响应对象,存放响应结果
Response response = new Response();
try {
// 1. 读取 body 中的信息(一个JSON格式的字符串)
String body = JSONUtil.readBody(req);
System.out.println("body: " + body);
//2.把json数据转成一个Java中的对象。
// 创建一个 Request 类来表示这次请求的结构
// 需要把 body 转成 Request 对象
// 此处使用 GSON库来实现。 下面参数中body是json格式的字符串 第二个参数是类对象
Request request = gson.fromJson(body, Request.class); //把json字符串转成Java对象。
// 3. 在数据库中查一下, 看看用户名是否已经存在了. 如果存在了就认为注册失败.
UserDao userDao = new UserDao();
User existsUser = userDao.selectByName(request.name);
if (existsUser != null) {
throw new ChatroomException("用户名已经存在");
}
// 4. 把新的用户名和密码构造 User 对象并插入数据库
User user = new User();
user.setName(request.name);
user.setPassword(request.password);
user.setNickName(request.nickName);
userDao.add(user);
// 5. 返回一个注册成功的响应结果.
response.ok = 1;
response.reason = "";
} catch (ChatroomException | JsonSyntaxException e) {
e.printStackTrace();
response.ok = 0;
response.reason = e.getMessage();
} finally {
resp.setContentType("application/json; charset=utf-8");
String jsonString = gson.toJson(response); //将响应对象转换为JSON字符串
resp.getWriter().write(jsonString);
}
}
}
(2)创建一个LoginServlet类来实现登陆和检测登陆状态的。
登录功能实现思路大致可以分为以下几步:
- a).读取 body 中的数据;
- b).把读到的数据转成 Request 对象;
- c).按用户名在数据库中查找, 匹配密码是否正确;
- d). 如果登陆失败, 则给出提示;
- e).如果登陆成功, 创建一个 session 对象;
- f).把结果写回到浏览器。
而检测登录状态的逻辑就会简单很多,就两步:
- a).根据请求, 查看该 sessionId 对应的 session 是否存在。如果 session 不存在, 就是登陆是失败的状态;
- b).如果 session 存在, 直接返回一个登陆成功即可。
package api;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonSyntaxException;
import dao.UserDao;
import model.User;
import util.ChatroomException;
import util.JSONUtil;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
public class LoginServlet extends HttpServlet {
private Gson gson = new GsonBuilder().create();
static class Request {
public String name;
public String password;
}
static class Response {
public int ok;
public String reason;
public int userId;
public String name;
public String nickName;
}
//登陆功能
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
req.setCharacterEncoding("utf-8");
Response response = new Response();
try {
// 1. 读取 body 中的数据
String body = JSONUtil.readBody(req);
// 2. 把读到的数据转成 Request 对象.
Request request = gson.fromJson(body, Request.class);
// 3. 按用户名在数据库中查找, 匹配密码是否正确.
UserDao userDao = new UserDao();
User user = userDao.selectByName(request.name);
// 4. 如果登陆失败, 则给出提示。失败分下面两种情况:
// a) 用户名没查到
// b) 密码不匹配
if (user == null || !request.password.equals(user.getPassword())) {
throw new ChatroomException("用户名或密码错误");
}
// 5. 如果登陆成功, 创建一个 session 对象.
HttpSession httpSession = req.getSession(true);
httpSession.setAttribute("user", user);
// 6. 把结果写回到浏览器
response.ok = 1;
response.name = user.getName();
response.userId = user.getUserId();
response.nickName = user.getNickName();
response.reason = "";
} catch (JsonSyntaxException | ChatroomException e) {
e.printStackTrace();
response.ok = 0;
response.reason = e.getMessage();
} finally {
resp.setContentType("application/json; charset=utf-8");
String jsonString = gson.toJson(response);
resp.getWriter().write(jsonString);
}
}
// 检测登陆状态
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
Response response = new Response();
try {
// 1. 根据请求, 查看该 sessionId 对应的 session 是否存在。
//如果 session 不存在, 就是登陆是失败的状态.
HttpSession httpSession = req.getSession(false);
if (httpSession == null) {
// 不存在的情况
throw new ChatroomException("当前未登陆");
}
User user = (User) httpSession.getAttribute("user");
// 2. 如果 session 存在, 直接返回一个登陆成功即可.
response.ok = 1;
response.userId = user.getUserId();
response.name = user.getName();
response.nickName = user.getNickName();
response.reason = "";
} catch (ChatroomException e) {
e.printStackTrace();
response.ok = 0;
response.reason = e.getMessage();
} finally {
resp.setContentType("application/json; charset=utf-8");
String jsonString = gson.toJson(response);
resp.getWriter().write(jsonString);
}
}
}
(3)小测试
写完代码不测等于赌博,上面三个功能写完之后,就可以拿Postman先测一下了。先在IDEA中运行tomcat,然后再去Postman中发送http请求:
a)注册功能
可以看到在Postman中发送http请求成功之后,user表中也出现了注册用户。所以,注册功能是没问题的。
b)登陆功能
可以看到在发送http请求成功后,在响应中成功返回了用户的昵称,ID等相关信息,所以登陆功能也是没问题的。
c)登陆状态检测功能
可以看到,在给服务器发送一个GET请求之后,成功检测出登陆状态,而且cookie中的sessionId,和上面登陆成功后分配的sessionId是一样的。
(4)创建一个ChannelServlet类来实现新增,删除和获取频道列表的功能
新增频道的操作很简单,实现时可以分为以下几步:
- a)验证登录状态;
- b)增加频道;
- c)返回一个注册成功的响应结果。
获取频道列表,也分为三步:
a)验证登陆状态. 如果未登陆则不能查看;
b)查数据库;
c)把查询结果包装成响应内容。
package api;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import dao.ChannelDao;
import model.Channel;
import model.User;
import util.ChatroomException;
import util.JSONUtil;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
// 这个代码是处理频道操作的 API
public class ChannelServlet extends HttpServlet {
private Gson gson = new GsonBuilder().create();
static class Response {
public int ok;
public String reason;
}
// 新增频道
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
Response response = new Response();
try {
//1.验证登录状态
HttpSession httpSession = req.getSession(false);
if(httpSession == null) {
throw new ChatroomException("您尚未登录");
}
//2.增加频道
ChannelDao channelDao = new ChannelDao();
String body = JSONUtil.readBody(req);
Channel channel = gson.fromJson(body,Channel.class);
if("".equals(channel.getChannelName())) {
throw new ChatroomException("频道名不能为空");
}
channelDao.add(channel);
//3.返回一个注册成功的响应结果
response.ok = 1;
response.reason = "";
} catch (ChatroomException e) {
e.printStackTrace();
}finally {
resp.setContentType("application/json; charset=utf-8");
String jsonString = gson.toJson(response);
resp.getWriter().write(jsonString);
}
}
// 获取频道列表
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
List<Channel> channels = new ArrayList<>();
try {
// 1. 验证登陆状态. 如果未登陆则不能查看.
HttpSession httpSession = req.getSession(false);
if (httpSession == null) {
throw new ChatroomException("您尚未登陆");
}
User user = (User) httpSession.getAttribute("user");
// 2. 查数据库
ChannelDao channelDao = new ChannelDao();
channels = channelDao.selectAll();
} catch (ChatroomException e) {
e.printStackTrace();
// 如果前面触发了异常, 此时 channels 将是一个空的 List
// 下面的 finally 中将会构造出一个空数组的 json
} finally {
// 3. 把查询结果包装成响应内容
// 如果参数是个 List, 转出的 json 字符串就是一个 数组
resp.setContentType("application/json; charset=utf-8");
String jsonString = gson.toJson(channels);
resp.getWriter().write(jsonString);
}
}
}
(5)创建一个MessageCenter类来实现管理所有用户消息和在线用户列表的功能
这个类是实现这个聊天室项目的关键代码,它主要实现了两个功能。一个是管理在线用户,包括用户上线,用户下线;另一个功能就是管理所有消息并实现消息转发。
这个类中包含了两个重要的数据结构:
a)保存消息的阻塞式队列。阻塞式队列,用来保存服务器收到的消息,它是后续转发的基础。
只要服务器接收到的消息就会放到这个消息队列中(生产者),另外启动一个线程,让这个线程扫描队列,并把消息转发给其他客户端(消费者)
b)保存在线用户列表。因为服务器转发消息的时候要转发给所有在线的用户。
package api;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import model.Message;
import javax.websocket.Session;
import java.io.IOException;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.LinkedBlockingQueue;
//这个类主要用来管理消息和用户列表. 实现消息转发.
//其次这个类作为一个单例即可.
public class MessageCenter {
private volatile static MessageCenter instance = null;
//双重校验锁的方式来写一个单例模式
public static MessageCenter getInstance() {
if (instance == null) {
synchronized (MessageCenter.class) {
if (instance == null) {
instance = new MessageCenter();
}
}
}
return instance;
}
// 1. 保存消息的队列.
private BlockingQueue<Message> messages = new LinkedBlockingQueue<>();
// 2. 保存在线用户列表
private ConcurrentHashMap<Integer, Session> onlineUsers = new ConcurrentHashMap<>();
// 1. 用户上线功能
public void addOnlineUser(int userId, Session session) {
onlineUsers.put(userId, session);
}
// 2. 用户下线
public void delOnlineUser(int userId) {
onlineUsers.remove(userId);
}
// 3. 新增消息
public void addMessage(Message message) throws InterruptedException {
messages.put(message);
}
// 接下来这个代码非常重要!!!!
// 创建一个线程, 来一直扫描消息的队列, 把里面存的消息转发给所有的在线用户.
//在构造MessageCenter 实例的时候, 就启动这个线程.
private MessageCenter() {
Thread t = new Thread() {
@Override
public void run() {
Gson gson = new GsonBuilder().create();
while (true) {
try {
// 1. 从队列中尝试取消息
// 如果队列为空, 此时 take 就会阻塞
Message message = messages.take();
// 2. 把消息转成 json 字符串
String jsonString = gson.toJson(message);
// 3. 遍历在线用户列表, 把消息转发给每个用户
for (ConcurrentHashMap.Entry<Integer, Session> entry : onlineUsers.entrySet()) {
Session session = entry.getValue();
session.getBasicRemote().sendText(jsonString);
}
} catch (InterruptedException | IOException e) {
e.printStackTrace();
}
}
}
};
t.start();
}
}
6)建立WebSocket连接
package api;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import dao.MessageDao;
import dao.UserDao;
import model.Message;
import model.User;
import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.sql.Timestamp;
import java.util.List;
// 这个类用来实现 websocket 相关接口
// 此处 userId 是一个变量. 是客户端连接的时候, 具体提交的 userId 。
@ServerEndpoint(value="/message/{userId}")
public class MessageAPI {
private Gson gson = new GsonBuilder().create();
// 通过用户连接的 url 中获取到的.
private int userId;
@OnOpen
public void onOpen(@PathParam("userId") String userIdStr, Session session) throws IOException {
// 1. 获取 userId
this.userId = Integer.parseInt(userIdStr);
System.out.println("连接建立: " + userId);
// 2. 把该用户加入到在线用户列表中.
MessageCenter.getInstance().addOnlineUser(userId, session);
// 3. 拉取历史消息, 并直接转发给该用户.
UserDao userDao = new UserDao();
User user = userDao.selectById(userId);
Timestamp lastLogout = user.getLastLogout();
MessageDao messageDao = new MessageDao();
List<Message> messages = messageDao.selectByTimeStamp(lastLogout, new Timestamp(System.currentTimeMillis()));
for (Message message : messages) {
String jsonString = gson.toJson(message);
session.getBasicRemote().sendText(jsonString);
}
}
@OnClose
public void onClose() {
System.out.println("连接断开! " + userId);
// 把用户从在线列表中删掉
MessageCenter.getInstance().delOnlineUser(userId);
// 更新下线时间
UserDao userDao = new UserDao();
userDao.updateLogout(userId);
}
@OnError
public void onError(Session session, Throwable error) {
System.out.println("连接出现错误! " + userId);
error.printStackTrace();
// 把用户从在线列表中删掉
MessageCenter.getInstance().delOnlineUser(userId);
// 更新下线时间
UserDao userDao = new UserDao();
userDao.updateLogout(userId);
}
@OnMessage
public void onMessage(String request, Session session) throws InterruptedException {
System.out.println("收到消息! " + userId + ": " + request);
// 1. 解析 message 格式. 收到的 request 对象应该是一个 json 格式的字符串
Message message = gson.fromJson(request, Message.class);
// 2. 把消息的发送时间填写一下
message.setSendTime(new Timestamp(System.currentTimeMillis()));
// 3. 把消息写入消息中心.
MessageCenter.getInstance().addMessage(message);
// 4. 把这个消息写入数据库
MessageDao messageDao = new MessageDao();
messageDao.add(message);
}
}