数据库应用开发入门:从需求梳理到功能落地

科技资讯48分钟前发布 jdkjadmin
0 00
数据库应用开发入门:从需求梳理到功能落地

数据库应用开发入门:从需求梳理到功能落地

数据库应用并不只是“建几张表,再写几条 SQL”。一个可用的应用,要先确定谁会使用它、数据如何变化、错误如何处理,再把这些约束落实到数据模型、接口和代码中。开发初期如果跳过需求梳理,常见结果是字段反复修改、列表接口越来越复杂,或者数据被并发请求意外覆盖。

本文以一个待办事项应用为例,介绍从需求到可运行功能的基本过程。示例使用 Python、Flask 和 SQLite,实现待办事项的创建、查询、更新与删除,并说明哪些做法适合入门项目,哪些问题需要在生产环境另行处理。

一、先判断数据库应用适不适合当前问题

只要多个信息需要被长期保存、查找或共同维护,就可能需要数据库。常见场景包括任务管理、客户记录、库存、订单、预约和内容管理。数据库能够按条件查询数据,并用约束维护数据之间的关系;它也可以在应用重启后保留信息。

但“需要保存数据”不代表任何情况下都要先上一个大型数据库。个人脚本只保存少量临时状态,文本文件或现有系统提供的存储服务可能更简单。需要多人同时访问、复杂筛选、数据关联或稳定备份时,关系型数据库通常更合适。本文选 SQLite,是因为它无需单独部署数据库服务,便于演示完整流程;它不是所有线上业务的默认答案。

SQLite 与服务型数据库的边界

  • 适合 SQLite 的场景:本地工具、原型、小型单机应用、自动化脚本,以及读写规模和并发需求有限的服务。
  • 需要评估其他方案的场景:多个应用实例要共同访问数据、写入并发较高、需要细粒度权限管理、复制与高可用,或已有统一的数据库运维体系。
  • 部署上的关键点:SQLite 数据存放在文件中。多实例部署时,若每个实例各有一份本地数据库,数据不会自动共享;容器重建也可能导致未挂载的数据丢失。需要持久化与共享存储设计。

选型不能只看数据量。数据一致性要求、写入方式、备份恢复、部署架构、团队运维能力,都会影响决策。对初学者来说,先用简单方案验证需求是合理的,但应知道它的边界在哪里。

二、从需求写出可验证的行为

“做一个待办事项系统”还不足以直接开发。需要将它拆成可以确认的行为。一个最小版本可以采用下面的需求:

  1. 用户可以创建待办事项,必须提供标题,标题长度为 1 到 200 个字符。
  2. 事项默认未完成;客户端也可以在创建时明确指定完成状态。
  3. 可以按页查询事项,结果按创建时间倒序排列。
  4. 可以通过事项 ID 查看、更新或删除事项。
  5. 更新请求只修改明确提供的字段;未提供的字段保持不变。
  6. 标题不符合规则、JSON 无效、记录不存在等情况,需要返回清晰的错误状态。

需求还要回答暂时不做什么。例如,这个入门应用没有用户登录、权限隔离、全文搜索、提醒和软删除。明确这些范围,能避免读者误以为“能增删改查”就等于完整业务系统。若待办事项属于不同用户,后续必须加入身份认证,并在每次查询和修改时校验数据归属;仅凭客户端传来的事项 ID 不能证明访问者有权限。

三、设计数据模型:先确定事实,再决定字段

根据上面的需求,待办事项至少需要一个唯一 ID、标题、完成状态和创建时间。创建时间用于排序,也方便之后分析数据。以下是一个简单的关系模型:

  • id:整数主键,由数据库生成。
  • title:非空文本,数据库侧限制最多 200 个字符。
  • done:整数状态,取值 0 或 1。
  • created_at:创建时间,保存为 UTC 的 ISO 8601 文本。

数据库约束与应用校验并不重复。应用校验可以给用户更友好的错误反馈;数据库约束则能拦截绕过应用代码写入的非法数据。两者解决的问题不同,可靠的设计通常会同时使用。

建表语句如下:

CREATE TABLE IF NOT EXISTS tasks (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL CHECK (length(title) BETWEEN 1 AND 200),
    done INTEGER NOT NULL DEFAULT 0 CHECK (done IN (0, 1)),
    created_at TEXT NOT NULL
);

SQLite 的 length 对文本的计数方式与用户界面中“字符长度”的具体定义可能不同;包含组合字符时,视觉字符数、Unicode 码点数和数据库函数计数不一定一致。示例将 200 作为应用层长度限制,并用数据库约束做基础防护,不能将其当作复杂国际化文本长度规则的完整实现。

本文暂不建立用户表和外键,因为示例没有账户功能。不要为了“显得正规”而增加暂时无法解释的数据表;同样,也不要在真实多用户业务里忽略数据归属关系。模型应由业务规则驱动。

四、设计接口:定义输入、输出和失败方式

示例使用 JSON HTTP API,路由约定如下:

方法与路径用途成功状态
POST /tasks创建事项201
GET /tasks分页查询事项200
GET /tasks/<id>查询单条事项200
PATCH /tasks/<id>更新提供的字段200
DELETE /tasks/<id>删除事项204

查询接口使用 limit 和 offset。本例将每页条数限制在 1 到 100,并要求偏移量非负。限制结果数量可以避免客户端一次请求大量记录,减少响应和数据库查询的资源消耗;它不是身份认证、拒绝服务防护或数据库层面的完整安全方案。

五、准备运行环境

示例适用于 Python 3.9 及以上版本。新建项目目录,在终端创建虚拟环境并安装 Flask:

python -m venv .venv
# macOS / Linux
. .venv/bin/activate
# Windows PowerShell
# .venv\Scripts\Activate.ps1

python -m pip install Flask

将下面的完整代码保存为 app.py。数据库文件默认创建在当前工作目录的 tasks.db。设置 TASKS_DB 环境变量可以更改文件路径。

六、完整示例:Flask + SQLite 待办事项 API

import os
import sqlite3
from datetime import datetime, timezone

from flask import Flask, g, jsonify, request

app = Flask(__name__)
DATABASE = os.environ.get("TASKS_DB", "tasks.db")

SCHEMA = """
CREATE TABLE IF NOT EXISTS tasks (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL CHECK (length(title) BETWEEN 1 AND 200),
    done INTEGER NOT NULL DEFAULT 0 CHECK (done IN (0, 1)),
    created_at TEXT NOT NULL
);
"""


def get_db():
    # 每个请求各自获取连接,并在请求结束时关闭。
    if "db" not in g:
        g.db = sqlite3.connect(DATABASE)
        g.db.row_factory = sqlite3.Row
        # 本示例没有外键;如添加外键表,应在每个连接启用外键约束。
    return g.db


@app.teardown_appcontext
def close_db(_error):
    db = g.pop("db", None)
    if db is not None:
        db.close()


def init_db():
    # 示例为便于首次运行,在启动时创建表。
    # 生产环境应使用可追踪、可回滚的数据库迁移。
    connection = sqlite3.connect(DATABASE)
    try:
        connection.executescript(SCHEMA)
        connection.commit()
    finally:
        connection.close()


def error(message, status):
    return jsonify({"error": message}), status


def parse_task_id(raw_id):
    try:
        task_id = int(raw_id)
    except (TypeError, ValueError):
        return None
    return task_id if task_id > 0 else None


def read_json_object():
    # silent=True 会把无法解析的 JSON 转为 None,再统一返回 400。
    data = request.get_json(silent=True)
    if not isinstance(data, dict):
        return None
    return data


def validate_title(value):
    if not isinstance(value, str):
        return "title 必须是字符串"
    title = value.strip()
    if not title:
        return "title 不能为空"
    if len(title) > 200:
        return "title 不能超过 200 个字符"
    return None


def task_to_dict(row):
    return {
        "id": row["id"],
        "title": row["title"],
        "done": bool(row["done"]),
        "created_at": row["created_at"],
    }


