引言
当讨论到一个聊天软件是如何运行的时候,我们需要想到它的主要功能是消息传递。对于多台主机或者是一台主机上的多个客户端来说,他们实现消息传递都需要使用到服务器。当客户端A将消息发送给服务端的时候,服务端再将消息转发给客户端B。这个发送与转发的过程我们可以借助Socket来实现,为了确保端A和端B之间的通信不被端C影响,消息在传输的过程中需要确保发送和转发时都能够确认他的发送方与接收方。如果端A上需要同时和端B端C端D进行通信,我们可以为每一个新的通信开启一个新的线程,确保通信不会被主线程阻塞。
具体实现的技术使用到了Java、Swing、Socket,全部源码附在末尾。
界面搭建
在Swing中写界面其实就相当于调用了多个方框控件进行位置的摆放,主要思路是为界面创建一个大的方框Panel后向里面添加不同功能的方框,如Button、Label、TextArea等等的内容,更改样式的话也只是设置一下控件大小、文本颜色等等的基础样式,远不及CSS的美,所以这里不过多叙述,直接在源代码中查看即可。
登录以及登录验证
假设你对于前端有一些了解,对于表单提交来说我们常常使用一个user object包裹account与password属性,当用户点击button提交后将整个的user对象提交给后端。这里亦然,我们可以创建一个User对象,在这个对象中设置私有属性account与password。但是,由于Java在传输对象的时候需要将其序列化(这其实是I/O操作中序列化传输的内容),我们需要对这个类进行改进。
同时需要注意的是,客户端将User类生成的实例对象发送给服务端时,我们应该保证两端中User字段的一致性,因此创建一个新的common包,在这个包下存放消息传递时使用到的对象,并且在服务端也如此设置。
package common;
public class User implements java.io.Serializable{
private String account;
private String password;
public String getAccount() {
return account;
}
public void setAccount(String account) {
this.account = account;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
}
首先考虑客户端部分。我们已经构建好了User类,接下来考虑如何将表单当中的数据方法绑定到具体的User对象身上。Swing中提供了一个ActionListener鼠标事件监听接口,在重写了actionPerformed方法后我们可以根据形参e来判断点击的是什么按钮,进而向服务端传输数据。
if ( 点击的部分 == 通过Swing生成的某个按钮 ) {
执行操作....
User u = new User();
u.setAccount(jp_center_jf.getText().trim());
// 密码拿到的是一个字符数组,所以需要通过创建一个字符串进行转换
u.setPassword(new String(jp_center_jpf.getPassword()));
}
现在我们已经获取到了表单当中的对象,可以向服务端发送消息进行身份验证,这一请求验证的过程需要使用到Socket通信。由于在一开始设计的时候采用MVC结构,我们将视图view和操作model放在了不同的包下,因此我们可以在model包下创建一个对象LinkServer负责客户端和服务端之间的连接。
public class LinkServer {
public Socket s;
// 发送请求
public boolean sendLoginToServer(Object o) {
try {
s = new Socket("127.0.0.1",8080);
ObjectOutputStream oos = new ObjectOutputStream(s.getOutputStream());
// Object o = new User()
oos.writeObject(o);
}catch (Exception e) {
e.printStackTrace();
}finally{
}
}
}
通过设置了IP地址和端口号,我们可以通过ObjectOutputStream将user对象进行消息的传递。接下来考虑服务端部分。在服务端中我们可以创建一个ServerStart类进行服务器的开启以及验证操作。当我们通过ServerSocket将服务器的端口号设置的和客户端一致时,就已经可以通过ObjectInputStream来接收相应的数据。
public class ServerStart {
public static void main(String[] args) {
new ServerStart();
}
public ServerStart() {
try {
System.out.println("服务器已开启....");
ServerSocket ss = new ServerSocket(8080);
while (true) {
// 等待客户端的连接
Socket s = ss.accept();
// 接收用户端初次连接时候传来的user状态
ObjectInputStream ois = new ObjectInputStream(s.getInputStream());
User u = (User)ois.readObject();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
接下来在服务端进行身份验证。验证之前需要考虑,不论验证是否通过,我们都需要传递一个消息给客户端。这样看来又是一个C/S之间的消息传输,为了确保消息的一致性,此时又需要在服务端与客户端的common包下设置一个Message类,并同样进行序列化处理。
package common;
public class Message implements java.io.Serializable{
// 用于登录的验证 1 为登录成功 2 为登陆失败
private String mesType;
// 发送者
private String sender;
// 接收者
private String getter;
// 信息内容
private String content;
// 发送时间
private String sendTime;
public String getMesType() {
return mesType;
}
public void setMesType(String mesType) {
this.mesType = 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;
}
}
此时就可以进行身份验证了,本来应该通过服务端获取到User实例对象,然后去和数据库中的用户数据进行验证对比,但这样的话会使用到 jdbc 的连接技术,这里姑且偷个懒,直接使用固定的密码1902151512来进行验证。
public ServerStart() {
try {
System.out.println("服务器已开启....");
ServerSocket ss = new ServerSocket(8080);
while (true) {
// 等待客户端的连接
Socket s = ss.accept();
// 接收用户端初次连接时候传来的user状态
ObjectInputStream ois = new ObjectInputStream(s.getInputStream());
User u = (User)ois.readObject();
// 返回同样序列化的Message
Message m = new Message();
ObjectOutputStream oos = new ObjectOutputStream(s.getOutputStream());
// 服务端返回信息 1为成功 2为失败
if(u.getPassword().equals("1902151512")){
m.setMesType("1");
oos.writeObject(m);
// 成功登录,单独开启一个线程,保持客户端与服务端之间的通信
}else{
m.setMesType("2");
oos.writeObject(m);
s.close();
}
}
} catch (IOException e) {
e.printStackTrace();
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
}
在服务端执行验证操作后会有Message发送到客户端,由于在客户端请求验证的时候开启了一条Socket线路与服务端s.accept进行连接,故返回Message的时候不用执行其他操作,在客户端中便可接收到数据,接下来再看看客户端代码。
public class LinkServer {
public Socket s;
// 发送请求
public boolean sendLoginToServer(Object o) {
// 设置一个bool值进行登录验证的判断
// 当登录成功时将 b 的值更改为true
boolean b = false;
try {
s = new Socket("127.0.0.1",8080);
ObjectOutputStream oos = new ObjectOutputStream(s.getOutputStream());
oos.writeObject(o);
ObjectInputStream ois = new ObjectInputStream(s.getInputStream());
Message ms = (Message)ois.readObject();
if (ms.getMesType().equals("1")) {
b = true;
}else{
//关闭Scoket
s.close();
}
} catch (Exception e) {
e.printStackTrace();
}finally{
}
return b;
}
}
现在已经实现了验证的功能,我们可以对该功能进行一点优化。
在平时写前端的过程中,我会将具体的路由请求进行一次封装,具体页面中直接使用封装好的函数即可,不必考虑其中的具体逻辑,这样不仅可以提高代码的可读性,还可以在以后修改的时候直接修改封装后的代码,不用到处去找更改位置。
// 一个前端例子
// 对登录接口进行封装
export const loginAPI = data => {
return request.request({
url: Api.login,
data: data,
method: 'post'
})
}
// 在页面中直接调用该方法
const onSubmit = function(values){
loginAPI(values)
.then(res=>{
console.log(res)
}
}
因此推荐你也这样操作,将具体的验证操作放在某个类中,然后新创建一个loginCheck类,在Swing的页面中直接调用loginCheck类中的方法即可。具体的页面只考虑方法的调用,而不去考虑该方法是如何实现的。如果是登录验证,对于登录的页面来说,噢,原来我直接在点击button之后,直接通过LoginCheck的实例对象调用checkUser的方法就能验证了,这太方便了!
在验证完成后跳转到好友列表页面进行之后的操作。
package client.model;
import common.User;
public class LoginCheck {
public boolean checkUser(User u) {
return new LinkServer().sendLoginToServer(u);
}
}
// clientLogin 的页面类
@Override
public void actionPerformed(ActionEvent e) {
if(e.getSource() == jp_bottom_login){
LoginCheck lc = new LoginCheck();
User u = new User();
u.setAccount(jp_center_jf.getText().trim());
// 密码拿到的是一个字符数组,所以需要通过创建一个字符串进行转换
u.setPassword(new String(jp_center_jpf.getPassword()));
if(lc.checkUser(u)){
// 跳转到好友列表页面
new clientFriend(u.getAccount());
// 关闭登录页面
this.dispose();
}else{
JOptionPane.showMessageDialog(this,"用户名或者密码错误!");
}
}
}
一对一的聊天
在登录后成功打开了好友列表,现在先暂停一下,缕一缕思路。
我们现在所有的操作都是对于主线程来说的,如果说端口A上进行了登录操作,用户1打算同时和用户2、3进行聊天,由于线程一直由用户1、2所占用,用户1、3之间的聊天则会被阻塞。因此对于聊天来说,每次开启一个聊天的服务,客户端和服务端都需要开启一个新的线程。
对于服务端来说,需要在多个线程中准确的找到消息的发送方,以及接收方同服务器的Socket的连接。对于客户端来说,需要在每次建立聊天的时候开启一个线程,防止单个用户无法同时和多个用户进行聊天。
我们先来看看服务端。
首先你需要想想在什么时候开启新的线程,前面我们在用户登录的时候可以做到每个账号的验证,在这里便可以为每个通过了验证的账号开启一个线程,并且传递他所拥有的Socket(不然在后续的操作中无法通过Socket交互实现消息的传输)。
因此在开启服务端的实现类中找到验证账户信息的方法,在验证成功之后开启线程。
if(u.getPassword().equals("123")){
m.setMesType("1");
oos.writeObject(m);
// 成功登录,单独开启一个线程,保持客户端与服务端之间的通信
ServerConnection sc = new ServerConnection(s);
ManageClientThread.addThread(u.getAccount(),sc);
sc.start();
}else{
m.setMesType("2");
oos.writeObject(m);
s.close();
}
开启线程的这一步中我添加了两个新的类 ServerConnection和ManageClientThread类,前者负责开启具体的线程,后者负责管理线程之间的对应关系。
还记得引言中讲解到的内容吗,当端A将消息发送给服务端的时候,服务端可以顺利拿到消息,之后怎么处理呢?假设现在只有一个ServerConnection类负责开启线程,但是在服务端的这条线程中仅仅只能保持服务端与某一个客户端之间的联系。我们无法在某个通信中获取另外一个通信中的数据(因为每个客户端都是单一的和服务端进行连接,所以无法直接保存每个通信的Socket),也就意味着无法通过发送方准确找到接收方的Socket进行通信。
因此创建一个Manage类,在类内设置一个HashMap,每次创建一个线程便向HashMap中存放用户的登录账号表示key值,以及它所对应的value,即开启的Socket。当具体的通信进程需要进行消息的接收与转发时,在服务端内通过Message消息类传递过来的接收方account作为key值,拿到发送方的Socket,最后便可以在一个线程中将这两条连接接通实现消息的发送。
package server;
import java.util.HashMap;
public class ManageClientThread {
public static HashMap hm = new HashMap<String,ServerConnection>();
// 添加线程的映射
public static void addThread(String uid,ServerConnection sc){
hm.put(uid,sc);
}
public static ServerConnection getServerConnection(String uid){
return (ServerConnection)hm.get(uid);
}
}
按照前面的思路,我们接下来就可以实现ServerConnection类,实现线程之间的socket传输。在这个类中先通过ObjectInputStream接收到客户端传来的Message数据,解析这个数据中所包含的发送方、接收方、消息内容的数据,然后通过HashMap获取到接收方的Socket进行消息转发。
package server;
import common.Message;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.net.Socket;
public class ServerConnection extends Thread{
private Socket s;
// 让线程保持客户端的Socket
public ServerConnection (Socket s) {
this.s = s;
}
@Override
public void run () {
while(true){
try {
ObjectInputStream ois = new ObjectInputStream(s.getInputStream());
// 现在 单个客户端已经可以将消息发送给服务端了
// 之后再处理转发给另外一个客户端的任务
// 如果需要将服务器上接收到的消息转发给服务端,不仅需要sender的socket,还需要getter的
Message m = (Message)ois.readObject();
// 取得接收者的socket
// 这样设置后 必须有两个人才能实现信息的传递
ServerConnection sc = ManageClientThread.getServerConnection(m.getGetter());
ObjectOutputStream oos = new ObjectOutputStream(sc.s.getOutputStream());
oos.writeObject(m);
} catch (IOException e) {
e.printStackTrace();
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
}
}
服务端设计完成,接下来考虑客户端。同样,服务端需要进行消息的接收和转发,客户端也需要进行对应的发送和接收,那么可以相同模式的创建ClientConnection线程类来保存Socket,和保存线程信息的ManageClientThread类,具体的实现和服务端的一致,都是在某个线程中进行消息的发送和接收,并且通过Manage类中的HashMap存储目标的Socket。这里就不过多叙述,具体的代码可以点开文末的源代码地址进行下载。
服务端的通信也需要考虑多个线程的通信问题,因此在登陆时接收到服务端的返回验证时开启通信线程,让一个账号和服务器保持通信的连接。
public class LinkServer {
public Socket s;
// 发送请求
public boolean sendLoginToServer(Object o) {
boolean b = false;
try {
s = new Socket("127.0.0.1",8080);
ObjectOutputStream oos = new ObjectOutputStream(s.getOutputStream());
oos.writeObject(o);
ObjectInputStream ois = new ObjectInputStream(s.getInputStream());
Message ms = (Message)ois.readObject();
if (ms.getMesType().equals("1")) {
// 就创建一个该qq号和服务器端保持通讯连接得线程
ClientConnection cc = new ClientConnection(s);
//启动该通讯线程
cc.start();
ManageClientThread.addClientThread(((User)o).getAccount(), cc);
b = true;
}else{
//关闭Scoket
s.close();
}
} catch (Exception e) {
e.printStackTrace();
}finally{
}
return b;
}
}
现在我们已经实现了多个账号之间的通信问题,最后需要考虑的是如何将逻辑实现的通信结果,展示到聊天窗口,以及如何成功将聊天框中的消息发送出去。
回顾一下之前写的客户端内容,我们在登录界面进行了身份验证,和某个账号登录后线程的开启,登录成功后跳转到好友列表页面(传参为个人的账号:String),在好友列表页面通过双击头像打开对话框,这一过程传参为自己的账号:String和别人的账号:String。现在如果想将信息展现到对话框界面中,肯定需要获取到Socket接收服务端转发回来的消息。但是无法靠多次的传参获取到这个Socket。此时就需要考虑借助一些函数封装来实现不同类之间的调用。
现在已知的是在创建线程的时候我们不仅有特定的socket成员变量,还拥有着等待着传输的Message消息。那么如果在客户端的线程类当中调用负责聊天界面类中的方法,并将具体的Message作为参数传递过去,不免是一种不错的解决办法。我认为这和 前端React 中的那种消息发布和接收的方法类似,在父组件中定义方法,在子组件中调用该方法,并将自己的属性值作为参数传递给父组件接收。
在好友列表界面类定义一个鼠标点击事件,在用户双击头像后打开聊天框,并将发送方和接收方的数据存入一个HashMap中,确保一个用户A在打开了和用户B的聊天界面后,再次点击头像时不会再次触发一个聊天框。
@Override
public void mouseClicked(MouseEvent e) {
JLabel j1 = (JLabel)e.getSource();
j1.setForeground(Color.blue);
// 限定双击才能打开聊天界面
if(e.getClickCount() == 2) {
// 得到该好友的编号
String friendNo = ((JLabel)e.getSource()).getText();
// 呼出聊天框
clientChat qqChat = new clientChat(this.owner,friendNo);
ManageChat.addChat(this.owner + "" +friendNo,qqChat);
}
}
在聊天界面类中设置一个展示消息的方法,等待线程控制类中将消息传递过来。
//写一个方法,让它显示消息
public void showMessage(Message m) {
String info= m.getSender() + "对你说:" + m.getContent() + "\r\n";
this.jt_area.append(info);
}
在线程类中确认是哪一个聊天框需要显示信息,并将信息发送过去。
ObjectInputStream ois = new ObjectInputStream(s.getInputStream());
Message m = (Message)ois.readObject();
// 将发送的消息传递到Chat页面上
client.view.clientChat cc = ManageChat.getChat(m.getGetter() + "" + m.getSender());
cc.showMessage(m);
至此已经成功实现了QQ聊天系统。
如果需要源代码的话可以到下面的链接中去下载:
下载后将本文当作理解的参考手册也可以,毕竟将从头至尾的设计思路介绍了一遍。