【JavaSE-网络部分02】网络编程套接字

本期我们来说网络编程套接字。这个套接字的单词是socket,它的英文原意叫做插槽,那么最早呢这种插槽是电脑上的一种接口。现在的话呢,socket它是操作系统给应用程序提供的一组网络编程的API,我们把它称之为socket API。在操作系统中,我们也把这个socket理解成一种特殊的文件,用来指代网卡。


之前我们也给大家谈到过,在操作系统里面会把很多的东西呢都抽象成文件。比如呢我们去操作一个硬盘,它是不好直接去操作的,那么我们呢可以把硬盘这样的一个东西呢给抽象是一个文件。然后呢我们去操作文件就间接的操作了硬盘。因此呢我们呢也把网卡抽象成一个文件。往socket里面去写数据就相当于往网卡中写数据,那么往socket中读数据就相当于是从网卡中读数据。


Socket的API是操作系统给应用程序提供的函数,那么应用程序是我们的应用层和应用层交互的就是传输层,那么socket API都是传输层来提供的。虽然我们的网络层,数据链路层也有自己的API,但是咱们作为编写应用程序的程序员来说,我们涉及不到。


之前我们谈的传输层有两个核心的协议就是tcp和upd,虽然他们都是传输层,都是负责端到端,都是围绕着源端口和目的端口展开的,但是这两个协议功能特性上差异很大,他们的编程手法也差别很大。说白了udp有udp的socket api我们的tcp有tcp的socket api这两套api的内容不同我们都需要进行掌握。


那么在进行网络编程之前,我们先简单介绍一下这两个协议。

Udp协议它是无连接,不可靠传输,面向数据报以及全双工的协议。而我们的tcp协议是有连接,可靠传输,面向字节流全双工的一个协议。

  • 连接(有连接和无连接):在这里我们谈到的连接,它是网络上的抽象的,虚拟的一个概念,并不是物理层面的一个连接。那什么叫做物理层面的连接呢?就是我们拿一根绳把两个东西给它捆在一块,这个呢我们叫做物理层面的理解。我们需要进行一个区分,虽然我们网络通信之中确实需要有网线这种物理层面的一个东西但是网线并不是我们此处所说的连接。咱们这里所说到的连接是一种抽象的概念,它是虚拟的,并没有物理层面的一个真实。咱们这里说到的连接描述的是一种关系。这样听起来可能有点抽象,但是呢我们举这样的一个生活例子:比如咱们的结婚这样的一个例子,对吧,我们结婚最主要的是就是领证,那么领证就相当于咱明确了一个婚姻关系,相当于是建立连接,这样一个抽象的,虚拟的连接。虽然咱们没有物理上的用一根绳捆在一起对吧,但是咱们已经是一根绳上的蚂蚱了。那么这样的一个关系,我们是怎么连接起来的呢?其实关键的要点是结婚证。我们的结婚证是一式两份的,男方的那一份我们有着女方的信息。而女方的那一份,我们存着男方的信息。所以类比着我们网络中所谓的连接,本质上就是通信双方记录了对方的信息。举个例子,比如我们有ab两个主机,那么a主机记录了b主机的关键信息。此时a就看到自己要和b通信了,那么b主机呢也记录了a主机的关键信息,那么a主机也就知道了自己要和a主机通信了,所以呢我们的抽象的连接就是说我们通信双方彼此记录对方的信息。那么关键信息是哪些呢?无非就是IP和端口号那些。

所以说我们tcp有连接,就是说在正式通信之前把对方的信息给获取到并保存下来,在代码中我们得先调用建立连接的函数,然后再调用发送数据的函数,参数,我们不必指定对方是谁。 。而我们的udp无连接则是不会保存对方的信息,那udp进行通信是怎么做的呢?它其实是每次调用发送接口的时候现场传递这个信息。也就是说你即使UPD是没有连接的,但是你通信的时候,你得知道对方的一个信息,只不过这个信息是我们在调用发送接口的时候是现成去传递的,在代码之中,我们直接调用发送数据的函数即可,但是我们得指定对方是谁,即指定对方的IP和端口号


那么我们说记录了对方的信息是建立连接,那么断开连接,反过来就是删除了对方的信息。好比我们结婚证变成了离婚证,他并不是直接的删除,而是做一个标记代表此次连接无效,他其实就是一种逻辑删除。


  • 可靠不可靠:那么什么叫做可靠传输?什么叫做不可靠传输呢?那么不可靠传输就是很好理解的,意思就是说我把数据发送出去之后我就不管了,哎,好比就是没有责任心的一种体现。与之对应的可靠传输就是说把数据发送出去之后,关心一下对方是否收到了这个数据。那这里边咱们也得注意一点,可靠传输我们并不敢保证数据100%到对方的,因为第一个点呢,我们网络上数据是非常容易,那么这是一个客观存在的大问题,无论是光信号还是说电信号,他都可能收到外界的一个干扰。那么另外一个情况,就是说网络世界里通过路由器,交换机来进行构建的,而路由器和交换机就像一个十字路口,数据如同汽车一样,会出现堵车的情况。某个节点我们实际要转发的数据会超过设备所能转发的上限,这样也是另外一种丢包。那么是否是可靠传输比不可靠传输更好呢?这个是不一定的。因为我们有得就有失,我们做的东西都是有成本的,tcp为了可靠传输,他牺牲了一部分传输效率,而我们的udp呢没有可靠传输,但是它的效率会更高一点。

  • 面向字节流和面向数据报。这个面向字节流,我们在文件io章节中也接触到过。在文件io章节中有字节流和字符流,那么这里tcp它的一个面向字节流的特点和文件是非常类似的,比如我们想发100个字节,我们可以一次全部发送。也可以分成十次,每次发10个字节,或者可以分成20次,每次发5个字节。而我们的udp面向数据报这一个特点呢就是说,udp传输的时候以数据报为单位进行传输,也就是说一次必须是发送或者说接收一个udp数据报不能是半个,这种数据报是由若干字节构成的一个结构体,那么你无论是发送还是接收你的以这个结构体为基本单位,我们不能像tcp一样,想发多少就发多少。
  • 全双工,我们的tcp和udp他们都是全双工的,那么这个全双工的意思是双向通信,全双工好比就是你打电话你对端可以向我说话,我对端同时也可以向你说话。与之对应的是半双工,也就是说同一时刻呢只能单向通信,比如我们的电报。

