Django路由与视图全解析:从URL匹配到CBV进阶
1. 先把Django的路由与视图关系捋清楚第一次接触Django的时候我觉得最难理解的就是URL路由和视图之间到底是怎么串联起来的。明明写了一个视图函数浏览器访问却报404这种问题我相信每个Django新手都遇到过。其实Django的处理逻辑非常直白用户在浏览器里输入URLDjango拿着这个URL去urls.py里面从头到尾匹配一旦匹配到某个path或者re_path就把请求交给对应的视图函数去处理视图函数处理完返回一个HttpResponse对象Django再把这个响应发回给浏览器。整个过程就是一个URL - 视图 - 响应的链路。这个链路里URLConf也就是urls.py充当的是总调度台的角色。Django的MTV模式里面M是Model负责数据库T是Template负责页面展示V是View负责业务逻辑。那路由呢路由不属于任何一层它是连接浏览器和视图之间的桥梁。很多初学者会混淆视图和路由的职责视图只负责收到请求后该做什么路由只负责哪个URL该交给哪个视图。把这两件事拆开想Django的设计思路就清晰了。用一个生活化的类比URL路由就像公司前台来访者说我要找财务部张会计前台查了一下通讯录带到张会计的工位。张会计就是视图函数她听完来意之后办事办完再把结果交出来。前端接待处URLConf不负责办理业务只负责指路张会计不负责接客只负责处理业务。这节我们先把这个模型立起来后面所有的高级配置、类视图、反向解析都围绕着这个模型展开。1.1 Django收到请求后究竟做了什么当你在终端执行python manage.py runserverDjango会启动一个开发服务器监听本机的8000端口。你拿着浏览器访问http://127.0.0.1:8000/courses/list/这个请求经过WSGIHandler进入Django框架内部。接下来发生的事情按照顺序是这样的Django读取根路由文件默认是项目同名目录下的urls.py这个文件在settings.py中被ROOT_URLCONF指定。从urlpatterns列表的第一个元素开始逐个匹配请求路径。匹配规则是不完全相等的如果某个path规则与请求路径匹配成功就停止继续查找。把匹配到的view函数或include指向的子路由加载出来。实例化一个HttpRequest对象封装了请求方式、请求头、GET/POST数据等。调用视图函数参数是两个request 路径参数如果在路由规则里定义了参数的话。视图函数执行完后返回HttpResponse或者JsonResponse。Django处理这个响应对象转换成HTTP格式发回给浏览器。这个流程里最容易理解错的一点是Django是第一个匹配即停不是把所有匹配的路由都试一遍也不是最精确的匹配优先。所以urlpatterns里的顺序很重要一旦把通配符或者正则写得太靠前就可能把后面的精确路由拦截掉。后面我在踩坑部分会专门讲这个。1.2 视图处理请求之后必须返回什么视图函数FBVFunction-Based View的定义方式极其简单就是一个普通Python函数from django.http import HttpResponse def course_list(request): courses [Python基础, Django实战, 数据结构] html ul .join(fli{c}/li for c in courses) /ul return HttpResponse(html)这个函数接收一个request参数返回一个HttpResponse对象。你可能会问能不能不返回不行。Django要求视图必须返回一个HttpResponse对象或其子类实例否则会报ValueError: The view xxx didnt return an HttpResponse object.。这是新手频繁踩的坑之一写了print()或者只调用函数忘记return浏览器就会看到错误页面。HttpResponse可以传入字符串不一定是HTML可以设置status状态码、content_type内容类型。比如返回JSON数据时更推荐用JsonResponsefrom django.http import JsonResponse def course_api(request): data {code: 0, data: [Python, Django]} return JsonResponse(data)JsonResponse会自动设置Content-Type: application/json并且帮你做json.dumps序列化。还有一个细节默认情况下JsonResponse只支持字典作为最外层数据如果你想返回列表需要加上safeFalse参数比如JsonResponse([1,2,3], safeFalse)。2. path与re_path的匹配规则路径转换器和正则的取舍Django 2.0之后官方主推path()函数取代了原来1.x时代的url()正则写法。path()最大的改进是引入了路径转换器path converter让你可以用int:pk这种直观的语法来声明参数类型而不是写一长串正则表达式。但这并不意味着正则彻底没用了在一些复杂的匹配需求下re_path()依然是杀手锏。先看一段实际项目中最常见的配置from django.urls import path, re_path from . import views urlpatterns [ path(courses/, views.course_list), path(courses/int:pk/, views.course_detail), path(courses/slug:slug/, views.course_detail_by_slug), re_path(r^courses/(?Pyear[0-9]{4})/$, views.course_archive), ]这里能看出两个关键点path()用括号声明参数名和转换器类型re_path()完全用正则的命名分组(?Pnamepattern)来声明。当年我从Django 1.11迁移到2.0的时候最大的体感就是写路由从写正则变成了写类型心智负担轻了非常多。原来写r^articles/(?Ppk\d)/$现在直接int:pk代码可读性提升了一个档次。2.1 Django内置的五个路径转换器官方默认提供了五个转换器覆盖了绝大多数常见场景转换器匹配内容示例说明str非空字符串不含斜杠courses/str:name/默认转换器不加类型时就是这个int0或正整数courses/int:pk/匹配后自动转成Python intslug字母、数字、横杠、下划线posts/slug:slug/适合文章标题的URL别名uuidUUID格式orders/uuid:oid/自动转成Python的UUID对象path可以包含斜杠的字符串files/path:file_path/适合传文件路径或多级目录需要注意str不匹配斜杠这是很多人踩坑的地方。如果你希望URL中间的一段可以携带斜杠比如分类/子分类/文章名要用path转换器而不是str。2.2 自定义路径转换器当内置的满足不了需求有些场景内置转换器不够用比如你想匹配18到60之间的数字年龄参数或者匹配特定格式的日期YYYYMMDD。这时候可以自己写一个转换器类。转换器需要实现三个方法/属性regex属性用来匹配的正则字符串、to_python方法把URL字符串转成Python对象传给视图、to_url方法把Python对象反向解析成URL字符串。下面是一个匹配日期格式的转换器示例from django.urls import path, register_converter class FourDigitYearConverter: regex r[0-9]{4} def to_python(self, value): return int(value) def to_url(self, value): return f{value:04d} register_converter(FourDigitYearConverter, yyyy) urlpatterns [ path(archive/yyyy:year/, views.archive_by_year), ]写自定义转换器的意义不仅仅是为了少写几行正则更重要的是把URL字符串和Python对象之间的转换逻辑集中在一个地方。比如to_url方法在reverse()反向解析的时候会被调用保证你在模板里写{% url archive 2024 %}能正确拼出/archive/2024/的URL。如果你只写了regex但不实现to_url反向解析就会报错。2.3 re_path什么时候必须上虽然path()很香但有些匹配需求它确实做不了。典型的例子是数字范围匹配或者多段组合的复杂规则这类场景re_path()更合适。比方说你需要匹配所有/article/数字/的URL但要求数字必须是4位不够4位要重定向到404而不是匹配错误页面用int转换器也行但如果你只要4位长度re_path(r^article/(?Pyear[0-9]{4})/$, views.article_by_year)就更精确。还有一个常见需求匹配形如/page/1、/page/2这样的页码同时要排除/page/0正则(?Ppage0?[1-9][0-9]*)可以做到path转换器就无能为力了。我的建议是80%的情况用path()遇到正则需求先想想能不能自定义转换器实在不行再上re_path。不要一上来就把所有路由都用正则写那样项目后期维护起来真的头疼——正则是出了名的写的时候爽读的时候哭。3. include、命名空间与反向解析把路由组织得像样单文件的urlpatterns在项目很小的时候用着挺顺手但一旦你的项目里有四五个应用app把所有路由堆在一个文件里就是灾难。Django提供了include()函数让你按应用拆分路由每个app维护自己的urls.py根路由只管分诊。以一个教室管理系统为例假设系统里有课程管理、学生管理、教师管理三个模块每个模块都是一个独立的app。根urls.py这样配置from django.contrib import admin from django.urls import path, include urlpatterns [ path(admin/, admin.site.urls), path(courses/, include(courses.urls)), path(students/, include(students.urls)), path(teachers/, include(teachers.urls)), ]每个app的urls.py只写自己的路由规则。比如courses/urls.pyfrom django.urls import path from . import views app_name courses urlpatterns [ path(, views.course_list, namelist), path(int:pk/, views.course_detail, namedetail), ]这里的关键是第二行app_name courses。别小看这一行它给这个app的所有URL加上了一个命名空间防止不同app之间出现同名URL时冲突。比如students应用里可能也有一个list的name如果没有命名空间Django的反向解析reverse(list)就不知道到底返回哪个应用的URL了。3.1 为什么强烈建议用include拆分路由维护过单体大项目的同学应该有体会一个300行的urls.py文件看起来好像还行但当你想确认某个URL到底交到哪个视图处理时得在长列表里来回扫。如果按app拆分每个app的路由文件通常只有一二十行定位问题的时间能缩短一半以上。还有一个更深层的好处是应用可复用性。如果你把courses这个app完整地包含路由、视图、模板、静态文件哪天你在另一个新项目里也需要课程功能直接把整个app拷过去在根路由加一行include(courses.urls)就完事了。这比在全局urls.py里复制粘贴路由配置要优雅得多。include还有一个容易忽略的用法include((pattern_list, app_namespace), namespace...)这种元组形式可以同时指定两个命名空间。实际开发里我用的最多的还是app_name声明因为这已经能满足绝大多数需求。3.2 反向解析不要在任何地方硬编码URL很多初学者在模板里写链接时习惯直接写死a href/courses/3/第三节课/a这个写法的问题在于如果哪天你决定把所有课程URL的前缀从/courses/改成/course-list/你就得去所有模板、JavaScript文件、视图重定向代码里一个个搜/courses/来替换。这种低级错误在一个稍有规模的项目里就是灾难。Django的反向解析机制就是为了解决这个问题设计的。核心思想只有一个给每个URL规则起个名字在代码和模板里通过名字来引用而不是写死路径。路由文件里加一个name参数path(courses/int:pk/, views.course_detail, namedetail),然后你在任何地方都可以通过名字来获取URL# 视图里重定向 from django.shortcuts import redirect def go_detail(request, pk): return redirect(courses:detail, pkpk)!-- 模板里写链接 -- a href{% url courses:detail course.pk %}{{ course.name }}/a注意这里用的是courses:detail格式是命名空间:路由name。这个语法把命名空间的价值体现出来了即使两个app都有detail这个名字只要有命名空间隔开就不会混淆。至于视图代码里的reverse函数我个人习惯在重定向场景直接使用redirect配合URL名称因为redirect内部就调用了reverse少一层封装。如果需要在get_success_url或者类视图中返回值要注意reverse(courses:detail, args[pk])返回的是字符串而类视图的get_success_url()要求返回字符串所以是可以直接用的。如果在类属性里定义success_url reverse(...)因为类属性定义时reverse报错URLConf可能还没加载完要用reverse_lazy。这是CBV开发里非常经典的一个坑。3.3 reverse函数的参数传递方式对比reverse()支持两种传参方式效果等价但风格不同# 位置参数 args reverse(courses:detail, args[3]) # 关键字参数 kwargs reverse(courses:detail, kwargs{pk: 3})如果路由规则里有多个参数比如path(courses/int:year/int:month/, ...)用kwargs的可读性更好reverse(courses:archive, kwargs{year: 2024, month: 3})模板里的{% url %}标签也支持类似写法{% url courses:archive year2024 month3 %}这里有个细节要注意模板标签里year2024中的2024如果不加引号会被当作一个变量名去模板上下文中查找如果直接写数字Django的模板引擎能识别数字字面量。但如果你传的是字符串常量必须加引号{% url courses:archive year2024 %}。这种细节写错不报错但生成URL时会出现无法匹配的情况排查起来很费时间。4. 函数视图与类视图的选择从FBV到CBV的进阶之路Django支持两种视图写法函数视图FBV和类视图CBV。初学者先用FBV逻辑直接好理解。但项目复杂之后你会发现自己写的函数越来越多重复代码也越来越多——比如获取某个对象不存在就404这个逻辑几乎在每个详情页视图里都要写一遍。类视图尤其是通用视图就是来解决这种重复性的。先说结论小型项目、简单页面用FBV模块多、业务模式雷同的用CBV两者可以混用没有规定说一个项目只能选一种。我见过很多老项目里两种视图并存这完全没有问题。4.1 用教室管理系统演示FBV到CBV的演进假设你要做一个课程详情页功能是根据主键获取课程找不到就返回404找到就渲染详情模板。FBV版本是这样from django.shortcuts import render, get_object_or_404 from .models import Course def course_detail(request, pk): course get_object_or_404(Course, pkpk) return render(request, courses/detail.html, {course: course})这段代码已经很简洁了但你能想象如果系统有课程、学生、老师、教室、排课、考试等多种资源都要写详情页每个详情页都重复这一套获取对象或404 渲染模板的逻辑代码量会膨胀成什么样。这时候引入通用视图的DetailViewfrom django.views.generic import DetailView from .models import Course class CourseDetailView(DetailView): model Course template_name courses/detail.html这个类的功能几乎和上面的FBV代码完全等价。它自动根据URL中的pk或者slug从Course模型查询对象查询不到就返回404查到了就渲染模板。模板中的object变量就代表这个课程对象也可以用course因为Django会自动用模型名的小写作为上下文变量名。细品一下这两种写法的差别FBV关注怎么做一步一个步骤递进CBV关注是什么声明这个视图是显示一个详情页。表达方式的差异会导致思考方式的转变——用CBV的时候你更多是在组合已有的功能块而不是编写每个步骤。4.2 从View基类源码理解CBV的运行原理CBV看起来很神奇但底层其实不复杂。当你把CourseDetailView.as_view()写到urls.py里时as_view()返回的是一个函数叫view这个函数接收request参数然后实例化你的视图类调用dispatch()方法。dispatch()检查请求方法GET、POST、PUT等把请求分发给对应名称的小写方法get()、post()、delete()等。这就是为什么CBV里处理不同请求方法时直接声明不同方法名即可from django.views import View from django.shortcuts import render, redirect class CourseCreateView(View): def get(self, request): return render(request, courses/form.html) def post(self, request): # 处理表单提交逻辑 return redirect(courses:list)如果在post方法里处理完表单后不返回redirect刷新页面会重复提交表单。理解了dispatch机制后你甚至可以重写dispatch来做统一的权限检查from django.views import View from django.core.exceptions import PermissionDenied class StaffRequiredView(View): def dispatch(self, request, *args, **kwargs): if not request.user.is_staff: raise PermissionDenied(需要员工权限) return super().dispatch(request, *args, **kwargs)这种在分发请求之前统一做校验的模式是CBV最典型的优势逻辑复用成本极低写一次子类继承即可。4.3 ListView和CreateView这些内置通用视图到底省了多少事Django封装了最常用的一组通用视图ListView、DetailView、CreateView、UpdateView、DeleteView分别对应列表展示详情展示创建表单更新表单删除数据这五类最典型的Web操作。还是一个教室管理系统的例子。课程列表页需要分页、按条件过滤。如果用FBV你需要自己处理page参数、调用分页器、构造上下文用ListView只要这样from django.views.generic import ListView from .models import Course class CourseListView(ListView): model Course template_name courses/list.html paginate_by 10模板里通过page_obj对象就能拿到分页相关信息page_obj.has_previous、page_obj.previous_page_number这些判断分页按钮显隐的属性全都有。再看创建课程的场景——CreateView可以自动处理表单的GET展示和POST提交以及成功后的重定向from django.views.generic.edit import CreateView from django.urls import reverse_lazy from .models import Course class CourseCreateView(CreateView): model Course fields [name, teacher, start_date] template_name courses/form.html success_url reverse_lazy(courses:list)这里面有两个细节需要留意。第一fields字段列表决定表单里显示哪些字段第二success_url用reverse_lazy而不是reverse原因我在前面提到过——类属性在模块导入时就会被求值此时URLConf还没有完全加载用reverse会抛出ImproperlyConfigured异常而reverse_lazy是惰性求值的真正使用时才解析URL。使用通用视图的回报是巨大的一个完整的创建功能视图代码只有十行左右数据库写入、表单校验、错误回显、CSRF防护全都由框架内置逻辑处理。5. 开发环境里绕不开的坑静态文件404、路由顺序和CSRF这一节从热搜词里挑几个高频问题来写。vscode写img标签在django的static文件中显示不了这个热搜我太熟悉了——这几乎是所有Django新手必经的一关。还有django创建app流程、路由顺序问题都属于View与URL配置里的重灾区。5.1 static文件404的完整排查流程Django开发环境下的静态文件处理流程是这样的django.contrib.staticfiles这个app在DEBUGTrue时会接管URL以STATIC_URL开头的请求去STATICFILES_DIRS指定的目录和每个app的static/子目录里寻找文件。如果你的img标签显示不了按照下面这个顺序排查就对了第一步检查settings.pyDEBUG True INSTALLED_APPS [ # ... django.contrib.staticfiles, ] STATIC_URL static/ STATICFILES_DIRS [ BASE_DIR / static, ]第二步确认你的项目根目录下确实有一个static文件夹里面放着你要加载的图片。注意Django不会自动创建这个目录collectstatic命令也不会帮你创建你需要在文件系统里手动建好。第三步检查模板中的静态文件引用方式。最稳妥的是用{% static %}模板标签{% load static %} img src{% static images/logo.png %} altlogo这里有三个细节经常出问题{% load static %}必须在模板开头加载否则{% static %}标签不生效images/logo.png的路径是相对于你在STATICFILES_DIRS中配置的目录的如果你的图片放在static/images/logo.png引用路径就是images/logo.png不要在前面加/写了/static/反而是错的因为{% static %}会自动拼接STATIC_URL。第四个隐藏坑是如果项目里有app而你把图片放在app内的static/目录下Django也能找到。但如果有多个app和全局static目录里存在同名文件Django按照INSTALLED_APPS顺序优先查找先找到谁用谁。避免这个问题的最好做法是在app的static目录里再套一层app名的文件夹比如courses/static/courses/css/course.css。这和模板目录的命名规范是同一个道理。如果你按照上面的步骤检查完还是显示不出来大概率是开发服务器没有重启。Django的runserver有自动重载功能但偶尔修改settings.py之后它不一定每次都能及时捕获手动重启一次通常能解决80%的玄学问题。5.2 路由顺序引发的意外通配符和精确路由的冲突前面提到过Django的匹配规则是第一个匹配即停这意味着urlpatterns里规则的顺序直接影响匹配结果。看这个反面教材urlpatterns [ path(courses/slug:slug/, views.course_detail), path(courses/new/, views.course_create), ]你访问/courses/new/时看起来应该匹配第二个规则对吧实际上不对。Django从头开始匹配第一条规则courses/slug:slug/会先把new当作slug参数捕获匹配成功然后调用course_detail视图传入slugnew。如果你的模型里没有slug为new的记录get_object_or_404就会抛404。解决方案有两个按优先级排列精确路由放在动态路由前面urlpatterns [ path(courses/new/, views.course_create), path(courses/slug:slug/, views.course_detail), ]如果动态路由必须在前比如你想让/courses/new/和/courses/slug/走同一个入口那就在视图里手动写分支判断。这个坑在正则路由时代更容易踩。r^courses/(?Pslug\w)/$这条正则不仅匹配new还会匹配list、create等单词把不该拦截的URL全吞掉。所以我在写路由的时候有个习惯凡是静态的URL片段能写成字面量的就写字面量不要用转换器或正则去推导。从可读性角度来说看到path(courses/new/, ...)就知道这是个固定页面看到path(courses/slug:slug/, ...)就知道这是个动态页面。一眼扫过去路由表表达的信息非常明确。5.3 CSRF校验、405错误和请求方法误用用CBV处理POST请求时如果表单没有带CSRF令牌Django会返回403错误。解决这个问题的方式很简单在模板表单里加一行form methodpost {% csrf_token %} ... /form{% csrf_token %}模板标签会在表单里渲染出一个隐藏的csrfmiddlewaretoken字段。Django的CsrfViewMiddleware在收到POST请求时会比对cookie中的csrf_token和表单提交的token一致才放行。如果你在开发接口时使用Postman等工具调试POST接口需要在请求头里加上X-CSRFToken的值从cookie中获取否则也会403。再说一个新手经常遇到的405 Method Not Allowed。这个状态码是CBV特有的当你定义了CourseCreateView只有get和post方法但请求是DELETE时dispatch()找不到对应的处理方法就会返回405。有时候你明明定义了post方法但还报405可能是因为URL被匹配到了另一个视图——注意还是路由顺序的问题。我工作里见过这样一个场景用户提交表单后页面报405排查了半天发现是表单的action指向的URL被ListView拦截了。这类问题的定位思路其实很简单在浏览器开发者工具里看请求落到哪个URL然后回到urlpatterns里手动模拟一遍匹配过程通常10分钟内能锁定问题。5.4 获取请求参数的正确姿势GET、POST、body和META视图的request对象里藏着客户端传来的所有信息区分方式如下def search(request): # 查询字符串参数/courses/search/?qpython q request.GET.get(q, ) # 表单提交的数据 name request.POST.get(name, ) # 原始请求体通常是JSON import json body_data json.loads(request.body) if request.body else {} # 请求头信息 user_agent request.META.get(HTTP_USER_AGENT, ) client_ip request.META.get(REMOTE_ADDR, )这里有几个小知识点request.GET.get(q, )比request.GET[q]安全——前者拿不到时返回空字符串后者直接抛MultiValueDictKeyError页面500错误。POST数据同理。request.body是字节串如果客户端提交的是JSON需要先decode再json.loads但更推荐直接用json.loads(request.body)因为json.loads接受字节串。request.META保存了所有HTTP头部信息REMOTE_ADDR是客户端IP地址HTTP_USER_AGENT是浏览器标识。这个字典的键名规则要注意HTTP头部的名字转换成大写横杠换成下划线再加HTTP_前缀。比如请求头里的X-Real-IP在META里就是HTTP_X_REAL_IP。测试的时候忘了这茬拿着原始头部名字去取META取几次取不到人就开始怀疑人生了。如果请求既不是GET也不是POST而是PUT、PATCH、DELETE这些数据通常放在request.body里而不是request.POST。这是RESTful API开发中经常遇到的情况建议直接使用django-rest-framework来解析请求体它会帮你把各种格式JSON、表单统一处理好就不用手动处理了。6. 404/500页面定制与最后的经验总结开发阶段的Django调试页面很友好会展示详细的traceback和SQL查询但生产环境DEBUGFalse之后Django会返回简洁的404和500页面。这两个页面默认长得很简陋所以定制错误页面几乎是每个上线的Django项目的必备操作。在根URLConf中指定四个handler即可from django.conf.urls import handler404, handler500 urlpatterns [...] handler404 apps.core.views.page_not_found handler500 apps.core.views.server_error对应的视图函数需要接收一个request参数和对于404来说一个exception参数def page_not_found(request, exception): return render(request, core/404.html, status404) def server_error(request): return render(request, core/500.html, status500)模板文件放在全局模板目录下不用在视图里传递任何额外的上下文。关键点在于错误页面的视图渲染过程中不能有异常发生否则Django会直接返回一个空白的500页面。所以错误页模板里尽量少用复杂的模板标签尤其不要依赖上下文变量因为在这种场景下上下文可能根本不完整。最后再分享一个小技巧在开发环境的settings.py里如果设置了DEBUG False但没配置ALLOWED_HOSTS访问任何URL都会返回400错误Bad Request (400)。这个问题出现的频率极高——你把DEBUG改成False准备做部署前测试结果一访问全站400。解决办法是在ALLOWED_HOSTS里加上域名或IPALLOWED_HOSTS [127.0.0.1, localhost, your-domain.com]我个人做了这么多年的Django项目最大的体会是视图与路由是整个Django框架中最简单、也最容易自以为懂的部分。路径转换器、include拆分、反向解析、CBV的dispatch机制每一个概念单独看都不难但组合起来用的时候坑往往是跨概念叠加的。比如路由写错了导致视图收不到参数、reverse_lazy用错导致启动报错、静态文件路径不对导致页面样式全挂——这些问题单独出现都能解决同时出现的时候才考验你对整个请求链路的理解是否扎实。把这条链路吃透Django项目的日常开发里至少一半的报错你瞄一眼就知道是哪个环节出了问题。这也是我为什么在这篇文章里花了大量篇幅讲请求从URL到视图再到响应的完整旅程因为只有先把地图刻在脑子里后面所有的高级技巧才有落地的根基。