【网络】TCP回显服务器和客户端的构造,以及相关bug解决方法

椰椰椰耶 2024-08-24 09:07:02 阅读 50

文章目录

ServerSocket构造方法方法

Socket构造方法方法

回显服务器(Echo Server)1. 构造方法2. 建立连接processConnection 方法的创建1. 读取请求并解析2. 根据请求计算响应3. 把响应写回给客户端

3. 完整代码

客户端(Echo Client)1. 构造方法2. 启动客户端3. 完整代码

服务器代码中的三个严重 bug1. 内存缓冲区2. 资源释放3. 多个客户端连接同一个服务器

不像 UDP 有 DatagramPacket 是专门的“UDP 数据报”,TCP 没有专门的“TCP 数据报”

因为 TCP 是面向字节流的,TCP 传输数据的基本单位就是 byteUDP 是面向数据报,UDP 这里需要定义专门的类,表示 UDP 数据报,作为 UDP 传输的基本单位TCP 这里在进行读数据或者写数据的时候,都是以字节或字节数组作为参数进行操作的

ServerSocket

专门给服务器使用的 <code>socket 对象

构造方法

方法签名 方法说明
ServerSocket(int port) 创建⼀个服务端流套接字 Socket,并绑定到指定端⼝ 创建⼀个服务端流套接字 Socket,并绑定到指定端⼝

方法

方法签名 方法说明
Socket accept() 开始监听指定端⼝(创建时绑定的端⼝),有客⼾端连接后,返回⼀个服务端 Socket 对象,并基于该 Socket 建⽴与客⼾端的连接,否则阻塞等待
void close() 关闭此套接字

TCP 是有连接的,有连接就需要有一个“建立连接”的过程

建立连接的过程就类似于打电话此处的 accept 就相当于接电话由于客户端是“主动发起”的一方,服务器是“被动接受”的一方,一定是客户端打电话,服务器接电话

Socket

既会给客户端使用,又会给服务器使用

构造方法

方法签名 方法说明
Socket(String host, int port) 创建⼀个客⼾端流套接字 Socket,并与对应 IP 的主机上,对应端⼝的进程建⽴连接

构造这个对象,就是和服务器“打电话”,建立连接

方法

方法签名 方法说明
InetAddress getInetAddress() 返回套接字所连接的地址
InputStream getInputStream() 返回此套接字的输⼊流
OutputStream getOutputStream() 返回此套接字的输出流

InputStreamOutputStream 称为“字节流”

前面针对文件操作的方法,针对此处的 TCP Socket 来说,也是完全适用的

回显服务器(Echo Server)

1. 构造方法

创建一个 Server Socket 对象,起到“遥控网卡”的作用

import java.io.IOException;

import java.net.ServerSocket;

public class TcpEchoServer {

private ServerSocket serverSocket= null;

public TcpEchoServer(int port) throws IOException {

serverSocket = new ServerSocket(port);

}

}

对于服务器这一端来说,需要在 socket 对象创建的时候,就指定一个端口号 port,作为构造方法的参数后续服务器开始运行之后,操作系统就会把端口号和该进程关联起来端口号的作用就是来区分进程的,一台主机上可能有很多个进程很多个程序,都要去操作网络。当我们收到数据的时候,哪个进程来处理,就需要通过端口号去区分

所以就需要在程序一启动的时候,就把这个程序关联哪个端口指明清楚


在调用这个构造方法的过程中,JVM 就会调用系统的 Socket API,完成“端口号-进程”之间的关联动作

这样的操作也叫“绑定端口号”(系统原生 API 名字就叫 bind)绑定好了端口号之后,就明确了端口号和进程之间的关联关系


对于一个系统来说,同一时刻,一个端口号只能被一个进程绑定;但是一个进程可以绑定多个端口号(通过创建多个 Socket 对象来完成)

