Skip to content

Latest commit

 

History

History
313 lines (224 loc) · 9.39 KB

140-文章标题图.md

File metadata and controls

313 lines (224 loc) · 9.39 KB

即使是一个最简单的博客项目,也绕不开文件的上传与下载,比如说博文的标题图片。很遗憾,Json 格式的载体是字符串,不能够直接处理文件流。

怎么办?很多开发者用 DRF 处理文件上传还是沿用了 Django 的老路子,即用 multipart/form-data 表单发送夹杂着元数据的文件。这种方法可行,但在主要接口中发送编码文件总感觉不太舒服。

除了上面这种老路子以外,你基本上还剩三种选择:

  • 用 Base64 对文件进行编码(将文件变成字符串)。这种方法简单粗暴,并且只靠 Json 接口就可以实现。代价是数据传输大小增加了约 33%,并在服务器和客户端中增加了编码/解码的开销。
  • 首先在 multipart/form-data 中单独发送文件,然后后端将保存好的文件 id 返回给客户端。客户端拿到文件 id 后,发送带有文件 id 的 Json 数据,在服务器端将它们关联起来。
  • 首先单独发送 Json 数据,然后后端保存好这些元数据后将其 id 返回给客户端。接着客户端发送带有元数据 id 的文件,在服务器端将它们关联起来。

三种方法各有优劣,具体用哪种方法应当视实际情况确定。

本文将使用第二种方法来实现博文标题图的功能。

模型和视图

图片字段 ImageField 依赖 Pillow 库,先把它安装好:

python -m pip install Pillow

旧版本 pip 可能安装 Pillow 会失败,比如 pip==10.x 。如果安装过程中报错,请尝试升级 pip。

按照上述两步走的思路:先上传图片、再上传其他文章数据的流程,将标题图设计为一个独立的模型:

# article/models.py

...
class Avatar(models.Model):
    content = models.ImageField(upload_to='avatar/%Y%m%d')


class Article(models.Model):
    ...
    # 标题图
    avatar = models.ForeignKey(
        Avatar,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name='article'
    )

Avatar 模型仅包含一个图片字段。接收的图片将保存在 media/avatar/年月日/ 的路径中。

接着按部就班的把视图集写了:

# article/views.py

...
from article.models import Avatar
# 这个 AvatarSerializer 最后来写
from article.serializers import AvatarSerializer

class AvatarViewSet(viewsets.ModelViewSet):
    queryset = Avatar.objects.all()
    serializer_class = AvatarSerializer
    permission_classes = [IsAdminUserOrReadOnly]

图片属于媒体文件,它也需要路由,因此会多一点点配置工作:

# drf_vue_blog/settings.py

...
MEDIA_URL =  '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')

以及注册路由:

# drf_vuew_blog/urls.py

...
from django.conf import settings
from django.conf.urls.static import static

...
router.register(r'avatar', views.AvatarViewSet)

urlpatterns = [
    ...
]

# 把媒体文件的路由注册了
if settings.DEBUG:
  urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

这些准备工作都搞好了,就又到了喜闻乐见的写序列化器的环节。

序列化器

图片是在文章上传前先单独上传的,因此需要有一个单独的序列化器:

# article/serializers.py

...

from article.models import Avatar

class AvatarSerializer(serializers.ModelSerializer):
    url = serializers.HyperlinkedIdentityField(view_name='avatar-detail')

    class Meta:
        model = Avatar
        fields = '__all__'

DRF 对图片的处理进行了封装,通常不需要你关心实现的细节,只需要像其他 Json 接口一样写序列化器就可以了。

图片上传完成后,会将其 id、url 等信息返回到前端,前端将图片的信息以嵌套结构表示到文章接口中,并在适当的时候将其链接到文章数据中:

# article/serializers.py

...

class ArticleBaseSerializer(serializers.HyperlinkedModelSerializer):
    ...
    
    # 图片字段
    avatar = AvatarSerializer(read_only=True)
    avatar_id = serializers.IntegerField(
        write_only=True, 
        allow_null=True, 
        required=False
    )

    # 验证图片 id 是否存在
    # 不存在则返回验证错误
    def validate_avatar_id(self, value):
        if not Avatar.objects.filter(id=value).exists() and value is not None:
            raise serializers.ValidationError("Avatar with id {} not exists.".format(value))

        return value
    
    ...

用户的操作流程如下:

  • 发表新文章时,标题图需要先上传。
  • 标题图上传完成会返回其数据(比如图片数据的 id)到前端并暂存,等待新文章完成后一起提交。
  • 提交新文章时,序列化器对标题图进行检查,如果无效则返回错误信息。

