1. 程式人生 > >Flask Api 文件管理與 Swagger 上手

Flask Api 文件管理與 Swagger 上手

Flask 是一個以自由度高、靈活性強著稱的 Python Web 框架。但高靈活性也意味著無盡的程式碼維護成本、高自由度意味著程式碼質量更依賴程式設計師自身而沒有一致的標準和規範。因此團隊內開發時 Flask 專案更需要建立程式碼和文件規範以保證不會出現太大的偏差。

本文從 Api 的角度探究 Flask 專案的 Api 規範以及獲得 Api 文件的最佳姿勢。眾數週知,文件的編寫和整理工作將花費巨大精力甚至不亞於程式碼的編寫,因此在時間緊任務重的情況下,文件是首先被忽略的工作。不過,就算專案在初期存在文件,但在後面的迭代中,文件落後嚴重,其產生的誤導比沒有文件更加可怕。

因此,個人認為 文件隨程式碼走

,程式碼改動時文件也應該跟進變動,但本著 人是不可靠的 原則,文件理想上是應該由程式碼生成,而不是靠人工維護。如果程式碼有任何改動,文件也能自動更新,這將是一件非常優雅的事情。雖然對很多文件來說這並不現實,但對於 Api 文件來說,實現成本並不高。

Flask-RESTPlus

對於 REST Api 來說,Flask-RESTPlus 是一個優秀的 Api 文件生成工具,這個包將會替換 Flask 路由層的編寫方式,通過自己的語法來規定 Api 細節,並生成 Api 文件。

安裝

安裝 Flask-RESTPlus

pip install flask-restplus

或者:

easy_install flask-restplus

最小 Demo

使用 Flask-RESTPlus 時需要按照這個庫規定的方式編寫 Api 層,包括 request 的引數解析,以及 response 的返回格式。一個 hello world 級的示範:

from flask import Flask
from flask_restplus import Resource, Api

app = Flask(__name__)
api = Api(app, prefix="/v1", title="Users", description="Users CURD api.")

@api.route('/users')
class UserApi(Resource):
    def get(self):
        return {'user': '1'}

if __name__ == '__main__':
    app.run()

執行之後效果如下:

實踐

這裡我會實現一個完整的小專案來實踐和介紹 Flask-RESTPlus 這個庫。我們實現一個簡單的 圖書訂單系統 ,實現使用者、圖書和訂單的 CURD。

Model

使用者 model,包含 id 和 username:

class User(object):
    user_id = None
    username = None

    def __init__(self, username: str):
        self.user_id = str(uuid.uuid4())
        self.username = username

圖書 model,包含 id,名稱和價格:

class Book(object):
    book_id = None
    book_name = None
    price = None

    def __init__(self, book_name: str, book_price: float):
        self.book_id = str(uuid.uuid4())
        self.book_name = book_name
        self.price = book_price

訂單 model,包含 id,購買者 id,圖書 id 和建立時間:

class Order(object):
    order_id = None
    user_id = None
    book_id = None
    created_at = None

    def __init__(self, user_id, book_id):
        self.order_id = str(uuid.uuid4())
        self.user_id = user_id
        self.book_id = book_id
        self.created_at = int(time.time())

藍圖

在 Flask 中構建大型 Web 專案,可以通過藍圖為路由分組,並在藍圖中新增通用的規則(url 字首、靜態檔案路徑、模板路徑等)。這個專案我們只用一個 api 藍圖,在實際中可能會使用 openapi 藍圖,internal api 藍圖來區分大的分類。

Flask-RESTPlusclass::Api 將直接掛在在藍圖下面,這麼我們即利用了 Flask 的藍圖進行對功能模組分類,也可以利用 Api 的版本對 Api 版本進行管理,對於小的模組分類,我們可以利用 Api 的 namespace,著這裡我們可以分為 user namespacebook namespaceorder namespace:

Api 藍圖:

from flask import Blueprint
from flask_restplus import Api

api_blueprint = Blueprint("open_api", __name__, url_prefix="/api")
api = Api(api_blueprint, version="1.0",
          prefix="/v1", title="OpenApi", description="The Open Api Service")

然後,就可以創建出不同的 namespace,來編寫自己的 api 程式碼了。而只需要在 app 工廠中註冊該 blueprint,便可將自己的編寫的 api 掛載到 flask app 中。

def create_app():
    app = Flask("Flask-Web-Demo")

    # register api namespace
    register_api()

    # register blueprint
    from apis import api_blueprint
    app.register_blueprint(api_blueprint)

    return app

