一、QQ聊天项目介绍
1.项目演示
2.选择该项目的原因
(1)有趣
(2)设计java各个方面的技术
-
项目框架设计
-
java面向对象编程
-
网络编程
-
多线程
-
IO流
-
Mysql / 使用集合充当内存数据库
(3)巩固旧知识,学习新知识
3.项目开发流程
4.需求分析
(1)用户登录
(2)拉取在线用户列表
(3)无异常退出(客户端、服务端)
(4)私聊
(5)群聊
(6)发文件
(7)服务器推送新闻
5.建议
(1)看视频时,记得记思路笔记。以记录自己的思路为核心,而不是死记硬背代码。
(2)看视频后,完全自己实现。当你觉得再看新的视频就会忘的时候,就停下来,看着自己的思路笔记,回忆并完完全全自己敲一遍代码实现,每一个文件都要自己建,每一行代码都要自己敲。
(3)学完项目后,写项目总结,可以记自己跟着视频学到的所有知识点、自己对项目知识点的理解程度、做项目时踩过的坑以及解决的方法。
二、通讯系统整体分析
1.当客户端和服务端需要双向通信时,由于涉及的信息类型较多,为了更高效地传递数据并简化数据处理过程,常常会优先考虑使用对象流来传输对象(User对象、Message对象),不再使用基于简单字符串的通信方式,因为消息里面包含的内容比较多,处理起来会比较麻烦。
2.多个客户端连接服务端,就会产生多个socket,服务端获取socket就应该启动线程,以便同时处理多个客户端的连接请求或者响应多个客户端的请求。当线程很多时,使用集合有助于统一管理、监控和调度,提高系统的可维护性和性能。
即:每次接收数据都启动一个线程,存放到集合中
3.服务器里有一个专门管理线程的集合,每一个线程里有一个socket,每一个socket对应一个数据通道。
4.客户端要同时发送文件并聊天,或与多个用户进行聊天,这些功能不能仅由单个线程处理,而是需要通过多线程来实现。因此,在客户端中也会存在一个专门管理线程的集合,每个线程都持有一个socket,每一个socket对应一个数据通道。
5.简而言之,就是socket套线程,线程套集合
三、代码实现思路
两个java项目:QQServer(服务端)、QQClient(客户端)
QQServer(服务端)
-
创建一个Java项目,命名为QQServer。
-
创建服务端主类QQServer。
- 初始化用户信息:初始化一个HashMap集合 validUsers,用于存放多个用户的信息,包括用户 ID 和密码。
- 验证用户合法性:根据用户提供的用户 ID 和密码,验证用户是否为合法用户。
- 启动服务器:建立ServerSocket 对象,监听指定端口,等待客户端连接,使用 while循环持续监听客户端的连接请求,同时,在服务器启动时,启动一个线程 SendNewsToAllService,用于向所有客户端发送消息。
- 处理客户端连接请求:当有客户端连接请求时,服务器接受连接,并获取连接的 Socket 对象,从连接的输入流中读取客户端发送的 User对象,验证用户的合法性,如果用户合法,则向客户端发送登录成功的消息,并启动一个线程来与客户端保持通信,同时将该线程对象加入管理集合中;如果用户非法,则向客户端发送登录失败的消息,并关闭连接。
-
创建服务端线程类ServerConnectClientThread
-
构造方法:初始化线程对象,保存客户端的连接和用户ID。
-
线程运行方法:通过循环持续监听客户端发送的消息,使用对象输入流读取 Message 对象,根据消息类型进行相应的业务处理。
-
获取 Socket 对象:提供 getSocket( ) 方法,用于获取线程持有的 Socket 对象,以便在需要时进行操作。
-
处理不同的消息类型:
(1)如果是获取在线用户列表的消息,通过 ManageClientThreads.getOnlineUsers() 方法获取在线用户列表,并将列表发送给客户端。
(2)如果是普通聊天消息,根据接收者的 ID 获取对应的线程,若接收者在线,则将消息转发给接收者,否则将消息保存到离线消息列表中。
(3)如果是群发消息,遍历管理线程的集合,将消息转发给除发送者之外的所有在线客户端。
(4)如果是文件传输消息,同样根据接收者的 ID 获取对应的线程,若接收者在线,则将文件消息发送给接收者,否则将文件消息保存到离线消息列表中。
(5)如果是客户端退出消息,从管理线程的集合中移除对应的线程,并关闭连接,退出线程。
-
-
创建服务端线程管理类ManageClientThreads
-
HashMap 存储管理和客户端通信的线程
-
添加线程到集合。
-
通过用户ID获取对应线程。
-
通过用户ID移除对应线程。
-
获取在线用户列表:通过迭代器遍历线程集合的键集合,将在线用户 ID 拼接成字符串并返回。
-
判断用户是否在线:根据用户ID判断用户是否在线,即判断线程集合中是否包含该用户 ID。
-
-
创建服务端推送新闻线程类SendNewsToAllService
-
线程运行方法:在
run
方法中使用循环,持续监听服务器要推送的新闻/消息,直到输入exit
命令退出推送服务。 -
获取新闻/消息:获取管理员输入的新闻或消息,存储在
news
变量中。 -
构建消息对象:将每条新闻或消息,构建成一个
Message
对象,消息类型设置为群发消息 (MESSAGE_TO_ALL_MES
,设置消息的发送者为 “服务器”,消息内容为管理员输入的新闻或消息,发送时间为当前时间。 -
推送消息给所有在线用户:
(1)通过
ManageClientThreads.getHm()
获取所有客户端线程的集合(2)使用迭代器遍历线程集合的键集合(即在线用户的用户ID)
(3)获取每个在线用户对应的客户端线程对象,并通过其关联的 socket 获取输出流
(4)将消息对象通过输出流发送给在线用户
-
-
创建服务端功能类SendOfflineMessageService
- 存储离线消息:使用 ConcurrentHashMap 存储离线用户及其消息,其中键为用户 ID,值为该用户的消息列表
- 添加离线消息:首先验证离线用户是否存在于集合中。如果不存在,则创建一个新的消息列表并将消息添加到其中,然后将该列表与用户 ID 关联存入集合中;如果已存在,则直接将消息添加到该用户的消息列表中。
- 发送离线消息:首先检查离线用户集合中是否包含该用户的消息列表。如果包含,则获取该用户的消息列表,并通过用户对应的客户端线程的 socket 输出流将消息列表发送给用户;如果不包含,则说明该用户无离线消息。
- 删除已发送的离线消息:通过用户 ID 从离线用户集合中移除对应的消息列表。
在搭建服务端时,需要使用到Use类、Message类和MessageType接口来处理用户信息和消息类型。
QQClient(客户端)
-
创建一个Java项目,命名为QQClient。
-
创建客户端主类QQView。
- 显示菜单界面:在菜单界面中,提供选项供用户选择登录系统、退出系统等操作。
- 用户登录系统:当用户选择登录系统时,通过控制台提示用户输入用户号和密码,并将输入的信息构建成 User对象,将用户信息发送到服务器进行验证。
- 显示二级菜单:登录成功后,进入二级菜单,提供选项供用户选择。
- 处理用户操作:根据用户的选择,调用相应的方法来实现对应的功能,在循环中持续监听用户的输入,直到用户选择退出系统。
- 入口方法main(),启动客户端。
-
创建客户端功能类UserClientService
-
创建用户对象:在方法中创建一个 User 对象,用于存储用户的账号和密码信息。
-
建立与服务器的连接: 创建客户端Socket,连接到指定的服务器IP和端口上。
-
用户登录验证:发送用户信息到服务器进行登录验证,再接收服务器的回复,根据消息类型来确定登录是否成功。
-
向服务器请求获取在线用户列表。
-
向服务端请求退出系统。
-
-
创建客户端线程类ClientConnectServerThread
-
持有Socket对象,用来与服务器通信。
-
实现线程执行的方法 run(),通过循环不断尝试从服务器接收消息,根据接收到的消息类型,进行相应的处理。
-
处理不同类型的消息
(1)如果接收到的是一个 ArrayList对象,表示这是离线消息或者文件传输消息,需要对消息进行特殊处理。
(2)如果接收到的是普通的聊天消息、群发消息或者文件消息,需要将消息内容打印到控制台,并根据需要将文件保存到本地。
-
关闭资源。
-
-
创建客户端线程管理类ManageClientConnectServerThread
-
HashMap 存储客户端连接到服务端的线程
-
添加线程到集合。
-
通过用户ID获取对应线程。
-
判断用户是否在线。
-
-
创建客户端功能类MessageClientService
- 发送消息给指定用户:创建相应的Message对象,将其通过对象输出流发送给服务端,由服务端将消息转发给指定的接收者。
- 发送消息给所有在线用户:创建相应的Message对象,将其通过对象输出流发送给服务端,由服务端转发给所有在线用户。
-
创建客户端功能类FileClientService
- 发送文件给指定用户:创建相应的Message对象,从源文件路径读取文件内容,并将文件内容转换为字节数组,设置到消息对象的 fileBytes属性中,将消息对象通过对象输出流发送给服务端,由服务端将消息转发给指定的接收者。
在搭建客户端时,同样需要使用到Use类、Message类或MessageType接口来处理用户信息和消息类型
项目目录
1.定义实体类和接口(共有部分)
放在qqcommon包里(两类一接口)
(1)User类:存放用户信息——用户Id号userId、用户密码pwd
(2)Message类:双方通信时的消息对象——发送者sender、接收者getter、发送内容content、发送时间sendTime、序列化Id serialVersionUID(增强兼容性) 、消息类型mesType(在接口定义)
(3)MessageType接口:存放不同的消息类型(常量)
- 登录成功:MESSAGE_LOGIN_SUCCEED = “1”
- 登录失败:MESSAGE_LOGIN_FAIL = “2”
package com.ma.qqcommon;
import java.io.Serializable;
/**
* 用户实体类,存放用户信息
*/
//把信息封装到对象,以对象的方式进行数据的传输
//一定要序列化,才能传输该类对象
public class User implements Serializable {
private String userId;//用户Id/用户名
private String pwd;//用户密码
public User() {
}
public User(String userId, String pwd) {
this.userId = userId;
this.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;
}
}
package com.ma.qqcommon;
import java.io.Serializable;
/**
* 客户端和服务器端通信时的消息对象
*/
public class Message implements Serializable {
private static final long serialVersionUID = 1L;//序列化ID, 增强兼容性
private String sender;//发送者
private String getter;//接收者
private String content;//消息内容
private String sendTime;//发送时间
private String mesType;//消息类型[可以在接口定义消息类型]
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;
}
}
package com.ma.qqcommon;
/**
* 消息类型
*/
public interface MessageType {
//1.在接口中定义了一些常量
//2.不同常量的值,表示不同的消息类型
String MESSAGE_LOGIN_SUCCEED = "1";//表示登录成功
String MESSAGE_LOGIN_FAIL = "2";//表示登录失败
}
2.界面设计
- 一级菜单
- 登录成功,进入二级菜单
视图类
qqclient.view包
QQView类:两个属性、两个个方法
(1)属性 loop 控制是否显示菜单
(2)属性key 接收用户键盘输入
(3)方法 mainMenu( ) 显示菜单
(4)主方法main( ) 创建 QQView实例 并调用 mainMenu 方法
- 打印菜单内容,使用while循环控制菜单的显示,直到用户选择退出系统。
- 根据用户的选择执行相应的操作
package com.ma.qqclient.view;
import com.ma.qqclient.service.UserClientService;
import com.ma.qqclient.utils.Utility;
import com.ma.qqcommon.User;
/**
* 显示客户端的菜单界面
*/
public class QQView {
public static void main(String[] args) {
new QQView().mainMenu();
System.out.println("退出系统");
}
private boolean loop = true;//控制是否显示菜单
private String key = "";//接收用户的键盘输入
private UserClientService userClientService = new UserClientService();//用于登录服务器
private void mainMenu() {
while (loop) {
System.out.println("============欢迎登录网络通信系统============");
System.out.println(" 1 登录系统");
System.out.println(" 2 退出系统");
System.out.print("请输入你的选择:");
key = Utility.readString(1);
switch (key) {
case "1":
//获取userId和pwd构建User对象然后发给服务端
System.out.print("请输入用户号:");
String userId = Utility.readString(50);
System.out.print("请输入密 码:");
String pwd = Utility.readString(50);
User user = new User(userId, pwd);
//这里比较麻烦,需要到服务端去验证该用户是否合法
if (true) {//登录成功
System.out.println("============欢迎 " + userId + " 用户============");
//进入二级菜单
while (loop){
System.out.println("============网络通信系统二级菜单(用户 " + userId + " )============");
System.out.println(" 1 显示在线用户列表");
System.out.println(" 2 群发消息");
System.out.println(" 3 私聊消息");
System.out.println(" 4 发送文件");
System.out.println(" 9 退出系统");
System.out.print("请输入你的选择:");
key = Utility.readString(1);
switch (key) {
case "1":
System.out.println("显示在线用户列表");
break;
case "2":
System.out.println("群发消息");
break;
case "3":
System.out.println("私聊消息");
break;
case "4":
System.out.println("发送文件");
break;
case "9":
loop = false;
break;
}
}
} else {//登录失败
System.out.println("登录失败!");
}
break;
case "2":
loop = false;
break;
}
}
}
}
工具类——Utility类(直接使用)
qqclient.utils包
package com.ma.qqclient.utils;
/**
工具类的作用:
处理各种情况的用户输入,并且能够按照程序员的需求,得到用户的控制台输入。
*/
import java.util.*;
public class Utility {
//静态属性。。。
private static Scanner scanner = new Scanner(System.in);
/**
* 功能:读取键盘输入的一个菜单选项,值:1——5的范围
* @return 1——5
*/
public static char readMenuSelection() {
char c;
for (; ; ) {
String str = readKeyBoard(1, false);//包含一个字符的字符串
c = str.charAt(0);//将字符串转换成字符char类型
if (c != '1' && c != '2' &&
c != '3' && c != '4' && c != '5') {
System.out.print("选择错误,请重新输入:");
} else break;
}
return c;
}
/**
* 功能:读取键盘输入的一个字符
* @return 一个字符
*/
public static char readChar() {
String str = readKeyBoard(1, false);//就是一个字符
return str.charAt(0);
}
/**
* 功能:读取键盘输入的一个字符,如果直接按回车,则返回指定的默认值;否则返回输入的那个字符
* @param defaultValue 指定的默认值
* @return 默认值或输入的字符
*/
public static char readChar(char defaultValue) {
String str = readKeyBoard(1, true);//要么是空字符串,要么是一个字符
return (str.length() == 0) ? defaultValue : str.charAt(0);
}
/**
* 功能:读取键盘输入的整型,长度小于2位
* @return 整数
*/
public static int readInt() {
int n;
for (; ; ) {
String str = readKeyBoard(2, false);//一个整数,长度<=2位
try {
n = Integer.parseInt(str);//将字符串转换成整数
break;
} catch (NumberFormatException e) {
System.out.print("数字输入错误,请重新输入:");
}
}
return n;
}
/**
* 功能:读取键盘输入的 整数或默认值,如果直接回车,则返回默认值,否则返回输入的整数
* @param defaultValue 指定的默认值
* @return 整数或默认值
*/
public static int readInt(int defaultValue) {
int n;
for (; ; ) {
String str = readKeyBoard(10, true);
if (str.equals("")) {
return defaultValue;
}
//异常处理...
try {
n = Integer.parseInt(str);
break;
} catch (NumberFormatException e) {
System.out.print("数字输入错误,请重新输入:");
}
}
return n;
}
/**
* 功能:读取键盘输入的指定长度的字符串
* @param limit 限制的长度
* @return 指定长度的字符串
*/
public static String readString(int limit) {
return readKeyBoard(limit, false);
}
/**
* 功能:读取键盘输入的指定长度的字符串或默认值,如果直接回车,返回默认值,否则返回字符串
* @param limit 限制的长度
* @param defaultValue 指定的默认值
* @return 指定长度的字符串
*/
public static String readString(int limit, String defaultValue) {
String str = readKeyBoard(limit, true);
return str.equals("")? defaultValue : str;
}
/**
* 功能:读取键盘输入的确认选项,Y或N
* 将小的功能,封装到一个方法中.
* @return Y或N
*/
public static char readConfirmSelection() {
System.out.println("请输入你的选择(Y/N)");
char c;
for (; ; ) {//无限循环
//在这里,将接受到字符,转成了大写字母
//y => Y n=>N
String str = readKeyBoard(1, false).toUpperCase();
c = str.charAt(0);
if (c == 'Y' || c == 'N') {
break;
} else {
System.out.print("选择错误,请重新输入:");
}
}
return c;
}
/**
* 功能: 读取一个字符串
* @param limit 读取的长度
* @param blankReturn 如果为true ,表示 可以读空字符串。
* 如果为false表示 不能读空字符串。
*
* 如果输入为空,或者输入大于limit的长度,就会提示重新输入。
* @return
*/
private static String readKeyBoard(int limit, boolean blankReturn) {
//定义了字符串
String line = "";
//scanner.hasNextLine() 判断有没有下一行
while (scanner.hasNextLine()) {
line = scanner.nextLine();//读取这一行
//如果line.length=0, 即用户没有输入任何内容,直接回车
if (line.length() == 0) {
if (blankReturn) return line;//如果blankReturn=true,可以返回空串
else continue; //如果blankReturn=false,不接受空串,必须输入内容
}
//如果用户输入的内容大于了 limit,就提示重写输入
//如果用户如的内容 >0 <= limit ,我就接受
if (line.length() < 1 || line.length() > limit) {
System.out.print("输入长度(不能大于" + limit + ")错误,请重新输入:");
continue;
}
break;
}
return line;
}
}
3.连接服务器的功能
若连接成功,发送User对象给服务器端,服务器端验证用户是否合法,回复一个Message对象给客户端
功能类
- 编写一个类UserClientService[用户登录,注册]
qqclient.service包
UserClientService类:两个属性、一个方法
(1)属性 User 存储用户信息,包括用户ID和密码
(2)属性 Socket 用于与服务器通讯
(3)方法checkUser( ) 到服务器验证该用户是否合法
- 定义一个Boolean类型的变量,判断用户是否合法
- 创建User对象
- 创建socket连接到服务器
- 将User对象通过对象输出流发送到服务器
- 读取从服务器回复的Message对象
- 若登录成功,启动一个线程"ClientConnectServerThread"与服务器保持通讯,并将该线程添加到管理线程的集合中,用true表示合法
- 若登录失败,不能启动和服务器通信的线程,关闭socket,用false表示不合法
package com.ma.qqclient.service;
import com.ma.qqcommon.Message;
import com.ma.qqcommon.MessageType;
import com.ma.qqcommon.User;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.net.InetAddress;
import java.net.Socket;
/**
* 该类主要完成用户的登录、验证
*/
public class UserClientService {
//把User做成属性
//因为我们可能在其他地方使用user信息,所以做成属性
private User u = new User();;
//因为Socket可能在其他地方使用,所以也做成属性
private Socket socket;
//根据userId和pwd到服务器验证该用户是否合法
public boolean checkUser(String userId, String pwd) {
boolean b = false;//定义一个变量判断用户是否合法
u.setUserId(userId);
u.setPwd(pwd);
//创建socket,发送User对象
try {
//1.连接本机的9999端口
socket = new Socket(InetAddress.getByName("127.0.0.1"), 9999);
//2.获取socket相关输出流,发送User对象
ObjectOutputStream oos = new ObjectOutputStream(socket.getOutputStream());
oos.writeObject(u);
//3.读取从服务端返回的Message对象
ObjectInputStream ois = new ObjectInputStream(socket.getInputStream());
Message message = (Message)ois.readObject();
if (message.getMesType().equals(MessageType.MESSAGE_LOGIN_SUCCEED)) {
//用户登录成功后,启动线程
//线程持有socket,保持客户端与服务端一直通讯
//创建一个和服务器端保持通信的线程 -> 创建一个类ClientConnectServerThread
//把socket传到线程类中,再把线程放到集合中去管理
//1.创建线程,并启动线程
ClientConnectServerThread clientConnectServerThread = new ClientConnectServerThread(socket);
clientConnectServerThread.start();
//2.把线程放到集合中
ManageClientConnectServerThread.addClientConnectServerThread(userId, clientConnectServerThread);
b = true;
} else {
//如果登录失败,不能启动和服务器通信的线程,关闭socket
socket.close();
}
} catch (Exception e) {
e.printStackTrace();
}
return b;
}
}
线程类
- 编写一个类ClientConnectServerThread 保持客户端与服务器的通信
qqclient.service包
ClientConnectServerThread类:一个属性、三个方法
(1)属性socket 持有与服务器端通讯的套接字
(2)构造方法ClientConnectServerThread( ) 通过传入的套接字与服务器进行通信
(3)方法getSocket( ) 以便外部类能够获取该线程持有的套接字对象
(4)方法run( ) 是线程执行的方法
- 通过循环保持与服务端的一直通信
- 打印提示信息
- 获取socket相关输入流,读取服务器发送的消息对象
package com.ma.qqclient.service;
import com.ma.qqcommon.Message;
import com.ma.qqcommon.MessageType;
import java.io.ObjectInputStream;
import java.net.Socket;
public class ClientConnectServerThread extends Thread{
//该线程需要持有Socket
private Socket socket;
//构造器可以接收一个Socket对象
public ClientConnectServerThread(Socket socket) {
this.socket = socket;
}
@Override
public void run() {
//因为Thread需要在后台和服务器通信,因此我们while循环
while (true) {
try {
System.out.println("客户端线程,等待从服务端发送的消息");
//获取socket相关输入流
//如果服务器没有发送Message对象,线程会阻塞
ObjectInputStream ois = new ObjectInputStream(socket.getInputStream());
Message message = (Message)ois.readObject();
//如果读取到的是服务器返回的在线用户列表
if (message.getMesType().equals(MessageType.MESSAGE_RET_ONLINE_FRIEND)) {
//取出在线用户列表,并显示
String[] onlineUsers = message.getContent().split(" ");
System.out.println("\n===========在线用户列表===========\n");
for (int i = 0; i < onlineUsers.length; i++) {
System.out.println("用户:" + onlineUsers[i]);
}
} else {
System.out.println("是其他类型的message,暂时不做处理");
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
//为了更方便得到Socket
public Socket getSocket() {
return socket;
}
}
线程管理类
- 编写一个类ManageClientConnectServerThread 管理客户端连接到服务器的线程
qqclient.service包
ManageClientConnectServerThread类:一个属性、两个方法
(1)属性HashMap 用于存储多个客户端线程
(2)静态方法addClientServerThread( ) 将某个用户Id对应的线程添加到集合中
(3)静态方法getClientConnectServerThread( ) 根据用户Id获取对应的线程
package com.ma.qqclient.service;
import java.util.HashMap;
/**
* 该类管理客户端连接到服务端线程的类
*/
public class ManageClientConnectServerThread {
//把多个线程放入一个HashMap集合,可以就是用户id,value是线程
private static HashMap<String, ClientConnectServerThread> hm = new HashMap<>();
//将某个线程加入集合
public static void addClientConnectServerThread(String userId, ClientConnectServerThread clientConnectServerThread) {
hm.put(userId, clientConnectServerThread);
}
//通过userId得到对应线程
public static ClientConnectServerThread getClientConnectServerThread(String userId) {
return hm.get(userId);
}
}
调用功能1.0
- 在QQView类中调用功能
(1)增加一个实例属性userClientService
→属性userClientService 是用于与服务器通信的服务类实例。
→将 UserClientService 类的实例设为 QQView 类的属性,可以在 QQView 类中直接调用 UserClientService 类的方法,提高代码复用性
private UserClientService userClientService = new UserClientService();//用于登录服务器
(2)通过该属性调用checkUser( )方法
4.服务端等待客户端连接
功能类
- 编写一个类QQServer 实现简单的QQ服务器端功能
qqserver.service包
QQServer类:一个属性、一个构造方法
(1)属性serverSocket 用于监听客户端的连接请求
(2)构造方法QQServer( ) 实现服务端的主要逻辑
- 打印提示信息
- 监听9999端口
- 使用while循环保持服务端的一直监听
- 客户端连接后,创建一个新的Socket对象用于和客户端保持通信
- 获取socket相关输入流,读取客户端发送的User对象
- 获取socket相关输出流,用于回复Message对象给客户端发送的User对象
- 创建一个Message对象,准备回复给客户端
- 验证固定用户
- 若验证成功,设置消息类型为成功,回复Message对象给客户端,并启动一个线程"ServerConnectClientThread"与客户端保持通讯,并将该线程添加到管理线程的集合中
- 若验证失败,设置消息类型为失败,回复Message对象给客户端,不能启动和客户端通信的线程,关闭socke
package com.ma.qqserver.service;
import com.ma.qqcommon.Message;
import com.ma.qqcommon.MessageType;
import com.ma.qqcommon.User;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.net.ServerSocket;
import java.net.Socket;
public class QQServer {
private ServerSocket serverSocket = null;
public QQServer() {
try {
//服务器监听9999端口,等待连接,并保持通信
//注意: 端口可以写在配置文件
System.out.println("服务端在9999端口监听,等待连接...");
serverSocket = new ServerSocket(9999);
//一直监听,当和某个客户端连接后,仍会继续监听
while (true) {
Socket socket = serverSocket.accept();//如果没有客户端连接,就会阻塞在这里
//得到socket关联的对象输入流
ObjectInputStream ois = new ObjectInputStream(socket.getInputStream());
//得到socket关联的对象输出流
ObjectOutputStream oos = new ObjectOutputStream(socket.getOutputStream());
//读取客户端发送的User对象
User u = (User)ois.readObject();
//创建一个Message对象,准备回复给客户端
Message message = new Message();
//验证
if (u.getUserId().equals("100") && u.getPwd().equals("123")) {//合法用户
message.setMesType(MessageType.MESSAGE_LOGIN_SUCCEED);
oos.writeObject(message);
//创建一个线程,和客户端保持通信,该线程需要持有socket对象
ServerConnectClientThread serverConnectClientThread = new ServerConnectClientThread(socket, u.getUserId());
serverConnectClientThread.start();//启动线程
//把该线程对象,放入一个集合中,进行管理
ManageClientThreads.addServerConnectClientThread(u.getUserId(), serverConnectClientThread);
} else {//登录失败
message.setMesType(MessageType.MESSAGE_LOGIN_FAIL);
oos.writeObject(message);
socket.close();//关闭socket
}
}
} catch (Exception e) {
e.printStackTrace();
} finally {//如果服务器退出了while,说明服务端不再监听,因此关闭ServerSocket
try {
serverSocket.close();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
}
线程类
- 编写一个类ServerConnectClientThread 保持服务端与客户端的通信
qqserver.service包
ServerConnectClientThread类:一个属性、两个方法
(1)属性socket 用于和客户端进行通信
(2)属性userId 连接到服务端的用户Id
(3)构造方法ServerConnectClientThread( ) 根据传入的套接字和用户Id创建线程
(4)方法run( ) 是线程执行的主体,负责处理客户端发送的消息并进行相应的业务处理
- 通过循环持续监听客户端发来的消息
- 打印通讯的提示信息
- 获取socket相关输入流,读取服务器发送的消息对象
- 根据消息类型进行相应的业务处理
package com.ma.qqserver.service;
import com.ma.qqcommon.Message;
import com.ma.qqcommon.MessageType;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.net.Socket;
/**
* 该类的一个对象和某个客户端保持通信
*/
public class ServerConnectClientThread extends Thread {
private Socket socket;
//连接到服务端的用户id
private String userId;
public ServerConnectClientThread(Socket socket, String userId) {
this.socket = socket;
this.userId = userId;
}
//线程处于run状态,可以发送和接受消息
@Override
public void run() {
while (true) {
try {
System.out.println("服务端和客户端" + userId + "保持通讯,读取数据...");
ObjectInputStream ois = new ObjectInputStream(socket.getInputStream());
Message message = (Message) ois.readObject();
//根据message的类型,做相应的业务处理...
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
线程管理类
- 编写一个类ManageClientThreads 管理服务端和客户端通信的线程
qqserver.service包
ManageClientThreads类:一个属性、两个方法
(1)属性HashMap 用于存储和客户端通信的线程对象
(2)静态方法addServerConnectClientThread( ) 将某个用户Id对应的线程添加到集合中
(3)静态方法ServerConnectClientThread 根据用户Id获取对应的线程
package com.ma.qqserver.service;
import java.util.HashMap;
import java.util.Iterator;
/**
* 该类用于管理和客户端通信的线程
*/
public class ManageClientThreads {
private static HashMap<String, ServerConnectClientThread> hm = new HashMap<>();
//添加线程对象到hm集合
public static void addServerConnectClientThread(String userId, ServerConnectClientThread serverConnectClientThread) {
hm.put(userId, serverConnectClientThread);
}
//根据userId返回ServerConnectClientThread线程
public static ServerConnectClientThread getServerConnectClientThread(String userId) {
return hm.get(userId);
}
}
程序入口
- 编写一个类QQFrame 作为程序入口,启动后台服务
qqframe包
package com.ma.qqframe;
import com.ma.qqserver.service.QQServer;
/**
* 该类创建QQServer,启动后台服务器
*/
public class QQFrame {
public static void main(String[] args) {
new QQServer();
}
}
5.多个用户登录
- 服务端QQServer
(1)增加一个属性HashMap 存放多个用户,并在静态代码块中初始化
//创建一个HashMap集合,存放多个用户,如果是这些用户登录,就认为是合法
//也可以使用ConcurrentHashMap, 处理并发的集合,没有线程安全问题
//HashMap 没有处理线程安全,因此在多线程情况下是不安全的
//ConcurrentHashMap 处理线程安全,即线程同步处理,在多线程情况下是安全的
private static HashMap<String, User> validUsers = new HashMap<>();
//在静态代码块中,初始化validUsers
static {
//键是userId, 值是用户对象(账号, 密码)
validUsers.put("100", new User("100", "123456"));
validUsers.put("hsp", new User("hsp", "123456"));
validUsers.put("至尊宝", new User("至尊宝", "123456"));
validUsers.put("紫霞仙子", new User("紫霞仙子", "123456"));
}
(2)增加一个方法checkUser( ) 验证用户是否有效
//写个验证用户是否有效的方法
public boolean checkUser(String userId, String pwd) {
//过关斩将验证法
//通过键获取值
User user = validUsers.get(userId);
//过关斩将验证法
if (user == null) {//说明userId没有存在validUsers的key中
return false;
}
if (!(user.getPwd()).equals(pwd)) {//userId正确,但是密码错误
return false;
}
return true;
}
(3)调用checkUser( )方法
checkUser(u.getUserId(),u.getPwd())
- 设置idea运行多个实例,启动多个用户(2021以上版本)
6.拉取在线用户
(1)扩展MessageType的类型(两端都要)
String MESSAGE_COMM_MES = "3";//普通信息包
String MESSAGE_GET_ONLINE_FRIEND = "4";//客户端请求返回在线用户列表
String MESSAGE_RET_ONLINE_FRIEND = "5";//服务端返回在线用户列表
String MESSAGE_CLIENT_EXIT = "6";//客户端请求退出
客户端
UserClientService
(2)增加一个方法onlineFriendList( ) 向服务端请求在线用户列表
-
创建一个Message对象,设置消息类型为 MESSAGE_GET_ONLINE_FRIEND,发送者为当前用户的 ID
-
从管理线程的集合里拿到userId对应的线程 -> 通过线程拿到关联的socket -> 获取socket相关输出流
-
将Message对象发送给服务端
//向服务端请求在线用户列表 public void onlineFriendList() { //发送一个Message,类型MESSAGE_GET_ONLINE_FRIEND //1.创建Message对象, 记得设置Sender为userId,否则服务端拿不到Sender对象 Message message = new Message(); message.setMesType(MessageType.MESSAGE_GET_ONLINE_FRIEND); message.setSender(u.getUserId()); //2.发送给服务器 //需要得到当前线程的Socket对应的ObjectOutputStream对象 //从管理线程的集合里拿到userId对应的线程 -> 通过线程拿到关联的socket -> 获取socket相关输出流 try { ClientConnectServerThread clientConnectServerThread = ManageClientConnectServerThread.getClientConnectServerThread(u.getUserId()); socket = clientConnectServerThread.getSocket(); ObjectOutputStream oos = new ObjectOutputStream(socket.getOutputStream()); oos.writeObject(message); } catch (IOException e) { throw new RuntimeException(e); } }
ClientConnectServerThread
(3)处理相应类型的Message
- 如果是 MESSAGE_RET_ONLINE_FRIEND,取出在线列表信息并显示
- 先获取服务端发送的消息内容
- 再用for循环遍历在线用户数组,逐个输出
- 如果是其他类型,暂时不做处理
if (message.getMesType().equals(MessageType.MESSAGE_RET_ONLINE_FRIEND)) {
//取出在线用户列表,并显示
String[] onlineUsers = message.getContent().split(" ");
System.out.println("\n===========在线用户列表===========\n");
for (int i = 0; i < onlineUsers.length; i++) {
System.out.println("用户:" + onlineUsers[i]);
}
} else {
System.out.println("是其他类型的message,暂时不做处理");
}
服务端
ManageClientThreads
(4)增加一个方法getOnlineUser( ) 返回在线用户列表
-
使用迭代器遍历HashMap集合
-
先获取一个迭代器
-
再定义一个变量存储返回的字符串
-
循环拼接用户,以空格分隔
回顾迭代器遍历的知识点
-
Iterator对象称为迭代器
-
iterator( )方法:用以返回一个实现了Iterator接口的对象,即可以返回一个迭代器
-
hasNext( )方法:判断是否还有下一个元素
-
next( )方法:①指针下移 ②将下移以后集合位置上的元素返回
-
在调用iterator.next( )方法之前必须调用iterator.hasNext( )进行检测。若不调用,且下一条记录无效,直接调用iterator.next( )会抛出NoSuchElementException异常
//返回在线用户列表
public static String getOnlineUsers() {
///使用迭代器遍历集合,HashMap的key
Iterator<String> iterator = hm.keySet().iterator();
String onlineUserList = "";//定义变量接收返回的用户
while (iterator.hasNext()) {
onlineUserList += iterator.next() + " ";
}
return onlineUserList;
}
ServerConnectClientThread
(5)根据Message的类型,做相应的业务处理
- 如果是 MESSAGE_GET_ONLINE_FRIEND
- 提示一句话,便于debugger
- 调用getOnlineUser( )获取在线用户列表
- 创建一个Message对象,设置消息类型为 MESSAGE_RET_ONLINE_FRIEND,接受者就是原来的发送者
- 将Message对象发送给客户端
//根据message的类型,做相应的业务处理
if (message.getMesType().equals(MessageType.MESSAGE_GET_ONLINE_FRIEND)) {
System.out.println("用户 " + message.getSender() + " 要在线用户列表");
//调用getOnlineUser()方法, 获取在线用户列表
String onlineUsers = ManageClientThreads.getOnlineUsers();
//创建一个Message对象
Message message2 = new Message();
message2.setMesType(MessageType.MESSAGE_RET_ONLINE_FRIEND);
message2.setContent(onlineUsers);
message2.setGetter(message.getSender());
//返回给客户端
ObjectOutputStream oos = new ObjectOutputStream(socket.getOutputStream());
oos.writeObject(message2);
}
测试
(6)在客户端调用方法,获取在线用户列表
QQView
userClientService.onlineFriendList();
(7)测试结果
7.无异常退出
当我们启动服务端,并在客户端登录成功后,选择功能9,程序其实没有真正退出,原因是子线程没有结束
虽然主线程结束了,但子线程还在运行
客户端
UserClientService
(1)增加一个方法logout( ) 向服务端请求退出系统
- 创建一个Message对象,设置类型为MESSAGE_CLIENT_EXIT,发送者为对应的客户端id
- 从管理线程的集合里拿到userId对应的线程 -> 通过线程拿到关联的socket -> 获取socket相关输出流,发送message
- 提示哪个用户退出了系统
- 结束进程
public void logout() {
//创建一个Message对象,类型为MESSAGE_CLIENT_EXIT
Message message = new Message();
message.setMesType(MessageType.MESSAGE_CLIENT_EXIT);
message.setSender(u.getUserId());//一定要指定是哪个客户端id
try {
//发送message
//ObjectOutputStream oos = new ObjectOutputStream(socket.getOutputStream());
ObjectOutputStream oos = new ObjectOutputStream(ManageClientConnectServerThread.getClientConnectServerThread(u.getUserId()).getSocket().getOutputStream());
oos.writeObject(message);
System.out.println(u.getUserId() + " 退出系统");
//结束进程
System.exit(0);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
QQView
(2)调用方法,给服务器发送一个退出系统的message
userClientService.logout();
服务端
ServerConnectClientThread
(3)根据类型MESSAGE_CLIENT_EXIT,做相应的业务处理
- 调用方法,从集合中移除某个线程对象
- 关闭连接
- 退出线程
else if (message.getMesType().equals(MessageType.MESSAGE_CLIENT_EXIT)) {
System.out.println(message.getSender() + " 退出");
//调用方法,从集合中移除某个线程对象
ManageClientThreads.removeServerConnectClientThread(message.getSender());
//关闭连接
socket.close();
//退出线程
break;
}
ManageClientThreads
(4)增加一个方法removeServerConnectClientThread( ) 根据userId从集合中移除某个线程对象
public static void removeServerConnectClientThread(String userId) {
hm.remove(userId);
}
测试
8.私聊
客户端
QQView
(1)提示用户输入
System.out.print("请输入想聊天的用户号(在线): ");
String getterId = Utility.readString(50);
System.out.print("请输入想说的话: ");
String content = Utility.readString(100);
// 编写一个方法,将消息发送给服务端
(2)增加一个类,提供和消息有关的服务方法
MessageClientService类:一个方法
方法sendMessageOne( ) 发送私聊信息
- 创建一个Messaage对象,设置消息类型、发送者、接收者、内容和时间
- 提示一句话:谁对谁说了什么
- 发送message给服务端
package com.ma.qqclient.service;
import com.ma.qqcommon.Message;
import com.ma.qqcommon.MessageType;
import java.io.IOException;
import java.io.ObjectOutputStream;
import java.util.Date;
/**
* 该类/对象,提供和消息有关的服务方法
*/
public class MessageClientService {
/**
*
* @param content 发送内容
* @param senderId 发送者用户Id
* @param getterId 接收者用户Id
*
*/
public void sendMessageToOne(String content, String senderId, String getterId){
//创建一个Message对象
//设置消息类型、发送者、接收者、内容和时间
Message message = new Message();
message.setMesType(MessageType.MESSAGE_COMM_MES);
message.setContent(content);
message.setSender(senderId);
message.setGetter(getterId);
message.setSendTime(new Date().toString());//发送时间设置到message对象
System.out.println(senderId + " 对 " + getterId + " 说: " + content);
//发送message
try {
ObjectOutputStream oos = new ObjectOutputStream(ManageClientConnectServerThread.getClientConnectServerThread(senderId).getSocket().getOutputStream());
oos.writeObject(message);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
(3)在QQView类中,调用方法发送私聊信息
- 增加一个实例属性MessageClientService
private MessageClientService messageClientService = new MessageClientService();//用于私聊/群聊
- 通过该属性调用sendMessageToOne( )方法
messageClientService.sendMessageToOne(content, userId, getterId);
服务端
ServerConnectClientThread
(3)根据类型MESSAGE_COMM_MES,做相应的业务处理
-
根据message获取接收者的id,得到对应的线程
-
再得到对应socket的对象输出流,将message对象转发给指定的客户端
else if (message.getMesType().equals(MessageType.MESSAGE_COMM_MES)){
//根据message获取接收者的id,得到对应的线程
ServerConnectClientThread serverConnectClientThread = ManageClientThreads.getServerConnectClientThread(message.getSender());
//再得到对应socket的对象输出流,将message对象转发给指定的客户端
ObjectOutputStream oos = new ObjectOutputStream(serverConnectClientThread.getSocket().getOutputStream());
oos.writeObject(message);//转发消息,提示——如果客户不在线,可以保存到数据库,这里可实现数据留言
}
- 补充一个getSocket( )方法,更方便得到socket
public Socket getSocket() {
return socket;
}
有人在视频里讨论属性socket和getSocket()是否为同一个socket?
- 这里认为是同一个socket,因为已经根据message获取接收者的id,得到了接收者对应的线程,所以无论是通过属性还是通过方法来获取 Socket 对象,都会得到同一个 Socket对象的引用
- 建议大家自行测试,看两种获取socket的方法是否都能实现私聊功能
返回客户端
ClientConnectServerThread
(4)处理MESSAGE_COMM_MES类型的Message
- 把服务器转发的消息,显示到控制台
else if (message.getMesType().equals(MessageType.MESSAGE_COMM_MES)) {//普通的聊天消息
//把服务器转发的消息,显示到控制台
System.out.println("\n" + message.getSender() + " 对你说: " + message.getContent());
}
测试
9.群聊
(1)扩展新的消息类型(两端都要)
String MESSAGE_TO_ALL_MES = "7";//群发消息报
客户端
QQView
(2)提示用户输入
System.out.print("请输入想聊天的用户号(在线): ");
String getterId = Utility.readString(50);
System.out.print("请输入想说的话: ");
String content = Utility.readString(100);
// 编写一个方法,将消息发送给服务端
MessageClientService
(3)增加一个方法
方法sendMessageToAll( ),发送群聊信息
- 创建一个Messaage对象,设置消息类型、发送者、内容和时间
- 提示一句话:谁对大家说了什么
- 发送message给服务端
/**
*
* @param content 发送内容
* @param senderId 发送者用户Id
*
*/
public void sendMessageToAll(String content, String senderId){
//创建一个Message对象
//设置消息类型、发送者、内容和时间
Message message = new Message();
message.setMesType(MessageType.MESSAGE_TO_ALL_MES);
message.setContent(content);
message.setSender(senderId);
message.setSendTime(new Date().toString());//发送时间设置到message对象
System.out.println(senderId + " 对大家说: " + content);
//发送message
try {
ObjectOutputStream oos = new ObjectOutputStream(ManageClientConnectServerThread.getClientConnectServerThread(senderId).getSocket().getOutputStream());
oos.writeObject(message);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
(4)在QQView类中,调用方法发送群聊信息
messageClientService.sendMessageToOne(content, userId, getterId);
服务端
ServerConnectClientThread
(5)根据类型MESSAGE_TO_ALL_MES,做相应的业务处理
- 遍历管理线程的集合(得先在ManageClientThreads类中,补充一个获取所有集合的方法)
- 得到所有socket
- 转发message
//返回线程集合hm
public static HashMap<String, ServerConnectClientThread> getHm() {
return hm;
}
else if (message.getMesType().equals(MessageType.MESSAGE_TO_ALL_MES)) {
//遍历管理线程的集合,把所有线程的socket得到,然后转发message
//获取所有线程
HashMap<String, ServerConnectClientThread> hm = ManageClientThreads.getHm();
//迭代器遍历
Iterator<String> iterator = hm.keySet().iterator();
while (iterator.hasNext()) {
String onlineUserId = iterator.next().toString();
if (!onlineUserId.equals(message.getSender())) {//排除发送者
//获取对应socket的输出流
ObjectOutputStream oos = new ObjectOutputStream(hm.get(onlineUserId).getSocket().getOutputStream());
oos.writeObject(message);
}
}
}
返回客户端
ClientConnectServerThread
(6)处理MESSAGE_TO_ALL_MES类型的Message
-
把服务器转发的消息,显示到控制台
else if (message.getMesType().equals(MessageType.MESSAGE_TO_ALL_MES)) { System.out.println("\n" + message.getSender() + " 对大家说: " + message.getContent()); }
测试
10.发文件
(1)扩展新的消息类型(两端都要)
String MESSAGE_FILE_MES = "8";//文件消息(发送文件)
(2)扩展Message类 (两端都要)
//扩展和文件相关的成员
private byte[] fileBytes;
private int fileLen = 0;
private String src;//源文件路径
private String dest;//文件传输目标路径
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 getSrc() {
return src;
}
public void setSrc(String src) {
this.src = src;
}
public String getDest() {
return dest;
}
public void setDest(String dest) {
this.dest = dest;
}
客户端
QQView
(3)提示用户输入
System.out.print("请输入想发送文件的用户号(在线): ");
getterId = Utility.readString(50);
System.out.print("请输入发送的文件完整路径(形式d:\\xxx.jpg): ");
String src = Utility.readString(100);
System.out.print("请输入发送文件到对方的路径(形式d:\\xxx.jpg): ");
String dest = Utility.readString(100);
// 编写一个方法,发送文件
(4)增加一个类,完成文件传输服务
FileClientService类:一个方法
方法sendFileToOne( ) 发送文件
- 创建一个Messaage对象,设置消息类型、源文件路径、目标路径、发送者和接收者
- 创建一个FileInputStream对象用于读取源文件src
- 根据源文件的大小创建一个对应大小的字节数组fileBytes,用于存储文件内容
- 将源文件的内容读取到fileBytes数组中
- 再将文件对应的字节数组设置到message
- 打印发送文件的提示信息
- 发送message给服务端
package com.ma.qqclient.service;
import com.ma.qqcommon.Message;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.ObjectOutputStream;
/**
* 该类/对象完成文件传输服务
*/
public class FileClientService {
/***
*
* @param src 源文件路径
* @param dest 目标路径
* @param senderId 发送者
* @param getterId 接收者
*
*/
public void sendFileToOne(String src, String dest, String senderId, String getterId) {
//读取src文件 --> 构建成Message
Message message = new Message();
message.setMesType(MessageType.MESSAGE_FILE_MES);
message.setSender(senderId);
message.setGetter(getterId);
message.setSrc(src);
message.setDest(dest);
//读取文件
FileInputStream fileInputStream = null;
byte[] fileBytes = new byte[(int)new File(src).length()];
//将src文件读入到字节数据
try {
fileInputStream = new FileInputStream(src);
fileInputStream.read(fileBytes);//将src文件读入到字节数组
message.setFileBytes(fileBytes);//将文件对应的字节数组设置到message
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
//关闭
if (fileInputStream != null) {
try {
fileInputStream.close();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
//提示信息
System.out.println("\n" + senderId + " 给 " + getterId + " 发送文件: " + src + " 到对方的电脑目录 " + dest);
//发送
try {
ObjectOutputStream oos = new ObjectOutputStream(ManageClientConnectServerThread.getClientConnectServerThread(senderId).getSocket().getOutputStream());
oos.writeObject(message);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
服务端
ServerConnectClientThread
(5)根据类型MESSAGE_FILE_MES,做相应的业务处理
- 根据getter id 获取对应的线程
- 再得到对应socket的对象输出流,将message对象转发
//根据getter id 获取对应的线程,将message对象转发
ObjectOutputStream oos = new ObjectOutputStream(ManageClientThreads.getServerConnectClientThread(message.getGetter()).getSocket().getOutputStream());
oos.writeObject(message);
返回客户端
ClientConnectServerThread
(6)处理MESSAGE_FILE_MES类型的Message
- 打印发送文件的提示信息
- 取出message文件字节数组
- 通过文件输出流写入到磁盘
- 关闭输出流
- 提示文件发送成功的信息
System.out.println("\n" + message.getSender() + " 给你发送文件: " + message.getSrc() + " 到你的电脑目录 " + message.getDest());
//取出message文件字节数组,通过文件输出流写入到磁盘
byte[] fileBytes = message.getFileBytes();
FileOutputStream fos = new FileOutputStream(message.getDest());
fos.write(fileBytes);
fos.close();
System.out.println("文件发送成功!");
测试
11.服务端推送新闻
工具类——Utility类(直接使用)
utils包
package com.ma.utils;
/**
工具类的作用:
处理各种情况的用户输入,并且能够按照程序员的需求,得到用户的控制台输入。
*/
import java.util.Scanner;
public class Utility {
//静态属性。。。
private static Scanner scanner = new Scanner(System.in);
/**
* 功能:读取键盘输入的一个菜单选项,值:1——5的范围
* @return 1——5
*/
public static char readMenuSelection() {
char c;
for (; ; ) {
String str = readKeyBoard(1, false);//包含一个字符的字符串
c = str.charAt(0);//将字符串转换成字符char类型
if (c != '1' && c != '2' &&
c != '3' && c != '4' && c != '5') {
System.out.print("选择错误,请重新输入:");
} else break;
}
return c;
}
/**
* 功能:读取键盘输入的一个字符
* @return 一个字符
*/
public static char readChar() {
String str = readKeyBoard(1, false);//就是一个字符
return str.charAt(0);
}
/**
* 功能:读取键盘输入的一个字符,如果直接按回车,则返回指定的默认值;否则返回输入的那个字符
* @param defaultValue 指定的默认值
* @return 默认值或输入的字符
*/
public static char readChar(char defaultValue) {
String str = readKeyBoard(1, true);//要么是空字符串,要么是一个字符
return (str.length() == 0) ? defaultValue : str.charAt(0);
}
/**
* 功能:读取键盘输入的整型,长度小于2位
* @return 整数
*/
public static int readInt() {
int n;
for (; ; ) {
String str = readKeyBoard(2, false);//一个整数,长度<=2位
try {
n = Integer.parseInt(str);//将字符串转换成整数
break;
} catch (NumberFormatException e) {
System.out.print("数字输入错误,请重新输入:");
}
}
return n;
}
/**
* 功能:读取键盘输入的 整数或默认值,如果直接回车,则返回默认值,否则返回输入的整数
* @param defaultValue 指定的默认值
* @return 整数或默认值
*/
public static int readInt(int defaultValue) {
int n;
for (; ; ) {
String str = readKeyBoard(10, true);
if (str.equals("")) {
return defaultValue;
}
//异常处理...
try {
n = Integer.parseInt(str);
break;
} catch (NumberFormatException e) {
System.out.print("数字输入错误,请重新输入:");
}
}
return n;
}
/**
* 功能:读取键盘输入的指定长度的字符串
* @param limit 限制的长度
* @return 指定长度的字符串
*/
public static String readString(int limit) {
return readKeyBoard(limit, false);
}
/**
* 功能:读取键盘输入的指定长度的字符串或默认值,如果直接回车,返回默认值,否则返回字符串
* @param limit 限制的长度
* @param defaultValue 指定的默认值
* @return 指定长度的字符串
*/
public static String readString(int limit, String defaultValue) {
String str = readKeyBoard(limit, true);
return str.equals("")? defaultValue : str;
}
/**
* 功能:读取键盘输入的确认选项,Y或N
* 将小的功能,封装到一个方法中.
* @return Y或N
*/
public static char readConfirmSelection() {
System.out.println("请输入你的选择(Y/N)");
char c;
for (; ; ) {//无限循环
//在这里,将接受到字符,转成了大写字母
//y => Y n=>N
String str = readKeyBoard(1, false).toUpperCase();
c = str.charAt(0);
if (c == 'Y' || c == 'N') {
break;
} else {
System.out.print("选择错误,请重新输入:");
}
}
return c;
}
/**
* 功能: 读取一个字符串
* @param limit 读取的长度
* @param blankReturn 如果为true ,表示 可以读空字符串。
* 如果为false表示 不能读空字符串。
*
* 如果输入为空,或者输入大于limit的长度,就会提示重新输入。
* @return
*/
private static String readKeyBoard(int limit, boolean blankReturn) {
//定义了字符串
String line = "";
//scanner.hasNextLine() 判断有没有下一行
while (scanner.hasNextLine()) {
line = scanner.nextLine();//读取这一行
//如果line.length=0, 即用户没有输入任何内容,直接回车
if (line.length() == 0) {
if (blankReturn) return line;//如果blankReturn=true,可以返回空串
else continue; //如果blankReturn=false,不接受空串,必须输入内容
}
//如果用户输入的内容大于了 limit,就提示重写输入
//如果用户如的内容 >0 <= limit ,我就接受
if (line.length() < 1 || line.length() > limit) {
System.out.print("输入长度(不能大于" + limit + ")错误,请重新输入:");
continue;
}
break;
}
return line;
}
}
服务端
(1)增加一个线程类,用于推送新闻/消息
SendNewsToAllService类:一个方法
方法run( )
-
使用循环,便于多次推送新闻/消息
-
设置退出循环的条件
-
一个Message对象,设置消息类型、发送者、内容和时间
-
提示服务器推送消息
-
遍历当前所有通信线程
-
通过onlineUserId得到socket, 并发送message
package com.ma.qqserver.service; import com.ma.qqcommon.Message; import com.ma.qqcommon.MessageType; import com.ma.utils.Utility; import java.io.IOException; import java.io.ObjectOutputStream; import java.util.Date; import java.util.HashMap; import java.util.Iterator; /** * 该类用于推送新闻/消息 */ public class SendNewsToAllService extends Thread{ @Override public void run() { while (true) { System.out.println("请输入服务器要推送的新闻/消息(输入exit退出退送服务): "); String news = Utility.readString(100); if ("exit".equals(news)) { System.out.println("成功退出推送服务!"); break; } //构建一个消息类型,群发消息 Message message = new Message(); message.setMesType(MessageType.MESSAGE_TO_ALL_MES); message.setSender("服务器"); message.setContent(news); message.setSendTime(new Date().toString()); System.out.println("服务器推送消息给所有人, 说: " + news); //遍历当前所有通信线程 HashMap<String, ServerConnectClientThread> hm = ManageClientThreads.getHm(); Iterator<String> iterator = hm.keySet().iterator(); while (iterator.hasNext()) { String onlineUserId = iterator.next(); try { //通过onlineUserId得到socket, 并发送message ObjectOutputStream oos = new ObjectOutputStream(hm.get(onlineUserId).getSocket().getOutputStream()); oos.writeObject(message); } catch (IOException e) { throw new RuntimeException(e); } } } } }
QQServer
- 启动推送服务的线程
//启动推送服务的线程
new Thread(new SendNewsToAllService()).start();
serverSocket = new ServerSocket(9999);
测试
12.扩展: 离线留言与离线发文件
实现离线留言,如果某个用户不在线,当登录后,可以接受离线消息
实现离线发文件,如果某个用户不在线,当登录后,可以接受离线的文件
服务端
(1)增加一个功能类,用于处理离线消息
SendOfflineMessageService类:一个属性、四个方法
-
属性offlineUsers 用于存储多个离线用户的消息列表
-
方法getOfflineUsers( ) 获取离线用户消息列表
-
方法addOfflineMessage( ) 向离线用户的消息列表中添加消息
① 检查离线用户集合中是否已存在消息接收者
② 如果不存在,则创建一个新的消息列表存储离线消息,将离线用户及其消息存储到集合
③ 如果存在,则直接获取该接收者对应的消息列表,并将消息添加到列表中
④ 提示给用户的消息/文件已保存 -
方法sendOfflineMessage( ) 在离线用户上线后,将之前保存的离线消息发送给该用户
① 检查离线用户集合中是否存在指定的用户
② 如果存在,则获取该用户对应的消息列表
③ 将消息列表发送给对应的客户端线程,以便离线用户上线后可以接收到消息/文件
④ 如果不存在,则输出用户无离线消息/文件 -
方法deleteMessage( ) 从离线消息集合中删除已发送的离线消息
package com.ma.qqserver.service;
import com.ma.qqcommon.Message;
import java.io.IOException;
import java.io.ObjectOutputStream;
import java.util.ArrayList;
import java.util.concurrent.ConcurrentHashMap;
/**
* 该类用于处理离线用户消息
*/
public class SendOfflineMessageService {
// 存储离线用户及其消息
private static ConcurrentHashMap<String, ArrayList<Message>> offlineUsers = new ConcurrentHashMap<>();
// 获取离线用户集合
public static ConcurrentHashMap<String, ArrayList<Message>> getOfflineUsers() {
return offlineUsers;
}
//添加离线消息
public static void addOfflineMessage(Message message) {
//多位用户向同一个离线用户发送信息
//验证该离线用户是否存在于集合中
if(!offlineUsers.containsKey(message.getGetter())) {//不存在
ArrayList<Message> messages = new ArrayList<>();
messages.add(message);
offlineUsers.put(message.getGetter(), messages);
} else {//存在
ArrayList<Message> messages = offlineUsers.get(message.getGetter());
messages.add(message);
}
System.out.println("给用户 " + message.getGetter() + " 的消息/文件已保存!");
}
//当离线用户上线后,发送对应的消息/文件
public static void sendOfflineMessage(String userId, ConcurrentHashMap offlineUsers) {
if (offlineUsers.containsKey(userId)) {
ArrayList<Message> messages = (ArrayList<Message>)offlineUsers.get(userId);
try {
ObjectOutputStream oos = new ObjectOutputStream(ManageClientThreads.getServerConnectClientThread(userId).getSocket().getOutputStream());
oos.writeObject(messages);
System.out.println("消息/文件已发送给用户: " + userId);
} catch (IOException e) {
throw new RuntimeException(e);
}
} else {
System.out.println("用户 " + userId + " 无离线消息/文件");
}
}
//删除已发送的离线消息
public static void deleteMessage(String userId) {
offlineUsers.remove(userId);
}
}
ManangeClientThreads
(2)增加一个方法
方法isOnline( ) 判断用户是否在线
//根据userId判断用户是否在线
public static boolean isOnline(String userId) {
return hm.containsKey(userId);
}
ServerConnectClientThread
(3)修改MESSAGE_COMM_MES和MESSAGE_FILE_MES类型的代码
-
根据message获取接收者的id,得到对应的线程
-
判断用户是否在线
-
如果用户在线,提示用户在线,并获取对应socket的对象输出流,将message对象转发给指定的客户端,实现消息的即时转发
-
当用户不在线,提示用户不在线,并将消息通过 addOfflineMessage方法保存到离线消息集合中
//根据message获取接收者的id,得到对应的线程
ServerConnectClientThread serverConnectClientThread = ManageClientThreads.getServerConnectClientThread(message.getGetter());
if (ManageClientThreads.isOnline(message.getGetter())) {//用户在线
//再得到对应socket的对象输出流,将message对象转发给指定的客户端
System.out.println(message.getGetter() + " 在线, 消息已转发!");
ObjectOutputStream oos = new ObjectOutputStream(serverConnectClientThread.getSocket().getOutputStream());
oos.writeObject(message);
} else {//用户不在线,消息可以保存到集合(模拟数据库),实现离线留言
System.out.println(message.getGetter() + " 不在线, 消息已保存");
SendOfflineMessageService.addOfflineMessage(message);
}
//根据getter id 获取对应的线程
ServerConnectClientThread serverConnectClientThread = ManageClientThreads.getServerConnectClientThread(message.getGetter());
if (ManageClientThreads.isOnline(message.getGetter())) {//用户在线
ObjectOutputStream oos = new ObjectOutputStream(serverConnectClientThread.getSocket().getOutputStream());
oos.writeObject(message);
System.out.println(message.getGetter() + " 在线, 文件发送成功!");
} else {//用户不在线,消息可以保存到集合(模拟数据库),实现离线发文件
SendOfflineMessageService.addOfflineMessage(message);
System.out.println(message.getGetter() + " 不在线, 文件已保存");
}
QQServer
(4)调用发送和删除离线消息的方法
- 当用户成功登录时,服务端会检查该用户是否有离线消息
- 如果有,则将离线消息发送给用户,并在发送后删除这些已发送的离线消息。
//发送可能存在的离线消息,并删除已发送的离线消息
SendOfflineMessageService.sendOfflineMessage(u.getUserId(), SendOfflineMessageService.getOfflineUsers());
SendOfflineMessageService.deleteMessage(u.getUserId());
客户端
ClientConnectServerThread
(5)增加处理离线消息的代码
- 用户登录时,要查看是否有离线消息
- 获取socket相关输入流,读取传入的信息
- 判断读取到的对象是离线消息列表还是普通的消息对象,并分别进行处理
- 如果是离线消息列表,需要遍历输出对应的离线消息/文件
- 如果是普通的消息对象,要先判断对象非空,再进行对应的处理,否则会产生NullPointerException的异常信息
//查看是否有离线消息
Object object = ois.readObject();
Message message = null;
if (object instanceof ArrayList) {
ArrayList<Message> messages = (ArrayList<Message>)object;
System.out.println("==========你的离线消息/文件如下==========");
for (Message m : messages) {
if (m.getMesType().equals(MessageType.MESSAGE_COMM_MES)) {
System.out.println(m.getSender() + " 说: " + m.getContent());
}
if (m.getMesType().equals(MessageType.MESSAGE_FILE_MES)) {
System.out.println(m.getSender() + " 发送文件到到你的电脑目录: " + m.getDest());
}
}
} else {
message = (Message)object;
}
//如果读取到的是服务器返回的在线用户列表
if (message != null) {
...
}
QQView
若已完善离线功能,可修改提示信息,去掉"(在线)"
测试
四、项目总结
1.学到的知识点
(1)通信时有不同的功能,可以设置消息类型来判定不同的消息并对其进行处理,不同的消息类型常量在接口中定义。
(2)用对象流传递数据,可以简化数据处理过程,提高系统高效性。
(3)用集合来管理线程,便于后期更方便地管理和操作多个线程。
(4)在类的设计中,若需要引用其他类,可将其设计为类的属性以增强模块化和可维护性。
2.对项目知识点的理解程度
(1)Socket编程:其实就是两台计算机之间的通信方式,可以类比成两个人互相写信,你写一封信给对方,对方回一封信给你。只不过在这里,计算机是通过socket进行通信。客户端发起连接,服务器端接收请求连接,形成一个数据通道,通过socket获取输入流/输出流,就可以进行数据的读取操作。
(2)对象流传递数据:当客户端和用户端双向通讯时,会涉及大量信息类型,这时用对象流来传递数据就方便多了。通俗易懂地解释对象流,就是把一些东西打包好,通过网络传递给对方,对方再拆开,获取自己想要的内容。
(3)集合管理线程:可以将其类比成班里的“花名册”,上面记录了每个同学的信息。在这个项目中,我们也有一个“花名册”,上面记录了每一个用户的信息,通过用户id就能找到对应的线程。
(4)多线程编程:处理数据会消耗一定的时间,当要处理的数据变多了,就得用多线程来同时处理多个客户端的连接请求或响应多个客户端的请求。多线程编程就是让我们的程序能够同时处理多个任务,提高了程序的效率和响应速度。
3.踩过的坑
(1)消息类型设置错误:在实现“拉取在线用户列表”功能时,消息类型设置错误,导致“拉取在线用户列表”的业务处理无法正确执行,例如:onlineFriendList()方法没有被正确调用到,所以在创建Message消息对象时,务必看准消息类型,以确保后续业务逻辑的正确执行。
(2)消息类型未设置:在实现“发文件”功能时,消息类型未设置,导致“发文件”的业务处理无法正确执行,引发空指针异常,当把消息类型正确设置后,代码就能正常执行。
(3)错误使用readObject()方法导致阻塞:在实现“离线留言”功能时,客户端需要判断并处理留言信息,原本思路是根据接收到的是 ArrayList对象还是Message对象来做相应的处理,但readObject()方法会阻塞直到有对象可读,下面的代码如果没有ArrayList对象可读,就一直会阻塞在那一行,解决方法就是扩大ois.readObject()的作用域并添加else语句。
//我的错误代码如下
if (ois.readObject() instanceof ArrayList) {
ArrayList<Message> messages = (ArrayList<Message>)ois.readObject();
System.out.println("==========你的离线消息/文件如下==========");
for (Message m : messages) {
if (m.getMesType().equals(MessageType.MESSAGE_COMM_MES)) {
System.out.println(m.getSender() + " 说: " + m.getContent());
}
}
}
Message message = (Message)ois.readObject();