网络编程中的重难点:套接字的应用和理解

什么是网络编程

网络编程,指的是网络上的主机,通过不同的进程,以编程的方式实现网络通信(或成为网络数据传输)。网络编程中的重难点:套接字的应用和理解_第1张图片

发送端和接收端

在一次网络数据传输时:

发送端:数据的发送方进程,称为发送端。发送端主机即网络通信中的源主机。

接收端:数据的接收方进程,称为接收端。接收端主机即网络通信中的目的主机。

网络编程中的重难点:套接字的应用和理解_第2张图片

请求和响应

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

第一次:请求数据的发送

第二次:相应数据的发送

网络编程中的重难点:套接字的应用和理解_第3张图片

客户端和服务端

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

客户端获取服务的一方进程,称为客户端。

常见的客户端服务器模型:

1. 客户端先发送请求到服务端

2. 服务端根据请求数据,执行相应的业务处理

3. 服务端返回响应:发送业务处理结果

4. 客户端根据响应数据,展示处理结果(展示获取的资源,或提示保存资源的处理结果)

Socket套接字

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

并且我们认为Socket API是和传输层密切相关的。

Socket套接字主要针对传输层协议分为以下几类:

流套接字:使用传输层TCP协议

数据报套接字:使用传输层UDP协议

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

无连接、有连接:

打电话就是有连接的,需要连接建立了才能通信。连接建立需要对方来接收,如果连接没有建立好,就通信不了。

发短信、发微信就是无连接的。

不可靠传输、可靠传输:

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

面向字节流、面向数据报:

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

数据传输以一个个的“数据报”(可能是若干字节,带有一定格式的)为基本单位,就叫面向数据报。

全双工、半双工:

一个通信通道,可以双向传输,既可以发送也可以接收就叫做全双工。

只能单向传输的就叫做半双工。

UDP

Java中使用UDP协议通信,主要基于DatagramSocket类来创建数据报套接字,并使用DatagramPacket作为发送或接收的UDP数据报,对于一次发送及接收UDP数据报的流程如下:

网络编程中的重难点:套接字的应用和理解_第4张图片

 以上只是一次发送端的UDP数据报发送,及接收端的数据报接收,并没有返回的数据。也就只有请求,没有响应。对于一个服务器来说,重要的是提供多个客户端的请求处理及响应,流程如下:

网络编程中的重难点:套接字的应用和理解_第5张图片

TCP

网络编程中的重难点:套接字的应用和理解_第6张图片

Socket编程

首先先了解一些注意事项:

1.客户端和服务器:开发时,一般是基于一个主机开启两个进程作为客户端和服务器,但真实的场景一般都是不同主机。

2.注意目的IP和目的端口号,标识了一次数据传输时要发送数据的终点主机和进程。

3.Socket编程我们是使用流套接字和数据报套接字,基于传输层的TCP或UDP协议,但应用层协议, 也需要考虑,这块我们在后续来说明如何设计应用层协议。

4.关于端口被占用的问题:

如果一个进程A已经绑定了一个端口,再启动一个进程B绑定该端口,就会报错,这就叫端口被占用。对于Java进程来说,端口被占用的常见报错信息如下:

网络编程中的重难点:套接字的应用和理解_第7张图片

在cmd输入:netstat -ano | findstr 端口号 就可以显示对应进程的pid,然后在任务管理器中通过pid查找进程。

网络编程中的重难点:套接字的应用和理解_第8张图片解决方法:

如果占用端口的进程A不需要运行,就可以关闭A后,再启动需要绑定该端口的进程B。

如果需要运行A进程,则可以修改进程B的绑定端口,换为其他没有使用的端口。

UDP数据报套接字编程

DatagramSocket API

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

在操作系统中,把这个socket对象也当成是一个文件来处理的,相当于是文件描述符表上的一项。只不过普通文件对应的设备是硬盘,而socket文件对应的设备是网卡。

DatagramSocket构造方法:

网络编程中的重难点:套接字的应用和理解_第9张图片

 DatagramSocket 方法:网络编程中的重难点:套接字的应用和理解_第10张图片

DatagramPacket API

DatagramPacket是UDP Socket发送和接收的数据报

DatagramPacket 构造方法:

网络编程中的重难点:套接字的应用和理解_第11张图片 DatagramPacket 方法:

网络编程中的重难点:套接字的应用和理解_第12张图片

UDP客户端服务器程序

服务器代码

普通的服务器:收到请求,根据请求计算响应,返回响应。

而echo server(回显服务器)省略了其中的根据请求计算响应,请求是啥,就返回啥。

先来看一遍完整代码:

public class UdpServer {
    private DatagramSocket socket = null;
    public UdpServer(int port) throws SocketException {
        socket = new DatagramSocket(port);
    }

    public void start() throws IOException {
        while(true) {
            DatagramPacket requestPacket = new DatagramPacket(new byte[4096], 4096);
            socket.receive(requestPacket);
            String request = new String(requestPacket.getData(), 0, requestPacket.getLength());

            String response = process(request);
            DatagramPacket responsePacket = new DatagramPacket(response.getBytes(), response.getBytes().length, requestPacket.getSocketAddress());
            socket.send(responsePacket);
        }
    }

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

    public static void main(String[] args) throws IOException {
        UdpServer udpServer = new UdpServer(1000);
        udpServer.start();
    }
}

我们一点一点来解析:

    private DatagramSocket socket = null;
    public UdpServer(int port) throws SocketException {
        socket = new DatagramSocket(port);
    }

在操作系统内核中, 使用了一种特殊的叫做 "socket" 这样的文件来抽象表示网卡,因此进行网络通信, 势必需要先有一个 socket 对象。

同时对于服务器来说, 创建 socket 对象的同时, 要让他绑定上一个具体的端口号,如果是操作系统随机分配的端口, 此时客户端就不知道这个端口是啥了, 也就无法进行通信了。

    public void start() throws IOException {
        while(true) {
            DatagramPacket requestPacket = new DatagramPacket(new byte[4096], 4096);
            socket.receive(requestPacket);
    }

对于UDP来说,传输数据的基本单位是DatagramPacket,并且用一个while循环来表示循环接收请求,用DatagramPacket来表示接收到的,然后再用receive把这个数据报给网卡接收到。

String request = new String(requestPacket.getData(), 0, requestPacket.getLength());

 此时的DatagramPacket是一个特殊的对象,并不方便直接进行处理,可以把这里包含的数据拿出来,通过构造字符串的方式来存到request里面去。

之前给的最大长度是4096,但是这里的空间不一定用满了,可能只用了一小部分,因此就通过getLength获取到实际的数据报长度,只把这个实际的有效部分给构造成字符串即可。

    String response = process(request);


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

紧接着我们用一个process方法来表示服务器的响应。实际开发中这个部分是最重要的,服务器的响应是整个网络编程最核心的部分之一。

DatagramPacket responsePacket = new DatagramPacket(response.getBytes(),
 response.getBytes().length, requestPacket.getSocketAddress());

获取到客户端的ip和端口号(这两个信息本身就在requestpacket中)

socket.send(responsePacket);

通过send方法把responsePacket方法里面的信息传出去。

主要的工作流程:

1.读取请求并解析

2.根据请求计算相应

3.构造响应并且写回客户端

客户端代码

一次通信,需要有两个ip,两个端口,客户端的ip是127.0.0.1,客户端的端口是系统自动分配的,服务器ip和端口需要告诉客户端,才能顺利把消息发给服务器。

先来看完整代码:

public class UdpEchoClient {
    private DatagramSocket socket = null;
    private String serverIP = null;
    private int serverPort = 0;

    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){
            System.out.println(">");
            String request = scanner.next();
            if(request.equals("exit")){
                System.out.println("退出");
                break;
            }
            DatagramPacket requestPacket = new DatagramPacket(request.getBytes(),request.getBytes().length,InetAddress.getByName(serverIP),serverPort);
            socket.send(requestPacket);

            DatagramPacket responsePacket = new DatagramPacket(new byte[4096],4096);
            socket.receive(responsePacket);
            String response = new String(responsePacket.getData(),0,responsePacket.getLength());

            System.out.println(response);
        }
    }

    public static void main(String[] args) throws IOException {
        UdpEchoClient Client = new UdpEchoClient("127.0.0.1",9090);
        Client.start();
    }
}
    private DatagramSocket socket = null;
    private String serverIP = null;
    private int serverPort = 0;

    public UdpEchoClient(String serverIP, int serverPort) throws SocketException {
        socket = new DatagramSocket();
        this.serverIP = serverIP;
        this.serverPort = serverPort;
    }

