Django REST framework 使用 MongoDB 作为数据库后端

想写个前后端分离的项目,需要在数据库中存储非常复杂的 JSON 格式(包含多层嵌套)的数据,又不想将 JSON 数据转为文本后以 Text 的格式存到 Mysql 数据库中。

因此想尝试下文档型数据库 MongoDB,其用来存放数据的文档结构,本身就是非常类似 JSON 对象的 BSON(Binary JSON)。

但 Django 的官方版本目前还未支持 NoSQL 数据库(参考 FAQ),MongoDB 官方文档建议借助 Djongo 组件完成到原生 Django ORM 的对接。
Djongo 实际上是一个 SQL 到 MongoDB 的翻译器。通过 Django 的 admin 应用可以向 MongoDB 中添加或修改文档,其他 Django 模块如 contribauthsession 等也可以在不做任何改动的情况下正常使用。

项目初始化

安装需要用到的 Python 模块,初始化项目:

1
2
3
4
$ pip install djongo djangorestframework
$ django-admin startproject mongo_test
$ cd mongo_test
$ django-admin startapp blogs

修改项目配置文件(mongo_test/settings.py),添加数据库配置:

1
2
3
4
5
6
7
8
...
DATABASES = {
'default': {
'ENGINE': 'djongo',
'NAME': 'mongo_test',
}
}
...

数据库迁移,创建管理员账户,运行 WEB 服务:

1
2
3
$ python manage.py migrate
$ python manage.py createsuperuser
$ python manage.py runserver 0.0.0.0:8000

访问 http://127.0.0.1:8000/admin ,进入 Django 管理员后台,各部分功能使用正常:
Django Admin

此时访问 MongoDB 数据库,可以查询到存入的数据:

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
// mongo shell
> show dbs
admin 0.000GB
apscheduler 0.000GB
config 0.000GB
local 0.000GB
mongo_test 0.000GB
> use mongo_test
switched to db mongo_test
> show collections;
__schema__
auth_group
auth_group_permissions
auth_permission
auth_user
auth_user_groups
auth_user_user_permissions
django_admin_log
django_content_type
django_migrations
django_session
> db.auth_user.find().pretty()
{
"_id" : ObjectId("5fc0a6a4e7b96c382fa9ccd8"),
"id" : 1,
"password" : "pbkdf2_sha256$180000$XL0v3lLCM1RW$rnw4qzoTUtwgc5EoKfB4yaaVEu1jTid8yuBVl0Y6P5Q=",
"last_login" : ISODate("2020-11-27T07:11:55.492Z"),
"is_superuser" : true,
"username" : "admin",
"first_name" : "",
"last_name" : "",
"email" : "",
"is_staff" : true,
"is_active" : true,
"date_joined" : ISODate("2020-11-27T07:11:31.955Z")
}

Django REST framework

在配置文件 mongo_test/settings.py 中的 INSTALLED_APPS 配置项下添加 rest_frameworkblogs 两个应用:

1
2
3
4
5
6
7
8
9
10
11
12
...
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'rest_framework',
'blogs'
]
...

数据库模型(Models)

编辑 blogs/models.py 文件,创建数据库模型,内容如下:

1
2
3
4
5
6
7
8
9
from djongo import models


class Blog(models.Model):
title = models.CharField(max_length=50)
content = models.TextField()

class Meta:
db_table = 'mongo_blog'

序列化器(Serializers)

创建 blogs/serializers.py 文件,内容如下:

1
2
3
4
5
6
7
8
from blogs.models import Blog
from rest_framework.serializers import ModelSerializer


class BlogSerializer(ModelSerializer):
class Meta:
model = Blog
fields = '__all__'

视图(Views)

编辑 blogs/views.py 文件,内容如下:

1
2
3
4
5
6
7
8
from blogs.models import Blog
from blogs.serializers import BlogSerializer
from rest_framework.viewsets import ModelViewSet


class BlogViewSet(ModelViewSet):
queryset = Blog.objects.all()
serializer_class = BlogSerializer

路由(URLs)

创建 blogs/urls.py 文件,内容如下:

1
2
3
4
5
6
7
8
9
10
from django.urls import include, path
from rest_framework import routers
from blogs import views

router = routers.DefaultRouter()
router.register(r'blog', views.BlogViewSet)

urlpatterns = [
path('', include(router.urls))
]

根路由

编辑项目路由配置文件 mongo_test/urls.py,内容如下:

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

urlpatterns = [
path('admin/', admin.site.urls),
path('', include('blogs.urls')),
]

访问 http://127.0.0.1/blog ,利用 POST 方法新增数据以测试 REST API 运行效果:
REST API

结果爆出 TypeError 错误(int() argument must be a string, a bytes-like object or a number, not 'ObjectId'):
TypeError

重新访问 http://127.0.0.1:8000/blog ,发现新增的数据已添加到数据库中,只是 id 项为 null

1
2
3
4
5
6
7
[
{
"id": null,
"title": "Blog",
"content": "This is a TEST Blog"
}
]

导致基于 REST API 的 CRUD 操作都是不能正常执行的。

ObjectId

实际上按照上述方式存入数据库的数据是以下格式:

1
2
3
4
5
6
7
// mongo shell
> db.mongo_blog.findOne()
{
"_id" : ObjectId("5fc0ae2ea7795c8c4ddae815"),
"title" : "Blog",
"content" : "This is a TEST Blog"
}

