Python之Flask入门教程

Flask简介

Flask是一个用python编写的Web应用程序框架。Armin Ronacher带领一个名为Pocco的国际Python爱好者团队开发了Flask。Flask基于Werkzeug WSGI工具包和Jinja2模板引擎。两者都是Pocco项目。

Flask也被称为“microframework” ,因为它使用简单的核心,用extension增加其他功能。Flask没有默认使用的数据库、窗体验证工具。

什么是Web Framework?

Web Application Framework(Web应用程序框架)或简单的Web Framework(Web框架)表示一个库和模块的集合,使Web应用程序开发人员能够编写应用程序,而不必担心协议,线程管理等低级细节。

WSGI

Web Server Gateway Interface(Web服务器网关接口,WSGI)已被用作Python Web应用程序开发的标准。 WSGI是Web服务器和Web应用程序之间通用接口的规范。

Werkzeug

它是一个WSGI工具包,它实现了请求,响应对象和实用函数。 这使得能够在其上构建web框架。 Flask框架使用Werkzeug作为其基础之一。

jinja2

jinja2是Python的一个流行的模板引擎。Web模板系统将模板与特定数据源组合以呈现动态网页。

Flask通常被称为微框架。 它旨在保持应用程序的核心简单且可扩展。Flask没有用于数据库处理的内置抽象层,也没有形成验证支持。相反,Flask支持扩展以向应用程序添加此类功能。一些受欢迎的Flask扩展将在本教程后续章节进行讨论。

创建Flask 应用

from flask import Flask
app = Flask(__name__)

@app.route('/')
def hello_world():
    return 'Hello, World!'

@app.route('/hello')
def hello():
    return 'Hello2'
  1. 必须在项目中导入Flask模块。 Flask类的一个对象是我们的WSGI应用程序。
  2. Flask构造函数使用当前模块(__name __)的名称作为参数。
  3. Flask类的route()函数是一个装饰器,它告诉应用程序哪个URL应该调用相关的函数。

不要使用 flask.py 作为应用名称,这会与 Flask 本身发生冲突。

app.route(rule, options)
  • rule 参数表示与该函数的URL绑定。
  • options 是要转发给基础Rule对象的参数列表。

在上面的示例中,'/ ' URL与hello_world()函数绑定。因此,当在浏览器中打开web服务器的主页时,将呈现该函数的输出。

最后,Flask类的 run() 方法在本地开发服务器上运行应用程序。

app.run(host, port, debug, options)

所有参数都是可选的

序号参数与描述
1host 要监听的主机名。 默认为127.0.0.1(localhost)。设置为“0.0.0.0”以使服务器在外部可用
2port 默认值为5000
3debug 默认为false。 如果设置为true,则提供调试信息
4options 要转发到底层的Werkzeug服务器。

上面给出的Python脚本是从Python shell执行的。

Python Hello.py

变量规则

通过把 URL 的一部分标记为 <variable_name> 就可以在 URL 中添加变量。标记的 部分会作为关键字参数传递给函数。通过使用 <converter:variable_name> ,可以 选择性的加上一个转换器,为变量指定规则。

from markupsafe import escape

@app.route('/user/<username>')
def show_user_profile(username):
    # show the user profile for that user
    return 'User %s' % escape(username)

@app.route('/post/<int:post_id>')
def show_post(post_id):
    # show the post with the given id, the id is an integer
    return 'Post %d' % post_id

@app.route('/path/<path:subpath>')
def show_subpath(subpath):
    # show the subpath after /path/
    return 'Subpath %s' % escape(subpath)

转换器类型:

类型说明
int接受正整数
float接受正浮点数
path类似 string ,但可以包含斜杠
uuid接受 UUID 字符串
string(缺省值) 接受任何不包含斜杠的文本

唯一的 URL / 重定向行为

以下两条规则的不同之处在于是否使用尾部的斜杠。

@app.route('/projects/')
def projects():
    return 'The project page'

@app.route('/about')
def about():
    return 'The about page'
  • projects 是标准的URL ,尾部有一个斜杠,看起来就如同一个文件夹。 访问一个没有斜杠结尾的 URL 时 Flask 会自动进行重定向,帮你在尾部加上一个斜杠。

  • about 的 URL 没有尾部斜杠,因此其行为表现与一个文件类似。如果访问这个 URL 时添加了尾部斜杠就会得到一个 404 错误。这样可以保持 URL 唯一,并帮助 搜索引擎避免重复索引同一页面。

URL 构建

url_for() 函数用于构建指定函数的 URL。它把函数名称作为第一个 参数。它可以接受任意个关键字参数,每个关键字参数对应 URL 中的变量。未知变量 将添加到 URL 中作为查询参数。

为什么不在把 URL 写死在模板中,而要使用反转函数 url_for() 动态构建?

  1. URL反转:根据视图函数名称得到当前所指向的url

  2. url_for() 函数最简单的用法是以视图函数名作为参数,返回对应的url,还可以用作加载静态文件,如

<link rel="stylesheet" href="{{url_for('static',filename='css/index.css')}}">

该条语句就是在模版中加载css静态文件.

  1. url_for 和 redirect 区别

url_for是用来拼接 URL 的: 可以使用程序 URL 映射中保存的信息生成 URL。url_for() 函数最简单的用法是以视图函数名作为参数, 返回对应的 URL。例如,在示例程序中 hello.py 中调用 url_for('index') 得到的结果是 /。

redirect 是重定向函数,输入一个URL后,自动跳转到另一个URL所在的地址

这里使用 test_request_context() 方法来尝试使用 url_for()。 test_request_context() 告诉 Flask 正在处理一个请求,而实际上也许我们正处在交互 Python shell 之中, 并没有真正的请求。参见 本地环境 。

from flask import Flask, url_for
from markupsafe import escape

app = Flask(__name__)

@app.route('/')
def index():
    return 'index'

@app.route('/login')
def login():
    return 'login'

@app.route('/guest/<guest>')
def hello_guest(guest):
    return 'Hello %s as Guest' % guest

@app.route('/user/<name>')
def hello_user(name):
   if name =='admin':
      return render_template('/index.html', num=name)
   else:
      return redirect(url_for('hello_guest',guest = name))

@app.route('/user/<username>')
def profile(username):
    return '{}\'s profile'.format(escape(username))

with app.test_request_context():
    print(url_for('index'))
    print(url_for('login'))
    print(url_for('login', next='/'))
    print(url_for('profile', username='John Doe'))

