一、跨应用搜索最怕的不是慢,而是撤权后还在返回

SemanticShareBroker 是一个文搜图索引代理。主应用维护照片语义索引,桌面侧的相册入口通过 DataShareExtensionAbility 查询“海边日落”,每次取 24 条。最初版本只在请求进入时校验调用方,随后把 RDB ResultSet 交给分页层。测试撤销授权后,已经打开的游标还能继续翻两页,等于把一次授权变成了不确定时长的通行证。

这不是普通分页 Bug。跨应用查询同时存在调用方身份、授权时限、查询作用域、数据库游标和页面生命周期。任何一层失效,都应该让后续读取立刻停止。于是我没有继续在 query() 外面补 if,而是把授权租约写进游标令牌:每一页都重新核对 lease、scope 和 queryEpoch,撤权后正在运行的查询返回 LEASE_REVOKED,并主动关闭 ResultSet。

演示任务固定为 DSQ-1209,调用方 com.example.albumwidget,查询词“海边日落”,scope=favorites,pageSize=24,cursor=c17,租约 60s,查询耗时 31ms,最终状态 PAGE_READY。这组数据贯穿代码、日志和图片。

二、租约不是一个布尔值

如果 Preferences 里只存 allowed=true,撤权与并发查询之间没有顺序。Demo 使用 AccessLease:caller、scope、issuedAt、expiresAt、leaseEpoch 和 revokedAt。每次授权变化都递增 epoch,旧游标令牌带着旧 epoch,自然无法继续。

第一段代码解决租约签发与校验。签名这里只用应用侧摘要接口表示,生产环境应把密钥放入安全能力中,不能把 secret 写进 ArkTS 常量。

interface AccessLease {
  caller: string
  scope: string
  issuedAt: number
  expiresAt: number
  leaseEpoch: number
  revokedAt: number
}

class LeaseRegistry {
  private leaseEpoch: number = 17
  private leases: Map<string, AccessLease> = new Map()

  issue(caller: string, scope: string, now: number): AccessLease {
    const lease: AccessLease = {
      caller, scope, issuedAt: now, expiresAt: now + 60_000,
      leaseEpoch: ++this.leaseEpoch, revokedAt: 0
    }
    this.leases.set(`${caller}|${scope}`, lease)
    return lease
  }

  assertActive(caller: string, scope: string,
    epoch: number, now: number): AccessLease {
    const lease = this.leases.get(`${caller}|${scope}`)
    if (!lease || lease.leaseEpoch !== epoch || lease.revokedAt > 0 || now >= lease.expiresAt) {
      throw new Error('LEASE_REVOKED')
    }
    return lease
  }
}

60 秒是 Demo 的分页租约,不代表用户授权只能存在一分钟。长期授权与短期查询租约是两件事:用户允许该组件访问收藏相册,Broker 再给一次具体查询签发短租约。短租约过期后可以在长期授权仍有效时重新签发;撤销长期授权则让所有短租约的 epoch 失效。

三、游标令牌只保存位置,不暴露数据库对象

第二个坑是把 ResultSet 留在 ExtensionAbility 的全局 Map 里,用随机 ID 返回给调用方。调用方消失后,这些游标无人关闭;进程常驻一段时间,打开游标持续增加。修复后 token 只保存排序锚点 score + assetId、leaseEpoch、queryEpoch 和 scope,不保存 ResultSet。每页都是一次新的有界 RDB 查询,完成后立即关闭。

排序锚点也不能只用 score。相似度可能相同,只有加上稳定的 assetId 才能保证下一页不重复、不漏项。c17 是对内部 token 的展示别名,真实 token 会签名,调用方不能篡改 scope 或 pageSize。

下面代码展示分页查询的核心。RDB ResultSet 在 finally 中关闭,即使租约在查询完成后、序列化前被撤销,也不会泄漏游标。

interface PageToken {
  cursor: string
  leaseEpoch: number
  queryEpoch: number
  lastScore: number
  lastAssetId: string
  scope: string
}

async function queryPage(store: relationalStore.RdbStore,
  leases: LeaseRegistry, caller: string, token: PageToken): Promise<SearchRow[]> {
  leases.assertActive(caller, token.scope, token.leaseEpoch, Date.now())
  const predicates = new relationalStore.RdbPredicates('semantic_index')
    .equalTo('scope', token.scope)
    .lessThanOrEqualTo('score', token.lastScore)
    .orderByDesc('score').orderByAsc('asset_id').limitAs(24)
  let result: relationalStore.ResultSet | undefined
  try {
    result = await store.query(predicates, ['asset_id', 'score', 'thumb_uri'])
    const rows = readStableRows(result, token.lastScore, token.lastAssetId)
    leases.assertActive(caller, token.scope, token.leaseEpoch, Date.now())
    return rows
  } finally {
    result?.close()
  }
}

第二次 assertActive 很重要。没有它,撤权恰好发生在数据库查询期间,旧请求仍会把结果返回。关闭 ResultSet 也必须由服务端负责,不能指望跨应用调用方通知“我不用了”。

四、撤权要熔断正在排队的查询

