基于现行WebSocket聊天开发新端的一些思考

191 阅读10分钟

前言


本次的项目背景为在现行的聊天服务基础上,开发PC端,兼容已有其它端(小程序、APP)消息互通。现行聊天服务已迭代5年+,消息体key有20+个,存在大量冗余key;人员更迭多次,几经易手,水平高低不齐;无文档可查,一直裸奔。

现状


举个🌰,一个单纯的文本消息,消息体demo

{
    "from": {
        "uid": 1000000000,
        "uidCry": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
        "name": "",
        "avatar": "",
        "identity": 1,
        "age": 0,
        "content": "",
        "distance": 0,
        "distenceDesc": "",
        "chatUserBoss": null,
        "gender": 0,
        "coverUrl": "",
        "headCoverUrl": "",
        "bottomUrl": "",
        "chatUserGeek": null,
        "lng": 0,
        "lat": 0,
        "userSource": 1,
        "type": 0,
        "protocolUrl": "",
        "friendRelationType": 0,
        "subTitleLabel": "",
        "deliverResumeStatus": 0,
        "interviewStatus": 0,
        "followStatus": 0,
        "officialImageUrl": "",
        "limitLevel": 0,
        "userSettingsVersion": 0,
        "groupNickname": null,
        "encryptId": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
    },
    "to": {
        "uid": 1000000001,
        "uidCry": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
        "name": "",
        "avatar": "",
        "identity": 2,
        "age": 0,
        "content": "",
        "distance": 0,
        "distenceDesc": "",
        "chatUserBoss": null,
        "gender": 0,
        "coverUrl": "",
        "headCoverUrl": "",
        "bottomUrl": "",
        "chatUserGeek": null,
        "lng": 0,
        "lat": 0,
        "userSource": 1,
        "type": 0,
        "protocolUrl": "",
        "friendRelationType": 0,
        "subTitleLabel": "",
        "deliverResumeStatus": 0,
        "interviewStatus": 0,
        "followStatus": 0,
        "officialImageUrl": "",
        "limitLevel": 0,
        "userSettingsVersion": 0,
        "groupNickname": null,
        "encryptId": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
    },
    "templateId": 1,
    "id": 3000000000,
    "clientMsgId": 0,
    "messageSeq": 0,
    "messageSeqFrom": 0,
    "messageSeqTo": 0,
    "createTime": 1672739356000,
    "type": 1,
    "text": "文本消息""textSyn": true"headTitle": "您有一条新消息",
    "eventTracking": null,
    "persistence": true,
    "entrySaved": false,
    "offline": false,
    "received": false,
    "msgSource": 0,
    "taskId": 0,
    "saveInbox": true,
    "saveOutbox": true,
    "sync": false,
    "convert": false,
    "sinceVersion": 0,
    "sendToSelf": false,
    "push": true,
    "typeAddFriend": 0,
    "clientInfoMap": null,
    "pushUrl": null,
    "pushExtraInfo": null,
    "fakeChat": false,
    "report": false,
    "noticeType": 0,
    "multiSyn": true,
    "readMsgId": 0,
    "msgSender": 0,
    "zpMsgRealChat": false,
    "fromId": 1000000000,
    "fromSource": 0,
    "toId": 1000000001,
    "toSource": 0,
    "lng": 0,
    "lat": 0,
    "backStatus": 0,
    "extStatus": 0,
    "groupId": 0,
    "toUsers": null,
    "toChatUsers": null,
    "snapshotId": 0,
    "groupVersion": 0,
    "groupUserVersion": 0,
    "unreadCount": -1,
    "push": true
    "pushExtraInfo": null
    "pushMessage": "文本消息",
    "pushMsgKey": null,
    "filters": null,
    "feedSign": false,
    "actionType": "CHAT_GUIDE_TAG",
    "extend": "",
    "aid": 0,
    "mediaBody": "{\"type\":85,\"extend\":\"\",\"lng\":0,\"lat\":0,\"feed_sign\":false}",
    "pushMessage": "",
    "mediaType": 4,
    "clientInfoJson": "",
    "mjobSource": 0,
    "mjobIdCry": null
}

