python

Проектирование сложных приложений в Flask

  • пятница, 15 января 2016 г. в 02:10:46
http://habrahabr.ru/post/275099/

Данная статья, размещенная в репозитории Flask на GitHub, является плодом коллективного творчества небезразличных программистов, а изначальный её автор — Brice Leroy. Она представляет собой достаточно полезный для начинающих материал по Flask. Лично для меня он стал ответом на многие простые вопросы, основным из которых был «как структурировать проект».

Для хоть сколько-то опытных программистов она вряд ли будет полезна, многие могут вовсе не согласиться с описанными принципами, однако для находящихся на ранней стадии обучения она может стать толчком к развитию, как стала для меня. Именно поэтому я сделал перевод на русский язык — у этой статьи очень низкий порог вхождения и стоит сделать его еще ниже.

Описанный пример протестирован на Python 3.5, Flask 0.10, Flask-SQLAlchemy 2.1, Flask-WTG 0.9.


Проектирование сложных приложений в Flask


Этот документ не входит в официальную документацию Flask. Он является компиляцией советов, полученных из различных неофициальных источников и никогда не подвергался какой-либо проверке. Описанные методики могут быть весьма полезны, но в то же время и достаточно опасны. Просьба не вносить никаких изменений в оригинальный документ, размещенный на Github, так как на него ссылаются многие ответы на StackOverflow. Вы можете вносить в него любые поправки и заметки, но для размещения используйте личный сайт или блог.

Данная статья является попыткой описать структуру большого проекта, использующего Flask и базовые модули SQLAlchemy и WTForms.

Установка


Flask


Инуструкция по установке Flask.

Я рекомендую использовать virtualenv — эта система очень проста и позволяет размещать несколько виртуальных окружений на одной системе и не требует прав суперпользователя, так как все библиотеки устанавливаются локально.

Flask-SQLAlchemy


SQLAlchemy обеспечивает простой и мощный интерфейс взаимодействия ваших объектов и реляционной базы данных любого типа. Для установки Flask-SQLAlchemy в ваше виртуальное окружение используйте pip:

pip install flask-sqlalchemy

Более полное описание пакета Flask-SQLAlchemy.

Flask-WTF


WTForms упрощает получение данных от пользователя.

pip install Flask-WTF

Более полное описание пакета Flask-WTF.

Введение


Итак, необходимые библиотеки подготовлены. Так должна выглядеть основная структура вашего проекта:

/app/users/__init__.py
/app/users/views.py
/app/users/forms.py
/app/users/constants.py
/app/users/models.py
/app/users/decorators.py

Для каждего модуля (элемента приложения) создаётся следующая структура:

/app/templates/404.html
/app/templates/base.html
/app/templates/users/login.html
/app/templates/users/register.html
...

Шаблоны представления (jinja) хранятся в директории templates и поддиректория модулей:

/app/static/js/main.js
/app/static/css/reset.css
/app/static/img/header.png

Для обработки неизменяемых файлов необходимо использовать отдельный веб-сервер, однако на время разработки можно возложить эту работу на Flask. Он автоматически выдает такие файлы из директории static, а для настройки использования другой директории вы можете воспользоваться информацией из данной статьи.

Для рассматриваемого приложения будут создан один модуль: users. Он обеспечит управление регистрацией и входом пользователей, просмотр данных своего профайла.

Конфигурация


/run.py используется для запуска веб-сервера:

    from app import app
    app.run(debug=True)

/shell.py даст доступ к консоли с возможностью выполнения команд. Возможно, не так удобно, как отладка через pdb, но достаточно полезно (по крайней мере при инициализации базы данных):

    #!/usr/bin/env python
    import os
    import readline
    from pprint import pprint

    from flask import *
    from app import *

    os.environ['PYTHONINSPECT'] = 'True'

Примечание переводчика:
В случае, если вы работаете в ОС Windows (не надо бросать кирпичи!), библиотека readline недоступна. В таком случае необходимо установить в своё виртуальное или реальное окружение python библиотеку pyreadline и обернуть импорт в конструкцию вида:


try:
    import readline
except:
    import pyreadline

В принципе, можно и вовсе обойтись без этой библиотеки, она просто упрощает взаимодействие с консолью, добавляя в нее некоторые bash-like элементы.

