FR-089 · jedi-asset 插件解剖 · 21 支套件的形狀範本

jedi-asset 插件解剖

一支套件要同時能「插進 Guidant AI」和「離開 Guidant AI 自己跑」。整套機制只有一句話:開機時宿主把一包東西放進袋子,之後每個 request 從袋子拿。 本頁先用五個名詞把地基釘住,再用「袋子」的比喻把這句話拆開講,然後交代主專案那側要寫什麼、每個檔各放什麼、怎麼證明套件真的能獨立跑。

21 支套件的形狀範本 SOP §4.3 為規範正本 port 雙重注入可收,不急

怎麼讀這一頁: 只想知道「怎麼接一支套件」→ 看五個詞新手教程就夠。 想懂「為什麼要這樣設計」→ 從袋子往下順著讀。想查「某個檔放什麼」→ 直接跳檔案

§1

先講五個詞

整份文件只圍繞五個名詞打轉。讀之前先把它們釘住,後面就不會卡:

一句話 在這裡具體是什麼
宿主 接零件的那個系統 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 那個介面的實作:「我來回答」。答案套件自己知道就寫在套件裡,答案在別的套件手上就寫在宿主
§2

標準形狀:一支套件長什麼樣

這節是「檔案該放哪」的規矩,不是機制。 想先懂機制的可以跳到袋子,回頭再看這張表。

jedi-asset 是 21 支套件的形狀範本,規範正本在 SOP §4.3。六條:

規定
plugin/ 五檔 __init__register() + 對外 re-export)/contractruntimeassemblymigrations
api/ 四件 guards.pyruntime() + 三個 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-v2jedi-common 沒有 route、沒有 register(),套用版面與 logger 規則,但不建 plugin/ 五檔與 api/ 四件——不為了形狀開空目錄。

對外 import 路徑是契約,子模組是實作細節。 from jedi_<name>.plugin import Xfrom jedi_<name>.api import Y 這兩條路徑不許動,內部怎麼拆都可以。

§3

袋子比喻:register 放東西、runtime 拿東西

整個機制只有兩個動作,發生在兩個不同的時間。角色四種:主專案(宿主)、套件、袋子 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[&quot;jedi_asset&quot;]")]
  P1 -- 放 --> BAG
  P2 -. 拿 .-> BAG
圖 1 — register 放進袋子、runtime 從袋子拿

袋子裡裝的東西:

內容
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 讀作「從袋子取出主專案給的驗登入函式」。

§4

為什麼要有袋子,不直接寫進 route

因為 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
圖 2 — 三個時間點

解法:① 只留一個「到時候去袋子拿」的殼,把決定推遲到 ③。這個殼就叫 lazy decorator。

主專案那側直接寫 @jwt_required() 是在 ① 就綁死實作。套件不能這樣寫,因為它不知道宿主用什麼認證,而且這樣寫等於套件反過來 import 主專案。

拿生活的比喻:套件是一間出租店面,裝潢時(import)把「收銀機的位置」留好了,但收銀機本身(jwt_required)要等房客搬進來(register)才放上去。每次客人結帳(request),店員走到那個位置拿收銀機來用。runtime() 就是「走到那個位置」。

§5

guards.py:一個 runtime 加三個守門

這節在講:套件怎麼在不認識宿主的前提下,做到「沒登入擋掉、沒權限擋掉」。 答案是套件只寫一個空殼,真正的判斷從袋子拿。

守門集中在 jedi_asset/api/guards.py,60 行。route 檔從 jedi_asset.api import,不需要知道它在哪一個子模組。

auth_required:殼在 import 期,守門在 request 期

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
圖 3 — auth_required 殼的內外

capability_required:連「哪個能力點名」都推遲

@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,欄位 nameresourceactiondescriptiondefault_rolesis_platform),在 plugin/__init__.py__all__ 曝露。21 支共 80 筆,沒有能力點的套件宣告空 tuple。

三條硬規則:

  • read 也要列。 BE route 只守寫入類,但前端選單與 route_capabilities 認 read。漏了的症狀是「選單看不到這個頁面」,而且不會有任何測試變紅
  • DEFAULT_*_CAPABILITY 常數從清單取值_BY_NAME["x.create"].name),不得寫死字面量。清單是唯一來源,常數只是相容用的別名。
  • route 只透過 config 取名字,不在 route 寫死字串。

版面守衛除了凍結名字集合、逐項檢查型別,還有一條 AST 驗 DEFAULT_* 不是字面量——🔴 這條不能在執行期用 is 比物件:CPython 會把同模組相同字面量摺疊成同一物件,改回寫死字串照樣綠。只有在原始碼層驗得出來。

