Java Socket通信实现多人多端网络画板聊天室

老规矩,先上实现的效果展示!

Java Socket通信实现多人多端网络画板聊天室

本文介绍了一个基于Socket实现网络画板聊天室的完整过程,聊天室具备多人文本对话、同步绘图等功能。

初尝试

Socket简介

Socket英文原意有插座、插孔的意思,在计算机术语中表示套接字。所谓套接字(Socket),就是对网络中不同主机上的应用进程之间进行双向通信的端点的抽象。Socket就像一个邮递员。我们想要将消息发送到哪里,或者从哪里接收消息,都需要通过套接字(Socket)。

Socket库相关API简介

服务端的ServerSocket

服务端在连接中是被动方,它可以被动的接收来自客户端的连接申请。

创建

在socket库中,服务端创建的是ServerSocket类。常用的构造方法是:
在这里插入图片描述

ServerSocket sevsoc=new ServerSocket(8888);

因为服务端的IP地址指向的一定是本机,所以可以省略IP地址的参数,只需要声明该SeverSocket绑定的端口即可。每一个端口指向电脑中的一个进程,相当于告诉ServerSocket要把收到的消息发送到哪一个进程上。(注:每一台主机的端口数都是有限的,数量为2^15个,也就是65535个。其中前8000个尽量不要用,以免和系统的程序冲突。)

等待连接

创建ServerSocket对象后,它需要等待Socket的连接申请。这里我们需要用到accept()方法,当ServerSocket对象被创建之后,它会阻塞在accep()方法处,直至接收到客户端Socket的申请。

//等待用户连接
Socket soc=sevsoc.accept();
获取IO流

当ServerSocket获得了Socket的连接申请后,我们需要获取该Socket对象的输入输出流,当我们需要向Socket发送消息时需要用到输出流OutputStream,当我们需要从Socket接收消息时需要用到输入流InputStream。

//获取IO流
InputStream input=soc.getInputStream();
OutputStream output=soc.getOutputStream();
读写数据

通过OutputStream发送数据使用的是write()方法
在这里插入图片描述

从上图我们可以看出通过OutputStream只能发送字节数组,那当我们需要发送整数、字符串或者其他更复杂的数据怎么办呢?一个方法是,我们可以使用OutputStream的子类DataOutputStream,DataOutputStream提供了发送int、char、double等数据类型的方法,这里不展开叙述。另一个方法是,我们模拟DataOutputStream写出类似的方法,并添加我们需要的功能。

//发送一个byte数组
byte[]bt=new byte[4];
output.write(bt);

接收数据使用的是read()方法
在这里插入图片描述

//使用一个byte数组接收数据(注意,这里的数组长度代表了需要接收的byte个数)
byte[]bt=new byte[4];
input.read(bt);

客户端的Socket

客户端在连接中是主动方,它主动向目标主机发起连接申请。

客户端Socket常用的构造方法如下所示:
在这里插入图片描述
这里需要输入的第一个参数是目标主机的IP地址,第二个参数是目标主机上的目标进程的端口号。

后面的输入输出流(IO流)的获取和读写方法与上文介绍ServerSocket时相同,不再赘述。

尝试一次简单的通信

PS:记得先打开服务端再打开客户端,因为服务端是需要等待客户端连接的。

服务端:

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;

public class SimpleServer {
	
	public static void main(String[]arguments) throws IOException{
		SimpleServer spsev=new SimpleServer();
		spsev.test();
	}

	private void test() throws IOException {
		ServerSocket sevsoc=new ServerSocket(8888);
		System.out.println("服务端已上线!");
		
		Socket soc=sevsoc.accept();
		System.out.println("用户已连接!");
		
		//获取IO
		InputStream input=soc.getInputStream();
		OutputStream output=soc.getOutputStream();
		System.out.println("已获取用户的IO");
		
		//服务端接收消息
		//先创建一个用于接收数据的数组,数组长度可以设大一点
		byte[]bt1=new byte[100];
		input.read(bt1);
		//将接收到的数组解析为String
		String str1=new String(bt1);
		System.out.println("接收到:"+str1);
		
		//服务端响应消息
		String str2="客户端你好,我已经接收到了你的消息,我是服务端!";
		//使用getByte方法可以直接将字符串转为一个byte数组
		byte[]bt2=str2.getBytes();
		//然后我们将这个数组发送出去
		output.write(bt2);
		
		input.close();
		output.close();
		sevsoc.close();
	}
}

客户端:

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;
import java.net.UnknownHostException;

