网络编程:通过代码完成基于网络的跨主机通信
跨主机通信方式:
1.TCP/IP网络
2.蓝牙通信
3.近场通信NFC
4.毫米波通信:功率高,带宽高,抗干扰能力差
其中TCP/IP网络是日常编程中最常涉及到的,最通用的跨主机通信的方式
一些概念
客户端 VS 服务器
客户端:在网络中主动发起通信的一方
服务器:被动接受的一方
客户端和服务器之间的交互
客户端给服务器发送的数据,称为请求(request)
服务器返回给客户端的数据,称为响应(response)
1.一问一答
一个请求对应一个响应,进行web开发就是这种模式
2.一问多答
一个请求对应多个响应,涉及到下载的场景
3.多问一答
多个请求对应一个响应,涉及到上传的场景
4.多问多答
多个请求对应多个响应,涉及到远程控制的场景
TCP VS UDP
进行网络编程需要使用系统的API,本质上是由传输层提供的
涉及到TCP和UDP两个协议,两个协议差异很大
TCP特点:有连接;可靠传输;面向字节流;全双工
UDP特点:无连接;不可靠传输;面向数据报;全双工
连接
有连接:指抽象且虚拟的连接。连接的特点是双方都能认同,例如打电话就是有连接的通信方式
无连接:例如发微信/短信,无论你是否同意,我都能给你发过去
网络中的连接:通信双方有一些数据结构能各自保存对方的相关信息
传输可靠性
前提:无论使用什么技术,都无法100%保证网络数据能从A传到B
可靠传输:尽可能完成数据传输,无法确保对方是否收到,但发送方可以知道对方是否收到了
不可靠传输:就是不知道对方是否收到数据咯
面向字节流/数据报
面向字节流:和文件的字节流一致,网络中传输的数据基本单位是字节
面向数据报:传输(发送和接收数据)的基本单位是一个数据报(由一系列字节构成的特定的结构)
全双工
全双工:一个信道可以双向通信(类似日常见到的马路)
半双工:只能单向通信
UDP socket api的使用
核心的类有两个:DatagramSocket, DatagramPacket
操作系统中有一种文件叫socket文件,这种文件抽象表示了网卡这个硬件设备
网卡:进行网络通信最核心的硬件设备
通过网卡发送数据,就是写socket文件
通过网卡接收数据,就是读socket文件
DatagramSocket
负责对socket文件进行读写
方法
DatagramPacket
表示一个UDP数据报
TCP相关的api
ServerSocket
对应到网卡的设备,这个类只能给服务器进行使用
Socket
既可以给服务器使用又可以给客户端使用
代码实操
回显服务器:UDP版本
服务器接收客户端的请求,返回响应;客户端发啥就响应啥
对于服务器来说
第一步:先创建DatagramSocket对象,接着操作网卡(通过socket对象完成)
socket对象存在内存中,针对这个内存对象进行操作就能影响到网卡
程序一启动就需要关联/绑定上一个操作系统的端口号(区分主机上进行网络通信的程序)
这个端口号需要我们手动指定
why?如果服务器端口号不固定,每次重启服务器之后端口号可能就变了,客户端就找不到这个服务器在哪了
一个主机上的一个端口号只能被一个进程绑定,如果一个端口已经被进程1绑定了,进程2也想绑定就会失败(除非进程1把端口号释放出来了,比如进程1结束了)
这个异常表示socket创建失败,比如端口号被别人占用了
第二步:服务器的启动逻辑
第2.1步:创建while(true)
杀进程?服务器一般是在Linux系统上,而Linux结束一个进程使用kill命令
第2.2步:创建requestPacket保存数据报信息,接着读取请求更新
new byte[] 通过这个字节数组保存收到的消息正文,就是UDP数据报载荷部分
receive从网卡中读取到一个UDP数据报,放到requestPacket对象中,其中数据报的载荷部分被放到requestPacket的内置字节数组中,报头部分可以被requestPacket其他属性保存
requestPacket还能知道数据从哪里来的(也就是源IP端口位置)
如果执行到receive的时候客户端还没发来请求,receive会暂时阻塞
上面代码的逻辑示意图
基于字节数组构造出String(无论是二进制数据还是文本数据,Java的String都可以保存)
从0号位置开始构造String,getLength获取到字节数组中有效数据的长度(不一定是4096)
第2.3步跳过
第2.4步:创建一个responsePacket作为响应对象,然后把响应返回给客户端
负责响应的packet对象里面不是空白的字节数组,而是直接把String里包含的字节数组给拎过来
构造对象第二个参数为何不能是response.length()? -->这个单位是字符,而原代码的单位是字节
第三个参数:requestPacket:客户端来的数据报
getSocketAddress()方法可以得到INetAddress对象,这个对象就包含了IP和端口和服务器通信对端(对应的客户端)的IP和端口
原理:把请求中的源IP,源端口作为响应的目的IP和目的端口,可以达到把消息返回给客户端的效果了
从上面的代码也可以看出:UDP是无连接的通信,体现在UDP的socket本身不保存对端的IP和端口,而是保存在每个数据报;另外,代码中没有体现建立连接和接收连接这样的操作
全双工怎么体现?一个socket对象既可以发送也可以接收
package network;
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.SocketException;
import java.nio.charset.StandardCharsets;
public class UdpEchoServer {
private DatagramSocket socket = null;
public UdpEchoServer(int port) throws SocketException {
socket = new DatagramSocket(port);
}
//服务器的启动逻辑
public void start() throws IOException {
System.out.println("服务器启动!");
//对于服务器来说,需要不停收到请求并返回响应
//一般的服务器是全天运行的,所以while true没有退出的必要
//如果想重启服务器就直接杀死进程就行
while(true){
//1.读取请求更新
DatagramPacket requestPacket = new DatagramPacket(new byte[4096], 4096);
socket.receive(requestPacket);
//读到的字节数组转换成String,方便后序逻辑处理
String request = new String(requestPacket.getData(), 0, requestPacket.getLength());
//2.根据请求计算响应(回显服务器不用做这一步)
String response = process(request);
//3.把响应返回给客户端
//构造一个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.getAddress().toString(),
requestPacket.getPort(), request, response);
}
}
//此处是回显服务器,可以直接return
publice String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
UdpEchoServer server = new UdpEchoServer(9090);//通常使用的端口,大于1024,小于65535
//注意端口别被其他进程占用
server.start();
}
}
对于客户端来说
第一步:创建socket对象
此处不需要手动指定端口号,因为系统会自动分配一个空闲的端口号,因为无法确保手动指定的端口号是否被别人占用
客户端要给服务器发起请求,前提是知道服务器在哪,要把服务器ip和端口找到
请求的源ip就是本机,源端口就是系统分配到
第二步:从控制台读取要发送的请求数据
最好用next()而不是nextLine(),因为使用nextLine()读取要手动输入换行符,也就是按enter键,由于enter键不仅仅会产生\n,还会产生其他字符,就会使读取到的内容有问题
next()是以空白符作为分割符,包括不限于换行,回车,空格,制表符等
第三步:构造请求并发送
第三个参数什么意思?因为我们初始化的ip采用的是字符串(点分十进制格式),而这里的操作都是用字节,所以要把serverIp转成字节形式
第四步:读取服务器响应
第五步:把响应显示到控制台上
package network;
import java.io.IOException;
import java.net.*;
import java.util.Scanner;
public class UdpEchoClient {
DatagramSocket socket = null;
private String serverIp;
private int serverPort;
//此处ip使用的是字符串,类似点分十进制
public UdpEchoClient(String serverIp, int serverPort) throws SocketException {
this.serverIp = serverIp;
this.serverPort = serverPort;
socket = new DatagramSocket();
}
public void start() throws IOException {
System.out.println("客户端启动");
Scanner scanner = new Scanner(System.in);
while(true){
System.out.println("->");//提示用户接下来输入的内容
//1.从控制台读取要发送的请求数据
if(!scanner.hasNext()){
break;
}
String request = scanner.next();
//2.构造请求并发送
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);
//4.把响应显示到控制台上
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("127.0.0.1", 9090);
client.start();
}
}
总结:三种DatagramPacket对象创建方法
启动程序
整个流程
1.服务器启动。启动之后,立即进入 while 循环,执行到 receive,进入阻塞,此时没有任何客户端发来请
2.客户端启动。启动之后,立即进入 while 循环,执行到 hasNext 这里,进入阻塞,此时用户没有在控制台输入任何内容
3.用户在客户端的控制台中输入字符串,按下回车。此时 hasNext 阻塞解除,next 会返回刚才输入的内容.基于用户输入的内容,构造出一个 DatagramPacket 对象,并进行 send。send 执行完毕之后,继续执行到 receive 操作,阻塞等待服务器返回的响应数据(此时服务器还没返回响应呢,这里也会阻塞),服务器收到请求之后,就会从 receive 的阻塞中返回.
4.返回之后,就会根据读到的 DatagramPacket 对象,构造 String request,通过 process 方法构造一个 String response,再根据 response 构造一个 DatagramPacket 表示响应对象,再通过 send 来进行发送给客户端。执行这个过程中,客户端也始终在阻寒等待。
5.客户端从 receive 中返回执行,就能够得到服务器返回的响应,并且打印倒控制台上于此同时, 服务器进入下一次循环,也要进入到第二次的 receive 阻塞, 等待下个请求了
回显服务器改进:词典服务器
package network;
import java.io.IOException;
import java.net.*;
import java.util.Scanner;
public class UdpEchoClient {
DatagramSocket socket = null;
private String serverIp;
private int serverPort;
//此处ip使用的是字符串,类似点分十进制
public UdpEchoClient(String serverIp, int serverPort) throws SocketException {
this.serverIp = serverIp;
this.serverPort = serverPort;
socket = new DatagramSocket();
}
public void start() throws IOException {
System.out.println("客户端启动");
Scanner scanner = new Scanner(System.in);
while(true){
System.out.print("->");//提示用户接下来输入的内容
//1.从控制台读取要发送的请求数据
if(!scanner.hasNext()){
break;
}
String request = scanner.next();
//2.构造请求并发送
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);
//4.把响应显示到控制台上
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("127.0.0.1", 9090);
client.start();
}
}
TCP版本
服务器代码
第一步:创建serverSocket
第二步:由于TCP是有连接的,和打电话一样,需要客户端拨号,服务器来接听
调用accept()方法建立连接
有一个客户端连进来了,accept只能返回一次结果;如果有若干个客户端要连进来,那我们就需要多次使用accept,所以我们用while(true)包裹起来
第三步:我们创建一个方法处理连接
3.1步:告诉用户客户端的IP和端口
3.2步:对socket进行读取和写入操作
TCP是面向字节流的,和文件中的字节流一样。所以可以用文件操作中的类来针对TCP的socket进行读写
inputStream是从网卡上读数据,outputStream是往网卡中写数据
现在读操作有两条路可以走:
1.使用read方法进行读取,读取到的数据存储到byte数组中,后续根据请求处理响应再把这个byte数组转成String
2.用Scanner方法读取inputStream的内容,直接就是字符串的形式,方便处理
这里我们用第二种方法
3.2.1步:读取请求并解析
3.2.2步:根据请求计算响应(process方法和UDP里的一模一样)
3.2.3步:把响应返回给客户端
第一种方法:用write()
但是这种方法不方便给返回的响应中添加\n
第二种方法:用PrintWriter类,相当于把字节流转成字符流
println方法可以自动在结尾加上一个换行
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;
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){
//通过accept方法来接听电话
Socket clientSocket = serverSocket.accept();
processConnection(clientSocket);
}
}
//通过这个方法处理一次连接,建立连接的过程会涉及多次请求响应交互
private void processConnection(Socket clientSocket) {
System.out.printf("[%s:%d] 客户端上线", clientSocket.getInetAddress(), clientSocket.getPort());
//循环读取客户端请求,保存对端信息
try(InputStream inputStream = clientSocket.getInputStream();
OutputStream outputStream = clientSocket.getOutputStream()){
Scanner scanner = new Scanner(inputStream);
while(true){
//通过scanner读取数据
if (!scanner.hasNext()){
//读取完毕,客户端断开连接,就打印读取完毕
System.out.printf("[%s:%d] 客户端下线", clientSocket.getInetAddress(),clientSocket.getPort());
break;
}
//1.读取请求并解析。next读到空白符才会结束
//因此要求客户端发来的请求必须带有空白符结尾
String request = scanner.next();
//2.根据请求计算响应
String response = process(request);
//3.把响应返回给客户端
PrintWriter printWriter = new PrintWriter(outputStream);
printWriter.println(response);
System.out.printf("[%s:%d] req: %s, resp:%s\n", clientSocket.getInetAddress(), clientSocket.getPort(),
request, response);
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
public String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
TcpEchoServer server = new TcpEchoServer(9090);
server.start();
}
}
客户端代码
执行画横线这行代码就会和对应的服务器进行tcp的连接建立流程(这是在系统内核中完成)
内核中连接的流程走完,服务器就能从accept返回
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 {
//由于tcp是有链接的,此处可以把这里的ip和port直接传给socket对象
socket = new Socket(serverIp,serverPort);
}
public void start(){
System.out.println("客户端启动");
try(InputStream inputStream = socket.getInputStream();
OutputStream outputStream = socket.getOutputStream()) {
Scanner scannerConsole = new Scanner(System.in);//控制台
Scanner scannerNetWork = new Scanner(inputStream);
PrintWriter writer = new PrintWriter(outputStream);
while (true){
//1.从控制台读取输入的字符串
if(!scannerConsole.hasNext()){
break;
}
System.out.println("->");
String request = scannerConsole.next();
//2.把请求发给服务器,使用println来发送,防止发送末尾带有\n
writer.println(request);
//3.从服务器中读取响应
String response = scannerNetWork.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();
}
}
客户端退出之后,服务器就能感知到客户端下线的操作
原理:
客户端退出的时候,这里的inputStream就会中断,scanner.hasNext()感知不到下一个数据直接返回false,整个循环就退出了
问题1
上述代码中,在客户端中输入hello,服务器没有响应
出现上面的情况,本质原因是PrintWriter内置的缓冲区在搞怪
为什么会有缓冲区呢?
我们知道IO操作是比较低效的操作,引入缓冲区,可以把多次写入网卡的数据攒起来一波统一发送,也就是把多次IO合并成1次,这样可以提高效率
缓冲区有个特性,如果发送的数据很少,还没填满缓冲区,数据就会待在缓冲区里无法被发送出去
简单的解决方法:手动冲刷缓冲区,调用flush方法
问题2
需要close吗?
其实是需要的,需要针对clientSocket进行close操作。因为TCP中的client socket每个客户端都有一个,随着客户端越来越多,这里消耗的socket也越来越多,如果不加释放就可能把文件描述符表占满
对于serverSocket来说,整个程序只有唯一一个对象,并且这个对象的生命周期跟随整个程序,无法提前关闭。随着进程的销毁会一起被释放,所以不需要手动close
对于UDP来说,DatagramSocket也是只有一个对象,也可以不用close
整个过程
问题3
在我们现在这个代码里面,开启多个客户端执行一下,发现开到第二个客户端的时候输入内容服务器已经没有反应了
这完全不符合我们一开始要让一份服务器服务多个客户端的初心
为什么会这样呢?仔细观察代码
第一个客户端连上服务器之后,服务器就会从 accept 这里返回.(解除阻塞)进入processConnection 中了
接下来就会再 scanner.hasNext 这里阻塞,等待客户端的请求,客户端请求到达后,scanner.hasNext 返回,继续执行,读取请求根据请求计算响应,返回响应给客户端…执行完上述一轮操作之后,循环回来继续再 hasNext 阻塞,等待下一个请求。直到客户端退出之后,连接结束,此时循环才会退出
内圈的while里面,只要循环不退出,服务器就一直在跟第一个客户端进行互动,根本无法回应第二个客户端。
解决办法:多线程
主线程负责执行accept,每次有一个客户端连上来就分配一个新的线程,由新的线程负责给客户端提供服务
把processConnection交给新的线程来负责,主线程就会快速地执行完一次之后回到accept这里阻塞等待新的客户端
上述采用多线程修改完之后,每次来一个客户端,就会创建一个线程;每次结束一个客户端,就会销毁对应的线程
如果客户端比较多,就会使服务器频繁创建销毁线程。那怎么办?线程池
问题4
那么,如果服务器只创建线程但是不销毁呢?现在有大量的客户端来对接服务器,如果每个客户端的处理时间很短,比如网站之类的,那还影响不是很大;如果每个客户端处理时间很长,比如王者或者LOL之类的,如果继续使用线程池/多线程,就会导致服务器上一下积累了大量的线程,服务器的负担就会很重
为了解决上面的问题,可以引入多个方案
1.协程:本质上是个线程,用户态可以以手动调度的方式让一个线程并发地做多个任务,就可以省去系统调度地开销了
2.IO多路复用:系统内核级别地机制。本质上让一个线程同时去负责处理多个socket,而且这些socket数据并非是同一时刻都需要处理(相当于你去买菜,付完一样的钱不去等师傅处理先去下一摊,付完钱后不等处理再去下一摊,等到头一摊地师傅处理完菜后一块过去拿就行)