网络编程套接字

✏️作者:银河罐头
系列专栏:JavaEE

“种一棵树最好的时间是十年前,其次是现在”

目录

  • Socket 套接字
  • UDP 和 TCP
  • UDP数据报套接字编程
    • DatagramSocket API
    • DatagramPacket API
    • UdpEchoServer
    • UdpEchoClient
    • UdpDictServer
    • 端口冲突
  • TCP流套接字编程
    • ServerSocket API
    • Socket API
    • TcpEchoServer
    • TcpEchoClient
    • 服务器同时连接多个客户端
    • Tcp 中的长短连接
    • C10M 问题

Socket 套接字

网络编程这里的核心,Socket API,操作系统给应用程序提供的网络编程 API。

可以认为 Socket API 是和传输层密切相关的。

传输层里提供了两个最核心的协议,UDP 和 TCP。因此 Socket API 也提供了两种风格,UDP 和 TCP

UDP 和 TCP

简单认识下 UDP 和 TCP

UDP :无连接 不可靠传输 面向数据报 全双工

TCP :有连接 可靠传输 面向字节流 全双工

  • 什么是有连接?什么是无连接?

打电话就是有连接的,需要建立连接才能通信,建立连接需要对方"接受"

发短信/微信就是无连接的,直接发就行了

  • 什么是可靠传输?什么是不可靠传输?

网络环境天然是复杂的,不可能保证传输的数据 100% 都能到达。发送方能知道自己的消息是不是发过去了,还是丢了。

打电话是可靠传输,发短信/微信 是不可靠传输(带有已读功能的是可靠传输,比如钉钉)

可靠不可靠和有没有连接没有任何关系

  • 面向字节流 && 面向数据报

面向字节流:数据传输就和文件读写类似,"流式"的

面向数据报:数据传输则以一个一个的"数据报"为基本单位(一个数据报可能是若干个字节,带有一定格式的)

  • 全双工

一个通信通道,可以双向传输(既可以发送,也可以接收)

网络编程套接字_第1张图片

为啥 TCP 和 UDP 都是全双工呢?

一根网线里有 8 根线

UDP数据报套接字编程

DatagramSocket API

DatagramSocket 使用这个类表示一个 Socket 对象。在操作系统中也是把这个对象当成是一个文件处理的,相当于是文件描述符表上的某一项。

普通的文件对应的硬件设备是硬盘;socket 文件对应的硬件设备是网卡。

一个 Socket 对象就可以和另外一台主机进行通信了,如果要和多个不同的主机进行通信,就要有多个 socket 对象。

  • DatagramSocket 构造方法:

DatagramSocket() 没有指定端口,系统则会自动分配一个空闲的端口
DatagramSocket(int port) 这个版本是要传入一个端口号,此时就是让当前的这个 socket 对象和指定的端口(简单的整数)关联起来。

端口号用来标识主机上不同的应用程序

本质上不是进程和端口建立联系,而是进程里的 socket 对象和端口建立了联系

  • DatagramSocket 方法:

DatagramPacket 表示 UDP 中传输的一个报文,构造这个对象可以指定一些具体的数据进去。

void receive(DatagramPacket p) 此时传入的相当于是一个空的对象,receive 方法内部会对这个空对象进行内容填充,从而构造出结果数据了。这里的参数也是一个"输出型参数"
void send(DatagramPacket p)
void close() 释放资源,用完之后记得关闭

DatagramPacket API

  • DatagramPacket 构造方法:

DatagramPacket(byte[] buf, int length) 把 buf 这个缓冲区给设置进去了

DatagramPacket(byte[] buf, int offset, int length, SocketAddress address) 构造缓冲区 + 地址

SocketAddress 使用这个类表示 IP + 地址

  • DatagramPacket 方法:

InetAddress getAddress()

int getPort()

byte[] getData()

UdpEchoServer

编写一个最简单的 UDP 版本的客户端服务器程序,称之为回显服务器(echo server)

一个普通的服务器:收到请求,根据请求计算响应(业务逻辑),返回响应;

echo server 省略了其中的"根据请求计算响应",请求是啥就返回啥(这个代码没有实际的业务,这个代码也没啥作用和意义,只是展示了 socket api 的基本用法)

作为一个真正的服务器,"根据请求计算响应"这个环节是最重要的

举个栗子:你去一家餐馆,点一份蛋炒饭,跟老板说"老板,我要一份蛋炒饭",老板把饭炒好以后,给你端上来一份蛋炒饭,其中制作蛋炒饭的过程是最困难的,你点餐和老板给你把饭端上来这两个动作都是简单的