以上输出

/
/login
/login?next=/
/user/John%20Doe

HTTP 方法

Web 应用使用不同的 HTTP 方法处理 URL 。 缺省情况下,一个路由只回应 GET 请求。 可以使用 route() 装饰器的 methods 参数来处理不同的 HTTP 方法:

from flask import request

@app.route('/login', methods=['GET', 'POST'])
def login():
    if request.method == 'POST':
        return do_the_login()
    else:
        return show_the_login_form()

如果当前使用了 GET 方法, Flask 会自动添加 HEAD 方法支持,并且同时还会 按照 HTTP RFC 来处理 HEAD 请求。同样, OPTIONS 也会自动实现。

静态文件

动态的 web 应用也需要静态文件,一般是 CSS 和 JavaScript 文件。只要在你的包或模块旁边创建一个名为 static 的文件夹就行了。

使用特定的 'static' 端点就可以生成相应的 URL

url_for('static', filename='style.css')

这个静态文件在文件系统中的位置应该是 static/style.css 。

渲染模板

在 Python 内部生成 HTML 相当笨拙。因为你必须自己负责 HTML 转义, 以确保应用的安全。因此, Flask 自动为你配置 Jinja2 模板引擎。

使用 render_template() 方法可以渲染模板,你只要提供模板名称和需要 作为参数传递给模板的变量就行了。下面是一个简单的模板渲染例子:

from flask import render_template

@app.route('/hello/')
@app.route('/hello/<name>')
def hello(name=None):
    return render_template('hello.html', name=name)

Flask 会在 templates 文件夹内寻找模板。因此,如果你的应用是一个模块, 那么模板文件夹应该在模块旁边;如果是一个包,那么就应该在包里面:

情形 1 : 一个模块:

/application.py
/templates
    /hello.html

情形 2 : 一个包:

/application
    /__init__.py
    /templates
        /hello.html
<!doctype html>
<title>Hello from Flask</title>
{% if name %}
  <h1>Hello {{ name }}!</h1>
{% else %}
  <h1>Hello, World!</h1>
{% endif %}

在模板内部可以和访问 get_flashed_messages() 函数一样访问 request 、 session 和 g 1 对象。

模板在继承使用的情况下尤其有用。其工作原理参见 模板继承 方案文档。简单的说,模板继承可以使每个页面的特定元素(如页头、导航和页尾) 保持一致。

自动转义默认开启。因此,如果 name 包含 HTML ,那么会被自动转义。如果你可以 信任某个变量,且知道它是安全的 HTML (例如变量来自一个把 wiki 标记转换为 HTML 的模块),那么可以使用 Markup 类把它标记为安全的,或者在模板 中使用 |safe 过滤器。更多例子: 官方 Jinja2 模板文档 。。

下面 Markup 类的基本使用方法:

>>> from markupsafe import Markup
>>> Markup('<strong>Hello %s!</strong>') % '<blink>hacker</blink>'
Markup(u'<strong>Hello &lt;blink&gt;hacker&lt;/blink&gt;!</strong>')
>>> Markup.escape('<blink>hacker</blink>')
Markup(u'&lt;blink&gt;hacker&lt;/blink&gt;')
>>> Markup('<em>Marked up</em> &raquo; HTML').striptags()
u'Marked up \xbb HTML'

什么是 g 对象?它是某个可以根据需要储存信息的 东西。更多信息参见 g 对象的文档和 使用 SQLite 3 文档。

操作请求数据

对于 web 应用来说对客户端向服务器发送的数据作出响应很重要。在 Flask 中由全局 对象 request 来提供请求信息。如果你有一些 Python 基础,那么 可能 会奇怪:既然这个对象是全局的,怎么还能保持线程安全?答案是本地环境

本地环境

某些对象在 Flask 中是全局对象,但不是通常意义下的全局对象。这些对象实际上是 特定环境下本地对象的代理。

设想现在处于处理线程的环境中。一个请求进来了,服务器决定生成一个新线程(或者 叫其他什么名称的东西,这个下层的东西能够处理包括线程在内的并发系统)。当 Flask 开始其内部请求处理时会把当前线程作为活动环境,并把当前应用和 WSGI 环境绑定到 这个环境(线程)。它以一种聪明的方式使得一个应用可以在不中断的情况下调用另一个 应用。

这对你有什么用?基本上你可以完全不必理会。这个只有在做单元测试时才有用。在测试 时会遇到由于没有请求对象而导致依赖于请求的代码会突然崩溃的情况。对策是自己创建 一个请求对象并绑定到环境。最简单的单元测试解决方案是使用 test_request_context() 环境管理器。通过使用 with 语句 可以绑定一个测试请求,以便于交互。例如:

from flask import request

with app.test_request_context('/hello', method='POST'):
    # now you can do something with the request until the
    # end of the with block, such as basic assertions:
    assert request.path == '/hello'
    assert request.method == 'POST'

另一种方式是把整个 WSGI 环境传递给 request_context() 方法:

from flask import request

with app.request_context(environ):
    assert request.method == 'POST'

请求对象

通过使用 method 属性可以操作当前请求方法,通过使用 form 属性处理表单数据(在 POST 或者 PUT 请求 中传输的数据)。以下是使用上述两个属性的例子:

from flask import request

@app.route('/login', methods=['POST', 'GET'])
def login():
    error = None
    if request.method == 'POST':
        if valid_login(request.form['username'],
                       request.form['password']):
            return log_the_user_in(request.form['username'])
        else:
            error = 'Invalid username/password'
    # the code below is executed if the request method
    # was GET or the credentials were invalid
    return render_template('login.html', error=error)

当 form 属性中不存在这个键时会发生什么?会引发一个 KeyError 。 如果你不像捕捉一个标准错误一样捕捉 KeyError ,那么会显示一个400 错误页面。因此,多数情况下你不必处理这个问题。

要操作 URL (如 key=value )中提交的参数可以使用 args 属性:

searchword = request.args.get('key', '')

用户可能会改变 URL 导致出现一个 400 请求出错页面。因此, 推荐使用 get 或通过捕捉 KeyError 来访问 URL 参数。

Request对象的重要属性如下所列:

  • Form - 它是一个字典对象,包含表单参数及其值的键和值对。
  • args - 解析查询字符串的内容,它是问号(?)之后的URL的一部分。
  • Cookies - 保存Cookie名称和值的字典对象。
  • files - 与上传文件有关的数据。
  • method - 当前请求方法。

