《互联网产品的服务端架构·产品、流量与基础能力》5.1 App、Web 与小程序的差异

从「回声」小程序复用 App 未读模型的事故写起,把 iOS、Android、Web 与小程序的登录态、推送、深链、包体上限差异整理成一张能力表,并用一段可测试的 Go 代码说明 BFF 为什么必须按端裁剪,以及未知端为什么不能默认兜底,避免同一个接口把强端和弱端一起拖死。

一个客服工单暴露的端差异

「回声」的 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 / AndroidWeb小程序
登录态设备令牌 + 刷新令牌Cookie 或浏览器会话平台 code 换 session-key
推送APNs / FCM,可后台到达无系统推送,只能页内提醒订阅消息,受次数与场景限制
深链Universal Link / App LinkURL 直达平台登记路径,跳转受限
包体主包有上限,可增量资源首包影响大,可流式加载主包与分包上限更紧
网络蜂窝/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 永远有效。
  • 让客户端自己判断端能力。判断逻辑散落在各端,服务端就无法保证契约一致。
  • 只测试正常端。未知端、大小写错误和漏传参数才是能力表最容易漏掉的边界。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查