建议大家先下源代码,导入到Eclipse,然后运行服务器和多个客户端,这样有个不错的体会。
首先来看下整个系统的文件架构图:
系统是个基于UDP的聊天室,因为不能保持所有用户和聊天室的持续连接。同时为了保持数据传输的可靠性,就需要自定义应用层协议了。
程序大概的一个流程如下:
1.启动服务器,点击"start service",之后服务器及开始监听指定端口。
2.启动客户端,输入用户名,点击"connect"。如果服务器端已经存在该用户,则会提示用户相应错误。如果用户登录成功,则服务器端会显示当前在线的用户列表,以及系统信息。客户端则会得到当前用户列表,就可以开发信息了。
3.再登录几个客户端,如2方法。此时其他用户会收到上线提醒。
4.在客户端随便输入点内容,点击"send",选择要发送的人,就会发现消息发送出去了。
5.用户点击"quit",服务器和其他用户都会得到下线提醒,并刷新用户列表。
接下来分析具体的源代码:
package common;
//分别表示消息的作用
public enum CMD {
CMD_USERLOGIN, //用户登录
CMD_UPDATEUSERLIST, //更新用户列表
CMD_SENDMESSAGE, //发送消息
CMD_USERALREADYEXISTS, //当前用户已经存在
CMD_SERVERSTOP, //服务器停止
CMD_USERQUIT //客户端退出
};
CMD类定义的都是一些应用层协议的命令代码,就好像是http协议中的404一样,通过指定的命令代码会告诉客户端和服务器应该怎么做。
package common;
import java.io.Serializable;
import java.net.InetAddress;
public class Client implements Serializable{
private static final long serialVersionUID = -4255412944764507834L;
//用户名
String clientName;
//用户IP地址
InetAddress clientIpAddress;
//用户端口
int clientPort;
public Client(String clientName, InetAddress clientIpAddress, int clientPort) {
super();
this.clientName = clientName;
this.clientIpAddress = clientIpAddress;
this.clientPort = clientPort;
}
public String getClientName() {
return clientName;
}
public InetAddress getClientIp() {
return clientIpAddress;
}
public int getClientPort() {
return clientPort;
}
@Override
public boolean equals(Object obj) {
if(obj==null)
return false;
//如果用户的姓名一样,则它们相同
if(obj instanceof Client){
return clientName.equals(((Client)obj).clientName);
}
return false;
}
}
Client类主要存放用户的一些网络地址信息,注意:因为我们传输和获取的都是对象,所有需要实现Serializable接口,这样才能直接从套接字中读取对象(具体的请百度java序列化)。这里重写了equals方法,使得只要用户名一样,就可以认为Client是一样的,这样便于用户列表的查找。
package common;
import java.io.Serializable;
//聊天消息的格式:接收人、消息内容、发送时间
public class ChatText implements Serializable{
private static final long serialVersionUID = -1356206790228754726L;
private String receiver;
private String text;
private String sendTime;
public String getReceiver() {
return receiver;
}
public String getText() {
return text;
}
public String getSendTime(){
return sendTime;
}
public ChatText(String receiver, String text,String sendTime) {
super();
this.receiver = receiver;
this.text = text;
this.sendTime = sendTime;
}
}
ChatText封装的是聊天消息,包括消息内容、发送人、发送时间等,它也实现了序列号接口。
package common;
import java.io.Serializable;
/*
* Socket传输中,设定消息的格式,便于解析。
* 实现Serializable接口便于传输
* */
public class Message implements Serializable{
private static final long serialVersionUID = 1L;
//载体,不同的命令对应不同的载体
private Object carrier;
private CMD cmd;
private Client client;
public Message(Object carrier, CMD cmd, Client client) {
super();
this.carrier = carrier;
this.cmd = cmd;
this.client = client;
}
public Object getCarrier() {
return carrier;
}
public CMD getCmd() {
return cmd;
}
public Client getClient() {
return client;
}
}
Message用于网络套接字的传输,它的Object对象可以存String、Client、ChatText等等,它也实现了序列号接口。
package common;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.text.SimpleDateFormat;
import java.util.Date;
public class Utils {
public static String serverIP = "127.0.0.1";
public static int serverPort = 8765;
public static String getCurrentFormatTime(){
SimpleDateFormat df = new SimpleDateFormat("[MM-dd HH:mm:ss]: ");//设置日期格式
return df.format(new Date());
}
public static void sendMessage(Message msg,InetAddress address,int port) throws Exception{
byte[] data = new byte[1024*1024];
//设置对象输入流
ByteArrayOutputStream bs = new ByteArrayOutputStream();
ObjectOutputStream bo = new ObjectOutputStream(bs);
bo.writeObject(msg);
//将流转化为字节数组
data = bs.toByteArray();
//设置数据包套接字,并发送
DatagramSocket sendSocket = new DatagramSocket();
DatagramPacket sendPack = new DatagramPacket(data, data.length,address,port);
sendSocket.send(sendPack);
}
/*
* 发送数据报时,注意不需要重复绑定端口
* */
public static Message receiveMessage(DatagramSocket receiveSocket) throws Exception{
//绑定随机的端口,然后接受服务器的信息
byte[] data = new byte[1024*1024];
DatagramPacket receivePack = new DatagramPacket(data, data.length);
receiveSocket.receive(receivePack);
//解析数据包中的Message对象
ByteArrayInputStream bis = new ByteArrayInputStream(receivePack.getData());
ObjectInputStream os = new ObjectInputStream(bis);
return (Message)os.readObject();
}
}
Utils类定义了服务器的Ip和端口号,实际运行中不一定是本地地址。还有获取格式化时间函数,以及根据Message、端口等内容收发数据报的函数。
package server;
import java.awt.EventQueue;
public class ServerMainFrame extends JFrame{
private static final long serialVersionUID = 1L;
private JPanel contentPane;
private static Vector<Client> userList = new Vector<Client>(); //保存登录用户信息
//用于接收的upd套接字
private static DatagramSocket receiveSocket = null;
private static boolean stopFlag = false;
public static void main(String[] args) {
EventQueue.invokeLater(new Runnable() {
public void run() {
try {
ServerMainFrame frame = new ServerMainFrame();
frame.setVisible(true);
} catch (Exception e) {
e.printStackTrace();
}
}
});
}
/**
* Create the frame.
*/
public ServerMainFrame() {
setTitle("Server Stop");
setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
setBounds(100, 100, 450, 377);
contentPane = new JPanel();
contentPane.setBorder(new EmptyBorder(5, 5, 5, 5));
setContentPane(contentPane);
contentPane.setLayout(null);
JLabel lblNewLabel = new JLabel("Service");
lblNewLabel.setBounds(10, 10, 54, 15);
contentPane.add(lblNewLabel);
final JButton btnStartService = new JButton("start service");
btnStartService.setBounds(86, 6, 155, 23);
contentPane.add(btnStartService);
final JButton btnEndService = new JButton("stop service");
btnEndService.setEnabled(false);
btnEndService.setBounds(269, 6, 155, 23);
contentPane.add(btnEndService);
JLabel lblNewLabel_1 = new JLabel("User List");
lblNewLabel_1.setBounds(10, 35, 54, 15);
contentPane.add(lblNewLabel_1);
JLabel lblNewLabel_2 = new JLabel("System Records");
lblNewLabel_2.setBounds(10, 162, 131, 15);
contentPane.add(lblNewLabel_2);
//禁止最大最小化
setResizable(false);
JScrollPane scrollPane_2 = new JScrollPane();
scrollPane_2.setBounds(12, 187, 412, 140);
contentPane.add(scrollPane_2);
final JTextArea textAreaSystemRecords = new JTextArea();
scrollPane_2.setViewportView(textAreaSystemRecords);
textAreaSystemRecords.setEditable(false);
JScrollPane scrollPane = new JScrollPane();
scrollPane.setEnabled(false);
scrollPane.setBounds(10, 60, 412, 94);
contentPane.add(scrollPane);
final JTextArea textAreaUserList = new JTextArea();
scrollPane.setViewportView(textAreaUserList);
textAreaUserList.setEditable(false);
//只绑定一次端口,防止重复绑定
try {
receiveSocket = new DatagramSocket(Utils.serverPort);
} catch (SocketException e2) {
e2.printStackTrace();
}
btnStartService.addActionListener(new ActionListener() {
//启动服务事件监听器
public void actionPerformed(ActionEvent e) {
new Thread(new Runnable() {
@Override
public void run() {
//开始执行服务器初始化过程
userList = new Vector<Client>();
//设置页面上的提示
String tmp = textAreaSystemRecords.getText();
if(!tmp.equals(""))//防止重启服务器时出现的不协调显示
tmp += "\n";
textAreaSystemRecords.setText(tmp+Utils.getCurrentFormatTime()+"the server start listening to port "+Utils.serverPort);
setTitle("Server : Started");
btnStartService.setEnabled(false);
btnEndService.setEnabled(true);
//将停止标志设为false
stopFlag = false;
while(true){
try {
//绑定服务器端口,然后接受来自客户端的信息
Message msg = Utils.receiveMessage(receiveSocket);
Client tc = msg.getClient();
//点击终止按钮后,服务器收到数据包后直接跳出
if(stopFlag)
break;
//根据不同的命令执行不同的过程
switch(msg.getCmd()){
case CMD_USERLOGIN:
//如果当前用户名已经存在
Message tm = null;
if(userList.contains(tc)){
tm = new Message(null,CMD.CMD_USERALREADYEXISTS,null);
Utils.sendMessage(tm,tc.getClientIp(),tc.getClientPort());
}
else{
userList.add(tc);
//如果登录成功,则将最新的在线用户列表发送给所有客户端(类似于好友上线提示)
textAreaSystemRecords.setText(textAreaSystemRecords.getText()+"\n"+Utils.getCurrentFormatTime()+"user [ "+tc.getClientName()+" ] login in");
updateListUserList(textAreaUserList, userList);
for(Client c : userList){
//tc表示当前上线的用户,用于通知其他客户端
tm = new Message(userList,CMD.CMD_UPDATEUSERLIST,tc);
Utils.sendMessage(tm,c.getClientIp(),c.getClientPort());
}
}
break;
//用户退出的响应
case CMD_USERQUIT:
textAreaSystemRecords.setText(textAreaSystemRecords.getText()+"\n"+Utils.getCurrentFormatTime()+"user [ "+tc.getClientName()+" ] login out");
//从用户列表中删除该用户
userList.remove(tc);
updateListUserList(textAreaUserList, userList);
//通知其他用户,该用户下线了
for(Client c : userList){
tm = new Message(userList,CMD.CMD_USERQUIT,tc);
Utils.sendMessage(tm,c.getClientIp(),c.getClientPort());
}
break;
//转发用户的消息
case CMD_SENDMESSAGE:
//获取接收人和聊天内容
ChatText chatText = (ChatText) msg.getCarrier();
String senderName = tc.getClientName();
String receiverName = chatText.getReceiver();
String chatMessage = chatText.getText();
tmp = "\n"+chatText.getSendTime()+":"+senderName+" said to "+receiverName+" :"+chatMessage;
textAreaSystemRecords.setText(textAreaSystemRecords.getText()+tmp);
//向接收人发送消息
msg = new Message(chatText,CMD.CMD_SENDMESSAGE,tc);
//找到接收人的ip地址和端口,由于只比较用户名,所以不用设置ip地址和端口号
tc = userList.elementAt(userList.indexOf(new Client(receiverName,null,0)));
Utils.sendMessage(msg,tc.getClientIp(),tc.getClientPort());
break;
}
} catch (Exception e1) {
JOptionPane.showMessageDialog(textAreaSystemRecords,e1.getMessage());
e1.printStackTrace();
}
}}}).start();
}
});
//由于停止按钮中不会发生阻塞,所以不用使用多线程
btnEndService.addActionListener(new ActionListener() {
//启动服务事件监听器
public void actionPerformed(ActionEvent e) {
//清空用户列表及相关区域
textAreaSystemRecords.setText(textAreaSystemRecords.getText()+"\n"+Utils.getCurrentFormatTime()+"the server stop service");
textAreaUserList.setText("");
setTitle("Server : Stoped");
//设置停止标记
stopFlag = true;
//向在线的所有用户发送服务器停止服务的消息
if(!userList.isEmpty()){
for(Client c : userList){
Message tm = new Message(null,CMD.CMD_SERVERSTOP,null);
try {
Utils.sendMessage(tm,c.getClientIp(),c.getClientPort());
} catch (Exception e1) {
e1.printStackTrace();
}
}
}
btnStartService.setEnabled(true);
btnEndService.setEnabled(false);
}
});
}
//更新界面上的用户列表区域
private static void updateListUserList(JTextArea jta,Vector<Client> v){
if(userList==null)
return;
String s = "UserName\t\tIP\t\tPort\n";
for(Client c : v){
s += c.getClientName()+"\t\t"+c.getClientIp().getHostAddress()+"\t\t"+c.getClientPort()+"\n";
}
jta.setText(s);
}
}
ServerMainFrame类,那些定义图形化控件的代码可以不看,主要看消息处理函数,这里以接收用户登录为例:
首先由于服务器要监听不同客户端发送的消息,所以必须使用多线程并使用死循环,否则服务器监听时,界面上的按钮都会卡死。
当服务器接收到一个Message对象,分析它的CMD命令,然后执行对应操作。例如命令为CMD_USERLOGIN,表示用户登录消息。服务器首先解析出用户名,然后在userList中搜索,如果已经存在该用户,则先客户端发送CMD_USERALREADYEXISTS表示用户已经存在。否则,添加用户到用户列表,更新服务器的用户列表,最后告诉所有的在线用户:“有新用户登录了,需要更新用户列表了”。
其他的都可以类似分析,至于一些控件的操作只是为了系统对用户更加友好。
package client;
import java.awt.EventQueue;
public class ClientMainFrame extends JFrame {
private static final long serialVersionUID = 7952439640530949282L;
private JPanel contentPane;
private JTextField textFieldUserName;
//由于本地测试时,客户端的端口号要不一致
private static int clientPort = new Random().nextInt(10000)+1024;
//每个客户端只有一个接收数据包套接字
private static DatagramSocket receiveSocket = null;
private boolean connectFlag = false;
public static void main(String[] args) {
EventQueue.invokeLater(new Runnable() {
public void run() {
try {
ClientMainFrame frame = new ClientMainFrame();
frame.setVisible(true);
} catch (Exception e) {
e.printStackTrace();
}
}
});
}
/**
* Create the frame.
*/
public ClientMainFrame() {
setTitle("Client : Off");
setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
setBounds(100, 100, 450, 371);
contentPane = new JPanel();
contentPane.setBorder(new EmptyBorder(5, 5, 5, 5));
setContentPane(contentPane);
contentPane.setLayout(null);
textFieldUserName = new JTextField();
textFieldUserName.setBounds(88, 10, 133, 21);
contentPane.add(textFieldUserName);
textFieldUserName.setColumns(10);
final JButton btnConnect = new JButton("connect");
btnConnect.setBounds(228, 9, 93, 23);
contentPane.add(btnConnect);
final JButton btnQuit = new JButton("quit");
btnQuit.setEnabled(false);
btnQuit.setBounds(331, 9, 93, 23);
contentPane.add(btnQuit);
JLabel lblNewLabel_1 = new JLabel("Message Records");
lblNewLabel_1.setBounds(10, 45, 113, 15);
contentPane.add(lblNewLabel_1);
JLabel lblNewLabel_2 = new JLabel("Sentence");
lblNewLabel_2.setBounds(10, 199, 73, 15);
contentPane.add(lblNewLabel_2);
JLabel lblNewLabel_3 = new JLabel("Receiver");
lblNewLabel_3.setBounds(331, 199, 54, 15);
contentPane.add(lblNewLabel_3);
final JButton btnSend = new JButton("Send");
btnSend.setEnabled(false);
btnSend.setBounds(331, 263, 93, 57);
contentPane.add(btnSend);
final JComboBox<String> comboBoxReceiver = new JComboBox<String>();
comboBoxReceiver.setBounds(331, 225, 93, 21);
contentPane.add(comboBoxReceiver);
JLabel lblNewLabel_4 = new JLabel("User Name");
lblNewLabel_4.setBounds(10, 10, 73, 15);
contentPane.add(lblNewLabel_4);
JScrollPane scrollPane = new JScrollPane();
scrollPane.setBounds(10, 70, 414, 119);
contentPane.add(scrollPane);
final JTextArea textAreaMsgRecords = new JTextArea();
textAreaMsgRecords.setEditable(false);
scrollPane.setViewportView(textAreaMsgRecords);
JScrollPane scrollPane_3 = new JScrollPane();
scrollPane_3.setBounds(10, 224, 298, 96);
contentPane.add(scrollPane_3);
final JTextArea textAreaSentence = new JTextArea();
scrollPane_3.setViewportView(textAreaSentence);
//禁止最大最小化
setResizable(false);
//只绑定一次端口,防止重复绑定
try {
receiveSocket = new DatagramSocket(clientPort);
} catch (SocketException e2) {
e2.printStackTrace();
}
btnConnect.addActionListener(new ActionListener() {
//启动服务事件监听器
public void actionPerformed(ActionEvent e) {
String userName = textFieldUserName.getText();
//未输入用户名
if(userName.equals("")){
JOptionPane.showMessageDialog(textAreaMsgRecords,"未输入用户名");
return;
}
/*点击连接服务器服务器按钮,要做两件事:
* 1.告诉服务器当前用户名,ip地址,端口号等信息。如果有人给你发信息,服务器就知道该往哪发。
* 2.根据服务器发送回的当前在线的用户列表,刷新客户端的用户列表
*/
try {
//将用户名,命令,用户的地址信息综合成msg
Message msg = new Message(userName, CMD.CMD_USERLOGIN, new Client(userName,InetAddress.getLocalHost(),clientPort));
Utils.sendMessage(msg,InetAddress.getByName(Utils.serverIP),Utils.serverPort);
}catch (Exception e1) {
JOptionPane.showMessageDialog(textAreaMsgRecords,e1.getMessage());
e1.printStackTrace();
}
//每次连接服务器后,设置停止标签
connectFlag = true;
//之后需要不停的等待服务器端的消息(服务器停止,接收信息等),所以用多线程
new Thread(new Runnable() {
@Override
public void run() {
while(connectFlag){
//获取服务器的回复报文
Message msg = null;
try {
msg = Utils.receiveMessage(receiveSocket);
} catch (Exception e) {
e.printStackTrace();
}
//根据不同的消息,作出不同的反应
switch(msg.getCmd()){
case CMD_USERALREADYEXISTS:
//用户已经存在
JOptionPane.showMessageDialog(textAreaMsgRecords,"您的账号已经在别处登录");
return;
case CMD_UPDATEUSERLIST:
btnSend.setEnabled(true);
btnConnect.setEnabled(false);
btnQuit.setEnabled(true);
textFieldUserName.setEditable(false);
String tmp = textAreaMsgRecords.getText();
if(!tmp.equals(""))
tmp += "\n";
textAreaMsgRecords.setText(tmp+Utils.getCurrentFormatTime()+msg.getClient().getClientName()+"成功登录服务器");
setTitle("Client : ON");
//更新用户列表下拉菜单
@SuppressWarnings("unchecked")
Vector<Client> v = (Vector<Client>)msg.getCarrier();
//首先清空原来的项,然后进行更新
comboBoxReceiver.removeAllItems();
for(Client c : v){
comboBoxReceiver.addItem(c.getClientName());
}
//既然和服务器连接了,那么退出时必须告诉服务器,取消Frame默认的退出功能
setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE);
break;
case CMD_SERVERSTOP:
btnSend.setEnabled(false);
textFieldUserName.setEditable(true);
btnConnect.setEnabled(true);
btnQuit.setEnabled(false);
setTitle("Client : Off");
comboBoxReceiver.removeAllItems();
textAreaMsgRecords.setText(textAreaMsgRecords.getText()+"\n"+Utils.getCurrentFormatTime()+"服务器停止服务");
//设置连接标志为停止false,表示不再接收报文,客户端停止
connectFlag = false;
break;
//其他用户下线,服务器通知
case CMD_USERQUIT:
Client tc = msg.getClient();
textAreaMsgRecords.setText(textAreaMsgRecords.getText()+"\n"+Utils.getCurrentFormatTime()+tc.getClientName()+"退出登录");
//更新用户列表下拉菜单
@SuppressWarnings("unchecked")
Vector<Client> v1 = (Vector<Client>)msg.getCarrier();
//首先清空原来的项,然后进行更新
comboBoxReceiver.removeAllItems();
for(Client c : v1){
comboBoxReceiver.addItem(c.getClientName());
}
break;
//接收其他用户发来的消息
case CMD_SENDMESSAGE:
//获取发件人,收件人,消息内容
ChatText chatText = (ChatText) msg.getCarrier();
String chatMessage = chatText.getText();
String senderName = msg.getClient().getClientName();
tmp = "\n"+chatText.getSendTime()+":"+senderName+" said:"+chatMessage;
textAreaMsgRecords.setText(textAreaMsgRecords.getText()+tmp);
break;
}
}
}
}).start();
}});
//退出按钮的响应函数
btnQuit.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent e) {
String userName = textFieldUserName.getText();
//向服务器发送退出消息
try {
//将用户名,命令,用户的地址信息综合成msg
Message msg = new Message(null, CMD.CMD_USERQUIT, new Client(userName,InetAddress.getLocalHost(),clientPort));
Utils.sendMessage(msg,InetAddress.getByName(Utils.serverIP),Utils.serverPort);
}catch (Exception e1) {
JOptionPane.showMessageDialog(textAreaMsgRecords,e1.getMessage());
e1.printStackTrace();
}
//恢复相关控件的状态
btnSend.setEnabled(false);
btnConnect.setEnabled(true);
btnQuit.setEnabled(false);
textAreaMsgRecords.setText(textAreaMsgRecords.getText()+"\n"+Utils.getCurrentFormatTime()+userName+"退出登录");
setTitle("Client : Off");
textFieldUserName.setEditable(true);
comboBoxReceiver.removeAllItems();
//退出程序
System.exit(0);
}
});
//发送消息的响应函数
btnSend.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent e) {
//获取用户输入的消息
String sentences = textAreaSentence.getText();
//用户未输入任何消息
if(sentences.equals("")){
JOptionPane.showMessageDialog(textAreaMsgRecords,"文本框为空,不能发送");
return;
}
String receiver = (String) comboBoxReceiver.getSelectedItem();
String userName = textFieldUserName.getText();
//向服务器发送“我要发消息”的消息
try {
//将消息格式内容,命令,用户的地址信息综合成msg
Message msg = new Message(new ChatText(receiver, sentences,Utils.getCurrentFormatTime()), CMD.CMD_SENDMESSAGE, new Client(userName,InetAddress.getLocalHost(),clientPort));
Utils.sendMessage(msg,InetAddress.getByName(Utils.serverIP),Utils.serverPort);
}catch (Exception e1) {
JOptionPane.showMessageDialog(textAreaMsgRecords,e1.getMessage());
e1.printStackTrace();
}
}
});
}
}
ClientMainFrame可以类似于服务器进行分析,就不再赘述了。大家可以自己调试或者分析下,有什么意见可以留言与我进行交流。
刚开始做这个项目,我准备使用非阻塞的TCP套接字,结果发现界面容易卡死,或者是过于复杂。之后就使用UDP套接字,并把地址信息加到数据报中,这样接收端可以进行转发或者恢复了。
另外,我并没有打算使用自定义协议,而是根据连接的次数或者设置一些控制变量来判断具体要进行什么操作。最后过于复杂,把我自己都弄糊涂了。只好按照QQ类似的做个应用层协议,发现很好使,真是无心插柳柳成荫呀。