声明:本篇笔记遵循CC BY 4.0协议。存在由AI生成的小部分内容,仅供参考,请仔细甄别可能存在的错误。
假设我们在开发一款专注软件,核心功能是由用户端设置专注的时长,然后由服务端进行在线监督并且实时返回剩余的时间,实现一个联网的计时器服务。
常见软件项目开发需要经过这样几个主要流程:
- 需求分析:分析软件具体的功能(跟甲方各种聊天…);
- 技术选型:选择合适的开发技术(开发框架、数据库等);
- 总体设计:设计软件的基本架构、数据结构、各个模块、网络接口等详细内容;
- 程序编写:编写软件的代码程序,打包测试版本(普通意义上的“程序员”的工作…);
- 软件测试:测试软件的功能以及性能是否达到预期标准,是否存在影响正常使用的恶性bug;
- 发布上架:完成审核备案等流程,上线到应用商店、小程序等公众平台,正式对用户开放使用;
- 运维迭代:软件上线后的运营和维护,以及现有问题的优化解决、新版本的升级迭代。
我们计划在Intellij IDEA中,基于之前学习的多线程、TCP编程以及UDP编程等Java技术,根据上面的步骤来从0实现这个联网计时器。
一、分析与规划
1.分析项目具体需求
在做任何项目之前,我们需要清楚这个项目需要实现什么功能,接受怎样的输入和提供怎样的输出。这样的需求往往在工作中由甲方在与产品经理的交流中总结出来;但是当我们自己开发项目时,或者我们有成为产品经理的志向时,拥有专业准确的需求分析与概括能力是至关重要的。
简单概括一下我们的需求:
- 核心功能:
- 客户端发起计时请求
- 服务端接受请求,并且持续告诉客户端剩余的时间
- UI界面
- 这里为了简化项目,计划采用终端来呈现数据的输入/输出
2.选择开发技术
- 我们选择Java SE来开发这个项目,采用
Java 21版本的JDK进行程序编写工作;
- 注意到客户端发起的计时请求包含时长数据,这个数据类似于微信消息,不允许丢失或者出错,因此考虑使用更可靠的TCP协议;
- 服务端告知剩余时间的行为类似于视频通话,需要很快的传输速度,同时可以接受一小部分的丢失(表现为低延迟,网络状况不良时略有卡顿),适合使用更快捷的UDP协议;
- 暂无数据持久化保存的需求,不考虑使用文件或数据库来保存数据。
- 服务端要求具有并行处理能力,计划使用线程池来实现。
- 需要并行处理大量计时任务,计划采取时间轮算法。
注:这里只是为了兼顾TCP和UDP,在初学者的角度展现两种通信协议的特点,采取了两种协议都使用的策略;实际开发中有综合使用的场景,也经常只用TCP或只用UDP;另外也不要产生“客户端用TCP,服务端用UDP”的错觉;实际上由哪一方使用哪种协议是很灵活的;需要根据各种协议的优势来综合考虑。
3.程序详细设计
① 功能设计
- 客户端:
- 前端输入:首先由用户输入“MM:SS”格式的时间,并且通过正则表达式进行合法性校验;
- 后端请求:将时长发送到服务端,使用TCP协议发起计时请求。
- 服务端:
- 请求处理:接收客户端的计时请求,每过1s返回剩余的时间,计时结束后进行提醒。
② 性能参数(主要是服务端的性能)
- 误差与延迟:服务端计时误差应该不超过±1s/60s,响应延迟在100ms以内;
- 抗压能力:要求拥有一定的并行处理能力,同时处理不少于10个客户端的计时任务;
二、软件编写与打包
1.开发环境搭建
- 首先在IDEA中新建项目
NetworkTimer:

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

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
|
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; int udpPort = 6606; Socket tcpSocket = null; try { tcpSocket = new Socket(serverAddress, tcpPort); System.out.printf("(成功连接到服务器 %s:%d)\n", tcpSocket.getInetAddress(), tcpSocket.getPort()); BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(tcpSocket.getOutputStream())); BufferedReader reader = new BufferedReader(new InputStreamReader(tcpSocket.getInputStream())); Scanner localReader = new Scanner(System.in); System.out.print("> 请输入专注时长(格式为MM:SS):"); String totalTime = localReader.nextLine(); while (totalTime.isEmpty() || !TIME_PATTERN.matcher(totalTime).matches()) { System.out.print("> 格式错误,请重新输入:"); totalTime = localReader.nextLine(); } startUdpReceiver(udpPort); System.out.println("(正在向服务器发送计时请求...)"); writer.write("START:" + totalTime); writer.newLine(); writer.flush(); System.out.println("\n--- 计时请求已发送,请开始专注! ---"); 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 { if (tcpSocket != null && !tcpSocket.isClosed()) { try { tcpSocket.close(); System.out.println("> 已断开与服务器的TCP连接。"); } catch (IOException e) { e.printStackTrace(); } } System.exit(0); } }
private static void startUdpReceiver(int port) { Thread udpThread = new Thread(() -> { DatagramSocket socket = null; try { 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) { 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(); } }
|
- TCP 连接与指令发送:
- 程序首先建立TCP连接,这是可靠通信的基础。
- 获取用户输入并校验后,它将指令格式化为
START:MM:SS (例如 START:25:00),然后通过 TCP 发送给服务器。
- UDP 接收线程 (
startUdpReceiver 方法):
- 在发送TCP指令之前,我们启动了一个新的线程。这是关键设计。
- 这个新线程负责创建
DatagramSocket 并在指定的UDP端口(6606)上循环监听。
System.out.print("\r剩余时间: " + timeLeft + " "); 这行代码使用 \r(回车符)将光标移回行首,从而实现了在同一行刷新时间的效果,用户体验更好。
- 这个线程被设置为守护线程 (
setDaemon(true))。当主线程(main 方法)结束时,这个守护线程会被自动终止,程序就能正常退出。
- 等待计时结束:
- 主线程在发送完指令后,执行
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
|
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) { ExecutorService threadPool = Executors.newFixedThreadPool(THREAD_POOL_SIZE); ServerSocket serverSocket = null; try { serverSocket = new ServerSocket(TCP_PORT); System.out.println("服务器已启动,正在 " + TCP_PORT + " 端口监听TCP连接..."); while (true) { try { Socket clientSocket = serverSocket.accept(); String clientId = clientSocket.getInetAddress() + ":" + clientSocket.getPort(); System.out.printf("\n新客户端已连接: %s\n", clientId); 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 (BufferedReader reader = new BufferedReader(new InputStreamReader(clientSocket.getInputStream())); BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(clientSocket.getOutputStream()))) { String request = reader.readLine(); if (request == null || !request.startsWith("START:")) { System.err.println("客户端 " + clientId + " 的请求格式错误: " + request); return; } 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); DatagramSocket udpSocket = new DatagramSocket(); InetAddress clientAddress = clientSocket.getInetAddress(); for (int i = totalSeconds; i >= 0; i--) { byte[] buffer = timeLeft.getBytes(); DatagramPacket packet = new DatagramPacket(buffer, buffer.length, clientAddress, UDP_PORT); udpSocket.send(packet); Thread.sleep(1000); } udpSocket.close(); System.out.println("客户端 " + clientId + " 计时结束。"); writer.write("FINISH"); writer.newLine(); writer.flush(); } catch (IOException | InterruptedException e) { System.out.println("与客户端 " + clientId + " 的通信中断: " + e.getMessage() + "\n"); } finally { try { clientSocket.close(); } catch (IOException e) { } System.out.println("客户端 " + clientId + " 的连接已关闭。" + "\n"); } } } }
|
- 启动服务端
- 启动客户端
- 服务端与客户端建立TCP连接,等待客户端发送计时请求;
- 交互与计时
- 接收到客户端发送的TCP请求后,分发给线程池中的线程处理;
- 子线程解析请求中的时间参数,并且初始化UDP发送器;
- 每隔1s通过UDP发送器发送剩余时间,计时结束通过TCP发送FINISH标志;
- 计时结束
- 完成计时任务,断开与客户端的连接;
- 线程空闲,回到线程池中等待新的任务。
三、软件测试
现在我们已经完成了程序的代码编写,运行测试一下是否能达到预期的效果:
1.普通测试
- 先运行服务端,再运行服务端(否则客户端无法连接会报错):

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