//UDP 版本的回显服务器
public class UdpEchoServer {
    //网络编程,本质上是要操作网卡
    //但是网卡不方便直接操作,在操作系统内核中华,使用了一种特殊的叫做 "socket"这样的文件来操作网卡
    //因此进行网络通信,首先得要有一个 socket 对象
    private DatagramSocket socket = null;
    //对于服务器来说,创建 socket 对象的同时要给他绑定上一个具体的端口号
    //服务器一定要关联上一个具体的端口
    //因为在网络传输中,服务器是被动的一方,如果是操作系统随机分配的端口,那么客户端就不知道端口是啥了,也就无法进行通信了
    public UdpEchoServer(int port) throws SocketException {
        socket = new DatagramSocket(port);
    }
    public void start() throws IOException {
        System.out.println("服务器启动");
        //服务器不是只给一个客户端提供服务就完了,需要服务很多客户端
        while(true){
            //只要有客户端过来,就可以提供服务
            //1.读取客户端发来的请求是啥
            // receive 方法的参数是一个输出型参数,需要先构造好一个空白的 DatagramPacket 对象,发给 receive 进行填充
            DatagramPacket requestPacket = new DatagramPacket(new byte[4096],4096);
            socket.receive(requestPacket);
            //此时这个 DatagramPacket 是一个特殊的对象,并不方便直接进行处理,可以把这里包含的数据拿出来,构造成一个字符串
            String request = new String(requestPacket.getData(),0,requestPacket.getLength());
            //2.根据请求计算响应,由于这里是回显服务器,请求和响应相同
            String response = process(request);
            //3.把响应协会到客户端,send的参数也是一个 DatagramPacket,需要把这个 Packet 对象给构造好
            //此处这里的响应对象,不能是空的字节数组构造了,而是要使用响应数据来构造
            DatagramPacket responsePacket = new DatagramPacket(response.getBytes(),response.getBytes().length,requestPacket.getSocketAddress());
            socket.send(responsePacket);
            //4.打印一下,当前这次请求响应的处理中间结果
            System.out.printf("[%s:%d] req: %s;resp: %s\n",requestPacket.getAddress().toString(),requestPacket.getPort(),request,response);
        }
    }
    //这个方法就表示根据请求计算响应
    public String process(String request) {
        return request;
    }

    public static void main(String[] args) throws IOException {
        //端口号可以在 1024 ~ 65535 里任意指定一个
        UdpEchoServer server = new UdpEchoServer(9090);
        server.start();
    }
}

关于服务器一定要关联上一个具体的端口这一点

举个栗子:假设我在某学校三食堂 5 号窗口,租了个店面,卖肉夹馍。

此时的三食堂就相当于我的 IP 地址,5 号窗口相当于我的端口号。我开了个餐厅,相当于我搭起了个服务器。

如果我作为一个服务器,我是一个随机端口,会有啥效果?(如果不是固定端口)。每次服务器启动就是一个不同的端口了。

有顾客觉得好吃下一回再来这个5号窗口时,发现不卖肉夹馍了?!

因此作为服务器得固定端口,才方便客户端找到我

对于 UDP 来说,传输数据的基本单位,DatagramPacket

receive 内部会针对参数对象填充数据,填充的数据来自网卡。

网络编程套接字_第2张图片

网络编程套接字_第3张图片

服务器的工作流程:

1.读取请求并解析

2.根据请求计算响应

3.构造响应发给对应的客户端

UdpEchoClient

public class UdpEchoClient {
    private DatagramSocket socket = null;
    private String serverIP = null;
    private int serverPort = 0;
    //一次通信需要 2 个 IP ,2 个端口
    //客户端的 IP 是 127.0.0.1 已知的
    //客户端的端口是系统分配的
    //服务器的 IP 和端口需要告诉客户端,才能顺利把消息发给服务器
    public UdpEchoClient(String serverIP,int serverPort) throws SocketException {
        socket = new DatagramSocket();
        this.serverIP = serverIP;
        this.serverPort = serverPort;
    }
    public void start(){
        System.out.println("客户端启动");
        while(true){
            //1.从控制台读取要发送的数据
            System.out.print("> ");
            String request = scanner.next();
            if(request.equals("exit")){
                System.out.println("goodbye");
                break;
            }
            //2.构造成 UDP 请求并发送
            //构造这个 Packet 的时候,需要把 serverIP和 Port 都传入过来,但是此处的 IP 地址需要填写的是一个 32 位的整数形式,
            //上述 IP 地址是一个字符串,需要使用 InetAddress.getByName()来做一个转换
            DatagramPacket requestPacket = new DatagramPacket(request.getBytes(),request.getBytes().length,
                    InetAddress.getByName(serverIP),serverPort);
            socket.send(requestPacket);
            //3.读取 UDP 的响应并解析
            DatagramPacket responsePacket = new DatagramPacket(new byte[4096],4096);
            socket.receive(responsePacket);
            String response = new String(responsePacket.getData(),0,responsePacket.getLength());
            //4.把解析好的结果显示出来
            System.out.println(response);
        }
    }
    public static void main(String[] args) throws IOException {
        UdpEchoClient udpEchoClient = new UdpEchoClient("127.0.0.1",9090);
        udpEchoClient.start();
    }
}

