网络编程入门:构建你的第一个客户端-服务器应用

发布于:2025-09-13 ⋅ 阅读:(22) ⋅ 点赞:(0)

目录

前言

一、网络的基础概念

1. 局域网和广域网

2. 五元组

3. 协议分层

4. 封装和分用

1. 封装的过程

2. 分用的过程

3. 数据包在网络上的传输

二、网络编程

1. 重要概念

2. UDP 编程

1. UDP 的核心 API

2. UDP 回显服务器和客户端的实现

3. 词典服务器的模拟实现

3. TCP 编程

1. TCP 的核心 API

2. TCP 回显服务器和客户端的实现


前言

本文介绍了网络编程的基础概念和实现方法。主要内容包括:1.网络基础概念,如局域网/广域网、五元组、协议分层及封装分用过程;2.UDP编程,详细说明了UDP的特性、核心API,并实现了回显服务器和词典服务器;3.TCP编程,介绍了TCP的特点、核心API,实现了回显服务器和客户端,讨论了多线程和线程池优化方案。文章通过具体代码示例,展示了如何使用Java进行UDP和TCP网络编程,并比较了两种协议的特点和适用场景。


一、网络的基础概念

1. 局域网和广域网

网络分为局域网(LAN)和广域网(WAN);

组建局域网和广域网需要用到路由器或者交换机;

交换机上的口都是等价的,所有接到交换机上的网络设备就组成了一个局域网;

路由器上的口分为两种,一种 LAN 口,用于接网络设备;,一种 WAN 口,用于接运营商的网络;

区别:交换机工作在数据链路层,路由器工作在网络层;

2. 五元组

IP 地址:

描述了一个设备在网络上的位置;

本质上是一个四字节的整数,往往通过点分十进制的方式进行表示,例如:192.168.0.100;

端口号:

描述了主机上的某个应用程序;

主机上有很多应用程序,端口号用于区分哪个应用程序;

每个应用程序在网络通信时,都需要有一个端口号,端口号可以是手动指定的,也可以是系统自动分配的;

一次网络通信的过程中(5元组):

涉及两个 IP 地址和两个端口号:

  • 源 IP:描述了信息的发送方,是网络上的哪台主机;
  • 源端口号:描述了信息的具体发送方是哪个进程;
  • 目的 IP:描述了信息的接收方,是网络上的哪台主机;
  • 目的端口号:描述了信息的具体接收方是哪个进程;
  • 协议:标识发送进程和接收进程双方约定好的数据格式;

3. 协议分层

TCP/IP 五层网络协议:

  • 应用层:进程拿到数据后,用于解决实际问题;
  • 传输层:关注网络数据包的起点和终点,也称为端到端之间的传输;
  • 网络层:关注起点和终点之间的路径规划;
  • 数据链路层:负责相邻两个节点间的传输;
  • 物理层:通信基础设施,纯硬件设施;

对于一台主机,操作系统内核实现了传输层到物理层的内容,也就是 TCP/IP 五层模型的下四层;

对于一台路由器,它实现了网络层到物理层,也就是 TCP/IP 的下三层;

对于一台交换机,它实现了数据链路层到物理层,也就是 TCP/IP 五层模型的下两层;

协议分层的意义:

  • 庞大复杂的协议不利于学习和维护,因此拆分成多个功能单一的协议,更有利于学习和维护;
  • 上层协议直接调用下层协议即可,不必关注下层协议的实现细节;
  • 某一层的协议发生替换后,对其他层不产生影响;

4. 封装和分用

1. 封装的过程

1. 在应用层,应用程序按照应用层的协议将数据包构造好;

2. 数据包构造好,应用程序调用操作系统提供的 API,把这个数据包交给传输层;

3. 传输层会把上述数据包作为一个整体(载荷),凭接上传输层的报头,构造成一个新的数据包;

4. 传输层把数据包构造好后,将数据包交给网络层;

5. 网络层会把传输层的数据包作为一个整体(载荷),凭借上网络层的报头,构造成一个新的数据包;

