网络原理(2)—— 网络编程套接字Socket

目录

一. 前言

二. TCP与UDP的对比

2.1 连接性

2.2 可靠性

2.3 面向字节流/数据报

2.4 全双工

三. UDP 的 API

3.1 DatagramSocket

3.1.1 构造方法

3.1.2 方法

3.2 DatagramPacket

3.2.1 构造方法

3.2.2 方法

3.3 回显服务器

1)服务器

2)客户端

 3)运行

4)整个过程的流程

四. TCP的API

4.1 ServerSocket 

4.1.1 构造方法

4.1.2 方法

4.2 Socket

4.2.1 构造方法

4.2.2 方法

4.3 回显服务器

1)服务器

2)客户端

3)运行与问题 

4)最终代码

五. 跨主机通信


一. 前言

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

  Socket 本身是"插槽"的意思(电脑主板插着各种硬件的口)。socket只是一个形象的比喻,因为网络通信需要一根线将设备连接在一起,通过这个线就可以通信了,因此这个术语就保留下来了。

   接下来要学习的是操作系统提供的 Socket API(Java版本),JDK把这些系统的相关操作都封装好了,系统原生API是C++要学的:比如 Linux操作系统,Java 只需要学习Linux基本操作,而C++除了要学习基本操作外,还要学习 “多线程编程” “多进程编程” “网络编程”,因为Java的API已经涵盖了“多线程编程”和“网络编程”,不需要学习系统原生的API。而C++直到最新的23标准,仍然不包含网络编程组件,只能学习原生API。但是不同操作系统的API是不一样的,C++学习了Linux的网络编程API,但无法进行 Windows的网络编程。Java的这一套 API 是可以跨平台的。(Java生态不鼓励“多进程编程”,虽然Java内部也提供了多进程相关的 API 封装,但一般很少用。)

   Socket API提供了两组不同的API:UDP一套,TCP一套(由于UDP和TCP的差别有点大,不因此,不得不分成两套不同API)

二. TCP与UDP的对比

  • TCP(传输控制协议):有连接,可靠传输,面向字节流,全双工
  • UDP(用户数据报协议):无连接,不可靠传输,面向数据报,全双工
2.1 连接性
  • TCP是面向连接的协议,在数据传输之前,需要先建立连接,数据传输完成后需要释放连接
  • UDP是无连接的,发送数据之前不需要建立连接,数据可以直接发送给接收方

   此处的连接是“抽象”的连接,可以认为是通信双方如果保存了通信对端的信息,就是“有连接”;如果不保存对端的信息,就是“无连接”。

   比如 结婚证一式两份,本上记录了两个人的名字/照片等信息,夫妻双方一人一份,此时两人就相当于建立起了“抽象/逻辑上的连接”。 而所说的网络上的连接,指通信双方A B分别保存记录对方的信息(主要是IP与端口号),此时就建立起了连接;如果通信双方各自把对方的信息删除掉,就相当于断开了连接。

   “对端” 是指网络通信中的另一端点或参与者,比如 网络连接的对端,当两个网络设备或网络应用程序通过互联网或其他网络进行通信时,每一方都可以被称为另一方的对端;在即时通讯软件中,两个用户之间的聊天会话中,每一方都是另一方的对端。

2.2 可靠性
  • TCP提供了可靠的服务。它确保数据包的顺序传输,并且通过重传机制保证数据的可靠性。
  • UDP不保证数据包的顺序,也不保证数据包的可靠性。如果发生数据丢失,UDP不会进行重传。

   此处谈到的可靠,不是百分之百到达对方,而是尽可能。因为网络的环境十分复杂,会存在很多的不确定因素(比如通信光纤受到破坏)。相对来说,不可靠就是指 完全不考虑数据是否能够到达对方。

   TCP内置了一些机制能够保证可靠传输:1)能感知对方是否收到了数据  2)有重传机制,在对方没收到的时候进行重试,提高传输成功的概率UDP没有可靠机制,完全不管发出去的数据是否顺利到达对方

   直观感觉可靠传输比不可靠传输更好,但不能一概而论。可靠传输要付出代价,会使TCP协议的设计比UDP复杂很多,同时也会降低一些传输数据的效率。

2.3 面向字节流/数据报
  • TCP是面向字节流的,传输过程的基本单位是字节,与文件流/水流是一样的特点。(具体"流"的解释可参考文件 文件操作 和 I/O流 这篇文章)
  • UDP是面向数据报的,传输过程的基本单位是UDP数据报。一次 发送/接收,必须 发送/接收 完整的UDP数据报(Datagram)。

上述不同就会直接影响到代码的写法。

