UDP数据包和TCP数据包的区别;网络编程套接字;不同协议的回显服务器
目录
一、UDP 数据包与 TCP 数据包的区别:
连接方面:
传输方面:
面向对象:
双工模式:
二、UDP 网络编程套接字;基于 UDP 协议的回显服务器:
1. UDP 数据报套接字核心类
DatagramSocket :
DatagramPacket :
2.基于 UDP 协议的回显服务器
服务器:
客户端:
继承 EchoServer 服务器的带有查询单词功能的服务器:
三、TCP 网络编程套接字;基于 TCP 协议的回显服务器:
1.TCP 数据报套接字核心类
ServerSocket:
Socket:
2.基于 TCP 协议的回显服务器
服务器:
客户端:
一、UDP 数据包与 TCP 数据包的区别:
连接方面:
UDP:是无连接协议。它不需要事先建立连接就可以直接发送数据,就像写信一样,直接把信寄出去,不需要先和对方打招呼说要寄信了。
TCP:是面向连接的协议。在数据传输之前,需要在发送端和接收端之间建立一条连接,就像打电话一样,先拨号建立连接,然后才能进行数据传输。这个连接建立的过程通过三次握手来完成,确保双方都做好了数据传输的准备。
类比示例:
UDP: 就像发短信直接发送,无需确认对方是否在线。
TCP: 就像打电话一样,需要先拨号接通后,再通话。
传输方面:
UDP:是不可靠传输。UDP 不确保数据一定能到达接收端,也不确保数据的顺序和完整性,它只是将数据包发送出去,不关心是否被成功接收。
TCP:是可靠传输。他通过确认机制、重传机制等保证数据能够u准确无误地到达接收端。如果数据包在传输过程中丢失或损坏,TCP 会自动重传,确保数据的完整性和顺序性。
使用示例:
UDP:直播和在线大型游戏大多数都是用了 UDP 数据包协议,允许少量丢包,或者但要求低延迟。
TCP:文件传输必须确保每个字节正确到达。
面向对象:
UDP:面向数据报,数据以独立数据包为单位传输,每个包有明确的边界,且接收方一次读取一个完整的数据包。
TCP:面向字节流。他把引用程序交下来的数据看成是一连串的无结构的字节流,就像流水一样,TCP 会按照顺序将这些字节流发送出去,并在接收端按照同样的顺序还原。
双工模式:
UDP:支持全双工通信。同一时间内,数据在两个方向上进行运输,发送端和接收端可以同时发送和接收 UDP 数据包。
TCP:支持全双工通信。在同一连接中,数据可以同时在两个方向上进行传输,即发送端和接收端可以同时发送和接收数据,就像两个人同时打电话,双方都可以说话和倾听。
二、UDP 网络编程套接字;基于 UDP 协议的回显服务器:
1. UDP 数据报套接字核心类
DatagramSocket :
这个类用于发送和接收 UDP 数据报的套接字。(类似于车辆,负责发送和接收数据报)
DatagramSocket 构造方法:
方法签名 | 参数说明 | 用途 |
DatagramSocket( ) | 无参数 | 绑定到本机的任意一个随机端口(一般用于客户端) |
DatagramSocket(int port ) | port : 绑定的端口号 | 绑定到本机指定的端口(一般用于服务端) |
DatagramSocket 实例方法:
方法 | 参数说明 | 用途 |
receive( DatagramPacket p ) | p : 接收数据包的容器 | 用于接收数据报(如果没有接收到数据报,该方法会阻塞等待) |
send( DatagramPacket p ) | p : 要发送的数据包 | 用于发送数据包(不会阻塞等待,直接发送 UDP 数据报) |
close( ) | 无 | 关闭数据报套接字 |
DatagramPacket :
这个类用于封装 UDP 数据报的容器。(类似于车辆上的包裹,负责存储要运输的数据包)
DatagramPacket 构造方法:(接收用)
DatagramPacket(byte[] buf,int length)
构造一个 DatagramPacket 以用于接收数据报,接收的数据保存在一个字节数组 buf 中,和最多接收的字节数 length 。
DatagramPacket 构造方法:(发送用)
//第一种
DatagramPacket(byte[] buf,int offset,int length,InetAddress address, int port)
//第二种
DatagramPacket(byte[] buf,int offset,int length,SocketAddress address)
构造一个 DatagramPacket 以用于发送数据报,要发送数据在字节数组 buf 中,从 偏移量 offset 开始,发送 length 个长度。
InetAddress 是一个具体类,可以直接实例化。SocketAddress 是一个抽象类,不能直接实例化,需要借助它的具体子类( InetSocketAddress
)来创建对象。
第一种方法的 address 可以这么构造:
InetAddress address = InetAddress.getByName("目标地址");
第二种方法的 address 可以这么构造:
SocketAddress socketAddress = new InetSocketAddress("目标地址", 端口号);
DatagramPacket 实例方法:
方法 | 说明 |
getAddress( ) | 从接收的数据报中,获取发送端主机的 ip 地址;或从发送的数据报中,获取接收端主机IP地址 |
getPort ( ) | 从接收的数据报中,获取发送端主机的端口号;或从发送的数据报中,获取接收端主机端口号 |
getSocketAddress ( ) | 获取发送方或接收方的套接字地址,返回一个 SocketAddress 对象(上面的DatagramPacket 构造方法:(发送用)有一个参数SocketAddress address 就是这个,DatagramPacket.getSocketAddress() 方法能够获取到包含 IP 地址和端口号的信息) |
2.基于 UDP 协议的回显服务器
服务器:
public class EchoServer {
//创建 socket 对象
private DatagramSocket socket;
//构造方法
public EchoServer(int port) throws SocketException {
socket = new DatagramSocket(port);
}
//启动服务器,完成主要的业务逻辑
public void start() throws IOException {
System.out.println();
System.out.println("服务器启动!");
//服务器24小时不间断的服务
while(true) {
//一、读取请求并解析
//1.创建一个空白的 DatagramPacket 对象
DatagramPacket reqPacket = new DatagramPacket(new byte[4096],4096);
//2.通过 receive 读取网卡的数据,如果网卡没有收到数据,就会阻塞等待。
socket.receive(reqPacket);
//3.把 DatagramPacket 中的数据解析成字符串,只需要从 DatagramPacket 取到有效的数据即可。
String request = new String(reqPacket.getData(),0,reqPacket.getLength());
//二、根据请求计算响应
String response = process(request);
//三、把响应写回到客户端
//1.把响应构造成 DatagramPacket 对象
DatagramPacket resPacket = new DatagramPacket(
response.getBytes(),
response.getBytes().length,
//获取到发送方的 IP 地址和端口号
reqPacket.getSocketAddress()
);
//2.把 DatagramPacket 写回到客户端。
socket.send(resPacket);
//四、打印日志
System.out.printf("[%s:%d] req:%s, resp:%s
",
reqPacket.getAddress(), reqPacket.getPort(), request, response);
}
}
//由于这里实现的是 “ 回显服务器 ” ,所以响应和请求是一样的,这里直接返回请求即可。
private String process(String request) {
return request;
}
//启动服务器
public static void main(String[] args) throws IOException {
EchoServer server = new EchoServer(9090);
server.start();
}
}
所以可以看到服务器处理的基本流程是:先构造一个 DatagramSocker 对象实例用于发送数据包。DatagramSocker 读取请求之前先构造一个用于读取数据报的 DatagramPacket 对象,经过处理后,再新构造一个用于发送数据报的 DatagramPacket 对象,再通过 DatagramSocker 对象实例发送数据包。
客户端:
public class EchoClient {
private DatagramSocket socket = null;
private String serverIp;
private int serverPort;
// 初始化服务器的 ip 和 服务器的端⼝.
public EchoClient(String serverIp, int serverPort) throws SocketException {
this.serverIp = serverIp;
this.serverPort = serverPort;
// 这个 new 操作, 就不再指定客户端的端⼝了. 让系统⾃动分配⼀个空闲端⼝.
socket = new DatagramSocket();
}
public void start() throws IOException {
Scanner scanner = new Scanner(System.in);
System.out.println("客户端启动!");
while(true) {
//一、从控制台读取用户输入的内容
System.out.print("> ");
String request = scanner.next();
//二、构造成 UDP 请求,并发送,不光要填内容, 还要填服务器的地址和端口.
DatagramPacket reqPacket = new DatagramPacket(
request.getBytes(),
request.getBytes().length,
//要发送的服务器的 ip 和 端口
InetAddress.getByName(serverIp),
serverPort);
socket.send(reqPacket);
//三、读取服务器的响应。
DatagramPacket respPacket = new DatagramPacket(new byte[4096],4096);
socket.receive(respPacket);
String response = new String(respPacket.getData(), 0, respPacket.getLength());
//四、把响应显示到控制台。
System.out.println(response);
}
}
public static void main(String[] args) throws IOException {
EchoClient echoClient = new EchoClient("127.0.0.1",9090);
echoClient.start();
}
}
所以可以看到客户端基本流程是:先构造一个 DatagramSocker 对象实例用于发送数据包。DatagramSocker 发送请求之前先构造一个用于发送数据报的 DatagramPacket 对象,经过服务器处理后,客户端读取返回结果之前,再新构造一个用于读取数据报的 DatagramPacket 对象,处理结果后打印在控制台上。
继承 EchoServer 服务器的带有查询单词功能的服务器:
//继承 EchoServer 类
public class DictServer extends EchoServer{
//使用哈希表,查询更快
private Map map = new HashMap<>();
public DictServer(int port) throws SocketException {
super(port);
map.put("cat", "小猫");
map.put("dog", "小狗");
map.put("pig", "小猪");
map.put("bird", "小鸟");
map.put("sheep", "小羊");
map.put("cow", "小牛");
map.put("chicken", "小鸡");
map.put("rabbit", "小兔子");
map.put("fish", "小鱼");
map.put("wolf", "狼");
map.put("monkey", "猴子");
map.put("tiger", "老虎");
map.put("lion", "狮子");
//......
}
//重写父类的 process 方法
@Override
public String process(String request) {
return map.getOrDefault(request,"没有查到该单词!");
}
public static void main(String[] args) throws IOException {
EchoServer server = new DictServer(9090);
server.start();
}
}
通过继承 EchoServer 这个类后,调用 process 方法会使用子类也就是 DictServer 这个类重写的 process 方法。
要注意的是, DictServer 继承 EchoServer 这个类 ,重写 process 方法之前,要把EchoServer 这个类process 这个方法改成 公有的方法(public)。
三、TCP 网络编程套接字;基于 TCP 协议的回显服务器:
1.TCP 数据报套接字核心类
ServerSocket:
ServerSocke 类是专门给服务器用的。它的主要作用是监听指定端口,等待客户端的连接请求。一旦有客户端连接,就会创建一个新的 Socket
对象来与客户端进行通信。
ServerSocket 构造方法:
ServerSocket serverscoket = new ServerSocket(int port);
port 是服务器的端口号。
ServerSocket 实例方法:
accept() | 监听并接受到此套接字的连接。该方法会阻塞,直到有客户端连接进来,然后返回一个 Socket 对象,用于与客户端进行通信。 |
close() | 关闭此服务器套接字。 |
Socket:
Socket 类用于表示客户端套接字,也就是和服务器建立连接后客户端用来与服务器进行通信的对象。它可以实现与服务器之间的数据读写操作。
Socket 构造方法:
Socket socket = new Socket(String IP,int port);
创建一个流套接字并将其连接到指定主机上的指定端口号。IP 是服务器的 IP 地址或主机名,port 是服务器监听的端口号。
Socket 实例方法:
getInputStream( ) | 返回此套接字的输入流,用于从服务器读取数据,返回流对象是InputStream |
getOutputStream( ) | 返回此套接字的输出流,用于向服务器发送数据,返回流对象是OutputStream |
2.基于 TCP 协议的回显服务器
服务器:
public class EchoServer {
private ServerSocket serverSocket;
public EchoServer(int port) throws IOException {
//服务器启动,就会绑定到 port 端口上面。
serverSocket = new ServerSocket(port);
}
public void start() throws IOException {
System.out.println("服务器上线!");
while(true) {
//接收一个客户端请求
Socket socket = serverSocket.accept();
//一个线程处理一个连接,这样服务器可以被多个客户端连接。
Thread t = new Thread(() -> {
this.processCoonection(socket);
});
t.start();
}
}
//处理一个客户端/一个连接的逻辑.
private void processCoonection(Socket socket) {
System.out.printf("[%s:%d] 客户端上线!
",socket.getInetAddress().toString(),socket.getPort());
//实现通信的代码逻辑
//获取与客户端连接的输入流和输出流
try(InputStream inputStream = socket.getInputStream();
OutputStream outputStream = socket.getOutputStream()) {
//创建一个 Scanner 对象,用它从输入流 inputStream 读取客户端发送的请求数据
Scanner scanner = new Scanner(inputStream);
//创建一个 PrintWriter 对象,利用它把响应数据写入输出流 outputStream
PrintWriter writer = new PrintWriter(outputStream);
while(true) {
//一、读取请求并解析。
if(!scanner.hasNext()) {
// 针对客户端下线逻辑的处理. 如果客户端断开连接了 (比如客户端结束了)
// 此时 hasNext 就会返回 false
System.out.printf("[%s:%d] 客户端下线!
", socket.getInetAddress().toString(), socket.getPort());
break;
}
String request = scanner.next();
//二、根据请求计算响应。
String response = process(request);
//三、把响应写回客户端。
writer.println(response);
//刷新缓冲区
writer.flush();
System.out.printf("[%s:%d] req: %s; resp: %s
", socket.getInetAddress().toString(), socket.getPort(), request, response);
}
} catch(IOException e) {
e.printStackTrace();
} finally {
try {
//防止文件资源泄露
socket.close();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
//这里由于是回显服务器,直接返回结果。
private String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
EchoServer echoServer = new EchoServer(9090);
echoServer.start();
}
}
在上面的这段代码 this.processCoonection(socket); 要加上 this ,因为processCoonection方法是EchoServer 的一个实例方法,这意味着只有 EchoServer 类的对象实例才能调用这个方法。
而 this 在这里就起到了确定 “到底是哪个对象实例要调用 processCoonection 方法” 的作用。因为我们是在 EchoServer 类的一个实例方法 start 里面创建的这个线程,所以 this 指向的就是调用 start 方法的那个 EchoServer 对象实例(比如前面创建的 echoServer 实例)。
还有,这里涉及到了变量捕获, this.processCoonection(socket);里传入的 socket 。
当使用 Lambda 表达式或者匿名内部类时,如果在其内部引用了外部作用域的局部变量,就会发生变量捕获。被捕获的变量必须是 final 或者事实 final (即初始化后值不会再改变)。
每次循环都会创建一个新的 socket
对象。在 Lambda 表达式中使用 socket
时,实际上捕获了当前循环中 socket
变量的值。所以 Lambda 表达式需要捕获当前 socket
变量的值,以便在其执行时能够正确访问到对应的 socket
对象。
this
同样也被捕获了。在 Lambda 表达式中使用 this.processCoonection(socket);
,这里的 this
指向当前 EchoServer
类的实例。由于 Lambda 表达式会在新的线程中执行,为了在该线程中能够调用当前 EchoServer
实例的 processCoonection
方法,this
引用也被捕获了。
服务器这里要注意三个问题:
1、要使用 flush 方法刷新缓冲区。
Java 的输入输出操作里,缓冲区是系统为了提升数据读写效率而设立的一块内存区域。当程序向输出流写入数据时,数据不会马上被发送到目标设备(如网络另一端的服务器),而是先被存到缓冲区里。等缓冲区满了,或者满足特定条件时,系统才会把缓冲区里的数据一次性发送出去。同理,当从输入流读取数据时,数据会先被读到缓冲区,程序再从缓冲区读取。
所以,我们这个服务器数据比较少,数据会一直停留在缓冲区里,如果不刷新缓冲区,服务器可能无法及时收到数据,从而影响程序的正常交互。
2、要关闭 Socket 对象。
Socket 对象在使用过程中会占用系统的网络资源、文件描述符等。如果不关闭,这些资源就无法被释放,会导致资源泄漏。随着程序运行时间的增加,不断累积的资源泄漏可能会耗尽系统资源,影响系统的正常运行,甚至导致系统崩溃。
客户端:
public class EchoClient {
private Socket socket;
public EchoClient(String serverIP, int serverPort) throws IOException {
// 在 new 这个对象的时候就涉及到 "建立连接操作"
// 由于连接建立好了之后, 服务器的信息就在操作系统中被 TCP 协议记录了. 我们在应用程序层面上就不需要保存 IP 和 端口.
socket = new Socket(serverIP,serverPort);
}
public void start() {
System.out.println("客户端上线!");
Scanner scanner = new Scanner(System.in);
// //获取与服务器连接的输入流和输出流
try(InputStream inputStream = socket.getInputStream();
OutputStream outputStream = socket.getOutputStream()) {
//创建一个 Scanner 对象,用它从输入流 inputStream 读取服务器返回的数据
Scanner scannerNet = new Scanner(inputStream);
//创建一个 PrintWriter 对象,利用它把请求数据发送给服务器
PrintWriter writer = new PrintWriter(outputStream);
while(true) {
//一、从控制台读取用户的输入。
System.out.print(">");
String request = scanner.next();
//二、构造请求发送给服务器
writer.println(request);
//刷新缓冲区
writer.flush();
//三、读取服务器的响应。
if(!scannerNet.hasNext()) {
System.out.println("服务器断开连接!");
break;
}
String response = scannerNet.next();
//四、把响应显示到控制台上
System.out.println(response);
}
} catch (IOException e) {
e.printStackTrace();
}
}
public static void main(String[] args) throws IOException {
EchoClient echoClient = new EchoClient("127.0.0.1",9090);
echoClient.start();
}
}