第14章:应用程序编程接口-Flask Web 开发(第2版)

近年来,Web应用程序已经有一种趋势,将越来越多的业务逻辑转移到客户端,从而产生一种称为富Internet应用程序(RIAs)的体系结构。在 RIAs 体系里,服务器的主要功能是为客户端应用提供数据存取服务。这种模式下,服务器变成了一个 Web Service(Web 服务)应用程序编辑接口 (API)

RIAs 可以通过几种协议与 Web Service 进行通信。远程过程调用(RPC)协议,如XML-RPC或其衍生物 SOAP 是几年前流行的选择。最近,REST 体系结构已经成为 Web 应用程序的最爱,因为它基于大家都熟悉的万维网模型。

Flask 是构建 RESTful Web 服务的理想框架,这要归功于其轻量级特性。本章你将学习到如何实现基于 Flask 的 RESTful API。

REST 简介

下表列示了用于 RESTful API 的 HTTP 请求方法。

请求方法 目标 说明 HTTP 响应状态码
GET 资源个体 URL 获取资源 200
GET 资源集合 URL 获取资源集合 200
POST 资源集合 URL 创建新资源,并将其添加到集合中 201
PUT 资源个体 URL 修改一个已存在的资源 200 或 204
DELETE 资源个体 URL 删除一个资源 200 或 204
DELETE 资源集合 URL 删除集合中的所有资源 200 或 204

除了以上4种请求方法,HTTP 协议还定义了其它方法,比如由 Flask 自动实现的 HEADOPTIONS

基于 Flask 的 RESTful Web Service

Flask 可以非常容易地创建 RESTful Web Service。带有 methods 参数的 route 装饰器可以用来定义在服务中处理资源的 URL。操作 JSON 数据也非常简单,通过 request.get_json() 可以从请求中获取 JSON 数据。如果需要在响应中包含 JSON,可以通过 Flask 的 jsonify() 辅助函数,来轻松地将一个 Python 字段转换成 JSON。

创建 API 蓝图

示例 14-1. API 蓝图的目录结构

|-flasky
  |-app/
    |-api
      |-__init__.py
      |-users.py
      |-posts.py
      |-comments.py
      |-authentication.py
      |-errors.py
      |-decorators.py

API 蓝图使用独立的模块实现每个资源(每个资源对应一个模块)。还包括用于处理认证和错误处理以及提供自定义装饰器的模块。蓝图的构造器如示例 14-2 所示。

# 示例 14-2. app/api/__init__.py: API 蓝图的构造器
from flask import Blueprint

api = Blueprint('api', __name__)

from . import authentication, posts, users, comments, errors

API 蓝图构造器的结构与其他蓝图的结构相似。 导入蓝图的所有组件是必要的,以便注册路由和其他处理程序。

示例 14-2 对 API 蓝图进行了注册。

# 示例 14-3. app/init.py: 注册 API 蓝图
def create_app(config_name):
    # ...
    from .api import api as api_blueprint
    app.register_blueprint(api_blueprint, url_prefix='/api/v1')
    # ...

错误处理

RESTful Web Service 通过在响应中发送适当的 HTTP 状态代码以及响应主体中的其他信息来将请求的状态通知客户端。下表列示了 API 返回的常用 HTTP 响应状态码。

状态码 名称 说明
200 OK 请求已成功完整
201 Created 请求已成功完整,并创建了一个新资源
202 Accepted 正在处理被接受的请求,但它还需要一个处理过程,这个处理过程以异步形式运行
204 No Content 请求已成功完整,并且响应中没有任何数据
400 Bad Request 非法请求
401 Unauthorized 该请求未包含身份认证信息,或者提供的凭据无效
403 Forbidden 该请求发送的身份认证凭据不足
404 Not Found URL 引用的资源不存在
405 Method Not Allowed 资源不支持该请求方法
500 Internal Server Error 处理该请求时发生了一个未处理的错误

状态码 404 和 500 的处理会带来一点麻烦,因为这些错误通常由 Flask 自行生成,默认会返回一个 HTML 响应。这可能会迷惑客户端,因为它期望的是所有响应都是 JSON 格式。

为所有客户端生成适当响应的一种方法是使错误处理程序根据客户端请求的格式调整其响应,称为内容协商(content negotiation) 技术。示例 14-4 展示的是一个改进的 404 错误处理程序,它以 JSON 格式响应 Web Service 客户端,以 HTML 格式响应其它终端。500 错误处理程序也使用相同的方法。

