Django学习2
Setup
我们为了保留第一部分代码,因此新开一个文件夹,叫storefront2, 并在里面创建虚拟环境。
创建好以后,VSCode可能不会把该虚拟环境当做默认虚拟环境,因此我们需要设置一下:
command+p 并在搜索栏敲> 呼出命令面板. 选择 python: select Interpreter选项,点击输入解释器路径,并输入当前文件夹下的虚拟环境的路径(通过pipenv shell可查看),就设置完成了,如下图所示
Building. RESTful APIs with Django REST Framework
What are RESTful APIs
之前我们看到的数据只有Admin才有权限查看。现在我们要写一些接口,能让我们以非Admin的角色来获取数据库中的信息。这些接口就是API。打一个不恰当的比方,API就好像是电视遥控板中的一个按键。
RESTful API则是对API提出了一下几点要求
- Fast
- Scalable
- Reliable
- Easy to understand
- Easy to change
首先,我们要了解一些基本概念:
Resources
RESTful API 中的 Resources 概念就好像是我们在application中创建的对象一样。浏览器能通过 URL(Uniform Resource Locator) 访问到 Resources,URL就好像是这些资源的地址。
比如说,我想访问 products 数据,那么,我们的URL可以是这样的:localhost:9000/products
或者,products中的具体数据,比如localhost:9000/products/1
再者,Resources中也可能有Resources,比如说一个特定的产品有很多评测,那么url可以是这样的:
1 | localhost:9000/products/1/reviews |
Resource Representations
当client用URL访问Resources的时候,后端就需要向客户回传这些Resources
一般,Resources 传给前端的格式是:HTML, XML, JSON。这些我们都比较熟悉了。
HTTP Methods
HTTP主要有几种请求方法:
- GET: 用来获取数据
- POST: 创建数据,比如
/products
- PUT: 更新数据,如果要修改所有的对象,就要使用PUT,
/products
- PATCH: 更新一部分数据, 比如只修改第一个对象,那么就要使用PATCH,
/products/1
- DELETE:删除数据, 比如
/products/1
Installing Django REST Framework
我们需要用这行代码下载Django REST Framework, 这样写起API就方便很多,
pipenv install djangorestframework
下载完成后,我们要在APP中注册:
1 | INSTALLED_APPS = [ |
Creating API Views
现在我们来创建 127.0.0.1:9000/store/products
对应的API
首先要明白,Django有自己的HttpRequest和HttpResponse模块,同时,Django Rest Framework也有自己的Request和Response模块。在这个项目中我们主要使用后两者,因为更方便,实现的功能也更强
其次,我们要把Django框架理解成层级访问,最高层就是我们创建的storefront,然后是storefront下面的各个注册的app,最后是app中的各种Object(Resources)。我们通过URL访问的时候,也是要”层级访问“
127.0.0.1:9000/
访问的就是最顶层 storefront ,如果我们要访问storefront中的store,需要在storefront里面的urls.py
注册store/
,如下所示:
1 | urlpatterns = [ |
再者,我们如果要访问store中的products对象,我们就需要在store里面的urls.py
中注册products/
.
1 | from django.urls import path |
最后,我们在view中编写api,来处理client通过URL这个“地址”发送来的请求:在这里,我使用了rest_framework中的api_view
这个修饰器,当api被@api_view
修饰后,里面的request就会自动替换成rest_framework 中更简介、强大的request对象。
然后,我们用 rest_framework 中的 Response
来替换 django.http 中的HttpResponse
,这样,返回的就是一个 Brosable API对象,非常简洁美观,一目了然:
1 | from django.shortcuts import render |
如果使用Django的HttpResponse,那么返回的仅仅是一个”OK”,但如果用的是rest_framework 的Response,就会显示我们的Http method, URL,以及Response的报文。同时,也可以以切换为json格式,查看浏览器真正会显示的画面,也就是”OK”
product details
接下来,我们再来创建一个API:product detail,用来访问每个Product特定的信息
首先我们编写 view: 功能很简单,从client发来的URL中找到参数id,然后返回这个id的值
1 |
|
然后,我们在urls.py中注册路由:
1 | urlpatterns = [ |
注意,因为我们访问的是每个product的编号,因此只能是整数类型,为了防止出现类似于/store/products/1/
的非法请求出现,我们在注册路由的时候,要给参数id添加一个限制条件:<int:id>
Creating Serializers
我们要将Object转换为浏览器可以处理的JSON格式的信息,首先我们需要 Serializer(序列化工具) 将这些对象信息 转换成字典,然后使用 REST Framework中的JSONRenderer模块将字典转换成JSON。
现在,我们就来学习如何创建一个Serializer,它的样子和models很像,只不过model负责的是和数据库对接,而serializer负责的是和client对接。然而,数据库中的某些信息我们是不希望用户去了解的,因此在serializer
中一般不会提供产品的所有信息
首先我们要在store中创建serializers.py
,需要用到rest_framework 中的 serializers
模块.在下面这个网站中,有serializers
提供的数据类型:
https://www.django-rest-framework.org/api-guide/fields/
这里,我们只想用户开放了三个字段信息:产品id,产品名称以及产品单价。因此可以这么写:
1 | from rest_framework import serializers |
Serializing Objects
在这个view中,我们要根据url中的id,从数据库中取出对象;然后,把这个对象交给ProductSerializers
去序列化,最后,把序列化后的数据传给Response,它会将其转换为JSON格式的文档回传给client
1 | from .models import Product |
我们看到unit_price
是字符串类型的,我们需要设置一下将其变为数字类型:在setting.py中加入如下设置:
1 | REST_FRAMEWORK = { |
现在,参数如果正确的话,是能从数据库中获取信息的,但是,如果参数是0呢?Django会报错,而不是返回一个显示错误信息的JSON文档。对此,我们有两种解决方法:
- 用rest_framework中的
status
模块
1 | from rest_framework import status |
- 用
Django.shortcut
中的get_object_or_404
方法
1 |
|
然后,我们再注册一个store/products/
路由,显示所有products信息。因此,我们可以用get_list_or_404
,它是用来获取数据库中所有对象的方法,注意,当queryset中有很多元素的时候,需要设置 many=True
,这样,serializer就会遍历queryset来执行操作:
1 |
|
Creating Custom Serializer Fields
首先,我们要明白API Model是不等于Data Model的,后者是应用实现的一个部分,而前者是要给外界展示的一个部分。因此,展示的部分可以在model本身的基础上做一个计算,比如说,我们可以再Serializer里给product增加一个税后的价钱:
我们这里创建了一个计算税后价格的函数calculate_tax
, 即把原价乘以1.1后返回(这里,由于1.1是浮点数,需要用Decimal(1.1)
包裹一下)。然后, 再用SerializerMethodField
客制化,将method_name
参数设定为我们自己写的函数的名称:
1 | class ProductSerializer(serializers.Serializer): |
注意了,这里我将
unit_price
改成了price
,但是model中并没有这个字段,因此直接查询会报错。我们需要设定source
参数为原来的字段
Serializing Relationships
使用serializers内置方法
如果我想在Serilizer中显示一对一关系、一对多关系。比如,我想在ProductSerializer中显示产品属于的集合类型,我可以使用 PrimaryKeyRelatedField
这个方法。
1 | from store.models import Product, Collection |
有时候,系统会发生报错,这是因为检查机制出了点问题,可以通过重启解决
但是,显示数字显然是不够的,我们想要显示collection的名称信息,因此我们可以用StringRelatedField
方法:
1 | class ProductSerializer(serializers.Serializer): |
但这样虽然能显示collection字符串,但是对每个product都会额外多出一个关于Collection的查询,导致性能异常低下。为了解决这个bug,我们需要在view中修改一下:将原来个get_list_or_404
改为Product.objects.select_related('collection').all()
1 |
|
使用嵌套的Serializer对象:
除了上面这种方法,我们还可以使用嵌套对象的方法,能展示的数据更多:
1 | class CollectionSerializer(serializers.Serializer): |
Model Serializers
我们发现,现在创建的Serializers和Models是非常相似的。为了节省代码,增加代码的可维护性,我们可以使用ModelSerializer
这个类:
使用了这个类之后,我们需要在里面创建Meta class,在 Meta中,我们要确定model的类型,并确定要放到ModelSerializer的字段, 即设置fields数组
如果我们想覆盖的话,我们可以自己写要呈现的内容。比如说collection
,如果我不采用嵌套对象的方法,ModelSerializer会默认使用外键的值。
此外还可以自己加入model中没有的值,比如price_with_tax
,这是我们自己写的一个字段,也可以加到fields中去
1 | class CollectionSerializer(serializers.ModelSerializer): |
Deserializing Objects
有了从server到client的对对象的序列化,那么就有对应从client到server的反序列化。比如说我想创建一个新的product,就要求sever反序列化得到client传入的关于新产品的信息,将它们变成一个对象。
我们知道,GET 方法是用来请求数据的,POST方法是用来更新数据的,我们现在在product_list
中实现一下两种方式的不同逻辑。
首先,在@api_view
中要列出这个api能处理的HTTP METHOD的类型.这里是['GET','POST']
.
然后,我们要设计if-else
来处理不同的请求。这里,如果是GET,就返回product列表,如果是POST我们要验证数据,并将合法的数据插入数据库,这里我们先不做验证,并直接返回OK作为测试
1 |
|
最后,我们把 http://127.0.0.1:9000/store/products/
拉到最后,然后随便输入一个JSON格式的文件,提交后我们就会看到 RESPONSE报文,其HTTP method为POST。
Data Validation
现在我们来学习数据验证相关的问题。
if-else block
这个逻辑是这样的,首先,serializer需要调用is_valid()
函数,如果通过,那么对就可以对validated_data进行保存等操作;如果不通过,我们这里就返回serializer.errors
,并附上状态码400
1 | elif request.method == 'POST': |
测试结果如下图所示:
raise_exception=True
我们可以用一种更简单的方法来取代if-else逻辑:
1 | elif request.method == 'POST': |
效果是一样的
现在,如果输入正确的信息,我们想将其在终端打印出来:(注意,这里要考虑好collection的类型,如果是对象的话,collection的值应该是一个字典。这里我使用的是默认的 PrimaryKeyRelatedField
,因此只输入了一个INT值
1 | { |
我们发现,在终端打印出来的serializer.validated_data
是一个OrderedDict
1 | OrderedDict([('title', 'a'), ('unit_price', Decimal('1.00')), ('collection', <Collection: collection1>)]) |
rest_framework 只提供给我们数据类型、是否为空这类简单的数据验证,如果我们要设计更加复杂的验证方法,需要在serializer.py中自己实现函数如:
1 | def validate(self, data): |
Saving Objects
我们可以用 ModelSerializer
中的save
方法将浏览器发送过来的数据存入数据库
由于我们在创建的时候,inventory字段设置了最小值为1的验证器,因此我们需要把这些必填的值也加入到serializer中去:
1 | class ProductSerializer(serializers.ModelSerializer): |
我们创建的产品如下:
1 | { |
提交后,Django就会保存了,非常方便
Updating Objects
想要更新数据库中的信息,需要使用 PUT 或者PATCH 方法, 且需要对一个特定的product进行修改。因此,我们把这个逻辑封装在product_detail
这个api当中。
首先,我们还是要确定这个API负责的HTTP Method类型,这里,为了方便我们就只用PUT类型来更新数据
然后做一个方法判断,在PUT方法中,由于我们要对一个特定的数据进行修改,因此我们往ProductSerializer
中传入的,不只是client递交过来的更新信息,还要这个目标对象的一个实例。因此,我们在if-else block之前就把product提取出来,然后传给ProductSerializer
,返回一个更新后的product
接着就是对product进行数据验证,返回一个保存后的信息并保存
1 |
|
- 递交前:
- 递交后
我们发现,id=2的产品已经更新了。title前面多了个’+’
Deleting Objects
如果我们在product_detail
中添加DELETE Method: @api_view(['GET', 'PUT','DELETE'])
这时候,回到client,就会发现这里多出来一个DELETE按钮。这就是Brosable API带来的好处——方便简洁
如果我们只写删除的逻辑,是会报错的,因为product是orderItem的外键,是保护关系。也就是说,如果不删除orderitem,就不能删除它引用的product。
因此我们要这么处理逻辑,如果引用该产品的orderitem数量(用product.orderitem_set
表示)大于0,我们就不能删除,并返回状态码 405,代表该方法对此情况不适用。如果等于0,那么放心删除,返回状态码204
1 | elif request.method == 'DELETE': |
这样,如果起了冲突的话,就会收到这样的返回报文:
如果该product没有被引用过,就可以安全删除:
注意点:
在代码中,反向引用计算该产品的orderitem 可以用product.orderitem_set
来表示,但是这种表示不容易记住。我们可以通过修改Orderitem
模型,在product字段中添加related_name
属性来提高代码的可读性:
1 | class OrderItem(models.Model): |
1 | elif request.method == 'DELETE': |
Exercise Building the Collections API
1 |
|
1 | urlpatterns = [ |
Advanced API Concepts
在这一章我们将学习更多技术,帮助我们更快的编写API
Class-based Views
之前我们是单独创建View,即一个函数为单位进行对模型的操作。但是,如果是要处理很多HTTP Method的话,就会用到很多if-else blocks
这会让代码看起来非常杂乱。因此我们现在来学习 class-basesd Views
首先,要引入APIView
, 然后,在这个类里面,我们可以定义各种方法的逻辑,每个方法写一个单独的函数。比如def get
,def post
. 函数内部的逻辑和之前if-else代码块内的逻辑是一致的。这样一来,我们就可以规避掉if-else的这种判断逻辑,让代码看起来更加整洁。
1 | from rest_framework.views import APIView |
同理,我们也可以重写ProductDetail,但是注意了,因为要获取特定的product,所以需要在函数中再加一个参数:id
1 | class ProductDetail(APIView): |
写完class
在url中还需要作相应修改:
1 | # URLConf |
Mixins
我们发现,对于Collection和Product,它们的view方法基本上是一模一样的。比如 product_list 和 collection_list的功能基本一样,就是从数据库中获取所有对象并返回;又比如 product_detail 和 collection_detail,都是访问特定的对象的具体信息。那么如此相同的逻辑,是否有一种方法可以将它们封装起来,我们就可以减少很多不必要的代码了。由此,我们来学习 Mixins(混合类)的概念。所谓混合类,顾名思义就是将很多操作都封装在一个类里面
首先,我们从 mixins的源码开始看起:混合类都放在rest_framework.mixins
中
1 | from rest_framework.mixins import ListModelMixin,CreateModelMixin |
比如说,这个ListModelMixin,其源码如下所示,我们看到这个类中只有一个list函数。函数的逻辑和我们自己写的逻辑非常相似:首先,从数据库中创建一个queryset, 然后进行分页等操作,再送到Serializer中去序列化,并返回序列化后的数据。
1 | class ListModelMixin: |
又比如说 CreateModelMixin: 里面的create函数逻辑和我们def put的逻辑一样。
首先把数据传入serializer创建序列化对象,然后进行数据验证,再进行保存操作,如果成功,最后返回HTTP 201
1 | class CreateModelMixin: |
此外,还有几种 mixin:
mixins | 作用 | 对应http的请求方法 |
---|---|---|
Mixins.ListModelMixin | 定义list方法,返回一个queryset列表 | GET |
Mixins.CreateModelMixin | 定义create方法,创建一个实例 | POST |
Mixins.RetrieveModelMixin | 定义retrieve方法,返回一个具体的实例 | GET |
Mixins.UpdateModelMixin | 定义update方法,对某个实例进行更新 | PUT/PATCH |
Mixins.DestroyModelMixin | 定义delete方法,删除某个实例 | DELETE |
在官网上我们能找到更多信息
Generic Views
在了解了mixins之后,我们需要再上一层,去了解Generic Views,也就是封装了Mixins的视图类。放在rest_framework.generics
1 | from rest_framework.generics import ListCreateAPIView |
比如说,我们来看ListCreateAPIView
这个类。它里面封装了ListModelMixin
和CreateModelMixin
这两个混合类,由此,我们可以用它来获取一个model中的所有对象信息,还可以创建新的对象。
此外,在这个类里面有 两个句柄函数(handler method):get和post,get函数调用了从ListModelMixin
继承下来的list方法;而post函数调用了从CreateModelMixin
继承下来的create
方法
1 | class ListCreateAPIView(mixins.ListModelMixin, |
此外,在 官网 上还有更多的Generic Views,比如ListAPIView
,就仅仅提供get方法,而且是只读的;RetrieveUpdateDestroyAPIView
就提供 get,put,patch和delete四种方法。
那么,对于我们刚刚创建的 Class-based View ,我们就可以让它继承ListCreateAPIView
类:由于里面已经封装了get和post函数。于是,我们只需要调用get_queryset
和get_serializer_class
这两个方法来获取queryset和serializer这两个对象就可以了。此外,为了让serializer获取到request的报文(这样就可以用其 去序列化、创建新对象),我们需要使用get_serializer_context
方法,传入一个字典{'request': self.request}
1 | class ProductList(ListCreateAPIView): |
我们甚至还能让这个类变得更简单。直接设置queryset
和serializer_class
这两个类
1 | class ProductList(ListCreateAPIView): |
使用Generic View,不仅在保持原功能的情况下简化自己的代码,还会自己创建一张表单,方便我们创建新的product,我们再也不用苦逼的手写JSON了
同样的,我们可以为Collection 创建 Generic Views. 如下所示:
这里,我们新加了一个字段products_count
,用来统计有多少products属于该collection
1 | class CollectionList(ListCreateAPIView): |
但是,当创建新的collection的时候,我希望products_count是一个只读的值,否则就乱了套了。因此,我们在CollectionSerializer
里面,需要把products_count`设置为已读
1 | class CollectionSerializer(serializers.ModelSerializer): |
结果如下所示,我们可以看到collection中的products_count
字段,但是要新建collection的话我们只能填写一个title字段:
Customizing Generic Views
事实上我们可以客制化Generic Views。
对于ProductDetail这个View,我们需要用到 PUT、GET、DELETE这三个函数。其中,PUT和GET都是可以交给封装好的函数去执行的。但对于DELETE方法,我们写了自己的逻辑进去,就是要先做一个判断。
因此,我们不能用Generic View提供给我们的DELETE方法,需要自己重写DELETE,方法也很简单,就保留def delete
即可。
修改完以后还没结束,运行会报错:
为了解决这个bug,我们可以修改url,把参数从id改为pk: path('products/<int:pk>/', views.ProductDetail.as_view())
;或者在 ProductDetail
里面设置 lookup_field
属性为id:lookup_field=id
,这里我们选择前者
如果选择前者,那么在delete
方法中,需要把参数id也改成pk,否则会导致参数不识别。
1 | class ProductDetail(RetrieveUpdateDestroyAPIView): |
现在,我们把CollectionDetail也修改一下:
1 | class CollectionDetail(RetrieveUpdateDestroyAPIView): |
ViewSets
我们来关注Product和Collection,这两个model都有两个Generic View——一个List用来取得所有信息,一个Detail来获得单独信息。在每个View里面,都需要定义queryset和serializer_class这两个属性,比较重复。因此,我们可以引入ViewSets(视图集),顾名思义,他将很多view放在一个集合类里面,这个类提供了很多内置Minixs。这样,只需要确定一套queryset和serializer_class就能完成多个view的任务了。
我们导入
然后来看看ModelViewSet的源码:from rest_framework.viewsets import ModelViewSet
1 | class ModelViewSet(mixins.CreateModelMixin, |
我们看到,ModelViewSet
里面继承了很多Mixins,提供了很多http 方法,是一个集大成者
由此,我们可以让ProductViewSet
和CollectionViewSet
继承 ModelViewSet
,如下所示。这样就把两个View集合为一个ViewSet了。
1 | class ProductViewSet(ModelViewSet): |
我们还要注意,对于ProductList,CollectionList这些View,是不能提供DELETE函数的,否则会出现删库跑路的情况。我们想要的是,给每一个特定的对象(ProductDetail)提供删除服务。
因此,我们不应该重写delete方法(这是对Products全适用的删除函数),而要重写destroy方法,它是用来删除一个单独的实例的。
在修改过程中,我们需要修改如下逻辑,因为在destroy方法中,已经有获取实例的逻辑了,我们不需要再访问一次数据库去获取product,因此,我们可以反向思考:查找OrderItem中的实例是否与该product相关联。
1 | if product.orderitems.count() > 0: |
最终修改后如下所示:
1 | class ProductViewSet(ModelViewSet): |
修改完成后,程序是无法运行的,因为url并没有修改。那么,怎么让url定位到我们编写的ViewSet呢?我们接下来学习Routers路由的写法
Routers
当我们使用ViewSet来集成View,就不用显式使用urlpatterns来注册url了。
SimpleRouter
首先,我们导入DRF中的 routers 中的SimpleRouter类
然后,我们创建一个SimpleRouter实例,并将两个ViewSet注册进去
打印一下注册后router.urls支持的格式:发现router已经帮我们自动注册了四条 URLPattern
1 | [ <URLPattern '^products/$' [name='product-list']>, |
最后,我们让 urlpatterns 等于 router.urls
也就是上面个数组
1 | from rest_framework.routers import SimpleRouter,DefaultRouter |
DefaultRouter
如果我们使用的是 DefaultRouter 类,那么相比SimpleRouter会多出两个功能:
首先,如果我们访问 http://127.0.0.1:9000/store/
就会出现一个根目录,也就会展示出这个app下开放的api,如下图所示
其次,如果我们访问http://127.0.0.1:9000/store/products.json
,就会展示出所有products的JSON格式的文档信息:
Building the Reviews API
现在,我们要对product创建一个Review内容,即用户反馈。
首先,创建Review model
1 | class Review(models.Model): |
然后,创建相应的Serializer:
1 | class ReviewSerializer(serializers.ModelSerializer): |
接着,创建ViewSet
1 | class ReviewViewSet(ModelViewSet): |
最后,注册路由,但是我们发现,Product和Review是一对多的关系,因此我们需要在检索单个product的url http://localhost:9000/store/products/1/
后面,再加上对Review的检索。这就是我们接下来要学习的嵌套路由
Nested Routers
首先,我们需要下载实现嵌套路由的包: pipenv install drf-nested-routers
具体教程在https://github.com/alanjds/drf-nested-routers 可以找到:
我们要实现的功能如下,前两行是通过domain pk找到单独的domain
第三行是找到关于此domain的所有nameservers
第四行是通过nameserver pk来找到关于此domain的特定的nameserver
1 | /domain/ <- Domains list |
实现逻辑如下,首先,创建一个SimpleRouter实例,并注册domain,这都是前置操作
1 | # urls.py |
接下来,我们要使用到上面导入的routers模块中的NestedSimpleRouter
类,我们要传入三个参数:
- 第一个参数是parent router,也就是之前注册的关于domain的路由;
- 第二个参数是子路由的前缀,也就是
http://localhost:9000/store/products/1/reviews/1/
中的products
- 第三个参数是lookup查询参数,这个嵌套路由要先查询domain
也就是说,这三个参数我们就可以理解为 父路由、”父类名+s”、loopup=”父类名”
然后,我们在这个NestedSimpleRouter
中注册nameservers,和NameserverViewSet
相对应。
basename 参数是一种名字模式,它是可选的,设置了这个参数以后,可以为我们提供命名View的选项。这里我们选择domain-nameservers
, 那么就会自动生成两个name:domain-nameservers-list
,domain-nameservers-detail
。
最后,我们创建两个urlpattern
1 | domains_router = routers.NestedSimpleRouter(router, 'domains', lookup='domain') |
在我们这个项目中,可以这么写:
1 | router = routers.DefaultRouter() |
这里,我们设置的basename='product-reviews'
, 能提供的url模板如下
1 | [<URLPattern '^products/(?P<products_pk>[^/.]+)/reviews/$' [name='product-reviews-list']>, |
结果如下,我们可以在http://localhost:9000/store/products/<id:pk>/reviews/
中看到reviews信息,并且能创建新的review
但这个程序存在bug,因为url中,是先检索product,再在这个product里面创建review,所以我们在创建的时候根本不需要填写product字段,这个字段的值应该有url中的参数来决定。但是,如果在serializer里面直接把product给去掉,是不可以的,因为product_id 这个字段名是必须的。对此,我们可以这样修改:
我们可以在 View中,利用get_serializer_context
函数将需要的参数(这里是product_pk) 传递给serializer:
1 | class ReviewViewSet(ModelViewSet): |
然后,在ReviewSerializer中,我们要重写create函数。我们从view那里传过来的context中获得了product_id,并获得了表单提交上来的validated_data,那么现在就集齐了创建一个新Review实例的所有信息。
我们可以调用objects.create
方法,将这些信息传入,如下所示
1 | class ReviewSerializer(serializers.ModelSerializer): |
但是,还是有一个bug,就是现在不管是哪个product,在products/<product_id>/reviews/
界面都可以获取所有的review信息:比如上面这个gif,我在访问product/3/
的时候能看到对product1的评论。
因此,我们要对其做一个筛选,只选择该products的Review信息
Filtering
接下来,我们就来学习如何根据url中的参数来筛选信息:
比如说,我想筛选collection4中所有的product,可以这样来写http://localhost:9000/store/products/?collection_id=4
现在我们来实现这个功能。我们不能直接在queryset中调用.filter()
,因为这时候参数没办法获取。因此我们要调用get_queryset
来返回客制化的 queryset
首先,我们要确认默认queryset仍然是Product.objects.all()
,因为当没有/?collection_id=<id>
这个参数的时候,仍然需要返回所有的product。
然后,我们要从url的参数中获取collection_id
的值。url中的参数都在quert_params
中,也就是self.request.query_params.get('collection_id')
。注意了这里我们需要用get函数,而不能直接写query_params['collection_id']
后者是默认参数一定存在的,如果不存在就会报错;而前者当参数不存在的时候,collection_id的值就为空(None)
如果collection_id非空,那么我们就在默认的queryset
上调用filter
筛选出目标对象,最后返回queryset
1 | #views.py |
但仅仅修改View是不够的,系统会报错。原因是在ProductViewSet
中我们删去了queryset属性,变成了get_queryset
函数,而router是根据这个属性默认生成basename的。
1 AssertionError: `basename` argument not specified, and could not automatically determine the name from the viewset, as it does not have a `.queryset` attribute.
因此,我们对url.py也要做一定的修改,即给ProductViewSet注册的路由组一个basename参数(可以使products也可以是product)
1 | #urls.py |
效果如下图所示:
Generic Filtering
上面我们只筛选了一个collection字段,那么如果我们想筛选另外一个字段,是不是得改很多代码呢?这也太hard code了,因此我们现在来学习 Generic Filter
我们需要用到Django Filter 来帮助我们完成这个功能:pipenv install django-filter
; 下载完后,记得在setting中注册:
1 | INSTALLED_APPS = [ |
然后,我们在view中使用这个包,我们就不需要再调用get_queryset
函数了,直接使用queryset = Product.objects.all()
即可。
接着,我们引入filter_backends = [DjangoFilterBackend]
,并设置可供筛选的参数。这里除了collection_id还有unit_price可以选
1 | from django_filters.rest_framework import DjangoFilterBackend |
结果如下所示:
但这样筛选是不符合常理的,对于unit_price的筛选应该是一个区间而不是一个特定的值。因此,我们需要对其进行改进。在 django-filter文档中我们可以获得答案
我们新建一个filter.py
的文件,里面用来写自定义的筛选器:
- 首先我们导入 FilterSet类,然后新建一个ProductFilter来继承这个类,
- 在这个类中,我们需要确定一些元数据: 确定该筛选器应用于哪个model,以及可供筛选的字段
- collection_id 这个字段不用改,因此我们就写
['exact']
- unit_price 这个字段需要填写区间范围,因此可以这么写:
['gt', 'lt']
,代表价格高于某个值或者低于某个值
- collection_id 这个字段不用改,因此我们就写
1 | from django_filters.rest_framework import FilterSet |
结果如下,非常方便,调试起来很快:
Searching
那么,对于title,description这样的字符串字段,我们就没有办法做一个筛选了,需要做查找。方法也很简单:
首先,我们可以使用rest_framework.filters
中的SearchFilter模块
然后,在filter_backends
中添加SearchFilter
最后,确定search_field属性即可,除了model本身的字段之外,还可以查找外键model的字段。查找不区分大小写,支持模糊查找,中间用逗号隔开即可,简直太方便了
1 | from rest_framework.filters import SearchFilter |
Sorting
现在,我们还可以用filters中的OrderingFilter类帮助我们对 model进行筛选:
首先,我们导入OrderingFilter
然后,把OrderingFilter 加到 filter_backends中去
最后,确定能排序的字段,即ordering_fields
1 | from rest_framework.filters import SearchFilter,OrderingFilter |
结果如下所示。我们发现,当对unit_price升序排列的时候,url为/products/?ordering=unit_price
而降序排列的时候,url为/products/?ordering=-unit_price
而且,对于serializer没有提供的字段 last_update
,也可以进行排序
Pagination
方法1
现在我们来讲分页,首先,引入pagination模块
然后设置pagination_class
属性为PageNumberPagination
1 | from rest_framework.pagination import PageNumberPagination |
接着,我们在setting中确认每一页的数量:
1 | REST_FRAMEWORK = { |
如下图所示,现在已经可以实现分页了,每一页中还会告诉你总数、下一页的url和前一页的url,分页结果放在 results 数组当中:
如果我们想设置全部使用 Django Restful Framework的model都采用分页的方式呈现,可以这样设置:
1 | REST_FRAMEWORK = { |
这样一来,我甚至不需要在view中规定pagination_class = PageNumberPagination
也可以进行分页
方法2
但是如果我们不想对所有model进行分页,但是在settings中规定了PAGE_SIZE
属性的话,系统会给一个warning,意思是如果规定了PAGE_SIZE
,你最好规定一下DEFAULT_PAGE_CLASS
。
为了规避这个warning,我们可以自己实现:创建一个 pagination.py 文件:
1 | # pagination.py |
然后,在view中,将原本的PageNumberPagination
改为DefaultPagination
即可
1 | class ProductViewSet(ModelViewSet): |
Designing and Implementing a Shopping Cart API
Designing the API
现在我们来设计一个购物车的API,主要包含以下几个功能:创建购物车、访问购物车的内容、删除购物车、往购物车中添加物品,往购物车更新物品的数量,往购物车删除物品
- Create a Cart 对于路人,不一定要登录,也可以创建一个购物车
METHOD | url | request | Response |
---|---|---|---|
POST | /carts/ | {} | cart |
- Getting a Cart
METHOD | url | request | Response |
---|---|---|---|
GET | /carts/:id | {} | cart |
- Deleting a Cart
METHOD | url | request | Response |
---|---|---|---|
DELETE | /carts/:id | {} | {} |
- Adding an Item
METHOD | url | request | Response |
---|---|---|---|
POST | /carts/:id/items | {prodId,qty} | item |
- Updating an Item 这里,我们要更新的话,只需要修改购物车内物品的数量,因此,我们使用PATCH方法
METHOD | url | request | Response |
---|---|---|---|
PATCH | /carts/:id/items/:id | {qty} | {qty} |
- Deleting an item
METHOD | url | request | Response |
---|---|---|---|
DELETE | /carts/:id/items/:id | {} | {} |
我们创建两个类,一个来负责购物车的API,称为CartViewSet
,一个来负责购物车中物品的API,称为CartItemViewSet
Revisiting the Data Model
首先,我们要知道,购物车是一个对用户比较较敏感的信息,不能轻易地被其他人获取到这个信息,因此我们需要使用到GUID,即Globally Unique Identifier。是一种由算法生成的二进制长度为128位的数字标识符
首先我们引入uuid4,然后重写Cart Model的 id为UUIDField,默认值为uuid的引用。注意,这里不能使用default = uuid64()
因为这样会导致在创建migration的时候就生成了一个UUID的值 ,进而导致所有的cart都会使用同一个UUID。
对于CartItem,我们也需要进行修改,首先,可以给ForeignKey添加一个related_name
属性
其次,我们需要给数据库增加一个限制:我们不允许在同一个cart中,有多个products。如果用户往购物车中添加相同的product,只改变 quantity的值。要在数据库里添加这个数据,我们可以设置元数据中的unique_together
属性
这个元数据是非常重要的一个!它等同于数据库的联合约束!
举个例子,假设有一张用户表,保存有用户的姓名、出生日期、性别和籍贯等等信息。要求是所有的用户唯一不重复,可现在有好几个叫“张伟”的,如何区别它们呢?(不要和我说主键唯一,这里讨论的不是这个问题)
我们可以设置不能有两个用户在同一个地方同一时刻出生并且都叫“张伟”,使用这种联合约束,保证数据库能不能重复添加用户(也不要和我谈小概率问题)。在模型中用
unique_together
,也就是联合唯一!
1 unique_together = [['name', 'birth_day', 'address']]
在这里,我们只要设置两个值即可:unique_together = [['cart', 'product']]
1 | class Cart(models.Model): |
migrate后,两个数据表如上图所示,我们看到了cart id从原来的bigint变成了char(32); 并且cart_id和product_id之间加上了一个约束
Creating a Cart
创建一个API的流程如下:
Serializer
1 | class CartSerializer(serializers.ModelSerializer): |
View
在这里我们并不希望CartViewSet去继承ModelViewSet
这个类,因为对于cart来说,我只希望创建一个购物车,获得一个购物车,删除一个购物车。 我并不想用GET方法请求/carts/
来获得所有的购物车信息。否则,其他人的购物车我们也看得到。
基于此,我们要客制化 ViewSets。在这一part,我先使用CreateModelMixin:
1 | class CartViewSet(CreateModelMixin, GenericViewSet): |
Router
注册Router
1 | router.register('collections', views.CollectionViewSet) |
结果如下:
我们发现,id仍然是可以由client设置的,这和我们将其设为默认自动填充的UUID不符,由此,我们可以在serializer中将id设为只读:
1 | class CartSerializer(serializers.ModelSerializer): |
这样就可以直接创建了,我们看到在这个/carts/
下,是没有办法通过GET来获取cart信息的,但是可以通过POST创建一个cart:
Getting a Cart
在这个购物车里,我希望列出购物车的id,购物车中物品信息,以及整个购物车的商品总价值。首先,为了能获取到特定一个购物车的信息,我们需要使用RetrieveModelMixin
CartItem
为此,我们需要创建一个CartItemSerializer
来显示商品的详细信息,在这个Serializer中,我想展示的信息有:这个Item的id,这个商品的信息,购买数量,以及这件商品的总价值
但我们又不想展示这个商品的所有信息,因此我们还需要创建一个SimpleProductSerializer
专门为cartitem展示信息。我们想展示在购物车中的商品信息时:商品id,商品名称,商品单价
然后,我们要计算这件cartitem的总金额,计算公式是:这件商品的数量乘以这件商品的价格。需要用到serializers.SerializerMethodField()
这个方法。比如字段名是total_price,那么就需要创建一个get_total_price
来计算这个字段
1 | class SimpleProductSerializer(serializers.ModelSerializer): |
最后,我们要计算购物车的总金额,同样需要用到serializers.SerializerMethodField()
方法。不过因为在这里items是个数组,因此我们需要用一些技巧来计算整个金额:
下面这行代码就是说,对于每个购物车里面的商品,我都计算出商品的数量和商品的价格,然后用sum()
函数对其求和
1 | sum([item.quantity*item.product.unit_price for item in cart.items.all()]) |
整体效果如下:
1 | class CartSerializer(serializers.ModelSerializer): |
最后,为了避免找到cart,再去一个一个找cartitem,增加数据库负担,我们可以用prefetch_related
方法在查找Cart的时候就将里面的item都检索出来。注意了,prefetch_related
是找反向关系的(父找子),select_related
方法是查找正向关系的(子找父),不要用错!
1 | class CartViewSet(CreateModelMixin, RetrieveModelMixin, GenericViewSet): |
Deleting a Cart
要删除很简单,我们加一个RetrieveModelMixin
就可以了
1 | class CartViewSet(CreateModelMixin, |
Getting Cart Items
现在,我们要把之前学的嵌套路由用上了,我们希望在输入/carts/<cart_id>/items
的时候,可以显示这个购物车里面所有的商品信息; 在输入/carts/<cart_id>/items/<item_id>
的时候可以显示购物车中某商品特定的信息
首先,我们要创建一个CartItemViewSet,因为对Cart Item,我们可以有获取list,获取detail,增加和删除的功能,因此,这里我们直接让其继承自ModelViewSet即可。注意,由于我们要用嵌套循环,父类cart的id是从request来的,因此我们要重写get_query
函数,筛选出id为cart_pk
的所有商品。
此外,由于我们想获取该item的商品信息,我们需要用selected_related
将商品一并找出。否则会增加很多重复查询。
1 | class CartItemViewSet(ModelViewSet): |
然后,我们注册嵌套路由,模式和之前的 Product-Review是一样的
1 | carts_router = routers.NestedDefaultRouter(router, 'carts', lookup='cart') |
结果如下图所示:
Adding a Cart Item
现在,我们要实现给购物车添加商品的功能,现在的表单,我们不是不能添加,但是超级麻烦,如下所示:
事实上,我们往一个购物车里添加信息,只需要两个字段就行了:product_id 和 quantity
1 | class CartItemViewSet(ModelViewSet): |
1 | class AddCartItemSerializer(serializers.ModelSerializer): |
效果如下,如果product_id相同,那么数量就会在原来的基础上增加,并不会创建一个新的item
此外,我对quantity在model中加上了一个validator:
1 | quantity = models.PositiveSmallIntegerField( |
这样,如果新增的product的数量为0,会报错:
Updating a Cart Item
现在,我们想更新 Cart Item的数量,即quantity:
我们首先定位到一个特定cart中的特定item,发现这个item里面字段太多了,我们只想让顾客修改quantity字段即可:
方法:
1 | #serializer.py |
1 | #view.py |
效果如下,我利用PATCH方法,成功地完成了修改quantity的值
Deleting a Cart Item
由于在上面我们已经确定了,CartItemViewSet中可行的请求方法:
1 | http_method_names = ['get', 'post', 'patch', 'delete'] |
我们可以直接删除特定的item,效果如下:
Django Authentication System
Django Authentication System
在这一章我们来学习Django的内置认证系统。并将学习如何客制化User model,让其为我们的 项目服务
在INSTALLED_APPS中默认就有django.contrib.auth
应用,在这个app中有很多模型:User,Group,Permission等
1 | INSTALLED_APPS = [ |
在数据库中的auth_user
表格里,我们也可以看到Django项目中的用户信息。里面有很多字段:密码、上次登录时间,是否是超级管理员,名字,邮箱,注册时间等。之后我们将介绍怎么客制化这张表格。
此外,我们还要了解Middleware, 下面是在这个项目中默认的中间件。在Django中,每当我们收到一个client发来的request的时候,request在经过view的时候,同时也在按照顺序将request一一经过下列中间件。每个中间件都可以往request中添加额外的信息或者直接返回一个Response(可能是发生错误了)。在这些中间件中我们看到有一个是用来处理用户信息的: django.contrib.auth.middleware.AuthenticationMiddleware
, 这个中间件的功能,是用来读取request中用户的信息、并设置其属性的。
1 | MIDDLEWARE = [ |
Customizing the User Model
现在我们来介绍两种客制化User Model的方法:
Extend User
第一种方法,是我新创建一个类(不妨叫做AppUser),让其继承自 User类. 使用这种方法,我们新加入的列就会显示在auth_user这张表格中。因此,如果要存储与身份验证(Authentication)相关的属性,建议使用这种方法
Create Profile
第二种方法,是我创建一个Profile类,并让其建立和User的一对一联系。使用这种方法,将会有另一张表格,通过外键和auth_user相连接。因此,如果要存储与身份验证无关的属性(用户个人信息,比如用户生日、地址等),建议使用这种方法。
此外,在不同的app中,我们也可以设计不同的Profile Model——在sales app中,我们可以用Customer来表示Profile,在hr app中,我们可以用Employee来表示Profile,在training app中,我们可以使用Student来表示Profile
由于第二种方法使用的场景更多,我们常常使用第二种方法作为客制化 User model的方法
Extending the User Model
我们观察到,在auth_user这张表格中,对于email并没有严格的限制,但是呢,事实上不会存在多个用户共同使用一个email的情况,因此,我们需要新建一张自己的user表格,在里面加上对email的唯一性限制
首先,我们应该把我们的 User 创建在那里,肯定不能是store,因为这时对整个项目来说的,不能是特定的app
因此我们可以新创建一个名为 core的app。然后,将其在 INSTALLED_APP中注册。
接着我们在 core>models.py中创建 User, 这个User需要继承自django.contrib.auth.models
中的抽象类AbstractUser
。
1 | from django.db import models |
然后,我们需要在setting中确定AUTH_USER_MODEL
属性,让其等于 core中的User
1 | AUTH_USER_MODEL = 'core.User' |
但这样一来,程序会起一个冲突:
原来,我们在第一部分写的Like app中,用到了django.contrib.auth.models
中的User类,但是现在,我们将AUTH_USER_MODEL
设置成了我们自己写的类,由此引发了冲突。为了修改这个冲突,我们可以对LikedItem Model进行修改:这里,根据系统的提示,我们隐式的确定USER_MODEL
1 | from django.conf import settings |
现在,冲突又出现了:由于我们的core还没有migrate,因此系统无法启动
1 | raise ValueError("Dependency on app with no migrations: %s" % key[0]) |
在makemigration之后,要进行migrate操作的时候,又发生了如下错误:
1 | raise InconsistentMigrationHistory( |
这个意思是说,我们在项目中期却打算修改原本的User模型,这一般是不被允许的,这涉及到数据库的底层设计。因此,我们需要重启数据库,将原有的库删除.然后重新migrate
1 | DROP DATABASE storefront2; |
所以,在今后我们最好在一个项目的一开始就创建自己的User类,可以这样写:
1 | from django.db import models |
现在,让我们重新创建一个superuser:python manage.py createsuperuser
进入admin页面后,我们发现,admin界面中,只有Groups page,没有Users page:
我们可以再 core>admin.py中作如下修改
1 | from django.contrib.auth.admin import UserAdmin as BaseUserAdmin |
可以看到,现在有了用户界面,可以看到已经注册的用户(现在就我一个)
我们来到新建用户画面,发现必填字段只有下面这些
这是因为,我们继承的BaseUserAdmin类中,有一个属性是add_fieldsets, 里面规定的字段不包含email等,我们需要拿出来重写:在这里,我在原来的基础上加上了email、first_name,last_name
1 |
|
刷新后结果如下,我们发现,email现在已经成了必填项,而且有唯一性,First name和Last name是选填项
我们可以看到创建新用户后的样子如下:
与此同时,我们发现数据库中存放用户的表格从auth_user
变成了core_user
,里面保存了相关信息:
小结
现在我们来小结一下这种方法的基本步骤:
- 创建一个专门存放核心信息的app(最好叫core之类的)
- 在这个app中创建继承自AbstractUser的 User Model,里面确定要修改的字段
- 在settings.py 设置AUTH_USER_MODEL 属性
- 将之前引用
Django.contrib.auth.models.User
的app修改为settings.AUTH_USER_MODEL
Creating user Profiles
现在我们来学习第二种方法,之前,在store>model.py中,我们写了一个Customer Model,里面有名字、手机号、生日、会员等级等信息。我们要做的就是在此基础上增加一个和core.User的一对一联系.此外,模型中的first_name,last_name,email 都已经在 core.User中实现了,我们现在将这些冗余信息删除
1 | class Customer(models.Model): |
接着,由于我们在__str__
中返回的是f'{self.first_name} {self.last_name}'
这个字符串,在class Meta也规定按照['first_name','last_name']
来排序,由此,我们需要对其进行修改:
1 | def __str__(self): |
接下来系统给我们报错:
1 | <class 'store.admin.CustomerAdmin'>: (admin.E033) The value of 'ordering[0]' refers to 'first_name', which is not a field of 'store.Customer'. |
原来,在第一章的admin模块中,我们在 store>admin中创建了 CustomerAdmin类,里面引用了 first_name.last_name,现在这些属性已经不属于 Customer了,我们要对其进行修改:
对于 ordering 属性,我们可以直接用user__first_name
和user__last_name
但是对于list_display属性,这种语法并不适用,所以我们保持原样,转向去 Customer Model去创建两个同名函数:
1 | #store>models.py |
这样bug就完美解除了,现在我们来更新数据库。在 Customer界面,我们可以为已经存在的用户创建Profile:
我们为admin和JohnSmith分别创建一个profile,结果如下:
我们还可以给User添加排序:需要用@admin.display()
修饰:
1 | # store>model.py |
小结
要是用第二种方法,我们小结一下步骤:
- 创建Profile model(叫什么随意)
- 在这个model中,创建和 AUTH_USER_MODEL 的一对一联系
Groups and Permissions
Groups就是一些权限的集合,为了不给每一个用户都单独分配权限,我们可以将拥有相同权限的集合放到一个group中,这样就方便很多.
我们以superadmin登陆后,在 AUTHENTICATION AND AUTHORIZATION中点击 Groups可以新建一个组:在这里,我希望这个组内的成员拥有对customer (Profile)、Order和OrderItem的CRUD权利:
然后,我们可以在User界面,挑选一个用户,将它加到Customer Service组中,记得勾选 Staff status,这样他就可以登录到admin 后台了
我们退出admin,登录JasonBall后,可以看到,他只能对Customer和Order这两个Model进行修改了(OrderItem在Order中修改)。
Creating Custom Permissions
我们发现,有些权限是系统不能提供的,比如说我要取消订单,这是一种订单状态的修改,而不是删除订单。
要实现客制化,我们需要在Meta类中设置元数据 permission属性。这个属性是一个元组列表,每个元组代表特定的权限。元组中第一个元素是code name(需具备唯一性),第二个元素是对这个权限的描述
1 | class Order(models.Model): |
那么关于这个权限是如何实现的,我们放在下面一节里细说
Securing APIs
在这一章,我们要对api进行安全认证
Token-based Authentication
对RESTful API,使用Token-base Authentication是一种基本操作. 基本流程如下:
- 新用户要使用我们的服务,需要发送request请求,里面有创建用户的信息
- server收到以后,为其创建一个账号
- 用户用刚注册的账号来访问接下来的api
- 如果后台认证成功(密码正确),那么就发送一个token
- 账号密码错误,返回一个error
token是什么我们就不用多说了。
Adding the Authentication Endpoints
虽然Django提供了用户认证系统,但是我们并没有为其注册url。现在我们要注册一系列url,以便让用户实现注册、登录等操作。
为了实现这层api,我们可以使用djoser
这个包。他提供给我们一系列Views:比如注册、登录、等等。在其官网中,我们可以找到一些教程
首先,我们pipenv install djoser
下载这个包,然后将其注册到 INSTALLED_APP中去
然后,我们将auth注册到urlpattern中:
1 | urlpatterns = [ |
djoser 要依赖与一个 Authentication Backend才能够实现,因为djoser只是一层api和view函数,我们需要确定项目的Auth engine,才能让djoser发挥其作用。
在Django中我们主要有两个 Auth Engines:
- Token-based Authentication, 这是DRF中的认知引擎
- JSON Web Token Authentication,这是一个独立的包,需要下载
那么两者有什么区别呢?
前者会使用一张独立的Token表格,来存放tokens,每次server收到一个请求后,都会去数据库中做验证,判断token是否合法。也就是每次请求,都需要访问数据库
后者则不需要依赖数据库,因为Token的架构不同,我们在server层就可以完成对用户的验证
在这里我们选择后者,因此,我们需要再下载一个包:pipenv install djangorestframework_simplejwt
然后,在settings.py中,我们要将项目的Auth Engine设为JWTAuthentication:
1 | REST_FRAMEWORK = { |
接下来,我们要设置SIMPLE_JWT, 这项设置要求用户在请求头中需要加上JWT前缀
1 | SIMPLE_JWT = { |
按照教程,我们还需要在 storefront>urls.py中注册一个新的urlpattern:
1 | urlpatterns = [ |
现在,djoser提供的endpoints就可以正常使用了:
/users/
/users/me/
/users/confirm/
/users/resend_activation/
/users/set_password/
/users/reset_password/
/users/reset_password_confirm/
/users/set_username/
/users/reset_username/
/users/reset_username_confirm/
/token/login/
(Token Based Authentication)/token/logout/
(Token Based Authentication)/jwt/create/
(JSON Web Token Authentication)/jwt/refresh/
(JSON Web Token Authentication)/jwt/verify/
(JSON Web Token Authentication)
比如说,我访问 http://localhost:9000/auth/users/
:
因为这些urls是不允许匿名用户访问的,我们必须有token才可以访问到信息。因此这里返回的状态码是401 Unauthorized。
Registering Users
接下来我们就来学怎么注册一个新用户,在这个endpoint,我们不仅有GET还有POST方法。因此是可以新建User的
如果我们的注册信息很简单:
1 | { |
我们看到是不能注册成功的
这是因为在settings.py中我们设置了一系列Validator: 有最短长度验证、简单密码验证、全数值密码验证等
1 | AUTH_PASSWORD_VALIDATORS = [ |
当我们改进password后,就可以正常注册了
但我们有没有发现,这个POST函数并没有要求我们输入firstname和lastname(可以不填,但必须有),这是因为djoser默认提供的字段只有这些,因此我们需要客制化serializer
在 https://djoser.readthedocs.io/en/latest/settings.html?highlight=serializer#serializers 中,我们可以看到djoser提供的所有serializer,其中我们要重写的是user_create
我们将这个serializer写在core app中,首先,我们要导入djoser.serializers
中的UserCreateSerializer
类
然后,我们要创建一个客制化类,让其继承自UserCreateSerializer
接着,我们要重写Meta 类中的fields属性,又不想重写Meta中所有的属性,所以我们让Meta也继承自原来类中的Meta类。
最后,我们确认fields属性中的字段,把first_name,last_name加上:
1 | # core>serializers.py |
此外,由于我们想用自己写的UserCreateSerializer
,我们需要在settings中注明这一点:
1 | # settings.py |
最后结果如下所示,注意,first_name和last_name是选填的
Building the Profile API
但是,现在我们输入的都是core.user的字段,我们想在注册的时候添加birthday这种在profile中的字段,该怎么办?
我们当然可以在UserCreateSerializer中添加这个字段,但是这会让代码的耦合度变得很高。我们的程序设计理念是模块化。理想的状态是这样的:在前端,用户填了一系列表单,表单中的有些字段是属于core.user的,而有些字段则是属于store.customer的,为了将这些信息都保存下来,我们需要用户在提交时先访问 create user API,然后再发送一个Update Profile 请求访问Profile API,这样能让整个系统结构更加稳定。
由于Djoser并不提供/user/profile
类似的api,我们需要自己实现:
1 | # store>serializers.py |
然后,在http://localhost:9000/store/customers/
界面,我们就可以为特定的 User创建profile了:
Logging In
现在我们来讲用户认证。在Djoser中,下面两个endpoints是给 auth engine为DRF自带的Token Based Authentication使用的
/token/login/
(Token Based Authentication)/token/logout/
(Token Based Authentication)
下面三个endpoints则是给 JSON Web Token Authentication使用的:
/jwt/create/
(JSON Web Token Authentication)/jwt/refresh/
(JSON Web Token Authentication)/jwt/verify/
(JSON Web Token Authentication)
其中/jwt/create/
这个endpoint就是用来给用户登录的:如果我们输入的用户名和密码错误,如下所示
如果我们输入正确的用户名和密码,那么DJango会返回两个个Token :access token和refresh token。access token是short-lived(短暂的)token,用来访问需要安全认证的API的;当access token过期的时候,需要用到refresh token来获得一个新的access token
在 simplejwt的官网中,我们可以看到一系列设置,其中包括'ACCESS_TOKEN_LIFETIME','REFRESH_TOKEN_LIFETIME'
也就是说,我们可以自己修改access token的寿命:
为了方便,我们这里就讲access token的有效时间也设为1天
1 | #settings.py |
在一个前后端分离的系统中,当前段获得了一对token后,需要将它们存在浏览器里面。这里我们只能存放在一个文件里面,用来模拟前端的小型数据库,这样就算登录账户了。
那么怎么登出账户呢?很简单,就是将token从前端的小型数据库中移除即可。所以,在这个项目中,没有登出用户一说,因为这是前端干的事,和后端、数据库没有关系
Inspecting a JSON Web Token
现在,我们来学习解构JWT,需要用到这个网站: https://jwt.io/
我们在这个网站里,将我们刚才保存的token输进去,如下图所示,右图是这个token解构后的信息:
第一部分是header,里面有typ键,这里是JWT,因为我们采用了这种认证机制
第二部分是payload, 第一个键是token_type,这里是access token;第二个键是过期时间;第三个键是这个token的唯一认证机制。最后一个键是user_id
第三部分是Verify Signature,是根据前两部分生成的,your-256-bit-secret
会存在server里面。如果我们对Payload进行修改,那么Signature也会做相应的修改。因此,就算黑客获取了你的token,打算修改你的user_id,这时候signature也会改变,也就不是原来的那个token了。如果拿这个去访问后端,是会被拒绝的,因为后端也认出这个your-256-bit-secret
不是自己生成的。
Refreshing Tokens
那么如果Access token 过期了,没法访问Protected API, 而Refresh token 没有过期,会怎么样呢?我们需要用refresh_token向后端重新申请一个access token.
Djoser 为我们提供的这个api,是可以通过一个refresh token去获得一个新的access token的:
/jwt/refresh/
(JSON Web Token Authentication)
效果如下:
Getting the Current User
我们可以通过 users/me/
这个endpoint 来获得当前的用户。
但是我们发现,如果直接访问http://localhost:9000/auth/users/me/
,Django是不会给我们提供任何信息的,因为我们并没有在请求头里存放token信息。
因此,我们需要用到这个浏览器插件:Modheader, 利用这个插件,我们可以往请求头中添加信息。这里,我要添加一个Authorization的信息,信息内容是 JWT (Access Token)
。之所以这么写,是因为之前我们在settings中规定了认证的类型为JWT: 'AUTH_HEADER_TYPES': ('JWT',)
设置好后,我们刷新页面,就可以得到个人信息了。
但是现在,我们发现只能获得默认的三个字段:email,id,username,如果我想获得其他信息如first_name,last_name 应该怎么办?
还是老方法,修改默认的Serializer:我们找到 'current_user': 'djoser.serializers.UserSerializer',
发现这个UserSerializer 是负责当前用户信息的。因此我们来重写他
1 | # core>serializers.py |
别忘了在 settings.py>Djoser中增加修改:
1 | DJOSER = { |
注意了,Modheader会向所有的browser发送这个Authorization,因此可能会扰乱其它网站的登录(如果他们也用JWT的话),因此最好用完就删掉。
Getting Current Users Profile
上面我们获得了User的核心信息,现在如果我想获得User Profile,该怎么办?
我们希望,访问/store/customers/me
的时候,可以获取到用户的电话号码、出生日期,会员等级等信息。
首先,我们要导入 action decorator,用它来修饰/me 这个动作,这里,我们要设置detail的值,如果为False,那么说明这个action作用于list view,通过/store/customers/me
即可访问;如果为True,说明这个action作用于detail view,需要通过/store/customers/<id>/me
来访问。在这里,由于只会筛选到一个customer的信息,因此我们设置为False即可
然后,由于token的Payload中有userid这个键,因此我们可以用request.user.id
找到特定的customer的profile信息
最后,我们将object传入Serializer并将serializer.data
传回
1 |
|
结果如下:
但是,现在我作为用户本人,只能查看个人的信息,并不能创建、修改更新个人信息。因为我们看到这边只允许GET方法,因此我们需要对CustomerView进行修改。
现在我们来捋一捋逻辑,我现在作为用户本人,已经登录了,现在在访问/store/customers/me/
这个endpoint
- 如果我有customer信息
- 我可以GET来获取
- 我可以PUT来更新
- 如果我没有customer信息
- 那么我可以创建相关信息
- GET
- PUT
- 那么我可以创建相关信息
- 在使用PUT的时候,我不希望修改这个user的id,否则就乱了套了
根据这个逻辑,我们可以这样修改:
- 首先规定action中的methods参数为GET和PUT
- 然后,根据token中的id去数据库中获取customer对象,如果有则返回对象,如果没有,就创建一个实例
- 如果方法是GET,那么就返回serializer.data
- 如果方法是PUT,说明要更新,因此我们需要将customer实例和request.data传给
CustomerSerializer
,让它去验证信息准确性,如果没问题,就保存
- 在CustomerSerializer中将user_id改为
read_only=True
, 这样一来,用户只有在访问/store/customers/me/
的时候,才能创建或修改个人信息。在/store/customers/
的时候,不能创建个人信息,因为此时是获取不到user_id的
1 | # store>views.py |
注意,这里使用get_or_create
方法的时候,返回值是一个对象元组,我们需要用一个元组来接收,如果只赋值给customer会报如下错误:
1 | Got AttributeError when attempting to get a value for field `user_id` on serializer `CustomerSerializer`. |
结果如下:
Applying Permissions
在 https://www.django-rest-framework.org/api-guide/permissions/ 中,我们可以看到各式各样的 Permission
比如 AllowAny,是对所有人都可以开放的权限,isAuthentication 是已经登陆的人的权限。我们也可以自己创建权限。
如果我们想给所有的viewset都加上 isAuthentication权限.可以直接修改settings.py
1 | # settings.py |
如果我们希望有些 api可以由匿名用户访问,我们就不能采用这种一劳永逸的方法。可以在viewset里面设置
1 | class CustomerViewSet(CreateModelMixin, |
但是对于Generic View,好像用不了这个方法。我们需要在class上面加上一个decorator:
1 |
|
我们希望,在CustomerViewSet中,如果是未认证的用户,也可以查看他人的用户信息,但是只用认证后的用户才有权限更新Profile:
1 | # store >views |
如果我们不登录,那么只有GET方法
如果我们登陆了,那么可以调用PUT方法
Applying Custom Permissions
现在我们来自己创建Permissions类:
对于Products,我们希望只有admin可以修改有关product的信息,但是其他用户(不管有没有登录),都不能修改。但是DRF中写好的permission类只有IsAuthenticatedOrReadOnly,没有 IsAdminOrReadOnly,.因此我们要客制化一个Permission 类。
我们首先来看IsAuthenticated
,其内部逻辑就是找到是否token里有user,并且这个user是否是认证的
1 | class IsAuthenticated(BasePermission): |
然后我们再来看看IsAdminUser
的内部逻辑,就是找到是否有user,并且user是否是admin
1 | class IsAdminUser(BasePermission): |
接着我们就可以自己写permission了。逻辑如下:
- 如果使用安全方法(GET,OPTION,HEAD)访问,那么就给这个权限
- 如果用PUT,DELETE,POST方法来访问的话,那么就需要判断是否为admin了
1 | from rest_framework import permissions |
我们看到,如果我是staff(admin)我就可以新创建一个Product,除此之外只能是已读 的
Applying Model Permissions
现在我们只给admin操作Customer的权限。在之前我们创建的一个Group 叫做 Customer Service,如果我们想让拥有这个权限的用户进行操作,该怎么办?
我们可以使用DjangoModelPermissions
1 | class CustomerViewSet(CreateModelMixin, |
当使用这种权限方式,只有Groups中的用户才有权限对这个endpoint 进行操作.
比如说对于 JasonBall用户,他被加入到 customer service组了,那么现在他就拥有对customer的增删改查的权限:http://localhost:9000/store/customers/5/
当我们把他从这个组删除,那么我们看到,除了GET方法,它不能对customer进行PUT,POST和DELETE操作了
那么如果我甚至不想给他查看用户信息的权限,我们该怎么办?
看到DjangoObjectPermissions的源码,我们发现,在 perms_map
这个列表中,只有POST、PUT、PATCH和DELETE是受保护的方法,而对GET方法没有权限方面的限制
1 | perms_map = { |
因此,我们可以重写DjangoModelPermissions,在里面为GET方法加上权限设置。然后,将VIewSet中的permission_classes 修改成 我们自己写的 FullDjangoModelPermissions
1 | # store>permissions |
结果如下,JasonBall现在连信息都看不了了
Applying Custom Model Permissions
现在我们客制化一个Model Permission,我们想实现的功能是,当用户拥有这个权限,那么他可以访问历史订单,否则看不见。因为我们还没有开始写订单API,所以我们先(意思意思)
首先,我们在customer下面定义 permission元组列表。migration后,我们就可以在/admin界面给用户添加这个权限了
1 | class Customer(models.Model): |
接着,我们要为这个自定义的permission创建一个类,注意,这种自定义的类都需要继承自 BasePermission
.在这个类中,我们要重写has_permission
函数,里面返回的是一个判断用户是否拥有特定权限的布尔值。
这里,权限的命名方式是:app名字 . 自定义的权限名字
1 | class ViewCustomerHistoryPermission(permissions.BasePermission): |
最后,我们把这个permission class加到我们想要的action里面去,注意,这里自定义的action需要self,request和pk三个参数。因为这个 action是为特定的customer服务的,因此需要用到pk参数
1 | lass CustomerViewSet( CreateModelMixin, |
如果没有这个权限:
如果我们把这个权限加给JasonBall
注意点:
我们在给权限的时候永远不要一个一个加,就算只给一个权限,我们也要先创建一个Group,然后在把用户加到组里,这样不仅方便管理,而且能够轻易地通过组来筛选组内的用户。
如果一个一个加,项目大的话会非常难以管理
Designing and Building the Orders API
Designing the API
我们现在来创建一个下单的API:首先给个设计
METHOD | url | request | Response |
---|---|---|---|
POST | /orders/ | {cartId} | order |
GET | /orders/ | {} | order[] |
GET | /orders/1 | {} | order |
PATCH | /orders/1 | —— | —— |
DELETE | /orders/1 | —— | —— |
Getting the Orders
首先我们在数据库里生成一些订单(因为创建订单的api我们还没写)
还是老套路,首先创建 serializer.我们最终想返回的是一个嵌套列表,形式如下:
1 | [ |
我们看到这个嵌套对象还是蛮复杂的,最外层是order,第二层是orderitems,第三层是product
为了实现这个嵌套数组,我们可以这样来写serializer
1 | # serializer.py |
最后,我们注册routers:
1 | # urls.py |
Applying Permissions
现在我们给 orders 添加权限,否则匿名用户也能对订单进行查看。而且每个人只能查看自己下的订单。因此,我们要对queryset进行一个重写:
如果用户是staff,那么就可以返回所有的订单
如果用户不是staff,那么,就筛选出该用户下的订单并返回
1 | # views.py |
Creating an Order
现在我们来理一下创建Order的逻辑。
首先,我们可以从 Token中获得user_id, 我们将其取出放入 context。然后,如果是POST方法,说明需要创建一个Order,因为创建Order需要同时创建item(这一部分暂未实现),内部实现逻辑更复杂,因此我们这里需要新建一个CreateOrderSerializer。
1 | class OrderViewSet(ModelViewSet): |
因为这个Serializer是要收入一个嵌套数组的(cart+cartItem),这不属于Model之一 ,因此我们这里使用Serializer
,需要自己定义save函数。创建一个Order最基本的两个条件是购物车号码以及用户的id(当item为空的时候),因此这边我们根据id去找到customer,并用这个customer去创建Order
需要注意的是,cart_id 存放在 **validated_data
里面,属于request.data中的内容;而user_id是view通过context(可以理解为小窗)传给Serializer的内容,不包含在validated_data里面
在调用save函数的时候,validated_data会自动传入,而context不会,因此我们要手动传入customer字段。
1 | class CreateOrderSerializer(serializers.Serializer): |
Creating Order Items
上面所说的,只是创建一个Order对象,但是并没有创建订单中的物品信息。为此,我们需要在订单之后,再创建订单中的OrderItems对象
第一步,我们获取validated_data
中的cart_id
信息。
第二步, 我们根据card_id找到隶属于这个购物车中的所有物品 cart_items
第三步,对于每个cart_item,我们都创建一个 order_item ;然后,作为数组成员放到 order_items 中去
第四步,调用 objects.bulk_create
方法,传入一个数组,这个方法就会为每一个数组中的成员创建一个对象,所以叫做 bulk,意思是大量创建
第五步,在创建订单后,购物车就没有用了,所以我们要删掉它
1 |
|
需要注意的是,如果在订单创建一半的时候服务器崩掉了,怎么办?我们肯定需要回滚,因此我们可以将其视作一个数据库事务。为了实现事务功能,我们在代码前面加上 with transaction.atomic()
即可
结果如下,我们首先创建一个购物车。
然后,我们把购物车编号输入,用POST方法创建一个订单
然后,用GET方法就可以获得当前用户的所有订单。
Returning the Created Order
我们现在发现,当我们用一个购物车号码去创建一个订单的时候,返回的结果只是它的订单号,而我们希望的是直接返回这个创建好后的订单:
这是因为,在OrderViewSet
中,我们让其继承自 ModelViewSet,我们查看其源代码中的 CreateModelMixin
类如下:
1 | class CreateModelMixin: |
我们发现,首先它会获取到ViewSet中定义的serializer,然后创建一个对象。在Response中, 同样用这个serializer来构造返回信息。因此,在这个例子中,当使用 POST请求方法的时候,用到的是CreateOrderSerializer
,这个serializer只接收一个字段——购物车的号码,因此返回体中也只有这个购物车号这一个字段
为了修改这个bug,我们可以重写create函数,让其覆盖掉返回时候的那个serializer,从而返回刚刚创建的订单对象。 需要注意的是,由于CreateOrderSerializer
需要接受到user_id和请求体中的cart_id,所以我们要传入两组数据。
get_serializer_context
的作用就是给serializer带去“额外”(不在请求体之内的) 信息的,现在我们在重写create函数的时候直接传入了context,因此这个函数可以删去。
1 |
|
结果如下所示:
Data Validation
现在我们虽然已经实现了创建订单,返回订单,但是如果我们用一个cart_id重复创建订单的话,系统并不会给我们报错,而这是不被允许的——因为当订单创建后,购物车会被删除,此时cart_id 就不存在了。
我们需要排除的情况是:
- 当前的UUID并不存在(购物车ID非法)
- 当前的购物车中并没有任何物品
我们可以再Serializer Class中对特定的字段进行验证,格式为:函数 validate_{字段名}
,比如说我们对cart_id进行验证。
1 | class CreateOrderSerializer(serializers.Serializer): |
结果如下
Revisiting the Permissions
如果我们想给不同的人不同的权限怎么办?在ViewSets里面怎么修改?
比如说,对于订单删除功能,我只想开放给Admin,对于一般的用户,是不能删除创建的订单的的,为此,我们可以重写get_permissions
函数
1 | class OrderViewSet(ModelViewSet): |
Updating an Order
修改完权限后,就必须是管理员才能修改订单了,但是,在修改订单的时候,我们只希望修改订单的状态,其他字段我们希望它是只读的。但是,如果我们直接修改OrderSerializer的话,需要给除了payment_status
以外的字段都加上read_only,这是比较繁琐的,而且如果以后有新的字段进来,还是要修改OrderSerializer的。
因此,我们可以另外创建一个UpdateOrderSerializer,专门来更新Order
- serializer.py
1 | class UpdateOrderSerializer(serializers.ModelSerializer): |
- views.py> class OrderViewSet
1 | def get_serializer_class(self): |
Signals
之前,我们在OrderViewSet
的get_queryset
函数中,我们使用了 get_or_create
方法:
1 | def get_queryset(self): |
因为在我们这个app中,先要创建一个User对象,然后, 再需要手动创建Customer,让其和User建立一对一的关系。因此存在忘记创建Customer的情况,使用了get_or_create
之后,当检测到对应User的Customer未创建,就会自动创建一个Customer对象。
但这种方法毕竟是权宜之计,我们想要的理想状态是:当创建了User之后,会自动生成对应的Customer对象(默认),然后,用户可以去自行修改其Customer对象的信息。
由此,我们就需要用到Signal了。 顾名思义,信号允许应用程序在发生特定事件时得到通知 。比如说,我想在有人发表评论或对文章做出React时通知文章作者,我们就可以使用信号。只要指定的Model被保存下来(修改、更新),就会发送signal,接收器收到以后,会执行相应的动作
信号系统包含以下三要素:
- 发送者-信号的发出方
- 信号-信号本身
- 接收者-信号的接受者
Django内置了一整套信号,下面是一些比较常用的:
django.db.models.signals.pre_save
&django.db.models.signals.post_save
在ORM模型的save()方法调用之前或之后发送信号
django.db.models.signals.pre_delete
&django.db.models.signals.post_delete
在ORM模型或查询集的delete()方法调用之前或之后发送信号。
django.db.models.signals.m2m_changed
m2m_changed
当多对多字段被修改时发送信号。
django.core.signals.request_started
&django.core.signals.request_finished
当接收和关闭HTTP请求时发送信号。
监听信号
- 接收器
首先我们要用到 @receiver
这个修饰器,被它修饰的函数把这三者都集成到了一起。
在这个例子中,signal
是post_save
, 发送者是core.User
, 如果是成功创建了的话,Customer就会创建出对应的对象
1 | from django.db.models.signals import post_save |
- 随后在apps/store/app.py的config类下重写ready方法,用来激活signals,因为这个app对core.User发出来的signal比较感兴趣。
1 | class StoreConfig(AppConfig): |
结果如下,我们看到,当创建了一个新用户的时候,对应的Customer也被创建了
Creating Custom Signals
此外,我们也可以自定义signals,比如说,当我创建了一个订单之后,我可以发送一个Signal。对这个signal感兴趣的app 就可以捕获它,实现提示用户等功能
由于情况变得复杂(这个app中既有系统信号又有自定义信号),我们把逻辑都移动到signals文件夹当中。其中,文件夹中的 handlers.py
用来存放 receiver的逻辑。__init__.py
用来存放新建的自定义信号。比如说,我想创建一个order_created
信号,在订单创建时候发送
1 | from django.dispatch import Signal |
然后,我们需要设计发送逻辑,我们希望在订单创建时候发送这个信号,就需要修改Serializer中的save函数:
1 | class CreateOrderSerializer(serializers.Serializer): |
信息发送有两个函数:send和send_robust
顾名思义,后者比较鲁棒,稳定性较强。
因为
send
函数,当其中一个receiver在接收信号的时候发生了错误,是不会影响其他receiver的,但send_robust
函数会捕获receiver发生的异常,并添加到返回的 responses数组中。
当store发出了这个signal之后,我们希望core app可以收到这个消息并做出一定的动作。那么,我们需要在core中也创建一个receiver。同样的,我们创建signals文件夹,里面再新建一个handlers.py
1 | from store.signals import order_created |