[java]基于socket通信技术实现的简单聊天室

引言

socket,你也许没听说过,但你不可能没用到过(除非你21世纪以来没有接触过电子产品)。常见如QQ,微信,只要存在聊天框和评论的地方,socket都发挥着它的作用。本文章将会分三大模块(socket后端编程,数据库,UI编程),带你DIY一个属于自己的聊天室

本项目已在GitHub开源:Torchman005/Simple-Chat-Room

socket核心模块:了解客户端与服务端之间信息传递的原理,手刃客户端,服务端

数据库模块:学习如何保存用户信息、群聊信息,实现数据的持久化

UI模块:为用户提供一个易于操作的窗口,美化聊天室,降低使用门槛

本项目需要的工具:jdk17(本人使用),IDEA,maven(IDEA有自带的),Navicat,mysql,javaFX相关依赖,JDBC相关依赖,最低2C2G云服务器,Xterminal(只要能连接服务器就行),一个会思考的大脑

需要会的东西:JavaSE基础,sql语句,数据库操作,maven依赖管理,Linux基本命令,docker操作(不作硬性要求)


socket核心java编程

简介

socket编程属于网络编程的一种经典方式,我们可以通过socket关键字实现网络上的通信。socket(插座),我们可以把它抽象的理解为客户端和服务端都需要一个socket插座,通过网络将这两个插座连接来实现客户端与客户端、客户端与服务端之间信息的传递。

一般的大型项目会使用WebSocket技术以及Netty+SpringBoot来支持大用户量的服务,但本文仅提供了聊天室的入门小项目教程,遂采用传统socket实现聊天功能

环境准备

JDK下载及环境变量配置

鉴于欲编写本项目的人应基本掌握Java基础,这里我会一笔带过

因为本项目是基于java语言编程,所以需要从Java官网下载对应版本的JDK

Java官网https://www.java.com/zh-CN/

下载安装包后双击安装程序,选择安装路径(可以保持默认),然后一直点下一步,选项都保持默认,直至安装完成

安装完成以后在高级系统设置中添加Path

如果这些东西还需要我详细说的话,建议先从Java基础开始学起→_→

项目编程

开始

首先我们要在IDEA中新建maven项目,然后在main目录下创建包,以下是我文件的目录结构

客户端服务端

说明:

cfg存放配置文件

service存放一些聊天功能如私聊、群聊的实现类

chatcommon存放User用户类和Message信息类,客户端与服务端需保持一致

utils为工具包,存放自己写的方便编程的工具类

mainchatframe分别为客户端和服务端的启动类

chatcommon

我们先从这个包入手,因为客户端与服务端的信息传递都靠这个包中的类,只有写完其中的类才能实现双端交互

User类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
package com.jinyu.chatcommon;

import java.io.Serializable;

public class User implements Serializable {
// 指定序列化版本号
private static final long serialVersionUID = 1L;
private String userId; // 用户Id属性
private String pwd; // 密码属性
private String userType; // 用户信息类型(判断用户是登录还是注册)

public String getUserType() {
return userType;
}

public void setUserType(String userType) {
this.userType = userType;
}

public User(String userId, String pwd) {
this.userId = userId;
this.pwd = pwd;
}

public User() {
}

@Override
public String toString() {
return "User{" +
"userId='" + userId + '\'' +
", pwd='" + pwd + '\'' +
'}';
}

public String getUserId() {
return userId;
}

public void setUserId(String userId) {
this.userId = userId;
}

public String getPwd() {
return pwd;
}

public void setPwd(String pwd) {
this.pwd = pwd;
}
}

声明了用户的userIdpwd,添加了构造方法和getter,setter方法,并重写了toString方法

UserType接口

1
2
3
4
5
6
7
package com.jinyu.chatcommon;

public interface UserType {
String USER_LOGIN = "1"; // 用户登录
String USER_REGISTER = "2"; // 用户注册
}

这个接口用来设定客户端发送给服务端的用户信息类型,判断是要登录还是要注册

Message类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
package com.jinyu.chatcommon;

import java.io.Serializable;
import java.util.PrimitiveIterator;
import java.util.Queue;

public class Message implements Serializable {
// 指定序列化版本号
private static final long serialVersionUID = 1L;
private String sender; // 发送者Id
private String getter; // 接收者Id
private String content;// 发送内容
private String sendTime; // 发送时间
private String mesType;// 消息类型(接口定义)

// 和文件相关的字段
private byte[] fileBytes; // 用来读取文件的字节数组
private int fileLen = 0;
private String dest;// 文件传输到哪里
private String src;// 文件源路径
private Queue<String> groupMembers; // 群组用户队列,临时存储群聊成员
private Queue<String> onlineUsers; // 在线用户队列,临时存储在线用户
private boolean isUser; // 判断有无此用户
private boolean isGroup; // 判断有无此群聊
private String groupName; // 群聊名称

public Message() {
}

public Message(String mesType, String content) {
this.mesType = mesType;
this.content = content;
this.sendTime = new java.util.Date().toString();
}

public Message(String mesType) {
this.mesType = mesType;
this.sendTime = new java.util.Date().toString();
}

public boolean isUser() {
return isUser;
}

public void setUser(boolean user) {
isUser = user;
}

public boolean isGroup() {
return isGroup;
}

public void setGroup(boolean group) {
isGroup = group;
}

public Queue<String> getOnlineUsers() {
return onlineUsers;
}

public void setOnlineUsers(Queue<String> onlineUsers) {
this.onlineUsers = onlineUsers;
}

public Queue<String> getGroupMembers() {
return groupMembers;
}

public void setGroupMembers(Queue<String> groupMembers) {
this.groupMembers = groupMembers;
}

public String getGroupName() {
return groupName;
}

public void setGroupName(String groupName) {
this.groupName = groupName;
}

public byte[] getFileBytes() {
return fileBytes;
}

public void setFileBytes(byte[] fileBytes) {
this.fileBytes = fileBytes;
}

public int getFileLen() {
return fileLen;
}

public void setFileLen(int fileLen) {
this.fileLen = fileLen;
}

public String getDest() {
return dest;
}

public void setDest(String dest) {
this.dest = dest;
}

public String getSrc() {
return src;
}

public void setSrc(String src) {
this.src = src;
}

public String getSender() {
return sender;
}

public void setSender(String sender) {
this.sender = sender;
}

public String getGetter() {
return getter;
}

public void setGetter(String getter) {
this.getter = getter;
}

public String getContent() {
return content;
}

public void setContent(String content) {
this.content = content;
}

public String getSendTime() {
return sendTime;
}

public void setSendTime(String sendTime) {
this.sendTime = sendTime;
}

public String getMesType() {
return mesType;
}

public void setMesType(String mesType) {
this.mesType = mesType;
}
}

Message接口(主要设置消息类型):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package com.jinyu.chatcommon;

public interface MessageType {
String MESSAGE_LOGIN_SUCCEED = "1";//登录成功的消息
String MESSAGE_LOGIN_FAIL = "2";//登录失败的消息
String MESSAGE_COMM_MES = "3";//Message和User对象接受消息
String MESSAGE_REQ_ONLINE_USERS = "4";//请求返回在线用户列表
String MESSAGE_RET_ONLINE_USERS_LIST = "5";//返回在线用户列表
String MESSAGE_CLIENT_EXIT = "6";//用户退出
String MESSAGE_TO_GROUP_MES = "7";//群发消息
String MESSAGE_FILE_MES = "8";//文件发送
String MESSAGE_PULL_GROUP_MES = "9";//拉群
String MESSAGE_SEND_TO_ALL = "10";//服务端推送新闻
String MESSAGE_SYSTEM = "11";//系统消息
String MESSAGE_REGISTER = "12";//注册
String MESSAGE_REGISTER_SUCCEED = "12";//注册成功
String MESSAGE_REGISTER_FAIL = "13";//注册失败
}