通过上面的测试,认为软件基本达到了预期的功能。
2.并发测试
上面只是进行了一次连接,但是实际情况下可能会有多个用户端同时连接到服务端,需要测试服务端的并发处理能力,以下是测试的步骤,大家可以跟着复刻一下:
- 尝试在客户端运行时再次运行客户端,提示“不允许并行运行Client”。我们需要手动允许重复运行此类:

- 右键编辑器区域,选择“修改运行配置”:

- 点击“修改选项(M)”,勾选“允许多个实例”:


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

-
通过询问AI,得到了“同一台机器上运行多个客户端程序,同时抢占UDP的6606端口导致冲突”的原因;进一步追问“为什么TCP不会有冲突”,得到如下解释:
- TCP 不冲突,是因为只有服务器在6606监听,客户端各自用随机临时端口去连。
- UDP 冲突,是因为让每个客户端进程都通过同一个6606端口去收包,操作系统不允许。
-
将客户端和服务端修改成如下程序即可(参考注释理解):
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
| 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; Socket tcpSocket = null; try { tcpSocket = new Socket(serverAddress, tcpPort); System.out.printf("(成功连接到服务器 %s:%d)\n", tcpSocket.getInetAddress(), tcpSocket.getPort()); BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(tcpSocket.getOutputStream())); BufferedReader reader = new BufferedReader(new InputStreamReader(tcpSocket.getInputStream())); Scanner localReader = new Scanner(System.in); System.out.print("> 请输入专注时长(格式为MM:SS):"); String totalTime = localReader.nextLine(); while (totalTime.isEmpty() || !TIME_PATTERN.matcher(totalTime).matches()) { System.out.print("> 格式错误,请重新输入:"); totalTime = localReader.nextLine(); } int assignedUdpPort = startUdpReceiver(); System.out.println("(UDP接收线程已启动,系统分配的端口号为: " + assignedUdpPort + ")"); System.out.println("(正在向服务器发送计时请求...)"); writer.write("START:" + totalTime); writer.newLine(); writer.newLine(); writer.flush(); System.out.println("\n--- 计时请求已发送,请开始专注! ---"); 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 { if (tcpSocket != null && !tcpSocket.isClosed()) { try { tcpSocket.close(); System.out.println("> 已断开与服务器的TCP连接。"); } catch (IOException e) { e.printStackTrace(); } } try { Thread.sleep(500); } catch (InterruptedException e) { e.printStackTrace(); } System.exit(0); } }
private static int startUdpReceiver() { final int[] assignedPort = new int[1]; Thread udpThread = new Thread(() -> { DatagramSocket socket = null; try { socket = new DatagramSocket(); 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) { 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(); 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
| 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 THREAD_POOL_SIZE = 10; public static void main(String[] args) { ExecutorService threadPool = Executors.newFixedThreadPool(THREAD_POOL_SIZE); ServerSocket serverSocket = null; try { serverSocket = new ServerSocket(TCP_PORT); System.out.println("服务器已启动,正在 " + TCP_PORT + " 端口监听TCP连接..."); while (true) { try { Socket clientSocket = serverSocket.accept(); String clientId = clientSocket.getInetAddress() + ":" + clientSocket.getPort(); System.out.printf("\n新客户端已连接: %s\n", clientId); 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 (BufferedReader reader = new BufferedReader(new InputStreamReader(clientSocket.getInputStream())); BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(clientSocket.getOutputStream()))) { String request = reader.readLine(); if (request == null || !request.startsWith("START:")) { System.err.println("客户端 " + clientId + " 的请求格式错误: " + request); return; } String udpLine = reader.readLine(); int udpPort = Integer.parseInt(udpLine.split(":")[1]); 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); DatagramSocket udpSocket = new DatagramSocket(); InetAddress clientAddress = clientSocket.getInetAddress(); for (int i = totalSeconds; i >= 0; i--) { byte[] buffer = timeLeft.getBytes(); DatagramPacket packet = new DatagramPacket(buffer, buffer.length, clientAddress, udpPort); udpSocket.send(packet); Thread.sleep(1000); } udpSocket.close(); System.out.println("客户端 " + clientId + " 计时结束。"); writer.write("FINISH"); writer.newLine(); writer.flush(); } catch (IOException | InterruptedException e) { System.out.println("与客户端 " + clientId + " 的通信中断: " + e.getMessage() + "\n"); } finally { try { clientSocket.close(); } catch (IOException e) { } System.out.println("客户端 " + clientId + " 的连接已关闭。" + "\n"); } } } }
|
- 重新编译运行新版本的程序,成功给两个客户端都返回了对应的计时和结束通知,没有发生冲突,表明程序目前没有了致命问题,基本上通过了测试:

四、总结与延伸
由于本项目仅用于演示TCP、UDP以及多线程在处理服务端-用户端通信时的应用,暂无实际使用的价值,这里不提供具体的打包发布流程。若感兴趣,可自行探索打包成jar/exe版本分发给用户运行;也可以不使用localhost,通过局域网ip甚至是公网ip,在两台设备上通过互联网来通信,实现真正意义上的“网络计时器”。
这个项目通过“基于互联网的服务端-客户端网络计时器”需求,应用了可靠的TCP与快速的UDP双协议、多线程(线程池)并发处理、正则表达式校验输入值等技术及其特点,在复习前阶段所学知识的同时,演示了具体开发项目从需求分析到代码编写,以及后续的测试优化的流程,是作为新手入门Java网络编程较好的参考资料;同时也存在很多改进空间,如缺少图形化界面,对用户操作不友好;没有使用时间轮算法,高并发下的大量计时任务性能差;没有实现真正的联网服务等;学有余力的情况下,可以在此基础上深入学习和改进,迭代出真正具有实用价值,甚至是商业价值的软件产品。
参考资料