完整的请求对象方法和属性参见 Request 文档。

文件上传

用 Flask 处理文件上传很容易,只要确保不要忘记在你的 HTML 表单中设置 enctype="multipart/form-data" 属性就可以了。否则浏览器将不会传送你的文件。

已上传的文件被储存在内存或文件系统的临时位置。你可以通过请求对象 files 属性来访问上传的文件。每个上传的文件都储存在这个 字典型属性中。这个属性基本和标准 Python file 对象一样,另外多出一个 用于把上传文件保存到服务器的文件系统中的 save() 方法。

from flask import request

@app.route('/upload', methods=['GET', 'POST'])
def upload_file():
    if request.method == 'POST':
        f = request.files['the_file']
        f.save('/var/www/uploads/uploaded_file.txt')
    ...

如果想要知道文件上传之前其在客户端系统中的名称,可以使用 filename 属性。但是请牢记这个值是 可以伪造的,永远不要信任这个值。如果想要把客户端的文件名作为服务器上的文件名, 可以通过 Werkzeug 提供的 secure_filename() 函数:

from flask import request
from werkzeug.utils import secure_filename

@app.route('/upload', methods=['GET', 'POST'])
def upload_file():
    if request.method == 'POST':
        f = request.files['the_file']
        f.save('/var/www/uploads/' + secure_filename(f.filename))
    ...

更好的例子参见 上传文件 方案。

Cookies

要访问 cookies ,可以使用 cookies 属性。可以使用响应 对象 的 set_cookie 方法来设置 cookies 。请求对象的 cookies 属性是一个包含了客户端传输的所有 cookies 的字典。在 Flask 中,如果使用 会话 ,那么就不要直接使用 cookies ,因为 会话 比较安全一些。

读取 cookies:

from flask import request

@app.route('/')
def index():
    username = request.cookies.get('username')
    # use cookies.get(key) instead of cookies[key] to not get a
    # KeyError if the cookie is missing.

储存 cookies:

from flask import make_response

@app.route('/')
def index():
    resp = make_response(render_template(...))
    resp.set_cookie('username', 'the username')
    return resp

注意, cookies 设置在响应对象上。通常只是从视图函数返回字符串, Flask 会把它们 转换为响应对象。如果你想显式地转换,那么可以使用 make_response() 函数,然后再修改它。

使用 延迟的请求回调 方案可以在没有响应对象的情况下设置一个 cookie 。

同时可以参见 关于响应 。

flask 表单

原生表单

form.html

