需求
- 实现用户登录功能
- 展示用户好友列表功能
- 实现用户历史消息展示
- 实现单聊信息和群聊信息
效果展示
- 用户登录
- 好友列表展示
- 历史消息展示
- 聊天
代码实现
说明:Springboot项目,页面是用 thymeleaf 整合的。
- maven依赖
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-freemarker</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
</dependencies>
- application.yml
spring:
thymeleaf:
cache: false
suffix: .html
- resource/templates目录下,创建页面
1) login.html,点击登录调用了/user/login接口
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
</head>
<body>
<div id="login" class="form-wrapper">
<div class="header">
登录
</div>
</div>
<div>
<span style="color: red" th:text="${msg}" th:if="${not #strings.isEmpty(msg)}"></span>
</div>
<form action="/user/login" method="post">
<div >
<div >
<input th:type="text" th:name="username" placeholder="username">
</div>
<div >
<input th:type="password" th:name="password" placeholder="password" >
</div>
</div>
<div class="action" onclick="document.getElementById('lick1').click()">
<div class="btn">
确认
</div>
</div>
<input th:type="submit" id="lick1">
</form>
</div>
</body>
</html>
2)chat.html,聊天主页面
[1]、退出登录按钮,调用了logout接口,把session中的token值清除了
[2]、好友列表,在跳转到chat页面的时候,调用了getUserList并且把用户列表数据注入到模型中,界面展示出来
[3]、连接websocket,调用了connectWebSocket()函数,调用了后端 websocket端点的onOpen方法
[4]、断开连接,调用了后台的onClose方法
[5]、发送消息,调用了后台的onMessage方法
[6]、查看历史消息,调用了后台的/history方法
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<html>
<head>
<meta charset="UTF-8">
<title>My WebSocket</title>
<style>
#message {
margin-top: 40px;
border: 1px solid gray;
padding: 20px;
}
</style>
</head>
<body>
<div>
用户token值:<span id="token" style="color: #ff0000" th:text="${session.token}"></span> <div> <a href="/logout">退出登录</a></div>
</div>
<div>
我的好友列表:<br/>
<table border="1">
<thead>
</thead>
<tbody>
<tr th:each="user:${users}" style="color: blue">
<a th:οnclick="aClick([[${user}]]);" th:text="${user}" style="color: blue"> </a><br/>
</tr>
</tbody>
</table>
</div>
昵称:<input type="text" id="nickname" th:value="${session.token}"/>
<button οnclick="conectWebSocket()">连接WebSocket</button>
<button οnclick="closeWebSocket()">断开连接</button>
<hr/>
<br/>
消息:<input id="text" type="text"/>
发送给谁: <input id="toUser" type="text">
<button οnclick="send()">发送消息</button>
<div id="message"></div>
历史消息:
<button οnclick="viewHistory()">查看历史消息</button>
<div id="history"></div>
</body>
<script type="text/javascript">
var websocket = null;
function conectWebSocket() {
//判断当前浏览器是否支持WebSocket
if ('WebSocket' in window) {
let nickname = document.getElementById("nickname").value;
if(nickname === ""){
alert("请输入昵称");
return;
}
websocket = new WebSocket("ws://localhost:8080/websocket/"+nickname);
} else {
alert('Not support websocket')
}
//连接发生错误的回调方法
websocket.onerror = function () {
setMessageInnerHTML("error");
};
//连接成功建立的回调方法
websocket.onopen = function (event) {
setMessageInnerHTML("Loc MSG: 成功建立连接");
}
//接收到消息的回调方法
websocket.onmessage = function (event) {
setMessageInnerHTML(event.data);
}
//连接关闭的回调方法
websocket.onclose = function () {
setMessageInnerHTML("Loc MSG:关闭连接");
}
//监听窗口关闭事件,当窗口关闭时,主动去关闭websocket连接,防止连接还没断开就关闭窗口,server端会抛异常。
window.onbeforeunload = function () {
websocket.close();
}
}
//将消息显示在网页上
function setMessageInnerHTML(innerHTML) {
document.getElementById('message').innerHTML += innerHTML + '<br/>';
}
//关闭连接
function closeWebSocket() {
websocket.close();
}
//发送消息
function send() {
var message = document.getElementById('text').value;
var toUser = document.getElementById("toUser").value;
var socketMsg = {msg:message,toUser:toUser};
if(toUser == ''){
//群聊
socketMsg.type = 0;
}else {
//单聊
socketMsg.type = 1;
}
websocket.send(JSON.stringify(socketMsg));
}
function aClick(e){
console.log("aaa")
console.log(e)
document.getElementById("toUser").value = e;
}
function viewHistory(){
var toUser = document.getElementById("toUser").value;
var httpRequest = new XMLHttpRequest(); //第一步:建立所需的对象
httpRequest.open('GET', '/history?toUser='+toUser, true); //第二步:打开连接 将请求参数写在url中 ps:"./Ptest.php?name=test&nameone=testone"
httpRequest.send(); //第三步:发送请求 将请求参数写在URL中
httpRequest.onreadystatechange = function() {
if (httpRequest.readyState == 4 && httpRequest.status == 200) {
console.log(httpRequest.responseText);
document.getElementById('history').innerHTML += httpRequest.responseText + '<br/>';
}
};
}
</script>
</html>
- 配置类,GetHttpConfiguration.java
作用:把Http请求中的参数,传递到WebSocket中
public class GetHttpConfiguration extends ServerEndpointConfig.Configurator {
@Override
public void modifyHandshake(ServerEndpointConfig sec, HandshakeRequest request, HandshakeResponse response) {
// 通过getUserProperties()使得websocket连接类中可获取到配置类中得到的数据
Map<String, Object> userProperties = sec.getUserProperties();
HttpSession httpSession = (HttpSession) request.getHttpSession();
userProperties.put("token",httpSession.getAttribute("token").toString());
super.modifyHandshake(sec, request, response);
}
}
- 拦截器,LoginInterceptor.java
作用:登录拦截器,必须要在session中有token的值,否则跳转去登录。
public class LoginInterceptor implements HandlerInterceptor {
static final Logger logger = LoggerFactory.getLogger(LoginInterceptor.class);
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object o) throws Exception {
HttpSession session = request.getSession();
Object token = session.getAttribute("token");
if (Objects.isNull(token)) {
response.sendRedirect("/login");
return false;
}
return true;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object o, ModelAndView modelAndView) throws Exception {
logger.info("postHandle...");
}
@Override
public void afterCompletion(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, Exception e) throws Exception {
logger.info("afterCompletion...");
}
}
- 配置类,WebConfig.java
作用:配置拦截器,配置不登录的路径
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor()) //可以把配置类加入bean 然后autowier得到 或者@Bean 返回值得到 JavaConfig 三种方法都可以
.addPathPatterns("/**") //拦截的路径 **代表所有
.excludePathPatterns("/login","/user/login"); //不拦截的路径
}
}
- 配置类,WebSocketConfig.java
作用:开启websocket支持
@Configuration
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
/**
* 扫描@ServerEndpoint,将@ServerEndpoint修饰的类注册为websocket
* 如果使用外置tomcat,则不需要此配置
*/
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}
- 模型类,History.java
作用:封装聊天消息历史类
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class History {
private String from;
private String to;
private String time;
private String content;
}
- 模型类,User.java
作用:封装用户登录
@Data
public class User {
private String username;
private String password;
}
- 模型类,SocketMsg.java
作用:封装消息发送类
public class SocketMsg {
private int type; //聊天类型0:群聊,1:单聊.
private String fromUser;//发送者.
private String toUser;//接受者.
private String msg;//消息
public int getType() {
return type;
}
public void setType(int type) {
this.type = type;
}
public String getFromUser() {
return fromUser;
}
public void setFromUser(String fromUser) {
this.fromUser = fromUser;
}
public String getToUser() {
return toUser;
}
public void setToUser(String toUser) {
this.toUser = toUser;
}
public String getMsg() {
return msg;
}
public void setMsg(String msg) {
this.msg = msg;
}
}
- html路由类,SsoController.java
作用:配置login登录页面视图,配置chat页面视图,配置logout页面视图,配置登录逻辑
@Controller
@CrossOrigin
public class SsoController {
@Autowired
private UserController userController;
@RequestMapping(value = "login", method = {RequestMethod.POST, RequestMethod.GET})
public String login() {
return "login";
}
@RequestMapping(value = "chat", method = {RequestMethod.POST, RequestMethod.GET})
public String chat(HttpSession session,Model model) {
List<String> users = userController.getUserList(session);
model.addAttribute("a","123");
model.addAttribute("users",users);
return "chat";
}
@RequestMapping("logout")
public String logOut(HttpSession session){
session.removeAttribute("token");
return "login";
}
@PostMapping("/user/login")
public String login(HttpServletRequest request, HttpSession session, Map<String, Object> map, Model model) {
String name = request.getParameter("username");
String password = request.getParameter("password");
if (UserController.userMap.containsKey(name)) {
if (UserController.userMap.get(name).equals(password)) {
session.setAttribute("token", name);
return "redirect:/chat";
}
}
model.addAttribute("msg", "请输入正确的账号和密码");
return "login";
}
}
- 我的好友类,UserController.java
作用:模拟数据库用户,模拟用户的好友数据
@CrossOrigin
@RestController
public class UserController {
//所有的用户
public static Map<String, String> userMap = new HashMap<>();
//每个用户对应的好友
public static Map<String, List<String>> friendsMap = new HashMap<>();
static {
userMap.put("zhangsan", "123456");
userMap.put("lisi", "123456");
userMap.put("wangwu", "123456");
userMap.put("zhaoliu", "123456");
userMap.put("yangsilu", "123456");
userMap.put("ranqilin", "123456");
userMap.put("xuqiaodi", "123456");
userMap.put("luowengang", "123456");
friendsMap.put("zhangsan", List.of("lisi", "wangwu", "luowengang", "zhaoliu"));
friendsMap.put("lisi", List.of("zhangsan", "wangwu", "ranqilin"));
friendsMap.put("wangwu", List.of("zhangsan", "lisi", "xuqiaodi", "yangsilu"));
}
//获取我的好友
@GetMapping("user/list")
public List<String> getUserList(HttpSession session) {
Object token = session.getAttribute("token");
List<String> users = friendsMap.get(token.toString());
return users;
}
}
- 聊天历史控制器,HistoryController.java
作用:获取用户聊天历史数据
@CrossOrigin
@RestController
public class HistoryController {
@GetMapping("/history")
public List<History> getHistory(@RequestParam("toUser")String toUser, HttpSession session){
Object token = session.getAttribute("token");
String fromUser = token.toString();
List<String> datas = FileUtil.readFileLine();
List<History> historyList = new ArrayList<>();
for (String data : datas) {
historyList.add(JSON.parseObject(data,History.class));
}
//排序
List<History> collect = historyList.stream().filter(item -> {
String from = item.getFrom();
String to = item.getTo();
boolean flag = checkIn(from, to, fromUser, toUser);
return flag;
}).sorted((x1,x2)-> x2.getTime().compareTo(x1.getTime())).
collect(Collectors.toList());
return collect;
}
private boolean checkIn(String from, String to, String fromUser, String toUser) {
if(from.equals(fromUser) && to.equals(toUser)){
return true;
}
if(from.equals(toUser) && to.equals(fromUser)){
return true;
}
return false;
}
}
- websocket核心类,MyWebSocket.java
作用:1)onOpen方法,某个客户端的会话session存储到map中。每个客户端的socket对象存储在webSocketSet中。
2)onClose方法,连接关闭时候,把webSocket对象移除。
3)onError方法,如果连接发送异常会调用
4)OnMessage方法,客户端发送消息的方法。根据当前session的id,获取到发起方的Session对象,根据传递参数的用户名,获取到接收方的Session对象。判断接收方对象是否为空,不为空就可以发送消息。
@ServerEndpoint(value = "/websocket/{nickname}", configurator = GetHttpConfiguration.class)
@Component
public class MyWebSocket {
//用来记录sessionId和该session进行绑定
private static Map<String, Session> map = new ConcurrentHashMap<>();
// sessionId和username的映射
private static Map<String, String> sessionIdNameMap = new ConcurrentHashMap<>();
//用来存放每个客户端对应的MyWebSocket对象。
private static CopyOnWriteArraySet<MyWebSocket> webSocketSet = new CopyOnWriteArraySet<MyWebSocket>();
//与某个客户端的连接会话,需要通过它来给客户端发送数据
private Session session;
private String nickname;
/**
* 连接建立成功调用的方法
*/
@OnOpen
public void onOpen(Session session, @PathParam("nickname") String nickname, EndpointConfig config) {
this.session = session;
this.nickname = nickname;
//在建立连接的时候,就保存频道号(这里使用的是session.getId()作为频道号)和session之间的对应关系
map.put(session.getId(), session);
Object token = config.getUserProperties().get("token");
sessionIdNameMap.put(session.getId(), token.toString());
webSocketSet.add(this); //加入set中
System.out.println("有新连接加入!当前在线人数为" + webSocketSet.size());
this.session.getAsyncRemote().sendText("恭喜" + nickname + "成功连接上WebSocket-->频道号是:" + nickname + "当前在线人数为:" + webSocketSet.size());
}
/**
* 连接关闭调用的方法
*/
@OnClose
public void onClose() {
webSocketSet.remove(this); //从set中删除
System.out.println("有一连接关闭!当前在线人数为" + webSocketSet.size());
}
/**
* 收到客户端消息后调用的方法
*
* @param message 客户端发送过来的消息
*/
@OnMessage
public void onMessage(String message, Session session, @PathParam("nickname") String nickname) {
System.out.println("来自客户端的消息-->" + nickname + ":" + message);
//群发消息
// broadcast(nickname + ":" +message);
//从客户端穿过来是json数据,转成SocketMsg对象,根据type判断是单聊还是群聊
ObjectMapper objectMapper = new ObjectMapper();
SocketMsg socketMsg;
try {
socketMsg = objectMapper.readValue(message, SocketMsg.class);
if (socketMsg.getType() == 1) {
//单聊,需要找到发送者和接受者
socketMsg.setFromUser(session.getId()); //发送者
Session fromSession = map.get(socketMsg.getFromUser());
Session toSession = map.get(getSessionId(sessionIdNameMap, socketMsg.getToUser()));
//发送给接受者
if (toSession != null) {
String from = nickname + ":" + socketMsg.getMsg();
String to = nickname + ":" + socketMsg.getMsg();
fromSession.getAsyncRemote().sendText(from);
toSession.getAsyncRemote().sendText(to);
//保存消息
String time = getTime();
History history = History.builder()
.from(sessionIdNameMap.get(session.getId()))
.to(socketMsg.getToUser())
.time(time)
.content(nickname + ":" + socketMsg.getMsg())
.build();
FileUtil.toFile(JSON.toJSONString(history));
} else {
fromSession.getAsyncRemote().sendText("系统消息:对方不在线或者您输入的频道号不对");
}
} else {
//群发消息
broadcast(nickname + ": " + socketMsg.getMsg());
// 保存消息
}
} catch (JsonProcessingException e) {
e.printStackTrace();
}
}
/**
* 发生错误时调用
*/
@OnError
public void onError(Session session, Throwable error) {
System.out.println("发生错误");
error.printStackTrace();
}
/**
* 群发自定义消息
*/
public void broadcast(String message) {
for (MyWebSocket item : webSocketSet) {
//同步异步说明参考:http://blog.csdn.net/who_is_xiaoming/article/details/53287691
//this.session.getBasicRemote().sendText(message);
item.session.getAsyncRemote().sendText(message);//异步发送消息.
}
}
public String getTime(){
SimpleDateFormat format = new SimpleDateFormat("yyyy-dd-mm HH:mm:ss");
return format.format(new Date());
}
public String getSessionId(Map<String, String> map, String name) {
if (StringUtils.isBlank(name)) return null;
for (Map.Entry<String, String> entry : map.entrySet()) {
if (entry.getValue().equals(name)) {
return entry.getKey();
}
}
return null;
}
}
- 历史消息存储和读取工具类,FileUtil.java
作用:把历史消息存储到文件中。
public class FileUtil {
private static final String file = "D:\\websocket-sse\\wechat\\history.txt";
public static void main(String[] args) {
readFileLine();
}
public static void toFile(String content) {
BufferedWriter out = null;
try {
out = new BufferedWriter(new OutputStreamWriter(
new FileOutputStream(file, true)));
out.write(content + "\n");
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
out.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
public static List<String> readFileLine() {
List<String> list = new ArrayList<>();
try (FileInputStream in = new FileInputStream(file)) {
Scanner sc = new Scanner(in, "UTF-8");
while (sc.hasNext()) {
String content = sc.nextLine();
if (StringUtils.isNotBlank(content)) {
list.add(content);
}
}
} catch (IOException e) {
}
return list;
}
}
测试
- 启动项目
- 浏览器访问 localhost:8080/login,进入登录页面 ,分别用两个不同的浏览器,登录zhangsan/123456和lisi/123456
- 分别都点击 连接WebSocket
- 在lisi的登录界面,点击好友列表中的zhangsan,代表和zhangsan聊天,自动在发送给谁中展示了zhangsan;在zhangsan的登录界面中,点击好友列表中的lisi,代表和lisi聊天,自动在发送给谁中展示了lisi。
- 查看历史记录
- 发送消息,在消息文本框中输入要发送的消息,然后点击发送消息