public class SimpleClient {
	
	public static void main(String[]arguments) throws UnknownHostException, IOException{
		SimpleClient spcln=new SimpleClient();
		spcln.test();
	}

	private void test() throws UnknownHostException, IOException {
		/*此处因为是在本地测试,"localhost"代表本机,8888代表888端口
		  如果需要和其他主机连接,"localhost"处替换未为目标主机的IP地址*/
		Socket soc=new Socket("localhost",8888);
		System.out.println("Socket已上线!");
		
		InputStream input=soc.getInputStream();
		OutputStream output=soc.getOutputStream();
		
		//服务端发送消息
		String str1="服务端你好,我是客户端!";
		//使用getByte方法可以直接将字符串转为一个byte数组
		byte[]bt1=str1.getBytes();
		//然后我们将这个数组发送出去
		output.write(bt1);
		
		//客户端接收消息
		//先创建一个用于接收数据的数组,数组长度可以设大一点
		byte[]bt2=new byte[100];
		input.read(bt2);
		//将接收到的数组解析为String
		String str2=new String(bt2);
		System.out.println("接收到:"+str2);
		
		input.close();
		output.close();
		soc.close();
	}
}

输出结果:
在这里插入图片描述
通过上面这个例子,相信你已经了解了Socket通信的基本方法。那么下面就让我们走进真正的画板聊天室项目吧!

开启项目!

窗体界面代码

下面的代码只是用来测试窗体显示效果的,未加监听器,也暂时未加入通信功能。

import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.GridLayout;
import java.awt.event.MouseEvent;
import java.awt.event.MouseMotionListener;

import javax.swing.JButton;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTextArea;
import javax.swing.UIManager;
import javax.swing.UnsupportedLookAndFeelException;

public class TestFrame {
	private JTextArea jta1;
	private JTextArea jta2;
	private Graphics g;
	
	public static void main(String[]arguments){
		TestFrame tf=new TestFrame();
		tf.showFrame();
	}
	
	private void showFrame() {
		//创建窗体
		JFrame jf=new JFrame("客户端");
		jf.setSize(1000,500);
		jf.setDefaultCloseOperation(3);
		jf.setLocationRelativeTo(null);
		jf.setResizable(false);
		
		//设置窗体界面布局为网格布局,设置布局为一行两列
		GridLayout grid=new GridLayout(1,2);
		jf.setLayout(grid);
		
		//设置窗体显示风格(这一步可以省略)
		try {
			UIManager.setLookAndFeel("com.sun.java.swing.plaf.nimbus.NimbusLookAndFeel");
		} catch (ClassNotFoundException | InstantiationException | IllegalAccessException
				| UnsupportedLookAndFeelException e) {
			e.printStackTrace();
		}
		
		//聊天界面(窗体左边的部分)
		JPanel jpLeft=new JPanel();
		
		JLabel jlb1=new JLabel("消息窗口");
		jpLeft.add(jlb1);
		//接收消息框
		jta1=new JTextArea(9,40);
		jta1.setLineWrap(true);//设置文本框内容自动换行
		jta1.setWrapStyleWord(true);//设置文本框内容在单词结束处换行
		jta1.append("开始聊天吧~");//向消息框内添加文本
		jta1.setEditable(false);//聊天框内容不可修改
		
		//添加滚动条
		JScrollPane jsp1=new JScrollPane(jta1,JScrollPane.VERTICAL_SCROLLBAR_ALWAYS,JScrollPane.HORIZONTAL_SCROLLBAR_NEVER);
		jpLeft.add(jsp1);
		
		JLabel jlb2=new JLabel("输入窗口");
		jpLeft.add(jlb2);
		
		//发送消息框
		jta2=new JTextArea(9,40);
		jta2.setLineWrap(true);//设置消息框内的文本每满一行就自动换行
		jta2.setWrapStyleWord(true);//设置消息框内文本按单词分隔换行
		
		//添加滚动条
		JScrollPane jsp2=new JScrollPane(jta2,JScrollPane.VERTICAL_SCROLLBAR_ALWAYS,JScrollPane.HORIZONTAL_SCROLLBAR_NEVER);
		jpLeft.add(jsp2);
		
		//发送和取消按钮
		JButton jb1=new JButton("发送");
		jpLeft.add(jb1);
		JButton jb2=new JButton("取消");
		jpLeft.add(jb2);
		
		//绘画界面
		JPanel jpRight=new JPanel();
		BorderLayout board=new BorderLayout();
		jpRight.setLayout(board);//设置绘画界面为板式布局
		
		//画板
		JPanel paintBoard=new JPanel();
		paintBoard.setBackground(Color.white);
		
		//右侧功能按键区
		JPanel buttonBoard=new JPanel();
		buttonBoard.setPreferredSize(new Dimension(80,0));
		
		//添加功能按钮
		String[]buttonNames={"直线","圆形","矩形","铅笔"};
		JButton[]jbtList=new JButton[buttonNames.length];
		for(int i=0;i<buttonNames.length;i++){
			jbtList[i]=new JButton(buttonNames[i]);
			buttonBoard.add(jbtList[i]);
		}
		
		//添加颜色按钮
		Color[]colors={Color.red,Color.yellow,Color.blue,Color.green,Color.black,Color.white};
		String[]colorButtonNames={"红","黄","蓝","绿","黑","白"};
		JButton[]CjbtList=new JButton[colorButtonNames.length];
		for(int i=0;i<colorButtonNames.length;i++){
			CjbtList[i]=new JButton();
			CjbtList[i].setActionCommand(colorButtonNames[i]);
			CjbtList[i].setBackground(colors[i]);
			buttonBoard.add(CjbtList[i]);
		}
		
		//将画板和按键功能区添加到右侧容器中
		jpRight.add(paintBoard,BorderLayout.CENTER);
		jpRight.add(buttonBoard,BorderLayout.EAST);
		
		//将左右两个JPanle添加到窗体上
		jf.add(jpLeft);
		jf.add(jpRight);
		
		jf.setVisible(true);
		
		Listener lis=new Listener();
		paintBoard.addMouseMotionListener(lis);
		
		//获取画笔
		g=paintBoard.getGraphics();
	}
	