/config.py хранит всю конфигурацию приложения. В данном примере в качестве базы данных используется SQLite, так как она очень удобна при разработке. Скорее всего файл /config.py не стоит включать в репозиторий, так как он будет разным на тестовой и промышленной системах.

    import os
    _basedir = os.path.abspath(os.path.dirname(__file__))

    DEBUG = False

    ADMINS = frozenset(['youremail@yourdomain.com'])
    SECRET_KEY = 'This string will be replaced with a proper key in production.'

    SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(_basedir, 'app.db')
    DATABASE_CONNECT_OPTIONS = {}

    THREADS_PER_PAGE = 8

    WTF_CSRF_ENABLED = True
    WTF_CSRF_SECRET_KEY = "somethingimpossibletoguess"

    RECAPTCHA_USE_SSL = False
    RECAPTCHA_PUBLIC_KEY = '6LeYIbsSAAAAACRPIllxA7wvXjIE411PfdB2gt2J'
    RECAPTCHA_PRIVATE_KEY = '6LeYIbsSAAAAAJezaIq3Ft_hSTo0YtyeFG-JgRtu'
    RECAPTCHA_OPTIONS = {'theme': 'white'}

  • _basedir — переменная, в которую помещается исполняемая директория скрипта;
  • DEBUG определяет появление сообщений об ошибках в тестовом окружении;
  • SECRET_KEY используется для подписи cookies, при его изменении пользователям потребуется логиниться заново;
  • ADMINS содержит адрес электронной почты администраторов для рассылок из приложения;
  • SQLALCHEMY_DATABASE_URI и DATABASE_CONNECT_OPTIONS, как несложно догадаться — опции подключения SQLAlchemy;
  • THREADS_PER_PAGE, как мне кажется, ставил 2 на ядро… Могу ошибаться;
  • WTF_CSRF_ENABLED и WTF_CSRF_SECRET_KEY защищают от подмены POST-сообщений;
  • RECAPTCHA_* используется для входящего в WTForms поля RecaptchaField. Получить приватный и публичный ключи можно на сайте **recaptcha.

Модуль


Настроим модуль users в следующем порядке: определим модели, связанные с моделями константы, далее форму и, наконец, представление и шаблоны.

Модель


/app/users/models.py:

    from app import db
    from app.users import constants as USER

    class User(db.Model):

        __tablename__ = 'users_user'
        id = db.Column(db.Integer, primary_key=True)
        name = db.Column(db.String(50), unique=True)
        email = db.Column(db.String(120), unique=True)
        password = db.Column(db.String(120))
        role = db.Column(db.SmallInteger, default=USER.USER)
        status = db.Column(db.SmallInteger, default=USER.NEW)

        def __init__(self, name=None, email=None, password=None):
          self.name = name
          self.email = email
          self.password = password

        def getStatus(self):
          return USER.STATUS[self.status]

        def getRole(self):
          return USER.ROLE[self.role]

        def __repr__(self):
            return '<User %r>' % (self.name)

И её константы в файле /app/users/constants.py:

    # User role
    ADMIN = 0
    STAFF = 1
    USER = 2
    ROLE = {
      ADMIN: 'admin',
      STAFF: 'staff',
      USER: 'user',
    }

    # user status
    INACTIVE = 0
    NEW = 1
    ACTIVE = 2
    STATUS = {
      INACTIVE: 'inactive',
      NEW: 'new',
      ACTIVE: 'active',
    }

К слову о константах: мне нравится, когда константы хранятся в отдельном файле внутри модуля. Константы скорее всего будут использоваться в моделях, формах и представлениях, так что таким образом вы получите удобно организованные данные, которые будет просто найти. К тому же, импортирование констант под именем модуля в верхнем регистре (например USERS для users.constants) поможет избежать конфликтов имен.

Форма


Когда создана модель нужного объекта, необходимо сконструировать форму для работы с ней.

Форма регистрации будет запрашивать имя пользователя, адрес электронной почты и пароль, будут использованы валидаторы для проверки корректности введенных пользователем данных, а поле Recaptcha защитит от регистрации ботов. На случай, если понадобится внедрить пользовательское соглашение, также добавлено поле BooleanField с именем accept_tos. Данное поле помечено, как required, то есть пользователь будет обязан отметить генерируемый формой чекбокс. Форма входа снабжена полями email и password с аналогичными валидаторами.

Описание форм содержится в файле /app/users/forms.py:

    from flask.ext.wtf import Form, RecaptchaField
    from wtforms import TextField, PasswordField, BooleanField
    from wtforms.validators import Required, EqualTo, Email

    class LoginForm(Form):
      email = TextField('Email address', [Required(), Email()])
      password = PasswordField('Password', [Required()])

    class RegisterForm(Form):
      name = TextField('NickName', [Required()])
      email = TextField('Email address', [Required(), Email()])
      password = PasswordField('Password', [Required()])
      confirm = PasswordField('Repeat Password', [
          Required(),
          EqualTo('password', message='Passwords must match')
          ])
      accept_tos = BooleanField('I accept the TOS', [Required()])
      recaptcha = RecaptchaField()