@app.post("/tasks")
def create_task():
    data = read_json_object()
    if data is None:
        return error("请求体必须是 JSON 对象", 400)

    if "title" not in data:
        return error("缺少 title", 400)
    message = validate_title(data["title"])
    if message:
        return error(message, 400)

    done = data.get("done", False)
    if not isinstance(done, bool):
        return error("done 必须是布尔值", 400)

    title = data["title"].strip()
    created_at = datetime.now(timezone.utc).isoformat()
    db = get_db()

    cursor = db.execute(
        "INSERT INTO tasks (title, done, created_at) VALUES (?, ?, ?)",
        (title, int(done), created_at),
    )
    db.commit()

    row = db.execute(
        "SELECT id, title, done, created_at FROM tasks WHERE id = ?",
        (cursor.lastrowid,),
    ).fetchone()
    return jsonify(task_to_dict(row)), 201


@app.get("/tasks")
def list_tasks():
    def query_integer(name, default):
        raw = request.args.get(name)
        if raw is None:
            return default
        try:
            return int(raw)
        except ValueError:
            return None

    limit = query_integer("limit", 20)
    offset = query_integer("offset", 0)

    if limit is None or not 1 <= limit <= 100:
        return error("limit 必须是 1 到 100 的整数", 400)
    if offset is None or offset < 0:
        return error("offset 必须是非负整数", 400)

    rows = get_db().execute(
        """
        SELECT id, title, done, created_at
        FROM tasks
        ORDER BY created_at DESC, id DESC
        LIMIT ? OFFSET ?
        """,
        (limit, offset),
    ).fetchall()

    return jsonify({
        "items": [task_to_dict(row) for row in rows],
        "limit": limit,
        "offset": offset,
    })


@app.get("/tasks/<task_id>")
def get_task(task_id):
    parsed_id = parse_task_id(task_id)
    if parsed_id is None:
        return error("id 必须是正整数", 400)

    row = get_db().execute(
        "SELECT id, title, done, created_at FROM tasks WHERE id = ?",
        (parsed_id,),
    ).fetchone()
    if row is None:
        return error("事项不存在", 404)
    return jsonify(task_to_dict(row))


@app.patch("/tasks/<task_id>")
def update_task(task_id):
    parsed_id = parse_task_id(task_id)
    if parsed_id is None:
        return error("id 必须是正整数", 400)

    data = read_json_object()
    if data is None:
        return error("请求体必须是 JSON 对象", 400)
    if not data:
        return error("至少提供一个要更新的字段", 400)

    allowed = {"title", "done"}
    unknown = set(data) - allowed
    if unknown:
        return error("包含不支持的字段", 400)

    db = get_db()
    if db.execute(
        "SELECT id FROM tasks WHERE id = ?", (parsed_id,)
    ).fetchone() is None:
        return error("事项不存在", 404)

    if "title" in data:
        message = validate_title(data["title"])
        if message:
            return error(message, 400)
    if "done" in data and not isinstance(data["done"], bool):
        return error("done 必须是布尔值", 400)

    # 列名来自固定白名单;值通过占位符绑定。
    fields = []
    values = []
    if "title" in data:
        fields.append("title = ?")
        values.append(data["title"].strip())
    if "done" in data:
        fields.append("done = ?")
        values.append(int(data["done"]))

    values.append(parsed_id)
    db.execute(
        "UPDATE tasks SET " + ", ".join(fields) + " WHERE id = ?",
        values,
    )
    db.commit()

    row = db.execute(
        "SELECT id, title, done, created_at FROM tasks WHERE id = ?",
        (parsed_id,),
    ).fetchone()
    return jsonify(task_to_dict(row))


@app.delete("/tasks/<task_id>")
def delete_task(task_id):
    parsed_id = parse_task_id(task_id)
    if parsed_id is None:
        return error("id 必须是正整数", 400)

    db = get_db()
    cursor = db.execute("DELETE FROM tasks WHERE id = ?", (parsed_id,))
    db.commit()
    if cursor.rowcount == 0:
        return error("事项不存在", 404)
    return "", 204


if __name__ == "__main__":
    init_db()
    # debug=True 只用于本地开发,不能作为生产服务器配置。
    app.run(debug=True)