通过设置消息类型(MessageType)来让服务端识别不同的功能请求,从而实现不同的功能

chatmain/chatframe

这两个包下都分别只有一个类,那就是程序的启动入口(主类)

客户端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package com.jinyu.main;

import com.jinyu.ui.LoginUI;

import javafx.application.Application;

public class ChatMain {
public static void main(String[] args) {
// 设置系统属性,解决中文乱码
System.setProperty("file.encoding", "UTF-8");
// 启动JavaFX应用
Application.launch(LoginUI.class, args);
}
}

这个main方法启动的是UI界面,UI的逻辑全部写在ui这个包下

服务端:

1
2
3
4
5
6
7
8
9
10
package com.jinyu.chatframe;

import com.jinyu.chatserver.service.ChatServer;
import java.io.IOException;

public class ChatFrame {
public static void main(String[] args) throws Exception {
new ChatServer();
}
}

通过new个ChatServer对象来启动服务端

resources

resources中专门存放项目的各种配置文件,在这个项目中,我们要写的配置文件十分简单

客户端:

1
2
3
#配置文件我命名为config.properties,所以下文会用config指代这个文件
host=127.0.0.1
port=2323

这里配置了要连接的服务器的ip和端口

服务端:

1
port=2323

service

注册与登录功能

前言

无论是注册还是登录,都需要用户的信息,而用户的信息需要用户自己在UI界面给的输入框中输入回车才会读取,所以在LoginUIRegisterUI类中,我会读取输入框用户输入的数据,紧接着调用ToUserFunction中的注册和登录方法并传入参数

关于UI方面都会在第三模块中解释,所以在这里不作代码演示,我们直接来看service中的逻辑<( ̄︶ ̄)↗[GO!]

ToUserFunction类

注册

众所周知,不管你用哪个应用或上哪个网站,用户都得先注册账号,将用户信息存储在数据库才能够使用原有账号密码登录,所以我这里先介绍注册的逻辑及实现

客户端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// ToUserFunction类
public boolean registerUser(String userId, String pwd) {
boolean b = false;
try {
// 读取配置文件
ClassLoader classLoader = ToUserFunction.class.getClassLoader();
InputStream input = classLoader.getResourceAsStream("config.properties");
Properties prop = new Properties();
prop.load(input);
String host = prop.getProperty("host");
String sport = prop.getProperty("port");
int port = Integer.parseInt(sport);

// 创建socket连接
socket = new Socket(InetAddress.getByName(host), port);

// 向服务端传输用户信息
user.setUserId(userId);
user.setPwd(pwd);
user.setUserType(UserType.USER_REGISTER);
ObjectOutputStream oos = new ObjectOutputStream(socket.getOutputStream());
oos.writeObject(user);

// 接收服务端回复的注册信息
ObjectInputStream ois = new ObjectInputStream(socket.getInputStream());
Message mes = (Message)ois.readObject();

// 判断注册是否成功
if(mes.getMesType().equals(MessageType.MESSAGE_REGISTER_SUCCEED)) {
b = true;
} else {
displayMessage(new Message(MessageType.MESSAGE_SYSTEM, "注册失败(用户名已存在)"));
socket.close();
}
} catch (Exception e) {
displayMessage(new Message(MessageType.MESSAGE_SYSTEM, "注册失败: " + e.getMessage()));
}
return b;
}

这个是用户在客户端的注册实现,我们来剖析一下

首先,我们需要让客户端与服务端建立连接,就需要用到socket

1
Socket socket = new Socket(InetAddress.getByName(host), port);

这里我们首先创建了一个socket对象,其中的参数分别是你要连接的服务器的InetAddress对象和所在端口号(这里解释一下,InetAddress类中的getByName方法是利用主机的IP地址来返回InetAddress对象的),所以要读取resources中的配置文件(config)

