【网络编程】套接字

用户在浏览器中,打开在线视频网站,实质是通过网络,获取到网络上的⼀个视频资源。

与本地打开视频文件类似,只是视频文件这个资源的来源是网络。 相比本地资源来说,网络提供了更为丰富的网络资源

  • 视频资源
  • 图片资源
  • 文本资源
  • 音频资源
  • ..................

所谓的网络资源,其实就是在网络中可以获取的各种数据资源。而所有的网络资源 ,都是通过网络编程来进行数据传输的。

而 网络编程,是指网络上的主机,通过不同的进程,以编程的方式实现网络通信(或称为网络数据传输)。

当然,我们只要满足进程不同就行;所以即便是同⼀个主机,只要是不同进程,基于网络来传输数 据,也属于网络编程。 特殊的,对于开发来说,在条件有限的情况下,⼀般也都是在⼀个主机中运行多个进程来完成网络编程。

但是,我们⼀定要明确,我们的目的是提供网络上不同主机,基于网络来传输数据资源:

  • 进程A:编程来获取网络资源
  • 进程B:编程来提供网络资源

1.网络编程中的基本概念

1.1 发送端和接收端

在⼀次网络数据传输时:

  • 发送端:数据的发送方进程,称为发送端。发送端主机即⽹络通信中的源主机。
  • 接收端:数据的接收方进程,称为接收端。接收端主机即⽹络通信中的目的主机。
  • 收发端:发送端和接收端两端,也简称为收发端。

注意:发送端和接收端只是相对的,只是⼀次网络数据传输产生数据流向后的概念。

1.2 请求和响应

⼀般来说,获取⼀个网络资源,涉及到两次网络数据传输:

  • 第一次:请求数据的发送
  • 第二次:响应数据的发送

1.3 客户端和服务端

  • 服务端:在常见的网络数据传输场景下,把提供服务的⼀方进程,称为服务端,可以提供对外服务。
  • 客户端:获取服务的⼀方进程,称为客户端。

对于服务来说,⼀般是提供:

  • 客户端获取服务资源
  • 客户端保存资源在服务端

1.4 常见的客户端服务端模型

最常见的场景,客户端是指给用户使用的程序,服务端是提供用户服务的程序:

  1. 客户端先发送请求到服务端
  2. 服务端根据请求数据,执行相应的业务处理
  3. 服务端返回响应:发送业务处理结果
  4. 客户端根据响应数据,展示处理结果(展示获取的资源,或提示保存资源的处理结果)

2.Socket 套接字

Socket套接字,是由操作系统提供用于网络通信的技术,是基于TCP/IP协议的网络通信的基本操作单元。 基于Socket套接字的网络程序开发就是网络编程。

  • 操作系统提供了 Socket API 来让用户程序操作 Socket套接字 进行网络通信,最终操作网卡进行数据收发。
  • 但是,直接操作网卡很困难,而在计算机中,"文件"是一个广义概念,它还可以代指一些硬件设备(操作系统管理硬件设备,也抽象成文件,当作文件统一管理),也就是"一切皆文件"。
  • 因此,操作系统将网卡的网络通信能力抽象成了一个 socket 文件(它相当于网卡的遥控器)。此时在操作网卡的时候,流程就和操作普通文件很相似:会在文件描述符表中分配一个表项,遵循 "打开 → 读写 → 关闭" 的流程。
  • 然后 Java 又对这个 Socket API 进行了封装,程序员最终使用封装后的 Java Socket 类。

【操作系统把网卡的收发能力,通过"socket文件"这个界面暴露出来,读写这个"socket文件",就等于让网卡发数据/收数据。

Socket套接字 被抽象成 文件描述符(在文件描述符表中占一个表项),这个文件描述符可视为 socket 文件。】

总结:

  • 套接字是操作系统内核中一个用来表示"网络通信端点"的数据结构,它包含了通信所需的所有关键信息:IP地址、端口号、通信协议(TCP/UDP)、连接状态、收发缓冲区等,因此,套接字才能操作网卡进行收发数据。
  • 套接字是Java程序进行网络通信的入口。它把一个复杂的网络传输过程,巧妙地简化成了文件读写(输入/输出流)操作。创建一个套接字,获取它的流,然后像读写本地文件一样读写网络数据。

当然,Socket API 是工作在应用层的,是传输层(TCP/UDP)给应用层提供的API,即编程接口。

而传输层的两个核心协议 TCP和UDP,差别非常大,编写代码的时候,也是不同的风格,

  • TCP:有连接、可靠传输、面向字节流、全双工
  • UDP:无连接、不可靠传输、面向数据报、全双工

