仿照第4篇笔记的形式,笔者决定将GUI和网络编程部分用综合练习的方式来总结。练习项目是有图形界面的聊天室,用到了GUI中的javax.swing包和网络编程中的TCP/socket编程。GUI部分的难点是图形控件的API较为复杂,设置不同属性需要很多的方法和字段,需要参考API手册和网上的一些图形界面作品的代码,不过GUI代码的结构较为固定(相对于聊天室简单的界面而言),容易总结固定格式。网络编程部分的难点是聊天室涉及到多客户端之间通过服务器的通信,不仅服务器要使用多线程,而且每个服务线程都要求能够调取其它服务线程中的socket以便向其它客户端传递消息,这就需要专门的数据容器来储存所有服务线程。网络编程部分的另一个难点是消息的结构设计。由于一个socket只有一对输入输出流,来自客户端和服务器的各种不同类型消息都要通过这对流来传递给对方,所以服务器和客户端都要能根据消息的类型采取不同的动作。这需要仔细考虑消息的形式、结构和解析方法。
编程用了两天时间,下面简要介绍下实现的功能,GUI和网络编程部分的思路,详细说明可以见最后的代码和注释。
1. 实现功能
作为聊天室软件实现了:
(1)可显示并即时更新当前在线列表。当新的客户端连接上服务器,或者在线的客户端退出时,客户端向服务器发送消息,服务器会立刻更新所有客户端的在线列表。在线列表显示当前在线者(不包括自己)的网名、IP和端口;
(2)发送消息时用户从在线列表中选择消息接收者,数量从一个人到所有人皆可;
(3)接收消息时显示发送者IP和端口;
(4)与服务器失去连接时可以在聊天窗口显示异常信息;
(5)服务器用多线程方式工作,有静态容器存储所有服务线程。
2. GUI部分概要
只有客户端需要图形界面。这个界面具有:
(1)聊天窗口:显示自己发送的和收到的信息(包括发信人身份),用设置成不可编辑的JTextArea控件实现,用JScrollPane控件包装来实现滚动条;
(2)打字窗口:输入聊天消息,用设置成可编辑的JTextArea控件实现;
(3)当前在线列表:显示当前在线的所有人的网名,IP和端口,由服务器即时更新。发送聊天消息时需要在表中选择消息接收人,从一个人到所有人皆可。用JTable控件实现,被服务器更新时可动态插入或删除行。用JScrollPane控件包装来实现滚动条;
(4)发送按钮:将打字窗口中的文字按照在线列表中选择的收信人发给服务器,由后者转发给收信人,随即将打字窗口清屏,用JButton控件实现;
(5)清屏按钮:将聊天窗口清屏,用JButton控件实现;
(6)退出按钮:向服务器发出退出消息,关闭此客户端程序。服务器接收到后更新所有客户端的当前在线列表;
(7)收信人标签:与在线列表中选择的收信人一致,起提醒作用。若用户没有选择任何收信人,则不能发送聊天消息。
3. 网络编程部分概要
(1)使用TCP/Socket连接。服务器使用多线程工作,每个客户端都享有一个服务线程;
(2)每个客户端用自己的IP地址和端口号组成一个字符串作为用户标识(uid)
(3)客户端和服务器之间每次通信都是传递一个字符串,这个字符串可能有这几种结构:
Exit/ 客户端发往服务器,表示该客户端退出
Chat/收信人地址/聊天内容 客户端发往服务器,表示该客户端要对别的客户端发送聊天消息
Chat/发信人地址/聊天内容 服务器发往客户端,表示服务器转发给收信客户端的聊天消息
OnlineListUpdate/在线者地址 服务器发往客户端,表示有客户端加入或退出,要更新所有客户端的当前在线列表
(4)收信人地址,发信人地址,在线者地址字符串都采用以下形式:
第一个客户端IP地址:第一个客户端端口号,第二个客户端IP地址:第二个客户端端口号,…
如果是发信人地址,则只有一个客户端IP地址和端口号
(5)服务器类有两个静态容器:
一个是String数组,用来储存当前在线的所有人的uid,
一个是HashTable<String, 服务线程>, 存储所有服务线程,可以根据uid取出对应的服务线程
当客户端加入或退出时,先更新服务器中的这两个容器,添加或删除相应元素,再向客户端发消息更新其在线列表
(6)服务器用while(true)循环中持续监听客户端消息,根据消息类型作出反应。收到"Exit/"类型消息就向所有客户端发出"OnlineListUpdate/在线者地址"类型消息,
收到"Chat/收信人地址/聊天内容"类型消息就向收信客户端发出"Chat/发信人地址/聊天内容"类型消息;
(7)客户端用while(true)循环中持续监听服务器消息,根据消息类型作出反应。收到"Chat/发信人地址/聊天内容"类型消息就在聊天窗口中显示发信人地址和聊天内容,
收到"OnlineListUpdate/在线者地址"类型消息更新在线列表控件显示新的在线列表;
(8)服务器只有在收到客户端消息时才会发送消息;
(9)客户端只有按发送或退出按钮时才会发送消息。
4. 功能示例
5. 服务器代码
import java.io.*;
import java.util.*;
import java.net.*;
import java.text.*;
public class Server
{
public static void main(String[] args) throws Exception
{
//建立服务器ServerSocket
ServerSocket ss = new ServerSocket(10000);
//提示Server建立成功
System.out.println("Server online... " + ss.getInetAddress().getLocalHost().getHostAddress() + ", " + 10000);
//监听端口,建立连接并开启新的ServerThread线程来服务此连接
while(true)
{
//接收客户端Socket
Socket s = ss.accept();
//提取客户端IP和端口
String ip = s.getInetAddress().getHostAddress();
int port = s.getPort();
//建立新的服务器线程, 向该线程提供服务器ServerSocket,客户端Socket,客户端IP和端口
new Thread(new ServerThread(s, ss, ip, port)).start();
}
}
}
class ServerThread implements Runnable
{
//获取的客户端Socket
Socket s = null;
//获取的服务器ServerSocket
ServerSocket ss = null;
//获取的客户端IP
String ip = null;
//获取的客户端端口
int port = 0;
//组合客户端的ip和端口字符串得到uid字符串
String uid = null;
//静态ArrayList存储所有uid,uid由ip和端口字符串拼接而成
static ArrayList<String> uid_arr = new ArrayList<String>();
//静态HashMap存储所有uid, ServerThread对象组成的对
static HashMap<String, ServerThread> hm = new HashMap<String, ServerThread>();
public ServerThread(Socket s, ServerSocket ss, String ip, int port)
{
this.s = s;
this.ss = ss;
this.ip = ip;
this.port = port;
uid = ip + ":" + port;
}
@Override
public void run()
{
//将当前客户端uid存入ArrayList
uid_arr.add(uid);
//将当前uid和ServerThread对存入HashMap
hm.put(uid, this);
//时间显示格式
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss");
//控制台打印客户端IP和端口
System.out.println("Client connected: " + uid);
try
{
//获取输入流
InputStream in = s.getInputStream();
//获取输出流
OutputStream out = s.getOutputStream();
//向当前客户端传输连接成功信息
String welcome = sdf.format(new Date()) + "
成功连接服务器...
服务器IP: " + ss.getInetAddress().getLocalHost().getHostAddress() + ", 端口: 10000
客户端IP: " + ip + ", 端口: " + port + "
";
out.write(welcome.getBytes());
//广播更新在线名单
updateOnlineList(out);
//准备缓冲区
byte[] buf = new byte[1024];
int len = 0;
//持续监听并转发客户端消息
while(true)
{
len = in.read(buf);
String msg = new String(buf, 0, len);
System.out.println(msg);
//消息类型:退出或者聊天
String type = msg.substring(0, msg.indexOf("/"));
//消息本体:空或者聊天内容
String content = msg.substring(msg.indexOf("/") + 1);
//根据消息类型分别处理
//客户端要退出
if(type.equals("Exit"))
{
//更新ArrayList和HashMap, 删除退出的uid和线程
uid_arr.remove(uid_arr.indexOf(uid));
hm.remove(uid);
//广播更新在线名单
updateOnlineList(out);
//控制台打印客户端IP和端口
System.out.println("Client exited: " + uid);
//结束循环,结束该服务线程
break;
}
//客户端要聊天
else if(type.equals("Chat"))
{
//提取收信者地址
String[] receiver_arr = content.substring(0, content.indexOf("/")).split(",");
//提取聊天内容
String word = content.substring(content.indexOf("/") + 1);
//向收信者广播发出聊天信息
chatOnlineList(out, uid, receiver_arr, word);
}
}
}
catch(Exception e){}
}
//向所有已连接的客户端更新在线名单
public void updateOnlineList(OutputStream out) throws Exception
{
for(String tmp_uid : uid_arr)
{
//获取广播收听者的输出流
out = hm.get(tmp_uid).s.getOutputStream();
//将当前在线名单以逗号为分割组合成长字符串一次传送
StringBuilder sb = new StringBuilder("OnlineListUpdate/");
for(String member : uid_arr)
{
sb.append(member);
//以逗号分隔uid,除了最后一个
if(uid_arr.indexOf(member) != uid_arr.size() - 1)
sb.append(",");
}
out.write(sb.toString().getBytes());
}
}
//向指定的客户端发送聊天消息
public void chatOnlineList(OutputStream out, String uid, String[] receiver_arr, String word) throws Exception
{
for(String tmp_uid : receiver_arr)
{
//获取广播收听者的输出流
out = hm.get(tmp_uid).s.getOutputStream();
//发送聊天信息
out.write(("Chat/" + uid + "/" + word).getBytes());
}
}
}
6. 客户端代码
import java.io.*;
import java.net.*;
import javax.swing.*;
import javax.swing.table.*;
import java.awt.*;
import java.awt.event.*;
import java.awt.geom.*;
import java.util.*;
import java.nio.charset.*;
import java.text.*;
public class Client1
{
//建立客户端Socket
static Socket s = null;
//消息接收者uid
static StringBuilder uidReceiver = null;
public static void main(String[] args)
{
//创建客户端窗口对象
ClientFrame cframe = new ClientFrame();
//窗口关闭键无效,必须通过退出键退出客户端以便善后
cframe.setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE);
//获取本机屏幕横向分辨率
int w = Toolkit.getDefaultToolkit().getScreenSize().width;
//获取本机屏幕纵向分辨率
int h = Toolkit.getDefaultToolkit().getScreenSize().height;
//将窗口置中
cframe.setLocation((w - cframe.WIDTH)/2, (h - cframe.HEIGHT)/2);
//设置客户端窗口为可见
cframe.setVisible(true);
try
{
//连接服务器
s = new Socket(InetAddress.getLocalHost(), 10000);
//获取输入流
InputStream in = s.getInputStream();
//获取输出流
OutputStream out = s.getOutputStream();
//获取服务端欢迎信息
byte[] buf = new byte[1024];
int len = in.read(buf);
//将欢迎信息打印在聊天消息框内
cframe.jtaChat.append(new String(buf, 0, len));
cframe.jtaChat.append("
");
//持续等待接收服务器信息直至退出
while(true)
{
in = s.getInputStream();
len = in.read(buf);
System.out.println(len);
//处理服务器传来的消息
String msg = new String(buf, 0, len);
//消息类型:更新在线名单或者聊天
String type = msg.substring(0, msg.indexOf("/"));
//消息本体:更新后的名单或者聊天内容
String content = msg.substring(msg.indexOf("/") + 1);
//根据消息类型分别处理
//更新在线名单
if(type.equals("OnlineListUpdate"))
{
//提取在线列表的数据模型
DefaultTableModel tbm = (DefaultTableModel) cframe.jtbOnline.getModel();
//清除在线名单列表
tbm.setRowCount(0);
//更新在线名单
String[] onlinelist = content.split(",");
//逐一添加当前在线者
for(String member : onlinelist)
{
String[] tmp = new String[3];
//如果是自己则不在名单中显示
if(member.equals(InetAddress.getLocalHost().getHostAddress() + ":" + s.getLocalPort()))
continue;
tmp[0] = "";
tmp[1] = member.substring(0, member.indexOf(":"));
tmp[2] = member.substring(member.indexOf(":") + 1);
//添加当前在线者之一
tbm.addRow(tmp);
}
//提取在线列表的渲染模型
DefaultTableCellRenderer tbr = new DefaultTableCellRenderer();
//表格数据居中显示
tbr.setHorizontalAlignment(JLabel.CENTER);
cframe.jtbOnline.setDefaultRenderer(Object.class, tbr);
}
//聊天
else if(type.equals("Chat"))
{
String sender = content.substring(0, content.indexOf("/"));
String word = content.substring(content.indexOf("/") + 1);
//在聊天窗打印聊天信息
cframe.jtaChat.append(cframe.sdf.format(new Date()) + "
来自 " + sender + ":
" + word + "
");
//显示最新消息
cframe.jtaChat.setCaretPosition(cframe.jtaChat.getDocument().getLength());
}
}
}
catch(Exception e)
{
cframe.jtaChat.append("服务器挂了.....
");
e.printStackTrace();
}
}
}
//客户端窗口
class ClientFrame extends JFrame
{
//时间显示格式
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss");
//窗口宽度
final int WIDTH = 700;
//窗口高度
final int HEIGHT = 700;
//创建发送按钮
JButton btnSend = new JButton("发送");
//创建清除按钮
JButton btnClear = new JButton("清屏");
//创建退出按钮
JButton btnExit = new JButton("退出");
//创建消息接收者标签
JLabel lblReceiver = new JLabel("对谁说?");
//创建文本输入框, 参数分别为行数和列数
JTextArea jtaSay = new JTextArea();
//创建聊天消息框
JTextArea jtaChat = new JTextArea();
//当前在线列表的列标题
String[] colTitles = {"网名", "IP", "端口"};
//当前在线列表的数据
String[][] rowData = null;
//创建当前在线列表
JTable jtbOnline = new JTable
(
new DefaultTableModel(rowData, colTitles)
{
//表格不可编辑,只可显示
@Override
public boolean isCellEditable(int row, int column)
{
return false;
}
}
);
//创建聊天消息框的滚动窗
JScrollPane jspChat = new JScrollPane(jtaChat);
//创建当前在线列表的滚动窗
JScrollPane jspOnline = new JScrollPane(jtbOnline);
//设置默认窗口属性,连接窗口组件
public ClientFrame()
{
//标题
setTitle("聊天室");
//大小
setSize(WIDTH, HEIGHT);
//不可缩放
setResizable(false);
//设置布局:不适用默认布局,完全自定义
setLayout(null);
//设置按钮大小和位置
btnSend.setBounds(20, 600, 100, 60);
btnClear.setBounds(140, 600, 100, 60);
btnExit.setBounds(260, 600, 100, 60);
//设置标签大小和位置
lblReceiver.setBounds(20, 420, 300, 30);
//设置按钮文本的字体
btnSend.setFont(new Font("宋体", Font.BOLD, 18));
btnClear.setFont(new Font("宋体", Font.BOLD, 18));
btnExit.setFont(new Font("宋体", Font.BOLD, 18));
//添加按钮
this.add(btnSend);
this.add(btnClear);
this.add(btnExit);
//添加标签
this.add(lblReceiver);
//设置文本输入框大小和位置
jtaSay.setBounds(20, 460, 360, 120);
//设置文本输入框字体
jtaSay.setFont(new Font("楷体", Font.BOLD, 16));
//添加文本输入框
this.add(jtaSay);
//聊天消息框自动换行
jtaChat.setLineWrap(true);
//聊天框不可编辑,只用来显示
jtaChat.setEditable(false);
//设置聊天框字体
jtaChat.setFont(new Font("楷体", Font.BOLD, 16));
//设置滚动窗的水平滚动条属性:不出现
jspChat.setHorizontalScrollBarPolicy(JScrollPane.HORIZONTAL_SCROLLBAR_NEVER);
//设置滚动窗的垂直滚动条属性:需要时自动出现
jspChat.setVerticalScrollBarPolicy(JScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED);
//设置滚动窗大小和位置
jspChat.setBounds(20, 20, 360, 400);
//添加聊天窗口的滚动窗
this.add(jspChat);
//设置滚动窗的水平滚动条属性:不出现
jspOnline.setHorizontalScrollBarPolicy(JScrollPane.HORIZONTAL_SCROLLBAR_NEVER);
//设置滚动窗的垂直滚动条属性:需要时自动出现
jspOnline.setVerticalScrollBarPolicy(JScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED);
//设置当前在线列表滚动窗大小和位置
jspOnline.setBounds(420, 20, 250, 400);
//添加当前在线列表
this.add(jspOnline);
//添加发送按钮的响应事件
btnSend.addActionListener
(
new ActionListener()
{
@Override
public void actionPerformed(ActionEvent event)
{
//显示最新消息
jtaChat.setCaretPosition(jtaChat.getDocument().getLength());
try
{
//有收信人才发送
if(Client1.uidReceiver.toString().equals("") == false)
{
//在聊天窗打印发送动作信息
jtaChat.append(sdf.format(new Date()) + "
发往 " + Client1.uidReceiver.toString() + ":
");
//显示发送消息
jtaChat.append(jtaSay.getText() + "
");
//向服务器发送聊天信息
OutputStream out = Client1.s.getOutputStream();
out.write(("Chat/" + Client1.uidReceiver.toString() + "/" + jtaSay.getText()).getBytes());
}
}
catch(Exception e){}
finally
{
//文本输入框清除
jtaSay.setText("");
}
}
}
);
//添加清屏按钮的响应事件
btnClear.addActionListener
(
new ActionListener()
{
@Override
public void actionPerformed(ActionEvent event)
{
//聊天框清屏
jtaChat.setText("");
}
}
);
//添加退出按钮的响应事件
btnExit.addActionListener
(
new ActionListener()
{
@Override
public void actionPerformed(ActionEvent event)
{
try
{
//向服务器发送退出信息
OutputStream out = Client1.s.getOutputStream();
out.write("Exit/".getBytes());
//退出
System.exit(0);
}
catch(Exception e){}
}
}
);
//添加在线列表项被鼠标选中的相应事件
jtbOnline.addMouseListener
(
new MouseListener()
{
@Override
public void mouseClicked(MouseEvent event)
{
//取得在线列表的数据模型
DefaultTableModel tbm = (DefaultTableModel) jtbOnline.getModel();
//提取鼠标选中的行作为消息目标,最少一个人,最多全体在线者接收消息
int[] selectedIndex = jtbOnline.getSelectedRows();
//将所有消息目标的uid拼接成一个字符串, 以逗号分隔
Client1.uidReceiver = new StringBuilder("");
for(int i = 0; i < selectedIndex.length; i++)
{
Client1.uidReceiver.append((String) tbm.getValueAt(selectedIndex[i], 1));
Client1.uidReceiver.append(":");
Client1.uidReceiver.append((String) tbm.getValueAt(selectedIndex[i], 2));
if(i != selectedIndex.length - 1)
Client1.uidReceiver.append(",");
}
lblReceiver.setText("发给:" + Client1.uidReceiver.toString());
}
@Override
public void mousePressed(MouseEvent event){};
@Override
public void mouseReleased(MouseEvent event){};
@Override
public void mouseEntered(MouseEvent event){};
@Override
public void mouseExited(MouseEvent event){};
}
);
}
}<span style="color:#3333ff;">
</span>
7. 总结
聊天室软件综合运用了类集框架,多线程,GUI和网络编程的知识。在写程序时笔者发现两个静态容器非常关键,它们是沟通不同客户端的桥梁。另外应当重视注释,否则像代码稍多的程序维护起来就会很困难。