FR-089 · jedi-asset 插件解剖 · 21 支套件的形狀範本
一支套件要同時能「插進 Guidant AI」和「離開 Guidant AI 自己跑」。整套機制只有一句話:開機時宿主把一包東西放進袋子,之後每個 request 從袋子拿。 本頁先用五個名詞把地基釘住,再用「袋子」的比喻把這句話拆開講,然後交代主專案那側要寫什麼、每個檔各放什麼、怎麼證明套件真的能獨立跑。
整份文件只圍繞五個名詞打轉。讀之前先把它們釘住,後面就不會卡:
| 詞 | 一句話 | 在這裡具體是什麼 |
|---|---|---|
| 宿主 | 接零件的那個系統 | Guidant AI 主專案。另一個宿主是 harness(見最後一節) |
**register() |
把套件接上宿主的唯一入口**,開機時呼叫一次 | 套件的 plugin/__init__.py 裡那支函式 |
| adapters | 宿主交給套件的一包東西:驗登入的函式、驗權限的函式、取使用者的函式、建 service 的工廠 | 一個 AssetAdapters dataclass,八個欄位 |
| 袋子 | 套件收下那包東西的地方,整個 app 一份、開機裝好後唯讀 | app.extensions["jedi_asset"],裝的是 _RuntimeContext |
runtime() |
「把袋子拿出來」的動作,每次有 request 進來就呼叫 | 套件的 api/guards.py 裡那支一行函式 |
一句話串起來:開機時宿主用 register() 把 adapters 放進袋子,之後每個 request 用 runtime() 從袋子拿。 本頁其餘內容都是這句話的細節。
再兩個詞,講「套件怎麼問宿主問題」:
| 詞 | 一句話 |
|---|---|
| port | 套件宣告的一個介面:「我需要有人能回答這件事」。只有方法簽名,沒有實作 |
| adapter | 那個介面的實作:「我來回答」。答案套件自己知道就寫在套件裡,答案在別的套件手上就寫在宿主 |
這節是「檔案該放哪」的規矩,不是機制。 想先懂機制的可以跳到袋子,回頭再看這張表。
jedi-asset 是 21 支套件的形狀範本,規範正本在 SOP §4.3。六條:
| 項 | 規定 |
|---|---|
plugin/ 五檔 |
__init__(register() + 對外 re-export)/contract/runtime/assembly/migrations |
api/ 四件 |
guards.py(runtime() + 三個 lazy decorator)/routing.py(URL 表+FROZEN_URLS)/routes/(一資源一檔)/serializers/(一資源一檔)。route 檔與 schema 不得平鋪在 api/ 頂層,只有一個資源也要開子目錄 |
| 頂層不平鋪 | 套件頂層只放 __init__.py(logger + re-export)與分層目錄。判準:ls jedi_<name>/*.py 只看得到 __init__.py。沒有 route 的套件一樣適用 |
| port 位置 | 一律 domain/ports.py,不開頂層 ports/——兩處並存讀者分不清哪個是 port |
| 取執行期組件 | 函式一律叫 runtime(),不叫 ctx()(理由見下方) |
| 其餘 | logger 掛 common.jedi_<pkg>;entity 一律 dataclass 並用 inspect.signature 對照 ORM 欄位;tests/ 在套件根不放 jedi_<name>/tests/(放套件內要靠 pyproject exclude 才不進 wheel) |
jedi-oscal-v2 與 jedi-common 沒有 route、沒有 register(),套用版面與 logger 規則,但不建 plugin/ 五檔與 api/ 四件——不為了形狀開空目錄。
對外 import 路徑是契約,子模組是實作細節。
from jedi_<name>.plugin import X與from jedi_<name>.api import Y這兩條路徑不許動,內部怎麼拆都可以。
整個機制只有兩個動作,發生在兩個不同的時間。角色四種:主專案(宿主)、套件、袋子 app.extensions["jedi_asset"]、每個 HTTP request。
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
flowchart LR
subgraph boot["動作一:開機時(一次)"]
direction LR
H1["主專案 app_factory.py<br/>register(app, adapters)<br/>adapters 裡裝著:驗登入函式、驗權限函式、取使用者函式、service 工廠"]
P1["套件 plugin/__init__.py<br/>① 把 9 條 route 掛上 app<br/>② 把 adapters 塞進袋子<br/>(打包成 _RuntimeContext)"]
H1 --> P1
end
subgraph req["動作二:每次有人打 API"]
direction LR
R1["PUT /api/1.0/device/abc<br/>Flask 找到 DeviceDetailRoute.put<br/>外面包著 @auth_required 等三層殼"]
P2["套件 api/guards.py<br/>runtime()<br/>= 把袋子拿出來,就這一行"]
R1 --> P2
end
BAG[("袋子<br/>app.extensions["jedi_asset"]")]
P1 -- 放 --> BAG
P2 -. 拿 .-> BAG
袋子裡裝的東西:
| 區 | 內容 |
|---|---|
adapters |
.auth_required、.capability_required、.current_user_login_name、.identity、.reference_counter |
config |
.device_update_capability … |
services |
.device_service(工廠) |
| 方法 | reply() / service() |
主專案在開機時把它的東西交給 register(),套件放進袋子。之後每個 request 進來,route 呼叫 runtime() 把袋子拿出來,從裡面取出主專案交來的函式來用。
# plugin/__init__.py ── 開機時:放進袋子
def register(app, adapters, config=None, ...):
context = _RuntimeContext(adapters=..., config=..., services=...)
app.extensions["jedi_asset"] = context # ← 放
app.register_blueprint(bp) # ← 掛 route
# api/guards.py ── request 時:拿出袋子
def runtime():
return current_app.extensions["jedi_asset"] # ← 拿,沒有別的所以 runtime 是什麼: 它不是一個「東西」,是一個「動作」。每次 route 需要主專案給的函式,就呼叫
runtime()把袋子拿出來、從裡面取。runtime().adapters.auth_required讀作「從袋子取出主專案給的驗登入函式」。
因為 route 檔在 import 的瞬間就定型了,那時候主專案的東西還沒交過來。
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
flowchart LR
T1["① import 期<br/>Python 載入 device_route.py<br/>@auth_required<br/>def put(self, uid):<br/>decorator 已經套上了,但袋子還是空的、<br/>主專案還沒把 jwt_required 交來。<br/>現在決定守門 → 決定不了"]
T2["② register 期<br/>主專案呼叫 register()<br/>adapters 這時才到。套件放進袋子。<br/>但 ① 早就跑完了,<br/>route 上的 decorator 來不及改。"]
T3["③ request 期<br/>有人打 PUT /device/abc<br/>decorator 的殼這時才執行:<br/>runtime().adapters.auth_required<br/>袋子裡已經有東西了,拿出來套上、執行。<br/>守門在這一刻才決定 ✓"]
T1 --> T2 --> T3
解法:① 只留一個「到時候去袋子拿」的殼,把決定推遲到 ③。這個殼就叫 lazy decorator。
主專案那側直接寫 @jwt_required() 是在 ① 就綁死實作。套件不能這樣寫,因為它不知道宿主用什麼認證,而且這樣寫等於套件反過來 import 主專案。
拿生活的比喻:套件是一間出租店面,裝潢時(import)把「收銀機的位置」留好了,但收銀機本身(jwt_required)要等房客搬進來(register)才放上去。每次客人結帳(request),店員走到那個位置拿收銀機來用。runtime() 就是「走到那個位置」。
這節在講:套件怎麼在不認識宿主的前提下,做到「沒登入擋掉、沒權限擋掉」。 答案是套件只寫一個空殼,真正的判斷從袋子拿。
守門集中在 jedi_asset/api/guards.py,60 行。route 檔從 jedi_asset.api import,不需要知道它在哪一個子模組。
def auth_required(fn): # ① import 期:只做這層包裝
@functools.wraps(fn)
def wrapper(*args, **kwargs): # ③ request 期:這裡才執行
guard = runtime().adapters.auth_required # 從袋子拿主專案的 jwt_required()
return guard(fn)(*args, **kwargs) # 現場套上、現場執行
return wrapper%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
flowchart TB
subgraph wrapper["wrapper(套件在 import 期做好的殼)"]
subgraph guard["guard(主專案給的,request 期才填入)"]
G1["Guidant AI:jwt_required()<br/>harness:只看 X-Auth header"]
PUT["put(self, uid) ← 被包住的 route 方法"]
G1 --> PUT
end
end
NOTE["殼是固定的,套件寫死。<br/>虛線框的內容每次 request 從袋子取,主專案給什麼就是什麼。<br/>route 那邊一個字都不用改,因為它從頭到尾只說「去袋子拿」。"]
wrapper -.-> NOTE
@capability_required(lambda rt: rt.config.device_update_capability)
def put(self, uid): ...參數不是字串 "device.update",是一個「給我袋子、我回你名字」的函式。原因相同:能力點名住在 config(主專案可以改),而 config 在 import 期還讀不到。殼在 request 期先 runtime() 拿袋子,呼叫這個函式拿到名字,再交給主專案的 require_capability(name)。
CAPABILITIES 清單plugin/contract.py 宣告 CAPABILITIES: tuple[Capability, ...](型別 jedi_common.plugin.Capability,欄位 name/resource/action/description/default_roles/is_platform),在 plugin/__init__.py 的 __all__ 曝露。21 支共 80 筆,沒有能力點的套件宣告空 tuple。
三條硬規則:
read 也要列。 BE route 只守寫入類,但前端選單與 route_capabilities 認 read。漏了的症狀是「選單看不到這個頁面」,而且不會有任何測試變紅。DEFAULT_*_CAPABILITY 常數從清單取值(_BY_NAME["x.create"].name),不得寫死字面量。清單是唯一來源,常數只是相容用的別名。config 取名字,不在 route 寫死字串。版面守衛除了凍結名字集合、逐項檢查型別,還有一條 AST 驗 DEFAULT_* 不是字面量——🔴 這條不能在執行期用 is 比物件:CPython 會把同模組相同字面量摺疊成同一物件,改回寫死字串照樣綠。只有在原始碼層驗得出來。
def current_user_login_name():
getter = runtime().adapters.current_user_login_name
return getter() if getter is not None else None寫入審計欄位 created_user 時呼叫。沒注入回 None,審計欄位留空,但寫入不擋。它不是安全的門,安全的門是 auth_required。
runtime 在軟體界有三個常見用法,這裡取第三個:
| 用法 | 意思 | 例子 |
|---|---|---|
| 執行期(run time) | 程式「跑起來之後」的那段時間,相對於寫程式時(design time)和編譯時(compile time) | 「這個錯誤要 runtime 才會出現」 |
| 執行環境 | 讓程式跑起來所需的那層東西 | Node.js runtime、JVM、.NET runtime |
| 執行期組件 | 程式跑起來後才存在、用來支撐運作的物件 | _RuntimeContext 就是這個 |
袋子的 class 叫 _RuntimeContext,意思是「register 期才組出來、request 期才用到的東西」,import 期不存在。取用它的函式叫 runtime(),讀作「拿執行期組件」,語意對得上。
為什麼不叫 ctx: ctx 是 context 的縮寫,業界直覺是「這個 request 的環境」:Koa 的
ctx、Go 的ctx都是每個 request 一份、裝 request 資料。但這裡的袋子是整個 app 一份、開機裝好後只讀,跟 request 無關。叫 ctx 會讓人問「這是哪個 request 的 context」,答案是「不是」。有 plugin 的 19 支都用同一個名字,是全系統唯一的取法。
為什麼不用 dependency_injector 的 @inject:
@inject加Provide[Containers.asset_container.device_service]裡的Containers是主專案的 DI container,在 import 期就要寫死路徑。套件一旦這樣寫,就等於套件 import 主專案,正是抽套件要消滅的東西。runtime().service("device_service")讓套件只認一個名字,工廠從哪來是主專案的事。
plugin/ 五檔、api/ 四件,對外 import 路徑只有 plugin/__init__.py 與 api/__init__.py 兩個。
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
flowchart TB
subgraph plugin["jedi_asset/plugin/"]
PI["__init__.py<br/>register() 本體 + re-export<br/>主專案只需要認識這一個檔。117 行。"]
CT["contract.py<br/>宿主要填的表格<br/>AssetAdapters 等 5 個 dataclass<br/>+ CAPABILITIES 能力點清單"]
RT["runtime.py<br/>袋子本體 _RuntimeContext<br/>service() / reply() 兩方法"]
AS["assembly.py<br/>組裝:build_services<br/>_assert_api_wiring / create_blueprint"]
MG["migrations.py<br/>iter_migrations()<br/>讀隨包的 001 / 002 SQL"]
PI --- CT
PI --- RT
PI --- AS
PI --- MG
end
subgraph api["jedi_asset/api/"]
AI["__init__.py<br/>只 re-export,20 行<br/>route 檔一律 from jedi_asset.api import …"]
GD["guards.py<br/>runtime() 拿袋子<br/>三個 lazy decorator"]
RO["routing.py<br/>mount_routes() 9 條 URL<br/>FROZEN_URLS 凍結清單"]
RS["routes/<br/>device_route.py(5 條)<br/>information_system_route.py(4 條)<br/>serializers/ request / response schema"]
AI --- GD
AI --- RO
AI --- RS
end
RT -. "guards.runtime() 拿的就是 runtime 的袋子" .-> GD
runtime.py 與 guards.py 是一對:runtime.py 定義袋子長什麼樣,guards.py 負責拿。主專案與 harness 只接觸 plugin/__init__.py;route 只接觸 api/__init__.py。
contract.py(132 行)— 宿主要讀的東西全在這五個 dataclass + 能力點清單 + 常數,只有型別宣告、沒有任何邏輯。接這支套件的人打開它就知道要填什麼:
| dataclass | 是什麼 | 關鍵 |
|---|---|---|
AssetAdapters |
宿主要填的八格表——三個守門(auth_required/capability_required/current_user_login_name)、四個選填 port(identity/reference_counter/user_directory/response_builder)、一個 service 名冊 |
守門三件刻意無預設值:預設放行會讓「忘記傳」變成九條無聲的公開端點,而設備清冊含主機名、IP、OS 版本 |
AssetServices |
service provider 名冊 | 🔴 存 provider 不存實例——service 內含 session-bound repo,存實例會讓第一個 request 建的物件用到天荒地老,平常看不出來、併發下才爆 |
AssetPluginConfig |
純參數值(URL 前綴、blueprint 名、六個能力點名) | 不含行為,行為一律在 adapters。六個能力點名的預設值是對外契約(FE 權限矩陣與 DB capabilities 表都認這些字串),開成欄位是讓宿主可覆寫,不是要換掉預設 |
SchemaExtensions |
宿主要多收/多吐欄位時的 hook | 插槽開齊,目前無產品使用 |
PluginHandle |
register() 的回傳值 |
mount_api=False 時 blueprint 為 None |
🔴 這個檔不得 import flask(有 AST 守衛盯著):契約型別是 consumer 讀的東西,要能在沒裝 web framework 的情境下 import 得起來。需要 Blueprint 型別走 TYPE_CHECKING。
runtime.py(52 行)— 袋子本體一個 _RuntimeContext dataclass,裝四樣:adapters、config、schema_extensions,以及解析後的 services。兩個方法:
service(name) — 依名字取一個 service 實例(呼叫 provider)。provider 沒接線時當場炸而不是回 None——回 None 會讓錯誤延後到 None.get_device() 那行,訊息看起來像「service 有 bug」而不是「你漏接線」。reply(ok, data) — 組回應信封,宿主沒指定就退回 jedi-common 內建(形狀一致)。⚠️
services欄位存的是解析後的名冊,不可改成直接讀adapters.services——那樣「宿主沒給 provider」就等於 service 不存在,而套件自己組得出來。
assembly.py(118 行)— 把 adapters 變成能用的東西三支函式,create_blueprint() 是主線,另外兩支是它的步驟:
build_services(adapters) — 宿主沒給 service provider 時,用 adapters 的 port 自己組一份。🔴 這支不可以省:三張選填 port 真正的消費者是兩支 service 的建構子,而 service 通常由宿主 DI 容器建;少了這條路,只照 README 接 port 的 consumer 會得到「register() 成功、九條 API 全綠,但暱稱恆空、引用計數恆 0、負責人存不進去」——三者降級都是安靜回空值,症狀像資料掉了而不是漏接線。_assert_api_wiring(adapters) — 掛 route 前先確認守門三件齊備,缺了當場拒絕掛載。🔴 不可改成「有就用、沒有就跳過」:跳過等於九條端點裸奔,而服務照常起得來、健康檢查照樣綠燈,是看不見的失效。create_blueprint(...) — 建 blueprint 但不掛載,供 consumer 自行 register_blueprint。執行期組件用 record_once 延後到掛上 app 那一刻才寫進 app.extensions,所以這支不吃 app 參數——同一套件掛到多個 app 時各自一份 context,不互相污染。
Blueprint()第二參數寫死"jedi_asset.plugin"不用__name__: blueprint 的root_path吃這個值,不釘住的話拆檔會讓資源路徑跟著搬家。
migrations.py(18 行)— 隨包 SQL 的出口MIGRATIONS_DIR + iter_migrations(),回 [(檔名, SQL), ...] 依檔名序(三位數序號決定套用順序)。套件只提供、不執行:何時套、套進哪個庫、怎麼記錄已套過,全是宿主的維運決定。
MIGRATIONS_DIR要parent.parent——SQL 住在套件根的migrations/,不在plugin/底下,子套件深一層。
__init__.py(119 行)— register() 本體與唯一對外門面register(app, adapters, config, schema_extensions, mount_api) + 對外 re-export(__all__ 列齊,版面守衛會比對)。register() 本身很短:組 adapters 與 config,然後分兩條路——
mount_api=True:create_blueprint() 後 app.register_blueprint(bp)mount_api=False(把插件當 library 用):🔴 仍然必須寫 runtime context。掛路由那條路靠 blueprint 的 record_once 寫入,而 record_once 只在 blueprint 真被掛上時才觸發;少了這段,「不掛路由」與「拿得到 runtime()」會變成綁死的二選一。guards.py(64 行)— runtime() + 三個 lazy decorator本頁前面那節整節在講它:runtime() 一行把袋子拿出來,auth_required/capability_required/current_user_login_name 三個殼在 import 期做好、request 期才去袋子取主專案給的實作。
routing.py(59 行)— URL 的唯一真相mount_routes(bp) 把九條 route 掛上 blueprint,這份對照表是對外 URL 的唯一真相;FROZEN_URLS 是對應的凍結清單,測試拿它與實際掛載結果做集合比對。
/devices 與 /information-systems/list——那是實況,統一即為 breaking change。/device 只承載 POST:get/put/delete 都要 uid,打裸路徑必 500。routes/(一資源一檔)— flask-restful Resourcedevice_route.py 五條、information_system_route.py 四條。每個 class 命名 <Type><動作>Route,方法上疊三層殼(登入/能力點/取操作者),本體只做「驗欄位 → 叫 service → 包信封」。
serializers/(一資源一檔)— marshmallow schemadevice.py、information_system.py,request 與 response schema 都在。欄位是 FE 契約,改欄位名等同改 API。
只有一個資源也要開子目錄。 route 檔與 schema 不得平鋪在
api/頂層——讀者要能用同一條路徑在 21 支套件裡找到東西。
jedi_asset/
├── plugin/ ← 宿主接觸面
│ ├── __init__.py register() + re-export 全部公開名字
│ ├── contract.py AssetAdapters / AssetPluginConfig / AssetServices / SchemaExtensions / PluginHandle + CAPABILITIES + DEFAULT_* 常數
│ ├── runtime.py _RuntimeContext(袋子)
│ ├── assembly.py build_services / _assert_api_wiring / create_blueprint
│ └── migrations.py iter_migrations()
├── api/
│ ├── __init__.py re-export
│ ├── guards.py runtime() + auth_required / capability_required / current_user_login_name
│ ├── routing.py mount_routes() + FROZEN_URLS
│ ├── routes/ DeviceListRoute / DeviceDetailRoute / DeviceCreateRoute / DeviceMenuRoute / DeviceReferenceRoute;InformationSystem 同型四支
│ └── serializers/ marshmallow schema,欄位是 FE 契約
├── app/
│ ├── dto/ DeviceDTO / InformationSystemDTO / 兩個 MenuDTO
│ └── service/ DeviceService / InformationSystemService(@transaction);_audit_names.py 補暱稱共用一份
├── domain/
│ ├── entity/ 六個 @dataclass entity
│ ├── repository/ IDeviceRepo / IInformationSystemRepo
│ ├── service/ 兩支 DomainService,屬性統一 self._repo
│ └── ports.py IAssetReferenceCounter / IUserDirectory / _NullUserDirectory
├── infra/
│ ├── models/ Device(public.devices)、InformationSystem(compliance.information_systems)
│ ├── repository/ 兩支 RepoImpl,session 用 lazy property
│ └── mapper/ model ↔ entity
├── common/
│ ├── error_code.py ErrorCode(BaseCode 不是 Enum;字串凍結)
│ └── enum/ AssetType + 四個 FIPS-199 enum
└── migrations/ 001 建表(冪等)、002 RLS+GRANT(需宿主生態)
harness/
├── dev_app.py 最小宿主:假 adapters + 兩張樁表 + --migrate / --smoke
└── docker-compose.yml 專屬 postgres 5493
SSP 序列化不在本套件,屬 jedi-oscal-v2。
這節是宿主那一側:主專案要寫什麼,套件才會活起來。
接觸面只有一個檔:core/plugins/asset.py——adapter、填表、掛載三段都在裡面, 接手的人打開它就知道這支套件接了什麼。
# ① 這支套件開的 port,主專案怎麼答
class AssetReferenceAdapter(IAssetReferenceCounter): ...
class AssetUserDirectory(IUserDirectory): ...
# ② 填表
def build_adapters(container):
asset = container.asset_container
return AssetAdapters(
services=AssetServices(
device_service=asset.device_service, # 🔴 沒括號:存工廠不存實例
information_system_service=asset.information_system_service,
),
identity=container.identity_container.identity(),
reference_counter=asset.asset_reference_counter(),
user_directory=asset.user_directory(),
**host_defaults(), # 守門三件+信封,六支套件共用的那組答案
)
# ③ 掛載
def mount(app, container, host):
from jedi_asset.plugin import register
register(app, build_adapters(container), mount_api=True)
PLUGIN = Plugin(
name="asset",
mount=mount,
capabilities=jedi_asset.plugin.CAPABILITIES, # 傳引用,不抄字串
)🔴
capabilities=必帶。Plugin.capabilities預設None專門表達「沒填」並判紅;沒有能力點的套件傳空 tuple。這個區分是必要的:能力點列表 seed 在主專案scripts/init/04-seed-core.sql,套件宣告了卻沒登記進 seed,症狀是客戶裝好後對應操作永遠 403,沒有任何錯誤訊息。test/test_module_boundaries.py的五條守衛把 21 支宣告的 80 筆對 seed 的 127 筆分四類(套件 80/主專案 36/疑廢 11/待裁report1)並要求零未歸類;第五條守的是豁免名單本身,否則新抽出來的套件會靜靜落在掃描範圍外。
存工廠不存實例:
asset.device_service是providers.Factory,每 request 呼叫一次建一個新的 service,內含 session-bound repo。加了括號變成傳實例,第一個 request 建出來的物件會服務到天荒地老,平常看不出來、併發下才爆。
PLUGINS: list[Plugin] = [
identity.PLUGIN, file_upload.PLUGIN, integrity.PLUGIN, notification.PLUGIN,
system_core.PLUGIN, issue.PLUGIN, api_log.PLUGIN, asset.PLUGIN,
participant.PLUGIN, bulletin.PLUGIN, survey.PLUGIN,
]container.wire(modules=di_modules) # 一定要先跑
...
for plugin in PLUGINS:
plugin.mount(app, container, plugin_host)必須在 container.wire() 之後,因為 adapters 要從 DI container 取工廠。把某支從 PLUGINS 拿掉就是「拔掉測試」:該模組端點全 404,但資料和 domain service 不受影響。
jwt_required()、require_capability、current_user_login_name、return_response 這四樣多數套件填的內容逐字相同,統一收在 core/plugins/_host.py 的 host_defaults()。
它存在的理由不是少打字,是改授權模型時只有一個落點——各填各的話,漏改一支的症狀是那支套件的端點守門與別人不一致,而且不會有任何錯誤訊息。
不是每支都收這四樣,所以不能無腦
</strong>host_defaults()**:SystemCoreAdapters沒有current_user_login_name、FileUploadAdapters沒有capability_required,多塞一個 key 就是 TypeError。收得齊的那兩支(asset/bulletin)用 splat,其餘按 key 取。
這節在講:套件需要「別的套件才知道的答案」時怎麼辦。 例如資產套件想知道「這台設備被幾個稽核任務用到」——答案在任務套件手上,而套件之間不准互相 import。
規則是「介面在 domain、實作在 infra」,套件和主專案都適用。差別只有一個:答案套件自己知道的,實作寫在套件;答案在別的套件裡的,實作寫在主專案。
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
flowchart LR
subgraph pkg["套件 jedi_asset/"]
direction TB
DOM["domain/ — 只有「我要什麼」,全是 abstract<br/>repository/device.py:IDeviceRepo(get_by_uid / add / update / get_menu …)<br/>ports.py:IAssetReferenceCounter(count_references(asset_type, id) → int)<br/>ports.py:IUserDirectory(get_id_by_uid(uid) / get_display_by_ids(ids))"]
INF["infra/ — 套件自己答得出來的,實作在這<br/>repository/device_repo_impl.py<br/>DeviceRepoImpl(IDeviceRepo):設備存在 public.devices,套件知道"]
GAP["套件答不出來的兩張 port<br/>「這台設備被幾個稽核任務引用」→ 答案在 jedi-task-platform 的三張表<br/>「負責人的 uid 對應哪個 users.id、暱稱是什麼」→ 答案在 jedi-iam<br/>套件不准 import 那兩支,只能留空等主專案填"]
INF -- 實作 --> DOM
end
subgraph host["主專案"]
direction TB
OTHER["別的套件<br/>jedi_task_platform:三張 job↔device 關聯表<br/>jedi_iam:users 表、UserDomainService"]
HINF["core/plugins/asset.py 的 ① 段<br/>AssetReferenceAdapter(IAssetReferenceCounter) → 問 jedi_task_platform.DeviceReferenceQuery<br/>AssetUserDirectory(IUserDirectory) → 問 jedi_iam"]
HINF -- adapter 去問 --> OTHER
end
HINF -- 實作 --> GAP
主專案可以認識所有套件(它是組裝根),套件之間不准互相 import。所以「跨套件的膠水」只能住在主專案。
三個介面都在套件的 domain 層。DeviceRepoImpl 在套件的 infra,因為答案是套件自己的表。另外兩個 adapter 在主專案,因為答案在 task-platform 與 iam,套件不准認識它們。
它們住哪:core/plugins/asset.py 的 ① 段,與接線同一個檔。 按分層純度,adapter 是接外部系統的膠水、該歸 infra/;但拆兩處的代價是接手的人要翻兩個地方才知道這支套件接了什麼。決策者裁定:一個檔看完,優先於分層純度。
唯一的例外是「別的層也在 import 它」:搬進 core/plugins/ 會讓 app/ 或 di_containers/ 反向 import 組裝根,形成循環。例如 infra/upload_file/remote_agent_adapter.py 被 app/upload_file/service/ 直接用,就留在 infra。這類在守衛的白名單裡逐支登記,寫明誰在用它。
| 介面(domain) | 實作(infra) | 實作住哪 | 為什麼 |
|---|---|---|---|
IDeviceRepo |
DeviceRepoImpl |
套件 | 設備存在哪張表,套件自己知道 |
IAssetReferenceCounter |
AssetReferenceAdapter |
主專案 core/plugins/asset.py |
答案在 task-platform,套件不准認識它 |
IUserDirectory |
AssetUserDirectory |
主專案 core/plugins/asset.py |
答案在 jedi-iam,同上 |
三個都是「介面在 domain、實作在 infra」,repository 和 port 是同一個模式。叫 port 只是為了標記「這扇門通到套件外面」。判準:有 @abstractmethod 的是介面;答案要跨套件的,實作寫在主專案。
那一段是主專案側的組裝表。套件在 plugin/contract.py 宣告了一張 AssetAdapters 表格(八個欄位:三個守門、一個工廠名冊、三張 port、一個信封建造器),build_adapters() 負責把主專案的東西一格一格填進去,回傳填好的表格給 register()。
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
flowchart LR
subgraph mat["主專案手上的材料"]
direction TB
M1["jwt_required()"]
M2["common.authz.require_capability"]
M3["get_user_context().login_name"]
M4["同檔 ① 段的兩支 adapter"]
M5["DI container 的 Factory"]
end
W["core/plugins/asset.py ② 段<br/>build_adapters(container)<br/>把左邊的材料一格一格填進套件宣告的表格<br/>全是「欄位=材料」,沒有邏輯"]
subgraph tbl["AssetAdapters(套件 plugin/contract.py 宣告)"]
direction TB
F1["auth_required"]
F2["capability_required"]
F3["current_user_login_name"]
F4["reference_counter"]
F5["user_directory"]
F6["identity"]
F7["services"]
F8["response_builder"]
end
M1 --> W
M2 --> W
M3 --> W
M4 --> W
M5 --> W
W -- 填 --> tbl
填好的表格交給 register(app, adapters)。之後套件裡的 route 用 runtime() 從袋子拿的,就是這張表格。
每支套件在主專案都有一支 core/plugins/<pkg>.py,形狀都一樣:① adapter ② 填表 ③ 掛載。它放 core/ 不放 api/ 或 app/,因為它是組裝根的一部分,被 app_factory 的迴圈呼叫、又要 import 守門與 DI container。
所以主專案接一支套件,寫的東西只有兩種:adapter(回答套件答不出來的問題)和填表(把 adapter 與其他材料裝進表格),兩者住同一個檔。runtime、register、袋子都在套件裡,主專案不碰。
以 jedi-asset 為例,照這個順序做,做完 API 就長出來。每一步寫「檔案在哪、加什麼、為什麼」。
為什麼是三處: adapter 與填表同住一個檔、掛載是清單加一列,於是必做的只有這三處,第四處看情況。
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
flowchart LR
S1["① pyproject.toml<br/>宣告依賴"]
S2["② core/plugins/asset.py<br/>① adapter ② 填表 ③ 掛載<br/>一個檔看完"]
S3["③ core/plugins/__init__.py<br/>PLUGINS 加一列"]
S4["(④ 看情況)di_containers/<br/>只有主專案別的模組<br/>要直接用套件 domain service 才加"]
LOOP["core/app_factory.py<br/>for p in PLUGINS: p.mount(...)<br/>已經寫好,不用改"]
PKG["jedi-asset<br/>register(app, adapters)"]
S1 --> S2
S4 -.-> S2
S2 --> S3
S3 --> LOOP
LOOP --> PKG
# [project] dependencies
"jedi-asset==0.1.0",
# 開發期改 path dependency(不 commit),改動套件源碼重啟 BE 即生效
# [tool.poetry.dependencies]
jedi-asset = { path = "/Users/…/jedi-python-package/jedi-asset", develop = true }然後 poetry update。不要用 poetry lock,會卡很久。
照抄任一支現成的,改名字就好。三段分別是:
① 這支套件開的 port,主專案怎麼答。 套件在 domain/ports.py 宣告抽象介面,主專案給實作——這是「主專案告訴套件它不知道的事」。
class AssetReferenceAdapter(IAssetReferenceCounter):
def __init__(self, device_reference_query=None):
self._q = device_reference_query # 來自 jedi-task-platform
def count_references(self, asset_type, asset_id):
if asset_type != AssetType.HARDWARE: return 0 # 資訊系統無外鍵引用,實查 0 條
return self._q.count_by_device_id(asset_id)
class AssetUserDirectory(IUserDirectory):
def __init__(self, user_domain_service): # 來自 jedi-iam
self._users = user_domain_service
def get_id_by_uid(self, uid): ...
def get_display_by_ids(self, ids): ...宿主 import 其他套件(task-platform、iam)是合法的,宿主是組裝根,它的工作就是把各套件接起來。疆界規則管的是套件之間。
② 填表。 把材料裝進套件宣告的 AssetAdapters:
def build_adapters(container):
asset = container.asset_container
return AssetAdapters(
services=AssetServices(
device_service=asset.device_service, # 工廠,不加括號
information_system_service=asset.information_system_service,
),
identity=container.identity_container.identity(),
reference_counter=asset.asset_reference_counter(),
user_directory=asset.user_directory(),
**host_defaults(), # 守門三件+信封
)③ 掛載。 呼叫套件的 register(),檔尾宣告 PLUGIN(capabilities= 必帶,見主專案接法):
def mount(app, container, host):
from jedi_asset.plugin import register
register(app, build_adapters(container), mount_api=True)
PLUGIN = Plugin(name="asset", mount=mount, capabilities=jedi_asset.plugin.CAPABILITIES)為什麼
mount()自己呼叫 register,而不是讓迴圈組裝? 實查十一支後發現六支的register()形狀都不一樣:身分要讀app.config、上傳與操作日誌要多一個 config 物件、完整性要兩個具名參數且沒有mount_api、人員指派的簽名是(app, container)、問卷只在 socketio 模式掛。逃生口若有一半的人要走,主路徑就是錯的形狀——改成每支自己說怎麼掛,特殊處留在自己那個檔裡看得到,不散成迴圈上的一串 if。
PLUGINS: list[Plugin] = [
identity.PLUGIN, file_upload.PLUGIN, ..., asset.PLUGIN, ...
]順序就是掛載順序。已知有實質依賴的兩處:identity 必須早於 file_upload(上傳的身分名冊用到 auth_container),api_log 必須晚於 configure_logging()(否則 dictConfig 會把轉發鏈的 handler 丟掉)。其餘沒有理由不要重排。
core/app_factory.py 的迴圈已經寫好,不必改。
只有在「主專案其他模組要直接用套件的 domain service」時才做這一步。 純掛 API 不需要。
jedi-asset 需要,因為 flow_control、module_frame、associations 共 6 處直接 Provide[asset_container.device_domain_service]。
class AssetContainer(containers.DeclarativeContainer):
auth_container = providers.DependenciesContainer() # 要用到 jedi-iam 的 user_domain_service
identity = providers.Singleton(IdentityContext, user_name_resolver=…) # 補暱稱用
device_repo = providers.Singleton(DeviceRepoImpl)
device_domain_service = providers.Factory(DeviceDomainService, device_repo=device_repo)
asset_reference_counter = providers.Factory(AssetReferenceAdapter, device_reference_query=…)
device_service = providers.Factory( # ← 這個就是之後交給套件的「工廠」
DeviceService,
device_domain_service=device_domain_service,
identity=identity,
reference_counter=asset_reference_counter,
)再掛進總 container:
asset_container = providers.Container(AssetContainer, config=config.asset, auth_container=auth_container)陷阱:identity 必須在這裡注入。 補暱稱是套件的 app service 做的,所以建 service 時就要給。只放進 ② 的 adapters 不放這裡,症狀是
created_user_name恆為 null 而 API 照常 200——沒有錯誤訊息,只能手測看得出來。
重啟 BE,九條 /api/1.0/devices* 端點就在了。打 GET /api/1.0/devices/menu 帶 token 回 200、不帶回 401;新增一筆看 created_user_name 有暱稱。
拔掉測試: 把該支從
PLUGINS拿掉重啟,九條端點應全部 404,其他功能照常。這是 D6 契約的驗收條件:套件拔掉不能拖垮宿主。
守衛(test/test_module_boundaries.py)會擋五種走鐘:adapter 放回 infra/、套件上移後主專案目錄長回來、插件檔缺 mount() 或 PLUGIN、PLUGIN.name 與檔名對不上、capabilities 沒填或對不上 seed。
這節回答一個常被問的問題:「套件是不是自己搞了一套 DI?」不是。 整個系統只有一個 DI 容器,在主專案;套件沒辦法直接拿到它,所以主專案把容器建好的東西打包遞過去。差別只在最後一步怎麼遞。
| 詞 | 意思 | 誰在用 |
|---|---|---|
| 依賴反轉(DIP) | 原則。高層不依賴低層,兩者都依賴抽象介面。Java 圈的「介面在上、實作在下、外面塞進去」就是它 | 主專案與套件都用,一路到底 |
| 依賴注入(DI) | 做法。物件需要的東西由外面給,不自己建。建構子收參數就是最基本的形式 | 主專案與套件都用 |
| DI 容器 | 工具。幫你算依賴順序、建實例。這裡是 dependency_injector 的 Container | 只有主專案有,套件不知道它存在 |
所以「套件不用 DI」這句話是錯的。套件用 DIP 也用 DI,只是沒有 DI 容器。harness 沒容器就三行手寫,效果一樣。
主專案的 route 拿 service 是這樣寫的:
from di_containers.containers import Containers # 主專案的類別
class DeviceRoute(MethodResource):
@inject
def put(self, uid, service=Provide[Containers.asset_container.device_service]):Provide[Containers...] 在 Python 載入檔案的那一刻就要求 Containers 存在。套件的 route 檔若這樣寫,套件就 import 了主專案,「套件不認識宿主」這條線立刻破,抽套件的意義就沒了。
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
flowchart LR
subgraph host["主專案"]
direction TB
DI["DI 容器(唯一一個)<br/>建主專案自己的 service、repo<br/>也建套件要的 adapter 與套件 service 的工廠"]
HR["主專案自己的 route<br/>@inject + Provide[Containers.xxx]<br/>直接認識容器,沒問題"]
end
subgraph pkg["套件(不准 import 主專案)"]
direction TB
BAD["route 寫 @inject?<br/>Provide[Containers...] 要 import 主專案<br/>→ 過不了那條線,守衛紅"]
GOOD["route 寫 runtime()<br/>從袋子拿主專案遞過來的包裹<br/>不需要認識容器"]
end
DI -- "register(app, adapters)<br/>把容器建好的東西打包遞過去" --> GOOD
DI -. "@inject 過不了邊界" .-x BAD
左邊主專案的 route 直接用 @inject,右邊套件的 route 不能。同一個容器建的東西,差別只在最後怎麼遞到 route 手上。
| 做法 | 為什麼不行 |
|---|---|
套件自己定義一個 Container,主專案 override() 它 |
套件就依賴 dependency_injector 這個套件。第二個產品若用別的 DI 框架或不用框架,裝不了。而且 override 要主專案認識套件容器的內部結構 |
Provide["asset.device_service"] 用字串路徑 |
字串還是綁定某個容器的命名,且 container.wire() 要列出套件內部的 route 模組名,等於主專案認識套件內部結構 |
主專案在 before_request 把 service 塞進 Flask 的 g |
這就是 Service Locator,跟 runtime() 是同一件事,只是換個袋子 |
第三條看清楚就知道:現在的做法就是「用 DI 一起處理」的結果。容器照樣建所有東西,只是最後遞給套件的那一步不能用 @inject,改用 register() 遞一個包裹。差別只在遞送方式。
Spring 是整個 Java 生態的事實標準,套件和宿主用同一個容器是預設。Python 沒有這種標準:dependency_injector、injector、punq、不用框架都常見。套件要「隨插即用」就不能假設宿主用哪一個,所以只能收建構子參數、不綁容器。
一句話: 同一個 DIP 原則、同一個 DI 容器、多一個包裹讓套件不用認識容器。
register()是主專案遞包裹、runtime()是套件拆包裹,兩個加起來就是那最後一哩路。
這節回答「這套東西會不會太複雜」:不會,接一支要寫的只有三個地方、約 270 行,其中大半是註解。
3 個必做的地方(第四個看情況),jedi-asset 這支約 270 行。
| 地方 | 行數 | 可不可以省 |
|---|---|---|
| ① pyproject.toml | 1 | 不能 |
| ② core/plugins/asset.py(① adapter ② 填表 ③ 掛載) | 167(含大量註解) | 不能,這是主專案的知識 |
| ③ core/plugins/__init__.py 加一列 | 1 | 不能 |
| (④)di_containers/asset/ + containers.py | 約 100 | 只有別的模組要用套件 domain service 才需要 |
core/app_factory.py 那一行不再需要——迴圈已經寫好。全部十一支插件檔合計約 2160 行, 其中身分那支佔 646 行(登入編排、租戶開通、政策來源都在那條路上),其餘十支平均約 150 行。
收斂本身的帳:core/app_factory.py 從 662 行降到 450 行(11 段 register、222 行 收成一個四行迴圈);散在 infra/ 的九支 adapter 檔與六支 core/*_wiring.py 併成十一個檔。 行數總量沒有明顯下降(註解與 docstring 都逐字保留),省的是「要翻幾個地方」: 從三到五處變成一處。
看 ④ 和 ②,identity、reference_counter、user_directory 三樣東西兩邊都有:DI container 把它們注進 service 建構子,build_adapters() 又把它們放進 adapters。core/plugins/asset.py 自己的註解承認「兩處給同一支 resolver」。
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
flowchart LR
subgraph c3["④ DI container"]
C3["device_service = Factory(DeviceService,<br/> identity=identity, ← 用到<br/> reference_counter=counter) ← 用到<br/><br/>主專案有給 services 工廠,所以套件用的是這裡建的 service"]
end
subgraph c5["② core/plugins/asset.py 填表段"]
C5["AssetAdapters(<br/> identity=IdentityContext(…), ← 沒人讀<br/> reference_counter=…, ← 沒人讀<br/> user_directory=…) ← 沒人讀<br/><br/>只有「主專案沒給工廠」時 build_services 才會讀"]
end
Guidant AI 走的是左邊那條路,右邊三行在本產品是死的。它們存在是因為 README 教第二個產品「只給 port、不給工廠」時要走右邊。兩邊都填是保險,不是需要。
套件內唯一讀 adapters.identity/reference_counter/user_directory 的地方是 plugin/assembly.py 的 build_services(),邏輯是「宿主給了工廠就用宿主的,沒給才用這三個 port 自組」。Guidant AI 在 core/plugins/asset.py 的 build_adapters() 裡有給工廠,所以自組那條路永遠不會跑,三個欄位存進袋子後沒有任何人讀。主專案側也沒有任何地方從袋子讀它們。
| 刪掉後 | 結果 |
|---|---|
| Guidant AI 行為 | 一模一樣,零影響 |
| 開機檢查 | _assert_api_wiring 只查守門三件,不查這三項,不會炸 |
| 失去的保險 | 若日後有人把 services= 那段也拿掉,套件退回自組路徑,這三項缺了會讓暱稱、引用數、負責人安靜地變空,API 照常 200 |
| 測試覆蓋 | 目前沒有任何測試守「主專案有沒有給 services」。要刪就該補一支:斷言 build_adapters(container).services.device_service is not None |
結論:可以刪,但不急。 實際內容是「刪三行+補一支守衛測試+README 明寫『有 DI 的宿主給工廠,沒 DI 的宿主給 port,不要兩邊都給』」。它不是 bug、不影響行為、不影響資安掃描。真正的價值是那條規則本身:有 DI 的宿主給工廠,沒 DI 的宿主給 port,不要兩邊都給。
另一個方向「主專案不給工廠、讓套件自組」不可行:主專案 flow_control、module_frame、associations 共 6 處直接 Provide[asset_container.device_domain_service] 用 domain service,那些 provider 還是得留在 DI container,省不到多少。
core/app_factory.py 掛全部插件只有四行:一個 for 逐支呼叫 mount()。共通說明 (順序就是掛載順序、兩處實質依賴)只寫一份在迴圈上方;各支特有的陷阱(例如任務平台 兩半掛法不同、問卷只在 socketio 模式登記)留在自己那個檔的 mount() docstring 裡——那才是下一個改它的人會打開的地方。
Plugin(name, mount) 讓每支自己說怎麼掛,而不是由迴圈組裝參數。理由見 新手教程 ② 的註:六支的 register() 簽名各不相同,統一組裝的形狀套不進去。
這節給想確認「這是不是自己發明的怪東西」的人看:不是。 主軸是一個業界標準框架(六角架構),其餘六樣是它落地時必然長出來的零件,每個都有出處。
業務核心在中間,所有與外界的接觸都經過「port」(介面),外界透過「adapter」(實作)接上來。DB、HTTP、認證系統、別的套件,全部是外界。Alistair Cockburn 2005 年提出,與 Clean Architecture、Onion Architecture 是近親。
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
flowchart LR
subgraph hex["六角(套件 jedi_asset/)"]
direction TB
IN["api/ 進入 adapter<br/>HTTP → 呼叫核心"]
CORE{{"app/ + domain/<br/>業務核心<br/>ports.py 在這<br/>不知道 HTTP、DB、宿主是誰"}}
OUT["infra/ 出去 adapter<br/>核心 → 呼叫 DB"]
IN --> CORE --> OUT
end
A1["主專案 core/plugins/asset.py ①<br/>AssetReferenceAdapter"]
A2["主專案 core/plugins/asset.py ①<br/>AssetUserDirectory"]
W1["主專案 core/plugins/asset.py ②<br/>jwt_required 等守門"]
W2["harness dev_app.py<br/>假守門、假 port"]
A1 -- 實作 port --> CORE
A2 -- 實作 port --> CORE
W1 -- 填 adapters --> CORE
W2 -- 填 adapters --> CORE
DDD 的四層與六角架構是同一件事的兩種畫法:DDD 講 api/app/domain/infra 是分層,六角講「核心在中間、adapter 在外圈」是同心圓。主專案本來就是這樣,套件只是把同一結構縮小成可拆的一塊。
| 這裡的東西 | 對應的模式 | 在六角架構裡的角色 | 有沒有選擇 |
|---|---|---|---|
domain/ports.py 兩個 ABC + 主專案 core/plugins/asset.py 實作 |
Ports and Adapters(Cockburn 2005) | 框架本身 | 這就是主軸 |
AssetAdapters 這張表格 |
Parameter Object + DDD 的 Anti-Corruption Layer 精神(Fowler;Evans) | 把一堆 adapter 打包成一個參數,宿主在邊界把自己的世界翻譯成套件認得的形狀 | 不打包就要傳八個參數 |
register(app, adapters) |
Plugin 註冊入口(Fowler《企業應用架構模式》) | 套件接上宿主的唯一入口 | 任何插件系統都有一個 |
_RuntimeContext 存進 app.extensions,runtime() 取 |
Service Locator(Fowler 2004) | route 層拿 adapter 的方法 | 唯一有替代方案的,但 flask-restful 的方法簽名固定塞不進 DI,Flask 生態慣例就是這樣 |
runtime().service("device_service")() 傳工廠不傳實例 |
Abstract Factory 簡化版(GoF) | 每 request 建新的 service | service 內含 session,Flask + SQLAlchemy 的既定限制 |
_NullUserDirectory |
Null Object(Woolf 1996) | 選填 adapter 缺了時的預設實作 | 不用它就要到處 if x is None |
contract.py 五個 dataclass 只放型別不放行為 |
Interface Segregation(SOLID 的 I) | 契約與實作分離,consumer 不裝 flask 也能 import | 不分就得為了讀型別裝整套 web framework |
所以不是「混了七種模式」,是「一個框架加上它落地時必然長出來的六個零件」。每個零件拿掉都會在別的地方付出代價。
| 同型的東西 | 對照 |
|---|---|
| Flask 擴充套件慣例(Flask-SQLAlchemy、Flask-Login、Flask-JWT-Extended) | 全部是 ext.init_app(app) 一個入口、狀態存 app.extensions[name]、用時從那裡取。register() 與 runtime() 就是這個慣例,只是多了一張 adapters 表格讓宿主填。 |
| pytest plugin、Django app | 「套件宣告需要什麼、宿主在註冊時提供」。 |
| Spring Boot Starter、Go Wire | starter 就是「一個套件附帶自己的 auto-configuration,宿主只要加依賴」,與 D6 契約「安裝後就有這個 API」是同一個目標。 |
| Clean Architecture、Onion Architecture | 六角架構的近親,同心圓、依賴向內。 |
真正業界少見的是「同一支套件同時要能插進大宿主、又要能零宿主獨立起來」這個要求。harness 是為了驗證這件事而加的,多數 Flask 擴充不會做到這步。
app.extensions 是 Flask 的全域字典,等於套件狀態放在全域變數裡。Flask 生態接受這件事(current_app、request 本身就是全域)。換到 FastAPI 或 Django 要重做 register() 與 runtime() 那層,但 app/domain/infra 三層不用動。這正是六角架構的承諾:換外圈不動核心。建不建議在程式碼裡改名去「符合模式」:不建議。 現在的名字(adapters、ports、register、build_services)已經是這些模式的慣用詞。模式是用來溝通的,不是用來遵守的。這張表放進 SOP §4.3 或套件 README 讓新人查出處就夠。
這節在講:怎麼證明「套件真的能離開 Guidant AI 自己跑」。 光用說的沒有人能確認,所以套件自己附帶一個假宿主,跑起來就知道。
harness 是一個 100 多行的 Flask 程式,不是測試框架。它扮演主專案的角色把套件接起來——如果套件偷偷 import 了主專案的東西,它就起不來。
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
flowchart LR
H1["宿主一:Guidant AI<br/>core/plugins/asset.py<br/>認證:jwt_required()<br/>授權:require_capability<br/>身分:jedi-iam 查暱稱<br/>引用數:問 task-platform 三張表<br/>service:DI container 工廠<br/>DB:DEV 開發庫 25432<br/>正式產品,真的一切"]
PKG["jedi-asset 套件<br/>同一份程式碼<br/>只認 register(app, adapters)"]
H2["宿主二:harness<br/>harness/dev_app.py<br/>認證:只看 X-Auth header<br/>授權:一律放行<br/>身分:回「帳號(測試暱稱)」<br/>引用數:固定回 7<br/>service:不給,逼套件自組<br/>DB:專屬 postgres 5493<br/>全部是假的,但形狀一樣"]
H1 -- 接 --> PKG
H2 -- 接 --> PKG
兩個宿主填的是同一張 AssetAdapters 表格。套件分不出誰在接它,這就是「插頭」的意思。如果套件偷偷 import 了主專案的東西,或偷偷連了主專案的 DB,右邊那個宿主就起不來。harness 的價值就是把這種隱性耦合逼出來。
| 證明(跑 --smoke 全綠代表這五件事成立) | 不證明(誠實聲明) |
|---|---|
| 套件 import 得起來,不需要主專案任何一個檔 | 沒有真的認證,X-Auth 是假守門 |
隨包的建表 SQL 套得起來(走 iter_migrations()) |
沒有 RLS,只套 001 建表、不套 002(RLS 和 GRANT 需要 cm_app 角色與租戶判定函式,那是宿主生態) |
register() 掛得上一個乾淨的 Flask app,9 條 route 真的出現 |
不測業務邏輯正確性,那是 tests/ 的事 |
| 端點實打走得通(缺 header 401、給 header 200) | |
| 三張 port 真的傳到 service 手上,不靠主專案的 DI container |
docker compose -f harness/docker-compose.yml up -d # 起專屬 postgres,port 5493;刻意不連開發庫
python harness/dev_app.py --migrate # 自建 tenants/org_units 兩張樁表,再套套件的 001
python harness/dev_app.py --smoke # 不起服務,用 test client 打三下就結束;給 CI 用
python harness/dev_app.py # 真的起在 5093,另開終端 curl 打寫 harness 的過程逼出兩件「套件對宿主的隱性要求」,不寫 harness 永遠不會知道,因為主專案剛好都滿足了:
ENABLE_MULTI_TENANT 必須在 import model 之前設好。 TenantScopedMixinModel 在 class 定義的瞬間決定要不要長 tenant_id 欄位,晚設等於沒設。症狀是寫入時 NotNullViolation,看起來像資料問題。tenants 與 org_units 兩張表要在 SQLAlchemy metadata 裡。 mixin 的外鍵在 mapper 設定期就要解析得到,只在 DB 建表沒用。harness 用 Base 宣告只有 id 和 name 的最小版本。宣告越少,「套件對宿主要求多少」的答案越誠實。為什麼 port 刻意避開 5432 和 25432: 連了開發庫就測不出隱藏耦合。如果套件某處偷偷 hardcode 了主專案的表名,用開發庫跑會過,用空庫跑才會炸。harness 要的就是那個炸。
以 PUT /api/1.0/device/abc 為例,看哪幾步去袋子拿了東西。
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
sequenceDiagram
participant C as Client
participant API as api 層<br/>DeviceDetailRoute.put
participant BAG as 袋子<br/>app.extensions["jedi_asset"]
participant APP as app 層<br/>DeviceService.update_device
participant DOM as domain 層<br/>DeviceDomainService
participant INF as infra 層<br/>DeviceRepoImpl
C->>API: PUT /api/1.0/device/abc {"name","ip"}
API-->>BAG: ① 拿 auth_required → jwt_required 驗 token,沒 token 401
API-->>BAG: ② 拿 capability_required + config 名字 "device.update" → require_capability 驗
API-->>BAG: ③ current_user_login_name() → 拿操作者帳號
API-->>BAG: ④ rt.service("device_service") → 拿工廠建 service(DeviceRequest().load(body) 驗欄位)
API->>APP: update_device(entity)
Note over APP: @transaction 開 session;組 DeviceEntity;找不到 uid 就 raise NotFound
APP->>DOM: update_device → self._repo.update(entity)
DOM->>INF: update
Note over INF: self.session 這時才拿到;查 model、更新、flush
INF-->>DOM: entity
DOM-->>APP: entity
APP-->>API: entity→DTO,fill_user_names 補暱稱 → DeviceResponse().dump
API-->>BAG: ⑤ rt.reply() → 拿 return_response 組信封
API-->>C: 200 {status: true, data}
袋子裡這趟被拿到的東西:auth_required、capability_required、config.device_update_…、current_user_login_name、services.device_service、reply → return_response。(identity / reference_counter 在建 service 時一起注入,不再經袋子。)
虛線就是「去袋子拿」。一個 request 拿了六次,全在 api 層的頭尾;中間三層(app / domain / infra)完全不知道袋子存在,它們需要的 port 在建 service 時就注入好了。把宿主換成 harness,這條路一步都不用改。