因此,Socket API 提供了两套 API。

  1. 关于 有连接/无连接 :抽象的概念,虚拟的/逻辑上的连接,通信双方彼此之间保存对方的信息的就是 有连接。
    1. 对于 TCP来说,在TCP协议中,就保存了对端的信息。例如,A和B通信,A和B先建立连接,让A保存B的信息,B保存A的信息,它们彼此之间互相知道谁和它们建立连接。
    2. 对于 UDP来说,UDP协议本身,不保存对端的信息,也就是无连接。但是可以在自己的代码中写变量保存对方的信息,但这不是UDP的行为。
  2. 关于 可靠传输/不可靠传输 :在网络上,数据是非常容易出现丢失的,即丢包。可靠传输的意思,不是保证数据包 100% 到达,而是尽可能的提高传输成功的概率,如果出现丢包了,能够感知到。而 不可靠传输,只是把数据包发了,之后就不管了。
  3. 关于 面向字节流/面向数据报 :面向字节流,就是读写数据时,以字节为单位;面向数据报,就是读写数据时,以一个数据报为单位(不是字符),即一次读写一个UDP数据报。
  4. 关于 全双工/半双工 :一个通信链路支持双向通信,即能读也能写,就是全双工;一个通信链路,只支持单向通信,即要么读,要么写,就是半双工。

3.UDP数据报套接字编程

3.1 API 介绍

1)DatagramSocket

DatagramSocket 是UDP Socket,用于发送和接收UDP数据报。

DatagramSocket 构造方法:

|--------------------------|-----------------------------------------------------------------------|
| 方法签名 | 方法说明 |
| DatagramSocket() | 创建⼀个UDP数据报套接字的Socket,绑定到本机任意⼀个随机端⼝(⼀般用户客户端),会抛出SocketException 异常 |
| DatagramSocket(int port) | 创建⼀个UDP数据报套接字的Socket,绑定到本机指 定的端⼝(⼀般用于服务端),会抛出SocketException 异常 |

DatagramSocket 方法:

|--------------------------------|-------------------------------------------------------------|
| 方法签名 | 方法说明 |
| void receive(DatagramPacket p) | 从此套接字接收数据报(如果没有接收到 数据报,该方法会阻塞等待),会抛出 IOException 异常 |
| void send(DatagramPacket p) | 从此套接字发送数据报包(不会阻塞等待,直接发送),会抛出 IOException 异常 |
| void close() | 关闭此数据报套接字 |

2)DatagramPacket

DatagramPacket是UDP Socket 发送和接收的数据报,即表示一个完整的UDP数据报。

DatagramPacket 构造方法:

|----------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------|
| 方法签名 | 方法说明 |
| DatagramPacket(byte[] buf, int length) | 构造⼀个DatagramPacket以用来接收 数据报,接收的数据保存在字节数组(第⼀个参数buf)中,即UDP数据报的载荷数据,接收指定长度(第⼆个参数length) |
| DatagramPacket(byte[] buf, int offset, int length,SocketAddress address) | 构造⼀个DatagramPacket以用来发送 数据报,发送的数据为字节数组(第⼀个参数buf)中,即UDP数据报的载荷数据,从0到指定长度(第⼆个参数length)。address指定目的主机的IP 和端⼝号 |

DatagramPacket 方法:

|--------------------------|--------------------------------------------|
| 方法签名 | 方法说明 |
| InetAddress getAddress() | 从接收的数据报中,获取发送端主机IP地址;或从发送的数据报中,获取接收端主机IP地址 |
| int getPort() | 从接收的数据报中,获取发送端主机的端⼝号;或从发送的数据报中,获取接收端主机端⼝号 |
| byte[] getData() | 获取数据报中的数据,即载荷数据 |

构造UDP发送的数据报时,需要传入SocketAddress ,该类是一个抽象类,对象可以使用它的子类 InetSocketAddress 来创建。

3)InetSocketAddress

InetSocketAddress ( SocketAddress 的子类)构造方法:

|----------------------------------------------|-------------------------|
| 方法签名 | 方法说明 |
| InetSocketAddress(InetAddress addr,int port) | 创建⼀个Socket地址,包含IP地址和端⼝号 |

3.2 代码示例

这里创建的是一个回显服务器,即客户端给服务器发一个数据(请求),服务器返回一个数据(响应),请求是什么,响应就是什么。

【真实的服务器,请求和响应是不一样的】

1)UdpEchoServer

