Django 笔记-2-源码理解-urls 篇

Django 笔记系列

  1. Django 笔记-1-从请求到响应

前言

注:本文使用 Django 版本:4.2.x

最近在处理公司接口端(基于 DRF)业务逻辑的时候想要通过 DRF 的 DefaultRouter 定制化一个类似 Swagger 的 API 页面展示,但是在编写路由解析方法的时候却犯了难。之前我能只理解了如何使用 Django urls 模块中的方法生成满足业务需求的路由,但是我还真没研究过怎么收集现有路由,并进行遍历和反向解析,于是便有了此次源码阅读。

本文以 Django 初始化和请求流程为主线,研究在这个过程中 Django 的 urls 模块做了哪些工作,并不是详细讲解 urls 模块下的全部方法。

流程梳理

本章以最常用的 python manage.py runserve 为例,梳理 Django 初始化和请求流程。这里为了阅读体验简化了步骤,想了解更完整的请求流程可搭配 Django 笔记-1-从请求到响应 进行阅读:

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
python manage.py runserver

django.core.management.commands.runserver.Command.handle

runserver.Command.run(**options)

runserver.Command.inner_run
↓ handler=runserver.Command.get_handler()
↓ ↓
↓ django.core.wsgi.get_wsgi_application() → django.core.handlers.wsgi.WSGIHandler()
django.core.servers.basehttp.run(..., wsgi_handler=handler, ...)

django.core.servers.basehttp.WSGIServer.serve_forever()
↓ ← request 请求进入
.....

django.core.servers.basehttp.WSGIRequestHandler.handle_one_request()

django.core.servers.basehttp.ServerHandler.run(self.server.get_app())

self.server.get_app()()

django.core.handlers.wsgi.WSGIHandler()()

django.core.handlers.wsgi.WSGIHandler.__call__

django.core.handlers.wsgi.WSGIHandler.get_response(request)

django.core.handlers.wsgi.WSGIHandler._middleware_chain(request)

django.core.handlers.wsgi.WSGIHandler._get_response

django.core.handlers.wsgi.WSGIHandler.resolve_request

django.urls.resolvers.URLResolver(settings.ROOT_URLCONF).resolve(request.path_info)
↓ return (callback, callback_args, callback_kwargs) from URLResolver.resolve(request.path_info)
...

response = callback(request, ...)

可以看到最后最关键的部分是调用了 django.urls.resolvers.URLResolver(settings.ROOT_URLCONF).resolve(request.path_info) 这样的一个方法,而这一个链式调用是由 django.core.handlers.wsgi.WSGIHandler.resolve_request 产生的,下面我们就以 resolve_request 方法为入口详细分析整个 urls 模块的调用链。

resolve_request

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
26
27
# django.core.handlers.base.BaseHandler(WSGIHandler 的父类)
def resolve_request(self, request):
# 一般来说 request 是没有 urlconf 的所以走的是 else
if hasattr(request, 'urlconf'):
urlconf = request.urlconf
set_urlconf(urlconf)
resolver = get_resolver(urlconf)
else:
resolver = get_resolver()
resolver_match = resolver.resolve(request.path_info)
request.resolver_match = resolver_match
return resolver_match

# django.urls.get_resolver
def get_resolver(urlconf=None):
if urlconf is None:
urlconf = settings.ROOT_URLCONF
return _get_cached_resolver(urlconf)

# django.urls._get_cached_resolver
@functools.lru_cache(maxsize=None)
def _get_cached_resolver(urlconf=None):
return URLResolver(RegexPattern(r'^/'), urlconf)

# 通过这三个方法的链式调用我们能够得知最终在 resolve_request 使用的 resolver 是
# URLResolver(RegexPattern(r'^/'), settings.ROOT_URLCONF)
# 从函数调用上我们还可以发现,Django 使用 functools.lru_cache 将整个 resolver 的结果缓存下来了

settings.ROOT_URLCONF

Django 文档对于 settings.ROOT_URLCONF 的定义是:

ROOT_URLCONF
默认:未定义

一个字符串,代表你的根 URLconf 的完整 Python 导入路径,例如 “mydjangoapps.urls”。可以通过在传入的 HttpRequest 对象上设置属性 urlconf 来覆盖每个请求。详情请参见 Django 如何处理一个请求。

一般情况下就是我们使用 django-admin startproject <projectname> 启动项目后在 <projectname> 目录下的 urls.py 模块,这里为了方便讲解我们模拟这样一个项目:

1
2
3
4
5
6
7
8
9
10
11
testapp\
apps.py
views.py
urls.py
testproject\
__init__.py
asgi.py
settings.py
urls.py
wsgi.py
manage.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
#testapp.views
from django.http.response import HttpResponse

