很多项目名其实就是随手起的代号,rea 就是其中一个。我一开始拿到“rea”这个名字时,既没有需求文档,也没有技术选型,只能自己在脑子里补完场景:每次新建项目,我都要做同一套重复动作——建目录、补 README、初始化 git、加 pyproject.toml、写第一个测试文件。真正要写的业务逻辑还没开始,光搭架子就耗掉二十分钟。
所以我把 rea 定义成一个个人效率工具:Reusable Executable Assistant。名字不算好听,但它解决的是真实问题——把“从零搭建一个标准项目”这件事固化下来,让我能越来越接近“一敲命令,项目骨架就自动出现”。下面这篇笔记,既是 rea 的开发复盘,也是对一个非常典型的个人小工具该怎么做、会踩哪些坑的记录。
1. 为什么我会动手做 rea:从“反复搭架构”到“一键生成”
如果你不是一个人维护多个项目,可能体会不到这种重复动作有多烦。我手里的项目虽然都是个人性质,但涉及的类型还算齐全:纯 Python 库、命令行工具、带 Starlette 入口的小服务、带 npm 的前后端混合项目。每一次开工,都要走一遍几乎一样的流程:
- 新建目录,起一个项目名
- 建
src或app子目录 - 写
README.md、.gitignore、pyproject.toml或package.json git init- 塞一个最基础的测试文件,跑一遍验证环境
这套流程本身不算难,难的是每次都要想一遍“这个项目该用哪种结构”。同样的参数在不同项目里写错一次,排查起来比直接手动搭还慢。我最早是在终端里保存了一堆手动拷贝命令,但时间久了维护成本很高,而且不同模板之间的差异没能沉淀下来。
1.1 大多数“脚手架方案”的隐形成本
市面上常见的脚手架方案其实不少,比如各种create-xxx项目生成器、在线模板仓库、甚至开发一个 Web 管理后台来统一管模板。这些方案能力更强,但都有同一个特点:它们把“生成项目”当成一个平台级问题来处理。你可能会想,这不是更好吗?能力更强、可扩展性更好,为什么我不用?
我被坑过一次之后就明白了:工具越重,你越不愿意为它维护规则。用在线模板仓库,你要考虑鉴权、版本、依赖管理;用 Web 后台,你要考虑前端、接口、部署;最后发现最花时间的不是搭项目本身,而是糊弄那套管理脚手架的工具链。对个人开发者来说,这是一种高射炮打蚊子的荒诞。
1.2 三套候选方案的取舍对比
我动手前先把当时想到的方案列了一张对比表,核心指标就三条:实现成本、日常使用成本、长期维护负担。
| 方案 | 实现成本 | 使用成本 | 维护负担 | 适合场景 |
|---|---|---|---|---|
| 纯 Shell 脚本拷贝固定目录 | 低 | 中高 | 高,改模板等于改脚本 | 一次性脚本,不长期复用 |
| 微型 CLI 工具 + 模板目录 | 中 | 低 | 低,新增模板只需加目录 | 个人工具箱,按下拉即用的效率工具 |
| Web 后台管理模板 | 高 | 低 | 高,前后端与部署都要管 | 团队级共享,模板非常多且要权限控制 |
表里的结论很直白:rea 不需要做“平台”,它只需要做“板子上的一个孔”——用户指定模板名,指定项目名,把文件复制过去,替换掉一两个变量。做这样的设计,后面扩展模板会非常轻松:新增一个模板就是在配置里加一行路径,再往模板目录里放一套文件,不用动任何代码。
1.3 rea 的问题域划定
所以我给 rea 划定的核心功能非常收敛:
- 从预置的模板目录复制出一套项目骨架
- 对骨架中的文件做少量变量替换
- 自动执行
git init和一些初始化动作 - 提供“干跑”模式,先看清楚要创建什么,再落盘
这四点听起来简单,但要把它们在跨平台、可测试、不让用户删错文件的前提下做稳,其实并没有想象中那么轻松。接下来我会逐一拆开讲,先看配置和模板怎么设计。
2. rea 的核心设计:配置驱动 + 模板变量 + 干跑模式
刚开始我犯过一个典型错误:试图把模板写成一个 Python 函数,代码里到处是mkdir和write_text。这样确实灵活,但模板一旦复杂起来,就看不出项目结构长什么样了,而且每次改项目结构都要改代码,容易把逻辑改坏。
后来我改成了配置驱动 + 占位符模板的设计:模板不是一个函数,而是一套真实的目录文件,里面用{{变量名}}标注需要替换的位置。代码只负责复制、扫描、替换,至于项目长什么样,完全由模板目录里的文件结构决定。这样一来,模板的修改就不需要动代码,甚至不懂编程的人也能改。
2.1 配置存在哪里,模板目录怎么组织
rea 的配置我放在~/.rea/config.json,默认模板目录是~/.rea/templates/。配置内容大概长这样:
{ "settings": { "author": "myalias", "git_init": true, "dry_run": false }, "templates": { "python-lib": "~/.rea/templates/python-lib", "python-cli": "~/.rea/templates/python-cli", "fastapi-api": "~/.rea/templates/fastapi-api" } }config.json里没有魔法,每个模板就是一条路径。这样做的好处是,模板本身可以独立打包、独立备份,甚至放到某个私有仓库里同步。我只需要维护一套“干净的模板目录”,其他所有项目都从这套目录上长出来。
2.2 模板里的占位符规则
模板目录里允许出现两类占位符:一类出现在文件名/目录名中,一类出现在文本文件的内容中。我常用这几个变量:
{{project_name}}:项目对外展示名,比如web-demo{{package_name}}:Python 包名,比如web_demo{{author}}:作者名{{current_year}}:当前年份
模板目录结构大概是这样:
~/.rea/templates/python-cli ├── {{package_name}}/ │ ├── __init__.py │ └── cli.py ├── tests/ │ └── test_{{package_name}}.py ├── README.md ├── pyproject.toml └── .gitignore你会注意到,目录名{{package_name}}也会被替换。这个看起来很容易,实际却有一个很容易忽略的点:必须先重命名目录,再去扫描目录内的文件。如果顺序反了,文件路径里的目录名一变,后续扫描可能找不到原来还在扫描队列里的子文件。
2.3 干跑模式为什么必须优先实现
我之前有个习惯,很多工具写出来先跑一遍,觉得没问题就完事。但 rea 这类生成工具有个特点:它是“创建型”操作,而且一次性会在磁盘上铺开几十个文件。如果不提供干跑模式,用户只能在“实际执行之后”才发现变量名拼错了,或者模板路径写错了,然后手动删掉一堆刚生成的文件。这个过程很伤,尤其是当目标目录已经存在旧项目时。
所以我把--dry-run作为一等公民设计:进入生成流程后,第一件事不是创建目录,而是“遍历模板,收集计划”。等全部文件扫完,再逐行打印:
[rea] 将创建目录: web-demo [rea] 将创建文件: web-demo/README.md [rea] 将创建文件: web-demo/pyproject.toml [rea] 变量 {{project_name}} -> web-demo [rea] 变量 {{package_name}} -> web_demo [rea] 干跑模式结束,未写入任何文件这个输出带来的安心感是实打实的。后来我甚至把干跑模式当成了默认习惯,真正往磁盘写文件之前先--dry-run一遍。
3. 用纯标准库实现 rea:从读配置到生成项目的完整链路
rea 的实现语言我选了 Python,理由很实际:我有大量配置文件和模板都是 Python 生态的,用 Python 处理起来最顺。而且 Python 标准库里的pathlib、shutil、argparse刚好覆盖了文件复制、路径拼接和命令行参数解析三块核心能力,基本不需要引入第三发依赖。
3.1 命令行入口与参数设计
rea的命令行入口我只做了两个子命令,一个是list,一个是new。参数不要贪多,时间越久越会发现,能少暴露一个参数就少暴露一个参数。命令在用户侧的体验是:
rea list rea new my-project -t python-lib rea new my-project -t python-lib --pkg my_lib --author "someone" rea new my-project -t python-cli --dry-run rea new my-project -t python-cli --force参数含义集中在new子命令里:-t指定模板名,--pkg指定包名,--dry-run只做预演,--force允许覆盖已存在的目标目录。
3.2 核心生成流程的代码骨架
下面这段代码是我精简后的 rea 核心,不包含完整异常处理,但生成链路一目了然:
import argparse import json import re import shutil import subprocess from datetime import datetime from pathlib import Path from typing import Dict, Iterable, Optional TEXT_EXTENSIONS = { ".py", ".md", ".txt", ".yml", ".yaml", ".json", ".toml", ".ini", ".cfg", ".sh", ".html" } PLACEHOLDER_RE = re.compile(r"\{\{\s*(\w+)\s*\}\}") def load_config(config_path: Path) -> dict: if not config_path.exists(): raise SystemExit(f"[rea] 配置文件不存在: {config_path}") data = json.loads(config_path.read_text(encoding="utf-8")) return data def resolve_template(config: dict, template_name: str) -> Path: template = config["templates"].get(template_name) if not template: raise SystemExit(f"[rea] 模板不存在: {template_name}") resolved_path = Path(template).expanduser() if not resolved_path.is_dir(): raise SystemExit(f"[rea] 模板路径不存在: {resolved_path}") return resolved_path def build_variables(args: argparse.Namespace) -> Dict[str, str]: return { "project_name": args.project_name, "package_name": args.pkg or args.project_name.replace("-", "_"), "author": args.author or "unknown", "current_year": str(datetime.now().year), } def generate( template_dir: Path, target_dir: Path, variables: Dict[str, str], dry_run: bool = False, ) -> None: if target_dir.exists(): raise SystemExit(f"[rea] 目标目录已存在: {target_dir}") if dry_run: print(f"[rea] 将创建目录: {target_dir}") for src in template_dir.rglob("*"): rel = src.relative_to(template_dir) rel = Path(*[variables.get(part, part) for part in rel.parts]) if src.is_file(): print(f"[rea] 将创建文件: {target_dir / rel}") print("[rea] 干跑模式结束,未写入任何文件") return shutil.copytree( template_dir, target_dir, ignore=shutil.ignore_patterns("__pycache__", ".git", "_meta.json"), ) for old in target_dir.rglob("*"): if old.name.startswith("{{") and old.name.endswith("}}"): new_name = PLACEHOLDER_RE.sub( lambda m: variables.get(m.group(1), m.group(0)), old.name ) old.rename(old.with_name(new_name)) old = old.with_name(new_name) if old.is_file() and old.suffix.lower() in TEXT_EXTENSIONS: text = old.read_text(encoding="utf-8") text = PLACEHOLDER_RE.sub( lambda m: variables.get(m.group(1), m.group(0)), text ) old.write_text(text, encoding="utf-8")generate函数承担了 rea 最主要的工作,顺序是:先校验目标目录、再复制整棵模板树、然后重命名占位目录、最后替换文本文件内容。占位符替换用正则{{ variable }}匹配,里面要求变量名只能是字母数字下划线,减少了误伤概率。
3.3 与 git 和依赖安装的联动
生成目录之后,如果用户在配置里打开了git_init,rea 会在目标目录下执行一次:
git init但这里有个很关键的细节:不要在复制文件之前就执行git init。因为一旦git init成功,目标目录里就会出现.git目录,如果后面复制流程出错,用户会发现想删目标目录时,里面多了一层凶恶的.git历史。所以我把git init放到了生成流程的最后一步,并且这个步骤也允许被--no-git跳过。
依赖安装我一开始也想自动做,后来放弃了。原因是“自动安装”会带来大量网络和版本不确定因素,一个脚手架工具最不该在第一步就和网络较劲。rea 只负责把项目骨架搭好,安装依赖这个动作留给用户手动执行,失败时不会污染整个执行流程。
4. 开发 rea 时真实踩过的四个坑,以及定位过程
这部分是本篇最有价值的部分。rea 的功能并不复杂,但它会实际地在磁盘上创建文件、覆盖目录、做编码转换,这些操作在真实环境里有一堆坑。我按踩坑的时间顺序记录四个最典型的。
4.1 坑一:模板里的二进制文件被替换成乱码
症状很直接:我在某个模板里放了一张logo.png,生成新项目后,打开图片发现文件损坏,编辑器提示不是有效的图片文件。查来查去,定位到最后发现是 rea 的渲染逻辑把所有文件都按 UTF-8 文本读取了一遍:
text = old.read_text(encoding="utf-8") old.write_text(text, encoding="utf-8")PNG 是二进制文件,用文本模式读取了,即便不去做替换,再写回去也可能丢掉原来的字节。当时我日志也没打,只看到图片文件还在,大小变成了几 KB,一下就知道是被文本读写污染了。
修复方案是把“是否按文本处理”做成扩展名白名单:只对.py、.md、.txt、.json等文本文件做占位符替换,其他扩展名一律走shutil.copy2原样复制。这个改动也让我后来增补扩展名时极克制,宁可漏处理一个文本类型,也不要误伤二进制文件。
4.2 坑二:路径分隔符在不同平台上的表现不一致
我早期写代码时图省事,喜欢用字符串拼接路径:
template_path + "/" + relative_path在 Linux 和 macOS 上跑得好好的,一放到 Windows 上就出问题。Windows 的路径分隔符是反斜杠,字符串里如果混入正斜杠,某些系统 API 能容错,某些不能。最惨的一种情况是:模板里有一个{{package_name}}目录,路径拼出来之后变成了template_dir\\web_demo\\cli.py,再用字符串替换去处理时,正则和字符串的匹配逻辑全乱套。
后来我把所有路径操作全部改成pathlib.Path,统一用/运算符组合路径,杜绝了手写分隔符的问题。这个坑对个人项目尤其隐蔽,因为你的开发环境和朋友们的运行环境大概率不是同一套操作系统。
4.3 坑三:模板里的“{{”被误替换
占位符替换这件事,最怕的就是“该替换的替换了,不该替换的也被动了”。有些项目代码里会用到花括号,比如 Python 的格式化字符串、Jinja2 模板、C 语言的初始化列表,都会有类似{{variable}}或{{value}}的片段。
我之前随手写了一个正则\{\{\s*(.+?)\s*\}\},匹配范围太宽,导致生成后的文件里,有些格式占位符被当成 rea 变量替换掉了。更麻烦的是,这些错误在测试时还不一定能发现,要到运行时才暴露。
最终的解决思路是“只替换已知变量”:先收集配置里出现的、以及命令行传进来的变量集合,正则匹配结果如果不在这个集合里,就原样保留。另外对确实需要字面保留{{foo}}的文件,我在模板里约定转义写法{{ "{{" }},渲染后还原成{{。这样既保住了灵活性,也避免了误伤。
4.4 坑四:--force 覆盖目录时直接物理删除
最初实现--force时,我的逻辑很简单粗暴:目标目录存在就rmtree删除,然后重新复制。这个逻辑在小项目上看着没问题,但有一次我在目标目录里已经放了一个config.local.yaml文件,这个文件不是模板生成的,是手工编辑过的配置。执行rea new some-project --force之后,旧配置直接没了。
从那次之后我再也不敢物理删除目标目录,改成“先备份再覆盖”:执行 force 时,把原有目录重命名为{project_name}.bak_{timestamp},然后在新位置生成项目。如果新生成的结果有问题,用户还能从备份里捞回数据。这个改动让--force从“危险操作”变成“安全操作”,本质上就是承认:生成器没有资格替用户删除任何东西。
5. 把 rea 打磨成可以每天使用的工具:测试、文档和模板管理
代码能跑通只是第一步。一个工具要真正成为“每天都会用”的工具,还差三件事:可靠的测试、清晰的帮助信息、可复用的模板管理方式。
5.1 帮助信息和退出码
我把rea的--help输出写成了下面这样,不追求炫酷,每个字段都告诉用户“这个命令会产生什么副作用”:
usage: rea [-h] {list,new} ... rea - reusable executable assistant 子命令: list 列出所有已注册模板 new 根据模板生成新项目 rea new 参数: project_name 项目名称 -t, --template 模板名,与 config.json 中模板 key 一致 --pkg 自定义 Python 包名,默认把 - 替换为 _ --dry-run 只展示将要创建的文件,不写入磁盘 --force 若目标目录存在,先备份后覆盖 --no-git 不自动执行 git init退出码也是可以给其他脚本程序用的“协议”。我设计成下表,让脚本调用 rea 时能判断失败原因:
| 退出码 | 含义 |
|---|---|
| 0 | 生成成功 |
| 2 | 命令行参数不合法(argparse 默认) |
| 3 | 配置文件不存在或无法解析 |
| 4 | 模板不存在 |
| 5 | 目标目录已存在且未加 --force |
| 6 | 干跑或生成过程中出现未知异常 |
5.2 用 pytest 做回归测试
rea 是典型的文件密集型工具,最适合用tmp_path做隔离测试。我测试时不会碰真实的~/.rea,而是用 pytest 自动创建的临时目录模拟整个环境。一个最基础的用例长这样:
from rea_core import generate from pathlib import Path def test_generate_replaces_package_name(tmp_path): template = tmp_path / "tpl" (template / "{{package_name}}").mkdir(parents=True) (template / "{{package_name}}" / "__init__.py").write_text( "name = '{{package_name}}'", encoding="utf-8" ) target = tmp_path / "out" generate(template, target, variables={"package_name": "demo_pkg"}) generated_file = target / "demo_pkg" / "__init__.py" assert generated_file.exists() assert generated_file.read_text(encoding="utf-8") == "name = 'demo_pkg'"这行测试看着简单,但覆盖了目录占位符替换、嵌套路径创建、文本内容替换三个核心环节。后来又陆续补了二进制文件不被破坏、目标已存在时报错、干跑模式不落盘等用例,每次改代码后跑一轮,心里踏实很多。
5.3 模板的分层管理技巧
用 rea 时间长了以后,我意识到一个规律:模板之间的差异通常小于共性。Python 库和 FastAPI 服务看起来差别很大,但它们都要有README.md、.gitignore、pyproject.toml、tests/main.py这些基础设施。所以我把模板拆成了“通用基础层”和“业务差异层”两个部分。
基础层放在~/.rea/templates/_base,业务模板通过_meta.json声明自己继承哪个基础层:
{ "extends": "_base", "description": "FastAPI 服务模板" }生成时,rea 先复制_base,再复制fastapi-api的差异文件,用差异文件覆盖同名文件。这个设计让新增模板变得非常廉价:大部分模板只要在基础层上改动入口文件和依赖配置就能成型。至于更复杂的“按条件选择文件”逻辑,我暂时不做,因为让模板保持“可读、可预判”比“强大”更重要。
如果现在让我给 rea 再加一个功能,我不会加图形界面,也不会加插件系统。我会优先做“模板变量校验”:定义每个模板必须提供哪些变量、哪些变量有默认值,在生成前一次性检查完,把错误挡在最前面。这样工具的行为会更接近一种“表单”,而不是“黑盒脚本”。小工具的价值不在于功能多,而在于你每敲一次命令,都能确定它下一步会做什么,并且不会把你的旧数据弄丢。