一、网络初识
1.网络协议
形如上面图示中的一样,网络通信指的是两个主机之间,进行数据传输的过程,但是,实际生活中咱们的网络通信是这样的吗?
其实并不是,主机A和主机B之间是有着很复杂的一段过程的,主机和主机之间会经历类似如下的一个过程:
数据为什么能准确的从主机A送达到主机B呢?
这里我们主要依靠的就是“网络协议”,通过一些既定的规则去编排数据,再用相同的规则去解析数据,那么最终数据就能够完整地被传输。
2.协议分层
为什么要引入协议分层?
比如说,一个公司是存在着上下级关系的,普通员工是不能够直接去跨级汇报事情给公司董事长的,必须一级一级的汇报,否则董事长就忙不过来了,那么网络协议也是类似,为了避免跨层级调用引起的混乱,人为的将协议进行分层,上层协议调用下层协议,下层协议服务上层协议,降低耦合,提高效率。
协议分层分为两种:
1.OSI七层网络协议模型(只出现在教科书中,了解即可)
2.TCP/IP五层网络模型(重点)
物理层:描述的网络通信中的一些基础设施需要遵守的规范(如网线、网口等的样子)
数据链路层:描述相邻节点之间,数据如何传输
网络层:路径规划(如上海->西安,有很多不同的路线走法,网络层这里就需要规划该走哪条路线)
传输层:描述数据从哪里来,到哪里去
应用层:描述数据用来干什么用
3.数据传输的基本流程
数据传输的核心步骤有两个:1.封装 2.分用
以QQ发信息为例:
用户A给用户B发信息
发送方的情况:
1.应用层:
QQ应用程序,从输入框获取到你所输入的信息,构造成应用层数据报(根据应用层协议),在这之后,引用程序就会调用传输层提供的接口,把数据报交给传输层处理。
2.传输层:
传输层的协议有很多,最主要的是TCP和UDP协议,这里以UDP为例
应用层传到传输层的数据,通过UDP协议,生成一个UDP数据报
UDP并不关心应用层发来的数据里面有什么,都是些什么内容,只是把应用层的数据当作一个字符串,构造出一个UDP数据报,这个数据报包含两个部件,分别是UDP报头和UDP载荷。
UDP报头包含两个主要信息:1.源端口 2.目的端口
传输层再进一步将UDP数据报交给网络层
3.网络层
网络层最主要的协议是IP协议
IP协议根据自己的格式,构造出一个IP数据报
IP协议同样也不关心传输层发来的是什么数据,只是在数据的前面拼接上一个IP报头
IP报头包含两个最主要信息:1.源IP 2.目的IP
此时我们可以引出网络通信的“五元组”:1.源IP 2.源端口 3.目的IP 4.目的端口 5.协议类型
接下来,我们的网络层继续将数据上传给数据链路层
4.数据链路层
最重要的协议:以太网(平时上网需要插的一个网线)
以太网又会针对IP数据报进一步的进行封装,再添加上帧头和帧尾
最后继续将IP数据报传给物理层
5.物理层
以太网的数据报本质上都是二进制的数据,通过硬件设备,如网卡等,将这些二进制数据转换成光信号/电信号/电磁波等。
到这里,主机A就完成了发送过程
接受方的情况(先不考虑中间过程)
1.物理层
一些硬件设备,如网卡等接收到电信号/光信号/电磁波等,再把这些信号进行解调,得到了一串二进制数据序列,也就是以太网数据报,这个数据被递交给上一层,数据链路层。
2.数据链路层
数据链路层的以太网协议就开始对数据进行解析,将帧头和帧尾解析,再将载荷交给上层,即网络层。
3.网络层
IP协议针对载荷进行解析,去掉IP报头,取出载荷,进一步交给传输层。
4.传输层
根据IP报头中的字段,就知道当前是一个UDP数据报,交给UDP处理,UDP针对数据报进行解析,去掉报头,取出载荷,再进一步交给应用程序。
5.应用层
UDP报头中,有一个字段是目的端口,目的端口找到关联的应用程序QQ,将数据交给它,QQ根据它的应用层协议,进行解析,再把这里的数据显示到界面上。
这样完整的十个过程就王成了QQ通信。
主机A从上到下添加报头的过程就叫做封装
主机B从上到下解析报头的过程就叫做分用
二、网络编程
网络传输层协议主要有两个:
1.UDP:不可靠传输,无连接,面向字符流,全双工
2.TCP:可靠传输,有连接,面向字节流,全双工
1.UDP
UDP主要有两个核心api:
1)DatagramSocket
在操作系统中,使用文件的概念去管理一些软硬件资源,如网卡等,表示网卡的这类文件,称为socket文件,要进行网络通信就必须有socket对象。
了解一个类,我们先了解下它的构造方法,DatagramSocket提供了两个构造方法,一个指定了port端口号,一个没有指定,我们之前了解知道,网络通信五元组分别是:源端口、源IP、目的端口、目的IP、协议类型,客户端和服务器在进行发送信息的时候,就需要一个端口来进行对端传输。
这里需要注意的是,客户端端口号系统会自动分配,服务器端口号需要自行指定。
为什么要这样安排呢?
一个客户端主机上,有很多的程序并发运行,我们并不能很明确知道某个端口是否是空闲的,而让系统自行去分配,这样更为的明智,而服务器是我们能够控制的,我们将服务器的程序安排好,就能很快找到空闲的端口了。举个例子:我在马路边开了个饭店,这个饭店的地址就必须是固定的且明确的,方便别人找到,客人来我这吃饭,它们坐的位置不是固定的,哪里空就坐哪里,饭店就相当于服务器,客人的位置就相当于客户端。
2)DatagramPacket
DatagramPcket表示了一个数据报,代表了系统中设定的UDP数据报的二进制结构
构造方法:
3)实例
到目前为止,我们了解了UDP的两个核心api,那我们就要开始进行实例训练了,我们这里做一个简单的客户端-服务器,叫做“回显服务器”,客户端发送什么,服务器就返回什么,但是实际开发中,服务器返回值都是比较复杂的,这里只是简单的一个应用。
服务端代码:
//回显服务器
//客户端发的请求是什么,服务器返回的响应就是什么
public class UdpEchoServer {
public DatagramSocket socket = null;
public UdpEchoServer(int port) throws SocketException {
socket = new DatagramSocket(port);//对象产生失败的原因就是端口号被占用
}
//使用这个方法启动服务器
public void start() throws IOException {
System.out.println("服务器启动!");
while(true){
//反复针对客户端的请求进行处理
//一个服务器,运行过程中,主要是三个核心环节
//1.读取请求,并解析
DatagramPacket requestPacket = new DatagramPacket(new byte[4096],4096);
socket.receive(requestPacket);
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);
//设置日志,记录服务端数据变化
System.out.printf("[%s:%d] req:%s,resp:%s\n",requestPacket.getAddress().toString(),requestPacket.getPort(),request,response);
}
}
public String process(String response){
return response;
}
public static void main(String[] args) throws IOException {
UdpEchoServer udpEchoServer = new UdpEchoServer(9090);
udpEchoServer.start();
}
}
客户端代码:
public class UdpEchoClient {
public DatagramSocket socket = null;
public String serverIp;
public int port;
//服务器的ip和端口传入
public UdpEchoClient(String serverIp,int port) throws SocketException {
this.serverIp = serverIp;
this.port = port;
//这个new操作,不再指定端口,由系统自动分配
socket = new DatagramSocket();
}
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.构造请求对象,传入服务器
DatagramPacket requestPacket = new DatagramPacket(request.getBytes(),request.getBytes().length, InetAddress.getByName(serverIp),port);
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 {
UdpEchoClient udpEchoClient = new UdpEchoClient("127.0.0.1",9090);
udpEchoClient.start();
}
}
2.TCP
TCP也同样提供了两个核心api:
1)ServerSocket
这个主要是给服务器使用。
2)Socket
这个不仅服务器可以使用,客户端也会用。
这里两个api的构造方法可以在java官方文档中查看详情,这里主要不是为了讲解这些构造方法的,所以先跳过,主要需要知道的就是前两个构造方法。
3)实例
TCP这里我们同样实现一个回显服务器,但是这里我们需要详解一些代码。
服务端:
public class TcpEchoServer {
public 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();
}
}
//通过这个方法来处理一个连接的逻辑
public void processConnection(Socket clientSocket) throws IOException {
System.out.printf("[%s:%d] 客户端上线!\n",clientSocket.getInetAddress().toString(),clientSocket.getPort());
//接下来就可以读取请求,根据请求计算响应,返回响应三步走
//Socket对象内部包含了两个字节流对象,可以把这两个字节流对象获取到,完成后续的读写操作
try(InputStream inputStream = clientSocket.getInputStream();
OutputStream outputStream = clientSocket.getOutputStream()){
//一次连接中,可能会涉及到多次请求/响应
while(true){
//1.读取请求并解析.为了读取方便,直接使用Scanner
Scanner scanner = new Scanner(inputStream);
if(!scanner.hasNext()){
//读取完毕,客户端下线
System.out.printf("[%s:%d] 客户端下线!\n",clientSocket.getInetAddress().toString(),clientSocket.getPort());
break;
}
//这个代码暗含一个约定,客户端发过来的请求,得是文本数据,同时,还得带有空白符(包括但不限于,换行,回车,空格等)做为分隔
String request = scanner.next();
//2.根据请求,计算响应
String response = process(request);
//3.把响应写回客户端,把OutputStream使用PrintWriter包裹一下,方便进行发数据
PrintWriter writer = new PrintWriter(outputStream);
//使用PrintWriter的println方法,把响应返回给客户端
//此处使用println,而不是print,是为了给结尾加上\n,方便客户端读取响应,使用scanner.next读取
writer.println(response);
//PrintWriter内置了缓冲区,通过手动刷新缓冲区,确保数据真的通过网卡发出去了,而不是残留在内存缓冲区中
writer.flush();
//日志,打印当前的请求详情
System.out.printf("[%s:%d] req:%s, resq:%s\n",clientSocket.getInetAddress().toString(),clientSocket.getPort(),request,response);
}
}finally {
//clientSocket被反复创建,所以需要关闭
clientSocket.close();
}
}
public String process(String request){
return request;
}
public static void main(String[] args) throws IOException {
TcpEchoServer tcpEchoServer = new TcpEchoServer(9090);
tcpEchoServer.start();
}
}
a)针对服务端代码解析
我们映入眼帘的第一个与UDP回显服务器不同的是这里,这是什么意思?accept了一个什么东西?
我们之前就知道TCP是有连接传输,客户端和服务器建立好连接以后就会存入一个队列,而这个accept就是将存放在内核中的连接拿出来进行使用。
第二个与UDP回显服务器不同的是这里,为什么使用多线程?
因为TCP是有连接的,如果单线程状态下,连续的两个客户端同时发送请求,就会有一个进入堵塞,但我们使用多线程的话,这样就一个服务器能同时处理多个客户端发送过来的请求。这里我们还可以进行优化,多线程中了解了线程池的概念,所以我们这里可以使用线程池来写,代码如下:
//创建一个不固定数量的线程池
public ExecutorService service = Executors.newCachedThreadPool();
//启动服务器
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();
service.submit(new Runnable() {
@Override
public void run() {
try {
processConnection(clientSocket);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
});
}
}
客户端:
public class TcpEchoClient {
public Socket socket = null;
// 要和服务器通信,就需要先知道,服务器所在的位置
public TcpEchoClient(String serverIp,int serverPort) throws IOException {
//这个new操作完成之后,就完成了tcp连接的建立
socket = new Socket(serverIp,serverPort);
}
public void start() throws IOException {
System.out.println("客户端启动了!");
Scanner scannerConsole = new Scanner(System.in);
try(InputStream inputStream = socket.getInputStream();
OutputStream outputStream = socket.getOutputStream()) {
while (true) {
//1.从控制台输入字符串
System.out.print("->:");
String request = scannerConsole.next();
//2.把请求发送给服务器
PrintWriter printWriter = new PrintWriter(outputStream);
//使用println带上换行,后续服务器读取请求,就可以使用scanner.next()来获取了
printWriter.println(request);
printWriter.flush();
//3.从服务器读取响应
Scanner scannerNetwork = new Scanner(inputStream);
String response = scannerNetwork.next();
//4.把响应打印出来
System.out.println(response);
}
}
}
public static void main(String[] args) throws IOException {
TcpEchoClient client = new TcpEchoClient("127.0.0.1",9090);
client.start();
}
}