前言
这学期我选修了孟宁老师的《网络程序设计》课程,学到了许多以后可能经常会用到的技术,比如异步调用、Socket API、网络协议设计、RPC以及Linux内核网络协议栈等。此外,我还自学了一些Java后端开发相关技术,并且有一台阿里云服务器。因此,我想将这些学过的内容整合在一起,以加深对这些技术的理解和应用,以及将写的代码在阿里云服务器上运行。由于个人水平有限,所以还存在许多需要完善的地方,在以后我也会不断完善这个项目,提升自己的编程能力。
我选择的做的项目是网上聊天室,实现了登录、注册、头像上传、网上聊天等功能。我的项目后端基于Spring Boot框架,运行在8080端口。前端通过向Nginx发送请求,并通过反向代理将请求转发至后端服务器。用户登录认证是通过Jwt令牌实现的,并且使用阿里云OSS存储用户头像。基本聊天功能是通过Socket套接字实现的,而聊天记录则存储在Redis中。用户信息则存储在MySQL数据库中。此外,我还通过定时任务实现了每周清空Redis中的聊天记录,并将其转存到MySQL中。由于相关的基础知识在网上已经有很多资料可供参考,因此接下来我将只介绍该项目的几个主要要点。
Nginx反向代理
Nginx反向代理是一种网络服务架构模式,它通过将客户端请求转发给后端服务器来实现请求的处理和响应的返回。与传统的正向代理不同,反向代理隐藏了后端服务器的真实地址,客户端只与反向代理服务器进行通信。
在反向代理模式中,客户端发送请求到反向代理服务器,然后反向代理服务器将请求转发给后端的真实服务器。后端服务器处理请求并将响应返回给反向代理服务器,最后再由反向代理服务器将响应返回给客户端。客户端并不直接与后端服务器通信,而是通过反向代理服务器作为中间层来进行通信。
在官网下载Nginx后,进入conf下的nginx.conf,vim nginx.conf,将当中server替换,然后将前端页面存放至html文件夹下。这样Nginx会自动将前端的请求转发至8080端口上的后端进程。
server {
listen 92;
server_name 你的IP地址;
location / {
root html;
index login.html login.htm;
}
# http请求反向代理
location /api/ {
proxy_pass http://localhost:8080/;
}
# 套接字反向代理
location /websocket {
proxy_pass http://localhost:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root html;
}
}
JWT令牌实现登录验证
通过Jwt与Interceptor可以实现登录验证,主要操作代码如下:
Jwt工具类,由于生成和解析Jwt令牌
package com.fang.utils;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import java.util.Date;
import java.util.Map;
public class JwtUtils {
private static String signKey = "fang";
private static Long expire = 43200000L; //令牌过期时间
/**
* 生成JWT令牌
* @param claims JWT第二部分负载 payload 中存储的内容
* @return
*/
public static String generateJwt(Map<String, Object> claims){
String jwt = Jwts.builder()
.addClaims(claims)
.signWith(SignatureAlgorithm.HS256, signKey)
.setExpiration(new Date(System.currentTimeMillis() + expire))
.compact();
return jwt;
}
/**
* 解析JWT令牌
* @param jwt JWT令牌
* @return JWT第二部分负载 payload 中存储的内容
*/
public static Claims parseJWT(String jwt){
Claims claims = Jwts.parser()
.setSigningKey(signKey)
.parseClaimsJws(jwt)
.getBody();
return claims;
}
}
在LoginController下实现登录验证操作,如果登录成功就将生成的Jwt令牌返回给前端。
package com.fang.controller;
@RestController
@Slf4j
public class LoginController {
@Autowired
private UserService userService;
@PostMapping("/login")
public Result login(@RequestBody User user){
log.info ( "员工登录:{}",user );
User u = userService.login(user);
if (u != null){
Map<String, Object> claims = new HashMap<> ();
claims.put("id", u.getId());
claims.put("username", u.getUsername ());
claims.put("password", u.getPassword ());
//claims.put ( "image",u.getImage () );
String jwt = JwtUtils.generateJwt(claims); //jwt包含了当前登录的员工信息
user.setToken ( jwt );
return Result.success(user);
}
//登录失败, 返回错误信息
return Result.error("用户名或密码错误");
}
}
全局拦截器,拦截器的主要作用是确保请求在访问需要登录的资源之前进行了身份验证。如果用户未登录或令牌无效,拦截器会返回相应的错误结果。如果用户已登录且令牌有效,拦截器会将用户信息设置到当前线程的上下文中,以便后续的处理可以使用该用户信息。
package com.fang.interceptor;
@Slf4j
@Component
public class LoginCheckInterceptor implements HandlerInterceptor {
@Override //目标资源方法运行前运行, 返回true: 放行, 放回false, 不放行
public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) throws Exception {
//1.获取请求url。
String url = req.getRequestURL().toString();
log.info("请求的url: {}",url);
User user = new User ( );
//2.判断请求url中是否包含login,如果包含,说明是登录操作,放行。
if(url.contains("login")){
log.info("登录操作, 放行...");
return true;
}
//3.获取请求头中的令牌(token)。
String jwt = req.getHeader("Authorization");
//4.判断令牌是否存在,如果不存在,返回错误结果(未登录)。
if(!StringUtils.hasLength(jwt)){
log.info("请求头token为空,返回未登录的信息");
Result error = Result.error("NOT_LOGIN");
//手动转换 对象--json --------> 阿里巴巴fastJSON
String notLogin = JSONObject.toJSONString(error);
resp.getWriter().write(notLogin);
return false;
}
//5.解析token,如果解析失败,返回错误结果(未登录)。
try {
Claims claims = JwtUtils.parseJWT ( jwt );
user.setId ( Integer.parseInt ( String.valueOf ( claims.get ( "id" ) ) ) );
user.setUsername ( claims.get ( "username" ).toString () );
user.setToken ( jwt );
BaseContext.setCurrentUser ( user );
} catch (Exception e) {//jwt解析失败
e.printStackTrace();
log.info("解析令牌失败, 返回未登录错误信息");
Result error = Result.error("NOT_LOGIN");
//手动转换 对象--json --------> 阿里巴巴fastJSON
String notLogin = JSONObject.toJSONString(error);
resp.getWriter().write(notLogin);
return false;
}
//6.放行。
log.info("令牌合法, 放行");
return true;
}
@Override //目标资源方法运行后运行
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
System.out.println("postHandle ...");
}
@Override //视图渲染完毕后运行, 最后运行
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
System.out.println("afterCompletion...");
}
}
聊天逻辑实现
该部分主要基于Spring Boot 的套接字,编程,主要逻辑如下:
config,主要用与Bean对象注册
/**
* bean注册:会自动扫描带有@ServerEndpoint注解声明的Websocket Endpoint(端点),注册成为Websocket bean。
* 要注意,如果项目使用外置的servlet容器,而不是直接使用springboot内置容器的话,就不要注入ServerEndpointExporter,因为它将由容器自己提供和管理。
*/
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
主要聊天控制逻辑如下:
/*
* 连接处理
* */
@OnOpen
public void onOpen(Session session) {
this.session = session;
webSocketSet.add(this); // 加入set中
addOnlineCount(); // 在线数加1
log.info ( "加入信息:{}",this.session );
System.out.println("有新连接加入!当前在线人数为" + getOnlineCount());
for (WebSocket item : webSocketSet) {
try {
item.sendMessage(null);
} catch (IOException e) {
e.printStackTrace();
continue;
}
}
List<String> list = messageService.getAllMessage();
if (list!=null){
list.forEach ( user -> {
User u = JSON.parseObject ( user,User.class );
if (u.getMessage ()!=null) {
try {
u.setPassword ( "" );
this.sendMessage ( u );
} catch (IOException e) {
e.printStackTrace ();
}
}
} );
}
}
/**
* 连接关闭调用的方法
*/
@OnClose
public void onClose() {
webSocketSet.remove(this); // 从set中删除
subOnlineCount(); // 在线数减1
for (WebSocket item : webSocketSet) {
try {
item.sendMessage(null);
} catch (IOException e) {
e.printStackTrace();
continue;
}
}
System.out.println("有一连接关闭!当前在线人数为" + getOnlineCount());
}
/*
* 信息处理
* */
@OnMessage
public void onMessage(String user, Session session) {
User u = JSON.parseObject ( user,User.class );
log.info ("来自客户端的消息:{}",u );
messageService.saveMessage(u);
//User u = JSON.parseObject ( user,User.class );
// 群发消息
//log.info ( "{}",BaseContext.getCurrentUser () );
//message = message+":"+BaseContext.getCurrentUser ().getUsername ();
for (WebSocket item : webSocketSet) {
try {
item.sendMessage(u);
} catch (IOException e) {
e.printStackTrace();
continue;
}
}
}
/*
* 错误处理
* */
@OnError
public void onError(Session session, Throwable error) {
System.out.println("发生错误");
error.printStackTrace();
}
/*
* 发送信息
* */
public void sendMessage(User u) throws IOException {
log.info ( "u={}",u );
User user = new User ();
user.setCount ( getOnlineCount () );
if (u!=null){
//log.info ( "11111,{}",u );
user.setMessage ( u.getMessage () );
user.setUsername ( u.getUsername () );
user.setId ( u.getId () );
user.setImage ( u.getImage () );
user.setSendtime ( u.getSendtime () );
}
log.info ( "{}",user );
this.session.getBasicRemote().sendText(JSON.toJSONString ( user ));
// this.session.getAsyncRemote().sendText(message);
}
前端实现逻辑类似,由于页面限制,只展示部分。
//发送套接字请求
const socket = new WebSocket('ws://你的IP地址:92/websocket');
// 监听WebSocket连接成功事件
socket.onopen = () => {
console.log('WebSocket连接已打开');
//validateToken(); // 验证Token是否登录成功
};
// 监听WebSocket接收到消息事件
socket.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.message != undefined) {
receiveMessage(data);
} else {
onlineUsersElement.innerText = '当前在线人数:' + data.count;
}
};
// 监听WebSocket连接关闭事件
socket.onclose = () => {
console.log('WebSocket连接已关闭');
};
// 监听WebSocket发生错误事件
socket.onerror = (error) => {
console.log('WebSocket发生错误:', error);
};
WebSocket调用Service接口
该部分是由于第一次我在WebSocket调用Service层接口,出现了报错, 项目启动时初始化,会初始化 WebSocket(非用户连接的),spring 同时会为其注入 service,该对象的 service 不是 null,被成功注入。但是,由于 spring 默认管理的是单例,所以只会注入一次 service。当新用户进入聊天时,系统又会创建一个新的 WebSocket对象,这时矛盾出现了:spring 管理的都是单例,不会给第二个 WebSocket对象注入 service,所以导致只要是用户连接创建的 WebSocket对象,都不能再注入了。
解决方法:
在WebSocketConfig下增加如下代码。
@Autowired
public void setService(MessageService messageService){
WebSocket.messageService = messageService;
}
在Web Socket下加入
public static MessageService messageService;
阿里云OSS对象存储
这部分主要用于头像存储,首先在阿里云上新建一个Bucket,并且记住相应的 AccessKey 等账号验证信息
编辑配置文件,本项目主要有两个配置文件
server:
port: 8080
spring:
profiles:
active: dev
main:
allow-circular-references: true
datasource:
driver-class-name: ${sky.datasource.driver-class-name}
url: jdbc:mysql://${sky.datasource.host}:${sky.datasource.port}/${sky.datasource.database}?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&useSSL=false&allowPublicKeyRetrieval=true
username: ${sky.datasource.username}
password: ${sky.datasource.password}
redis:
host: ${sky.redis.host}
port: ${sky.redis.port}
password: ${sky.redis.password}
database: ${sky.redis.database}
mybatis:
#mapper配置文件
mapper-locations: classpath:mapper/*.xml
configuration:
#开启驼峰命名
map-underscore-to-camel-case: true
logging:
level:
com:
sky:
mapper: debug
service: info
controller: info
sky:
alioss:
endpoint: ${sky.alioss.endpoint}
access-key-id: ${sky.alioss.access-key-id}
access-key-secret: ${sky.alioss.access-key-secret}
bucket-name: ${sky.alioss.bucket-name}
sky:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
host: localhost
port: 3306
database: mybatis
username: root
password: 123456
alioss:
endpoint: oss-cn-hangzhou.aliyuncs.com
access-key-id: LTawdAI5t6tVZBJekcwadaLimiRZwAe
access-key-secret: ZboMWnadwdauSfxS0XbvBmZ4awd8b3eTEl0EMs
bucket-name: itskyff
redis:
host: localhost
port: 6379
password: 123
database: 1
阿里云工具类:
public class AliOssUtil {
private String endpoint;
private String accessKeyId;
private String accessKeySecret;
private String bucketName;
/**
* 文件上传
*
* @param bytes
* @param objectName
* @return
*/
public String upload(byte[] bytes, String objectName) {
// 创建OSSClient实例。
OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
try {
// 创建PutObject请求。
ossClient.putObject(bucketName, objectName, new ByteArrayInputStream(bytes));
} catch (OSSException oe) {
System.out.println("Caught an OSSException, which means your request made it to OSS, "
+ "but was rejected with an error response for some reason.");
System.out.println("Error Message:" + oe.getErrorMessage());
System.out.println("Error Code:" + oe.getErrorCode());
System.out.println("Request ID:" + oe.getRequestId());
System.out.println("Host ID:" + oe.getHostId());
} catch (ClientException ce) {
System.out.println("Caught an ClientException, which means the client encountered "
+ "a serious internal problem while trying to communicate with OSS, "
+ "such as not being able to access the network.");
System.out.println("Error Message:" + ce.getMessage());
} finally {
if (ossClient != null) {
ossClient.shutdown();
}
}
//文件访问路径规则 https://BucketName.Endpoint/ObjectName
StringBuilder stringBuilder = new StringBuilder("https://");
stringBuilder
.append(bucketName)
.append(".")
.append(endpoint)
.append("/")
.append(objectName);
log.info("文件上传到:{}", stringBuilder.toString());
return stringBuilder.toString();
}
}
在前端传送图片给后端后,后端通过调用上面的方法实现图片上传,并且返回图片的路径。
Redis
首先在阿里云上部署Redis,在Redis目录下修改redis.conf中如下代码,用于内容比较多,可能需要用到查找功能,修改redis登录密码为123:
并且运行redis:
redis-server redis.conf
定时任务
该部分主要是每过一周将Redis聊天记录清空,并且将数据存入MySql中
package com.fang.task;
import com.alibaba.fastjson.JSON;
import com.fang.mapper.MessageMapper;
import com.fang.pojo.User;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.List;
@Component
@Slf4j
public class MessageTask {
@Autowired
private RedisTemplate<String,String> redisTemplate;
@Autowired
private MessageMapper messageMapper;
private static final String CHAT_RECORD_KEY = "chat_records";
@Scheduled(cron = "0 0 0 */7 * ?")//七天触发一次
public void processMessageData(){
log.info ( "定时清除redis数据,只保留七天聊天记录,定时写入数据库备份" );
List<String> list = redisTemplate.opsForList ().range ( CHAT_RECORD_KEY, 0, -1 );
redisTemplate.delete ( CHAT_RECORD_KEY );
if (list!=null){
list.forEach ( user -> {
User u = JSON.parseObject ( user,User.class );
messageMapper.insert ( u );
} );
}
}
}
项目打包部署
首先确保阿里云服务器已经安装好了Java环境、Redis、MySql、Nginx等。
在idea中使用package打包该项目:
在服务器中后台运行jar包,并且将日志输入到log.txt中。
运行效果
在电脑浏览器运行:
在手机浏览器运行: