لغات البرمجة عملياً الأدلة التقنية

Go لمن يعرف البرمجة: الأدوات Toolchain وأول مشروع

تثبيت Go 1.27 والفرق في صيغتها عما تعرفه، ثم أدواتها في أمر واحد وشكل المشروع، مع تجربة كاملة تبني حاسبة طلب باختباراتها وفحصها وملف تنفيذي واحد.

Go لمن يعرف البرمجة: الأدوات Toolchain وأول مشروع

لنفرض أنك تكتب الكود منذ سنوات بلغة Python أو Java أو JavaScript، وتعمل في شركة ناشئة تبيع القهوة والتمر والهيل عبر الإنترنت، والمتجر يعمل بخير، ثم قرر الفريق أن خدمة الطلبات الجديدة سوف تكتب بلغة Go، والسبب الذي سمعته في الاجتماع أنها تخرج ملفاً تنفيذياً واحداً، وتستهلك ذاكرة قليلة، وتتعامل مع آلاف الطلبات المتزامنة دون تعقيد. وأنت لا تريد دورة للمبتدئين تشرح لك ما هو المتغير وما هي الحلقة، فهذا تعرفه جيداً، وإنما تريد أن تعرف في يوم واحد كيف تثبت اللغة بالطريقة الصحيحة، وما الذي يختلف في صيغتها عما تعرفه، وما هي الأدوات التي سوف تستخدمها كل يوم، وكيف يبدو المشروع الحقيقي من الداخل، حتى تستطيع أن تفتح أول Pull Request دون أن يعلق عليه زميلك بأن هذا «ليس أسلوب Go».

وهذا المقال هو الأول في مرحلة لغة Go للمطورين من مسار لغات البرمجة عملياً، والمقالات الخمسة في هذه المرحلة تتبع نفس القصة: في هذا المقال نجهز الأدوات ونكتب أول برنامج صغير يحسب إجمالي الطلب مع ضريبة القيمة المضافة، ثم نتعمق في الأنواع والذاكرة والأخطاء، ثم في التزامن، ثم نبني خدمة الطلبات كاملة مع PostgreSQL والاختبارات وDocker، ثم نختم بالأخطاء التي يقع فيها القادمون من لغات أخرى. وإذا كنت لم تقرر بعد هل Go هي اللغة المناسبة لمشروعك أصلاً، فقد قارنا بين أربع لغات بنفس الخدمة في مقال كيف تختار لغة البرمجة لمشروعك، وإذا كنت جديداً على البرمجة نفسها فابدأ من أساسيات البرمجة ثم عد إلى هنا.

وسوف نناقش في هذا المقال ما يلي:

  • لماذا صممت Go، وما المشكلة التي جاءت لحلها في Google عام 2007، وما الذي تركته عن قصد.
  • تثبيت الإصدار الحالي Go 1.27.3 بالطريقة الصحيحة، وكيف يبدل الأمر go الإصدار وحده بحسب المشروع.
  • جولة سريعة في الصيغة Syntax مقارنة بما تعرفه: المتغيرات والقيمة الصفرية، والدوال بقيمتين، والحلقة الوحيدة، والـ struct بدل الكلاسات، والشرائح والخرائط، وdefer، والحرف الكبير بدل public.
  • الأدوات Toolchain كلها في أمر واحد: البناء والاختبار والتنسيق والفحص وإدارة الحزم، وما تحتاجه من خارجه.
  • شكل المشروع Project Layout: الوحدة Module والحزمة Package والمجلد internal.
  • تجربة عملية كاملة: حاسبة طلب صغيرة باختباراتها، ثم التنسيق والفحص والبناء لنظامين مختلفين، وإضافة مكتبة، والمشكلات التي واجهتنا.
  • الأخطاء الشائعة في الأيام الأولى، وقائمة تحقق قبل أن تكتب أول خدمة.

لماذا صممت Go؟ المشكلة Problem التي جاءت لحلها

أفضل طريقة لتفهم أي لغة أن تعرف المشكلة التي صنعت من أجلها، لأن كل قرار غريب في صيغتها سوف يصبح منطقياً عندما تعرف ما كان يزعج من صمموها. وفي حالة Go فالقصة موثقة بشكل ممتاز في محاضرة Rob Pike بعنوان Go at Google: Language Design in the Service of Software Engineering، حيث يشرح أن اللغة ولدت في أواخر 2007 كإجابة على مشكلات كانت Google تعيشها يومياً: برامج سيرفرات ضخمة بلغة C++ وJava، يعمل عليها آلاف المطورين، وتبنى على عناقيد من الأجهزة، والبناء نفسه صار يأخذ دقائق طويلة وأحياناً ساعات. ويذكر Pike رقماً يشرح المشكلة كلها: في 2007 قاس مهندسو البناء في Google ترجمة برنامج كبير بلغة C++، فوجدوا أن حوالي ألفي ملف حجمها مجتمعة 4.2 ميجابايت تحولت بعد توسيع ملفات #include إلى أكثر من 8 جيجابايت تصل إلى المترجم، أي أن كل بايت من الكود صار ألفي بايت، وأن بناء هذا البرنامج أخذ 45 دقيقة.

ولذلك جلس ثلاثة مهندسين هم Robert Griesemer وRob Pike وKen Thompson، والأخير أحد صانعي Unix ولغة B التي سبقت C، وصمموا لغة هدفها الأول ليس جمال الصيغة، وإنما أن تجعل الفرق الكبيرة سريعة: ترجمة سريعة جداً لأن كل ملف يذكر ما يستورده بالضبط ولا يوجد ملفات header، واستيراد غير مستخدم يعتبر خطأ ترجمة حتى لا تتراكم الاعتماديات بلا سبب، وصيغة صغيرة يقرؤها أي مطور في الفريق دون أن يحتاج إلى معرفة عشرين ميزة متقدمة، وتنسيق واحد للكود تفرضه أداة، وتزامن جزء من اللغة لأن البرامج التي كانوا يكتبونها هي سيرفرات تخدم آلاف الاتصالات في نفس الوقت. وتجد قصة الأسماء والتواريخ وكثيراً من «لماذا لا توجد هذه الميزة؟» في الأسئلة الشائعة في موقع Go، وهي من أفضل ما تقرأه في يومك الأول، وقد روينا قصص صانعي اللغات بشكل أوسع في مقال قصص لغات البرمجة ومن صنعها.

وأصبحت اللغة مفتوحة المصدر في نوفمبر 2009، ثم صدر Go 1 في مارس 2012 ومعه وثيقة مهمة جداً لمن يكتب كوداً سوف يعيش سنوات، وهي وعد التوافق Go 1 Compatibility، والتي تقول إن البرنامج المكتوب لإصدار Go 1 سوف يستمر في الترجمة والعمل مع كل إصدارات Go 1 اللاحقة. ولهذا الوعد أثر عملي تراه كل يوم: الكود الذي كتب قبل عشر سنوات يترجم اليوم في الغالب دون تعديل، والترقية من إصدار إلى آخر حدث عادي وليس مشروعاً. وبعد ذلك بعشر سنوات جاءت أكبر إضافة على اللغة، وهي الأنواع العامة Generics في Go 1.18 عام 2022، ثم في الإصدار الحالي Go 1.27، الذي صدر في 19 أغسطس 2026، صار بإمكان الدالة المرتبطة بنوع Method أن تأخذ معاملات نوع خاصة بها، كما تذكر ملاحظات إصدار Go 1.27. وتصدر Go إصداراً رئيسياً كل ستة أشهر تقريباً، في فبراير وأغسطس، ويدعم الفريق آخر إصدارين رئيسيين بالإصلاحات الأمنية بحسب صفحة سجل الإصدارات، ولذلك الإصدار المناسب لمشروع جديد اليوم هو 1.27.3 الصادر في 19 أكتوبر 2026، وهو الإصدار الذي استخدمناه في كل التجارب.

