Flask RESTful API 開發----基礎篇 (2)

Gevin發表於2017-06-15

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庫,把程式碼組織架構部分做一定調整和更詳細的講解

相關文章