创建一个回显服务器:

  • 首先需要一个socket对象,该对象就代表网卡文件,读这个文件等于从网卡接收数据,写这个文件等于让网卡发送数据。
    • 在构造方法中,使用 DatapromSocket 类创建出 UDP Socket 对象,并指定一个固定的端口号,让服务器使用。
  • 启动服务器 start(),对于服务器来说,客户端什么时候发送请求,发多少个请求无法预测,因此,服务器中通常都需要一个死循环 ,持续不断尝试读取客户端的请求数据(7*24小时)
    • 循环一次,相当于处理一次请求,处理请求的过程,典型的服务器都是分成三个步骤:
      1. 读取并解析数据
      • 构造 DatagramPacket 对象,即请求数据报 requestPacket,DatagramPacket 就代表 UDP数据包【报头+载荷(new字节数据保存)】
      • 调用 socket.receive(DatagramPacket p) 方法,读取/接收 请求数据报。这里 receive 的参数是一个输出型参数,调用之前先构造空的DatagramPacket对象,把该对象传递到 receive 里面之后,receive 就会把数据从网卡中读出来,填充到 参数中。
      • 读取到数据(二进制)之后,把UDP数据报的载荷取出来getData(),构造成一个 String,即转化成一个字符串 request,注意只取出实际读到的载荷数据,通过 getLength() 方法获取有效长度。【客户端发来数据是字符串,服务器为了理解它,必须进行还原】
      1. 根据请求,计算响应(服务器的关键逻辑)
      • 但是此处是回显服务器,这个环节相当于省略了
      • 直接返回上述转化成字符串后的请求数据,作为响应response 即可。
      1. 把响应返回给客户端
      • 根据response 构造DatagramPacket 对象,即响应数据报 responsePacket,发送给客户端,需要将字符串数据,又转换回二进制数据,依然只需要写入实际读到的二进制数据。
      • 当构造好要响应给客户端的UDP数据报后,不能直接 socket.send(DatagramPacket p) 发送,因为UDP协议自身没有保存对端信息,即不知道发送给谁,因此需要指定目的IP和目的端口号,因此,在构造 DatagramPacket 时,还需要传入 目的IP和目的端口号 的信息 【收到请求的源IP和源端口,就是要返回响应的 目的IP和目的端口】
      • 最后才将构造好的 响应数据报 send 发送出去,该数据报就包含了目的IP和端口
    • 最后可以记录一下这次客户端/服务器交互的过程,也就是记录日志。
java 复制代码
public class UdpEchoServer {
    private DatagramSocket socket = null;

    public UdpEchoServer(int port) throws SocketException{
        //指定一个固定端口号,让服务器使用
        socket = new DatagramSocket(port);
    }

    //启动服务器
    public void start() throws IOException{
        System.out.println("服务器启动");
        while(true) {
            //1.读取数据并解析
            //先构造一个空的请求数据报,然后再读取/接收 数据到数据报中
            DatagramPacket requestPacket = new DatagramPacket(new byte[4096],4096);
            socket.receive(requestPacket);
            //读取到数据之后,获取载荷数据,将数据转成字符串进行解析,只读取有效长度
            String request = new String(requestPacket.getData(),0,requestPacket.getLength());
            
            //2.根据请求数据报,计算响应
            String response = process(request);
    
            //3.返回响应给客户端
            //根据 response 构造出响应数据报,需要将字符串转回二进制数据,并说明目的IP和端口号
            DatagramPacket responsePacket = new DatagramPacket(response.getBytes(),
                response.getBytes().length,requestPacket.getSocketAddress());
            //然后再发送响应数据报给客户端
            socket.send(responsePacket);

            //4.打印一个日志信息
            System.out.printf("[%s:%d] request: %s, response: %s\n",requestPacket.getAddress().toString,
            requestPacket.getPort(),request,response);
        }
    }

    private void process(String request) {
        return request;
    }
}

此时启动该服务器:

当前的服务器启动了 ,但是启动之后,由于客户端还没有启动 ,因此也没有请求发送过来 ,此时 receive 方法就会触发阻塞行为,直到客户端发送请求过来,receive 才会返回。

2)UdpEchoClient