current_user_login_name:同樣去袋子拿,只是回值不是殼

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(),不叫 ctx()

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: @injectProvide[Containers.asset_container.device_service] 裡的 Containers 是主專案的 DI container,在 import 期就要寫死路徑。套件一旦這樣寫,就等於套件 import 主專案,正是抽套件要消滅的東西。runtime().service("device_service") 讓套件只認一個名字,工廠從哪來是主專案的事。

§6

檔案:每個檔放什麼

plugin/ 五檔、api/ 四件,對外 import 路徑只有 plugin/__init__.pyapi/__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
圖 4 — plugin 五檔與 api 四件的關係

runtime.pyguards.py 是一對:runtime.py 定義袋子長什麼樣,guards.py 負責拿。主專案與 harness 只接觸 plugin/__init__.py;route 只接觸 api/__init__.py

plugin/ 五檔逐個講

contract.py(132 行)— 宿主要讀的東西全在這

五個 dataclass + 能力點清單 + 常數,只有型別宣告、沒有任何邏輯。接這支套件的人打開它就知道要填什麼:

dataclass 是什麼 關鍵
AssetAdapters 宿主要填的八格表——三個守門(auth_requiredcapability_requiredcurrent_user_login_name)、四個選填 port(identityreference_counteruser_directoryresponse_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=FalseblueprintNone

🔴 這個檔不得 import flask(有 AST 守衛盯著):契約型別是 consumer 讀的東西,要能在沒裝 web framework 的情境下 import 得起來。需要 Blueprint 型別走 TYPE_CHECKING

runtime.py(52 行)— 袋子本體

一個 _RuntimeContext dataclass,裝四樣:adaptersconfigschema_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_DIRiter_migrations(),回 [(檔名, SQL), ...] 依檔名序(三位數序號決定套用順序)。套件只提供、不執行:何時套、套進哪個庫、怎麼記錄已套過,全是宿主的維運決定。

MIGRATIONS_DIRparent.parent——SQL 住在套件根的 migrations/,不在 plugin/ 底下,子套件深一層。

__init__.py(119 行)— register() 本體與唯一對外門面

register(app, adapters, config, schema_extensions, mount_api) + 對外 re-export(__all__ 列齊,版面守衛會比對)。register() 本身很短:組 adapters 與 config,然後分兩條路——

  • mount_api=Truecreate_blueprint()app.register_blueprint(bp)
  • mount_api=False(把插件當 library 用):🔴 仍然必須寫 runtime context。掛路由那條路靠 blueprint 的 record_once 寫入,而 record_once 只在 blueprint 真被掛上時才觸發;少了這段,「不掛路由」與「拿得到 runtime()」會變成綁死的二選一。

api/ 四件逐個講

guards.py(64 行)— runtime() + 三個 lazy decorator

本頁前面那節整節在講它:runtime() 一行把袋子拿出來,auth_requiredcapability_requiredcurrent_user_login_name 三個殼在 import 期做好、request 期才去袋子取主專案給的實作。

routing.py(59 行)— URL 的唯一真相

mount_routes(bp) 把九條 route 掛上 blueprint,這份對照表是對外 URL 的唯一真相FROZEN_URLS 是對應的凍結清單,測試拿它與實際掛載結果做集合比對

  • ⚠️ 要比集合不比數量:數量對但拼錯一條,症狀是該 API 對前端憑空消失 404,而 route 表上看不出任何異常。
  • 九條路徑逐字凍結(FE 常數對著它們),含看起來不對稱的 /devices/information-systems/list——那是實況,統一即為 breaking change。
  • ⚠️ 裸路徑 /device 只承載 POST:get/put/delete 都要 uid,打裸路徑必 500。

routes/(一資源一檔)— flask-restful Resource

device_route.py 五條、information_system_route.py 四條。每個 class 命名 <Type><動作>Route,方法上疊三層殼(登入/能力點/取操作者),本體只做「驗欄位 → 叫 service → 包信封」。

serializers/(一資源一檔)— marshmallow schema

device.pyinformation_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。

§7

主專案怎麼接

這節是宿主那一側:主專案要寫什麼,套件才會活起來。

接觸面只有一個檔core/plugins/asset.py——adapter、填表、掛載三段都在裡面, 接手的人打開它就知道這支套件接了什麼。

core/plugins/asset.py:一個檔三段

# ① 這支套件開的 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/待裁 report 1)並要求零未歸類;第五條守的是豁免名單本身,否則新抽出來的套件會靜靜落在掃描範圍外。

存工廠不存實例: asset.device_serviceproviders.Factory,每 request 呼叫一次建一個新的 service,內含 session-bound repo。加了括號變成傳實例,第一個 request 建出來的物件會服務到天荒地老,平常看不出來、併發下才爆。

core/plugins/init.py:清單就是掛載順序

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,
]

