Tech Radar - 学习记录
目录
- 项目初始化与技术栈选择
- 数据模型设计:Article 与 @property 的用法
- SQLite 去重:INSERT OR IGNORE vs 先查后插
- 并发请求:从串行到 asyncio.gather
1. 项目初始化与技术栈选择
一句话结论:选 Python 而不是 TypeScript,因为学一门新语言比重复练熟练度更能拉升能力面,且 Python 是 AI 生态的主力语言。
为什么这样设计
取舍分析:
| TypeScript | Python | |
|---|---|---|
| 优势 | 零学习成本,前端直接上手 | AI 生态主力,学了能用在更多场景 |
| 劣势 | 只是练熟练度,没有新知识增量 | 需要学新语法和工具链 |
| 适合场景 | 赶工期、不想冒险 | 学习驱动、想扩展能力面 |
关键判断:这个项目的目的不是"尽快做完",而是"边做边学"。选 Python 是刻意让自己走出舒适区。
工具链类比
Node.js 你熟悉的 Python 对应的
──────────────────────────────────────
npm / pnpm uv(现代一体化方案)
package.json pyproject.toml
package-lock.json uv.lock
npx uv run
node_modules/ .venv/(虚拟环境)
npm init uv init
为什么选 uv 而不是 pip:pip 相当于早期的 npm —— 能用,但没有 lock 文件、不自动管虚拟环境、安装慢。uv 是 2024 年用 Rust 写的新一代工具,一个命令干了以前 pip + venv + pip-tools 三个工具的活。类比就是 pnpm 对 npm 的关系。
相关代码位置
pyproject.toml— 项目配置和依赖声明.python-version— 锁定 Python 版本
面试 / 技术对话角度
面试话术:"我在个人项目中选了 Python 而不是更熟悉的 TypeScript,因为项目目标是学习而非交付。Python 在 AI 生态中是主力语言,HTTP 请求和数据处理也是它的舒适区。工具链上选了 uv 替代传统 pip,它类似于 Node.js 世界里 pnpm 对 npm 的升级——一体化管理依赖、虚拟环境和 lock 文件。"
2. 数据模型设计:Article 与 @property 的用法
一句话结论:Article 的 id 不是传入的字段,而是由 source + url 实时计算的派生值,用 @property 实现,防止外部传错。
原理 / 本质
Python 的 @property 让一个方法看起来像属性访问。调用时不需要加括号:
# 定义
@property
def id(self) -> str:
return sha256(f"{self.source}:{self.url}".encode()).hexdigest()[:16]
# 使用 — 看起来像访问属性,实际在执行函数
article.id # ✅ 不是 article.id()
与前端已知知识的类比
Python @property React 对应概念
──────────────────────────────────────
@property useMemo(派生状态)
id 由 source+url 决定 memo 值由 deps 决定
不需要手动维护 不需要手动同步
核心思想一致:如果一个值能从其他值算出来,就不要让人手动传,让系统自己算。
三层对比
❌ 初级做法:id 作为普通字段,创建时手动传入
→ 问题:调用者可能传错值、拼写错误、忘记传
⚠️ 中级做法:在 __init__ 里自动计算 id 并赋值
→ 隐患:如果 url 后来被修改了,id 不会自动更新(虽然在本项目中 Article 创建后不会改)
✅ 资深做法:用 @property 每次访问实时计算
→ 保证 id 永远和 source+url 一致,无论何时访问
→ 代价是每次访问都要算一次 hash,但 sha256 很快,不是瓶颈
相关代码位置
src/tech_radar/models.py:17-19—@property def id
面试话术
面试话术:"在数据模型设计中,我把 id 设计为 @property 而非普通字段,因为它是 source 和 url 的派生值。这样做的好处是调用者不可能传错 id,类似于 React 中 useMemo 的思想——能从已有数据算出来的值就不要让人手动维护。代价是每次访问会重新计算 hash,但 sha256 在这个数据量下完全不是瓶颈。"
3. SQLite 去重:INSERT OR IGNORE vs 先查后插
一句话结论:用 INSERT + 捕获 IntegrityError 实现去重,一次操作搞定,比"先 SELECT 再 INSERT"更高效且没有竞态问题。
为什么这样设计
两种去重方式的对比:
# ❌ 先查后插 — 两次数据库操作
existing = db.execute("SELECT id FROM articles WHERE id = ?", (article.id,))
if not existing:
db.execute("INSERT INTO articles ...", (...))
# 问题1: 两次 IO
# 问题2: 并发时,查的时候不存在,插的时候别人已经插了 → 报错
# ✅ 直接插,失败就跳过 — 一次操作
try:
db.execute("INSERT INTO articles ...", (...))
except sqlite3.IntegrityError:
pass # PRIMARY KEY 冲突 = 重复,跳过
# 让数据库自己用约束来判断,比应用层判断更可靠
原理 / 本质
这利用了数据库的 PRIMARY KEY 约束:如果插入的 id 已存在,SQLite 会抛 IntegrityError。数据库引擎在最底层保证了唯一性——比你在 Python 代码里自己判断更可靠,因为数据库内部会加锁。
与前端已知知识的类比
类似于表单提交时的处理策略:
❌ 前端自己判断"用户名是否存在" → 发请求查 → 再发请求注册
(两次请求之间别人可能注册了同名用户)
✅ 直接提交注册 → 后端用数据库唯一约束判断 → 冲突返回错误
(一次操作,数据库保证)
本质是同一个原则:把唯一性校验交给最权威的那一层(数据库),而不是在应用层自己实现。
相关代码位置
src/tech_radar/db.py:46-61—insert_articles方法
面试话术
面试话术:"去重我用了 INSERT + 捕获 IntegrityError 的方式,而不是先 SELECT 再 INSERT。原因有两个:一是减少一次 IO 操作;二是避免竞态条件——在并发场景下,先查后插有 TOCTOU 问题(Time of Check vs Time of Use),而直接 INSERT 让数据库用 PRIMARY KEY 约束来保证唯一性,是最可靠的做法。"
4. 并发请求:从串行到 asyncio.gather
一句话结论:30 个互不依赖的 HTTP 请求应该并发而非串行。Python 的 asyncio.gather 等价于 JavaScript 的 Promise.all。
原理 / 本质
串行(改之前):
请求1 ──────> 请求2 ──────> 请求3 ──────> ... 请求30
总耗时 = 30 × 单次请求时间 ≈ 3-5 秒
并发(改之后):
请求1 ──────>
请求2 ──────>
请求3 ──────>
...
请求30 ──────>
总耗时 ≈ 最慢的那一个 ≈ 0.3-0.5 秒
判断标准很简单:后面的请求需不需要前面的结果?不需要 → 并发。需要 → 串行。
HN adapter 里,每条 story 只需要自己的 id 来请求详情,和其他 story 没有任何关系,所以应该并发。
与前端已知知识的类比
JavaScript Python
──────────────────────────────────────────────────
Promise.all([p1, p2, p3]) asyncio.gather(t1, t2, t3)
→ 一个失败全部失败 → 一个失败全部失败
Promise.allSettled([p1, p2, p3]) asyncio.gather(t1, t2, t3, return_exceptions=True)
→ 失败的返回 {status: 'rejected'} → 失败的返回 Exception 对象
// 前端常用模式:catch 后返回 null # Python 等价模式:except 后返回 None
urls.map(u => async def safe_fetch(u):
fetch(u).catch(() => null) try: return await client.get(u)
) except: return None
数据流 / 执行流程图
story_ids = [1, 2, 3, ..., 30]
│
▼
┌──────────────────────────────┐
│ asyncio.gather( │
│ fetch_item(1), │ ── 30 个协程同时发出
│ fetch_item(2), │
│ ... │
│ fetch_item(30), │
│ ) │
└──────────┬───────────────────┘
│ [dict|None, dict|None, ...]
▼
┌──────────────────────────────┐
│ 过滤 None + score < 50 │ ── 串行处理结果
│ → 构建 Article 列表 │
└──────────────────────────────┘
三层对比
❌ 初级做法:for 循环里 await 每个请求(串行)
→ 30 个请求排队执行,慢
⚠️ 中级做法:asyncio.gather 无脑并发所有请求
→ 30 个没问题,但如果是 300 或 3000 个呢?
会同时打开大量连接,可能被服务端限流或拒绝
✅ 资深做法:用 Semaphore 控制并发上限
→ 例如最多同时 10 个请求,既快又不会压垮服务端
→ 类似前端的"请求池"概念:p-limit、p-queue 库做的事
相关代码位置
src/tech_radar/sources/hackernews.py:29-41—asyncio.gather并发请求
延伸讨论:如果是 300 个请求怎么办
直接 gather 300 个请求的问题:
- 服务端限流 — 大部分 API 不允许同一 IP 同时发几百个请求
- 本地资源耗尽 — 同时打开几百个 TCP 连接,fd 可能不够
- 超时堆积 — 如果其中几个很慢,会拖住整体
解决方案是 asyncio.Semaphore(信号量),限制同时执行的并发数:
sem = asyncio.Semaphore(10) # 最多同时 10 个
async def fetch_with_limit(sid):
async with sem: # 超过 10 个就等待
return await fetch_item(sid)
results = await asyncio.gather(*(fetch_with_limit(sid) for sid in story_ids))
前端等价:p-limit 库做的就是这个事。
面试话术
面试话术:"对于互不依赖的 IO 操作,我用 asyncio.gather 并发执行而非串行。这和前端的 Promise.all 是一样的思路。但在生产环境中需要注意并发控制——如果请求量很大,直接 gather 几百个会压垮服务端。这时需要用 Semaphore 做并发限流,类似前端社区的 p-limit 库,控制同一时间最多发出 N 个请求。"