此时服务端需要监听来自客户端socket的动作,客户端与服务端之间才能建立连接,于是在服务端写出如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// ChatServer类
ServerSocket ss = null;
public ChatServer() throws Exception{

try {
// 启动推送新闻的线程
new Thread(new SendNewsToAllService()).start();
// 读取配置文件
ClassLoader classLoader = ChatServer.class.getClassLoader();
InputStream input = classLoader.getResourceAsStream("config.properties");
Properties prop = new Properties();
prop.load(input);
String sport = prop.getProperty("port");
int port = Integer.parseInt(sport);
System.out.println("服务端在" + port + "端口监听");
ss = new ServerSocket(port);

while(true){
Socket socket = ss.accept();// 监听客户端的连接,若没有则阻塞
// 对象输入输出流读取用户对象
ObjectInputStream ois = new ObjectInputStream(socket.getInputStream());
ObjectOutputStream oos = new ObjectOutputStream(socket.getOutputStream());
User user = (User) ois.readObject();
// 准备一个Message对象,用来回复客户端
Message message = new Message();
} catch (Exception e) {
e.printStackTrace();
}finally {
ss.close();
}
}

这整个代码都是在ChatServer这个类的构造方法中执行的,保证服务端启动后这些代码会立即执行

可以看出,服务端首先也是读取配置文件,创建ServerSocket对象,把从配置文件读取的端口号(port)作为参数传入

1
ServerSocket ss = new ServerSocket(port);

然后调用ServerSocket的accept方法来监听客户端连接

1
Socket socket = ss.accept();

注意:因为将会有多个客户端登录,所以客户端只监听一次是远远不够的,这时就需要在外面包裹一层while循环来持续监听

至此,客户端与服务端之间已建立连接

服务端就先告一段落,让我们回到客户端

客户端:

1
2
3
4
5
6
// ToUserFunction类

// 向服务端传输用户信息
user.setUserId(userId);
user.setPwd(pwd);
user.setUserType(UserType.USER_REGISTER);

用户的注册,就是要把用户输入的信息传到服务端,让服务端写入数据库

这里的目的,就是封装好User对象,set好User的Idpwd你此次传输的用户信息类型(UserType),然后通过IO流传给服务端

下面,是创建ObjectInputStreamObjectOutputStream来读写User对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// ToUserFunction类

ObjectOutputStream oos = new ObjectOutputStream(socket.getOutputStream());
oos.writeObject(user);
// 接收服务端回复的注册信息
ObjectInputStream ois = new ObjectInputStream(socket.getInputStream());
Message mes = (Message)ois.readObject();

// 判断注册是否成功
if(mes.getMesType().equals(MessageType.MESSAGE_REGISTER_SUCCEED)) {
b = true; // 判定为成功,返回true
} else {
displayMessage(new Message(MessageType.MESSAGE_SYSTEM, "注册失败(用户名已存在)"));
socket.close();
}
} catch (Exception e) {
displayMessage(new Message(MessageType.MESSAGE_SYSTEM, "注册失败: " + e.getMessage()));
}

ObjectOutputStream写入服务端的socket后,服务端读入

1
2
3
4
5
6
// ChatServer类

ObjectInputStream ois = new ObjectInputStream(socket.getInputStream());
User user = (User) ois.readObject();
// 准备个message对象来承载信息并发送至客户端
Message message = new Message();

读入以后就要判断这个User的类型了,判断它是登录还是注册

1
2
3
4
5
6
7
8
9
10
if(user.getUserType().equals(UserType.USER_REGISTER)){
//这里涉及数据库SQL操作,暂不作处理
//注册成功
message.setMesType(MessageType.MESSAGE_REGISTER_SUCCEED);
} else {
//注册失败
message.setMesType(MessageType.MESSAGE_REGISTER_FAIL);
}
oos.writeObject(message);
}

随后将message传给客户端

oos.writeObject(message);

客户端:

