基于WebSocket + SpringBoot + MongoDB的聊天系统
近期实现了一个一直想做的聊天系统项目,使用Vue + WebSocket + SpringBoot + MongoDB + Mysql + Github图床 + GitHub Api调用+ BootStrap完成,支持头像更改、私聊、聊天室、聊天记录存储、敏感词过滤、发送表情、消息未读等功能,欢迎交流,跪谢Star。
https://github.com/Mazai-Liu/WebSocketChat.git界面如下图。(Lec是聊天室)
大致实现
前端部分
本人对前端以及Vue不熟悉,很多地方只是会用,细节也不太会处理,界面也不好看(BootStrap Stdio拖出来的) 。
// ChatComponent.vue
export default {
computed: {
// 创建ws连接(附带token)
ws: function() {
return new WebSocket("ws://localhost:8070/websocket/chat" + "?token=" + this.token);
},
},
// ...
mounted(){
// 挂载函数,在websocket连接不同的事件触发时执行对应的操作
// 连接成功时
this.ws.onopen = () => {
console.log("Connection Created");
// ...
};
// 连接关闭时
this.ws.onclose = () => {
console.log("Connection Closed");
// ...
};
// 本websocket客户端收到消息时
this.ws.onmessage = (event) => {
// ...
};
}
}
后端部分
WebSocket部分
WebSocketConfig.java,后端websocket配置类:
@Configuration
@EnableWebSocket
@EnableWebMvc
public class WebSocketConfig implements WebSocketConfigurer{
@Autowired
private WebSocketInterceptor webSocketInterceptor;
@Autowired
private ChatWebSocketHandler chatWebSocketHandler;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
// /ws/websocket/log 路径就是前端要访问的路径 类似@ServerEndpoint("/websocket/chat")
//添加处理器、添加拦截地址、添加拦截器
registry.addHandler(chatWebSocketHandler, "/websocket/chat")
.setAllowedOriginPatterns("*")
.addInterceptors(webSocketInterceptor);
}
}
WebSocketInterceptor.java,websocket拦截器:
@Component
@Slf4j
public class WebSocketInterceptor implements HandshakeInterceptor {
@Override
public boolean beforeHandshake(ServerHttpRequest request,
ServerHttpResponse response,
WebSocketHandler wsHandler,
Map<String, Object> attributes) throws Exception {
log.info("Before Handshake");
// check token
// ...
// 设置本次websocket连接的属性
attributes.put("id",id);
attributes.put("username",username);
return true;
}
@Override
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) {
}
}
ChatWebSocketHandler.java, websocket逻辑处理部分:
@Component
@Slf4j
public class ChatWebSocketHandler extends TextWebSocketHandler {
// 用户名与Session互相的映射,用户名可用id
private static Map<String,WebSocketSession> USER2SESSION = new ConcurrentHashMap<>();
private static Map<WebSocketSession, String> session2username = new ConcurrentHashMap<>();
private static SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
// 在线人数
public static Integer headcount = 0;
@Autowired
private RecordService recordService;
// 敏感词过滤
@Autowired
private SensitiveWordUtil sensitiveWordUtil;
@Autowired
private UserService userService;
/**
* 连接建立后保存用户的登录状态
* @param session
* @throws Exception
*/
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
log.info("连接" + session + "建立");
String username = (String) session.getAttributes().get("username");
// 建立会话和用户名的映射
// ...
headcount++;
listUsers();
}
/**
* 向所有websocket客户端更新在线用户
*/
private void listUsers(){
// ...
broadcast(message);
}
/**
* 获取在线用户的名字和头像用以展示
* @return
*/
public List<NameAndAvatar> getNamesAndAvatars(){
// ...
}
/**
* 向所有websocket客户端广播消息
* @param message
*/
private void broadcast(String message){
Set<String> names = USER2SESSION.keySet();
for(String name : names){
WebSocketSession session = USER2SESSION.get(name);
try {
session.sendMessage(new TextMessage(message));
} catch (IOException e) {
e.printStackTrace();
}
}
}
/**
* 收到发送的聊天消息
* @param session 发送者的 WebSocketSession
* @param message 消息
* @throws Exception
*/
@Override
public void handleMessage(WebSocketSession session, WebSocketMessage<?> message) throws Exception {
// 封装聊天记录对象
// ...
// 新增聊天记录到MongoDB
// ...
}
@Override
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
}
/**
* 连接关闭后,更新用户在线状态
* @param session
* @param closeStatus
* @throws Exception
*/
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) throws Exception {
// 改变用户名和Session映射
// ...
headcount--;
listUsers();
}
@Override
public boolean supportsPartialMessages() {
return false;
}
}
MongoDB部分
MongoConfig.java 以及mongoTemplate的使用
@Configuration
public class MongoConfig {
@Bean
public MappingMongoConverter mappingMongoConverter(MongoDatabaseFactory factory, MongoMappingContext context, BeanFactory beanFactory) {
DbRefResolver dbRefResolver = new DefaultDbRefResolver(factory);
MappingMongoConverter mappingConverter = new MappingMongoConverter(dbRefResolver, context);
try {
mappingConverter.setCustomConversions(beanFactory.getBean(CustomConversions.class));
} catch (NoSuchBeanDefinitionException ignore) {
}
// Don't save _class to mongo
mappingConverter.setTypeMapper(new DefaultMongoTypeMapper(null));
return mappingConverter;
}
}
@Service
public class RecordServiceImpl implements RecordService {
@Autowired
private MongoTemplate mongoTemplate;
/**
* 获取两人间的聊天记录
* @param getRecordsForm 消息发送者,接受者
* @return 二者的聊天记录
*/
@Override
public Result<List<Record>> getRecords(GetRecordsForm getRecordsForm) {
String fromString = getRecordsForm.getFromName(), toString = getRecordsForm.getToName();
// 这是聊天室的聊天内容处理
if(toString.equals("Lec")){
// ...
}
// select (fromName = a and toName = b) || (fromName = b and toName = a)
// one part ...
// two part ...
// or Operator ...
List<Record> records = mongoTemplate.find(query, Record.class, "record");
return Result.success(MessageAndCode.OK,records);
}
/**
* 在用户修改完头像后,更新历史聊天记录中的用户头像
* @param username 修改头像的用户
* @param newAvatarPath 新头像的图床路径
*/
public void setAvatarAfterChanged(String username, String newAvatarPath){
// ...
}
/**
* 插入聊天记录
* @param record 一条聊天记录
* @return
*/
@Override
public Result<?> insertRecord(Record record) {
// ...
}
public Result<?> insertRecords(List<Record> records) {
// ...
}
}
GitHub Api调用
使用GitHubAPi完成将用户上传的头像上传到Github图床(Github图床当图床很卡就是了)
@Component
@Slf4j
public class GithubUploader {
public static final Set<String> ALLOW_FILE_SUFFIX = new HashSet<>(Arrays.asList("jpg", "png", "jpeg", "gif"));
public static final String AVATAR_PREFIX = "img/chat/avatar/";
@Value("${github.bucket.url}")
private String url;
@Value("${github.bucket.api}")
private String api;
@Value("${github.bucket.access-token}")
private String accessToken;
@Autowired
RestTemplate restTemplate;
/**
* 上传头像到GitHub图床
* @param multipartFile 用户上传的文件
* @return GitHubApi的响应对象
*/
public JSONObject uploadAvatar (MultipartFile multipartFile){
String fileName = getNewFileName(multipartFile);
// 最终的文件路径
String filePath = AVATAR_PREFIX + fileName;
// 封装GitHubApi要求的请求体
log.info("成功上传头像到Github:{}", filePath);
return process(filePath,HttpMethod.PUT,map);
}
public JSONObject deleteAvatar(String filePath){
// 同上传
}
public String getNewFileName(MultipartFile multipartFile){
// 使用UUID进行文件重命名
}
public JSONObject process(String filePath, HttpMethod method,HashMap<String,String> requestbody){
String body = JSON.toJSONString(requestbody);
// 封装GitHubApi要求的请求头 ...
// 发送请求
ResponseEntity<JSONObject> responseEntity =
this.restTemplate.exchange(this.api + filePath, method,
new HttpEntity(body, httpHeaders), JSONObject.class);
log.info("执行完毕: {}", responseEntity.getBody());
return responseEntity.getBody();
}
/**
* 获取文件的后缀
* @param fileName
* @return
*/
protected String getSuffix(String fileName) {
int index = fileName.lastIndexOf(".");
if (index != -1) {
String suffix = fileName.substring(index + 1);
if (!suffix.isEmpty()) {
return suffix;
}
}
return null;
}
}
TODO
有时间的话还是想继续完善,大概会从以下方面吧。
- 发送表情包,消息撤回、ui优化等聊天的优化。
- 心跳检测。防止巨量没有请求连接占用资源。
- 项目上线。有钱买服务器或者找到一些能免费上线的平台。
- 项目压测、JVM调优、数据结构优化等 。
- 聊天记录定时删除。(这个倒是简单)
- 增加发送离线消息功能。(现在只能发给在线的人)
- 考虑并处理并发问题。
- ······