1. 程式人生 > >Socket程式設計 ------ 模擬QQ聊天工具

Socket程式設計 ------ 模擬QQ聊天工具

模擬QQ聊天

一、要求

1、一個伺服器可以與多個使用者同時通訊

2、使用者可以通過伺服器與使用者之間通訊

3、使用者可以選擇和所有人發訊息,也可以選擇和某個使用者單獨發訊息

4、伺服器要顯示當前所有線上人員

5、使用者要顯示當前線上的人員

6、當有新使用者登入時或線上使用者退出時,伺服器要向所有其他線上使用者傳送提示資訊,並且伺服器也要顯示相應的提示資訊

7、不能有相同的使用者名稱同時登陸

8、不能傳送空訊息

9、客戶端可以設定連線的伺服器IP和埠

二、瞭解B/S模式的底層socket通訊原理

QQ聊天可以利用協議方式傳送訊息。所以先要了解瀏覽器和伺服器直接的協議,從而仿照。

瀏覽器的請求

GET / HTTP/1.1
Accept: text/html, application/xhtml+xml, */*
Accept-Language: zh-CN
User-Agent: Mozilla/5.0 (Windows NT 6.1; WOW64; Trident/7.0; rv:11.0) like Gecko
Accept-Encoding: gzip, deflate
Host: 192.168.31.169:9090
DNT: 1
Connection: Keep-Alive


請求行,包含:  請求方式(GET或POST) 空格 請求的資源路徑 空格 http的協議版本
接下來是請求訊息頭(...)
空行
請求體(包括瀏覽器向伺服器提交的表單資料等)

HTTP/1.1 200 OK
Date: Fri, 11 Sep 2015 12:33:43 GMT
Server: Apache-Coyote/1.1
Content-Type: text/html;charset=UTF-8
Set-Cookie: JSESSIONID=409C8CF8220AD78D26D47B15DCEADCD3; Path=/; HttpOnly
Vary: Accept-Encoding
Connection: close
Transfer-Encoding: chunked
Content-Language: zh-CN

應答行,包含:http協議版本 空格 應答狀態碼 空格 應答狀態碼資訊碼描述
應答訊息頭(...)
空行
應答體(頁面內容)

三、QQ聊天協議

在伺服器端 用一個HashMap<userName,socket> 維護所有使用者相關的資訊,從而能夠保證和所有的使用者進行通訊。

客戶端的動作:

(1)連線(登入):傳送userName    伺服器的對應動作:1)介面顯示,2)通知其他使用者關於你登入的資訊, 3)把其他線上使用者的userName通知當前使用者 4)開啟一個執行緒專門為當前執行緒

服務

(2)退出(登出):

(3)傳送訊息

※※傳送通訊內容之後,對方如何知道是幹什麼,通過訊息協議來實現:


客戶端向伺服器發的訊息格式設計:
命令關鍵字@#接收方@#訊息內容@#傳送方
連線:userName      ----握手的執行緒serverSocket專門接收該訊息,其它的由伺服器新開的與客戶進行通訊的socket來接收
退出:exit@#全部@#null@#userName
傳送: on @# JList.getSelectedValue() @# tfdMsg.getText() @# tfdUserName.getText()


伺服器向客戶端發的訊息格式設計:
命令關鍵字@#傳送方@#訊息內容
登入:
   1) msg   @#server @# 使用者[userName]登入了  (給客戶端顯示用的)
   2) cmdAdd@#server @# userName (給客戶端維護線上使用者列表用的)
退出:
   1) msg   @#server @# 使用者[userName]退出了  (給客戶端顯示用的)
   2) cmdRed@#server @# userName (給客戶端維護線上使用者列表用的)


傳送:
   msg   @#訊息傳送者( msgs[3] ) @# 訊息內容 (msgs[2])

四、註解和實現程式碼

ClientForm.java類

package com.sina.chat;

import java.awt.BorderLayout;
import java.awt.Dimension;
import java.awt.FlowLayout;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.io.IOException;
import java.io.PrintWriter;
import java.net.Socket;
import java.net.UnknownHostException;
import java.util.Scanner;

import javax.swing.DefaultListModel;
import javax.swing.JButton;
import javax.swing.JDialog;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JList;
import javax.swing.JMenu;
import javax.swing.JMenuBar;
import javax.swing.JMenuItem;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTextArea;
import javax.swing.JTextField;
import javax.swing.ListSelectionModel;
import javax.swing.border.TitledBorder;

public class ClientForm extends JFrame implements ActionListener{
	
	private JTextField userName;//使用者名稱
	private DefaultListModel lm;//用於維護線上使用者列表
	private JList list;
	private JTextField msg;//傳送訊息口
	private JTextArea allMsg;
	private JButton btnConn;
	
	private String HOST="127.0.0.1";//伺服器地址
	private int PORT = 9090;//伺服器埠號


	public ClientForm(){
		addJMnuBar();//新增並處理自定義選單
		JPanel upP = new JPanel();
		upP.add(new JLabel("使用者標識"));
		userName = new JTextField(10);
		upP.add(userName);
		
		//上部面板
		btnConn = new JButton("連線");
		btnConn.setActionCommand("conn");
		btnConn.addActionListener(this);
		upP.add(btnConn);
		JButton btnExit = new JButton("退出");
		btnExit.setActionCommand("exit");
		btnExit.addActionListener(this);
		upP.add(btnExit);
		this.getContentPane().add(upP, BorderLayout.NORTH);
		
		//中部面板
		JPanel cenP = new JPanel(new BorderLayout());
		//以下這段設定“線上使用者”列表	----東	
		lm = new DefaultListModel();//如果版本不對,可以新增<String>
		list = new JList(lm);
		lm.addElement("全部");
		list.setSelectedIndex(0);
		list.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);//設定只能進行單項選擇
		list.setVisibleRowCount(2);//設定預設顯示的行數
		JScrollPane jc = new JScrollPane(list);
		jc.setBorder(new TitledBorder("線上"));
		//setSize是設定的固定大小,而setPreferredSize僅僅是設定最好的大小,
		//這個不一定與實際顯示出來的控制元件大小一致(根據介面整體的變化而變化)
		jc.setPreferredSize(new Dimension(70,cenP.getHeight()));
		cenP.add(jc,BorderLayout.EAST);
		
		//以下這段設定訊息傳送面板  ----南
		JPanel downP = new JPanel();
		downP.add(new JLabel("訊息"));
		msg = new JTextField(20);
		downP.add(msg);
		JButton btnSend = new JButton("傳送");
		btnSend.setActionCommand("send");
		btnSend.addActionListener(this);
		downP.add(btnSend);
		cenP.add(downP,BorderLayout.SOUTH);
		
		//以下設定中間的聊天記錄
		allMsg = new JTextArea();
		allMsg.setEditable(false);
		allMsg.setLineWrap(true);//設定文字域自動換行
		allMsg.setWrapStyleWord(true);
//		cenP.add(new JScrollPane(allMsg));//把訊息框用滾動面板包起來,再加到center中
		cenP.add(allMsg);
		
		this.getContentPane().add(cenP,BorderLayout.CENTER);
		
		this.addWindowListener(new WindowAdapter(){
			public void windowClosing(WindowEvent e){
				sendExitMsg();//傳送退出資訊
			}

		});
		
		this.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);//設定框架上的關閉按鈕
		this.setBounds(300, 300, 400, 300);
		this.setVisible(true);
	}
	
	private void addJMnuBar() {
		JMenuBar  bar = new JMenuBar();
		JMenu m=new JMenu("選項");
		JMenuItem mi = new JMenuItem("設定");
		mi.addActionListener(new ActionListener() {
			
			@Override
			public void actionPerformed(ActionEvent e) {
				//因為setDlg、host等變數要放在內部類中,所以要設定成final型
				final JDialog setDlg = new JDialog(ClientForm.this,true);
				setDlg.setBounds(ClientForm.this.getX()+40, ClientForm.this.getY()+100, 350, 100);
				setDlg.setLayout(new FlowLayout());
				JButton btn = new JButton("設定");
				
				final JTextField host = new JTextField(10);
				host.setText("127.0.0.1");
				final JTextField port = new JTextField(5);
				port.setText("9090");
				
				btn.addActionListener(new ActionListener() {
					
					@Override
					public void actionPerformed(ActionEvent e) {
						ClientForm.this.HOST = host.getText();
						try {
							ClientForm.this.PORT = Integer.parseInt(port.getText());
						} catch (NumberFormatException e1) {
							JOptionPane.showMessageDialog(ClientForm.this,"請輸入數字");
						}
						setDlg.dispose();
					}
				});
				setDlg.add(new JLabel("伺服器IP:"));
				setDlg.add(host);
				setDlg.add(new JLabel("埠號:"));
				setDlg.add(port);
				setDlg.add(btn);
				setDlg.setVisible(true);
			}
		});
		JMenuItem help = new JMenuItem("幫助");
		help.addActionListener(new ActionListener() {
			
			@Override
			public void actionPerformed(ActionEvent e) {
				JDialog dlg = new JDialog(ClientForm.this,true);
				dlg.setBounds(ClientForm.this.getX()+40, ClientForm.this.getY()+100, 300, 80);
				dlg.setLayout(new FlowLayout());
				JLabel label = new JLabel("版本所有@城院.2015-9-13,QQ:888888");
				dlg.add(label);
				dlg.setVisible(true);
			}
		});
		
		bar.add(m);
		m.add(mi);
		m.add(help);
		this.setJMenuBar(bar);
	}

	public static void main(String[] args){
		JFrame.setDefaultLookAndFeelDecorated(true);
		new ClientForm();
	}
	
	public void actionPerformed(ActionEvent e) {
		//連線按鈕
		if(e.getActionCommand().equals("conn")){
			System.out.println("連線。。。");
			connercting();//與伺服器建立連線
		}else if(e.getActionCommand().equals("send")){
			sendMsg();//傳送聊天訊息
		}else if(e.getActionCommand().equals("exit")){
			sendExitMsg();//傳送退出訊息
		}
	}
	
	private Socket client;//宣告一個客戶端的套接字
	private PrintWriter pw;//宣告一個列印流
	private void connercting() {
		try {
			//握手
			client = new Socket(HOST,PORT);
			String userName = this.userName.getText();
			if(userName.equals("")){//控制使用者名稱不能為空
				JOptionPane.showMessageDialog(this, "使用者名稱不能為空");
				return ;
			}
			//連線成功後,將使用者名稱框和連線按鈕設定為不可選
			btnConn.setEnabled(false);
			this.userName.setEditable(false);
			pw=new PrintWriter(client.getOutputStream(), true);
			
			pw.println(userName);
			this.setTitle("使用者【"+userName+"】線上");
			
			new ClientThread().start();//線上聊天處理
		} catch (IOException e) {
			JOptionPane.showMessageDialog(null, "伺服器連線失敗!!!");
		}
		
	}
	//傳送退出訊息
	private void sendExitMsg() {
		if(client==null){
			System.exit(0);
		}
		//自定義訊息協議
		String msg = "exit@#"+"全部"+"@#"+null+"@#"+userName.getText();
		pw.println(msg);
		pw.flush();//這裡一定要記得重新整理
		System.exit(0);
	}
	//傳送聊天訊息
	private void sendMsg() {
		String strMsg=this.msg.getText();
		if(strMsg.equals("")){//保證傳送的訊息不能為空
			JOptionPane.showMessageDialog(this, "不能傳送空訊息。。。");
			return ;
		}
		String msg="on@#"+list.getSelectedValue()+"@#"+strMsg+"@#"+userName.getText();
		pw.println(msg);
		pw.flush();
		this.msg.setText("");
	}
	
	class ClientThread extends Thread{
		public void run(){
			try {
				Scanner sc = new Scanner(client.getInputStream());
				while(sc.hasNextLine()){
					String str = sc.nextLine();
					String[] msgs=str.split("@#");
					//通過聊天協議,解析服務端傳送來的訊息
					if("msg".equals(msgs[0])){
						if("server".equals(msgs[1])){
							if("error".equals(msgs[2])){//判斷連線到伺服器的客戶中是否有同名
								btnConn.setEnabled(true);
								userName.setEditable(true);
								JOptionPane.showMessageDialog(ClientForm.this, "使用者名稱被佔用");
								continue;
							}else{
								str="【通知】:"+msgs[2];
							}
						}else{
							str="【"+msgs[1]+"】說:"+msgs[2];
						}
						allMsg.append("\r\n"+str);
					}else if("cmdAdd".equals(msgs[0])){
						System.out.println(111111111);
						lm.addElement(msgs[2]);
					}else if("cmdRed".equals(msgs[0])){
						System.out.println(6666);
						lm.removeElement(msgs[2]);
					}
				}
			} catch (IOException e) {
				e.printStackTrace();
			}
			
		}
	}
}
ServerForm.java類
package com.sina.chat;

import java.awt.BorderLayout;
import java.awt.Dimension;
import java.awt.Toolkit;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.KeyEvent;
import java.io.IOException;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.Scanner;

import javax.swing.DefaultListModel;
import javax.swing.JFrame;
import javax.swing.JList;
import javax.swing.JMenu;
import javax.swing.JMenuBar;
import javax.swing.JMenuItem;
import javax.swing.JScrollPane;
import javax.swing.JTextArea;
import javax.swing.KeyStroke;
import javax.swing.border.TitledBorder;

public class ServerForm extends JFrame {
	private int PORT = 9090;
	private DefaultListModel lm;//用於維護線上使用者列表
	private JList list;
	private JTextArea area;
	
	private Map<String,Socket> usersMap = new HashMap<String,Socket>();
	
	public ServerForm(){
		super("這是伺服器");
		//聊天訊息記錄框
		area = new JTextArea();
		area.setEditable(false);
		area.setLineWrap(true);
		this.getContentPane().add(new JScrollPane(area));//預設加在center位置
		//介面右邊的線上使用者列表
		lm = new DefaultListModel();
		list = new JList(lm);
		JScrollPane jc = new JScrollPane(list);
		jc.setBorder(new TitledBorder("線上"));
		//setSize是設定的固定大小,而setPreferredSize僅僅是設定最好的大小,
		//這個不一定與實際顯示出來的控制元件大小一致(根據介面整體的變化而變化)
		jc.setPreferredSize(new Dimension(100,this.getHeight()));
		this.getContentPane().add(jc,BorderLayout.EAST);
		//選單
		JMenuBar bar =new JMenuBar();
		this.setJMenuBar(bar);
		JMenu m=new JMenu("控制");
		m.setMnemonic('C');
		bar.add(m);
		//“開啟”選單項,因為內部類要用到這個變數,所以定義為final型
		final JMenuItem run = new JMenuItem("開啟");
		run.setAccelerator(KeyStroke.getKeyStroke('R',KeyEvent.CTRL_MASK));
		run.setActionCommand("run");
		m.add(run);
		m.addSeparator(); //選單分隔線
		//“開啟”選單項
		JMenuItem exit = new JMenuItem("退出");
		exit.setAccelerator(KeyStroke.getKeyStroke('E',KeyEvent.CTRL_MASK));//設定快捷鍵
		exit.setActionCommand("exit");
		m.add(exit);
		
		ActionListener a = new ActionListener() {
			
			public void actionPerformed(ActionEvent e) {
				if(e.getActionCommand().equals("run")){
					startServer();
					run.setEnabled(false);//點選執行按鈕後,將執行按鈕設定為不可選
				}else if(e.getActionCommand().equals("exit")){
					System.exit(0);
				}
			}
		};
		run.addActionListener(a);
		exit.addActionListener(a);
		//整個介面視窗的設定
		final int winWidth = 500;
		final int winHeight = 400;
		Toolkit toolkit = Toolkit.getDefaultToolkit();
		int width = (int) toolkit.getScreenSize().getWidth();//獲得系統解析度
		int height = (int) toolkit.getScreenSize().getHeight();
		this.setBounds(width/2-winWidth/2, height/2-winHeight/2, winWidth, winHeight);
		this.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
		this.setVisible(true);
		
	}
	
	public static void main(String[] args) {
		JFrame.setDefaultLookAndFeelDecorated(true);
		new ServerForm();
	}
	
	private void startServer() {
		try {
			ServerSocket server = new ServerSocket(PORT);
			area.append("啟動伺服器:\r\nIP:"+server.getInetAddress().getHostAddress()+"\r\nPORT:"+server.getLocalPort());
			
			new ServerThread(server).start();
		} catch (IOException e) {
			e.printStackTrace();
		}
	}
	
	class ServerThread extends Thread{
		ServerSocket server = null;
		public ServerThread(ServerSocket server) {
			this.server = server;
		}
		//專門處理握手訊息
		public void run(){
			try {
				while(true){
					Socket socketClient = server.accept();
					Scanner sc = new Scanner(socketClient.getInputStream());
					if(sc.hasNextLine()){
						String userName =sc.nextLine();
						if(isError(userName)){//判斷使用者名稱是否存在,若存在則給使用者傳送提示資訊
							PrintWriter pw = new PrintWriter(socketClient.getOutputStream(),true);
							String msg = "msg@#server@#error";
							pw.println(msg);
							pw.flush();
							continue;
						}
						area.append("\r\n使用者:【"+userName+"】登入,"+socketClient);
						//把使用者新增到list當中----通過該list模組的資料層控制元件lm來完成
						lm.addElement(userName);
						//1通知所有已經線上的人,userName這個人登入了
						msgAll(userName);
						//2告訴userName這個人,有哪些人目前線上
						msgSelf(socketClient);
						//3開一個專門用於和該userName客戶端通訊的執行緒
						new ClientThread(socketClient);
						//4把該使用者放到“線上使用者池”中
						usersMap.put(userName, socketClient);
					}
				}
			} catch (IOException e) {
				e.printStackTrace();
			}
		}
		//判斷使用者名稱是否已經存在了
		private boolean isError(String userName) {
			return usersMap.containsKey(userName);
		}
		//通知所有已經線上的人,userName這個人登入了
		private void msgAll(String userName) {
			Iterator<Socket> it = usersMap.values().iterator();
			while(it.hasNext()){
				Socket s = it.next();
				try {
					PrintWriter pw = new PrintWriter(s.getOutputStream(),true);
					//給使用者顯示在訊息框中的
					String msg = "msg@#server@#使用者【"+userName+"】登入了";
					pw.println(msg);
					pw.flush();//重新整理、重新整理、重新整理
					//給使用者維護JList列表(線上使用者)
					msg = "cmdAdd@#server@#"+userName;
					pw.println(msg);
					pw.flush();
				
				} catch (IOException e) {
					e.printStackTrace();
				}
			}
		}
		//告訴userName這個人,有哪些人目前線上.只要給客戶端傳送“維護JList列表(線上使用者)的訊息”
		private void msgSelf(Socket socketClient) {
			PrintWriter pw = null;
			try {
				pw = new PrintWriter(socketClient.getOutputStream(),true);
				Iterator<String> it = usersMap.keySet().iterator();
				while(it.hasNext()){
					String userName = it.next();
					String msg = "cmdAdd@#server@#"+userName;
					pw.println(msg);
				}
			} catch (IOException e) {
				e.printStackTrace();
			}
			pw.flush();
		}
	}
	
	class ClientThread extends Thread{
		private Socket client;
		
		public ClientThread(Socket client) {
			this.client = client;
			start();
		}
		
		public void run(){
			try {
				Scanner sc = new Scanner(client.getInputStream());
				while(sc.hasNext()){
					String str = sc.nextLine();
					String[] msgs = str.split("@#");
					if("on".equals(msgs[0])){
						sendMsgToSb(msgs);
					}else if("exit".equals(msgs[0])){
						//從“線上使用者池usersMap”中刪除當前使用者
						usersMap.remove(msgs[3]);
						//從介面中的“線上使用者列表”中刪除當前使用者,還要在伺服器的area中顯示該使用者的退出訊息
						lm.removeElement(msgs[3]);
						area.append("\r\n使用者【"+msgs[3]+"】退出了");
						//發該使用者退出的訊息發給其他線上使用者
						sendExitMsgToAll(msgs);
					}
				}
			} catch (IOException e) {
				e.printStackTrace();
			}
		}

		private void sendMsgToSb(String[] msgs) throws IOException {
			if("全部".equals(msgs[1])){
				Iterator<Socket> it = usersMap.values().iterator();
				while(it.hasNext()){
					Socket s = it.next();
					String str = "msg@#"+msgs[3]+"@#"+msgs[2];
					PrintWriter pw = new PrintWriter(s.getOutputStream(),true);
					pw.println(str);
					pw.flush();
				}
			}else{
				Socket s = usersMap.get(msgs[1]);
				String str = "msg@#"+msgs[3]+"@#"+msgs[2];
				PrintWriter pw = new PrintWriter(s.getOutputStream(),true);
				pw.println(str);
				pw.flush();
			}
		}

		private void sendExitMsgToAll(String[] msgs) throws IOException {
			Iterator<Socket> it = usersMap.values().iterator();
			while(it.hasNext()){
				Socket s =it.next();
				//給使用者顯示在訊息框中的
				String str = "msg@#server@#【"+msgs[3]+"】退出了";
				PrintWriter pw = new PrintWriter(s.getOutputStream(),true);
				pw.println(str);
				pw.flush();
				//給使用者維護JList列表(線上使用者)
				str = "cmdRed@#server@#"+msgs[3];
				pw.println(str);
				pw.flush();
			}
		}
	}
}

五、知識點清單

1、java圖形介面基礎知識(javax.swing和java.awt)

2、setPreferredSize:(1)setPreferredSize需要在使用佈局管理器的時候使用,佈局管理器會獲取空間的preferredsize,因而可以生效。例如borderlayout在north中放入一個panel,panel的

高度可以通過這樣實現:panel.setPreferredSize(new Dimension(0, 100));這樣就設定了一個高度為100的panel,寬度隨視窗變化。

(2)setSize,setLocation,setBounds方法需要在不使用佈局管理器的時候使用,也就是setLayout(null)的時候可以使用這三個方法控制佈局。

區分好這兩個不同點之後,我相信你的佈局會更隨心所欲。

3、DefaultListModel:建立並且設定列表資料模型,和JList配合使用,使新增刪除JList上的元素時變得容易

4、setLineWrap:public void setLineWrap(boolean wrap)設定文字區的換行策略。

如果設定為 true,則當行的長度大於所分配的寬度時,將換行。

如果設定為 false,則始終不換行。當策略更改時,將激發 PropertyChange 事件("lineWrap")。此屬性預設為 false

public void setWrapStyleWord(boolean word)設定換行方式(如果文字區要換行)。

如果設定為 true,則當行的長度大於所分配的寬度時,將在單詞邊界(空白)處換行。

如果設定為 false,則將在字元邊界處換行。此屬性預設為 false。

5、匿名類

6、內部類

7、網路程式設計技術

六、執行結果截圖