本来这个报告是要算作平时分的,但最后 Java 公选课老师觉得讲的太慢了就取消了这个大作业;暑假里没事干,还是把它做出来了。

Java 多人聊天室大作业报告

Author:天泽龟

一。简介

本来这个报告是要算作平时分的,但最后 Java 公选课老师觉得讲的太慢了就取消了这个大作业。但暑假里没事干,还是把它做出来了。

虽说是做出来,但代码还是抄的 B 站一个教程视频,讲的课感觉很拉,敲的代码也有弹幕佬说很多细节没做好,但暂且不管,反正先把这玩意的框架抄了再说。

涉及的知识要点有:IO 编程,Socket 编程,多线程编程,异常处理,GUI 编程。 笔者会对每个模块进行具体分析。

二。GUI 编程

引入 swingawt 包,搭建服务器端和客户端的 GUI。想法是对于服务器端整一个 文本显示区域 输出客户端信息,并引入开启/关闭功能的按钮;对于客户端引入文本输入区域和文本显示区域

1. 服务器 GUI 设计:

声明一个继承了 JFrame 类的 ServerChat 类用来实例化一个服务器端对象。

先 new 几个组件:

  • TaSer 表示文本显示区域
  • StartSerBtnEndSerBtn 表示两个按钮用来实现开闭功能
  • 为了让两个按钮能并列的放在窗口下端,还得设置一个 Panel 对象 BtnTool 将二者绑定。

在它的 init 方法中,我们对 GUI 窗口进行初始化。代码参考如下:

public class ServerChat extends JFrame {
// 声明部分:
JTextArea TaSer = new JTextArea(10,20);
JPanel btnTool = new JPanel();
// 设置一个 Panel 对象,将多个组件整在一起
JButton StartSerBtn = new JButton("服务器启动");
JButton EndSerBtn = new JButton("服务器停止");
// 初始化部分:
public void init() throws Exception {
this.setTitle("服务器");
this.add(TaSer, BorderLayout.CENTER);
this.add(btnTool, BorderLayout.SOUTH);
btnTool.add(EndSerBtn); btnTool.add(StartSerBtn);
// 将两个 Button 组在一起放在 South,且默认是 *流式* 的
this.setBounds(300,300,300,400);
// .... 实现 EndSerBtn 的监听
this.setVisible(true); // 显示屏幕
TaSer.setEditable(false); // TaSer 组件仅用作显示,不可编辑
this.setDefaultCloseOperation(EXIT_ON_CLOSE); // 窗口关闭同时,停止服务器。
}
// ...
}

这里的 EndSerBtn 对象需要实现一个监听器的接口,在 GUI 设计部分不做过多阐释,详细可参考 第三部分:Socket 编程

2. 客户端 GUI 设计:

同理设计客户端的 GUI,我们声明一个继承了 JFrame 类的 ClientChat 类用来实例化一个客户端的 GUI 对象。该类包含以下组件:ta 表示文本显示区域tf 表示文本输入区域

我们应该实现的功能是,在 tf 组件中输入非空文本后,可以在 ta 里显示出来。因此,ta 需要实现对于 tf 组件的监听。tf 拿到文本串时候顺手还往服务器端传一份。

代码参考如下:

public ClientChat() {
// 可以直接写在构造函数里
this.setTitle("客户端");
this.add(ta,BorderLayout.CENTER);
this.add(tf,BorderLayout.SOUTH);
this.setBounds(300,300,300,400);
tf.addActionListener(new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
String text = tf.getText();
// 从 tf 组件拿到 text 字符串
if ( text != null && text.length() != 0) {
send(text + '\n');
} // 若 text 非空,则向服务器端发送数据
tf.setText("");
// 在 ta 上设置 text,并将 tf 清空
}
}); // 对 tf *监听*,写个匿名类

this.setVisible(true);
ta.setEditable(false);
tf.requestFocus(true); // 使光标初始时在 tf 组件
this.setDefaultCloseOperation(EXIT_ON_CLOSE); // 使用 exit 退出进程。
// ...
}

三。Socket 编程 / IO 编程

网络编程的目标,是实现 客户端向服务器发送数据,服务器再向所有客户端返回数据。我们为客户端添加一个 Socket 类变量,为服务器端添加 ServerSocket 类变量。

(背书)当客户端的 Socket 试图与服务器指定端口建立连接时,服务器被激活并通过 SeverSocket 对象与客户端的 Socket 对象建立两个主机之间的固定连接。 一旦客户端与服务器建立了连接,则两者之间就可以传送数据。由于不涉及多线程部分,这里我们只考虑单客户端的情况

1. 服务器端 网络编程

声明如下变量:

  • serversocket:通过调用 accept 方法在指定的端口监听到来的连接。
  • socket :用来存客户端传来的套接字。
  • dis:服务器输入流,从 Socket 中读取数据;

① 我们先写一个 StartServer 方法用来启动服务器端,具体流程如下:

  • 基于指定端口创建一个新的 ServerSocket 对象。
  • ServerSocket 对象调用 accept 方法在指定的端口监听到来的连接。accept 一直处于阻塞状态 直到有客户端试图建立连接。
  • 服务器与客户端根据一定的协议交互,直到关闭连接。

