一、Socket套接字

1. 什么是Socket?

Socket(套接字)是系统提供的网络通信技术,是基于TCP/IP协议的网络编程基本操作单元。Socket就像通信端口,进程通过这个端口发送和接收数据,实现跨设备/跨进程通信。

2. Socket中两个核心协议

(1)流套接字(基于TCP协议)

  • 有连接:通信前必须建立连接;
  • 可靠传输:数据不会丢失、重复,且按发送顺序到达;
  • 面向字节流:以字节为单位读取数据,这样就支持任意长度,但也带来了“粘包问题”。数据无边界,可多次发送、分多次接收;
  • 双缓冲区:有发送缓冲区和接收缓冲区,数据先存入缓冲区再传输;
  • 大小不限:可传输任意大小的数据。

(2)数据报套接字(基于UDP协议)

  • 无连接:通信前无需建立连接,直接发送数据;
  • 不可靠传输:数据可能丢失、重复或乱序;
  • 面向数据报:以一个数据报为单位,一次必须读写一个数据报,而不能是半个;
  • 仅接收缓冲区:无发送缓冲区,数据直接发送;
  • 大小受限:一次最多传输64KB数据。

二、UDP数据报套接字编程

UDP是无连接协议,编程流程相对简单,核心类为DatagramSocket(套接字)和DatagramPacket(数据报)。

1. 核心API

(1)DatagramSocket(UDP套接字)

用于发送和接收UDP数据报,关键构造方法和方法:

构造方法/方法 说明
DatagramSocket() 创建客户端套接字,绑定本机随机端口
DatagramSocket(int port) 创建服务端套接字,绑定本机指定端口
void receive(DatagramPacket p) 阻塞接收数据报,无数据则等待
void send(DatagramPacket p) 发送数据报,无需等待直接发送
void close() 关闭套接字,释放资源

(2)DatagramPacket(UDP数据报)

封装UDP传输的数据、目标IP和端口,关键构造方法和方法:

构造方法/方法 说明
DatagramPacket(byte[] buf, int length) 用于接收数据,数据存入buf数组,接收长度为length
DatagramPacket(byte[] buf, int offset, int length, SocketAddress address) 用于发送数据,buf为发送数据,address指定目标IP和端口
InetAddress getAddress() 获取发送端/接收端IP地址
int getPort() 获取发送端/接收端端口号
byte[] getData() 获取数据报中的数据

(3)InetSocketAddress

创建包含IP和端口的Socket地址,构造方法:InetSocketAddress(InetAddress addr, int port)

2.UDP回显服务器(Echo Server)

服务端代码(UdpEchoServer.java)

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

public class UDPEchoServer {
    private DatagramSocket socket = null;

    //创建socket对象,代表网卡文件
    //读这个文件就等于从网卡中收数据,写这个文件就等于让网卡发数据
    public UDPEchoServer(int port) throws SocketException {
        socket = new DatagramSocket(port);//指定端口号
    }

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

        while(true){
            //循环一次相当于一次请求
            //处理请求时,典型的服务器分为三个步骤:

            //1、读取请求并解析

            //    DatagramPacket 相当于一个UDP数据报,此处传入字节数组,就保存UDP数据报的载荷部分
            //    其中包括报头+载荷(这里通过 new 字节数组来保存)
            DatagramPacket requestPacket = new DatagramPacket(new byte[4096], 4096);
            socket.receive(requestPacket);//输出型参数

            //    把UDP数据包载荷(二进制数据)取出来转为字符串:
            //     1)通过requestPacket.getData()拿到DatagramPacket中的字节数组
            //     2)通过requestPacket.getLength()拿到有效数据的长度
            //     3)根据字节数组,构造出一个String
            String request = new String(requestPacket.getData(),0,requestPacket.getLength());

            //2、根据请求计算相应(也是服务器的核心逻辑)
            String response = process(request);

            //3、把相应返回给客户端

            //    根据response构造DatagramPacket,这里是响应数据报,发送客户端
            //     1)通过response.getBytes()拿到字节数组
            //     2)response.getBytes().length拿到字节数组长度
            //          此处不能使用response.length,因为response.length拿到的是字符数组的长度,单位是字符
            //     3) requestPacket.getSocketAddress()拿到客户端的IP和端口号(数据从哪里来就回到哪里去)
            DatagramPacket responsePacket = new DatagramPacket(response.getBytes(),
                    response.getBytes().length,
                    requestPacket.getSocketAddress());

            //依据responsePacket中的IP和目的端口发送数据
            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;
    }

    static void main() {
        
    }
}

思考:

  1. socket是否需要关闭?
    这需要从socket的生命周期来考虑问题:这里的socket对象自始至终伴随UDP服务器;如果服务器关闭(进程结束),进程结束时会自动释放PCB的文件描述符表中所有资源,也就不需要手动close
  2. 在客户端请求过来之前,服务器都在干啥?
    recieve会触发阻塞行为,直到客户端的请求发来,recieve才会返回

