编程进阶网编程进阶网
  • 基础组成体系
  • 程序编程原理
  • 异常和IO系统
  • 六大设计原则
  • 设计模式导读
  • 创建型设计模式
  • 结构型设计模式
  • 行为型设计模式
  • 设计模式案例
  • 面向对象思想
  • 基础入门
  • 高级进阶
  • JVM虚拟机
  • 数据集合
  • Java面试题
  • C语言入门
  • C综合案例
  • C标准库
  • C语言专栏
  • C++入门
  • C++综合案例
  • C++专栏
  • HTML
  • CSS
  • JavaScript
  • 前端专栏
  • Swift
  • iOS入门
  • 基础入门
  • 开源库解读
  • 性能优化
  • Framework
  • 方案设计
  • 媒体音视频
  • 硬件开发
  • Groovy
  • 常用工具
  • 大厂面试题
  • 综合案例
  • 网络底层
  • Https
  • 网络请求
  • 故障排查
  • 专栏
  • 数组
  • 链表
  • 栈
  • 队列
  • 树
  • 递归
  • 哈希
  • 排序
  • 查找
  • 字符串
  • 其他
  • Bash脚本
  • Linux入门
  • 嵌入式开发
  • 代码规范
  • Markdown
  • 开发理论
  • 开发工具
  • Git管理
  • 百宝箱
  • 开源协议
  • 技术招聘
  • 测试经验
  • 职场提升
  • 技术模版
  • 关于我
  • 目标清单
  • 学习框架
  • 育儿经验
  • 我的专栏
  • 底层能力
  • 读书心得
  • 随笔笔记
  • 职场思考
  • 中华历史
  • 经济学故事
  • 基础组成体系
  • 程序编程原理
  • 异常和IO系统
  • 六大设计原则
  • 设计模式导读
  • 创建型设计模式
  • 结构型设计模式
  • 行为型设计模式
  • 设计模式案例
  • 面向对象思想
  • 基础入门
  • 高级进阶
  • JVM虚拟机
  • 数据集合
  • Java面试题
  • C语言入门
  • C综合案例
  • C标准库
  • C语言专栏
  • C++入门
  • C++综合案例
  • C++专栏
  • HTML
  • CSS
  • JavaScript
  • 前端专栏
  • Swift
  • iOS入门
  • 基础入门
  • 开源库解读
  • 性能优化
  • Framework
  • 方案设计
  • 媒体音视频
  • 硬件开发
  • Groovy
  • 常用工具
  • 大厂面试题
  • 综合案例
  • 网络底层
  • Https
  • 网络请求
  • 故障排查
  • 专栏
  • 数组
  • 链表
  • 栈
  • 队列
  • 树
  • 递归
  • 哈希
  • 排序
  • 查找
  • 字符串
  • 其他
  • Bash脚本
  • Linux入门
  • 嵌入式开发
  • 代码规范
  • Markdown
  • 开发理论
  • 开发工具
  • Git管理
  • 百宝箱
  • 开源协议
  • 技术招聘
  • 测试经验
  • 职场提升
  • 技术模版
  • 关于我
  • 目标清单
  • 学习框架
  • 育儿经验
  • 我的专栏
  • 底层能力
  • 读书心得
  • 随笔笔记
  • 职场思考
  • 中华历史
  • 经济学故事
  • 基础入门
  • 开源库解读
  • 性能优化
  • Framework
  • 方案设计
  • 媒体音视频
  • 硬件开发
  • Groovy
  • 常用工具
  • 大厂面试题

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
      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)

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” 就作为数据结尾,截取分隔符之前的数据 ,就得到了一个完整的数据段,然后再验证校验确保数据正确。
贡献者: yangchong211