Flask RESTful API 开发----基础篇 (2)

Posted by Gevin on Category: Flask RESTful Tutorial Tags: flask tutorial RESTful

0. 前言

接下来一段时间,Gevin将开一个系列专题,讲Flask RESTful API的开发,本文是第2篇《一个简单的Flask RESTful 实例》,本系列文章列表如下:

1. 准备

所谓“麻雀虽小,五脏俱全”,博客就是这样一个东西:一个轻量级的应用,大家都很熟悉,做简单了,只要有一个地方创建文章、显示文章即可,做复杂了,文章管理、草稿管理、版本管理、用户管理、权限管理、角色管理…… 等等一系列的功能都可以做上去,而这些业务逻辑,在很多应用场景下都是通用的或者类似的,从无到有、从粗到精的做好一个博客的开发,很多其他应用的开发就触类旁通了。本教程也将以博客为具体实例,开展接下来的Flask RESTful API的实现,博客的原型会一点点的变得复杂,相应的技能点也会逐一展开。

本节先从博客的基础开始,即实现文章的创建、获取、更新和删除。

2. Model的设计与实现

2.1 前提背景

通常设计并实现一个应用,是从数据模型的设计开始的,除非这个应用本身不包含数据的存取。按传统的说法,这个章节应该叫做“数据库的设计与实现”,其主要目标是,根据实际的数据存储需求,抽象出数据的实物模型(按关系型数据库的说法,即画E-R图),然后基于实物模型和采用的数据库,再设计出逻辑模型,逻辑模型即数据在数据库中真实的存储形式。随着数据库技术的发展,渐渐兴起了一种叫做ORM的技术,随着NoSQL的发展,又出现了OGM, ODM等,这三个名词分别对应"Object Relationship Mapping","Object Graph Mapping"和"Object Document Mapping",关于ORM及与之类似的几个名词,这里就不再赘述了,在Flask web开发的大背景下,如果哪位同学不了解这类技术,确实需要补补课了。

(注:上文的“基于实物模型和数据库技术,设计逻辑模型”的说法,省略了数据库选择这一步,技术发展至今,结合实践中的各种数据和需求,传统的关系型数据库已不再是数据存储的万金油,需要根据实际的数据需求,在数据库选型中考虑诸如采用SQL, Document还是Graph Database,要不要考虑空间数据库的支持等问题。)

对于开发者而言,通过ORM(或ODM, OGM等)与数据库通信,而非直接连接数据库、操作数据库,是一个很好的开发实践,一方面一个ORM,通常都支持多种关系型数据库,这样可以在开发中,将业务逻辑与存储数据的数据库解耦,另一方面,将开发中与数据库交互的相关逻辑交给大神开发的ORM处理,既简化了自己开发中的工作量,也更加靠谱。因此,除非特定需求的开发,或者采用的数据库太冷门或太超前导致没有合适的ORM,Gevin建议在开发中,将数据存取相关的业务逻辑交给专业的ORM来处理,与之对应的,当选择文档型数据库或图数据库时,配合ODMOGM来开发。

2.2 基于MongoEngine的model设计与实现

2.2.1 数据库选型

对于博客这样一个轻量级的应用,无论采用传统的关系型数据库,还是近年来火起来的NoSQL数据库,都能很好的满意本应用的业务需求,本教程中Gevin采用MongoDB作为博客应用的数据库,原因如下:

  1. Flask入门很经典的那本《Flask Web开发》,采用了关系型数据库,相关的实现作者大神远比我写的好,所以Gevin没必要做重复的轮子;
  2. MongoDB比较灵活,没有关系型数据库的那些约束限制,本教程随着不断深入,数据模型也会不断修改完善,MongoDB不会像关系型数据库那样,每次修改以创建的数据模型,都要直接或间接通过SQL命令修改数据表,开发体验更爽;
  3. MongoDB天生分布式(本应用用不到这样的特性),其诞生之日就号称最适合web开发的数据库,有很多很好的特性,值得大家去使用;当然更重要的是,MongoDB与Flask配合使用非常好,不像Django对MongoDB的支持那么有限(相关内容,我在Flask 入门指南中有更详细的描述),Gevin推荐Flask + MongoDB 这样的搭配。

确立了MongoDB这个数据库,就要去找可用的ODM框架,Python的生态下有很多MongoDB的ODM框架,MongoEngine是Gevin最喜欢的一个。MongoEngine相对于其他很多ODM框架,更新维护要活跃很多,而且非常好用,其使用方法与一直广受好评的Django 内置的ORM非常类似,所以上手非常容易,推荐大家使用。

接下来介绍的博客系统的数据模型,也将基于MongoEngine展开。

2.2.2 数据模型的设计与实现

