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

目录

什么是网络编程

发送端和接收端

请求和响应

客户端和服务端

Socket套接字

UDP

TCP

Socket编程

UDP数据报套接字编程

DatagramSocket API

DatagramPacket API

UDP客户端服务器程序

服务器代码

客户端代码

端口占用

TCP流套接字编程

ServerSocket API

Socket API

TCP客户端服务器程序

服务器代码

客户端代码

 多线程、线程池连接

TCP中的长短连接

总结


什么是网络编程

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

发送端和接收端

在一次网络数据传输时:

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

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

请求和响应

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

第一次:请求数据的发送

第二次:相应数据的发送

客户端和服务端

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

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

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

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

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

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

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

Socket套接字

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

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

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

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

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

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

无连接、有连接:

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

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

不可靠传输、可靠传输:

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

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

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

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

全双工、半双工:

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

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

UDP

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

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

TCP

Socket编程

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

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

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

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

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

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

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

解决方法:

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

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

UDP数据报套接字编程

DatagramSocket API

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

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

DatagramSocket构造方法:

 DatagramSocket 方法:

DatagramPacket API

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

DatagramPacket 构造方法:

 DatagramPacket 方法:

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打印出来。

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

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

 

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

端口占用

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

  

TCP流套接字编程

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

ServerSocket API

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

构造方法:

方法:

Socket API

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

构造方法:

 方法:

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传输的数据,再打印出来。

 

 多线程、线程池连接

当前咱们的服务器同一时刻只能给一个客户端提供服务,这是不科学的。当前启动服务器后,先后启动两个客户端,客户端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发送数据时,需要先建立连接,什么时候关闭连接就决定是短连接还是长连接:

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

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

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

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

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

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

总结

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

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

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

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

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值