def test(request):
return HttpResponse("test")


#testapp.urls
from .views import test
from django.urls import re_path

app_name = "testapp"
urlpatterns = [
re_path(r'^test-api/', test, name="testapi"),
]

#testproject.urls
from django.contrib import admin
from django.urls import path, re_path, include

urlpatterns = [
path('admin/', admin.site.urls),
re_path(r'^test2/', test, name="test2"),
re_path('test/', include("testapp.urls", namespace="test")),
]

path, re_path, include

通过观察 testproject.urls 不难看出在 Django 项目下注册路由主要是通过 django.urls 模块下的 path,re_path 和 include 三个方法,我们先观察一下这三个方法的定义:

1
2
3
4
5
6
7
8
9
10
11
# django.urls.conf.py
from functools import partial

def include(arg, namespace=None):
pass

def _path(route, view, kwargs=None, name=None, Pattern=None):
pass

path = partial(_path, Pattern=RoutePattern)
re_path = partial(_path, Pattern=RegexPattern)

将定义中使用 partial 的部分替换后可得

1
2
3
4
path = _path(..., Pattern=RoutePattern)
re_path = _path(..., Pattern=RegexPattern)

include(arg, namespace=None)

re_path(‘test/‘, include(“testapp.urls”, namespace=”testapp”))

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
# django.urls.conf
from importlib import import_module

# include("testapp.urls", namespace="test")
def include(arg, namespace=None):
# arg = "testapp.urls"
# namespace = "test"
app_name = None
if isinstance(arg, tuple):
# 如果 arg 是元组则解包成 路由模块 和 应用名称
try:
urlconf_module, app_name = arg
except ValueError:
if namespace:
raise ImproperlyConfigured(...)
raise ImproperlyConfigured(...)
else:
# 否则 路由模块 就是 arg
urlconf_module = arg

if isinstance(urlconf_module, str):
# 如果 urlconf_module 是字符串尝试导入
urlconf_module = import_module(urlconf_module)

# 从 路由模块 中获取 路由模式 和 应用名称
patterns = getattr(urlconf_module, "urlpatterns", urlconf_module)
app_name = getattr(urlconf_module, "app_name", app_name)

if namespace and not app_name:
raise ImproperlyConfigured(...)

# 如果没有 namespace 那么将 app_name 设置为 namespace
namespace = namespace or app_name

# [re_path(r'^test-api/', test, name="testapi")]
# 检查 路由模式 中的 匹配项 是否存在问题
if isinstance(patterns, (list, tuple)):
for url_pattern in patterns:
pattern = getattr(url_pattern, "pattern", None)
if isinstance(pattern, LocalePrefixPattern):
# 这里为什么对 LocalePrefixPattern 报错我不是很理解一下是 AI 给出的回答:
# 在 include 中不允许使用 i18n_patterns
# 是因为 Django 的国际化和本地化系统(i18n)的设计限制
# i18n_patterns 是用于在 URL 中添加语言前缀的便捷方法
# 但由于其特性,它只能在主 URL 配置中使用
# 这种限制是为了确保URL配置的一致性和可维护性。
# 如果允许在包含的URL配置中使用i18n_patterns,
# 可能会导致混乱和不一致的URL结构,从而增加了维护和调试的复杂性。
# 因此,为了遵循最佳实践并确保代码的清晰性,
# Django限制了i18n_patterns的使用范围,只允许在主URL配置中使用。
raise ImproperlyConfigured(...)

# 可以看到 include 方法最终是将传入的内容解析成了一个三元元组
# 分别是 路由模块、应用名称和命名空间
# 返回 (<testapp.urls>, "testapp", "test")
return (urlconf_module, app_name, namespace)
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
26
27
28
29
30
31
32
33
34
35
# django.urls.conf
# admin.site.urls 具体的对应关系可以去 django.contrib.admin.sites.AdminSite 查看
# path('admin/', admin.site.urls)
# _path('admin/', (self.get_urls(), "admin", self.name), Pattern=RoutePattern)

# re_path('test2/', test)
# _path('test2/', test, Pattern=RegexPattern)

# re_path('test/', (<testapp.urls>, "testapp", "test"))
# _path('test/', (<testapp.urls>, "testapp", "test"), Pattern=RegexPattern)
def _path(route, view, kwargs=None, name=None, Pattern=None):
from django.views import View

