Go Wind UBA 拆解系列 - 架构总览:三服务、数据流与契约优先
本文回答一个问题:当一个用户行为从浏览器发出,到最终在 Vue 看板上变成一条留存曲线,中间经过了哪些服务、哪些代码、哪些取舍?
一、先看全貌:四个角色的接力
GoWind UBA 不是单体,也不是"微服务为了微服务"。它把用户行为分析的链路切成职责清晰的三段服务,外加一个采集 SDK:
SDK 上报 ──HTTP──▶ Collector ──Publish──▶ Kafka ──▶ Core ──▶ OLAP │ Vue 看板 ◀──HTTP/SSE── Admin ◀──gRPC──┘| 服务 | 角色 | 端口 | 一句话职责 |
|---|---|---|---|
| Collector | 采集 BFF | HTTP 5700 | 接收 SDK 上报,鉴权 + 校验 + 补全,转发 Kafka。无状态、不落库 |
| Core | 核心业务 | gRPC 动态端口 | 事件入库、25 个分析模型、风险检测、标签画像。所有重逻辑 |
| Admin | 管理 BFF | HTTP 5600 / SSE 5601 | 给前端用的 HTTP 网关,薄转发到 Core |
| SDK | 客户端 | — | 浏览器(TS)/ Unity·Godot(C#),批量上报 + 重试 |
三个服务都通过etcd注册发现;跨服务调用走gRPC;Core 的 gRPC 端口是0.0.0.0:0(随机端口),启动时注册到 etcd,Admin/Collector 凭 etcd 找到它。
这套划分的好处很实在:Collector 纯 IO,可以无脑水平扩;Admin 是薄转发,改前端需求不动它;Core 才是承载业务复杂度的地方。下面逐个拆。
二、Collector:只干一件事,干到极致
Collector 的代码量很小,因为它恪守"接收 + 转发"的边界。入口就一个:POST /uba/v1/report。
它做的事是一个严格的四步流水线(backend/app/collector/service/internal/service/report_service.go):
1. appId/appSecret 鉴权 → 拿到权威 tenantId 2. validateEvents → 校验 eventId/eventName/eventTime + oneof 载荷 3. 权威覆盖 tenantId → 杜绝跨租户伪造 4. handleBehavior/Risk → 补字段 → Publish Kafka几个值得注意的设计:
鉴权在请求体,不在 Header。appId/appSecret放在 JSON body 里。这看起来"不标准",但它有一个关键收益:让sendBeacon成为可能——浏览器关闭页面时,navigator.sendBeacon无法设置自定义 Header,只能发 body。把凭证放进 body,SDK 在页面卸载时也能可靠地把残留事件冲刷出去(详见 第 3 篇)。
鉴权器是加固过的。AppAuthenticator(app_auth.go)有几个细节值得抠:
- Redis 里只存
appSecret的SHA-256 哈希,不存明文——Redis 泄露不等于密钥泄露。 - 比较密钥用
subtle.ConstantTimeCompare——防时序攻击。 - 负缓存:不存在的 appId 也写进 Redis(短 TTL 1 分钟),防缓存穿透打穿到 DB。
- gRPC 查应用失败时返回
InternalServerError而不是Unauthorized——网络抖动不该被误报成"密码错误"。
tenantId 权威覆盖是信任边界。两行代码:
// 用应用所属的权威 tenant_id 覆盖每个事件,杜绝客户端伪造跨租户上报。for_,event:=rangevalidEvents{event.TenantId=app.TenantID}客户端上报的tenantId一律作废,服务端按appId反查出"这个应用属于哪个租户"并强制覆盖。这是整个平台防跨租户越权的第一道闸(OLAP 层还有第二道,见 第 4 篇)。
响应永远不撒谎。即便 HTTP 200,failedCount也可能 > 0——批量上报里部分事件校验失败时,会把失败明细按事件类型分组放进errorsByType返回。SDK 拿到后记 warn,而不是误以为全成功。
三、Core:复杂度的集中地
Core 是承载所有"重"逻辑的服务。它的对外协议是gRPC(不直接对前端),数据源有两条线:
业务实体 ──ent ORM──▶ PostgreSQL (应用、用户、角色、权限、事件 Schema……) 分析聚合 ──原生 SQL──▶ OLAP (events_fact / sessions_fact / risk_events)为什么分析数据要走原生 SQL 而不是 ORM?因为漏斗、留存、LTV 这些模型本质是GROUP BY + 时间分桶 + 窗口函数,是 OLAP 引擎的主场。用 ent 这类行式 ORM 去拼这些聚合,既写不出也跑不动。所以项目做了一个清晰的分层:
- 写业务 CRUD→ ent +
go-crud泛型 Repository,自动处理分页 / 过滤 / FieldMask; - 写分析聚合→ 原生 SQL,Doris 用
db.SelectContext,ClickHouse 用db.Select,SQL 函数按方言切换。
Core 最有意思的部分是双引擎:同一份业务模型,ClickHouse 和 Doris 各实现一份 repo,运行时二选一。这是整个平台最硬核的设计之一,我会用整个 第 2 篇 来讲。
⚠️一个诚实的现状:截至当前版本,
uba_events_raw/uba_risk_events的Kafka 消费入库逻辑在 Core 内尚未实现。Collector 已经正确 Publish,Core 也提供了BehaviorEventService.BatchCreate入库入口,但连接两者的 subscriber 缺失。这意味着上报数据目前会停留在 Kafka,不会自动落库。生产化前需要补一个消费者(在 Core 内订阅 topic 调BatchCreate,或引入独立 worker)。详见 架构文档 的诚实披露。这种"能力具备但管线未接通"的状态,在真实项目里很常见,文档主动写出来比藏着好。
四、Admin:薄转发 + SSE
Admin 是给前端用的 HTTP 网关。它的设计哲学是纯转发,不含业务逻辑:
// admin/service/internal/service/xxx_service.gofunc(s*XxxService)List(ctx context.Context,req*adminV1.ListXxxRequest)(*adminV1.ListXxxResponse,error){returns.client.List(ctx,req)// 直接转发到 Core 的 gRPC client}每个 admin 方法体基本就是return s.client.Method(ctx, req)。业务逻辑全在 Core。这样改前端需求时,Admin 层几乎不动;权限 / 菜单 / 聚合都收敛到一处。
Admin 有两个特色值得展开:
1. SSE 实时推送(站内消息)
Admin 单独开了一个 SSE 端口(5601)。配置很简洁(server.yaml):
server:sse:addr:":5601"path:"/events"auto_stream:true# 关键:按 streamID 自动创建流,无需预注册auto_stream: true是点睛之笔——调用方不用预先 createStream,URL 上带?stream=<id>就能自动物化一个流。
推送逻辑(internal_message_service.go)的设计很巧妙:
// 用收件人的 access token 作为 streamIdrecipientStreamIds,_:=s.authenticationServiceClient.GetAccessTokens(ctx,&authenticationV1.GetAccessTokensRequest{UserId:recipientUserId,ClientType:authenticationV1.ClientType_admin,})for_,streamId:=rangerecipientStreamIds.AccessTokens{s.sseServer.Publish(ctx,sse.StreamID(streamId),&sse.Event{Event:[]byte("notification"),Data:recipientJson,ID:[]byte(id.NewGUIDv7(false)),// 有序 ID,客户端可去重/排序})}Stream ID = 用户的 admin access token。一个用户在多个设备登录就有多个 token,于是天然实现"按设备 fan-out"。前端登录后用?stream=<accessToken>连上,后端 Publish 到同一个 streamId,闭环就接上了:
// frontend/.../stores/authentication.state.tsconsttargetSseUrl=`${import.meta.env.VITE_GLOB_SSE_URL}?stream=${accessToken}`;globalSSEClient.connect(targetSseUrl);// layouts/basic.vueglobalSSEClient.on<InternalMessageRecipient>('notification',handleSseNotification);事件名notification是前后端硬契约。注意 SSE 这里只用于站内消息通知,不是通用事件总线——这是克制的设计。
2. 服务发现 + 动态端口
Core 的 gRPC 监听0.0.0.0:0(随机端口),启动时把"我的地址"写进 etcd;Admin/Collector 通过 etcd 发现 Core 并建 gRPC 连接。这让 Core 可以多实例部署 + 滚动更新,是微服务的标准操作。
五、契约优先:一条代码生成管线
这套平台最省心的地方,是先写.proto/ ent schema,再生成多端代码。理解这条管线,是二次开发的前提。
5.1 Makefile 分层
生成命令分两层:顶层backend/Makefile负责编排,每个服务目录下的 Makefileinclude backend/app.mk提供具体动作。SRCS_MK := $(wildcard app/*/*/Makefile)自动发现所有服务,所以make ent/make wire会递归下钻到每个服务。
核心命令(在backend/下):
| 命令 | 产物 |
|---|---|
make api | proto → Go(messages + gRPC stub + Kratos HTTP + validate)+ struct tag |
make ts | proto → 前端 TS 客户端 |
make openapi | proto → Swagger / OpenAPI |
make ent | ent schema → ORM 实体代码 |
make wire | 重新生成依赖注入(wire_gen.go) |
make gen | =ent + wire + api + openapi(不含 ts) |
5.2 buf Managed Mode:自动注入 go_package
buf.gen.yaml开了 managed mode,自动给项目 proto 注入go_package:
managed:enabled:truedisable:# 这些外部模块的 go_package 不许 buf 重写-module:buf.build/googleapis/googleapis-module:buf.build/envoyproxy/protoc-gen-validate-module:buf.build/kratos/apis# ...override:-file_option:go_package_prefixvalue:go-wind-uba/api/gen/go-file_option:go_packagepath:admin/service/v1value:go-wind-uba/api/gen/go/admin/service/v1;adminpb# 显式包名注意disable列表——vendored / registry 模块(googleapis、PGV、kratos apis)的go_package不能被 buf 覆盖,否则会破坏它们的 canonical 路径。
5.3 关键细节:TS 只对 admin/service/v1 生成
这是整个 BFF 模式的核心,但容易被忽略。看buf.admin.typescript.gen.yaml:
inputs:-directory:protospaths:-protos/admin/service/v1# ← 只有这个子树是输入只有admin/service/v1会被生成 TS 客户端。后端内部的 gRPC 服务契约(uba/service/v1、internal_message/service/v1、authentication/service/v1等)故意被排除。
为什么?因为前端只跟 Admin BFF 对话,而 Admin BFF 已经把这些后端服务的数据重新暴露成它自己的 HTTP 接口。如果把后端内部 proto 也生成 TS,会把内部契约泄漏进浏览器 bundle,破坏 BFF 边界。这是一个很小但很关键的决策。
5.4 ⚠️ ent 生成的坑
如果你直接跑ent generate ./schema,生成的代码会缺方法,编译报错。正确命令在app.mk里:
ent: @ent generate \ --feature privacy \ --feature entql \ --feature sql/modifier \ --feature sql/upsert \ --feature sql/lock \ ./internal/data/ent/schema这五个 feature 是非默认的扩展:privacy(隐私策略拦截)、entql(类型化谓词 DSL)、sql/modifier(Modify()原生 SQL)、sql/upsert(ON CONFLICT)、sql/lock(SELECT FOR UPDATE)。裸ent generate生成的 builder 不带这些方法,任何调用.Privacy()/.QueryModifier()/.OnConflict()的代码都会编译失败。
记住:要么make ent,要么带全 feature。这是二次开发第一个会踩的坑。
💡 顺带一个发现:
make ts引用了buf.collector.typescript.gen.yaml,但这个文件在backend/api/下不存在——只有buf.collector.openapi.gen.yaml。所以make ts第二个 buf 调用会失败。二次开发时注意补上或去掉这一行。
六、前端:契约驱动 + vue-query + ECharts 按需
前端(Vue 3 + Vben Admin)的架构也深受"契约优先"影响。生成的 TS 客户端落在src/api/generated/admin/service/v1/,外面再包一层composable。
三种 composable 范式
api/composables/下 40 个文件,每个都导出三种风格的函数(以role.ts为例):
// 1. 响应式读(vue-query useQuery)—— 在 setup() 里用exportfunctionuseListRoles(query,options?){returnuseQuery({queryKey:['listRoles',query],queryFn:()=>apiClient.roleService.List(query.toRawParams()),...options,});}// 2. 命令式读(queryClient.fetchQuery)—— 在路由守卫/store/watch 里用exportasyncfunctionfetchListRoles(params){returnqueryClient.fetchQuery({queryKey:['listRoles',params],queryFn:()=>apiClient.roleService.List(params.toRawParams()),staleTime:0,retry:0,});}// 3. mutation(useMutation)—— 增删改exportfunctionuseUpdateRole(options?){returnuseMutation({mutationFn:({id,values})=>apiClient.roleService.Update({id,data:{...values}asany,updateMask:makeUpdateMask(Object.keys(values??{})),// FieldMask 部分更新}),...options,});}关键点:前两种共享同一个queryKey和同一个queryClient单例,所以响应式 hook 和命令式函数读写的是同一个缓存。updateMask用Object.keys(values)生成 FieldMask,所以是部分更新而非整对象 PUT——很 proto 风格。
分析类 composable(analytics.ts,最大)用staleTime: 60_000(1 分钟缓存),而 CRUD 模块用staleTime: 0——因为分析聚合重,能接受 1 分钟内的轻微陈旧数据。
ECharts 按需注册
Vben 默认只注册了BarChart / LineChart / PieChart / RadarChart。项目为 BI 看板额外注册了Funnel、Heatmap、VisualMap、MarkLine、MarkPoint(echarts.ts):
echarts.use([TitleComponent,PieChart,RadarChart,TooltipComponent,GridComponent,DatasetComponent,TransformComponent,BarChart,LineChart,FunnelChart,HeatmapChart,ScatterChart,VisualMapComponent,MarkLineComponent,MarkPointComponent,LabelLayout,UniversalTransition,CanvasRenderer,LegendComponent,ToolboxComponent,]);FunnelChart→ 漏斗分析HeatmapChart + VisualMapComponent→ 留存矩阵(色阶)MarkLine / MarkPoint→ 异常检测的事件趋势标注
💡 一个小瑕疵:
usePathSankey这个 composable 存在,但SankeyChart没有在echarts.use里注册。所以路径桑基图要么走了自定义适配,要么这是个没接完的点——二次开发时留意。
路由自动收录
加页面不用手动注册路由。router/routes/index.ts:
constdynamicRouteFiles=import.meta.glob('./modules/**/*.ts',{eager:true});constdynamicRoutes:RouteRecordRaw[]=mergeRouteModules(dynamicRouteFiles);modules/下任何新.ts文件都会被import.meta.glob自动收录——加一个modules/app/foo.ts导出路由数组,菜单里就有了。
七、小结:这套架构的可借鉴之处
回到最初的问题:这套架构值不值得抄?我觉得有几条是超越了"UBA 平台"本身、可以迁移到任何数据密集型后台的设计原则:
- 职责切片按"状态"和"复杂度"分,而不是按业务领域分。Collector 无状态纯 IO,Core 承载所有状态和复杂度,Admin 薄转发。这样扩展性和可维护性都好。
- 契约优先 + 代码生成,是前后端协同的杠杆。proto 改一处,Go / TS / OpenAPI / struct tag 全部跟着变。代价是管线本身有学习曲线(ent feature、buf managed mode)。
- BFF 模式要落实到生成边界。只对
admin/service/v1生成 TS,是用工具链强制守住"内部契约不外泄"——这比口头约定可靠得多。 - SSE 用 access token 当 streamId,实现按设备 fan-out。一个巧思,省了一个独立的消息分发服务。
- 诚实的文档。Kafka 消费未实现、双引擎是编译期常量——这些写进 README 的取舍,让项目可信。
本文代码均出自 go-wind-uba 仓库。如有疑问,欢迎到仓库 issue 讨论。