构造这个 socket 对象,不需要显示的绑定一个端口,让操作系统自动分配一个端口

对于服务器来说,端口必须是确定好的;

对于客户端来说,端口可以是系统分配的。

服务器的端口是要固定指定的:目的是为了方便客户端找到服务器程序

客户端的端口是由系统随机分配的:如果手动指定,可能会和客户端其他程序的端口冲突(服务器上面的程序可控,客户端是运行在用户电脑上,环境更复杂,不可控)

127.0.0.1 => 32位的整数(给计算机看的)

​ => 点分十进制(给人看的)

​ 每个部分是 0 ~ 255 一个字节

服务器 - 客户端 交互执行过程:

1.一定是服务器先启动,服务器运行到 receive() 阻塞

2.客户端读取用户输入的内容

3.客户端发送请求

4.客户端阻塞等待响应过来;服务器收到请求,从阻塞中返回。

5.服务器根据请求计算响应

6.服务器发送响应

7.客户端从阻塞中返回,读到响应了

启动服务器,客户端

网络编程套接字_第4张图片

在 IDEA 上可以手动设置打开多个客户端,让服务器同时和多个客户端进行通信

网络编程套接字_第5张图片

网络编程套接字_第6张图片

每次点 运行 都是创建了一个客户端进程。

当前的服务器和客户端的程序,都是在自己的本机上跑的。而实际上网络存在的意义是跨主机通信。当前按这个程序可以做到跨主机通信。

举个栗子:如果张三(服务器)在北京,李四(客户端)在南京。李四想要和张三实现网络通信,不可行。因为张三的电脑没有外网IP,只能在局域网内部进行访问,除非李四到张三家里才可以和张三通信。

不过,李四可以连上"云服务器"(有外网 IP)这样的特殊的电脑,任何一个连上网络的设备都能访问。

UdpDictServer

回显服务器缺少业务逻辑,在上述代码的基础上稍作调整,实现一个"查词典"的服务器。(英文翻译成中文)

package network;
import java.io.IOException;
import java.net.SocketException;
import java.util.HashMap;
import java.util.Map;

//对于 DictServer 来说,和 EchoServer 相比,大部分都是一样的,
// 主要是"根据请求计算响应"这一步不太一样
public class UdpDictServer extends UdpEchoServer{
    private Map<String,String> dict = new HashMap<>();
    public UdpDictServer(int port) throws SocketException {
        super(port);
        //给这个 Dict 设置下内容
        dict.put("cat","小猫");
        dict.put("dog","小狗");
        //这里可以无限多的设置键值对...
    }
    @Override
    public String process(String request){
        //查词典的过程
        return dict.getOrDefault(request,"当前单词没有查到结果!");
    }

    public static void main(String[] args) throws IOException {
        UdpDictServer udpDictServer = new UdpDictServer(9090);
        udpDictServer.start();
    }
}

网络编程套接字_第7张图片

端口冲突

一个端口只能被一个进程使用,如果有多个使用就不行。

前面的UdpEchoServer 和 UdpDictServer 端口都是 9090

UdpEchoServer server = new UdpEchoServer(9090);
UdpDictServer udpDictServer = new UdpDictServer(9090);

此时如果同时运行 UdpEchoServer 和 UdpDictServer 就会抛异常

网络编程套接字_第8张图片

TCP流套接字编程

TCP 提供的 API 主要是 2 个类。

ServerSocket API

ServerSocket : 专门给服务器使用的 Socket 对象

  • ServerSocket 构造方法:

    ServerSocket(int port) :创建一个服务端流套接字Socket,并绑定到指定端口

  • ServerSocket 方法:

Socket accept() :开始监听指定端口(创建时绑定的端口),有客户端连接后,返回一个服务端Socket
对象,并基于该Socket建立与客户端的连接,否则阻塞等待。

TCP是有连接,此处的 accept() 相当于是"接电话"

Socket API

2)Socket : 既会给客户端使用,也会给服务器使用

注意,TCP不需要一个类来表示 “TCP 数据报”,因为 UDP 是以数据报为单位来传输的,而 TCP 是以字节为单位进行传输的

