【计算机网络】网络编程

发布于:2025-03-24 ⋅ 阅读:(21) ⋅ 点赞:(0)

1. 客户端/服务器

在网络中,主动发起请求的一方就是客户端,被动接受的一方就是服务器。
客户端发送给服务器的数据,叫做请求(request)。
服务器返回给客户端的数据,叫做响应(response)。

客户端-服务器之间的交互:

一问一答<----->场景:web开发
一问多答<----->场景:下载
多问一答<----->场景:上传
多问多答<----->场景:远程控制

2. TCP/UDP协议

进行网络通信,需要调用系统的api,本质上是传输层提供的传输层,涉及到的协议主要是TCP和UDP。
区别:

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

连接:网络上的连接是抽象的,本质上就是通信双方保存了对方的相关信息。

有连接类似于打电话,需要对方接通
无连接类似于发短信,无需对方接通

可靠传输:这里的可靠传输就是发的数据到没到,发送方能够清楚的感知到。

面向字节流:网络中传输的数据的基本单位是字节。
面向数据报:每次传输的基本单位是数据报。

全双工:一个信道可以双向通信,就像公路一样是双向车道。
半双工:只能单向通信,就像过独木桥。

3. 网络编程套接字-socket

socket 是操作系统给应用程序(传输层给应用层)提供的API,Java对这个API进行了封装。
socket提供了两组不同的 API,UDP有一套,TCP有一套,本文主要介绍api的使用

3.1 API的使用

Java把系统原生的API进行了封装,操作系统中有一类文件叫做 scoket 文件,抽象的表示了"网卡"这样的设备,通过操作scoket文件就可以对网卡进行操作。

通过网卡发送数据,就是写scoket文件
通过网卡接收数据,就是读socket文件

3.1 DatagramScoket类

DatagramScoket是UDP scoket,用于接收和发送数据报

构造方法 说明
DatagramSocket() 创建一个UDP数据报套接字的Socket,绑定到本机任意一个随机端口(一般用于客户端)
DatagramSocket(int port) 创建一个UDP数据报套接字的Socket,绑定到本机指定的端口(port就是端口号)
内置方法 说明(下面的DatagramPacket p是作为输出型参数的)
void receive(DatagramPacket p) 从此套接字接收数据报(如果没有接收到数据报,该方法会阻塞等待)
void send(DatagramPacket p) 从此套接字发送数据报包(不会阻塞等待,直接发送)
void close() 关闭数据报套接字

输出型参数:输出型参数是一个变量,函数会修改它的值,并将修改后的值传递回调用者。调用者可以通过这个参数获取函数处理后的数据。就像是我们自己带饭盒去食堂吃饭,饭盒就相当于DatagramPacket,打饭的阿姨会帮我们把饭盒装满饭菜,此时打饭的阿姨就是void receive。

3.1 DatagramScoket类

UDP面向数据报,每次发送接收数据的基本单位,就是一个UDP数据报。

构造方法 说明
DatagramPacket(byte[] buf, int length) 构造一个 DatagramPacket 用来接收数据报,接收的数据保存在字节数组 buf 中,接收指定的长度 length
DatagramPacket(byte[] buf,int offset,int length,SocketAddress address) 构造一个 DatagramPacket 用来接收数据报,接收的数据保存在字节数组 buf 中,接收指定的长度 length,address 表示指定的目的主机的 ip 和端口号
内置方法 说明(下面的DatagramPacket p是作为输出型参数的)
InetAddress getAddress() 从接收的数据报中,获取发送端主机 IP 地址;或从发送的数据报中,获取接收端主机IP地址
int getPort() 从接收的数据报中,获取发送端主机的端口号;或者从发送的数据报中,获取接收端主机的端口号
byte[] getData() 获取数据报中的数据

4. 通过UDP实现回显服务器程序

回显服务器(Echo Server)是一种网络服务器,其主要功能是将接收到的数据原样返回给发送者。

4.1 服务器代码

  • 创建 DatagramSocket 对象,接下来操作网卡,操作网卡都是通过 socket 对象来完成的,此时创建 DatagramSocket 对象需要指定端口号,以方便客户端寻找服务器。
  • 对于服务器来说,需要不断地接受请求与返回响应,所以这里一直while循环,直至被强制终止。
  • receive从网卡能读取到一个 UDP 数据报,并放到了 requestPacket 对象中。
    其中 UDP 数据报的载荷部分就被放到 requestPacket 内置的字节数组中。
    另外报头部分,也会被 requestPacket 的其他属性保存,除了 UDP 报头之外,还有其他信息,比如收到的数据源 IP…
    通过 requestPacket 获取数据报的源 ip、源端口
  • 传输层会为每个socket对象分配一个缓冲区(内核里),此处给socket分配的缓冲区就是“阻塞队列”。
  • 因为这个是回显服务器,所以process没有任何操作。
package network;

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

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);
            //读到的字节数组,转成String方便后续的逻辑处理
            String request = new String(requestPacket.getData(), 0, requestPacket.getLength());
            //2.根据请求计算相应(对于辉县服务器来说,这一步啥都不用做)
            String response = process(request);
            //3.把响应返回给客户端
            //构造一个DatagramPacket作为响应对象
            DatagramPacket responsePacket = new DatagramPacket(response.getBytes(), response.getBytes().length, requestPacket.getSocketAddress());
            socket.send(responsePacket);

            //打印日志
            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();
    }
}

4.2 客户端代码

  • 客户端不需要手动指定端口,因为不知道用户客户端哪个端口空闲。
  • 客户端给服务器发送请求时,需要知道服务器的IP地址与端口号。
  • 使用 scanner 读取字符串,最好使用 next 而不是 nextLine。
    如果使用 nextLine 读取,需要手动输入换行符,enter 来进行控制,由于 enter 键不仅仅会产生 \n 还会产生其他字符,就会导致读取到的内容就容易出问题。
    使用 next 其实是以"空白符" 作为分隔符,包括不限于换行, 回车,空格, 制表符,垂直制表符…
package network;

import java.io.IOException;
import java.net.*;
import java.util.Scanner;

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

    public UdpEchoClient(String serverIp,int serverPort) throws SocketException {
        this.serverIp = serverIp;
        this.serverPort = serverPort;//这里是十进制位的IP地址
        socket = new DatagramSocket();
    }

    public void start() throws IOException {
        System.out.println("客户端启动");
        Scanner scanner = new Scanner(System.in);
        while (true) {
            //1.从控制台上读取要发送的数据
            System.out.print("->");//输入
            if (!scanner.hasNext()){
                break;
            }
            String request = scanner.next();
            //2.构造请求并发送
            DatagramPacket requestPacker = new DatagramPacket(request.getBytes(),request.getBytes().length,
                    InetAddress.getByName(serverIp),serverPort);//这里是改为二进制后的IP地址
            socket.send(requestPacker);
            //3.读取服务器的响应
            DatagramPacket responsePacker = new DatagramPacket(new byte[4096],4096);
            socket.receive(responsePacker);
            //4.把响应显示在控制台
            String response = new String(responsePacker.getData(),0,responsePacker.getLength());
            System.out.println(response);
        }
    }

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

4.3 代码执行过程

  1. 服务器启动,启动之后,立刻进入while循环,执行到receive,进入阻塞。此时没有任何客户端发来请求。

  2. 客户端启动,启动之后,立刻进入while循环,执行到hasNext,进入阻塞。此时用户没有在控制台输入任何内容。

  3. 用户在客户端的控制台中输入字符串,按下回车,此时hasNext阻塞解除,next会返回刚才输入的内容。
    基于用户输入的内容,构造出一个DatagramPacket对象,并进行send。
    send执行完毕之后,执行到receive操作,等待服务器返回的响应数据。

  4. 服务器收到请求之后,就会从receive的阻塞中返回。
    返回之后,就会根据读到的DatagramPacket对象,构造String request,通过process方法构造一个String response。
    再根据response构造一个DatagramPacket表示响应对象,在通过send来进行发送给客户端。
    执行这个过程中,客户端始终在阻塞等待。

  5. 客户端从receive中进行返回,就能够得到服务器返回的响应,并且打印在控制台上。
    与此同时,服务器也进入下一次循环,也要进入到第二次的receive阻塞,等待下一个请求。

4.4 通过UDP实现翻译客户端

package network;

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

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

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

        hashMap.put("cat","小猫");
        hashMap.put("dog","小狗");
        hashMap.put("chicken","小鸡");
    }

    //start() 方法完全从父类集成下来即可
    //process() 方法要进行重写,加入咱们自己的业务逻辑,进行翻译


    @Override
    public String process(String request) {
        return hashMap.getOrDefault(request,"您查的单词不存在");
    }

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

5. 通过TCP实现回显服务器

TCP是面向字节流的,传输的基本单位是字节,TCP协议是需要建立连接的。

连接建立:从客户端Socket的构造方法发送连接请求,服务器的SerevrSocket监听到请求后并且调用accept()方法,这样就建立了连接,然后accept()方法在服务器中会生成一个新的Socket对象用来进行通信。

构造方法 说明
ServerSocket(int port) 创建⼀个服务端流套接字Socket,并绑定到指定端⼝
Socket(String host, int port) 创建⼀个客户端流套接字Socket,并与对应IP的主机上,对应端口的进程建立连接

ServerSocket 类的内置方法:

内置方法 说明
Socket accept() 开始监听指定端⼝(创建时绑定的端⼝),有客户端连接后,返回一个Socket对象,并且基于Socket建立与客户端的连接,没有就阻塞等待
void close() 关闭套接字

Socket 类的内置方法:

内置方法 说明
InetAddress getInetAddress() 返回套接字锁连接的地址
InputStream getInputStream() 返回此套接字的输⼊流
InputStream getOutputStream() 返回此套接字的输出流

ServerSocket只能在服务器中使用,而Socket既可以在服务器中使用也可以在客户端使用。
TCP是有连接的,就类似需要客户端拨打电话,服务器来接听。

5.1 服务器代码

  • 通过 ServerSocket 提供的构造方法,给服务器分配一个端口号(这里我们需要注意,服务器是必须要指定端口号的,而客户端系统自己分配端口号就可以)
  • 服务器的 ServerSocket 是用来监听请求的,如果有客户端发送请求,那么ServerSocket就会感知到。
  • 当 ServerSocket 监测到有客户端发来连接的请求,ServerSocket 会调用accept()方法,accept()方法会返回一个新的Socket对象,这就算是建立了连接而这个新的对象就是用来与客户端通信。
  • accept() 是一个可能会产生阻塞的操作,如果没有客户端连过来,会一直阻塞。
  • 在客户端运行完毕之后我们要close()。
    TCP的客户端只有一个,随着客户端越来越多,消耗的socket也会越来越多,如果不释放,可能会把文件描述符表占满。
    serverSocket 整个程序只有唯一一个对象,并且这个对象的生命周期很长是要跟随整个程序的,这个对象无法提前关闭。只要程序退出,随着进程的销毁一起被释放即可。(不需要手动进行)

过程就像是客户端的 Socket 想要通过服务器的 ServerSocket 认识服务器中的 Socket。于是客户端的 Socket 就请求服务器的 ServerSocket 帮忙牵线搭桥,服务器的 ServerSocket 就把服务器的 Socket 的电话号码给了客户端,而客户端的构造方法就类似于给服务器拨通了电话,而当前只是在响铃,而accept()方法就类似接听,只有调用accept()的方法后才算真正建立连接。

  • 陌生代码讲解
InputStream inputStream=clientSocket.getInputStream();
OutputStream outputStream=clientSocket.getOutputStream();

从网卡内读数据以及往网卡内写数据,TCP中操作socket文件,对其进行读写(InputStream,OutputStream),就是在操作网卡,操作系统把网卡抽象成了一个文件。

package network;

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;

public class TcpEchoSever {
    private ServerSocket serverSocket = null;

    public TcpEchoSever(int port) throws IOException {
        serverSocket = new ServerSocket(port);
    }

    public void start() throws IOException {
        System.out.println("服务器启动");
        while (true) {
            //通过accept方法来“接听电话”,然后才能通信.如果没有客户端连过来,会进入阻塞状态
            Socket clientSocket = serverSocket.accept();
            processConnention(clientSocket);
        }
    }

    //通过这个方法来处理一次连接,连接建立的过程中涉及到多次的请求响应交互
    private void processConnention(Socket clientSocket) {
        System.out.printf("[%s: %d] 客户端上线\n", clientSocket.getInetAddress(), 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] 客户端下线\n", clientSocket.getInetAddress(), clientSocket.getPort());
                    break;
                }
                //1.读取请求并解析,这里注意隐藏约定,next 读到空白符(\n 或者 空格)才结束
                String request = scanner.next();
                //2.根据请求计算响应
                String response = process(request);
                //3.把响应返回给客户端
                //下行代码可以写会,但是这种方式不方便给返回的响应中添加 \n
                // outputStream.write(response.getBytes(),0,response.getBytes().length);
                //可以给 outputStream 套上一层,完成更方便的写入
                PrintWriter printWriter = new PrintWriter(outputStream);
                printWriter.println(response);
                printWriter.flush();//通过主动刷新缓冲区,确保数据真正发送出去