# 示例 14-4. app/api/errors.py: 使用内容协商的404错误处理
@main.app_errorhandler(404)
def page_not_found(e):
    if request.accept_mimetypes.accept_json and \
            not request.accept_mimetypes.accept_html:
        response = jsonify({'error': 'not found'})
        response.status_code = 404
        return response
    return render_template('404.html'), 404

改进后的错误处理程序,通过检测编码在 request.accept_mimetypesAccept 请求头来识别客户端想要的响应格式。浏览器通常不会对响应格式指定任何限制,但 API 客户端通常会这样做。

其它的状态代码由 Web Service 显式生成,因此它们可以通过蓝图的 errors.py 模块中的辅助函数生成。示例 14-5 展示了 403 错误的实现,其它状态的实现方法与此相同。

# 示例 14-5. app/api/errors.py: API 的 403 错误处理器
def forbidden(message):
    response = jsonify({'error': 'forbidden', 'message': message})
    response.status_code = 403
    return response

API 蓝图中的视图函数在需要时,可以通过调用这些辅助函数来生成响应。

使用 Flask-HTTPAuth 对用户进行身份认证

和常规 Web 应用一样,Web Service 也需要对信息进行保护,确保未经认证的用户无法获取信息。出于这个原因,RIAs 需要用户提供登录凭据,然后将它们传递给服务器进行验证。RESTful 服务是无状态(stateless)的,意思是服务器不允许客户端在多次请求之间“记住”任何东西。所以客户端的所有请求都要带上用户凭据。

【wrdll 注】HTTP 协议本身就是无状态的。只是在常规的 web 应用中,通过 cookie、session 等技术实现了 HTTP 请求间的状态维护。

当前的登录功能是借助 Flask-Login 实现的,它将数据保存在 Session 中。Flask 默认把 Session 存储在客户端的 Cookie 里,所以服务器不会存储任何与用户有关的信息,而是让客户端进行存储。看起来这种实现符合 REST 的无状态要求,但在 RESTful Web Service 中使用 Cookie 属于灰色区域,因为对于不是 Web 浏览器的客户端来说,实现它们可能会很麻烦。鉴于此,在 API 中使用 Cookie 通常被认为是糟糕的设计方案。

由于 RESTful 体系结构基于 HTTP 协议,因此 HTTP 身份验证是用于发送凭证的首选方法,无论是基本还是令牌风格。使用 HTTP 身份验证时,用户凭证包含在所有请求的 Authorization 请求头中。

HTTP 身份验证协议非常简单,可以直接实现,但 Flask-HTTPAuth 扩展提供了一个隐藏协议详细信息的装饰器,可以简化操作。这个装饰器和 Flask-Login 的 login_required 装饰器类似。

通过 pip 安装 Flask-HTTPAuth:

(venv) $ pip install flask-httpauth

必须通过创建 HTTPBasicAuth 类的实例来初始化这个扩展的 HTTP 基本认证。和 Flask-Login 一样,Flask-HTTPAuth 也不会对验证用户凭证所需的过程做任何假设,因此这些信息需要在回调函数中给出。示例 14-6 展示如何初始化这个扩展,同时提供了一个用于用户身份认证的回调函数。

# 示例 14-6. app/api/authentication.py: Flask-HTTPAuth 初始化
from flask_httpauth import HTTPBasicAuth
auth = HTTPBasicAuth()

@auth.verify_password
def verify_password(email, password):
    if email == '':
        return False
    user = User.query.filter_by(email = email).first()
    if not user:
        return False
    g.current_user = user
    return user.verify_password(password)

由于只有 API 蓝图需这种用户身份认证,所以 Flask-HTTPAuth 扩展在蓝图中初始化,而不像其它扩展那样,在应用程序中初始化。

现有的 User 模型已支持对 Email 和密码进行验证,直接使用即可。身份认证的回调函数在登录有效时,返 True,否则返回 False。Flask-HTTPAuth 扩展还会为不带身份验证的请求调用回调函数,并将两个参数都设置为空字符串。本例中,当 email 为空时,函数直接返回 False 来阻止本次请求(对于某些允许匿名用户的应用程序,可以返回 True)。这个回调函数将通过认证的用户存储在 Flask 的 g 上下文变量中,之后可以在视图函数里访问它。

