如果你的第一次代码作业只是“能跑”,那么第二次作业大概率会逼你重新想清楚一件事:同一个功能,怎么写才不会让两个月后的自己看得一头雾水。
我最近刚把第二次作业交上去,项目是一个文件归档整理工具——输入一个乱糟糟的下载目录,按后缀名和自定义规则把图片、文档、压缩包自动归入对应子目录。第一版大约三百行,功能能跑,但跑完我出了一身冷汗;第二版我拆成了六个模块,补了配置、日志和预演模式,等于从头重写。这篇不是讲大道理,就是把这版代码从设计到踩坑的完整过程摊开给你看。如果你也正在做类似“第二版重构”的练手项目,或者想从“能跑就行”过渡到“能维护、能交代”,这里面的取舍和问题排查思路应该能帮上忙。
1. 第一次作业交上去之后:问题清单比代码还长
第一次作业的题目其实很简单:给定一个源目录,递归扫描所有文件,按照文件类型移动到目标目录的分类子文件夹中。我用了三天写了个三百行的 Python 脚本,跑通测试集的那一刻确实挺爽,但交上去之后被批注里那句“你有没有考虑过重名文件”问得哑口无言。
冷静下来整理问题,发现根本不只重名这一处。我把踩到的问题分了四类,列在下面这个表里,后面逐个展开说。
| 问题分类 | 具体表现 | 带来的后果 |
|---|---|---|
| 逻辑全堆在 main() 里 | 一个函数内嵌套四层循环和条件判断 | 想加一个文件分类要翻几十行代码 |
| 分类规则写死在代码里 | 扩展名映射表 hardcode 在文件头部 | 新增类别必须改源码,非技术用户完全没法用 |
| 重名文件直接覆盖 | 用 os.replace 处理同名文件 | 差点把一个重要文件覆盖掉 |
| 没有任何日志和预演 | 跑完无法确认哪些文件被移动了 | 出问题只能全部重新跑一遍 |
1.1 所有逻辑都堆在 main() 里:第一处着火点
第一次作业的代码结构基本长这样:一个 main(),从 sys.argv 里读入源目录和目标目录,然后 os.walk 遍历,再根据扩展名判断分类,最后 shutil.move。听着没什么问题,但把所有步骤串在一个函数里之后,每一层循环之间都夹着两三个 if 判断,整段代码就像一团揉在一起的面。
真正让我意识到问题的时刻,是第二次作业要求加一个“跳过隐藏文件”的功能。按理说只是几行代码的事,但我在 main() 里翻了很久才找到遍历逻辑的入口,而且改完还要担心会不会影响后面移动文件的代码。那次改动花了将近一个小时,而实际新增的逻辑只有三行。
1.2 分类规则写死在代码里:第二处着火点
第一版代码开头长这样:
ext_to_category = { ".jpg": "images", ".jpeg": "images", ".png": "images", ".gif": "images", ".pdf": "documents", ".doc": "documents", ".docx": "documents", ".zip": "archives", ".tar": "archives", }当时觉得挺直观的,但一放到真实场景就露馅了。测试集里有一批扫描件是.PDF大写后缀,而字典里只有小写.pdf,结果这些文件全被丢进了“其他”目录。更麻烦的是,我的朋友拿这个脚本去整理他的设计素材目录,想加一个svg分类,看到代码里那一长串字典之后直接放弃了——他不会改代码,这个工具对他来说就是一次性的。
这个教训让我后来特别坚持一件事:业务规则要外置,不能埋在代码里。分类规则应该是配置文件里可以改的内容,而不是程序员写死的常量。
1.3 覆盖式移动:最让我后怕的一个缺陷
第一次作业里,移动文件用的是shutil.move,但遇到目标目录已经存在同名文件的情况时,我当时图省事直接加了句os.replace。也就是说,新文件会悄悄覆盖掉旧文件。
结果在真实测试时,目录里有个叫“需求文档.docx”的文件,刚好另一个子目录也有一份同名但内容完全不同的草稿版,程序跑完之后,原来的草稿版就这么没了。虽然那次不是工作文件,但那种“数据无声无息消失”的感觉太糟糕了。从那以后我给自己定了个规矩:任何覆盖类操作,要么预留确认机制,要么把旧文件自动改名保留。人眼发现不了的问题,程序一旦跑起来就是灾难。
1.4 没有日志:出了问题只能靠猜
第一版跑完,我根本不知道哪些文件被移动了,哪些被跳过了。如果中间有一半文件处理失败,程序会继续往下跑还是直接退出?我当时完全靠看文件数去猜。更难受的是,如果某个文件因为权限问题移动失败,程序抛个异常就停了,前面的文件已经移动,后面的文件没处理,想要重跑还得先把前面的文件手动挪回去。
这个场景想必很多人熟悉:你愿意写一个月的代码,却不愿意花半小时让程序告诉你在干什么。这不是技术问题,是意识问题。
2. 推倒重写还是打补丁?我的判断逻辑
按常规思路,第二次作业最省力的方式是保留旧脚本,在原有代码上加配置、加重名处理、加日志。但我认真评估之后还是决定重写。理由不是“旧代码不够好”,而是修修补补的真实成本已经超过了重写的成本,而且需求本身已经变了。
2.1 先算一笔账:修复这些缺陷需要动多少地方
我列了一个改动清单,逐条估算可能触及的代码路径:
- 重名文件要改为“自动改名/跳过/覆盖”三选一策略,这会触及移动文件的整个流程。
- 分类规则要外置,意味着扩展名映射表要从代码里挪到配置加载层,main() 里的分类逻辑几乎全部要改。
- 加日志,需要在入口、扫描、分类、移动、冲突处理这五个位置插入打印点。
- 加预演模式,需要把“移动文件”这个动作抽象出一个可替换层。
- 隐藏文件和忽略目录选项,会影响遍历逻辑本身。
算下来,旧代码里超过一半的函数体都要动。再加上旧代码没有模块边界,改一处就可能影响另一处。这种情况下,打补丁的实际工作量其实和重写差不多,但代码会变得更拧巴。
2.2 需求变了,补丁结构撑不起新功能
第一次作业的功能定位是“自己跑一遍,做个作业交差”。第二次作业的定位变成了“这个工具要能给别人用,还要能应对不规则目录”。
这两者的差别不只是多几个功能的事。给别人用意味着配置文件友好、错误信息清楚、操作前能预览;应对不规则目录意味着要处理特殊文件名、权限异常、符号链接、跨磁盘移动这类边缘情况。这些需求都会触及核心数据流,而不是外围功能。老结构里根本没有“配置加载层”,也没有“统一冲突处理入口”,想打补丁都不知道把补丁缝在哪。
业界常说“重写是最后的选项”,但对一个单文件小脚本来说,重写的成本没那么高,风险也完全可控。判断标准很简单:如果改动会触及超过一半的代码路径,而且旧边界已经被打穿过,那重构的收益远大于损失。
2.3 重写不是从零开始:第一次作业留下的知识地图
做决定之前我还有点犹豫,觉得重写就是抛弃上一次的经验。但实际上,第一次作业已经给了我一张非常宝贵的知识地图:
- 我知道测试目录的结构长什么样,哪些子目录容易藏大文件。
- 我知道文件类型分布很脏,光图片就有 jpg、jpeg、png、webp、gif、svg 好几种,还有大写后缀混在里面。
- 我手里有一份边界情况清单:权限拒绝、隐藏文件、超长路径、重名文件。
这些东西在第一版里是踩坑记录,在第二版里就直接变成了需求文档和测试用例。比如“忽略隐藏文件”“自动重命名策略”“大写后缀归一化”这些特性,全是从第一版的实际故障里提炼出来的。所以我不觉得重写是在否定第一次作业,更像是对那版代码做了一次全面复盘,然后把这些经验固化进新设计。
3. 第二次作业的骨架:从入口到落盘的完整数据流
确定重写之后,我做的第一件事不是写代码,而是画清楚一条数据流:用户输入命令之后,程序到底按什么顺序处理文件,每个环节的输入输出是什么。这条数据流是整个第二版的地基。
3.1 从命令行参数到日志落盘:一次任务的生命周期
第二次作业的完整流程是这样的:
- 解析命令行参数,拿到源目录、目标目录、配置文件路径、是否预演。
- 加载 YAML 配置文件,解析分类规则、冲突策略、忽略项。
- 扫描源目录,得到所有待处理文件的路径列表。
- 对每个文件调用分类器,算出应该进入哪个分类目录。
- 对每个文件调用冲突检测,计算出最终的目标文件名。
- 如果是预演模式,只打印计划;如果是正式执行,先建目录再移动文件。
- 无论预演还是执行,每一步都写进日志。
流程看起来平平无奇,但这条主线保证了每个模块的职责是单一的。扫描器不知道什么叫“分类”,分类器不知道什么叫“移动”,移动器不知道什么叫“规则”。任何环节出问题,我可以单独测它,不会牵连其他部分。
3.2 模块划分与依赖规则
最终代码拆成了六个模块加一个入口文件,每个模块大致行数如下:
| 模块 | 职责 | 约行数 |
|---|---|---|
| cli.py | 参数解析、入口调度 | 90 |
| config.py | 读取和校验 YAML 配置 | 130 |
| scanner.py | 递归扫描目录,输出文件路径列表 | 110 |
| classifier.py | 根据规则给文件打分类标签 | 100 |
| resolver.py | 冲突检测、目标文件名生成 | 80 |
| runner.py | 执行移动或预演,调度前序模块 | 120 |
总的代码量比第一版的 300 行长了不少,但每个模块都可以单独写测试。依赖方向也很明确:cli 调用 runner,runner 依赖 scanner、classifier、resolver,config 只被 cli 和 runner 引用。反向依赖一律不允许,这个约束让后续改配置格式或者换日志方案时,不会波及核心逻辑。
3.3 配置文件的定位:把决策权从代码里拿出来
第二次最大的变化是加了一个配置文件,用的是 YAML。原因很直接:Python 的 dict 字面量写进代码里,对会用编辑器的人来说还行,但任何想调整规则的人都要面对“改代码”的心理门槛。YAML 文件则不同,它看起来就是一份配置文档,改起来没有心理负担。
我的配置长这样:
source_dir: "./inbox" target_root: "./archive" fallback_dir: "others" conflict_policy: "rename" # rename / skip / overwrite ignore_hidden: true ignore_dirs: - ".git" - "__pycache__" follow_symlinks: false rules: - name: images extensions: [".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"] - name: documents extensions: [".pdf", ".doc", ".docx", ".txt", ".md"] - name: spreadsheets extensions: [".xls", ".xlsx", ".csv"] - name: archives extensions: [".zip", ".tar", ".gz", ".7z"] - name: backup_files regex: "^backup.*"这里有个设计细节想提一下:fallback_dir的存在很重要。任何一套分类规则都不可能覆盖全部文件类型,与其让未匹配文件乱糟糟留在原目录,不如给它们一个明确的归宿,这样用户至少知道“没分进去的东西去哪儿了”。
3.4 数据结构的三个基石
第二版代码里有三个核心数据结构,分别是FileRecord、Rule和ConflictPolicy。
from dataclasses import dataclass from enum import Enum from pathlib import Path @dataclass class FileRecord: source_path: Path # 原始文件完整路径 category: str # 分类名称 target_name: Path # 目标路径(已由 resolver 处理过冲突) @dataclass class Rule: name: str extensions: list[str] = None regex: str = None class ConflictPolicy(str, Enum): RENAME = "rename" SKIP = "skip" OVERWRITE = "overwrite"FileRecord像一条流水线上的工件,每个环节都往上面挂信息:扫描器填source_path,分类器填category,resolver 填target_name。这样到执行阶段,runner 只需要遍历List[FileRecord]然后调用移动函数,不需要重新访问文件系统。所有决策都在内存里做完,执行阶段只是一个落盘动作,理解起来非常直观。
4. 关键实现细节:扫描、分类、预演、落盘
这一章挑四个关键点展开:扫描器为什么换用 scandir,分类引擎怎么从字典升级成规则引擎,预演与真实模式如何共用一套核心逻辑,以及日志系统怎么设计才不会被忽略。
4.1 扫描器:为什么我放弃 os.walk 改用 os.scandir
第一版用的是os.walk,代码短,用起来也简单。但第二版我换成了os.scandir加显式栈遍历。原因有两个:第一是性能,第二是对遍历过程的可控性。
os.walk在内部其实也是调用scandir,但它会把目录树整个展开,对每个目录返回一个列表。对于归档工具这种“只关心文件、不需要目录层级信息”的场景,os.walk会额外维护很多用不到的目录结构。显式维护一个栈之后,我可以直接在遍历过程中过滤符号链接、隐藏目录和忽略目录,不用事后二次筛选。
核心扫描代码大致长这样:
import os from pathlib import Path def scan_files(root: Path, ignore_hidden: bool = True, ignore_dirs: set[str] | None = None, follow_symlinks: bool = False) -> list[Path]: ignore_dirs = ignore_dirs or set() visited = set() stack = [root] result = [] while stack: current = stack.pop() try: real = current.resolve() except OSError: continue if real in visited: continue visited.add(real) with os.scandir(current) as it: for entry in it: if ignore_hidden and entry.name.startswith("."): continue if entry.is_dir(follow_symlinks=follow_symlinks): if entry.name not in ignore_dirs: stack.append(Path(entry.path)) elif entry.is_file(): result.append(Path(entry.path)) return result注意这里用了entry.is_dir(follow_symlinks=follow_symlinks)。第一版没有这个参数,结果测试时遇到了符号链接导致的死循环,这个坑后面会单独讲。在扫描器这一层就把符号链接处理掉,后面分类和移动就少了很多麻烦。
4.2 分类引擎:从扩展名映射到规则匹配
分类器的设计目标是让规则完全外置。代码里不出现任何具体扩展名,只负责取规则、做匹配、返回结果。
import re from pathlib import Path from config import Rule def classify(path: Path, rules: list[Rule], fallback: str) -> str: lower_suffix = path.suffix.lower() lower_name = path.name.lower() # 纯扩展名规则优先,属于同一个优先级层级 for rule in rules: if rule.extensions and lower_suffix in rule.extensions: return rule.name # 正则规则次之,因为它匹配强度更高、误伤风险也更大 for rule in rules: if rule.regex and re.search(rule.regex, lower_name): return rule.name return fallback这里有一个设计上的取舍:为什么扩展名规则优先于正则规则?因为扩展名匹配是“确定的”,一个文件叫backup.tar,它既可能是备份文件也可能是归档文件,但.tar后缀是客观事实。正则表达式大多表达的是用户的模糊意图,容易出现误命中。所以我把扩展名匹配放在第一轮,正则匹配放在第二轮,并且在文档里明确写了“扩展名优先级高于正则规则,同一类规则内按配置顺序生效”。
4.3 预演和真实模式:只允许存在一套核心逻辑
很多工具做预演的方式很粗暴:复制一份代码,把移动函数注释掉,改个 print 就完事。这种做法最大的问题是,预演逻辑和执行逻辑很容易出现分叉——预演里没报冲突,正式执行却撞车了。
我的做法是把“计划生成”和“计划执行”分离。runner 里只有一份生成计划的代码,预演模式只是“执行部分换个落盘函数”。
def build_plans(records: list[FileRecord], dry_run: bool = False) -> None: for record in records: if dry_run: logger.info("[DRY RUN] %s -> %s", record.source_path, record.target_name) else: plan_and_move(record) def plan_and_move(record: FileRecord) -> None: final_path = ensure_no_conflict(record.target_name) # 执行前二次检查 if final_path is None: logger.warning("SKIP %s: 目标文件已存在", record.source_path) return record.target_name = final_path final_path.parent.mkdir(parents=True, exist_ok=True) shutil.move(str(record.source_path), str(final_path)) logger.info("MOVED %s -> %s", record.source_path, final_path)这段代码最关键的是ensure_no_conflict在预演和执行时都被调用:预演时它只做检测并返回“如果冲突会怎么处理”,执行时它做同样的检测并真正生成新路径。两段逻辑是同一份代码,不会出现“预演说没问题,执行却发现问题”的状态差。当然,严格来说预演和执行之间仍存在时间窗口,所以执行阶段还会再调一次ensure_no_conflict,这就是 5.3 节要讲的坑。
4.4 冲突解决:三种策略与文件名生成
冲突策略是第二次作业里用户最关心的功能,因为第一版直接覆盖数据的阴影还在。resolver 里实现了三种策略:
rename:目标已存在时,自动生成带时间戳的新文件名。skip:目标已存在时跳过该文件,并记录警告日志。overwrite:覆盖旧文件,仅在目标文件属于同一分类时允许。
rename 策略的文件名生成逻辑:
from datetime import datetime def rename_target(target_path: Path) -> Path: timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") return target_path.with_name(f"{target_path.stem}_{timestamp}{target_path.suffix}")有个细节是,overwrite 策略如果不做约束就是危险的。我加了一条规则:覆盖只允许发生在“同分类目录”内,如果目标文件所在的目录和当前文件分类不一致,则拒绝执行并记录错误。这样就算用户手滑配置了全局覆盖,跨分类目录的数据也不会被误伤。
4.5 日志设计:程序必须能自己说清楚它干了什么
第二版日志分了两个通道:控制台和文件。控制台只输出进度和警告,文件里记录完整细节。初始化的核心代码:
import logging def setup_logging(log_path: str) -> None: file_handler = logging.FileHandler(log_path, encoding="utf-8", errors="backslashreplace") console_handler = logging.StreamHandler() formatter = logging.Formatter("%(asctime)s | %(levelname)s | %(message)s") file_handler.setFormatter(formatter) console_handler.setFormatter(formatter) root = logging.getLogger() root.setLevel(logging.INFO) root.addHandler(file_handler) root.addHandler(console_handler)这里errors="backslashreplace"不是随手加的,而是踩过坑之后的补丁。日志记录的内容包括:每次移动的源路径、目标路径、冲突处理方式、跳过的原因。跑完一次归档任务,翻一遍日志就能完整还原程序做了什么,不需要人肉去数文件。
5. 测试阶段踩过的四个坑,以及完整排查链路
第二版写完之后,我满怀信心地拿测试目录跑了一遍,结果一波三折。下面这四个问题每个都是我实际遇到、又花了不少时间排查的,每个都有完整的“现象—定位—原因—修复”链路。
5.1 日志写入碰上特殊文件名,进程直接退出
现象:程序跑到大约第 137 个文件时,日志输出突然中断,控制台抛出UnicodeEncodeError,进程直接退出。而此时前面的文件已经移动了,后面的还没处理,整个任务卡在一个很尴尬的中间状态。
定位:我先在日志 handler 里临时加了一个 try/except,把出问题的文件名用repr()打印出来。结果发现触发日志崩溃的文件名里包含\udce6\udcb1这样的代理区字符。
原因:在 Windows 上,当某个文件的文件名无法用当前代码页解码时,Python 的os.scandir会借用surrogateescape机制,把无法解码的字节映射到 Unicode 的保留代理区。这些字符在内存里是“合法”的字符串,但一尝试按默认编码(比如 GBK 或者 UTF-8)写入文件,解码就炸了。
修复:给FileHandler显式指定encoding="utf-8"和errors="backslashreplace"。这样即使遇到代理区字符,也只是把无法表示的字节转成\udce6这类字面量写进日志,不会中断进程。
验证:重新跑完整测试集,日志完整落盘,文本内容也仍然可读。这个修复只改了一行代码,但如果不知道surrogateescape这个机制,估计得排查到怀疑人生。
5.2 符号链接把扫描带进死循环
现象:扫描一个包含软链接的目录时,程序运行时间异常久,进程占满了一个 CPU 核心,像陷入死循环一样。
定位:我在scandir循环里临时加了当前路径打印,肉眼看到同一个目录的 real path 反复出现,尤其是符号链接指回父目录的情况。检查visited集合之后发现,压栈时用的是Path(entry.path),路径本身带着符号链接前缀,而visited里存的是real路径。符号链接路径每次都能通过visited检查,导致它反复被入栈。
原因:entry.is_dir()默认会跟随符号链接,所以一个指向父目录的链接会被当成“新目录”继续遍历。理论上visited能在第二次遇到时拦住它,但因为入栈路径和判定路径不一致,这个拦截完全失效了。
修复:扫描时对目录项显式传入follow_symlinks=False,更关键的是统一使用resolve()之后的路径作为遍历依据,并且每次入栈前先检查visited。同时我还在配置里加了follow_symlinks选项,默认 False,因为归档工具一旦跟着符号链接跑,很容易把同一份数据当作多份文件处理。
验证:构造一个“目录 A 里放了指向 A 自身的符号链接”的测试用例,扫描正常结束,无重复文件记录。
5.3 预演一切正常,正式执行却触发冲突
现象:我先用--dry-run跑了一遍,所有文件都计划好了,没有一条冲突警告。紧接着正式执行,日志里却连续出现“目标文件已存在,自动重命名”的提示。
定位:一开始我怀疑是dry_run的逻辑分叉了,导致预演和执行走了不同的冲突检测路径。仔细对比两段代码才发现它们用的是同一个函数,理论上行为应该一致。然后我检查了源目录和目标目录,发现目标目录里有一个同名文件是上一次手动测试留下的,而预演模式跑的时候它还不存在。
原因:预演模式不会创建目标目录,也不会真正移动文件,所以它检测到的文件系统状态是“预演那一刻”的状态。正式执行时,目录状态可能已经因为外部操作发生了变化,甚至只是另一个用户手动放了一个同名文件进来。预演和执行之间存在时间窗口,任何状态都可能变。
修复:把冲突检测拆成两层——预演时做一次“计划展示”,执行时再做一次“安全闸门”。plan_and_move里的ensure_no_conflict就是安全闸门,不管预演时怎么判断,真到移动之前都必须再查一次。这两层共用同一个检测函数,只是调用时机不同。
验证:故意在预演之后往目标目录放一个同名文件,再次执行,程序正确触发冲突处理,不会再盲目标覆盖。这条经验也告诉我要把“预演”的宣传文案从“与真实执行完全一致”改成“仅作为预览参考,执行阶段仍会二次校验”。
5.4 规则重叠导致文件进错目录
现象:某次归档之后,一个叫backup_projects.tar.gz的文件没有进入 archives 目录,而是被放进了 backup_files 目录。用户看到之后很困惑:这明明是个压缩包,为什么不是归档?
定位:测试配置里 backup_files 规则用的是正则^backup.*,而 archives 规则用的是扩展名列表。分类器里正则规则排在扩展名规则之前,所以文件名里的backup前缀命中正则规则并返回了 backup_files。
原因:规则匹配顺序设计有误。正则规则表达的是“名字里带什么”,扩展名规则表达的是“客观文件类型”。“名字里带 backup”的误伤概率远高于“后缀是 .tar.gz”,所以后者应当拥有更高优先级。
修复:把分类器改成两轮匹配——第一轮只跑纯扩展名规则,第二轮跑正则规则。并在配置文档里写明“扩展名规则优先于正则规则,同类规则内部按配置文件顺序生效”。另外我在日志里加了一个字段,每次分类时把匹配到的所有规则名都记录下来,便于发现规则重叠。
验证:构造一个文件名同时满足“某扩展名规则”和“某正则规则”的测试用例,断言它归入扩展名规则对应的分类。同时把日志里的命中规则列表打印出来,变成配置调优的依据。
6. 两版代码的量化对比:数字不会说谎
写完第二版之后,我拿着同一批测试集分别跑了一遍两版代码,做了一次量化对比。数据本身不惊艳,但差距背后的原因值得说清楚。
6.1 核心指标对照表
| 指标 | 第一次作业 | 第二次作业 |
|---|---|---|
| 总代码行数 | 约 300 行 | 约 570 行 |
| 模块数 | 1 | 6 |
| 可配置项 | 0 | 15 |
| 重名文件策略 | 直接覆盖 | rename / skip / overwrite |
| 预演模式 | 无 | 有 |
| 日志 | 无 | 文件+控制台双通道 |
| 测试用例 | 0 | 17 |
| 全量处理耗时(约 2 万文件) | 约 210 秒 | 约 40 秒 |
| 人工确认重名文件耗时 | 手动改了 4 个文件名 | 0 |
代码行数增多这件事我完全预料到了,模块化本来就会带来一些额外的边界代码。但 2 万文件的处理耗时从 210 秒降到 40 秒,确实超出预期。
6.2 性能提升的主要原因
第一版慢在几个地方:os.walk之后每拿到一个文件路径就用os.path.join拼接,然后反复调用os.path.exists检查目标路径;每次判断分类时还硬编码一串字典查找。第二版改用os.scandir之后,目录项信息一次取回,不需要为每个文件重复发起系统调用。另一个优化点是 mkdir 的时机:第一版每移动一个文件就os.makedirs一次,第二版提前把所有分类目录建好,执行阶段省掉了大量重复的目录创建与 stat 检查。
另外,由于分类规则从代码挪到了配置,程序不再需要为每个文件做一次字典遍历和手工判断,规则匹配逻辑本身也精简了不少。
6.3 除了代码行数和耗时,我更看重的三个隐性收益
第一项是“可测试性”。第二版我能针对扫描器、分类器、resolver 分别写测试,第一版完全没有这个可能。第二项是“可配置性”。现在换个文件类型规则、改个冲突策略,只需要动配置文件,不需要重跑部署流程。第三项是“可解释性”。看日志就能还原一次归档任务的全部过程,排障不再靠猜。这三个收益在指标表里没有直接体现,但对我来说比性能数字更重要。
6.4 后续可以继续扩展的方向
这版做完之后,我心里还有几个很自然的扩展方向,列在这里作为后续参考:
- 配置文件的 schema 校验。现在配置写错了只会运行时报错,想做成启动时预检。
- 增量归档。给文件算 hash 并记录已处理集合,下次只扫描新增文件。
- 规则优先级可视化。在预演日志里输出每个文件命中的全部规则,帮助调优配置文件。
- 跨磁盘移动的安全校验。
shutil.move在不同盘之间会退化成复制+删除,这时需要考虑磁盘空间和中断恢复的问题。
这些方向都是在第二版基础上自然而然长出来的,不是空想的功能列表。
最后说点个人体会。我在写第二次作业的过程中最大的转变,不是学会了 scandir 还是 YAML,而是开始把“程序跑通”和“程序可用”分开看。跑通只需要对测试集负责,可用则要对自己的使用场景、对可能的误操作、对未来的维护者负责。第一版的每一处跳过的异常处理、每个写死的映射表,都像是给自己挖的坑;第二版把这些坑填上之后,整个工具才真正变成一个“可以交给别人用”的东西。
如果你也在写自己的第二次作业,我的建议很简单:先花一小时把第一版的问题列成清单,再决定哪里补、哪里重写,比一头扎进代码里更值得。工具不会自己说出它哪里疼,但你会从每一次“程序让你失望”的时刻里找到答案。