6. 数据包构造好后,会把数据包交给数据链路层;

7. 数据链路层会把网络层的数据包作为一个整体(载荷),前面拼接上以太网的帧头,后面拼接上以太网的帧尾,构造成一个新的数据包;

8. 数据包构造好后,将数据包交给物理层;

9. 物理层将数据包转化为光信号或电信号,在网络上进行传输;

2. 分用的过程

1. 物理层接收到光信号或者电信号后,将物理信号转化为数字信号,交给数据链路层;

2. 数据链路层接收到数据包后,按照以太网帧格式进行解析,取出其中载荷,将载荷交给网络层;

3. 网络层接收到数据包后,按照网络层协议进行解析,取出其中载荷,将载荷交给传输层;

4. 传输层接收到数据包后,按照传输层协议进行解析,取出其中载荷,将载荷交给应用层;

5. 应用程序拿到数据包后,按照应用层的协议进行解析,拿到发送方发送的数据;

3. 数据包在网络上的传输

数据包封装后,往往不是立马传到接收方的网卡上,而是需要在网络中经过多个节点,最终到达接收方;

网络中的节点有交换机,也有路由器;

经过交换机:

1. 经过交换机的时候,数据包就会在交换机分用,交换机一般分用到数据链路层,即按照以太网协议解析,拿到载荷;

2. 拿到载荷后,再根据以太网帧头中的信息,将数据包重新封装,即加上以太网的帧头和帧尾,交给物理层;

3. 物理层将数字信号转化为光信号或者电信号,在网络上继续传输;

经过路由器:

1. 经过路由器的时候,数据包则会在路由器分用,路由器通常工作在网络层,即先按照以太网协议解析,拿到载荷,再交给网络层;

2. 网络层拿到数据后,按照网络层协议解析,拿到载荷;

3. 拿到载荷后,再根据原来网络层报头中的信息,重新将数据包封装,即拼接上网络层的报头,交给数据链路层;

4. 数据链路层拿到数据包后,根据原本数据链路层以太网帧头中的信息,再将数据包作为整体重新封装,即拼接上以太网的帧头和帧尾,交给物理层;

5. 物理层将数字信号转化为光信号或者电信号,在网络上继续传输;

二、网络编程

1. 重要概念

客户端:主动发起请求的一方;

服务器:被动接收,返回响应的一方;

客户端发给服务器的数据,称为请求(request);

服务器返回给客户端的数据,称为响应(response);

客户端和服务器交互有多种模式:

1. 一问一答:一个请求对应一个响应,在网站开发中比较常见;

2. 一问多答:一个请求对应多个响应,在下载的场景中比较常见;

3. 多问一答:多个请求对应一个响应,在上传的场景中比较常见;

4. 多问多答:多个请求对应多个响应,在远程时比较常见;

2. UDP 编程

网络编程需要使用系统的 API,本质上是传输层提供的;

传输层涉及两个协议,一个是 UDP,一个是TCP;

TCP 协议的特点:有连接、可靠传输、面向字节流、全双工;

UDP 协议的特点:无连接,不可靠传输、面向数据报,全双工;

操作系统有一类特殊的文件,即 socket 文件,针对 socket 文件读写,就是借助网卡发送和接收数据;

1. UDP 的核心 API

UDP 协议有两个核心的 API,分别是 DatagramSocket 和 DatagramPacket;

DatagramSocket():创建一个 UDP 数据报的 socket,绑定到本机的任意端口,通常应用于客户端;

DatagramSocket(int port):创建一个 UDP 数据报的 socket,绑定到本机指定的端口,通常应用于服务器;

服务器必须要手动指定端口号,因为客户端访问服务器需要提前知道服务器的端口,如果服务器随机指定端口号,客户端不知道服务器的端口号就无法访问;

void receive(DatagramPacket p):从 socket 接收数据报,如果没有数据报,该方法会阻塞等待;

void send(DatagramPacket p):从 socket 发送数据报;

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