创建一个客户端:

  • 首先需要一个socket对象,该对象就代表网卡文件,读这个文件等于从网卡接收数据,写这个文件等于让网卡发送数据。
  • 由于 UDP本身不保存对端信息,因此,需要手动设置变量保存对端,即服务器的IP serverIP和端口号 serverPort。
  • 在构造方法中,和服务器不同,此处客户端的构造方法参数指定的是要访问的服务器的地址和端口号,使用 DatapromSocket 类创建出 UDP Socket 对象,是无参数的版本,不需要指定端口,客户端访问服务器,serverIP和serverPort是目的IP和目的端口,源IP是客户端所在主机,而源端口应该是随机的一个端口 (操作系统分配的空闲端口)。
  • 启动客户端 start():对于客户端来说,用户先要发送的内容可能不止一条,因此也需要一个死循环来让服务器持续响应客户端的请求,而在循环中:
    • 1.从控制台读取用户输入的内容,即要发送的内容 request,是文本,即字符串
    • 2.根据 request,构造一个请求数据报 DatagramPacket requestPacket,除了需要载荷request的内容,还需要指定服务器的IP和端口号,使用以下的构造方法:
    • 3.然后将请求数据报发送给服务器 socket.send(requestPacket)
    • 4.接着返回服务器的响应
      • 先构造出一个空的响应数据报 responsePacket,然后再接收服务器返回的响应 socket.receive(responsePacket)
    • 5.最后解析从服务器读取的响应数据,即将二进制数据转化为字符串 response,打印出来
java 复制代码
public class UdpEchoClient {
    private DatagramSocket socket = null;
    //UDP 本身不保存对端信息,手动设置变量保存    
    private String serverIP;
    private int serverPort;

    public UdpEchoClient(String serverIP.int serverPort) throws SocketException{
        this.serverIP = serverIP;
        this.serverPort = serverPort;
        socket = new DatagramSocket();
    }
    
    //启动客户端
    public void start() throws IOException{
        Scanner scanner = new Scanner(System.in);
        while(true) {
            //1.从控制台输入/读取用户的内容
            System.out.println("请输入发送的内容:");
            if(!scanner.hasNext()) {
                break;
            }
            String request = scanner.next();

            //2.构造请求数据报,除了需要载荷,还需要指定服务器IP和端口号
            DatagramPacket requestPacket = new DatagramPacket(request.getBytes(),
                request.getBytes().length,InetAddress.getByName(serverIP),serverPort);
            
            //3.发送请求数据报给服务器
            socket.send(requestPacket);

            //4.接收服务器返回的响应
            //先构造出一个空的响应数据报,然后接收响应
            DatagramPacket responsePacket = new DatagramPacket(new byte[4096],4096);
            socket.receive(responsePacket);

            //5.解析从服务器读取出来的响应数据报,即将二进制数据转成字符串,打印出来
            String response = new String(responsePacket.getData(),0,resopnsePacket.getLength());
            System.out.println(response);
        }
    }
}

运行 / 启动客户端,输入请求,发送给服务器,然后获取响应(要保证服务器也是启动的状态,即运行起来):

127.0.0.1 是一个环回地址,表示当前主机,无论主机的IP真实是什么,都可以使用该地址代替,相当于 this。9090就是之前设置的回显服务器的端口号。

当发送一个 "hello":

服务器响应后打印了一个日志:

客户端输入多条请求,服务器就响应几条请求:


问题1:socket 需不需要释放资源呢?

  • 要关注这个 socket 对象的生命周期是怎样的
  • 在上述示例中,这个 socket 是跟随整个进程,此时就不需要单独 close,进程退出,进程内部的 PCB 也会销毁,里面的文件描述符表,包含 socket 文件描述符,也就是释放了。
  • 如果 socket 的生命周期较短,也就是不随整个进程,用完之后不会再使用了,那么此时就要单独 close。

问题2:在自己的电脑上启动服务器,那么在其他人的电脑上启动客户端,能不能直接访问到我的电脑服务器?

  • 答案是不能,除非其他人带着电脑进入我所在的局域网或者我把程序部署到云服务器上。

3)UdpDictServer

一个服务器可以同时给多个客户端提供服务。而一个真实的服务器,通常要带有业务逻辑,要能解决实际问题,上述的例子中,是一个回显服务器 ,只是将用户输入的内容/请求 作为 响应又返回给用户。接下来我来写另一个服务器 ------ 翻译服务器(中译英),是在回显服务器的基础上的。

回显服务器中步骤二,计算响应这个关键逻辑,几乎忽略不计了,只是在 process() 方法中直接返回请求而已,而翻译服务器就是在回显服务器原有的基础上,对 这个步骤的 process() 方法里的逻辑进行修改,因此,翻译服务器UdpDictSever 可以继承 UdpEchoSever类实现

  • 翻译,这里是中译英,就是输入中文,返回英文,那么可以通过 HashMap 来保存中文以及它对应的英文,相当于词典。
  • 在该类中,其他逻辑一样,直接继承即可,只需要重写 process() 方法,记得将其改为 public 修饰的方法,private 修饰是不可被继承的。