Первый параметр для каждого поля — его метка, например для поля name в форме задана метка NickName. Для полей ввода пароля используется валидатор EqualTo, сравнивающий данные в двух полях.

Более полная информация о возможностях WTForms находится по этой ссылке.

Представление


В представлении объявляется Blueprint — объект схемы модуля, в свойствах которого указывается url_prefix, который будет подставляться в начале любого URLа, указанного в route. Также в представлении используется метод формы form.validate_on_submit, выдающий истину для метода HTTP POST и валидной формы. После успешного входа пользователь перенаправляется на страницу профиля (/users/me). Для предотвращения доступа неавторизованных пользователей создаётся специальный декоратор в файле /app/users/decorators.py:

    from functools import wraps

    from flask import g, flash, redirect, url_for, request

    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('users.login', next=request.path))
        return f(*args, **kwargs)
      return decorated_function

Данный декоратор проверяет наличие данных в переменной g.user. В случае, если переменная не задана, то пользователь не аутенфицирован, тогда задаётся информационное сообщение и осуществляется перенаправление на представление login (вход в систему). Данные в переменную g.user помещаются в функции before_request. Когда вы получаете большой объем данных из профиля пользователя (исторические данные, друзья, сообщения, действия) возможно серьезное замедление работы при обращении к БД, так что кеширование данных пользователей может решить эту проблему (но только пока вы модифицируете объекты централизованно и очищаете кеш при каждом обновлении). Ниже прилагается код представления /app/users/views.py:

    from flask import Blueprint, request, render_template, flash, g, session, redirect, url_for
    from werkzeug import check_password_hash, generate_password_hash

    from app import db
    from app.users.forms import RegisterForm, LoginForm
    from app.users.models import User
    from app.users.decorators import requires_login

    mod = Blueprint('users', __name__, url_prefix='/users')

    @mod.route('/me/')
    @requires_login
    def home():
      return render_template("users/profile.html", user=g.user)

    @mod.before_request
    def before_request():
      """
      pull user's profile from the database before every request are treated
      """
      g.user = None
      if 'user_id' in session:
        g.user = User.query.get(session['user_id'])

    @mod.route('/login/', methods=['GET', 'POST'])
    def login():
      """
      Login form
      """
      form = LoginForm(request.form)
      # make sure data are valid, but doesn't validate password is right
      if form.validate_on_submit():
        user = User.query.filter_by(email=form.email.data).first()
        # we use werzeug to validate user's password
        if user and check_password_hash(user.password, form.password.data):
          # the session can't be modified as it's signed, 
          # it's a safe place to store the user id
          session['user_id'] = user.id
          flash('Welcome %s' % user.name)
          return redirect(url_for('users.home'))
        flash('Wrong email or password', 'error-message')
      return render_template("users/login.html", form=form)

    @mod.route('/register/', methods=['GET', 'POST'])
    def register():
      """
      Registration Form
      """
      form = RegisterForm(request.form)
      if form.validate_on_submit():
        # create an user instance not yet stored in the database
        user = User(name=form.name.data, email=form.email.data, \
          password=generate_password_hash(form.password.data))
        # Insert the record in our database and commit it
        db.session.add(user)
        db.session.commit()

        # Log the user in, as he now has an id
        session['user_id'] = user.id

        # flash will display a message to the user
        flash('Thanks for registering')
        # redirect user to the 'home' method of the user module.
        return redirect(url_for('users.home'))
      return render_template("users/register.html", form=form)

Шаблон


Шаблонизатор Jinja встроен в Flask. Одним из его преимуществ является возможность наследования и встроенной логики (зависимости, циклы, контекстные изменения). Создадим шаблон /app/templates/base.html, от которого будут наследоваться остальные шаблоны. Возможно задание более чем одного наследования (например наследование от шаблона twocolumn.html, который в свою очередь наслудется от main.html). Базовый шаблон также упрощает отображение информационных (flash) сообщений из переменной get_flashed_messages в каждом наследующем шаблоне.

