{{% admonition abstract 摘要 %}}
记载了「通往全栈之路上」的一则edx慕课作业:用Flask框架写一个图书查询的web应用。
【honor code警告】如果你刚巧也注册了这门课,千万不要抄。
{{% /admonition %}}
[成品效果视频](https://v.youku.com/v_show/id_XNDQzMzA5MzQ4OA==.html?spm=a2hzp.8244740.0.0) @ 优酷:
## 缘起
出于不可自拔的技能焦虑,跑到edx上撸起了课——其实论技术课程,Udacity比edx和Coursera要好,但edx也不赖(就是国内访问越来越困难,经常加载不出来)——撸到一门偏前端的纯码农培训课。说出来吓死人,哈佛**【继续教育学院】**(对,就是范玮琪读的那个哈佛)开的[用Python和Javascript撸网络编程](https://courses.edx.org/courses/course-v1:HarvardX+CS50W+Web/course/)。它的主要卖点是教小白怎么用Flask框架搭网站,就是号称一个 .py + 一个 .html就能欢快地跑出 hello world 来的小快灵建站利器。
想想看,上一个号称小快灵的神器还是PHP呢。那都是二十年前的事儿了。
Flask最好的好处是可以多快好省地做网站,迅速实现一个原型或弄出一个0.1功能版本。若将来再学一点小程序啥的,起码搞起数据科学工程产品来,能派上一丢丢的用场——也算走向「全网没人肯要的中老年」全栈工程师的第一步罢。
事实很打脸。这个作业只是整个课程的五大作业里的一个,我拿出所有业余时间埋头苦干,做了足足两个礼拜。以这个效率去搬砖,你猜老板会用什么武功揍我?
## 作业要求
[要求原文在此](https://docs.cs50.net/web/2019/x/projects/1/project1.html):做个图书+书评查询网络应用。大致要求:
1. 首先,自己想辙把压缩包里的[book.csv](https://cdn.cs50.net/web/2019/x/projects/1/project1.zip) 5000条图书信息导进数据库里。
1. 到[heroku](https://www.heroku.com)上注册创建数据库实例,订阅一个乞丐版就行;
2. 数据库用PostgreSQL
2. 然后,该应用要有以下功能
1. 能注册
2. 能登录
3. 能注销
4. 能根据ISBN、书名、作者查询书籍
5. 能点进具体一本书里
1. 除了固有信息,还要利用Goodreads的API获取平均评分
2. 能看到其他用户发的书评和评级
6. 能发书评和评级,但一个用户只能发一次
7. 能暴露API给人家用,返回一个JSON串
## 准备
### 数据库
[heroku](https://www.heroku.com)已经被salesforce.com买了,以傻瓜式建站和贵著称。我也跑上去建了一个实例,但是要翻墙。反正就是个作业,哪家SQL不是SQL?所以我就本地弄了个sqlite数据库`db.db`。
建三张表,`mbr`,`book`和`review`。`review`表里`mbr_id`和`book_id`分别外键关联到`mbr`和`book`。
{{% admonition question "为啥不用'user'?" %}}
因为postgresql不同意我用这个表名,所以在sqlite里也这么干。
{{% /admonition %}}
{{% figure class="center" src="https://gh-1251443721.cos.ap-chengdu.myqcloud.com/2019/0916/db.png" title="图 | 数据库设计" %}}
```sql
CREATE TABLE IF NOT EXISTS book (
id INTEGER PRIMARY KEY,
isbn TEXT NOT NULL,
title TEXT NOT NULL,
author TEXT NOT NULL,
year INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS mbr (
id INTEGER PRIMARY KEY,
username TEXT NOT NULL,
pwd TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS review (
id INTEGER PRIMARY KEY,
rev_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now')),
mbr_id INTEGER NOT NULL,
book_id INTEGER NOT NULL,
rating INTEGER,
review TEXT
);
```
{{% admonition info "备注" %}}
以上是sqlite的建表DDL,如果用PostgreSQL,语句略有不同。
{{% /admonition %}}
再把`books.csv`里的数据导进去。
我是坚定的pandas粉,所以直接把csv读进pandas再一口气灌进sqlite里。面对postgresql我也这么干。传统的方法是用csv包一行一行扫描,再写入数据库。我是向量运算的刀山火海里捶打出来的,轻易才不用循环。
[import_local.py](https://github.com/madlogos/edx_cs50/blob/master/project1/import_local.py)部分代码如下。如果用heroku上的PostgreSQL,则用[import.py](https://github.com/madlogos/edx_cs50/blob/master/project1/import.py)。
```python
# import_local.py
import sqlite3
import pandas as pd
conn = sqlite3.connect('db.db')
cur = conn.cursor()
if cur.execute('select count(*) from book;').fetchone()[0] == 0:
df = pd.read_csv(r'books.csv')
df.to_sql(name='book', con=conn, if_exists='append', index=False)
cur.close()
conn.close()
```
### 其它
当然,python那边得把python装好。我就直接用anaconda了。此外,需要把`Flask`,`Flask-Session`,`psycopg2-binary`和`SQKAlchemy`几个包都装上,`conda`/`pip`爱谁谁。
[Goodreads](www.goodreads.com)是个书评网站(也被墙了-_-||)。需要自己上去注册账号,申请API开发密钥。
## 开工
### 项目结构
{{% admonition info "源代码托管于Github" %}}
戳这里看源码
{{% /admonition %}}
```text
project1
|-- application.py
|--+ static
| |--+ css
| | |-- style.css
| | `-- star-rating.min.css
| `--+ js
| |-- main.js
| `-- star-rating.min.js
|--+ templates
| |-- base.html
| |-- book.html
| |-- index.html
| |-- login.html
| `-- register.html
`-- db.db
```
这个应用比较简单,所以结构很扁平。
- application.py是后端,所有后台功能代码都写在上面。
- static文件夹放静态文件,css和js这种(叫assets也行)。
- js: 放了自定义的main.js,和用于打分评级的star-rating的js
- css: 放了自定义的style.css,和star-rating的css
- templates文件夹放各类html模板,这些模板都是用html+jinja2语法写的宏。包括基础模板base.html和几个衍生的功能性模板
### 基础模板
[base.html](https://github.com/madlogos/edx_cs50/blob/master/project1/templates/base.html)是框架模板。简单写一下。
- 样式主要靠Bootstrap
- body部分放了几个通用块(block):head, flash, disp, control, misc。用jinja2结构`{% block xxx %}{% endblock %}`来占位。
- 块里面基本都没有进一步定义。只是给导航条加了点功能,如果当前线程有用户登着,就显示个注销按钮,否则就没有。
- flash块比较特别,定义了一个比较通用的flash渲染宏,到时候只需要在后台.py里套用`flash`函数就能实现告警框。
- 后续写其他模板时,引用(extend) base.html就行了。
```html
{% block title %}{% endblock %}
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
{{ message }}
{% endfor %}
{% endif %}
{% endwith %}
{% block control %}
{% endblock %}
{% block disp %}
{% endblock %}
{% block misc %}
{% endblock %}
```
对应地,在application.py里定义一些基本代码。
- "db.db"被export到环境变量`DATABASE_URL`,为了避免thread不一致问题,写成"sqlite:////absolute/path/to/db?check_same_thread=false"。
- application.py被export到环境变量`FLASK_APP`,方便后面直接命令行`flask run`启动应用。
```python
# -*- coding: UTF-8 -*-
# application.py
import os
import requests
from flask import Flask, flash, jsonify, render_template, request, \
redirect, session, url_for
from jinja2 import Markup
from flask_session import Session
from sqlalchemy import create_engine
from sqlalchemy.orm import scoped_session, sessionmaker
app = Flask(__name__, static_folder='static')
# Check for environment variable
if not os.getenv("DATABASE_URL"):
raise RuntimeError("DATABASE_URL is not set")
# Configure session to use filesystem
app.config["SESSION_PERMANENT"] = False
app.config["SESSION_TYPE"] = "filesystem"
Session(app)
# Set up database
engine = create_engine(os.getenv("DATABASE_URL"))
db = scoped_session(sessionmaker(bind=engine))
sess = db()
@app.teardown_request
def remove_session(ex=None):
db.remove()
```
### 登录
访问首页,先跳转到登录界面。
{{% figure class="center" src="https://gh-1251443721.cos.ap-chengdu.myqcloud.com/2019/0916/sign-in.png" title="图 | 登录界面" %}}
登录页[login.html](https://github.com/madlogos/edx_cs50/blob/master/project1/templates/login.html)很简单,首先继承base.html的元素,然后在control块里放一个`form-signin`控件。套了一些bootstrap的元素。action绑定sign_in,也就是`signin()`函数。
对前端技术不熟,丑得很。
```html
{% extends "base.html" %}
{% block title %}
Sign In
{% endblock %}
{% block control %}
{% endblock %}
```
[application.py](https://github.com/madlogos/edx_cs50/blob/master/project1/application.py)里,后台部分写两个路由函数。
主路由下,如果当前session没有用户登录,就转跳去登录页/login,否则直接进书籍列表页/index。
```python
# application.py
@app.route("/", methods=['GET'])
def home():
"""Home page
"""
if session.get('act_user') is None:
return redirect(url_for("sign_in"))
else:
return render_template("index.html", act_user=session.get('act_user'))
```
如果进登录页,那么'GET'方法下跟主路由差不多逻辑,'POST'方法下(点按钮触发POST),就要校验用户名密码了。成功就进书籍列表,假如不对,就`flash`一个错误来。利用application.py里定义的`sign_in()`函数和模板form中的`url_for()`函数,就把后端功能绑定到前端了。
```python
# application.py
@app.route("/login", methods=['GET', 'POST'])
def sign_in():
"""Sign in
"""
if request.method == 'GET':
if session.get('act_user') is None:
return render_template(
"login.html", act_user=session.get("act_user"))
else:
return render_template(
"index.html", act_user=session.get("act_user"))
elif request.method == 'POST':
username = request.form.get('username')
pwd = request.form.get('password')
mbrinfo = sess.execute(
"""SELECT id, username FROM mbr WHERE username = :username
AND pwd = :pwd;""",
{"username": username, "pwd": pwd}).fetchone()
if mbrinfo is None:
flash(Markup(
"""
User not exist or wrong password."""),
'danger')
else:
session['act_user'] = {'id': mbrinfo[0], 'username': mbrinfo[1]}
return home()
```
Flask是用SQLAlchemy的。SQLAlchemy是很高效的ORM工具,坊间一直认为它好过Django自带的ORM。不过这门课要求不用ORM,直接硬写SQL。当然,不是直接字符串拼接那么土,还是套了一个模板,变量装在字典里映射过去。这样能避免SQL注入攻击,算基本操作了。
### 注销
有登陆就有注销。反正base.html里注销按钮已经绑定了logout路由,所以只要定义logout路由的后台绑定函数就行了。登出后,清空`session['act_use']`对象,回到登录页。
```python
# application.py
@app.route('/logout', methods=['GET'])
def sign_off():
session.pop('act_user', None)
flash(Markup(
"""
You have logged out."""), 'success')
return home()
```
### 注册
{{% figure class="center" src="https://gh-1251443721.cos.ap-chengdu.myqcloud.com/2019/0916/register.png" title="图 | 注册页" %}}
注册页[register.html](https://github.com/madlogos/edx_cs50/blob/master/project1/templates/register.html)和登录页差不多。
```html
{% extends "base.html" %}
{% block title %}
Sign Up
{% endblock %}
{% block control %}
{% endblock %}
```
后端分别对'GET'和'POST'两种方法做了定义。GET的话,渲染注册页模板而已。POST的话,就比较pwd和repwd的值是否相同,然而提交执行INSERT操作。继续转眺回登录页。
考究点的话当然还要有反机器人的措施。我是那种考究的人嘛?作业又没这要求,就不贴金了。
```python
# application.py
@app.route("/signup", methods=['GET', 'POST'])
def sign_up():
"""Sign up
"""
if request.method == 'GET':
return render_template("register.html", act_user=None)
elif request.method == 'POST':
username = request.form.get('username')
pwd = request.form.get('password')
repwd = request.form.get('repassword')
mbrinfo = sess.execute(
"""SELECT id, username FROM mbr WHERE username = :username;""",
{"username": username}).fetchone()
if mbrinfo is not None:
flash(Markup(
"""
The username has been registered. Please change one."""),
'warning')
return redirect(url_for('sign_up'))
else:
if pwd == repwd:
sess.execute(
'INSERT INTO mbr (username, pwd) VALUES (:username, :pwd);',
{'username': username, 'pwd': pwd})
sess.commit()
flash(Markup(
"""
You have successfully created a new account. Now sign in."""),
'success')
return redirect(url_for("sign_in"))
else:
flash(Markup(
"""
You did not input the same password."""), 'danger')
return redirect(url_for('sign_up'))
```
### 检索书籍
{{% figure class="center" src="https://gh-1251443721.cos.ap-chengdu.myqcloud.com/2019/0916/books.png" title="图 | 初始载入空列表" %}}
登录进去后,进入真正的[index.html](https://github.com/madlogos/edx_cs50/blob/master/project1/templates/index.html)页。通过三个文本框联合查询。在block disp部分,写一个jinja2宏循环,把books这个对象逐个解析出来填进表格里。如果什么条件都不给,那就会一口气查出5000条来。
此处应有分页。我用了一个插件Flask-Paginate来实现。
#### 安装Flask-Paginate
Flask虽然好上手,但什么功能都要自己写,比较上头。好在还是有好心人做了不少插件。比如这款小巧的[Flask_Paginate](https://pythonhosted.org/Flask-paginate/)。
```bash
pip install flask-paginate
```
#### 模板
[前端模板](https://github.com/madlogos/edx_cs50/blob/master/project1/templates/books.html)主要就是遍历books,把列表对象逐个填进表格td里。
```html
{% extends "base.html" %}
{% block title %}
Books
{% endblock %}
{% block control %}
{% endblock %}
{% block disp %}
{% if books|length > 0 %}
{{ pagination.info }}
{{ pagination.links }}