والصورة التالية تجمع ما أخذته Go وما تركته عن قصد، وهي خريطة مفيدة لما سوف تفتقده من لغتك في الأسابيع الأولى:

عمودان: ما وضعته Go في اللغة والأدوات مثل الترجمة السريعة والملف التنفيذي الواحد والـ Goroutines وجامع القمامة وأداة go الواحدة والواجهات الضمنية، وما تركته عن قصد مثل الكلاسات والوراثة والاستثناءات وحلقة while وتحميل الدوال والتحويل الضمني والحساب على المؤشرات، وتحتهما خط زمني من 2007 إلى الإصدار 1.27.3
كل ما تركته Go كان قراراً مكتوباً، وأغلبه لصالح قراءة الكود في فريق كبير

وقد يتساءل البعض: إذا كانت اللغة تركت كل هذا، فلماذا يحبها الناس؟ والإجابة أن ما تخسره في التعبير تكسبه في القراءة، فالكود في Go يشبه بعضه في كل الشركات، وتستطيع أن تفتح مشروع Kubernetes أو Docker أو Prometheus، وكلها مكتوبة بلغة Go، وتقرأ ملفاً منها دون أن تحتاج إلى معرفة «أسلوب» فريقها. ولكن هذه البساطة لها ثمن حقيقي سوف تشعر به، وهو الكود المتكرر، خصوصاً في معالجة الأخطاء، وسوف نتحدث عنه بصراحة في المقال الثاني.

تثبيت Go: الأداة الرسمية Toolchain بالإصدار 1.27.3

هناك ثلاث طرق شائعة لتثبيت Go، ولكل منها مكان مختلف:

  • مدير حزم التوزيعة مثل apt install golang: الأسهل، ولكنه في الغالب يعطيك إصداراً قديماً، فالتوزيعات المستقرة تجمد الإصدار عند بداية دورتها، وقد تجد نفسك على إصدار لم يعد يحصل على إصلاحات أمنية، وبعض المكتبات الحديثة تطلب في ملف go.mod إصداراً أحدث مما لديك. لذلك لا ينصح بهذه الطريقة لجهاز التطوير.
  • الملف الرسمي من صفحة التنزيل: ملف مضغوط لنظام Linux أو macOS تفكه في /usr/local/go، أو ملف تثبيت لنظام Windows، والخطوات في دليل التثبيت الرسمي. وهذه الطريقة الأنسب لجهازك، لأنك تعرف بالضبط أي إصدار لديك.
  • الـ Docker Image الرسمية golang: مناسبة للتجارب ولخطوة البناء في Dockerfile، وهي التي استخدمناها هنا، والإصدار في الوسم نفسه، مثل golang:1.27.3-trixie المبنية على Debian 13.

وعلى Linux تكون خطوات الطريقة الثانية كما يلي، مع التأكد من بصمة الملف من نفس صفحة التنزيل قبل فكه:

curl -LO https://go.dev/dl/go1.27.3.linux-amd64.tar.gz
sha256sum go1.27.3.linux-amd64.tar.gz
sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.27.3.linux-amd64.tar.gz
echo 'export PATH=$PATH:/usr/local/go/bin:$HOME/go/bin' >> ~/.profile

ولاحظ أن الدليل الرسمي يطلب حذف المجلد القديم قبل فك الجديد، والسبب أن فك إصدار فوق آخر يترك ملفات قديمة تخلط بين الإصدارين بطرق غريبة. والمجلد $HOME/go/bin هو المكان الذي يضع فيه الأمر go install الأدوات التي تثبتها لاحقاً مثل staticcheck، لذلك أضفناه إلى المسار من البداية. وفي تجربتنا شغلنا كل شيء داخل Container من الـ Image الرسمية مع Volume لحفظ الـ Cache بين التشغيلات، والأمر كما يلي:

docker run --rm -v slj-go-cache:/root/.cache -v slj-go-mod:/go/pkg/mod \
  -v "$PWD:/lab" -w /lab golang:1.27.3-trixie bash run.sh

وأول ما يقوم به السكربت هو أن يسأل عن الإصدار والإعدادات:

go version
go env GOROOT GOPATH GOOS GOARCH GOTOOLCHAIN GOFLAGS CGO_ENABLED

والمخرج سوف يكون كما يلي:

go version go1.27.3 linux/amd64
/usr/local/go
/go
linux
amd64
local

1

في المخرج أعلاه لاحظ التالي:

  • GOROOT هو مكان اللغة نفسها: المترجم والمكتبة القياسية Standard Library بكودها المصدري كاملاً، وتستطيع أن تفتح أي ملف فيها وتقرأ كيف كتبت، وهذا من أفضل طرق التعلم في Go.
  • GOPATH هو مجلد العمل الخاص بك، وفيه أهم مجلدين: pkg/mod حيث تنزل المكتبات، وbin حيث تثبت الأدوات. وفي الإصدارات القديمة قبل الوحدات Modules كان يجب أن يكون كودك نفسه داخل GOPATH/src، وهذا لم يعد مطلوباً منذ سنوات، فإذا قرأت دليلاً يطلب ذلك فهو دليل قديم.
  • GOOS وGOARCH هما النظام والمعالج الذي يبني له المترجم، وتغييرهما وحده يكفي لبناء ملف لنظام آخر، كما سوف نرى في التجربة.
  • GOTOOLCHAIN=local، وهذا إعداد الـ Image الرسمية، ومعناه ألا ينزل الأمر go إصداراً آخر وحده. والقيمة الافتراضية على جهازك هي auto، وسوف نشرحها حالاً.
  • CGO_ENABLED=1: يعني أن الكود يستطيع استدعاء مكتبات مكتوبة بلغة C، وهذا يحتاج مترجم C على الجهاز، وسوف نعود إليه عند الحديث عن الملف التنفيذي الثابت.

الإصدار في go.mod والتبديل التلقائي Toolchain Switching

منذ Go 1.21 صار السطر go 1.27 في ملف go.mod حداً أدنى ملزماً، وليس ملاحظة، فإذا كان على جهازك Go 1.26 وفتحت مشروعاً يطلب 1.27، فإن الأمر go بالإعداد auto ينزل الإصدار المطلوب من سيرفرات Go ويشغله بدلاً منه، وتشرح وثيقة الـ Toolchains هذه الآلية بالتفصيل، والسبب في تدوينة الإعلان عنها أن الفريق أراد أن يتوقف عن «ترجمة الكود بإصدار أقدم مما كتب له» دون أن يعرف أحد. وجربنا العكس داخل الـ Container، أي أن نطلب إصداراً أقدم صراحة:

GOTOOLCHAIN=go1.26.9 go version
GOTOOLCHAIN=local go version

والمخرج سوف يكون كما يلي:

go: downloading go1.26.9 (linux/amd64)
go version go1.26.9 linux/amd64
go version go1.27.3 linux/amd64

فالأمر نزل الإصدار 1.26.9 كاملاً وشغله، ثم عاد إلى الإصدار المثبت عندما طلبنا local. وهذه الآلية مفيدة جداً في الفرق، فالمشروع نفسه يحدد إصداره، ولكن انتبه إلى أمرين: الأول أن جهاز CI الذي لا يصل إلى الإنترنت سوف يفشل عند هذه الخطوة، لذلك ثبت فيه الإصدار الصحيح من البداية. والثاني أن رفع السطر go في مكتبة يستخدمها غيرك يجبرهم على الترقية، فلا ترفعه إلا عندما تحتاج ميزة من الإصدار الجديد فعلاً.

جولة سريعة في الصيغة Syntax مقارنة بلغتك

الصيغة في Go صغيرة عن قصد، ومواصفات اللغة Language Specification كلها صفحة واحدة تقرؤها في جلسة، وهذا ليس مبالغة، فهي أقصر بكثير من مواصفات Java أو C++. ولكي لا نكرر ما شرحناه عن أنماط البرمجة وأنظمة الأنواع في مقال أنماط البرمجة وأنظمة الأنواع، سوف نمر هنا على ما يختلف فعلاً عن لغتك، والجدول التالي يضع الأفكار جنباً إلى جنب:

جدول من تسعة صفوف يقارن Python وJava وJavaScript وGo في المتغير والدالة بقيمتين والحلقة الشرطية والمرور على قائمة والكلاس والخطأ والتنظيف عند الخروج والخاص والعام والتشغيل المتزامن، وعمود Go ملون بالأخضر
أغلب ما تعرفه موجود في Go، ولكن بكلمة واحدة بدل عدة كلمات

المتغيرات والقيمة الصفرية Zero Value

في Go تكتب النوع بعد اسم المتغير وليس قبله، فالمتغير var total int يقرأ «المتغير total من نوع int»، والسبب الذي يذكره الفريق أن هذا الترتيب يبقى مقروءاً من اليسار إلى اليمين حتى مع الأنواع المعقدة مثل دالة ترجع دالة. ولكنك نادراً ما تكتب النوع، لأن الصيغة القصيرة total := 0 تعرف المتغير وتستنتج نوعه من القيمة، وهي الأكثر استخداماً داخل الدوال. والأهم من الصيغة فكرة القيمة الصفرية Zero Value: كل متغير في Go له قيمة من لحظة تعريفه، فالأرقام صفر، والنص فارغ ""، والقيمة المنطقية false، والمؤشرات والخرائط والشرائح nil، ولا توجد قيمة «غير معرفة» كما في JavaScript، ولا خطأ «متغير لم يعط قيمة» كما في Java. ولهذه الفكرة أثر في التصميم سوف تراه في كل مكتبة جيدة، وهو أن تجعل القيمة الصفرية للنوع صالحة للاستخدام مباشرة، فالمتغير var b strings.Builder جاهز للكتابة فيه، وvar mu sync.Mutex قفل جاهز دون أي دالة إنشاء.

الدوال Functions بقيمتين والخطأ Error

الدالة في Go تستطيع أن ترجع أكثر من قيمة، وأشهر استخدام لذلك أن ترجع النتيجة والخطأ معاً، كما في func Compute(lines []Line) (Totals, error). ولا توجد استثناءات Exceptions في Go، وإنما الخطأ قيمة عادية من النوع error، تكون nil إذا نجحت العملية، ويجب على من يستدعي الدالة أن يفحصها بنفسه بالسطر الذي سوف تراه آلاف المرات: if err != nil { return err }. وقد شرحنا الفرق بين الطريقتين وقسنا ثمن كل منهما بأربع لغات في مقال كيف تتعامل لغات البرمجة مع الأخطاء، ومنه أن فريق Go أعلن في 2025 أنه توقف عن البحث عن صيغة مختصرة لهذا السطر، أي أنه باق. والمقال الثاني في هذه المرحلة يتعمق في أنواع الأخطاء ومتى تستخدم panic.

حلقة واحدة for والشرط switch

لا توجد while ولا do في Go، وإنما for وحدها بأربعة أشكال: for i := 0; i < n; i++ الحلقة المعروفة، وfor n > 0 وهي while بلا اسمها، وfor وحدها وهي حلقة لا تنتهي إلا بـ break أو return، وfor i, p := range items للمرور على الشرائح والخرائط والنصوص والقنوات. ومنذ Go 1.22 صار متغير الحلقة جديداً في كل دورة، وهذا أغلق خطأً قديماً مشهوراً كانت فيه الـ Goroutines داخل الحلقة ترى كلها آخر قيمة، كما تشرح تدوينة تغيير متغير الحلقة، لذلك إذا قرأت كوداً قديماً فيه السطر الغريب p := p داخل الحلقة فهذا هو سببه. أما switch فلا يحتاج break في نهاية كل حالة، لأن الحالة لا تنزل إلى التي تحتها إلا إذا كتبت fallthrough صراحة، وهذا عكس C وJava وJavaScript، ويستطيع أن يعمل بلا متغير أصلاً كبديل مرتب لسلسلة من if. والشرط if يقبل جملة قبل الشرط، مثل if err := f(); err != nil، فيبقى المتغير err محصوراً في هذا الشرط.

الـ struct والدوال المرتبطة Methods بدل الكلاسات

لا توجد كلاسات Classes في Go، وإنما تعرف نوعاً من البيانات بالكلمة struct، ثم تربط به دوالاً بكتابة المستقبل Receiver قبل اسم الدالة، مثل func (p *Product) Reserve(n int). والنجمة قبل النوع تعني أن الدالة تعمل على الأصل عبر مؤشر Pointer، وبدونها تعمل على نسخة، وهذا الفرق هو أول خطأ يقع فيه القادم من Java أو Python، لأن الدالة بدون النجمة تعدل النسخة وتترك الأصل كما هو دون أي رسالة، وسوف نراه بالمخرج في المقال الثاني. ولا توجد وراثة Inheritance، وبدلها التضمين Embedding، أي أن تضع نوعاً داخل نوع فتظهر حقوله ودواله في النوع الخارجي، والواجهات Interfaces تتحقق ضمنياً: أي نوع له الدوال المطلوبة يحقق الواجهة دون أن يعلن ذلك بكلمة مثل implements.

الشرائح Slices والخرائط Maps

المصفوفة Array في Go حجمها جزء من نوعها، فالنوع [3]int يختلف عن [4]int، ولذلك نادراً ما تستخدمها مباشرة، وإنما تستخدم الشريحة Slice، وهي نافذة على مصفوفة: مؤشر إلى بدايتها وطول Length وسعة Capacity، وتكبر بالدالة append التي تنسخ البيانات إلى مصفوفة أكبر عندما تمتلئ السعة. وهذا التصميم يفسر أشياء سوف تفاجئك، مثل أن شريحتين قد تشيران إلى نفس البيانات فيغير تعديل إحداهما الأخرى، أو أن شريحة صغيرة تمسك مصفوفة كبيرة في الذاكرة، وهي المشكلة التي قسناها بالأرقام في قسم التسرب من مقال إدارة الذاكرة. أما الخريطة map[string]int فهي جدول هاش Hash Table عادي، والقراءة من خريطة nil ترجع القيمة الصفرية دون خطأ، ولكن الكتابة فيها توقف البرنامج، ولذلك تنشئها بـ make أو بقيمة حرفية قبل الكتابة. وترتيب المرور على الخريطة عشوائي عن قصد في كل تشغيل، حتى لا يعتمد أحد على ترتيب لم تعد به اللغة.

الأمر defer للتنظيف Cleanup

بدلاً من finally في Java وJavaScript أو with في Python، تكتب في Go السطر defer f.Close() مباشرة بعد فتح الملف، فيؤجل التنفيذ إلى لحظة خروج الدالة، مهما كان طريق الخروج: return عادي أو خطأ أو حتى panic. وميزة هذا الأسلوب أن الفتح والإغلاق متجاوران في الكود، فلا تنسى الإغلاق عندما تضيف return جديداً في منتصف الدالة بعد سنة. وتنفذ الأوامر المؤجلة بترتيب عكسي، آخرها أولها، وقيم المعاملات تحسب لحظة كتابة defer وليس لحظة التنفيذ، وهذه النقطة الأخيرة مصدر خطأ كلاسيكي عندما تؤجل طباعة متغير ثم تغيره.

الحرف الكبير بدل public أو private Visibility

لا توجد كلمات public وprivate في Go، وإنما يحدد الحرف الأول من الاسم كل شيء: الاسم الذي يبدأ بحرف كبير مثل Compute مصدر Exported تراه الحزم الأخرى، والذي يبدأ بحرف صغير مثل round لا يراه أحد خارج حزمته. وهذا ينطبق على الدوال والأنواع والحقول والثوابت، وله أثر لا يتوقعه القادم من لغات أخرى: حقل الـ struct الذي يبدأ بحرف صغير لا تراه مكتبة encoding/json أيضاً، لأنها حزمة أخرى، فيخرج الكائن في JSON فارغاً دون أي خطأ، وهذا من أشهر أسئلة الأسبوع الأول.

