Files

290 lines
15 KiB
Markdown
Raw Permalink Normal View History

# معماری
<div dir="rtl">
این سند تصمیم‌های معماری پروژه و دلیل هر کدام را توضیح می‌دهد. برای اجرا و مرور سریع به [README](../README.md) مراجعه کنید.
</div>
---
## ۱. نمای کلی
```
┌────────────┐ HTTPS ┌─────────────────┐ HTTP ┌──────────────────┐
│ مرورگر │───────────▶│ nginx (frontend)│───────────▶│ gunicorn + Django│
│ React SPA │◀───────────│ :8080 │◀───────────│ :8000 │
└────────────┘ └─────────────────┘ └───────┬──────────┘
سرو باندل + پراکسی /api │
│
┌────────────────────┬──────────────────────┬─────────────┴────────┐
▼ ▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌─────────────────┐ ┌──────────────┐
│ PostgreSQL │ │ Redis │ │ Elasticsearch │ │ Logstash │
│ منبع حقیقت │ │ cache/throttle│ │ ایندکس سرچ │ │ خط لوله لاگ │
└──────────────┘ └──────────────┘ └────────┬────────┘ └──────┬───────┘
│ │
└────────┬────────────┘
▼
┌───────────┐
│ Kibana │
└───────────┘
```
---
## ۲. مسیر یک درخواست سرچ
<div dir="rtl">
از لحظه‌ای که کاربر حرفی تایپ می‌کند تا لحظه‌ای که کارت نتیجه رندر می‌شود:
</div>
```
۱. کاربر تایپ می‌کند
│ useDebouncedValue(300ms) → یک درخواست به‌جای شش تا
▼
۲. useSearchFilters وضعیت را در URL می‌نویسد (?q=python&skills=django)
│ URL منبع حقیقت UI است ⇒ نتیجه قابل اشتراک و مقاوم به refresh
▼
۳. TanStack Query با کلید ['search', filters] کش را چک می‌کند
│ اگر تازه بود، اصلاً درخواستی نمی‌رود
▼
۴. axios: هدر Bearer + رهگیر ۴۰۱ (چرخش خودکار توکن)
▼
۵. nginx: /api/... → backend:8000 (هم‌مبدأ ⇒ بدون CORS)
▼
۶. RequestIDMiddleware: X-Request-ID می‌سازد/می‌پذیرد
▼
۷. SearchRequestSerializer: اعتبارسنجی و نرمال‌سازی کوئری‌استرینگ
│ خروجی: یک SearchRequest تغییرناپذیر (frozen dataclass)
▼
۸. get_search_backend() → ElasticsearchBackend یا PostgresBackend
▼
۹. build_query(request) → بدنه کوئری → اجرا → SearchResult
▼
۱۰. SearchResponseSerializer: قالب یکسان پاسخ
▼
۱۱. audit.search: یک رویداد JSON → Logstash → Elasticsearch → Kibana
▼
۱۲. AccessLogMiddleware: خط لاگ دسترسی با duration_ms و user_id
```
---
## ۳. تصمیم‌های کلیدی
### ۳.۱ PostgreSQL منبع حقیقت، Elasticsearch یک projection
<div dir="rtl">
**تصمیم:** داده به‌صورت نرمال‌شده در Postgres ذخیره می‌شود و Elasticsearch فقط یک نسخه denormalize‌شده برای سرچ نگه می‌دارد.
**چرا:**
- ایندکس همیشه با `search_index --rebuild` از دیتابیس بازسازی می‌شود ⇒ از دست رفتن ایندکس یک حادثه عملیاتی است، نه از دست رفتن داده.
- تغییر mapping (که در Elasticsearch نیازمند reindex است) بدون ترس انجام می‌شود.
- محدودیت‌های یکتایی و ارجاعی (FK, unique) را می‌شود در جایی گذاشت که واقعاً تضمینشان می‌کند.
**هزینه:** دو نسخه از داده. با ۲۸۴ رکورد ناچیز است؛ در مقیاس بزرگ‌تر باید ایندکس‌کردن به یک صف پس‌زمینه (Celery) منتقل شود — که به‌خاطر interface تمیز `SearchBackend` تغییر کوچکی است.
</div>
### ۳.۲ لایه سرچ به‌عنوان Strategy
```python
class SearchBackend(ABC):
name: str
def search(self, request: SearchRequest) -> SearchResult: ...
def suggest(self, prefix: str, limit: int = 8) -> list[str]: ...
def ping(self) -> bool: ...
```
<div dir="rtl">
دو پیاده‌سازی: `ElasticsearchBackend` و `PostgresBackend`. انتخاب با یک متغیر محیطی (`SEARCH_BACKEND`) انجام می‌شود و `get_search_backend()` با `functools.cache` نتیجه را نگه می‌دارد (استخر اتصال یک بار در هر پروسه ساخته شود، نه در هر درخواست).
**چه چیزی به دست می‌آید:**
| مزیت | توضیح |
|---|---|
| تست‌پذیری | کل تست‌سوییت بدون کلاستر Elasticsearch اجرا می‌شود |
| قابلیت مرور | ارزیاب می‌تواند پروژه را با یک Postgres تنها اجرا کند |
| تاب‌آوری | قطعی ES ⇒ سوییچ به حالت کم‌توان، نه خواب کامل محصول |
| فشار طراحی | interface مجبورمان می‌کند منطق سرچ از منطق HTTP جدا بماند |
هیچ‌جای ویو، سریالایزر یا فرانت‌اند نمی‌داند کدام موتور جواب داده — فقط فیلد `backend` در پاسخ آن را گزارش می‌کند (برای شفافیت عملیاتی).
</div>
### ۳.۳ ویوهای نازک، لایه‌های مشخص
<div dir="rtl">
| مسئولیت | کجا |
|---|---|
| اعتبارسنجی و نرمال‌سازی ورودی | `apps/search/serializers.py` |
| منطق رتبه‌بندی و فیلتر | `apps/search/backends/*.py` |
| قالب پاسخ | `SearchResponseSerializer` |
| ماسک‌کردن داده حساس | `apps/profiles/serializers.py` + `privacy.py` |
| قالب خطا | `apps/core/exceptions.py` |
| لاگ و همبستگی درخواست | `apps/core/middleware.py` + `logging.py` |
کل `SearchView.get()` کمتر از ۲۰ خط است. این عمدی است: ویو فقط ارکستراتور است.
</div>
### ۳.۴ dataclass تغییرناپذیر به‌جای dict
<div dir="rtl">
`SearchRequest` یک `@dataclass(frozen=True, slots=True)` است، نه یک دیکشنری. سه سود:
1. type checker اشتباه نام فیلد را در زمان کامپایل می‌گیرد، نه در زمان اجرا.
2. تغییرناپذیری یعنی هیچ لایه‌ای نمی‌تواند کوئری را وسط راه عوض کند.
3. `as_log_context()` روی همان شیء زندگی می‌کند ⇒ لاگ و رفتار هیچ‌وقت از هم دور نمی‌افتند.
</div>
### ۳.۵ چرا nginx جلوی هر دو؟
<div dir="rtl">
SPA و API از یک مبدأ (`localhost:8080`) سرو می‌شوند. نتیجه:
- **بدون CORS**: نه pre-flight، نه لیست مبدأ در پروداکشن، نه یک کلاس کامل از باگ‌ها.
- **CSP سخت‌گیرانه**: چون همه‌چیز self است، `default-src 'self'` کافی است.
- **یک نقطه برای هدرهای امنیتی** و کش‌کردن asset‌های هش‌دار.
در حالت توسعه (`make dev`) به‌جای nginx، پراکسی خود Vite همین کار را می‌کند.
</div>
---
## ۴. مدل داده
```
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ Company │◀───────│ Experience │───────▶│ Profile │
│ (unique │ FK │ (سوابق شغلی)│ FK │ │
│ name) │ └──────────────┘ │ search_ │
└─────────────┘ │ vector │
▲ │ (tsvector) │
│ FK (current_company) └──────┬──────┘
└──────────────────────────────────────────────┤
│
┌─────────────┐ ┌──────────────┐ │
│ School │◀───────│ Education │───────────────┤
└─────────────┘ FK └──────────────┘ FK │
│
┌─────────────┐ ┌──────────────┐ │
│ Skill │◀───────│ ProfileSkill │───────────────┘
│ (slug uniq) │ FK │ (+position) │ FK
└─────────────┘ └──────────────┘
```
<div dir="rtl">
**ایندکس‌ها و دلیلشان:**
| ایندکس | برای چه کوئری‌ای |
|---|---|
| `GIN(search_vector)` | سرچ full-text در بک‌اند Postgres |
| `GIN(seniority_levels)` | فیلتر `seniority_levels__overlap` |
| `(location_country, job_title_role)` | ترکیب پرتکرار «کشور + خانواده شغلی» |
| `GIN(full_name gin_trgm_ops)` | شباهت trigram برای تحمل غلط تایپی |
| `GIN(name gin_trgm_ops)` روی Skill | `icontains` در type-ahead مهارت‌ها |
| `profile_count` روی Skill | مرتب‌سازی پیشنهاد فیلترها |
**ستون‌های denormalize‌شده:** `skills_text`، `companies_text`، `education_text`.
دلیل: `SearchVector` جنگو نمی‌تواند از رابطه many-to-many عبور کند. با تخت‌کردن این متن‌ها هنگام ایمپورت، به‌روزرسانی کل tsvector یک `UPDATE` تکی می‌شود و زمان کوئری از O(join) به O(ایندکس) می‌رسد.
</div>
---
## ۵. ساختار اپلیکیشن‌های جنگو
<div dir="rtl">
| اپ | مسئولیت | وابسته به |
|---|---|---|
| `core` | لاگ ساخت‌یافته، middleware، قالب خطا، صفحه‌بندی، throttle، health | — |
| `accounts` | مدل کاربر، نقش‌ها، JWT | `core` |
| `profiles` | مدل دامنه، خواننده CSV، ایمپورتر، ماسک PII، API پروفایل | `core` |
| `search` | ایندکس ES، بک‌اندهای سرچ، API سرچ | `core`, `profiles` |
جهت وابستگی یک‌طرفه است: `search → profiles → core`. هیچ import حلقوی‌ای وجود ندارد (تنها استثنا `core.views` است که برای health-check بک‌اند سرچ را import می‌کند — به‌صورت lazy داخل تابع).
</div>
---
## ۶. پیکربندی
<div dir="rtl">
تنظیمات به چهار ماژول تقسیم شده‌اند:
```
config/settings/
├── base.py مشترک؛ همه اسرار از متغیر محیطی
├── dev.py DEBUG، Browsable API، بدون HTTPS
├── prod.py HSTS، کوکی امن، و «fail fast» روی پیکربندی ناامن
└── test.py سریع، بدون سرویس خارجی، بک‌اند Postgres
```
`prod.py` عمداً روی این دو حالت **بالا نمی‌آید**:
</div>
```python
if DEBUG:
raise RuntimeError("DJANGO_DEBUG must be false when using production settings.")
if len(SECRET_KEY) < 50:
raise RuntimeError("DJANGO_SECRET_KEY must be at least 50 characters in production.")
```
<div dir="rtl">
یک deploy که شکست می‌خورد بی‌نهایت بهتر از یک deploy است که با `DEBUG=True` بالا می‌آید.
</div>
---
## ۷. قابلیت اتکا
<div dir="rtl">
| سناریو | رفتار سیستم |
|---|---|
| Redis از دسترس خارج شود | `IGNORE_EXCEPTIONS=True` ⇒ کش بی‌صدا رد می‌شود، API سالم می‌ماند (throttle موقتاً سخت‌گیر نیست) |
| Logstash از دسترس خارج شود | ارسال لاگ async است ⇒ فقط یک warning، هیچ درخواستی بلاک نمی‌شود |
| Elasticsearch از دسترس خارج شود | `SearchBackendError` ⇒ خطای ۵۰۳ با پیام روشن؛ `/health/ready/` هم ۵۰۳ می‌دهد تا orchestrator ترافیک نفرستد. با `SEARCH_BACKEND=postgres` سرویس ادامه می‌دهد |
| یک ردیف دیتاست خراب باشد | ایمپورتر آن رکورد را لاگ و رد می‌کند، بقیه ایمپورت می‌شوند |
| reindex نیمه‌کاره بماند | ایندکس فیزیکی جدید ساخته و **بعد** alias جابه‌جا می‌شود ⇒ کاربر هیچ‌وقت ایندکس نیمه‌پر نمی‌بیند |
</div>
---
## ۸. اگر قرار بود مقیاس بگیرد
<div dir="rtl">
با ۲۸۴ رکورد بسیاری از این‌ها لازم نیست، ولی مسیر رشد از قبل باز گذاشته شده:
| محدودیت فعلی | قدم بعدی |
|---|---|
| ایندکس‌گذاری همزمان با ایمپورت | انتقال به Celery + صف؛ `SearchBackend` تغییری نمی‌کند |
| facetها در بک‌اند Postgres در پایتون شمرده می‌شوند | جایگزینی با `unnest` + `GROUP BY` یا فقط اتکا به ES |
| صفحه‌بندی `from/size` | `search_after` برای اسکرول عمیق |
| یک نود Elasticsearch | کلاستر چندنودی + replica؛ `number_of_replicas` در `INDEX_SETTINGS` |
| به‌روزرسانی کامل ایندکس | ایندکس افزایشی با سیگنال‌های مدل (`index_profile` از قبل نوشته شده) |
| بدون CDN | سرو asset‌های هش‌دار از CDN؛ هدرهای cache از قبل درست‌اند |
</div>