需求
- 实现用户登录功能
- 展示用户好友列表功能
- 实现用户历史消息展示
- 实现单聊信息和群聊信息
效果展示
- 用户登录
- 好友列表展示
- 历史消息展示
- 聊天
代码实现
说明: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: falsesuffix: .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() {//判断当前浏览器是否支持WebSocketif ('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 {@Overridepublic 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);@Overridepublic 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;}@Overridepublic void postHandle(HttpServletRequest request, HttpServletResponse response, Object o, ModelAndView modelAndView) throws Exception {logger.info("postHandle...");}@Overridepublic void afterCompletion(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, Exception e) throws Exception {logger.info("afterCompletion...");}
}
- 配置类,WebConfig.java
作用:配置拦截器,配置不登录的路径
@Configuration
public class WebConfig implements WebMvcConfigurer {@Overridepublic 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,则不需要此配置*/@Beanpublic 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 {@Autowiredprivate 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
作用:1)获取用户聊天历史数据
2)提供了checkSocket接口,用于判断当前用户是否已经连接了webSocket。需要在前端连接websocket的时候做一个限制,目前没有实现。
@CrossOrigin
@RestController
public class HistoryController {// 判断当前用户是否连接了webSocket@GetMapping("/checkSocket")public boolean checkSocket(HttpSession session) {Object token = session.getAttribute("token");String fromUser = token.toString();return MyWebSocket.sessionIdNameMap.values().contains(fromUser);}@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;/*** 连接建立成功调用的方法*/@OnOpenpublic 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());}/*** 连接关闭调用的方法*/@OnClosepublic void onClose() {String id = session.getId();webSocketSet.remove(this); //从set中删除sessionIdNameMap.remove(id);map.remove(id);System.out.println("有一连接关闭!当前在线人数为" + webSocketSet.size());}/*** 收到客户端消息后调用的方法** @param message 客户端发送过来的消息*/@OnMessagepublic 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();}}/*** 发生错误时调用*/@OnErrorpublic 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。
- 查看历史记录
- 发送消息,在消息文本框中输入要发送的消息,然后点击发送消息