开屏暴击,意不意外?惊不惊喜?开不开心?

前面说的20+key看来保守了,这里简单拆分下,看起来清晰一点

{
    // 发送方用户信息
    "from": {...},
    // 接收方用户信息
    "to": {...},
    // 各种ID🤨
    "templateId": 1,
    "id": 3000000000,
    "clientMsgId": 0,
    "taskId": 0,
    "fromId": 1000000000,
    "toId": 1000000001,
    "groupId": 0,
    "snapshotId": 0,
    "aid": 0,
    "mjobIdCry": null
    // 各种枚举
    "type": 1,
    "msgSource": 0,
    "noticeType": 0,
    "backStatus": 0,
    "extStatus": 0,
    "actionType": "CHAT_GUIDE_TAG",
    "mediaType": 4,
    "mjobSource": 0,
    // 各种你猜这是啥
    "messageSeq": 0,
    "messageSeqFrom": 0,
    "messageSeqTo": 0,
    "eventTracking": null,
    "persistence": true,
    "entrySaved": false,
    "offline": false,
    "received": false,
    "saveInbox": true,
    "saveOutbox": true,
    "sync": false,
    "convert": false,
    "sinceVersion": 0,
    "sendToSelf": false,
    "push": true,
    "typeAddFriend": 0,
    "clientInfoMap": null,
    "pushUrl": null,
    "pushExtraInfo": null,
    "fakeChat": false,
    "report": false,
    "multiSyn": true,
    "msgSender": 0,
    "zpMsgRealChat": false,
    "lng": 0,
    "lat": 0,
    "toUsers": null,
    "toChatUsers": null,
    "groupVersion": 0,
    "groupUserVersion": 0,
    "unreadCount": -1,
    "pushMsgKey": null,
    "filters": null,
    "feedSign": false,
    "extend": "",
    "mediaBody": "{\"type\":85,\"extend\":\"\",\"lng\":0,\"lat\":0,\"feed_sign\":false}",
    "pushMessage": "",
    "clientInfoJson": "",
}

可以看出来,消息体根节点下近60+key,即便有文档,面对数量如此之多的key,维护起来的各项成本也十分沉重;重点是,不同的消息类型,消息体的key是有出入的;不要忘记,还有基于这些key的业务逻辑。。。

这是经过初步整理后的消息体业务逻辑。 WX20230103-184009@2x.png 小程序的就不贴出来了,业务和视图耦合在一起了,保护眼睛👀先

只能说,💩⛰️无疑了。

What happened


现在去把写出如此代码的人抓起来打一顿也无济于事了,甚至有可能这个人还是你现在的领导🐶;

  • 实nao力dong分da析kai一下 最初版本应该仅包含from,to,id,type,mediaType,actionType,createTime,text等核心信息;

随着业务不断的迭代,开发设计时发现现有的key无法满足新的业务场景,本着不能影响现有业务的前提,加个新key吧,反正就这么几个,稍微看下就知道都是啥了;

迭代了几个版本后,来了个新伙伴开发新需求,拿到代码后,看不懂啊,也没个文档,这都是啥,这个key是干啥的,算了,加个新的吧,改之前的再出了问题咋整。。。

坏味道不是一下子就爆发出来的,是一点一点积累起来的。

小程序出现了,业务:我们也要上,和APP搞一套一样的!这版先简单点

前端er:APP端有文档没啊?

没有,一点点对吧

前端er:* * * ,文档都没有,APP咋弄的,抄一下呗。。。

坏味道开始蔓延

最终,经过大家的不断努力,聊天服务支撑了上百万的用户的使用,期间也没出现啥线上问题。

聊天服务支持了多端消息互通,增加了消息敏感词过滤,消息队列、断线重连;从最简单的文本消息,到现在的图片、视频、语音通话、视频通话、唤起电话;展现多种多样的卡片,支持按钮、超链接跳转等多种交互;集成语音识别转文字。。。

扒开光鲜亮丽的面具,里面的腐败,已经难以呼吸