通过socket,IP和端口我们才能和服务器端连接起来。

   public void start() throws IOException {
        System.out.println("客户端启动");
        Scanner scanner = new Scanner(System.in);

        while(true){
            System.out.println(">");
            String request = scanner.next();
            if(request.equals("exit")){
                System.out.println("退出");
                break;
            }
            DatagramPacket requestPacket = new DatagramPacket(
            request.getBytes(),
            request.getBytes().length,
            InetAddress.getByName(serverIP),
            serverPort);

            socket.send(requestPacket);

在客户端中,需要用户自己输入,获取到用户的request后,需要打包成requestPacket然后通过socket.send发送给服务器。

DatagramPacket responsePacket = new DatagramPacket(new byte[4096],4096);
socket.receive(responsePacket);
String response = new String(responsePacket.getData(),0,responsePacket.getLength());

System.out.println(response);

完成上一步后,等待服务器的响应,到客户端这边用receive接收,类型为responsePacket。最后再转换成String类型的response打印出来。

网络编程中的重难点:套接字的应用和理解_第13张图片

客户端发送给服务器后,就进入阻塞等待,这里的receive能阻塞,是因为操作系统原生提供的API就是阻塞的函数,这里的阻塞不是Java实现的,而是系统内核里实现的。

同时最后的main函数中,应该指定好ip和端口号,以便客户端能访问到服务器端。

网络编程中的重难点:套接字的应用和理解_第14张图片

 网络编程中的重难点:套接字的应用和理解_第15张图片

 同时也可以打开这个选项,同时开启多个客户端,共用一个服务器。

端口占用

针对上述的程序,来看看端口冲突是什么效果,一个端口只能被一个进程使用,如果有多个就不行。

网络编程中的重难点:套接字的应用和理解_第16张图片

  

TCP流套接字编程

TCP和UDP的差别还是有不少的,比如一个有连接一个无连接,一个是可以直接发送,一个需要数据报打包发送。

ServerSocket API

ServerSocket 是创建TCP服务端Socket的API。

构造方法:

方法:网络编程中的重难点:套接字的应用和理解_第17张图片

Socket API

Socket 是客户端Socket,或服务端中接收到客户端建立连接(accept方法)的请求后,返回的服务端Socket。 不管是客户端还是服务端Socket,都是双方建立连接以后,保存的对端信息,及用来与对方收发数据的。

构造方法:

 方法:网络编程中的重难点:套接字的应用和理解_第18张图片

TCP客户端服务器程序

服务器代码
public class TcpEchoServer {
    private ServerSocket serverSocket = null;

    private 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().toString(),clientSocket.getPort());
        try(InputStream inputStream = clientSocket.getInputStream();
            OutputStream outputStream = clientSocket.getOutputStream()){
            while(true){
                Scanner scanner = new Scanner(inputStream);
                if(!scanner.hasNext()){
                    System.out.printf("[%s:%d]客户端下线!", clientSocket.getInetAddress().toString(), clientSocket.getPort());
                    break;
                }
                String request = scanner.next();
                String response = process(request);
                PrintWriter printWriter = new PrintWriter(outputStream);
                printWriter.println((response));
                printWriter.flush();
                System.out.printf("[%s:%d] req: %s; resp: %s \n", clientSocket.getInetAddress().toString(), clientSocket.getPort(), request, response);
            }
        }catch(IOException e){
            e.printStackTrace();
        }finally{
            try{
                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(9090);
        server.start();
    }
}
    private ServerSocket serverSocket = null;

    private 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);
        }
    }

