综合练习 01:TCP 多发多收

需求:客户端可以多次发送消息(直到输入 “exit”),服务端能多次接收并打印,解决 “一次通信就断开” 的问题。
核心思路

  • 客户端:用Scanner读取用户输入,循环发送消息,输入 “exit” 则退出循环,关闭连接。
  • 服务端:循环读取客户端消息,收到 “exit” 则退出循环,关闭连接。

代码示例(客户端核心逻辑)

Scanner sc = new Scanner(System.in);
BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream(), "UTF-8"));

while (true) {
    System.out.print("客户端请输入消息(输入exit退出):");
    String msg = sc.nextLine();
    if ("exit".equals(msg)) {
        bw.write(msg); // 把exit发给服务端,让服务端知道要断开
        bw.newLine();
        bw.flush();
        break; // 退出循环
    }
    bw.write(msg);
    bw.newLine();
    bw.flush();
}
sc.close();
bw.close();
socket.close();

代码示例(服务端核心逻辑)

BufferedReader br = new BufferedReader(new InputStreamReader(clientSocket.getInputStream(), "UTF-8"));
String line;
while ((line = br.readLine()) != null) {
    if ("exit".equals(line)) {
        System.out.println("服务端:客户端请求断开连接");
        break;
    }
    System.out.println("服务端收到:" + line);
}
br.close();
clientSocket.close();

综合练习 02:TCP 接收并反馈

需求:客户端发送消息后,服务端接收并回复 “已收到:XXX”,实现 “双向通信”(类似聊天的 “已读回执”)。
核心思路:服务端除了输入流(读客户端消息),还需要输出流(向客户端发反馈)。

服务端核心代码

// 读客户端消息的输入流
BufferedReader br = new BufferedReader(new InputStreamReader(clientSocket.getInputStream(), "UTF-8"));
// 向客户端发反馈的输出流
BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(clientSocket.getOutputStream(), "UTF-8"));

String line;
while ((line = br.readLine()) != null) {
    if ("exit".equals(line)) {
        break;
    }
    System.out.println("服务端收到:" + line);
    // 发送反馈
    String feedback = "已收到:" + line;
    bw.write(feedback);
    bw.newLine();
    bw.flush();
}
br.close();
bw.close();
clientSocket.close();

客户端核心代码(新增接收反馈的逻辑):

// 发送消息的输出流
BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream(), "UTF-8"));
// 接收服务端反馈的输入流(单独开线程,避免阻塞发送逻辑)
new Thread(() -> {
    try {
        BufferedReader br = new BufferedReader(new InputStreamReader(socket.getInputStream(), "UTF-8"));
        String feedback;
        while ((feedback = br.readLine()) != null) {
            System.out.println("服务端反馈:" + feedback);
        }
        br.close();
    } catch (Exception e) {
        e.printStackTrace();
    }
}).start();

// 以下是原有的发送消息逻辑(略)

注意:客户端接收反馈需要单独开线程,因为readLine()是阻塞方法,如果和 “发送消息” 在同一个线程,会导致 “发消息前必须先等反馈”,无法正常交互。

综合练习 03:TCP 上传文件

需求:客户端将本地文件(如图片、文档)上传到服务端,服务端接收文件数据后,保存到本地指定目录 —— 这是 TCP 的经典实战场景(如网盘上传、文件传输工具)。
核心思路

  • 客户端:用FileInputStream读取本地文件(字节流,适合所有文件类型),通过 Socket 的输出流将字节数据发送给服务端;
  • 服务端:用 Socket 的输入流读取客户端发送的字节数据,通过FileOutputStream写入本地文件。

(1)客户端实现(文件上传方)

步骤拆解

  1. 创建Socket,连接服务端;
  2. 创建FileInputStream,读取本地要上传的文件(需指定文件路径,如D:/test.jpg);
  3. 获取 Socket 的输出流,将文件字节数据写入输出流;
  4. 上传完成后,调用shutdownOutput()告知服务端 “数据已发送完毕”;
  5. 关闭所有流和 Socket。