了解完前置知识之后,我们就开始进入udp和tcp socket的一个编程。

Socket API本质上是传输层提供的,也就是说我们操纵系统已经提供的。

我们原生的socket API是c语言,而我们JAVA的标准库中就对原生的c语言的API进行了封装,得到了JAVA版本和API。使用JAVA版本的API,我们使用起来更方便。那么我们首先来看udp的API,我们在看tcp的API。

UDP数据报套接字编程 API介绍

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

Datagram是数据报的意思,因为我们的udp是面向数据报的。那么这里有一个疑问,就是说此处为什么不是叫做UdpSocket,而是叫DatagramSocket呢?因为在以前,传输层的协议是非常多的。我们有很多都是自己留,也有很多都是数据报的,那么为了统称这很多个数据包,我们就直接给他命名为data gram socket,只不过后来时随着时间的推移,大部分协议都被淘汰掉了,只剩下知名的udp和tcp。

创建一个DatagramSocket这样的对象就相当于在系统内核里面创建了一个socket的文件,那么创建socket的文件也会占用文件描述符表的一个位置,好比就是我们创建一个文件,会在一个链表中将这个节点给加进去,所以呢我们用完之后得及时关闭,否则呢可能会出现文件资源泄露的情况。同时我们注意一个点,我们说创建这个对象呢,就是创建了一个文件对象。那么我们去做网络编程的时候,其实也就是在操作socket文件,所以我们的基本流程都是三步:打开文件、进行读或写、关闭文件。那么打开文件,我们用DatagramSocket这个类的构造方法,进行读和写我们用receive和send这两个方法,关闭文件我们用close方法。

那么我们如何创建这个对象呢?我们提供了两个风格的构造方法,一个是有参数,一个是无参数。

DatagramSocket

构造方法

方法签名 方法说明
DatagramSocket() 创建一个UDP数据报套接字的Socket,绑定到本机任意一个随机端口(一般用于客户端)
DatagramSocket(int port) 创建一个UDP数据报套接字的Socket,绑定到本机指定的端口(一般用于服务端)

那么这里为什么我们没有参数的构造方法用于客户端,然后我们有参数的构造方法用于服务端呢?其实这里边参数是端口,也就是说我们指定端口用服务端,而我们不指定端口让本机任意给一个随机端口的情况用于客户端,这是为什么呢?首先你得区分谁是客户端,谁是服务端,我们主动的一方是客户端,而被动的一方是服务端。因为客户端主动去请求服务端被动接收请求。也就是说我们一次通信服务端和客户端都得创建这个文件对象。那么具体为什么客户端无参版本,服务端呢用另外一个构造方法。

也就是说服务器必须要指定固定的端口,为了确保客户端能够随时找到服务器。

而对于客户端来说,必须不指定固定端口。而是让系统随机分配空闲的端口,这样的目的是为了避免端口后出现冲突。

举一个具体的例子,比如我要在学校的食堂里面去开一家饭店,那么这个饭店就是我们的首端服务器,这个饭店是得有具体的。地址的,也就是说你餐馆在食堂里面,食堂就是我们的IP地址,食堂里面的哪号窗口,这个窗口就是我们的端口号服务器,也就是我们的这个餐馆所在的位置,他是固定的。那么等同学们下课之后呢就来到了食堂,此时来到我这个饭店里面去,进行买饭,那么问题来了,食堂里面有那么多位置,同学坐的位置每一次都是固定唯一的吗?肯定是可能不一样的,那么同学坐在食堂里面的位置的时候,肯定是去找一个空闲的,没有其他同学坐的位置。那么这样一个场景,同学就好比是客户端我们客户端他的端口号就是我们的位置,由我们的操作系统自己去分配一个空闲的端口给我们的客户端程序。

常用方法

方法签名 方法说明
void receive(DatagramPacket p) 从此套接字接收数据报(如果没有接收到数据报,该方法会阻塞等待)
void send(DatagramPacket p) 从此套接字发送数据报包(不会阻塞等待,直接发送)
void close() 关闭此数据报套接字

这里我们需要关注的是receive和send的方法中,它的一个参数就是DatagramPacket ,这一个类表示一个udp的数据报,数据报事我们udp进行网络通信传输的基本单位,正是因为呢我们的udp传输的基本单位是这个类,所以呢我们的参数无论是发送还是接收,也就是说无论是读还是写,我们都将这个基本单位封装成这个类,把它传递进去。因此接下来我们先了解这个类。

DatagramPacket

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

构造方法

方法签名 方法说明
DatagramPacket(byte[] buf, int length) 构造一个DatagramPacket以用来接收数据报,接收的数据保存在字节数组(第一个参数buf)中,接收指定长度(第二个参数length)
DatagramPacket(byte[] buf, int offset, int length, SocketAddress address) 构造一个DatagramPacket以用来发送数据报,发送的数据为字节数组(第一个参数buf)中,从0到指定长度(第二个参数length)。address指定目的主机的IP和端口号

常用方法

方法签名 方法说明
InetAddress getAddress() 从接收的数据报中,获取发送端主机IP地址;或从发送的数据报中,获取接收端主机IP地址
int getPort() 从接收的数据报中,获取发送端主机的端口号;或从发送的数据报中,获取接收端主机端口号
byte[] getData() 获取数据报中的数据

构造UDP发送的数据报时,需要传入SocketAddress,该对象可以使用InetSocketAddress来创建。

InetSocketAddress

InetSocketAddressSocketAddress的子类)

构造方法

方法签名 方法说明
InetSocketAddress(InetAddress addr, int port) 创建一个Socket地址,包含IP地址和端口号

代码:

当前我们要编写一个最简单的udp客户端服务器程序。这个程序的逻辑就是udp客户端,给udp服务器发送一个字符串。这个字符串由用户控制台输入,然后udp服务器收到之后呢把这个字符串原封不动的返回给客户端。比如客户端请求服务器发送一个hello,然后我们的服务器返回给客户端也是一个hello。那么这样的程序我们叫做回显服务器,英文意思是echo server。

我们先写服务端

java 复制代码
package net.udp;

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

/**
 * @ClassName UdpServer
 * @Description TODO
 * @Author zhongge
 * @Date 2026-01-30 9:36
 * @Version 1.0
 */
public class UdpEchoServer {
    //先创建一个socket对象
    private DatagramSocket socket = null;

    /**
     这里边我们会出现一个socket的异常,那么这个情况是我们典型的情况,叫做端口号冲突。导致的异常。
     因为我们是通过端口号来区分应用程序的,所以我们不允许端口号冲突重复。
     那么为什么我们的服务器需要指定一个端口号呢?首先,端口号是网络通信中的重要概念,
     客户端想要访问服务器,就需要知道服务器的IP和服务器的端口号。这个是一个基本的问题常识,你看这个服务器,你得知道这个服务器在哪,
     然后你要访问这个服务器上的哪一个程序?首先知道服务器的地址,我们用IP地址来标识,那么如何判别服务器上的哪个程序呢?我们使用端口号来区分。
     而其中我们的IP地址是很容易确定的,他是在那个机器上启动的服务器,我们只需要通过一个IP config的命令就可以查到我们的IP地址。

     */
    public UdpEchoServer(int port) throws SocketException {
        this.socket = new DatagramSocket(port);
    }

    //启动服务器
    public void start() throws IOException {
        //启动服务器
        System.out.println("服务器启动");

        //服务器需要不断的处理客户端请求 所以要循环
        while(true){
            /**
             * 那么对于我们服务器端的一个写法的话,一般基本流程都是这样的。
             * 我们都是写一个循环,每循环一次就是处理一个请求,每一次循环都有以下三个步骤。
             * 第一,读取请求并解析。
             * 第二,根据请求构造响应。
             * 第三,把响应返回给客户端。
             */
            //第一,读取请求并解析

            /**
             * 我们创建DatagramPacket对象的时候一个镇的对象代表一个udp数据报,那么一个udp数据报,它有报头和载荷。
             * 那么这里为什么我们创建的时候需要指定一个字节数组?其实是我们将这个udp数据包中的载荷部分给拿出来,存到这个字节数组中。
             */
            DatagramPacket requestPacket = new DatagramPacket(new byte[1024], 1024);

            /**
             * 我们这里边填写的这个参数呢,也是一种输出型参数。
             * 对于输出型参数的处理就是我们现在方法外边创建出空的DatagramPacket对象
             * 然后再将这个空的对象传递到receive方法中,由receive方法将这个对象给填充,这个就是我们的输出型参数
             *
             * 这里边你得注意。我们服务器代码是被动的一方,当服务器运行到receive的时候,客户端可能发送的请求,也可能没有发送请求,
             * 那么如果客户端还没有发送请求的话,此时receive就会被阻塞,也就是说如果客户端还没有发出请求,他就会卡在这里。
             * 我去,那他会卡到什么时候呢?他会一直等到客户端真正的发送数据过来,才会解除阻塞。
             */
            socket.receive(requestPacket);//使用这个方法读取客户端传来的数据
            //这里边我们为了处理请求,方便我们将里面的载荷数据取出来,构造成一个String对象。他的处理逻辑是我们String类里面的知识。
            String request = new String(requestPacket.getData(), 0, requestPacket.getLength());//这一步是将字节数组变为String对象的一个构造方法。

            //第二,根据请求构造响应

            /**
             * 我们构造响应的话用这个process方法来处理逻辑就可以了。
             * 需要注意的是,我们是根据请求来构造的,所以呢我们的参数是请求对象。
             */
            String response = process(request);

            //第三,把响应返回给客户端

            /**
             * 我们要把从客户端收到的请求构造出的响应给发送回去。
             * 那么此时我们就得构造出一个UPD数据报responsePacket 然后再通过send发送回去
             * response.getBytes()是将字符串变为自己数组
             */
            DatagramPacket responsePacket = new DatagramPacket(response.getBytes(), response.getBytes().length,requestPacket.getSocketAddress());//字符串转换为字节数组


            /**
             * 注意,由于我们的udp是无连接的。因此呢他并没有保存客户端的信息,也就是说我没有保存客户端的IP地址和端口号。
             * 所以我们在进行send方法的时候,你发给谁我们是不知道的。
             * 于是我们得手动指定IP地址和端口号。怎么指定呢?我们通过参数来指定。
             * 也就是说数据从哪来的就发挥到哪里去,怎么样知道数据是从哪来的呢?我们通过requestPacket是可以拿到的。
             * DatagramPacket requestPacket = new DatagramPacket(new byte[1024], 1024);
             * requestPacket他记录了客户端的IP和端口号。所以我们拿到它即可。requestPacket.getSocketAddress();
             * 拿到之后呢,我们把它封装到我们响应Udp数据报中。如下所示:
             *  DatagramPacket responsePacket = new DatagramPacket(response.getBytes(), response.getBytes().length,requestPacket.getSocketAddress());
             *
             *  这个方法包含了IP地址和端口号。
             *   public synchronized SocketAddress getSocketAddress() {
             *         return new InetSocketAddress(getAddress(), getPort());
             *     }
             */
            socket.send(responsePacket);//发送回去


           //打印日志
            System.out.printf("[%s: %d] 请求: %s, 响应: %s\n", requestPacket.getAddress(),requestPacket.getPort(),
                    request, response);

        }
    }

    //回显服务器的处理方式
    protected String process(String request) {
        return request;
    }



    //主方法用来作为启动服务器的入口程序
    public static void main(String[] args) throws IOException {
        //端口号的范围是0-65535
        UdpEchoServer server = new UdpEchoServer(9090);

        server.start();
        //不过你运行服务器的时候,如果没有启动客户端,那么你的服务器就会卡在service方法那一行代码中
    }
}

