云计算百科
云计算领域专业知识百科平台

Java 网络编程套接字入门:从“发一段数据”到“写一个可并发的服务器”

Java 网络编程套接字入门:从“发一段数据”到“写一个可并发的服务器”

网络编程最核心的目标:让不同主机(或同一主机的不同进程)通过网络传输数据。你在浏览器看视频、刷图片、读文章,本质都是“客户端进程”向“服务端进程”请求网络资源,然后接收响应数据。

先明确请求/响应、客户端/服务端,再落到 UDP/TCP 两条路线,最后讲到端口占用、并发处理、长短连接这些工程级坑点。下面按一条顺滑的学习路径串起来。


1)网络编程到底在编什么:进程之间的数据传输

来看一个关键定义:网络编程就是网络上的主机,通过不同进程,以编程方式实现网络通信(网络数据传输)。只要是不同进程,哪怕在同一台机器上,通过网络协议栈收发数据,也算网络编程。

于是会出现三个高频“角色名词”:

  • 发送端 / 接收端:一次数据流向里,发送数据的一方叫发送端,接收数据的一方叫接收端(这俩是相对概念)。
  • 请求 / 响应:获取网络资源通常要两次传输:先发请求,再回响应。
  • 客户端 / 服务端:提供服务资源的一方是服务端,获取服务的一方是客户端。常见流程是:客户端请求 → 服务端处理业务 → 服务端响应 → 客户端展示结果。
    在这里插入图片描述

2)Socket 套接字:网络通信的“基本操作单元”

来看 Socket 的定位:它是系统提供的一种网络通信技术,是基于 TCP/IP 的网络通信基本操作单元。基于 Socket 写出来的程序,就是网络编程。
在这里插入图片描述