因为端口号是用来区分进程,收到数据之后,明确说这个数据要给谁,如果一个端口号对应到多个进程,那么就难以起到区分的效果如果有多个进程,尝试绑定一个端口号,只有一个能绑定成功,后来的都会绑定失败

2. 建立连接

public void start() throws IOException {

while(true) {

//建立连接

Socket clientSocket = serverSocket.accept();

processConnection(clientSocket);

}

}

TCP 建立连接的流程,是操作系统内核完成的,我们的代码感知不到

accept 操作,是内核已经完成了连接建立的操作,然后才能够进行“接通电话”accept 相当于是针对内核中已经建立好的连接进行“确认”动作 由于 accept 的返回对象是 Socket,所以还需要创建一个 clientSocket 来接收返回值

clientSocketserverSocket 这两个都是 Socket,都是“网卡的遥控器”,都是用来操作网卡的。但是在 TCP 中,使用两个不同的 Socket 进行表示,他们的分工是不同的,作用是不同的

serverSocket 就相当于是卖房子的销售,负责在外面揽客clientSocket 相当于是售楼部里面的置业顾问,提供“一对一服务”

processConnection 方法的创建

针对一个连接,提供处理逻辑

先打印客户端信息然后创建一个 InputStream 对象用来读取数据,创建一个 OutputStream 对象随后,在 while 死循环中完成客户端针对请求的响应处理

private void processConnection(Socket clientSocket) {

//打印客户端信息

System.out.printf("[%s:%d] 客户端上线!\n",clientSocket.getInetAddress(),clientSocket.getPort());

try(InputStream inputStream = clientSocket.getInputStream();

OutputStream outputStream = clientSocket.getOutputStream()){

while(true) {

// 1. 读取请求并解析

// 2. 根据请求计算响应

// 3. 把响应写回给客户端

}

}catch (IOException e){

e.printStackTrace();

}

System.out.printf("[%s:%d] 客户端下线!",clientSocket.getInetAddress(),clientSocket.getPort());

}

因为 TCP 是全双工的通信,所以一个 Socket 对象,既可以读,也可以写因此就可以通过 clientSocket 对象拿出里面的 InputStreamOutputStream,我们就既能读,也能写了

1. 读取请求并解析

通过 inputStream.read() 读取请求,但如果直接这样读就不方便,读到的还是二进制数据

我们可以先使用 Scanner 包装一下 InputStream,这样就可以更方便地读取这里的请求数据了

//针对一个连接,提供处理逻辑

private void processConnection(Socket clientSocket) {

//打印客户端信息

System.out.printf("[%s:%d] 客户端上线!",clientSocket.getInetAddress(),clientSocket.getPort());

try(InputStream inputStream = clientSocket.getInputStream();

OutputStream outputStream = clientSocket.getOutputStream()){

Scanner scanner = new Scanner(inputStream);

//使用 Scanner 包装一下 InputStream,就可以更方便地读取这里的请求数据了

while(true) {

// 1. 读取请求并解析

if(!scanner.hasNext()){

//如果 scanner 无法读取数据,说明客户端关闭了连接,导致服务器这边读取到 “末尾”

break;

}

// 2. 根据请求计算响应

// 3. 把响应写回给客户端

}

}catch (IOException e){

e.printStackTrace();

}

System.out.printf("[%s:%d] 客户端下线!",clientSocket.getInetAddress(),clientSocket.getPort());

}

scanner 无法读取出数据时(scanner 没有下一个数据了),说明客户端关闭了连接,导致服务器这边读到了末尾,就进行 break

在这个判断的外面(try/catch 外面)加上日志,当数据读完后 break 了,就打印日志

2. 根据请求计算响应

由于是回显服务器,所以请求就是响应,process 就是直接 return request

//针对一个连接,提供处理逻辑

