一,应用层
和应用程序直接相关
虽然在应用层中,有很多大佬研究出来了很多协议,但是在实际开发的过程中,主要的应用层还是需要"自定义协议"
实例---开发外面软件
首先,实现获取商家列表的功能
针对这样的功能,需要约定一下"应用层协议"
step1
先确定,功能中,客户端和服务器需要交互哪些数据(和功能需求直接相关)
比如需求为:根据用户的位置,获取到方圆5公里范围内所有的商家,并且按照距离升序和降序每个商家的信息,包含名称,图片,评分等等
请求:
用户所在位置(经纬度)
用户id
响应:
商家列表
商家相关的信息
step2
再确定数组组织的格式
网络通信本质上是传输"字节流"或者"字符串"(二进制)
而我们需要把结构化的数据,能够组织成"字符串" ----------- 序列化
并且还能把"字符串"解析回结构化的数据 ------------------反序列化
(结构化数据即结构体,数据中包含了多个属性)
1)直接使用纯文本的格式
请求:
用户id,经度,纬度\n
响应:
商家id,商家名称,商家图片,商家评分,商家距离; 商家id,商家名称,商家图片,商家评分,商家距离; 商家id,商家名称,商家图片,商家评分,商家距离\n
这种直接使用纯文本的格式是基于简单的分隔符的定义方式,有很多的缺点:1,不能直观看到每个属性是什么意思 2,未来要扩展/修改属性,会很麻烦
2)基于 XML 格式
XML 格式是比较经典的格式,通过成对的"标签"描述某个值的含义
请求:
<request>
<userId>111</userId>
<jingdu>12</jingdu>
<weidu>23</weidu>
</request>
XML 和 HTML 的区别为:XML 的标签是程序员自定义的,HTML的标签都是标准的,所以XML 是通用的一般的情况,而HTML 是专用的,特殊的情况
XML的缺点:非常消耗资源(网络带宽),格式上不优雅
3)JSON
JSON相当于XML来说可读性更高,网络带宽消耗更少
请求:
{
"userId": 111,
"jingdu": 12,
"weidu": 23
}
由此可以看出是键值对的格式,而且也支持嵌套,其中的值也可以是另一个JSON
注意:JSON的每个{}都是独立的,如果一次请求只是一个{},此时就不需要分隔符,如果一次请求中,需要有多个{},此时就可以使用++数组的语法 ++
响应:
{ "accountId": 1111, "name": "麻辣烫", "image": "xxxxx", "distance": "2.5km" }, { "accountId": 1111, "name": "麻辣烫", "image": "xxxxx", "distance": "2.5km" }, { "accountId": 1111, "name": "麻辣烫", "image": "xxxxx", "distance": "2.5km" }
4) protobuf
protobuf是纯二进制的数据格式(不可读,无法使用肉眼进行解析)
虽然可读性很差,但是传输效率很高
1.2 用纯文本的格式实现算术运算器
约定好协议:
1,明确传递哪些数据
请求:运算符(+-*/)
操作数(操作数1,操作数2)
响应:运算结果
2,组织传输数据的格式
自定义文本格式
请求:运算符,操作数1,操作数2\n
响应:结果\n
java
// 表示一个请求
public class Request {
// 支持 + - * /
private String operator;
private double num1;
private double num2;
public Request(String operator, double num1, double num2) {
this.operator = operator;
this.num1 = num1;
this.num2 = num2;
}
// 能够把这个对象转成字符串
public String convertToString() {
// String.format 就类似于 printf , 把结果构造到 String 中.
return String.format("%s,%f,%f\n", operator, num1, num2);
}
public static Request convertFromString(String request) {
// 1. 获取参数
String[] split = request.split(",");
String operator = split[0];
double num1 = Double.parseDouble(split[1]);
double num2 = Double.parseDouble(split[2]);
// 2. 创建对象
return new Request(operator, num1, num2);
}
public String getOperator() {
return operator;
}
public double getNum1() {
return num1;
}
public double getNum2() {
return num2;
}
}
java
// 表示一个响应数据
public class Response {
private double result;
public Response(double result) {
this.result = result;
}
public String convertToString() {
return String.format("%f\n", result);
}
public static Response convertFromString(String response) {
double result = Double.parseDouble(response);
return new Response(result);
}
public double getResult() {
return result;
}
}
java
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 TcpCalcClient {
private Socket socket = null;
public TcpCalcClient(String serverIp, int serverPort) throws IOException {
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 printWriter = new PrintWriter(outputStream);
while (true) {
// 1. 从控制台读取用户输入的内容
System.out.println("请输入要进行的运算 (+ - * /): ");
String operator = scannerConsole.next();
System.out.println("请输入第一个数字: ");
double num1 = scannerConsole.nextDouble();
System.out.println("请输入第二个数字: ");
double num2 = scannerConsole.nextDouble();
// 2. 构造请求并发送
Request request = new Request(operator, num1, num2);
String requestString = request.convertToString();
printWriter.print(requestString);
printWriter.flush();
// 3. 读取服务器的响应结果
if (!scannerNetwork.hasNext()) {
break;
}
String responseString = scannerNetwork.next();
Response response = Response.convertFromString(responseString);
// 4. 打印响应结果
System.out.println(response.getResult());
}
} catch (IOException e) {
e.printStackTrace();
}
}
public static void main(String[] args) throws IOException {
TcpCalcClient client = new TcpCalcClient("127.0.0.1", 9090);
client.start();
}
}
java
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 TcpCalcServer {
private ServerSocket serverSocket = null;
public TcpCalcServer(int port) throws IOException {
serverSocket = new ServerSocket(port);
}
public void start() throws IOException {
System.out.println("服务器启动...");
ExecutorService service = Executors.newCachedThreadPool();
while (true) {
Socket socket = serverSocket.accept();
service.submit(() -> {
try {
processConnection(socket);
} catch (IOException e) {
throw new RuntimeException(e);
}
});
}
}
// 处理一个连接
public void processConnection(Socket socket) throws IOException {
System.out.printf("[%s:%d] 客户端上线!", socket.getInetAddress().toString(), socket.getPort());
try (InputStream inputStream = socket.getInputStream();
OutputStream outputStream = socket.getOutputStream()) {
Scanner scanner = new Scanner(inputStream);
PrintWriter printWriter = new PrintWriter(outputStream);
while (true) {
// 1. 读取请求并解析
if (!scanner.hasNext()) {
break;
}
String requestString = scanner.next();
// 相比于前面的 EchoServer, 多了一步 "反序列化"
Request request = Request.convertFromString(requestString);
// 2. 根据请求计算响应
Response response = process(request);
// 3. 把响应写回到客户端
String responseString = response.convertToString();
// 构造 String 的时候, 已经带了 \n, 直接 print 写入即可.
printWriter.print(responseString);
printWriter.flush();
}
} catch (IOException e) {
e.printStackTrace();
} finally {
System.out.printf("[%s:%d] 客户端下线!\n", socket.getInetAddress().toString(), socket.getPort());
socket.close();
}
}
private Response process(Request request) {
// 根据当前参数和运算符完成计算, 并且构造响应
if (request.getOperator().equals("+")) {
return new Response(request.getNum1() + request.getNum2());
} else if (request.getOperator().equals("-")) {
return new Response(request.getNum1() - request.getNum2());
} else if (request.getOperator().equals("*")) {
return new Response(request.getNum1() * request.getNum2());
} else if (request.getOperator().equals("/")) {
return new Response(request.getNum1() / request.getNum2());
} else {
throw new RuntimeException("不支持的运算符: " + request.getOperator());
}
}
public static void main(String[] args) throws IOException {
TcpCalcServer server = new TcpCalcServer(9090);
server.start();
}
}
二,传输层
传输层负责数据能够从发送端传输到接收端,不关心中间过程,只关心起点和终点
在TCP/IP协议中,⽤"源IP","源端⼝号","⽬的IP","⽬的端⼝号","协议号"这样⼀个五元组来标识⼀个 通信(可以通过netstat-n查看);
2.1 端口号
整数,用来区分同一个主机上不同的应用程序
端口号是两个字节表示的无符号整数,取值范围为0 ~ 65535,但是0 ~ 1023之间的端口号数字一般会避开,因为这些端口号是给知名的服务器预留的,
剩下的 1024-65535:操作系统动态分配的端⼝号.客⼾端程序的端⼝号,就是由操作系统从这个范围分配的.
比较重要的两个知名服务器:
1,80 ---- HTTP服务器
2,443 --- HTTPS服务器
两个问题
Q1:一个进程是否可以bind多个端口号?
A:可以。不是进程绑定端口号,而是socket绑定端口号,一个进程中完全可以创建多个socket => 同时关联到多个端口号
Q2:一个端口号是否可以被多个进程bind?
A:如果是同一种协议就不可以,如果是不同的协议就可以
2.2 UDP协议
2.2.1 UDP协议端格式
整个UDP数据报如下:

整个UDP报头为8个字节,分为四个部分,每个部分两个字节
长度:通过两个字节的数据,描述了整个UDP数据报的长度(报头固定8个字节,整个长度-8就是载荷的长度)
其中,两个字节的取值范围最大最65535,约等于64KB,但是放在现在是不够用的
- 16位UDP长度,表示整个数据报(UDP首部+UDP数据)的最大长度
- 如果校验和出错,就会直接丢弃
校验和:起到的作用,是验证传输的数据是否出错了
网络传输过程中为什么会出错?
A:像10101这样的bit流,本质上是电信号/光信号/电磁波,电信号中高电平表示1,低电平表示2,但是在网络传输的过程中,会受到外面环境的干扰,如果网线中间经过了一些变化的磁场,感应电流就可能影响到传输的数据内容(所以一些高质量的网线,外层通常都有"屏蔽层")
本质上就是把UDP数据报每个字节(除去校验和本身的这俩字节)依次代入到一个数学公式,进行一系列的计算====》得到一个整数
如果输入的这一串字节,内容是固定的,并且计算公式也是固定的,此时得到的结果也就是一定的,反之,如果发现两次计算得到的结果不同,此时输入的内容也一定是不同的
计算校验和的方案:
本质上都是hash算法
1)CRC(循环冗余方案):简单相加,计算起来比较简单快捷,但是容易出现不同输入数据,得到相同结果的情况
2)md5系列/sha1系列:套入更加复杂的公式
特点:1,定长:无论输入的内容是多少个字节,输出结果就是固定长度的(有很多个版本,16,32,64,128......)
2,分散:输入的内容哪怕只是改变一点点,都会差别很大
3,不可逆:根据原始数据,计算出md5/sha1非常快的,根据md5/sha1值,反推出原始数据,成本非常大,几乎不可能