10.JAVAEE之网络编程

发布于:2024-04-29 ⋅ 阅读:(32) ⋅ 点赞:(0)

1.网络编程

  • 通过网络,让两个主机之间能够进行通信 =>基于这样的通信来完成一定的功能
  • 进行网络编程的时候,需要操作系统给咱们提供一组 AP1, 通过这些 API才能完成编程(API 可以认为是 应用层 和 传输层 之间交互的路径)(API:Socket API相当于一个插座:通过这一套 Socket AP| 可以完成不同主机之间,不同系统之间的网络通信)
  • 传输层,提供的网络协议,主要是两个:TCP UDP
  • 这俩协议的特性(工作原理) 差异很大.导致,使用这两种协议进行网络编程,也存在一定差别系统就分别提供了两套 API
  • TCP 和 UDP 的区别.(后面网络原理章节, 学习的重点)

       1.TCP 是有连接的, UDP 是无连接的

       2.TCP 是可靠传输的,UDP 是不可靠传输的

       3.TCP 是面向字节流的,UDP 是面向数据报

       4.TCP 和 UDP 都是全双工的

 1.TCP 是有连接的, UDP 是无连接的
(连接 是 抽象 的概念)
计算机中,这种 抽象 的连接是很常见的,此处的连接本质上就是建立连接的双方,各自保存对方的信息两台计算机建立连接,就是双方彼此保存了对方的关键信息~~
TCP 要想通信, 就需要先建立连接 (刚才说的, 保存对方信息),做完之后,才能后续通信(如果 A 想和 B 建立连接, 但是 B 拒绝了! 通信就无法完成!!!)

UDP 想要通信,就直接发送数据即可~~不需要征得对方的同意,UDP 自身也不会保存对方的信息(UDP 不知道,但是写程序的人得知道.UDP 自己不保存,但是你调用 UDP 的 socket api的时候要把对方的位置啥的给传过去)

2.TCP 是可靠传输的,UDP 是不可靠传输的
网络上进行通信, A ->B 发送一个消息,这个消息是不可能做到 100% 送达的!! 

可靠传输,退而求其次.
A ->B 发消息,消息是不是到达 B 这一方,A 自己能感知到.(A 心里有数)进一步的,就可以在发送失败的时候采取一定的措施(尝试重传之类的)

TCP 就内置了可靠传输机制;UDP 就没有内置可靠传输

【tips】可靠传输,听起来挺美好的呀, 为啥不让 UDP 也搞个可靠传输呢??

想要可靠传输,你就是要付出代价的(需要去交换)
可靠传输要付出什么代价?
1)机制更复杂
2)传输效率会降低

3.TCP 是面向字节流的,UDP 是面向数据报
此处说的 字节流 和 文件 操作这里的 字节流 是一个意思!!!
TCP 也是和文件操作一样,以字节为单位来进行传输.
UDP 则是按照数据报为单位,来进行传输的
UDP 数据报是有严格的格式的 

网络通信数据的基本单位,涉及到多种说法~~
1.数据报(Datagram)
2.数据包(Packet)

3.数据帧(Frame)
4.数据段 (Segment) 

4. TCP 和 UDP 都是全双工的
一个信道,允许双向通信, 就是全双工
一个信道,只能单向通信,就是半双工
代码中使用一个 Socket 对象, 就可以发送数据也能接受数据~~ 

2.UDP 的 socket api 如何使用

Datagramsocket 

Datagrampacket 

【回显服务器:(echo server)】

写一个简单的 UDP 的客户端/服务器 通信的程序.
这个程序没有啥业务逻辑,只是单纯的调用 socket api.
让客户端给服务器发送一个请求,请求就是一个从控制台输入的字符串.
服务器收到字符串之后,也就会把这个字符串原封不动的返回给客户端,客户端再显示出来. 

import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.SocketException;

public class UdpEchoServer {
    // 创建一个 DatagramSocket 对象. 后续操作网卡的基础.
    private DatagramSocket socket = null;

    public UdpEchoServer(int port) throws SocketException {
        // 这么写就是手动指定端口
        socket = new DatagramSocket(port);
        // 这么写就是让系统自动分配端口
        // socket = new DatagramSocket();
    }