客户端程序

java 复制代码
package net.udp;

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

/**
 * @ClassName UdpClient
 * @Description TODO
 * @Author zhongge
 * @Date 2026-01-30 9:37
 * @Version 1.0
 */
public class UdpEchoClient {
    //先创建一个Socket对象
    private DatagramSocket socket = null;

    private String serverIp;//ip地址
    private int serverPort;//端口号
    /**
     * 和服务端不同的是,我们客户端创建的时候是不需要手动指定端口号的。
     * 他有我们的操作系统去分配一个空闲的端口,
     * 我们在前置知识中也说过客户端的端口号是我们操作系统自己指定的服务器端的端口号,我们是自己手动指定。
     * 也就是说服务器必须要指定固定的端口,为了确保客户端能够随时找到服务器。
     * 而对于客户端来说,必须不指定固定端口。而是让系统随机分配空闲的端口,这样的目的是为了避免端口后出现冲突。
     * 我们客户端是运行在用户的电脑上的,那用户的电脑上有哪一些程序?这些程序使用了哪一些端口我们是不可控的,所以呢你不可以手动的去指定,万一它冲突了呢?
     * 因此我们呀才让操作系统来自己去分配一个空闲的端口。所以呢我们这里用无参构造new DatagramSocket()。
     * 而我们的服务器是在程序员手里的,上面运行了哪些程序?使用了哪些端口,我们是可控的。
     */
    public UdpEchoClient(String serverIp, int serverPort) throws SocketException {
        this.socket = new DatagramSocket();
        this.serverIp = serverIp;
        this.serverPort = serverPort;
    }

    public void start() throws IOException {
        //客户端我们也想让他持续运行
        //每循环一次,就从控制台读取一个输入的数据,再把这个数据发送到服务器,读取服务器的响应。
        while (true){
            //1.
            Scanner scanner = new Scanner(System.in);
            String request = scanner.nextLine();

            //2.构造请求发送给服务器。
            /**
             * 我们需要注意的是我们呢?在构造请求的时候,不仅是将输入的字符串变成字节数组,
             * 由于我们的udp是无连接传输的,所以呢你不知道要传给谁,因此啊我们得指定所要请求对端的服务器的IP和端口。
             * 那么我们服务器端的这个IP就是本机,也就是127.0.0.1。我们的端口号是9090。
             * 注意我们传IP地址和端口号的时候,对于IP地址,我们不可以直接传字符串serverIp。而是通过InetAddress.getByName将它进行一个封装成对象。
             * 通过InetAddress.getByName将它进行一个封装成对象。
             * 啊,为什么要这样呢?这是因为我们DatagramPacket它所能识别的就是将IP地址分割成对象,而不是字符串。==》可以翻文档或者AI
             */
            DatagramPacket requestPacket = new DatagramPacket(request.getBytes(), request.getBytes().length,
                    InetAddress.getByName(serverIp),serverPort);

            socket.send(requestPacket);

            //3.请求发送给服务器之后,我们来读取服务器给我们返回的响应。
            DatagramPacket responsePacket = new DatagramPacket(new byte[1024], 1024);
            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();
    }
}

我们实际上真正意义上的服务器通常是要有业务逻辑的,那么什么是业务逻辑呢?就是说你的产品或者说你的项目要解决什么问题?是怎么解决的?

因此呢接下来我们再写一个有逻辑的服务器,写一个什么样的服务器呢?我们写一个小案例叫做英译中的服务器。也就是请求发来英文单词,我们响应对应的中文翻译。

java 复制代码
package net.udp;

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

/**
 * @ClassName UdpDictServer
 * @Description TODO 英译中服务器
 * @Author zhongge
 * @Date 2026-01-30 12:00
 * @Version 1.0
 */
public class UdpDictServer extends UdpEchoServer{

    //字典【哈希表】
    private Map<String, String> dict = new HashMap<>();

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

        dict.put("hello", "你好");
        dict.put("cat", "小猫");
        dict.put("dog", "小狗");
        dict.put("pig", "小猪");
    }

    /**
     * 注意,咱们这里呢是这样的。这个服务器程序刚才UdpEchoServer逻辑是差不多的,
     * 好的所以我们使用继承来复用代码。
     * 唯一不同的是我们的这个process方法。
     */