                System.out.printf("[%s: %d] req: %s, resp: %s\n", clientSocket.getInetAddress(), clientSocket.getPort(), request, response);
            }
        } catch (IOException e) {
            throw new RuntimeException(e);
        }finally {
            try {
                clientSocket.close();
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }
    }

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

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

5.2 客户端代码

  • 陌生代码讲解
 Scanner scannerConsole = new Scanner(System.in);
 Scanner scannerNetwork = new Scanner(inputStream);

在客户端上,scannerConsle 是在控制台中读取数据,也就是我们用户输入的时候读取数据,并且转变为 String 发送给服务器(就是通过OutputStream写入操作网卡的文件)。scannerNetwork 就是在服务器做出响应后,通过 inputStream 读取网卡上的数据 最终打印出结果。

  PrintWriter writer = new PrintWriter(outputStream);
   //2.把请求发送给服务器,这里使用println来发送,是为了让末尾带有\n,与服务器的scanner.next呼应
  writer.println(request);
  writer.flush();//通过主动刷新缓冲区,确保数据真正发送出去

PrintWriter 是 Java 中的一个类,位于 java.io 包中,用于以文本形式写入输出数据。它继承了 Writer 抽象类,提供了多种方法来方便地写入字符和字符串到文件或其他输出流中。
flush() 方法的作用是刷新缓冲区。因为IO都是比较低效的操作,一次一次读写,太麻烦。缓冲区就将先把数据放到内存缓冲区中,等攒够了数据一起发送,这样就变得高效了,而flush() 就是将缓存区刷新,将数据一点一点发送出去,不用等到满了一股脑发出去。

package network;

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;

    public TcpEchoClient(String severIp, int severPort) throws IOException {
        //此处直接将ip和port传给socket对象。由于TCP是有连接的,因此socket里面会保存好这两信息,故TcpEchoClient就不用保存
        socket = new Socket(severIp,severPort);
    }

    public void start() {
        System.out.println("客户端启动");
        try(InputStream inputStream = socket.getInputStream();
            OutputStream outputStream = socket.getOutputStream()) {
            Scanner scannerConsole = new Scanner(System.in);
            Scanner scannerNetwork = new Scanner(inputStream);
            PrintWriter writer = new PrintWriter(outputStream);
            while(true){
                //1.从控制台读取输入的字符串
                System.out.print("->");
                if (!scannerConsole.hasNext()){
                    break;
                }
                String request = scannerConsole.next();
                //2.把请求发送给服务器,这里使用println来发送,是为了让末尾带有\n,与服务器的scanner.next呼应
                writer.println(request);
                writer.flush();//通过主动刷新缓冲区,确保数据真正发送出去
                //3.从服务器读取响应,与服务器返回响应的逻辑相呼应
                String response = scannerNetwork.next();
                //4.把响应显示出来
                System.out.println(response);
            }
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

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

5.3 代码执行过程

  1. 服务器启动,阻塞在accept(),等待客户端连接。
  2. 客户端启动,创建一个Socket对象,触发客户端与服务器之间的建立连接的操作,此时,服务器将从accpet的阻塞状态中返回。
  3. 从阻塞状态中返回后,进入到 processConnection 方法,这里执行到 hasNext() 产生阻塞,因为虽然建立连接,但是没有发来任何请求,hasNext() 阻塞等待请求到达。
  4. 客户端继续执行到 hasNext() ,等待用户向控制台写入内容。
  5. 用户在控制条输入内容,从 hasNext() 的阻塞返回,继续执行发送请求的逻辑,将请求发出去后,将会等待服务器的返回,此时也会由 next() 产生阻塞。
  6. 服务器从 hasNext() 的阻塞状态返回读取到请求内容并进行处理,构造出响应,将响应写会客户端,此时,服务器结束这次循环,开启下一轮循环,继续阻塞在 hasNext() 等待下一个请求。
  7. 客户端读取到响应,并显示出来,结束这次循环,继续阻塞在 hasNext() 等待用户的输入