从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,此时跳出循环并打印「客户端下线」,线程随之结束,不会一直空转。
本文结束~欢迎一起来交流技术!!!
