Перейти к содержанию

172 уроков, 13 библиотек и челлендж «Что выведет код?» — бесплатно, код прямо в браузере

Начать обучение
Урок 17 из 20 Средний 40 мин 140 XP

Фабрика приложений: паттерн create_app

Семнадцатый урок расширения Flask: один глобальный app протекает состоянием между тестами. Паттерн «фабрика приложений» собирает свежий экземпляр с блюпринтом и конфигом на каждый тест.

Редакция Питоники

В прошлом уроке мы научили тесты получать клиента из фикстуры и работали на временных базах. Но в фундаменте осталась трещина, которую мы честно отложили: все наши тесты импортируют один и тот же объект app из модуля приложения. Пока приложение возвращает константы — ничего страшного. Как только в нём появляется изменяемое состояние, тесты начинают друг другу мешать. Сегодня разберём, почему так происходит, и выучим паттерн, через который проходят все серьёзные Flask-проекты: фабрику приложений create_app().

Проблема: состояние течёт через глобальный app

Гостевая книга: список записей хранится прямо в модуле приложения, POST дописывает в него, GET возвращает. Смотри, что сделает с таким приложением набор из двух невинных тестов — первый добавляет запись, второй требует, чтобы список начинался с пустого:

Два теста против глобального списка (запустите)
from pathlib import Path
import sys

Path("app_guestbook.py").write_text('''from flask import Flask, jsonify, request

app = Flask(__name__)

ENTRIES = []  # глобальное хранилище - живёт вне приложения

@app.route("/api/entries", methods=["GET", "POST"])
def entries():
    if request.method == "POST":
        ENTRIES.append(request.get_json()["text"])
    return jsonify(ENTRIES)
''', encoding="utf-8")

Path("test_guestbook.py").write_text('''import sys
sys.path.insert(0, "")  # песочнице нужен явный путь к текущей папке

from app_guestbook import app

def test_add_entry():
    client = app.test_client()
    client.post("/api/entries", json={"text": "Привет!"})
    assert len(client.get("/api/entries").get_json()) == 1

def test_list_starts_empty():
    client = app.test_client()
    assert len(client.get("/api/entries").get_json()) == 0
''', encoding="utf-8")

for name in ("app_guestbook", "test_guestbook"):
    sys.modules.pop(name, None)

import pytest

rc = pytest.main(["test_guestbook.py", "-q", "--no-header", "--tb=no", "-p", "no:cacheprovider"])
print("exit:", int(rc))
Вывод
.F                                                                       [100%]
=========================== short test summary info ===========================
FAILED test_guestbook.py::test_list_starts_empty - AssertionError: assert 1 == 0
1 failed, 1 passed in 0.18s
exit: 1
Флаг --tb=no прячет трассировку, в сводке остаётся суть: второй тест ждал 0 записей, а увидел 1. Каждый тест создал собственного клиента - но приложение-то одно.

Второй тест упал, хотя оба по отдельности правильные. Причина в том, как Python работает с модулями: from app_guestbook import app исполняет модуль один раз и кладёт его в кэш импортов. Все тесты процесса получают один и тот же объект app — и один и тот же список ENTRIES, который живёт в модуле, а не в приложении. Первый тест дописал «Привет!» в общий список, и второй увидел чужие данные. Тесты стали зависимыми: поменяй их местами — упадёт уже другой.

Структура проекта с фабрикой
board/
  app/
    __init__.py        # create_app(): config, расширения, блюпринты
    board_api.py       # Blueprint("api") с маршрутами
  conftest.py          # фикстура app = create_app({"TESTING": True})
  test_board.py        # тесты - каждому своё приложение
Схема папок: маршруты и сборка живут отдельно. Тесты импортируют не приложение, а фабрику - и собирают столько экземпляров, сколько нужно.

create_app(): приложение как функция

Идея фабрики простая: не создавай приложение на уровне модуля, а научись собирать его функцией. Каждый вызов create_app() возвращает новенький, ни разу не использованный объект Flask — со своими настройками и своими маршрутами. Состояние при этом кладётся не в глобальные переменные, а на само приложение, в app.config:

Два приложения из одной фабрики (запустите)
from flask import Flask, jsonify

def create_app(config=None):
    """Собирает приложение заново при каждом вызове."""
    app = Flask(__name__)
    app.config.update(config or {})

    @app.route("/api/greeting")
    def greeting():
        name = app.config.get("NAME", "гость")
        formal = app.config.get("FORMAL", False)
        if formal:
            return jsonify({"text": "Здравствуйте, " + name})
        return jsonify({"text": "Привет, " + name})

    return app