java 复制代码
public class UdpDictServer extends UdpEchoServer {
    HashMap<String,String> dict = new HashMap<>();
    
    public UdpDictServer(int port) throws SocketException{
        super(port);

        //初始化哈希表/词典
        dict.put("小猫","cat");
        dict.put("小狗","dog");
        dict.put("小兔子","rabbit");
        dict.put("小鸭子","duck");
    }

    @Override
    public String process(String request) {
        return dict.getOrDefault(request,"未找到该词");
    }
}

运行翻译服务器:

同时运行起客户端服务器,输入要翻译的中文,服务器收到请求之后返回给客户端响应:

同时服务器这端也会打印日志信息:

4.TCP流套接字编程

关于 TCP 的 Socket,有两种,一种是专门给服务器使用的socket,即 ServerSocket类;一种是专门给服务器,或者服务器中与客户端建立的连接后返回的socket,即 Socket 类。

注意:TCP 并没有类似于 UDP 一样的 DatagramPacket 数据报类,因为 TCP 是面向字节流的,读写数据的基本单位就是 字节byte,而不是一个数据报,因此,TCP 连接的数据是一连串无消息边界的字节流,使用 InputStream 和 OutputStream 来进行读写,而不是像 UDP 那样用保留消息边界的 send 和 receive 来操作一个个完整的数据报。

4.1 API 介绍

1)ServerSocket

ServerSocket 是 TCP 中专门创建服务器的Socket API。

ServerSocket 构造方法:

|------------------------|---------------------------------------------------|
| 方法签名 | 方法说明 |
| ServerSocket(int port) | 创建⼀个服务端套接字Socket,并绑定到指定端⼝,会抛出 IOException 异常 |

ServerSocket 方法:

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

  • TCP 是有连接,这里的 accept 方法是联通连接的关键操作,即accept 是 服务器与客户端 建立 "通话" 的关键操作。
  • 调用 accept 后可以得到一个新的 socket,通过它能拿到对端(客户端)的地址信息(IP 和端口),即保存对端信息,之后的数据读写都在这个新 socket 上进行。
  • 通俗一点讲,就是客户端的 Socket 向服务器发起连接,相当于给服务器拨打了电话,而服务器调用accept后(得到的Socket 对象),就相当于拿起听筒接电话, 此时两端就正式开始通话,客户端发起的连接生效,服务器获取到了客户端的消息,如果客户端并没有发起连接,accept 就会一直阻塞等待,直到连接到来。
  • 通过上述,我们可以知道,在服务器中,其实 ServerSocket 就是负责招揽客户端的,accept 就是它确认是否有客户端发起连接的 "工具",当 accept 确认有客户端发起连接后,Socket 就全权负责 服务 这个客户端。即ServerSocket 负责等待和接纳,accept 负责取出,Socket 负责服务。

2)Socket

Socket 是用于客户端Socket,或服务端中接收到客户端建立连接(accept方法)的请求后,返回的服务端Socket。

不管是客户端还是服务端Socket,都是双方建立连接以后,保存的对端信息,及用来与对方收发数据的。

Socket 构造方法:

|------------------------------|-----------------------------------------------------------|
| 方法签名 | 方法说明 |
| Socket(String host,int port) | 创建⼀个客户端套接字Socket,并与对应IP的主机 上,对应端⼝的进程建立连接(即服务器的IP和端口) |

Socket 方法:

|--------------------------------|-------------------------------|
| 方法签名 | 方法说明 |
| InetAddress getInetAddress() | 返回套接字所连接的地址 |
| InputStream getInputStream() | 返回此套接字的输入流,会抛出 IOException 异常 |
| OutputStream getOutputStream() | 返回此套接字的输出流,会抛出 IOException 异常 |

4.2 代码示例

实现一个 TCP 版本的回显服务器。

