اذهب إلى المحتوى

البحث في الموقع

المحتوى عن 'البرمجة بلغة go'.

  • ابحث بالكلمات المفتاحية

    أضف وسومًا وافصل بينها بفواصل ","
  • ابحث باسم الكاتب

نوع المحتوى


التصنيفات

  • الإدارة والقيادة
  • التخطيط وسير العمل
  • التمويل
  • فريق العمل
  • دراسة حالات
  • التعامل مع العملاء
  • التعهيد الخارجي
  • السلوك التنظيمي في المؤسسات
  • عالم الأعمال
  • التجارة والتجارة الإلكترونية
  • نصائح وإرشادات
  • مقالات ريادة أعمال عامة

التصنيفات

  • مقالات برمجة عامة
  • مقالات برمجة متقدمة
  • PHP
    • Laravel
    • ووردبريس
  • جافاسكربت
    • لغة TypeScript
    • Node.js
    • React
    • Vue.js
    • Angular
    • jQuery
    • Cordova
  • HTML
  • CSS
    • Sass
    • إطار عمل Bootstrap
  • SQL
  • لغة C#‎
    • ‎.NET
    • منصة Xamarin
  • لغة C++‎
  • لغة C
  • بايثون
    • Flask
    • Django
  • لغة روبي
    • إطار العمل Ruby on Rails
  • لغة Go
  • لغة جافا
  • لغة Kotlin
  • لغة Rust
  • برمجة أندرويد
  • لغة R
  • الذكاء الاصطناعي
  • صناعة الألعاب
  • سير العمل
    • Git
  • الأنظمة والأنظمة المدمجة

التصنيفات

  • تصميم تجربة المستخدم UX
  • تصميم واجهة المستخدم UI
  • الرسوميات
    • إنكسكيب
    • أدوبي إليستريتور
  • التصميم الجرافيكي
    • أدوبي فوتوشوب
    • أدوبي إن ديزاين
    • جيمب GIMP
    • كريتا Krita
  • التصميم ثلاثي الأبعاد
    • 3Ds Max
    • Blender
  • نصائح وإرشادات
  • مقالات تصميم عامة

التصنيفات

  • مقالات DevOps عامة
  • خوادم
    • الويب HTTP
    • البريد الإلكتروني
    • قواعد البيانات
    • DNS
    • Samba
  • الحوسبة السحابية
    • Docker
  • إدارة الإعدادات والنشر
    • Chef
    • Puppet
    • Ansible
  • لينكس
    • ريدهات (Red Hat)
  • خواديم ويندوز
  • FreeBSD
  • حماية
    • الجدران النارية
    • VPN
    • SSH
  • شبكات
    • سيسكو (Cisco)

التصنيفات

  • التسويق بالأداء
    • أدوات تحليل الزوار
  • تهيئة محركات البحث SEO
  • الشبكات الاجتماعية
  • التسويق بالبريد الالكتروني
  • التسويق الضمني
  • استسراع النمو
  • المبيعات
  • تجارب ونصائح
  • مبادئ علم التسويق

التصنيفات

  • مقالات عمل حر عامة
  • إدارة مالية
  • الإنتاجية
  • تجارب
  • مشاريع جانبية
  • التعامل مع العملاء
  • الحفاظ على الصحة
  • التسويق الذاتي
  • العمل الحر المهني
    • العمل بالترجمة
    • العمل كمساعد افتراضي
    • العمل بكتابة المحتوى

التصنيفات

  • الإنتاجية وسير العمل
    • مايكروسوفت أوفيس
    • ليبر أوفيس
    • جوجل درايف
    • شيربوينت
    • Evernote
    • Trello
  • تطبيقات الويب
    • ووردبريس
    • ماجنتو
    • بريستاشوب
    • أوبن كارت
    • دروبال
  • الترجمة بمساعدة الحاسوب
    • omegaT
    • memoQ
    • Trados
    • Memsource
  • برامج تخطيط موارد المؤسسات ERP
    • تطبيقات أودو odoo
  • أنظمة تشغيل الحواسيب والهواتف
    • ويندوز
    • لينكس
  • مقالات عامة

التصنيفات

  • آخر التحديثات

أسئلة وأجوبة

  • الأقسام
    • أسئلة البرمجة
    • أسئلة ريادة الأعمال
    • أسئلة العمل الحر
    • أسئلة التسويق والمبيعات
    • أسئلة التصميم
    • أسئلة DevOps
    • أسئلة البرامج والتطبيقات

التصنيفات

  • كتب ريادة الأعمال
  • كتب العمل الحر
  • كتب تسويق ومبيعات
  • كتب برمجة
  • كتب تصميم
  • كتب DevOps

ابحث في

ابحث عن


تاريخ الإنشاء

  • بداية

    نهاية


آخر تحديث

  • بداية

    نهاية


رشح النتائج حسب

تاريخ الانضمام

  • بداية

    نهاية


المجموعة


النبذة الشخصية

  1. قدمت لغة جو في الإصدار 1.18 ميزةً جديدة تُعرف باسم الأنواع المُعمّمة generic types -أو اختصارًا generics- والتي كانت في قائمة أمنيات مُطوّري هذه اللغة لبعض الوقت. والنوع المُعمّم في لغات البرمجة، هو نوع يمكن استخدامه مع أنواع متعددة أخرى. سابقًا، عندما كنا نرغب في استخدام نوعين مختلفين لنفس المتغير في لغة جو، كنا نحتاج إما لاستخدام واجهة مُعرّفة بأسلوب معين مثل io.Reader، أو استخدام {}interface الذي يسمح باستخدام أي قيمة. المشكلة في أن استخدام {}interface يجعل التعامل مع تلك الأنواع صعبًا، لأنه يجب التحويل بين العديد من الأنواع الأخرى المحتملة للتفاعل معها (عليك تحويل القيم بين أنواع مختلفة من البيانات للتعامل معها). على سبيل المثال، إذا كان لدينا قيمة من {}interface تمثل عددًا، يجب علينا تحويلها إلى int قبل أن تتمكن من إجراء العمليات الحسابية عليها. هذه العملية قد تكون معقدة وتستلزم كتابة الكثير من التعليمات الإضافية للتعامل مع تحويل الأنواع المختلفة، أما الآن وبفضل الأنواع المُعمّمة، يمكننا التفاعل مباشرةً مع الأنواع المختلفة دون الحاجة للتحويل بينها، مما يؤدي إلى شيفرة نظيفة سهلة القراءة. نُنشئ في هذا المقال برنامجًا يتفاعل مع مجموعة من البطاقات، إذ سنبدأ بإنشائها بحيث تستخدم {}interface للتفاعل مع البطاقات الأخرى، ثم نُحدّثها من أجل استخدام الأنواع المُعمّمة، ثم سنضيف نوعًا ثانيًا من البطاقات باستخدام الأنواع المُعمّمة، ثم سنُحدّثها لتقييد نوعها المُعمّم، بحيث تدعم أنواع البطاقات فقط. نُنشئ أخيرًا دالةً تستخدم البطاقات الخاصة بنا وتدعم الأنواع المُعمّمة. المتطلبات الأولية لمتابعة هذا المقال التعليمي، سنحتاج إلى: إصدار مُثبّت من جو 1.16 أو أعلى، ويمكنك الاستعانة بمقال تثبيت لغة جو Go وإعداد بيئة برمجة محلية على أبونتو Ubuntu لإعداده. تثبيت لغة جو وإعداد بيئة برمجة محلية على نظام ماك macOS. تثبيت لغة جو وإعداد بيئة برمجة محلية على ويندوز. فهم قوي لأساسيات لغة جو، مثل المتغيرات والدوال وأنواع البيانات والشرائح والحلقات ..إلخ. كل الأساسيات ومقالات أخرى متقدمة في هذه اللغة تجدها هنا. التجميعات Collections في لغة جو بدون استخدام الأنواع المعممة يشير مصطلح التجميعة Collection في هذا السياق إلى حاوية بيانات يمكن أن تحتوي على قيم أو عناصر متعددة، وهو مفهوم ذو مستوى أعلى يشمل أنواعًا مختلفة من هياكل البيانات، مثل المصفوفات والقوائم والمجموعات والروابط maps، مما يسمح بتخزين وتنظيم عدة قيم مرتبطة معًا. توفر التجميعات عمليات وتوابع للوصول إلى العناصر التي تحتويها ومعالجتها وتكرارها. نستخدم مصطلح "التجميعة" هنا لوصف الهيكل أو الحاوية المستخدمة لتخزين وإدارة أوراق اللعب في البرنامج. تتمتع لغة جو بميزة قوية تتمثّل في قدرتها على تمثيل العديد من الأنواع بطريقة مرنة باستخدام الواجهات interfaces. يمكن للكثير من الشيفرات المكتوبة بلغة جو أن تعمل جيدًا باستخدام دوال الواجهات المتاحة، وهذا هو أحد الأسباب التي جعلت اللغة موجودةً لفترة طويلة دون دعم الأنواع المُعمّمة. نُنشئ في هذا المقال برنامجًا يحاكي عملية الحصول على بطاقة لعب عشوائية PlayingCard من مجموعة بطاقات Deck. سنستخدم في هذا القسم {}interface للسماح للدستة Deck بالتفاعل مع أي نوع من البطاقات. نُعدّل لاحقًا البرنامج لاستخدام الأنواع المُعمّمة لنتمكن من فهم الفروق بينهما والتعرف على الحالات التي تكون فيها الأنواع المُعمّمة خيارًا أفضل من غيرها. يمكن تصنيف أنظمة النوع type systems في لغات البرمجة عمومًا إلى فئتين مختلفتين: النوع typing والتحقق من النوع type checking. تُشير الفئة الأولى إلى كيفية التعامل مع الأنواع في اللغة وكيفية تعريف وتمثيل أنواع البيانات المختلفة. هناك نوعان رئيسيان من أنظمة "النوع": النوع القوي strong والنوع الضعيف weak وكذلك الثابت static والديناميكي dynamic. تُطبّق قواعد صارمة في النوع القوي تضمن توافق الأنواع وعدم وجود تضارب بينها. على سبيل المثال، لا يمكن تخزين قيمة من نوع بيانات ما في متغير من نوع بيانات آخر (لا يمكن تخزين قيمة int في متغير string) إلا إذا كانت هناك توافقية بينهما. أما في النوع الضعيف، فقد تسمح اللغة بتحويل تلقائي للأنواع من خلال عمليات تحويل ضمنية. أما بالنسبة "للتحقق من النوع"، فهذا يشير إلى كيفية التحقق من صحة استخدام الأنواع في البرنامج. هناك نوعان رئيسيان من أنظمة التحقق من النوع: التحقق الثابت statically-checked والتحقق الديناميكي dynamically-checked. تُفحص الأنواع في التحقق الثابت خلال عملية التصريف ويُفحص توافقها وصحتها والتأكد من عدم وجود أخطاء في النوع قبل تنفيذ البرنامج. أما في التحقق الدينامكي، فإن الفحص يجري أثناء تنفيذ البرنامج نفسه، ويمكن للأنواع أن تتغير وتحول ديناميكيًا خلال تنفيذ البرنامج. تنتمي لغة جو عمومًا إلى فئة اللغات ذات النوع القوي والتحقق الثابت، إذ تستخدم قواعد صارمة للتحقق من توافق الأنواع وتتحقق من صحتها خلال عملية التصريف، مما يساعد في تجنب الأخطاء الناتجة عن تعامل غير صحيح مع الأنواع في البرنامج، لكن يترتب على ذلك بعض القيود على البرامج، لأنه يجب أن تُعرّف الأنواع التي تنوي استخدامها قبل تصريف البرنامج. يمكن التعامل مع هذا من خلال استخدام النوع {}interface، إذ يعمل نوع {}interface مع أي قيمة. لبدء إنشاء البرنامج باستخدام {}interface لتمثيل البطاقات، نحتاج إلى مجلد للاحتفاظ بمجلد البرنامج فيه، وليكن باسم "projects". أنشئ المجلد وانتقل إليه: $ mkdir projects $ cd projects نُنشئ الآن مجلدًا للمشروع، وليكن باسم "generics" ثم ننتقل إليه: $ mkdir generics $ cd generics من داخل هذا المجلد، نفتح الملف "main.go" باستخدام محرر نانو nano أو أي محرر آخر تريده: $ nano main.go نضع مقتطف الشيفرة التالي داخل هذا الملف، إذ نُصرّح عن الحزمة package لإخبار المُصرّف أن يحوّل البرنامج إلى ملف ثنائي لتتمكّن من تشغيله مباشرةً. نستورد أيضًا الحزم اللازمة عن طريق تعليمة import: package main import ( "fmt" "math/rand" "os" "time" ) نُعرّف النوع PlayingCard والدوال والتوابع المرتبطة به: ... type PlayingCard struct { Suit string Rank string } func NewPlayingCard(suit string, card string) *PlayingCard { return &PlayingCard{Suit: suit, Rank: card} } func (pc *PlayingCard) String() string { return fmt.Sprintf("%s of %s", pc.Rank, pc.Suit) } عرّفنا بنية بيانات تُسمى PlayingCard مع الخصائص نوع البطاقة Suit وترتيبها Rank لتمثيل مجموعة ورق اللعب Deck المكونة من 52 بطاقة. يكون Suit أحد القيم التالية: Diamonds أو Hearts أو Clubs أو Spades. أما Rank يكون أما A أو 2 أو 3، وهكذا حتى K. عرّفنا أيضًا دالة NewPlayingCard لتكون باني constructor لبنية البيانات PlayingCard، وتابع String ليُرجع ترتيب ونوع البطاقة باستخدام fmt.Sprintf. ننشئ الآن النوع Deck بالدوال AddCard و RandomCard، إضافةً إلى الدالة NewPlayingCardDeck لإنشاء Deck* مملوء بجميع بطاقات اللعبة التي عددها 52. ... type Deck struct { cards []interface{} } func NewPlayingCardDeck() *Deck { suits := []string{"Diamonds", "Hearts", "Clubs", "Spades"} ranks := []string{"A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"} deck := &Deck{} for _, suit := range suits { for _, rank := range ranks { deck.AddCard(NewPlayingCard(suit, rank)) } } return deck } func (d *Deck) AddCard(card interface{}) { d.cards = append(d.cards, card) } func (d *Deck) RandomCard() interface{} { r := rand.New(rand.NewSource(time.Now().UnixNano())) cardIdx := r.Intn(len(d.cards)) return d.cards[cardIdx] } في البنية Deck المُعرّفة أعلاه، أنشأنا حقلًا يُسمّى cards لاحتواء مجموعة ورق اللعب. نظرًا لأننا نرغب في أن تكون مجموعة البطاقات قادرة على استيعاب أنواع متعددة مختلفة من البطاقات، فلا يمكن تعريفها فقط على أنها PlayingCard*. لذا عرّفناها على أنها {}interface[] حتى نتمكن من استيعاب أي نوع من البطاقات التي قد نُنشئها مستقبلًا. بالإضافة إلى الحقل {}interface[] في Deck، أنشأنا التابع AddCard الذي يقبل قيمة من نفس نوع interface{} لإضافة بطاقة إلى حقل البطاقات في Deck. أنشأنا أيضًا التابع RandomCard الذي يُرجع بطاقةً عشوائية من cards في Deck. يستخدم هذا التابع حزمة math/rand لإنشاء رقم عشوائي بين 0 وعدد البطاقات في المجموعة. يُنشئ سطر rand.New مولد رقم عشوائي جديد باستخدام الوقت الحالي مثل مصدر للعشوائية، وإلا فإن الرقم العشوائي يمكن أن يكون نفسه في كل مرة. يستخدم السطر ((r.Intn(len(d.cards مولد الأرقام العشوائية لإنشاء قيمة صحيحة بين 0 والعدد المُقدم. نظرًا لأن التابع Intn لا يشمل قيمة المعامل ضمن نطاق الأرقام الممكنة، فلا حاجة لطرح 1 من الطول لمعالجة فكرة البدء من 0 (إذا كان لدينا مجموعة ورق لعب تحتوي على 10 عناصر، فالأرقام الممكنة ستكون من 0 إلى 9). لدينا أيضًا RandomCard تُرجع قيمة البطاقة في الفهرس المحدد بالرقم العشوائي. تحذير: كن حذرًا في اختيار مولد الأرقام العشوائية الذي تستخدمه في برامجك. ليست حزمة math/rand آمنة من الناحية التشفيرية ولا ينبغي استخدامها في البرامج التي تتعلق بالأمان. توفر حزمة crypto/rand مولد أرقام عشوائية يمكن استخدامه لهذه الأغراض. بالنسبة للدالة NewPlayingCardDeck فهي تُرجع القيمة Deck* مملوءة بجميع البطاقات الموجودة في دستة بطاقات اللعب. نستخدم شريحتين، واحدة تحتوي على جميع الأشكال المتاحة وأخرى تحتوي على جميع الرتب المتاحة، ثم نكرر فوق كل قيمة لإنشاء PlayingCard* جديدة لكل تركيبة قبل إضافتها إلى المجموعة باستخدام AddCard. تُرجع القيمة بمجرد إنشاء بطاقات مجموعة ورق اللعب. بعد إعداد Deck و PlayingCard، يمكننا إنشاء الدالة main لاستخدامها لسحب البطاقات: ... func main() { deck := NewPlayingCardDeck() fmt.Printf("--- drawing playing card ---\n") card := deck.RandomCard() fmt.Printf("drew card: %s\n", card) playingCard, ok := card.(*PlayingCard) if !ok { fmt.Printf("card received wasn't a playing card!") os.Exit(1) } fmt.Printf("card suit: %s\n", playingCard.Suit) fmt.Printf("card rank: %s\n", playingCard.Rank) } أنشأنا بدايةً مجموعةً جديدة من البطاقات باستخدام الدالة NewPlayingCardDeck ووضعناها في المتغير deck، ثم استخدمنا fmt.Printf لطباعة جملة تدل على أننا نسحب بطاقة. نستخدم التابع RandomCard من deck للحصول على بطاقة عشوائية من المجموعة. نستخدم بعد ذلك fmt.Printf مرةً أخرى لطباعة البطاقة التي سحبناها من المجموعة. نظرًا لأن نوع المتغير card هو {}interface، سنحتاج إلى استخدام توكيد النوع type assertion للحصول على مرجع إلى البطاقة بنوعها الأصلي PlayingCard*. إذا كان النوع في المتغير card ليس نوع PlayingCard*، الذي يجب أن يكون عليه وفقًا لطريقة كتابة البرنامج الحالية، ستكون قيمة ok تساوي false وسيطبع البرنامج رسالة خطأ باستخدام fmt.Printf ويخرج مع شيفرة خطأ 1 باستخدام os.Exit. إذا كان النوع PlayingCard* سيطبع البرنامج قيمتي Suit و Rank من playingCard باستخدام fmt.Printf. لنُشغّل ملف البرنامج "main.go" بعد حفظه من خلال الأمر go run: $ go run main.go يُفترض أن نرى في الخرج بطاقة مُختارة عشوائيًا من المجموعة، إضافةً إلى نوع البطاقة وترتيبها: --- drawing playing card --- drew card: Q of Diamonds card suit: Diamonds card rank: Q قد تكون النتيجة المطبوعة مختلفةً عن النتيجة المعروضة أعلاه، لأن البطاقة مسحوبة عشوائيًا من المجموعة، لكن يجب أن تكون مشابهة. يُطبع السطر الأول قبل سحب البطاقة العشوائية من المجموعة، ثم يُطبع السطر الثاني بمجرد سحب البطاقة. يمكن رؤية خرج البطاقة باستخدام القيمة المُرجعة من التابع String من PlayingCard. يمكننا أخيرًا رؤية سطري النوع والرتبة المطبوعة بعد تحويل قيمة {}interface المستلمة إلى قيمة PlayingCard* مع الوصول إلى حقول Suit و Rank. أنشأنا في هذا القسم مجموعة Deck تستخدم قيم {}interface لتخزين والتفاعل مع أي قيمة، وأيضًا نوع PlayingCard ليكون بمثابة البطاقات داخل هذه الدستة Deck، ثم استخدمنا Deck و PlayingCard لاختيار بطاقة عشوائية من المجموعة وطباعة معلومات حول تلك البطاقة. للوصول إلى معلومات محددة تتعلق بقيمة PlayingCard* التي سُحبت، كُنّا بحاجة لعمل إضافي يتجلى بتحويل النوع {}interface إلى النوع PlayingCard* مع الوصول إلى حقول Suit و Rank. ستعمل الأمور في المجموعة Deck جيدًا بهذه الطريقة، ولكن قد ينتج أخطاء إذا أُضيفت قيمة أخرى غير PlayingCard* إلى المجموعة Deck. سنُعدّل في القسم التالي المجموعة Deck، بحيث يمكننا الاستفادة من خصائص الأنواع القوية والتحقق الثابت للأنواع في لغة جو، مع الاحتفاظ بالمرونة في قبول قيم {}interface، وذلك من خلال استخدام الأنواع المعممة. التجميعات في لغة جو مع استخدام الأنواع المعممة أنشأنا في القسم السابق تجميعةً باستخدام شريحة من أنواع {}interface، ولكن كان علينا إتمام عمل إضافي لاستخدام تلك القيم يتجلى بتحويل القيم من نوع {}interface إلى النوع الفعلي لتلك القيم. يمكننا باستخدام الأنواع المعممة إنشاء معامل واحد أو أكثر للأنواع، والتي تعمل تقريبًا مثل معاملات الدالة، ولكن يمكن أن تحتوي على أنواع بدلًا من بيانات. توفر الأنواع المعممة بهذا الأسلوب طريقةً لاستبدال نوع مختلف من معاملات الأنواع في كل مرة يجري فيها استخدام النوع المعمم. هنا يأتي اسم الأنواع المعممة؛ فبما أن النوع المعمم يمكن استخدامه مع أنواع متعددة، وليس فقط نوع محدد مثل io.Reader أو {}interface، يكون عامًا بما يكفي ليناسب عدة حالات استخدام. نُعدّل في هذا القسم مجموعة ورق اللعب Deck لتكون من نوع معمم يمكنه استخدام أي نوع محدد للبطاقة عند إنشاء نسخة من Deck بدلًا من استخدام {}interface. نفتح ملف "main.go" ونحذف استيراد حزمة os: package main import ( "fmt" "math/rand" // "os" حذف استيراد "time" ) لن نحتاج بعد الآن إلى استخدام دالة os.Exit، لذا فمن الآمن حذف هذا الاستيراد. نُعدّل البنية Deck الآن لتكون نوعًا معممًا: ... type Deck[C any] struct { cards []C } يقدم هذا التحديث الصيغة الجديدة لتعريف البنية Deck، بحيث نجعلها تحقق مفهوم النوع المُعمم، وذلك من خلال استخدام مفهوم "الموضع المؤقت Placeholder" أو "معاملات النوع Type parameters". يمكننا التفكير في هذه المعاملات بطريقة مشابهة للمعاملات التي نُضمّنُها في دالة؛ فعند استدعاء دالة، تُقدم قيمًا لكل معامل في الدالة. وهنا أيضًا، عند إنشاء قيمة من النوع المعمم، نُقدّم أنواعًا لمعاملات الأنواع. نلاحظ بعد اسم البنية Deck أننا أضفنا عبارة داخل قوسين مربعين ([])، إذ تسمح هذه الأقواس المعقوفة لنا بتحديد معامل أو أكثر من هذه المعاملات للبنية. نحتاج في حالتنا للنوع Deck إلى معامل نوع واحد فقط، يُطلق عليه اسم C، لتمثيل نوع البطاقات في المجموعة deck. من خلال كتابتنا C any نكون قد صرّحنا عن معامل نوع باسم C يمكننا استخدامه في البنية struct ليُمثّل أي نوع (any). ينوب any عن الأنواع المختلفة، أي يُعرف نوعه في الزمن الحقيقي، أي وقت تمرير قيمته، فإذا كانت القيمة المُمررة سلسلة يكون سلسلة، وإذا كانت عددًا صحيحًا يكون صحيحًا. يُعد النوع any في حقيقة الأمر اسمًا بديلًا للنوع {}interface، وهذا ما يجعل الأنواع المعممة أكثر سهولة للقراءة، فنحن بغنى عن كتابة {}C interface ونكتب C any فقط. يحتاج deck الخاص بنا إلى نوع معمم واحد فقط لتمثيل البطاقات، ولكن إذا كنا نحتاج إلى أنواع معممة إضافية، فيمكن إضافتها من خلال فصلها بفواصل، مثل F any, C any. يمكن أن يكون اسم المعامل المُستخدم لمعاملات النوع أي شيء نرغب فيه إذا لم يكن محجوزًا، ولكنها عادةً قصيرة وتبدأ بحرف كبير. عدّلنا أيضًا نوع الشريحة cards في البنية Deck ليكون من النوع C. عند استخدام مفهوم الأنواع المعممة، يمكننا استخدام معاملات النوع لتنوب عن أي نوع، أي يمكننا وضعها في نفس الأماكن التي نضع فيها أنواع البيانات عادةً. في حالتنا، نريد أن يمثل المعامل C كل بطاقة في الشريحة، لذا نضع اسم الشريحة ثم [] ثم المعامل C الذي سينوب عن النوع. نُعدّل الآن التابع AddCard لاستخدام النوع المعمّم الذي عرّفناه، لكن سنتخطى تعديل دالة NewPlayingCardDeck حاليًا: ... func (d *Deck[C]) AddCard(card C) { d.cards = append(d.cards, card) } تضمّن التحديث الخاص بالتابع AddCard في Deck، إضافة المعامل المُعمّم [C] إلى المستقبل الخاص بالتابع. يُخبر هذا لغة جو باسم المعامل المُعمّم الذي سنستخدمه في أماكن أخرى في تصريح التابع، ويتيح لنا ذلك معرفة النوع المُمرّر مثل قيمة إلى معامل النوع C عند استخدام التابع. يتبع هذا النوع من الكتابة بأقواس معقوفة نفس طريقة التصريح عن بنية struct، إذ يُعرّف معامل النوع داخل الأقواس المعقوفة بعد اسم الدالة والمستقبل. لا نحتاج في حالتنا إلى تحديد أي قيود لأنها مُحددة فعلًا في تصريح Deck. عدّلنا أيضًا معامل الدالة card لاستخدام معامل النوع C بدلًا من النوع الأصلي {}interface، وهذا يتيح للدالة استخدام النوع C الذي سيُعرف ما هو لاحقًا. بعد تعديل التابع AddCard، نُعدّل التابع RandomCard لاستخدام النوع المعمم C أيضًا: ... func (d *Deck[C]) RandomCard() C { r := rand.New(rand.NewSource(time.Now().UnixNano())) cardIdx := r.Intn(len(d.cards)) return d.cards[cardIdx] } بدلًا من استخدام نوع معمّم C مثل معامل دالة، عدّلنا التابع ()RandomCard ليعيد قيمةً من النوع المُعمّم C بدلًا من معامل الدالة {}interface. هذا يعني أنه عند استدعاء هذا التابع، سنحصل على قيمة من النوع C المحدد، بدلًا من الحاجة إلى تحويل القيمة إلى نوع محدد بواسطة "تأكيدات النوع type assertion". عدّلنا أيضًا المستقبل الخاص بالدالة ليتضمن [C]، وهو ما يخبر لغة جو بأن الدالة هي جزء من Deck التي تستخدم النوع المعمم C مثل معامل. لا نحتاج إلى أي تحديثات أخرى في الدالة. حقل cards في Deck مُعدّل فعلًا في تصريح البنية struct ليكون من النوع C[]، مما يعني أنه عندما يعيد التابع قيمة من cards، فإنه يعيد قيمةً من النوع C المحدد، وبالتالي لا حاجة لتحويل النوع بعد ذلك. بعد تعديلنا للنوع Deck لاستخدام الأنواع المعممة، نعود للدالة NewPlayingCardDeck لجعلها تستخدم النوع المعمم Deck مع الأنواع PlayingCard*: ... func NewPlayingCardDeck() *Deck[*PlayingCard] { suits := []string{"Diamonds", "Hearts", "Clubs", "Spades"} ranks := []string{"A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"} deck := &Deck[*PlayingCard]{} for _, suit := range suits { for _, rank := range ranks { deck.AddCard(NewPlayingCard(suit, rank)) } } return deck } ... تبقى معظم الشيفرة في NewPlayingCardDeck كما هي، باستثناء بعض التغييرات البسيطة، ولكن الآن نستخدم إصدارًا مُعمّمًا من Deck، ويجب علينا تحديد النوع الذي نرغب في استخدامه مع C عند استخدام Deck. نفعل ذلك عن طريق الإشارة إلى نوع Deck بالطريقة المعتادة، سواء كان Deck نفسه أو مرجعًا مثل Deck*، ثم تقديم النوع الذي يجب استبدال C به باستخدام نفس الأقواس المعقوفة التي استخدمناها عند التصريح عن معاملات النوع في البداية. بمعنى آخر، عندما نُعرّف متغير من نوع Deck المعمّم، يجب علينا تحديد النوع الفعلي للمعامل C؛ فإذا كانت Deck من النوع Deck*، يجب أن نُوفّر النوع الفعلي الذي يجب استبدال C به في الأقواس المعقوفة مثل [PlayingCard*]. هكذا نحصل على نسخة محددة من Deck لنوع البطاقات التي ترغب في استخدامها، والتي في هذه الحالة هي PlayingCard*. يمكن تشبيه هذه العملية لإرسال قيمة لمعامل دالة عند استدعاء الدالة؛ فعند استخدامنا لنوع Deck المعمّم، فإننا نمرر النوع الذي نرغب في استخدامه مثل معامل لهذا النوع. بالنسبة لنوع القيمة المُعادة في NewPlayingCardDeck، نستمر في استخدام Deck* كما فعلت من قبل، لكن هذه المرة، يجب أن تتضمن الأقواس المعقوفة و PlayingCard* أيضًا؛ فمن خلال تقديم [PlayingCard*] لمعامل النوع، فأنت تقول أنك ترغب في استخدام النوع PlayingCard* في تصريح Deck والتوابع الخاصة به ليحل محل قيمة C، وهذا يعني أن نوع حقل cards في Deck يتغير فعليًا من C[] إلى PlayingCard*[]. بمعنى آخر، عندما ننشئ نسخةً جديدةً من Deck باستخدام NewPlayingCardDeck، فإننا نُنشئ Deck يمكنه تخزين أي نوع محدد من PlayingCard*. هذا يتيح لنا استخدام مجموعة متنوعة من أنواع البطاقات في Deck بدلًا من الاعتماد على {}interface. بالتالي يمكننا الآن التعامل مباشرةً مع البطاقات بنوعها الفعلي بدلًا من الحاجة إلى استبدال الأنواع والتحويلات التي كنا نستخدمها مع {}interface في الإصدار السابق من Deck. بالمثل، عند إنشاء نسخة جديدة من Deck، يجب علينا أيضًا توفير النوع الذي يحل محل C. نستخدم عادةً {}Deck& لإنشاء مرجع جديد إلى Deck، ولكن بدلًا من ذلك يجب علينا تضمين النوع داخل الأقواس المعقوفة للحصول على {}[PlayingCard*]&. الآن بعد أن عدّلنا الأنواع الخاصة بنا لاستخدام الأنواع المُعمّمة، نُعدّل الدالة main للاستفادة منها: ... func main() { deck := NewPlayingCardDeck() fmt.Printf("--- drawing playing card ---\n") playingCard := deck.RandomCard() fmt.Printf("drew card: %s\n", playingCard) // Code removed fmt.Printf("card suit: %s\n", playingCard.Suit) fmt.Printf("card rank: %s\n", playingCard.Rank) } أزلنا هذه المرة بعض التعليمات، لأنه لم تعد هناك حاجة لتأكيد قيمة {}interface على أنها PlayingCard*. عندما عدّلنا التابع RandomCard في Deck لإعادة النوع C و NewPlayingCardDeck لإعادة [Deck[*PlayingCard*، نكون قد عدّلنا التابع RandomCard بجعله يعيد PlayingCard* بدلًا من {}interface. هذا يعني أن نوع playingCard هو PlayingCard* أيضًا بدلًا من interface{} ويمكننا الوصول إلى حقول Suit و Rank مباشرةً. لنُشغّل ملف البرنامج "main.go" بعد حفظه من خلال الأمر go run: $ go run main.go سنشاهد خرجًا مشابهًا لما سبق، ولكن البطاقة المسحوبة قد تكون مختلفة: --- drawing playing card --- drew card: 8 of Hearts card suit: Hearts card rank: 8 على الرغم من أن الخرج هو نفسه مثل الإصدار السابق من البرنامج عندما استخدمنا {}interface، إلا أن الشيفرة أنظف وتتجنب الأخطاء المحتملة. لم يعد هناك حاجةٌ لتأكيد النوع PlayingCard*، مما يُجنّبنا التعامل مع الأخطاء الإضافية، كما أنّه من خلال تحديد أن نسخة Deck يمكن أن تحتوي فقط على PlayingCard*، لن يكون هناك أي احتمال لوجود قيمة أخرى غير PlayingCard* تُضاف إلى مجموعة البطاقات. عدّلنا في هذا القسم البنية Deck لتكون نوعًا مُعمّمًا، مما يوفر مزيدًا من السيطرة على أنواع البطاقات التي يمكن أن تحتويها كل نسخة من مجموعة أوراق اللعب. عدّلنا أيضًا التابعين AddCard و RandomCard إما لقبول وسيط مُعمم أو لإرجاع قيمة مُعممة. عدّلنا أيضًا NewPlayingCardDeck لإرجاع Deck* يحتوي على بطاقات PlayingCard*. أخيرًا أزلنا التعامل مع الأخطاء في الدالة main لأنه لم يعد هناك حاجة لذلك. الآن بعد تعديل Deck ليكون مُعممًا، يمكننا استخدامه لاحتواء أي نوع من البطاقات التي نرغب. سنستفيد في القسم التالي من هذه المرونة من خلال إضافة نوع جديد من البطاقات إلى البرنامج. استخدام أنواع مختلفة مع الأنواع المعممة يمكننا -بإنشاء نوع مُعمّم مثل النوع Deck- استخدامه مع أي نوع آخر؛ فعندما نُنشئ نسخةً من Deck، ونرغب باستخدامها مع أنواع PlayingCard*، فإن الشيء الوحيد الذي نحتاجه هو تحديد هذا النوع عند إنشاء القيمة، وإذا أردنا استخدام نوع آخر، نُبدّل نوع PlayingCard* بالنوع الجديد الذي نرغب باستخدامه. نُنشئ في هذا القسم بنية بيانات جديدة تُسمى TradingCard لتمثيل نوع مختلف من البطاقات، ثم نُعدّل البرنامج لإنشاء Deck يحتوي على عدة TradingCard*. لإنشاء النوع TradingCard، نفتح ملف "main.go" مرةً أخرى ونضيف التعريف التالي: ... import ( ... ) type TradingCard struct { CollectableName string } func NewTradingCard(collectableName string) *TradingCard { return &TradingCard{CollectableName: collectableName} } func (tc *TradingCard) String() string { return tc.CollectableName } النوع TradingCard مشابه لنوع PlayingCard، ولكن بدلًا من وجود حقول Suit و Rank، يحتوي على حقل CollectableName لتتبع اسم بطاقة التداول، كما يتضمن دالة الباني NewTradingCard والتابع String، أي بطريقة مشابهة للنوع PlayingCard. نُنشئ الآن الدالة البانية NewTradingCardDeck لإنشاء Deck مُعبأة بقيم TradingCards*: ... func NewPlayingCardDeck() *Deck[*PlayingCard] { ... } func NewTradingCardDeck() *Deck[*TradingCard] { collectables := []string{"Sammy", "Droplets", "Spaces", "App Platform"} deck := &Deck[*TradingCard]{} for _, collectable := range collectables { deck.AddCard(NewTradingCard(collectable)) } return deck } عند إنشاء أو إرجاع Deck* في هذه الحالة، فإننا نُبدّل قيم PlayingCard* بقيم TradingCard*، وهذا هو الأمر الوحيد الذي نحتاج إلى تغييره في Deck. هناك مجموعة من البطاقات الخاصة، والتي يمكننا المرور عليها لإضافة كل TradingCard* إلى Deck. يعمل التابع AddCard في Deck بنفس الطريقة كما في السابق، لكن هذه المرة يقبل قيمة TradingCard* من NewTradingCard. إذا حاولنا تمرير قيمة من NewPlayingCard، سيُعطي المُصرّف خطأ لأنه يتوقع TradingCard* وليس PlayingCard*. نُعدّل الدالة main لإنشاء Deck جديدة من أجل عدة TradingCard*، وسحب بطاقة عشوائية باستخدام RandomCard، وطباعة معلومات البطاقة: ... func main() { playingDeck := NewPlayingCardDeck() tradingDeck := NewTradingCardDeck() fmt.Printf("--- drawing playing card ---\n") playingCard := playingDeck.RandomCard() ... fmt.Printf("card rank: %s\n", playingCard.Rank) fmt.Printf("--- drawing trading card ---\n") tradingCard := tradingDeck.RandomCard() fmt.Printf("drew card: %s\n", tradingCard) fmt.Printf("card collectable name: %s\n", tradingCard.CollectableName) } أنشأنا مجموعة جديدة من بطاقات التداول باستخدام NewTradingCardDeck وخزّناها في tradingDeck. نظرًا لأننا لا نزال نستخدم نفس النوع Deck كما كان من قبل، يمكننا استخدام RandomCard للحصول على بطاقة تداول عشوائية من Deck وطباعة البطاقة. يمكننا أيضًا الإشارة إلى وطباعة حقل CollectableName مباشرةً من tradingCard لأن Deck المُعمّم الذي نستخدمه قد عرّف C على أنه TradingCard*. يُظهر هذا التحديث أيضًا قيمة استخدام الأنواع المُعمّمة. لدعم نوع بطاقة جديد تمامًا، لم نحتاج إلى تغيير Deck إطلاقًا، فمن خلال معاملات النوع في Deck تمكّننا من تحديد نوع البطاقة الذي نريده، وبدءًا من هذه النقطة فصاعدًا، يُستخدم النوع TradingCard* بدلًا من النوع PlayingCard* في أية تفاعلات مع قيم Deck. لنُشغّل ملف البرنامج "main.go" بعد حفظه من خلال الأمر go run: $ go run main.go ليكون الخرج على النحو التالي: --- drawing playing card --- drew card: Q of Diamonds card suit: Diamonds card rank: Q --- drawing trading card --- drew card: App Platform card collectable name: App Platform يجب أن نرى نتيجةً مشابهة للنتيجة الموضحة أعلاه (مع بطاقات مختلفة بسبب العشوائية) بعد انتهاء تشغيل البرنامج. تظهر بطاقتين مسحوبتين: البطاقة الأصلية للّعب وبطاقة التداول الجديدة المُضافة. أضفنا في هذا القسم النوع TradingCard الجديد لتمثيل نوع بطاقة مختلف عن نوع البطاقة الأصلية PlayingCard. بمجرد إضافة نوع البطاقة TradingCard،أنشأنا الدالة NewTradingCardDeck لإنشاء وملء مجموعة ورق اللعب ببطاقات التداول. أخيرًا عدّلنا الدالة main لاستخدام مجموعة ورق التداول الجديدة وطباعة معلومات مُتعلقة بالبطاقة العشوائية المستخرجة. بصرف النظر عن إنشاء دالة جديدة NewTradingCardDeck لملء الدستة ببطاقات مختلفة، لم نحتاج إلى إجراء أي تحديثات أخرى على مجموعة ورق اللعب لدعم نوع بطاقة جديد تمامًا. هذه هي قوة الأنواع المُعمّمة؛ إذ يمكننا كتابة الشيفرة مرةً واحدةً فقط واستخدامها متى أردنا لأنواع مختلفة من البيانات المماثلة أيضًا. هناك مشكلة واحدة في Deck الحالي، وهي أنه يمكن استخدامه لأي نوع، بسبب التصريح C any الذي لدينا. قد يكون هذا هو ما نريده حتى نتمكن من إنشاء مجموعة ورق لعب للقيم int بكتابة {}[Deck[int&، ولكن إذا كنا نرغب في أن يحتوي Deck فقط على بطاقات، سنحتاج إلى طريقة لتقييد أنواع البيانات المسموح بها مع C. القيود على الأنواع المعممة غالبًا لا نرغب أو نحتاج إلى أي قيود على الأنواع المستخدمة في الأنواع المُعمّمة لأننا قد لا نهتم بنوع البيانات المُستخدمة، لكن قد نحتاج في أحيان أخرى إلى القدرة على تقييد الأنواع المستخدمة بواسطة الأنواع المُعمّمة. على سبيل المثال، إذا كُنّا نُنشئ نوعًا مُعمّمًا Sorter، قد نرغب في تقييد أنواعه المُعمّمة لتلك التي تحتوي على تابع Compare (أي تقييد الأنواع المستخدمة معه، بحيث تكون فقط الأنواع القابلة للمقارنة)، حتى يتمكن Sorter من مقارنة العناصر التي يحتوي عليها. بدون هذا القيد، فقد لا تحتوي القيم على التابع Compare، ولن يعرف Sorter كيفية مقارنتها. نُنشئ في هذا القسم واجهةً جديدة تسمى Card، ثم نُعدّل Deck للسماح فقط بإضافة أنواع Card. لتطبيق التحديثات، افتح ملف "main.go" وضِف الواجهة Card: ... import ( ... ) type Card interface { fmt.Stringer Name() string } واجهة Card مُعرّفة بنفس طريقة تعريف أي واجهة أخرى في لغة جو؛ وليس هناك متطلبات خاصة لاستخدامها مع الأنواع المُعمّمة. نحن نقول في واجهة Card هذه، أنه لكي نعد شيئًا ما بطاقة Card، يجب أن يُحقق النوع fmt.Stringer (يجب أن يحتوي على تابع String الذي تحتويه البطاقات فعلًا)، ويجب أيضًا أن يحتوي على تابع Name الذي يعيد قيمة من نوع string. نُعدّل الآن أنواع TradingCard و PlayingCard لإضافة التابع Name الجديد، إضافةً إلى التابع String الموجود فعلًا، لكي نستوفي شروط تحقيق الواجهة Card: ... ... type TradingCard struct { ... } ... func (tc *TradingCard) Name() string { return tc.String() } ... type PlayingCard struct { ... } ... func (pc *PlayingCard) Name() string { return pc.String() } يحتوي كُلًا من TradingCard و PlayingCard فعليًا على تابع String الذي يُحقق الواجهة fmt.Stringer. لذا، لتحقيق الواجهة Card، نحتاج فقط إلى إضافة التابع الجديد Name. بما أن fmt.Stringer يمكنها إرجاع أسماء البطاقات، يمكننا ببساطة إرجاع نتيجة التابع String في التابع Name. نُعدّل الآن Deck، بحيث يُسمح فقط باستخدام أنواع Card مع C: ... type Deck[C Card] struct { cards []C } كان لدينا C any قبل هذا التحديث مثل قيد نوع type constraint والمعروف أيضًا بتقييد النوع type restriction، وهو ليس قيدًا صارمًا. بما أن any هو اسم {}interface البديل (له نفس المعنى)، فقد سمح باستخدام أي نوع في جو بمثابة قيمة للمعامل C. الآن بعدما عوّضنا any بالواجهة Card الجديدة، سيتحقق المُصرّف في جو أن أي نوع مستخدم مع C يُحقق واجهة Card عند تصريف البرنامج. يمكننا الآن بعد وضع هذا القيد، استخدام أي توابع تقدمها الواجهة Card داخل توابع النوع Deck. إذا أردنا من RandomCard طباعة اسم البطاقة المسحوبة، ستتمكن من الوصول إلى التابع Name لأنها جزء من الواجهة Card. سنرى ذلك في الجزء التالي. هذه التحديثات القليلة هي التحديثات الوحيدة التي نحتاج إليها لتقييد النوع Deck، بحيث يستخدم فقط لقيم Card. لنُشغّل ملف البرنامج "main.go" بعد حفظه من خلال الأمر go run: $ go run main.go سيكون الخرج: --- drawing playing card --- drew card: 5 of Clubs card suit: Clubs card rank: 5 --- drawing trading card --- drew card: Droplets card collectable name: Droplets نلاحظ أن الناتج لم يتغير من حيث الشكل العام، فنحن لم نُعدّل بالوظيفة الرئيسية للبرنامج؛ وضعنا فقط قيودًا على الأنواع، إذ أضفنا في هذا الجزء الواجهة Card الجديدة وعدّلنا كلًا من TradingCard و PlayingCard لتحقيق هذه الواجهة. عدّلنا أيضًا Deck بهدف تقييد الأنواع التي يتعامل معها، بحيث يكون النوع المستخدم يُحقق الواجهة Card. كل ما فعلناه إلى الآن هو إنشاء نوع بيانات جديد من خلال مفهوم البنى struct وجعله مُعمّمًا، لكن لغة جو تسمح أيضًا بإنشاء دوال مُعممة، إضافةً إلى إنشاء أنواع مُعممة. إنشاء دوال معممة يتبع إنشاء دوال مُعممة في لغة جو طريقة تصريح مشابه جدًا للأنواع المُعممة في لغة جو. يتطلب إنشاء دوال مُعممة إضافة مجموعة ثانية من المعاملات إلى تلك الدوال كما هو الحال في الأنواع المُعممة التي تحتوي على معاملات نوع. نُنشئ في هذا الجزء دالة مُعممة جديدة باسم printCard، ونستخدم تلك الدالة لطباعة اسم البطاقة المقدمة. نفتح ملف "main.go" ونجري التحديثات التالية: ... func printCard[C any](card C) { fmt.Println("card name:", card.Name()) } func main() { ... fmt.Printf("card collectable name: %s\n", tradingCard.CollectableName) fmt.Printf("--- printing cards ---\n") printCard[*PlayingCard](playingCard) printCard(tradingCard) } نلاحظ أن التصريح عن الدالة printCard مألوف من ناحية تعريف معاملات النوع (أقواس معقوفة تحتوي على معاملات النوع المعممة. تحدد هذه المعاملات النوع الذي سيجري استخدامه في الدالة)، تليها المعاملات العادية للدالة داخل قوسين، ثم في دالة main، تستخدم الدالة printCard لطباعة كل من PlayingCard* و TradingCard*. قد نلاحظ أن أحد استدعاءات printCard يتضمن معامل النوع [PlayingCard*]، بينما الاستدعاء الثاني لا يحتوي على نفس معامل النوع [TradingCard*]. يمكن لمُصرّف لغة جو أن يستدل على معامل النوع المقصود من القيمة التي نمررها إلى المعاملات، لذلك في حالات مثل هذه، تكون معاملات النوع اختيارية. يمكننا أيضًا إزالة معامل النوع [PlayingCard*]. لنُشغّل ملف البرنامج "main.go" بعد حفظه من خلال الأمر go run: $ go run main.go سيظهر هذه المرة خطأ في التصريف: # وسطاء سطر الأوامر ./main.go:87:33: card.Name undefined (type C has no field or method Name) عند استخدامنا للشيفرة المذكورة أعلاه وتصريفها تظهر رسالة الخطأ "card.Name undefined"، وتعني أن Name غير معرفة في النوع C. هذا يحدث لأننا استخدمنا any قيدًا للنوع في معامل النوع C في دالة printCard. تعني any أن أي نوع يمكن استخدامه مع C، ولكنه لا يعرف عنه أي شيء محدد مثل وجود التابع Name في النوع. لحل هذه المشكلة والسماح بالوصول إلى Name، يمكننا استخدام الواجهة Card قيدًا للنوع في C. تحتوي الواجهة Card على التابع Name ونُطبقها في النوعين TradingCard و PlayingCard. وبذلك تضمن لنا لغة جو أن يكون لدى C التابع Name وبالتالي يتيح لنا استخدامها في الدالة printCard دون وجود أخطاء في وقت التصريف. نُعدّل ملف "main.go" لآخر مرة لاستبدال القيد any بالقيد Card: ... func printCard[C Card](card C) { fmt.Println("card name:", card.Name()) } لنُشغّل ملف البرنامج "main.go" بعد حفظه من خلال الأمر go run: $ go run main.go سيكون الخرج: --- drawing playing card --- drew card: 6 of Hearts card suit: Hearts card rank: 6 --- drawing trading card --- drew card: App Platform card collectable name: App Platform --- printing cards --- card name: 6 of Hearts card name: App Platform نلاحظ سحب البطاقتين كما اعتدنا سابقًا، ولكن تطبع الآن الدالة printCard البطاقات وتستخدم التابع Name للحصول على الاسم لتجري طباعته. أنشأنا في هذا القسم دالةً جديدة مُعممة printCard قادرة على أخذ أي قيمة Card وطباعة الاسم. رأينا أيضًا أن استخدام قيد النوع any بدلًا من Card أو قيمةً أخرى محددة يؤثر على التوابع المتاحة. الخاتمة أنشأنا في هذا المقال برنامجًا جديدًا يحتوي على بنية اسمها Deck تُمثّل نوعًا يمكن أن يُعيد بطاقة عشوائية من مجموعة ورق اللعب بصيغة {}interface، وأنشأنا النوع PlayingCard لتمثيل بطاقة اللعب في المجموعة، ثم عدّلنا النوع Deck لدعم مفهوم النوع المُعمّم وتمكنّا من إزالة بعض عمليات التحقق من الأخطاء لأن النوع المُعمّم يضمن عدم ظهور ذلك النوع من الأخطاء. أنشأنا بعد ذلك نوعًا جديدًا يسمى TradingCard لتمثيل نوع مختلف من البطاقات التي يمكن أن تدعمها Deck، وكذلك أنشأنا مجموعة ورق لعب لكل نوع من أنواع البطاقات وأرجعنا بطاقةً عشوائيةً من كل مجموعة. أضفنا أيضًا قيدًا للنوع إلى البنية Deck لضمان أنه يمكن إضافة أنواع تُحقق الواجهة Card فقط إلى مجموعة ورق اللعب. أنشأنا أخيرًا دالة مُعمّمة تسمى printCard يمكنها طباعة اسم أي قيمة من نوع Card باستخدام التابع Name. يمكن أن يُقلل استخدام الأنواع المُعمّمة في الشيفرة الخاصة بنا إلى حد كبير عدد الأسطر البرمجية اللازمة لدعم أنواع متعددة لنفس الشيفرة. يجب أن نُحقق التوازن بين الأداء وسهولة قراءة الشيفرة عند استخدام الأنواع المُعمّمة؛ إذ يؤثر استخدام الأنواع المُعمّمة على الأداء، لكنه يُسهّل القراءة. من جهة أخرى، استخدام الواجهات بدلًا من الأنواع المُعمّمة أفضل من ناحية الأداء، لكنه أسوأ في القراءة. بالتالي، إذا كان بإمكاننا استخدام الواجهة بدلًا من الأنواع المُعمّمة فمن الأفضل استخدام الواجهة. ترجمة -وبتصرف- للمقال How To Make HTTP Requests in Go لصاحبه Kristin Davidson. اقرأ أيضًا المقال السابق كيفية إنشاء طلبات HTTP في لغة جو Go. مقدمة إلى مفهوم الأنواع المعممة Generic Types في لغة Rust. مفهوم البرمجة المعممة Generic Programming
  2. يُعد بروتوكول HTTP الخيار الأفضل غالبًا عندما يحتاج المطورون إلى إنشاء اتصال بين البرامج. تقدم لغة جو (المعروفة بمكتبتها القياسية الشاملة) دعمًا قويًا لبروتوكول HTTP من خلال حزمة net/http الموجودة في المكتبة القياسية. لا تتيح هذه الحزمة إنشاء خوادم HTTP فحسب، بل تتيح أيضًا إجراء طلبات HTTP مثل عميل. سننشئ في هذه المقالة برنامجًا يتفاعل مع خادم HTTP من خلال إجراء أنواع مختلفة من الطلبات. سنبدأ بطلب GET باستخدام عميل HTTP الافتراضي في لغة جو، ثم سنعمل على تحسين البرنامج ليشمل طلب POST مع متن الطلب. أخيرًا نُخصص طلب POST من خلال دمج ترويسة HTTP وتحقيق آلية المهلة، للتعامل مع الحالات التي تتجاوز فيها مدة الطلب حدًا معينًا. المتطلبات الأولية لمتابعة هذا المقال التعليمي، سنحتاج إلى: إصدار مُثبّت من جو 1.16 أو أعلى، ويمكنك الاستعانة بمقال تثبيت لغة جو Go وإعداد بيئة برمجة محلية على أبونتو Ubuntu لإعداده. تثبيت لغة جو وإعداد بيئة برمجة محلية على نظام ماك macOS. تثبيت لغة جو وإعداد بيئة برمجة محلية على ويندوز. معرفة بكيفية إنشاء خادم HTTP في لغة جو. فهم لتنظيمات جو goroutines والقنوات channels. يمكنك الاطلاع على مقالة كيفية تشغيل عدة دوال على التساير في لغة جو Go. الإلمام بكيفية إنشاء طلبات HTTP وإرسالها (موصى به). تقديم طلب GET نناقش في هذه الفقرة كيفية تقديم طلب GET باستخدام حزمة net/http مثل عميل، إذ توفّر لنا هذه الحزمةطرقًا وخيارات متنوعة للتفاعل مع موارد HTTP. أحد الخيارات الشائعة هو استخدام عميل "HTTP عام" مع دوال مثل http.Get، التي تسمح بإنشاء طلب GET سريعًا باستخدام عنوان URL ومتن فقط. يمكننا أيضًا إنشاء http.Request للحصول على مزيد من التحكم وتخصيص جوانب معينة من الطلب. سنبدأ في هذا القسم بإنشاء برنامج أولي يستخدم http.Get لتقديم طلب HTTP، ونعدّله لاحقًا لاستخدام http.Request مع عميل HTTP الافتراضي. استخدام دالة http.Get لتقديم طلب نستخدم الدالة http.Get في الإصدار الأولي من البرنامج لإرسال طلب إلى خادم HTTP داخل البرنامج، إذ تُعد هذه الدالة مناسبة لأنها تتطلب الحد الأدنى من التهيئة، أي أنها لا تحتاج إلى الكثير من التفاصيل والإعدادات، وهذا ما يجعلها مثالية لتقديم طلب واحد مباشر. بالتالي، يكون استخدام الدالة http.Get هو الأسلوب الأنسب عندما نحتاج إلى تنفيذ طلب سريع لمرة واحدة فقط. كما هو معتاد، سنحتاج لبدء إنشاء برامجنا إلى إنشاء مجلد للعمل ووضع الملفات فيه، ويمكن وضع المجلد في أي مكان على الحاسب، إذ يكون للعديد من المبرمجين عادةً مجلدٌ يضعون داخله كافة مشاريعهم. سنستخدم في هذا المقال مجلدًا باسم "projects"، لذا فلننشئ هذا المجلد وننتقل إليه: $ mkdir projects $ cd projects ننشئ مجلدًا للمشروع وننتقل إليه. لنسميه مثلًا "httpclient": $ mkdir httpclient $ cd httpclient نستخدم الآن محرر نانو nano أو أي محرر آخر تريده لفتح ملف "main.go": $ nano main.go نضع بداخله الشيفرة التالية: package main import ( "errors" "fmt" "net/http" "os" "time" ) const serverPort = 3333 // ضبط منفذ الخادم على 3333 أضفنا في الشيفرة السابقة الحزمة main لضمان إمكانية تصريف البرنامج وتنفيذه. نستورد أيضًا عدة حزم لاستخدامها في البرنامج. نُعّرف ثابت const اسمه serverPort بقيمة 3333، سيجري استخدامه مثل منفذ لخادم HTTP والعميل. ننشئ دالة main ضمن الملف "main.go" ونبدأ بإعداد خادم HTTP مثل تنظيم goroutine: func main() { // مثل تنظيم جو HTTP نبدأ تشغيل خادم go func() { // نُنشئ جهاز توجيه جديد mux := http.NewServeMux() // معالجة مسار الجذر "/" وطباعة معلومات الطلب mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Printf("Server: %s /\n", r.Method) }) // HTTP تهيئة خادم server := http.Server{ Addr: fmt.Sprintf(":%d", serverPort), Handler: mux, } // بدء تشغيل الخادم، ومعالجة الأخطاء المحتملة if err := server.ListenAndServe(); err != nil { if !errors.Is(err, http.ErrServerClosed) { fmt.Printf("Error running HTTP server: %s\n", err) } } }() // الانتظار لمدة قصيرة للسماح للخادم بالبدء time.Sleep(100 * time.Millisecond) } تعمل الدالة main بمثابة نقطة دخول للبرنامج، ونستخدم الكلمة المفتاحية go للإشارة إلى أن خادم HTTP سيجري تشغيله ضمن تنظيم جو goroutine. نعالج مسار الجذر /، ونطبع معلومات الطلب باستخدام fmt.Printf، ونُهيّئ خادم HTTP بالعنوان والمعالج المحددين. يبدأ الخادم بالاستماع إلى الطلبات باستخدام الدالة ListenAndServe، ويجري التعامل مع أية أخطاء محتملة؛ فإذا حدث خطأ ولم يكن هذا الخطأ هو http.ErrServerClosed (إغلاق طبيعي تحدثنا عنه في المقال السابق)، ستُطبع رسالة خطأ. نستخدم الدالة time.Sleep للسماح للخادم بوقت كافٍ لبدء التشغيل قبل تقديم الطلبات إليه. نجري الآن بعض التعديلات الإضافية على الدالة mian من خلال إعداد عنوان URL للطلب، وذلك بدمج اسم المضيف http://localhost مع قيمة serverPort باستخدام fmt.Sprintf. نستخدم بعد ذلك الدالة http.Get لتقديم طلب إلى عنوان URL هذا: ... requestURL := fmt.Sprintf("http://localhost:%d", serverPort) res, err := http.Get(requestURL) if err != nil { fmt.Printf("error making http request: %s\n", err) os.Exit(1) } fmt.Printf("client: got response!\n") fmt.Printf("client: status code: %d\n", res.StatusCode) } يرسل البرنامج طلب HTTP باستخدام عميل HTTP الافتراضي إلى عنوان URL المحدد عند استدعاء http.Get، ويعيد http.Response في حالة نجاح الطلب أو ظهور قيمة خطأ في حالة فشل الطلب. في حال حدوث خطأ، يطبع البرنامج رسالة الخطأ ويخرج من البرنامج باستخدام os.Exit مع شيفرة خطأ 1. إذا نجح الطلب، يطبع البرنامج رسالةً تشير إلى تلقي استجابة، جنبًا إلى جنب مع شيفرة حالة HTTP للاستجابة. لنُشغّل ملف البرنامج "main.go" بعد حفظه من خلال الأمر go run: $ go run main.go سيكون الخرج على النحو التالي: server: GET / client: got response! client: status code: 200 يشير السطر الأول من الخرج إلى أن الخادم تلقى طلب GET من العميل على المسار /. يشير السطران التاليان إلى أن العميل قد تلقى استجابة من الخادم بنجاح وأن شيفرة حالة الاستجابة كان 200. على الرغم من أن الدالة http.Get ملائمة لإجراء طلبات HTTP سريعة مثل تلك الموضحة في هذا القسم، لكن استخدامhttp.Request يوفر نطاقًا أوسع من الخيارات لتخصيص الطلب. استخدام دالة http.Request لتقديم طلب توفّر لنا http.Request مزيدًا من التحكم في الطلب أكثر من مجرد استخدام تابع HTTP (على سبيل المثال، GET و POST) وعنوان URL فقط. على الرغم من أننا لن نستخدم ميزات إضافية في الوقت الحالي، يسمح لنا استخدام http.Request بإضافة تخصيصات في أقسام لاحقة من هذا المقال. يتضمن التحديث الأولي تعديل معالج خادم HTTP لإرجاع استجابة تتضمّن بيانات جسون JSON وهمية باستخدام fmt.Fprintf. تُنشأ هذه البيانات ضمن خادم HTTP مكتمل باستخدام حزمة encoding/json. لمعرفة المزيد حول العمل مع بيانات جسون في لغة جو، يمكن الرجوع إلى المقال "كيفية استخدام جسون JSON في لغة جو". نحتاج أيضًا إلى استيراد io/ioutil لاستخدامه في تحديث لاحق. نفتح ملف "main.go" لتضمينhttp.Request كما هو موضح أدناه: package main import ( ... "io/ioutil" ... ) ... func main() { ... mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Printf("server: %s /\n", r.Method) fmt.Fprintf(w, `{"message": "hello!"}`) }) ... هدفنا هو استبدال استخدام http.Get، من خلال استخدام كل من الدالة http.NewRequest من الحزمة net/http -لإنشاء http.Request جديد، والذي يمثل طلب HTTP يمكن إرساله إلى الخادم - و http.DefaultClient ليكون عميل HTTP افتراضي يوفر لنا استخدام التابع Do لإرسال http.Request إلى الخادم واسترداد http.Response المقابل. يسمح هذا التغيير بمزيد من التحكم في الطلب وإجراء تعديلات عليه قبل الإرسال إلى الخادم: ... requestURL := fmt.Sprintf("http://localhost:%d", serverPort) req, err := http.NewRequest(http.MethodGet, requestURL, nil) if err != nil { fmt.Printf("client: could not create request: %s\n", err) os.Exit(1) } res, err := http.DefaultClient.Do(req) if err != nil { fmt.Printf("client: error making http request: %s\n", err) os.Exit(1) } fmt.Printf("client: got response!\n") fmt.Printf("client: status code: %d\n", res.StatusCode) resBody, err := ioutil.ReadAll(res.Body) if err != nil { fmt.Printf("client: could not read response body: %s\n", err) os.Exit(1) } fmt.Printf("client: response body: %s\n", resBody) } نبدأ باستخدام الدالة http.NewRequest لإنشاء قيمة http.Request. نحدد تابع HTTP للطلب على أنه GET باستخدام http.MethodGet. نُنشئ أيضًا عنوان URL للطلب من خلال دمج اسم المضيف http://localhost مع قيمة serverPort باستخدام fmt.Sprintf. نفحص أيضًا متن الطلب إذا كان فارغًا (أي قيمة nil) لنتعامل أيضًا مع أي خطأ محتمل قد يحدث أثناء إنشاء الطلب. بخلاف http.Get الذي يرسل الطلب على الفور، فإنhttp.NewRequest يُجهّز الطلب فقط ولا يرسله مباشرةً، ويتيح لنا ذلك تخصيص الطلب كما نريد قبل إرساله فعليًا. بمجرد إعداد http.Request، نستخدم(http.DefaultClient.Do (req لإرسال الطلب إلى الخادم باستخدام عميل HTTP الافتراضي http.DefaultClient. يبدأ التابع Do الطلب ويعيد الاستجابة المستلمة من الخادم مع أي خطأ محتمل. بعد الحصول على الرد نطبع معلومات عنه، إذ نعرض أن العميل قد تلقى استجابة بنجاح، إلى جانب شيفرة حالة HTTP للاستجابة. نقرأ بعد ذلك متن استجابة HTTP باستخدام الدالة ioutil.ReadAll. يُمثّل متن الاستجابة على أنه io.ReadCloser، والذي يجمع بين io.Reader و io.Closer. نستخدم ioutil.ReadAll لقراءة جميع البيانات من متن الاستجابة حتى النهاية أو حدوث خطأ. تُعيد الدالة البيانات بقيمة byte[]، والتي نطبعها مع أي خطأ مصادف. لنُشغّل ملف البرنامج "main.go" بعد حفظه من خلال الأمر go run: $ go run main.go الخرج مشابه لما سبق مع إضافة بسيطة: server: GET / client: got response! client: status code: 200 client: response body: {"message": "hello!"} يُظهر السطر الأول أن الخادم لا يزال يتلقى طلب GET إلى المسار /. يتلقى العميل استجابة من الخادم برمز الحالة 200. يقرأ العميل متن استجابة الخادم ويطبعها أيضًا، والتي تكون في هذه الحالة {" message ":" hello! "}. يمكننا معالجة استجابة جسون هذه أيضًا باستخدام حزمة encoding/json، لكن لن ندخل في هذه التفاصيل الآن. طورّنا في هذا القسم برنامج باستخدام خادم HTTP وقدمنا طلبات HTTP إليه باستخدام طرق مختلفة. استخدمنا في البداية الدالة http.Get لتنفيذ طلب GET للخادم باستخدام عنوان URL الخاص بالخادم فقط. حدّثنا بعد ذلك البرنامج لاستخدام http.NewRequest لإنشاء قيمة http.Request، ثم استخدمنا التابع Do لعميل HTTP الافتراضيhttp.DefaultClient، لإرسال الطلب وطباعة متن الاستجابة. تُعد طلبات GET مفيدةً لاسترداد المعلومات من الخادم، لكن بروتوكول HTTP يوفر طرقًا أخرى متنوعة للاتصال بين البرامج. إحدى هذه الطرق هي طريقة POST، والتي تتيح لك إرسال معلومات من برنامجك إلى الخادم. إرسال طلب POST يُستخدم طلب GET في واجهة برمجة تطبيقات REST، فقط لاسترداد المعلومات من الخادم، لذلك لكي يُشارك البرنامج بالكامل في REST، يحتاج أيضًا إلى دعم إرسال طلبات POST، وهو عكس طلب GET تقريبًا، إذ يرسل العميل البيانات إلى الخادم داخل متن الطلب. سنُعدّل في هذا القسم البرنامج لإرسال طلب POST بدلُا من طلب GET، إذ سيتضمن طلب POST متنًا للطلب، وسنُعدّل الخادم لتوفير معلومات أكثر تفصيلًا حول الطلبات الواردة من العميل. نفتح ملف "main.go" ونُضمّن الحزم الإضافية التالية: ... import ( "bytes" "errors" "fmt" "io/ioutil" "net/http" "os" "strings" "time" ) ... ثم نُعدّل دالة المعالجة لطباعة معلومات متنوعة حول الطلب الوارد، مثل قيم سلسلة الاستعلام وقيم الترويسة ومتن الطلب: ... mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Printf("server: %s /\n", r.Method) fmt.Printf("server: query id: %s\n", r.URL.Query().Get("id")) fmt.Printf("server: content-type: %s\n", r.Header.Get("content-type")) fmt.Printf("server: headers:\n") for headerName, headerValue := range r.Header { fmt.Printf("\t%s = %s\n", headerName, strings.Join(headerValue, ", ")) } reqBody, err := ioutil.ReadAll(r.Body) if err != nil { fmt.Printf("server: could not read request body: %s\n", err) } fmt.Printf("server: request body: %s\n", reqBody) fmt.Fprintf(w, `{"message": "hello!"}`) }) ... أضفنا في هذا التحديث لمعالج طلب HTTP الخاص بالخادم تعليمات fmt.Printf لتوفير مزيد من المعلومات حول الطلب الوارد. يُستخدم التابع r.URL.Query ().Get لاسترداد قيمة سلسلة الاستعلام المسماة id. يُستخدم التابع r.Header.Get للحصول على قيمة الترويسة content-type. تتكرر حلقة for معr.Header فوق كل ترويسة HTTP يستقبلها الخادم وتطبع اسمها وقيمتها. يمكن أن تكون هذه المعلومات ذات قيمة لأغراض استكشاف الأخطاء وإصلاحها في حالة ظهور أي مشكلات مع سلوك العميل أو الخادم. تُستخدم أيضًا الدالة ioutil.ReadAll لقراءة متن الطلب من r.Body. بعد تحديث دالة المعالجة للخادم، نُعدّل شيفرة الطلب في الدالة main، بحيث ترسل طلب POST مع متن الطلب: ... time.Sleep(100 * time.Millisecond) jsonBody := []byte(`{"client_message": "hello, server!"}`) bodyReader := bytes.NewReader(jsonBody) requestURL := fmt.Sprintf("http://localhost:%d?id=1234", serverPort) req, err := http.NewRequest(http.MethodPost, requestURL, bodyReader) ... في هذا التعديل قُدّم متغيران جديدان: يمثل الأول jsonBody بيانات جسون مثل قيم من النوع byte[] بدلًا من سلسلة string. يُستخدم هذا التمثيل لأنه عند ترميز بيانات جسون باستخدام حزمة encoding/json، فإنها تُرجع byte[] بدلًا من سلسلة. المتغير الثاني bodyReader، هو bytes.Reader مُغلّف ببيانات jsonBody. يتطلب http.Request أن يكون متن الطلب من النوع io.Reader. نظرًا لأن قيمة jsonBody هي byte[] ولا تُحقق io.Reader مباشرةً، يُستخدم bytes.Reader لتوفير واجهة io.Reader الضرورية، مما يسمح باستخدام قيمة jsonBody على أنها متن الطلب. نُعدّل أيضًا المتغير requestURL لتضمين قيمة سلسلة الاستعلام id = 1234، وذلك لتوضيح كيف يمكن تضمين سلسلة استعلام في عنوان URL للطلب إلى جانب مكونات عنوان URL القياسية الأخرى. أخيرًا نُعدّل استدعاء الدالة http.NewRequest لاستخدام التابع POST مع http.MethodPost، وضبط متن الطلب على bodyReader، وهو قارئ بيانات جسون. لنُشغّل ملف البرنامج "main.go" بعد حفظه من خلال الأمر go run: $ go run main.go سيكون الخرج بالتأكيد أطول من السابق، بسبب طباعة معلومات إضافية: server: POST / server: query id: 1234 server: content-type: server: headers: Accept-Encoding = gzip User-Agent = Go-http-client/1.1 Content-Length = 36 server: request body: {"client_message": "hello, server!"} client: got response! client: status code: 200 client: response body: {"message": "hello!"} يُظهر الخرج السلوك المُحدّث للخادم والعميل بعد إجراء التغييرات لإرسال طلب POST؛ إذ يشير السطر الأول إلى أن الخادم قد تلقى طلب POST للمسار /؛ ويعرض السطر الثاني قيمة سلسلة الاستعلام id، والتي تحمل القيمة 1234 في هذا الطلب؛ بينما يعرض السطر الثالث قيمة ترويسة Content-Type، وهي فارغة في هذه الحالة. قد يختلف ترتيب الترويسات المطبوعة من r.Headers أثناء التكرار عليها باستخدام range في كل مرة نُشغّل فيها البرنامج، فبدءًا من إصدار جو 1.12، لا يمكن ضمان أن يكون الترتيب الذي يجري فيه الوصول إلى العناصر هو نفس الترتيب الذي تُدرج في العناصر في الرابط map، وهذا لأن لغة جو تُحقق الروابط باستخدام بنية لا تحافظ على ترتيب الإدراج، لذلك قد يختلف ترتيب طباعة الترويسات عن الترتيب الذي جرى استلامها به فعليًا في طلب HTTP. الترويسات الموضحة في المثال هي Accept-Encoding و User-Agent و Content-Length. قد نرى أيضًا قيمة مختلفة للترويسة User-Agent عما رأيناه أعلاها اعتمادًا على إصدار جو المُستخدم. يعرض السطر التالي متن الطلب الذي استلمه الخادم، وهو بيانات جسون التي يرسلها العميل. يمكن للخادم بعد ذلك استخدام حزمة encoding/json لتحليل بيانات جسون هذه التي أرسلها العميل وصياغة استجابة. تمثل الأسطر التالية خرج العميل، مما يشير إلى أنه تلقى استجابةً مع رمز الحالة 200. يحتوي متن الاستجابة المعروض في السطر الأخير على بيانات جسون أيضًا. أجرينا في القسم السابق العديد من التحديثات لتحسين البرنامج. أولًا، استبدلنا طلب GET بطلب POST، مما يتيح للعميل إرسال البيانات إلى الخادم في متن الطلب، إذ أنجزنا هذا عن طريق تحديث شيفرة الطلب وتضمين متن الطلب باستخدام byte[]. عدّلنا أيضًا دالة معالجة الطلب الخاصة بالخادم لتوفير معلومات أكثر تفصيلًا حول الطلبات الواردة، مثل قيمة سلسلة الاستعلام id وترويسة Content-Type وجميع الترويسات المستلمة من العميل. تجدر الإشارة إلى أنه في الخرج المعروض، لم تكن ترويسة Content-Type موجودة في طلب HTTP، مما يشير عادةً إلى نوع المحتوى المُرسل في المتن. سنتعلم في القسم التالي المزيد عن كيفية تخصيص طلب HTTP، بما في ذلك ضبط ترويسة Content-Type لتحديد نوع البيانات المُرسلة. تخصيص طلب HTTP يسمح تخصيص طلب HTTP بنقل مجموعة واسعة من أنواع البيانات بين العملاء والخوادم. كان بإمكان عملاء HTTP سابقًا افتراض أن البيانات المستلمة من خادم HTTP هي HTML غالبًا، أما اليوم يمكن أن تشمل البيانات تنسيقات مختلفة، مثل جسون والموسيقى والفيديو وغيرهم. لنقل معلومات إضافية متعلقة بالبيانات المرسلة، يشتمل بروتوكول HTTP على العديد من الترويسات، أهمها الترويسة Content-Type، التي تُخبر الخادم (أو العميل، اعتمادًا على اتجاه البيانات) بكيفية تفسير البيانات التي يتلقاها. سنعمل في القسم التالي على تحسين البرنامج عن طريق ضبط الترويسة Content-Type في طلب HTTP للإشارة إلى أن الخادم يجب أن يتوقع بيانات جسون. ستتاح لنا الفرصة أيضًا لاستخدام عميل HTTP مخصص بدلًا من http.DefaultClient الافتراضي، مما يتيح لنا تخصيص إرسال الطلب وفقًا للمتطلبات المحددة. نفتح ملف "main.go" لإجراء التعديلات الجديدة على الدالة main: ... req, err := http.NewRequest(http.MethodPost, requestURL, bodyReader) if err != nil { fmt.Printf("client: could not create request: %s\n", err) os.Exit(1) } req.Header.Set("Content-Type", "application/json") client := http.Client{ Timeout: 30 * time.Second, } res, err := client.Do(req) if err != nil { fmt.Printf("client: error making http request: %s\n", err) os.Exit(1) } ... بدايةً، جرى النفاذ إلى ترويسات http.Request باستخدام req.Header وضبطنا ترويسة Content-Type على application/json، إذ يُمثّل ضبط هذه الترويسة إشارةً للخادم إلى أن البيانات الموجودة في متن الطلب مُنسّقة بتنسيق جسون، وهذا يساعد الخادم في تفسير متن الطلب ومعالجته بطريقة صحيحة. أنشأنا أيضًا نسخة خاصة من البنية http.Client في المتغير client. يتيح لنا هذا مزيدًا من التحكم في سلوك العميل. يمكننا في هذه الحالة ضبط قيمة المهلة Timeout للعميل على 30 ثانية؛ وهذا يعني أنه إذا لم يجري تلقي الرد في غضون 30 ثانية، سيستسلم العميل ويتوقف عن الانتظار. هذا مهم لمنع البرنامج من إهدار الموارد بإبقاء الاتصالات مفتوحة إلى أجل غير مسمى. من خلال إنشاء http.Client خاص بنا مع مهلة محددة، فإننا تتأكد من أن البرنامج لديه حد محدد للمدة التي سينتظرها الرد، ويختلف هذا عن استخدام http.DefaultClient الافتراضي، والذي لا يحدد مهلة وسينتظر إلى أجل غير مسمى. لذلك يعد تحديد المهلة أمرًا بالغ الأهمية لإدارة الموارد بفعالية وتجنب مشكلات الأداء المحتملة. أخيرًا عدّلنا الطلب لاستخدام التابع Do من المتغير client. هذا التغيير واضح ومباشر لأننا كنا نستخدم التابع Do في جميع أنحاء البرنامج، سواء مع العميل الافتراضي أو العميل المخصص. الاختلاف الوحيد الآن هو أننا أنشأنا صراحةً متغيرًا من http.Client. توفر هذه التحديثات مزيدًا من التحكم والمرونة لبرنامجنا، مما يسمح بضبط ترويسات محددة وإدارة المهلات الزمنية بالطريقة المناسبة. من خلال فهم وتخصيص هذه الجوانب لطلب HTTP، يمكننا ضمان الأداء الأمثل والتواصل الموثوق مع الخادم. لنُشغّل ملف البرنامج "main.go" بعد حفظه من خلال الأمر go run: $ go run main.go يجب أن تكون مخرجاتك مشابهة جدًا للإخراج السابق ولكن مع مزيد من المعلومات حول نوع المحتوى: server: POST / server: query id: 1234 server: content-type: application/json server: headers: Accept-Encoding = gzip User-Agent = Go-http-client/1.1 Content-Length = 36 Content-Type = application/json server: request body: {"client_message": "hello, server!"} client: got response! client: status code: 200 client: response body: {"message": "hello!"} يمكننا ملاحظة أن الخادم قد استجاب بقيمة الترويسة Content-Type المُرسلة من قِبل العميل وهي من النوع application/json، ونلاحظ القيمة content-type وهي أيضًا application/json. يسمح وجود Content-Type بالمرونة في التعامل مع أنواع مختلفة من واجهات برمجة التطبيقات في وقت واحد، إذ يمكّن الخادم من التمييز بين الطلبات التي تحتوي على بيانات جسون والطلبات التي تحتوي على بيانات XML. يصبح هذا التمييز مهمًا خاصةً عند التعامل مع واجهات برمجة التطبيقات التي تدعم تنسيقات بيانات مختلفة. لفهم كيفية عمل مهلة العميل Timeout، يمكننا تعديل الشيفرة لمحاكاة سيناريو يستغرق فيه الطلب وقتًا أطول من المهلة المحددة، وذلك لكي نتمكن من ملاحظة تأثير المهلة. يمكننا إضافة استدعاء دالة time.Sleep ضمن دالة المعالجة لخادم HTTP، إذ تؤدي هذه الدالة إلى تأخير مُصطنع في استجابة الخادم. للتأكد من أن التأخير يتجاوز قيمة المهلة التي حددناها، نجعل time.Sleep يستمر لمدة 35 ثانية، وبالتالي سيظهر الخادم وكأنه غير مستجيب، مما يتسبب في انتظار العميل للاستجابة: ... func main() { go func() { mux := http.NewServeMux() mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { ... fmt.Fprintf(w, `{"message": "hello!"}`) time.Sleep(35 * time.Second) }) ... }() ... } لنُشغّل ملف البرنامج "main.go" بعد حفظه من خلال الأمر go run: $ go run main.go عند تنفيذ البرنامج مع إضافة (time.Sleep (35 * time.Second في دالة المعالجة، سيتغير سلوك العميل والخادم، وذلك بسبب التأخير المصطنع الذي حدث في استجابة الخادم. نلاحظ أيضًا أن البرنامج يستغرق وقتًا أطول للخروج مقارنةً بالسابق، وذلك لأن البرنامج ينتظر انتهاء طلب HTTP قبل الإنهاء. الآن مع الانتظار الجديد الذي أضفناه، لن يكتمل طلب HTTP حتى تصل الاستجابة أو نتجاوز المهلة المحددة البالغة 30 ثانية، ونظرًا لأن الخادم متوقف مؤقتًا لمدة 35 ثانية، متجاوزًا مدة المهلة، فسيلغي العميل الطلب بعد 30 ثانية، وستجري عملية معالجة خطأ المهلة الزمنية: server: POST / server: query id: 1234 server: content-type: application/json server: headers: Content-Type = application/json Accept-Encoding = gzip User-Agent = Go-http-client/1.1 Content-Length = 36 server: request body: {"client_message": "hello, server!"} client: error making http request: Post "http://localhost:3333?id=1234": context deadline exceeded (Client.Timeout exceeded while awaiting headers) exit status 1 نلاحظ تلقي الخادم طلب POST بطريقة صحيحة إلى المسار /، والتقط أيضًا معامل الاستعلام id بقيمة1234. حدد الخادم نوع محتوى الطلب content-type على أنه application/json بناءً على ترويسة نوع المحتوى، كما عرض الخادم الترويسات التي تلقاها. طبع الخادم أيضًا متن الطلب. نلاحظ حدوث خطأ context deadline exceeded من جانب العميل أثناء طلب HTTP كما هو متوقع. تشير رسالة الخطأ إلى أن الطلب تجاوز المهلة الزمنية البالغة 30 ثانية (الخادم تلقى الطلب وعالجه، لكنه بسبب وجود تعليمة انتظار من خلال استدعاء دالة time.Sleep، سيبدو وكأنه لم ينتهي من معالجته). بالتالي سيفشل استدعاء التابع client.Do، ويخرج البرنامج مع رمز الحالة 1 باستخدام (os.Exit (1. يسلط هذا الضوء على أهمية ضبط قيم المهلة الزمنية بطريقة مناسبة لضمان عدم تعليق الطلبات إلى أجل غير مسمى. أجرينا خلال هذا القسم تعديلات على البرنامج لتخصيص طلب HTTP من خلال إضافة ترويسة Content-Type إليه. عدّلنا البرنامج أيضًا من خلال إنشاء http.Client جديد مع مهلة زمنية 30 ثانية، والتي استخدمناها لاحقًا لتنفيذ طلب HTTP. فحصنا أيضًا تأثير المهلة من خلال استخدام التعليمة time.Sleep داخل معالج طلب HTTP. سمح لنا هذا بملاحظة أن استخدام عميل http.Client مع المهلات الزمنية المضبوطة بدقة أمرٌ بالغ الأهمية لمنع الطلبات من الانتظار إلى أجل غير مسمى. بالتالي اكتسبنا رؤى حول أهمية ضبط المهلات المناسبة لضمان معالجة الطلب بكفاءة ومنع المشكلات المحتملة مع الطلبات التي قد تظل خاملة. الخاتمة اكتسبنا خلال هذا المقال فهمًا شاملًا للعمل مع طلبات HTTP في لغة جو باستخدام حزمة net/http؛ إذ بدأنا بتقديم طلب GET إلى خادم HTTP باستخدام كل من دالة http.Get و http.NewRequest مع عميل HTTP الافتراضي؛ ثم وسّعنا بعد ذلك معرفتنا عن طريق إجراء طلب POST مع متن طلب باستخدام bytes.NewReader. تعلمنا كيفية تخصيص الطلب عن طريق ضبط ترويسة Content-Type باستخدام التابع Set في حقل ترويسة http.Request.اكتشفنا أهمية ضبط المهلات للتحكم في مدة الطلبات من خلال إنشاء عملي http.Client. تجدر الإشارة إلى أن حزمة net/http توفر دوال أكثر مما غطيناه في هذا المقال، فعلى سبيل المثال يمكن استخدام دالة http.Post لتقديم طلبات POST والاستفادة من ميزات مثل إدارة ملفات تعريف الارتباط. من خلال إتقان هذه المفاهيم، نكون قد زودنا أنفسنا بالمهارات الأساسية للتفاعل مع خوادم HTTP وإنشاء أنواع مختلفة من الطلبات باستخدام لغة جو. ترجمة -وبتصرف- للمقال How To Make HTTP Requests in Go لصاحبه Kristin Davidson. اقرأ أيضًا المقال السابق كيفية إنشاء خادم HTTP في لغة Go إنشاء طلبات HTTP وإرسالها كيفية استخدام صيغة JSON في لغة Go
  3. يكرّس العديد من المطورين جزءًا من وقتهم لبناء خوادم تُسهّل توزيع المحتوى عبر الإنترنت. يُعد بروتوكول النقل التشعبي Hypertext Transfer Protocol-أو اختصارًا HTTP- من أهم الوسائل المستخدمة لتوزيع المحتوى مهما كان نوع البيانات عبر الإنترنت. تتضمّن مكتبة لغة جو القياسية وظائفًا مدمجة لإنشاء خادم HTTP لتخديّم محتوى الويب، أو إنشاء طلبات HTTP للتواصل مع هذه الخوادم. سنتعلم في هذا المقال كيفية إنشاء خادم HTTP باستخدام مكتبة لغة جو القياسية، وكيفية توسيع وظائف الخادم لاستخراج البيانات من أجزاء مختلفة من طلب HTTP، مثل سلسلة الاستعلام والمتن Body وبيانات النموذج في الطلب. سنتعلّم أيضًا كيفية تعديل استجابة الخادم عن طريق إضافة ترويسات HTTP ورموز حالة مخصصة status codes، وبالتالي السماح للمطورين بتخصيص سلوك الخادم الخاص بهم. توضيح بعض المصطلحات: في سياق إنشاء خادم HTTP باستخدام مكتبة جو القياسية، تشير المصطلحات "سلسلة استعلام الطلب" و"المتن" و"بيانات النموذج" إلى أجزاء مختلفة من طلب HTTP. سلسلة الاستعلام: هي جزء من عنوان URL الذي يأتي بعد رمز علامة الاستفهام (?). يحتوي عادةً على أزواج ذات قيمة مفتاح مفصولة بعلامات &. المتن أو النص الأساسي: يحتوي متن طلب HTTP على البيانات التي يرسلها العميل إلى الخادم، مثل JSON أو XML أو النص العادي. يُستخدم بكثرة في طلبات من نوع POSTو PUT و PATCH لإرسال البيانات إلى الخادم. بيانات النموذج: تُرسل عادةً بيانات النموذج مثل جزء من طلب POST مع ضبط ترويسة "نوع المحتوى" على "application/x-www-form-urlencoded" أو "multipart/form-data". وهو يتألف من أزواج مفتاح - قيمة على غرار سلسلة الاستعلام، ولكن يُرسل في متن الطلب. بروتوكول HTTP هو بروتوكول يعمل على مستوى التطبيقات، ويستخدم لنقل مستندات الوسائط التشعبية، مثل صفحات HTML عبر الإنترنت. يعمل HTTP بمثابة أساس لاتصالات البيانات على شبكة الويب العالمية. يتبع HTTP نموذج خادم-العميل، إذ يرسل العميل (عادةً متصفح ويب) طلبًا إلى الخادم، ويستجيب الخادم بالمعلومات المطلوبة. تُستخدم بعض الدوال، مثل GET و POSTو PUT و DELETE لتسهيل عملية الاتصال بين العميل والخادم. يمكنك الاطلاع على مقال مدخل إلى HTTP على أكاديمية حسوب لمزيدٍ من المعلومات حول بروتوكول HTTP. المتطلبات الأولية لمتابعة هذا المقال التعليمي، سنحتاج إلى: إصدار مُثبّت من جو 1.16 أو أعلى، ويمكنك الاستعانة بمقال تثبيت لغة جو Go وإعداد بيئة برمجة محلية على أبونتو Ubuntu لإعداده. تثبيت لغة جو وإعداد بيئة برمجة محلية على نظام ماك macOS. تثبيت لغة جو وإعداد بيئة برمجة محلية على ويندوز. القدرة على استخدام أداة curl لإجراء طلبات ويب. إلمام بكيفية استخدام جسون JSON في لغة جو. معرفة بكيفية استخدام السياقات Contexts في لغة جو Go. فهم لتنظيمات جو goroutines والقنوات channels. يمكنك الاطلاع على مقالة كيفية تشغيل عدة دوال على التساير في لغة جو Go. الإلمام بكيفية إنشاء طلبات HTTP وإرسالها (موصى به). إعداد المشروع تتوفّر معظم وظائف HTTP التي تسمح لنا بإجراء الطلبات من خلال حزمة net/http الموجودة في المكتبة القياسية في لغة جو، بينما تتولى حزمة net بقية عمليات الاتصال بالشبكة. توفّر حزمة net/http أيضًا خادم HTTP يمكن استخدامه لمعالجة تلك الطلبات. سننشئ في هذا القسم برنامجًا يستخدم الدالة http.ListenAndServe لتشغيل خادم HTTP يستجيب للطلبات ذات المسارات / و hello/، ثم سنوسّع البرنامج لتشغيل خوادم HTTP متعددة في نفس البرنامج. كما هو معتاد، سنحتاج لبدء إنشاء برامجنا إلى إنشاء مجلد للعمل ووضع الملفات فيه، ويمكن وضع المجلد في أي مكان على الحاسب، إذ يكون للعديد من المبرمجين عادةً مجلدٌ يضعون داخله كافة مشاريعهم. سنستخدم في هذا المقال مجلدًا باسم "projects"، لذا فلننشئ هذا المجلد وننتقل إليه: $ mkdir projects $ cd projects الآن من داخل هذا المجلد، سنشغّل الأمر mkdir لإنشاء مجلد "httpserver" ثم سنستخدم cd للانتقال إليه: $ mkdir httpserver $ cd httpserver الآن، بعد أن أنشأنا مجلدًا للبرنامج وانتقلنا إليه، يمكننا البدء في تحقيق خادم HTTP. الاستماع إلى الطلبات وتقديم الردود يتضمّن مخدّم HTTP في لغة جو مكونين رئيسيين: المخدّم الذي يستمع إلى الطلبات القادمة من العميل الذي يرسل طلبات HTTP (عميل HTTP أو عملاء HTTP) ومُعالج طلبات (أو أكثر) يستجيب لتلك الطلبات. سنبدأ حاليًا في استخدام الدالة http.HandleFunc التي تخبر المُخدّم بالدالة التي يجب استدعاؤها لمعالجة الطلب، ثم سنستخدم الدالة http.ListenAndServe لتشغيل الخادم وإخباره بالتحضير للاستماع إلى طلب HTTP جديد وتخديمه من خلال معالجته بدوال المعالجة Handler functions التي نُنشئها مُسبقًا. بما أننا الآن داخل مجلد "httpserver"، يمكننا فتح ملف "main.go" باستخدام محرر نانو nano أو أي محرر آخر تريده: $ nano main.go سننشئ داخل هذا الملف دالتين getRoot و getHello سيمثلان دوال المعالجة الخاصة بنا، ثم سننشئ دالةً رئيسية main لاستخدامها في إعداد معالجات الطلبات من خلال الدالة http.HandleFunc، وذلك بتمرير المسار / الخاص بالدالة getRoot والمسار hello/ الخاص بالدالة getHello. بمجرد أن ننتهي من إعداد دوال المعالجة الخاصة بنا، يمكننا استدعاء http.ListenAndServe لبدء تشغيل المخدّم والاستماع للطلبات. دعنا نضيف التعليمات البرمجية التالية إلى الملف لبدء تشغيل البرنامج وإعداد المعالجات: package main import ( "errors" "fmt" "io" "net/http" "os" ) func getRoot(w http.ResponseWriter, r *http.Request) { fmt.Printf("got / request\n") io.WriteString(w, "This is my website!\n") } func getHello(w http.ResponseWriter, r *http.Request) { fmt.Printf("got /hello request\n") io.WriteString(w, "Hello, HTTP!\n") } أعددنا في البداية الحزمة الخاصة بالبرنامج package مع استيراد الحزم المطلوبة من خلال تعليمة import، كما أنشأنا الدالتين getRoot و getHello، ونلاحظ أن لهما نفس البصمة Signature، إذ تقبلان نفس الوسيطين، هما: قيمة http.ResponseWriter وقيمة http.Request*. هاتان الدالتان لهما بصمة تطابق النوع http.HandlerFunc، والذي يشيع استخدامه لتعريف دوال معالجة HTTP. عند تقديم طلب إلى الخادم، فإنه يُزوِّد هاتين القيمتين بمعلومات حول الطلب الحالي، ثم يستدعي دالة المعالج التي تتوافق مع هذه القيم. تُستخدم القيمة http.ResponseWriter (االمُسماة w) ضمن http.HandlerFunc للتحكّم بمعلومات الاستجابة التي يُعاد كتابتها إلى العميل الذي قدّم الطلب، مثل متن الاستجابة response body (جزء من استجابة HTTP ويحمل الحمولة الفعلية أو المعلومات التي طلبها العميل أو التي يوفرها الخادم) أو رموز الحالة status codes، وهي معلومات حول نتيجة الطلب والحالة الحالية للخادم، وهي جزء من بروتوكول HTTP يجري تضمينها في ترويسة الاستجابة، وتشير إلى ما إذا كان الطلب ناجحًا أو واجه خطأً أو يتطلب إجراءً إضافيًا. بعد ذلك، تُستخدم القيمة http.Request* (المسماة r) للحصول على معلومات حول الطلب الذي جاء إلى الخادم، مثل المتن المُرسل في حالة طلب POST أو معلومات حول العميل الذي أجرى الطلب. في كل من معالجات HTTP التي أنشأناها، يمكننا استخدام الدالة fmt.Printf للطباعة، وذلك عندما يأتي طلب لدالة المعالجة، ثم نستخدم http.ResponseWriter لإرسال نص ما إلى متن الاستجابة. http.ResponseWriter هي واجهة في حزمة http تمثل الاستجابة التي سترسل مرةً أخرى إلى العميل عند تقديم طلب إلى الخادم، وهي io.Writer مما يعني أنها توفر إمكانية كتابة البيانات. نستخدم http.ResponseWriterفي الشيفرة السابقة مثل وسيطw في دوال المعالجة (getRoot و getHello)، للسماح بالتحكم في الرد المرسل إلى العميل، وبالتالي إمكانية كتابة متن الاستجابة، أو ضبط الترويسات headers، أو تحديد رمز الحالة باستخدامها. نستخدم الدالة io.WriteString لكتابة الاستجابة ضمن متن الرسالة. لنضيف الآن الدالة main إلى الشيفرة السابقة: ... func main() { http.HandleFunc("/", getRoot) http.HandleFunc("/hello", getHello) err := http.ListenAndServe(":3333", nil) ... تكون الدالة الرئيسية main في جزء الشيفرة السابق، مسؤولةً عن إعداد خادم HTTP وتحديد معالجات الطلب. هناك استدعاءان إلى الدالة http.HandleFunc، بحيث يربط كل استدعاء لها دالة معالجة من أجل مسار طلب محدد ضمن مجمّع الخادم الافتراضي default server multiplexer. يتطلب الأمر وسيطين: الأول هو مسار الطلب (في هذه الحالة / ثم hello/) ودالة المعالجة (getRoot ثم getHello على التوالي). الدالتان getRoot و getHello هما دوال المعالجة التي سيجري استدعاؤها عند تقديم طلب إلى المسارات المقابلة (/ و hello/). للدالتين توقيع مماثل للدالة http.HandlerFunc التي تقبلhttp.ResponseWriter و http.Request* مثل وسطاء. تُستخدم الدالة http.ListenAndServe لبدء تشغيل خادم HTTP للاستماع إلى الطلبات الواردة. يتطلب الأمر وسيطين، هما: عنوان الشبكة للاستماع عليه (في هذه الحالة 3333:) ومعالج اختياري http.Handler. يحدد 3333: في برنامجنا أن الخادم يجب أن يستمع إلى المنفذ 3333، ونظرًا لعدم تحديد عنوان IP، سيستمع إلى جميع عناوين IP المرتبطة بالحاسب. يمثّل منفذ الشبكة network port -مثل "3333"- طريقةً تمكّن جهاز الحاسوب من أن يكون لديه عدة برامج تتواصل مع بعضها بنفس الوقت، بحيث يستخدم كل برنامج منفذه المخصص، وبالتالي عند اتصال العميل مع منفذ معين يعلم الحاسوب إلى أي منفذ سيُرسل. إذا كنت تريد قصر الاتصالات على المضيف المحلي localhost فقط، فيمكنك استخدام ‎127.0.0.1:3333. تمرّر الدالة http.ListenAndServe قيمة nil من أجل المعامل http.Handler، وهذا يخبر دالة ListenAndServe بأنك تريد استخدام مجمّع الخادم الافتراضي وليس أي مجمّع ضبطه سابقًا. الدالة ListenAndServe هي استدعاء "حظر"، مما يعني أنها ستمنع تنفيذ التعليمات البرمجية الأخرى حتى يُغلق الخادم. تُعيد هذه الدالة خطًأ إذا فشلت عملية بدء تشغيل الخادم أو إذا حدث خطأ أثناء التشغيل. من المهم تضمين عملية معالجة الأخطاء بعد استدعاء http.ListenAndServe، وذلك لأن الدالة يمكن أن تفشل، بالتالي من الضروري التعامل مع الأخطاء المحتملة. يُستخدم المتغير err لالتقاط أي خطأ يُعاد بواسطة ListenAndServe. يمكننا إضافة شيفرة معالجة الأخطاء بعد هذا السطر لمعالجة أي أخطاء محتملة قد تحدث أثناء بدء تشغيل الخادم أو تشغيله كما سنرى. لنضيف الآن شيفرة معالجة الأخطاء إلى دالة ListenAndServe ضمن دالة main الرئيسية كما يلي: ... func main() { ... err := http.ListenAndServe(":3333", nil) if errors.Is(err, http.ErrServerClosed) { fmt.Printf("server closed\n") } else if err != nil { fmt.Printf("error starting server: %s\n", err) os.Exit(1) <^>} } بعد استدعاء http.ListenAndServe، يجري تخزين الخطأ المُعاد في المتغير err. تجري عملية فحص الخطأ الأولى باستخدام (errors.Is(err, http.ErrServerClosed، إذ يجري التحقق ما إذا كان الخطأ هو http.ErrServerClosed، والذي يُعاد عندما يُغلق الخادم أو يجري إيقاف تشغيله. يعني ظهور هذا الخطأ أن الخادم قد أُغلق بطريقة متوقعة، بالتالي طباعة الرسالة "server closed". يُنجز فحص الخطأ الثاني باستخدام Err != nil. يتحقق هذا الشرط مما إذا كان الخطأ ليس nil، مما يشير إلى حدوث خطأ أثناء بدء تشغيل الخادم أو تشغيله. إذا تحقق الشرط، فهذا يعني حدوث خطأ غير متوقع، وبالتالي طباعة رسالة خطأ مع تفاصيل الخطأ باستخدام fmt.Printf، كما يجري إنهاء البرنامج مع شيفرة الخطأ 1 باستخدام (os.Exit (1 للإشارة إلى حدوث خطأ. تجدر الإشارة إلى أن أحد الأخطاء الشائعة التي قد تواجهها هو أن العنوان قيد الاستخدام فعلًا address already in use. يحدث هذا عندما تكون الدالة ListenAndServe غير قادرة على الاستماع إلى العنوان أو المنفذ المحدد للخادم لأنه قيد الاستخدام فعلًا من قبل برنامج آخر، أي إذا كان المنفذ شائع الاستخدام أو إذا كان برنامج آخر يستخدم نفس العنوان أو المنفذ. ملاحظة: عند ظهور هذا الخطأ، يجب التأكد من إيقاف أي مثيلات سابقة للبرنامج ثم محاولة تشغيله مرة أخرى. إذا استمر الخطأ يجب علينا محاولة استخدام رقم منفذ مختلف لتجنب التعارضات، فمن المحتمل أن برنامجًا آخر يستخدم المنفذ المحدد. يمكن اختيار رقم منفذ مختلف (أعلى من 1024 وأقل من 65535) وتعديل الشيفرة وفقًا لذلك. على عكس برامج لغة جو الأخرى؛ لن يُنهى البرنامج فورًا من تلقاء نفسه. لنُشغّل ملف البرنامج "main.go" بعد حفظه من خلال الأمر go run: $ go run main.go بما أن البرنامج يبقى قيد التشغيل في الطرفية الحالية، فسنحتاج إلى فتح نافذة أخرى للطرفية لكي نتفاعل مع الخادم (ستظهر الأوامر بلون مختلف عن الطرفية الأولى). ضمن الطرفية الثانية التي فتحناها، نستخدم الأمر curl لتقديم طلب HTTP إلى خادم HTTP الخاص بنا. curl هي أداة مُثبّتة افتراضيًا على العديد من الأنظمة التي يمكنها تقديم طلبات للخوادم من أنواع مختلفة، وسنستخدمها في هذا المقال لإجراء طلبات HTTP. يستمع الخادم إلى الاتصالات على المنفذ 3333 لجهاز الحاسوب، لذا يجب تقديم الطلب للمضيف المحلي على نفس المنفذ: $ curl http://localhost:3333 سيكون الخرج على النحو التالي: This is my website! العبارة This is my website ناتجة عن الدالة getRoot، وذلك لأنك استخدمت المسار / على خادم HTTP. دعونا الآن نستخدم المسار hello/ على نفس المضيف والمنفذ، وذلك بإضافة المسار إلى نهاية أمر curl: $ curl http://localhost:3333/hello ليكون الخرج هذه المرة: Hello, HTTP! نلاحظ أن الخرج السابق كان نتيجةً لاستدعاء الدالة getHello. إذا عدنا إلى المحطة الطرفية الأولى التي يعمل عليها خادم HTTP، نلاحظ وجود سطرين أنتجهما الخادم الخاص بنا: واحد للطلب / والآخر للطلب hello/. got / request got /hello request سيستمر البرنامج بالعمل، لذا يجب علينا إيقافه يدويًا من خلال الضغط على المفتاحين "Ctrl+C". لقد أنشأنا برنامجًا يمثل خادم HTTP، لكنه يستخدم مجمّع خادم افتراضي وخادم HTTP افتراضي أيضًا. يمكن أن يؤدي الاعتماد على القيم الافتراضية أو العامة Global إلى حدوث أخطاء يصعب تكرارها أو يصعب إنتاجها باستمرار Reproduce consistently، إذ يمكن أن تُعدِّل أجزاءً مختلفة من البرنامج هذه القيم العامة في أوقات مختلفة، مما يؤدي إلى حالة غير صحيحة أو غير متسقة. يصبح تحديد مثل هذه الأخطاء أمرًا صعبًا لأنها قد تحدث فقط في ظل ظروف معينة أو إذا جرى استدعاء وظائف معينة بترتيب معين. معالجات طلبات التجميع عند بدء تشغيل خادم HTTP سابقًا؛ استخدمنا مجمّع خادم افتراضي عن طريق تمرير قيمة صفرية (أي nil) للمعامل http.Handler في دالة ListenAndServe. رأينا أيضًا أن هناك بعض المشاكل التي قد تطرأ في حالة استخدام المعاملات الافتراضية. بما أن http.Handler هو واجهة interface، فهذا يعني أنه لدينا الخيار لإنشاء بنية مخصصة تحقق هذه الواجهة. هناك طبعًا حالات نحتاج فيها فقط إلى http.Handler الافتراضي الذي يستدعي دالة واحدة لمسار طلب معين، أي كما في حالة مجمّع الخادم الافتراضي، لكن هناك حالات قد تتطلّب أكثر من ذلك؛ هذا ما نناقشه تاليًا. لنعدّل البرنامج الآن لاستخدام http.ServeMux، إنها أداة تعمل مثل مجمّع للخادم، وهي مسؤولة عن التوجيه والتعامل مع طلبات HTTP الواردة بناءً على مساراتها. تحقّقhttp.ServeMux الواجهة http.Handler المؤمنة من قِبل حزمة net/http، مما يعني قدرتها على التعامل مع طلبات HTTP وإنشاء الاستجابات المناسبة. بالتالي مزيد من التحكم في التوجيه والتعامل مع مسارات الطلبات المختلفة، وإتاحة الفرصة لتحديد دوال أو معالجات محددة لكل مسار. بالتالي نكون قد اتبعنا نهجًا أكثر تنظيمًا وقابلية للتخصيص للتعامل مع طلبات HTTP في البرنامج. يمكن تهيئة بنية http.ServeMux بطريقة مشابهة للمجمّع الافتراضي، لذا لن نحتاج إلى إجراء العديد من التغييرات على البرنامج لبدء استخدام مجمّع الخادم المخصّص بدلًا من الافتراضي. لتحديث البرنامج وفقًا لذلك، نفتح ملف "main.go" مرةً أخرى ونجري التعديلات اللازمة لاستخدام http.ServeMux: ... func main() { mux := http.NewServeMux() mux.HandleFunc("/", getRoot) mux.HandleFunc("/hello", getHello) err := http.ListenAndServe(":3333", mux) ... } أنشأنا http.ServeMux جديد باستخدام باني http.NewServeMux وأسندناه إلى المتغير mux، ثم عدّلنا استدعاءات http.HandleFunc لاستخدام المتغير mux بدلًا من استدعاء حزمة http مباشرة. أخيرًا، عدّلنا استدعاء http.ListenAndServe لتزويده بالمعالج http.Handler الذي أنشأنه mux بدلًا من nil. لنُشغّل ملف البرنامج "main.go" بعد حفظه من خلال الأمر go run: $ go run main.go سيستمر البرنامج في العمل كما في المرة السابقة، لذا نحتاج إلى تشغيل أوامر في طرفية أخرى للتفاعل مع الخادم. أولًا، نستخدم curl لطلب المسار /: $ curl http://localhost:3333 سيكون الخرج على النحو التالي: This is my website! الخرج كما هو في المرة السابقة. دعونا الآن نستخدم المسار hello/ على نفس المضيف والمنفذ، وذلك بإضافة المسار إلى نهاية أمر curl: $ curl http://localhost:3333/hello سيكون الخرج كما يلي: Hello, HTTP! نلاحظ أن الخرج السابق كان كما المرة السابقة أيضًا. إذا عدنا الآن إلى الطرفية الأولى، فسنرى مخرجات كل من / و hello / كما كان من قبل: got / request got /hello request نلاحظ أن التعديلات التي أجريناها لا تغيّر في وظيفة البرنامج، وإنما فقط بدلنا مجمّع الخادم الافتراضي بآخر مخصص. سيستمر البرنامج بالعمل، لذا يجب علينا إيقافه يدويًا من خلال الضغط على المفتاحين "Ctrl+C". تشغيل عدة خوادم في وقت واحد سنجري خلال هذا القسم تعديلات على البرنامج لاستخدام عدة خوادم HTTP في نفس الوقت باستخدام http.Server التي توفرها حزمة net/http. يمكننا بالحالة الافتراضية تشغيل خادم HTTP واحد فقط في البرنامج، لكن قد تكون هناك سيناريوهات نحتاج فيها إلى تخصيص سلوك الخادم أو تشغيل عدة خوادم في نفس الوقت، مثل استضافة موقع ويب عام Public website وموقع ويب إداري خاص Private admin website داخل نفس البرنامج. لأجل ذلك سنعدّل ملف "main.go" لإنشاء نسخ متعددة من http.Server لأغراض مختلفة، إذ سيكون لكل خادم التهيئة والإعدادات الخاصة به. يتيح لك هذا مزيدًا من التحكم في سلوك الخادم ويمكّنك من التعامل مع وظائف خادم متعددة داخل نفس البرنامج. سنعدّل أيضًا دوال المعالجة لتحقيق إمكانية الوصول إلى Context.Context المرتبط مع http.Request*؛ أي إمكانية الوصول إلى سياق الطلبات الواردة، إذ يمكننا من خلال هذا السياق تمييز الخادم الذي يأتي الطلب منه. إذًا من خلال تخزين هذه المعلومات في متغير السياق، يصبح بمقدورنا استخدامها داخل دالة المعالجة لتنفيذ إجراءات محددة أو تخصيص الاستجابة بناءً على الخادم الذي أنشأ الطلب. لنفتح ملف "main.go" ونعدّله بالتالي: package main import ( // os لاحظ أننا حذفنا استيراد "context" "errors" "fmt" "io" "net" "net/http" ) const keyServerAddr = "serverAddr" func getRoot(w http.ResponseWriter, r *http.Request) { ctx := r.Context() fmt.Printf("%s: got / request\n", ctx.Value(keyServerAddr)) io.WriteString(w, "This is my website!\n") } func getHello(w http.ResponseWriter, r *http.Request) { ctx := r.Context() fmt.Printf("%s: got /hello request\n", ctx.Value(keyServerAddr)) io.WriteString(w, "Hello, HTTP!\n") } عدّلنا بيان الاستيراد import لتضمين الحزم المطلوبة، ثم أنشأنا سلسلة نصية ثابتة const string تسمى keyServerAddr لتعمل مثل مفتاح لقيمة عنوان خادم HTTP في سياق http.Request، ثم عدّلنا دوال المعالجة getRoot و getHello للوصول إلى قيمة Context.Context التابعة إلى http.Request. بعد الحصول على القيمة يمكننا تضمين عنوان خادم HTTP في خرج fmt.Printf حتى نتمكن من معرفة أي من الخادمين تعامل مع طلب HTTP. لنعدّل الآن الدالة main بإضافة قيمتي http.Server: ... func main() { ... mux.HandleFunc("/hello", getHello) ctx, cancelCtx := context.WithCancel(context.Background()) serverOne := &http.Server{ Addr: ":3333", Handler: mux, BaseContext: func(l net.Listener) context.Context { ctx = context.WithValue(ctx, keyServerAddr, l.Addr().String()) return ctx }, } التغيير الأول الذي أجريناه هو إنشاء قيمة Context.Context جديدة مع دالة متاحة هي الدالة `cancelCtx'. هذا يسمح لنا بإلغاء السياق عند الحاجة. عرّفنا أيضًا نسخةً تسمى serverOne من http.Server، وهو مشابه لخادم HTTP الذي كنا نستخدمه، ولكن بدلًا من تمرير العنوان والمعالج مباشرةً إلى http.ListenAndServe، يمكن إسنادهما مثل قيم Addr و Handler في بنية http.Server. تعديل آخر كان بإضافة دالة BaseContext، وهي دالة تسمح بتعديل أجزاء من Context.Context الذي جرى تمريره إلى دوال المعالجة عند استدعاء التابع Context من http.Request*. أضفنا في الشيفرة السابقة عنوان الاستماع الخاص بالخادم إلى السياق باستخدام المفتاح serverAddr، وهذا يعني أن العنوان الذي يستمع فيه الخادم للطلبات الواردة مرتبط بمفتاح serverAddr في السياق. عندما نستدعي الدالة BaseContext، فإنها تتلقى net.Listener، والذي يمثل مستمع الشبكة الأساسي الذي يستخدمه الخادم. بالنسبة لبرنامجنا: من خلال استدعاء ()l.Addr(). String، نكون قد حصلنا على عنوان شبكة المستمع. يتضمن هذا عادةً عنوان IP ورقم المنفذ الذي يستمع الخادم عليه. بعد ذلك نضيف العنوان الذي حصلنا عليه إلى السياق باستخدام الدالة Context.WithValue، والتي تتيح لنا تخزين أزواج المفتاح والقيمة في السياق. في هذه الحالة يكون المفتاح هو serverAddr، والقيمة المرتبطة به هي عنوان الاستماع الخاص بالخادم. لنعرّف الآن الخادم الثاني serverTwo: ... func main() { ... serverOne := &http.Server { ... } serverTwo := &http.Server{ Addr: ":4444", Handler: mux, BaseContext: func(l net.Listener) context.Context { ctx = context.WithValue(ctx, keyServerAddr, l.Addr().String()) return ctx }, } نعرّف الخادم الثاني بنفس طريقة تعريف الأول، لكن نضع حقل العنوان على 4444: بدلًا من 3333:، وذلك لكي يستمع الخادم الأول على للاتصالات على المنفذ 3333 ويستمع الخادم الثاني على المنفذ 4444. لنعدّل الآن البرنامج من أجل استخدام الخادم الأول serverOne وجعله يعمل مثل تنظيم جو goroutine: ... func main() { ... serverTwo := &http.Server { ... } go func() { err := serverOne.ListenAndServe() if errors.Is(err, http.ErrServerClosed) { fmt.Printf("server one closed\n") } else if err != nil { fmt.Printf("error listening for server one: %s\n", err) } cancelCtx() }() نستخدم تنظيم goroutine لبدء تشغيل الخادم الأول serverOne باستخدام الدالة ListenAndServe كما فعلنا سابقًا، لكن هذه المرة دون أي معاملات لأن قيم http.Server جرى تهيئتها مسبقًا باستخدام العنوان والمعالج المطلوبين. تجري عملية معالجة الأخطاء كما في السابق؛ فإذا كان الخادم مغلقًا، فإنه يطبع رسالة تشير إلى أن "الخادم الأول" أصبح مغلقًا؛ وإذا كان هناك خطأ آخر بخلاف http.ErrServerClosed، فستظهر رسالة خطأ. تُستدعى أخيرًا الدالة CancelCtx لإلغاء السياق الذي قدمناه لمُعالجات HTTP ودوال BaseContext لكلا الخادمين. بالتالي إنهاء أي عمليات جارية تعتمد عليه بأمان. هذا يضمن أنه إذا انتهى الخادم لأي سبب، فسيُنهى أيضًا السياق المرتبط به. لنعدّل الآن البرنامج لاستخدام الخادم الثاني، بحيث يعمل مثل تنظيم جو : ... func main() { ... go func() { ... }() go func() { err := serverTwo.ListenAndServe() if errors.Is(err, http.ErrServerClosed) { fmt.Printf("server two closed\n") } else if err != nil { fmt.Printf("error listening for server two: %s\n", err) } cancelCtx() }() <-ctx.Done() } تنظيم جو هنا هو نفسه الأول من الناحية الوظيفية، فهو يُشغّل serverTwo فقط بدلًا من serverOne. بعد بدء تشغيل serverTwo، يصل التنظيم الخاص بالدالة main إلى السطر ctx.Done. ينتظر هذا السطر إشارةً من قناة ctx.Done، والتي تُعاد عند إلغاء السياق أو الانتهاء منه. من خلال هذا الانتظار نكون قد منعنا تنظيم الدالة الرئيسية من الخروج من الدالة main حتى تنتهي تنظيمات جو لكلا الخادمين من العمل. إذًا، الغرض من هذا الأسلوب هو التأكد من استمرار تشغيل البرنامج حتى انتهاء كلا الخادمين أو مواجهة خطأ. لنُشغّل ملف البرنامج "main.go" بعد حفظه من خلال الأمر go run: $ go run main.go نُشغّل الآن أوامر curl (في الطرفية الثانية) لطلب المسار / والمسارhello/ من الخادم الذي يستمع على 3333، مثل الطلبات السابقة: $ curl http://localhost:3333 $ curl http://localhost:3333/hello سيكون الخرج كما في المرات السابقة: This is my website! Hello, HTTP! لنشغّل الأوامر نفسها مرة أخرى، ولكن هذه المرة مع المنفذ 4444 الذي يتوافق مع serverTwo: $ curl http://localhost:4444 $ curl http://localhost:4444/hello سيبقى الخرج نفسه كما في المرة السابقة أيضًا: This is my website! Hello, HTTP! لنلقي نظرةً الآن على الطرفية الأولى حيث يعمل الخادم: [::]:3333: got / request [::]:3333: got /hello request [::]:4444: got / request [::]:4444: got /hello request الخرج مشابه لما رأيناه من قبل، لكنه يعرض هذه المرة الخادم الذي استجاب للطلب. يظهِر الطلبان الأولان أنهما جاءا من الخادم الذي يستمع على المنفذ 3333 أي serverOne، والطلبان الثانيان جاءا من الخادم الذي يستمع على المنفذ 4444 أي serverTwo. إنها القيم التي جرى استردادها من قيمة serverAddr في BaseContext. قد يكون الخرج مختلفًا قليلًا عن الخرج أعلاه اعتمادًا على ما إذا كان جهاز الحاسب المُستخدم يستخدم IPv6 أم لا. إذا كان الحاسب يستخدم IPv6 فسيكون الخرج كما أعلاه، وإلا سنرى 0.0.0.0 بدلًا من [::]. السبب في ذلك هو أن جهاز الحاسب سيتواصل مع نفسه عبر IPv6، و [::] هو تدوين IPv6 والذي يُقابل 0.0.0.0 في IPv4. بعد الانتهاء نضغط "CONTROL+C" لإنهاء الخادم. تعرفنا في هذا القسم على عملية إنشاء برنامج خادم HTTP وتحسينه تدريجيًا للتعامل مع السيناريوهات المختلفة. بدأنا بتهيئة الخادم الافتراضي باستخدام http.HandleFunc و http.ListenAndServe. بعد ذلك عدّلناه لاستخدام http.ServeMux مثل مجمّع خادم، مما يسمح لنا بالتعامل مع معالجات طلبات متعددة لمسارات مختلفة. وسّعنا البرنامج أيضًا لاستخدام http.Server، مما يمنحنا مزيدًا من التحكم في تهيئة الخادم ويسمح لنا بتشغيل خوادم HTTP متعددة في نفس الوقت داخل نفس البرنامج. على الرغم من أن البرنامج يعمل، إلا أنه يفتقر إلى التفاعل الذي يتجاوز تحديد المسارات المختلفة. لمعالجة هذا القيد، يركز القسم التالي على دمج قيم سلسلة الاستعلام query string values في وظائف الخادم، مما يتيح للمستخدمين التفاعل مع الخادم باستخدام معاملات الاستعلام. فحص سلسلة الاستعلام الخاصة بالطلب ينصب التركيز في هذا القسم على دمج قيم سلسلة الاستعلام في وظائف خادم HTTP. سلسلة الاستعلام هي مجموعة من القيم الملحقة بنهاية عنوان URL، تبدأ بالمحرف ? وتستخدم المُحدّد & للقيم الإضافية. توفر قيم سلسلة الاستعلام وسيلة للمستخدمين للتأثير على الاستجابة التي يتلقونها من خادم HTTP عن طريق تخصيص النتائج أو تصفيتها، فمثلًا قد يستخدم أحد الخوادم قيمة results للسماح للمستخدم بتحديد شيء مثل results = 10 ليقول إنه يرغب في رؤية 10 عناصر في قائمة النتائج. لتحقيق هذه الميزة نحتاج إلى تحديث دالة المعالجة getRoot في ملف "main.go" للوصول إلى قيم سلسلة الاستعلام http.Request* باستخدام التابع r.URL.Query، ثم طباعتها بعد ذلك على الخرج. نزيل أيضًا serverTwo وكل الشيفرات المرتبطة به من الدالة main، لأنها لم تعد مطلوبة للتغييرات القادمة: ... func getRoot(w http.ResponseWriter, r *http.Request) { ctx := r.Context() hasFirst := r.URL.Query().Has("first") first := r.URL.Query().Get("first") hasSecond := r.URL.Query().Has("second") second := r.URL.Query().Get("second") fmt.Printf("%s: got / request. first(%t)=%s, second(%t)=%s\n", ctx.Value(keyServerAddr), hasFirst, first, hasSecond, second) io.WriteString(w, "This is my website!\n") } ... يمكننا في دالة getRoot المحدَّثة استخدام الحقل r.URL الذي يتبع إلى http.Request* للوصول إلى الخصائص المتعلقة بعنوان URL المطلوب. باستخدام التابع Query في الحقل r.URL، يمكننا الوصول إلى قيم سلسلة الاستعلام المرتبطة بالطلب. هناك طريقتان يمكن استخدامهما للتفاعل مع بيانات سلسلة الاستعلام: يتحقق التابع Has ما إذا كانت سلسلة الاستعلام تحتوي على قيمة بمفتاح معين، مثل"first" أو "second". يعيد التابع قيمة بوليانية bool تشير إلى وجود المفتاح. يسترد التابع Get القيمة المرتبطة بمفتاح معين من سلسلة الاستعلام وتكون من نوع string، وإذا لم يعثر على المفتاح، يعيد عادةً سلسلة فارغة. نظريًّا يمكننا دائمًا استخدام Get لاسترداد قيم سلسلة الاستعلام لأنها ستعيد دائمًا إما القيمة الفعلية للمفتاح المحدد أو سلسلة فارغة إذا كان المفتاح غير موجود. هذا جيد في كثير من الحالات، ولكن في بعض الحالات الأخرى، قد نرغب في معرفة الفرق بين المستخدم الذي يقدم قيمة فارغة أو عدم تقديم قيمة إطلاقًا. إذًا، بناءً على حالة الاستخدام الخاصة بنا، قد يكون من المهم التمييز بين المستخدم الذي يقدم قيمة فارغة للمرشّح أو عدم تقديم مرشح filter إطلاقًا: يُقدّم المستخدم قيمة فارغة للمرشح filter: هنا يحدد المستخدم صراحة قيمة المرشح، ولكنه يضبطها على قيمة فارغة. قد يشير هذا إلى اختيار متعمد لاستبعاد نتائج معينة أو تطبيق شرط مرشحfilter معين. مثلًا، إذا كانت لدينا دالة بحث وقدم المستخدم قيمة فارغة للمرشح، فيمكننا تفسيرها على أنها "إظهار كافة النتائج". لا يوفر المستخدم مرشّح إطلاقًا: هنا لم يقدّم المستخدم مرشّحًا في الطلب. هذا يعني عادةً أن المستخدم يريد استرداد جميع النتائج دون تطبيق أي تصفية محددة. يمكن عدّه سلوكًا افتراضيًا، إذ لا تُطبّق شروط ترشيح محددة. يسمح لك استخدام Has و Get بالتمييز بين الحالات التي يقدم فيها المستخدم صراحة قيمةً فارغة والحالات التي لا تُقدّم فيها قيمة إطلاقًا. بالتالي إمكانية التعامل مع السيناريوهات المختلفة اعتمادًا على حالة الاستخدام المحددة الخاصة بنا. يمكنك تحديث دالة getRoot لعرض قيم Has و Get من أجل قيمتي سلسلة الاستعلام first و second. لنعدّل الدالة main بحيث نستخدم خادم واحد مرة أخرى: ... func main() { ... mux.HandleFunc("/hello", getHello) ctx := context.Background() server := &http.Server{ Addr: ":3333", Handler: mux, BaseContext: func(l net.Listener) context.Context { ctx = context.WithValue(ctx, keyServerAddr, l.Addr().String()) return ctx }, } err := server.ListenAndServe() if errors.Is(err, http.ErrServerClosed) { fmt.Printf("server closed\n") } else if err != nil { fmt.Printf("error listening for server: %s\n", err) } } نلاحظ ضمن الدالة main إزالة المراجع والشيفرات المرتبطة بالخادم الثاني serverTwo، لأننا لم نعد بحاجة إلى خوادم متعددة. نقلنا أيضًا تنفيذ الخادم (serverOne سابقًا) خارج التنظيم goroutine وإلى الدالة main. هذا يعني أنه سيجري بدء تشغيل الخادم بطريقة متزامنة، و ينتظر التنفيذ حتى يُغلق الخادم قبل المتابعة. تغيير آخر كان باستخدام الدالة server.ListenAndServe؛ فبدلًا من استخدام http.ListenAndServe، يمكن الآن استخدامserver.ListenAndServe لبدء الخادم. يتيح ذلك الاستفادة من تهيئة http.Server وأي تخصيصات أجريناها. كذلك أضفنا شيفرة لمعالجة الأخطاء، وذلك للتحقق ما إذا كان الخادم مغلقًا أو واجه أي خطأ آخر أثناء الاستماع. إذا كان الخطأ هو http.ErrServerClosed، فهذا يعني أن عملية الإغلاق عن قصد (إغلاق طبيعي)، وخلاف ذلك ستجري طباعة الخطأ. بإجراء هذه التغييرات سيُشغّل برنامجنا الآن خادم HTTP واحد باستخدام وفقًا لتهيئةhttp.Server ليبدأ في الاستماع للطلبات الواردة، ولن تحتاج إلى تحديثات أخرى من أجل تخصيصات للخادم مستقبلًا. لنُشغّل ملف البرنامج "main.go" بعد حفظه من خلال الأمر go run: $ go run main.go نُشغّل الآن أوامر curl (في الطرفية الثانية). هنا نحتاج إلى إحاطة عنوان URL بعلامات اقتباس مفردة (')، وإلا فقد تُفسر صدفة الطرفية (أي Shell) الرمز & في سلسلة الاستعلام على أنه ميزة "تشغيل الأمر في الخلفية". ضمن عنوان URL نضيف first=1 إلى first و =second إلى second: $ curl 'http://localhost:3333?first=1&second=' سيكون الخرج على النحو التالي: This is my website! لاحظ أن الخرج لم يتغير عن المرة السابقة، لكن إذا عدنا إلى خرج برنامج الخادم، فسنرى أن الخرج الجديد يتضمن قيم سلسلة الاستعلام: [::]:3333: got / request. first(true)=1, second(true)= يظهِر خرج قيمة سلسلة الاستعلام first أن التابع Has أعاد true لأن first لها قيمة، وأيضًا التابع Get أعاد القيمة 1. يُظهر ناتج second أنه قد أعاد true لأننا ضمّنّا second، لكن التابع Get لم يُعيد أي شيء إلا سلسلة فارغة. يمكننا أيضًا محاولة إجراء طلبات مختلفة عن طريق إضافة وإزالة first و second أو إسناد قيم مختلفة لنرى كيف تتغير النتائج. سيستمر البرنامج بالعمل، لذا يجب علينا إيقافه يدويًا من خلال الضغط على المفتاحين "Ctrl+C". حدّثنا في هذا القسم البرنامج لاستخدام http.Server واحد فقط مرةً أخرى، لكن أضفنا أيضًا دعمًا لقراءة قيم first و second من سلسلة الاستعلام لدالة المعالجة getRoot. ليس استخدام سلسلة الاستعلام الطريقة الوحيدة للمستخدمين لتقديم مدخلات إلى خادم HTTP، فهناك طريقة أخرى شائعة لإرسال البيانات إلى الخادم وهي تضمين البيانات في متن الطلب. سنعدّل في القسم التالي البرنامج لقراءة نص الطلب من بيانات http.Request*. قراءة متن الطلب عند إنشاء واجهة برمجة تطبيقات مبنية على HTTP، مثل واجهة برمجة تطبيقات REST، قد تكون هناك حالات تتجاوز فيها البيانات المُرسلة قيود تضمينها في عنوان URL نفسه، مثل الطول الأعظمي للعنوان. قد نحتاج أيضًا إلى تلقي بيانات لا تتعلق بكيفية تفسير البيانات، وهذا يشير إلى الحالات التي لا ترتبط فيها البيانات المرسلة في متن الطلب ارتباطًا مباشرًا بالمحتوى نفسه. بمعنى آخر: لا توفر هذه البيانات الإضافية إرشادات أو بيانات وصفية حول المحتوى، ولكنها تتضمن معلومات تكميلية تحتاج إلى المعالجة بطريقة منفصل. تخيل صفحة بحث، يمكن فيها للمستخدمين إدخال كلمات للبحث عن عناصر محددة. تمثل كلمات البحث نفسها تفسير المحتوى المقصود، ومع ذلك قد تكون هناك بيانات إضافية في متن الطلب، مثل تفضيلات المستخدم أو الإعدادات، والتي لا ترتبط مباشرةً باستعلام البحث نفسه ولكنها لا تزال بحاجة إلى النظر فيها أو معالجتها بواسطة الخادم. للتعامل مع مثل هذه السيناريوهات، يمكننا تضمين البيانات في متن طلب HTTP باستخدام توابع مثل POST أو PUT. تُستخدم قيمة http.Request* في http.HandlerFunc للوصول إلى معلومات متعلقة بالطلب الوارد، بما في ذلك متن الطلب، والذي يمكن الوصول إليه من خلال حقل Body. سنعدّل في هذا القسم دالة المعالجة getRoot لقراءة نص الطلب. لنفتح ملف main.go ونعدّل getRoot لاستخدام ioutil.ReadAll لقراءة حقلr.Body للطلب: package main import ( ... "io/ioutil" ... ) ... func getRoot(w http.ResponseWriter, r *http.Request) { ... second := r.URL.Query().Get("second") body, err := ioutil.ReadAll(r.Body) if err != nil { fmt.Printf("could not read body: %s\n", err) } fmt.Printf("%s: got / request. first(%t)=%s, second(%t)=%s, body:\n%s\n", ctx.Value(keyServerAddr), hasFirst, first, hasSecond, second, body) io.WriteString(w, "This is my website!\n") } ... تُستخدم الدالة ioutil.ReadAll لقراءة r.Body من http.Request* لاسترداد بيانات متن الطلب. ioutil.ReadAll هي أداة مساعدة تقرأ البيانات من io.Reader حتى تنتهي من القراءة أو ظهور خطأ. نظرًا لأن r.Body هو io.Reader، فيمكن استخدامه لقراءة متن الطلب. نعدّل العبارة fmt.Printf بعد قراءة النص لتضمين محتوى المتن في الخرج. لنُشغّل ملف البرنامج "main.go" بعد حفظه من خلال الأمر go run: $ go run main.go نُشغّل الآن أوامر curl (في الطرفية الثانية)، لتقديم طلب POST مع خيار X POST- وتحديد أن طريقة الطلب يجب أن تكون POST، ومتن طلب باستخدام الخيار b-. يُضبط متن الطلب على السلسلة النصيّة المقدمة، والتي في هذه الحالة هي "This is the body": $ curl -X POST -d 'This is the body' 'http://localhost:3333?first=1&second=' ليكون الخرج: This is my website! خرج دالة المعالجة هو نفسه، لكن سنرى تسجيلات الخادم الخاصة بك قد حُدّثت. سيُرسل طلب POST عندما نشغّل هذا الأمر إلى الخادم المحلي الذي يعمل على المنفذ 3333 مع محتوى المتن المحدد ومعلمات سلسلة الاستعلام. بالتالي يعالج الخادم الطلب ونرى الخرج في سجلات الطرفية للخادم، بما في ذلك قيم سلسلة الاستعلام ومتن الطلب. [::]:3333: got / request. first(true)=1, second(true)=, body: This is the body سيستمر البرنامج بالعمل، لذا يجب علينا إيقافه يدويًا من خلال الضغط على المفتاحين "Ctrl+C". عدّلنا في هذا القسم البرنامج لقراءة وطباعة متن الطلب. تفتح هذه الإمكانية إمكانيات التعامل مع أنواع مختلفة من البيانات، مثل جسون encoding/json، وتتيح إنشاء واجهات برمجة تطبيقات يمكنها التفاعل مع بيانات المستخدم بطريقة مألوفة. تجدر الملاحظة إلى أنه لا تأتي جميع بيانات المستخدم في شكل واجهات برمجة التطبيقات. تتضمن العديد من مواقع الويب نماذج يملؤها المستخدمون، والتي ترسل البيانات إلى الخادم مثل بيانات نموذج Form. سنعمل على تحسين البرنامج في القسم التالي ليشمل القدرة على قراءة بيانات النموذج ومعالجتها، بالإضافة إلى جسم الطلب وبيانات سلسلة الاستعلام التي كنا نعمل معها سابقًا. استرجاع بيانات النموذج لطالما كان إرسال البيانات باستخدام النماذج أو الاستمارات هو الطريقة القياسية للمستخدمين لإرسال البيانات إلى خادم HTTP والتفاعل مع مواقع الويب، وعلى الرغم من انخفاض شعبية النماذج بمرور الوقت، إلا أنها لا تزال تخدم أغراضًا مختلفة لتقديم البيانات. توفر قيمة http.Request* في http.HandlerFunc طريقةً للوصول إلى هذه البيانات، بطريقة مشابهة للطريقة التي توفر بها الوصول إلى سلسلة الاستعلام ومتن الطلب. سنعدّل في هذا القسم الدالة getHello لتلقي اسم مستخدم من نموذج والرد بتحية شخصية. لنفتح ملف "main.go" ونعدّل getHello لاستخدام التابع PostFormValue من http.Request*: ... func getHello(w http.ResponseWriter, r *http.Request) { ctx := r.Context() fmt.Printf("%s: got /hello request\n", ctx.Value(keyServerAddr)) myName := r.PostFormValue("myName") if myName == "" { myName = "HTTP" } io.WriteString(w, fmt.Sprintf("Hello, %s!\n", myName)) } ... الآن في دالة getHello المحدَّثة، تجري قراءة قيم النموذج المرسلة إلى دالة المعالجة والبحث عن قيمة تسمى myName. إذا لم يُعثر على القيمة أو كانت سلسلة فارغة، تُضبط القيمة الافتراضية HTTP إلى المتغير myName لمنع عرض اسم فارغ على الصفحة. نعدّل أيضًا الخرج المرسل إلى المستخدم لكي يعرض الاسم الذي قدمه أو HTTP إذا لم يُقدّم أي اسم. لنُشغّل ملف البرنامج "main.go" بعد حفظه من خلال الأمر go run: $ go run main.go نُشغّل الآن أوامر curl (في الطرفية الثانية)، لتقديم طلب POST مع خيار X POST-، وبدلًا من استخدام الخيار b-، نستخدم الخيار 'F 'myName=Sammy لتضمين بيانات النموذج مع حقل myName والقيمة Sammy: curl -X POST -F 'myName=Sammy' 'http://localhost:3333/hello' سيكون الخرج: Hello, Sammy! يُستخدم التابع r.PostFormValue في getHello لاسترداد قيمة حقل النموذج myName. يبحث هذا التابع تحديدًا عن القيم المنشورة (البيانات المرسلة من العميل إلى الخادم) في متن الطلب. يمكن أيضًا استخدام التابع r.FormValue، والذي يتضمن كلًا من متن النموذج وأي قيم أخرى في سلسلة الاستعلام. إذا استخدمنا ("r.FormValue("myName وأزلنا الخيار F-، فيمكن تضمين myName = Sammy في سلسلة الاستعلام لرؤية القيمة Sammy. يوصى عمومًا بأن نكون أكثر صرامة وأن نستخدم الدالة r.PostFormValue إذا كنا نريد تحديدًا استرداد القيم من متن النموذج، إذ تتجنب هذه الدالة التعارضات أو الأخطاء المحتملة التي قد تنشأ عن خلط قيم النموذج من مصادر مختلفة. إذًا، تعد الدالة r.PostFormValue خيارًا أكثر أمانًا عند التعامل مع بيانات النموذج، لكن إذا كنا نحتاج إلى المرونة في وضع البيانات في كل من متن النموذج وسلسلة الاستعلام، فربما نضطر إلى الدالة الأخرى. عند النظر إلى سجلات الخادم، سنرى أن طلب hello/ قد جرى تسجيله بطريقة مشابهة للطلبات السابقة: [::]:3333: got /hello request سيستمر البرنامج بالعمل، لذا يجب علينا إيقافه يدويًا من خلال الضغط على المفتاحين "CONTROL+C". عدّلنا في هذا القسم البرنامج لقراءة اسم من بيانات النموذج المنشورة على الصفحة ثم إعادة هذا الاسم إلى المستخدم. يمكن أن تسوء بعض الأشياء في هذه المرحلة من البرنامج، لا سيما عند التعامل مع أحد الطلبات، ولن يجري إعلام المستخدمين. لنعدّل في القسم التالي دوال المعالجة لإرجاع رموز حالة HTTP والترويسات headers. الرد باستجابة تتضمن الترويسات ورمز الحالة هناك بعض الميزات المستخدمة خلف الكواليس ضمن بروتوكول HTTP لتسهيل الاتصال الفعّال بين المتصفحات والخوادم. إحدى هذه الميزات هي "رموز الحالة"، والتي تعمل مثل وسيلة للخادم لتوضّح للعميل ما إذا كان الطلب ناجحًا أو واجه أي مشكلات في أي من الطرفين. آلية اتصال أخرى تستخدمها خوادم وعملاء HTTP هي استخدام "حقول الترويسة header fields"، التي تتكون من أزواج ذات قيمة مفتاح يجري تبادلها بين العميل والخادم لنقل المعلومات عن أنفسهم. يمتلك بروتوكول HTTP عدة ترويسات معرّفة مسبقًا، مثل ترويسة Accept، التي يستخدمها العميل لإعلام الخادم بنوع البيانات التي يمكنه التعامل معها. يمكن أيضًا تعريف ترويسات خاصة باستخدام البادئة x- متبوعة بالاسم المطلوب. سنعمل في هذا القسم على تحسين البرنامج بجعل حقل النموذج myName في دالة المعالجة getHello حقلًا إلزاميًا. بالتالي، إذا لم تُعطى قيمة للحقل myName، فسيستجيب الخادم للعميل برمز الحالة "Bad Request طلب غير صالح" وتضمين الترويسة x-missing-field في الاستجابة، والتي تُعلم العميل بالحقل المفقود من الطلب. لنفتح ملف "main.go" ونعدّل الدالة getHello وفقًا لما ذُكر: ... func getHello(w http.ResponseWriter, r *http.Request) { ctx := r.Context() fmt.Printf("%s: got /hello request\n", ctx.Value(keyServerAddr)) myName := r.PostFormValue("myName") if myName == "" { w.Header().Set("x-missing-field", "myName") w.WriteHeader(http.StatusBadRequest) return } io.WriteString(w, fmt.Sprintf("Hello, %s!\n", myName)) } … سابقًا: إذا كان حقل myName فارغًا، كان يُسند إليه قيمة افتراضية، أما الآن ضمن الدالة getHello المُحدَّثة، نُرسل رسالة خطأ إلى العميل. يُستخدم بدايةً التابع w.Header().Set لضبط الترويسة x-missing-field بقيمة myName في ترويسة الاستجابة، ثم التابع w.WriteHeader لكتابة ترويسات الاستجابة ورمز الحالة "طلب غير صالح" إلى العميل. أخيرًا تُستخدم تعليمة return لضمان إنهاء الدالة وعدم إرسال استجابة إضافية. من الضروري التأكد من ضبط الترويسات وإرسال رمز الحالة بالترتيب الصحيح، إذ يجب إرسال جميع الترويسات في بروتوكول HTTP قبل الجسم، مما يعني أنه يجب إجراء أي تعديلات على ()w.Header قبل استدعاء w.WriteHeader. عند استدعاء w.WriteHeader يُرسل رمز الحالة والترويسات، ويمكن كتابة المتن بعد ذلك حصرًا. يجب اتباع هذا الأمر لضمان حسن سير استجابة HTTP. لنُشغّل ملف البرنامج "main.go" بعد حفظه من خلال الأمر go run: $ go run main.go نُشغّل الآن الأمر curl -X POST (في الطرفية الثانية) مع المسار hello/ وبدون تضمين F- لإرسال بيانات النموذج. نحتاج أيضًا إلى تضمين الخيار v- لإخبار curl بإظهار الخرج المطوّل حتى نتمكن من رؤية جميع الترويسات والخرج للطلب: curl -v -X POST 'http://localhost:3333/hello' سنرى هذه المرة الكثير من المعلومات بسبب استخدامنا الخيار v-: * Trying ::1:3333... * Connected to localhost (::1) port 3333 (#0) > POST /hello HTTP/1.1 > Host: localhost:3333 > User-Agent: curl/7.77.0 > Accept: */* > * Mark bundle as not supporting multiuse < HTTP/1.1 400 Bad Request < X-Missing-Field: myName < Date: Wed, 02 Mar 2022 03:51:54 GMT < Content-Length: 0 < * Connection #0 to host localhost left intact تشير الأسطر الأولى إلى أن curl تحاول إنشاء اتصال بالخادم عند منفذ المضيف المحلي 3333. الأسطر المسبوقة بالرمز < تمثل الطلب المُرسل بواسطة curl، إذ يُرسل طلب POST إلى العنوان أو المسار hello/ باستخدام بروتوكول HTTP 1.1. يتضمن الطلب ترويسات مثل User-Agent و Accept و Host. والجدير بالذكر أن الطلب لا يتضمن متنًا، كما هو موضح في السطر الفارغ. تظهر استجابة الخادم بالسابقة > بعد إرسال الطلب. يشير السطر الأول إلى أن الخادم قد استجاب برمز حالة "طلب غير صالح" (المعروفة برمز الحالة 400)، كما تُضمّن الترويسة X-Missing-Field التي جرى ضبطها في ترويسة استجابة الخادم، مع تحديد أن الحقل المفقود هو myName. ينتهي الرد بدون أي محتوى في المتن، وهذا ما يتضح من طول المحتوى 0. إذا نظرنا مرةً أخرى إلى خرج الخادم، فسنرى طلب hello/ للخادم الذي جرت معالجته في الخرج: [::]:3333: got /hello request سيستمر البرنامج بالعمل، لذا يجب علينا إيقافه يدويًا من خلال الضغط على المفتاحين "CONTROL+C". عدّلنا في هذا القسم خادم HTTP ليشمل التحقق من صحة إرسال الطلب hello/. إذا لم يُقدّم اسم في في الطلب، تُضبط ترويسة باستخدام التابع w.Header(). Set للإشارة إلى الحقل المفقود. يُستخدم التابع w.WriteHeader بعد ذلك، لكتابة الترويسات إلى العميل، جنبًا إلى جنب مع رمز الحالة التي تشير إلى "طلب غير صالح". إذًا يُخبر الخادم العميل بوجود مشكلة في الطلب من خلال ضبط الترويسة ورمز الحالة. يسمح هذا الأسلوب بمعالجة الأخطاء بطريقة صحيحة ويوفر ملاحظات للعميل فيما يتعلق بحقل النموذج المفقود. الخاتمة تعلمنا كيفية إنشاء خادم HTTP في لغة جو باستخدام حزمة net/http، وطوّرنا الخادم باستخدام مجمّع مخصص للخادم واستخدام نسخhttp.Server متعددة. اسكتشفنا أيضًا طرقًا مختلفة للتعامل مع مُدخلات المستخدم، مثل قيم سلسلة الاستعلام ومتن الطلب وبيانات النموذج. تحققنا أيضًا من صحة الطلب من خلال إعادة ترويسات HTTP مخصصة ورموز حالة إلى العميل. تتمثل إحدى نقاط القوة في نظام بروتوكول HTTP في توافقه مع العديد من الأطر التي تتكامل بسلاسة مع حزمة net/http. توضح مشاريع مثل github.com/go-chi/chi ذلك من خلال توسيع وظائف خادم http القياسي من خلال تحقيق الواجهة http.Handler. يتيح ذلك للمطورين الاستفادة من البرامج الوسيطة والأدوات الأخرى دون الحاجة لإعادة كتابة المنطق الخاص الخادم، وهذا يسمح بالتركيز على إنشاء برمجيات وسيطة middleware وأدوات أخرى لتحسين الأداء بدلًا من التعامل مع الوظائف الأساسية فقط. توفر حزمة net/http المزيد من الإمكانات التي لم نتناولها في هذا المقال، مثل العمل مع ملفات تعريف الارتباط وخدمة حركة مرور HTTPS. ترجمة -وبتصرف- للمقال How To Make an HTTP Server in Go لصاحبه Kristin Davidson. اقرأ أيضًا المقال السابق كيفية استخدام صيغة جسون JSON في لغة جو Go. مدخل إلى خادم الويبن. مدخل إلى HTTP: شرح التخاطب بين العميل والخادم. رموز الحالة status codes.
  4. إحدى الخصائص المهمة في البرامج الحديثة هو إمكانية التواصل مع البرامج الأخرى، سواءٌ كان برنامج جو يتحقق ما إذا كان لدى المستخدم حق الوصول إلى برنامج آخر، أو برنامج جافا سكريبت JavaScript يحصل على قائمة بالطلبات السابقة لعرضها على موقع ويب، أو برنامج رست Rust يقرأ نتائج اختبار من ملف، فهناك حاجة إلى طريقة نزوّد البرامج من خلالها بالبيانات. لدى أغلب لغات البرمجة طريقة خاصة في تخزين البيانات داخليًّا، والتي لا تفهمها اللغات البرمجية الأخرى. للسماح لهذه اللغات بالتفاعل مع بعضها، يجب تحويل البيانات إلى تنسيق أو صيغة مشتركة يمكنهم فهمها جميعًا. إحدى هذه الصيغ هي صيغة جسون JSON، إنها وسيلة شائعة لنقل البيانات عبر الإنترنت وكذلك بين البرامج في نفس النظام. تمتلك لغة جو والعديد من لغات البرمجة الأخرى طريقة لتحويل البيانات من وإلى صيغة جسون في مكتباتها القياسية. سنُنشئ في هذا المقال برنامجًا يستخدم حزمة encoding/json لتحويل صيغة البيانات من النوع map إلى بيانات جسون، ثم من النوع struct إلى جسون، كما سنتعلم كيفية إجراء تحويل عكسي. المتطلبات إصدار مُثبّت من جو 1.16 أو أعلى، ويمكنك الاستعانة بمقال تثبيت لغة جو Go وإعداد بيئة برمجة محلية على أبونتو Ubuntu لإعداده. تثبيت لغة جو وإعداد بيئة برمجة محلية على نظام ماك macOS. تثبيت لغة جو وإعداد بيئة برمجة محلية على ويندوز. (اختياري) معرفة بكيفية التعامل مع التاريخ والوقت في لغة جو. يمكنك الاطلاع على مقالة استخدام التاريخ والوقت في لغة جو Go. معرفة مسبقة بصيغةجسون. معرفة مسبقة بكيفية التعامل مع وسوم البنية Struct tags لتخصيص حقول البنية. يمكنك الاطلاع على مقال استخدام وسوم البنية Struct Tags في لغة جو. استخدام الروابط Maps لتوليد بيانات بصيغة JSON توفر حزمة encoding/json بعض الدوال لتحويل البيانات من وإلى جسون JSON. الدالة الأولى هي json.Marshal. التنظيم Marshaling أو المعروف أيضًا باسم السلسلة Serialization، هو عملية تحويل بيانات البرنامج من الذاكرة إلى تنسيق يمكن نقله أو حفظه في مكان آخر، وهذا ما تفعله الدالة json.Marshal في لغة جو، إذ تحول بيانات البرنامج قيد التشغيل (الموجود في الذاكرة) إلى بيانات جسون. تقبل هذه الدالة أي قيمة من النوع واجهة {}interface لتنظيمها بصيغة جسون، لذلك يُسمح بتمرير أي قيمة مثل معامل، لتُعيد الدالة البيانات ممثلة بصيغة جسون في النتيجة. سننشئ في هذا القسم برنامجًا يستخدم دالة json.Marshal لإنشاء ملف جسون يحتوي أنواعًا مختلفة من البيانات من قيم map، ثم سنطبع هذه القيم على شاشة الخرج. تُمثّل بيانات جسون غالبًا على شكل كائن مفاتيحه من سلاسل نصيّة وقيمه من أنواع مختلفة، وهذا يُشبه آلية تمثيل البيانات في روابط جو، لذا فإن الطريقة الأفضل لإنشاء بيانات جسون في لغة جو هي وضع البيانات ضمن رابطة map مع مفاتيح من النوع string وقيم من النوع {}interface. تٌفسّر مفاتيح map مباشرةً على أنها مفاتيح جسون، ويمكن أن تكون قيم النوع interface أي نوع بيانات في لغة جو، مثل int، أو string، أو حتى {}map[string]interface لبدء استخدام الحزمة encoding/json، وكما هو معتاد، سنحتاج لبدء إنشاء برامجنا إلى إنشاء مجلد للعمل ووضع الملفات فيه، ويمكن وضع المجلد في أي مكان على الحاسب، إذ يكون للعديد من المبرمجين عادةً مجلدٌ يضعون داخله كافة مشاريعهم. سنستخدم في هذا المقال مجلدًا باسم "projects"، لذا فلننشئ هذا المجلد وننتقل إليه: $ mkdir projects $ cd projects الآن، من داخل هذا المجلد، سنشغّل الأمر mkdir لإنشاء مجلد "jsondata" ثم سنستخدم cd للانتقال إليه: $ mkdir jsondata $ cd jsondata يمكننا الآن فتح ملف "main.go" باستخدام محرر نانو nano أو أي محرر آخر تريده: $ nano main.go نضيف داخل ملف "main.go" دالة main لتشغيل البرنامج، ثم نضيف قيمة {}map[string]interface مع مفاتيح وقيم من أنواع مختلفة، ثم نستخدم الدالة json.Marshal لتحويل بيانات map إلى بيانات جسون: package main import ( "encoding/json" "fmt" ) func main() { data := map[string]interface{}{ "intValue": 1234, "boolValue": true, "stringValue": "hello!", "objectValue": map[string]interface{}{ "arrayValue": []int{1, 2, 3, 4}, }, } jsonData, err := json.Marshal(data) if err != nil { fmt.Printf("could not marshal json: %s\n", err) return } fmt.Printf("json data: %s\n", jsonData) } نلاحظ في المتغير data أن كل قيمة لديها مفتاح string، لكن قيم هذه المفاتيح تختلف، فأحدها int وأحدها bool والأخرى هي رابط {}map[string]interface مع قيم int بداخلها. عند تمرير المتغير data إلى json.Marshal، ستنظر الدالة إلى جميع القيم التي يتضمنها الرابط وتحدد نوعها وكيفية تمثيلها في جسون، وإذا حدثت مشكلة في تفسير بيانات الرابط، ستُعيد خطأ يصف المشكلة. إذا نجحت العملية، سيتضمّن المتغير jsonData بيانات من النوع byte[] تُمثّل البيانات التي جرى تنظيمها إلى صيغة جسون. بما أن byte[] يمكن تحويلها إلى قيمة string باستخدام (myString := string(jsonData أو العنصر النائب s% ضمن تنسيق سلسلة، يمكننا طباعة بيانات جسون على شاشة الخرج باستخدام دالة الطباعة fmt.Printf. بعد حفظ وإغلاق الملف، لنُشغّل ملف البرنامج "main.go" من خلال الأمر go run: $ go run main.go سيكون الخرج على النحو التالي: json data: {"boolValue":true,"intValue":1234,"objectValue":{"arrayValue":[1,2,3,4]},"stringValue":"hello!"} نلاحظ من الخرج أن قيمة جسون هي كائن مُمثّل بأقواس معقوصة curly braces {} تحيط به، وأن جميع قيم المتغير data موجودة ضمن هذين القوسين. نلاحظ أيضًا أن رابط المفتاح objectValue الذي هو {}map[string]interface قد جرى تفسيره إلى كائن جسون آخر مُمثّل بأقواس معقوصة {} تحيط به أيضًا، ويتضمن أيضًا المفتاح arrayValue بداخله مع مصفوفة القيم المقابلة [1،2،3،4]. ترميز البيانات الزمنية في جسون لا تقتصر قدرات الحزمة encoding/json على إمكانية تمثيل البيانات من النوع string و int، إذ يمكنها التعامل مع أنواع أعقد مثل البيانات الزمنية من النوع time.Time من الحزمة time. لنفتح ملف البرنامج "main.go" مرةً أخرى ونضيف قيمة من النوع time.Time باستخدام الدالة time.Date: package main import ( "encoding/json" "fmt" "time" ) func main() { data := map[string]interface{}{ "intValue": 1234, "boolValue": true, "stringValue": "hello!", "dateValue": time.Date(2022, 3, 2, 9, 10, 0, 0, time.UTC), "objectValue": map[string]interface{}{ "arrayValue": []int{1, 2, 3, 4}, }, } ... } يؤدي هذا التعديل إلى ضبط التاريخ على March 2, 2022 والوقت على ‎9:10:00 AM في المنطقة الزمنية UTC وربطهم بالمفتاح dateValue. لنُشغّل ملف البرنامج "main.go" من خلال الأمر go run: $ go run main.go سيعطي الخرج التالي: json data: {"boolValue":true,"dateValue":"2022-03-02T09:10:00Z","intValue":1234,"objectValue":{"arrayValue":[1,2,3,4]},"stringValue":"hello!"} نلاحظ هذه المرة في الخرج الحقل dateValue ضمن بيانات جسون، وأن الوقت مُنسّقٌ وفقًا لتنسيق RFC 3339، وهو تنسيق شائع يُستخدم لنقل التواريخ والأوقات على أنها قيم string. ترميز قيم Null في جسون قد نحتاج إلى التعامل مع قيم null، وتحويلها إلى صيغة جسون أيضًا. يمكن لحزمة encoding/json تولي هذه المهمة أيضًا، إذ يمكننا التعامل مع قيم nil (تُقابل قيم null) مثل أي قيمة من نوع آخر ضمن الرابط. لنفتح ملف "main.go" ولنضع قيمتي null ضمن الرابط: ... func main() { data := map[string]interface{}{ "intValue": 1234, "boolValue": true, "stringValue": "hello!", "dateValue": time.Date(2022, 3, 2, 9, 10, 0, 0, time.UTC), "objectValue": map[string]interface{}{ "arrayValue": []int{1, 2, 3, 4}, }, "nullStringValue": nil, "nullIntValue": nil, } ... } وضعنا في الشيفرة أعلاه قيمتي null مع مفتاحين مختلفين، هما nullStringValue و nullIntValue على التوالي، وعلى الرغم من أن أسماء المفاتيح تُشير إلى قيم string و int، لكن هي ليست كذلك (مجرد أسماء). طبعًا كل القيم ضمن الرابط مُشتقة من النوع {}interface والقيمة nil هي قيمة مُحتملة لهذا النوع وبالتالي تُفسّر على أنها null فقط، وهذا كل شيء. لنُشغّل ملف البرنامج "main.go" من خلال الأمر go run: $ go run main.go سيكون الخرج كما يلي: json data: {"boolValue":true,"dateValue":"2022-03-02T09:10:00Z","intValue":1234,"nullIntValue":null,"nullStringValue":null,"objectValue":{"arrayValue":[1,2,3,4]},"stringValue":"hello!"} نلاحظ في الخرج أن الحقلين nullIntValue و nullStringValue مُضمنان مع قيمة null لكل منهما، بالتالي تمكنا من استخدام {}map[string]interface مع قيم null دون أية مشاكل. أنشأنا في هذا القسم برنامجًا يمكنه تحويل قيم بيانات من النوع {}map[string]interface إلى بيانات جسون. أضفنا بعد ذلك حقلًا يستخدم بيانات زمنية من النوع time.Time ضمن الرابط، وأخيرًا أضفنا حقلين يستخدمان القيمة null. بالرغم من مرونة استخدام {}map[string]interface لتحويل البيانات إلى بيانات جسون، إلا أنه قد يكون عُرضةً لحدوث أخطاء غير مقصودة، ولا سيما إذا كنا بحاجة إلى إرسال نفس البيانات إلى عدة أماكن. إذا أرسلنا نُسخًا من هذه البيانات إلى أكثر من مكان ضمن الشيفرة، فربما نُغيّر عن طريق الخطأ اسم حقل أو نضع قيمة غير صحيحة ضمن حقل. في هكذا حالات ربما يكون من المفيد استخدام النوع struct لتمثيل البيانات التي نُريد تحويلها إلى جسون. استخدام البنى Structs لتوليد بيانات بصيغة جسون تُعَدّ جو لغةً ثابتة الأنواع statically-typed language مثل لغة C وجافا Java و ++C، وهذا يعني أن كل تعليمة في البرنامج تُفحَص في وقت التصريف. تتمثل فائدة ذلك في السماح للمُصرّف باستنتاج نوع المتغيرات والتحقق منها وفرض التناسق بين قيم المتغيرات. تستفيد الحزمة encoding/json من ذلك من خلال تعريف بنية struct تُمثّل بيانات جسون. يمكننا التحكم في كيفية تفسير البيانات التي تتضمنها البنية باستخدام وسوم البنية Struct tags. سنعدّل البرنامج السابق خلال هذا القسم، لاستخدام بنية بدلًا من رابط، لتوليد بيانات جسون. عند استخدام struct لتعريف بيانات جسون، يجب علينا تصدير أسماء الحقول (وليس اسم النوع struct نفسه) التي نريد تحويلها إلى جسون، وذلك fأن نبدأ أسماء الحقول بحرف كبير (أي بدلًا من كتابة intValue نكتب IntValue) وإلا لن تكون الحزمة encoding/json قادرةً على الوصول إلى هذه الحقول لتحويلها إلى جسون. الآن بالنسبة لأسماء الحقول، إذا لم نستخدم وسوم البنية للتحكم في تسمية هذه الحقول، ستُفسّر كما هي مباشرةً ضمن البنية. قد يكون استخدام الأسماء الافتراضية هو ما نريده في بيانات جسون، وذلك وفقًا للطريقة التي نرغب بها بتنسيق بياناتنا، وبذلك لن نحتاج في هذه الحالة إلى أية وسوم. يستخدم العديد من المبرمجين تنسيقات أسماء، مثل intValue، أو int_value مع حقول البيانات، وسنحتاج في هذه الحالة إلى وسوم البنية للتحكم في كيفية تفسير هذه الأسماء. سيكون لدينا في المثال التالي بنية struct مع حقل وحيد اسمه IntValue وسنحول هذه البنية إلى صيغة جسون: type myInt struct { IntValue int } data := &myInt{IntValue: 1234} إذا حوّلنا المتغير data إلى صيغة جسون باستخدام الدالة json.Marshal، سنرى الخرج التالي: {"IntValue":1234} لكن لو كنا نريد أن يكون اسم الحقل في ملف جسون هو intValue بدلًا من IntValue، سنحتاج إلى إخبار encoding/json بذلك. بما أن json.Marshal لا تعرف ماذا نتوقع أن يكون اسم بيانات جسون، سنحتاج إلى إخبارها من خلال إضافة وسم البنية json بعد اسم الحقل مباشرةً مع إرفاقه بالاسم الذي نريد أن يظهر به في صيغة جسون. إذًا، من خلال إضافة هذا الوسم إلى الحقل IntValue مع الاسم الذي نريد يظهر به intValue، ستستخدم الدالة json.Marshal الاسم الذي نريده اسمًا للحقل ضمن صيغة جسون: type myInt struct { IntValue int `json:"intValue"` } data := &myInt{IntValue: 1234} إذا حوّلنا المتغير data إلى صيغة جسون باستخدام الدالة json.Marshal، سنرى الخرج التالي، وكما نلاحظ فإنه يستخدم اسم الحقل الذي نريده: {"intValue":1234} سنعدل البرنامج الآن لتعريف نوع بيانات struct يُمكن تحويله إلى بيانات جسون. سنضيف بنيةً باسم myJSON لتمثيل البيانات بطريقة يمكن تحويلها إلى جسون ونضيف البنية myObject التي ستكون قيمة للحقل ObjectValue ضمن البنية myJSON. سنضيف أيضًا وسمًا لكل اسم حقل ضمن البنية myJSON لتحديد الاسم الذي نريد أن يظهر به الحقل ضمن بيانات جسون. يجب أيضًا أن نحدّث الإسناد الخاص بالمتغير dataبحيث نسند له بنية myJSON مع التصريح عنه بنفس الطريقة التي نتعامل مع بنى جو الأخرى. ... type myJSON struct { IntValue int `json:"intValue"` BoolValue bool `json:"boolValue"` StringValue string `json:"stringValue"` DateValue time.Time `json:"dateValue"` ObjectValue *myObject `json:"objectValue"` NullStringValue *string `json:"nullStringValue"` NullIntValue *int `json:"nullIntValue"` } type myObject struct { ArrayValue []int `json:"arrayValue"` } func main() { otherInt := 4321 data := &myJSON{ IntValue: 1234, BoolValue: true, StringValue: "hello!", DateValue: time.Date(2022, 3, 2, 9, 10, 0, 0, time.UTC), ObjectValue: &myObject{ ArrayValue: []int{1, 2, 3, 4}, }, NullStringValue: nil, NullIntValue: &otherInt, } ... } تُشبه معظم التغييرات في الشيفرة أعلاه ما فعلناه في المثال السابق مع الحقل IntValue، إلا أن هناك بعض الأشياء تستحق الإشارة إليها. أحد هذه الأشياء هو الحقل ObjectValue الذي يستخدم قيمةً مرجعية myObject* لإخبار دالة json.Marshal -التي تؤدي عملية التنظيم- إلى وجود قيمة مرجعية من النوع myObject أو قيمة nil. بهذه الطريقة نكون قد عرّفنا كائن جسون بأكثر من طبقة، وفي حال كانت هذه الطريقة مطلوبة، سيكون لدينا بنيةً أخرى من نوع struct داخل النوع myObject، وهكذا، وبالتالي نلاحظ أنه بإمكاننا تعريف كائنات جسون أعقد وأعقد باستخدام أنواع struct وفقًا لحاجتنا. واحد من الأشياء الأخرى التي تستحق الذكر هي الحقلين NullStringValue و NullIntValue، وعلى عكس StringValue و IntValue؛ أنواع هذه القيم هي أنواع مرجعية int* و string*، وقيمها الافتراضية هي قيم صفريّة أي nil وهذا يُقابل القيمة 0 لنوع البيانات int والسلسلة الفارغة '' لنوع البيانات string. يمكننا من الكلام السابق أن نستنتج أنه في حال أردنا التعبير عن قيمة من نوع ما تحتمل أن تكون nil، فيجب أن نجعلها قيمةً مرجعية. مثلًا لو كنا نريد أن نعبر عن قيمة حقل تُمثّل إجابة مُستخدم عن سؤال ما، فهنا قد يُجيب المُستخدم عن السؤال أو قد لا يُجيب (نضع niil). نُعدّل قيمة الحقل NullIntValue بضبطه على القيمة 4321 لنُظهر كيف يمكن إسناد قيمة لنوع مرجعي مثل int*. تجدر الإشارة إلى أنه في لغة جو، يمكننا إنشاء مراجع لأنواع البيانات الأولية primitive types فقط، مثل int و string باستخدام المتغيرات. إذًا، لإسناد قيمة إلى الحقل NullIntValue، نُسند أولًا قيمةً إلى متغير آخر otherInt، ثم نحصل على مرجع منه otherInt& (بدلًا من كتابة 4321& مباشرةً). لنُشغّل ملف البرنامج "main.go" من خلال الأمر go run: $ go run main.go سيكون الخرج على النحو التالي: json data: {"intValue":1234,"boolValue":true,"stringValue":"hello!","dateValue":"2022-03-02T09:10:00Z","objectValue":{"arrayValue":[1,2,3,4]},"nullStringValue":null,"nullIntValue":4321} نلاحظ أن هذا الناتج هو نفسه عندما استخدمنا {}map[string]interface، باستثناء أن قيمة nullIntValue هذه المرة هي 4321 لأن هذه هي قيمة otherInt. يستغرق الأمر في البداية بعض الوقت لتعريف البنية struct وإعداد حقولها، ولكن يمكننا بعد ذلك استخدامها مرارًا وتكرارًا في الشيفرة، وستكون النتيجة هي نفسها بغض النظر عن مكان استخدامها، كما يمكننا تعديلها من مكان واحد بدلًا من تعديلها في كل مكان تتواجد نسخة منها فيه كما في حالة الروابط map. تتيح لنا الدالة json.Marshal إمكانية تحديد الحقول التي نُريد تضمينها في جسون، في حال كانت قيمة تلك الحقول صفريّة (أي Null وهذا يُكافئ 0 في حالة int و false في حالة bool والسلسلة الفارغة في حالة string ..إلخ). قد يكون لدينا أحيانًا كائن جسون كبير أو حقول اختيارية لا نريد تضمينها دائمًا في بيانات جسون، لذا يكون تجاهل هذه الحقول مفيدًا. يكون التحكم في تجاهل هذه الحقول -عندما تكون قيمها صفريّة أو غير صفريّة- باستخدام الخيار omitempty ضمن وسم بنية json. لنُحدّث البرنامج السابق لإضافة الخيار omitempty إلى حقل NullStringValue وإضافة حقل جديد يسمى EmptyString مع نفس الخيار: ... type myJSON struct { ... NullStringValue *string `json:"nullStringValue,omitempty"` NullIntValue *int `json:"nullIntValue"` EmptyString string `json:"emptyString,omitempty"` } ... بعد تحويل البنية myJSON إلى بيانات جسون، سنلاحظ أن الحقل EmptyString والحقل NullStringValue غير موجودان في بيانات جسون، لأن قيمهما صفريّة. لنُشغّل ملف البرنامج "main.go" من خلال الأمر go run: $ go run main.go سيكون الخرج على النحو التالي: json data: {"intValue":1234,"boolValue":true,"stringValue":"hello!","dateValue":"2022-03-02T09:10:00Z","objectValue":{"arrayValue":[1,2,3,4]},"nullIntValue":4321} نلاحظ أن الحقل nullStringValue لم يعد موجودًا في الخرج، لأنه يُعد حقلًا بقيمة nil، بالتالي فإن الخيار omitempty استبعده من الخرج. نفس الأمر بالنسبة للحقل emptyString، لأن قيمته صفريّة (القيمة الصفريّة تُكافئ nil كما سبق وذكرنا). استخدمنا في هذا القسم أسلوب البُنى struct بدلًا من الروابط، لتمثيل كائن جسون في برنامجنا، ثم حولناها إلى بيانات جسون باستخدام الدالة json.Marshal. تعلمنا أيضًا كيفية تجاهل القيم الصفريّة من بيانات جسون. هذه البيانات (بعد تحويلها لصيغة جسون) قد تُرسل إلى برامج أخرى، وبالتالي إذا كان البرنامج الآخر مكتوب بلغة جو، يجب أن نعرف كيف يمكننا قراءة هذا النوع من البيانات. يمكنك أن تتخيل الأمر على أنه برنامجي جو A و B أحدهما مُخدم والآخر عميل. يُرسل A طلبًا إلى B، فيعالجه ويرسله بصيغة جسون إلى A، ثم يقرأ A هذه البيانات. لأجل ذلك توفر الحزمة encoding/json طريقةً لفك ترميز بيانات جسون وتحويلها إلى أنواع جو المقابلة (مجرد عملية عكسية). سنتعلم في القسم التالي كيفية قراءة بيانات جسون وتحويلها إلى روابط. تحليل بيانات جسون باستخدام الروابط بطريقة مشابهة لما فعلناه عندما استخدمنا {}map[string]interface مثل طريقة مرنة لتوليد بيانات جسون، يمكننا استخدامها أيضًا مثل طريقة مرنة لقراءة بيانات جسون. تعمل الدالة json.Unmarshal بطريقة معاكسة للدالة json.Marshal، إذ تأخذ بيانات جسون وتحولها إلى بيانات جو، وتأخذ أيضًا متغيرًا لوضع البيانات التي جرى فك تنظيمها فيه، وتعيد إما خطأ error في حال فشل عملية التحليل أو nil في حال نجحت. سنعدّل برنامجنا في هذا القسم، بحيث نستخدم الدالة json.Unmarshal لقراءة بيانات جسون من سلسلة وتخزينها في متغير من النوع map، وطباعة الخرج على الشاشة. لنعدّل البرنامج إذًا، بحيث نفك تنظيم البيانات باستخدام الدالة السابقة ونحولها إلى رابط {}map[string]interface. لنبدأ باستبدال المتغير data الأصلي بمتغير jsonData يحتوي على سلسلة جسون، ثم نُصرّح عن متغير data جديد على أنه {}map[string]interfac لتلقي بيانات جسون، ثم نستخدم الدالة json.Unmarshal مع هذه المتغيرات للوصول إلى بيانات جسون: ... func main() { jsonData := ` { "intValue":1234, "boolValue":true, "stringValue":"hello!", "dateValue":"2022-03-02T09:10:00Z", "objectValue":{ "arrayValue":[1,2,3,4] }, "nullStringValue":null, "nullIntValue":null } ` var data map[string]interface{} err := json.Unmarshal([]byte(jsonData), &data) if err != nil { fmt.Printf("could not unmarshal json: %s\n", err) return } fmt.Printf("json map: %v\n", data) } جرى إسناد قيمة المتغير jsonData في الشيفرة أعلاه من خلال سلسلة نصية أولية، وذلك للسماح بكتابة البيانات ضمن أسطر متعددة لتسهيل القراءة. بعد التصريح عن المتغير data على أنه متغير من النوع {}map[string]interface، نمرر jsonData والمتغير data إلى الدالة json.Unmarshal لفك تنظيم بيانات جسون وتخزين النتيجة في data. يُمرَّر المتغير jsonData إلى دالة فك التنظيم على شكل مصفوفة بايت byte[]، لأن الدالة تتطلب النوع byte[]، والمتغير jsonData عُرّف على أنه قيمة من نوع سلسلة نصية string. طبعًا هذا الأمر ينجح، لأنه في لغة جو، يمكن تحويل string إلى byte[] والعكس. بالنسبة للمتغير data، ينبغي تمريره مثل مرجع، لأن الدالة تتطلب معرفة موقع المتغير في الذاكرة. أخيرًا، يجري فك تنظيم البيانات وتخزين النتيجة في المتغير data، لنطبع النتيجة بعدها باستخدام دالة fmt.Printf. لنُشغّل ملف البرنامج "main.go" من خلال الأمر go run: $ go run main.go سيكون الخرج على النحو التالي: json map: map[boolValue:true dateValue:2022-03-02T09:10:00Z intValue:1234 nullIntValue:<nil> nullStringValue:<nil> objectValue:map[arrayValue:[1 2 3 4]] stringValue:hello!] يُظهر الخرج نتيجة تحويل بيانات جسون إلى رابط. نلاحظ أن جميع الحقول من بيانات جسون موجودة، بما في ذلك القيم الصفريّة null. بما أن بيانات جو الآن مُخزنة في قيمة من النوع {}map[string]interface، سيكون لدينا القليل من العمل مع بياناتها؛ إذ نحتاج إلى الحصول على القيمة من الرابط باستخدام قيمة مفتاح string معينة، وذلك للتأكد من أن القيمة التي تلقيناها هي القيمة التي نتوقعها، لأن القيمة المعادة هي قيمة من النوع {}interface. لنفتح ملف "main.go" ونُحدّث البرنامج لقراءة حقل dateValue: ... func main() { ... fmt.Printf("json map: %v\n", data) rawDateValue, ok := data["dateValue"] if !ok { fmt.Printf("dateValue does not exist\n") return } dateValue, ok := rawDateValue.(string) if !ok { fmt.Printf("dateValue is not a string\n") return } fmt.Printf("date value: %s\n", dateValue) } استخدمنا في الشيفرة أعلاه المفتاح dateValue لاستخراج قيمة من الرابط بكتابة ["data["dateValue، وخزّنا النتيجة في rawDateValue ليكون قيمةً من النوع {}interface، واستخدمنا المتغير ok للتأكد من أن الحقل ذو المفتاح dateValue موجود ضمن الرابط. استخدمنا بعدها توكيد النوع type assertion، للتأكد من أن rawDateValue هو قيمة string، وأسندناه إلى المتغير dateValue. استخدمنا بعدها المتغير ok للتأكد من نجاح عملية التوكيد. أخيرًا، طبعنا dateValue باستخدام دالة الطباعة fmt.Printf. لنُشغّل ملف البرنامج "main.go" من خلال الأمر go run: $ go run main.go سيكون الخرج كما يلي: json map: map[boolValue:true dateValue:2022-03-02T09:10:00Z intValue:1234 nullIntValue:<nil> nullStringValue:<nil> objectValue:map[arrayValue:[1 2 3 4]] stringValue:hello!] date value: 2022-03-02T09:10:00Z يمكننا أن نلاحظ في السطر الأخير من الخرج استخراج قيمة الحقل dateValue من الرابط map وتغير نوعها إلى string. استخدمنا في هذا القسم الدالة json.Unmarshal لفك تنظيم unmarshal بيانات جسون وتحويلها إلى بيانات في برنامج جو بالاستعانة بمتغير من النوع {}map[string]interface. بعد ذلك استخرجنا قيمة الحقل dateValue من الرابط الذي وضعنا فيه بيانات جسون وطبعناها على الشاشة. الجانب السيء في استخدام النوع {}map[string]interface في عملية فك التنظيم، هو أن مُفسّر اللغة لا يعرف أنواع الحقول التي جرى فك تنظيمها؛ فكل ما يعرفه أنها من النوع {}interface، وبالتالي لا يمكنه أن يفعل شيئًا أكثر من تخمين الأنواع. بالتالي لن يجري فك تنظيم أنواع البيانات المعقدة، مثل time.Time في الحقل dateValue إلى بيانات من النوع time.Time، وإنما تُفسّر على أنها string. تحدث مشكلة مماثلة إذا حاولنا الوصول إلى أي قيمة رقمية number في الرابط بهذه الطريقة، لأن الدالة json.Unmarshal لا تعرف ما إذا كان الرقم من النوع int أو float أو int64 ..إلخ. لذا يكون التخمين الأفضل هو عدّ الرقم من النوع float64 لأنه النوع الأكثر مرونة. نستنتج مما سبق جانب إيجابي للروابط وهو مرونة استخدامها، وجانب سيئ يتجلى بالمشكلات السابقة. هنا تأتي ميزة استخدام البنى لحل المشكلات السابقة. بطريقة مشابهة لآلية تنظيم البيانات من بنية struct باستخدام الدالة json.Marshal لإنتاج بيانات جسون، يمكن إجراء عملية معاكسة باستخدام json.Unmarshal أيضًا كما سبق وفعلنا مع الروابط. يمكننا باستخدام البنى الاستغناء عن تعقيدات توكيد النوع التي عانينا منها مع الروابط، من خلال تعريف أنواع البيانات في حقول البنية لتحديد أنواع بيانات جسون التي يجري فك تنظيمها. هذا ما سنتحدث عنه في القسم التالي. تحليل بيانات جسون باستخدام البنى عند قراءة بيانات جسون، هناك فرصة جيدة لمعرفة أنواع البيانات التي نتلقاها من خلال استخدام البُنى؛ فمن خلال استخدام البُنى، يمكننا منح مُفسّر اللغة تلميحات تُساعده في تحديد شكل ونوع البيانات التي يتوقعها. عرّفنا في المثال السابق البنيتين myJSON و myObject وأضفنا وسوم json لتحديد أسماء الحقول بعد تحويلها إلى جسون. يمكننا الآن استخدام قيم البنية struct نفسها لفك ترميز سلسلة جسون المُستخدمة، وهذا ما قد يكون مفيدًا لتقليل التعليمات البرمجية المكررة في البرنامج عند تنظم أو فك تنظيم بيانات جسون نفسها. فائدة أخرى لاستخدام بنية في فك تنظيم بيانات جسون هي إمكانية إخبار المُفسّر بنوع بيانات كل حقل، وهناك فائدة أخرى تأتي من استخدام مُفسّر اللغة للتحقق من استخدام الأسماء الصحيحة للحقول، وبالتالي تجنب أخطاء قد تحدث في أسماء الحقول (من النوع string) عند استخدام الروابط. لنفتح ملف "main.go"، ونعدّل تصريح المتغير data لاستخدام مرجع للبنية myJSON ونضيف بعض تعليمات الطباعة fmt.Printf لإظهار بيانات الحقول المختلفة في myJSON: ... func main() { ... var data *myJSON err := json.Unmarshal([]byte(jsonData), &data) if err != nil { fmt.Printf("could not unmarshal json: %s\n", err) return } fmt.Printf("json struct: %#v\n", data) fmt.Printf("dateValue: %#v\n", data.DateValue) fmt.Printf("objectValue: %#v\n", data.ObjectValue) } نظرًا لأننا عرّفنا سابقًا أنواع البُنى، فلن نحتاج إلا إلى تحديث نوع الحقل data لدعم عملية فك التنظيم في بنية. تُظهر بقية التحديثات بعض البيانات الموجودة في البنية نفسها. لنُشغّل ملف البرنامج "main.go" من خلال الأمر go run: $ go run main.go ويظهر لدينا الخرج التالي: json struct: &main.myJSON{IntValue:1234, BoolValue:true, StringValue:"hello!", DateValue:time.Date(2022, time.March, 2, 9, 10, 0, 0, time.UTC), ObjectValue:(*main.myObject)(0x1400011c180), NullStringValue:(*string)(nil), NullIntValue:(*int)(nil), EmptyString:""} dateValue: time.Date(2022, time.March, 2, 9, 10, 0, 0, time.UTC) objectValue: &main.myObject{ArrayValue:[]int{1, 2, 3, 4}} هناك شيئان يجب أن نشير لهما في الخرج السابق، إذ نلاحظ أولًا في سطر json struct وسطر dateValue، أن قيمة التاريخ والوقت من بيانات جسون جرى تحويلها إلى قيمة من النوع time.Time (يظهر النوع time.Date عند استخدام العنصر النائب v#%). بما أن مُفسّر جو كان قادرًا على التعرّف على النوع time.Time في حقل DateValue، فهو قادر أيضًا على تحليل قيم النوع string. الشيء الثاني الذي نلاحظه هو أن EmptyString يظهر على سطر json struct على الرغم من أنه لم يُضمّن في بيانات جسون الأصلية. إذا جرى تضمين حقل في بنية مُستخدمة في عملية فك تنظيم بيانات جسون، وكان هذا الحقل غير موجود في بيانات جسون الأصلية، فإنه هذا الحقل يُضبط بالقيمة الافتراضية لنوعه ويجري تجاهله. يمكننا بهذه الطريقة تعريف جميع الحقول المحتملة التي قد تحتوي عليها بيانات جسون بأمان، دون القلق بشأن حدوث خطأ إذا لم يكن الحقل موجودًا في أي من جانبي العملية. ضُبط كل من الحقلين NullStringValue و NullIntValue على قيمتهما الافتراضية nil، لأن بيانات جسون تقول أن قيمهما null، لكن حتى لو لم يكونا ضمن بيانات جسون، سيأخذان نفس القيمة. على غرار الطريقة التي تجاهلت بها الدالة json.Unmarshal حقل EmptyString في البنية struct عندما كان حقل emptyString مفقودًا من بيانات جسون، فإن العكس هو الصحيح أيضًا؛ فإذا كان هناك حقل في بيانات جسون ليس له ما يقابله في البنية struct، سيتجاهل مفسّر اللغة هذا الحقل، وينتقل إلى الحقل التالي لتحليله. بالتالي إذا كانت بيانات جسون التي نقرأها كبيرة جدًا وكان البرنامج يحتاج عددًا صغيرًا من تلك الحقول، يمكننا إنشاء بنية تتضمن الحقول التي نحتاجها فقط، إذ يتجاهل مُفسّر اللغة أية حقول من بيانات جسون غير موجودة في البنية. لنفتح ملف "main.go" ونعدّل jsonData لتضمين حقل غير موجود في myJSON: ... func main() { jsonData := ` { "intValue":1234, "boolValue":true, "stringValue":"hello!", "dateValue":"2022-03-02T09:10:00Z", "objectValue":{ "arrayValue":[1,2,3,4] }, "nullStringValue":null, "nullIntValue":null, "extraValue":4321 } ` ... } لنُشغّل ملف البرنامج "main.go" من خلال الأمر go run: $ go run main.go سيكون الخرج على النحو التالي: json struct: &main.myJSON{IntValue:1234, BoolValue:true, StringValue:"hello!", DateValue:time.Date(2022, time.March, 2, 9, 10, 0, 0, time.UTC), ObjectValue:(*main.myObject)(0x14000126180), NullStringValue:(*string)(nil), NullIntValue:(*int)(nil), EmptyString:""} dateValue: time.Date(2022, time.March, 2, 9, 10, 0, 0, time.UTC) objectValue: &main.myObject{ArrayValue:[]int{1, 2, 3, 4}} نلاحظ عدم ظهور الحقل الجديد extraValue الذي أضفناه إلى بيانات جسون ضمن الخرج، إذ تجاهله مُفسّر اللغة، لأنّه غير موجود ضمن البنية myJSON. استخدمنا في هذا المقال أنواع البُنى struct المُعرّفة مسبقًا في عملية فك تنظيم بيانات جسون. رأينا كيف أن ذلك يُمكّن مُفسّر اللغة من تحليل قيم الأنواع المعقدة مثل time.Time، ويسمح بتجاهل الحقل EmptyString الموجود ضمن البنية وغير موجود ضمن بيانات جسون. رأينا أيضًا كيف يمكن التحكم في الحقول التي نستخرجها من بيانات جسون وتحديد ما نريده من حقول بدقة. الخاتمة أنشأنا في هذا المقال برنامجًا يستخدم الحزمة encoding/json من مكتبة لغة جو القياسية. استخدمنا بدايةً الدالة json.Marshal مع النوع {}map[string]interface لإنشاء بيانات جسون بطريقة مرنة. عدّلنا بعد ذلك البرنامج لاستخدام النوع struct مع وسوم json لإنشاء بيانات جسون بطريقة متسقة وموثوقة باستخدام الدالة json.Marshal. استخدمنا بعد ذلك الدالة json.Unmarshal مع النوع {}map[string]interface لفك ترميز سلسلة جسون وتحويلها إلى بيانات يمكن التعامل معها في برنامج جو. أخيرًا، استخدمنا نوع بنية struct مُعرّف مسبقًا في عملية فك تنظيم بيانات جسون باستخدام دالة json.Unmarshal للسماح لمُفسّر اللغة بإجراء التحليل واستنتاج أنواع البيانات وفقًا لحقول البنية التي عرّفناها. يمكننا من خلال الحزمة encoding/json التفاعل مع العديد من واجهات برمجة التطبيقات APIs المتاحة على الإنترنت لإنشاء عمليات متكاملة مع مواقع الويب الأخرى. يمكننا أيضًا تحويل بيانات جو في برامجنا إلى تنسيق يمكن حفظه ثم تحميله لاحقًا للمتابعة من حيث توقف البرنامج (لأن عملية السلسلة Serialization تحفظ البيانات قيد التشغيل في الذاكرة). تتضمن الحزمة encoding/json أيضًا دوالًا أخرى مُفيدة للتعامل مع بيانات جسون، مثل الدالة json.MarshalIndent التي تساعدنا في عرض بيانات جسون بطريقة مُرتبة وأكثر وضوحًا للاطلاع عليها ومساعدتنا في استكشاف الأخطاء وإصلاحها. ترجمة -وبتصرف- للمقال How To Use JSON in Go لصاحبه Kristin Davidson. اقرأ أيضًا المقال السابق استخدام السياقات Contexts في لغة جو Go استخدام وسوم البنية Struct Tags في لغة جو.
  5. عند تطوير التطبيقات الكبيرة، وخصوصًا برمجيات الخادم - يكون من المفيد أحيانًا لدالةٍ ما معرفة بعض المعلومات عن البيئة التي تُنفّذ بها إلى جانب المعلومات اللازمة لعمل الدالة نفسها. لنأخذ مثلًا دالة خادم ويب تتعامل مع طلب HTTP لعميل معين، هنا قد تحتاج الدالة إلى معرفة عنوان URL الذي يطلبه العميل فقط لتحقيق الاستجابة، وفي هذه الحالة ربما تحتاج فقط إلى تمرير العنوان مثل معامل إلى الدالة. المشكلة أن هناك بعض الأشياء المفاجئة التي يمكن أن تحدث مثل انقطاع الاتصال مع العميل قبل تحقيق الاستجابة وتلقيه الرد. بالتالي، إذا كانت الدالة التي تؤدي الاستجابة لا تعرف أن العميل غير متصل، لن يصل الرد والعمليات التي يجريها الخادم ستكون مجرد هدر للموارد الحاسوبية على استجابة لن تُستخدم. لتفادي هكذا حالات يجب أن يكون بمقدور الخادم معرفة سياق الطلب (مثل حالة اتصال العميل)، وبالتالي إمكانية إيقاف معالجة الطلب بمجرد انقطاع الاتصال مع العميل. هذا من شأنه الحفاظ على الموارد الحاسوبية ويحد من الهدر ويتيح للخادم التحرر أكثر من الضغط وتقديم أداء أفضل. تظهر فائدة هذا النوع من المعلومات أكثر في الحالات التي تتطلّب فيها الدوال وقتًا طويلًا نسبيًا في التنفيذ، مثل إجراء استدعاءات قاعدة البيانات. لمعالجة هذه القضايا ومنح إمكانية الوصول الكامل لمثل هذه المعلومات تُقدم لغة جو حزمة السياق context في المكتبة القياسية. سنُنشئ خلال هذا المقال برنامجًا يستخدم سياقًا داخل دالة. سنعدّل بعدها البرنامج لتخزين بيانات إضافية في السياق واستردادها من دالة أخرى. بعد ذلك سنستفيد من فكرة السياق لإرسال إشارة للدالة التي تُجري عملية المعالجة، لتوقف تنفيذ أي عمليات معالجة مُتبقية. المتطلبات إصدار مُثبّت من جو 1.16 أو أعلى، ويمكنك الاستعانة بمقال تثبيت لغة جو Go وإعداد بيئة برمجة محلية على أبونتو Ubuntu لإعداده. تثبيت لغة جو وإعداد بيئة برمجة محلية على نظام ماك macOS. تثبيت لغة جو وإعداد بيئة برمجة محلية على ويندوز. فهم لخيوط معالجة جو goroutines والقنوات channels. يمكنك الاطلاع على مقالة كيفية تشغيل عدة دوال على التساير في لغة جو Go. معرفة بكيفية التعامل مع التاريخ والوقت في لغة جو. يمكنك الاطلاع على مقالة استخدام التاريخ والوقت في لغة جو Go. معرفة كيفية التعامل مع تعليمة switch في لغة جو. يمكنك الاطلاع على مقالة التعامل مع التعليمة Switch في لغة جو Go. إنشاء سياق context تستخدم العديد من الدوال في لغة جو حزمة context لجمع معلومات إضافية حول البيئة التي تُنفّذ فيها، وعادةً ما يُقدّم هذا السياق للدوال التي تستدعيها أيضًا. ستتمكن البرامج من خلال واجهة context.Context التي توفرها حزمة السياق، وتمريرها من من دالة إلى أخرى، من نقل معلومات السياق من أعلى نقطة تنفيذ في البرنامج (دالة main) إلى أعمق نقطة تنفيذ في البرنامج (دالة ضمن دالة أخرى أو ربما أعمق). مثلًا، تُقدّم الدالة Context من النوع http.Request سياقًا context.Context يتضمن معلومات عن العميل الذي أرسل الطلب، ويُحذف أو ينتهي هذا السياق في حالة قُطع الاتصال مع العميل، حتى لو لم يكن الطلب قد انتهت معالجته. بالتالي، إذا كانت هناك دالة ما تستدعي الدالة QueryContext التابعة إلى sql.DB، وكان قد مُرر لهذه الدالة قيمة context.Context، وقُطع الاتصال مع العميل، سيتوقف تنفيذ الاستعلام مباشرةً في حالة لم يكن قد انتهى من التنفيذ. سننشئ في هذا القسم برنامجًا يتضمن دالة تتلقى سياقًا مثل معامل، ونستدعي هذه الدالة باستخدام سياق فارغ نُنشئه باستخدام الدالتين context.TODO و Context.Background. كما هو معتاد، سنحتاج لبدء إنشاء برامجنا إلى إنشاء مجلد للعمل ووضع الملفات فيه، ويمكن وضع المجلد في أي مكان على الحاسب، إذ يكون للعديد من المبرمجين عادةً مجلدٌ يضعون داخله كافة مشاريعهم. سنستخدم في هذا المقال مجلدًا باسم "projects"، لذا فلننشئ هذا المجلد وننتقل إليه: $ mkdir projects $ cd projects الآن من داخل هذا المجلد، سنشغّل الأمر mkdir لإنشاء مجلد "contexts" ثم سنستخدم cd للانتقال إليه: $ mkdir contexts $ cd contexts يمكننا الآن فتح ملف "main.go" باستخدام محرر نانو nano أو أي محرر آخر تريده: $ nano main.go سنُنشئ الآن دالة doSomething داخل ملف "main.go". تقبل هذه الدالة context.Context مثل معامل، ثم نضيف دالة main التي تُنشئ سياقًا وتستدعي doSomething باستخدام ذلك السياق. نضيف ما يلي داخل ملف main.go: package main import ( "context" "fmt" ) func doSomething(ctx context.Context) { fmt.Println("Doing something!") } func main() { ctx := context.TODO() doSomething(ctx) } استخدمنا الدالة context.TODO داخل الدالة main، لإنشاء سياق فارغ ابتدائي. يمكننا استخدام السياق الفارغ مثل موضع مؤقت placeholder عندما لا نكون متأكدين من السياق الذي يجب استخدامه. لدينا أيضًا الدالة doSomething التي تقبل معاملًا وحيدًا هو context.Context -له الاسم ctx- وهو الاسم الشائع له، ويُفضل أن يكون أول معامل في الدالة في حال كان هناك معاملات أخرى، لكن الدالة لا تستخدمه الآن فعليًّا. لنُشغّل ملف البرنامج "main.go" من خلال الأمر go run: $ go run main.go سيكون الخرج على النحو التالي: Doing something! نلاحظ أن الخرج الذي أظهرته الدالة fmt.Println هو !Doing something نتيجةً لاستدعاء الدالة doSomething. لنعدّل البرنامج الآن ونستخدم الدالة context.Background التي تُنشئ سياقًا فارغًا: ... func main() { ctx := context.Background() doSomething(ctx) } تنشئ الدالة context.Background سياقًا فارغًا مثل context.TODO، ومن حيث المبدأ تؤدي كلتا الدالتين السابقتين نفس الغرض. الفرق الوحيد هو أن context.TODO تُستخدم عندما تُريد أن تُخبر المطورين الآخرين أن هذا السياق هو مجرد سياق مبدأي وغالبًا يجب تعديله، أما context.Background تتُستخدم عندما لا نحتاج إلى هكذا إشارة إلى المطورين الآخرين، أي نحتاج ببساطة إلى سياق فارغ لا أكثر ولا أقل، وفي حال لم تكن متأكدًا أيهما تستخدم، ستكون الدالة context.Background خيارًا افتراضيًا. لنُشغّل ملف البرنامج "main.go" من خلال الأمر go run: $ go run main.go سيكون الخرج: Doing something! سيكون الخرج طبعًا نفسه كما في المرة السابقة، وبذلك نكون قد تعلمنا كيفية إنشاء سياق فارغ بطريقتين مختلفتين. لا يفيدنا السياق الفارغ تمامًا إذا بقي على هذا النحو، فعلى الرغم من قدرتنا على تمريره بين الدوال، إلا أنه لا يوفر أية معلومات. لجعل السياق مفيدًا نُضيف له بيانات يمكن للدوال الأخرى استردادها والاطلاع عليها. إضافة معلومات إلى السياق إحدى فوائد استخدام context.Context في برنامجٍ ما هي القدرة على الوصول إلى البيانات المخزنة داخل سياق ما، إذ يمكن لكل طبقة من البرنامج إضافة معلومات إضافية حول ما يحدث من خلال إضافة البيانات إلى سياق وتمرير السياق من دالة إلى أخرى. مثلًا، قد تضيف الدالة الأولى اسم مستخدم إلى السياق، والدالة التالية مسار الملف إلى المحتوى الذي يحاول المستخدم الوصول إليه، والدالة الثالثة تقرأ الملف من قرص النظام وتسجل ما إذا كان قد نجح تحميله أم لا، إضافةً إلى المستخدم الذي حاول تحميله. يمكن استخدم الدالة Context.WithValue من حزمة السياق لإضافة قيمة جديدة إلى السياق. تقبل الدالة ثلاث معاملات: السياق الأب (الأصلي) context.Context والمفتاح والقيمة. السياق الأب هو السياق الذي يجب إضافة القيمة إليه مع الاحتفاظ بجميع المعلومات الأخرى المتعلقة بالسياق الأصلي. يُستخدم المفتاح لاسترداد القيمة من السياق. يمكن أن يكون المفتاح والقيمة من أي نوع بيانات، وفي هذا المقال سيكونان من نوع سلسلة نصية string. تعيد الدالة Context.WithValue قيمةً من النوع context.Context تتضمن السياق الأب مع المعلومات المُضافة. يمكن الحصول على القيمة التي يُخزنها السياق context.Context من خلال استخدام التابع Value مع المفتاح. لنفتح الآن ملف "main.go" ولنضِف قيمة إلى السياق باستخدام الدالة السابقة، ثم نحدِّث دالة doSomething، بحيث تطبع تلك القيمة باستخدام دالة fmt.Printf: ... func doSomething(ctx context.Context) { fmt.Printf("doSomething: myKey's value is %s\n", ctx.Value("myKey")) } func main() { ctx := context.Background() ctx = context.WithValue(ctx, "myKey", "myValue") doSomething(ctx) } أسندنا في الشيفرة السابقة سياقًا جديدًا إلى المتغير ctx، الذي يُستخدم للاحتفاظ بالسياق الأب، ويُعد هذا نمط شائع الاستخدام في حال لم يكن هناك سبب للإشارة إلى سياق أب محدد. إذا كنا بحاجة إلى الوصول إلى السياق الأب في وقتٍ ما، يمكننا إسناد القيمة إلى متغير جديد، كما سنرى قريبًا. لنُشغّل ملف البرنامج "main.go" من خلال الأمر go run: $ go run main.go سيكون الخرج على النحو التالي: doSomething: myKey's value is myValue يمكننا رؤية القيمة myValue التي خزّناها في السياق ضمن الخرج السابق، وذلك نتيجةً لاستدعاء الدالة doSomething ضمن الدالة main. طبعًا القيمة myValue المُستخدمة هنا هي فقط من أجل التوضيح، فمثلًا لو كنا نعمل على خادم كان من الممكن أن تكون هذه القيمة شيئًا آخر مثل وقت بدء تشغيل البرنامج. عند استخدام السياقات من المهم معرفة أن القيم المخزنة في سياقٍ context.Context ما تكون ثابتة immutable. بالتالي عندما استدعينا الدالة context.WithValue ومررنا لها السياق الأب، حصلنا على سياق جديد وليس السياق الأب أو نسخة منه، وإنما حصلنا على سياق جديد يضم المعلومات الجديدة إضافةً إلى سياق الأب مُغلّفًا wrapped ضمنه. لنفتح الآن ملف "main.go" لإضافة دالة جديدة doAnother تقبل سياقًا context.Context وتطبع قيمة السياق من خلال المفتاح، ونعدّل أيضًا الدالة doSomething، بحيث نُنشئ داخلها سياقًا جديدًا يُغلّف السياق الأب ويضيف معلومات جديدة ولتكن anotherValue، ثم نستدعي الدالة doAnother على السياق anotherCtx الناتج، ونطبع في السطر الأخير من الدالة قيمة السياق الأب. ... func doSomething(ctx context.Context) { fmt.Printf("doSomething: myKey's value is %s\n", ctx.Value("myKey")) anotherCtx := context.WithValue(ctx, "myKey", "anotherValue") doAnother(anotherCtx) fmt.Printf("doSomething: myKey's value is %s\n", ctx.Value("myKey")) } func doAnother(ctx context.Context) { fmt.Printf("doAnother: myKey's value is %s\n", ctx.Value("myKey")) } ... لنُشغّل ملف البرنامج "main.go" من خلال الأمر go run: $ go run main.go سيكون الخرج: doSomething: myKey's value is myValue doAnother: myKey's value is anotherValue doSomething: myKey's value is myValue نلاحظ في الخرج سطرين من الدالة doSomething وسطر من الدالة doAnother. لم نغير شيئًا داخل الدالة main؛ إذ أنشأنا سياقًا فارغًا وغلّفناه مع القيمة myValue والمفتاح myKey ومرّرنا السياق الناتج إلى doSomething، ويمكننا أن نلاحظ أن هذه القيمة مطبوعة على أول سطر من الخرج. يظهر السطر الثاني من الخرج أنه عند استخدام context.WithValue داخل doSomething لتغليف السياق الأب ctx وتوليد السياق anotherCtx بالقيمة anotherValue والمفتاح myKey (المفتاح نفسه للأب لم يتغير) وتمرير هذا السياق الناتج إلى doAnother، فإن القيمة الجديدة تتخطى القيمة الابتدائية. بالنسبة للسطر الأخير، نلاحظ أنه يطبع القيمة المرتبطة بالمفتاح myKey والمرتبطة بالسياق الأب وهي myValue. نظرًا لأن الدالة context.WithValue تغلّف السياق الأب فقط، سيبقى السياق الأب محتفظًا بنفس القيم الأصلية نفسها. عندما نستدعي التابع Value على سياق ما، فإنه يُعيد القيمة المرتبطة بالمفتاح من المستوى الحالي للسياق. عند استدعاء anotherCtx.Value من أجل مفتاح myKey، سيعيد القيمة anotherValue لأنها القيمة المغلّفة للسياق، وبالتالي يتجاوز أي قيم أخرى مُغلّفة للمفتاح، وعند استدعاء anotherCtx داخل doSomething للمرة الثانية، لن تغلِّف anotherCtx السياق ctx، وستُعاد القيمة الأصلية myValue. ملاحظة: السياق أداة قوية يمكن من خلالها تخزين جميع القيم التي نريد الاحتفاظ بها. قد يبدو من المغري وضع جميع البيانات في السياق واستخدام تلك البيانات في الدوال بدلًا من المعاملات، ولكن يمكن أن يؤدي ذلك إلى شيفرة يصعب قراءتها والحفاظ عليه. يجب تحقيق التوازن بين البيانات المُخزنة في السياق والبيانات المُمررة إلى دالةٍ ما مثل معاملات. القاعدة الأساسية المناسبة هي أن أي بيانات مطلوبة لتشغيل دالة ما يجب أن تُمرر مثل معاملات. مثلًا قد يكون من المفيد الاحتفاظ بأسماء المستخدمين ضمن السياق لاستخدامها عند تسجيل المعلومات لاحقًا، إلا أنه من الممكن أن تكون هناك حاجة لاستخدام اسم المستخدم في تحديد ما إذا كانت الدالة يجب أن تعرض بعض المعلومات المحددة المرتبطة به، وبالتالي من الأفضل تضمينها مثل معامل للدالة حتى لو كانت متاحةً في السياق، لكي يسهل معرفة البيانات التي تُستخدم ضمن الدالة لنا وللآخرين. حدّثنا في هذا القسم برنامجنا لتخزين قيمة في سياق، ثم تغليف السياق لتجاوز هذه القيمة. هذه ليست الأداة الوحيدة التي يمكن للسياقات توفيرها، إذ يمكن للسياقات أن تُستخدم للإشارة إلى أجزاء أخرى من البرنامج عندما ينبغي التوقف عن المعالجة لتجنب هدر الموارد الحاسوبية. إنهاء سياق إحدى الأدوات الأخرى التي يُقدمها السياق context.Context هي إمكانية الإشارة إلى أي دالة تستخدمه بأن السياق قد انتهى ويجب عدّه مكتملًا، وبالتالي يمكن للدوال إيقاف تنفيذ أي عمل يؤدونه. هذا يسمح للبرنامج أن يكون أكثر كفاءة، فهو أصبح يعرف متى يجب أن يتوقف عن معالجة طلب ما عندما تنتهي الحاجة إليه. مثلًا، إذا أرسل المستخدم طلبًا للحصول على صفحة ويب من الخادم وليكن عن طريق الخطأ، ثم ضغط على زر "إيقاف" أو إغلاق المتصفح قبل انتهاء تحميل الصفحة، في هذه الحالة إن لم يُدرك الخادم أن المستخدم قرر إلغاء العملية، سيعالج هذا الطلب دون جدوى، فالمستخدم لن يرى النتيجة لأنه لا يريدها بعد الآن، وبالتالي تُهدر الموارد على طلب دون فائدة، ولا سيما إذا كانت الصفحة المطلوبة تتطلب تنفيذ بعض الاستعلامات من قاعدة البيانات؛ أما إذا كانت الدالة التي تُخدّم الطلب تستخدم سياقًا، فإنها ستعرف وستُخبر بقية الدوال ذات الصلة بأن السياق انتهى لأن الخادم ألغاه، وبالتالي يمكنهم تخطي تنفيذ أية استعلامات مُتبقية من قاعدة البيانات. يؤدي ذلك إلى الحد من هدر الموارد من خلال إتاحة وقت المعالجة هذا إلى طلب آخر ربما يكون منتظرًا. سنعدّل في هذا القسم البرنامج ليكون قادرًا على معرفة وقت انتهاء السياق، وسنتعرّف على 3 توابع لإنهاء السياق. تحديد انتهاء السياق يحدث تحديد ما إذا كان السياق قد انتهى بنفس الطريقة، وذلك بصرف النظر عن السبب؛ إذ يوفر النوع context.Context تابعًا يُسمى Done للتحقق من انتهاء سياق ما. يُعيد هذا التابع قناةً channel تُغلق حالما ينتهي السياق، وأي دالة تُتابع هذه القناة توقف تنفيذ أي عملية ذات صلة في حال أُغلقت القناة. لا يُكتب على هذه القناة أية قيم، وبالتالي عند إغلاق القناة تُعيد القيمة nil إذا حاولنا قراءتها. إذًا، يمكننا من خلال هذه القناة، إنشاء دوال يمكنها معالجة الطلبات ومعرفة متى يجب أن تكمل المعالجة ومتى يجب أن تتوقف من خلال التحقق الدوري من حالة القناة، كما أن الجمع بين معالجة الطلبات والفحص الدوري لحالة القناة وتعليمة select يمكن أن يقدم فائدةً أكبر من خلال السماح بإرسال أو استقبال البيانات من قنوات أخرى في نفس الوقت، إذ تُستخدم التعليمة select للسماح للبرنامج بالكتابة أو القراءة من عدة قنوات بصورة متزامنة. يمكن تنفيذ عملية واحدة خاصة بقناة في وقت واحد ضمن تعليمة select، لذا نستخدم حلقة for على تعليمة select كما سنرى في المثال التالي، لإجراء عدة عمليات على القناة. يُمكن إنشاء تعليمة select من خلال الكلمة المفتاحية select متبوعةً بقوسين {} مع تعليمة case واحدة أو أكثر ضمن القوسين. يمكن أن تكون كل تعليمة case عملية قراءة أو كتابة على قناة، وتنتظر تعليمة select حتى تُنفّذ إحدى حالتها (تبقى منتظرة حتى تُنفذ إحدى تعليمات case)، وفي حال أردنا ألا تنتظر، يمكننا أن نستخدم التعليمة default، وهي الحالة الافتراضية التي تُنفّذ في حال عدم تحقق شرط تنفيذ إحدى الحالات الأخرى (تشبه تعليمة switch). توضّح الشيفرة التالية كيف يمكن استخدام تعليمة select ضمن دالة، بحيث تتلقى نتائج من قناة وتراقب انتهاء السياق من خلال التابع Done: ctx := context.Background() resultsCh := make(chan *WorkResult) for { select { case <- ctx.Done(): // The context is over, stop processing results return case result := <- resultsCh: // عالج النتائج } } تُمرر عادةً قيم كل من ctx و resultsCh إلى دالة مثل معاملات، إذ يكون ctx سياقًا من النوع context.Context، بينما resultsCh هي قيمة من قناة يمكننا القراءة منها فقط داخل الدالة، وغالبًا ما تكون هذه القيمة نتيجة من عامل worker (أو خيوط معالجة جو) في مكانٍ ما. في كل مرة تُنفذ فيها تعلمية select سيوقف جو تنفيذ الدالة ويراقب جميع تعليمات case، وحالما تكون هناك إمكانية لتنفيذ أحدها (قراءة من قناة كما في في حالة resultsCh، أو كتابة أو معرفة حالة القناة عبر Done) يُنفذ فرع هذه الحالة، ولا يمكن التنبؤ بترتيب تنفيذ تعلميات case هذه، إذ من الممكن أن تُنفذ أكثر من حالة بالتزامن. نلاحظ في الشيفرة أعلاه أنه يمكننا استمرار تنفيذ الحلقة إلى الأبد طالما أن السياق لم ينتهي، أي طالما ctx.Done لم تُشر إلى إغلاق القناة، إذ لاتوجد أي تعليمات break أو return إلا داخل عبارة case. بالرغم من عدم إسناد الحالة case <- ctx.Done أي قيمة لأي متغير، إلا أنه سيظل بالإمكان تنفيذها عند إغلاق ctx.Done، لأن القناة تحتوي على قيمة يمكن قراءتها حتى لو لم نُسنِد تلك القيمة وتجاهلناها. إذا لم تُغلق القناة ctx.Done (أي لم يتحقق فرع الحالة case <- ctx.Done)، فسوف تنتظر تعليمة select حتى تُغلق أو حتى يُصبح بالإمكان قراءة قيمة من resultsCh. إذا كانت resultsCh تحمل قيمة يمكن قراءتها يُنفّذ فرع الحالة الذي يتضمنها ويجري انتظارها ريثما تنتهي من التنفيذ، وبعدها يجري الدخول في تكرار آخر للحلقة (تُنفّذ حالة واحدة في الاستدعاء الواحد لتعليمة select)، وكما ذكرنا سابقًا إذا كان بالإمكان تنفيذ الحالتان، يجري الاختيار بينهما عشوائيًا. في حال وجود تعليمة default، فإن الأمر الوحيد الذي يتغير هو أنه يُنفذ حالًا في حال لم تكن إحدى الحالات الأخرى قابلة للتنفيذ، وبعد تنفيذ default يجري الخروج من select ثم يجري الدخول إليها مرةً أخرى بسبب وجود الحلفة. يؤدي هذا إلى تنفيذ حلقة for بسرعة كبيرة لأنها لن تتوقف أبدًا وتنتظر القراءة من قناة. تُسمى الحلقة في هذه الحالة "حلقة مشغولة busy loop" لأنه بدلًا من انتظار حدوث شيء ما، تكون الحلقة مشغولة بالتكرار مرارًا وتكرارًا. يستهلك ذلك الكثير من دورات وحدة المعالجة المركزية CPU، لأن البرنامج لا يحصل أبدًا على فرصة للتوقف عن التشغيل للسماح بتنفيذ التعليمات البرمجية الأخرى. عمومًا تكون هذه العملية مفيدة أحيانًا، فمثلًا إذا كنا نريد التحقق مما إذا كانت القناة جاهزة لفعل شيء ما قبل الذهاب لإجراء عملية أخرى غير متعلقة بالقناة. كما ذكرنا، تتجلى الطريقة الوحيدة للخروج من الحلقة في هذا المثال بإغلاق القناة المُعادة من التابع Done، والطريقة الوحيدة لإغلاق هذه القناة هي إنهاء السياق، بالتالي نحن بحاجة إلى طريقة لإنهائه. توفر لغة جو عدة طرق لإجراء ذلك وفقًا للهدف الذي نبتغيه، والخيار المباشر هو استدعاء دالة "إلغاء cancel" السياق. إلغاء السياق يعد إلغاء السياق Cancelling context أكثر طريقة مباشرة ويمكن التحكم بها لإنهاء السياق. يمكننا -بأسلوب مشابه لتضمين قيمة في سياق باستخدام دالة context.WithValue- ربط دالة إلغاء سياق مع سياق باستخدام دالة context.WithCancel، إذ تقبل هذه الدالة السياق الأب مثل معامل وتعيد سياقًا جديدًا إضافةً إلى دالة يمكن استخدامها لإلغاء السياق المُعاد؛ وكذلك يؤدي استدعاء دالة الحذف المُعادة فقط إلى إلغاء السياق الذي أُعيد مع جميع السياقات الأخرى التي تستخدمه مثل سياق أب، وذلك بطريقة مشابهة أيضًا لأسلوب عمل دالة context.WithValue. هذا لا يعني طبعًا أنه لا يمكن إلغاء السياق الأب الذي مررناه إلى دالة context.WithCancel، بل يعني أنه لا يُلغى إذا استدعيت دالة الإلغاء بهذا الشكل. لنفتح ملف "main.go" لنرى كيف نستخدم context.WithCancel: package main import ( "context" "fmt" "time" ) func doSomething(ctx context.Context) { ctx, cancelCtx := context.WithCancel(ctx) printCh := make(chan int) go doAnother(ctx, printCh) for num := 1; num <= 3; num++ { printCh <- num } cancelCtx() time.Sleep(100 * time.Millisecond) fmt.Printf("doSomething: finished\n") } func doAnother(ctx context.Context, printCh <-chan int) { for { select { case <-ctx.Done(): if err := ctx.Err(); err != nil { fmt.Printf("doAnother err: %s\n", err) } fmt.Printf("doAnother: finished\n") return case num := <-printCh: fmt.Printf("doAnother: %d\n", num) } } } ... أضفنا استيرادًا للحزمة time وجعلنا الدالة doAnother تقبل قناةً جديدة لطباعة أرقام على شاشة الخرج. استخدمنا تعليمة select ضمن حلقة for للقراءة من تلك القناة والتابع Done الخاص بالسياق. أنشأنا ضمن الدالة doSomething سياقًا يمكن إلغاؤه إضافةً إلى قناة لإرسال الأرقام إليها. أضفنا استدعاءً للدالة doAnother سبقناه بالكلمة المفتاحية go ليُنفّذ مثل خيوط معالجة جو goroutine، ومررنا له السياق ctx والقناة printCh. أخيرًا أرسلنا بعض الأرقام إلى القناة ثم ألغينا السياق. لنُشغّل ملف البرنامج "main.go" من خلال الأمر go run: $ go run main.go سيكون الخرج على النحو التالي: doAnother: 1 doAnother: 2 doAnother: 3 doAnother err: context canceled doAnother: finished doSomething: finished تعمل الدالة doSomething في الشيفرة السابقة على إرسال العمل إلى خيط معالجة واحد أو أكثر يقرؤون الأرقام من القناة ويطبعوها، وتكون في هذه الحالة الدالة doAnother هي العامل worker وعملها هو طباعة الأرقام، وحالما يبدأ خيط معالجة جو doAnother، تبدأ دالة doSomething بإرسال الارقام للطباعة. تنتظر تعليمة select -داخل الدالة doAnother- إغلاق قناة ctx.Done أو استقبال رقم على القناة printCh. تبدأ الدالة doSomething عملية إرسال الأرقام إلى القناة بعد بدء تنفيذ doAnother كما نرى في الشيفرة أعلاه، إذ ترسل 3 أرقام إلى القناة، وبالتالي تُفعّل 3 عمليات طباعة fmt.Printf لكل رقم (فرع الحالة الثانية داخل تعليمة select)، ثم تستدعي دالة cancelCtx لإلغاء السياق. بعد أن تقرأ الدالة doAnother الأرقام الثلاثة من القناة، ستنتظر العملية التالية من القناة، أي تبقى منتظرة ولن يُنفّذ الفرع المقابل في select، وبما أن doSomething في هذه الأثناء استدعت cancelCtx، بالتالي يُستدعى فرع ctx.Done، الذي يستخدم الدالة Err التي يوفرها النوع context.Context لتحديد كيفية إنهاء السياق. بما أننا ألغينا السياق باستخدام cancelCtx، بالتالي سيكون الخطأ الذي نراه هو context canceled. ملاحظة: إذا سبق وشغّلت برامج لغة جو من قبل ونظرت إلى الخرج، فربما سبق وشاهدت الخطأ context canceled من قبل، فهو خطأ شائع عند استخدام حزمة http من لغة جو، ويهدف لمعرفة وقت قطع اتصال العميل بالخادم، قبل أن يعالج الخادم الاستجابة. أخيرًا، تستخدم الدالة doSomething بعد ذلك الدالة time.Sleep للانتظار لفترة قصيرة من الوقت، لتعطي بذلك الدالة doAnother وقتًا لمعالجة حالة السياق المُلغى وإنهاء التنفيذ، ثم تطبع الدالة doSomething رسالة تُشير إلى انتهاء التنفيذ. تجدر الملاحظة إلى أنه لا داعٍ إلى استخدام دالة time.Sleep غالبًا، لكنها ضرورية عندما تنتهي الشيفرة من التنفيذ بسرعة، وسينتهي بدونها البرنامج دون رؤية كامل الخرج على الشاشة. تكون الدالة context.WithCancel ودالة الإلغاء التي تُعيدها مفيدةً أكثر عندما يتطلب الأمر قدرًا كبيرًا من التحكم عند إنهاء السياق، لكن في كثير من الأحيان قد لا نحتاج إلى هذا القدر من التحكم. الدالة التالية المتاحة لإنهاء السياقات في حزمة السياق context هي Context.WithDeadline، إذ تنهي هذه الدالة السياق تلقائيًا نيابةً عنا. إعطاء السياق مهلة زمنية للانتهاء يمكننا تحديد مهلة زمنية Deadline يجب خلالها أن ينتهي السياق باستخدام الدالة Context.WithDeadline، وبعد انتهاء هذه المهلة سوف ينتهي السياق تلقائيًا. الأمر أشبه بأن نكون في امتحان، ويكون هناك مهلة محددة لحل الأسئلة، وتُسحب الورقة منا عند انتهائها تلقائيًا، حتى لو لم ننتهي من حلها. لتحديد موعد نهائي لسياق ما، نستخدم الدالة Context.WithDeadline مع تمرير السياق الأب وقيمة زمنية من النوع time.Time تُشير إلى الموعد النهائي. تُعيد هذه الدالة سياقًا جديدًا ودالة لإلغاء السياق، وكما رأينا في Context.WithCancel تُطبق عملية الإلغاء على السياق الجديد وعلى أبنائه (السياقات التي تستخدمه). يمكننا أيضًا إلغاء السياق يدويًا عن طريق استدعاء دالة الإلغاء كما في دالة context.WithCancel. لنفتح ملف البرنامج لنحدثه ونستخدم دالة Context.WithDeadline بدلًا من context.WithCancel: ... func doSomething(ctx context.Context) { deadline := time.Now().Add(1500 * time.Millisecond) ctx, cancelCtx := context.WithDeadline(ctx, deadline) defer cancelCtx() printCh := make(chan int) go doAnother(ctx, printCh) for num := 1; num <= 3; num++ { select { case printCh <- num: time.Sleep(1 * time.Second) case <-ctx.Done(): break } } cancelCtx() time.Sleep(100 * time.Millisecond) fmt.Printf("doSomething: finished\n") } ... تستخدم الشيفرة الآن الدالة Context.WithDeadline ضمن الدالة context.WithDeadline لإلغاء السياق تلقائيًا بعد 1500 ميلي ثانية (1.5 ثانية) من بدء تنفيذ الدالة، إذ حددنا الوقت من خلال دالة time.Now. إضافةً إلى استخدام الدالة Context.WithDeadline، أجرينا بعض التعديلات الأخرى؛ فنظرًا لأنه من المحتمل أن ينتهي البرنامج الآن عن طريق استدعاء cancelCtx مباشرةً أو الإلغاء التلقائي وفقًا للموعد النهائي، حدّثنا دالة doSomething، بحيث نستخدم تعليمة select لإرسال الأرقام على القناة. بالتالي، إذا كانت doAnother لا تقرأ من printCh وكانت قناة ctx.Done مُغلقة، ستلاحظ ذلك doSomething وتتوقف عن محاولة إرسال الأرقام. نلاحظ أيضًا استدعاء cancelCtx مرتين، مرة عبر تعليمة defer ومرة كما في السابق. وجود الاستدعاء الأول غير ضروري طالما أن الاستدعاء الثاني موجود وسيُنفذ دومًا، ولكن من المهم وجوده لو كانت هناك تعليمة return أو حدث ما يُمكن أن يتسبب في عدم تنفيذ الاستدعاء الثاني. على الرغم من إلغاء السياق بعد انقضاء المهلة الزمنية، إلا أننا نستدعي دالة الإلغاء، وذلك من أجل تحرير أية موارد مُستخدمة بمثابة إجراء أكثر أمانًا. لنُشغّل ملف البرنامج "main.go" من خلال الأمر go run: $ go run main.go سيكون الخرج على النحو التالي: doAnother: 1 doAnother: 2 doAnother err: context deadline exceeded doAnother: finished doSomething: finished نلاحظ هذه المرة إلغاء السياق بسبب خطأ تجاوز الموعد النهائيdeadline exceeded قبل طباعة جميع الأرقام الثلاثة، وهذا منطقي فالمهلة الزمنية هي 1.5 ثانية بدءًا من لحظة تنفيذ doSomething، وبما أن doSomething تنتظر ثانية واحدة بعد إرسال رقم، فستنتهي المهلة قبل طباعة الرقم الثالث. بمجرد انقضاء المهلة الزمنية، ينتهي تنفيذ كل من doSomething و doAnother، وذلك لأنهما يراقبان لحظة إغلاق قناة ctx.Done. لو عدّلنا مدة المهلة وجعلناها أكثر من 3 ثوان، فربما سنشاهد الخطأ context canceled يظهر من جديد، وذلك لأن المهلة طويلة. قد يكون الخطأ context deadline exceeded مألوفًا أيضًا، ولا سيما للمبرمجين الذين يستخدمون تطبيقات لغة جو ويقرأون رسائل الخطأ التي تظهر. هذا الخطأ شائع في خوادم الويب التي تستغرق وقتًا في إرسال الاستجابات إلى العميل. مثلًا، إذا استغرق استعلام من قاعدة البيانات أو عملية ما وقتًا طويلًا، فقد يتسبب ذلك بإلغاء سياق الطلب وظهور هذا الخطأ لأن الخادم لا يسمح بتجاوز مهلة معينة في معالجة طلب ما. يسمح لنا إلغاء السياق باستخدام context.WithCancel بدلًا من cont4ext.WithCancel بإلغاء السياق تلقائيًا بعد انتهاء مهلة نتوقع أنها كافية، دون الحاجة إلى تتبع ذلك الوقت. بالتالي: إذا كنا نعرف متى يجب أن ينتهي السياق (أي نعرف المهلة الكافية)، ستكون هذه الدالة خيارًا مناسبًا. أحيانًا ربما لا نهتم بالوقت المحدد الذي ينتهي فيه السياق، وتريد أن ينتهي بعد دقيقة واحدة من بدئه. هنا يمكننا أيضًا استخدام الدالة context.WithDeadline مع بعض توابع الحزمة time لتحقيق الأمر، لكن لغة جو توفر لنا الدالة context.WithTimeout لتبسيط الأمر. إعطاء السياق وقت محدد تؤدي دالة context.WithTimeout نفس المهمة التي تؤديها الدالة السابقة، والفرق الوحيد هو أننا في context.WithDeadline نمرر قيمة زمنية محددة من النوع time.Time لإنهاء السياق، أما في context.WithTimeout نحتاج فقط إلى تمرير المدة الزمنية، أي قيمة من النوع time.Duration. إذًا، يمكننا استخدام context.WithDeadline إذا أردنا تحديد وقت معين time.Time. ستحتاج -بدون context.WithTimeout- إلى استخدام الدالة ()time.Now والتابع Add لتحديد المهلة الزمنية، أما مع context.WithTimeout، فيمكنك تحديد المهلة مباشرةً. لنفتح ملف البرنامج مجددًا ونعدله، بحيث نستخدم context.WithTimeout بدلًا من context.WithDeadline: ... func doSomething(ctx context.Context) { ctx, cancelCtx := context.WithTimeout(ctx, 1500*time.Millisecond) defer cancelCtx() ... } ... لنُشغّل ملف البرنامج main.go من خلال الأمر go run: $ go run main.go ليكون الخرج: doAnother: 1 doAnother: 2 doAnother err: context deadline exceeded doAnother: finished doSomething: finished نلاحظ أن الخرج ورسالة الخطأ هي نفسها التي حصلنا عليها في الخرج السابق عندما استخدمنا الدالة context.WithDeadline، ورسالة الخطأ أيضًا نفسها التي تظهر أن context.WithTimeout هي فعليًا مغلّّف يجري عمليات رياضية نيابةً عنك. استخدمنا في هذا القسم 3 طرق مختلفة لإنهاء السياق context.Context.؛ إذ بدأنا بالدالة context.WithCancel التي تسمح لنا باستدعاء دالة لإلغاء السياق؛ ثم الدالة context.WithDeadline مع قيمة time.Time لإنهاء السياق في وقت محدد؛ ثم الدالة context.WithTimeout مع قيمة time.Duration لإنهاء السياق بعد مدة معينة. نضمن من خلال استخدام هذه الدوال أن البرنامج لن يستهلك موارد الحاسب أكثر مما تحتاجه، كما يُسهّل فهم الأخطاء التي تُسببها السياقات عملية استكشاف الأخطاء وإصلاحها في برامج جو. الخاتمة أنشأنا خلال هذا المقال برنامجًا يستخدم حزمة السياق context التي تقدمها لغة جو بطرق مختلفة. أنشأنا دالةً تقبل سياقًا context.Context مثل معامل، واستخدمنا الدالتين context.TODO و context.Background لإنشاء سياق فارغ. بعد ذلك، استخدمنا الدالة context.WithValue لإنشاء سياق جديد يُغلّف سياقًا آخر ويحمل قيمة جديدة، وتعرّفنا على كيفية قراءة هذه القيمة لاحقًا من خلال التابع Value من داخل الدوال الأخرى التي تستخدم هذا السياق. بعد ذلك، تعرفنا على التابع Done الذي يُساعدنا في معرفة الوقت الذي تنتفي فيه الحاجة إلى إبقاء السياق. تعلمنا أيضًا كيف نلغي السياق بطرق مختلفة من خلال الدوال context.WithCancel و context.WithDeadline و context.WithTimeout وكيف نضع حدًا للمدة التي يجب أن تُنفّذ بها التعليمات البرمجية التي تستخدم تلك السياقات. ترجمة -وبتصرف- للمقال How To Use Contexts in Go لصاحبه Kristin Davidson. اقرأ أيضًا المقال السابق استخدام التاريخ والوقت في لغة جو Go بناء تطبيقات لغة Go على أنظمة التشغيل والمعماريات المختلفة.
  6. صُممت البرمجيات لتسهيل إنجاز الأعمال، وفي بعض الأحيان يتطلب الأمر التعامل مع التاريخ والوقت، إذ يمكن رؤية قيم التاريخ والوقت في معظم البرامج الحديثة، فمثلًا لو أردنا تطوير تطبيق يعطينا تنبيهات عن أوقات الصلاة، سيتوجب على البرنامج تشغيل تنبيه عند وقت وتاريخ محدد. مثال آخر هو تتبع الوقت في السيارات الحديثة، إذ نحتاج إلى إرسال تنبيهات إلى مالك السيارة لإبلاغه عن وجود مشكلة أو حاجة معينة للسيارة، أو تتبع التغييرات في قاعدة بيانات لإنشاء سجل تدقيق، أو الوقت الذي يتطلبه إنجاز عملية ما مثل عبور شارع معين للوصول إلى الوجهة …إلخ. يشير هذا إلى ضرورة التعامل مع التاريخ والوقت في البرامج والتفاعل معها وعرضها على المستخدمين بتنسيق واضح وسهل الفهم، فهذه خاصية أساسية لهكذا تطبيقات. سنُنشئ خلال هذا المقال برنامج جو يمكنه معرفة التوقيت المحلي الحالي من خلال جهاز الحاسب الذي يعمل عليه، ثم عرضه على الشاشة بتنسيق سهل القراءة. بعد ذلك، سنفسّر سلسلة نصية لاستخراج معلومات التاريخ والوقت، كما سنغيّر أيضًا قيم التاريخ والوقت من منطقة زمنية إلى أخرى، ونضيف أو نطرح قيم الوقت لتحديد الفاصل الزمني بين وقتين. المتطلبات لتتابع هذا المقال، ستحتاج إلى: إصدار مُثبّت من جو 1.16 أو أعلى، ويمكنك الاستعانة بمقال تثبيت لغة جو Go وإعداد بيئة برمجة محلية على أبونتو Ubuntu لإعداده. تثبيت لغة جو وإعداد بيئة برمجة محلية على نظام ماك macOS. تثبيت لغة جو وإعداد بيئة برمجة محلية على ويندوز. الحصول على التاريخ والوقت الحالي سنستخدم خلال هذا القسم حزمة لغة جو time للوصول إلى قيم التاريخ والوقت الحالي. تُقدّم لنا هذه الحزمة القياسية من مكتبة لغة جو القياسية -العديد من الدوال المتعلقة بالتاريخ والوقت، والتي يمكن من خلالها تمثيل نقاط معينة من الوقت باستخدام النوع time.Time. يمكننا أيضًا من خلال هذه الدوال التقاط بعض المعلومات عن المنطقة الزمنية التي تمثِّل التاريخ والوقت المقابل. كما هي العادة، سنحتاج لبدء إنشاء برامجنا إلى إنشاء مجلد للعمل ووضع الملفات فيه. يمكن أن نضع المجلد في أي مكان على الحاسب، إذ يكون للعديد من المبرمجين عادةً مجلدٌ يضعون داخله كافة مشاريعهم. سنستخدم في هذا المقال مجلدًا باسم "projects"، لذا فلننشئ هذا المجلد وننتقل إليه: $ mkdir projects $ cd projects الآن، من داخل هذا المجلد، سنشغّل الأمر mkdir لإنشاء مجلد "datetime" ثم سنستخدم cd للانتقال إليه: $ mkdir datetime $ cd datetime يمكننا الآن فتح ملف main.go باستخدام محرر نانو nano أو أي محرر آخر تريده: $ nano main.go نضيف الدالة main التي سنكتب فيها تعليمات الحصول على التاريخ والوقت وعرضهما: package main import ( "fmt" "time" ) func main() { currentTime := time.Now() fmt.Println("The time is", currentTime) } استخدمنا الدالة time.Now من الحزمة time للحصول على الوقت الحالي مثل قيمة من النوع time.Time وتخزينها في المتغير currentTime، ثم طبعنا قيمة هذا المتغير باستخدام الدالة fmt.Println، إذ سيُطبع وفقًا لتنسيق سلسلة نصية افتراضي خاص بالنوع time.Time. شغّل الآن ملف البرنامج "main.go" باستخدام الأمر go run: $ go run main.go سيبدو الخرج الذي يعرض التاريخ والوقت الحاليين مشابهًا لما يلي: The time is 2021-08-15 14:30:45.0000001 -0500 CDT m=+0.000066626 طبعًا في كل مرة نُشغّل فيها هذا البرنامج سيكون التاريخ والوقت مختلفين، كما أن المنطقة الزمنية ‎0500 CDT- مُتغيرة تبعًا للمنطقة الزمنية التي ضُبط عليها الحاسب كما سبق وأشرنا. نلاحظ وجود القيمة =m، التي تُشير إلى ساعة رتيبة monotonic clock، وتُستخدم ضمنيًّا في جو عند قياس الاختلافات في الوقت، وقد صُممت لتعويض التغييرات المحتملة في تاريخ ووقت ساعة نظام الحاسب أثناء تشغيل البرنامج. من خلال هذه الساعة، ستبقى القيمة المُعادة من الدالة time.Now صحيحة حتى لو جرى تغيير ساعة نظام الحاسب لاحقًا. مثلًا لو استدعينا الدالة time.Now الآن وكان الوقت 10:50، ثم بعد دقيقتين جرى تأخير لساعة الحاسب بمقدار 60 دقيقة، ثم بعد 5 دقائق (من الاستدعاء الأول للدالة time.Now) استدعينا الدالة time.Now مرةً أخرى (في نفس البرنامج)، سيكون الخرج 10:55 وليس 9:55. لست بحاجة إلى فهم أكثر من ذلك حول آلية عمل هذه الساعة خلال المقال، لكن إذا كنت ترغب في معرفة المزيد حول الساعات الرتيبة وكيفية استخدامها، لكن يمكنك الذهاب إلى التوثيق الرسمي ورؤية المزيد من التفاصيل لو أحببت. قد يكون التنسيق الذي يظهر به التاريخ والوقت على شاشة الخرج غير مناسب وقد ترغب بتغييره أو أنه يتضمن أجزاء من التاريخ أو الوقت أكثر مما تريد عرضه. لحسن الحظ، يوفر النوع time.Time العديد من الدوال لتنسيق عرض التاريخ والوقت وعرض أجزاء محددة منهما؛ فمثلًا لو أردنا معرفة السنة فقط من المتغير currentTime يمكننا استخدام التابع Year أو يمكننا عرض الساعة فقط من خلال التابع Hour. لنفتح ملف "main.go" مرةً أخرى ونضيف التعديلات التالية لرؤية ذلك: ... func main() { currentTime := time.Now() fmt.Println("The time is", currentTime) fmt.Println("The year is", currentTime.Year()) fmt.Println("The month is", currentTime.Month()) fmt.Println("The day is", currentTime.Day()) fmt.Println("The hour is", currentTime.Hour()) fmt.Println("The minute is", currentTime.Hour()) fmt.Println("The second is", currentTime.Second()) } شغّل البرنامج باستخدام go run: $ go run main.go سيكون الخرج: The time is 2021-08-15 14:30:45.0000001 -0500 CDT m=+0.000066626 The year is 2021 The month is August The day is 15 The hour is 14 The minute is 14 The second is 45 كما ذكرنا منذ قليل: في كل مرة نُشغّل فيها هذا البرنامج سيكون التاريخ أو الوقت مختلفين، لكن التنسيق يجب أن يكون متشابهًا. طبعنا في هذا المثال التاريخ كاملًا (السطر الأول)، ثم استخدمنا توابع النوع time.Time لعرض تفاصيل محددة من التاريخ كلٌ منها بسطر منفرد؛ بحيث عرضنا السنة ثم الشهر ثم اليوم ثم الساعة وأخيرًا الثواني. ربما نلاحظ أن الشهر طُبع اسمه August وليس رقمه كما في التاريخ الكامل، وذلك لأن التابع Month يعيد الشهر على أنه قيمة من النوع time.Month بدلًا من مجرد رقم، ويكون التنسيق عند طباعته سلسلة نصية string. لنحدّث ملف "main.go" مرةً أخرى ونضع استدعاءات التوابع السابقة كلها ضمن دالة fmt.Printf لنتمكن من طباعة التاريخ والوقت الحاليين بتنسيق أقرب إلى ما قد نرغب في عرضه على المستخدم: ... func main() { currentTime := time.Now() fmt.Println("The time is", currentTime) fmt.Printf("%d-%d-%d %d:%d:%d\n", currentTime.Year(), currentTime.Month(), currentTime.Day(), currentTime.Hour(), currentTime.Hour(), currentTime.Second()) } شغّل البرنامج باستخدام go run: $ go run main.go سيكون الخرج على النحو التالي: The time is 2021-08-15 14:30:45.0000001 -0500 CDT m=+0.000066626 2021-8-15 14:14:45 الخرج الآن أقرب بكثير إلى ما نريد، ولكن لا تزال هناك بعض الأشياء التي يمكن تعديلها في الخرج، فمثلًا عُرض الشهر رقمًا هذه المرة، لأننا استخدمنا العنصر النائب d% الذي سيُجبر النوع time.Month على استخدام رقم وليس سلسلة نصية. هنا قد نفضل عرض رقمين 08 بدلًا من رقم واحد 8، وبالتالي يجب أن نغيّر تنسيق fmt.Printf وفقًا لذلك، ولكن ماذا لو أردنا أيضًا إظهار الوقت بتنسيق 12 ساعة بدلًا من 24 ساعة كما هو موضح في الخرج أعلاه؟ سيتطلب هذا بعض العمليات الحسابية الخاصة ضمن الدالة fmt.Printf. بالرغم من إمكانية طباعة التواريخ والأوقات باستخدام fmt.Printf، ولكن يمكن أن يُصبح اﻷمر مرهقًا إذا أردنا إجراء تنسيقات أعقد، فقد ينتهي بنا الأمر بكتابة عدد كبير من الأسطر البرمجية لكل جزء نريد عرضه، أو قد نحتاج إلى إجراء عدد من العمليات الحسابية الخاصة لتحديد ما نريد عرضه. أنشأنا في هذا القسم برنامجًا يستخدم الدالة time.Now للحصول على الوقت الحالي واستخدمنا دوال مثل Year و Hour على النوع time.Time لعرض معلومات عن التاريخ والوقت الحاليين، كما تعرّفنا على كيفية إجراء تنسيقات بسيطة على آلية عرضهما، مثل عرض أجزاء محددة من التاريخ والوقت. رأينا أيضًا أن عملية الحصول على تنسيقات أعقد تصبح أصعب باستخدام الدالة fmt.Printf. لتسهيل الأمر توفر لغة جو تابع خاص لتنسيق التواريخ والأوقات ويعمل بطريقة مشابهة لعمل الدالة fmt.Printf التي استخدمناها في البداية. طباعة وتنسيق تواريخ محددة إضافة إلى التوابع التي رأيناها في القسم السابق، يوفر النوع time.Time تابعًا يُدعى Format، يسمح لك بتقديم نسق layout على هيئة سلسلة نصية string (بطريقة مشابهة لتنسيق السلاسل في دالتي fmt.Printf و fmt.Sprintf). يُخبر هذا النسق التابع Format بالشكل الذي نريده لطباعة التاريخ والوقت. نستخدم خلال هذا القسم نفس الشيفرة السابقة، ولكن بطريقة أكثر إيجازًا وسهولةً من خلال التابع Format. بدايةً ربما يكون من الأفضل معرفة كيف يؤثر تابع Format على خرج التاريخ والوقت في حال لم يتغير في كل مرة نُشغّل البرنامج، أي عندما نُثبّت التاريخ والوقت. نحصل حاليًا على الوقت الحالي باستخدام time.Now، لذلك في كل مرة نُشغّل البرنامج سيظهر رقم مختلف. هناك دالةٌ مفيدة أخرى توفرها الحزمة time، وهي الدالة time.Date التي تسمح بتحديد تاريخ ووقت محددين لتمثيلهم مثل قيم من النوع time.Time. لنبدأ باستخدام الدالة time.Date بدلًا من time.Now في البرنامج. افتح ملف "main.go" مرةً أخرى واستبدل time.Now بدالة time.Date: ... func main() { theTime := time.Date(2021, 8, 15, 14, 30, 45, 100, time.Local) fmt.Println("The time is", theTime) } تتضمن معاملات الدالة time.Date السنة والشهر واليوم والساعة والدقيقة والثواني من التاريخ والوقت اللذين نريد تمثيلهما للنوع time.Time. يمثل المعامل قبل الأخير النانو ثانية، والمعامل الأخير هو المنطقة الزمنية المطلوب إنشاء الوقت لها (نُغطي موضوع المناطق الزمنية لاحقًا). شغّل البرنامج باستخدام go run: $ go run main.go سيكون الخرج على النحو التالي: The time is 2021-08-15 14:30:45.0000001 -0500 CDT سيكون الخرج الذي الذي سنراه في كل مرة نُشغّل فيها البرنامج هو نفسه باستثناء المنطقة الزمنية لأنها تعتمد على المنطقة الزمنية لجهاز الحاسب الذي تُشغّل عليه، وذلك لأننا نستخدم تاريخ ووقت محددين. طبعًا لا يزال تنسيق الخرج مشابهًا لما رأيناه من قبل، لأن البرنامج لا يزال يستخدم التنسيق الافتراضي للنوع time.Time. بعد أن أصبح لدينا تاريخ ووقت قياسي للعمل به -يمكننا استخدامه لتغيير كيفية تنسيق الوقت عند عرضه باستخدام التابع Format. تنسيق عرض التاريخ والوقت باستخدام تابع Format تتضمن العديد من لغات البرمجة طريقة مشابهة لتنسيق التواريخ والأوقات التي تُعرض، ولكن الطريقة التي تُنشئ بها لغة جو تصميمًا لتلك التنسيقات قد تكون مختلفةً قليلًا عن باقي لغات البرمجة. يستخدم تنسيق التاريخ في اللغات الأخرى نمطًا مشابهًا لكيفية عمل Printf في لغة جو، إذ يُستخدم محرف % متبوعًا بحرف يمثل جزءًا من التاريخ أو الوقت المطلوب إدراجه. مثلًا قد تُمثّل السنة المكونة من 4 أرقام بواسطة العنصر النائب Y% موضوعًا ضمن السلسلة؛ أما في لغة جو تُمثّل هذه الأجزاء من التاريخ أو الوقت بمحارف تمثل تاريخًا محددًا. مثلًا، لتمثيل سنة معينة من 4 أرقام نكتب 2006 ضمن السلسلة. تتمثل فائدة هذا النوع من التصميم في أن ما نراه في الشيفرة يمثل ما سنراه في الخرج، وبالتالي عندما نكون قادرين على رؤية شكل الخرح، فإنه يسهل علينا التحقق من أن التنسيق الذي كتبناه يُطابق ما نبحث عنه، كما أنه يسهل على المطوّرين الآخرين فهم خرج البرنامج دون تشغيل البرنامج أصلًا. يعتمد جو التصميم الافتراضي التالي، لعرض التاريخ والوقت: 01/02 03:04:05PM '06 -0700 إذا نظرت إلى كل جزء من التاريخ والوقت في هذا التصميم الافتراضي، فسترى أنها تزيد بمقدار واحد لكل جزء. يأتي الشهر أولًا 01 ثم يليه يوم الشهر 02 ثم الساعة 03 ثم الدقيقة 04 ثم الثواني 05 ثم السنة 06 (أي 2006)، وأخيرًا المنطقة الزمنية 07. يسهل هذا الأمر إنشاء تنسيقات التاريخ والوقت مستقبلًا. يمكن أيضًا العثور على أمثلة للخيارات المتاحة للتنسيق في توثيق لغة جو الخاصة بحزمة time. سنستخدم الآن التابع الجديد Format لاستبدال وتنظيف تنسيق التاريخ الذي طبعناه في القسم الأخير. يتطلب الأمر -بدون Format- عدة أسطر واستدعاءات لدوال من أجل عرض ما نريده، لكن باستخدام هذه الدالة يُصبح الأمر أسهل وأنظف. لنفتح ملف "main.go" ونضيف استدعاء جديد للدالة fmt.Println ونمرر لها القيمة المُعادة من استدعاء التابع Format على المتغير theTime: ... func main() { theTime := time.Date(2021, 8, 15, 14, 30, 45, 100, time.Local) fmt.Println("The time is", theTime) fmt.Println(theTime.Format("2006-1-2 15:4:5")) } إذا نظرنا إلى التصميم المستخدم للتنسيق، فسنرى أنه يستخدم نفس التصميم الافتراضي في تحديد كيفية تنسيق التاريخ (January 2, 2006). شيء واحد يجب ملاحظته هو أن الساعة تستخدم 15 بدلًا من 03. يوضح هذا أننا نرغب في عرض الساعة بتنسيق 24 ساعة بدلًا من تنسيق 12 ساعة. شغّل البرنامج باستخدام go run: $ go run main.go سيكون الخرج على النحو التالي: The time is 2021-08-15 14:30:45.0000001 -0500 CDT 2021-8-15 14:30:45 نلاحظ أننا حصلنا على خرج مماثل للقسم الأخير لكن بطريقة أبسط. كل ما نحتاج إليه هو سطر واحد من التعليمات البرمجية وسلسلة تمثّل التصميم، ونترك الباقي للدالة Format. اعتمادًا على التاريخ أو الوقت الذي نعرضه، من المحتمل أن يكون استخدام تنسيق متغير الطول مثل التنسيق السابق عند طباعة الأرقام مباشرةً صعب القراءة لنا أو للمستخدمين أو لأي شيفرة أخرى تحاول قراءة القيمة. يؤدي استخدام 1 لتنسيق الشهر إلى عرض شهر آذار (مارس) بالرقم 3، بينما يحتاج شهر تشرين الأول (أكتوبر) محرفين ويظهر بالرقم 10. افتح الملف "main.go" وأضِف سطرًا إضافيًا إلى البرنامج لتجربة نسق آخر أكثر تنظيمًا. هذه المرة سنضع بادئة 0 قبل أجزاء التاريخ والوقت الفردية، ونجعل الساعة تستخدم تنسيق 12 ساعة. func main() { theTime := time.Date(2021, 8, 15, 14, 30, 45, 100, time.Local) fmt.Println("The time is", theTime) fmt.Println(theTime.Format("2006-1-2 15:4:5")) fmt.Println(theTime.Format("2006-01-02 03:04:05 pm")) } شغّل البرنامج باستخدام go run: $ go run main.go سيكون الخرج: The time is 2021-08-15 14:30:45.0000001 -0500 CDT 2021-8-15 14:30:45 2021-08-15 02:30:45 pm بإضافة بادئة 0 إلى أجزاء سلسلة النسق، يصبح الرقم 8 للشهر في الخرج الجديد 08، ونفس الأمر بالنسبة للساعة التي أصبحت الآن بتنسيق 12 ساعة. لاحظ أن النسق الذي نكتبه يوافق ما نراه في الخرج، وهذا يجعل الأمور أبسط. في بعض الحالات يكون هناك برامج أخرى تتفاعل مع البرنامج الذي لدينا، وقد يتضمن ذلك التعامل مع تنسيقات وتصميمات التواريخ في برنامجنا، كما أنه قد يكون من المتعب ومن العبء إعادة إنشاء هذه التنسيقات في كل مرة. في هذه الحالة ربما يكون من الأبسط استخدام تصميم تنسيق مُعرّف مُسبقًا ومعروف. استخدام تنسيقات معرفة مسبقا هناك العديد من التنسيقات جاهزة الاستخدام والشائعة للتاريخ، مثل العلامات الزمنية Timestamps لرسائل التسجيل log messages، وفي حال أردت إنشاء هكذا تنسيقات في كل مرة تحتاجها، سيكون أمرًا مملًا ومتعبًا. تتضمن حزمة time تنسيقات جاهزة يمكنك استخدامها لجعل الأمور أسهل واختصار الجهد المكرر. أحد هذه التنسيقات هو RFC 3339، وهو مستند يُستخدم لتحديد كيفية عمل المعايير على الإنترنت، ويمكن بعد ذلك بناء تنسيقات RFC على بعضها. تحدد بعض تنسيقات RFC مثل RFC 2616 كيفية عمل بروتوكول HTTP، وهناك تنسيقات تُبنى على هذا التنسيق لتوسيع تعريف بروتوكول HTTP. لذا في حالة RFC 3339، يحدد RFC تنسيقًا قياسيًا لاستخدامه في الطوابع الزمنية على الإنترنت. التنسيق معروف ومدعوم جيدًا عبر الإنترنت، لذا فإن فرص رؤيته في مكان آخر عالية. تُمثّل جميع تنسيقات الوقت المُعرّفة مسبقًا في الحزمة الزمنية time بسلسلة ثابتة const string تُسمى بالتنسيق الذي تمثله. هناك اثنين من التنسيقات المتاحة للتنسيق RFC 3339، هما: time.RFC3339 و time.RFC3339Nano، والفرق بينهما أن التنسيق الثاني يُمثّل الوقت بالنانو ثانية. لنفتح الآن ملف "main.go" ونعدّل البرنامج لاستخدام تنسيق time.RFC3339Nano للخرج: ... func main() { theTime := time.Date(2021, 8, 15, 14, 30, 45, 100, time.Local) fmt.Println("The time is", theTime) fmt.Println(theTime.Format(time.RFC3339Nano)) } نظرًا لأن التنسيقات المعرّفة مسبقًا هي قيم من النوع string للتنسيق المطلوب، نحتاج فقط إلى استبدال التنسيق الذي كُنا تستخدمه عادةً بهذا التنسيق (أو أي تنسيق جاهز آخر نريده). شغّل البرنامج باستخدام go run: $ go run main.go سيكون الخرج على النحو التالي: The time is 2021-08-15 14:30:45.0000001 -0500 CDT 2021-08-15T14:30:45.0000001-05:00 يُعد تنسيق RFC 3339 جيدًا للاستخدام إذا كنا بحاجة إلى حفظ قيمة زمنية مثل سلسلة نصية string في مكان ما، إذ يمكن قراءتها بسهولة من قبل العديد من لغات البرمجة والتطبيقات الأخرى، كما أنها مختصرة قدر الإمكان. عدّلنا خلال هذا القسم البرنامج بحيث استخدمنا التابع Format لطباعة التاريخ والوقت، إذ يمنحنا هذا النسق المرن إمكانية الحصول على الخرج الأخير. أخيرًا، تعرّفنا واستخدمنا واحدًا من السلاسل النصية المعرفة مسبقًا لطباعة الوقت والتاريخ اعتمادًا على تنسيقات جاهزة ومدعومة يمكن استخدامها مباشرةً دون تكلّف عناء كتابة تصميمات تنسيق يدويًا. سنعدّل في القسم التالي برنامجنا لتحويل قيمة السلسلة string إلى النوع time.Time لنتمكن من معالجتها من خلال تحليلها Parsing. يمكنك الاطلاع على مقال تحليل التاريخ والوقت في dot NET على أكاديمية حسوب لمزيدٍ من المعلومات حول مفهوم التحليل. تحويل السلاسل النصية إلى قيم زمنية عبر تحليلها قد نصادف في العديد من التطبيقات تواريخ ممثلة بسلاسل نصية من النوع string، وقد نرغب بإجراء بعض التعديلات أو بعض العمليات عليها؛ فمثلًا قد تحتاج إلى استخراج جزء التاريخ من القيمة، أو جزء الوقت، أو كامل القيمة للتعامل معها ..إلخ، إضافةً إلى إمكانية استخدام التابع Format لإنشاء قيم string من قيم النوع time.Time. تتيح لغة جو أيضًا إمكانية التحويل بالعكس، أي من سلسلة إلى time.Time من خلال الدالة time.Parse، إذ تعمل هذه الدالة بآلية مشابهة للتابع Format، بحيث تأخذ نسق التاريخ والوقت المتوقع إضافةً إلى قيمة السلسلة مثل معاملات. لنفتح ملف "main.go" ونحدّثه لاستخدام دالة time.Parse لتحويل timeString إلى متغير time.Time: ... func main() { timeString := "2021-08-15 02:30:45" theTime, err := time.Parse("2006-01-02 03:04:05", timeString) if err != nil { fmt.Println("Could not parse time:", err) } fmt.Println("The time is", theTime) fmt.Println(theTime.Format(time.RFC3339Nano)) } على عكس التابع Format، تُعيد الدالة time.Parse قيمة خطأ محتملة في حالة عدم تطابق قيمة السلسلة المُمرّرة مع التنسيق المُمرّر. إذا نظرنا إلى النسق المستخدم، سنرى أن النسق المُعطى لدالة time.Parse يستخدم 1 للشهر 2 لليوم من الشهر ..إلخ، وهذا هو نفس النسق المُستخدم في تابع Format. شغّل البرنامج باستخدام go run: $ go run main.go سيكون الخرج كما يلي: The time is 2021-08-15 02:30:45 +0000 UTC 2021-08-15T02:30:45Z هناك بعض الأشياء التي يجب ملاحظتها في هذا الخرج، أولها هو أن المنطقة الزمنية الناتجة عن تحليل المتغير timeString هي المنطقة الزمنية الافتراضية، وهي إزاحة 0+، والتي تُعرف بالتوقيت العالمي المُنسّق Coordinated Universal Time -أو اختصارًا UTC أو توقيت غرينتش، فنظرًا لعدم احتواء القيمة الزمنية أو التصميم على المنطقة الزمنية، لا تعرف الدالة time.Parse المنطقة الزمنية التي تريد ربطها بها، وبالتالي تعدّه غرينتش. إذا كنت بحاجة لتحديد المنطقة الزمنية، يمكنك استخدام الدالة time.ParseInLocation لذلك. يمكن ملاحظة استخدام نسق time.RFC3339Nano، لكن الخرج لا يتضمن قيم بالنانو ثانية، وسبب ذلك هو أن القيم التي تحللها دالة time.Parse ليست نانو ثانية، وبالتالي تُضبط القيمة لتكون 0 افتراضيًا، وعندما تكون النانو ثانية 0، لن يتضمّن استخدام التنسيق time.RFC3339Nano قيمًا بالنانو ثانية في الخرج. يمكن للتابع time.Parse أيضًا استخدام أيًّا من تنسيقات الوقت المعرفة مسبقًا والمتوفرة في حزمة الوقت time عند تحليل قيمة سلسلة. لنفتح ملف "main.go" ونعدّل قيمة timeString، يحبث نحل مشكلة عدم ظهور قيم النانو ثانية عند استخدام تنسيق time.RFC3339Nano ولنحدّث معاملات time.Parse بطريقة توافق التعديلات الجديدة: ... func main() { timeString := "2021-08-15T14:30:45.0000001-05:00" theTime, err := time.Parse(time.RFC3339Nano, timeString) if err != nil { fmt.Println("Could not parse time:", err) } fmt.Println("The time is", theTime) fmt.Println(theTime.Format(time.RFC3339Nano)) } شغّل البرنامج باستخدام go run: $ go run main.go سيكون الخرج على النحو التالي: The time is 2021-08-15 14:30:45.0000001 -0500 CDT 2021-08-15T14:30:45.0000001-05:00 يُظهر خرج Format هذه المرة بأن time.Parse كانت قادرة على تحليل كلٍ من المنطقة الزمنية وقيم النانو ثانية من متغير timeString. تعلمنا في هذا القسم كيفية استخدام دالة time.Parse لتحليل سلسلة string تُمثّل تاريخًا معينًا وفقًا لتصميم معين، وتعلمنا كيفية تحديد المنطقة الزمنية للسلسلة المُحللة. سنتعرّف في القسم التالي على كيفية التعامل مع المناطق الزمنية بطريقة أوسع وكيفية التبديل بين المناطق الزمنية المختلفة من خلال الميزات التي يوفرها النوع time.Time. التعامل مع المناطق الزمنية يُشاع في التطبيقات التي يستخدمها مُستخدمين من أنحاء مختلفة من العالم -تخزّين التواريخ والأوقات باستخدام توقيت غرينيتش UTC، ثم التحويل إلى التوقيت المحلي للمستخدم عند الحاجة. يسمح ذلك بتخزين البيانات بتنسيق موحّد ويجعل الحسابات بينها أسهل، نظرًا لأن التحويل مطلوب فقط عند عرض التاريخ والوقت للمستخدم. أنشأنا خلال المقال برنامجًا يعمل وفقًا للمنطقة الزمنية المحلية المتواجدين بها. لحفظ قيم البيانات من النوع time.Time على أنها قيم UTC، نحتاج أولًا إلى تحويلها إلى قيم UTC باستخدام التابع UTC الذي يُعيد القيمة الزمنية الموافقة لنظام UTC من متغير التاريخ الذي يستدعيها. ملاحظة: يجري التحويل هنا من المنطقة المحلية التي يوجد بها الحاسب الذي نستخدمه إلى UTC، وبالتالي إذا كان حاسبنا موجود ضمن منطقة UTC لن نرى فرقًا. لنفتح ملف "main.go" ونُطبق هذا الكلام: ... func main() { theTime := time.Date(2021, 8, 15, 14, 30, 45, 100, time.Local) fmt.Println("The time is", theTime) fmt.Println(theTime.Format(time.RFC3339Nano)) utcTime := theTime.UTC() fmt.Println("The UTC time is", utcTime) fmt.Println(utcTime.Format(time.RFC3339Nano)) } هذه المرة يُنشئ البرنامج متغير theTime على أنه فيمة من النوع time.Time وفقًا لمنطقتنا الزمنية، ثم يطبعه بتنسيقين مختلفين، ثم يستخدم تابع UTC للتحويل من المنطقة الزمنية المحلية الحالية إلى المنطقة الزمنية UTC. شغّل البرنامج باستخدام go run: $ go run main.go سيكون الخرج على النحو التالي: The time is 2021-08-15 14:30:45.0000001 -0500 CDT 2021-08-15T14:30:45.0000001-05:00 The UTC time is 2021-08-15 19:30:45.0000001 +0000 UTC 2021-08-15T19:30:45.0000001Z سيختلف الخرج اعتمادًا على المنطقة الزمنية المحلية، ولكن سنرى في الخرج السابق أن المنطقة الزمنية في المرة الأولى كانت بتوقيت CDT (التوقيت الصيفي المركزي لأمريكا الشمالية)، وهي "5-" ساعات من UTC. بعد استدعاء تابع UTC وطباعة الوقت وفق نظام UTC، سنرى أن الوقت تغير من 14 إلى 19، لأن تحويل الوقت إلى UTC أضاف خمس ساعات. من الممكن أيضًا تحويل UTC إلى التوقيت المحلي باستخدام التابع Local على متغير النوع time.Time بنفس الأسلوب. افتح ملف "main.go" مرةً أخرى، وضِف استدعاءً للتابع Local على المتغير utcTime لتحويله مرةً أخرى إلى المنطقة الزمنية المحلية: ... func main() { theTime := time.Date(2021, 8, 15, 14, 30, 45, 100, time.Local) fmt.Println("The time is", theTime) fmt.Println(theTime.Format(time.RFC3339Nano)) utcTime := theTime.UTC() fmt.Println("The UTC time is", utcTime) fmt.Println(utcTime.Format(time.RFC3339Nano)) localTime := utcTime.Local() fmt.Println("The Local time is", localTime) fmt.Println(localTime.Format(time.RFC3339Nano)) } شغّل البرنامج باستخدام go run: $ go run main.go ليكون الخرج: The time is 2021-08-15 14:30:45.0000001 -0500 CDT 2021-08-15T14:30:45.0000001-05:00 The UTC time is 2021-08-15 19:30:45.0000001 +0000 UTC 2021-08-15T19:30:45.0000001Z The Local time is 2021-08-15 14:30:45.0000001 -0500 CDT 2021-08-15T14:30:45.0000001-05:00 نلاحظ أنه جرى التحويل من UTC إلى المنطقة الزمنية المحلية، وهذا يعني طرح خمس ساعات من UTC، وتغيير الساعة من 19 إلى 14. عدّلنا خلال هذا القسم البرنامج لتحويل البيانات الزمنية من منطقة زمنية محلية إلى توقيت غرينتش UTC باستخدام التابع UTC، كما أجرينا تحويلًا معاكسًا باستخدام التابع Local. تتمثل إحدى الميزات الإضافية التي تقدمها حزمة time والتي يمكن أن تكون مفيدة في تطبيقاتك -في تحديد ما إذا كان وقت معين قبل أو بعد وقت آخر. مقارنة الأوقات الزمنية قد تكون مقارنة تاريخين مع بعضهما صعبة أحيانًا، بسبب المتغيرات التي يجب أخذها بالحسبان عند المقارنة، مثل الحاجة إلى الانتباه إلى المناطق الزمنية أو حقيقة أن الأشهر لها عدد مختلف من الأيام عن بعضها. توفر الحزمة الزمنية time تابعين لتسهيل ذلك، هما: Before و After اللذان يمكن تطبيقهما على متغيرات النوع time.Time. تقبل هذه التوابع قيمةً زمنيةً واحدة وتعيدان إما true أو false اعتمادًا على ما إذا كان الوقت المُمثّل بالمتغير الذي يستدعيهما قبل أو بعد الوقت المقدم. لنفتح ملف "main.go" ونرى كيف تجري الأمور: ... func main() { firstTime := time.Date(2021, 8, 15, 14, 30, 45, 100, time.UTC) fmt.Println("The first time is", firstTime) secondTime := time.Date(2021, 12, 25, 16, 40, 55, 200, time.UTC) fmt.Println("The second time is", secondTime) fmt.Println("First time before second?", firstTime.Before(secondTime)) fmt.Println("First time after second?", firstTime.After(secondTime)) fmt.Println("Second time before first?", secondTime.Before(firstTime)) fmt.Println("Second time after first?", secondTime.After(firstTime)) } شغّل البرنامج باستخدام go run: $ go run main.go سيكون الخرج على النحو التالي: The first time is 2021-08-15 14:30:45.0000001 +0000 UTC The second time is 2021-12-25 16:40:55.0000002 +0000 UTC First time before second? true First time after second? false Second time before first? false Second time after first? true بما أننا نستخدم في الشيفرة أعلاه نظام الوقت UTC، فيجب أن يكون الخرج متشابهًا بغض النظر عن المنطقة الزمنية. نلاحظ أنه عند استخدام تابع Before مع المتغير firstTime وتمرير الوقت secondTime الذي نريد المقارنة به، ستكون النتيجة true أي أن ‎2021-08-15 قبل ‎2021-12-25. عند استخدام After مع المتغير firstTime وتمرير secondTime، تكون النتيجة false لأن ‎2021-08-15 ليس بعد 2021-12-25. يؤدي تغيير ترتيب استدعاء التوابع على secondTime إلى إظهار نتائج معاكسة. هناك طريقة أخرى لمقارنة القيم الزمنية في حزمة الوقت وهي تابع Sub، الذي يطرح تاريخًا من تاريخ آخر ويُعيد قيمةً من نوع جديد هو time.Duration. على عكس قيم النوع time.Time التي تمثل نقاط زمنية مطلقة، تمثل قيمة time.Duration فرقًا في الوقت. مثلًا، قد تعني عبارة "في ساعة واحدة" مدة duration لأنها تعني شيئًا مختلفًا بناءً على الوقت الحالي من اليوم، لكنها تمثّل "عند الظهر" وقتًا محددًا ومطلقًا. تستخدم لغة جو النوع time.Duration في بعض الحالات، مثل الوقت الذي نريد فيه تحديد المدة التي يجب أن تنتظرها الدالة قبل إعادة خطأ أو كما هو الحال هنا، إذ نحتاج إلى معرفة مقدار الزمن بين وقت وآخر. لنفتح الآن ملف "main.go" ونستخدم التابع Sub على المتغير firstTime و secondTime ونطبع النتائج: ... func main() { firstTime := time.Date(2021, 8, 15, 14, 30, 45, 100, time.UTC) fmt.Println("The first time is", firstTime) secondTime := time.Date(2021, 12, 25, 16, 40, 55, 200, time.UTC) fmt.Println("The second time is", secondTime) fmt.Println("Duration between first and second time is", firstTime.Sub(secondTime)) fmt.Println("Duration between second and first time is", secondTime.Sub(firstTime)) شغّل البرنامج باستخدام go run: $ go run main.go سيكون الخرج على النحو التالي: The first time is 2021-08-15 14:30:45.0000001 +0000 UTC The second time is 2021-12-25 16:40:55.0000002 +0000 UTC Duration between first and second time is -3170h10m10.0000001s Duration between second and first time is 3170h10m10.0000001s يوضح الناتج أعلاه أن هناك 3170 ساعة و 10 دقائق و 10 ثوان و 100 نانو ثانية بين التاريخين، وهناك بعض الأشياء التي يجب ملاحظتها في الخرج، أولها أن المدة بين المرة الأولى first time والثانية second time قيمة سالبة، وهذا يشير إلى أن الوقت الثاني بعد الأول، وسيكون الأمر مماثلًا إذا طرحنا 5 من 0 وحصلنا على 5-. نلاحظ أيضًا أن أكبر وحدة قياس للمدة هي ساعة، لذلك فهي لا تُقسم إلى أيام أو شهور. بما أن عدد الأيام في الشهر غير متسق وقد يكون لكلمة "اليوم" معنى مختلف عند التبديل للتوقيت الصيفي، فإن قياس الساعة هو أدق مقياس، إذ أنه لا يتقلب. عدّلنا البرنامج خلال هذا القسم مرتين للمقارنة بين الأوقات الزمنية باستخدام ثلاث توابع مختلفة؛ إذ استخدمنا أولًا التابعين Before و After لتحديد ما إذا كان الوقت قبل أو بعد وقت آخر؛ ثم استخدمنا Sub لمعرفة المدة بين وقتين. ليس الحصول على المدة بين وقتين الطريقة الوحيدة التي تستخدم بها الحزمة الزمنية الدالة time.Duration، إذ يمكننا أيضًا استخدامها لإضافة وقت أو إزالته من قيمة من النوع time.Time. إضافة وطرح الأوقات الزمنية إحدى العمليات الشائعة عند كتابة التطبيقات التي تستخدم التواريخ والأوقات هي تحديد وقت الماضي أو المستقبل بناءً على وقت آخر مرجعي، فمثلًا يمكن استخدام هذا الوقت المرجعي لتحديد موعد تجديد الاشتراك بخدمة، أو ما إذا كان قد مر قدرٌ معينٌ من الوقت منذ آخر مرة جرى فيها التحقق من أمر معين. توفر حزمة الوقت time طريقةً لمعالجة الأمر، وذلك من خلال تحديد المدد الزمنية الخاصة بنا باستخدام متغيرات من النوع time.Duration. إنشاء قيمة من النوع time.Duration بسيط جدًا والعمليات الرياضية عليه متاحة كما لو أنه متغير عددي عادي؛ فمثلًا لإنشاء مدة زمنية time.Duration تُمثّل ساعة أو ساعتين أو ثلاثة ..إلخ، يمكننا استخدام time.Hour مضروبةً بعدد الساعات التي نريدها: oneHour := 1 * time.Hour twoHours := 2 * time.Hour tenHours := 10 * time.Hour نستخدم time.Minute و time.Second في حال الدقائق والثواني: tenMinutes := 10 * time.Minute fiveSeconds := 5 * time.Second يمكن أيضًا إضافة مدة زمنية إلى مدة أخرى للحصول على مجموعهما. لنفتح ملف main.go ونطبق ذلك: ... func main() { toAdd := 1 * time.Hour fmt.Println("1:", toAdd) toAdd += 1 * time.Minute fmt.Println("2:", toAdd) toAdd += 1 * time.Second fmt.Println("3:", toAdd) } شغّل البرنامج باستخدام go run: $ go run main.go سيكون الخرج على النحو التالي: 1: 1h0m0s 2: 1h1m0s 3: 1h1m1s نلاحظ من الخرج أن المدة الأولى المطبوعة هي ساعة واحدة، وهذا يتوافق مع ‎1 * time.Hour في الشيفرة. أضفنا بعد ذلك ‎1 * time.Minute إلى القيمة toAdd أي ساعة ودقيقة واحدة. أخيرًا أضفنا ‎1 * time.Second إلى قيمة toAdd، لينتج لدينا ساعة واحدة ودقيقة واحدة وثانية في المدة الزمنية الممثلة بالنوع time.Duration. يمكن أيضًا دمج عمليات إضافة المُدد معًا في عبارة واحدة، أو طرح مدة من أخرى: oneHourOneMinute := 1 * time.Hour + 1 * time.Minute tenMinutes := 1 * time.Hour - 50 * time.Minute لنفتح ملف "main.go" ونعدله بحيث نستخدم توليفة من هذه العمليات لطرح دقيقة واحدة وثانية واحدة من toAdd: ... func main() { ... toAdd += 1 * time.Second fmt.Println("3:", toAdd) toAdd -= 1*time.Minute + 1*time.Second fmt.Println("4:", toAdd) } شغّل البرنامج باستخدام go run: $ go run main.go سيعطي الخرج التالي: 1: 1h0m0s 2: 1h1m0s 3: 1h1m1s 4: 1h0m0s يظهر السطر الرابع من الخرج، والذي يُمثّل ناتج طرح المجموع ‎1*time.Minute + 1*time.Second من toAdd أن العملية ناجحة، إذ حصلنا على قيمة ساعة واحدة (طُرحت الثانية والدقيقة). يتيح لنا استخدام هذه المُدد المقترنة بالتابع Add للنوع time.Time حساب المدد الزمنية بين نقطتين زمنيتين إحداهما نقطة مرجعية، مثل حساب المدة منذ أول يوم اشتراك في خدمة معينة حتى اللحظة. لرؤية مثال آخر نفتح ملف "main.go" ونجعل قيمة toAdd تساوي 24 ساعة أي ‎24 * time.Hour، ثم نستخدم التابع Add على قيمة متغير من النوع time.Time لمعرفة الوقت الذي سيكون بعد 24 ساعة من تلك النقطة: ... func main() { theTime := time.Date(2021, 8, 15, 14, 30, 45, 100, time.UTC) fmt.Println("The time is", theTime) toAdd := 24 * time.Hour fmt.Println("Adding", toAdd) newTime := theTime.Add(toAdd) fmt.Println("The new time is", newTime) } شغّل البرنامج باستخدام go run: $ go run main.go سيكون الخرج على النحو التالي: The time is 2021-08-15 14:30:45.0000001 +0000 UTC Adding 24h0m0s The new time is 2021-08-16 14:30:45.0000001 +0000 UTC نلاحظ أن إضافة 24 ساعة إلى التاريخ ‎2021-08-15 سينتج عنه التاريخ الجديد ‎2021-08-16. يمكننا أيضًا استخدام التابع Add لطرح الوقت، إذ سنستخدم قيمة سالبة ببساطة، فيصبح الأمر كما لو أننا نستخدم التابع Sub. لنفتح ملف "main.go" ونطبق هذا الكلام، fحيث سنطرح هذه المرة 24 ساعة، أي يجب أن نستخدم قيمة "24-". ... func main() { theTime := time.Date(2021, 8, 15, 14, 30, 45, 100, time.UTC) fmt.Println("The time is", theTime) toAdd := -24 * time.Hour fmt.Println("Adding", toAdd) newTime := theTime.Add(toAdd) fmt.Println("The new time is", newTime) } ` شغّل البرنامج باستخدام go run: $ go run main.go سيكون الخرج كما يلي: The time is 2021-08-15 14:30:45.0000001 +0000 UTC Adding -24h0m0s The new time is 2021-08-14 14:30:45.0000001 +0000 UTC نلاحظ من الخرج أنه قد طُرح 24 ساعة من الوقت الأصلي. استخدمنا خلال هذا القسم التوابع time.Hour و time.Minute و time.Second لإنشاء قيم من النوع time.Duration. استخدمنا أيضًا قيم النوع time.Duration مع التابع Add للحصول على قيمة جديدة لمتغير من النوع time.Time. سيكون لدينا -من خلال التوابع time.Now و Add و Before و After- القدرة الكافية على التعامل مع التاريخ والوقت في التطبيقات. الخاتمة استخدمنا خلال هذا المقال الدالة time.Now للحصول على التاريخ والوقت المحلي الحالي على شكل قيم من النوع time.Time، ثم استخدمنا التوابع Year و Month و Hour ..إلخ، للحصول على معلومات محددة من التاريخ والوقت. استخدمنا بعد ذلك التابع Format لطباعة الوقت بالطريقة التي نريدها وفقًا لتنسيق مخصص نقدمه أو وفقًا لتنسيق جاهز مُعرّف مسبقًا. استخدمنا الدالة time.Parse لتحويل قيمة سلسلة نصية string تمثّل بيانات زمنية إلى قيمة من النوع time.Time لنتمكن من التعامل معها. تعلمنا أيضًا كيفية التبديل من المنطقة الزمنية المحلية إلى منطقة غرينتش UTC باستخدام التابع Local والعكس. تعلمنا استخدام توابع Sub و Add لإجراء عمليات جمع وطرح على البيانات الزمنية، لإيجاد الفرق بين مدتين زمنيتين أو لإضافة مدة زمنية إلى بيانات زمنية محددة. ترجمة -وبتصرف- للمقال How To Use Dates and Times in Go لصاحبه Kristin Davidson. اقرأ أيضًا المقال السابق كيفية إرفاق معلومات إضافية عن الأخطاء في لغة Go كتابة برنامجك الأول في جو Go بناء تطبيقات لغة Go على أنظمة التشغيل والمعماريات المختلفة
  7. عندما تفشل دالة في لغة جو، فإنها تُعيد قيمةً باستخدام الواجهة error للسماح للمُستدعي بمعالجة الخطأ. في كثير من الأحيان يستخدم المطورون الدالة fmt.Errorf من الحزمة fmt لإعادة هذه القيم. قبل الإصدار 1.13 من لغة جو، كان الجانب السلبي لاستخدام هذه الدالة هو أننا سنفقد المعلومات المتعلقة بالأخطاء. لحل هذه المشكلة استخدم المطورون حزمًا توفر أساليب لتغليف wrap هذه الأخطاء داخل أخطاء أخرى، أو إنشاء أخطاء مخصصة من خلال تنفيذ التابع Error() string على أحد أنواع خطأ struct الخاصة بهم. أحيانًا، يكون إنشاء هذه الأنواع من struct مملًا إذا كان لديك عدد من الأخطاء التي لا تحتاج إلى المعالجة الصريحة من قبل من المُستدعي. جاء إصدار جو 1.13 مع ميزات لتسهيل التعامل مع هذه الحالات، وتتمثل إحدى الميزات في القدرة على تغليف الأخطاء باستخدام دالة fmt.Errorf بقيمة خطأ error يمكن فكها لاحقًا للوصول إلى الأخطاء المغلفة. بالتالي، أنهى تضمين دالة تغليف للأخطاء داخل مكتبة جو القياسية الحاجة إلى استخدام طرق ومكتبات خارجية. إضافةً إلى ذلك، تجعل الدالتان errors.Is و errors.As من السهل تحديد ما إذا كان خطأ محدد مُغلّف في أي مكان داخل خطأ ما، ويمنحنا الوصول إلى هذا الخطأ مباشرةً دون الحاجة إلى فك جميع الأخطاء يدويًا. سننشئ في هذا المقال برنامجًا يستخدم هذه الدوال لإرفاق معلومات إضافية مع الأخطاء التي تُعاد من الدوال، ثم سننشئ بنية struct للأخطاء المخصصة والتي تدعم دوال التغليف وفك التغليف. المتطلبات لتتابع هذا المقال، يجب أن تستوفي الشروط التالية: إصدار مُثبّت من جو 1.13 أو أعلى، ويمكنك الاستعانة بمقال تثبيت لغة جو Go وإعداد بيئة برمجة محلية على أبونتو Ubuntu لإعداده. تثبيت لغة جو وإعداد بيئة برمجة محلية على نظام ماك macOS. تثبيت لغة جو وإعداد بيئة برمجة محلية على ويندوز. (اختياري) قراءة مقال معالجة الأخطاء في لغة جو Go ، قد يكون مفيدًا للحصول على شرح أكثر تعمقًا لمعالجة الأخطاء. عمومًا نحن نغطي بعض الموضوعات منها في هذا المقال، لكن بشرح عالي المستوى. (اختياري) هذ المقال نوعًا ما هو امتداد لمقال معالجة حالات الانهيار في لغة جو Go مع الإشارة إلى بعض الميزات. قراءة المقالة السابقة مهم أو مفيد، لكن ليس ضروري. إعادة ومعالجة الأخطاء في لغة جو تُعد معالجة الأخطاء من الممارسات الجيدة التي تحدث أثناء تنفيذ البرامج حتى لا يراها المستخدمون أبدًا، لكن لا بُد من التعرّف عليها أولًا لمعالجتها. يمكننا في لغة جو معالجة الأخطاء في البرامج من خلال إعادة معلومات متعلقة بهذه الأخطاء من الدوال التي حدث فيها الخطأ باستخدام واجهة interface من نوع خاص هو error، إذ يسمح استخدام هكذا واجهة لأي نوع بيانات في لغة جو أن يُعاد مثل قيمة من نوع error طالما أن ذلك النوع لديه تابع Error() string معرّف. توفر مكتبة جو القياسية دوالًا، مثل fmt.Errorf للتعامل مع هذا الأمر وإعادة خطأ error. سننشئ في هذا المقال برنامجًا مع دالة تستخدم fmt.Errorf لإعادة خطأ error، وسنضيف أيضًا معالج خطأ للتحقق من الأخطاء التي يمكن أن ترجعها الدالة. يمكنك العودة للمقال معالجة الأخطاء في لغة جو Go . لدى معظم المطورين مجلد يضعون داخله مشاريعهم، وسنستخدم في هذا المقال مجلدًا باسم "projects". لننشئ هذا المجلد وننتقل إليه: $ mkdir projects $ cd projects سننشئ داخل مجلد "projects" مجلدًا باسم "errtutorial" لوضع البرنامج داخله: $ mkdir errtutorial سننتقل إليه: $ cd errtutorial سنستخدم الآن الأمر go mod init لإنشاء وحدة errtutorial: $ go mod init errtutorial نفتح الآن ملف "main.go" من مجلد errtutorial باستخدام محرر نانو nano أو أي محرر آخر تريده: $ nano main.go الآن، سنكتب البرنامج، الذي سيمر ضمن حلقة على الأرقام من 1 إلى 3 ويحاول أن يحدد ما إذا كان أحد هذه الأرقام صالحًا أم لا باستخدام دالة تُدعى validateValue؛ فإذا لم يكن الرقم صالحًا، سيستخدم البرنامج الدالة fmt.Errorf لتوليد قيمة من النوع error تُعاد منها، إذ تسمح هذه الدالة بإنشاء قيمة error، بحيث تكون رسالة الخطأ هي الرسالة التي تقدمها للدالة، وهي تعمل بطريقة مشابهة للدالة fmt.Printf، لكن تُعاد مثل خطأ بدلًا من طباعة الرسالة على الشاشة. الآن، ستكون هناك عملية تحقق من قيمة الخطأ في الدالة الرئيسية main، لمعرفة ما إذا كانت القيمة هي nil أو لا؛ فإذا كانت nil ستكون الدالة نجحت في التنفيذ دون أخطاء وسنرى الرسالة !valid، وإلا سيُطبع الخطأ الحاصل. لنضع الآن هذه الشيفرة داخل ملف "main.go": package main import ( "fmt" ) func validateValue(number int) error { if number == 1 { return fmt.Errorf("that's odd") } else if number == 2 { return fmt.Errorf("uh oh") } return nil } func main() { for num := 1; num <= 3; num++ { fmt.Printf("validating %d... ", num) err := validateValue(num) if err != nil { fmt.Println("there was an error:", err) } else { fmt.Println("valid!") } } } تأخذ الدالة validateValue في البرنامج السابق رقمًا وتعيد خطأً اعتمادًا على ما إذا كانت القيمة المُمررة صالحة أم لا. في مثالنا العدد 1 غير صالح، وبالتالي تُعيد الدالة خطأ مع رسالة that's odd. الرقم 2 غير صالح ويؤدي إلى إرجاع خطأ مع رسالة uh oh. تستخدم الدالة validateValue الدالة fmt.Errorf لتوليد قيمة الخطأ المُعادة، إذ تُعد الدالة fmt.Errorf ملائمة لإعادة الأخطاء، لأنها تسمح بتنسيق رسالة خطأ باستخدام تنسيق مشابه للدالة fmt.Printf أو fmt.Sprintf دون الحاجة إلى تمرير هذه السلسلة string إلى errors.New. ستبدأ حلقة for داخل الدالة main بالمرور على الأرقام من 1 إلى 3 وتخزّن القيمة ضمن المتغير num. سيؤدي استدعاء fmt.Printf داخل متن الحلقة إلى طباعة الرقم الذي يتحقق منه البرنامج حاليًا. بعد ذلك، سيجري استدعاء الدالة validateValue مع تمرير المتغير num (الذي نريد التحقق من صلاحيته)، وتخزين نتيجة الاستدعاء هذا في المتغير err. أخيرًا، إذا كانت قيمة err ليست nil، فهذا يعني أن خطًأ ما قد حدث وستُطبع رسالة الخطأ باستخدام fmt.Println، أما إذا كانت قيمته nil، فهذا يعني أن الرقم صالح وسنرى على الخرج "!valid". بعد حفظ التغييرات الأخيرة يمكننا تشغيل ملف البرنامج "main.go" باستخدام الأمر go run من المجلد "errtutorial": $ go run main.go سيُظهر الخرج أن البرنامج قد تحقق من صحة كل رقم، وأن الرقم 1 والرقم 2 أديا إلى إظهار الأخطاء المناسبة لهما: validating 1... there was an error: that's odd validating 2... there was an error: uh oh validating 3... valid! نلاحظ أن البرنامج يحاول التحقق من الأرقام الثلاثة من خلال الدالة validateValue؛ ففي المرة الأولى قد حصل على قيمة غير صالحة هي 1 فطبع رسالة الخطأ that's odd؛ وحصل في المرة الثانية على قيمة غير صالحة أيضًا هي 2 فطبع رسالة خطأ مختلفة هي uh oh؛ وحصل في المرة الثالثة على الرقم 3 وهي قيمة صالحة فأعاد قيمة !valid وفي هذه الحالة تكون قيمة الخطأ المُعادة هي nil إشارةً إلى عدم حدوث أي مشاكل وأن الرقم صالح. وفقًا للطريقة التي كُتبت فيها الدالة validateValue، يمكننا أن نقول أنها ستُعيد nil من أجل أي قيمة باستثناء الرقمين 1 و2. استخدمنا في هذا القسم الدالة fmt.Errorf لإنشاء قيم خطأ مُعادة من دالة، وأضفنا مُعالج خطأ يطبع رسالة الخطأ عند حصوله من الدالة. قد يكون من المفيد أحيانًا معرفة معنى الخطأ، وليس مجرد حدوث خطأ. سنتعلم في القسم التالي كيفية تخصيص معالجة الأخطاء لحالات محددة. معالجة أخطاء محددة باستخدام أخطاء الحارس Sentinel errors عندما نستقبل قيمة خطأ من دالة، فإن أبسط طريقة لمعالجة هذا الخطأ هي التحقق من قيمته إذا كانت nil أو لا، إذ يخبرنا هذا ما إذا كانت الدالة تتضمن خطأً، ولكن قد نرغب أحيانًا في تخصيص معالجة الأخطاء لحالة خطأ محددة. لنتخيل أن لدينا شيفرة مرتبطة بخادم بعيد، وأن معلومات الخطأ الوحيدة التي نحصل عليها هي "لديك خطأ". قد نرغب في معرفة ما إذا كان الخطأ بسبب عدم توفر الخادم، أو إذا كانت بيانات اعتماديات الاتصال connection credentials الخاصة بنا غير صالحة. إذا كنا نعلم أن الخطأ يعني أن بيانات اعتماد المستخدم خاطئة، فقد نرغب في إعلام المستخدم فورًا بذلك، ولكن إذا كان الخطأ يعني أن الخادم غير متاح، فقد نرغب في محاولة إعادة الاتصال عدة مرات قبل إعلام المستخدم. سنتمكن من خلال تحديد الاختلاف بين هذه الأخطاء من كتابة برامج أكثر قوة وسهولة في الاستخدام. تتمثل إحدى الطرق التي يمكن من خلالها التحقق من نوع محدد من الخطأ في استخدام التابع Error على النوع error للحصول على الرسالة من الخطأ ومقارنة هذه القيمة بنوع الخطأ الذي نبحث عنه. لنتخيل أننا نريد إظهار رسالة غير الرسالة there was an error: uh oh التي رأيناها سابقًا عند الحصول على الخطأ uh oh، وإحدى الطرق لمعالجة هذه الحالة هي التحقق من القيمة المعادة من التابع Error كما يلي: if err.Error() == "uh oh" { // Handle 'uh oh' error. fmt.Println("oh no!") } سيجري في الشيفرة أعلاه التحقق من قيمة السلسلة المُعادة من ()err.Error لمعرفة ما إذا كانت القيمة هي uh oh وستجري الأمور على نحو سليم، لكن لن تعمل الشيفرة السابقة إذا كانت السلسلة النصية التي تُعبّر عن الخطأ uh oh مختلفة قليلًا في مكان آخر في البرنامج. يمكن أن يؤدي التحقق من الأخطاء بهذه الطريقة أيضًا إلى تحديثات مهمة على الشيفرة إذا كانت رسالة الخطأ نفسها بحاجة إلى التحديث، إذ يجب تحديث كل مكان يجري فيه التحقق من الخطأ. خذ على سبيل المثال الشيفرة التالية: func giveMeError() error { return fmt.Errorf("uh h") } err := giveMeError() if err.Error() == "uh h" { // "uh h" error code } تتضمن رسالة الخطأ هنا خطأً إملائيًا، إذ يغيب الحرف oعن السلسلة uh oh. إذا حدث ولاحظنا هذا الخطأ وأردنا إصلاحه، يتعين علينا إصلاحه في جميع أجزاء الشيفرة الأخرى التي تتضمن جملة التحقق "err.Error() == "uh oh، وإذا نسينا أحدهم وهذا محتمل لأنه تغيير في حرف واحد فقط، فلن يعمل معالج الخطأ المخصص لأنه يتوقع uh h وليس uh oh. قد نرغب في مثل هذه الحالات بمعالجة خطأ محدد بطريقة مختلفة، إذ من الشائع إنشاء متغير يكون الغرض منه الاحتفاظ بقيمة خطأ. يمكن للشيفرة بهذه الطريقة أن تتحقق من حصول هذا المتغير بدلًا من السلسلة. تبدأ أسماء هذه المتغيرات عادةً بالعبارة Err أو err للإشارة إلى أنها أخطاء. استخدم err، إذا كان الهدف استخدام الخطأ فقط داخل الحزمة المعرّف فيها، أما إذا أردت استخدامه خارج الحزمة، استخدم البادئة Err ليصبح قيمة مُصدّرة exported value على غرار ما نفعله مع الدوال أو البنى struct. لنفترض الآن أنك كنت تستخدم إحدى قيم الخطأ هذه في المثال السابق الذي تضمن أخطاء إملائية: var errUhOh = fmt.Errorf("uh h") func giveMeError() error { return errUhOh } err := giveMeError() if err == errUhOh { // "uh oh" error code } جرى هنا تعريف المتغير errUhOh على أنه قيمة الخطأ للخطأ "uh oh" (على الرغم من أنه يحتوي على أخطاء إملائية). تُعيد الدالة giveMeError قيمة errUhOh لأنها تريد إعلام المُستدعي بحدوث خطأ uh oh. تقارن شيفرة معالجة الخطأ بعد ذلك قيمة err التي أُعيدت من giveMeError مع errUhOh لمعرفة ما إذا كان الخطأ "uh oh" هو الخطأ الذي حدث. حتى إذا جرى العثور على الخطأ الإملائي وجرى إصلاحه، فستظل جميع التعليمات البرمجية تعمل، لأن التحقق من الخطأ يجري من خلال المقارنة مع القيمة errUhOh، وقيمة errUhOh هي قيمة ثابتة تُعاد من giveMeError. تُعرّف قيمة الخطأ المُراد فحصها ومقارنتها بهذه الطريقة بخطأ الحارس sentinel error، وهو خطأ مُصمّم ليكون قيمة فريدة يمكن مقارنتها دائمًا بمعنًى معين. ستحمل قيمة errUhOh السابقة دائمًا نفس المعنى (حدوث خطأ uh oh)، لذلك يمكن للبرنامج الاعتماد على مقارنة خطأ مع errUhOh لتحديد ما إذا كان هذا الخطأ قد حدث أم لا. تتضمن مكتبة جو القياسية عددًا من أخطاء الحارس لتطوير برامج جو. أحد الأمثلة على ذلك هو خطأ sql.ErrNoRows، الذي يُعاد عندما لا يُعيد استعلام قاعدة البيانات أية نتائج، لذلك يمكن معالجة هذا الخطأ بطريقة مختلفة عن خطأ الاتصال. بما أنه خطأ حارس، بالتالي يمكن استخدامه في شيفرة التحقق من الأخطاء لمعرفة متى لا يُعيد الاستعلام أية صفوف rows، ويمكن للبرنامج التعامل مع ذلك بطريقة مختلفة عن الأخطاء الأخرى. عند إنشاء قيمة خطأ حارس، تُستخدم الدالة errors.New من حزمة errors بدلًا من دالة fmt.Errorf التي كنا نستخدمها. لا يؤدي استخدام errors.New بدلًا من fmt.Errorf إلى إجراء أي تغييرات أساسية في كيفية عمل الخطأ، ويمكن استخدام كلتا الدالتين بالتبادل في معظم الأوقات. أكبر فرق بين الاثنين هو أن errors.New تنشئ خطأ مع رسالة ثابتة، بينما تسمح دالة fmt.Errorf بتنسيق الرسالة مع القيم بطريقة مشابهة لآلية تنسيق السلاسل في fmt.Printf أو fmt.Sprintf. نظرًا لأن أخطاء الحارس هي أخطاء أساسية بقيم لا تتغير، يُعد استخدام errors.New لإنشائها شائعًا. لنحدّث الآن البرنامج السابق من أجل استخدام خطأ الحارس مع الخطأ uh oh بدلًا من fmt.Errorf. نفتح ملف "main.go" لإضافة خطأ الحارس errUhOh الجديد وتحديث البرنامج لاستخدامه. تُحدَّث دالة validateValue بحيث تعيد خطأ الحارس بدلًا من استخدام fmt.Errorf. تُحدّث دالة main بحيث تتحقق من وجود خطأ حارس errUhOh وطباعة oh no عندما يواجهها خطأ بدلًا من رسالة :there was an error التي تظهر لأخطاء أخرى. package main import ( "errors" "fmt" ) var ( errUhOh = errors.New("uh oh") ) func validateValue(number int) error { if number == 1 { return fmt.Errorf("that's odd") } else if number == 2 { return errUhOh } return nil } func main() { for num := 1; num <= 3; num++ { fmt.Printf("validating %d... ", num) err := validateValue(num) if err == errUhOh { fmt.Println("oh no!") } else if err != nil { fmt.Println("there was an error:", err) } else { fmt.Println("valid!") } } } نحفظ الشيفرة ونشغّل البرنامج كالعادة باستخدام الأمر go run: $ go run main.go سيُظهر الخرج هذه المرة ناتج الخطأ العام للقيمة 1، لكنه يستخدم الرسالة المخصصة !oh no عندما تصادف الخطأ errUhOh الناتج من تمرير 2 إلى validateValue: validating 1... there was an error: that's odd validating 2... oh no! validating 3... valid! يؤدي استخدام أخطاء الحارس داخل شيفرة فحص الأخطاء إلى تسهيل التعامل مع حالات الخطأ الخاصة، إذ يمكن لأخطاء الحارس مثلًا المساعدة في تحديد ما إذا كان الملف الذي نقرأه قد فشل لأننا وصلنا إلى نهاية الملف، والذي يُشار إليه بخطأ الحارس io.EOF، أو إذا فشل لسبب آخر. أنشأنا في هذا القسم برنامج جو يستخدم خطأ حارس باستخدام errors.New للإشارة إلى حدوث نوع معين من الأخطاء. بمرور الوقت ومع نمو البرنامج، قد نصل إلى النقطة التي نريد فيها تضمين مزيدٍ من المعلومات في الخطأ الخاص بنا بدلًا من مجرد قيمة الخطأ uh oh. لا تقدم قيمة الخطأ هذه أي سياق حول مكان حدوث الخطأ أو سبب حدوثه، وقد يكون من الصعب تعقب تفاصيل الخطأ في البرامج الأكبر حجمًا. للمساعدة في استكشاف الأخطاء وإصلاحها ولتقليل وقت تصحيح الأخطاء، يمكننا الاستفادة من تغليف الأخطاء لتضمين التفاصيل التي نحتاجها. تغليف وفك تغليف الأخطاء يُقصد بتغليف الأخطاء أخذ قيمة خطأ معينة ووضع قيمة خطأ أخرى داخلها، وكأنها هدية مغلفة، وبما أنها مغلفة، فهذا يعني أنك بحاجة لفك غلافها لمعرفة ما تحتويه. يمكننا من خلال تغليف الخطأ تضمين معلومات إضافية حول مصدر الخطأ أو كيفية حدوثه دون فقدان قيمة الخطأ الأصلية، نظرًا لوجودها داخل الغلاف. قبل الإصدار 1.13 كان من الممكن تغليف الأخطاء، إذ كان بالإمكان إنشاء قيم خطأ مخصصة تتضمن الخطأ الأصلي، ولكن سيتعين علينا إما إنشاء أغلفة خاصة، أو استخدام مكتبة تؤدي الغرض نيابةً عنا. بدءًا من الإصدار 1.13 أضافت لغة جو دعمًا لعملية تغليف الأخطاء وإلغاء التغليف بمثابة جزء من المكتبة القياسية عن طريق إضافة الدالة errors.Unwrap والعنصر النائب w% لدالة fmt.Errorf. سنحدّث خلال هذا القسم برنامجنا لاستخدام العنصر النائب w% لتغليف الأخطاء بمزيد من المعلومات، وبعد ذلك ستستخدم الدالة errors.Unwrap لاسترداد المعلومات المغلفة. تغليف الأخطاء مع الدالة fmt.Errorf كانت الدالة fmt.Errorf تُستخدم سابقًا لإنشاء رسائل خطأ منسقة بمعلومات إضافية باستخدام عناصر نائبة مثل v% للقيم المعمّمة و s% للسلاسل النصية، أما حديثًا (بدءًا من الإصدار 1.13) أُضيف عنصر نائب جديد هو w%. عندما يجري تضمين هذا العنصر ضمن تنسيق السلسلة وتتوفر قيمة للخطأ، ستتضمّن قيمة الخطأ المُعادة من fmt.Errorf قيمة الخطأ error المُغلّف في الخطأ المُنشأ. افتح ملف "main.go" وحدّثه ليشمل دالةً جديدةً تسمى runValidation. ستأخذ هذه الدالة الرقم الذي يجري التحقق منه حاليًا وستُشغّل أي عملية تحقق مطلوبة على هذا الرقم، وفي حالتنا ستحتاج إلى تنفيذ الدالة runValidation فقط. إذا واجه البرنامج خطأً في التحقق من القيمة، سيغلِّف الخطأ باستخدام fmt.Errorf والعنصر النائب w% لإظهار حدوث خطأ في التشغيل، ثم يعيد هذا الخطأ الجديد. ينبغي أيضًا تحديث الدالة main، فبدلًا من استدعاء validateValue مباشرةً، نستدعي runValidation: ... var ( errUhOh = errors.New("uh oh") ) func runValidation(number int) error { err := validateValue(number) if err != nil { return fmt.Errorf("run error: %w", err) } return nil } ... func main() { for num := 1; num <= 3; num++ { fmt.Printf("validating %d... ", num) err := runValidation(num) if err == errUhOh { fmt.Println("oh no!") } else if err != nil { fmt.Println("there was an error:", err) } else { fmt.Println("valid!") } } } يمكننا الآن -بعد حفظ التحديثات- تشغيل البرنامج: $ go run main.go سيظهر خرج مشابه لما يلي: validating 1... there was an error: run error: that's odd validating 2... there was an error: run error: uh oh validating 3... valid! هناك عدة أشياء يمكن ملاحظتها من هذا الخرج. إذ نرى أولًا رسالة الخطأ للقيمة 1 مطبوعةً الآن ومتضمنة run error: that's odd في رسالة الخطأ. يوضح هذا أن الخطأ جرى تغليفه بواسطة الدالة fmt.Errorf الخاصة بالدالة runValidation وأن قيمة الخطأ التي جرى تغليفها that's odd مُضمّنة في رسالة الخطأ. هناك مشكلة، إذ أن معالج الخطأ الخاص الذي أضفناه إلى خطأ errUhOh لم يُنفّذ، وسنرى في السطر الثاني من الخرج والذي يتحقق من صلاحية الرقم 2 -رسالة الخطأ الافتراضية للقيمة 2 وهي: there was an error: run error: uh oh بدلًا من الرسالة المتوقعة !oh no. نحن نعلم أن الدالة ValidateValue لا تزال تُعيد الخطأ uh oh، إذ يمكننا رؤية ذلك في نهاية الخطأ المُغلف، لكن معالج الخطأ في errUhOh لم يعد يعمل. يحدث هذا لأن الخطأ الذي أُعيد من الدالة runValidation لم يعد الخطأ errUhOh، وإنما الخطأ المغلف الذي أُنشئ بواسطة الدالة fmt.Errorf. عندما تحاول الجملة الشرطية if مقارنة متغير err مع errUhOh، فإنها تُعيد خطأ لأن errUhOh لم يعد مساويًا للخطأ الذي يُغلّفerrUhOh، ولحل هذه المشكلة يجب الحصول الخطأ من داخل الغلاف عن طريق فك التغليف باستخدام دالة errors.Unwrap. فك تغليف الأخطاء باستخدام errors.Unwrap إضافةً إلى العنصر النائب w% في الإصدار 1.13، أُضيفت بعض الدوال الجديدة إلى حزمة الأخطاء errors. واحدة من هذه الدوال هي الدالة errors.Unwrap، التي تأخذ خطأ error مثل معامل، وإذا كان الخطأ المُمرّر مُغلّف خطأ، ستعيد الخطأ المُغلّف، وإذا لم يكن الخطأ المُمرّر غلافًا تُعيد nil. نفتح الآن ملف "main.go"، وباستخدام الدالة errors.Unwrap سنحدّث آلية التحقق من خطأ errUhOh لمعالجة الحالة التي يجري فيها تغليف errUhOh داخل مغلّف خطأ: func main() { for num := 1; num <= 3; num++ { fmt.Printf("validating %d... ", num) err := runValidation(num) if err == errUhOh || errors.Unwrap(err) == errUhOh { fmt.Println("oh no!") } else if err != nil { fmt.Println("there was an error:", err) } else { fmt.Println("valid!") } } } شغّل البرنامج: $ go run main.go لتكون النتيجة على النحو التالي: validating 1... there was an error: run error: that's odd validating 2... oh no! validating 3... valid! سنرى الآن في السطر الثاني من الخرج أن خطأ !oh no للقيمة 2 قد ظهر. يسمح الاستدعاء الإضافي للدالة errors.Unwrap الذي أضفناه إلى تعليمة if باكتشاف errUhOh عندما تكون err هي قيمة خطأ errUhOh وكذلك إذا كان err هو خطأ يُغلّف خطأ errUhOh مباشرةً. استخدمنا في هذا القسم العنصر w% المضاف إلى fmt.Errorf لتغليف الخطأ errUhOh داخل خطأ آخر وإعطائه معلومات إضافية. استخدمنا بعد ذلك errors.Unwrap للوصول إلى الخطأ errorUhOh المُغلّف داخل خطأ آخر. يُعد تضمين أخطاء داخل أخطاء أخرى مثل قيم ضمن سلسلة string أمرًا مقبولًا بالنسبة للأشخاص الذين يقرؤون رسائل الخطأ، ولكن قد ترغب أحيانًا في تضمين معلومات إضافية مع غلاف الأخطاء لمساعدة البرنامج في معالجة الخطأ، مثل رمز الحالة status code في خطأ طلب HTTP، وفي هكذا حالة يمكنك إنشاء خطأ مخصص جديد لإعادته. يمكنك الاطلاع على مقال كيفية استكشاف وإصلاح رموز أخطاء HTTP الشائعة على أكاديمية حسوب لمزيدٍ من المعلومات حول رموز حالة أخطاء HTTP الشائعة. أخطاء مغلفة مخصصة بما أن القاعدة الوحيدة للواجهة error في لغة جو هي أن تتضمن تابع Error، فمن الممكن تحويل العديد من أنواع لغة جو إلى خطأ مخصص، وتتمثل إحدى الطرق في تعريف نوع بنية struct مع معلومات إضافية حول الخطأ وتضمين تابع Error. بالنسبة لخطأ التحقق Validation error، سيكون من المفيد معرفة القيمة التي تسببت بالخطأ. لننشئ الآن بنيةً جديدة اسمها ValueError تحتوي على حقل من أجل القيمة Value التي تسبب الخطأ وحقل Err يحتوي على الخطأ الفعلي. تستخدم عادةً أنواع الأخطاء المخصصة اللاحقة Error في نهاية اسم النوع، للإشارة إلى أنه نوع يتوافق مع الواجهة error. افتح الآن ملف "main.go" وضِف البنية الجديدة ValueError، إضافةً إلى دالة newValueError لتُنشئ نسخًا من هذه البنية. نحتاج أيضًا إلى إنشاء تابع يُسمى Error من أجل البنية ValueError لكي تُعد من النوع error. يجب أن يُعيد التابع Error القيمة التي تريد عرضها عندما يجري تحويل الخطأ إلى سلسلة نصية. نستخدم في حالتنا الدالة fmt.Sprintf لإعادة سلسلة نصية تعرض :value error ثم الخطأ المُغلّف. حدِّث الدالة ValidateValue، فبدلًا من إعادة الخطأ الأساسي فقط، ستستخدم الدالة newValueError لإعادة خطأ مخصص: ... var ( errUhOh = fmt.Errorf("uh oh") ) type ValueError struct { Value int Err error } func newValueError(value int, err error) *ValueError { return &ValueError{ Value: value, Err: err, } } func (ve *ValueError) Error() string { return fmt.Sprintf("value error: %s", ve.Err) } ... func validateValue(number int) error { if number == 1 { return newValueError(number, fmt.Errorf("that's odd")) } else if number == 2 { return newValueError(number, errUhOh) } return nil } ... لنُشغّل البرنامج الآن: $ go run main.go ستكون النتيجة على النحو التالي: validating 1... there was an error: run error: value error: that's odd validating 2... there was an error: run error: value error: uh oh validating 3... valid! يظهر الناتج الآن أن الأخطاء مُغلّفة داخل ValueError من خلال عرض :value error قبلها، لكن نلاحظ أن خطأ uh oh لم يُكتشف مرةً أخرى لأن errUhOh داخل طبقتين من الأغلفة الآن، هما: ValueError وغلاف fmt.Errorf من runValidation. تُستخدم الدالة errors.Unwrap مرةً واحدةً فقط على الخطأ، لذلك ينتج عن استخدام (errors.Unwrap(err القيمة ValueError* وليس errUhOh. تتمثل إحدى الحلول في تعديل عملية التحقق من errUhOh بإضافة استدعاء ()errors.Unwrap مرتين لفك كلتا الطبقتين. نفتح الآن ملف "main.go" ونُعدّل الدالة main لإضافة التعديل: ... func main() { for num := 1; num <= 3; num++ { fmt.Printf("validating %d... ", num) err := runValidation(num) if err == errUhOh || errors.Unwrap(err) == errUhOh || errors.Unwrap(errors.Unwrap(err)) == errUhOh { fmt.Println("oh no!") } else if err != nil { fmt.Println("there was an error:", err) } else { fmt.Println("valid!") } } } لنُشغّل البرنامج الآن: $ go run main.go ستكون النتيجة على النحو التالي: validating 1... there was an error: run error: value error: that's odd validating 2... there was an error: run error: value error: uh oh validating 3... valid! نلاحظ أن معالجة الأخطاء الخاصة errUhOh لا تعمل، إذ كنا نتوقع ظهور خرج معالج الخطأ الخاص !oh no من أجل سطر التحقق الثاني لكن ما زالت الرسالة الافتراضية . . .:there was an error: run error تظهر بدلًا منها. هذا يحدث لأن errors.Unwrap لا تعرف كيفية فك تغليف الخطأ المخصص ValueError. يجب أن يكون لدى الخطأ المخصص تابع فك تغليف Unwrap خاص، يعيد الخطأ الداخلي مثل قيمة error.عندما كنا نُنشئ أخطاءً باستخدام fmt.Errorf مع العنصر النائب w%، كان مُصرّف جو يُنشئ خطأ مع تابع تغليف Unwrap تلقائيًا، لذلك لم نكن بحاجة إلى إضافة التابع يدويًا، لكننا هنا نستخدم دالةً خاصة، وبالتالي يجب أن نُضيف هذا التابع يدويًا. إذًا، لإصلاح مشكلة errUhOh نفتح ملف "main.go" ونضيف التابع Unwrap إلى ValueError التي تُعيد الحقل Err الذي يحتوي على الخطأ الداخلي المغلف: ... func (ve *ValueError) Error() string { return fmt.Sprintf("value error: %s", ve.Err) } func (ve *ValueError) Unwrap() error { return ve.Err } ... لنُشغّل البرنامج الآن: $ go run main.go ستكون النتيجة كما يلي: validating 1... there was an error: run error: value error: that's odd validating 2... oh no! validating 3... valid! نلاحظ أن المشكلة قد حُلت وظهرت رسالة الخطأ !oh no التي تُشير إلى الخطأ errUhOh هذه المرة، لأن errors.Unwrap أصبح قادرًا على فك تغليف ValueError. أنشأنا في هذا القسم خطأ جديد مخصص ValueError، لتزويدنا والمستخدمين بمعلومات إضافية عن عملية التحقق في جزء من رسالة الخطأ. أضفنا أيضًا دعمًا (الدالة Unwrap) لعملية فك تغليف الأخطاء إلى ValueError، بحيث يمكن استخدام errors.Unwrap للوصول إلى الخطأ المغلف. يمكننا أن نلاحظ عمومًا أن معالجة الأخطاء أصبحت صعبة نوعًا ما ويصعب الحفاظ عليها، إذ يجب علينا إضافة دالة فك تغليف errors.Unwrap من أجل كل طبقة تغليف جديدة. لحسن حظنا هناك دوال جاهزة errors.Is و errors.As تجعل العمل مع الأخطاء المغلفة أسهل. التعامل مع الأخطاء المغلفة Wrapped Errors عند الحاجة إلى إضافة استدعاء جديد للدالة errors.Unwrap من أجل كل طبقة تغليف في البرنامج، سيستغرق الأمر وقتًا طويلًا ويصعب الحفاظ عليه. لهذا الأمر أُضيفت الدالتان errors.Is و errors.As إلى حزمة الأخطاء errors بدءًا من الإصدار 1.13 من لغة جو، إذ تعمل هاتان الدالتان على تسهيل التعامل مع الأخطاء من خلال السماح لك بالتفاعل مع الأخطاء بغض النظر عن مدى عمق تغليفها داخل الأخطاء الأخرى. تسمح الدالة errors.Is بالتحقق ما إذا كانت قيمة خطأ حارس معين موجودةً في أي مكان داخل خطأ مغلف؛ بينما تتيح الدالة errors.As الحصول على مرجع لنوع معين من الأخطاء من أي مكان داخل خطأ مغلّف. فحص قيمة خطأ باستخدام الدالة errors.Is يؤدي استخدام الدالة errors.Is للتحقق من وجود خطأ معين إلى جعل معالجة الخطأ الخاص errUhOh أقصر بكثير، لأنه يعالج جميع الأخطاء المتداخلة تلقائيًّا بدل إجرائها يدويًا من قبلنا. تأخذ الدالة معاملين كل منهما خطأ error، المعامل الأول هو الخطأ الذي تلقيناه والثاني هو الخطأ الذي نريد التحقق منه. لإزالة التعقيدات من عملية معالجة الخطأ errUhOh، سنفتح ملف "main.go"، ونحدّث عملية التحقق من errUhOh في دالة main لنستخدم الدالة errors.Is: ... func main() { for num := 1; num <= 3; num++ { fmt.Printf("validating %d... ", num) err := runValidation(num) if errors.Is(err, errUhOh) { fmt.Println("oh no!") } else if err != nil { fmt.Println("there was an error:", err) } else { fmt.Println("valid!") } } } لنُشغّل البرنامج الآن: $ go run main.go سنحصل على النتيجة التالية: validating 1... there was an error: run error: value error: that's odd validating 2... oh no! validating 3... valid! يظهر الخرج رسالة الخطأ !oh no، وهذا يعني أنه على الرغم من وجود عملية فحص خطأ واحدة من أجل errUhOh، سيظل بالإمكان فك سلسلة الأخطاء والوصول إلى الخطأ المُغلّف. تستفيد الدالة errors.Is من التابع Unwrap لمواصلة البحث العميق في سلسلة من الأخطاء، حتى تعثر على قيمة الخطأ المطلوبة أو خطأ حارس أو تُصادف تابع Unwrap يعيد قيمة nil. بعد إضافة دالة errors.Is في إصدار 1.13، أصبح استخدامها موصى به للتحقق من وجود أخطاء. تجدر الإشارة إلى أنه يمكن استخدام هذه الدالة مع قيم الخطأ الأخرى، مثل خطأ sql.ErrNoRows سالف الذكر. استرداد نوع الخطأ باستخدام errors.As الدالة الثانية التي سنتحدث عنها هي errors.As، والتي تُستخدم عندما نريد الحصول على مرجع لنوع معين من الأخطاء للتفاعل معه بتفصيل أكبر. مثلًا يتيح الخطأ المخصص ValueError الذي أضفناه سابقًا؛ الوصول إلى القيمة الفعلية التي يجري التحقق من صحتها في حقل Value الخاص بالخطأ، ولكن لا يمكن الوصول إليه إلا إذا كان لدينا مرجع لهذا الخطأ أولًا. هنا يأتي دور errors.As التي تأخذ معاملين، الأول خطأ والثانية متغير لنوع الخطأ، إذ يجري المرور على سلسلة الأخطاء لمعرفة ما إذا كان أي من الأخطاء المغلفة يتطابق مع النوع المُقدم، فإذا حصل تطابق مع أحدها فسيُضبط المتغير المُمرر لنوع الخطأ بالخطأ الذي عثر عليه errors.As، وستعيد الدالة true وإلا ستعيد false. إذًا، أصبح بإمكاننا باستخدام هذه الدلة الاستفادة من نوع ValueError لإظهار معلومات إضافية عن الخطأ في معالج الأخطاء. لنفتح ملف "main.go" للمرة الأخيرة ونحدّث الدالة main لإضافة حالة جديدة لمعالجة الأخطاء من نوع ValueError، بحيث تطبع قيمة الخطأ والرقم غير الصالح وخطأ التحقق: ... func main() { for num := 1; num <= 3; num++ { fmt.Printf("validating %d... ", num) err := runValidation(num) var valueErr *ValueError if errors.Is(err, errUhOh) { fmt.Println("oh no!") } else if errors.As(err, &valueErr) { fmt.Printf("value error (%d): %v\n", valueErr.Value, valueErr.Err) } else if err != nil { fmt.Println("there was an error:", err) } else { fmt.Println("valid!") } } } صرّحنا في الشيفرة السابقة عن متغير جديد اسمه valueErr واستخدمنا errors.As، للحصول على مرجع إلى ValueError في حال كان مغلفًا داخل قيمة err. بمجرد الوصول إلى الخطأ الخاص ValueError، سنتمكن من الوصول إلى أية حقول إضافية يوفرها هذا النوع، مثل القيمة الفعلية التي فشل التحقق منها. قد يكون هذا مفيدًا إذا كانت عملية التحقق عميقة في البرنامج ولم يكن لدينا إمكانية الوصول إلى القيم لمنح المستخدمين تلميحات حول مكان حدوث خطأ ما. مثال آخر على المكان الذي يمكن أن يكون مفيدًا فيه هو في حال كنا في صدد برمجة شبكة وواجهنا خطأ net.DNSError. يمكنك -من خلال الحصول على مرجع للخطأ- معرفة ما إذا كان الخطأ ناتجًا عن عدم القدرة على الاتصال، أو ما إذا كان الخطأ ناتجًا عن القدرة على الاتصال، ولكن لم يُعثر على المورد المطلوب، وهذا يمكّننا من التعامل مع الخطأ بطرق مختلفة. لرؤية كيف تجري الأمور مع الدالة errors.As دعونا نُشغّل البرنامج: $ go run main.go ستكون النتيجة على النحو التالي: validating 1... value error (1): that's odd validating 2... oh no! validating 3... valid! لن ترى رسالة الخطأ الافتراضي … :there was an error هذه المرة في الخرج، لأن جميع الأخطاء تُعالج بواسطة معالجات الأخطاء الأخرى. يظهر السطر اﻷول من الخرج الخاص بالتحقق من صحة القيمة 1 أن الدالة errors.As تُعيد true لأن رسالة الخطأ ... value error عُرضت. بما أن الدالة errors.As تُعيد true، يُضبط المتغير valueErr ليكون خطأ ValueError ويمكن استخدامه لطباعة القيمة التي فشلت في التحقق من الصحة من خلال الوصول إلى valueErr.Value (لاحظ كيف طُبعت القيمة 1 والتي فشلت في اختبار التحقق من الصحة). يظهر أيضًا السطر الثاني من الخرج، والذي يشير إلى اختبار التحقق من صحة الرقم 2، أنه على الرغم من تغليف errUhOh داخل غلاف ValueError، ما زالت رسالة الخطأ !oh no تظهر (بالرغم من وجود طبقتي تغليف)، وهذا لأن معالج الأخطاء الخاص الذي يستخدم الدالة errors.Is مع errUhOh تأتي أولًا في مجموعة تعليمات اختبار if لمعالجة الأخطاء. بما أن هذا المعالج يُعيد true قبل تنفيذ errors.As، يُنفّذ معالج !oh no. لو كانت الدالة errors.As تظهر قبل الدالة errors.Is في البرنامج لرأينا خرجًا مشابهًا لحالة القيمة 1، أي أن رسالة !oh no ستكون value error (2): uh oh. حدّثنا خلال هذا القسم البرنامج لنتمكن من استخدام errors.Is لإزالة الاستدعاءات الكثيرة والإضافية للدالة errors.Unwrap وجعل شيفرة معالجة الأخطاء أكثر متانة وسهولة للتعديل. استخدمنا أيضًا الدالة errors.As للتحقق ما إذا كان أي من الأخطاء المغلّفة هو ValueError، ثم استخدمنا حقولها في حال توافرها. الخاتمة تعرّفنا خلال هذا المقال على كيفية تغليف خطأ باستخدام العنصر النائب w% وكيفية فكه باستخدام الدالة errors.Unwrap. أنشأنا أيضًا نوع خطأ مخصص يدعم الدالة errors.Unwrap، واستخدمناه لاستكشاف دوال مُساعدة جديدة errors.Is و errors.As تُسهّل علينا عملية إرفاق معلومات أعمق وأكثر تفصيلًا عن الأخطاء التي نُنشئها، أو نتعامل معها وتضمن استمرارية عملية فحص الأخطاء حتى في حالة الأخطاء العميقة. ترجمة -وبتصرف- للمقال How to Add Extra Information to Errors in Go لصاحبه Kristin Davidson. اقرأ أيضًا المقال السابق كيفية تنفيذ عدة دوال عبر ميزة التساير Concurrency في لغة جو Go معالجة الأخطاء في لغة جو Go
  8. واحدة من أهم الميزات التي تدعمها لغة جو هي القدرة على إجراء أكثر من عملية في وقت واحد، وهذا ما يُسمى "بالتساير Concurrency"، وقد أصبحت فكرة تشغيل التعليمات البرمجية بطريقة متسايرة جزءًا مهمًا في تطبيقات الحاسب نظرًا لما تتيحه من استثمار أكبر للموارد الحاسوبية المتاحة والسرعة في إنهاء تنفيذ البرامج، فبدلًا من تنفيذ كتلة واحدة من التعليمات البرمجية في وقت واحد، يمكن تنفيذ عدة كتل من التعليمات البرمجية. مفهوم التساير هو فكرة يمكن دعم البرامج بها من خلال تصميم البرنامج بطريقة تسمح بتنفيذ أجزاء منه باستقلالية عن الأجزاء الأخرى. تمتلك لغة جو ميزتين، هما: خيوط معالجة جو Goroutines والقنوات Channels تُسهّلان إجراء عمليات التساير؛ إذ تُسهّل الأولى عملية إعداد الشيفرة المتسايرة للبرنامج، وتجعل الثانية عملية التواصل بين أجزاء البرنامج المتساير آمنة. سنتعرّف في هذا المقال على كل من خيوط معالجة جو والقنوات، إذ سننشئ برنامجًا يستخدم خيوط معالجة جو لتشغيل عدة دوال في وقت واحد، ثم سنضيف قنوات لتحقيق اتصال آمن بين أجزاء البرنامج المتساير. أخيرًا، سنضيف عدة خيوط معالجة (كل منها يُسمّى عامل Worker، إشارةً إلى أدائه مهمة ما)، لمحاكاة حالات أكثر تعقيدًا. الفرق بين التزامن Synchronous وعدم التزامن Asynchronous والتساير Concurrency والتوازي Parallelism التزامن وعدم التزامن هما نموذجان مختلفان للبرمجة، يشيران إلى أنماط البرمجة. تُكتب التعليمات البرمجية في النموذج الأول مثل خطوات، إذ تُنفّذ التعليمات البرمجية من الأعلى إلى الأسفل، خطوةً بخطوة، وتنتقل إلى الخطوة الثانية فقط عندما تنتهي من الخطوة الأولى، ويمكن هنا التنبؤ بالخرج: func step1() { print("1") } func step2() { print("2") } func main() { step1() step2() } // result -> 12 تُكتب التعليمات البرمجية في النموذج الثاني مثل مهمات، ويجري تنفيذها بعد ذلك على التساير. التنفيذ المتساير يعني أنه من المحتمل أن تُنفّذ جميع المهام في نفس الوقت، وهنا الخرج لايمكن التنبؤ به: func task1() { print("1") } func task2() { print("2") } func main() { task1() task2() } // result -> 12 or 21 في نموذج البرمجة غير المتزامن، يكون التعامل مع المهام على أنها خطوة واحدة تُشغّل مهام متعددة، ولا يؤخذ بالحسبان كيفية ترتيب هذه المهام. يمكن تشغيلها في وقت واحد أو في بعض الحالات، ستكون هناك بعض المهام التي تتُنفّذ أولًا ثم تتوقف مؤقتًا وتأتي مهام أخرى لتحل محلها بالتناوب وهكذا. يُطلق على هذا السلوك اسم متساير. لفهم الأمر أكثر تخيل أنّه طُلب منك تناول كعكة ضخمة وغناء أغنية، وستفوز إذا كنت أسرع من يغني الأغنية وينهي الكعكة. القاعدة هي أن تغني وتأكل في نفس الوقت، ويمكنك أن تأكل كامل الكعكة ثم تغني كامل الأغنية، أو أن تأكل نصف كعكة ثم تغني نصف أغنية ثم تفعل ذلك مرةً أخرى، إلخ. ينطبق الأمر نفسه على علوم الحاسب؛ فهناك مهمتان تُنفذان بصورةٍ متسايرة، ولكن تُنفذان على وحدة معالجة مركزية أحادية النواة، لذلك ستقرر وحدة المعالجة المركزية تشغيل مهمة أولًا ثم المهمة الأخرى، أو تشغيل نصف مهمة ونصف مهمة أخرى، إلخ. يجعلنا هذا التقسيم نشعر بأن جميع المهام تُُنفّذ بنفس الوقت. حسنًا تبقى لنا معرفة التنفيذ المتوازي. بالعودة إلى مثال الكعكة والأغنية تخيل أن يُسمح لك بالاستعانة بصديق، وبالتالي يمكن أن يغني بينما أنت تأكل. هذا يقابل أن يكون لدينا وحدة معالجة مركزية بنواتين، إذ يمكن تنفيذ المهمة -التي يمكن تقسيمها لمهمتين فرعيتين- على نواتين مختلفتين، وهذا ما يسمى بالتوازي، وهو نوع معين من التساير، إذ تُنفّذ المهام فعلًا في وقت واحد. لا يمكن تحقيق التوازي إلا في بيئات متعددة النواة. المتطلبات لتتابع هذه المقالة، يجب أن تستوفي الشروط التالية: إصدار مُثبّت من جو 1.13 أو أعلى، ويمكنك الاستعانة بمقال تثبيت لغة جو Go وإعداد بيئة برمجة محلية على أبونتو Ubuntu لإعداده. على درايةٍ بكيفية عمل واستخدام الدوال في لغة جو. يمكنك الاطلاع على مقالة كيفية تعريف واستدعاء الدوال في لغة جو Go. تشغيل عدة دوال في وقت واحد باستخدام خيوط معالجة جو Goroutines تُصمّم المعالجات الحديثة أو وحدة المعالجة المركزية CPU لأجهزة الحواسيب بحيث يمكنها تنفيذ أكبر عدد من المجاري التدفقية Streams من الشيفرة (أي كتل من التعليمات البرمجية) في نفس الوقت. لتحقيق هذه الإمكانية تتضمن المعالجات نواة أو عدة أنوية (يمكنك التفكير بكل نواة على أنها معالج أصغر) كل منها قادر على تنفيذ جزء من تعليمات البرنامج في نفس الوقت. بالتالي، يمكننا أن نستنتج أنّه يمكن الحصول على أداء أسرع بازدياد عدد الأنوية التي تُشغّل البرنامج. لكن مهلًا، لا يكفي وجود عدة أنوية لتحقيق الأمر؛ إذ يجب أن يدعم البرنامج إمكانية التنفيذ على نوى متعددة وإلا لن يُنفّذ البرنامج إلا على نواة واحدة في وقت واحد ولن نستفيد من خاصية النوى المتعددة والتنفيذ المتساير. للاستفادة من وجود النوى المتعددة، يقع على عاتق المبرمج تصميم شيفرة البرنامج، بحيث يمكن تقسيم هذه الشيفرة إلى كتل تُنفّذ باستقلالية عن بعضها. ليس تقسيم شيفرة البرنامج إلى عدة أجزاء أو كتل برمجية أمرًا سهلًا، فهو يمثّل تحدٍ للمبرمج. لحسن الحظ أن لغة جو تجعل الأمر أسهل من خلال الأداة goroutine أو "تنظيم جو". تنظيم جو هو نوع خاص من الدوال يمكنه العمل في نفس الوقت الذي تعمل به خيوط المعالجة الأخرى. نقول عن برنامج أنّه مُصمّم ليُنفّذ على التساير عندما يتضمن كتل من التعليمات البرمجية التي يمكن تنفيذها باستقلالية في وقت واحد. بالحالة العادية عندما تُستدعى دالة ما، ينتظر البرنامج انتهاء تنفيذ كامل الدالة قبل أن يكمل تنفيذ الشيفرة. يُعرف هذا النوع من التنفيذ باسم التشغيل في "المقدمة Foreground" لأنه يمنع البرنامج من تنفيذ أي شيء آخر قبل أن ينتهي. من خلال "تنظيم جو"، ستُستدعى الدالة وتنفّذ في "الخلفية Background" بينما يُكمل البرنامج تنفيذ باقي الشيفرة. نقول عن شيفرةً -أو جزء من شيفرة- أنها تُنفّذ في الخلفية عندما لا يمنع تنفيذها باقي أجزاء الشيفرة من التنفيذ، أي عندما لا تكون باقي أجزاء الشيفرة مُضطرة لانتظارها حتى تنتهي. تأتي قوة خيوط معالجة جو -وهي عبارة عن خيط معالجة lightweight thread يديره مشغل جو الآني Go runtime)- من فكرة أن كل تنظيم يمكنه أن يشغل نواة معالج واحدة في نفس الوقت. إذا كان لديك معالج بأربع نوى ويتضمن برنامجك 4 خيوط معالجة، فإن كل تنظيم يمكنه أن يُنفّذ على نواة في نفس الوقت. عندما تُنفّذ عدة أجزاء من الشيفرة البرمجيّة في نفس الوقت على نوى مختلفة (كما في الحالة السابقة) -نقول عن عملية التنفيذ أنها تفرعيّة (أو متوازية). يمكنك الاطلاع على مقال تنفيذ المهام بالتوازي في dot NET على أكاديمية حسوب لمزيدٍ من المعلومات حول تنفيذ المهام على التوازي. لتوضيح الفرق بين التساير concurrency والتوازي parallelism، ألقِ نظرةً على المخطط التالي. عندما يُنفّذ المعالج دالةً ما، فهو عادةً لا يُنفّذها من البداية للنهاية دفعةً واحدة، إذ يعمل نظام التشغيل أحيانًا على التبديل بين الدوال أو خيوط المعالجة أو البرامج الأخرى التي تُنفّذ على نواة المعالج عندما تنتظر الدالة حدوث شيءٍ ما (مثلًا قد يتطلب تنفيذ الدالة استقبال مُدخلات من المستخدم أو قراءة ملف). يعرض لنا المخطط كيف يمكن للبرنامج المصمّم للعمل على التساير التنفيّذ على نواة واحدة أو عدة نوى، كما يوضح أيضًا أنه من الملائم أكثر تنفيّذ خيوط معالجة جو على التوازي من التشغيل على نواة واحدة. يوّضح المخطط على اليسار والمُسمّى "تساير Concurrency" كيف يمكن تنفيذ برنامج متساير التصميم على نواة معالج وحيدة من خلال تشغيل أجزاء من goroutine1ثم دالة أو تنظيم أو برنامج ثم goroutine2 ثم goroutine1 مرةً أخرى وهكذا. هنا يشعر المستخدم بأن البرنامج ينفِّذ كل الدوال أو خيوط المعالجة في نفس الوقت، على الرغم من أنها تُنفّذ على التسلسل. يوضّح العمود الأيمن من المخطط والمسمّى "توازي Parallelism" كيف أن نفس البرنامج يمكن أن يُنفّذ على التوازي على معالج يملك نواتين، إذ يُظهر المخطط أن النواة الأولى تنفِّذ goroutine1 وهناك دوال أو خيوط معالجة أو برامج أخرى تُنفّذ معها أيضًا، نفس الأمر بالنسبة للنواة الثانية التي تنفِّذ goroutine1. نلاحظ أحيانًا أن goroutine1 و goroutine2 تُنفّذان في نفس الوقت لكن على نوى مختلفة. يظهر هذا المخطط أيضًا سمات أخرى من سمات جو القوية، وهي قابلية التوسع Scalability، إذ يكون البرنامج قابلًا للتوسع عندما يمكن تشغيله على أي شيء بدءًا من جهاز حاسوب صغير به عدد قليل من الأنوية وحتى خادم كبير به عشرات الأنوية والاستفادة من هذه الموارد الإضافية، أي كلما أعطيته موارد أكبر، يمكنه الاستفادة منها. يوضّح المخطط أنه من خلال استخدام خيوط معالجة جو، يمكن لبرنامج متساير أن يُنفّذ على نواة وحيدة، لكن مع زيادة عدد الأنوية سيكون البرنامج قابلًا للتنفيذ المتوازي، وبالتالي يمكن تنفيّذ أكثر من تنظيم في نفس الوقت وتسريع الأداء. للبدء بإنشاء برنامج متساير، علينا أولًا إنشاء مجلد "multifunc" في المكان الذي تختاره. قد يكون لديك مجلد مشاريع خاص بك، لكن هنا سنعتمد مجلدًا اسمه "projects". يمكنك إنشاء المجلد إما من خلال بيئة تطوير متكاملة IDE أو سطر الأوامر. إذا كنت تستخدم سطر الأوامر، فابدأ بإنشاء مجلد "projects" وانتقل إليه: $ mkdir projects $ cd projects من المجلد "projects" استخدم الأمر mkdir لإنشاء مجلد المشروع "multifunc" وانتقل إليه: $ mkdir multifunc $ cd multifunc افتح الآن ملف "main.go" باستخدام محرر نانو nano أو أي محرر آخر تريده: $ nano main.go ضع بداخله الشيفرة التالية: package main import ( "fmt" ) func generateNumbers(total int) { for idx := 1; idx <= total; idx++ { fmt.Printf("Generating number %d\n", idx) } } func printNumbers() { for idx := 1; idx <= 3; idx++ { fmt.Printf("Printing number %d\n", idx) } } func main() { printNumbers() generateNumbers(3) } يعرّف هذا البرنامج الأولي دالتين generateNumbers و printNumbers، إضافةً إلى الدالة الرئيسية main التي تُستدعى هذه الدوال ضمنها. تأخذ الدالة الأولى معامًلا يُدعى total يُمثّل عدد الأعداد المطلوب توليدها، وفي حالتنا مررنا القيمة 3 لهذه الدالة وبالتالي سنرى الأعداد من 1 إلى 3 على شاشة الخرج. لا تأخذ الدالة الثانية أيّة معاملات، فهي تطبع دومًا الأرقام من 0 إلى 3. بعد حفظ ملف main.go، يمكننا تشغيله باستخدام الأمر التالي: $ go run main.go سيكون الخرج كما يلي: Printing number 1 Printing number 2 Printing number 3 Generating number 1 Generating number 2 Generating number 3 نلاحظ أن الدالة printNumbers نُفّذت أولًا، ثم تلتها الدالة generateNumbers، وهذا منطقي لأنها استُدعيت أولًا. تخيل الآن أن كل من هاتين الدالتين تحتاج 3 ثوان حتى تُنفّذ. بالتالي عند تنفيذ البرنامج السابق بطريقة متزامنة synchronously (خطوةً خطوة)، سيتطلب الأمر 6 ثوانٍ للتنفيذ (3 ثوان لكل دالة). الآن، لو تأملنا قليلًا سنجد أن هاتين الدالتين مستقلتان عن بعضهما بعضًا، أي لا تعتمد أحدهما على نتيجة تنفيذ الأخرى، وبالتالي يمكننا الاستفادة من هذا الأمر والحصول على أداء أسرع للبرنامج من خلال تنفيذ الدوال بطريقة متسايرة باستخدام خيوط معالجة جو. نظريًّا: سنتمكن من تنفيذ البرنامج في نصف المدة (3 ثوان)، وذلك لأن كل من الدالتين تحتاج 3 ثوان لتُنفّذ، وبما أنهما سيُنفذان في نفس الوقت، بالتالي يُفترض أن ينتهي تنفيذ البرنامج خلال 3 ثوان. يختلف حقيقةً الجانب النظري هنا عن التجربة، فليس ضروريًا أن يكتمل التنفيذ خلال 3 ثوان، لأن هناك العديد من العوامل الأخرى الخارجية، مثل البرامج الأخرى التي تؤثر على زمن التنفيذ. إن تنفيذ دالة على التساير من خلال تنظيم جو مُشابه لتنفيّذ دالة بطريقة متزامنة. لتنفيذ دالة من خلال تنظيم جو (أي بطريقة متسايرة) يجب وضع الكلمة المفتاحية go قبل استدعاء الدالة. لجعل البرنامج يُنفّذ جميع خيوط المعالجة على التساير، يتعين علينا إضافة تعديل بسيط للبرنامج، بحيث نجعله ينتظر انتهاء تنفيذ كل خيوط معالجة جو. هذا ضروري لأنه إذا انتهى تنفيذ دالة main ولم تنتظر انتهاء خيوط معالجة جو، فقد لا يكتمل تنفيذ خيوط معالجة جو (تحدث مقاطعة لتنفيذها إن لم تكن قد انتهت). لتحقيق عملية الانتظار هذه نستخدم WaitGroup من حزمة sync التابعة لجو، والتي تتضمّن الأدوات الأولية للتزامن synchronization primitives، مثل WaitGroup المُصممة لتحقيق التزامن بين عدة أجزاء من البرنامج. ستكون مهمة التزامن في مثالنا هي تعقب اكتمال تنفيذ الدوال السابقة وانتظارها حتى تنتهي لكي يُسمح بإنهاء البرنامج. تعمل الأداة الأولية WaitGroup عن طريق حساب عدد الأشياء التي تحتاج إلى انتظارها باستخدام دوال Add و Done و Wait. تزيد الدالة Add العداد من خلال الرقم المُمرر إلى الدالة، بينما تنقص الدالة Done العداد بمقدار واحد. تنتظر الدالة Wait حتى تصبح قيمة العداد 0، وهذا يعني أن الدالة Done استُدعيت بما يكفي من المرات لتعويض استدعاءات Add. بمجرد وصول العداد إلى الصفر، ستعود دالة الانتظار وسيستمر البرنامج في العمل. لنعدّل ملف "main.go" لتنفيذ الدوال من خلال خيوط معالجة جو باستخدام الكلمة المفتاحية go، ولنضِف sync.WaitGroup إلى البرنامج: package main import ( "fmt" "sync" ) func generateNumbers(total int, wg *sync.WaitGroup) { defer wg.Done() for idx := 1; idx <= total; idx++ { fmt.Printf("Generating number %d\n", idx) } } func printNumbers(wg *sync.WaitGroup) { defer wg.Done() for idx := 1; idx <= 3; idx++ { fmt.Printf("Printing number %d\n", idx) } } func main() { var wg sync.WaitGroup wg.Add(2) go printNumbers(&wg) go generateNumbers(3, &wg) fmt.Println("Waiting for goroutines to finish...") wg.Wait() fmt.Println("Done!") } سنحتاج -بعد التصريح عن WaitGroup- إلى معرفة عدد الأشياء التي يجب انتظارها. سيدرّك wg عند وضع التعليمة (wg.Add(2 داخل الدالة main قبل بدء تنفيذ خيوط معالجة جو، أن عليه انتظار استدعائين Done حتى يُنهي عملية الانتظار. إذا لم نفعل ذلك قبل بدء تنفيذ خيوط معالجة جو، فمن المحتمل أن تحدث حالات تعطّل في البرنامج أو قد تحدث حالة هلع Panic في الشيفرة، لأن wg لا يعرف أنه يجب أن ينتظر أية استدعاءات Done. ستستخدم كل دالة defer بعد ذلك، لاستدعاء Done بهدف تخفيض العداد بواحد بعد انتهاء تنفيذ الدالة. تُحدَّث الدالة main أيضًا لتضمين استدعاء Wait من WaitGroup، لذا ستنتظر الدالة main حتى تُستدعى الدالة Done مرتين قبل إنهاء البرنامج. بعد حفظ ملف main.go، يمكننا تشغيله باستخدام الأمر التالي: $ go run main.go ويكون الخرج على النحو التالي: Printing number 1 Waiting for goroutines to finish... Generating number 1 Generating number 2 Generating number 3 Printing number 2 Printing number 3 Done! قد يختلف الخرج عما تراه أعلاه (تذكر أن التنفيذ المتساير لا يمكن التنبؤ بسلوكه)، وقد يختلف في كل مرة تُنفّذ فيها الشيفرة. سيعتمد الخرج الناتج عن تنفيذ الدالتين السابقتين على مقدار الوقت الذي يمنحه نظام التشغيل ومُصرّف لغة جو لكل دالة وهذا أمر يصعب معرفته؛ فمثلًا قد تُمنح كل دالة وقتًا كافيًا لتُنفّذ كل تعليماتها دفعةً واحدة قبل أن يقاطع نظام التشغيل تنفيذها، وبالتالي سيكون الخرج كما لو أن التنفيذ كان تسلسيًا، وفي بعض الأحيان لن تحصل الدالة على ما يكفي من الوقت دفعةً واحدة، أي تطبع سطر ثم يتوقف تنفيذها ثم تطبع الدالة الثانية سطر ثم يتوقف تنفيذها ثم نعود للدالة الأولى فتطبع باقي الأسطر وهكذا، وسنرى عندها خرجًا مشابهًا للخرج أعلاه. على سبيل التجربة، يمكننا حذف تعليمة استدعاء ()wg.Wait في الدالة الرئيسية main ثم تنفيذ الشيفرة عدة مرات باستخدام go run. اعتمادًا على جهاز الحاسب المُستخدم، قد ترى بعض النتائج من دالتي generateNumbers و printNumbers، ولكن من المحتمل أيضًا ألا ترى أي خرج منهما إطلاقًا، وذلك لأنه عند حذفنا لاستدعاء الدالة Wait، لن ينتظر البرنامج اكتمال تنفيذ الدالتين حالما ينتهي من تنفيذ باقي تعليماته؛ أي بدلًا من استخدام مبدأ "ضعهم في الخلفية وأكمل عملك وانتظرهم"، سيعتمد مبدأ "ضعهم في الخلفية وأكمل عملك وانساهم"، وبما أن الدالة الرئيسية تنتهي بعد وقت قصير من دالة Wait، فهناك فرصة جيدة لأن يصل برنامجك إلى نهاية الدالة main ويخرج قبل انتهاء تنفيذ خيوط معالجة جو. عند حصول ذلك قد ترى بعضًا من الأرقام تُطبع من خلال الدالتين، لكن غالبًا لن ترى الخرج المتوقع كاملًا. أنشأنا في هذا القسم برنامجًا يستخدم الكلمة المفتاحية go لتنفيذ دالتين على التساير وفقًا لمبدأ خيوط معالجة جو وطباعة سلسلة من الأرقام. استخدمنا أيضًا sync.WaitGroup لجعل البرنامج ينتظر هذه خيوط المعالجة حتى تنتهي قبل الخروج من البرنامج. ربما نلاحظ أن الدالتين generateNumbers و printNumbers لا تعيدان أية قيم، إذ يتعذر على خيوط معالجة جو إعادة قيم مثل الدوال العادية، على الرغم من أنه بمقدورنا استخدام الكلمة المفتاحية go مع الدوال التي تُعيد قيم، ولكن سيتجاهل المُصرّف هذه القيم ولن تتمكن من الوصول إليها. هذا يطرح تساؤلًا؛ ماذا نفعل إذا كنا بحاجة إلى تلك القيم المُعادة (مثلًا نريد نقل بيانات من تنظيم إلى تنظيم آخر)؟ يكمن الحل باستخدام قنوات جو التي أشرنا لها في بداية المقال، والتي تسمح لخيوط معالجة جو بالتواصل بطريقة آمنة. التواصل بين خيوط معالجة جو بأمان من خلال القنوات أحد أصعب أجزاء البرمجة المتسايرة هو الاتصال بأمان بين أجزاء البرنامج المختلفة التي تعمل في وقت واحد، فإذا لم تكن حذرًا قد تتعرض لمشاكل من نوع خاص أثناء التنفيذ. على سبيل المثال، يمكن أن يحدث ما يسمى سباق البيانات Data race عندما يجري تنفيذ جزأين من البرنامج على التساير، ويحاول أحدهما تحديث متغير بينما يحاول الجزء الآخر قراءته في نفس الوقت. عندما يحدث هذا، يمكن أن تحدث القراءة أو الكتابة بترتيب غير صحيح، مما يؤدي إلى استخدام أحد أجزاء البرنامج أو كليهما لقيمة خاطئة. مثلًا، كان من المفترض الكتابة ثم القراءة، لكن حدث العكس، وبالتالي نكون قد قرأنا قيمة خاطئة. يأتي اسم "سباق البيانات" من فكرة أن عاملين Worker يتسابقان للوصول إلى نفس المتغير أو المورد. على الرغم من أنه لا يزال من الممكن مواجهة مشكلات التساير مثل سباق البيانات في لغة جو، إلا أن تصميم اللغة يُسهّل تجنبها. إضافةً إلى خيوط معالجة جو، تُعد قنوات جو ميزةً أخرى تهدف إلى جعل التساير سهلًا وأكثر أمنًا للاستخدام. يمكن التفكير بالقناة على أنها أنبوب pipe بين تنظيمين أو أكثر، ويمكن للبيانات العبور خلالها. يضع أحد خيوط معالجة البيانات في أحد طرفي الأنبوب ليتلقاه تنظيم آخر في الطرف الآخر، وتجري معالجة الجزء الصعب المتمثل في التأكد من انتقال البيانات من واحد إلى آخر بأمان نيابةً عنّا. إنشاء قناة في جو مُشابه لإنشاء شريحة slice، باستخدام الدالة المُضمّنة ()make. يكون التصريح من خلال استخدام الكلمة المفتاحية chan متبوعةً بنوع البيانات التي تريد إرسالها عبر القناة؛ فمثلًا لإنشاء قناة تُرسل قيمًا من الأعداد الصحيحة، يجب أن تستخدم النوع chan int؛ وإذا أردت قناة لإرسال قيم بايت byte[]، سنكتب chan []byte: bytesChan := make(chan []byte) يمكنك -بمجرد إنشاء القناة- إرسال أو استقبال البيانات عبر القناة باستخدام العامل ->، إذ يحدد موضع العامل -> بالنسبة إلى متغير القناة ما إذا كنت تقرأ من القناة أو تكتب إليها؛ فللكتابة إلى قناة نبدأ بمتغير القناة متبوعًا بالعامل ->، ثم القيمة التي نريد كتابتها إلى القناة: intChan := make(chan int) intChan <- 10 لقراءة قيمة من قناة: نبدأ بالمتغير الذي نريد وضع القيمة فيه، ثم نضع إما = أو =: لإسناد قيمة إلى المتغير متبوعًا بالعامل ->، ثم القناة التي تريد القراءة منها: intChan := make(chan int) intVar := <- intChan للحفاظ على هاتين العمليتين في وضع سليم، تذكر أن السهم -> يشير دائمًا إلى اليسار (عكس <-)، ويشير السهم إلى حيث تتجه القيمة. في حالة الكتابة إلى قناة، يكون السهم منطلقًا من القيمة إلى القناة. أما عند القراءة من قناة، فيكون السهم من القناة إلى المتغير. على غرار الشريحة: يمكن قراءة القناة باستخدام الكلمة المفتاحية range في حلقة for. عند قراءة قناة باستخدام range، ستُقرأ القيمة التالية من القناة في كل تكرار للحلقة وتُوضع في متغير الحلقة، وستستمر القراءة من القناة حتى إغلاق القناة، أو الخروج من حلقة for بطريقة ما، مثل استخدام تعليمة break: intChan := make(chan int) for num := range intChan { // Use the value of num received from the channel if num < 1 { break } } قد نرغب أحيانًا في السماح لدالة فقط بالقراءة أو الكتابة من القناة وليس كلاهما. لأجل ذلك يمكننا أن نضيف العامل -> إلى تصريح القناة chan. يمكننا استخدام المعامل -> بطريقة مشابهة لعملية القراءة والكتابة من قناة للسماح فقط بالقراءة أو الكتابة أو القراءة والكتابة معًا. على سبيل المثال، لتعريف قناة للقراءة فقط لقيم int، سيكون التصريح chan int->: func readChannel(ch <-chan int) { // ch is read-only } لجعل القناة للكتابة فقط، سيكون التصريح chan<- int: func writeChannel(ch chan<- int) { // ch is write-only } لاحظ أن السهم يُشير إلى خارج القناة للقراءة ويشير إلى القناة من أجل الكتابة. إذا لم يكن للتصريح سهم، كما في حالة chan int، فهذا يعني أنه يمكن استخدام القناة للقراءة والكتابة. أخيرًا، عند انتهاء الحاجة من استخدام القناة، يمكن إغلاقها باستخدام الدالة ()close، وتًعد هذه الخطوة ضرورية، فقد يؤدي إنشاء القنوات وتركها دون استخدام عدة مرات في أحد البرامجما يُعرف باسم تسرب الذاكرة Memory leak، إذ يحدث تسرب للذاكرة عندما يُنشئ برنامج ما شيئًا ما يستخدم ذاكرة الحاسب، لكنه لا يحرّر تلك الذاكرة بعد الانتهاء من استخدامها، وهذا من شأنه أن يُبطئ تنفيذ البرنامج والحاسب عمومًا؛ فعند إنشاء قناة باستخدام ()make، يخصص نظام التشغيل جزءًا من ذاكرة الحاسب للقناة، وتحرّر هذه الذاكرة عند استدعاء الدالة ()close ليُتاح استخدامها من قبل شيء آخر. لنحدّث الآن ملف "main.go" لاستخدام قناة chan int للتواصل بين خيوط معالجة جو. ستنشئ الدالة generateNumbers أرقامًا وتكتبها على القناة بينما ستقرأ الدالة printNumbers هذه الأرقام من القناة وتطبعها على الشاشة. سننشئ في الدالة main قناةً جديدة لتمريرها مثل معامل لكل دالة من الدوال الأخرى، ثم تستخدم ()close على القناة لإغلاقها لعدم الحاجة لاستخدامها بعد ذلك. يجب ألا تكون دالة generateNumbers تنظيم جو بعد ذلك، لأنه بمجرد الانتهاء من تنفيذ هذه الدالة، سينتهي البرنامج من إنشاء جميع الأرقام التي يحتاجها. تُستدعى دالة ()close بهذه الطريقة على القناة فقط قبل انتهاء تشغيل كلتا الدالتين. package main import ( "fmt" "sync" ) func generateNumbers(total int, ch chan<- int, wg *sync.WaitGroup) { defer wg.Done() for idx := 1; idx <= total; idx++ { fmt.Printf("sending %d to channel\n", idx) ch <- idx } } func printNumbers(ch <-chan int, wg *sync.WaitGroup) { defer wg.Done() for num := range ch { fmt.Printf("read %d from channel\n", num) } } func main() { var wg sync.WaitGroup numberChan := make(chan int) wg.Add(2) go printNumbers(numberChan, &wg) generateNumbers(3, numberChan, &wg) close(numberChan) fmt.Println("Waiting for goroutines to finish...") wg.Wait() fmt.Println("Done!") } تستخدم أنواع chan في معاملات الدالتين generateNumbers و printNumbers أنواع القراءة فقط والكتابة فقط، إذ تحتاج الدالة generateNumbers إلى القدرة على الكتابة في القناة وبالتالي نجعل chan للكتابة فقط من خلال جعل السهم -> يشير إلى القناة، بينما printNumbers تحتاج للقراءة فقط من خلال جعل السهم -> يشير إلى خارج القناة. على الرغم من أنه كان بإمكاننا جعل الدوال تستطيع القراءة والكتابة وليس فقط واحدًا منهما من خلال استخدام chan int، إلا أنه من الأفضل تقييدهم وفقًا لما تحتاجه كل دالة، وذلك لتجنب التسبب بطريق الخطأ في توقف برنامجك عن العمل والوقوع في حالة الجمود أو التوقف التام deadlock. تحدث حالة الجمود عندما ينتظر ينتظر جزء A من البرنامج جزءًا آخر B لفعل شيء ما، لكن الجزء B هذا ينتظر أيضًأ الجزء A لفعل شيء ما. هنا أصبح كل منهما ينتظر الآخر وبالتالي فإن كلاهما سيبقى منتظرًا ولن ينتهي من التنفيذ، ولن يكتمل تنفيذ البرنامج أيضًا. قد يحدث الجمود بسبب الطريقة التي تعمل بها اتصالات القنوات في لغة جو، فعندما يكتب جزء من البرنامج إلى قناة، فإنه سينتظر حتى يقرأ جزءًا آخر من البرنامج من تلك القناة قبل المتابعة، وبالمثل، إذا كان البرنامج يقرأ من قناة، فإنه سينتظر حتى يكتب جزءًا آخر من البرنامج على تلك القناة قبل أن يستمر. عندما يكتب جزء A على القناة، سينتظر الجزء B أن يقرأ ما كتبه على القناة قبل أن يستمر في عمله. بطريقة مشابهة إذا كان الجزء A يقرأ من قناة، سينتظر حتى يكتب الجزء B قبل أن يستمر في عمله. يُقال أن جزءًا من البرنامج "محظور Blocking" عندما ينتظر حدوث شيء آخر، وذلك لأنه ممنوع من المتابعة حتى يحدث ذلك الشيء. تُحظر القنوات عند الكتابة إليها أو القراءة منها، لذلك إذا كانت لدينا دالة نتوقع منها أن تكتب إلى القناة ولكنها عن طريق الخطأ تقرأ من القناة، فقد يدخل البرنامج في حالة الجمود لأن القناة لن يُكتب فيها إطلاقًا. لضمان عدم حدوث ذلك نستخدم أسلوب القراءة فقط chan int-> أو الكتابة فقط chan<- int بدلًًا من القراءة والكتابة معًا chan int. أحد الجوانب المهمة الأخرى للشيفرة المحدّثة هو استخدام ()close لإغلاق القناة بمجرد الانتهاء من الكتابة عليها عن طريق generateNumbers، إذ يؤدي استدعاء الدالة ()close في البرنامج السابق إلى إنهاء حلقة for ... range في دالة printNumbers. نظرًا لأن استخدام range للقراءة من القناة يستمر حتى تُغلق القناة التي يُقرأ منها، بالتالي إذا لم تُستدعى close على numberChan فلن تنتهي printNumbers أبدًا، وفي هذه الحالة لن يُستدعى التابع Done الخاص بـ WaitGroup إطلاقًا من خلال defer عند الخروج من printNumbers، وإذا لم يُستدعى، لن ينتهي تنفيذ البرنامج، لأن التابع Wait الخاص بـ WaitGroup في الدالة main لن يستمر. هذا مثال آخر على حالة الجمود، لأن الدالة main تنتظر شيئًا لن يحدث أبدًا. نفّذ الآن ملف "main.go" باستخدام الأمر go run: $ go run main.go قد يختلف الخرج قليلًا عما هو معروض أدناه، ولكن يجب أن يكون متشابهًا: sending 1 to channel sending 2 to channel read 1 from channel read 2 from channel sending 3 to channel Waiting for functions to finish... read 3 from channel Done! يُظهر خرج البرنامج السابق أن الدالة generateNumbers تولّد الأرقام من واحد إلى ثلاثة أثناء كتابتها على القناة المشتركة مع printNumbers. حالما تستقبل printNumbers الرقم تطبعه على شاشة الخرج، وبعد أن تولّد generateNumbers الأرقام الثلاثة كلها، سيكون قد انتهى تنفيذها وستخرج، سامحةً بذلك للدالة main بإغلاق القناة والانتظار ريثما تنتهيprintNumbers. بمجرد أن تنتهي printNumbers من طباعة الأرقام، تستدعي Done الخاصة بـ WaitGroup وينتهي تنفيذ البرنامج. على غرار نتائج الخرج التي رأيناها سابقًا، سيعتمد الخرج الذي تراه على عوامل خارجية مختلفة، مثل عندما يختار نظام التشغيل أو مُصرّف لغة جو تشغيل تنظيم أو عامل معين قبل الآخر أو يبدّل بينهما، ولكن يجب أن يكون الخرج متشابهًا عمومًا. تتمثل فائدة تصميم البرامج باستخدام خيوط معالجة جو والقنوات في أنه بمجرد تصميم البرامج بطريقة تقبل التقسيم، يمكنك توسيع نطاقه ليشمل المزيد من خيوط معالجة جو. بما أن generateNumbers يكتب فقط على القناة، فلا يهم عدد الأشياء الأخرى التي تقرأ من تلك القناة، إذ سيرسل فقط أرقامًا إلى أي شيء يقرأ القناة. يمكنك الاستفادة من ذلك عن طريق تشغيل أكثر من دالة printNumbers مثل تنظيم جو، بحيث يقرأ كل منها من نفس القناة ويتعامل مع البيانات في نفس الوقت. الآن، بعد أن استخدم البرنامج قنوات للتواصل، نفتح ملف "main.go" مرةً أخرى لنُحدّث البرنامج بطريقة تمكننا من استخدام عدة دوال printNumbers على أنها خيوط معالجة جو. سنحتاج إلى تعديل استدعاء wg.Add، بحيث نضيف واحدًا لكل تنظيم نبدأه، ولا داعٍ لإضافة واحد إلىWaitGroup من أجل استدعاء generateNumbers بعد الآن، لأن البرنامج لن يستمر دون إنهاء تنفيذ كامل الدالة، على عكس ما كان يحدث عندما كنا ننفذه مثل تنظيم. للتأكد من أن هذه الطريقة لا تقلل من عدد WaitGroup عند انتهائها، يجب علينا إزالة سطر ()defer wg.Done من الدالة. تسهّل إضافة رقم التنظيم إلى printNumbers رؤية كيفية قراءة القناة بواسطة كل منهم. تُعد زيادة كمية الأرقام التي تُنشأ فكرةً جيدة أيضًا بحيث يسهل تتبعها: func generateNumbers(total int, ch chan<- int, wg *sync.WaitGroup) { for idx := 1; idx <= total; idx++ { fmt.Printf("sending %d to channel\n", idx) ch <- idx } } func printNumbers(idx int, ch <-chan int, wg *sync.WaitGroup) { defer wg.Done() for num := range ch { fmt.Printf("%d: read %d from channel\n", idx, num) } } func main() { var wg sync.WaitGroup numberChan := make(chan int) for idx := 1; idx <= 3; idx++ { wg.Add(1) go printNumbers(idx, numberChan, &wg) } generateNumbers(5, numberChan, &wg) close(numberChan) fmt.Println("Waiting for goroutines to finish...") wg.Wait() fmt.Println("Done!") } بعد تحديث ملف "main.go"، يمكنك تشغيل البرنامج مرةً أخرى باستخدام go run. ينبغي أن يبدأ البرنامج بإنشاء ثلاثة خيوط معالجة للدالة printNumbers قبل المتابعة وتوليد الأرقام، كما ينبغي أن يُنشئ البرنامج أيضًا خمسة أرقام بدلًا من ثلاثة لتسهيل رؤية الأرقام موزعة بين كل من خيوط المعالجة الثلاثة للدالة printNumbers: $ go run main.go قد يبدو الخرج مشابهًا لهذا (قد يختلف الخرج قليلًا): sending 1 to channel sending 2 to channel sending 3 to channel 3: read 2 from channel 1: read 1 from channel sending 4 to channel sending 5 to channel 3: read 4 from channel 1: read 5 from channel Waiting for goroutines to finish... 2: read 3 from channel Done! بالنظر إلى الخرج في هذه المرة، هناك احتمال كبير ليختلف الخرج عن الناتج الذي تراه أعلاه، لأنه هناك 3 خيوط معالجة من الدالة printNumbers تُنفّذ، وأيٌّ منها قد يقرأ أحد الأرقام المولّدة، وبالتالي هناك احتمالات خرج عديدة. عندما يتلقى أحد خيوط معالجة الدالة printNumbers رقمًا، فإنه يقضي وقتًا قصيرًا في طباعة هذا الرقم على الشاشة، وفي نفس الوقت يكون هناك تنظيم آخر يقرأ الرقم التالي من القناة ويفعل الشيء نفسه. عندما ينتهي تنظيم من قراءة الرقم الذي استقبله وطباعته على الشاشة، سيذهب للقناة مرةً أخرى ويحاول قراءة رقم آخر وطباعته، وإذا لم يجد رقمًا جديدًا، فسيبدأ الحظر حتى يمكن قراءة الرقم التالي. بمجرد أن تنتهي الدالة generateNumbers من التنفيذ وتستدعى الدالة ()close على القناة، ستغلِق كل خيوط معالجة الدالة printNumbers حلقاتها وتخرج، وعندما تخرج جميع خيوط المعالجة الثلاثة وتستدعي Done من WaitGroup، يصل عدّاد WaitGroup إلى الصفر وينتهي البرنامج. يمكنك أيضًا تجربة زيادة أو إنقاص عدد خيوط المعالجة أو الأرقام التي تُنشأ لمعرفة كيف يؤثر ذلك على الخرج. عند استخدام خيوط معالجة جو، تجنب أن تُكثر منها؛ فمن الناحية النظرية يمكن أن يحتوي البرنامج على مئات أو حتى الآلاف من خيوط المعالجة، وهذا ما قد يكون له تأثير عكسي على الأداء، فقد يُبطئ برنامجك والحاسب والوقوع في حالة مجاعة الموارد Resource Starvation. في كل مرة تُنفّذ فيها لغة جو تنظيمًا، يتطلب ذلك وقتًا إضافيًا لبدء التنفيذ من جديد، إضافةً إلى الوقت اللازم لتشغيل الشيفرة في الدالة التالية، وبالتالي من الممكن أن يستغرق الحاسب وقتًا أطول في عملية التبديل بين خيوط المعالجة مقارنةً تشغيل التنظيم نفسه، وهذا ما نسميه مجاعة الموارد، لأن البرنامج وخيوط المعالجة لا تأخذ الموارد الكافية للتنفيذ أو ربما لا تحصل على أية موارد، وفي هذه الحالة يكون من الأفضل تخفيض عدد خيوط المعالجة (أجزاء الشيفرة التي تعمل بالتساير) التي ينفذها البرنامج، لتخفيض عبء الوقت الإضافي المُستغرق في التبديل بينها، ومنح المزيد من الوقت لتشغيل البرنامج نفسه. يُفضّل غالبًا أن يكون عدد خيوط المعالجة مساوٍ لعدد النوى الموجودة في المعالج أو ضعفها. يتيح استخدام مزيج من خيوط معالجة جو والقنوات إمكانية إنشاء برامج قوية جدًا وقابلة للتوسع من أجل أجهزة حواسيب أكبر وأكبر. رأينا في هذا القسم أنه يمكن استخدام القنوات للتواصل بين عدد قليل من خيوط معالجة جو أو حتى آلاف دون الحاجة للكثير من التغييرات. إذا أخذنا هذا في الحسبان عند كتابة البرامج، سنتمكن من الاستفادة من التساير المتاح في لغة جو لتزويد المستخدمين بتجربة شاملة أفضل. الخاتمة أنشأنا في هذا المقال برنامجًا يطبع أرقامًا على الشاشة باستخدام الكلمة المفتاحية go وخيوط معالجة جو التي تتيح لنا التنفيذ المتساير. بمجرد تشغيل البرنامج أنشأنا قنواتًا جديدةً تمرِّر قيمًا صحيحة int عبرها باستخدام (make(chan int، ثم استخدمنا القناة من خلال إرسال أرقام من تنظيم جو إلى تنظيم جو آخر عبرها، ليطبعها الأخير على الشاشة بدوره. أخيرًا وسّعنا البرنامج من خلال إنشاء عدة خيوط معالجة تؤدي نفس المهمة (تستقبل أرقام من القناة وتطبعها)، وكان مثالًا على كيفية استخدام القنوات وخيوط المعالجة لتسريع البرامج على أجهزة الحواسيب متعددة النوى. ترجمة -وبتصرف- للمقال How To Run Multiple Functions Concurrently in Go لصاحبه Kristin Davidson. اقرأ أيضًا المقال السابق كيفية استخدام وحدة خاصة Private Module ضمن مشروعك بلغة Go. تعرف على لغة البرمجة Go.
  9. واحدة من ميزات لغة جو هو أنها تتضمن عددًا كبيرًا من الوحدات مفتوحة المصدر، وبالتالي يمكن الوصول إليها وفحصها واستخدامها والتعلم منها بحرية. أحيانًا يكون من الضروري إنشاء وحدة جو خاصة لأسباب مختلفة، مثل الاحتفاظ بمنطق عمل داخلي خاص بالشركة. ستتعلم في هذه المقالة كيفية نشر وحدة module خاصة في لغة جو، وكيفية إعداد الاستيثاق authentication للوصول إليها، وستتعلم أيضًا كيفية استخدام هذا النوع من الوحدات ضمن مشروع. المتطلبات الأساسية أن يكون لديك مساحة عمل خاصة في لغة جو، وإذا لم يكن لديك اتبع سلسلة المقالات التالية: تثبيت لغة جو Go وإعداد بيئة برمجة محلية على أبونتو Ubuntu. تثبيت لغة جو وإعداد بيئة برمجة محلية على نظام ماك macOS. تثبيت لغة جو وإعداد بيئة برمجة محلية على ويندوز. أن تكون على دراية بكيفية إنشاء الوحدات في لغو جو. يمكنك مراجعة مقالة كيفية استخدام الوحدات Modules في لغة جو Go. لديك معرفة مسبقة بنظام التحكم بالإصدار غيت Git. يمكنك مراجعة مقالة ما هو جيت Git؟. إنشاء مستودع فارغ باسم mysecret للوحدة التي ستنشرها. رمز وصول شخصي Personal access token على جيت هاب. ستحتاجه للسماح للغة جو بالوصول إلى مستودعك الخاص. توزيع وحدة خاصة على عكس العديد من لغات البرمجة، توزّع لغة جو الوحدات البرمجية من المستودعات بدلًا من خادم الحزمة المركزي. تتمثل إحدى فوائد هذا النهج في أن نشر وحدة خاصة يشبه إلى حد بعيد نشر وحدة عامة، إذ تُوزّع الوحدة الخاصة من خلال مستودع شيفرة مصدر خاص، بدلًا من طلب خادم حزمة خاص منفصل تمامًا. نظرًا لأن معظم خيارات استضافة التعليمات البرمجية المصدر تدعم هذا الأمر بما يكفي، فلا داع لإعداد خادم خاص إضافي. من أجل استخدام وحدة خاصة، ينبغي أن يكون لديك حق الوصول إلى وحدة خاصة. سنُنشئ في هذا القسم وحدةً خاصة وننشرها، وستتمكن من استخدامها لاحقًا خلال هذه المقالة من الوصول إلى وحدة خاصة من شيفرة أُخرى. سنستخدم بدايةً الأمر git clone لأخذ نسخة محلية من المستودع الفارغ الذي لا بُد أن نكون قد أنشأناه لأنه جزء من المتطلبات الأساسية. يجب أن يكون هذا المستودع خاصًا، وسنفترض أن اسمه "mysecret". يمكن نسخ هذا المستودع إلى أي مكان نريده على جهاز الحاسوب، لكن يميل العديد من المطورين إلى إنشاء مجلد خاص يتضمن جميع مشاريعهم. سنستخدم هنا مجلدًا باسم "projects". ابدأ بإنشاء المجلد وانتقل إليه: $ mkdir projects $ cd projects من مجلد "projects"، شغل الأمر git clone لنسخ المستودع "mysecret" إلى جهاز الحاسب: $ git clone git@github.com:your_github_username/mysecret.git ستعطي جو تأكيدًا على عملية نسخ الوحدة، وقد تحذّرك بأنك قد استنسخت مستودعًا فارغًا، إذا حصل ذلك فلا داعٍ للقلق بشأن ذلك الأمر: Cloning into 'mysecret'... warning: You appear to have cloned an empty repository. انتقل الآن باستخدام الأمر cd إلى المجلد الجديد "mysecret" الذي استنسخته واستخدم الأمر go mod init لإنشاء الوحدة الجديدة وتمرير موقع المستودع اسمًا لها: $ cd mysecret $ go mod init github.com/your_github_username/mysecret يُعد التأكد من تطابق اسم الوحدة مع موقع المستودع أمرًا مهمًا؛ لأن هذه هي الطريقة التي تعثر بها أداة go mod init على مكان تنزيل الوحدة عند استخدامها في مشاريع أخرى. سنستخدم الآن محرر نصوص مثل نانو nano، لإنشاء وفتح ملف يحمل نفس اسم المستودع "mysecret.go". $ nano mysecret.go يمكن تسمية هذا الملف بأي اسم، ولكن يُفضّل استخدام نفس اسم الحزمة لتسهيل معرفة مكان البدء عند العمل مع حزمة غير مألوفة. نضيف إلى الملف "mysecret.go" الدالة SecretProcess لطباعة الرسالة !Running the secret process: package mysecret import "fmt" func SecretProcess() { fmt.Println("Running the secret process!") } يمكنك الآن نشر الوحدة الخاصة ليستخدمها الآخرون. بما أن المستودع الذي أنشأته هو مستودع خاص، بالتالي لا يمكن لشخص غيرك الوصول إليه، ويمنحك ذلك إمكانية التحكم في حق وصول الآخرين إلى وحدتك، وبالتالي يمكنك السماح لأشخاص محددين فقط بالوصول إليها أو عدم السماح لأي شخص بالوصول. بما أن الوحدات البرمجية الخاصة والعامة في لغة جو هي مستودعات مصدرية، فإن عملية نشر وحدة خاصة تتبع نفس نهج عملية نشر وحدة عامة. يجب علينا الآن إدراج stage الملفات باستخدام git add وإيداعها بالمستودع باستخدام git commit، لكي ننشر الوحدة: $ git add . $ git commit -m "Initial private module implementation" سترى تأكيدًا من جيت بأن الإيداع الأولي قد نجح، إضافةً إلى ملخص للملفات المضمنة في الإيداع. سيكون الخرج كما يلي: [main (root-commit) bda059d] Initial private module implementation 2 files changed, 10 insertions(+) create mode 100644 go.mod create mode 100644 mysecret.go نستخدم الآن الأمر git push لدفع الوحدة إلى مستودع غيت هب: $ git push سيدفع جو تغييراتك ويجعلها متاحة لأي شخص يملك حق الوصول إلى مستودعك الخاص، وسيكون الخرج كما يلي: git push origin main Enumerating objects: 4, done. Counting objects: 100% (4/4), done. Delta compression using up to 8 threads Compressing objects: 100% (3/3), done. Writing objects: 100% (4/4), 404 bytes | 404.00 KiB/s, done. Total 4 (delta 0), reused 0 (delta 0), pack-reused 0 To github.com:your_github_username/mysecret.git * [new branch] main -> main يمكنك أيضًا إضافة إصدارات إلى الوحدة الخاصة التي أنشأتها بنفس الطريقة التي نفعلها مع الوحدات العامة. أنشأت في هذا القسم وحدةً برمجيةً جديدةً تمتلك دالة تُدعى SecretProcess ونشرتها في مستودع mysecret الخاص بك على غيت هب، وبما أن هذا المستودع خاص، فهذا يعني أن الوحدة خاصة أيضًا، ومن أجل الوصول إلى هذه الوحدة من شيفرة أخرى أو برنامج آخر في جو، ستحتاج إلى إعداد مُحدد، حتى يعرف مُصرّف اللغة كيفية الوصول إليها. ضبط جو لمنح إمكانية الوصول إلى الوحدات البرمجية الخاصة كما ذكرنا سابقًا، تُوّزع الوحدات البرمجية المكتوبة بلغة جو من مستودعات الشيفرة المصدرية. يُضيف فريق تطوير لغة جو بعض الخدمات المركزية إلى هذه الوحدات للمساعدة في ضمان استمرار وجود هذه الوحدات في حالة حدوث شيء ما للمستودع الأصلي. تتضمن الإعدادات الافتراضية في لغة جو استخدام هذه الخدمات تلقائيًا، ولكن يمكن أن تحدث مشاكل عند محاولة تنزيل وحدة خاصة لأنها تتضمن إمكانية وصول مُقيّدة. لإخبار مُصرّف اللغة بأن بعض مسارات الاستيراد خاصة وأنه لا يجب محاولة استخدام خدمات جو المركزية معها، يمكنك استخدام متغير البيئة GOPRIVATE، وهو يمثّل قائمةً من بادئات مسار الاستيراد مفصولة بفواصل، إذ ستحاول أدوات جو الوصول إليها مباشرةً عند مواجهتها بدلًا من المرور عبر الخدمات المركزية. أحد الأمثلة على ذلك هو الوحدة الخاصة التي أنشأتها للتو. إذًا، من أجل استخدام الوحدة الخاصة، ينبغي أن نخبر مُصرّف اللغة عن المسار الذي نعدّه خاصًا، وذلك عن طريق ضبط قيمة المتغير GOPRIVATE. هناك عدد قليل من الخيارات الممكن إجراؤها عند ضبط قيم متغير GOPRIVATE، وأحد الخيارات هو ضبطه على github.com، لكن قد لا يفي ذلك بالغرض لأن هذا سيخبر جو بعدم استخدام الخدمات المركزية لأي وحدة مستضافة على github.com، بما في ذلك الوحدات التي لا تمتلكها. الخيار الثاني هو ضبط GOPRIVATE على مسار المستخدم الخاص بك فقط، مثلًا github.com/your_github_username، فهذا يحل المشكلة السابقة التي تتجلى بعدّ كل الوحدات المستضافة خاصة، ولكن في وقت ما قد يكون لديك وحدات عامة أنشأتها وتريد تنزيلها من نفس الحساب. قد يكون هذا حلًا مناسبًا لحد ما، ولكن كما ذكرنا، قد يمنعك من استخدام وحدات عامة من نفس الحساب. الخيار الأكثر دقة هو إعداد GOPRIVATE، بحيث يُطابق مسار الوحدة الخاصة بك تمامًا، مثلًا github.com/your_github_username/mysecret، إذ يحل ذلك المشاكل السابقة التي رأيناها في الخيار الأول والثاني، ولكن هنا يجب أن نضيف كل وحدة خاصة إلى هذا المتغير (قد تكون هذه مشكلةً للبعض) كما يلي: GOPRIVATE=github.com/your_github_username/mysecret,github.com/your_github_username/othersecret يعود اختيار الخيار الأفضل لك، حسبما تراه مناسبًا أكثر. نظرًا لأن لديك الآن وحدةً خاصة واحدة فقط، سنستخدم اسم المستودع الكامل قيمةً للمتغير. لإجراء ذلك، استخدم الأمر export كما يلي: $ export GOPRIVATE=github.com/your_github_username/mysecret إذا كنت ترغب في التحقق من نجاح عملية ضبط المتغير، فيمكنك استعراض قيمته باستخدام الأمر env مع grep: $ env | grep GOPRIVATE سيكون الخرج كما يلي: GOPRIVATE=github.com/your_github_username/mysecret الآن، أصبح مُصرّف اللغة يعرف أن وحدتك خاصة، لكن هذا لا يزال غير كافٍ لاستخدام الوحدة في شيفرة أو برنامج آخر. جرّب تشغيل هذه الوحدة في وحدة أخرى: $ go get github.com/your_github_username/mysecret وسترى الخطأ التالي: go get: module github.com/your_github_username/mysecret: git ls-remote -q origin in /Users/your_github_username/go/pkg/mod/cache/vcs/2f8c...b9ea: exit status 128: fatal: could not read Username for 'https://github.com': terminal prompts disabled Confirm the import path was entered correctly. If this is a private repository, see https://golang.org/doc/faq#git_https for additional information. تشير رسالة الخطأ هذه إلى أن مُصرّف اللغة حاول تنزيل الوحدة الخاصة بك، لكنه واجه شيئًا لا يمكنه الوصول إليه. بما أنك تستخدم غيت لتنزيل الوحدة، فغالبًا سيُطلب منك إدخال بيانات الاعتماد credentials الخاصة بك، لكن هنا يتصل مُصرّف اللغة بغيت نيابةً عنك، وبالتالي لا يمكنه مطالبتك بإدخال بيانات الاعتماد، وبالتالي يظهر الخطأ. إذًا، أنت بحاجة إلى توفير طريقة لغيت لاسترداد بيانات الاعتماد الخاصة بك. توفير بيانات الاعتماد اللازمة للاتصال بالوحدة الخاصة عند استخدام بروتوكول HTTPS هناك طريقة واحدة لإخبار جو بكيفية تسجيل الدخول نيابةً عنك، وهي ملف "netrc." الموجود في المجلد الرئيسي "Home" الخاص بالمستخدم؛ إذ يحتوي هذا الملف على أسماء مُضيفات مختلفة Hosts مع بيانات اعتماد تسجيل الدخول لهم، ويستخدم على نطاق واسع في عدة أدوات برمجية بما في ذلك لغة جو. سيحاول الأمر go get استخدام HTTPS أولًا عندما يحاول تنزيل الوحدة، لكن كما ذكرنا؛ لا يمكنه مطالبتك باسم المستخدم وكلمة المرور. لمنح غيت بيانات الاعتماد الخاصة بك، يجب أن يكون لديك ملف "netrc." يتضمن github.com في مجلدك الرئيسي. لإنشاء ملف "netrc." على نظام لينوكس Linux أو ماك أو اسم MacOS أو على نظام ويندوز الفرعي لنظام لينوكس Windows Subsystem for Linux -أو اختصارًا WSL، افتح ملف "netrc." في المجلد الرئيسي (/~) حتى تتمكن من تحريره: $ nano ~/.netrc أنشئ الآن مدخلًا جديدًا إلى هذا الملف. ينبغي أن تكون قيمة الحقل machine هي اسم المضيف الذي تضبط بيانات الاعتماد له، وهو github.com في هذه الحالة، أما قيمة login فهي اسم المستخدم الخاص بك على غيت هب. أخيرًا، قيمة password يجب أن تكون رمز الوصول الشخصي الخاص بك، والذي من المفترض أن تكون قد أنشأته على غيت هب. machine github.com login your_github_username password your_github_access_token يمكنك أيضًا وضع جميع المدخلات ضمن سطر واحد في الملف: machine github.com login your_github_username password your_github_access_token ملاحظة: إذا كنت تستخدم بيت باكيت Bitbucket لاستضافة شيفرة المصدر الخاصة بك، فقد تحتاج أيضًا إلى إضافة مُدخل ثاني من أجل api.bitbucket.org إضافةً إلى bitbucket.org. كانت بيت باكيت توفّر سابقًا استضافةً لأنواع متعددة من أنظمة التحكم في الإصدار، لذلك سيستخدم مُصرّف جو واجهة برمجة التطبيقات للتحقق من نوع المستودع قبل محاولة تنزيله. هذا الأمر كان في الماضي ولم يعد الأمر كذلك، إلا أن واجهة برمجة التطبيقات التي تُمكّنك من فحص نوع المستودع ما زالت موجودة. بكافة الأحوال، إذا واجهت هذه المشكلة، فقد تبدو رسالة الخطأ على النحو التالي: go get bitbucket.org/your_github_username/mysecret: reading https://api.bitbucket.org/2.0/repositories/your_bitbucket_username/protocol?fields=scm: 403 Forbidden server response: Access denied. You must have write or admin access. إذا رأيت رسالة الخطأ "‎403 Forbidden" أثناء محاولة تنزيل وحدة خاصة، تحقق من اسم مستخدم جو الذي تحاول الاتصال به؛ فمن الممكن أن تشير إلى اسم مستخدم مختلف مثل api.bitbucket.org، والذي ينبغي إضافته إلى الملف "netrc.". بذلك تكون قد أعددت بيئتك لاستخدام مصادقة HTTPS لتنزيل الوحدة الخاصة بك. ذكرنا سابقًا أن الأمر go get يستخدم افتراضيًا HTTPS عندما يحاول تنزيل الوحدة، لكن من الممكن أيضًا جعله يستخدم بروتوكول النقل الآمن Secure Shell -أو اختصارًا SSH- بدلًا من ذلك. يمكن أن يكون استخدام SSH بدلًا من HTTPS مفيدًا حتى تتمكن من استخدام نفس مفتاح SSH الذي استخدمته لدفع الوحدة الخاصة بك، كما يسمح لك باستخدام مفاتيح النشر deploy keys عند إعداد بيئة CI/CD إذا كنت تفضل عدم إنشاء رمز وصول شخصي. توفير بيانات الاعتماد اللازمة للاتصال بالوحدة الخاصة عند استخدام بروتوكول SSH يوفر غيت خيار إعداد يُسمى insteadOf لاستخدام مفتاح SSH الخاص بك بمثابة طريقة للمصادقة مع الوحدة الخاصة بك بدلًا من HTTPS. يتيح لك هذا الخيار أن تقول "بدلًا من" استخدام "/https://github.com" مثل عنوان URL لإرسال طلبات إلى غيت، تُريد استخدام "/ssh://git@github.com". يتواجد هذا الإعداد في أنظمة لينوكس وماك ونظام ويندوز الفرعي WSL في ملف "gitconfig.". قد تكون على دراية بهذا الملف فعلًا، إذ يتضمن أيضًا إعدادات عنوان البريد الإلكتروني والاسم الخاصين بك. افتح هذا الملف "gitconfig./~" الآن من مجلدك الرئيسي "Home" باستخدام محرر النصوص الذي تفضله وليكن نانو هنا: $ nano ~/.gitconfig عدّل هذا الملف ليحتوي على قسم url من أجل "/ssh://git@github.com" كما يلي: [user] email = your_github_username@example.com name = Sammy the Shark [url "ssh://git@github.com/"] insteadOf = https://github.com/ ترتيب قسم user بالنسبة للقسم url غير مهم، ولا داع للقلق إن لم يكن هناك أي شيء آخر غير قسم url الذي أضفته منذ قليل. أيضًا تريتب حقلي email و name داخل قسم user غير مهم. سيخبر القسم الجديد الذي أضفته غيت أن أي عنوان URL تكون بادئته "/https://github.com" يجب أن تُستبدل بادئته إلى "/ssh: //git@github.com" (الاختلاف بالبادئة). هذا سيؤثر طبعًا على أوامر go get، لأن جو تستخدم HTTPS افتراضيًا. لو أخذنا مسار الوحدة الخاصة بك مثالًا، فإن جو سيُحوّل مسار الاستيراد "github.com/your_github_username/mysecret" إلى عنوان URL يكون كما يلي: "https://github.com/your_github_username/mysecret". عندما يصادف جو عنوان URL هذا، سيرى عنوان URL يطابق البادئة "/https://github.com" المشار إليها بواسطة insteadOf وسيعيد عنوان URL إلى "ssh://git@github.com/your_github_username/mysecret" يمكن أيضًا استخدام هذا النمط لنطاقات أخرى وليس فقط لغيت هب، طالما أن @ssh://git يعمل مع ذلك المضيف أيضًا. ضبطنا خلال هذا القسم غيت لاستخدام SSH، وذلك من أجل تنزيل وحدات جو، وذلك من خلال تحديث ملف "gitconfig."، إذ أضفنا القسم url. الآن بعد أن أكملنا الإعدادات اللازمة للمصادقة، أصبح بالإمكان الوصول إلى الوحدة واستخدامها في برامج جو. استخدام وحدة خاصة تعلمت خلال الأقسام السابقة كيفية إعداد جو ليتمكن من الوصول إلى الوحدة الخاصة بك عبر HTTPS أو SSH أو ربما كليهما. إذًا، أصبح بإمكانك الآن استخدامها مثل أي وحدة عامة أخرى. ستنشئ الآن وحدةً جديدةً تستخدم وحدتك الخاصة. أنشئ الآن مجلدًا باسم "myproject" باستخدام الأمر mkdir وضعه في مجلد المشاريع الخاص بك "projects": $ mkdir myproject انتقل الآن إلى المجلد الذي أنشأته باستخدام الأمر cd وهيّئ الوحدة الجديدة باستخدام الأمر go mod init اعتمادًا على عنوان URL للمستودع الذي ستضع فيه المشروع، مثل "github.com/your_github_username/myproject". إذا كنت لا تخطط لدفع المشروع إلى مستودع آخر، فيمكن أن يكون اسم الوحدة myproject أو أي اسم آخر، ولكن من الممارسات الجيدة استخدام عناوين URL الكاملة، لأن معظم الوحدات التي تجري مشاركتها ستحتاج إليها. $ cd myproject $ go mod init github.com/your_github_username/myproject سيكون الخرج على النحو التالي: go: creating new go.mod: module github.com/your_github_username/myproject انشئ الآن ملف "main.go" لتضع فيه أول شيفرة باستخدام محرر النصوص الذي تفضله وليكن نانو: $ nano main.go ضع الشيفرة التالية داخل هذا الملف، والتي تتضمن الدالة main التي سنستدعي الوحدة بداخلها: package main import "fmt" func main() { fmt.Println("My new project!") } شغل ملف "main.go" باستخدام الأمر go run لرؤية المشروع النهائي الذي يستخدم الوحدة الخاصة بك: $ go run main.go سيكون الخرج على النحو التالي: My new project! أضف الآن وحدتك الخاصة مثل اعتمادية لمشروعك الجديد باستخدام الأمر go get تمامًا كما تفعل مع الوحدة العامة: $ go get github.com/your_github_username/mysecret تنزّل عندها أداة go شيفرة الوحدة الخاصة وتضيفها مثل اعتمادية باستخدام سلسلة نصية للإصدار تطابق إيداع التعميلة hash الأخير ووقت ذلك الإيداع: go: downloading github.com/your_github_username/mysecret v0.0.0-20210920195630-bda059d63fa2 go get: added github.com/your_github_username/mysecret v0.0.0-20210920195630-bda059d63fa2 افتح ملف "main.go" مرةً أخرى وحدّثه لإضافة استدعاء لدالة الوحدة الخاصة SecretProcess ضمن دالة main الرئيسية. ستحتاج أيضًا إلى تحديث عبارة import لإضافة وحدتك الخاصة github.com/your_github_username/mysecret: package main import ( "fmt" "github.com/your_github_username/mysecret" ) func main() { fmt.Println("My new project!") mysecret.SecretProcess() } شغل ملف "main.go" باستخدام الأمر go run لرؤية المشروع النهائي الذي يستخدم الوحدة الخاصة بك: $ go run main.go ستلاحظ أن الخرج يتضمن الجملة !My new project من الشيفرة الأصلية، كما يتضمن الجملة !Running the secret process من الوحدة mysecret التي استوردناها. My new project! Running the secret process! استخدمنا في هذا القسم الأمر go init لإنشاء وحدة جديدة للوصول إلى الوحدة الخاصة التي نشرناها سابقًا. بعد إنشاء الوحدة، ستتمكن من استخدام الأمر go get لتنزيل الوحدة الخاصة بك؛ تمامًا كما لو كنت تتعامل مع وحدة عامة. استخدمنا أيضًا الأمر go run لتصريف وتشغيل البرنامج الذي يستخدم الوحدة الخاصة. الخاتمة نشرنا خلال هذه المقالة وحدة خاصة باستخدام لغة جو، وتعلمنا كيفية إعداد متطلبات المصادقة باستخدام بروتوكول HTTPS ومفتاح SSH من أجل الوصول إلى الوحدة الخاصة من شيفرة أخرى. استخدمنا أيضًا الوحدة الخاصة ضمن مشروع. ترجمة -وبتصرف- للمقال How to Use a Private Go Module in Your Own Project لصاحبه Kristin Davidson. اقرأ أيضًا المقال السابق: توزيع الوحدات Modules المكتوبة بلغة Go. كتابة أول برنامج (ومكتبة) لك باستخدام لغة البرمجة Go الدليل السريع إلى لغة البرمجة Go بناء البرامج المكتوبة بلغة جو Go وتثبيتها التعامل مع ملفات المستودع في BitBucket واستخدام Git
  10. تسمح العديد من لغات البرمجة الحديثة -بما في ذلك لغة جو- للمطورين بتوزيع مكتبات جاهزة للآخرين لاستخدامها في برامجهم. تستخدم بعض اللغات مستودعًا مركزيًا لتثبيت هذه المكتبات، بينما توزعها لغة جو من نفس مستودع التحكم في الإصدار version control repository المستخدم لإنشاء المكتبات. تستخدم لغة جو أيضًا نظام إصدار يسمى الإصدار الدلالي Semantic Versioning، ليوضح للمستخدمين متى وما هو نوع التغييرات التي أُجريت. يساعد ذلك المستخدمين على معرفة ما إذا كان الإصدار الأحدث من الوحدة آمنًا للترقية، وما إذا كان يساعد في ضمان استمرار عمل برامجهم مع الوحدة. سننشئ في هذه المقالة وحدةً برمجيةً جديدة باستخدام لغة جو وسننشرها، وسنتعلم استخدام الإصدار الدلالي، وسننشر إصدارًا دلاليًا من الوحدة التي أنشأناها. المتطلبات أن يكون لديك مساحة عمل خاصة في لغة جو، وإذا لم يكن لديك اتبع سلسلة المقالات التالية: تثبيت لغة جو Go وإعداد بيئة برمجة محلية على أبونتو Ubuntu. تثبيت لغة جو وإعداد بيئة برمجة محلية على نظام ماك macOS. تثبيت لغة جو وإعداد بيئة برمجة محلية على ويندوز. أن تكون على دراية بكيفية إنشاء الوحدات في لغو جو. يمكنك مراجعة مقالة كيفية استخدام الوحدات Modules في لغة جو Go. لديك معرفة مسبقة بنظام التحكم بالإصدار غيت Git. يمكنك مراجعة مقالة ما هو جيت Git؟. إنشاء مستودع فارغ باسم pubmodule للوحدة التي ستنشرها. إنشاء وحدة للتحضير لنشرها على عكس العديد من لغات البرمجة الأخرى، توزّع الوحدة المُنشأة باستخدام لغة جو مباشرةً من مستودع الشيفرة المصدر الذي يتضمنها بدلًا من مستودع حزم مستقل. يسهل هذا على المستخدمين العثور على الوحدات المشار إليها في التعليمات البرمجية الخاصة بهم وعلى المشرفين على الوحدة لنشر إصدارات جديدة من الوحدة الخاصة بهم. سننشئ في هذا القسم وحدةً جديدة، وسننشرها بعد ذلك لجعلها متاحةً للمستخدمين الآخرين. لنبدأ بإنشاء الوحدة الآن. سنستخدم بدايةً الأمر git clone لأخذ نسخة محلية من المستودع الفارغ الذي لا بُد أن نكون أنشأناه لأنه جزء من المتطلبات الأساسية. يمكن نسخ هذا المستودع إلى أي مكان نريده على جهاز الحاسب، لكن يميل العديد من المطورين إلى إنشاء مجلد خاص يتضمن جميع مشاريعهم. سنستخدم هنا مجلدًا باسم "projects". أنشئ مجلد "projects" وانتقل إليه: $ mkdir projects $ cd projects من مجلد "projects"، شغل الأمر git clone لنسخ المستودع إلى جهاز الحاسب: $ git clone git@github.com:your_github_username/pubmodule.git ستكون نتيجة تنفيذ هذا الأمر هي نسخ الوحدة إلى مجلد "pubmodule" داخل مجلد "projects". قد تتلقى تحذيرًا بأنك نسخت مستودعًا فارغًا، ولكن لا داعٍ للقلق بشأن ذلك: Cloning into 'pubmodule'... warning: You appear to have cloned an empty repository. انتقل الآن إلى المجلد "pubmodule": $ cd pubmodule سنستخدم الآن الأمر go mod init لإنشاء الوحدة الجديدة وتمرير موقع المستودع اسمًا للوحدة. يُعد التأكد من تطابق اسم الوحدة مع موقع المستودع أمرًا مهمًا، لأن هذه هي الطريقة التي تعثر بها أداة go mod init على مكان تنزيل الوحدة عند استخدامها في مشاريع أخرى: $ go mod init github.com/your_github_username/pubmodule ستظهر رسالة تؤكد عملية إنشاء الوحدة من خلال إعلامنا بأن الملف "go.mod" قد أُنشئ: go: creating new go.mod: module github.com/your_github_username/pubmodule سنستخدم الآن محرر نصوص مثل نانو nano، لإنشاء وفتح ملف يحمل نفس اسم المستودع "pubmodule.go". $ nano pubmodule.go يمكن تسمية هذا الملف بأي اسم، ولكن يُفضّل استخدام نفس اسم الحزمة لتسهيل معرفة مكان البدء عند العمل مع حزمة غير مألوفة، كما ينبغي أن يكون اسم الحزمة هو نفس اسم المستودع. بالتالي، عندما يُشير شخص ما إلى تابع أو نوع من تلك الحزمة، فإنها تتطابق مع اسم المستودع، مثل pubmodule.MyFunction. سيسهل ذلك عليهم معرفة مصدر الحزمة في حال احتاجوا إلى الرجوع إليها لاحقًا. نُضيف الآن الدالة Hello إلى الحزمة، والتي ستعيد السلسلة !Hello, You. ستكون هذه الدالة متاحةً لأي شخص يستورد الحزمة: package pubmodule func Hello() string { return "Hello, You!" } لقد أنشأنا الآن وحدةً جديدةً باستخدام go mod init مع اسم وحدة يتطابق مع اسم المستودع البعيد (github.com/yourgithubusername/pubmodule)، وأضفنا أيضًا ملفًا باسم "pubmodule.go" إلى الوحدة، مع دالة تُسمى Hello يمكن استدعاؤها من قِبل مستخدمي هذه الوحدة. سننشر في الخطوة التالية هذه الوحدة بهدف إتاحتها للآخرين. نشر الوحدة لقد حان الوقت لنشر الوحدة التي أنشأناها في الخطوة السابقة. نظرًا لأن وحدات لغة جو تُوزّع من نفس مستودعات الشيفرة التي تُخزّن فيها، فسوف نُودع commit الشيفرة في مستودع غيت المحلي وندفعه push إلى المستودع الخاص بنا على github.com/your_github_username/pubmodule، ولكن قبل ذلك من الجيد التأكد من عدم إيداع ملفات لا نريد إيداعها عن طريق الخطأ أو عدم الانتباه، لأنها ستُنشر علنًا عند دفع الشيفرة إلى غيت هب. يمكن إظهار جميع الملفات داخل مجلد "pubmodule" والتغييرات التي ستُنفّذ باستخدام الأمر التالي: $ git status سيبدو الخرج كما يلي: On branch main No commits yet Untracked files: (use "git add <file>..." to include in what will be committed) go.mod pubmodule.go يجب أن نرى ملف "go.mod" الذي أُنشئ بواسطة الأمر go mod init، وملف "pubmodule.go" الذي أنشأنا فيه دالة Hello. قد يكون اسم الفرع مختلفًا عن الخرج السابق اعتمادًا على كيفية إنشاء المستودع، وستكون الأسماء غالبًا إما main أو master. عندما نتأكد من أن الملفات التي نريدها فقط موجودة، يمكننا إدراج stage الملفات باستخدام git add وإيداعها بالمستودع باستخدام الأمر git commit: $ git add . $ git commit -m "Initial Commit" سيكون الخرج كما يلي: [main (root-commit) 931071d] Initial Commit 2 files changed, 8 insertions(+) create mode 100644 go.mod create mode 100644 pubmodule.go نستخدم الأمر git push لدفع الوحدة إلى مستودع غيت هب: $ git push سيكون الخرج كما يلي: Enumerating objects: 4, done. Counting objects: 100% (4/4), done. Delta compression using up to 8 threads Compressing objects: 100% (3/3), done. Writing objects: 100% (4/4), 367 bytes | 367.00 KiB/s, done. Total 4 (delta 0), reused 0 (delta 0), pack-reused 0 To github.com:your_github_username/pubmodule.git * [new branch] main -> main ستُدفع الوحدة إلى المستودع بعد تشغيل الأمر git push، وستكون متاحةً الآن لأي شخص آخر لاستخدامها. ستستخدم جو الشيفرة الموجودة في الفرع الافتراضي للمستودع على أنها شيفرة للوحدة إذا لم يكن لديك أيّة إصدارات منشورة. لا يهم ما إذا كان اسم الفرع الافتراضي هو main أو master أو أي شيء آخر، المهم هو الفرع الذي ضُبط على أنه فرع افتراضي. تعلّمنا في هذا القسم كيفية نشر وحدة في لغة جو على مستودع غيت هب من المستودع المحلي لإتاحتها للآخرين. واحدة من الأمور الأساسية التي يجب أخذها بالحسبان، هي التأكد من أن مستخدمي الوحدة يمكنهم استخدام إصدار مستقر منها. من المحتمل أن نرغب في إجراء تغييرات وإضافة ميزات إلى الوحدة من الآن فصاعدًا، ولكن إذا أجرينا هذه التغييرات دون استخدام مفهوم الإصدارات، فقد تتعطل شيفرة شخص ما يستخدم هذه الوحدة عن طريق الخطأ. لحل هذه المشكلة: يمكننا إضافة إصدارات إلى الوحدة عندما نصل إلى مرحلة جديدة في عملية التطوير، ومع ذلك، عند إضافة إصدارات جديدة يجب التأكد من اختيار رقم إصدار مفيد حتى يعرف المستخدمون ما إذا كان من الآمن لهم الترقية فورًا أم لا. الإصدار الدلالي يعطي رقم الإصدار التعبيري meaningful version number للمستخدمين فكرةً عن مدى تغير الواجهة العامة أو واجهة برمجة التطبيقات التي يتفاعلون معها. تنقل جو هذه التغييرات من خلال نظام أو مخطط إصدار versioning scheme يُعرف باسم الإصدار الدلالي semantic versioning -أو اختصارًا SemVer. يستخدم الإصدار الدلالي سلسلة الإصدار لنقل المعنى حول تغييرات الشيفرة، ومن هنا يأتي اسم الإصدار الدلالي. يُستخدم الإصدار الدلالي لتحديد الإصدارات الأحدث من الإصدار الحالي المُستخدم، وما إذا كان بالإمكان الترقية للإصدار الأحدث آليًا بأمان. يعطي الإصدار الدلالي كل رقم في سلسلة الإصدار معنى meaning. يحتوي الإصدار النموذجي في SemVer على ثلاثة أرقام أساسية: الإصدار الرئيسي major والإصدار الثانوي minor وإصدار التصحيح patch version. تُدمج كل من هذه الأرقام مع بعضها بواسطة النقطة (.) لتشكيل سلسلة الإصدار، مثلًا 1.2.3، تُرتّب فيه الأرقام وفقًا للآلية التالية: أول رقم للإصدار الرئيسي ثم الإصدار الثانوي ثم إصدار التصحيح أخيرًا. يمكنك بهذه الطريقة معرفة أيهما أحدث لأن الرقم الموجود في مكان معين أعلى من الإصدارات السابقة. على سبيل المثال، الإصدار 2.2.3 أحدث من 1.2.3 لأن الإصدار الرئيسي أعلى، وبالمثل، فإن الإصدار 1.4.3 أحدث من 1.2.10 لأن الإصدار الثانوي أعلى، وعلى الرغم من أن 10 أعلى من 3 في إصدار التصحيح، فإن الإصدار الثانوي 4 أعلى من 2، لذا فإن هذا الإصدار له الأسبقية. عندما يزداد رقم في سلسلة الإصدار، يُعاد ضبط جميع الأجزاء الأخرى من الإصدار التي تليها إلى 0. على سبيل المثال، تؤدي زيادة الإصدار الثانوي من 1.3.10 إلى 1.4.0 وزيادة الإصدار الرئيسي من 2.4.1 إلى 3.0.0. يسمح استخدام هذه القواعد للغة جو بتحديد إصدار الوحدة التي يجب استخدامها عند تشغيل go get. مثلًا، لنفرض أن لدينا مشروعًا يستخدم الإصدار 1.4.3 من الوحدة github.com/your_github_username/pubmodule. إذا كنا نعتمد على كون الوحدة pubmodule مستقرة، فقد نرغب فقط في ترقية إصدار التصحيح تلقائيًا 3.. إذا شغّلت الأمر: go get -u=patch github.com/your_github_username/pubmodule ستستنتج لغو جو أننا تريد ترقية إصدار التصحيح للوحدة، وستبحث فقط عن الإصدارات الجديدة مع 1.4 للجزء الرئيسي والثانوي من الإصدار. من المهم أن نضع في الحسبان كيف تغيرت واجهة برمجة التطبيقات العامة للوحدة عند إنشاء إصدار جديد منها، إذ ينقل لنا كل جزء من سلسلة الإصدار الدلالية نطاق التغيير في واجهة برمجة التطبيقات وكذلك للمستخدمين. تنقسم هذه الأنواع من التغييرات عادةً إلى ثلاث فئات مختلفة، تتماشى مع كل مكون من مكونات الإصدار؛ إذ تؤدي التغيرات الأصغر إلى زيادة إصدار التصحيح، وتزيد التغييرات متوسطة الحجم من الإصدار الثانوي، وتزيد التغييرات الأكبر في الإصدار الرئيسي. سيساعدنا استخدام هذه الفئات في تحديد رقم الإصدار المراد زيادته وتجنب تعطل break الشيفرة الخاصة بنا أو شيفرة أي شخص آخر يعتمد على هذه الوحدة. أرقام الإصدارات الرئيسية الرقم الأول في SemVer هو رقم الإصدار الرئيسي (1.4.3)، وهو أهم رقم يجب مراعاته عند إطلاق إصدار جديد من الوحدة. في هذا النوع من الإصدارات يكون هناك تغيّر كبير في الإصدار يُشير إلى تغيرات غير متوافقة backward-incompatible changes مع الإصدارات السابقة لواجهة برمجة التطبيقات العامة المستخدمة API؛ وقد يكون التغير غير المتوافق مع الإصدارات السابقة هو أي تغير يطرأ على الوحدة من شأنه أن يتسبب في تعطل breaking برنامج شخص ما إذا أجرى الترقية دون إجراء أي تغييرات أخرى. يمكن أن يمثّل التعطّل أي حالة فشل في البناء بسبب تغيير اسم دالة أو تغيير في كيفية عمل المكتبة، بحيث أن تابعًا أصبح يُعيد "v1" بدلًا من "1". هذا فقط من أجل واجهة برمجة التطبيقات العامة الخاصة بنا، ومع ذلك يمكن لشخص آخر استخدام أي أنواع أو توابع قد صُدّرت. إذا كان الإصدار يتضمن فقط تحسينات لن يلاحظها مستخدم المكتبة، فلا يحتاج إلى تغيير كبير في الإصدار. قد تكون إحدى الطرق لتذكر التغييرات التي تناسب هذه الفئة هي أخذ كل "تحديث" أو "حذف" بمثابة زيادة كبيرة في الإصدار. ملاحظة: على عكس الأنواع الأخرى من الأرقام في SemVer، فإن الإصدار الرئيسي 0 له أهمية خاصة إضافية، إذ يُعد إصدارًا "قيد التطوير in development". لا يُعد أي SemVer بإصدار رئيسي 0 مستقرًا، وأي شيء يمكن أن يتغير في واجهة برمجة التطبيقات في أي وقت. يُفضّل أن نبدأ دومًا بالإصدار الرئيسي 0 عند إنشاء وحدة جديدة، وتحديث الإصدارات الثانوية والإصدارات التصحيحية فقط حتى ننتهي من التطوير الأولي للوحدة. بعد الانتهاء من تغيير واجهة برمجة التطبيقات العامة للوحدة وعدّها مستقرةً للمستخدمين، حان الوقت لبدء الإصدار 1.0.0. لنأخذ الشيفرة التالية مثالًا على الشكل الذي قد يبدو عليه تغيير الإصدار الرئيسي. لدينا دالة تسمى UserAddress تقبل حاليًا string معاملًا وتعيد string: func UserAddress(username string) string { // تُعيد عنوان المستخدم مثل سلسلة } تُعيد الدالة حاليًا سلسلة، وقد يكون من الأفضل لنا وللمستخدمين إذا أعادت الدالة السابقة بنيةً struct مثل Address*، إذ يمكن بهذه الطريقة تضمين بيانات إضافية (مثل الرمز البريدي) وبطريقة منظمة: type Address struct { Address string PostalCode string } func UserAddress(username string) *Address { // تُعيد عنوان المستخدم والرمز البريدي مثل بنية } قد يكون هذا مثالًا على تغيير رئيسي في الإصدار لأنه سيتطلب من المستخدمين إجراء تغييرات على التعليمات البرمجية الخاصة بهم من أجل استخدامها. سيكون الأمر نفسه صحيحًا إذا قررنا حذف الدالة UserAddress، لأن المستخدمين سيحتاجون إلى تحديث التعليمات البرمجية الخاصة بهم لاستخدام البديل. مثال آخر على تغيير الإصدار الرئيسي هو إضافة معامل جديد إلى دالة UserAddress على الرغم من أنها ما زالت تُعيد string: func UserAddress(username string, uppercase bool) string { // true تُعيد عنوان المستخدم مثل سلسلة بأحرف كبيرة إذا كان المعامل المنطقي قيمته } نظرًا لأن هذا التغيير يتطلب أيضًا من المستخدمين تحديث التعليمات البرمجية الخاصة بهم إذا كانوا يستخدمون دالة UserAddress، فسيتطلب ذلك أيضًا زيادة كبيرة في الإصدار. لن تكون كل التغييرات التي نجريها على الشيفرة جذرية، إذ سنُجري أحيانًا تغييرات على واجهة برمجة التطبيقات العامة API، بحيث نُضيف دوالًا أو قيمًا جديدة، ولكن هذا لا يغير أيًا من الدوال أو القيم الحالية. أرقام الإصدارات الثانوية الرقم الثاني في إصدار SemVer هو رقم الإصدار الثانوي (1.4.3)، وهنا يطرأ تغيير بسيط في الإصدار للإشارة إلى التغييرات المتوافقة مع الإصدارات السابقة لواجهة برمجة التطبيقات العامة الخاصة بنا. سيكون التغيير المتوافق مع الإصدارات السابقة أي تغيير لا يؤثر على التعليمات البرمجية أو المشاريع التي تستخدم الوحدة الحالية. على غرار رقم الإصدار الرئيسي؛ يؤثر هذا فقط على واجهة برمجة التطبيقات العامة. قد تكون إحدى الطرق لتذكر التغييرات التي تناسب هذه الفئة، هي أي شيء يعد "إضافة"، ولكن ليس "تحديثًا". باستخدام نفس المثال السابق الذي شرحنا فيه رقم الإصدار الرئيسي، تخيل أن لديك تابع باسم UserAddress يُعيد سلسلة string: func UserAddress(username string) string { //تُعيد عنوان المستخدم مثل سلسلة } هنا بدلًا من تحديث UserAddress بجعله يُعيد Address*، سنضيف تابع جديد تمامًا يسمى UserAddressDetail: type Address struct { Address string PostalCode string } func UserAddress(username string) string { // تُعيد عنوان المستخدم مثل سلسلة } func UserAddressDetail(username string) *Address { // تُعيد عنوان المستخدم والرمز البريدي مثل بنية } لا تتطلب إضافة الدالة الجديدة UserAddressDetail إجراء تغييرات من قِبل المستخدمين إذا حدّثوا إلى هذا الإصدار من الوحدة، لذلك ستُعد زيادة بسيطة في رقم الإصدار. يمكن للمستخدمين الاستمرار في استخدام UserAddress وسيحتاجون فقط إلى تحديث التعليمات البرمجية الخاصة بهم إذا كانوا يفضلون تضمين المعلومات الإضافية من UserAddressDetail. من المحتمل ألا تكون تغييرات واجهة برمجة التطبيقات العامة هي المرة الوحيدة التي نُطلق فيها إصدارًا جديدًا من الوحدة. تُعد الأخطاء bugs جزءًا لا مفر منه من تطوير البرامج، ورقم إصدار التصحيح موجود للتستر على تلك الثغرات. أرقام إصدارات التصحيح الرقم الأخير في صيغة SemVer هي إصدار التصحيح، (1.4.3). تغيير إصدار التصحيح هو أي تغيير لا يؤثر على واجهة برمجة التطبيقات العامة API للوحدة. تكون غالبًا التغييرات التي لا تؤثر على واجهة برمجة التطبيقات العامة للوحدة، أشياءً مثل إصلاحات الأخطاء أو إصلاحات الأمان. بالعودة إلى الدالة UserAddress من الأمثلة السابقة، لنفترض أن إصدارًا من الوحدة يفتقد إلى جزء من العنوان في السلسلة التي تُعيدها الدالة. إذا أطلقنا إصدارًا جديدًا من الوحدة لإصلاح هذا الخطأ، سيؤدي ذلك إلى زيادة إصدار التصحيح فقط، ولن يتضمن الإصدار أيّة تغييرات في كيفية استخدام المستخدم لواجهة برمجة التطبيقات العامة UserAddress، وإنما مُجرد تعديلات لضمان صحة البيانات المُعادة. يُعد اختيار رقم إصدار جديد بعناية طريقة مهمة لكسب ثقة المستخدمين، إذ يُظهر استخدام الإصدار الدلالي للمستخدمين مقدار العمل المطلوب للتحديث إلى إصدار جديد، وبالتأكيد لن تفاجئهم عن طريق الخطأ بتحديث يكسر برنامجهم. بعد التفكير في التغييرات التي أجريناها على الوحدة، وتحديد رقم الإصدار التالي المطلوب استخدامه، يمكننا الآن نشر الإصدار الجديد وإتاحته للمستخدمين. نشر إصدار جديد من الوحدة سنحتاج إلى تحديث الوحدة بالتغييرات التي نخطط لإجرائها قبل نشر أي إصدار جديد من الوحدة. لن نكون قادرين على تحديد أي جزء من الإصدار الدلالي يجب أن يزداد دون أن نُجري تغييرات. بالنسبة للوحدة التي أنشأناها، سنضيف التابع Goodbye لاستكمال التابع Hello، وبعد ذلك ستنشر هذا الإصدار الجديد للاستخدام. افتح ملف "pubmodule.go" وضِف التابع الجديد Goodbye إلى واجهة برمجة التطبيقات العامة API: package pubmodule func Hello() string { return "Hello, You!" } func Goodbye() string { return "Goodbye for now!" } سنحتاج الآن إلى التحقق من التغييرات التي يُتوقع أن تُنفّذ عن طريق تنفيذ الأمر التالي: $ git status يوضّح الخرج أن التغيير الوحيد في الوحدة الخاصة بنا، هو التابع الذي أضفناه إلى ملف "pubmodule.go": On branch main Your branch is up to date with 'origin/main'. Changes not staged for commit: (use "git add <file>..." to update what will be committed) (use "git restore <file>..." to discard changes in working directory) modified: pubmodule.go no changes added to commit (use "git add" and/or "git commit -a") نضيف بعد ذلك التغيير إلى الملفات المُدرجة ونجري التغيير في المستودع المحلي باستخدام git add و git commit: $ git add . $ git commit -m "Add Goodbye method" سيكون الخرج كما يلي: [main 3235010] Add Goodbye method 1 file changed, 4 insertions(+) سنحتاج إلى دفع التغييرات بعد تنفيذها إلى مستودع غيت هب الخاص بنا، وتكون عادةً هذه الخطوة مختلفة قليلًا عند العمل على مشروع برمجي أكبر أو عند العمل مع مطورين آخرين في مشروع. عند إجراء تطوير على ميزة جديدة، يُنشئ المطور فرع غيت جديد لإجراء تلك التغييرات وتحضيرها إلى أن تصبح هذه الميزة مستقرة وجاهزة للإصدار، وبعدها سيأتي مطور آخر ويُراجع هذه التغييرات ضمن ذلك الفرع للتأكد أو لاكتشاف المشكلات التي ربما غفل عنها المطور الأول. يُدمج الفرع الخاص بتلك الميزة في الفرع الافتراضي، مثل master أو main بمجرد الانتهاء من المراجعة، وتُجمع هذه التغييرات في الفرع الافتراضي حتى يحين وقت نشر إصدار جديد. هنا لا تمر الوحدة بهذه العملية (إضافة ميزة جديدة في فرع آخر)، لذا سيكون دفع التغييرات التي أجريناها على المستودع، هو فقط التغييرات التي أجريناها: $ git push سيكون الخرج على النحو التالي: numerating objects: 5, done. Counting objects: 100% (5/5), done. Delta compression using up to 8 threads Compressing objects: 100% (3/3), done. Writing objects: 100% (3/3), 369 bytes | 369.00 KiB/s, done. Total 3 (delta 0), reused 0 (delta 0), pack-reused 0 To github.com:your_github_username/pubmodule.git 931071d..3235010 main -> main يوضح الخرج أن الشيفرة الجديدة جاهزة للاستخدام في الفرع الافتراضي من قبل المستخدمين. كل ما فعلناه حتى الآن يُطابق ما فعلناه عند نشر الوحدة في البداية. الآن، تأتي نقطة مهمة في عملية إطلاق الإصدارات، وهي اختيار رقم الإصدار. إذا نظرنا إلى التغييرات التي أجريناها على الوحدة، فإن التغيّر الوحيد على واجهة برمجة التطبيقات العامة (أو أي تغيير) هو إضافة التابع Goodbye إلى الوحدة. بما أنه يمكن للمستخدم التحديث من الإصدار السابق الذي كان يحتوي فقط على الدالة Hello، دون إجراء تغييرات من جانبهم، سيكون هذا التغيير متوافقًا مع الإصدارات السابقة. قد يعني ذلك (ضمن مفهوم الإصدار الدلالي) التغيير المتوافق مع الإصدارات السابقة لواجهة برمجة التطبيقات العامة زيادةً في رقم الإصدار الثانوي. هذا الإصدار هو الإصدار الأول من الوحدة التي نشرناها، لذلك ليس هناك إصدار سابق لنزيد عليه. عمومًا، إذا كان الإصدار الحالي هو 0.0.0 أي "لا يوجد إصدار"، ستقودنا زيادة الإصدار الثانوي إلى الإصدار 0.1.0، وهو الإصدار التالي من الوحدة التي أنشأناها. الآن، بعد أن أصبح لدينا رقم إصدار نُعطيه للوحدة، أصبح بإمكاننا استخدامه مع وسوم غيت لنشر إصدار جديد. عندما يستخدم المطورون غيت لتتبع شيفرة المصدر الخاصة بهم (حتى في لغات أخرى غير جو)، فإن العرف الشائع هو استخدام وسوم غيت لتتبع الشيفرة التي أُطلقت لإصدار معين؛ فبهذه الطريقة يمكنهم استخدام الوسم إذا احتاجوا في أي وقت إلى إجراء تغييرات على إصدار قديم. بما أن لغة جو تُنزّل الوحدات من المستودعات المصدرية، بالتالي تستطيع الاستفادة من هذه الخاصية في استخدام وسوم الإصدار نفسها. لنشر نسخة جديدة من الوحدة التي أنشأناها باستخدام هذه الوسوم، سنضع وسمًا على الشيفرة التي نطلقها باستخدام الأمر git tag، وستحتاج أيضًا إلى تقديم وسم الإصدار مثل وسيط لهذا الأمر. لإنشاء وسم الإصدار، نبدأ بالبادئة v ونضيف SemVer بعدها مباشرةً. في حالتنا، سيكون وسم الإصدار النهائي هو v0.1.0. شغل الأمر git tag لتمييز الوحدة التي أنشأناها بوسم الإصدار هذا: $ git tag v0.1.0 سنحتاج -بعد إضافة وسم الإصدار محليًا- إلى دفع هذا الوسم إلى مستودع غيت هب باستخدام git push مع origin: $ git push origin v0.1.0 بعد نجاح تنفيذ الأمر git push، سترى أن الوسم v0.1.0 قد أُنشئ: Total 0 (delta 0), reused 0 (delta 0), pack-reused 0 To github.com:your_github_username/pubmodule.git * [new tag] v0.1.0 -> v0.1.0 يوضح الخرج أعلاه أن الوسم السابق قد أَُضيف، وأن مستودع غيت هب الخاص بنا يحتوي على الوسم الجديد v0.1.0، وسيكون متاحًا لمستخدمي الوحدة الوصول إليه. بعد نشر إصدار جديد من الوحدة واستخدام الأمر git tag، لن يكون المستخدم بحاجة إلى تنزيل إصدار جديد بناءً على أحدث إيداع تعمية hash من الفرع الافتراضي، وذلك عندما يرغب بالحصول على أحدث إصدار من الوحدة باستخدام الأمر go get. بعد إطلاق إصدار الوحدة، ستبدأ أداة go في استخدام هذه الإصدارات لتحديد أفضل طريقة لتحديث الوحدة، ويتيح لنا ذلك إلى جانب الإصدار الدلالي، إمكانية تكرار الوحدات وتحسينها مع تزويد المستخدمين بتجربة متسقة ومستقرة. الخاتمة أنشأنا خلال هذه المقالة وحدةً برمجيةً عامة باستخدام لغة جو ونشرناها في مستودع غيت هب حتى يتمكن الآخرون من استخدامها، واستخدمنا أيضًا مفهوم الإصدار الدلالي لتحديد رقم إصدار مناسب للوحدة. وسّعنا أيضًا دوال هذه الوحدة، ونشرنا الإصدار الجديد اعتمادًا على مفهوم الإصدار الدلالي مع ضمان عدم تعطّل البرامج التي تعتمد عليها. ترجمة -وبتصرف- للمقال How to Distribute Go Modules لصاحبه Kristin Davidson. اقرأ أيضًا المقال السابق: كيفية استخدام الوحدات Modules في لغة جو Go. كتابة أول برنامج (ومكتبة) لك باستخدام لغة البرمجة Go الدليل السريع إلى لغة البرمجة Go بناء البرامج المكتوبة بلغة جو Go وتثبيتها
  11. أضاف مؤلفو لغة جو في النسخة 1.13 طريقةً جديدةً لإدارة المكتبات التي يعتمد عليها مشروع مكتوب باستخدام هذه اللغة، تسمى وحدات Go، وقد أتت استجابةً إلى حاجة المطورين إلى تسهيل عملية الحفاظ على الإصدارات المختلفة من الاعتماديات dependencies وإضافة المزيد من المرونة إلى الطريقة التي ينظم بها المطورون مشاريعهم على أجهزتهم. تتكون الوحدات عادةً في لغة جو من مشروع أو مكتبة واحدة إلى جانب مجموعة من حزم لغة جو التي تُطلق released معًا بعد ذلك. تعمل الوحدات في هذه اللغة على حل العديد من المشكلات بالاستعانة بمتغير البيئة GOPATH الخاص بنظام التشغيل، من خلال السماح للمستخدمين بوضع شيفرة مشروعهم في المجلد الذي يختارونه وتحديد إصدارات الاعتماديات لكل وحدة. سننشئ في هذه المقالة وحدة جو عامة public خاصة بنا وسنضيف إليها حزمة، وسنتعلم أيضًا كيفية إضافة وحدة عامة أنشأها آخرون إلى مشروعنا، وإضافة إصدار محدد من هذه الوحدة إلى المشروع. المتطلبات أن يكون لديك مساحة عمل خاصة في لغة جو، وإذا لم يكن لديك اتبع سلسلة المقالات التالية: تثبيت لغة جو Go وإعداد بيئة برمجة محلية على أبونتو Ubuntu. تثبيت لغة جو وإعداد بيئة برمجة محلية على نظام ماك أو اس macOS. تثبيت لغة جو وإعداد بيئة برمجة محلية على ويندوز. معرفة بكيفية إنشاء الحزم في لغة جو Go. إنشاء وحدة جديدة قد تبدو الوحدة module مشابهة للحزمة package للوهلة الأولى في لغة جو، لكن تحتوي الوحدة على عدد من الشيفرات التي تُحقق وظيفة الحزمة، إضافةً إلى ملفين مهمين في المجلد الجذر root هما "go.mod" و "go.sum". تحتوي هذه الملفات على معلومات تستخدمها أداة go لتتبع إعدادات ضبط Configuration الوحدة الخاصة بك، وتجري عادةً صيانتها بواسطة الأداة نفسها، لذا لن تكون مضطرًا لفعل ذلك بنفسك. أول شيء عليك التفكير فيه هو المكان الذي ستضع فيه الوحدة، فمفهوم الوحدات يجعل بالإمكان وضع المشاريع في أي مكان من نظام الملفات، وليس فقط ضمن مجلد حُدد مسبقًا في جو. ربما يكون لديك مجلد خاص لإنشاء المشاريع وترغب في استخدامه، لكن هنا سننشئ مجلدًا باسم projects، ونسمي الوحدة الجديدة mymodule. يمكنك إنشاء المجلد projects إما من خلال بيئة تطوير متكاملة IDE أو من خلال سطر الأوامر. في حال استخدامك لسطر الأوامر، ابدأ بإنشاء المجلد وانتقل إليه: $ mkdir projects $ cd projects سننشئ بعد ذلك مجلد الوحدة، إذ يكون عادةً اسم المجلد المتواجد في المستوى الأعلى من الوحدة هو نفس اسم الوحدة لتسهيل تتبع الأشياء. أنشئ ضمن مجلد projects مجلدًا باسم "mymodule": $ mkdir mymodule بعد إنشاء مجلد الوحدة، ستصبح لدينا البنية التالية: └── projects └── mymodule الخطوة التالية هي إنشاء ملف "go.mod" داخل مجلد "mymodule" كي نُعّرف الوحدة، وسنستخدم لأجل ذلك الأمر go mod init ونحدد له اسم الوحدة mymodule كما يلي: $ go mod init mymodule يعطي هذا الأمر الخرج التالي عند إنشاء الوحدة: go: creating new go.mod: module mymodule سيصبح لديك الآن بنية المجلد التالية: └── projects └── mymodule └── go.mod دعنا نلقي نظرةً على ملف "go.mod" لمعرفة ما فعله الأمر go mod init. الملف go.mod يلعب الملف "go.mod" جزءًا مهمًا جدًا عند تشغيل الأوامر باستخدام الأداة go، إذ يحتوي على اسم الوحدة وإصدارات الوحدات الأخرى التي تعتمد عليها الوحدة الخاصة بك، ويمكن أن يحتوي أيضًا على موجّهات directives أخرى مثل replace، التي يمكن أن تكون مفيدة لإجراء عمليات تطوير على وحدات متعددة في وقت واحد. افتح الملف go.mod الموجود ضمن المجلد mymodule باستخدام محرر نانو nano أو محرر النصوص المفضل لديك: $ nano go.mod ستكون محتوياته هكذا (قليلة أليس كذلك؟): module mymodule go 1.16 في السطر الأول لدينا موجّه يُدعى module، مهمته إخبار مُصرّف اللغة عن اسم الوحدة الخاصة بك، بحيث عندما يبحث في مسارات الاستيراد import ضمن حزمة فإنه يعرف أين عليه أن يبحث عن mymodule. تأتي القيمة mymodule من المعامل الذي مررته إلى go mod init: module mymodule لدينا في السطر الثاني والأخير من الملف موجّهًا آخرًا هو go، مهمته إخبار المصرّف عن إصدار اللغة التي تستهدفها الوحدة. بما أننا ننشئ الوحدة باستخدام النسخة 1.16 من لغة جو، فإننا نكتب: go 1.16 سيتوسع هذا الملف مع إضافة المزيد من المعلومات إلى الوحدة، ولكن من الجيد إلقاء نظرة عليه الآن لمعرفة كيف يتغير عند إضافة اعتماديات في أوقات لاحقة. لقد أنشأت الآن وحدة جو باستخدام go mod init واطلعت على ما يحتويه ملف "go.mod"، لكن ليس لهذه الوحدة أي وظيفة تفعلها حتى الآن. سنبدأ في الخطوة التالية بتطوير هذه الوحدة وإضافة بعض الوظائف إليها. كيفية إضافة شيفرات برمجية إلى الوحدة ستنشئ ملف "main.go" داخل المجلد "mymodule" لنضع فيه شيفرة برمجية ونشغلها ونختبر صحة عمل الوحدة. يُعد استخدام ملف "main.go" في برامج هذه اللغة للإشارة إلى نقطة بداية البرنامج أمرًا شائعًا. الأهم من هذا الملف هو الدالة main التي سنكتبها بداخله، أما اسم الملف بحد ذاته فهو اختياري، لكن من الأفضل تسميته بهذا الاسم لجعل عملية العثور عليه أسهل. سنجعل الدالة main تطبع رسالةً ترحيبيةً عند تشغيل البرنامج. نُنشئ الآن ملفًا باسم "main.go" باستخدام محرّر نانو أو أي محرر نصوص مفضل لديك: $ nano main.go ضع التعليمات البرمجية التالية في ملف "main.go"، إذ تصرّح هذه التعليمات أولًا عن الحزمة، ثم تستورد الحزمة fmt، وتطبع رسالةً ترحيبية: package main import "fmt" func main() { fmt.Println("Hello, Modules!") } تُمثّل الحزمة في لغة جو بالمجلد الذي تُنشأ فيه، ولكل ملف داخل مجلد الحزمة سطر يُصرّح فيه عن الحزمة التي ينتمي إليها هذا الملف. أعطينا الحزمة الاسم main في ملف "main.go" الذي أنشأته للتو، ويمكنك تسمية الحزمة بالطريقة التي تريدها، ولكن حزمة main خاصة في لغة جو، فعندما يرى مُصرّف اللغة أن الحزمة تحمل اسم main، فإنه يعلم أن الحزمة ثنائية binary، ويجب تصريفها إلى ملف تنفيذي، وليست مجرد مكتبة مصممة لاستخدامها في برنامج آخر. بعد تحديد الحزمة التي يتبع لها الملف، يجب أن نستورد الحزمة fmt لكي نستطيع استخدام الدالة Println منها، وذلك لطباعة الرسالة الترحيبية !Hello, Modules على شاشة الخرج. أخيرًا، نُعرّف الدالة main التي بدورها لها معنى خاص في لغة جو فهي مرتبطة بحزمة main، وعندما يرى المُصرّف دالة اسمها main داخل حزمة باسم mainسيعرف أن الدالة main هي الدالة الأولى التي يجب تشغيلها، ويُعرف هذا بنقطة دخول البرنامج. بمجرد إنشاء ملف "main.go"، ستصبح بنية مجلد الوحدة مشابهة لما يلي: └── projects └── mymodule └── go.mod └── main.go إذا كنت معتادًا على استخدام لغة جو ومتغير البيئة GOPATH، فإن تشغيل شيفرة ضمن وحدة مشابهٌ لكيفية تشغيل شيفرة من مجلد في GOPATH. لا تقلق إذا لم تكن معتادًا على GOPATH، لأن استخدام الوحدات يحل محل استخدامها. هناك طريقتان شائعتان لتشغيل برنامج تنفيذي في لغة جو، هما: إنشاء ملف ثنائي باستخدام go build وتشغيله، أو تشغيل الملف مباشرةً باستخدام go run. ستستخدم هنا go run لتشغيل الوحدة مباشرةً، إذ أن الأخير يجب أن يُشغّل لوحده. شغّل ملف "main.go" الذي أنشأته باستخدام الأمر go run: $ go run main.go ستحصل على الخرج التالي: Hello, Modules! أضفنا في هذا القسم الملف "main.go" إلى الوحدة التي أنشأناها وعرّفنا فيها نقطة دخول متمثلة بالدالة main وجعلناها تطبع عبارة ترحيبية. لم نستفد حتى الآن من خصائص أو فوائد الوحدات في لغة جو؛ إذ لا تختلف الوحدة التي أنشأناها للتو عن أي ملف يُشغّل باستخدام go run، وأول فائدة حقيقية لوحدات جو هي القدرة على إضافة الاعتماديات إلى مشروعك في أي مجلد وليس فقط ضمن مجلد "GOPATH"، كما يمكنك أيضًا إضافة حزم إلى وحدتك. سنوسّع في القسم التالي هذه الوحدة عن طريق إنشاء حزمة إضافية داخلها. إضافة حزمة إلى الوحدة قد تحتوي الوحدة على أي عدد من الحزم والحزم الفرعية sub-packages على غرار حزمة جو القياسية، وقد لا تحتوي على أية حزم. سننشئ الآن حزمة باسم mypackage داخل المجلد "mymodule". سنستخدم كما جرت العادة الأمر mkdir ونمرر له الوسيط mypackage لإنشاء مجلد جديد داخل المجلد "mymodule": $ mkdir mypackage سيؤدي هذا إلى إنشاء مجلد جديد باسم "mypackage" مثل حزمة فرعية جديدة من المجلد "mymodule": └── projects └── mymodule └── mypackage └── main.go └── go.mod استخدم الأمر cd للانتقال إلى المجلد "mypackage" الجديد، ثم استخدم محرر نانو أو محرر النصوص المفضل لديك لإنشاء ملف "mypackage.go". يمكن أن يكون لهذا الملف أي اسم، ولكن استخدم نفس اسم الحزمة لتسهيل عملية العثور على الملف الأساسي للحزمة: $ cd mypackage $ nano mypackage.go ضِف داخل الملف "mypackage.go" الشيفرة التالية التي تتضمن دالةً تدعى PrintHello تطبع عبارة !Hello, Modules! This is mypackage speaking عند استدعائها. package mypackage import "fmt" func PrintHello() { fmt.Println("Hello, Modules! This is mypackage speaking!") } نحن نريد أن تكون الدالة PrintHello متاحةً للحزم والبرامج الأخرى، لذا جعلناها دالةً مُصدّرةً exported function بكتابتنا لأول حرف منها بالحالة الكبيرة P. بعد أن أنشأنا الحزمة mypackage ووضعنا فيها دالةً مُصدَّرةً، سنحتاج الآن إلى استيرادها من حزمة mymodule لاستخدامها. هذا مشابه لكيفية استيراد حزم أخرى مثل حزمة fmt، لكن هنا يجب أن نضع اسم الوحدة قبل اسم الحزمة mymodule/mypackage. افتح ملف "main.go" من مجلد "mymodule" واستدعِ الدالة PrintHello كما يلي: package main import ( "fmt" "mymodule/mypackage" ) func main() { fmt.Println("Hello, Modules!") mypackage.PrintHello() } كما ذكرنا قبل قليل، عندما نريد استيراد الحزمة mypackage نضع اسم الوحدة قبلها مع وضع الفاصل / بينهما، وهو نفسه اسم الوحدة الذي وضعته في ملف "go.mod" (أي mymodule). "mymodule/mypackage" إذا أضفت لاحقًا حزمًا أخرى داخل mypackage، يمكنك استيرادها بطريقة مماثلة. على سبيل المثال، إذا كان لديك حزمة أخرى تسمى extrapackage داخل mypackage، فسيكون مسار استيراد هذه الحزمة هو mymodule/mypackage/extrapackage. شغّل الوحدة مرةً أخرى بعد إجراء التعديلات عليها باستخدام الأمر go run ومرر اسم الملف "main.go" الموجود ضمن المجلد "mymodule" كما في السابق: $ go run main.go عند التشغيل سترى نفس الرسالة التي حصلنا عليها سابقًا والمتمثلة بالرسالة !Hello, Modules إضافةً إلى الرسالة المطبوعة من دالة PrintHello والموجودة في الحزمة الجديدة التي أضفناها: Hello, Modules! Hello, Modules! This is mypackage speaking! لقد أضفت الآن حزمةً جديدة إلى وحدتك عن طريق إنشاء مجلد يسمى "mypackage" مع دالة PrintHello. يمكنك لاحقًا توسيع هذه الوحدة بإضافة حزم ودوال جديدة إليها، كما سيكون من الجيد تضمين وحدات أنشأها أشخاص آخرون في وحدتك. سنضيف في القسم التالي وحدةً بعيدة (من غيت Git) مثل اعتمادية لوحدتك. تضمين وحدة بعيدة أنشأها آخرون في وحدتك تُوزَّع وحدات لغة جو من مستودعات التحكم بالإصدار Version Control Repositories -أو اختصارًا VCSs- وأكثرها شيوعًا غيت git. عندما ترغب بإضافة وحدة جديدة مثل اعتمادية لوحدتك، تستخدم مسار المستودع للإشارة إلى الوحدة التي ترغب باستخدامها. عندما يرى مُصرّف اللغة مسار الاستيراد لهذه الوحدة، سيكون قادرًا على استنتاج مكان وجود هذه الوحدة اعتمادًا على مسار المستودع. سنضيف في المثال التالي المكتبة cobra مثل اعتمادية للوحدة التي أنشأناها، وهي مكتبة شائعة لإنشاء تطبيقات الطرفية Console. بطريقة مشابهة لما فعلناه عند إنشاء الوحدة mymodule سنستخدم الأداة go مرةً أخرى، لكن سنضيف لها get أي سنستخدم الأمر go get، وسنضع بعده مسار المستودع. شغّل الأمر go get من داخل مجلد "mymodule": $ go get github.com/spf13/cobra عند تشغيل هذا الأمر ستبحث أداة go عن مستودع Cobra من المسار الذي حددته، وستبحث عن أحدث إصدار منها من خلال البحث في الفروع ووسوم المستودع. بعد ذلك، سيُحمّل هذا الإصدار ويبدأ في تعقبها، وذلك من خلال إضافة اسم الوحدة والإصدار إلى ملف "go.mod" للرجوع إليه مستقبلًا. افتح ملف "go.mod" الموجود في المجلد "mymodule" لترى كيف حدّثت أداة go ملف "go.mod" عند إضافة الاعتمادية الجديدة. قد يتغير المثال أدناه اعتمادًا على الإصدار الحالي من Cobra أو إصدار أداة go التي تستخدمها، ولكن يجب أن تكون البنية العامة للتغييرات متشابهة: module mymodule go 1.16 require ( github.com/inconshreveable/mousetrap v1.0.0 // indirect github.com/spf13/cobra v1.2.1 // indirect github.com/spf13/pflag v1.0.5 // indirect ) لاحظ إضافة قسم جديد مُتمثّل بالموجّه require، ومهمته إخبار مُصرّف اللغة عن الوحدة التي تريدها (في حالتنا هي github.com/spf13/cobra) وعن إصدارها أيضًا. نلاحظ أيضًا أن هناك تعليق مكتوب فيه indirect، ليوضح أنه في الوقت الذي أُضيف فيه الموجّه require، لم تكن هناك إشارة مباشرة إلى الوحدة في أي من ملفات المصدر الخاصة بالوحدة. نلاحظ أيضًا وجود بعض الأسطر في require تشير إلى وحدات أخرى تتطلبها المكتبة Cobra والتي يجب على أداة جو أن تشير إليها أيضًا. نلاحظ أيضًا أنه عند تشغيل الأمر go run أُنشئ ملف جديد باسم "go.sum" في مجلد "mymodule"، وهو ملفٌ آخر مهم لوحدات جو يحتوي على معلومات تستخدمها جو لتسجيل قيم معمّاة hashes وإصدارات معينة من الاعتماديات. يضمن هذا تناسق الاعتماديات، حتى لو ثُبتت على جهاز مختلف. ستحتاج أيضًا إلى تحديث الملف "main.go" بعد تنزيل الاعتمادية، وذلك من خلال إضافة بعض التعليمات البرمجية الخاصة بها، لكي تتمكن من استخدامها. افتح الملف "main.go" الموجود في المجلد "mymodule" وضع فيه المحتويات التالية: package main import ( "fmt" "github.com/spf13/cobra" "mymodule/mypackage" ) func main() { cmd := &cobra.Command{ Run: func(cmd *cobra.Command, args []string) { fmt.Println("Hello, Modules!") mypackage.PrintHello() }, } fmt.Println("Calling cmd.Execute()!") cmd.Execute() } تُنشئ هذه الشيفرة بنية cobra.Command مع دالة Run تطبع عبارة ترحيب، والتي ستُنفّذ بعد ذلك باستدعاء ()cmd.Execute. شغّل الشيفرة الآن بعد أن عدلناها: $ go run main.go سترى الخرج التالي الذي يبدو مشابهًا لما رأيته من قبل، لكننا استخدمنا هنا الاعتمادية الجديدة التي أضفناها كما هو موضح في السطر الأول: Calling cmd.Execute()! Hello, Modules! Hello, Modules! This is mypackage speaking! يؤدي استخدام الأمر go get لإضافة اعتماديات إلى وحدتك إلى تنزيل أحدث إصدار من هذه الاعتمادية، وهو أمرٌ جيد لأن أحدث إصدار يتضمن كل التعديلات الجديدة والإصلاحات. قد نرغب أحيانًا في استخدام إصدار أقدم أو فرع مُحدد من مستودع هذه الاعتمادية. سنستخدم في القسم التالي الأداة go get لمعالجة هذه الحالة. استخدام إصدار محدد من وحدة بما أن وحدات لغة جو توزّع من مستودع التحكم في الإصدار، بالتالي يمكنها استخدام ميزاته، مثل الوسوم والفروع والإيداعات. يمكنك الإشارة إلى هذه الأمور في اعتمادياتك باستخدام الرمز @ في نهاية مسار الوحدة جنبًا إلى جنب مع الإصدار الذي ترغب في استخدامه. لاحظ أنه كان بإمكانك استخدام هذه الميزة مباشرةً في المثال السابق، فهي لا تتطلب شيئًا إلا وضع الرمز والإصدار. إذًا يمكننا استنتاج أنه في حال عدم استخدام هذا الرمز، يفترض جو أننا نريد أحدث إصدار، وهذا يُقابل وضع latest بعد هذا الرمز. latest هي ميزة خاصة لأداة جو، وليست جزءًا من الوحدة أو مستودع الوحدة التي تريد استخدامها مثل my-tag أو my-branch. إذًا، تُكافئ الصيغة التالية تنزيل أحدث إصدار من الوحدة سالفة الذكر: $ go get github.com/spf13/cobra@latest تخيل الآن أن هناك وحدة تستخدمها، وهي قيد التطوير حاليًا، ولنفترض اسمها your_domain/sammy/awesome. افترض أن هناك ميزة أضيفت إلى هذه الوحدة في فرع اسمه new-feature. لإضافة هذا الفرع مثل اعتمادية للوحدة الخاصة بك، يمكنك ببساطة استخدام مسار الوحدة متبوعًا بالرمز @ متبوعًا باسم الفرع: $ go get your_domain/sammy/awesome@new-feature عند تشغيل هذا الأمر ستتصل أداة go بالمستودع your_domain/sammy/awesome، وستنزّل الفرع new-features من آخر إيداع، وتضيف هذه المعلومات إلى الملف "go.mod". ليست الفروع الطريقة الوحيدة التي يمكنك من خلالها استخدام الرمز @، ولكن يمكنك استخدامه مع الوسوم أو الإيداعات أيضًا، فقد يكون أحيانًا أحدث إصدار من المكتبة التي تستخدمها إيداعًأ معطلًا، وفي هذه الحالة يمكنك الرجوع إلى إيداع سابق واستخدامه. بالعودة إلى نفس المثال السابق، افترض أنك بحاجة إلى الإشارة إلى الإيداع 07445ea من github.com/spf13/cobra لأنه يحتوي على بعض التغييرات التي تحتاجها ولا يمكنك استخدام إصدار آخر لسبب ما. في هذه الحالة، يمكنك وضع قيمة معمّاة hash بعد الرمز @. شغّل الآن الأمر go get من داخل مجلد "mymodule" لتنزيل الإصدار الجديد: $ go get github.com/spf13/cobra@07445ea إذا فتحت الآن ملف "go.mod" الخاص بالوحدة، فسترى أن go get قد حدّث سطر require للإشارة إلى الإيداع الذي تستخدمه: module mymodule go 1.16 require ( github.com/inconshreveable/mousetrap v1.0.0 // indirect github.com/spf13/cobra v1.1.2-0.20210209210842-07445ea179fc // indirect github.com/spf13/pflag v1.0.5 // indirect ) على عكس الوسوم أو الفروع، ونظرًا لأن الإيداع يمثّل نقطةً زمنيةً معينة، تتضمن جو معلومات إضافية في موجّه require للتأكد من استخدام الإصدار الصحيح مستقبلًا؛ فإذا نظرت إلى الإصدار، سترى أنه يتضمن إيداعًا معمّى hash قد أضفته، أي v1.1.2-0.20210209210842-07445ea179fc. تستخدم جو هذه الوظيفة لدعم إطلاق عدة إصدارات من الوحدة، فعندما تطلق وحدة جو إصدارًا جديدًا، سيُضاف وسمًا جديدًا إلى المستودع مع وسم لرقم الإصدار. إذا كنت تريد استخدام إصدار محدد، يمكنك النظر إلى قائمة الوسوم في المستودع حتى تجد الإصدار الذي تبحث عنه، أما إذا كنت تعرف الإصدار، لن تكون مضطرًا للبحث ضمن قائمة الوسوم لأنها مرتبة بانتظام. بالعودة إلى نفس المثال Cobra السابق، ولنفترض أنك بحاجة إلى استخدام الإصدار 1.1.1، الذي يملك وسمًا يدعى v1.1.1 في مستودع Cobra. سنستخدم الأمر go get مع الرمز @ حتى نتمكن من استخدام هذا الإصدار الموسوم تمامًا كما فعلنا مع الفروع أو الوسوم التي لا تشير لإصدار non-version tag. الآن، حدّث الوحدة الخاصة بك حتى تستخدم Cobra 1.1.1 من خلال تشغيل الأمر go get مع رقم الإصدار v1.1.1: $ go get github.com/spf13/cobra@v1.1.1 إذا فتحت الآن ملف "go.mod" الخاص بالوحدة، فسترى أن go get قد حدّث سطرrequire للإشارة إلى الإيداع الذي تستخدمه: module mymodule go 1.16 require ( github.com/inconshreveable/mousetrap v1.0.0 // indirect github.com/spf13/cobra v1.1.1 // indirect github.com/spf13/pflag v1.0.5 // indirect ) أخيرًا، إذا كنت تستخدم إصدارًا محددًا من المكتبة، مثل الإيداع 07445ea أو الإصدار v1.1.1 الذين تعرفنا عليهما منذ قليل، ثم قررت استخدام أحدث إصدار من المكتبة، فمن الممكن إنجاز ذلك باستخدام latest كما ذكرنا سابقًا. لتحديث الوحدة الخاصة بك إلى أحدث إصدار من Cobra، استخدم الأمر go get مرةً أخرى مع مسار الوحدة وتحديد الإصدار latest بعد الرمز @: $ go get github.com/spf13/cobra@latest بعد تنفيذ هذا الأمر، يُحدّث ملف "go.mod" ليبدو كما كان عليه عند تنزيل المكتبة Cobra أول مرة. قد يبدو الخرج مختلفًا قليلًا بحسب إصدار جو الذي تستخدمه والإصدار الأخير من Cobra ولكن يُفترض أن تلاحظ أن السطر github.com/spf13/cobra في قسم require قد تحدّث إلى آخر إصدار: module mymodule go 1.16 require ( github.com/inconshreveable/mousetrap v1.0.0 // indirect github.com/spf13/cobra v1.2.1 // indirect github.com/spf13/pflag v1.0.5 // indirect ) يُعد الأمر go get أداةً قوية يمكنك استخدامها لإدارة الاعتماديات في ملف "go.mod" دون الحاجة إلى تحريره يدويًا. يتيح لك استخدام الرمز @ مع اسم الوحدة، أن تستخدم إصدارات معينة لوحدة ما، أو حتى إيداعات، أو فروع، أو الرجوع إلى أحدث إصدار من اعتمادياتك كما رأينا للتو. سيسمح استخدام مجموعة الأدوات التي تعرفنا عليها في هذه المقالة بضمان استقرار البرنامج مستقبلًا. الخاتمة أنشأنا في هذه المقالة وحدةً برمجية باستخدام لغة جو مع حزمة فرعية، واستخدمنا تلك الحزمة داخل الوحدة التي أنشأناها. أضفنا أيضًا وحدةً خارجية إليها مثل اعتمادية، وتعلمنا كيفية الإشارة إلى إصدارات الوحدة بطرق مختلفة. ترجمة -وبتصرف- للمقال How to Use Go Modules لصاحبه Gopher Guides. اقرأ أيضًا المقال السابق: كيفية استخدام الحزمة Flag في لغة جو Go كتابة أول برنامج (ومكتبة) لك باستخدام لغة البرمجة Go استيراد الحزم في لغة جو Go بناء البرامج المكتوبة بلغة جو Go وتثبيتها
  12. نادرًا ما تكون الأدوات المساعدة لسطر الأوامر Command-line utilities مفيدةً دون ضبط configuration إضافي وذلك عندما يتطلب الأمر إجراء عمليات خارج الصندوق. تُعد الإعدادات الافتراضية للأوامر أمرًا جيدًا ومهمًا، لكن ينبغي أن تتميز بإمكانية قبول إعدادات ضبط محددة من المستخدمين أيضًا. في معظم أنظمة التشغيل يمكن تخصيص أوامر سطر الأوامر من خلال استخدام الرايات Flags؛ وهي سلاسل نصية تُضاف إلى أمر ما، بحيث تؤدي إلى سلوك خاص لهذا الأمر حسب قيمتها. تتيح لك لغة جو إنشاء أدوات مساعدة لسطر الأوامر تقبل رايات يمكن من خلالها تخصيص سلوك الأوامر، وذلك باستخدام حزمة flag من المكتبة القياسية. ستتعلم في هذه المقالة طرقًا مختلفة لاستخدام حزمة flag بهدف إنشاء أنواع مختلفة من الأدوات المساعدة لسطر الأوامر. سنستخدم رايةً للتحكم في خرج برنامج وتقديم وسطاء موضعية Positional arguments (وهي وسطاء يجب وضعها في الموضع أو الترتيب المناسب المُحدد مسبقًا)، إذ يمكننا مزج الرايات والبيانات الأخرى وتنفيذ أوامر فرعية. استخدام الرايات لتغيير سلوك البرنامج يتضمن استخدام حزمة الراية ثلاث خطوات: تبدأ بتعريف متغيرات تلتقط قيم الرايات، ثم تحديد الرايات التي سيستخدمها التطبيق، وتنتهي بتحليل الرايات المُقدمة للتطبيق عند التنفيذ. تُركّز معظم الدوال داخل حزمة الراية على تعريف رايات وربطها بالمتغيرات التي تُعرّفها، وتنجز الدالة ()Parse مرحلة التحليل. لتوضيح الأمور سنُنشئ برنامجًا بسيطًا يتضمن رايةً بوليانية تُغيّر الرسالة المطبوعة؛ فإذا كانت الراية color- موجودة، سيطبع البرنامج الرسالة باللون الأزرق؛ وإذا لم تُقدّم أي راية، فلن يكون للرسالة أي لون. انشئ ملفًا باسم boolean.go: $ nano boolean.go اضِف ما يلي إلى الملف: package main import ( "flag" "fmt" ) type Color string const ( ColorBlack Color = "\u001b[30m" ColorRed = "\u001b[31m" ColorGreen = "\u001b[32m" ColorYellow = "\u001b[33m" ColorBlue = "\u001b[34m" ColorReset = "\u001b[0m" ) func colorize(color Color, message string) { fmt.Println(string(color), message, string(ColorReset)) } func main() { useColor := flag.Bool("color", false, "display colorized output") flag.Parse() if *useColor { colorize(ColorBlue, "Hello, DigitalOcean!") return } fmt.Println("Hello, DigitalOcean!") } يستخدم هذا المثال سلاسل الهروب آنسي ANSI Escape Sequences لجعل الطرفية تُعطي خرجًا ملونًأ؛ وهي سلاسل خاصة من المحارف، لذا من المنطقي أن نُعرّف نوع خاص بها. في مثالنا نسمي هذا النوع Color ونعرّفه على أنه string، ثم نُعرّف لوحة ألوان لاستخدامها بكتلة نسميها const تتضمن عدة خيارات لونية اعتمادًا على النوع السابق. تستقبل الدالة colorize المُعرّفة بعد الكتلة const قيمةً لونيةً من اللوحة السابقة (أي عمليًّا متغير من النوع Color) إضافةً إلى الرسالة المطلوب تلوينها. بعد ذلك توجّه الطرفية Terminal لتغيير اللون عن طريق طباعة تسلسل الهروب للون المطلوب، ثم طباعة الرسالة. أخيرًا، يُطلب من الطرفية إعادة ضبط اللون الأساسي لها من خلال طباعة ColorReset، أي نطبع سلسلة الهروب للون المطلوب ثم رسالتنا، فتظهر باللون المطلوب، ثم نعيد ضبط اللون إلى حالته الأصلي. نستخدم داخل الدالة main الدالة flag.Bool لتعريف راية بوليانية اسمها color. المعامل الثاني لهذه الدالة false هو القيمة الافتراضية للراية، أي عندما نُشغل البرنامج بدونها. على عكس ما قد تتوقعه، فإن ضبط هذا المعامل على true هو أمر غير صحيح، لأننا لا نريد أن يُطبق سلوك التلوين إلا عندما نُمرر الراية كما سترى بعد قليل. إذًا، تكون قيمة هذا المعامل تكون غالبًا false مع الرايات البوليانية. المعامل الأخير هو نص توضيحي لهذه الراية، أي كأنه توثيق استخدام أو وصف. القيمة التي تعيدها الدالة هي مؤشر إلى bool، وتُضبط قيمة هذا المؤشر من خلال الدالة flag.Parse بناءً على الراية التي يُمررها المستخدم. يمكننا بعد ذلك التحقق من قيمة هذا المؤشر البولياني عن طريق تحصيل قيمته باستخدام المعامل *. إذًا باستخدام هذه القيمة المنطقية، يمكننا استدعاء colorize عند تمرير color- أو استدعاء دالة الطباعة العادية fmt.Println دون تلوين إذا لم تُمرر الراية. احفظ وأغلق الملف وشغله بدون تقديم أي راية: $ go run boolean.go ستحصل على الخرج التالي: Hello, DigitalOcean! أعد تشغيله مع تمرير الراية color-: $ go run boolean.go -color سيكون الناتج هو نفس النص، ولكن هذه المرة باللون الأزرق. يمكنك أيضًا إرسال أسماء ملفات أو بيانات أخرى إلى البرنامج، وليس فقط رايات. التعامل مع الوسطاء الموضعية تمتلك الأوامر غالبًا بعض الوسطاء التي تحدد سلوكها. لنأخذ مثلًا الأمر head الذي يطبع الأسطر الأولى من ملف ما. غالبًا ما يُستدعى هذا الأمر بالشكل التالي head example.txt، إذ يُمثل الملف example.txt وسيطًا موضعيًّا هنا. تستمر الدالة ()Parse في تحليل الرايات التي تصادفها ريثما تجد شيئًا آخر لا يُمثّل راية (سنرى بعد قليل أنها ستتوقف عند مصادفة وسيط موضعي). لذا سنتعرف الآن على دالة أخرى توفرها الحزمة flag تتمثل بالدالة ()Args والدالة ()Arg. لتوضيح ذلك سنعيد تنفيذ الأمر head الذي يعرض أول عدة أسطر من ملف معين. أنشئ ملفًا جديدًا يسمى head.go وضِف الشيفرة التالية: package main import ( "bufio" "flag" "fmt" "io" "os" ) func main() { var count int flag.IntVar(&count, "n", 5, "number of lines to read from the file") flag.Parse() var in io.Reader if filename := flag.Arg(0); filename != "" { f, err := os.Open(filename) if err != nil { fmt.Println("error opening file: err:", err) os.Exit(1) } defer f.Close() in = f } else { in = os.Stdin } buf := bufio.NewScanner(in) for i := 0; i < count; i++ { if !buf.Scan() { break } fmt.Println(buf.Text()) } if err := buf.Err(); err != nil { fmt.Fprintln(os.Stderr, "error reading: err:", err) } } نُعرّف بدايةً المتغير count الذي سيُخزّن عدد الأسطر التي يجب أن يقرأها البرنامج من الملف، كما نُعرّف أيضًا الراية n- باستخدام flag.IntVar لكي نعكس سلوك البرنامج head. تسمح لنا هذه الدالة بتمرير المؤشر الخاص بنا إلى متغير على عكس دوال حزمة الراية flag الأخرى، التي لا تحتوي على اللاحقة Var. بغض النظر عن هذا الفرق، ستكون بقية المعاملات للدالة flag.IntVar مثل نظيراتها في دوال flag.Int الأخرى التي لا تتضمن هذه اللاحقة، أي ستكون المعاملات كما يلي: اسم الراية ثم القيمة الافتراضية ثم الوصف. بعد ذلك، نستدعي دالة ()flag.Parse لتفسير دخل المستخدم. يقرأ القسم التالي الملف، إذ نعرّف متغيرًا باسم io.Reader الذي سيُضبط إما على الملف الذي يطلبه المستخدم، أو الدخل القياسي الذي يُمرر إلى البرنامج. نستخدم الدالة flag.Arg داخل التعليمة if للوصول إلى أول وسيط موضعي يأتي بعد الرايات. ستكون قيمة filename، إما اسم ملف يُقدمه المستخدم أو سلسلة فارغة ""؛ ففي حال تقديم اسم ملف، تُستخدم الدالة os.Open لفتح الملف وضبط المتغير io.Reader سالف الذكر؛ وإذا لم يُقدم اسم ملف (أي سلسلة فارغة) فإننا نستخدم os.Stdin للقراءة من الدخل القياسي. يستخدم القسم الأخير bufio.Scanner* الذي أنشئ باستخدام bufio.NewScanner لقراءة الأسطر من متغير io.Reader الذي يُمثله in. بعد ذلك، نكرّر حلقة عدة مرات حسب قيمة count. تُستدعى break إذا كان ناتج قراءة سطر باستخدام buf.Scan هو القيمة false، إذ يشير ذلك إلى أن عدد الأسطر أقل من الرقم الذي يطلبه المستخدم. شغّل هذا البرنامج واعرض محتويات الملف البرمجي نفسه الذي كتبته للتو من خلال استخدام head.go مثل وسيط، أي سنشغّل البرنامج head.go ونقرأ محتوياته أيضًا: $ go run head.go -- head.go الفاصل -- هو راية، إذ يُفسَّر وجودها من قِبل حزمة الراية flag على أنه لن يكون هناك رايات بدءًا منها. سيكون الخرج كما يلي: package main import ( "bufio" "flag" دعنا نستخدم الراية n- التي عرّفناها لتحديد عدد الأسطر المقروءة: $ go run head.go -n 1 head.go سيكون الخرج هو أول سطر فقط من الملف: package main أخيرًا، عندما يكتشف البرنامج أنه لم تُقدّم أية وسطاء موضعية، سيقرأ من الدخل القياسي، تمامًا مثل الأمر head. جرّب تشغيل هذا الأمر: $ echo "fish\nlobsters\nsharks\nminnows" | go run head.go -n 3 سيكون الخرج على النحو التالي: fish lobsters sharks يقتصر سلوك دوال حزمة الراية التي رأيناها حتى الآن على فحص الأمر المُستدعى كاملًا. لا نريد دائمًا هذا السلوك، خاصةً إذا كانت الأداة التي نريدها تتضمن أوامر فرعية Sub-commands. استخدام FlagSet لدعم إمكانية تحقيق الأوامر الفرعية تتضمن تطبيقات سطر الأوامر الحديثة غالبًا وجود أوامر فرعية، بهدف تجميع مجموعة من الأدوات تحت أمر واحد. الأداة الأكثر شهرة التي تستخدم هذا النمط هي git، فمثلًا في git init، لدينا git هو الأمر الأساسي و init هو الأمر الفرعي التابع له. إحدى السمات البارزة للأوامر الفرعية هي أن كل أمر فرعي يمكن أن يكون له مجموعته الخاصة من الرايات. يمكن لتطبيقات لغة جو أن تدعم الأوامر الفرعية من خلال نوع خاص يُدعى (flag.(*FlagSet. سننشئ برنامجًا يُنفذ أمرًا باستخدام أمرين فرعيين وبرايات مختلفة، وذلك لكي نجعل الأمور واضحة. نُنشئ ملفًا جديدًا يسمى subcommand.go بالمحتويات التالية: package main import ( "errors" "flag" "fmt" "os" ) func NewGreetCommand() *GreetCommand { gc := &GreetCommand{ fs: flag.NewFlagSet("greet", flag.ContinueOnError), } gc.fs.StringVar(&gc.name, "name", "World", "name of the person to be greeted") return gc } type GreetCommand struct { fs *flag.FlagSet name string } func (g *GreetCommand) Name() string { return g.fs.Name() } func (g *GreetCommand) Init(args []string) error { return g.fs.Parse(args) } func (g *GreetCommand) Run() error { fmt.Println("Hello", g.name, "!") return nil } type Runner interface { Init([]string) error Run() error Name() string } func root(args []string) error { if len(args) < 1 { return errors.New("You must pass a sub-command") } cmds := []Runner{ NewGreetCommand(), } subcommand := os.Args[1] for _, cmd := range cmds { if cmd.Name() == subcommand { cmd.Init(os.Args[2:]) return cmd.Run() } } return fmt.Errorf("Unknown subcommand: %s", subcommand) } func main() { if err := root(os.Args[1:]); err != nil { fmt.Println(err) os.Exit(1) } } ينقسم هذا البرنامج إلى عدة أجزاء: الدالة main، والدالة root، وبعض الدوال لتحقيق الأمر الفرعي. تعالج الدالة main الأخطاء التي تُعاد من الأوامر، بينما تلتقط تعليمة if الأخطاء التي قد تُعيدها الدوال وتطبع الخطأ ويُنهى البرنامج وتعاد القيمة 1 إشارةً إلى حدوث خطأ إلى نظام التشغيل. نمرر جميع المعطيات التي استُدعي البرنامج معها داخل الدالةmain إلى الدالة root مع حذف الوسيط الأول الذي يُمثل اسم البرنامج (في الأمثلة السابقة subcommand\.) من خلال استقطاع os.Args. تعرّف الدالة الدالة root الأمر الفرعي Runner[] حيثما تُعرّف جميع الأوامر الفرعية. Runner هي واجهة الأوامر الفرعية التي تسمح لدالة root باسترداد اسم الأمر الفرعي باستخدام ()Name ومقارنته بمحتويات المتغير subcommand. بمجرد تحديد الأمر الفرعي الصحيح بعد التكرار على متغير cmds، نُهيئ الأمر الفرعي مع بقية الوسطاء ونستدعي التابع ()Run الخاص بهذا الأمر. على الرغم من أننا نُعرّف أمرًا فرعيًّا واحدًا، إلا أن هذا الإطار سيسمح لنا بسهولة بإنشاء أوامر أخرى. نُعرّف نسخةً من النوع GreetCommand باستخدام NewGreetCommand، إذ نُنشئ flag.FlagSet* جديد باستخدام flag.NewFlagSet. تأخذ flag.NewFlagSet وسيطين، هما اسم مجموعة الرايات واستراتيجية للإبلاغ عن أخطاء التحليل. يمكن الوصول إلى اسم flag.FlagSet* باستخدام التابعflag.(*FlagSet).Name. نستخدم هذا في الطريقة ()GreetCommand).Name*) بحيث يتطابق اسم الأمر الفرعي مع الاسم الذي قدمناه إلى flag.FlagSet*. يُعرّف NewGreetCommand أيضًا الراية name- بطريقة مشابهة للأمثلة السابقة، ولكنه بدلًا من ذلك يستدعيها مثل تابع للحقل flag.FlagSet* في GreetCommand* (نكتب gc.fs). عندما تستدعي root التابع ()Init الذي يخص GreetCommand*، فإننا نمرر المعطيات المقدمة إلى التابع Parse الخاص بالحقل flag.FlagSet*. سيكون من الأسهل أن ترى الأوامر الفرعية إذا بنيت هذا البرنامج ثم شغلته. اِبنِ البرنامج كما يلي: $ go build subcommand.go الآن، شغّل البرنامج دون وسطاء: $ ./subcommand سيكون الخرج على النحو التالي: You must pass a sub-command شغّل الآن البرنامج مع استخدام الأمر الفرعي greet: $ ./subcommand greet سيكون الخرج على النحو التالي: Hello World ! استخدم الآن الراية name مع greet لتحديد اسم: $ ./subcommand greet -name Sammy سيكون الخرج كما يلي: Hello Sammy ! يوضح هذا المثال بعض المبادئ الكامنة وراء كيفية هيكلة تطبيقات سطر الأوامر الأكبر حجمًا أو الأكثر تعقيدًا في لغة جو. صُمم FlagSets لمنح المطورين مزيدًا من التحكم وكيفية معالجة الرايات من خلال منطق تحليل الراية. الخاتمة تمنح الرايات مستخدمي برنامجك التحكم في كيفية تنفيذ البرامج، أو التحكم بسلوكه، وبالتالي تجعله أكثر مرونةً في التعامل مع سياقات التنفيذ المحتملة. من المهم أن تمنح المستخدمين إعدادات افتراضية مفيدة، ولكن يجب أن تمنحهم الفرصة لتجاوز الإعدادات التي لا تناسب حالتهم. لقد رأينا أن حزمة الراية flag توفر خيارات مرنة لتعزيز إمكانية إضافة خيارات ضبط خاصة للمستخدمين. يمكنك اختيار بعض الرايات البسيطة، أو إنشاء مجموعة من الأوامر الفرعية القابلة للتوسيع. سيساعدك استخدام حزمة الراية في كلتا الحالتين على بناء أدوات مرنة لسطر الأوامر. ترجمة -وبتصرف- للمقال How To Use the Flag Package in Go لصاحبه Gopher Guides. اقرأ أيضًا المقال السابق: استخدام الراية ldflags لضبط معلومات الإصدار لتطبيقات لغة جو Go كيفية تعريف واستدعاء الدوال في لغة جو Go البرمجة بلغة go كيفية تعريف واستدعاء الدوال في لغة جو Go
  13. يؤدي بناء الثنائيات أو الملفات التنفيذية Binaries جنبًا إلى جنب مع إنشاء البيانات الوصفية Metadata والمعلومات الأخرى المتعلقة بالإصدار Version عند نشر التطبيقات إلى تحسين عمليات المراقبة Monitoring والتسجيل Logging وتصحيح الأخطاء، وذلك من خلال إضافة معلومات تعريف تساعد في تتبع عمليات البناء التي تُجريها بمرور الوقت. يمكن أن تتضمن معلومات الإصدار العديد من الأشياء التي تتسم بالديناميكية، مثل وقت البناء والجهاز أو المستخدم الذي أجرى عملية بناء الملف التنفيذي ورقم المعرّف ID للإيداع Commit على نظام إدارة الإصدار VCS الذي تستخدمه غيت Git مثلًا. بما أن هذه المعلومات تتغير باستمرار، ستكون كتابة هذه المعلومات ضمن الشيفرة المصدر مباشرةً، وتعديلها في كل مرة نرغب فيها بإجراء تعديل أو بناء جديد أمرًا مملًا، وقد يُعرّض التطبيق لأخطاء. يمكن للملفات المصدرية التنقل وقد تُبدّل المتغيرات أو الثوابت الملفات خلال عملية التطوير، مما يؤدي إلى كسر عملية البناء. إحدى الطرق المستخدمة لحل هذه المشكلة في لغة جو هي استخدام الراية ldflags- مع الأمر go build لإدراج معلومات ديناميكية في الملف الثنائي التنفيذي في وقت البناء دون الحاجة إلى تعديل التعليمات البرمجية المصدرية. تُشير Id ضمن الراية السابقة إلى الرابط linker، الذي يُمثّل البرنامج الذي يربط الأجزاء المختلفة من الشيفرة المصدرية المُصرّفة مع الملف التنفيذي النهائي. إذًا، تعني ldflags رايات الرابط linker flags؛ لأنها تمرر إشارة إلى الأداة cmd/link الخاصة بلغة جو، والتي تسمح لك بتغيير قيم الحزم المستوردة في وقت البناء من سطر الأوامر. سنستخدم في هذه المقالة الراية ldflags- لتغيير قيمة المتغيرات في وقت البناء وإدخال المعلومات الديناميكية ضمن ملف ثنائي تنفيذي، من خلال تطبيق يطبع معلومات الإصدار على الشاشة. المتطلبات أن يكون لديك مساحة عمل خاصة في لغة جو. لقد تحدّثنا عن ذلك في بداية السلسلة في المقال تثبيت لغة جو Go وإعداد بيئة برمجة محلية على أبونتو Ubuntu، وتثبيت لغة جو وإعداد بيئة برمجة محلية على نظام ماك macOS، وتثبيت لغة جو وإعداد بيئة برمجة محلية على ويندوز. بناء تطبيق تجريبي يجب أن يكون لدينا تطبيق حتى نستطيع تجريب عملية إدراج المعلومات إليه دينامكيًا من خلال الراية ldflags-. سنبني في هذه الخطوة تطبيقًا بسيطًا للتجريب عليه، بحيث يطبع المعلومات الخاصة بالإصدار. بدايةً أنشئ مجلدًا باسم app -يُمثّل اسم التطبيق- داخل المجلد src: $ mkdir app انتقل إلى هذا المجلد: $ cd app أنشئ باستخدام محرر النصوص الذي تُفضّله وليكن نانو nano الملف main.go: $ nano main.go ضع الشيفرة التالية بداخل هذا الملف، والتي تؤدي إلى طباعة معلومات الإصدار الحالي من التطبيق: package main import ( "fmt" ) var Version = "development" func main() { fmt.Println("Version:\t", Version) } صرّحنا داخل الدالة main عن متغير يُدعى Version، ثم طبعنا السلسلة النصية :Version متبوعةً بإزاحة جدول واحدة tab كما يلي: t\، ثم قيمة المتغير Version. هنا أعطينا متغير الإصدار القيمة development، والتي ستكون إشارةً إلى الإصدار الافتراضي من التطبيق. سنُعدّل لاحقًا قيمة هذا المتغير، بحيث تُشير إلى الإصدار الرسمي من التطبيق، ووفقًا للتنسيق المُتبع في تسمية الإصدارات. احفظ واغلق الملف، ثم ابنِ الملف وشغّله للتأكد من أنه يعمل: $ go build $ ./app ستحصل على الخرج التالي: Version: development لديك الآن تطبيق يطبع معلومات الإصدار الافتراضي، ولكن ليس لديك حتى الآن طريقةً لتمرير معلومات الإصدار الحالي في وقت البناء. ستستخدم في الخطوة التالية الراية ldflags- لحل هذه المشكلة. استخدام ldflags مع go build تحدّثنا سابقًا أن رايات الربط تُستخدم لتمرير الرايات إلى أداة الربط الأساسية underlying الخاصة بلغة جو. يحدث ذلك وفقًا للصيغة التالية: $ go build -ldflags="-flag" هنا مرّرنا flag إلى الأداة الأساسية go tool link التي تعمل بمثابة جزء من الأمر go build. هنا وضعنا علامتي اقتباس حول القيمة التي نمررها إلى ldflags، وذلك لكي نمنع حدوث التباس لدى سطر الأوامر (لكي لا يفسرها بطريقة خاطئة أو يعدها عدة محارف كل منها لغرض مختلف). يمكنك تمرير العديد من رايات الروابط، وفي هذه المقالة سنحتاج إلى استخدام الراية X- لضبط معلومات متغير الإصدار في وقت الربط link time، وسيتبعها مسار المتغير (هنا اسم الحزمة متبوعة باسم المتغير) مع قيمته الجديدة. $ go build -ldflags="-X 'package_path.variable_name=new_value'" لاحظ أننا وضعنا الخيار X- ضمن علامتي اقتباس وبجانبها وضعنا اسم الحزمة متبوعةً بنقطة "." متبوعةً باسم المتغير والقيمة الجديدة وأحطناهم بعلامات اقتباس مفردة لكي يُفسرها سطر الأوامر على أنها كتلة واحدة. إذًا، سنستخدم الصيغة السابقة لاستبدال قيمة متغير الإصدار Version في تطبيقنا: $ go build -ldflags="-X 'main.Version=v1.0.0'" تمثّل main هنا مسار الحزمة للمتغير Version، لأنه يتواجد داخل الملف main.go. هنا Version هو المتغير المطلوب تعديله، والقيمة v1.0.0 هي القيمة الجديدة التي نريد ضبطه عليها. عندما نستخدم الراية ldflags، يجب أن تكون القيمة التي تريد تغييرها موجودة وأن يكون المتغير موجودًا ضمن مستوى الحزمة ومن نوع string. لا يُسمح بأن يكون المتغير ثابتًا const، أو أن تُضبط قيمته من خلال استدعاء دالة. يتوافق كل شيء هنا مع المتطلبات، لذا ستعمل الأمور كما هو متوقع؛ فالمتغير موجود ضمن الملف main.go والمتغير والقيمة v1.0.0 التي نريد ضبط المتغير عليها كلاهما من النوع string. شغّل التطبيق بعد بنائه: $./app ستحصل على الخرج التالي: Version: v1.0.0 إذًا، استطعنا من خلال الراية ldflags- تعديل قيمة متغير الإصدار من development إلى v1.0.0 في وقت البناء. يمكنك تضمين تفاصيل الإصدار ومعلومات الترخيص وغيرهم من المعلومات باستخدام ldflags- جنبًا إلى جنب مع الملف التنفيذي النهائي الذي ترغب بنشره وذلك فقط من خلال سطر الأوامر. في هذا المثال: كان المتغير موجود في مسار واضح، لكن في أمثلة أخرى قد يكون العثور على مسار المتغير أمرًا معقدًا. سنناقش في الخطوة التالية هذا الموضوع، وسنرى ماهي أفضل الطرق لتحديد مسارات المتغيرات الموجودة في حزم فرعية في الحزم ذات الهيكلية الأكثر تعقيدًا. تحديد مسار الحزمة للمتغيرات كنا قد وضعنا في المثال السابق متغير الإصدار Version ضمن المستوى الأعلى من الحزمة في الملف main.go، لذا كان أمر الوصول إليه بسيطًا. هذه حالة مثالية، لكن في الواقع هذا لايحدث دائمًا، فأحيانًا تكون المتغيرات ضمن حزمة فرعية أخرى. عمومًا، لا يُحبذ وضع هكذا متغيرات ضمن main، لأنه حزمة غير قابلة للاستيراد، ويُفضّل وضع هكذا متغيرات ضمن حزمة أخرى. لذا سنعدل بعض الأمور في تطبيقنا، إذ سنُنشئ الحزمة app/build ونضع فيها معلومات حول وقت بناء الملف التنفيذي واسم المستخدم الذي بناه. انشئ مجلدًا جديدًا باسم الحزمة الجديدة: $ mkdir -p build انشئ ملفًا جديدًا باسم build.go من أجل وضع المتغير/المتغيرات ضمنه: $ nano build/build.go ضع بداخله المتغيرات التالية بعد فتحه باستخدام محرر النصوص الذي تريده: package build var Time string # سيُخزّن وقت بناء التطبيق var User string # سيُخزّن اسم المستخدم الذي بناه لا يمكن لهذين المتغيرين أن يتواجدا بدون قيم، لذا لا داعٍ لوضع قيم افتراضية لهما كما فعلنا مع متغير الإصدار. احفظ وأغلق الملف. افتح ملف main.go لوضع المتغيرات داخله: $ nano main.go ضع فيه المحتويات التالية: package main import ( "app/build" "fmt" ) var Version = "development" func main() { fmt.Println("Version:\t", Version) fmt.Println("build.Time:\t", build.Time) fmt.Println("build.User:\t", build.User) } استوردنا الحزمة app/build، ثم طبعنا قيمة build.Time و build.User بنفس الطريقة التي طبعنا فيها Version سابقًا. احفظ وأغلِق الملف. إذا أردت الآن الوصول إلى هذه المتغيرات عند استخدام الراية ldflags-، يمكنك استخدام اسم الحزمة app/build يتبعها Time. أو User. كوننا نعرف مسار الحزمة. سنستخدم الأمر nm بدلًا من ذلك من أجل محاكاة موقف أكثر تعقيدًا، والذي يكون فيه مسار الحزمة غير واضح. يُنتج الأمر go tool nm الرموز المتضمنة في ملف تنفيذي أو ملف كائن أو أرشيف، إذ يشير الرمز إلى كائن موجود في الشيفرة (متغير أو دالة مُعرّفة أو مستوردة). يمكنك العثور بسرعة على معلومات المسار من خلال إنشاء جدول رموز باستخدام nm واستخدام grep للبحث عن متغير. ملاحظة: لن يساعدك الأمر nm في العثور على مسار المتغير إذا كان اسم الحزمة يحتوي على أي محارف ليست ASCII، أو " أو ٪ (هذه قيود خاصة بالأداة). ابنِ التطبيق أولًا لاستخدام هذا الأمر: $ go build وجّه الأداة nm إلى التطبيق بعد بنائه وابحث في الخرج: $ go tool nm ./app | grep app عند تشغيل الأمر nm ستحصل على العديد من البيانات، لذا وضعنا | لتوجيه الخرج إلى الأمر grep الذي يبحث بعد ذلك عن المسارات التي تحتوي على الاسم app في المستوى الأعلى منها. ستحصل على الخرج التالي: 55d2c0 D app/build.Time 55d2d0 D app/build.User 4069a0 T runtime.appendIntStr 462580 T strconv.appendEscapedRune . . . يظهر في أول سطرين مسارات المتغيرات التي تبحث عنها: app/build.Time و app/build.User. ابنِ التطبيق الآن بعد أن تعرفت على المسارات، وعدّل متغير الإصدار إضافةً إلى المتغيرات الجديدة التي أضفناها والتي تُمثّل وقت بناء التطبيق واسم المستخدم (تذكر أنك تُعدّل هذه المتغيرات في وقت البناء). لأجل ذلك ستحتاج إلى تمرير عدة رايات X- إلى الراية ldflags-: $ go build -v -ldflags="-X 'main.Version=v1.0.0' -X 'app/build.User=$(id -u -n)' -X 'app/build.Time=$(date)'" هنا مررنا الأمر id -u -n لعرض المستخدم الحالي، والأمر date لعرض التاريخ الحالي. شغّل التطبيق بعد بنائه: $ ./app ستحصل على الخرج التالي في حال كنت تعمل على نظام يستند إلى يونيكس Unix: Version: v1.0.0 build.Time: Fri Oct 4 19:49:19 UTC 2019 build.User: sammy لديك الآن ملف تنفيذي يتضمن معلومات الإصدار والبناء، والتي يمكن أن توفر مساعدة حيوية في الإنتاج عند حل المشكلات. الخاتمة وضّحت هذه المقالة مدى قوة استخدام ldflags لإدخال معلومات في وقت البناء إذا طُبقت بطريقة سليمة. يمكنك بهذه الطريقة التحكم في رايات الميزة feature flags (هي تقنية برمجية تُمكّن الفريق البرمجي من إجراء تغييرات بدون استخدام المزيد من التعليمات البرمجية) ومعلومات البيئة ومعلومات الإصدار والأمور الأخرى دون إدخال تغييرات على الشيفرة المصدر. يمكنك الاستفادة من الخصائص التي تمنحك إياها لغة جو لعمليات النشر من خلال استخدام ldflags في عمليات البناء. ترجمة -وبتصرف- للمقال Using ldflags to Set Version Information for Go Applications لصاحبه Gopher Guides. اقرأ أيضًا المقال السابق: بناء تطبيقات لغة Go على أنظمة التشغيل والمعماريات المختلفة استخدام وسوم البناء لتخصيص الملفات التنفيذية Binaries في لغة جو Go بناء البرامج المكتوبة بلغة جو Go وتثبيتها
  14. عند تطوير البرمجيات من المهم الأخذ بالحسبان نوع نظام التشغيل الذي تبني التطبيق عليه والمعمارية التي ستُصرّف تطبيقك من أجلها إلى ملف ثنائي تنفيذي Binary، وتكون عملية تشغيل التطبيق على نظام تشغيل مختلف أو معمارية مختلفة غالبًا عمليةً بطيئة أو مستحيلة أحيانًا، لذا من الممارسات العمليّة الشائعة بناء ملف تنفيذي يعمل على العديد من المنصات المختلفة، وذلك لزيادة شعبية وعدد مستخدمي تطبيقك، إلا أن ذلك غالبًا ما يكون صعبًا عندما تختلف المنصة التي تطوّر تطبيقك عليها عن المنصة التي تريد نشره عليها؛ إذ كان يتطلب مثلًا تطوير برنامج على ويندوز ونشره على لينوكس Linux أو ماك أو إس MacOS سابقًا إعدادات بناء مُحددة من أجل كل بيئة تُريد تصريف البرنامج من أجلها، ويتطلب الأمر أيضًا الحفاظ على مزامنة الأدوات، إضافةً إلى الاعتبارات الأخرى التي قد تضيف تكلفةً وتجعل الاختبار التعاوني Collaborative Testing والنشر أكثر صعوبة. تحل لغة جو هذه المشكلة عن طريق بناء دعم لمنصّات متعددة مباشرةً من خلال الأداة go build وبقية أدوات اللغة. يمكنك باستخدام متغيرات البيئة ووسوم البناء التحكم في نظام التشغيل والمعمارية التي بُنى الثنائي النهائي من أجلها، إضافةً إلى وضع مُخطط لسير العمل يمكن من خلاله التبديل إلى التعليمات البرمجية المُضمّنة والمتوافقة مع المنصة المطلوب تشغيل التطبيق عليها دون تغيير الشيفرة البرمجية الأساسية. ستُنشئ في هذا المقال تطبيقًا يربط السلاسل النصية مع بعضها في مسار ملف، إضافةً إلى كتابة شيفرات برمجية يمكن تضمينها اختياريًّا، يعتمد كلٌ منها على منصة مُحددة. ستُنشئ ملفات تنفيذية لأنظمة تشغيل ومعماريات مختلفة على نظامك الخاص، وسيُبيّن لك ذلك كم أن لغة جو قوية في هذا الجانب. ملاحظة: في تكنولوجيا المعلومات، المنصة هي أي عتاد Hardware أو برمجية Software تُستخدم لاستضافة تطبيق أو خدمة. على سبيل المثال قد تتكون من عتاديات ونظام تشغيل وبرامج أخرى تستخدم مجموعة التعليمات الخاصة بالمعالج. المتطلبات تفترض هذه المقالة أنك على دراية بوسوم البناء في لغة جو، وإذا لم يكن لديك معرفةً بها، راجع مقالة استخدام وسوم البناء لتخصيص الملفات التنفيذية Binaries. أن يكون لديك مساحة عمل خاصة في لغة جو، وكنا قد تحدّثنا عن ذلك في بداية السلسلة في المقال تثبيت لغة جو Go وإعداد بيئة برمجة محلية على أبونتو Ubuntu، وتثبيت لغة جو وإعداد بيئة برمجة محلية على نظام ماك macOS، وتثبيت لغة جو وإعداد بيئة برمجة محلية على ويندوز. المنصات التي يمكن أن تبني لها تطبيقك باستخدام لغة جو قبل أن نعرض كيف يمكننا التحكم بعملية البناء من أجل بناء ملفات تنفيذية تتوافق مع منصات مختلفة، سنستعرض أولًا أنواع المنصات التي يمكن لجو البناء من أجلها، وكيف تشير جو إلى هذه المنصات باستخدام متغيرات البيئة GOOS و GOARCH. يمكن عرض قائمة بأسماء المنصات التي يمكن لجو أن تبني تطبيقًا من أجلها، وتختلف هذه القائمة من إصدار لآخر، لذا قد تكون القائمة التي سنعرضها عليك مختلفةً عن القائمة التي تظهر عندك وذلك تبعًا لإصدار جو الذي تعمل عليه (الإصدار الحالي 1.13). نفّذ الأمر التالي لعرض القائمة: $ go tool dist list ستحصل على الخرج التالي: aix/ppc64 freebsd/amd64 linux/mipsle openbsd/386 android/386 freebsd/arm linux/ppc64 openbsd/amd64 android/amd64 illumos/amd64 linux/ppc64le openbsd/arm android/arm js/wasm linux/s390x openbsd/arm64 android/arm64 linux/386 nacl/386 plan9/386 darwin/386 linux/amd64 nacl/amd64p32 plan9/amd64 darwin/amd64 linux/arm nacl/arm plan9/arm darwin/arm linux/arm64 netbsd/386 solaris/amd64 darwin/arm64 linux/mips netbsd/amd64 windows/386 dragonfly/amd64 linux/mips64 netbsd/arm windows/amd64 freebsd/386 linux/mips64le netbsd/arm64 windows/arm نلاحظ أن الخرج هو مجموعة من أزواج المفتاح والقيمة key-value مفصولة بالرمز "/"؛ إذ يمثّل المفتاح key (على اليسار) نظام التشغيل. تُعد هذه الأنظمة قيمًا محتملة لمتغير البيئة GOOS (يُنطق "goose")، اختصارًا إلى Go Operating System؛ بينما تشير القيمة value (على اليمين) إلى المعمارية، وتمثّل القيم المُحتملة لمتغير البيئة GOARCH (تُنطق "gore-ch") وهي اختصار Go Architecture. دعنا نأخذ مثال linux/386 لفهم ما تعنيه وكيف يجري الأمر: تُمثّل linux المفتاح وهي قيمة المتغير GOOS، بينما تمثّل 386 المعالج Intel 80386 والتي ستكون هي القيمة، وتُمثّل قيمة المتغير GOARCH. دورة تطوير التطبيقات باستخدام لغة Python احترف تطوير التطبيقات مع أكاديمية حسوب والتحق بسوق العمل فور انتهائك من الدورة اشترك الآن هناك العديد من المنصات التي يمكن أن تتعامل معها من خلال الأداة go build، لكن غالبًا سيكون تعاملك مع منصة لينكس أو ويندوز أو داروين darwin في قيم المتغير GOOS. إذًا، هذا يُغطي المنصات الثلاثة الأكبر: ويندوز ولينكس وماك، إذ يعتمد الأخير على نظام التشغيل داروين. يمكن لجو عمومًا تغطية المنصات الأقل شهرة مثل nacl. عند تشغيل أمر مثل go build، يستخدم جو مُتغيرات البيئة GOOS و GOARCH المرتبطين بالمنصة الحالية، لتحديد كيفية بناء الثنائي. لمعرفة تركيبة المفتاح-قيمة للمنصة التي تعمل عليها، يمكنك استخدام الأمر go env وتمرير GOOS و GOARCH مثل وسيطين: $ go env GOOS GOARCH يعمل الجهاز الذي نستخدمه بنظام ماك ومعمارية AMD64 لذا سيكون الخرج: darwin amd64 أي أن منصتنا لديها القيم التالية لمتغيرات البيئة GOOS=darwin و GOARCH=amd64. أنت الآن تعرف ما هي GOOS و GOARCH، إضافةً إلى قيمهما المحتملة. سنكتب الآن برنامجًا لاستخدامه مثالًا على كيفية استخدام متغيرات البيئة هذه ووسوم البناء، بهدف بناء ملفات تنفيذية لمنصات أخرى. بناء تطبيق يعتمد على المنصة سنبدأ أولًا ببناء برنامج بسيط، ومن الأمثلة الجيدة لهذا الغرض الدالة Join من الحزمة path/filepath من مكتبة جو القياسية، إذ تأخذ هذه الدالة عدة سلاسل وتُرجع سلسلةً مكونةً من تلك السلاسل بعد ربطها اعتمادًا على فاصل مسار الملف filepath. يُعد هذا المثال التوضيحي مناسبًا لأن تشغيل البرنامج يعتمد على نظام التشغيل الذي يعمل عليه، ففي نظام التشغيل ويندوز، يكون فاصل المسار \، بينما تستخدم الأنظمة المستندة إلى يونيكس Unix الفاصل /. سنبدأ ببناء تطبيق يستخدم ()filepath.Join، وسنكتب لاحقًا تنفيذًا خاصًا لهذه الدالة يُخصص الشيفرة للثنائيات التي تتبع لمنصة محددة. أنشئ مجلدًا داخل المجلد src باسم تطبيقك: $ mkdir app انتقل إلى المجلد: $ cd app أنشئ ملفًا باسم main.go من خلال محرر النصوص نانو nano أو أي محرر آخر: $ nano main.go ضع في الملف الشيفرة التالية: package main import ( "fmt" "path/filepath" ) func main() { s := filepath.Join("a", "b", "c") fmt.Println(s) } تستخدم الدالة الرئيسية ()main هنا الدالة ()filepath.Join لربط ثلاث سلاسل مع فاصل المسار الصحيح المعتمد على المنصة. احفظ الملف واخرج منه، ثم نفّذه من خلال الأمر التالي: $ go run main.go عند تشغيل هذا البرنامج، ستتلقى مخرجات مختلفة بناءً على المنصة التي تستخدمها، ففي نظام التشغيل ويندوز، سترى السلاسل مفصولة بالفاصل \: a\b\c أما في أنظمة يونِكس مثل ماك ولِنُكس: a/b/c يوضح هذا أن اختلاف بروتوكولات نظام الملفات المستخدمة في أنظمة التشغيل هذه، يقتضي على البرنامج بناء شيفرات مختلفة للمنصات المختلفة. نحن نعلم أن الاختلاف هنا سيكون بفاصل الملفات كما تحدثنا، وبما أننا نستخدم الدالة ()filepath.Join فلا خوف من ذلك، لأنها ستأخذ بالحسبان اختلاف نظام التشغيل الذي تُستخدم ضمنه. تفحص سلسلة أدوات جو تلقائيًا GOOS و GOARCH في جهازك وتستخدم هذه المعلومات لاستخدام الشيفرة المناسبة مع وسوم البناء الصحيحة وفاصل الملفات المناسب. سنرى الآن من أين تحصل الدالة ()filepath.Join على الفاصل المناسب. شغّل الأمر التالي لفحص المقتطف ذي الصلة من مكتبة جو القياسية: $ less /usr/local/go/src/os/path_unix.go سيُظهر هذا الأمر محتويات الملف "path_unix.go". ألقِ نظرةً على السطر الأول منه، والذي يُمثّل وسوم البناء. . . . // +build aix darwin dragonfly freebsd js,wasm linux nacl netbsd openbsd solaris package os const ( PathSeparator = '/' // OS-specific path separator PathListSeparator = ':' // OS-specific path list separator ) . . . تعرّف الشيفرة الفاصل PathSeparator المستخدم مع الأنواع المختلفة من الأنظمة التي تستند على يُنِكس والتي يدعمها جو. لاحظ في السطر الأول وسوم البناء التي تمثل كل واحدة منها قيمةً محتملةً للمتغير GOOS وهي جميعها تمثل أنظمة تستند إلى ينكس. يأخذ GOOS أحد هذه القيم ويُنتج الفاصل المناسب تبعًا لنوع النظام. اضغط q للعودة لسطر الأوامر. افتح الآن ملف path_windows الذي يُعبر عن سلوك الدالة ()filepath.Join على نظام ويندوز: . . . package os const ( PathSeparator = '\\' // OS-specific path separator PathListSeparator = ';' // OS-specific path list separator ) . . . على الرغم من أن قيمة PathSeparator هنا هي \\، إلا أن الشيفرة ستعرض الشرطة المائلة الخلفية المفردة \ اللازمة لمسارات ملفات ويندوز، إذ تُستخدم الشرطة المائلة الأولى بمثابة مفتاح هروب Escape character. لاحظ أنه في الملف الخاص بينكس كان لدينا وسوم بناء، أما في ملف ويندوز فلا يوجد وسوم بناء، وذلك لأن GOOS و GOARCH يمكن تمريرهما أيضًا إلى go build عن طريق إضافة شرطة سفلية "_" وقيمة متغير البيئة مثل لاحقة suffix لاسم الملف (سنتحدث عن ذلك أكثر بعد قليل). يجعل الجزء windows_ من path_windows.go الملف يعمل كما لو كان يحتوي على وسم البناء build windows+ // في أعلى الملف، لذلك عند تشغيل البرنامج على ويندوز، سيستخدم الثوابت PathSeparator و PathListSeparator من الشيفرة الموجودة في الملف "path_windows.go". اضغط q للعودة لسطر الأوامر. أنشأت في هذه الخطوة برنامجًا يوضّح كيف يمكن لجو أن يجري التبديل تلقائيًا بين الشيفرات من خلال متغيرات البيئة GOOS و GOARCH ووسوم البناء. يمكنك الآن تحديث برنامجك وكتابة تنفيذك الخاص للدالة ()filepath.Join، والاعتماد على وسوم البناء لتحديد الفاصل PathSeparator المناسب لمنصات ويندوز ويونيكس يدويًا. تنفيذ دالة خاصة بالمنصة بعد أن تعرّفت على كيفية تحقيق مكتبة جو القياسية للتعليمات البرمجية الخاصة بالمنصة، يمكنك استخدام وسوم البناء لأجل ذلك في تطبيقك. ستكتب الآن تعريفًا خاصًا للدالة ()filepath.Join. افتح ملف main.go الخاص بتطبيقك: $ nano main.go استبدل محتويات main.go وضع فيه الشيفرة التالية التي تتضمن دالة خاصة اسميناها Join: package main import ( "fmt" "strings" ) func Join(parts ...string) string { return strings.Join(parts, PathSeparator) } func main() { s := Join("a", "b", "c") fmt.Println(s) } تأخذ الدالة Join عدة سلاسل نصية من خلال المعامل parts وتربطهما معًا باستخدام الفاصل PathSeparator، وذلك من خلال الدالة ()strings.Join من حزمة strings. لم نُعرّف PathSeparator بعد، لذا سنُعرّفه الآن في ملف آخر. احفظ main.go واخرج منه، وافتح المحرر المفضل لديك، وأنشئ ملفًا جديدًا باسم path.go: nano path.go صرّح عن الثابت PathSeparator وأسند له فاصل المسارات الخاص بملفات يونيكس "/": package main const PathSeparator = "/" صرّف التطبيق وشغّله: $ go build $ ./app ستحصل على الخرج التالي: a/b/c هذا جيد بالنسبة لأنظمة ينكس، لكنه ليس ما نريده تمامًا، فهو يُعطي دومًا a/b/c بغض النظر عن المنصة، وهذا لا يتناسب مع ويندوز مثلًا. إذًا، نحن بحاجة إلى نسخة خاصة من PathSeparator لنظام ويندوز، وإخبار go build أي من هذه النسخ يجب استخدامها وفقًا للمنصة المطلوبة. هنا يأتي دور وسوم البناء. استخدام وسوم البناء مع متغيرات البيئة لكي نعالج حالة كون النظام هو ويندوز، سنُنشئ الآن ملفًا بديلًا للملف path.go وسنضيف وسوم بناء Build Tags مهمتها المطابقة مع المتغيرات GOOS و GOARCH، وذلك لضمان أن الشيفرة المستخدمة هي الشيفرة التي تعمل على المنصة المحددة. أضف بدايةً وسم بناء إلى الملف "path.go" لإخباره أن يبني لكل شيء باستثناء ويندوز، افتح الملف: $ nano path.go أضِف وسم البناء التالي إلى الملف: // +build !windows package main const PathSeparator = "/" تُقدّم وسوم البناء في لغة جو إمكانية "العكس inverting" مما يعني أنه يمكنك توجيه جو لبناء هذا الملف من أجل أي منصة باستثناء ويندوز. لعكس وسم بناء، ضع "!" قبل الوسم كما فعلنا أعلاه. احفظ واخرج من الملف. والآن إذا حاولت تشغيل هذا البرنامج على ويندوز، ستتلقى الخطأ التالي: ./main.go:9:29: undefined: PathSeparator في هذه الحالة لن تكون جو قادرةً على تضمين path.go لتعريف فاصل المسار PathSeparator. الآن بعد أن تأكدت من أن path.go لن يعمل عندما يكون GOOS هو ويندوز. أنشئ ملفًا جديدًا windows.go: $ nano windows.go أضِف ضمن هذا الملف PathSeparator ووسم بناء أيضًا لإخبار الأمر go build أن هذا الملف هو التحقيق المقابل للويندوز: // +build windows package main const PathSeparator = "\\" احفظ الملف واخرج من محرر النصوص. يمكن للتطبيق الآن تصريف نسخة لنظام ويندوز ونسخة أخرى لباقي الأنظمة. ستُبنى الآن ملفات تنفيذية بطريقة صحيحة وفقًا للمنصة المطلوبة، إلا أنه هناك المزيد من التغييرات التي يجب عليك إجراؤها من أجل التصريف على المنصة التي لا يمكنك الوصول إليها. سنُعدّل في الخطوة التالية متغيرات البيئة المحلية GOOS و GOARCH. استخدام متغيرات البيئة المحلية GOOS و GOARCH استخدمنا سابقًا الأمر go env GOOS GOARCH لمعرفة النظام والمعمارية التي تعمل عليها. عند استخدام الأمر go env، سيبحث عن المتغيرين GOOS و GOARCH، فإذا وجدهما سيستخدم قيمهما وإلا سيفترض أن قيمهما هي معلومات المنصة الحالية (نظام ومعمارية المنصة). نستنتج مما سبق أنه بإمكاننا تحديد نظام ومعمارية لمتغيرات البيئة غير نظام ومعمارية المنصة الحالية. يعمل الأمر go build بطريقة مشابهة للأمر السابق، إذ يمكنك تحديد قيم لمتغيرات البيئة GOOS و GOARCH مختلفة عن المنصة الحالية. إذا كنت لا تستخدم نظام ويندوز، ابنِ نسخةً ثنائيةً من تطبيقك لنظام ويندوز عن طريق تعيين قيمة متغير البيئة GOOS على windows عند تشغيل الأمر go build: $ GOOS=windows go build اسرد الآن محتويات مجلدك الحالي: $ ls ستجد في الخرج ملفًا باسم app.exe، إذ يكون امتداده exe والذي يشير إلى ملف ثنائي تنفيذي في نظام ويندوز. app app.exe main.go path.go windows.go يمكنك باستخدام الأمر file الحصول على مزيد من المعلومات حول هذا الملف، للتأكد من بنائه: $ file app.exe ستحصل على: app.exe: PE32+ executable (console) x86-64 (stripped to external PDB), for MS Windows يمكنك أيضًا إعداد واحد أو اثنين من متغيرات البيئة أثناء وقت البناء. نفّذ الأمر التالي: $ GOOS=linux GOARCH=ppc64 go build بذلك يكون قد استُبدل ملفك التنفيذي app بملف لمعمارية مختلفة. شغّل الأمر file على تطبيقك الثنائي: $ file app ستحصل على الخرج التالي: app: ELF 64-bit MSB executable, 64-bit PowerPC or cisco 7500, version 1 (SYSV), statically linked, not stripped من خلال متغيرات البيئة GOOS و GOARCH يمكنك الآن إنشاء تطبيق قابل للتنفيذ على منصات مختلفة، دون الحاجة إلى إعدادات ضبط مُعقدة. سنستخدم في الخطوة التالية بعض "الاصطلاحات" لأسماء الملفات لكي نجعل تنسيقها دقيقًا، إضافةً إلى البناء تلقائيًّا من دون الحاجة إلى وسوم البناء. استخدام لواحق اسم الملف مثل دليل إلى المنصة المطلوبة كما لاحظت سابقًا، تعتمد جو على استخدام وسوم البناء كثيرًا لفصل الإصدارات أو النسخ المختلفة من التطبيق إلى ملفات مختلفة بغية تبسيط التعليمات البرمجية، فعندما فتحت ملف "os/path_unix.go" كان هناك وسم بناء يعرض جميع التركيبات المحتملة التي تُعبر عن منصات شبيهة أو تستند إلى ينكس، إلا أن ملف "os/path_windows.go" لا يحتوي على وسوم بناء، لأن لاحقة اسم الملف كانت كافية لإخبار جو بالمنصة المطلوبة. دعونا نلقي نظرةً على تركيب أو قاعدة هذه الميزة، فعند تسمية ملف امتداده "go."، يمكنك إضافة GOOS و GOARCH مثل لواحق إلى اسم الملف بالترتيب، مع فصل القيم عن طريق الشرطات السفلية "_". إذا كان لديك ملف جو يُسمى filename.go، يمكنك تحديد نظام التشغيل والمعمارية عن طريق تغيير اسم الملف إلى filename_GOOSGO_ARCH.go. على سبيل المثال، إذا كنت ترغب في تصريفه لنظام التشغيل ويندوز باستخدام معمارية ‎64-bit ARM، فيمكنك كتابة اسم الملف filename_windows_arm64.go، إذ يساعد اصطلاح التسمية هذا في الحفاظ على الشيفرة منظمةً بدقة. حدّث البرنامج الآن بحيث نستخدم لاحقات اسم الملف بدلًا من وسوم البناء، وأعد تسمية ملف "path.go" و "windows.go" لاستخدام الاصطلاح المستخدم في حزمةos: $ mv path.go path_unix.go $ mv windows.go path_windows.go بعد تعديل اسم الملف أصبح بإمكانك حذف وسم البناء من ملف "path_windows.go": $ nano path_windows.go بعد حذف build windows+ // سيكون ملفك كما يلي: package main const PathSeparator = "\\" احفظ الملف واخرج منه. بما أن unix هي قيمة غير صالحة للمتغير GOOS، فإن اللاحقة unix.go_ ليس لها أي معنى لمُصرّف جو، وبالرغم من ذلك، فإنه ينقل الغاية المرجوة من الملف. لا يزال مثلًا ملف os/path_unix.go بحاجة إلى استخدام وسوم البناء، لذا يجب الاحتفاظ بهذا الملف دون تغيير. نستنتج أنه من خلال الاصطلاحات استطعنا التخلص من وسوم البناء غير الضرورية التي أضفناها إلى شيفرات التطبيق، كما جعلنا نظام الملفات أكثر تنظيمًا ووضوحًا. الخاتمة لا تحتاج لغة جو إلى أدوات إضافية لدعم فكرة المنصات المتعددة، وهذه ميزة قوية في جو. تعلمنا في هذه المقالة استخدام هذه الإمكانية عن طريق إضافة وسوم البناء واللواحق لاسم الملف، وذلك لتحديد الشيفرات البرمجية التي يجب تنفيذها وفقًا للمنصة المطلوب العمل عليه. أنشأنا تطبيقًا متعدد المنصات وتعلمنا كيفية التعامل مع متغيرات البيئة GOOS و GOARCH لبناء ملفات تنفيذية لمنصات أخرى تختلف عن المنصة الحالية. تعدد المنصات أمر مهم جدًا، فهو يُضيف ميزةً مهمة لتطبيقك، إذ تُمكنه من التصريف وفقًا للمنصة المطلوبة من خلال متغيرات البيئة هذه. ترجمة -وبتصرف- للمقال Building Go Applications for Different Operating Systems and Architectures لصاحبه Gopher Guides. اقرأ أيضًا المقال السابق: كيفية استخدام الواجهات Interfaces في لغة جو Go بناء البرامج المكتوبة بلغة جو Go وتثبيتها ما هو نظام التشغيل لينكس؟
  15. أهم الصفات التي يجب أن تتمتع بها البرامج أو التعليمات البرمجية التي نكتبها، هي المرونة وإمكانية إعادة الاستخدام، إضافةً إلى الصفة التركيبية modular، إذ تُعد هذه الصفات الثلاثة أمرًا ضروريًّا لتطوير برامج متعددة الاستخدامات، كما أنها تُسهّل عمليات التعديل والصيانة على البرامج، فمثلًا إذا احتجنا لتعديل بسيط على البرنامج، سيكون بالإمكان إجراء هذا التعديل في مكانٍ واحد بدلًا من إجراء نفس التعديل في أماكن متعددة من البرنامج. تختلف كيفية تحقيق تلك الصفات من لغة إلى أخرى، فعلى سبيل المثال، يٌستخدم مفهوم الوراثة في لغات مثل جافا Java و ++C و #C لتحقيق ذلك. يمكن للمطورين أيضًا تحقيق أهداف التصميم هذه من خلال التركيب Composition، وهي طريقة لدمج الكائنات أو أنواع البيانات في أنواع أكثر تعقيدًا، وهو النهج المُستخدم في لغة جو لتحقيق الصفات السابقة. توفِّر الواجهات في لغة جو توابعًا لتنظيم التراكيب المعقدة، وسيتيح لك تعلم كيفية استخدامها إنشاء شيفرة مشتركة وقابلة لإعادة الاستخدام. ستتعلم في هذا المقال كيفية تركيب أنواع مخصصة لها سلوكيات مشتركة، مما سيتيح لنا إعادة استخدام الشيفرة التي نكتبها، وسنتعلم أيضًا كيفية تحقيق الواجهات للأنواع المخصصة التي تتوافق مع الواجهات المُعرّفة في حزم أخرى. تعريف سلوك Behavior تُعد الواجهات من العناصر الأساسية للتركيب، فهي تعرّف سلوك نوعٍ ما، وتُعد الواجهة fmt.Stringer من أكثر الواجهات شيوعًا في مكتبة جو القياسية: type Stringer interface { String() string } نُعرّف في السطر الأول من الشيفرة السابقة نوعًا جديدًا يُدعى Stringer، ونحدد أنه واجهة. بعد ذلك، نكتب محتويات هذه البنية بين قوسين {}، إذ ستُعرِّف هذه المحتويات سلوك الواجهة، أي ما الذي تفعله الواجهة؛ فبالنسبة للواجهة السابقة Stringer، من الواضح أن السلوك الوحيد فيها هو تابع ()String لا يأخذ أي وسطاء ويعيد سلسلةً نصية. سنرى الآن بعض المقتطفات البرمجية التي تمتلك سلوك الواجهة fmt.Stringer: package main import "fmt" type Article struct { Title string Author string } func (a Article) String() string { return fmt.Sprintf("The %q article was written by %s.", a.Title, a.Author) } func main() { a := Article{ Title: "Understanding Interfaces in Go", Author: "Sammy Shark", } fmt.Println(a.String()) } هنا نُنشئ نوعًا جديدًا اسمه Article، ويمتلك حقلين هما Title و Author، وكلاهما من نوع سلاسل نصية. ... type Article struct { Title string Author string } … نُعرّف بعد ذلك تابعًا يسمى String على النوع Article، بحيث يعيد هذا التابع سلسلةً تمثل هذا النوع، أي محتوياته عمليًّا: ... func (a Article) String() string { return fmt.Sprintf("The %q article was written by %s.", a.Title, a.Author) } ... نُعرّف بعد ذلك في الدالة main متغيرًا من النوع Article ونسميه a، ونسند السلسلة "Understanding Interfaces in Go" إلى الحقل Title والسلسلة "Sammy Shark" للحقل Author: ... a := Article{ Title: "Understanding Interfaces in Go", Author: "Sammy Shark", } ... نطبع بعد ذلك نتيجة التابع String من خلال استدعاء الدالة fmt.Println وتمرير نتيجة استدعاء التابع ()a.String إليها: ... fmt.Println(a.String()) ستحصل عند تشغيل البرنامج على: The "Understanding Interfaces in Go" article was written by Sammy Shark. لم نستخدم واجهةً حتى الآن، لكننا أنشأنا نوعًا يمتلك سلوكًا يطابق سلوك الواجهة fmt.Stringer. دعنا نرى كيف يمكننا استخدام هذا السلوك لجعل الشيفرة الخاصة بنا أكثر قابلية لإعادة الاستخدام. تعريف واجهة Interface بعد أن عرّفنا نوعًا جديدًا مع سلوك مُحدد، سنرى كيف يمكننا استخدام هذا السلوك، لكن قبل ذلك سنلقي نظرةً على ما سنحتاج إلى فعله إذا أردنا استدعاء التابع String من نوع Article داخل دالة: package main import "fmt" type Article struct { Title string Author string } func (a Article) String() string { return fmt.Sprintf("The %q article was written by %s.", a.Title, a.Author) } func main() { a := Article{ Title: "Understanding Interfaces in Go", Author: "Sammy Shark", } Print(a) } func Print(a Article) { fmt.Println(a.String()) } أضفنا في هذه الشيفرة دالةً جديدةً تسمى Print، تأخذ وسيطًا من النوع Article. لاحظ أن كل ما تفعله هذه الدالة هو أنها تستدعي التابع String، لذا يمكننا بدلًا من ذلك تعريف واجهة للتمرير إلى الدالة: package main import "fmt" type Article struct { Title string Author string } func (a Article) String() string { return fmt.Sprintf("The %q article was written by %s.", a.Title, a.Author) } type Stringer interface { String() string } func main() { a := Article{ Title: "Understanding Interfaces in Go", Author: "Sammy Shark", } Print(a) } func Print(s Stringer) { fmt.Println(s.String()) } ننشئ هنا واجهةً اسمها Stringer: ... type Stringer interface { String() string } ... تتضمّن هذه الواجهة تابعًا وحيدًا يسمى ()String يعيد سلسلةً، ويُعرّف هذا التابع على نوع بيانات مُحدد، وعلى عكس الدوال فلا يمكن له أن يُستدعى إلا من متغير من هذا النوع. نعدّل بعد ذلك بصمة الدالة Print بحيث تستقبل وسيطًا من النوع Stringer الذي يُمثّل واجهةً، وليس نوعًا مُحددًا مثل Article. بما أن المصرّف يعرف أن Stringer هي واجهة تمتلك التابع String، فلن يقبل إلا الأنواع التي تُحقق هذا التابع. يمكننا الآن استخدام الدالة Print مع أي نوع يتوافق مع الواجهة Stringer. دعنا ننشئ نوعًا آخر لتوضيح ذلك: package main import "fmt" type Article struct { Title string Author string } func (a Article) String() string { return fmt.Sprintf("The %q article was written by %s.", a.Title, a.Author) } type Book struct { Title string Author string Pages int } func (b Book) String() string { return fmt.Sprintf("The %q book was written by %s.", b.Title, b.Author) } type Stringer interface { String() string } func main() { a := Article{ Title: "Understanding Interfaces in Go", Author: "Sammy Shark", } Print(a) b := Book{ Title: "All About Go", Author: "Jenny Dolphin", Pages: 25, } Print(b) } func Print(s Stringer) { fmt.Println(s.String()) } عرّفنا هنا نوع بيانات جديد يُسمى Book يمتلك التابع String، وبالتالي يُحقق الواجهة Stringer، وبالتالي يمكننا استخدامه مثل وسيط للدالة Print. The "Understanding Interfaces in Go" article was written by Sammy Shark. The "All About Go" book was written by Jenny Dolphin. It has 25 pages. أوضحنا إلى الآن كيفية استخدام واجهة واحدة فقط، ويمكن عمومًا أن يكون للواجهة أكثر من سلوك معرّف. سنرى فيما يلي كيف يمكننا جعل واجهاتنا أكثر تنوعًا من خلال التصريح عن المزيد من التوابع. تعدد السلوكيات في الواجهة تُعد كتابة أنواع صغيرة وموجزة وتركيبها في أنواع أكبر وأكثر تعقيدًا من الأمور الجيدة عند كتابة الشيفرات في لغة جو، وينطبق الشيء نفسه عند إنشاء واجهات. لمعرفة كيفية إنشاء الواجهة، سنبدأ أولًا بتعريف واجهة واحدة فقط. سنحدد شكلين؛ دائرة Circle ومربع Square، وسيُعرّف كلاهما تابعًا يُسمى المساحة Area يعيد المساحة الهندسية للشكل: package main import ( "fmt" "math" ) type Circle struct { Radius float64 } func (c Circle) Area() float64 { return math.Pi * math.Pow(c.Radius, 2) } type Square struct { Width float64 Height float64 } func (s Square) Area() float64 { return s.Width * s.Height } type Sizer interface { Area() float64 } func main() { c := Circle{Radius: 10} s := Square{Height: 10, Width: 5} l := Less(c, s) fmt.Printf("%+v is the smallest\n", l) } func Less(s1, s2 Sizer) Sizer { if s1.Area() < s2.Area() { return s1 } return s2 } بما أن النوعين يُصرحان عن تابع Area، يمكننا إنشاء واجهة تحدد هذا السلوك. نُنشئ واجهة Sizer التالية: ... type Sizer interface { Area() float64 } … نعرّف بعد ذلك دالةً تسمى Less تأخذ واجهتين Sizer مثل وسيطين وتعيد أصغر واحدة: ... func Less(s1, s2 Sizer) Sizer { if s1.Area() < s2.Area() { return s1 } return s2 } ... لاحظ أن معاملات الدالة وكذلك القيمة المُعادة هي من النوع Sizer، وهذا يعني أننا لا نعيد مربعًا أو دائرة، بل نعيد واجهة Sizer. والآن نطبع الوسيط الذي لديه أصغر مساحة: {Width:5 Height:10} is the smallest سنضيف الآن سلوكًا آخر لكل نوع، وسنضيف التابع ()String الذي يعيد سلسلةً نصيةً، وهذا بدوره سيؤدي إلى تحقيق الواجهة fmt.Stringer: package main import ( "fmt" "math" ) type Circle struct { Radius float64 } func (c Circle) Area() float64 { return math.Pi * math.Pow(c.Radius, 2) } func (c Circle) String() string { return fmt.Sprintf("Circle {Radius: %.2f}", c.Radius) } type Square struct { Width float64 Height float64 } func (s Square) Area() float64 { return s.Width * s.Height } func (s Square) String() string { return fmt.Sprintf("Square {Width: %.2f, Height: %.2f}", s.Width, s.Height) } type Sizer interface { Area() float64 } type Shaper interface { Sizer fmt.Stringer } func main() { c := Circle{Radius: 10} PrintArea(c) s := Square{Height: 10, Width: 5} PrintArea(s) l := Less(c, s) fmt.Printf("%v is the smallest\n", l) } func Less(s1, s2 Sizer) Sizer { if s1.Area() < s2.Area() { return s1 } return s2 } func PrintArea(s Shaper) { fmt.Printf("area of %s is %.2f\n", s.String(), s.Area()) } بما أن النوعين Circle و Square ينفذّان التابعين Area و String، سيكون بالإمكان إنشاء واجهة أخرى لوصف تلك المجموعة الأوسع من السلوك. لأجل ذلك سننشئ واجهةً تسمى Shaper من واجهة Sizer وواجهة fmt.Stringer: ... type Shaper interface { Sizer fmt.Stringer } ... ملاحظة: حبذا أن ينتهي اسم الواجهة بالحرفين er مثل fmt.Stringer و io.Writer، إلخ. ولهذا السبب أطلقنا على واجهتنا اسم Shaper، وليس Shape. يمكننا الآن إنشاء دالة تسمى PrintArea تأخذ وسيطًا من النوع Shaper. هذا يعني أنه يمكننا استدعاء التابعين على القيمة التي تُمرّر لكل من Area و String: ... func PrintArea(s Shaper) { fmt.Printf("area of %s is %.2f\n", s.String(), s.Area()) } ستحصل عند تشغيل البرنامج على الخرج التالي: area of Circle {Radius: 10.00} is 314.16 area of Square {Width: 5.00, Height: 10.00} is 50.00 Square {Width: 5.00, Height: 10.00} is the smallest رأينا كيف يمكننا إنشاء واجهات أصغر وبناء واجهات أكبر حسب الحاجة. كان بإمكاننا البدء بالواجهة الأكبر وتمريرها إلى جميع الدوال، إلا أن الممارسات الجيدة تقتضي إرسال أصغر واجهة فقط إلى الدالة المطلوبة، إذ أن ذلك ضروري لجعل الشيفرة واضحة أكثر، فإذا كانت الدالة تهدف لإجراء سلوك محدد، وهذا السلوك مُعرّف في واجهة أصغر، فحبذا أن تُمرر الواجهة الأصغر وليس الواجهة الأكبر (التي تحتوي الواجهة الأصغر). إذا مررنا مثلًا الواجهة Shaper إلى الدالة Less، فهنا نفترض أنها ستستدعي كلًا من التابعين Area و String، لكنها لا تستدعي إلا التابع Area، وهذا سيجعل الدالة أقل وضوحًا، كما أننا نعلم أنه يمكننا فقط استدعاء التابع Area لأي وسيط يُمرّر إليه. خاتمة تعلمنا في هذا المقال كيفية إنشاء واجهات صغيرة وكيفية توسيعها لتصبح واجهات أكبر، وكيفية مشاركة الأشياء التي نريدها فقط مع دالة أو تابع من خلال تلك الواجهات. تعلمنا أيضًا كيفية تركيب واجهة من واجهات أصغر أو من واجهات موجودة في حزم أخرى وليس فقط الحزمة التي نعمل ضمنها. ترجمة -وبتصرف- للمقال How To Use Interfaces in Go لصاحبه Gopher Guides. اقرأ أيضًا المقال السابق: استخدام وسوم البنية Struct Tags في لغة جو Go تعريف التوابع Methods في لغة جو Go البنى Structs في لغة جو Go
  16. تُستخدم البنى structs لجمع أجزاء متعددة من المعلومات معًا ضمن كتلة واحدة، وتُستخدم مجموعات المعلومات هذه لوصف المفاهيم ذات المستوى الأعلى، مثل العنوان المكوَّن من شارع ومدينة وولاية ورمز بريدي. عندما تقرأ هذه المعلومات من أنظمة، مثل قواعد البيانات، أو واجهات برمجة التطبيقات API، يمكنك استخدام وسوم البنية للتحكم في كيفية تخصيص هذه المعلومات لحقول البنية. وسوم البنية هي أجزاء صغيرة من البيانات الوصفية المرفقة بحقول البنية التي توفر إرشادات إلى شيفرة جو أُخرى تعمل مع البنية. كيف يبدو شكل وسم البنية؟ وسم البنية في لغة جو هو "توضيح" يُكتب بعد نوع الحقل داخل البنية، ويتكون كل وسم من زوج "key:"value أي مفتاح مرتبط بقيمة مقابلة ويوضع ضمن علامتين اقتباس مائلة (`) كما يلي: type User struct { Name string `example:"name"` } من خلال هذا التعريف، ستكون هناك شيفرة جو أخرى قادرة على فحص هذه البنية واستخراج القيم المخصصة لمفاتيح معينة، وبدون هذه الشيفرة الأخرى لن تؤثر وسوم البنية على تعليماتك البرمجية. جرّب هذا المثال لترى كيف يبدو وسم البنية، وكيف سيكون عديم التأثير دون الشيفرة الأخرى. package main import "fmt" type User struct { Name string `example:"name"` } func (u *User) String() string { return fmt.Sprintf("Hi! My name is %s", u.Name) } func main() { u := &User{ Name: "Sammy", } fmt.Println(u) } سيكون الخرج على النحو التالي: Hi! My name is Sammy يعرّف هذا المثال نوع بيانات باسم User يمتلك حقلًا اسمه Name، وأعطينا هذا الحقل وسم بنية "example:"name مُتمثّل بالمفتاح example والقيمة "name" للحقل Name. عرّفنا التابع ()String في البنية User، والذي تحتاجه الواجهة fmt.Stringer، وبالتالي سيُستدعى تلقائيًا عندما نُمرّر هذا النوع إلى fmt.Println وبالتالي نحصل على طباعة مُرتبة للبنية. نأخذ في الدالة main متغيرًا من النوع User ونمرّره إلى دالة الطباعة fmt.Println. على الرغم من أنه لدينا وسم بنية، إلا أننا لن نلحظ أي فرق في الخرج، أي كما لو أنها غير موجودة. لكي نحصل على تأثير وسم البنية، يجب كتابة شيفرة أخرى تفحص البنية في وقت التشغيل runtime. تحتوي المكتبة القياسية على حزم تستخدم وسوم البنية كأنها جزء من عملها، وأكثرها شيوعًا هي حزمة encoding/json. دورة تطوير واجهات المستخدم ابدأ عملك الحر بتطوير واجهات المواقع والمتاجر الإلكترونية فور انتهائك من الدورة اشترك الآن ترميز JSON جسون JSON هي اختصار إلى "ترميز كائن باستخدام جافا سكريبت JavaScript Object Notation"، وهي تنسيق نصي لترميز مجموعات البيانات المنظمة وفق أسماء مفاتيح مختلفة. يُشاع استخدام جسون لربط البيانات بين البرامج المختلفة، إذ أن التنسيق بسيط ويوجد مكتبات جاهزة لفك ترميزه في العديد من اللغات البرمجية. فيما يلي مثال على جسون: { "language": "Go", "mascot": "Gopher" } يتضمن كائن جسون أعلاه مفتاحين؛ الأول language والثاني mascot، ولكل منهما قيمة مرتبطة به هما Go و Gopher. يستخدم مُرمّز encoder جسون في المكتبة القياسية وسوم البنية مثل "توصيفات annotations" تشير إلى الكيفية التي تريد بها تسمية الحقول الخاصة بك في خرج جسون. يمكن العثور على آليات ترميز وفك ترميز جسون في حزمة encoding/json من هنا. جرب هذا المثال لترى كيف يكون ترميز جسون دون وسوم البنية: package main import ( "encoding/json" "fmt" "log" "os" "time" ) type User struct { Name string Password string PreferredFish []string CreatedAt time.Time } func main() { u := &User{ Name: "Sammy the Shark", Password: "fisharegreat", CreatedAt: time.Now(), } out, err := json.MarshalIndent(u, "", " ") if err != nil { log.Println(err) os.Exit(1) } fmt.Println(string(out)) } وسيكون الخرج على النحو التالي: { "Name": "Sammy the Shark", "Password": "fisharegreat", "CreatedAt": "2019-09-23T15:50:01.203059-04:00" } عرّفنا بنية تمثّل مُستخدم مع حقول تُدل على اسمه Name وكلمة المرور Password وتاريخ إنشاء الحساب CreatedAt. أخذنا داخل الدالة main متغيرًا من هذه البنية وأعطينا قيمًا لجميع حقوله عدا PreferredFish (يحب Sammy جميع الأسماك). بعد ذلك، مرّرنا البنية إلى الدالة json.MarshalIndent، إذ تمكننا هذه الدالة من رؤية خرج جسون بسهولة ودون استخدام أي أداة خارجية. يمكن استبدال الاستدعاء السابق لهذه الدالة بالاستدعاء (json.Marshal(u وذلك لطباعة جسون بدون أي فراغات إضافية، إذ يتحكم الوسيطان الإضافيان للدالة json.MarshalIndent في بادئة الخرج، التي أهملناها مع السلسلة الفارغة، وبالمحارف المُراد استخدامها من أجل تمثيل المسافة البادئة (هنا فراغين). سُجّلت الأخطاء الناتجة عن json.MarshalIndent وأُنهي البرنامج باستخدام (os.Exit(1، وأخيرًا حوّلنا المصفوفة byte[] المُعادة من json.MarshalIndent إلى سلسلة ومررنا السلسلة الناتجة إلى fmt.Println للطباعة على الطرفية. تظهر حقول البنية تمامًا كما تُسمّى، وهذا ليس طبعًا نمط جسون النموذجي الذي يستخدم تنسيق "سنام الجمل Camel case" لأسماء الحقول. سنحقق ذلك في المثال التالي، إذ سنرى عند تشغيله أنه لن يعمل لأن أسماء الحقول المطلوبة تتعارض مع قواعد جو المتعلقة بأسماء الحقول المصدَّرة. package main import ( "encoding/json" "fmt" "log" "os" "time" ) type User struct { name string password string preferredFish []string createdAt time.Time } func main() { u := &User{ name: "Sammy the Shark", password: "fisharegreat", createdAt: time.Now(), } out, err := json.MarshalIndent(u, "", " ") if err != nil { log.Println(err) os.Exit(1) } fmt.Println(string(out)) } وسيكون الخرج على النحو التالي: {} استبدلنا هذه المرة أسماء الحقول، بحيث تتوافق مع تنسيق سنام الجمل؛ فبدلًا من Name وضعنا name وبدلًا من Password وضعنا password وبدلًا من CreatedAt وضعنا createdAt، وعدّلنا داخل متن الدالة main أسماء الحقول أيضًا بحيث تتوافق مع الأسماء الجديدة، ومرّرنا البنية إلى الدالة json.MarshalIndent كما في السابق. الخرج كان عبارة عن كائن جسون فارغ {}. يفرض نمط سنام الجمل أن يكون أول حرف صغير دومًا، بينما لا تهتم جسون بذلك، ولغة جو صارمة مع حالة اﻷحرف؛ إذ تدل البداية بحرف كبير على أن الحقل غير مُصدّر وتدل البداية بحرف صغير على أنّه مُصدّر، وبما أن الحزمة encoding/json هي حزمة منفصلة عن حزمة main التي نستخدمها، يجب علينا كتابة الحرف الأول بأحرف كبيرة لجعله مرئيًا للحزمة encoding/json. حسنًا، يبدو أننا في طريق مسدود، فنحن بحاجة إلى طريقة ما لننقل إلى ترميز جسون ما نود تسمية هذا الحقل به. استخدام وسوم البنية للتحكم بالترميز يمكنك تعديل المثال السابق، بحيث تكون قادرًا على تصدير الحقول المتوافقة مع تنسيق سنام الجمل عن طريق إضافة وسم بنية لكل حقل. يجب أن يكون لدى وسوم البنية التي تتعرّف عليها الحزمة encoding/json مفتاحها json مع قيمة مُرافقة لهذا المفتاح تتحكم بالخرج. إذًا، من خلال استخدام تنسيق سنام الجمل لقيم المفاتيح، سيستخدم المرمّز هذه القيم مثل أسماء للحقول مع الإبقاء على الحقول مُصدّرة، وبالتالي نكون أنهينا المشاكل السابقة: package main import ( "encoding/json" "fmt" "log" "os" "time" ) type User struct { Name string `json:"name"` Password string `json:"password"` PreferredFish []string `json:"preferredFish"` CreatedAt time.Time `json:"createdAt"` } func main() { u := &User{ Name: "Sammy the Shark", Password: "fisharegreat", CreatedAt: time.Now(), } out, err := json.MarshalIndent(u, "", " ") if err != nil { log.Println(err) os.Exit(1) } fmt.Println(string(out)) } سيكون الخرج على النحو التالي: { "name": "Sammy the Shark", "password": "fisharegreat", "preferredFish": null, "createdAt": "2019-09-23T18:16:17.57739-04:00" } لاحظ أننا تركنا أسماء الحقول تبدأ بأحرف كبيرة من أجل السماح بعملية التصدير، ولحل مشكلة تنسيق سنام الجمل استخدمنا وسوم بنية من الشكل "json:"name، إذ أن "name" هي القيمة التي نريد من json.MarshalIndent أن تعرضها عند طباعة كائن جسون. لاحظ أن الحقل PreferredFish لم نُعطه أي قيمة، وبالتالي قد لا نرغب بظهوره عند طباعة كائن جسون. فيما يلي سنتحدث حول هذا الموضوع. حذف حقول جسون الفارغة يُعد حذف حقول الخرج التي ليس لها قيمة في جسون أمرًا شائعًا، وبما أن جميع الأنواع في جو لها "قيمة صفرية" أو قيمة افتراضية مُهيّأة بها، تحتاج حزمة encoding/json إلى معلومات إضافية لتتمكن من معرفة أن بعض الحقول ينبغي عدّها غير مضبوطة، أي قيمتها صفرية في جو. هذه المعلومات هي في الحقيقة مجرد كلمة واحدة نضيفها إلى نهاية القيمة المرتبطة بمفتاح الحقل في وسم البنية؛ وهذه الكلمة هي omitempty,، إذ نُخبر جسون من خلال إضافة هذه الكلمة إلى الحقل بأننا لا نريد ظهوره عندما تكون قيمته صفرية. يوضح المثال التالي الأمر: package main import ( "encoding/json" "fmt" "log" "os" "time" ) type User struct { Name string `json:"name"` Password string `json:"password"` PreferredFish []string `json:"preferredFish,omitempty"` CreatedAt time.Time `json:"createdAt"` } func main() { u := &User{ Name: "Sammy the Shark", Password: "fisharegreat", CreatedAt: time.Now(), } out, err := json.MarshalIndent(u, "", " ") if err != nil { log.Println(err) os.Exit(1) } fmt.Println(string(out)) } سيكون الخرج على النحو التالي: { "name": "Sammy the Shark", "password": "fisharegreat", "createdAt": "2019-09-23T18:21:53.863846-04:00" } عدّلنا الأمثلة السابقة بحيث أصبح حقل PreferredFish يحتوي على وسم البنية "json:"preferredFish,omitempty، إذ سيمنع وجود الكلمة omitempty, ظهور هذا الحقل في خرج كائن جسون. أصبحت الآن الأمور أفضل، لكن هناك مشكلة أخرى واضحة، وهي ظهور كلمة المرور، ولحل المشكلة تؤمن encoding/json طريقةً لتجاهل الحقول الخاصة تمامًا. منع عرض الحقول الخاصة في خرج كائنات جسون يجب تصدير بعض الحقول من البنى حتى تتمكن الحزم الأخرى من التفاعل بطريقة صحيحة مع النوع، لكن قد تكون المشكلة في حساسية طبيعة أحد هذه الحقول كما في حالة كلمة المرور في المثال السابق، لذا نود أن يتجاهل مُرمّز جسون الحقل تمامًا، حتى عند تهيئته بقيمة. يكون حل هذه المشكلة باستخدام المحرف - ليكون قيمةً لوسيط المفتاح الخاص بوسم البنية :json. يعمل هذا المثال على إصلاح مشكلة عرض كلمة مرور المستخدم. package main import ( "encoding/json" "fmt" "log" "os" "time" ) type User struct { Name string `json:"name"` Password string `json:"-"` CreatedAt time.Time `json:"createdAt"` } func main() { u := &User{ Name: "Sammy the Shark", Password: "fisharegreat", CreatedAt: time.Now(), } out, err := json.MarshalIndent(u, "", " ") if err != nil { log.Println(err) os.Exit(1) } fmt.Println(string(out)) } سيكون الخرج على النحو التالي: { "name": "Sammy the Shark", "createdAt": "2019-09-23T16:08:21.124481-04:00" } الشيء الوحيد الذي تغير في هذا المثال عن المثال السابق هو أن حقل كلمة المرور يستخدم الآن القيمة الخاصة "-" لوسم البنية :json. يمكنك أن تلاحظ من الخرج السابق اختفاء كلمة المرور من الخرج. ميزتا التجاهل والإخفاء، أو حتى باقي الخيارات في حزمة encoding/json التي استخدمناها مع حقل PreferredFish و Password، ليستا قياسيتين، أي ليست كل الحزم تستخدم نفس الميزات ونفس بنية القواعد، لكن حزمة encoding/json هي حزمة مُضمّنة عمومًا في المكتبة القياسية، وبالتالي سيكون لدى الحزم الأخرى نفس الميزات ونفس العرض convention. مع ذلك، من المهم قراءة التوثيق الخاص بأي حزمة تابعة لجهة خارجية تستخدم وسوم البنية لمعرفة ما هو مدعوم وما هو غير مدعوم. خاتمة توفر وسوم البنية وسيلةً قويةً لتسهيل التعامل مع دوال الشيفرات التي تعمل مع البنى، كما توفر العديد من الحزم القياسية والخارجية طرقًا لتخصيص عملياتها من خلال استخدام هذه الوسوم. يوفر استخدام الوسوم بفعالية في التعليمات البرمجية الخاصة بك سلوك تخصيص ممتاز ويوثّق بإيجاز كيفية استخدام هذه الحقول للمطوّرين المستقبليين. ترجمة -وبتصرف- للمقال How To Use Struct Tags in Go لصاحبه Gopher Guides. اقرأ أيضًا المقال السابق: بناء البرامج المكتوبة بلغة جو Go وتثبيتها استخدام وسوم البناء لتخصيص الملفات التنفيذية Binaries في لغة جو Go
  17. استخدمنا خلال هذه السلسلة البرمجة بلغة GO الأمر go run كثيرًا، وكان الهدف منه تشغيل شيفرة البرنامج الخاص بنا، إذ أنه يُصرّف شفرة المصدر تلقائيًا ويُشغّل الملف التنفيذي الناتج أي القابل للتنفيذ executable. يُعد هذا الأمر مفيدًا عندما تحتاج إلى اختبار برنامجك من خلال سطر الأوامر command line، لكن عندما ترغب بنشر تطبيقك، سيتطلب منك ذلك بناء تعليماتك البرمجية في ملف تنفيذي، أو ملف واحد يحتوي على شيفرة محمولة أو تنفيذية (يُطلق عليه أيضًا الكود-باء p-code، وهو شكل من أشكال مجموعة التعليمات المصممة للتنفيذ الفعّال بواسطة مُصرّف برمجي) يمكنها تشغيل تطبيقك. يمكنك لإنجاز ذلك استخدام سلسلة أدوات جو لبناء البرنامج وتثبيته. تسمى عملية ترجمة التعليمات البرمجية المصدر إلى ملف تنفيذي في لغة جو بالبناء؛ فعند بناء هذا الملف التنفيذي ستُضاف إليه الشيفرة اللازمة لتنفيذ البرنامج التنفيذي الثنائي binary على النظام الأساسي المُستهدف. هذا يعني أن جو التنفيذي Go binary (خادم مفتوح المصدر أو حزمة برمجية تسمح للمستخدمين الذين لا يستخدمون جو بتثبيت الأدوات المكتوبة بلغة جو بسرعة، دون تثبيت مُصرّف جو أو مدير الحزم - كل ما تحتاجه هو curl) لا يحتاج إلى اعتماديات dependencies النظام مثل أدوات جو للتشغيل على نظام جديد. سيسمح وضع هذه الملفات التنفيذية ضمن مسار ملف تنفيذي على نظامك، بتشغيل البرنامج من أي مكان في نظامك؛ أي كما لو أنك تُثبّت أي برنامج عادي على نظام التشغيل الخاص بك. ستتعلم في هذا المقال كيفية استخدام سلسلة أدوات لغة جو Go toolchain لتشغيل وبناء وتثبيت برنامج "!Hello، World" لفهم كيفية استخدام التطبيقات البرمجية وتوزيعها ونشرها بفعالية. المتطلبات أن يكون لديك مساحة عمل خاصة في لغة جو، وإذا لم يكن لديك ذلك اتبع سلسلة المقالات التالية، فقد تحدّثنا عن ذلك في بداية السلسلة "البرمجة بلغة Go: تثبيت لغة جو Go وإعداد بيئة برمجة محلية على أبونتو Ubuntu تثبيت لغة جو وإعداد بيئة برمجة محلية على نظام ماك أو إس macOS تثبيت لغة جو وإعداد بيئة برمجة محلية على ويندوز. إعداد وتشغيل جو التنفيذي Go Binary سننشئ بدايةً تطبيقًا بسيطًا بلغة جو يطبع العبارة الترحيبية "!Hello، World"، وذلك لتوضيح سلسلة أدوات جو، وانظر مقال كتابة برنامجك الأول في جو Go إن لم تتطلع عليه مسبقًا. أنشئ مجلد "greeter" داخل المجلد "src": $ mkdir greeter بعد ذلك أنشئ ملف "main.go" بعد الانتقال إلى هذا المجلد الذي أنشأته للتو، ويمكنك استخدام أي محرر نصوص تختاره ﻹنشاء الملف، هنا استخدمنا محرر نانو nano: $ cd greeter $ nano main.go بعد فتح الملف، ضِف المحتويات التالية إليه: package main import "fmt" func main() { fmt.Println("Hello, World!") } عند تشغيل هذا البرنامج سيطبع العبارة "!Hello, World" ثم سينتهي البرنامج. احفظ الملف الآن واغلقه. استخدم الأمر go run لاختبار البرنامج: $ go run main.go سيكون الخرج على النحو التالي: Hello, World! كما تحدّثنا سابقًا؛ يبني الأمر go run الملف المصدري الخاص بك في ملف تنفيذي، ثم يُصرّفه ويعرض الناتج. نريد هنا أن نتعلم كيفية بناء الملف التنفيذي بطريقة تمكننا من توزيع ونشر برامجنا، ولذلك سنستخدم الأمر go build في الخطوة التالية. إنشاء وحدة جو من أجل Go binary بُنيت برامج ومكتبات جو وفق المفهوم الأساسي "للوحدة module"، إذ تحتوي الوحدة على معلومات حول المكتبات التي يستخدمها برنامجك وإصدارات هذه المكتبات التي يجب استخدامها. لتخبر جو أن مجلدًا ما هو وحدة، ستحتاج إلى إنشاء هذا المجلد باستخدام الأمر go mod: $ go mod init greeter سيؤدي ذلك إلى إنشاء الملف go.mod الذي يتضمن اسم الوحدة ونسخة جو المستخدمة في إنشائها. go: creating new go.mod: module greeter go: to add module requirements and sums: go mod tidy سيطالبك جو بتشغيل go mod tidy لتحديث متطلبات هذه الوحدة إذا تغيرت في المستقبل، ولن يكون لتشغيله الآن أي تأثير إضافي. بناء الملفات التنفيذية باستخدام الأمر go build يمكنك بناء ملف تنفيذي باستخدام الأمر go build من أجل تطبيق مكتوب بلغة جو، مما يسمح لك بتوزيعه ونشره في المكان الذي تريده. سنجرب ذلك مع ملف main.go. من داخل المجلد greeter من خلال تنفيذ الأمر التالي: $ go build إذا لم تُقدم وسيطًا لهذا الأمر، سيُصرّف الأمر go build تلقائيًا برنامج main.go في مجلدك الحالي، وسيتضمن ذلك أيضًا كل الملفات التي يكون امتدادها go.* في هذا المجلد. سيبني أيضًا جميع التعليمات البرمجية الداعمة واللازمة لتكون قادرًا على تنفيذ البرنامج التنفيذي على أي جهاز حاسوب له نفس معمارية النظام، بغض النظر عما إذا كان هذا النظام يحتوي على أدوات جو أو مُصرّف جو أو ملفاته المصدرية. إذًا، فقد بنيت تطبيق الترحيب الخاص بك في ملف تنفيذي أُضيف إلى مجلدك الحالي. تحقق من ذلك عن طريق تشغيل الأمر التالي: $ ls إذا كنت تستخدم نظام ماك أو إس macOS أو لينكس Linux، فستجد ملفًا تنفيذيًا جديدًا مُسمّى على اسم المجلد الذي بنيت فيه برنامجك: greeter main.go go.mod ملاحظة: في نظام التشغيل ويندوز، سيكون الملف التنفيذي باسم "greeter.exe". سيُنشئ الأمرgo build افتراضيًا ملفًا تنفيذيًا للنظام الأساسي والمعمارية الحاليين. على سبيل المثال، إذا بُنيَ على نظام تشغيل linux / 386، سيكون الملف التنفيذي متوافقًا مع أي نظام Linux / 386 آخر، حتى إذا لم يكن جو مُثبّتًا على ذلك النظام. تدعم لغة جو إمكانية البناء على الأنظمة والمعماريات الأخرى. بعد أن أنشأت ملفك التنفيذي، يمكنك تشغيله للتأكد من أنه قد بُنيَ بطريقة سليمة. في نظام ماك أو إس أو لينكس، شغّل الأمر التالي: $ ./greeter أما في ويندوز نفّذ الأمر: $ greeter.exe سيكون الخرج على النحو التالي: Hello, World! بذلك تكون قد أنشأت ملفًا تنفيذيًّا يحتوي على برنامجك وعلى شيفرة النظام المطلوبة لتشغيل هذا الملف التنفيذي. يمكنك الآن توزيع هذا البرنامج على أنظمة جديدة أو نشره على خادم، مع العلم أن الملف سيعمل دائمًا على نفس البرنامج. سنشرح فيما يلي كيفية تسمية ملف تنفيذي وكيفية تعديله بحيث يمكنك التحكم أكثر في عملية بناء البرنامج. تغيير اسم الملف التنفيذي بعد أن عرفت كيفية إنشاء ملف تنفيذي، ستكون الخطوة التالية هي تحديد كيفية اختيار جو اسمًا للملف التنفيذي وكيفية تخصيص هذا الاسم لمشروعك. يقرر جو تلقائيًا اسم الملف التنفيذي الذي بُنيَ عند تشغيل الأمر go build، وذلك اعتمادًا على الوحدة التي أنشأتها. عندما نفّذنا الأمر go mod init greeter منذ قليل، أُنشئت وحدة باسم greeter، وهذا هو سبب تسمية الملف التنفيذي الثنائي binary الذي أُنشئ باسم "greeter" بدوره. إذا فتحت ملف go.mod (الذي يُفترض أن يكون ضمن مجلد مشروعك)، وكان يتضمن التصريح التالي: module github.com/sammy/shark وهذا يعني أن الاسم الافتراضي للملف التنفيذي الذي بُنيَ هو shark. لن تكون هذه التسميات الافتراضية دائمًا الخيار الأفضل لتسمية الملف التنفيذي الخاص بك في البرامج الأكثر تعقيدًا التي تتطلب تسميات اصطلاحية محددة، وسيكون من الأفضل تحديد أسماء مُخصصة من خلال الراية o-. سنغيّر الآن اسم الملف التنفيذي الذي أنشأناه في القسم السابق إلى الاسم "hello" ونضعه في مجلد فرعي يسمى "bin". لن نحتاج إلى إنشاء هذا المجلد (bin)، إذ ستتكفل جو بذلك أثناء عملية البناء. نفّذ الأمر go build مع الرايةo-: $ go build -o bin/hello تُخبر الراية o- مُصرّف جو أن عليه مُطابقة خرج الأمر go build مع الوسيط المُحدد بعدها والمتمثّل بالعبارة bin/hello. بعبارةٍ أوضح؛ تُخبر هذه الراية المُصرّف أن الملف التنفيذي السابق يجب أن يكون اسمه "hello" وأن يكون ضمن مجلد اسمه "bin"، وفي حال لم يكن هذا المجلد موجودًا فعليك إنشاؤه تلقائيًّا. لاختبار الملف التنفيذي الجديد، انتقل إلى المجلد الجديد وشغّل الملف التنفيذي: $ cd bin $ ./hello ستحصل على الخرج التالي: Hello, World! يمكنك الآن اختيار اسم الملف التنفيذي ليناسب احتياجات مشروعك. إلى الآن لا تزال مقيدًا بتشغيل ملفك التنفيذي من المجلد الحالي، وإذا أردت استخدام الملفات التنفيذية التي بنيتها من أي مكان على نظامك، يجب عليك تثبيتها باستخدام الأمر go install. تثبيت برامج جو باستخدام الأمر go install ناقشنا حتى الآن كيفية إنشاء ملفات تنفيذية من ملفات مصدرية بامتداد "go."، هذه الملفات التنفيذية مفيدة من أجل التوزيع والنشر والاختبار، ولكن لا يمكن تنفيذها من خارج المجلدات المصدرية الموجودة ضمنها. قد تكون هذه مشكلة إذا كنت تريد استخدام برنامجك باستمرار ضمن سكريبتات الصدفة shell scripts أو في مهام أخرى. لتسهيل استخدام البرامج، يمكنك تثبيتها في نظامك والوصول إليها من أي مكان. لتوضيح الفكرة سنستخدم الأمر go install لتثبيت البرنامج الذي نعمل عليه. يعمل الأمر go install على نحوٍ مماثل تقريبًا للأمر go build، ولكن بدلًا من ترك الملف التنفيذي في المجلد الحالي أو مجلد محدد بواسطة الراية o-، فإنه يضعه في المجلد "GOPATH/bin$". لمعرفة مكان وجود مجلد "GOPATH$" الخاص بك، شغّل الأمر التالي: $ go env GOPATH قد يختلف الخرج الذي تتلقاه، ولكن يُفترض أن يكون ضمن مجلد go الموجود داخل مجلد HOME$: $HOME/go نظرًا لأن go install سيضع الملفات التنفيذية التي أُنشئت في مجلد فرعي للمجلد GOPATH$ اسمه bin، يجب إضافة هذا المجلد إلى متغير البيئة PATH$. تحدّثنا عن هذه المواضيع في مقالة كيفية تثبيت جو وإعداد بيئة برمجة محلية. بعد إعداد المجلد GOPATH/bin$، ارجع إلى مجلد greeter: $ cd .. شغّل الآن أمر التثبيت: $ go install سيؤدي هذا إلى بناء ملفك التنفيذي ووضعه في GOPATH/bin$. شغّل الأمر التالي لاختبار ذلك: $ ls $GOPATH/bin سيسرد لك هذا الأمر محتويات المجلد GOPATH/bin$: greeter ملاحظة: لا يدعم الأمر go install الراية o-، لذلك سيستخدم الاسم الافتراضي الذي تحدّثنا عنه سابقًا لتسمية الملف التنفيذي. تحقق الآن ما إذا كان البرنامج سيعمل من خارج المجلد المصدر. ارجع أولًا إلى المجلد HOME: $ cd $HOME استخدم ما يلي لتشغيل البرنامج: $ greeter ستحصل على الخرج التالي: Hello, World! يمكنك الآن تثبيت البرامج التي تكتبها في نظامك، مما يتيح لك استخدامها من أي مكان، ومتى احتجت إليها. خاتمة أوضحنا في هذا المقال كيف تسهل سلسلة أدوات جو عملية إنشاء ملفات تنفيذية ثنائية من التعليمات البرمجية المصدرية، ويمكن توزيعها لتعمل على أنظمة أخرى، حتى لو لم تحتوي على أدوات وبيئات للغة جو. استخدمنا أيضًا الأمر go install لبناء برامجنا وتثبيتها تلقائيًا مثل ملفات تنفيذية ضمن متغير البيئة PATH$ الخاص بالنظام. ستستطيع من خلال الأمرين go install و go build مشاركة واستخدام التطبيق الخاص بك كما تشاء. الآن بعد أن تعرفت على أساسيات go build، يمكنك استكشاف كيفية إنشاء شيفرة مصدر معيارية من خلال المقالة استخدام وسوم البناء لتخصيص الملفات التنفيذية في لغة جو Go. ترجمة -وبتصرف- للمقال How To Build and Install GOPrograms لصاحبه Gopher Guides. اقرأ أيضًا المقال السابق: تعريف التوابع Methods في لغة جو Go كتابة برنامجك الأول في جو Go
  18. تُمكّنك الدوال Functions من تنظيم منطق مُحدد ضمن إجراءات procedures قابلة للتكرار يمكنها استخدام وسطاء مختلفة في كل مرة تُنفّذ فيها. تعمل عدة دوال غالبًا على نفس الجزء من البيانات في كل مرة. يُمكن لغة جو التعرّف على هذه الأنماط، وتسمح لك بتعريف دوال خاصة تُسمى "التوابع Methods"، وتجري عمليات على نوع بيانات مُحدد يُشار إليه باسم "المُستقبل Receiver". تعريف تابع قواعد كتابة التوابع مُشابهة لقواعد كتابة الدوال، والاختلاف الوحيد هو إضافة معامل يُوضع بعد الكلمة المفتاحية func لتحديد مُستقبل التابع. المُستقبل هو تصريح لنوع البيانات الذي تُريد تعريف التابع من أجله. نُصرّح في المثال التالي عن تابع يُطبّق على نوع بنية struct: package main import "fmt" type Creature struct { Name string Greeting string } func (c Creature) Greet() { fmt.Printf("%s says %s", c.Name, c.Greeting) } func main() { sammy := Creature{ Name: "Sammy", Greeting: "Hello!", } Creature.Greet(sammy) } نحصل عند تشغيل الشيفرة على ما يلي: Sammy says Hello! أنشأنا بنيةً أسميناها Creature تمتلك حقلين من النوع string باسم Name و Greeting. لدى Creature تابع وحيد مُعرّف يُسمى Greet. أسندنا أثناء التصريح عن المستقبل نسخةً من Creature إلى المتغير c، بحيث يُمكننا من خلالها الوصول إلى حقول Creature وطباعة محتوياته باستخدام دالة fmt.Printf. يُشار عادةً للمُستقبل في لغات البرمجة الأخرى بكلمات رئيسية مثل self كما في لغة بايثون أو this كما في لغة جافا. يُعد المستقبل في لغة جو مُتغيرًا كما باقي المتغيرات، وبالتالي يُمكنك تسميته كما تشاء. يُفضّل عمومًا تسميته بأول حرف من اسم نوع البيانات الذي تُريد أن يتعامل معه التابع، فقد سُمّي المعامل في المثال السابق بالاسم c لأن نوع بيانات المستقبل كان Creature. أنشأنا داخل الدالة main نسخةً من البنية Creature وأعطينا حقولها Name و Greeting القيم "Sammy" و "!Hello" على التوالي. استدعينا التابع Greet والذي أصبح مُعرّفًا على نوع البيانات Creature من خلال وضع نقطة Creature.Greet، ومرّرنا له النسخة التي أنشأناه للتو مثل وسيط. قد يبدو هذا الأسلوب في الاستدعاء مُربكًا قليلًا، سنعرض فيما يلي أسلوبًا آخر لاستدعاء التوابع: package main import "fmt" type Creature struct { Name string Greeting string } func (c Creature) Greet() { fmt.Printf("%s says %s", c.Name, c.Greeting) } func main() { sammy := Creature{ Name: "Sammy", Greeting: "Hello!", } sammy.Greet() } ستحصل عند تشغيل الشيفرة على نفس الخرج السابق: Sammy says Hello! لا يختلف هذا المثال عن المثال السابق إلا في استدعاء التابع Greet، إذ استخدمنا هنا الأسلوب المختصر، بحيث نضع اسم المتغير الذي يُمثّل البنية Creature وهو sammy متبوعًا بنقطة ثم اسم التابع بين قوسين ()sammy.Greet. يُحبّذ دومًا استخدام هذا الأسلوب الذي يُسمّى التدوين النقطي dot notation، فمن النادر أن يستخدم المطورون الأسلوب السابق الذي يُسمّى أسلوب الاستدعاء الوظيفي functional invocation style. يوضح المثال التالي أحد الأسباب التي تجعل هذا الأسلوب أكثر انتشارًا: package main import "fmt" type Creature struct { Name string Greeting string } func (c Creature) Greet() Creature { fmt.Printf("%s says %s!\n", c.Name, c.Greeting) return c } func (c Creature) SayGoodbye(name string) { fmt.Println("Farewell", name, "!") } func main() { sammy := Creature{ Name: "Sammy", Greeting: "Hello!", } sammy.Greet().SayGoodbye("gophers") Creature.SayGoodbye(Creature.Greet(sammy), "gophers") } عند تشغيل الشيفرة السابقة سيكون الخرج على النحو التالي: Sammy says Hello!! Farewell gophers ! Sammy says Hello!! Farewell gophers ! عدّلنا الأمثلة السابقة لكي نُقدّم تابعًا جديدًا يُسمى SayGoodbye، وعدّلنا أيضًا تعريف التابع Greet بحيث يُعيد أيضًا المُعامل c الذي يُمثل Creature، وبالتالي سيكون لدينا إمكانية استخدام القيمة المُعادة من هذا التابع والمُتمثّلة بنسخة Creature. نستدعي في الدالة main التابعين SayGoodbye و Greet على المتغير sammy باستخدام أسلوبي التدوين النقطي والاستدعاء الوظيفي. يعطي الأسلوبان نفس النتائج في الخرج، لكن التدوين النُقطي أسهل للقراءة، كما أنه يعرض لنا عملية استدعاء التوابع مثل تسلسل، إذ نستدعي التابع Greet من خلال المتغير sammy الذي يُمثّل Creature، ثم نستدعي التابع SayGoodbye من خلال القيمة التي يُعيدها هذا التابع والتي تمثّل Creature أيضًا.لا يعكس أسلوب الاستدعاء الوظيفي هذا الترتيب بسبب إضافة معامل إلى استدعاء SayGoodbye يؤدي إلى حجب ترتيب الاستدعاءات. وضوح التدوين النقطي هو السبب في أنه النمط المفضل لاستدعاء التوابع في لغة جو، سواءً في المكتبة القياسية أو في الحزم الخارجية. تعريف التوابع على نوع بيانات مُحدد هو أمر مُختلف عن تعريف الدوال التي تعمل بناءً على قيمة ما، وهذا له أهمية خاصة في لغة جو لأنه مفهوم أساسي في الواجهات interfaces. الواجهات interfaces عندما تُعرّف أي تابع على نوع بيانات في لغة جو، يضُاف هذا التابع إلى مجموعة التوابع الخاصة بهذا النوع، وهي مجموعة من الدوال المرتبطة بهذا النوع ويستخدمها مُصرّف لغة جو لتحديد إذا كان يمكن إسناد نوع ما لمتغير من نوع "واجهة interface"؛ وهذا النوع هو نهج يعتمده المُصرّف لضمان أن مُتغيّرًا من نوع البيانات المطلوب يُحقق التوابع التي تتضمنها الواجهة. يُعد أي نوع يمتلك توابع لها نفس الاسم ونفس المعلمات ونفس القيم المُعادة؛ مثل تلك الموجودة في تعريف الواجهة منفِّذًا لتلك الواجهة ويُسمح بإسناده إلى متغيرات من تلك الواجهة. نعرض فيما يلي تعريفًا لواجهة fmt.Stringer من المكتبة القياسية: type Stringer interface { String() string } لاحظ هنا استخدام الكلمة المفتاحية type لتعريف نوع بيانات جديد يُمثّل "واجهة". لكي ينفّذ أي نوع واجهة fmt.Stringer، يجب أن يوفر تابعًا اسمه ()String يُعيد سلسلة نصية. سيُمكّنك تنفيذ هذه الواجهة من طباعة "نوعك" تمامًا كما تريد ويُسمى ذلك "طباعة مُرتّبة pretty-printed"، وذلك عند تمرير نسخة من النوع الخاص بك إلى دوال محددة في حزمة fmt. يُعرّف المثال التالي نوعًا ينفّذ هذه الواجهة: package main import ( "fmt" "strings" ) type Ocean struct { Creatures []string } func (o Ocean) String() string { return strings.Join(o.Creatures, ", ") } func log(header string, s fmt.Stringer) { fmt.Println(header, ":", s) } func main() { o := Ocean{ Creatures: []string{ "sea urchin", "lobster", "shark", }, } log("ocean contains", o) } ويكون الخرج على النحو التالي: ocean contains : sea urchin, lobster, shark صرّحنا في هذا المثال عن نوع بيانات جديد يُمثّل بنيةً اسمها Ocean. يُمكننا القول أن هذه البنية تنفّذ الواجهة fmt.Stringer لأنها تعرِّف تابعًا اسمه String لا يأخذ أي وسطاء ويعيد سلسلةً نصية، أي تمامًا كما في الواجهة. عرّفنا داخل الدالة main متغيرًا o يُمثّل بنيةً Ocean ومرّرناه إلى الدالة log التي تطبع سلسة نصية تُمرّر لها، متبوعةً بأي شيء تُنفّذه وليكن fmt.Stringer. سيسمح لنا مُصرّف جو هنا أن نُمرّر البنية o لأنه يُنفّذ كل التوابع التي تطلبها الدالة fmt.Stringer (هنا يوجد تابع وحيد String). نستخدم داخل الدالة log دالة الطباعة fmt.Println التي تستدعي التابع String من البنية Ocean لأننا مررنا لها المعامل s من النوع fmt.Stringer في ترويستها (أي بمثابة أحد معاملاتها). إذا لم تنفّذ البنية Ocean التابع String سيُعطينا جو خطأً في التصريف، لأن الدالة log تتطلب تمرير وسيط من النوع fmt.Stringer، وسيكون الخطأ كما يلي: src/e4/main.go:24:6: cannot use o (type Ocean) as type fmt.Stringer in argument to log: Ocean does not implement fmt.Stringer (missing String method) سيتحقق مُصرّف لغة جو من مطابقة التابع ()String للتابع المُستدعى من قِبل الواجهة fmt.Stringer، وإذا لم يكن مُطابقًا، سيعطي الخطأ: src/e4/main.go:26:6: cannot use o (type Ocean) as type fmt.Stringer in argument to log: Ocean does not implement fmt.Stringer (wrong type for String method) have String() want String() string استخدمت التوابع المُعرّفة في الأمثلة السابقة "مُستقبل قيمة"، أي إذا استخدمنا أسلوب الاستدعاء الوظيفي للتوابع، سيكون المعامل الأول الذي يشير إلى النوع الذي عُرّف التابع عليه قيمةً من هذا النوع وليس مؤشرًا؛ وهذا يعني أن أي تعديل نُجريه على هذا النوع المُمثّل بالمعامل المُحدد (مثلًا في المثال السابق كان هذا المعامل هو o) سيكون محليًّا وسيُنفّذ على نسخة من البيانات ولن يؤثر على النسخة الأصلية. سنرى فيما يلي أمثلة تستخدم مُستقبلات مرجعية "مؤشر". مستقبلات مثل مؤشرات يُشبه استخدام المؤشرات مثل مستقبلات في التوابع إلى حد كبير استخدام "مستقبل القيمة"، والفرق الوحيد هو وضع علامة * قبل اسم النوع. يوضح المثال التالي تعريف تابع على مستقبل مؤشر إلى نوع: package main import "fmt" type Boat struct { Name string occupants []string } func (b *Boat) AddOccupant(name string) *Boat { b.occupants = append(b.occupants, name) return b } func (b Boat) Manifest() { fmt.Println("The", b.Name, "has the following occupants:") for _, n := range b.occupants { fmt.Println("\t", n) } } func main() { b := &Boat{ Name: "S.S. DigitalOcean", } b.AddOccupant("Sammy the Shark") b.AddOccupant("Larry the Lobster") b.Manifest() } سيكون الخرج على النحو التالي: The S.S. DigitalOcean has the following occupants: Sammy the Shark Larry the Lobster يُعرّف هذا المثال نوعًا يُسمّى Boat يمتلك حقلين هما Name و occupants. نريد حماية الحقل occupants بحيث لا تتمكن الشيفرات الأخرى من خارج الحزمة أن تُعدّل عليه إلا من خلال التابع AddOccupant، لهذا السبب جعلناه حقلًا غير مُصدّر، وذلك بجعل أول حرف صغيرًا. نريد أيضًا جعل التعديلات التي يُجريها التابع AddOccupant على نفس المتغير وبالتالي نحتاج إلى تمريره بالمرجع؛ أي يجب أن نُعرّف مُستقبل مؤشر (b *Boat) وليس مُستقبل قيمة، إذ سيعمل مُستقبل المؤشر على إجراء التعديلات على نفس بيانات المتغير الأصلي الموجودة في الذاكرة من خلال تمرير عنوانه. تعمل المؤشرات مثل مراجع إلى متغير من نوع محدد بدلاً من نسخة من هذا النوع، لذلك سيضمن تمرير عنوان متغير من النوع Boat إلى التابع AddOccupant تنفيذ التعديلات على المتغير نفسه وليس نسخةً منه. نُعرّف داخل الدالة main متغيرًا b يحمل عنوان بنية من النوع Boat، وذلك من خلال وضع & قبل تعريف البنية (Boat*) كما في الشيفرة أعلاه. استدعينا التابع AddOccupant مرتين لإضافة عُنصرين. يُعرّف التابع Manifest على النوع Boat ويستخدم مُستقبل قيمة (b Boat). ما زلنا قادرين على استدعاء Manifest داخل الدالة main لأن لغة جو قادرة على تحصيل dereference قيمة المؤشر تلقائيًا من Boat، إذ تكافئ ()b.Manifest هنا ()b).Manifest*). بغض النظر عن نوع المستقبل الذي تُعرّفه لتابع ما؛ مستقبل مؤشر أو مستقبل القيمة، فإن له آثارًا مهمة عند محاولة إسناد قيم للمتغيرات من نوع واجهة. المستقبلات مثل مؤشرات والواجهات عندما تحاول إسناد قيمة متغير من نوع محدد إلى متغير نوعه واجهة، يتأكد مُصرّف جو ما إذا كان ذلك النوع يُنفّذ كل التوابع التي تتطلبها الواجهة. تختلف مجموعة التوابع لمستقبل المؤشر عن مستقبل القيمة لأن التوابع التي تتلقى مؤشرًا يمكنها التعديل على المُستقبل الخاص بها، بينما لا يمكن لتلك التي تتلقى قيمة فعل ذلك. package main import "fmt" type Submersible interface { Dive() } type Shark struct { Name string isUnderwater bool } func (s Shark) String() string { if s.isUnderwater { return fmt.Sprintf("%s is underwater", s.Name) } return fmt.Sprintf("%s is on the surface", s.Name) } func (s *Shark) Dive() { s.isUnderwater = true } func submerge(s Submersible) { s.Dive() } func main() { s := &Shark{ Name: "Sammy", } fmt.Println(s) submerge(s) fmt.Println(s) } سيكون الخرج على النحو التالي: Sammy is on the surface Sammy is underwater عرّفنا واجهة تُسمى Submersible، تقبل أنواعًا تُنفّذ التابع ()Dive الخاص بها. عرّفنا أيضًا النوع Shark مع الحقل Name والتابع isUnderwater لتتبع حالة متغيرات هذا النوع. عرّفنا أيضًا التابع ()Dive مع مُستقبل مؤشر من النوع Shark، إذ يُعدّل هذا التابع قيمة التابع isUnderwater لتصبح true. عرّفنا أيضًا التابع ()String مع مُستقبل قيمة من النوع Shark لطباعة حالة Shark بأسلوب مُرتب باستخدام fmt.Println ومن خلال الواجهة fmt.Stringer التي تعرّفنا عليها مؤخرًا. عرّفنا أيضًا الدالة submerge التي تأخذ معاملًا من النوع Submersible. يسمح لنا استخدام الواجهة Submersible بدلًا من Shark* في الدالة submerge جعل هذه الدالة تعتمد فقط على السلوك الذي يوفره النوع، وبالتالي جعلها أكثر قابلية لإعادة الاستخدام، فلن تضطر إلى كتابة دوال submerge جديدة لحالات خاصة أخرى مثل Submarine أو Whale أو أي كائنات مائية أخرى في وقت لاحق، فطالما أنها تُعرّف التابع ()Dive يمكنها أن تعمل مع الدالة submerge. نُعرّف داخل الدالة main المتغير s الذي يمثّل مؤشرًا إلى Shark ونطبعه مباشرةً باستخدام الدالة fmt.Println مما يؤدي إلى طباعة Sammy is on the surface على شاشة الخرج. نمرر بعدها المتغير s إلى الدالة submerge ثم نستدعي الدالة fmt.Println مرةً أخرى على المتغير s مما يؤدي لطباعة Sammy is underwater. إذا جعلنا s من النوع Shark بدلًا من Shark*، سيعطي المُصرّف الخطأ التالي: cannot use s (type Shark) as type Submersible in argument to submerge: Shark does not implement Submersible (Dive method has pointer receiver) يخبرنا مُصرّف جو أن Shark تمتلك التابع Dive وأن ذلك معرّفٌ في مستقبل المؤشر. عندما ترى رسالة الخطأ هذه، فإن الحل هو تمرير مؤشر إلى نوع الواجهة باستخدام العامل & قبل اسم المتغير الذي ترغب بإسناده لمتغير آخر. خاتمة لا يختلف التصريح عن توابع في لغة جو كثيرًا عن تعريف الدوال التي تستقبل أنواعًا مختلفة من المتغيرات، إذ تنطبق نفس قواعد العمل مع المؤشرات. توفر لغة جو الراحة في تعريف الدوال ويسمح بجمعها في مجموعات من التوابع التي يمكن تفسيرها من خلال أنواع الواجهة. سيسمح لك استخدام التوابع بطريقة فعّالة بالعمل مع الواجهات في شيفراتك البرمجية وتحسين إمكانية الاختبار، كما يجعل الشيفرة أكثر تنظيمًا وسهلة القراءة للمطورين الآخرين. ترجمة -وبتصرف- للمقال Defining Methods in Go لصاحبه Gopher Guides. اقرأ أيضًا المقال السابق: البنى Structs في لغة جو Go التعامل مع السلاسل في لغة جو Go المؤشرات Pointers في لغة جو Go
  19. يُعد بناء تجريدات abstractions لتفاصيل البرنامج الخاص بك أعظم أداة يمكن أن تقدمها لغة البرمجة للمطور، إذ تسمح البُنى structs لمطوري لغة جو بوصف العالم الذي يعمل فيه البرنامج؛ فبدلًا من استخدام السلاسل النصية strings لوصف أشياء، مثل الشارع Street والمدينة City والرمز البريدي PostalCode، يمكن استخدام بنية تجمع كل هذه الأشياء تحت مفهوم "العنوان Address". يمكن تعريف البنية على أنها هيكل بيانات يُستخدم لتعريف نوع بيانات جديد يحتوي مجموعةً محددةً من القيم مختلفة النوع، ويمكن الوصول لهذه العناصر أو القيم عن طريق اسمها. تساعد البنى المطورين المستقبليين (بما في ذلك نحن) بتحديد البيانات المهمة للبرامج الخاصة بنا وكيف يجب أن تستخدم الشيفرات المستقبلية هذه البيانات بالطريقة الصحيحة. يمكن تعريف البنى واستخدامها بعدة طرق مختلفة. سنلقي نظرةً في هذا المقال على كل من هذه التقنيات. تعريف البنى يمكنك أن تتخيل البنى مثل نماذج جوجل التي يُطلب منك ملؤها أحيانًا. تتضمّن هذه النماذج حقولًا يُطلب منك تعبئتها، مثل الاسم، أو العنوان، أو البريد الإلكتروني، أو مربعات فارغة يمكن تحديد إحداها لوصف حالتك الزوجية (أعزب، متزوج، أرمل) …إلخ. يمكن أن تتضمن البُنى أيضًا حقولًا يمكنك تعبئتها. تهيئة متغير ببنية جديدة، أشبه باستخراج نسخة من نموذج جاهز للتعبئة. لإنشاء بنية جديدة يجب أولاً إعطاء لغة جو مُخططًا يصف الحقول التي تحتوي عليها البنية. يبدأ تعريف البنية بالكلمة المفتاحية type متبوعًا باسم البنية الذي تختاره Creature ثم الكلمة struct ثم قوسين {} نضع بداخلهما الحقول التي نريدها في البنية. يمكنك استخدام البنية بعد الانتهاء من التصريح عنها مع المتغيرات كما لو أنها نوع بيانات بحد ذاته. package main import "fmt" type Creature struct { Name string } func main() { c := Creature{ Name: "Sammy the Shark", } fmt.Println(c.Name) } ستحصل عند تشغيل البرنامج على الخرج التالي: Sammy the Shark صرّحنا في هذا المثال أولًا عن بنية اسمها Creature تتضمّن حقلًا اسمه Name من نوع سلسلة نصية string. نُعرّف داخل الدالة main مُتغيرًا c من النوع Creature ونهيئ الحقل Name فيه بالقيمة "Sammy the Shark"، إذ نفتح قوسين {} ونضع هذه المعلومات بينهما كما في الشيفرة أعلاه. أخيرًا استدعينا الدالة fmt.Println وطبعنا من خلالها الحقل Name من خلال المتغير c وذلك عن طريق وضع اسم المتغير متبوعًا بنقطة . ومتبوعًا باسم الحقل، مثل c.Name، الذي يعيد في هذه الحالة حقل Name. عندما نأخذ نسخةً من بنية، نذكر غالبًا اسم كل حقل ونسند له قيمة (كما فعلنا في المثال السابق). عمومًا، إذا كنت ستؤمن قيمة كل حقل أثناء تعريف نسخة من بنية فيمكنك عندها تجاهل كتابة أسماء الحقول، كما يلي: package main import "fmt" type Creature struct { Name string Type string } func main() { c := Creature{"Sammy", "Shark"} fmt.Println(c.Name, "the", c.Type) } وسيكون الخرج على النحو التالي: Sammy the Shark أضفنا حقلًا جديدًا للبنية Creature باسم Type وحددنا نوعه string. أنشأنا داخل الدالة main نسخةً من هذه البنية وهيّأنا قيم الحقلين Name و Type بالقيمتين "Sammy" و "shark" على التوالي من خلال التعليمة: Creature{"Sammy", "Shark"} واستغنينا عن كتابة أسماء الحقول صراحةً. نُسمي هذه الطريقة بالطريقة المُختصرة للتصريح عن نسخة من بنية، وهذه الطريقة غير شائعة كثيرًا لأنها تتضمن عيوبًا مثل ضرورة تحديد قيم لجميع الحقول وبالترتيب وعدم نسيان أي حقل. نستنتج سريعًا أن استخدام هذه الطريقة لا يكون جيدًا عندما يكون لدينا عددٌ كبيرٌ من الحقول لأننا سنكون عُرضةً للخطأ والنسيان والتشتت عندما نقرأ الشيفرة مرةً أخرى. إذًا، يُفضّل استخدام هذه الطريقة فقط عندما يكون عدد الحقول قليل. ربما لاحظت أننا نبدأ أسماء جميع الحقول بحرف كبير، وهذا مهم جدًا لأنه يلعب دورًا في تحديد إمكانية الوصول إلى هذه الحقول؛ فعندما نبدأ اسم الحقل بحرف كبير، فهذا يعني إمكانية الوصول إليه من خارج الحزمة، أما الحرف الصغير فلا يمكن الوصول إليها من خارج الحزمة. تصدير حقول البنية يعتمد تصدير حقول البنية على نفس قواعد تصدير المكونات الأخرى في لغة جو؛ فإذا بدأ اسم الحقل بحرف كبير، فسيكون قابلاً للقراءة والتعديل بواسطة شيفرة من خارج الحزمة التي صُرّح عنه فيها؛ أما إذا بدأ الحقل بحرف صغير، فلن تتمكن من قراءة وتعديل هذا الحقل إلا من شيفرة من داخل الحزمة التي صُرّح عنه فيها. يوضّح المثال التالي الأمر: package main import "fmt" type Creature struct { Name string Type string password string } func main() { c := Creature{ Name: "Sammy", Type: "Shark", password: "secret", } fmt.Println(c.Name, "the", c.Type) fmt.Println("Password is", c.password) } وسيكون الخرج على النحو التالي: Sammy the Shark Password is secret أضفنا إلى البنية السابقة حقلًا جديدًا secret، وهو حقل من النوع string ويبدأ بحرف صغير؛ أي أنه غير مُصدّر، وأي حزمة أخرى تحاول إنشاء نسخة من هذه البنية Creature لن تتمكن من الوصول إلى حقل secret. عمومًا، يمكننا الوصول إلى هذا الحقل ضمن نطاق الحزمة، لذا إذا حاولنا الوصول إلى هذا الحقل من داخل الدالة main والتي بدورها موجودة ضمن نفس الحزمة بالتأكيد، فيمكننا الرجوع لهذا الحقل c.password والحصول على القيمة المُخزنة فيه. وجود حقول غير مُصدّرة أمر شائع في البنى مع إمكانية وصول بواسطة توابع مُصدّرة exported. البنى المضمنة Inline تُسمى أيضًا البنى السريعة. تُمكّنك لغة جو من تعريف بُنى في أي وقت تريده وفي أي مكان ودون الحاجة للتصريح عنها على أنها نوع بيانات جديد بحد ذاته، وهذا مفيد في الحالات التي تكون فيها بحاجة إلى استخدام بنية مرةً واحدةً فقط (أي لن تحتاج إلى إنشاء أكثر من نسخة)، فمثلًا، تستخدم الاختبارات غالبًا بنيةً لتعريف جميع المعاملات التي تُشكل حالة اختبار معينة. سيكون ابتكار أسماء جديدة مثل CreatureNamePrintingTestCase مرهقًا لدى استخدام هذه البنية في مكان واحد فقط. يكون كتابة البنى المُضمّنة بنفس طريقة كتابة البنى العادية تقريبًا، إذ نكتب الكلمة المفتاحية struct متبوعةً بقوسين {} نضع بينهما الحقول وعلى يمين اسم المتغير. يجب أيضًا وضع قيم لهذه الحقول مباشرةً من خلال استخدام قوسين آخرين {} كما هو موضح في الشيفرة التالية: package main import "fmt" func main() { c := struct { Name string Type string }{ Name: "Sammy", Type: "Shark", } fmt.Println(c.Name, "the", c.Type) } ويكون الخرج كما يلي: Sammy the Shark لاحظ هنا أننا لم نحتاج إلى تعريف نوع بيانات جديد لتمثيل البنية، وبالتالي لم نكتب الكلمة المفتاحية type، فكل ما نحتاجه هنا هو استخدام الكلمة المفتاحية struct إشارةً إلى البنية، وإلى معامل الإسناد القصير =:. نحتاج أيضًا إلى تعريف قيم الحقول مباشرةً كما فعلنا في الشيفرة أعلاه. الآن أصبح لدينا متغيرًا اسمه c يُمثّل بنيةً يمكن الوصول لحقولها من خلال النقطة . كما هو معتاد. سترى البنى المُضمّنة غالبًا في الاختبارات، إذ تُعرّف البنى الفردية بصورةٍ متكررة لاحتواء البيانات والتوقعات لحالة اختبار معينة. خاتمة البُنى هي كتل بيانات غير متجانسة (أي أنها تضم حقولًا أو عناصر من أنواع بيانات مختلفة) يعرّفها المبرمجون لتنظيم المعلومات. تتعامل معظم البرامج مع أحجام هائلة من البيانات، وبدون البنى سيكون من الصعب تذكر أي من المتغيرات ترتبط معًا وأيّها غير مرتبطة أو أيّها من نوع string وأيها من نوع int. لذلك إذا كنت تتعامل مع مجموعة من المتغيرات، اسأل نفسك عما إذا كان تجميعها ضمن بنية سيكون أفضل، إذ من الممكن أن تصف هذه المتغيرات مفهومًا عالي المستوى، فيمكن مثلًا أن يشير أحد المتغيرات إلى عنوان شركة حسوب وهناك متغيّر آخر يخص عنوان شركة أُخرى. ترجمة -وبتصرف- للمقال Defining Structs in Go لصاحبه Gopher Guides. اقرأ أيضًا المقال السابق: المؤشرات Pointers في لغة جو Go هياكل البيانات المكدس Stack والرتل Queue وأنواع البيانات المجردة ADT
  20. المؤشّر هو عنوان يشير إلى موقع في الذاكرة، وتُستخدم المؤشرات عادةً للسماح للدوالّ أو هياكل البيانات بالحصول على معلومات عن الذاكرة وتعديلها دون الحاجة إلى نسخ الذاكرة المشار إليها، والمؤشّرات قابلة للاستخدام سواءٌ مع الأنواع الأوليّة (المٌضمّنة) أو الأنواع التي يعرّفها المستخدم. تتضمن البرامج المكتوبة بلغة جو عادةً دوالًا وتوابعًا Methods نمرر لها بيانات مثل وسطاء، ونحتاج أحيانًا إلى إنشاء نسخة محلية من تلك البيانات بحيث تظل النسخة الأصلية من البيانات دون تغيير. مثلًا، لو كان لديك برنامج يمثّل مصرفًا bank، وتريد أن يُظهر هذا البرنامج التغيرات التي تطرأ على رصيد المستخدم تبعًا للخطة التوفيرية التي يختارها، فهنا قد تحتاج إلى بناء دالة تنجز هذا الأمر من خلال تمرير الرصيد الحالي للمستخدم إضافةً إلى الخطة التي يريدها. لا نريد هنا تغيير الرصيد الأساسي وإنما نريد فقط إظهار التعديل الذي سيطرأ على الرصيد، وبالتالي يجب أن نأخذ نسخةً من رصيد المستخدم مثل وسيط للدالة ونعدّل على هذه النسخة. تسمى هذه النسخة نسخةً محلية، وندعو عملية التمرير هذه "التمرير بالقيمة passing by value" لأننا لانرسل المتغير نفسه وإنما قيمته فقط. هناك حالات أخرى قد تحتاج فيها إلى تعديل البيانات الأصلية؛ بمعنى آخر قد نحتاج إلى تغيير قيمة المتغير الأصلي مباشرةً من خلال الدالة، فمثلًا، عندما يودع المستخدم رصيدًا إضافيًّا في حسابه، فهنا تحتاج إلى جعل الدالة قادرةً على تعديل قيمة الرصيد الأصلي وليس نسخةً منه (نحن نريد إضافة مال إلى رصيده السابق). ليس ضروريًا هنا تمرير البيانات الفعلية إلى الدالة، إذ يمكنك ببساطة إخبار الدالة بالمكان الذي توجد به البيانات في الذاكرة من خلال "مؤشر" يحمل عنوان البيانات الموجودة في الذاكرة. لا يحمل المؤشر القيمة، وإنما فقط عنوان أو مكان وجود القيمة، وتتمكن الدالة من خلال هذا المؤشر من التعديل على البيانات الأصلية مباشرةً. يسمى هذا "التمرير بالمرجع passing by reference"، لأن قيمة المتغير لا تُمرّر إلى الدالة، بل إلى موقعها فقط. سننشئ في هذا المقال المؤشرات ونستخدمها لمشاركة الوصول إلى الذاكرة المُخصصة لمتغير ما. تعريف واستخدام المؤشرات يوجد عنصرا صيغة مختلفان يختصان باستخدام مؤشّر لمتغيرٍ variable ما، وهما: معامل العنونة Address-of operator وهو "&" الذي يعيد عنوان المتغيّر الذي يوضع أمامه في الذاكرة، ومعامل التحصيل Dereference، وهو "*" الذي يعيد قيمة المتغير الموجود في العنوان المحدّد بواسطة عامله. ملاحظة: يُستخدم رمز النجمة * أيضًا للتصريح عن مؤشّر لمجرد التوضيح بأنه مؤشر، ولا ينبغي أن تخلط بينه وبين عامل التحصيل الذي يُستخدم للحصول على القيمة الموجودة في عنوان محدّد، فهما شيئان مختلفان مُمثّلان بنفس الرمز. مثال: var myPointer *int32 = &someint صرّحنا هنا عن متغير يُسمّى myPointer يُمثّل مؤشرًا لمتغير من نوع العدد الصحيح int32، وهيأنا المؤشر بعنوان someint، فالمؤشر هنا يحمل عنوان المتغير int32 وليس قيمته. دعنا نلقي الآن نظرةً على مؤشر لسلسلة، إذ تُصرّح الشيفرة التالية عن متغير يُمثّل سلسلة ومتغير آخر يُمثّل مؤشرًا على تلك السلسلة: package main import "fmt" func main() { var creature string = "shark" var pointer *string = &creature fmt.Println("creature =", creature) fmt.Println("pointer =", pointer) } شغّل البرنامج بالأمر التالي: $ go run main.go سيطبع البرنامج عند تشغيله قيمة المتغير إضافةً إلى عنوان تخزين المتغير (عنوان المؤشر). عنوان الذاكرة هو سلسلة أرقام مكتوبة بنظام العد السداسي عشري لأسباب عتادية وبرمجية لا تهمنا الآن، وطبعنا القيم هنا للتوضيح فقط. ستلاحظ تغيُّر العنوان المطبوع في كل مرة تُشغّل فيها البرنامج، لأنه يُهيّأ من جديد وتأخذ فيه المتغيرات أماكن غير محددة في الذاكرة؛ فكل برنامج ينشئ مساحته الخاصة من الذاكرة عند تشغيله. إذًا، سيكون خرج الشيفرة السابقة مختلفًا لديك عند تشغيله: creature = shark pointer = 0xc0000721e0 عرّفنا المتغير الأول creature من النوع string وهيّأناه بالقيمة shark. أنشأنا أيضًا متغيرًا يُسمّى pointer يُمثّل مؤشرًا على عنوان متغير سلسلة نصية، أي يحمل عنوان متغير نوعه string، وهيّأناه بعنوان السلسلة النصية المُمثّلة بالمتغير creature وذلك من خلال وضع المعامل "&" قبل اسمه. إذًا، سيحمل pointer عنوان الذاكرة التي يوجد بها creature وليس قيمته. هذا هو السبب وراء الحصول على القيمة 0xc0000721e0 عندما طبعنا قيمة المؤشر، وهو عنوان مكان تخزين متغير creature حاليًا في ذاكرة الحاسب. يمكنك الوصول إلى قيمة المتغير مباشرةً من خلال نفس المؤشر باستخدام معامل التحصيل "*" كما يلي: package main import "fmt" func main() { var creature string = "shark" var pointer *string = &creature fmt.Println("creature =", creature) fmt.Println("pointer =", pointer) fmt.Println("*pointer =", *pointer) } ويكون الخرج على النحو التالي: creature = shark pointer = 0xc000010200 *pointer = shark يُمثّل السطر الإضافي المطبوع ماتحدّثنا عنه (الوصول لقيمة المتغير من خلال المؤشر). نسمي ذلك "التحصيل" إشارةً إلى الوصول لقيمة المتغير من خلال عنوانه. يمكننا استخدام هذه الخاصية أيضًا في تعديل قيمة المتغير المُشار إليه: package main import "fmt" func main() { var creature string = "shark" var pointer *string = &creature fmt.Println("creature =", creature) fmt.Println("pointer =", pointer) fmt.Println("*pointer =", *pointer) *pointer = "jellyfish" fmt.Println("*pointer =", *pointer) } وسيكون الخرج على النحو التالي: creature = shark pointer = 0xc000094040 *pointer = shark *pointer = jellyfish لاحظ أننا في السطر "pointer = "jellyfish* وضعنا معامل التحصيل * قبل المؤشر للإشارة إلى أننا نريد تعديل القيمة التي يُشير إلى عنوانها المؤشر. أسندنا القيمة "jellyfish" إلى موقع الذاكرة التي يُِشير لها pointer، وهذا يُكافئ تعديل قيمة المتغير creature. لاحظ أنه عند طباعة القيمة التي يُشير لها المؤشر سنحصل على القيمة الجديدة. كما ذكرنا؛ فهذا يُكافئ تعديل قيمة المتغير creature، وبالتالي لو حاولنا طباعة قيمة المتغير creature سنحصل على القيمة "jellyfish" لأننا نُعدّل على الموقع الذاكري نفسه. سنضيف الآن سطرًا يطبع قيمة المتغير creature إلى الشيفرة السابقة: package main import "fmt" func main() { var creature string = "shark" var pointer *string = &creature fmt.Println("creature =", creature) fmt.Println("pointer =", pointer) fmt.Println("*pointer =", *pointer) *pointer = "jellyfish" fmt.Println("*pointer =", *pointer) fmt.Println("creature =", creature) } وسيكون الخرج كما يلي: creature = shark pointer = 0xc000010200 *pointer = shark *pointer = jellyfish creature = jellyfish يهدف كل ما تعلمته حتى الآن إلى توضيح فكرة المؤشرات في لغة جو وليس حالات الاستخدام الشائعة لها، فهي تُستخدم غالبًا عند تعريف وسطاء الدوال والقيم المُعادة منها أو عند تعريف التوابع مع أنواع مخصصة. دعنا الآن نلقي نظرةً على كيفية استخدام المؤشرات مع الدوال لمشاركة الوصول إلى المتغيرات. ضع في الحسبان أننا نطبع قيمة المؤشر لتوضيح أنه مؤشر، فلن تستخدم عمليًا قيمة المؤشر إلا للإشارة إلى القيمة الأساسية لاستردادها أو تعديلها. مستقبلات مؤشرات الدوال عند كتابة دالة، يمكنك تعريف بعض الوسطاء لكي تُمررهم لها إما بالقيمة أو بالمرجع؛ فعندما تُمرر وسيطًا ما بالقيمة، فهذا يعني أنك تُرسل نسخةً مُستقلة من قيمة هذا الوسيط إلى الدالة، وبالتالي فإن أي تغيير يحدث لهذه النسخة لن يؤثر على النسخة الأساسية من البيانات، لأن كل التعديلات ستجري على نُسخة من البيانات؛ أما عندما تُمرر وسيطًا بالمرجع، فهذا يعني أنك تُرسل مؤشرًا يحمل عنوان ذلك الوسيط -أي مكان تواجد البيانات في الذاكرة- إلى الدالة، وبالتالي أصبح لديك القدرة على الوصول إلى البيانات الأصلية من داخل الدالة والتعديل عليها مباشرةً. يمكنك الاطلاع على المقالة التالية إذا أردت معرفة المزيد عن الدوال وطرق استدعائها في لغة جو. يمكنك طبعًا تمرير أي وسيط بالطريقة التي تُريدها (بالقيمة أو بالمرجع)، فهذا يعتمد على ما تحتاجه؛ فإذا كنت تريد أن تُعدّل الدالة على البيانات الأصلية نُمرر الوسيط بالمرجع وإلا بالقيمة. لمعرفة الفرق بدقة، دعنا أولًا نلقي نظرة على دالة تمرّر وسيطًا بالقيمة: package main import "fmt" type Creature struct { Species string } func main() { var creature Creature = Creature{Species: "shark"} fmt.Printf("1) %+v\n", creature) changeCreature(creature) fmt.Printf("3) %+v\n", creature) } func changeCreature(creature Creature) { creature.Species = "jellyfish" fmt.Printf("2) %+v\n", creature) } ويكون الخرج على النحو التالي: 1) {Species:shark} 2) {Species:jellyfish} 3) {Species:shark} أنشأنا بدايةً نوع بيانات مخصص أسميناه Creature، يحتوي على حقل واحد يُسمى Species من نوع سلسلة نصية string، وأنشأنا داخل الدالة الرئيسية main متغير من النوع Creature اسمه creature وأسندنا السلسلة shark إلى الحقل Species. بعد ذلك طبعنا المتغير creature لإظهار القيمة التي يتضمنها في الوقت الحالي، ثم مرّرنا المتغير creature (تمرير بالقيمة أي نُسخة) إلى الدالة changeCreature والتي بدورها تطبع قيمة المتغير المُمرر لها بعد إسناد السلسلة "jellyfish" إلى الحقل Species (هنا نطبعه من داخل الدالة أي محليًّا). بعد ذلك طبعنا قيمة المتغير creature مرةً أخرى (خارج الدالة السابقة). لاحظ أنه يوجد لدينا ثلاث تعليمات طباعة؛ جرى السطر الأول والثالث من الخرج ضمن نطاق الدالة main بينما كان السطر الثاني ضمن نطاق الدالة changeCreature. لاحظ أيضًا أنه في البداية كانت قيمة المتغير creature هي "shark" وبالتالي عند تنفيذ تعليمة الطباعة الأولى سيطبع: (1) {Species:shark} أما تعليمة الطباعة في السطر الثاني والموجودة ضمن نطاق الدالة changeCreature، فسنلاحظ أنها ستطبع القيمة: (2) {Species:jellyfish} لأننا عدلنا قيمة المتغير، أما في التعليمة الثالثة فقد يُخطئ البعض ويعتقد أنها ستطبع نفس القيمة التي طبعتها تعليمة السطر الثاني، لكن هذا لا يحدث لأن التعديل بقي محليًّا ضمن نطاق الدالة changeCreature، أي حدث التعديل على نسخة من المتغير creature وبالتالي لا ينتقل التعديل إلى المتغير الأساسي. إذًا سيكون خرج تعليمات الطباعة للسطرين الأول والثالث متطابق. سنأخذ الآن نفس المثال، لكن سنغيّر عملية التمرير إلى الدالة changeCreature لتصبح تمرير بالمرجع، وذلك من خلال تغيير النوع من creature إلى مؤشر باستخدام المعامل "*"، فبدلًا من تمرير creature، سنمرّر الآن مؤشرًا إلى creature أو creature*. كان creature في المثال السابق من النوع struct ويحتوي قيمة الحقل Species وهي "shark"، أما creature* فهو مؤشر وليس struct، وبالتالي قيمته هي موقع الذاكرة وهذا ما مرّرناه إلى الدالة ()changeCreature. لاحظ أننا نضع المعامل "&" عند تمرير المتغير creature إلى الدالة. package main import "fmt" type Creature struct { Species string } func main() { var creature Creature = Creature{Species: "shark"} fmt.Printf("1) %+v\n", creature) changeCreature(&creature) fmt.Printf("3) %+v\n", creature) } func changeCreature(creature *Creature) { creature.Species = "jellyfish" fmt.Printf("2) %+v\n", creature) } ستحصل عند تنفيذ الشيفرة السابقة على الخرج التالي: 1) {Species:shark} 2) &{Species:jellyfish} 3) {Species:jellyfish} قد تبدو الأمور واضحة الآن، فعندما مرّرنا المتغير creature إلى الدالة changeCreature، كان التمرير بالمرجع، وبالتالي أي تغيير يطرأ على المتغير creature (وهو تغيير قيمة الحقل Species إلى "jellyfish") داخل هذه الدالة، سيكون مُطبّقًا على المتغير الأصلي نفسه الموجود ضمن الدالة main لأننا نُعدّل على نفس الموقع في الذاكرة، وبالتالي ستكون قيمة الخرج لتعليمات الطباعة 2 و 3 مُتطابقة. قد لا يكون لدينا في بعض الأحيان قيمة مُعرّفة للمؤشر، وهذا قد يحدث لأسباب كثيرة منها ما هو متوقع ومنها لا، وبالتالي قد يسبب لك حالات هلع panic في البرنامج. دعنا نلقي نظرةً على كيفية حدوث ذلك وكيفية التخطيط لتلك المشكلة المحتملة. التأشير إلى اللاشيء Nil القيمة الافتراضية لجميع المتغيرات في لغة جو هي الصفر، وهذا الكلام ينطبق أيضًا على المؤشرات. لدى التصريح عن مؤشر بنوعٍ ما ولكن دون أي قيمة مُسندة، ستكون القيمة الصفرية الافتراضية هي nil. الصفر هنا مفهوم متعلق بالنوع، أي أنه في حالة الأعداد الصحيحة هو العدد 0، وفي حالة السلاسل النصية هو السلسلة الفارغة ""، وأخيرًا في حالة المؤشرات هو القيمة nil إشارةً إلى الحالة الافتراضية لقيمة أي مؤشر. سنُعدّل في البرنامج التالي على البرنامج السابق، بحيث نعرّف مؤشرًا متغيرًا creature من النوع Creature، لكن دون استنساخ للنسخة الحقيقية من Creature ودون إسناد عنوانها إلى المؤشر؛ أي أن قيمة المؤشر هي nil، ولن نستطيع الرجوع إلى أي من الحقول أو التوابع المُعرّفة في النوع Creature. لنرى ماذا سيحدث: package main import "fmt" type Creature struct { Species string } func main() { var creature *Creature fmt.Printf("1) %+v\n", creature) changeCreature(creature) fmt.Printf("3) %+v\n", creature) } func changeCreature(creature *Creature) { creature.Species = "jellyfish" fmt.Printf("2) %+v\n", creature) } سيكون الخرج على النحو التالي: 1) <nil> panic: runtime error: invalid memory address or nil pointer dereference [signal SIGSEGV: segmentation violation code=0x1 addr=0x8 pc=0x109ac86] goroutine 1 [running]: main.changeCreature(0x0) /Users/corylanou/projects/learn/src/github.com/gopherguides/learn/_training/digital-ocean/pointers/src/nil.go:18 +0x26 main.main() /Users/corylanou/projects/learn/src/github.com/gopherguides/learn/_training/digital-ocean/pointers/src/nil.go:13 +0x98 exit status 2 نلاحظ عند تشغيل البرنامج أن تعليمة الطباعة الأولى 1 نجحت وطبعت قيمة المتغير creature وهي <nil>، لكن عندما وصلنا إلى استدعاء الدالة changeCreature ومحاولة ضبط قيمة الحقل Species، ظهرت حالة هلع في البرنامج نظرًا لعدم إنشاء نسخة من هذا المتغيّر، وأدى هذا إلى محاولة الوصول إلى موقع ذاكري غير موجود أصلًا أو غير مُحدد. هذا الأمر شائع في لغة جو، لذلك عندما تتلقى وسيطًا مثل مؤشر، لا بُد من فحصه إذا كان فارغًا أم لا قبل إجراء أي عمليات عليه، لتجنب حالات هلع كهذه. نتحقق عادةً من قيمة المؤشر في أي دالة تستقبل مؤشرًا مثل وسيط لها كما يلي: if someVariable == nil { // هنا يمكن أن نطبع أي رسالة تُشير إلى هذه الحالة أو أن نخرج من الدالة } يمكنك بذلك التحقق مما إذا كان الوسيط يحمل قيمةً صفريةً أم لا. عند تمرير قيمة صفرية (هنا نقصد nil) قد ترغب في الخروج من الدالة باستخدام تعليمة return أو إعادة رسالة خطأ تخبر المستخدم أن الوسيط الممرّر إلى الدالة أو التابع غير صالح. تتحقق الشيفرة التالية من وجود قيمة صفرية للمؤشر: package main import "fmt" type Creature struct { Species string } func main() { var creature *Creature fmt.Printf("1) %+v\n", creature) changeCreature(creature) fmt.Printf("3) %+v\n", creature) } func changeCreature(creature *Creature) { if creature == nil { fmt.Println("creature is nil") return } creature.Species = "jellyfish" fmt.Printf("2) %+v\n", creature) } أضفنا إلى الدالة changeCreature تعليمات لفحص قيمة الوسيط creature فيما إذا كانت صفرية أم لا؛ ففي حال كانت صفرية نطبع "creature is nil" ونخرج من الدالة من خلال تعليمة return، وإلا نتابع العمل في الدالة ونُعدّل قيمة الحقل Species. سنحصل الآن على المخرجات التالية: 1) <nil> creature is nil 3) <nil> لاحظ أنه على الرغم من وجود حالة صفرية للمتغير، إلا أنه لم تحدث حالة هلع للبرنامج لأننا عالجناها. إذا أنشأنا نسخةً من النوع Creature وأُسندت للمتغير creature، سيتغير الخرج بالتأكيد، لأنه أصبح يُشير إلى موقع ذاكري حقيقي: package main import "fmt" type Creature struct { Species string } func main() { var creature *Creature creature = &Creature{Species: "shark"} fmt.Printf("1) %+v\n", creature) changeCreature(creature) fmt.Printf("3) %+v\n", creature) } func changeCreature(creature *Creature) { if creature == nil { fmt.Println("creature is nil") return } creature.Species = "jellyfish" fmt.Printf("2) %+v\n", creature) } سنحصل على الناتج المتوقع التالي: 1) &{Species:shark} 2) &{Species:jellyfish} 3) &{Species:jellyfish} عندما تتعامل مع المؤشرات، هناك احتمال أن يتعرّض البرنامج لحالة هلع، لذلك يجب عليك التحقق لمعرفة ما إذا كانت قيمة المؤشر صفرية قبل محاولة الوصول إلى أي من الحقول أو التوابع المعرّفة ضمن نوع البيانات الذي يشير إليه. دعنا نلقي نظرةً على كيفية استخدام المؤشرات مع التوابع. مستقبلات مؤشرات التوابع المُستقبل receiver في لغة جو هو الوسيط الذي يُعرّف عند التصريح عن التابع. ألقِ نظرةً على الشيفرة التالية: type Creature struct { Species string } func (c Creature) String() string { return c.Species } المُستقبل في هذا التابع هو c Creature، وهو يُشير إلى أن نسخة المتغير c من النوع Creature وأنك ستستخدمه ليُشير إلى نسخة متغير من هذا النوع. يختلف أيضًا سلوك التوابع كما هو الحال في الدوال التي يختلف فيها سلوك الدالة تبعًا لطريقة تمرير الوسيط (بالمرجع أو بالقيمة). ينبع الاختلاف الأساسي من أنه إذا صرّحت عن دالة مع مُستقبل "قيمة"، فلن تتمكن من إجراء تغييرات على نسخة هذا النوع التي عُرّف التابع عليه. عمومًا، ستكون هناك أوقات تحتاج فيها أن يكون تابعك قادرًا على تحديث نسخة المتغير الذي تستخدمه، ولإجراء هكذا تحديثات ينبغي عليك جعل المستقبل "مؤشرًا". دعنا نضيف التابع Reset للنوع Creature، الذي يسند سلسلةً نصيةً فارغة إلى الحقل Species. package main import "fmt" type Creature struct { Species string } func (c Creature) Reset() { c.Species = "" } func main() { var creature Creature = Creature{Species: "shark"} fmt.Printf("1) %+v\n", creature) creature.Reset() fmt.Printf("2) %+v\n", creature) } إذا شغّلت البرنامج ستحصل على الخرج: 1) {Species:shark} 2) {Species:shark} لاحظ أنه على الرغم من ضبطنا قيمة الحقل Species على السلسلة الفارغة في التابع Reset، إلا أننا عندما طبعنا المتغير creature في الدالة main حصلنا على "shark". السبب في عدم انتقال التغيير هو استخدامنا مُستقبل قيمة في تعريف التابع Reset، وبالتالي سيكون لهذا التابع إمكانية التعديل فقط على نسخة المتغير creature وليس المتغير الأصلي. بالتالي، إذا أردنا تحديث هذه القيمة؛ أي التعديل على النسخة الأصلية للمتغير، فيجب علينا تعريف مُستقبل مؤشر. package main import "fmt" type Creature struct { Species string } func (c *Creature) Reset() { c.Species = "" } func main() { var creature Creature = Creature{Species: "shark"} fmt.Printf("1) %+v\n", creature) creature.Reset() fmt.Printf("2) %+v\n", creature) } لاحظ أننا أضفنا المعامل "*" أمام النوع Creature عندما صرّحنا عن التابع Reset، وهذا يعني أن الوسيط الذي نُمرره أصبح مؤشرًا، وبالتالي أصبحت كل التعديلات التي نُجريها من خلاله مُطبّقةً على المتغير الأصلي. 1) {Species:shark} 2) {Species:} لاحظ أن التابع Reset عدّل قيمة الحقل Species كما توقعنا، وهذا مُماثل لفكرة التمرير بالمرجع أو القيمة في الدوال. خاتمة تؤثر طريقة تمرير الوسطاء (بالمرجع أو القيمة) إلى التوابع أو الدوال على آلية الوصول إلى المتغير المُمرّر؛ ففي حالة التمرير بالمرجع يكون التعديل مباشرةً على المتغير الأصلي، أما في الحالة الثانية فيكون على نسخة من المتغير. الآن بعد أن تعرفت على المؤشرات، أصبح بإمكانك التعرف على استخدامها مع الواجهات أيضًا. ترجمة -وبتصرف- للمقال Understanding Pointers in Go لصاحبه Gopher Guides. المقال السابق: استخدام وسوم البناء لتخصيص الملفات التنفيذية Binaries في لغة جو Go المؤشرات Pointers في لغة سي C المؤشرات Pointers في لغة Cpp
  21. وسم البناء Build tag أو قيد البناء Build constraint هو مُعرّف يُضاف إلى التعليمات البرمجية لتحديد متى يجب تضمين ملف ما في حزمة أثناء عملية البناء build، ويتيح لك إمكانية بناء إصدارات مُختلفة لتطبيقك من نفس التعليمات البرمجية المصدرية والتبديل بينها بطريقة سريعة ومنظمة. يستخدم العديد من المطورين وسوم البناء لتحسين سير العمل Workflow عند بناء تطبيقات متوافقة مع جميع أنظمة تشغيل الأساسية Cross-platform، مثل البرامج التي تتطلب تغييرات في التعليمات البرمجية لمراعاة الفروقات بين أنظمة التشغيل المختلفة. تُستخدم وسوم البناء أيضًا من أجل اختبار التكامل Integration testing، مما يسمح لك بالتبديل بسرعة بين الشيفرة المتكاملة والشيفرة باستخدام خادم زائف Mock server أو شيفرة اختبارية بديلة Stub، وبين المستويات المختلفة لمجموعات الميزات التي يتضمنها تطبيقك. لنأخذ مثلًا، مشكلة اختلاف مجموعات الميزات التي تُمنح للعملاء، فعند كتابة بعض التطبيقات، قد ترغب في التحكم بالمميزات التي يجب تضمينها في الثنائي binary، مثل التطبيق الذي يوفّر مستويات مجانية واحترافية Pro ومتقدمة Enterprise. كلما رفع العميل من مستوى اشتراكه في هذه التطبيقات، توفّرت له المزيد من الميزات وأصبحت غير مقفلة. يمكنك حل هذه المشكلة من خلال الاحتفاظ بمشاريع منفصلة ومحاولة إبقائها متزامنةً مع بعضها بعضًا من خلال استخدام تعليمات الاستيراد import، وعلى الرغم من أن هذا النهج سيعمل، لكنه سيصبح مملًا بمرور الوقت وعرضةً للخطأ، وقد يكون النهج البديل هو استخدام وسوم البناء. ستستخدم في هذه المقالة وسوم البناء في لغة جو، لإنشاء ملفات تنفيذية مختلفة تُقدم مجموعات ميزات مجانية واحترافية ومتقدمة لتطبيقك. سيكون لكل من هذه الملفات التنفيذية مجموعةٌ مختلفةٌ من الميزات المتاحة، إضافةً للإصدار المجاني، الذي هو الخيار الافتراضي. ملاحظات: التوافق مع أنظمة التشغيل الأساسية Cross-platform: ‏ هو مصطلح يستخدم في علم الحوسبة يشير إلى برامج الحاسوب أو أنظمة التشغيل أو لغات الحاسوب أو لغات البرمجة وتطبيقاتها التي يمكنها العمل على عدة منصات حاسوبية. هناك نوعان رئيسيان من البرمجيات المتوافقة مع أنظمة التشغيل الأساسية، إذ يستلزم الأول بناءه لكل منصة يمكنه العمل عليها، والثاني يمكنه العمل مباشرةً على أي منصة تدعمه. اختبار التكامل Integration testing: يمثّل مرحلة اختبار البرامج التي تتكامل فيها الوحدات البرمجية وتُختبر مثل وحدة واحدة متكاملة. يُجرى اختبار التكامل لتقييم مدى امتثال نظام أو مكون برمجي لمتطلبات وظيفية محددة، وغالبًا ما تكون هذه المتطلبات مدونة في توثيق الخاصيات والمتطلبات. خادم زائف Mock server: هو إطار عمل يهدف إلى تبسيط اختبار التكامل. تعتمد هذه الأُطر على مفهوم الكائنات الزائفة، وهي كائنات محاكاة تحاكي سلوك الكائنات الحقيقية بطرق خاضعة للرقابة، وتكون غالبًا بمثابة جزء من عملية اختبار البرنامج. يُنشئ المبرمج عادةً كائنًا زائفًا لاختبار سلوك بعض الأشياء الأخرى، بنفس الطريقة التي يستخدم بها مصمم السيارة دمية اختبار التصادم لمحاكاة السلوك الديناميكي للإنسان في اصطدام السيارة. هذه التقنية قابلة للتطبيق أيضًا في البرمجة العامة. شيفرة اختبارية بديلة Stub: برنامج صغير يُستبدل ببرنامج أطول، ربما يُحمّل لاحقًا، أو يكون موجودًا عن بُعد في مكان ما، إذ يكون بديلًا مؤقتًا للشيفرة التي لم تُطوّر بعد، وهذه الشيفرة مفيدة جدًا في نقل البيانات والحوسبة الموزعة وكذلك تطوير البرمجيات واختبارها عمومًا. المتطلبات الأساسية أن يكون لديك مساحة عمل خاصة في لغة جو، وإذا لم يكن لديك مساحة عمل، اتبع سلسلة المقالات التالية: تثبيت لغة جو Go وإعداد بيئة برمجة محلية على أبونتو Ubuntu، وتثبيت لغة جو وإعداد بيئة برمجة محلية على نظام ماك macOS، وتثبيت لغة جو وإعداد بيئة برمجة محلية على ويندوز. بناء النسخة المجانية سنبدأ ببناء الإصدار المجاني من التطبيق، إذ سيكون هو الإصدار الافتراضي عند تنفيذ الأمر go build دون أي وسوم بناء. سنستخدم لاحقًا وسوم البناء لإضافة أجزاء أخرى إلى برنامجنا. أنشئ مجلدًا باسم التطبيق الخاص بك في مجلد src، وسنستخدم هنا الاسم "app": $ mkdir app انتقل إلى المجلد "app" الذي أنشأته: $ cd app أنشئ الآن ملف "main.go" داخل مجلد المشروع. استخدمنا هنا محرر النصوص نانو nano لفتح وإنشاء الملف: $ nano main.go سنعرّف الآن الإصدار المجاني من التطبيق. انسخ المحتويات التالية إلى ملف main.go: package main import "fmt" var features = []string{ "Free Feature #1", "Free Feature #2", } func main() { for _, f := range features { fmt.Println(">", f) } } أنشأنا برنامجًا يُصرّح عن شريحة Slice باسم features، تحتوي على سلسلتين نصيتين strings تمثلان ميزات إصدار تطبيقنا المجاني. تستخدم الدالة ()main حلقة for لتنتقل عبر عناصر شريحة الميزات من أجل طباعة جميع الميزات المتاحة على الشاشة. احفظ الملف واخرج منه، وبعد حفظ الملف لن نضطر إلى تحريره مرةً أخرى خلال هذا المقال، إذ سنستخدم وسوم البناء لتغيير ميزات الثنائيات التي سنبنيها منها. اِبنِ وشغّل البرنامج: $ go build $ ./app ستحصل على الخرج التالي: > Free Feature #1 > Free Feature #2 طبع البرنامج ميزتين مجانيتين تكملان ميزات الإصدار المجاني من تطبيقنا. أكملنا الآن الإصدار المجاني المُتمثّل بتطبيق يحتوي على مجموعة ميزات أساسية جدًا. سنبني بعد ذلك شيفرة تمكننا من إضافة مزيدٍ من الميزات إلى التطبيق عند البناء. إضافة ميزات احترافية باستخدام go build تجنّبنا حتى الآن إجراء تغييرات على الملف main.go، وذلك لمحاكاة بيئة إنتاج عامة ينبغي فيها إضافة الشيفرة دون تغيير الشيفرة الرئيسية أو كسرها. ونظرًا لإمكانية تعديل ملف main.go، سنحتاج إلى استخدام آلية أخرى لإدخال المزيد من الميزات إلى شريحة features باستخدام وسوم البناء. سننشئ ملفًا جديدًا باسم "pro.go"، والذي سيستخدم الدالة ()init لإضافة المزيد من الميزات إلى شريحة features: $ nano pro.go أضف المحتويات التالية إلى الملف بعد فتحه: package main func init() { features = append(features, "Pro Feature #1", "Pro Feature #2", ) } استخدمنا الدالة ()init لتشغيل الشيفرة قبل الدالة ()main في التطبيق، ثم استخدمنا الدالة ()append لإضافة ميزات احترافية إلى شريحة features. احفظ الملف واخرج منه، ثم صرّف التطبيق وشغّله باستخدام الأمر التالي: $ go build نظرًا لوجود ملفين الآن في المجلد الحالي، هما "pro.go" و "main.go"، سينشئ الأمر go build ملفًا ثنائيًا من كليهما: $ ./app ستحصل على الخرج التالي: > Free Feature #1 > Free Feature #2 > Pro Feature #1 > Pro Feature #2 يتضمن التطبيق الآن كلًا من الميزات الاحترافية والمجانية، وهذا غير مرغوب بالوضع الحالي للتطبيق؛ فلا يوجد تمييز بين الإصدارات، إذ يتضمن الإصدار المجاني الميزات التي من المفترض أن تكون متوفرة فقط في الإصدار الاحترافي. يمكنك حل المشكلة عن طريق إضافة المزيد من التعليمات البرمجية لإدارة المستويات المختلفة للتطبيق، أو استخدام وسوم البناء لإخبار أدوات جو عن الملفات التي تكون بامتداد "go."،التي يجب بناؤها وتلك التي يجب تجاهلها. سنضيف في الخطوة التالية وسوم البناء. إضافة وسوم البناء يمكنك الآن استخدام وسوم البناء لتمييز الإصدار الاحترافي عن المجاني. يكون شكل الوسم كما يلي: // +build tag_name من خلال وضع هذا السطر البرمجي في بداية الحزمة الخاصة بك (في أول سطر) وتبديل tag_name إلى اسم وسم البناء الذي تريده، ستُوسّم هذه الحزمة لتصبح شيفرةً يمكن تضمينها اختياريًّا في الثنائي النهائي. دعنا نرى هذا عمليًا عن طريق إضافة وسم البناء إلى ملف pro.go لإخبار الأمر go build بتجاهلها ما لم يُحدّد الوسم. افتح الملف في محرر النصوص الخاص بك: $ nano pro.go ضِف مايلي: // +build pro package main func init() { features = append(features, "Pro Feature #1", "Pro Feature #2", ) } أضفنا في بداية الملف pro.go السطر ‎// +build proمتبوعًا بسطر جديد فارغ؛ وهذا السطر الجديد ضروري، وبدونه سيفسّر جو السطر السابق على أنه تعليق. يجب أن تكون تصريحات وسوم البناء أيضًا في أعلى الملف ذي الامتداد "go." دومًا، لا تضع أي شيء، ولا حتى التعليقات قبلها. يُخبِر التصريح build+ الأمر go build أن هذا ليس تعليقًا، بل هو وسم بناء. الجزء الثاني هو الوسم pro، فبإضافة هذا الوسم في الجزء العلوي من ملف pro.go، سيُضمّن الأمر go build ملف pro.go في حال وجود الوسم pro فقط. صرّف التطبيق الآن وشغّله: $ go build $ ./app ستحصل على الخرج التالي: > Free Feature #1 > Free Feature #2 بما أن ملف pro.go يتطلب وجود الوسم pro، سيجري تجاهل الملف وسيُصرّف التطبيق دونه. عند استخدام الأمر go build، يمكننا استخدام الراية tags- لتضمين شيفرة محددة لكي تُصرّف مع التطبيق عن طريق إضافة وسم الشيفرة مثل وسيط. سنجرّب ذلك مع الوسم pro: $ go build -tags pro ستحصل على الخرج التالي: > Free Feature #1 > Free Feature #2 > Pro Feature #1 > Pro Feature #2 سنحصل الآن على الميزات الاحترافية فقط إذا أضفنا الوسم pro. هذا جيد إذا كان هناك إصدارين فقط، ولكن الأمور تصبح معقدةً عند وجود مزيدٍ من الإصدارات وإضافة مزيدٍ من الوسوم. سنستخدم في الخطوة التالية وسوم بناء متعددة مع منطق بولياني Boolean logic، لإضافة إصدار مُتقدم. استخدام المنطق البولياني مع وسوم البناء عندما تكون هناك وسوم بناء متعددة في حزمة جو، تتفاعل الوسوم مع بعضها بعضًا باستخدام المنطق البولياني. لتوضيح ذلك، سنضيف المستوى "مُتقدم Enterprise" لتطبيقنا باستخدام الوسم pro والوسم enterprise. سنحتاج من أجل بناء ثنائي للإصدار المتقدم إلى تضمين كل من الميزات الافتراضية والميزات الاحترافية، ومجموعة جديدة من الميزات الخاصة بالإصدار المتقدم. افتح محررًا وأنشئ ملفًا جديدًا enterprise.go، لنضع فيه الميزات الجديدة: $ nano enterprise.go ستبدو محتويات enterprise.go متطابقة تقريبًا مع pro.go ولكنها ستحتوي على ميزات جديدة. ضِف الأسطر التالية إلى الملف "enterprise.go": package main func init() { features = append(features, "Enterprise Feature #1", "Enterprise Feature #2", ) } احفظ الملف واخرج. لا يحتوي ملف enterprise.go حاليًا على أي وسوم بناء، وكما تعلّمت عندما أضفت pro.go، فهذا يعني أن هذه الميزات ستضاف إلى الإصدار المجاني عند تنفيذ go.build. بالنسبة إلى pro.go، أضفت build pro+ \\ متبوعًا بسطر فارغ في أعلى الملف لإخبار الأمر go build أنه يجب تضمينه فقط عند استخدام tags pro-. تحتاج في هذه الحالة فقط إلى وسم بناء واحد لتحقيق الهدف، لكن عند إضافة الميزات الجديدة المتقدمة، إذ يجب أن يكون لديك أولًا ميزات احترافية Pro. سنجعل الآن ملف enterprise.go يدعم وسم البناء pro. افتح الملف باستخدام محرر النصوص الخاص بك: $ nano enterprise.go ضِف بعد ذلك وسم البناء قبل سطر التصريح package main وتأكد من إضافة سطر فارغ كما تحدثنا: // +build pro package main func init() { features = append(features, "Enterprise Feature #1", "Enterprise Feature #2", ) } احفظ الملف واغلقه، ثم صرّف التطبيق دون إضافة أي وسوم (الإصدار المجاني): $ go build $ ./app ستحصل على الخرج التالي: > Free Feature #1 > Free Feature #2 لاحظ أن الميزات المتقدمة لا تظهر في الإصدار المجاني. دعنا الآن نضيف وسم الإصدار المحترف pro ونبني التطبيق ونشغّله مرةً أخرى: $ go build -tags pro $ ./app ستحصل على الخرج التالي: > Free Feature #1 > Free Feature #2 > Enterprise Feature #1 > Enterprise Feature #2 > Pro Feature #1 > Pro Feature #2 ليس هذا فعلًا ما نحتاجه حتى الآن، لأن الميزات المتقدمة تظهر عندما نبني إصدار احترافي، وسنحتاج إلى استخدام وسم بناء آخر لحل هذه المشكلة. بالنسبة للوسم enterprise فهو على عكس الوسم pro، إذ نحتاج هنا إلى التأكد من توفُّر الميزات الاحترافية pro والمتقدمة enterprise في نفس الوقت. يراعي نظام البناء في جو هذا الموقف من خلال السماح باستخدام بعض المنطق البولياني في نظام وسوم البناء. لنفتح enterprise.go مرةً أخرى: $ nano enterprise.go سنضيف الآن وسمًا إلى هذا الملف باسم enterprise كما فعلنا سابقًا عند إضافة الوسم pro: // +build pro enterprise package main func init() { features = append(features, "Enterprise Feature #1", "Enterprise Feature #2", ) } احفظ الملف واخرج، ثم صرّف التطبيق مع إضافة الوسم enterprise: $ go build -tags enterprise $ ./app سيكون الخرج على النحو التالي: > Free Feature #1 > Free Feature #2 > Enterprise Feature #1 > Enterprise Feature #2 لاحظ أننا خسرنا ميزات الإصدار الاحترافي، وسبب ذلك هو أنه عندما نضع عدة وسوم بناء ضمن نفس السطر في ملف "go."، سيفسر go build أن العلاقة بينهما هي OR المنطقية. إذًا، بإضافة السطر build pro enterprise+ \\ سيُبنى الملف enterprise.go في حال وجود إحدى الوسمين pro أو enterprise. نحن الآن بحاجة إلى كتابة وسوم البناء بطريقة صحيحة مع استخدام المنطق AND، ولفعل ذلك سنكتب كلًا من الوسمين في سطر منفصل، وبهذا الشكل سيفسّر go build على أن العلاقة بينهما AND. افتح الملف enterprise.go مرةً أخرى وافصل وسوم البناء في أسطر منفصلة: // +build pro // +build enterprise package main func init() { features = append(features, "Enterprise Feature #1", "Enterprise Feature #2", ) } الآن، صرّف تطبيقك مع الوسم enterprise: $ go build -tags enterprise $ ./app ستحصل على الخرج التالي: > Free Feature #1 > Free Feature #2 ما زال هذا غير كافي لأن منطق AND يتطلب أن يكون العنصران محققين "true"؛ إذًا نحتاج إلى وجود وسمي البناء pro و enterprise: $ go build -tags "enterprise pro" $ ./app ستحصل على الخرج التالي: > Free Feature #1 > Free Feature #2 > Enterprise Feature #1 > Enterprise Feature #2 > Pro Feature #1 > Pro Feature #2 يمكنك الآن بناء التطبيق من نفس شجرة المصدر بعدة طرق مختلفة وفقًا للميزات التي تريد توفيرها. استخدمنا في هذا المثال وسم بناء جديد باستخدام وسم build+ // للدلالة على منطق AND، ولكن هناك طرق بديلة لتمثيل المنطق البولياني باستخدام وسوم البناء. يحتوي الجدول التالي على بعض الأمثلة على التنسيقات النحوية الأخرى لوسوم البناء، جنبًا إلى جنب مع مكافئها المنطقي: قاعدة وسم البناء عينة عن الوسم التعليمة المنطقية عناصر مفصولة بفراغ build pro enterprise+ \\ محترف أو متقدم pro OR enterprise -------- -------- -------- عناصر مفصولة بفاصلة build pro,enterprise+ \\ محترف ومتقدم pro AND enterprise -------- -------- -------- عناصر مفصولة بعلامة تعجب build !pro+ \\ ليس محترف NOT pro خاتمة تعرّفت في هذا المقال على كيفية استخدام وسوم البناء للتحكم في الملفات التي ستُصرّف إلى ملفات ثنائية في جو؛ إذ صرّحنا أولًا عن وسم البناء، ثم استخدمناها في go build ثم دمجنا عدة وسوم باستخدام المنطق البولياني. أخيرًا بنينا برنامجًا يتضمن مجموعات ميزات مختلفة كل منها يمثل إصدارًا (مجاني، احترافي، متقدم)، والذي أظهر قوة وسوم البناء التي تمثّل أداةً قوية جدًا بالتحكم بإصدارات المشروع. ترجمة -وبتصرف- للمقال Customizing Go Binaries with Build Tags لصاحبه Gopher Guides. اقرأ أيضًا المقال السابق: تعرف على دالة التهيئة init واستخدامها في لغة جو Go العمليات الحسابية في لغة جو Go مدخل إلى البيانات وأنواعها: أنواع البيانات الأساسية
  22. ستتعلم في هذه المقالة كيفية كتابة برنامج بسيط باستخدام لغة جو، إذ سنحاول اتباع العادة التي جرت عليها دروس تعلم لغات البرمجة، وهي ببساطة كتابة جملة "Hello, World!‎" أي "أهلًا بالعالم!"، لكن سنجعل الأمور أكثر متعةً من خلال جعل البرنامج يسأل المستخدِم عن اسمه لكي يطبع الاسم بجانب عبارة الترحيب، وبعد الانتهاء من كتابة البرنامج سيكون خرج البرنامج مشابهًا للخرج التالي: Please enter your name. Sammy Hello, Sammy! I'm Go! المتطلبات يجب أن تكون قد أعددت بيئة تطوير جو على حاسبك، فإذا لم يكن لديك بيئة تطوير جاهزة، فيمكنك العودة إلى المقالات السابقة واتباع التعليمات: تثبيت لغة جو وإعداد بيئة برمجة محلية على أبونتو. تثبيت لغة جو وإعداد بيئة برمجة محلية على نظام ماك macOS. تثبيت لغة جو وإعداد بيئة برمجة محلية على ويندوز. الخطوة 1 - كتابة برنامج "Hello, World!‎" الأساسي افتح محرر نصوص سطر الأوامر مثل نانو nano وانشئ ملفًا جديدًا: $ nano hello.go اكتب البرنامج التالي بعد فتح الملف النصي في الطرفية: package main import "fmt" func main() { fmt.Println("Hello, World!") } مكونات الشيفرة: الحزمة package: هي كلمة مفتاحية keyword في جو تحدِّد اسم الحزمة التي ينتمي لها هذا الملف، أي الملف الذي تكتب فيه البرنامج، إذ لا بدّ أن يكون لكل ملف حزمة ما، ولا بدّ أن تنتمي كل حزمة في جو إلى مجلد ما، كما لا يمكن لحزمتَين التواجد على مستوى نفس المجلد، لكن يمكن لعدة ملفات أن تنتمي إلى نفس الحزمة (يعني مجلد به عدة ملفات)، كما يمكن أن تتواجد حزمة داخل حزمة أخرى لكن كلٌ في مجلد فرعي على حِدى. في مثالنا السابق، أعطينا main كاسم لحزمتنا، وهو اسم خاص، حيث يُعامل مُصرّف compiler لغة جو هذه الحزمة على أنها مدخل البرنامج، أي أن التعليمات الموجودة في هذه الحزمة يتم تشغيلها أولًا. أسبقنا اسم الحزمة بالكلمة المفتاحية package. import كلمة مفتاحية أخرى وتعني استرِد أو اجلِب المكتبة الفلانية أو الحزمة الفلانية. fmt اختصار format أو formatting وهي مكتبة قياسية standard library تأتي مع جو، وهي خاصة ببناء وطباعة النص. لاحظ أن اسم المكتبات أو الحزم المُراد استيرادها دائما ما يتم إحاطتها بعلامة اقتباس "". في التعبير fmt.Println استعملنا دالة Println من الحزمة fmt التي استوردناها، ولاستعمال أيّ حزمة في جو يكفي كتابة اسمها ثم نقطة ثم اسم الدالة التي تريد استعمالها، وهنا أردنا طباعة نص "Hello, World!‎"، لذا لاحظ أننا مررنا القيمة بين علامتَي اقتباس "" ﻷنها سلسلة نصية string. احفظ الملف البرمجي الذي أنشأته واخرج من المحرر عن طريق كتابة CTRL + X وتأكيد الخروج بالضغط على المفتاح Y عندما يطلب منك، ويمكنك الآن تجربة برنامجك. الخطوة 2 - تشغيل البرنامج يمكنك تشغيل أيّ برنامج مكتوب بلغة جو من خلال كتابة الكلمة المفتاحية go متبوعة باسم الملف، وبالتالي لتشغيل البرنامج السابقة سنكتب: $ go run hello.go الخرج: Hello, World! التفسير: بدايةً يُصرِّف البرنامج قبل تشغيله، أي إنشاء برنامج قابل للتنفيذ يمكن للحاسوب فهمه وتنفيذه، إذ يحوَّل إلى ملف ثنائي، وبالتالي عند استدعاء go run سيُصرَّف الملف البرمجي ثم يُشغل الملف التنفيذي الناتج. غياب حزمة main من برنامج جو يجعل منه مكتبة فقط وليس برنامجًا بحد ذاته (حزمة تنفيذية)، لذا تتطلب برامج جو وجود هذه الحزمة، كما تتطلب وجود دالة ()main واحدة تعمل على أساس نقطة دخول للبرنامج بحيث لا تأخذ هذه الدالة أيّ وسائط ولا تُعيد أيّ قيم. تُنفَّذ الشيفرة أو البرنامج بعد انتهاء عملية التصريف من خلال وضع الدالة ()main في الحزمة main، والتي تتضمن طباعة السلسلة النصية "Hello, World!‎" عن طريق استدعاء دالة fmt.Println أي ("!fmt.Println("Hello, World، وتُدعى السلسلة النصية وسيطًا لتلك الدالة بما أنها تُمرَّر إليها. ملاحظة: لا تُطبَع علامتَا الاقتباس الموجودتان على جانبي Hello, World!‎ على الشاشة لأنك تستخدمهما لإخبار جو من أين تبدأ السلسلة الخاصة بك وأين تنتهي. ستستكشف في الخطوة التالية كيفية جعل البرنامج تفاعليًّا أكثر. الخطوة 3 - إدخال معلومات من المستخدم لاستخدامها في البرنامج سيطبع البرنامج السابق الخرج نفسه في كل مرة تستدعيه بها، كما يمكنك إضافة بعض التعليمات إلى البرنامج بحيث تجعله يطلب من المستخدِم إدخال اسمه لكي يُطبع بجانب عبارة الترحيب. بإمكانك تعديل برنامجك السابق، ولكن يُفضَّل أن تُنشئ برنامجًا جديدًا يسمى greeting.go باستخدام المحرِّر نانو: nano greeting.go أضف التعليمات التالية: package main import ( "fmt" ) func main() { fmt.Println("Please enter your name.") } استخدامنا دالة fmt.Println لطباعة النص على الشاشة كما في المرة السابقة. أضف الآن السطرvar name string لتخزين مُدخلات المستخدِم: package main import ( "fmt" ) func main() { fmt.Println("Please enter your name.") var name string } سيُنشئ السطر السابق متغيرًا جديدًا اسمه name باستخدام الكلمة المفتاحية var، وحددنا نوع هذا المتغير على أنه string، أي سلسلة نصية. أضف الآن السطر fmt.Scanln(&name)‎ إلى الشيفرة: package main import ( "fmt" ) func main() { fmt.Println("Please enter your name.") var name string fmt.Scanln(&name) } يخبر التابع fmt.Scanln الحاسب أن ينتظر إدخالًا من لوحة المفاتيح ينتهي بسطر جديد (الضغط على Enter) أو المحرف n\، إذ توقف عملية الإدخال هذه البرنامج مؤقتًا إلى حين انتهاء المستخدِم من إدخال أيّ نص يريده، ثم يستمر البرنامج عندما يضغط المستخدم على مفتاح الإدخال ENTER من لوحة المفاتيح، إذ تُلتقط بعد ذلك كافة ضغطات المفاتيح بما في ذلك ضغطات المفتاح ENTER وتُحوّل إلى سلسلة من المحارف. بما أن الهدف هو استخدام النص (اسمه) الذي يدخله المستخدِم لوضعه بجانب العبارة الترحيبية التي تظهر في خرج البرنامج، فسينبغي عليك حفظ هذه المحارف عن طريق تخزينها في متغير من النوع string أي في المتغير name، إذ يُخزّن جو هذه السلسلة في ذاكرة الحاسب إلى حين انتهاء تشغيل البرنامج. أخيرًا، أضف السطر fmt.Printf("Hi, %s! I'm Go!", name)‎ إلى برنامجك لطباعة الخرج: package main import ( "fmt" ) func main() { fmt.Println("Please enter your name.") var name string fmt.Scanln(&name) fmt.Printf("Hi, %s! I'm Go!", name) } استخدمنا هذه المرة الدالة fmt.Printf في عملية الطباعة ﻷنها ستسمح لنا بتنسيق عملية الطباعة بالطريقة التي نريدها، إذ تأخذ هذه الدالة وسيطَين الأول هو سلسلة نصية تتضمن العنصر النائب s% (العنصر النائب Placeholder هو متغير يأخذ قيمة في وقتٍ لاحق أي ينوب عن القيمة الحقيقة في البداية)، والثاني هو المُتغير الذي سيُحقن في هذا العنصر النائب، وهذه الدالة تُسمى دالة مرنة، أي تأخذ عددًا غير مُحدد من المعطيات، كما أن المُعطى الأول قد يتضمن أكثر من عنصر نائب لكن هذا لايهمنا الآن، ونفعل ذلك لأن لغة جو لا تدعم تضمين المتغيرات ضمن السلاسل النصية مباشرة كما تفعل لغات أخرى مثل لغة جافاسكربت. احفظ الملف البرمجي الذي أنشأته واخرج من المحرِّر عن طريق الضغط على CTRL + X وتأكيد الخروج بالضغط على المفتاح Y عندما يطلب منك. شغّل البرنامج الآن وستُطالب بإدخال اسمك، لذا أدخله واضغط على ENTER، وقد لا يكون الخرج هو بالضبط ما تتوقعه: Please enter your name. Sammy Hi, Sammy ! I'm Go! بدلًا من طباعة Hi, Sammy! I'm Go!‎، هناك سطر فاصل بعد الاسم مباشرةً، إذ التقط البرنامج جميع ضغطات المفاتيح بما في ذلك المفتاح ENTER الذي ضغطنا عليه لإخبار البرنامج بالمتابعة، لذا تكمن المشكلة في السلسلة، إذ يؤدي الضغط على المفتاح الإدخال ENTER إلى إنشاء رمز خاص يُنشئ سطرًا جديدًا وهو الرمز n\. افتح الملف لإصلاح المشكلة: $ nano greeting.go أضف السطر التالي: ... fmt.Scanln(&name) ... ثم السطر التالي: name = strings.TrimSpace(name) استخدمنا هنا الدالة TrimSpace من الحزمة strings في مكتبة جو القياسية على السلسلة التي التقطتها باستخدام fmt.Scanln، إذ تزيل الدالة strings.TrimSpace محارف المسافة space characters بما في ذلك الأسطر الجديدة من بداية السلسلة ونهايتها، إذًا ستُزيل محرف السطر الجديد في نهاية السلسلة التي أُنشِئت عند الضغط على ENTER. ستحتاج لاستخدام الحزمة strings إلى استيرادها في الجزء العلوي من البرنامج. ضع ما يلي ضمن البرنامج: import ( "fmt" ) أضف السطر التالي لاستيراد الحزمة strings: import ( "fmt" "strings" ) سيكون برنامج الآن كما يلي: package main import ( "fmt" "strings" ) func main() { fmt.Println("Please enter your name.") var name string fmt.Scanln(&name) name = strings.TrimSpace(name) fmt.Printf("Hi, %s! I'm Go!", name) } احفظ الملف البرمجي الذي أنشأته واخرج من المحرِّر عن طريق الضغط على الاختصار CTRL + X وتأكيد الخروج بالضغط على الزر Y عندما يطلب منك. شغّل البرنامج: $ go run greeting.go ستحصل على الناتج المتوقع هذه المرة بعد إدخال اسمك والضغط على مفتاح ENTER: Please enter your name. Sammy Hi, Sammy! I'm Go! أصبح لديك الآن برنامج جو يأخذ مدخلات من المستخدِم ويطبعها على الشاشة. الخاتمة تعلمت في هذه المقالة كيفية كتابة برنامج بسيط يطبع رسالة ترحيبية، كما تعلمت كيفية إنشاء برنامج يأخذ مُدخلات من المستخدِم ويعالجها ويطبع رسالةً تفاعليةً، كما يمكنك الآن التلاعب بهذا البرنامج وتعديل بعض الأمور بداخله مثل أن تجعله يطلب اسم اللون المُفضّل للمستخدِم أو اسم فريقه المُفضل ويطبع تلك المعلومات على الشاشة. ترجمة -وبتصرُّف- للمقال How To Write Your First Program in Go لصاحبه Gopher Guides. اقرأ أيضًا المقال السابق: تثبيت لغة جو وإعداد بيئة برمجة محلية لها على ويندوز الدليل السريع إلى لغة البرمجة Go بناء خادم بروتوكول التحكم في الإرسال TCP متزامن في لغة البرمجة Go
  23. تُستخدَم الدالة المُعرّفة مسبقًا ()init في لغة جو لجعل شيفرة مُحدَّدة تُنفّذ قبل أيّ شيفرة أخرى ضمن الحزمة الخاصة بك، إذ ستُنفَّذ هذه الشيفرة عند استيراد الحزمة مباشرةً، وبالتالي يمكنك استخدام هذه الدالة عندما تحتاج إلى تهيئة تطبيقك في حالة معينة مثل أن يكون لديك إعدادات أوليّة محددة أو مجموعة من الموارد التي يحتاجها تطبيقك لكي يبدأ. تُستخدم أيضًا عند استيراد تأثير جانبي side effect، وهي تقنية تستخدَم لضبط حالة البرنامج من خلال استيراد حزمة معينة، ويُستخدَم هذا غالبًا لتسجيل register حزمة مع أخرى للتأكد من عمل البرنامج بطريقة صحيحة. يُعَدّ استخدام الدالة ()init مفيدًا، ولكنه يجعل من قراءة الشيفرة أمرًا صعبًا في بعض الأحيان وذلك لأنّ نسخة ()init التي يصعُب العثور عليها ستؤثر إلى حد كبير في ترتيب تنفيذ شفرات البرنامج، وبالتالي لا بدّ من فهم هذه الدالة جيدًا لاستخدامها بطريقة صحيحة. سنتعلم في هذا المقال استخدام الدالة ()init لإعداد وتهيئة متغيرات حزمة معيّنة وعمليات حسابية تجرى لمرة واحدة وتسجيل حزمة لاستخدامها مع حزمة أخرى. المتطلبات ستحتاج من أجل بعض الأمثلة في هذا المقال إلى مساحة عمل خاصة مُهيئة وجاهزة، فإذا لم يكن لديك واحدة، فيمكنك اتباع المقالات السابقة حيث تحدثنا عن كيفية إعداد وتثبيت بيئة برمجة محلية في لغة جو. إذ يستخدِم هذا المقال بنية الملفات التالية: . ├── bin │ └── src └── github.com └── gopherguides التصريح عن الدالة ()init بمجرّد التصريح عن أيّ دالة ()init سيُنفّذها مصرِّف جو قبل أيّ شيفرة أخرى في الحزمة، وسنوضِّح في هذا القسم كيفية تعريف هذه الدالة وكيف تؤثر على تشغيل الحزمة، لذا دعونا الآن نلقي نظرةً على مثال لا يتضمن هذه الدالة: package main import "fmt" var weekday string func main() { fmt.Printf("Today is %s", weekday) } صرّحنا في هذا البرنامج عن متغير عام weekday من النوع string، وبما أننا لم نُهيّئ هذا المتغير بأيّ قيمة، فستكون القيمة الافتراضية لها هي السلسلة الفارغة، وبالتالي لن نرى أيّ شيء عند طباعتها، ولنشغّل هذه الشيفرة كما يلي: $ go run main.go سيكون الخرج كما يلي: Today is يمكننا إعطاء قيمة أوليّة إلى المتغير weekday من خلال الدالة ()init بحيث نُهيّئها بقيمة اليوم الحالي، لذا سنعدِّل على الشيفرة السابقة كما يلي: package main import ( "fmt" "time" ) var weekday string func init() { weekday = time.Now().Weekday().String() } func main() { fmt.Printf("Today is %s", weekday) } استخدمنا في هذه الشيفرة الحزمة time للحصول على اليوم الحالي من خلال كتابة ()Now().Weekday().String ثم استخدمنا الدالة ()init لتهيئة المتغير weekday بها، والآن إذا شغّلنا البرنامج، فسنحصل على خرج مُختلف عن المرة السابقة: Today is Monday كان الهدف من هذا المثال هو توضيح عمل الدالة، إلا أن استخدامها الأكثر شيوعًا يكون عند استيراد حزمة، فقد نكون بحاجة إلى إتمام بعض عمليات التهيئة أو بعض العمليات الأولية قبل استخدام الحزمة، لذا دعنا ننشئ برنامجًا سيتطلب تهيئة محددة للحزمة لتعمل على النحو المنشود. تهيئة الحزم عند استيرادها بدايةً، سنكتب برنامجًا بسيطًا يختار عشوائيًا عنصرًا من شريحة ما ويطبعها، ولن نستخدِم هنا الدالة ()init لكي نُبيّن المشكلة التي ستحدث وكيف ستحلّها هذه الدالة فيما بعد، لذا أنشئ مجلدًا اسمه creature من داخل المجلد /src/github.com/gopherguides كما يلي: $ mkdir creature أنشئ بداخله الملف creature.go: $ nano creature/creature.go ضع بداخل الملف التعليمات التالية: package creature import ( "math/rand" ) var creatures = []string{"shark", "jellyfish", "squid", "octopus", "dolphin"} func Random() string { i := rand.Intn(len(creatures)) return creatures[i] } يُعرِّف هذا الملف متغيرًا يسمى creatures يحتوي على مجموعة من أسماء الكائنات البحرية على أساس قيم لهذه الشريحة، كما يحتوي هذا الملف على دالة عشوائية مُصدّرة تُعيد قيمةً عشوائيةً من هذه الشريحة. احفظ الملف واخرج منه. سننشئ الآن الحزمة cmd التي سنستخدِمها لكتابة الدالة ()main واستدعاء الحزمة creature، ولأجل ذلك يجب أن نُنشئ مجلدًا اسمه cmd بجانب المجلد creature كما يلي: $ mkdir cmd سنُنشئ بداخله الملف main.go: $ nano cmd/main.go أضف المحتويات التالية إلى المجلد: package main import ( "fmt" "github.com/gopherguides/creature" ) func main() { fmt.Println(creature.Random()) fmt.Println(creature.Random()) fmt.Println(creature.Random()) fmt.Println(creature.Random()) } استوردنا هنا الحزمة creature، ثم استدعينا الدالة ()creature.Random بداخل الدالة ()main للحصول على عنصر عشوائي من الشريحة وطباعته أربع مرات. احفظ الملف main.go ثم أغلقه. انتهينا الآن من كتابة الشيفرة، لكن قبل أن نتمكن من تشغيل هذا البرنامج، سنحتاج أيضًا إلى إنشاء ملفَين من ملفات الضبط configuration حتى تعمل التعليمات البرمجية بطريقة صحيحة. تُستخدَم وحدات لغة جو Go Modules لضبط اعتماديات الحزمة لاستيراد الموارد، إذ تُعَدّ وحدات لغة جو ملفات ضبط موضوعة في مجلد الحزمة الخاص بك والتي تخبر المُصرِّف بمكان استيراد الحزم منه، ولن نتحدث عن وحدات لغة جو في هذا المقال، لكن سنكتب السطرين التاليين لكي تعمل الشيفرة السابقة، لذا أنشئ الملف go.mod ضمن المجلد cmd كما يلي: $ nano cmd/go.mod ثم ضع بداخله التعليمات التالية: module github.com/gopherguides/cmd replace github.com/gopherguides/creature => ../creature يخبر السطر الأول المصرِّف أنّ الحزمة cmd التي أنشأناها هي في الواقع github.com/gopherguides/cmd؛ أما السطر الثاني، فيُخبر المصرِّف أنه يمكن العثور على المجلد github.com/gopherguides/creature محليًا ضمن المجلد creature/..، لذا احفظ وأغلق الملف ثم أنشئ ملف go.mod في مجلد creature: $ nano creature/go.mod أضف السطر التالي من التعليمات البرمجية إلى الملف: module github.com/gopherguides/creature يخبر هذا المصرِّف أنّ الحزمة creature التي أنشأناها هي في الواقع الحزمة github.com/gopherguides/creature، وبدون ذلك لن تعرف الحزمة cmd مكان استيراد هذه الحزمة. احفظ وأغلق الملف. يجب أن يكون لديك الآن بنية المجلد التالية: ├── cmd │ ├── go.mod │ └── main.go └── creature ├── go.mod └── creature.go الآن بعد أن انتهينا من إنشاء ملفات الضبط اللازمة أصبح بالإمكان تشغيل البرنامج من خلال الأمر التالي: $ go run cmd/main.go سيكون الخرج كما يلي: jellyfish squid squid dolphin حصلنا عند تشغيل هذا البرنامج على أربع قيم وطبعناها، وإذا أعدنا تشغيل هذا البرنامج عدة مرات، فسنلاحظ أننا نحصل دائمًا على الخرج نفسه بدلًا من نتيجة عشوائية كما هو متوقع، وسبب ذلك هو أنّ الحزمة rand تُنشئ أعدادًا شبه عشوائية تولِّد الخرج نفسه باستمرار من أجل حالة أولية initial state واحدة. للحصول على عشوائية أكبر، يمكننا إعادة ضبط مفتاح توليد الأعداد seed في الحزمة، أو ضبط مصدر متغير بحيث تختلف الحالة الأولية في كل مرة نُشغّل فيها البرنامج، وفي لغة جو من الشائع استخدام الوقت الحالي على أساس مفتاح توليد في حزمة rand، وبما أننا نريد من الحزمة creature التعامل مع دالة عشوائية، افتح هذا الملف: $ nano creature/creature.go أضف التعليمات التالية إليه: package creature import ( "math/rand" "time" ) var creatures = []string{"shark", "jellyfish", "squid", "octopus", "dolphin"} func Random() string { rand.Seed(time.Now().UnixNano()) i := rand.Intn(len(creatures)) return creatures[i] } استوردنا في هذا البرنامج الحزمة time واستخدمنا الدالة ()Seed لضبط مفتاح توليد الأعداد seed في الحزمة إلى وقت التنفيذ آنذاك. احفظ وأغلق الملف. الآن بتشغيل البرنامج: $ go run cmd/main.go ستحصل على ما يلي: jellyfish octopus shark jellyfish إذا شغلت البرنامج عدة مرات، فستحصل على نتائج مختلفة في كل مرة، وعمومًا هذا ليس نهجًا مثاليًا لأن الدالة ()creature.Random تُستدعى في كل مرة ولأنه يُعاد ضبط مفتاح توليد الأعداد من خلال الاستدعاء (()rand.Seed(time.Now().UnixNano، وإعادة ضبط مفتاح التوليد re-seeding باستمرار قد يصادف استعمال قيمتين متماثلتين في حالتنا لو استدعي أكثر من مرة بدون تغير الوقت، أي إذا لم تتغير الساعة الداخلية، وهذا سيؤدي إلى تكرار محتمل للنمط العشوائي أو سيزيد من وقت المعالجة في وحدة المعالجة المركزية عن طريق جعل البرنامج ينتظر حتى تتغير الساعة الداخلية، ولحل هذه المشكلة يمكننا استخدام الدالة ()init، لذا سنعدّل الملف creature.go: $ nano creature/creature.go أضف ما يلي إلى الملف: package creature import ( "math/rand" "time" ) var creatures = []string{"shark", "jellyfish", "squid", "octopus", "dolphin"} func init() { rand.Seed(time.Now().UnixNano()) } func Random() string { i := rand.Intn(len(creatures)) return creatures[i] } إضافة الدالة ()init ستخبر المصرِّف أنه يجب استدعاؤها عند استيراد الحزمة creature ولمرة واحدة فقط، وبالتالي يكون لدينا مفتاح توليد seed واحد لتوليد العدد العشوائي، ويجنبنا هذا النهج تكرار تنفيذ التعليمات البرمجية، والآن إذا أعدنا تشغيل الشيفرة عدة مرات، فسنحصل على نتائج مختلفة: $ go run cmd/main.go سيكون الخرج كما يلي: dolphin squid dolphin octopus رأينا في هذا القسم كيف يمكّننا استخدام الدالة ()init من إجراء العمليات الحسابية أو التهيئة المناسبة قبل استخدام الحزمة، وسنرى فيما يلي كيف يمكننا استخدام عدة تعليمات تهيئة ()init في حزمة ما. استخدام عدة تعليمات من ()init يمكن التصريح عن الدالة ()init أكثر من مرة ضمن الحزمة على عكس الدالة ()main التي لا يمكن التصريح عنها إلا مرةً واحدةً، وعمومًا يؤدي وجود أكثر من دالة تهيئة إلى حدوث التباس في أولوية التنفيذ، أي مَن يُنفَّذ أولًا ومَن لا يُنفَذ، لذا سيوضح هذا القسم كيفية استخدام تعليمات تهيئة متعددة وكيفية التحكم بها. ستُنفّذ عادةً دوال init()‎ بالترتيب نفسه الذي تظهر فيه في الشيفرة مثل ما يلي: package main import "fmt" func init() { fmt.Println("First init") } func init() { fmt.Println("Second init") } func init() { fmt.Println("Third init") } func init() { fmt.Println("Fourth init") } func main() {} لنُشغّل البرنامج كما يلي: $ go run main.go سيكون الخرج كما يلي: First init Second init Third init Fourth init لاحظ أنّ تعليمات التهيئة قد نُفِّذت حسب الترتيب الذي وجدها فيه المصرِّف، وعمومًا قد لا يكون من السهل دائمًا تحديد الترتيب الذي ستُنفّذ فيه تعليمات التهيئة، ففي المثال التالي سنعرض حزمةً أكثر تعقيدًا تتضمن عدة ملفات وكل ملف لديه دالة تهيئة خاصة به. لتوضيح الأمر سننشئ برنامجًا يشارك متغيرًا اسمه message ويطبعه، والآن احذف مجلدات creature و cmd ومحتوياتهما واستبدلهما بالمجلدات وبنية الملف التالية: ├── cmd │ ├── a.go │ ├── b.go │ └── main.go └── message └── message.go سنضع الآن محتويات كل ملف في a.go، لذا أضف الأسطر التالية: package main import ( "fmt" "github.com/gopherguides/message" ) func init() { fmt.Println("a ->", message.Message) } يتضمن الملف دالة تهيئة واحدة تطبع قيمة message.Message من الحزمة message، والآن أضف الأسطر التالية في b.go: package main import ( "fmt" "github.com/gopherguides/message" ) func init() { message.Message = "Hello" fmt.Println("b ->", message.Message) } لدينا في هذا الملف دالة تهيئة واحدة تُسند السلسلة Hello إلى المتغير message.Message وتطبعها، والآن أنشئ الآن ملف main.go كما يلي: package main func main() {} هذا الملف لا يفعل شيئًا، ولكنه يوفر نقطة دخول للبرنامج لكي يُنفّذ، والآن أنشئ ملف message.go: package message var Message string تُصرّح حزمة message عن المتغير المُصدّر Message، ولتشغيل البرنامج نفّذ الأمر التالي من مجلد cmd: $ go run *.go نحتاج إلى إخبار المصرِّف بأنه يجب تصريف جميع ملفات go. الموجودة في مجلد cmd نظرًا لوجود العديد من ملفات جو في مجلد cmd الذي يُشكّل الحزمة الرئيسية main، فمن خلال كتابة go.* يمكننا إخبار المُصرّف أنه عليه تحميل كل الملفات التي لاحقتها go. في مجلد cmd، وإذا شغّلنا الأمر go run main.go، فسيفشل البرنامج في التصريف لأنه لن يرى ملفَي a.go و b.go، وسنحصل على الخرج التالي: a -> b -> Hello وفقًا لقواعد لغة جو في تهيئة الحزم، فإنه عند مصادفة عدة ملفات في الحزمة نفسها، ستكون أفضلية المعالجة وفق الترتيب الأبجدي alphabetical، لذا في أول مرة طبعنا فيها message.Message من a.go كانت القيمة المطبوعة هي القيمة الفارغة، وستبقى فارغةً طالما لم تُهيئ من خلال الدالة ()init في الملف b.go، وإذا أردنا تغيير اسم ملف a.go إلى c.go، فسنحصل على نتيجة مختلفة: b -> Hello a -> Hello الآن أصبح المصّرِف يرى الملف b.go أولًا، وبالتالي أصبحت قيمة message.Message مُهيّئة بالقيمة Hello وذلك بعد تنفيذ الدالة ()init في ملف c.go. لاحظ أنّ هذا السلوك قد يخلق أخطاءً و مشاكل لاحقة لأنه من الشائع تغيير أسماء الملفات أثناء تطوير أيّ مشروع، وهذا يؤثر على ترتيب تنفيذ دالة التهيئة كما أوضحنا. يفضَّل تقديم الملفات المتعددة الموجودة ضمن الحزمة نفسها ضمن ملف واحد بترتيب مُعجمي lexical order إلى المصرِّف للتحكم بنهج التهيئة المتعدد، وبالتالي ضمان أنّ كل دوال التهيئة تُحمّل من أجل التصريح عنها في ملف واحد، وبالتالي منع تغيير ترتيب تنفيذها حتى لو تغيرت الأسماء. يجب أن تحاول أيضًا تجنب إدارة الحالة managing state في الحزمة الخاصة بك باستخدام المتغيرات العامة، أي المتغيرات التي يمكن الوصول إليها من أيّ مكان في الحزمة، ففي البرنامج السابق مثلًا كان المتغير message.Message مُتاحًا في أيّ مكان ضمن الحزمة مع الحفاظ على حالة البرنامج، وبسبب هذه الإمكانية كانت دوال التهيئة قادرةً على الوصول إلى هذا المتغير وتعديل قيمته، وبالتالي أصبح من الصعب التنبؤ بسلوك هذا البرنامج، ولتجنب ذلك يُفضَّل إبقاء المتغيرات ضمن مساحات يمكنك التحكم بها مع إمكانية وصول ضئيلة لها قدر الإمكان حسب حاجة البرنامج. قد يؤدي وجود عدة تعليمات تهيئة ضمن البرنامج إلى ظهور تأثيرات غير مرغوب فيها ويجعل من الصعب قراءة برنامجك أو التنبؤ به، كما يضمن لك تجنب استخدام تعليمات التهيئة المتعددة أو الاحتفاظ بها جميعًا في ملف واحد بعدم تغير سلوك برنامجك عند نقل الملفات أو تغيير الأسماء. استخدام دالة التهيئة لتحقيق مفهوم التأثير الجانبي غالبًا ما تستورَد بعض الحزم في لغة جو بغية الاستفادة من تأثيرها الجانبي فقط وليس من أجل أيّ مكوِّن آخر من مكوناتها، وغالبًا ما يكون ذلك عندما تتضمن هذه الحزمة دالة تهيئة بداخلها، وبالتالي تُنفّذ قبل أيّ شيفرة أخرى بمجرد استيراد الحزمة، وبالتالي إمكانية التلاعب بالحالة التي يبدأ بها البرنامج، وتسمى هذه العملية باستيراد التأثير الجانبي. إحدى حالات الاستخدام الشائعة لمفهوم استيراد التأثير الجانبي هي الحصول على معلومات أولية تُفيد في تحديد أي جزء من شيفرة ما يجب أن يُنفّذ، ففي حزمة image مثلًا تحتاج الدالة image.Decode إلى معرفة تنسيق الصورة التي تحاول فك تشفيرها (jpg ،png ،gif، …إلخ) قبل أن تتمكن من تنفيذها، وهكذا أمر يمكن تحقيقه من خلال مفهوم التأثير الجانبي، فلنقل أنك تحاول استخدام image.Decode مع ملف بتنسيق png كما يلي: . . . func decode(reader io.Reader) image.Rectangle { m, _, err := image.Decode(reader) if err != nil { log.Fatal(err) } return m.Bounds() } . . . هذا البرنامج سليم من ناحية التصريف، لكن إذا حاولت فك تشفير صورة بتنسيق png، فستتلقى خطأً، ولحل هذه المشكلة سنحتاج إلى تسجيل تنسيق الصورة في الدالة image.Decode، ولحسن الحظ تتضمن الحزمة image/png تعليمة التهيئة التالية: func init() { image.RegisterFormat("png", pngHeader, Decode, DecodeConfig) } وبالتالي إذا استوردنا image/png إلى الشيفرة السابقة، فستُنفَّذ دالة التهيئة ()image.RegisterFormat مباشرةً وقبل أيّ شيء آخر. . . . import _ "image/png" . . . func decode(reader io.Reader) image.Rectangle { m, _, err := image.Decode(reader) if err != nil { log.Fatal(err) } return m.Bounds() } سيؤدي هذا إلى ضبط حالة البرنامج وتسجيل أننا نحتاج إلى إصدار png من image.Decode()‎، وسيحدث هذا التسجيل بوصفه أثرًا جانبيًا لاستيراد الحزمة image/png. ربما لاحظت وجود الشرطة السفلية _ قبل "image/png"، فهذا أمر مهم لأنّ جو لا تسمح لك باستيراد حزمة غير مستخدَمة في البرنامج، وبالتالي فإنّ وجود الشرطة السفلية سيتجاهل أيّ شيء في الحزمة المستورَدة باستثناء تعليمة التهيئة -أي التأثير الجانبي-، وهذا يعني أنه حتى إذا لم نستورِد الحزمة image/png، فيمكننا استيراد تأثيرها الجانبي. من المهم أن تعرف متى تحتاج إلى استيراد حزمة للحصول على تأثيرها الجانبي، فبدون التسجيل الصحيح، من المحتمل أن تتمكن جو من تصريف برنامجك ولكن لن يعمل كما تتوقع، وعادةً ما تتضمن مراجع (أو توثيقات documentation) المكتبة القياسية إشارةً إلى الحاجة إلى هكذا نوع من التأثيرات مع حالات معينة، وبالتالي إذا كتبت حزمةً تتطلب استيرادًا للتأثيرات الجانبية، فيجب عليك أيضًا التأكد من توثيق دالة التهيئة التي تستخدِمها حتى يتمكن المستخدِمون الذين يستورِدون الحزمة الخاصة بك من استخدامها بطريقة صحيحة. الخاتمة تعلّمنا في هذا المقال استخدام دالة التهيئة ()init وفهمنا كيف أنها تُنفَّذ قبل أيّ شيء آخر في البرنامج وأنها تستطيع أداء بعض المهام وضبط حالة البرنامج الأولية، كما تحدّثنا أيضًا عن تعليمات التهيئة المتعددة والموجودة ضمن ملفات متعددة وكيف أنّ ترتيب تنفيذها يعتمد على على أمور محددة مثل الترتيب الأبجدي أو المعجمي لملفات الحزمة. ترجمة -وبتصرُّف- للمقال Understanding init in Go لصاحبه Gopher Guides. اقرأ أيضًا المقال السابق: التعليمة defer في لغة جو Go كيفية تعريف واستدعاء الدوال في لغة جو Go
  24. تتضمن لغة جو العديد من تعليمات التحكم بسير عمل البرنامج مثل if و switch و for …إلخ، وهذه التعليمات موجودة في أغلب لغات البرمجة، إلا أنّ هناك تعليمة خاصة في لغة جو غير موجودة في معظم اللغات الأخرى هي تعليمة التأجيل defer. على الرغم من أن هذه التعليمة ليست مشهورة إلا أنها مفيدة، فالهدف الأساسي من هذه التعليمة هو إجراء عملية تنظيف الموارد بطريقة سليمة بعد انتهاء استخدامها مثل الملفات المفتوحة أو الاتصالات بالشبكة أو مقابض قاعدة البيانات handles بعد تأجيلها ريث الانتهاء منها، إذ تُعَدّ عملية تنظيف أو تحرير الموارد بعد استخدامها أمرًا مهمًا جدًا للسماح للمستخدِمين الآخرين باستخدام هذا المورد دون وجود أي بقايا تركها المستخدِم السابق سواءً كان المستخدِم آلةً أو شخصًا أو برنامجًا أو جزءًا آخر من الشيفرة نفسها. تساعدنا تعليمة التأجيل defer في جعل الشيفرة أنظف وأكثر متانةً وأقل عرضةً للأخطاء من خلال جعل تعليمة استدعاء أو حجز الملف أو المورد قريبة من تعليمة تحريره، وفي هذا المقال سنتعلم كيفية استخدام تعليمة التأجيل defer بطريقة صحيحة لتنظيف الموارد بالإضافة إلى العديد من الأخطاء الشائعة التي تحدث عند استخدام هذه التعليمة. ما هي تعليمة defer؟ تسمح لك جو بتأجيل تنفيذ استدعاء دالة بإضافتها إلى طابور تنفيذ خاص ريثما تكون الدالة المُستدعاة ضمنها قد أكتمل تنفيذها من خلال استخدام الكلمة المفتاحية defer قبلها بالشكل التالي: package main import "fmt" func main() { defer fmt.Println("Bye") fmt.Println("Hi") } أجّلنا هنا استخدام الدالة ("fmt.Println("Bye إلى حين انتهاء تنفيذ الدالة المُستدعاة ضمنها أي الدالة main، وبالتالي سيكون الخرج كما يلي: Hi Bye أي سيُنفّذ هنا كل شيء داخل الدالة main ثم بعد الانتهاء من كل التعليمات (هنا لا توجد إلا تعليمة واحدة هي التعليمة التي تطبع Hi) ستُنفَّذ التعليمة المؤجلة، كما رأيت بالمثال. والآن قد يتبادر إلى الأذهان السؤال التالي: ماذا لو كان هناك أكثر من استدعاء مؤجل، أي أكثر من دالة مؤجلة، ماذا سيحدث؟ كل دالة تُسبق بتعليمة التأجيل تُضاف إلى مكدس، ومن المعروف أنّ المكدس هو بنية معطيات تخرج منها البيانات وفق مبدأ مَن يدخل آخرًا يخرج أولًا، وبالتالي إذا كان لدينا أكثر من استدعاء مؤجل، فسيكون التنفيذ وفق هذه القاعدة، ولجعل الأمور أبسط سنأخذ المثال التالي: package main import "fmt" func main() { defer fmt.Println("Bye1") defer fmt.Println("Bye2") fmt.Println("Hi") } سيكون الخرج كما يلي: Hi Bye2 Bye1 لدينا في البرنامج السابق استدعاءان مؤجلان، إذ يُضاف أولًا إلى المكدس التعليمة التي تطبع Bye1 ثم التعليمة التي تطبع Bye2، ووفق القاعدة السابقة ستُطبع التعليمة التي أٌضيفت أخيرًا إلى المكدس أي Bye2 ثم Bye1، وبالطبع تُنفَّذ التعليمة التي تطبع Hi وأيّ تعليمة أخرى ضمن الدالة main قبل تنفيذ أيّ تعليمة مؤجلة، ولدينا مثال آخر كما يلي: package main import "fmt" func main() { fmt.Println("Hi0") defer fmt.Println("Bye1") defer fmt.Println("Bye2") fmt.Println("Hi1") fmt.Println("Hi2") } سيكون الخرج كما يلي: Hi0 Hi1 Hi2 Bye2 Bye1 عند تأجيل تنفيذ دالة ما ستقيَّم الوسائط على الفور، وبالتالي لن يؤثر أيّ تعديل لاحق، لذا انتبه جيدًا إلى المثال التالي: package main import "fmt" func main() { x := 9 defer fmt.Println(x) x = 10 } سيكون الخرج كما يلي: 9 كانت هنا قيمة x تساوي 9 ثم أجلنا تنفيذ دالة الطباعة التي تطبع قيمة هذا المتغير، وعلى الرغم من أنّ تعليمة x=10 ستُنفَّذ قبل تعليمة الطباعة، إلا أنّ القيمة التي طُبعَت هي القيمة السابقة للمتغير x وذلك للسبب الذي ذكرناه منذ قليل. ما تعلمناه حتى الآن كان بغرض التوضيح فقط، إذ لا تُستخدم بهذا الشكل بل عادة ما تُستخدم تعليمة التأجيل في تنظيف الموارد وهذا ما سنراه تاليًا. تنظيف الموارد باستخدام تعليمة التأجيل يُعَدّ استخدام تعليمة التأجيل لتنظيف الموارد أمرًا شائعًا جدًا في جو، وسنلقي الآن نظرةً على برنامج يكتب سلسلةً نصيةً في ملف ولكنه لا يستخدِم تعليمة التأجيل لتنظيف المورد: package main import ( "io" "log" "os" ) func main() { if err := write("readme.txt", "This is a readme file"); err != nil { log.Fatal("failed to write file:", err) } } func write(fileName string, text string) error { file, err := os.Create(fileName) if err != nil { return err } _, err = io.WriteString(file, text) if err != nil { return err } file.Close() return nil } لدينا في هذا البرنامج دالة تسمى write والهدف منها بدايةً هو محاولة إنشاء ملف، وإذا حصل خطأ أثناء هذه المحاولة، فستُعيد هذا الخطأ وتُنهي التنفيذ؛ أما في حال نجاح عملية إنشاء الملف، فستكتب فيه السلسلة This is a readme file، وفي حال فشلت عملية الكتابة، فستُعيد الخطأ وتنهي تنفيذ الدالة أيضًا، وأخيرًا إذا نجح كل شيء، فستغلق الدالة الملف الذي أُنشِئ معيدة إياه إلى نظام الملفات ثم تُعيد القيمة nil إشارةً إلى نجاح التنفيذ. هذا البرنامج يعمل بطريقة سليمة، إلا أنّ هناك زلة برمجية صغيرة؛ فإذا فشلت عملية الكتابة في الملف، فسيبقى الملف المُنشئ مفتوحًا، أي توجد هناك موارد غير محررة، ولحل هذه المشكلة مبدأيًا يمكنك تعليمة file.Close()‎ أخرى ومن دون استخدام تعليمة التأجيل: package main import ( "io" "log" "os" ) func main() { if err := write("readme.txt", "This is a readme file"); err != nil { log.Fatal("failed to write file:", err) } } func write(fileName string, text string) error { file, err := os.Create(fileName) if err != nil { return err } _, err = io.WriteString(file, text) if err != nil { file.Close() return err } file.Close() return nil } الآن سيُغلق الملف حتى إذا فشل تنفيذ io.WriteString، وقد يكون هذا حلًا بسيطًا وأسهل من غيره، لكن إذا كانت الدوال أكثر تعقيدًا، فقد ننسى إضافة هكذا تعليمات في الأماكن المُحتملة التي قد ينتهي فيها التنفيذ قبل تحرير المورد، لذا يتمثّل الحال الأكثر احترافيةً وأمانًا باستخدام تعليمة التأجيل مع الدالة file.Close وبالتالي ضمان تنفيذها دومًا بغض النظر عن أيّ مسار تنفيذ أو خطأ مُفاجئ قد يؤدي إلى إنهاء الدالة: package main import ( "io" "log" "os" ) func main() { if err := write("readme.txt", "This is a readme file"); err != nil { log.Fatal("failed to write file:", err) } } func write(fileName string, text string) error { file, err := os.Create(fileName) if err != nil { return err } defer file.Close() _, err = io.WriteString(file, text) if err != nil { return err } return nil } أضفنا هنا السطر defer file.Close لإخبار المُصرّف بإغلاق الملف بعد انتهاء تنفيذ الدالة، وبالتالي ضمان تحريره مهما حدث. انتهينا هنا من زلة برمجية ولكن ستظهر لنا زلة برمجية أخرى؛ ففي حال حدث خطأ في عملية إغلاق الملف file.Close، فلن تكون هناك أيّ معلومات حول ذلك الخطأ، أي لن يُعاد الخطأ، وبالتالي يمنعنا استخدام تعليمة التأجيل من الحصول على الخطأ أو الحصول على أيّ قيمة تُعيدها الدالة المؤجلة. يُعَدّ استدعاء الدالة Close في جو أكثر من مرة آمنًا ولا يؤثر على سلوك البرنامج، وفي حال كان هناك خطأ، فسيُعاد من المرة الأولى التي تُستدعى فيها الدالة، وبالتالي سيسمح لنا هذا باستدعائها ضمن مسار التنفيذ الناجح في الدالة. سنعالج في المثال التالي المشكلة السابقة التي تتجلى بعدم إمكانية الحصول على معلومات الخطأ في حال حدوثه مع تعليمة الإغلاق المؤجلة: package main import ( "io" "log" "os" ) func main() { if err := write("readme.txt", "This is a readme file"); err != nil { log.Fatal("failed to write file:", err) } } func write(fileName string, text string) error { file, err := os.Create(fileName) if err != nil { return err } defer file.Close() _, err = io.WriteString(file, text) if err != nil { return err } return file.Close() } لاحظ أنّ التعديل الوحيد على الشيفرة السابقة كان بإضافة السطر return file.Close()‎، وبالتالي إذا حصل خطأ في عملية الإغلاق، فسيُعاد مباشرةً إلى الدالة التي تستدعي الدالة write، ولاحظ أيضًا أنّ التعليمة defer file.Close ستُنفّذ بكافة الأحوال، مما يعني أنّ تعليمة الإغلاق ستُنفّذ مرتين غالبًا، وعلى الرغم من أنّ ذلك ليس مثاليًّا، إلا أنه مقبول وآمن. إذا ظهر خطأ في تعليمة WriteString، فسيُعاد الخطأ وينتهي تنفيذ الدالة ثم ستُنفّذ تعليمة file.Close لأنها تعليمة مؤجلة، وهنا على الرغم من إمكانية ظهور خطأ عند محاولة إغلاق الملف، إلا أنّ هذا الخطأ لم يَعُد مهمًا لأن الخطأ الذي تعيده WriteString سيكون غالبًا هو السبب وراء حدوثه. تعرّفنا حتى الآن على كيفية استخدام تعليمة التأجيل واحدة لضمان تحرير أو تنظيف مورد ما، وسنتعلّم الآن كيفية استخدام عدة تعليمات تأجيل من أجل تنظيف أكثر من مورد. استخدام تعليمات تأجيل متعددة من الطبيعي أن تكون لديك أكثر من تعليمة defer في دالة ما، لذا دعنا ننشئ برنامجًا يحتوي على تعليمات تأجيل فقط لنرى ما سيحدث في هذه الحالة (كما أشرنا سابقًا): package main import "fmt" func main() { defer fmt.Println("one") defer fmt.Println("two") defer fmt.Println("three") } سيكون الخرج كما يلي: three two one كما ذكرنا سابقًا أنّ سبب الطباعة بهذا الترتيب هو أن تعليمة التأجيل تعتمد على تخزين سلسلة الاستدعاءات ضمن بنية المكدس، والمهم الآن أن تعرف أنه يمكن أن يكون لديك العديد من الاستدعاءات المؤجلة حسب الحاجة في دالة ما، ومن المهم أن تتذكر أنها تُستدعى جميعها بعكس ترتيبها في الشيفرة حسب بنية المكدس. الآن بعد أن فهمنا هذه الفكرة جيدًا، سننشئ برنامجًا يفتح ملفًا ويكتب عليه ثم يفتحه مرةً أخرى لنسخ المحتويات إلى ملف آخر: package main import ( "fmt" "io" "log" "os" ) func main() { if err := write("sample.txt", "This file contains some sample text."); err != nil { log.Fatal("failed to create file") } if err := fileCopy("sample.txt", "sample-copy.txt"); err != nil { log.Fatal("failed to copy file: %s") } } func write(fileName string, text string) error { file, err := os.Create(fileName) if err != nil { return err } defer file.Close() _, err = io.WriteString(file, text) if err != nil { return err } return file.Close() } func fileCopy(source string, destination string) error { src, err := os.Open(source) if err != nil { return err } defer src.Close() dst, err := os.Create(destination) if err != nil { return err } defer dst.Close() n, err := io.Copy(dst, src) if err != nil { return err } fmt.Printf("Copied %d bytes from %s to %s\n", n, source, destination) if err := src.Close(); err != nil { return err } return dst.Close() } لدينا هنا دالة جديدة fileCopy تفتح أولًا ملف المصدر الذي سننسخ منه ثم تتحقق من حدوث خطأ أثناء فتح الملف، فإذا كان الأمر كذلك، فسنعيد الخطأ ونخرج من الدالة، وإلا فإننا نؤجل إغلاق الملف المصدر الذي فتحناه. نُنشئ بعد ذلك ملف الوجهة ونتحقق من حدوث خطأ أثناء إنشاء الملف، فإذا كان الأمر كذلك، فسنعيد هذا الخطأ ونخرج من الدالة، وإلا فسنؤجل أيضًا إغلاق ملف الوجهة، وبالتالي لدينا الآن دالتان مؤجلتان ستُستدعيان عندما تخرج الدالة من نطاقها. الآن بعد فتح كل من ملف المصدر والوجهة فإننا سننسخ ()Copy البيانات من الملف المصدر إلى الملف الوجهة، فإذا نجح ذلك، فسنحاول إغلاق كلا الملفين، وإذا تلقينا خطأً أثناء محاولة إغلاق أيّ ملف، فسنُعيد الخطأ ونخرج من الدالة. لاحظ أننا نستدعي ()Close لكل ملف صراحةً على الرغم من أنّ تعليمة التأجيل ستستدعيها أيضًا، والسبب وراء ذلك هو كما ذكرناه في الفقرة السابقة؛ وهو للتأكد من أنه إذا كان هناك خطأ في إغلاق ملف، فإننا نُعيد معلومات هذا الخطأ، كما يضمن أنه إذا أُنهيَت الدالة مُبكرًا لأيّ سبب من الأسباب بسبب خطأ ما مثل الفشل في عملية النسخ بين الملفين، فسيحاول كل ملف الإغلاق بطريقة سليمة من خلال تنفيذ الاستدعاءات المؤجلة. الخاتمة تحدثنا في هذا المقال عن تعليمة التأجيل defer وكيفية استخدامها والتعامل معها وأهميتها في تنظيف وتحرير الموارد بعد الانتهاء من استخدامها، وبالتالي ضمان توفير الموارد للمستخدِمين المختلفِين وتوفير الذاكرة. يمكنك أيضًا الرجوع إلى مقال معالجة حالات الانهيار في لغة جو Go: يتضمن حالةً خاصةً من حالات استخدام تعليمة التأجيل. ترجمة -وبتصرُّف- للمقال Understanding defer in Go لصاحبه Gopher Guides. اقرأ أيضا المقال السابق: كيفية تعريف واستدعاء الدوال في لغة جو Go كيفية كتابة التعليمات الشرطية if في لغة جو Go فهم الملفات Files وأنظمة الملفات file systems
  25. تُعَدّ الدالة funtion كتلةً من التعليمات التي تنفِّذ إجراءً ما، ويمكن بعد تعريفها إعادة استخدامها في أكثر من موضع. تجعل الدوال الشيفرة تركيبية modular، مما يسمح بتقسيم الشيفرة إلى أجزاء صغيرة سهلة الفهم واستخدامها مرارًا وتكرارًا. تضم لغة جو مكتبةً قياسيةً تدعى fmt، إذ تحتوي على عدد من الدوال المُضمّنة التي قد تكون شائعة بالنسبة لك مثل: الدالة ()fmt.Println تُستخدَم للطباعة على شاشة الخرج القياسية المُستخدَمة. الدالة ()fmt.Printf تُستخدَم للطباعة مع إمكانية تنسيق الخرج. تتضمن أسماء الدوال الأقواس وقد تتضمن معامِلات أيضًا، وسنتعلم في هذا المقال كيفية تعريف الدوال وكيفية استخدامها في البرامج. تعريف الدالة لنبدأ بتحويل برنامج "Hello, World!‎" إلى دالة، لذا أنشئ ملفًا نصيًا جديدًا وافتحه في محرر النصوص المفضل عندك ثم استدع البرنامج ‎hello.go. تُعرَّف الدالة باستخدام الكلمة المفتاحية func متبوعة باسم من اختيارك ثم قوسين يمكن أن يَحتويا المعامِلات التي ستأخذها الدالة ثم ينتهي التعريف بنقطتين، وسنعرّف هنا دالةً باسم ‎‎hello()‎‎: func hello() {} أعددنا في الشفرة أعلاه تعليمة التهيئة لإنشاء الدالة، وبعد ذلك سنضيف سطرًا ثانيًا مُزاحًا بأربع مسافات بيضاء ثم سنكتب التعليمات التي ستنفّذها الدالة، وفي هذه الحالة سنطبع العبارة !Hello, World في الطرفية: func hello() { fmt.Println("Hello, World!") } أتممنا تعريف دالتنا ولكن إذا نَفَّذنا البرنامج الآن، فلن يحدث أيّ شيء لأننا لم نستدع الدالة، لذلك سنستدعي الدالة ‎hello()‎ ضمن الدالة ()main كما يلي: package main import "fmt" func main() { hello() } func hello() { fmt.Println("Hello, World!") } لننفّذ البرنامج الآن كما يلي: $ go run hello.go يجب أن تحصل على الخرج التالي: Hello, World! الدالة ()main هي دالة خاصة تخبر المُصرّف أنّ هذا هو المكان الذي يجب أن يبدأ منه البرنامج، فأيّ برنامج تريده أن يكون قابلاً للتنفيذ (برنامج يمكن تشغيله من الطرفية)، فستحتاج إلى دالة ()main، كما يجب أن تظهر الدالة ()main مرةً واحدةً فقط وأن تكون في الحزمة main ولا تستقبل أو تعيد أيّ وسائط، وهذا يسمح بتنفيذ البرنامج في أيّ برنامج جو آخر حسب المثال التالي: package main import "fmt" func main() { fmt.Println("this is the main section of the program") } بعض الدوال أكثر تعقيدًا بكثير من الدالة ‎hello()‎ التي عرّفناها أعلاه، إذ يمكننا على سبيل المثال استخدام حلقة for والتعليمات الشرطية وغيرها داخل كتلة الدالة، فالدالة المُعرّفة أدناه على سبيل المثال تستخدِم تعليمةً شرطيةً للتحقق مما إذا كانت المدخلات الممرّرة إلى المتغير ‎name‎ تحتوي على حرف صوتي vowel، ثم تستخدِم الحلقة ‎for‎ للتكرار على الحروف الموجودة في السلسلة النصية ‎name‎. package main import ( "fmt" "strings" ) func main() { names() } func names() { fmt.Println("Enter your name:") var name string fmt.Scanln(&name) // Check whether name has a vowel for _, v := range strings.ToLower(name) { if v == 'a' || v == 'e' || v == 'i' || v == 'o' || v == 'u' { fmt.Println("Your name contains a vowel.") return } } fmt.Println("Your name does not contain a vowel.") } تستخدِم الدالة ‎names()‎ التي عرّفناها أعلاه تعليمةً شرطيةً وحلقة ‎for‎، وهذا توضيح لكيفية تنظيم الشيفرة البرمجية ضمن تعريف الدالة، كما يمكننا أيضًا جعل التعليمة الشرطية والحلقة ‎for‎ دالتين منفصلتين. يجعل تعريف الدوال داخل البرامج الشفرة البرمجية تركيبيةً modular وقابلةً لإعادة الاستخدام، وذلك سيتيح لنا استدعاء الدالة نفسها دون إعادة كتابة شيفرتها في كل مرة. المعاملات عرّفنا حتى الآن دالة ذات قوسين فارغين لا تأخذ أيّ وسائط arguments، وسنتعلم في هذا القسم كيفية تعريف المعامِلات parameters وتمرير البيانات إلى الدوال. يُعَدّ المعامِل كيانًا مُسمًّى يوضَع في تعريف الدالة ويعرِّف وسيطًا يمكن أن تقبله الدالة عند استدعائها، ويجب عليك في لغة Go أن تحدد نوع البيانات data type لكل معامِل لننشئ برنامجًا يُكرر كلمة عدة مرات، إذ سنحتاج إلى متغير من النوع string سنسميه word ومتغير من النوع int سنسميه reps يُحدد عدد التكرارات. package main import "fmt" func main() { repeat("Sammy", 5) } func repeat(word string, reps int) { for i := 0; i < reps; i++ { fmt.Print(word) } } مرّرنا السلسلة Sammy إلى المتغير word والقيمة 5 إلى المعامِل reps وذلك وفقًا لترتيب المعامِلات في ترويسة الدالة repeat، إذ تتضمّن الدالة حلقة for تطبع قيمة المعامِل word عدة مرات يُحدّدها المعامِل reps، وسيكون الخرج كما يلي: SammySammySammySammySammy إذا كانت لديك مجموعة من المعامِلات وجميعها تمتلك القيمة نفسها، فيمكنك تجاهل تحديد النوع من أجل كل متغير كما سنرى، لذا دعنا ننشئ برنامجًا صغيرًا يأخذ ثلاثة معامِلات ‎x‎ و ‎y‎ و ‎z‎ من النوع int، إذ سننشئ دالةً تجمع تلك المعامِلات وفق عدة مجموعات ثم تطبع الدالة حاصل جمعها. package main import "fmt" func main() { addNumbers(1, 2, 3) } func addNumbers(x, y, z int) { a := x + y b := x + z c := y + z fmt.Println(a, b, c) } عند تعريف الدالة addNumbers لم نكن بحاجة إلى تحديد نوع كل متغير على حدة، وإنما وضعنا نوع بيانات كل المتغيرات مرةً واحدةً فقط. مرّرنا العدد ‎1‎ إلى المعامل ‎x‎ والعدد ‎2‎ إلى المعامل ‎y‎ والعدد ‎3‎ إلى المعامل ‎z‎، إذ تتوافق هذه القيم مع المعامِلات المقابلة لها في ترتيب الظهور، ويُجري البرنامج العمليات الحسابية على المعامِلات على النحو التالي: a = 1 + 2 b = 1 + 3 c = 2 + 3 تطبع الدالة أيضًا ‎a‎ و ‎b‎ و ‎c‎، وبناءً على العمليات الحسابية أعلاه، فستساوي قيمة ‎a‎ العدد ‎3‎ و ‎b‎ العدد ‎4‎ و ‎c‎ العدد ‎5‎، ولننفّذ البرنامج سنكتب ما يلي: $ go run add_numbers.go سيكون الخرج كما يلي: 3 4 5 عندما نمرر 1 و 2 و 3 على أساس معامِلات إلى الدالة ()addNumbers، فإننا نتلقى الناتج المتوقع. تُعَدّ المعامِلات وسائط تُعرَّف عادة على أساس متغيرات ضمن تعريف الدالة، كما يمكن تعيين قيم إليها عند تنفيذ التابع بتمرير وسائط إلى الدالة. إعادة قيمة يمكن تمرير قيم إلى الدالة ويمكن كذلك أن تُنتج الدالة قيمةً وتُعيدها لمن استدعاها، إذ يمكن أن تنتج الدالة قيمةً عبر استخدام التعليمة ‎return‎ وهي تعليمة اختيارية، ولكن في حال استخدامها ستُنهي الدالة عملها مباشرةً وتوقف تنفيذها وتُمرَّر قيمة التعبير الذي يعقُبها اختياريًا إلى المستدعي، كما يجب تحديد نوع البيانات المُعادة أيضًا. استخدمنا حتى الآن الدالة ()fmt.Println‎ بدلاً من التعليمة ‎return‎ في دوالنا لطباعة شيء بدلًا من إعادته، فلننشئ برنامجًا يعيد متغيرًا بدلًا من طباعته مباشرةً، لذا سننشئ برنامجًا في ملف نصي جديد يسمى double.go‎ يحسب ناتج مُضاعفة المعامِل ‎x‎ ويُسند الناتج إلى المتغير ‎y‎ ثم يعيده، كما سنطبع المتغير ‎result‎ والذي يساوي ناتج تنفيذ الدالة double(3)‎. package main import "fmt" func main() { result := double(3) fmt.Println(result) } func double(x int) int { y := x * 2 return y } لننفّذ البرنامج كما يلي: $ go run double.go سيكون الخرج كما يلي: 6 خرج البرنامج هو العدد الصحيح ‎6 الذي أعادته الدالة وهو ما نتوقعه إذا طلبنا من جو حساب ناتج ضرب 2 بالعدد 3. إذا حددنا نوع القيمة المُعادة فيجب علينا إعادة قيمة من هذا النوع وإلا فسيُعطي البرنامج خطأً في التصريف، ففي المثال التالي سنلغي تعليمة الإعادة المُستخدَمة في الشيفرة السابقة بوضع تعليق على تعليمة الإعادة لكي يتجاهلها المُصرّف كما يلي: package main import "fmt" func main() { result := double(3) fmt.Println(result) } func double(x int) int { y := x * 2 // return y } لنحاول تنفيذ البرنامج: $ go run double.go سيُشير الخرج إلى خطأ لأنه لم يجد تعليمة الإعادة return: ./double.go:13:1: missing return at end of function لا يمكن تصريف البرنامج بدون تعليمة الإعادة هذه، فعندما تصل الدالة إلى تعليمة return فإنها ستُنهي تنفيذ الدالة حتى إذا كان هناك تعليمات تالية ضمنها: package main import "fmt" func main() { loopFive() } func loopFive() { for i := 0; i < 25; i++ { fmt.Print(i) if i == 5 { //i == 5 أوقف الدالة عندما return } } fmt.Println("This line will not execute.") // هذا السطر لن ينفَّذ } نستخدِم هنا حلقة for تُؤدي عملية تكرار 25 مرة وبداخلها تعليمة if تتحقق مما إذا كانت قيمة i تساوي العدد 5، فإذا كانت كذلك، فسيكون لدينا تعليمة return تُنهي تنفيذ الحلقة وتُنهي تنفيذ الدالة أيضًا، وهذا يعني أنّ باقي التكرارات لن تُنفّذ والسطر الأخير من الدالة This line will not execute لن يُنفّذ. يؤدي استخدام التعليمة ‎return‎ داخل الحلقة ‎for‎ إلى إنهاء الدالة، وبالتالي لن يُنفَّذ السطر الموجود خارج الحلقة، فإذا استخدمنا بدلًا من ذلك التعليمة break، فسيُنفّذ السطر fmt.Println()‎ الأخير من المثال السابق. نعيد التذكير أنَّ التعليمة ‎return‎ تنهي عمل الدالة وقد تعيد قيمةً إذا أعقبها تعبير وكان ذلك محدد في تعريف الدالة. إعادة عدة قيم يمكن للدوال أن تُعيد أكثر من قيمة، إذ سنجعل برنامج repeat.go يُعيد قيمتين بحيث تكون القيمة الأولى القيمة المُكررة والثانية خطأً في حال كانت قيمة المعامِل reps أصغر أو تساوي 0. package main import "fmt" func main() { val, err := repeat("Sammy", -1) if err != nil { fmt.Println(err) return } fmt.Println(val) } func repeat(word string, reps int) (string, error) { if reps <= 0 { return "", fmt.Errorf("invalid value of %d provided for reps. value must be greater than 0.", reps) } var value string for i := 0; i < reps; i++ { value = value + word } return value, nil } تتحقق الدالة repeat بدايةً من أن الوسيط reps صالح، فأيّ قيمة أقل أو تساوي الصفر ستؤدي إلى خطأ. بما أننا مررنا القيمة 1- إلى reps فهذا سيؤدي إلى تحقق شرط حدوث خطأ، وبالتالي ستُعاد قيمتين الأولى هي سلسلة فارغة "" والثانية هي رسالة خطأ، وبالطبع يجب علينا إعادة قيمتين دومًا تبعًا للتعريف الذي وضعناه في ترويسة الدالة، إذ حددنا أنّ هناك قيمتان مُعادتان من الدالة؛ فالأولى هي سلسلة والثانية هي خطأ، ولهذا السبب أعدنا سلسلةً فارغةً في حال ظهور خطأ. نستدعي الدالة repeat بداخل الدالة main ونُسند القيم المُعادة إلى متغيرين هما value و err، وبما أنّ هناك احتمال لحدوث خطأ، فسنتحقق في الأسطر التالية من وجوده، وفي حال وجوده سنطبع رسالةً تشير إلى الخطأ وسنستخدِم التعليمة return للخروج من الدالة main والبرنامج، وبتنفيذ البرنامج سنحصل على ما يلي: invalid value of -1 provided for reps. value must be greater than 0. ملاحظة: من السلوكيات الجيدة في البرمجة إعادة قيمتان أو ثلاثة، بالإضافة إلى إعادة كل الأخطاء كآخر قيمة معادة من الدالة. تعلّمنا في هذا القسم كيف نجعل تعليمة return تُعيد أكثر من قيمة. الدوال المرنة Variadic الدالة المرنة Variadic هي دالة لا تقبل أي قيمة أو تقبل قيمةً واحدةً أو قيمتين أو أكثر على أساس وسيط واحد، والدوال المرنة ليست شائعة الاستخدام لكنها تجعل الشيفرة أنظف وأكثر قابليةً للقراءة، وأحد الأمثلة على هذا النوع من الدوال هو الدالة Println من الحزمة fmt: func Println(a ...interface{}) (n int, err error) نسمي الدالة التي تتضمن مُعامِلًا مُلحقًا بثلاثة نقاط ... كما في الشيفرة أعلاه بالدالة المرنة، إذ تشير النقاط الثلاث إلى أنّ هذا المعامِل يمكن أن يكون صفر قيمة أو قيمةً واحدةً أو قيمتين أو عدة قيم، وبالتالي تُعَدّ الدالة fmt.Println دالةً مرنةً لأنها تتضمن مُعامِلًا مرنًا يسمى a. سنستخدِم في المثال التالي الدالة المرنة السابقة وسنبيّن كيف أنه من الممكن أن نمرر لها عددًا غير مُحدد من الوسائط: package main import "fmt" func main() { fmt.Println() fmt.Println("one") fmt.Println("one", "two") fmt.Println("one", "two", "three") } كما تُلاحظ فإننا نستدعيها أول مرة بدون تمرير أيّ وسيط ثم نستدعيها مع تمرير وسيط واحد هو السلسلة one ثم نستدعيها مع وسيطين ثم نستدعيها مع ثلاثة وسائط، ولننفذ البرنامج الآن: $ go run print.go سيكون الخرج كما يلي: one one two one two three لاحظ أنّ السطر الأول من الخرج فارغ لأننا استدعينا تعليمة الطباعة بدون تمرير أيّ متغير ثم السطر الثاني يحتوي على one لأن دالة الطباعة التي استدعيناها في المرة الثانية تتضمنها، ثم one two لأننا مررناهما إلى دالة الطباعة في المرة الثالثة، وأخيرًا السطر الأخير one two three لأننا مررنا هذه الكلمات إلى دالة الطباعة في الشيفرة السابقة. سنتحدّث الآن عن كيفية تعريف هذه الدوال بعد أن اطلعنا على كيفية استخدام الدوال المرنة. تعريف الدوال المرنة كما ذكرنا سابقًا فإن الدوال المرنة تُعرّف من خلال وضع ثلاث نقاط ... بعد اسم أحد المعامِلات فيها، وفي المثال التالي سنعرّف دالةً نمرر لها أسماءً لكي تُحَييهم: package main import "fmt" func main() { sayHello() sayHello("Sammy") sayHello("Sammy", "Jessica", "Drew", "Jamie") } func sayHello(names ...string) { for _, n := range names { fmt.Printf("Hello %s\n", n) } } تتضمّن الدالة sayHello معامِلًا اسمه names من النوع string، وكما نلاحظ فإنه توجد ثلاث نقاط بعد اسمه، وبالتالي هو معامل مرن أي يقبل عدد غير مُحدد من الوسائط، وبالتالي تُعَدّ الدالة sayHello هي دالةً مرنةً. تُعامِل هذه الدالة المعامِل names على أنه شريحة من الأسماء، أي أنها تعامله على أساس شريحة من السلاسل النصية string[]، وبالتالي يمكننا التكرار عليه بحلقة for من خلال استخدام العامِل range. ستحصل عند تنفيذ هذه الشيفرة على ما يلي: Hello Sammy Hello Sammy Hello Jessica Hello Drew Hello Jamie لاحظ أنه في المرة الأولى التي استدعينا فيها الدالة sayHello لم يُطبع أيّ شيء، وذلك لأننا لم نمرر لها أيّ قيمة، أي عمليًّا هي شريحة فارغة، وبالتالي لا تتضمن أي قيمة ليُكرر عليها، والآن سنُعدّل الشيفرة السابقة بحيث تطبع عبارةً مُحددةً عندما لا تُمرّر أيّ قيمة: package main import "fmt" func main() { sayHello() sayHello("Sammy") sayHello("Sammy", "Jessica", "Drew", "Jamie") } func sayHello(names ...string) { if len(names) == 0 { fmt.Println("nobody to greet") return } for _, n := range names { fmt.Printf("Hello %s\n", n) } } أضفنا تعليمة if تتحقق مما إذا كانت الشريحة فارغةً وهذا يُكافئ أن يكون طولها 0، وفي هذه الحالة سنطبع nobody to greet: nobody to greet Hello Sammy Hello Sammy Hello Jessica Hello Drew Hello Jamie يجعل استخدام الدوال والمتغيرات المرنة شيفرتك أكثر قابليةً للقراءة، وسنُنشئ الآن دالةً مرنةً تربط الكلمات اعتمادًا على رمز مُحدّد، لكن سنكتب أولًا دالةً ليست مرنة لنُبيّن الفرق: package main import "fmt" func main() { var line string line = join(",", []string{"Sammy", "Jessica", "Drew", "Jamie"}) fmt.Println(line) line = join(",", []string{"Sammy", "Jessica"}) fmt.Println(line) line = join(",", []string{"Sammy"}) fmt.Println(line) } func join(del string, values []string) string { var line string for i, v := range values { line = line + v if i != len(values)-1 { line = line + del } } return line } مرّرنا هنا الفاصلة , إلى الدالة join لكي يُنجز الربط اعتمادًا عليها، ثم مرّرنا شريحةً من الكلمات لكي تُربط، فكان الخرج كما يلي: Sammy,Jessica,Drew,Jamie Sammy,Jessica Sammy لاحظ هنا أنّ تعريف شريحة في كل مرة نستدعي فيها هذه الدالة قد يكون مُملًا وأكثر صعوبةً في القراءة، لذا سنُعرّف الآن الشيفرة نفسها لكن مع دالة مرنة: package main import "fmt" func main() { var line string line = join(",", "Sammy", "Jessica", "Drew", "Jamie") fmt.Println(line) line = join(",", "Sammy", "Jessica") fmt.Println(line) line = join(",", "Sammy") fmt.Println(line) } func join(del string, values ...string) string { var line string for i, v := range values { line = line + v if i != len(values)-1 { line = line + del } } return line } سنحصل عند تشغيل البرنامج على خرج المثال السابق نفسه: Sammy,Jessica,Drew,Jamie Sammy,Jessica Sammy يمكن بسهولة ملاحظة أنّ استخدام مفهوم الدالة المرنة قد جعل من الدالة join أكثر قابليةً للقراءة. ترتيب الوسائط المرنة يمكنك تعريف معامِل مرن واحدة فقط في الدالة ويجب أن يكون هو المعامِل الأخير في ترويسة الدالة، فإذا عرّفتَ أكثر من معامِل مرن أو وضعته قبل المعامِلات العادية، فسيظهر لك خطأ وقت التصريف compilation error. package main import "fmt" func main() { var line string line = join(",", "Sammy", "Jessica", "Drew", "Jamie") fmt.Println(line) line = join(",", "Sammy", "Jessica") fmt.Println(line) line = join(",", "Sammy") fmt.Println(line) } func join(values ...string, del string) string { var line string for i, v := range values { line = line + v if i != len(values)-1 { line = line + del } } return line } وضعنا هنا المعامِل المرن values أولًا ثم وضعنا المعامِل العادي del، وبالتالي خالفنا الشرط المذكور سلفًا، وبالتالي سنحصل على الخطأ التالي: ./join_error.go:18:11: syntax error: cannot use ... with non-final parameter values عند تعريف دالة مرنة لا يمكن أن يكون المعامِل الأخير إلا معاملًا مرنًا. تفكيك الوسائط رأينا كيف أنّ المعامل المرن سيسمح لنا بتمرير 0 قيمة أو قيمة واحدة أو أكثر من قيمة إلى الدالة، لكن هناك حالات سيكون لدينا فيها شريحة من القيم نريد تمريرها إلى الدالة المرنة، لذا دعونا نرى الدالة join التي بنيناها مؤخرًا لرؤية ما يحدث: package main import "fmt" func main() { var line string names := []string{"Sammy", "Jessica", "Drew", "Jamie"} line = join(",", names) fmt.Println(line) } func join(del string, values ...string) string { var line string for i, v := range values { line = line + v if i != len(values)-1 { line = line + del } } return line } سنحصل عند تشغيل البرنامج على خطأ وقت التصريف: ./join-error.go:10:14: cannot use names (type []string) as type string in argument to join على الرغم من أن الدالة المرنة ستحوّل المعامِل values ...string إلى شريحة من السلاسل النصية string[]، إلا أنّ هذا لا يعني أنه بإمكاننا تمرير شريحة من السلاسل على أساس وسيط، فالمُصرّف يتوقع وسائط منفصلةً من نوع سلسلة نصية. يمكننا لحل هذه المشكلة تفكيك قيم الشريحة -أي فصلها عمليًّا- من خلال وضع ثلاثة نقط بعد اسم الشريحة عندما نُمررها لدالة مرنة. package main import "fmt" func main() { var line string names := []string{"Sammy", "Jessica", "Drew", "Jamie"} line = join(",", names...) fmt.Println(line) } func join(del string, values ...string) string { var line string for i, v := range values { line = line + v if i != len(values)-1 { line = line + del } } return line } لاحظ أننا وضعنا 3 نقاط بعد اسم الشريحة names عندما مررناها إلى الدالة join، وهذا يؤدي إلى تفكيك عناصر الشريحة، وبالتالي كأنها قيم منفصلة، وسيكون الخرج كما يلي: Sammy,Jessica,Drew,Jamie لاحظ أنه مازال بإمكاننا عدم تمرير أيّ شيء أو تمرير أيّ عدد نريده من القيم، ويبيّن المثال التالي كل الحالات: package main import "fmt" func main() { var line string line = join(",", []string{"Sammy", "Jessica", "Drew", "Jamie"}...) fmt.Println(line) line = join(",", "Sammy", "Jessica", "Drew", "Jamie") fmt.Println(line) line = join(",", "Sammy", "Jessica") fmt.Println(line) line = join(",", "Sammy") fmt.Println(line) } func join(del string, values ...string) string { var line string for i, v := range values { line = line + v if i != len(values)-1 { line = line + del } } return line } سيكون الخرج كما يلي: Sammy,Jessica,Drew,Jamie Sammy,Jessica,Drew,Jamie Sammy,Jessica Sammy أصبحنا الآن نعرف كيف نمرِّر 0 قيمة أو أكثر إلى دالة مرنة، كما أصبحنا نعرف كيف يمكننا تمرير شريحة إلى دالة مرنة. غالبًا ما تكون الدوال المرنة مفيدةً في الحالات التالية: عندما تكون بحاجة إلى تعريف شريحة مؤقتًا من أجل تمريرها إلى دالة. لجعل الشيفرة أكثر قابليةً للقراءة. عندما يكون عدد الوسائط غير معروف أو متغير مثل دالة الطباعة. الخاتمة تُعَدّ الدوال كتلًا من التعليمات البرمجية التي تُنفِّذ إجراءات معيّنة داخل البرنامج، كما تساعد على جعل الشفرة تركيبيةً وقابلةً لإعادة الاستخدام، بالإضافة إلى أنها تنظمها وتسهل من قراءتها، كما تجعل الدوال المرنة الشيفرة أكثر قابليةً للقراءة، إلا أنها ليست شائعة الاستخدام، ولتتعلم كيف تجعل برنامجك تركيبيًّا أكثر، فيمكنك قراءة مقال كيفية تعريف الحزم في لغة جو Go. ترجمة -وبتصرف- للمقالَين How To Define and Call Functions in Go وللمقال How To Use Variadic Functions in Go لصاحبه Gopher Guides. اقرأ أيضًا المقال السابق: التعامل مع حلقة التكرار For في لغة جو Go استخدام المتغيرات والثوابت في لغة جو Go
×
×
  • أضف...