if kwargs is not None and not isinstance(kwargs, dict):
raise TypeError(...)
if isinstance(view, (list, tuple)):
# 如果 view 是数组或元组,使用 RegexPattern 实例化匹配规则并返回 URLResolver 解析器
pattern = Pattern(route, is_endpoint=False)
urlconf_module, app_name, namespace = view
return URLResolver(
pattern,
urlconf_module,
kwargs,
app_name=app_name,
namespace=namespace,
)
elif callable(view):
# 如果 view 是可调用对象,使用 RoutePattern 实例化匹配规则并返回 URLPattern 匹配器
pattern = Pattern(route, name=name, is_endpoint=True)
return URLPattern(pattern, view, kwargs, name)
elif isinstance(view, View):
view_cls_name = view.__class__.__name__
raise TypeError(...)
else:
raise TypeError(...)

RoutePattern 与 RegexPattern

RoutePattern 与 RegexPattern 最后都会被转换为正则匹配,只是 RoutePattern 在定义的时候可以使用特殊的语法定义参数变量,而 RegexPattern 则需要使用正则匹配去表达这些内容,例如 RoutePattern('foo/<int:pk>') 会被转换为 RegexPattern('^foo\\/(?P<pk>[0-9]+)')。感兴趣的可以看一下 django.urls.resolvers._route_to_regex 方法。

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
# django.urls.resolvers.py
class LocaleRegexDescriptor:
# LocaleRegexDescriptor 是一个描述器,可以查看 参考文章3 进行学习
# 这个描述器的功能还是比较简单的大概理解就是:
# 初始化时设置一个变量名称为 attr 的属性名称
# 将调用实例中名字和变量 attr 相同的属性复制给 pattern
# 将调用实例的 regx 属性设置为调用实例的 _compile(pattern) 的返回值
# 看文字一大串好像很难理解
# **其实就是把传给 RoutePattern 或 RegexPattern 的匹配字符串变成一个正则对象**
# 之所以搞这么多步骤主要是为了在调用 __get__ 方法的时候设置一些国际化相关的内容
def __init__(self, attr):
self.attr = attr

def __get__(self, instance, cls=None):
if instance is None:
return self
pattern = getattr(instance, self.attr)
if isinstance(pattern, str):
instance.__dict__["regex"] = instance._compile(pattern)
return instance.__dict__["regex"]
language_code = get_language()
if language_code not in instance._regex_dict:
instance._regex_dict[language_code] = instance._compile(str(pattern))
return instance._regex_dict[language_code]

class RoutePattern(CheckURLMixin):
regex = LocaleRegexDescriptor("_route")

def __init__(self, route, name=None, is_endpoint=False):
# 一般来说 route 就是调用 path 或 re_path 时传入的那个用于做路由匹配的字符串
# path('admin/', admin.site.urls) 中的 admin/
self._route = route
self._regex_dict = {}
self._is_endpoint = is_endpoint
self.name = name
self.converters = _route_to_regex(str(route), is_endpoint)[1]

def match(self, path):
# 在解析是被调用的匹配方法,很重要但是没什么难懂的地方
match = self.regex.search(path)
if match:
kwargs = match.groupdict()
for key, value in kwargs.items():
converter = self.converters[key]
try:
kwargs[key] = converter.to_python(value)
except ValueError:
return None
return path[match.end() :], (), kwargs
return None

def _compile(self, route):
return re.compile(_route_to_regex(route, self._is_endpoint)[0])

class RegexPattern(CheckURLMixin):
regex = LocaleRegexDescriptor("_regex")

def __init__(self, regex, name=None, is_endpoint=False):
# 同上,只是不叫 route 叫 regx
# re_path('test/', (<testapp.urls>, "testapp", "test")) 中的 test/
self._regex = regex
self._regex_dict = {}
self._is_endpoint = is_endpoint
self.name = name
self.converters = {}

def match(self, path):
# 在解析是被调用的匹配方法,很重要但是没什么难懂的地方
match = (
self.regex.fullmatch(path)
if self._is_endpoint and self.regex.pattern.endswith("$")
else self.regex.search(path)
)
if match:
kwargs = match.groupdict()
args = () if kwargs else match.groups()
kwargs = {k: v for k, v in kwargs.items() if v is not None}
return path[match.end() :], args, kwargs
return None

def _compile(self, regex):
try:
return re.compile(regex)
except re.error as e:
raise ImproperlyConfigured(...) from e

URLPattern 与 URLResolver

URLPattern 与 URLResolver 是不同模式路由匹配方案,URLPattern 用于定义简单路由基本上可以理解为一个萝卜一个坑,一个 URLPattern 只负责一个视图的匹配,而 URLResolver 则是通过命名空间和应用名称将一组路由(这一组路由中也可能只有一个路由)汇集到一起用于匹配。

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
# django.urls.resolvers.py
class ResolverMatch:
# 用于路由匹配解析结果的类,主要的方法是 __getitem__ 方法
# 最后会使用 __gititem__ 进行解包操作
def __init__(self, func, args, kwargs, url_name=None, app_names=None, namespaces=None, route=None, ...):
self.func = func
self.args = args
self.kwargs = kwargs
...

