韩顺平-模拟QQ离线留言项目:
项目相关背景:
这是在学习韩老师的java基础课的QQ通信系统中离线留言功能实现时产生的问题。
问题描述
问题:
在一个用户给另一个仍未上线的用户(即离线用户)发送消息时,由于对方用户离线,服务端会将message先存储起来,等待该用户上线再转发message给他。但是当我这样处理的时候,出现了如下错误。
java.io.StreamCorruptedException: invalid stream header: 7371007E
at java.io.ObjectInputStream.readStreamHeader(ObjectInputStream.java:936)
at java.io.ObjectInputStream.<init>(ObjectInputStream.java:394)
at com.hspedu.qqclient.service.ClientConnectServerThread.run(ClientConnectServerThread.java:23)
而且,我发现,该离线用户一旦上线,服务端是会正常发送他离线期间收到的信息给他的,关键是接收出了问题。
服务端没有报错:
客户端报错,但是它是在收到一条正确的离线留言之后才产生错误的:
给出关键源代码如下:
服务端
// 将离线消息都发过去
oos=new ObjectOutputStream(ManageClientThreads.getClientThread(user.getUserId()).getSocket().getOutputStream());
if(offlineDb.containsKey(user.getUserId())){
ArrayList<Message> messages = offlineDb.get(user.getUserId());
for (Message offlinems:messages
) {
System.out.println(offlinems.getContent());
oos.writeObject(offlinems);
}
offlineDb.remove(user.getUserId());
System.out.println(user.getUserId()+"上线了,服务端已经转发完成所有离线消息");
}
客户端
package com.hspedu.qqclient.service;
import com.hspedu.qqcommon.Message;
import com.hspedu.qqcommon.MessageType;
import java.io.IOException;
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;
}
private ObjectInputStream ois;
@Override
public void run() {
// 因为Thread需要后台和服务器通信,所以我们一直循环
while(true){
System.out.println("客户端线程,等待读取从服务器端发送过来的消息");
try {
ois= new ObjectInputStream(socket.getInputStream());
// 如果服务器没有发送Message对象,线程就会阻塞在这里
Message message=(Message)(ois.readObject());
//如果读取到的是服务端返回的在线用户列表
if(message.getMesType().equals(MessageType.MESSAGE_RET_ONLINE_FRIEND)){
//取出在线列表信息并显示
String[] onlineUsers=message.getContent().split(" ");
System.out.println("============当前在线用户列表=============");
for (int i = 0; i < onlineUsers.length; i++) {
System.out.println(onlineUsers[i]);
}
}else if(message.getMesType().equals(MessageType.MESSAGE_COMM_MES)){
//收到私聊信息
System.out.println("我"+message.getGetter()+"收到了来自"+message.getSender()+"的私聊信息:"+message.getContent());
}else if(message.getMesType().equals(MessageType.MESSAGE_TO_ALL_MES)){
//收到群发信息
System.out.println("我收到了来自"+message.getSender()+"的群发信息:"+message.getContent());
}else if(message.getMesType().equals(MessageType.MESSAGE_NEWS)){
System.out.println("我收到了来自服务端推送的新闻:"+message.getContent());
}
else{
System.out.println("是其他类型的message,暂时不处理");
}
} catch (IOException e) {
e.printStackTrace();
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
}
public Socket getSocket() {
return socket;
}
public void setSocket(Socket socket) {
this.socket = socket;
}
}
该文件全部源代码:
服务端
package com.hspedu.qqserver.service;
import com.hspedu.qqcommon.Message;
import com.hspedu.qqcommon.MessageType;
import com.hspedu.qqcommon.User;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.ArrayList;
import java.util.HashMap;
public class QQServer {
private ServerSocket ss = null;
// 创建一个集合,存放多个用户,如果是这些用户登录,就认为合法
private static HashMap<String, User> validUsers = new HashMap<>();
private static HashMap<String, ArrayList<Message>> offlineDb =new HashMap<>();
static {//在静态代码块内初始化validUsers
validUsers.put("100", new User("100", "123456"));
validUsers.put("200", new User("200", "123456"));
validUsers.put("300", new User("300", "123456"));
validUsers.put("李东海", new User("李东海", "李赫宰"));
validUsers.put("李赫宰", new User("李赫宰", "李东海"));
}
public static HashMap<String, ArrayList<Message>> getOfflineDb() {
return offlineDb;
}
// 验证用户是否有效的方法
private boolean checkUser(String userId, String password) {
User user = validUsers.get(userId);
if (user != null && user.getPassword().equals(password)) {
return true;
}
return false;
}
public QQServer() {
System.out.println("服务端在9999端口监听...");
try {
ss = new ServerSocket(9999);
SendNewsToAllService sendNewsToAllService = new SendNewsToAllService();
sendNewsToAllService.start();
//当和某个客户端建立连接之后,会继续监听,因为应该用while循环
while (true) {
Socket socket = ss.accept();
//得到socket关联的对象输入流
ObjectInputStream ois = new ObjectInputStream(socket.getInputStream());
//得到socket关联的对象输出流
ObjectOutputStream oos = new ObjectOutputStream(socket.getOutputStream());
User user = (User) (ois.readObject());
// 验证
Message message = new Message();
if (checkUser(user.getUserId(), user.getPassword())) {
message.setMesType(MessageType.MESSAGE_LOGIN_SUCCEED);
// 回复客户端一个Message对象
oos.writeObject(message);
//创建一个线程,和客户端保持通信,该线程需要持有socket对象
ServerConnectClientThread serverConnectClientThread = new ServerConnectClientThread(socket, user.getUserId());
// 启动线程
serverConnectClientThread.start();
// 把该线程对象放到集合中管理
ManageClientThreads.addClientThread(user.getUserId(), serverConnectClientThread);
// 将离线消息都发过去
oos=new ObjectOutputStream(ManageClientThreads.getClientThread(user.getUserId()).getSocket().getOutputStream());
if(offlineDb.containsKey(user.getUserId())){
ArrayList<Message> messages = offlineDb.get(user.getUserId());
for (Message offlinems:messages
) {
System.out.println(offlinems.getContent());
oos.writeObject(offlinems);
}
offlineDb.remove(user.getUserId());
System.out.println(user.getUserId()+"上线了,服务端已经转发完成所有离线消息");
}
} else {
System.out.println("登录失败");
message.setMesType(MessageType.MESSAGE_LOGIN_FAIL);
oos.writeObject(message);
socket.close();
}
}
} catch (IOException | ClassNotFoundException e) {
e.printStackTrace();
} finally {
try {
ss.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
客户端
// 客户端全部代码都在上面放了
原因分析:
分析过程:
刚开始,我觉得很奇怪,按道理来说要不就都收不到,要不就都收到,怎么会只收到一条正确离线信息呢?
后来我看到了这两位大佬的文章,我才知道是ObjectOutputStream和ObjectInputStream Header的问题。
在此也给出两位大佬的文章链接,方便查阅。
链接1必看,解释的很到位!!!
链接2作为补充。
分析原因:
原因的关键就是在于,ObjectOutputStream每次new的时候都会调用writeStreamHeader()方法写入4个字节的StreamHeader。源码如下:
public ObjectOutputStream(OutputStream out) throws IOException {
verifySubclass();
bout = new BlockDataOutputStream(out);
handles = new HandleTable(10, (float) 3.00);
subs = new ReplaceTable(10, (float) 3.00);
enableOverride = false;
writeStreamHeader();
bout.setBlockDataMode(true);
if (extendedDebugInfo) {
debugInfoStack = new DebugTraceInfoStack();
} else {
debugInfoStack = null;
}
}
同样的,ObjectInputStream每次new的时候都会调用readStreamHeader()方法读入4个字节的StreamHeader,标记这是对象处理流。源码如下:
public ObjectInputStream(InputStream in) throws IOException {
verifySubclass();
bin = new BlockDataInputStream(in);
handles = new HandleTable(10);
vlist = new ValidationList();
enableOverride = false;
readStreamHeader();
bin.setBlockDataMode(true);
}
理解了原理之后,再看看我自己写的代码:每次读入我都是先在client端先new一个新的ObjectInputStream
while(true){
System.out.println("客户端线程,等待读取从服务器端发送过来的消息");
try {
ois= new ObjectInputStream(socket.getInputStream());
// 如果服务器没有发送Message对象,线程就会阻塞在这里
Message message=(Message)(ois.readObject());
而我在server端的写入多次,只new过一个ObjectOutputStream:
// 将离线消息都发过去
oos=new ObjectOutputStream(ManageClientThreads.getClientThread(user.getUserId()).getSocket().getOutputStream());
if(offlineDb.containsKey(user.getUserId())){
ArrayList<Message> messages = offlineDb.get(user.getUserId());
for (Message offlinems:messages
) {
//oos=new ObjectOutputStream(ManageClientThreads.getClientThread(user.getUserId()).getSocket().getOutputStream());
System.out.println(offlinems.getContent());
oos.writeObject(offlinems);
}
offlineDb.remove(user.getUserId());
System.out.println(user.getUserId()+"上线了,服务端已经转发完成所有离线消息");
}
这样导致客户端读入对象的时候,每次先读Header,第一个离线留言是有头部的,因为第一次new ObjectSteam会给它加入头部,可是后面的object写入的时候都没有头部,而我客户端想要读入第二个对象的头部的时候,你并没有提供,我就以为内容就是头部,导致出错。
解决方案(两个):
- 直接在服务端new多个ObjectOutputStream,每次传对象就new一个。
// 将离线消息都发过去
//oos=new ObjectOutputStream(ManageClientThreads.getClientThread(user.getUserId()).getSocket().getOutputStream());
if(offlineDb.containsKey(user.getUserId())){
ArrayList<Message> messages = offlineDb.get(user.getUserId());
for (Message offlinems:messages
) {
oos=new ObjectOutputStream(ManageClientThreads.getClientThread(user.getUserId()).getSocket().getOutputStream());
System.out.println(offlinems.getContent());
oos.writeObject(offlinems);
}
offlineDb.remove(user.getUserId());
System.out.println(user.getUserId()+"上线了,服务端已经转发完成所有离线消息");
}
- 服务端直接把整个Arraylist放到一个new Message()对象的content中,客户端收到整个ArrayList再打开一个个Message执行相应工作。