修改数据库模型(blogs/models.py),令其包含 _id 字段:

1
2
3
4
5
6
7
8
9
10
from djongo import models


class Blog(models.Model):
_id = models.ObjectIdField()
title = models.CharField(max_length=50)
content = models.TextField()

class Meta:
db_table = 'mongo_blog'

刷新 http://127.0.0.1:8000/blog 页面,此时数据显示正常,也可以通过 POST 方法正常添加数据(_id 项留空,会自动生成):
POST

POST 结果

Retrieve

上述实现仍有部分问题,实际上只有新值数据(Create)和获取数据列表(List)能够正常运行。而 CRUD 中的 Retrieve、Update、Delete 都会报出 404 错误。即无法通过 _id 获取对应的数据对象。

比如访问 http://127.0.0.1:8000/blog/5fc0b18e60870125f0ed846d/
Retrieve

原因是 MongoDB 中的 _idOjbectId 类型,与 Django REST framework 用于检索的 _id 类型不一致,导致无法通过 _id 找到对应的对象。需要在中间做一步转换工作(将字符串形式的 _id 转换为 ObjectId 形式)。

1
2
3
4
5
// mongo shell
> db.mongo_blog.find({"_id": "5fc0b18e60870125f0ed846d"})
>
> db.mongo_blog.find({"_id": ObjectId("5fc0b18e60870125f0ed846d")})
{ "_id" : ObjectId("5fc0b18e60870125f0ed846d"), "title" : "Blog2", "content" : "This is another Blog" }

查看 ModelViewSet 源代码

通过查看 ModelViewSet 的源代码,发现后台对 Retrieve 操作的响应逻辑是由mixinx.RetrieveModelMixin 类实现的,其中获取某个特定对象的函数是 self.get_object()

1
2
3
4
5
6
7
8
class RetrieveModelMixin:
"""
Retrieve a model instance.
"""
def retrieve(self, request, *args, **kwargs):
instance = self.get_object()
serializer = self.get_serializer(instance)
return Response(serializer.data)

进一步查找,发现 get_object() 函数是在 generics.GenericAPIVie 类中实现的,其代码为:

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
class GenericAPIView(views.APIView):
def get_object(self):
"""
Returns the object the view is displaying.

You may want to override this if you need to provide non-standard
queryset lookups. Eg if objects are referenced using multiple
keyword arguments in the url conf.
"""
queryset = self.filter_queryset(self.get_queryset())

# Perform the lookup filtering.
lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field

assert lookup_url_kwarg in self.kwargs, (
'Expected view %s to be called with a URL keyword argument '
'named "%s". Fix your URL conf, or set the `.lookup_field` '
'attribute on the view correctly.' %
(self.__class__.__name__, lookup_url_kwarg)
)

filter_kwargs = {self.lookup_field: self.kwargs[lookup_url_kwarg]}
obj = get_object_or_404(queryset, **filter_kwargs)

# May raise a permission denied
self.check_object_permissions(self.request, obj)

return obj

其中最关键的两句为:

1
2
filter_kwargs = {self.lookup_field: self.kwargs[lookup_url_kwarg]}
obj = get_object_or_404(queryset, **filter_kwargs)

{self.lookup_field: self.kwargs[lookup_url_kwarg]} 决定了最终 MongoDB 会以怎样的方式和条件检索某个对象。

实现自己的 ModelViewSet

综上,为了让 CURD 操作中的 URD 能够通过 _id(ObjectId)检索获取特定对象,可以实现自己的 ModelViewSet 类,重写 get_object() 方法。

新建 blogs/mongo_viewset.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
26
27
28
29
from bson import ObjectId
from django.shortcuts import get_object_or_404
from rest_framework.viewsets import ModelViewSet


class MongoModelViewSet(ModelViewSet):
def get_object(self):
queryset = self.filter_queryset(self.get_queryset())

# Perform the lookup filtering.
lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field

assert lookup_url_kwarg in self.kwargs, (
'Expected view %s to be called with a URL keyword argument '
'named "%s". Fix your URL conf, or set the `.lookup_field` '
'attribute on the view correctly.' %
(self.__class__.__name__, lookup_url_kwarg)
)

if self.lookup_field == '_id':
filter_kwargs = {self.lookup_field: ObjectId(self.kwargs[self.lookup_field])}
else:
filter_kwargs = {self.lookup_field: self.kwargs[self.lookup_url_kwarg]}
obj = get_object_or_404(queryset, **filter_kwargs)

# May raise a permission denied
self.check_object_permissions(self.request, obj)

return obj

最主要的改动即:

1
2
3
4
if self.lookup_field == '_id':
filter_kwargs = {self.lookup_field: ObjectId(self.kwargs[self.lookup_field])}
else:
filter_kwargs = {self.lookup_field: self.kwargs[self.lookup_url_kwarg]}

视图代码 blogs/views.py 改为如下版本:

1
2
3
4
5
6
7
8
9
from blogs.models import Blog
from blogs.serializers import BlogSerializer
from blogs.mongo_viewset import MongoModelViewSet


class BlogViewSet(MongoModelViewSet):
queryset = Blog.objects.all()
serializer_class = BlogSerializer
lookup_field = '_id'

此时访问 http://172.20.23.34:8000/blog/5fc0b18e60870125f0ed846d/ 即可正常显示,即能够通过 _id(ObjectId)获取对应的数据对象。
Retrieve

由此 CRUD 操作全部可以正常支持。