客户端代码(UdpEchoClient.java)

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

public class UDPEchoClient {
    private DatagramSocket socket;
    //UDP本身不保存对端信息,需要在代码中保存
    private String serverIp ;
    private int serverPort;

    //在构造函数中指定访问的服务器地址
    public UDPEchoClient(String serverIp, int serverPort) throws SocketException {
        this.serverIp = serverIp;
        this.serverPort = serverPort;
        //这里必须使用无参的构造方法,由操作系统分配空闲端口
        socket = new DatagramSocket();
    }

    public void start() throws IOException {
        Scanner scan = new Scanner(System.in);

        while(true){
          //1.从控制台读取
          System.out.println("请输入要发送的内容:");
          String request = scan.next();

          //2.把请求发送给服务器
          //    构造过程不光要构造载荷,也要设置服务器IP和端口号
          //    IP是通过字符串来构造的,通过InetAddress.getByName(serverIp)把字符串转为Java标注库可识别的对象
          DatagramPacket requestPacket = new DatagramPacket(
                  request.getBytes(),
                  request.getBytes().length,
                  InetAddress.getByName(serverIp),
                  serverPort);


          //3.发送数据报
          socket.send(requestPacket);

          //4.接受服务器响应
          DatagramPacket responsePacket = new DatagramPacket(new byte[1024], 1024);
          socket.receive(responsePacket);


          //5.从服务器中读取数据进行解析并打印
          String response = new String(responsePacket.getData(), 0, responsePacket.getLength());
          System.out.println(response);

      }
    }

    static void main() throws IOException {
        UDPEchoClient client = new UDPEchoClient("127.0.0.1",9090);
        client.start();
    }
}

与服务器的逻辑相同,客户端的socket跟随整个进程,没有必要手动释放资源

三、TCP流套接字编程

TCP是有连接协议,通信前需建立连接(三次握手),通信后需关闭连接(四次挥手),可靠性更高,适合对数据传输稳定性要求高的场景(文件传输、登录验证)。

1. 核心API介绍

(1)ServerSocket(TCP服务端套接字)

用于创建TCP服务端,监听客户端连接,关键构造方法和方法:

构造方法/方法 说明
ServerSocket(int port) 创建服务端套接字,绑定指定端口
Socket accept() 阻塞监听客户端连接,连接成功返回与客户端通信的Socket
void close() 关闭服务端套接字

(2)Socket(TCP通信套接字)

客户端套接字,或服务端accept方法返回的通信套接字,用于收发数据,关键构造方法和方法:

构造方法/方法 说明
Socket(String host, int port) 客户端创建套接字,与指定IP和端口的服务端建立连接
InputStream getInputStream() 获取输入流,读取对端数据
OutputStream getOutputStream() 获取输出流,向对端发送数据
InetAddress getInetAddress() 获取对端IP地址
void close() 关闭套接字,释放连接

2. TCP回显服务器

TCP通信流程:服务端监听端口→客户端连接→建立连接后收发数据→关闭连接。

服务端代码(TcpEchoServer.java)

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 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){
            //与UDP不同,TCP需要先处理客户端发来的连接
            //accept类似于“接通电话”的操作
            //通过读写 clientSocket来和客户端进行通信
            Socket clientSocket = serverSocket.accept();
            processConnection(clientSocket);
        }
    }

    private void processConnection(Socket clientSocket) throws IOException {
        System.out.printf("[%s,%d] 客户端上线",
                clientSocket.getInetAddress(),
                clientSocket.getPort());

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

            //针对InputStream套了一层
            Scanner scan = new Scanner(inputStream);
            //针对OutputStream套了一层
            PrintWriter writer = new PrintWriter(outputStream);

            while(true){
                //1.读取请求并解析
                if(!scan.hasNext()){
                    //没有下一条数据,说明连接断开
                    System.out.printf("[%s:%d]",
                            clientSocket.getInetAddress(),
                            clientSocket.getPort());
                    break;
                }
                String request = scan.next();

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

                //3.返回响应到客户端
                //println这个操作只是把数据放进发送缓冲区(内存中),并没有真正地写入网卡
                writer.println(response);
                writer.flush();


                //4.打印日志
                System.out.printf("[%s:%d] req:%s,resp:%s",
                        clientSocket.getInetAddress(),
                        clientSocket.getPort(),
                        request, response);
            }

        } catch (IOException e) {
            throw new IOException(e);
        }
    }

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

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