private void processConnection(Socket clientSocket) {

//打印客户端信息

System.out.printf("[%s:%d] 客户端上线!",clientSocket.getInetAddress(),clientSocket.getPort());

try(InputStream inputStream = clientSocket.getInputStream();

OutputStream outputStream = clientSocket.getOutputStream()){

Scanner scanner = new Scanner(inputStream);

//使用 Scanner 包装一下 InputStream,就可以更方便地读取这里的请求数据了

while(true) {

// 1. 读取请求并解析

if(!scanner.hasNext()){

//如果 scanner 无法读取数据,说明客户端关闭了连接,导致服务器这边读取到 “末尾”

break;

}

// 2. 根据请求计算响应

String response = process(request);

// 3. 把响应写回给客户端

}

}catch (IOException e){

e.printStackTrace();

}

System.out.printf("[%s:%d] 客户端下线!",clientSocket.getInetAddress(),clientSocket.getPort());

private String process(String request) {

return request;

}

}

这里的请求就是读取的 InputStream 里面的数据

3. 把响应写回给客户端

//针对一个连接,提供处理逻辑

private void processConnection(Socket clientSocket) {

//打印客户端信息

System.out.printf("[%s:%d] 客户端上线!",clientSocket.getInetAddress(),clientSocket.getPort());

try(InputStream inputStream = clientSocket.getInputStream();

OutputStream outputStream = clientSocket.getOutputStream()){

Scanner scanner = new Scanner(inputStream);

//使用 Scanner 包装一下 InputStream,就可以更方便地读取这里的请求数据了

PrintWrite printWriter = new PrintWriter(outputStream);

while(true) {

// 1. 读取请求并解析

Scanner scanner = new Scanner(inputStream);

if(!scanner.hasNext()){

//如果 scanner 无法读取数据,说明客户端关闭了连接,导致服务器这边读取到 “末尾”

break;

}

// 2. 根据请求计算响应

String response = process(request);

// 3. 把响应写回给客户端

printWriter.println(response);

}

}catch (IOException e){

e.printStackTrace();

}

System.out.printf("[%s:%d] 客户端下线!",clientSocket.getInetAddress(),clientSocket.getPort());

private String process(String request) {

return request;

}

}

此处写入响应的时候,会在末尾加上“\n

我们在刚才在使用 scanner 读取请求的时候,隐藏了一个条件——请求是以“空白符”(空格、回车、制表符、垂直制表符、翻页符…)结尾,否则就会在 next() 或者 hasNext() 那里发生阻塞,这样就没法读取到数据了因此此处约定,使用“\n”作为请求和响应的结尾标志 TCP 是字节流的,读写方式存在无数种可能,就需要有办法区分出,从哪里到哪里是一个完整的请求

此处就可以引入分隔符来区分

3. 完整代码

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 {

private ServerSocket serverSocket= null;

public TcpEchoServer(int port) throws IOException {

serverSocket = new ServerSocket(port);

}

public void start() throws IOException {

while(true) {

//建立连接

Socket clientSocket = serverSocket.accept();

processConnection(clientSocket);

}

}

//针对一个连接,提供处理逻辑

private void processConnection(Socket clientSocket) {

//打印客户端信息

System.out.printf("[%s:%d] 客户端上线!",clientSocket.getInetAddress(),clientSocket.getPort());

try(InputStream inputStream = clientSocket.getInputStream();

OutputStream outputStream = clientSocket.getOutputStream()){

Scanner scanner = new Scanner(inputStream);

PrintWriter printWriter = new PrintWriter(outputStream);

//使用 Scanner 包装一下 InputStream,就可以更方便地读取这里的请求数据了

while(true) {

// 1. 读取请求并解析

if(!scanner.hasNext()){

//如果 scanner 无法读取数据,说明客户端关闭了连接,导致服务器这边读取到 “末尾”

break;

}

String request = scanner.next();

// 2. 根据请求计算响应

String response = process(request);

// 3. 把响应写回给客户端

printWriter.println(response);

System.out.printf("[%s:%d] req=%s; resp=%s\n", clientSocket.getInetAddress(),clientSocket.getPort());

}

}catch (IOException e){

e.printStackTrace();

}

System.out.printf("[%s:%d] 客户端下线!",clientSocket.getInetAddress(),clientSocket.getPort());

}

private String process(String request) {

return request;

}

public static void main(String[] args) throws IOException {

TcpEchoServer server = new TcpEchoServer(9090);

server.start();

}

}