2.4 全双工

   “全双工” 允许数据在通信通道上同时双向传输,即数据可以在同一时间内沿着两个相反的方向流动。这种模式类似于电话通话,通话的双方可以同时说话和听对方说话,而不会相互干扰。TCP与UDP均为全双工。(在物理层面上,并非只有一根线连接,假如一根网线里有8根铜线,分成两组 [44一组, 一组主要是工作的,另一组主要是备份的 ] ,一组的4根线中,会有一根用来发送,一根用来接收)

   “半双工” 允许数据在通信通道上双向传输,但是不能同时进行。在半双工模式下,设备可以在给定的时间内发送数据或者接收数据,但不能同时进行这两种操作。这就像一个单轨道铁路,火车只能在一个方向上行驶,当火车在轨道上行驶时,另一方向的火车必须等待。

   Java代码基本都是全双工,不必考虑半双工;但C++的“进程间通信”-->管道pipe,就是半双工的。

三. UDP 的 API

核心类:DatagramSocket类 和 DatagramPacket类

3.1 DatagramSocket

   DatagramSocket 代表一个 Socket 对象(操作系统的概念),相当于网卡设备的“遥控器”,可
以进行数据的发送和接收。(如果直接通过代码操作网卡,不好操作,因为网卡有很多种不同型号,它们之间提供的 API 都会有差别,因此操作系统就把网卡的概念封装成 Socket,应用程序员就不必关注硬件的差异和细节,统一操作 Socket对象 就能间接操作网卡,就像网卡遥控器一样)

   在操作系统的广义“文件”下,Socket 也可以认为是一种文件类型,这样的文件,就是“网卡”这种硬件设备抽象的表现形式。操作系统中的硬件设备有很多种(显卡、硬盘、打印机、控制台、键盘....),为了便于管理、简化代码编写过程,就会对具体硬件设备做进一步的抽象,因此引入了“文件”的概念,而 Socket 就是文件中的一种类型,特指网卡这个硬件设备。

   因此 Socket 的很多操作都与文件类似,比如 文件操作:打开->读写->关闭;同理,Socket操作:打开->读写->关闭;创建一个文件对象就会占用一个文件描述表里面的资源,同理,创建一个 Socket 对象也会占用一个文件描述表里面的资源。

3.1.1 构造方法
方法签名方法说明
DatagramSocket()创建一个UDP数据报套接字的Socket,绑定到本机任意一个随机端口(一般用于客户端)
DatagramSocket(int port)创建一个UDP数据报套接字的Socket,绑定到本机指定的端口(一般用于服务端)
3.1.2 方法
方法签名方法说明
void receive(DatagramPacket p)从此套接字接收数据报(如果没有接收到数据报,该方法会阻塞等待
void send(DatagramPacket p)从此套接字发送数据报包不会阻塞等待,直接发送)
void close()关闭此数据报套接字
3.2 DatagramPacket

   DatagramPacket 代表一个UDP数据报,就是UDP传输数据的基本单位。

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

   版本一需要指定 DatagramPacket 如何存储网络传输的数据,版本二需要传入SocketAddress(指定IP地址和端口号)。版本一是接收数据的时候使用,版本二是发送数据的时候使用。

3.2.2 方法
方法签名方法说明
InetAddress getAddress()从接收的数据报中,获取发送端主机的IP地址;或从发送的数据报中,获取接收端主机IP地址
int getport()从接收的数据报中,获取发送端主机的端口号;或从发送的数据报中,获取接收端主机端口号
byte[] getData获取数据报中的数据
3.3 回显服务器

   最简单的网络程序,都差不多要把上面的API用个遍,很难把API单独拎出来写一个示例,和
JDBC的情况是类似的。代码部分需要实现两个程序:1)UDP服务器:被动接收请求的一方  2)UDP客户端:主动发起通信的一方。