注意,由于用户凭证会在与每个请求中进行交换,所以为 API 路由使用 HTTPS 是非常重要的,以便所有请求和响应都加密传输。

当遇到无效凭据时,服务器会返回 401 状态的响应给客户。(凭据无效时)Flask-HTTPAuth 默认使用这个状态码来生成响应,但是,为了确保该响应与 API 返回的其他错误一致,可以自定义错误响应,如果示例 14-7 所示。

# 示例 14-7. app/api/authentication.py: Flask-HTTPAuth 错误处理
from .errors import unauthorized

@auth.error_handler
def auth_error():
    return unauthorized('无效的凭据')

为了对路由进行保护,需要使用 auth.login_required 装饰器。

@api.route('/posts/')
@auth.login_required
def get_posts():
    pass

由于该蓝图的所有路由都需要进行保护,所以可以在 before_request 处理器上使用这个装饰器,而不需要为每个路由都写一遍。

# 示例 14-8. app/api/authentication.py: 带用户认证的 before_request
from .errors import forbidden

@api.before_request
@auth.login_required
def before_request():
    if not g.current_user.is_anonymous and \
            not g.current_user.confirmed:
        return forbidden('未经确认的账户')

现在这个蓝图里的所有路由都会进行身份认证检查。同时,这个 before_request 处理器还会检查用户是否确认了他的账户。

基于令牌的身份认证

用户需要在每个请求里都发送认证凭据。为了避免不断传输密码等敏感信息,可以使用基于令牌的认证解决方案。

在基于令牌的认证中,客户端向服务器发送一个包含登录凭据的认证信息,来请求一个访问令牌,这个令牌可以在后续请求里,代替登录凭据进行身份认证。出于安全原因,签发的令牌有过期时间。一旦令牌过期,客户端需要重新进行认证,以获得新的令牌。由于其有效期短,令牌被盗用的风险有限。

【wrdll 注】基于令牌的身份认证在当下应用的非常广泛,微信公众号 API 的调用就是使用该解决方案。这篇文章能在一定程度上帮助理解基于令牌的身份认证。

示例14-9为给 User 模型添加两个方法:使用 itdangerous 来生成并验证身份认证令牌。

# 示例 14-9. app/models.py: 对基于令牌认证进行支持
class User(db.Model):
    # ...
    def generate_auth_token(self, expiration):
        s = Serializer(current_app.config['SECRET_KEY'],
                       expires_in=expiration)
        return s.dumps({'id': self.id}).decode('utf-8')

    @staticmethod
    def verify_auth_token(token):
        s = Serializer(current_app.config['SECRET_KEY'])
        try:
            data = s.loads(token)
        except:
            return None
        return User.query.get(data['id'])

generate_auth_token() 方法返回一个对用户id进行编码并签名的令牌。同时通过秒数设置了它的有效期。verify_auth_token() 方法接收一个令牌,并且在这个令牌通过验证后,返回保存在其中的用户。它是一个静态方法,因为只有对令牌解码后才能知道是哪个用户。

要验证附带令牌的请求,必须修改 Flask-HTTPAuth 的 verify_password 回调函数以接受令牌和常规凭据。

# 示例 14-10. app/api/authentication.py: 改进身份认证验证函数,以支持令牌认证
@auth.verify_password
def verify_password(email_or_token, password):
    if email_or_token == '':
        return False
    if password == '':
        g.current_user = User.verify_auth_token(email_or_token)
        g.token_used = True
        return g.current_user is not None
    user = User.query.filter_by(email=email_or_token).first()
    if not user:
        return False
    g.current_user = user
    g.token_used = False
    return user.verify_password(password)

在这个新版本中,用于验证的第一个参数可以是一个 Email 地址或是一个认证令牌。如果这个参数为空,就假定是匿名用户。如果第二个参数为空,第一个参数将作为令牌进行验证。如果两个参数都不为空,则进行常规的 Email 和密码的验证。这个实现中,基本令牌的认证是可选的,客户端可以决定是否使用该认证方式。为了使用视图函数能区分这两种验证方式,增加了 g.token_used 变量。

将认证令牌返回给客户端的路由也会添加到 API 蓝图中。示例 14-11 是其实现。