一篇博客,通常包含以下字段就够了:

  • 标题
  • slug
  • 作者
  • 正文
  • 目录
  • 标签
  • 创建时间
  • 更新时间

slug字段需要专门说明一下,因为这个字段是唯一不直接存在于博客的概念模型里面的,而是考虑到博客系统的业务逻辑后,为了系统逻辑的优化而设计出来的。通常,一篇博客均对应一个数据库记录,这个记录必须是唯一的,需要有一个主键(候选键)来唯一识别这条记录。虽然每条数据库记录的id可以用作主键,但通常id是自动递增的,同一篇博客,创建成功后,删掉再新建,两次的数据库记录一般是不相同的,而且这确实是两条不同的数据库记录,使用了不同的id也是理所应当的。而在业务逻辑中却并非如此,在业务逻辑中,或者说从产品的角度看,同一篇博客,不管删除多少次再新建,依然是同一篇,始终可以通过一个永久不变的主键找到这条记录。在博客中,最典型的便是博客的导入功能,如果我们迁移了博客系统的服务器,并试图通过博客的导入导出恢复文章时,如果通过id定位每篇博客,很有可能切换服务器前后,文章的url就变了,这会导致原来放出去的博文链接均失效了,这是博客系统不希望看到的,但通过slug就不存在这种问题了。

举例来说:

比如『Gevin的博客』中,《RESTful API 编写指南》 一文,URL为https://blog.igevin.info/posts/restful-api-get-started-to-write/,URL最后一段的restful-api-get-started-to-write就是这篇文章的slug。Gevin就是用它来唯一识别每篇博客,每篇博客的永久链接也基于slug生成,这样无论我的博客系统浴火重生多少次,无论以后采用哪种编程语言开发,哪种数据库技术存储,每篇博客的永久链接将永久有效。

说到这里,可以对数据模型的设计做一点深入和经验的提炼:好的数据模型,在设计时不仅会包含概念模型所涉及到的内容,还会站到产品的角度,深入业务逻辑,增加一些支持整个产品逻辑的字段,也会综合考虑数据的一致性和查询效率等问题,设计必要的冗余字段

所以,在博客的数据模型中设计slug字段,并非一种特例,实际上大量常见的应用中,其数据模型中的id永远都是候选键,只会应用于产品逻辑的某些特殊场景中,大部分情况下,让概念模型中有意义的某个字段或者某几个字段的组合作为主键,才能更好的支持整个业务逻辑,也能使代码逻辑更具可扩展性,更好的应对变化的需求

(画外音:作为一个讲话严密的人,Gevin在上文提到slug不直接存在于博客的概念模型中的表述很准确,大家可以当做课外题想想,如果要设计一个优秀的、经得住用户考验的博客系统,在提炼数据的概念模型时,是不是会不自觉的引入类似于slug的这样一个概念 :P)

理论说的太多了,让我们赶紧进入show me the code阶段吧~

上面提到的博客的数据模型,用MongoEngine表达出来时,代码如下:

class Post(db.Document):
    title = db.StringField(max_length=255, required=True)
    slug = db.StringField(max_length=255, required=True, unique=True)
    abstract = db.StringField()
    raw_content = db.StringField(required=True)
    pub_time = db.DateTimeField()
    update_time = db.DateTimeField()
    author = db.StringField()
    category = db.StringField(max_length=64)
    tags = db.ListField(db.StringField(max_length=30))


    def save(self, *args, **kwargs):
        now = datetime.datetime.now()
        if not self.pub_time:
            self.pub_time = now
        self.update_time = now

        return super(Post, self).save(*args, **kwargs)

这里用了一个重写save()函数的小技巧,因为每次更新博文时,文章对象的更新时间字段都会修改,而发布时间,只会在第一次发布时更新,这个小功能细节虽然也可以放到业务逻辑中实现,但那会使得业务逻辑变得冗长,在save()中实现更加优雅。Gevin还会再save()中还会做更多的事情,这个会再下一篇文章中讲到。

3. API 的设计与实现

3.1 设计思路

常规的RESTful API, 即资源的CRUD操作(create, read, updatedelete)。通常RESTful API的read操作,包含2种情况:资源列表的获取和某个指定资源的获取;update操作存在两种形式:PUTPATCH。如何合理组织资源的这些操作,Gevin的一个实践方案是,资料列表获取资源创建两个操作,都是面向资源列表的,可以放到一个函数或类中实现;而资源的获取、更新和删除,是面向某个指定资源的,这些可以放到一个函数或类中实现。

在博客这个实例中,代码上表现如下:

class PostListCreateView(MethodView):
    def get(self):
        return 'Not ready yet'

    def post(self):
        return 'Not ready yet', 201