1)服务器

   编写一个最简单的客户端服务器程序,不涉及业务流程,只是对 API 的用法做演示,这样的服务器叫做“回显服务器(echo server)” —— 客户端发出什么样的请求,就返回什么样的相应,没有任何的业务逻辑,不做任何计算或处理。

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);
            // receive操作就会将从网卡收到的udp数据报 写到datagramPacket里面
            // (这个datagramPacket里面包含了收到的请求数据,以二进制字节数组体现,后续如果要进行打印之类的处理操作,最好转成字符串)
            String request = new String(requestPacket.getData(),0, requestPacket.getLength());

            // 2:根据请求计算响应,由于此处是一个回响服务器,响应就是请求
            String response = process(request);

            // 3:把响应协会到客户端
            DatagramPacket responsePacket = new DatagramPacket(response.getBytes(),response.getBytes().length,
                    requestPacket.getSocketAddress());
            //针对返回响应操作,不能使用空的数组来构造Packet对象
            socket.send(responsePacket);

            // 4:打印日志
            System.out.printf("[%s:%d] req = %s, resp = %s\n",requestPacket.getAddress(),requestPacket.getPort(),
                    request,response);

        }
    }
    protected String process(String request){
        return request;
    }

    public static void main(String[] args) throws IOException {
        UdpEchoServer server = new UdpEchoServer(9090);
        server.start();//当前只有服务端,无法收到数据,只能阻塞,无法看到效果
    }
}

   服务器需要在 Socket对象 创建的时候,指定一个端口号,作为构造方法的参数。当后续服务器开始运行之后,操作系统就会把端口号与该进程关联起来,用来区分进程。因为一台主机上要有很多的进程、很多程序操作网络,当收到数据时,就要通过端口号区分具体要将数据交给哪个进程处理。 在程序启动的时候,就要确定程序所关联的端口号,否则客户端后续想找到服务器就无法找到。(比如学校食堂新开了一个餐口,把店开起来的前提是必须要确定好开在哪个食堂【服务器的IP】、哪个档口【服务器的端口号】)

绑定端口号问题:

   在调用这个构造方法的过程中,jvm 就会调用系统的原生 Socket API,完成"端口号"和"进程"之间的关联动作 --> "绑定端口号”(“绑定"-->系统原生 API 的名字叫做bind)。

   对于一个系统来说,同一时刻,一个端口号只能被一个进程绑定(因为端口号是用来区分进程,明确收到的数据要交给谁),如果有多个进程尝试绑定同一个端口号,此时后来的进程就会绑定失败( 但是一个进程可以绑定多个端口号,通过创建多个Socket对象完成)

   此时就需要找到端口号是被谁绑定了,找到对应的进程,决定要删掉旧的进程,还是修改新进程的端口号(需要用到一些命令,Windows上,提供了netstat、netstat -ano、netstat -ano|findstr 【按照指定的信息进行过滤】 的命令,就可以显示出主机上的网络相关的信息;
Linux上,netstat 命令,字符串查找就是 netstat | grep)

   此时就能找到端口号为9090的一条UDP记录,这条记录就描述了当前绑定9090端口的进程信息,最后的数字32804就是绑定9090端口进程的PID(进程唯一标识),之后打开任务管理器,找到PID为32804的进程。

   但如果是在不同协议下,9090端口,在UDP下被一个进程绑定了,还可以在TCP下被另个进程绑定。


   服务器中包含一个死循环是很常见的情况,并不是bug。因为对于服务器这种 7*24小时 工作的情
况,存在死循环是非常常见的。对于一个服务器来说,主要的工作就是不断处理客户端发来的请求,由于客户端什么时候发来请求,服务器是无法预测的,因此服务器只能时刻准备好(随时有客户端的请求发来了,就要随时处理)


   receive 操作时从网卡上读取数据,但是调用receive的时候,网卡上不一定就有数据。如果网
卡上收到数据了,receive就立即返回,并且获取到收到的数据;如果网卡上没有收到数据,receive就会阻塞等待,一直等待到真正收到数据为止。

   此处 receive 也是通过输出型参数获取到网卡上收到的数据(首先需要构造一个DatagramPacket对象,将DatagramPacket对象作为参数传递给 receive,就能将获取到的数据存入到这个对象中)

   DatagramPacket 自身需要存储数据的,但是存储数据的空间多大,需要外部来定义(空间大
小能确保存储下通信的一个数据报即可,无固定要求),receive 操作就会将从网卡收到的UDP数据报,写到DatagramPacket里面(这个DatagramPacket里面包含了收到的请求数据,以二进制字节数组体现,后续如果要进行打印之类的处理操作,最好转成字符串

   构造一个String对象,是可以基于一个字节数组来构造,参数要指定字节数组起始位置(偏移量,默认指的是相对于数组开头的偏移位置,因此偏移量就与数组下标等价,即从数组的哪个位置开始构造 String)、长度。 


  • 请求(request):客户端主动给服务器发起的数据
  • 响应(response):服务器给客户端返回的数据

   String 可以通过字节数组来构造,也可以取出里面的字节数组。

注意:

   response.getBytes().length 与 response.length() 并不是一样的。

  • response.getBytes().length:先获取字节数组,再获取字节数组的长度,单位是“字节"
  • response.length():获取字符串中字符的个数,单位是“字符"

   很有可能字符数与字节数不同,如果字符串都是 英文字母 / 阿拉伯数字 / 英文标点符号,都是ASCII编码的,一个字符与一个字节是等长的;如果字符串中有中文,字节数与字符数是不同的。 

   UDP 的特点是"无连接"(所谓的连接就是通信对方保存对方的信息),换句话说, DatagramSocket 这个对象中,不持有对方(客户端)的IP和端口号,因此进行 send 时,就需要在 send 的数据报里,把要发给谁这样的信息写进去,才能够正确把数据进行返回。相比之下,后续TCP的代码中,不需要关心对端的IP和端口号,只管发数据即可。

   客户端IP与端口号,就包含在 requestPacket.getSocketAddress() 中,源码如下:

   因为创建一个Socket对象,就会在文件描述表占用空间,因此使用完后就要关闭。但在此处的代码中,Socket的生命周期是跟随整个进程的,进程结束了,Socket 就会被自动回收,释放文件描述表里面的所有内容,也就相当于close了。


2)客户端
public class UdpEchoClient {
    private DatagramSocket socket = null;