要注意的是,因為 Api 中很多工具方法依賴 api 物件,因此在註冊 namespace 的時候要避免迴圈引用,而且,這注冊藍圖的時候,需要先將 namespace 註冊,否則會 404。這個庫的很多方法太依賴 api 物件,感覺設計並不合理,很容易就迴圈引用,並不是非常優雅。

註冊 namespace:

def register_api():
    from apis.user_api import ns as user_api
    from apis.book_api import ns as book_api
    from apis.order_api import ns as order_api
    from apis import api
    api.add_namespace(user_api)
    api.add_namespace(book_api)
    api.add_namespace(order_api)

下面就是 Api 的編寫了。

編寫 Api

列表和建立

我們先完成使用者的列表和建立 Api,程式碼如下:

from flask_restplus import Resource, fields, Namespace

from model import User
from apis import api

ns = Namespace("users", description="Users CURD api.")

user_model = ns.model('UserModel', {
    'user_id': fields.String(readOnly=True, description='The user unique identifier'),
    'username': fields.String(required=True, description='The user nickname'),
})
user_list_model = ns.model('UserListModel', {
    'users': fields.List(fields.Nested(user_model)),
    'total': fields.Integer,
})


@ns.route("")
class UserListApi(Resource):
    # 初始化資料
    users = [User("HanMeiMei"), User("LiLei")]

    @ns.doc('get_user_list')
    @ns.marshal_with(user_list_model)
    def get(self):
        return {
            "users": self.users,
            "total": len(self.users),
        }

    @ns.doc('create_user')
    @ns.expect(user_model)
    @ns.marshal_with(user_model, code=201)
    def post(self):
        user = User(api.payload['username'])
        return user

解釋下上面的程式碼,首先需要建立一個 user model 來讓 Flask-RESTPlus 知道我們如何渲染和解析 json:

user_model = ns.model('UserModel', {
    'user_id': fields.String(readOnly=True, description='The user unique identifier'),
    'username': fields.String(required=True, description='The user nickname'),
})

這裡面定義了欄位以及欄位的描述,這些欄位並不參與引數檢查,而只是渲染到 api 文件上,來標記 api 將返回什麼結果,以及應該怎麼呼叫 api。

然後介紹下目前用到的裝飾器:

  1. @ns.doc 來標記這個 api 的作用
  2. @ns.marshal_with 來標記如何渲染返回的 json
  3. @ns.expect 來標記我們預期什麼樣子的 request

執行程式我們可以看到以下結果:

我們也可以通過 try it 來呼叫 api:

查詢和更新

因為路由是繫結到一個類上的,因此限定了這個類能處理的 url,對於 '/users/user_id' 類似的路徑,需要單獨的類來處理:

@ns.route("/<string:user_id>")
@ns.response(404, 'User not found')
@ns.param('user_id', 'The user identifier')
class UserInfoApi(Resource):
    users = [User("HanMeiMei"), User("LiLei")]
    print([u.user_id for u in users])

    @ns.doc("get_user_by_id")
    @ns.marshal_with(user_model)
    def get(self, user_id):
        for u in self.users:
            if u.user_id == user_id:
                return u
        ns.abort(404, "User {} doesn't exist".format(user_id))

    @ns.doc("update_user_info")
    @ns.expect(user_model)
    @ns.marshal_with(user_model)
    def put(self, user_id):
        user = None
        for u in self.users:
            if u.user_id == user_id:
                user = u
        if not user:
            ns.abort(404, "User {} doesn't exist".format(user_id))
        user.username = api.payload['username']
        return user

在這裡面可以看到更改了 url 和新引入了兩個裝飾器:

  1. @ns.response 用來標記可能出現的 Response Status Code 並渲染在文件中
  2. @ns.param 用來標記 URL 引數

執行程式之後我們可以嘗試根據 id 獲得一個使用者:

注意namespace 的 name 會被拼接到 url 中,比如上面 url 中的 “users” 即是 namespace name。

帶巢狀的 Api

使用者 Api 和圖書 Api 基本一樣而且簡單,但是對於訂單 Api 中,需要包含使用者資訊和圖書資訊,在實現上略微不同。

from flask_restplus import Resource, fields, Namespace

from model import Order, Book, User
from apis.user_api import user_model
from apis.book_api import book_model

ns = Namespace("order", description="Order CURD api.")

order_model = ns.model('OrderModel', {
    "order_id": fields.String(readOnly=True, description='The order unique identifier'),
    "user": fields.Nested(user_model, description='The order creator info'),
    "book": fields.Nested(book_model, description='The book info.'),
    "created_at": fields.Integer(readOnly=True, description='create time: unix timestamp.'),
})
order_list = ns.model('OrderListModel', {
    "orders": fields.List(fields.Nested(order_model)),
    "total": fields.Integer(description='len of orders')
})