    //重写 process方法
    /**
     * 我们要想通过英文来翻译成中文的话,我们得先让服务器知道你的英文是啥,然后它对应的中文是啥?
     * 我们去查这样的一个表。其实这种就是一种key value的形式,也就是说我们的键值对的形式,它好比就是一个哈希表。
     * 这个哈希表呢我们在构造方法中就已经提前给创建好了,它好比就是一个字典。
     */
    @Override
    protected String process(String request){
        //实现英文->中文
        return dict.getOrDefault(request,"【没有找到该单词】");
    }

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

由于服务器的工作流程一般都是固定的套路,因此呢业界有很多大佬基于这套流程去封装出一系列的框架。框架就是把固定的这些逻辑呢封装起来,我们就只需要去写类似于process这样的逻辑就好了。虽然以后开发当中呢我们经常会使用的框架,但是学习使用socket API还是非常之重要的,任何一个网络的框架底层一定会用到API。那么我们如果走的更远的话,很多时候我们是要去阅读源码的,甚至去魔改源码。而这些基础就是我们需要了解这些底层的API的一个基本流程。


TCP流套接字编程

和上述的有的UDP一样,我们先写一个回显服务器,我们再写一个英译中的服务器。

Tcp这里边呢它涉及到两个类,第一个是ServerSocket,第二个是Socket,那么在这里有两个socket,言外之意,他们都是网卡的代言人。我们去操作这些socket,就是在操作我们的网卡,唯一的区别就是ServerSocket是给服务器使用的,而Socket我们既能给客户端使用,也能给服务器端使用。

API介绍

和UDP数据报套接字编程的API设计逻辑类似,TCP流套接字编程核心依赖ServerSocket(服务端专属)和Socket(客户端/服务端通信专属)两个核心类,以下是详细API说明。

ServerSocket

ServerSocket 是创建TCP服务端Socket的专属API,用于服务端绑定端口、监听客户端连接请求。

构造方法

方法签名 方法说明
ServerSocket(int port) 创建一个服务端流套接字Socket,并绑定到指定端口

由于ServerSocket是给我们的服务器使用的,而我们服务器启动的时候需要指定一个具体的固定端口。因此呢这个类的构造方法呢也就只有一个,而且是带参数的构造方法,参数是端口号。

常用方法

方法签名 方法说明
Socket accept() 开始监听创建时绑定的端口,有客户端连接后,返回一个服务端Socket对象并基于该Socket建立与客户端的连接;若无客户端连接,该方法会阻塞等待
void close() 关闭此服务端套接字

accept是ServerSocket最关键的方法,它的作用是辅助完成连接的建立,为什么说他正要说他关键呢?因为我们的tcp是有连接的。我们在进行通信之前,tcp是需要先进行连接的,而我们的tcp连接地的流程是操作系统内核完成的,核心的逻辑不需要你做,但是有一点你是需要去做的,也就是说我们tcp的连接在操作系统内核完成之后,需要我们程序员通过accept把内核里面建立好的连接拿到应用程序中,然后才能通信。好比我们打电话,你只需要去拨号码,然后再按下接听键就可以了,那么这个accept就是按下接听键这样的一个效果,你只需要按这个接听键就行了,里面电话是怎么连接起来的这些细节你不用关心。

第二个方法是close方法,这个方法呢是用来释放资源的。那么回到我们刚才的udp里面去,你会发现我们的udp里面没有调用close这个方法。那是为什么呢?其实我们调用close目的是为了释放资源,也就是文件描述符表[链表]中把节点给删除掉,从而防止文件资源的泄露。但是我们的文件是否需要关闭,得需要我们考虑清楚这个文件对象的生命周期是怎么样的,那么此处udp的socket对象它是伴随着整个udp服务器自始至终的,因此UDP中的socket我们不能在服务器运行的时候就关闭它,如果服务器结束代表着进程结束,此时呢就会自动释放我们的文件描述符表中的所有资源,所以呢我们就不用手动去调用close方法。

Socket

Socket 既可以作为客户端Socket直接使用,也可以是服务端通过accept()方法接收到客户端连接请求后,返回的服务端通信Socket。
核心作用:双端建立连接后,保存对端的网络信息,同时作为双端收发数据的核心载体,实现字节流的双向传输。

构造方法

方法签名 方法说明
Socket(String host, int port) 创建一个客户端流套接字Socket,并与指定IP(host)的主机上、指定端口的进程建立TCP连接

由于我们的tcp是面向自己留的,因此呢它不像UDP一样,有DatagramPacket这个类,

同时需要注意的是,由于我们的tcp它不像udp一样提供receive和send的方法,因此呢它发送数据我们使用的是getInputStream和getOutputStream方法,通过这两个方法,我们一旦拿到了这两个流对象,那么后续的操作就是和文件io的操作是一样的。

常用方法

方法签名 方法说明
InetAddress getInetAddress() 返回此套接字所连接的对端主机地址
InputStream getInputStream() 返回此套接字的字节输入流,用于读取对端发送过来的数据
OutputStream getOutputStream() 返回此套接字的字节输出流,用于向对端发送数据

那么我们这里边呢有一个疑问:

ServerSocket中有accept方法,而这个方法返回的是Socket对象,而这个Socket的对象中有getInputStream和getInputStream对象我们通过这两个对象让服务端和客户端通信,那么你会不会觉得服务器端一下子用ServerSocket,一下子又用Socket?会不会觉得是多余的呢?

答:其实并不是的,我们举这样的一个例子,比如你去买房,你到街上去的时候会有一个房屋中介小哥,他们会拉你去看房,但是具体房屋的介绍并不是由他来完成,因为它的一个主要作用就是拉客。然后呢他会把你交给真正的我们房屋介绍女士,由她来详细给您介绍房屋的具体情况。也就是说呀,你完成看房这件事情,为我们服务的一个是拉客的小哥,另外一个是介绍具体业务逻辑的女士。所以呢我们这里ServerSocket和Socket他们也是一样的道理,他们是相辅相成的,一个是对外拉客,一个是实现具体逻辑的,我们这个ServerSocket呢它提供accept方法,用来接通我们的连接,再由我们的socket去完成具体的业务逻辑交互的过程。


代码:

回显服务器

java 复制代码
package net.tcp;

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

/**
 * @ClassName TcpEchoServer
 * @Description TODO 回显服务器
 * @Author zhongge
 * @Date 2026-02-01 14:45
 * @Version 1.0
 */
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){
            //我们的tcp不像udp一样,一上来就可以处理连接。
            // 我们tcp每次循环要处理一个连接,一个连接里面可能会有多种请求和响应。
            // 那么我们这里通过accept来建立这个连接。

            /**
             * accept就是等价于打电话接通
             * 1.如果有客户端给服务器打电话那么此时就直接接通
             * 2.如果还没有客户端给服务器打电话,那么此时accept就在等,等到客户端真打来电话的时候,然后才解除阻塞并接通。
             *
             * 这里面我们补充一点,我们tcp建立连接的流程,我们称之为三次握手。
             * 那么这个流程是我们操作系统的时候给我们处理好的,我们应用程序代码是感知不到的,你调用accept的方法,
             * 感受到有人打电话的时候,此时三次握手已经结束了,你只是做一个接通的作用。
             *
             */
            Socket socket = serverSocket.accept();

            //那么为什么返回的对象是Socket呢?
            // 因为通过这个对象,他有getInputString和getOutputStream这两个方法。
            // 通过这两个方法,我们获取到流对象,进一步和客户端通信。

            //处理一次连接
            processConnection(socket);
        }
    }

    private void processConnection(Socket socket) {
        //注意我们一个连接中可能会涉及多组请求和响应的交互。
        // 也就是说你打一次电话,可能说的话是很多很多,做的事情都很多。

        System.out.printf("[%s:%d] 客⼾端上线!\n", socket.getInetAddress().toString(),socket.getPort());

        try(InputStream inputStream = socket.getInputStream();
            OutputStream outputStream = socket.getOutputStream()
        ){

            //读取一个请求方法二使用Scanner
            Scanner scanner = new Scanner(inputStream);

            //一次循环呢,处理一组请求和响应。
            while (true){
                //1.读取请求并解析
                //2.根据请求计算出响应
                //3.把响应写给客户端

                /**
                 * 1.读取请求并解析
                 * 对于我们的udp来说,读取一个DatagramPacket就是一个请求。
                 * 而对于我们的TCP来说,是一次读一个字节吗,如果一个请求应该包含很多字节呢。具体这一个请求是多少个字节不到哪里是一个完整的请求???
                 * 这里我们为了明确哪里是一个请求的结束,我们就做出这样的一个约定:我们约定一个请求到底是多长?比如可以约定一个请求以\n结尾。【
                 * 其实这样的约定就是一种协议的思想
                 * 】
                 */



                //方法一:再使用一个循环,缺点就是太麻烦了,所以我们使用方法二
               /*
               StringBuffer request = new StringBuffer();
               while (true){
                    int b = inputStream.read();
                    char c = (char) b;
                    if (c == '\n'){
                        break;
                    }
                    request.append(b);
                }*/

                //方法二:我们使用Scanner读取数据
                /**
                 * Scanner它里边的一个参数是InputStream对象,
                 * 那么所以我们的scanner并不是和控制台绑定的。
                 * 而是和InputStream绑定的,InputStream可以给控制台,
                 * 那么也可以给我们的socket,还可以是硬盘文件.
                 *
                 * 况且呢我们的scanner中有一个next方法。这个next方法是以空白符作为结束标志的,这个空白符中有一个就是包括\n的
                 * 所以我们的next也是以\n作为结束标志的
                 */

                if (!scanner.hasNext()){
                    //判断当前tcp的连接是否断开了,如果断开的话,那么就没有必要再读取了
                    System.out.printf("[%s:%d] 客⼾端下线!\n", socket.getInetAddress().toString(),socket.getPort());
                    break;
                }
                String request =scanner.next();

                //2.根据请求计算出响应
                String response = process(request);

                /**
                 *  3.把响应写给客户端
                 *此处返回响应的时候,需要给响应也接上一个\n作为结尾。
                 * 那么客户端后续读的时候也要通过scanner.next 也要有明确的结束标记,才能读取具体是哪些字节是一个响应。
                 */
                response += "\n";
                outputStream.write(response.getBytes());//转为字节流写过去

                // ⽇志, 打印当前的请求详情.
                System.out.printf("[%s:%d] req: %s, resp: %s\n", socket.getInetAddress().toString(),socket.getPort(),
                        request, response);

            }
        }catch (IOException e){
            e.printStackTrace();
        }
    }

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


    //后端服务器程序入口
    public static void main(String[] args) throws IOException {
        TcpEchoServer tcpEchoServer = new TcpEchoServer(9090);
        tcpEchoServer.start();
    }
}