    private String serverIP;
    private int serverPort;

    public UdpEchoClient(String serverIP, int serverPort) throws SocketException {
        socket = new DatagramSocket();
        this.serverIP = serverIP;
        this.serverPort = serverPort;
    }
    
    public void start() throws IOException {
        System.out.println("客户端启动");
        Scanner scanner = new Scanner(System.in);

        while(true){
            // 1.从控制台读取到用户的输入
            System.out.print("->");
            String request = scanner.next();
            // 2.构造一个UDP请求,发送给服务器
            DatagramPacket requestPacket = new DatagramPacket(request.getBytes(), request.getBytes().length,
                    InetAddress.getByName(this.serverIP), this.serverPort);
            socket.send(requestPacket);
            // 3.从服务器读取响应
            DatagramPacket responsePacket = new DatagramPacket(new byte[4096], 4096);
            socket.receive(responsePacket);//receive也会发生阻塞,直到服务器有数据发送过来,返回响应
            //直接构造一个空白的DatagramPacket,然后调用receive方法,进行接收
            // 4.将响应显示到控制台
            String response = new String(responsePacket.getData(), 0, responsePacket.getLength());
            System.out.println(response);
        }
    }

    public static void main(String[] args) throws SocketException {
        UdpEchoClient client = new UdpEchoClient("127.0.0.1", 9090);
        try {
            client.start();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

注意:

   在服务器创建socket时,一定要指定端口号!

原因:服务器必须要明确自己的端口号,客户端主动发起数据的时候,才能找到服务器)


   在客户端创建Socket时,最好不要指定端口号!

原因:

1. 客户端是主动发起的一方,不需要让服务器来找他,客户端就不需要指定自己的端口号了(不指定不代表没有端口号,而是系统自动分配了一个端口号。因为一次通信过程中,需要源IP 源端口,目的IP 目的端口。当客户端给服务器发数据,此时源IP 源端口就是客户端,目的IP 目的端口就是服务器)
2. 如果在客户端指定了端口之后,由于客户端是在"用户"的电脑上运行的,无法知道用户电脑上,有哪些程序已经占用了哪些端口。万一代码指定的端口与用户电脑上运行的其他的程序的端口冲突了,就出bug了。因此,让系统自动分配一个端口,就能确保是分配一个无人使用的空闲端口

例子:

   学校二食堂的七号档口开了一家麻辣烫,有同学过来点餐,食堂阿姨就说,你先找地方坐一下,一会儿好了给你端过去。这个同学找的位置必然是一个“空闲”的位置,而且他这次来和下次来大概率是坐在不同位置上(可能上次位置有人了)
   同学“找的位置”就类似于系统自动分配的空闲端口号,不能指定固定的(当客户端被分配了端口号,服务器就要记录这个端口号);而“食堂档口号"就类似于服务器,必须要有一个固定端口号。

问题

   服务器指定固定端口就不怕与别的程序冲突吗?—— 不怕的!

原因:

   服务器程序运行在服务器上,服务器主机与用户电脑的区别是,服务器主机在程序员手里,当端口号发生冲突,程序员就可以进行修改,灵活操作。
   如果代码运行在服务器上,环境问题就非常好处理;如果代码运行在用户电脑上,环境问题就非常麻烦。


   此处是给服务器发送数据,发送数据的时候,UDP数据报里就需要带有目标IP和目标端口
号;接受数据的时候,构造的UDP数据报,就是一个空的数据报即可。

异常提示:此处DatagramPacket不提供这个版本的构造方法,问题主要出现在IP。因为一般使用的是整数IP,而此处是一个String类型的IP,因此要将这里的IP进行转换 --> 给serverlP包裹一个方法,根据字符串风格,将IP进行转换。


receive也会发生阻塞,直到服务器有数据发送过来,返回响应为止。


   此处的IP是一个特殊的IP,127.0.0.1 叫做 "环回IP"如果客户端与服务器在同一台主机上通信
时,就会用到这个IP(代表本机)


 3)运行