虽然把服务器代码编写的差不多了,但还存在三个非常严重的问题,都会导致严重的 bug

但需要结合后面客户端的代码进行分析

客户端(Echo Client)

1. 构造方法

首先创建一个 Socket 对象,来进行网络通信,再创建构造方法

import java.io.IOException;

import java.net.Socket;

public class TcpEchoClient {

private Socket socket = null;

public TcpEchoClient(String serverIp, int serverPort) throws IOException {

socket = new Socket(serverIp,serverPort);

}

}

写构造方法的时候,就不能使用无参数的版本了,需要在这里指定要访问的服务器的 IP 和端口号

这里可以直接填入一个 String 类型的 IP,不用像前面 UDP 那样还需要手动转换

2. 启动客户端

先拿出 socket 里面的 InputStreamOutputStream,再进行 while 循环使用 Scanner 包装一下 InputStream,这样就可以更方便地读取这里的请求数据了实例化一个 PrintWriter 对象,获取到 OutputStream,方便后续对数据进行打印创建一个 scannerIn 对象,用来读取从控制台输入的数据

public void start() {

System.out.println("客户端启动!");

try(InputStream inputStream = socket.getInputStream();

OutputStream outputStream = socket.getOutputStream()) {

Scanner scanner = new Scanner(inputStream);

Scanner scannerIn = new Scanner(System.in);

PrintWriter printWriter = new PrintWriter(outputStream);

while(true){

//1. 从控制台读取数据

System.out.println("-> ");

String request = scannerIn.next();

//2. 把请求发送给服务器

printWriter.println(request);

//3. 从服务器读取响应

if(!scanner.hasNext()){

break;

}

String response = scanner.next();

//4. 打印响应结果

System.out.println(response);

}

} catch (Exception e) {

throw new RuntimeException(e);

}

}

步骤上和 UDP 是非常相似的,只不过此处的 API 不一样前面的 UDP 不管发送也好,接收也罢,都是先去构造一个 DatagramPacket 再去操作,但是对于 TCP 来说,它是纯字节流的操作,就拿字节作为单位进行操作即可

这里为了操作方便,又给这个字节流套上了对应的字符流/工作类,之后再去进行读写,都会非常方便

3. 完整代码

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 = new Socket(serverIp,serverPort);

}

public void start() {

System.out.println("客户端启动!");

try(InputStream inputStream = socket.getInputStream();

OutputStream outputStream = socket.getOutputStream()) {

Scanner scanner = new Scanner(inputStream);

Scanner scannerIn = new Scanner(System.in);

PrintWriter printWriter = new PrintWriter(outputStream);

while(true){

//1. 从控制台读取数据

System.out.println("-> ");

String request = scannerIn.next();

//2. 把请求发送给服务器

printWriter.println(request);

//3. 从服务器读取响应

if(!scanner.hasNext()){

break;

}

String response = scanner.next();

//4. 打印响应结果

System.out.println(response);

}

} catch (Exception e) {

throw new RuntimeException(e);

}

}

public static void main(String[] args) throws IOException {

TcpEchoClient client = new TcpEchoClient("127.0.0.1",9090);

client.start();

}

}

服务器代码中的三个严重 bug

1. 内存缓冲区

客户端发送了数据之后,并没有任何响应

此处的情况是,客户端并没有真正的将数据发送出去,服务器没有收到,自然没有任何响应

//这是客户端中,将数据发送给服务器的代码

printWriter.println(request);

//这是服务器中,把响应写回给客户端的代码