代码示例:TCP 文件上传客户端

import java.io.FileInputStream;
import java.io.OutputStream;
import java.net.Socket;

public class TCPFileUploadClient {
    public static void main(String[] args) throws Exception {
        // 1. 连接服务端(IP:127.0.0.1,端口:8888)
        Socket socket = new Socket("127.0.0.1", 8888);
        System.out.println("客户端:已连接服务端,开始上传文件...");

        // 2. 读取本地文件(要上传的文件路径,需确保文件存在)
        String localFilePath = "D:/test.jpg"; // 本地文件路径
        FileInputStream fis = new FileInputStream(localFilePath);

        // 3. 获取Socket输出流,发送文件数据
        OutputStream out = socket.getOutputStream();

        // 4. 缓冲区读取文件(1024字节/次,提高效率)
        byte[] buffer = new byte[1024];
        int len; // 每次实际读取的字节数
        while ((len = fis.read(buffer)) != -1) {
            out.write(buffer, 0, len); // 只写实际读取的字节(避免缓冲区冗余)
        }

        // 5. 告知服务端“文件已上传完毕”(关键:否则服务端会一直阻塞等数据)
        socket.shutdownOutput();
        System.out.println("客户端:文件上传完成!");

        // 6. 关闭资源(顺序:先关文件流,再关Socket流,最后关Socket)
        fis.close();
        out.close();
        socket.close();
    }
}

(2)服务端实现(文件接收方)

步骤拆解

  1. 创建ServerSocket,绑定端口(如 8888);
  2. 调用accept()监听客户端连接,获取Socket
  3. 创建FileOutputStream,指定文件保存路径(如E:/received_test.jpg);
  4. 获取 Socket 的输入流,读取客户端发送的文件字节数据;
  5. 将读取的字节数据写入FileOutputStream,保存到本地;
  6. 关闭所有流和 Socket。

代码示例:TCP 文件上传服务端

import java.io.FileOutputStream;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;

public class TCPFileUploadServer {
    public static void main(String[] args) throws Exception {
        // 1. 创建ServerSocket,绑定端口8888
        ServerSocket serverSocket = new ServerSocket(8888);
        System.out.println("服务端:已启动,等待客户端上传文件...");

        // 2. 监听客户端连接
        Socket clientSocket = serverSocket.accept();
        System.out.println("服务端:客户端" + clientSocket.getInetAddress().getHostAddress() + "已连接");

        // 3. 准备保存文件(指定保存路径)
        String saveFilePath = "E:/received_test.jpg"; // 服务端保存文件的路径
        FileOutputStream fos = new FileOutputStream(saveFilePath);

        // 4. 获取Socket输入流,读取客户端发送的文件数据
        InputStream in = clientSocket.getInputStream();

        // 5. 缓冲区接收文件数据
        byte[] buffer = new byte[1024];
        int len;
        while ((len = in.read(buffer)) != -1) {
            fos.write(buffer, 0, len); // 写入本地文件
        }
        System.out.println("服务端:文件接收完成,已保存到" + saveFilePath);

        // 6. 关闭资源
        fos.close();
        in.close();
        clientSocket.close();
        serverSocket.close();
    }
}

关键注意事项

  • 文件路径有效性:客户端需确保本地文件存在(否则FileInputStream会报FileNotFoundException),服务端需确保保存目录有写入权限(否则FileOutputStream会报PermissionDeniedException);
  • 二进制文件 vs 文本文件:此处用字节流(FileInputStream/FileOutputStream),可上传所有类型文件(图片、视频、文档等);若用字符流,仅能上传文本文件,会导致二进制文件损坏;
  • shutdownOutput()不可少:客户端上传完必须调用此方法,否则服务端的in.read()会一直阻塞(认为还有数据没传完),无法完成文件保存。