Теперь нет необходимости задавать основную структуру страницы и каждое изменение base.html отразится на наследующих шаблонах. Рекомендуется называть шаблоны в соответствии с вызывающими их представлениями, именно так поименован шаблон /app/templates/users/register.html:

    <html>
      <head>
        <title>{% block title %}My Site{% endblock %}</title>
        {% block css %}
        <link rel="stylesheet" href="/static/css/reset-min.css" />
        <link rel="stylesheet" href="/static/css/main.css" />
        {% endblock %}
        {% block script %}
        <script src="/static/js/main.js" type="text/javascript"></script>
        {% endblock %}
      </head>
      <body>
        <div id="header">{% block header %}{% endblock %}</div>
        <div id="messages-wrap">
          <div id="messages">
            {% for category, msg in get_flashed_messages(with_categories=true) %}
              <p class="message flash-{{ category }}">{{ msg }}</p>
            {% endfor %}
          </div>
        </div>
        <div id="content">{% block content %}{% endblock %}</div>
        <div id="footer">{% block footer %}{% endblock %}</div>
      </body>
    </html>

И шаблон /app/templates/users/login.html:

    {% extends "base.html" %}
    {% block content %}
      {% from "forms/macros.html" import render_field %}
      <form method="POST" action="." class="form">
        {{ form.csrf_token }}
        {{ render_field(form.email, class="input text") }}
        {{ render_field(form.password, class="input text") }}
        <input type="submit" value="Login" class="button green">
      </form>
      <a href="{{ url_for('users.register') }}">Register</a>
    {% endblock %}

Созданные шаблоны используют макросы для автоматизации создания полей html. Так как этот макрос будет использоваться в различных модулях, он помещен в отдельный файл /app/templates/forms/macros.html:

    {% macro render_field(field) %}
        <div class="form_field">
        {{ field.label(class="label") }}
        {% if field.errors %}
            {% set css_class = 'has_error ' + kwargs.pop('class', '') %}
            {{ field(class=css_class, **kwargs) }}
            <ul class="errors">{% for error in field.errors %}<li>{{ error|e }}</li>{% endfor %}</ul>
        {% else %}
            {{ field(**kwargs) }}
        {% endif %}
        </div>
    {% endmacro %}

Наконец, создан примитивный шаблон /app/templates/users/profile.html:

   {% extends "base.html" %}
   {% block content %}
     Hi {{ user.name }}!
   {% endblock %}

Инициализация приложения


Как несложно догадаться, инициализация приложения происходит в файле /app/init.py:

    import os
    import sys

    from flask import Flask, render_template
    from flask.ext.sqlalchemy import SQLAlchemy

    app = Flask(__name__)
    app.config.from_object('config')

    db = SQLAlchemy(app)

    ########################
    # Configure Secret Key #
    ########################
    def install_secret_key(app, filename='secret_key'):
        """Configure the SECRET_KEY from a file
        in the instance directory.

        If the file does not exist, print instructions
        to create it from a shell with a random key,
        then exit.
        """
        filename = os.path.join(app.instance_path, filename)

        try:
            app.config['SECRET_KEY'] = open(filename, 'rb').read()
        except IOError:
            print('Error: No secret key. Create it with:')
            full_path = os.path.dirname(filename)
            if not os.path.isdir(full_path):
                print('mkdir -p {filename}'.format(filename=full_path))
            print('head -c 24 /dev/urandom > {filename}'.format(filename=filename))
            sys.exit(1)

    if not app.config['DEBUG']:
        install_secret_key(app)

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

    from app.users.views import mod as usersModule
    app.register_blueprint(usersModule)

    # Later on you'll import the other blueprints the same way:
    #from app.comments.views import mod as commentsModule
    #from app.posts.views import mod as postsModule
    #app.register_blueprint(commentsModule)
    #app.register_blueprint(postsModule)

Экземпляр БД SQLAlchemy и модель Users находятся в двух разных файлах, необходимо импортировать оба из них в общее пространство имен с помощью строки from app.users.views import mod as usersModule. В противном случае команда db.create_all() не принесет результата.

Активируем виртуальное окружение virtualenv и инициализируем БД:

user@Machine:~/Projects/dev$ . env/bin/activate
(env)user@Machine:~/Projects/dev$ python shell.py 
>>> from app import db
>>> db.create_all()
>>> exit()

Теперь можно выполнить команду python run.py и получить сообщение следующего вида:

(env)user@Machine:~/Projects/dev$ python run.py 
 * Running on http://127.0.0.1:5000/
 * Restarting with reloader

Открыв в браузере адрес http://127.0.0.1:500/users/me/ вы будете перенаправлены на страницу входа и увидите ссылку на страницу регистрации.