{% extends 'common/base.html' %}
{% block title %}
    原生表单
{% endblock %}
{% block pagecontent %}
{#    <form action="{{ url_for('check') }}" method="post">#}
    <form action="{{ url_for('form') }}" method="post">
        <p>用户名: <input type="text" name="username" placeholder="请输入用户名" maxlength="12"></p>
        <p>密码: <input type="password" name="userpass" placeholder="请输入密码..."></p>
        <p><input type="submit" value="提交"></p>
    </form>
{% endblock %}

manage.py

@app.route('/form/')
def form():
    return render_template('form1.html')

#接收表单的数据
@app.route('/check/',methods=['POST'])
def check():
    print(request.form)
    return '提交过来了'

将俩个路由地址合并为同一个

@app.route('/form/',methods=['GET','POST'])
def form():
    if request.method == 'POST':
        print(request.form)
    return render_template('form1.html')

使用flask-wtf表单扩展库

作用: 是一个用于表单处理的扩展库 提供表单的校验 csrf的功能

pip install flask-wtf

字段类型

字段名称字段类型
StringField普通文本字段
PasswordField密码框
SubmitField提交按钮
TextAreaField多行文本域
HiddenField隐藏域
DateField日期
DateTimeField日期时间
IntegerField整形
FloatFIeld浮点型
RadioField单选字段
SelectField下拉
FileField文件上传字段
BooleanField布尔字段

验证器

验证器说明
DataRequired必填
Length长度 min max
IPAddressIP地址
Email邮箱
URL地址
Regexp正则匹配
EqualTo验证俩个字段值的正确性
NumberRange输入值的范围 min max

实例

from flask import Flask,render_template,request
from flask_script import Manager
from flask_bootstrap import Bootstrap
#导入自定义表单类的基类
from flask_wtf import FlaskForm
#导入表单的字段
from wtforms import StringField,PasswordField,SubmitField
#导入验证器
from wtforms.validators import Length,DataRequired


app = Flask(__name__)
bootstrap = Bootstrap(app)
#加密种子 csrf需要使用
app.config['SECRET_KEY'] = 'abcdedff'
manager = Manager(app)

class Login(FlaskForm):
    username = StringField('用户名',validators=[Length(min=6,max=12,message='用户名的长度为6~12为'),DataRequired(message='用户名不能为空!!!')])
    userpass = PasswordField('密码',validators=[Length(min=6,max=12,message='用户名的长度为6~12为'),DataRequired(message='密码不能为空!!!')])
    submit = SubmitField('登录')

@app.route('/')
def index():
    return render_template('index.html')

@app.route('/form/',methods=['GET','POST'])
def form():
    #将表单类实例化
    form = Login()
    if request.method == 'POST':
        #验证是否存在正确的csrftoken和 数据的正确性 如果都正确则为真
        if form.validate_on_submit():
            # print(request.form)
            print(form.username.data)
    return render_template('form2.html',form=form)

CRSF验证:这个机制其实就是在表单提交的过程中加一个随机字符串,只有当客户端和服务器的随机字符串一致,后端才执行,看似简单的一个机制却有效防止了CSRF攻击。

{% extends 'common/base.html' %}
{% block title %}
    原生表单
{% endblock %}
{% block pagecontent %}
    <form action="{{ url_for('form') }}" method="post">
        <p>{{ form.csrf_token }}</p>
        <p>{{ form.username.label() }} {{ form.username(style='color:red;',class='userclass',placeholder='请输入用户名') }}
            {% if  form.errors %}
            <span style="color:red;">{{ form.errors.username.0 }}</span>
            {% endif %}
        </p>
        <p>{{ form.userpass.label() }} {{ form.userpass() }}</p>
        <p>{{ form.submit() }}</p>
    </form>
{% endblock %}

使用 bootstrap渲染表单

{% import 'bootstrap/wtf.html' as wtf %}
{% block pagecontent %}
    <div class="row">
        <div class="col-md-8">图片</div>
        <div class="col-md-4">{{ wtf.quick_form(form,action="",method="") }}
        </div>
    </div>
{% endblock %}

自定义表单验证器

class Login(FlaskForm):
    ...
    def validate_username(self,field):
        # print(field)
        if field.data == 'zhangsan':
        # if self.username.data == 'zhangsan':
            raise ValidationError('该用户已存在')

注意:

validate_ 验证的字段名称 为固定格式

所有字段和验证器方法的使用

class Login(FlaskForm):
    username = StringField('用户名',validators=[Length(min=6,max=12,message='用户名的长度为6~12为'),DataRequired(message='用户名不能为空!!!')])
    userpass = PasswordField('密码',validators=[Length(min=6,max=12,message='用户名的长度为6~12为'),DataRequired(message='密码不能为空!!!'),EqualTo('confirm',message='俩次密码输入不一致')])
    confirm = PasswordField('确认密码')
    info = TextAreaField('个人简介',validators=[Length(min=6,max=20,message='内容为6-20个长度'),DataRequired(message='内容不能为空')],render_kw={"style":"resize:none;",'placeholder':"请输入你此刻的感谢..."})
    hidde =  HiddenField()
    birth = DateField('出生日期')
    birth = DateTimeField('出生日期')
    age = IntegerField('年龄',validators=[NumberRange(min=6,max=99,message='年龄为6~99岁')])
    money = FloatField()

    sex = RadioField(choices=[('w','女'),('m','男')])
    address = SelectField(choices=[('1001','北京'),('1002','上海'),('1003','天津')])

    file = FileField('文件上传')

    argee = BooleanField('请仔细阅读以上条款')

    ip = StringField('IPV4',validators=[IPAddress(message='请输入正确的ip地址')])
    url = StringField('url地址',validators=[URL(message='输入正确的url地址')])
    email = StringField('email',validators=[Email(message='请输入正确的邮箱地址')])
    preg = StringField('手机号码',validators=[Regexp('^[1][3-8][0-9]{9}$',flags=re.I,message='请输入正确的手机号码')])
    submit = SubmitField('登录')

flash 消息的显示

当用户请求 或者有消息的显示 通过flash,get_flashed_messages 来进行操作

导入

from flask import flash,get_flashed_messages

class Login(FlaskForm):
    username = StringField('用户名',validators=[DataRequired(message='用户名不能为空')])
    userpass = PasswordField('密码',validators=[DataRequired(message='密码不能为空')])
    submit = SubmitField('登录')

@app.route('/form/',methods=['GET','POST'])
def form():
    form = Login()
    if form.validate_on_submit():
        if form.username.data == 'zhangsan' and form.userpass.data == '123456':
            flash('登录成功')
            return redirect(url_for('index'))
        else:
            flash('当前用户不存在')
    return render_template('user/login.html',form=form)

使用

{% for message in get_flashed_messages() %}
    <div class="alert alert-danger" role="alert">{{ message }}</div>
{% endfor %}

重定向和错误

使用 redirect() 函数可以重定向。使用 abort() 可以 更早退出请求,并返回错误代码:

from flask import abort, redirect, url_for

@app.route('/')
def index():
    return redirect(url_for('login'))

@app.route('/login')
def login():
    abort(401)
    this_is_never_executed()

上例实际上是没有意义的,它让一个用户从索引页重定向到一个无法访问的页面(401 表示禁止访问)。但是可以说明重定向和出错跳出是如何工作的。

缺省情况下每种出错代码都会对应显示一个黑白的出错页面。使用 errorhandler() 装饰器可以定制出错页面:

from flask import render_template

@app.errorhandler(404)
def page_not_found(error):
    return render_template('page_not_found.html')

注意 render_template() 后面的 404 ,这表示页面对就的出错 代码是 404 ,即页面不存在。缺省情况下 200 表示:一切正常。

详见 错误处理 。

关于响应

视图函数的返回值会自动转换为一个响应对象。如果返回值是一个字符串,那么会被 转换为一个包含作为响应体的字符串、一个 200 OK 出错代码 和一个 text/html 类型的响应对象。如果返回值是一个字典,那么会调用 jsonify() 来产生一个响应。以下是转换的规则:

  1. 如果视图返回的是一个响应对象,那么就直接返回它。
  2. 如果返回的是一个字符串,那么根据这个字符串和缺省参数生成一个用于返回的 响应对象。
  3. 如果返回的是一个字典,那么调用 jsonify 创建一个响应对象。
  4. 如果返回的是一个元组,那么元组中的项目可以提供额外的信息。元组中必须至少 包含一个项目,且项目应当由 (response, status) 、 (response, headers) 或者 (response, status, headers) 组成。 status 的值会重载状态代码, headers 是一个由额外头部值组成的列表 或字典。
  5. 如果以上都不是,那么 Flask 会假定返回值是一个有效的 WSGI 应用并把它转换为 一个响应对象。

如果想要在视图内部掌控响应对象的结果,那么可以使用 make_response() 函数。

设想有如下视图:

@app.errorhandler(404)
def not_found(error):
    return render_template('error.html'), 404

可以使用 make_response() 包裹返回表达式,获得响应对象,并对该对象 进行修改,然后再返回:

@app.errorhandler(404)
def not_found(error):
    resp = make_response(render_template('error.html'), 404)
    resp.headers['X-Something'] = 'A value'
    return resp

JSON 格式的 API

JSON 格式的响应是常见的,用 Flask 写这样的 API 是很容易上手的。如果从视图 返回一个 dict ,那么它会被转换为一个 JSON 响应。

@app.route("/me")
def me_api():
    user = get_current_user()
    return {
        "username": user.username,
        "theme": user.theme,
        "image": url_for("user_image", filename=user.image),
    }

如果 dict 还不能满足需求,还需要创建其他类型的 JSON 格式响应,可以使用 jsonify() 函数。该函数会序列化任何支持的 JSON 数据类型。 也可以研究研究 Flask 社区扩展,以支持更复杂的应用。

@app.route("/users")
def users_api():
    users = get_all_users()
    return jsonify([user.to_json() for user in users])

会话

除了请求对象之外还有一种称为 session 的对象,允许你在不同请求 之间储存信息。这个对象相当于用密钥签名加密的 cookie ,即用户可以查看你的 cookie ,但是如果没有密钥就无法修改它。

使用会话之前你必须设置一个密钥。举例说明:

from flask import Flask, session, redirect, url_for, request
from markupsafe import escape

app = Flask(__name__)

# Set the secret key to some random bytes. Keep this really secret!
app.secret_key = b'_5#y2L"F4Q8z\n\xec]/'

@app.route('/')
def index():
    if 'username' in session:
        return 'Logged in as %s' % escape(session['username'])
    return 'You are not logged in'

@app.route('/login', methods=['GET', 'POST'])
def login():
    if request.method == 'POST':
        session['username'] = request.form['username']
        return redirect(url_for('index'))
    return '''
        <form method="post">
            <p><input type=text name=username>
            <p><input type=submit value=Login>
        </form>
    '''

@app.route('/logout')
def logout():
    # remove the username from the session if it's there
    session.pop('username', None)
    return redirect(url_for('index'))

这里用到的 escape() 是用来转义的。如果不使用模板引擎就可以像上例 一样使用这个函数来转义。

如何生成一个好的密钥?

生成随机数的关键在于一个好的随机种子,因此一个好的密钥应当有足够的随机性。 操作系统可以有多种方式基于密码随机生成器来生成随机数据。使用下面的命令 可以快捷的为 Flask.secret_key ( 或者 SECRET_KEY )生成值:

python -c 'import os; print(os.urandom(16))'
b'_5#y2L"F4Q8z\n\xec]/'

基于 cookie 的会话的说明: Flask 会取出会话对象中的值,把值序列化后储存到 cookie 中。在打开 cookie 的情况下,如果需要查找某个值,但是这个值在请求中 没有持续储存的话,那么不会得到一个清晰的出错信息。请检查页面响应中的 cookie 的大小是否与网络浏览器所支持的大小一致。

除了缺省的客户端会话之外,还有许多 Flask 扩展支持服务端会话。

消息闪现

一个好的应用和用户接口都有良好的反馈,否则到后来用户就会讨厌这个应用。 Flask 通过闪现系统来提供了一个易用的反馈方式。闪现系统的基本工作原理是在请求结束时 记录一个消息,提供且只提供给下一个请求使用。通常通过一个布局模板来展现闪现的 消息。

flash() 用于闪现一个消息。在模板中,使用 get_flashed_messages() 来操作消息。完整的例子参见 消息闪现 。

日志

有时候可能会遇到数据出错需要纠正的情况。例如因为用户篡改了数据或客户端代码出错 而导致一个客户端代码向服务器发送了明显错误的 HTTP 请求。多数时候在类似情况下 返回 400 Bad Request 就没事了,但也有不会返回的时候,而代码还得继续运行 下去。

这时候就需要使用日志来记录这些不正常的东西了。自从 Flask 0.3 后就已经为你配置好 了一个日志工具。

以下是一些日志调用示例:

app.logger.debug('A value for debugging')
app.logger.warning('A warning occurred (%d apples)', 42)
app.logger.error('An error occurred')

logger 是一个标准的 Logger Logger 类, 更多信息详见官方的 logging 文档。

更多内容请参阅 应用错误处理 。

集成 WSGI 中间件

如果想要在应用中添加一个 WSGI 中间件,那么可以用应用的 wsgi_app 属性来 包装。例如,假设需要在 Nginx 后面使用 ProxyFix 中间件,那么可以这样做:

from werkzeug.middleware.proxy_fix import ProxyFix
app.wsgi_app = ProxyFix(app.wsgi_app)

用 app.wsgi_app 来包装,而不用 app 包装,意味着 app 仍旧指向你 的 Flask 应用,而不是指向中间件。这样可以继续直接使用和配置 app 。

用户登录验证

Flask-Login 和其他 Flask 组件并没有太大区别,有必要开始之前了解下用户登录的步骤:

  • 登录:用户提供登录凭证(如用户名和密码)提交给服务器
  • 建立会话:服务器验证用户提供的凭证,如果通过验证,则建立会话( Session ),并返回给用户一个会话号( Session id )
  • 验证:用户在后续的交互中提供会话号,服务器将根据会话号( Session id )确定用户是否有效
  • 登出:当用户不再与服务器交互时,注销与服务器建立的会话

依据以上步骤,我们设计一个应用场景,作为实现:

  • 提供一个主页,需要登录才能访问
  • 如果没有登录,跳转到登录页面,登录成功再跳回
  • 登录成功后,可以点击登出退出登录
  • 在登录页面提供注册连接,点击后跳转到注册页面
  • 注册完成后,跳转到登录页面

初始化

安装

pip install flask-login

先实例化 login_manager 对象,然后用它来初始化应用:

from flask import Flask
from flask_login import LoginManager
# ...
app = Flask(__name__)  # 创建 Flask 应用

app.secret_key = 'abc'  # 设置表单交互密钥

login_manager = LoginManager()  # 实例化登录管理对象
login_manager.init_app(app)  # 初始化应用
login_manager.login_view = 'login'  # 设置用户登录视图函数 endpoint
  • 表单交互时,所以要设置 secret_key,以防跨域攻击( CSRF )
  • 登录管理对象 login_manager 的 login_view 属性,指定登录页面的视图函数 (登录页面的 endpoint),即验证失败时要跳转的页面,这里设置为登录页

用户模块

用户数据

要做用户验证,需要维护用户记录,为了方便演示,使用一个全局列表 USERS 来记录用户信息,并且初始化了两个用户信息:

from werkzeug.security import generate_password_hash
# ...
USERS = [
    {
        "id": 1,
        "name": 'lily',
        "password": generate_password_hash('123')
    },
    {
        "id": 2,
        "name": 'tom',
        "password": generate_password_hash('123')
    }
]

用户信息只包含最基本的信息:

  • name 为登录用户名
  • password 为登录密码,幸运的是模块 werkzeug.security 提供了 generate_password_hash 方法,使用 sha256 加密算法将字符串变为密文
  • id 为用户识别码,相当于主键

基于用户信息,定义两方法,用来创建( create_user )和获取( get_user )用户信息:

from werkzeug.security import generate_password_hash
import uuid
# ...
def create_user(user_name, password):
    """创建一个用户"""
    user = {
        "name": user_name,
        "password": generate_password_hash(password),
        "id": uuid.uuid4()
    }
    USERS.append(user)

def get_user(user_name):
    """根据用户名获得用户记录"""
    for user in USERS:
        if user.get("name") == user_name:
            return user
    return None
  • create_user 接受用户名和密码,创建用户记录,对密码明文进行加密,并添加用户 ID (使用 uuid 模板的 uuid4 方法生成一个全球唯一码),存储到 USERS 列表中
  • get_user 接受用户名,从 USERS 列表中查找用户记录,没有返回空

用户类

下面创建一个用户类,类维护用户的登录状态,是生成 Session 的基础,Flask-Login 提供了用户基类 UserMixin,方便定义自己的用户类,我们定义一个 User

from flask_login import UserMixin  # 引入用户基类
from werkzeug.security import check_password_hash
# ...
class User(UserMixin):
    """用户类"""
    def __init__(self, user):
        self.username = user.get("name")
        self.password_hash = user.get("password")
        self.id = user.get("id")

    def verify_password(self, password):
        """密码验证"""
        if self.password_hash is None:
            return False
        return check_password_hash(self.password_hash, password)

    def get_id(self):
        """获取用户ID"""
        return self.id

    @staticmethod
    def get(user_id):
        """根据用户ID获取用户实体,为 login_user 方法提供支持"""
        if not user_id:
            return None
        for user in USERS:
            if user.get('id') == user_id:
                return User(user)
        return None
  • 实例化方法接受一个用户记录,即 USERS 列表中的一个元素,用来初始化成员变量
  • get_id 方法返回用户实例的 ID,这是必须实现的,不然 Flask-Login 将无法判断用户是否被验证
  • get 是个静态方法,即可以通过类之间调用,是为了在获取验证后的用户实例时用的,必须接受参数 ID,返回 ID 所以对应的用户实例
  • verify_password 方法接受一个明文密码,与用户实例中的密码做校验,将被用在用户验证的判断逻辑中

加载登录用户

有了用户类,并且实现了 get 方法,就可以实现 login_manager 的 user_loader 回调函数了,user_loader 的作用是根据 Session 信息加载登录用户,它根据用户 ID,返回一个用户实例:

@login_manager.user_loader  # 定义获取登录用户的方法
def load_user(user_id):
    return User.get(user_id)

登录页面

页面包括后台和展现(可以理解成前台)两部分

后台

需要定义一个 Form 类,用来设置页面的元素和规则:

from wtforms import StringField, PasswordField
from wtforms.validators import DataRequired, EqualTo
# ...
class LoginForm(FlaskForm):
    """登录表单类"""
    username = StringField('用户名', validators=[DataRequired()])
    password = PasswordField('密码', validators=[DataRequired()])
  • 定义用户名和密码两个字段,分别是字符类型字段和密码类型字段,密码类型字段会在页面上显示为密码形式,以提高安全性
  • 为两个字段设置必填规则

然后定义一个用户登录的视图函数 login:

from flask import render_template, redirect, url_for, request
from flask_login import login_user
# ...
@app.route('/login/', methods=('GET', 'POST'))  # 登录
def login():
    form = LoginForm()
    emsg = None
    if form.validate_on_submit():
        user_name = form.username.data
        password = form.password.data
        user_info = get_user(user_name)  # 从用户数据中查找用户记录
        if user_info is None:
            emsg = "用户名或密码密码有误"
        else:
            user = User(user_info)  # 创建用户实体
            if user.verify_password(password):  # 校验密码
                login_user(user)  # 创建用户 Session
                return redirect(request.args.get('next') or url_for('index'))
            else:
                emsg = "用户名或密码密码有误"
    return render_template('login.html', form=form, emsg=emsg)

分析下视图函数的逻辑:

  • 视图函数同时支持 GET 和 POST 方法
  • form.validate_on_submit() 可以判断用户是否完整的提交了表单,只对 POST 有效,所以可以用来判断请求方式
  • 如果是 POST 请求,获取提交数据,通过 get_user 方法查找是否存在该用户
  • 如果用户存在,则创建用户实体,并校验登录密码
  • 校验通过后,调用 login_user 方法创建用户 Session,然后跳转到请求参数中 next 所指定的地址或者首页 (不用担心如何设置 next,还记得上面设置的 login_manager.login_view = 'login' 吗? 对,未登录访问时,会跳转到 login,并且带上 next 查询参数)
  • 非 POST 请求,或者未经过验证,会显示 login.html 模板渲染后的结果

前台

在 templates 模板下创建登录页面的模板 login.html:

{% macro render_field(field) %} <!-- 定义字段宏 -->
  <dt>{{ field.label }}:
  <dd>{{ field(**kwargs)|safe }}
  {% if field.errors %}
    <ul class=errors>
    {% for error in field.errors %}
      <li>{{ error }}</li>
    {% endfor %}
    </ul>
  {% endif %}
  </dd>
{% endmacro %}

<!-- 登录表单 -->
<form method="POST">
    {{ form.csrf_token }}
    {{ render_field(form.username) }}
    {{ render_field(form.password) }}
    {% if emsg %}  <!-- 如果有错误信息 则显示 -->
        <h3> {{ emsg }}</h3>
    {% endif %}
    <input type="submit" value="登录">
</form>
  • render_field 是 Jinja2 模板引擎的宏,接受表单字段将其渲染成 Html 代码,并格式化错误信息
  • emsg 错误信息单独做了处理,如果存在会显示出来
  • form 中并没有 action 属性,默认为当前路径

需要验证的页面

为了方便演示,将首页作为需要验证的页面,通过验证将看到登录者欢迎信息,页面上还有个登出链接

首页视图函数 index:

from flask import render_template, url_for
from flask_login import current_user, login_required
# ...
@app.route('/')  # 首页
@login_required  # 需要登录才能访问
def index():
    return render_template('index.html', username=current_user.username)
  • 注解 @login_required 会做用户登录检测,如果没有登录要方法此视图函数,就被跳转到 login 接入点( endpoint )
  • current_user 是当前登录者,是 User 的实例,是 Flask-Login 提供全局变量( 类似于全局变量 g )
  • username 是模板中的变量,可以将当前登录者的用户名传入 index.html 模板

首页模板 index.html:

<h1>欢迎 {{ username }}!</h1>
<a href='{{ url_for('logout')}}'>登出</a>

登出视图函数 logout:

from flask import redirect, url_for
from flask_login import logout_user
# ...
@app.route('/logout')  # 登出
@login_required
def logout():
    logout_user()
    return redirect(url_for('login'))
  • 只有登录了才有必要登出,所以加上注解 @login_required
  • logout_user 方法和 login_user 相反,由于注销用户的 Session
  • 登出视图不需要模板,直接跳转到登录页,实际项目中可以增加一个登出页,展示些有趣的东西

小试牛刀

终于可以试试了,加上启动代码:

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

启动项目,如果一切正常将看到类似的反馈

用户注册

上面的演示了,已存在用户登录的情况,不存在用户需要完成注册才能登录。

注册功能和登录很类似,页面上多了密码确认字段,并且需要验证两次输入的密码是否一致,后台逻辑是:如果用户不存在,且通过检验,将用户数据保存到 USERS 列表中,跳转到 login 页面。

Flask-Login 其他特性

上面的实例中使用了一些 Flask-Login 的基本特性,Flask-Login 还提供了一些其他重要特性

记住我

记住我,并不是用户登出之后,再次登录时自动填写用户名和密码(这是浏览器的功能),而是在用户意外退出后(比如关闭浏览器)不用再次登录。

如果用户本地的 cookie 失效了,Flask-Login 会自动将用户 Session 放入 cookie 中。

开启方法是将 login_user 方法的命名参数 remember 设置为 True,此功能默认是关闭的

Session 防护

Session 信息一般存放在 cookie 中,但是 cookie 不够安全,容易被窃取其中 Session 信息,伪造用户登录系统,幸运的是 Flask-Login 提供了 Session 防护机制,提供有 basic 和 strong 两种保护等级,通过 login_manager.session_protection 来开关和设置等级,默认等级为 basic,如果设置为 None 将关闭 Session 防护机制。

在保护机制开启的情况下,每次请求会根据用户的特征(一般指有用户IP、浏览器类型生成的哈希码)与 Session 中的对比,如果无法匹配则要求用户重新登录,在强模式下( strong )一旦匹配失败会删除登录者 Session,以消除攻击者重构 cookie 的可能

Request Loader

有时候因为一些原因不想或者无法使用 cookie,可以将 Session 记录在其他地方,比如 Header 中或者请求参数中,那么构造用户 Session 时就需要将 user_loader 替换为 request_loader, request_loader 将 request 作为参数,这样就可以从请求的任何数据中获取 Session 信息了

Flask-RESTful Api

现在单页 Web 项目很流行,使用各种 Js 框架,通过 Ajax 和服务器的 Api 进行交互,实现类似原生 app 效果,很酷,对 Flask 来说小菜一碟,是时候了解下 Flask-RESTful 了,官方文档:快速入门 — Flask-RESTful 0.3.1 documentation

开始前先了解下 RESTful,阮一峰有这样的解释:

网络应用程序,分为前端和后端两个部分。当前的发展趋势,就是前端设备层出不穷(手机、平板、桌面电脑、其他专用设备……)。 因此,必须有一种统一的机制,方便不同的前端设备与后端进行通信。这导致API构架的流行,甚至出现”API First”的设计思想。RESTful API是目前比较成熟的一套互联网应用程序的API设计理论

也就是说 RESTful 一个框架和互联网应用的设计原则,遵循这个设计原则,可以让应用脱离前台展现的束缚,支持不同的前端设备。

Flask 的 RESTful 模块是 flask-restful ,使用 pip 安装:

pip install flask-restful

如果安装顺利,可以在 Python Shell 环境下导入

from flask_restful import Api

实例

安装好后,简单试试。 flask-restful 像之前的 bootstrop-flask 以及 flask-sqlalchamy 模块一样,使用前需要对 Flask 应用进行初始化,然后会得到当前应用的 api 对象,用 api 对象进行资源绑定和路由设置:

from flask import Flask
from flask_restful import Api, Resource

app = Flask(__name__)

api = Api(app)  # 初始化得到 api 对象

#上面代码中从 flask_restful 中引入的 Resource 类是用来定义资源的,具体资源必须是 Resource 的子类,下面定义一个 HelloRESTful 资源:
class HelloRESTful(Resource):
    def get(self):
        return {'greet': 'Hello Flask RESTful!'}
    
    def post(self):
        data = json.loads(request.get_data())
        name = data.get('data')
        return name

#给资源绑定 URI:    
api.add_resource(HelloRESTful, '/')

if __name__ == '__main__':   # 别忘了启动应用的代码
    app.run(debug=True)

在终端或者命令行下运行 python app.py 启动应用,访问 127.0.0.1:5000 查看效果,将会看到 JSON 格式的数据输出:

{
  "greet": "Hello Flask RESTful!"
}

也可以用 curl 工具在终端或者命令行下发送请求:

# -s 开启安静模式
curl http://localhost:5000 -s

curl -H "Content-Type:application/json" -X POST -d '{"data":12}' http://127.0.0.1:8000/TestApi
{
  12
}

资源

从上面代码中可以看到,资源是 Resource 类的子类,以请求方法( GET、POST 等)名称的小写形式定义的方法,能对对应方法的请求作出相应,例如上面资源类中定义的 get 方法可以对 GET 请求作出相应,还可以定义 putpostdelete 等,称之为视图方法。

例如创建一个 todo 字样,支持获取代办事项和新增代办事项:

# 初始化待办列表
todos = {
  'todo_1': "读《程序员的自我修养》",
  'todo_2': "买点吃的",
  'todo_3': "去看星星"
}
class Todo(Resource):
    # 根据 todo_id 获取代办事项
    def get(self, todo_id):
        return { todo_id: todos[todo_id] }

    # 新增一个待办事项
    def put(self, todo_id):
        todos[todo_id] = request.form['data']
        return {todo_id: todos[todo_id]}
  • 通过 GET 方式,提供 todo_id, 从 todos 列表中获取待办事项内容
  • 通过 PUT 方式,提供 todo_id, 从请求体中获取到内容,作为待办事项内容
  • 两种方法都返回 todo_id 所对应的待办事项内容

为 Todo 资源指定 URI:

api.add_resource(Todo, '/todo/<string:todo_id>/')

启动项目,用 curl 工具测试:

# 读取 key 为 todo_1 的待办事项
 curl http://localhost:5000/todo/todo_1/
{
    "todo_1": "\u8bfb\u300a\u7a0b\u5e8f\u5458\u7684\u81ea\u6211\u4fee\u517b\u300b"
}

# 创建一个 key 为 todo_4 的代办事项
curl http://localhost:5000/todo/todo_4/ -d "data=学习 Flask" -X PUT

{
    "todo_4": "\u5b66\u4e60 Flask"
}

Flask-RESTful 支持多种视图方法的返回值:

class Todo1(Resource):
    def get(self):
        # 直接返回
        return { 'task': 'Hello world'}

class Todo2(Resource):
    def get(self):
        # 返回内容及状态码
        return {'task': 'Hello world'}, 201

class Todo3(Resource):
    def get(self):
        # 返回内容,状态码以及 Header
        return {'task': 'Hello world'}, 200, {'Etag': 'some-opaque-string'}

为三个资源指定 URI:

api.add_resource(Todo1, '/todo_1/')
api.add_resource(Todo1, '/todo_2/')
api.add_resource(Todo1, '/todo_3/')

启动项目后,用 curl 工具来测试:

curl http://localhost:5000/todo_1/
{
    "task": "Hello world"
}

# -请求 todo_2 并显示出 HTTP 标头,HTTP 状态码为 201
curl http://localhost:5000/todo_2/ -i
HTTP/1.0 201 CREATED
Content-Type: application/json
Content-Length: 30
Server: Werkzeug/0.16.0 Python/3.7.5rc1
Date: Thu, 31 Oct 2019 14:12:54 GMT

{
    "task": "Hello world"
}

# -请求 todo_3 并显示出 HTTP 标头,HTTP 状态码为 200 ,标头中还有 Etag
curl http://localhost:5000/todo_3/ -i
HTTP/1.0 200 OK
Content-Type: application/json
Content-Length: 30
Etag: some-opaque-string
Server: Werkzeug/0.16.0 Python/3.7.5rc1
Date: Thu, 31 Oct 2019 14:14:57 GMT

{
    "task": "Hello world"
}

路由

从上面可以看到,通过 api.add_resource 方法来为资源设置路由。第一个参数是资源类,第二个参数是路由,和之前介绍的 @app.route 注解参数一样

可以为一个资源制定多个路由,例如:

api.add_resource(Todo, '/todo/', '/mytodo/')

http://localhost:5000/todo/ 和 http://localhost:5000/mytodo/ 都将指向 Todo

既然路由,就应该有 endpoint,通过命名参数 endpoint 指定:

api.add_resource(Todo, '/todo/', endpoint='todo_ep')

设置路由的 endpoint 为 todo_ep,如果不指定,endpoint 就是资源类名的小写形式

endpoint 是 Flask 中对具体路由的内部的具体定义,一般作为 url_for 方法的第一个参数,即通过 endpoint 获得该路由的 URL,在列出 RESTful 资源 URL 时非常有用。

请求解析

RESTful 服务器对请求数据有很强的依赖,就请求数据的获取及校验是很繁琐的事情,还好 Flask-RESTful 提供了非常好的请求解析工具 reqparse,不仅可以获取请求数据,还可以对数据进行校验并返回合适的错误消息。

from flask_restful import reqparse  # 引入 reqparse 模块
# ...

parser = reqparse.RequestParser()  # 定义全局的解析实体
# 定义参数 id,类型必须是整数
parser.add_argument('id', type=int, help='必须提供参数id且类型正确')
# 定义参数 name,且为必填
parser.add_argument('name', required=True)
# ...

class Reqparser(Resource):
    def get(self):
        args = parser.parse_args()  # 获取解析器中定义的参数 并校验
        return args

api.add_resource(Reqparser, '/reqparser/')  # 指定路由

看下效果:

# 提供一个非整数参数 id
 curl http://localhost:5000/reqparser/ -d "id=noint" -X GET
{
    "message": {
        "id": "必须提供参数 id且类型正确"
    }
}

# 不提供参数 name
curl http://localhost:5000/reqparser/
{
    "message": {
        "name": "Missing required parameter in the JSON body or the post body or the query string"
    }
}
#必须提供两个参数,且类型正确
http://127.0.0.1:5000/reqparser?name=Ads&id=123
{
    "id": 123,
    "name": "Ads"
}
  • 当参数校验失败,自动返回 400 状态码,以及错误信息,通过命名参数 help 设置错误信息,不提供会有默认信息,如比选参数 name 的错误信息。
  • 默认情况下有多个参数错误,会以定义参数的顺序,逐个显示错误,定义解析器时将 bundle_errors 设置为 True,则可显示多个错误,如 parser = reqparse.RequestParser(bundle_errors=True),或者设置应用配置,如 app.config['BUNDLE_ERRORS'] = True
  • 默认情况下参数都是从请求表单中获取,定义参数时命名参数 location 可以指定从 form、headers、args(即 querystring)还是从 cookies 等中获取,如 parser.add_argument('id', type=int, help='必须提供参数 id', location='args')

请求解析器支持继承,可以定义最高级别的解析器,逐渐细化,最后应用的具体资源上:

from flask_restful import reqparse

parser = reqparse.RequestParser()
parser.add_argument('foo', type=int)

parser_copy = parser.copy()  # 继承
parser_copy.add_argument('bar', type=int)  # parser_copy 将有两个参数

# 改变继承来的参数 foo 必填且的获取位置为 querystring
parser_copy.replace_argument('foo', required=True, location='args')

# 删除继承来的参数 foo
parser_copy.remove_argument('foo')

格式化输出

请求解析处理用收到的信息,对于输入的信息也可以处理,通过 Flask-RESTful 提供的类 fields 和注解 marshal_with 来实现:

from flask_restful import Resource, fields, marshal_with

resource_fields = {
    'name': fields.String,
    'address': fields.String,
    'date_updated': fields.DateTime(dt_format='rfc822'),
}

class TodoFormat(Resource):
    @marshal_with(resource_fields, envelope='resource')
    def get(self):
        return db_get_todo()  # 某个获得待办事项的方法
  • 定义一个字段格式化模板,属性用 fields 的类型方法定义
  • 在响应方法上加上 marshal_with 注解,指定格式化模板,和封装属性名

格式化模板属性名,需要在响应函数返回的对象属性中匹配,如果需要会要对字段重命名,可以这样:

fields = {
    # name 将被重命名为 private_name
    'name': fields.String(attribute='private_name'),
    'address': fields.String
}

返回值中没有可以定义默认值:

fields = {
    # 为 name 设置默认值
    'name': fields.String(default='Anonymous User'),
    'address': fields.String
}
  • 3
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值