core/app_factory.py:一個迴圈掛全部

container.wire(modules=di_modules)      # 一定要先跑
...
for plugin in PLUGINS:
    plugin.mount(app, container, plugin_host)

必須在 container.wire() 之後,因為 adapters 要從 DI container 取工廠。把某支從 PLUGINS 拿掉就是「拔掉測試」:該模組端點全 404,但資料和 domain service 不受影響。

host_defaults():六支套件重複填的那四樣

jwt_required()require_capabilitycurrent_user_login_namereturn_response 這四樣多數套件填的內容逐字相同,統一收在 core/plugins/_host.pyhost_defaults()

它存在的理由不是少打字,是改授權模型時只有一個落點——各填各的話,漏改一支的症狀是那支套件的端點守門與別人不一致,而且不會有任何錯誤訊息。

不是每支都收這四樣,所以不能無腦 </strong>host_defaults()**:SystemCoreAdapters 沒有 current_user_login_nameFileUploadAdapters 沒有 capability_required,多塞一個 key 就是 TypeError。收得齊的那兩支(asset/bulletin)用 splat,其餘按 key 取。

§8

port 與 adapter:介面在 domain、實作在 infra,但分兩邊

這節在講:套件需要「別的套件才知道的答案」時怎麼辦。 例如資產套件想知道「這台設備被幾個稽核任務用到」——答案在任務套件手上,而套件之間不准互相 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
圖 5 — port 在套件 domain,adapter 分兩邊

主專案可以認識所有套件(它是組裝根),套件之間不准互相 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.pyapp/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 的是介面;答案要跨套件的,實作寫在主專案。

core/plugins/asset.py 的 ② 段是什麼

那一段是主專案側的組裝表。套件在 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
圖 6 — build_adapters 把主專案材料填進 AssetAdapters 表格

填好的表格交給 register(app, adapters)。之後套件裡的 route 用 runtime() 從袋子拿的,就是這張表格。

每支套件在主專案都有一支 core/plugins/<pkg>.py,形狀都一樣:① adapter ② 填表 ③ 掛載。它放 core/ 不放 api/app/,因為它是組裝根的一部分,被 app_factory 的迴圈呼叫、又要 import 守門與 DI container。

所以主專案接一支套件,寫的東西只有兩種:adapter(回答套件答不出來的問題)和填表(把 adapter 與其他材料裝進表格),兩者住同一個檔。runtime、register、袋子都在套件裡,主專案不碰。

§9

新手教程:主專案接一支套件要動哪 3 個地方

以 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
圖 7 — 三個地方的位置與依賴方向

① pyproject.toml:宣告依賴

# [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,會卡很久。

② core/plugins/asset.py:三段寫完

照抄任一支現成的,改名字就好。三段分別是:

① 這支套件開的 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(),檔尾宣告 PLUGINcapabilities= 必帶,見主專案接法):

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。

③ core/plugins/init.py:清單加一列

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 的迴圈已經寫好,不必改。

④(看情況)di_containers/:只有別的模組要用才加

只有在「主專案其他模組要直接用套件的 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()PLUGINPLUGIN.name 與檔名對不上、capabilities 沒填或對不上 seed。

§10

DI 與 runtime 的關係:同一個容器,最後一哩路換個遞法

這節回答一個常被問的問題:「套件是不是自己搞了一套 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
圖 8 — @inject 過不了套件邊界,register 遞包裹

左邊主專案的 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() 遞一個包裹。差別只在遞送方式。

為什麼 Java 沒這問題

Spring 是整個 Java 生態的事實標準,套件和宿主用同一個容器是預設。Python 沒有這種標準:dependency_injector、injector、punq、不用框架都常見。套件要「隨插即用」就不能假設宿主用哪一個,所以只能收建構子參數、不綁容器。

一句話: 同一個 DIP 原則、同一個 DI 容器、多一個包裹讓套件不用認識容器。register() 是主專案遞包裹、runtime() 是套件拆包裹,兩個加起來就是那最後一哩路。

§11

複雜度評估:接一支套件算不算複雜

這節回答「這套東西會不會太複雜」:不會,接一支要寫的只有三個地方、約 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 都逐字保留),省的是「要翻幾個地方」: 從三到五處變成一處。