如此这般,到了今天。

我来班门弄斧一下

前车之鉴过于沉痛,恰逢团队代码风格统一提上日程,TS安排起来。

整个聊天的服务已经积重难返,推倒重构显然不可取,时间、测试、人力的成本根本不允许。

只能退一步,把混乱隔离在过去

  • 将WebSocket封装成独立API并对外提供
    export type ChatWebsocketType = {
        connectState: ConnectStateEnum // 当前连接状态
        client?: ClientType // 客户端实例
        init: (
            opt: ConfigType, // 连接配置
            cb: InitCallBackType // 回调事件
        ) => void
        connect: (opt?: ConfigType) => void // 发起连接
        reConnect: (opt?: ConfigType) => void // 重新连接
        close: () => void // 关闭连接
        onConnectSuccess: (e: ConnetctEventType) => void // 连接成功回调
        onConnectFailure: (e: ConnetctEventType) => void // 连接失败回调
        onConnectStateChange: (e: ConnetctEventType) => void // 连接状态变更回调
        onMessageArrived: (data: unknown) => void // 收到消息
        syncMessageState: (data: unknown) => void // 消息已送达回执
        addFriend: (data: unknown) => void // 添加好友
        onMessageStateChange: (data: unknown, state: MessageStateEnum) => void // 消息状态变更
        send: (message: unknown, qos?: number, timeout?: number) => boolean // 发送消息方法
        sendMessage: SendMessageType // 一般消息发送
        resendMessage: SendMessageType // 一般消息重发
    }
    
    • 对消息体封装方法单独处理,包括传入传出,使API的出参入参保持一致;
    • 将消息体key使用TS进行规范统一,并进行长期规划形成框架,使后续更改被严格限制在框架内;
      // 消息单条类型
      export type MessageItemType = {
          from?: MessageItemFriendType
          to: MessageItemFriendType
          content: MessageItemContentAllType // 消息内容
          type: MessageItemTypeEnum // 消息类型
          source?: MessageItemSourceEnum
          status: MessageItemStatusEnum
          id?: number
          createTime?: number
      }
      // 消息内容
      export interface MessageItemContentType {
          value: string | unknown
          operated?: boolean
      }
      // 消息卡片类型
      export enum MessageItemTypeEnum {
          text = 1, // 文本
          image = 2, // 图片
          sound = 3, // 语音
          videoCall = 4, // 视频通话
          audioCall = 5, // 语音通话
          exchangePhone = 6, // 交换电话
          exchangeWx = 7, // 交换微信
          resumeCard = 8, // 牛人卡片
          invite = 9, // 面试邀请
          soundText = 10, // 语音转文字
          position = 11, // 位置
          other = 100, // 其他消息类型
      }
      
    • 协调服务端整理输出现行的消息服务相关的文档,
      • 消息体字段与最终呈现的UI对应关系
      • 消息体全部字段内容格式及其含义
      • 各种事件函数等处理逻辑。
  • 拆分UI,降低UI与业务逻辑的耦合
    • 按照TS规定的类型进行拆分消息卡片组件,使之脱离业务逻辑; WX20230104-102527@2x.png
    • 结合React生态,按照数据驱动视图的思想,抽离提取交互逻辑
    • 合理增加注释,降低后续上手成本
      - chat-message // 聊天全部页面组件
          - index.tsx // 根组件
          - contant.ts // TS类型、枚举
          - style.css // 样式文件
          - component // 聊天内组件
            - friend-filter // 好友列表筛选条
            - friend-list // 好友列表
            - chat-friend-info // 选中的好友基础信息
            - chat-list // 对话消息列表
            - chat-msg-item // 对话列表单条,按照MessageItemTypeEnum逐个处理
            - chat-input // 消息输入框
      
  • 整理文档
    • 整理输出目录、组件、关键流程、类型声明、API文档,降低后续上手成本
    • 在关键位置增加注释规则说明如何迭代维护
  • 组内成员互通有无,结合团队代码风格统一进行普及

why