    public void start() throws IOException {
        // 通过这个方法来启动服务器.
        System.out.println("服务器启动!");
        // 一个服务器程序中, 经常能看到 while true 这样的代码.
        while (true) {
            // 1. 读取请求并解析.
            DatagramPacket requestPacket = new DatagramPacket(new byte[4096], 4096);
            socket.receive(requestPacket);
            // 当前完成 receive 之后, 数据是以 二进制 的形式存储到 DatagramPacket 中了.
            // 要想能够把这里的数据给显示出来, 还需要把这个二进制数据给转成字符串.
            String request = new String(requestPacket.getData(), 0, requestPacket.getLength());
            // 2. 根据请求计算响应(一般的服务器都会经历的过程)
            //    由于此处是回显服务器, 请求是啥样, 响应就是啥样.
            String response = process(request);
            // 3. 把响应写回到客户端.
            //    搞一个响应对象, DatagramPacket
            //    往 DatagramPacket 里构造刚才的数据, 再通过 send 返回.
            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 {
        UdpEchoServer server = new UdpEchoServer(9090);
        server.start();
    }
}
import java.io.IOException;
import java.net.*;
import java.util.Scanner;

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

    public UdpEchoClient(String ip, int port) throws SocketException {
        // 创建这个对象, 不能手动指定端口.
        socket = new DatagramSocket();
        // 由于 UDP 自身不会持有对端的信息. 就需要在应用程序里, 把对端的情况给记录下来.
        // 这里咱们主要记录对端的 ip 和 端口 .
        serverIp = ip;
        serverPort = port;
    }

    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. 把请求内容构造成 DatagramPacket 对象, 发给服务器.
            DatagramPacket requestPacket = new DatagramPacket(request.getBytes(), request.getBytes().length,
                    InetAddress.getByName(serverIp), serverPort);
            socket.send(requestPacket);
            // 3. 尝试读取服务器返回的响应了.
            DatagramPacket responsePacket = new DatagramPacket(new byte[4096], 4096);
            socket.receive(responsePacket);
            // 4. 把响应, 转换成字符串, 并显示出来.
            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);
        // UdpEchoClient client = new UdpEchoClient("42.192.83.143", 9090);
        client.start();
    }
}

【客户端】

  • 对于服务器来说,也就需要把端口号给明确下来了~~
  • 客户端的端口号是不需要确定的.交给系统进行分配即可,
  • 如果你手动指定确定的端口,就可能和别人的程序的端口号冲突
  • 【tips】服务器这边手动指定端口,就不会出现冲突嘛??
    为啥客户端在意这个冲突,而服务器不在意呢??

    服务器是在程序猿手里的,一个服务器上都有哪些程序, 都使用哪些端口,程序猿都是可控的!!程序猿写代码的时候,就可以指定一个空闲的端口,给当前的服务器使用即可
    但是客户端就不可控,客户端是在用户的电脑上;一方面,用户千千万~~ 每个用户电脑上装的程序都不一样,占用的端口也不一样;交给系统分配比较稳妥.系统能保证肯定分配一个空闲的端口

服务器一旦启动,就会立即执行到这里的 receive 方法此时,客户端的请求可能还没来呢~~
这种情况也没关系.receive 就会直接阻塞, 就会一直阻塞到真正客户端把请求发过来为止,(类似于阻塞队列)

【question】

//根据请求计算响应(核心步骤)

这个步骤是一个服务器程序,最核心的步骤!!!
咱们当前是 echo server 不涉及到这些流程,也不必考虑响应怎么计算,只要请求过来,就把请求当做响应

【question】

【question】上述写的代码中,为啥没写 close??
socket 也是文件,不关闭不就出问题了,不就文件资源泄露了么,为啥这里咱们可以不写 close?为啥不写 close 也不会出现文件资源泄露??

private DatagramSocket socket = null;
这个 socket 在整个程序运行过程中都是需要使用的(不能提前关闭)当 socket 不需要使用的时候, 意味着程序就要结束了

进程结束,此时随之文件描述符表就会销毁了(PCB 都销毁了).谈何泄露??
随着销毁的过程,被系统自动回收了~~

啥时候才会出现泄露?代码中频繁的打开文件,但是不关闭在一个进程的运行过程中,不断积累打开的文件,逐渐消耗掉文件描述符表里的内容最终就消耗殆尽了
但是如果进程的生命周期很短,打开一下没多久就关闭了.谈不上泄露
文件资源泄露这样的问题,在服务器这边是比较 严重的, 在客户端这边一般来说影响不大.

 【服务器】

【交互】

