From 14b42c530290e450f9a3473d4c0362e274e7b417 Mon Sep 17 00:00:00 2001 From: Jipeng Zhang Date: Sat, 18 Jul 2026 15:01:11 +0700 Subject: [PATCH] Clarify production governance guidance --- CHANGELOG.md | 7 +++ README.md | 68 +++++++++++++++++++++------- README.zh-CN.md | 56 ++++++++++++++++++++--- URLRouterDemo/DemoViews.swift | 8 ++-- URLRouterDemo/URLRouterDemoApp.swift | 28 ++++++++---- 5 files changed, 132 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0732218..f323d1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes are documented here. This project follows [Semantic Versioni ## [Unreleased] +## [2.4.2] - 2026-07-18 + +### Changed + +- Expanded the production-governance guides in English and Chinese with plain-language explanations, operating examples, and integration steps for remote policy/circuit breaking, observability, and route-contract CI. +- Added English comments at URLRouter integration points in the demo so the scene host, policy lifecycle, observability, and circuit-breaker behavior are easy to follow. + ## [2.4.1] - 2026-07-18 ### Added diff --git a/README.md b/README.md index a7caca2..9c3aa11 100644 --- a/README.md +++ b/README.md @@ -195,7 +195,20 @@ The registry rejects duplicate module IDs, a route returned by the wrong module, ### Remote policy and emergency circuit breaker -`ModuleRouteRemotePolicy` is a Codable restriction document that the App Shell can fetch from any approved remote-config service. The library never fetches configuration itself: the host must authenticate, validate, cache, and roll back the document. A remote policy can only restrict a local policy; it cannot grant authorization. +In plain terms, a remote policy is a route control panel that the backend can change without waiting for an App Store release. If the article module has an incident, the backend can disable `content`; the next article link is safely rejected. If routing itself is unsafe, setting `isCircuitBreakerOpen` to `true` pulls the main switch: all module routes stop immediately while the rest of the app continues to work. + +The backend sends ordinary JSON such as: + +```json +{ + "isCircuitBreakerOpen": false, + "disabledModuleIDs": ["content"], + "allowedPresentationStyles": ["push", "tab"], + "acceptedContractVersions": ["1"] +} +``` + +`ModuleRouteRemotePolicy` represents this JSON in Swift. URLRouter never fetches configuration itself: the host app fetches it from a company service, Firebase, or another configuration service and owns authentication, signature verification, caching, and rollback. A remote policy can only **tighten** local rules; it cannot bypass local authorization or re-enable a locally rejected contract version. For the recommended cache-first app lifecycle, add the optional product from this same package: @@ -205,8 +218,17 @@ For the recommended cache-first app lifecycle, add the optional product from thi `URLRouterPolicyProvider` depends on `URLRouter`; the reverse is not true. It does not choose an HTTP client, remote-config vendor, or signing scheme. The -App supplies those small adapters, while the provider handles cache-first -startup, TTL, stale-cache fallback, and atomic replacement of the policy. +app supplies only where data comes from and how it is verified; the provider +handles this failure-prone lifecycle: + +```text +App starts + → restore the last verified local cache immediately + → fetch the newest policy in the background without blocking the first screen + → validate and atomically replace the active policy + → save the new verified cache + → keep the old cache after a temporary failure; fall back to local safe rules when it is too old +``` ```swift @State private var routePolicyStore = ModuleRoutePolicyStore( @@ -222,15 +244,11 @@ func applyTrustedRemotePolicy(_ data: Data) throws { } ``` -Set `isCircuitBreakerOpen` to `true` for an immediate, release-free stop to module routing. The same document can disable individual modules, provide an allow-list, reject presentation styles, or tighten accepted contract versions. +To use it, the app gives a `ModuleRoutePolicyStore` to `moduleLinkRouting`, then calls `replaceRemotePolicy` only after receiving and validating a new policy. The next route automatically uses the new rules; no view rebuild or app release is needed. Set `isCircuitBreakerOpen` to `true` for an immediate stop to module routing. The same document can disable individual modules, provide an allow-list, reject presentation styles, or tighten accepted contract versions. ### Recommended app refresh strategy -Use this sequence: read the last verified cache first, refresh in the -background at cold start, refresh when the app becomes active after the TTL, -and keep the last verified policy during a temporary outage. If no verified -cache exists or it exceeds the hard stale limit, URLRouter keeps the App's -local safe policy. +Do not fetch the backend for every link: that is slow and unreliable. Use this sequence instead: read the last verified cache first, refresh in the background at cold start, refresh when the app becomes active after the TTL, and keep the last verified policy during a temporary outage. If no verified cache exists or it exceeds the hard stale limit, URLRouter keeps the app's local safe policy. The defaults are a 30-minute foreground refresh, one-hour normal cache, and a 24-hour hard stale limit. Use a shorter TTL or push-triggered refresh for incident-sensitive circuit breakers. ```swift import URLRouter @@ -277,19 +295,37 @@ final class AppRoutePolicySession { Use `JSONRoutePolicyPayloadValidator` for a plain trusted JSON endpoint. For a signed response or envelope, implement `RoutePolicyPayloadValidating`; only a -validated `ModuleRouteRemotePolicy` is cached or applied. For a normal policy, -the standard timing is a 30-minute foreground refresh, one-hour normal cache, -and 24-hour hard stale limit. Make the backend's emergency circuit-breaker -delivery more frequent or push-triggered when your incident requirements need -it. +validated `ModuleRouteRemotePolicy` is cached or applied. A corrupted response +or intercepted network payload therefore cannot replace the current working +policy with a partial value. ### Unified observability -Adopt `ModuleRouteObserving` in adapters for your logging, metrics, and tracing SDKs, then supply a `ModuleRouteObservability` instance to `moduleLinkRouting`. Each event carries a trace ID, outcome, host, module/route identity, presentation, and a stable `failureCode`; it deliberately excludes URL query values. +In plain terms, unified observability is a dashcam for routing. When a user says “the order link did nothing,” the team can see whether the route succeeded, was circuit-broken, used an unsupported version, targeted a disabled module, or was malformed. It also lets monitoring alert when one failure reason suddenly spikes. + +To use it, write a small adapter that forwards an event to the logging, metrics, or tracing systems your company already uses, then pass it to `moduleLinkRouting`. `ModuleRouteObservability` can fan each event out to multiple observers: for example, one logs and one increments a metric. + +```swift +@MainActor +final class AppRouteObserver: ModuleRouteObserving { + func record(_ event: ModuleRouteEvent) { + logger.notice("route outcome=\(event.outcome.rawValue) trace=\(event.traceID)") + metrics.increment("route.\(event.failureCode ?? "handled")") + } +} + +let observability = ModuleRouteObservability(observers: [AppRouteObserver()]) +``` + +Each event carries a trace ID, outcome, host, module/route identity, presentation, and a stable `failureCode`. It deliberately excludes URL query values, tokens, phone numbers, and other potentially sensitive data. Use the trace ID to correlate with your app's controlled logs when troubleshooting. ### Route contract CI -[`RouteContracts.json`](RouteContracts.json) is the source-controlled public route catalog. CI runs `Scripts/validate_route_contract.swift` before builds and rejects duplicate route IDs or path/presentation invocations, invalid presentation values, and contracts that omit required `presentation` or `version` parameters. Update the catalog, feature parser, release notes, and migration plan together whenever a public route changes. +In plain terms, route contract CI treats a URL as an interface agreement between teams. `/articles/:id?presentation=push&version=1` is not just a string: another Feature, a web page, a push notification, or an older app may depend on it. Without this check, one team can change a path or presentation and another team discovers broken links only in production. + +[`RouteContracts.json`](RouteContracts.json) is the source-controlled public route catalog. Every entry states who owns a route, its path shape, permitted presentation styles, and required parameters. CI runs `Scripts/validate_route_contract.swift` before builds and rejects duplicate route IDs or path/presentation invocations, invalid presentation values, and contracts that omit required `presentation` or `version` parameters. + +To use it, update four things in the same PR whenever a public link changes: the Feature URL parser, `RouteContracts.json`, the README/caller examples, and any required migration notes. CI catches structural catalog mistakes; whether an old URL can be removed and how old clients migrate must still be explicit in PR review and release notes. That distinction prevents “automatic validation” from being mistaken for “automatic compatibility.” ## Routing scenarios diff --git a/README.zh-CN.md b/README.zh-CN.md index 07d36b2..2f189a9 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -193,7 +193,20 @@ Swift 无法在运行时发现未链接的 Package。存在两个或更多 Featu ### 远程策略与紧急熔断 -`ModuleRouteRemotePolicy` 是可 `Codable` 解码的限制文档,App Shell 可从任意已批准的远程配置服务获取。库本身不访问网络:宿主 App 必须负责鉴权、验签、缓存和回滚。远程策略只能收紧本地策略,不能越过本地授权。 +先用人话说:远程策略就是后台随时能改的“路由开关表”。它解决的是“某个页面或功能出了事故,不能等 App Store 审核再修”的问题。例如文章模块有严重故障时,后台把 `content` 放进禁用列表;用户下一次打开文章链接时,App 会安全地拒绝跳转。若所有路由都存在风险,把 `isCircuitBreakerOpen` 设为 `true`,就像拉下总闸:所有模块路由立即停止,但 App 其他功能不受影响。 + +后台下发的是一份普通 JSON,大意可以是: + +```json +{ + "isCircuitBreakerOpen": false, + "disabledModuleIDs": ["content"], + "allowedPresentationStyles": ["push", "tab"], + "acceptedContractVersions": ["1"] +} +``` + +`ModuleRouteRemotePolicy` 负责把这份 JSON 表示为 Swift 数据。URLRouter 不会自己联网:宿主 App 负责从公司接口、Firebase 或其他配置服务获取数据,并负责鉴权、验签、缓存和回滚。远程策略只能**收紧**本地规则,不能绕过本地授权或放宽本地禁止的版本;因此即使远程配置异常,也不能把一个本来不应打开的路由放行。 需要“先读缓存、后台刷新”的 App 生命周期时,可从同一个 Package 按需引入: @@ -201,7 +214,16 @@ Swift 无法在运行时发现未链接的 Package。存在两个或更多 Featu .product(name: "URLRouterPolicyProvider", package: "URLRouter") ``` -`URLRouterPolicyProvider` 依赖 `URLRouter`,反过来核心库不依赖它。它不绑定 HTTP 客户端、远程配置厂商或签名方案;App 只实现这些小适配层,Provider 负责缓存优先启动、TTL、旧缓存回退和策略原子替换。 +`URLRouterPolicyProvider` 依赖 `URLRouter`,反过来核心库不依赖它。它不绑定 HTTP 客户端、远程配置厂商或签名方案;App 只实现“去哪里拿数据”和“如何验签”这两小部分,Provider 负责下面这套容易出错的流程: + +```text +启动 App + → 先读取上一次已验证的本地缓存,马上可用 + → 后台请求最新配置,不阻塞首屏 + → 验签、解析、检查通过后一次性替换当前策略 + → 保存新的可信缓存 + → 请求失败时继续使用旧缓存;旧缓存太久则回退到 App 内置安全规则 +``` ```swift @State private var routePolicyStore = ModuleRoutePolicyStore( @@ -217,11 +239,11 @@ func applyTrustedRemotePolicy(_ data: Data) throws { } ``` -将 `isCircuitBreakerOpen` 设为 `true`,即可不发版立即停止模块路由。该文档还可禁用指定模块、提供允许列表、拒绝某些展示方式或进一步收紧支持的协议版本。 +怎么用:App 创建一个 `ModuleRoutePolicyStore` 并交给 `moduleLinkRouting`;随后只需要在拿到并验证新配置时调用 `replaceRemotePolicy`。下一次路由自动使用新规则,不需要重建界面或重新发版。将 `isCircuitBreakerOpen` 设为 `true`,即可不发版立即停止模块路由。该文档还可禁用指定模块、提供允许列表、拒绝某些展示方式或进一步收紧支持的协议版本。 ### 推荐的 App 拉取策略 -建议顺序是:启动先读取上一次已验证缓存,再在后台刷新;App 回到前台且超过 TTL 时按需刷新;短暂断网时继续使用最后一次可信策略。没有可信缓存,或缓存超过硬过期时间时,URLRouter 保持 App 内置的安全本地策略。 +推荐策略不是“每次点链接都请求后台”,那样既慢又不可靠。建议顺序是:启动先读取上一次已验证缓存,再在后台刷新;App 回到前台且超过 TTL 时按需刷新;短暂断网时继续使用最后一次可信策略。没有可信缓存,或缓存超过硬过期时间时,URLRouter 保持 App 内置的安全本地策略。默认值是:前台 30 分钟刷新一次、普通缓存 1 小时、最多使用 24 小时的旧可信缓存;熔断要求更高的业务可缩短 TTL 或由静默推送触发立即刷新。 ```swift import URLRouter @@ -266,15 +288,35 @@ final class AppRoutePolicySession { } ``` -普通可信 JSON 接口可使用 `JSONRoutePolicyPayloadValidator`。如果响应有签名或信封结构,App 实现 `RoutePolicyPayloadValidating`;只有校验通过的 `ModuleRouteRemotePolicy` 才会写入缓存和生效。普通策略建议前台 30 分钟刷新、常规缓存 1 小时、硬过期 24 小时;对事故熔断,可按实际要求使用更短刷新间隔或静默推送触发刷新。 +普通可信 JSON 接口可使用 `JSONRoutePolicyPayloadValidator`。如果响应有签名或信封结构,App 实现 `RoutePolicyPayloadValidating`;只有校验通过的 `ModuleRouteRemotePolicy` 才会写入缓存和生效。这样即使网络被劫持、返回了损坏 JSON,当前正在使用的策略也不会被半成品覆盖。 ### 统一可观测性 -为日志、指标、Tracing SDK 编写 `ModuleRouteObserving` 适配器,再将 `ModuleRouteObservability` 传给 `moduleLinkRouting`。每个事件包含 trace ID、结果、host、模块/路由标识、展示方式和稳定的 `failureCode`;它刻意不包含 URL query 值。 +先用人话说:统一可观测性就是给路由装“行车记录仪”。当用户说“点订单链接没反应”时,团队不需要猜;可以看到这次路由是成功、被熔断、版本不支持、模块关闭,还是 URL 本身不合法。它还能让监控系统在“某个失败原因突然暴增”时报警。 + +怎么用:写一个很薄的适配器,把事件送到公司已有的日志、指标或追踪系统,再在根部传给 `moduleLinkRouting`。`ModuleRouteObservability` 可以同时发送给多个观察者,例如一个写日志、一个计数、一个上报 tracing。 + +```swift +@MainActor +final class AppRouteObserver: ModuleRouteObserving { + func record(_ event: ModuleRouteEvent) { + logger.notice("route outcome=\(event.outcome.rawValue) trace=\(event.traceID)") + metrics.increment("route.\(event.failureCode ?? "handled")") + } +} + +let observability = ModuleRouteObservability(observers: [AppRouteObserver()]) +``` + +每个事件包含 trace ID、结果、host、模块/路由标识、展示方式和稳定的 `failureCode`。它刻意不包含 URL query 值、token、手机号等可能敏感的数据;需要排障时用 trace ID 关联 App 自己的受控日志即可。 ### 路由契约 CI -[`RouteContracts.json`](RouteContracts.json) 是受版本控制的公开路由目录。CI 会在构建前运行 `Scripts/validate_route_contract.swift`,拒绝重复的路由 ID 或路径/展示方式组合、非法展示方式,以及缺少 `presentation` 或 `version` 参数的契约。变更公开路由时,应同步更新目录、Feature 解析器、发布说明和迁移方案。 +先用人话说:路由契约 CI 就是把 URL 当成团队之间的“接口协议”来管理。`/articles/:id?presentation=push&version=1` 不只是一个字符串;其他 Feature、网页、推送和旧 App 都可能依赖它。没有这层检查时,团队 A 改了路径或展示方式,团队 B 往往要等线上链接失效才发现。 + +[`RouteContracts.json`](RouteContracts.json) 是这份受版本控制的公开路由目录。每个条目声明谁拥有路由、路径长什么样、允许怎样展示、以及必须有哪些参数。CI 会在构建前运行 `Scripts/validate_route_contract.swift`,拒绝重复的路由 ID 或路径/展示方式组合、非法展示方式,以及缺少 `presentation` 或 `version` 参数的契约。 + +怎么用:新增或修改公开链接时,按同一个 PR 同步修改四处:Feature 的 URL 解析器、`RouteContracts.json`、README/调用方示例、以及必要的迁移说明。CI 擅长阻止目录本身的结构错误;是否可以删除旧链接、旧版本如何迁移,则仍应在 PR 审查和发布说明中明确,这是为了避免把“自动检查”误当成“自动兼容”。 ## 常见路由场景 diff --git a/URLRouterDemo/DemoViews.swift b/URLRouterDemo/DemoViews.swift index 827810c..30019f9 100644 --- a/URLRouterDemo/DemoViews.swift +++ b/URLRouterDemo/DemoViews.swift @@ -7,12 +7,12 @@ // import SwiftUI -import URLRouter +import URLRouter // ModuleRouter and ModuleRoutePolicyStore drive this tab shell. import NavigationFeature struct DemoTabs: View { - @Bindable private var router: ModuleRouter - @Bindable private var policyStore: ModuleRoutePolicyStore + @Bindable private var router: ModuleRouter // URLRouter owns the selected tab state. + @Bindable private var policyStore: ModuleRoutePolicyStore // URLRouter reads this on the next link. private let latestRouteEvent: ModuleRouteEvent? private let policyStatus: String @@ -29,6 +29,7 @@ struct DemoTabs: View { } var body: some View { + // Bind SwiftUI tab selection to URLRouter's tab route state. TabView(selection: $router.selectedTab) { HomeView() .tabItem { Label("Home", systemImage: "house") } @@ -63,6 +64,7 @@ struct DemoTabs: View { Binding( get: { !(policyStore.remotePolicy?.isCircuitBreakerOpen ?? false) }, set: { enabled in + // Simulate the backend circuit breaker by replacing URLRouter's remote policy. policyStore.replaceRemotePolicy(with: ModuleRouteRemotePolicy( isCircuitBreakerOpen: !enabled )) diff --git a/URLRouterDemo/URLRouterDemoApp.swift b/URLRouterDemo/URLRouterDemoApp.swift index 7bc2b4d..43dffc4 100644 --- a/URLRouterDemo/URLRouterDemoApp.swift +++ b/URLRouterDemo/URLRouterDemoApp.swift @@ -8,20 +8,21 @@ import SwiftUI import Observation -import URLRouter -import URLRouterPolicyProvider +import URLRouter // Core routing types and the SwiftUI router host. +import URLRouterPolicyProvider // Optional cache-first remote policy lifecycle. import ContentFeature import NavigationFeature @main /// The demo composes the same `RouterHost` API available to every supported platform. struct URLRouterDemoApp: App { - @State private var router = ModuleRouter() - @State private var policySession = DemoPolicySession() - @State private var routeObserver = DemoRouteObserver() + @State private var router = ModuleRouter() // One URLRouter navigation state per scene. + @State private var policySession = DemoPolicySession() // Owns URLRouter's local and remote route policy. + @State private var routeObserver = DemoRouteObserver() // Receives URLRouter route telemetry events. var body: some Scene { WindowGroup { + // RouterHost renders URLRouter push, sheet, and full-screen destinations. RouterHost(router: router) { DemoTabs( router: router, @@ -30,15 +31,19 @@ struct URLRouterDemoApp: App { policyStatus: policySession.status ) } destination: { route in + // Ask URLRouter's registry for the Feature-owned destination view. DemoModules.registry.destination(for: route) } + // Install URLRouter once at the scene root for openURL and Universal Links. .moduleLinkRouting( router: router, registry: DemoModules.registry, allowedHosts: ["example.com"], policyStore: policySession.store, + // Send privacy-safe URLRouter events to the demo observer. observability: ModuleRouteObservability(observers: [routeObserver]) ) + // Restore cached policy first, then refresh the demo's remote policy. .task { await policySession.bootstrapAndRefresh() } } } @@ -47,17 +52,19 @@ struct URLRouterDemoApp: App { @MainActor enum DemoModules { + // Register every Feature Package with URLRouter; Features keep their own URL parsing. static let registry = ModuleRouteRegistry(modules: [ ContentFeature.module, NavigationFeatureRoutes.module ]) static func makePolicyStore() -> ModuleRoutePolicyStore { + // Keep URLRouter's trusted local rules separate from replaceable remote restrictions. ModuleRoutePolicyStore( localPolicy: ModuleRoutePolicy( acceptedContractVersions: ["1"], allowsUnversionedLinks: false ), - remotePolicy: ModuleRouteRemotePolicy() + remotePolicy: ModuleRouteRemotePolicy() // Demo starts with routing enabled. ) } } @@ -65,11 +72,12 @@ enum DemoModules { @MainActor @Observable final class DemoPolicySession { - let store = DemoModules.makePolicyStore() + let store = DemoModules.makePolicyStore() // The store used by moduleLinkRouting. private let provider: RoutePolicyProvider private(set) var status = "Using local policy" init() { + // RoutePolicyProvider demonstrates URLRouter's cache-first policy refresh flow. provider = RoutePolicyProvider( store: store, source: DemoPolicySource(), @@ -79,14 +87,15 @@ final class DemoPolicySession { } func bootstrapAndRefresh() async { - let bootstrap = await provider.bootstrap() - let refresh = await provider.refresh() + let bootstrap = await provider.bootstrap() // Apply a verified cached policy, if any. + let refresh = await provider.refresh() // Then fetch and atomically apply the newest policy. status = "Policy \(bootstrap) · \(refresh)" } } private struct DemoPolicySource: RoutePolicyRemoteSource { func fetchPolicyData() async throws -> Data { + // Real apps fetch a signed payload from their own backend or config service. try JSONEncoder().encode(ModuleRouteRemotePolicy()) } } @@ -97,6 +106,7 @@ final class DemoRouteObserver: ModuleRouteObserving { private(set) var latestEvent: ModuleRouteEvent? func record(_ event: ModuleRouteEvent) { + // URLRouter emits a privacy-safe event after every routing attempt. latestEvent = event } }