1)TcpEchoServer

  • 大体逻辑一样,主要在启动服务器 start() 方法上的区别:
  • TCP 是面向连接的,因此,服务器需要首先"接通" 客户端发来的连接,即accept() ,获取到双方的信息,然后根据返回的 Socket 对象 clientSocket,与客户端进行通信服务。可能会有多个客户端发来连接,因此这里使用 while 循环。
  • 每次当客户端发来一个连接,就得对这一次的连接进行处理,而一个连接,可以包含多个请求,即长连接;也可以只有一个请求,即短连接 。就像打电话时,打通一次电话可以只说一句话就挂了,也可以说很多句话再挂。对一次连接的处理,使用 processConnection() 方法:
    • 不要忘记这里是使用字节流 clientSocket.getInputStream() 和 clientSocket.getOutputStream() 进行读写,但是这样直接读写有点麻烦,使用 read()/write() 读写请求后,还需要手动进行类型转化,字节数组 <------> 字符串,
    • 可以使用 Scanner 和 PrintWriter 这两个类来代替,这两个类其实是对 InputStream和OutputStream 进行了 套壳,本质上是为了把底层的字节流抽象成更方便的"文本行/单词"操作,避免直接和字节数组打交道。
    • 而 Scanner 和 PrintWriter 的构造方法,填入的其实是一个 InputStream对象 和 OutputStream 对象。
  • 回到处理一个连接这里,同样包含三个步骤:
  • 1.读取请求并解析:
    • 通过 scanner.next() 读取请求 request,根据 hasNext() 查看输入流里是否还能读出一个非空白的 token,如果能够(哪怕现在还没有,它也会阻塞等待,直到有数据到达或流结束),返回 true。如果已经彻底没有数据了(客户端关闭了连接,流到达末尾),返回 false。
  • 2.根据请求,计算响应:
    • 这里是回显服务器,因此 process() 方法直接将 request 返回给响应 response
  • 3.返回响应给客户端:
    • 通过 printWriter.println(response) 方法,返回响应给客户端。
  • 由于一个服务器可以给多个客户端服务,那么服务器就可能接收到多个连接,或者一个客户端发起了多次连接,因此上述的过程,使用 while 循环。
  • 最后每一次连接完成后,打印日志信息。
java 复制代码
public class TcpEchoServer {
    private ServerSocket serverSocket = null;

    public TcpEchoServer(int port) throws IOException{
        serverSocket = new TcpEchoServer(port);
    }

    //启动服务器
    public void start() throws IOException{
        System.out.println("服务器启动");
        while(true) {
             //接通客户端发来的连接
            Socker clientSocker = serverSocket.accept();
            //处理一个客户端的连接
            processConnection(clientSocket);
        }
    }
    private void processConnection(Socket clinetSocket) {
        System.out.println("[%s:%d] 客户端上线!\n",clientSocket.getInetAddress(),
            clientSocket.getPort());
        try(InputStream inputStream = clientSocket.getInputStream();
            OutputStream outputStream = clientSocket.getOutputStream()) {
            //针对 InputStream 套一层壳
            Scanner scanner = new Scanner(inputStream);
            //针对 OutputStream 套一层壳
            PrintWriter writer = new PrintWriter(outputStream);
            
            while(true) {
                //1.读取请求并解析
                if(!scanner.hasNext()) {
                    //连接断开
                    System.out.println("[%s:%d] 客户端下线!\n",clientSocket.getInetAddress(),clientSocket.getPort())
                    break;
                }
                String request = scanner.next();
                
                //2.根据请求计算响应
                String response = process(request);
                
                //3.返回响应给客户端
                writer.println(response);

                //打印日志
                System.out.printf("[%s:%d] request: %s, response: %s \n",
                    clientSocket.getInetAddress(),clientSocket.getPort(),request,response);
            }
        }catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
    private String process(String request) {
        return response;
    }
} 

运行服务器:

此时由于没有客户端向服务器发起连接,因此,服务器中的 accept 一直在阻塞等待连接的到来。

2)TcpEchoClient

客户端的逻辑大体也会UDP的客户端一致。以下是区别:

  • 由于TCP能够保存对端信息,因此,不需要自己创建变量去保存服务器的IP和端口。
  • 在构造方法中,创建Socket对象,直接将要发起连接的目标服务器的IP和端口作为Socket对象的参数,此时就会在底层与对端,即服务器建立 TCP连接,即客户端发起了连接,而服务器接通了(accept 方法等到了连接的到来),连接生效,双方都获取到了对端的信息。
  • 在启动客户端 start 方法中,
    • 首先需要从控制台输入/读取请求 request
    • 然后将 request 发送给服务器,使用 PrintWriter 的 println() 方法
    • 接着接收/读取服务器返回的响应 response,使用 Scanner 的 next() 方法
    • 最后将 response 打印到控制台上