1.服务器先启动.服务器启动之后,就会进入循环,执行到 receive 这里并阻塞 (此时还没有客户端过来呢)
2.客户端开始启动,也会先进入 while 循环,执行 scanner.next.并且也在这里阻塞当用户在控制台输入字符串之后,next 就会返回,从而构造请求数据并发送出来~~

3.客户端发送出数据之后,
服务器: 就会从 receive 中返回,进一步的执行解析请求为字符串,执行 process 操作,执行 send 操作
客户端: 继续往下执行,执行到 receive,等待服务器的响应

4.客户端收到从服务器返回的数据之后,就会从 receive 中返回执行这里的打印操作,也就把响应给显示出来了
5.服务器这边完成一次循环之后, 又执行到 receive 这里,客户端这边完成一次循环之后,又执行到 scanner.next 这里双双进入阻塞

 【翻译服务器】

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

public class UdpDictServer extends UdpEchoServer {
    private Map<String, String> dict = new HashMap<>();

    public UdpDictServer(int port) throws SocketException {
        super(port);

        // 此处可以往这个表里插入几千几万个这样的英文单词.
        dict.put("dog", "小狗");
        dict.put("cat", "小猫");
        dict.put("pig", "小猪");
    }

    // 重写 process 方法, 在重写的方法中完成翻译的过程.
    // 翻译本质上就是 "查表"
    @Override
    public String process(String request) {
        return dict.getOrDefault(request, "该词在词典中不存在!");
    }

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

上述重写 process 方法,就可以在子类中组织你想要的"业务逻辑",(你要写代码解决一些实际的问题)

3.TCP 的 socket api 如何使用 

TCP 的 socket api 和 UDP 的 socket api 差异又很大~,

但是和前面讲的 文件操作,有密切联系的

两个关键的类

1.ServerSocket(给服务器使用的类,使用这个类来绑定端口号)

2.Socket(既会给服务器用,又会给客户端用)

这俩类都是用来表示 socket 文件的,(抽象了网卡这样的硬件设备)

TCP 是字节流的.传输的基本单位,是 byte

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.net.Socket;
import java.util.Scanner;

public class TcpEchoClient {
    private Socket socket = null;

    public TcpEchoClient(String serverIp, int serverPort) throws IOException {
        // 需要在创建 Socket 的同时, 和服务器 "建立连接", 此时就得告诉 Socket 服务器在哪里~~
        // 具体建立连接的细节, 不需要咱们代码手动干预. 是内核自动负责的.
        // 当我们 new 这个对象的时候, 操作系统内核, 就开始进行 三次握手 具体细节, 完成建立连接的过程了.
        socket = new Socket(serverIp, serverPort);
    }

    public void start() {
        // tcp 的客户端行为和 udp 的客户端差不多.
        // 都是:
        // 3. 从服务器读取响应.
        // 4. 把响应显示到界面上.
        Scanner scanner = new Scanner(System.in);
        try (InputStream inputStream = socket.getInputStream();
             OutputStream outputStream = socket.getOutputStream()) {
            PrintWriter writer = new PrintWriter(outputStream);
            Scanner scannerNetwork = new Scanner(inputStream);
            while (true) {
                // 1. 从控制台读取用户输入的内容
                System.out.print("-> ");
                String request = scanner.next();
                // 2. 把字符串作为请求, 发送给服务器
                //    这里使用 println, 是为了让请求后面带上换行.
                //    也就是和服务器读取请求, scanner.next 呼应
                writer.println(request);
                writer.flush();
                // 3. 读取服务器返回的响应.
                String response = scannerNetwork.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();
    }
}
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.Scanner;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

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) {
            // 通过 accept 方法, 把内核中已经建立好的连接拿到应用程序中.
            // 建立连接的细节流程都是内核自动完成的. 应用程序只需要 "捡现成" 的.
            Socket clientSocket = serverSocket.accept();
            // 此处不应该直接调用 processConnection, 会导致服务器不能处理多个客户端.
            // 创建新的线程来调用更合理的做法.
            // 这种做法可行, 不够好
//            Thread t = new Thread(() -> {
//                processConnection(clientSocket);
//            });
//            t.start();

            // 更好一点的办法, 是使用线程池.
            service.submit(new Runnable() {
                @Override
                public void run() {
                    processConnection(clientSocket);
                }
            });
        }
    }