综合练习 04:TCP 上传文件(解决文件名重复问题)

问题场景:若多个客户端上传同名文件(如都叫test.jpg),服务端会覆盖之前保存的文件 —— 这是实际开发中必须解决的问题。
解决方案:给服务端保存的文件名添加 “唯一标识”,确保文件名不重复,常见方案有两种:

方案实现逻辑优点缺点
1. 时间戳 + 原文件名用当前时间(如20240907153020)拼接原文件名,生成20240907153020_test.jpg实现简单,文件名可追溯原文件若同一毫秒上传同名文件,仍可能重复
2. UUID + 原文件名用 Java 的UUID.randomUUID()生成唯一字符串(如550e8400-e29b-41d4-a716-446655440000),拼接原文件名绝对唯一,无重复风险文件名较长,可读性差

优化后服务端核心代码(时间戳方案)

import java.io.FileOutputStream;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.text.SimpleDateFormat;
import java.util.Date;

public class TCPFileUploadServerV2 {
    public static void main(String[] args) throws Exception {
        ServerSocket serverSocket = new ServerSocket(8888);
        Socket clientSocket = serverSocket.accept();

        // 关键优化:生成唯一文件名(时间戳+原文件名)
        String originalFileName = "test.jpg"; // 实际开发中可让客户端传原文件名,此处简化用固定名
        // 1. 获取当前时间戳(格式:yyyyMMddHHmmssSSS,精确到毫秒)
        SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmssSSS");
        String timeStamp = sdf.format(new Date());
        // 2. 生成唯一文件名
        String uniqueFileName = timeStamp + "_" + originalFileName;
        // 3. 保存路径(用唯一文件名)
        String saveFilePath = "E:/" + uniqueFileName;
        FileOutputStream fos = new FileOutputStream(saveFilePath);

        // 后续读取文件数据、保存的逻辑与之前一致(略)
        InputStream in = clientSocket.getInputStream();
        byte[] buffer = new byte[1024];
        int len;
        while ((len = in.read(buffer)) != -1) {
            fos.write(buffer, 0, len);
        }
        System.out.println("服务端:文件接收完成,已保存为" + uniqueFileName);

        // 关闭资源(略)
        fos.close();
        in.close();
        clientSocket.close();
        serverSocket.close();
    }
}

进阶优化:让客户端上传时 “主动发送原文件名”
实际开发中,原文件名不应在服务端写死,而是由客户端在上传文件前,先将原文件名发给服务端:

  1. 客户端流程:先发送原文件名(如test.jpg)→ 再发送文件数据;
  2. 服务端流程:先读取客户端发送的原文件名→ 生成唯一文件名→ 再接收文件数据并保存。

客户端发送原文件名的核心代码

// 1. 先发送原文件名(用输出流发送字符串)
OutputStream out = socket.getOutputStream();
String originalFileName = "test.jpg"; // 从本地文件路径中解析,此处简化
out.write(originalFileName.getBytes("UTF-8"));
socket.shutdownOutput(); // 注意:此处不能调用shutdownOutput(),否则后续无法发文件数据!
// 正确做法:用特殊字符(如“|”)分隔文件名和文件数据,或分两次获取流(需服务端配合)
注:分隔 “文件名” 和 “文件数据” 的更严谨方案是 “先发送文件名长度,再发送文件名,最后发送文件数据”,避免文件名中包含特殊字符导致解析错误,具体可结合 IO 流的 “长度前缀法” 实现。

综合练习 05:多线程版 TCP 服务端

问题场景:之前的服务端只能处理一个客户端请求(处理完一个才会accept()下一个),若多个客户端同时连接,会出现 “排队等待”—— 这不符合实际服务端 “并发处理多客户端” 的需求。
解决方案:用多线程,让服务端的 “主线程” 只负责accept()监听连接,每次收到客户端连接后,启动一个 “子线程” 专门处理该客户端的通信(如文件上传、消息交互),主线程继续监听下一个连接。

