参考:
- Vue+Flask轻量级前端、后端框架,如何完美同步开发
- Vue 2.0 起步(3) 数据流vuex和LocalStorage实例 - 微信公众号RSS
- Vue 2.0 起步(2) 组件及vue-router实例 - 微信公众号RSS
- Vue 2.0 起步(1) 脚手架工具vue-cli + Webstorm 2016 + webpack
本篇实现
- Flask框架搭建
- 后端用户注册、认证 (注意:改用Flask-Security了:http://www.jianshu.com/p/f37871e31231)
- 跨域(Access-Control-Allow-Origin)本地调试
- 表单验证validation
Demo(http://vue2.heroku.com)
使用RESTful思想,互联网应用的后端仅提供API接口来提供鉴权服务和资源,前端Vue使用Ajax访问获取数据并显示。
MVVM模型 - 这里Flask担任Model(数据模型)、View(路由)角色,Vue担任VM(ViewModel视图模型)角色。
工具选择
- 后端:使用Flask这一广受好评的Python微(Micro)框架,非常适合快速开发。当然,使用Node.js、PHP、Java等等其它语言,思路也是大同小异的。Flask被称为“micro framework”,是因为它使用简单的核心,用extension增加其他功能。Flask没有默认使用的数据库、窗体等等验证工具。然而,Flask保留了扩增的弹性,可以用Flask-extension加入这些功能:ORM、窗体验证工具、文件上传、各种开放式身份验证技术。是不是跟vue很像啊?
Flask最基本的hello-world,用几行代码、一个文件就能实现。但为了适应大型项目扩展,跟vue-cli创建的脚手架类似,Flask也有推荐的项目典型目录,结构如下:
vue-tutorial/
|--app/
|--api_1_0/ # api目录,对于REST访问返回数据
|--users.py
|--main/
|--views.py # 路由文件,SPA里,只需要返回"/"根路由
|--static/ # js, css
|--templates/ # SPA里,只需要index.html
|--__init__.py # flask app初始化
|--models.py # model数据库定义
|--config.py # Flask配置
|--manage.py # Flask启动文件,包含命令行
- 用户鉴权:使用JWT(JSON Web Token)。本实例是SPA(单页面应用),前端vue-router插件已经实现路由功能,后端Flask只需要提供api接口就行。所以不需要使用Flask_login来管理session。JWT是一个非常轻巧的规范,允许我们使用JWT在用户和服务器之间传递安全可靠的信息。
(注意:改用Flask-Security了:http://www.jianshu.com/p/f37871e31231)
1. Flask框架搭建
请先下载项目源码,对照源码阅读和实践会效率更高
首先,设计一个结构清晰的关系型数据库(Model):
- User用户表,肯定是要有的,记录用户名、密码Hash和他关注的公众号
- Mp公众号表,记录哪些人关注了这个公众号,以及这个公众号有哪些文章
- Article文章表,相对简单,记录公众号文章
关系:
- Mp和Article是一对多的关系
- User和Mp,看起来像一对多,但其实是多对多的关系:一个用户关注多个公众号,同一个公众号也可能被多个用户共同关注,所以需要另加一张“关联表” - Subscription
- User和Subscription:一对多
- Mp和Subscription:一对多
下面就来创建model,在Flask models.py实现。
/app/models.py
- Subscription:关联到User和Mp两个表
# encoding: utf-8
from datetime import datetime
import hashlib
from werkzeug.security import generate_password_hash, check_password_hash
from flask import current_app, request, url_for, jsonify
from flask_login import UserMixin, AnonymousUserMixin
from . import db
# 订阅公众号和User是多对多关系
class Subscription(db.Model):
__tablename__ = 'subscriptions'
# follower_id
subscriber_id = db.Column(db.Integer, db.ForeignKey('users.id'),
primary_key=True)
# followed_id
mp_id = db.Column(db.Integer, db.ForeignKey('mps.id'),
primary_key=True)
subscribe_timestamp = db.Column(db.DateTime, default=datetime.utcnow)
- User:功能最为复杂。
- 关联到Subscription表
- password.setter:用户注册时,密码转化为hash存储。永远不要存储密码明文!
- subscribed_mps方法:用过滤器和联结查询,返回该用户订阅的所有公众号
class User(UserMixin, db.Model):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(64), unique=True, index=True)
password_hash = db.Column(db.String(128))
member_since = db.Column(db.DateTime(), default=datetime.utcnow)
last_seen = db.Column(db.DateTime(), default=datetime.utcnow)
mps = db.relationship('Subscription',
foreign_keys=[Subscription.subscriber_id],
backref=db.backref('subscriber', lazy='joined'),
lazy='dynamic',
cascade='all, delete-orphan')
@property
def password(self):
raise AttributeError('password is not a readable attribute')
@password.setter
def password(self, password):
self.password_hash = generate_password_hash(password)
def verify_password(self, password):
return check_password_hash(self.password_hash, password)
@property
def subscribed_mps(self):
# SQLAlchemy 过滤器和联结
return Mp.query.join(Subscription, Subscription.mp_id == Mp.id)\
.filter(Subscription.subscriber_id == self.id)
def __repr__(self):
return '<User %r>' % self.username
- Mp:
- 关联到Subscription表
- to_json方法:在REST访问时,用来返回json格式的公众号数据
# 公众号
class Mp(db.Model):
__tablename__ = 'mps'
id = db.Column(db.Integer, primary_key=True)
weixinhao = db.Column(db.Text)
image = db.Column(db.Text)
summary = db.Column(db.Text)
sync_time = db.Column(db.DateTime, index=True, default=datetime.utcnow)
articles = db.relationship('Article', backref='mp', lazy='dynamic')
subscribers = db.relationship('Subscription',
foreign_keys=[Subscription.mp_id],
backref=db.backref('mp', lazy='joined'),
lazy='dynamic',
cascade='all, delete-orphan')
def to_json(self):
json_mp = {
'weixinhao': self.weixinhao,
'image': self.image,
'summary': self.summary,
'articles_count': self.articles.count()
}
return json_mp
- Article:最为简单
- 关联到Mp表
- to_json方法:在REST访问时,用来返回json格式的文章数据
# 公众号的文章
class Article(db.Model):
__tablename__ = 'articles'
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.Text)
image = db.Column(db.Text)
summary = db.Column(db.Text)
url = db.Column(db.Text)
timestamp = db.Column(db.DateTime, index=True, default=datetime.utcnow)
mp_id = db.Column(db.Integer, db.ForeignKey('mps.id'))
def to_json(self):
json_article = {
'url': url_for('api.get_comment', id=self.id, _external=True),
'body': self.body,
'timestamp': self.timestamp
}
return json_article
第二步,在app初始化时,引用models.py,并创建JWT
/app/_init_.py
# encoding: utf-8
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_jwt import JWT
from config import config
db = SQLAlchemy()
# models引用必须在 db之后,不然会循环引用
from .models import User
# JWT鉴权:默认参数为username/password,在数据库里查找并比较password_hash
def authenticate(username, password):
print 'JWT auth argvs:', username, password
user = User.query.filter_by(username=username).first()
if user is not None and user.verify_password(password):
return user
# JWT检查user_id是否存在
def identity(payload):
print 'JWT payload:', payload
user_id = payload['identity']
user = User.query.filter_by(id=user_id).first()
return user_id if user is not None else None
# 创建jwt实例
jwt = JWT(authentication_handler=authenticate, identity_handler=identity)
def create_app(config_name):
app = Flask(__name__)
# 引入Flask用户配置
app.config.from_object(config[config_name])
config[config_name].init_app(app)
# 初始化数据库和JWT
db.init_app(app)
jwt.init_app(app)
# 注册main/api蓝本,这样用户访问路径“/xxx”指向main,“/api/v1.0”指向api
from .main import main as main_blueprint
app.register_blueprint(main_blueprint)
from .api_1_0 import api as api_1_0_blueprint
app.register_blueprint(api_1_0_blueprint, url_prefix='/api/v1.0')
return app
第三步,在启动文件中,创建命令行(数据库、部署、测试等等),启动app
/manage.py
#!/usr/bin/env python
import os
from app import create_app, db, jwt
from app.models import User, Subscription, Mp, Article
from flask_script import Manager, Shell
from flask_migrate import Migrate, MigrateCommand
app = create_app(os.getenv('FLASK_CONFIG') or 'default')
manager = Manager(app)
migrate = Migrate(app, db)
def make_shell_context():
return dict(app=app, db=db, User=User, Subscription=Subscription, Mp=Mp,
Article=Article)
manager.add_command("shell", Shell(make_context=make_shell_context))
manager.add_command('db', MigrateCommand)
@manager.command
def deploy():
"""Run deployment tasks."""
from flask_migrate import upgrade
from app.models import User
# migrate database to latest revision
upgrade()
if __name__ == '__main__':
manager.run()
好了,总算初始框架都完成了。看上去很复杂,但跟vue-cli脚手架工具一样,你下载项目源码,框架都是现成的(而且是成熟可靠的),稍微修改一下就能跑起来了。主要是数据库定义models.py,完全要自己来定!
Python的依赖模块安装:
跟<code>npm install</code> node_modules类似,直接用<code>pip install -r requirements.txt</code>就一步完成了。
现在数据库还没有,我们来创建吧,很简单,三行命令:
打开CMD(Windows),或Shell(Linux)
c:\git\vue-tutorial>python manage.py db init
Creating directory c:\git\vue-tutorial\migrations ... done
Creating directory c:\git\vue-tutorial\migrations\versions ... done
Generating c:\git\vue-tutorial\migrations\alembic.ini ... done
Generating c:\git\vue-tutorial\migrations\env.py ... done
Generating c:\git\vue-tutorial\migrations\env.pyc ... done
Generating c:\git\vue-tutorial\migrations\README ... done
Generating c:\git\vue-tutorial\migrations\script.py.mako ... done
Please edit configuration/connection/logging settings in 'c:\\git\\vue-tutorial\\migrations\\alembic.ini' before proceed
ing.
c:\git\vue-tutorial>python manage.py db migrate -m "init"
INFO [alembic.runtime.migration] Context impl SQLiteImpl.
INFO [alembic.runtime.migration] Will assume non-transactional DDL.
INFO [alembic.autogenerate.compare] Detected added table 'mps'
INFO [alembic.autogenerate.compare] Detected added index 'ix_mps_sync_time' on '['sync_time']'
INFO [alembic.autogenerate.compare] Detected added table 'users'
INFO [alembic.autogenerate.compare] Detected added index 'ix_users_username' on '['username']'
INFO [alembic.autogenerate.compare] Detected added table 'articles'
INFO [alembic.autogenerate.compare] Detected added index 'ix_articles_timestamp' on '['timestamp']'
INFO [alembic.autogenerate.compare] Detected added table 'subscriptions'
Generating c:\git\vue-tutorial\migrations\versions\599e99548c86_init.py ... done
c:\git\vue-tutorial>python manage.py db upgrade
INFO [alembic.runtime.migration] Context impl SQLiteImpl.
INFO [alembic.runtime.migration] Will assume non-transactional DDL.
INFO [alembic.runtime.migration] Running upgrade -> 599e99548c86, init
现在,数据库文件已经产生了,默认是config.py文件里定义的<code>/data-dev.sqlite</code>,打开来看看:
models.py里定义的表,都创建好了,是不是很清楚啊?比如:Subscription表,有两个Foreign_Key,指向Mp和User。
2. 后端用户注册、认证
回到Vue.js,我们准备把注册功能,放在右侧Siderbar.vue
- template第一部分:如果已经登录is_login,则显示用户头像和退出按钮
- template第二部分:如果没有登录,则显示一个表单,可以输入username/password,注册和登录两个按钮
# /src/components/Sidebar.vue (部分)
<template>
<div class="card">
<div v-if="is_login" class="card-header" align="center">
<img src="http://avatar.csdn.net/1/E/E/1_kevin_qq.jpg"
class="avatar img-circle img-responsive" />
<p><strong v-text="username"></strong>
<a href="javascript:" @click="logout()" title="退出">
<i class="fa fa-sign-out float-xs-right"></i></a>
</p>
</div>
<div v-else class="card-header" align="center">
<form class="form" @submit.prevent>
<div class="form-group">
<input class="form-control" name="username" type="text" placeholder="用户名" v-model="username"
required pattern="\w{3,12}" />
<p class="text-muted"><small>3~12位字母、数字、下划线</small></p>
</div>
<div class="form-group">
<input class="form-control" name= "password" type="password" placeholder="密码" v-model="password"
required pattern="\w{4,}"/>
<p class="text-muted"><small>至少4位,字母数字下划线</small></p>
</div>
<div class="form-group clearfix">
<input type="submit" @click="register()" class="btn btn-outline-danger float-xs-left" value="注册" />
<input type="submit" @click="login()" class="btn btn-outline-success float-xs-right" value="登录" />
</div>
</form>
</div>
。。。
</div>
</template>
- Script部分:加入新的data和methods
- methods.register(),用vue-resource post功能,提交username/password到Flask,由<code>/api/users.py</code>处理
# /src/components/Sidebar.vue (部分)
<script>
export default {
name : 'Sidebar',
data() {
return {
is_login: false,
username: '',
password: '',
token: ''
}
},
。。。
methods : {
register() {
this.$http.post('http://127.0.0.1:5000/api/v1.0/register',
//body
{ username: this.username,
password: this.password
},
//options
{
headers: {'Content-Type':'application/json; charset=UTF-8'}
} ).then((response) => {
// 响应成功回调
var data = response.body;
if (data.status=='success') {
alert('Success! ' + data.msg)
}
else {
alert(data.msg)
}
this.password = ''
}, (response) => {
// 响应错误回调
alert('注册出错了! '+ JSON.stringify(response))
});
}
</script>
- 后端处理register请求:
对于ajax post请求,上面是用json提交的,所以用<code>request.get_json</code>来得到数据。然后检查数据是否有效,用户名是否已经注册。一切无误的话就会添加用户到数据库。
# /app/api_1_0/users.py
@api.route('/register', methods=['GET', 'POST'])
def register():
username = request.get_json()['username']
password = request.get_json()['password']
print 'register Header: %s\nusername: %s, password:%s'% (request.headers, username, password)
if username <> '' and password <> '':
if User.query.filter_by(username=username).first():
return jsonify({
'status': 'failure',
'msg': u'用户名已被占用,换一个吧'
})
user = User(username=username, password=password)
db.session.add(user)
db.session.commit()
return jsonify({
'status': 'success',
'msg': 'register OK, please login!'
})
return jsonify({
'status': 'failure',
'msg': 'register fail, check username and password.'
})
Flask跑起来,用的是5000端口:
c:\git\vue-tutorial>python manage.py runserver
* Restarting with stat
* Debugger is active!
* Debugger pin code: 302-156-201
* Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
前端点击注册按钮,应该成功了!咦,怎么返回Bad Request (400)?
127.0.0.1 - - [15:25:09] "OPTIONS /api/v1.0/register HTTP/1.1" 400 -
我明明发的是POST,为什么服务器端说收到是的OPTIONS呢?哈哈,这就是著名的CORS跨域请求了!请看下一节的灵巧解决方案!
3. 跨域(Access-Control-Allow-Origin)本地调试
前端跨域Post请求,由于CORS(cross origin resource share)规范的存在,浏览器会首先发送一次Options嗅探,同时header带上origin,判断是否有跨域请求权限,服务器响应access control allow origin的值,供浏览器与origin匹配,如果匹配,浏览器则正式发送post请求。
如果有服务器程序权限,设置headeraccess control allow origin等于*,就可以允许前端跨域访问了。
我以前也是这么解决的:Flask: Ajax 设置Access-Control-Allow-Origin实现跨域访问
CORS深入
目前jsonp是最简单跨域方案,不过只能GET,不支持POST。如果要POST,则服务器端设置ACAO很麻烦,或用其它的绕路方法。
但是:
我们上一篇,不是有这个方案吗:Vue+Flask轻量级前端、后端框架,如何完美同步开发
这样就不存在跨域烦恼了,我们本身就在一个服务器(localhost:5000,包含端口号)下呀!
好了,马上来试试。上一篇的步骤是适用于最简的Flask项目,在这里有个小小改动,因为我们用了新的Flask目录框架,需要把修改后的index.html放入
/app/templates/index.html
同样,static文件放入新的目录:
/app/static/font-awesome/
再修改Siderbar.vue,POST不需要跨域了,直接是同一服务器+端口号上的路径<code>/api/v1.0/register</code>:
# /src/components/Sidebar.vue (部分)
<script>
。。。
methods : {
register() {
this.$http.post('/api/v1.0/register',
。。。
}
</script>
Flask跑起来,再点击“注册”,成功啦!
对应的Flask log:
register Header: Referer: http://localhost:5000/
Origin: http://localhost:5000
Content-Length: 41
User-Agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTM
537.36
Connection: keep-alive
X-Requested-With: XMLHttpRequest
Host: localhost:5000
Accept: application/json, text/plain, */*
Accept-Language: en-US,en;q=0.8,zh-CN;q=0.6,zh;q=0.4
Content-Type: application/json; charset=UTF-8
Accept-Encoding: gzip, deflate
username: hellovue, password:1111
127.0.0.1 - - [23/Dec/2016 12:16:03] "POST /api/v1.0/register HTTP/1.1" 200 -
想不到,我们一个小小改进,不仅前后端完美同步开发,而且还解决了CORS跨域问题!
OK,继续完成用户登录、登出功能,很简单,在Siderbar.vue里添加methods就行
- login():/auth是Flask-JWT默认的鉴权路由,鉴权方法已经在/app/_init_.py里写好了。如果登录成功,本地LocalStorage把得到的token保存下来,以后REST请求会用到这个。
- logout():is_login设成false,然后本地LocalStorage删除user(目的是删除保存的token)
# /src/components/Sidebar.vue (部分)
methods : {
login() {
this.$http.post('/auth',
//body
{ username: this.username,
password: this.password
},
//options
{
headers: {'Content-Type':'application/json; charset=UTF-8'}
} ).then((response) => {
// 响应成功回调
var data = response.body;
this.token = data.access_token;
this.is_login = true;
// alert(data.access_token);
var userData = {'username': this.username, 'token': this.token};
window.localStorage.setItem("user", JSON.stringify(userData))
}, (response) => {
// 响应错误回调
alert('登录出错了! '+ response.status+ response.statusText)
});
},
logout() {
this.is_login = false;
this.password = '';
this.token = '';
window.localStorage.removeItem("user")
},
4. 表单验证validation
表单验证是个很普遍的需求,如果用户不停地收到输入错误的告警,会很抓狂滴!所以,最好在提交前做好验证。
vue-validator是Vue全家桶里用到的表单验证插件,但适用于Vue2.0的版本迟迟没推出。
那就自己写一个简单的呗,几行代码而已。
对于HTML5,本身就有基本的表单验证功能,提交时浏览器会自动检查,但仅限于部分浏览器
- template input里,<code>required pattern="\w{3,12}"</code>是HTML5的功能
- 按钮上,绑定一个计算属性validation: <code>:disabled="!validation"</code>
- 提交事件: <form class="form" @submit.prevent>,阻止了默认submit事件,由vue方法接手
# /src/components/Sidebar.vue (部分)
<form class="form" @submit.prevent>
<div class="form-group">
<input class="form-control" name="username" type="text" placeholder="用户名" v-model="username"
required pattern="\w{3,12}" />
<p class="text-muted"><small>3~12位字母、数字、下划线</small></p>
</div>
<div class="form-group">
<input class="form-control" name= "password" type="password" placeholder="密码" v-model="password"
required pattern="\w{4,}"/>
<p class="text-muted"><small>至少4位,字母数字下划线</small></p>
</div>
<div class="form-group clearfix">
<input type="submit" @click="register()" class="btn btn-outline-danger float-xs-left"
value="注册" :disabled="!validation" />
<input type="submit" @click="login()" class="btn btn-outline-success float-xs-right"
value="登录" :disabled="!validation" />
</div>
- 计算属性validation,实时计算用户输入内容是否有效。比如:/(\w{3,12})/是判断是否为:3到12位的数字、字母、下划线。
- login/register方法:在post提交前,如果计算属性validation为false(输入有误),就不提交
# /src/components/Sidebar.vue (部分)
computed : {
validation() {
var patt1 = /(\w{3,12})/;
var patt2 = /(\w{4,})/;
// alert(this.username + patt1.test(this.username));
return patt1.test(this.username) && patt2.test(this.password)
}
},
methods : {
login() {
if (!this.validation) return;
this.$http.post('/auth',
。。。
舒了一口气,这篇写得时间较长,因为相当于把后端Flask启蒙了一遍。。。
后续的ajax保存、请求订阅列表,相对比较简单明了,大家有什么其它需求,请评论留言哦!
项目源码 https://github.com/kevinqqnj/vue-tutorial
请使用新的template: https://github.com/kevinqqnj/flask-template-advanced
TODO:
- 后端保存用户订阅的公众号,搜狗的链接都是临时的
- 公众号文章的更新,这个Python爬虫最拿手了