    // 通过这个方法, 来处理当前的连接.
    public void processConnection(Socket clientSocket) {
        // 进入方法, 先打印一个日志, 表示当前有客户端连上了.
        System.out.printf("[%s:%d] 客户端上线!\n", clientSocket.getInetAddress(), clientSocket.getPort());
        // 接下来进行数据的交互.
        try (InputStream inputStream = clientSocket.getInputStream();
             OutputStream outputStream = clientSocket.getOutputStream()) {
            // 使用 try ( ) 方式, 避免后续用完了流对象, 忘记关闭.
            // 由于客户端发来的数据, 可能是 "多条数据", 针对多条数据, 就循环的处理.
            while (true) {
                Scanner scanner = new Scanner(inputStream);
                if (!scanner.hasNext()) {
                    // 连接断开了. 此时循环就应该结束
                    System.out.printf("[%s:%d] 客户端下线!\n", clientSocket.getInetAddress(), clientSocket.getPort());
                    break;
                }
                // 1. 读取请求并解析. 此处就以 next 来作为读取请求的方式. next 的规则是, 读到 "空白符" 就返回.
                String request = scanner.next();
                // 2. 根据请求, 计算响应.
                String response = process(request);
                // 3. 把响应写回到客户端.
                //    可以把 String 转成字节数组, 写入到 OutputStream
                //    也可以使用 PrintWriter 把 OutputStream 包裹一下, 来写入字符串.
                PrintWriter printWriter = new PrintWriter(outputStream);
                //    此处的 println 不是打印到控制台了, 而是写入到 outputStream 对应的流对象中, 也就是写入到 clientSocket 里面.
                //    自然这个数据也就通过网络发送出去了. (发给当前这个连接的另外一端)
                //    此处使用 println 带有 \n 也是为了后续 客户端这边 可以使用 scanner.next 来读取数据.
                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 {
            try {
                // 在这个地方, 进行 clientSocket 的关闭.
                // processConnection 就是在处理一个连接. 这个方法执行完毕, 这个连接也就处理完了.
                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();
    }
}

【服务器】

内核中有一个“队列”(可以视为阻塞队列)

如果有客户端,和服务器建立连接,这个时候服务器的应用程序是不需要做出任何操作(也没有任何感知的),内核直接就完成了连接建立的流程(三次握手).

完成流程之后,就会在内核的队列中(这个队列是每个 serverSocket 都有一个这样的队列)。

排队应用程序要想和这个客户端进行通信,就需要通过一个 accept 方法把内核队列里已经建立好的连接对象,拿到应用程序中。

【question】

前面写过的 DatagramSocket, ServerSocket 都没写 close, 但是我们说这个东西都没关系但是 clientSocket 如果不关闭,就会真的泄露了!!!
DatagramSocket 和 ServerSocket,都是在程序中,只有这么一个对象.申明周期, 都是贯穿整个程序的.

而ClientSocket 则是在循环中,每次有一个新的客户端来建立连接,都会创建出新的clientSocket

每次执行这个,都会创建新的 clientSocket,并且这个 socket 最多使用到 该客户端退出(断开连接)
此时,如果有很多客户端都来建立连接~~此时,就意味着每个连接都会创建 clientSocket.当连接断开clientSocket 就失去作用了,但是如果没有手动 close此时这个 socket 对象就会占据着文件描述符表的位置

【客户端】

【question】出现一个bug

当前启动两个客户端,同时连接服务器.
其中一个客户端(先启动的客户端) 一切正常.
另一个客户端 (后启动的客户端)则没法和服务器进行任何交互,(服务器不会提示"建立连接”,也不会针对 请求 做出任何响应)

上述bug和代码结构密切相关

确实如刚才推理的现象一样,第一个客户端结束的时候,就从 processConnection 返回了就可以执行到第二次 accept 了,也就可以处理第二个客户端了~~
很明显,如果启动第三个客户端,第三个客户端也会僵硬住,又会需要第二个客户端结束才能活过来...

如何解决上述问题?让一个服务器可以同时接待多个客户端呢??

关键就是,在处理第一个客户端的请求的过程中,要让代码能够快速的第二次执行到 accept ~~~【多线程】

上述这里的关键,就是让这两个循环能够"并发"执行.
各自执行各自的,不会因为进入一个循环影响到另一个~~

【刚才出现这个问题的关键在于两重循环在一个线程里进入第二重循环的时候,无法继续执行第一个循环.
Udp 版本的服务器,当时是只有一个循环,不存在类似的问题~~(前面部署到云服务器的时候)】