目录
TCP与UDP协议的区别
传输控制协议(TCP)与 用户数据报协议(UDP)是传输层两个重要的协议,二者互补共存,共同支撑互联网的多层次传输需求。
二者的区别如下:
· TCP 协议是有连接的、可靠传输、面向字节流、全双工
· UDP 协议是无连接的、不可靠传输、面向数据报、全双工
其中有/无连接是指:如果通信双方保存了通信对端的信息,就相当于是有连接;如果不保存对端的信息,就是无连接。
可靠传输/不可靠传输:此处的可靠不是指 100% 能到达对方,而是指“尽可能”确保数据的传输,而“不可靠”则意味着完全不保证数据是否能成功到达对方。TCP 通过一些内置机制(确认应答机制、重传机制等)保证了可靠传输,UDP则没有可靠性机制。
面向字节流/面向数据报:TCP 是面向字节流的,TCP 的传输过程就和文件流/水流是一样的;而 UDP 是面向数据报的,其传输数据的基本单位是数据报,一次发送/接收必须发送/接收完整的数据报。
全双工/半双工:全双工是指一个通信链路既可以发送数据也可以接收数据(双向通信),半双工是指一个通信链路只能发送/接收数据(单向通信)。
TCP 和 UDP 的选择以及适用场景:
· 当数据准确性 > 传输延迟时(例如软件更新下载、文件传输、网页浏览等场景),选择 TCP。
因为 TCP 能够保证数据的可靠传输,通过确认应答、重传机制等手段确保数据的完整性和准确性,适用于对数据传输准确性要求较高的应用。
· 当实时性 > 数据完整性时(例如多人游戏同步、在线视频会议、实时语音通话等场景),选择 UDP。
UDP适用于对实时性要求较高的场景,虽然它不保证数据的完整性,但能减少延迟,确保数据传输的实时性。特别是在实时通信中,丢失少量数据包对用户体验的影响相对较小,而过高的延迟可能影响整体体验。
基于 UDP 协议实现回显服务器
UDP Socket 编程常用 Api
· DatagramSocket:是一种用于网络通信的套接字对象,代表了操作系统中一个特定类型的“文件”资源。可以将其理解为操作系统对网络设备(如网卡)的一种抽象表示,就像操作系统将硬盘、键盘等设备抽象为文件一样,DatagramSocket 抽象了网络数据的发送与接收通道。它专门用于通过 UDP(用户数据报协议) 发送和接收数据报(Datagram),支持无连接、不可靠但高效的通信方式。通过 DatagramSocket,应用程序能够将数据以数据报的形式发送到目标地址,同时也能接收来自网络上其他节点的数据报,实现轻量级的网络通信。
· DatagramSocket的构造方法包括以下两种形式:
DatagramSocket():创建一个 UDP 数据报套接字的 Socket,绑定到本机任意一个随机端口(通常用于客户端)
DatagramSocket(int port):创建一个 UDP 数据报套接字的 Socket,绑定到本机指定的端口(通常用于服务端)
· DatagramSocket类下的方法:
void receive(DatagramPacket p):从套接字接收数据报,如果没有接收到数据报就会阻塞等待
void send(DatagramPacket p):从套接字发送数据报,不会阻塞等待,直接发送
void close():关闭此数据报套接字
· DatagramPacket:是 UDP Socket 发送/接收的数据报,其构造方法包括:
DatagramPacket(byte[] buf, int length):构造⼀个DatagramPacket以⽤来接收数据报,接收的数据保存在字节数组(第⼀个参数buf)中,接收指定长度(第⼆个参数length)
DatagramPacket(byte[] buf, int offset, int length, SocketAddress address):构造⼀个DatagramPacket以⽤来发送数据报,发送的数据为字节数组(第⼀个参数buf)中,从0到指定长度(第⼆个参数length)。address指定⽬的主机的 IP 和端口号。
· DatagramPacket类下的方法:
InetAddress getAddress():从接收的数据报中,获取发送端主机 IP 地址;或从发送的数据报中,获取接收端主机 IP 地址
int getPort():从接收的数据报中,获取发送端主机的端口号;或从发送的数据报中,获取接收端主机端口号
byte[] getData():获取数据报中的数据
回显服务器需要实现两个程序:UDP 服务器和 UDP 客户端,主动发起通信的一方称为客户端,被动接收的一方的是服务器。
UDP 服务器
package network;
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.SocketException;
/**
* Created with IntelliJ IDEA.
* Description: 回显服务器
* 客户端发啥样的请求,服务器就返回啥样的响应
* User: Li_yizYa
* Date: 2025/5/13
* Time: 15:29
*/
public class UdpEchoServer {
private DatagramSocket socket = null;
public UdpEchoServer(int port) throws SocketException {
socket = new DatagramSocket(port);
}
/**
* 通过 start 启动服务器的核心流程
*/
public void start() throws IOException {
System.out.println("服务器启动!");
while (true) {
// 此处通过 “死循环” 不停的处理客户端的请求.
// 1. 读取客户端的请求并解析
DatagramPacket requestPacket = new DatagramPacket(new byte[4096], 4096);
socket.receive(requestPacket);
// 上述收到的数据,是二进制 byte[] 的形式体现的,后续代码如果要进行打印之类的处理操作
// 需要转成字符串才好处理
String request = new String(requestPacket.getData(), 0, requestPacket.getLength());
// 2. 根据请求计算响应,由于此处是回显服务器,响应就是请求
String response = process(request);
// 3. 把响应写回到客户端
DatagramPacket responsePacket = new DatagramPacket(response.getBytes(), response.getBytes().length,
requestPacket.getSocketAddress());
socket.send(responsePacket);
// 4. 打印日志
System.out.printf("[%s:%d] req=%s, resp=%s\n", requestPacket.getAddress(), requestPacket.getPort(),
request, response);
}
}
public String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
UdpEchoServer server = new UdpEchoServer(9090);
server.start();
}
}
UDP 客户端
package network;
import java.io.IOException;
import java.net.*;
import java.util.Scanner;
/**
* Created with IntelliJ IDEA.
* Description:
* User: Li_yizYa
* Date: 2025/5/13
* Time: 15:29
*/
public class UdpEchoClient {
DatagramSocket socket = null;
private String serverIP;
private int serverPort;
public UdpEchoClient(String serverIP, int serverPort) throws IOException {
socket = new DatagramSocket();
this.serverIP = serverIP;
this.serverPort = serverPort;
}
public void start() throws IOException {
System.out.println("启动客户端");
Scanner scanner = new Scanner(System.in);
while (true) {
// 1. 从控制台读取到用户的输入
System.out.print("-> ");
String request = scanner.next();
// 2. 构造一个 UDP 请求,发送给服务器
DatagramPacket requestPacket = new DatagramPacket(request.getBytes(), request.getBytes().length,
InetAddress.getByName(this.serverIP), this.serverPort);
socket.send(requestPacket);
// 3. 从服务器读取到响应
DatagramPacket responsePacket = new DatagramPacket(new byte[4096], 4096);
socket.receive(responsePacket);
String response = new String(responsePacket.getData(), 0, responsePacket.getLength());
// 4. 把响应打印到控制台上
System.out.println(response);
}
}
public static void main(String[] args) throws IOException {
// 127.0.0.1 特殊 IP,环回 IP
// 如果客户端和服务器在同一个主机上,就使用这个 IP
UdpEchoClient client = new UdpEchoClient("127.0.0.1", 9090);
client.start();
}
}
基于 TCP 协议实现回显服务器
TCP Socket 编程常用 Api
在TCP Socket编程中,核心类是 ServerSocket(专门给服务器使用的 Socket 对象)和 Socket(既会给客户端使用,又会给服务器使用)。由于 TCP 协议是面向字节流的,其数据的基本传输单位就是 byte,因此不需要像 UDP 协议那样定义一个类来表示 “数据报” 对象。
· ServerSocket:是创建 TCP 服务器 Socket 的 Api,其构造方法为:
ServerSocket(int port):创建⼀个服务端流套接字Socket,并绑定到指定端口
· ServerSocket 类下常用的方法:
Socket accept():开始监听指定端口(创建时绑定的端口),有客户端连接后,返回⼀个服务端 Socket 对象,并基于该 Socket 建立与客户端的连接,否则阻塞等待
void close():关闭此套接字
· Socket:其是客户端 Socket,或服务端接收到客户端建立连接(accept方法)的请求后,返回的服务端 Socket。不管是客户端还是服务端 Socket,都是双方建立连接以后,保存的对端信息,及时用来与对方收发数据的。其构造方法为:
Socket(String host, int port):创建⼀个客户端流套接字 Socket,并与对应 IP 的主机 上,对应端口的进程建立连接。
· Socket 类下的方法:
InetAddress getInetAddress():返回套接字所在的地址
InputStream getInputStream():返回此套接字的输入流
OutputStream getOutputStream():返回此套接字的输出流
TCP 服务器
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;
/**
* Created with IntelliJ IDEA.
* Description: TCP回显服务器
* 客户端发啥样的请求,服务器就返回啥样的响应
* User: Li_yizYa
* Date: 2025/5/21
* Time: 20:19
*/
public class TcpEchoServer {
private ServerSocket serverSocket = null;
public TcpEchoServer(int port) throws IOException {
serverSocket = new ServerSocket(port);
}
public void start() throws IOException {
System.out.println("启动服务器");
while (true) {
Socket clientSocket = serverSocket.accept();
Thread t = new Thread(() -> {
try {
processConnection(clientSocket);
} catch (IOException e) {
throw new RuntimeException(e);
}
});
t.start();
}
}
// 针对一个连接,提供处理逻辑
private void processConnection(Socket clientSocket) throws IOException {
// 先打印一下客户端信息
System.out.printf("[%s:%d] 客户端上线!\n", clientSocket.getInetAddress(), clientSocket.getPort());
// TCP 是全双工的通信,一个 socket 对象,既可以读,也可以写
// 获取到 socket 中持有的流对象
try (InputStream inputStream = clientSocket.getInputStream();
OutputStream outputStream = clientSocket.getOutputStream()) {
// 使用 Scanner 包装一下 inputStream,就可以更方便的读取这里的请求数据了
while (true) {
// 1. 读取请求并解析
Scanner scanner = new Scanner(inputStream);
PrintWriter printWriter = new PrintWriter(outputStream);
if (!scanner.hasNext()) {
// 如果 scanner 无法读取出数据,说明客户端关闭了连接,导致服务器这边读取到 “末尾”
break;
}
String request = scanner.next();
// 2. 根据请求计算响应
String response = process(request);
// 3. 把响应写回给客户端
// 此处可以按照字节数组来写,也可以右另外一种写法
// outputStream.write(response.getBytes());
printWriter.println(response);
printWriter.flush();
// 打印日志
System.out.printf("[%s:%d] req=%s; resp=%s\n", clientSocket.getInetAddress(),
clientSocket.getPort(), request, response);
}
} catch (IOException e) {
e.printStackTrace();
} finally {
System.out.printf("[%s:%d] 客户端下线!\n", clientSocket.getInetAddress(), clientSocket.getPort());
clientSocket.close();
}
}
private String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
TcpEchoServer server = new TcpEchoServer(9090);
server.start();
}
}
TCP 客户端
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;
/**
* Created with IntelliJ IDEA.
* Description:
* User: Li_yizYa
* Date: 2025/5/21
* Time: 20:20
*/
public class TcpEchoClient {
private Socket socket = null;
/**
* 构造方法
* @param serverIp 服务器 Ip
* @param serverPort 服务器端口号
*/
public TcpEchoClient(String serverIp, int serverPort) throws IOException {
socket = new Socket(serverIp, serverPort);
}
public void start() {
System.out.println("客户端启动");
try (InputStream inputStream = socket.getInputStream();
OutputStream outputStream = socket.getOutputStream()) {
Scanner scanner = new Scanner(inputStream);
Scanner scannerIn = new Scanner(System.in);
PrintWriter printWriter = new PrintWriter(outputStream);
while (true) {
// 1. 从控制台读取数据
System.out.print("-> ");
String request = scannerIn.next();
// 2. 把请求发给服务器
printWriter.println(request);
// 引入 flush(冲刷) 操作,主动刷新缓冲区
printWriter.flush();
// 3. 从服务器读取响应
if (!scanner.hasNext()) {
break;
}
String response = scanner.next();
// 4. 打印响应结果
System.out.println(response);
}
} catch (Exception e) {
e.printStackTrace();
}
}
public static void main(String[] args) throws IOException {
TcpEchoClient client = new TcpEchoClient("127.0.0.1", 9090);
client.start();
}
}
TCP 服务端常见的 bug
客户端发送数据后,没有响应
在客户端中,我们通过下面的方式给服务端发送请求:
// 2. 把请求发给服务器
printWriter.println(request);
之所以这里没有响应,是因为其实客户端的数据并没有发送出去,因为 PrintWriter 这个类以及 IO 流中的很多类,都是自带缓冲区的,引入缓冲区之后,进行 写入数据操作 时,不会立即触发 IO,而是先将其放在内存缓冲区中,等缓冲区数据到达一定数量后,才会统一发送。而上述的问题,其实就是因为数据比较少,并未触发发送操作。因此,我们通过引入 flush 操作就可以解决该问题:
// 引入 flush(冲刷) 操作,主动刷新缓冲区
printWriter.flush();
服务器仅支持与一个客户端建立连接
对于该问题,引入多线程操作修改服务器代码中的 start 方法即可,每有一个客户端请求建立与服务器的连接,就创建一个线程来专门处理与该客户端之间的数据发送/接收,具体代码如下:
public void start() throws IOException {
System.out.println("启动服务器");
while (true) {
Socket clientSocket = serverSocket.accept();
Thread t = new Thread(() -> {
try {
processConnection(clientSocket);
} catch (IOException e) {
throw new RuntimeException(e);
}
});
t.start();
}
}