从Socket到TCP回显服务器:Java网络编程完整指南

从Socket到TCP回显服务器:Java网络编程完整指南

摘要 :从 Socket 概念与 TCP 四大特点讲起,介绍 ServerSocket、Socket 核心 API,并完整实现基于线程池的 Java TCP 回显服务器与客户端,附 10 个实战注意点(flush()、println() 与 Scanner 分隔约定、accept() 阻塞、Allow multiple instances 等)。

目录

1.socket套接字

2.TCP协议的特点

3.TCP数据报套接字编程

4.基于TCP实现一个回显服务器

正文

1.socket套接字

Socket(套接字)是操作系统提供给应用程序的一组网络编程接口,它把「传输层协议 + IP 地址 + 端口号」抽象成一个可读写的文件描述符,让程序可以像读写文件一样收发网络数据。

1.1分类

Socket套接字主要针对传输层协议划分为如下三类:

  • 1.流套接字:使用传输层TCP协议.
  • 2.数据报套接字:使用传输层UDP协议.
  • 3.原始套接字:使用传输层自定义协议.
    这篇文章我们展开学习数据报套接字:使用传输层TCP协议.

2.TCP协议的特点

  • 1.有连接:对TCP来说,TCP协议中就保存了对端的信息.(如A和B通信,A和B建立连接.让A保存B的信息,B保存A的信息)
  • 2.可靠传输:不保证数据百分百到达,而是尽可能提高传输成功的概率.
  • 3.面向字节流:读取数据的时候以字节为单位.
  • 4.全双工:能读能写.

3.TCP数据报套接字编程

3.1API介绍

ServerSocket:(专门给服务器用的)

ServerSocket是创建TCP服务端 Socket的API.(DatagramSocket是创建UDP的API)

ServerSocket构造方法:

ServerSocket方法:

Socket:(客户端,服务端都可以用)

Socket构造方法:

Socket方法:(TCP的一个核心的特点是面向字节流,因此,Socket对象自身并不是提供读写,而是通过字节流对象)

4.基于TCP实现一个回显服务器

基于上述内容,我们来实现一个回显服务器。一般服务器的工作流程是:客户端发起请求,服务器根据请求返回响应结果。回显服务器比较特殊------**它不做任何业务处理,收到什么就原样返回什么,**把「计算响应」这一步直接省略,因此常被用来验证网络连通性和客户端收发流程是否正确。

与 UDP 每个数据报都自带完整地址不同,TCP 是面向连接的:客户端用 new Socket(serverIp, serverPort) 发起连接,服务器用 serverSocket.accept() 接受连接并拿到专属的 clientSocket,之后双方的通信都在这条连接上进行,两端各自保存对端信息,字节流不再重复携带地址。也正因为连接是独占的,服务器必须为每个 clientSocket 分配线程或交给线程池处理,否则一个客户端不退出,其他客户端就连接不进来。

java 复制代码
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 就会阻塞.

            // 主线程负责进行 accept, 每次 accept 到一个客户端, 就创建一个线程, 由新线程负责处理客户端的请求.
            Socket clientSocket = serverSocket.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()) {
            // 针对 InputStream 套了一层
            Scanner scanner = new Scanner(inputStream);
            // 针对 OutputStream 套了一层
            PrintWriter writer = new PrintWriter(outputStream);
            // 分成三个步骤
            while (true) {
                // 1. 读取请求并解析. 可以直接 read, 也可以借助 Scanner 来辅助完成.
                if (!scanner.hasNext()) {
                    // 连接断开了
                    System.out.printf("[%s:%d] 客户端下线!\n", clientSocket.getInetAddress(), clientSocket.getPort());
                    break;
                }
                String request = scanner.next();
                // 2. 根据请求计算响应
                String response = process(request);
                // 3. 返回响应到客户端
                // outputStream.write(response.getBytes());
                writer.println(response);
                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);
        } 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 {
        TcpEchoServer server = new TcpEchoServer(9090);
        server.start();
    }
}
java 复制代码
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.next();
                // 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();
    }
}