客户端代码(TcpEchoClient.java)

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 {
    Socket clientSocket = null;
    TCPEchoClient(String serverIP,int serverPort) throws IOException {
        //TCP创建socket对象时,就会在底层和对端建立TCP的连接(记录对端信息)
        //IP和端口这样的信息不需要自己创建变量保存
        Socket socket = new Socket(serverIP,serverPort);

    }

    void start(){
        Scanner scan = new Scanner(System.in);
        try(InputStream inputStream = clientSocket.getInputStream();
            OutputStream outputStream = clientSocket.getOutputStream()) {

            Scanner scannerNet = new Scanner(inputStream);
            PrintWriter writer = new PrintWriter(outputStream);

            while(true){
                //1.从控制台读取用户输入
                String request = scan.next();

                //2.发送给服务器
                //println这个操作只是把数据放进发送缓冲区(内存中),并没有真正地写入网卡
                writer.println(request);
                writer.flush();

                //3.读取服务器给出的响应
                String response = scannerNet.next();

                //4.打印到控制台
                System.out.println(response);

            }
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}

注意:

  1. 服务端中使用的serverSocket与客户端使用的socket对象绝对不是同一个对象,但是存在一定关联。这两个socket可以理解为两部电话,从A话筒说话,那么B的话筒也可以听到。
  2. 服务端通过scan.hasNext()来判断是否有下一条数据,就是通过判断是否包括“空白符”(换行,回车,空格,制表符,翻页符…)。遇到空白符才认为是完整的next,否则会一直阻塞。因此客户端发送数据时(writer.println(request))必须使用prinln函数,而不能使用print函数。这样就暗中约定好,一个请求/响应使用\n作为结束标记,对端读取时,读到\n结束。
  3. 服务端中的clientSocket只对应一个客户端的请求,当一个客户端请求结束时,它的生命周期也就结束了,此时需要手动释放。

3. TCP服务端支持多客户端(线程池优化)

服务端存在一个问题:当连接客户端1,而客户端1没有发送请求,服务端会一直阻塞在hasNext(),这也意味着accept()不能被执行,除非客户端1断开连接。这说明当前代码无法同时等待accept和用户请求。

解决方案是为每个客户端分配独立线程,或使用线程池减少线程创建销毁的开销。

多线程版服务端

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.lang.reflect.Executable;
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("服务器启动");
        //这种情况不使用fixedThreadPool,因为会固定客户端连接数目
        ExecutorService executorService = Executors.newCachedThreadPool();

        while(true){
            //与UDP不同,TCP需要先处理客户端发来的连接
            //accept类似于“接通电话”的操作
            //通过读写 clientSocket来和客户端进行通信
            
            // 主线程负责进行 accept, 每次 accept 到一个客户端, 就创建一个线程, 由新线程负责处理客户端的请求.
            Socket clientSocket = serverSocket.accept();

            //使用多线程优化
//            Thread t = new Thread(()->{
//                try {
//                    processConnection(clientSocket);
//                } catch (IOException e) {
//                    throw new RuntimeException(e);
//                }
//            });
//            t.start();
            
            //使用线程池优化
            executorService.submit(()->{
                try {
                    processConnection(clientSocket);
                } catch (IOException e) {
                    throw new RuntimeException(e);
                }
            });
        }
    }

    private void processConnection(Socket clientSocket) throws IOException {
        System.out.printf("[%s,%d] 客户端上线\n",
                clientSocket.getInetAddress(),
                clientSocket.getPort());

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

            //针对InputStream套了一层
            Scanner scan = new Scanner(inputStream);
            //针对OutputStream套了一层
            PrintWriter writer = new PrintWriter(outputStream);

            while(true){
                //1.读取请求并解析
                if(!scan.hasNext()){
                    //没有下一条数据,说明连接断开
                    System.out.printf("[%s:%d]:客户端下线\n",
                            clientSocket.getInetAddress(),
                            clientSocket.getPort());
                    break;
                }
                String request = scan.next();

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

                //3.返回响应到客户端
                writer.println(response);
                writer.flush();


                //4.打印日志
                System.out.printf("[%s:%d] req:%s,resp:%s \n",
                        clientSocket.getInetAddress(),
                        clientSocket.getPort(),
                        request, response);
            }

        } catch (IOException e) {
            throw new IOException(e);
        }finally {
            clientSocket.close();
        }
    }

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

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

四、TCP长短连接

TCP连接的生命周期决定了是长连接还是短连接,两者适用场景不同:

短连接

  • 逻辑:每次请求-响应后,立即关闭连接;
  • 特点:每次通信都要建立/关闭连接,耗时略高;
  • 适用场景:客户端请求频率低的场景,例如浏览网页、查询天气。

长连接

  • 逻辑:建立连接后不关闭,持续收发数据;
  • 特点:仅第一次建立连接,后续通信效率高;支持服务端主动推送数据;
  • 适用场景:通信频繁的场景,例如聊天室、实时游戏、股票行情。

注意:

  • 基于BIO(同步阻塞IO)的长连接会占用大量线程资源,高并发场景下建议使用NIO(同步非阻塞IO)实现长连接,提升性能。

  • 端口被占用问题
    如果启动服务端时出现java.net.BindException: Address already in use,说明端口已被其他进程占用,解决方案:

    1. 关闭占用端口的进程:通过netstat -ano | findstr 端口号(Windows)或lsof -i:端口号(Linux/Mac)查找进程PID,再通过任务管理器/kill命令关闭;
    2. 修改服务端绑定的端口,使用未被占用的端口。
Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