客户端

java 复制代码

问题1

我们前面说过socket的也是文件,所以呢需要被关闭,你还记得吗?我们写udp echo server的时候,我们是没有关闭操作的,因为呢udp这里的socket它的生命周期是跟着整个程序的,知道那么进程结束才可以不使用socket,而进程结束的时候,整个pcb也就释放掉了,因此呢我们就不再手动需要释放文件资源了。

而在我们tcp这里呢,tcp客户端的socket,是不需要单独关闭的,因为它的生命周期跟随整个进程。而我们的tcp服务器中ServerSocke对象t也是不需要单独关闭的,因为它的生命周期跟随整个进程。但是在我们的tcp服务器中,socket他并不是只有一个,而是有很多个,因为我们每一次连接都会创建一个socket对象,所以呢它是跟随tcp连接的,我们的连接断开,它就代表不使用这个socket,如果你的socket不去手动的关闭的话,那么它就会越来越多,从而占用文件资源。

所以最终需要加上释放资源的代码

java 复制代码
}finally {
            try {
                System.out.printf("[%s:%d] 客⼾端下线!\n", socket.getInetAddress().toString(),socket.getPort());
                socket.close();
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }

改善后的代码

java 复制代码
package net.tcp;

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

/**
 * @ClassName TcpEchoServer
 * @Description TODO 回显服务器
 * @Author zhongge
 * @Date 2026-02-01 14:45
 * @Version 1.0
 */
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){
            //我们的tcp不像udp一样,一上来就可以处理连接。
            // 我们tcp每次循环要处理一个连接,一个连接里面可能会有多种请求和响应。
            // 那么我们这里通过accept来建立这个连接。

            /**
             * accept就是等价于打电话接通
             * 1.如果有客户端给服务器打电话那么此时就直接接通
             * 2.如果还没有客户端给服务器打电话,那么此时accept就在等,等到客户端真打来电话的时候,然后才解除阻塞并接通。
             *
             * 这里面我们补充一点,我们tcp建立连接的流程,我们称之为三次握手。
             * 那么这个流程是我们操作系统的时候给我们处理好的,我们应用程序代码是感知不到的,你调用accept的方法,
             * 感受到有人打电话的时候,此时三次握手已经结束了,你只是做一个接通的作用。
             *
             */
            Socket socket = serverSocket.accept();

            //那么为什么返回的对象是Socket呢?
            // 因为通过这个对象,他有getInputString和getOutputStream这两个方法。
            // 通过这两个方法,我们获取到流对象,进一步和客户端通信。

            //处理一次连接
            processConnection(socket);
        }
    }

    private void processConnection(Socket socket) {
        //注意我们一个连接中可能会涉及多组请求和响应的交互。
        // 也就是说你打一次电话,可能说的话是很多很多,做的事情都很多。

        System.out.printf("[%s:%d] 客⼾端上线!\n", socket.getInetAddress().toString(),socket.getPort());

        try(InputStream inputStream = socket.getInputStream();
            OutputStream outputStream = socket.getOutputStream()
        ){

            //读取一个请求方法二使用Scanner
            Scanner scanner = new Scanner(inputStream);

            //一次循环呢,处理一组请求和响应。
            while (true){
                //1.读取请求并解析
                //2.根据请求计算出响应
                //3.把响应写给客户端

                /**
                 * 1.读取请求并解析
                 * 对于我们的udp来说,读取一个DatagramPacket就是一个请求。
                 * 而对于我们的TCP来说,是一次读一个字节吗,如果一个请求应该包含很多字节呢。具体这一个请求是多少个字节不到哪里是一个完整的请求???
                 * 这里我们为了明确哪里是一个请求的结束,我们就做出这样的一个约定:我们约定一个请求到底是多长?比如可以约定一个请求以\n结尾。【
                 * 其实这样的约定就是一种协议的思想
                 * 】
                 */



                //方法一:再使用一个循环,缺点就是太麻烦了,所以我们使用方法二
               /*
               StringBuffer request = new StringBuffer();
               while (true){
                    int b = inputStream.read();
                    char c = (char) b;
                    if (c == '\n'){
                        break;
                    }
                    request.append(b);
                }*/

                //方法二:我们使用Scanner读取数据
                /**
                 * Scanner它里边的一个参数是InputStream对象,
                 * 那么所以我们的scanner并不是和控制台绑定的。
                 * 而是和InputStream绑定的,InputStream可以给控制台,
                 * 那么也可以给我们的socket,还可以是硬盘文件.
                 *
                 * 况且呢我们的scanner中有一个next方法。这个next方法是以空白符作为结束标志的,这个空白符中有一个就是包括\n的
                 * 所以我们的next也是以\n作为结束标志的
                 */

                if (!scanner.hasNext()){
                    //判断当前tcp的连接是否断开了,如果断开的话,那么就没有必要再读取了
                    break;
                }
                String request =scanner.next();

                //2.根据请求计算出响应
                String response = process(request);

                /**
                 *  3.把响应写给客户端
                 *此处返回响应的时候,需要给响应也接上一个\n作为结尾。
                 * 那么客户端后续读的时候也要通过scanner.next 也要有明确的结束标记,才能读取具体是哪些字节是一个响应。
                 */
                response += "\n";
                outputStream.write(response.getBytes());//转为字节流写过去

                // ⽇志, 打印当前的请求详情.
                System.out.printf("[%s:%d] req: %s, resp: %s\n", socket.getInetAddress().toString(),socket.getPort(),
                        request, response);

            }
        }catch (IOException e){
            e.printStackTrace();
        }finally {
            try {
                System.out.printf("[%s:%d] 客⼾端下线!\n", socket.getInetAddress().toString(),socket.getPort());
                socket.close();
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }

    }

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


    //后端服务器程序入口
    public static void main(String[] args) throws IOException {
        TcpEchoServer tcpEchoServer = new TcpEchoServer(9090);
        tcpEchoServer.start();
    }
}