# два приложения из одной фабрики - с разными настройками
casual = create_app()
official = create_app({"NAME": "Анна", "FORMAL": True})

print(casual.test_client().get("/api/greeting").get_json())
print(official.test_client().get("/api/greeting").get_json())
print("Приложения разные:", casual is not official)
Вывод
{'text': 'Привет, гость'}
{'text': 'Здравствуйте, Анна'}
Приложения разные: True
Одна фабрика - два приложения с разным поведением. Флаг TESTING, адрес базы, режим отладки - всё это точно так же задаётся словарём config при сборке.

Разбор по строчкам. Аргумент config=None принимает словарь настроек; app.config.update(config or {}) применяет его поверх значений по умолчанию — вызвал фабрику без аргументов, получил приложение с дефолтами. Маршруты объявляются внутри фабрики, поэтому каждое собранное приложение получает собственную копию. А проверка casual is not official подтверждает главное: это два разных объекта, изменение одного не видно другому.

Фабрика — не изобретение для тестов, а штатный паттерн Flask: документация называет его application factory и рекомендует для проектов любого размера. В большом проекте фабрика живёт в пакете приложения и собирает всё: конфиг из классов (это было в уроке про конфигурацию), расширения, блюпринты:

app/__init__.py — фабрика настоящего проекта (схема)
from flask import Flask

def create_app(config=None):
    app = Flask(__name__)
    app.config.update(config or {})

    from .db import db
    db.init_app(app)                 # расширение привязывается к приложению

    from .blog import bp as blog_bp
    from .auth import bp as auth_bp
    app.register_blueprint(blog_bp, url_prefix="/blog")
    app.register_blueprint(auth_bp, url_prefix="/auth")

    return app
Схема без запуска: расширения и блюпринты импортируются и привязываются внутри фабрики, поэтому каждое собранное приложение получает свой полный комплект. Упрощённую версию этого шаблона собираем в следующем блоке.

Откуда вообще взялась привычка писать app = Flask(__name__) на уровне модуля? Она естественна: объект нужен и декораторам маршрутов, и flask run, и test_client — проще создать один раз сверху. Пока проект маленький, эта строка ничем не мешает. Плата приходит позже, и приходит она сразу в трёх местах: приложение невозможно собрать второй раз с другим конфигом (оно уже построено), состояние, привязанное к нему, живёт весь процесс, а импорт модуля имеет побочный эффект — «просто импортирую модели» и получаешь собранное приложение со всеми его привычками. Фабрика переворачивает отношение: модуль экспортирует не готовый объект, а способ его построить — решение, когда и что строить, остаётся вызывающему.

Блюпринт регистрируется внутри фабрики

Блюпринт из урока про Blueprint идеально ложится на фабрику: маршруты живут в отдельном модуле и не зависят от приложения, а фабрика вешает их на каждый собранный экземпляр. Заодно решим проблему состояния честно: хранилище задач берём из current_app.config — то есть из того приложения, которое обслуживает текущий запрос:

Блюпринт + фабрика: у каждого приложения своё состояние (запустите)
from flask import Blueprint, Flask, current_app, jsonify, request

bp = Blueprint("api", __name__)

@bp.route("/api/tasks", methods=["GET", "POST"])
def tasks():
    store = current_app.config["TASKS"]
    if request.method == "POST":
        store.append({"id": len(store) + 1, "text": request.get_json()["text"]})
    return jsonify(store)

def create_app(config=None):
    app = Flask(__name__)
    app.config.update(config or {})
    app.config.setdefault("TASKS", [])   # хранилище живёт на приложении
    app.register_blueprint(bp)           # блюпринт регистрируется внутри
    return app

first = create_app()
first.test_client().post("/api/tasks", json={"text": "Задача один"})

second = create_app()

print("Первое приложение:", first.test_client().get("/api/tasks").get_json())
print("Второе приложение:", second.test_client().get("/api/tasks").get_json())
Вывод
Первое приложение: [{'id': 1, 'text': 'Задача один'}]
Второе приложение: []
Запись уехала в первое приложение - второе о ней не знает: у каждого свой список TASKS в собственном config. Сравни с гостевой книгой из начала урока.

Вот теперь протечка закрыта принципиально: состояние хранится на приложении, а приложение собирается заново каждой фабрикой. current_app — объект Flask «приложение, которое обрабатывает текущий запрос», поэтому один и тот же код блюпринта честно обслуживает оба экземпляра, доставая данные из своего.

