用TCP实现服务器与客户端的交互_tcp客户端和服务端
目录
一、TCP的特点
二、API介绍
1.ServerSocket
2.Socket
三、实现服务器
四、实现客户端
五、测试解决bug
1.客户端发送了数据之后,并没有响应
2.clientSocket没有执行close()操作
3.尝试使用多个客户端同时连接服务器
六、优化
1.短时间有大量客户端访问并断开连接
2.有大量的客户端长时间在线访问
七、源码
引言:
这篇文章主要是用TCP构造的回显服务器,也就是客户端发什么,就返回什么。用实现这个过程方式来学会TCP套接字的使用。
一、TCP的特点
- TCP是可靠的:这个需要去了解TCP的机制,这是一个大工程,博主后面写好了把连接附上
- TCP是面向字节流的
- TCP是全双工的
- TCP是有连接的
除了可靠性,在编程中无法体会到,其他特性我都会一 一讲解。
二、API介绍
1.ServerSocket
ServerSocket 是创建TCP服务端Socket的API
ServerSocket 构造⽅法:
ServerSocket ⽅法:
关闭此套接字
2.Socket
Socket 是客⼾端Socket,或服务端中接收到客⼾端建⽴连接(accept⽅法)的请求后,返回的服务端Socket。 不管是客⼾端还是服务端Socket,都是双⽅建⽴连接以后,保存的对方信息,及⽤来与对⽅收发数据的。
Socket构造方法:
创建一个客户端流套接字Socket,并与对应IP的主机上,对应端口的进程建立连接。
host:IP地址
prot:端口号
这里new出来就是和对方建立完成了。如果建立失败,就会在构造对象的时候抛出异常。
Socket方法:
三、实现服务器
服务器需要指定端口号:
public class TcpEchoServer { private ServerSocket serverSocket = null; // 需要指定服务器的端口 处理ServerSocket抛出的异常 public TcpEchoServer(int port) throws IOException { // 指定服务器的端口 serverSocket = new ServerSocket(port); }}
注意处理抛出的异常
和客户端建立连接:
public class TcpEchoServer { private ServerSocket serverSocket = null; // 需要指定服务器的端口 处理ServerSocket抛出的异常 public TcpEchoServer(int port) throws IOException { // 指定服务器的端口 serverSocket = new ServerSocket(port); } public void start() throws IOException { //服务器需要不停的执行 while (true) { //开始监听指定端口,当有客户端连接后,返回一个保存对方信息的Socket Socket clientSocket = serverSocket.accept(); //处理逻辑 processConnection(clientSocket); } } //针对一个连接,提供处理逻辑 private void processConnection(Socket clientSocket) { }}
这里的accept()就体现了TCP的有连接
当连接成功后,需要处理的逻辑:
//针对一个连接,提供处理逻辑 private void processConnection(Socket clientSocket) { //打印客户端的信息 返回IP地址返回端口号 System.out.printf(\"[%s : %d]客户端上线\\n\",clientSocket.getInetAddress(), clientSocket.getPort()); //获取到socket中持有的流对象 try(InputStream inputStream = clientSocket.getInputStream(); OutputStream outputStream = clientSocket.getOutputStream()) { while (true) { //1.获取请求 //2.处理请求 //3.返回响应 //4.打印日志 } }catch (IOException e) { } }
全双工的意思:通信的双方(如客户端和服务器)可以在同一时间内同时进行数据的发送和接收,即两个方向的数据流可以同时传输,互不干扰。
这里的getInputStream、getOutputStream就体现了全双工和面向字节流。
不了解这两个接口的可以去看我这篇文章:
JAVA如何操作文件?(超级详细)_java操作文件-CSDN博客
实现处理逻辑:
//针对一个连接,提供处理逻辑 private void processConnection(Socket clientSocket) { //打印客户端的信息 返回IP地址返回端口号 System.out.printf(\"[%s : %d]客户端上线\\n\",clientSocket.getInetAddress(), clientSocket.getPort()); //获取到socket中持有的流对象 try(InputStream inputStream = clientSocket.getInputStream(); OutputStream outputStream = clientSocket.getOutputStream()) { //因为我们用字符串来做为数据传输,用Scanner就可以更方便的传输了 Scanner scanner = new Scanner(inputStream); //包装输出流,主要是用println()会在数据之后加上\\n PrintWriter printWriter = new PrintWriter(outputStream); while (true) { //1.获取请求 if (!scanner.hasNext()) { //如果scanner无法读取出数据,说明客户端断开了连接,导致服务器这边读取到”末尾“ break; } //2.处理请求 //接收客户端的请求 //如果遇到 空白字符 就会停止输入 String request = scanner.next(); //处理请求 String response = process(request); //3.返回响应 //此处可以按字节数组的形式,但是我们要输入的是字符串,这个就不太方便 //outputStream.write(response.getBytes()); //此方法在写入之后会自动加上\\n printWriter.println(response); //4.打印日志 System.out.printf(\"[%s : %d] 请求 = %s 响应 = %s \\n\",clientSocket.getInetAddress(),clientSocket.getPort(), request,response); } }catch (IOException e) { throw new RuntimeException(); } } private String process(String request) { //由于我们是回显服务器这里直接返回就可以了 return request; }
注意里面使用了两个接口包装了一下输入输出流,最主要的是可以在用\\n做为分割。
注意里面的:
发送字符串给客户端,最后会自动加上 \\n 做为结尾
println(response);
接收客户端信息,以空白符做为结尾。
空白符:包括不限于 空格、回车、制表符……
scanner.next();
如果是nextLine()就比较严格,必须是\\n做为结尾
这里的服务器处理逻辑就写完了,但其实这里还有三个错误,后面再单独讲解:
public class TcpEchoServer { private ServerSocket serverSocket = null; // 需要指定服务器的端口 处理ServerSocket抛出的异常 public TcpEchoServer(int port) throws IOException { // 指定服务器的端口 serverSocket = new ServerSocket(port); } public void start() throws IOException { //服务器需要不停的执行 while (true) { //开始监听指定端口,当有客户端连接后,返回一个保存对方信息的Socket Socket clientSocket = serverSocket.accept(); //处理逻辑 processConnection(clientSocket); } } //针对一个连接,提供处理逻辑 private void processConnection(Socket clientSocket) { //打印客户端的信息 返回IP地址返回端口号 System.out.printf(\"[%s : %d]客户端上线\\n\",clientSocket.getInetAddress(), clientSocket.getPort()); //获取到socket中持有的流对象 try(InputStream inputStream = clientSocket.getInputStream(); OutputStream outputStream = clientSocket.getOutputStream()) { //因为我们用字符串来做为数据传输,用Scanner就可以更方便的传输了 Scanner scanner = new Scanner(inputStream); //包装输出流,主要是用println()会在数据之后加上\\n PrintWriter printWriter = new PrintWriter(outputStream); while (true) { //1.获取请求 if (!scanner.hasNext()) { //如果scanner无法读取出数据,说明客户端断开了连接,导致服务器这边读取到”末尾“ break; } //2.处理请求 //接收客户端的请求 //如果遇到 空白字符 就会停止输入 String request = scanner.next(); //处理请求 String response = process(request); //3.返回响应 //此处可以按字节数组的形式,但是我们要输入的是字符串,这个就不太方便 //outputStream.write(response.getBytes()); //此方法在写入之后会自动加上\\n printWriter.println(response); //4.打印日志 System.out.printf(\"[%s : %d] 请求 = %s 响应 = %s \\n\",clientSocket.getInetAddress(),clientSocket.getPort(), request,response); } }catch (IOException e) { throw new RuntimeException(); } } private String process(String request) { //由于我们是回显服务器这里直接返回就可以了 return request; } public static void main(String[] args) throws IOException { TcpEchoServer server = new TcpEchoServer(8080); server.start(); }}
四、实现客户端
指定服务器的IP和端口号:
public class TcpEchoClient { private Socket socket = null; public TcpEchoClient(String serverIP, int serverPort) throws IOException { //这里只要建立实例,就是和服务端的accept()建立了连接 //socket也就保存了服务器的IP和端口号等 //需要传入服务器的 IP地址 和 端口号 socket = new Socket(serverIP, serverPort); } public void start() { System.out.println(\"客户端启动!\"); } }
整体逻辑:
public class TcpEchoClient { private Socket socket = null; public TcpEchoClient(String serverIP, int serverPort) throws IOException { //需要传入服务器的 IP地址 和 端口号 //这里只要建立实例,就是和服务端的accept()建立了连接 socket = new Socket(serverIP, serverPort); } public void start() { System.out.println(\"客户端启动!\"); try(OutputStream outputStream = socket.getOutputStream(); InputStream inputStream = socket.getInputStream()) { while (true) { //1.从控制台获取数据 //2.将数据发送给服务器 //3.接收服务器响应 //4.打印相关结果 } }catch (IOException e) { throw new RuntimeException(); } }}
整体逻辑实现:
public class TcpEchoClient { private Socket socket = null; public TcpEchoClient(String serverIP, int serverPort) throws IOException { //这里只要建立实例,就是和服务端的accept()建立了连接 //socket也就保存了服务器的IP和端口号等 //需要传入服务器的 IP地址 和 端口号 socket = new Socket(serverIP, serverPort); } public void start() { System.out.println(\"客户端启动!\"); try(OutputStream outputStream = socket.getOutputStream(); InputStream inputStream = socket.getInputStream()) { //用来接收服务器的信息 Scanner scanner = new Scanner(inputStream); //用于接收用户输入 Scanner scannerIn = new Scanner(System.in); //用于输出数据给服务器 PrintWriter printWriter = new PrintWriter(outputStream); while (true) { //1.从控制台获取数据 System.out.print(\"->\"); String request = scannerIn.next(); //2.将数据发送给服务器 printWriter.println(request); //3.接收服务器响应 //判断服务端是否还有信息 if (!scanner.hasNext()) { break; } //接收服务端信息 String response = scanner.next(); //4.打印相关结果 System.out.println(response); } }catch (IOException e) { throw new RuntimeException(); } } public static void main(String[] args) throws IOException {// 127.0.0.1是专门用来访问自己的 TcpEchoClient client = new TcpEchoClient(\"127.0.0.1\",8080); client.start(); }}
这里仍然纯在一个问题,一会和服务器的问题一起将
五、测试解决bug
最后我会把所有的问题解决了,再把源附上
1.客户端发送了数据之后,并没有响应
先运行服务器,再运行客户端
可以看到目前还是成功的,那么我们来输入数据。
我们在客户端输入了消息,但是没有任何反应了!
此处的情况是,客户端并没有真正把请求发出去:
PrintWriter这样的类,以及很多IO流中的类,都是 “自带缓冲区” 的。
此方法就带有缓冲区:
printWriter.println(request);
引入缓冲区之后,进行写入数据的操作,并不会马上触发IO,而是先放到内存缓冲区中,等到缓冲区里攒了一波之后,再统一进行发送。
为什么引入缓冲区的机制?
因为IO操作其实是不小的开销,如果数据量较少,那么每一次都进行IO,就有很大一部分开销是IO操作。如果积累到一定数据量再进行IO操作,那么一次IO就传输了这么多数据。
我们可以使用flush方法,主动“刷新缓冲区”:
注意:
服务器 和 客户端 都需要在printWriter.println();后面加上flush()方法。
再来测试:
此时就可以接收到了
2.clientSocket没有执行close()操作
这个问题比较隐蔽,这些ServerSocket 和 Socket 每一次都会在“文件描述符”中创建一个新的表项。
文件描述符:描述了该进程都要操作哪些文件。数组的每个元素就是一个struct file对象,每个结构体就描述了对应的文件信息,数组的小标就称为“文件描述符”。
每次打开一个文件,就想当于在数组上占用了一个位置,而这个数组又是不能扩容的,如果数组满了就会打开文件失败。除非主动调用close才会关闭文件,或者这个进程直接结束了这个数组也被带走了。
那么我们就需要处理一下clientSocket:
3.尝试使用多个客户端同时连接服务器
要对同一代码启动多个进程,需要设置一下步骤:
分别启动客户端1 和 客户端2 ,可以看到服务器上根本没有第二个客户端启动的信息:
原因:
我们可以用多线程去执行专门执行每一个客户端的请求:
public void start() throws IOException { //服务器需要不停的执行 while (true) { //开始监听指定端口,当有客户端连接后,返回一个保存对方信息的Socket Socket clientSocket = serverSocket.accept(); //让一个线程去对应一个客户端 Thread thread = new Thread(() -> { //处理逻辑 processConnection(clientSocket); }); thread.start(); } }
结果:
bug问题解决了,但还有一些场景,可能会把服务器干崩溃
六、优化
1.短时间有大量客户端访问并断开连接
一旦短时间内有大量的客户端,并且每个客户端请求都是很快的连接之后并退出的,这个时候对于服务器来说,就会有比较大的压力。这个时候,就算是进程比线程更加的轻量,但是短时间内有大量的线程创建销毁,就无法忽略它的开销了。
我们可以引入线程池,这样就解决了这个问题:
public void start() throws IOException { //服务器需要不停的执行 while (true) { //开始监听指定端口,当有客户端连接后,返回一个保存对方信息的Socket Socket clientSocket = serverSocket.accept(); ExecutorService service = Executors.newCachedThreadPool();// //让一个线程去对应一个客户端// Thread thread = new Thread(() -> {// //处理逻辑// try {// processConnection(clientSocket);// } catch (IOException e) {// e.printStackTrace();// }// });// thread.start(); service.submit(() -> { try { processConnection(clientSocket); } catch (IOException e) { e.printStackTrace(); } }); } }
这个线程可创建的线程数是很大的:
2.有大量的客户端长时间在线访问
例如直播这样的情况,每个客户端分配一个线程,对于一个系统来说,这里搞几百个线程压力就非常大了。所以这里 线程池/线程 都不太适用了。
可以使用 IO多路复用 ,也就是一个线程分配多个客户端进行服务,因为大部分时间线程都是在等待状态,就能够让线程分配多个客户端,这样的机制我们做为java程序员不需要过多了解,这样的机制以及被大佬们,装进各种框架中了。
七、源码
服务器源码:
import java.io.IOException;import java.io.InputStream;import java.io.OutputStream;import java.io.PrintWriter;import java.net.ServerSocket;import java.net.Socket;import java.util.Scanner;import java.util.concurrent.ExecutorService;import java.util.concurrent.Executors;public class TcpEchoServer { private ServerSocket serverSocket = null; // 需要指定服务器的端口 处理ServerSocket抛出的异常 public TcpEchoServer(int port) throws IOException { // 指定服务器的端口 serverSocket = new ServerSocket(port); } public void start() throws IOException { //服务器需要不停的执行 while (true) { //开始监听指定端口,当有客户端连接后,返回一个保存对方信息的Socket Socket clientSocket = serverSocket.accept(); ExecutorService service = Executors.newCachedThreadPool();// //让一个线程去对应一个客户端// Thread thread = new Thread(() -> {// //处理逻辑// try {// processConnection(clientSocket);// } catch (IOException e) {// e.printStackTrace();// }// });// thread.start(); service.submit(() -> { try { processConnection(clientSocket); } catch (IOException e) { e.printStackTrace(); } }); } } //针对一个连接,提供处理逻辑 private void processConnection(Socket clientSocket) throws IOException { //打印客户端的信息 返回IP地址返回端口号 System.out.printf(\"[%s : %d]客户端上线\\n\",clientSocket.getInetAddress(), clientSocket.getPort()); //获取到socket中持有的流对象 try(InputStream inputStream = clientSocket.getInputStream(); OutputStream outputStream = clientSocket.getOutputStream()) { //因为我们用字符串来做为数据传输,用Scanner就可以更方便的传输了 Scanner scanner = new Scanner(inputStream); //包装输出流,主要是用println()会在数据之后加上\\n PrintWriter printWriter = new PrintWriter(outputStream); while (true) { //1.获取请求 if (!scanner.hasNext()) { //如果scanner无法读取出数据,说明客户端断开了连接,导致服务器这边读取到”末尾“ break; } //2.处理请求 //接收客户端的请求 //如果遇到 空白字符 就会停止输入 String request = scanner.next(); //处理请求 String response = process(request); //3.返回响应 //此处可以按字节数组的形式,但是我们要输入的是字符串,这个就不太方便 //outputStream.write(response.getBytes()); //此方法在写入之后会自动加上\\n printWriter.println(response); //刷新缓冲区 printWriter.flush(); //4.打印日志 System.out.printf(\"[%s : %d] 请求 = %s 响应 = %s \\n\",clientSocket.getInetAddress(),clientSocket.getPort(), request,response); } }catch (IOException e) { throw new RuntimeException(); } finally { System.out.printf(\"[%s : %d]客户端下线\\n\",clientSocket.getInetAddress(), clientSocket.getPort()); clientSocket.close(); } } private String process(String request) { //由于我们是回显服务器这里直接返回就可以了 return request; } public static void main(String[] args) throws IOException { TcpEchoServer server = new TcpEchoServer(8080); server.start(); }}
客户端源码:
import java.io.IOException;import java.io.InputStream;import java.io.OutputStream;import java.io.PrintWriter;import java.net.Socket;import java.util.Scanner;public class TcpEchoClient { private Socket socket = null; public TcpEchoClient(String serverIP, int serverPort) throws IOException { //这里只要建立实例,就是和服务端的accept()建立了连接 //socket也就保存了服务器的IP和端口号等 //需要传入服务器的 IP地址 和 端口号 socket = new Socket(serverIP, serverPort); } public void start() { System.out.println(\"客户端启动!\"); try(OutputStream outputStream = socket.getOutputStream(); InputStream inputStream = socket.getInputStream()) { //用来接收服务器的信息 Scanner scanner = new Scanner(inputStream); //用于接收用户输入 Scanner scannerIn = new Scanner(System.in); //用于输出数据给服务器 PrintWriter printWriter = new PrintWriter(outputStream); while (true) { //1.从控制台获取数据 System.out.print(\"->\"); String request = scannerIn.next(); //2.将数据发送给服务器 printWriter.println(request); //刷新缓冲区 printWriter.flush(); //3.接收服务器响应 //判断服务端是否还有信息 if (!scanner.hasNext()) { break; } //接收服务端信息 String response = scanner.next(); //4.打印相关结果 System.out.println(response); } }catch (IOException e) { throw new RuntimeException(); } } public static void main(String[] args) throws IOException { TcpEchoClient client = new TcpEchoClient(\"127.0.0.1\",8080); client.start(); }}