java 复制代码
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("客户端启动");
        Scanner scanner = new Scanner(System.in);

        try(InputStream inputStream = socket.getInputStream();
            OutputStream outputStream = socket.getOutputStream()) {
            //为了方便使用,套壳
            Scanner read = new Scanner(inputStream);
            PrintWriter writer = new PrintWriter(outputStream);

            while(true) {
                //1.从控制台输入/读取请求
                String request = scanner.next();
                //2.发送请求到服务器
                writer.println(request);
                //3.接收服务器返回的响应
                String response = read.next();
                //4.打印到控制台
                System.out.println(response);
            }
        }catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
} 
  • 再次强调,以下客户端的socket对象,和服务器中的socket对象,绝对不是用一个对象(它们分别在不同的进程中,甚至在不同的主机上),这两个socket 可以理解成两部电话,从A 的话筒说话,B 的听筒就能听见,从B 的话筒说法,A 的听筒就能听见,A和B在进行一次通信,但是 A和B 绝对不是同一个对象。
  • ------客户端 socket 服务器socket

运行客户端:

此时服务器就收到了来自客户端的一个连接:

当关闭客户端,也就是断连,服务器也会有提示:

再次启动客户端,输入 "你好" 这个内容:发现服务器并没有响应结果给客户端

3)flush() 刷新缓冲区,真正发送数据给服务器

原因主要出在以下图中的这个操作上:

println() 这个操作只是把数据放到 "发送缓冲区中 "(是一块内存空间),并没有真正写入到网卡里,这样做 的目的是 攒批发送,提高网络 I/O 效率,因此,我们需要**flush()**方法来手动 "冲刷/刷新缓冲区",真正将数据发送出去。

在客户端和服务器上都加上 flush() 方法:

再次运行服务器和客户端,此时服务器就能够正常根据请求返回响应给客户端:

且服务器打印出了日志信息:

4)关于 println() 和 print() 方法:

  • 这两个方法的主要区别是 ------ 是否会自动加上 \n。

当我们在客户端和服务器中都使用 writer.print()方法:

运行起来发现,服务器并没有返回响应给客户端:

这个时候,客户端的数据确实已经发过去服务器那边了,服务器收到了但是却没有真正去处理它,原因出现在一下图中的代码:

scanner.hasNext() 方法,判定收到的数据中是否包含有 "空白符"(换行/回车/空格/制表符/翻页符......),如果遇到空白符,则认为是一个 "完整的next",在遇到之前,都会阻塞等待。而 println() 方法,就规定了一个请求/响应 使用 \n 作为结束标记,对端读的时候,也就是读到了 \n 就结束,认为已经读完了一个完整的请求;而 print() 方法并没有这样的规定。

5)close() 关闭流套接字

解决完上述的 flush 刷新问题后,并没有结束,还有问题,前面在讲UDP套接字的时候,说过 socket对象是否要关闭,主要看它的生命周期。

在 TCP 的服务器中,每一次客户端连接,都会创建出一个与它连接的socket对象 clientSocket,每个客户端断开连接,这个对象也就可以不要了,这个socket对象生命周期比较短,因此,我们可以在每处理好一次客户端连接后,将这个对象给关闭掉,等需要再用的时候再创建出一个新的。

通过 finally{} 代码块来实现:close() 方法会抛出 IOException 异常

6)服务器引入多线程

一个服务器不止可以给一个客户端提供给服务,一个服务器可以同时给多个客户端提供服务。

由于此时只有一台电脑,为了实现这一功能,设置客户端可以被多次运行,模拟服务器可以同时给多个客户提供服务:

此时有多个客户端向服务器发起了连接:

客户端1 向服务器发送请求,此时客户端1 能够正常收到响应:

如果是 客户端2 向服务器发起请求,此时发现,客户端2 并没有收到来自服务器的响应:

但是,如果我们将 客户端1 关闭,即断开 客户端1 与服务器的连接,此时的 客户端2 又可以收到来自服务器的响应了

原因:当客户端1 发起的连接到服务器这边生效后,服务器就会处理 客户端1 的请求,当 客户端1 的请求处理完成并成功得到响应后,此时客户端1 不再发请求过来,但是又没有断连,因此,服务器会一直在 hasNext() 这里阻塞等待,

当 客户端2 开始向服务器发起连接,由于服务器正在等待客户端1 发来请求,就没有办法同时去等 accept ,这个时候,有新的客户端连过来,也无法接通,也就是客户端2 无法上线成功。

解决上述问题,就需要引入 多线程,主线程负责进行 accept(),每次 accept 到一个客户端,就创建一个线程,由这个新的线程负责处理客户端的请求。

此时再次运行,客户端1 和 客户端2 就都能正常得到来自 服务器的响应结果:

7)服务器引入线程池

进一步优化:有客户端发来连接,就要创建新线程,客户端断开,又要将这个线程销毁,如果去频繁创建和销毁线程,开销会很大,因此,可以进一步引入线程池,省去创建和销毁线程的开销,直接从线程池中取线程来处理客户端的请求。

4.3 最终完整的代码

1)TcpEchoServer

java 复制代码
public class TcpEchoServer {
    private ServerSocket serverSocket = null;

    public TcpEchoServer(int port) throws IOException{
        serverSocket = new TcpEchoServer(port);
    }

    //启动服务器
    public void start() throws IOException{
        System.out.println("服务器启动");
        ExecutorService executorService = Executors.newCachedThreadPool();
        while(true) {
             //接通客户端发来的连接
            Socker clientSocker = serverSocket.accept();
            //使用线程池处理一个客户端的请求
            executorService.submit(()->{
               processConnection(clientSocket); 
            });
        }
    }
    private void processConnection(Socket clinetSocket) {
        System.out.println("[%s:%d] 客户端上线!\n",clientSocket.getInetAddress(),
            clientSocket.getPort());
        try(InputStream inputStream = clientSocket.getInputStream();
            OutputStream outputStream = clientSocket.getOutputStream()) {
            //针对 InputStream 套一层壳
            Scanner scanner = new Scanner(inputStream);
            //针对 OutputStream 套一层壳
            PrintWriter writer = new PrintWriter(outputStream);
            
            while(true) {
                //1.读取请求并解析
                if(!scanner.hasNext()) {
                    //连接断开
                    System.out.println("[%s:%d] 客户端下线!\n",clientSocket.getInetAddress(),clientSocket.getPort())
                    break;
                }
                String request = scanner.next();
                
                //2.根据请求计算响应
                String response = process(request);
                
                //3.返回响应给客户端
                writer.println(response);
                //刷新缓冲区,真正发送数据
                writer.flush();

                //打印日志
                System.out.printf("[%s:%d] request: %s, response: %s \n",
                    clientSocket.getInetAddress(),clientSocket.getPort(),request,response);
            }
        }catch (IOException e) {
            throw new RuntimeException(e);
        }finally {
            try {
                clientSocket.close();
            }catch(IOException e) {
                throw new RuntimeException(e);
            }
        }
    }
    private String process(String request) {
        return response;
    }
} 

2)TcpEchoClient

java 复制代码
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("客户端启动");
        Scanner scanner = new Scanner(System.in);

        try(InputStream inputStream = socket.getInputStream();
            OutputStream outputStream = socket.getOutputStream()) {
            //为了方便使用,套壳
            Scanner read = new Scanner(inputStream);
            PrintWriter writer = new PrintWriter(outputStream);

            while(true) {
                //1.从控制台输入/读取请求
                String request = scanner.next();
                //2.发送请求到服务器
                writer.println(request);
                //刷新缓冲区,真正发送请求
                writer.flush();
                //3.接收服务器返回的响应
                String response = read.next();
                //4.打印到控制台
                System.out.println(response);
            }
        }catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
} 
相关推荐
国科安芯1 小时前
AS32S601 抗辐射 MCU 在星载高速光通信链路的集成设计与性能验证
网络·单片机·嵌入式硬件·risc-v·安全性测试
wangl_921 小时前
Modbus RTU 与 Modbus TCP 深入指南-附录:快速参考表
网络·网络协议·tcp/ip·tcp·modbus·rtu
广州灵眸科技有限公司2 小时前
瑞芯微(EASY EAI)RV1126B openclaw部署接入飞书
linux·网络·人工智能·算法·yolo·飞书
idjoy2 小时前
网络原因导致gitee推送不上 提示没有权限或没有库
网络·gitee
骆驼10242 小时前
eNSP 与物理网络互通:从 ICS 到 Windows 路由转发的完整记录
网络·ensp·实验环境连通
笨笨饿2 小时前
80_聊聊SPI以及它们的变体
linux·c语言·网络·stm32·单片机·算法·个人开发
ITyunwei09873 小时前
数字化转型与遗留系统:如何为老旧的IT系统“减负“并注入新活力?
运维·网络·数据库
xhbh6663 小时前
从零实现Linux软路由:报文转发配置+静态路由+NAT实战
网络·端口转发·流量端口转发·ssh端口转发·端口转发工具
wangl_924 小时前
Modbus RTU 与 Modbus TCP 深入指南-决策树与选型建议
网络·网络协议·tcp/ip·tcp·modbus·rtu