flask—website,是flask曾经的主站源码,使用flask制作,包含模版渲染,数据库操作,openID认证, 全文检索等功能。对于学习如何使用flask制作一个完备的web站点,很有参考价值,我们一起来学习它。

项目结构

flask-website已经归档封存,我们使用最后的版本8b08,包括如下几个模块:

模块 描述
run.py 启动脚本
websiteconfig.py 设置脚本
update-doc-searchindex.py 更新索引脚本
database.py 数据库模块
docs.py 索引文档模块
openid_auth.py oauth认证
search.py 搜素模块
utils.py 工具类
listings 一些展示栏
views 蓝图模块,包括社区,扩展,邮件列表,代码片段等
static 网站的静态资源
templates 网站的模版资源

flask-website的项目结构,可以作为flask的脚手架,按照这个目录规划构建自己的站点:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
.
├── LICENSE
├── Makefile
├── README
├── flask_website
│   ├── __init__.py
│   ├── database.py
│   ├── docs.py
│   ├── flaskystyle.py
│   ├── listings
│   ├── openid_auth.py
│   ├── search.py
│   ├── static
│   ├── templates
│   ├── utils.py
│   └── views
├── requirements.txt
├── run.py
├── update-doc-searchindex.py
└── websiteconfig.py
  • run.py作为项目的启动入口
  • requirements.txt描述项目的依赖包
  • flask_website是项目的主模块,里面包括:存放静态资源的static目录; 存放模版文件的templates目录;存放一些蓝图模块的views模块,使用这些蓝图构建网站的不同页面。

网站入口

网站的入口run.py代码很简单,导入app并运行:

1
2
from flask_website import app
app.run(debug=True)

app是基于flask,使用websiteconfig中的配置进行初始化

1
2
app = Flask(__name__)
app.config.from_object('websiteconfig')

app中设置了一些全局实现,比如404页面定义,全局用户,关闭db连接,和模版时间:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
@app.errorhandler(404)
def not_found(error):
    return render_template('404.html'), 404

@app.before_request
def load_current_user():
    g.user = User.query.filter_by(openid=session['openid']).first() \
        if 'openid' in session else None

@app.teardown_request
def remove_db_session(exception):
    db_session.remove()

@app.context_processor
def current_year():
    return {'current_year': datetime.utcnow().year}

加载view部分使用了两种方式,第一种是使用flask的add_url_rule函数,设置了文档的搜索实现,这些url执行docs模块:

1
2
3
4
5
app.add_url_rule('/docs/', endpoint='docs.index', build_only=True)
app.add_url_rule('/docs/<path:page>/', endpoint='docs.show',
                 build_only=True)
app.add_url_rule('/docs/<version>/.latex/Flask.pdf', endpoint='docs.pdf',
                 build_only=True)

第二种是使用flask的蓝图功能:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
from flask_website.views import general
from flask_website.views import community
from flask_website.views import mailinglist
from flask_website.views import snippets
from flask_website.views import extensions
app.register_blueprint(general.mod)
app.register_blueprint(community.mod)
app.register_blueprint(mailinglist.mod)
app.register_blueprint(snippets.mod)
app.register_blueprint(extensions.mod)

最后app还定义了一些jinja模版的工具函数:

1
2
3
4
app.jinja_env.filters['datetimeformat'] = utils.format_datetime
app.jinja_env.filters['dateformat'] = utils.format_date
app.jinja_env.filters['timedeltaformat'] = utils.format_timedelta
app.jinja_env.filters['displayopenid'] = utils.display_openid

模版渲染

现在主流的站点都是采用前后端分离的结构,后端提供存粹的API,前端使用vue等构建。这种结构对于构建小型站点,会比较复杂,有牛刀杀鸡的感觉。对个人开发者,还需要学习更多的前端知识。而使用后端的模版渲染方式构建页面,是比较传统的方式,对小型站点比较实用。

本项目就是使用模版构建,在general蓝图中:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
mod = Blueprint('general', __name__)

@mod.route('/')
def index():
    if request_wants_json():
        return jsonify(releases=[r.to_json() for r in releases])

    return render_template(
        'general/index.html',
        latest_release=releases[-1],
        # pdf link does not redirect, needs version
        # docs version only includes major.minor
        docs_pdf_version='.'.join(releases[-1].version.split('.', 2)[:2])
    )

可以看到首页有2种输出方式,一种是json化的输出,另一种是html方式输出,我们重点看看第二种方式。函数render_template传递了模版路径,latest_release和docs_pdf_version两个变量值。

