SpringBoot3 + Netty + UniApp 即时通讯系统开发详细教程
项目概述
本教程将从零开始,详细介绍如何构建一个完整的即时通讯系统。包含后端SpringBoot服务、Netty WebSocket服务器和UniApp前端应用。
技术栈说明
后端技术栈
- SpringBoot 3.2.0 - Java Web开发框架,简化配置
- JDK 17 - Java开发环境
- Netty 4.1.104 - 高性能网络通信框架,用于WebSocket
- MySQL 8.0 - 关系型数据库,存储用户和消息数据
- Redis - 内存数据库,存储用户在线状态
- Spring Data JPA - 数据访问层,简化数据库操作
- JWT - JSON Web Token,用于用户身份认证
- Maven - 项目构建和依赖管理工具
前端技术栈
- UniApp - 跨平台开发框架,一套代码多端运行
- Vue 3 - 前端MVVM框架
- WebSocket - 实时双向通信协议
开发环境准备
1. 安装开发工具
1.1 安装JDK 17
- 下载JDK 17:https://www.oracle.com/java/technologies/downloads/
- 安装到指定目录(如:D:\Program Files\Java\jdk-17)
- 配置环境变量:
- JAVA_HOME: D:\Program Files\Java\jdk-17
- Path中添加: %JAVA_HOME%\bin
- 验证安装:打开命令行输入
java -version
1.2 安装IDE
- IntelliJ IDEA(推荐)或 Eclipse
- HBuilderX(用于UniApp开发)
1.3 安装数据库
- MySQL 8.0:https://dev.mysql.com/downloads/mysql/
- Redis:可以使用Docker或直接安装
1.4 安装Maven
- 下载Maven:https://maven.apache.org/download.cgi
- 配置环境变量
第一步:创建SpringBoot项目
1.1 使用Spring Initializr创建项目
- 访问 https://start.spring.io/
- 配置项目信息:
- Project: Maven
- Language: Java
- Spring Boot: 3.2.0
- Group: com.zhentao
- Artifact: Study-Im
- Java: 17
- 添加依赖:
- Spring Web
- Spring Data JPA
- MySQL Driver
- Spring Data Redis
- Validation
- 生成并下载项目
1.2 项目结构说明
Study-Im/
├── src/main/java/com/zhentao/im/
│ ├── ImApplication.java # 主启动类
│ ├── config/ # 配置类
│ ├── controller/ # 控制器层
│ ├── dto/ # 数据传输对象
│ ├── entity/ # 实体类
│ ├── repository/ # 数据访问层
│ ├── service/ # 业务逻辑层
│ ├── util/ # 工具类
│ └── netty/ # Netty相关类
├── src/main/resources/
│ ├── application.yml # 配置文件
│ └── db/ # 数据库脚本
└── pom.xml # Maven配置文件
```## 第二步
:配置Maven依赖
### 2.1 编辑pom.xml文件
在项目根目录的pom.xml中添加所需依赖:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version>
<relativePath/>
</parent>
<groupId>com.zhentao</groupId>
<artifactId>Study-Im</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>jar</packaging>
<properties>
<java.version>17</java.version>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<netty.version>4.1.104.Final</netty.version>
<jwt.version>4.4.0</jwt.version>
</properties>
<dependencies>
<!-- SpringBoot Web启动器 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- SpringBoot WebSocket启动器 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- SpringBoot JPA启动器 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- SpringBoot Redis启动器 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- SpringBoot 验证启动器 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Netty网络框架 -->
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>${netty.version}</version>
</dependency>
<!-- MySQL数据库驱动 -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>8.0.33</version>
</dependency>
<!-- JSON处理库 -->
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>2.0.43</version>
</dependency>
<!-- JWT身份认证 -->
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>${jwt.version}</version>
</dependency>
<!-- Lombok代码简化 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
</project>
2.2 依赖说明
- spring-boot-starter-web: 提供Web开发基础功能
- spring-boot-starter-websocket: WebSocket支持
- spring-boot-starter-data-jpa: 数据库操作简化
- spring-boot-starter-data-redis: Redis操作支持
- netty-all: 高性能网络通信框架
- mysql-connector-j: MySQL数据库连接驱动
- fastjson2: JSON数据处理
- java-jwt: JWT令牌生成和验证
- lombok: 减少样板代码(如getter/setter)
第三步:配置应用程序
3.1 创建application.yml配置文件
在src/main/resources/目录下创建application.yml:
# 服务器配置
server:
port: 8080 # HTTP服务端口
spring:
application:
name: study-im # 应用名称
# 数据库配置
datasource:
url: jdbc:mysql://localhost:3306/study_im?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root # 数据库用户名
password: root # 数据库密码
driver-class-name: com.mysql.cj.jdbc.Driver
# JPA配置
jpa:
hibernate:
ddl-auto: update # 自动更新数据库表结构
show-sql: true # 显示SQL语句
properties:
hibernate:
dialect: org.hibernate.dialect.MySQL8Dialect
# Redis配置
redis:
host: # Redis服务器地址
port: 6379 # Redis端口
password: # Redis密码
database: 0 # 使用的数据库编号
timeout: 3000ms # 连接超时时间
lettuce:
pool:
max-active: 8 # 最大连接数
max-wait: -1ms # 最大等待时间
max-idle: 8 # 最大空闲连接
min-idle: 0 # 最小空闲连接
# Netty配置
netty:
server:
port: 9999 # WebSocket服务端口
boss-threads: 1 # Boss线程数
worker-threads: 4 # Worker线程数
# JWT配置
jwt:
secret: study-im-secret-key-2024 # JWT密钥
expiration: 86400000 # 过期时间(24小时)
# 日志配置
logging:
level:
com.zhentao.im: debug # 项目日志级别
org.springframework.web: debug # Spring Web日志级别
3.2 配置说明
- server.port: HTTP服务监听端口
- datasource: 数据库连接配置
- jpa.hibernate.ddl-auto:
update: 自动更新表结构create: 每次启动重新创建表none: 不自动操作表结构
- redis: Redis缓存服务器配置
- netty: WebSocket服务器配置
- jwt: 用户身份认证配置## 第四步:创
建数据库实体类
4.1 创建用户实体类
在src/main/java/com/zhentao/im/entity/目录下创建User.java:
package com.zhentao.im.entity;
import jakarta.persistence.*;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 用户实体类
* @Data 注解自动生成getter/setter方法
* @Entity 标记这是一个JPA实体
* @Table 指定数据库表名
*/
@Data
@Entity
@Table(name = "users")
public class User {
@Id // 主键
@GeneratedValue(strategy = GenerationType.IDENTITY) // 自增主键
private Long id;
@Column(unique = true, nullable = false) // 唯一且非空
private String username; // 用户名
@Column(nullable = false)
private String password; // 密码
private String nickname; // 昵称
private String avatar; // 头像URL
@Enumerated(EnumType.STRING) // 枚举类型存储为字符串
private UserStatus status = UserStatus.OFFLINE; // 用户状态
@Column(name = "last_login_time")
private LocalDateTime lastLoginTime; // 最后登录时间
@Column(name = "create_time")
private LocalDateTime createTime = LocalDateTime.now(); // 创建时间
/**
* 用户状态枚举
*/
public enum UserStatus {
ONLINE, // 在线
OFFLINE, // 离线
BUSY // 忙碌
}
}
4.2 创建消息实体类
在同目录下创建Message.java:
package com.zhentao.im.entity;
import jakarta.persistence.*;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 消息实体类
*/
@Data
@Entity
@Table(name = "messages")
public class Message {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "from_user_id", nullable = false)
private Long fromUserId; // 发送者ID
@Column(name = "to_user_id", nullable = false)
private Long toUserId; // 接收者ID
@Column(nullable = false, columnDefinition = "TEXT")
private String content; // 消息内容
@Enumerated(EnumType.STRING)
private MessageType type = MessageType.TEXT; // 消息类型
@Enumerated(EnumType.STRING)
private MessageStatus status = MessageStatus.SENT; // 消息状态
@Column(name = "send_time")
private LocalDateTime sendTime = LocalDateTime.now(); // 发送时间
@Column(name = "read_time")
private LocalDateTime readTime; // 阅读时间
/**
* 消息类型枚举
*/
public enum MessageType {
TEXT, // 文本消息
IMAGE, // 图片消息
FILE, // 文件消息
SYSTEM // 系统消息
}
/**
* 消息状态枚举
*/
public enum MessageStatus {
SENT, // 已发送
DELIVERED, // 已送达
READ // 已读
}
}
4.3 实体类注解说明
- @Data: Lombok注解,自动生成getter/setter/toString等方法
- @Entity: JPA注解,标记这是一个数据库实体
- @Table: 指定对应的数据库表名
- @Id: 标记主键字段
- @GeneratedValue: 主键生成策略,IDENTITY表示自增
- @Column: 指定列的属性(唯一性、非空、列名等)
- @Enumerated: 枚举类型的存储方式
第五步:创建数据访问层
5.1 创建用户Repository
在src/main/java/com/zhentao/im/repository/目录下创建UserRepository.java:
package com.zhentao.im.repository;
import com.zhentao.im.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
/**
* 用户数据访问接口
* JpaRepository提供基本的CRUD操作
*/
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
/**
* 根据用户名查找用户
* Spring Data JPA会自动实现这个方法
*/
Optional<User> findByUsername(String username);
/**
* 检查用户名是否存在
*/
boolean existsByUsername(String username);
}
5.2 创建消息Repository
在同目录下创建MessageRepository.java:
package com.zhentao.im.repository;
import com.zhentao.im.entity.Message;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
/**
* 消息数据访问接口
*/
@Repository
public interface MessageRepository extends JpaRepository<Message, Long> {
/**
* 查询两个用户之间的聊天记录
* 使用JPQL查询语言
*/
@Query("SELECT m FROM Message m WHERE " +
"(m.fromUserId = :userId1 AND m.toUserId = :userId2) OR " +
"(m.fromUserId = :userId2 AND m.toUserId = :userId1) " +
"ORDER BY m.sendTime ASC")
List<Message> findChatHistory(@Param("userId1") Long userId1,
@Param("userId2") Long userId2);
/**
* 查询发送给指定用户的消息
*/
List<Message> findByToUserIdOrderBySendTimeDesc(Long toUserId);
}
5.3 Repository说明
- JpaRepository<Entity, ID>: Spring Data JPA提供的基础接口
- Entity: 实体类型
- ID: 主键类型
- 自动方法实现: Spring根据方法名自动生成实现
- findBy + 字段名: 根据字段查询
- existsBy + 字段名: 检查是否存在
- @Query: 自定义JPQL查询语句
- @Param: 绑定查询参数##
第六步:创建数据传输对象(DTO)
6.1 创建统一响应格式
在src/main/java/com/zhentao/im/dto/目录下创建ApiResponse.java:
package com.zhentao.im.dto;
import lombok.Data;
/**
* 统一API响应格式
* 所有接口都使用这个格式返回数据
*/
@Data
public class ApiResponse<T> {
private int code; // 响应码:200成功,其他失败
private String message; // 响应消息
private T data; // 响应数据
/**
* 成功响应
*/
public static <T> ApiResponse<T> success(T data) {
ApiResponse<T> response = new ApiResponse<>();
response.setCode(200);
response.setMessage("success");
response.setData(data);
return response;
}
/**
* 失败响应
*/
public static <T> ApiResponse<T> error(String message) {
ApiResponse<T> response = new ApiResponse<>();
response.setCode(500);
response.setMessage(message);
return response;
}
/**
* 自定义响应码的失败响应
*/
public static <T> ApiResponse<T> error(int code, String message) {
ApiResponse<T> response = new ApiResponse<>();
response.setCode(code);
response.setMessage(message);
return response;
}
}
6.2 创建登录请求DTO
创建LoginRequest.java:
package com.zhentao.im.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 登录请求数据传输对象
*/
@Data
public class LoginRequest {
@NotBlank(message = "用户名不能为空")
private String username; // 用户名
@NotBlank(message = "密码不能为空")
private String password; // 密码
}
6.3 创建注册请求DTO
创建RegisterRequest.java:
package com.zhentao.im.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 注册请求数据传输对象
*/
@Data
public class RegisterRequest {
@NotBlank(message = "用户名不能为空")
private String username; // 用户名
@NotBlank(message = "密码不能为空")
private String password; // 密码
private String nickname; // 昵称(可选)
}
6.4 创建消息DTO
创建MessageDto.java:
package com.zhentao.im.dto;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 消息数据传输对象
* 用于前后端消息传输
*/
@Data
public class MessageDto {
private Long id; // 消息ID
private Long fromUserId; // 发送者ID
private Long toUserId; // 接收者ID
private String content; // 消息内容
private String type; // 消息类型
private LocalDateTime sendTime; // 发送时间
private String fromUsername; // 发送者用户名
private String toUsername; // 接收者用户名
}
6.5 DTO说明
- DTO (Data Transfer Object): 数据传输对象,用于不同层之间传递数据
- @NotBlank: 验证注解,确保字段不为空
- 泛型: 使ApiResponse可以包装任何类型的数据
- 静态方法: 提供便捷的创建方法
第七步:创建工具类
7.1 创建JWT工具类
在src/main/java/com/zhentao/im/util/目录下创建JwtUtil.java:
package com.zhentao.im.util;
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.auth0.jwt.interfaces.DecodedJWT;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.Date;
/**
* JWT工具类
* 用于生成和验证JWT令牌
*/
@Component
public class JwtUtil {
@Value("${jwt.secret}") // 从配置文件读取密钥
private String secret;
@Value("${jwt.expiration}") // 从配置文件读取过期时间
private Long expiration;
/**
* 生成JWT令牌
* @param userId 用户ID
* @param username 用户名
* @return JWT令牌字符串
*/
public String generateToken(Long userId, String username) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + expiration);
return JWT.create()
.withSubject(username) // 主题(用户名)
.withClaim("userId", userId) // 自定义声明(用户ID)
.withIssuedAt(now) // 签发时间
.withExpiresAt(expiryDate) // 过期时间
.sign(Algorithm.HMAC256(secret)); // 签名算法
}
/**
* 验证JWT令牌
* @param token JWT令牌
* @return 解码后的JWT对象
*/
public DecodedJWT verifyToken(String token) {
try {
return JWT.require(Algorithm.HMAC256(secret))
.build()
.verify(token);
} catch (JWTVerificationException e) {
throw new RuntimeException("无效的JWT令牌", e);
}
}
/**
* 从令牌中获取用户名
*/
public String getUsernameFromToken(String token) {
return verifyToken(token).getSubject();
}
/**
* 从令牌中获取用户ID
*/
public Long getUserIdFromToken(String token) {
return verifyToken(token).getClaim("userId").asLong();
}
/**
* 检查令牌是否过期
*/
public boolean isTokenExpired(String token) {
try {
Date expiration = verifyToken(token).getExpiresAt();
return expiration.before(new Date());
} catch (Exception e) {
return true;
}
}
}
7.2 JWT工具类说明
- @Component: Spring组件注解,自动注入到容器
- @Value: 从配置文件注入值
- JWT.create(): 创建JWT构建器
- withSubject(): 设置主题(通常是用户标识)
- withClaim(): 添加自定义声明
- sign(): 使用指定算法签名
- verify(): 验证JWT令牌的有效性
第八步:创建Redis配置
8.1 创建Redis配置类
在src/main/java/com/zhentao/im/config/目录下创建RedisConfig.java:
package com.zhentao.im.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
/**
* Redis配置类
* 配置Redis序列化方式
*/
@Configuration
public class RedisConfig {
/**
* 配置RedisTemplate
* RedisTemplate是Spring操作Redis的核心类
*/
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
// 设置key的序列化方式为String
template.setKeySerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
// 设置value的序列化方式为JSON
template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
template.afterPropertiesSet();
return template;
}
}
8.2 Redis配置说明
- @Configuration: 标记这是一个配置类
- @Bean: 将方法返回的对象注册为Spring Bean
- RedisTemplate: Spring操作Redis的模板类
- 序列化器:
- StringRedisSerializer: 字符串序列化
- GenericJackson2JsonRedisSerializer: JSON序列化## 第九步
:创建业务逻辑层
9.1 创建用户服务类
在src/main/java/com/zhentao/im/service/目录下创建UserService.java:
package com.zhentao.im.service;
import com.zhentao.im.dto.LoginRequest;
import com.zhentao.im.dto.RegisterRequest;
import com.zhentao.im.entity.User;
import com.zhentao.im.repository.UserRepository;
import com.zhentao.im.util.JwtUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
/**
* 用户业务逻辑服务类
*/
@Service
@RequiredArgsConstructor // Lombok注解,自动生成构造函数
public class UserService {
// 依赖注入,final字段会通过构造函数注入
private final UserRepository userRepository;
private final JwtUtil jwtUtil;
private final RedisTemplate<String, Object> redisTemplate;
private final InMemoryUserStatusService inMemoryUserStatusService;
/**
* 用户注册
* @param request 注册请求数据
* @return 包含token和用户信息的Map
*/
public Map<String, Object> register(RegisterRequest request) {
// 1. 检查用户名是否已存在
if (userRepository.existsByUsername(request.getUsername())) {
throw new RuntimeException("用户名已存在");
}
// 2. 创建新用户
User user = new User();
user.setUsername(request.getUsername());
user.setPassword(request.getPassword()); // 实际项目中应该加密密码
user.setNickname(request.getNickname() != null ?
request.getNickname() : request.getUsername());
user.setCreateTime(LocalDateTime.now());
// 3. 保存到数据库
user = userRepository.save(user);
// 4. 生成JWT令牌
String token = jwtUtil.generateToken(user.getId(), user.getUsername());
// 5. 返回结果
Map<String, Object> result = new HashMap<>();
result.put("token", token);
result.put("user", user);
return result;
}
/**
* 用户登录
* @param request 登录请求数据
* @return 包含token和用户信息的Map
*/
public Map<String, Object> login(LoginRequest request) {
// 1. 查找用户
Optional<User> userOpt = userRepository.findByUsername(request.getUsername());
if (userOpt.isEmpty() || !userOpt.get().getPassword().equals(request.getPassword())) {
throw new RuntimeException("用户名或密码错误");
}
// 2. 更新用户状态
User user = userOpt.get();
user.setLastLoginTime(LocalDateTime.now());
user.setStatus(User.UserStatus.ONLINE);
userRepository.save(user);
// 3. 生成JWT令牌
String token = jwtUtil.generateToken(user.getId(), user.getUsername());
// 4. 将用户在线状态存储到Redis,如果失败则使用内存存储
try {
redisTemplate.opsForValue().set("user:online:" + user.getId(), true, 24, TimeUnit.HOURS);
} catch (Exception e) {
System.out.println("Redis连接失败,使用内存存储: " + e.getMessage());
inMemoryUserStatusService.setUserOnline(user.getId());
}
// 5. 返回结果
Map<String, Object> result = new HashMap<>();
result.put("token", token);
result.put("user", user);
return result;
}
/**
* 用户退出登录
* @param userId 用户ID
*/
public void logout(Long userId) {
Optional<User> userOpt = userRepository.findById(userId);
if (userOpt.isPresent()) {
// 1. 更新数据库中的用户状态
User user = userOpt.get();
user.setStatus(User.UserStatus.OFFLINE);
userRepository.save(user);
// 2. 从Redis中移除在线状态,如果失败则从内存中移除
try {
redisTemplate.delete("user:online:" + userId);
} catch (Exception e) {
System.out.println("Redis连接失败,使用内存存储: " + e.getMessage());
inMemoryUserStatusService.setUserOffline(userId);
}
}
}
/**
* 根据ID获取用户
* @param userId 用户ID
* @return 用户对象
*/
public User getUserById(Long userId) {
return userRepository.findById(userId).orElse(null);
}
/**
* 检查用户是否在线
* @param userId 用户ID
* @return true-在线,false-离线
*/
public boolean isUserOnline(Long userId) {
try {
return Boolean.TRUE.equals(redisTemplate.hasKey("user:online:" + userId));
} catch (Exception e) {
System.out.println("Redis连接失败,使用内存存储: " + e.getMessage());
return inMemoryUserStatusService.isUserOnline(userId);
}
}
}
9.2 创建消息服务类
创建MessageService.java:
package com.zhentao.im.service;
import com.zhentao.im.dto.MessageDto;
import com.zhentao.im.entity.Message;
import com.zhentao.im.entity.User;
import com.zhentao.im.repository.MessageRepository;
import com.zhentao.im.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.stream.Collectors;
/**
* 消息业务逻辑服务类
*/
@Service
@RequiredArgsConstructor
public class MessageService {
private final MessageRepository messageRepository;
private final UserRepository userRepository;
/**
* 保存消息到数据库
* @param message 消息对象
* @return 保存后的消息
*/
public Message saveMessage(Message message) {
return messageRepository.save(message);
}
/**
* 获取两个用户之间的聊天历史
* @param userId1 用户1的ID
* @param userId2 用户2的ID
* @return 消息DTO列表
*/
public List<MessageDto> getChatHistory(Long userId1, Long userId2) {
// 1. 从数据库查询消息
List<Message> messages = messageRepository.findChatHistory(userId1, userId2);
// 2. 转换为DTO对象
return messages.stream().map(this::convertToDto).collect(Collectors.toList());
}
/**
* 将Message实体转换为MessageDto
* @param message 消息实体
* @return 消息DTO
*/
private MessageDto convertToDto(Message message) {
MessageDto dto = new MessageDto();
dto.setId(message.getId());
dto.setFromUserId(message.getFromUserId());
dto.setToUserId(message.getToUserId());
dto.setContent(message.getContent());
dto.setType(message.getType().name());
dto.setSendTime(message.getSendTime());
// 查询用户名
User fromUser = userRepository.findById(message.getFromUserId()).orElse(null);
User toUser = userRepository.findById(message.getToUserId()).orElse(null);
if (fromUser != null) {
dto.setFromUsername(fromUser.getNickname());
}
if (toUser != null) {
dto.setToUsername(toUser.getNickname());
}
return dto;
}
}
9.3 创建内存用户状态服务(Redis备用方案)
创建InMemoryUserStatusService.java:
package com.zhentao.im.service;
import org.springframework.stereotype.Service;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
/**
* 内存用户状态服务
* 当Redis不可用时的备用方案
*/
@Service
public class InMemoryUserStatusService {
// 线程安全的HashMap,存储用户在线状态
private final ConcurrentHashMap<Long, Long> onlineUsers = new ConcurrentHashMap<>();
// 定时任务执行器
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
public InMemoryUserStatusService() {
// 每小时清理过期的在线状态
scheduler.scheduleAtFixedRate(this::cleanExpiredUsers, 1, 1, TimeUnit.HOURS);
}
/**
* 设置用户在线
* @param userId 用户ID
*/
public void setUserOnline(Long userId) {
// 存储用户ID和过期时间(24小时后)
onlineUsers.put(userId, System.currentTimeMillis() + TimeUnit.HOURS.toMillis(24));
}
/**
* 设置用户离线
* @param userId 用户ID
*/
public void setUserOffline(Long userId) {
onlineUsers.remove(userId);
}
/**
* 检查用户是否在线
* @param userId 用户ID
* @return true-在线,false-离线
*/
public boolean isUserOnline(Long userId) {
Long expireTime = onlineUsers.get(userId);
if (expireTime == null) {
return false;
}
// 检查是否过期
if (System.currentTimeMillis() > expireTime) {
onlineUsers.remove(userId);
return false;
}
return true;
}
/**
* 清理过期的用户状态
*/
private void cleanExpiredUsers() {
long currentTime = System.currentTimeMillis();
onlineUsers.entrySet().removeIf(entry -> currentTime > entry.getValue());
}
}
9.4 业务逻辑层说明
- @Service: 标记这是一个业务逻辑层组件
- @RequiredArgsConstructor: 自动生成包含final字段的构造函数
- Optional: Java 8的容器类,避免空指针异常
- Stream API: Java 8的流式处理,用于集合操作
- ConcurrentHashMap: 线程安全的HashMap
- ScheduledExecutorService: 定时任务执行器## 第十
步:创建控制器层
10.1 创建认证控制器
在src/main/java/com/zhentao/im/controller/目录下创建AuthController.java:
package com.zhentao.im.controller;
import com.zhentao.im.dto.ApiResponse;
import com.zhentao.im.dto.LoginRequest;
import com.zhentao.im.dto.RegisterRequest;
import com.zhentao.im.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* 认证控制器
* 处理用户登录、注册、退出等请求
*/
@RestController // REST风格控制器
@RequestMapping("/api/auth") // 请求路径前缀
@RequiredArgsConstructor // 自动生成构造函数
@CrossOrigin(origins = "*") // 允许跨域请求
public class AuthController {
private final UserService userService;
/**
* 用户注册接口
* POST /api/auth/register
*/
@PostMapping("/register")
public ApiResponse<Map<String, Object>> register(@Validated @RequestBody RegisterRequest request) {
try {
Map<String, Object> result = userService.register(request);
return ApiResponse.success(result);
} catch (Exception e) {
return ApiResponse.error(e.getMessage());
}
}
/**
* 用户登录接口
* POST /api/auth/login
*/
@PostMapping("/login")
public ApiResponse<Map<String, Object>> login(@Validated @RequestBody LoginRequest request) {
try {
Map<String, Object> result = userService.login(request);
return ApiResponse.success(result);
} catch (Exception e) {
return ApiResponse.error(e.getMessage());
}
}
/**
* 用户退出登录接口
* POST /api/auth/logout
*/
@PostMapping("/logout")
public ApiResponse<Void> logout(@RequestHeader("Authorization") String token) {
// 这里应该从token中解析用户ID,简化处理
return ApiResponse.success(null);
}
}
10.2 创建消息控制器
创建MessageController.java:
package com.zhentao.im.controller;
import com.zhentao.im.dto.ApiResponse;
import com.zhentao.im.dto.MessageDto;
import com.zhentao.im.service.MessageService;
import com.zhentao.im.util.JwtUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 消息控制器
* 处理消息相关的HTTP请求
*/
@RestController
@RequestMapping("/api/messages")
@RequiredArgsConstructor
@CrossOrigin(origins = "*")
public class MessageController {
private final MessageService messageService;
private final JwtUtil jwtUtil;
/**
* 获取聊天历史记录
* GET /api/messages/history/{toUserId}
*/
@GetMapping("/history/{toUserId}")
public ApiResponse<List<MessageDto>> getChatHistory(
@PathVariable Long toUserId, // 路径参数
@RequestHeader("Authorization") String authHeader) { // 请求头参数
try {
// 1. 从Authorization头中提取token
String token = authHeader.replace("Bearer ", "");
// 2. 从token中获取当前用户ID
Long currentUserId = jwtUtil.getUserIdFromToken(token);
// 3. 查询聊天历史
List<MessageDto> messages = messageService.getChatHistory(currentUserId, toUserId);
return ApiResponse.success(messages);
} catch (Exception e) {
return ApiResponse.error(e.getMessage());
}
}
/**
* 标记消息为已读
* POST /api/messages/{messageId}/read
*/
@PostMapping("/{messageId}/read")
public ApiResponse<Void> markAsRead(@PathVariable Long messageId) {
// 实现消息已读功能(这里简化处理)
return ApiResponse.success(null);
}
}
10.3 创建健康检查控制器
创建HealthController.java:
package com.zhentao.im.controller;
import com.zhentao.im.dto.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.sql.DataSource;
import java.sql.Connection;
import java.util.HashMap;
import java.util.Map;
/**
* 健康检查控制器
* 用于检查系统各组件的运行状态
*/
@RestController
@RequestMapping("/api/health")
@RequiredArgsConstructor
@CrossOrigin(origins = "*")
public class HealthController {
private final DataSource dataSource;
private final RedisTemplate<String, Object> redisTemplate;
/**
* 系统健康检查
* GET /api/health
*/
@GetMapping
public ApiResponse<Map<String, Object>> health() {
Map<String, Object> status = new HashMap<>();
// 检查数据库连接
try (Connection connection = dataSource.getConnection()) {
status.put("database", "UP");
} catch (Exception e) {
status.put("database", "DOWN - " + e.getMessage());
}
// 检查Redis连接
try {
redisTemplate.opsForValue().set("health:check", "ok");
String result = (String) redisTemplate.opsForValue().get("health:check");
status.put("redis", "ok".equals(result) ? "UP" : "DOWN");
} catch (Exception e) {
status.put("redis", "DOWN - " + e.getMessage());
}
status.put("application", "UP");
status.put("timestamp", System.currentTimeMillis());
return ApiResponse.success(status);
}
}
10.4 控制器层注解说明
- @RestController: 组合注解,等于@Controller + @ResponseBody
- @RequestMapping: 指定请求路径前缀
- @PostMapping/@GetMapping: 指定HTTP方法和路径
- @RequestBody: 将请求体JSON转换为Java对象
- @PathVariable: 获取URL路径中的参数
- @RequestHeader: 获取请求头中的参数
- @Validated: 启用参数验证
- @CrossOrigin: 允许跨域请求
第十一步:创建Netty WebSocket服务器
11.1 创建WebSocket处理器
在src/main/java/com/zhentao/im/netty/目录下创建WebSocketHandler.java:
package com.zhentao.im.netty;
import com.alibaba.fastjson2.JSON;
import com.zhentao.im.entity.Message;
import com.zhentao.im.service.MessageService;
import com.zhentao.im.util.JwtUtil;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* WebSocket消息处理器
* 处理WebSocket连接和消息
*/
@Slf4j
@Component
public class WebSocketHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {
// 存储用户ID和Channel的映射关系
private static final Map<Long, ChannelHandlerContext> USER_CHANNELS = new ConcurrentHashMap<>();
@Autowired
private JwtUtil jwtUtil;
@Autowired
private MessageService messageService;
/**
* 连接建立时调用
*/
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
log.info("WebSocket连接建立: {}", ctx.channel().id());
super.channelActive(ctx);
}
/**
* 连接断开时调用
*/
@Override
public void channelInactive(ChannelHandlerContext ctx) throws Exception {
log.info("WebSocket连接断开: {}", ctx.channel().id());
// 从映射中移除断开的连接
USER_CHANNELS.entrySet().removeIf(entry -> entry.getValue() == ctx);
super.channelInactive(ctx);
}
/**
* 接收到消息时调用
*/
@Override
protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame frame) throws Exception {
String text = frame.text();
log.info("收到WebSocket消息: {}", text);
try {
// 解析JSON消息
Map<String, Object> message = JSON.parseObject(text, Map.class);
String type = (String) message.get("type");
switch (type) {
case "auth":
handleAuth(ctx, message);
break;
case "chat":
handleChat(ctx, message);
break;
case "heartbeat":
handleHeartbeat(ctx);
break;
default:
log.warn("未知消息类型: {}", type);
}
} catch (Exception e) {
log.error("处理WebSocket消息失败", e);
sendErrorMessage(ctx, "消息处理失败: " + e.getMessage());
}
}
/**
* 处理认证消息
*/
private void handleAuth(ChannelHandlerContext ctx, Map<String, Object> message) {
try {
String token = (String) message.get("token");
// 验证JWT令牌
Long userId = jwtUtil.getUserIdFromToken(token);
// 将用户ID和Channel关联
USER_CHANNELS.put(userId, ctx);
// 发送认证成功消息
sendMessage(ctx, Map.of(
"type", "auth_success",
"message", "认证成功",
"userId", userId
));
log.info("用户 {} 认证成功", userId);
} catch (Exception e) {
sendErrorMessage(ctx, "认证失败: " + e.getMessage());
}
}
/**
* 处理聊天消息
*/
private void handleChat(ChannelHandlerContext ctx, Map<String, Object> message) {
try {
// 获取发送者ID
Long fromUserId = getUserIdByChannel(ctx);
if (fromUserId == null) {
sendErrorMessage(ctx, "请先进行身份认证");
return;
}
// 获取消息内容
Long toUserId = Long.valueOf(message.get("toUserId").toString());
String content = (String) message.get("content");
// 保存消息到数据库
Message chatMessage = new Message();
chatMessage.setFromUserId(fromUserId);
chatMessage.setToUserId(toUserId);
chatMessage.setContent(content);
chatMessage.setType(Message.MessageType.TEXT);
chatMessage.setSendTime(LocalDateTime.now());
Message savedMessage = messageService.saveMessage(chatMessage);
// 构造转发消息
Map<String, Object> forwardMessage = Map.of(
"type", "chat",
"messageId", savedMessage.getId(),
"fromUserId", fromUserId,
"toUserId", toUserId,
"content", content,
"sendTime", savedMessage.getSendTime().toString()
);
// 发送给接收者
ChannelHandlerContext toChannel = USER_CHANNELS.get(toUserId);
if (toChannel != null) {
sendMessage(toChannel, forwardMessage);
}
// 发送确认消息给发送者
sendMessage(ctx, Map.of(
"type", "message_sent",
"messageId", savedMessage.getId(),
"status", "success"
));
log.info("消息发送成功: {} -> {}", fromUserId, toUserId);
} catch (Exception e) {
sendErrorMessage(ctx, "发送消息失败: " + e.getMessage());
}
}
/**
* 处理心跳消息
*/
private void handleHeartbeat(ChannelHandlerContext ctx) {
sendMessage(ctx, Map.of("type", "heartbeat", "timestamp", System.currentTimeMillis()));
}
/**
* 发送消息到客户端
*/
private void sendMessage(ChannelHandlerContext ctx, Map<String, Object> message) {
String json = JSON.toJSONString(message);
ctx.writeAndFlush(new TextWebSocketFrame(json));
}
/**
* 发送错误消息
*/
private void sendErrorMessage(ChannelHandlerContext ctx, String error) {
sendMessage(ctx, Map.of("type", "error", "message", error));
}
/**
* 根据Channel获取用户ID
*/
private Long getUserIdByChannel(ChannelHandlerContext ctx) {
return USER_CHANNELS.entrySet().stream()
.filter(entry -> entry.getValue() == ctx)
.map(Map.Entry::getKey)
.findFirst()
.orElse(null);
}
/**
* 异常处理
*/
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
log.error("WebSocket异常", cause);
ctx.close();
}
}
```#
## 11.2 创建Netty服务器
创建NettyServer.java:
```java
package com.zhentao.im.netty;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.codec.http.HttpObjectAggregator;
import io.netty.handler.codec.http.HttpServerCodec;
import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;
import io.netty.handler.stream.ChunkedWriteHandler;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
/**
* Netty WebSocket服务器
* 提供WebSocket连接服务
*/
@Slf4j
@Component
public class NettyServer {
@Value("${netty.server.port}")
private int port;
@Value("${netty.server.boss-threads}")
private int bossThreads;
@Value("${netty.server.worker-threads}")
private int workerThreads;
@Autowired
private WebSocketHandler webSocketHandler;
private EventLoopGroup bossGroup;
private EventLoopGroup workerGroup;
private Channel serverChannel;
/**
* 启动Netty服务器
* @PostConstruct 注解表示在Bean初始化后自动调用
*/
@PostConstruct
public void start() {
new Thread(() -> {
try {
// 1. 创建事件循环组
bossGroup = new NioEventLoopGroup(bossThreads); // 处理连接
workerGroup = new NioEventLoopGroup(workerThreads); // 处理I/O
// 2. 创建服务器启动器
ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class) // 使用NIO
.option(ChannelOption.SO_BACKLOG, 128) // 连接队列大小
.childOption(ChannelOption.SO_KEEPALIVE, true) // 保持连接
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) throws Exception {
ChannelPipeline pipeline = ch.pipeline();
// 3. 添加处理器链
// HTTP编解码器
pipeline.addLast(new HttpServerCodec());
// HTTP对象聚合器,将多个HTTP消息聚合成一个完整的HTTP消息
pipeline.addLast(new HttpObjectAggregator(65536));
// 支持大文件传输
pipeline.addLast(new ChunkedWriteHandler());
// WebSocket协议处理器
pipeline.addLast(new WebSocketServerProtocolHandler("/ws"));
// 自定义WebSocket消息处理器
pipeline.addLast(webSocketHandler);
}
});
// 4. 绑定端口并启动服务器
ChannelFuture future = bootstrap.bind(port).sync();
serverChannel = future.channel();
log.info("Netty WebSocket服务器启动成功,端口: {}", port);
// 5. 等待服务器关闭
future.channel().closeFuture().sync();
} catch (Exception e) {
log.error("Netty服务器启动失败", e);
} finally {
shutdown();
}
}, "netty-server").start();
}
/**
* 关闭Netty服务器
* @PreDestroy 注解表示在Bean销毁前自动调用
*/
@PreDestroy
public void shutdown() {
log.info("正在关闭Netty服务器...");
if (serverChannel != null) {
serverChannel.close();
}
if (bossGroup != null) {
bossGroup.shutdownGracefully();
}
if (workerGroup != null) {
workerGroup.shutdownGracefully();
}
log.info("Netty服务器已关闭");
}
}
11.3 Netty相关概念说明
- EventLoopGroup: 事件循环组,处理I/O操作
- BossGroup: 处理客户端连接
- WorkerGroup: 处理已建立连接的I/O操作
- Channel: 网络连接的抽象
- ChannelPipeline: 处理器链,按顺序处理消息
- ChannelHandler: 消息处理器
- Bootstrap: 服务器启动器,配置服务器参数
第十二步:创建主启动类
12.1 编辑主启动类
编辑src/main/java/com/zhentao/im/ImApplication.java:
package com.zhentao.im;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* SpringBoot主启动类
* @SpringBootApplication 是一个组合注解,包含:
* - @Configuration: 标记为配置类
* - @EnableAutoConfiguration: 启用自动配置
* - @ComponentScan: 自动扫描组件
*/
@SpringBootApplication
public class ImApplication {
/**
* 程序入口点
* @param args 命令行参数
*/
public static void main(String[] args) {
SpringApplication.run(ImApplication.class, args);
}
}
12.2 启动类说明
- @SpringBootApplication: SpringBoot核心注解
- SpringApplication.run(): 启动SpringBoot应用
- 自动扫描当前包及子包下的所有组件
第十三步:数据库初始化
13.1 创建数据库
在MySQL中执行以下SQL创建数据库:
-- 创建数据库
CREATE DATABASE IF NOT EXISTS study_im
DEFAULT CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
-- 使用数据库
USE study_im;
13.2 JPA自动创建表
由于我们在application.yml中配置了ddl-auto: update,JPA会自动根据实体类创建数据库表:
- users表: 存储用户信息
- messages表: 存储聊天消息
13.3 插入测试数据(可选)
-- 插入测试用户
INSERT INTO users (username, password, nickname, create_time) VALUES
('user1', '123456', '用户1', NOW()),
('user2', '123456', '用户2', NOW());
-- 插入测试消息
INSERT INTO messages (from_user_id, to_user_id, content, send_time) VALUES
(1, 2, '你好!这是第一条测试消息', NOW()),
(2, 1, '你好!收到了', NOW());
第十四步:启动和测试后端
14.1 启动应用
- 确保MySQL和Redis服务正在运行
- 在IDE中运行ImApplication.java
- 或使用Maven命令:
mvn spring-boot:run
14.2 检查启动日志
启动成功后应该看到类似日志:
Started ImApplication in 3.456 seconds
Netty WebSocket服务器启动成功,端口: 9999
14.3 测试接口
使用Postman或浏览器测试:
- 健康检查: GET http://localhost:8080/api/health
- 用户注册: POST http://localhost:8080/api/auth/register
{ "username": "testuser", "password": "123456", "nickname": "测试用户" } - 用户登录: POST http://localhost:8080/api/auth/login
{ "username": "testuser", "password": "123456" }
14.4 WebSocket测试
可以使用在线WebSocket测试工具:
- 连接地址: ws://localhost:9999/ws
- 发送认证消息:
{ "type": "auth", "token": "your_jwt_token_here" } - 发送聊天消息:
{ "type": "chat", "toUserId": 2, "content": "Hello World!" } ```##
第十五步:创建UniApp前端项目
15.1 创建UniApp项目
- 打开HBuilderX
- 文件 -> 新建 -> 项目
- 选择uni-app项目
- 输入项目名称:uniappIm
- 选择默认模板
- 点击创建
15.2 项目结构说明
uniappIm/
├── pages/ # 页面目录
│ ├── index/ # 首页
│ ├── login/ # 登录页
│ ├── chat/ # 聊天列表页
│ └── chatDetail/ # 聊天详情页
├── static/ # 静态资源
├── utils/ # 工具类
│ ├── api.js # API接口封装
│ └── websocket.js # WebSocket封装
├── App.vue # 应用入口
├── main.js # 入口文件
├── manifest.json # 应用配置
└── pages.json # 页面配置
15.3 配置pages.json
编辑pages.json文件:
{
"pages": [
{
"path": "pages/login/login",
"style": {
"navigationBarTitleText": "登录",
"navigationStyle": "custom"
}
},
{
"path": "pages/chat/chat",
"style": {
"navigationBarTitleText": "聊天"
}
},
{
"path": "pages/chatDetail/chatDetail",
"style": {
"navigationBarTitleText": "聊天详情"
}
}
],
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "即时通讯",
"navigationBarBackgroundColor": "#F8F8F8",
"backgroundColor": "#F8F8F8"
},
"tabBar": {
"color": "#7A7E83",
"selectedColor": "#3cc51f",
"borderStyle": "black",
"backgroundColor": "#ffffff",
"list": [
{
"pagePath": "pages/chat/chat",
"iconPath": "static/chat.png",
"selectedIconPath": "static/chat-active.png",
"text": "聊天"
}
]
}
}
15.4 配置App.vue
编辑App.vue文件:
<template>
<view id="app">
<!-- 应用入口 -->
</view>
</template>
<script>
export default {
onLaunch: function() {
console.log('App Launch')
},
onShow: function() {
console.log('App Show')
},
onHide: function() {
console.log('App Hide')
}
}
</script>
<style>
/*每个页面公共css */
page {
background-color: #f5f5f5;
}
.container {
padding: 20rpx;
}
.btn-primary {
background-color: #007aff;
color: white;
border-radius: 10rpx;
padding: 20rpx;
text-align: center;
margin: 20rpx 0;
}
.input-group {
margin-bottom: 30rpx;
}
.input-group label {
display: block;
margin-bottom: 10rpx;
font-weight: bold;
}
.input-group input {
width: 100%;
padding: 20rpx;
border: 1px solid #ddd;
border-radius: 10rpx;
box-sizing: border-box;
}
</style>
第十六步:创建API工具类
16.1 创建API封装
在utils目录下创建api.js:
// API配置
const BASE_URL = 'http://localhost:8080/api'
/**
* 统一请求封装
* @param {string} url 请求地址
* @param {object} options 请求选项
* @returns {Promise} 请求Promise
*/
function request(url, options = {}) {
return new Promise((resolve, reject) => {
// 获取存储的token
const token = uni.getStorageSync('token')
// 发起请求
uni.request({
url: BASE_URL + url,
method: options.method || 'GET',
data: options.data,
header: {
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : '',
...options.header
},
success: (res) => {
resolve(res.data)
},
fail: (err) => {
reject(err)
}
})
})
}
/**
* 用户登录
* @param {object} data 登录数据 {username, password}
* @returns {Promise} 登录结果
*/
export function login(data) {
return request('/auth/login', {
method: 'POST',
data
})
}
/**
* 用户注册
* @param {object} data 注册数据 {username, password, nickname}
* @returns {Promise} 注册结果
*/
export function register(data) {
return request('/auth/register', {
method: 'POST',
data
})
}
/**
* 用户退出登录
* @returns {Promise} 退出结果
*/
export function logout() {
return request('/auth/logout', {
method: 'POST'
})
}
/**
* 获取聊天历史记录
* @param {number} toUserId 对方用户ID
* @returns {Promise} 聊天记录
*/
export function getChatHistory(toUserId) {
return request(`/messages/history/${toUserId}`)
}
/**
* 标记消息为已读
* @param {number} messageId 消息ID
* @returns {Promise} 操作结果
*/
export function markMessageAsRead(messageId) {
return request(`/messages/${messageId}/read`, {
method: 'POST'
})
}
16.2 API工具类说明
- BASE_URL: 后端API基础地址
- request(): 统一请求封装函数
- uni.request(): UniApp提供的网络请求API
- uni.getStorageSync(): 同步获取本地存储数据
- Promise: 异步操作封装
第十七步:创建WebSocket工具类
17.1 创建WebSocket封装
在utils目录下创建websocket.js:
let socketTask = null // WebSocket连接对象
let messageHandler = null // 消息处理函数
let reconnectTimer = null // 重连定时器
let heartbeatTimer = null // 心跳定时器
// WebSocket服务器地址
const WS_URL = 'ws://localhost:9999/ws'
/**
* 连接WebSocket
* @param {string} token JWT令牌
* @param {function} onMessage 消息处理回调函数
*/
export function connectWebSocket(token, onMessage) {
// 如果已有连接,先关闭
if (socketTask) {
socketTask.close()
}
messageHandler = onMessage
// 创建WebSocket连接
socketTask = uni.connectSocket({
url: WS_URL,
success: () => {
console.log('WebSocket连接成功')
},
fail: (err) => {
console.error('WebSocket连接失败:', err)
}
})
// 连接打开事件
socketTask.onOpen(() => {
console.log('WebSocket已打开')
// 发送认证消息
sendWebSocketMessage({
type: 'auth',
token: token
})
// 开始心跳
startHeartbeat()
})
// 接收消息事件
socketTask.onMessage((res) => {
try {
const message = JSON.parse(res.data)
console.log('收到WebSocket消息:', message)
if (message.type === 'auth_success') {
console.log('WebSocket认证成功')
} else if (message.type === 'heartbeat') {
console.log('心跳响应')
} else if (messageHandler) {
messageHandler(message)
}
} catch (error) {
console.error('解析WebSocket消息失败:', error)
}
})
// 连接关闭事件
socketTask.onClose(() => {
console.log('WebSocket连接关闭')
stopHeartbeat()
// 自动重连
if (!reconnectTimer) {
reconnectTimer = setTimeout(() => {
console.log('尝试重连WebSocket')
connectWebSocket(token, messageHandler)
reconnectTimer = null
}, 3000)
}
})
// 连接错误事件
socketTask.onError((err) => {
console.error('WebSocket错误:', err)
})
}
/**
* 发送WebSocket消息
* @param {object} message 要发送的消息对象
*/
export function sendWebSocketMessage(message) {
if (socketTask && socketTask.readyState === 1) {
socketTask.send({
data: JSON.stringify(message),
success: () => {
console.log('WebSocket消息发送成功:', message)
},
fail: (err) => {
console.error('WebSocket消息发送失败:', err)
}
})
} else {
console.error('WebSocket未连接')
}
}
/**
* 关闭WebSocket连接
*/
export function closeWebSocket() {
stopHeartbeat()
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
if (socketTask) {
socketTask.close()
socketTask = null
}
}
/**
* 开始心跳检测
*/
function startHeartbeat() {
heartbeatTimer = setInterval(() => {
sendWebSocketMessage({
type: 'heartbeat'
})
}, 30000) // 30秒心跳
}
/**
* 停止心跳检测
*/
function stopHeartbeat() {
if (heartbeatTimer) {
clearInterval(heartbeatTimer)
heartbeatTimer = null
}
}
17.2 WebSocket工具类说明
- uni.connectSocket(): UniApp提供的WebSocket连接API
- onOpen/onMessage/onClose/onError: WebSocket事件处理
- 心跳机制: 定期发送心跳消息保持连接
- 自动重连: 连接断开后自动尝试重连
- JSON.parse/stringify: JSON数据解析和序列化##
第十八步:创建登录页面
18.1 创建登录页面
在pages/login目录下创建login.vue:
<template>
<view class="login-container">
<!-- 登录头部 -->
<view class="login-header">
<text class="title">即时通讯</text>
<text class="subtitle">请登录您的账号</text>
</view>
<!-- 登录表单 -->
<view class="login-form">
<view class="input-group">
<input
v-model="loginForm.username"
placeholder="请输入用户名"
class="input-field"
/>
</view>
<view class="input-group">
<input
v-model="loginForm.password"
placeholder="请输入密码"
type="password"
class="input-field"
/>
</view>
<button @click="handleLogin" class="login-btn" :disabled="loading">
{{ loading ? '登录中...' : '登录' }}
</button>
<view class="register-link">
<text @click="handleRegister">还没有账号?点击注册</text>
</view>
</view>
<!-- 注册弹窗 -->
<view v-if="showRegisterModal" class="modal-overlay" @click="closeRegisterPopup">
<view class="register-popup" @click.stop>
<view class="popup-title">注册账号</view>
<view class="input-group">
<input
v-model="registerForm.username"
placeholder="请输入用户名"
class="input-field"
/>
</view>
<view class="input-group">
<input
v-model="registerForm.password"
placeholder="请输入密码"
type="password"
class="input-field"
/>
</view>
<view class="input-group">
<input
v-model="registerForm.nickname"
placeholder="请输入昵称(可选)"
class="input-field"
/>
</view>
<view class="popup-buttons">
<button @click="closeRegisterPopup" class="cancel-btn">取消</button>
<button @click="submitRegister" class="confirm-btn">注册</button>
</view>
</view>
</view>
</view>
</template>
<script>
import { login, register } from '@/utils/api.js'
export default {
data() {
return {
loading: false, // 登录加载状态
showRegisterModal: false, // 注册弹窗显示状态
loginForm: { // 登录表单数据
username: '',
password: ''
},
registerForm: { // 注册表单数据
username: '',
password: '',
nickname: ''
}
}
},
methods: {
/**
* 处理登录
*/
async handleLogin() {
// 表单验证
if (!this.loginForm.username || !this.loginForm.password) {
uni.showToast({
title: '请填写完整信息',
icon: 'none'
})
return
}
this.loading = true
try {
// 调用登录API
const result = await login(this.loginForm)
if (result.code === 200) {
// 保存token和用户信息到本地存储
uni.setStorageSync('token', result.data.token)
uni.setStorageSync('userInfo', result.data.user)
uni.showToast({
title: '登录成功',
icon: 'success'
})
// 跳转到聊天页面
uni.reLaunch({
url: '/pages/chat/chat'
})
} else {
uni.showToast({
title: result.message,
icon: 'none'
})
}
} catch (error) {
uni.showToast({
title: '登录失败',
icon: 'none'
})
} finally {
this.loading = false
}
},
/**
* 显示注册弹窗
*/
handleRegister() {
this.showRegisterModal = true
},
/**
* 关闭注册弹窗
*/
closeRegisterPopup() {
this.showRegisterModal = false
this.registerForm = {
username: '',
password: '',
nickname: ''
}
},
/**
* 提交注册
*/
async submitRegister() {
// 表单验证
if (!this.registerForm.username || !this.registerForm.password) {
uni.showToast({
title: '请填写完整信息',
icon: 'none'
})
return
}
try {
// 调用注册API
const result = await register(this.registerForm)
if (result.code === 200) {
uni.showToast({
title: '注册成功',
icon: 'success'
})
this.closeRegisterPopup()
// 自动填充登录表单
this.loginForm.username = this.registerForm.username
this.loginForm.password = this.registerForm.password
} else {
uni.showToast({
title: result.message,
icon: 'none'
})
}
} catch (error) {
uni.showToast({
title: '注册失败',
icon: 'none'
})
}
}
}
}
</script>
<style scoped>
.login-container {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
flex-direction: column;
justify-content: center;
padding: 40rpx;
}
.login-header {
text-align: center;
margin-bottom: 80rpx;
}
.title {
font-size: 60rpx;
font-weight: bold;
color: white;
display: block;
margin-bottom: 20rpx;
}
.subtitle {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.8);
}
.login-form {
background: white;
border-radius: 20rpx;
padding: 60rpx 40rpx;
box-shadow: 0 10rpx 30rpx rgba(0, 0, 0, 0.1);
}
.input-group {
margin-bottom: 40rpx;
}
.input-field {
width: 100%;
height: 80rpx;
border: 2rpx solid #e0e0e0;
border-radius: 10rpx;
padding: 0 20rpx;
font-size: 28rpx;
box-sizing: border-box;
}
.input-field:focus {
border-color: #667eea;
}
.login-btn {
width: 100%;
height: 80rpx;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 10rpx;
font-size: 32rpx;
font-weight: bold;
margin-bottom: 40rpx;
}
.login-btn:disabled {
opacity: 0.6;
}
.register-link {
text-align: center;
}
.register-link text {
color: #667eea;
font-size: 26rpx;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.register-popup {
width: 600rpx;
background: white;
border-radius: 20rpx;
padding: 40rpx;
margin: 40rpx;
}
.popup-title {
text-align: center;
font-size: 36rpx;
font-weight: bold;
margin-bottom: 40rpx;
}
.popup-buttons {
display: flex;
justify-content: space-between;
margin-top: 40rpx;
}
.cancel-btn, .confirm-btn {
width: 45%;
height: 70rpx;
border-radius: 10rpx;
font-size: 28rpx;
}
.cancel-btn {
background: #f5f5f5;
color: #666;
border: none;
}
.confirm-btn {
background: #667eea;
color: white;
border: none;
}
</style>
18.2 登录页面功能说明
- 双向数据绑定: 使用v-model绑定表单数据
- 事件处理: @click绑定点击事件
- 条件渲染: v-if控制注册弹窗显示
- 本地存储: uni.setStorageSync保存token和用户信息
- 页面跳转: uni.reLaunch跳转到聊天页面
- 消息提示: uni.showToast显示操作结果
第十九步:创建聊天列表页面
19.1 创建聊天列表页面
在pages/chat目录下创建chat.vue:
<template>
<view class="chat-container">
<!-- 聊天头部 -->
<view class="chat-header">
<text class="header-title">聊天列表</text>
<text @click="logout" class="logout-btn">退出</text>
</view>
<!-- 用户列表 -->
<view class="user-list">
<view
v-for="user in userList"
:key="user.id"
@click="openChat(user)"
class="user-item"
>
<!-- 用户头像 -->
<view class="avatar">
<text>{{ user.nickname.charAt(0) }}</text>
</view>
<!-- 用户信息 -->
<view class="user-info">
<text class="username">{{ user.nickname }}</text>
<text class="last-message">点击开始聊天</text>
</view>
<!-- 在线状态 -->
<view class="status" :class="{ online: user.online }"></view>
</view>
</view>
<!-- 空状态 -->
<view v-if="userList.length === 0" class="empty-state">
<text>暂无其他用户</text>
</view>
</view>
</template>
<script>
export default {
data() {
return {
userInfo: null, // 当前用户信息
userList: [ // 用户列表(模拟数据)
{ id: 1, nickname: '用户1', online: true },
{ id: 2, nickname: '用户2', online: false }
]
}
},
onLoad() {
this.checkLogin()
this.loadUserInfo()
},
methods: {
/**
* 检查登录状态
*/
checkLogin() {
const token = uni.getStorageSync('token')
if (!token) {
uni.reLaunch({
url: '/pages/login/login'
})
}
},
/**
* 加载用户信息
*/
loadUserInfo() {
this.userInfo = uni.getStorageSync('userInfo')
// 过滤掉当前用户
if (this.userInfo) {
this.userList = this.userList.filter(user => user.id !== this.userInfo.id)
}
},
/**
* 打开聊天窗口
* @param {object} user 用户对象
*/
openChat(user) {
uni.navigateTo({
url: `/pages/chatDetail/chatDetail?userId=${user.id}&nickname=${user.nickname}`
})
},
/**
* 退出登录
*/
logout() {
uni.showModal({
title: '提示',
content: '确定要退出登录吗?',
success: (res) => {
if (res.confirm) {
// 清除本地存储
uni.clearStorageSync()
// 跳转到登录页
uni.reLaunch({
url: '/pages/login/login'
})
}
}
})
}
}
}
</script>
<style scoped>
.chat-container {
height: 100vh;
background: #f5f5f5;
}
.chat-header {
background: white;
padding: 20rpx 30rpx;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1rpx solid #e0e0e0;
}
.header-title {
font-size: 36rpx;
font-weight: bold;
}
.logout-btn {
color: #ff4757;
font-size: 28rpx;
}
.user-list {
padding: 20rpx 0;
}
.user-item {
background: white;
padding: 30rpx;
margin-bottom: 2rpx;
display: flex;
align-items: center;
}
.avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
background: #667eea;
display: flex;
align-items: center;
justify-content: center;
margin-right: 20rpx;
}
.avatar text {
color: white;
font-size: 32rpx;
font-weight: bold;
}
.user-info {
flex: 1;
}
.username {
font-size: 32rpx;
font-weight: bold;
display: block;
margin-bottom: 10rpx;
}
.last-message {
font-size: 26rpx;
color: #999;
}
.status {
width: 20rpx;
height: 20rpx;
border-radius: 50%;
background: #ccc;
}
.status.online {
background: #2ed573;
}
.empty-state {
text-align: center;
padding: 100rpx 0;
color: #999;
}
</style>
```## 第二十
步:创建聊天详情页面
### 20.1 创建聊天详情页面
在pages/chatDetail目录下创建chatDetail.vue:
```vue
<template>
<view class="chat-detail">
<!-- 消息列表 -->
<scroll-view
class="message-list"
:scroll-top="scrollTop"
scroll-y="true"
:scroll-into-view="scrollIntoView"
>
<view
v-for="message in messages"
:key="message.id"
:id="'msg-' + message.id"
class="message-item"
:class="{ 'own-message': message.fromUserId === userInfo.id }"
>
<!-- 消息内容 -->
<view class="message-content">
<text>{{ message.content }}</text>
</view>
<!-- 消息时间 -->
<view class="message-time">
{{ formatTime(message.sendTime) }}
</view>
</view>
</scroll-view>
<!-- 输入区域 -->
<view class="input-area">
<input
v-model="inputMessage"
placeholder="输入消息..."
class="message-input"
@confirm="sendMessage"
confirm-type="send"
/>
<button @click="sendMessage" class="send-btn">发送</button>
</view>
</view>
</template>
<script>
import { getChatHistory } from '@/utils/api.js'
import { connectWebSocket, sendWebSocketMessage, closeWebSocket } from '@/utils/websocket.js'
export default {
data() {
return {
userId: null, // 对方用户ID
nickname: '', // 对方昵称
userInfo: null, // 当前用户信息
messages: [], // 消息列表
inputMessage: '', // 输入的消息
scrollTop: 0, // 滚动位置
scrollIntoView: '' // 滚动到指定元素
}
},
onLoad(options) {
// 获取页面参数
this.userId = parseInt(options.userId)
this.nickname = options.nickname
this.userInfo = uni.getStorageSync('userInfo')
// 设置导航栏标题
uni.setNavigationBarTitle({
title: this.nickname
})
// 初始化
this.loadChatHistory()
this.initWebSocket()
},
onUnload() {
// 页面卸载时关闭WebSocket连接
closeWebSocket()
},
methods: {
/**
* 加载聊天历史记录
*/
async loadChatHistory() {
try {
const result = await getChatHistory(this.userId)
if (result.code === 200) {
this.messages = result.data
this.scrollToBottom()
}
} catch (error) {
console.error('加载聊天记录失败:', error)
}
},
/**
* 初始化WebSocket连接
*/
initWebSocket() {
const token = uni.getStorageSync('token')
connectWebSocket(token, (message) => {
// 处理接收到的消息
if (message.type === 'chat') {
// 只显示与当前聊天对象相关的消息
if (message.fromUserId === this.userId || message.toUserId === this.userId) {
this.messages.push({
id: message.messageId,
fromUserId: message.fromUserId,
toUserId: message.toUserId,
content: message.content,
sendTime: message.sendTime
})
this.scrollToBottom()
}
}
})
},
/**
* 发送消息
*/
sendMessage() {
if (!this.inputMessage.trim()) {
return
}
// 构造消息对象
const message = {
type: 'chat',
toUserId: this.userId,
content: this.inputMessage
}
// 发送WebSocket消息
sendWebSocketMessage(message)
// 添加到本地消息列表(乐观更新)
this.messages.push({
id: Date.now(), // 临时ID
fromUserId: this.userInfo.id,
toUserId: this.userId,
content: this.inputMessage,
sendTime: new Date().toISOString()
})
// 清空输入框并滚动到底部
this.inputMessage = ''
this.scrollToBottom()
},
/**
* 格式化时间显示
* @param {string} timeStr 时间字符串
* @returns {string} 格式化后的时间
*/
formatTime(timeStr) {
const time = new Date(timeStr)
const now = new Date()
const diff = now - time
if (diff < 60000) { // 1分钟内
return '刚刚'
} else if (diff < 3600000) { // 1小时内
return Math.floor(diff / 60000) + '分钟前'
} else if (diff < 86400000) { // 24小时内
return Math.floor(diff / 3600000) + '小时前'
} else {
// 超过24小时显示具体日期
return time.toLocaleDateString()
}
},
/**
* 滚动到底部
*/
scrollToBottom() {
this.$nextTick(() => {
if (this.messages.length > 0) {
const lastMessage = this.messages[this.messages.length - 1]
this.scrollIntoView = 'msg-' + lastMessage.id
}
})
}
}
}
</script>
<style scoped>
.chat-detail {
height: 100vh;
display: flex;
flex-direction: column;
background: #f5f5f5;
}
.message-list {
flex: 1;
padding: 20rpx;
}
.message-item {
margin-bottom: 30rpx;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.message-item.own-message {
align-items: flex-end;
}
.message-content {
max-width: 70%;
padding: 20rpx 30rpx;
border-radius: 20rpx;
background: white;
word-wrap: break-word;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.1);
}
.own-message .message-content {
background: #667eea;
color: white;
}
.message-time {
font-size: 22rpx;
color: #999;
margin-top: 10rpx;
}
.input-area {
background: white;
padding: 20rpx;
display: flex;
align-items: center;
border-top: 1rpx solid #e0e0e0;
box-shadow: 0 -2rpx 10rpx rgba(0, 0, 0, 0.1);
}
.message-input {
flex: 1;
height: 70rpx;
border: 1rpx solid #e0e0e0;
border-radius: 35rpx;
padding: 0 30rpx;
margin-right: 20rpx;
font-size: 28rpx;
}
.send-btn {
width: 120rpx;
height: 70rpx;
background: #667eea;
color: white;
border: none;
border-radius: 35rpx;
font-size: 28rpx;
}
</style>
20.2 聊天详情页面功能说明
- 消息列表: 使用scroll-view实现滚动
- 消息气泡: 区分自己和对方的消息样式
- 实时通信: WebSocket接收和发送消息
- 时间格式化: 智能显示消息时间
- 自动滚动: 新消息自动滚动到底部
- 乐观更新: 发送消息立即显示,提升用户体验
第二十一步:添加静态资源
21.1 添加聊天图标
在static目录下添加以下图标文件:
- chat.png - 聊天图标(未选中状态)
- chat-active.png - 聊天图标(选中状态)
可以使用在线图标生成工具或设计软件创建,建议尺寸为64x64像素。
21.2 图标说明
- iconPath: TabBar未选中时的图标
- selectedIconPath: TabBar选中时的图标
- 支持PNG、JPG格式
- 建议使用PNG格式以支持透明背景
第二十二步:测试和调试
22.1 启动前端项目
- 在HBuilderX中打开uniappIm项目
- 点击"运行" -> “运行到浏览器” -> “Chrome”
- 或者运行到手机模拟器进行测试
22.2 功能测试流程
-
注册测试:
- 打开应用,点击"点击注册"
- 输入用户名、密码、昵称
- 点击注册按钮
-
登录测试:
- 输入注册的用户名和密码
- 点击登录按钮
- 成功后跳转到聊天列表页
-
聊天测试:
- 在聊天列表中点击用户
- 进入聊天详情页
- 输入消息并发送
- 使用另一个浏览器窗口登录不同用户进行对话测试
22.3 调试技巧
- 浏览器开发者工具: 查看网络请求和WebSocket连接
- console.log: 在代码中添加日志输出
- uni.showToast: 显示调试信息
- 后端日志: 查看SpringBoot控制台输出
第二十三步:部署和优化
23.1 后端部署
-
打包应用:
mvn clean package -DskipTests -
运行JAR包:
java -jar target/Study-Im-1.0-SNAPSHOT.jar -
配置生产环境:
- 修改application.yml中的数据库和Redis配置
- 使用环境变量或配置文件管理敏感信息
23.2 前端部署
-
H5部署:
- 在HBuilderX中选择"发行" -> “网站-PC Web或手机H5”
- 将生成的dist目录部署到Web服务器
-
小程序部署:
- 选择"发行" -> “小程序-微信”
- 使用微信开发者工具上传代码
-
App部署:
- 选择"发行" -> “原生App-云打包”
- 配置应用信息和证书
23.3 性能优化建议
-
后端优化:
- 数据库索引优化
- Redis缓存策略
- 连接池配置
- 日志级别调整
-
前端优化:
- 图片压缩和懒加载
- 消息分页加载
- WebSocket重连策略
- 本地存储优化
总结
通过以上23个步骤,我们完成了一个完整的即时通讯系统的开发:
后端技术要点
- SpringBoot框架: 快速构建Web应用
- JPA数据访问: 简化数据库操作
- Redis缓存: 用户状态管理
- JWT认证: 安全的用户身份验证
- Netty WebSocket: 高性能实时通信
前端技术要点
- UniApp框架: 跨平台开发
- Vue.js: 响应式数据绑定
- WebSocket: 实时双向通信
- 本地存储: 用户状态持久化
- 组件化开发: 可维护的代码结构
系统特性
- ✅ 用户注册和登录
- ✅ 实时消息发送和接收
- ✅ 消息历史记录
- ✅ 用户在线状态
- ✅ 自动重连机制
- ✅ 跨平台支持
这个项目为学习SpringBoot、Netty和UniApp提供了一个完整的实践案例,涵盖了现代Web应用开发的核心技术栈。
附录:项目中每个类的详细作用说明
🏗️ 架构设计说明
在深入了解每个类之前,先理解整个系统的架构:
┌─────────────────┐ HTTP/WebSocket ┌─────────────────┐
│ UniApp前端 │ ◄─────────────────► │ SpringBoot后端 │
│ │ │ │
│ ├─ 登录页面 │ │ ├─ 控制器层 │
│ ├─ 聊天列表 │ │ ├─ 业务逻辑层 │
│ ├─ 聊天详情 │ │ ├─ 数据访问层 │
│ └─ WebSocket │ │ └─ Netty服务器 │
└─────────────────┘ └─────────────────┘
│
┌─────────┴─────────┐
│ │
┌────▼────┐ ┌───▼───┐
│ MySQL │ │ Redis │
│ 数据库 │ │ 缓存 │
└─────────┘ └───────┘
🎯 后端类详细说明
1. 主启动类
ImApplication.java - 应用程序入口
@SpringBootApplication
public class ImApplication
存在意义:
- 🚀 系统启动器:整个SpringBoot应用的启动入口点
- 🔧 自动配置触发器:通过@SpringBootApplication注解启动Spring的自动配置机制
- 📦 组件扫描根节点:从这个类所在的包开始扫描所有Spring组件
在项目中的角色:
- 生命周期管理者:控制整个应用的启动和关闭
- 配置聚合点:汇聚所有的配置信息并初始化Spring容器
- 服务协调者:协调HTTP服务器(Tomcat)和WebSocket服务器(Netty)同时启动
为什么需要这个类:
- SpringBoot需要一个明确的入口点来启动应用
- 提供了统一的应用生命周期管理
- 简化了复杂的Spring配置过程
2. 实体类层 (Entity Layer)
User.java - 用户实体类
@Entity
@Table(name = "users")
public class User
存在意义:
- 👤 用户数据模型:定义了系统中用户的数据结构
- 🗃️ 数据库映射:通过JPA注解与数据库表建立映射关系
- 🔐 业务实体:承载用户相关的所有业务属性
在项目中的角色:
- 数据载体:存储用户的基本信息(用户名、密码、昵称等)
- 状态管理器:管理用户的在线状态(在线/离线/忙碌)
- 认证基础:为用户登录认证提供数据基础
为什么需要这个类:
- 即时通讯系统必须要有用户概念,需要区分不同的聊天参与者
- 需要持久化存储用户信息,方便用户登录和身份识别
- 用户状态管理是即时通讯的核心功能之一
关键字段说明:
id: 用户唯一标识,用于消息关联username/password: 登录凭证nickname: 聊天时显示的名称status: 当前在线状态,影响其他用户看到的状态lastLoginTime: 用于统计和状态判断
Message.java - 消息实体类
@Entity
@Table(name = "messages")
public class Message
存在意义:
- 💬 消息数据模型:定义了聊天消息的完整数据结构
- 📝 通信记录:保存所有的聊天历史,实现消息持久化
- 🔗 关系纽带:连接发送者和接收者,建立通信关系
在项目中的角色:
- 消息载体:承载消息内容、时间、状态等信息
- 历史记录器:保存聊天历史,支持消息回溯
- 状态跟踪器:跟踪消息的发送、送达、已读状态
为什么需要这个类:
- 即时通讯的核心就是消息传递,必须有消息实体
- 用户需要查看历史聊天记录
- 需要跟踪消息状态,提供更好的用户体验
关键字段说明:
fromUserId/toUserId: 建立消息的发送和接收关系content: 消息的实际内容type: 支持不同类型的消息(文本、图片、文件等)status: 消息状态跟踪(已发送、已送达、已读)sendTime/readTime: 时间戳,用于排序和状态判断
3. 数据传输对象层 (DTO Layer)
ApiResponse.java - 统一响应格式
public class ApiResponse<T>
存在意义:
- 📋 响应标准化:为所有API接口提供统一的响应格式
- 🎯 错误处理统一:标准化成功和失败的响应结构
- 🔧 前端适配:让前端能够统一处理所有接口响应
在项目中的角色:
- 响应包装器:包装所有API的返回数据
- 状态传达者:通过code字段明确告知操作结果
- 数据载体:通过泛型承载不同类型的响应数据
为什么需要这个类:
- 前端需要统一的方式来判断接口调用是否成功
- 便于错误处理和用户提示
- 提高代码的可维护性和一致性
设计优势:
- 使用泛型支持任意类型的数据返回
- 提供静态方法简化响应对象的创建
- 标准化的错误信息传递
LoginRequest.java - 登录请求DTO
public class LoginRequest
存在意义:
- 🔐 登录数据载体:专门承载用户登录时提交的数据
- ✅ 数据验证:通过注解进行输入数据的合法性验证
- 🛡️ 安全边界:明确定义登录接口接受的数据范围
在项目中的角色:
- 数据接收器:接收前端提交的登录表单数据
- 验证执行者:执行用户名和密码的基础验证
- 业务传递者:将验证后的数据传递给业务逻辑层
为什么需要这个类:
- 登录是系统的关键入口,需要专门的数据结构
- 分离关注点,登录数据不应该直接使用User实体
- 便于添加登录特有的验证规则
RegisterRequest.java - 注册请求DTO
public class RegisterRequest
存在意义:
- 📝 注册数据载体:专门处理用户注册时的数据提交
- 🔍 注册验证:对注册数据进行专门的验证处理
- 🎨 灵活扩展:可以包含注册特有的字段(如验证码、邀请码等)
在项目中的角色:
- 注册数据接收器:接收用户注册表单数据
- 业务数据转换器:将DTO数据转换为User实体
- 扩展预留:为未来的注册功能扩展预留空间
为什么需要这个类:
- 注册和登录虽然都涉及用户,但业务逻辑不同
- 注册可能需要额外的字段(昵称、邮箱验证等)
- 保持代码的清晰性和可维护性
MessageDto.java - 消息传输DTO
public class MessageDto
存在意义:
- 📤 消息传输载体:专门用于前后端之间的消息数据传输
- 🔄 数据转换桥梁:在Message实体和前端数据之间进行转换
- 📊 信息聚合:聚合消息相关的用户信息,减少前端查询
在项目中的角色:
- 数据适配器:将数据库实体适配为前端需要的格式
- 信息整合器:整合消息和用户信息,提供完整的显示数据
- 传输优化器:只传输前端需要的字段,优化网络传输
为什么需要这个类:
- 前端显示消息时需要发送者和接收者的用户名
- 避免前端进行复杂的数据关联查询
- 控制传输的数据量,提高性能
4. 数据访问层 (Repository Layer)
UserRepository.java - 用户数据访问接口
public interface UserRepository extends JpaRepository<User, Long>
存在意义:
- 🗄️ 数据访问抽象:为用户相关的数据库操作提供抽象接口
- 🔍 查询方法定义:定义用户相关的各种查询方法
- 🚀 自动实现:利用Spring Data JPA的自动实现机制
在项目中的角色:
- 数据库操作代理:代理所有用户相关的数据库操作
- 查询方法提供者:提供根据用户名查找、检查用户存在等方法
- 事务边界:自动处理数据库事务
为什么需要这个类:
- 用户登录需要根据用户名查找用户
- 注册时需要检查用户名是否已存在
- 提供标准的CRUD操作,简化数据访问代码
关键方法说明:
findByUsername(): 登录时根据用户名查找用户existsByUsername(): 注册时检查用户名是否已存在- 继承的CRUD方法:save(), findById(), delete()等
MessageRepository.java - 消息数据访问接口
public interface MessageRepository extends JpaRepository<Message, Long>
存在意义:
- 💬 消息数据访问:专门处理消息相关的数据库操作
- 📚 聊天历史查询:提供复杂的聊天记录查询功能
- 🔗 关系查询:处理用户之间的消息关联查询
在项目中的角色:
- 消息存储器:保存新发送的消息到数据库
- 历史查询器:查询两个用户之间的聊天历史
- 消息管理器:提供消息的各种管理功能
为什么需要这个类:
- 即时通讯需要保存所有消息记录
- 用户需要查看历史聊天记录
- 需要支持复杂的消息查询(按时间、按用户等)
关键方法说明:
findChatHistory(): 查询两个用户之间的完整聊天记录findByToUserIdOrderBySendTimeDesc(): 查询发给指定用户的消息
5. 业务逻辑层 (Service Layer)
UserService.java - 用户业务逻辑服务
@Service
public class UserService
存在意义:
- 🧠 业务逻辑中心:处理所有用户相关的业务逻辑
- 🔐 认证服务提供者:提供用户注册、登录、认证等核心功能
- 🔄 状态管理器:管理用户的在线状态和会话状态
在项目中的角色:
- 认证协调者:协调用户认证流程,整合JWT、Redis等组件
- 业务规则执行者:执行用户相关的业务规则(如用户名唯一性)
- 状态同步器:同步用户状态到数据库和Redis
为什么需要这个类:
- 用户认证涉及多个组件的协调(数据库、JWT、Redis)
- 需要统一的地方处理用户相关的业务逻辑
- 为控制器层提供高级的业务操作接口
核心方法说明:
register(): 用户注册流程,包括验证、保存、生成tokenlogin(): 用户登录流程,包括验证、状态更新、token生成logout(): 用户退出流程,清理状态信息isUserOnline(): 检查用户在线状态
业务逻辑复杂性:
- 注册时需要检查用户名唯一性
- 登录时需要验证密码、更新状态、生成JWT、设置Redis
- 需要处理Redis连接失败的备用方案
MessageService.java - 消息业务逻辑服务
@Service
public class MessageService
存在意义:
- 💬 消息业务中心:处理所有消息相关的业务逻辑
- 🔄 数据转换器:在实体对象和DTO之间进行转换
- 📊 消息聚合器:聚合消息和用户信息,提供完整的消息数据
在项目中的角色:
- 消息处理器:处理消息的保存、查询、转换等操作
- 数据整合器:整合消息数据和用户数据,提供完整信息
- 业务规则执行者:执行消息相关的业务规则
为什么需要这个类:
- 消息处理涉及复杂的数据转换和聚合
- 需要统一的地方处理消息相关的业务逻辑
- 为WebSocket和HTTP接口提供统一的消息服务
核心方法说明:
saveMessage(): 保存消息到数据库getChatHistory(): 获取聊天历史,包含用户信息convertToDto(): 将Message实体转换为MessageDto
InMemoryUserStatusService.java - 内存用户状态服务
@Service
public class InMemoryUserStatusService
存在意义:
- 🔄 Redis备用方案:当Redis不可用时提供用户状态管理
- 💾 内存状态管理:使用内存数据结构管理用户在线状态
- ⏰ 自动清理机制:定时清理过期的用户状态
在项目中的角色:
- 状态备份器:作为Redis的备用状态存储
- 容错保障:确保Redis故障时系统仍能正常工作
- 性能优化器:提供快速的内存状态查询
为什么需要这个类:
- 提高系统的可用性,避免Redis故障导致系统不可用
- 在开发环境中可能没有Redis,需要备用方案
- 演示了如何设计容错机制
设计特点:
- 使用ConcurrentHashMap保证线程安全
- 定时任务自动清理过期状态
- 与Redis接口保持一致,便于切换
6. 控制器层 (Controller Layer)
AuthController.java - 认证控制器
@RestController
@RequestMapping("/api/auth")
public class AuthController
存在意义:
- 🚪 认证入口:提供用户认证相关的HTTP接口
- 🔗 前后端桥梁:连接前端认证请求和后端认证服务
- 📋 接口标准化:提供标准化的RESTful认证接口
在项目中的角色:
- 请求接收器:接收前端的登录、注册请求
- 数据验证器:验证请求数据的合法性
- 响应生成器:生成标准化的API响应
为什么需要这个类:
- 前端需要HTTP接口来进行用户认证
- 需要统一的地方处理认证相关的HTTP请求
- 提供RESTful风格的认证API
接口设计:
POST /api/auth/register: 用户注册POST /api/auth/login: 用户登录POST /api/auth/logout: 用户退出
MessageController.java - 消息控制器
@RestController
@RequestMapping("/api/messages")
public class MessageController
存在意义:
- 📨 消息接口提供者:提供消息相关的HTTP接口
- 📚 历史查询入口:为前端提供聊天历史查询接口
- 🔐 权限验证器:验证用户是否有权限查看特定消息
在项目中的角色:
- 历史服务器:提供聊天历史记录查询服务
- 权限守护者:确保用户只能查看自己相关的消息
- 数据提供者:为前端提供格式化的消息数据
为什么需要这个类:
- 前端需要HTTP接口来获取聊天历史
- 需要验证用户权限,保护消息隐私
- WebSocket主要用于实时通信,HTTP用于历史数据查询
HealthController.java - 健康检查控制器
@RestController
@RequestMapping("/api/health")
public class HealthController
存在意义:
- 🏥 系统健康监控:提供系统各组件的健康状态检查
- 🔧 运维支持:为运维人员提供系统状态监控接口
- 🚨 故障诊断:帮助快速定位系统故障
在项目中的角色:
- 状态检查器:检查数据库、Redis等组件的连接状态
- 监控端点:为监控系统提供健康检查端点
- 诊断工具:帮助开发和运维人员诊断问题
为什么需要这个类:
- 生产环境需要监控系统健康状态
- 便于快速定位系统故障
- 符合微服务架构的最佳实践
7. 网络通信层 (Netty Layer)
NettyServer.java - Netty服务器
@Component
public class NettyServer
存在意义:
- 🌐 WebSocket服务器:提供高性能的WebSocket连接服务
- 🔧 网络配置管理:管理网络服务器的启动、配置和关闭
- ⚡ 高并发支持:利用Netty的高性能特性支持大量并发连接
在项目中的角色:
- 实时通信基础设施:为即时通讯提供底层网络支持
- 连接管理器:管理所有WebSocket连接的生命周期
- 性能优化器:通过Netty提供高性能的网络I/O
为什么需要这个类:
- 即时通讯需要实时双向通信,WebSocket是最佳选择
- Netty提供比传统Servlet更好的性能和并发能力
- 需要独立的WebSocket服务器来处理实时消息
技术特点:
- 使用NIO模型提高并发性能
- 事件驱动架构,响应速度快
- 支持大量并发连接
WebSocketHandler.java - WebSocket消息处理器
@Component
public class WebSocketHandler extends SimpleChannelInboundHandler<TextWebSocketFrame>
存在意义:
- 📨 消息处理中心:处理所有WebSocket消息的接收和分发
- 🔐 连接认证管理:管理WebSocket连接的身份认证
- 🔄 消息路由器:将消息路由到正确的接收者
在项目中的角色:
- 消息分发器:接收消息并转发给目标用户
- 连接管理器:管理用户连接的建立、维护和断开
- 协议处理器:处理不同类型的WebSocket消息
为什么需要这个类:
- WebSocket连接需要专门的处理器来处理消息
- 需要实现消息的实时转发功能
- 需要管理用户连接和身份认证
核心功能:
- 用户身份认证(通过JWT token)
- 实时消息转发
- 心跳检测和连接管理
- 错误处理和异常恢复
消息类型处理:
auth: 身份认证消息chat: 聊天消息heartbeat: 心跳检测消息
8. 配置层 (Configuration Layer)
RedisConfig.java - Redis配置类
@Configuration
public class RedisConfig
存在意义:
- ⚙️ Redis配置中心:统一配置Redis相关的所有设置
- 🔧 序列化配置:配置Redis数据的序列化和反序列化方式
- 🎯 Bean管理:将Redis相关的Bean注册到Spring容器
在项目中的角色:
- 配置提供者:为Redis操作提供配置信息
- 序列化管理器:管理Redis数据的存储格式
- 连接配置器:配置Redis连接参数
为什么需要这个类:
- Redis默认配置可能不适合项目需求
- 需要配置合适的序列化方式以支持复杂对象存储
- 统一管理Redis相关配置,便于维护
配置要点:
- Key使用String序列化,便于查看和调试
- Value使用JSON序列化,支持复杂对象
- 配置连接池参数优化性能
9. 工具类层 (Utility Layer)
JwtUtil.java - JWT工具类
@Component
public class JwtUtil
存在意义:
- 🔐 JWT操作封装:封装JWT token的生成、验证、解析等操作
- 🛡️ 安全工具:为系统提供安全的用户身份验证机制
- 🔧 配置集中化:集中管理JWT相关的配置参数
在项目中的角色:
- Token生成器:为用户登录生成JWT token
- Token验证器:验证token的有效性和完整性
- 信息提取器:从token中提取用户信息
为什么需要这个类:
- 用户认证需要安全可靠的token机制
- JWT是无状态认证的最佳选择
- 需要统一的地方管理token相关操作
核心功能:
- 生成包含用户信息的JWT token
- 验证token的签名和有效期
- 从token中提取用户ID和用户名
- 检查token是否过期
🎨 前端类详细说明
1. 工具类层
api.js - API接口封装
// API配置和请求封装
存在意义:
- 🌐 接口统一管理:统一管理所有后端API接口调用
- 🔧 请求标准化:标准化HTTP请求的发送方式
- 🛡️ 认证集成:自动添加JWT token到请求头
在项目中的角色:
- 接口代理:代理所有后端接口调用
- 认证管理器:自动处理用户认证token
- 错误处理器:统一处理接口调用错误
为什么需要这个文件:
- 避免在每个页面重复写接口调用代码
- 统一管理API地址,便于环境切换
- 自动处理认证token,简化页面代码
websocket.js - WebSocket封装
// WebSocket连接和消息处理封装
存在意义:
- 🔌 WebSocket管理:封装WebSocket连接的建立、维护和关闭
- 💓 心跳机制:实现心跳检测,保持连接稳定
- 🔄 自动重连:连接断开时自动尝试重连
在项目中的角色:
- 连接管理器:管理WebSocket连接的整个生命周期
- 消息路由器:处理不同类型的WebSocket消息
- 稳定性保障:通过心跳和重连机制保证连接稳定
为什么需要这个文件:
- WebSocket连接管理比较复杂,需要专门封装
- 即时通讯需要稳定的连接,必须有心跳和重连机制
- 统一处理WebSocket消息,避免代码重复
2. 页面组件层
login.vue - 登录页面
<template>登录界面</template>
存在意义:
- 🚪 系统入口:用户进入系统的第一个页面
- 🔐 身份验证界面:提供用户登录和注册的界面
- 🎨 用户体验:提供友好的登录体验
在项目中的角色:
- 认证入口:用户身份验证的起始点
- 数据收集器:收集用户的登录凭证
- 状态管理器:管理登录状态和用户信息
为什么需要这个页面:
- 即时通讯系统需要识别用户身份
- 需要友好的界面让用户输入登录信息
- 作为系统的安全入口,控制访问权限
chat.vue - 聊天列表页面
<template>聊天列表</template>
存在意义:
- 📋 用户列表展示:显示可以聊天的用户列表
- 🏠 主页面:用户登录后的主要操作界面
- 🔄 状态显示:显示其他用户的在线状态
在项目中的角色:
- 导航中心:用户选择聊天对象的中心页面
- 状态展示器:展示用户的在线状态
- 功能入口:提供退出登录等功能入口
为什么需要这个页面:
- 用户需要选择聊天对象
- 需要显示其他用户的在线状态
- 作为应用的主页面,提供导航功能
chatDetail.vue - 聊天详情页面
<template>聊天界面</template>
存在意义:
- 💬 聊天核心界面:实现实际的聊天功能
- 📚 消息展示器:显示聊天历史和实时消息
- ⌨️ 消息输入器:提供消息输入和发送功能
在项目中的角色:
- 通信界面:用户进行实时通信的主要界面
- 消息管理器:管理消息的显示、发送和接收
- WebSocket客户端:与后端WebSocket服务器通信
为什么需要这个页面:
- 这是即时通讯的核心功能页面
- 需要实时显示和发送消息
- 需要良好的用户体验来展示聊天内容
🔗 类之间的协作关系
数据流向图
前端页面 → Controller → Service → Repository → Database
↓ ↓ ↓ ↓
WebSocket ← Handler ← Service ← Entity ← Redis
依赖关系图
Controller 依赖 Service
Service 依赖 Repository + Util
Repository 依赖 Entity
Handler 依赖 Service + Util
Config 配置 所有组件
职责分离原则
- Controller: 只负责HTTP请求处理,不包含业务逻辑
- Service: 包含所有业务逻辑,协调各个组件
- Repository: 只负责数据访问,不包含业务逻辑
- Entity: 只是数据载体,不包含业务方法
- DTO: 只用于数据传输,不包含业务逻辑
- Util: 提供通用工具方法,无状态
这种设计确保了:
- 🔧 高内聚:每个类都有明确的职责
- 🔗 低耦合:类之间的依赖关系清晰简单
- 🔄 易维护:修改一个功能不会影响其他功能
- 🧪 易测试:每个类都可以独立测试
- 📈 易扩展:可以轻松添加新功能而不影响现有代码
1556

被折叠的 条评论
为什么被折叠?