Двойная регистрация — ошибка при сборке
app.register_blueprint(bp)
app.register_blueprint(bp)

Traceback (most recent call last):
  ...
ValueError: The name 'api' is already registered for this
blueprint. Use 'name=' to provide a unique name.
Распечатка ошибки, не запускаемый код: имя блюпринта обязано быть уникальным в пределах одного приложения. Разные приложения могут спокойно регистрировать один и тот же блюпринт - это и есть механизм фабрики.

Фабрика в тестах: свежий экземпляр каждому тесту

Теперь соберём всё вместе с фикстурами из прошлого урока: в conftest.py живёт фикстура app, которая вызывает фабрику, а фикстура client принимает её аргументом. Каждый тест получает собственное приложение — те самые «свежие коробки»:

pytest + фабрика: изоляция на уровне приложений (запустите)
from pathlib import Path
import sys

Path("app_board.py").write_text('''from flask import Blueprint, Flask, current_app, jsonify, request

bp = Blueprint("api", __name__)

@bp.route("/api/tasks", methods=["GET", "POST"])
def tasks():
    store = current_app.config["TASKS"]
    if request.method == "POST":
        store.append({"id": len(store) + 1, "text": request.get_json()["text"]})
    return jsonify(store)

def create_app(config=None):
    app = Flask(__name__)
    app.config.update(config or {})
    app.config.setdefault("TASKS", [])
    app.register_blueprint(bp)
    return app
''', encoding="utf-8")

Path("conftest.py").write_text('''import sys
sys.path.insert(0, "")  # песочнице нужен явный путь к текущей папке

import pytest

from app_board import create_app

@pytest.fixture
def app():
    # свежий экземпляр приложения на каждый тест
    return create_app({"TESTING": True})

@pytest.fixture
def client(app):
    return app.test_client()
''', encoding="utf-8")

Path("test_board.py").write_text('''def test_add_task(client):
    client.post("/api/tasks", json={"text": "Полить цветы"})
    assert len(client.get("/api/tasks").get_json()) == 1

def test_starts_clean(client):
    assert client.get("/api/tasks").get_json() == []
''', encoding="utf-8")

for name in ("app_board", "conftest", "test_board"):
    sys.modules.pop(name, None)

import pytest

