拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Flask+SQLAlchemy图书管理系统开发实战:数据库课设完整指南

简介在数据库应用开发中如何将实体关系、主外键约束、多表联查等核心概念落地为可运行的工程实践是许多初学者面临的共同难题。ORM对象关系映射作为连接关系型数据库与业务代码的桥梁能够有效规避裸SQL的注入风险并提升数据操作的可维护性。Flask作为轻量级Web框架结合SQLAlchemy ORM组件既能清晰展示SQL语句的执行过程又能快速构建完整的增删改查CRUD功能。本文从数据库表设计出发讲解图书、读者、借阅三张表的外键关联与范式优化再深入Flask-SQLAlchemy的模型映射、表单处理与事务控制最后分享分页查询、并发扣库存等细节技巧。无论是应对数据库课程设计还是快速搭建中小型管理后台这套技术栈都能提供一套简洁可靠的参考方案。1. 项目整体设计与思路拆解期末数据库课程设计我选了“图书管理系统”这个经典到不能再经典的题目。之所以选它是因为它足够小却能覆盖数据库设计里最核心的概念实体关系、主外键约束、多表联查、增删改查、事务处理。技术栈我敲定的是 Flask SQLAlchemy SQLite后期切 MySQL 也就改一行配置的事。前后折腾了两周从建表到课程演示完整跑通这篇文章就把这套流程掰开揉碎讲清楚给正在做数据库课设的同学一个可以直接“抄作业”的参考。1.1 需求分析图书管理系统到底要管什么很多同学拿到题目就急着写代码结果代码写了一半发现表结构根本撑不起业务逻辑又回过头改数据表越改越乱。我当时的第一步是先列需求把所有“要管的事”写清楚。一个面向课程设计的图书管理系统不需要像图书馆那样做到几十万并发但要能把“书、人、借还”这三件事理清楚。图书信息书名、作者、出版社、ISBN、分类、库存总量、当前可借数量。读者信息学号/工号、姓名、联系方式、所属学院或部门。借阅记录哪位读者、借了哪本书、借出时间、应还时间、实际归还时间、是否逾期。用户权限区分管理员和普通读者管理员可以维护图书和借还操作读者只能查询和查看自己的借阅记录。把这些需求落成一句话系统要能完成图书和读者的信息维护以及借书、还书、续借、查询等日常操作同时保证数据的一致性。比如一本书被借走之后可借数量要减一还回来之后要加一如果读者已经借了三本还没还就不能再借。这些规则看似简单但数据库表设计如果没做好代码里写再多 if 都补不回来。1.2 技术选型为什么用 Flask 而不是其他框架选型这件事我一开始也犹豫过。Django 功能全自带 Admin 后台但当时我的核心目标是数据库课设不是 Web 框架课设Django 的 ORM、Migration、Admin 这些体系反而会掩住数据库本身的操作逻辑。FastAPI 很新但课程设计阶段没必要上异步那套东西。最终选了 Flask理由很简单轻量、直观、可控性强所有数据库操作都写在自己面前老师问起来也讲得清楚。Flask 的 SQLAlchemy 集成做得比较成熟Flask-SQLAlchemy 这个扩展把数据库会话、模型映射、查询接口都封装好了写起来有点像 Django ORM但又没有那么多“魔法”。所以我用 Flask 而不是原生 MySQLdb 或 pymysql 写裸 SQL有个很重要的考虑ORM 帮我们管理数据库连接、参数绑定和结果集转换代码更安全也不容易出 SQL 注入问题。但底层 SQLAlchemy 依然可以执行原生 SQL调试和验收的时候可以直接翻出执行的 SQL 语句这点在答辩时加分不少。1.3 数据库模型设计图书管理系统的核心实体就是三张表图书表book、读者表reader、借阅表borrow。这三张表的关系非常明确读者和图书是多对多关系而多对多关系在关系型数据库里必须拆成一张中间表来描述这张中间表就是借阅记录表。我的表结构设计如下表名字段类型说明bookidINTEGER主键自增isbnVARCHAR(20)国际标准书号做唯一约束titleVARCHAR(100)书名authorVARCHAR(50)作者publisherVARCHAR(100)出版社publish_dateDATE出版日期total_countINTEGER总藏书量available_countINTEGER当前可借数量不能为负数categoryVARCHAR(50)分类readeridINTEGER主键自增reader_noVARCHAR(20)学号/工号唯一nameVARCHAR(50)姓名phoneVARCHAR(20)联系电话departmentVARCHAR(50)学院/部门borrowidINTEGER主键自增book_idINTEGER外键关联 book.idreader_idINTEGER外键关联 reader.idborrow_dateDATETIME借出时间due_dateDATETIME应还时间return_dateDATETIME实际归还时间空表示未还这里有个关键点借阅表不要直接存书名和读者姓名只存外键。很多同学图省事想在 borrow 表里冗余一个 book_title 字段查询时候倒是方便了但更新和一致性会出问题。比如书名改了一个字所有借阅记录里的旧书名不会自动变。数据库设计第三范式强调的就是消除冗余课程设计阶段老老实实按范式来后期扩展和答辩都会轻松很多。2. 核心细节解析与实操要点2.1 Flask 项目目录结构不要把代码全堆在一个 app.py 里。课程设计虽然不大但一个文件超过五百行之后改起来就非常痛苦。我当时用的是 Flask 的蓝图结构把项目按功能拆成几个模块book_manager/ ├── app.py # 程序入口创建 app 实例 ├── config.py # 配置信息数据库连接 ├── models.py # 数据库表模型 ├── extensions.py # 初始化 SQLAlchemy 实例 ├── views/ │ ├── __init__.py │ ├── book.py # 图书相关路由 │ ├── reader.py # 读者相关路由 │ └── borrow.py # 借还书相关路由 ├── templates/ │ ├── base.html # 基础模板 │ ├── book_list.html │ ├── book_form.html │ └── ... └── static/ └── style.css蓝图的优点在于每个功能模块都可以独立维护。比如图书的路由放在 book.py 里借还逻辑放在 borrow.py 里调试的时候定位问题非常快。对数据库课设来说models.py 是核心所有表结构都集中在里面老师检查代码时一眼就能看出你设计了哪些实体、哪些关系。2.2 数据库连接与模型迁移Flask-SQLAlchemy 的配置非常简单config.py 里写一行数据库地址就行。使用 SQLite 时连接字符串是import os BASE_DIR os.path.abspath(os.path.dirname(__file__)) class Config: SQLALCHEMY_DATABASE_URI sqlite:/// os.path.join(BASE_DIR, library.db) SQLALCHEMY_TRACK_MODIFICATIONS False SECRET_KEY course-design-secret-key之后在 extensions.py 中创建 SQLAlchemy 实例from flask_sqlalchemy import SQLAlchemy db SQLAlchemy()在 app.py 中初始化并创建数据表from flask import Flask from config import Config from extensions import db import models # 一定要导入模型否则建表时找不到表 app Flask(__name__) app.config.from_object(Config) db.init_app(app) with app.app_context(): db.create_all()关于建表这里必须提醒一句db.create_all()只会新建不存在的表不会修改已经存在的表结构。如果你改了 models.py 里的字段直接重启服务不会生效需要删掉之前生成的 library.db 再重新建。这还是小事后面如果换 MySQL推荐用 Flask-Migrate 做数据库迁移它能在保留数据的前提下同步表结构变化。课程设计用不到太复杂但知道这个工具会增加你的项目成熟度。2.3 图书增删改查的通用套路图书管理系统说白了就是一个围绕数据库表的增删改查系统。增删改查的英文缩写是 CRUD这四个操作在 Flask-SQLAlchemy 里的写法基本固定掌握了套路之后图书、读者、借阅记录都是同一套逻辑。以图书模块为例。新增图书from flask import request, redirect, url_for from models import db, Book app.route(/book/add, methods[POST]) def book_add(): title request.form.get(title) author request.form.get(author) isbn request.form.get(isbn) total_count int(request.form.get(total_count, 1)) book Book( titletitle, authorauthor, isbnisbn, total_counttotal_count, available_counttotal_count ) db.session.add(book) db.session.commit() return redirect(url_for(book_list))这里有几个容易踩的坑。第一available_count默认要等于total_count否则新书上架就可借数量不对后面借书一定会出问题。第二db.session.commit()记得加 try/except因为唯一约束比如 ISBN 重复会抛异常不处理的话前端会直接 500。第三request.form.get()拿到的全是字符串数字类型必须手动转换否则 SQLAlchemy 存不进去。修改图书和删除图书同理删除时要注意外键关联app.route(/book/delete/int:book_id) def book_delete(book_id): book Book.query.get_or_404(book_id) db.session.delete(book) db.session.commit() return redirect(url_for(book_list))如果这个 book_id 已经在 borrow 表里存在关联记录直接删会触发外键约束报错。所以删除前要么检查有没有未归还的借阅记录要么在数据库设计时把外键设为级联删除。课程设计阶段我推荐用前者逻辑更安全。2.4 模板与表单处理Flask 默认使用 Jinja2 模板引擎。前端页面我直接用了 Bootstrap 写样式省去大量 CSS 工作看起来也规范。表单部分没有使用 Flask-WTF因为课程设计阶段手写 HTML 表单更能理解 HTTP 请求和响应过程不用被表单验证框架抢了戏。一个典型的图书列表模板片段table classtable table-striped thead tr thISBN/th th书名/th th作者/th th出版社/th th可借数量/th th操作/th /tr /thead tbody {% for book in books %} tr td{{ book.isbn }}/td td{{ book.title }}/td td{{ book.author }}/td td{{ book.publisher }}/td td{{ book.available_count }}/{{ book.total_count }}/td td a href{{ url_for(book_edit, book_idbook.id) }}编辑/a a href{{ url_for(book_delete, book_idbook.id) }} onclickreturn confirm(确定删除吗)删除/a /td /tr {% endfor %} /tbody /table模板里尽量只做展示和简单的条件判断不要把业务逻辑写到模板里。比如“可借数量大于 0 时才显示借书按钮”这个用模板判断没问题但真正能不能借后端接口必须重新校验一次。原因很简单前端可以被绕过直接用 POST 请求就能把数据提交到后台所以安全边界永远在后端。3. 实操过程与核心环节实现3.1 环境准备与依赖安装做课程设计环境准备这一步看似简单但往往是问题最多的环节。我当时用的是 Python 3.10Windows 系统完全在虚拟环境里操作。创建项目目录和虚拟环境mkdir book_manager cd book_manager python -m venv venv venv\Scripts\activate激活虚拟环境之后再安装依赖pip install flask flask-sqlalchemy为什么要用虚拟环境因为不同课程项目可能依赖不同版本的第三方库比如另一个项目需要 Flask 1.x而这里需要 Flask 2.x混在一起很容易互相干扰。虚拟环境会把所有依赖隔离在项目目录下删掉重来也干净。顺带说一句最好把依赖列表导出来复制环境时直接一键安装pip freeze requirements.txt答辩或换电脑演示时只要pip install -r requirements.txt就能恢复环境省得每次手动装包。3.2 实现图书模型和借阅关系models.py 是整个项目的数据库核心。完整代码大致如下from datetime import datetime from extensions import db class Book(db.Model): __tablename__ book id db.Column(db.Integer, primary_keyTrue) isbn db.Column(db.String(20), uniqueTrue, nullableFalse) title db.Column(db.String(100), nullableFalse) author db.Column(db.String(50)) publisher db.Column(db.String(100)) publish_date db.Column(db.Date) total_count db.Column(db.Integer, default1, nullableFalse) available_count db.Column(db.Integer, default1, nullableFalse) category db.Column(db.String(50)) created_at db.Column(db.DateTime, defaultdatetime.now) borrows db.relationship(Borrow, backrefbook, lazyTrue) def __repr__(self): return fBook {self.title} class Reader(db.Model): __tablename__ reader id db.Column(db.Integer, primary_keyTrue) reader_no db.Column(db.String(20), uniqueTrue, nullableFalse) name db.Column(db.String(50), nullableFalse) phone db.Column(db.String(20)) department db.Column(db.String(50)) created_at db.Column(db.DateTime, defaultdatetime.now) borrows db.relationship(Borrow, backrefreader, lazyTrue) def __repr__(self): return fReader {self.name} class Borrow(db.Model): __tablename__ borrow id db.Column(db.Integer, primary_keyTrue) book_id db.Column(db.Integer, db.ForeignKey(book.id), nullableFalse) reader_id db.Column(db.Integer, db.ForeignKey(reader.id), nullableFalse) borrow_date db.Column(db.DateTime, defaultdatetime.now) due_date db.Column(db.DateTime) return_date db.Column(db.DateTime, nullableTrue) property def is_returned(self): return self.return_date is not None注意这里用db.relationship建立了对象关系。在 SQLAlchemy 里通过book.borrows可以直接拿到这本书的全部借阅记录不需要手动写 JOIN。这种写法在 Jinja2 模板里非常方便。数据库层面ForeignKey(book.id)保证了引用完整性如果插入一个不存在的 book_id数据库会直接拒绝。due_date的默认值建议在业务代码里设置通常是借出时间加 30 天。不要用数据库默认值写死因为不同读者类型可能有不同的借阅期限放在业务层更灵活。3.3 路由与业务逻辑编写借书操作用户体验上很简单但后端逻辑要多想几步读者能不能借、书是否还有库存、借阅记录是否重复。我的借书接口是这样实现的from datetime import datetime, timedelta from flask import render_template, request, redirect, url_for, flash from models import db, Book, Reader, Borrow app.route(/borrow/add, methods[POST]) def borrow_add(): reader_id request.form.get(reader_id) book_id request.form.get(book_id) reader Reader.query.get(reader_id) book Book.query.get(book_id) if not reader or not book: flash(读者或图书不存在) return redirect(url_for(borrow_list)) if book.available_count 0: flash(图书已借完) return redirect(url_for(borrow_list)) not_returned Borrow.query.filter_by( reader_idreader_id, book_idbook_id, return_dateNone ).first() if not_returned: flash(该读者已借过这本书且未归还) return redirect(url_for(borrow_list)) borrow Borrow( reader_idreader.id, book_idbook.id, borrow_datedatetime.now(), due_datedatetime.now() timedelta(days30) ) book.available_count - 1 db.session.add(borrow) db.session.commit() flash(借书成功) return redirect(url_for(borrow_list))这里最重要的一步是“检查可借数量”和“扣减库存”必须放在同一个请求里并且最后一起 commit。因为如果检查完到 commit 之间又有其他请求把库存借走就会出现超卖。课程设计里并发不大但靠 commit 的顺序已经能保证同进程内的数据一致性。如果想要更强的一致性可以用数据库事务和行锁比如 MySQL 的SELECT ... FOR UPDATE但 SQLite 课设级别大可不必。还书逻辑更简单找到对应的未归还记录把return_date设置为当前时间再增加书的available_count即可。注意还书时不要新增一条记录而是在原记录上更新否则统计“借阅历史”时会重复。3.4 前端页面渲染前端页面我分了三个模块图书管理页、读者管理页、借阅管理页。每个页面都继承base.html公共导航栏写一次即可。base.html 的核心结构!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title{% block title %}图书管理系统{% endblock %}/title link hrefhttps://cdn.jsdelivr.net/npm/bootstrap5.3.0/dist/css/bootstrap.min.css relstylesheet /head body nav classnavbar navbar-expand-lg navbar-dark bg-dark div classcontainer a classnavbar-brand href{{ url_for(index) }}图书管理系统/a div classnavbar-nav a classnav-link href{{ url_for(book_list) }}图书管理/a a classnav-link href{{ url_for(reader_list) }}读者管理/a a classnav-link href{{ url_for(borrow_list) }}借阅管理/a /div /div /nav div classcontainer mt-4 {% with messages get_flashed_messages() %} {% if messages %} {% for message in messages %} div classalert alert-info rolealert{{ message }}/div {% endfor %} {% endif %} {% endwith %} {% block content %}{% endblock %} /div /body /htmlget_flashed_messages()是 Flask 提供的消息闪现功能用来在重定向后显示提示信息。我在借书和还书的接口里都用flash(...)返回操作成功或失败的原因用户体验比直接刷新列表好很多。图书新增和编辑共用一个表单页。表单提交到/book/add或/book/update通过隐藏字段区分。Jinja2 模板里可以直接读取book对象填充默认值input typetext nametitle classform-control value{{ book.title if book else }} required form methodpost action{{ url_for(book_update, book_idbook.id) if book else url_for(book_add) }}这种“同一套表单、两种场景复用”的做法能省不少代码但要注意在模板里做空值判断不然新增页面直接访问book.title会报错。3.5 运行测试开发阶段用 Flask 内置的开发服务器就够了。启动命令python app.py启动后访问http://127.0.0.1:5000。我建议第一件事不是点页面而是接上数据库工具看一眼建好的表结构。我用的是一款叫 DB Browser for SQLite 的免费工具打开 library.db 后可以直观看到 book、reader、borrow 三张表以及字段、索引、外键关系。课程设计演示时把数据库工具和浏览器分屏展示一边操作页面一边看数据库变化老师会觉得你对数据链路非常清楚。测试用例我列了一个清单新增一本 ISBN 重复的书预期提示错误。借出一本书后该书可借数量减一。同一读者重复借同一本未归还的书预期被拒绝。还书后可借数量加一。删除有关联借阅记录的读者预期外键报错或提示。模糊搜索书名包含“Python”的图书。跑完这些用例系统基本就稳了。4. 常见问题与排查技巧实录4.1 数据库初始化失败症状是运行db.create_all()时报错或者报 “no such table”。大部分原因是models.py没有被 import 到app.py中。SQLAlchemy 只有在模型类被定义并导入后才会把表注册到 metadata 里。所以app.py里哪怕你不直接用 Model 类也要写一句import models。还有一个很隐蔽的问题如果模型重复定义比如两个类映射到同一个表名会报 “Table book is already defined”。排查时先看抛出的异常堆栈再确认有没有重复的类定义。另外如果改过模型字段重新启动后发现数据库里还是旧字段多半是 SQLite 旧库缓存导致。SQLite 不像 MySQL 那样能用 ALTER TABLE 随意加列简单粗暴的办法是删除 library.db 再重新建表。开发阶段数据丢了无所谓但如果是演示数据建议先备份。4.2 外键关联查询报错使用db.relationship时常见报错是sqlalchemy.exc.NoReferencedTableError: Foreign key associated with column borrow.book_id could not find table book。这个经常是因为模型类里__tablename__拼写不一致或者模型未被导入。还有种情况是外键字段写成了db.ForeignKey(Book.id)但 SQL 层面的外键必须使用表名而不是类名所以应该是db.ForeignKey(book.id)其中book是__tablename__定义的值。在 SQLAlchemy 2.x 中db.relationship需要显式指定foreign_keys的情况比较少见但当一个模型有两个外键指向同一张表时会出现歧义比如你加了一个“操作员”字段也指向读者表就必须在 relationship 中指定关联哪个外键。课程设计一般用不到这个复杂度知道就好。4.3 表单 POST 请求 400/405我调试时遇到过最无语的问题是表单提交后返回 405 Method Not Allowed。原因是路由装饰器只写了app.route(/book/add, methods[POST])但浏览器直接访问这个地址会发起 GET 请求从而不被允许。解决办法是把methods改成[GET, POST]或者在表单提交地址上用url_for拼接不要手写路径。另一个常见错误是 400 Bad Request多半是提交的表单字段名和后端request.form.get()名字对不上。比如模板里namebook_title后端却写request.form.get(title)取到的是 None如果字段设置了nullableFalse提交时就会报错。排查方法很简单在接口里加一个print(request.form)把接收到的字段全部打出来一眼就能看到问题。4.4 分页查询与性能优化数据量小的时候Book.query.all()完全没问题。但如果几百本书加上几千条借阅记录页面渲染就会变慢这时候要给列表加分页。Flask-SQLAlchemy 内置了分页方法page request.args.get(page, 1, typeint) per_page 10 pagination Book.query.order_by(Book.id).paginate(pagepage, per_pageper_page, error_outFalse) books pagination.items模板里要显示上一页、下一页和页码还需要循环处理pagination.iter_pages()。分页接口相比all()的差别在于它用了 SQL 的LIMIT和OFFSET不会一次性把所有数据加载进内存。数据库课设答辩时老师如果问“数据量大了怎么办”你能答出分页和索引优化就已经超出预期了。说到索引我建议在isbn、reader_no这类唯一字段上建立唯一索引SQLAlchemy 的uniqueTrue会自动创建。借阅表里的reader_id和book_id也是高频查询字段外键本身在生产数据库里建议加索引SQLite 中 SQLAlchemy 会自动为外键创建索引。如果以后切到 MySQL可以再加一个组合索引(reader_id, return_date)用来加速“查询某读者未归还记录”的请求。4.5 数据一致性并发与事务虽然课设不太可能遇到真正的高并发但借书场景天然就是一个并发问题。如果两个浏览器同时借同一本书的最后一本代码里先查available_count 0再执行available_count - 1在极端情况下可能两个请求都看到了剩余 1 本都执行成功最终库存变成 -1。SQLAlchemy 的 session 本身有事务隔离机制当两个并发请求同时读写同一行时后提交的事务会报错或者被阻塞。但为了安全我通常会在借书逻辑中加入一个条件更新result Book.query.filter_by(idbook_id, available_count 0).update({ available_count: Book.available_count - 1 }) if result 0: flash(图书已借完) return redirect(url_for(borrow_list))这样就把“检查”和“更新”合并成一条 SQL能避免大部分并发情况下超借。当然 SQLite 对并发写支持不太好但用来演示概念完全足够。4.6 答辩前的小技巧整理代码的时候我最推荐你在项目里加一个 README.md把环境搭建、启动方式、测试账号和表结构说明都写清楚。答辩时先让老师看 README再现场运行系统顺序是启动虚拟环境、运行 app.py、打开浏览器、演示增删改查、打开数据库工具展示表变化。整个流程控制在三分钟内比讲十分钟 PPT 更有说服力。另一个小技巧是把 SQLAlchemy 的echoTrue临时打开在控制台打印能看到所有 SQL 语句SQLALCHEMY_ECHO True这样你在页面上点一下“借书”控制台立刻显示对应的 INSERT 和 UPDATE 语句。给老师展示这一步能非常直观地证明你的系统确实是“数据库课程作业”而不只是前端页面。我个人做完这个项目最大的感受是图书管理系统虽然老套但它像一个完整的数据库实训容器几乎所有数据库核心概念都能在这套代码里找到对应实践。从需求分析、表结构设计到 ORM 映射、事务处理每一步都能学到东西。如果你也是数据库课设选了类似题目这套 Flask SQLAlchemy 的思路可以直接复用把模块替换成“学生管理系统”“仓库管理系统”整体架构完全不用动写起来会轻松很多。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门