Связываем маршруты с действиями в базе данных: архитектура
- Объяснить разделение main.py (маршруты, HTTP) и db.py (SQL) и почему оно важно
- Сопоставлять каждый Endpoint с действием SQL и подходящим кодом состояния
- Описать, что происходит в одном запросе — от браузера через FastAPI и db.py до базы и обратно
- Реализовать и запустить GET /students и GET /students/{student_id} поверх базы данных
Что мы изучим?
Теперь мы соединяем всё: сервер из темы бэкенда и FastAPI и базу из темы баз данных. Этот урок сосредоточен на архитектуре, то есть на том, как соединяются части, и реализует первые два Endpoint-а (чтение). В следующем уроке — остальное.
Зачем это нужно?
В теме бэкенда и FastAPI сервер хранил студентов в RAM, и они исчезали при каждом перезапуске. Теперь сервер будет сохранять в базу данных: данные, которые остаются, с ограничениями и транзакциями. Это базовая архитектура почти любого бэкенда в мире: Client → API → Database.
Объяснение с основ
Два файла, две ответственности
students-api/
├── main.py ← the API layer: routes, HTTP, validation, status codes
└── db.py ← the data layer: SQL, connections, commit (from the databases topic)
MySQL in Docker (kodkod-mysql) ← where the data livesmain.py | db.py | |
|---|---|---|
| Знает о | HTTP, запросах, кодах состояния, JSON, Pydantic | SQL, mysql.connector, таблицах, commit |
| Не знает о | SQL, соединениях | HTTP, 404, FastAPI |
| Язык ошибок | HTTPException(404) | None, False, ValueError |
Это то самое «базовое разделение кода между маршрутом и кодом доступа к базе данных», которого требует программа курса. И почему это хорошо?
- Каждый файл маленький и сфокусированный (SRP, тема чистого кода).
- Каждый слой проверяется сам по себе:
db.pyмы уже проверили в предыдущем уроке, без сервера. - Один слой заменяется, не трогая другой: переход на PostgreSQL изменит только
db.py. Другой интерфейс (приложение командной строки) будет использовать тот жеdb.py.
Таблица соответствий
| Endpoint | Функция в db.py | SQL | Успех | Сбои |
|---|---|---|---|---|
GET /students | get_all_students() | SELECT | 200 + список | — |
GET /students/{id} | get_student(id) | SELECT WHERE id | 200 + dict | None → 404 |
POST /students | create_student(...) | INSERT | 201 + dict | ValueError → 409. Проверка → 400 / 422 |
PUT /students/{id} | update_student(id, ...) | UPDATE WHERE id | 200 + dict | None → 404. ValueError → 409 |
DELETE /students/{id} | delete_student(id) | DELETE WHERE id | 204 | False → 404 |
Каждый маршрут делает три вещи: (1) получает и проверяет ввод (FastAPI + проверка), (2) вызывает одну функцию в db.py, (3) переводит результат в HTTP.
Один запрос от начала до конца
GET /students/2
│
▼ Uvicorn receives the HTTP request (port 8000)
▼ FastAPI finds the route, converts "2" → 2 (int), calls get_student(2)
▼ main.get_student → db.get_student(2)
▼ db: open connection → SELECT ... WHERE id = %s (2) → fetchone → dict → close
▼ main: dict is not None → return it
▼ FastAPI: dict → JSON, status 200
▼ Uvicorn sends the HTTP response
{"id": 2, "name": "Avi", "age": 31, "email": "avi@mail.com"}Весь курс на одном пути: HTTP и порт (тема о компьютере, операционной системе и сети), функции, dict и JSON (темы Python), классы и Pydantic (тема ООП), SQL (тема баз данных).
main.py = официант: принимает заказы у посетителей, проверяет, что они осмысленны («пиццы в меню нет»), передаёт их на кухню и подаёт. db.py = кладовщик на кухне: знает, где каждый продукт, достаёт и кладёт. Официант не заходит в кладовую, а кладовщик не разговаривает с посетителями. Каждый — в своей роли.
Когда создаётся таблица?
db.init_db() вызывается один раз при запуске сервера (на верхнем уровне main.py). Благодаря IF NOT EXISTS это безопасно при каждом запуске: в первый раз таблица создаётся, а в остальные ничего не происходит.
Синтаксис
from fastapi import FastAPI, HTTPException
import db # our data layer (same folder)
app = FastAPI(title="Students API")
db.init_db() # create the table once, on startup
@app.get("/students/{student_id}")
def get_student(student_id: int):
student = db.get_student(student_id) # 1 call to the data layer
if student is None: # translate "not found" to HTTP
raise HTTPException(status_code=404, detail="student not found")
return student # dict → JSON (200)Одинаковые имена в двух слоях: функция в main.py называется get_student, как и в db.py. Столкновения нет, потому что функцию базы вызывают через имя модуля: db.get_student(...) (вспомните модули!).
Первый пример кода
Настройка проекта (контейнер MySQL должен работать — docker start kodkod-mysql):
mkdir ~\dev\kodkod6\week-4\students-api
cd ~\dev\kodkod6\week-4\students-api
python -m venv .venv
.venv\Scripts\Activate.ps1
pip install "fastapi[standard]" mysql-connector-python
git init.gitignore:
.venv/
__pycache__/
.envСкопируйте db.py из темы баз данных (полный CRUD) в папку. А main.py на этом этапе — только два Endpoint-а:
from fastapi import FastAPI, HTTPException
import db
app = FastAPI(title="Students API")
db.init_db()
@app.get("/students")
def list_students():
return db.get_all_students()
@app.get("/students/{student_id}")
def get_student(student_id: int):
student = db.get_student(student_id)
if student is None:
raise HTTPException(status_code=404, detail="student not found")
return studentДобавляем данные для проверки (база пуста! POST ещё нет). seed.py:
import db
db.init_db()
for name, age, email in [("Dana", 24, "dana@mail.com"), ("Avi", 31, "avi@mail.com")]:
try:
print(db.create_student(name, age, email))
except ValueError as error:
print("Skipped:", error)python seed.py
uvicorn main:app --reloadВ браузере:
http://127.0.0.1:8000/studentshttp://127.0.0.1:8000/students/2http://127.0.0.1:8000/students/99
Ответы:
[{"id":1,"name":"Dana","age":24,"email":"dana@mail.com"},{"id":2,"name":"Avi","age":31,"email":"avi@mail.com"}]{"id":2,"name":"Avi","age":31,"email":"avi@mail.com"}{"detail":"student not found"}Разбор кода построчно
| Код | Объяснение |
|---|---|
import db | Импорт слоя данных. main.py и db.py в одной папке (урок о модулях). |
db.init_db() | Выполняется один раз, когда Uvicorn загружает main.py: таблица готова до первого запроса. |
def list_students(): return db.get_all_students() | Маршрут в одну строку. Список (словарей) становится JSON. |
student_id: int | FastAPI преобразует и проверяет (/students/abc → 422, тема FastAPI). |
db.get_student(student_id) | Один вызов слоя данных. |
if student is None: raise HTTPException(404) | Перевод: «не найдено» из db.py → 404 из HTTP. |
seed.py | Seed = начальные данные. Отдельный скрипт, который использует тот же db.py. Доказательство, что слой не зависит от сервера. |
А теперь важный эксперимент: остановите сервер (Ctrl + C) и запустите снова. Обновите /students. Данные на месте! В отличие от темы FastAPI, они в базе — на сервере MySQL в контейнере, который записывает их на накопитель (тема о компьютере!). Их можно увидеть и в Database Client.
Ещё один пример
Ещё один Endpoint, который использует db.py с параметром запроса: поиск. Добавьте в db.py функцию search_students из практики предыдущего урока, а в main.py:
@app.get("/students/search")
def search_students(name: str):
if len(name.strip()) < 2:
raise HTTPException(status_code=400, detail="search text must have at least 2 characters")
return db.search_students(name)Где в файле? /students/search — фиксированный путь, поэтому он должен идти до /students/{student_id} (тема FastAPI, ошибка 3!). Иначе search будет пойман как student_id → 422.
GET /students/search?name=av → 200 [{"id": 2, "name": "Avi", ...}]
GET /students/search?name=a → 400 {"detail": "search text must have at least 2 characters"}
GET /students/search → 422 (name is required)Обратите внимание на разделение работы: проверка (не меньше 2 символов) — в main.py, потому что это правило API. SQL (LIKE, lower) — в db.py.
Типичная ошибка
Ошибка 1: SQL внутри маршрута.
@app.get("/students/{student_id}")
def get_student(student_id: int):
connection = mysql.connector.connect(
host="localhost", user="root", password="kodkod", database="school")
cursor = connection.cursor()
cursor.execute("SELECT * FROM students WHERE id = %s", (student_id,))
...Это «работает», но теперь SQL в двух местах, данные для подключения в двух местах, и маршрут, который делает всё. Маршрут = ввод + вызов db + перевод.
Ошибка 2: вернуть None из маршрута.
@app.get("/students/{student_id}")
def get_student(student_id: int):
return db.get_student(student_id)Для несуществующего студента возвращается 200 с null. Клиент подумает, что всё удалось. «Не найдено» = 404.
Ошибка 3: сервер не запускается — контейнер MySQL не работает. db.init_db() выполняется, когда Uvicorn загружает main.py, поэтому сервер падает уже при запуске:
mysql.connector.errors.InterfaceError: 2003: Can't connect to MySQL server on 'localhost:3306' ...Проверьте через docker ps и запустите: docker start kodkod-mysql (тема баз данных!).
Ошибка 4: ModuleNotFoundError: No module named 'db'. db.py не в той же папке, что main.py, или вы запускаете uvicorn из другой папки. Запускайте из папки проекта.
Практика
Упражнения опираются только на то, что пройдено в этом уроке и в предыдущих. Сначала попробуйте сами, и только потом открывайте решение.
Упражнение 1: Два слоя
Для каждой строки кода — к чему она относится: к main.py (слой HTTP) или к db.py (слой данных)?
cursor.execute("SELECT * FROM students WHERE id = %s", (student_id,))raise HTTPException(status_code=404, detail="student not found")@app.get("/students/{student_id}")mysql.connector.connect(host="localhost", user="root", ...)return db.get_student(student_id)
Показать решение
- db.py — SQL.
- main.py — код HTTP.
- main.py — маршрут.
- db.py — данные для подключения к базе.
- main.py — сервер вызывает слой данных.
Правило: main.py говорит на HTTP, db.py говорит на SQL. Ни один из них не знает деталей другого.
Упражнение 2: Маршруты для чтения
У вас есть db.py из предыдущего урока (с init_db, get_all_students и get_student). Напишите main.py с:
db.init_db()при запуске сервера;GET /students— все студенты;GET /students/{student_id}— один студент или 404 с"student not found".
Показать решение
main.py
from fastapi import FastAPI, HTTPException
import db
app = FastAPI(title="Students API")
db.init_db()
@app.get("/students")
def list_students():
return db.get_all_students()
@app.get("/students/{student_id}")
def get_student(student_id: int):
student = db.get_student(student_id)
if student is None:
raise HTTPException(status_code=404, detail="student not found")
return studentdb.get_student возвращает None, когда студента нет, — и main.py переводит это в 404. В этом вся роль слоя HTTP.
Упражнение 3: Один запрос — от начала до конца
Расставьте по порядку то, что происходит в запросе GET /students/2:
- (а)
db.get_student(2)выполняетSELECT ... WHERE id = %sс(2,) - (б) FastAPI преобразует
"2"из пути вint - (в) Ответ возвращается в виде JSON с кодом 200
- (г) Вызывается
main.get_student(2) - (д) Строка возвращается в виде словаря
Показать решение
Порядок: (б), (г), (а), (д), (в)
GET /students/2
-> FastAPI: student_id = 2 (int)
-> main.get_student(2)
-> db.get_student(2)
-> SELECT ... WHERE id = %s (2,)
-> {'id': 2, 'name': 'Avi', ...}
-> JSON, 200Упражнение 4: Endpoint поиска
Добавьте в main.py маршрут GET /students/search?name=..., который использует db.search_students. Имя короче 2 символов → 400. Где нужно определить маршрут и почему?
Показать решение
main.py (в дополнение к предыдущим маршрутам)
@app.get("/students/search")
def search_students(name: str):
if len(name.strip()) < 2:
raise HTTPException(status_code=400, detail="too short")
return db.search_students(name)?name=av→ 200 со списком.?name=a→ 400. Безname→ 422 (обязательный параметр запроса, потому что у него нет значения по умолчанию).- Определите
/students/searchдо/students/{student_id}— иначе FastAPI попытается преобразовать"search"в int и вернёт 422.
Упражнение 5: Что не так?
@app.get("/students/{student_id}")
def get_student(student_id: int):
connection = mysql.connector.connect(
host="localhost", user="root", password="kodkod", database="school")
cursor = connection.cursor()
cursor.execute("SELECT * FROM students WHERE id = %s", (student_id,))
return cursor.fetchone()Найдите в этом коде три проблемы.
Показать решение
- SQL внутри маршрута — смешение слоёв. Если мы изменим структуру таблицы, придётся искать SQL во всех маршрутах. Его место — в
db.py. - Соединение не закрывается — нет
close(), и уж точно не вfinally. На сервере, который работает всё время, это накапливается. - Несуществующий студент возвращается как 200 с
null— нужно проверитьNoneи вернуть 404. А ещё безdictionary=Trueстудент возвращается списком без имён полей, а не словарём.
Исправление: return db.get_student(student_id) с проверкой на None — как во втором упражнении.
Итоги
main.py= слой API: маршруты, проверка и коды состояния.db.py= слой данных: SQL, соединения и commit. Никакого SQL в маршрутах.- Соответствия: GET→SELECT, POST→INSERT (201), PUT→UPDATE, DELETE→DELETE (204).
None/False→ 404.ValueError→ 409. - Маршрут = ввод → один вызов db → перевод в HTTP.
db.init_db()— один раз при запуске сервера (IF NOT EXISTS).- Фиксированные пути (
/students/search) — до/students/{student_id}. - Данные переживают перезапуск сервера, потому что они в базе.