Java学习笔记(十五-续):网络编程实践


声明:本篇笔记遵循CC BY 4.0协议。存在由AI生成的小部分内容,仅供参考,请仔细甄别可能存在的错误。


假设我们在开发一款专注软件,核心功能是由用户端设置专注的时长,然后由服务端进行在线监督并且实时返回剩余的时间,实现一个联网的计时器服务。

常见软件项目开发需要经过这样几个主要流程:

  1. 需求分析:分析软件具体的功能(跟甲方各种聊天…);
  2. 技术选型:选择合适的开发技术(开发框架、数据库等);
  3. 总体设计:设计软件的基本架构、数据结构、各个模块、网络接口等详细内容;
  4. 程序编写:编写软件的代码程序,打包测试版本(普通意义上的“程序员”的工作…);
  5. 软件测试:测试软件的功能以及性能是否达到预期标准,是否存在影响正常使用的恶性bug;
  6. 发布上架:完成审核备案等流程,上线到应用商店、小程序等公众平台,正式对用户开放使用;
  7. 运维迭代:软件上线后的运营和维护,以及现有问题的优化解决、新版本的升级迭代。

我们计划在Intellij IDEA中,基于之前学习的多线程、TCP编程以及UDP编程等Java技术,根据上面的步骤来从0实现这个联网计时器。

一、分析与规划

1.分析项目具体需求

在做任何项目之前,我们需要清楚这个项目需要实现什么功能,接受怎样的输入和提供怎样的输出。这样的需求往往在工作中由甲方在与产品经理的交流中总结出来;但是当我们自己开发项目时,或者我们有成为产品经理的志向时,拥有专业准确的需求分析与概括能力是至关重要的。

简单概括一下我们的需求:

  • 核心功能:
    • 客户端发起计时请求
    • 服务端接受请求,并且持续告诉客户端剩余的时间
  • UI界面
    • 这里为了简化项目,计划采用终端来呈现数据的输入/输出

2.选择开发技术

  • 我们选择Java SE来开发这个项目,采用Java 21版本的JDK进行程序编写工作;
  • 注意到客户端发起的计时请求包含时长数据,这个数据类似于微信消息,不允许丢失或者出错,因此考虑使用更可靠的TCP协议
  • 服务端告知剩余时间的行为类似于视频通话,需要很快的传输速度,同时可以接受一小部分的丢失(表现为低延迟,网络状况不良时略有卡顿),适合使用更快捷的UDP协议
  • 暂无数据持久化保存的需求,不考虑使用文件或数据库来保存数据。
  • 服务端要求具有并行处理能力,计划使用线程池来实现。
  • 需要并行处理大量计时任务,计划采取时间轮算法[1]

注:这里只是为了兼顾TCP和UDP,在初学者的角度展现两种通信协议的特点,采取了两种协议都使用的策略;实际开发中有综合使用的场景,也经常只用TCP或只用UDP;另外也不要产生“客户端用TCP,服务端用UDP”的错觉;实际上由哪一方使用哪种协议是很灵活的;需要根据各种协议的优势来综合考虑。

3.程序详细设计

① 功能设计

  • 客户端:
    • 前端输入:首先由用户输入“MM:SS”格式的时间,并且通过正则表达式进行合法性校验;
    • 后端请求:将时长发送到服务端,使用TCP协议发起计时请求。
  • 服务端:
    • 请求处理:接收客户端的计时请求,每过1s返回剩余的时间,计时结束后进行提醒。

② 性能参数(主要是服务端的性能)

  • 误差与延迟:服务端计时误差应该不超过±1s/60s\pm 1s/60s,响应延迟在100ms以内;
  • 抗压能力:要求拥有一定的并行处理能力,同时处理不少于10个客户端的计时任务;

二、软件编写与打包

1.开发环境搭建

  1. 首先在IDEA中新建项目NetworkTimer

  1. 我们的项目构建工具Maven已在(十四)节中介绍过,如果没有印象可以先回头复习一下;现在我们在src/main/java中新建两个Java类,分别命名为ClientServer,即客户端程序与服务端程序的源代码文件:

2.客户端编程

根据之前的学习和上面的分析,写出下面的客户端程序;重点部分已经通过注释说明,且附有关键工作流程详解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
// Client.java

import java.io.*;
import java.net.*;
import java.util.Scanner;
import java.util.regex.Pattern;

public class Client {
// 预编译正则表达式,提高检验效率
private static final Pattern TIME_PATTERN = Pattern.compile("^\\d+:[0-5]\\d$");

public static void main(String[] args) {
// 配置服务器地址与端口信息
String serverAddress = "localhost";
int tcpPort = 6606; // 用于传输重要数据的TCP端口
int udpPort = 6606; // 用于接收剩余时间的UDP端口

Socket tcpSocket = null;
try {
// 1. 创建TCP连接
tcpSocket = new Socket(serverAddress, tcpPort);
System.out.printf("(成功连接到服务器 %s:%d)\n", tcpSocket.getInetAddress(), tcpSocket.getPort());

// 2. 获取输入输出流
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(tcpSocket.getOutputStream()));
BufferedReader reader = new BufferedReader(new InputStreamReader(tcpSocket.getInputStream()));
Scanner localReader = new Scanner(System.in);

// 3. 获取并校验用户输入的专注时长
System.out.print("> 请输入专注时长(格式为MM:SS):");
String totalTime = localReader.nextLine();
while (totalTime.isEmpty() || !TIME_PATTERN.matcher(totalTime).matches()) {
System.out.print("> 格式错误,请重新输入:");
totalTime = localReader.nextLine();
}

// 4. 启动一个独立的线程来接收UDP广播的剩余时间
// 这样可以在等待服务器计时结束消息的同时,实时显示倒计时
startUdpReceiver(udpPort);

// 5. 通过TCP向服务器发送计时请求
System.out.println("(正在向服务器发送计时请求...)");
writer.write("START:" + totalTime);
writer.newLine(); // 发送换行符作为消息结束的标志
writer.flush();
System.out.println("\n--- 计时请求已发送,请开始专注! ---");

// 6. 等待服务器发来的计时结束消息(通过TCP)
// 这会阻塞当前线程,直到服务器发送数据
String endMessage = reader.readLine();
if (endMessage != null && endMessage.equals("FINISH")) {
System.out.println("\n--- 计时结束!恭喜你完成了专注! ---\n");
} else {
System.out.println("\n--- 已断开与服务器的连接。 ---\n");
}

} catch (IOException e) {
System.err.println("> 连接服务器或通信时发生错误:" + e.getMessage());
} finally {
// 7. 关闭TCP连接
if (tcpSocket != null && !tcpSocket.isClosed()) {
try {
tcpSocket.close();
System.out.println("> 已断开与服务器的TCP连接。");
} catch (IOException e) {
e.printStackTrace();
}
}
System.exit(0); // 退出程序
}
}

