290 lines
15 KiB
Markdown
290 lines
15 KiB
Markdown
|
|
# معماری
|
|||
|
|
|
|||
|
|
<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>
|