news 2026/10/26 10:18:45

Go IM 源码实战(4):发消息 16 行不落库,11 个开关配错满屏红点

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Go IM 源码实战(4):发消息 16 行不落库,11 个开关配错满屏红点

你好,我是利威尔xu。

这是**「Go IM 源码实战」系列第 4 篇**。前 3 篇讲的是"门"——客户端怎么连进来、HTTP 怎么进来。这一篇开始讲门里发生的第一件事:一条消息被服务端接住。

先给结论,你就知道这篇有多反直觉:发送接口只有 16 行,而且它不落库。

func(m*msgServer)SendMsg(ctx context.Context,req*pbmsg.SendMsgReq)(*pbmsg.SendMsgResp,error){ifreq.MsgData==nil{returnnil,errs.ErrArgs.WrapMsg("msgData is nil")}iferr:=authverify.CheckAccess(ctx,req.MsgData.SendID);err!=nil{returnnil,err}before:=new(*sdkws.MsgData)resp,err:=m.sendMsg(ctx,req,before)iferr!=nil{returnnil,err}if*before!=nil&&proto.Equal(*before,req.MsgData)==false{resp.Modify=req.MsgData}returnresp,nil}

源码位置(全系列同一份):

  • GitHub:https://github.com/CYQ111999/IM-Server
  • 基线:OpenIM Server v3.8 瘦身版,main @07f9ff6
  • 本篇涉及:internal/rpc/msg/send.go、internal/rpc/msg/verify.go、pkg/msgprocessor/options.go

前 3 篇按顺序读:第 1 篇 全局地图、第 2 篇 WS 网关、第 3 篇 HTTP API 层。系列导航在《新粉必看:技术博客学习路线与使用指南》。

文章目录

    • 一、先破除错觉:发消息不是"一次请求写完就完事"
    • 二、三个 ID:一个消息身上为什么挂了三个名字
    • 三、16 行里的三个细节
      • ① `CheckAccess`:API 层验过 token 了,这里为什么还验
      • ② `before` 为什么是 `**sdkws.MsgData`
      • ③ 没有 `defer recover`
    • 四、分流:三种会话,为什么通知完全不校验
      • 群聊为什么比单聊多两件事
    • 五、`encapsulateMsgData`:补两个字段,改写一堆开关
    • 六、11 个开关:一张速查表,和一个反直觉的默认值
    • 七、六道闸门:一条单聊消息要过几关
    • 八、不落库:三个代价,和一个必须看的指标
    • 九、两个真 bug,我读代码时挖到的
      • bug 一:`NewMsgOptions` 构造完就丢
      • bug 二:SendTime 的语义自相矛盾
    • 十、踩坑记录:三个坑,一共 12 小时
    • 十一、面试会怎么问
    • 十二、小结:三个数字和一张表

一、先破除错觉:发消息不是"一次请求写完就完事"

很多人脑子里的发消息是:客户端 POST 一下 → 服务端 INSERT → 返回 OK。这个模型在三个地方必然崩。

崩溃点你的直觉做法真实后果这个项目的解法
500 人群给 500 个人各写一份一次发送 500 次写,接口延迟直接从 5ms 到 500ms消息写一份 + 每人一份"状态",异步扇出
10 万 QPS同步写 Mongo 再返回Mongo 扛不住,接口跟着躺投 Kafka 即返回,落库交给消费端
网络断了客户端不知道成功没有要么重发(重复)要么不重发(丢)ClientMsgID 幂等 + 服务端可信 ID

这三个点合起来,就是这个项目最基本的一条设计原则:发送接口只负责"接住",不负责"办完"。

有人说那不就是"异步"两个字吗?不是。异步只解决了"快",没解决"对"——快而不对,等于把事故推迟到明天。所以后面每一节,你都要盯住同一个问题:异步之后,正确性靠什么兜住?

面试追问:为什么不直接用消息队列当作"发送成功"的判据?
因为队列只保证"我收下了",不保证"我处理对了"。中间还有分配 seq、写 Redis、写 Mongo、推送四步。所以真正要对账的不是"发送成功数",而是"发送成功数"和"落库成功数"的差值——第八节会给这两个指标的名字。

二、三个 ID:一个消息身上为什么挂了三个名字

ID谁生成唯一性范围用来干什么
ClientMsgID客户端客户端保证(uuid)幂等去重:重发时服务端认得出是同一条
ServerMsgID服务端全局近似唯一对外标识一条消息,日志、撤回、引用都用它
Seq服务端(Redis Lua)会话内严格递增排序、未读数、增量同步的锚点