وفي هذه الجولة تركنا عن قصد أهم ميزتين في اللغة، وهما الواجهات والتزامن، لأن لكل منهما مقالاً كاملاً في هذه المرحلة، فالأولى في المقال الثاني، والـ Goroutines والقنوات Channels في المقال الثالث، وفيه نقيس نفس المهمة بالتسلسل والتزامن بالأرقام.

أدوات اللغة Tools في أمر واحد

في Python تحتاج pip أو uv للحزم، وpytest للاختبار، وBlack أو Ruff للتنسيق، وMyPy للفحص، وفي Java تحتاج Maven أو Gradle، وفي JavaScript npm وJest وPrettier وESLint، وقد شرحنا هذا التنوع في مقالي مديرو الحزم وأدوات البناء. أما في Go فأغلب هذا كله أمر واحد اسمه go، ومعه أداة التنسيق gofmt، والصورة التالية تجمع الأوامر التي سوف تستخدمها كل يوم:

ست بطاقات للأمر go: البناء والتشغيل بالأمرين go run وgo build، والاختبار بـ go test مع الخيارات cover وrace وbench، والتنسيق بـ gofmt، والفحص بـ go vet، والحزم بـ go mod init وgo get وgo mod tidy، وإصدار اللغة بالمتغير GOTOOLCHAIN، وتحتها شريط للأدوات الخارجية gopls وstaticcheck وgolangci-lint وgovulncheck
أداة واحدة رسمية لكل مرحلة، ولذلك تتشابه مشاريع Go في كل مكان

وأهم ما يجب أن تعرفه عن هذه الأدوات:

  • المترجم Compiler: go build يترجم ويربط ويخرج ملفاً واحداً، وgo run يفعل نفس الشيء في مجلد مؤقت ثم يشغل البرنامج، وهو للتجربة فقط وليس للإنتاج. وكلاهما يحفظ نتيجة ترجمة كل حزمة في الـ Build Cache، فلا يعيد ترجمة ما لم يتغير، وقد رأينا في مقال كيف تختار لغة البرمجة ماذا يحدث داخل Dockerfile إذا ضاع هذا الـ Cache مع كل بناء. وإذا أردت أن تفهم ما يحدث بين الكود والملف التنفيذي، من التحليل إلى توليد كود الآلة، فقد شرحناه في مقال المترجم والمفسر وJIT.
  • الاختبار Test Runner: go test يبحث عن الملفات التي تنتهي بـ _test.go ويشغل كل دالة تبدأ بـ Test، والإطار نفسه هو الحزمة القياسية testing، ومعه قياس الأداء Benchmarks وتغطية الكود Coverage وكاشف السباقات Race Detector.
  • المنسق Formatter: gofmt له شكل واحد ولا يقبل أي إعدادات، ويقول Rob Pike في تدوينة gofmt إن أسلوب gofmt لا يعجب أحداً بالكامل، ولكنه يعجب الجميع لأنه أنهى الجدال، وهذا صحيح، فلن تقضي وقتاً في مراجعة الكود على المسافات والأقواس أبداً.
  • الفاحص Vet: go vet يبحث عن أخطاء يقبلها المترجم ولكنها في الغالب خطأ، مثل نوع خاطئ في Printf، أو نسخ قفل بالقيمة، أو كود لا يصل إليه التنفيذ. ويشغل go test جزءاً منه تلقائياً قبل كل اختبار.
  • الحزم Modules: go mod init ينشئ الوحدة، وgo get يضيف مكتبة أو يغير إصدارها، وgo mod tidy يجعل ملف go.mod مطابقاً لما يستورده الكود فعلاً، والمرجع الكامل في مرجع الوحدات Go Modules Reference.

وهناك أدوات خارج الأمر go لا يستغني عنها فريق جاد: gopls وهو خادم اللغة Language Server الرسمي الذي يعطي VS Code وNeovim وGoLand الإكمال والانتقال إلى التعريف وإعادة التسمية، وstaticcheck وهو أشهر فاحص ثابت Static Analyzer أعمق من go vet، وgolangci-lint الذي يجمع عشرات الفاحصات ومنها staticcheck في أمر واحد مع ملف إعداد، وهو الأكثر استخداماً في خطوط CI، وgovulncheck الأداة الرسمية التي تبحث في قاعدة بيانات الثغرات عن ثغرات معروفة في المكتبات التي يستدعيها كودك فعلاً، وليس في كل ما هو مذكور في go.mod، وهذا يقلل الإنذارات الكاذبة كثيراً.

شكل المشروع Project Layout: الوحدة Module والحزمة Package

أكثر ما يربك القادم من Java أن مشروع Go لا يحتاج أي بنية مجلدات مفروضة، لا يوجد src/main/java ولا setup.py، وإنما مفهومان فقط، والصورة التالية تبين مشروع التجربة:

شجرة مشروع store فيها go.mod وgo.sum والمجلد cmd/pricecalc وفيه main.go، والمجلد internal/money وفيه money.go وmoney_test.go، وبجانبها خمس بطاقات تشرح الوحدة والحزمة والحرف الكبير والمجلد internal والمجلد cmd
الوحدة مجلد فيه go.mod، والحزمة مجلد واحد، والباقي أعراف
  • الوحدة Module: مجلد فيه ملف go.mod، والسطر الأول فيه اسم الوحدة مثل module example.com/store، وهذا الاسم هو بداية مسار كل استيراد داخلها. وإذا كانت مكتبة ينشرها الآخرون فاسمها هو عنوان مستودعها مثل github.com/jackc/pgx/v5، والسبب أن الأمر go ينزل المكتبة من هذا العنوان نفسه.
  • الحزمة Package: كل مجلد حزمة واحدة، وكل الملفات فيه تبدأ بنفس السطر package money، وترى بعضها مباشرة دون استيراد. والحزمة main مع الدالة main() هي التي تصبح برنامجاً تنفيذياً.
  • المجلد internal: هذا ليس عرفاً، وإنما قاعدة يفرضها المترجم، فالحزم تحته لا يستطيع استيرادها إلا الكود الذي يقع داخل المجلد الأب لها، لذلك تضع فيه ما لا تريد أن يعتمد عليه غيرك.
  • المجلد cmd: عرف شائع لوضع كل برنامج تنفيذي في مجلد باسمه، فإذا كان للمشروع خدمة وأداة سطر أوامر صار لديك cmd/orders وcmd/migrate.

وقد يتساءل البعض: ألا يوجد شكل «قياسي» للمشروع؟ ستجد في البحث مستودعاً مشهوراً باسم golang-standards/project-layout فيه عشرات المجلدات مثل pkg وapi وconfigs، ولكن انتبه إلى أنه ليس من فريق Go رغم اسمه، ويذكر هو نفسه في أوله أنه «ليس معياراً رسمياً». والمرجع الرسمي هو صفحة تنظيم وحدة Go التي تبدأ بأبسط شكل، ملف واحد في مجلد واحد، ثم تضيف internal وcmd عندما تحتاجهما. والقاعدة العملية أن تبدأ بأقل عدد من الحزم، وأن تقسم عندما يصبح للحزمة اسم واضح ومسؤولية واحدة، وليس قبل ذلك، والسبب أن كل حزمة في Go حدود حقيقية: ما يبدأ بحرف صغير لا يعبرها، والاعتماد الدائري Import Cycle بين حزمتين خطأ ترجمة، فالتقسيم المبكر يجبرك على تصدير أشياء لم تكن تريد تصديرها.

التجربة العملية Lab: حاسبة الطلب pricecalc