rc = pytest.main(["test_board.py", "-q", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
Вывод
..                                                                       [100%]
2 passed in 0.03s
exit: 0
Тот же сценарий, что с гостевой книгой: первый тест пишет, второй требует чистоту. Теперь набор зелёный - не потому, что тесты везучие, а потому что фабрика гарантирует каждому тесту собственное приложение.

Обрати внимание на "TESTING": True в фикстуре app: это стандартный флаг Flask, при котором приложение меняет поведение для тестов — например, отдаёт настоящие исключения вместо аккуратных страниц ошибок. Именно поэтому фабрика с параметром config удобнее глобального app: тестовый и рабочий режимы собираются из одного кода с разными настройками.

Фабрика + tmp_path: заготовка для тестов с базой
@pytest.fixture
def app(tmp_path):
    return create_app({
        "TESTING": True,
        "DATABASE": str(tmp_path / "board.db"),
    })

@pytest.fixture
def client(app):
    return app.test_client()
Фрагмент без запуска: фабрика принимает любой конфиг, поэтому тестовое приложение собирается с базой во временной папке tmp_path из прошлого урока. В финальном проекте курса эта связка соберётся целиком.
СценарийГлобальный appФабрика create_app
Состояние между тестамитечёт через общий модульу каждого теста свой экземпляр
Разные конфигипереписывать config рукамисловарь аргументом фабрики
Блюпринтырегистрируются один раз навсегдарегистрируются при каждой сборке
Порядок тестоввлияет на результатне влияет

Честности ради: фабрика нужна не каждому проекту. Скрипт на три маршрута, учебное приложение, прототип на вечер — глобальный app читается проще, и заставлять себя писать create_app() ради формы не стоит. Сигналы, что фабрика уже назрела, такие: у проекта появился хотя бы один тест с test_client, конфиг начал переключаться между режимами, или приложение нужно собирать в двух местах — например, рабочее и для сид-скрипта из следующего урока. Всё это случается раньше, чем кажется, поэтому паттерн из этого урока ты, скорее всего, применишь уже в первом своём серьёзном проекте.

Итоги

Трещина закрыта: приложение больше не глобальная переменная, а результат вызова create_app(). Блюпринты регистрируются внутри фабрики, конфиг подаётся аргументом, изменяемое состояние живёт на приложении — и каждый тест получает собственную, стерильную сборку. Набор тестов перестал зависеть от порядка запуска окончательно, на любом уровне.

Приложение собрано и протестировано — осталось обсудить, как его вообще запускают на машине: не app.run(), а flask run, и не только. В следующем уроке познакомимся с командной строкой Flask: переменной FLASK_APP, встроенными командами и своими командами для сидов и обслуживания, которые можно вызывать даже из тестов. Карта курса — самоучитель Flask.

Фабрика приложений — это функция, которая собирает Flask-приложение заново при каждом вызове: свежий конфиг, чистое состояние.

Что выведет код?

Сначала предскажи ответ в голове — это главный навык программиста.

def create_app():
    return {"tasks": []}

a = create_app()
a["tasks"].append("задача")
b = create_app()
print(len(b["tasks"]))
def create_app(config=None):
    conf = {"LANG": "ru"}
    conf.update(config or {})
    return conf

ru = create_app()
en = create_app({"LANG": "en"})
print(ru["LANG"], en["LANG"])
STORE = []

def create_app_bad():
    return {"store": STORE}

def create_app_good():
    return {"store": []}

first = create_app_bad()
second = create_app_bad()
first["store"].append("x")
print(len(second["store"]), end=" ")

g1 = create_app_good()
g2 = create_app_good()
g1["store"].append("x")
print(len(g2["store"]))
Проверь себя
0 / 6

1. Почему тесты гостевой книги влияли друг на друга?

2. Что делает create_app(config=None)?

3. Где должно жить изменяемое состояние при фабрике?

4. Зачем блюпринт регистрируют внутри create_app()?

5. Что даёт фикстура app = create_app({"TESTING": True}) в conftest.py?

6. Что произойдёт при повторном register_blueprint того же блюпринта на одном приложении?

Карточки терминов
Запомнено: 0 / 6
Практика

Собери фабрику create_app(prefix) для API приветствий: маршрут /api/greeting возвращает JSON {'text': '<prefix>, мир'}, где префикс берётся из config с ключом GREETING (по умолчанию «Привет»). Собери два приложения: с дефолтами и с GREETING = «Здравствуйте», проверь через test_client, что ответы разные, и напечатай оба текста.

practice.py
Вопросы и ответы по уроку

Что такое фабрика приложений во Flask и зачем она нужна?

Это паттерн create_app(): приложение не создаётся на уровне модуля, а собирается функцией при каждом вызове — с поданным конфигом и зарегистрированными блюпринтами. Главное следствие для тестов: каждый тест получает свежий экземпляр приложения, и состояние перестаёт протекать между тестами. Документация Flask рекомендует фабрику для всех проектов, кроме самых маленьких.

Почему тесты проходят по одному, но падают при запуске набора?

Классический признак протечки состояния: тесты делят один объект приложения или глобальную переменную модуля. По отдельности каждый тест застаёт чистые данные, а в наборе — последствия предыдущего. Лечение: перенести состояние на app.config или расширения и собирать приложение фабрикой create_app() в фикстуре, чтобы каждый тест получал собственный экземпляр.

Где регистрировать блюпринты при использовании фабрики?

Внутри create_app(): импортируйте блюпринт и вызовите app.register_blueprint(bp, url_prefix=...) до return. Тогда каждое собранное приложение получит маршруты. Регистрация одного блюпринта дважды на одном приложении даёт ValueError об уже занятом имени — фабрике это не грозит, она каждый раз собирает новое.

Чем create_app() отличается от обычного app = Flask(__name__)?

Глобальный app создаётся один раз при импорте модуля: все, кто его импортировал, делят один объект с одним конфигом и одним состоянием. create_app() возвращает новый экземпляр на каждый вызов: тесты собирают приложение с TESTING=True, рабочий скрипт — с боевым конфигом, и никто никому не мешает.

Понравился урок? Сошлитесь на него

«Фабрика приложений — это функция, которая собирает Flask-приложение заново при каждом вызове: свежий конфиг, чистое состояние.»

Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.

TelegramVK

Похожие уроки по темам

Подобраны автоматически по пересечению тем и ключевых слов.

Проверьте знания по Flask

В челлендже — 20 задач по Flask, по 2 из каждого урока этого раздела. Формат: фрагмент кода и четыре варианта — что напечатает. После ответа — вердикт и объяснение со ссылкой на урок-источник.

Тест по Flask: 20 задач