文章目录
- 一、TCP
- 1.1、TCP提供的api —— ServerSocket 、Socket
- 1.2、使用TCP协议编写回显服务器
- 1.3、长/短连接
- 二、应用层协议、传输层协议详解
- 2.1、应用层(后端开发必知必会)
- 2.1.1、自定义应用层协议
- 2.1.2、通用的协议格式
- 2.1.2.1、XML
- 2.1.2.2、json
- 2.1.2.3、protobuffer
- 2.2、传输层
- 2.2.1、UDP
一、TCP
TCP服务器端需要做的事:
1、获取连接
2、处理连接:(1)、读取请求并解析 (2)、根据请求计算出响应 (3)、写回响应
TCP客户端需要做的事:
1、构造请求并发送
2、从服务器端获取响应
3、把响应显示
1.1、TCP提供的api —— ServerSocket 、Socket
1、ServerSocket:
ServerSocket 是提供给服务器使用的类。
由于TCP特点是有连接的,因此TCP服务器启动后的第一件事不是读取客户端请求,而是先处理客户端的 “连接”。
握手是由系统内核负责的,写代码过程中无法感知到握手的过程,写代码时主要是处理连接,连接是握手之后得到的东西,是比较抽象的,就是说客户端和服务器彼此保存了对方的信息,这个抽象的连接是由系统内核完成的)。
一个服务器要对应很多的客户端,因此内核中的连接很多,这些连接就像一个一个的待办事项,这些待办事项存储在一个队列这样的数据结构中,应用程序就需要从队列中一个一个的取出连接,完成这些连接任务。
2、Socket:
既会给服务器使用,也会给客户端使用。
TCP传输数据的基本单位是 字节流,一个TCP数据报,就是一个字节数组 byte[] buf。
1.2、使用TCP协议编写回显服务器
使用TCP协议编写回显服务器的源码地址
代码详解:
1、serverSocket.accept():把内核中的连接获取到应用程序中。(但是此处的返回值并非是一个 “Connection” 这样的对象,而是一个 Socket 对象,这个 Socket 对象,就像一个耳麦一样,拿着它既可以讲话让对方听见,也可以听到对方讲话的声音(是TCP全双工的表现))。
这个过程类似于 “生产者消费者模型”。
但有可能出现一种情况:即当程序执行到代码serverSocket.accept()时,有可能此时服务器还没有客户端连接,那么此时队列中也就没有任何连接,应用程序也就从内核中取不到任何连接,此时程序就会阻塞在accept()方法这里,直到有客户端连接成功为止。(一次IO分两个部分:1、等待(阻塞)。2、拷贝数据)
为啥建立连接要进行握手??就像相亲时不可能双方一见面就马上结婚(结婚也是一种抽象的连接),必须有一个相互了解的阶段。此处衍生出一个重要的面试题:TCP的3次握手和14次挥手。(后面再详解)
2、在TCP服务器代码处,有两种 Socket 对象,一种是 ServerSocket ,一种是 Socket 。
那这两种 Socket 有什么区别?
serverSocket 就像面包店外试吃区的销售人员,socket 就像面包店里售卖面包的销售人员。试吃区的销售人员其实就是在为面包店拉客,面包店里的销售人员会给被试吃区拉来的顾客详细介绍店里的面包种类,来进一步吸引顾客购买。这两种销售人员虽然有不一样的称呼,但是他们的目的都是为了销售面包店里的面包给顾客。
ServerSocket 只有一个,生命周期跟随程序,不关闭也没事。但是对于 Socket 来说,一定要关闭。如果有1w个客户端连接,就会有1w个Socket,此处的 Socket 是被反复创建的,因此对于 Socket 来说,必须确保连接断开之后会被关闭!否则就会发生文件资源泄露,导致整个程序被带走。
3、在TCP服务器代码中,通过方法 processConnection() 处理客户端的一个连接,由于accept() 方法会阻塞,因此需要借助线程池来处理客户端的连接。当只有一个执行流时,accept()方法处发送阻塞会导致其他连接了服务器的客户端没办法继续执行,这显然不符合一个服务器的执行常理,因此服务器端需要借助线程池的多个执行流处理客户端的连接。
其实我们的服务器代码没加上线程池前,会出现一个问题:当第一个客户端连接好了之后,第二个客户端,不能正确被处理:即此时服务器看不到客户端上线,同时客户端发来的请求也无法被处理。当第一个客户端退出之后,之前第二个客户端发来的请求,就能正确的响应了。
但是对于服务器来说,服务器是能够同时处理多个客户端的请求,因此上述第一个客户端连接执行完毕后,第二个客户端连接才能被处理这种做法是不正常的,因为一个客户端连接不知道什么时候才能执行完,一个客户端连接是可以进行很多个请求、响应的,因此对应的也就不知道accept()方法的阻塞什么时候才能结束,因此想要解决accept()的阻塞,可以使用线程池来解决该问题,使用线程池后程序中就可以含有多个执行流,既可以执行accept(),也可以执行processConnection()方法。一个执行流执行processConnection()方法,另一个执行流也能快速调用到accept()。不使用线程池也可以,只要是多线程方式都可以解决该问题。(在网络编程中,多线程会常常使用到)
还有一个问题:
有些同学可能会在TCP服务器端写出这样的代码:
还有问题:我们引入了 线程池 解决了 accept() 阻塞问题,但是线程池也是有开销的,在服务器对应的客户端很多的情况下,服务器就需要创建出大量的线程去处理客户端连接的任务,此时服务器的开销是很大的,响应速度也会大打折扣。那么是否有办法,使用一个线程或者3、4个线程就能让服务器高效的处理客户端的并发(几万/几十万客户端)请求?
其实随着互联网的发展,客户端会越来愈多,请求也越来越多,C10M就出现了。C10M:同一时刻,有1kw的客户端并发请求。此时服务器的负担真的很大,因此引入了很多技术,其中一个很有效的手段就是:IO多路复用!(一种节流的方式,在同样的请求下,消耗的硬件更少了,本质上就是减少线程的数量)。
解决高并发,其实就是两步走:1、开源:引入更多的硬件资源。2、节流:提高单位硬件资源能够处理的请求数,同样的请求数,消耗的硬件资源更少。
4、String request = sc.next();
此处使用 next() 读取数据,一直读到空白符算结束。那什么是空白符?包括但不限于:换行符(\n:让光标另起一行)、回车符(\r:让光标回到行首(不会另起一行))、空格键、制表符、换页符、垂直制表符…
5、PrintWriter
访问IO比访问内存的开销大,因此进行IO的次数越多,程序的速度越慢。因此通常会使用一块内存作为缓冲区,写数据的时候,先写到缓冲区里,缓冲区里攒一波数据,统一进行IO。而 PrintWriter 内置缓冲区。printWriter.flush() 是进行手动刷新,就是确保缓冲区里的数据真的通过网卡发出去了,而不是残留在内存缓冲区里,缓冲区内置了一定的刷新策略,例如缓冲区满了,就会触发刷新,例如程序退出,也会出发刷新。
6、
流对象中持有的资源,有两个部分:1、内存(对象销毁,内存就会被回收)。2、文件描述符。
当 while 循环一圈,内存自然就会被销毁。 在我们服务器端的代码,Scanner 和 PrintWriter 没有持有文件描述符,他们持有的是 inputStream、outputStream 的引用,当 inputStream、outputStream 被销毁之后,Scanner 和 PrintWriter 也都被销毁了;而 inputStream、outputStream 是被 Socket 对象持有的,因此当 Socket 对象被关闭之后,inputStream、outputStream 也就随之被销毁了。
1.3、长/短连接
TCP程序时,涉及到两种写法:1、短连接:一个连接中只传输一次请求和响应。2、长连接:一个连接中可以传输多次请求、连接。
二、应用层协议、传输层协议详解
2.1、应用层(后端开发必知必会)
2.1.1、自定义应用层协议
应用层这一层,有很多现成的协议,也有很多时候,需要我们程序员自己去自定义应用层协议。自定义应用层协议,也是一件很简单的事情。
举个例子:此处有一个需求场景:一个外卖软件,需要在用户打开此软件时,给用户显示用户住址附近的商家列表,列表中有很多项,每一项都包含了一些信息:譬如:商家名称、商家图片、商家店铺好评率,商家与用户的距离、商家评分…(其实外卖软件和服务器之间的沟通,有很多种方式,展示商家列表,只是其中之一)
客户端(用户…):需要给服务器发起一个请求,服务器收到请求之后,就给客户端返回一个响应。[!]那么这个请求里应该包含什么信息呢??该请求应该按照什么格式组织呢??同样的,这个响应应该包含什么信息??应该按照什么格式解析??
此时程序就应该做出如下设计:
1、明确当前程序的请求和响应中都包含哪些信息??(一般在实际开发工作中,请求和响应中都包含哪些信息是根据需求文档里标的来写的)
2、明确具体的请求和响应的格式。[所谓的“明确格式”就是看你按照啥样的方式构造出一个字符串,后续这个字符串就可以作为 tcp/udp的payload进行传输]
明确具体的请求和响应的格式:
示例1:假设此处的请求包含的信息:用户身份、用户当前位置;响应包含的信息:商家名称、商家图片、商家好评率、商家距离用户的位置、商家评分…
请求:1234,80 100\n
响应:蓝胖子肉蟹煲,1.jpg,98%,1km,4.9\n 美蛙鱼头,2.jpg,95%,2km,4.2\n \n
示例1 的响应使用\n来分割每条商家信息。
示例2:
请求:1234;80,100.
响应:蓝胖子肉蟹煲;1.jpg;1km;98%;4.7!美蛙鱼头;2.jpg;2km;95%;4.2!.
示例2 的响应使用!来分割每条商家信息。
其实我们可以看到示例2的商家列表的信息顺序跟示例1并不一样,这是因为这些都是可以自定义的,不需要一样。
示例3:
从上述几种示例可知,请求和响应的数据组织格式非常灵活,程序员想怎么组织就怎么自定义,当然了,自定义的时候必须要能够保证客户端和服务器这边使用的是相同规则即可。因为在实际开发中,客户端和服务器,往往是由不同的人员负责,因此开发之前,双方就必须共同约定好请求和响应的数据组织格式。
虽然说程序员可以自定义应用层协议,但是为了避免出现过于天马行空的设计,此时行业标准就规定了一些“通用的协议格式”,程序员开发时参考这些格式,就可以在自定义应用层协议时更加严谨、正确。
2.1.2、通用的协议格式
2.1.2.1、XML
xml:是以成对的标签来表示键值对的信息,标签支持嵌套,就可以构成一些更复杂的树形结构数据。
以上面的外卖软件例子为例,使用xml格式来表示 请求:
<request>
<useId>1234</useId>
<position> 80 100</position>
</request>
响应:
<response>
<shops>
<shop>
<name>蓝胖子肉蟹煲</name>
<image>1.jpg</image>
<distance>1km</distance>
<rate>98%</rate>
<star>4.7</star>
</shop>
<shop>
<name>美蛙鱼头</name>
<image>2.jpg</image>
<distance>2km</distance>
<rate>95%</rate>
<star>4.2</star>
</shop>
</shops>
</response>
上面的 xml 格式表示的是 键值对 结构。key:userId,value:1234。其实对象本质上也是键值对,属性的名字就是 键,属性的值就是 值。
xml 优缺点:
优点:非常清晰的将结构化数据表示出来了。
缺点:表示数据需要引入大量的标签,看起来繁琐,同时也会占用不少的网络带宽。
其实我们有没有觉得 xml 的编写形式和 html 有些类似,html是编写网页的语言,也是以标签的形式出现。但是 xml 里的标签是程序员自定义的,html 里的标签是有自己的一套使用标准的。(可以把 html 视为是 xml 的特化版本)
2.1.2.2、json
json,当前最主流的一种网络传输数据的格式,本质上也是键值对,但json看起来,比 xml格式 简单便捷、干净很多;json对于换行并不敏感,如果这些内容全都放在一行,也是完全合法的。
一般网络传输时,会对json进行压缩(即去掉不必要的换行和空格),同时把所有数据都放到一行去,要传输的数据其整体占用的带宽就降低了。但可能这样的压缩会影响可读性,不过可以使用一些现成的 json 格式化工具恢复json原有的格式。
在json中,使用 { } 表示键值对,使用 [ ] 表示数组,数组里的每个元素,可以是数字、字符串、字母、或者是其他的 { } 、[ ]…
json格式的请求:
{
userId:1234,
position:“100 80”
}
响应:
{
{
name:“蓝胖子肉蟹煲”
image:“1.jpg”,
distance:“1km”,
rate:98%,
star:4.7
}
{
name:“鱼头”
image:“2.jpg”,
distance:“2km”,
rate:95%,
star:4.2
}
}
json优点:相比于 xml ,表示的数据简洁很多。
2.1.2.3、protobuffer
protobuffer是谷歌提出的一套二进制数据序列化方式,使用二进制的方式,约定几个字节,表示哪个属性。
优点:可以最大程度上节省空间,节省带宽,最大化效率。即不必传输 key,可以根据位置和长度,区分每个属性。
缺点:是二进制数据,无法直接肉眼直接观察,不方便调试。
这类格式很有可能会在工作中用到,尤其是在一些规模复杂的后端服务器。
Java标准库提供好了上述3种序列化方式(数据组织格式),而其他的第3方库,提供的方式更加丰富,后续再介绍。
2.2、传输层
传输层最常见的协议是 UDP、TCP,学习一个协议,不仅要掌握协议的特性,还需要理解协议报文格式。
2.2.1、UDP
我们都知道 UDP 载荷部分里是应用层数据报,那么UDP报头里都有啥,都是啥信息??[重点理解]
从上图知:一个 UDP报头 含有4部分:1、源端口号。2、目的端口号。3、UDP报文长度。4、校验和。这4个部分都是2个字节的长度,那2个字节,表示的数据范围有多大(这可是最基本的知识点,必须牢记):
(1)、1字节:
有符号:-128 ~ +127
无符号:0~255
(2)、2字节:
有符号:-32768 ~ +32767
无符号:0~65535
(3)、4字节:
有符号:-21亿 ~ +21亿
无符号:0~42亿9千万
在程序中,程序员设定某个这些基本单位时,一定一定要小心,不然程序极易出现错误:
例子1:假如一家游戏公司,使用单位为2字节的某个字段实现游戏的某个功能,我们知道2字节可以表示的数据范围为:0~65535,那么用户在玩游戏时,疯狂玩,此时其收获的成绩峰值就极速上升,上升至了6w,用户还不满足,希望自己的战绩能更好,再继续疯狂玩,但却突然发现,自己的战绩却从6w跌至0。这是不应该的,表示此时程序出现了bug。其实原因就是因为使用了2字节的字段作为表示用户战绩情况,2字节的数据表示范围:0 ~ 65535,当达到最大值之后,数值就无法再继续增大了,会从0重新开始。因此,程序中使用某个字段表示某个功能时,其字段的选择应该要慎重、要考虑其缺陷性。
例子2:假如我们在工作中,被安排了一个任务:记录这一天,公司的搜索浏览器一共有多少请求?假设公司搜索浏览器每天的请求量在10亿量级,此时记录请求的变量,是使用 int 还是 long 类型??
注意:10亿量级,是只有10亿吗??量级此单位,其实是2倍,10亿量级,其实是21亿多。那么此时,变量使用int就不合适了,因为int能够表示的数据范围是 0~65535,变量可以使用long类型,long类型的数据表示范围为 0 ~ 42亿9千万,此时该数据范围完全可以表示 21亿 。
报文长度:
UDP报文长度2个字节,通过换算单位,可得到64kB,因此一个 UDP数据报其最大长度就是64KB(2个字节 = 216,210 = 1KB, 1KB*2^8 = 64KB),那如果说,当前程序使用UDP协议通信,程序想发送的数据超过了64KB,此时怎么使用UDP数据报来携带已超过协议最大长度的数据进行网络传输?1、可以把数据拆分成多组,通过几个UDP数据报进行传输;2、使用 TCP 代替 UDP ,因为 TCP 没有长度限制。不管用啥办法,反正UDP就是不能超过它的最大长度。因为当时行业标准设定UDP的最大长度就是64KB,我们只能遵守这个规则。
源端口、目的端口:
UDP端口号长度2个字节:可以看到 UDP报头 包含 源端口和目的端口,因为端口号使用2字节表示,因此端口号的数据表示范围为0~65535,但对于 1 ~ 1024 的端口号范围,这个范围的端口号已经被一些知名的程序的服务器占用,因此我们写的服务器,很少会用1 ~ 1024 范围的端口号来表示了,一般都是使用 1024之后的端口号表示,不过 1024 之后的端口号也有一些已经被知名程序的服务器占用了,如 3306 已经被 MySQL 程序占用了。
校验和:
其实在网络传输中,受到外界的干扰,传输的数据很容易出错,因为数据传送过程中,本质上是 光信号/电信号/电磁波,这些形式很容易受到磁场、高能粒子射线的干扰。此时就会导致本来你要传输的数据发生比特翻转,如 0 ——> 1、1 ——> 0…此时接收方接收到的数据就是错的。尤其是UDP的长度是64KB,遇上大过64KB的数据就需要进行拆分,将数据分成多组UDP数据发送,此时拆分的数据虽然说是传输过去了,但是对于接收方来说,还需要将数据拼接起来,此时怎么拼接?拼接过程发生了差错怎么办??怎么校验差错??那么,此时协议就通过 校验和 来对网络传输过来的数据进行校验,查看当前数据是否出错。实际的校验和,是会根据数据内容来生成,因此当数据出错,校验和就能感知到数据出错。校验和使用的是CRC校验算法(循环冗余校验和):即把UDP数据报中的每个字节都依次进行累加,把累加结果保存到2个字节的变量中,加着加着,可能就溢出了,但溢出也无所谓,所有字节都加了一遍,最终就得到了校验和。其实在传输数据时,就会把原始数据和校验和一起传递过去,接收方收到数据,同时也收到了发送端传送过来的校验和(旧的校验和),接收方按照同样的方式再算一遍,得到新的校验和。如果新的校验和 与 旧的校验和 相同,则可以视为数据传输过程中,数据是正确的,未发生差错;否则则反之。但是需要注意:数据相同,可推出校验和相同;校验和相同,无法推出数据相同!