Tech Radar - 学习记录

Tech Radar - 学习记录

目录

  1. 项目初始化与技术栈选择
  2. 数据模型设计:Article 与 @property 的用法
  3. SQLite 去重:INSERT OR IGNORE vs 先查后插
  4. 并发请求:从串行到 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-61insert_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-41asyncio.gather 并发请求

延伸讨论:如果是 300 个请求怎么办

直接 gather 300 个请求的问题:

  1. 服务端限流 — 大部分 API 不允许同一 IP 同时发几百个请求
  2. 本地资源耗尽 — 同时打开几百个 TCP 连接,fd 可能不够
  3. 超时堆积 — 如果其中几个很慢,会拖住整体

解决方案是 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 个请求。"