在这里有serverSocket和clientSocket,这两个socket是不同的,serverSocket接收端口号和Ip地址,然后通过clientSocket和客户端连接。因为需要连接上后才能发送消息,所以每用到一个clientSocket就会有一个客户端连接上来,都会返回/创建一个Socket对象,Socket就是文件,每次创建一个clientSocket对象,就要占用一个文件描述符表的位置。

因此这里的socket需要释放。前面的socket都没有释放,一方面这些socket生面周期更长,另一方面这些socket也不多。但是此处的clientSocket数量多,每个客户端都有一个,生命周期也更短。

accept如果没有连接到客户端,就会一直阻塞。

要注意,TCP server一次性只能处理一个客户端

    public void processConnection(Socket clientSocket) throws IOException {
        System.out.printf("[%s:%d] 客户端上线 \n",clientSocket.getInetAddress().toString(),clientSocket.getPort());
        try(InputStream inputStream = clientSocket.getInputStream();
            OutputStream outputStream = clientSocket.getOutputStream()){

通过clientSocket进行processConnection进行了具体的连接以后,通过try with resources来完成InputStream和outputStream来完成字节流的传输。

            while(true){
                Scanner scanner = new Scanner(inputStream);
                if(!scanner.hasNext()){
                    System.out.printf("[%s:%d]客户端下线!", clientSocket.getInetAddress().toString(), clientSocket.getPort());
                    break;
                }
                String request = scanner.next();
                String response = process(request);
                PrintWriter printWriter = new PrintWriter(outputStream);
                printWriter.println((response));
                printWriter.flush();
                System.out.printf("[%s:%d] req: %s; resp: %s \n", clientSocket.getInetAddress().toString(), clientSocket.getPort(), request, response);
            }
        }catch(IOException e){
            e.printStackTrace();
        }finally{
            try{
                clientSocket.close();
            }catch (IOException e){
                e.printStackTrace();
            }
        }
    }

通过InputStream接收到服务器端的数据后,再通过scanner写入到request,request传入到process方法返回服务器相应的数据。接下来应该用outputStream来写入服务器返回的数据,但是outputStream中并没有write String这样的功能,所以此处用println来写入。

并且println中会在发送的数据后面自动带上\n换行,TCP协议是面向字节流的协议,但是接收方如何知道这一次一共需要读多少字节呢?这就需要我们再数据传输中进行明确的规定:

此处代码中,隐式约定使用了\n来作为当前代码的请求、相应分割约定。

所以这里的println也可以当做是服务器发送给客户端的发送行为。

客户端代码
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()){
            while(true){
                System.out.println(">");
                String request = scanner.next();
                if(request.equals("exit")){
                    System.out.println("bye");
                    break;
                }
                PrintWriter printWriter = new PrintWriter(outputStream);
                printWriter.println(request);
                printWriter.flush();

                Scanner responseScanner = new Scanner(inputStream);
                String response = responseScanner.next();

                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();
    }
}
public class TcpEchoClient {
    private Socket socket = null;

    public TcpEchoClient(String serverIp,int serverPort) throws IOException {
        socket = new Socket(serverIp,serverPort);
    }

通过socket来接收服务器的ip和端口号。