1
2
3
4
5
6
7
8
9
10
11
12
13
// ToUserFunction类

// 接收服务端回复的注册信息
ObjectInputStream ois = new ObjectInputStream(socket.getInputStream());
Message mes = (Message)ois.readObject();

// 判断注册是否成功
if(mes.getMesType().equals(MessageType.MESSAGE_REGISTER_SUCCEED)) {
b = true;
} else {
displayMessage(new Message(MessageType.MESSAGE_SYSTEM, "注册失败(用户名已存在)"));
socket.close();
}

客户端接收到来自客户端注册成功与否的信息,并将结果返回

注册逻辑已完成o( ̄︶ ̄)o

登录

其实登录的大体逻辑和注册差不多,只不过多了验证Id、密码和创建用户线程的步骤

客户端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// ToUserFunction类

public boolean checkUser(String userId, String pwd){
// 给登录的用户账号和密码初始化,便于后续验证
user.setUserId(userId);
user.setPwd(pwd);
user.setUserType(UserType.USER_LOGIN);

boolean b = false;
try {
// 读取配置文件
ClassLoader classLoader = ToUserFunction.class.getClassLoader();
InputStream input = classLoader.getResourceAsStream("config.properties");
Properties prop = new Properties();
prop.load(input);
String host = prop.getProperty("host");
String sport = prop.getProperty("port");
int port = Integer.parseInt(sport);

// 创建socket连接
socket = new Socket(InetAddress.getByName(host), port);

// 向服务端传输用户信息,传的是对象
ObjectOutputStream oos = new ObjectOutputStream(socket.getOutputStream());
oos.writeObject(user);

return b;
}

服务端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// ChatServer类

if(user.getUserType().equals(UserType.USER_LOGIN)){
// 然后再验证是否能够成功登录
if(/*这里需要使用SQL语句检索数据库并比对来判断用户Id和密码的合理性*/){
message.setMesType(MessageType.MESSAGE_LOGIN_SUCCEED);
// 加入线程
ServerConnectClientThread thread = new ServerConnectClientThread(socket, user.getUserId());
thread.start();
ClientThreadsManage.addServerConnectClientThread(user.getUserId(), thread);

// 将用户Id加入在线用户队列
OnlineUsers.addOnlineUsers(user.getUserId());

// 然后把message传给客户端
oos.writeObject(message);
}else{
// 登录失败
System.out.println("用户账号或密码不正确");
message.setMesType(MessageType.MESSAGE_LOGIN_FAIL);
oos.writeObject(message);
socket.close();
}
}

服务端验证用户Id和密码是否匹配,若匹配则登陆成功,给message设置登陆成功的类型,并增加对应用户线程

客户端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//            还要接受服务端回复的登录信息
ObjectInputStream ois = new ObjectInputStream(socket.getInputStream());
Message mes = (Message)ois.readObject();

// 判断登录是否成功
if(mes.getMesType().equals(MessageType.MESSAGE_LOGIN_SUCCEED)){
b = true;
// 因为登录成功,所以新增一个通信线程
ClientConnectServerThread thread = new ClientConnectServerThread(socket, chatUI);
// 启动线程
thread.start();
// 把这个线程存到map集合中统一管理
ClientConnServerThreadsManage.addClientConnectServerThread(userId, thread);

}else{
displayMessage(new Message(MessageType.MESSAGE_SYSTEM, "用户名或密码不正确"));
socket.close();
}
} catch (Exception e) {
displayMessage(new Message(MessageType.MESSAGE_SYSTEM, "登录失败: " + e.getMessage()));
}

客户端再次接收到服务端传来的信息,确定自己是登录成功还是登录失败,若登陆成功则添加自身对应线程

这里遇到了一个新的内容:线程

认真思考的人已经注意到了,既然这里new了一个新线程,那这个线程类的内容是什么呢?

如你所见,下面分别是客户端和服务端的线程类:

客户端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
// ClientConnectServerThread类

