前言
前面我们学习了使用 UDP 数据报实现套接字编程,因为 UDP 和 TCP 存在一些差异,所以在实现 TCP 流套接字编程的时候也会出现一些不同的做法,这篇文章我将为大家分享如何使用 TCP 流实现套接字编程。
TCP 和 UDP 的特点对比
要想实现 TCP 流的套接字编程就需要先知道 TCP 和 UDP 的区别。
TCP 是有连接的,UDP 是无连接的。
TCP 是可靠传输,UDP 是不可靠传输。
TCP 是面向字节流,UDP 是面向数据报。
TCP 和 UDP 都是全双工的。
这里实现 TCP 流套接字编程与 UDP 数据报套接字编程的区别则主要体现在 TCP 有连接,UDP 无连接;TCP 面向字节流,UDP 面向数据报。
TcpEchoServer 服务端实现
1. 创建 ServerSocket 类实现通信双方建立连接
要想实现 TCP 通信,首先需要通信双方建立连接。在 Java 中,TCP 双方建立连接依靠 ServerSocket 接口,ServerSocket 底层类似一个阻塞队列,当客户端和服务端建立连接之后,ServerSocket 会通过内核将这些建立好的连接给依次存储下来,当双方需要进行通信的时候服务器就会将这个连接从内核中取出来,然后进行通信。
public class TcpEchoServer { ServerSocket serverSocket = null; public TcpEchoServer(int port) throws IOException { //服务端需要指定端口号 serverSocket = new ServerSocket(port); } }
2. 取出建立的连接实现双方通信
通过 accept()
方法可以取出建立的连接进行双方的通信。
public void start() throws IOException { System.out.println("服务端启动"); while (true) { Socket clientSocket = serverSocket.accept(); //进行通信 processConnection(clientSocket); } }
3. 服务端业务逻辑实现
TCP 是面向字节流的传输,不像 UDP 依靠数据报传输,所以在这里需要用到前面文件操作的 InputStream
和 OutputStream
来实现请求的读取和响应的发送了。
public void processConnection(Socket clientSocket) { //提示客户端上线 System.out.printf("[%s %d] 客户端线\n",clientSocket.getInetAddress(), clientSocket.getPort()); try (InputStream inputStream = clientSocket.getInputStream(); OutputStream outputStream = clientSocket.getOutputStream()) { } catch (IOException e) { throw new RuntimeException(e); } }
读取字节数据的时候会显得比较麻烦,所以可以使用 Scanner
来简化读取过程。
scanner.next()
方法读取到空白符(空格、回车、制表符)结束,这正好与我们客户端输入请求的时候以空白符结束对应。
Scanner scanner = new Scanner(inputStream); while (true) { if (!scanner.hasNext()) { System.out.printf("[%s %d] 客户端下线",clientSocket.getInetAddress(), clientSocket.getPort()); break; } String request = scanner.next(); }
process(String request)
方法实现对请求数据的处理。
String response = process(request); public String process(String request) { return request; }
当服务端接收到 process() 方法返回的数据之后,就需要将处理的数据返回给服务端,因为同样是面向字节流,所以需要依靠 OutputStream 类来将数据传输给服务端,但是字节流操作较麻烦,所以可以使用 PrintWriter 类封装一下 OutputStream 类来简化操作。
PrintWriter printWriter = new PrintWriter(outputStream); printWriter.println(response); printWriter.flush(); //打印出服务端日志 System.out.printf("[%s %d] req=%s res=%s\n",clientSocket.getInetAddress(), clientSocket.getPort(),request,response);
关闭资源
在 TCP 流实现套接字编程的时候,如果不关闭资源的话就会出现资源泄露的问题,那么为什么会出现资源泄露呢?当服务器和新的客户端实现双方通信的时候就是调用 serverSocket.accept() 方法创建出新的 Socket 资源,而且这个 Socket 资源并不是一直贯穿服务端程序始终的,所以在这里就需要及时关闭资源,防止发生资源泄露的问题。
但是呢,我们不是使用了 try-with-resources 模型吗,当这个模型当中的代码执行完成了之后不是会自动调用里面的对象的 close 方法吗?是的,这里没问题,但是我们在这个模型中创建的是 InputStream 对象和 OutputStream 对象,程序结束的时候关闭的是流对象,而 Socket 对象并没有关闭,所以就还需要我们呢手动关闭这个 Socket 资源。
我们这里使用的是 finally 代码块来关闭资源,防止中间代码出现异常而导致资源没有成功关闭。
finally { clientSocket.close(); }
服务端整体代码
package netWork; 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; public class TcpEchoServer { ServerSocket serverSocket = null; public TcpEchoServer(int port) throws IOException { //服务端需要指定端口号 serverSocket = new ServerSocket(port); } public void start() throws IOException { System.out.println("服务端启动"); while (true) { Socket clientSocket = serverSocket.accept(); processConnection(clientSocket); } } public void processConnection(Socket clientSocket) throws IOException { System.out.printf("[%s %d] 客户端上线\n",clientSocket.getInetAddress(), clientSocket.getPort()); try (InputStream inputStream = clientSocket.getInputStream(); OutputStream outputStream = clientSocket.getOutputStream()) { Scanner scanner = new Scanner(inputStream); PrintWriter printWriter = new PrintWriter(outputStream); while (true) { if (!scanner.hasNext()) { System.out.printf("[%s %d] 客户端下线",clientSocket.getInetAddress(), clientSocket.getPort()); break; } String request = scanner.next(); String response = process(request); printWriter.println(response); printWriter.flush(); System.out.printf("[%s %d] req=%s res=%s\n",clientSocket.getInetAddress(), clientSocket.getPort(),request,response); } } catch (IOException e) { throw new RuntimeException(e); } finally { clientSocket.close(); } } public String process(String request) { return request; } public static void main(String[] args) throws IOException { TcpEchoServer tcpEchoServer = new TcpEchoServer( 9906); tcpEchoServer.start(); } }
TcpEchoClient 客户端实现
1. 创建出 Socket 对象来与服务端实现通信
客户端实现与服务端的通信依赖于 Socket
接口。
public class TcpEchoClient { Socket socket = null; public TcpEchoClient(String serverIp, int serverPort) throws IOException { socket = new Socket(serverIp,serverPort); } }
虽然 TCP 通信,双方会保存对方的信息,但是我们在客户端代码中还是需要指定出 目的 IP 和 目的端口。
2. 实现客户端的主要逻辑
提示用户输入请求数据。
public void run() { Scanner scanner = new Scanner(System.in); try (InputStream inputStream = socket.getInputStream(); OutputStream outputStream = socket.getOutputStream()) { Scanner netWorkScanner = new Scanner(inputStream); while (true) { String request = scanner.next(); } } catch (IOException e) { throw new RuntimeException(e); } }
将请求的数据通过 OutputStream
传输。
PrintWriter printWriter = new PrintWriter(outputStream); printWriter.println(request); printWriter.flush();
使用 InputStream
读取服务端传回的数据。
Scanner netWorkScanner = new Scanner(inputStream); String response = netWorkScanner.next(); System.out.println(response);
服务端整体代码
package netWork; 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 { // 需要在创建 Socket 的同时, 和服务器 "建立连接", 此时就得告诉 Socket 服务器在哪里~~ // 具体建立连接的细节, 不需要咱们代码手动干预. 是内核自动负责的. // 当我们 new 这个对象的时候, 操作系统内核, 就开始进行 三次握手 具体细节, 完成建立连接的过程了. socket = new Socket(serverIp, serverPort); } public void start() { // tcp 的客户端行为和 udp 的客户端差不多. // 都是: // 3. 从服务器读取响应. // 4. 把响应显示到界面上. Scanner scanner = new Scanner(System.in); try (InputStream inputStream = socket.getInputStream(); OutputStream outputStream = socket.getOutputStream()) { PrintWriter writer = new PrintWriter(outputStream); Scanner scannerNetwork = new Scanner(inputStream); while (true) { // 1. 从控制台读取用户输入的内容 System.out.print("-> "); String request = scanner.next(); // 2. 把字符串作为请求, 发送给服务器 // 这里使用 println, 是为了让请求后面带上换行. // 也就是和服务器读取请求, scanner.next 呼应 writer.println(request); writer.flush(); // 3. 读取服务器返回的响应. String response = scannerNetwork.next(); // 4. 在界面上显示内容了. System.out.println(response); } } catch (IOException e) { e.printStackTrace(); } } public static void main(String[] args) throws IOException { TcpEchoClient client = new TcpEchoClient("192.168.144.1", 9090); client.start(); } }
功能实现
多个客户端访问服务器
既然是网络编程,那肯定离不开多个客户端同时访问一个服务器的情况,如果我们要想使用多个客户端同时访问同一个服务器能行吗?
在 IDEA 中如何实现同一个代码两个进程呢?
当多个客户端同时访问这个服务器的时候,我们可以发现:这个功能并没有真正的实现。那么为什么这个代码不能实现多个客户端同时访问同一个服务器的情况呢?
通过观察服务器端的代码,可以发现:其实这里是有两个 while 循环的,第一个循环是用来取得和多个客户端的连接的,但是为什么这里没有取得和我们的第二个客户端的连接呢?这里第一个客户端在和服务端进行通信的时候,是处在第二个 while 循环当中的,并且这里第一个客户端并没有与服务器断开连接,也就是说,当前服务器还处于第二个 while 循环中等待第一个客户端发送请求,所以当第二个客户端想要和服务器进行通信的时候就不能到达 serverSocket.accept() 方法与服务器进行通信。那么如何解决这个问题呢?
这就需要用到我们前面学习的多线程的知识了,创建多个线程进行 processConnection() 方法,这样就可以使的另一个客户端想要和服务器进行通信的时候就可以在另一个线程中得到和服务器的连接
public void start() throws IOException { System.out.println("服务端启动"); while (true) { Socket clientSocket = serverSocket.accept(); Thread t = new Thread(() -> { try { processConnection(clientSocket); } catch (IOException e) { throw new RuntimeException(e); } }); t.start(); } }
但是这样频繁的创建和销毁线程也会消耗较多的资源,所以这里可以做出优化:使用线程池,虽然这里线程池可以优化,但是优化的不多。
public void start() throws IOException { System.out.println("服务端启动"); ExecutorService service = Executors.newCachedThreadPool(); while (true) { Socket clientSocket = serverSocket.accept(); service.submit(new Runnable() { @Override public void run() { try { processConnection(clientSocket); } catch (IOException e) { throw new RuntimeException(e); } } }); } }
其实这里处理多个客户端同一时间访问同一个服务器的最好方法就是 IO 多路复用 / IO 多路转接。
IO多路复用是一种同步IO模型,它允许在单个进程/线程内同时处理多个IO请求。具体来说,一个进程/线程可以监视多个文件句柄,一旦某个文件句柄就绪,就能够通知应用程序进行相应的读写操作。如果没有文件句柄就绪,应用程序将被阻塞,并交出CPU。
这种模型允许多个请求共享同一个进程/线程,使得在处理大量请求时,可以更有效地利用系统资源。如果每个请求都使用独立的进程/线程来处理,那么系统需要创建和管理大量的进程/线程,这将消耗大量的系统资源。而使用IO多路复用技术,可以复用一个或几个线程来处理多个TCP连接,从而大大减少了系统开销。
IO多路复用技术的出现主要是为了解决阻塞IO的问题。在操作系统中,最初只有BIO模式,即阻塞IO。当一个请求被处理时,如果该请求需要等待IO操作完成(例如读写操作),则该进程/线程将被阻塞,直到IO操作完成。这会导致系统资源的浪费,尤其是在需要处理大量请求的情况下。
IO多路复用技术的引入解决了这个问题。通过监视多个文件句柄,一个进程/线程可以在同一时间处理多个IO请求,从而提高了系统的效率。当一个文件句柄就绪时,该进程/线程可以立即进行相应的读写操作,而不需要等待其他请求完成。这种方式可以有效地减少系统资源的浪费,并提高系统的吞吐量。
IO多路复用技术被广泛应用在网络编程中,特别是服务器端编程。由于服务器需要同时处理来自大量客户端的请求,因此使用IO多路复用技术可以提高服务器的性能和响应速度。例如,Nginx服务器就使用了IO多路复用技术来处理大量的客户端连接。
这里具体怎么解决我这里及不过多叙述了,大家有兴趣可以去学习学习。
优化后的服务端代码
package netWork1; 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; public TcpEchoServer(int port) throws IOException { serverSocket = new ServerSocket(port); } public void start() throws IOException { System.out.println("服务器启动!"); ExecutorService service = Executors.newCachedThreadPool(); while (true) { // 通过 accept 方法, 把内核中已经建立好的连接拿到应用程序中. // 建立连接的细节流程都是内核自动完成的. 应用程序只需要 "捡现成" 的. Socket clientSocket = serverSocket.accept(); // 此处不应该直接调用 processConnection, 会导致服务器不能处理多个客户端. // 创建新的线程来调用更合理的做法. // 这种做法可行, 不够好 // Thread t = new Thread(() -> { // processConnection(clientSocket); // }); // t.start(); // 更好一点的办法, 是使用线程池. service.submit(new Runnable() { @Override public void run() { processConnection(clientSocket); } }); } } // 通过这个方法, 来处理当前的连接. public void processConnection(Socket clientSocket) { // 进入方法, 先打印一个日志, 表示当前有客户端连上了. System.out.printf("[%s:%d] 客户端上线!\n", clientSocket.getInetAddress(), clientSocket.getPort()); // 接下来进行数据的交互. try (InputStream inputStream = clientSocket.getInputStream(); OutputStream outputStream = clientSocket.getOutputStream()) { // 使用 try ( ) 方式, 避免后续用完了流对象, 忘记关闭. // 由于客户端发来的数据, 可能是 "多条数据", 针对多条数据, 就循环的处理. while (true) { Scanner scanner = new Scanner(inputStream); if (!scanner.hasNext()) { // 连接断开了. 此时循环就应该结束 System.out.printf("[%s:%d] 客户端下线!\n", clientSocket.getInetAddress(), clientSocket.getPort()); break; } // 1. 读取请求并解析. 此处就以 next 来作为读取请求的方式. next 的规则是, 读到 "空白符" 就返回. String request = scanner.next(); // 2. 根据请求, 计算响应. String response = process(request); // 3. 把响应写回到客户端. // 可以把 String 转成字节数组, 写入到 OutputStream // 也可以使用 PrintWriter 把 OutputStream 包裹一下, 来写入字符串. PrintWriter printWriter = new PrintWriter(outputStream); // 此处的 println 不是打印到控制台了, 而是写入到 outputStream 对应的流对象中, 也就是写入到 clientSocket 里面. // 自然这个数据也就通过网络发送出去了. (发给当前这个连接的另外一端) // 此处使用 println 带有 \n 也是为了后续 客户端这边 可以使用 scanner.next 来读取数据. printWriter.println(response); // 此处还要记得有个操作, 刷新缓冲区. 如果没有刷新操作, 可能数据仍然是在内存中, 没有被写入网卡. printWriter.flush(); // 4. 打印一下这次请求交互过程的内容 System.out.printf("[%s:%d] req=%s, resp=%s\n", clientSocket.getInetAddress(), clientSocket.getPort(), request, response); } } catch (IOException e) { e.printStackTrace(); } finally { try { // 在这个地方, 进行 clientSocket 的关闭. // processConnection 就是在处理一个连接. 这个方法执行完毕, 这个连接也就处理完了. clientSocket.close(); } catch (IOException e) { e.printStackTrace(); } } } public String process(String request) { // 此处也是写的回显服务器. 响应和请求是一样的. return request; } public static void main(String[] args) throws IOException { TcpEchoServer server = new TcpEchoServer(9906); server.start(); } }
根据回显服务器实现一个简单的字典功能
这个功能服务器的业务逻辑相比于简单的回显就稍复杂了一点,但是也没有复杂很多,只是需要更改一下服务端的 process
方法,在这个方法中使用 Map
存储几个单词的翻译就好了。
package netWork; import java.io.IOException; import java.util.HashMap; import java.util.Map; public class TcpDictServer extends TcpEchoServer { Map<String, String> dict = new HashMap<>(); public TcpDictServer(int port) throws IOException { super(port); dict.put("cat","小猫"); dict.put("dog","小狗"); dict.put("bird","小鸟"); dict.put("pig","小猪"); } @Override public String process(String request) { return dict.getOrDefault(request,"您查找的单词在该词典中不存在"); } public static void main(String[] args) throws IOException { TcpDictServer tcpDictServer = new TcpDictServer(9906); tcpDictServer.start(); } }