book = Book("Book1", 10.5)
user = User("LiLei")
order = Order(user.user_id, book.book_id)


@ns.route("")
class UserListApi(Resource):

    @ns.doc('get_order_list')
    @ns.marshal_with(order_list)
    def get(self):
        return {
            "orders": [{
                "order_id": order.order_id,
                "created_at": order.created_at,
                "user": {
                    "user_id": user.user_id,
                    "username": user.username,
                },
                "book": {
                    "book_id": book.book_id,
                    "book_name": book.book_name,
                    "price": book.price,
                }
            }],
            "total": 1}

    @ns.doc('create_order')
    @ns.expect(order_model)
    @ns.marshal_with(order_model, code=201)
    def post(self):
        return {
            "order_id": order.order_id,
            "created_at": order.created_at,
            "user": {
                "user_id": user.user_id,
                "username": user.username,
            },
            "book": {
                "book_id": book.book_id,
                "book_name": book.book_name,
                "price": book.price,
            }
        }

這裡使用了更靈活的格式組合,包括 fields.Nested 可以引入其他 model,因為 model 可以相互引用,因此還是有必要把這些 model 放在一起,來避免迴圈引用。不過由此也可以看出,Response 解析還是比較自由的。

備註:這裡 return 的是一個字典,但是理想狀態下應該是一個類(user 欄位和 book 欄位),只是因為沒有資料庫操作,簡化處理。

到這裡,這個小專案就是寫完了,最後執行效果圖如下:

改造

可以通過這個簡單的 Demo 瞭解 Flask-RESTPlus 的使用,但是目前只是從零到一的寫一個完成的專案,因此看起來非常容易上手,但是如果是舊專案改造,我們需要做什麼?

通過上述程式碼,我們可以看到要做的主要是兩件事:

  1. Api 層的改造
  2. 設計 Api Model

Api 層改造涉及到兩點,因為 url 是由 blueprint、api obj、namespace 三個東西共同組成的,因此需要設計怎麼分配,可能還有重寫部分 api 的實現。但是理想的 api-service-model 架構的程式, api 應該是比較薄的一層,要接入並不困難,只是瑣碎。

Api Model 一般是原有專案沒有的,需要引入,其中包括的引數檢查的 model(Flask-RESTPlus 提供了 Request Parsing,本文並沒討論,可以參考文件: Request Parsing )和解析 Response 的 model,這些需要梳理所有 api 和欄位,工作量不小,如果資料庫模型設計合理的話也許能減輕部分工作量。

Swagger

Swagger 是一款非常流行的 Api 文件管理、互動工具,適用於在團隊中的 Api 管理,以及服務元件對接。其好用與重要程度不必贅言,下面基於上文的 demo,完成一個 Swagger 文件以及基於文件生成用於對接的 client。

獲得 Swagger 文件

Flask-RESTPlus 是已經集成了 Swagger UI 的,在執行時所獲得介面即是通過 Swagger UI 渲染的。而我們目前需要的是獲取 Swagger 文件 json 或 yaml 檔案。

在控制檯可以看到,在訪問程式時:

是的,這就是 Swagger 文件:

程式碼生成

使用 Swagger 生成文件需要

在 macOS 下載:

brew install swagger-codegen

然後可以通過 help 名稱檢視幫助:

Hypo-MBP:~ hypo$ swagger-codegen help
usage: swagger-codegen-cli <command> [<args>]

The most commonly used swagger-codegen-cli commands are:
    config-help   Config help for chosen lang
    generate      Generate code with chosen lang
    help          Display help information
    langs         Shows available langs
    meta          MetaGenerator. Generator for creating a new template set and configuration for Codegen.  The output will be based on the language you specify, and includes default templates to include.
    validate      Validate specification
    version       Show version information

See 'swagger-codegen-cli help <command>' for more information on a specific
command.

生成 Python client:

swagger-codegen generate -i http://127.0.0.1:5000/api/swagger.json -l python

執行完成後,便可以在當前路徑的 swagger_client 下找到 api client 了。

總結

本文介紹了 Flask-RESTPlus 的使用,因為其本身就支援 Swagger 語法並內建了 Swagger UI,所以 Swagger 對接簡單異常。因此,主要工作量放在了編寫 api 層上,包括 model,以及 api 中起到解釋說明作用的裝飾器。雖然在程式碼上需要編寫不少不必要的程式碼(介紹說明用的描述等),但是這些額外程式碼輔助生成了與程式碼一致的文件,在元件對接和維護上,實則降低了成本。


歡迎到微信裡去當吃瓜群眾