23.Socket通信设计实践
目录介绍
- 01.整体概述
- 1.1 项目背景说明
- 1.2 技术方案选型
- 1.3 基础概念介绍
- 1.4 开发设计目标
- 02.通信基础概念
- 2.1 Socket基础理解
- 2.2 TCP/IP协议简介
- 2.3 Socket与Http对比
- 2.4 Socket的使用类型
- 2.5 Socket和ServerSocket
- 03.Socket实践思路
- 3.1 基础实践步骤
- 3.2 Socket连接
- 3.3 Socket中tls连接
- 3.4 Socket数据读写
- 3.5 Socket断开连接
- 3.6 Socket完整案例
- 3.7 Socket多线程应用
- 04.方案设计
- 5.1 整体架构图
- 5.2 UML设计图
- 5.3 关键流程图
- 5.4 接口设计图
- 5.5 模块间依赖关系
- 05.稳定性实践说明
- 5.1 性能设计
- 5.2 稳定性设计
- 5.3 灰度设计
- 5.4 降级设计
- 5.5 异常设计
- 5.6 安全性设计
- 06.Socket原理探索和分析
- 6.1 Socket设计原理
- 6.2 Socket设计思想
- 07.TCP/IP精髓设计
- 7.1 协议版本如何升级
- 7.2 如何发送不定长数据的数据包
- 7.3 如何保证数据有序性
- 7.4 UDP传输数据可靠
00.问题汇总说明
- 关于Socket问题说明
- Socket概念:Socket是如何通信的?跟Http有何区别?数据传递性能如何?是否具有安全性?
- Socket实践:Socket是如何使用的的?如何创建连接,读数据(接受)和写数据(发送)分别是怎么设计的?
- Socket实践:读数据的时候,如何将io字节流转化为特定的tcp数据,拿到tcp数据后如何解析数据(解析成对应实体bean)?
- Socket长链接:如何设置socket保持长链接?如何保持轮训心跳包稳定性并且不会阻塞主线程?如何理解心跳包?
- Socket读写:如何理解Socket读写数据?如何处理读写异常逻辑?异常之后如何设计重新连接?
- Socket数据:TcpPacket是如何设计的?消息的长度是不确定的,并且每条消息都有它的边界。我们如何来处理这个边界?
- Socket数据:如何保证数据有序性?一个任务队列,执行任务,如何保证先取出的任务,执行结果需要先放入结果队列?
01.整体概述
1.1 项目背景说明
1.2 技术方案选型
- 类似微信消息接收等场景
- 方案1:需要客户端主动去轮询,则会频繁发起请求,对于服务器会产生很大的负载压力,浪费带宽流量。
- 方案2:通过长连接,服务端可以主动把消息下发给客户端,做到最高实时性,且节省流量。
1.3 基础概念介绍
1.4 开发设计目标
02.通信基础概念
2.1 Socket基础理解
- Socket定义
- 即套接字,是应用层 与 TCP/IP 协议族通信的中间软件抽象层,表现为一个封装了 TCP / IP协议族 的编程接口(API)。
- 核心要点
- Socket不是一种协议,而是一个编程调用接口(API),属于传输层(主要解决数据如何在网络中传输)
- 通过Socket,我们才能在Android平台上通过 TCP/IP协议进行开发;对用户来说,只需调用Socket去组织数据,以符合指定的协议,即可通信。
2.2 TCP/IP协议简介
- IP
- IP 协议提供了主机和主机间的通信。为了完成不同主机的通信,我们需要某种方式来唯一标识一台主机,这个标识,就是著名的IP地址。通过IP地址,IP 协议就能够帮我们把一个数据包发送给对方。
- TCP
- TCP 协议在 IP 协议提供的主机间通信功能的基础上,完成这两个主机上进程对进程的通信。
- Port
- 为了标识数据属于哪个进程,我们给需要进行 TCP 通信的进程分配一个唯一的数字来标识它。这个数字,就是我们常说的端口号。
2.3 Socket与Http对比
- 不属于同一层面
- Socket属于传输层,因为 TCP / IP协议属于传输层,解决的是数据如何在网络中传输的问题
- HTTP协议 属于 应用层,解决的是如何包装数据
- 工作方式的不同
- Http:采用 请求—响应 方式。可理解为:是客户端有需要才进行通信;
- Socket:采用 服务器主动发送数据 的方式。可理解为:是服务器端有需要才进行通信
2.4 Socket的使用类型
- Socket的使用类型主要有两种:
- 流套接字(streamsocket) :基于 TCP协议,采用 流的方式 提供可靠的字节流服务
- 数据报套接字(datagramsocket):基于 UDP协议,采用 数据报文 提供数据打包发送的服务
- 具体原理图如下:
image
2.5 Socket和ServerSocket
- Socket 和 ServerSocket 的区别是什么
- 在 Java 的 SDK 中,socket 的共有两个接口:用于监听客户连接的 ServerSocket 和用于通信的 Socket。
- 那各自的使用场景是什么样的
- Socket类代表一个客户端套接字,即任何时候连接到一个远程服务器应用时构建所需的socket。
- ServerSocket,要实现一个服务器应用,需要不同的做法。服务器需随时待命,因为不知道客户端什么时候会发来请求,此时,需要使用ServerSocket。
- ServerSocket与Socket不同,ServerSocket是等待客户端的请求,一旦获得一个连接请求,就创建一个Socket示例来与客户端进行通信。
03.Socket实践思路
3.1 基础实践步骤
- Socket可基于TCP或者UDP协议,但TCP更加常用。所以下面的使用步骤 & 实例的Socket将基于TCP协议。
- 第一步:创建客户端 & 服务器的连接。
- 第二步:客户端 & 服务器 通信。
- 第三步:断开客户端 & 服务器 连接。
3.2 Socket连接
- 第一步:创建客户端 & 服务器的连接。创建Socket对象 & 指定服务端的IP及端口号 ,判断客户端和服务器是否连接成功。
// 创建Socket对象 & 指定服务端的IP及端口号 Socket socket = new Socket("192.168.1.32", 1989); // 判断客户端和服务器是否连接成功 socket.isConnected();
- Socket连接条件
- 需要指定ip地址和port端口号。然后调用
socket?.connect(address, timeOut)
- 需要指定ip地址和port端口号。然后调用
3.3 Socket中tls连接
- 这一步的作用主要是:增加安全性校验。
3.4 Socket数据读写
- 第二步:客户端 & 服务器 通信。通信包括:客户端 接收服务器的数据 & 发送数据到服务器
<-- 操作1:接收服务器的数据 --> // 步骤1:创建输入流对象InputStream InputStream is = socket.getInputStream() // 步骤2:创建输入流读取器对象 并传入输入流对象 // 该对象作用:获取服务器返回的数据 InputStreamReader isr = new InputStreamReader(is); BufferedReader br = new BufferedReader(isr); // 步骤3:通过输入流读取器对象 接收服务器发送过来的数据 br.readLine(); <-- 操作2:发送数据 到 服务器 --> // 步骤1:从Socket 获得输出流对象OutputStream // 该对象作用:发送数据 OutputStream outputStream = socket.getOutputStream(); // 步骤2:写入需要发送的数据到输出流对象中 outputStream.write(("杨充"+"\n").getBytes("utf-8")); // 特别注意:数据的结尾加上换行符才可让服务器端的readline()停止阻塞 // 步骤3:发送数据到服务端 outputStream.flush();
3.5 Socket断开连接
- 第三步:断开客户端 & 服务器 连接
// 断开 客户端发送到服务器 的连接,即关闭输出流对象OutputStream os.close(); // 断开 服务器发送到客户端 的连接,即关闭输入流读取器对象BufferedReader br.close(); // 最终关闭整个Socket连接 socket.close();
3.6 Socket完整案例
- TCP Socket分为Socket和ServerSocket对应着client和server,下面我来用代码实现一个简单的TCP通讯功能:
- 客户端:首先创建一个Socket和InetSocketAddress,然后通过Socket的connect()方法进行连接,连接成功后可以获取到输出流,通过该输出流就可以向服务端传输数据。
public class TCPClient { public static void main(String[] args) throws IOException { //1.创建TCP客户端Socket服务 Socket client = new Socket(); //2.与服务端进行连接 InetSocketAddress address = new InetSocketAddress("192.168.31.137",10000); client.connect(address); //3.连接成功后获取客户端Socket输出流 OutputStream outputStream = client.getOutputStream(); //4.通过输出流往服务端写入数据 outputStream.write("hello server".getBytes()); //5.关闭流 client.close(); } }
- 服务端:创建一个服务端Socket并明确端口号,通过accept()方法获取到链接过来的客户端Socket,从客户端Socket中获取输入流,最后由输入流读取客户端传输来的数据。
public class TCPServer { public static void main(String[] args) throws IOException { //1.创建服务端Socket并明确端口号 ServerSocket serverSocket = new ServerSocket(10000); //2.获取到客户端的Socket Socket socket = serverSocket.accept(); //3.通过客户端的Socket获取到输入流 InputStream inputStream = socket.getInputStream(); //4.通过输入流获取到客户端传递的数据 BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream)); String line = null; while ((line = bufferedReader.readLine())!=null){ System.out.println(line); } //5.关闭流 socket.close(); serverSocket.close(); } }
- 一个服务端是可以同时和多个客户端进行通信的,那么它是如何区分不同客户端呢?
- 从上面代码我们可以看到,服务端首先通过accept()获取到客户端Socket,然后通过客户端的Socket获取的流进行通讯,这也让服务端得以区分每个客户端。
3.7 Socket多线程应用
- 如何是阻塞线程?
- 参考OkHttp的分发器
05.方案设计
5.1 整体架构图
5.2 UML设计图
5.3 关键流程图
5.4 接口设计图
5.5 模块间依赖关系
06.稳定性实践说明
6.1 性能设计
- 关于长链接心跳包优化【固定心跳】
- 如果想节省资源,在有客户发送数据的时候可以省略 heart beat。目前的做法是发送数据的时候,更新发送ping【移除之前的ping消息,然后发送一条最新的延迟ping消息】
- 优化使用智能心跳策略【动态心跳】
- 在尽量不影响用户收消息及时性的前提下,根据网络类型自适应的找出保活信令TCP连接的尽可能大的心跳间隔,从而达到减少App因心跳引起的空中信道资源消耗,减少心跳Server的负载,以及减少部分因心跳引起的耗电。
- 第一种:自适应心跳。第二种:前后台策略。
- 自适应心跳的设计
- 首先,如果心跳间隔越久,产生的负载和消耗也会越小。因此采用自适应心跳:当找到一个有效心跳间隔后,我们主动去加大这个间隔,然后测试是否能成功,如果不能,则使用比上一次成功间隔稍短的时间作为间隔;否则继续加大间隔,直到找到可用的有效间隔。
- 如何判断一个心跳间隔有效呢?采用方案是使用固定短心跳直到满足三次连续短心跳成功,则认为这个间隔有效。
- 探测过程大致为:60秒短心跳,连续发3次后开始探测,三次成功则改为90,依次类推120,150,180,210,240,270。
- 前后台策略的设计
- 考虑到App在前后台对于长连接的需求是不同的。
- 1.当App在前台活跃态时,采用了固定心跳机制;
- 2.当前台熄屏态或者后台活跃态(进入后台10分钟内)时,先用几次最小心跳维持长连接,然后进入自适应心跳机制;
- 3.当后台稳定态(超过10分钟),则采用自适应心跳计算出来的最大心跳作为固定值。
6.2 稳定性设计
- 如何建立稳定长连接
- 上面提到了多种长连接断开的原因,那我们应该如何进行优化,尽可能保证长连接不断开,或者及时断开了,也要尽快重连呢?
- 第一种:长连接独立进程。将长连接逻辑单独提取到了一个独立的进程里。这个进程只做网络交互,消耗的内存等资源自然较少,从而减少了被系统回收的概率。
- 第二种:心跳机制。对于心跳包很多人误以为只是用来定期告诉服务端我们的状态,实际并非如此。需要通过心跳机制来保证App的活跃度,防止发生 NAT 超时导致断开连接。
- 第三种:断开重连。在线上运行时,长连接很有可能会由于网络切换之类的原因断开。这时,我们需要尽快发现长连接断开,并立即重连。
6.3 灰度设计
6.4 降级设计
- 长连接通道建设及容灾
- 客户端与代理长连服务器建立长连接,代理服务器可全国多地部署,在建立长连时可以选择最近的服务器IP就近接入;
- 长连接建立好后,客户端对要发送的二进制数据进行加密并传输;
- 代理服务器收到后,可以通过内部专线或普通Http请求来访问业务服务器;
- 如果长连接出现问题导致不可用,为保障客户端运行,需要立即降级成普通Http短连或者UDP通道。
6.5 异常设计
- 如何处理读写异常逻辑?
- 对读出错时候的处理,可能也存在一些争议。读出错后,我们只是关闭了 socket。socket 需要等到下一次写动作发生时,才会重新连接。
- 实际应用中,如果这是一个问题,在读出错后可以直接开始重连。这种情况下,还需要一些额外的同步,避免重复创建 socket。
6.6 安全性设计
- 长连接数据协议及加密
- 长连接传递的是二进制数据,前后端可以自行协商每个字节要存放的内容即可。当然,也可以考虑采用一些通用协议:比如SMTP、ProtoBuf等序列化方案。
- 在数据加密方面,可以结合非对称加密算法RSA和对称加密算法AES来对数据进行加密传输。
07.TCP/IP精髓设计
7.1 协议版本如何升级
- 当我们对协议版本进行升级的时候,正确识别不同版本的协议对软件的兼容非常重要。那么,我们如何设计协议,才能够为将来的版本升级做准备呢?
- 答案可以在 IP 协议找到。IP 协议的第一个字段叫 version,目前使用的是 4 或 6,分别表示 IPv4 和 IPv6。由于这个字段在协议的开头,接收端收到数据后,只要根据第一个字段的值就能够判断这个数据包是 IPv4 还是 IPv6。
- 再强调一下,这个字段在两个版本的IP协议都位于第一个字段,为了做兼容处理,对应的这个字段必须位于同一位置。文本协议(如,JSON、HTML)的情况类似。
7.2 如何发送不定长数据的数据包
- 举个例子,我们用微信发送一条消息。这条消息的长度是不确定的,并且每条消息都有它的边界。我们如何来处理这个边界呢?
- 第一种:IP 的头部有个 header length 和 data length 两个字段。通过添加一个 len 域,我们就能够把数据根据应用逻辑分开。
- 第二种:那就是在数据的末尾放置终止符。比方说,想 C 语言的字符串那样,我们在每个数据的末尾放一个 \0 作为终止符,用以标识一条消息的尾部。这个方法带来的问题是,用户的数据也可能存在 \0。此时,我们就需要对用户的数据进行转义。比方说,把用户数据的所有 \0 都变成 \0\0。读消息的过程总,如果遇到 \0\0,那它就代表 \0,如果只有一个 \0,那就是消息尾部。
- 两种方案优缺点分析
- 第一种使用 len 字段的好处是,我们不需要对数据进行转义。读取数据的时候,只要根据 len 字段,一次性把数据都读进来就好,效率会更高一些。
- 第二种终止符的方案虽然要求我们对数据进行扫描,但是如果我们可能从任意地方开始读取数据,就需要这个终止符来确定哪里才是消息的开头了。
- 当然,这两个方法不是互斥的,可以一起使用。
7.3 如何保证数据有序性
- 曾经遇到过的面试题。现在有一个任务队列,多个工作线程从中取出任务并执行,执行结果放到一个结果队列中。
- 先要求,放入结果队列的时候,顺序顺序需要跟从工作队列取出时的一样(也就是说,先取出的任务,执行结果需要先放入结果队列)。
- 看看 TCP/IP 是怎么处理的。IP 在发送数据的时候,不同数据报到达对端的时间是不确定的,后面发送的数据有可能较先到达。TCP 为了解决这个问题,给所发送数据的每个字节都赋了一个序列号,通过这个序列号,TCP 就能够把数据按原顺序重新组装。
- 一样,我们也给每个任务赋一个值,根据进入工作队列的顺序依次递增。工作线程完成任务后,在将结果放入结果队列前,先检查要放入对象的写一个序列号是不是跟自己的任务相同,如果不同,这个结果就不能放进去。此时,最简单的做法是等待,知道下一个可以放入队列的结果是自己所执行的那一个。但是,这个线程就没办法继续处理任务了。
- 更好的方法是,我们维护多一个结果队列的缓冲,这个缓冲里面的数据按序列号从小到大排序。工作线程要将结果放入,有两种可能:
- 刚刚完成的任务刚好是下一个,将这个结果放入队列。然后从缓冲的头部开始,将所有可以放入结果队列的数据都放进去。
- 所完成的任务不能放入结果队列,这个时候就插入结果队列。然后,跟上一种情况一样,需要检查缓冲。
- 如果测试表明,这个结果缓冲的数据不多,那么使用普通的链表就可以。如果数据比较多,可以使用一个最小堆。
7.4 UDP传输数据可靠
- 模拟tcp
4.3 基于Socket通信
- Socket通信的优点
- 1、传输数据支持多种编码:传输数据为字节级,可灵活转换适配各种编码方式。
- 2、支持一对多:基于Socket通信,支持一对多,后期扩展性强。
- 3、速度快:传输时间短,速度快,性能高。
- Socket是对TCP/IP协议的封装,Socket本身并不是协议,而是一个调用接口(API)
- 通过Socket,我们才能使用TCP/IP协议来发送数据和接收数据。
- 如果是在一台电脑上运行两个程序,只要写死IP为127.0.0.1,端口选择一个合适的就可以进行通信。
- 但是实际场景有点不一样
- 因为一台是PC,另外一台是Android机具,在断网的情况下怎么通过一根USB线介质,实现Socket双向通信?adb forward tcp:1111 tcp:2222
- 顾名思义,adb forward的功能是建立一个转发,adb forward tcp:11111 tcp:22222的意思是,将PC端的11111端口收到的数据,转发给到手机中22222端口。
- 但是光执行这个命令还不能转发数据,还需要完成两个步骤才能传数据。这两个步骤是:
- 1、在手机端,建立一个端口为22222的server,并打开server到监听状态。
- 2、在PC端,建立一个socket client端,连接到端口为11111的server上。
4.4 TCP粘包/拆包
- TCP编程底层都有粘包和拆包机制,因为我们在C/S这种传输模型下,以TCP协议传输的时候,在网络中的byte其实就像是河水,TCP就像一个搬运工,将这流水从一端转送到另一端,这时又分两种情况:
- 1、如果客户端的每次制造的水比较多,也就是我们常说的客户端给的包比较大,TCP这个搬运工就会分多次去搬运。
- 2、如果客户端每次制造的水比较少的话,TCP可能会等客户端多次生产之后,把所有的水一起再运输到另一端。
- 上述第一种情况,就是需要我们进行粘包,在另一端接收的时候,需要把多次获取的结果粘在一起,变成我们可以理解的信息,第二种情况,我们在另一端接收的时候,就必须进行拆包处理,因为每次接收的信息,可能是另一个远程端多次发送的包,被TCP粘在一起的。
- 粘包问题的解决策略:由于底层的TCP无法理解上层的业务数据,所以在底层是无法保证数据包不被拆分和重组的,这个问题只能通过上层的应用协议栈设计来解决,根据业界的主流协议的解决方案,可以归纳如下。
- 1、消息定长,例如每个报文的大小为固定长度200字节,如果不够,空位补空格;
- 2、在包尾增加回车换行符进行分割,例如FTP协议;
- 3、将消息分为消息头和消息体,消息头中包含表示消息总长度(或者消息体长度)的字段,通常设计思路为消息头的第一个字段使用int32来表示消息的总长度;
- 4、更复杂的应用层协议。
- 采用的解决方案:
- 目前我们微校使用的更复杂的应用层协议的包结构定义
包头 包长度 包ID 版本号 Json数据 MD5 包尾 WX+U/D/C) 69 1 1.0.0 {"action":"pay","total":1} cc3d4f642efb68525ea4a7d21ff5ad7e \r\n
- 使用队列的方式,接收事件收到数据就往队列里添加,数据处理线程从队列里取数据,以分隔符 “\r\n” 就作为数据结尾,截取分隔符之前的数据 ,就得到了一个完整的数据段,然后再验证校验确保数据正确。
- 目前我们微校使用的更复杂的应用层协议的包结构定义