الآن نعود إلى قصتنا، فأول قطعة يحتاجها فريق خدمة الطلبات هي الدالة التي تحسب إجمالي الطلب مع ضريبة القيمة المضافة 15%، وهي نفس الدالة التي سوف تعيش داخل الخدمة الحقيقية في المقال الرابع. والقاعدة الأولى في أي حساب مالي أن تخزن المبالغ بأصغر وحدة كأرقام صحيحة، أي بالهللة، والسبب أن الأرقام العشرية Floating Point لا تمثل 0.1 بدقة، وقد شرحنا ذلك من الداخل في مقال الأرقام العشرية Floating Point، ورأينا في مقال اختيار اللغة كيف اختلفت هللة واحدة بين لغتين بسبب التقريب. لذلك نحسب الضريبة على المجموع بالهللات، ونقرب النصف إلى الأعلى بمعادلة صحيحة: نضرب في 15، ونضيف 50، ثم نقسم على 100.

الكود: الحزمة money والبرنامج pricecalc

أنشأنا الوحدة بالأمر go mod init example.com/store، فأصبح ملف go.mod كما يلي:

module example.com/store

go 1.27

ثم الحزمة money في الملف internal/money/money.go:

// Package money keeps every amount in integer halalas (1 SAR = 100 halalas).
package money

import "fmt"

// VATRate is the Saudi VAT rate in percent.
const VATRate = 15

// Line is one product line of an order.
type Line struct {
	PriceHalalas int
	Qty          int
}

// Totals is the result of Compute.
type Totals struct {
	Subtotal int
	VAT      int
	Total    int
}

// VAT returns 15% of subtotal in halalas, rounded half up.
func VAT(subtotal int) int {
	return (subtotal*VATRate + 50) / 100
}

// Compute adds the lines, then the VAT on the subtotal.
func Compute(lines []Line) (Totals, error) {
	var t Totals
	for i, l := range lines {
		if l.Qty <= 0 {
			return Totals{}, fmt.Errorf("line %d: qty must be > 0, got %d", i, l.Qty)
		}
		t.Subtotal += l.PriceHalalas * l.Qty
	}
	t.VAT = VAT(t.Subtotal)
	t.Total = t.Subtotal + t.VAT
	return t, nil
}

// Format prints halalas as riyals: 3416 -> "34.16 SAR".
func Format(h int) string {
	return fmt.Sprintf("%d.%02d SAR", h/100, h%100)
}

في الكود أعلاه لاحظ التالي:

  • التعليق فوق كل اسم مصدر يبدأ باسمه، مثل // VAT returns…، وهذا عرف تعتمد عليه أداة go doc وموقع pkg.go.dev لتوليد التوثيق، ويطلبه دليل Effective Go.
  • الدالة Compute ترجع قيمتين، وعند الخطأ ترجع Totals{} وهي القيمة الصفرية للنوع، مع خطأ يحمل رقم السطر والقيمة الخاطئة، لأن رسالة مثل «invalid input» وحدها لا تساعد أحداً في السجل.
  • الحلقة for i, l := range lines تعطي الموقع والقيمة، والقيمة l نسخة من العنصر وليست مرجعاً إليه.
  • الدالة Format تستخدم %02d لتطبع الهللات برقمين دائماً، فيظهر 34.06 وليس 34.6.

ثم البرنامج نفسه في cmd/pricecalc/main.go، ويأخذ أسطر الطلب من سطر الأوامر بصيغة السعر بالهللة ثم الكمية:

// pricecalc prints the total of an order given as price:qty pairs.
//
//	pricecalc 990:3 4500:1
package main

import (
	"fmt"
	"os"
	"strconv"
	"strings"

	"example.com/store/internal/money"
)

func main() {
	if len(os.Args) < 2 {
		fmt.Fprintln(os.Stderr, "usage: pricecalc price:qty ...")
		os.Exit(2)
	}
	var lines []money.Line
	for _, arg := range os.Args[1:] {
		price, qty, ok := strings.Cut(arg, ":")
		if !ok {
			fmt.Fprintf(os.Stderr, "bad item %q, want price:qty\n", arg)
			os.Exit(2)
		}
		p, err1 := strconv.Atoi(price)
		q, err2 := strconv.Atoi(qty)
		if err1 != nil || err2 != nil {
			fmt.Fprintf(os.Stderr, "bad numbers in %q\n", arg)
			os.Exit(2)
		}
		lines = append(lines, money.Line{PriceHalalas: p, Qty: q})
	}
	t, err := money.Compute(lines)
	if err != nil {
		fmt.Fprintln(os.Stderr, "error:", err)
		os.Exit(1)
	}
	fmt.Println("subtotal:", money.Format(t.Subtotal))
	fmt.Println("vat 15%: ", money.Format(t.VAT))
	fmt.Println("total:   ", money.Format(t.Total))
}

ولاحظ هنا أمرين: الأول أن الاستيراد يكون بالمسار الكامل example.com/store/internal/money ثم يستخدم باسم الحزمة الأخير money، والثاني أن الدالة strings.Cut ترجع ثلاث قيم، والثالثة منطقية تقول هل وجد الفاصل أم لا، وهذا نمط شائع في المكتبة القياسية بدل رمي الاستثناء.

التشغيل go run والقيمة الخاطئة

الآن نجرب الطلب الذي سوف نعود إليه في كل مقالات المسار: ثلاثة أكياس هيل سعر الواحد 9.90 ريال:

go run ./cmd/pricecalc 990:3
go run ./cmd/pricecalc 990:0; echo "exit=$?"

والمخرج سوف يكون كما يلي:

subtotal: 29.70 SAR
vat 15%:  4.46 SAR
total:    34.16 SAR

error: line 0: qty must be > 0, got 0
exit status 1
exit=1

المجموع 29.70 ريال، والضريبة الدقيقة 4.455 ريال، أي 445.5 هللة، ومعادلة التقريب إلى الأعلى أعطت 446 هللة، فالإجمالي 34.16 ريال. وفي التشغيل الثاني رجعت الدالة الخطأ، فطبع البرنامج الرسالة وخرج بالرمز 1، ولاحظ السطر exit status 1، وهو من الأمر go run نفسه وليس من برنامجنا، فهو يخبرك أن البرنامج الذي شغله خرج برمز غير صفري.

الاختبار go test والتغطية Coverage

والاختبار في نفس المجلد في ملف money_test.go، بنمط اسمه الاختبارات الجدولية Table-Driven Tests، وهو الأسلوب الأكثر شيوعاً في Go:

package money

import "testing"

func TestCompute(t *testing.T) {
	tests := []struct {
		name  string
		lines []Line
		want  Totals
	}{
		{"three cardamom bags", []Line{{990, 3}}, Totals{2970, 446, 3416}},
		{"coffee and dates", []Line{{4500, 1}, {3800, 2}}, Totals{12100, 1815, 13915}},
		{"empty order", nil, Totals{0, 0, 0}},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			got, err := Compute(tc.lines)
			if err != nil {
				t.Fatalf("unexpected error: %v", err)
			}
			if got != tc.want {
				t.Errorf("Compute() = %+v, want %+v", got, tc.want)
			}
		})
	}
}

func TestComputeRejectsZeroQty(t *testing.T) {
	if _, err := Compute([]Line{{990, 0}}); err == nil {
		t.Fatal("expected an error for qty 0")
	}
}

func TestFormat(t *testing.T) {
	if got := Format(3416); got != "34.16 SAR" {
		t.Errorf("Format(3416) = %q", got)
	}
}

ثم نشغله بالخيار -v لنرى كل حالة:

go test -v ./...
go test -cover ./...

والمخرج سوف يكون كما يلي:

?   	example.com/store/cmd/pricecalc	[no test files]
=== RUN   TestCompute
=== RUN   TestCompute/three_cardamom_bags
=== RUN   TestCompute/coffee_and_dates
=== RUN   TestCompute/empty_order
--- PASS: TestCompute (0.00s)
    --- PASS: TestCompute/three_cardamom_bags (0.00s)
    --- PASS: TestCompute/coffee_and_dates (0.00s)
    --- PASS: TestCompute/empty_order (0.00s)
