单机阶段 -> 局域网阶段 -> 广域网阶段 -> 移动互联网阶段
一个国家的网络,就可以认为是广域网
描述了在网络上主机的位置
IP地址是一个32位的二进制数,通常用点分十进制的方式来表示。
如:127.0.0.1是一个环回IP地址,主要用于本机到本机的通信方式。
描述了一个主机上的应用程序
两个不同的进程,不能绑定同一个端口号,但是一个进程可以绑定多个端口号
在网络通信中,需要约定的协议,其实是非常复杂的,面对复杂的环境,就需要复杂的协议,但是协议太复杂了也不好。一个协议太复杂,就可以拆分为多个协议,协议是拆分很多,存在有些小的协议,作用或者定位是类似的,就可以针对这些小协议,进行分类,同时再这些不同的类别,进行分层,分层就是相当于约定了层级与层级之间的调用关系,要求上层协议调用下层协议,下层协议给上层提供支持,不能跨层调用。
在网络通信的时候,本质上传输的是光信号与电信号,通过光信号的频率(高低频)和电信号的电平(高低电平)来表示 0 / 1,这一串 0 / 1 就需要通信双方约定好协议。
拿打电话举个例子,你和你朋友打电话(此时用的是座机),你们约定的协议就是普通话,座机之间有座机协议。协议就分成了两层,后续就可以比较容易的针对这里的某一层协议进行替换。比如把座机改为了用手机打电话,或者是我们用英语进行通话。
- 分层之后就可以做到,层次之间,耦合程度比较低,上层协议不必了解下层的细节,下层也不必了解上层的细节
- 方便对某一层的协议进行替换
在协议分层背景下,数据如何通过网络传输?
发送方发送数据,要把数据从上到下,依次交给对应的层次的协议,进行封装
接收方接收数据,要把数据从下到上,依次交给对应的层次的协议,进行解析
以qq发消息为例子:这里面的封装本质上就是字符串的拼接
- 应用层(qq应用程序)拿到上述用户数据,进行封装,封装成应用层数据包,一个典型的数据报都是通过报头 + 载荷的方式构成的,比如会封装进去时间,A的qq号,B的qq号等,当然,为了区分上述字段,可能会引入分隔符或者长度信息来做界定
- 传输层拿到上述数据,应用层要调用传输层提供的api,来处理这个数据,传输层有很多协议,最典型的就是TCP和UDP,以UDP为例,UDP针对上述的数据包再进行封装,UDP数据报头会封装进去源端口和目的端口
- 传输层到网络层,把这个数据报交给网络层,网络层,最主要的就是IP协议,网络层给数据报加上一个IP协议的报头,包含源 IP 和目的 IP ,这两个IP就是描述了这次传输最初的起点和最终的终点
- 网络层交给数据链路层,最典型的协议叫做以太网,封装一个以太网帧头和一个以太网帧尾,包含源mac和目的mac,mac地址,也叫物理地址,也是描述一个主机在网络上的位置,它的功能和IP很相似,但是当下就是把两个地址作用于两个不同的用途,IP用来进行网络层的路径规划,mac用来进行描述数据链路层两个即将进行传输的相邻节点,mac是和网卡绑定的,每个网卡,每个设备都会有自己唯一一个mac地址(理论上),但是IP则不是
- 数据链路层就是把上述以太网数据帧交给物理层了,物理层要把上述 0 / 1 的二进制数据转化为光信号/电信号/电磁波信号,进行传输了。
数据链路层完成帧同步,差错控制,流量管理,链路管理
上述过程操作系统帮我们封装好了
- 物理层,网卡收到高低电平二进制数据,就会对这里的信号继续解析,还原成 0 / 1 这样的二进制序列
- 从物理层交给数据链路层,此时就把上述 0 / 1 这系列数据当作一个以太网数据帧(此处就是从以太网线,收到的数据,就要交给以太网协议来处理了),把帧头帧尾去掉,取出中间的载荷,再往上交给网络层,以太网数据帧头中有一个消息类型,根据这个类型就知道了网络层是IP协议了
- 来到网络层,此时就由网络层的IP协议进行解析数据报,也是去掉IP报头,同时会做一些工作,最重要的还是取出载荷,交给上层的传输层协议,IP数据报头也有一个字段,标识当前传输层用的是哪个协议
- 来到传输层,此处是由UDP来解析处理,还是去掉报头,取出载荷,把数据交给应用层,传输层借助端口号来区分具体的应用程序,每个要接受网络数据的程序都需要关联上一个端口号
- 来到应用,由qq这个程序,进行解析应用层数据报,取出下列字段,放到程序的界面中,qq服务器需要根据接收者的qq号找到对应的客户端在哪个机器上登录
真实的网络环境中,数据的传输中间可能要经历很多节点进行转发,比如中间有个交换机:
交换机会进行分用,从物理层分用到数据链路层,知道当前这个数据报的目的 mac 对不对就行了,然后交换机针对这个数据重新封装,从数据链路层封装到物理层,把数据继续转发。重新封装源 mac 和目的 mac 就变了。
如果中间有个路由器:
路由器收到的数据,会从物理层分用到网络层,根据当前得到的目的 IP 进行下一阶段的寻路操作( IP 协议是在一边传输的过程中,一边规划路径的)。之后把数据包重新进行封装,从网络层封装到物理层(此时经过数据链路层的时候,也会调整 mac 地址)
所以说:中间的交换机,只需要封装分用到数据链路层(只需要改源mac和目的mac)
中间的路由器,则需要封装分用到网络层(也需要改mac,同时还需要根据目的IP进行下一阶段的路径规划)。通常也说,交换机进行二层转发,路由器进行三层转发。
网络编程套接字,是由系统提供用于网络通信的技术,也是由操作系统给应用程序提供的一组API,是基于TCP / IP 协议的网络通信的基本操作单元。socket 可以视为是应用层和传输层的桥梁,应用层和传输层之间,交互数据就是靠的socket API
操作系统给我们提供的API,主要是两组:基于UDP的API 和TCP 的 API ,TCP 和 UDP 协议差别很大,所以这两组 API 的差别也很大:
比如发短信,就相当于无连接通信,直接投递,不需要接受连接,就能通信
打电话,就是有连接通信,需要先把连接接受了,才能通信
可靠传输:发送方知道接收方有没有接收到消息,但是不是对方能100%接收到,假如你要发出去的时候,网线被人剪断了,你就知道这数据没有成功传输出去了
不可靠传输:把短信发出去了,你想发给谁就输入他的手机号,发出去就发出去了,不会知道对方有没有看到或者是手机号有没有改。
面向字节流:数据是以字节为单位,进行传输的,就是说假设有100个字节的数据,可以一次发完,也可以一次发10个字节,发送10次,也可以一次发20个字节,发5次
面向数据报:以数据报为单位进行传输,每个数据报都会明确大小,一次发送/接收必须是一个完整的数据报。
一根管子就类似于半双工
高速公路就相当于全双工
主要涉及两个类:DatagramSocket 和 DatagramPacket
- DatagramSocket 中 Socket 表示的是:这是一个DatagramSocket 对象,就对应到操作系统中的一个socket文件,这个socket文件,就是对应着网卡这种硬件设备的,从socket文件中读取数据,本质上就是读取网卡,从socket文件中写入数据,本质上就是写入网卡,所以我们就需要一个socket对象来进行网络编程,socket就相当于是个遥控器。
- DatagramPacket 这个类,就代表了一个UDP数据报,使用UDP传输数据的基本单位,每次发送 / 接收数据,都是在传输一个DatagramPacket对象。
DatagramSocket 是UDP Socket,用于发送和接收UDP数据报
此处的socket对象可能被客户端 / 服务器都使用的,服务器这边的socket往往要关联一个具体的端口号(必须要不变),客户端这边则不需要手动指定,系统自动分配一个空闲的端口号(则不要求)。比如把服务器看成是要开一家店,你在宣传你的店的时候肯定需要把地址给写上吧。
socket 也是文件,文件用完了记得关闭,否则会出现文件资源泄露的问题
DatagramPacket是UDP Socket发送和接收的数据报
DatagramPacket 构造方法:
方法签名 | 方法说明 |
---|---|
DatagramPacket(byte[]buf, int length) | 构造一个DatagramPacket以用来接收数据报,接收的数据保存在字节数组(第一个参数buf)中,接收指定长度(第二个参数length) |
DatagramPacket(byte[]buf, int offset, int length,SocketAddress address) | 构造一个DatagramPacket以用来发送数报,发送的数据为字节数组(第一个参数buf)中,从0到指定长度(第二个参数length)。address指定目的主机的IP和端口号 |
第一个版本,不需要设置地址进去,通常用来接受消息
第二个版本,需要显式的设置地址进去,通常要用来发送消息
下面基于UDP socket 写一个客户端服务器的回显服务(客户端发了请求,服务器返回一个一模一样的响应)
一个服务器,主要要做3个核心工作:
- 读取请求并解析
- 根据请求计算响应(省略了)
- 把响应返回到客户端
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.SocketException;
public class UdpEchoServer {
// 需要先定义一个 socket 对象
// 通过网络通信,必须要使用 socket 对象
private DatagramSocket socket = null;
// 绑定一个端口,不一定能成功
public UdpEchoServer(int port) throws SocketException {
// 构造 socket 的同时,指定要关联/绑定的端口
socket = new DatagramSocket(port);
}
// 启动服务器的主逻辑
public void start() throws IOException {
System.out.println("服务器启动");
while(true) {
// 每次循环,要做三件事:
// 1. 读取请求并解析
// 构造空饭盒
DatagramPacket requesPacket = new DatagramPacket(new byte[4096], 4096);
// 食堂大妈给饭盒里盛饭(饭从网卡上来)
socket.receive(requesPacket);
// 为了方便处理这个请求,把数据转成 String
String request = new String(requesPacket.getData(), 0, requesPacket.getLength());
// 2. 根据请求计算响应(此处省略这个步骤)
String response = process(request);
// 3. 把响应的结果写回到客户端
// 根据 response 字符串,构造一个DatagramPacket
// 和请求 packet 不同,此处构造响应的时候,需要指定这个包要给发给谁
// requestPacket 是从客户端这里收来的,getSocketAddress 就会得到客户端的ip和端口
DatagramPacket responsePacket = new DatagramPacket(response.getBytes(), response.getBytes().length, requesPacket.getSocketAddress());
socket.send(responsePacket);
System.out.printf("[%s:%d] req: %s, resp: %s\n", requesPacket.getAddress().toString(), requesPacket.getPort(), request, response);
}
}
// 这个方法希望是根据请求计算响应.
// 由于咱们写的是个 回显 程序. 请求是啥, 响应就是啥!!
// 如果后续写个别的服务器, 不再回显了, 而是有具体的业务了, 就可以修改 process 方法,
// 根据需要来重新构造响应.
// 之所以单独列成一个方法, 就是想让同学们知道, 这是一个服务器中的关键环节!!!
private String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
UdpEchoServer udpEchoServer = new UdpEchoServer(9090);
udpEchoServer.start();
}
}
import java.io.IOException;
import java.net.*;
import java.util.Scanner;
public class UdpEchoClient {
private DatagramSocket socket = null;
private String serverIP;
private int serverPort;
// 客户端启动, 需要知道服务器在哪里!!
public UdpEchoClient(String serverIP, int serverPort) throws SocketException {
// 对于客户端来说, 不需要显示关联端口.
// 不代表没有端口, 而是系统自动分配了个空闲的端口.
socket = new DatagramSocket();
this.serverIP = serverIP;
this.serverPort = serverPort;
}
public void start() throws IOException {
// 通过这个客户端可以多次和服务器进行交互.
Scanner scanner = new Scanner(System.in);
while (true) {
// 1. 先从控制台, 读取一个字符串过来
// 先打印一个提示符, 提示用户要输入内容
System.out.print("-> ");
String request = scanner.next();
// 2. 把字符串构造成 UDP packet, 并进行发送.
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 显示出来.
String response = new String(responsePacket.getData(), 0, responsePacket.getLength());
System.out.printf("req: %s, resp: %s\n", request, response);
}
}
public static void main(String[] args) throws IOException {
UdpEchoClient udpEchoClient = new UdpEchoClient("127.0.0.1", 9090);
udpEchoClient.start();
}
}
服务器和客户端交互效果:
小结:
其实最后还得调用 close 方法来关闭文件的,但是这个程序文件的生命周期退出 while 循环之后,main 紧接着就结束了,意味着进程也结束了,所有的文件资源也就自动释放了。
import java.io.IOException;
import java.net.SocketException;
import java.util.HashMap;
import java.util.Map;
public class UdpDictServer extends UdpEchoServer {
private Map<String, String> dict = new HashMap<>();
public UdpDictServer(int port) throws SocketException {
super(port);
dict.put("dog", "小狗");
dict.put("cat", "小猫");
dict.put("fuck", "卧槽");
}
@Override
public String process(String d) {
return dict.getOrDefault(d, "未找到改单词");
}
public static void main(String[] args) throws IOException {
UdpDictServer udpDictServer = new UdpDictServer(9090);
udpDictServer.start();
}
}
TCP 也是两个核心的类
ServerSocket 是创建TCP服务端Socket的API
ServerSocket 构造方法:
方法 | 方法说明 |
---|---|
ServerSocket(int port) | 创建一个服务端流套接字Socket,并绑定到指定端口 |
方法签名 | 方法说明 |
---|---|
InetAddress getInetAddress() | 返回套接字所连接的地址 |
InputStream getInputStream() | 返回此套接字的输入流 |
OutputStream getOutputStream() | 返回此套接字的输出流 |
TCP发送数据时,需要先建立连接,什么时候关闭连接就决定是短连接还是长连接:
短连接:每次接收到数据并返回响应后,都关闭连接,即是短连接。也就是说,短连接只能一次收发数据。
长连接:不关闭连接,一直保持连接状态,双方不停的收发数据,即是长连接。也就是说,长连接可以多次收发数据。
对比以上长短连接,两者区别如下:
- 建立连接、关闭连接的耗时:短连接每次请求、响应都需要建立连接,关闭连接;而长连接只需要第一次建立连接,之后的请求、响应都可以直接传输。相对来说建立连接,关闭连接也是要耗时
的,长连接效率更高。- 主动发送请求不同:短连接一般是客户端主动向服务端发送请求;而长连接可以是客户端主动发送请求,也可以是服务端主动发。
- 两者的使用场景有不同:短连接适用于客户端请求频率不高的场景,如浏览网页等。长连接适用于客户端与服务端通信频繁的场景,如聊天室,实时游戏等。
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 {
// serverSocket 就是外场拉客的小哥
// clientSocket 就是内场服务的小姐姐.
// serverSocket 只有一个. clientSocket 会给每个客户端都分配一个~
private 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(); // 这里的 clientSocket 是服务器的 Socket
processConnection(clientSocket);
}
}
// 通过这个方法来处理一个连接.
// 读取请求
// 根据请求计算响应
// 把响应返回给客户端
private void processConnection(Socket clientSocket) throws IOException {
System.out.printf("[%s:%d] 客户端上线!\n", clientSocket.getInetAddress().toString(), clientSocket.getPort());
// try () 这种写法, ( ) 中允许写多个流对象. 使用 ; 来分割
try(InputStream inputStream = clientSocket.getInputStream();
OutputStream outputStream = clientSocket.getOutputStream()) {
// 没有这个 scanner 和 printWriter, 完全可以!! 但是代价就是得一个字节一个字节扣, 找到哪个是请求的结束标记 \n
// 不是不能做, 而是代码比较麻烦.
// 为了简单, 把字节流包装秤了更方便的字符流~~
Scanner scanner = new Scanner(inputStream);
PrintWriter printWriter = new PrintWriter(outputStream);
while(true) {
// 1. 读取请求
if(!scanner.hasNext()) {
// 读取的流到了结尾了 (对端关闭了)
System.out.printf("[%s:%d] 客户端下线!\n", clientSocket.getInetAddress().toString(), clientSocket.getPort());
break;
}
// 直接使用 scanner 读取一段字符串.
String request = scanner.next();
// 2. 根据请求计算响应
String response = process(request);
// 3. 把响应写回给客户端. 不要忘了, 响应里也是要带上换行的.
printWriter.println(response);
printWriter.flush(); // 立即刷新缓冲区,确保服务器第一时间感知到请求
System.out.printf("[%s:%d] req: %s; resp: %s\n", clientSocket.getInetAddress().toString(), clientSocket.getPort(), request, response);
}
} catch (IOException e) {
e.printStackTrace();
} finally {
clientSocket.close();
}
}
private String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
TcpEchoServer tcpEchoServer = new TcpEchoServer(9090);
tcpEchoServer.start();
}
}
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 port) throws IOException {
// 这个操作相当于让客户端和服务器建立 tcp 连接.
// 这里的连接连上了, 服务器的 accept 就会返回.
socket = new Socket(serverIp, port); // 这里的 new 就是在与服务器建立连接
}
public void start() {
Scanner scanner = new Scanner(System.in);
try(InputStream inputStream = socket.getInputStream();
OutputStream outputStream = socket.getOutputStream()) {
PrintWriter printWriter = new PrintWriter(outputStream);
Scanner scannerFromSocket = new Scanner(inputStream);
while(true) {
// 1. 从键盘上读取用户输入的内容.
System.out.print("-> ");
String request = scanner.next();
// 2. 把读取的内容构造成请求, 发送给服务器.
// 注意, 这里的发送, 是带有换行的!!
printWriter.println(request);
printWriter.flush(); // 立即刷新缓冲区,确保服务器第一时间感知到请求
// 3. 从服务器读取响应内容
String response = scannerFromSocket.next();
// 4. 把响应结果显示到控制台上.
System.out.printf("req: %s; resp: %s\n", request, 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();
}
}
交互过程:
但是,如果没有printWriter.flush(),当我们程序运行起来,在客户端那里输入内容后,发现服务器却没有反应,因为:
所以 printWriter.println(request); 这里只是把数据写入到内存的缓冲区中,等到缓冲区满了,才会真正写网卡,但是我们可以手动刷新缓冲区,让数据立即被写入网卡,就是使用 printWriter.flush(),当然了读取也是有缓冲区的,有两个缓冲区,接收缓冲区和发送缓冲区。
当前,程序已经跑起来了,已经可以正常通信了,但是还有一个非常严重的bug,服务器需要同时能够给多个客户端提供服务的,但是当我们启动两次客户端的时候,发现只有第一个客户端能和服务器通信,第二个没有反应:
改进后客户端的代码:
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;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class TcpEchoServer {
// serverSocket 就是外场拉客的小哥
// clientSocket 就是内场服务的小姐姐.
// serverSocket 只有一个. clientSocket 会给每个客户端都分配一个~
private ServerSocket serverSocket = null;
public TcpEchoServer(int port) throws IOException {
serverSocket = new ServerSocket(port);
}
public void start() throws IOException {
System.out.println("服务器启动");
ExecutorService executorService = Executors.newCachedThreadPool();
while(true) {
Socket clientSocket = serverSocket.accept(); // 这里的 clientSocket 是服务器的 Socket
// Thread t = new Thread(() -> {
// try {
// processConnection(clientSocket);
// } catch (IOException e) {
// e.printStackTrace();
// }
// });
// t.start();
executorService.submit(new Runnable() {
@Override
public void run() {
try {
processConnection(clientSocket);
} catch (IOException e) {
e.printStackTrace();
}
}
});
}
}
// 通过这个方法来处理一个连接.
// 读取请求
// 根据请求计算响应
// 把响应返回给客户端
private void processConnection(Socket clientSocket) throws IOException {
System.out.printf("[%s:%d] 客户端上线!\n", clientSocket.getInetAddress().toString(), clientSocket.getPort());
// try () 这种写法, ( ) 中允许写多个流对象. 使用 ; 来分割
try(InputStream inputStream = clientSocket.getInputStream();
OutputStream outputStream = clientSocket.getOutputStream()) {
// 没有这个 scanner 和 printWriter, 完全可以!! 但是代价就是得一个字节一个字节扣, 找到哪个是请求的结束标记 \n
// 不是不能做, 而是代码比较麻烦.
// 为了简单, 把字节流包装秤了更方便的字符流~~
Scanner scanner = new Scanner(inputStream);
PrintWriter printWriter = new PrintWriter(outputStream);
while(true) {
// 1. 读取请求
if(!scanner.hasNext()) {
// 读取的流到了结尾了 (对端关闭了)
System.out.printf("[%s:%d] 客户端下线!\n", clientSocket.getInetAddress().toString(), clientSocket.getPort());
break;
}
// 直接使用 scanner 读取一段字符串.
String request = scanner.next();
// 2. 根据请求计算响应
String response = process(request);
// 3. 把响应写回给客户端. 不要忘了, 响应里也是要带上换行的.
printWriter.println(response);
printWriter.flush(); // 立即刷新缓冲区,确保服务器第一时间感知到请求
System.out.printf("[%s:%d] req: %s; resp: %s\n", clientSocket.getInetAddress().toString(), clientSocket.getPort(), request, response);
}
} catch (IOException e) {
e.printStackTrace();
} finally {
clientSocket.close();
}
}
private String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
TcpEchoServer tcpEchoServer = new TcpEchoServer(9090);
tcpEchoServer.start();
}
}
那么,有个问题:为什么UDP版本的程序在没有使用多线程的情况,也能执行多个客户端的请求?
因为UDP不需要处理连接,所以只需要一个循环,就能处理所有客户的请求了。