package com.jinyu.chatclient.service;

import com.jinyu.chatcommon.Message;
import com.jinyu.chatcommon.MessageType;
import com.jinyu.ui.ChatUI;
import com.jinyu.utils.Utility;

import javax.management.ObjectName;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectInput;
import java.io.ObjectInputStream;
import java.net.Socket;
import java.util.Queue;

public class ClientConnectServerThread extends Thread{
// 全局变量事先声明
private Socket socket;
private ChatUI chatUI;

public ClientConnectServerThread(Socket socket,ChatUI chatUI){
this.socket = socket;
this.chatUI = chatUI;
}
@Override
public void run() {
// while循环来持续接收服务端传来的信息
while (true) {
// 等待读取
// System.out.println("(等待读取)");

try {
ObjectInputStream ois = new ObjectInputStream(socket.getInputStream());
Message mes = (Message) ois.readObject();
// 等待服务端传来message
if (mes.getMesType().equals(MessageType.MESSAGE_RET_ONLINE_USERS_LIST)) {
// 服务端的信息是返回在线用户列表,所以这里等待接收
Queue<String> onlineUsers = mes.getOnlineUsers();
chatUI.updateOnlineUsers(onlineUsers);
} else if (mes.getMesType().equals(MessageType.MESSAGE_COMM_MES)) {
// 普通的聊天消息
chatUI.displayMessage(mes);
} else if(mes.getMesType().equals(MessageType.MESSAGE_FILE_MES)){
chatUI.displayMessage(mes);
// 文件保存逻辑将在UI中处理
} else if(mes.getMesType().equals(MessageType.MESSAGE_SEND_TO_ALL)){
// 服务端推送消息
chatUI.displayMessage(mes);
} else if(mes.getMesType().equals(MessageType.MESSAGE_TO_GROUP_MES)){
if(mes.isGroup()){
chatUI.displayMessage(mes);
} else{
chatUI.displayMessage(new Message(MessageType.MESSAGE_SYSTEM, "无此群聊>﹏<"));
}
} else {
chatUI.displayMessage(new Message(MessageType.MESSAGE_SYSTEM, "其他类型的信息,暂时不做处理"));
}

} catch (Exception e) {
chatUI.displayMessage(new Message(MessageType.MESSAGE_SYSTEM, "接收消息错误: " + e.getMessage()));
break;
}
}
}
public Socket getSocket(){
return socket;
}
}

服务端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
// ServerConnectClientThread类

package com.jinyu.chatserver.service;

import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.net.Socket;
import java.util.Iterator;
import java.util.Queue;

import com.jinyu.chatcommon.Message;
import com.jinyu.chatcommon.MessageType;