多线程服务端实现思路

  1. 主线程:创建ServerSocket,循环调用accept(),每接收一个客户端连接,就创建一个ClientHandler线程(实现Runnable),并传入Socket
  2. 子线程(ClientHandler):负责与单个客户端的通信逻辑(如接收文件、发送反馈),处理完后关闭该客户端的Socket

代码示例:多线程 TCP 服务端

import java.net.ServerSocket;
import java.net.Socket;

// 主线程:负责监听客户端连接,分配子线程处理
public class TCPMultiThreadServer {
    public static void main(String[] args) throws Exception {
        ServerSocket serverSocket = new ServerSocket(8888);
        System.out.println("多线程服务端:已启动,等待客户端连接...");

        // 循环监听(主线程不退出,一直接收客户端)
        while (true) {
            Socket clientSocket = serverSocket.accept(); // 阻塞等待客户端连接
            String clientIp = clientSocket.getInetAddress().getHostAddress();
            System.out.println("多线程服务端:客户端" + clientIp + "已连接,分配子线程处理");

            // 启动子线程处理该客户端的通信
            new Thread(new ClientHandler(clientSocket)).start();
        }
    }
}

// 子线程:负责处理单个客户端的通信(如文件上传)
class ClientHandler implements Runnable {
    private Socket clientSocket;

    // 构造方法:传入当前客户端的Socket
    public ClientHandler(Socket clientSocket) {
        this.clientSocket = clientSocket;
    }