仅让下一页失败还不够。Broker 为每个 caller/scope 维护 queryEpoch,撤权或重新授权都会递增;任务进入队列时记录 epoch,执行前、RDB 返回后和序列化前都核对。旧任务不尝试重试,也不降级到其他 scope。

第三段代码展示 ExtensionAbility 的消息入口如何把调用方、租约和查询代次收敛到一个上下文。示例省略系统身份提取细节,真实项目必须使用系统提供的调用方信息,不能相信消息正文里的 caller 字段。

class QueryGate {
  private epochs: Map<string, number> = new Map()

  begin(key: string): number {
    const epoch = (this.epochs.get(key) ?? 0) + 1
    this.epochs.set(key, epoch)
    return epoch
  }

  revoke(key: string): void {
    this.epochs.set(key, (this.epochs.get(key) ?? 0) + 1)
  }

  assertCurrent(key: string, epoch: number): void {
    if (this.epochs.get(key) !== epoch) throw new Error('QUERY_REVOKED')
  }
}

async function handleBrokerQuery(request: BrokerRequest): Promise<BrokerPage> {
  const caller = CallerIdentity.resolve()
  const key = `${caller}|${request.scope}`
  const epoch = queryGate.begin(key)
  const token = TokenCodec.verify(request.cursor)
  queryGate.assertCurrent(key, epoch)
  const rows = await queryPage(rdbStore, leaseRegistry, caller, token)
  queryGate.assertCurrent(key, epoch)
  return { taskId: 'DSQ-1209', cursor: 'c17', rows, state: 'PAGE_READY' }
}

同一 caller 发起新查询会让旧查询失效,这是 Demo 的产品规则;如果业务允许并行标签页,key 还应加入 sessionId。页面离场不等于撤销用户授权,只取消该 session 的 queryEpoch;两种生命周期不要混为一谈。

五、从日志反推授权边界

工程目录分为 SemanticShareExtension.ets、LeaseRegistry.ets、QueryGate.ets、TokenCodec.ets、SemanticIndexStore.ets 和 BrokerAuditPage.ets。底部 HiLog 固定输出:task=DSQ-1209 caller=com.example.albumwidget scope=favorites lease=60s、query=海边日落 cursor=c17 pageSize=24、rows=24 elapsed=31ms openResultSet=0、state=PAGE_READY revoked=0。

六、最终验证与异常恢复

正常查询返回 24 条,游标 c17,耗时 31ms;翻页后 assetId 无重复。查询执行到一半撤权时,服务返回 LEASE_REVOKED,ResultSet 计数归零,不再生成下一游标。重新授权会签发新 leaseEpoch,旧 token 仍然无效,避免“撤销后又被历史页面复活”。

七、边界不在 RDB,而在数据责任

这套方案没有把原始图片跨应用暴露出去,返回的是受 scope 限制的缩略图 URI 与排序分数;真实图片读取还要经过资源侧权限校验。调用方白名单、用户可见授权说明、审计记录与数据最小化也必须一起落地。

我后来更在意的是:跨应用查询不能只问“这个调用方以前是否允许过”,还要回答“这一次、这个 scope、这个游标、此刻是否仍然有效”。租约、锚点游标和 queryEpoch 分别守住时间、位置和并发边界,三者合在一起,撤权才不只是 UI 上关掉一个开关。

八、上线前补的一组回归

我把时钟前拨 61 秒,确认短租约过期后返回 LEASE_REVOKED;随后重新签发 leaseEpoch,旧 c17 仍不能复用。再把两条记录的 score 设成相同值,分页依靠 assetId 保持稳定,没有重复或漏项。最后模拟调用方进程被杀,服务端没有遗留 ResultSet,openResultSet 仍为 0。

这些回归看起来比搜索算法琐碎,却决定了能力能否交给别的应用使用。语义召回可以逐步优化,越权返回一张本不该继续可见的照片则属于边界失守。跨应用接口一旦发布,调用方不会替服务端清理资源,也不会替它解释授权语义,所以每一次查询都必须能够独立完成、独立关闭,并留下可审计的任务 ID 和结果状态。

九、把故障注入放进日常开发

最后一轮验证没有再盯正常路径,而是在三个执行点主动制造中断。第一处放在 queryPage() 刚拿到 ResultSet 之后,此时撤销租约,预期是关闭游标并抛出 LEASE_REVOKED,不能返回已经读出的半页数据。第二处放在生成 c17 之前,模拟数据库提交新照片;因为游标携带稳定锚点,当前页仍保持 24 条,新增记录留给下一次新查询。第三处放在页面销毁与回调返回之间,确认 BrokerAuditPage 不再接收旧 session 的状态,同时服务端 openResultSet 回到 0。

这里还有一个容易被忽略的产品边界:撤销租约 是用户意图,不能做成仅清理页面变量的“假撤权”。按钮按下后先让服务端递增 leaseEpoch,再使本地按钮、游标与缩略图失效;即使进程恰好被杀,服务端状态也已经生效。相反,普通返回键只结束当前 session,不应顺手抹掉用户授予其他合法会话的权限。把这两条路径分开后,日志里每一次 revoked 才有明确含义,测试也能判断失败发生在授权、分页还是页面生命周期。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