一,应用层
和应用程序直接相关
虽然在应用层中,有很多大佬研究出来了很多协议,但是在实际开发的过程中,主要的应用层还是需要“自定义协议”
实例---开发外面软件
首先,实现获取商家列表的功能
针对这样的功能,需要约定一下“应用层协议”
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
// 表示一个请求 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; } }// 表示一个响应数据 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; } }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(); } }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值,反推出原始数据,成本非常大,几乎不可能