    @Override
    public void run() {
        // 子线程的核心逻辑:与客户端通信(此处以文件上传为例)
        try {
            // 1. 生成唯一文件名(参考练习04的时间戳方案)
            String originalFileName = "client_upload.jpg";
            String timeStamp = new SimpleDateFormat("yyyyMMddHHmmssSSS").format(new Date());
            String saveFilePath = "E:/" + timeStamp + "_" + originalFileName;
            FileOutputStream fos = new FileOutputStream(saveFilePath);

            // 2. 接收客户端发送的文件数据
            InputStream in = clientSocket.getInputStream();
            byte[] buffer = new byte[1024];
            int len;
            while ((len = in.read(buffer)) != -1) {
                fos.write(buffer, 0, len);
            }

            System.out.println("子线程处理完成:客户端" + clientSocket.getInetAddress().getHostAddress() + "的文件已保存");

            // 3. 关闭资源(仅关闭当前客户端的流和Socket,主线程的ServerSocket不关闭)
            fos.close();
            in.close();
            clientSocket.close();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

关键优势

  • 主线程不阻塞于单个客户端的处理,可同时接收多个客户端连接;
  • 每个客户端的通信逻辑在独立子线程中执行,互不干扰;
  • 符合实际服务端 “并发处理” 的需求(如 Tomcat 服务器的基础原理就是 “多线程处理请求”)。

问题隐患:若同时有 1000 个客户端连接,会创建 1000 个线程 —— 线程的创建和销毁会消耗大量 CPU 和内存,可能导致服务端性能下降(“线程爆炸”)。

综合练习 06:线程池版 TCP 服务端

问题场景:多线程版服务端的 “线程爆炸” 问题 —— 频繁创建 / 销毁线程会带来性能开销。
解决方案:用线程池ExecutorService),预先创建固定数量的线程,客户端连接到来时,从线程池 “复用” 线程处理,处理完后线程不销毁,放回线程池供后续使用,避免频繁创建线程的开销。

线程池服务端实现思路

  1. 创建固定大小的线程池(如Executors.newFixedThreadPool(10),表示最多同时处理 10 个客户端);
  2. 主线程循环accept()客户端连接,将ClientHandler任务提交给线程池(executorService.submit(handler));
  3. 线程池中的线程处理完任务后,自动回到线程池,等待下一个任务。

代码示例:线程池 TCP 服务端

import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

// 线程池版服务端:复用线程,避免线程爆炸
public class TCPThreadPoolServer {
    public static void main(String[] args) throws Exception {
        // 1. 创建固定大小的线程池(核心参数:核心线程数=10,最多同时处理10个客户端)
        // 实际开发中,线程池大小需根据CPU核心数、业务复杂度调整(如CPU核心数*2)
        ExecutorService executorService = Executors.newFixedThreadPool(10);
        System.out.println("线程池服务端:已启动,线程池大小=10,等待客户端连接...");

        // 2. 创建ServerSocket,循环监听客户端
        ServerSocket serverSocket = new ServerSocket(8888);
        while (true) {
            Socket clientSocket = serverSocket.accept();
            String clientIp = clientSocket.getInetAddress().getHostAddress();
            System.out.println("线程池服务端:客户端" + clientIp + "已连接,提交线程池处理");

            // 3. 将客户端处理任务提交给线程池(复用线程,无需手动创建线程)
            executorService.submit(new ClientHandler(clientSocket));
        }

        // 注意:实际服务端不会主动关闭线程池,若需关闭,可调用executorService.shutdown();
    }
}

// 复用之前的ClientHandler类(无需修改,线程池会自动分配线程执行run()方法)
class ClientHandler implements Runnable {
    // 代码与“多线程版”完全一致(略)
}

线程池的核心优势

  • 资源复用:线程不频繁创建 / 销毁,降低 CPU 和内存开销;
  • 控制并发量:通过线程池大小限制最大并发数,避免服务端资源被耗尽;
  • 简化开发:无需手动管理线程的生命周期(创建、启动、销毁),由线程池自动维护。
注:实际开发中,不推荐用Executors的默认方法(如newFixedThreadPool),而是通过ThreadPoolExecutor手动创建线程池,更灵活地设置 “核心线程数、最大线程数、空闲线程存活时间、任务队列” 等参数,避免资源耗尽风险。

综合练习 07:BS 架构模型

需求:理解 B/S(Browser/Server,浏览器 / 服务器)架构的通信原理 —— 浏览器作为 “通用客户端”,向服务端发送 HTTP 请求,服务端返回 HTML 页面数据,浏览器解析 HTML 并显示。
核心原理:B/S 架构本质是 “基于 HTTP 协议的 TCP 通信”,浏览器自动按照 HTTP 协议格式发送请求,服务端需按照 HTTP 协议格式返回响应,两者通过 TCP 连接传输数据。

(1)HTTP 协议的核心格式(简化版)

要实现 BS 架构通信,需先了解 HTTP 请求和响应的基本格式(浏览器会自动构造请求,服务端需手动构造响应):

  • HTTP 请求格式

    (浏览器发送给服务端):

    GET /index.html HTTP/1.1  # 请求行:请求方法(GET/POST)、请求资源路径、HTTP版本
    Host: localhost:8080       # 请求头:键值对,如主机名、浏览器信息等
    User-Agent: Mozilla/5.0    # 浏览器标识
    (空行)                    # 请求头与请求体的分隔(GET请求无请求体,POST请求有)
  • HTTP 响应格式

    (服务端返回给浏览器):

    HTTP/1.1 200 OK            # 响应行:HTTP版本、状态码(200=成功,404=资源不存在)
    Content-Type: text/html;charset=UTF-8  # 响应头:返回数据类型(HTML)、编码
    Content-Length: 128        # 响应数据的长度(字节)
    (空行)                    # 响应头与响应体的分隔
    <html><body><h1>Hello BS!</h1></body></html>  # 响应体:HTML内容(浏览器会解析显示)

(2)BS 架构服务端实现(TCP+HTTP)

服务端本质是一个 “支持 HTTP 协议的 TCP 服务端”,需完成 3 件事:

  1. 用 TCP 接收浏览器发送的 HTTP 请求;
  2. 构造符合 HTTP 格式的响应(包含 HTML 内容);
  3. 通过 TCP 将响应发送给浏览器,浏览器解析后显示页面。

代码示例:BS 架构服务端

import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;

public class BSServer {
    public static void main(String[] args) throws Exception {
        // 1. 创建TCP服务端,绑定端口8080(HTTP默认端口是80,此处用8080避免权限问题)
        ServerSocket serverSocket = new ServerSocket(8080);
        System.out.println("BS服务端已启动,端口8080,浏览器访问:http://localhost:8080");

        // 循环监听浏览器连接(支持多个浏览器访问)
        while (true) {
            Socket browserSocket = serverSocket.accept();
            System.out.println("浏览器已连接:" + browserSocket.getInetAddress().getHostAddress());

            // 启动子线程处理浏览器请求(避免阻塞主线程)
            new Thread(() -> {
                try {
                    // 2. 读取浏览器发送的HTTP请求(此处简化,仅打印请求,不解析)
                    // InputStream in = browserSocket.getInputStream();
                    // byte[] buffer = new byte[1024];
                    // int len = in.read(buffer);
                    // System.out.println("浏览器请求:\n" + new String(buffer, 0, len));

                    // 3. 构造HTTP响应(关键:必须符合HTTP格式)
                    String htmlContent = "<!DOCTYPE html>" +
                                         "<html lang='zh-CN'>" +
                                         "<head><meta charset='UTF-8'><title>BS架构测试</title></head>" +
                                         "<body><h1 style='color:red'>Hello BS Architecture!</h1>" +
                                         "<p>这是网络编程实现的BS服务端页面</p></body>" +
                                         "</html>"; // 响应体:HTML内容

                    // 响应头:指定数据类型、编码、长度
                    String responseHeader = "HTTP/1.1 200 OK\r\n" +  // 响应行:成功状态
                                            "Content-Type: text/html;charset=UTF-8\r\n" +  // 数据类型是HTML,编码UTF-8
                                            "Content-Length: " + htmlContent.getBytes("UTF-8").length + "\r\n" +  // 响应体长度
                                            "\r\n"; // 空行:分隔响应头和响应体

                    // 4. 发送响应(先发送响应头,再发送响应体)
                    OutputStream out = browserSocket.getOutputStream();
                    out.write(responseHeader.getBytes("UTF-8")); // 写响应头
                    out.write(htmlContent.getBytes("UTF-8"));    // 写响应体
                    out.flush();

                    // 5. 关闭资源
                    out.close();
                    browserSocket.close();
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }).start();
        }
    }
}

(3)测试步骤

  1. 启动BSServer服务端;
  2. 打开任意浏览器(Chrome、Edge 等),在地址栏输入 http://localhost:8080
  3. 浏览器会显示服务端返回的 HTML 页面(红色标题 “Hello BS Architecture!” 和段落文本),实现 “浏览器访问自定义服务端” 的效果。

(4)BS 架构与 CS 架构的核心差异

通过这个练习,能更清晰地理解两种架构的区别:

对比维度B/S 架构(Browser/Server)C/S 架构(Client/Server)
客户端通用浏览器(无需安装专用软件)专用客户端(如 QQ、Navicat,需手动安装)
通信协议基于 HTTP/HTTPS 协议(TCP 的上层协议)可自定义协议(如 UDP、TCP 原生协议)
维护成本仅需维护服务端(客户端是浏览器,无需更新)需同时维护服务端和客户端(客户端需升级)
灵活性功能受浏览器限制(如无法直接操作本地文件)灵活(可定制复杂功能,如本地文件操作)
适用场景网页应用(如百度、淘宝、网课平台)桌面应用、移动端应用(如 QQ、微信、游戏)
分类: Java-Backend 标签: Java

评论

暂无评论数据

暂无评论数据

目录