DatagramPacket:UDP是面向数据报的,每次发送接收数据的基本单位,就是一个 UDP 数据报,DatagramPacket 就表示了一个 UDP 数据报;

2. UDP 回显服务器和客户端的实现

服务器的逻辑:

  1. 服务器是被动等待的一方,需要等到客户端发送请求;
  2. 服务器接收到请求后,需要解析请求,并根据请求计算响应;
  3. 响应计算完成之后,将响应按照 DatagramPacket 的形式进行封装,发送给客户端;

回显服务器:

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 {
        this.socket = new DatagramSocket(port);
    }

    public void start() throws IOException {
        System.out.println("服务器启动!");
        while(true){
            // 1. 创建 DatagramPacket 对象,用于接收客户端的请求
            DatagramPacket requestDatagram = new DatagramPacket(new byte[4096], 4096);
            // 2. 等待接收客户端的请求
            socket.receive(requestDatagram);
            // 3. 解析客户端请求
            String request = new String(requestDatagram.getData(), 0, requestDatagram.getLength());
            // 4. 接收到客户端的请求后,根据请求计算响应
            String response = process(request);
            // 5. 根据响应,构建 DatagramPacket 用于返回响应
            DatagramPacket responseDatagram = new DatagramPacket(response.getBytes(), response.getBytes().length,
                    requestDatagram.getSocketAddress());
            // 6. 返回响应
            socket.send(responseDatagram);
            System.out.printf("[%s %d] request: %s, response: %s",
                    requestDatagram.getAddress().toString(), requestDatagram.getPort(), request, response);
            System.out.println();
        }
    }

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

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

可以看到,UDP 协议每次在收发数据的时候,都需要传入IP 地址和端口号,因此 UDP 是无连接的;

客户端的逻辑:

  1. 等待用户输入请求;
  2. 将请求按照 DatagramPacket 的形式进行封装,并发送给服务器,等待服务器返回响应;
  3. 拿到服务器响应后进行解析;

客户端:

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 {
        socket = new DatagramSocket();
        this.serverIp = serverIp;
        this.serverPort = serverPort;
    }

    private void start() throws IOException {
        System.out.println("客户端启动!");
        Scanner in = new Scanner(System.in);
        while(true){
            // 1. 等待用户进行输入
            System.out.print("-> ");
            if(!in.hasNext()){
                break;
            }
            String request = in.next();
            // 2. 根据用户输入,构造 DatagramPacket 对象
            DatagramPacket requestPacket = new DatagramPacket(request.getBytes(), request.getBytes().length,
                    InetAddress.getByName(serverIp), serverPort);
            // 3. 将构造好的请求发送给服务器
            socket.send(requestPacket);
            // 4. 构造 DatagramPacket 用于接收服务器响应
            DatagramPacket responsePacket = new DatagramPacket(new byte[4096], 4096);
            // 5. 接收服务器返回的响应
            socket.receive(responsePacket);
            // 6. 解析响应,并打印
            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();
    }
}

3. 词典服务器的模拟实现

基于上述 UDP 回显服务器,可以实现一个带有一点业务逻辑的词典服务器,步骤如下:

  1. 继承 UDP 回显服务器;
  2. 重写 UDP 回显服务器的 process() 方法(业务逻辑);

代码如下:

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("cat", "猫咪");
        dict.put("dog", "狗子");
        // 此处可以无限添加英汉键值对
        // 专业的词典程序本质上也是有一个非常大的,很多键值对的 HashMap
    }

    // start() 方法可以继承父类的,因为执行逻辑一样
    // process() 需要重写,把查词典的逻辑加进去
    @Override
    public String process(String request) {
        if(!dict.containsKey(request)){
            return "您要查询的单词未收录~";
        }
        return dict.get(request);
    }

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

3. TCP 编程

1. TCP 的核心 API

ServerSocket:这是 Socket 类,对应到网卡,这个类只能给服务器使用;

Socket:对应到网卡,既可以给服务器使用,又可以给客户端使用;

TCP 是面向字节流的,传输的基本单位是字节;