發現:三張 port 給了兩次

看 ④ 和 ②,identityreference_counteruser_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
圖 9 — port 雙重注入

Guidant AI 走的是左邊那條路,右邊三行在本產品是死的。它們存在是因為 README 教第二個產品「只給 port、不給工廠」時要走右邊。兩邊都填是保險,不是需要。

刪掉那三行會怎樣(實際追過程式碼)

套件內唯一讀 adapters.identityreference_counteruser_directory 的地方是 plugin/assembly.pybuild_services(),邏輯是「宿主給了工廠就用宿主的,沒給才用這三個 port 自組」。Guidant AI 在 core/plugins/asset.pybuild_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,省不到多少。

app_factory.py 為什麼只有一個迴圈

core/app_factory.py 掛全部插件只有四行:一個 for 逐支呼叫 mount()。共通說明 (順序就是掛載順序、兩處實質依賴)只寫一份在迴圈上方;各支特有的陷阱(例如任務平台 兩半掛法不同、問卷只在 socketio 模式登記)留在自己那個檔的 mount() docstring 裡——那才是下一個改它的人會打開的地方

Plugin(name, mount)每支自己說怎麼掛,而不是由迴圈組裝參數。理由見 新手教程 ② 的註:六支的 register() 簽名各不相同,統一組裝的形狀套不進去。

§12

設計模式對照:主框架與零件

這節給想確認「這是不是自己發明的怪東西」的人看:不是。 主軸是一個業界標準框架(六角架構),其餘六樣是它落地時必然長出來的零件,每個都有出處。

主框架:六角架構(Ports and Adapters)

業務核心在中間,所有與外界的接觸都經過「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
圖 10 — 六角架構對照 jedi-asset

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.extensionsruntime() 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 擴充不會做到這步。

該知道的一個限制

  • 綁 Flask。 app.extensions 是 Flask 的全域字典,等於套件狀態放在全域變數裡。Flask 生態接受這件事(current_apprequest 本身就是全域)。換到 FastAPI 或 Django 要重做 register()runtime() 那層,但 app/domain/infra 三層不用動。這正是六角架構的承諾:換外圈不動核心。

建不建議在程式碼裡改名去「符合模式」:不建議。 現在的名字(adapters、ports、register、build_services)已經是這些模式的慣用詞。模式是用來溝通的,不是用來遵守的。這張表放進 SOP §4.3 或套件 README 讓新人查出處就夠。

§13

harness 是什麼

這節在講:怎麼證明「套件真的能離開 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
圖 11 — 同一個套件、兩個宿主

兩個宿主填的是同一張 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 的過程逼出兩件「套件對宿主的隱性要求」,不寫 harness 永遠不會知道,因為主專案剛好都滿足了:

  • ENABLE_MULTI_TENANT 必須在 import model 之前設好。 TenantScopedMixinModel 在 class 定義的瞬間決定要不要長 tenant_id 欄位,晚設等於沒設。症狀是寫入時 NotNullViolation,看起來像資料問題。
  • tenantsorg_units 兩張表要在 SQLAlchemy metadata 裡。 mixin 的外鍵在 mapper 設定期就要解析得到,只在 DB 建表沒用。harness 用 Base 宣告只有 id 和 name 的最小版本。宣告越少,「套件對宿主要求多少」的答案越誠實。

為什麼 port 刻意避開 5432 和 25432: 連了開發庫就測不出隱藏耦合。如果套件某處偷偷 hardcode 了主專案的表名,用開發庫跑會過,用空庫跑才會炸。harness 要的就是那個炸。

§14

一個 request 的旅程

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}
圖 12 — request 經過五層,六次從袋子取東西

袋子裡這趟被拿到的東西:auth_requiredcapability_requiredconfig.device_update_…current_user_login_nameservices.device_servicereply → return_response。(identity / reference_counter 在建 service 時一起注入,不再經袋子。)

虛線就是「去袋子拿」。一個 request 拿了六次,全在 api 層的頭尾;中間三層(app / domain / infra)完全不知道袋子存在,它們需要的 port 在建 service 時就注入好了。把宿主換成 harness,這條路一步都不用改。

來源:jedi_asset/plugin/ 五檔、jedi_asset/api/guards.pyapi/routing.pyharness/dev_app.py、主專案 core/app_factory.pycore/plugins/asset.pytest/test_module_boundaries.py。契約全文見 docs/features/FR-069-2608-jedi-module-extraction/ 的 design.md D6/D7 段與 extraction-sop.md §4.3。本頁只描述現況,沿革見 FR-089-LOG 與 git log。