
简介面向毕业设计与课程设计的Python图书管理系统项目采用MVC分层架构构建高内聚低耦合扩展性强用于解决常规管理系统代码耦合度高、后续维护困难的问题。项目包含完整的图书增删改查、批量查询等基础功能数据持久化选用cushy-storage文件存储机制有效减少自定义存储协议的工作量同时引入rich库实现更美观的终端显示整体设计思路非常适合新手学习借鉴。资源包为zip压缩格式共14个文件其中11个Python源码文件覆盖模型、服务、菜单、缓存等模块另含requirements.txt依赖清单、README.md说明文档含完整架构图及.gitignore配置文件包体大小仅11KB轻量且便于快速部署。目前已有177人学习查看对于需要完成课程设计、毕业设计或入门MVC架构的开发者这份项目源码可直接作为基础框架运行结合架构图理解分层思想并在此基础上扩展功能。1. 基于Python的图书管理系统为什么非要用MVC分层架构一个反直觉的事实是图书管理系统作为课设和毕设里出现频率最高的题目之一挂掉的人往往不是功能做不完而是答不上你的目录为什么这么分。单文件Flask把路由、SQL查询和HTML拼串写在一起两百行能跑通增删改查但表结构一改要动三处加一个管理员角色提心吊胆——这种代码经不起第二个人阅读也经不起答辩追问。MVC分层架构的价值不是把简单问题复杂化而是让数据、调度、展示三条线的修改互不牵连。这篇博文用图书管理系统做载体把MVC从概念落到最小可运行的工程结构模型层怎么定义图书和借阅关系控制层怎么用蓝图拆路由视图层怎么用模板继承以及高内聚低耦合在实际代码里长什么样。2. MVC职责边界怎么划图书管理系统的模型层设计与数据表落地2.1 三层各自的边界Model、View、Controller到底管什么先立一条硬性原则在MVC分层架构里Model是唯一允许操作数据库的层View是唯一允许输出用户界面的层Controller是中间调度员它接收HTTP请求、解析参数、调用Model、决定渲染哪个View。判断一段代码放哪层不需要看它在哪个文件夹而是看它知道得太多还是只知道自己的事。借阅成功后在模板里直接update库存这是视图越权路由函数里把逾期判定、罚金计算全写完这是控制层过胖两者都破坏高内聚低耦合。“能否整体替换”是我验证职责边界最常用的办法把MySQL换成SQLite改动只落在Model层和config配置文件里把Jinja2换成其他模板引擎改动只落在templates目录。反过来如果改数据库连接地址还要同步改Controller里的SQL或者换个页面风格要动路由函数那职责就串了。答辩时老师问高内聚低耦合怎么理解把这条替换链讲清楚比背出定义具体得多。高内聚与低耦合在这里是同一件事的两面。高内聚指一个模块内部的元素都围绕同一职责组织比如Book模型里只有书籍字段和关系不掺入逾期费计算低耦合指模块之间只通过明确的接口通信Controller不认识SQL模板不认识session。MVC拆出的三层天然契合这两个目标但只有真正遵守单向依赖时才算数。图书管理系统规模小单文件也能跑可一旦功能堆到上万行没有分层就是改一处崩三处的结局。2.2 图书与借阅记录的SQLAlchemy模型核心字段和关系图书管理系统的数据核心是三个实体图书、用户、借阅记录。三者关系清晰一个用户可借多本书一本书可被多次借出借阅记录本身带有时效状态所以它既是关联表又是独立业务对象。在Flask-SQLAlchemy里模型层通常集中在app/models.py只做数据定义和关系声明不做业务判断。下面是三个模型的最小可用版本# app/models.py from datetime import datetime from app.extensions import db class Book(db.Model): __tablename__ book id db.Column(db.Integer, primary_keyTrue) isbn db.Column(db.String(20), uniqueTrue, nullableFalse, indexTrue) title db.Column(db.String(120), nullableFalse) author db.Column(db.String(60)) total_copies db.Column(db.Integer, default1) available_copies db.Column(db.Integer, default1) created_at db.Column(db.DateTime, defaultdatetime.now) borrow_records db.relationship(BorrowRecord, back_populatesbook) class User(db.Model): __tablename__ user id db.Column(db.Integer, primary_keyTrue) username db.Column(db.String(60), uniqueTrue, nullableFalse) password_hash db.Column(db.String(128), nullableFalse) role db.Column(db.String(20), defaultreader) class BorrowRecord(db.Model): __tablename__ borrow_record id db.Column(db.Integer, primary_keyTrue) book_id db.Column(db.Integer, db.ForeignKey(book.id)) user_id db.Column(db.Integer, db.ForeignKey(user.id)) borrow_date db.Column(db.DateTime, defaultdatetime.now) due_date db.Column(db.DateTime) return_date db.Column(db.DateTime, nullableTrue) status db.Column(db.String(20), defaultborrowed) book db.relationship(Book, back_populatesborrow_records)这段代码里值得解释的有两点。db.Column的第一个参数是类型Integer、String、DateTime分别对应整数、变长字符串和日期时间db.ForeignKey(book.id)声明外键它只约束数据库层的关系Python层要靠db.relationship建立对象导航。book db.relationship(Book, back_populatesborrow_records)让record.book.title这种属性访问成为可能同时Book.borrow_records反过来能拿到某本书的全部借阅历史。注意back_populates两端必须成对写漏掉一端会抛sqlalchemy.exc.InvalidRequestError提示找不到反向属性。字段设计上有一个容易忽略的细节available_copies与total_copies分开存第一版先不急着写库存流水表。total_copies记录馆藏总量available_copies表示当前可借数量借出时减一、归还时加一。这是图书管理系统最常见的库存模型优点是查询快缺点是并发借书时需要事务保护。答辩被追问并发问题时可以补充生产环境会把UPDATE放进with db.session.begin()事务里。2.3 模型关系设计为什么借阅记录必须单独建表如果不建BorrowRecord直接在Book上挂一个borrower_id那同一本书被两个人借时只能覆盖前一个人的记录历史彻底丢失。单独的借阅表让一本书的借阅历史和一个用户当前借了几本变成两个可查询的集合而不是被覆盖的字段。下面这张表是模型层常用字段速查设计表结构时可以直接参考字段类型约束用途isbnString(20)unique, index唯一标识一本书查重和检索都靠它total_copiesIntegerdefault1馆藏总量新增图书时先写这里available_copiesIntegerdefault1当前可借数借/还时增减roleString(20)defaultreader区分读者和管理员控制菜单权限statusString(20)defaultborrowedborrowed/returned/overdue 三态建表用db.create_all()按模型自动生成但有两个前提模型类必须被当前进程import过且app上下文已初始化。常见的报错是RuntimeError: No application found. Either work inside a view function or push an application context这说明你在没启动app的情况下调用了create_all。解决办法是在脚本里先with app.app_context():再执行建表语句。表结构设计到这里模型层的职责就算完整了。很多人习惯在这个阶段就写根据书名模糊查询的逻辑正确做法是把它放到Controller或Service层的查询函数里Model层职责只是字段和关系定义。借阅规则、逾期判定、用户权限判断下一章在控制层和服务层展开。3. 控制器与视图落地Flask蓝图路由和Jinja2模板的配合方式3.1 用Flask蓝图拆控制层按图书、读者、借阅三个模块分文件路由函数如果全部堆在app.py里一旦超过十个文件就开始难以翻阅而且每个函数都要重复写app.route装饰器。Flask提供Blueprint对象做模块化路由注册在MVC分层架构里通常一个业务模块对应一个控制器文件每个文件导出一个蓝图。图书管理系统的控制层最少拆三块图书管理、读者管理、借阅流通下面是图书模块的控制器# app/controllers/book_controller.py from flask import Blueprint, render_template, request, redirect, url_for from app.models import Book from app.extensions import db book_bp Blueprint(book, __name__, url_prefix/books) book_bp.route(/) def index(): page request.args.get(page, 1, typeint) keyword request.args.get(keyword, , typestr) query Book.query if keyword: query query.filter(Book.title.contains(keyword) | Book.author.contains(keyword)) pagination query.order_by(Book.id.desc()).paginate(pagepage, per_page10) return render_template(book/index.html, paginationpagination, keywordkeyword) book_bp.route(/create, methods[GET, POST]) def create(): if request.method POST: book Book( isbnrequest.form[isbn], titlerequest.form[title], authorrequest.form[author], total_copiesint(request.form[total_copies]), available_copiesint(request.form[total_copies]) ) db.session.add(book) db.session.commit() return redirect(url_for(book.index)) return render_template(book/form.html, bookNone)Blueprint构造函数的三个参数需要说明。book是蓝图名url_for(book.index)里的book就是它__name__用于蓝图定位资源url_prefix/books让本蓝图所有路由统一挂到/books下/对应/books//create对应/books/create。request.args.get(page, 1, typeint)把分页参数从URL查询串里取出来typeint是Flask提供的类型转换非法值会落到默认值1而不是抛异常。GET和POST共用一个函数是Flask常见写法判断request.method决定是展示表单还是处理提交。新增和编辑各写一个视图函数也能跑但表单页几乎一样共用book/form.html更省事。这里有个坑创建时用Book(...)构造参数直接赋值编辑时应先查再改字段两种方式都合法但不要混用成查出来再传构造参数那会生成新对象而不是更新原对象。3.2 注册蓝图与请求流转从URL到浏览器渲染蓝图本身不生效必须注册到app上。注册代码通常在app/__init__.py的工厂函数里这也是下一章配置分离的前提# app/__init__.py from flask import Flask from app.controllers.book_controller import book_bp from app.controllers.user_controller import user_bp from app.controllers.borrow_controller import borrow_bp from app.extensions import db def create_app(): app Flask(__name__) app.config.from_object(config.settings) db.init_app(app) app.register_blueprint(book_bp) app.register_blueprint(user_bp) app.register_blueprint(borrow_bp) return appcreate_app是Flask应用工厂模式好处是测试时可以多次调用得到独立app实例不共享全局状态。app.config.from_object(config.settings)从config/settings.py读取配置项数据库地址、密钥这些环境相关的东西都集中在配置文件里而不是散落在各个控制器。启动入口单独放一个run.py# run.py from app import create_app app create_app() if __name__ __main__: app.run(debugTrue, host0.0.0.0, port5000)一次完整请求的流转路径是浏览器请求GET /books/?keywordPythonFlask按蓝图前缀匹配到book_bp再按路径找到index()index()从查询参数取keyword构造SQLAlchemy查询调用Book.title.contains(keyword)拼接模糊查询条件paginate()取分页结果最后render_template把结果交给模板渲染成HTML返回浏览器。全程Controller没有触碰数据库底层它只是组织者。请求URL匹配的蓝图路由调用的模型操作渲染的模板GET /books/book_bp /Book.query.filter paginatebook/index.htmlPOST /books/createbook_bp /createBook(...) db.session.add重定向到 book.indexGET /users/user_bp /User.query.paginateuser/index.htmlPOST /borrow/borrowborrow_bp /borrow借阅服务层调用重定向回上一页3.3 视图层模板继承base.html如何消除重复页面视图层在Flask里默认是templates目录下的Jinja2模板。图书管理系统至少有列表、表单、详情三类页面每页都有导航栏、页脚、消息提示如果每个模板都复制一遍公共结构改一次导航要动五六个文件低耦合就名存实亡了。模板继承是Jinja2的核心用法先写父模板{# templates/base.html #} !DOCTYPE html html langzh-CN head meta charsetUTF-8 title{% block title %}图书管理系统{% endblock %}/title link relstylesheet href{{ url_for(static, filenamecss/style.css) }} /head body nav a href{{ url_for(book.index) }}图书管理/a a href{{ url_for(user.index) }}读者管理/a a href{{ url_for(borrow.index) }}借阅流通/a /nav main {% block content %}{% endblock %} /main /body /html子模板只需覆盖内容块{# templates/book/index.html #} {% extends base.html %} {% block title %}图书列表 - 图书管理系统{% endblock %} {% block content %} table trthISBN/thth书名/thth作者/thth可借数/th/tr {% for book in pagination.items %} tr td{{ book.isbn }}/td td{{ book.title }}/td td{{ book.author }}/td td{{ book.available_copies }}/td /tr {% endfor %} /table {% endblock %}{% extends base.html %}声明继承关系{% block content %}与父模板同名渲染时子模板内容会替换进父模板预留的位置。三个{% block %}分别是title和content父模板里没被子模板覆盖的块保持默认内容。Jinja2的{{ }}是变量输出{% %}是控制结构book.title这种属性访问在模板里会自动尝试取attr再取item。视图层最容易越界的动作是在模板里做数据查询和复杂运算。Jinja2虽然支持{% set %}和过滤器但模板里出现Book.query.all()这种代码说明数据准备没做干净。正确姿势是Controller把所有页面需要的数据都放进render_template的context参数模板只做遍历和条件展示。跨页面公共数据比如导航栏里的当前用户、未还借阅数可以注册上下文处理器app.context_processor统一注入不要在每个视图函数里重复传参。4. 高内聚低耦合的落地细节服务层封装、配置分离与依赖注入4.1 把借阅规则抽到service层Controller保持薄业务逻辑可单测图书管理系统里最容易写乱的地方是借阅流程。一个借书动作包含每人最多借几本、书是否在架上、借期多少天、库存扣减四件事如果直接在路由函数里写借书、续借、归还各自实现一份库存修改逻辑散落三处如果写进Model的方法里Book模型又要知道User的存在模型间互相引用越缠越紧。常见做法是在MVC三层之外加一个服务层Service专门放业务规则Controller调用它它调用Model形成控制器→服务→模型单向调用链。# app/services/borrow_service.py from datetime import datetime, timedelta from app.models import BorrowRecord, Book from app.extensions import db MAX_BORROW_COUNT 5 LOAN_DAYS 30 ADMIN_ROLE admin def borrow_book(user, book_id): if user.role ADMIN_ROLE: raise PermissionError(管理员账号不能借书) active_count BorrowRecord.query.filter_by( user_iduser.id, statusborrowed ).count() if active_count MAX_BORROW_COUNT: raise ValueError(f每人最多同时借 {MAX_BORROW_COUNT} 本) book Book.query.get(book_id) if book is None or book.available_copies 0: raise ValueError(图书不存在或库存不足) record BorrowRecord( user_iduser.id, book_idbook.id, borrow_datedatetime.now(), due_datedatetime.now() timedelta(daysLOAN_DAYS) ) book.available_copies - 1 db.session.add(record) db.session.commit() return record def return_book(user, record_id): record BorrowRecord.query.filter_by( idrecord_id, user_iduser.id ).first() if record is None: raise ValueError(借阅记录不存在) if record.status returned: raise ValueError(该记录已归还请勿重复操作) record.status returned record.return_date datetime.now() record.book.available_copies 1 db.session.commit() return recordborrow_book里每一步判断都对应一个明确的失败分支。raise ValueError(...)抛业务异常由Controller捕获后通过flash消息提示用户PermissionError只针对管理员角色意味着权限规则被收拢在服务层Controller里的if user.role ...判断可以全部删除。库存扣减紧跟db.session.add(record)两条写操作在同一个事务里要么同时成功要么同时回滚不会出现记录建好了库存没减的中间态。Controller调用服务的写法非常薄# app/controllers/borrow_controller.py from flask import Blueprint, request, redirect, url_for, flash from app.services import borrow_service borrow_bp Blueprint(borrow, __name__, url_prefix/borrow) borrow_bp.route(/borrow/int:book_id, methods[POST]) def borrow(book_id): user get_current_user() # 从session或token取当前用户 try: borrow_service.borrow_book(user, book_id) flash(借书成功, success) except ValueError as exc: flash(str(exc), error) return redirect(url_for(book.index))服务层把规则和视图层完全隔离单元测试不用启动Flask、不用发HTTP请求直接构造一个User和一个Book就能跑完整借还流程。答辩时现场改一个参数比如MAX_BORROW_COUNT从5改成3只需要动service层一个常量控制器和模板一行都不用改。4.2 配置分离与create_app工厂换数据库只改一处高内聚低耦合在工程上的另一个体现是配置集中管理。图书管理系统经常在开发、演示、验收三种环境间切换数据库地址、调试开关、密钥各不相同。把配置项塞进各个模块不可维护正确做法是建config/settings.py按环境拆类# config/settings.py import os BASE_DIR os.path.abspath(os.path.dirname(__file__)) class BaseConfig: SQLALCHEMY_TRACK_MODIFICATIONS False SECRET_KEY os.environ.get(SECRET_KEY, dev-only-key) class DevConfig(BaseConfig): SQLALCHEMY_DATABASE_URI sqlite:/// os.path.join(BASE_DIR, ../library_dev.db) class ProdConfig(BaseConfig): SQLALCHEMY_DATABASE_URI os.environ.get(DATABASE_URL, mysqlpymysql://root:passwordlocalhost/library_db) class TestConfig(BaseConfig): SQLALCHEMY_DATABASE_URI sqlite:///:memory:工厂函数里用config.from_object加载指定类而不是默认从环境变量猜# run.py import os from app import create_app config_name os.environ.get(FLASK_CONFIG, DevConfig) app create_app(fconfig.settings.{config_name})FLASK_CONFIGProdConfig python run.py启动时自动连MySQL不加环境变量时默认走SQLite。整个系统里除了settings.py没有任何一处直接写数据库连接字符串这就是配置与代码分离。换数据库时先看db.engine.url确认当前连的是哪个库再确认对应驱动已装比如MySQL要pip install pymysql否则启动时抛ModuleNotFoundError。4.3 项目目录结构与分层调用规则速查到这里一个按MVC分层架构组织的图书管理系统目录就完整了照着建就行library_project/ run.py # 启动入口 config/ settings.py # 环境配置 app/ __init__.py # create_app工厂 extensions.py # db SQLAlchemy() 独立文件 models.py # 模型层Book/User/BorrowRecord controllers/ # 控制层视图函数 book_controller.py user_controller.py borrow_controller.py services/ # 服务层业务规则 borrow_service.py templates/ # 视图层Jinja2模板 base.html book/ index.html form.html static/ css/style.css提示分层调用有一条铁律——上层可以调用下层下层绝不反向调用。Controller和Service可以import ModelModel不能import Controllertemplates只能通过render_template拿到变量不能反向import服务层。越级调用的典型症状是在模型文件里写session.get(user_id)、在模板里调db.session.query这两种都该立刻被review出来。层职责允许依赖常见违规Model表映射、字段约束、关系定义仅db扩展模型里写业务if/elseService业务规则、事务封装Model直接操作request/sessionController参数解析、调用服务、选模板Service、Model在路由里写复杂业务View渲染变量、展示逻辑入参变量模板里出现db或请求对象extensions.py单独存放db SQLAlchemy()是本方案的一个关键细节。它避免了models.py和__init__.py相互import导致的循环引用所有模块都from app.extensions import db拿到的是同一个全局实例Flask的db.init_app(app)在工厂里统一绑定。目录建好后最常见的问题是循环导入报错往往是ImportError: cannot import name db from partially initialized module。排查口诀是被依赖的基础对象放最底层文件所有模块只从最底层import。这里的extensions.py就是最底层models、controllers、services都直接import它绝不反向。5. 毕业设计验收3个MVC判断方法、序列化输出和测试策略5.1 三个检查点判断系统是否真的MVC验收前的自检按三层分别执行。第一模型层看外键把Book和BorrowRecord的relationship删掉其他代码是否还跑得通如果跑不通说明其他层直接用了模型关系属性依赖过深。第二控制层看路由函数行数单个视图函数超过30行里面大概率混入了业务逻辑应该下沉到service层。第三视图层做换肤测试在模板里改title和导航文案不改任何Python代码全站随之生效说明模板继承结构完整如果某页样式改了另一处跟着坏说明公共部分没抽干净。如果你用的不是Flask而是DjangoMVC的对应关系略有差别Django的Model对应数据模型Template对应视图展示URL配置加视图函数承担了Controller角色但不强制要求服务层。这套三层职责判断方法跨框架通用阅卷老师认的是拆分逻辑不是框架名。5.2 给图书列表加JSON查询API图书管理系统的验收加分项经常是给图书列表加JSON接口方便后续做小程序端或管理端联调。有了前面的service层序列化输出只差一个入口函数# app/controllers/api_controller.py from flask import Blueprint, jsonify, request from app.models import Book api_bp Blueprint(api, __name__, url_prefix/api) api_bp.route(/books) def list_books(): page request.args.get(page, 1, typeint) books Book.query.paginate(pagepage, per_page5) return jsonify({ total: books.total, items: [ { id: b.id, isbn: b.isbn, title: b.title, author: b.author, available: b.available_copies, } for b in books.items ] })jsonify自动设置Content-Type: application/json字典嵌套列表是JSON序列化最常见的结构。books.total是分页对象的总条数属性前端做分页条可以直接用。别忘了在工厂函数里app.register_blueprint(api_bp)注册这个API否则访问/api/books直接404。底层业务规则仍走同一套Model和Service页面路由和API不会出现两套逻辑的割裂。5.3 用SQLite内存库验证service层借书、还书逻辑写完了不跑一遍不算数。最轻量的测试方法是用SQLite内存库测试结束即销毁不污染开发库# tests/test_borrow_service.py import pytest from app import create_app from app.extensions import db from app.models import User, Book from app.services import borrow_service pytest.fixture() def app(): app create_app(config.settings.TestConfig) with app.app_context(): db.create_all() yield app db.drop_all() def test_borrow_reduces_available_copies(app): with app.app_context(): user User(usernamestu01, password_hashx, rolereader) book Book(isbn978-7-01-000001-1, titleMVC实战, total_copies3, available_copies3) db.session.add_all([user, book]) db.session.commit() borrow_service.borrow_book(user, book.id) db.session.refresh(book) assert book.available_copies 2sqlite:///:memory:每个连接独立内存库fixture里必须保证create_all和断言在同一个app.app_context()内执行否则连接切换会出现no such table。断言book.available_copies 2验证的是真实落库后的数据比print大法可靠得多。验收前一天跑一次pytest -v留下全绿输出比口头解释分层架构更有说服力。本文还有配套的精品资源点击获取