用 FastAPI 写一个只读 API:从建表到部署

米阳 2025-12-13 8 12/13

这个知识库的后端就是一个只读 API:查文章、看统计、读知识页。几千行代码,跑在一台 2 核机器上。这篇说一下结构和几个关键决策。

为什么只读

内容由后台脚本写入(同步、编译),API 只负责查询。好处:

  • 不需要处理并发写入、事务、锁
  • 可以用 SQLite 的只读模式打开,天然防误写
  • 缓存策略简单,读多写少

只有「加新内容」这类操作走 POST,且必须过口令验证。

只读打开 SQLite

import sqlite3

def conn():
    c = sqlite3.connect(f"file:{DB}?mode=ro", uri=True, timeout=5)
    c.row_factory = sqlite3.Row
    return c

mode=ro 是内核级保证,任何写操作直接报错。比在代码里小心不要写要可靠得多。

timeout=5 避免写入方持有锁时立即报 database is locked。

结构

app/
├── serve.py       # FastAPI 入口,路由都在这里
├── ask.py         # 检索逻辑
├── store.py       # 数据库 schema 和工具函数
├── llm.py         # 模型调用
└── webapp/index.html   # 单页前端

路由按用途分:读(GET)、写(POST,带口令)、配置(GET/PUT)。写操作的入口只有几个,都要过校验。

一个路由的样子

from fastapi import FastAPI, HTTPException, Query

app = FastAPI()

@app.get("/api/articles")
def list_articles(q: str = "", page: int = Query(1, ge=1),
                  size: int = Query(20, le=100)):
    with conn() as c:
        if q:
            rows = c.execute(
                "SELECT id, title, url, source FROM article_fts f "
                "JOIN articles a ON a.id = f.rowid WHERE article_fts MATCH ? "
                "LIMIT ? OFFSET ?", (q, size, (page - 1) * size)).fetchall()
        else:
            rows = c.execute(
                "SELECT id, title, url, source FROM articles "
                "ORDER BY published_at DESC LIMIT ? OFFSET ?",
                (size, (page - 1) * size)).fetchall()
    return {"items": [dict(r) for r in rows]}

Query(1, ge=1) 自动做参数校验,le=100 防止有人一次要 10000 条把服务拖垮。这类边界必须在 API 层挡住,不能靠前端。

分页

LIMIT/OFFSET 在数据量大时越翻越慢。更好的做法是游标分页:

SELECT * FROM articles WHERE id < ? ORDER BY id DESC LIMIT 20;

前端传上一页最后一条的 id,不用算 offset。数据量上万之后差别明显。

静态页面

把 index.html 挂在根路径,前端用原生 JS 调 API,不引框架:

from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse

@app.get("/")
def index():
    return FileResponse(os.path.join(BASE, "webapp/index.html"))

一个 HTML 文件搞定所有页面,没有构建步骤,改完刷新就生效。对内部工具来说,这比上 Vue/React 划算得多。

部署

ExecStart=/www/blog-ai/.venv/bin/python -m uvicorn serve:app --host 127.0.0.1 --port 8800

uvicorn 监听本地,Nginx 反代到公网,加 Basic Auth。这样应用不直接暴露,也不用在代码里处理 TLS。

几点经验

  1. 日志用 print 就够,systemd 会收进 journalctl,不用上日志框架
  2. 错误信息要脱敏,HTTPException(500, str(exc)[:300]) 截断,避免把路径、SQL 泄出去
  3. 加个 /api/stats,返回文章数、同步时间,方便监控和健康检查
  4. 写接口要有白名单,只接受固定字段,不要 **kwargs 直接传进 SQL

一句话

只读 API 的价值在「简单可靠」。不需要 ORM,不需要迁移框架,SQL 写得清楚,跑起来就不会出意外。

- THE END -

米阳

12月13日20:45

最后修改:2025年12月13日
0

非特殊说明,本博所有文章均为博主原创。

共有 0 条评论