模版也是模块化的,一般是根据页面布局而来。比如分成左右两栏的结构,或者上下结构,布局定义的模版一般叫做layout。比如本项目的模版就从上至下定义成下面5块:

  • head 一般定义html页面标题(浏览器栏),css样式/js-script的按需加载等
  • body_title 定义页面的标题
  • message 定义一些统一的通知,提示类的展示空间
  • body 页面的正文部分
  • footer 统一的页脚

使用layout模版定义,将网站的展示风格统一下来,各个页面可以继承和扩展。下面是head快和message块的定义细节:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
<!doctype html>
{% block head %}
<title>{% block title %}Welcome{% endblock %} | Flask (A Python Microframework)</title>
<meta charset=utf-8>
<link rel=stylesheet type=text/css href="{{ url_for('static', filename='style.css') }}">
<link rel="shortcut icon" href="{{ url_for('static', filename='favicon.ico') }}">
<script type=text/javascript
  src="http://ajax.googleapis.com/ajax/libs/jquery/1.4.2/jquery.min.js"></script>
{% endblock %}
<div class=box>
  ...
  <p class=nav>
    <a href="{{ url_for('general.index') }}">overview</a> //
    <a href="{{ url_for('docs.index') }}">docs</a> //
    <a href="{{ url_for('community.index') }}">community</a> //
    <a href="{{ url_for('extensions.index') }}">extensions</a> //
    <a href="https://psfmember.org/civicrm/contribute/transact?reset=1&id=20">donate</a>
  {% for message in get_flashed_messages() %}
    <p class=message>{{ message }}
  {% endfor %}
  ...

本项目首页的general/index继承自全局的layout,并对其中的body部分进行覆盖,使用自己的配置:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
{% extends "layout.html" %}
    ....
{% block body %}
  <ul>
    <li><a href="{{ latest_release.detail_url }}">Download latest release</a> ({{ latest_release.version }})
    <li><a href="{{ url_for('docs.index') }}">Read the documentation</a>
    <li><a href="{{ url_for('mailinglist.index') }}">Join the mailinglist</a>
    <li><a href=https://github.com/pallets/flask>Fork it on github</a>
    <li><a href=https://github.com/pallets/flask/issues>Add issues and feature requests</a>
  </ul>
  ...
  • 这个列表主要使用了蓝图中传入的latest_release变量,展示最新文档(pdf)的url

数据库操作

网站有交互,必定要持久化数据。本项目使用的sqlite的数据库,比较轻量级。数据库使用sqlalchemy封装的ORM实现。下面的代码展示了如何创建一个评论:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
@mod.route('/comments/<int:id>/', methods=['GET', 'POST'])
@requires_admin
def edit_comment(id):
    comment = Comment.query.get(id)
    snippet = comment.snippet
    form = dict(title=comment.title, text=comment.text)
    if request.method == 'POST':
        ...
        form['title'] = request.form['title']
        form['text'] = request.form['text']
        ..
        comment.title = form['title']
        comment.text = form['text']
        db_session.commit()
        flash(u'Comment was updated.')
        return redirect(snippet.url)
    ...
  • 创建comment对象
  • 从html的form表单中获取用户提交的title和text
  • 对comment对象进行赋值和提交
  • 刷新页面的提示信息(在模版的message部分展示)
  • 返回到新的url

借助sqlalchemy,数据模型的操作API简单易懂。要使用数据库,需要先创建数据库连接,构建模型等, 主要在database模块:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
DATABASE_URI = 'sqlite:///' + os.path.join(_basedir, 'flask-website.db')
# 创建引擎
engine = create_engine(app.config['DATABASE_URI'],
                       convert_unicode=True,
                       **app.config['DATABASE_CONNECT_OPTIONS'])
# 创建session(连接)                
db_session = scoped_session(sessionmaker(autocommit=False,
                                         autoflush=False,
                                         bind=engine))
# 初始化
def init_db():
    Model.metadata.create_all(bind=engine)

# 定义基础模型
Model = declarative_base(name='Model')
Model.query = db_session.query_property()

Comment数据模型定义:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class Comment(Model):
    __tablename__ = 'comments'
    id = Column('comment_id', Integer, primary_key=True)
    snippet_id = Column(Integer, ForeignKey('snippets.snippet_id'))
    author_id = Column(Integer, ForeignKey('users.user_id'))
    title = Column(String(200))
    text = Column(String)
    pub_date = Column(DateTime)

    snippet = relation(Snippet, backref=backref('comments', lazy=True))
    author = relation(User, backref=backref('comments', lazy='dynamic'))

    def __init__(self, snippet, author, title, text):
        self.snippet = snippet
        self.author = author
        self.title = title
        self.text = text
        self.pub_date = datetime.utcnow()

    def to_json(self):
        return dict(author=self.author.to_json(),
                    title=self.title,
                    pub_date=http_date(self.pub_date),
                    text=unicode(self.rendered_text))

    @property
    def rendered_text(self):
        from flask_website.utils import format_creole
        return format_creole(self.text)