   此处的信息就是客户端给服务器发起请求,服务器处理的过程关键日志:127.0.0.1 是客户
端的IP,56865 是客户端的端口号(随机的)

4)整个过程的流程

1. 服务器一启动,就会在 receive 的地方进行阻塞,等待客户端发起请求。

2. 用户在客户端输入内容之后,就会真正执行下列发送请求的逻辑,执行完send发送完毕的。同时,客户端继续往下走,就会在receive处阻塞,等待服务器的响应。

3. 服务器收到了请求,从 receive 返回,继续往下走(此时客户端阻塞),构造 String,处理请求后,构造响应数据包,再 send 给客户端。

4. 客户端收到了服务器返回的响应之后,就会从receive这里解除阻塞,继续执行。

5. 服务器 send 完毕之后,就会打印日志,进入下一次循环,继续在receive 处阻塞;客户端打印完之后,也会进入下一次循环,就要继续从scanner中,读取用户输入的内容。

四. TCP的API

 核心类:ServerSocket类 和 Socket类。

SeverSocket类是专门给服务器使用的Socket,Socket类既会给客户端使用,也会给服务器使用。

问题

   此处为什么没有一个类表示TCP数据报呢?

原因:

   TCP是面向字节流的,TCP上传输数据的基本单位就是byte,并不需要额外定义,直接用byte数组即可;UDP是面向数据报的,因此需要定义专门的类,作为UDP传输的基本单位。因此TCP进行读数据写数据的时候,都是以字节作为参数进行展开。

4.1 ServerSocket 
4.1.1 构造方法
方法签名方法说明
ServerSocket (int port)创建一个服务端流套接字Socket,并绑定到指定端口

注:同一时刻,同一个协议下,一个端口号,只能被一个进程绑定,如果有多个进程绑定,就会报错 

4.1.2 方法
方法签名方法说明
Socket accept()开始监听指定端口(创建时绑定的端口),有客户端连接后,返回一个服务端Socket对象,并基于该Socket建立与客户端的连接,否则阻塞等待
void close()关闭此套接字
  • accept():TCP是有连接的,就需要有一个"建立过程”,建立连接的过程就类似于打电话,此处的accept就相当于接电话。由于客户端是主动发起的一方,服务器是被动接受的一方,一定是客户端"打电话”,服务器“接电话”,有几个客户端就需要接几次电话。
4.2 Socket
4.2.1 构造方法
方法签名方法说明
Socket(String host, int port)创建一个客户端流套接字Socket,并与对应IP的主机上,对应端口的进程建立连接

host --> IP,port --> 指端口号,构造Socket对象,就是与服务器“打电话”,建立连接的过程。 

4.2.2 方法
方法签名方法说明
InetAddress getlnetAddress()返回套接字所连接的地址
InputStream getlnputStream()返回此套接字的输入流
OutputStream getOutputStream()返回此套接字的输出流
  • InetAddress getlnetAddress():得到对端的信息(IP、端口)
  • InputStream 和 OutputStream 称为“字节流",而TCP本身就是面向字节流的,因此针对TCP进行的读写,就是基于 InputStream 和 OutputStream 展开的。前面针对文件操作的方法,此处针对 TCP Socket 也是完全适用的。
4.3 回显服务器