/**
* 启动一个后台线程,专门用于通过UDP接收服务器发送的剩余时间。
*/
private static void startUdpReceiver(int port) {
Thread udpThread = new Thread(() -> {
DatagramSocket socket = null;
try {
// 创建UDP套接字并绑定到指定端口
socket = new DatagramSocket(port);
byte[] buffer = new byte[1024];
DatagramPacket packet = new DatagramPacket(buffer, buffer.length);

System.out.println("(UDP接收线程已启动,正在监听剩余时间...)");

while (!Thread.currentThread().isInterrupted()) {
// 阻塞等待接收数据包
socket.receive(packet);
String timeLeft = new String(packet.getData(), 0, packet.getLength());

// 在同一行更新时间,而不是每次都换行
System.out.print("\r> 剩余时间: " + timeLeft + " ");
}
} catch (SocketException e) {
// 如果是因为线程中断而关闭socket,这是正常情况,无需打印错误
if (!Thread.currentThread().isInterrupted()) {
System.err.println("> UDP套接字错误: " + e.getMessage());
}
} catch (IOException e) {
System.err.println("> UDP接收数据失败: " + e.getMessage());
} finally {
if (socket != null && !socket.isClosed()) {
socket.close();
}
System.out.println("\n(UDP接收线程已停止。)");
}
});

// 设置为守护线程,这样当主线程退出时,这个线程也会自动退出
udpThread.setDaemon(true);
udpThread.start();
}
}
  1. TCP 连接与指令发送
    • 程序首先建立TCP连接,这是可靠通信的基础。
    • 获取用户输入并校验后,它将指令格式化为 START:MM:SS (例如 START:25:00),然后通过 TCP 发送给服务器。
  2. UDP 接收线程 (startUdpReceiver 方法)
    • 在发送TCP指令之前,我们启动了一个新的线程。这是关键设计。
    • 这个新线程负责创建 DatagramSocket 并在指定的UDP端口(6606)上循环监听。
    • System.out.print("\r剩余时间: " + timeLeft + " "); 这行代码使用 \r(回车符)将光标移回行首,从而实现了在同一行刷新时间的效果,用户体验更好。
    • 这个线程被设置为守护线程 (setDaemon(true))。当主线程(main 方法)结束时,这个守护线程会被自动终止,程序就能正常退出。
  3. 等待计时结束
    • 主线程在发送完指令后,执行 reader.readLine()。这个方法会阻塞,直到从 TCP 连接的另一端(服务器)收到一行文本。
    • 当服务器计时结束后,它会通过这个 TCP 连接向客户端发送一个 "FINISH" 字符串。
    • 客户端收到 "FINISH" 后,打印恭喜信息,然后程序继续执行到 finally 块,关闭 TCP 连接,整个程序退出。此时,UDP 守护线程也会随之结束。

3.服务端编程

根据之前的学习和上面的分析,写出下面的服务端程序;重点部分也已经通过注释说明,且附有关键工作流程详解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
// Server.java