printWriter.println(response);

PrintWriter 这样的类,以及很多 IO 流中的类,都是“自带缓冲区”的

进行文件/网络操作,都是 IO 操作,IO 操作本身是一种耗时比较多,开销比较大的操作。耗时比较多的操作频繁进行,就会影响程序执行效率,所以我们可以引入“缓冲区”,减少 IO 的次数,从而提高效率引入“缓冲区”之后,进行写入操作,不会立即触发 IO,而是先放到内存缓冲区中,等到缓冲区里攒了一波之后,再统一进行发送


此处可以引入 flush 操作,主动“刷新缓冲区”

flush 的原意为“冲刷”,类似于冲厕所


改为:

// 客户端

printWriter.println(request);

printWriter.flush();

// 服务器

printWriter.println(response);

printWriter.flush();

2. 资源释放

当前的服务器代码,针对 clientSocket 没有进行 close 操作

while(true) {

//建立连接

Socket clientSocket = serverSocket.accept();

processConnection(clientSocket);

}

ServerSocketDatagramPacket,它们的生命周期都是跟随整个进程的,和进程同生死,进程关了之后他俩对应的资源也释放了但此处的 clientSocket 并非如此,它是“连接级别”的数据,随着客户端断开连接了,这个 Socket 也就不再使用了,但资源是不释放的

即使是同一个客户端,断开之后,重新连接,也是一个新 Socket,和旧的 Socket 不是同一个了因此,这样的 Socket 就应该主动关闭掉,避免文件资源泄露


改后:

close 加到 finally 里面,把日志前移(不然释放之后日志就打印不出来了)

private void processConnection(Socket clientSocket) throws IOException {

try(InputStream inputStream = clientSocket.getInputStream();

OutputStream outputStream = clientSocket.getOutputStream()){

...

while(true) {

...

}

}catch (IOException e){

e.printStackTrace();

}finally {

System.out.printf("[%s:%d] 客户端下线!\n",clientSocket.getInetAddress(),clientSocket.getPort());

clientSocket.close();

}

}

GC 释放的是内存资源,此处讨论的“文件资源泄露”是针对文件描述符的

其实,流对象如果被 GC 回收了,也是会自动执行 close 的,但是由于 GC 过程是不可逆的(不知道 GC 什么时候发生,也不知到这次 GC 是否能释放掉你这个对象)一个对象可能不会很及时释放,在有些情况下,在还没来得及释放的时候,就导致这里的文件描述符就没了因此,我们写代码不能全指望这个东西,尤其是当前“高并发”服务器的背景下,短时间内就可能处理大量的客户端

3. 多个客户端连接同一个服务器

尝试使用多个客户端来同时连接服务器

作为一个服务器,就是要同时给多个客户端提供服务的

当第一个客户端连上服务器之后,服务器代码救护已进入 processConnect 内部的 while 循环,无法跳出此时第二个客户端尝试连接的时候,无法执行到第二次 accept所有第二个客户端发来的请求数据,都积压在操作系统的内核的接收缓冲区中

第一个客户端推出的时候,processConnect 的循环就结束了,于是外层的循环就可以执行 accept 了,也是就可以处理第二个客户端之前积压的请求数据了此处无法处理多个客户端,本质上是服务器代码结构存在问题采取了双重 while 循环的写法,导致进入里层 while 的时候,外层 while 就无法执行了解决办法就是:把双重 while 改成一重 while,分别进行执行——使用多线程

改后:

public void start() throws IOException {

while(true) {

//建立连接

Socket clientSocket = serverSocket.accept();

Thread t = new Thread(() -> {

try {

processConnection(clientSocket);

} catch (IOException e) {

throw new RuntimeException(e);

}

});

t.start();

}

}



声明

本文内容仅代表作者观点,或转载于其他网站,本站不以此文作为商业用途
如有涉及侵权,请联系本站进行删除
转载本站原创文章,请注明来源及作者。