了解网络编程
目录
一、引言
为什么需要网络编程?
二、网络编程基础
1.网络编程中的基本概念
发送端和接收端
请求和响应
客户端和服务端
常见的客户端模型
三、Socket套接字
1.概念
2.分类
3.Java套接字通信模型(UDP协议的)
4.Java流套接字通信模型(TCP协议的)
5.Socket编程注意事项
6.UDP数据报套接字编程
DatagramSocket API
DatagramPakcet API
InetSocketAddress API
7.UDP示例:请求响应
结论:
8.TCP流套接字编程
SeverSocket API
Socket API
TCP中的长短连接:
9.TCP示例:请求响应
一、引言
为什么需要网络编程?
用户在浏览器中,打开视频网站,观看视频,实质是通过网络,获取到网络上的一个视频资源。与本地打开视频文件类似,只是视频文件这个资源的来源是网络。相比与本地资源来说,网络提供了更为丰富的网络资源。
而所有的网络资源,都是通过网络编程的作用来进行数据传输的。
二、网络编程基础
网络编程,指通过编写程序实现网络通信,使不同设备能够交换数据和资源(通过编程实现不同主机进程间的通信)。
1.网络编程中的基本概念
发送端和接收端
在一次网络数据传输时:
- 发送端:数据的发送方进程,称为发送端。发送端主机即网络通信中的源主机。
- 接收端:数据的接收方进程,称为接收端。接收端主机即为网络通信中的目的主机。
- 收发端:发送端和接收端两端,也简称为收发端。
注意:发送端和接收端只是相对的,只是一次网络数据传输产生数据流向后的概念。
请求和响应
一般来说,获取一个网络资源,涉及到两次网络数据传输:
- 第一次:请求数据的发送
- 第二次:响应数据的发送
有请求就需要有应答。
比如访问一个网站,我首先要发送请求,服务端收到请求后就会发送响应,让我能够获得到这个网站里的资源。
客户端和服务端
- 服务端:在常见的网络数据传输场景下,把提供服务的一方进程,称为服务端,可以提供对外服务。
- 客户端:获取服务的一方进程,称为客户端。
对于服务来说,一般是提供:
- 客户端获取服务资源:客户端请求获取服务资源,然后服务端返回响应,即返回服务资源。
- 客户端保存资源在服务端:客户端请求保存用户资源,然后服务端返回响应,返回处理结果。
常见的客户端模型
- 最常见的场景,客户端是指给用户使用的程序,服务端是提供用户服务的程序;
- 客户端先发送请求到服务端;
- 服务端根据请求数据,执行响应的业务处理;
- 服务端返回响应;发送业务处理结果;
- 客户端根据响应数据,展示处理结果(展示获取的资源,或提示保存资源的处理结果)
三、Socket套接字
1.概念
Socket套接字,是由系统提供用于网络通信技术,是基于TCP/IP模型的网络通信的基本操作单元,套接字可以看作是通信的端点,允许不同计算机上的程序通过网络进行数据交换。基于Socket套接字的网络程序开发就是网络编程。
套接字(Socket)提供了一个应用程序编程接口(API),使得开发者能够创建网络连接、发送数据和接收数据。
2.分类
传输层提供的网络协议,主要是两个:TCP和UDP。
而Socket套接字主要针对(围绕)传输层协议而划分为如下三类:
流套接字,数据报套接字,原始套接字
流套接字:使用传输层TCP协议
TCP,即Transmission Control Protocol(传输控制协议),传输层协议。
以下为TCP的特点:
- 有连接(建立联系的双方各自保存对方的信息)
- 可靠传输
- 面向字节流
- 有接收缓冲区,也有发送缓冲区(Buffer)
- 大小不限
- 是全双工的
对于字节流来说,可以简单的理解为,传输数据是基于IO流,流式数据的特征就是在IO流没有关闭的情况下,是无边界的数据,可以多次发送,也可以分开多次接收。
数据报套接字:使用传输层UDP协议
UDP,即User Datagram Protocol(用户数据报协议),传输层协议。
以下为UDP的特点(细节后续再学习):
- 无连接
- 不可靠传输
- 面向数据报
- 有接收缓冲区,无发送缓冲区(Buffer)
- 大小受限:一次最多传输64k (最大值是,单位为字节,则是65535字节)
UDP数据报数据的大长度 = IP数据报的最大长度-IP头部的最小长度-UDP头部的长度
65535字节-20字节-8字节=65507字节(约等于64kb)
下面这种方法不完全正确:
UDP数据报数据的最大长度 = UDP数据报的总长度-UDP头部的长度
65535-8字节 = 65527字节(约等于64kb)
解释:
因为IP数据报的最大长度为65535字节,如果再加上IP的头部20字节后,IP数据报的总长度将是65555字节,所以要用IP数据报的最大长度去减。
- 是全双工的
对于数据报来说,可以简单的理解为,传输数据是一块一块的,发送一块数据假如100个字节,必须一次发送,接收也必须一次接收100个字节,但不能分100次,每次接收1个字节。
原始套接字
原始套接字用于自定义传输层协议,用于读写内核没有处理的IP协议数据,本篇不介绍原始套接字,简答了解即可。
3.Java套接字通信模型(UDP协议的)
对于UDP协议来说,具有无连接,面向数据报的特征,即每次都是没有建立连接,并且一次发送全部数据报,一次接收全部的数据报。
Java中使用UDP协议通信,主要基于DatagramSocket类来创建数据报套接字,并使用DatagramPacket作为发送或接收的UDP数据报。对于一次发送及接收UDP数据报的流程如下:
注意只有请求,没有回应
以上只是一次发送UDP数据报发送及接收端的数据报接收,并没有返回的数据,也就是只有请求,没有响应。对于一个服务器来说,重要的是提供多个用户端的请求处理及响应,流程如下:
4.Java流套接字通信模型(TCP协议的)
5.Socket编程注意事项
- 客户端和服务端:开发时,经常是基于一个主机开启两个进程作为客户端和服务端,但真实的场景,一般都是不同主机。
- 注意目的IP和目的端口号,标识了一次数据传输时要发送数据的终点主机和进程。
- Socket编程我们是使用流套接字和数据报套接字,基于传输层的TCP或UDP协议,但应用层协议,也需要考虑,这块我们在后续来说明如何设计应用层协议。
- 关于端口被占用问题,如果一个进程A已经绑定了一个端口,再启动一个进程B绑定该端口, 就会报错,这种情况也叫做端口被占用。对于java进程来说,端口被占用的常见报错信息如下:
此时需要检查进程B绑定的是哪个端口,再查看该端口被哪个进程占用。以下为通过端口号查进程的方式:
- 在cmd输入netstat -ano|findstr 端口号,则可以显示对应进程的pid。如一下命令显示了3030进程的pid:
- 在任务管理器中,通过pid查找进程:
解决端口被占用的问题:
- 如果占用端口的进程A不需要运行,就可以关闭A后,再启动需要绑定该端口的进程B。
- 如果需要运行A进程,则可以修改被占用该端口的B进程的端口,把B进程的端口换为其他没有使用的端口。
6.UDP数据报套接字编程
DatagramSocket API
DatagramSocket是UDP Socket,用于发送和接收UDP数据报。
- DatagramSocket的部分构造方法:
方法签名 | 方法说明 |
DatagramSocket() | 创建一个UDP数据报套接字的Socket,绑定到本机任意一个随机端口(一般用于客户端) |
DatagramSocket(int port) | 创建一个UDP数据报套接字的Socket,绑定到本机指定的端口(port)(一般用于客户端) |
- DatagramSocket的部分方法:
方法签名 | 方法说明 |
void receive(DatagramPacket p) | 从此套接字接收数据报(如果没有接收到数据报,该方法会阻塞等待) |
void send(DatagramPacket p) | 从此套接字发送数据报(不会阻塞等待) |
void close() | 关闭数据报套接字 |
DatagramPakcet API
DatagramPakcet是UDP Socket发送和接收的数据报。
- DatagramPacket的部分构造方法:
方法签名 | 方法说明 |
DatagramPacket(byte[] buf, int length) | 构造一个DatagramPacket用来接收数据报,接收的数据保存在字节数组(第一个参数buf)中,接收指定长度(length) |
DatagramPacket(byte[] buf,int offset,int length,SocketAddress address) | 跟上面的方法基本一样,但是多了两个参数,offset参数的意思是从字节数组哪个位置开始写入和读取数据;address指定目的主机的IP和端口号(SocketAddress是接口类型,实现该接口构造一个包含IP和端口号的对象(address)) |
DatagramPacket(byte[],int length,InetAddress address,int port) | address是通过InetAddress.getByName(String IP)获取的,其他参数和上面的一样 |
注意:包含有IP和端口号的构造方法是适用来发送数据的而没有的则是对应用来接收数据的。
- DatagramPakcet的部分方法:
方法签名 | 方法说明 |
InetAddress getAddress() | 从接收的数据报中,获取发送端主机IP地址;或从发送的数据报中,获取接收端主机IP地址(就是获取对方的IP地址) |
int getPort() | 从接收的数据报中,获取发送端主机的端口号;或从发送的数据报中,获取接收端主机端口号(就是为了获取对方的端口号) |
byte[] getData() | 获取数据报中的数据 |
构造UDP发送的数据时,需要传入SocketAddress实现的对象,该对象可以使用InetSocketAddress来创建。
InetSocketAddress API
InetSocketAddress (SocketAddress接口的实现类)的构造方法:
方法签名 | 方法说明 |
InetSocketAddress(InetAddress addr ,int port) | 创建一个Socket地址,包含IP地址和端口号 |
InetSocketAddress(String hostname,int port) | 同理。但是第一个参数变成了String类型 |
7.UDP示例:请求响应
下面是一个服务端和客户端交互的代码,代码思路是客户端发送请求(数据),服务端接收请求(数据),然后服务端返回响应,客户端接收响应。
- UDP服务端代码:
public class UdpEchoSever {
//创建一个DatagramSocket对象,套接字对象
private DatagramSocket socket = null;
public UdpEchoSever(int port)throws SocketException {
//手动指定端口
socket = new DatagramSocket(port);
//让系统自动分配端口
//socket = new DatagramSocket();
}
//利用该方法来启动服务器
public void start() throws IOException {
System.out.println("服务器启动!");
//一个服务器程序中经常看到while(true)这样的代码
while (true){
//1.读取请求并解析
//构造要存储内容的对象
DatagramPacket resquestPacket = new DatagramPacket(new byte[4096],4096);
//套接字接收数据报
socket.receive(resquestPacket);//客户端没有发送请求过来服务端会一直进入请求状态
//当前完成receive之后,数据是以二进制的形式存储到DatagramPacket中了
//要想能够把这里的数据给显示出来,还需要把这个二进制数据转成字符串
String request = new String(resquestPacket.getData(),0, resquestPacket.getLength());
//2.根据请求计算响应(一般的服务器都会经历的过程)
//由于此处是回显服务器,请求是啥样,响应就是啥样
String response = process(request);
//3.把响应写回到客户端
//搞一个响应对象,DatagramPacket,把接收到的内容变成字节数组,再给出客户端的IP和端口号
//往DatagramPacket里构造刚才的数据,再通过send返回
DatagramPacket responsePacket = new DatagramPacket(response.getBytes(),response.getBytes().length,
resquestPacket.getSocketAddress());
socket.send(responsePacket);
//4.打印一个日志,把这次数据交互的详细打出来
//会打印对方IP地址,端口号以及发送和响应的内容
System.out.printf("[%s:%d] req=%s,resp=%s\n",responsePacket.getAddress().toString(),
resquestPacket.getPort(),request,response);
}
}
public String process(String request){
return request;
}
public static void main(String[] args) throws IOException {
//创建服务器对象,并手动设置端口号
UdpEchoSever sever = new UdpEchoSever(9090);
sever.start();
}
}
- UDP客户端代码:
public class UdpEchoClient {
private DatagramSocket socket = null;
private String serverIP ="";
private int serverPort =0;
public UdpEchoClient(String ip,int port) throws SocketException {
//创建这个对象,不能手动指定端口
socket = new DatagramSocket();
//由于UDP自身不会持有对端的信息,就需要在应用程序里,把对端的情况记录下来
//这里咱们主要记录对端的IP和端口
serverIP = ip;
serverPort = port;
}
public void start() throws IOException {
System.out.println("客户端启动!");
Scanner scanner = new Scanner(System.in);
while(true){
//1.从控制台读取数据,作为请求
System.out.println("->");
String request = scanner.next();
//2.把请求内容构造成DatagramPacket对象,发给服务器
//包含了服务器IP以及服务进程的端口
/*SocketAddress address = new InetSocketAddress(serverIP,serverPort);
DatagramPacket requestPacket= new DatagramPacket(request.getBytes(),0,request.getBytes().length,
address);
*/
//注意发送数据时要学会使用对应的构造函数,发送数据的数据报要有对应的IP和端口号
DatagramPacket requestPacket = new DatagramPacket(request.getBytes(), request.getBytes().length,
InetAddress.getByName(serverIP), 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());
System.out.println(response);
}
}
public static void main(String[] args) throws IOException {
UdpEchoClient client = new UdpEchoClient("192.168.17.1",9090);
client.start();
}
}
客户端运行结果:
服务端运行结果:
运行过程:
- 服务器先启动,服务器启动完后进入循环执行到receive这里阻塞,因为还没有客户端过来;
- 客户端开始启动,也会先进入while循环执行scanner.next并且在这里阻塞,用户在控制台输入后next返回从而构造数据报并发出来;
- 客户端发送数据后,服务器就会从receive中返回,进一步执行解析请求为字符串内容,执行process操作,执行send操作。客户端继续往下执行,执行到receive等待服务器的响应;
- 客户端收到从服务器返回的数据后,就会从receive中返回,执行到这里的打印操作,也就是把响应显示出来了;
- 服务器这边完成一次循环之后,又执行到了receive这里,客户端完成一次循环后又到scanner.next这里,都进入阻塞。
结论:
UDP是无连接的:DatagramSocket不会自动记住对端的地址和端口所以客户端需要把对端的信息记录下来;
每次发送数据时都需要指定目标地址和端口:UDP允许发送数据到不同的对端,因此每次发送时都需要明确指定目标地址和端口(如代码中的创建DatagramPacket实例时需要传入对端的IP和端口)。
过程可能有点复杂,需要慢慢理解。
8.TCP流套接字编程
TCP的socket api和UDP的socket api差异又是很大的,TCP是针对字节流进行操作,是基本的传输单位。
SeverSocket API
SeverSocket 是创建TCP服务端Socket的API。
- SeverSocket的部分构造方法:
方法签名 | 方法说明 |
ServerSocket(int port) | 创建一个服务端流套接字Socket,并绑定到指定端口 |
- ServerSocket的部分方法:
方法签名 | 方法说明 |
Socket accept() | 开始监听指定端口(创建时绑定的端口),有客户端连接后,返回一个服务端Socket对象,并基于该Socket建立与客户端的连接(系统内核完成),该对象代表与客户端建立的连接,否则阻塞等待。 |
void close() | 关闭此套接字 |
Socket API
Socket是客户端Socket,或是服务端中接收到客户端建立连接(accept方法)的请求后,返回服务端的Socket。
Socket类既能给服务端用又能给客户端用,但服务端主要还是用ServerSocket类好一点。
- Socket的部分构造方法:
方法签名 | 方法说明 |
Socket(String host,int port) | 创建一个客户端流套接字Socket,并与对应IP的主机上,对应的端口的进程建立连接。 |
- Socket的部分方法:
方法签名 | 方法说明 | |
InetAddress getInetAddress() | 返回套接字所连接的地址 | |
InputStream getInputStream() | 返回此套接字的输入流(返回的是实现InputStream抽象类的实例化子类(类型是SocketInputStream)),返回的是一个流对象 | 借助这两个流对象完成数据的“发送”和“接收”操作 |
OutputStream getOutputStream() | 返回此套接字的输出流(跟上面同理) |
TCP中的长短连接:
TCP发送数据时,需要先建立连接,什么时候关闭连接就决定是短连接还是长连接。
- 短连接:每次接收到数据并返回响应后,都关闭连接,即是短连接。也就是说短连接只能一次收发数据。
- 长连接:不关闭连接,一直保持连接状态,双方不停地收发数据,即是长连接。也就是说长连接可以多次收发数据。
对比以上长连接,两者区别如下:
- 建立连接、关闭连接的耗时:短连接每次请求、响应都需要建立连接,关闭连接;而长连接只需要第一次建立连接,之后的请求、响应都可以直接传输。相对来说建立连接和关闭连接都是要耗时的,长连接效率更高。
- 主动发送请求不同:短连接一般是客户端主动向服务端发送请求;而长连接可以是客户端主动发送请求,也可以是服务端主动发。
- 两者的使用场景有不同:短连接适用于客户端请求频率不高的场景,如浏览网页。长连接适用于客户端与服务端通信频繁的场景,如聊天室,实时游戏。
对比UDP:
UDP是一种无连接的协议,这就意味着:
- 在发送数据之前,不需要建立一个持久的连接;
- 每次发送数据时,都需要通过DatagramPacket明确指定目标地址和端口;
- UDP不会维护连接状态,因此每次发送和接收数据都是独立的。
9.TCP示例:请求响应
主要思路:
- 服务端程序:
服务端程序利用多线程的方法解决多用户的问题,利用accept方法获取与客户端连接的连接对象,然后以字节流为基本传输单位,获取连接对象的流对象,借助流对象读取数据和发送响应数据。其中不能遗漏的一点是所创建的客户端对象要显示的关闭防止文件资源泄露
- 客户端程序:
客户端程序创建了Socket对象后就建立了与服务端的连接,客户端从键盘输入的数据写入到输出流对象中,这样借助流对象传输给服务器,然后通过输入流读取服务器返回回来的响应,最后把发来的响应显示到输出窗口上。
详细内容还请看代码注释:
TCP服务端:
public class TcpEchoSever {
private ServerSocket serverSocket = null;
//提供端口号
public TcpEchoSever(int port)throws IOException {
serverSocket = new ServerSocket(port);
}
public void start()throws IOException{
System.out.println("服务器启动!");
//使用线程池的方法,解决多客户端创建的情况,同时线程池可以更好的解决线程的
//创建和销毁的问题,提高了效率
ExecutorService service = Executors.newCachedThreadPool();
while(true){
//利用accept方法,返回服务端的对象,代表了与客户端建立的连接
//建立连接的细节流程都是内核自动完成的,应用程序只需要得到连接的对象(socket)即可
Socket clientSocket = serverSocket.accept();
//使用线程池的方法
service.submit(new Runnable(){
@Override
public void run() {
processConnection(clientSocket);
}
});
}
}
//通过这个方法,来处理当前的连接
public void processConnection(Socket clinetSocket){
//进入方法,先打印一个日志,表示当前有客户端连上了
System.out.printf("[%s:%d] 客户端上线!\n",clinetSocket.getInetAddress(),clinetSocket.getPort());
//接下来进行数据的交互
//获取输入输出流对象完成数据的“发送”和“接收操作”,进行这样的操作就是以字节为单位了
try(InputStream inputStream = clinetSocket.getInputStream();
OutputStream outputStream = clinetSocket.getOutputStream()){
//使用try()方式,避免后续用完了流对象而忘记关闭了
//由于客户端发送来的数据,可能是“多条数据”,针对多条数据就循环处理
while(true){
//从输入源inputStream输入流对象中读取数据
Scanner scanner = new Scanner(inputStream);
if (!scanner.hasNext()){
//不输入数据scanner.hasNext()会阻塞等待,直到有数据
//连接断开了,输入流关闭了,此时循环就应该结束
System.out.printf("[%s:%d]客户端下线!\n",clinetSocket.getInetAddress(),clinetSocket.getPort());
break;
}
//1.读取请求并解析,此处就以next来作为读取请求的方式,next的规则是读到“空白符(1)”就返回
String request = scanner.next();
//2.根据请求,计算响应
String response = process(request);
//3.把响应写回到客户端
//可以把String转成字节数据,写入到OutputStream
//也可以使用PrintWriter把OutputStream包裹一下,把字符串的内容写入到输出流对象中
PrintWriter printWriter = new PrintWriter(outputStream);
//此处的println不是打印到控制台了,而是写入到outputStream对应的流对象中,最后也就是写入到clientSocket里面
//自然这个数据也就通过网络发送出去了(发给当前这个连接的另外一端)
//此处使用println带有\n也是为了后续客户端这边可以使用scanner.next来读取数据
printWriter.println(response);
//此处还要记得要有个操作,刷新缓冲区,如果没有刷新操作,可能数据仍然是在内存中,还没有写入网卡
printWriter.flush();
//打印一下这次请求交互过程的内容
System.out.printf("[%s:%d] req=%s,resp=%s\n",clinetSocket.getInetAddress(),clinetSocket.getPort(),
request,response);
}
}catch (IOException e){
e.printStackTrace();
}finally {
try{
//最后还要把clientSocket进行关闭,不然大量的客户端的创建
//会导致有大量的clientSocket对象,当客户端断开连接时
//clientSocket对象仍然存在,不会自动的关闭,所以需要手动关闭
clinetSocket.close();
}catch (IOException e){
e.printStackTrace();
}
}
}
public String process(String request){
//此处也是写的回显服务器,响应和请求是一样的
return request;
}
public static void main(String[] args)throws IOException {
TcpEchoSever sever = new TcpEchoSever(9090);
sever.start();
}
}
TCP客户端:
public class TcpEchoClient {
private Socket socket =null;
public TcpEchoClient (String severIp,int serverPort) throws IOException {
//需要在创建Socket的同时,和服务器“建立联系”,此时就得告诉Socket服务器在哪里
//具体建立连接的细节,不需要我们自己手动干预,是内核自动负责的
//当我们new这个对象的时候,操作系统内核,就开始进行三次握手具体细节,完成建立连接的过程了
socket = new Socket(severIp,serverPort);
}
public void start(){
//tcp的客户端行为和udp的客户端差不多
//都是从服务端读取响应,把响应显示到界面上
//从键盘中输入,从键盘中读取数据
Scanner scanner = new Scanner(System.in);
try(InputStream inputStream = socket.getInputStream();
OutputStream outputStream = socket.getOutputStream()){
//把输入的数据写入到输出流中即写入到socket中然后传输到服务端中
PrintWriter printWriter = new PrintWriter(outputStream);
//读取输入流中的数据即服务端发过来的响应
Scanner scannerNetwork = new Scanner(inputStream);
while(true){
//1.从控制台中读取用户输入的内容
System.out.println("->");
String request = scanner.next();
//2.把字符串作为请求,发送给服务器
//这里使用println,是为了让请求后面带上换行
//一个请求一段数据
//也就是和服务器读取请求scanner.next()呼应
printWriter.println(request);
//刷新内存
printWriter.flush();
//3.读取服务器返回的响应
String response = scannerNetwork.next();
//4.在界面上显示内容了
System.out.println(response);
}
}catch(IOException e){
e.printStackTrace();
}
}
public static void main(String[] args)throws IOException {
TcpEchoClient client = new TcpEchoClient("127.0.0.1",9090);
client.start();
}
}
服务端运行结果:
客户端运行结果:
关于scanner.next():
next读到空白符就会返回,空白符是一类特殊的字符,包含了“换行符,回车符,空格符,制表符,翻页符,垂直制表符等等”。
在TCP通信中,数据是连续的字节流,没有明确的分隔符。为了方便解析数据,通常会在每条消息的末尾添加上一个空白符,这样接收方可以通过换行符来解析数据,分隔不同的消息;如果没有分隔符,接收方将无法确定消息的边界,从而无法正确解析数据。