纲要
- 回顾:社交 RPC 服务已就绪,需暴露 HTTP 接口
- 构建社交 API 服务
- 编写
social.api定义路由与数据结构 - 使用
goctl api go生成代码 ServiceContext注入social-rpc与user-rpc客户端- 配置
etc/socialapi.yaml及jwt认证
- 编写
- 业务接口实现
- 好友申请:验证登录状态,调用社交 RPC
- 好友申请处理:透传处理逻辑
- 好友列表:聚合用户信息与好友关系
- 从社交 RPC 获取好友 ID 列表
- 批量调用用户 RPC 查询用户详情
- 数据映射与组装
- 好友列表聚合流程(Mermaid 图)
- 微服务设计反思
- 为什么不共享数据库:解耦、灵活性、安全性
- 为什么不在 RPC 服务间相互调用:避免循环依赖、复杂度、故障隔离
- BFF 层的价值:聚合、隔离、可观测性
- 测试验证与总结
背景
前面我们已经完成了社交 RPC 服务,实现了好友申请、处理与列表查询的 gRPC 接口。为了让前端能够调用这些功能,还需要构建一层 HTTP API 服务(social-api)。同时,好友列表不仅需要关系数据,还需要用户详细信息,这涉及多服务数据聚合,是微服务中典型的 BFF(Backend For Frontend)场景。
本文将基于 go-zero@latest 完成社交 API 服务的搭建,并深入探讨服务间数据聚合的设计哲学。
项目结构
dir
apps/social/
├── api/
│ ├── internal/
│ │ ├── config/
│ │ ├── handler/
│ │ ├── logic/
│ │ ├── svc/
│ │ └── types/
│ ├── social.api
│ └── socialapi.go
└── rpc/
├── internal/
├── model/
└── social.proto
构建社交 API 服务
定义 API 描述文件
创建 apps/social/api/social.api,定义好友相关接口:
go
type (
FriendApplyReq {
FriendId string `json:"friend_id"`
Reason string `json:"reason,optional"`
}
FriendApplyResp {}
FriendApplyHandleReq {
ApplyId int64 `json:"apply_id"`
HandleType int32 `json:"handle_type"` // 1:通过 2:拒绝
}
FriendApplyHandleResp {}
FriendListResp {
List []FriendInfo `json:"list"`
}
FriendInfo {
UserId string `json:"user_id"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
Desc string `json:"desc"`
}
)
@server(
prefix: /v1/social
jwt: Auth
)
service social-api {
@handler FriendApplyHandler
post /friend/apply (FriendApplyReq) returns (FriendApplyResp)
@handler FriendApplyHandleHandler
put /friend/apply/handle (FriendApplyHandleReq) returns (FriendApplyHandleResp)
@handler FriendListHandler
get /friend/list returns (FriendListResp)
}
注意:jwt: Auth 指定该服务需要 JWT 验证,相关配置会在生成代码后自动添加中间件。
执行生成命令:
bash
$ goctl api go -api apps/social/api/social.api -dir apps/social/api -style goZero
配置多 RPC 客户端
编辑 apps/social/api/internal/config/config.go,增加两个 RPC 配置:
go
package config
import (
"github.com/zeromicro/go-zero/rest"
"github.com/zeromicro/go-zero/zrpc"
)
type Config struct {
rest.RestConf
SocialRpc zrpc.RpcClientConf
UserRpc zrpc.RpcClientConf
JWT struct {
AccessSecret string
AccessExpire int64
}
}
etc/socialapi.yaml 示例(开发环境):
yaml
Name: social-api
Host: 0.0.0.0
Port: 8881
SocialRpc:
Etcd:
Hosts:
- 127.0.0.1:2379
Key: social.rpc
UserRpc:
Etcd:
Hosts:
- 127.0.0.1:2379
Key: user.rpc
JWT:
AccessSecret: "your-secret-key"
AccessExpire: 86400
svc/servicecontext.go 创建双客户端代理:
go
package svc
import (
"im-system/apps/social/api/internal/config"
"im-system/apps/social/rpc/social"
"im-system/apps/user/rpc/user"
"github.com/zeromicro/go-zero/zrpc"
)
type ServiceContext struct {
Config config.Config
SocialRpc social.SocialClient
UserRpc user.UserClient
}
func NewServiceContext(c config.Config) *ServiceContext {
return &ServiceContext{
Config: c,
SocialRpc: social.NewSocialClient(zrpc.MustNewClient(c.SocialRpc).Conn()),
UserRpc: user.NewUserClient(zrpc.MustNewClient(c.UserRpc).Conn()),
}
}
业务逻辑实现
好友申请
从 JWT 解析出的 context 中提取当前用户 uid,然后调用社交 RPC。
go
// internal/logic/friendapplylogic.go
func (l *FriendApplyLogic) FriendApply(req *types.FriendApplyReq) (*types.FriendApplyResp, error) {
uid := svc.GetUidFromCtx(l.ctx) // 工具函数从 context 提取 uid
_, err := l.svcCtx.SocialRpc.FriendApply(l.ctx, &social.FriendApplyRequest{
UserId: uid,
FriendId: req.FriendId,
Reason: req.Reason,
})
if err != nil {
return nil, err
}
return &types.FriendApplyResp{}, nil
}
好友申请处理
同样透传,仅需传递 apply_id 和 handle_type。
go
func (l *FriendApplyHandleLogic) FriendApplyHandle(req *types.FriendApplyHandleReq) (*types.FriendApplyHandleResp, error) {
_, err := l.svcCtx.SocialRpc.FriendApplyHandle(l.ctx, &social.FriendApplyHandleRequest{
ApplyId: req.ApplyId,
HandleType: req.HandleType,
})
if err != nil {
return nil, err
}
return &types.FriendApplyHandleResp{}, nil
}
好友列表(核心聚合)
好友列表需要返回好友的昵称、头像等信息,这些数据存在于用户服务。因此需要:
- 调用社交 RPC 获取好友 ID 列表。
- 调用用户 RPC 批量查询用户信息。
- 组装结果。
go
func (l *FriendListLogic) FriendList() (*types.FriendListResp, error) {
uid := svc.GetUidFromCtx(l.ctx)
// 1. 获取好友 ID 列表
friendsResp, err := l.svcCtx.SocialRpc.FriendList(l.ctx, &social.FriendListRequest{
UserId: uid,
})
if err != nil {
return nil, err
}
if len(friendsResp.FriendIds) == 0 {
return &types.FriendListResp{List: []types.FriendInfo{}}, nil
}
// 2. 批量获取用户信息
usersResp, err := l.svcCtx.UserRpc.FindUser(l.ctx, &user.FindUserRequest{
Ids: friendsResp.FriendIds,
})
if err != nil {
return nil, err
}
// 3. 构建映射:uid → 用户信息
userMap := make(map[string]*user.GetUserInfoResponse, len(usersResp.Users))
for _, u := range usersResp.Users {
userMap[u.Uid] = u
}
// 4. 组装好友列表
list := make([]types.FriendInfo, 0, len(friendsResp.FriendIds))
for _, fid := range friendsResp.FriendIds {
friendInfo := types.FriendInfo{
UserId: fid,
}
if u, ok := userMap[fid]; ok {
friendInfo.Nickname = u.Username
friendInfo.Avatar = u.Avatar
friendInfo.Desc = "" // 可扩展
}
list = append(list, friendInfo)
}
return &types.FriendListResp{List: list}, nil
}
注意:用户服务中的 FindUser 支持按 ID 集合查询,正好用于批量获取。这要求我们在用户 RPC 中实现了 FindUser 接口(之前已实现)。
聚合流程图示
数据库 user-rpc social-rpc social-api 客户端 数据库 user-rpc social-rpc social-api 客户端 #mermaid-svg-CYKaaRqvFtDJ34hS{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-CYKaaRqvFtDJ34hS .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-CYKaaRqvFtDJ34hS .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-CYKaaRqvFtDJ34hS .error-icon{fill:#552222;}#mermaid-svg-CYKaaRqvFtDJ34hS .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-CYKaaRqvFtDJ34hS .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-CYKaaRqvFtDJ34hS .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-CYKaaRqvFtDJ34hS .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-CYKaaRqvFtDJ34hS .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-CYKaaRqvFtDJ34hS .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-CYKaaRqvFtDJ34hS .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-CYKaaRqvFtDJ34hS .marker{fill:#333333;stroke:#333333;}#mermaid-svg-CYKaaRqvFtDJ34hS .marker.cross{stroke:#333333;}#mermaid-svg-CYKaaRqvFtDJ34hS svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-CYKaaRqvFtDJ34hS p{margin:0;}#mermaid-svg-CYKaaRqvFtDJ34hS .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-CYKaaRqvFtDJ34hS text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-CYKaaRqvFtDJ34hS .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-CYKaaRqvFtDJ34hS .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-CYKaaRqvFtDJ34hS .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-CYKaaRqvFtDJ34hS .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-CYKaaRqvFtDJ34hS #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-CYKaaRqvFtDJ34hS .sequenceNumber{fill:white;}#mermaid-svg-CYKaaRqvFtDJ34hS #sequencenumber{fill:#333;}#mermaid-svg-CYKaaRqvFtDJ34hS #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-CYKaaRqvFtDJ34hS .messageText{fill:#333;stroke:none;}#mermaid-svg-CYKaaRqvFtDJ34hS .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-CYKaaRqvFtDJ34hS .labelText,#mermaid-svg-CYKaaRqvFtDJ34hS .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-CYKaaRqvFtDJ34hS .loopText,#mermaid-svg-CYKaaRqvFtDJ34hS .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-CYKaaRqvFtDJ34hS .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-CYKaaRqvFtDJ34hS .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-CYKaaRqvFtDJ34hS .noteText,#mermaid-svg-CYKaaRqvFtDJ34hS .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-CYKaaRqvFtDJ34hS .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-CYKaaRqvFtDJ34hS .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-CYKaaRqvFtDJ34hS .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-CYKaaRqvFtDJ34hS .actorPopupMenu{position:absolute;}#mermaid-svg-CYKaaRqvFtDJ34hS .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-CYKaaRqvFtDJ34hS .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-CYKaaRqvFtDJ34hS .actor-man circle,#mermaid-svg-CYKaaRqvFtDJ34hS line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-CYKaaRqvFtDJ34hS :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} GET /v1/social/friend/list (JWT)解析 JWT 获取 uidFriendList(uid)查询好友关系表friend_idsfriend_idsFindUser(ids)批量查询用户表用户列表用户详情列表映射组装 FriendInfo{code:0, data:{list:...}}
微服务设计反思
在开发过程中,有两个设计决策值得深入讨论:
为什么不直接在 social-rpc 中查询用户表?
从微服务原则分析:
- 解耦:每个微服务应维护自己独立的数据存储,避免数据库成为耦合点。共享数据库会让服务间产生隐式依赖,一旦表结构变更,影响面广。
- 灵活性:如果用户表被多个服务直接访问,用户服务的扩展、分库分表都会受限于其他服务的操作模式。
- 安全性:数据库权限集中,任何服务的漏洞都可能泄露整体用户数据;独立数据库可以实施更精细的权限隔离。
- 团队自治:不同团队可以独立管理自己的数据库,不必协调表结构变更。
因此,社交服务不直接操作用户表,只能通过 RPC 接口获取用户信息。
为什么不在 social-rpc 中直接调用 user-rpc,而要放在 API 层?
如果 social-rpc 调用 user-rpc,会形成服务间的网状依赖,可能引发循环依赖。例如用户服务也可能需要好友关系数据,相互调用会造成死锁或级联故障。
将聚合逻辑上提到 API 层(BFF) 有以下好处:
- 职责清晰:RPC 服务专注于自身领域的原子操作,API 层负责面向前端的业务编排。
- 避免循环依赖:RPC 之间保持无依赖或单向依赖(如社交 RPC 不依赖用户 RPC),系统依赖关系收敛。
- 故障隔离:用户服务不可用时,聚合层可以降级返回好友 ID 列表而非整体失败,RPC 层相互隔离不影响核心逻辑。
- 便于扩展:当需要新增前端时,可以在 API 层灵活组合已有 RPC,无需改动底层服务。
当然,少量、单向的 RPC 间调用并非绝对禁止,但本项目通过 BFF 提前聚合,遵循了更加清晰的设计范式。
测试验证
启动所需服务后,使用 API 工具完成测试:
- 好友申请:登录用户 A,向用户 B 发起申请,检查数据库新增记录。
- 好友申请处理:用户 B 登录,批准申请,确认好友关系表增加两条记录。
- 好友列表:任意已登录用户请求,应返回好友的昵称、头像等信息,而非仅 ID。
通过调试,确认多服务协作正常,响应格式统一。
总结
本文完成了社交 API 服务的构建,重点展示了以下技术实践:
- 使用
goctl api快速生成 API 代码,并配置双 RPC 客户端。 - 利用 BFF 层聚合社交关系与用户信息,避免 RPC 服务间直接调用。
- 结合 Mermaid 时序图直观展示跨服务查询流程。
- 阐述了微服务数据库隔离与服务间调用的设计原则,强调了 BFF 在解耦和稳定性方面的价值。
至此,即时通讯系统的用户与社交两大基础服务已初步形成,后续将继续扩展 IM 服务与 WebSocket 通信部分,构建完整的聊天核心。