三个 ID 各管一件事,缺一个都不行:

  • 只有 ClientMsgID:客户端能去重,但服务端排序没法做(uuid 无序)。
  • 只有 ServerMsgID:能标识,但不可排序(它也是 md5)。
  • 只有 Seq:能排序,但发送成功的响应里根本没有 seq——因为 seq 是消费端分配的,发送时还没生成。

最后这条是新手最容易卡住的:你以为发送成功就拿到了 seq,其实没有。那客户端怎么知道自己发到哪了?靠"本地插入 + 推送对齐":本地先插一条 status=sending 的消息,收到服务端推送回来的同 ClientMsgID 消息后替换掉。这也是为什么断网重连后必须拉一次增量。

ServerMsgID的生成代码就在verify.go:157:

funcGetMsgID(sendIDstring)string{t:=timeutil.GetCurrentTimeFormatted()returnencrypt.Md5(t+"-"+sendID+"-"+strconv.Itoa(rand.Int()))}

MD5(时间 + sendID + 随机数)。为什么不用 UUID?因为 md5 是 32 位十六进制,比 UUID 短、索引更省空间。为什么不追求绝对唯一?因为输入带随机数,碰撞概率可以忽略,而消息 ID 不需要"绝对不重复",只需要"实务上不会撞"。

⚠️ 但这里有个可提的改进点:rand.Int()用的是全局math/rand。高并发下如果随机源不够好,理论上存在撞击窗口。更严谨的做法是crypto/rand或 snowflake。

三、16 行里的三个细节

回到开头那段SendMsg,逐行看。

①CheckAccess:API 层验过 token 了,这里为什么还验

因为RPC 层不只被 API 调用。它还被:

  • 其他 RPC 服务调用(内部互调);
  • 消息中转服务调用(系统给自己发通知);
  • 管理后台调用;
  • 以及"如果端口暴露了"的直连。

API 层的 token 只保护 HTTP 入口。RPC 层必须自己再守一次,这叫纵深防御。

第 3 篇讲过这个项目的分工:认证在 API 层,授权在 RPC 层。CheckAccess就是授权的第一道实现——它从 gRPC metadata 里取出"当前调用者",和req.MsgData.SendID比对。和第 2 篇网关里"消息里的 sendID 必须等于连接的真实 userID"是同一个思路。

②before为什么是**sdkws.MsgData

一个双指针,看着很怪。它的用途是"让下游函数可以选择性地录像":

调用方传一个"空指针的地址"进去;下游如果调了 webhook(业务方可能改写消息内容),就在调之前把原始消息存进去;返回后,用proto.Equal(*before, req.MsgData)比较,不相等说明被改了,就把新值塞进resp.Modify告诉客户端"你发的内容被改了"。

为什么不用返回值?因为sendMsg已经有三个分支(单聊/群聊/通知),其中通知分支根本不调 webhook。加一个返回值,三个分支都要跟着改。用出参,让需要的分支自己写——这是 Go 里常见的取舍:牺牲一点可读性,换调用点干净。

③ 没有defer recover

SendMsg没有自己兜 panic,它依赖 gRPC 拦截器链里的PanicCapture。

这意味着:如果你写单测直接调SendMsg,panic 会一路冒到测试进程。这是个隐含约定,不知道的话排查起来很奇怪。这也是"框架帮你做了一件事,但你得知道它做了"的典型例子。

四、分流:三种会话,为什么通知完全不校验

func(m*msgServer)sendMsg(ctx context.Context,req*pbmsg.SendMsgReq,before**sdkws.MsgData)(*pbmsg.SendMsgResp,error){m.encapsulateMsgData(req.MsgData)switchreq.MsgData.SessionType{caseconstant.SingleChatType:returnm.sendMsgSingleChat(ctx,req,before)caseconstant.NotificationChatType:returnm.sendMsgNotification(ctx,req,before)caseconstant.ReadGroupChatType:returnm.sendMsgGroupChat(ctx,req,before)default:returnnil,errs.ErrArgs.WrapMsg("unknown sessionType")}}

encapsulateMsgData放在switch之前,因为"补 ServerMsgID、补 SendTime、按内容类型改开关"对所有类型都一样。这是个非常常见的重构手法:把分支里的重复前置动作提到分支之前。

三种类型的差异:

类型六道校验接收偏好webhook特殊逻辑
单聊SingleChatType✅✅ 会按偏好丢弃有无
群聊ReadGroupChatType✅❌有@处理
通知NotificationChatType❌ 完全不校验❌❌无

通知分支短到只有一件事:投 MQ。为什么敢完全不校验?因为通知是服务端自己产生的(“你被踢出群了”“好友申请通过了”),不来自用户输入。服务端自己发的东西,不需要防自己。

这是个很值得在面试里说的判断:校验成本要按"输入的信任等级"分配,不是所有路径都要全套。但注意前提——真的只有内部能调。如果哪天把通知接口暴露成 HTTP,这条豁免立刻变成漏洞。

群聊为什么比单聊多两件事

群聊路径(sendMsgGroupChat)比单聊多出两个动作,都在发完 MQ 之后:

ifreq.MsgData.ContentType==constant.AtText{gom.setConversationAtInfo(ctx,req.MsgData)// ← 异步,不阻塞发送}m.webhookAfterSendGroupMsg(ctx,&m.config.WebhooksConfig.AfterSendGroupMsg,req)

第一个是@信息的落库,而且必须异步。为什么?因为@要在会话上记"这条消息 @ 了哪些人",涉及 Redis 和 Mongo 的额外读写;如果同步做,一条 @ 全体成员的消息会让发送接口多花几十毫秒——而@ 本身不影响消息是否发送成功,它只是"事后的标记",完全没必要占用发送的响应时间。

注意setConversationAtInfo自己带了三样东西:

它带了什么为什么
go关键字不阻塞发送响应
defer recover()异步协程里 panic 会直接带崩进程,必须自己兜
mcontext.NewCtx("@@@" + operationID)保留了链路 ID,日志还能串起来(前面加@@@是为了在日志里一眼认出"这是异步分支")

这三样合起来,是 Go 里写"fire-and-forget"协程的标准姿势:异步 + 兜 panic + 传追踪信息。少任何一样都会在半年后变成事故。

第二个是群聊的 webhook(After 钩子),它只是通知业务方"群消息已经发出去了",不参与决策,所以即使失败也不该阻断发送。

面试追问:go出去的协程,失败了怎么办?
看它是否影响主流程。像@标记这种"可补偿的附属信息",失败可以记日志、由补偿任务兜底;如果是"必须成功"的动作,就不能用裸go,得进队列做重试。判断标准不是"快不快",而是"丢了能不能补"。

五、encapsulateMsgData:补两个字段,改写一堆开关

func(m*msgServer)encapsulateMsgData(msg*sdkws.MsgData){msg.ServerMsgID=GetMsgID(msg.SendID)ifmsg.SendTime==0{msg.SendTime=timeutil.GetCurrentTimestampByMill()}switchmsg.ContentType{caseconstant.Text,constant.Picture,constant.Voice,constant.Video,constant.File,constant.AtText,constant.Merger,constant.Card,constant.Location,constant.Custom,constant.Quote,constant.AdvancedText,constant.MarkdownText:// 普通消息:什么都不做,用默认开关caseconstant.Revoke:// 撤回:不计未读、不离线推送caseconstant.HasReadReceipt:// 已读回执:不更新会话、不计未读、不推送caseconstant.Typing:// 正在输入:只推送,其余全关(7 个)}}

这是全篇最实用的一个知识点。四组开关,四种"消息生命周期":

内容类型关掉的开关数关掉哪些为什么
普通消息(文本/图片/语音…)0—全生命周期:存历史、计未读、推送、更新会话
撤回Revoke2IsUnreadCount、IsOfflinePush撤回不该给对面加红点,更不该半夜把人震醒
已读回执HasReadReceipt4IsConversationUpdate、IsSenderConversationUpdate、IsUnreadCount、IsOfflinePush绝不能变成会话列表的"最后一条"
正在输入Typing7IsHistory、IsPersistent、IsSenderSync、IsConversationUpdate、IsSenderConversationUpdate、IsUnreadCount、IsOfflinePush它是瞬时状态,不是消息

已读回执那两条ConversationUpdate是最容易被忽略的:如果不关,你打开聊天窗口,会话列表那一行会变成"对方已读",而不是最后一条聊天内容——用户会觉得聊天记录被吃掉了。

这就是 IM 里"不可见消息"的概念:它要传、要处理,但不能污染用户的可见状态。这句话背下来,面试问"IM 里最反直觉的设计"时直接能用。

六、11 个开关:一张速查表,和一个反直觉的默认值

Options本质是map[string]bool,一共 11 个 key:

类别开关含义
分类IsNotNotification普通消息还是通知
分类IsSendMsg是否需要克隆一条"给用户看"的消息
存储IsHistory是否进历史(Mongo)
存储IsPersistent是否持久化
通知IsOfflinePush是否离线推送
通知IsUnreadCount是否计未读
同步IsSenderSync是否同步给发送者其他端
会话IsConversationUpdate是否更新会话"最后一条"
会话IsSenderConversationUpdate是否更新发送者自己的会话
其它IsNotPrivate是否非私密(读扩散判断)
其它IsReactionFromCache表情回复是否从缓存取

用 map 做开关,好处是"加行为不用改函数签名",坏处是"key 写错不报错"——编译期检查为零,运行时静默走默认值。

而这个默认值,是它最反直觉的地方:

func(o Options)Is(notificationstring)bool{v,ok:=o[notification]if!ok||v{returntrue// ← key 不存在时,返回 true}returnfalse}

key 不存在 = 开启。再把构造函数放在一起看:

funcNewOptions(opts...OptionsOpt)Options{options:=make(map[string]bool,11)options[constant.IsNotNotification]=falseoptions[constant.IsSendMsg]=falseoptions[constant.IsHistory]=falseoptions[constant.IsPersistent]=falseoptions[constant.IsOfflinePush]=falseoptions[constant.IsUnreadCount]=falseoptions[constant.IsConversationUpdate]=falseoptions[constant.IsSenderSync]=true// ← 唯一一个 trueoptions[constant.IsNotPrivate]=falseoptions[constant.IsSenderConversationUpdate]=falseoptions[constant.IsReactionFromCache]=false...}

两个设计放在一起,效果是:

场景map 状态实际行为
服务端NewOptions()构造11 个 key 全显式写了全 false(IsSenderSync除外)
客户端传来的消息没带 options空 map全部 true(存历史、推离线、计未读)

也就是说:客户端不传开关时,服务端走"全部开启"的保守策略;内部构造时走"全部关闭"的显式策略。好处是老版本客户端不传字段也不会丢功能;代价是新人看到"没赋值"会以为"不生效",实际是"全生效"。

📌 面试里被问"用 map 做配置有什么坑",标准答案就是这个:默认值语义必须写进文档,否则它一定会变成 bug 温床。

七、六道闸门:一条单聊消息要过几关

internal/rpc/msg/verify.go:40的messageVerification,单聊分支有 6 个判断,顺序本身就是设计:

#闸门不通过会怎样
1管理员豁免直接放行(管理员不受限)
2通知类消息豁免(ContentType在通知区间)直接放行
3webhook 前置钩子业务方可以拒绝(敏感词命中、风控拦截)
4系统账号豁免直接放行
5黑名单ErrBlockedByPeer(被对方拉黑)
6好友关系(FriendVerify开关控制)ErrNotPeersFriend(不是好友)

为什么豁免要放在黑名单之前?因为管理员和系统账号如果被"好友关系"挡住,群管理、系统通知就全废了——权限高的主体必须先豁免,再谈业务规则。

为什么黑名单在好友关系之前?因为"被拉黑"的提示更准确:如果先判好友关系,被拉黑的人会收到"你们不是好友",而不是"对方拒收",两者对产品的含义完全不同。

面试追问:这些校验为什么必须在"发送时"做,不能在"接收时"过滤?
因为消息是"写一份、多人读"的。接收时过滤要么每个读的人都算一遍(成本 ×N),要么过滤结果不一致(A 看不到、B 看得到)。发送时把关是唯一一处能保证全局一致的位置。代价是规则变更只对新消息生效——改了黑名单,历史消息不会重算,这正好也是产品期望的行为。

八、不落库:三个代价,和一个必须看的指标

发送接口返回成功时,消息躺在 Kafka 里。这安全吗?工程上算安全——Kafka 有多副本 + 落盘,配置了同步 acks 之后,“进了 Kafka"基本等于"不会丢”。

但三个代价必须说清:

代价具体表现怎么兜
最终一致性发完立刻拉历史,可能拉不到(还没落 Mongo)客户端"本地插入 + 推送对齐",不能依赖发完立刻查
拿不到 seq响应里只有 ServerMsgID 和 ClientMsgID靠推送回来的消息补 seq
发送与落库解耦发送成功 ≠ 落库成功监控两个指标,看差值

第三个代价值得展开。项目里有两个计数器:

  • SingleChatMsgProcessSuccessCounter:发送接口侧成功数;
  • MsgInsertMongoSuccessCounter:落库侧成功数。

这两个数的差值,就是"黑洞消息"的数量——用户看到"发送成功",但消息永远不会出现在历史里。

📌 这句话在面试里很值钱:“异步链路最该监控的不是成功率,是两段成功率的差。”因为成功率各自看起来都很健康,差值才是用户真实感受到的丢失。

另一个常见误区是"那我至少同步写 Redis 吧"。不行——Redis 不是可靠存储,它会因内存策略淘汰 key,也可能主从切换丢最后一段。这个项目的定位很清楚:Redis 是可重建的加速层,Mongo 才是真相。

九、两个真 bug,我读代码时挖到的

bug 一:NewMsgOptions构造完就丢

funcNewMsgOptions()Options{options:=make(map[string]bool,11)options[constant.IsOfflinePush]=falsereturnmake(map[string]bool)// ← 前面两行白写了}

前两行构造的 map 被丢弃,return又新建了个空 map。后果有两层:

  1. 第 2 行的赋值完全无效(ineffectual assignment,go vet能报);
  2. 返回的空 map 在Is()里被解读为"全部开启"——和作者想要的"关闭离线推送"完全相反。

好在调用方(msgtransfer里克隆通知消息)紧接着用WithOptions显式设置了IsOfflinePush和IsUnreadCount,所以实际影响很小:只有IsHistory、IsPersistent这些没被显式设置的开关会落到 true。

但它的意义不在影响面,而在于暴露了这套设计的根本问题:用 map 存开关,编译器一个错都抓不到,只能靠人眼和 lint。

bug 二:SendTime 的语义自相矛盾

encapsulateMsgData里有这个判断:

ifmsg.SendTime==0{msg.SendTime=timeutil.GetCurrentTimestampByMill()}

说明设计上允许客户端指定发送时间(迁移历史消息的场景)。但到了落库环节(BatchInsertChat2Cache),为了批量效率会强制覆盖 SendTime。

结果:客户端指定的时间会被静默丢弃。两处代码单独看都对,合起来语义冲突。

📌 这是"读单点代码看不出来,必须顺着链路读"才能发现的问题。面试里聊"你怎么读源码",说这个比说"我通读了 400 个文件"可信得多。

十、踩坑记录:三个坑,一共 12 小时

坑一:撤回消息给对面加了未读红点(5 小时)

现象:测试提了一个"撤回一条消息,对方会话列表红点 +1"的 bug。

定位:撤回消息走了普通发送路径,Options没被改写,IsUnreadCount保持默认(客户端不传 options → 空 map → 全部 true)。

修复:确认encapsulateMsgData里case constant.Revoke分支生效——原来是自己的简化分支里把 ContentType 传错了,压根没进这个 case。

教训:开关类逻辑出错,先查"这条消息的 ContentType 到底是什么",再查开关。

坑二:已读回执把会话列表刷成"对方已读"(4 小时)

现象:会话列表最后一条消息显示"对方已读",实际聊天内容不见了。

定位:HasReadReceipt的 4 个开关里,IsConversationUpdate没关(也是 ContentType 不对)。

修复:对齐constant.HasReadReceipt。

教训:"不可见消息"必须有测试用例。这类 bug 只在特定消息类型 + 特定开关组合下出现,靠人眼回归是漏的。

坑三:把通知当普通消息发,结果计了未读(3 小时)

现象:系统通知(入群提示)在会话列表里出现未读数。

定位:调用方用了SingleChatType而不是NotificationChatType,于是走了六道校验 + 计未读的路径。

修复:改成通知类型。

教训:SessionType不只是"单聊还是群聊",它决定了整条链路的校验强度和副作用。选错类型不是"少个字段",是"走错整条流水线"。

十一、面试会怎么问

追问怎么答(一句话版)
发送接口为什么不落库?500 人群 × 500 写,同步落库必然超时;投 MQ 即返回,落库交给消费端
那"发送成功"丢消息怎么办?Kafka 多副本 + 落盘保不丢;监控"发送成功数 - 落库成功数"的差值
三个 ID 各干什么?Client 幂等、Server 标识、Seq 排序与同步锚点
为什么响应里没有 seq?seq 是消费端分配的,发送时还不存在
撤回为什么要关两个开关?不计未读(不加红点)、不离线推送(不半夜震人)
已读回执为什么要关 4 个?尤其不能更新会话"最后一条",否则列表显示"对方已读"
Options 用 map 的最大风险?key 写错编译不报错;且"key 不存在 = true"的默认极易踩
六道校验为什么这个顺序?高权限主体先豁免,再判黑名单(提示更准),最后判好友关系
你在里面发现过 bug 吗?NewMsgOptions构造后被丢弃、SendTime两处语义冲突
@信息为什么要go出去?它不影响发送成败,只是事后标记;异步 + recover + 传 operationID
为什么不在发送时就分配 seq?seq 必须在"同一会话串行"的消费端统一分配,发送侧并发无法保证连续

十二、小结:三个数字和一张表

  • 16 行:发送入口的长度,其中两件事最关键——CheckAccess(纵深防御)和before(webhook 改写回执)。
  • 11 个开关:一条消息的全部行为由它们决定,"key 不存在 = true"是最大的坑。
  • 6 道闸门:顺序就是设计,豁免在前、业务在后。

这套设计最值得抄的不是代码,是它的分工方式:发送侧只做"能立刻判断的事"(身份、关系、黑名单、字段补齐),凡是"需要串行或需要重试的事"(分 seq、落库、推送)全部推到消费侧。判断标准非常清晰:能不能在一次同步调用里给出一致答案。能,就同步;不能,就异步 + 补偿。

下面这张表建议收藏,下次你自己写消息系统(或者被问"IM 怎么保证不丢不重"),照着过一遍:

检查项该问什么本项目的答案
幂等客户端重发怎么去重?ClientMsgID,服务端只认一次
排序用什么做顺序锚点?会话级 Seq,服务端分配
扇出群消息写几份?一份消息 + 每人一份状态,异步扇出
校验时机发送时还是接收时?发送时,唯一能保证全局一致的位置
消息分类靠什么区分通知/普通?SessionType + ContentType
行为控制硬编码还是开关?11 个 Options 开关,map 存储
持久化同步还是异步?异步(Kafka → Mongo)
监控看哪个数?发送成功数与落库成功数的差值
不可见消息怎么不污染会话?关掉 ConversationUpdate 系列开关

留个问题,评论区一句话能答:已读回执如果只关IsUnreadCount、忘了关IsConversationUpdate,用户会看到什么现象?

如果这篇帮你把"发送链路"捋顺了,点个赞、收个藏——下一篇是整条链路里最难的一块:seq 分配器,518 行代码、两段式 Lua、号段预分配,一个"自增 1"背后全是坑。也欢迎评论区聊聊你们消息系统踩过的坑。

下一篇:《Go IM 源码实战(5):一个自增 seq,为什么用两个 Lua 脚本才敢发号》——从 Snowflake 为什么不行,讲到号段、空洞和客户端容忍。

作者:利威尔xu | CSDN 专栏《Go IM 源码实战》

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/26 10:18:32

js图片上传详解

点选择框选图片 → 校验(是不是图片、大小)→ 本地预览(不用发给服务器,浏览器自己看)→ 把图片文件打包成 FormData后端(接口干活):接收 FormData 里面的图片 → 存到服务器文件夹 …

作者头像 李华
网站建设 2026/10/26 10:17:44

从Java到AI产品经理:大模型应用初体验(收藏版)

本文分享了作者从Java开发转型为AI产品经理的初体验。文章指出,虽然大模型应用领域没有外界吹的那么“高大上”,但确实比传统开发更有趣。作者认为,转型并非从零开始,Java经验反而处处接得上手。文章重点探讨了AI产品经理需要关注…

作者头像 李华
网站建设 2026/10/26 10:17:42

前端小白必看:收藏!AI大模型时代如何升级进阶成为高薪程序员

文章指出AI工具已在前端开发中普及,84%开发者使用AI提升效率。市场对前端要求升级,薪资分化明显。建议前端开发者通过深入AI工程化、走向全栈架构或深耕体验性能三个方向转型升级,掌握AI协作、后端能力和用户体验设计,成为不可替代…

作者头像 李华
网站建设 2026/10/26 10:17:37

【C++】搜索二叉树

目录 1. 二叉搜索树的概念 2. 时间复杂度分析 3. 插入,查找,删除的代码实现 删除的代码实现: 4. 二叉搜索树的使用场景 1. key搜索场景 2. key / value搜索场景 5. Insert 和 Find 的递归写法(了解) 1. 二叉搜…

作者头像 李华