Socket 在服务器这边是由 accept() 返回的;

在客户端这边,代码里构造指定一个 IP 和端口号(此处的 IP 和端口是服务器的 IP 和端口),有了这个信息就能和服务器建立连接了。

  • Socket 方法:

InputStream getInputStream() :返回此套接字的输入流

OutputStream getOutputStream() :返回此套接字的输出流

进一步通过 Socket 对象获取到内部的流对象,通过流对象来发送和接收

之前的 文件操作是操作硬盘,这里是 操作网卡,读网卡,写网卡。

TcpEchoServer

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){
            //使用 clientSocket 和具体的客户端进行交流
            Socket clientSocket = serverSocket.accept();
            //效果是建立连接,前提是有客户端来连接
            //客户端在构造 Socket 对象的时候指定服务器的 IP 和 端口
            //如果没有客户端来连接,就会阻塞
            processConnection(clientSocket);
        }
    }

    //使用这个方法来处理一个连接
    //这一个连接对应一个客户端,但是这里面可能涉及到多次交互
    private void processConnection(Socket clientSocket) {
        System.out.printf("[%s:%d] 客户端上线\n",clientSocket.getInetAddress().toString(),
                clientSocket.getPort());
        //基于上述 socket 对象和客户端进行通信
        try(InputStream inputStream = clientSocket.getInputStream();
                OutputStream outputStream = clientSocket.getOutputStream()){
            //由于要处理多个请求和响应,此处用 while 循环
            while(true){
                //1.读取请求
                Scanner scanner = new Scanner(inputStream);
                if(!scanner.hasNext()){
                    //没有下个数据说明读完了,(客户端关闭了连接)
                    System.out.printf("[%s:%d] 客户端下线!\n",clientSocket.getInetAddress().toString(),clientSocket.getPort());
                    break;
                }
                //next一直读到 换行/空格/空白符 结束,最终返回结果里不包换上述空白符
                String request = scanner.next();
                //2.根据请求构造响应
                String response = process(request);
                //3.返回响应结果
                //OutputStream 没有 write String 这样的功能,可以把 String 里面的字节数组拿出来,进行写入
                //也可以用字符流来转换一下
                PrintWriter printWriter = new PrintWriter(outputStream);
                //此处用 println 来写入,让结果中带有一个 \n 换行,方便对端来进行接收解析
                printWriter.println(response);
                //flush 来刷新缓冲区,保证当前写入的数据,确实是发送出去了
                printWriter.flush();
                System.out.printf("[%s:%d] req:%s res:%s",clientSocket.getInetAddress().toString(),
                                  clientSocket.getPort(),request,response);
            }
        }catch(IOException e){
            e.printStackTrace();
        }finally {
            //更合适的做法,是把 close 放到 finally 里面,保证他一定能够执行到
            try {
                clientSocket.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
    private String process(String request) {
        return request;
    }
}

image-20230203162233053

任意一个 客户端连上来都会返回/创建一个 socket 对象,socket 就是文件,每次创建一个 clientSocket 对象,就要占用一个文件描述符表的位置,因此在使用完毕之后要释放。

前面的 UdpEchoServer 的 socket 没有手动释放,一方面是因为这些 socket 的生命周期更长(伴随整个程序),另一方面是这些 socket 不多,固定数量。

而此处的 clientSocket ,数量多,每个客户端有一个,生命周期更短。

TcpEchoClient

public class TcpEchoClient {
    private Socket socket = null;
    public TcpEchoClient(String serverIp,int serverPort) throws IOException {
        //socket 构造方法 能够识别 点分十进制 的 IP地址,比 DatagramPacket 更方便
        //new 这个对象的同时就会进行 TCP 连接操作
        socket = new Socket(serverIp,serverPort);

    }
    public void start(){
        Scanner scanner = new Scanner(System.in);
        try (InputStream inputStream = socket.getInputStream();
             OutputStream outputStream = socket.getOutputStream()){
            while(true){
                //1.先从键盘读取用户输入的内容
                System.out.print("> ");
                String request = scanner.next();
                if(request.equals("exit")){
                    System.out.println("goodbye");
                    break;
                }
                //2.把读到的内容构造成请求,发送给服务器
                PrintWriter printWriter = new PrintWriter(outputStream);
                printWriter.println(request);
                printWriter.flush();
                //3.读取服务器发来的响应
                Scanner respScanner = new Scanner(inputStream);
                String response = respScanner.next();
                //4.把响应内容打印到屏幕上
                System.out.println(response);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

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

网络编程套接字_第9张图片

网络编程套接字_第10张图片

服务器同时连接多个客户端

当前代码中还有一个重要的问题:当前的服务器,同一时刻只能处理一个连接(只能给一个客户端提供服务)

当启动服务器之后,启动客户端1可以看到正常的上线提示;再启动客户端2此时发现没有任何提醒了。

客户端1给服务器发送消息正常,而客户端2发送消息则没有任何提示。

(相当于打电话占线了)

当客户端1退出之后,客户端2就可以正常发消息了。

网络编程套接字_第11张图片

针对 TcpEchoServer 里的代码而言,当客户端连上服务器之后,代码就执行到了 processConnection 这个方法的循环中,此时意味着只要processConnection 这个方法的循环不结束,processConnection 这个方法就结束不了,从而无法第二次调用 accept().

那如果 processConnection 里面不用循环可行吗?

不行,虽然不使用循环,读取请求的时候,可能会阻塞,(next一直读到空白符才结束)

//next一直读到 换行/空格/空白符 结束,最终返回结果里不包换上述空白符
String request = scanner.next();

网络编程套接字_第12张图片

这里的解决办法是可以采用多线程。

每次收到一个连接,就创建一个新线程,由这个新线程负责处理这个新的客户端。每个线程都是独立的执行流,每个独立的执行流是各自执行各自的逻辑,彼此之间是并发的逻辑,不会发生这边阻塞影响到另一边的情形。

public class TcpEchoServer {
   public void start() throws IOException {
        System.out.println("服务器启动");
        while (true){
            //使用 clientSocket 和具体的客户端进行交流
            Socket clientSocket = serverSocket.accept();
            Thread t = new Thread(()->{
                processConnection(clientSocket);
            });
            t.start();
        }
    }
}

如果客户端特别多,很多客户端频繁的来建立连接,就需要频繁创建/销毁线程了。此时就可以用线程池来做进一步的优化。

public void start() throws IOException {
        System.out.println("服务器启动");
        //此处使用 cachedThreadPool,使用 FixedThreadPool不太合适(线程数量不太应该是固定的)
        ExecutorService threadPool = Executors.newCachedThreadPool();
        while (true){
            //使用 clientSocket 和具体的客户端进行交流
            Socket clientSocket = serverSocket.accept();
            
            //使用线程池
            threadPool.submit(()->{
                processConnection(clientSocket);
            });
        }
    }

Tcp 中的长短连接

TCP有连接的场景下,针对连接这个概念有两种典型的表现形式。

1)短连接:客户端每次给服务器发消息,先建立连接,发送请求,读取响应,关闭连接,下次再发送则重新建立连接

2)长连接:客户端,建立连接之后,连接先不着急断开,然后再发送请求读取响应,再发送请求读取响应,若干轮之后,客户端确实短时间之内不再需要使用这个连接了,此时再断开。

C10M 问题

上述 TcpEchoServer 虽然是使用了线程池了,但是还不够。

如果客户端非常多,而且客户端连接都迟迟不断开,就会导致咱们的机器上有很多线程,如果一个服务器有几千个客户端就得是几千个线程,有几万个客户端,几万个线程…

这个事情对于机器来说是个很大的负担。

多开服务器是能解决这个问题,但是多开服务器意味着成本的增加。

是否有办法解决单机支持更大量客户端的问题呢?C10M 问题

C10K 问题:单机处理 1 w 个客户端

C10M 问题:单机处理 1kw 个客户端(1kw不是具体数量,只是为了描述比C10K多很多)

针对上述多线程的版本,最大问题是机器承担不了这么大的线程开销。

是否有办法让 1 个线程处理多个客户端的连接?

IO多路复用(IO多路转接)

举个栗子:相当于一个人同时接 2 个电话,这2个电话传输过来的内容是有停顿的,IO过程中也会有等待,IO多路复用就是充分利用等待时间,做别的事。

比如生活中到饭点我去买饭,打算取买个饭再买杯奶茶再去取个快递,干这3件事,最省时间的做法是我先去买饭点好饭之后,利用这个等饭的时间取点杯奶茶,然后立马去取快递,这样做就充分利用了这个等待时间。

给这个线程安排个集合,这个集合就放了一堆连接,这个线程就负责监听这个集合,哪个连接有数据来了线程就来处理哪个连接。这个其实就应用了一个事实,虽然连接有很多,但是这些连接的请求并非严格意义的同时,总还是有间隔时间的。

在操作系统里,提供了一些原生 API:select , poll , epoll。在 Java 中,提供了一组 NIO 这样的类,就封装了上述多路复用的 API。

你可能感兴趣的:(JavaEE初阶,网络,udp,tcp/ip)