	class Listener implements MouseMotionListener{
		public void mouseDragged(MouseEvent e) {}
		public void mouseMoved(MouseEvent e) {}
	}
}

窗体显示效果:
在这里插入图片描述

走向多端通信

在前面的例子中,我们实现的是服务端和客户端的双端通信,那如果我们想要实现多个客户端同时互相传递消息,应该怎么实现呢?
在这里插入图片描述
在双端通信的基础上,我们只需要把服务端作为信息中转站,让服务端把接收到的每一个客户端的消息再发给其他所有客户端即可。
在这里插入图片描述
我们接下来要做的工作可以简单地概括为:

  1. 构建自己的“通信协议”,让文本、图形顺利传输。
  2. 对服务端:在服务端建立while循环不断接受Socket的请求,每次接受Socket请求后就将它的输入输出流存入数组列表中。
  3. 对服务端:每接受一个Socket请求,就创建一个线程,用于监听该Socket的数据。一旦接收到数据,就循环遍历OutputStream的数组列表,将数据逐次发送给其他所有客户端。
  4. 对客户端:每打开一个客户端就创建一个线程接收消息,但是客户端不需要额外的线程去执行发送消息的操作,因为监听器在监听到用户操作后可以发送消息。

构建自己的“通信协议”

通信协议是指双方实体完成通信或服务所必须遵循的规则和约定……交流什么、怎样交流及何时交流,都必须遵循某种互相都能接受的规则。这个规则就是通信协议。

本项目实现的通信功能共有:发送文字、发送直线、发送圆形、发送矩形、发送铅笔、更改颜色、清空画布七个功能。每一个功能发送的消息类型都不尽相同,因此我们需要一套通信协议来告诉通信双方,目前传送的是什么消息。
在这里插入图片描述

byte数组和int的转换

在上述协议中,我们在很多地方都需要传输int数据类型,比如说发送字符串长度、发送坐标、发送颜色值等。但是我们的输入输出流只能传输byte和byte数组,所以我们需要构建方法完成byte数组和int的转换。

//将int转换为byte数组
public byte[] getByte(int number){
	byte[]bt=new byte[4];
	bt[0]=(byte) ((number>>0) & 0xff);
	bt[1]=(byte) ((number>>8) & 0xff);
	bt[2]=(byte) ((number>>16) & 0xff);
	bt[3]=(byte) ((number>>24) & 0xff);
	return bt;
}

//将byte数组还原为int
public int getInt(byte[]bt){
	int number=(bt[3]& 0xff)<<24|
			(bt[2]& 0xff)<<16|
			(bt[1]& 0xff)<<8|
			(bt[0]& 0xff)<<0;
	return number;
}

