难度:★★★(需要会 Python 的类)
教材:resource_manager_v2/(作者重构后的版本)
预计时间:讲解 70 分钟 + 练习 50 分钟
学完你能:说出"一个 Python 项目该怎么组织",并看懂分层架构、仓储模式、事件总线这三个高频概念
作者的其他项目大多是一个大文件撑起一个应用;而 resource_manager_v2 是**唯一一个
按标准工程结构组织的项目**。它把代码拆成了几个文件夹,每个文件夹只干一类事。
这正是从"会写代码"到"会做项目"的分水岭。本课会带你把它的每一层都走一遍。
cd resource_manager_v2
REM 1. 以"开发模式"安装自己(会装好依赖)
.venv\Scripts\pip install -e ".[dev]"
REM 没有 venv 的话:py -3 -m venv .venv 先建一个
REM 2. 跑测试
.venv\Scripts\pytest
REM 3. 看命令行工具能干什么
.venv\Scripts\python -m cli.main --help
REM 4. 启动图形界面(需要 PySide6)
.venv\Scripts\python run_ui.py
你会看到 pytest 打印出测试全部通过(绿色),--help 打印出 import / export / ai 三个子命令。这些都是后面要讲的东西在生效。
resource_manager_v2/
├── pyproject.toml 项目的"身份证":名字、版本、依赖、打包规则
├── run_ui.py 图形界面入口
├── README.md 给人看的说明书
│
├── core/ 心脏:与界面无关的核心逻辑
│ ├── models.py 数据长什么样(Category/Graph/Node/Edge)
│ ├── repository.py 和数据库打交道(仓储模式)
│ ├── tree_service.py 目录树的业务逻辑(服务层)
│ ├── graph_service.py 图谱的业务逻辑
│ ├── event_bus.py 事件总线(发布/订阅)
│ └── __init__.py 统一出口
│
├── cli/ 命令行入口(import / export / ai 子命令)
├── ui/ 图形界面(窗口、树面板、图谱面板)
├── exchange/ 导入导出(json / yaml 适配器 + 注册表)
├── ai_interface/ AI 能力的"插座"(接口 + 空实现)
├── resources/ 前端资源(echarts.min.js、graph.html)
└── tests/ 测试(pytest)
一句话总结分层:ui / cli(入口)→ core(业务+数据)→ 数据库;exchange 和 ai_interface 是可替换的能力模块。
pyproject.toml —— 项目的身份证[project]
name = "resource-manager-v2"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = ["pyside6>=6.5.0", "pyyaml>=6.0"]
[project.optional-dependencies]
dev = ["pytest>=7.0"]
[tool.setuptools.packages.find]
include = ["core*", "cli*", "exchange*", "ai_interface*", "ui*"]
[tool.pytest.ini_options]
testpaths = ["tests"]
逐个看:
dependencies:这个项目运行需要什么库。别人拿到你的项目,装依赖不用猜——pip install -e . 就装齐了
[project.optional-dependencies] dev:只有开发时才需要的(测试工具),用 pip install -e ".[dev]" 才会装。这是"给用户"和"给开发者"的区分
packages.find:告诉打包工具"哪些文件夹算代码包"(resources/、tests/、docs/ 不会被当成包打进安装包)
[tool.pytest.ini_options]:pytest 的配置——去 tests/ 目录找 test_*.py对比「查资料」:那边没有 pyproject,依赖靠 requirements.txt。
小脚本用 requirements 就够;一旦要"安装成包、发布给别人用",就该上 pyproject。
core/models.py —— 先定义"数据长什么样"from dataclasses import dataclass, field
from uuid import uuid4
from datetime import datetime, timezone
@dataclass
class Category:
id: str = field(default_factory=lambda: uuid4().hex)
name: str = ""
parent_id: Optional[str] = None
metadata: dict[str, Any] = field(default_factory=dict)
created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
三个必学知识点:
@dataclass:一句话生成"初始化、打印、比较"等方法。不用手写 def __init__(self, ...),代码短很多。
field(default_factory=...):为什么不能直接写 metadata: dict = {}?因为 Python 的可变默认值会被所有实例共享(经典大坑!),
用 default_factory 让每个实例各自新建一个。
str | None、dict[str, Any]:写给人和工具看的"数据类型说明",VS Code 能据此提示,减少 bug。
模型层的作用:把"数据"从数据库表、界面控件里独立出来,
全项目都用同一套定义。这是分层的起点。
core/repository.py —— 仓储模式(所有 SQL 只在这里)SQLiteRepository 类做了三件事:
connect / close)init_schema)——注意这里的表设计比第 04 课更"正规": FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
ON DELETE CASCADE(级联删除):删掉一个分类,它下面的图自动被删;
删除图,图里的节点和边自动被删。
对比第 04 课「查资料」——那边是在代码里手动先删关系再删卡片(第 1179-1180 行);
这里交给数据库自动做。同样的问题,两种层次的做法。
create_category / list_nodes / add_edge …)每个方法都:拼参数化 SQL → 执行 → commit
(第 04 课学的三道保险,这里都在)
底部还有四个 _row_to_xxx 方法(_row_to_category 等)——
这就是第 04 课 row_to_card() 的"正规版":数据库行 → 数据模型对象。
为什么要单独一个 repository?
将来想把 SQLite 换成 PostgreSQL、或者换 ORM,只改这一个文件,上层全不动。
它和上层的唯一约定是"方法名和返回值"。这叫面向接口编程。
core/tree_service.py —— 服务层(业务逻辑的家)class TreeService:
def __init__(self, repo: SQLiteRepository, event_bus: EventBus):
self.repo = repo
self.event_bus = event_bus
def add_category(self, name, parent_id=None) -> Category:
category = self.repo.create_category(name, parent_id)
self.event_bus.publish("tree.changed", {"category_id": category.id, "type": "category_added", "category": category})
return category
看它的构造:服务层拿着 repository 和 event_bus。它做的是"业务组合":
get_path()(第 24 行)是纯业务计算:一直往上找父级,拼出完整路径为什么界面不能直接调 repository? 因为"规则"要有统一的家。
比如以后要加"分类名不能重复"的校验,写在 Service 里,CLI 和 UI 都会自动生效。
(第 04 课讲 Model 管规则,这里是同一个思想的升级版。)
core/event_bus.py —— 事件总线(发布/订阅)class EventBus:
def subscribe(self, event_type, callback): ... # 有人想听某种事件
def publish(self, event_type, data=None): ... # 发生了什么事
def unsubscribe(self, event_type, callback): ...
这就是一个"广播站":
TreeService 改完数据,publish("tree.changed") 广播一下subscribe("tree.changed", 刷新函数)和第 03 课对比:关系图里是 Controller 手动叫 view.refreshAll();
这里改成"发广播,谁想听谁听"——发布者和订阅者互相不认识,彻底解耦。
和第 07 课预告:这套思想在 Qt(PySide6)里就是信号与槽,
在 React 里就是"状态更新自动触发重渲染"。同一个思想,不同实现。
core/__init__.py —— 统一出口(门面)from .models import Category, Graph, Node, Edge
from .repository import SQLiteRepository
from .tree_service import TreeService
from .event_bus import EventBus, AIEvents
...
__all__ = ["Category", "SQLiteRepository", ...]
外面的人只需要写:
from core import SQLiteRepository, TreeService
而不用关心"这些东西分别在哪个文件"。这叫门面(Facade):
包对外只暴露一扇门,内部怎么摆是内部的事。
__all__ 还声明了"这个包公开哪些名字"——发开源项目时,这是礼貌,也是约定。
cli/main.py —— 命令行入口(复习 + 新技巧)parser = argparse.ArgumentParser(prog="rmgr", description="Resource Manager CLI")
parser.add_argument("--db", default="data.db", help="Database path")
subparsers = parser.add_subparsers(dest="command", required=True)
import_parser = subparsers.add_parser("import", help="Import data from a file")
...
if args.command == "import":
from .import_cmd import main as import_main
import_main(args)
帮你生成 --help、校验参数、报错提示
import / export / ai,这就是 --help 里那三个命令的来历(和 git 有 git add / git commit 是一个套路)
from .import_cmd import ... 写在 if 里面,只有真的执行 import 命令时才去加载那个模块——启动更快、依赖更清爽
tests/test_repository.py —— 第一次见正经测试class TestRepository:
@pytest.fixture
def repo(self):
with tempfile.NamedTemporaryFile(delete=False, suffix=".db") as f:
db_path = f.name
repo = SQLiteRepository(db_path)
repo.connect()
repo.init_schema()
yield repo # 交给测试用
repo.close() # 测试结束后清理
os.unlink(db_path)
def test_create_category(self, repo):
cat = repo.create_category("Test")
assert cat.name == "Test"
三个 pytest 核心概念:
test_ 开头,pytest 会自动发现并运行@pytest.fixture:每个测试前自动准备的"背景环境"。这里用临时数据库文件,测完删掉——测试不会污染你的真实数据
assert 断言:写"我认为结果应该是什么";不对就报错并告诉你差在哪第 12 课会把测试展开讲,这里先建立印象。
exchange/ 与 ai_interface/ —— 插件思想exchange/adapter_base.py 用抽象类定义了两个"插头":
class Importer(ABC):
@abstractmethod
def can_handle(self, fmt: str) -> bool: ...
@abstractmethod
def import_data(self, stream: IO) -> ExportData: ...
然后 json 和 yaml 各写一个适配器插上去。registry.py 维护一张
"格式 → 适配器"的表(又是一个注册表/表驱动,复习第 02 课)。
ai_interface/service_provider.py 定义了一堆 AI 能力接口
(NodeAnnotator、EdgeRecommender…),并且每个都配了一个 NoOp(空实现):
class NoOpAnnotator(NodeAnnotator):
def annotate(self, node_name, metadata):
return {} # 什么也不做,返回空
为什么要有"什么都不做"的实现? 这样上层代码可以放心调用 AI 能力,
接上真 AI 之前先跑通流程,不会因为"AI 还没实现"而卡住。
这是很成熟的设计思路:先定接口,再填实现。
官方推荐的两种布局:flat layout(包和 pyproject 平级,就是作者这套)和
src layout(包放在 src/ 下)。机器视觉/科学项目常用 src layout,
可有效避免"不小心 import 到本地文件而不是安装后的包"。两者都合理。
项目里的 pyproject.toml 写法,和官方教程的结构基本一致。
这里的 repository → service → ui/cli,正是业界经典的分层架构(类似三层架构):
| 教材的层 | 职责 | 对应开源常见叫法 |
|---|---|---|
models | 数据结构 | Domain Model / Entity |
repository | 数据存取 | Repository / DAO |
tree_service / graph_service | 业务规则 | Service / UseCase |
ui / cli | 入口 | Presentation / Adapter |
很多大型项目(Django、SQLAlchemy 生态)都是这个骨架。
| 机制 | 出现在哪 | 特点 |
|---|---|---|
| 事件总线(你的) | 本项目 | 手写 20 行,理解原理 |
| Qt 信号与槽 | 你的 PySide6 项目 | 框架内置(第 07 课) |
| React 状态订阅 | 前端框架 | 数据变 → 界面自动变(第 11 课) |
练习 1(热身):跑测试并搞坏它
先 pytest 看到全绿,然后把 create_category 里 name 存成固定字符串 "X",
再跑测试,观察 pytest 怎么告诉你"哪一个测试挂了、期望什么、实际什么",然后改回来。
练习 2(核心):给 Category 加一个颜色字段
跟着第 04 课的经验,找出需要动的所有地方:
(1)models.py 加 color: str = "#4f6ef7"
(2)repository.py 的建表 SQL 加一列、create_category 的 INSERT 加一列、update_category 的 UPDATE 加一列、_row_to_category 翻译新列
(3)老数据库要 ALTER TABLE(复习第 04 课)
(4)给测试补一条断言:新建的分类颜色是默认值
练习 3(服务层):加一个"重命名分类"功能
在 TreeService 里加 rename_category(category_id, new_name):
取到分类 → 改名字 → 调 repo.update_category → 发布一个 tree.changed 事件。
提示:repo.update_category(e) 接受的是改好的 Category 对象。
练习 4(事件总线):亲耳听一次广播
写一个 5 行的小脚本:
from core import SQLiteRepository, TreeService, EventBus
bus = EventBus()
bus.subscribe("tree.changed", lambda data: print("收到事件:", data))
repo = SQLiteRepository("test_bus.db")
repo.connect(); repo.init_schema()
svc = TreeService(repo, bus)
svc.add_category("测试分类") # 看看有没有打印
repo.close()
运行它,观察输出。然后把 subscribe 那行注释掉再运行一次,对比差异。
练习 5(思考题):
为什么 ui 不应该直接 import repository?用自己的话写一段解释(3 行以内)。
想不出来就想想:"如果允许 UI 直接写 SQL,将来换数据库要改几个地方?"
pyproject.toml 是现代 Python 项目的身份证(依赖、打包、工具配置)| 术语 | 人话解释 |
|---|---|
| 包(package) | 有 __init__.py 的文件夹,可用 import 导入 |
| pyproject.toml | 项目配置与依赖清单的标准文件 |
| 分层架构 | 入口 / 业务 / 数据 各管一段,单向依赖 |
| 仓储模式(Repository) | 把"数据存取"封装成类,隔离数据库细节 |
| 服务层(Service) | 放业务规则的层,组合数据操作并对外提供功能 |
| 事件总线 | 发布/订阅的广播站,让模块互相解耦 |
| 抽象类(ABC) | 只定接口不做实现的"模板类" |
| NoOp 实现 | 什么都不做的占位实现 |
| fixture | pytest 里为测试准备环境的小工具 |
| 门面(Facade) | 用 __init__.py 对外只暴露少量入口 |
| 级联删除 | 删主表数据时,数据库自动删掉关联数据 |
第 07 课:类与信号槽:桌面应用骨架。
教材换成 guigubahuang_mod_manager——看 PySide6 桌面程序怎么把
"界面"和"逻辑"分开,以及 Qt 信号与槽(刚才讲的事件总线的"框架内置版")。
上完这课,你所有桌面项目都能看懂了。