تغليف الشيفرة ونشرها
جعل الكود يعمل كما هو مقصود أمرٌ صعب؛ وجعل نفس الكود يعمل على جهازٍ مختلفٍ عن جهازك غالبًا أصعب.
يعني شحن الكود (shipping code) أخذ الكود الذي كتبته وتحويله إلى صورةٍ قابلةٍ للاستخدام يمكن لشخصٍ آخر تشغيلها دون إعداد جهازك بالضبط. يتخذ شحن الكود أشكالًا متعددةً ويعتمد على اختيارات لغة البرمجة، ومكتبات النظام، ونظام التشغيل، وغيرها من العوامل الكثيرة. كما يعتمد على ما تبنيه: فالمكتبة البرمجية (software library)، والأداة القابلة للتشغيل من سطر الأوامر (command line tool)، وخدمة الويب، جميعها لها متطلباتٌ وخطوات نشرٍ مختلفة. وعلى أي حال، هناك نمطٌ مشتركٌ بين كل هذه السيناريوهات: نحتاج إلى تحديد ما هو المنتج النهائي — أي القطعة (artifact) — وما الافتراضات التي يضعها حول البيئة المحيطة به.
في هذه المحاضرة سنغطي:
- التبعيات والبيئات (Dependencies & Environments)
- القطع والتغليف (Artifacts & Packaging)
- الإصدارات والترقيم (Releases & Versioning)
- إعادة الإنتاجية (Reproducibility)
- الأجهزة الافتراضية والحاويات (VMs & Containers)
- الإعدادات (Configuration)
- الخدمات والتنسيق (Services & Orchestration)
- النشر (Publishing)
سنشرح هذه المفاهيم من خلال أمثلةٍ من نظام بايثون (Python) البيئي، لأن الأمثلة الملموسة تساعد في الفهم. ورغم أن الأدوات تختلف في النظم البيئية للغات الأخرى، فستكون المفاهيم متشابهةً إلى حدٍ كبير.
التبعيات والبيئات
في تطوير البرمجيات الحديثة، تنتشر طبقات التجريد (abstractions) في كل مكان. تحمّل البرامج منطقها بطبيعة الحال إلى مكتباتٍ أو خدماتٍ أخرى. لكن هذا يقدّم علاقة تبعيةٍ (dependency) بين برنامجك والمكتبات التي يحتاجها ليعمل. على سبيل المثال، في بايثون، لجلب محتوى موقعٍ ما غالبًا ما نفعل:
import requests
response = requests.get("https://missing.csail.mit.edu")
ومع ذلك، فإن مكتبة requests لا تأتي مضمّنةً مع بيئة تشغيل بايثون، لذا إذا حاولنا تشغيل هذا الكود دون تثبيت requests، سيرفع بايثون خطأً:
$ python fetch.py
Traceback (most recent call last):
File "fetch.py", line 1, in <module>
import requests
ModuleNotFoundError: No module named 'requests'
لجعل هذه المكتبة متاحةً نحتاج أولًا إلى تشغيل pip install requests لتثبيتها.
pip هي الأداة من سطر الأوامر التي توفرها لغة برمجة بايثون لتثبيت الحزم (packages).
ينتج عن تنفيذ pip install requests تسلسل الإجراءات التالي:
- البحث عن
requestsفي فهرس حزم بايثون (PyPI) - البحث عن القطعة المناسبة للمنصة (platform) التي نعمل عليها
- حلّ التبعيات — فمكتبة
requestsنفسها تعتمد على حزمٍ أخرى، لذا يجب على أداة التثبيت أن تجد إصداراتٍ متوافقةً من جميع التبعيات غير المباشرة (transitive dependencies) وتثبيتها مسبقًا - تنزيل القطع، ثم فكّ ضغطها ونسخ الملفات إلى الأماكن الصحيحة في نظام ملفاتنا
$ pip install requests
Collecting requests
Downloading requests-2.32.3-py3-none-any.whl (64 kB)
Collecting charset-normalizer<4,>=2
Downloading charset_normalizer-3.4.0-cp311-cp311-manylinux_x86_64.whl (142 kB)
Collecting idna<4,>=2.5
Downloading idna-3.10-py3-none-any.whl (70 kB)
Collecting urllib3<3,>=1.21.1
Downloading urllib3-2.2.3-py3-none-any.whl (126 kB)
Collecting certifi>=2017.4.17
Downloading certifi-2024.8.30-py3-none-any.whl (167 kB)
Installing collected packages: urllib3, idna, charset-normalizer, certifi, requests
Successfully installed certifi-2024.8.30 charset-normalizer-3.4.0 idna-3.10 requests-2.32.3 urllib3-2.2.3
هنا يمكننا أن نرى أن requests لها تبعياتها الخاصة مثل certifi أو charset-normalizer، وأنه يجب تثبيتها قبل أن يمكن تثبيت requests.
بمجرد التثبيت، يمكن لبيئة تشغيل بايثون العثور على هذه المكتبة عند استيرادها.
$ python -c 'import requests; print(requests.__path__)'
['/usr/local/lib/python3.11/dist-packages/requests']
$ pip list | grep requests
requests 2.32.3
تمتلك لغات البرمجة أدواتٍ وأعرافًا وممارساتٍ مختلفةً لتثبيت المكتبات ونشرها.
في بعض اللغات مثل Rust، تكون مجموعة الأدوات موحّدة — cargo يتولى البناء والاختبار وإدارة التبعيات والنشر.
وفي لغاتٍ أخرى مثل بايثون، يحدث التوحيد على مستوى المواصفات — فبدلًا من أداةٍ واحدة، هناك مواصفاتٌ معياريةٌ تحدد كيفية عمل التغليف، مما يسمح بوجود أدواتٍ متعددةٍ متزاحمةٍ لكل مهمة (pip مقابل uv وsetuptools مقابل hatch مقابل poetry).
وفي بعض النظم البيئية مثل LaTeX، تأتي التوزيعات مثل TeX Live أو MacTeX مضمّنةً بآلاف الحزم المثبّتة مسبقًا.
تقديم التبعيات يقدّم أيضًا تعارضاتِ التبعيات.
تحدث التعارضات عندما تتطلب البرامج إصداراتٍ غير متوافقةٍ من نفس التبعية.
على سبيل المثال، إذا كان tensorflow==2.3.0 يتطلب numpy>=1.16.0,<1.19.0 وكان pandas==1.2.0 يتطلب numpy>=1.16.5، فسيكون أي إصدارٍ يحقق numpy>=1.16.5,<1.19.0 صالحًا.
لكن إذا كانت حزمةٌ أخرى في مشروعك تتطلب numpy>=1.19، فستواجه تعارضًا دون إصدارٍ صالحٍ يحقق جميع القيود.
هذه الحالة — حيث تتطلب حزمٌ متعددةٌ إصداراتٍ متعارضةً بشكلٍ متبادلٍ من تبعياتٍ مشتركة — يُشار إليها عادةً باسم جحيم التبعيات (dependency hell). إحدى طرق التعامل مع التعارضات هي عزل تبعيات كل برنامجٍ في بيئته الخاصة. في بايثون ننشئ بيئةً افتراضيةً بتشغيل:
$ which python
/usr/bin/python
$ pwd
/home/missingsemester
$ python -m venv venv
$ source venv/bin/activate
$ which python
/home/missingsemester/venv/bin/python
$ which pip
/home/missingsemester/venv/bin/pip
$ python -c 'import requests; print(requests.__path__)'
['/home/missingsemester/venv/lib/python3.11/site-packages/requests']
$ pip list
Package Version
------- -------
pip 24.0
يمكنك التفكير في البيئة على أنها نسخةٌ مستقلةٌ كاملةٌ من بيئة تشغيل اللغة مع مجموعتها الخاصة من الحزم المثبّتة. تعزل هذه البيئة الافتراضية (venv) التبعيات المثبّتة عن تثبيت بايثون العام (global). من الممارسات الجيدة أن يكون لديك بيئة افتراضية لكل مشروع، تحتوي على التبعيات التي يتطلبها.
بينما تأتي العديد من أنظمة التشغيل الحديثة مزوّدةً بتثبيتاتٍ لبيئات تشغيل لغات البرمجة مثل بايثون، فمن غير الحكيم تعديل هذه التثبيتات لأن نظام التشغيل قد يعتمد عليها لعمل وظائفه. فضّل استخدام بيئاتٍ منفصلةٍ بدلًا من ذلك.
في بعض اللغات، لا يُعرَّف بروتوكول التثبيت بواسطة أداةٍ بل كمواصفةٍ.
في بايثون، يعرّف PEP 517 واجهة نظام البناء، ويحدد PEP 621 كيفية تخزين بيانات المشروع في pyproject.toml.
مكّن هذا المطوّرين من التحسين على pip وإنتاج أدواتٍ أكثر تحسينًا مثل uv. لتثبيت uv يكفي تشغيل pip install uv.
يتبع استخدام uv بدلًا من pip نفس الواجهة لكنه أسرع بشكلٍ ملحوظ:
$ uv pip install requests
Resolved 5 packages in 12ms
Prepared 5 packages in 0.45ms
Installed 5 packages in 8ms
+ certifi==2024.8.30
+ charset-normalizer==3.4.0
+ idna==3.10
+ requests==2.32.3
+ urllib3==2.2.3
نوصي بشدةٍ باستخدام
uv pipبدلًا منpipكلما أمكن لأنه يقلل بشكلٍ كبيرٍ من وقت التثبيت.
إلى جانب عزل التبعيات، تتيح لك البيئات أيضًا الحصول على إصداراتٍ مختلفةٍ من بيئة تشغيل لغتك البرمجية.
$ uv venv --python 3.12 venv312
Using CPython 3.12.7
Creating virtual environment at: venv312
$ source venv312/bin/activate && python --version
Python 3.12.7
$ uv venv --python 3.11 venv311
Using CPython 3.11.10
Creating virtual environment at: venv311
$ source venv311/bin/activate && python --version
Python 3.11.10
يساعد هذا عندما تحتاج إلى اختبار كودك عبر إصدارات بايثون متعددةٍ أو عندما يتطلب مشروعٌ ما إصدارًا معيّنًا.
في بعض لغات البرمجة، يحصل كل مشروعٍ تلقائيًا على بيئته الخاصة لتبعياته بدلًا من إنشائها يدويًا، لكن المبدأ هو نفسه. معظم اللغات هذه الأيام تمتلك أيضًا آليةً لإدارة إصداراتٍ متعددةٍ من اللغة على نظامٍ واحد، ثم تحديد الإصدار الذي يجب استخدامه لمشاريع فردية.
القطع والتغليف
في تطوير البرمجيات نفرّق بين الكود المصدري (source code) والقطع (artifacts). يكتب المطورون ويقرؤون الكود المصدري، بينما القطع هي المخرجات المغلفة القابلة للتوزيع والمنتجة من ذلك الكود المصدري — الجاهزة للتثبيت أو النشر.
يمكن أن تكون القطعة بسيطةً كملف كودٍ نشغّله، ومعقدةً كجهازٍ افتراضيٍّ كاملٍ يحتوي على جميع المكونات الضرورية لتطبيقٍ ما.
لنأخذ هذا المثال حيث لدينا ملف بايثون greet.py في مجلدنا الحالي:
$ cat greet.py
def greet(name):
return f"Hello, {name}!"
$ python -c "from greet import greet; print(greet('World'))"
Hello, World!
$ cd /tmp
$ python -c "from greet import greet; print(greet('World'))"
ModuleNotFoundError: No module named 'greet'
يفشل الاستيراد بمجرد انتقالنا إلى مجلدٍ مختلفٍ لأن بايثون يبحث عن الوحدات في مواقع محددة فقط (المجلد الحالي، الحزم المثبّتة، والمسارات في PYTHONPATH). يحلّ التغليف هذه المشكلة بتثبيت الكود في موقعٍ معروف.
في بايثون، يتضمن تغليف مكتبةٍ إنتاج قطعةٍ يمكن لأدوات التثبيت مثل pip أو uv استخدامها لتثبيت الملفات ذات الصلة.
تُسمّى قطع بايثون العجلات (wheels) وتحتوي على جميع المعلومات الضرورية لتثبيت حزمةٍ: ملفات الكود، وبيانات وصفيةٌ عن الحزمة (الاسم، الإصدار، التبعيات)، وتعليماتٌ حول مكان وضع الملفات في البيئة.
يتطلب بناء قطعةٍ كتابة ملف مشروعٍ (يُعرف أيضًا غالبًا باسم بيان المشروع manifest) يوضّح تفاصيل المشروع، والتبعيات المطلوبة، وإصدار الحزمة، وغيرها من المعلومات. في بايثون، نستخدم pyproject.toml لهذا الغرض.
pyproject.tomlهو الأسلوب الحديث والموصى به. بينما ما تزال طرق التغليف الأقدم مثلrequirements.txtأوsetup.pyمدعومةً، يجب أن تفضّلpyproject.tomlكلما أمكن.
هذا pyproject.toml بسيطٌ لمكتبةٍ توفر أيضًا أداةً من سطر الأوامر:
[project]
name = "greeting"
version = "0.1.0"
description = "A simple greeting library"
dependencies = ["typer>=0.9"]
[project.scripts]
greet = "greeting:cli"
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
مكتبة typer حزمة بايثون شائعةٌ لإنشاء واجهات سطر الأوامر بأقل قدرٍ من الكود التمهيدي (boilerplate).
وgreeting.py المقابل:
import typer
def greet(name: str) -> str:
return f"Hello, {name}!"
def cli():
typer.run(greet)
if __name__ == "__main__":
cli()
بهذا الملف، يمكننا الآن بناء العجلة:
$ uv build
Building source distribution...
Building wheel from source distribution...
Successfully built dist/greeting-0.1.0.tar.gz
Successfully built dist/greeting-0.1.0-py3-none-any.whl
$ ls dist/
greeting-0.1.0-py3-none-any.whl
greeting-0.1.0.tar.gz
ملف .whl هو العجلة (أرشيف zip ذو بنيةٍ محددة)، و.tar.gz هو توزيعٌ مصدريٌّ للأنظمة التي تحتاج إلى البناء من المصدر.
يمكنك فحص محتويات عجلةٍ لترى ما الذي يُغلف:
$ unzip -l dist/greeting-0.1.0-py3-none-any.whl
Archive: dist/greeting-0.1.0-py3-none-any.whl
Length Date Time Name
--------- ---------- ----- ----
150 2024-01-15 10:30 greeting.py
312 2024-01-15 10:30 greeting-0.1.0.dist-info/METADATA
92 2024-01-15 10:30 greeting-0.1.0.dist-info/WHEEL
9 2024-01-15 10:30 greeting-0.1.0.dist-info/top_level.txt
435 2024-01-15 10:30 greeting-0.1.0.dist-info/RECORD
--------- -------
998 5 files
الآن، إذا أعطينا هذه العجلة لشخصٍ آخر، فسيمكنهم تثبيتها بتشغيل:
$ uv pip install ./greeting-0.1.0-py3-none-any.whl
$ greet Alice
Hello, Alice!
سيثبّت هذا المكتبة التي بنيناها سابقًا في بيئتهم، بما في ذلك أداة greet من سطر الأوامر.
هناك حدودٌ لهذا الأسلوب. على وجه الخصوص، إذا كانت مكتبتنا تعتمد على مكتباتٍ خاصةٍ بالمنصة، مثل CUDA لتسريع وحدة معالجة الرسوميات، فإن قطعتنا تعمل فقط على الأنظمة التي تحتوي على تلك المكتبات المحددة المثبّتة، وقد نحتاج إلى بناء عجلاتٍ منفصلةٍ لمنصاتٍ (لينكس، macOS، ويندوز) ومعمارياتٍ (x86، ARM) مختلفة.
عند تثبيت البرامج، هناك تمييزٌ مهمٌّ بين التثبيت من المصدر وتثبيت ملفٍ ثنائيٍّ مُبنى مسبقًا (prebuilt binary). التثبيت من المصدر يعني تنزيل الكود الأصلي وترجمته على جهازك — وهذا يتطلب تثبيت مترجمٍ وأدوات بناء، وقد يستغرق وقتًا طويلًا للمشاريع الكبيرة.
تثبيت ملفٍ ثنائيٍّ مُبنى مسبقًا يعني تنزيل قطعةٍ ترجمها شخصٌ آخر — أسرع وأبسط، لكن يجب أن يطابق الملف الثنائي منصتك ومعماريتك. على سبيل المثال، تعرض صفحة إصدارات ripgrep ملفاتٍ ثنائيةً مبنيةً مسبقًا للينكس (x86_64، ARM)، وmacOS (Intel، Apple Silicon)، وويندوز.
الإصدارات والترقيم
يُبنى الكود في عمليةٍ مستمرةٍ لكنه يُطلق على أساسٍ منفصل. في تطوير البرمجيات هناك تمييزٌ واضحٌ بين بيئتي التطوير والإنتاج. يجب إثبات أن الكود يعمل في بيئة التطوير (dev) قبل شحنه إلى الإنتاج (prod). تتضمن عملية الإصدار خطواتٍ كثيرة، بما في ذلك الاختبار، وإدارة التبعيات، والترقيم، والإعدادات، والنشر، والإتاحة.
المكتبات البرمجية ليست ثابتةً بل تتطور عبر الزمن وتحصل على إصلاحاتٍ وميزاتٍ جديدة. نتتبع هذا التطور بمعرّفات إصداراتٍ منفصلةٍ (version identifiers) تقابل حالة المكتبة عند نقطةٍ زمنيةٍ معينة. يمكن أن تتراوح التغييرات في سلوك مكتبةٍ من التصحيحات (patches) التي تصلح وظيفةً غير حرجة، إلى ميزاتٍ جديدةٍ توسّع وظائفها، إلى تغييراتٍ تكسر التوافقية الرجعية (backwards compatibility). توثّق سجلات التغيير (changelogs) التغييرات التي يُدخلها إصدارٌ ما — وهذه وثائقٌ يستخدمها مطورو البرمجيات للتواصل حول التغييرات المرتبطة بإصدارٍ جديد.
ومع ذلك، فإن تتبّع التغييرات المستمرة في كل تبعيةٍ وتبعيةٍ غير عملية، بل أكثر من ذلك عندما نأخذ في الاعتبار التبعيات غير المباشرة — أي تبعيات تبعياتنا.
يمكنك تصوّر شجرة التبعيات الكاملة لمشروعك بالأمر
uv tree، الذي يعرض جميع الحزم وتبعياتها غير المباشرة بصيغةٍ شجرية.
لتبسيط هذه المشكلة توجد أعرافٌ حول كيفية ترقيم البرامج، ومن أكثرها انتشارًا الترقيم الدلالي أو SemVer. بموجب الترقيم الدلالي يكون للإصدار معرّفٌ بصيغة MAJOR.MINOR.PATCH حيث تأخذ كلٌّ من القيم رقمًا صحيحًا. الملخص باختصارٍ: الترقية:
- PATCH (مثل 1.2.3 ← 1.2.4) يجب أن تحتوي على إصلاحات أخطاءٍ فقط وتكون متوافقةً رجعيًا بالكامل
- MINOR (مثل 1.2.3 ← 1.3.0) تضيف وظيفةً جديدةً بطريقةٍ متوافقةٍ رجعيًا
- MAJOR (مثل 1.2.3 ← 2.0.0) تشير إلى تغييراتٍ قاصمةٍ قد تتطلب تعديلاتٍ في الكود
هذا تبسيطٌ ونشجع على قراءة مواصفة SemVer الكاملة لفهم مثلًا لماذا قد يسبب الانتقال من 0.1.3 إلى 0.2.0 تغييراتٍ قاصمةً أو ماذا يعني 1.0.0-rc.1. يدعم تغليف بايثون الترقيم الدلالي بشكلٍ أصلي، لذا عند تحديد إصدارات تبعياتنا يمكننا استخدام محدداتٍ مختلفة:
في pyproject.toml لدينا طرقٌ مختلفةٌ لتقييد نطاقات الإصدارات المتوافقة من تبعياتنا:
[project]
dependencies = [
"requests==2.32.3", # Exact version - only this specific version
"click>=8.0", # Minimum version - 8.0 or newer
"numpy>=1.24,<2.0", # Range - at least 1.24 but less than 2.0
"pandas~=2.1.0", # Compatible release - >=2.1.0 and <2.2.0
]
توجد محددات الإصدارات عبر العديد من مديري الحزم (npm، cargo، إلخ) بدلالاتٍ دقيقةٍ متفاوتة. المشغّل ~= هو مشغّل "الإصدار المتوافق" الخاص ببايثون — ~=2.1.0 تعني "أي إصدارٍ متوافقٍ مع 2.1.0"، والذي يترجم إلى >=2.1.0 و<2.2.0. وهذا مكافئٌ تقريبًا للمشغّل ^ (الرمز الإطاري caret) في npm وcargo، الذي يتبع مفهوم التوافق في SemVer.
ليست كل البرامج تستخدم الترقيم الدلالي. البديل الشائع هو الترقيم بالتقويم (Calendar Versioning / CalVer)، حيث تُبنى الإصدارات على تواريخ الإصدار بدلًا من المعنى الدلالي. على سبيل المثال، يستخدم أوبونتو إصداراتٍ مثل 24.04 (أبريل 2024) و24.10 (أكتوبر 2024). يجعل CalVer من السهل معرفة مدى قِدم الإصدار، وإن كان لا يوصل أي شيءٍ عن التوافق. أخيرًا، الترقيم الدلالي ليس معصومًا من الخطأ، وأحيانًا يُدخل صانعو الصيانة تغييراتٍ قاصمةً بطريق الخطأ في إصدارات فرعية أو تصحيحية.
إعادة الإنتاجية
في تطوير البرمجيات الحديثة، يجلس الكود الذي تكتبه فوق عددٍ كبيرٍ من طبقات التجريد. يتضمن ذلك أشياءً مثل بيئة تشغيل لغة البرمجة، ومكتبات الجهات الخارجية، ونظام التشغيل، أو حتى العتاد نفسه. أي اختلافٍ عبر أيٍّ من هذه الطبقات قد يغيّر سلوك كودك أو حتى يمنعه من العمل كما هو مقصود. علاوةً على ذلك، حتى الاختلافات في العتاد الأساسي تؤثر على قدرتك على شحن البرامج.
يشير تثبيت التبعية بإصدارٍ محدد (pinning) إلى تحديد إصدارٍ دقيقٍ بدلًا من نطاقٍ، مثل requests==2.32.3 بدلًا من requests>=2.0.
جزءٌ من مهمة مدير الحزم هو النظر في جميع القيود التي توفرها التبعيات — والتبعيات غير المباشرة — ثم إنتاج قائمة إصداراتٍ صالحةٍ تلبي جميع القيود. يمكن بعد ذلك حفظ قائمة الإصدارات المحددة في ملفٍ لأغراض إعادة الإنتاجية؛ تُشار إلى هذه الملفات باسم ملفات القفل (lock files).
$ uv lock
Resolved 12 packages in 45ms
$ cat uv.lock | head -20
version = 1
requires-python = ">=3.11"
[[package]]
name = "certifi"
version = "2024.8.30"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/...", hash = "sha256:..." }
wheels = [
{ url = "https://files.pythonhosted.org/...", hash = "sha256:..." },
]
...
أحد التمييزات الحاسمة عند التعامل مع ترقيم التبعيات وإعادة الإنتاجية هو الفرق بين المكتبات والتطبيقات/الخدمات. المكتبة مصمّمةٌ لتُستورد وتُستخدم بواسطة كودٍ آخر قد يكون له تبعياته الخاصة، لذا فإن تحديد قيود إصداراتٍ شديدة الصرامة قد يسبب تعارضاتٍ مع تبعيات المستخدم الأخرى. في المقابل، التطبيقات أو الخدمات هي مستهلكةٌ نهائيةٌ للبرامج وتُظهر وظائفها عادةً من خلال واجهة مستخدمٍ أو واجهة برمجة تطبيقات (API)، لا من خلال واجهة برمجة. بالنسبة للمكتبات، من الممارسات الجيدة تحديد نطاقات إصداراتٍ لتعظيم التوافق مع النظام البيئي الأوسع للحزم. أما بالنسبة للتطبيقات، فالتثبيت بإصداراتٍ دقيقةٍ يضمن إعادة الإنتاجية — كل من يشغّل التطبيق يستخدم نفس التبعيات بالضبط.
بالنسبة للمشاريع التي تتطلب أقصى درجات إعادة الإنتاجية، توفر أدواتٌ مثل Nix وBazel عمليات بناءٍ مُحكمة (hermetic) — حيث يتم تثبيت كل مُدخلٍ بما في ذلك المترجمون، ومكتبات النظام، وحتى بيئة البناء نفسها، وتخزينه بعناوين محتوى (content-addressed). يضمن هذا مخرجاتٍ مطابقةً بتًا بتًا (bit-for-bit) بغض النظر عن متى أو أين يُنفَّذ البناء.
يمكنك حتى استخدام NixOS لإدارة تثبيت حاسوبك بالكامل بحيث يمكنك بسهولةٍ إطلاق نسخٍ جديدةٍ من إعداد حاسوبك وإدارة إعدادها الكامل من خلال ملفات إعدادٍ خاضعةٍ للتحكم بالإصدارات.
توترٌ أبديٌّ في تطوير البرمجيات: تُدخل إصدارات البرامج الجديدة أعطالًا عن قصدٍ أو عن غير قصد، بينما من ناحيةٍ أخرى تصبح إصدارات البرامج القديمة معرّضةً لثغراتٍ أمنيةٍ بمرور الوقت. يمكننا معالجة هذا باستخدام خطوط التكامل المستمر (continuous integration) (سنرى المزيد في محاضرة جودة الكود وCI) التي تختبر تطبيقنا مقابل إصدارات البرامج الجديدة، وبوجود أتمتةٍ لاكتشاف متى تُصدر إصداراتٌ جديدةٌ من تبعياتنا، مثل Dependabot.
حتى مع وجود اختبار CI، ما تزال المشكلات تحدث عند ترقية إصدارات البرامج، غالبًا بسبب عدم التطابق الحتمي بين بيئتي التطوير والإنتاج. في تلك الظروف، أفضل مسارٍ للعمل هو وجود خطة تراجعٍ (rollback)، حيث يُرجَع إصدار الترقية ويُعاد نشر إصدارٍ معروفٍ أنه جيدٌ بدلًا منه.
الأجهزة الافتراضية والحاويات
عندما تبدأ بالاعتماد على تبعياتٍ أكثر تعقيدًا، فمن المرجح أن تتجاوز تبعيات كودك حدود ما يمكن لمدير الحزم التعامل معه. أحد الأسباب الشائعة هو الحاجة إلى التفاعل مع مكتبات نظامٍ معينةٍ أو برامج تشغيل عتاد (drivers). على سبيل المثال، في الحوسبة العلمية والذكاء الاصطناعي، تحتاج البرامج غالبًا إلى مكتباتٍ وبرامج تشغيل متخصصةٍ لاستخدام عتاد وحدة معالجة الرسوميات (GPU). العديد من التبعيات على مستوى النظام (برامج تشغيل GPU، إصدارات مترجمٍ محددة، مكتباتٌ مشتركةٌ مثل OpenSSL) ما تزال تتطلب تثبيتًا على مستوى النظام.
حُلّت هذه المشكلة الأوسع للتبعيات تقليديًا باستخدام الأجهزة الافتراضية (Virtual Machines / VMs). تجرد الأجهزة الافتراضية الحاسوب بأكمله وتوفر بيئةً معزولةً تمامًا بنظام تشغيلٍ مخصصٍ خاصٍ بها. النهج الأحدث هو الحاويات (containers)، التي تغلّف تطبيقًا مع تبعياته ومكتباته ونظام ملفاته، لكنها تشارك نواة نظام التشغيل الخاص بالمضيف بدلًا من تجريد حاسوبٍ بأكمله. الحاويات أخف وزنًا من الأجهزة الافتراضية لأنها تشارك النواة، مما يجعلها أسرع في الإقلاع وأكثر كفاءةً في التشغيل.
منصة الحاويات الأكثر شعبيةً هي Docker. قدمت Docker طريقةً موحّدةً لبناء الحاويات وتوزيعها وتشغيلها. تحت الغطاء، تستخدم Docker containerd كبيئة تشغيلٍ للحاويات — وهو معيارٌ صناعيٌّ تستخدمه أيضًا أدواتٌ أخرى مثل Kubernetes.
تشغيل حاويةٍ أمرٌ مباشر. على سبيل المثال، لتشغيل مفسّر بايثون داخل حاويةٍ نستخدم docker run (تجعل العلمتان -it الحاوية تفاعليةً مع طرفيةٍ. عند خروجك، تتوقف الحاوية.):
$ docker run -it python:3.12 python
Python 3.12.7 (main, Nov 5 2024, 02:53:25) [GCC 12.2.0] on linux
>>> print("Hello from inside a container!")
Hello from inside a container!
عمليًا، قد يعتمد برنامجك على نظام الملفات بأكمله. للتغلب على هذا، يمكننا استخدام صور الحاويات (container images) التي تُشحن بنظام ملفات التطبيق بالكامل كقطعة. تُنشأ صور الحاويات برمجيًا. مع docker نحدد بالضبط التبعيات ومكتبات النظام وإعدادات الصورة باستخدام صيغة ملف Dockerfile:
FROM python:3.12
RUN apt-get update
RUN apt-get install -y gcc
RUN apt-get install -y libpq-dev
RUN pip install numpy
RUN pip install pandas
COPY . /app
WORKDIR /app
RUN pip install .
تمييزٌ مهمٌّ: صورة Docker هي القطعة المغلفة (مثل قالبٍ)، بينما الحاوية هي مثيلٌ قيد التشغيل لتلك الصورة. يمكنك تشغيل حاوياتٍ متعددةٍ من نفس الصورة. تُبنى الصور في طبقاتٍ (layers)، حيث تنشئ كل تعليمةٍ (FROM, RUN, COPY, إلخ) في ملف Dockerfile طبقةً جديدةً. يخزّن Docker هذه الطبقات مؤقتًا (caches)، لذا إذا غيّرت سطرًا في ملف Dockerfile، فستحتاج فقط إلى إعادة بناء تلك الطبقة والطبقات التي تليها.
يمتلك ملف Dockerfile السابق عدة مشكلات: يستخدم صورة بايثون الكاملة بدلًا من نسخةٍ نحيفة (slim)، ويشغّل أوامر RUN منفصلةً تنشئ طبقاتٍ غير ضرورية، ولا تُثبَّت الإصدارات، ولا ينظّف مخازن مدير الحزم المؤقتة، مما يشحن ملفاتٍ غير ضرورية. تشمل الأخطاء المتكررة الأخرى تشغيل الحاويات بشكلٍ غير آمنٍ بصلاحيات الجذر (root) وتضمين أسرارٍ (secrets) بطريق الخطأ في الطبقات.
هذه نسخةٌ محسّنةٌ:
FROM python:3.12-slim
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
RUN apt-get update && \
apt-get install -y --no-install-recommends gcc libpq-dev && \
rm -rf /var/lib/apt/lists/*
COPY pyproject.toml uv.lock ./
RUN uv pip install --system -r uv.lock
COPY . /app
في المثال السابق نرى أنه بدلًا من تثبيت uv من المصدر، ننسخ الملف الثنائي المُبنى مسبقًا من صورة ghcr.io/astral-sh/uv:latest. يُعرف هذا باسم نمط الباني (builder pattern). بهذا النمط لا نحتاج إلى شحن جميع الأدوات اللازمة لترجمة كودنا، بل فقط الملف الثنائي النهائي المطلوب لتشغيل التطبيق (uv في هذه الحالة).
لدى Docker قيودٌ مهمةٌ يجب أن تكون على درايةٍ بها. أولًا، صور الحاويات غالبًا خاصةٌ بالمنصة — صورةٌ مبنيةٌ لـlinux/amd64 لن تعمل بشكلٍ أصليٍّ على linux/arm64 (أجهزة Apple Silicon من ماك) دون محاكاةٍ (emulation)، وهي بطيئة. ثانيًا، تتطلب حاويات Docker نواة لينكس، لذا على macOS وويندوز يشغّل Docker بالفعل جهازًا افتراضيًا خفيفًا للينكس تحت الغطاء، مما يضيف حملًا إضافيًا. ثالثًا، عزل Docker أضعف من الأجهزة الافتراضية — الحاويات تشارك نواة المضيف، وهو مصدر قلقٍ أمنيٍّ في البيئات متعددة المستأجرين (multi-tenant).
هذه الأيام، يستفيد المزيد من المشاريع أيضًا من nix لإدارة حتى المكتبات والتطبيقات "على مستوى النظام" لكل مشروعٍ عبر nix flakes.
الإعدادات
البرامج بطبيعتها قابلةٌ للإعداد. في محاضرة بيئة سطر الأوامر رأينا برامج تتلقى الخيارات عبر العلمات (flags) أو متغيرات البيئة أو حتى ملفات الإعداد الملقّبة بأسماءٍ تبدأ بنقطة (dotfiles). ينطبق هذا حتى على التطبيقات الأكثر تعقيدًا، وتوجد أنماطٌ راسخةٌ لإدارة الإعدادات على نطاقٍ واسع. لا يجب تضمين إعدادات البرنامج في الكود بل يجب توفيرها في وقت التشغيل (runtime). من أكثرها شيوعًا متغيرات البيئة وملفات الإعداد.
هذا مثالٌ على تطبيقٍ يُعدّ عبر متغيرات البيئة:
import os
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:///local.db")
DEBUG = os.environ.get("DEBUG", "false").lower() == "true"
API_KEY = os.environ["API_KEY"] # Required - will raise if not set
يمكن أيضًا إعداد تطبيقٍ عبر ملف إعدادٍ (مثل برنامج بايثون يحمّل إعدادًا عبر yaml.load)، config.yaml:
database:
url: "postgresql://localhost/myapp"
pool_size: 5
server:
host: "0.0.0.0"
port: 8080
debug: false
قاعدةٌ جيدةٌ لتبسيط التفكير في الإعدادات هي أن نفس قاعدة الكود يجب أن تكون قابلةً للنشر في بيئاتٍ مختلفة (التطوير، التجهيز staging، الإنتاج) مع تغيير الإعدادات فقط، لا الكود أبدًا.
من بين خيارات الإعدادات العديدة، غالبًا ما توجد بياناتٌ حسّاسةٌ مثل مفاتيح API. يجب التعامل مع الأسرار (secrets) بعنايةٍ لتجنب كشفها بطريق الخطأ، ويجب ألّا تُضمّن في التحكم بالإصدارات.
الخدمات والتنسيق
نادرًا ما توجد التطبيقات الحديثة في عزلة. قد يحتاج تطبيق الويب النموذجي إلى قاعدة بياناتٍ للتخزين الدائم، وذاكرةً مؤقتةً (cache) للأداء، وقائمة رسائل (message queue) للمهام الخلفية، وخدمات داعمةٍ أخرى متنوعة. بدلًا من حشر كل شيءٍ في تطبيقٍ واحدٍ متآلفٍ (monolithic)، غالبًا ما تفكك المعماريات الحديثة الوظائف إلى خدماتٍ منفصلةٍ يمكن تطويرها ونشرها وتوسيعها بشكلٍ مستقل.
كمثالٍ، إذا قررنا أن تطبيقنا قد يستفيد من استخدام ذاكرةٍ مؤقتةٍ، فبدلًا من ابتكار حلٍّ خاصٍ بنا يمكننا الاستفادة من حلولٍ مجرّبةٍ ومختبرةٍ موجودةٍ مثل Redis أو Memcached. يمكننا تضمين Redis في تبعيات تطبيقنا ببنائه كجزءٍ من الحاوية، لكن ذلك يعني تنسيق جميع التبعيات بين Redis وتطبيقنا، وهو ما قد يكون صعبًا أو حتى غير ممكن. بدلًا من ذلك، ما يمكننا فعله هو نشر كل تطبيقٍ على حدةٍ في حاويته الخاصة. يُشار إلى هذا عادةً باسم معمارية الخدمات الصغيرة (microservice architecture) حيث يعمل كل مكوّنٍ كخدمةٍ مستقلةٍ تتواصل عبر الشبكة، عادةً عبر واجهات HTTP API.
Docker Compose أداةٌ لتحديد وتشغيل تطبيقاتٍ متعددة الحاويات. بدلًا من إدارة الحاويات فرديًا، تعلن جميع الخدمات في ملف YAML واحدٍ وتنسّقها معًا. الآن يشمل تطبيقنا الكامل أكثر من حاويةٍ واحدة:
# docker-compose.yml
services:
web:
build: .
ports:
- "8080:8080"
environment:
- REDIS_URL=redis://cache:6379
depends_on:
- cache
cache:
image: redis:7-alpine
volumes:
- redis_data:/data
volumes:
redis_data:
باستخدام docker compose up، تبدأ كلتا الخدمتين معًا، ويمكن لتطبيق الويب الاتصال بـRedis باستخدام اسم المضيف cache (يحلّ DNS الداخلي لـDocker أسماء الخدمات تلقائيًا).
يتيح لك Docker Compose إعلان كيفية نشر خدمةٍ أو أكثر، ويتولى تنسيق إقلاعها معًا، وإعداد الشبكات بينها، وإدارة وحدات التخزين المشتركة لاستمرار البيانات.
بالنسبة لنشرات الإنتاج، غالبًا ما تريد أن تبدأ خدمات docker compose الخاصة بك تلقائيًا عند الإقلاع وتُعاد عند الفشل. الأسلوب الشائع هو استخدام systemd لإدارة نشر docker compose:
# /etc/systemd/system/myapp.service
[Unit]
Description=My Application
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/opt/myapp
ExecStart=/usr/bin/docker compose up -d
ExecStop=/usr/bin/docker compose down
[Install]
WantedBy=multi-user.target
يضمن ملف وحدة systemd هذا بدء تطبيقك عندما يُقلع النظام (بعد أن يصبح Docker جاهزًا)، ويوفر أدوات تحكمٍ قياسيةً مثل systemctl start myapp وsystemctl stop myapp وsystemctl status myapp.
مع ازدياد تعقيد متطلبات النشر — الحاجة إلى قابلية التوسع عبر أجهزةٍ متعددةٍ، وتحمل الأعطال (fault tolerance) عند انهيار الخدمات، وضمانات التوافر العالي (high availability) — تلجأ المؤسسات إلى منصات تنسيق حاوياتٍ متطورةٍ مثل Kubernetes (k8s)، التي يمكنها إدارة آلاف الحاويات عبر مجموعاتٍ من الأجهزة. ومع ذلك، يمتلك Kubernetes منحنى تعلّمٍ حادًا وحملًا تشغيليًا كبيرًا، لذا فهو غالبًا مبالغٌ فيه للمشاريع الأصغر.
هذا الإعداد متعدد الحاويات ممكنٌ جزئيًا لأن الخدمات الحديثة تتواصل مع بعضها البعض عبر واجهات برمجة تطبيقاتٍ معيارية، مثل واجهات HTTP REST. على سبيل المثال، عندما يتفاعل برنامجٌ مع مزوّد نماذج لغةٍ كبيرةٍ مثل OpenAI أو Anthropic، فإنه يرسل تحت الغطاء طلب HTTP إلى خوادمهم ويحلل الاستجابة:
$ curl https://api.anthropic.com/v1/messages \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "content-type: application/json" \
-H "anthropic-version: 2023-06-01" \
-d '{"model": "claude-sonnet-4-20250514", "max_tokens": 256,
"messages": [{"role": "user", "content": "Explain containers vs VMs in one sentence."}]}'
النشر
بمجرد أن تُثبت أن كودك يعمل، قد تكون مهتمًا بتوزيعه ليقوم الآخرون بتنزيله وتثبيته. يتخذ التوزيع أشكالًا كثيرةً ويرتبط بطبيعته بلغة البرمجة والبيئات التي تعمل بها.
أبسط أشكال التوزيع هو رفع القطع (artifacts) ليقوم الناس بتنزيلها وتثبيتها محليًا.
ما يزال هذا شائعًا ويمكنك العثور عليه في أماكن مثل أرشيف حزم أوبونتو، وهو أساسًا قائمة أدلة HTTP بملفات .deb.
هذه الأيام، أصبح GitHub المنصة الفعلية (de facto) لنشر الكود المصدري والقطع. بينما يكون الكود المصدري غالبًا متاحًا للعموم، تسمح إصدارات GitHub (GitHub Releases) للمحافظين بإرفاق ملفاتٍ ثنائيةٍ مبنيةٍ مسبقًا وقطعٍ أخرى بإصداراتٍ موسومة (tagged).
تدعم مديرو الحزم أحيانًا التثبيت مباشرةً من GitHub، إما من المصدر أو من عجلةٍ مبنيةٍ مسبقًا:
# التثبيت من المصدر (سيتم استنساخه وبناؤه)
$ pip install git+https://github.com/psf/requests.git
# التثبيت من وسم/فرعٍ محدد
$ pip install git+https://github.com/psf/requests.git@v2.32.3
# تثبيت عجلةٍ مباشرةً من إصدار GitHub
$ pip install https://github.com/user/repo/releases/download/v1.0/package-1.0-py3-none-any.whl
في الواقع، تستخدم بعض اللغات مثل Go نموذج توزيعٍ لا مركزي — فبدلًا من مستودع حزمٍ مركزي، تُوزّع وحدات Go مباشرةً من مستودعات كودها المصدري.
تشير مسارات الوحدات مثل github.com/gorilla/mux إلى مكان وجود الكود، ويجلبها go get مباشرةً من هناك. مع ذلك، تمتلك معظم مديري الحزم مثل pip أو cargo أو brew فهارس مركزيةً للمشاريع المعبأة مسبقًا لسهولة التوزيع والتثبيت. إذا شغّلنا:
$ uv pip install requests --verbose --no-cache 2>&1 | grep -F '.whl'
DEBUG Selecting: requests==2.32.5 [compatible] (requests-2.32.5-py3-none-any.whl)
DEBUG No cache entry for: https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl.metadata
DEBUG No cache entry for: https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl
نرى من أين نجلب عجلة requests. لاحظ py3-none-any في اسم الملف — وهذا يعني أن العجلة تعمل مع أي إصدار بايثون 3، على أي نظام تشغيل، على أي معمارية. بالنسبة للحزم ذات الكود المترجم، تكون العجلة خاصةً بالمنصة:
$ uv pip install numpy --verbose --no-cache 2>&1 | grep -F '.whl'
DEBUG Selecting: numpy==2.2.1 [compatible] (numpy-2.2.1-cp312-cp312-macosx_14_0_arm64.whl)
هنا يشير cp312-cp312-macosx_14_0_arm64 إلى أن هذه العجلة مخصصةٌ تحديدًا لـCPython 3.12 على macOS 14+ ومعمارية ARM64 (Apple Silicon). إذا كنت على منصةٍ مختلفة، فسوف ينزّل pip عجلةً مختلفةً أو يبني من المصدر.
وبالمقابل، لكي يتمكن الناس من العثور على حزمةٍ أنشأناها، نحتاج إلى نشرها في أحد هذه السجلات (registries).
في بايثون، السجل الرئيسي هو فهرس حزم بايثون (PyPI).
كما هو الحال مع التثبيت، توجد طرقٌ متعددةٌ لنشر الحزم. يوفر الأمر uv publish واجهةً حديثةً لرفع الحزم إلى PyPI:
$ uv publish --publish-url https://test.pypi.org/legacy/
Publishing greeting-0.1.0.tar.gz
Publishing greeting-0.1.0-py3-none-any.whl
هنا نستخدم TestPyPI — وهو سجل حزمٍ منفصلٌ مخصصٌ لاختبار سير عمل النشر دون تلويث PyPI الحقيقي. بمجرد الرفع، يمكنك التثبيت من TestPyPI:
$ uv pip install --index-url https://test.pypi.org/simple/ greeting
اعتبارٌ رئيسيٌّ عند نشر البرامج هو الثقة. كيف يتحقق المستخدمون من أن الحزمة التي ينزّلونها تأتي منك فعلًا ولم يتم العبث بها؟ تستخدم سجلات الحزم مجاميع الفحص (checksums) للتحقق من السلامة، وتدعم بعض النظم البيئية توقيع الحزم (package signing) لتقديم دليلٍ مشفّرٍ على التأليف.
لكل لغةٍ سجل حزمٍ خاصٌّ بها: crates.io لـRust، وnpm لجافاسكريبت، وRubyGems لـRuby، وDocker Hub لصور الحاويات. وفي الوقت نفسه، بالنسبة للحزم الخاصة أو الداخلية، تنشر المؤسسات غالبًا مستودعات حزمٍ خاصةً بها (مثل خادم PyPI خاصٍّ أو سجل Docker خاص) أو تستخدم حلولًا مُدارةً من مزوّدي السحابة.
يتضمن نشر خدمة ويبٍ إلى الإنترنت بنيةً تحتيةً إضافيةً: تسجيل اسم النطاق، وإعدادات DNS لتوجيه نطاقك إلى خادمك، وغالبًا بروكسي عكسيٌّ (reverse proxy) مثل nginx للتعامل مع HTTPS وتوجيه حركة المرور. لحالات الاستخدام الأبسط مثل التوثيق أو المواقع الثابتة، يوفر GitHub Pages استضافةً مجانيةً مباشرةً من مستودعٍ.
تمارين
- احفظ بيئتك بالأمر
printenvفي ملفٍ، ثم أنشئ بيئةً افتراضيةً وفعّلها، ثم احفظprintenvفي ملفٍ آخر، وقارنdiff before.txt after.txt. ما الذي تغيّر في البيئة؟ لماذا يفضّل المُعالج (shell) البيئة الافتراضية؟ (تلميح: انظر إلى$PATHقبل التفعيل وبعده.) شغّلwhich deactivateوفكّر في ما تفعله دالةdeactivateفي المعالج. - أنشئ حزمة بايثون بملف
pyproject.tomlوثبّتها في بيئةٍ افتراضية. أنشئ ملف قفلٍ وافحصه. - ثبّت Docker واستخدمه لبناء موقع مقرر Missing Semester من الصف محليًا باستخدام docker compose.
- اكتب ملف Dockerfile لتطبيق بايثون بسيط. ثم اكتب
docker-compose.ymlيشغّل تطبيقك جنبًا إلى جنبٍ مع ذاكرةٍ مؤقتةٍ من Redis. - انشر حزمة بايثون إلى TestPyPI (لا تنشر إلى PyPI الحقيقي ما لم تكن تستحق المشاركة!). ثم ابنِ صورة Docker بهذه الحزمة وادفعها إلى
ghcr.io. - أنشئ موقعًا إلكترونيًا باستخدام GitHub Pages. إضافي (غير) مقسوبٍ لك: اضبطه بنطاقٍ مخصص.