一、实验目的
本次实验中,我们通过Java语言编写一个简单的聊天室客户端程序,实现多用户群聊的功能。本次实验的基础是基于TCP协议的以套接字(Socket)为端点的端到端通信技术。其中,通信的一端是我们将要编写的聊天室客户端进程,另一端是服务端进程。
二、用户界面设计
按照如下的设计来实现聊天室客户端程序的用户界面:
- 窗口的最上方是将要连接的服务器端地址和端口号。填写后点击“连接”按钮发起连接
- 连接成功与否,都弹出对话框对用户进行提示
- 连接成功后,按钮文字变成“断开”,点击后断开与服务器的连接,退出聊天。
- 窗口的中间区域显示聊天室中当前各个用户的聊天内容
- 窗口最下方是用户昵称,以及聊天内容编辑框,点击“发送”按钮后即将聊天消息发送到服务器端。然后服务器端再将这条消息下发到其它客户端,从而实现了群聊。除了了点击“发送”按钮,用户通过按下键盘上的回车键也可以触发消息的发送
三、程序编写
3.1 创建Java应用程序工程
启动Eclipse集成开发环境,选择菜单项“File -> New -> Java Project”,系统弹出类似下面的对话框:
在“Project name”一栏中填写你的项目名称,然后点击右下角的“Finish”按钮完成创建。新建的项目出现在左侧的“Package Explorer”视图中:
3.2 创建Java代码包
右键点击“src”文件夹,在弹出菜单中选择“New -> Package”,在弹出的对话框中填写包名:
点击“Finish”按钮,“src”目录下出现我们新建的包名:
3.3 创建UI视图类
3.3.1 建立ClientView类
UI视图类用来实现前文的用户界面,并且响应交互,例如输入文字、点击按钮、敲击键盘等。
在步骤3.2中创建的包名上右键单击鼠标,在弹出的菜单中选择“New -> Class”,在弹出的对话框中的“Name”栏中填写类名ClientView,在父类“Superclass”栏中填写“javax.swing.JFrame”:
点击“Finish”按钮完成创建。此时,在源代码目录中出现了源文件ClientView.java。双击打开,内容如下:
package com.simplechat;
import javax.swing.JFrame;
public class ClientView extends JFrame {
}
3.3.2 实现处理交互事件的接口
由于我们视图中将来要响应鼠标点击、敲击键盘等交互事件,需要分别实现对应的接口。在类定义上增加需要实现的接口如下:
public class ClientView extends JFrame implements
ActionListener, KeyListener {
}
其中,接口ActionListener用来处理鼠标点击事件,而接口KeyListener用来处理键盘事件。
此时,类名ClientView下面会出现红色波浪线提示出错。将光标定位到类名,按F2键,在弹出的菜单中选择“Add unimplemented methods”:
这时,开发环境会自动向类ClientView中添加两个接口要求实现的方法如下:
@Override
public void keyTyped(KeyEvent e) {
// TODO Auto-generated method stub
}
@Override
public void keyPressed(KeyEvent e) {
// TODO Auto-generated method stub
}
@Override
public void keyReleased(KeyEvent e) {
// TODO Auto-generated method stub
}
@Override
public void actionPerformed(ActionEvent e) {
// TODO Auto-generated method stub
}
我们重点关注两个方法:
- keyPressed():处理键盘按下事件
- actionPerformed():处理鼠标点击事件
稍后我们会在这两个方法中编写事件处理代码。
3.3.3 UI元素初始化
要达到前文设计的UI显示效果,需要编写代码添加各个窗口元素。对于需要响应交互事件的元素,还需要绑定对事件的监听
首先,在类中添加如下的成员变量:
private JTextArea taChatList; // 聊天内容区
private JTextField tfMessage; // 聊天输入框
private JTextField tfName; // 用户名输入框
private JButton btnSend; // 发送按钮
private JLabel labelNick;
private JPanel jp1, jp2;
private JScrollPane scrollPane;
private JLabel labelHost;
private JLabel labelPort;
private JTextField tfHost; // 服务器地址输入框
private JTextField tfPort; // 服务器端口输入框
private JButton btnConnect; // 连接/断开服务器按钮
以上都是是用户界面中的UI元素对象,后面需要操作或访问的对象已经通过注释来标明用途。
接下来,编写一个用户界面初始化函数initView(),对各个UI元素对象分配存储空间,并按照设计要求添加到视图中。代码如下:
private void initView() {
taChatList = new JTextArea(20, 20);
taChatList.setEditable(false);
scrollPane = new JScrollPane(taChatList);
tfMessage = new JTextField(15);
btnSend = new JButton("发送");
jp1 = new JPanel();
labelHost = new JLabel("主机地址");
tfHost = new JTextField(15);
tfHost.setText("localhost");
labelPort = new JLabel("端口号");
tfPort = new JTextField(4);
tfPort.setText("8765");
btnConnect = new JButton("连接");
jp1.add(labelHost);
jp1.add(tfHost);
jp1.add(labelPort);
jp1.add(tfPort);
jp1.add(btnConnect);
labelNick = new JLabel("昵称:");
tfName = new JTextField(8);
jp2 = new JPanel();
jp2.add(labelNick);
jp2.add(tfName);
tfName.setText("用户0");
jp1.setLayout(new FlowLayout(FlowLayout.CENTER));
jp2.add(tfMessage);
jp2.add(btnSend);
jp2.setLayout(new FlowLayout(FlowLayout.CENTER));
add(jp1, BorderLayout.NORTH);
add(scrollPane, BorderLayout.CENTER);
add(jp2, BorderLayout.SOUTH);
setTitle("聊天室");
setSize(500, 500);
setLocation(450, 150);
setVisible(true);
setDefaultCloseOperation(EXIT_ON_CLOSE);
// 当光标定位在聊天输入框时监听回车键按下事件
tfMessage.addKeyListener(this);
// 为发送按钮增加鼠标点击事件监听
btnSend.addActionListener(this);
// 为连接按钮增加鼠标点击事件监听
btnConnect.addActionListener(this);
// 当窗口关闭时触发
addWindowListener(new WindowAdapter() { // 窗口关闭后断开连接
@Override
public void windowClosing(WindowEvent e) {
}
});
}
找到keyPressed()方法,添加对按下回车键事件的处理:
@Override
public void keyPressed(KeyEvent e) {
// TODO Auto-generated method stub
if (e.getKeyCode() == KeyEvent.VK_ENTER) {
// 发送聊天消息
}
}
找到actionPerformed()方法,添加对两个按钮的响应:
@Override
public void actionPerformed(ActionEvent e) {
// TODO Auto-generated method stub
if (e.getSource() == btnSend) {
// 响应发送按钮
} else if (e.getSource() == btnConnect) {
// 响应连接/断开按钮
}
}
最后,为ClientView创建一个构造方法,并且在构造方法中调用initView()方法对用户界面进行创建:
public ClientView() {
initView();
}
3.3.4 运行程序查看主窗口
到此为止,我们的客户端程序的UI视图搭建完毕。在ClientView类的末尾增加主函数作为程序运行的入口:
public static void main(String[] args) {
ClientView view = new ClientView();
}
在开发环境左侧Project Explorer中选中我们的项目,点右键在弹出菜单中选择“Run As -> Java Application”,如果一切整成,则会看到我们的应用程序窗口:
可以看到窗口能够展示出来了,但是还没有实现任何功能。接下来我们编写一个网络服务模块来提供程序所需要的网络功能。
3.4 编写网络服务模块
3.4.1 创建NetworkService类
我们将网络相关的处理全部放到一个专门的网络服务模块中实现,其它模块需要时进行调用即可。在这里,我将该类命名为NetworkService。右键单击刚才创建的包,在弹出的菜单中选择“New -> Class”。在出现的对话框中的“Name”栏目中填写类名:
点击“Finish”按钮完成创建。此时,对应的源代码文件NetworkService.java将出现在com.simplechat包下:
双击NetworkService.java打开,内容如下:
package com.simplechat;
public class NetworkService {
}
3.4.2 定义NetworkService模块的功能
NetworkService模块需要提供的功能包括:
- connnect():连接到服务器
- disconnect():断开与服务器的连接
- isConnected():判断当前是否已经连接到服务器
- sendMessage():发送聊天消息
因此,为NetworkService类添加与这些功能相对应的方法:
public class NetworkService {
/**
* 连接到服务器
* @param host 服务器地址
* @param port 服务器端口
*/
public void connect(String host, int port) {
}
/**
* 断开连接
*/
public void disconnect() {
}
/**
* 是否已经连接到服务器
* @return true为已连接,false为未连接
*/
public boolean isConnected() {
}
/**
* 发送聊天消息
* @param name 用户名
* @param msg 消息内容
*/
public void sendMessage(String name, String msg) {
}
}
3.4.3 定义回调接口
同时,以上网络功能操作完成后,往往需要反馈一些状态,例如在收到消息后通知用户界面刷新聊天内容,使得用户能够看到新消息。而根据分层设计的原理,这样的代码放在专注于网络操作的NetworkService类中是非常丑陋的做法。因此,我们定义一个回调接口,其中定义各项处理结束的通知函数。稍后我们在用户界面类(即ClientView类)中实现这个接口,就可以实现UI对网络处理的响应了。
在NetworkService类的最前面中添加接口Callback的定义,同时用Callback类型定义一个成员变量,并创建setter方法:
public class NetworkService {
public interface Callback {
void onConnected(String host, int port); //连接成功
void onConnectFailed(String host, int port); //连接失败
void onDisconnected(); //已经断开连接
void onMessageSent(String name, String msg); //消息已经发出
void onMessageReceived(String name, String msg);//收到消息
}
private Callback callback;
public void setCallback(Callback callback) {
this.callback = callback;
}
...
}
3.4.4 添加网络通信相关的成员变量
添加网络通信所需要的以下成员变量:
// 套接字对象
private Socket socket = null;
// 套接字输入流对象,从这里读取收到的消息
private DataInputStream inputStream = null;
// 套接字输出流对象,从这里发送聊天消息
private DataOutputStream outputStream = null;
// 当前连接状态的标记变量
private boolean isConnected = false;
3.4.5 实现connect()操作
connect()方法实现连接服务器的操作。它的逻辑如下:
- 根据参数提供的服务器地址和端口创建套接字。创建套接字的过程即建立连接的过程。
- 如果创建成功,记录已连接状态,同时通过回调函数通知外界连接成功。同时还要启动一个线程来监听是否有服务器发来的聊天消息。
- 如果创建套件字失败,则记录未连接状态,通过回调函数通知外界连接失败
为connect()方法编写代码如下:
public void connect(String host, int port) {
try {
// 创建套接字对象,与服务器建立连接
socket = new Socket(host, port);
isConnected = true;
// 通知外界已连接
if (callback != null) {
callback.onConnected(host, port);
}
// 开始侦听是否有聊天消息到来
beginListening();
} catch (IOException e) {
// 连接服务器失败
isConnected = false;
// 通知外界连接失败
if (callback != null) {
callback.onConnectFailed(host, port);
}
e.printStackTrace();
}
}
其中,用来监听聊天记录到来的方法beginListening()实现如下:
private void beginListening() {
Runnable listening = new Runnable() {
@Override
public void run() {
try {
inputStream = new DataInputStream(socket.getInputStream());
while (true) {
String[] s = inputStream.readUTF().split("#");
if (callback != null) {
callback.onMessageReceived(s[0], s[1]);
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
};
(new Thread(listening)).start();
}
3.4.6 实现disconnect()操作
disconnect()的功能是断开与服务器的连接。在这里要关闭套接字,并且关闭所有的输入、输出流。实现代码如下:
public void disconnect() {
try {
if (socket != null) {
socket.close();
}
if (inputStream!= null) {
inputStream.close();
}
if (outputStream != null) {
outputStream.close();
}
isConnected = false;
// 通知外界连接断开
if (callback != null) {
callback.onDisconnected();
}
} catch (IOException e) {
e.printStackTrace();
}
}
3.4.6 实现isConnected()
外界通过调用isConnected()方法来获知当前是否已经连接到服务器。简单的返回isConnected变量即可:
public boolean isConnected() {
return isConnected;
}
3.4.7 实现sendMessage()操作
sendMessage()方法将参数传来的用户名和消息串按照一定的格式发送出去。操作的实质就是将消息写入到套接字对象的输出流。实现如下:
public void sendMessage(String name, String msg) {
// 检查参数合法性
if (name == null || "".equals(name) || msg == null || "".equals(msg)) {
return;
}
if (socket == null) { //套接字对象必须已创建
return;
}
try {
// 将消息写入套接字的输出流
outputStream = new DataOutputStream(socket.getOutputStream());
outputStream.writeUTF(name + "#" + msg);
outputStream.flush();
// 通知外界消息已发送
if (callback != null) {
callback.onMessageSent(name, msg);
}
} catch (IOException e) {
e.printStackTrace();
}
}
到此为止,我们的网络服务模块就编写完成了。下面,我们回到用户界面视图,即ClientView类中,创建网络服务模块对象,然后在相关的地方调用网络服务模块提供的功能。
3.5 用户界面与网络服务模块对接
3.5.1 为ClientView类增加NetworkService模块并实现回调
重新打开ClientView.java文件进行修改。
为了在ClientView类中对网络服务模块即NetworkService类进行调用,首先必须在ClientView类中增加一个NetworkService类型的成员:
private NetworkService networkService;
然后专门写一个函数initNetworkService()来初始化这个networkService对象。这个函数主要做两件事:
- 创建networkService对象(new)
- 设置回调接口以处理NetworkService中各个网络操作发来的通知
initNetworkService()函数实现如下:
private void initNetworkService() {
networkService = new NetworkService();
networkService.setCallback(new Callback() {
@Override
public void onConnected(String host, int port) {
// 连接成功时,弹对话框提示,并将按钮文字改为“断开”
alert("连接", "成功连接到[" + host + ":" + port + "]");
btnConnect.setText("断开");
}
@Override
public void onConnectFailed(String host, int port) {
// 连接失败时,弹对话框提示,并将按钮文字设为“连接”
alert("连接", "无法连接到[" + host + ":" + port + "]");
btnConnect.setText("连接");
}
@Override
public void onDisconnected() {
// 断开连接时,弹对话框提示,并将按钮文字设为“连接”
alert("连接", "连接已断开");
btnConnect.setText("连接");
}
@Override
public void onMessageSent(String name, String msg) {
// 发出消息时,清空消息输入框,并将消息显示在消息区
tfMessage.setText("");
taChatList.append("我(" + name + "):\r\n" + msg + "\r\n");
}
@Override
public void onMessageReceived(String name, String msg) {
// 收到消息时,将消息显示在消息区
taChatList.append(name + ":\r\n" + msg + "\r\n");
}
});
}
其中,alert()函数用来显示一个对话框以向用户通告某个信息,实现如下:
// 显示标题为title,内容为message的对话框
private void alert(String title, String message) {
JOptionPane.showMessageDialog(this, message, title, JOptionPane.INFORMATION_MESSAGE);
}
然后找到构造方法ClientView(),在其末尾调用这个函数如下:
public ClientView() {
initView();
initNetworkService();
}
3.5.2 实现用户交互
不同的用户交互将会触发相应的网络操作,包括:
- 未连接状态下,点击连接/断开按钮执行连接操作
- 已连接状态下,点击连接/断开按钮执行断开连接操作
- 已连接状态下,关闭窗口执行断开连接操作
- 按回车键发送消息
下面分别调用NetworkService模块提供的功能来完成以上的交互操作。
3.5.2.1 处理关闭窗口操作
在ClientView类中找到如下代码:
// 当窗口关闭时触发
addWindowListener(new WindowAdapter() { // 窗口关闭后断开连接
@Override
public void windowClosing(WindowEvent e) {
}
});
增加断开连接操作,如下:
// 当窗口关闭时触发
addWindowListener(new WindowAdapter() { // 窗口关闭后断开连接
@Override
public void windowClosing(WindowEvent e) {
networkService.disconnect();
}
});
3.5.2.2 处理按钮点击操作
其中包括对连接/断开按钮的点击,以及对发送按钮的点击。前者连接或者断开服务器,后者将编辑框中的消息发送出去。
找到actionPerformed()方法,改写如下:
@Override
public void actionPerformed(ActionEvent e) {
// TODO Auto-generated method stub
if (e.getSource() == btnSend) {
sendMessage();
} else if (e.getSource() == btnConnect) {
// 响应连接/断开按钮
if (!networkService.isConnected()) {
// 未连接状态下,执行连接服务器操作
String host = tfHost.getText();
int port = Integer.valueOf(tfPort.getText());
networkService.connect(host, port);
} else {
// 已连接状态下,执行断开连接操作
networkService.disconnect();
}
}
}
其中,sendMessage()方法实现如下:
private void sendMessage() {
// 响应发送按钮
String name = tfName.getText();
String msg = tfMessage.getText();
// 检查参数合法性
if (name == null || msg == null || "".equals(name) || "".equals(msg)) {
return;
}
// 发送消息
networkService.sendMessage(name, msg);
}
3.5.2.3 处理回车键按下操作
当按下回车键时,如果聊天输入框中有内容,就将其发送出去。
找到keyPressed()方法,改下如下:
@Override
public void keyPressed(KeyEvent e) {
// TODO Auto-generated method stub
if (e.getKeyCode() == KeyEvent.VK_ENTER) {
// 发送聊天消息
sendMessage();
}
}
到此为止,聊天室客户端程序编写完成。
四、 测试聊天室系统
4.1 运行服务器端程序
从以下链接下载已编写好的聊天室服务器端代码工程:
https://pan.baidu.com/s/1GHHWY3pv5DBrIQvSBqfl2Q
下载后解压。在eclipse中选择菜单项“File -> Import”,在弹出的对话框中选择“General -> Existing Project into Workspace”,点击“Next”按钮进入下一步对话框,在“Select Root Directory”项下选择刚才解压出来的目录:
点击“Finish”按钮完成导入。此时,服务器端项目出现在窗口左侧的列表中:
按照之前运行客户端程序同样的方法运行此项目,将会看到如下的窗口:
点击“打开服务器”按钮,使服务器进入运行状态:
4.2 运行客户端程序并连接服务器
连续运行两次我们编写的客户端程序,得到两个客户端程序窗口。分别点击各自的“连接按钮”,观察服务器程序窗口的变化。
分别在各个客户端窗口输入并发送消息,观察是否能够出现在另一客户端窗口。