7 个 Java 网络编程综合练习 —— 从 “会写” 到 “能用”
综合练习 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)客户端实现(文件上传方)
步骤拆解:
- 创建
Socket
,连接服务端; - 创建
FileInputStream
,读取本地要上传的文件(需指定文件路径,如D:/test.jpg
); - 获取 Socket 的输出流,将文件字节数据写入输出流;
- 上传完成后,调用
shutdownOutput()
告知服务端 “数据已发送完毕”; - 关闭所有流和 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)服务端实现(文件接收方)
步骤拆解:
- 创建
ServerSocket
,绑定端口(如 8888); - 调用
accept()
监听客户端连接,获取Socket
; - 创建
FileOutputStream
,指定文件保存路径(如E:/received_test.jpg
); - 获取 Socket 的输入流,读取客户端发送的文件字节数据;
- 将读取的字节数据写入
FileOutputStream
,保存到本地; - 关闭所有流和 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();
}
}
进阶优化:让客户端上传时 “主动发送原文件名”
实际开发中,原文件名不应在服务端写死,而是由客户端在上传文件前,先将原文件名发给服务端:
- 客户端流程:先发送原文件名(如
test.jpg
)→ 再发送文件数据; - 服务端流程:先读取客户端发送的原文件名→ 生成唯一文件名→ 再接收文件数据并保存。
客户端发送原文件名的核心代码:
// 1. 先发送原文件名(用输出流发送字符串)
OutputStream out = socket.getOutputStream();
String originalFileName = "test.jpg"; // 从本地文件路径中解析,此处简化
out.write(originalFileName.getBytes("UTF-8"));
socket.shutdownOutput(); // 注意:此处不能调用shutdownOutput(),否则后续无法发文件数据!
// 正确做法:用特殊字符(如“|”)分隔文件名和文件数据,或分两次获取流(需服务端配合)
注:分隔 “文件名” 和 “文件数据” 的更严谨方案是 “先发送文件名长度,再发送文件名,最后发送文件数据”,避免文件名中包含特殊字符导致解析错误,具体可结合 IO 流的 “长度前缀法” 实现。
综合练习 05:多线程版 TCP 服务端
问题场景:之前的服务端只能处理一个客户端请求(处理完一个才会accept()
下一个),若多个客户端同时连接,会出现 “排队等待”—— 这不符合实际服务端 “并发处理多客户端” 的需求。
解决方案:用多线程,让服务端的 “主线程” 只负责accept()
监听连接,每次收到客户端连接后,启动一个 “子线程” 专门处理该客户端的通信(如文件上传、消息交互),主线程继续监听下一个连接。
多线程服务端实现思路
- 主线程:创建
ServerSocket
,循环调用accept()
,每接收一个客户端连接,就创建一个ClientHandler
线程(实现Runnable
),并传入Socket
; - 子线程(
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
),预先创建固定数量的线程,客户端连接到来时,从线程池 “复用” 线程处理,处理完后线程不销毁,放回线程池供后续使用,避免频繁创建线程的开销。
线程池服务端实现思路
- 创建固定大小的线程池(如
Executors.newFixedThreadPool(10)
,表示最多同时处理 10 个客户端); - 主线程循环
accept()
客户端连接,将ClientHandler
任务提交给线程池(executorService.submit(handler)
); - 线程池中的线程处理完任务后,自动回到线程池,等待下一个任务。
代码示例:线程池 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 件事:
- 用 TCP 接收浏览器发送的 HTTP 请求;
- 构造符合 HTTP 格式的响应(包含 HTML 内容);
- 通过 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)测试步骤
- 启动
BSServer
服务端; - 打开任意浏览器(Chrome、Edge 等),在地址栏输入
http://localhost:8080
; - 浏览器会显示服务端返回的 HTML 页面(红色标题 “Hello BS Architecture!” 和段落文本),实现 “浏览器访问自定义服务端” 的效果。
(4)BS 架构与 CS 架构的核心差异
通过这个练习,能更清晰地理解两种架构的区别:
对比维度 | B/S 架构(Browser/Server) | C/S 架构(Client/Server) |
---|---|---|
客户端 | 通用浏览器(无需安装专用软件) | 专用客户端(如 QQ、Navicat,需手动安装) |
通信协议 | 基于 HTTP/HTTPS 协议(TCP 的上层协议) | 可自定义协议(如 UDP、TCP 原生协议) |
维护成本 | 仅需维护服务端(客户端是浏览器,无需更新) | 需同时维护服务端和客户端(客户端需升级) |
灵活性 | 功能受浏览器限制(如无法直接操作本地文件) | 灵活(可定制复杂功能,如本地文件操作) |
适用场景 | 网页应用(如百度、淘宝、网课平台) | 桌面应用、移动端应用(如 QQ、微信、游戏) |
本文系作者 @xiin 原创发布在To Future$站点。未经许可,禁止转载。
暂无评论数据