看到如此大费周章的设计开发,有同学该说了:把小程序的逻辑粘过来,改吧改吧不就搞定了,剩下时间开开心心摸鱼多好。

举个简单的🌰,前段时间有个需求,最终的改动加起来也就10行不到的代码,结果用了2天时间定位需要改动的代码位置,2天回测改动是否达到预期且不影响其它逻辑,来回来去精神高度集中的折腾4天,提心吊胆的改,胆战心惊的上线,就是因为💩⛰️的现状,导致后续维护成本飙升,丢了西瓜捡了芝麻的买卖咱可不兴干啊

回过头来,再说下这么设计的来龙去脉

  • 首先是出参入参保持一致
    • 对于一个外部的API,相同的实例/参数具有相同的结构更加符合直觉
    • 同样的结构更利于转化为TS类型
    • 基于同样的结构能进一步减少错误的产生
    • 结构一致还可以降低理解成本
    • 增加函数复用度,更加有利于结合其它设计模式
  • 将WebSocket拆分成独立API
    • WebSocket属于基建服务,与业务耦合很低甚至接近没有
    • 完整性较强,不存在后续拓展空间
    • 对使用场景限制很低
    • 独立出去便于后续迭代维护
  • 整理输出WebSocket文档
    • 尽管代码是最直接的文档,但每个人水平的差异势必造成理解的误差,使用自然语言能尽可能降低理解成本
    • 可以作为代码的参照,校对逻辑,检查遗漏
    • 文字化相关修改拓展规约,避免沦为💩⛰️
    • 达成KPI🤩
  • 拆分UI
    • 业务逻辑和交互逻辑一般来说紧密相连,但实际的代码中,业务逻辑包含交互是再普遍不过的事
    • 降低编写成本,提升编码效率,组件化模块化势在必行
    • 业务逻辑内嵌到组件内会造成UI组件降级为业务组件
    • 无论类组件还是函数组件,复杂的业务逻辑使得组件变得异常沉重
    • 本着单一职责原则,最小化可复用单元
    • 组件变小变轻对性能的提升十分可观
    • 利于炫技😎
  • 引入TS
    • 静态语言
    • 类型系统
    • 严格模式
    • 工作量+++
  • 其它
    • WebSocketAPI本身只提供基础的连接断开消息收发及编码加密
    • 消息的格式规范化外部封装单独方法处理
    • WebSocketAPI后续升级为类,以支持多实例场景
    • 消息存储分层分片以提升读写性能
      type StateType = {
          ...
          message?: Record<string, MessageBody> // 全部聊天消息{ [${friendId}]: MessageBody }
      }
      
      type MessageBody = {
          unReadCount: [number, number] // [自己发送的消息未读的数量, 朋友发送的消息未读的数量]
          sortList: Array<number> // 消息ID递增顺序list
          bodyMap: Record<number, MessageItemType> // 消息map { [${id}]: MessageItemType }
          paged: MessagePageType
      }
      
    • 使用React的memo、useMemo、useCallback等手段降低渲染次数
    • 对好友列表、消息列表使用rc-virtual-list提升性能
    • 对长流程交互的按钮等元素增加debounce、throttle处理
    • 尽可能封装组件为纯函数组件,尽可能缓存组件避免重复渲染,避免重复的props触发组件更新
    • 业务逻辑适当抽象提取到父组件处理,协助子组件减负
    • 结合策略模式等手段改造多层IF嵌套
    • 避免层级过深,尽可能打平

一些思考


对于任何系统来说,迭代时间一旦变久,人员更迭次数增加,团队核心变更,势必导致整个系统的熵增;不管初始版本的设计多么齐备、完善,文档如何齐全,内聚耦合控制的多理想,拓展修改限制的多完美,面对deadline,都不堪一击;面对此情此景,你会如何去做呢,还是来颗🍭一起躺平吧,梦里啥都有!!!

思考题:针对上面场景现状,假设让你来维护这个业务线,你会如何做呢

  • 三个部分
    • 数据结构
    • 业务逻辑
    • UI
  • 两个层面
    • 初始版本设计
    • 后续版本迭代