Feature 設計與使用
適用對象: 維護
appfuse-server、app-tenant-server,或以 AppFuse 建立下游 server 的團隊 最後更新: 2026-08-18 相關決策: ADR-014、 ADR-024、 方法論 ADR-006、 方法論 ADR-012
本指南的 Feature 是 server reference implementation 中可 provision、可版本化升級的 feature slice;不是產品規格裡的 Feature List,也不是「所有功能程式碼」的泛稱。
先分清三層
| 層 | 用途 | 典型落點 | 誰決定 |
|---|---|---|---|
| Capability | 可重用的機制、演算法與 SPI | appfuse-server jar 的 io.leandev.appfuse.* | 框架 |
| Feature | 把 capability 接進應用的中性、可同步 slice,並可附帶預設 reference implementation | app 的 {basePkg}.feature.{id}、config/feature-{id}.yml 與 catalog referenceDomains | 框架維護 core;下游擁有 seam 與附屬 reference domain |
| Reference implementation | 可運行、預期依產品重塑的實作範例 | app 的 controller、service、entity、repository、security 與業務域 | 應用 |
三層不是依檔案行數或是否使用 Spring 判斷,而是依所有權與生命週期判斷:
- 固定技術機制若每個應用都不該重寫,放 capability。
- 需要留在應用、但可以中性同步的組裝,放 Feature。
- URL、HTTP method、wire DTO、entity schema、角色詞彙、資料治理與產品政策,放 reference implementation。
複雜度的優先順序
機制的首要目標是降低 reference implementation 的複雜度,其次才是降低 Feature 的複雜度。Capability 可以承擔必要且固定的技術複雜度,因為它只實作一次、由套件版本同步。
採用以下順序:
- 先消除每個應用都要重寫、且容易寫錯的機制。
- 再讓 Feature 只保留可讀的組裝與少量組態。
- 不為了讓 capability 類別更少,而把固定機制推回 Feature 或 reference implementation。
- 不以「拆出更多介面或檔案」冒充降複雜度;只有真實替換點才需要 SPI。
例如 RSA wire encoding、DAO authentication manager 的事件發布、resource-server filter 順序適合由 capability 承擔;金鑰來源、是否允許開發期 ephemeral key、登入鎖定是否套用於 M2M, 仍由應用組裝決定。
Feature 的設計不變量
一個 Feature 必須同時滿足:
- 中性:Feature 的 core slice 不得 import reference implementation 的 controller、service、 entity、repository 或產品業務域;附屬 reference domain 不屬 core,不受此句混淆。
- 低 drift:非 seam 檔應能由
/feature sync零分歧覆蓋;持續 drift 表示分層錯位。 - 可移除:移除 optional Feature 後,不應在共用組態留下它的 bean、URL 或權限字串。
- 依賴顯式:
requires是實際 import 邊與beanRequires的聯集;required Feature 不得依賴 optional Feature。 - 位址中立:Feature 與 capability 都不決定具體公開端點位址。
- 框架收納:Feature 一律位於
{basePkg}.feature.*;root 其餘業務域屬應用。
Feature 可以附帶 service、repository 或 entity 嗎?
可以,但它們不是 Feature core。若某 optional Feature 需要一套「開箱可跑、下游可重塑」的資源
實作,在 catalog 以 referenceDomains 附帶:
"service-account": {
"kind": "optional",
"requires": ["auth", "cache"],
"beanRequires": ["cache"],
"referenceDomains": ["serviceaccount"],
"seams": [
"src/main/java/io/leandev/app/feature/serviceaccount/ServiceAccountConfig.java"
]
}
這會形成兩種不同生命週期:
| 部分 | 例子 | provision | sync | ownership |
|---|---|---|---|---|
| Feature core / seam | feature/serviceaccount/ServiceAccountConfig | 安裝中性組裝 | core 可同步;seam 只顯示 diff | 由 variant-resolved source 提供 |
| Attached reference domain | controller/serviceaccount、service/serviceaccount、entity/serviceaccount、repository/serviceaccount、security/serviceaccount | 隨 Feature 一起安裝並 stamp | 不盲覆蓋;逐檔顯示上游差異 | 可用同路徑 overlay |
因此「Feature 有 entity」的精確說法是:Feature 可以帶一個預設 entity reference
implementation,但 entity 不會因此變成可零分歧同步的 Feature contract。移除 optional Feature
時,core、設定、seed、測試與所有 referenceDomains 必須整組離開。
Seam 不是逃生門
Seam 是下游擁有的組態接合點,通常是 *Config、*Properties 或
config/feature-{id}.yml。每個 config fragment 必須登錄為同名 Feature 的 seam,並逐檔標成
defaultable 或 application-owned;它不會被 legacy sync 覆蓋,release upgrade 則依分類與
可信基線快轉或要求明確 resolution。
如果非組態檔反覆需要客製,不應只把它加入 seam:
- 反覆變異的是一個值 → 變成設定。
- 反覆變異的是一個行為點 → 抽 SPI,機制上收 capability。
- 整個資源或 API 面都會因產品而異 → 降級 reference implementation。
檔名不是判準。名為 Config 但只做固定註冊的類別仍可同步;controller 若承載產品資源的
wire contract,屬 reference implementation;只把 capability 接成固定 Actuator/health/
system handshake 的 canonical adapter,則屬 Feature core。
公開端點的所有權
產品資源的具體 URL 與 HTTP method 由 reference implementation 決定。Capability 提供 filter、 parser、service、錯誤語意與 matcher 的消費機制,不擁有應用 URL。Feature 則可以擁有固定的 application integration surface:例如 instance-scoped Actuator endpoint、health/info contributor,以及不依賴產品模型的 system handshake。判準是是否預期由消費端重塑,不是 annotation 或路徑前綴。詳見 ADR-026。
一般使用者/SPA 可達的 /api/** 採 reference-first。即使 controller 只依賴
capability,path property 也只外部化位址,不會自動交還 HTTP method、操作集合、DTO、
錯誤與 authorization。StagingFileController 就是此修訂的例子:storage 與 cleanup
機制中性,但 prepare/PUT/batch/metadata/delete 與 isAuthenticated() 是消費端 API
政策,因此 controller 與 multipart/batch 設定住 controller/file reference domain。
以 auth 為例:
| 內容 | 所屬 |
|---|---|
| JWT/resource-server 固定 filter 組裝與順序 | Capability:ResourceServerSecurity |
| API-key header 解析與 principal lookup SPI | Capability |
SecurityConfig 組裝 bean 與接受設定值 | Feature |
POST /api/v1/auth/login、POST /api/v1/auth/logout 等位址與公開政策 | Reference implementation |
AuthController、ClientTokenController 與回應 DTO | Reference implementation |
| staging upload REST、wire、multipart/batch 與授權 | Reference implementation:controller/file |
Account、service-account CRUD、API-key entity/repository | Reference implementation |
/actuator/mail、mail health 與 mail:ops 授權 | Feature |
/actuator/configcheck、/api/v1/system/info 與隨行授權 | Feature |
Reference implementation 的命名亦表達這條邊界:
ServiceAccountManagementService只負責服務帳號與 API-key 的治理生命週期,不是認證引擎。ServiceAccountApiKeyPrincipalLookup是ServiceAccountApiKey → AuthPrincipal的 capability SPI adapter,不是 API-key 管理服務。
server-tenantless 下游直接從 app-tenantless-server 同 type source 選取 required auth
與使用者選擇的 optional Feature。選擇 service-account 時,來源內容會:
- 將
app.security.m2m.require-tenant設為false; - 使用 tenantless 的 service-account account seed;
- 透過該 Feature 的
referenceDomains: ["serviceaccount"]保留附屬 reference domain; - 使用 ownership-native 的
ServiceAccountManagementPolicy。
因此 service-account 直接取得 ownership 版;未選則 core、reference domain、設定與 seed
一起裁掉,不經 variant 組裝或文字轉換。
因此框架指南出現 /api/v1/auth/... 時,那只是 app-tenant-server 的參考路徑,不是 capability
或 Feature 的固定契約。下游可以改路徑,但必須同步修改 controller mapping、security matcher、
OpenAPI、client 與測試。
公開規則要精確
Reference implementation 應以 HTTP method + exact path 宣告匿名端點,不使用
permitAll("/api/v1/auth/**")。新增在同一 prefix 下的端點會因此預設落入
authenticated(),不會靜默公開。
目前 app-tenant-server 的參考白名單是:
| Method | Path | 原因 |
|---|---|---|
| POST | /api/v1/auth/login | 取得憑證前無法先認證 |
| POST | /api/v1/auth/refresh | refresh token 由端點驗證 |
| POST | /api/v1/auth/logout | 未帶 token 也要能冪等成功,供過期 session 清理 UI 狀態 |
| POST | /api/v1/auth/token | optional service-account Feature 的 reference endpoint;M2M client credentials 由 capability 驗證 |
| POST | /api/v1/auth/exchange | 一次性 code 交換 session |
| GET | /api/v1/auth/link/consume | 簽章連結兌換 |
這份表是 reference implementation 現況,不是 Feature catalog。特別注意:
/api/v1/auth/token的 exact matcher 由ServiceAccountSecurityContributor與 controller 一起隨 optional Feature provision;requiredauth不預留此位址。permitAll只讓匿名請求到達 controller;若請求攜帶無效或過期 Bearer token,標準 resource-server filter 仍可能在 controller 前回401。- logout 刻意接受「沒有 Authorization header」並回成功;若產品要求連無效 Bearer 都忽略,
必須另外設計 bearer resolver 的排除規則,不能靠
permitAll達成。
如何設計一個新 Feature
1. 先找 capability
先問:「這段機制放進 jar,會不會奪走應用的宣告權或替換權?」
- 不會:先在
appfuse-server建 capability。 - 會:留下最小組裝點,並讓 capability 以介面接受差異。
- 涉及產品資源、資料模型或 HTTP contract:直接放 reference implementation。
新增 capability 時,同一個發版週期應交付對應 Feature、catalog entry、測試與 CHANGELOG;否則 下游升級只拿到 API,卻沒有正確接法。
2. 建立中性 slice
Java package 放在:
src/main/java/{basePkgPath}/feature/{featurePkg}/
設定片段放在:
src/main/resources/config/feature-{id}.yml
Feature 只 import:
- JDK、Spring 與第三方函式庫;
io.leandev.appfuse.*capability;- catalog 宣告的其他 Feature。
Feature 不 import root 的業務 package。對業務資料的需求應轉為 capability SPI;SPI 的具體 adapter 住 reference implementation。
3. 登錄依賴與 seam
在 source features.json 登錄:
kind:required 或 optional;requires:可由 import 與 bean 需求解釋的直接依賴;beanRequires:編譯圖看不見的 bean 依賴;referenceDomains:選填,隨 Feature provision/scaffold 的預設 reference implementation domain;其檔案可客製、sync 不盲覆蓋;seams:真正由下游擁有的組態接合檔。
不要把產品 resource controller、entity 或業務 service 登錄成 seam 來保留它們;它們應先
離開 feature/。零 drift 的 canonical application adapter 留在 core,不登錄為 seam。
app-tenant-server 目前的直接相依
下表是 source catalog 的直接邊;安裝時仍須取遞移閉包。ownership 的「支援」表示該 Feature
會進入 ownership 組裝樹並通過 I4,不代表每個產品都必須安裝它。
| Feature | kind | 直接 requires | referenceDomains | ownership |
|---|---|---|---|---|
cache | required | — | — | 支援 |
scheduling | optional | — | — | 支援 |
auth | required | cache | — | 支援 |
acting | optional | — | acting | 支援 |
calendar | optional | — | calendar | 支援 |
tenant | optional | — | tenant | 排除 |
almanac | optional | cache | — | 支援 |
audit | optional | auth | audit | 支援 |
delegation | optional | acting, auth | delegation | 支援 |
file | optional | scheduling | file | 支援(overlay) |
persistence-encryption | optional | — | — | 支援 |
mail | optional | auth, persistence-encryption | mail | 支援(overlay) |
platform-info | optional | auth | — | 支援 |
reference-data | optional | auth | referencedata | 支援(overlay) |
service-account | optional | auth, cache | serviceaccount | 支援(overlay) |
signed-link | optional | auth, cache | signedlink | 支援 |
notification | optional | auth, mail, scheduling, signed-link | notification | 支援(overlay) |
notification-tenant-override | optional | notification, tenant | notificationtenantoverride | 排除 |
impersonation | optional | acting, auth, notification, signed-link | impersonation | 支援 |
notification-tenant-override 是刻意的 reference-only micro Feature:中性貢獻契約由
notification 提供,本 Feature 只附帶 tenant 專用的範本覆寫資源域。ownership 因排除
tenant,會透過 requires 閉包一併排除它。
persistence-encryption 是應用組裝 Feature:framework jar 提供 crypto primitive,Feature
決定 root key ring 的設定 namespace 與 Spring bean。mail 以固定 purpose 取得 TextCipher,
不另增部署 key;notification 的 Outbox 預設使用 identity,沒有直接依賴。由於 notification
仍依賴 mail,provision 的遞移閉包會間接包含本 Feature;business data encryption 則由應用依
資料分類另外選配。
4. 用測試守住邊界
至少驗證:
- Feature 不依賴 reference implementation;
- required 不依賴 optional;
- capability 不依賴 app package;
- optional 功能關閉或 lookup 缺席時 fail-closed;
- reference implementation 的 exact public endpoint matrix。
如何使用 /feature
Feature 採「來源發布、下游升級」:來源端發布不可變 app release 並盤點 fleet,下游在自己的 工作區執行有損的升級交易。
:::caution 實作過渡期
ADR-012 已接受,但 /feature upgrade、Feature Changelog 與新 catalog state 尚待依 ADR 的
實作順序落地。現行 /feature sync 只視為 legacy 機制;過渡期不得用無 target 的 broadcast
write 代替正式發布/升級流程。
:::
# 來源端(唯讀盤點/發布)
/feature status
/feature status <target>
/release-app
# 下游端(寫入)
/feature provision <id> --from app-v<CalVer>
/feature upgrade [<id>] --to app-v<CalVer>
| 操作 | 用途 | 是否寫檔 |
|---|---|---|
status | 查看 fleet 或單一 target 的已裝 Feature、drift 與落後 commit | 否 |
provision | 自動判斷 absent→install、present→migrate、partial→補齊 | 是 |
upgrade | 依 app release 更新 canonical core,並對 seam 做有基線的 three-way merge | 是 |
操作紀律
- 一次 provision 一個 Feature;每一步先看 drift 分類,再 build 與執行 catalog invariant check。
provision會解requires遞移閉包,不應手動只複製部分檔案。- core 自動覆蓋只適合中性 core slice;若準備把某檔永久列為
localSeams,先判斷它是否其實應上收 capability 或降級 reference implementation。 coreRef是 per-Feature;seam 的reconciledRef是 per-file。只更新實際完成的 surface, 不以 core 成功替未處理的 seam 推進基線。- target 的模組 type 決定同 type source:
server-tenant與server-tenantless各自升級, 不跨 type upgrade;共同 app tag 只提供 release 座標。
完整的 migrate 與 drift 分類規則見 方法論 ADR-007、 ADR-012 與 ADR-010。
Review 快速判定
| 問題 | 是 | 否 |
|---|---|---|
| 每個應用都應以相同方式實作,而且錯了是技術或安全問題? | Capability | 繼續問 |
| 是應用必須看見並選擇的中性組裝嗎? | Feature | 繼續問 |
| 含 entity、repository、產品 DTO/角色詞彙/資源政策嗎? | Reference implementation | 繼續問 |
| 是只依賴 capability、且 wire/操作/授權皆無合理 retarget 需求的固定 adapter 嗎? | Feature core | Reference implementation |
| Feature 非組態檔持續 drift 嗎? | 抽 capability 或降級 reference implementation | 可維持 canonical |
| 新增公開端點只靠共用 prefix 自動放行嗎? | 改成 exact method + path | 保持 |