1)服务器
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("启动服务器");
        while(true){
            Socket clientSocket = serverSocket.accept();
            processConnection(clientSocket);
           
        }
    }

    private void processConnection(Socket clientSocket) throws IOException {
        // 打印以下客户端的信息
        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);
            PrintWriter printWriter = new PrintWriter(outputStream);
            while(true){
                //1.读取请求并解析
                if(!scanner.hasNext()){
                    //如果scanner中无法读取数据,说明客户端关闭了连接,导致服务器这边读取到“末尾”
                    break;
                }
                String request = scanner.next();
                //2.根据请求计算响应
                String response = process(request);
                //3.把响应写回给客户端
                //此处可以按照字节数组直接来写,也可以有另外一种写法
                //outputStream.write(response.getBytes());
                printWriter.println(response);

                //4.打印日志
                System.out.printf("[%s:%d] req = %s; resp = %s\n",clientSocket.getInetAddress(),clientSocket.getPort(),
                        request,response);
            }
        }catch(IOException e){
            e.printStackTrace();
        }
        System.out.printf("[%s:%d]客户端下线!\n",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();
    }
}

   TCP 建立连接的过程是操作系统内核完成的,代码感知不到。accept操作,是内核已经完成了连接建立的操作,然后才能进行"接通电话";相当于针对内核中,已经建立好的连接进行一个确认的动作,才能进行后续通信。 accept的返回值是一个Socket对象,此时程序中就有两个Socket对象。(都是“Socket”,都是“网卡的遥控器”,操作网卡,但是在TCP中,使用两个不同的 Socket进
行表示,分工是不同的,工作也是不同的。)

   每次服务器调用一次 accept,都会产生出一个新的Socket对象,来和客户端进行“一对一的服务”(类似于揽客与接客,买楼的销售,一个会负责揽客,将揽到的客人交给“置业顾问”,负责提供一对一的讲解服务,但他们本质都是“销售"的工作)


   TCP是一个全双工的通信,一个Socket对象,既可以读,也可以写。


   使用 PrintWriter 的 println 目的是在写入响应的时候,末尾能够自动加上“\n”(执行换行操作),因为Scanner读取数据的时候,隐藏了一个条件:请求应该是以“空白符”结尾(包括但不限于“回车符”“制表符”“空格”“翻页符”…...)因此就约定使用“\n”作为请求和响应的结尾,后续客户端也会使用scanner.next读取响应。

   TCP是字节流的,读写方式存在无数种可能,就需要有办法区分出从哪里到哪里是一个完整的
请求数据,此处就可以引入空白符进行区分。

2)客户端
public class TcpEchoClient {
    private Socket socket = null;
    private String serverIP;
    private int serverPort;
    public TcpEchoClient(String serverIP, int serverPort) throws Exception {
        // 此处Socket可以直接填写一个String风格的IP,不需要像UDP一样进行转换,因为java.net.Socket的构造函数已经做了处理
        socket = new Socket(serverIP, serverPort);
    }
    public void start() throws Exception {
        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.print("->");
                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){
            e.printStackTrace();
        }
    }

    public static void main(String[] args) throws Exception {
        TcpEchoClient tcpEchoClient = new TcpEchoClient("127.0.0.1",9090);
        tcpEchoClient.start();
    }
}

   这里写入IP和端口,意味着对象一旦 new 好 Socket对象,就会和服务器的连接建立完成;如果连接建立失败,就会直接在构造对象的时候抛出异常。

服务器的阻塞

  • 第一处阻塞:等待客户端与自己连接上 (new Socket),才会执行accept操作
  • 第二处阻塞:等待客户端发送数据(客户端进行println),在客户端发数据之前,hasNext不会返回,只会阻塞(next也会阻塞,如果不加hasNext),除非是主动告诉服务器不会再发消息,此时hasNext才会返回
3)运行与问题 

问题一: 客户端发送了数据之后,并没有任何响应。

   此处的情况是:客户端并没有真正把请求发送出去。为什么呢?—— 由于请求存放在PrintWriter的缓冲区里,每次的请求数据比较少,没有办法将请求发送出去,就一直停留在缓冲区了。 

   问题就出现在PrintWriter。因为像 PrintWriter 这样的类,以及很多IO流中的类,都是自带“缓冲区”,因为进行文件/网络操作,都是I/O操作,本身就是一个开销比较大,耗时比较多的操作。如果频繁进行I/O操作,就会很大影响程序的执行效率。引入缓冲区后,进行写入数据的操作,就不会立即触发I/O,而是先放到一个内存缓冲区中,等到缓冲区里的数据足够多,再统一进行发送,这样就能减少I/O操作的次数
   此时,我们可以引入一个 flush操作,主动“刷新缓冲区"的内容。

   上述这个问题,其实是一个很普遍的问题,不局限于Java,未来在开发中,经常会添加“日
志”,可能会遇到明明打印日志的函数已经执行到了,但是日志没有显示出来,这种情况很可
能就是缓冲区导致的问题。


问题二:当前服务器的代码,针对 clientSocket 没有进行 close操作 。

   每次循环都会执行 accept操作,即创建一个Socket对象。UDP的服务器中,像ServerSocket、DatagramSocket的生命周期是跟随整个进程的,因此此时可以不写close而这里的 clientSocket的“连接级别”数据,是随着客户端断开连接,这个Socket就不再使用了(同一个客户端断开之后,重新连接,也是一个新的Socket,与旧的Socket不是同一个)。因此,这样的Socket应该主动关闭掉,如果不关闭,就会造成文件资源泄漏。

   因此,在以后要注意一切需要关闭的资源(像 Socket、文件、释放锁等问题),都要即使处
