写在前面的话:
- 版权声明:本文为博主原创文章,转载请注明出处!
- 博主是一个小菜鸟,并且非常玻璃心!如果文中有什么问题,请友好地指出来,博主查证后会进行更正,啾咪~~
- 每篇文章都是博主现阶段的理解,如果理解的更深入的话,博主会不定时更新文章。
- 本文初次更新时间:2020.12.22,最后更新时间:2020.12.22
正文开始
开发环境
- windows 10
- eclipse 2020-06
1. 客户端和服务器的出生
1.1 基础知识
网络开发需要的知识:
- 通讯协议:规定怎么传输,怎么算是发送完毕。
- IP:网络地址,IP地址用于识别网络上的每台计算机。
常见的协议:
- TCP(可靠传输协议):保证发送的数据绝对能收到,发一块数据,就必须回应收到没有,东西对不对,对的话再发下一块,不对的话再重新发这一块,保证最终把所有的数据都收到。会占用更高的通信带宽,速度慢,好处是保证能收到。
- UDP(不可靠传输协议):一口气全甩给你,爱收到没收到。以量取胜,占用带宽小,速度快,有个别收不到(丢包),即时性好,速度快,有可能看不全,应用非常的广,例如网游、声音、视频都是UDP。
本机IP地址:localhost
端口号:0-65535
每台电脑不止一个应用程序用网络,但是对外只有一个IP地址,发送和接收数据都是通过这一个IP地址,操作系统收到数据包,需要根据端口号判断给哪个应用程序。
应用程序开启都需要向操作系统申请端口号,端口号在允许的范围内随便申请,只要没被其他程序占用就可以。
即:通过IP地址找到网络上的计算机,通过端口号连接上该计算机上想通讯的应用程序。通讯即两台计算机上的两个应用程序之间互相发送数据。
1.2 新建项目、包、类
打开eclipse,【File】->【New】->【Java Project】新建项目。
项目目录下,右击【src】->【New】->【Package】新建包:
右击【包名】->【New】->【Class】新建两个类:
- Server 类
- Client 类
1.3 客户端出生咯
正常情况下,一个类的起始方法是main方法,main方法本身是静态方法,很多的成员变量都不能用,所以这里我们自己专门定义一个start()
方法,只需要main里面启动起来就好了。
先简单介绍一下关键代码:
import java.net.Socket;
private Socket socket;
socket = new Socket("localhost", 9999); //实例化Socket,实例化的过程就是连接的过程
java.net.Socket
封装了TCP通讯协议的细节操作,使用它与服务端连接后,通过操作两个流即可完成与服务端的数据交换。
Socket(String host, int port)
构建一个套接字,用来连接给定的主机和端口,需要传入的两个参数:
- 服务端的
IP地址
信息 - 服务端的服务
端口号
如上面所说,通过IP地址可以找到服务端的计算机,通过端口号可以找到运行在服务端计算机上的服务端应用程序。实例化的过程就是连接的过程,该过程会抛出异常:
- UnknownHostException:如果连接失败,将会抛出UnknownHostException异常。
- IOException:如果存在其他问题,将抛出IOException异常。UnknownHostException是IOException的子类。
Client 代码:
package csdn.socket;
import java.io.IOException;
import java.net.Socket;
import java.net.UnknownHostException;
/**
* 聊天室客户端
* @author returnzc
*
*/
public class Client {
private Socket socket;
/**
* 构造方法,用来初始化客户端
*/
public Client() {
try {
System.out.println("正在连接服务端...");
socket = new Socket("localhost", 9999);
System.out.println("已连接。");
} catch (UnknownHostException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}
/**
* 客户端开始工作的方法
*/
public void start() {
}
public static void main(String[] args) {
Client client = new Client();
client.start();
}
}
运行结果:
由于此时还没有打开服务端,所以会报错,没关系,之后写好服务端就好了。
正在连接服务端...
java.net.ConnectException: Connection refused: connect
at java.base/sun.nio.ch.Net.connect0(Native Method)
at java.base/sun.nio.ch.Net.connect(Net.java:503)
at java.base/sun.nio.ch.Net.connect(Net.java:492)
at java.base/sun.nio.ch.NioSocketImpl.connect(NioSocketImpl.java:588)
at java.base/java.net.SocksSocketImpl.connect(SocksSocketImpl.java:333)
at java.base/java.net.Socket.connect(Socket.java:648)
at java.base/java.net.Socket.connect(Socket.java:597)
at java.base/java.net.Socket.<init>(Socket.java:520)
at java.base/java.net.Socket.<init>(Socket.java:294)
at JavaSE/csdn.socket.Client.<init>(Client.java:21)
at JavaSE/csdn.socket.Client.main(Client.java:38)
1.4 服务器出生咯
先简单介绍一下关键代码:
import java.net.ServerSocket;
private ServerSocket server;
server = new ServerSocket(6666);
在服务端的java.net.ServerSocket
,主要有两个作用:
- 向系统申请服务端口,客户端就是通过该端口与服务端建立连接的。
- 监听服务端口,一旦客户端发起连接则会自动创建一个Socket与该客户端进行交互。
ServerSocket(int port)
用于创建一个监听端口的服务器套接字,参数port
即监听的端口号,该方法需要捕获IOException异常。
import java.net.Socket;
Socket socket = server.accept(); //等待连接
Socket accept()
用于等待连接,该方法阻塞(卡住了,程序不往下运行了)当前线程直到建立连接为止,即告诉程序不停地等待,直到有客户端连接到这个端口。一旦有人通过网络发送了正确的连接请求,并连接到了响应的端口上,该方法会返回一个表示连接已经建立的Socket
对象,该对象可以用来获得输入流和输出流,即程序可以通过这个Socket
对象与连接中的客户端进行通信。该方法需要捕获IOException异常。
Server 代码:
package csdn.socket;
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
/**
* 聊天室服务端
* @author returnzc
*
*/
public class Server {
private ServerSocket server;
/**
* 初始化服务端
*/
public Server() {
try {
System.out.println("正在启动服务端...");
server = new ServerSocket(6666);
System.out.println("服务端启动完毕。");
} catch (IOException e) {
e.printStackTrace();
}
}
/**
* 服务端开始工作的方法
*/
public void start() {
try {
System.out.println("等待客户端连接...");
Socket socket = server.accept();
System.out.println("一个客户端连接了。");
} catch (IOException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
Server server = new Server();
server.start();
}
}
运行程序:
一旦启动了服务器程序,它便会等待某个客户端连接到它的端口。
正在启动服务端...
服务端启动完毕。
等待客户端连接...
在服务器等待过程中,启动之前写好的客户端,可以得到如下结果:
Server 端
正在启动服务端...
服务端启动完毕。
等待客户端连接...
一个客户端连接了。
Client 端
正在连接服务端...
已连接。
总结一下流程:
- 开启服务器:此时创建一个ServerSocket实例,监听指定端口号。
- 服务器监听指定端口,阻塞,直到建立连接为止。
- 开启客户端:创建一个Socket实例,指定服务器端的IP地址和端口号,在该实例创建的同时便进行了连接服务器的操作。
一个简单的、可以相互连接的客户端和服务器就这样写好啦,接下来该看看他们怎么成长咯~~
2. 客户端和服务器的成长之路
2.1 一对一聊天
先看下图:
可以看到,如果客户端向服务器发送消息,需要有一个输出流,服务器接收客户端的请求需要有一个输入流,反之亦然。
流相关知识以后会单独补充。
2.1.1 成功发送、接收消息
上面提到,accept()
方法会返回一个表示连接已经建立的Socket
对象,该对象可以用来获得输入流和输出流,用于交互,获取的流走的都是TCP协议,获取方式:
OutputStream outStream = socket.getOutputStream(); //通过socket获取输出流,获取的是OutputStream的子类
我们先给客户端加上输出流,用于给服务端发送消息,给服务器端加上输入流,用于接收客户端的消息,发送/接收消息部分都放到start()
方法中写。
Client:
/**
* 客户端开始工作的方法
*/
public void start() {
try {
OutputStream outStream = socket.getOutputStream(); //通过Socket获取输出流
OutputStreamWriter outWriter = new OutputStreamWriter(outStream, "UTF-8");
BufferedWriter bufWriter = new BufferedWriter(outWriter);
PrintWriter out = new PrintWriter(bufWriter, true);
out.println("你好,服务端。");
} catch (IOException e) {
e.printStackTrace();
}
}
Server:
/**
* 服务端开始工作的方法
*/
public void start() {
try {
System.out.println("等待客户端连接...");
Socket socket = server.accept();
System.out.println("一个客户端连接了。");
InputStream inStream = socket.getInputStream(); //通过Socket获取输入流
InputStreamReader inReader = new InputStreamReader(inStream, "UTF-8");
BufferedReader in = new BufferedReader(inReader);
String line = in.readLine();
System.out.println("客户端说:" + line);
} catch (IOException e) {
e.printStackTrace();
}
}
2.1.2 循环读写
上节其实已经可以发送、接收消息了,不过只有一句,还是我们写在代码里固定的消息,接下来改成循环读写
,并且可以通过客户端终端输入想要发送的消息。
Client 代码:
/**
* 客户端开始工作的方法
*/
public void start() {
try {
Scanner scanner = new Scanner(System.in);
OutputStream outStream = socket.getOutputStream(); //通过Socket获取输出流
OutputStreamWriter outWriter = new OutputStreamWriter(outStream, "UTF-8");
BufferedWriter bufWriter = new BufferedWriter(outWriter);
PrintWriter out = new PrintWriter(bufWriter, true);
while (true) {
String line = scanner.nextLine();
out.println(line);
}
} catch (IOException e) {
e.printStackTrace();
}
}
Server 代码:
/**
* 服务端开始工作的方法
*/
public void start() {
try {
System.out.println("等待客户端连接...");
Socket socket = server.accept();
System.out.println("一个客户端连接了。");
InputStream inStream = socket.getInputStream(); //通过Socket获取输入流
InputStreamReader inReader = new InputStreamReader(inStream, "UTF-8");
BufferedReader in = new BufferedReader(inReader);
while (true) {
String line = in.readLine();
System.out.println("客户端说:" + line);
}
} catch (IOException e) {
e.printStackTrace();
}
}
在建立连接之后,就可以通过客户端发送消息了,测试如下:
客户端
正在连接服务端...
已连接。
Hello
Server
服务端
正在启动服务端...
服务端启动完毕。
等待客户端连接...
一个客户端连接了。
客户端说:Hello
客户端说:Server
2.1.3 改进循环读写:处理客户端断开连接
值得注意的是,服务端通过in.readLine()
方法读取客户端发送过来的一行字符串时,当客户端断开连接时,客户端在不同的操作系统上,服务端的反应是不同的:
- 当windows的客户端断开连接时,服务端这边通常readLine方法会直接抛出异常。
- 当linux的客户端断开连接时,服务端这边的常见反应是readLine方法返回为
null
。
所以,服务端在循环读取消息时,最好做以下处理:
String line = null;
while ((line = in.readLine()) != null) {
System.out.println("客户端说:" + line);
}
放上本部分最终代码。
Client:
package csdn.socket;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.net.Socket;
import java.net.UnknownHostException;
import java.util.Scanner;
/**
* 聊天室客户端
* @author returnzc
*
*/
public class Client {
private Socket socket;
/**
* 构造方法,用来初始化客户端
*/
public Client() {
try {
System.out.println("正在连接服务端...");
socket = new Socket("localhost", 9999);
System.out.println("已连接。");
} catch (UnknownHostException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}
/**
* 客户端开始工作的方法
*/
public void start() {
try {
Scanner scanner = new Scanner(System.in);
OutputStream outStream = socket.getOutputStream(); //通过Socket获取输出流
OutputStreamWriter outWriter = new OutputStreamWriter(outStream, "UTF-8");
BufferedWriter bufWriter = new BufferedWriter(outWriter);
PrintWriter out = new PrintWriter(bufWriter, true);
while (true) {
String line = scanner.nextLine();
out.println(line);
}
} catch (IOException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
Client client = new Client();
client.start();
}
}
Server:
package csdn.socket;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.ServerSocket;
import java.net.Socket;
/**
* 聊天室服务端
* @author returnzc
*
*/
public class Server {
private ServerSocket server;
/**
* 初始化服务端
*/
public Server() {
try {
System.out.println("正在启动服务端...");
server = new ServerSocket(9999);
System.out.println("服务端启动完毕。");
} catch (IOException e) {
e.printStackTrace();
}
}
/**
* 服务端开始工作的方法
*/
public void start() {
try {
System.out.println("等待客户端连接...");
Socket socket = server.accept();
System.out.println("一个客户端连接了。");
InputStream inStream = socket.getInputStream(); //通过Socket获取输入流
InputStreamReader inReader = new InputStreamReader(inStream, "UTF-8");
BufferedReader in = new BufferedReader(inReader);
String line = null;
while ((line = in.readLine()) != null) {
System.out.println("客户端说:" + line);
}
} catch (IOException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
Server server = new Server();
server.start();
}
}
总结一下该部分内容:
- 客户端获取输出流,用于发送消息,服务端获取输入流,用于接收消息,输入流和输出流均通过socket对象获取。
- 增加循环读写。
- 对循环读写进行改进,处理客户端断开连接的情况。
2.2 一对多聊天
如果这时两个客户端都连服务器,会发现两个都可以连上服务器,但是只有一个客户端发的消息服务器可以收到。
这是因为server的accept方法是个阻塞方法,调用以后就开始等待客户端连接,当一个客户端连上以后,就会创建一个Socket,而在整个服务端的执行过程中,accept方法只调用过一次,有多个客户端时,当第一个客户端连接上服务器后,服务器并没有再次调用accept,意味着服务器端不再接收新客户端连接了,如果想让多个客户端都能连上的话,便要重复调用accept。当调用一次accept,一个客户端连接了,创建了一个Socket出来,这时再循环调用accept的话,第二个客户端便又可以连接啦~~
思考一下该怎么循环调用accept呢,比如这样:
while (true) {
System.out.println("等待客户端连接...");
Socket socket = server.accept();
System.out.println("一个客户端连接了。");
}
乍一看好像是可以的,然鹅,我们会悲催地发现,这个循环下面的获取输入流代码开始报错了,因为这里成了死循环,下面的代码永远走不到,即不可达代码。如果执意用这段代码,把下面不可达代码全部注释掉,会发现倒是可以连接多个客户端了,然鹅又没办法接收消息,么得用呀。
2.2.1 创建ClientHandler类
好吧,该多线程登场了。多线程部分知识之后单独补充。
该部分只需要对Server进行调整。如上面所说,首先我们需要循环执行accept()方法,还需要一个循环用于读写操作,而这两个循环可以通过多线程兼得。解决方法:将accept放入循环中,当一个客户端成功连接,开启一个线程用于接收该客户端的消息,然后便进入下一个循环,又是执行accept等待新的客户端连接。
首先,我们来在Server中创建一个类ClientHandler
用于实现Runnable
接口。需要注意的是,ClientHandler
首先要实现Runnable
接口,并且需要重写run()
方法,我们把所有的读写操作都放到run()
方法中。
public void run() {
try {
InputStream inStream = socket.getInputStream(); //通过Socket获取输入流
InputStreamReader inReader = new InputStreamReader(inStream, "UTF-8");
BufferedReader in = new BufferedReader(inReader);
String line = null;
while ((line = in.readLine()) != null) {
System.out.println("客户端说:" + line);
}
} catch (IOException e) {
e.printStackTrace();
}
}
又由于我们的Socket对象是通过accept()连接成功获得的,所以这里需要将获取到的Socket对象传进来,可以在实例化ClientHandler
类时便将Socket对象传进来,即放到构造方法中:
public ClientHandler(Socket socket) {
this.socket = socket;
}
此时,ClientHandler
类的代码如下:
/**
* 该线程的任务为与指定客户端进行交互
*/
private class ClientHandler implements Runnable {
private Socket socket;
public ClientHandler(Socket socket) {
this.socket = socket;
}
public void run() {
try {
InputStream inStream = socket.getInputStream(); //通过Socket获取输入流
InputStreamReader inReader = new InputStreamReader(inStream, "UTF-8");
BufferedReader in = new BufferedReader(inReader);
String line = null;
while ((line = in.readLine()) != null) {
System.out.println("客户端说:" + line);
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
2.2.2 启动线程
启动一个线程的代码如下:
//启动一个线程来处理该客户端交互
ClientHandler handler = new ClientHandler(socket);
Thread thread = new Thread(handler);
thread.start();
我们还可以通过socket得知远端计算机的地址信息:
private String host; //客户端的地址信息
InetAddress address = socket.getInetAddress(); //通过Socket获取远端计算机地址信息
host = address.getHostAddress(); //获取远端计算机IP地址的字符串形式
Server代码:
package csdn.socket;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
/**
* 聊天室服务端
* @author returnzc
*
*/
public class Server {
private ServerSocket server;
/**
* 初始化服务端
*/
public Server() {
try {
System.out.println("正在启动服务端...");
server = new ServerSocket(9999);
System.out.println("服务端启动完毕。");
} catch (IOException e) {
e.printStackTrace();
}
}
/**
* 服务端开始工作的方法
*/
public void start() {
try {
while (true) {
System.out.println("等待客户端连接...");
Socket socket = server.accept();
System.out.println("一个客户端连接了。");
//启动一个线程来处理该客户端交互
ClientHandler handler = new ClientHandler(socket);
Thread thread = new Thread(handler);
thread.start();
}
} catch (IOException e) {
e.printStackTrace();
}
}
/**
* 该线程的任务为与指定客户端进行交互
*/
private class ClientHandler implements Runnable {
private Socket socket;
private String host; //客户端的地址信息
public ClientHandler(Socket socket) {
this.socket = socket;
//通过Socket获取远端计算机地址信息
InetAddress address = socket.getInetAddress();
//获取远端计算机IP地址的字符串形式
host = address.getHostAddress();
}
public void run() {
try {
InputStream inStream = socket.getInputStream(); //通过Socket获取输入流
InputStreamReader inReader = new InputStreamReader(inStream, "UTF-8");
BufferedReader in = new BufferedReader(inReader);
String line = null;
while ((line = in.readLine()) != null) {
System.out.println(host + "说:" + line);
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
public static void main(String[] args) {
Server server = new Server();
server.start();
}
}
总结:
- 本部分Client代码无变动
- Server加入线程,线程用于处理接收客户端信息。具体方法为创建
ClientHandler
类,实现Runnable
接口,重写run()
方法,run()方法中为该线程执行的具体操作。
2.3 服务端回复客户端
前面我们只写到客户端向服务端发送消息,没有写服务端向客户端发送消息,这里补上这部分内容。
前面提到这个图:
客户端向服务器发送消息,需要有一个输出流,服务器接收客户端的请求需要有一个输入流;相反,如果服务端想向客户端发送消息,则服务端需要一个输出流,而客户端需要一个输入流。
上节由于加入多线程,我们创建了ClientHandler
类,该类实现Runnable
接口,重写run()
方法,我们将服务端接收消息部分都放在了run()方法中,同理,我们将获取输出流(发送消息)部分也放在run()方法中。为了方便,我们先写一个学客户端说话,即把客户端发过来的消息原话发回去:
public void run() {
try {
InputStream inStream = socket.getInputStream(); //通过Socket获取输入流
InputStreamReader inReader = new InputStreamReader(inStream, "UTF-8");
BufferedReader in = new BufferedReader(inReader);
OutputStream outStream = socket.getOutputStream(); //通过Socket获取输出流,用于给客户端发送消息
OutputStreamWriter outWriter = new OutputStreamWriter(outStream, "UTF-8");
BufferedWriter bufWriter = new BufferedWriter(outWriter);
PrintWriter out = new PrintWriter(bufWriter, true);
String line = null;
while ((line = in.readLine()) != null) {
System.out.println(host + "说:" + line);
out.println(host + "服务器说:" + line);
}
} catch (IOException e) {
e.printStackTrace();
}
}
然后再为客户端加上输入流,用于读取服务端发来的消息,当然由于目前是服务端学说话,所以暂时是发一句接收一句:
/**
* 客户端开始工作的方法
*/
public void start() {
try {
Scanner scanner = new Scanner(System.in);
OutputStream outStream = socket.getOutputStream(); //通过Socket获取输出流
OutputStreamWriter outWriter = new OutputStreamWriter(outStream, "UTF-8");
BufferedWriter bufWriter = new BufferedWriter(outWriter);
PrintWriter out = new PrintWriter(bufWriter, true);
InputStream inStream = socket.getInputStream(); //通过Socket获取输入流读取服务端发送过来的消息
InputStreamReader inReader = new InputStreamReader(inStream, "UTF-8");
BufferedReader in = new BufferedReader(inReader);
while (true) {
String line = scanner.nextLine();
out.println(line);
line = in.readLine();
System.out.println(line);
}
} catch (IOException e) {
e.printStackTrace();
}
}
验证结果:
2.3 客户端对话客户端
在上面我们都是客户端与服务端直接进行对话。接下来需要改成客户端与连接到同一个服务端的另一个客户端进行对话。
首先回忆一下,客户端接收服务端的消息,需要拿到服务端的输出流,如果想要接收到另一个客户端的消息,那么也需要拿到这个客户端的输出流。
在我们的Server
代码中,out
是各自ClientHandler
的run方法里的局部变量。要想拿到另一个ClientHandler
里run方法的out
输出流,需要把输出流共享出来,即每个客户端都能看得见,而这些ClientHandler
都是Server
类的内部类(内部类的特点:能访问外部类的属性),这几个内部类都是用同一个Server实例化出来的。所以我们可以这样解决:在Server里定义一个数组,数组中存储所有客户端的输出流。
总结一下:问题在于每个客户端要想给其他的客户端发消息,必须得有那个客户端对应的输出流才能发,而每一个ClientHandler只能看到自己的out,看不到其他人的,为了让所有的ClientHandler能互相看到对方的输出流,于是必须让这些ClientHandler找到一个大家都能看得见的地方把各自的输出流分享出来,而ClientHandler是Server类的内部类,内部类有一个特点是可以访问到外部类的属性,由于我们的ClientHandler是在同一个Server new出来的,那他们都同属于这个Server,如果在这个Server上定义一个属性的话,意味着这些ClientHandler都可以看得到这个属性,于是定义成一个数组(可以放多个元素),每一个ClientHandler都把想让别人看到的输出流都放到这个数组中,这样大家都可以看到对方的了。发一条消息,遍历数组,就可以找到想发的人了。如果有新来的客户端可以扩容数组,掉线可以缩容。
2.3.1 修改Server
- 创建数组
private PrintWriter[] allOut = {};
该数组用于保存所有ClientHandler对应的输出流,便于所有ClientHandler获取以广播消息给所有的客户端。由于内部类可以访问外部类的属性,对此经常可以在外部类上定义属性作为所有内部类的公共区域来共享它们的信息使用。
- 将输出流保存到 allOut 数组中
//1. 扩容数组
allOut = Arrays.copyOf(allOut, allOut.length + 1);
//2. 将当前输出流存入数组
allOut[allOut.length-1] = out;
我们知道每连接一个客户端,便会实例化一个ClientHandler对象,在run()方法中,会获取所连接客户端的输出流,所以,只需要在获取输出流之后将该输出流保存到allOut
数组中即可,而由于我们最初创建的数组是一个空数组,所以需要在保存输出流之前先对数组进行扩容。
- 遍历 allOut 数组,发送给所有客户端
//遍历allOut数组,给所有客户端发送
for (int i = 0; i < allOut.length; i++) {
allOut[i].println(host + "客户端说:" + line);
}
- 如果一个客户端断线了,服务端要把输出流从数组里删除
finally { //只要断开都要关掉socket
//处理当前客户端断开连接后的操作
//将该out从数组中删除
for (int i = 0; i < allOut.length; i++) {
if (allOut[i] == out) {
//将其与最后一个元素交换
allOut[i] = allOut[allOut.length-1];
break;
}
}
//缩容
allOut = Arrays.copyOf(allOut, allOut.length - 1);
try {
socket.close(); //关闭socket释放资源
} catch (IOException e) {
e.printStackTrace();
}
}
由于输入流和输出流都是通过socket获取的,所以只需要关掉socket就好了,注意,需要将PrintWriter out = null;
改在try
外面。
目前Server代码如下:
package csdn.socket;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.Arrays;
/**
* 聊天室服务端
* @author returnzc
*
*/
public class Server {
private ServerSocket server;
//该数组用于保存所有ClientHandler对应的输出流
private PrintWriter[] allOut = {};
/**
* 初始化服务端
*/
public Server() {
try {
System.out.println("正在启动服务端...");
server = new ServerSocket(9999);
System.out.println("服务端启动完毕。");
} catch (IOException e) {
e.printStackTrace();
}
}
/**
* 服务端开始工作的方法
*/
public void start() {
try {
while (true) {
System.out.println("等待客户端连接...");
Socket socket = server.accept();
System.out.println("一个客户端连接了。");
//启动一个线程来处理该客户端交互
ClientHandler handler = new ClientHandler(socket);
Thread thread = new Thread(handler);
thread.start();
}
} catch (IOException e) {
e.printStackTrace();
}
}
/**
* 该线程的任务为与指定客户端进行交互
*/
private class ClientHandler implements Runnable {
private Socket socket;
private String host; //客户端的地址信息
public ClientHandler(Socket socket) {
this.socket = socket;
//通过Socket获取远端计算机地址信息
InetAddress address = socket.getInetAddress();
//获取远端计算机IP地址的字符串形式
host = address.getHostAddress();
}
public void run() {
PrintWriter out = null;
try {
InputStream inStream = socket.getInputStream(); //通过Socket获取输入流
InputStreamReader inReader = new InputStreamReader(inStream, "UTF-8");
BufferedReader in = new BufferedReader(inReader);
OutputStream outStream = socket.getOutputStream(); //通过Socket获取输出流,用于给客户端发送消息
OutputStreamWriter outWriter = new OutputStreamWriter(outStream, "UTF-8");
BufferedWriter bufWriter = new BufferedWriter(outWriter);
out = new PrintWriter(bufWriter, true);
//1. 扩容数组
allOut = Arrays.copyOf(allOut, allOut.length + 1);
//2. 将当前输出流存入数组
allOut[allOut.length-1] = out;
String line = null;
while ((line = in.readLine()) != null) {
System.out.println(host + "说:" + line);
//out.println(host + "服务器说:" + line);
//遍历allOut数组,给所有客户端发送
for (int i = 0; i < allOut.length; i++) {
allOut[i].println(host + "客户端说:" + line);
}
}
} catch (IOException e) {
e.printStackTrace();
} finally { //只要断开都要关掉socket
//处理当前客户端断开连接后的操作
//将该out从数组中删除
for (int i = 0; i < allOut.length; i++) {
if (allOut[i] == out) {
//将其与最后一个元素交换
allOut[i] = allOut[allOut.length-1];
break;
}
}
//缩容
allOut = Arrays.copyOf(allOut, allOut.length - 1);
try {
socket.close(); //关闭socket释放资源
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
public static void main(String[] args) {
Server server = new Server();
server.start();
}
}
2.3.2 修改Client
上面我们修改了Server代码,然鹅目前代码还是有问题滴。具体在于,以上代码能收到消息,但是收到的不全。第一个客户端发送消息,服务端转发给了所有客户端,但是客户端能不能显示消息,取决于输入流读没读这句话,但是目前我们的客户端是先发一句再读一句,而其他的客户端还没执行到读操作,所以看着像是没收到消息一样。所以客户端要一直读取,并且读写操作要分开,没错,又到线程部分啦。
这里我们创建一个ServerHandler
类,实现Runnable
接口,即创建一个线程用于执行读操作:
private class ServerHandler implements Runnable {
public void run() {
InputStream inStream;
try {
//通过Socket获取输入流读取服务端发送过来的消息
inStream = socket.getInputStream();
InputStreamReader inReader = new InputStreamReader(inStream, "UTF-8");
BufferedReader in = new BufferedReader(inReader);
String message = null;
while ((message = in.readLine()) != null) {
System.out.println(message);
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
启动线程:
ServerHandler handler = new ServerHandler();
Thread thread = new Thread(handler);
thread.start();
目前Client代码如下:
package csdn.socket;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.net.Socket;
import java.net.UnknownHostException;
import java.util.Scanner;
/**
* 聊天室客户端
* @author returnzc
*
*/
public class Client {
private Socket socket;
/**
* 构造方法,用来初始化客户端
*/
public Client() {
try {
System.out.println("正在连接服务端...");
socket = new Socket("localhost", 9999);
System.out.println("已连接。");
} catch (UnknownHostException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}
/**
* 客户端开始工作的方法
*/
public void start() {
try {
//先启动用于读取服务端消息的线程
ServerHandler handler = new ServerHandler();
Thread thread = new Thread(handler);
thread.start();
Scanner scanner = new Scanner(System.in);
OutputStream outStream = socket.getOutputStream(); //通过Socket获取输出流
OutputStreamWriter outWriter = new OutputStreamWriter(outStream, "UTF-8");
BufferedWriter bufWriter = new BufferedWriter(outWriter);
PrintWriter out = new PrintWriter(bufWriter, true);
while (true) {
String line = scanner.nextLine();
out.println(line);
}
} catch (IOException e) {
e.printStackTrace();
}
}
private class ServerHandler implements Runnable {
public void run() {
InputStream inStream;
try {
//通过Socket获取输入流读取服务端发送过来的消息
inStream = socket.getInputStream();
InputStreamReader inReader = new InputStreamReader(inStream, "UTF-8");
BufferedReader in = new BufferedReader(inReader);
String message = null;
while ((message = in.readLine()) != null) {
System.out.println(message);
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
public static void main(String[] args) {
Client client = new Client();
client.start();
}
}
2.4 考虑并发安全问题
上面我们说到,新建一个数组allOut
用于保存所有客户端的输出流,然后我们会在连接上一个客户端的时候对数组进行扩容,增加一个输入流进去,如果一个客户端断开连接,就需要删除响应的输出流,然后缩容。
所以,我们对数组有遍历、添加、删除的操作,并且每个ClientHandler
都对allOut
数组有这样的操作。要知道,ClientHandler
是一个线程,多个线程是很有可能同时操作同一个数据的,这样就会出现并发安全问题。
首先,添加和删除操作都对数据进行了修改,是一定要考虑并发安全问题的,那来看一下遍历,单独只看遍历的话,只访问数据,并不会修改,不会出现问题。But,在遍历的过程中,其他线程是有可能添加或删除的,这样子就对数据有修改,所以在这里遍历也要考虑并发安全问题。
怎么解决这个问题呢。当然是加同步啦~~
- 扩容增加同步
/**
* 多个ClientHandler不能同时向数组添加元素,否则会出现并发安全问题。
* 对此下面的代码要保证同步运行。
*/
synchronized (allOut) {
//1. 扩容数组
allOut = Arrays.copyOf(allOut, allOut.length + 1);
//2. 将当前输出流存入数组
allOut[allOut.length-1] = out;
}
- 遍历增加同步
synchronized (allOut) {
//遍历allOut数组,给所有客户端发送
for (int i = 0; i < allOut.length; i++) {
allOut[i].println(host + "客户端说:" + line);
}
}
- 缩容增加同步
synchronized (allOut) {
//将该out从数组中删除
for (int i = 0; i < allOut.length; i++) {
if (allOut[i] == out) {
//将其与最后一个元素交换
allOut[i] = allOut[allOut.length-1];
break;
}
}
//缩容
allOut = Arrays.copyOf(allOut, allOut.length - 1);
}
目前Server代码如下(Client未变):
package csdn.socket;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.Arrays;
/**
* 聊天室服务端
* @author returnzc
*
*/
public class Server {
private ServerSocket server;
//该数组用于保存所有ClientHandler对应的输出流
private PrintWriter[] allOut = {};
/**
* 初始化服务端
*/
public Server() {
try {
System.out.println("正在启动服务端...");
server = new ServerSocket(9999);
System.out.println("服务端启动完毕。");
} catch (IOException e) {
e.printStackTrace();
}
}
/**
* 服务端开始工作的方法
*/
public void start() {
try {
while (true) {
System.out.println("等待客户端连接...");
Socket socket = server.accept();
System.out.println("一个客户端连接了。");
//启动一个线程来处理该客户端交互
ClientHandler handler = new ClientHandler(socket);
Thread thread = new Thread(handler);
thread.start();
}
} catch (IOException e) {
e.printStackTrace();
}
}
/**
* 该线程的任务为与指定客户端进行交互
*/
private class ClientHandler implements Runnable {
private Socket socket;
private String host; //客户端的地址信息
public ClientHandler(Socket socket) {
this.socket = socket;
//通过Socket获取远端计算机地址信息
InetAddress address = socket.getInetAddress();
//获取远端计算机IP地址的字符串形式
host = address.getHostAddress();
}
public void run() {
PrintWriter out = null;
try {
InputStream inStream = socket.getInputStream(); //通过Socket获取输入流
InputStreamReader inReader = new InputStreamReader(inStream, "UTF-8");
BufferedReader in = new BufferedReader(inReader);
OutputStream outStream = socket.getOutputStream(); //通过Socket获取输出流,用于给客户端发送消息
OutputStreamWriter outWriter = new OutputStreamWriter(outStream, "UTF-8");
BufferedWriter bufWriter = new BufferedWriter(outWriter);
out = new PrintWriter(bufWriter, true);
/**
* 多个ClientHandler不能同时向数组添加元素,否则会出现并发安全问题。
* 对此下面的代码要保证同步运行。
*/
synchronized (allOut) {
//1. 扩容数组
allOut = Arrays.copyOf(allOut, allOut.length + 1);
//2. 将当前输出流存入数组
allOut[allOut.length-1] = out;
}
String line = null;
while ((line = in.readLine()) != null) {
System.out.println(host + "说:" + line);
//out.println(host + "服务器说:" + line);
synchronized (allOut) {
//遍历allOut数组,给所有客户端发送
for (int i = 0; i < allOut.length; i++) {
allOut[i].println(host + "客户端说:" + line);
}
}
}
} catch (IOException e) {
e.printStackTrace();
} finally { //只要断开都要关掉socket
//处理当前客户端断开连接后的操作
synchronized (allOut) {
//将该out从数组中删除
for (int i = 0; i < allOut.length; i++) {
if (allOut[i] == out) {
//将其与最后一个元素交换
allOut[i] = allOut[allOut.length-1];
break;
}
}
//缩容
allOut = Arrays.copyOf(allOut, allOut.length - 1);
}
try {
socket.close(); //关闭socket释放资源
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
public static void main(String[] args) {
Server server = new Server();
server.start();
}
}
2.5 采用集合
前面我们保存客户端输出流的数据结构是数组,多麻烦呀,换成集合吧~~
- 修改 allOut 定义
private PrintWriter[] allOut = {};
//改成
private List<PrintWriter> allOut = new ArrayList<PrintWriter>();
- 修改扩容
//1. 扩容数组
allOut = Arrays.copyOf(allOut, allOut.length + 1);
//2. 将当前输出流存入数组
allOut[allOut.length-1] = out;
//改成
allOut.add(out);
- 修改遍历
//遍历allOut数组,给所有客户端发送
for (int i = 0; i < allOut.length; i++) {
allOut[i].println(host + "客户端说:" + line);
}
//改成
for (PrintWriter o : allOut) {
o.println(host + "客户端说:" + line);
}
- 修改删除
//将该out从数组中删除
for (int i = 0; i < allOut.length; i++) {
if (allOut[i] == out) {
//将其与最后一个元素交换
allOut[i] = allOut[allOut.length-1];
break;
}
}
//缩容
allOut = Arrays.copyOf(allOut, allOut.length - 1);
//改成
allOut.remove(out);
最终版Server(Client无变动):
package csdn.socket;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
/**
* 聊天室服务端
* @author returnzc
*
*/
public class Server {
private ServerSocket server;
//用于保存所有ClientHandler对应的输出流
private List<PrintWriter> allOut = new ArrayList<PrintWriter>();
/**
* 初始化服务端
*/
public Server() {
try {
System.out.println("正在启动服务端...");
server = new ServerSocket(9999);
System.out.println("服务端启动完毕。");
} catch (IOException e) {
e.printStackTrace();
}
}
/**
* 服务端开始工作的方法
*/
public void start() {
try {
while (true) {
System.out.println("等待客户端连接...");
Socket socket = server.accept();
System.out.println("一个客户端连接了。");
//启动一个线程来处理该客户端交互
ClientHandler handler = new ClientHandler(socket);
Thread thread = new Thread(handler);
thread.start();
}
} catch (IOException e) {
e.printStackTrace();
}
}
/**
* 该线程的任务为与指定客户端进行交互
*/
private class ClientHandler implements Runnable {
private Socket socket;
private String host; //客户端的地址信息
public ClientHandler(Socket socket) {
this.socket = socket;
//通过Socket获取远端计算机地址信息
InetAddress address = socket.getInetAddress();
//获取远端计算机IP地址的字符串形式
host = address.getHostAddress();
}
public void run() {
PrintWriter out = null;
try {
InputStream inStream = socket.getInputStream(); //通过Socket获取输入流
InputStreamReader inReader = new InputStreamReader(inStream, "UTF-8");
BufferedReader in = new BufferedReader(inReader);
OutputStream outStream = socket.getOutputStream(); //通过Socket获取输出流,用于给客户端发送消息
OutputStreamWriter outWriter = new OutputStreamWriter(outStream, "UTF-8");
BufferedWriter bufWriter = new BufferedWriter(outWriter);
out = new PrintWriter(bufWriter, true);
/**
* 多个ClientHandler不能同时向数组添加元素,否则会出现并发安全问题。
* 对此下面的代码要保证同步运行。
*/
synchronized (allOut) {
allOut.add(out);
}
String line = null;
while ((line = in.readLine()) != null) {
System.out.println(host + "说:" + line);
//out.println(host + "服务器说:" + line);
synchronized (allOut) {
//遍历allOut,给所有客户端发送
for (PrintWriter o : allOut) {
o.println(host + "客户端说:" + line);
}
}
}
} catch (IOException e) {
e.printStackTrace();
} finally { //只要断开都要关掉socket
//处理当前客户端断开连接后的操作
synchronized (allOut) {
//将该out删除
allOut.remove(out);
}
try {
socket.close(); //关闭socket释放资源
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
public static void main(String[] args) {
Server server = new Server();
server.start();
}
}
好了,这个简易聊天室先成长到这里了,至于再加点啥,以后再说吧~~
参考
《Java核心技术》(原书第10版)