问题二

我们的一个服务器不仅仅是为一个客户端的服务,而我们的上述代码中,当你启动多个客户端的时候,我们的服务器只能感知到一个客户端上线,让我们尝试使用多个客户端访问服务器的时候,只有第一个客户端是成功的,而其他的客户端是不能提示上线的,也不能发送请求,那么此时我们将第一个客户端退出的时候,我们服务器就能感受到第二个客户端了,但是此时第三个客户端还是不行...也就是说咱们的服务器同一时刻只能处理一个请求,即只能处理一个客户端。

这样的原因其实是我们写代码的一个bug。

当客户端1连接成功之后,服务器就会进入到processConnection的循环之中,此时客户端1如果不发送请求,服务器就会阻塞在hasNext,如果客户端1发送请求,那么hasNext解除阻塞,然后继续执行执行完毕之后循环回到processConnection()方法中的循环头继续执行,再次执行scanner.hasNext()进入阻塞,等待该客户端的下一次请求。只要第一个客户端未下线,processConnection()的循环会一直执行,等待下一个请求。那么由于客户端退出,此时processConnection方法中循环才会结束,所以也就是说只要我们的客户端不退出,那么processConnection()方法里处理单个客户端请求的内层循环一直在转,导致主线程出不来,无法执行外层的accept(),因此我们没有办法来处理第二个客户端,服务器的代码在processConnection方法中不断的循环进行的时候,此时也就意味着就无法第二次执行到我们的accept【此时第二个连接在底层已经建立了,但是我们没有接通,也就是说电话呢已经可以连上了,但是我们没有接听】,也就是无法处理第二个客户端的请求。

其实呢这里最大的问题就是我们的accept和processConnection()方法中的hasNext()无法同时执行而已,那么你想让他同时执行,我们就怎么办呢?用多线程。

那么如何解决这个问题呢?我们使用多线程方案,主线程主要负责accept,每次有客户端连接上来的时候,创建新的线程,由新的线程来负责处理客户端的请求和响应processConnection()方法,有n个客户端,我就创建n个线程。此时我们的accept和processConnection()方法就可以同时运行了。

修改之后的代码如下:

java 复制代码
package net.tcp;

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