一个int(32bit)数据类型的长度是4个byte(8bit),如果将int强制转型为byte,会丢失int高24位的数据。所以我们需要将int逐次移位,并逐次取低八位的值存入byte,这样才能保证int的完整性。int和byte数组相互转换的具体原理解析,可以参考博主之前发的这篇文章:Java int和byte数组互相转换时为什么要用到&0xff

发送文字

发送

我们在多行文本框中输入我们想要发送的文字内容,点击发送按钮后,消息被发出。我们给发送按钮添加动作监听器,然后程序判断按下的是发送按钮时,执行sendMsg()函数。在执行所有发送消息操作前,都要发送一条4个汉字长度(8byte)的字符串,告诉接收方目前正在发送的消息类型。

//发送消息
public void sendMsg(){
	try {
		String OP="发送文字";//表示发送文字操作(一定要是8个字节长度的操作)
		output.write(OP.getBytes());
		
		//发送用户名(长度为4个字节)
		output.write(clientName.getBytes());
		
		//获得发送文本的字节长度
		int msglen=jta2.getText().getBytes().length;
		//发送字节长度(方便接收方定义用于接收数据的byte数组大小)
		output.write(getByte(msglen));
		//发送文本内容
		output.write(jta2.getText().getBytes());
		System.out.println("发送:"+jta2.getText());
		
		//获得当前时间
		SimpleDateFormat formatter= new SimpleDateFormat("yyyy-MM-dd 'at' HH:mm:ss z");
		Date date = new Date(System.currentTimeMillis());
		//在多行文本框中显示发送的消息
		jta1.append("\n\r"+"我	"+formatter.format(date)+"\n\r"+jta2.getText());
		//将滚动条拖到最下方
		jta1.setCaretPosition(jta1.getText().length());
		//清空输入框的文本内容
		jta2.setText("");
	} catch (IOException e1) {
		e1.printStackTrace();
	}
}
接收

在所有消息类型进行接收前,都要进行这一步操作:

//接收代表消息类型的字符串,确认接收到的是什么类型的消息
byte[]OP=new byte[8];
input.read(OP);
//然后根据这个字符串跳转到相应的读取方法
switch(new String(OP)){
		……
}
//接收文字的方法
private void readMsg() {
	try {
		//接收消息发送者编号
		byte[]otherName=new byte[4];
		input.read(otherName);
		
		//接收发送文本内容的长度
		byte[]bt1=new byte[4];
		input.read(bt1);
		int reclen=getInt(bt1);
		System.out.println("receive byte:"+reclen);
		
		//根据接收到的文本内容字节长度创建用于接收消息的byte数组
		byte[]bt2=new byte[reclen];
		//接收文本内容
		input.read(bt2);
		String recmsg=new String(bt2);
		System.out.println("接收到:"+recmsg);
		//获得当前时间
		SimpleDateFormat formatter= new SimpleDateFormat("yyyy-MM-dd 'at' HH:mm:ss z");
		Date date = new Date(System.currentTimeMillis());
		//将接收的信息加入多行文本框
		jta1.append("\n\r"+"用户"+new String(otherName)+"	"+formatter.format(date)+"\n\r"+recmsg);
		//将滚动条拖到最下方
		jta1.setCaretPosition(jta1.getText().length());
	} catch (IOException e) {
		e.printStackTrace();
	}
}

发送图形

因为我们想要实现的效果是,同步画板的画面。所以说每次在画布上画完一个图形就需要执行一次发送图形的操作。

比如说当我们绘制直线,在监听到鼠标按下+鼠标松开操作后,就需要在画板上绘制一条直线,并发送一次图形消息。

绘制图形并发送

下面代码中用到的shapePoint是一个长度为4的int数组

//MouseListener中的方法
public void mousePressed(MouseEvent e) {
	//记录鼠标按下的坐标
	shapePoint[0]=e.getX();
	shapePoint[1]=e.getY();
}