理。

问题

   为什么使用完Scanner和PrintWriter,没有进行close呢?

   调用 close 最主要的目的是为了释放文件描述符。在Java网络编程中,Scanner 和 PrintWriter 作为高级流包装类,并不直接持有文件描述符,而是依赖于底层的Socket流,关闭Socket是释放文件描述符的关键操作。因此,在关闭Socket时,操作系统会释放与之关联的文件描述符,从而间接关闭了这些装饰流。尽管在上述代码中不显式关闭 Scanner 和 PrintWriter,不会导致资源泄露,但建议使用完毕后关闭所有流对象,有助于清晰管理资源并避免潜在问题。

   一个进程中,有三个特殊的流对象(特殊的文件描述符),不需要关闭!当进程一启动,操作系统就会自动打开这三个流对象,他们的生命周期是跟随整个进程的:

  • System.in --> 标准输入
  • System.out --> 标准输出
  • System.err --> 标准错误 

   标准输出和标准错误,都是显示在控制台上,看起来没什么区别(颜色差异只是idea染色)。其实标准输出,标准错误的内容,是支持重定向的,可以把这些输出的内容,重定向到文件中。如果采取重定向的话,就可以把标准输出(打印程序正常信息)和标准错误(打印程序异常信息)重定向到不同的文件中。


问题三: 此时的服务器在同一时刻,只能给一个服务器提供服务,只能停止上一个客户端,才能服务下一个客户端(这是不科学的!)

   当第一个客户端连上服务器之后,服务器代码就会进入 processConnection 内部的 while循环。此时,第二个客户端,尝试连接的时候,无法执行到第二次 accept,就无法给第二个客户端提供服务。而第二个客户端输入的请求,都积压在操作系统内核的接收缓冲区中,等到第一个客户端退出的时候,意味着processConnection内部的while 循环结束了,于是外层循环就可以执行accept,就可以处理第二个客户端之前积压的数据了。

   此处无法处理多个客户端,本质上是服务器代码结构存在的问题。由于采取了双重while循环
写法,就会导致进入里层 while循环 的时候,外层while循环无法继续执行了。

解决办法: 将“双重while”改成“一重while”,分别进行执行。 

如何实现呢? —— 多线程 

 

   这样的代码,属于是比较经典的一种服务器开发模型,给每个客户端分配一个线程来提供服务

   如果一旦短时间内有大量的客户端,并且每个客户端其实请求都是很快的,这时对于服务器来说,就会有比较大的压力。虽然创建线程比创建进程更轻量,但是也架不住短时间内,创建销毁大量的线程。此时,就可以选择使用“线程池”(应用于线程频繁创建和销毁的情况

   但是当有几百万个客户端连上了服务器,针对这种高并发场景,是以分布式(集群)方式来应对。

   进行网络服务器开发的时候,可以使用更少的线程,处理更多的客户端。 虽然刚才是一个线程服务于一个客户端,实际上,每个这样的线程,都可能会阻塞(客户端不是持续发送请求的)

   相比于处理请求的时间,大部分的时间可能都是在阻塞等待。如果可以让一个线程同时给多个客户端提供服务的话,就可以充分利用资源。
   针对这样的情况,就需要操作系统内部提供支持了。IO多路复用,也就是操作系统内核提供的功能(IO多路复用具体的实现方案有多种,最知名的就是Linux下的epoll

    “IO多路复用 是一种允许单个线程或进程同时监视多个文件描述符(通常是网络套接字)的状态变化(如可读、可写和异常)的技术。这种技术在网络编程中尤为重要,因为它可以有效地处理多个并发连接,而不需要为每个连接单独分配一个线程或进程。

    Linux中的 epoll 机制:在操作系统内核中,设计了一种数据结构,可以将多个Socket(每个Socket对应一个客户端)放到这个数据结构中。同一时刻,大部分的Socket都是处于阻塞等待(没有数据需要处理),少数收到数据的Socket,epoll机制就会通过回调函数的方式,通知应用程序,这里有数据了。应用程序,就可以使用少量的线程,针对这里“有数据”的Socket 进行处理即可。

   epoll 通常用于需要处理大量并发连接的网络服务器,如高性能的Web服务器、代理服务器、游戏服务器等,它通过优化文件描述符的管理事件通知机制,大大提高了网络编程的性能和效率。


  • 长连接   一种在通信完成后保持连接状态的连接方式,适用于需要频繁交换数据的场景。客户端连上服务器之后,一个连接中,会多次发起请求,接受多个响应(一个连接到底进行多少次请求是不确定的)。当前的 EchoClient 就属于这种模式
  • 短连接   一种每次通信完成后就会断开的连接方式。客户端连上服务器之后,一个连接,只发一个请求,接受一个响应,然后就断开连接了。(可能会频繁和服务器 建立 / 断开连接,建立/断开连接,也是有开销的)
4)最终代码
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){
            Socket clientSocket = serverSocket.accept();
            //使用线程池
            service.submit(()->{
                try{
                    processConnection(clientSocket);
                }catch(IOException e){
                    e.printStackTrace();
                }
            });
        }
    }

    private void processConnection(Socket clientSocket) throws IOException {
        // 打印以下客户端的信息
        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);
            PrintWriter printWriter = new PrintWriter(outputStream);
            while(true){
                //1.读取请求并解析
                if(!scanner.hasNext()){
                    //如果scanner中无法读取数据,说明客户端关闭了连接,导致服务器这边读取到“末尾”
                    break;
                }
                String request = scanner.next();
                //2.根据请求计算响应
                String response = process(request);
                //3.把响应写回给客户端
                //此处可以按照字节数组直接来写,也可以有另外一种写法
                //outputStream.write(response.getBytes());
                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{
            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(9090);
        server.start();
    }
}
public class TcpEchoClient {
    private Socket socket = null;
    private String serverIP;
    private int serverPort;
    public TcpEchoClient(String serverIP, int serverPort) throws Exception {
        // 此处Socket可以直接填写一个String风格的IP,不需要像UDP一样进行转换,因为java.net.Socket的构造函数已经做了处理
        socket = new Socket(serverIP, serverPort);
    }
    public void start() throws Exception {
        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.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 (Exception e){
            e.printStackTrace();
        }
    }