/**
 * @ClassName TcpEchoServer
 * @Description TODO 回显服务器
 * @Author zhongge
 * @Date 2026-02-01 14:45
 * @Version 1.0
 */
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){
   
            Socket socket = serverSocket.accept();

            //之前我们是直接调用,
//            processConnection(socket);

            //现在改成创建新的线程,由线程调用这个方法。
            Thread t = new Thread(() -> {
                processConnection(socket);
            });
            t.start();
        }
    }

    private void processConnection(Socket socket) {
        //注意我们一个连接中可能会涉及多组请求和响应的交互。
        // 也就是说你打一次电话,可能说的话是很多很多,做的事情都很多。

        System.out.printf("[%s:%d] 客⼾端上线!\n", socket.getInetAddress().toString(),socket.getPort());

        try(InputStream inputStream = socket.getInputStream();
            OutputStream outputStream = socket.getOutputStream()
        ){

            //读取一个请求方法二使用Scanner
            Scanner scanner = new Scanner(inputStream);

            //一次循环呢,处理一组请求和响应。
            while (true){
            
                if (!scanner.hasNext()){
                    //判断当前tcp的连接是否断开了,如果断开的话,那么就没有必要再读取了
                    break;
                }
                String request =scanner.next();

                //2.根据请求计算出响应
                String response = process(request);

                
                response += "\n";
                outputStream.write(response.getBytes());//转为字节流写过去

                // ⽇志, 打印当前的请求详情.
                System.out.printf("[%s:%d] req: %s, resp: %s\n", socket.getInetAddress().toString(),socket.getPort(),
                        request, response);

            }
        }catch (IOException e){
            e.printStackTrace();
        }finally {
            try {
                System.out.printf("[%s:%d] 客⼾端下线!\n", socket.getInetAddress().toString(),socket.getPort());
                socket.close();
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }

    }

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


    //后端服务器程序入口
    public static void main(String[] args) throws IOException {
        TcpEchoServer tcpEchoServer = new TcpEchoServer(9090);
        tcpEchoServer.start();
    }
}

启动三个客户端

客户端:


上述的代码我们说解决了多个客户端访问的问题,但是呢又引入了新的问题,就是说当前这个代码可能会频繁的创建线程,那么服务器同时可能会有很多的客户端,如果你频繁的创建和销毁线程可能会影响我们的效率。
因此呢我们可以使用线程池来解决这个问题【这个也是我们的线程池的应用场景】

修改之后的代码:

java 复制代码
package net.tcp;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.Scanner;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
 * @ClassName TcpEchoServer
 * @Description TODO 回显服务器
 * @Author zhongge
 * @Date 2026-02-01 14:45
 * @Version 1.0
 */
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){
    
            Socket socket = serverSocket.accept();


            //之前我们是直接调用,
//            processConnection(socket);

            //现在改成创建新的线程,由线程调用这个方法。
            //我们改为使用线程池
//            Thread t = new Thread(() -> {
//                processConnection(socket);
//            });
//            t.start();
            
            //现在我们使用线程池
            service.submit(() -> {
                processConnection(socket);
            });
        }
    }

    private void processConnection(Socket socket) {
        //注意我们一个连接中可能会涉及多组请求和响应的交互。
        // 也就是说你打一次电话,可能说的话是很多很多,做的事情都很多。

        System.out.printf("[%s:%d] 客⼾端上线!\n", socket.getInetAddress().toString(),socket.getPort());

        try(InputStream inputStream = socket.getInputStream();
            OutputStream outputStream = socket.getOutputStream()
        ){

            //读取一个请求方法二使用Scanner
            Scanner scanner = new Scanner(inputStream);

            //一次循环呢,处理一组请求和响应。
            while (true){
                //1.读取请求并解析
                //2.根据请求计算出响应
                //3.把响应写给客户端

                if (!scanner.hasNext()){
                    //判断当前tcp的连接是否断开了,如果断开的话,那么就没有必要再读取了
                    break;
                }
                String request =scanner.next();

                //2.根据请求计算出响应
                String response = process(request);

           
                response += "\n";
                outputStream.write(response.getBytes());//转为字节流写过去

                // ⽇志, 打印当前的请求详情.
                System.out.printf("[%s:%d] req: %s, resp: %s\n", socket.getInetAddress().toString(),socket.getPort(),
                        request, response);

            }
        }catch (IOException e){
            e.printStackTrace();
        }finally {
            try {
                System.out.printf("[%s:%d] 客⼾端下线!\n", socket.getInetAddress().toString(),socket.getPort());
                socket.close();
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }

    }

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


    //后端服务器程序入口
    public static void main(String[] args) throws IOException {
        TcpEchoServer tcpEchoServer = new TcpEchoServer(9090);
        tcpEchoServer.start();
    }
}

服务器效果

客户端效果:


查字典服务器

java 复制代码
package net.tcp;

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

/**
 * @ClassName TcpDictServer
 * @Description TODO 英译中服务器
 * @Author zhongge
 * @Date 2026-02-02 14:43
 * @Version 1.0
 */
public class TcpDictServer extends TcpEchoServer{
    private Map<String, String > dict = new HashMap<>();
    public TcpDictServer(int port) throws IOException {
        super(port);

        dict.put("hello", "你好");
        dict.put("cat", "小猫");
        dict.put("dog", "小狗");
        dict.put("pig", "小猪");
    }

    @Override
    protected String process(String request) {
        //实现英文->中文
        return dict.getOrDefault(request,"【没有找到该单词】");
    }

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



【补充:如何启动多个客户端?操作如下:】

相关推荐
学习的周周啊2 小时前
ClawdBot(openclaw) + Cloudflare Tunnel + Zero-Trust 零基础保姆教程
网络·人工智能·python·clawdbot
会员源码网2 小时前
交易所 K 线模块无法启动?核心源码排查位置与实战解决方案
网络·elasticsearch
星夜落月2 小时前
从零部署Wallos:打造专属预算管理平台
服务器·前端·网络·建站
郝学胜-神的一滴2 小时前
Linux网络编程之Socket函数:构建通信的桥梁
linux·服务器·网络·c++·程序人生
阿钱真强道2 小时前
11 JetLinks MQTT 直连设备功能调用完整流程与 Python 实现
服务器·开发语言·网络·python·物联网·网络协议
小学导航员2 小时前
VMWARE虚拟机上不了网络
服务器·网络·php
zt1985q3 小时前
本地部署静态网站生成工具 Vuepress 并实现外部访问
运维·服务器·网络·数据库·网络协议
万法若空3 小时前
U-Boot命令手册
网络·u-boot
艾佳者3 小时前
Cookie、Session、Token 三者核心区别(易懂版)
网络