一个客服工单暴露的端差异
「回声」的 App 上线半年后,产品决定补一个小程序入口。需求评审只花了二十分钟:列表、详情、私信、下单,四个页面“照 App 做一遍”。服务端沿用了 App 的接口,客户端只换了皮肤。上线第三天,客服收到一类工单:小程序用户的红点消不掉。
排查发现,App 的未读模型依赖一个前提:用户点开消息后,服务端通过推送通道下发一条“已读同步”,其他设备收到后清掉红点。小程序没有系统推送通道,这条同步永远不会到达;而小程序又复用了 App 的“未读数由服务端推送驱动”的假设,于是红点只能等下一次冷启动轮询才消失,短则几分钟,长则一整天。
这个工单不是客户端的 bug,也不是服务端的 bug,而是把“端”当成了展示层,忽略了端在登录态、推送、深链、包体和网络上的能力差异。BFF(Backend for Frontend)存在的理由,正是把这些差异放到服务端显式处理,而不是让每个客户端自己猜。
本节先不讨论 BFF 怎么拆分,而是回答一个更靠前的问题:同一个业务,App、Web 和小程序到底差在哪里,这些差异为什么会改变服务端的接口形状。
三端差异不是界面差异
最容易被低估的是登录态。App 通常持有设备令牌和刷新令牌,可以在后台静默续期;Web 依赖 Cookie 或浏览器会话,用户清一次缓存就退出;小程序的登录要先拿平台临时 code,再换服务端 session-key,而且这个 key 的失效规则由平台决定。三者拿到的“用户身份”看起来都是 user_id,但恢复登录、跨设备同步和注销语义完全不同。
推送的差异更直接。iOS 走 APNs,Android 走 FCM 或厂商通道,Web 没有系统级推送,小程序只有用户主动订阅的模板消息。把“推送”写成布尔值还不够,因为“有推送”不等于“推得到”:用户可能关闭通知,厂商通道可能限流,小程序订阅消息还受次数和场景限制。
深链、包体和网络同样不是展示层问题。App 可以被外部链接唤起,Web 靠 URL 直达,小程序的路径必须在平台后台登记;App 的主包有体积上限,小程序对主包和分包的限制更紧;移动端会在蜂窝和 Wi-Fi 之间切换,还可能被系统冻结,Web 的失败模型则更接近“请求超时或页面关闭”。
| 维度 | iOS / Android | Web | 小程序 |
|---|---|---|---|
| 登录态 | 设备令牌 + 刷新令牌 | Cookie 或浏览器会话 | 平台 code 换 session-key |
| 推送 | APNs / FCM,可后台到达 | 无系统推送,只能页内提醒 | 订阅消息,受次数与场景限制 |
| 深链 | Universal Link / App Link | URL 直达 | 平台登记路径,跳转受限 |
| 包体 | 主包有上限,可增量资源 | 首包影响大,可流式加载 | 主包与分包上限更紧 |
| 网络 | 蜂窝/Wi-Fi 切换,可能被冻结 | 相对稳定,受浏览器约束 | 平台网络库,弱网更常见 |
| 更新 | 应用商店审核,版本碎片化 | 即时发布 | 平台审核 + 灰度 |
这张表里没有一行是“界面”。它们决定的是同一份领域数据在不同端应该怎么拿、拿多少、失败时怎么办。比如未读数,App 可以靠推送做跨设备同步,Web 可以在页面获得焦点时拉一次,小程序只能在 onShow 时拉取并依赖本地已读回执。
同一业务,不同端的“可用”定义
App 的“可用”是进程还在前台、网络还在、令牌还能静默续期;Web 的“可用”是页面没关、Cookie 还在;小程序的“可用”更接近一次会话,用户切到后台再回来,onShow 可能已经隔了很久。三者对“会话”的定义不同,服务端就不能用一个长连接或一个内存会话同时覆盖。
网络差异还会改变重试策略。App 可以在后台用系统调度重试,Web 只能等页面还活着,小程序的重试窗口更短。服务端如果对三个端返回同样的超时和重试建议,弱端会在用户已经离开后继续打请求,强端则可能过早放弃。
- App:允许后台刷新,但系统可能冻结进程,恢复时要能补拉增量。
- Web:页面关闭即结束,刷新是常态,接口要能快速重建首屏。
- 小程序:onShow 是主要恢复点,不能假设常驻后台。
- 多端并存:同一账号的已读、草稿和购物车要能收敛,不能各存一份。
- 合规:不同端能收集的标识和能展示的授权文案不同,BFF 不能返回统一的追踪字段。
把这些差异写进能力表,BFF 才能决定“这个端要不要返回增量游标”“失败时是让用户重试还是静默降级”。如果只在客户端做 if-else,服务端永远不知道自己的响应被谁、在什么网络条件下消费。
还有一个常被忽略的差异是调试能力。App 可以带日志上报和本地缓存,Web 可以打开开发者工具,小程序的问题往往只能靠平台后台的有限日志复现。BFF 返回的错误码和 trace id 如果不够具体,弱端的排障成本会高一个数量级。
- 错误响应要带稳定的错误码和可检索的 trace id,不能只返回一句“系统繁忙”。
- 关键字段的缺失要能被埋点区分,否则弱端只能靠用户截图反馈。
所以 BFF 按端裁剪的产品理由不是“客户端懒得写”,而是三件事:
- 同一份数据在不同端的正确性前提不同。App 的未读依赖推送,小程序的未读依赖拉取,不能共用同一个刷新策略。
- 同一份数据的成本不同。小程序的主包和网络更贵,首屏塞满字段会直接换成加载失败。
- 同一份数据的失败语义不同。Web 刷新页面就能重试,App 在后台被冻结后需要恢复流程,小程序可能连重试窗口都没有。
如果服务端坚持“一个接口喂所有端”,最后一定会出现两种结局:要么按最强端的假设设计,弱端被拖死;要么按最弱端设计,强端的能力被浪费。BFF 的价值是在服务端为每个端维护一份显式契约,而不是在客户端里散落 if-else。
失败场景:小程序复用了 App 的未读模型
回到开头那个红点。App 的未读模型有三个组成部分:服务端保存未读计数、推送通道下发已读事件、客户端收到事件后本地清零。小程序复用了第一和第三部分,却拿不到第二部分。
具体后果比“红点消不掉”严重。假设一个用户在小程序里读完十条私信,服务端收到的已读请求只更新了当前会话;跨会话的未读数仍然靠推送清零,而小程序没有推送。用户下次打开小程序,看到 99+ 的未读,点进去却只有两条新消息。连续几次之后,用户不再相信红点,客服开始收到“消息丢失”的投诉。
更麻烦的是多端并存。用户在 App 里读完消息,App 的已读事件通过推送同步到其他 App 设备;小程序不在这个同步链路里,于是同一个账号在 App 上显示 0,在小程序上显示 99+。团队起初以为是缓存,加了一层 Redis 反而让不一致持续更久,因为缓存把错误的计数固定了下来。
修复方案不是给小程序补一个“假推送”,而是把未读模型改成“服务端计数 + 端上拉取 + 幂等已读回执”:
- 服务端只保存权威未读计数和每条会话的最后已读位置,不假设一定存在推送通道。
- App 有推送时,推送只作为“尽快刷新”的提示,不作为唯一的数据通路;收到提示后仍然拉一次增量。
- Web 在页面获得焦点时拉取;小程序在 onShow 时拉取,并在本地维护已读回执。
- 所有已读上报带幂等键和客户端时间,服务端按会话去重,避免重复上报把计数减成负数。
这个修复的关键不是技术难度,而是承认“推送是可选能力”。一旦把端能力写成显式输入,未读模型就不会再把某个端的偶然能力当成全局前提。
用能力表把端差异写进代码
把差异写在文档里,下一次改需求时还是会忘。更可靠的做法是把端能力收敛成一个封闭集合和一个查询函数,让“未知端”直接失败,而不是落到某个默认分支。
下面这段代码来自本书配套的 webapp-lab。Client 只允许四个值,Capability 描述 BFF 能对该端承诺的最小能力集,Of 负责把端映射成能力。注意 Push 是字符串而不是布尔值:apns、fcm 和空字符串表达的是不同的通道,后续选择推送策略时不能混为一谈。
// file: internal/bff/client.go
// tier: L1
// verify: go test ./internal/bff/
package bff
import "fmt"
// Client 是 BFF 服务的端类型。
type Client string
const (
IOS Client = "ios"
Android Client = "android"
Web Client = "web"
MiniProgram Client = "miniprogram"
)
// Capability 是 BFF 对某个端承诺的最小能力集。
// Push 为空表示没有可用的系统推送通道;MaxPayloadKB 必须为正。
type Capability struct {
Login string
Push string
DeepLink bool
MaxPayloadKB int
}
// Of 返回端能力。未知端必须报错,不能用 Web 的默认值兜底。
func Of(client Client) (Capability, error) {
switch client {
case IOS:
return Capability{Login: "device-token", Push: "apns", DeepLink: true, MaxPayloadKB: 256}, nil
case Android:
return Capability{Login: "device-token", Push: "fcm", DeepLink: true, MaxPayloadKB: 256}, nil
case Web:
return Capability{Login: "cookie", Push: "", DeepLink: true, MaxPayloadKB: 512}, nil
case MiniProgram:
return Capability{Login: "session-key", Push: "", DeepLink: false, MaxPayloadKB: 128}, nil
default:
return Capability{}, fmt.Errorf("bff: unknown client %q", client)
}
}
这段代码有三个刻意的选择。
第一,Client 是字符串类型而不是整数枚举。日志、网关和埋点里出现的都是 ios、miniprogram 这样的字面量,排查问题时不需要再查一张映射表。代价是调用方可以构造任意字符串,所以 Of 必须把未知值挡在入口。
第二,未知端返回错误,而不是返回 Web 的能力。默认兜底看起来“兼容”,实际上会把配置错误变成线上行为差异:某个新端因为拼写错误被当成 Web,拿到了更大的包体预算和错误的登录态,直到用户投诉才被发现。
第三,MaxPayloadKB 是建议上限,不是硬限制。它告诉 BFF 这个端的首屏大概能承受多大响应体;真正的硬裁剪需要第 5.3 节的预算函数。把建议值和强制值分开,是为了让契约可讨论,而不是让一个魔法数字同时承担产品和技术两种含义。
还有一个容易被忽略的细节:iOS 和 Android 在这张表里只有推送通道不同,登录态、深链和包体上限都一样。这不代表它们永远一样。随着系统版本、厂商通道和审核规则变化,能力表需要跟着更新;把差异集中在一个函数里,更新时只需要改一处,测试也能立刻暴露回归。
测试:未知端不能被默认成 Web
能力表最怕两件事:新增端时忘了登记,以及有人为了“让测试通过”把未知端映射成默认值。下面的测试用表驱动的方式锁住四个端的完整能力,又单独验证未知输入必须报错。
// file: internal/bff/client_test.go
// tier: L1
// verify: go test ./internal/bff/
package bff
import "testing"
func TestOfClients(t *testing.T) {
cases := []struct {
client Client
want Capability
}{
{IOS, Capability{Login: "device-token", Push: "apns", DeepLink: true, MaxPayloadKB: 256}},
{Android, Capability{Login: "device-token", Push: "fcm", DeepLink: true, MaxPayloadKB: 256}},
{Web, Capability{Login: "cookie", Push: "", DeepLink: true, MaxPayloadKB: 512}},
{MiniProgram, Capability{Login: "session-key", Push: "", DeepLink: false, MaxPayloadKB: 128}},
}
for _, c := range cases {
t.Run(string(c.client), func(t *testing.T) {
got, err := Of(c.client)
if err != nil {
t.Fatal(err)
}
if got != c.want {
t.Fatalf("capability = %+v, want %+v", got, c.want)
}
})
}
}
func TestOfRejectsUnknownClient(t *testing.T) {
for _, client := range []Client{"", "desktop", "IOS"} {
if _, err := Of(client); err == nil {
t.Fatalf("client %q: want error", client)
}
}
}
func TestPayloadLimitIsPositive(t *testing.T) {
for _, client := range []Client{IOS, Android, Web, MiniProgram} {
got, err := Of(client)
if err != nil {
t.Fatal(err)
}
if got.MaxPayloadKB <= 0 {
t.Fatalf("client %q: MaxPayloadKB = %d", client, got.MaxPayloadKB)
}
}
}
测试里最值得看的是 TestOfRejectsUnknownClient 的三个输入:空字符串、desktop 和大写的 IOS。空字符串代表上游漏传,desktop 代表产品口头提到但还没登记的端,大写 IOS 代表大小写不一致。三者都应该失败,因为能力表是契约,不是模糊匹配。
TestOfClients 直接比较整个 Capability 结构体,而不是只检查某个字段。这样以后有人给结构体加字段,测试会提醒你为新端补上期望值;如果只比较 Login,新增字段的零值可能悄悄通过。
TestPayloadLimitIsPositive 看起来最简单,却对应一条真实规则:包体预算不能是 0 或负数。零预算会让 BFF 在任何响应上都失败,负数则会让后续比较出现反直觉的结果。把这条规则放在能力表入口,比等到线上裁剪时才发现便宜得多。
这些测试不验证 APNs 或 FCM 是否真的可用,也不验证小程序订阅消息的配额。它们验证的是服务端对端的假设是否被显式写出、是否可回归。真实的通道可用性要靠监控和演练,不能靠单元测试假装。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/bff/ -count=1 -run 'TestOf|TestPayload' -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestOfClients
=== RUN TestOfClients/ios
=== RUN TestOfClients/android
=== RUN TestOfClients/web
=== RUN TestOfClients/miniprogram
--- PASS: TestOfClients (0.00s)
--- PASS: TestOfClients/ios (0.00s)
--- PASS: TestOfClients/android (0.00s)
--- PASS: TestOfClients/web (0.00s)
--- PASS: TestOfClients/miniprogram (0.00s)
=== RUN TestOfRejectsUnknownClient
--- PASS: TestOfRejectsUnknownClient (0.00s)
=== RUN TestPayloadLimitIsPositive
--- PASS: TestPayloadLimitIsPositive (0.00s)
PASS
ok webapp/internal/bff 0.296s
-run 只选择本节相关的测试,避免把同一个包里的聚合和预算测试也算进来。判定标准仍然是退出码为 0 且没有 FAIL;-v 只是让人能看清哪个子用例失败。真实机器上的耗时会有波动,0.296s 不是性能指标。
小结
App、Web 和小程序的差异不在界面,而在登录态、推送、深链、包体和网络这些会改变接口形状的能力。把“端”当成展示层,最常见的事故就是让弱端复用强端的假设,比如小程序复用 App 的推送驱动未读模型。
BFF 按端裁剪的理由有三条:正确性前提不同、数据成本不同、失败语义不同。能力表应该是一个封闭集合加一个查询函数,未知端必须报错,不能默认兜底。MaxPayloadKB 只是建议值,真正的硬预算要由后续的裁剪逻辑执行。
下一节会讨论 BFF 的聚合、裁剪与契约:当首页需要五个下游的数据时,怎样把“拼 JSON”变成一份能写清字段来源、失败语义和缓存键的契约。
阅读导航:上一节:目录与学习路径 · 下一节:5.2 BFF 的聚合、裁剪与契约 。
常见错误与边界
- 把端差异当成 UI 差异。只换皮肤不改接口假设,弱端一定会在某个异常路径上暴露问题。
- 用布尔值表达推送。
true不能区分 APNs、FCM 和订阅消息,后续策略选择会失去依据。 - 未知端默认成 Web。配置错误会变成线上行为差异,而且很难从日志里发现。
- 把
MaxPayloadKB当硬限制。建议值和强制裁剪混在一起,会让产品和技术对同一个数字产生不同理解。 - 假设推送一定到达。用户关闭通知、厂商限流、小程序订阅额度都会让推送失效,必须有拉取兜底。
- 忽略登录态恢复。App 能静默续期,Web 和小程序不能,接口不能假设 token 永远有效。
- 让客户端自己判断端能力。判断逻辑散落在各端,服务端就无法保证契约一致。
- 只测试正常端。未知端、大小写错误和漏传参数才是能力表最容易漏掉的边界。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。