پروژه پایانی: یکپارچهسازی، رفع باگ، بازنویسی و بازبینی کد
امروز درس تازهای نیست. API آموزشگاه را در وضعیتی تحویل میگیرید که هر برنامهنویسی میشناسد: تقریباً تمام. چند آزمون شکست میخورند، چند باگ هیچ آزمونی ندارند، دو endpoint جا ماندهاند، یک endpoint زشت است و کلاینت عقب مانده است. در گروههای دونفره همه آنچه در این دوره یاد گرفتید را به کار میبرید تا پروژه درست، کامل و تمیز شود، و بعد از کدتان در یک جلسه بازبینی دفاع میکنید.
۱. برنامه نهایی چه دارد
| لایه | آنچه در این دوره ساختیم | فصل |
|---|---|---|
| چارچوب | FastAPI، روترها، مستند زنده | ۲ |
| داده ورودی و خروجی | طرحوارههای Pydantic، Create و Update و Read | ۳ |
| ساختار | تزریق وابستگی، سرویسها، تنظیمات، خطای متمرکز | ۴ |
| پایگاه داده | مدلهای تایپدار SQLAlchemy 2، رابطهها | ۵ تا ۷ |
| تحول طرح | مهاجرتهای Alembic ۰۰۰۱ تا ۰۰۰۴ | ۸ |
| فهرست حرفهای | صفحهبندی، جستوجو، فیلتر، مرتبسازی | ۸ |
| امنیت | Argon2، JWT، نقشها، قاعده مالکیت | ۹ و ۱۰ |
| قابلیتهای واقعی | بارگذاری فایل، CORS، middleware | ۱۱ |
| کارایی | async از بالا تا پایین | ۱۲ |
| اطمینان | مجموعه آزمون pytest | ۱۳ |
| مصرفکننده | کلاینت پایتونی و مجموعه Bruno | ۱۴ و ۱۵ |
۲. تقسیم زمان جلسه
| بخش | دقیقه | آنچه اتفاق میافتد |
|---|---|---|
| توجیه | ۱۰ | خواندن TASKS.md و تشکیل گروهها |
| کار عملی | ۱۲۵ | وظیفههای A تا E |
| بازبینی | ۴۵ | ارائه گروهها و پرسشهای code-review.md |
۳. شروع کار
cd S16-capstone/01_starting_project alembic upgrade head python seed.py pytest # 10 tests fail: your first to-do list python main.py # and open bruno/ in Bruno
وظیفهها با نشانههایشان توصیف شدهاند، همانطور که یک گزارش باگ به دستتان میرسد، نه با محل دقیق خرابی.
A. سبز کردن مجموعه آزمون
- «فهرست دورهها خالی است، در حالی که دوره داریم.»
- «دورهای با ظرفیت دو، دانشجوی سوم را پذیرفت.»
- «پاسخ ثبتنام چیزی را نشان میدهد که هرگز نباید نشان دهد.»
- «
GET /users/me/enrollmentsبا ۵۰۰ از کار میافتد.» - «رمزهایی مثل
12345678پذیرفته میشوند.» - «یک مدرس یک دوره را حذف کرد.»
- «هر مدرس واردشدهای دانشجویان همه دورهها را میبیند.»
B. باگهایی که هنوز آزمونی ندارند
- «فیلتر
min_price=3000000دورههای ارزان را نشان میدهد.» - «کاربری را غیرفعال کردیم، اما برنامهاش تا انقضای توکن کار میکند.»
- «دانشجویی که ثبتنامش را لغو کرده هنوز فایلهای درسی را دانلود میکند.»
- «یک مدرس ظرفیت دوره را منفی سه گذاشت.»
برای هر کدام اول یک آزمون بنویسید که شکست بخورد، بعد باگ را درست کنید و ببینید آزمون سبز میشود.
C. قابلیتهای جامانده
PATCH /users/me: کاربر نام کامل خودش را تغییر دهد، هرگز نقش یاis_activeرا.POST /users/me/password: رمز قدیمی اشتباه ۴۰۱، رمز تازه ضعیف ۴۲۲ و موفقیت ۲۰۴.
D. بازنویسی
GET /users/statsکار میکند، اما نگاهش کنید: منطق کسبوکار در روتر، کل جدولها در پایتون و حلقههای تودرتو. آن را به سرویس ببرید و بگذارید پایگاه داده باGROUP BYبشمارد. پاسخ JSON نباید تغییر کند.
@router.get('/stats')
async def stats(admin: AdminUser, db: DbSession) -> dict:
# REFACTOR ME (capstone): business logic in a router, Python loops over whole tables
users = (await db.scalars(select(User))).all()
roles = {}
for u in users:
if u.role in roles:
roles[u.role] = roles[u.role] + 1
else:
roles[u.role] = 1
courses = (await db.scalars(select(Course))).all()
active = 0
for c in courses:
if c.is_active == True: # noqa: E712
active += 1
enrollments = (await db.scalars(select(Enrollment))).all()
statuses = {}
for e in enrollments:
statuses[e.status] = statuses.get(e.status, 0) + 1
counts = {}
for e in enrollments:
if e.status == 'active':
for c in courses:
if c.id == e.course_id:
counts[c.title] = counts.get(c.title, 0) + 1
top = sorted(counts.items(), key=lambda x: -x[1])[:3]
return {
'users_per_role': roles,
'active_courses': active,
'enrollments_per_status': statuses,
'top_courses': [{'title': t, 'students': n} for t, n in top],
}
کدی که باید بازنویسی شود: 01_starting_project/app/routers/users.py
E. آزمون و کلاینت
- برای هر باگ بخش B و هر قابلیت بخش C آزمون بنویسید.
- به
client_appمتدهایupdate_me،change_password،my_enrollmentsوenrollرا با آزمون MockTransport اضافه کنید، بهعلاوه نمای دانشجو در داشبورد.
برای هر وظیفه: نشانه را در Bruno بازسازی کنید، آزمونی بنویسید که همان نشانه را نشان دهد، کد را درست کنید و کل مجموعه را دوباره اجرا کنید. هیچ درستی بدون آزمون پذیرفته نیست.
۴. یک راهحل
پوشه 02_solution یک راهحل کامل است که همه ۶۹ آزمونش سبزند. فقط بعد از بازبینی بازش کنید و با کار خودتان مقایسه کنید.
async def stats(self) -> Stats:
"""Every number is computed by the DATABASE (GROUP BY), not by Python loops."""
roles = await self.db.execute(select(User.role, func.count()).group_by(User.role))
statuses = await self.db.execute(
select(Enrollment.status, func.count()).group_by(Enrollment.status)
)
active_courses = await self.db.scalar(
select(func.count()).select_from(Course).where(Course.is_active)
)
top = await self.db.execute(
select(Course.title, func.count(Enrollment.id).label('students'))
.join(Enrollment, Enrollment.course_id == Course.id)
.where(Enrollment.status == 'active')
.group_by(Course.id)
.order_by(func.count(Enrollment.id).desc(), Course.title)
.limit(3)
)
return Stats(
users_per_role=dict(roles.tuples().all()),
active_courses=active_courses or 0,
enrollments_per_status=dict(statuses.tuples().all()),
top_courses=[{'title': title, 'students': n} for title, n in top.tuples()],
)
آمار با GROUP BY: 02_solution/app/services.py
async def update_me(self, user: User, data: UserUpdateMe) -> User:
user.full_name = data.full_name
await self.db.commit()
await self.db.refresh(user)
return user
async def change_password(self, user: User, data: PasswordChange) -> None:
valid, _ = verify_password(data.old_password, user.hashed_password)
if not valid:
raise AuthenticationError('The old password is not correct')
if data.old_password == data.new_password:
raise BusinessRuleError('The new password must be different')
user.hashed_password = hash_password(data.new_password)
await self.db.commit()
تغییر نام و رمز: 02_solution/app/services.py
async def test_disabled_user_token_stops_working(
client: AsyncClient, make_user: UserFactory, auth: Auth
) -> None:
admin, student = await make_user('admin'), await make_user('student')
headers = auth(student)
assert (await client.get('/users/me', headers=headers)).status_code == 200
await client.patch(f'/users/{student.id}', json={'is_active': False}, headers=auth(admin))
assert (await client.get('/users/me', headers=headers)).status_code == 403
آزمون باگ کاربر غیرفعال: 02_solution/tests/test_capstone.py
۵. بازبینی کد
هر گروه پنج تا هفت دقیقه پروژهاش را ارائه میدهد و بعد به پرسشهای گروه دیگر پاسخ میدهد. با کد خودتان روی صفحه پاسخ دهید و به فایل و خط اشاره کنید.
- مسیر یک درخواست،
POST /courses/{id}/enrollments، را از روتر تا پایگاه داده و برگشت دنبال کنید. به کدام فایلها دست میزند و چرا هر کدام جداست؟ - چرا برای یک دوره هم طرحواره Pydantic داریم و هم مدل SQLAlchemy؟ فیلدی نشان دهید که فقط در یکی از آنها هست.
- نگاشتهای تایپدار (
Mapped[int]) در برابر سبک قدیمیColumn(...)چه چیزی به ما میدهند؟ - پرسوجویی نشان دهید که از N+1 دوری میکند. چطور ثابتش میکنید؟
- چرا طرح پایگاه داده با Alembic تغییر میکند و نه با
create_all؟ - سه جا را نشان دهید که تزریق وابستگی به کار رفته است. آزمونها کدام را جایگزین کردند؟
- async کجا واقعاً به این API کمک میکند و کجا نه؟ اگر کسی در یک سرویس
time.sleep()صدا بزند چه میشود؟ - جریان ورود را از فرم تا JWT توضیح دهید. چه چیزی در توکن هست و چه چیزی نیست؟
- API فرق ۴۰۱ و ۴۰۳ را از کجا میفهمد؟
- کدام قاعده جلوی مدرسی را میگیرد که میخواهد دوره مدرس دیگری را تغییر دهد؟
- فایلهای بارگذاریشده چطور بررسی میشوند و کجا ذخیره میشوند؟
- آزمونهایتان چطور از API محافظت میکنند؟ یک آزمون شکست را نشان دهید که به آن افتخار میکنید.
- کلاینت پایتونی چطور با API صحبت میکند و وقتی سرور خاموش است چه میکند؟
- اگر یک روز دیگر وقت داشتید، اول چه چیزی را بهتر میکردید؟
۶. از اینجا به بعد
این دوره عمداً از چیزهایی مثل Redis، صفهای کار، Kubernetes و استقرار ابری دوری کرد تا روی پایهها تمرکز کند. قدمهای منطقی بعدی:
- استقرار: اجرای برنامه پشت یک سرور وب با چند worker و PostgreSQL واقعی.
- توکن تازهسازی (refresh token) برای ورودهای طولانی بدون توکن دسترسی بلندعمر.
- کارهای پسزمینه: فرستادن ایمیل تأیید بدون معطل کردن درخواست.
- مشاهدهپذیری: لاگ ساختاریافته با
X-Request-IDو اندازهگیری زمان پاسخها. - CI: اجرای خودکار
pytestوruffروی هر commit.
پوشه S16-capstone شامل 01_starting_project (نقطه شروع شما، اول TASKS.md را بخوانید)، 02_solution (یک راهحل کامل) و code-review.md (پرسشهای بازبینی) است.
۷. خلاصه دوره
| فصل | یک جمله |
|---|---|
| ۱ | FastAPI همان پایتون پیشرفته است که با تمام قدرت به کار رفته. |
| ۲ | مسیر، تابع، کد وضعیت؛ و روترها برای نظم. |
| ۳ | به کلاینت اعتماد نکن: هر ورودی و خروجی یک طرحواره دارد. |
| ۴ | وابستگیها ساختار را ممکن میکنند؛ سرویسها از HTTP خبر ندارند. |
| ۵ | نوع پایتونی خودِ ستون پایگاه داده است. |
| ۶ | پرسوجو را بخوان، تراکنش را بشناس، قید را تور ایمنی بدان. |
| ۷ | رابطه راحت است؛ N+1 را ببین و درستش کن. |
| ۸ | طرح پایگاه داده تاریخچه دارد؛ فهرستها صفحه دارند. |
| ۹ | رمز را هرگز ذخیره نکن؛ دو شکست ورود را یکسان پاسخ بده. |
| ۱۰ | ۴۰۱ یعنی «کیستی»، ۴۰۳ یعنی «اجازه نداری». |
| ۱۱ | به نوع فایل اعلامشده اعتماد نکن؛ بایتها را بخوان. |
| ۱۲ | async فقط وقتی کمک میکند که هیچ چیز مسدود نکند. |
| ۱۳ | آزمون شکست به اندازه آزمون موفقیت مهم است. |
| ۱۴ | یک کلاینت خوب پیچیدگی HTTP را پشت متدهای تایپدار پنهان میکند. |
| ۱۵ | فقط درخواستهای idempotent را دوباره امتحان کن. |
| ۱۶ | کدی که میتوانی از آن دفاع کنی، کدی است که میفهمی. |
تمرینهای این فصل
- همه وظیفههای A تا E فایل
TASKS.mdرا انجام دهید. - به
GET /courses/{id}/materialsصفحهبندی باPage[T]اضافه کنید. - وضعیت
completedرا اضافه کنید:POST /courses/{id}/complete/{student_id}برای مدرس دوره و یک endpoint گواهی که فقط برای دانشجوی تمامکرده یک فایل متنی برگرداند. - کل پروژه را روی PostgreSQL با asyncpg اجرا کنید و مجموعه Bruno را دوباره اجرا کنید.
- با
DATABASE_ECHO=trueتعداد دستورهای SQLGET /users/statsرا پیش و پس از بازنویسی بشمارید. - پروژه پایانی: API کتابخانه را به یک برنامه کامل با همین شکل تبدیل کنید: روترها، طرحوارهها، سرویسها، وابستگیها، تنظیمات، مدلها با رابطه، مهاجرتهای Alembic، JWT با دو نقش، بارگذاری جلد، صفحهبندی و فیلتر، دستکم سی آزمون، یک مجموعه Bruno و یک کلاینت ناهمگام با داشبورد. با پرسشهای بازبینی این فصل از آن دفاع کنید.