Comment模型按照结构化的方式定义了表名,6个字段,2个关联关系和json化和文本化的展示方法。

sqlalchemy的使用,在之前的文章中有过介绍,本文就不再赘述。

openID认证

一个小众的网站,构建自己的账号即麻烦也不安全,使用第三方的用户体系会比较合适。本项目使用的是Flask-OpenID这个库提供的optnID登录认证。

用户登录的时候,会根据用户选择的三方登录站点,跳转到对应的网站进行认证:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
@mod.route('/login/', methods=['GET', 'POST'])
@oid.loginhandler
def login():
    ..
    openid = request.values.get('openid')
    if not openid:
        openid = COMMON_PROVIDERS.get(request.args.get('provider'))
    if openid:
        return oid.try_login(openid, ask_for=['fullname', 'nickname'])
    ..

从对应的模版上更容易理解这个过程, 可以看到默认支持AOL/Google/Yahoo三个账号体系认证:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
{% block body %}
  <form action="" method=post>
    <p>
      For some of the features on this site (such as creating snippets
      or adding comments) you have to be signed in.  You don't need to
      create an account on this website, just sign in with an existing
      <a href=http://openid.net/>OpenID</a> account.
    <p>
      OpenID URL:
      <input type=text name=openid class=openid size=30>
      <input type=hidden name=next value="{{ next }}">
      <input type=submit value=Login>
    <p>
      Alternatively you can directly sign in by clicking on one of
      the providers here in case you don't know the identity URL:
    <ul>
      <li><a href=?provider=aol>AOL</a>
      <li><a href=?provider=google>Google</a>
      <li><a href=?provider=yahoo>Yahoo</a>
    </ul>
  </form>
{% endblock %}

在三方站点认证完成后,会建立本站点的用户和openid的绑定关系:

1
2
3
4
5
6
7
@mod.route('/first-login/', methods=['GET', 'POST'])
def first_login():
    ...
        db_session.add(User(request.form['name'], session['openid']))
        db_session.commit()
        flash(u'Successfully created profile and logged in')
    ...
  • session中的openid是第三方登录成功后写入session

三方登录的逻辑过程大概就如上所示,先去三方平台登录,然后和本地站点的账号进行关联。其具体的实现,主要依赖Flask-OpenID这个模块, 我们大概了解即可。

全文检索

全文检索对于一个站点非常重要,可以帮助用户在网站上快速找到适合的内容。本项目展示了使用whoosh这个纯python实现的全文检索工具,构建网站内容检索,和使用ElasticSearch这样大型的检索库不一样。总之,本项目使用的都是小型工具,纯python实现。

全文检索从/search/入口进入:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
@mod.route('/search/')
def search():
    q = request.args.get('q') or ''
    page = request.args.get('page', type=int) or 1
    results = None
    if q:
        results = perform_search(q, page=page)
        if results is None:
            abort(404)
    return render_template('general/search.html', results=results, q=q)
  • q是搜素的关键字,page是翻页的页数
  • 使用perform_search方法对索引进行查询
  • 如果找不到内容展示404;如果找到内容,展示结果

在search模块中提供了search方法,前面调用的perform_search函数是其别名:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
def search(query, page=1, per_page=20):
    with index.searcher() as s:
        qp = qparser.MultifieldParser(['title', 'content'], index.schema)
        q = qp.parse(unicode(query))
        try:
            result_page = s.search_page(q, page, pagelen=per_page)
        except ValueError:
            if page == 1:
                return SearchResultPage(None, page)
            return None
        results = result_page.results
        results.highlighter.fragmenter.maxchars = 512
        results.highlighter.fragmenter.surround = 40
        results.highlighter.formatter = highlight.HtmlFormatter('em',
            classname='search-match', termclass='search-term',
            between=u'<span class=ellipsis> … </span>')
        return SearchResultPage(result_page, page)
  • 从ttile和content中搜素关键字q
  • 设置使用unicode编码
  • 将检索结果封装成SearchResultPage

