目录
前言
在前面,我们已经学习了一遍有关网络编程的基础知识,例如OSI七层模型、TCP/IP五层模型等,那么本篇我们就来了解如何使用网络编程。
为什么需要网络编程
网络编程对我们现在的生活和娱乐有着很大的影响。以下是网络编程所被重视的几个原因:
- 信息交换的需求:在信息化时代中,数据和信息的交换无处不在。网络编程提供了数据传输的途径,使得不同设备、不同位置的用户可以相互交流和分享信息。
- 分布式系统:我们现在的计算机系统往往采用的是分布式架构,以提高系统的性能、可靠性和可扩展性。是实现分布式系统各组件之间通信的关键技术。
- 安全性:网络安全变得越来越重要,网络编程涉及到了如何在传输过程中确保数据的安全性和完整性。
- 服务化架构:现代软件架构越来越倾向服务化,通过网络编程,不同的服务之间可以进行有效通信,实现复杂的功能。
- 互联网的发展:互联网逐渐普及,人们的生活方式和工作方式发生了翻天覆地的变化,网络编程是构建互联网应用的基础,无论是浏览网页、购物、社交互动等,还是云服务,都离不开网络编程。
相较于本地资源,网络提供了更加丰富的网络资源:
所谓的网络资源,其实就是在网络上可以获取到的各种数据资源,而所有的网络资源,都是通过网络编程来进行数据传输的。
什么是网络编程
网络编程其实就是使用编程语言和通信协议来网络上进行数据交换和通信的过程。通过IP地址、端口号连接到另一台主机上对应的程序,按照规定的协议进行数据交换。
当然,只要是不同的进程,即使在同一台主机上,基于网络来传输数据的,也属于网络编程。
网络编程中的基本概念
发送端和接收端
发送端:数据的发送方进程,称为发送端。发送端主机即网络通信中的源主机。
接收端:数据的接收方进程,称为接收端。接受端主机即网络通信中的目的主机。
收发端:发送端和接收端,简称为收发端。
注意:发松端和接收端只是相对的,只是一次网络数据传输产生数据流向后的概念。
请求和相应
请求(Request):是指客户端向服务器发送的一条信息或指令,用来获取或处理某些资源或数据。通常包含客户端希望服务器执行的操作信息。例如,我们在浏览器中输入一个网址,浏览器就会发送一个GET请求到相应的服务器中,请求网页内容。
响应(Response):是指服务器对收到信息作出回应的过程。当服务器接收到客户端的请求后,会根据请求的内容进行处理,并返回相应的数据或结果给客户端。例如,当我们请求一个网页的时候,服务器可能会发送一个包含HTML内容的响应,浏览器随后将渲染这个页面。
客户端和服务端
客户端:指使用软件程序或者浏览器等应用程序向服务器发起请求的计算机或者设备。
功能:
- 发送请求:客户端向服务器发送请求,请求可以是为了获取数据、执行操作或者访问资源。
- 处理响应:客户端接收来自服务器的响应,并根据需要进行处理,例如,显示网页内容、存储数据等。
服务端:通常是提供服务的计算机或设备。服务器接收客户端发生的请求,处理请求并返回相应的数据或者服务。
功能:
- 监听请求:服务器监听来自客户端的请求。
- 处理请求:服务器根据请求类型执行相应的操作,如检索数据、执行计算或者存储信息。
- 发送响应:服务器将处理结果作为响应发送回客户端。
我们可以来举个例子:
假设现在我去银行取钱,那么现在我们相当于客户端,而银行的工作人员就是服务端。
我去取钱这个操作,就需要跟银行人员说明情况,相当于(发送请求)。工作人员就会接受我的请求(监听请求),查看我的存款,看是否足够(处理请求)。再将钱从保险柜中取出交给我(发送响应),这就是服务端所做的事。我再将钱放进我的钱包,相当于(处理响应)。
常见的客户端服务端模型
最常见的场景,客户端是指给用户使用的程序,服务端是提供用户服务的程序:
- 客户端先发送请求到服务端
- 服务端根据请求数据,执行相应的业务处理
- 服务端返回响应:发送业务处理结果
- 客户端根据响应数据,展示处理结果(展示获取的资源,或提示资源的处理结果)
Socket套接字
什么是Socket套接字
Socket(套接字)是网络编程中一个重要的概念,是应用程序进行网络通信的接口。套接字允许应用程序在不同计算机之间进行通信,就像它们在同一台计算机上的进程间通信一样。
Socket上联应用进程,下联网络协议栈,是应用程序通过网络协议进行通信的接口,是应用程序与网络协议进行交互的接口。
在网络中,套接字是网络上不同主机间进行通信的一个端点。每个套接字都有唯一的标识,由一个IP地址和一个端口号组成。套接字可以看作是两个网络实体(如客户端和服务器)之间通信的“通道”。
套接字的分类
Socket套接字主要针对传输层协议分为以下三类:
- 流套接字(SOCK_STEAM):流套接字用于提供面向连接、可靠的数据传输服务。它通过使用传输控件协议(TCP)来确保数据的无差错、无重复发送,并且数据能够按照顺序接受。这种套接字适合传输大量的数,但不支持广播和多播方式。
- 数据报套接字(SOCK_DGRAM):数据报套接字提供了一个无连接的服务,通信双方不需要建立任何显式的连接,数据可以发送到指定的套接字,并且可以从指定的套接字接收数据。这种服务不能保证数据传输的可靠性,数据有可能在传输过程中丢失或者出现数据重复,且无法保证顺序接收到数据。这种套接字使用用户数据报协议(UDP)进行数据的传输。
- 原始套接字(RAW):用于自定义传输层协议,用于读写内核没有处理的IP协议数据。利用这种类型的套接字,进程可以直接访 问底层协议(因此称为原始)。例如,可在原始(raw)某个以太网设备上打开原始套接字,然后获 取原始的 IP 数据传输信息。
TCP协议和UDP协议的区别
1.TCP是有连接的,UDP是无连接的。
在计算机中,这种抽象的连接是常见的,有连接就是建立连接的双方各自保存了对方的信息。
连接本质上就是建立连接的双方是否有保存对方的信息。
TCP若想要通信,就需要通信双方建立连接,连接之后才能进行通信。
而UDP不需要双方建立连接就可以直接通信,会直接发送数据。所以我们在使用UDP协议写代码时,需要将对方的IP地址等信息写进去。
2.TCP是可靠传输,UDP是不可靠传输。
数据在传输的时候,不可能做到数据的百分百传输,就好比你在学习没有接触过的知识,不可能听一遍就把所有的知识点全部掌握。
那这么说,TCP为什么是可靠传输呢?
TCP拥有重传机制,当传输的数据包未被确认,那么发送方就会假设传输过程中丢失了,重新发生这个数据包。这样保证了TCP是可靠的传输。而UDP没有这种重传机制。
那么这种可靠传输有什么缺点呢?
- 机制更加复杂
- 传输效率降低
3.TCP是面向字节流的,而UDP是面对数据报的。
此处的字节流与文件操作中的字节流一个意思。TCP是以字节为单位进行数据传输的,而UDP是以数据报为单位进行数据传输的。
对于字节流来说,可以简单的理解为,传输数据是基于IO流,流式数据的特征就是在IO流没有关闭的 情况下,是⽆边界的数据,可以多次发送,也可以分开多次接收。
对于数据报来说,可以简单的理解为,传输数据是⼀块⼀块的,发送⼀块数据假如100个字节,必须⼀ 次发送,接收也必须⼀次接收100个字节,⽽不能分100次,每次接收1个字节。
4.TCP和UDP都是全双工的。
什么是全双工呢?其实就是就是指通信双方可以同时给彼此发送数据,接受数据等,这是就是全双工;
而半双工就是通信双方在同一时刻,一方只能接受数据,一方只能接受数据,不能同时发送或接收数据。
总结:
TCP:有连接,可靠,面向字节流,全双工
UDP:无连接,不可靠,面向数据报,全双工
介绍完TCP和UDP的区别之后,那么我们就来使用使用UDP协议来实现一个C/S通信程序。
如何在Java中实现UDP套接字编程
在java中,我们想要实现UDP套接字编程,需要依赖DatagramSocket类来创建数据报套接字对象。socket其实是操作系统的概念,socket可以认为是广义的文件下的一种文件类型(网卡),操作系统将网卡的概念封装成socket,我们不需要关注硬件的差异和细节,直接通过操作socket对象,间接地去操作网卡。
本篇我们实现的是一个回显服务器:其实就是将客户端的请求数据作为响应返回给客户端。
相关方法
DatagramSocket构造方法
我们主要使用的就是以下两个构造方法:
构造方法 | 说明 |
DatagramSocket() | 创建一个UDP数据报套接字的Socket,绑定到本机上任意一个随机端口(一般用于客户端) |
DatagramSocket(int port) | 创建一个UDP数据报套接字的Socket,绑定到本机指定的端口(一般用于服务端) |
DatagramSocket相关方法
方法 | 说明 |
void receive(DatagramPacket p) | 从此套接字接收数据报(如果没有接收到数据报,就进入阻塞等待) |
void send(DatagramPacket p) | 从此套接字发送数据报包(不会阻塞等待,直接发送) |
void close() | 关闭此数据套接字 |
DatagramPacket构造方法
构造方法 | 说明 |
DatagramPacket(byte[] buf,int length) | 构造一个DatagramPacket以用来接收数据报,接收的数存储在字节数组*第一个参数buf)中,接收指定长度(第二个参数length) |
DatagramPacket(byte[] buf,int offset,int length,SocketAddress address) | 构造一个DatagramPacket以用来发送数据报,发送的数据为字节数组(第一个参数buf)中,从offset到指定长度(第三个参数length),address指定目的主机的IP和端口号 |
DatagramPacket相关方法
方法 | 说明 |
InetAddress getAddress() | 从接收的数据报中,获取发送端主机IP地址;或从发送的数据报中,获取接收端主机IP地址 |
int getPort() | 从接收的数据报中,获取发送端主机的端口号;或从发送的数据报中,获取接收端主机端口号 |
byte[] getData() | 获取数据报中的数据 |
服务端
1.创建DatagramSocket对象并指定端口号
为什么会有指定端口号和不指定端口号这两种方法呢?
我们这是C/S模型,作为程序员,我们肯定能计算机上那些端口没有被使用,所以我们可以合理分配端口号,但对于用户来说,他们是不知道计算机上哪些端口可以使用,如果让用户自己指定端口,有可能由于端口已经被占用,就会导致DatagramSocket对象创建失败,而用户也不知道怎么回事,那么用户可能就会投诉你,给你找麻烦,所以为了避免这种情况,我们在编写代码时,服务器的端口我们可以自己指定,但用户端口我们选择让系统随机分配。
import java.net.DatagramSocket;
import java.net.SocketException;
public class UDPServers {
//声明一个DatagramSocket类型的变量,用于监听UDP端口
DatagramSocket socket=null;
/**
* 构造函数:初始化UDP服务器
* 通过指定的端口号创建DatagramSocket对象,使得服务器能够接收发送到该端口的UDP数据报
*
* @param port 服务器监听的端口号
* @throws SocketException 如果端口已被占用或出现其他错误,将抛出此异常
*/
public UDPServers(int port) throws SocketException {
socket=new DatagramSocket(port);
}
}
2.实现start()方法
我们通过实现一个start()方法,通过这个方法来让服务器启动。
/**
* 启动服务器并等待接收客户端数据
* 本方法启动服务器后,将无限循环以等待并处理客户端发送的数据
*/
public void start(){
// 启动服务器
System.out.println("服务器启动");
while(true){
// 接收客户端发送的数据
// 创建DatagramPacket以接收数据,缓冲区大小为4096字节
DatagramPacket requestPacket=new DatagramPacket(new byte[4096], 4096);
}
}
此处为什么需要无线循环?
这是因为我们不知道客户端什么时候发送请求给服务器,所以服务器需要一直处于工作状态。
由于UDP数据的传输和接收都是以数据报我单位的,所以我们这里也需要创建一个数据报对象DatagramPacket,同时在其构造方法中传入一个大小为4096个字节的byte数组以及数组的长度。
这个字节数组就是用来接收客户端的请求。
当创建完数据报对象之后,我们就可以调用socket中的receive()方法来接收数据。这个方法会将接收到的数据传入到byte数组中。
socket.receive(requestPacket);
当接收到数据后,由于我们接收到的数据是二进制形式的,所以我们需要将二进制数据转换为字符串数据。当然,此处我们也可以指定一个字符集,如果不指定,那么默认使用utf8.
//将接收到的数据转换为字符串
String request=new String(requestPacket.getData(),0,requestPacket.getLength(),"UTF-8");
3.处理请求
我们这里实现一个process()方法来处理请求数据,由于是回显服务器,所以我们在方法中直接返回请求数据即可。
定义一个字符串response接受方法的返回值。
//处理客户端发送的数据
String response=process(request);
private String process(String request) {
return request;
}
这里我们既然已经处理完请求,那么我们就需要将数据打包成数据报返回,并需要在这个数据报创建的时候指定目的IP和目的端口号,这个目的IP和端口号我们可以getSocketAddress()方法获取到。
//将处理后的数据打包为DatagramPacket
DatagramPacket responsePacket=new DatagramPacket(response.getBytes(),response.getBytes().length,requestPacket.getSocketAddress());
4.返回响应
既然服务器已经将请求的业务处理好了,那么我们就将这个处理之后的数据用 send()方法返回给客户端。
当所有业务逻辑完成之后,我们可以打印一下日志:
//发送处理后的数据
socket.send(responsePacket);
//打印日志
System.out.printf("[%s : %d] req=%s resp=%s",requestPacket.getSocketAddress(),requestPacket.getPort(),request,response);
服务端完整代码
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.SocketException;
public class UDPServers {
//声明一个DatagramSocket类型的变量,用于监听UDP端口
DatagramSocket socket=null;
/**
* 构造函数:初始化UDP服务器
* 通过指定的端口号创建DatagramSocket对象,使得服务器能够接收发送到该端口的UDP数据报
*
* @param port 服务器监听的端口号
* @throws SocketException 如果端口已被占用或出现其他错误,将抛出此异常
*/
public UDPServers(int port) throws SocketException {
socket=new DatagramSocket(port);
}
/**
* 启动服务器并等待接收客户端数据
* 本方法启动服务器后,将无限循环以等待并处理客户端发送的数据
*/
public void start() throws IOException {
// 启动服务器
System.out.println("服务器启动");
while(true){
// 接收客户端发送的数据
// 创建DatagramPacket以接收数据,缓冲区大小为4096字节
DatagramPacket requestPacket=new DatagramPacket(new byte[4096], 4096);
// 接收数据
socket.receive(requestPacket);
//将接收到的数据转换为字符串
String request=new String(requestPacket.getData(),0,requestPacket.getLength(),"UTF-8");
//处理客户端发送的数据
String response=process(request);
//将处理后的数据打包为DatagramPacket
DatagramPacket responsePacket=new DatagramPacket(response.getBytes(),response.getBytes().length,requestPacket.getSocketAddress());
//发送处理后的数据
socket.send(responsePacket);
//打印日志
System.out.printf("[%s : %d] req=%s resp=%s\n",requestPacket.getSocketAddress(),requestPacket.getPort(),request,response);
}
}
private String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
UDPServer server=new UDPServer(8808);
server.start();
}
}
客户端
客户端的步骤其实与服务器是类似的,服务器是先接收请求、处理请求再做出响应,而客户端是先发送请求再接收响应。
创建DatagramSocket对象
import java.net.DatagramSocket;
public class UDPclient {
// 声明一个DatagramSocket类型的变量,用于监听UDP端口
DatagramSocket socket=null;
// 定义服务器的IP地址和端口号
private String serverIp="";
private int serverPort=0;
public UDPclient(String serverIp, int serverPort) throws SocketException {
//当创建DatagramSocket对象时,如果不传入参数的话,计算机就会自动分配未被占用的端口号
socket = new DatagramSocket();
//因为 UDP 在通信之前不需要建立连接,所以就需要我们主动添加目的IP和目的端口号
this.serverIp = serverIp;
this.serverPort = serverPort;
}
}
由于客户端是先发送请求的一方,所以就需要先指定出目的端口和目的IP,这个是需要客户端手动指定的。
实现客户端的start()方法
/**
* 客户端启动方法
* 负责初始化客户端输入流,并循环执行以下操作:
* 1. 从标准输入读取用户请求
* 2. 将请求打包成数据报
* 3. 发送数据报到服务器
* 4. 接收服务器返回的数据报
* 5. 显示服务器返回的信息
* 此方法演示了客户端与服务器的基本交互过程
*/
public void start() throws Exception{
// 客户端启动
System.out.println("客户端启动");
// 初始化标准输入流Scanner对象,用于读取用户输入
Scanner scanner=new Scanner(System.in);
// 持续监听用户输入,处理与服务器的交互
while(true){
// 提示用户输入
System.out.print("-> ");
// 从键盘读取数据请求
String request=scanner.nextLine();
// 创建数据报,将请求数据打包成数据报
DatagramPacket requestPacket=new DatagramPacket(request.getBytes(),request.getBytes().length, InetAddress.getByName(serverIp),serverPort);
// 发送数据
socket.send(requestPacket);
// 接收数据,准备一个空的数据报用于接收服务器返回的数据
DatagramPacket responsePacket=new DatagramPacket(new byte[4096],4096);
socket.receive(responsePacket);
// 解析服务器返回的数据,并打印
String response=new String(responsePacket.getData(),0,responsePacket.getLength());
System.out.println(response);
}
}
就此,我们把回显C/S程序完成,我们在客户端的main方法中实例化一个客户端。
完整代码
package netCode;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.SocketException;
import java.util.Scanner;
public class UDPclient {
// 声明一个DatagramSocket类型的变量,用于监听UDP端口
DatagramSocket socket=null;
// 定义服务器的IP地址和端口号
private String serverIp="";
private int serverPort=0;
public UDPclient(String serverIp, int serverPort) throws SocketException {
//当创建DatagramSocket对象时,如果不传入参数的话,计算机就会自动分配未被占用的端口号
socket = new DatagramSocket();
//因为 UDP 在通信之前不需要建立连接,所以就需要我们主动添加目的IP和目的端口号
this.serverIp = serverIp;
this.serverPort = serverPort;
}
/**
* 客户端启动方法
* 负责初始化客户端输入流,并循环执行以下操作:
* 1. 从标准输入读取用户请求
* 2. 将请求打包成数据报
* 3. 发送数据报到服务器
* 4. 接收服务器返回的数据报
* 5. 显示服务器返回的信息
* 此方法演示了客户端与服务器的基本交互过程
*/
public void start() throws Exception{
// 客户端启动
System.out.println("客户端启动");
// 初始化标准输入流Scanner对象,用于读取用户输入
Scanner scanner=new Scanner(System.in);
// 持续监听用户输入,处理与服务器的交互
while(true){
// 提示用户输入
System.out.print("-> ");
// 从键盘读取数据请求
String request=scanner.nextLine();
// 创建数据报,将请求数据打包成数据报
DatagramPacket requestPacket=new DatagramPacket(request.getBytes(),request.getBytes().length, InetAddress.getByName(serverIp),serverPort);
// 发送数据
socket.send(requestPacket);
// 接收数据,准备一个空的数据报用于接收服务器返回的数据
DatagramPacket responsePacket=new DatagramPacket(new byte[4096],4096);
socket.receive(responsePacket);
// 解析服务器返回的数据,并打印
String response=new String(responsePacket.getData(),0,responsePacket.getLength());
System.out.println(response);
}
}
public static void main(String[] args) throws Exception{
// 创建客户端对象
UDPclient client=new UDPclient("127.0.0.1",8888);
// 启动客户端
client.start();
}
}
全过程理解
我们已经实现完上面全部的代码了,那么现在我们来理解一下整个过程:
首先运行服务端,当服务端启动之后,会执行到receive方法,但由于此时客户端没有发送请求,所以客户端执行到receive方法就会进入阻塞等待状态,直到接收到请求;接着启动客户端,提示用户输入数据,当用户输入数据之后,客户端会将我们输入的内容打包成数据报,根据指定的目的IP和目的端口send给服务端;当服务端接受到请求的数据报后,会先对数据报进行解析,将数据报中的二进制数据转换为字符串数据,接着调用process方法进行业务处理,再用一个字符串接收process返回值,再将字符串打包成数据报,将数据报根据源IP和源端口返回给客户端;当客户端接收到数据报之后,会将数据报中的二进制数据转换为字符串数据并打印出来。
接下来我们通过图解来进一步了解:
- 服务器先启动,执行到receive方法等待客户端发送请求
- 客户端启动,输入之后,执行到receive方法进入阻塞等待
- 服务器接收到请求,进行处理,处理之后的数据返回给客户端
- 客户端接收到响应,将其转换为字符串并打印
以上就是整个回显C/S的执行流程。
为什么这里没有用到 close 方法关闭 socket?
这里的 socket 既然是一个文件,如果频繁的创建而不销毁的话,就会占据大量的文件描述表的内容,可能会造成文件资源的泄露,但是这里为什么不需要 close 呢?因为这里 socket 文件在整个程序运行的过程中都会被使用到的,如果 socket 文件被关闭就意味着程序已经运行结束了,所以根本谈不上内存泄漏。
实现英译中功能
只需要利用HashMap即可,添加至服务器,其余一致。
import java.net.DatagramSocket;
import java.net.SocketException;
import java.util.HashMap;
public class UDPServers {
private HashMap<String,String> map=new HashMap<>();
//声明一个DatagramSocket类型的变量,用于监听UDP端口
DatagramSocket socket=null;
/**
* 构造函数:初始化UDP服务器
* 通过指定的端口号创建DatagramSocket对象,使得服务器能够接收发送到该端口的UDP数据报
*
* @param port 服务器监听的端口号
* @throws SocketException 如果端口已被占用或出现其他错误,将抛出此异常
*/
public UDPServers(int port) throws SocketException {
socket=new DatagramSocket(port);
map.put("cat","小猫");
map.put("dog","小狗");
map.put("mouse","老鼠");
map.put("bird","小鸟");
}
/**
* 启动服务器并等待接收客户端数据
* 本方法启动服务器后,将无限循环以等待并处理客户端发送的数据
*/
public void start() throws IOException {
// 启动服务器
System.out.println("服务器启动");
while(true){
// 接收客户端发送的数据
// 创建DatagramPacket以接收数据,缓冲区大小为4096字节
DatagramPacket requestPacket=new DatagramPacket(new byte[4096], 4096);
// 接收数据
socket.receive(requestPacket);
//将接收到的数据转换为字符串
String request=new String(requestPacket.getData(),0,requestPacket.getLength(),"UTF-8");
//处理客户端发送的数据
String response=process(request);
//将处理后的数据打包为DatagramPacket
DatagramPacket responsePacket=new DatagramPacket(response.getBytes(),response.getBytes().length,
requestPacket.getSocketAddress());
//发送处理后的数据
socket.send(responsePacket);
//打印日志
System.out.printf("[%s : %d] req=%s resp=%s\n",requestPacket.getSocketAddress(),requestPacket.getPort(),request,response);
}
}
private String process(String request) {
return map.getOrDefault(request, "未知单词");
}
public static void main(String[] args) throws IOException {
UDPServers server=new UDPServers(8888);
server.start();
}
}
以上就是本篇所有内容,若有不足,欢迎指正~