# 示例 14-11. app/api/authentication.py: 生成认证令牌
@api.route('/tokens/', methods=['POST'])
def get_token():
    if g.current_user.is_anonymous or g.token_used:
        return unauthorized('无效的凭据')
    return jsonify({'token': g.current_user.generate_auth_token(
        expiration=3600), 'expiration': 3600})

由于该路由在 API 蓝图中,因此添加在 before_request 处理器中的身份认证机制也适用于它。为防止客户端使用先前获得的令牌而不是电子邮件地址和密码对此路由进行身份认证,对 g.token_used 变量进行检查,并且拒绝了使用令牌认证的请求。这样做的目的是,防止用户使用已过期的旧令牌来获取新令牌。该函数在 JSON 响应中返回一个有效期为一个小时的令牌。该时限也包含在 JSON 响应中。

序列化资源到 JSON,以及从 JSON 中获取已序列化的资源

编写 Web Service 时经常需要将资源的内部表示转换为 JSON,以便在通过 HTTP 进行传输。将内部表示转换为 JSON 的过程称为 序列化(serialization)。示例 14-12 给 Post 模型增加了一个 to_json() 方法。

# 示例 14-12. app/models.py: 将博文序列化为一个 JSON 字典
class Post(db.Model):
    # ...
    def to_json(self):
        json_post = {
            'url': url_for('api.get_post', id=self.id),
            'body': self.body,
            'body_html': self.body_html,
            'timestamp': self.timestamp,
            'author_url': url_for('api.get_user', id=self.author_id),
            'comments_url': url_for('api.get_post_comments', id=self.id),
            'comment_count': self.comments.count()
        }
        return json_post

此示例显示了如何在资源表示中返回“组成(made-up)”属性。comment_count 字段返回博文的评论数量。尽管这不是模型的真正属性,但为了便于客户端使用,将包含在资源表示中。

User 模型的 to_json() 方法可以用类似的方法进行实现。

# 示例 14-13. app/models.py: 将用户序列化为一个 JSON 字典
class User(UserMixin, db.Model):
    # ...
    def to_json(self):
        json_user = {
            'url': url_for('api.get_user', id=self.id),
            'username': self.username,
            'member_since': self.member_since,
            'last_seen': self.last_seen,
            'posts_url': url_for('api.get_user_posts', id=self.id),
            'followed_posts_url': url_for('api.get_user_followed_posts',
                                          id=self.id),
            'post_count': self.posts.count()
        }
        return json_user

序列化的反向操作称为反序列化(deserialization)。将来自客户端的 JSON 数据反序列化为模型表示,会遇到一些挑战。原因在于,这些数据可能是无效的、错误的或者是不需要的。示例14-14展示了通过 JSON 创建 Post 模型的方法。

# Example 14-14. app/models.py: 通过 JSON 创建博文
from app.exceptions import ValidationError

class Post(db.Model):
    # ...
    @staticmethod
    def from_json(json_post):
      body = json_post.get('body')
        if body is None or body == '':
            raise ValidationError('博文没有正文')
        return Post(body=body)

这里只需要 body 属性。Post 的其它属性要么由程序自动生成,要么不允许由客户端提交。

注意该方法是如何完成错误检查的。如果没有 body 字段,或者该字段的值为空,将抛出 ValidationError 异常。在这种情况下,引发异常是处理错误的适当方式,因为此方法没有正确处理错误所需的条件。异常将错误有效地传递给调用者,从而使上层代码能够对错误进行处理。ValidationError 通过继承 Python 的 ValueError 来实现,如示例14-15。

# 示例 14-15. app/exceptions.py: ValidationError 异常
class ValidationError(ValueError):
    pass

应用程序现在需要通过向客户端提供适当的响应来处理此异常。为避免在视图函数中添加捕获异常的代码,可以使用 Flask 的 errorhandler 修饰器来注册全局异常处理程序。示例14-16实现了 ValidationError 异常的处理程序。

# 示例 14-16. app/api/errors.py: API 的 ValidationError 异常处理函数
@api.errorhandler(ValidationError)
def validation_error(e):
    return bad_request(e.args[0])

和注册 HTTP 状态码处理函数一样,也使用 errorhandler 装饰器,但是这里使用 Exception 类作为参数。无论何时,一旦有对应的异常抛出,被装饰的函数将被调用。注意,这个装饰器只作用于 API 蓝图。通过此技术,视图函数不需要进行错误检查,从而使其代码将变得非常清晰和简洁。如下所示:

@api.route('/posts/', methods=['POST'])
def new_post():
    # wrdll 注,from_json() 会抛出异常,
    # 按传统做法,应该使用 try 语句对其进行处理
    # 有了 errorhandler,不需要这样做了
    # 代码变得清晰而简洁
    post = Post.from_json(request.json)
    post.author = g.current_user
    db.session.add(post)
    db.session.commit()
    return jsonify(post.to_json())

实现资源端点

剩下的工作就是给不同的资源实现路由。GET 请求通常是最简单的,因为它们只是返回信息而不需要进行修改操作。

# 示例 14-17. app/api/posts.py: 博文资源的 GET 处理
@api.route('/posts/')
def get_posts():
    posts = Post.query.all()
    return jsonify({ 'posts': [post.to_json() for post in posts] })

@api.route('/posts/<int:id>')
def get_post(id):
    post = Post.query.get_or_404(id)
    return jsonify(post.to_json())

博文的 POST 处理器用于往数据库里插入一篇新博文。

# 示例 14-18. app/api/posts.py: 博文资源的 POST 处理
@api.route('/posts/', methods=['POST'])
@permission_required(Permission.WRITE)
def new_post():
    post = Post.from_json(request.json)
    post.author = g.current_user
    db.session.add(post)
    db.session.commit()
    return jsonify(post.to_json()), 201, \
        {'Location': url_for('api.get_post', id=post.id)}

请注意,为了方便客户端使用,响应的主体包括了这篇新博文。这将节省客户端在创建资源后立即发出 GET 请求以获得这篇新博文的请求。

permission_required 装饰器用于阻止未经授权的用户发布新博文,它和在应用程序使用的装饰器类似,但例中的装饰器在 API 蓝图中定义。

# 示例 14-19. app/api/decorators.py: permission_required 装饰器
def permission_required(permission):
    def decorator(f):
        @wraps(f)
        def decorated_function(*args, **kwargs):
            if not g.current_user.can(permission):
                return forbidden('Insufficient permissions')
            return f(*args, **kwargs)
        return decorated_function
    return decorator

博文的 PUT 处理器用于修改一篇已存在的博文。

# 示例 14-20. app/api/posts.py: 博文资源的 PUT 处理器
@api.route('/posts/<int:id>', methods=['PUT'])
@permission_required(Permission.WRITE)
def edit_post(id):
    post = Post.query.get_or_404(id)
    if g.current_user != post.author and \
            not g.current_user.can(Permission.ADMIN):
        return forbidden('权限不足')
    post.body = request.json.get('body', post.body)
    db.session.add(post)
    db.session.commit()
    return jsonify(post.to_json())

本应用不允许客户端删除博文,所以不需要为博文定义 DELETE 处理器。用户和评论的资源处理的实现方式和此类似。下表列示了本应用所需的全部 API 资源。这些 API 的完整实现可以在git仓库看到。

资源URL 请求方法 说明
/users/<int:id> GET 返回一个用户
/users/<int:id>/posts/ GET 返回某用户发表的所有博文
/users/<int:id>/timeline/ GET 返回某用户关注的所有博文
/posts/ GET 返回所有博文
/posts/ POST 创建新博文
/posts/<int:id> GET 返回一篇博文
/posts/<int:id> PUT 修改一篇博文
/posts/<int:id>/comments/ GET 返回某篇博文的所有评论
/posts/<int:id>/comments/ POST 为某篇博文发表评论
/comments/ GET 返回所有评论
/comments/<int:id> GET 返回一条评论

给大资源集合进行分页

# 示例 14-21. app/api/posts.py: 给博文分页
@api.route('/posts/')
def get_posts():
    page = request.args.get('page', 1, type=int)
    pagination = Post.query.paginate(
        page, per_page=current_app.config['FLASKY_POSTS_PER_PAGE'],
        error_out=False)
    posts = pagination.items
    prev = None
    if pagination.has_prev:
        prev = url_for('api.get_posts', page=page-1)
    next = None
    if pagination.has_next:
        next = url_for('api.get_posts', page=page+1)
    return jsonify({
        'posts': [post.to_json() for post in posts],
        'prev_url': prev,
        'next_url': next,
        'count': pagination.total
    })