    public void start(){
        System.out.println("客户端启动");
        Scanner scanner = new Scanner(System.in);
        try(InputStream inputStream = socket.getInputStream();
            OutputStream outputStream = socket.getOutputStream()){

和服务器不同的是,客户端方需要读取用户自己输入的数据,所以通过System.in来接收用户输入的,但是最终是需要用到流式传输中,所以需要用try with resources来包含InputStream和outputStream。

        try(InputStream inputStream = socket.getInputStream();
            OutputStream outputStream = socket.getOutputStream()){
            while(true){
                System.out.println(">");
                String request = scanner.next();
                if(request.equals("exit")){
                    System.out.println("bye");
                    break;
                }
                PrintWriter printWriter = new PrintWriter(outputStream);
                printWriter.println(request);
                printWriter.flush();

                Scanner responseScanner = new Scanner(inputStream);
                String response = responseScanner.next();

                System.out.println(response);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

通过request接收到用户输入的数据后,用PrintWriter来写入,再通过println来发送。并且以防万一,我们用flush来刷新缓冲区避免数据传输失败。

等待服务器返回消息后,用responseScanner来接收InputStream传输的数据,再打印出来。

 网络编程中的重难点:套接字的应用和理解_第19张图片

 多线程、线程池连接

当前咱们的服务器同一时刻只能给一个客户端提供服务,这是不科学的。当前启动服务器后,先后启动两个客户端,客户端1可以看到正常的上线提示,但是客户端2没有任何提醒。当结束客户端1后,客户端2马上显示上线。

当客户端连接上服务器之后,代码执行到processConnection这个方法中的while循环了,此时意味着,只要这个循环不结束,processConnection方法就结束不了。进一步的也就无法调用到第二次的accept。

解决办法就是:使用多线程

    public void start() throws IOException {
        System.out.println("启动服务器");
        while(true){
            Socket clientSocket = serverSocket.accept();
            Thread t = new Thread(() ->{
                processConnection(clientSocket);
            });
            t.start();
        }
    }

其实修改的部分很小,只要在启动连接的时候,作为一个单独的线程启动就大功告成。

但是呢,这里的多线程版本的程序,最大的问题就是可能会涉及到频繁申请释放线程,当客户端数量足够多,也会造成很大的资源消耗。

所以解决办法就是:使用线程池

    public void start() throws IOException {
        System.out.println("启动服务器");
        ExecutorService threadPool = Executors.newCachedThreadPool();
        while(true) {
            Socket clientSocket = serverSocket.accept();
            threadPool.submit(()->{
                processConnection(clientSocket);
            });
        }
    }

通过线程池的方法,就能进一步减少消耗。

但是呢,如果客户端都在响应,就算使用了线程池了但是还是不够,而且如果客户端非常多,客户端连接迟迟不断开,就会导致机器上有很多线程。

解决办法就是:IO多路复用,IO多路转接

给这个线程安排一个集合,这个集合就放了一堆连接。这个线程就来负责监听这个集合,哪个连接有数据来了,线程就处理哪个连接。这其实就是因为,虽然连接有很多很多,但是这些连接的请求并非完全严格的同时,总还是有先后的。

TCP中的长短连接

TCP发送数据时,需要先建立连接,什么时候关闭连接就决定是短连接还是长连接:

短连接:每次接收到数据并返回响应后,都关闭连接,即是短连接。也就是说,短连接只能一次收发数据。

长连接:不关闭连接,一直保持连接状态,双方不停的收发数据,即是长连接。也就是说,长连接可以多次收发数据。

对比以上长短连接,两者区别如下:

建立连接、关闭连接的耗时:短连接每次请求、响应都需要建立连接,关闭连接;而长连接只需要第一次建立连接,之后的请求、响应都可以直接传输。相对来说建立连接,关闭连接也是要耗时的,长连接效率更高。

主动发送请求不同:短连接一般是客户端主动向服务端发送请求;而长连接可以是客户端主动发送请求,也可以是服务端主动发。

两者的使用场景有不同:短连接适用于客户端请求频率不高的场景,如浏览网页等。长连接适用于客户端与服务端通信频繁的场景,如聊天室,实时游戏等。

网络编程中的重难点:套接字的应用和理解_第20张图片

总结

本文主要介绍了UDP和TCP的相关知识和一些差别。

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

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

这其中很多特点都是可以从代码中直接看到的,但还有比如可靠传输,这个东西隐藏在TCP背后,从代码的角度是感知不到的。TCP诞生的意义,就是为了解决可靠传输的问题~

你可能感兴趣的:(网络,java)