Skip to content
本页目录

博客文章通常需要分类,方便用户快速识别文章的类型,或者进行某种关联操作。

本章就来实现对文章的分类。

增加分类模型

首先在 article/models.py 里增加一个分类的模型,并且将其和博文成为一对多的外键:

python
# article/models.py

...

class Category(models.Model):
    """文章分类"""
    title = models.CharField(max_length=100)
    created = models.DateTimeField(default=timezone.now)

    class Meta:
        ordering = ['-created']

    def __str__(self):
        return self.title


class Article(models.Model):
    # 分类
    category = models.ForeignKey(
        Category,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name='articles'
    )
    ...

字段很简单,大体上就 title 字段会用到。

别忘了数据迁移。

教程把分类的 model 放到 article app中了。实际项目应根据情况考虑是否需要另起一个单独的分类 app。

视图与路由

视图还是用视图集的形式:

python
# article/views.py

...
from article.models import Category
from article.serializers import CategorySerializer

class CategoryViewSet(viewsets.ModelViewSet):
    """分类视图集"""
    queryset = Category.objects.all()
    serializer_class = CategorySerializer
    permission_classes = [IsAdminUserOrReadOnly]

与博文的视图集完全一样,没有新的知识。CategorySerializer 还没写,不慌等一会来搞定它。

将路由也注册好:

python
# drf_vue_blog/urls.py

...
from rest_framework.routers import DefaultRouter
from article import views

...
# 其他都不改,就增加这行
router.register(r'category', views.CategoryViewSet)

urlpatterns = [
    ...
]

是否感受到了自动注册的方便?

序列化器

接下来把 article/serializers.py 改成下面这样:

python
# article/serializers.py

from rest_framework import serializers
from article.models import Article
from user_info.serializers import UserDescSerializer
from article.models import Category


class CategorySerializer(serializers.ModelSerializer):
    """分类的序列化器"""
    url = serializers.HyperlinkedIdentityField(view_name='category-detail')

    class Meta:
        model = Category
        fields = '__all__'
        read_only_fields = ['created']

        
class ArticleSerializer(serializers.HyperlinkedModelSerializer):
    """博文序列化器"""
    author = UserDescSerializer(read_only=True)
    # category 的嵌套序列化字段
    category = CategorySerializer(read_only=True)
    # category 的 id 字段,用于创建/更新 category 外键
    category_id = serializers.IntegerField(write_only=True, allow_null=True, required=False)

    # category_id 字段的验证器
    def validate_category_id(self, value):
        if not Category.objects.filter(id=value).exists() and value is not None:
            raise serializers.ValidationError("Category with id {} not exists.".format(value))
        return value

    class Meta:
        model = Article
        fields = '__all__'

稍微开始有点复杂了,让我们来拆分解读一下代码。

先看 CategorySerializer

  • HyperlinkedIdentityField 前面章节有讲过,作用是将路由间的表示转换为超链接。view_name 参数是路由名,你必须显示指定。 category-detail 是自动注册路由时,Router 默认帮你设置的详情页面的名称,类似的还有 category-list 等,更多规则参考文档
  • 创建日期不需要后期修改,所以设置为 read_only_fields

再来看 ArticleSerializer

  • 由于我们希望文章接口不仅仅只返回分类的 id 而已,所以需要显式指定 category ,将其变成一个嵌套数据,与之前的 author 类似。
  • DRF 框架原生没有实现可写的嵌套数据(因为其操作逻辑没有统一的标准),那我想创建/更新文章和分类的外键关系怎么办?一种方法是自己去实现序列化器的 create()/update() 方法;另一种就是 DRF 框架提供的修改外键的快捷方式,即显式指定 category_id 字段,则此字段会自动链接到 category 外键,以便你更新外键关系。
  • 再看 category_id 内部。write_only 表示此字段仅需要可写;allow_null 表示允许将其设置为空;required 表示在创建/更新时可以不设置此字段。

经过以上设置,实际上序列化器已经可以正常工作了。但有个小问题是如果用户提交了一个不存在的分类外键,后端会返回外键数据不存在的 500 错误,不太友好。解决方法就是对数据预先进行验证

