网络程序设计课程学习报告:WebSocket网络编程
一、工作原理
1.websocket
WebSocket是一种网络通信协议,它提供全双工(full-duplex)通信,允许服务器与客户端之间进行实时、双向的交互。
在引入WebSocket之前,由于HTTP协议的限制,服务器不能主动向客户端发送消息,只能在客户端发起请求后才能响应。这使得实时交互十分困难,例如聊天应用,股票价格更新等需要交互的情况,都无法做到实时。
WebSocket的出现,解决了这个问题。一旦WebSocket连接建立,服务器和客户端就可以在任何时候主动发送数据给对方。同时,由于WebSocket协议更轻量级,所以相比于HTTP,其让交互变得更加高效和实时。
例如,对于一个聊天应用,用户A发送一条信息后,服务器不需要等待用户B发起请求,就可以直接将用户A的消息推送到用户B的客户端,用户B这时就可以立即看到用户A的信息了。
2.session和cookie
Session和Cookie都是为了解决HTTP协议无状态的问题而设计的技术,它们用来在多个请求之间保存用户的状态信息。下面让我们详细了解一下它们。
Cookie: Cookie是一种在客户端保存状态信息的技术,它的信息被保存在浏览器中。当浏览器请求网站时,如果HTTP请求头中包含Cookie,那么Cookie的信息就会被发送到服务器。服务器可以使用这些信息来识别用户。
Session: Session是一个更加复杂的技术,它在服务器端储存用户状态信息。当一个用户首次访问网站时,服务器会为此用户建立一个Session,并生成一个唯一的Session ID。这个Session ID通常被储存在用户的Cookie中,然后被发送到客户端。当用户再次访问网站时,服务器就可以通过查找这个Session ID来获得对应的Session信息,进而识别用户。
3.如何实现自动登录(24小时内有效)?
在Web应用程序中,我们经常要跟踪用户身份。当一个用户登录成功后,如果他继续访问其他页面,Web程序如何才能识别出该用户身份?
因为HTTP协议是一个无状态协议,即Web应用程序无法区分收到的两个HTTP请求是否是同一个浏览器发出的。为了跟踪用户状态,服务器可以向浏览器分配一个唯一ID,并以Cookie的形式发送到浏览器,浏览器在后续访问时总是附带此Cookie,这样,服务器就可以识别用户身份。
我们把这种基于唯一ID识别用户身份的机制称为Session。每个用户第一次访问服务器后,会自动获得一个Session ID。如果用户在一段时间内没有访问服务器,那么Session会自动失效,下次即使带着上次分配的Session ID访问,服务器也认为这是一个新用户,会分配新的Session ID。
JavaEE的Servlet机制内建了对Session的支持。我们以登录为例,当一个用户登录成功后,我们就可以把这个用户的名字放入一个HttpSession对象,以便后续访问其他页面的时候,能直接从HttpSession取出用户名
4.使用WebSocket实现多人聊天的基本步骤:
- 建立WebSocket服务器:首先你需要在你的服务器端设置和运行一个WebSocket服务器。当用户连接到这个WebSocket服务器时,服务器会为每一个用户创建一个WebSocket连接实例。
- 客户端连接:当用户打开聊天应用时,他们的设备(客户端)会通过WebSocket协议向服务器发起连接请求。
- 接收消息:当用户发送聊天消息时,客户端会把这个消息通过它们的WebSocket连接发送到服务器。服务器在接收到消息后,会处理这个消息。在一个聊天应用中,处理通常涉及记录消息(例如将其保存在数据库中),以及准备将其广播给其它用户。
- 广播消息:服务器通过WebSocket连接将消息广播到所有已连接的客户端。这样,每一个在聊天室里的用户都会接收到新的聊天消息。
- 断开连接:当用户关闭他们的设备或者关闭网页时,他们的WebSocket连接会被关闭。服务器在检测到连接的关闭,会从活跃连接列表中移除这个连接。
这个过程需要在客户端和服务端都有代码的执行。在客户端,代码会处理发送和接收消息。在服务端,代码会处理接收消息,保存消息,及广播消息等任务。
二、代码实现
1. 准备工作
后端Springboot
Springboot 添加Pom依赖:
<!-- websocket -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
2.代码部分
添加配置类WebSocketConfig
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;
@Configuration
public class WebSocketConfig {
/**
* 注入一个ServerEndpointExporter,该Bean会自动注册使用@ServerEndpoint注解申明的websocket endpoint
*/
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}
添加WebSocketServer服务器
这里把关键部分放一下,一定要注意不能导错包特别是
import jakarta.websocket.server.PathParam;
import jakarta.websocket.server.ServerEndpoint;
import jakarta.websocket.*;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import jakarta.websocket.server.PathParam;
import jakarta.websocket.server.ServerEndpoint;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import jakarta.websocket.*;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@ServerEndpoint(value = "/imserver/{username}")
@Component
public class WebSocketServer {
private static final Logger log = LoggerFactory.getLogger(WebSocketServer.class);
/**
* 记录当前在线连接数
*/
public static final Map<String, Session> sessionMap = new ConcurrentHashMap<>();
/**
* 连接建立成功调用的方法
*/
@OnOpen
public void onOpen(Session session, @PathParam("username") String username) {
sessionMap.put(username, session);
log.info("有新用户加入,username={}, 当前在线人数为:{}", username, sessionMap.size());
JSONObject result = new JSONObject();
JSONArray array = new JSONArray();
result.set("users", array);
for (Object key : sessionMap.keySet()) {
JSONObject jsonObject = new JSONObject();
jsonObject.set("username", key);
// {"username", "zhang", "username": "admin"}
array.add(jsonObject);
}
// {"users": [{"username": "zhang"},{ "username": "admin"}]}
sendAllMessage(JSONUtil.toJsonStr(result)); // 后台发送消息给所有的客户端
}
@OnClose
public void onClose(Session session, @PathParam("username") String username) {
sessionMap.remove(username);
log.info("有一连接关闭,移除username={}的用户session, 当前在线人数为:{}", username, sessionMap.size());
}
/**
* 收到客户端消息后调用的方法
* 后台收到客户端发送过来的消息
* onMessage 是一个消息的中转站
* 接受 浏览器端 socket.send 发送过来的 json数据
* @param message 客户端发送过来的消息
*/
@OnMessage
public void onMessage(String message, Session session, @PathParam("username") String username) {
log.info("服务端收到用户username={}的消息:{}", username, message);
JSONObject obj = JSONUtil.parseObj(message);
String text = obj.getStr("text"); // 发送的消息文本 hello
// {"to": "admin", "text": "聊天文本"}
// 服务器端 再把消息组装一下,组装后的消息包含发送人和发送的文本内容
// {"from": "zhang", "text": "hello"}
JSONObject jsonObject = new JSONObject();
jsonObject.set("from", username); // from 是 zhang
jsonObject.set("text", text); // text 同上面的text
for (Session toSession : sessionMap.values()) {
if(sessionMap.get(username)!=toSession) {
log.info("服务端给客户端[{}]发送消息{}", session.getId(), message);
this.sendMessage(jsonObject.toString(), toSession);
}
}
}
//....后续
private void sendMessage(String message, Session toSession) {
try {
log.info("服务端给客户端[{}]发送消息{}", toSession.getId(), message);
toSession.getBasicRemote().sendText(message);
} catch (Exception e) {
log.error("服务端发送消息给客户端失败", e);
}
}
}
这段代码是一个使用Java和WebSocket协议实现的一个简单的聊天服务器。这个服务器可以接收客户端的消息,并将消息转发给所有连接的客户端。
- @ServerEndpoint(value = “/imserver/{username}”):这个注释用于声明这是一个WebSocket服务器的端点,/imserver/{username}是该服务器的网址。客户端可以通过ws://<服务器地址>/imserver/<用户名>的形式连接到这个服务器。
- private static final Map<String, Session> sessionMap = new ConcurrentHashMap<>();:这是一个用于存储所有与客户端的连接的map。它的键是用户名,值是与之对应的WebSocket会话。
- @OnOpen:这个注释用于标记当新的客户端连接到服务器时应该调用的方法。在这个方法中,服务器将新连接的用户名和会话添加到sessionMap中,并向所有客户端发送当前在线用户列表。
- @OnClose:这个注释用于标记当客户端关闭连接时应该调用的方法。在这个方法中,服务器将断开的客户端从sessionMap中删除,然后记录日志。
- @OnMessage:这个注释用于标记当服务器收到来自客户端的消息时应该调用的方法。在这个方法中,服务器将解析收到的消息,将消息内容和发送者添加到新的消息对象中,并将新的消息对象发送给所有连接的客户端。
- @OnError:这个注释用于标记当服务器发生错误时应该调用的方法。在这个方法中,服务器将记录错误并打印错误堆栈。
- sendMessage:这个方法用于向特定的客户端发送消息。它将接受一个消息和一个WebSocket会话作为参数,然后将消息发送到该会话。
前端页面
新建vue组件
关键代码
init() {
this.user = localStorage.getItem("user") ? JSON.parse(localStorage.getItem("user")) : {}
console.log(this.user)
this.circleUrl=this.user.avatarUrl;
let username = this.user.username;
console.log(this.user)
console.log("hello")
let _this = this;
if (typeof (WebSocket) == "undefined") {
console.log("您的浏览器不支持WebSocket");
} else {
console.log("您的浏览器支持WebSocket");
let socketUrl = "ws://localhost:9090/imserver/" + username;
if (socket != null) {
socket.close();
socket = null;
}
// 开启一个websocket服务
socket = new WebSocket(socketUrl);
//打开事件
socket.onopen = function () {
console.log("websocket已打开");
};
// 浏览器端收消息,获得从服务端发送过来的文本消息
socket.onmessage = function (msg) {
console.log("收到数据====" + msg.data)
let data = JSON.parse(msg.data) // 对收到的json数据进行解析, 类似这样的: {"users": [{"username": "zhang"},{ "username": "admin"}]}
if (data.users) { // 获取在线人员信息
_this.users = data.users.filter(user => user.username !== username) // 获取当前连接的所有用户信息,并且排除自身,自己不会出现在自己的聊天列表里
} else {
console.log("数据是什么?"+msg.data)
// 如果服务器端发送过来的json数据 不包含 users 这个key,那么发送过来的就是聊天文本json数据
// // {"from": "zhang", "text": "hello"}
_this.messages.push(data)
// 构建消息内容
_this.createContent(data.from, null, data.text)
}
};
//关闭事件
socket.onclose = function () {
console.log("websocket已关闭");
};
//发生了错误事件
socket.onerror = function () {
console.log("websocket发生了错误");
}
}
}
这段Javascript代码定义了一个函数init(),旨在初始化一段客户端的WebSocket通信。主要用于与WebSocketServer服务器进行互动。
- 初始化用户信息:从本地存储获取"user"的值,如果有值,则通过JSON.parse()将字符串转化为JavaScript对象。
-log输出当前用户信息,用于在控制台监视和调试。检测浏览器是否支持WebSocket,如果不支持,就在浏览器console输出提示信息;如果支持,则创建一个指向服务器的WebSocket对象。- socketUrl定义了WebSocket服务器的地址,其中username代表当前用户的用户名。
- 销毁可能存在的之前的socket,避免重复连接。
- 创建新的WebSocket连接,地址为上面定义的socketUrl。
- 接着,通过设置socket.onopen、socket.onmessage、socket.onclose、socket.onerror四个方法来定义WebSocket的四个主要事件处理函数:在连接建立时,收到消息时,关闭连接时,以及发生错误时,分别应该进行怎样的操作。
- socket.onmessage函数用于处理收到的消息。如果收到的数据有users键,则更新当前的用户列表,排除掉自身;如果没有users键,那么假设收到的是聊天消息,并添加到messages数组中,通过createContent方法显示。
- socket.onclose()和socket.onerror()函数则在WebSocket连接关闭或发生错误时输出相应的信息。
使用session机制24小时内再次进入群聊能够自动识别用户
设置跨域
后端
@Configuration
public class CorsConfig {
// 当前跨域请求最大有效时长。这里默认1天
private static final long MAX_AGE = 24 * 60 * 600;
@Bean
public CorsFilter corsFilter() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration corsConfiguration = new CorsConfiguration();
corsConfiguration.addAllowedOrigin("http://localhost:8080"); // 1 设置访问源地址
corsConfiguration.addAllowedHeader("*"); // 2 设置访问源请求头
corsConfiguration.addAllowedMethod("*"); // 3 设置访问源请求方法
corsConfiguration.setAllowCredentials(true); // 设置是否允许发送cookies
corsConfiguration.setMaxAge(MAX_AGE);
source.registerCorsConfiguration("/**", corsConfiguration); // 4 对接口配置跨域设置
return new CorsFilter(source);
}
}
前端
import axios from 'axios'
const request = axios.create({
baseURL: '/api',
timeout: 5000,
withCredentials: true // 新增这一行
})
后端接收cookie生成唯一的sessionId
数据层和业务层
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
private static final Log LOG = Log.get();
@Override
public UserDTO login(UserDTO userDTO, HttpSession session) {
User one = getUserInfo(userDTO);//查找
if (one != null) {
session.setAttribute(session.getId(),one);
session.setMaxInactiveInterval(60*60*24);
BeanUtil.copyProperties(one, userDTO, true);//将实体类对象拷贝到UserDTO中并返回
userDTO.setSessionId(session.getId());
System.out.println("sessionGetId:"+session.getId());
return userDTO;
} else {
throw new ServiceException(Constants.CODE_600, "用户名或密码错误");
//全局异常处理器中的handle方法会捕获到这个ServiceException异常,并将该异常对象作为参数传递给handle方法进行处理
}
}
//....后续代码
}
@RestController
@RequestMapping("/user")
@CrossOrigin(origins = "http://localhost:8080", allowCredentials = "true")
public class UserController {
@Resource//依赖注入
private IUserService userService;
@PostMapping("/login")
public Result login(@RequestBody UserDTO userDTO, HttpSession session) {//登录检查
String username = userDTO.getUsername();
String password = userDTO.getPassword();
if (StrUtil.isBlank(username) || StrUtil.isBlank(password)) {
return Result.error(Constants.CODE_400,"参数错误");
}//校验
return Result.success(userService.login(userDTO,session));
}
@GetMapping("/validate")
public Result validate(HttpSession session) {
String sessionId = session.getId();
System.out.println("sessionId:"+sessionId);
User one = (User)session.getAttribute(sessionId);
if (one != null) {
UserDTO userDTO=new UserDTO();
BeanUtil.copyProperties(one, userDTO, true);
// session有效且用户存在,返回用户信息
return Result.success(userDTO);
} else {
// session无效或者用户不存在,返回错误
return Result.error(Constants.CODE_600,"Session已过期或不存在此用户");
}
}
}
- login(UserDTO userDTO, HttpSession session) 方法接收两个参数,一个是用户数据传输对象UserDTO,另一个是登录会话HttpSession。该方法的主要功能是登录,该方法先根据UserDTO获取用户信息,如果用户信息不为空,将用户信息存入session,并设置session的有效期为一天(606024秒),然后拷贝用户对象的属性到UserDTO中并返回。如果用户信息为空,则抛出一个统一服务异常,消息为“用户名或密码错误”。
- @CrossOrigin注解表示该控制器支持跨域请求,其中origins用于限定源,allowCredentials如果为true,表示服务器对跨域请求时是否需要使用凭证。
- @Resource 是Java的一种标准注释,用于依赖注入userService。
- login(@RequestBody UserDTO userDTO, HttpSession session) 方法接收两个参数,一个是用户登录信息,另一个是HttpSession登录会话。在这个方法中,首先进行了对用户名和密码非空的校验,只有当用户名和密码都不为空时,才调用userService进行登录,并将userService的返回结果封装成Result对象返回给前端。
- validate(HttpSession session) 方法用于校验用户的登录状态,根据传入的session获取用户信息,如果用户信息不为空,就返回该用户的信息,否则返回“Session已过期或不存在此用户”的错误。
前端发送sessionId校验如果sessionId有效则自动登录
methods: {
login() {
this.$refs['userForm'].validate((valid) => {
if (valid) { // 表单校验合法
this.request.post("http://localhost:9090/user/login", this.user).then(res => {
if(res.code === '200') {
localStorage.setItem("user", JSON.stringify(res.data)) // 存储用户信息到浏览器
console.log(res.data)
localStorage.setItem("sessionId", res.data.sessionId)
console.log("res.data.sessionId: "+localStorage.getItem("sessionId"))
this.$router.push("/") // 切换路由到主页
this.$message.success("登录成功")
} else {
this.$message.error(res.msg)
}
})
}
});
},
register(){
this.$router.push('/register')
},
visitor(){
this.request.post("http://localhost:9090/user/login",
validate() {
let sessionId = localStorage.getItem("sessionId");
console.log("session: "+sessionId)
if(sessionId != null) {
this.request.get("http://localhost:9090/user/validate").then(res => {
if (res.code === '200') {
// sessionId仍然有效
localStorage.setItem("user", JSON.stringify(res.data))
this.$router.push("/");
} else {
// sessionId无效
console.log("sessionId无效")
}
})
}
},
}
}
</script>
三、运行演示
在登录时,输入http://localhost:8080/login如果一天之内登录过,sessionId就会合法,就会自动跳转登录
四、致谢
感谢中科大孟宁老师在教授我们网络程序设计这门课程中倾注的工作和耐心。在孟老师的教导下,我对这门课程的理解有了显著的提高,对计算机网络有了更深的认识。
孟老师为我们提供的学习资源和实践经验对我来说非常宝贵,让我能够更快地融入到这门课程的学习中,并且产生浓厚的学习兴趣。您的教学风格鼓励我们对知识进行探索和质疑,这对我来说是一次发人深省的学习经历。
再次感谢您的悉心教导。我期待在未来的学习生涯中能继续受益于您的知识和专业性。
最后附上项目地址(其实这个项目是基于我另一门课程的大作业,我在原有的基础上实现了孟老师实验中要求的功能)github地址