import java.io.*;
import java.net.*;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public class Server {
// 服务器端口配置
private static final int TCP_PORT = 6606;
private static final int UDP_PORT = 6606;
// 线程池配置
private static final int THREAD_POOL_SIZE = 10;

public static void main(String[] args) {
// 1. 创建一个固定大小的线程池
ExecutorService threadPool = Executors.newFixedThreadPool(THREAD_POOL_SIZE);

ServerSocket serverSocket = null;
try {
// 2. 创建ServerSocket并绑定端口
serverSocket = new ServerSocket(TCP_PORT);
System.out.println("服务器已启动,正在 " + TCP_PORT + " 端口监听TCP连接...");

while (true) { // 循环接受客户端连接
try {
// 3. 阻塞等待客户端连接
Socket clientSocket = serverSocket.accept();
// **优化点**:在主循环中也可以创建clientId,使日志更简洁
String clientId = clientSocket.getInetAddress() + ":" + clientSocket.getPort();
System.out.printf("\n新客户端已连接: %s\n", clientId);

// 4. 将客户端连接交给线程池处理
threadPool.submit(new ClientHandler(clientSocket));

} catch (IOException e) {
System.err.println("\n接受客户端连接时发生错误: " + e.getMessage());
}
}
} catch (IOException e) {
System.err.println("\n服务器启动失败: " + e.getMessage());
} finally {
// 关闭服务器资源
if (serverSocket != null && !serverSocket.isClosed()) {
try {
serverSocket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
// 优雅地关闭线程池
threadPool.shutdown();
try {
if (!threadPool.awaitTermination(5, TimeUnit.SECONDS)) {
threadPool.shutdownNow();
}
} catch (InterruptedException e) {
threadPool.shutdownNow();
}
System.out.println("\n服务器已关闭。");
}
}

/**
* 内部类,用于处理单个客户端的计时请求
*/
private static class ClientHandler implements Runnable {
private final Socket clientSocket;

public ClientHandler(Socket socket) {
this.clientSocket = socket;
}

@Override
public void run() {
// **核心优化**:在方法开始时就创建好客户端唯一标识符的字符串
// 这样后续所有日志输出都可以直接复用,避免了重复计算和字符串拼接
String clientId = clientSocket.getInetAddress() + ":" + clientSocket.getPort();

// 使用try-with-resources确保流和socket被自动关闭
try (BufferedReader reader = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(clientSocket.getOutputStream()))) {

// 1. 读取客户端发送的计时请求
String request = reader.readLine();
if (request == null || !request.startsWith("START:")) {
System.err.println("客户端 " + clientId + " 的请求格式错误: " + request);
return; // 格式错误,直接关闭连接
}

// 2. 解析请求,获取总时长(单位:秒)
String timeString = request.substring("START:".length());
String[] parts = timeString.split(":");
int minutes = Integer.parseInt(parts[0]);
int seconds = Integer.parseInt(parts[1]);
int totalSeconds = minutes * 60 + seconds;

System.out.printf("客户端 %s 请求计时: %d分%d秒\n", clientId, minutes, seconds);

// 3. 初始化UDP发送器
DatagramSocket udpSocket = new DatagramSocket();
InetAddress clientAddress = clientSocket.getInetAddress(); // 获取客户端的IP地址

// 4. 循环倒计时并发送UDP消息
for (int i = totalSeconds; i >= 0; i--) {
// 格式化剩余时间为 MM:SS String timeLeft = String.format("%02d:%02d", i / 60, i % 60);

// 准备UDP数据包
byte[] buffer = timeLeft.getBytes();
DatagramPacket packet = new DatagramPacket(buffer, buffer.length, clientAddress, UDP_PORT);

// 发送UDP数据包
udpSocket.send(packet);

// 等待1秒
Thread.sleep(1000);
}
udpSocket.close(); // 计时结束,关闭UDP套接字

// 5. 计时结束,通过TCP发送FINISH标志
System.out.println("客户端 " + clientId + " 计时结束。");
writer.write("FINISH");
writer.newLine();
writer.flush();

} catch (IOException | InterruptedException e) {
// 如果客户端中途断开连接,这里会抛出异常,这是正常情况
System.out.println("与客户端 " + clientId + " 的通信中断: " + e.getMessage() + "\n");
} finally {
// 确保客户端socket被关闭
try {
clientSocket.close();
} catch (IOException e) { }
System.out.println("客户端 " + clientId + " 的连接已关闭。" + "\n");
}
}
}
}
  1. 启动服务端
    • 服务器启动后,开启对端口6606的监听;
  2. 启动客户端
    • 服务端与客户端建立TCP连接,等待客户端发送计时请求;
  3. 交互与计时
    • 接收到客户端发送的TCP请求后,分发给线程池中的线程处理;
    • 子线程解析请求中的时间参数,并且初始化UDP发送器;
    • 每隔1s通过UDP发送器发送剩余时间,计时结束通过TCP发送FINISH标志;
  4. 计时结束
    • 完成计时任务,断开与客户端的连接;
    • 线程空闲,回到线程池中等待新的任务。

三、软件测试

现在我们已经完成了程序的代码编写,运行测试一下是否能达到预期的效果:

1.普通测试

  1. 先运行服务端,再运行服务端(否则客户端无法连接会报错):

  1. 然后再客户端输入一个时间值,然后按回车发送,注意中间的冒号是英文的(:),可以看到服务端持续传来了剩余时间,并且在结束之后提醒了“计时结束”的信息:

通过上面的测试,认为软件基本达到了预期的功能。

2.并发测试

上面只是进行了一次连接,但是实际情况下可能会有多个用户端同时连接到服务端,需要测试服务端的并发处理能力,以下是测试的步骤,大家可以跟着复刻一下:

  1. 尝试在客户端运行时再次运行客户端,提示“不允许并行运行Client”。我们需要手动允许重复运行此类:

  • 右键编辑器区域,选择“修改运行配置”:
  • 点击“修改选项(M)”,勾选“允许多个实例”:


  • 点击右下角的“确定”,现在就可以重复运行Client类了。
  1. 尝试同时通过两个客户端请求计时服务,后面请求的一个遇到了UDP的报错 UDP套接字错误: Address already in use: bind(懒得录GIF了,自己运行代码复现):

  2. 通过询问AI,得到了“同一台机器上运行多个客户端程序,同时抢占UDP的6606端口导致冲突”的原因;进一步追问“为什么TCP不会有冲突”,得到如下解释:

    • TCP 不冲突,是因为只有服务器在6606监听,客户端各自用随机临时端口去连。
    • UDP 冲突,是因为让每个客户端进程都通过同一个6606端口去收包,操作系统不允许。
  3. 将客户端和服务端修改成如下程序即可(参考注释理解):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
/* ------------------------- Client.java ------------------------- */
import java.io.*;
import java.net.*;
import java.util.Scanner;
import java.util.regex.Pattern;

public class Client {
// 预编译正则表达式,提高检验效率
private static final Pattern TIME_PATTERN = Pattern.compile("^\\d+:[0-5]\\d$");

public static void main(String[] args) {
// 配置服务器地址与端口信息
String serverAddress = "localhost";
int tcpPort = 6606; // 用于传输重要数据的TCP端口

Socket tcpSocket = null;
try {
// 1. 创建TCP连接
tcpSocket = new Socket(serverAddress, tcpPort);
System.out.printf("(成功连接到服务器 %s:%d)\n", tcpSocket.getInetAddress(), tcpSocket.getPort());

// 2. 获取输入输出流
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(tcpSocket.getOutputStream()));
BufferedReader reader = new BufferedReader(new InputStreamReader(tcpSocket.getInputStream()));
Scanner localReader = new Scanner(System.in);

// 3. 获取并校验用户输入的专注时长
System.out.print("> 请输入专注时长(格式为MM:SS):");
String totalTime = localReader.nextLine();
while (totalTime.isEmpty() || !TIME_PATTERN.matcher(totalTime).matches()) {
System.out.print("> 格式错误,请重新输入:");
totalTime = localReader.nextLine();
}

// 4. 启动一个独立的线程来接收UDP广播的剩余时间
// 这次调用会返回系统分配的随机UDP端口号
int assignedUdpPort = startUdpReceiver();
System.out.println("(UDP接收线程已启动,系统分配的端口号为: " + assignedUdpPort + ")");


// 5. 通过TCP向服务器发送计时请求和UDP端口号
System.out.println("(正在向服务器发送计时请求...)");
writer.write("START:" + totalTime);
writer.newLine();

// --- 核心修改点 1: 发送系统分配的UDP端口号 --- writer.write("UDP_PORT:" + assignedUdpPort);
writer.newLine();
writer.flush();
// ----------------------------------------------

System.out.println("\n--- 计时请求已发送,请开始专注! ---");

// 6. 等待服务器发来的计时结束消息(通过TCP)
String endMessage = reader.readLine();
if (endMessage != null && endMessage.equals("FINISH")) {
System.out.println("\n--- 计时结束!恭喜你完成了专注! ---\n");
} else {
System.out.println("\n--- 已断开与服务器的连接。 ---\n");
}

} catch (IOException e) {
System.err.println("> 连接服务器或通信时发生错误:" + e.getMessage());
} finally {
// 7. 关闭TCP连接
if (tcpSocket != null && !tcpSocket.isClosed()) {
try {
tcpSocket.close();
System.out.println("> 已断开与服务器的TCP连接。");
} catch (IOException e) {
e.printStackTrace();
}
}
// 让主线程等待一小会儿,以便UDP线程有时间打印退出信息
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.exit(0); // 退出程序
}
}

/**
* 启动一个后台线程,专门用于通过UDP接收服务器发送的剩余时间。
* 该线程会让系统自动分配一个可用的UDP端口。
* @return 系统为该客户端分配的UDP端口号。
*/
private static int startUdpReceiver() {
// 使用一个数组来传递端口号,因为在lambda表达式中使用的外部变量必须是final或effectively final
final int[] assignedPort = new int[1];

Thread udpThread = new Thread(() -> {
DatagramSocket socket = null;
try {
// --- 核心修改点 2: 创建不指定端口的DatagramSocket ---
// 操作系统会自动为我们选择一个可用的端口
socket = new DatagramSocket();
// ----------------------------------------------------

// --- 核心修改点 3: 获取并存储系统分配的端口号 --- assignedPort[0] = socket.getLocalPort();
// ----------------------------------------------------

byte[] buffer = new byte[1024];
DatagramPacket packet = new DatagramPacket(buffer, buffer.length);

// 等待端口号被成功获取后再打印
while (assignedPort[0] == 0) {
Thread.sleep(10);
}
System.out.println("(UDP接收线程已启动,正在监听 " + assignedPort[0] + " 端口...)");

while (!Thread.currentThread().isInterrupted()) {
socket.receive(packet);
String timeLeft = new String(packet.getData(), 0, packet.getLength());
System.out.print("\r> 剩余时间: " + timeLeft + " ");
}
} catch (SocketException e) {
// 如果是因为线程中断而关闭socket,这是正常情况
if (!e.getMessage().contains("socket closed")) {
System.err.println("> UDP套接字错误: " + e.getMessage());
}
} catch (IOException | InterruptedException e) {
System.err.println("> UDP接收数据失败: " + e.getMessage());
} finally {
if (socket != null && !socket.isClosed()) {
socket.close();
}
System.out.println("\n(UDP接收线程已停止。)");
}
});

udpThread.setDaemon(true);
udpThread.start();

// --- 核心修改点 4: 等待线程获取到端口号后再返回 --- // 循环等待,直到子线程成功获取到端口号
while (assignedPort[0] == 0) {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// ----------------------------------------------------

return assignedPort[0];
}
}
/* --------------------------------------------------------------- */
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
/* ------------------------- Server.java ------------------------- */
import java.io.*;
import java.net.*;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public class Server {
// 服务器端口配置
private static final int TCP_PORT = 6606;
// private static final int UDP_PORT = 6606;
// 线程池配置
private static final int THREAD_POOL_SIZE = 10;

public static void main(String[] args) {
// 1. 创建一个固定大小的线程池
ExecutorService threadPool = Executors.newFixedThreadPool(THREAD_POOL_SIZE);

ServerSocket serverSocket = null;
try {
// 2. 创建ServerSocket并绑定端口
serverSocket = new ServerSocket(TCP_PORT);
System.out.println("服务器已启动,正在 " + TCP_PORT + " 端口监听TCP连接...");

while (true) { // 循环接受客户端连接
try {
// 3. 阻塞等待客户端连接
Socket clientSocket = serverSocket.accept();
// **优化点**:在主循环中也可以创建clientId,使日志更简洁
String clientId = clientSocket.getInetAddress() + ":" + clientSocket.getPort();
System.out.printf("\n新客户端已连接: %s\n", clientId);

// 4. 将客户端连接交给线程池处理
threadPool.submit(new ClientHandler(clientSocket));

} catch (IOException e) {
System.err.println("\n接受客户端连接时发生错误: " + e.getMessage());
}
}
} catch (IOException e) {
System.err.println("\n服务器启动失败: " + e.getMessage());
} finally {
// 关闭服务器资源
if (serverSocket != null && !serverSocket.isClosed()) {
try {
serverSocket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
// 优雅地关闭线程池
threadPool.shutdown();
try {
if (!threadPool.awaitTermination(5, TimeUnit.SECONDS)) {
threadPool.shutdownNow();
}
} catch (InterruptedException e) {
threadPool.shutdownNow();
}
System.out.println("\n服务器已关闭。");
}
}

/**
* 内部类,用于处理单个客户端的计时请求
*/
private static class ClientHandler implements Runnable {
private final Socket clientSocket;

public ClientHandler(Socket socket) {
this.clientSocket = socket;
}

@Override
public void run() {
// **核心优化**:在方法开始时就创建好客户端唯一标识符的字符串
// 这样后续所有日志输出都可以直接复用,避免了重复计算和字符串拼接
String clientId = clientSocket.getInetAddress() + ":" + clientSocket.getPort();

// 使用try-with-resources确保流和socket被自动关闭
try (BufferedReader reader = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(clientSocket.getOutputStream()))) {

// 1. 读取客户端发送的计时请求
String request = reader.readLine();
if (request == null || !request.startsWith("START:")) {
System.err.println("客户端 " + clientId + " 的请求格式错误: " + request);
return; // 格式错误,直接关闭连接
}
String udpLine = reader.readLine(); // 读取后面发送的客户端UDP端口信息
int udpPort = Integer.parseInt(udpLine.split(":")[1]); // 拆解出端口号

// 2. 解析请求,获取总时长(单位:秒)
String timeString = request.substring("START:".length());
String[] parts = timeString.split(":");
int minutes = Integer.parseInt(parts[0]);
int seconds = Integer.parseInt(parts[1]);
int totalSeconds = minutes * 60 + seconds;

System.out.printf("客户端 %s 请求计时: %d分%d秒\n", clientId, minutes, seconds);

// 3. 初始化UDP发送器
DatagramSocket udpSocket = new DatagramSocket();
InetAddress clientAddress = clientSocket.getInetAddress(); // 获取客户端的IP地址

// 4. 循环倒计时并发送UDP消息
for (int i = totalSeconds; i >= 0; i--) {
// 格式化剩余时间为 MM:SS String timeLeft = String.format("%02d:%02d", i / 60, i % 60);

// 准备UDP数据包
byte[] buffer = timeLeft.getBytes();
DatagramPacket packet = new DatagramPacket(buffer, buffer.length, clientAddress, udpPort);

// 发送UDP数据包
udpSocket.send(packet);

// 等待1秒
Thread.sleep(1000);
}
udpSocket.close(); // 计时结束,关闭UDP套接字

// 5. 计时结束,通过TCP发送FINISH标志
System.out.println("客户端 " + clientId + " 计时结束。");
writer.write("FINISH");
writer.newLine();
writer.flush();

} catch (IOException | InterruptedException e) {
// 如果客户端中途断开连接,这里会抛出异常,这是正常情况
System.out.println("与客户端 " + clientId + " 的通信中断: " + e.getMessage() + "\n");
} finally {
// 确保客户端socket被关闭
try {
clientSocket.close();
} catch (IOException e) { }
System.out.println("客户端 " + clientId + " 的连接已关闭。" + "\n");
}
}
}
}
/* --------------------------------------------------------------- */
  1. 重新编译运行新版本的程序,成功给两个客户端都返回了对应的计时和结束通知,没有发生冲突,表明程序目前没有了致命问题,基本上通过了测试:

四、总结与延伸

由于本项目仅用于演示TCP、UDP以及多线程在处理服务端-用户端通信时的应用,暂无实际使用的价值,这里不提供具体的打包发布流程。若感兴趣,可自行探索打包成jar/exe版本分发给用户运行;也可以不使用localhost,通过局域网ip甚至是公网ip,在两台设备上通过互联网来通信,实现真正意义上的“网络计时器”。

这个项目通过“基于互联网的服务端-客户端网络计时器”需求,应用了可靠的TCP与快速的UDP双协议多线程(线程池)并发处理正则表达式校验输入值等技术及其特点,在复习前阶段所学知识的同时,演示了具体开发项目从需求分析到代码编写,以及后续的测试优化的流程,是作为新手入门Java网络编程较好的参考资料;同时也存在很多改进空间,如缺少图形化界面,对用户操作不友好;没有使用时间轮算法,高并发下的大量计时任务性能差;没有实现真正的联网服务等;学有余力的情况下,可以在此基础上深入学习和改进,迭代出真正具有实用价值,甚至是商业价值的软件产品。


参考资料

  1. 程序员Derozan.一文直接搞懂时间轮算法的精妙之处[EB/OL].(2023-02-25)[2025-10-01]. https://zhuanlan.zhihu.com/p/609284043

Java学习笔记(十五-续):网络编程实践
http://blog.morely.top/2025/10/07/Java学习笔记(十五-续):网络编程实践/
作者
陌离
发布于
2025年10月7日
许可协议