代码块中的 &gt;&lt; 是 HTML 转义后的显示内容;浏览器会在代码元素中显示为对应的比较符号。复制代码时,应使用实际的 Python 符号 > 和 <,而不是把 HTML 实体写进 Python 文件。

七、运行并验证接口

启动服务:

python app.py

Flask 开发服务器默认监听本机的 5000 端口。可以用 curl 创建一条记录:

curl -X POST http://127.0.0.1:5000/tasks \
  -H "Content-Type: application/json" \
  -d '{"title":"整理需求","done":false}'

返回示例:

{
  "created_at": "2025-01-01T12:00:00+00:00",
  "done": false,
  "id": 1,
  "title": "整理需求"
}

查询列表、读取单条记录、更新和删除:

curl "http://127.0.0.1:5000/tasks?limit=20&offset=0"
curl http://127.0.0.1:5000/tasks/1

curl -X PATCH http://127.0.0.1:5000/tasks/1 \
  -H "Content-Type: application/json" \
  -d '{"done":true}'

curl -i -X DELETE http://127.0.0.1:5000/tasks/1

实际终端命令中的查询参数分隔符是 &;在 HTML 中需要写成 &amp; 才能正确显示。创建请求返回 201,删除成功返回 204 且没有响应体。可通过 curl -i 查看 HTTP 状态码和响应头。

八、读懂实现中的关键安全与数据规则

1. SQL 参数绑定不等于字符串拼接

所有由请求提供的 SQL 值都使用 ? 占位符并单独传入,例如:

db.execute(
    "SELECT id, title FROM tasks WHERE id = ?",
    (parsed_id,),
)

这样可以让数据库驱动按值处理参数,避免把用户输入当作 SQL 语句的一部分。这是防止 SQL 注入的重要做法,但不代表应用因此自动安全:仍要做输入校验、权限检查和错误处理,也要保护数据库文件与运行环境。

占位符通常用于绑定值,不能直接代替表名、列名或排序关键字。本例动态更新字段时,列名只能从代码内的固定白名单 title、done 中生成,用户输入没有直接拼进 SQL 标识符。切勿把请求里的排序字段原样拼接到查询语句中;若要支持排序,应先映射到服务端认可的固定列名。

2. 参数校验要覆盖类型、范围和业务含义

标题不只要检查“是否存在”,还要检查类型、去除首尾空格后的内容是否为空、长度是否超限。完成状态要求 JSON 布尔值,避免把任意非空字符串都当作真。这里使用 Python 的 isinstance(value, bool),比单纯判断“是不是整数”更符合接口约定,因为 Python 中布尔值也是整数的子类。

查询参数来自 URL,通常以文本形式到达应用,所以需要先转换为整数再检查范围。限制 limit 是一种资源控制;100 是本例自行选定的业务上限,不是 Flask、SQLite 或 HTTP 协议强制规定的值。真实项目应结合响应大小、数据库负载和产品需求选择上限。

3. 更新接口要定义“没提供”与“提供空值”的区别

PATCH 语义通常表示部分更新。本例只修改请求中出现的字段;省略 done 不会将其重置为 false。对于 title,显式提供空字符串会被拒绝。把请求字段白名单化,还能避免客户端尝试修改 ID、创建时间等本不应由其控制的数据。

4. 提交事务与失败处理

示例在每次写操作后调用 commit(),让更改提交到数据库。SQLite 连接在连接关闭前若存在未提交事务,不能依赖它自动成为已保存数据。多个 SQL 语句需要作为一个整体成功或失败时,应使用事务边界;若其中一步失败,需要回滚,而不能让部分数据留在不一致状态。

本例每个写操作只执行一条主要数据修改语句,结构相对简单。随着业务增加,例如创建订单同时扣减库存,就必须明确事务范围,并处理并发竞争;仅仅把两条语句先后执行,不足以保证业务原子性。具体隔离行为和锁等待处理会受数据库类型及配置影响,需要针对所用数据库设计和测试。

九、常见错误及其修正方式

错误一:把用户输入直接拼接进 SQL

