网络聊天室

前言

​ 这学期我选修了孟宁老师的《网络程序设计》课程,学到了许多以后可能经常会用到的技术,比如异步调用、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 等账号验证信息

image-20231228160727392

编辑配置文件,本项目主要有两个配置文件

image-20231228161000387

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:

image-20231228161634353

并且运行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打包该项目:

image-20231228161922641

在服务器中后台运行jar包,并且将日志输入到log.txt中。img

运行效果

在电脑浏览器运行:

image-20231228162735302

在手机浏览器运行:

img

项目仓库地址

fzaa/chatproject (gitee.com)

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值