public void mouseReleased(MouseEvent e) {
	//记录鼠标松开的坐标
	shapePoint[2]=e.getX();
	shapePoint[3]=e.getY();
	
	//判断最后按下的是哪个图形按钮
	switch(nowButton){
	case"直线":
		System.out.println("直线"+shapePoint[0]+" "+shapePoint[1]+" "+shapePoint[2]+" "+shapePoint[3]);
		//绘制直线
		g.drawLine(shapePoint[0],shapePoint[1],shapePoint[2],shapePoint[3]);
		//调用发送图形方法
		sendShape();
		break;
	case"圆形":
		System.out.println("圆形"+shapePoint[0]+" "+shapePoint[1]+" "+shapePoint[2]+" "+shapePoint[3]);
		//记录圆形左上角坐标点,并计算其宽高
		int x1=Math.min(shapePoint[0],shapePoint[2]);
		int y1=Math.min(shapePoint[1],shapePoint[3]);
		int width=Math.abs(shapePoint[0]-shapePoint[2]);
		int height=Math.abs(shapePoint[1]-shapePoint[3]);
		shapePoint[0]=x1;
		shapePoint[1]=y1;
		shapePoint[2]=width;
		shapePoint[3]=height;
		//绘制椭圆
		g.fillOval(shapePoint[0],shapePoint[1],shapePoint[2],shapePoint[3]);
		//调用发送图形方法
		sendShape();
		break;
	case"矩形":
		//实现方法与画圆类似
		System.out.println("矩形"+shapePoint[0]+" "+shapePoint[1]+" "+shapePoint[2]+" "+shapePoint[3]);
		x1=Math.min(shapePoint[0],shapePoint[2]);
		y1=Math.min(shapePoint[1],shapePoint[3]);
		width=Math.abs(shapePoint[0]-shapePoint[2]);
		height=Math.abs(shapePoint[1]-shapePoint[3]);
		shapePoint[0]=x1;
		shapePoint[1]=y1;
		shapePoint[2]=width;
		shapePoint[3]=height;
		g.fillRect(shapePoint[0],shapePoint[1],shapePoint[2],shapePoint[3]);
		sendShape();
		break;
	}
}

//绘制铅笔的方法
public void mouseDragged(MouseEvent e) {
	if(nowButton.equals("铅笔")){
		shapePoint[2]=shapePoint[0];
		shapePoint[3]=shapePoint[1];
		
		shapePoint[0]=e.getX();
		shapePoint[1]=e.getY();
		g.drawLine(shapePoint[0],shapePoint[1],shapePoint[2],shapePoint[3]);
		
		sendShape();
	}
}
接收
//接收图形消息
private void readShape(String OP) {
	//只要是传输两个点的图形绘制操作都可以用这一条
	try {
		//更新两个点的坐标
		for(int i=0;i<4;i++){
			byte[]bt=new byte[4];
			input.read(bt);
			shapePoint[i]=getInt(bt);
		}
		switch(OP){
		case"发送直线":
			g.drawLine(shapePoint[0],shapePoint[1],shapePoint[2],shapePoint[3]);break;
		case"发送圆形":
			g.fillOval(shapePoint[0],shapePoint[1],shapePoint[2],shapePoint[3]);break;
		case"发送矩形":
			g.fillRect(shapePoint[0],shapePoint[1],shapePoint[2],shapePoint[3]);break;
		case"发送铅笔":
			g.drawLine(shapePoint[0],shapePoint[1],shapePoint[2],shapePoint[3]);break;
		}
	} catch (IOException e) {
		e.printStackTrace();
	}
}

更改颜色

发送
//发送颜色的哈希值
private void sendColor(int colorHashCode) {
	try {
		String OP="更改颜色";
		output.write(OP.getBytes());
		
		//发送颜色哈希值
		output.write(getByte(colorHashCode));
	} catch (IOException e1) {
		e1.printStackTrace();
	}
}
接收
//更改颜色
private void changeColor() {
	try {
		byte[]color=new byte[4];
		input.read(color);
		g.setColor(new Color(getInt(color)));
	} catch (IOException e) {
		e.printStackTrace();
	}
}

清空画布

发送
//只需要发送一个字符串的操作
private void sendSimpleOP(String OP) {
	try {
		output.write(OP.getBytes());
	} catch (IOException e1) {
		e1.printStackTrace();
	}
}
接收
//只接收一个字符串的简单操作
private void readSimpleOP(String OP) {
	switch(OP){
	case"清空画布":paintBoard.paint(g);break;
	}
}

使用多线程收发消息

在这里插入图片描述
服务端的消息通道线程工作流程
在这里插入图片描述
这里的MessageChannel可能需要再讲一下,怎么将接收到的消息发送给每一个客户端呢?

前面说到SeverSocket每接收一个Socket的申请,就将从它获取的输入输出流对象存放到一个数组列表里。当MessageChannel每接收到一次来自客户端的消息时,就循环遍历存放输出流的数组列表,然后依次将对应的消息发送给每一个客户端。

那么所有的代码原理差不多都介绍完了,直接放源代码吧!
完整源代码链接 提取码:swue

试着运行一下!

先打开服务端,接着多次打开客户端,然后就可以顺利通信了!

评论 18
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值