例如将标题直接拼成查询字符串,会引入注入风险,也容易因为引号、特殊字符导致语句错误。值一律使用参数绑定;动态表名、字段名则采用固定映射或白名单,而不是指望值占位符能够保护标识符。

错误二:只在前端校验

前端校验主要改善交互,客户端可以被绕过。服务端必须再次校验输入;重要数据规则还应由数据库约束兜底。前端可以提示“标题不能为空”,但服务端仍要拒绝空标题。

错误三:没有排序规则就做分页

数据库不承诺无排序查询的返回顺序。依赖默认顺序进行分页,可能出现重复或遗漏。示例用创建时间倒序并以 ID 作为第二排序键,让排序具有确定性。高频、大数据量列表还可能需要基于游标的分页,避免较大的 OFFSET 带来额外扫描成本。

错误四:只靠应用代码维护字段规则

未来可能有另一个脚本直接写数据库。如果数据库没有非空、取值范围等约束,数据质量会逐渐失控。适合由数据库保证的规则应尽量落实到数据库;但并非所有业务规则都能或应该写成约束,复杂权限和流程仍要由应用负责。

错误五:把启动时建表当成数据库迁移

CREATE TABLE IF NOT EXISTS 只能在表不存在时创建表,不能自动处理已有表结构变化。例如后来新增字段,它不会替旧表补列。真实项目应使用迁移工具维护有版本、有顺序的结构变更,并在发布前验证升级与回滚策略。

错误六:把调试服务器直接用于线上

示例中的 debug=True 只为本地开发提供调试体验,不应公开部署。生产运行需使用适合部署环境的 WSGI 服务器,并关闭调试模式、管理密钥与错误信息、限制访问、配置日志和备份。SQLite 数据库文件也需要合理的文件权限,不能放在任何 Web 用户都可下载的静态目录里。

错误七:认为有 ID 就有权限

一个不可预测的 ID 也不是访问控制机制。本示例没有登录系统,因此任何能访问服务的人都能操作任何记录。真实应用要从可信的认证上下文取得用户身份,并在每条读写 SQL 中加入所有权条件,例如同时匹配 id 与 user_id。不能只在前端隐藏按钮,也不能相信请求体中的用户 ID。

十、如何继续完善

把示例用于真实项目之前,可以按风险和业务优先级逐步补齐:

  1. 测试:为标题边界、非法 JSON、不存在的 ID、分页上限、部分更新和删除写自动化测试。测试应使用临时数据库,避免污染真实数据。
  2. 错误响应:统一错误格式和状态码,避免把数据库异常、路径信息或内部堆栈直接返回给客户端。服务端记录必要的诊断信息,但不记录敏感内容。
  3. 迁移:通过迁移脚本新增字段、索引和约束,并在有备份的前提下演练升级过程。大型表结构变更还要考虑耗时与应用兼容性。
  4. 索引:根据真实查询模式建立索引。例如按状态筛选并按创建时间排序时,可以评估组合索引;索引会占用空间并增加写入维护成本,不应盲目添加。
  5. 身份与权限:实现可靠的认证、授权和数据隔离,检查每个对象操作的归属关系。浏览器应用还要根据认证方式评估 CSRF 等风险。
  6. 运维:设置备份与恢复演练、日志、监控、连接与超时策略,并确认数据库文件持久化。备份存在不等于恢复方案可用,必须实际验证恢复。
  7. 并发与部署:评估写入并发、多个实例共享数据的方式以及故障恢复要求。需求超出 SQLite 的合适范围时,再迁移到 PostgreSQL、MySQL 等服务型数据库,并重新验证 SQL 兼容性与事务行为。

结语

数据库应用开发的起点不是写 CRUD,而是把需求写成清楚、可验证的规则,再选择合适的数据模型和接口边界。落地时,参数绑定、输入校验、数据库约束、确定性排序和明确的更新语义,能帮助一个小应用避免许多基础问题。与此同时,示例的“可运行”不等于“可直接上线”:身份权限、迁移、备份、部署和并发能力都需要结合实际场景补全。先把边界讲清楚,再按风险逐步扩展,比一开始堆叠复杂技术更可靠。

© 版权声明

相关文章