前言
本次的项目背景为在现行的聊天服务基础上,开发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的业务逻辑。。。
这是经过初步整理后的消息体业务逻辑。
小程序的就不贴出来了,业务和视图耦合在一起了,保护眼睛👀先
只能说,💩⛰️无疑了。
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规定的类型进行拆分消息卡片组件,使之脱离业务逻辑;
- 结合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 // 消息输入框
- 按照TS规定的类型进行拆分消息卡片组件,使之脱离业务逻辑;
- 整理文档
- 整理输出目录、组件、关键流程、类型声明、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
- 两个层面
- 初始版本设计
- 后续版本迭代