按传输层协议,Socket 主要分三类:

  • 流套接字(TCP)

    • 有连接、可靠传输、面向字节流
    • 有发送缓冲区也有接收缓冲区
    • 数据“没有边界”:可以多次发送、分多次接收(只要连接不断)
  • 数据报套接字(UDP)

    • 无连接、不可靠传输、面向数据报
    • 有接收缓冲区、通常不强调发送缓冲区
    • 数据“有边界”:发 100 字节就要一次发完、一次收完
    • 单个数据报大小受限(常见上限 64KB)
  • 原始套接字(用于自定义传输层协议/读写内核未处理的 IP 数据,了解即可)


  • 3)UDP 编程模型:一次一包,收发都靠 DatagramPacket

    来看 UDP 的“脾气”:它不建立连接,发送一块数据就必须整体发送,接收也必须整体接收。Java 里主要靠两个类:

    • DatagramSocket:UDP Socket,用来 send/receive 数据报
    • DatagramPacket:数据报本体(携带字节数组 + 目标/来源地址信息)
      在这里插入图片描述
      在这里插入图片描述

    3.1 DatagramSocket 关键用法

    • new DatagramSocket():绑定本机随机端口(更常见于客户端)
    • new DatagramSocket(port):绑定本机指定端口(更常见于服务端)
    • receive(packet):阻塞等待接收
    • send(packet):发送(通常不阻塞等待)
    • close():关闭套接字

    3.2 DatagramPacket 关键用法

    • 接收包:new DatagramPacket(byte[] buf, int length)
    • 发送包:new DatagramPacket(byte[] buf, int offset, int length, SocketAddress address) 或指定 InetAddress + port
    • getAddress()/getPort()/getData():获取对端地址、端口和数据

    4)来看 UDP 回显:最短路径理解“请求→处理→响应”

    回显(Echo)是网络编程里的“Hello World”:客户端发什么,服务端回什么。下面这两段就是完整可运行版本(带注释)。代码来自压缩包。

    4.1 UDP Echo Server(服务端)

    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);
    //socket对象代表网卡文件,读这个文件等于从网卡收数据,写这个文件等于让网卡发数据
    }
    public void start() throws IOException {
    //启动服务器
    System.out.println("服务器启动!");
    while(true){
    //循环一次,相当于处理一次请求
    //处理请求的过程,典型的服务器都分为三个步骤
    //1、读取请求并解析
    // DatagramPacket表示一个UDP数据报,此处传入的字节数组就保存UDP的载荷部分
    DatagramPacket requestPacket = new DatagramPacket(new byte[4096],4096);
    socket.receive(requestPacket);
    //receive会触发阻塞,直到收到客户端的请求

    //把读取到的二进制数据转换成字符串
    String request = new String(requestPacket.getData(),0,requestPacket.getLength());
    //2、根据请求,计算响应(服务器最关键的逻辑)
    String response = process(request);

    //3、把响应返回给客户端
    //根据response构造DatagramPacket,发送给客户端
    //此处不能使用response.length() 这是string中字符的个数 而下面的是String中字节的个数
    DatagramPacket responsePacket = new DatagramPacket(response.getBytes(),response.getBytes().length,requestPacket.getSocketAddress());
    //此处还不能直接发送,UDP协议自身没有保存对方的信息
    //需要指定目的ip和端口号
    socket.send(responsePacket);

    //4.打印一个日志
    System.out.printf("[%s:%d] req: %s, resp: %s\\n",requestPacket.getAddress().toString(),responsePacket.getPort(),request,response);
    }
    }

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

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

    4.2 UDP Echo Client(客户端)

    package network;

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

    public class UdpEchoClient {
    private DatagramSocket socket = null;
    //UDP本身不保存对端的信息,给自己的代码中保存一下
    private String serverIp;
    private int serverPort;

    public UdpEchoClient(String serverIp,int serverPort) throws SocketException {
    this.serverIp = serverIp;
    this.serverPort = serverPort;
    socket = new DatagramSocket();
    //一定不能填写serverPort,serverip是目的ip,serverport是目的端口
    //源ip所在的客户端的主机Ip,源端口,应该是操作系统随机分配一个端口
    //就像学生去食堂吃饭,食堂提供把饭做好了端过去的服务,学生在食堂是随便坐一样
    }
    public void start() throws IOException {
    Scanner scanner = new Scanner(System.in);
    while(true) {
    //1.从控制台读取用户输入的内容
    System.out.println("请输入要发送的内容!");
    if(!scanner.hasNext()) break;
    String request = scanner.next();

    //2.把请求发送给服务器,首先构造数据报
    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);
    client.start();
    }
    }

    读代码时重点看三件事:

  • UDP 服务端 receive() 会阻塞等待;2) UDP 必须在发送包里指定对端地址;3) “一次一包”的边界来自 DatagramPacket。

  • 5)把 Echo 改造成“英译汉”:核心就是改 process

    来看一个非常实用的抽象:服务器主循环基本都一样,差异常常只在“如何处理 request 得到 response”。因此做英译汉字典服务时,核心就是把 process(request) 改成“查表并返回”。

    (工程上常见做法是让 process 具备可扩展性:例如改成 protected,再用继承/组合注入不同处理逻辑。)


    6)TCP 编程模型:先建立连接,再用字节流持续收发

    来看 TCP 的关键区别:它是面向连接的。通信前要建立连接;建立后双方通过 InputStream/OutputStream 像读写文件一样收发数据。
    在这里插入图片描述

    Java 里 TCP 的两个核心类:

    • ServerSocket:用来创建 TCP 服务端监听套接字

      • new ServerSocket(port):绑定端口
      • accept():阻塞等待客户端连接,返回 Socket
    • Socket:客户端 socket;或服务端 accept 后得到的连接 socket

      • new Socket(host, port):客户端发起连接
      • getInputStream()/getOutputStream():获取读写流

    7)来看 TCP 回显:阻塞点、换行协议、flush 都在这里

    下面是完整可运行版本(带注释)。代码来自压缩包。

    7.1 TCP Echo Server(服务端:线程池并发版)

    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;
    import java.util.concurrent.ExecutorService;
    import java.util.concurrent.Executors;

    public class TcpEchoServer {
    private ServerSocket serverSocket = null;

    //这里和UDP类似,也是在构造对象的时候绑定端口
    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){
    //tcp来说,需要先处理客户端发来的连接
    //通过读写clientSocket和客户端进行通信
    //如果没有客户端发起连接,此时accept就会阻塞
    Socket clientSocket = serverSocket.accept();//每个客户端连接,都会创建一个新的
    //每个客户端断开连接,这个对象都可以不要了
    //主线程应该复杂进行accept,每次accept到一个客户端就创建一个线程负责处理客户端的请求
    // Thread t = new Thread(() ->{
    // processConnection(clientSocket);
    // });
    // t.start(); 这段多线程代码能够正常运行 但还可以再优化 优化原因:客户端多了 频繁的创建销毁线程占比开销大 这时候就该引入线程池
    executorService.submit(() ->{
    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 scanner = new Scanner(inputStream);
    PrintWriter writer = new PrintWriter(outputStream);
    //分成三个步骤
    while(true){
    //1.读取请求并解析
    /**byte[] request = new byte[1024];
    inputStream.read(request); **/
    //这个read的操作会把clientSocket的东西全部写到request数组里
    if(!scanner.hasNext()){//如果客户端#1不发请求,那么服务器就会阻塞在此没法回应其他客户端
    //连接断开了
    break;
    }
    String request = scanner.nextLine();
    //2.根据请求计算响应
    String response = process(request);
    //3.返回响应到客户端
    writer.println(response);//和sout类似
    //这个操作只是把数据放到"发送缓冲区"中,还没有真正写入到网卡里
    //加上刷新缓冲区操作才是真正发送数据
    writer.flush();
    //打印日志
    System.out.printf("[%s:%d] req:%s,resp:%s\\n",clientSocket.getInetAddress(),clientSocket.getPort(),request,response);
    }
    }catch (IOException e){
    throw new RuntimeException(e);
    }
    }

    private String process(String request) {
    return "this server accept your request = " + request;
    }

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

    7.2 TCP Echo Client(客户端)

    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 = null;

    public TcpEchoClient(String serverIp,int serverPort) throws IOException{
    //直接把字符串的IP地址设置进来
    //127.0.0.1这种字符串
    socket = new Socket(serverIp,serverPort);
    }

    public void start(){
    Scanner scanner = new Scanner(System.in);
    try(InputStream inputStream = socket.getInputStream();
    OutputStream outputStream = socket.getOutputStream()){
    //为了使用方便,套壳操作
    Scanner scannerNet = new Scanner(inputStream);
    PrintWriter writer = new PrintWriter(outputStream);

    //从控制台读取请求发送给服务器
    while(true){
    //1.从控制台读取用户输入
    String request = scanner.next();
    //2.发送给服务器
    writer.println(request);
    //这个操作只是把数据放到"发送缓冲区"中,还没有真正写入到网卡里
    //加上刷新缓冲区操作才是真正发送数据
    writer.flush();
    //3.读取服务器返回的响应
    String response = scannerNet.nextLine();
    //4.打印到控制台
    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();
    }
    }

    读 TCP 这套代码时,盯住三个工程细节:

    • accept() 是阻塞点:没有客户端连上来就会一直等。
    • 用 println + flush 相当于定义了一个很简单的“应用层协议”:一行一个消息,否则对端读取边界会很痛苦。
    • 服务端要并发就必须“一个连接交给一个线程/任务”,否则一个客户端卡住会拖死所有人。

    8)Socket 编程注意事项:端口占用、目的地址、以及“别忘了协议”

    来看几个真实世界里最常见的坑:

    8.1 目的 IP + 目的端口决定“你把数据发给谁”

    一次数据传输里,目的 IP + 目的端口唯一标识了对端主机和对端进程。写错了就不是“收不到”,而是“发给了别的地方”。

    8.2 端口被占用:同一端口只能被一个进程绑定

    如果进程 A 已经绑定端口,再让进程 B 绑定同一端口会报错(典型错误:Address already in use)。排查方式:

    • netstat -ano | findstr 端口号 查到 PID
    • 在任务管理器按 PID 找到并结束进程,或换一个未被占用的端口

    8.3 应用层协议不能忽略

    就算底层用 TCP/UDP,应用层仍需要约定“数据怎么分隔、怎么解析、字段怎么定义”。否则双方读写会互相折磨。


    9)长连接 vs 短连接:什么时候关连接决定系统形态

    来看一个经典分叉:TCP 发数据前要先建连接,什么时候关闭连接决定你是短连接还是长连接。

    • 短连接:一次请求-响应就关闭,只能收发一次
    • 长连接:不关闭连接,双方可多次收发

    对比差异:

    • 短连接每次都要建连/断连,耗时更高;长连接首次建连后复用,效率更高
    • 短连接通常是客户端主动发起;长连接场景里服务端也可能主动推送
    • 短连接适合低频请求(比如普通网页浏览);长连接适合高频通信(聊天室、实时游戏等)

    还有一个“扩展但很重要”的工程提醒:基于 BIO(同步阻塞 IO)的长连接会长期占用线程资源,并发高时成本非常昂贵;实际高并发长连接更常用 NIO(同步非阻塞 IO)来实现,性能能上一个量级。


    10)怎么把代码跑起来:最短运行方式

    来看最简单的本地运行方式(同机回环地址):

  • 先启动服务端:UdpEchoServer 或 TcpEchoServer
  • 再启动客户端:UdpEchoClient 或 TcpEchoClient
  • 在客户端控制台输入字符串,观察是否收到回显/响应
  • 只要端口一致(示例里都是 9090)且未被占用,就能跑通。


    把这些概念和代码吃透之后,你就拥有了写网络程序的“底盘能力”:知道什么时候会阻塞、如何定义消息边界、怎么做并发、为什么端口会炸,以及长连接为什么不能傻用 BIO。剩下的就是在这个底盘上继续往上盖:HTTP、RPC、自定义协议、NIO/Netty——都只是更复杂、更工程化的版本而已。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » Java 网络编程套接字入门:从“发一段数据”到“写一个可并发的服务器”
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!