验证方式又有如下几种:

  • 覆写序列化器的 .validate(...) 方法。这是个全局的验证器,其接收的唯一参数是所有字段值的字典。当你需要同时对多个字段进行验证时,这是个很好的选择。
  • 另一种就是教程用到的,即 .validate_{field_name}(...) 方法,它会只验证某个特定的字段,比如 category_id

validate_category_id 检查了两样东西:

  • 数据库中是否包含了对应 id 值的数据。
  • 传入值是否为 None。这是为了能够将已有的外键置空。

如果没通过上述检查,后端就抛出一个 400 错误(代替之前的 500 错误),并返回错误产生的提示,这就更友好一些了。

这就基本完成了对分类的开发。接下来就是实际的测试了。

测试

打开命令行,首先创建分类:

你创建的数据 id 和博主的不一定相同,这是正常的,因为我在写教程时会反复测试,确保正确。

python
C:\...> http -a dusai:admin123456 POST http://127.0.0.1:8000/api/category/ title=Django
...

{
    "created": "2020-08-23T08:01:20.113074Z",
    "id": 6,
    "title": "Django",
    "url": "http://127.0.0.1:8000/api/category/6/"
}

更新已有的分类:

python
C:\...> http -a dusai:admin123456 PUT http://127.0.0.1:8000/api/category/6/ title=Flask
...
{
    "created": "2020-08-23T08:01:20.113074Z",
    "id": 6,
    "title": "Flask",
    "url": "http://127.0.0.1:8000/api/category/6/"
}

创建文章时指定分类:

python
C:\Users\Dusai>http -a dusai:admin123456 POST http://127.0.0.1:8000/api/article/ category_id=6 title=ILoveDRF body=WishYouToo!
...
{
    "author": {
        ...
    },
    "body": "WishYouToo!",
    "category": {
        "created": "2020-08-23T08:01:20.113074Z",
        "id": 6,
        "title": "Flask",
        "url": "http://127.0.0.1:8000/api/category/6/"
    },
    "title": "ILoveDRF",
    ...
}

把已有的分类置空:

python
C:\Users\Dusai>http -a dusai:admin123456 PATCH http://127.0.0.1:8000/api/article/20/ category_id:=null
...
{
    "category": null,
    ...
}

这里细心一点的就会发现,在更新资源时用到了 POSTPUTPATCH 三种请求方法,它们的区别是啥?

  • POST :创建新的资源。
  • PUT : 整体更新特定资源,默认情况下你需要完整给出所有必须的字段。
  • PATCH: 部分更新特定资源,仅需要给出需要更新的字段,未给出的字段默认不更改。

另一个小问题是更新分类用了 := 符号,这是 httpie 里输入非字符串数据的方法。详情见文档

删除以及权限等功能就不试了,读者朋友自行尝试吧。

完善分类详情

上面写的分类接口中,我希望分类的列表页面不显示其链接的文章以保持数据清爽,但是详情页面则展示出链接的所有文章,方便接口的使用。因此就需要同一个视图集用到两个不同的序列化器了,即前面章节讲的覆写 get_serializer_class()

修改序列化器:

python
# article/serializers.py

...

class ArticleCategoryDetailSerializer(serializers.ModelSerializer):
    """给分类详情的嵌套序列化器"""
    url = serializers.HyperlinkedIdentityField(view_name='article-detail')

    class Meta:
        model = Article
        fields = [
            'url',
            'title',
        ]


class CategoryDetailSerializer(serializers.ModelSerializer):
    """分类详情"""
    articles = ArticleCategoryDetailSerializer(many=True, read_only=True)

    class Meta:
        model = Category
        fields = [
            'id',
            'title',
            'created',
            'articles',
        ]

然后修改视图:

python
# article.views.py

...
from article.serializers import CategorySerializer, CategoryDetailSerializer

class CategoryViewSet(viewsets.ModelViewSet):
    """分类视图集"""
    ...
    def get_serializer_class(self):
        if self.action == 'list':
            return CategorySerializer
        else:
            return CategoryDetailSerializer

除此之外没有新的魔法。