这个流程在后面的前端章节会体现得更直观。

测试

接下来测试图片的增删改查。

Postman 操作文件接口需要将 Content-Type 改为 multipart/form-data ,并在 Body 中上传图片文件。具体操作方式请百度。

创建新图片:

PS C:\...> http -a dusai:admin123456 -f POST http://127.0.0.1:8000/api/avatar/ content@'D:\Image\Sea.jpg'

...

{
    "content": "http://127.0.0.1:8000/media/avatar/20200908/Sea.jpg",
    "id": 1,
    "url": "http://127.0.0.1:8000/api/avatar/1/"
}

看到创建图片后返回的 id 了吗?其实就是图片是先于 Json 数据单独上传的,上传完毕后客户端将其 id 记住,以便真正提交 Json 时能与之对应。

更新已有图片:

PS C:\...> http -a dusai:admin123456 -f PATCH http://127.0.0.1:8000/api/avatar/1/ content@'D:\Image\Sea.jpg'
...

{
    "content": "http://127.0.0.1:8000/media/avatar/20200908/Sea_EdNw2EF.jpg",
    "id": 1,
    "url": "http://127.0.0.1:8000/api/avatar/1/"
}

删除:

PS C:\Users\Dusai> http -a dusai:admin123456 -f DELETE http://127.0.0.1:8000/api/avatar/1/

HTTP/1.1 204 No Content
Allow: GET, PUT, PATCH, DELETE, HEAD, OPTIONS
Content-Length: 0
Date: Tue, 08 Sep 2020 10:52:04 GMT
Server: WSGIServer/0.2 CPython/3.8.2
Vary: Accept, Cookie
X-Content-Type-Options: nosniff
X-Frame-Options: DENY

查找:

PS C:\...> http -a dusai:admin123456 http://127.0.0.1:8000/api/avatar/
...

{
    "count": 4,
    "next": "http://127.0.0.1:8000/api/avatar/?page=2",
    "previous": null,
    "results": [
        {
            "content": "http://127.0.0.1:8000/media/avatar/20200831/Playground.jpg",
            "id": 6,
            "url": "http://127.0.0.1:8000/api/avatar/6/"
        },
        {
            "content": "http://127.0.0.1:8000/media/avatar/20200831/Shoes.jpg",
            "id": 7,
            "url": "http://127.0.0.1:8000/api/avatar/7/"
        },
        {
            "content": "http://127.0.0.1:8000/media/avatar/20200831/Sea.jpg",
            "id": 9,
            "url": "http://127.0.0.1:8000/api/avatar/9/"
        }
    ]
}

都正常运作了。

重构

仔细看下 ArticleBaseSerializer 序列化器,发现分类标题图的验证方法是比较类似的:

# article/serializers.py

...

class ArticleBaseSerializer(serializers.HyperlinkedModelSerializer):
    ...

    def validate_avatar_id(self, value):
        if not Avatar.objects.filter(id=value).exists() and value is not None:
            raise serializers.ValidationError("Avatar with id {} not exists.".format(value))
            self.fail('incorrect_avatar_id', value=value)

        return value

    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))
            self.fail('incorrect_category_id', value=value)

        return value

因此可以将它们整理整理,变成下面的样子:

# article/serializers.py

...

class ArticleBaseSerializer(serializers.HyperlinkedModelSerializer):
    ...

    # 自定义错误信息
    default_error_messages = {
        'incorrect_avatar_id': 'Avatar with id {value} not exists.',
        'incorrect_category_id': 'Category with id {value} not exists.',
        'default': 'No more message here..'
    }

    def check_obj_exists_or_fail(self, model, value, message='default'):
        if not self.default_error_messages.get(message, None):
            message = 'default'

        if not model.objects.filter(id=value).exists() and value is not None:
            self.fail(message, value=value)

    def validate_avatar_id(self, value):
        self.check_obj_exists_or_fail(
            model=Avatar,
            value=value,
            message='incorrect_avatar_id'
        )

        return value

    def validate_category_id(self, value):
        self.check_obj_exists_or_fail(
            model=Category,
            value=value,
            message='incorrect_category_id'
        )

        return value
  • 把两个字段验证器的雷同代码抽象到 check_obj_exists_or_fail() 方法里。
  • check_obj_exists_or_fail() 方法检查了数据对象是否存在,若不存在则调用钩子方法 fail() 引发错误。
  • fail() 又会调取 default_error_messages 属性中提供的错误类型,并将其返回给接口。

看起来似乎代码行数更多了,但更整洁了。起码你的报错信息不再零散分布在整个序列化器中,并且合并了两个验证器的重复代码,维护起来会更省事。