TCP 是有连接的:

  • 服务器调用 accept() 方法,服务器内核就会等待客户端请求,完成创建连接的工作;
  • 客户端调用 Socket(String serverIp, int port) 方法,内核会主动发送请求,建立 TCP 连接;

accept() 方法可能会产生阻塞,没有客户端请求,就会阻塞等待;

有客户端请求,accept() 就会返回一次,多个客户端请求,accept() 就需要返回多次;

2. TCP 回显服务器和客户端的实现

TCP 服务器的逻辑:

  1. 等待客户端发送请求,创建 TCP 连接;
  2. TCP 连接创建好后,等待客户端发送业务请求;
  3. 解析请求并计算响应并返回;

代码如下:

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){
            // 等待客户端端建立连接
            Socket clientSocket = serverSocket.accept();
            ProcessConnection(clientSocket);
        }
    }

    private void ProcessConnection(Socket clientSocket) {
        System.out.printf("[%s %d] 客户端上线!\n", clientSocket.getInetAddress(), clientSocket.getPort());
        try(InputStream inputStream = clientSocket.getInputStream();
            OutputStream outputStream = clientSocket.getOutputStream()){
            Scanner in = new Scanner(inputStream);
            PrintWriter out = new PrintWriter(outputStream);
            while(true){
                // 1. 等待客户端发送请求
                if(!in.hasNext()){
                    break;
                }
                String request = in.next();
                // 2. 根据请求计算响应
                String response = process(request);
                // 3. 返回响应
                out.println(response);
                out.flush();
                System.out.printf("[%s %d] request: %s response: %s\n",
                        clientSocket.getInetAddress(), clientSocket.getPort(), request, response);
            }

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

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

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

注意:

每个客户端创建请求都会创建一个 clientSocket,当客户端断开连接的时候,务必要关闭;否则会造成文件描述符表泄露;

TCP 是有连接的,服务器会保存客户端的信息,发送信息不需要指定客户端的 IP 地址和端口号;

TCP 是面向字节流的,每个 Socket 都会有一个 InputStream 和 一个 OutputStream;

使用 PrintWriter 包裹 OutputStream 时,数据都是写在内存缓冲区的,因此写完之后还要调用 flush() 方法,才能将数据写到网卡发送出去;

客户端的逻辑:

  1. 主动发起请求,建立 TCP 连接;
  2. 等待用户输入,生成请求;;
  3. 向服务器发送请求,等待响应;
  4. 接收响应并解析;

代码如下:

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 = new Socket(serverIp, serverPort);
    }

    private void start(){
        System.out.println("客户端启动!");
        try(InputStream inputStream = socket.getInputStream();
            OutputStream outputStream = socket.getOutputStream()){
            Scanner in = new Scanner(System.in);
            PrintWriter writer = new PrintWriter(outputStream);
            Scanner reader = new Scanner(inputStream);

            while(true){
                System.out.print("-> ");
                // 1. 等待用户输入请求
                if(!in.hasNext()){
                    break;
                }
                String request = in.next();
                // 2. 发送请求到服务器
                writer.println(request);
                writer.flush();
                // 3. 等待服务器响应
                String response = reader.next();
                System.out.println(response);
            }
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

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

上述代码还有个严重问题,就是不能支持多个客户端访问;

多个客户端需要调用 accept() 方法返回多个 Socket 对象,但是服务器程序运行起来后,会一直在 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("服务器启动!");
        while(true){
            // 等待客户端端建立连接
            Socket clientSocket = serverSocket.accept();
            ExecutorService service = Executors.newCachedThreadPool();
            service.submit(() -> {
                ProcessConnection(clientSocket);
            });
        }
    }

对于服务器:

  • 如果每个客户端的请求处理时间很短,就是一个短连接;
  • 每个客户端处理的时间较长,那么就是一个长连接;

如果客户端都是长连接,而且数量较多,服务器的负担会非常重;

解决上述问题,可以考虑使用协程或者 IO 多路复用的方式;