此技术适用于所有需要返回一个资源集合的 API 路由。

如果你从 Github 克隆了示例代码,可以使用 git checkout 14a 签出本示例。签出后,记得运行 pip install requirements/dev.txt

使用 HTTPie 测试 Web Service

必须通过一个 HTTP 客户端来测试 Web Service。最常用的两个测试 Python Web Service 的命令行客户端工具是 cURLHTTPie。这两个都是非常好用的工具,后者有更多简单而易于阅读的命令行语法,更方便构造 API 请求。HTTPie 可以通过 pip 来安装:

(venv) $ pip install httpie

假设开发服务器运行在默认的 http://127.0.0.1:5000 地址上,可以在其它的终端窗口,通过以下命令向开发服务器发起一个 GET 请求:

(venv) $ http --json --auth <email>:<password> GET \
> http://127.0.0.1:5000/api/v1/posts
HTTP/1.0 200 OK
Content-Length: 7018
Content-Type: application/json
Date: Sun, 22 Dec 2013 08:11:24 GMT
Server: Werkzeug/0.9.4 Python/2.7.3

{
    "posts": [
        ...
    ],
    "prev_url": null
    "next_url": "http://127.0.0.1:5000/api/v1/posts/?page=2", "count": 150
}

下面的命令发起一个新建博文的 POST 请求:

(venv) $ http --auth <email>:<password> --json POST \
> http://127.0.0.1:5000/api/v1/posts/ \
> "body=I'm adding a post from the *command line*."
HTTP/1.0 201 CREATED
Content-Length: 360
Content-Type: application/json
Date: Sun, 22 Dec 2013 08:30:27 GMT
Location: http://127.0.0.1:5000/api/v1/posts/111
Server: Werkzeug/0.9.4 Python/2.7.3

{
    "author": "http://127.0.0.1:5000/api/v1/users/1",
    "body": "I'm adding a post from the *command line*.",
    "body_html": "<p>I'm adding a post from the <em>command line</em>.</p>",
    "comments": "http://127.0.0.1:5000/api/v1/posts/111/comments",
    "comment_count": 0,
    "timestamp": "Sun, 22 Dec 2013 08:30:27 GMT",
    "url": "http://127.0.0.1:5000/api/v1/posts/111"
}

要使用认证令牌来代替用户名和密码,需要先发送到一个 POST 请求到 /api/v1/tokens/ 来获取令牌:

(venv) $ http --auth <email>:<password> --json POST \
> http://127.0.0.1:5000/api/v1/tokens/
HTTP/1.0 200 OK
Content-Length: 162
Content-Type: application/json
Date: Sat, 04 Jan 2014 08:38:47 GMT
Server: Werkzeug/0.9.4 Python/3.3.3

{
    "expiration": 3600,
    "token": "eyJpYXQiOjEzODg4MjQ3MjcsImV4cCI6MTM4ODgyODMyNywiYWxnIjoiSFMy..."
}

现在可以在一个小时内,使用这个令牌代替用户名和密码来访问其它资源了:

(venv) $ http --json --auth eyJpYXQ...: GET http://127.0.0.1:5000/api/v1/posts/

一旦令牌过期,请求将会返回 401 错误。此时需要重新获取令牌。

【wrdll 注】本节中,所有的命令行输出都应该是作者直接复制以前版本的,从输出的时间(2013或2014)和 Werkzeug 及 Python(2.7 和 3.3 都有) 版本都可以看出这点。因为本书英文第二版是在2018年发行。读者不需要纠结这些,重点在于那些命令的构建。

恭喜!至此,第二部分已经全部学习完了,并且 Flasky 项目的功能开发阶段也告一段落。下一步,我们将学习如何对其进行部署,这会是一系列新的挑战,这是第三部分的主题。

本文并非《Flask Web Development(2nd edition)》的全文翻译。而只是在阅读该书时,对自认为重点的内容进行记录以及思考。由于水平有限,本文所述内容难免出现不足或错误。请通过购买正版图书来进行更系统地学习,原书地址。本站将依照本系列文章录制视频教程,届时本文未作记录的部分以及项目实际开发中所需要的知识都会在视频教程中进行补充。

flask Flask Web Development(2nd edition) 2018-03-29 10:41 1312895