class PostDetailGetUpdateDeleteView(MethodView):
    def get(self, slug):
        return 'Not ready yet'

    def put(self, slug):
        return 'Not ready yet'

    def patch(self, slug):
        return 'Not ready yet'

    def delete(self, slug):
        return 'Not ready yet', 204

上面代码阐述了博客相关API实现的思路框架,需要特别注意的是201204两个http状态码,当创建数据成功时,要返回201(CREATED),删除数据成功时,要返回204(No Content),上面代码中没有体现出来的状态码为400404,这两个状态码是面向客户端请求的,常用于函数体内,对应代码实现中的常见错误请求,即,当请求错误时(如传入参数不正确), 返回400(Bad Request),当机遇请求条件查询不到数据时,返回404(Not Found);常用的状态码还有401403,与认证和权限有关,以后再展开。

3.2 实现

接下来让我们完成上面代码中没有实现的部分。由于博客这个例子非常简单,博客资源的CRUD操作,均围绕博客对应model的相关操作完成,而且基于上一篇文章的基础,写出这些API的实现,应该不成问题。如博客资源的创建,其实现如下:

def post(self):
        data = request.get_json()

        article = Post()
        article.title = data.get('title')
        article.slug = data.get('slug')
        article.abstract = data.get('abstract')
        article.raw = data.get('raw')
        article.author = data.get('author')
        article.category = data.get('category')
        article.tags = data.get('tags')

        article.save()

        return 'Succeed to create a new post', 201

当我们使用post请求上面API时,传入如下格式的json数据,即可完成博文的创建:

{
        "title": "Title 1",
        "slug": "title-1",
        "abstract": "Abstract for this article",
        "raw": "The article content",
        "author": "Gevin",
        "category": "default",
        "tags": ["tag1", "tag2"]
}

类似的,获取博客资源的实现如下:

def get(self, slug):
    obj = Post.objects.get(slug=slug)
    return jsonify(obj) # This line will raise an error

资源获取功能的实现,比创建资源的代码更简洁,但正如上面代码中的注释所述,上面的实现会报错,因为jsonify只能序列化dictlist,不能序列化object,所以若要解决上面的报错,需要把obj序列化,而把obj序列化只要把obj包含的数据,转化到dict中即可。

所以为修复bug,代码要做如下修改:

def get(self, slug):
    obj = Post.objects.get(slug=slug)

    post_dict = {}

    post_dict['title'] = obj.title
    post_dict['slug'] = obj.slug
    post_dict['abstract'] = obj.abstract
    post_dict['raw'] = obj.raw
    post_dict['pub_time'] = obj.pub_time.strftime('%Y-%m-%d %H:%M:%S')
    post_dict['update_time'] = obj.update_time.strftime('%Y-%m-%d %H:%M:%S')
    post_dict['content_html'] = obj.content_html
    post_dict['author'] = obj.author
    post_dict['category'] = obj.category
    post_dict['tags'] = obj.tags

    return jsonify(post_dict)

一个比较好的写API的实践经验是,编写资源创建或更新的API时,实现功能后不要仅返回一个“资源创建(更新)成功”的消息,而是返回创建或更新后的结果,这既能验证这些操作是否正确实现,也会让客户端调用API时感觉更舒服;另外,在获取资源时,如果资源不存在,就返回404

类似的,博客更新和删除的实现如下:

def put(self, slug):
        try:
            post = Post.objects.get(slug=slug)
        except Post.DoesNotExist:
            return jsonify({'error': 'post does not exist'}), 404

        data = request.get_json()

        if not data.get('title'):
            return 'title is needed in request data', 400

        if not data.get('slug'):
            return 'slug is needed in request data', 400

        if not data.get('abstract'):
            return 'abstract is needed in request data', 400

        if not data.get('raw'):
            return 'raw is needed in request data', 400

        if not data.get('author'):
            return 'author is needed in request data', 400

        if not data.get('category'):
            return 'category is needed in request data', 400

        if not data.get('tags'):
            return 'tags is needed in request data', 400



        post.title = data['title']
        post.slug = data['slug']
        post.abstract = data['abstract']
        post.raw = data['raw']
        post.author = data['author']
        post.category = data['category']
        post.tags = data['tags']

        post.save()

        return jsonify(post=post.to_dict())

    def patch(self, slug):
        try:
            post = Post.objects.get(slug=slug)
        except Post.DoesNotExist:
            return jsonify({'error': 'post does not exist'}), 404

        data = request.get_json()

        post.title = data.get('title') or post.title 
        post.slug = data.get('slug') or post.slug
        post.abstract = data.get('abstract') or post.abstract
        post.raw = data.get('raw') or post.raw
        post.author = data.get('author') or post.author
        post.category = data.get('category') or post.category
        post.tags = data.get('tags') or post.tags

        return jsonify(post=post.to_dict())

    def delete(self, slug):
        try:
            post = Post.objects.get(slug=slug)
        except Post.DoesNotExist:
            return jsonify({'error': 'post does not exist'}), 404

        post.delete()

        return 'Succeed to delete post', 204

