Django-基本入门

这篇讲什么

  • 这篇把第一条链路完整跑通:装 Django、建项目、配数据库、建应用、定义模型、迁移出表、写视图和模板、用路由把页面打开。
  • 所有命令和输出都来自本机 Django 5.2.19 LTS + Python 3.14.10 + MySQL 9.7.4,命名一律按 PEP8(应用叫 myapp,字段叫 boy_num)。

Django简介

  • 是一个开放源代码的 Web 应用框架,由 Python 写成。它自带 ORM、后台管理、模板引擎、表单、认证、缓存、迁移这些「电池全含」的组件,做后台系统时基本不用再挑第三方库。

  • 历史:2005 年 7 月以 BSD 许可首次公开发布,2008 年 9 月发布第一个正式版本 1.0。本套笔记的当前基线是 Django 5.2.19 LTS(延长支持到 2028-04),版本选择的实测依据见《Django 5.2 LTS 与 6.x 的区别对比》。

  • 一句话理解它在干什么:收到一个 URL → 路由表找到视图 → 视图查模型、渲染模板 → 把响应发回去。这篇就按这条线把每个环节跑通一遍。

  • 命名一律按 PEP8 写:模块名、包名(应用名)、函数、变量、模型字段都用小写下划线,类名用大驼峰。所以本笔记里应用叫 myapp(不是老笔记的 myApp),字段叫 boy_num、 is_delete(不是 boyNum、 isDelete)。理由很实际:字段名一旦建表就很难改,而且 python -m pylint、 ruff 这类检查器默认都会报驼峰字段。

  • MVC

    • 概述
      一种软件设计典范,用一种业务逻辑、数据、界面显示分离的方法组织代码,将业务逻辑聚集到一个部件里面,在改进和个性化定制界面及用户交互的同时,不需要重新编写业务逻辑。MVC 被独特地发展起来,用于把传统的输入、处理和输出功能映射到一个图形化用户界面的逻辑结构中。
    • 核心思想
      解耦:界面改了不用动业务逻辑,换数据库也不用动视图代码。
    • 图解
      MVC
    • 编程模式
      • Model(模型):
        应用程序中处理数据逻辑的部分,通常负责在数据库中存取数据。
      • View(视图):
        应用程序中处理数据显示的部分,通常视图是依据模型数据创建的。
      • Controller(控制器):
        应用程序中处理用户交互的部分,负责从视图读取数据、控制用户输入,并向模型发送数据。
    • 优点
      降低各功能模块之间的耦合性,方便变更,更容易重构代码,最大程度地实现代码重用。
  • MTV

    • 概述
      本质上与 MVC 没有差别,也是各组件之间为了保持松耦合关系,只是定义上有些许不同。

    • 核心思想
      还是解耦,但 Django 把「控制器」这个角色拆给了框架自己(路由分发 + 请求对象),所以你的代码里只有 M、T、V 三件要写。

    • 图解
      MTV

    • 编程模式与实测对应关系

      MTV 在 Django 里是哪个文件 负责什么 本笔记实测里的例子
      Model myapp/models.py 业务对象与数据库的映射(ORM) Grade、 Student 两个类
      Template templates/myapp/*.html 把页面展示给用户 students.html、 grades.html
      View myapp/views.py 业务逻辑,适当时候调用 Model 和 Template index、 students、 grades、 grade_students
      (分发器) urls.py 把 URL 请求分派给不同视图 path("students/<int:gid>/", ...)
    • 注意

      • Django 还有一个 URL 分发器,它的作用是把一个个 URL 的页面请求分发给不同的 view 处理,view 再调用相应的 Model 和 Template。
      • Django 的 View 比 MVC 的 View 更「厚」:它同时承担了 MVC 里 Controller 的活(读请求、调模型、选模板),所以新手最容易犯的错是把业务逻辑全写进模板里。

Django安装

  • Django 与 Python 版本对应关系

    • 官方 FAQ 的支持矩阵(2026-11-04 抓的),加上本机 importlib.metadata 读到的 Requires-Python 实测值:

      Django 版本 官方支持的 Python 本机实测 Requires-Python
      4.2 LTS 3.8、3.9、3.10、3.11、3.12(4.2.8 起) —
      5.0 3.10、3.11、3.12 —
      5.1 3.10、3.11、3.12、3.13(5.1.3 起) —
      5.2 LTS(本笔记基线) 3.10、3.11、3.12、3.13、3.14(5.2.8 起) >=3.10
      6.0 3.12、3.13、3.14 —
      6.1 3.12、3.13、3.14、3.15(6.1.3 起) >=3.12
    • 老笔记里那张 1.8 / 1.9 / 2.0 / 2.1 的表已经全部停止支持(连安全修复都没有了),照着装会踩到 Python 版本对不上的问题,所以这里换成现行矩阵。

  • 安装过程

    • 建议给每个项目单独建环境,不要都塞进 base:

      1
      2
      3
      conda create -n django python=3.14 -y
      conda activate django
      pip install -i https://mirrors.ustc.edu.cn/pypi/web/simple Django==5.2.19
    • 验证是否安装成功(进 Python 环境,实测输出):

      1
      2
      3
      4
      5
      >>> import django
      >>> django.get_version()
      '5.2.19'
      >>> django.VERSION
      (5, 2, 19, 'final', 0)

      get_version() 返回给人看的字符串,VERSION 是给代码写判断用的五元组,两者都可用。

    • 命令行验证也可以:

      1
      django-admin --version        # 5.2.19

Django项目创建

  • 建工程

    • 打开终端,进入工程存放目录,然后:

      1
      django-admin startproject project
    • 也可以指定建在哪(startproject 的第二个参数是目标目录,目录要先存在):

      1
      django-admin startproject project E:\Desktop\Test\django-basic-probe
  • 目录层级(实测 startproject + startapp myapp 后的完整文件清单)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    manage.py
    project/__init__.py
    project/asgi.py
    project/settings.py
    project/urls.py
    project/wsgi.py
    myapp/__init__.py
    myapp/admin.py
    myapp/apps.py
    myapp/models.py
    myapp/tests.py
    myapp/views.py
    myapp/migrations/__init__.py
    • manage.py:一个命令行工具,使我们能用多种方式与 Django 项目交互。
    • project/__init__.py:空文件,告诉 Python 这个目录是一个包。
    • project/settings.py:项目的配置文件。
    • project/urls.py:项目的 URL 声明(根路由表)。
    • project/wsgi.py:项目与 WSGI 兼容的 Web 服务器入口(同步部署用,如 Gunicorn)。
    • project/asgi.py:ASGI 入口(异步部署用,如 uvicorn;老笔记里没有这个文件,是 Django 3.0 之后才加的)。
    • myapp/apps.py:应用配置类,需要「应用启动时做点事」时在这里写 ready()。
    • myapp/migrations/__init__.py:迁移文件目录,没有它 makemigrations 会报错。
  • settings.py 里几个关键行(实测生成内容)

    settings.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    # project/settings.py 实测生成内容(节选)
    from pathlib import Path

    # Build paths inside the project like this: BASE_DIR / 'subdir'.
    BASE_DIR = Path(__file__).resolve().parent.parent

    ALLOWED_HOSTS = []

    TEMPLATES = [
    {
    "BACKEND": "django.template.backends.django.DjangoTemplates",
    "DIRS": [],
    ...
    },
    ]

    USE_TZ = True

    DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
    • BASE_DIR 是 pathlib.Path 对象,所以官方注释里推荐 BASE_DIR / "templates" 这种写法;老笔记里的 os.path.join(BASE_DIR, "templates") 也能用,但要先把 os 导进来。
    • DEFAULT_AUTO_FIELD 这一行新项目里默认就是 BigAutoField(实测),它决定了所有没写主键的模型自增列的类型,见下面「定义模型」。
    • 一个坑:生成文件里的引号风格由本机装没装 black 决定(startproject 末尾会调 run_formatters()),装了就是双引号、没装就是模板里的单引号。写脚本批量改 settings 时,替换完一定要断言期望内容真的进了文件。
    • 这一节只过了「生成文件里能看到的那几行」。想知道每个键的默认值、改了为什么不生效、diffsettings 怎么读,看《Django-settings详解》——146 个键的默认值是本机 global_settings 现读的。

数据库配置

  • 默认用 SQLite

    • Django 默认使用 SQLite,settings.py 里 DATABASES 的默认值就是:

      settings.py
      1
      2
      3
      4
      5
      6
      DATABASES = {
      "default": {
      "ENGINE": "django.db.backends.sqlite3",
      "NAME": BASE_DIR / "db.sqlite3",
      }
      }
    • 学习阶段用 SQLite 最省事:不用装数据库、不用起服务,工程目录下会生成一个 db.sqlite3 文件。

  • 换成 MySQL(官方推荐 mysqlclient,本机实测)

    • 官方推荐的驱动是 mysqlclient(C 扩展,导入名是 MySQLdb)。PyPI 上有对应 Python 版本的预编译 wheel,本机不需要编译环境:

      1
      2
      pip install --only-binary :all: mysqlclient
      Successfully installed mysqlclient-2.3.0
      • --only-binary :all: 的作用是「没有 wheel 就立刻失败」,避免它悄悄退回源码编译然后卡一晚上。

      • 装完直接 import MySQLdb 就能用,不需要 pymysql.install_as_MySQLdb() 那种伪装:

        1
        2
        MySQLdb.version_info: (2, 3, 0, 'final', 0)
        MySQLdb.get_client_info(): 3.4.9

        3.4.9 是 Windows wheel 里自带的 MariaDB Connector/C 客户端库版本,不是 MySQL 版本;用它连本机的 MySQL 9.7.4 服务端实测正常。

    • 配置 DATABASES(password 走环境变量,不要把明文写进代码):

      settings.py
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      import os

      DATABASES = {
      "default": {
      "ENGINE": "django.db.backends.mysql",
      "NAME": "django_note_mcli", # 数据库名,要先建好
      "USER": "root",
      "PASSWORD": os.environ["DJANGO_NOTE_MCLI"],
      "HOST": "127.0.0.1",
      "PORT": "3306",
      "OPTIONS": {"charset": "utf8mb4"},
      }
      }

      OPTIONS 里的 charset 决定连接字符集,实测建出来的表列排序规则是 utf8mb4_0900_ai_ci。

    • 本机实测(WSL 里的 MySQL 9.7.4 测试库,Windows 侧填 127.0.0.1 就能连上):

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      check rc=0 System check identified no issues (0 silenced).
      makemigrations rc=0
      + Create model Grade
      + Create model Student
      migrate rc=0
      Applying myapp.0001_initial... OK
      vendor: mysql
      mysql_version: (9, 7, 4)
      mysql_is_mariadb: False
      客户端库: 3.4.9
      @@sql_mode: ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION
      读回: mcli班级 1 1
      超长写入: DataError (1406, "Data too long for column 'name' at row 1")
      回滚后存在吗: False
      参数化查询: (1,)
      • sqlmigrate 能看到 Django 给 MySQL 生成的 DDL:主键是 bigint AUTO_INCREMENT,布尔列写成 bool,外键单独一条 ALTER TABLE ... ADD CONSTRAINT:

        1
        2
        3
        CREATE TABLE `myapp_grade` (`id` bigint AUTO_INCREMENT NOT NULL PRIMARY KEY, `name` varchar(20) NOT NULL, `boy_num` integer NOT NULL, `girl_num` integer NOT NULL, `is_delete` bool NOT NULL);
        CREATE TABLE `myapp_student` (`id` bigint AUTO_INCREMENT NOT NULL PRIMARY KEY, `name` varchar(20) NOT NULL, `sex` bool NOT NULL, `age` integer NOT NULL, `is_delete` bool NOT NULL, `grade_id` bigint NOT NULL);
        ALTER TABLE `myapp_student` ADD CONSTRAINT `myapp_student_grade_id_c33d0adc_fk_myapp_grade_id` FOREIGN KEY (`grade_id`) REFERENCES `myapp_grade` (`id`);
      • 判断严格模式有个坑:connection.sql_mode 返回的是集合({'STRICT_TRANS_TABLES', ...}),写 "STRICT" in connection.sql_mode 永远是 False;要么用完整名字判断,要么直接 SELECT @@sql_mode。

    • 老笔记那条纯 Python 路线(PyMySQL + 伪装)也实测能跑,属于兼容层,不是官方推荐:

      myapp/__init__.py
      1
      2
      3
      import pymysql

      pymysql.install_as_MySQLdb()
      • 本机的实测数字:pip 元数据 PyMySQL 1.2.3、 pymysql.__version__ 2.2.8、 伪装后 MySQLdb.version_info (2, 2, 8, 'final', 1),正好过了 Django 对 mysqlclient 版本下限的检查,所以 check 不抱怨。
      • 新项目直接用 mysqlclient,别把伪装写进 __init__.py;只有在装不了 C 扩展的环境(某些纯托管镜像、共享主机)才退回 PyMySQL。
  • 换成 PostgreSQL(WSL2 Ubuntu 里装,本机实测)

    • 安装(Ubuntu 26.04 源里的当前大版本就是 18):

      1
      2
      3
      sudo apt-get install -y postgresql postgresql-contrib
      systemctl is-enabled postgresql
      systemctl is-active postgresql@18-main
      • 实测装到 postgresql-18 (18.6-0ubuntu0.26.04.1),服务 enabled 且 postgresql@18-main 是 active。
      • PostgreSQL 没有单独的 LTS 线:每个大版本发布后支持 5 年,所以「要最新的长期支持版」在 Ubuntu 里就等于「源里最新的大版本」。
    • 建角色和库(在 WSL 里以 postgres 用户执行):

      1
      2
      CREATE ROLE django LOGIN PASSWORD 'password';
      CREATE DATABASE django_note OWNER django;
      • 实测 SELECT rolname, rolcanlogin FROM pg_roles WHERE rolname = 'django' 得到 django | t,\l 里 django_note 的编码是 UTF8。
      • 这里的 password 是占位符,真项目换成随机密码并放环境变量,不要提交进仓库。
    • 让 Windows 侧也能连(WSL2 的坑,实测):

      /etc/postgresql/18/main/postgresql.conf
      1
      2
      listen_addresses = '*'
      port = 5600 # 默认是 5432,本机必须挪走,原因见下一条
      1
      2
      # /etc/postgresql/18/main/pg_hba.conf 末尾追加
      host all all 0.0.0.0/0 scram-sha-256
      • 默认只监听 localhost,所以第一步就是 listen_addresses = '*' 并重启服务。
      • 端口必须从 5432 挪走:127.0.0.1:5432 在 Windows 侧压根没人听(netstat -ano | findstr :5432 空),因为 5432 落在 netsh int ipv4 show excludedportrange protocol=tcp 列出的保留区间 5384-5483 里,负责转发的 wslrelay.exe 绑不上这个端口。实测 wsl --shutdown 重启整个 WSL 也没用,换到区间外的 5600 立刻就能被代收。
      • 不想改端口的话只有两条退路:主机填 WSL 自己的 IP(wsl hostname -I 拿到 192.168.104.119,实测能连通,但这个 IP 每次重启可能变),或者管理员加一条 netsh interface portproxy(普通权限直接报「请求的操作需要提升」)。
      • 细节与 Navicat 的完整填法见《用 Navicat 连 WSL2 里的数据库》的 PostgreSQL 一节。
    • 装驱动:Django 4.2 起 PostgreSQL 后端只认 psycopg 3(psycopg2 不再支持),本机装带二进制包的 extra:

      1
      2
      pip install --only-binary :all: "psycopg[binary]"
      Successfully installed psycopg-3.3.8 psycopg-binary-3.3.8
    • 配置(同样 password 走环境变量):

      settings.py
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      import os

      DATABASES = {
      "default": {
      "ENGINE": "django.db.backends.postgresql",
      "NAME": "django_note",
      "USER": "django",
      "PASSWORD": os.environ["DJANGO_NOTE_PG_PW"],
      "HOST": "127.0.0.1",
      "PORT": "5600", # 本机把 PostgreSQL 从 5432 挪到了 5600;没挪就是 5432
      "OPTIONS": {"connect_timeout": 8},
      "CONN_MAX_AGE": 60,
      }
      }
      • OPTIONS 里的 connect_timeout 强烈建议加:连不上时 8 秒就报错。本机踩过「HOST 填了 127.0.0.1、端口还是 5432,manage.py 直接挂住」的坑,就是因为没有超时(实测等满 3 秒超时才抛 ConnectionTimeout)。
      • CONN_MAX_AGE = 60 是连接复用(持久连接),60 秒内不重连。
    • 本机实测:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      check rc=0 System check identified no issues (0 silenced).
      migrate rc=0
      Applying blog.0001_initial... OK
      vendor: postgresql
      psycopg: 3.3.8
      has psycopg2: False
      SQL version: PostgreSQL 18.6 (Ubuntu 18.6-0ubuntu0.26.04.1) | num: 180006
      读回: pg文章 {'k': [1, 2]} ['django', 'pg'] 1
      JSONField 查询: 1
      ArrayField 查询: 1
      超长写入: DataError value too long for type character varying(50)
      回滚后存在吗: False
      • sqlmigrate 生成的 DDL 和 MySQL 明显不同:主键是 bigint NOT NULL PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,JSONField 落成 jsonb,ArrayField 落成 varchar(20)[],unique=True 的 CharField 还会额外建一个 varchar_pattern_ops 索引:

        1
        2
        CREATE TABLE "blog_article" ("id" bigint NOT NULL PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, "title" varchar(50) NOT NULL UNIQUE, "meta" jsonb NOT NULL, "keywords" varchar(20)[] NOT NULL, "views" integer NOT NULL);
        CREATE INDEX "blog_article_title_3c514952_like" ON "blog_article" ("title" varchar_pattern_ops);
      • django.contrib.postgres 里的特性只在 PostgreSQL 上有效。最直观的一条:admin 的 search_fields 用 @ 前缀时走全文检索,实测生成的 SQL 是:

        1
        WHERE (UPPER("blog_article"."title"::text) = UPPER('pg文章') OR to_tsvector(COALESCE("blog_article"."title", '')) @@ (plainto_tsquery('pg文章')))

        同样的配置放到 MySQL 上,实测直接 django.core.exceptions.FieldError: Unsupported lookup 'search' for CharField or join on the field not permitted.

    • 后面的文章(模型、视图、站点管理等)默认都走 MySQL 主路径;只有需要 ArrayField、全文检索这类 PostgreSQL 特性时才换过来。

  • 一句提醒

    上面 127.0.0.1:3306、 192.168.104.119:5432 都是本机的测试库,只给「写文章时逐条跑一遍」用。线上生产库的连接信息不要写进笔记,也不要因为本机连不上就去改它。

创建应用与激活应用

  • 创建应用

    • 一个工程可以有一个或多个应用,每个应用对应处理一类业务逻辑。终端进入工程目录后:

      1
      python manage.py startapp myapp
    • 应用名就是 Python 包名,按 PEP8 全小写(myapp、 student_manage 这种)。老笔记用的 myApp 也能跑,但包名带大写会在大小写敏感的系统(Linux 服务器、Docker 卷)上埋雷,而且 ruff / pylint 会直接报 invalid-name。

  • 激活应用

    • 配置 settings.py 中的 INSTALLED_APPS(注意拼写,是 installed):

      settings.py
      1
      2
      3
      4
      5
      6
      7
      8
      9
      INSTALLED_APPS = [
      "django.contrib.admin",
      "django.contrib.auth",
      "django.contrib.contenttypes",
      "django.contrib.sessions",
      "django.contrib.messages",
      "django.contrib.staticfiles",
      "myapp",
      ]
    • 老笔记这里写的是 INSTALL_APPS。实测两种错法的表现完全不同:

      错法 实测结果 说明
      键名写成 INSTALL_APPS python manage.py check 失败:LookupError: No installed app with label 'admin'.;apps.get_app_configs() 返回 [](myapp 在里面吗: False) 框架读的是 INSTALLED_APPS,你写的那个键没人看,等于用了默认的空列表
      键名对、但应用名写错("myappTYPO") ModuleNotFoundError: No module named 'myappTYPO' 这条会立刻炸,反而好查
      • 结论:settings 里的键名是按名字被读取的,写错名字不会有任何提示,所以改完 settings 就顺手跑一次 python manage.py check。

定义模型与生成数据表

  • 有一个数据表,就对应有一个模型类

    • 先按老笔记的表结构图理解要存什么(班级表 + 学生表,一对多):

      表结构

    • 在应用目录下的 models.py 中定义模型(字段名全部按 PEP8 下划线写):

      myapp/models.py
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      from django.db import models


      class Grade(models.Model):
      """班级表"""
      name = models.CharField(max_length=20)
      boy_num = models.IntegerField()
      girl_num = models.IntegerField()
      is_delete = models.BooleanField(default=False)

      def __str__(self):
      return self.name


      class Student(models.Model):
      """学生表,grade 外键指向 Grade"""
      name = models.CharField(max_length=20)
      sex = models.BooleanField()
      content = models.CharField(max_length=20)
      age = models.IntegerField()
      grade = models.ForeignKey(Grade, on_delete=models.CASCADE, related_name="students")
      is_delete = models.BooleanField(default=False)

      def __str__(self):
      return self.name
    • 老笔记这里写的是 boyNum、 girlNum、 isDelete;上面换成 boy_num、 girl_num、 is_delete。字段名一旦建表就很难改(改名要么写 db_column 过渡,要么做「加新列-迁数据-删旧列」三步迁移),所以一开始就按规范命名最省事。

    • on_delete=models.CASCADE 是必填的:不写会直接 TypeError: ForeignKey.__init__() missing 1 required positional argument: 'on_delete'(实测)。

    • related_name="students" 让反向查询写成 grade.students.all();不写的话 Django 按「小写模型名 + _set」生成,即 grade.student_set.all()(前一轮实测过默认名就是 student_set)。

  • 不需要定义主键

    • 老笔记说「不需要定义主键,生成时自动添加并且值为自动增长」,这句对,但要说清是哪一种自增主键。实测:

      1
      python manage.py shell -c "from myapp.models import Student; f = Student._meta.pk; print(f.name, type(f).__name__, f.get_internal_type())"
      1
      2
      主键: id BigAutoField BigAutoField
      字段: ['id', 'name', 'sex', 'content', 'age', 'grade', 'is_delete']
    • 因为新项目 settings.py 里默认写了 DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField",所以自动主键是 64 位的 BigAutoField。

    • 两个实测细节:Django 5.2 的全局默认(settings 里完全不管这个设置时)是 AutoField,32 位;而把生成的那一行删掉后 check 并不报错、主键也仍是 BigAutoField,因为已生成的 0001_initial.py 里把字段类型写死了。也就是说 DEFAULT_AUTO_FIELD 只管以后新建的模型,改它不会改动已有表。

  • 生成数据表

    • 生成迁移文件:

      1
      python manage.py makemigrations
      1
      2
      3
      4
      Migrations for 'myapp':
      myapp\migrations\0001_initial.py
      - Create model Grade
      - Create model Student
      • 迁移文件生成在应用的 migrations 目录下,它描述的是「怎么改表」,不是表本身。
    • 执行迁移(在数据库中建表):

      1
      python manage.py migrate
      1
      2
      3
      4
      5
      Applying contenttypes.0001_initial... OK
      Applying auth.0001_initial... OK
      ...
      Applying myapp.0001_initial... OK
      Applying sessions.0001_initial... OK
    • 任何时候想确认工程没问题:

      1
      python manage.py check
      1
      System check identified no issues (0 silenced).

数据测试:shell 里的增删改查

  • 进入 shell 并引入模型

    1
    python manage.py shell
    1
    >>> from myapp.models import Student, Grade

    manage.py shell 与直接敲 python 的区别是:它已经把 settings 装好、数据库连接也配好了,所以模型可以直接用。

  • 增

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    >>> grade = Grade()
    >>> grade.name = "zzpython1801"
    >>> grade.boy_num = 40
    >>> grade.girl_num = 10
    >>> grade.save()
    >>> sut = Student()
    >>> sut.name = "sunck"
    >>> sut.sex = True
    >>> sut.content = "sunck is a good man"
    >>> sut.age = 18
    >>> sut.grade = grade
    >>> sut.save()
    >>> sut.pk, sut.grade_id
    (1, 1)
    • 实测这一串跑完的输出:pk= 1 grade_id= 1 反向= 1(最后一项是 grade.students.count())。

    • 更省事的写法是一次性 create:

      1
      2
      3
      >>> g = Grade.objects.create(name="zzpython1802", boy_num=41, girl_num=11)
      >>> g.pk, str(g)
      (2, 'zzpython1802')

      str(g) 之所以是班级名而不是 Grade object (2),是因为模型里定义了 __str__;不定义就会显示 Grade object (2)(前一轮实测),模板里 {{ stu.grade }} 也会跟着变难看。

    • 老笔记这里把字段名写成了 sut.contend,这是个典型的静默丢数据。实测两种写法的结果完全不同:

      写法 实测结果
      属性赋值 sut.contend = "..." 然后 save() save 成功,但库里 content 存的是空字符串 ''(contend 是字段吗: False)—— 数据静默丢了
      构造参数 Student(contend="...") 当场 TypeError: Student() got unexpected keyword arguments: 'contend'

      所以:给模型实例赋一个不存在的属性,Python 不会拦你,Django 也不会拦,只有查库才发现。写的时候用表单/序列化器保存,或者调 full_clean() 自检,比肉眼可靠。

  • 查

    1
    2
    3
    >>> Student.objects.all()                 # 全部,返回查询集
    >>> Student.objects.get(pk=1) # 一条,返回模型对象
    >>> Student.objects.filter(grade_id=1) # 多条,返回查询集
    • 实测三种等价取法的结果一致:filter(grade_id=g.id).count() = filter(grade=g).count() = g.students.count(),都返回 2。

    • get() 查不到时抛的是模型自己的异常,不是返回 None(实测):

      1
      2
      DoesNotExist : Student matching query does not exist.
      first() 拿不到时: None

      所以要么 filter(...).first(),要么 try / except Student.DoesNotExist。

  • 删

    1
    2
    >>> stu = Student.objects.get(pk=1)
    >>> stu.delete()
    • 老笔记说「仅仅删除数据库中对应数据,程序中的对象依然可以使用」,这句在现在的 Django 上只对了一半,实测:

      1
      删前 pk= 1 删后对象 pk= None 库里条数= 1

      对象确实还能访问属性,但 pk 被置成了 None(Django 3.0 起的行为),所以别指望继续拿它当「刚删掉那条」用。

    • 老笔记还说「stu.save() 可以恢复数据」,实测恢复不出原来那条:

      1
      再 save 之后: 原 pk 还在吗 False 新 pk= 3 库里条数= 2

      因为 pk 已经是 None,save() 走的是 INSERT,新行拿到一个新主键。要「撤销删除」得靠事务(transaction.atomic 里回滚),不是靠 save()。

  • 改

    1
    2
    3
    >>> stu = Student.objects.get(pk=1)
    >>> stu.age = 19
    >>> stu.save()

    只改部分字段可以用 save(update_fields=["age"]),能少发几列的 UPDATE。

  • max_length 到底拦不拦(实测两种数据库)

    1
    2
    >>> s = Student(name="n" * 40, sex=True, content="c" * 40, age=1, grade=g)
    >>> s.full_clean()
    1
    2
    ValidationError {'name': ['Ensure this value has at most 20 characters (it has 40).'],
    'content': ['Ensure this value has at most 20 characters (it has 40).']}
    • 但直接 save() 时:

      数据库 实测结果
      SQLite 存下来了,len(...) == 40(不截断、不报错)
      MySQL 9.7.4(sql_mode 含 STRICT_TRANS_TABLES) DataError (1406, "Data too long for column 'name' at row 1")
    • 结论:max_length 主要由表单和 full_clean() 负责校验,数据库层是否拦取决于 DBMS。开发用 SQLite 一路绿灯、上线换 MySQL 就报错,就是这个原因。

视图、路由与模板:把页面跑起来

  • 视图的基本使用

    视图是对 Web 请求进行的响应;它接收 request 对象作为第一个参数(由 Django 构建,包含请求的信息);本质就是一个函数,定义在 views.py 中。函数名同样按 PEP8 用下划线(老笔记的 gradeStudents 改成 grade_students)。

    myapp/views.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    from django.http import HttpResponse
    from django.shortcuts import render

    from myapp.models import Student, Grade


    def index(request):
    return HttpResponse("<H1>Hello Django</H1>")


    def students(request):
    stus = Student.objects.all()
    return render(request, "myapp/students.html", {"stus": stus})


    def grades(request):
    gs = Grade.objects.all()
    return render(request, "myapp/grades.html", {"grades": gs})


    def grade_students(request, gid):
    stus = Student.objects.filter(grade_id=gid)
    return render(request, "myapp/students.html", {"stus": stus})
  • 路由配置

    • 主路由(project/urls.py)关联子路由。老笔记这段少了 urlpatterns 的收尾方括号,照抄会 SyntaxError,这里给完整版:

      urls.py
      1
      2
      3
      4
      5
      6
      7
      from django.contrib import admin
      from django.urls import include, path

      urlpatterns = [
      path("admin/", admin.site.urls),
      path("", include("myapp.urls")),
      ]
    • 子路由:在应用目录下创建 urls.py。path() 够用就用它,需要正则再用 re_path()(django.conf.urls.url() 早在 Django 4.0 就被删了):

      myapp/urls.py
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      from django.urls import path, re_path

      from myapp import views

      app_name = "myapp" # 命名空间写在这里

      urlpatterns = [
      path("", views.index, name="index"),
      path("students/", views.students, name="students"),
      path("grades/", views.grades, name="grades"),
      path("students/<int:gid>/", views.grade_students, name="grade_students"),
      re_path(r"^old_students/(?P<gid>\d+)/$", views.grade_students, name="old_grade_students"),
      ]
    • 三种写法的实测差别:

      写法 匹配范围 视图收到的 gid
      path("students/<int:gid>/", ...) 只有数字,/students/abc/ 直接 404(实测) 已经是 int
      re_path(r"^students/(\d+)/$", ...) 同样只匹配数字 是字符串,要自己转
      re_path(r"^old_students/(?P<gid>\d+)/$", ...) 命名组,可读性更好 同上,字符串
    • 命名路由 + reverse() 实测(写死 URL 的坏处就是改路径时到处都要改):

      1
      2
      3
      4
      5
      6
      7
      >>> from django.urls import reverse
      >>> reverse("myapp:index")
      '/'
      >>> reverse("myapp:grade_students", kwargs={"gid": 2})
      '/students/2/'
      >>> reverse("myapp:old_grade_students", kwargs={"gid": 3})
      '/old_students/3/'
  • 模板的基本使用

    • 模板是 HTML 页面,可以根据视图中传递的数据进行填充(渲染)。

    • 建目录:工程目录下建 templates,其下再按应用建 myapp(这样不同应用的同名模板不会互相覆盖)。

    • 配置模板路径(settings.py 里 TEMPLATES 的 DIRS):

      settings.py
      1
      2
      3
      4
      5
      6
      7
      8
      TEMPLATES = [
      {
      "BACKEND": "django.template.backends.django.DjangoTemplates",
      "DIRS": [BASE_DIR / "templates"],
      "APP_DIRS": True,
      ...
      },
      ]

      DIRS 必须是列表。实测把它写成字符串 "DIRS": "templates" 时 check 一声不吭,真正渲染才炸出 TemplateDoesNotExist: myapp/students.html;APP_DIRS=True 时还会额外去每个应用的 templates/ 子目录里找。

    • 模板文件(templates/myapp/students.html),注意 {% empty %} 是空数据兜底:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      <!DOCTYPE html>
      <html lang="zh">
      <head><meta charset="UTF-8"><title>学生信息</title></head>
      <body>
      <h1>学生信息</h1>
      <ul>
      {% for stu in stus %}
      <li>{{ stu.name }}--{{ stu.age }}--{{ stu.grade }}</li>
      {% empty %}
      <li>还没有学生</li>
      {% endfor %}
      </ul>
      </body>
      </html>
    • templates/myapp/grades.html(点班级看对应班级的学生)。这里用 {% url %} 而不是写死路径:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      <!DOCTYPE html>
      <html lang="zh">
      <head><meta charset="UTF-8"><title>班级信息</title></head>
      <body>
      <ul>
      {% for grade in grades %}
      <li><a href="{% url 'myapp:grade_students' grade.id %}">{{ grade.name }}</a></li>
      {% endfor %}
      </ul>
      </body>
      </html>

      老笔记写的是 <a href="/students/{{ grade.id }}/">,改路由时模板得跟着改;{% url %} 会跟着路由表走。

    • 视图里用模板:

      views.py
      1
      2
      3
      4
      5
      6
      7
      def index(request):
      return render(request, "myapp/index.html")


      def students(request):
      stus = Student.objects.all()
      return render(request, "myapp/students.html", {"stus": stus})

      render() 的第三个参数是上下文字典,模板里用 {{ stu.name }} 取值;{{ stu.grade }} 打印的是班级对象,实际调用模型的 __str__。

  • 启动服务与实测结果

    1
    python manage.py runserver 127.0.0.1:8435 --noreload
    请求 实测状态码 页面长度 校验点
    / 200 21 字节 HttpResponse 直接返回的那串 HTML
    /students/ 200 314 字节 渲染 students.html,列表里是 sunck--18--zzpython1801
    /grades/ 200 251 字节 {% url %} 生成的链接是 /students/1/、 /students/2/
    /students/1/ 200 314 字节 走 grade_students,同样渲染 students.html
    /students/abc/ 404 — <int:gid> 转换器不匹配非数字
    /probe/ 200 31 字节 内容是三条 reverse() 结果用竖线拼起来的:/、 /students/2/、 /old_students/3/
    • 格式:python manage.py runserver ip:port。
      • ip 可以不写,直接 python manage.py runserver 8080;默认端口是 8000。
      • 写成 0.0.0.0:8000 会监听所有网卡,手机/局域网都能访问,但 DEBUG=True 时别这么干到公网机器上。
      • --noreload 关掉自动重载,调试脚本、压测时用;默认开着,改代码会自动重启,但删文件、加应用这类改动仍需手动重启。
    • 这是纯 Python 写的轻量级开发服务器,只在开发阶段用,稳定性不足以对外服务;生产用 Gunicorn / uvicorn + Nginx(见部署篇)。
    • DEBUG = False 时 ALLOWED_HOSTS 必须填上域名或 IP,否则每个请求都是 400 并提示 DisallowedHost。

修改模型之后:迁移排障

  • 正常流程

    改 models.py → makemigrations → migrate,绝大多数情况这样就够了。

  • 先判断「到底有没有待生成的迁移」

    1
    python manage.py makemigrations --check --dry-run

    实测两种结果的退出码不一样,这条命令可以直接放进 CI:

    情况 rc 输出
    模型和迁移文件一致 0 No changes detected
    模型改了但没生成迁移 1 打印出生成的迁移内容,例如 myapp\migrations\0002_teacher.py - Create model Teacher
  • 看迁移计划与已应用状态

    1
    python manage.py migrate --plan
    1
    2
    Planned operations:
    No planned migration operations.
    1
    python manage.py showmigrations myapp
    1
    2
    myapp
    [X] 0001_initial

    [X] 表示这条迁移已经写进 django_migrations 表(记录),[ ] 表示没跑过。

  • 真出问题时的两种方案

    1. 先备份。任何迁移排障前,mysqldump 或复制 db.sqlite3。
    2. 迁移文件没问题、只是记录对不上:python manage.py migrate myapp 0001_initial --fake 或 --fake-initial,把记录补上而不动表。
    3. 想让某应用的迁移记录全部回到「没跑过」:
      1
      python manage.py migrate myapp zero --fake
      实测输出:
      1
      2
      3
      4
      5
      Operations to perform:
      Unapply all migrations: myapp
      Running migrations:
      Rendering model states... DONE
      Unapplying myapp.0001_initial... FAKED
      再 showmigrations myapp 就变成 [ ] 0001_initial(实测)。表和数据还在库里,只是记录被抹掉了 —— 这正是 --fake 的危险之处:接下来 migrate 会试图重新建表,如果表还在就会报「表已存在」。所以第 3 步之后要么手工把表删掉再 migrate,要么把迁移文件也一起删干净重来。
    4. 开发库(SQLite / 本地测试 MySQL):删掉 db.sqlite3 或 DROP DATABASE,删掉 migrations/00*.py(留 __init__.py),重新 makemigrations + migrate。
    5. 生产库:不允许走到第 3、4 步,只能补迁移文件,必要时用 RunPython 写数据修补。
  • 改字段名/删字段的顺序

    先 makemigrations 生成迁移,再打开迁移文件检查它是不是「删旧列 + 加新列」(这会丢数据)。想把 boyNum 改成 boy_num 这种改名,正确做法是在新字段上写 db_column="boyNum" 先占住旧列名,或者拆成两步迁移:先加新列、跑数据、再删旧列。

这篇里的新手卡点清单

都是实测过的,按踩中概率排序:

卡点 症状 正确做法
字段名拼错(contend) 属性赋值不报错,库里存空值 用表单/序列化器保存,或 full_clean() 自检
字段用驼峰(boyNum) 能跑,但迁移改名很痛、检查器报警 建表前就按 PEP8 写 boy_num
INSTALLED_APPS 拼成 INSTALL_APPS check 报 LookupError: No installed app with label 'admin'. 改完 settings 就跑一次 python manage.py check
外键不写 on_delete TypeError: ForeignKey.__init__() missing 1 required positional argument: 'on_delete' 每个外键都写 on_delete=models.CASCADE 之类
模板目录没配进 DIRS TemplateDoesNotExist: myapp/students.html DIRS: [BASE_DIR / "templates"],注意是列表
delete() 后想 save() 恢复 恢复出一条新记录,pk 变了 删除前留好主键,或用事务回滚
以为 max_length 会拦超长 SQLite 存进去了,MySQL 报 DataError (1406, ...) 校验交给表单/序列化器,别指望数据库
照抄旧笔记的 url() ImportError: cannot import name 'url' from 'django.conf.urls' 用 path(),需要正则时 re_path()
urlpatterns 少收尾 ] SyntaxError 或导入期报错 老笔记里这段是截断的,别整段照抄

参考