注意:

  • 1.必须调用 flush() :PrintWriter 默认带缓冲,println() 只是把数据写进缓冲区,不 flush() 数据可能一直留在缓冲区里发不出去,服务端就会一直阻塞在 scanner.hasNext() 上。所以客户端 writer.flush()、服务端 writer.flush() 都不能省。
  • 2.PrintWriter 要配合 println() 使用 :println() 会在末尾自动加换行符,正好和 Scanner.next() 按空白符切分的规则对上;如果改用 print(),两端没有分隔符,Scanner 会一直等不到一个完整的 token。
  • 3.accept() 是阻塞的 :没有客户端连接时主线程会卡在 serverSocket.accept();同理 scanner.hasNext() 在没收到数据时也会阻塞。这是 TCP 的正常行为,不是 bug。
  • 4.一个 accept 对应一个 clientSocket :serverSocket 只负责监听和建立连接,真正的读写必须用 accept() 返回的 clientSocket,两者不要混用。
  • 5.并发处理要用线程池/多线程 :主线程只负责 accept,把 processConnection 交给线程池执行,否则一个客户端不退出,其他客户端就连接不上。这里用 newCachedThreadPool() 而不是 newFixedThreadPool(),是因为前者可以按需创建线程,不会把并发连接数写死。
  • 6.IDEA 中要勾选 Allow multiple instances :想同时启动多个客户端验证并发效果时,需要在 Run/Debug Configurations 里勾选 Allow multiple instances(允许并行运行),否则第二次点运行会直接复用/停止已有实例,看不到多个客户端同时在线的效果。


  • 7.端口号要一致且不能被占用 :客户端 new Socket("127.0.0.1", 9090) 的端口必须和服务端 new ServerSocket(9090) 一致;如果 9090 已被占用,会抛 BindException,换一个端口即可。
  • 8.Scanner.next() 以空白符分隔 :输入 hello world 会被拆成两次请求,想要一次发一整行可以用 scanner.nextLine(),同时服务端也要改成按行读取,两端规则必须一致。
  • 9.资源释放用 try-with-resources :InputStream/OutputStream 放在 try 的括号里会自动关闭;clientSocket 在 finally 中关闭,避免连接泄漏。
  • 10.客户端断开时服务端要能感知 :客户端关闭后,服务端 scanner.hasNext() 返回 false,此时跳出循环并打印「客户端下线」,线程随之结束,不会一直空转。

本文结束~欢迎一起来交流技术!!!

相关推荐
养军博客1 小时前
Spring Security 4.x / 5.x / 6.x 配置全对比:前后端不分离与前后端分离
java·后端·spring·springsecurity
wifi___1 小时前
SpringBoot自动装配原理
java·spring boot·后端
云-缩影1 小时前
做微信小程序,自建服务器还是用云托管:技术选型对比
服务器·微信小程序·小程序
SL_staff1 小时前
制造订单交付闭环实践:用跨部门计划模板解决缺料-停机-返工三角死循环
java·开源·产品
device_Any1 小时前
接口文档写着三个字段,接口返回了五个
android·java·linux
Wang's Blog1 小时前
Java 项目实战: 交友项目 - 小视频模块实战:访客记录、FastDFS 存储与 SpringCache 缓存
java·缓存·交友
安易算力1 小时前
智算中心网络架构:RDMA/IB/以太网选型深度解析与优化实践
网络·架构·php
土星云SaturnCloud2 小时前
基于边缘计算与AI视觉的智慧街面治理方案:架构设计与场景落地
服务器·人工智能·ai·边缘计算
香蕉大香蕉(06敬畏市场)2 小时前
MySQL 单点详解(09)
java·数据库·mysql·面试·八股·大厂实习