重点在index.searcher()这个索引, 它使用下面方法构建:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
from whoosh import highlight, analysis, qparser
from whoosh.support.charset import accent_map
...
def open_index():
    from whoosh import index, fields as f
    if os.path.isdir(app.config['WHOOSH_INDEX']):
        return index.open_dir(app.config['WHOOSH_INDEX'])
    os.mkdir(app.config['WHOOSH_INDEX'])
    analyzer = analysis.StemmingAnalyzer() | analysis.CharsetFilter(accent_map)
    schema = f.Schema(
        url=f.ID(stored=True, unique=True),
        id=f.ID(stored=True),
        title=f.TEXT(stored=True, field_boost=2.0, analyzer=analyzer),
        type=f.ID(stored=True),
        keywords=f.KEYWORD(commas=True),
        content=f.TEXT(analyzer=analyzer)
    )
    return index.create_in(app.config['WHOOSH_INDEX'], schema)

index = open_index()
  • whoosh创建本地的索引文件
  • whoosh构建搜素的数据结构,包括url,title,,关键字和内容
  • 关键字和内容参与检索

索引需要构建和刷新:

1
2
3
4
5
6
7
def update_documentation_index():
    from flask_website.docs import DocumentationPage
    writer = index.writer()
    for page in DocumentationPage.iter_pages():
        page.remove_from_search_index(writer)
        page.add_to_search_index(writer)
    writer.commit()

文档索引构建在docs模块中:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
DOCUMENTATION_PATH = os.path.join(_basedir, '../flask/docs/_build/dirhtml')
WHOOSH_INDEX = os.path.join(_basedir, 'flask-website.whoosh')

class DocumentationPage(Indexable):
    search_document_kind = 'documentation'

    def __init__(self, slug):
        self.slug = slug
        fn = os.path.join(app.config['DOCUMENTATION_PATH'],
                          slug, 'index.html')
        with open(fn) as f:
            contents = f.read().decode('utf-8')
            title, text = _doc_body_re.search(contents).groups()
        self.title = Markup(title).striptags().split(u'—')[0].strip()
        self.text = Markup(text).striptags().strip().replace(u'¶', u'')
    
    @classmethod
    def iter_pages(cls):
        base_folder = os.path.abspath(app.config['DOCUMENTATION_PATH'])
        for dirpath, dirnames, filenames in os.walk(base_folder):
            if 'index.html' in filenames:
                slug = dirpath[len(base_folder) + 1:]
                # skip the index page.  useless
                if slug:
                    yield DocumentationPage(slug)
  • 文档读取DOCUMENTATION_PATH目录下的源文件(项目文档)
  • 读取文件的标题和文本,构建索引文件

小结

本文我们走马观花的查看了flask-view这个flask曾经的主站。虽然没有深入太多细节,但是我们知道了模版渲染,数据库操作,OpenID认证和全文检索四个功能的实现方式,建立了相关技术的索引。如果我们需要构建自己的小型web项目,比如博客,完全可以以这个项目为基础,修改实现。

经过数周的调整,接下我们开始进入python影响力巨大的项目之一: Django。敬请期待。

小技巧

本项目提供了2个非常实用的小技巧。第1个是json化和html化输出,这样用户可以自由选择输出方式,同时站点也可以构建纯API的接口。这个功能是使用下面的request_wants_json函数提供:

1
2
3
4
5
6
7
8
def request_wants_json():
    # we only accept json if the quality of json is greater than the
    # quality of text/html because text/html is preferred to support
    # browsers that accept on */*
    best = request.accept_mimetypes \
        .best_match(['application/json', 'text/html'])
    return best == 'application/json' and \
       request.accept_mimetypes[best] > request.accept_mimetypes['text/html']

request_wants_json函数中判断头部的mime类型,进行根据是application/json还是text/html决定展示方式。

第2个小技巧是认证装饰器, 前面一个是登录验证,后一个是超级管理认证:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
def requires_login(f):
    @wraps(f)
    def decorated_function(*args, **kwargs):
        if g.user is None:
            flash(u'You need to be signed in for this page.')
            return redirect(url_for('general.login', next=request.path))
        return f(*args, **kwargs)
    return decorated_function

def requires_admin(f):
    @wraps(f)
    def decorated_function(*args, **kwargs):
        if not g.user.is_admin:
            abort(401)
        return f(*args, **kwargs)
    return requires_login(decorated_function)

这两个装饰器,在view的API上使用, 比如编辑snippet需要登录,评论需要管理员权限:

1
2
3
4
5
6
7
8
9
@mod.route('/edit/<int:id>/', methods=['GET', 'POST'])
@requires_login
def edit(id):
    ...

@mod.route('/comments/<int:id>/', methods=['GET', 'POST'])
@requires_admin
def edit_comment(id):
    ...

参考链接