public class ServerConnectClientThread extends Thread{
private Socket socket;
private String userId;
public ServerConnectClientThread(Socket socket, String userId){
this.socket = socket;
this.userId = userId;
}
public Socket getSocket(){
return this.socket;
}
@Override
public void run(){
while(true){
try {
System.out.println(userId + "已连接并保持通信");
ObjectInputStream ois = new ObjectInputStream(socket.getInputStream());
Message mes = (Message) ois.readObject();
// 获取在线用户列表并且发给客户端
if(mes.getMesType().equals(MessageType.MESSAGE_REQ_ONLINE_USERS)){
System.out.println(mes.getSender() + "请求获取在线用户列表");
Queue<String> onlineUsers = ClientThreadsManage.getOnlineUsers();
Message mes2 = new Message();
mes2.setMesType(MessageType.MESSAGE_RET_ONLINE_USERS_LIST);
mes2.setOnlineUsers(onlineUsers);
mes2.setGetter(mes.getSender());

ObjectOutputStream oos = new ObjectOutputStream(socket.getOutputStream());
oos.writeObject(mes2);
}else if(mes.getMesType().equals(MessageType.MESSAGE_CLIENT_EXIT)){
// 调用方法
ClientThreadsManage.removeSCCThread(mes.getSender());
System.out.println(userId + "退出登录");
OnlineUsers.deleteUser(userId);
socket.close(); // 关闭socket连接
break;
}else if(mes.getMesType().equals(MessageType.MESSAGE_COMM_MES)){
// 私聊转发
ObjectOutputStream oos = null;
try {
oos = new ObjectOutputStream(ClientThreadsManage.getServerConnectClientThread(mes.getGetter()).getSocket().getOutputStream());
oos.writeObject(mes);//若要离线留言,可发送给数据库
} catch (Exception e) {
System.out.println( mes.getGetter() + "不在线,无法私聊");
}
} else if(mes.getMesType().equals(MessageType.MESSAGE_FILE_MES)){
// 文件转发
ServerConnectClientThread thread = ClientThreadsManage.getServerConnectClientThread(mes.getGetter());
ObjectOutputStream oos = new ObjectOutputStream(thread.getSocket().getOutputStream());
oos.writeObject(mes);
} else if(mes.getMesType().equals(MessageType.MESSAGE_PULL_GROUP_MES)) {
// 拉群,并将群存储
Groups.addGroup(mes.getGroupName(), mes.getGroupMembers());
} else if(mes.getMesType().equals(MessageType.MESSAGE_TO_GROUP_MES)){
// 群发消息
// 判断是否有此群聊
if(Groups.hasGroup(mes.getGroupName())){
mes.setGroup(true);
Queue<String> group = Groups.getGroup(mes.getGroupName());
Iterator<String> iterator = group.iterator();
while(iterator.hasNext()){
String onlineUser = iterator.next();
if(!onlineUser.equals(mes.getSender())){
// 排除发消息的用户
ObjectOutputStream oos = new ObjectOutputStream(ClientThreadsManage.getServerConnectClientThread(onlineUser).getSocket().getOutputStream());
oos.writeObject(mes);
}
}
} else{
// 将不存在群组的信息发送给客户端
System.out.println("不存在此群聊");
mes.setGroup(false);
ObjectOutputStream oos = new ObjectOutputStream(ClientThreadsManage.getServerConnectClientThread(mes.getSender()).getSocket().getOutputStream());
oos.writeObject(mes);
}
} else{
System.out.println("其他类型的信息,暂时不作处理");
}
} catch (IOException e) {
// 处理客户端异常断开连接的情况
System.out.println(userId + "退出登录");
ClientThreadsManage.removeSCCThread(userId);
OnlineUsers.deleteUser(userId);
try {
socket.close();
} catch (IOException ex) {
ex.printStackTrace();
}
break;
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
}
}

客户端和服务端的线程类代码看似很长,其实逻辑很简单,那就是接收来自服务端/客户端的信息,判断信息的类型来实现相应的代码/功能

创建这些线程后,得需要有一个专门的单位去管理它们,于是就出现了线程管理类

客户端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// ClientConnServerThreadManage类

package com.jinyu.chatclient.service;

import java.util.HashMap;

/*
用来管理每个用户线程的类(用map集合存储)
*/
public class ClientConnServerThreadsManage {
private static HashMap<String, ClientConnectServerThread> hm = new HashMap<>();


// 添加用户线程
public static void addClientConnectServerThread(String userId, ClientConnectServerThread thread) {
hm.put(userId, thread);
}

// 根据userId获取用户线程
public static ClientConnectServerThread getClientConnectServerThread(String userId) {
return hm.get(userId);
}
// 删除线程
public static void removeCCSThread(String userId){
hm.remove(userId);
}
}

服务端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// ClientThreadsManage类

package com.jinyu.chatserver.service;

import java.util.HashMap;
import java.util.Queue;

public class ClientThreadsManage {
private static HashMap<String, ServerConnectClientThread> threads = new HashMap<>();

public static HashMap<String, ServerConnectClientThread> getThreads() {
return threads;
}

public static void addServerConnectClientThread(String userId, ServerConnectClientThread serverConnectClientThread){

threads.put(userId, serverConnectClientThread);

}
public static ServerConnectClientThread getServerConnectClientThread(String userId){
return threads.get(userId);
}
public static Queue<String> getOnlineUsers(){
return OnlineUsers.onlineUsers;
}
public static void removeSCCThread(String userId){
threads.remove(userId);
}
}

每次创建线程之后,需将线程纳入线程管理类,将线程都放入一个集合统一管理

了解完线程的大体内容之后,我们再回到登录逻辑

添加好对应用户的线程之后,这名用户便成为了我们平常所说的在线用户

登录已完成,看似复杂,实际上只要捋清楚每个类的职责,并不困难(迫真)

私聊功能

其实本项目的大体框架已经完成,剩下的功能实现逻辑都很相似,我在这就先就着一个典型例子详细介绍

就比如私聊逻辑,一开始客户端选择进行私聊,并将发送者ID(senderId)、接收者ID(getterId)、发送的内容(content)、消息类型(MessageType)封装进message对象中发送给服务端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// ClientMessageService类

public void sendMessageToOne( String content, String senderId, String getterId){
// 实现私聊
// 封装message
Message mes = new Message();
mes.setMesType(MessageType.MESSAGE_COMM_MES);
mes.setSender(senderId);
mes.setContent(content);
mes.setGetter(getterId);
mes.setSendTime(new Date().toString());//发送时间
System.out.println("\n" + mes.getSendTime() + " " + mes.getSender() + ": " + mes.getContent());

try {
ObjectOutputStream oos = new ObjectOutputStream(ClientConnServerThreadsManage.getClientConnectServerThread(senderId).getSocket().getOutputStream());
oos.writeObject(mes);
} catch (Exception e) {
e.printStackTrace();
}
}

服务端接收信息的代码就由线程类来执行

1
2
3
4
5
6
7
8
9
10
11
12
// ServerConnectClientThread类

if(mes.getMesType().equals(MessageType.MESSAGE_COMM_MES)){
// 私聊转发
ObjectOutputStream oos = null;
try {
oos = new ObjectOutputStream(ClientThreadsManage.getServerConnectClientThread(mes.getGetter()).getSocket().getOutputStream());
oos.writeObject(mes);//若要离线留言,可发送给数据库
} catch (Exception e) {
System.out.println( mes.getGetter() + "不在线,无法私聊");
}
}

OK,服务端接收了来自客户端的message,那么服务端在这里就起到一个转发的作用,有了getterId,我们就可以从线程管理类中找到这个getterId对应的线程,服务端就可以准确地把message发送给这个客户端,接收者接收到message以后,客户端线程就会再次判断信息的类型,如果是私聊类型,便会输出私聊消息的内容到指定区域

其他功能

私聊功能已完成,其他功能大同小异,我不会再详细剖析,我会交代一些细节,读者可以自己尝试实现

获取在线用户列表:实现这个功能主要在于如何获取在线用户Id,而我们知道判断一个用户是否在线就要看线程集合中有没有这个用户Id对应的线程,所以,我们可以遍历线程集合来获取在线用户的Id并传给客户端

创建群聊:拉群需要知道群聊名、在线用户都有谁,我们可以将每个群的群成员放入到一个队列中,再将群聊放入一个HashMap中集中管理,key为群聊名,value为用户队列

群聊:跟私聊的逻辑实现差不多,只不过这个要发给好几个人,就要在要发的群聊的用户队列中遍历用户来传信息

文件发送:跟私聊,群聊很相似,唯一不同就是要使用File的IO流来传文件

服务端新闻推送:主要还是遍历,遍历每个在线客户端,给它们发消息,并且用while循环不断读取需要发送的消息

无异常退出:这个需要在用户想要退出的时候,服务端从管理线程的集合中移除该用户线程

后端模块已基本完成,现在你就可以打开服务端,多开客户端来在控制台体验一下自己写的无UI聊天室