=== RUN   TestComputeRejectsZeroQty
--- PASS: TestComputeRejectsZeroQty (0.00s)
=== RUN   TestFormat
--- PASS: TestFormat (0.00s)
PASS
ok  	example.com/store/internal/money	0.132s

	example.com/store/cmd/pricecalc		coverage: 0.0% of statements
ok  	example.com/store/internal/money	1.078s	coverage: 100.0% of statements

في المخرج أعلاه لاحظ التالي:

  • النمط ./... يعني هذه الحزمة وكل ما تحتها، لذلك ظهرت الحزمة cmd/pricecalc مع الملاحظة [no test files].
  • كل حالة في الجدول ظهرت كاختبار فرعي Subtest باسمها، والمسافات في الاسم تحولت إلى شرطة سفلية، وتستطيع تشغيل حالة واحدة بالخيار -run 'TestCompute/three_cardamom'.
  • الرقم 0.132s هو زمن تشغيل ملف الاختبار كاملاً، والاختبارات نفسها أخذت أقل من جزء من المللي ثانية كما يظهر في (0.00s)، وارتفع الرقم إلى 1.078s مع -cover لأن الكود صار يحمل عدادات التغطية والجهاز كان مشغولاً.
  • التغطية 100% للحزمة money، وصفر للبرنامج لأنه لا يوجد له اختبار. ولا تجعل 100% هدفاً في كل مكان، ولكن الحساب المالي من الأماكن التي يستحق فيها كل سطر اختباراً، وقد شرحنا أنواع الاختبارات ومتى تستخدم كلاً منها في مقال أنواع الاختبارات.

ولاحظ أن هذا المقال لا يحتاج أي مكتبة خارجية للاختبار، فلا يوجد assert ولا expect، وإنما if got != want ثم t.Errorf، وهذا مقصود في ثقافة Go، والسبب الذي يذكره الفريق أن رسالة الخطأ التي تكتبها بنفسك تقول بالضبط ماذا كان متوقعاً وماذا ظهر، كما في Compute() = %+v, want %+v. ومع ذلك ستجد مكتبة testify في كثير من المشاريع، ولا بأس بها إذا اتفق عليها الفريق.

التنسيق gofmt والفحص go vet

لكي نرى ما يفعله المنسق كتبنا ملفاً بشكل سيء عن قصد، بلا مسافات ولا إزاحة، ثم طلبنا من gofmt أن يعرض الفرق دون أن يغير الملف:

gofmt -d fmtme/main.go

والمخرج سوف يكون كما يلي:

diff fmtme/main.go.orig fmtme/main.go
--- fmtme/main.go.orig
+++ fmtme/main.go
@@ -1,6 +1,10 @@
 package main
+
 import "fmt"