    public static void main(String[] args) throws Exception {
        TcpEchoClient tcpEchoClient = new TcpEchoClient("127.0.0.1",9090);
        tcpEchoClient.start();
    }
}

五. 跨主机通信

问题

   此处的通信是本机上的,如果有两个主机,能够跨主机通信吗?—— 能也不能

原因:

  • 不能:如果是独立的两台主机,是无法进行直接通信的;除非两个服务器都连接到同一个网络,无论是局域网(WiFi是无线局域网)、广域网、互联网,它们就能通过IP地址和端口号进行通信。(主要与IPv4协议有关)
  • :如果把我们的服务器代码,放到“云服务器”上,此时就是可以的。云服务器拥有公网IP,而我们自己的电脑没有公网IP。

   所谓的云服务器也是一个电脑,这个电脑是租来的电脑,通常租来的云服务器的硬件配置和性能,相比于咱们的台式机/笔记本弱很多,但是云服务器具备一点优势,带有公网IP(个人电脑很难获取到)。云服务器当然也有配置高的,但是价格较高。
   云服务器一般都是使用Linux作为操作系统,与Windows的差别是很大的,最直观的体现是:Linux是通过命令行来操作的,Windows是通过可视化界面操作的(Linux虽然也有图形界面,但是Linux的图形界面比Windows的界面落后很多)

跨主机通信步骤

1)先有一个云服务器(腾讯云、阿里云、华为云、京东云、ucloud…...哪个便宜买哪个)
2)  还要本地电脑上有一个连接服务器的终端软件,种类非常多,用哪个都行(Xshell7)
3)登录到服务器上(服务器IP,用户名,密码)
4)把自己写的服务器代码,打包成一个jar包(Java 编译生成的.class文件,把.class文件打成
特定结构的压缩包,就是jar包),上传到服务器上
5)在云服务器上运行服务器程序 jar包(java -jar 包名.jar)
6)运行客户端,连接服务器(修改 serverIP为云服务器的IP),便能进行客户端服务器的通讯

网络编程的意义

   打破空间的制约,只要能够连接上网络,就可以通过网络和服务器进行交互。掌握Scket编程之后,后续自己写的程序或项目,也都是服务器类的项目,就可以部署到云服务器上,同时也可以给别人使用。Socket 进行网络编程的基础API,后续实际工作中,更多时候使用的是封装层次更高的库/框架。


服务器的基本流程:

  1. 读取请求并解析
  2. 根据请求计算响应(服务器最重要的环节
  3. 把响应写回到客户端

客户端的基本流程:

  1. 从控制台读取数据 (一种简单的情况)
  2. 构造请求,发送给服务器
  3. 从服务器读取响应
  4. 显示结果到控制台 (一种简单的情况)

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值