更新和删除博客时,首先要找到对应的博客,如果博客记录不存在,则返回404,使用PUT方法更新资源时,请求API时,传入数据要包含资源的全部字段,而使用PATCH时,只需传入需要更新的字段数据即可,所以在上面的实现中,当传入json字段不完整时,会报400错误。(上面代码中的to_dict()函数,下文再介绍)

4. 代码的组织架构

Flask作为一个micro web framework,只要用一个文件就可以开发一个web服务或网站,但随着业务逻辑的增加,把所有的代码放到一个文件中是不合理的,应该把不同职责的代码放到不同的功能模块中,其基本思路是,将flask 实例的创建、数据模型的设计和业务逻辑(API)的实现分别放到不同的模块中。

Gevin在上一篇提到过,本教程对应的源码放到GitHub的restapi_exampl项目中,本篇涉及到的源码,将延续使用第一章搭好的框架,后续随着业务逻辑和代码越来越复杂,Gevin还会给大家更加深入的介绍Flask代码的组织架构风格。

4.1 App Factory

由app factory 负责flask实例的创建是Flask开发的惯例,正如flask官方文档中的Application Factories章节所述:

So why would you want to do this?

  1. Testing. You can have instances of the application with different settings to test every case.
  2. Multiple instances. Imagine you want to run different versions of the same application. Of course you could have multiple instances with different configs set up in your webserver, but if you use factories, you can have multiple instances of the same application running in the same application process which can be handy.

对于本应用而言,可以把app factory的实现放到factory.py文件中,并包含以下factory功能的实现代码:

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import os
from flask import Flask
from flask.views import MethodView

from flask_mongoengine import MongoEngine


db = MongoEngine()


def create_app():
    app = Flask(__name__)

    app.config['DEBUG'] = True
    app.config['MONGODB_SETTINGS'] = {'DB': 'RestBlog'}

    db.init_app(app)

    return app


4.2 数据模型

数据模型的设计可以放到models.py文件中,其实现代码如下:

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import datetime

from factory import db


class Post(db.Document):
    title = db.StringField(max_length=255, required=True)
    slug = db.StringField(max_length=255, required=True, unique=True)
    abstract = db.StringField()
    raw = db.StringField(required=True)
    pub_time = db.DateTimeField()
    update_time = db.DateTimeField()
    content_html = db.StringField()
    author = db.StringField()
    category = db.StringField(max_length=64)
    tags = db.ListField(db.StringField(max_length=30))


    def save(self, *args, **kwargs):
        now = datetime.datetime.now()
        if not self.pub_time:
            self.pub_time = now
        self.update_time = now

        return super(Post, self).save(*args, **kwargs)

    def to_dict(self):
        post_dict = {}

        post_dict['title'] = self.title
        post_dict['slug'] = self.slug
        post_dict['abstract'] = self.abstract
        post_dict['raw'] = self.raw
        post_dict['pub_time'] = self.pub_time.strftime('%Y-%m-%d %H:%M:%S')
        post_dict['update_time'] = self.update_time.strftime('%Y-%m-%d %H:%M:%S')
        post_dict['content_html'] = self.content_html
        post_dict['author'] = self.author
        post_dict['category'] = self.category
        post_dict['tags'] = self.tags

        return post_dict


    meta = {
        'indexes': ['slug'],
        'ordering': ['-pub_time']
    }

上面代码中,Gevin在博客的model中又增加了一个to_dict()成员方法,该方法实现了把类的对象转化为dict类型数据的功能,把对象序列化做的更优雅,这也是一种最基础的对象序列化方法。代码最后的meta,表示在MongDB中创建博客的collection时,要基于slug字段(也就是本博客设计的主键)进行索引,查询博文记录时,默认按照发布时间倒序排列。关于MongoEngine更详细的介绍,可以去查阅MongoEngine官方文档

4.3 API

基于上一篇的源码,API实现部分的代码,可以继续放到app.py文件中,下一篇会给大家介绍更加合理的代码组织方式。

5. What's More

  • 本篇涉及到的源码,大家可以在restapi_examplchapter2分支查阅

  • chapter2分支中的源码,执行命令python app.py即可运行,如果你没有安装相关依赖,请查阅requirements.txt文件进行安装

  • 下一讲预告:Gevin将介绍一些flask RESTful 开发中常用的Python库,把代码组织架构部分做一定调整和更详细的讲解


注:转载本文,请与Gevin联系




如果您觉得Gevin的文章有价值,就请Gevin喝杯茶吧!

|

欢迎关注我的微信公众账号



Comments