-func main(){
-prices:=[]int{990,4500}
-for i,p:=range prices{ fmt.Println(i,p) }
+
+func main() {
+	prices := []int{990, 4500}
+	for i, p := range prices {
+		fmt.Println(i, p)
+	}
 }

المنسق أضاف سطراً فارغاً بعد package وبعد الاستيراد، ووضع المسافات حول := وبعد الفواصل، وأزاح الأسطر بعلامة Tab وليس بمسافات، وفتح القوس في نفس سطر الحلقة. والأخيرة ليست ذوقاً، وإنما ضرورة، لأن المترجم يضيف الفاصلة المنقوطة تلقائياً في نهاية السطر، فلو وضعت القوس في السطر التالي لفهمه المترجم بشكل خاطئ. وفي المشروع الحقيقي لا تشغل هذا الأمر بيدك، وإنما تجعل المحرر يشغله عند كل حفظ، وتجعل خطوة في CI تفشل إذا أخرج gofmt -l . أي ملف.

ثم الفاحص، وكتبنا له خطأً شائعاً، وهو الدالة Printf مع ترتيب معاملات خاطئ، اسم المنتج مكان الرقم والكمية مكان النص:

package main

import "fmt"

func main() {
	name := "هيل حب 100 غرام"
	qty := 3
	fmt.Printf("product %d qty %s\n", name, qty)
}

والمترجم يقبل هذا الكود دون اعتراض، لأن Printf تقبل أي قيم، وعند التشغيل ثم الفحص يكون المخرج كما يلي:

product %!d(string=هيل حب 100 غرام) qty %!s(int=3)
vet/main.go:8:22: fmt.Printf format %d has arg name of wrong type string
exit=1

فالسطر الأول هو ما طبعه البرنامج فعلاً، والعلامة %!d(string=…) هي طريقة Go لتقول إن الفعل %d جاءه نص، فهي لا توقف البرنامج، وإنما تطبع هذا الشكل الغريب في السجل، وقد يمر أسابيع قبل أن يلاحظه أحد. والسطر الثاني من go vet، ويحدد الملف والسطر والعمود والسبب، ويخرج برمز غير صفري فيوقف خط CI.

الفحص الأعمق staticcheck ثم govulncheck

وهناك أخطاء لا يراها go vet، مثل هذا الملف:

package main

import (
	"fmt"
	"strings"
)

func main() {
	sku := "  hl-100 "
	strings.TrimSpace(sku)
	if sku == "hl-100" {
		fmt.Println("found")
	}
	fmt.Println(strings.Title(sku))
}

والخطأ هنا أن strings.TrimSpace لا تعدل النص، وإنما ترجع نصاً جديداً، لأن النصوص في Go ثابتة Immutable، فالشرط بعدها لن يتحقق أبداً. وشغلنا go vet فخرج برمز صفر دون أي ملاحظة، ثم ثبتنا staticcheck وشغلناه:

go install honnef.co/go/tools/cmd/[email protected]
staticcheck ./static

والمخرج سوف يكون كما يلي:

go: downloading golang.org/x/sync v0.23.0
go: downloading golang.org/x/mod v0.41.0
staticcheck 2026.2.2 (0.8.2)
static/main.go:10:2: TrimSpace doesn't have side effects and its return value is ignored (SA4017)
static/main.go:14:14: strings.Title has been deprecated since Go 1.18 and an alternative has been available since Go 1.0: The rule Title uses for word boundaries does not handle Unicode punctuation properly. Use golang.org/x/text/cases instead.  (SA1019)
exit=1

فالأداة وجدت مشكلتين، لكل منهما رمز تبحث عنه في قائمة الفحوصات: SA4017 للقيمة المرجعة المهملة من دالة ليس لها أثر جانبي، وSA1019 لاستخدام دالة قديمة Deprecated، فالدالة strings.Title لا تتعامل مع حدود الكلمات في Unicode بشكل صحيح، والبديل في الحزمة golang.org/x/text/cases. وعندما شغلنا البرنامج طبع Hl-100 بالمسافات، ولم يطبع found، وهذا تأكيد للخطأ الأول.

ثم govulncheck، وهي الأداة الرسمية للثغرات، وشغلناها على مشروعنا:

go install golang.org/x/vuln/cmd/govulncheck@latest
govulncheck ./...

والمخرج كان No vulnerabilities found.، وهذا متوقع في مشروع لا يستخدم إلا المكتبة القياسية من أحدث إصدار. ولكن قيمة هذه الأداة تظهر عندما يصبح للمشروع عشرات المكتبات، فهي لا تكتفي بأن تقول إن المكتبة فيها ثغرة، وإنما تتبع الاستدعاءات وتقول هل الدالة المصابة يستدعيها كودك فعلاً، كما يشرح توثيق إدارة الثغرات في Go، وهذا يجعل تقريرها قصيراً وقابلاً للتنفيذ، بدل قائمة بمئات الإنذارات لا يقرؤها أحد.

البناء go build: ملف واحد بلا مكتبات Static Binary

الآن نبني البرنامج كما سوف نوزعه، ثم نبنيه مرة ثانية مع حذف رموز التصحيح، ثم نسأل هل يعتمد على مكتبات النظام، ثم نبنيه لنظام Windows على معالج ARM من نفس الجهاز:

go build -o /tmp/pricecalc ./cmd/pricecalc
go build -ldflags="-s -w" -o /tmp/pricecalc-s ./cmd/pricecalc
ldd /tmp/pricecalc
GOOS=windows GOARCH=arm64 go build -o /tmp/pricecalc.exe ./cmd/pricecalc

والصورة التالية تجمع ما خرج من هذه الأوامر:

مسار من ثلاث مراحل: المدخل وهو ملفات الكود والمكتبة القياسية، ثم المترجم والرابط الذي يربط كل شيء ربطاً ثابتاً، ثم المخرج وهو ملف واحد حجمه 2.40 ميجابايت أو 1.55 مع حذف الرموز أو 2.39 لنظام windows/arm64، وتحتها زمن البناء الأول 9.66 ثانية والثاني 0.24 ثانية
البرنامج كله في ملف واحد، والبناء لنظام آخر متغيران فقط

والأرقام كما ظهرت في المخرج:

real	0m9.664s
user	0m0.515s
sys	0m0.964s
-rwxr-xr-x 1 root root 2399204 Oct 31 07:14 /tmp/pricecalc

-rwxr-xr-x 1 root root 1548448 Oct 31 07:14 /tmp/pricecalc-s

	not a dynamic executable

-rwxr-xr-x 1 root root 2391552 Oct 31 07:15 /tmp/pricecalc.exe

في المخرج أعلاه لاحظ التالي:

  • الملف 2.4 ميجابايت تقريباً لبرنامج من خمسين سطراً، والسبب أنه يحمل داخله الـ Runtime كاملاً: جامع القمامة، والمجدول Scheduler الذي يدير الـ Goroutines، وكود الحزم fmt وstrconv وos. وهذا الحجم ثابت تقريباً، فالخدمة الكاملة في المقال الرابع لن تكون أكبر بعشرة أضعاف.
  • الخيار -s -w يحذف جدول الرموز ومعلومات التصحيح DWARF، فنزل الحجم إلى 1.55 ميجابايت، ولكنك تخسر أسماء الدوال عند استخدام المصحح Debugger، أما رسائل panic فتبقى بأسماء الدوال لأن الـ Runtime يحتفظ بجدول خاص بها.
  • not a dynamic executable تعني أن الملف لا يحتاج أي مكتبة من النظام، ولا حتى libc، فتستطيع أن تنسخه إلى أي جهاز Linux على نفس المعالج وتشغله، أو تضعه وحده في Docker Image فارغة تقريباً. ولكن انتبه إلى أن هذا صحيح لأن برنامجنا لا يستخدم حزماً تستدعي C، فإذا استخدمت مكتبة تعتمد على cgo، أو بعض الحالات في الحزمتين net وos/user، صار الملف يعتمد على libc، والحل المعتاد في البناء للحاويات هو CGO_ENABLED=0.
  • البناء لنظام Windows على ARM خرج من نفس الـ Container دون أي أداة إضافية، فقط بالمتغيرين GOOS وGOARCH، وهذه من أكبر الأسباب التي جعلت أدوات سطر الأوامر مثل Docker CLI وTerraform وkubectl تكتب بلغة Go.

أما الأمر go list std | wc -l فأعاد 383، وهو عدد الحزم في المكتبة القياسية لهذا الإصدار، وفيها سيرفر HTTP كامل وتشفير وJSON وقواعد بيانات بواجهة database/sql واختبار وقياس أداء، وهذا سبب أن كثيراً من خدمات Go لا تستخدم إطار عمل Framework أصلاً، كما سوف نرى في المقال الرابع.

إضافة مكتبة Dependency: go get ثم go mod tidy

الآن نضيف مكتبة خارجية لنرى ما يحدث في الملفات، وهي golang.org/x/text بإصدار محدد:

go get golang.org/x/[email protected]
cat go.mod; head -4 go.sum
go mod tidy
cat go.mod

والمخرج سوف يكون كما يلي:

go: downloading golang.org/x/text v0.30.0
go: added golang.org/x/text v0.30.0
module example.com/store

go 1.27

require golang.org/x/text v0.30.0 // indirect
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=

# go mod tidy; cat go.mod
module example.com/store

go 1.27
go.sum

في المخرج أعلاه لاحظ التالي:

  • أضاف go get السطر require مع العلامة // indirect، والسبب أن كودنا لا يستورد هذه المكتبة بعد، فهي في نظر الأداة اعتمادية غير مباشرة.
  • الملف go.sum فيه بصمتان لكل إصدار: بصمة الكود كاملاً، وبصمة ملف go.mod الخاص بالمكتبة. وهذه البصمات تتحقق منها الأداة مع قاعدة بصمات عامة يديرها فريق Go، كما يشرح قسم قاعدة البصمات Checksum Database، فإذا غير أحد كود إصدار منشور على GitHub بعد نشره رفضت الأداة تنزيله. وهذه حماية لا تجدها افتراضياً في كل مديري الحزم.
  • go mod tidy حذف السطر كله، لأن الكود لا يستورد المكتبة، وهذا ما تريده دائماً قبل الـ Commit، وبقي الملف go.sum في مكانه كما يظهر في السطر الأخير.

وقد يتساءل البعض: أين ملف القفل Lock File مثل package-lock.json؟ والإجابة أن Go لا تحتاجه، لأنها تستخدم خوارزمية اسمها أقل إصدار ممكن Minimal Version Selection، فإذا طلبت مكتبتك الإصدار 1.2 من مكتبة، وطلبت مكتبة أخرى 1.4 منها، اختارت الأداة 1.4 بالضبط، وليس أحدث إصدار منشور، والنتيجة نفسها في كل جهاز وفي كل يوم ما دام go.mod لم يتغير. ويشرح Russ Cox الفكرة وسببها في مقاله Minimal Version Selection، ومن نتائجها أن مكتبة تنشر إصداراً جديداً سيئاً لا تصل إلى مشروعك وحدها، وإنما عندما ترفع الإصدار بنفسك.

مشكلات Problems واجهتنا أثناء التجربة

ثلاث مشكلات صغيرة ظهرت أثناء التجربة، وكل واحدة منها سوف تقابلك في أسبوعك الأول:

  • عندما أردنا أن نرى رسالة المترجم عن الاستيراد غير المستخدم، كتبنا go build ./unused فظهر خطأ لا علاقة له بالكود: go: build output "unused" already exists and is a directory. والسبب أن go build لحزمة main واحدة يكتب الملف التنفيذي في المجلد الحالي باسم آخر جزء من المسار، وهنا اسم المجلد نفسه. والحل أن تحدد المخرج بالخيار -o، ونحن كتبنا -o /dev/null لأننا نريد رسائل الترجمة فقط.
  • ثبتنا staticcheck بالإصدار 2026.1.1 من الذاكرة، فرفض الأمر بالرسالة invalid version: unknown revision 2026.1.1، لأن هذا الإصدار لم يصدر أصلاً. ولاحظنا بعدها أن المشروع يعطي إصداراته أسماء بالسنة مثل 2026.2.2، بينما رقم الوحدة الحقيقي v0.8.2، كما ظهر في المخرج. لذلك خذ رقم الإصدار من صفحة الإصدار وثبته في ملف إعداد CI، ولا تستخدم @latest هناك حتى لا يتغير الفاحص وحده في يوم لم يغير فيه أحد شيئاً.
  • البناء الأول أخذ 9.66 ثانية بالوقت الحقيقي، بينما وقت المعالج كله 0.52 ثانية، ثم أخذ البناء الثاني 0.24 ثانية. فالفرق ليس ترجمة، وإنما انتظار القرص، لأن الكود في مجلد مشترك بين Windows والـ Container، والكتابة إلى /tmp وقراءة المجلد المشترك بطيئة في Docker Desktop، والجهاز كان يشغل أعمالاً أخرى. والدرس أن تقرأ user وsys بجانب real قبل أن تحكم على سرعة أي أداة.

ماذا يحدث إذا تركت متغيراً أو استيراداً غير مستخدم Unused؟

في أغلب اللغات يكون المتغير غير المستخدم تحذيراً يتجاهله الجميع، أما في Go فهو خطأ يوقف البناء، والملف التالي فيه الحالتان:

package main

import (
	"fmt"
	"os"
)

func main() {
	total := 3416
	fmt.Println("done")
}

وعند بنائه يكون المخرج كما يلي:

# example.com/broken/unused
unused/main.go:5:2: "os" imported and not used
unused/main.go:9:2: declared and not used: total
exit=1

والسبب كما يذكر الفريق في الأسئلة الشائعة أن الاستيراد غير المستخدم يبطئ الترجمة ويضيف اعتماديات بلا سبب، والمتغير غير المستخدم في الغالب علامة على خطأ، مثل نتيجة حسبتها ونسيت أن ترجعها. وهذا مزعج في البداية عندما تعلق سطراً لتجربة شيء، فيرفض المترجم البناء بسبب استيراد صار غير مستخدم، والحل العملي أن تجعل المحرر يشغل goimports أو gopls عند الحفظ، فيضيف الاستيرادات ويحذفها وحده. ولاحظ أن الشرطة السفلية _ تتجاوز هذه القاعدة، فالسطر _ = total يرضي المترجم، ولا تقم بذلك في كود حقيقي إلا لسبب مكتوب، لأنه يخفي بالضبط ما أرادت القاعدة كشفه.

أخطاء شائعة Common Mistakes في الأيام الأولى

  • تثبيت Go من مدير حزم التوزيعة ثم الاستغراب من رسالة go.mod requires go >= 1.27. ثبت الإصدار الرسمي، واترك GOTOOLCHAIN على auto في جهازك.
  • نسخ بنية مجلدات ضخمة من مستودع مشهور قبل أن يكون لديك ملف واحد. ابدأ بحزمة واحدة، ثم أضف internal وcmd عندما تحتاجهما.
  • تسمية الحزم بأسماء عامة مثل utils وcommon وhelpers. اسم الحزمة جزء من كل استدعاء، فـ money.VAT تقرأ جملة مفهومة، وutils.VAT لا تقول شيئاً.
  • تكرار اسم الحزمة في أسماء ما بداخلها، مثل money.MoneyFormat. اكتب money.Format، لأن الاسم الكامل عند الاستدعاء يحمل اسم الحزمة دائماً.
  • حقل في struct يبدأ بحرف صغير ثم الاستغراب من أن JSON خرج فارغاً. الحقول التي تريدها خارج الحزمة يجب أن تبدأ بحرف كبير، والاسم في JSON تحدده بالوسم `json:"name_ar"`.
  • تجاهل الخطأ بالشرطة السفلية لتسكت المترجم، مثل p, _ := strconv.Atoi(s). الرقم الخاطئ يصبح صفراً بهدوء، وهذا سعر صفر في فاتورة.
  • تشغيل الإنتاج بالأمر go run. هذا الأمر يترجم في كل تشغيل في مجلد مؤقت، وابن الملف بـ go build ثم شغله.
  • عدم تشغيل gofmt وgo vet في CI، ثم مراجعات كود تدور حول المسافات. اجعلها خطوتين تفشلان البناء من اليوم الأول.

قائمة تحقق Checklist قبل أن تكتب أول خدمة

البندكيف تتحقق
الإصدار الرسمي الحالي مثبتgo version يطبع go1.27.x
السطر go في go.mod مقصودلا يزيد عن الإصدار الذي يحتاجه الكود فعلاً
المحرر يستخدم gopls وينسق عند الحفظاحفظ ملفاً سيء التنسيق وراقب التغيير
CI يشغل التنسيقtest -z "$(gofmt -l .)"
CI يشغل الفحص والاختبارgo vet ./... ثم go test -race ./...
فاحص أعمق بإصدار مثبتstaticcheck أو golangci-lint برقم إصدار في ملف CI
فحص الثغراتgovulncheck ./... في كل Pull Request وكل أسبوع
go.mod وgo.sum نظيفانgo mod tidy ثم git diff --exit-code
المبالغ بالهللة كأرقام صحيحةلا يوجد float64 في أي حساب مالي

تدريب عملي

  • أضف إلى pricecalc خياراً -vat بالحزمة القياسية flag يغير نسبة الضريبة، ثم أضف حالة إلى جدول الاختبار تتحقق من نسبة 5%.
  • شغل go build -gcflags=-m ./internal/money واقرأ ما يقوله المترجم عن كل دالة: هل استطاع أن يدمجها Inline في مكان استدعائها؟
  • غير السطر go 1.27 إلى go 1.28 في go.mod، ثم شغل go version داخل المجلد، واقرأ رسالة الخطأ، لأن هذا الإصدار لم يصدر بعد.
  • استبدل strings.Title في الملف الذي فحصه staticcheck بالحزمة golang.org/x/text/cases، وشاهد كيف تتغير go.mod وgo.sum هذه المرة دون العلامة indirect.

الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

  • صممت Go لمشكلة فرق كبيرة في Google: بناء بطيء، واعتماديات متشابكة، وكود يصعب قراءته، ولذلك كل ما تركته من الكلاسات والاستثناءات وحلقة while كان قراراً لصالح البساطة والقراءة.
  • ثبت الإصدار الرسمي 1.27.3، فالسطر go في go.mod حد أدنى ملزم، والأمر go ينزل الإصدار المطلوب وحده بالإعداد auto.
  • أهم اختلافات الصيغة: النوع بعد الاسم، والقيمة الصفرية لكل متغير، والدالة بقيمتين مع error، وfor وحدها، والـ struct مع الدوال المرتبطة، وdefer، والحرف الكبير للتصدير.
  • الأمر go وحده يبني ويختبر ويفحص ويدير الحزم، ومعه gofmt، وتضيف إليه staticcheck أو golangci-lint وgovulncheck وgopls.
  • في التجربة حسبت الحاسبة 29.70 ريال مع ضريبة 4.46 فالإجمالي 34.16، ونجحت الاختبارات بتغطية 100% للحزمة، وكشف go vet خطأ Printf وكشف staticcheck خطأين لم يرهما.
  • الملف التنفيذي 2.4 ميجابايت بلا أي مكتبة من النظام، و1.55 مع حذف الرموز، والبناء لنظام Windows على ARM كان متغيرين فقط.

وفي المقالات التالية من مرحلة لغة Go للمطورين نكمل نفس القصة، من الأنواع والواجهات والأخطاء، إلى التزامن بالـ Goroutines، ثم خدمة الطلبات كاملة، ثم الأخطاء الشائعة وخطة القراءة:

دليلكيف تختار لغة البرمجة Programming Language لمشروعك؟نختار لغة خدمة حقيقية بأسئلة الفريق والتوظيف والمكتبات والرخصة، ثم نكتبها بأربع لغات ونقيس البناء والحجم والذاكرة والطلبات في الثانية بأنفسنا.دليلالبرمجة بعدة لغات Polyglot: متى تفيد ومتى تعقد مشروعك؟نستدعي نفس الدالة من Python بخمس طرق، عبر FFI إلى C وGo وعبر HTTP وgRPC، ونقيس كلفة كل حد بين لغتين وتعقيد البناء وما يحدث عندما يتعطل الطرف الآخر.

سجل التحديثات

  • أكتوبر 2026: كتابة المقال، وتنفيذ التجربة بالإصدار Go 1.27.3 داخل الـ Image الرسمية golang:1.27.3-trixie على Windows 11، مع staticcheck 2026.2.2 وgovulncheck v1.9.0.
Could not load content
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.