由于每次接到信息时服务器不可中断,因此应该将接收数据写入死循环中

详细代码如下:

public void StartServer() throws Exception {
try {
try {
serverSocket = new ServerSocket(8888);
IsStart = true;
TaSer.append("服务器已启动!\n");
} catch (Exception e) {
e.printStackTrace();
}
// 接每一個信息时,服务器不可以终断,所以将其写入 while() 中,判断符为服务器开关的判断符
System.out.println("等待客户端上线...");
while (IsStart) {
socket = serverSocket.accept();
System.out.println("\n" + "一个客户端连接服务器" + socket.getInetAddress() + ": " + socket.getPort());
TaSer.append("\n" + "一个客户端连接服务器" + socket.getInetAddress() + ": " + socket.getPort() + "\n");
// 输出客户端的信息
ReceiveSer();
// 服务器接受客户端一句话
}
} catch (SocketException e) {
System.out.println("服务器终断连接。");
} catch (Exception e) {
e.printStackTrace();
}
}

② 我们还需要 ReceiveSer 方法从客户端接收数据。

具体来说,我们可以通过调用 Socket 对象的 getInputStream 方法 或者 getOutputStream方法 建立与客户端交互的输入流和输出流

同理,read 方法也是一个阻塞函数,也需要通过死循环的方式读取数据,代码如下:

public void ReceiveSer()
{
try {
dis = new DataInputStream(socket.getInputStream());
// 创建一个输入流
while (IsStart) {
System.out.println("等待客户端输入内容...");
String Text = dis.readUTF();
// 从流中读取数据
if (Text != "") {
TaSer.append(socket.getPort()+" say: "+Text);
System.out.println(socket.getPort()+" say: "+Text);
}
}
} catch (IOException e) {
e.printStackTrace();
}
}

2. 客户端 网络编程

同理,客户端我们先得 new 一个套接字实例,用来建立与服务端的联系。

其次,我们要写一个 send 方法把文本传过去。方法里面 new 一个输出流,并将文本写进去就可以了。代码如下:

public void send(String Text) {
try {
dos = new DataOutputStream(s.getOutputStream());
dos.writeUTF(Text);
} catch (IOException e) {
e.printStackTrace();
}
}

四。多线程编程

最后,为了实现多人聊天室的功能,我们需要引入多线程编程!

对于每一个客户端与服务器端的请求,**我们都给它开一个新的线程,让服务器并发地处理数据。**具体地说,我们在服务器包中定义一个内部类 ClientConn 让他实现 Runnable 接口,并通过重写 run 方法从各个客户端读取数据。每有一个客户端链接上去,就 new 一个 ClientConn 的实例即可。

同时,为了实现 “聊天室” 这一功能,我们需要同步每个客户端的信息——即实现群聊的功能。我们可以将每个客户端塞进一个数组中,每当某一客户端向服务器上传数据,服务器就将该数据发放给每一个客户端即可。

run方法代码如下:

public void run() {
DataInputStream dis = null;
// 服务器输入流,从 Socket 中读取数据
try {
dis = new DataInputStream(socket.getInputStream());
while (IsStart) {
System.out.println("等待客户端输入内容...");
String Text = dis.readUTF();
String str = socket.getPort()+" say: "+Text;
TaSer.append(str); System.out.println(str);

for (ClientConn cli: ccList) cli.send(str);
// 向每个客户端发送信息,实现聊天室
}
} catch (SocketException e) {
TaSer.append("一个客户端已下线:" + socket.getPort() + '\n');
// 异常处理。
}
catch (IOException e) {
e.printStackTrace();
}
}

同时,客户端也应该实现一个线程类 Receive 来接收服务器发来的数据。这里不能光写一个普通的方法! 因为主线程会一直占用 cpu 资源,你就等着接收数据了、那咋实现传数据的任务。

Receive类实现如下:

class Receive implements Runnable{
@Override
public void run() {
while (isConn) {
DataInputStream dis = null;
try {
dis = new DataInputStream(s.getInputStream());
String str = dis.readUTF();
ta.append(str);
} catch (SocketException e) {
isConn = false;
ta.append("服务器中断,失去连接\n");
// 异常处理。
} catch (IOException e) {
e.printStackTrace();
}

}
}
}

五。异常处理

主要有以下两种异常:

  1. 当服务器关闭后,客户端因无法接受数据弹出 SocketException 异常。

  2. 当客户端关闭后,服务器因无法接收数据弹出 SocketException 异常。

**两种情况用 try/catch 语句就可解决,**代码如下:

catch (SocketException e) {
isConn = false;
ta.append("服务器中断,失去连接\n");
// 异常处理。
}

最后我们的多人聊天室就可以跑起来啦!运行结果如下:

QQ截图20220725004558.png

写在最后:待改进的部分和感悟

  1. 实现服务器开启/关闭功能,实现客户端的重新连接;

  2. 整一个数据库,支持用户注册账号(还得学 MySQL);

  3. GUI 太丑,想写个漂亮点的,那还要去学点 Js,Vue 啥的应该。。

虽然跟着写完了,但对于 java 的高级编程还是没弄通透,之后可能再看看书或者多抄几个项目。没准抄着抄着就抄明白了)