def __getitem__(self, index):
return (self.func, self.args, self.kwargs)[index]

class URLPattern:
def __init__(self, pattern, callback, default_args=None, name=None):
self.pattern = pattern
self.callback = callback # the view
self.default_args = default_args or {}
self.name = name
...

def resolve(self, path):
match = self.pattern.match(path)
if match:
new_path, args, captured_kwargs = match
kwargs = {**captured_kwargs, **self.default_args}
return ResolverMatch(
self.callback,
args,
kwargs,
self.pattern.name,
route=str(self.pattern),
captured_kwargs=captured_kwargs,
extra_kwargs=self.default_args,
)

class URLResolver:
def __init__(
self, pattern, urlconf_name, default_kwargs=None, app_name=None, namespace=None
):
self.pattern = pattern
self.urlconf_name = urlconf_name
self.callback = None
self.default_kwargs = default_kwargs or {}
self.namespace = namespace
self.app_name = app_name
self._reverse_dict = {}
self._namespace_dict = {}
self._app_dict = {}
self._callback_strs = set()
self._populated = False
self._local = Local()

def resolve(self, path):
# URLResolver.resolve 方法是一个比较绕的执行流程
# 感兴趣的话可以将前面得到的结果拿过来然后自己模拟输入一些路径来尝试匹配
# 例如 _path('test/', (<testapp.urls>, "testapp", "test"), Pattern=RegexPattern).resolve(<request_path>)
path = str(path)
tried = []
match = self.pattern.match(path)
if match:
new_path, args, kwargs = match
for pattern in self.url_patterns:
try:
sub_match = pattern.resolve(new_path)
except Resolver404 as e:
self._extend_tried(tried, pattern, e.args[0].get("tried"))
else:
if sub_match:
sub_match_dict = {**kwargs, **self.default_kwargs}
sub_match_dict.update(sub_match.kwargs)
sub_match_args = sub_match.args
if not sub_match_dict:
sub_match_args = args + sub_match.args
current_route = (
""
if isinstance(pattern, URLPattern)
else str(pattern.pattern)
)
self._extend_tried(tried, pattern, sub_match.tried)
return ResolverMatch(
sub_match.func,
sub_match_args,
sub_match_dict,
sub_match.url_name,
[self.app_name] + sub_match.app_names,
[self.namespace] + sub_match.namespaces,
self._join_route(current_route, sub_match.route),
tried,
captured_kwargs=sub_match.captured_kwargs,
extra_kwargs={
**self.default_kwargs,
**sub_match.extra_kwargs,
},
)
tried.append([pattern])
raise Resolver404({"tried": tried, "path": new_path})
raise Resolver404({"path": path})

调用出栈

伴随着 URLPattern 与 URLResolver 被理解,我们可以沿着目录开始一层一层的将调用结果出栈,最终返回到 resolve_request 的最后三行:

1
2
3
4
5
6
7
8
9
10
11
12
13
# django.core.handler.base.BaseHandler.resolve_request
resolver_match = resolver.resolve(request.path_info)
request.resolver_match = resolver_match
# 这里返回的就是一个 ResolverMatch 对象
return resolver_match

# django.core.handler.base.BaseHandler._get_response
# 这里通过解包调用 ResolverMatch 对象的 __getitem__ 方法
callback, callback_args, callback_kwargs = self.resolve_request(request)
...
# 最终经过一系列过程大概会执行一个类似这样的方法
# 其中 callback 就是根据 request 信息,通过 url 匹配获取到的视图方法
response = callback(request, ...)
1
2
3
4
5
6
7
8
9
path('admin/', admin.site.urls),
re_path(r'^test2/', test, name="test2"),
re_path('test/', include("testapp.urls", namespace="test")),
re_path(r'^test-api/', test, name="testapi"),
我们的 urlpatterns 类似上面这样,这里我们可以想象一下:假设有一个 /test2/ 请求进入会怎么进行:
1. 首先是走到 _get_cached_resolver(urlconf=None) 返回的 URLResolver(RegexPattern(r'^/'), urlconf)
2. URLResolver(RegexPattern(r'^/')) 将 /test2/ 中的 / 拿走,剩余 test2/ 继续匹配
3. 匹配到 test2/ 最终执行 test 方法
4. 将匹配结果返回,最终 django.core.handler.base.BaseHandler._get_response 调用 test 方法

参考

  1. Django 文档
  2. Django 4.2.x 源码
  3. Python 文档-描述器